lllin000_PaperForge/paperforge/worker/ocr_figures.py
Research Assistant d52ea37e92 feat(ocr): tail regime remediation + style-aware heading profiles + boundary detection
- Task: Bidirectional body/backmatter boundary detection (forward/backward spine + tail spread reconciliation)
- Task: Style-aware heading profiles from PDF span_metadata (extract, cluster, disambiguate)
- Task: Lock tests for tail-candidate overreach, cross-page continuation, style-aware heading detection
- 296/296 tests pass, real-paper 7C8829BD verified
2026-06-05 22:47:13 +08:00

195 lines
5.9 KiB
Python

from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from paperforge.core.io import write_json
_FIGURE_NUMBER_PATTERN = re.compile(
r"(?:Figure|Fig\.?|Supplementary\s+Figure|Supplementary\s+Fig\.?|"
r"Extended\s+Data\s+Figure|Extended\s+Data\s+Fig\.?)\s+"
r"(?:S)?(\d+(?:\.\d+)?)",
flags=re.IGNORECASE,
)
_BODY_MENTION_VERBS = (
"shows",
"illustrates",
"depicts",
"presents",
"summarizes",
"demonstrates",
"displays",
"reveals",
"indicates",
"highlights",
"compares",
"outlines",
"reports",
"lists",
)
_BODY_MENTION_PATTERN = re.compile(
r"\b(?:Figure|Fig\.?|Supplementary\s+Figure|Supplementary\s+Fig\.?|"
r"Extended\s+Data\s+Figure|Extended\s+Data\s+Fig\.?)\s+"
r"\d+\.?\s+(?:" + "|".join(_BODY_MENTION_VERBS) + r")\b",
flags=re.IGNORECASE,
)
def _extract_figure_number(text: str) -> int | None:
m = _FIGURE_NUMBER_PATTERN.search(text)
if m:
try:
return int(float(m.group(1)))
except ValueError:
return None
return None
def _compute_overlap_score(a_bbox: list[float], b_bbox: list[float]) -> float:
ax1, ay1, ax2, ay2 = a_bbox
bx1, by1, bx2, by2 = b_bbox
ix1 = max(ax1, bx1)
iy1 = max(ay1, by1)
ix2 = min(ax2, bx2)
iy2 = min(ay2, by2)
iw = max(0, ix2 - ix1)
ih = max(0, iy2 - iy1)
intersection = iw * ih
if intersection == 0:
return 0.0
a_area = (ax2 - ax1) * (ay2 - ay1)
b_area = (bx2 - bx1) * (by2 - by1)
union = a_area + b_area - intersection
if union == 0:
return 0.0
return intersection / union
def _centroid_y(bbox: list[float]) -> float:
return (bbox[1] + bbox[3]) / 2
def _is_body_mention(block: dict) -> bool:
raw_role = block.get("raw_role", block.get("role", ""))
if raw_role == "body_paragraph":
return True
if block.get("block_label", "") == "text":
text = block.get("text", "")
return bool(_BODY_MENTION_PATTERN.search(text))
return False
def build_figure_inventory(structured_blocks: list[dict]) -> dict[str, Any]:
legends: list[dict] = []
assets: list[dict] = []
unmatched_legends: list[dict] = []
unmatched_assets: list[dict] = []
matched_figures: list[dict] = []
for block in structured_blocks:
role = block.get("role", "")
if role == "figure_caption":
if _is_body_mention(block):
continue
legends.append(block)
elif role == "figure_asset":
assets.append(block)
elif role == "media_asset":
raw_label = str(block.get("raw_label", "")).strip()
if raw_label in {"image", "chart", "figure_title", "figure"} or not raw_label:
assets.append(block)
used_asset_indices: set[int] = set()
for legend in legends:
legend_page = legend.get("page", 0)
legend_bbox = legend.get("bbox", [0, 0, 0, 0])
legend_text = legend.get("text", "")
fig_num = _extract_figure_number(legend_text)
candidate_pages = [legend_page]
matched_assets = []
for page in candidate_pages:
if page < 1:
continue
page_assets = [
(i, a) for i, a in enumerate(assets) if a.get("page", 0) == page and i not in used_asset_indices
]
if not page_assets:
continue
candidates_for_page: list[dict] = []
for i, asset in page_assets:
asset_bbox = asset.get("bbox", [0, 0, 0, 0])
overlap = _compute_overlap_score(legend_bbox, asset_bbox)
dist_y = abs(_centroid_y(legend_bbox) - _centroid_y(asset_bbox))
direction_bonus = 1.0 if _centroid_y(asset_bbox) < _centroid_y(legend_bbox) else 0.5
score = overlap * 10 + direction_bonus + 2.0 - dist_y * 0.01
candidates_for_page.append(
{
"asset_index": i,
"asset": asset,
"score": score,
"overlap": overlap,
"distance_y": dist_y,
"same_page": True,
}
)
candidates_for_page.sort(key=lambda c: c["score"], reverse=True)
for candidate in candidates_for_page:
if len(matched_assets) >= 3:
break
if candidate["score"] > -5:
matched_assets.append(candidate["asset"])
used_asset_indices.add(candidate["asset_index"])
if matched_assets:
break
is_legend_only = len(matched_assets) == 0
matched_figures.append(
{
"legend_block_id": legend.get("block_id", ""),
"page": legend_page,
"text": legend_text,
"figure_number": fig_num,
"matched_assets": [
{
"block_id": a.get("block_id", ""),
"bbox": a.get("bbox", [0, 0, 0, 0]),
}
for a in matched_assets
],
"confidence": 0.85 if not is_legend_only else 0.4,
"flags": [] if not is_legend_only else ["legend_only"],
}
)
if is_legend_only:
unmatched_legends.append(legend)
for i, asset in enumerate(assets):
if i not in used_asset_indices:
unmatched_assets.append(asset)
return {
"figure_legends": legends,
"figure_assets": assets,
"matched_figures": matched_figures,
"unmatched_legends": unmatched_legends,
"unmatched_assets": unmatched_assets,
"official_figure_count": len(matched_figures),
}
def write_figure_inventory(dst: Path, inventory: dict[str, Any]) -> None:
write_json(dst, inventory)