Skip to content

Commit d216cd0

Browse files
committed
Add figure quality gate and reject bad crops
1 parent 45d6266 commit d216cd0

10 files changed

Lines changed: 447 additions & 3 deletions

references/figure-placement.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@ Do not let scripts make the final semantic choice; scripts should only prepare c
4545
- Do not produce a text-only note first and then ask the user in a follow-up whether figures should be inserted.
4646
- If no figure can be confidently replaced, finish the note with placeholders and explain that outcome in the final response.
4747

48+
## Visual Quality Gate
49+
50+
Figure/table insertion has two separate gates:
51+
- identity match: the candidate label, caption, and local context match the planned figure/table
52+
- visual usability: the crop actually contains the visual body needed by the reader
53+
54+
A label or caption match is not insertion approval.
55+
Fail closed when visual usability is weak: keep the placeholder instead of inserting the candidate.
56+
57+
Reject candidates that are:
58+
- caption-only crops
59+
- tables with no visible table body
60+
- large text, title-page, or abstract crops masquerading as figures
61+
- crops where the visual body is tiny relative to the crop
62+
4863
## Placeholder Requirements
4964

5065
Each placeholder should include:

references/final-writing.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,10 @@ The note should preserve the full figure/table structure even when image extract
209209
If the bundle contains candidate figure pages or candidate image files:
210210
- use them as evidence for semantic matching
211211
- prefer the candidate with the strongest caption/page-context agreement
212+
- treat identity match and visual usability as separate gates
213+
- never treat a matching label or caption as sufficient approval to insert an image
214+
- reject caption-only crops, missing table bodies, large text/title/abstract crops, and crops with very low visual body ratio
215+
- if visual quality is missing, ambiguous, or failed, keep the placeholder
212216
- still make the final decision yourself rather than trusting the candidate ranking blindly
213217

214218
Final-note figure rules:

references/model-synthesis.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ The rule is simple:
3434
If `pdf_assets` is present in the bundle, use it for semantic figure selection:
3535
- inspect page-level image metadata
3636
- inspect figure candidate pages and candidate images from `figure_plan`
37+
- inspect `figure_assets[].quality_signals` when available
3738
- match likely figures by page proximity, caption context, and candidate snippets
39+
- require both identity match and visual usability before insertion
40+
- fail closed on caption-only crops, missing table bodies, large text/title/abstract crops, or very low visual body ratio
3841
- keep the final semantic matching decision on the model side
3942
Build the note in placeholder-first order:
4043
- plan placeholders for all major figures/tables that matter to the note

scripts/build_synthesis_bundle.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,29 @@ def sanitize_page_assets(assets_wrapper: dict, *, limit: int = 24) -> list[dict]
133133
return sanitized
134134

135135

136+
def sanitize_figure_assets(assets_wrapper: dict, *, limit: int = 48) -> list[dict]:
137+
sanitized: list[dict] = []
138+
for item in (assets_wrapper.get("figure_assets", []) or [])[:limit]:
139+
if not isinstance(item, dict):
140+
continue
141+
record = {
142+
"filename": item.get("filename", ""),
143+
"path": item.get("path", ""),
144+
"page_number": item.get("page_number", 0),
145+
"label": item.get("label", ""),
146+
"kind": item.get("kind", ""),
147+
"caption_text": normalize_whitespace(str(item.get("caption_text", ""))),
148+
"width": item.get("width", 0),
149+
"height": item.get("height", 0),
150+
"size_bytes": item.get("size_bytes", 0),
151+
"extraction_level": item.get("extraction_level", ""),
152+
}
153+
if isinstance(item.get("quality_signals"), dict):
154+
record["quality_signals"] = item.get("quality_signals")
155+
sanitized.append(record)
156+
return sanitized
157+
158+
136159
def bundle(metadata: dict, evidence_wrapper: dict, figures_wrapper: dict, assets_wrapper: dict) -> dict:
137160
evidence_pack = evidence_wrapper.get("evidence_pack", {}) if isinstance(evidence_wrapper.get("evidence_pack"), dict) else {}
138161
figure_plan = figures_wrapper.get("figure_plan", {}) if isinstance(figures_wrapper.get("figure_plan"), dict) else {}
@@ -177,6 +200,7 @@ def bundle(metadata: dict, evidence_wrapper: dict, figures_wrapper: dict, assets
177200
"images_dir": assets_wrapper.get("images_dir", ""),
178201
"page_assets": sanitize_page_assets(assets_wrapper),
179202
"image_assets": assets_wrapper.get("image_assets", []),
203+
"figure_assets": sanitize_figure_assets(assets_wrapper),
180204
"ocr_available": assets_wrapper.get("ocr_available", False),
181205
},
182206
"summary": evidence_wrapper.get("summary", {}),
@@ -340,6 +364,9 @@ def bundle(metadata: dict, evidence_wrapper: dict, figures_wrapper: dict, assets
340364
},
341365
"figure_rules": [
342366
"先规划主要图表的占位标签,再决定哪些可以替换成真实图片",
367+
"label/caption match is not insertion approval; it only proves identity, and visual usability is a separate hard gate.",
368+
"Reject caption-only crops, missing table body crops, large text/title/abstract crops, and low visual body ratio crops.",
369+
"If visual quality is missing, ambiguous, or marked review/reject, fail closed: keep the placeholder instead of inserting the image.",
343370
"如果没有高置信度图像匹配,不要删除占位标签",
344371
"图注必须保留论文原始编号,例如 Fig. 1、Table 2",
345372
"如果插入的是局部子图或不完整裁剪,必须明确说明",

scripts/contracts.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,29 @@ class EquationCandidate(TypedDict, total=False):
4545
kind_hint: str
4646

4747

48+
class FigureQualitySignals(TypedDict, total=False):
49+
visual_quality_status: str
50+
quality_reason_codes: list[str]
51+
page_coverage_ratio: float
52+
visual_rect_count: int
53+
visual_body_ratio: float
54+
paragraph_text_chars: int
55+
table_body_rows: int
56+
caption_text_chars: int
57+
58+
59+
class FigureAssetCandidate(TypedDict, total=False):
60+
filename: str
61+
path: str
62+
width: int
63+
height: int
64+
size_bytes: int
65+
label: str
66+
extraction_level: str
67+
quality_signals: FigureQualitySignals
68+
candidate_status: str
69+
70+
4871
class EvidencePack(TypedDict, total=False):
4972
paper_id: str
5073
problem_evidence: list[EvidenceItem]
@@ -75,8 +98,9 @@ class FigurePlanItem(TypedDict, total=False):
7598
priority: int
7699
anchor_text: str
77100
insert_mode: str
78-
figure_asset_candidate: dict[str, Any]
101+
figure_asset_candidate: FigureAssetCandidate
79102
candidate_pages: list[dict[str, Any]]
103+
candidate_status: str
80104
matching_strategy: str
81105

82106

scripts/extract_pdf_assets.py

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,80 @@ def _looks_like_data_row(text: str) -> bool:
6060
return numeric_tokens >= max(2, len(tokens) // 2)
6161

6262

63+
def _rect_area(bbox: tuple[float, float, float, float]) -> float:
64+
return max(0.0, bbox[2] - bbox[0]) * max(0.0, bbox[3] - bbox[1])
65+
66+
67+
def _intersection_area(
68+
a: tuple[float, float, float, float],
69+
b: tuple[float, float, float, float],
70+
) -> float:
71+
x0 = max(a[0], b[0])
72+
y0 = max(a[1], b[1])
73+
x1 = min(a[2], b[2])
74+
y1 = min(a[3], b[3])
75+
return _rect_area((x0, y0, x1, y1))
76+
77+
78+
def _rects_intersect(
79+
a: tuple[float, float, float, float],
80+
b: tuple[float, float, float, float],
81+
) -> bool:
82+
return _intersection_area(a, b) > 0
83+
84+
85+
def _classify_visual_quality(
86+
*,
87+
kind: str,
88+
page_coverage_ratio: float,
89+
visual_rect_count: int,
90+
visual_body_ratio: float,
91+
paragraph_text_chars: int,
92+
table_body_rows: int,
93+
caption_text_chars: int,
94+
) -> dict:
95+
"""Classify whether a caption-matched crop is visually usable.
96+
97+
This is intentionally conservative. A label/caption match proves identity,
98+
but not that the rendered crop contains the figure or table body.
99+
"""
100+
normalized_kind = kind.strip().lower()
101+
reasons: list[str] = []
102+
103+
if normalized_kind == "table":
104+
if table_body_rows <= 0:
105+
reasons.append("table_body_missing")
106+
if table_body_rows <= 1 and visual_body_ratio < 0.03 and caption_text_chars >= 40:
107+
reasons.append("caption_only_suspected")
108+
status = "reject" if reasons else "usable"
109+
else:
110+
if paragraph_text_chars >= 450:
111+
reasons.append("large_text_block_suspected")
112+
if page_coverage_ratio >= 0.70 and paragraph_text_chars >= 250:
113+
reasons.append("oversized_page_crop")
114+
if visual_rect_count <= 1 and visual_body_ratio < 0.03:
115+
reasons.append("low_visual_body_ratio")
116+
if any(code in reasons for code in ("large_text_block_suspected", "oversized_page_crop", "low_visual_body_ratio")):
117+
status = "reject"
118+
elif visual_rect_count == 0 or visual_body_ratio < 0.08:
119+
if "low_visual_body_ratio" not in reasons:
120+
reasons.append("low_visual_body_ratio")
121+
status = "review"
122+
else:
123+
status = "usable"
124+
125+
return {
126+
"visual_quality_status": status,
127+
"quality_reason_codes": reasons,
128+
"page_coverage_ratio": round(page_coverage_ratio, 6),
129+
"visual_rect_count": int(visual_rect_count),
130+
"visual_body_ratio": round(visual_body_ratio, 6),
131+
"paragraph_text_chars": int(paragraph_text_chars),
132+
"table_body_rows": int(table_body_rows),
133+
"caption_text_chars": int(caption_text_chars),
134+
}
135+
136+
63137
def _classify_caption_kind(label: str) -> str:
64138
"""Return 'table' if the caption label starts with 'Table', else 'figure'."""
65139
return "table" if label.strip().lower().startswith("table") else "figure"
@@ -246,6 +320,23 @@ def _collect_drawing_rects(page) -> list[tuple[float, float, float, float]]:
246320
return rects
247321

248322

323+
def _visual_signal_for_bbox(page, bbox: tuple[float, float, float, float]) -> tuple[int, float]:
324+
"""Return visual rect count and visual-area ratio inside a crop."""
325+
crop_area = _rect_area(bbox)
326+
if crop_area <= 0:
327+
return 0, 0.0
328+
rects = _collect_xref_rects(page) + _collect_drawing_rects(page)
329+
count = 0
330+
visual_area = 0.0
331+
for rect in rects:
332+
area = _intersection_area(rect, bbox)
333+
if area <= 0:
334+
continue
335+
count += 1
336+
visual_area += area
337+
return count, min(1.0, visual_area / crop_area)
338+
339+
249340
def _find_body_text_blocks(page) -> list[tuple[float, float, float, float, str]]:
250341
"""Return bounding boxes of body-text blocks (non-caption) sorted top-to-bottom."""
251342
results: list[tuple[float, float, float, float, str]] = []
@@ -308,6 +399,67 @@ def _find_paragraph_blocks(page, *, min_chars: int = 200) -> list[tuple[float, f
308399
return results
309400

310401

402+
def _count_paragraph_text_chars_in_bbox(
403+
page,
404+
bbox: tuple[float, float, float, float],
405+
caption_bbox: tuple[float, float, float, float],
406+
) -> int:
407+
"""Count prose-like text intersecting a crop, excluding the caption area."""
408+
chars = 0
409+
blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)["blocks"]
410+
for block in blocks:
411+
if block.get("type") != 0:
412+
continue
413+
bb = tuple(block["bbox"])
414+
if not _rects_intersect(bb, bbox):
415+
continue
416+
if _intersection_area(bb, caption_bbox) / max(_rect_area(bb), 1.0) > 0.6:
417+
continue
418+
419+
lines = block.get("lines", [])
420+
line_texts = [
421+
"".join(s.get("text", "") for s in line.get("spans", [])).strip()
422+
for line in lines
423+
]
424+
line_texts = [text for text in line_texts if text]
425+
full_text = normalize_whitespace(" ".join(line_texts))
426+
if len(full_text) < 80:
427+
continue
428+
if CAPTION_RE.match(full_text):
429+
continue
430+
numeric_rows = sum(1 for text in line_texts if _looks_like_data_row(text))
431+
if line_texts and numeric_rows > len(line_texts) * 0.4:
432+
continue
433+
chars += len(full_text)
434+
return chars
435+
436+
437+
def _quality_signals_for_crop(
438+
page,
439+
kind: str,
440+
bbox: tuple[float, float, float, float],
441+
caption_anchor: dict,
442+
page_rect,
443+
*,
444+
table_body_rows: int,
445+
) -> dict:
446+
page_area = _rect_area((page_rect.x0, page_rect.y0, page_rect.x1, page_rect.y1))
447+
page_coverage_ratio = _rect_area(bbox) / page_area if page_area > 0 else 0.0
448+
visual_rect_count, visual_body_ratio = _visual_signal_for_bbox(page, bbox)
449+
caption_bbox = tuple(caption_anchor["bbox"])
450+
paragraph_text_chars = _count_paragraph_text_chars_in_bbox(page, bbox, caption_bbox)
451+
caption_text_chars = len(normalize_whitespace(str(caption_anchor.get("line_text", ""))))
452+
return _classify_visual_quality(
453+
kind=kind,
454+
page_coverage_ratio=page_coverage_ratio,
455+
visual_rect_count=visual_rect_count,
456+
visual_body_ratio=visual_body_ratio,
457+
paragraph_text_chars=paragraph_text_chars,
458+
table_body_rows=table_body_rows,
459+
caption_text_chars=caption_text_chars,
460+
)
461+
462+
311463
def _clip_to_page(
312464
bbox: tuple[float, float, float, float],
313465
page_rect,
@@ -594,6 +746,17 @@ def _estimate_table_bbox(
594746
next_anchor: dict | None,
595747
page_rect,
596748
) -> tuple[float, float, float, float] | None:
749+
result = _estimate_table_bbox_with_rows(page, caption_anchor, prev_anchor, next_anchor, page_rect)
750+
return result[0] if result is not None else None
751+
752+
753+
def _estimate_table_bbox_with_rows(
754+
page,
755+
caption_anchor: dict,
756+
prev_anchor: dict | None,
757+
next_anchor: dict | None,
758+
page_rect,
759+
) -> tuple[tuple[float, float, float, float], int] | None:
597760
r"""Estimate the bounding box of a table.
598761
599762
Tables in academic papers come in two layouts:
@@ -651,12 +814,18 @@ def _estimate_table_bbox(
651814
return None
652815

653816
chosen: list[tuple[float, float, float, float]]
817+
chosen_data_rows: int
654818
if up_data > down_data:
655819
chosen = up_lines
820+
chosen_data_rows = up_data
656821
else:
657822
chosen = down_lines
823+
chosen_data_rows = down_data
658824

659-
return _finalize_table_bbox(page, caption_anchor, chosen, page_rect)
825+
bbox = _finalize_table_bbox(page, caption_anchor, chosen, page_rect)
826+
if bbox is None:
827+
return None
828+
return bbox, chosen_data_rows
660829

661830

662831
def _render_crop(page, bbox: tuple[float, float, float, float], dpi: int) -> bytes:
@@ -688,8 +857,13 @@ def extract_figure_regions(
688857
kind = anchor.get("kind", "figure")
689858

690859
bbox: tuple[float, float, float, float] | None
860+
table_body_rows = 0
691861
if kind == "table":
692-
bbox = _estimate_table_bbox(page, anchor, prev_anchor, next_anchor, page_rect)
862+
table_result = _estimate_table_bbox_with_rows(page, anchor, prev_anchor, next_anchor, page_rect)
863+
if table_result is not None:
864+
bbox, table_body_rows = table_result
865+
else:
866+
bbox = None
693867
if bbox is None:
694868
# Fall back to the figure-shape estimator in case the table is
695869
# actually rendered as an embedded image.
@@ -716,6 +890,14 @@ def extract_figure_regions(
716890

717891
width_px = int((bbox[2] - bbox[0]) * dpi / 72.0)
718892
height_px = int((bbox[3] - bbox[1]) * dpi / 72.0)
893+
quality_signals = _quality_signals_for_crop(
894+
page,
895+
kind,
896+
bbox,
897+
anchor,
898+
page_rect,
899+
table_body_rows=table_body_rows,
900+
)
719901

720902
assets.append(
721903
{
@@ -731,6 +913,7 @@ def extract_figure_regions(
731913
"bbox_pt": list(bbox),
732914
"size_bytes": len(png_bytes),
733915
"extraction_level": "figure",
916+
"quality_signals": quality_signals,
734917
}
735918
)
736919

0 commit comments

Comments
 (0)