- 新增 dwg_review/heatmap.py:entity_boxes / build_grid(counts+coverage)/ occupied_cells / cluster_cells(4-连通,可选膨胀合并)/ grid_to_windows / grid_stats / render_overlay - drop_oversized:过滤 bbox 占满图框的实体(实测每框恰 1 个,即图框块),否则热力图全满 - 新增 verify_heatmap.py:取实体 bbox(分页)-> 建网格 -> 聚类 -> 渲染 PNG,并缓存 bbox JSON 供离线调参 - 新增 tests/test_heatmap.py(22 项)
126 lines
4.9 KiB
Python
126 lines
4.9 KiB
Python
"""实体分布热力图验证:取图框内实体 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())
|