"""实体分布热力图验证:取图框内实体 bbox → 建占用网格 → 聚类 → 渲染 PNG 供人工目视。 不消耗 VLM token(纯几何统计 + 本地渲染)。 用法: python verify_heatmap.py [--frames 3] [--div 32,64] [--dilate 0] [--out reports/_probe] 前置:CAD 在线(127.0.0.1:8080 监听)。不需要 DEEPSEEK_API_KEY。 同时把每个图框的实体 bbox 缓存为 JSON,便于后续**离线**反复调聚类 / 切分参数。 """ import argparse import json from pathlib import Path from dwg_review import config from dwg_review import heatmap from dwg_review.context import ReviewContext from dwg_review.mcp_client import result_json from dwg_review.phases import planning _PAGE = 1000 # query_entities 的 limit 上限 _MAX_PAGES = 20 # 兜底,避免异常分页死循环 def fetch_boxes(tools, window): """分页取回 window 内全部实体的 bbox。 返回 (boxes, total, fetched);total 为工具报告的总数,fetched 为实际取回条数。 """ boxes, offset, total = [], 0, 0 for _ in range(_MAX_PAGES): env = result_json( tools.call("query_entities", {"window": window, "limit": _PAGE, "offset": offset}), {}, ) items = env.get("items") or [] boxes.extend(heatmap.entity_boxes(env)) total = int(env.get("total") or len(items)) offset += len(items) if not items or offset >= total: break return boxes, total, offset def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument("--frames", type=int, default=3, help="最多分析的图框数(0=全部)") ap.add_argument("--div", default="32", help="网格划分(长边切几份),逗号分隔可多档对比") ap.add_argument("--dilate", type=int, default=0, help="聚类前膨胀格数(合并相近内容)") ap.add_argument("--max-area-frac", type=float, default=0.5, help="丢弃 bbox 面积超过图框面积该比例的实体(默认 0.5,去图框边框/图签块)") ap.add_argument("--out", default="reports/_probe") args = ap.parse_args(argv) divs = [int(x) for x in str(args.div).split(",") if x.strip()] ctx = ReviewContext.create(need_llm=False) plan = planning.plan(ctx) frames = plan.get("frames", []) if args.frames > 0: frames = frames[: args.frames] if not frames: print("未识别到图框") return 1 out_dir = Path(config.PROJECT_ROOT) / args.out out_dir.mkdir(parents=True, exist_ok=True) for i, frame in enumerate(frames, 1): bbox = frame.get("bbox") if not bbox or len(bbox) != 4: print(f"[图框 {i}] 缺少有效 bbox,跳过") continue win = { k: float(v) for k, v in zip(("min_x", "min_y", "max_x", "max_y"), bbox) } print(f"\n[图框 {i}] sheet={frame.get('sheet_size')} scale={frame.get('sheet_scale')} " f"bbox={bbox}") boxes, total, fetched = fetch_boxes(ctx.tools, win) kept = heatmap.drop_oversized(boxes, win, args.max_area_frac) dropped = len(boxes) - len(kept) print(f" 实体:工具报告 {total} 条,取回 {fetched} 条,有效 bbox {len(boxes)} 个;" f"丢弃超大实体(图框边框/图签块等){dropped} 个 -> 参与分析 {len(kept)} 个") boxes = kept # 缓存 bbox,便于离线调参 cache = out_dir / f"boxes_frame{i}.json" cache.write_text( json.dumps({"frame": frame, "window": win, "boxes": boxes}, ensure_ascii=False), encoding="utf-8", ) for div in divs: cell = heatmap.div_to_cell(win, div) grid = heatmap.build_grid(win, boxes, cell) clusters = heatmap.cluster_cells(grid, dilate=args.dilate) st = heatmap.grid_stats(grid, clusters) print(f" [div={div}] 格子 {st['cols']}x{st['rows']}(cell={cell:.1f})" f" 占用格 {st['occupied']} ({st['occupied_ratio']:.0%})" f" 最大格计数 {st['max_count']}") print(f" 簇 {st['clusters']} 个,最大簇 {st['max_cluster_cells']} 格 / " f"{st['max_cluster_count']} 个实体") png = out_dir / f"heatmap_frame{i}_div{div}.png" heatmap.render_overlay(png, win, boxes, grid, clusters) print(f" -> {png.name}") # 前几个簇的窗口(后续瓦片规划直接复用) for j, cl in enumerate(clusters[:5], 1): x1, y1, x2, y2 = cl["bbox"] print(f" 簇{j}: {len(cl['cells'])} 格 / {cl['count']} 实体 " f"bbox=({x1:.0f},{y1:.0f},{x2:.0f},{y2:.0f}) " f"尺寸={x2 - x1:.0f}x{y2 - y1:.0f}") print(f"\n输出目录: {out_dir}") return 0 if __name__ == "__main__": raise SystemExit(main())