mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
feat(ocr): journal layout generalization, OCR diagnostics, and debug scripts
This commit is contained in:
parent
f9b5fb0541
commit
c235dbdc68
11 changed files with 1872 additions and 525 deletions
50
_check_effect.py
Normal file
50
_check_effect.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
json_path = Path("D:/L/OB/Literature-hub/System/PaperForge/ocr/7C8829BD/json/result.json")
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
|
||||
pageno = 0
|
||||
for payload in data:
|
||||
for res in payload.get("layoutParsingResults", []):
|
||||
pageno += 1
|
||||
if pageno != 7:
|
||||
continue
|
||||
blocks = res.get("prunedResult", {}).get("parsing_res_list", [])
|
||||
|
||||
from paperforge.worker.ocr import block_sort_key, validate_block_order, is_embedded_figure_text_block
|
||||
from paperforge.worker.ocr_orchestrator import reorder_blocks_layered
|
||||
|
||||
sorted_blocks = sorted(blocks, key=block_sort_key)
|
||||
validated = validate_block_order(sorted_blocks, 1191)
|
||||
|
||||
print("=== is_embedded_figure_text_block on VALIDATED blocks ===")
|
||||
for b in validated:
|
||||
lbl = b.get("block_label", "")
|
||||
if lbl == "paragraph_title":
|
||||
result = is_embedded_figure_text_block(b, validated, page_width=1191, page_height=1684)
|
||||
txt = b.get("block_content", "")[:60]
|
||||
print(f" [{txt}] => embedded={result}")
|
||||
|
||||
layered = reorder_blocks_layered(validated, page_width=1191, page_height=1684)
|
||||
|
||||
print()
|
||||
print("=== is_embedded_figure_text_block on LAYERED blocks ===")
|
||||
for b in layered:
|
||||
lbl = b.get("block_label", "")
|
||||
if lbl == "paragraph_title":
|
||||
result = is_embedded_figure_text_block(b, layered, page_width=1191, page_height=1684)
|
||||
txt = b.get("block_content", "")[:60]
|
||||
print(f" [{txt}] => embedded={result}")
|
||||
|
||||
print()
|
||||
print("=== Role assignment ===")
|
||||
from paperforge.worker.ocr_roles import assign_block_role
|
||||
for b in validated:
|
||||
lbl = b.get("block_label", "")
|
||||
if lbl == "paragraph_title":
|
||||
role = assign_block_role(b, validated, page_width=1191, page_height=1684)
|
||||
txt = b.get("block_content", "")[:60]
|
||||
print(f" [{txt}] => role={role.role} conf={role.confidence}")
|
||||
break
|
||||
break
|
||||
52
_extract_master_figs.py
Normal file
52
_extract_master_figs.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Extract figure/clustering functions from origin/master ocr.py."""
|
||||
import subprocess, re, sys
|
||||
|
||||
raw = subprocess.run(
|
||||
["git", "show", "origin/master:paperforge/worker/ocr.py"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace"
|
||||
).stdout
|
||||
|
||||
func_starts = [
|
||||
"def clean_block_text", "def is_subfigure_label",
|
||||
"def media_clusters", "def _bbox_width", "def _bbox_height",
|
||||
"def _bbox_horizontal_overlap", "def _bbox_vertical_overlap",
|
||||
"def _bbox_horizontal_overlap_ratio", "def _bbox_center_x",
|
||||
"def _bbox_center_y", "def _cluster_bbox", "def _union_bboxes",
|
||||
"def is_formal_figure_legend", "def is_numbered_figure_caption",
|
||||
"def _figure_caption_blocks", "def estimate_body_column_width",
|
||||
"def is_body_paragraph_like_text_block",
|
||||
"def _precaption_media_region", "def compute_precaption_composite_regions",
|
||||
"def is_embedded_figure_text_block",
|
||||
]
|
||||
|
||||
lines = raw.split("\n")
|
||||
|
||||
# Find line numbers for each function
|
||||
func_lines = {}
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
for fname in func_starts:
|
||||
if stripped.startswith(fname) and fname not in func_lines:
|
||||
func_lines[fname] = i
|
||||
|
||||
# Extract function bodies (from def to next def at same indent level)
|
||||
extracted = {}
|
||||
for fname, start_line in func_lines.items():
|
||||
# Find the indentation of the def
|
||||
orig_indent = len(lines[start_line]) - len(lines[start_line].lstrip())
|
||||
# Read until next top-level def
|
||||
end_line = len(lines)
|
||||
for j in range(start_line + 1, len(lines)):
|
||||
ls = lines[j]
|
||||
if ls.strip() and not ls.startswith("#") and not ls.startswith(" ") and not ls.startswith("\n"):
|
||||
curr_indent = len(ls) - len(ls.lstrip())
|
||||
if curr_indent <= orig_indent and not ls.startswith(" "):
|
||||
end_line = j
|
||||
break
|
||||
extracted[fname] = "\n".join(lines[start_line:end_line])
|
||||
|
||||
# Print all extracted functions
|
||||
for fname in sorted(extracted.keys()):
|
||||
print(f"=== {fname} ===")
|
||||
print(extracted[fname])
|
||||
print()
|
||||
468
_master_funcs.txt
Normal file
468
_master_funcs.txt
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
=== def _bbox_center_x ===
|
||||
def _bbox_center_x(bbox: list[int]) -> float:
|
||||
return (int(bbox[0]) + int(bbox[2])) / 2
|
||||
|
||||
|
||||
|
||||
=== def _bbox_center_y ===
|
||||
def _bbox_center_y(bbox: list[int]) -> float:
|
||||
return (int(bbox[1]) + int(bbox[3])) / 2
|
||||
|
||||
|
||||
|
||||
=== def _bbox_height ===
|
||||
def _bbox_height(bbox: list[int]) -> int:
|
||||
return max(0, int(bbox[3]) - int(bbox[1]))
|
||||
|
||||
|
||||
|
||||
=== def _bbox_horizontal_overlap ===
|
||||
def _bbox_horizontal_overlap(a: list[int], b: list[int]) -> int:
|
||||
return max(0, min(int(a[2]), int(b[2])) - max(int(a[0]), int(b[0])))
|
||||
|
||||
|
||||
|
||||
=== def _bbox_horizontal_overlap_ratio ===
|
||||
def _bbox_horizontal_overlap_ratio(a: list[int], b: list[int]) -> float:
|
||||
width = min(_bbox_width(a), _bbox_width(b))
|
||||
if width <= 0:
|
||||
return 0.0
|
||||
return _bbox_horizontal_overlap(a, b) / width
|
||||
|
||||
|
||||
|
||||
=== def _bbox_vertical_overlap ===
|
||||
def _bbox_vertical_overlap(a: list[int], b: list[int]) -> int:
|
||||
return max(0, min(int(a[3]), int(b[3])) - max(int(a[1]), int(b[1])))
|
||||
|
||||
|
||||
|
||||
=== def _bbox_width ===
|
||||
def _bbox_width(bbox: list[int]) -> int:
|
||||
return max(0, int(bbox[2]) - int(bbox[0]))
|
||||
|
||||
|
||||
|
||||
=== def _cluster_bbox ===
|
||||
def _cluster_bbox(cluster: list[dict]) -> list[int]:
|
||||
return [
|
||||
min(int(item["block_bbox"][0]) for item in cluster),
|
||||
min(int(item["block_bbox"][1]) for item in cluster),
|
||||
max(int(item["block_bbox"][2]) for item in cluster),
|
||||
max(int(item["block_bbox"][3]) for item in cluster),
|
||||
]
|
||||
|
||||
|
||||
|
||||
=== def _figure_caption_blocks ===
|
||||
def _figure_caption_blocks(blocks: list[dict]) -> list[dict]:
|
||||
captions = []
|
||||
for block in blocks:
|
||||
if block.get("block_label") not in {"figure_title", "paragraph_title", "text"}:
|
||||
continue
|
||||
text = clean_block_text(block.get("block_content", ""))
|
||||
if is_formal_figure_legend(text):
|
||||
captions.append(block)
|
||||
return captions
|
||||
|
||||
|
||||
|
||||
=== def _precaption_media_region ===
|
||||
def _precaption_media_region(
|
||||
caption_bbox: list[int],
|
||||
cluster_bboxes: list[list[int]],
|
||||
blocks: list[dict] | None = None,
|
||||
body_column_width: int = 0,
|
||||
|
||||
=== def _union_bboxes ===
|
||||
def _union_bboxes(bbox_list: list[list[int]]) -> list[int]:
|
||||
return [
|
||||
min(int(bbox[0]) for bbox in bbox_list),
|
||||
min(int(bbox[1]) for bbox in bbox_list),
|
||||
max(int(bbox[2]) for bbox in bbox_list),
|
||||
max(int(bbox[3]) for bbox in bbox_list),
|
||||
]
|
||||
|
||||
|
||||
|
||||
=== def clean_block_text ===
|
||||
def clean_block_text(text: str) -> str:
|
||||
text = html.unescape(normalize_obsidian_markdown(text)).strip()
|
||||
return text
|
||||
|
||||
|
||||
|
||||
=== def compute_precaption_composite_regions ===
|
||||
def compute_precaption_composite_regions(blocks: list[dict], page_width: int = 0, page_height: int = 0) -> list[dict]:
|
||||
caption_blocks = _figure_caption_blocks(blocks)
|
||||
_, clusters = media_clusters(blocks)
|
||||
cluster_bboxes = [_cluster_bbox(cluster) for cluster in clusters]
|
||||
body_column_width = estimate_body_column_width(blocks, page_width=page_width)
|
||||
regions: list[dict] = []
|
||||
for caption in caption_blocks:
|
||||
caption_bbox = [int(value) for value in caption.get("block_bbox", [0, 0, 0, 0])]
|
||||
precaption_region = _precaption_media_region(
|
||||
caption_bbox, cluster_bboxes, blocks=blocks, body_column_width=body_column_width
|
||||
)
|
||||
if not precaption_region:
|
||||
continue
|
||||
region_blocks = []
|
||||
for block in blocks:
|
||||
label = block.get("block_label", "")
|
||||
bbox = [int(value) for value in block.get("block_bbox", [0, 0, 0, 0])]
|
||||
if bbox[3] > caption_bbox[1] + 24:
|
||||
continue
|
||||
if bbox[3] < precaption_region[1] - 240:
|
||||
continue
|
||||
vertical_overlap_with_region = _bbox_vertical_overlap(bbox, precaption_region)
|
||||
near_region_side = vertical_overlap_with_region > 0 and (
|
||||
0 <= precaption_region[0] - bbox[2] <= 80 or 0 <= bbox[0] - precaption_region[2] <= 80
|
||||
)
|
||||
intersects_region = (
|
||||
_bbox_horizontal_overlap(bbox, precaption_region) > 0
|
||||
or precaption_region[0] - 24 <= _bbox_center_x(bbox) <= precaption_region[2] + 24
|
||||
or near_region_side
|
||||
)
|
||||
if not intersects_region:
|
||||
continue
|
||||
if label in {"image", "chart"}:
|
||||
if _bbox_vertical_overlap(bbox, precaption_region) <= 0 and bbox[3] < precaption_region[1] - 80:
|
||||
continue
|
||||
region_blocks.append(block)
|
||||
continue
|
||||
if label in {"text", "paragraph_title"}:
|
||||
if bbox[3] < precaption_region[1] - 80:
|
||||
continue
|
||||
width = _bbox_width(bbox)
|
||||
text = clean_block_text(block.get("block_content", ""))
|
||||
if is_body_paragraph_like_text_block(
|
||||
block,
|
||||
body_column_width=body_column_width,
|
||||
cluster_bboxes=cluster_bboxes,
|
||||
caption_bbox=caption_bbox,
|
||||
):
|
||||
continue
|
||||
if (
|
||||
text
|
||||
and (
|
||||
not re.match(
|
||||
"^(?:Extended\\s+Data\\s+Fig\\.?|Extended\\s+Data\\s+Figure|Figure|Fig\\.?|Table)\\s+\\w+",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
)
|
||||
and (
|
||||
width <= int(max(body_column_width, 1) * 0.78)
|
||||
or is_embedded_figure_text_block(block, blocks, page_width=page_width, page_height=page_height)
|
||||
)
|
||||
):
|
||||
region_blocks.append(block)
|
||||
media_ids = {block.get("block_id") for block in region_blocks if block.get("block_label") in {"image", "chart"}}
|
||||
text_ids = {
|
||||
block.get("block_id") for block in region_blocks if block.get("block_label") in {"text", "paragraph_title"}
|
||||
}
|
||||
if len(media_ids) < 1 or not text_ids or len(region_blocks) < 3:
|
||||
continue
|
||||
region_bbox = [
|
||||
min(int(block["block_bbox"][0]) for block in region_blocks),
|
||||
min(int(block["block_bbox"][1]) for block in region_blocks),
|
||||
max(int(block["block_bbox"][2]) for block in region_blocks),
|
||||
max(int(block["block_bbox"][3]) for block in region_blocks),
|
||||
]
|
||||
regions.append(
|
||||
{
|
||||
"bbox": region_bbox,
|
||||
"block_ids": {block.get("block_id") for block in region_blocks},
|
||||
"caption_block_id": caption.get("block_id"),
|
||||
}
|
||||
)
|
||||
return regions
|
||||
|
||||
|
||||
|
||||
=== def estimate_body_column_width ===
|
||||
def estimate_body_column_width(blocks: list[dict], page_width: int = 0) -> int:
|
||||
widths: list[int] = []
|
||||
for block in blocks:
|
||||
if block.get("block_label") not in {"text", "paragraph_title", "abstract"}:
|
||||
continue
|
||||
text = clean_block_text(block.get("block_content", ""))
|
||||
if (
|
||||
not text
|
||||
or is_subfigure_label(text)
|
||||
or re.match("^(?:Figure|Fig\\.?|Table)\\s+\\w+", text, flags=re.IGNORECASE)
|
||||
):
|
||||
continue
|
||||
bbox = [int(value) for value in block.get("block_bbox", [0, 0, 0, 0])]
|
||||
width = _bbox_width(bbox)
|
||||
height = _bbox_height(bbox)
|
||||
if width <= 0 or height <= 0:
|
||||
continue
|
||||
if page_width and width >= int(page_width * 0.82):
|
||||
widths.append(width)
|
||||
continue
|
||||
if width >= 430:
|
||||
widths.append(width)
|
||||
if not widths:
|
||||
return int(page_width * 0.45) if page_width else 520
|
||||
widths.sort()
|
||||
return widths[len(widths) // 2]
|
||||
|
||||
|
||||
|
||||
=== def is_body_paragraph_like_text_block ===
|
||||
def is_body_paragraph_like_text_block(
|
||||
block: dict,
|
||||
body_column_width: int = 0,
|
||||
cluster_bboxes: list[list[int]] | None = None,
|
||||
caption_bbox: list[int] | None = None,
|
||||
|
||||
=== def is_embedded_figure_text_block ===
|
||||
def is_embedded_figure_text_block(block: dict, blocks: list[dict], page_width: int = 0, page_height: int = 0) -> bool:
|
||||
label = block.get("block_label", "")
|
||||
if label not in {"text", "paragraph_title"}:
|
||||
return False
|
||||
if label == "paragraph_title":
|
||||
return False
|
||||
text = clean_block_text(block.get("block_content", ""))
|
||||
if not text:
|
||||
return False
|
||||
if is_formal_figure_legend(text):
|
||||
return False
|
||||
bbox = [int(value) for value in block.get("block_bbox", [0, 0, 0, 0])]
|
||||
width = _bbox_width(bbox)
|
||||
height = _bbox_height(bbox)
|
||||
if width <= 0 or height <= 0:
|
||||
return False
|
||||
if label == "paragraph_title" and is_subfigure_label(text):
|
||||
return True
|
||||
caption_blocks = _figure_caption_blocks(blocks)
|
||||
cluster_index, clusters = media_clusters(blocks)
|
||||
cluster_bboxes = [_cluster_bbox(cluster) for cluster in clusters]
|
||||
body_column_width = estimate_body_column_width(blocks, page_width=page_width)
|
||||
del cluster_index
|
||||
nearest_media = None
|
||||
nearest_media_distance = None
|
||||
close_media_count = 0
|
||||
stacked_media_above = False
|
||||
stacked_media_below = False
|
||||
side_media = False
|
||||
for cluster_bbox in cluster_bboxes:
|
||||
horizontal_ratio = _bbox_horizontal_overlap_ratio(bbox, cluster_bbox)
|
||||
vertical_overlap = _bbox_vertical_overlap(bbox, cluster_bbox)
|
||||
top_gap = int(bbox[1]) - int(cluster_bbox[3])
|
||||
bottom_gap = int(cluster_bbox[1]) - int(bbox[3])
|
||||
center_inside_x = int(cluster_bbox[0]) <= _bbox_center_x(bbox) <= int(cluster_bbox[2])
|
||||
center_inside_y = int(cluster_bbox[1]) <= _bbox_center_y(bbox) <= int(cluster_bbox[3])
|
||||
if (
|
||||
horizontal_ratio >= 0.45
|
||||
and (0 <= top_gap <= 48 or 0 <= bottom_gap <= 48 or vertical_overlap > 0)
|
||||
or (center_inside_x and abs(_bbox_center_y(bbox) - _bbox_center_y(cluster_bbox)) <= max(80, height * 4))
|
||||
):
|
||||
close_media_count += 1
|
||||
if horizontal_ratio >= 0.5 and 0 <= top_gap <= 90:
|
||||
stacked_media_above = True
|
||||
if horizontal_ratio >= 0.5 and 0 <= bottom_gap <= 90:
|
||||
stacked_media_below = True
|
||||
if vertical_overlap > 0 and (
|
||||
0 <= int(bbox[0]) - int(cluster_bbox[2]) <= 60 or 0 <= int(cluster_bbox[0]) - int(bbox[2]) <= 60
|
||||
):
|
||||
side_media = True
|
||||
dx = 0
|
||||
if int(bbox[2]) < int(cluster_bbox[0]):
|
||||
dx = int(cluster_bbox[0]) - int(bbox[2])
|
||||
elif int(cluster_bbox[2]) < int(bbox[0]):
|
||||
dx = int(bbox[0]) - int(cluster_bbox[2])
|
||||
dy = 0
|
||||
if int(bbox[3]) < int(cluster_bbox[1]):
|
||||
dy = int(cluster_bbox[1]) - int(bbox[3])
|
||||
elif int(cluster_bbox[3]) < int(bbox[1]):
|
||||
dy = int(bbox[1]) - int(cluster_bbox[3])
|
||||
distance = dx + dy
|
||||
if nearest_media_distance is None or distance < nearest_media_distance:
|
||||
nearest_media_distance = distance
|
||||
nearest_media = cluster_bbox
|
||||
del center_inside_y
|
||||
nearest_caption_above = None
|
||||
nearest_caption_below = None
|
||||
for caption in caption_blocks:
|
||||
cb = [int(value) for value in caption.get("block_bbox", [0, 0, 0, 0])]
|
||||
if cb[3] <= bbox[1] and (nearest_caption_above is None or cb[3] > nearest_caption_above[3]):
|
||||
nearest_caption_above = cb
|
||||
if cb[1] >= bbox[3] and (nearest_caption_below is None or cb[1] < nearest_caption_below[1]):
|
||||
nearest_caption_below = cb
|
||||
media_between_block_and_caption = 0
|
||||
media_x_covering_block = 0
|
||||
precaption_region = None
|
||||
if nearest_caption_below:
|
||||
precaption_region = _precaption_media_region(nearest_caption_below, cluster_bboxes)
|
||||
for cluster_bbox in cluster_bboxes:
|
||||
if cluster_bbox[1] >= nearest_caption_below[1] + 24:
|
||||
continue
|
||||
if cluster_bbox[3] <= bbox[1] - 24:
|
||||
continue
|
||||
media_between_block_and_caption += 1
|
||||
if (
|
||||
cluster_bbox[0] - 24 <= _bbox_center_x(bbox) <= cluster_bbox[2] + 24
|
||||
or _bbox_horizontal_overlap_ratio(bbox, cluster_bbox) >= 0.35
|
||||
):
|
||||
media_x_covering_block += 1
|
||||
if nearest_caption_above:
|
||||
caption_gap = bbox[1] - nearest_caption_above[3]
|
||||
left_align_gap = abs(bbox[0] - nearest_caption_above[0])
|
||||
effective_page_width = max(page_width, width)
|
||||
if (
|
||||
0 <= caption_gap <= 72
|
||||
and left_align_gap <= 80
|
||||
and (width >= int(effective_page_width * 0.45))
|
||||
and (not stacked_media_above)
|
||||
):
|
||||
return False
|
||||
if is_body_paragraph_like_text_block(
|
||||
block,
|
||||
body_column_width=body_column_width,
|
||||
cluster_bboxes=cluster_bboxes,
|
||||
caption_bbox=nearest_caption_below,
|
||||
):
|
||||
return False
|
||||
score = 0.0
|
||||
if is_subfigure_label(text):
|
||||
score += 4.0
|
||||
if width <= int(max(body_column_width, 1) * 0.78):
|
||||
score += 1.4
|
||||
elif width <= int(max(body_column_width, 1) * 0.9):
|
||||
score += 0.5
|
||||
if label == "paragraph_title" and len(text) <= 24:
|
||||
score += 0.8
|
||||
if height <= 26:
|
||||
score += 0.8
|
||||
elif height <= 34:
|
||||
score += 0.4
|
||||
if page_width and width <= int(page_width * 0.22):
|
||||
score += 0.5
|
||||
if close_media_count >= 2:
|
||||
score += 2.0
|
||||
elif close_media_count == 1:
|
||||
score += 1.1
|
||||
if stacked_media_above and stacked_media_below:
|
||||
score += 2.2
|
||||
elif stacked_media_above or stacked_media_below:
|
||||
score += 0.9
|
||||
if side_media:
|
||||
score += 1.0
|
||||
if nearest_caption_below and nearest_media:
|
||||
caption_gap = nearest_caption_below[1] - bbox[3]
|
||||
media_gap = min(
|
||||
abs(bbox[1] - nearest_media[3]),
|
||||
abs(nearest_media[1] - bbox[3]),
|
||||
abs(_bbox_center_y(bbox) - _bbox_center_y(nearest_media)),
|
||||
)
|
||||
if 0 <= caption_gap <= 120 and media_gap <= 80:
|
||||
score += 0.8
|
||||
if nearest_caption_below:
|
||||
caption_gap = nearest_caption_below[1] - bbox[3]
|
||||
if 0 <= caption_gap <= 520 and media_between_block_and_caption >= 3:
|
||||
score += 0.9
|
||||
if 0 <= caption_gap <= 520 and media_x_covering_block >= 2:
|
||||
score += 1.3
|
||||
if (
|
||||
0 <= caption_gap <= 520
|
||||
and media_x_covering_block >= 1
|
||||
and (width <= int(max(page_width, width) * 0.82))
|
||||
and (height <= 96)
|
||||
):
|
||||
score += 0.8
|
||||
if precaption_region:
|
||||
region_overlap = _bbox_horizontal_overlap_ratio(bbox, precaption_region)
|
||||
within_region_y = (
|
||||
int(precaption_region[1]) - 24 <= int(bbox[1]) <= int(precaption_region[3]) + 24
|
||||
and int(bbox[3]) <= int(nearest_caption_below[1]) + 24
|
||||
)
|
||||
if region_overlap >= 0.55 and within_region_y:
|
||||
score += 1.5
|
||||
if (
|
||||
int(precaption_region[0]) - 24 <= _bbox_center_x(bbox) <= int(precaption_region[2]) + 24
|
||||
and within_region_y
|
||||
and (media_between_block_and_caption >= 2)
|
||||
):
|
||||
score += 1.1
|
||||
if len(text) <= 22:
|
||||
score += 0.15
|
||||
return score >= 2.6
|
||||
|
||||
|
||||
|
||||
=== def is_formal_figure_legend ===
|
||||
def is_formal_figure_legend(text: str) -> bool:
|
||||
cleaned = clean_block_text(text)
|
||||
if not cleaned:
|
||||
return False
|
||||
return bool(
|
||||
re.match(
|
||||
"^(?:Extended\\s+Data\\s+Fig\\.?\\s+\\w+|Extended\\s+Data\\s+Figure\\s+\\w+|Extended\\s+Data\\s+Table\\s+\\w+|Supplementary\\s+Fig\\.?\\s+\\w+|Supplementary\\s+Figure\\s+\\w+|Supplementary\\s+Table\\s+\\w+|Supplementary\\s+Video\\s+\\w+|Figure\\s+\\d+|Fig\\.?\\s+\\d+|Table\\s+\\d+|Scheme\\s+\\w+|Graphical\\s+Abstract(?:\\s*[:|.\\-].*)?)",
|
||||
cleaned,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
=== def is_numbered_figure_caption ===
|
||||
def is_numbered_figure_caption(text: str) -> bool:
|
||||
cleaned = clean_block_text(text)
|
||||
if not re.match(r"^(?:Figure|Fig\.?)\s+\d+\b", cleaned, flags=re.IGNORECASE):
|
||||
return False
|
||||
return not re.match(
|
||||
r"^(?:Figure|Fig\.?)\s+\d+\s+(?:shows?|illustrates?|depicts?|describes?|summarizes?)\b",
|
||||
cleaned,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
=== def is_subfigure_label ===
|
||||
def is_subfigure_label(text: str) -> bool:
|
||||
compact = re.sub("\\s+", " ", text.strip().lower())
|
||||
return bool(
|
||||
re.fullmatch("(?:\\([a-z]\\)\\s*)+", compact)
|
||||
or re.fullmatch("[a-z]", compact)
|
||||
or re.fullmatch("[a-z]\\)", compact)
|
||||
)
|
||||
|
||||
|
||||
|
||||
=== def media_clusters ===
|
||||
def media_clusters(blocks: list[dict]) -> tuple[dict[int, int], list[list[dict]]]:
|
||||
media = [b for b in blocks if b.get("block_label") in {"image", "chart"}]
|
||||
clusters: list[list[dict]] = []
|
||||
block_to_cluster: dict[int, int] = {}
|
||||
for block in media:
|
||||
x1, y1, x2, y2 = block.get("block_bbox", [0, 0, 0, 0])
|
||||
assigned = None
|
||||
for idx, cluster in enumerate(clusters):
|
||||
cx1 = min(item["block_bbox"][0] for item in cluster) - 40
|
||||
cy1 = min(item["block_bbox"][1] for item in cluster) - 40
|
||||
cx2 = max(item["block_bbox"][2] for item in cluster) + 40
|
||||
cy2 = max(item["block_bbox"][3] for item in cluster) + 40
|
||||
vertical_overlap = max(0, min(y2, cy2) - max(y1, cy1))
|
||||
min_height = max(1, min(y2 - y1, cy2 - cy1))
|
||||
if x2 < cx1:
|
||||
horizontal_gap = cx1 - x2
|
||||
elif x1 > cx2:
|
||||
horizontal_gap = x1 - cx2
|
||||
else:
|
||||
horizontal_gap = 0
|
||||
overlaps_cluster = not (x2 < cx1 or x1 > cx2 or y2 < cy1 or y1 > cy2)
|
||||
side_by_side_panel = vertical_overlap >= int(min_height * 0.35) and horizontal_gap <= 60
|
||||
if overlaps_cluster or side_by_side_panel:
|
||||
assigned = idx
|
||||
break
|
||||
if assigned is None:
|
||||
assigned = len(clusters)
|
||||
clusters.append([])
|
||||
clusters[assigned].append(block)
|
||||
block_to_cluster[block.get("block_id", -1)] = assigned
|
||||
return (block_to_cluster, clusters)
|
||||
|
||||
|
||||
|
||||
47
_render_full.py
Normal file
47
_render_full.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import json, sys, os
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, "D:/L/Med/Research/99_System/LiteraturePipeline/ocr-reading-order-layers")
|
||||
from paperforge.worker.ocr import render_page_blocks, block_sort_key, validate_block_order
|
||||
|
||||
json_path = Path("D:/L/OB/Literature-hub/System/PaperForge/ocr/7C8829BD/json/result.json")
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
|
||||
vault = Path("D:/L/OB/Literature-hub")
|
||||
images_dir = Path("D:/L/OB/Literature-hub/System/PaperForge/ocr/7C8829BD/images")
|
||||
page_cache_dir = Path("D:/L/OB/Literature-hub/System/PaperForge/ocr/7C8829BD/pages")
|
||||
|
||||
pageno = 0
|
||||
all_lines = []
|
||||
|
||||
for payload in data:
|
||||
for res in payload.get("layoutParsingResults", []):
|
||||
pageno += 1
|
||||
pruned = res.get("prunedResult", {})
|
||||
blocks = pruned.get("parsing_res_list", [])
|
||||
if not blocks:
|
||||
continue
|
||||
try:
|
||||
rendered = render_page_blocks(vault, pageno, res, images_dir, page_cache_dir, pdf_doc=None)
|
||||
all_lines.extend(rendered)
|
||||
except Exception as e:
|
||||
all_lines.append(f"<!-- page {pageno} ERROR: {e} -->")
|
||||
|
||||
output = "\n\n".join(all_lines)
|
||||
out_path = Path(os.environ.get("TEMP", "/tmp")) / "7c8829bd_layered_fulltext.md"
|
||||
out_path.write_text(output, encoding="utf-8")
|
||||
|
||||
# Stats
|
||||
heading_count = output.count("### ")
|
||||
page_count = output.count("<!-- page ")
|
||||
print(f"Pages: {page_count}")
|
||||
print(f"Headings: {heading_count}")
|
||||
print(f"Output: {out_path}")
|
||||
print(f"Size: {len(output)} chars")
|
||||
|
||||
# Show all headings
|
||||
import re
|
||||
headings = re.findall(r"^### (.+)$", output, re.MULTILINE)
|
||||
print("\nSection headings found:")
|
||||
for h in headings:
|
||||
print(f" ### {h}")
|
||||
|
|
@ -1,424 +0,0 @@
|
|||
# OCR Journal Layout Generalization Plan
|
||||
|
||||
Date: 2026-06-05
|
||||
Primary regression papers:
|
||||
|
||||
- `7C8829BD` — Frontiers-style mixed tail spread with backmatter + references
|
||||
- `2GN9LMCW` — PeerJ-style unnumbered heading hierarchy + declarations container + references
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Generalize the structured OCR pipeline so it handles multiple journal layout families without article-specific text patches.
|
||||
|
||||
This plan targets reusable layout behaviors, not one-off paper fixes:
|
||||
|
||||
- frontmatter zoning on style-driven first pages,
|
||||
- heading hierarchy for numbered and unnumbered journals,
|
||||
- backmatter detection from structure rather than keyword suppression,
|
||||
- container-style declaration regions before references,
|
||||
- figure legend validation that distinguishes real captions from inner chart labels.
|
||||
|
||||
It explicitly avoids:
|
||||
|
||||
- hard-coding one paper's sentences,
|
||||
- special-casing a single body line,
|
||||
- direct text patching to "make the markdown look right".
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Already stabilized
|
||||
|
||||
These are no longer the main blockers and should not be re-opened casually:
|
||||
|
||||
1. `7C8829BD` tail-spread ordering
|
||||
- body paragraphs are no longer broadly absorbed into tail sections
|
||||
- funding continuation now reattaches correctly
|
||||
- references zone is separated from neighboring backmatter
|
||||
|
||||
2. Derived rebuild / backfill runtime
|
||||
- legacy OCR papers can be backfilled into structured layers
|
||||
- rebuild now clears stale `done_incomplete` state correctly after successful validation
|
||||
|
||||
3. Basic page-marker compatibility
|
||||
- rebuild path can restore `ocr_status=done` when the rendered marker count is actually correct
|
||||
|
||||
4. First-page metadata for `2GN9LMCW`
|
||||
- author line is now preferred over affiliation line
|
||||
- obvious first-page furniture leakage is reduced
|
||||
|
||||
### Still incomplete
|
||||
|
||||
1. `2GN9LMCW` backmatter container is only partially modeled
|
||||
- `ADDITIONAL INFORMATION AND DECLARATIONS` is recognized as a boundary
|
||||
- but child sections inside that container are not all normalized into the same backmatter-child regime
|
||||
|
||||
2. `2GN9LMCW` child declarations still have unstable ownership
|
||||
- `Grant Disclosures`
|
||||
- `Author Contributions`
|
||||
- `Data Availability`
|
||||
- `Supplemental Information`
|
||||
still do not all retain clean heading/body grouping
|
||||
|
||||
3. Figure 4 in `2GN9LMCW` is still wrong
|
||||
- a chart-internal label is still promoted to a formal legend
|
||||
|
||||
4. Generalization is not yet explicit
|
||||
- the current code works better on the two audit papers
|
||||
- but the rules are not yet expressed as reusable layout-family logic
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Model
|
||||
|
||||
### Root Cause A — boundary detection is necessary but not sufficient
|
||||
|
||||
The current bidirectional boundary method answers:
|
||||
|
||||
- where the main body ends
|
||||
- where backmatter begins
|
||||
- where references begin
|
||||
|
||||
But that alone does not solve:
|
||||
|
||||
- what internal structure exists inside backmatter
|
||||
- whether the backmatter is flat or container-based
|
||||
- which child headings belong to that container
|
||||
- which bodies belong to which headings
|
||||
|
||||
So the missing piece is not a new boundary rule; it is **post-boundary structural modeling**.
|
||||
|
||||
### Root Cause B — unnumbered heading hierarchy is still too coarse
|
||||
|
||||
The pipeline now detects many unnumbered headings, but it still needs stronger level assignment for journals where hierarchy comes from:
|
||||
|
||||
- font size
|
||||
- font family
|
||||
- boldness / flags
|
||||
- color
|
||||
- spacing
|
||||
- line height / bbox shape
|
||||
|
||||
The issue is not "heading detection only"; it is "heading family classification".
|
||||
|
||||
### Root Cause C — `figure_title` is still too trusted
|
||||
|
||||
Multi-panel figure pages can contain OCR blocks that are:
|
||||
|
||||
- axis titles
|
||||
- panel labels
|
||||
- chart legends
|
||||
- group annotations
|
||||
|
||||
and those can still be misread as formal captions when raw label says `figure_title`.
|
||||
|
||||
So the missing piece is **formal legend validation**, not better cropping or matching geometry alone.
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
### 1. Generalize by layout family, not by article
|
||||
|
||||
The plan should support classes like:
|
||||
|
||||
- numbered two-column body with mixed tail spread
|
||||
- unnumbered style-driven hierarchy
|
||||
- backmatter as flat section list
|
||||
- backmatter as container + child sections
|
||||
- reference-dominant last pages
|
||||
|
||||
This is broad enough to generalize, but still concrete enough to implement.
|
||||
|
||||
### 2. Geometry and structure outrank weak text patterns
|
||||
|
||||
Priority order should be:
|
||||
|
||||
1. raw OCR structural priors
|
||||
2. page/spread regime
|
||||
3. geometry / zones
|
||||
4. style profile
|
||||
5. heading/body ownership
|
||||
6. weak text signals
|
||||
|
||||
Weak textual phrases may assist, but should never be the first or final authority for backmatter/body decisions.
|
||||
|
||||
### 3. Backmatter must be modeled after the boundary, not before it
|
||||
|
||||
The system should:
|
||||
|
||||
1. detect `body_end`
|
||||
2. detect `backmatter_start`
|
||||
3. detect `references_start`
|
||||
4. classify backmatter form:
|
||||
- `flat_backmatter`
|
||||
- `container_backmatter`
|
||||
5. resolve headings and bodies inside that regime
|
||||
|
||||
### 4. Figure captions need a formality test
|
||||
|
||||
Formal caption != any `figure_title`.
|
||||
|
||||
The system must validate that a candidate caption is consistent with:
|
||||
|
||||
- caption location
|
||||
- caption span width
|
||||
- numbering or caption-like phrasing
|
||||
- relation to the whole panel group
|
||||
- not obviously being an internal chart label
|
||||
|
||||
---
|
||||
|
||||
## Target Architecture Changes
|
||||
|
||||
### Task 1. Formalize bidirectional boundary output
|
||||
|
||||
Files:
|
||||
|
||||
- `paperforge/worker/ocr_render.py`
|
||||
- possibly shared helpers extracted into a new small module if needed
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Keep the current forward/backward logic, but make the outputs explicit:
|
||||
- `body_end_page`
|
||||
- `backmatter_start_page`
|
||||
- `references_start_page`
|
||||
- `tail_spread_range`
|
||||
|
||||
2. Add a reconciliation object, not just a tuple:
|
||||
- body range
|
||||
- tail range
|
||||
- references activation page
|
||||
- whether the spread is mixed or cleanly separated
|
||||
|
||||
3. Make downstream tail logic consume this structured boundary state instead of recomputing ad hoc.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Both `7C8829BD` and `2GN9LMCW` produce stable, explainable boundary outputs.
|
||||
|
||||
### Task 2. Add backmatter form classification
|
||||
|
||||
Files:
|
||||
|
||||
- `paperforge/worker/ocr_roles.py`
|
||||
- `paperforge/worker/ocr_render.py`
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Within the detected backmatter range, classify the regime as:
|
||||
- `flat_backmatter`
|
||||
- `container_backmatter`
|
||||
|
||||
2. `container_backmatter` is triggered only when:
|
||||
- a strong boundary/container heading exists,
|
||||
- it is followed by multiple declaration-style child headings,
|
||||
- and references begin later in the same region
|
||||
|
||||
3. Add / retain roles:
|
||||
- `backmatter_boundary_heading`
|
||||
- `backmatter_heading`
|
||||
- `backmatter_body`
|
||||
- `reference_heading`
|
||||
- `reference_item`
|
||||
|
||||
4. Do not make container detection depend on one journal phrase alone.
|
||||
- The heading text may seed the candidate,
|
||||
- but boundary confirmation must depend on late-page position + style + child-heading cluster + references follow-through.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- `2GN9LMCW` page 10/11 is treated as `container_backmatter`
|
||||
- `7C8829BD` remains in the simpler mixed-tail / flat-backmatter path
|
||||
|
||||
### Task 3. Normalize backmatter child section ownership
|
||||
|
||||
Files:
|
||||
|
||||
- `paperforge/worker/ocr_render.py`
|
||||
- potentially `paperforge/worker/ocr_roles.py`
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Inside `flat_backmatter`:
|
||||
- keep current heading/body ownership, but ensure references remain terminal
|
||||
|
||||
2. Inside `container_backmatter`:
|
||||
- child headings are grouped under the container
|
||||
- their bodies are assigned by:
|
||||
- same-page geometry
|
||||
- same-column preference
|
||||
- nearest valid heading above
|
||||
- interruption by a stronger sibling heading
|
||||
|
||||
3. Remove any residual path where container children fall back to generic body flow or noise suppression.
|
||||
|
||||
4. Normalize child heading levels:
|
||||
- container heading at one level
|
||||
- child declarations at one consistent lower level
|
||||
- references handled separately
|
||||
|
||||
Acceptance:
|
||||
|
||||
- For `2GN9LMCW`, the following each keep their own body:
|
||||
- `Funding`
|
||||
- `Grant Disclosures`
|
||||
- `Competing Interests`
|
||||
- `Author Contributions`
|
||||
- `Data Availability`
|
||||
- `Supplemental Information`
|
||||
|
||||
### Task 4. Finish style-aware heading family classification
|
||||
|
||||
Files:
|
||||
|
||||
- `paperforge/worker/ocr_render.py`
|
||||
- `paperforge/worker/ocr_roles.py`
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Build reusable style-family clustering using:
|
||||
- span size
|
||||
- font family
|
||||
- flags
|
||||
- color
|
||||
- bbox height
|
||||
- local spacing
|
||||
|
||||
2. Distinguish:
|
||||
- main section headings
|
||||
- subsection headings
|
||||
- backmatter child headings
|
||||
- body-like emphasized text that should not become headings
|
||||
|
||||
3. Preserve numbering as primary when present.
|
||||
- style serves as validation and level refinement, not replacement
|
||||
|
||||
Acceptance:
|
||||
|
||||
- `2GN9LMCW`
|
||||
- `MATERIALS AND METHODS`, `RESULTS`, `DISCUSSION`, `CONCLUSIONS` remain top-level
|
||||
- `Groups`, `Cell preparation and culture`, `Electrical stimulation of cells`, `Cell viability and activity`, `Osteogenic differentiation`, `Data analysis` are consistently lower-level
|
||||
- numbered papers do not regress
|
||||
|
||||
### Task 5. Tighten first-page frontmatter zoning generally
|
||||
|
||||
Files:
|
||||
|
||||
- `paperforge/worker/ocr_roles.py`
|
||||
- `paperforge/worker/ocr_metadata.py`
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Keep the zone-based first-page logic, but generalize it beyond `2GN9LMCW`:
|
||||
- title zone
|
||||
- author zone
|
||||
- affiliation zone
|
||||
- furniture zone
|
||||
- abstract zone
|
||||
|
||||
2. Use geometry + style + text morphology together rather than phrase suppression.
|
||||
|
||||
3. Ensure affiliation blocks cannot retain `authors`.
|
||||
|
||||
4. Preserve raw frontmatter for audit, but keep furniture out of the main body by default.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- `2GN9LMCW` remains correct for author extraction
|
||||
- earlier papers with good frontmatter do not regress
|
||||
|
||||
### Task 6. Add formal legend validation for ambiguous figure-title blocks
|
||||
|
||||
Files:
|
||||
|
||||
- `paperforge/worker/ocr_roles.py`
|
||||
- `paperforge/worker/ocr_figures.py`
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Treat `figure_title` as a strong prior only.
|
||||
|
||||
2. Add a formality filter:
|
||||
- caption-like width and placement
|
||||
- relation to whole figure cluster
|
||||
- numbering or caption-style wording when available
|
||||
- reject obvious axis / inner-chart labels
|
||||
|
||||
3. Add safe degradation:
|
||||
- if the system cannot validate a formal legend, keep the asset and lower confidence,
|
||||
- do not invent a bad figure note.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- `2GN9LMCW` Figure 4 no longer uses the axis-title line as the formal legend
|
||||
- already-correct figures in `7C8829BD` remain stable
|
||||
|
||||
---
|
||||
|
||||
## Remaining Risks
|
||||
|
||||
### Risk 1. Overfitting container backmatter to PeerJ
|
||||
|
||||
Mitigation:
|
||||
|
||||
- require structural confirmation:
|
||||
- late-page location
|
||||
- heading style
|
||||
- multiple child sections
|
||||
- references follow-through
|
||||
|
||||
### Risk 2. Style-family clustering destabilizes numbered journals
|
||||
|
||||
Mitigation:
|
||||
|
||||
- numbering stays primary,
|
||||
- style only validates and refines.
|
||||
|
||||
### Risk 3. Reference start swallows later declaration children
|
||||
|
||||
Mitigation:
|
||||
|
||||
- references remain terminal only after `references_start` and validated reference density,
|
||||
- not merely after any reference-like text.
|
||||
|
||||
### Risk 4. Figure legend tightening reduces recall
|
||||
|
||||
Mitigation:
|
||||
|
||||
- preserve high-confidence numbered caption path,
|
||||
- tighten only the ambiguous `figure_title` fallback path.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
General:
|
||||
|
||||
- [ ] no direct single-line paper-specific body-text patches are introduced
|
||||
- [ ] geometry/style/ownership remain primary over text suppression
|
||||
|
||||
`7C8829BD`:
|
||||
|
||||
- [ ] funding continuation still attaches correctly
|
||||
- [ ] late body text is not absorbed into backmatter
|
||||
- [ ] references zone remains stable
|
||||
|
||||
`2GN9LMCW`:
|
||||
|
||||
- [ ] authors display is the true author line
|
||||
- [ ] page-1 furniture does not leak into main body
|
||||
- [ ] heading hierarchy is consistent for unnumbered sections
|
||||
- [ ] `ADDITIONAL INFORMATION AND DECLARATIONS` acts as a container boundary
|
||||
- [ ] declarations child sections each retain their own body
|
||||
- [ ] references remain separate from declaration sections
|
||||
- [ ] Figure 4 no longer uses an inner chart label as formal legend
|
||||
|
||||
Regression:
|
||||
|
||||
- [ ] numbered-heading papers still pass
|
||||
- [ ] older legacy-backfilled papers still rebuild successfully
|
||||
|
|
@ -0,0 +1,532 @@
|
|||
# OCR Asset-First Figure Pipeline And Backmatter Unification Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Preserve the proven figure/panel asset handling from `master`, keep the clearer structured matching flow from the new OCR pipeline, and unify backmatter parsing after the body/tail boundary so mixed journal layouts stop producing unstable tail sections.
|
||||
|
||||
**Architecture:** The plan separates figure handling into `asset-first clustering -> legend validation -> matching`, and separates tail handling into `boundary detection -> unified backmatter family -> references zone`. It intentionally avoids paper-specific body-text patches and instead generalizes by layout behavior.
|
||||
|
||||
**Tech Stack:** Python, PyMuPDF-derived OCR span metadata, existing `paperforge.worker.ocr*` modules, pytest.
|
||||
|
||||
---
|
||||
|
||||
## Why This Plan
|
||||
|
||||
Current regressions show two distinct problems:
|
||||
|
||||
1. **Figure pipeline regression**
|
||||
- `master` had stronger image/chart clustering, composite-region handling, and figure-internal text rejection.
|
||||
- the new pipeline has cleaner separation of concepts, but lost the strong `asset-first` step.
|
||||
- result: multi-panel figures can degrade into `one panel matched + the rest orphaned`.
|
||||
|
||||
2. **Backmatter instability after boundary**
|
||||
- current tail logic can find the boundary, but after entering backmatter it still allows mixed role families:
|
||||
- `section_heading`
|
||||
- `subsection_heading`
|
||||
- `backmatter_boundary_heading`
|
||||
- `backmatter_heading`
|
||||
- `body_paragraph`
|
||||
- `frontmatter_noise`
|
||||
- result: child declarations drift, bodies disappear, and ordering becomes unstable.
|
||||
|
||||
The right fix is not more local patching. It is:
|
||||
|
||||
- **retain `master`'s useful asset-first logic**
|
||||
- **keep current structured matching because its separation is clearer**
|
||||
- **collapse backmatter to one role family after the boundary**
|
||||
|
||||
---
|
||||
|
||||
## Design Contract
|
||||
|
||||
### Figure contract
|
||||
|
||||
Figure handling is split into four layers:
|
||||
|
||||
1. **Asset clustering layer**
|
||||
- merge adjacent image/chart blocks into figure candidate regions
|
||||
- includes multi-panel and composite-region handling
|
||||
- excludes figure-internal text from participating as candidate legend text
|
||||
|
||||
2. **Legend validation layer**
|
||||
- determine whether a text block is a formal legend, candidate legend, or rejected inner text
|
||||
|
||||
3. **Matching layer**
|
||||
- match validated legends to candidate regions
|
||||
- do not let low-confidence legend fallback create bogus formal figure objects
|
||||
|
||||
4. **Degradation layer**
|
||||
- if no valid legend exists, keep unresolved candidate regions as non-formal assets
|
||||
- do not fabricate `Figure N` from inner labels
|
||||
|
||||
### Backmatter contract
|
||||
|
||||
Backmatter handling is split into three layers:
|
||||
|
||||
1. **Boundary detection**
|
||||
- determine:
|
||||
- `body_end`
|
||||
- `backmatter_start`
|
||||
- `references_start`
|
||||
|
||||
2. **Backmatter role unification**
|
||||
- once inside backmatter:
|
||||
- all non-reference headings become `backmatter_heading`
|
||||
- all owned content becomes `backmatter_body`
|
||||
- `References` becomes `reference_heading`
|
||||
- bibliography items become `reference_item`
|
||||
|
||||
3. **Local page/spread ordering**
|
||||
- use geometry and zone ownership only inside the backmatter region
|
||||
- references are a dedicated zone
|
||||
- no re-entry into ordinary body-heading logic
|
||||
|
||||
This means:
|
||||
|
||||
- `backmatter_boundary_heading` remains only a boundary aid
|
||||
- it is not a long-lived internal family that competes with child sections
|
||||
|
||||
---
|
||||
|
||||
## What To Reuse From `master`
|
||||
|
||||
### Keep / adapt
|
||||
|
||||
These parts from `paperforge/worker/ocr.py` in `master` are valuable and should be adapted into the new structured pipeline rather than discarded:
|
||||
|
||||
- `media_clusters()`
|
||||
- adjacent image/chart cluster formation
|
||||
- overlap-aware and side-by-side panel handling
|
||||
|
||||
- `_cluster_bbox()`
|
||||
- cluster region bbox calculation
|
||||
|
||||
- `_precaption_media_region()`
|
||||
- media region above a caption
|
||||
- useful for whole-figure composite detection
|
||||
|
||||
- `compute_precaption_composite_regions()`
|
||||
- identifies composite figure regions spanning multiple panels/media blocks
|
||||
|
||||
- `is_embedded_figure_text_block()`
|
||||
- rejects inner chart labels / panel-local text / embedded figure text
|
||||
|
||||
- `is_numbered_figure_caption()`
|
||||
- protects against prose references like `Figure 3 shows ...`
|
||||
|
||||
### Do not copy forward unchanged
|
||||
|
||||
These aspects of `master` should remain improved in the new system:
|
||||
|
||||
- direct inline rendering-driven caption/media coupling
|
||||
- single-file OCR orchestration coupling
|
||||
- old rendering-time figure decisions mixed into markdown emission
|
||||
|
||||
So the migration rule is:
|
||||
|
||||
- **reuse the geometry / clustering / rejection heuristics**
|
||||
- **do not re-couple them to inline rendering**
|
||||
|
||||
---
|
||||
|
||||
## What To Keep From The New Pipeline
|
||||
|
||||
The current split across modules is an improvement and should remain:
|
||||
|
||||
- `ocr_blocks.py`
|
||||
- `ocr_roles.py`
|
||||
- `ocr_figures.py`
|
||||
- `ocr_tables.py`
|
||||
- `ocr_objects.py`
|
||||
- `ocr_render.py`
|
||||
- `ocr_health.py`
|
||||
- `ocr_index.py`
|
||||
|
||||
Also keep the clearer conceptual split:
|
||||
|
||||
- structured roles
|
||||
- figure inventory
|
||||
- object emission
|
||||
- final render
|
||||
|
||||
The old `master` logic is only a source of asset-first heuristics, not a source of architecture.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Task 1: Restore `master` asset-first figure clustering inside the new pipeline
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\paperforge\worker\ocr_figures.py`
|
||||
- Reference only: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\paperforge\worker\ocr.py`
|
||||
- Test: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\tests\test_ocr_figures.py`
|
||||
|
||||
- [ ] **Step 1: Add failing tests for multi-panel candidate clustering**
|
||||
|
||||
Add tests that require:
|
||||
|
||||
- adjacent panel charts on one page become one candidate figure region
|
||||
- composite regions above caption are preserved
|
||||
- cluster bbox union matches all relevant panels
|
||||
|
||||
The tests must cover:
|
||||
|
||||
- side-by-side panels
|
||||
- stacked panels
|
||||
- composite page with 2xN chart layout
|
||||
|
||||
- [ ] **Step 2: Run the failing tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_ocr_figures.py -k "multi_panel or composite_region" -q
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- FAIL because current inventory does not produce unified candidate regions.
|
||||
|
||||
- [ ] **Step 3: Port `master` clustering heuristics into structured figure preprocessing**
|
||||
|
||||
Implement reusable helpers in `ocr_figures.py` based on `master` logic:
|
||||
|
||||
- media clustering
|
||||
- cluster bbox union
|
||||
- precaption composite-region building
|
||||
|
||||
Do not render anything here. Output candidate figure regions only.
|
||||
|
||||
- [ ] **Step 4: Run the focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_ocr_figures.py -k "multi_panel or composite_region" -q
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add paperforge/worker/ocr_figures.py tests/test_ocr_figures.py
|
||||
git commit -m "fix: restore asset-first figure clustering"
|
||||
```
|
||||
|
||||
### Task 2: Keep figure-internal text rejection from `master`
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\paperforge\worker\ocr_roles.py`
|
||||
- Modify: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\paperforge\worker\ocr_figures.py`
|
||||
- Reference only: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\paperforge\worker\ocr.py`
|
||||
- Test: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\tests\test_ocr_figures.py`
|
||||
|
||||
- [ ] **Step 1: Add failing tests for inner chart labels not becoming formal legends**
|
||||
|
||||
Cover:
|
||||
|
||||
- axis-title text
|
||||
- panel-local labels
|
||||
- chart-internal annotations
|
||||
- prose `Figure N shows...` rejection
|
||||
|
||||
- [ ] **Step 2: Run the failing tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_ocr_figures.py -k "inner_label or formal_legend" -q
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- FAIL because ambiguous `figure_title` still creates formal objects.
|
||||
|
||||
- [ ] **Step 3: Implement legend rejection and candidate demotion**
|
||||
|
||||
Use `master`'s rejection ideas:
|
||||
|
||||
- `is_numbered_figure_caption()`
|
||||
- embedded figure-text rejection
|
||||
- caption-width / placement sanity
|
||||
|
||||
Rules:
|
||||
|
||||
- low-confidence `figure_title` may remain `rejected_legend`
|
||||
- it must not create a formal `matched_figure`
|
||||
- unresolved candidate regions should remain unresolved/orphan clusters
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_ocr_figures.py -k "inner_label or formal_legend" -q
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add paperforge/worker/ocr_roles.py paperforge/worker/ocr_figures.py tests/test_ocr_figures.py
|
||||
git commit -m "fix: reject figure-internal text from formal legends"
|
||||
```
|
||||
|
||||
### Task 3: Make matching consume candidate regions, not raw single assets
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\paperforge\worker\ocr_figures.py`
|
||||
- Modify: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\paperforge\worker\ocr_objects.py`
|
||||
- Test: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\tests\test_ocr_figures.py`
|
||||
- Test: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\tests\test_ocr_objects.py`
|
||||
|
||||
- [ ] **Step 1: Add failing tests for unresolved cluster degradation**
|
||||
|
||||
Cover:
|
||||
|
||||
- multi-panel figure with no validated legend does not become bogus `Figure 4`
|
||||
- object generation does not create a misleading figure note from a rejected legend
|
||||
|
||||
- [ ] **Step 2: Run the failing tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_ocr_figures.py tests/test_ocr_objects.py -k "unresolved or orphan_cluster or figure_4" -q
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- FAIL
|
||||
|
||||
- [ ] **Step 3: Update figure inventory and object emission**
|
||||
|
||||
Implement:
|
||||
|
||||
- candidate region inventory entries
|
||||
- formal match only from validated legends
|
||||
- unresolved cluster output path
|
||||
- object generation must consume matched candidate regions, not arbitrary first assets
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_ocr_figures.py tests/test_ocr_objects.py -k "unresolved or orphan_cluster or figure_4" -q
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add paperforge/worker/ocr_figures.py paperforge/worker/ocr_objects.py tests/test_ocr_figures.py tests/test_ocr_objects.py
|
||||
git commit -m "fix: match figures against clustered candidates"
|
||||
```
|
||||
|
||||
### Task 4: Unify backmatter family after the boundary
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\paperforge\worker\ocr_roles.py`
|
||||
- Modify: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\paperforge\worker\ocr_render.py`
|
||||
- Test: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\tests\test_ocr_rendering.py`
|
||||
- Test: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\tests\test_ocr_render_stabilization.py`
|
||||
|
||||
- [ ] **Step 1: Add failing tests for container-backmatter normalization**
|
||||
|
||||
Cover:
|
||||
|
||||
- once `backmatter_start` is entered, all non-reference headings become `backmatter_heading`
|
||||
- child sections do not remain `section_heading` / `subsection_heading`
|
||||
- bodies inside backmatter do not fall back to `frontmatter_noise`
|
||||
|
||||
- [ ] **Step 2: Run the failing tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_ocr_rendering.py tests/test_ocr_render_stabilization.py -k "backmatter_container or declarations or author_contributions" -q
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- FAIL
|
||||
|
||||
- [ ] **Step 3: Normalize roles after boundary**
|
||||
|
||||
Implement:
|
||||
|
||||
- after `backmatter_start`, non-reference headings normalize to `backmatter_heading`
|
||||
- `backmatter_boundary_heading` remains only a boundary aid, not a competing long-lived family
|
||||
- child bodies become `backmatter_body`
|
||||
- `reference_heading` and `reference_item` remain distinct
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_ocr_rendering.py tests/test_ocr_render_stabilization.py -k "backmatter_container or declarations or author_contributions" -q
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add paperforge/worker/ocr_roles.py paperforge/worker/ocr_render.py tests/test_ocr_rendering.py tests/test_ocr_render_stabilization.py
|
||||
git commit -m "fix: unify backmatter roles after boundary"
|
||||
```
|
||||
|
||||
### Task 5: Keep local page/spread ordering simple and zone-driven
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\paperforge\worker\ocr_render.py`
|
||||
- Test: `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\tests\test_ocr_rendering.py`
|
||||
|
||||
- [ ] **Step 1: Add failing tests for backmatter local ordering**
|
||||
|
||||
Cover:
|
||||
|
||||
- backmatter child sections on a page/spread keep heading-body ownership
|
||||
- references zone remains terminal
|
||||
- no mixed ordering from old role families survives
|
||||
|
||||
- [ ] **Step 2: Run the failing tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_ocr_rendering.py -k "tail_spread or references_zone or backmatter_order" -q
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- FAIL
|
||||
|
||||
- [ ] **Step 3: Simplify ordering to local zone ownership**
|
||||
|
||||
Implement ordering using:
|
||||
|
||||
- body/backmatter/references boundaries
|
||||
- backmatter heading/body grouping
|
||||
- references zone grouping
|
||||
|
||||
Avoid adding new complex fallback layers. After role unification, ordering should become simpler.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_ocr_rendering.py -k "tail_spread or references_zone or backmatter_order" -q
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add paperforge/worker/ocr_render.py tests/test_ocr_rendering.py
|
||||
git commit -m "fix: simplify backmatter ordering by zones"
|
||||
```
|
||||
|
||||
### Task 6: End-to-end regression validation on both layout families
|
||||
|
||||
**Files:**
|
||||
- Verify only:
|
||||
- `D:\L\OB\Literature-hub\System\PaperForge\ocr\7C8829BD\fulltext.md`
|
||||
- `D:\L\OB\Literature-hub\System\PaperForge\ocr\2GN9LMCW\fulltext.md`
|
||||
- corresponding `meta.json`, `figure_inventory.json`, `health/ocr_health.json`
|
||||
- Test:
|
||||
- `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\tests\test_ocr_figures.py`
|
||||
- `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\tests\test_ocr_rendering.py`
|
||||
- `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\tests\test_ocr_render_stabilization.py`
|
||||
- `D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.worktrees\feat-ocr-structured-pipeline\tests\test_ocr_objects.py`
|
||||
|
||||
- [ ] **Step 1: Rebuild `7C8829BD` and `2GN9LMCW`**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
@'
|
||||
from pathlib import Path
|
||||
from paperforge.worker.ocr_rebuild import run_derived_rebuild_for_keys, backfill_from_result
|
||||
vault = Path(r'D:\L\OB\Literature-hub')
|
||||
print(run_derived_rebuild_for_keys(vault, ['7C8829BD']))
|
||||
print(backfill_from_result(vault, '2GN9LMCW'))
|
||||
'@ | python -
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- both complete without exceptions
|
||||
|
||||
- [ ] **Step 2: Run focused OCR suites**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_ocr_figures.py tests/test_ocr_objects.py tests/test_ocr_rendering.py tests/test_ocr_render_stabilization.py tests/test_ocr_roles.py tests/test_ocr_rebuild.py -q
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 3: Manual artifact audit**
|
||||
|
||||
Verify:
|
||||
|
||||
- `7C8829BD`
|
||||
- funding continuation remains correct
|
||||
- references zone remains clean
|
||||
- `2GN9LMCW`
|
||||
- Figure 4 is either correctly unresolved or correctly matched as a whole figure, not a single wrong panel
|
||||
- declarations section ordering is stable
|
||||
- `Grant Disclosures`, `Author Contributions`, `Data Availability`, `Supplemental Information` each keep their body
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add paperforge/worker/ocr_figures.py paperforge/worker/ocr_roles.py paperforge/worker/ocr_render.py paperforge/worker/ocr_objects.py tests/test_ocr_figures.py tests/test_ocr_objects.py tests/test_ocr_rendering.py tests/test_ocr_render_stabilization.py tests/test_ocr_roles.py tests/test_ocr_rebuild.py
|
||||
git commit -m "fix: generalize figure clustering and backmatter parsing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coverage Checklist
|
||||
|
||||
- [ ] `master` asset-first clustering behavior is preserved
|
||||
- [ ] `master` embedded-figure-text rejection behavior is preserved
|
||||
- [ ] current structured matching architecture is preserved
|
||||
- [ ] low-confidence legends no longer fabricate formal figure objects
|
||||
- [ ] multi-panel figure pages are analyzed as clustered figure candidates before legend matching
|
||||
- [ ] backmatter boundary remains a boundary aid, not a competing internal family
|
||||
- [ ] after backmatter start, all non-reference headings normalize into one backmatter family
|
||||
- [ ] references remain a dedicated terminal zone
|
||||
- [ ] `7C8829BD` stays correct
|
||||
- [ ] `2GN9LMCW` stops producing broken Figure 4 and unstable declaration ordering
|
||||
|
||||
---
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-06-06-ocr-asset-first-and-backmatter-unification-plan.md`. Two execution options:
|
||||
|
||||
1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
||||
2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
||||
|
||||
Which approach?
|
||||
|
|
@ -101,19 +101,60 @@ def _is_formal_legend(text: str, block: dict | None = None, page_width: float =
|
|||
|
||||
lower = text.lower().strip()
|
||||
axis_words = {
|
||||
"days", "time", "concentration", "percentage", "volume",
|
||||
"frequency", "intensity", "ratio", "expression", "level",
|
||||
"content", "activity", "treatment", "group", "control",
|
||||
"dose", "response", "size", "culture", "medium",
|
||||
"supplemented", "differentiation", "osteogenic",
|
||||
"chondrogenic", "adipogenic", "induction", "stimulation",
|
||||
"exposure", "incubation", "harvest", "collection",
|
||||
"days",
|
||||
"time",
|
||||
"concentration",
|
||||
"percentage",
|
||||
"volume",
|
||||
"frequency",
|
||||
"intensity",
|
||||
"ratio",
|
||||
"expression",
|
||||
"level",
|
||||
"content",
|
||||
"activity",
|
||||
"treatment",
|
||||
"group",
|
||||
"control",
|
||||
"dose",
|
||||
"response",
|
||||
"size",
|
||||
"culture",
|
||||
"medium",
|
||||
"supplemented",
|
||||
"differentiation",
|
||||
"osteogenic",
|
||||
"chondrogenic",
|
||||
"adipogenic",
|
||||
"induction",
|
||||
"stimulation",
|
||||
"exposure",
|
||||
"incubation",
|
||||
"harvest",
|
||||
"collection",
|
||||
}
|
||||
words = set(lower.rstrip(". ").split())
|
||||
stop_words = {
|
||||
"of", "the", "in", "and", "to", "a", "an",
|
||||
"by", "at", "for", "with", "on", "is", "are",
|
||||
"was", "were", "post", "after", "during", "before",
|
||||
"of",
|
||||
"the",
|
||||
"in",
|
||||
"and",
|
||||
"to",
|
||||
"a",
|
||||
"an",
|
||||
"by",
|
||||
"at",
|
||||
"for",
|
||||
"with",
|
||||
"on",
|
||||
"is",
|
||||
"are",
|
||||
"was",
|
||||
"were",
|
||||
"post",
|
||||
"after",
|
||||
"during",
|
||||
"before",
|
||||
}
|
||||
text_len = len(text)
|
||||
if text_len < 100 and words and words.issubset(axis_words | stop_words):
|
||||
|
|
@ -122,6 +163,122 @@ def _is_formal_legend(text: str, block: dict | None = None, page_width: float =
|
|||
return True
|
||||
|
||||
|
||||
def _cluster_bbox(bboxes: list[list[float]]) -> list[float]:
|
||||
if not bboxes:
|
||||
return [0, 0, 0, 0]
|
||||
x1 = min(b[0] for b in bboxes)
|
||||
y1 = min(b[1] for b in bboxes)
|
||||
x2 = max(b[2] for b in bboxes)
|
||||
y2 = max(b[3] for b in bboxes)
|
||||
return [x1, y1, x2, y2]
|
||||
|
||||
|
||||
def _media_clusters(blocks: list[dict], page_width: float = 1200) -> list[list[dict]]:
|
||||
media = [
|
||||
b
|
||||
for b in blocks
|
||||
if b.get("role") == "figure_asset"
|
||||
or (b.get("role") == "media_asset" and b.get("raw_label", "") in {"image", "chart", "figure"})
|
||||
]
|
||||
media.sort(key=lambda b: (b.get("page", 0), b.get("bbox", [0, 0, 0, 0])[1], b.get("bbox", [0, 0, 0, 0])[0]))
|
||||
|
||||
clusters: list[list[dict]] = []
|
||||
for m in media:
|
||||
page = m.get("page", 0)
|
||||
bbox = m.get("bbox", [0, 0, 0, 0])
|
||||
placed = False
|
||||
for cluster in clusters:
|
||||
c_page = cluster[0].get("page", 0)
|
||||
if c_page != page:
|
||||
continue
|
||||
c_bbox = _cluster_bbox([cb.get("bbox", [0, 0, 0, 0]) for cb in cluster])
|
||||
mx1, my1, mx2, my2 = bbox
|
||||
cx1, cy1, cx2, cy2 = c_bbox
|
||||
h_overlap = mx1 < cx2 and cx1 < mx2
|
||||
v_overlap = my1 < cy2 and cy1 < my2
|
||||
h_gap = max(cx1 - mx2, mx1 - cx2, 0)
|
||||
v_gap = max(cy1 - my2, my1 - cy2, 0)
|
||||
small_h = min(my2 - my1, cy2 - cy1)
|
||||
if h_overlap and v_gap < small_h * 0.3:
|
||||
cluster.append(m)
|
||||
placed = True
|
||||
break
|
||||
if v_overlap and h_gap < 50:
|
||||
cluster.append(m)
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
clusters.append([m])
|
||||
return clusters
|
||||
|
||||
|
||||
def _precaption_media_region(media_cluster: list[dict], caption_block: dict) -> bool:
|
||||
cluster_bottom = max(b.get("bbox", [0, 0, 0, 0])[3] for b in media_cluster)
|
||||
caption_top = caption_block.get("bbox", [0, 0, 0, 0])[1]
|
||||
tolerance = 10
|
||||
return cluster_bottom < caption_top + tolerance
|
||||
|
||||
|
||||
def _compute_candidate_figure_regions(blocks: list[dict], page_width: float = 1200) -> list[dict]:
|
||||
clusters = _media_clusters(blocks, page_width)
|
||||
captions = [b for b in blocks if b.get("role") == "figure_caption"]
|
||||
regions: list[dict] = []
|
||||
for i, cluster in enumerate(clusters):
|
||||
cluster_bbox = _cluster_bbox([b.get("bbox", [0, 0, 0, 0]) for b in cluster])
|
||||
page = cluster[0].get("page", 0)
|
||||
attached: list[dict] = []
|
||||
unvalidated: list[dict] = []
|
||||
for cap in captions:
|
||||
if cap.get("page", 0) != page:
|
||||
continue
|
||||
if _precaption_media_region(cluster, cap):
|
||||
attached.append(cap)
|
||||
else:
|
||||
unvalidated.append(cap)
|
||||
regions.append(
|
||||
{
|
||||
"region_id": f"region_{i + 1:03d}",
|
||||
"page": page,
|
||||
"cluster_bbox": cluster_bbox,
|
||||
"media_blocks": cluster,
|
||||
"attached_captions": attached,
|
||||
"unvalidated_captions": unvalidated,
|
||||
}
|
||||
)
|
||||
return regions
|
||||
|
||||
|
||||
def is_embedded_figure_text(block: dict, all_blocks: list[dict], page_width: float = 1200) -> bool:
|
||||
block_bbox = block.get("bbox") or block.get("block_bbox")
|
||||
if not block_bbox or len(block_bbox) < 4:
|
||||
return False
|
||||
bx1, by1, bx2, by2 = block_bbox[:4]
|
||||
cx = (bx1 + bx2) / 2
|
||||
cy = (by1 + by2) / 2
|
||||
block_page = block.get("page", 0)
|
||||
|
||||
for other in all_blocks:
|
||||
if other is block:
|
||||
continue
|
||||
if other.get("role") not in ("figure_asset", "media_asset"):
|
||||
continue
|
||||
if other.get("page", 0) != block_page:
|
||||
continue
|
||||
ob = other.get("bbox") or other.get("block_bbox")
|
||||
if not ob or len(ob) < 4:
|
||||
continue
|
||||
ox1, oy1, ox2, oy2 = ob[:4]
|
||||
if ox1 <= cx <= ox2 and oy1 <= cy <= oy2:
|
||||
return True
|
||||
block_width = bx2 - bx1
|
||||
if block_width < page_width * 0.2:
|
||||
h_overlap = bx1 < ox2 and ox1 < bx2
|
||||
if h_overlap:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def build_figure_inventory(structured_blocks: list[dict], page_width: float = 1200) -> dict[str, Any]:
|
||||
legends: list[dict] = []
|
||||
rejected_legends: list[dict] = []
|
||||
|
|
@ -134,8 +291,6 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
|
|||
if block.get("page_width"):
|
||||
page_width = float(block["page_width"])
|
||||
|
||||
low_conf_legends: list[dict] = []
|
||||
|
||||
for block in structured_blocks:
|
||||
role = block.get("role", "")
|
||||
if role == "figure_caption":
|
||||
|
|
@ -143,7 +298,6 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
|
|||
continue
|
||||
if not _is_formal_legend(block.get("text", ""), block, page_width):
|
||||
rejected_legends.append(block)
|
||||
low_conf_legends.append(block)
|
||||
else:
|
||||
legends.append(block)
|
||||
elif role == "figure_asset":
|
||||
|
|
@ -153,54 +307,41 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
|
|||
if raw_label in {"image", "chart", "figure_title", "figure"} or not raw_label:
|
||||
assets.append(block)
|
||||
|
||||
candidate_regions = _compute_candidate_figure_regions(structured_blocks, page_width)
|
||||
used_asset_indices: set[int] = set()
|
||||
used_region_ids: set[str] = 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
|
||||
region_match = None
|
||||
same_page_regions = [
|
||||
region
|
||||
for region in candidate_regions
|
||||
if region["page"] == legend_page and region["region_id"] not in used_region_ids
|
||||
]
|
||||
attached_regions = [
|
||||
region
|
||||
for region in same_page_regions
|
||||
if any(cap.get("block_id") == legend.get("block_id") for cap in region.get("attached_captions", []))
|
||||
]
|
||||
region_pool = attached_regions or same_page_regions
|
||||
if region_pool:
|
||||
region_match = max(
|
||||
region_pool,
|
||||
key=lambda region: len(region.get("media_blocks", [])),
|
||||
)
|
||||
matched_assets = list(region_match.get("media_blocks", []))
|
||||
used_region_ids.add(region_match["region_id"])
|
||||
for asset in matched_assets:
|
||||
for i, candidate in enumerate(assets):
|
||||
if (
|
||||
candidate.get("block_id") == asset.get("block_id")
|
||||
and candidate.get("page", 0) == asset.get("page", 0)
|
||||
):
|
||||
used_asset_indices.add(i)
|
||||
break
|
||||
|
||||
is_legend_only = len(matched_assets) == 0
|
||||
|
||||
|
|
@ -217,7 +358,7 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
|
|||
}
|
||||
for a in matched_assets
|
||||
],
|
||||
"confidence": 0.85 if not is_legend_only else 0.4,
|
||||
"confidence": 0.85 if region_match is not None else 0.4,
|
||||
"flags": [] if not is_legend_only else ["legend_only"],
|
||||
}
|
||||
)
|
||||
|
|
@ -225,37 +366,9 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
|
|||
if is_legend_only:
|
||||
unmatched_legends.append(legend)
|
||||
|
||||
# Fallback: for low-confidence legends (rejected by formality check),
|
||||
# match them to the first unmatched asset on the same page.
|
||||
# Each low-confidence legend produces at most one figure entry.
|
||||
used_low_conf: set[int] = set()
|
||||
for li, legend in enumerate(low_conf_legends):
|
||||
leg_page = legend.get("page", 0)
|
||||
leg_text = legend.get("text", "")
|
||||
fig_num = _extract_figure_number(leg_text)
|
||||
|
||||
# Find first unmatched asset on the legend's page
|
||||
match_idx = None
|
||||
for i, asset in enumerate(assets):
|
||||
if i not in used_asset_indices and asset.get("page", 0) == leg_page:
|
||||
match_idx = i
|
||||
break
|
||||
|
||||
if match_idx is not None and li not in used_low_conf:
|
||||
matched_figures.append({
|
||||
"legend_block_id": legend.get("block_id", ""),
|
||||
"page": leg_page,
|
||||
"text": leg_text,
|
||||
"figure_number": fig_num,
|
||||
"matched_assets": [{
|
||||
"block_id": assets[match_idx].get("block_id", ""),
|
||||
"bbox": assets[match_idx].get("bbox", [0, 0, 0, 0]),
|
||||
}],
|
||||
"confidence": 0.3,
|
||||
"flags": ["legend_uncertain"],
|
||||
})
|
||||
used_asset_indices.add(match_idx)
|
||||
used_low_conf.add(li)
|
||||
# Low-confidence legends remain rejected evidence only. They do not
|
||||
# fabricate formal figure objects; unresolved multi-panel assets remain
|
||||
# available as unmatched assets for downstream inspection/review.
|
||||
|
||||
for i, asset in enumerate(assets):
|
||||
if i not in used_asset_indices:
|
||||
|
|
|
|||
|
|
@ -175,6 +175,17 @@ def extract_and_write_objects(
|
|||
):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for stale_dir, pattern in (
|
||||
(figures_asset_dir, "*.jpg"),
|
||||
(tables_asset_dir, "*.jpg"),
|
||||
(orphans_asset_dir, "*.jpg"),
|
||||
(figures_render_dir, "*.md"),
|
||||
(tables_render_dir, "*.md"),
|
||||
):
|
||||
for stale in stale_dir.glob(pattern):
|
||||
with contextlib.suppress(Exception):
|
||||
stale.unlink()
|
||||
|
||||
if page_dimensions_by_page is None:
|
||||
page_dimensions_by_page = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ def _find_owning_heading(
|
|||
|
||||
body_y = body_bbox[1]
|
||||
body_col = _get_column(body, page_width)
|
||||
body_width = body_bbox[2] - body_bbox[0]
|
||||
|
||||
candidates: list[tuple[int, float]] = []
|
||||
for i, sec in enumerate(sections):
|
||||
|
|
@ -127,7 +128,11 @@ def _find_owning_heading(
|
|||
continue
|
||||
h_col = _get_column(h, page_width)
|
||||
dist = body_y - h_bottom
|
||||
col_penalty = 0.0 if h_col == body_col else 10000.0
|
||||
horizontal_overlap = max(0.0, min(body_bbox[2], h_bbox[2]) - max(body_bbox[0], h_bbox[0]))
|
||||
if body_width >= page_width * 0.45 and horizontal_overlap > 0:
|
||||
col_penalty = 0.0
|
||||
else:
|
||||
col_penalty = 0.0 if h_col == body_col else 10000.0
|
||||
candidates.append((i, dist + col_penalty))
|
||||
|
||||
if not candidates:
|
||||
|
|
@ -532,8 +537,6 @@ def _reorder_tail_run(
|
|||
sub_secs = [s for s in backmatter_sections if s["heading"].get("role") != "backmatter_boundary_heading"]
|
||||
for sec in boundary_secs + sub_secs:
|
||||
h = sec["heading"]
|
||||
if h.get("_backmatter_regime") == "container" and h.get("role") == "backmatter_heading":
|
||||
h["_container_child"] = True
|
||||
result.append(h)
|
||||
result.extend(sec["bodies"])
|
||||
if ref_section is not None and ref_section is not carried_ref:
|
||||
|
|
@ -597,8 +600,6 @@ def _reorder_tail_run_fifo(
|
|||
sub_secs = [s for s in backmatter_sections if s["heading"].get("role") != "backmatter_boundary_heading"]
|
||||
for sec in boundary_secs + sub_secs:
|
||||
h = sec["heading"]
|
||||
if h.get("_backmatter_regime") == "container" and h.get("role") == "backmatter_heading":
|
||||
h["_container_child"] = True
|
||||
result.append(h)
|
||||
result.extend(sec["bodies"])
|
||||
if ref_section is not None and ref_section is not carried_ref:
|
||||
|
|
@ -969,6 +970,60 @@ def _label_backmatter_regime(tail_boundary: TailBoundary, backmatter_form: str,
|
|||
block["_backmatter_regime"] = "flat"
|
||||
|
||||
|
||||
def _normalize_backmatter_roles_after_boundary(
|
||||
tail_boundary: TailBoundary | None,
|
||||
backmatter_form: str,
|
||||
blocks: list[dict],
|
||||
) -> None:
|
||||
"""Normalize mixed roles inside the backmatter region.
|
||||
|
||||
Once the backmatter boundary has been entered, all non-reference headings
|
||||
should be treated as backmatter headings and all owned content should stop
|
||||
competing with body/frontmatter roles.
|
||||
"""
|
||||
if (
|
||||
tail_boundary is None
|
||||
or tail_boundary.spread_start is None
|
||||
or tail_boundary.spread_end is None
|
||||
or backmatter_form != "container"
|
||||
):
|
||||
return
|
||||
|
||||
boundary_seen = False
|
||||
for block in blocks:
|
||||
page = block.get("page")
|
||||
if page is None or page < tail_boundary.spread_start or page > tail_boundary.spread_end:
|
||||
continue
|
||||
|
||||
role = block.get("role")
|
||||
if role == "backmatter_boundary_heading":
|
||||
if boundary_seen:
|
||||
block["role"] = "backmatter_heading"
|
||||
block["_backmatter_regime"] = "container"
|
||||
else:
|
||||
boundary_seen = True
|
||||
block["_backmatter_regime"] = "container"
|
||||
continue
|
||||
|
||||
if not boundary_seen:
|
||||
continue
|
||||
|
||||
if role == "reference_heading":
|
||||
break
|
||||
|
||||
if role in {"section_heading", "subsection_heading", "sub_subsection_heading"}:
|
||||
block["role"] = "backmatter_heading"
|
||||
block["_backmatter_regime"] = "container"
|
||||
block["render_default"] = True
|
||||
continue
|
||||
|
||||
if role in {"body_paragraph", "frontmatter_noise"}:
|
||||
block["role"] = "backmatter_body"
|
||||
block["_backmatter_regime"] = "container"
|
||||
block["render_default"] = True
|
||||
block["index_default"] = True
|
||||
|
||||
|
||||
def _assign_tail_spread_ownership(
|
||||
blocks: list[dict],
|
||||
tail_spread: TailBoundary | None = None,
|
||||
|
|
@ -1063,6 +1118,7 @@ def _order_tail_blocks(blocks: list[dict], style_profiles: dict | None = None) -
|
|||
if tail_spread is not None:
|
||||
backmatter_form = _classify_backmatter_form(tail_spread, blocks)
|
||||
_label_backmatter_regime(tail_spread, backmatter_form, blocks)
|
||||
_normalize_backmatter_roles_after_boundary(tail_spread, backmatter_form, blocks)
|
||||
|
||||
# Step 0.5: only inside the reconciled tail spread, promote plausible
|
||||
# body paragraphs into tail candidates using geometry rather than
|
||||
|
|
@ -1266,10 +1322,7 @@ def render_fulltext_markdown(
|
|||
emitted_pages.add(block_page)
|
||||
|
||||
if role == "backmatter_boundary_heading" or role == "backmatter_heading" or role == "reference_heading":
|
||||
if block.get("_container_child"):
|
||||
lines.append(f"### {text}")
|
||||
else:
|
||||
lines.append(f"## {text}")
|
||||
lines.append(f"## {text}")
|
||||
lines.append("")
|
||||
elif role == "section_heading":
|
||||
if text.strip().lower() in FRONTMATTER_NOISE:
|
||||
|
|
|
|||
|
|
@ -1,11 +1,196 @@
|
|||
"""Phase 2 contract tests for figure inventory.
|
||||
|
||||
paperforge.worker.ocr_figures does not exist yet -- tests will fail until
|
||||
Task 5 implements the module.
|
||||
"""
|
||||
"""Phase 2 contract tests for figure inventory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# --- clustering helpers ---
|
||||
|
||||
|
||||
def test_cluster_bbox_union() -> None:
|
||||
from paperforge.worker.ocr_figures import _cluster_bbox
|
||||
|
||||
bboxes = [
|
||||
[100, 100, 300, 300],
|
||||
[250, 200, 500, 400],
|
||||
]
|
||||
result = _cluster_bbox(bboxes)
|
||||
assert result == [100, 100, 500, 400]
|
||||
|
||||
|
||||
def test_cluster_bbox_single() -> None:
|
||||
from paperforge.worker.ocr_figures import _cluster_bbox
|
||||
|
||||
result = _cluster_bbox([[50, 60, 200, 300]])
|
||||
assert result == [50, 60, 200, 300]
|
||||
|
||||
|
||||
def test_cluster_bbox_empty() -> None:
|
||||
from paperforge.worker.ocr_figures import _cluster_bbox
|
||||
|
||||
result = _cluster_bbox([])
|
||||
assert result == [0, 0, 0, 0]
|
||||
|
||||
|
||||
def test_media_clusters_side_by_side() -> None:
|
||||
from paperforge.worker.ocr_figures import _media_clusters
|
||||
|
||||
blocks = [
|
||||
{"block_id": 1, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [0, 0, 100, 200]},
|
||||
{"block_id": 2, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [120, 0, 220, 200]},
|
||||
]
|
||||
clusters = _media_clusters(blocks)
|
||||
assert len(clusters) == 1
|
||||
assert len(clusters[0]) == 2
|
||||
|
||||
|
||||
def test_media_clusters_stacked() -> None:
|
||||
from paperforge.worker.ocr_figures import _media_clusters
|
||||
|
||||
blocks = [
|
||||
{"block_id": 1, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [0, 0, 200, 100]},
|
||||
{"block_id": 2, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [0, 120, 200, 220]},
|
||||
]
|
||||
clusters = _media_clusters(blocks)
|
||||
assert len(clusters) == 1
|
||||
assert len(clusters[0]) == 2
|
||||
|
||||
|
||||
def test_media_clusters_different_pages() -> None:
|
||||
from paperforge.worker.ocr_figures import _media_clusters
|
||||
|
||||
blocks = [
|
||||
{"block_id": 1, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [0, 0, 100, 100]},
|
||||
{"block_id": 2, "page": 2, "role": "figure_asset", "raw_label": "image", "bbox": [0, 0, 100, 100]},
|
||||
]
|
||||
clusters = _media_clusters(blocks)
|
||||
assert len(clusters) == 2
|
||||
assert len(clusters[0]) == 1
|
||||
assert len(clusters[1]) == 1
|
||||
|
||||
|
||||
def test_media_clusters_wide_gap_no_cluster() -> None:
|
||||
from paperforge.worker.ocr_figures import _media_clusters
|
||||
|
||||
blocks = [
|
||||
{"block_id": 1, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [0, 0, 200, 100]},
|
||||
{"block_id": 2, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [0, 200, 200, 300]},
|
||||
]
|
||||
clusters = _media_clusters(blocks)
|
||||
assert len(clusters) == 2
|
||||
|
||||
|
||||
def test_media_clusters_ignores_non_media_assets() -> None:
|
||||
from paperforge.worker.ocr_figures import _media_clusters
|
||||
|
||||
blocks = [
|
||||
{"block_id": 1, "page": 1, "role": "figure_caption", "raw_label": "caption", "bbox": [0, 0, 100, 100]},
|
||||
{"block_id": 2, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [0, 0, 100, 100]},
|
||||
]
|
||||
clusters = _media_clusters(blocks)
|
||||
assert len(clusters) == 1
|
||||
assert len(clusters[0]) == 1
|
||||
|
||||
|
||||
def test_precaption_media_region_above() -> None:
|
||||
from paperforge.worker.ocr_figures import _precaption_media_region
|
||||
|
||||
media_cluster = [
|
||||
{"bbox": [0, 0, 200, 100]},
|
||||
]
|
||||
caption = {"bbox": [0, 120, 200, 150]}
|
||||
assert _precaption_media_region(media_cluster, caption) is True
|
||||
|
||||
|
||||
def test_precaption_media_region_below() -> None:
|
||||
from paperforge.worker.ocr_figures import _precaption_media_region
|
||||
|
||||
media_cluster = [
|
||||
{"bbox": [0, 200, 200, 300]},
|
||||
]
|
||||
caption = {"bbox": [0, 0, 200, 50]}
|
||||
assert _precaption_media_region(media_cluster, caption) is False
|
||||
|
||||
|
||||
def test_is_embedded_figure_text_inside_media() -> None:
|
||||
from paperforge.worker.ocr_figures import is_embedded_figure_text
|
||||
|
||||
blocks = [
|
||||
{"block_id": 1, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [100, 100, 500, 400]},
|
||||
]
|
||||
block_inside = {"block_id": 2, "page": 1, "role": "text", "bbox": [200, 200, 300, 250]}
|
||||
assert is_embedded_figure_text(block_inside, blocks) is True
|
||||
|
||||
|
||||
def test_is_embedded_figure_text_outside_media() -> None:
|
||||
from paperforge.worker.ocr_figures import is_embedded_figure_text
|
||||
|
||||
blocks = [
|
||||
{"block_id": 1, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [100, 100, 500, 400]},
|
||||
]
|
||||
block_outside = {"block_id": 2, "page": 1, "role": "text", "bbox": [600, 600, 700, 650]}
|
||||
assert is_embedded_figure_text(block_outside, blocks) is False
|
||||
|
||||
|
||||
def test_is_embedded_figure_text_narrow_axis_label() -> None:
|
||||
from paperforge.worker.ocr_figures import is_embedded_figure_text
|
||||
|
||||
blocks = [
|
||||
{"block_id": 1, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [100, 100, 500, 400]},
|
||||
]
|
||||
narrow = {"block_id": 2, "page": 1, "role": "text", "bbox": [200, 450, 320, 470]}
|
||||
assert is_embedded_figure_text(narrow, blocks) is True
|
||||
|
||||
|
||||
def test_compute_candidate_figure_regions_basic() -> None:
|
||||
from paperforge.worker.ocr_figures import _compute_candidate_figure_regions
|
||||
|
||||
blocks = [
|
||||
{"block_id": 1, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [100, 100, 500, 300]},
|
||||
{
|
||||
"block_id": 2,
|
||||
"page": 1,
|
||||
"role": "figure_caption",
|
||||
"text": "Figure 1. Test caption.",
|
||||
"bbox": [100, 320, 500, 360],
|
||||
},
|
||||
]
|
||||
regions = _compute_candidate_figure_regions(blocks)
|
||||
assert len(regions) == 1
|
||||
assert regions[0]["page"] == 1
|
||||
assert len(regions[0]["media_blocks"]) == 1
|
||||
assert len(regions[0]["attached_captions"]) == 1
|
||||
|
||||
|
||||
def test_compute_candidate_figure_regions_no_caption() -> None:
|
||||
from paperforge.worker.ocr_figures import _compute_candidate_figure_regions
|
||||
|
||||
blocks = [
|
||||
{"block_id": 1, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [100, 100, 500, 300]},
|
||||
]
|
||||
regions = _compute_candidate_figure_regions(blocks)
|
||||
assert len(regions) == 1
|
||||
assert len(regions[0]["attached_captions"]) == 0
|
||||
|
||||
|
||||
def test_compute_candidate_figure_regions_caption_before_media() -> None:
|
||||
from paperforge.worker.ocr_figures import _compute_candidate_figure_regions
|
||||
|
||||
blocks = [
|
||||
{
|
||||
"block_id": 1,
|
||||
"page": 1,
|
||||
"role": "figure_caption",
|
||||
"text": "Figure 1. Caption before image.",
|
||||
"bbox": [100, 50, 500, 90],
|
||||
},
|
||||
{"block_id": 2, "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [100, 110, 500, 400]},
|
||||
]
|
||||
regions = _compute_candidate_figure_regions(blocks)
|
||||
assert len(regions) == 1
|
||||
assert len(regions[0]["attached_captions"]) == 0
|
||||
|
||||
|
||||
# --- existing tests ---
|
||||
|
||||
|
||||
def test_formal_figure_count_is_based_on_legends_not_raw_images() -> None:
|
||||
from paperforge.worker.ocr_figures import build_figure_inventory
|
||||
|
|
@ -78,37 +263,44 @@ def test_unmatched_assets_are_preserved() -> None:
|
|||
|
||||
def test_extract_figure_number_basic() -> None:
|
||||
from paperforge.worker.ocr_figures import _extract_figure_number
|
||||
|
||||
assert _extract_figure_number("Figure 1. Caption") == 1
|
||||
|
||||
|
||||
def test_extract_figure_number_fig_dot() -> None:
|
||||
from paperforge.worker.ocr_figures import _extract_figure_number
|
||||
|
||||
assert _extract_figure_number("Fig. 2. Test") == 2
|
||||
|
||||
|
||||
def test_extract_figure_number_supplementary() -> None:
|
||||
from paperforge.worker.ocr_figures import _extract_figure_number
|
||||
|
||||
assert _extract_figure_number("Supplementary Fig. S3") == 3
|
||||
|
||||
|
||||
def test_extract_figure_number_extended_data() -> None:
|
||||
from paperforge.worker.ocr_figures import _extract_figure_number
|
||||
|
||||
assert _extract_figure_number("Extended Data Fig. 4.") == 4
|
||||
|
||||
|
||||
def test_extract_figure_number_decimal_truncated() -> None:
|
||||
from paperforge.worker.ocr_figures import _extract_figure_number
|
||||
|
||||
result = _extract_figure_number("Figure 1.2. Magnified view")
|
||||
assert result == 1 or result == 1.2
|
||||
|
||||
|
||||
def test_extract_figure_number_none() -> None:
|
||||
from paperforge.worker.ocr_figures import _extract_figure_number
|
||||
|
||||
assert _extract_figure_number("Some random text") is None
|
||||
|
||||
|
||||
def test_extract_figure_number_multiline() -> None:
|
||||
from paperforge.worker.ocr_figures import _extract_figure_number
|
||||
|
||||
assert _extract_figure_number("Figure 3.\nDescription continues") == 3
|
||||
|
||||
|
||||
|
|
@ -291,3 +483,81 @@ def test_legend_does_not_steal_offpage_asset() -> None:
|
|||
assert "legend_only" in inventory["matched_figures"][0]["flags"]
|
||||
assert len(inventory["unmatched_assets"]) == 1
|
||||
assert inventory["unmatched_assets"][0]["block_id"] == "p2_b1"
|
||||
|
||||
|
||||
def test_low_confidence_inner_label_does_not_create_formal_figure_object() -> None:
|
||||
from paperforge.worker.ocr_figures import build_figure_inventory
|
||||
|
||||
structured_blocks = [
|
||||
{
|
||||
"paper_id": "K001",
|
||||
"page": 9,
|
||||
"block_id": "p9_b2",
|
||||
"role": "media_asset",
|
||||
"raw_label": "chart",
|
||||
"text": "",
|
||||
"bbox": [429, 237, 733, 485],
|
||||
},
|
||||
{
|
||||
"paper_id": "K001",
|
||||
"page": 9,
|
||||
"block_id": "p9_b3",
|
||||
"role": "media_asset",
|
||||
"raw_label": "chart",
|
||||
"text": "",
|
||||
"bbox": [772, 238, 1071, 484],
|
||||
},
|
||||
{
|
||||
"paper_id": "K001",
|
||||
"page": 9,
|
||||
"block_id": "p9_b4",
|
||||
"role": "media_asset",
|
||||
"raw_label": "chart",
|
||||
"text": "",
|
||||
"bbox": [363, 504, 742, 757],
|
||||
},
|
||||
{
|
||||
"paper_id": "K001",
|
||||
"page": 9,
|
||||
"block_id": "p9_b5",
|
||||
"role": "media_asset",
|
||||
"raw_label": "chart",
|
||||
"text": "",
|
||||
"bbox": [766, 503, 1075, 750],
|
||||
},
|
||||
{
|
||||
"paper_id": "K001",
|
||||
"page": 9,
|
||||
"block_id": "p9_b6",
|
||||
"role": "media_asset",
|
||||
"raw_label": "chart",
|
||||
"text": "",
|
||||
"bbox": [428, 774, 729, 1016],
|
||||
},
|
||||
{
|
||||
"paper_id": "K001",
|
||||
"page": 9,
|
||||
"block_id": "p9_b7",
|
||||
"role": "media_asset",
|
||||
"raw_label": "chart",
|
||||
"text": "",
|
||||
"bbox": [765, 768, 1075, 1013],
|
||||
},
|
||||
{
|
||||
"paper_id": "K001",
|
||||
"page": 9,
|
||||
"block_id": "p9_b8",
|
||||
"role": "figure_caption",
|
||||
"raw_label": "figure_title",
|
||||
"text": "Days post culture in osteogenic differentiation supplemented medium",
|
||||
"bbox": [374, 1046, 1143, 1077],
|
||||
},
|
||||
]
|
||||
|
||||
inventory = build_figure_inventory(structured_blocks, page_width=1224)
|
||||
|
||||
assert len(inventory["matched_figures"]) == 0, (
|
||||
"Rejected low-confidence inner labels must not create formal matched figures"
|
||||
)
|
||||
assert len(inventory["rejected_legends"]) == 1
|
||||
assert len(inventory["unmatched_assets"]) == 6
|
||||
|
|
|
|||
|
|
@ -1883,3 +1883,178 @@ def test_mixed_tail_page_keeps_late_body_out_of_funding_and_attaches_real_fundin
|
|||
assert late_body_idx < funding_idx, "Late body text must remain before Funding"
|
||||
assert funding_idx < funding_body_idx < funding_cont_idx, "Funding body and continuation must stay under Funding"
|
||||
assert funding_cont_idx < ack_idx < refs_idx, "Funding continuation must complete before later tail sections"
|
||||
|
||||
|
||||
def test_backmatter_boundary_normalizes_child_sections_before_references() -> None:
|
||||
from paperforge.worker.ocr_render import render_fulltext_markdown
|
||||
|
||||
structured_blocks = [
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 10,
|
||||
"block_id": "b1",
|
||||
"role": "backmatter_boundary_heading",
|
||||
"text": "ADDITIONAL INFORMATION AND DECLARATIONS",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 1200, 1080, 1240],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 10,
|
||||
"block_id": "b2",
|
||||
"role": "backmatter_heading",
|
||||
"text": "Funding",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 1280, 520, 1320],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 10,
|
||||
"block_id": "b3",
|
||||
"role": "backmatter_body",
|
||||
"text": "The work was supported by Grant A.",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 1330, 1120, 1450],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 11,
|
||||
"block_id": "b4",
|
||||
"role": "subsection_heading",
|
||||
"text": "Grant Disclosures",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 160, 620, 200],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 11,
|
||||
"block_id": "b5",
|
||||
"role": "body_paragraph",
|
||||
"text": "Grant A was disclosed by the authors.",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 210, 920, 290],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 11,
|
||||
"block_id": "b6",
|
||||
"role": "backmatter_heading",
|
||||
"text": "Author Contributions",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 320, 720, 360],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 11,
|
||||
"block_id": "b7",
|
||||
"role": "frontmatter_noise",
|
||||
"text": "Author A conceived the study and wrote the manuscript.",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 370, 1120, 430],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 11,
|
||||
"block_id": "b8",
|
||||
"role": "backmatter_heading",
|
||||
"text": "Data Availability",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 460, 680, 500],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 11,
|
||||
"block_id": "b9",
|
||||
"role": "body_paragraph",
|
||||
"text": "The raw data has been supplied as Supplementary Files.",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 510, 980, 560],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 11,
|
||||
"block_id": "b10",
|
||||
"role": "backmatter_boundary_heading",
|
||||
"text": "Supplemental Information",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 590, 760, 630],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 11,
|
||||
"block_id": "b11",
|
||||
"role": "body_paragraph",
|
||||
"text": "Supplemental information for this article can be found online.",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 640, 1120, 700],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 11,
|
||||
"block_id": "b12",
|
||||
"role": "reference_heading",
|
||||
"text": "REFERENCES",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 760, 620, 800],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
{
|
||||
"paper_id": "KEY001",
|
||||
"page": 11,
|
||||
"block_id": "b13",
|
||||
"role": "reference_item",
|
||||
"text": "Smith J. Example reference.",
|
||||
"render_default": True,
|
||||
"block_bbox": [360, 820, 1000, 880],
|
||||
"page_width": 1200,
|
||||
"page_height": 1700,
|
||||
},
|
||||
]
|
||||
|
||||
md = render_fulltext_markdown(
|
||||
structured_blocks=structured_blocks,
|
||||
resolved_metadata={},
|
||||
figure_inventory={},
|
||||
table_inventory={},
|
||||
page_count=11,
|
||||
)
|
||||
|
||||
funding_idx = md.index("## Funding")
|
||||
grant_idx = md.index("## Grant Disclosures")
|
||||
author_idx = md.index("## Author Contributions")
|
||||
data_idx = md.index("## Data Availability")
|
||||
supp_idx = md.index("## Supplemental Information")
|
||||
refs_idx = md.index("## REFERENCES")
|
||||
grant_body_idx = md.index("Grant A was disclosed by the authors.")
|
||||
author_body_idx = md.index("Author A conceived the study")
|
||||
data_body_idx = md.index("The raw data has been supplied")
|
||||
supp_body_idx = md.index("Supplemental information for this article")
|
||||
|
||||
assert funding_idx < grant_idx < author_idx < data_idx < supp_idx < refs_idx
|
||||
assert grant_idx < grant_body_idx < author_idx
|
||||
assert author_idx < author_body_idx < data_idx
|
||||
assert data_idx < data_body_idx < supp_idx
|
||||
assert supp_idx < supp_body_idx < refs_idx
|
||||
|
|
|
|||
Loading…
Reference in a new issue