支持直接审查 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 丢图框块,离线侧暂无等价手段),留待后续打磨。
408 lines
16 KiB
Python
408 lines
16 KiB
Python
# -*- 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())
|