mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
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.
382 lines
13 KiB
Python
382 lines
13 KiB
Python
"""
|
|
Scan all 739 papers in the ocr vault for figure caption patterns.
|
|
Count each format, cross-reference against figure-map.json inventory,
|
|
and report matched/unmatched rates per pattern.
|
|
|
|
Output: scripts/dev/caption_pattern_analysis.json
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
import traceback
|
|
from pathlib import Path
|
|
from collections import defaultdict, Counter
|
|
|
|
OCR_ROOT = Path("D:/L/OB/Literature-hub/System/PaperForge/ocr")
|
|
|
|
# Patterns for classification
|
|
# Order matters: more specific patterns first
|
|
PATTERNS = [
|
|
# ---- Standard numeric ----
|
|
("figure_n_numeric", re.compile(
|
|
r'\b(?:Figure|Fig\.?)\s+(\d+)\b', re.IGNORECASE
|
|
)),
|
|
# ---- Supplementary patterns ----
|
|
("figure_s_numeric", re.compile(
|
|
r'\b(?:Figure|Fig\.?)\s+S(\d+)\b', re.IGNORECASE
|
|
)),
|
|
("suppl_figure", re.compile(
|
|
r'\b(?:Supplementary|Supplemental|Suppl\.?)\s+(?:Figure|Fig\.?)\s*(\d*)\b', re.IGNORECASE
|
|
)),
|
|
# ---- Appendix patterns ----
|
|
("figure_appendix_letter_number", re.compile(
|
|
r'\b(?:Figure|Fig\.?)\s+([A-Z])(\d+)\b'
|
|
)),
|
|
("appendix_figure", re.compile(
|
|
r'\b(?:Appendix|Appx\.?|App\.?)\s+(?:Figure|Fig\.?)\s*([A-Z]?\d*)\b', re.IGNORECASE
|
|
)),
|
|
# ---- Letter-only (no number) ----
|
|
("figure_letter_only", re.compile(
|
|
r'\b(?:Figure|Fig\.?)\s+([A-Z])\b(?!\s*\d)'
|
|
)),
|
|
# ---- Figure with alpha suffix (e.g. Figure 1A, Figure 1a) ----
|
|
("figure_numeric_alpha", re.compile(
|
|
r'\b(?:Figure|Fig\.?)\s+(\d+)([A-Za-z])\b', re.IGNORECASE
|
|
)),
|
|
# ---- Box (not exactly a figure but some papers use it) ----
|
|
("box_pattern", re.compile(
|
|
r'\bBox\s+(\d+)\b', re.IGNORECASE
|
|
)),
|
|
]
|
|
|
|
# We'll classify captions into these categories
|
|
CATEGORIES = [
|
|
"standard_numeric", # Figure 1, Fig. 1, Fig 1
|
|
"supplementary", # Figure S1, Supplementary Figure 1, Suppl. Fig 1
|
|
"appendix", # Figure A1, Appendix Figure 1
|
|
"letter_only", # Figure A, Fig. B
|
|
"numeric_alpha", # Figure 1A, Figure 2b
|
|
"box", # Box 1
|
|
"unclassified", # No figure pattern matched
|
|
]
|
|
|
|
|
|
def classify_caption(text):
|
|
"""Classify a caption text into one of the categories."""
|
|
if not text or not text.strip():
|
|
return "unclassified", None
|
|
|
|
stripped = text.strip()
|
|
|
|
# Try patterns in order; first match wins
|
|
for cat, pat in PATTERNS:
|
|
m = pat.search(stripped)
|
|
if m:
|
|
if cat == "figure_n_numeric":
|
|
return "standard_numeric", m.group(1)
|
|
elif cat in ("figure_s_numeric", "suppl_figure"):
|
|
return "supplementary", m.group(0)
|
|
elif cat in ("figure_appendix_letter_number", "appendix_figure"):
|
|
return "appendix", m.group(0)
|
|
elif cat == "figure_letter_only":
|
|
return "letter_only", m.group(1)
|
|
elif cat == "figure_numeric_alpha":
|
|
return "numeric_alpha", m.group(0)
|
|
elif cat == "box_pattern":
|
|
return "box", m.group(0)
|
|
|
|
return "unclassified", None
|
|
|
|
|
|
def extract_figure_number(text):
|
|
"""Extract best-effort figure label (e.g. '1', 'S1', 'A1', 'A') from text."""
|
|
if not text:
|
|
return None
|
|
stripped = text.strip()
|
|
|
|
# Figure S1 or Supplementary Figure 1
|
|
m = re.search(r'\b(?:Figure|Fig\.?)\s+S(\d+)\b', stripped, re.IGNORECASE)
|
|
if m:
|
|
return f"S{m.group(1)}"
|
|
|
|
m = re.search(r'\b(?:Supplementary|Supplemental|Suppl\.?)\s+(?:Figure|Fig\.?)\s*(\d+)', stripped, re.IGNORECASE)
|
|
if m:
|
|
return f"S{m.group(1)}"
|
|
|
|
# Figure A1 (letter + number)
|
|
m = re.search(r'\b(?:Figure|Fig\.?)\s+([A-Z])(\d+)\b', stripped)
|
|
if m:
|
|
return f"{m.group(1)}{m.group(2)}"
|
|
|
|
# Figure 1A (number + alpha suffix)
|
|
m = re.search(r'\b(?:Figure|Fig\.?)\s+(\d+)([A-Za-z])\b', stripped, re.IGNORECASE)
|
|
if m:
|
|
return f"{m.group(1)}{m.group(2).upper()}"
|
|
|
|
# Figure N (plain number)
|
|
m = re.search(r'\b(?:Figure|Fig\.?)\s+(\d+)\b', stripped, re.IGNORECASE)
|
|
if m:
|
|
return m.group(1)
|
|
|
|
# Letter only
|
|
m = re.search(r'\b(?:Figure|Fig\.?)\s+([A-Z])\b(?!\s*\d)', stripped)
|
|
if m:
|
|
return m.group(1)
|
|
|
|
return None
|
|
|
|
|
|
def load_figure_map(paper_dir):
|
|
"""Load figure-map.json if it exists. Returns list of figure entries or None."""
|
|
figmap_path = paper_dir / "figure-map.json"
|
|
if not figmap_path.exists():
|
|
return None
|
|
try:
|
|
with open(figmap_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
entries = []
|
|
for fig in data.get("figures", []):
|
|
entries.append({
|
|
"number": fig.get("number"),
|
|
"label": fig.get("label", ""),
|
|
"type": fig.get("type", "main_figure"),
|
|
})
|
|
for fig in data.get("supplementary_figures", []):
|
|
entries.append({
|
|
"number": fig.get("number"),
|
|
"label": fig.get("label", ""),
|
|
"type": "supplementary_figure",
|
|
})
|
|
return entries
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def figure_map_contains(fig_map_entries, fig_number):
|
|
"""Check if a figure number exists in the figure map entries."""
|
|
if not fig_map_entries or not fig_number:
|
|
return False
|
|
for entry in fig_map_entries:
|
|
if entry["number"] == fig_number:
|
|
return True
|
|
# Also match by label
|
|
label_num = re.search(r'(\d+|[A-Z]\d*)$', entry.get("label", ""))
|
|
if label_num and label_num.group(1) == fig_number:
|
|
return True
|
|
return False
|
|
|
|
|
|
def process_paper(paper_dir):
|
|
"""Process a single paper's blocks.structured.jsonl."""
|
|
blocks_path = paper_dir / "structure" / "blocks.structured.jsonl"
|
|
if not blocks_path.exists():
|
|
return None
|
|
|
|
paper_id = paper_dir.name
|
|
captions = []
|
|
fig_map = load_figure_map(paper_dir)
|
|
|
|
try:
|
|
with open(blocks_path, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
block = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
role = block.get("role")
|
|
if role not in ("figure_caption", "figure_caption_candidate"):
|
|
continue
|
|
|
|
text = block.get("text", "")
|
|
category, matched_pattern = classify_caption(text)
|
|
fig_number = extract_figure_number(text)
|
|
in_inventory = figure_map_contains(fig_map, fig_number) if fig_map else None
|
|
|
|
captions.append({
|
|
"paper_id": paper_id,
|
|
"role": role,
|
|
"text_preview": text[:120],
|
|
"category": category,
|
|
"fig_number": fig_number,
|
|
"fig_map_exists": fig_map is not None,
|
|
"in_inventory": in_inventory,
|
|
})
|
|
except Exception as e:
|
|
print(f" [WARN] Error reading {paper_id}: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
return captions
|
|
|
|
|
|
def main():
|
|
paper_dirs = sorted(OCR_ROOT.glob("*/structure/blocks.structured.jsonl"))
|
|
total_papers = len(paper_dirs)
|
|
print(f"Found {total_papers} papers with blocks.structured.jsonl", file=sys.stderr)
|
|
|
|
all_captions = []
|
|
papers_with_captions = 0
|
|
papers_with_map = 0
|
|
papers_with_both = 0
|
|
errors = 0
|
|
|
|
for i, blocks_path in enumerate(paper_dirs):
|
|
paper_dir = blocks_path.parent.parent
|
|
paper_id = paper_dir.name
|
|
|
|
if (i + 1) % 50 == 0:
|
|
print(f" Progress: {i+1}/{total_papers}...", file=sys.stderr)
|
|
|
|
result = process_paper(paper_dir)
|
|
if result is None:
|
|
errors += 1
|
|
continue
|
|
|
|
if result:
|
|
papers_with_captions += 1
|
|
all_captions.extend(result)
|
|
|
|
# Check if figure-map exists
|
|
fig_map_path = paper_dir / "figure-map.json"
|
|
if fig_map_path.exists():
|
|
papers_with_map += 1
|
|
|
|
print(f"\nProcessed: {total_papers} papers", file=sys.stderr)
|
|
print(f" Papers with captions: {papers_with_captions}", file=sys.stderr)
|
|
print(f" Papers with figure-map.json: {papers_with_map}", file=sys.stderr)
|
|
print(f" Total caption blocks: {len(all_captions)}", file=sys.stderr)
|
|
print(f" Errors: {errors}", file=sys.stderr)
|
|
|
|
# ---- Analysis ----
|
|
|
|
# 1. Distribution by category
|
|
cat_counts = Counter(c["category"] for c in all_captions)
|
|
cat_with_map = Counter(c["category"] for c in all_captions if c["fig_map_exists"])
|
|
|
|
# 2. Per-category: matched vs unmatched in inventory
|
|
# Only count captions from papers that HAVE a figure-map
|
|
cat_matched = Counter()
|
|
cat_unmatched = Counter()
|
|
cat_without_map = Counter()
|
|
for c in all_captions:
|
|
cat = c["category"]
|
|
if c["fig_map_exists"]:
|
|
if c["in_inventory"] is True:
|
|
cat_matched[cat] += 1
|
|
elif c["in_inventory"] is False:
|
|
cat_unmatched[cat] += 1
|
|
else:
|
|
cat_without_map[cat] += 1 # shouldn't happen since fig_map_exists is True
|
|
else:
|
|
cat_without_map[cat] += 1
|
|
|
|
# 3. Detailed breakdown by role
|
|
role_cat_counts = defaultdict(Counter)
|
|
for c in all_captions:
|
|
role_cat_counts[c["role"]][c["category"]] += 1
|
|
|
|
# 4. Collect examples for each pattern
|
|
examples_by_cat = defaultdict(list)
|
|
for c in all_captions:
|
|
cat = c["category"]
|
|
if len(examples_by_cat[cat]) < 5:
|
|
examples_by_cat[cat].append(c["text_preview"])
|
|
|
|
# 5. Detailed unmatched analysis
|
|
unmatched_by_cat = defaultdict(list)
|
|
for c in all_captions:
|
|
if c["fig_map_exists"] and c["in_inventory"] is False:
|
|
unmatched_by_cat[c["category"]].append({
|
|
"paper_id": c["paper_id"],
|
|
"text_preview": c["text_preview"],
|
|
"fig_number": c["fig_number"],
|
|
})
|
|
|
|
# Build result
|
|
total_with_map = sum(cat_counts.get(c, 0) for c in CATEGORIES if c in cat_matched or c in cat_unmatched)
|
|
|
|
distribution = {}
|
|
for cat in CATEGORIES:
|
|
total = cat_counts.get(cat, 0)
|
|
matched = cat_matched.get(cat, 0)
|
|
unmatched = cat_unmatched.get(cat, 0)
|
|
no_map = cat_without_map.get(cat, 0)
|
|
with_map = matched + unmatched
|
|
distribution[cat] = {
|
|
"total": total,
|
|
"with_figure_map": with_map,
|
|
"matched_in_inventory": matched,
|
|
"unmatched_in_inventory": unmatched,
|
|
"no_figure_map": no_map,
|
|
"unmatched_rate": round(unmatched / with_map, 4) if with_map > 0 else None,
|
|
}
|
|
|
|
result = {
|
|
"summary": {
|
|
"total_papers": total_papers,
|
|
"papers_with_captions": papers_with_captions,
|
|
"papers_with_figure_map": papers_with_map,
|
|
"total_caption_blocks": len(all_captions),
|
|
"errors": errors,
|
|
},
|
|
"caption_role_breakdown": {
|
|
role: dict(counts) for role, counts in sorted(role_cat_counts.items())
|
|
},
|
|
"pattern_distribution": distribution,
|
|
"examples_by_category": dict(examples_by_cat),
|
|
"unmatched_details": {
|
|
cat: entries[:20] for cat, entries in sorted(unmatched_by_cat.items())
|
|
if entries
|
|
},
|
|
}
|
|
|
|
output_path = Path("scripts/dev/caption_pattern_analysis.json")
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
json.dump(result, f, indent=2, ensure_ascii=False)
|
|
|
|
print(f"\nResults saved to {output_path}", file=sys.stderr)
|
|
|
|
# ---- Print summary to stdout ----
|
|
print("\n" + "=" * 70)
|
|
print(" CAPTION PATTERN ANALYSIS RESULTS")
|
|
print("=" * 70)
|
|
print(f" Papers scanned: {total_papers}")
|
|
print(f" Papers w/ captions: {papers_with_captions}")
|
|
print(f" Papers w/ figure-map:{papers_with_map}")
|
|
print(f" Total caption blocks: {len(all_captions)}")
|
|
print(f" Errors: {errors}")
|
|
print()
|
|
|
|
# Role breakdown
|
|
print(" --- Role Breakdown ---")
|
|
for role, counts in sorted(role_cat_counts.items()):
|
|
total_role = sum(counts.values())
|
|
print(f" {role}: {total_role}")
|
|
for cat, cnt in sorted(counts.items()):
|
|
print(f" {cat}: {cnt}")
|
|
print()
|
|
|
|
# Pattern distribution
|
|
print(" --- Pattern Distribution & Unmatched Rate ---")
|
|
print(f" {'Category':<25} {'Total':>8} {'WithMap':>8} {'Matched':>8} {'Unmatched':>8} {'UnmatchRate':>12}")
|
|
print(f" {'-'*25} {'-'*8} {'-'*8} {'-'*8} {'-'*8} {'-'*12}")
|
|
for cat in CATEGORIES:
|
|
d = distribution[cat]
|
|
rate_str = f"{d['unmatched_rate']*100:.1f}%" if d['unmatched_rate'] is not None else "N/A"
|
|
print(f" {cat:<25} {d['total']:>8} {d['with_figure_map']:>8} {d['matched_in_inventory']:>8} {d['unmatched_in_inventory']:>8} {rate_str:>12}")
|
|
|
|
# Extra: unclassified examples
|
|
if examples_by_cat.get("unclassified"):
|
|
print(f"\n --- Sample Unclassified Captions (first 5) ---")
|
|
for ex in examples_by_cat["unclassified"][:5]:
|
|
print(f" \"{ex}\"")
|
|
|
|
print("=" * 70)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|