feat(review): PDF/图片离线源适配(C1)—— 无需 CAD 的整条审查链路
支持直接审查 PDF 与位图图纸,全链路离线(不连 CAD、不连 8080 端口)。 渲染在内存内完成,复用既有墨迹热力图 + 自适应瓦片规划。 - 新增 dwg_review/sources.py - detect_source / plan_file / collect 统一入口 - PdfReader:pypdfium2 惰性导入,页序即图框序,处理 /Rotate 与 crop 边距 - ImageReader:世界单位 = 源像素,snap_box 整数对齐保证窗口与像素严格一致 - ink_grid:纯 PIL 灰度阈值 → 占用网格(不引入 numpy) - ReviewContext.create(connect_cad=False):离线时 client/tools 均为 None - 全链路 ctx.tools is None 守卫:review 只读子循环 / inspect_region / annotator / checks 的窗口类检查 / 绘图区最大化 - checklist.build_system_prompt(allow_tools=False, offline=True):离线提示词 - __main__:位置参数 source,按扩展名自动判定源类型并决定是否连 CAD - config 新增 8 个常量(PDF_MAX_PAGES / 文本最小字数 / 各源缩放上限 / 墨迹阈值等) - tiling.content_predicate 与 heatmap.cell_box 提升为公开函数 验证: - pytest 457 passed - verify_pdf_source.py:PDF 23/23、图片 9/9(不需 CAD),含实测瓦片字高像素与 旋转映射自检(文本区墨迹 0.311 vs 忽略 /Rotate 反向对照 0.145) - 真实 LLM 端到端:20 页 HVAC 图纸前 3 页跑通,结论具体可核验且未编造 handle 已知局限:页边线在墨迹网格里会把整页连成单簇,瓦片常退化为整页象限(CAD 侧 靠 drop_oversized 丢图框块,离线侧暂无等价手段),留待后续打磨。
This commit is contained in:
1 parent
d4a9442e30
commit
6143bb9ab2
32 files changed
+2617
-77
No files matched your search
@@ -0,0 +1,285 @@
|
||||
"""`dwg_review.sources` 的 **PDF** 部分单测。
|
||||
|
||||
用手工构造的最小 PDF(`_build_pdf`)驱动,不依赖任何 PDF 生成库、不依赖外部样本文件。
|
||||
真实工程图纸的端到端验收见仓库根 `verify_pdf_source.py`。
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from dwg_review import config, sources
|
||||
from tests.fakes import make_ctx
|
||||
|
||||
|
||||
def _build_pdf(pages):
|
||||
"""构造最小可解析 PDF。pages = [{"mediabox", "rotate", "text"}, ...]。"""
|
||||
objs = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
None, # obj 2:Pages(末尾回填,需先知道各页 obj 号)
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
]
|
||||
page_ids = []
|
||||
for spec in pages:
|
||||
mb = spec.get("mediabox", (0, 0, 200, 100))
|
||||
rot = spec.get("rotate", 0)
|
||||
text = spec.get("text", "HELLO")
|
||||
parts = []
|
||||
if spec.get("fill"):
|
||||
# 整页填黑 → 墨迹铺满整页(用于「整页一个簇」的场景)
|
||||
parts.append(
|
||||
f"{mb[0]} {mb[1]} {mb[2] - mb[0]} {mb[3] - mb[1]} re f"
|
||||
)
|
||||
if text:
|
||||
parts.append(f"BT /F1 12 Tf 20 40 Td ({text}) Tj ET")
|
||||
stream = "\n".join(parts).encode()
|
||||
page_id = len(objs) + 1
|
||||
content_id = page_id + 1
|
||||
page_ids.append(page_id)
|
||||
objs.append(
|
||||
(
|
||||
f"<< /Type /Page /Parent 2 0 R "
|
||||
f"/MediaBox [{mb[0]} {mb[1]} {mb[2]} {mb[3]}] /Rotate {rot} "
|
||||
f"/Resources << /Font << /F1 3 0 R >> >> /Contents {content_id} 0 R >>"
|
||||
).encode()
|
||||
)
|
||||
objs.append(
|
||||
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n"
|
||||
+ stream + b"\nendstream"
|
||||
)
|
||||
kids = " ".join(f"{i} 0 R" for i in page_ids)
|
||||
objs[1] = f"<< /Type /Pages /Kids [{kids}] /Count {len(page_ids)} >>".encode()
|
||||
|
||||
out = bytearray(b"%PDF-1.4\n")
|
||||
offsets = []
|
||||
for i, body in enumerate(objs, 1):
|
||||
offsets.append(len(out))
|
||||
out += f"{i} 0 obj\n".encode() + body + b"\nendobj\n"
|
||||
xref_pos = len(out)
|
||||
n = len(objs) + 1
|
||||
out += f"xref\n0 {n}\n".encode() + b"0000000000 65535 f \n"
|
||||
for off in offsets:
|
||||
out += f"{off:010d} 00000 n \n".encode()
|
||||
out += (
|
||||
f"trailer\n<< /Size {n} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n"
|
||||
).encode()
|
||||
return bytes(out)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdf_path(tmp_path):
|
||||
"""把 `_build_pdf(pages)` 落盘,返回路径。"""
|
||||
|
||||
def _make(pages):
|
||||
path = tmp_path / "mini.pdf"
|
||||
path.write_bytes(_build_pdf(pages))
|
||||
return str(path)
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PdfReader
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_pdf_reader_frames_basic(pdf_path):
|
||||
reader = sources.PdfReader(pdf_path([{}]))
|
||||
try:
|
||||
frames = reader.frames()
|
||||
finally:
|
||||
reader.close()
|
||||
assert len(frames) == 1
|
||||
assert frames[0]["bbox"] == [0.0, 0.0, 200.0, 100.0]
|
||||
assert frames[0]["source_type"] == "pdf"
|
||||
assert frames[0]["page_index"] == 0
|
||||
assert frames[0]["sheet_scale"] == 1.0
|
||||
|
||||
|
||||
def test_pdf_reader_frames_multipage_keeps_order(pdf_path):
|
||||
reader = sources.PdfReader(
|
||||
pdf_path([{"mediabox": (0, 0, 100, 50)}, {"mediabox": (0, 0, 300, 400)}])
|
||||
)
|
||||
try:
|
||||
frames = reader.frames()
|
||||
finally:
|
||||
reader.close()
|
||||
assert [f["page_index"] for f in frames] == [0, 1]
|
||||
assert frames[1]["bbox"] == [0.0, 0.0, 300.0, 400.0]
|
||||
|
||||
|
||||
def test_pdf_reader_rot90_swaps_page_size(pdf_path):
|
||||
"""带 /Rotate 90 的页:世界尺寸应是 (MediaBox 高, MediaBox 宽)。"""
|
||||
reader = sources.PdfReader(pdf_path([{"mediabox": (0, 0, 200, 100), "rotate": 90}]))
|
||||
try:
|
||||
frames = reader.frames()
|
||||
finally:
|
||||
reader.close()
|
||||
assert frames[0]["bbox"] == [0.0, 0.0, 100.0, 200.0]
|
||||
|
||||
|
||||
def test_pdf_reader_render_region_size(pdf_path):
|
||||
reader = sources.PdfReader(pdf_path([{}]))
|
||||
try:
|
||||
out = reader.render((0, 0, 100, 50), 2.0)
|
||||
finally:
|
||||
reader.close()
|
||||
assert out.size == (200, 100)
|
||||
|
||||
|
||||
def test_pdf_reader_render_bad_box(pdf_path):
|
||||
reader = sources.PdfReader(pdf_path([{}]))
|
||||
try:
|
||||
assert reader.render((0, 0, 0, 0), 2.0) is None
|
||||
finally:
|
||||
reader.close()
|
||||
|
||||
|
||||
def test_pdf_reader_text_items(pdf_path):
|
||||
reader = sources.PdfReader(pdf_path([{"text": "HELLO"}]))
|
||||
try:
|
||||
items = reader.text_items(0)
|
||||
finally:
|
||||
reader.close()
|
||||
assert len(items) == 1
|
||||
assert items[0]["content"] == "HELLO"
|
||||
x0, y0, x1, y1 = items[0]["bbox"]
|
||||
assert 0 <= x0 < x1 <= 200 and 0 <= y0 < y1 <= 100
|
||||
|
||||
|
||||
def test_pdf_reader_text_items_min_chars(pdf_path):
|
||||
"""短于 PDF_TEXT_MIN_CHARS 的片段被丢弃(滤噪)。"""
|
||||
reader = sources.PdfReader(pdf_path([{"text": "AB"}]))
|
||||
try:
|
||||
assert reader.text_items(0, min_chars=8) == []
|
||||
assert len(reader.text_items(0, min_chars=2)) == 1
|
||||
finally:
|
||||
reader.close()
|
||||
|
||||
|
||||
def test_pdf_reader_text_items_respects_box_filter(pdf_path):
|
||||
reader = sources.PdfReader(pdf_path([{"text": "HELLO"}]))
|
||||
try:
|
||||
# 文本在 y≈40~49,给一个完全错开的窗口 → 过滤掉
|
||||
assert reader.text_items(0, box=(0, 80, 200, 100)) == []
|
||||
finally:
|
||||
reader.close()
|
||||
|
||||
|
||||
def test_pdf_reader_text_items_rot90_maps_into_world(pdf_path):
|
||||
reader = sources.PdfReader(pdf_path([{"mediabox": (0, 0, 200, 100), "rotate": 90}]))
|
||||
try:
|
||||
items = reader.text_items(0)
|
||||
finally:
|
||||
reader.close()
|
||||
assert len(items) == 1
|
||||
x0, y0, x1, y1 = items[0]["bbox"]
|
||||
# 世界尺寸为 (100, 200)(旋转后),bbox 必须落在其中
|
||||
assert 0 <= x0 < x1 <= 100.0
|
||||
assert 0 <= y0 < y1 <= 200.0
|
||||
|
||||
|
||||
def test_pdf_reader_empty_text_layer(pdf_path):
|
||||
reader = sources.PdfReader(pdf_path([{"text": ""}]))
|
||||
try:
|
||||
assert reader.text_items(0) == []
|
||||
finally:
|
||||
reader.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF 端到端(离线,plan + collect)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_pdf_plan_and_collect_end_to_end(pdf_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "TILING_ENABLED", True)
|
||||
path = pdf_path([{"text": "HELLO"}])
|
||||
ctx = make_ctx(connect_cad=False, source="pdf", source_path=path)
|
||||
try:
|
||||
plan = sources.plan_file(ctx)
|
||||
assert plan["drawing_info"]["unit"] == "pt"
|
||||
assert plan["drawing_info"]["page_count"] == 1
|
||||
assert len(plan["frames"]) == 1
|
||||
ev = sources.collect(ctx, 1, plan["frames"][0])
|
||||
finally:
|
||||
sources.close_reader(ctx)
|
||||
|
||||
assert ev["source_type"] == "pdf"
|
||||
assert ev["window"] == {"min_x": 0.0, "min_y": 0.0, "max_x": 200.0, "max_y": 100.0}
|
||||
assert ev["entities"] == {"total": 0, "items": []}
|
||||
assert ev["texts"]["total"] == 1
|
||||
assert ev["texts"]["items"][0]["content"] == "HELLO"
|
||||
assert ev["images"] and ev["images"][0]["label"] == "overall"
|
||||
assert base64.b64decode(ev["images"][0]["b64"]).startswith(b"\x89PNG")
|
||||
|
||||
|
||||
def test_pdf_pdf_max_pages_caps_frames(pdf_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "PDF_MAX_PAGES", 1)
|
||||
path = pdf_path([{}, {}])
|
||||
ctx = make_ctx(connect_cad=False, source="pdf", source_path=path)
|
||||
try:
|
||||
plan = sources.plan_file(ctx)
|
||||
finally:
|
||||
sources.close_reader(ctx)
|
||||
assert len(plan["frames"]) == 1
|
||||
assert plan["drawing_info"]["page_count"] == 2
|
||||
|
||||
|
||||
def test_pdf_blank_page_has_only_overall(pdf_path, monkeypatch):
|
||||
"""无墨迹的页:墨迹网格为空 → 只出整体图(不凭空造瓦片)。"""
|
||||
monkeypatch.setattr(config, "TILING_ENABLED", True)
|
||||
path = pdf_path([{"text": ""}])
|
||||
ctx = make_ctx(connect_cad=False, source="pdf", source_path=path)
|
||||
try:
|
||||
plan = sources.plan_file(ctx)
|
||||
ev = sources.collect(ctx, 1, plan["frames"][0])
|
||||
finally:
|
||||
sources.close_reader(ctx)
|
||||
assert [i["label"] for i in ev["images"]] == ["overall"]
|
||||
|
||||
|
||||
def test_pdf_collect_multi_page_uses_own_page(pdf_path):
|
||||
"""多页时每页取证必须渲染**自己的页**(页序不可错位)。"""
|
||||
path = pdf_path(
|
||||
[
|
||||
{"mediabox": (0, 0, 200, 100), "text": "FIRST"},
|
||||
{"mediabox": (0, 0, 400, 200), "text": "SECOND"},
|
||||
]
|
||||
)
|
||||
ctx = make_ctx(connect_cad=False, source="pdf", source_path=path)
|
||||
try:
|
||||
plan = sources.plan_file(ctx)
|
||||
ev2 = sources.collect(ctx, 2, plan["frames"][1])
|
||||
finally:
|
||||
sources.close_reader(ctx)
|
||||
assert ev2["window"]["max_x"] == 400.0
|
||||
assert ev2["texts"]["items"][0]["content"] == "SECOND"
|
||||
|
||||
|
||||
def test_pdf_dedupes_tile_equal_to_overall(pdf_path, monkeypatch):
|
||||
"""整页即一个簇且瓦片分辨率不更高时,不重复出一张与整体图相同的图。"""
|
||||
monkeypatch.setattr(config, "TILING_ENABLED", True)
|
||||
monkeypatch.setattr(config, "PDF_OVERVIEW_MAX_ZOOM", 4.0)
|
||||
monkeypatch.setattr(config, "PDF_TILE_MAX_ZOOM", 4.0)
|
||||
path = pdf_path([{"fill": True}]) # 墨迹铺满整页 → 整页即一个簇
|
||||
ctx = make_ctx(connect_cad=False, source="pdf", source_path=path)
|
||||
try:
|
||||
plan = sources.plan_file(ctx)
|
||||
ev = sources.collect(ctx, 1, plan["frames"][0])
|
||||
finally:
|
||||
sources.close_reader(ctx)
|
||||
labels = [i["label"] for i in ev["images"]]
|
||||
assert labels.count("overall") == 1
|
||||
assert "tile-1" not in labels # 与整体图完全重合 → 省掉
|
||||
|
||||
|
||||
def test_pdf_keeps_tile_when_higher_zoom_than_overall(pdf_path, monkeypatch):
|
||||
"""瓦片分辨率**高于**整体图时不能去重(否则丢掉细节)。"""
|
||||
monkeypatch.setattr(config, "TILING_ENABLED", True)
|
||||
monkeypatch.setattr(config, "PDF_OVERVIEW_MAX_ZOOM", 1.0)
|
||||
monkeypatch.setattr(config, "PDF_TILE_MAX_ZOOM", 6.0)
|
||||
path = pdf_path([{"fill": True}])
|
||||
ctx = make_ctx(connect_cad=False, source="pdf", source_path=path)
|
||||
try:
|
||||
plan = sources.plan_file(ctx)
|
||||
ev = sources.collect(ctx, 1, plan["frames"][0])
|
||||
finally:
|
||||
sources.close_reader(ctx)
|
||||
assert [i["label"] for i in ev["images"]] == ["overall", "tile-1"]
|
||||
Reference in new issue
Block a user