From cb3f459ba2295ad22ba0aaad1f19c757ddd91d33 Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Sat, 6 Jun 2026 16:01:57 +0800 Subject: [PATCH] fix: prioritize formal figure legends over panel subcaptions --- paperforge/worker/ocr_figures.py | 43 ++++++++++++---------- tests/test_ocr_figures.py | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 18 deletions(-) diff --git a/paperforge/worker/ocr_figures.py b/paperforge/worker/ocr_figures.py index acb9175a..56e89048 100644 --- a/paperforge/worker/ocr_figures.py +++ b/paperforge/worker/ocr_figures.py @@ -85,6 +85,9 @@ def _is_body_mention(block: dict) -> bool: return False +_PANEL_SUBCAPTION_PATTERN = re.compile(r"^\s*[a-z][\.\)]\s") + + def _is_formal_legend(text: str, block: dict | None = None, page_width: float = 1200) -> bool: if not text: return False @@ -160,7 +163,7 @@ def _is_formal_legend(text: str, block: dict | None = None, page_width: float = if text_len < 100 and words and words.issubset(axis_words | stop_words): return False - return True + return not (_PANEL_SUBCAPTION_PATTERN.match(text) and not _FIGURE_NUMBER_PATTERN.search(text)) def _cluster_bbox(bboxes: list[list[float]]) -> list[float]: @@ -307,10 +310,14 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 if raw_label in {"image", "chart", "figure_title", "figure"} or not raw_label: assets.append(block) + numbered_legends = [leg for leg in legends if _extract_figure_number(leg.get("text", "")) is not None] + unnumbered_legends = [leg for leg in legends if _extract_figure_number(leg.get("text", "")) is None] + ordered_legends = numbered_legends + unnumbered_legends + candidate_regions = _compute_candidate_figure_regions(structured_blocks, page_width) used_asset_indices: set[int] = set() used_region_ids: set[str] = set() - for legend in legends: + for legend in ordered_legends: legend_page = legend.get("page", 0) legend_text = legend.get("text", "") fig_num = _extract_figure_number(legend_text) @@ -336,9 +343,8 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 used_region_ids.add(region_match["region_id"]) for asset in matched_assets: for i, candidate in enumerate(assets): - if ( - candidate.get("block_id") == asset.get("block_id") - and candidate.get("page", 0) == asset.get("page", 0) + if candidate.get("block_id") == asset.get("block_id") and candidate.get("page", 0) == asset.get( + "page", 0 ): used_asset_indices.add(i) break @@ -385,23 +391,24 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 for asset in media_blocks: for i, candidate in enumerate(assets): - if ( - candidate.get("block_id") == asset.get("block_id") - and candidate.get("page", 0) == asset.get("page", 0) + if candidate.get("block_id") == asset.get("block_id") and candidate.get("page", 0) == asset.get( + "page", 0 ): used_asset_indices.add(i) break - unresolved_clusters.append({ - "cluster_id": f"cluster_{len(unresolved_clusters) + 1:03d}", - "page": page, - "bbox": bbox, - "media_block_ids": media_block_ids, - "matched_legend_block_id": None, - "status": "unresolved_multi_panel", - "confidence": 0.45, - "flags": ["legend_rejected", "multi_panel_cluster"], - }) + unresolved_clusters.append( + { + "cluster_id": f"cluster_{len(unresolved_clusters) + 1:03d}", + "page": page, + "bbox": bbox, + "media_block_ids": media_block_ids, + "matched_legend_block_id": None, + "status": "unresolved_multi_panel", + "confidence": 0.45, + "flags": ["legend_rejected", "multi_panel_cluster"], + } + ) for i, asset in enumerate(assets): if i not in used_asset_indices: diff --git a/tests/test_ocr_figures.py b/tests/test_ocr_figures.py index af43501a..f1623a5b 100644 --- a/tests/test_ocr_figures.py +++ b/tests/test_ocr_figures.py @@ -591,3 +591,64 @@ def test_unresolved_clusters_in_inventory() -> None: assert len(inventory["unmatched_assets"]) == 0, ( "Six panels should be consumed by unresolved cluster, not left as individual unmatched assets" ) + + +# === panel subcaption / formal legend precedence === + + +def test_panel_subcaption_rejected_by_is_formal_legend() -> None: + from paperforge.worker.ocr_figures import _is_formal_legend + + panel_subcaption = "c. Redistribution of cells after 24 hours of treatment" + assert _is_formal_legend(panel_subcaption) is False + + +def test_panel_subcaption_with_parenthesis_rejected() -> None: + from paperforge.worker.ocr_figures import _is_formal_legend + + panel_subcaption = "a) Control group measurements at 48 hours post stimulation" + assert _is_formal_legend(panel_subcaption) is False + + +def test_formal_legend_precedence_over_panel_subcaption() -> None: + """When a numbered formal legend and a panel subcaption exist on the same page + with a single candidate region, the formal legend must claim the region.""" + from paperforge.worker.ocr_figures import build_figure_inventory + + structured_blocks = [ + { + "paper_id": "K001", + "page": 1, + "block_id": "p1_b1", + "role": "figure_caption", + "text": "Fig. 2. Quantitative analysis of cell migration under DC electric field " + "stimulation over 48 hours.", + "bbox": [50, 420, 550, 460], + }, + { + "paper_id": "K001", + "page": 1, + "block_id": "p1_b2", + "role": "figure_caption", + "text": "c. Redistribution of cells after 24 hours of treatment with " + "osteogenic medium for differentiation induction", + "bbox": [60, 350, 540, 380], + }, + { + "paper_id": "K001", + "page": 1, + "block_id": "p1_b3", + "role": "figure_asset", + "text": "", + "bbox": [60, 50, 540, 330], + }, + ] + + inventory = build_figure_inventory(structured_blocks) + + assert len(inventory["matched_figures"]) == 1 + assert inventory["matched_figures"][0]["figure_number"] == 2 + assert len(inventory["matched_figures"][0]["matched_assets"]) == 1 + + assert len(inventory["rejected_legends"]) == 1 + assert "c. Redistribution" in inventory["rejected_legends"][0].get("text", "")