From 08a52dcb4808237846c4cbd4626e007ddee7e5cd Mon Sep 17 00:00:00 2001 From: LLLin000 <809867916@qq.com> Date: Sat, 4 Jul 2026 22:03:22 +0800 Subject: [PATCH 1/3] feat(pr4): add column compatibility check for figure caption-asset pairing --- paperforge/worker/ocr_figures.py | 62 ++++++++++++++++++++++++++++++++ tests/test_ocr_figures.py | 47 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/paperforge/worker/ocr_figures.py b/paperforge/worker/ocr_figures.py index 4160ffbb..fa5f3af1 100644 --- a/paperforge/worker/ocr_figures.py +++ b/paperforge/worker/ocr_figures.py @@ -280,6 +280,54 @@ def _column_band_id(bbox: list[float], page_width: float) -> int | None: return None +def _is_full_width_bbox(bbox: list[float], page_width: float) -> bool: + """True if bbox spans 80%+ of page width (single-column or full-width).""" + if not bbox or len(bbox) < 4 or page_width <= 0: + return False + bw = bbox[2] - bbox[0] + return bw >= page_width * 0.8 + + +def _column_compatible_for_caption_asset(caption_bbox: list[float], asset_bbox: list[float], page_width: float) -> bool: + """Check if caption and asset are in compatible column bands. + Full-width -> compatible (spans both columns). + Same explicit column band -> compatible. + Both/one center/ambiguous -> compatible. + Different explicit bands -> incompatible. + """ + if _is_full_width_bbox(caption_bbox, page_width) or _is_full_width_bbox(asset_bbox, page_width): + return True + caption_band = _column_band_id(caption_bbox, page_width) + asset_band = _column_band_id(asset_bbox, page_width) + if caption_band is None or asset_band is None: + # At least one is center/ambiguous -> compatible + return True + return caption_band == asset_band + + +def _group_column_band(media_blocks: list[dict], page_width: float) -> int | None: + """Return dominant column band for a list of media blocks. + Returns None if group spans multiple bands (composite/multi-column figure). + """ + bands: set[int | None] = set() + for mb in media_blocks: + bbox = mb.get("bbox") or [0, 0, 0, 0] + if _is_full_width_bbox(bbox, page_width): + return None # full-width = center + band = _column_band_id(bbox, page_width) + bands.add(band) + non_none = {b for b in bands if b is not None} + if not non_none: + return None # all center/ambiguous + if len(non_none) > 1: + return None # spans multiple explicit columns + # All non-None bands agree on one column + if None in bands: + return None # center mixed with explicit -> ambiguous + return next(iter(non_none)) + + + def _is_safe_page_assets_group( group: dict, legend: dict, @@ -963,6 +1011,7 @@ def _build_semantic_figure_groups_from_assets( ) page_groups.append(entry) next_id += 1 + entry["column_band"] = _group_column_band(cluster, page_width) page_group_count = len(page_groups) page_distance_cluster_count = sum(1 for g in page_groups if g["group_type"] == "distance_cluster") @@ -1080,6 +1129,19 @@ def _score_legend_to_group( family_supported: bool = False, zone_supported: bool = False, ) -> dict: + # Column compatibility check -- must run before safe_auto_match + # Skip for rotated captions (side captions sit in different column by design) + if not legend.get("_rotated_caption_prematch"): + legend_bbox = legend.get("bbox") or legend.get("block_bbox") or [0, 0, 0, 0] + group_band = group.get("column_band") or _group_column_band(group.get("media_blocks", []), page_width) + legend_band = _column_band_id(legend_bbox, page_width) + if legend_band is not None and group_band is not None and legend_band != group_band: + return { + "score": 0.0, + "decision": "rejected", + "evidence": ["column_incompatible: legend band=" + str(legend_band) + " group band=" + str(group_band)], + } + gt = group.get("group_type", "") if gt == "distance_cluster": diff --git a/tests/test_ocr_figures.py b/tests/test_ocr_figures.py index 67baed31..7b903063 100644 --- a/tests/test_ocr_figures.py +++ b/tests/test_ocr_figures.py @@ -8318,3 +8318,50 @@ def test_build_figure_inventory_delegates_to_vnext(monkeypatch): assert result["pipeline_mode"] == "vnext" assert called["args"] == (blocks, 777, {1: []}) + + +# ────────────────────────────────────────────── +# PR4: Column compatibility check for figure caption-asset pairing +# ────────────────────────────────────────────── + + +def test_column_compatible_cross_column_rejected() -> None: + from paperforge.worker.ocr_figures import _column_compatible_for_caption_asset + pw = 1200.0 + left_bbox = [100, 100, 500, 200] # left column band = 0 + right_bbox = [700, 100, 1100, 200] # right column band = 1 + result = _column_compatible_for_caption_asset(left_bbox, right_bbox, pw) + assert result is False + + +def test_column_compatible_same_column() -> None: + from paperforge.worker.ocr_figures import _column_compatible_for_caption_asset + pw = 1200.0 + left_bbox = [100, 100, 500, 200] + left_bbox2 = [100, 300, 500, 400] + assert _column_compatible_for_caption_asset(left_bbox, left_bbox2, pw) + + +def test_column_compatible_full_width() -> None: + from paperforge.worker.ocr_figures import _column_compatible_for_caption_asset + pw = 1200.0 + full_bbox = [50, 100, 1150, 200] # 1100/1200 = 91.7% -> full-width + right_bbox = [700, 300, 1100, 400] + assert _column_compatible_for_caption_asset(full_bbox, right_bbox, pw) + + +def test_column_compatible_center_band() -> None: + from paperforge.worker.ocr_figures import _column_compatible_for_caption_asset + pw = 1200.0 + center_bbox = [200, 100, 1000, 200] # center -> None band + right_bbox = [700, 300, 1100, 400] # right -> band 1 + assert _column_compatible_for_caption_asset(center_bbox, right_bbox, pw) + + +def test_group_column_band_composite() -> None: + from paperforge.worker.ocr_figures import _group_column_band + pw = 1200.0 + left_asset = {"bbox": [100, 100, 500, 300]} + right_asset = {"bbox": [700, 100, 1100, 300]} + band = _group_column_band([left_asset, right_asset], pw) + assert band is None # spans multiple columns -> None From 5cac8d1e08e42793b846026f7a2c14e50984473e Mon Sep 17 00:00:00 2001 From: LLLin000 <809867916@qq.com> Date: Sat, 4 Jul 2026 22:29:09 +0800 Subject: [PATCH 2/3] fix(pr4): remove unused _column_compatible_for_caption_asset helper --- paperforge/worker/ocr_figures.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/paperforge/worker/ocr_figures.py b/paperforge/worker/ocr_figures.py index fa5f3af1..d42d989d 100644 --- a/paperforge/worker/ocr_figures.py +++ b/paperforge/worker/ocr_figures.py @@ -288,21 +288,6 @@ def _is_full_width_bbox(bbox: list[float], page_width: float) -> bool: return bw >= page_width * 0.8 -def _column_compatible_for_caption_asset(caption_bbox: list[float], asset_bbox: list[float], page_width: float) -> bool: - """Check if caption and asset are in compatible column bands. - Full-width -> compatible (spans both columns). - Same explicit column band -> compatible. - Both/one center/ambiguous -> compatible. - Different explicit bands -> incompatible. - """ - if _is_full_width_bbox(caption_bbox, page_width) or _is_full_width_bbox(asset_bbox, page_width): - return True - caption_band = _column_band_id(caption_bbox, page_width) - asset_band = _column_band_id(asset_bbox, page_width) - if caption_band is None or asset_band is None: - # At least one is center/ambiguous -> compatible - return True - return caption_band == asset_band def _group_column_band(media_blocks: list[dict], page_width: float) -> int | None: From 17b02b8abf7ffa62e7bae51a39e1e8cfd668642e Mon Sep 17 00:00:00 2001 From: LLLin000 <809867916@qq.com> Date: Sat, 4 Jul 2026 22:34:50 +0800 Subject: [PATCH 3/3] fix(pr4): handle column_band=0 falsy correctly in score_legend_to_group --- paperforge/worker/ocr_figures.py | 4 +++- tests/test_ocr_figures.py | 36 -------------------------------- 2 files changed, 3 insertions(+), 37 deletions(-) diff --git a/paperforge/worker/ocr_figures.py b/paperforge/worker/ocr_figures.py index d42d989d..694c5a17 100644 --- a/paperforge/worker/ocr_figures.py +++ b/paperforge/worker/ocr_figures.py @@ -1118,7 +1118,9 @@ def _score_legend_to_group( # Skip for rotated captions (side captions sit in different column by design) if not legend.get("_rotated_caption_prematch"): legend_bbox = legend.get("bbox") or legend.get("block_bbox") or [0, 0, 0, 0] - group_band = group.get("column_band") or _group_column_band(group.get("media_blocks", []), page_width) + group_band = group.get("column_band") + if group_band is None: + group_band = _group_column_band(group.get("media_blocks", []), page_width) legend_band = _column_band_id(legend_bbox, page_width) if legend_band is not None and group_band is not None and legend_band != group_band: return { diff --git a/tests/test_ocr_figures.py b/tests/test_ocr_figures.py index 7b903063..d15f5685 100644 --- a/tests/test_ocr_figures.py +++ b/tests/test_ocr_figures.py @@ -8320,42 +8320,6 @@ def test_build_figure_inventory_delegates_to_vnext(monkeypatch): assert called["args"] == (blocks, 777, {1: []}) -# ────────────────────────────────────────────── -# PR4: Column compatibility check for figure caption-asset pairing -# ────────────────────────────────────────────── - - -def test_column_compatible_cross_column_rejected() -> None: - from paperforge.worker.ocr_figures import _column_compatible_for_caption_asset - pw = 1200.0 - left_bbox = [100, 100, 500, 200] # left column band = 0 - right_bbox = [700, 100, 1100, 200] # right column band = 1 - result = _column_compatible_for_caption_asset(left_bbox, right_bbox, pw) - assert result is False - - -def test_column_compatible_same_column() -> None: - from paperforge.worker.ocr_figures import _column_compatible_for_caption_asset - pw = 1200.0 - left_bbox = [100, 100, 500, 200] - left_bbox2 = [100, 300, 500, 400] - assert _column_compatible_for_caption_asset(left_bbox, left_bbox2, pw) - - -def test_column_compatible_full_width() -> None: - from paperforge.worker.ocr_figures import _column_compatible_for_caption_asset - pw = 1200.0 - full_bbox = [50, 100, 1150, 200] # 1100/1200 = 91.7% -> full-width - right_bbox = [700, 300, 1100, 400] - assert _column_compatible_for_caption_asset(full_bbox, right_bbox, pw) - - -def test_column_compatible_center_band() -> None: - from paperforge.worker.ocr_figures import _column_compatible_for_caption_asset - pw = 1200.0 - center_bbox = [200, 100, 1000, 200] # center -> None band - right_bbox = [700, 300, 1100, 400] # right -> band 1 - assert _column_compatible_for_caption_asset(center_bbox, right_bbox, pw) def test_group_column_band_composite() -> None: