支持直接审查 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 丢图框块,离线侧暂无等价手段),留待后续打磨。
402 lines
13 KiB
Python
402 lines
13 KiB
Python
"""`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])
|