diff --git a/paperforge/worker/ocr_document.py b/paperforge/worker/ocr_document.py index 6b1132f7..1d931533 100644 --- a/paperforge/worker/ocr_document.py +++ b/paperforge/worker/ocr_document.py @@ -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)) diff --git a/paperforge/worker/ocr_scores.py b/paperforge/worker/ocr_scores.py index 77a808ae..5c71ebf8 100644 --- a/paperforge/worker/ocr_scores.py +++ b/paperforge/worker/ocr_scores.py @@ -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