fix: sidecar figure caption preservation + rotated table caption matching

37LK5T97 had two bugs:

1. Figure 1 sidecar demotion (ocr_document.py):
   - caption in left column (246px wide), image in right column
   - _is_near_figure_media uses h_overlap which fails for adjacent columns
   - candidate_resolution demoted the caption as 'narrative prose'
   - Fix: add _is_sidecar_candidate check — if figure_caption_candidate
     has vertical overlap with a media_asset but no horizontal overlap
     (adjacent columns), skip the prose-based demotion.
   - New narrow helper, does not change _is_near_figure_media (avoids
     regression in vision_footnote routing)

2. Rotated table caption matching (ocr_scores.py):
   - Tables 1-3 had rotated captions (dir=[0,-1], vertical text)
   - score_table_match only checked x_overlap + asset_below_caption
   - Rotated captions are beside the table body, not above it
   - Fix: when caption has rotated text and x_overlap < 0.5, use
     adjacent-column gap check + y_overlap ratio instead
   - Non-rotated captions unaffected (elif only fires for rotated)

Result: 37LK5T97 Figure 1 matched (figure_001, asset=block_id=9).
All 6 tables (3 rotated + 1 continuation x2 + 1 normal) matched.
428 regression tests pass.
This commit is contained in:
LLLin000 2026-07-02 14:42:59 +08:00
parent 1cff29ff01
commit bd3f3b63e9
2 changed files with 56 additions and 0 deletions

View file

@ -4332,6 +4332,27 @@ def _check_caption_style_match(block: dict, blocks: list[dict]) -> bool:
return size_match and font_match
def _is_sidecar_candidate(block: dict, page_blocks: list[dict]) -> bool:
"""Check if a figure_caption_candidate is a sidecar: no x_overlap but y_overlap
with a media_asset on the same page (adjacent columns layout)."""
bb = block.get("bbox") or block.get("block_bbox") or [0, 0, 0, 0]
if len(bb) < 4:
return False
bx1, by1, bx2, by2 = bb[:4]
for other in page_blocks:
label = str(other.get("block_label") or other.get("raw_label") or "").strip()
if label not in {"image", "chart", "figure"}:
continue
ob = other.get("bbox") or other.get("block_bbox") or []
if len(ob) < 4:
continue
ox1, oy1, ox2, oy2 = ob[:4]
# Adjacent columns: no horizontal overlap but vertical overlap
if not (bx1 < ox2 and ox1 < bx2) and (by1 < oy2 and oy1 < by2):
return True
return False
def _resolve_ambiguous_candidates(
blocks: list[dict],
doc_structure: DocumentStructure,
@ -4535,6 +4556,7 @@ def _resolve_ambiguous_candidates(
reason="backmatter heading candidate in backmatter region, promoted to backmatter heading",
)
# ---- 2.2 Resolve figure_caption_candidate ----
if role == "figure_caption_candidate":
text = block.get("text", "") or ""
@ -4556,6 +4578,9 @@ def _resolve_ambiguous_candidates(
if (
in_body_spine
and is_prose
# Sidecar figure captions (caption in one column, image adjacent)
# must survive prose detection — they are long by nature.
and not _is_sidecar_candidate(block, page_blocks)
and not (zone == "display_zone" and style_family == "legend_like" and raw_label == "figure_title")
# Vision-footnote figure descriptions already validated by dedicated rescue
and not (raw_label == "vision_footnote" and _looks_like_figure_description_opening(text))

View file

@ -65,6 +65,37 @@ def score_table_match(caption: dict, asset: dict, *, is_continuation: bool = Fal
if _bbox_x_overlap_ratio(caption_bbox, asset_bbox) >= 0.5:
score += 0.25
evidence.append("x_overlap")
elif len(caption_bbox) >= 4 and len(asset_bbox) >= 4:
# Check if caption has rotated text (dir != [1,0]) — vertical caption
# placed beside the table body, not above it.
span = caption.get("span_metadata") or []
if isinstance(span, dict):
span = [span]
_is_rotated = False
if isinstance(span, list):
for _entry in span:
if not isinstance(_entry, dict):
continue
_dir = _entry.get("dir")
if isinstance(_dir, (list, tuple)) and len(_dir) == 2:
if abs(float(_dir[1])) > abs(float(_dir[0])):
_is_rotated = True
break
if _is_rotated:
# Adjacent-column check: caption beside table, no x_overlap
_gap = max(0.0, asset_bbox[0] - caption_bbox[2], caption_bbox[0] - asset_bbox[2])
_narrow_w = min(caption_bbox[2] - caption_bbox[0], asset_bbox[2] - asset_bbox[0])
if _gap < _narrow_w * 0.3 and _gap < 80:
score += 0.20
evidence.append("adjacent_x")
# y_overlap: caption and table share vertical band
_by1, _by2 = caption_bbox[1], caption_bbox[3]
_oy1, _oy2 = asset_bbox[1], asset_bbox[3]
_y_overlap = max(0.0, min(_by2, _oy2) - max(_by1, _oy1))
_min_span = min(_by2 - _by1, _oy2 - _oy1)
if _min_span > 0 and _y_overlap / _min_span >= 0.3:
score += 0.15
evidence.append("y_overlap_with_asset")
if len(caption_bbox) >= 4 and len(asset_bbox) >= 4:
if same_page and asset_bbox[1] >= caption_bbox[3]:
score += 0.25