Files
cad-agent/tests/test_sources.py
T
gjm 6143bb9ab2 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 丢图框块,离线侧暂无等价手段),留待后续打磨。
2026-10-16 23:07:23 +08:00

402 lines
13 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.
"""`dwg_review.sources` 单元测试(纯函数 + 图片源;PDF 真机在 verify_pdf_source.py)。
设计约束:本文件**不依赖 pypdfium2**(PDF 读取器惰性 import,只有真处理 PDF 才需要)。
"""
import base64
import pytest
from dwg_review import config, heatmap, sources
from tests.fakes import make_ctx
# ---------------------------------------------------------------------------
# detect_source / is_file_source
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"name,expect",
[
("a.dwg", "cad"),
("a.DXF", "cad"),
("b.pdf", "pdf"),
("c.PDF", "pdf"),
("d.png", "image"),
("e.JPG", "image"),
("f.tiff", "image"),
("g.webp", "image"),
],
)
def test_detect_source(name, expect):
assert sources.detect_source(name) == expect
def test_detect_source_unknown_raises():
with pytest.raises(ValueError):
sources.detect_source("x.svg")
def test_is_file_source():
assert sources.is_file_source("pdf") and sources.is_file_source("image")
assert not sources.is_file_source("cad")
# ---------------------------------------------------------------------------
# PDF 坐标换算
# ---------------------------------------------------------------------------
def test_rotate_point_identity():
assert sources.rotate_point(3, 4, 100, 200, 0) == (3, 4)
def test_rotate_point_90_matches_probe():
"""/Rotate 90:(x,y) → (y, mw - x)(实测验证过的映射)。"""
mw, mh = 842.0, 1191.0
assert sources.rotate_point(0, 0, mw, mh, 90) == (0.0, 842.0)
assert sources.rotate_point(mw, mh, mw, mh, 90) == (mh, 0.0)
def test_rotate_point_180_and_270_stay_in_range():
mw, mh = 842.0, 1191.0
for rot, (rw, rh) in ((180, (mw, mh)), (270, (mh, mw))):
for x, y in ((0, 0), (mw, mh), (100, 200)):
px, py = sources.rotate_point(x, y, mw, mh, rot)
assert 0 <= px <= rw and 0 <= py <= rh
def test_rect_to_world_rot90():
mw, mh = 842.0, 1191.0
# 未旋转的 (10,20)-(30,40) → 旋转后 x 取 [20,40],y 取 [842-30, 842-10]
world = sources.rect_to_world((10, 20, 30, 40), mw, mh, 90)
assert world == (20.0, 812.0, 40.0, 832.0)
def test_crop_of_is_margin_not_rect():
"""crop 是「裁掉的边距」(left,bottom,right,top),不是绝对矩形。"""
assert sources.crop_of((10, 20, 110, 220), 200, 300) == (10.0, 20.0, 90.0, 80.0)
def test_crop_of_clamps_negative():
assert sources.crop_of((-5, -5, 300, 400), 200, 300) == (0.0, 0.0, 0.0, 0.0)
# ---------------------------------------------------------------------------
# 幅面反查
# ---------------------------------------------------------------------------
def test_guess_sheet_size_a3_landscape():
w = 420.0 * config.PT_PER_MM
h = 297.0 * config.PT_PER_MM
assert sources.guess_sheet_size(w, h) == "A3"
def test_guess_sheet_size_a4_portrait():
w = 210.0 * config.PT_PER_MM
h = 297.0 * config.PT_PER_MM
assert sources.guess_sheet_size(w, h) == "A4"
def test_guess_sheet_size_unknown():
assert sources.guess_sheet_size(500, 700) is None
# ---------------------------------------------------------------------------
# 渲染倍率 / 尺度
# ---------------------------------------------------------------------------
def test_tile_zoom_uses_max_edge():
assert sources._tile_zoom((0, 0, 1000, 500), 10.0, 2000) == 2.0
def test_tile_zoom_capped_by_max_zoom():
assert sources._tile_zoom((0, 0, 10, 10), 3.0, 2000) == 3.0
def test_tile_zoom_degenerate_returns_max():
assert sources._tile_zoom((0, 0, 0, 0), 4.0, 1000) == 4.0
def test_file_scales_image_is_one():
assert sources.file_scales("image") == (1.0, 1.0)
def test_file_scales_pdf_matches_readable_px(monkeypatch):
monkeypatch.setattr(config, "TEXT_HEIGHT_MM", 2.0)
monkeypatch.setattr(config, "TEXT_READABLE_PX", 8)
monkeypatch.setattr(config, "LINE_READABLE_PX", 6)
s_text, s_line = sources.file_scales("pdf")
text_pt = 2.0 * config.PT_PER_MM
assert s_text == pytest.approx(text_pt / 8)
assert s_line == pytest.approx(text_pt / 6)
assert s_text < s_line # 文字更细 → 单张覆盖更小
# ---------------------------------------------------------------------------
# 墨迹网格(纯 PIL)
# ---------------------------------------------------------------------------
def _img(size, draw=None, mode="RGB", bg=(255, 255, 255)):
from PIL import Image, ImageDraw
img = Image.new(mode, size, bg)
if draw:
draw(ImageDraw.Draw(img))
return img
def test_ink_grid_marks_only_dark_regions():
from PIL import ImageDraw
# 左半白、右半黑
img = _img((100, 100), lambda d: d.rectangle([50, 0, 99, 99], fill=(0, 0, 0)))
grid, boxes = sources.ink_grid(img, (0, 0, 100, 100), div=8, max_edge=0)
assert grid["cols"] == 8 and grid["rows"] == 8
occupied = heatmap.occupied_cells(grid)
# 只应命中右半(col >= 4)
assert occupied and all(c >= 4 for _r, c in occupied)
assert boxes and len(boxes) == len(occupied)
def test_ink_grid_blank_image_has_no_boxes():
grid, boxes = sources.ink_grid(_img((64, 64)), (0, 0, 64, 64), div=8, max_edge=0)
assert boxes == []
def test_ink_grid_degenerate_box():
grid, boxes = sources.ink_grid(_img((10, 10)), (0, 0, 0, 0))
assert boxes == [] and grid["cols"] == 0
def test_ink_grid_counts_reflect_density():
from PIL import ImageDraw
# 全黑 → 每格占比 ~1 → counts 应显著大于 1
img = _img((80, 80), lambda d: d.rectangle([0, 0, 79, 79], fill=(0, 0, 0)))
grid, _ = sources.ink_grid(img, (0, 0, 80, 80), div=8, max_edge=0)
assert max(max(row) for row in grid["counts"]) >= 50
def test_ink_grid_respects_threshold():
from PIL import ImageDraw
# 灰 210 比阈值 200 亮 → 不算墨迹
img = _img((40, 40), lambda d: d.rectangle([0, 0, 39, 39], fill=(210, 210, 210)))
_grid, boxes = sources.ink_grid(img, (0, 0, 40, 40), div=8, max_edge=0)
assert boxes == []
def test_ink_grid_box_is_in_world_coords():
from PIL import ImageDraw
img = _img((100, 100), lambda d: d.rectangle([0, 0, 49, 49], fill=(0, 0, 0)))
_grid, boxes = sources.ink_grid(img, (0, 0, 200, 200), div=4, max_edge=0)
for b in boxes:
assert 0 <= b[0] <= b[2] <= 200
assert 0 <= b[1] <= b[3] <= 200
# ---------------------------------------------------------------------------
# ImageReader
# ---------------------------------------------------------------------------
@pytest.fixture
def sample_png(tmp_path):
from PIL import ImageDraw
img = _img((400, 200), lambda d: d.rectangle([10, 10, 110, 90], fill=(0, 0, 0)))
path = tmp_path / "sample.png"
img.save(path)
return str(path)
def test_image_reader_frames(sample_png):
reader = sources.ImageReader(sample_png)
try:
frames = reader.frames()
finally:
reader.close()
assert len(frames) == 1
f = frames[0]
assert f["bbox"] == [0.0, 0.0, 400.0, 200.0]
assert f["source_type"] == "image"
assert f["page_index"] == 0
def test_image_reader_render_crops_world_box(sample_png):
reader = sources.ImageReader(sample_png)
try:
# 世界 (0,0)-(100,100)(y 向上)→ 图像上半左 100x100 区域
out = reader.render((0, 0, 100, 100), 1.0)
finally:
reader.close()
assert out.size == (100, 100)
def test_image_reader_render_never_upscales(sample_png):
reader = sources.ImageReader(sample_png)
try:
out = reader.render((0, 0, 50, 50), 4.0) # zoom>1 不放大
finally:
reader.close()
assert out.size == (50, 50)
def test_image_reader_render_downscales(sample_png):
reader = sources.ImageReader(sample_png)
try:
out = reader.render((0, 0, 400, 200), 0.5)
finally:
reader.close()
assert out.size == (200, 100)
def test_image_reader_text_items_empty(sample_png):
reader = sources.ImageReader(sample_png)
try:
assert reader.text_items(0) == []
finally:
reader.close()
# ---------------------------------------------------------------------------
# open_reader / plan_file / collect(图片端到端,离线)
# ---------------------------------------------------------------------------
def test_open_reader_rejects_cad():
ctx = make_ctx(connect_cad=False)
with pytest.raises(ValueError):
sources.open_reader(ctx)
def test_open_reader_requires_path():
ctx = make_ctx(connect_cad=False, source="image")
with pytest.raises(ValueError):
sources.open_reader(ctx)
def test_plan_file_image(sample_png):
ctx = make_ctx(connect_cad=False, source="image", source_path=sample_png)
try:
plan = sources.plan_file(ctx)
finally:
sources.close_reader(ctx)
assert plan["drawing_info"]["unit"] == "px"
assert plan["drawing_info"]["page_count"] == 1
assert len(plan["frames"]) == 1
assert plan["layers"] == {"total": 0, "items": []}
def test_plan_file_slices_frames_by_max_frames(sample_png):
"""max_frames 对文件源同样生效(用假 reader 造多页,避免依赖真实 PDF)。"""
ctx = make_ctx(connect_cad=False, source="image", source_path=sample_png, max_frames=2)
ctx.source_reader = _FakeReader(page_count=5)
plan = sources.plan_file(ctx)
assert len(plan["frames"]) == 2
assert plan["drawing_info"]["page_count"] == 5
class _FakeReader:
"""最小读取器替身:只实现 plan_file 需要的接口。"""
def __init__(self, page_count=1):
self.page_count = page_count
def frames(self):
return [
{"bbox": [0.0, 0.0, 10.0, 10.0], "page_index": i,
"source_type": "image"}
for i in range(self.page_count)
]
def close(self):
pass
def test_collect_image_end_to_end_offline(sample_png, monkeypatch):
"""图片源取证:同形 evidence + 至少一张整体图 + 世界坐标 window。"""
monkeypatch.setattr(config, "TILING_ENABLED", True)
monkeypatch.setattr(config, "IMAGE_MAX_EDGE", 1280)
ctx = make_ctx(
connect_cad=False, source="image", source_path=sample_png, frame_limit=200
)
try:
plan = sources.plan_file(ctx)
ev = sources.collect(ctx, 1, plan["frames"][0])
finally:
sources.close_reader(ctx)
assert ev["source_type"] == "image"
assert ev["window"] == {"min_x": 0.0, "min_y": 0.0, "max_x": 400.0, "max_y": 200.0}
assert ev["entities"] == {"total": 0, "items": []}
assert ev["texts"] == {"total": 0, "items": []}
assert ev["images"], "至少应有整体图"
assert ev["images"][0]["label"] == "overall"
# 图片确实可解码
assert base64.b64decode(ev["images"][0]["b64"]).startswith(b"\x89PNG")
# 每张图都登记了世界可见范围
for img in ev["images"]:
assert len(img["window"]) == 4
def test_collect_image_without_tiling(sample_png, monkeypatch):
monkeypatch.setattr(config, "TILING_ENABLED", False)
ctx = make_ctx(connect_cad=False, source="image", source_path=sample_png)
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_collect_image_bad_bbox_returns_none(sample_png):
ctx = make_ctx(connect_cad=False, source="image", source_path=sample_png)
try:
assert sources.collect(ctx, 1, {"source_type": "image", "bbox": [0, 0, 1]}) is None
finally:
sources.close_reader(ctx)
def test_reader_is_cached_on_ctx(sample_png):
ctx = make_ctx(connect_cad=False, source="image", source_path=sample_png)
try:
r1 = sources.open_reader(ctx)
r2 = sources.open_reader(ctx)
assert r1 is r2 is ctx.source_reader
finally:
sources.close_reader(ctx)
assert ctx.source_reader is None
# ---------------------------------------------------------------------------
# snap_box(图片源:世界单位 = 源像素,必须整数对齐)
# ---------------------------------------------------------------------------
def test_snap_box_outward():
assert sources.snap_box((1.2, 2.7, 10.4, 20.1)) == (1.0, 2.0, 11.0, 21.0)
def test_snap_box_with_bounds():
assert sources.snap_box((-5.5, -1.2, 300.4, 50.0), (0.0, 0.0, 100.0, 100.0)) == (
0.0, 0.0, 100.0, 50.0)
def test_snap_box_degenerate_returns_none():
assert sources.snap_box((0.0, 0.0, 0.0, 0.0)) is None
assert sources.snap_box(None) is None
def test_image_tile_window_matches_pixels_exactly(sample_png, monkeypatch):
"""图片源瓦片:注册的 window 宽度必须与渲染像素数**精确相等**(倍率恰为 1)。"""
import io as _io
from PIL import Image as _Image
monkeypatch.setattr(config, "TILING_ENABLED", True)
ctx = make_ctx(connect_cad=False, source="image", source_path=sample_png)
try:
plan = sources.plan_file(ctx)
ev = sources.collect(ctx, 1, plan["frames"][0])
finally:
sources.close_reader(ctx)
tiles = [i for i in ev["images"] if i["label"] != "overall"]
assert tiles
for im in tiles:
w = im["window"]
img = _Image.open(_io.BytesIO(base64.b64decode(im["b64"])))
assert img.size[0] == int(w[2] - w[0])
assert img.size[1] == int(w[3] - w[1])