Files
daily-paper/tests/test_pdf_image_extractor.py
T

455 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
from unittest.mock import MagicMock
import pymupdf
from app.services import pdf_image_extractor as mod
from app.services.layout_detector import LayoutBox
def _caption_block(bbox, text):
"""构造一个 page.get_text("dict") 风格的文本块。"""
return {
"type": 0,
"bbox": list(bbox),
"lines": [{"spans": [{"text": text}]}],
}
def test_process_page_pairs_caption_from_text(tmp_path):
"""caption 来自 PDF 文本流(figure 标题在内容下方),用其 ID 直接命名。"""
images_dest = tmp_path / "images"
images_dest.mkdir()
manifest: dict[str, dict] = {}
pix = MagicMock()
pix.tobytes.return_value = b"jpeg"
page = MagicMock()
page.rect.width = 600
page.rect.height = 800
page.get_pixmap.return_value = pix
page.get_text.return_value = {
"blocks": [
_caption_block((95, 310, 320, 325), "Figure 1: Overall architecture.")
]
}
doc = MagicMock()
doc.__getitem__.return_value = page
boxes = [LayoutBox(100, 100, 300, 300, "picture")]
extracted = mod._process_page(
doc,
0,
boxes,
images_dest=images_dest,
manifest=manifest,
seen_labels=set(),
arxiv_id="2401.00001",
)
assert extracted == 1
# caption 自带 ID → 直接命名 figure_1.jpg
info = manifest["figure_1.jpg"]
assert info["label"] == "Figure 1"
assert info["caption_text"] == "Figure 1: Overall architecture."
assert info["caption_source"] == "text"
def test_process_page_includes_caption_in_render(tmp_path):
"""渲染时把 caption 文本块区域合并进同一张截图。"""
images_dest = tmp_path / "images"
images_dest.mkdir()
manifest: dict[str, dict] = {}
pix = MagicMock()
pix.tobytes.return_value = b"jpeg"
page = MagicMock()
page.rect.width = 600
page.rect.height = 800
page.get_pixmap.return_value = pix
page.get_text.return_value = {
"blocks": [_caption_block((95, 310, 320, 325), "Figure 1: Caption text.")]
}
doc = MagicMock()
doc.__getitem__.return_value = page
boxes = [LayoutBox(100, 100, 300, 300, "picture")]
mod._process_page(
doc,
0,
boxes,
images_dest=images_dest,
manifest=manifest,
seen_labels=set(),
arxiv_id="2401.00001",
)
# 内容 [100,100,300,300] caption [95,310,320,325],各方向加 _REGION_PADDING=5
# → Rect(90, 95, 325, 330)
clip = page.get_pixmap.call_args.kwargs["clip"]
assert clip == pymupdf.Rect(90, 95, 325, 330)
def test_process_page_table_caption_above(tmp_path):
"""table 标题惯例在内容上方,配对后命名 table_N.jpg。"""
images_dest = tmp_path / "images"
images_dest.mkdir()
manifest: dict[str, dict] = {}
pix = MagicMock()
pix.tobytes.return_value = b"jpeg"
page = MagicMock()
page.rect.width = 600
page.rect.height = 800
page.get_pixmap.return_value = pix
# caption 在内容上方 [80, 90, 320, 105],内容表格 [80, 120, 320, 280]
page.get_text.return_value = {
"blocks": [_caption_block((80, 90, 320, 105), "Table 2 | Results summary.")]
}
doc = MagicMock()
doc.__getitem__.return_value = page
boxes = [LayoutBox(80, 120, 320, 280, "table")]
extracted = mod._process_page(
doc,
0,
boxes,
images_dest=images_dest,
manifest=manifest,
seen_labels=set(),
arxiv_id="2401.00001",
)
assert extracted == 1
info = manifest["table_2.jpg"]
assert info["label"] == "Table 2"
assert info["caption_source"] == "text"
def test_process_page_filters_uncaptioned(tmp_path):
"""没有 Figure/Table caption 配对的 boxAlgorithm、无编号表等)被过滤,不输出。"""
images_dest = tmp_path / "images"
images_dest.mkdir()
manifest: dict[str, dict] = {}
pix = MagicMock()
pix.tobytes.return_value = b"jpeg"
page = MagicMock()
page.rect.width = 600
page.rect.height = 800
page.get_pixmap.return_value = pix
page.get_text.return_value = {"blocks": []} # 无任何 caption 文本块
doc = MagicMock()
doc.__getitem__.return_value = page
boxes = [LayoutBox(100, 100, 300, 300, "picture")]
extracted = mod._process_page(
doc,
0,
boxes,
images_dest=images_dest,
manifest=manifest,
seen_labels=set(),
arxiv_id="2401.00001",
)
assert extracted == 0
assert manifest == {}
def test_process_page_merges_subfigure_clusters(tmp_path):
"""复合图被切成多个稀疏子图框时,配到同一主 caption 后合并成一张截图。"""
images_dest = tmp_path / "images"
images_dest.mkdir()
manifest: dict[str, dict] = {}
pix = MagicMock()
pix.tobytes.return_value = b"jpeg"
page = MagicMock()
page.rect.width = 600
page.rect.height = 800
page.get_pixmap.return_value = pix
# 主 caption 覆盖整张复合图宽度,在内容下方
page.get_text.return_value = {
"blocks": [_caption_block((95, 410, 455, 425), "Figure 3: Composite figure.")]
}
doc = MagicMock()
doc.__getitem__.return_value = page
# 两个子图框左右排列,间距 50pt> _CLUSTER_GAP=15,不被 _cluster_boxes 合并)
boxes = [
LayoutBox(100, 100, 250, 300, "picture"),
LayoutBox(300, 100, 450, 300, "picture"),
]
extracted = mod._process_page(
doc,
0,
boxes,
images_dest=images_dest,
manifest=manifest,
seen_labels=set(),
arxiv_id="2401.00003",
)
assert extracted == 1 # 两子图合并成一张
info = manifest["figure_3.jpg"]
assert info["label"] == "Figure 3"
assert info["subfigure_count"] == 2
# 合并 bbox = union 两子图 = [100,100,450,300]
assert info["box"] == [100.0, 100.0, 450.0, 300.0]
def test_process_page_two_tables_captions_below_not_merged(tmp_path):
"""同页相邻两张表、标题都在表下方时,各自配对自己的内容框,不互相吞并。
回归 2606.19926Table 3/4 同页、两标题均在表下方。旧逻辑(table 标题
必在上的错向惩罚)会让 Table 3 吞掉 Table 4 的内容框、Table 4 缺图。
"""
images_dest = tmp_path / "images"
images_dest.mkdir()
manifest: dict[str, dict] = {}
pix = MagicMock()
pix.tobytes.return_value = b"jpeg"
page = MagicMock()
page.rect.width = 600
page.rect.height = 800
page.get_pixmap.return_value = pix
# 两个 table 标题都在各自内容下方(本文实际排版)
page.get_text.return_value = {
"blocks": [
_caption_block((80, 324, 460, 346), "Table 3 Pass@1 success rate."),
_caption_block((80, 503, 460, 547), "Table 4 Core gold-context metrics."),
]
}
doc = MagicMock()
doc.__getitem__.return_value = page
boxes = [
LayoutBox(80, 77, 460, 321, "table"), # Table 3 内容(上)
LayoutBox(80, 420, 460, 494, "table"), # Table 4 内容(下)
]
extracted = mod._process_page(
doc,
0,
boxes,
images_dest=images_dest,
manifest=manifest,
seen_labels=set(),
arxiv_id="2606.19926",
)
assert extracted == 2
assert "table_3.jpg" in manifest
assert "table_4.jpg" in manifest
# 各自只含自己的内容框,未被合并成一张大图
assert manifest["table_3.jpg"]["box"] == [80.0, 77.0, 460.0, 321.0]
assert manifest["table_4.jpg"]["box"] == [80.0, 420.0, 460.0, 494.0]
assert "subfigure_count" not in manifest["table_3.jpg"]
assert "subfigure_count" not in manifest["table_4.jpg"]
def test_caption_regex_rejects_multi_reference_body_text():
"""多图引用型正文("Figure 14 and 15 show...")不应被当作独立 caption。
回归 2606.24597:第 35 页正文 "Figure 14 and 15 show..." 被误判为 caption
与真标题 "Figure 14:" 竞争 seen_labels 去重,导致 Figure 14 截成另一张图。
"""
re_ = mod._CAPTION_HEAD_RE
# 真标题:编号后接标题文字 / 冒号 / 子图编号,均应命中
assert re_.match("Figure 14: Interaction examples.")
assert re_.match("Figure 1 Context efficiency.")
assert re_.match("Table 1 Zero-shot results.")
assert re_.match("Figure 3.5 Sub-figure detail.")
# 多图引用正文:编号后紧跟 "and/to/, + 另一编号" → 拒绝
assert not re_.match("Figure 14 and 15 show representative examples.")
assert not re_.match("Figure 1 to 3 illustrate the pipeline.")
assert not re_.match("Table 2, 3 compare methods.")
def test_link_figures_fallback_excludes_assigned_images(tmp_path, monkeypatch):
"""序号兜底不复用已被精确匹配(策略 1)占用的图片。
回归 2606.19926Table 4 在 manifest 缺图 → 兜底误取 table_1.jpg(已属 Table 1)。
"""
monkeypatch.setattr(mod, "PAPERS_DIR", tmp_path)
images_dir = tmp_path / "x" / "images"
images_dir.mkdir(parents=True)
# manifest 只命中 Table 1 → table_1.jpgTable 4 无精确匹配,走兜底
(images_dir / "manifest.json").write_text(
json.dumps({"table_1.jpg": {"label": "Table 1"}})
)
figures = [{"id": "Table 1"}, {"id": "Table 4"}]
images = [
{"name": "table_1.jpg", "url": "/papers/x/images/table_1.jpg"},
{"name": "table_2.jpg", "url": "/papers/x/images/table_2.jpg"},
]
out = mod.link_figures_with_images(figures, images, "x")
by_id = {f["id"]: f for f in out}
assert by_id["Table 1"]["image_url"] == "/papers/x/images/table_1.jpg"
# 兜底只能拿到未被占用的 table_2.jpg,不能再复用 table_1.jpg
assert by_id["Table 4"]["image_url"] == "/papers/x/images/table_2.jpg"
def test_process_page_absorbs_far_subfigure_panel(tmp_path):
"""多面板图主 caption 只在最下方时,距 caption 过远的上方面板被吸收进来。
回归 2606.24597 Figure 4(a) 子图距主标题 270pt> _CAPTION_MATCH_DISTANCE),
原本被丢弃;(b) 子图距 46pt 正常配对。吸收后两面板合并成一张含 (a)(b)。
"""
images_dest = tmp_path / "images"
images_dest.mkdir()
manifest: dict[str, dict] = {}
pix = MagicMock()
pix.tobytes.return_value = b"jpeg"
page = MagicMock()
page.rect.width = 600
page.rect.height = 800
page.get_pixmap.return_value = pix
# 主 caption 在最下方 y=479,距上方 (a) 面板 270pt、距 (b) 面板 46pt
page.get_text.return_value = {
"blocks": [
_caption_block((80, 479, 520, 500), "Figure 4: Representative examples.")
]
}
doc = MagicMock()
doc.__getitem__.return_value = page
boxes = [
LayoutBox(80, 73, 520, 209, "picture"), # (a) 面板,距 caption 270pt
LayoutBox(80, 245, 520, 433, "picture"), # (b) 面板,距 caption 46pt
]
extracted = mod._process_page(
doc,
0,
boxes,
images_dest=images_dest,
manifest=manifest,
seen_labels=set(),
arxiv_id="2606.24597",
)
assert extracted == 1
info = manifest["figure_4.jpg"]
# 合并后含两面板:y 从 73 到 433
assert info["box"] == [80.0, 73.0, 520.0, 433.0]
assert info["subfigure_count"] == 2
def test_process_page_absorbs_chained_panels_multipass(tmp_path):
"""链式排列的多面板(上图→中图→下图→caption)需多趟吸收才能全部并入。
回归 2606.24597 Figure 153 个面板纵排、主 caption 只在最下方,中图被并入后
上图才能链上。单趟扫描会漏掉最上图。
"""
images_dest = tmp_path / "images"
images_dest.mkdir()
manifest: dict[str, dict] = {}
pix = MagicMock()
pix.tobytes.return_value = b"jpeg"
page = MagicMock()
page.rect.width = 600
page.rect.height = 800
page.get_pixmap.return_value = pix
# 主 caption 在最下方 y=710,仅下图(y505-669)在其 120pt 内
page.get_text.return_value = {
"blocks": [
_caption_block((80, 710, 520, 730), "Figure 15: Text-based domains.")
]
}
doc = MagicMock()
doc.__getitem__.return_value = page
boxes = [
LayoutBox(80, 73, 520, 217, "picture"), # 上图,距 caption 493pt
LayoutBox(80, 256, 520, 467, "picture"), # 中图,距 caption 243pt
LayoutBox(80, 505, 520, 669, "picture"), # 下图,距 caption 41pt(配得上)
]
extracted = mod._process_page(
doc,
0,
boxes,
images_dest=images_dest,
manifest=manifest,
seen_labels=set(),
arxiv_id="2606.24597",
)
assert extracted == 1
info = manifest["figure_15.jpg"]
# 三面板全并入:y 从 73 到 669
assert info["box"] == [80.0, 73.0, 520.0, 669.0]
assert info["subfigure_count"] == 3
def test_absorb_stragglers_respects_caption_boundary(tmp_path):
"""两图之间夹着(异类)标题时,漏配的上图不并入下图(吸收护栏 2)。
上图距下图标题 160pt(>120)漏配;下图正常配对。两图间夹一个 Table 标题
(异类,上图不会配它)→ 护栏判定有 caption 边界,不吸收上图。
"""
images_dest = tmp_path / "images"
images_dest.mkdir()
manifest: dict[str, dict] = {}
pix = MagicMock()
pix.tobytes.return_value = b"jpeg"
page = MagicMock()
page.rect.width = 600
page.rect.height = 800
page.get_pixmap.return_value = pix
page.get_text.return_value = {
"blocks": [
_caption_block(
(80, 160, 460, 175), "Table 9 Some table."
), # 夹中间的表标题
_caption_block((80, 310, 520, 330), "Figure 5 Some figure."), # 下图标题
]
}
doc = MagicMock()
doc.__getitem__.return_value = page
boxes = [
LayoutBox(80, 73, 520, 150, "picture"), # 上图,距 Figure 5 标题 160pt → 漏配
LayoutBox(80, 185, 520, 300, "picture"), # 下图,距 Figure 5 标题 10pt → 配上
]
extracted = mod._process_page(
doc,
0,
boxes,
images_dest=images_dest,
manifest=manifest,
seen_labels=set(),
arxiv_id="2606.24597",
)
# 上图未被并入下图 → 仅下图输出,box 不含上图、无 subfigure_count
assert extracted == 1
assert manifest["figure_5.jpg"]["box"] == [80.0, 185.0, 520.0, 300.0]
assert "subfigure_count" not in manifest["figure_5.jpg"]