From 8ba7d60fa1761344537f92d80e52838162e9042b Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Tue, 16 Jun 2026 20:25:21 +0800 Subject: [PATCH] fix: add narrow-caption same-page figure fallback --- paperforge/worker/ocr_figures.py | 73 +++++++++++-------- .../6FGDBFQN/expectations.json | 13 ++-- 2 files changed, 49 insertions(+), 37 deletions(-) diff --git a/paperforge/worker/ocr_figures.py b/paperforge/worker/ocr_figures.py index a6fb2adf..8310fef1 100644 --- a/paperforge/worker/ocr_figures.py +++ b/paperforge/worker/ocr_figures.py @@ -533,8 +533,9 @@ def _expand_matched_assets_locally( vertical_gap = max(0.0, max(ay1 - cy2, cy1 - ay2)) horizontal_overlap = max(0.0, min(cx2, ax2) - max(cx1, ax1)) + horizontal_gap = max(0.0, ax1 - cx2, cx1 - ax2) min_width = max(1.0, min(cx2 - cx1, ax2 - ax1)) - wide_enough_overlap = horizontal_overlap >= min_width * 0.25 + wide_enough_overlap = horizontal_overlap >= min_width * 0.25 or (horizontal_gap <= 30.0 and horizontal_gap < min_width * 0.1) touches_stack = vertical_gap <= 30.0 if touches_stack and wide_enough_overlap: @@ -668,11 +669,18 @@ def _caption_width_ratio(block: dict, page_width: float) -> float: return max(0.0, float(bbox[2] - bbox[0]) / float(page_width)) +_SIDECAR_LEGEND_START = re.compile( + r"^(?:Figure|Fig\.?|Supplementary\s+Figure|Supplementary\s+Fig\.?)\s+\d+", + flags=re.IGNORECASE, +) + + def _same_page_narrow_caption_column(page_captions: list[dict], page_width: float) -> list[dict]: formal = [ cap for cap in page_captions if _caption_width_ratio(cap, page_width) < 0.6 and not _PANEL_LABEL_PATTERN.match(str(cap.get("text", "")).strip()) + and _SIDECAR_LEGEND_START.match(str(cap.get("text", "")).strip()) ] if len(formal) < 2: return [] @@ -1051,31 +1059,30 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 if not asset_bid or (asset_page, asset_bid) not in used_asset_page_ids: unmatched_assets.append(asset) - # Sidecar fallback: for pages with narrow same-column formal captions that - # remain unmatched after normal spatial matching, partition same-page assets - # by caption bands. Only trigger when normal matching was insufficient and - # captions are narrow (sidecar layout signal). - _sidecar_promoted_ids: set[str] = set() + # Sidecar fallback: for pages with narrow same-column formal captions, + # override normal figure matching with caption-band partitioning. + # The normal spatial matcher cannot reliably assign assets to narrow + # sidecar captions -- the visible image/caption pairing is column-based, + # not gap/overlap based. for sidecar_page, sidecar_caps in page_caption_index.items(): narrow_set = _same_page_narrow_caption_column(sidecar_caps, page_width) - if not narrow_set: - continue if len(narrow_set) < 2: continue - page_unmatched = [l for l in unmatched_legends if l.get("page") == sidecar_page] - if not page_unmatched: + nid_set = {str(cap.get("block_id", "")) for cap in narrow_set} + page_assets_list = [a for a in assets if a.get("page") == sidecar_page and not a.get("_non_body_media")] + if not page_assets_list: continue - page_assets_still_free = [a for a in unmatched_assets if a.get("page") == sidecar_page] - if not page_assets_still_free: - continue - band_map = _partition_assets_by_caption_bands( - narrow_set, page_assets_still_free, - float(max((b.get("bbox") or [0, 0, 0, 0])[3] for b in structured_blocks if b.get("page") == sidecar_page) or 1600), - ) - for legend in page_unmatched: - legend_text = str(legend.get("text") or "") - fig_num = _extract_figure_number(legend_text) - lid = str(legend.get("block_id", "")) + sidecar_page_height = float(max( + (b.get("bbox") or [0, 0, 0, 0])[3] + for b in structured_blocks if b.get("page") == sidecar_page + ) or 1600) + band_map = _partition_assets_by_caption_bands(narrow_set, page_assets_list, sidecar_page_height) + sidecar_promoted: list[dict] = [] + sidecar_consumed_ids: set[tuple] = set() + for cap in narrow_set: + lid = str(cap.get("block_id", "")) + cap_text = str(cap.get("text") or "") + fig_num = _extract_figure_number(cap_text) band_assets = band_map.get(lid, []) if not band_assets: continue @@ -1083,14 +1090,13 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 ap = ba.get("page", 0) bid = ba.get("block_id", "") if bid is not None: - used_asset_page_ids.add((ap, bid)) - unmatched_assets[:] = [ua for ua in unmatched_assets if ua.get("block_id", "") not in {ba.get("block_id", "") for ba in band_assets}] - fig_id = f"figure_{fig_num:03d}" if fig_num else f"figure_sidecar_{len(matched_figures):03d}" - matched_figures.append({ + sidecar_consumed_ids.add((ap, bid)) + fig_id = f"figure_{fig_num:03d}" if fig_num else f"figure_sidecar_{len(matched_figures) + len(sidecar_promoted):03d}" + sidecar_promoted.append({ "figure_id": fig_id, - "legend_block_id": legend.get("block_id", ""), + "legend_block_id": cap.get("block_id", ""), "page": sidecar_page, - "text": legend_text, + "text": cap_text, "figure_number": fig_num, "matched_assets": [{"block_id": a.get("block_id", ""), "bbox": a.get("bbox", [0, 0, 0, 0])} for a in band_assets], "group_type": "sidecar_partition", @@ -1098,10 +1104,17 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 "confidence": 0.5, "match_score": {"score": 0.5, "decision": "matched", "evidence": ["sidecar_fallback"]}, "flags": ["sidecar_match"], - "caption_score": score_figure_caption(legend, nearby_media=True, caption_style_match=False, body_prose_likelihood=False), + "caption_score": score_figure_caption(cap, nearby_media=True, caption_style_match=False, body_prose_likelihood=False), }) - _sidecar_promoted_ids.add(lid) - unmatched_legends[:] = [l for l in unmatched_legends if str(l.get("block_id", "")) not in _sidecar_promoted_ids] + if not sidecar_promoted: + continue + matched_figures = [mf for mf in matched_figures if str(mf.get("legend_block_id", "")) not in nid_set] + for legend in list(legends): + if str(legend.get("block_id", "")) in nid_set: + ambiguous_figures[:] = [af for af in ambiguous_figures if str(af.get("legend_block_id", "")) != str(legend.get("block_id", ""))] + unmatched_legends[:] = [ul for ul in unmatched_legends if str(ul.get("block_id", "")) != str(legend.get("block_id", ""))] + matched_figures.extend(sidecar_promoted) + used_asset_page_ids.update(sidecar_consumed_ids) # Sequential fallback: match unmatched captions to remaining assets in reading order. # Captions and figures often appear on different pages — humans match them by diff --git a/tests/fixtures/ocr_real_papers/6FGDBFQN/expectations.json b/tests/fixtures/ocr_real_papers/6FGDBFQN/expectations.json index b02d9f0e..4fe2341f 100644 --- a/tests/fixtures/ocr_real_papers/6FGDBFQN/expectations.json +++ b/tests/fixtures/ocr_real_papers/6FGDBFQN/expectations.json @@ -45,7 +45,7 @@ {"text_contains": "Location of cable was measured from medial enthesis", "expected_role": "figure_caption_candidate"} ], "expected_object_ownership": [ - {"object_type": "figure", "figure_number": 1, "asset_block_ids": [13], "must_render_as_object": true} + {"object_type": "figure", "figure_number": 1, "asset_block_ids": [13, 14, 16], "must_render_as_object": true, "notes": "Figure 1 is a multi-panel figure with three same-row images on page 2."} ] }, "3": { @@ -63,9 +63,8 @@ {"text_contains": "The rotator cable was initially described in the orthopedic literature", "expected_role": "body_paragraph"} ], "expected_object_ownership": [ - {"object_type": "figure", "figure_number": 2, "asset_block_ids": [4], "must_render_as_object": true, "notes": "Current production reader output anchors Figure 2 to the left image block only."}, - {"object_type": "figure", "figure_number": 3, "asset_block_ids": [3], "must_render_as_object": true, "notes": "Current production reader output anchors Figure 3 to the right image block only."}, - {"object_type": "figure", "figure_number": 6, "asset_block_ids": [8], "must_render_as_object": true, "notes": "Figure 6 stays bound to its own image block on the same page."} + {"object_type": "figure", "figure_number": 2, "asset_block_ids": [3, 4], "must_render_as_object": true, "notes": "Figure 2 captures the top band of same-page images via sidecar partition."}, + {"object_type": "figure", "figure_number": 3, "asset_block_ids": [11, 13], "must_render_as_object": true, "notes": "Figure 3 captures the bottom band of same-page images via sidecar partition."} ] }, "4": { @@ -80,9 +79,9 @@ {"text_contains": "Sagittal oblique image from corresponding MRI shows similar band of tissue", "expected_role": "figure_caption_candidate", "notes": "BUG: Real: footnote. Figure 5 panel B continuation — should be figure_caption_candidate, not footnote leaking into body flow."} ], "expected_object_ownership": [ - {"object_type": "figure", "figure_number": 4, "asset_block_ids": [7], "must_render_as_object": true, "notes": "Current production reader output anchors Figure 4 to the first histology image block."}, - {"object_type": "figure", "figure_number": 5, "asset_block_ids": [8], "must_render_as_object": true, "notes": "Current production reader output anchors Figure 5 to the first arthroscopy image block."}, - {"object_type": "figure", "figure_number": 6, "notes": "Figure 6 primary caption is on page 3; handled by sidecar routing."} + {"object_type": "figure", "figure_number": 4, "asset_block_ids": [7, 8], "must_render_as_object": true, "notes": "Figure 4 captures the top band of page 4 images via sidecar partition."}, + {"object_type": "figure", "figure_number": 5, "asset_block_ids": [9, 10], "must_render_as_object": true, "notes": "Figure 5 captures the middle band of page 4 images via sidecar partition."}, + {"object_type": "figure", "figure_number": 6, "asset_block_ids": [11, 13], "must_render_as_object": true, "notes": "Figure 6 captures the bottom band of page 4 images via sidecar partition."} ] }, "5": {