lllin000_PaperForge/paperforge/worker/ocr_table_passes.py
LLLin000 3cda942f96 fix: appendix numbering cross-cutting bugs (TABLE regex, int block_id, table alpha prefix, weak-caption tie-break)
3 distinct bugs found and fixed:

1. TABLE in figure regex (_FIGURE_NUMBER_PATTERN)
   Removed TABLE/Table from figure regex — caused table captions to be
   extracted as figure numbers and roadmapped into figure inventory.
   Added unit tests (table_numeric_caption_is_not_a_figure_number,
   table_appendix_caption_marker_has_no_figure_number).

2. int block_id type mismatch in cross-page lookup
   CrossPageSettlementPass and PrimarySamePagePass compared block_id
   with === but deduped_legends use int keys while ResourceRef stores
   str.  Fixed by casting both to str.  Table continuation lookup
   missed page filter, picking wrong page's same block_id.  Added
   page filter + int_block_id tests.

3. Table appendix support gaps
   - Table prefix regex only accepted digits/roman, not 'TABLE A1'.
     Added [A-Z]\d+ token and strip-leading-alpha in parse.
   - _is_validation_first_table_candidate didn't cover figure_title
     raw_label blocks with table_caption_like style.  Added second
     gate.
   - _is_weak_explicit_table_caption only checked table_caption roles.
     Extended to include validation-first candidates.
   - Same-page tie-break didn't apply for weak-explicit captions.
     Ported _bare_table_tie_break into vnext pass.
   - figure_caption_candidate not excluded from note attachment.
   - Continuation merge stripped leading duplicate table marker.

M84CTEM9 vault verification: 6/6 figures matched (3 main + 3
appendix + assets), 4/4 tables (Table 1,2 + Table A1,A2 + assets),
0 false positives, 0 figure asset leakage.
2026-07-04 01:29:06 +08:00

456 lines
21 KiB
Python

from __future__ import annotations
from .ocr_pairing_types import ClaimProposal, PassReport, ResourceRef
class TableWeakCaptionRecoveryPass:
name = "table_weak_caption_recovery"
def run(self, state):
from . import ocr_tables
report = PassReport(pass_name=self.name)
for record in state.candidate_index.caption_records:
if not record["is_weak_truncated"]:
continue
caption = record["caption"]
continuation = ocr_tables._find_table_caption_continuation(caption, state.corpus.blocks)
materialized, continuation_ids = ocr_tables._materialize_table_caption(caption, continuation)
record["caption"] = materialized
record["caption_text"] = str(materialized.get("text", "") or "")
record["continuation_ids"] = continuation_ids
if record["is_validation_first_candidate"]:
same_page_assets = state.candidate_index.assets_by_page.get(int(caption.get("page", 0) or 0), [])
if not same_page_assets:
record["status"] = "held"
held_count = len([r for r in state.candidate_index.caption_records if r.get("status") == "held"])
record["held_table"] = {
"table_id": f"held_table_{held_count + 1:03d}",
"caption_block_id": record["caption_block_id"],
"page": caption.get("page", 0),
"caption_text": record["caption_text"],
"table_number": record["table_number"],
"formal_table_number": record["formal_table_number"],
"hold_reason": "insufficient_caption_evidence",
"zone": caption.get("zone", ""),
"style_family": caption.get("style_family", ""),
"marker_signature": caption.get("marker_signature", {}),
}
return report
class TableSamePagePass:
name = "table_same_page"
def run(self, state):
from . import ocr_tables
report = PassReport(pass_name=self.name)
for record in state.candidate_index.caption_records:
if record.get("status") != "pending":
continue
caption = record["caption"]
caption_page = int(caption.get("page", 0) or 0)
page_assets = [
(idx, asset)
for idx, asset in state.candidate_index.assets_by_page.get(caption_page, [])
if state.ledger.owner_of_asset(page=caption_page, block_id=asset.get("block_id")) is None
]
scored = ocr_tables._score_candidate_assets(page_assets, caption, is_continuation=record["is_continuation"])
scored.sort(key=lambda item: item[2].get("score", 0.0), reverse=True)
record["candidate_assets"] = [
{"asset_block_id": asset.get("block_id", ""), "match_score": score} for _, asset, score in scored[:3]
]
if not scored:
continue
top_idx, top_asset, top_score = scored[0]
second_score = scored[1][2].get("score", 0.0) if len(scored) > 1 else -1.0
top_score_val = top_score.get("score", 0.0)
if top_score_val < 0.4:
continue
if top_score_val - second_score < 0.15:
if record.get("is_weak_explicit_caption") and len(scored) > 1:
scored.sort(
key=lambda item: ocr_tables._bare_table_tie_break(item[2], caption, item[1]),
reverse=True,
)
top_idx, top_asset, top_score = scored[0]
top_tie = ocr_tables._bare_table_tie_break(top_score, caption, top_asset)
second_tie = ocr_tables._bare_table_tie_break(scored[1][2], caption, scored[1][1])
if top_tie == second_tie:
record["status"] = "ambiguous"
continue
else:
record["status"] = "ambiguous"
continue
owner = ResourceRef(
kind="legend",
page=caption_page,
block_id=record["caption_block_id"],
figure_no=record["formal_table_number"],
)
asset_ref = ResourceRef(
kind="asset", page=int(top_asset.get("page", 0) or 0), block_id=top_asset.get("block_id")
)
conflict = state.ledger.try_claim_assets([asset_ref], owner=owner, reason=self.name)
if conflict is not None:
report.conflicts.append(conflict)
continue
match_status = "matched" if top_score.get("score", 0.0) >= 0.6 else "matched_low_confidence"
state.accept_match(
ClaimProposal(
pass_name=self.name,
figure_no=record["formal_table_number"],
claim_type="match",
legends=[owner],
assets=[asset_ref],
groups=[],
texts=[],
confidence=float(top_score.get("score", 0.0)),
evidence_rank=0,
reason=self.name,
),
{
"caption_block_id": record["caption_block_id"],
"page": caption_page,
"caption_text": record["caption_text"],
"table_number": record["table_number"],
"formal_table_number": record["formal_table_number"],
"asset_block_id": top_asset.get("block_id", ""),
"asset_bbox": top_asset.get("bbox", [0, 0, 0, 0]),
"assistive_text": str(top_asset.get("text", "") or ""),
"truth_source": "image",
"has_asset": True,
"segments": [
{
"page": top_asset.get("page", 0),
"asset_block_id": top_asset.get("block_id", ""),
"asset_bbox": top_asset.get("bbox", [0, 0, 0, 0]),
"is_continuation": record["is_continuation"],
}
],
"note_block_ids": [],
"note_texts": [],
"note_bboxes": [],
"note_band_bbox": [],
"note_match_reason": "",
"note_confidence": 0.0,
"bridge_block_ids": [],
"consumed_block_ids": [
record["caption_block_id"],
top_asset.get("block_id", ""),
*record.get("continuation_ids", []),
],
"is_continuation": record["is_continuation"],
"continuation_of": None,
"match_status": match_status,
"candidate_assets": record["candidate_assets"],
"match_score": top_score,
"render_bbox": None,
"render_rotation_deg": 0,
"asset_family_hint": top_asset.get("asset_family_hint"),
"asset_family_confidence": top_asset.get("asset_family_confidence"),
"asset_family_evidence": top_asset.get("asset_family_evidence"),
},
)
record["status"] = "matched"
return report
class TableAdjacentPagePass:
name = "table_adjacent_page"
def run(self, state):
from . import ocr_tables
report = PassReport(pass_name=self.name)
for record in state.candidate_index.caption_records:
if record.get("status") != "pending":
continue
caption = record["caption"]
caption_page = int(caption.get("page", 0) or 0)
candidate_pages = [caption_page - 1, caption_page, caption_page + 1]
all_candidates = []
for page in candidate_pages:
if page < 1:
continue
page_assets = [
(idx, asset)
for idx, asset in state.candidate_index.assets_by_page.get(page, [])
if state.ledger.owner_of_asset(page=page, block_id=asset.get("block_id")) is None
]
all_candidates.extend(
ocr_tables._score_candidate_assets(page_assets, caption, is_continuation=record["is_continuation"])
)
for _, asset, score_dict in all_candidates:
a_page = int(asset.get("page", 0) or 0)
if a_page == caption_page - 1:
ab = asset.get("bbox") or [0, 0, 0, 0]
cb = caption.get("bbox") or [0, 0, 0, 0]
if len(ab) >= 4 and len(cb) >= 4:
x_ratio = (min(cb[2], ab[2]) - max(cb[0], ab[0])) / max(1.0, min(cb[2] - cb[0], ab[2] - ab[0]))
page_h = max(state.corpus.page_max_y.values()) if state.corpus.page_max_y else 1.0
if x_ratio >= 0.5 and float(ab[3]) >= page_h * 0.85 and float(cb[1]) <= page_h * 0.15:
score_dict["score"] = min(score_dict.get("score", 0.0) + 0.15, 1.0)
score_dict.setdefault("evidence", []).append("continuation_geometry_elevation")
all_candidates.sort(key=lambda item: item[2].get("score", 0.0), reverse=True)
record["candidate_assets"] = [
{"asset_block_id": asset.get("block_id", ""), "match_score": score}
for _, asset, score in all_candidates[:3]
]
if not all_candidates:
record["status"] = "unmatched_caption"
continue
top_idx, top_asset, top_score = all_candidates[0]
second_score = all_candidates[1][2].get("score", 0.0) if len(all_candidates) > 1 else -1.0
if top_score.get("score", 0.0) < 0.4:
record["status"] = "unmatched_caption"
continue
if top_score.get("score", 0.0) - second_score < 0.15:
record["status"] = "ambiguous"
continue
owner = ResourceRef(
kind="legend",
page=caption_page,
block_id=record["caption_block_id"],
figure_no=record["formal_table_number"],
)
asset_ref = ResourceRef(
kind="asset", page=int(top_asset.get("page", 0) or 0), block_id=top_asset.get("block_id")
)
conflict = state.ledger.try_claim_assets([asset_ref], owner=owner, reason=self.name)
if conflict is not None:
report.conflicts.append(conflict)
continue
match_status = "matched" if top_score.get("score", 0.0) >= 0.6 else "matched_low_confidence"
continuation_of = None
if record["is_continuation"] and record["formal_table_number"] is not None:
for existing in state.matches:
if existing.get("formal_table_number") == record["formal_table_number"] and not existing.get(
"is_continuation"
):
continuation_of = record["formal_table_number"]
break
state.accept_match(
ClaimProposal(
pass_name=self.name,
figure_no=record["formal_table_number"],
claim_type="match",
legends=[owner],
assets=[asset_ref],
groups=[],
texts=[],
confidence=float(top_score.get("score", 0.0)),
evidence_rank=1,
reason=self.name,
),
{
"caption_block_id": record["caption_block_id"],
"page": caption_page,
"caption_text": record["caption_text"],
"table_number": record["table_number"],
"formal_table_number": record["formal_table_number"],
"asset_block_id": top_asset.get("block_id", ""),
"asset_bbox": top_asset.get("bbox", [0, 0, 0, 0]),
"assistive_text": str(top_asset.get("text", "") or ""),
"truth_source": "image",
"has_asset": True,
"segments": [
{
"page": top_asset.get("page", 0),
"asset_block_id": top_asset.get("block_id", ""),
"asset_bbox": top_asset.get("bbox", [0, 0, 0, 0]),
"is_continuation": record["is_continuation"],
}
],
"note_block_ids": [],
"note_texts": [],
"note_bboxes": [],
"note_band_bbox": [],
"note_match_reason": "",
"note_confidence": 0.0,
"bridge_block_ids": [],
"consumed_block_ids": [
record["caption_block_id"],
top_asset.get("block_id", ""),
*record.get("continuation_ids", []),
],
"is_continuation": record["is_continuation"],
"continuation_of": continuation_of,
"match_status": match_status,
"candidate_assets": record["candidate_assets"],
"match_score": top_score,
"render_bbox": None,
"render_rotation_deg": 0,
"asset_family_hint": top_asset.get("asset_family_hint"),
"asset_family_confidence": top_asset.get("asset_family_confidence"),
"asset_family_evidence": top_asset.get("asset_family_evidence"),
},
)
record["status"] = "matched"
return report
class TableNotesAttachmentPass:
name = "table_notes_attachment"
def run(self, state):
from . import ocr_tables
report = PassReport(pass_name=self.name)
for table in state.matches:
if not table.get("has_asset"):
table.setdefault("note_block_ids", [])
table.setdefault("note_texts", [])
table.setdefault("note_bboxes", [])
table.setdefault("note_band_bbox", [])
table.setdefault("note_match_reason", "")
table.setdefault("note_confidence", 0.0)
table.setdefault("bridge_block_ids", [])
continue
asset_page = int(table.get("page", 0) or 0)
asset_bbox = table.get("asset_bbox", [0, 0, 0, 0])
asset_bottom = asset_bbox[3] if len(asset_bbox) >= 4 else 0
candidates = []
note_match_reason = ""
for block in state.corpus.blocks:
if int(block.get("page", 0) or 0) != asset_page:
continue
brole = str(block.get("role", "") or "")
braw_label = str(block.get("raw_label", "") or "").strip()
btext = str(block.get("text", "") or "").strip()
is_note = (
brole == "footnote"
or braw_label == "vision_footnote"
or (
0 < len(btext) < 120
and brole
not in {
"noise",
"page_footer",
"page_header",
"frontmatter_noise",
"table_caption",
"table_caption_candidate",
"table_asset",
"media_asset",
"figure_caption",
"section_heading",
"subsection_heading",
"reference_heading",
}
)
)
if not is_note:
continue
bbbox = block.get("bbox") or [0, 0, 0, 0]
if len(bbbox) < 4:
note_match_reason = "invalid_bbox"
continue
if bbbox[1] < asset_bottom or bbbox[1] > asset_bottom + 100:
note_match_reason = "outside_vertical_range"
continue
if ocr_tables._table_note_falls_into_page_footnote_prior(
bbbox, asset_page, state.corpus.page_footnote_prior
):
note_match_reason = "page_footnote_prior_rejected"
continue
if ocr_tables._looks_like_body_text_below_table(block, asset_bbox):
note_match_reason = "body_text_like_excluded"
continue
candidates.append(block)
if candidates:
candidates.sort(key=lambda b: (b.get("bbox") or [0, 0, 0, 0])[1])
table["note_block_ids"] = [str(b.get("block_id", "")) for b in candidates if b.get("block_id")]
table["note_texts"] = [
str(b.get("text", "") or "").strip() for b in candidates if str(b.get("text", "") or "").strip()
]
table["note_bboxes"] = [b.get("bbox", [0, 0, 0, 0]) for b in candidates]
table["note_band_bbox"] = [
min(bb[0] for bb in table["note_bboxes"]),
min(bb[1] for bb in table["note_bboxes"]),
max(bb[2] for bb in table["note_bboxes"]),
max(bb[3] for bb in table["note_bboxes"]),
]
table["note_match_reason"] = "note_band_geometry_match"
table["note_confidence"] = 0.85
else:
table.setdefault("note_block_ids", [])
table.setdefault("note_texts", [])
table.setdefault("note_bboxes", [])
table.setdefault("note_band_bbox", [])
table["note_match_reason"] = note_match_reason or "no_footnote_role"
table.setdefault("note_confidence", 0.0)
asset_block = next(
(
a
for a in state.corpus.raw_assets
if str(a.get("block_id", "")) == str(table.get("asset_block_id", ""))
and int(a.get("page", 0) or 0) == asset_page
),
None,
)
if asset_block is not None:
rot = ocr_tables._table_has_rotated_content(asset_block)
if rot:
ab = table.get("asset_bbox", [])
caption = next(
(
r["caption"]
for r in state.candidate_index.caption_records
if r["caption_block_id"] == table.get("caption_block_id")
),
None,
)
cb = (caption.get("bbox") or caption.get("block_bbox") or []) if caption else []
if len(ab) >= 4 and len(cb) >= 4:
table["render_bbox"] = [
min(cb[0], ab[0]),
min(cb[1], ab[1]),
max(cb[2], ab[2]),
max(cb[3], ab[3]),
]
table["render_rotation_deg"] = rot
# Bridge block detection
table["bridge_block_ids"] = [
str(block.get("block_id") or "")
for block in state.corpus.blocks
if int(block.get("page", 0) or 0) == asset_page
and block.get("bridge_eligible")
and str(block.get("layout_region") or "") == "display_zone"
and block.get("block_id")
]
# Consumed block IDs include notes
existing_ids = set(table.get("consumed_block_ids") or [])
for bid in table.get("note_block_ids", []):
if bid not in existing_ids:
table.setdefault("consumed_block_ids", []).append(bid)
return report
class TableFinalAccountingPass:
name = "table_final_accounting"
def run(self, state):
report = PassReport(pass_name=self.name)
state.completeness = {
"total_numbered_tables": len(
[r for r in state.candidate_index.caption_records if r.get("formal_table_number") is not None]
),
"accounted_for": len(
[r for r in state.candidate_index.caption_records if r.get("status") in {"matched", "held"}]
),
"details": [
{"caption_block_id": r["caption_block_id"], "status": r.get("status", "pending")}
for r in state.candidate_index.caption_records
],
}
return report