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:
gjm committed 2026-10-16 23:07:23 +08:00
1 parent d4a9442e30
commit 6143bb9ab2
32 files changed
+2617 -77

No files matched your search

+407
View File
@@ -0,0 +1,407 @@
# -*- coding: utf-8 -*-
"""
verify_pdf_source.py
PDF / 图片**离线源**验收脚本。**不需要 CAD、不需要 8080 端口**(这是本方向的核心卖点)。
用法:
python verify_pdf_source.py "E:\\path\\图纸.pdf"
python verify_pdf_source.py "E:\\path\\图纸.pdf" --pages 3 # 只验前 3 页
python verify_pdf_source.py "E:\\path\\scan.png" # 图片源
python verify_pdf_source.py "E:\\path\\图纸.pdf" --dump out/c1 # 导出图片供目视
python verify_pdf_source.py "E:\\path\\图纸.pdf" --llm # 追加真实 LLM 端到端
覆盖:
0) 离线:全程 ctx.tools is None / ctx.client is None(绝不连 CAD)
1) 规划:页数、页序保持、每页 bbox / 幅面反查
2) 取证:同形 evidence(空结构化信封)、文本层、整体图 + 墨迹瓦片
3) 分辨率自检:**实测**每张图的「纸面字高像素数」,不得低于线条可读下限
4) 旋转自检(/Rotate ≠ 0 且有文本层时):文本矩形处的墨迹密度显著高于错误映射
5) --dump:导出 PNG 供人工目视
6) --llm :跑真实 `python -m dwg_review <file>`,检查报告落盘
"""
import base64
import io
import os
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from PIL import Image # noqa: E402
from dwg_review import config, evidence_model, sources, tiling # noqa: E402
from dwg_review.context import ReviewContext # noqa: E402
from dwg_review.phases import evidence as evidence_phase # noqa: E402
from dwg_review.phases import planning # noqa: E402
RESULTS = []
def record(name, ok, detail=""):
RESULTS.append((name, ok, detail))
print(" [%s] %-34s %s" % ("PASS" if ok else "FAIL", name, detail))
def info(msg):
print(" %s" % msg)
# ---------------------------------------------------------------- 小工具
def dark_frac(img, threshold=None):
"""一张图里「暗像素」(墨迹)的占比。纯 PIL,无 numpy。"""
threshold = config.INK_THRESHOLD if threshold is None else threshold
bw = img.convert("L").point(lambda p: 255 if p < threshold else 0, mode="L")
hist = bw.histogram()
total = sum(hist) or 1
return hist[255] / float(total)
def clip_box(box, page_box):
"""把 bbox 夹到页范围内;退化返回 None。"""
x1 = max(box[0], page_box[0])
y1 = max(box[1], page_box[1])
x2 = min(box[2], page_box[2])
y2 = min(box[3], page_box[3])
return None if x2 <= x1 or y2 <= y1 else (x1, y1, x2, y2)
def png_size(b64):
return Image.open(io.BytesIO(base64.b64decode(b64))).size
def render_scale_of(image_b64, window):
"""实测该图的渲染倍率 = 图像像素宽 / 世界宽。
- PDF 源:世界单位是**点** → 倍率即 px/点,可直接换算纸面字高像素;
- 图片源:世界单位是**源像素** → 倍率是「相对原图的缩放」,应恒 ≤ 1(不放大)。
"""
w_px, _h_px = png_size(image_b64)
w_world = float(window[2]) - float(window[0])
return (w_px / w_world) if w_world > 0 else 0.0
def text_px_of(image_b64, window):
"""纸面字高(TEXT_HEIGHT_MM)在该图上占多少像素。**仅对 PDF 源有意义**。"""
return config.TEXT_HEIGHT_MM * config.PT_PER_MM * render_scale_of(image_b64, window)
# ---------------------------------------------------------------- 主流程
def main():
args = parse_args()
path = Path(args.path).expanduser().resolve()
if not path.is_file():
print("[错误] 文件不存在:%s" % path)
return 2
print("=== PDF / 图片离线源验收 ===")
print("文件: %s" % path)
try:
source = sources.detect_source(str(path))
except ValueError as exc:
print("[错误] %s" % exc)
return 2
if not sources.is_file_source(source):
print("[错误] %s 是 CAD 源,请用 CAD 在线流程(不带参数运行 dwg_review)。" % source)
return 2
print("来源: %s\n" % source)
# ---------------------------------------------------------- 0) 离线
print("0) 离线上下文(不连 CAD)")
ctx = ReviewContext.create(need_llm=False, connect_cad=False,
source=source, source_path=str(path))
record("ctx.tools is None", ctx.tools is None, "离线源不得持有 CAD 工具表")
record("ctx.client is None", ctx.client is None, "离线源不得创建 MCP 客户端")
record("无需 CAD 端口", True, "本脚本全程未连接 %s:%d" % (config.HOST, config.PORT))
try:
return run_checks(ctx, path, args)
finally:
sources.close_reader(ctx)
def parse_args():
import argparse
p = argparse.ArgumentParser(description="PDF / 图片离线源验收")
p.add_argument("path", help="PDF 或图片文件路径")
p.add_argument("--pages", type=int, default=0, help="只验前 N 页(0=全部,默认前 5 页)")
p.add_argument("--dump", default=None, help="把证据图片导出到该目录供目视")
p.add_argument("--llm", action="store_true", help="追加真实 LLM 端到端(需 DEEPSEEK_API_KEY)")
a = p.parse_args()
if a.pages == 0:
a.pages = 5
return a
def run_checks(ctx, path, args):
# ---------------------------------------------------------- 1) 规划
print("\n1) 规划(每页一个图框,保持原始页序)")
plan = planning.plan(ctx)
frames = plan["frames"]
info("drawing_info: %s" % plan["drawing_info"])
record("frames 非空", bool(frames), "共 %d 页" % len(frames))
if not frames:
return 1
idx = [f.get("page_index") for f in frames]
record("页序保持 0..N-1", idx == list(range(len(frames))), "page_index=%s" % idx)
bad = [f for f in frames if not f.get("bbox") or f["bbox"][2] <= 0]
record("每页 bbox 有效", not bad, "非法 %d 页" % len(bad))
if frames:
info("页 0: bbox=%s 幅面=%s" % (frames[0]["bbox"], frames[0].get("sheet_size")))
# ---------------------------------------------------------- 2) 取证
limit = min(args.pages, len(frames))
source_is_pdf = ctx.source == evidence_model.SOURCE_PDF
print("\n2) 取证(前 %d 页)" % limit)
dumps = []
stats = {"tiles": 0, "text_pages": 0, "text_items": 0, "min_text_px": None}
for i in range(limit):
frame = frames[i]
ev = evidence_phase.collect(ctx, i + 1, frame)
if ev is None:
record("页 %d 取证" % i, False, "collect 返回 None")
continue
page_box = tiling.frame_bbox(frame)
win = ev["window"]
assert win is not None
# 非文本结构化键必须是空信封;`texts` 例外(PDF 的 best-effort 文本层就放这里)
struct_ok = all(
ev.get(k) == {"total": 0, "items": []}
for k in evidence_model.STRUCTURED_SOURCES
if k != "texts"
)
texts_env = ev.get("texts") or {}
struct_ok = struct_ok and isinstance(texts_env, dict) and isinstance(
texts_env.get("items"), list
)
imgs = evidence_model.evidence_images(ev)
labels = [im.get("label") for im in imgs]
ok_shape = (
ev["source_type"] == evidence_model.source_type_of(frame)
and imgs and labels[0] == "overall"
)
record(
"页 %d evidence 同形" % i,
struct_ok and ok_shape,
"结构化=空信封, 图=%s, 文本层=%d 条"
% (labels, (ev.get("texts") or {}).get("total", 0)),
)
# 每张图:PNG 可解码、世界窗口在图框内、实测渲染倍率
bad_win = []
tile_px, tile_zoom, overall_px = [], [], None
for im in imgs:
w = im.get("window")
if not w or len(w) != 4 or w[0] < -1e-6 or w[1] < -1e-6 or \
w[2] > page_box[2] + 1e-6 or w[3] > page_box[3] + 1e-6:
bad_win.append(im.get("label"))
continue
try:
px = png_size(im["b64"])
except Exception as exc: # noqa: BLE001
bad_win.append("%s(解码失败:%s)" % (im.get("label"), exc))
continue
scale = render_scale_of(im["b64"], w)
tpx = text_px_of(im["b64"], w)
# 整体图是「整页缩略」——字读不清属**设计如此**(细节由瓦片负责),不参与达标判定
if im.get("label") == "overall":
overall_px = tpx
else:
tile_px.append(tpx)
tile_zoom.append(scale)
if max(px) > config.IMAGE_MAX_EDGE + 1:
bad_win.append("%s(超长边 %d)" % (im.get("label"), max(px)))
if args.dump:
dumps.append((i, im.get("label"), im["b64"], px, w, tpx))
record("页 %d 图片世界窗口在图框内" % i, not bad_win, "越界/异常: %s" % (bad_win or "无"))
# 分辨率自检
floor = config.LINE_READABLE_PX - 0.5
if tile_px:
page_min = min(tile_px)
stats["min_text_px"] = (
page_min if stats["min_text_px"] is None
else min(stats["min_text_px"], page_min)
)
n_text_ok = sum(1 for v in tile_px if v >= config.TEXT_READABLE_PX - 0.5)
detail = ("瓦片 %d 张,最差 %.1fpx(下限 %.1f;达文字档 %.1fpx 的 %d 张);"
"整体图 %.1fpx" % (
len(tile_px), page_min, floor,
config.TEXT_READABLE_PX, n_text_ok,
overall_px if overall_px is not None else -1))
if source_is_pdf:
record("页 %d 瓦片分辨率达标" % i, page_min >= floor, detail)
else:
# 图片源不允许放大(IMAGE_MAX_ZOOM=1),倍率必须 ≤1
record(
"页 %d 图片瓦片未放大" % i,
max(tile_zoom) <= config.IMAGE_MAX_ZOOM + 1e-6,
"倍率 %.2f~%.2f(上限 %.2f);%s"
% (min(tile_zoom), max(tile_zoom), config.IMAGE_MAX_ZOOM, detail),
)
stats["tiles"] += max(0, len(imgs) - 1)
if (ev.get("texts") or {}).get("total"):
stats["text_pages"] += 1
stats["text_items"] += ev["texts"]["total"]
# 旋转自检
check_rotation(ctx, frame, ev, i)
info(
"瓦片合计 %d 张;有文本层的页 %d;文本片段 %d;%s"
% (
stats["tiles"], stats["text_pages"], stats["text_items"],
("最差字高像素 %s" % ("%.1f" % stats["min_text_px"])
if stats["min_text_px"] is not None and source_is_pdf
else "(图片源:倍率 ≤1,不放大)" if not source_is_pdf else "n/a"),
)
)
if stats["min_text_px"] is not None and source_is_pdf:
record(
"全局瓦片分辨率达标",
stats["min_text_px"] >= config.LINE_READABLE_PX - 0.5,
"最差 %.1fpx(下限 %.1f)" % (stats["min_text_px"], config.LINE_READABLE_PX),
)
# ---------------------------------------------------------- 5) 导出
if args.dump and dumps:
out = Path(args.dump)
out.mkdir(parents=True, exist_ok=True)
for i, label, b64, px, w, tpx in dumps:
name = "p%02d_%s.png" % (i, label)
(out / name).write_bytes(base64.b64decode(b64))
info("导出 %s %sx%s 倍率=%.2f 世界=%s"
% (name, px[0], px[1], render_scale_of(b64, w),
[round(v, 1) for v in w]))
record("导出目视包", True, "%d 张 → %s" % (len(dumps), out))
# ---------------------------------------------------------- 6) LLM
if args.llm:
print("\n6) 真实 LLM 端到端(python -m dwg_review)")
if not config.has_api_key():
record("LLM 端到端", True, "跳过:未配置 DEEPSEEK_API_KEY")
else:
report_dir = Path(args.dump or "out/verify_pdf_source") / "report"
env = dict(os.environ, REPORT_DIR=str(report_dir))
cmd = [sys.executable, "-m", "dwg_review", str(path), "--frames", "1"]
print(" $ %s" % " ".join(cmd))
proc = subprocess.run(cmd, env=env, capture_output=True, text=True,
encoding="utf-8", errors="replace")
tail = (proc.stdout or "")[-1500:]
print(tail)
if proc.stderr:
print("[stderr] %s" % proc.stderr[-800:])
mds = sorted(report_dir.glob("review_*.md")) if report_dir.exists() else []
jsons = sorted(report_dir.glob("review_*.json")) if report_dir.exists() else []
record("退出码 0", proc.returncode == 0, "returncode=%s" % proc.returncode)
record("报告已落盘", bool(mds and jsons),
"%s" % (mds[-1].name if mds else "无"))
# ---------------------------------------------------------- 汇总
print("\n=== 汇总 ===")
failed = [r for r in RESULTS if not r[1]]
for name, _ok, detail in RESULTS:
print(" [%s] %-34s %s" % ("PASS" if _ok else "FAIL", name, detail))
print("\n通过 %d / %d" % (len(RESULTS) - len(failed), len(RESULTS)))
if failed:
print("失败项:%s" % ", ".join(r[0] for r in failed))
return 1
return 0
def _unrotate_point(X, Y, mw, mh, rotation):
"""`sources.rotate_point` 的逆:世界坐标 → 未旋转坐标。"""
r = int(rotation) % 360
if r == 90:
return (mw - Y, X)
if r == 180:
return (mw - X, mh - Y)
if r == 270:
return (Y, mh - X)
return (X, Y)
def _unrotate_box(world, mw, mh, rotation):
x0, y0, x1, y1 = world
pts = [
_unrotate_point(x, y, mw, mh, rotation)
for x, y in ((x0, y0), (x0, y1), (x1, y0), (x1, y1))
]
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
return (min(xs), min(ys), max(xs), max(ys))
def _render_raw(reader, page_index, box, zoom, dims):
"""按**指定页尺寸**渲染(`dims` 决定 crop 基准)。
`PdfReader.render` 固定以「旋转后页尺寸」为 crop 基准;做「忽略旋转」对照时
需要用 MediaBox 尺寸作基准,故这里直接调 pdfium。
"""
page = reader.doc[page_index]
bitmap = page.render(scale=zoom, rotation=0,
crop=sources.crop_of(box, dims[0], dims[1]))
return bitmap.to_pil()
def check_rotation(ctx, frame, ev, page_index):
"""旋转自检:`/Rotate` 映射必须让「文本矩形」落在真有字的地方。
两条判据(都来自当初的手工探针:rot90 命中率 0.335 vs 其它假设 ≤0.064):
a) 文本矩形处的墨迹密度 **明显高于** 整页平均(映射错了就会落进空白);
b) 同一文本矩形若**忽略** `/Rotate` 直接当世界坐标,密度应更低(反向对照)。
"""
items = (ev.get("texts") or {}).get("items") or []
reader = ctx.source_reader
if not items or not isinstance(reader, sources.PdfReader):
return
page_idx = frame.get("page_index", 0)
rw, rh, mw, mh, rot = reader._meta(page_idx)
if rot % 360 == 0:
return
page_box = tiling.frame_bbox(frame)
rects = [it["bbox"] for it in items
if it.get("bbox") and it["bbox"][2] > it["bbox"][0]]
if not rects:
return
zoom = 4.0
page_img = reader.render(page_box, 2.0, page_idx)
page_density = dark_frac(page_img) if page_img is not None else 0.0
hits, wrongs = [], []
for rect in rects[:20]:
correct = clip_box(rect, page_box)
if correct is None:
continue
img = reader.render(correct, zoom, page_idx)
if img is not None:
hits.append(dark_frac(img))
wrong_box = _unrotate_box(rect, mw, mh, rot)
img_w = _render_raw(reader, page_idx, wrong_box, zoom, (mw, mh))
if img_w is not None:
wrongs.append(dark_frac(img_w))
if not hits:
return
mean_hit = sum(hits) / len(hits)
record(
"页 %d 旋转映射命中文本区(/Rotate %d)" % (page_index, rot),
mean_hit > page_density * 1.5 and mean_hit > 0.05,
"文本区墨迹=%.3f vs 整页平均=%.3f(n=%d)" % (mean_hit, page_density, len(hits)),
)
if wrongs:
mean_wrong = sum(wrongs) / len(wrongs)
record(
"页 %d 反向对照(忽略旋转更差)" % page_index,
mean_hit > mean_wrong,
"正确=%.3f vs 忽略旋转=%.3f" % (mean_hit, mean_wrong),
)
if __name__ == "__main__":
sys.exit(main())