@@ -60,6 +60,80 @@ def _looks_like_data_row(text: str) -> bool:
6060 return numeric_tokens >= max (2 , len (tokens ) // 2 )
6161
6262
63+ def _rect_area (bbox : tuple [float , float , float , float ]) -> float :
64+ return max (0.0 , bbox [2 ] - bbox [0 ]) * max (0.0 , bbox [3 ] - bbox [1 ])
65+
66+
67+ def _intersection_area (
68+ a : tuple [float , float , float , float ],
69+ b : tuple [float , float , float , float ],
70+ ) -> float :
71+ x0 = max (a [0 ], b [0 ])
72+ y0 = max (a [1 ], b [1 ])
73+ x1 = min (a [2 ], b [2 ])
74+ y1 = min (a [3 ], b [3 ])
75+ return _rect_area ((x0 , y0 , x1 , y1 ))
76+
77+
78+ def _rects_intersect (
79+ a : tuple [float , float , float , float ],
80+ b : tuple [float , float , float , float ],
81+ ) -> bool :
82+ return _intersection_area (a , b ) > 0
83+
84+
85+ def _classify_visual_quality (
86+ * ,
87+ kind : str ,
88+ page_coverage_ratio : float ,
89+ visual_rect_count : int ,
90+ visual_body_ratio : float ,
91+ paragraph_text_chars : int ,
92+ table_body_rows : int ,
93+ caption_text_chars : int ,
94+ ) -> dict :
95+ """Classify whether a caption-matched crop is visually usable.
96+
97+ This is intentionally conservative. A label/caption match proves identity,
98+ but not that the rendered crop contains the figure or table body.
99+ """
100+ normalized_kind = kind .strip ().lower ()
101+ reasons : list [str ] = []
102+
103+ if normalized_kind == "table" :
104+ if table_body_rows <= 0 :
105+ reasons .append ("table_body_missing" )
106+ if table_body_rows <= 1 and visual_body_ratio < 0.03 and caption_text_chars >= 40 :
107+ reasons .append ("caption_only_suspected" )
108+ status = "reject" if reasons else "usable"
109+ else :
110+ if paragraph_text_chars >= 450 :
111+ reasons .append ("large_text_block_suspected" )
112+ if page_coverage_ratio >= 0.70 and paragraph_text_chars >= 250 :
113+ reasons .append ("oversized_page_crop" )
114+ if visual_rect_count <= 1 and visual_body_ratio < 0.03 :
115+ reasons .append ("low_visual_body_ratio" )
116+ if any (code in reasons for code in ("large_text_block_suspected" , "oversized_page_crop" , "low_visual_body_ratio" )):
117+ status = "reject"
118+ elif visual_rect_count == 0 or visual_body_ratio < 0.08 :
119+ if "low_visual_body_ratio" not in reasons :
120+ reasons .append ("low_visual_body_ratio" )
121+ status = "review"
122+ else :
123+ status = "usable"
124+
125+ return {
126+ "visual_quality_status" : status ,
127+ "quality_reason_codes" : reasons ,
128+ "page_coverage_ratio" : round (page_coverage_ratio , 6 ),
129+ "visual_rect_count" : int (visual_rect_count ),
130+ "visual_body_ratio" : round (visual_body_ratio , 6 ),
131+ "paragraph_text_chars" : int (paragraph_text_chars ),
132+ "table_body_rows" : int (table_body_rows ),
133+ "caption_text_chars" : int (caption_text_chars ),
134+ }
135+
136+
63137def _classify_caption_kind (label : str ) -> str :
64138 """Return 'table' if the caption label starts with 'Table', else 'figure'."""
65139 return "table" if label .strip ().lower ().startswith ("table" ) else "figure"
@@ -246,6 +320,23 @@ def _collect_drawing_rects(page) -> list[tuple[float, float, float, float]]:
246320 return rects
247321
248322
323+ def _visual_signal_for_bbox (page , bbox : tuple [float , float , float , float ]) -> tuple [int , float ]:
324+ """Return visual rect count and visual-area ratio inside a crop."""
325+ crop_area = _rect_area (bbox )
326+ if crop_area <= 0 :
327+ return 0 , 0.0
328+ rects = _collect_xref_rects (page ) + _collect_drawing_rects (page )
329+ count = 0
330+ visual_area = 0.0
331+ for rect in rects :
332+ area = _intersection_area (rect , bbox )
333+ if area <= 0 :
334+ continue
335+ count += 1
336+ visual_area += area
337+ return count , min (1.0 , visual_area / crop_area )
338+
339+
249340def _find_body_text_blocks (page ) -> list [tuple [float , float , float , float , str ]]:
250341 """Return bounding boxes of body-text blocks (non-caption) sorted top-to-bottom."""
251342 results : list [tuple [float , float , float , float , str ]] = []
@@ -308,6 +399,67 @@ def _find_paragraph_blocks(page, *, min_chars: int = 200) -> list[tuple[float, f
308399 return results
309400
310401
402+ def _count_paragraph_text_chars_in_bbox (
403+ page ,
404+ bbox : tuple [float , float , float , float ],
405+ caption_bbox : tuple [float , float , float , float ],
406+ ) -> int :
407+ """Count prose-like text intersecting a crop, excluding the caption area."""
408+ chars = 0
409+ blocks = page .get_text ("dict" , flags = fitz .TEXT_PRESERVE_WHITESPACE )["blocks" ]
410+ for block in blocks :
411+ if block .get ("type" ) != 0 :
412+ continue
413+ bb = tuple (block ["bbox" ])
414+ if not _rects_intersect (bb , bbox ):
415+ continue
416+ if _intersection_area (bb , caption_bbox ) / max (_rect_area (bb ), 1.0 ) > 0.6 :
417+ continue
418+
419+ lines = block .get ("lines" , [])
420+ line_texts = [
421+ "" .join (s .get ("text" , "" ) for s in line .get ("spans" , [])).strip ()
422+ for line in lines
423+ ]
424+ line_texts = [text for text in line_texts if text ]
425+ full_text = normalize_whitespace (" " .join (line_texts ))
426+ if len (full_text ) < 80 :
427+ continue
428+ if CAPTION_RE .match (full_text ):
429+ continue
430+ numeric_rows = sum (1 for text in line_texts if _looks_like_data_row (text ))
431+ if line_texts and numeric_rows > len (line_texts ) * 0.4 :
432+ continue
433+ chars += len (full_text )
434+ return chars
435+
436+
437+ def _quality_signals_for_crop (
438+ page ,
439+ kind : str ,
440+ bbox : tuple [float , float , float , float ],
441+ caption_anchor : dict ,
442+ page_rect ,
443+ * ,
444+ table_body_rows : int ,
445+ ) -> dict :
446+ page_area = _rect_area ((page_rect .x0 , page_rect .y0 , page_rect .x1 , page_rect .y1 ))
447+ page_coverage_ratio = _rect_area (bbox ) / page_area if page_area > 0 else 0.0
448+ visual_rect_count , visual_body_ratio = _visual_signal_for_bbox (page , bbox )
449+ caption_bbox = tuple (caption_anchor ["bbox" ])
450+ paragraph_text_chars = _count_paragraph_text_chars_in_bbox (page , bbox , caption_bbox )
451+ caption_text_chars = len (normalize_whitespace (str (caption_anchor .get ("line_text" , "" ))))
452+ return _classify_visual_quality (
453+ kind = kind ,
454+ page_coverage_ratio = page_coverage_ratio ,
455+ visual_rect_count = visual_rect_count ,
456+ visual_body_ratio = visual_body_ratio ,
457+ paragraph_text_chars = paragraph_text_chars ,
458+ table_body_rows = table_body_rows ,
459+ caption_text_chars = caption_text_chars ,
460+ )
461+
462+
311463def _clip_to_page (
312464 bbox : tuple [float , float , float , float ],
313465 page_rect ,
@@ -594,6 +746,17 @@ def _estimate_table_bbox(
594746 next_anchor : dict | None ,
595747 page_rect ,
596748) -> tuple [float , float , float , float ] | None :
749+ result = _estimate_table_bbox_with_rows (page , caption_anchor , prev_anchor , next_anchor , page_rect )
750+ return result [0 ] if result is not None else None
751+
752+
753+ def _estimate_table_bbox_with_rows (
754+ page ,
755+ caption_anchor : dict ,
756+ prev_anchor : dict | None ,
757+ next_anchor : dict | None ,
758+ page_rect ,
759+ ) -> tuple [tuple [float , float , float , float ], int ] | None :
597760 r"""Estimate the bounding box of a table.
598761
599762 Tables in academic papers come in two layouts:
@@ -651,12 +814,18 @@ def _estimate_table_bbox(
651814 return None
652815
653816 chosen : list [tuple [float , float , float , float ]]
817+ chosen_data_rows : int
654818 if up_data > down_data :
655819 chosen = up_lines
820+ chosen_data_rows = up_data
656821 else :
657822 chosen = down_lines
823+ chosen_data_rows = down_data
658824
659- return _finalize_table_bbox (page , caption_anchor , chosen , page_rect )
825+ bbox = _finalize_table_bbox (page , caption_anchor , chosen , page_rect )
826+ if bbox is None :
827+ return None
828+ return bbox , chosen_data_rows
660829
661830
662831def _render_crop (page , bbox : tuple [float , float , float , float ], dpi : int ) -> bytes :
@@ -688,8 +857,13 @@ def extract_figure_regions(
688857 kind = anchor .get ("kind" , "figure" )
689858
690859 bbox : tuple [float , float , float , float ] | None
860+ table_body_rows = 0
691861 if kind == "table" :
692- bbox = _estimate_table_bbox (page , anchor , prev_anchor , next_anchor , page_rect )
862+ table_result = _estimate_table_bbox_with_rows (page , anchor , prev_anchor , next_anchor , page_rect )
863+ if table_result is not None :
864+ bbox , table_body_rows = table_result
865+ else :
866+ bbox = None
693867 if bbox is None :
694868 # Fall back to the figure-shape estimator in case the table is
695869 # actually rendered as an embedded image.
@@ -716,6 +890,14 @@ def extract_figure_regions(
716890
717891 width_px = int ((bbox [2 ] - bbox [0 ]) * dpi / 72.0 )
718892 height_px = int ((bbox [3 ] - bbox [1 ]) * dpi / 72.0 )
893+ quality_signals = _quality_signals_for_crop (
894+ page ,
895+ kind ,
896+ bbox ,
897+ anchor ,
898+ page_rect ,
899+ table_body_rows = table_body_rows ,
900+ )
719901
720902 assets .append (
721903 {
@@ -731,6 +913,7 @@ def extract_figure_regions(
731913 "bbox_pt" : list (bbox ),
732914 "size_bytes" : len (png_bytes ),
733915 "extraction_level" : "figure" ,
916+ "quality_signals" : quality_signals ,
734917 }
735918 )
736919
0 commit comments