@@ -220,74 +220,105 @@ def _scan_fields_with_tesseract(pdf_path: str, dpi: int = 200) -> list[dict]:
220220 lt = " " .join (w ["text" ] for w in lw )
221221 print (f" line { idx :02d} : '{ lt [:90 ]} '" )
222222
223- # ── Identical field detection logic as PyMuPDF path ─────────────
223+ # ── Enhanced field detection: multi-colon + numbered patterns ───────
224+ # Handles forms like ICS-214 where:
225+ # - Multiple "Label: [blank] Label: [blank]" appear on one line
226+ # - Numbered fields "1. Incident Name" have no colon but ARE fillable
224227 for line_words in lines :
225228 line_words = sorted (line_words , key = lambda w : w ["x0" ])
226- full_text = " " .join (w ["text" ] for w in line_words )
229+ full_text = " " .join (w ["text" ] for w in line_words )
227230
228231 has_colon = ":" in full_text
229232 has_underscores = "_" * 4 in full_text or full_text .count ("_" ) >= 4
230233
231- if not (has_colon or has_underscores ):
232- continue
233-
234- # Skip header lines with content after colon
235- if has_colon and not has_underscores :
236- colon_pos = full_text .find (":" )
237- after_colon = full_text [colon_pos + 1 :].strip ()
238- if after_colon and not after_colon .startswith ("_" ):
239- label_part = full_text [:colon_pos ].strip ().lower ()
240- HEADER_WORDS = {"title" , "form" , "section" , "page" , "date" , "version" , "revision" }
241- if any (hw in label_part for hw in HEADER_WORDS ):
242- continue
243-
244- label_text = full_text .strip ()
245- answer_x : float | None = None
246234 answer_y = min (w ["y0" ] for w in line_words )
247235 answer_bottom = max (w ["y1" ] for w in line_words )
248236
237+ # ── Strategy A: UNDERSCORE blanks ───────────────────────────
249238 if has_underscores :
250239 for w in line_words :
251240 if "_" * 4 in w ["text" ]:
252- answer_x = w ["x0" ]
253- answer_bottom = w ["y1" ]
241+ answer_x = w ["x0" ]
254242 label_parts = [lw ["text" ] for lw in line_words if lw ["x0" ] < w ["x0" ]]
255- if label_parts :
256- label_text = " " .join (label_parts )
257- break
258-
259- if answer_x is None and has_colon :
260- for w in line_words :
261- if ":" in w ["text" ]:
262- answer_x = w ["x1" ] + 2
263- label_parts = []
264- for lw in line_words :
265- label_parts .append (lw ["text" ])
266- if ":" in lw ["text" ]:
267- break
268- label_text = " " .join (label_parts )
269- break
270-
271- if answer_x is None :
272- answer_x = line_words [- 1 ]["x1" ] + 2
273-
274- clean_label = re .sub (r'^[\d\.\)\s]+' , '' , label_text )
275- clean_label = clean_label .rstrip (': _\n ' ).strip ()
276-
277- if not clean_label :
278- continue
243+ label_text = " " .join (label_parts ) if label_parts else full_text
244+ clean_label = re .sub (r'^[\d\.\)\s]+' , '' , label_text ).rstrip (': _\n ' ).strip ()
245+ if clean_label :
246+ found_fields .append ({
247+ "raw_label" : clean_label , "answer_x" : round (answer_x , 2 ),
248+ "answer_y" : round (answer_y , 2 ), "answer_bottom" : round (w ["y1" ], 2 ),
249+ "page_num" : page_num , "page_w" : round (pw_pt , 2 ), "page_h" : round (ph_pt , 2 ),
250+ "line_text" : full_text .strip (),
251+ })
252+ print (f" [FIELD] '{ clean_label :25s} ' answer_x={ answer_x :.1f} pt y0={ answer_y :.1f} pt" )
253+ continue # underscore lines done
254+
255+ # ── Strategy B: ALL colons in one line → one field each ──────
256+ if has_colon :
257+ HEADER_WORDS = {"title" , "form" , "section" , "page" , "version" , "revision" , "ics" , "noaa" }
258+ # Find every word that contains ":" and treat each as a field label endpoint
259+ colon_words = [i for i , w in enumerate (line_words ) if ":" in w ["text" ]]
260+ for ci , colon_idx in enumerate (colon_words ):
261+ # Label = words from previous colon_word+1 up to this colon_word (inclusive)
262+ prev_end = colon_words [ci - 1 ] + 1 if ci > 0 else 0
263+ label_parts = [line_words [j ]["text" ] for j in range (prev_end , colon_idx + 1 )]
264+ label_text = " " .join (label_parts )
265+ clean_label = re .sub (r'^[\d\.\)\s/]+' , '' , label_text ).rstrip (': _\n ' ).strip ()
266+
267+ if not clean_label or len (clean_label ) < 2 :
268+ continue
269+ if any (hw in clean_label .lower () for hw in HEADER_WORDS ):
270+ continue
279271
280- found_fields .append ({
281- "raw_label" : clean_label ,
282- "answer_x" : round (answer_x , 2 ),
283- "answer_y" : round (answer_y , 2 ),
284- "answer_bottom" : round (answer_bottom , 2 ),
285- "page_num" : page_num ,
286- "page_w" : round (pw_pt , 2 ),
287- "page_h" : round (ph_pt , 2 ),
288- "line_text" : full_text .strip (),
289- })
290- print (f" [FIELD] '{ clean_label :25s} ' answer_x={ answer_x :.1f} pt y0={ answer_y :.1f} pt" )
272+ # answer_x = right edge of the colon word + small padding
273+ answer_x = line_words [colon_idx ]["x1" ] + 2
274+
275+ # answer width = distance to next colon word (or page edge)
276+ if ci + 1 < len (colon_words ):
277+ next_label_start = line_words [colon_words [ci + 1 ] - 1 ]["x0" ] if colon_words [ci + 1 ] > 0 else pw_pt
278+ field_w = max (next_label_start - answer_x - 4 , 20 )
279+ else :
280+ field_w = pw_pt * 0.9 - answer_x
281+
282+ found_fields .append ({
283+ "raw_label" : clean_label , "answer_x" : round (answer_x , 2 ),
284+ "answer_y" : round (answer_y , 2 ), "answer_bottom" : round (answer_bottom , 2 ),
285+ "page_num" : page_num , "page_w" : round (pw_pt , 2 ), "page_h" : round (ph_pt , 2 ),
286+ "line_text" : full_text .strip (),
287+ })
288+ print (f" [FIELD] '{ clean_label :25s} ' answer_x={ answer_x :.1f} pt y0={ answer_y :.1f} pt" )
289+ continue # colon lines done — all colons processed
290+
291+ # ── Strategy C: Numbered fields "1. Incident Name" (no colon) ──
292+ # ICS-214 has headers like "1. Incident Name 2. Operational Period"
293+ # Find all numbered tokens, split the line at each, create one field per segment
294+ numbered_positions = [(m .start (), m .group ()) for m in re .finditer (r'(?<!\d)\d+\.(?!\d)' , full_text )]
295+ if len (numbered_positions ) >= 1 :
296+ # Split full_text into segments between numbered markers
297+ segments = []
298+ for si , (start , _ ) in enumerate (numbered_positions ):
299+ end = numbered_positions [si + 1 ][0 ] if si + 1 < len (numbered_positions ) else len (full_text )
300+ segments .append (full_text [start :end ].strip ())
301+
302+ for seg in segments :
303+ # The answer area is to the RIGHT of this label segment's last word in the line
304+ seg_clean = re .sub (r'^\d+\.\s*' , '' , seg ).strip ().rstrip (': _' ).strip ()
305+ if not seg_clean or len (seg_clean ) < 2 :
306+ continue
307+ # Find x position of the last word in this segment
308+ seg_words = [w for w in line_words if w ["text" ] in seg .split ()]
309+ if seg_words :
310+ last_seg_word = max (seg_words , key = lambda w : w ["x1" ])
311+ answer_x = last_seg_word ["x1" ] + 4
312+ else :
313+ answer_x = line_words [- 1 ]["x1" ] + 4
314+
315+ found_fields .append ({
316+ "raw_label" : seg_clean , "answer_x" : round (answer_x , 2 ),
317+ "answer_y" : round (answer_y , 2 ), "answer_bottom" : round (answer_bottom , 2 ),
318+ "page_num" : page_num , "page_w" : round (pw_pt , 2 ), "page_h" : round (ph_pt , 2 ),
319+ "line_text" : full_text .strip (),
320+ })
321+ print (f" [FIELD] '{ seg_clean :25s} ' answer_x={ answer_x :.1f} pt y0={ answer_y :.1f} pt" )
291322
292323 doc .close ()
293324 print (f"[TESSERACT] Done — { len (found_fields )} field(s) found" )
@@ -467,9 +498,14 @@ async def scan_static_template(template_id: int, db: Session = Depends(get_db)):
467498 })
468499 print (f"[SCAN] ✅ { scan_mode } → { len (found_fields )} fields with exact coords" )
469500
470- # ── PATH 3: Gemma Vision — last resort only ───────────────────────────
471- if not found_fields :
472- print ("[SCAN] PATH 3: Gemma Vision (last resort — both deterministic paths failed)..." )
501+ # ── PATH 3: Gemma Vision — true last resort only ──────────────────────
502+ # Only fire if BOTH deterministic paths found absolutely nothing.
503+ # Gemma Vision is slow (~5-10 min) and coordinates can be imprecise
504+ # (off-page for complex table forms). Use only when Tesseract gets 0 fields.
505+ MIN_FIELDS_THRESHOLD = 0
506+ if len (found_fields ) <= MIN_FIELDS_THRESHOLD :
507+ reason = "0 fields found" if not found_fields else f"only { len (found_fields )} fields found (table form needs Gemma vision)"
508+ print (f"[SCAN] PATH 3: Gemma Vision ({ reason } )..." )
473509 scan_mode = "vision"
474510 llm = LLM ()
475511 try :
0 commit comments