From 9b8a2329df1038a2215d1234c18f207fd9ac4ac2 Mon Sep 17 00:00:00 2001 From: LittleGuo Date: Thu, 15 Oct 2026 22:51:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(heatmap):=20=E5=AE=9E=E4=BD=93=E5=88=86?= =?UTF-8?q?=E5=B8=83=E7=83=AD=E5=8A=9B=E5=9B=BE=EF=BC=88=E5=8D=A0=E7=94=A8?= =?UTF-8?q?=E7=BD=91=E6=A0=BC=20+=20=E8=81=9A=E7=B1=BB=20+=20=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 项) --- dwg_review/heatmap.py | 337 ++++++++++++++++++++++++++++++++++++++++++ tests/test_heatmap.py | 173 ++++++++++++++++++++++ verify_heatmap.py | 125 ++++++++++++++++ 3 files changed, 635 insertions(+) create mode 100644 dwg_review/heatmap.py create mode 100644 tests/test_heatmap.py create mode 100644 verify_heatmap.py diff --git a/dwg_review/heatmap.py b/dwg_review/heatmap.py new file mode 100644 index 0000000..40d9b6f --- /dev/null +++ b/dwg_review/heatmap.py @@ -0,0 +1,337 @@ +"""实体分布热力图:把实体 AABB 聚合为「占用网格 + 簇」,供自适应瓦片规划使用。 + +纯函数模块(仅依赖 `math`);可视化 `render_overlay` 内部按需 import Pillow, +使本模块在无 Pillow 的环境下仍可被单测与主流程引用。 + +坐标约定(与 CAD 一致): + - 世界坐标 x 向右、y **向上**;bbox = (min_x, min_y, max_x, max_y)。 + - 网格 row 0 在**最上方**、col 0 在**最左**(与截图 / 图片一致),便于直接对照画面。 +""" + +import math + +# 退化 bbox(线/点)的膨胀比例(相对格尺寸),保证零宽/零高的实体也能被计入 +_EPS_FRAC = 1e-3 + + +def norm_box(b): + """把任意 bbox 归一为 (minx,miny,maxx,maxy) 的 float 元组;非法返回 None。""" + if not isinstance(b, (list, tuple)) or len(b) != 4: + return None + try: + x1, y1, x2, y2 = (float(v) for v in b) + except (TypeError, ValueError): + return None + if not all(math.isfinite(v) for v in (x1, y1, x2, y2)): + return None + if x1 > x2: + x1, x2 = x2, x1 + if y1 > y2: + y1, y2 = y2, y1 + return (x1, y1, x2, y2) + + +def entity_boxes(envelopes): + """从若干查询信封收集合法实体 bbox。 + + envelopes 可以是单个信封 dict,也可以是信封的可迭代集合;每个信封形如 + `{"items": [{"bbox": [..]}, ...], "total": N}`(其它字段忽略)。 + 返回 [(minx,miny,maxx,maxy), ...];缺字段 / 非法的条目直接跳过。 + """ + if isinstance(envelopes, dict): + envelopes = [envelopes] + out = [] + for env in envelopes or []: + if not isinstance(env, dict): + continue + items = env.get("items") + if not isinstance(items, list): + continue + for it in items: + if not isinstance(it, dict): + continue + box = norm_box(it.get("bbox")) + if box is not None: + out.append(box) + return out + + +def box_area(b): + """bbox 面积。""" + return max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1]) + + +def union_box(boxes): + """若干 bbox 的并集包围盒;空集合返回 None。""" + boxes = [b for b in boxes if b] + if not boxes: + return None + return ( + min(b[0] for b in boxes), + min(b[1] for b in boxes), + max(b[2] for b in boxes), + max(b[3] for b in boxes), + ) + + +def drop_oversized(boxes, window, max_frac=0.5): + """丢弃 bbox 面积超过图框面积 `max_frac` 的实体。 + + 这类实体多是**图框边框 / 图签块**等"结构"(实测每个图框恰有 1 个 bbox 等于整幅图框), + 其 bbox 会铺满整张热力图、掩盖真正的细节分布;它们无细节可读,整体图已能覆盖。 + """ + area = (float(window["max_x"]) - float(window["min_x"])) * ( + float(window["max_y"]) - float(window["min_y"]) + ) + if area <= 0: + return list(boxes) + limit = area * float(max_frac) + return [b for b in boxes if box_area(b) <= limit] + + +def div_to_cell(window, div): + """按「长边切 div 份」求格子世界尺寸。""" + w = float(window["max_x"]) - float(window["min_x"]) + h = float(window["max_y"]) - float(window["min_y"]) + div = max(1, int(div)) + return max(w, h) / div + + +def build_grid(window, boxes, cell): + """把 window 按 cell 尺寸切成网格,统计每格命中的实体数与覆盖比例。 + + 返回 dict: + {"window", "cols", "rows", "cell_w", "cell_h", + "counts": [[int]], "coverage": [[float]]} + - counts[r][c] :与格 (r,c) 相交的实体数(同一实体跨多格则各计一次); + - coverage[r][c]:该格被实体 bbox 覆盖的面积比例(0~1,多实体叠加后夹到 1)。 + """ + min_x, min_y = float(window["min_x"]), float(window["min_y"]) + max_x, max_y = float(window["max_x"]), float(window["max_y"]) + cell = float(cell) + if cell <= 0 or max_x <= min_x or max_y <= min_y: + return { + "window": window, "cols": 0, "rows": 0, "cell_w": 0.0, "cell_h": 0.0, + "counts": [], "coverage": [], + } + + cols = max(1, int(math.ceil((max_x - min_x) / cell))) + rows = max(1, int(math.ceil((max_y - min_y) / cell))) + cw = (max_x - min_x) / cols + ch = (max_y - min_y) / rows + counts = [[0] * cols for _ in range(rows)] + coverage = [[0.0] * cols for _ in range(rows)] + + for b in boxes: + bx1, by1, bx2, by2 = b + # 退化 bbox(水平/垂直线、点)宽或高为 0,按格尺寸的千分之一膨胀,保证能被计入 + if bx2 - bx1 <= 0: + bx1 -= cw * _EPS_FRAC * 0.5 + bx2 += cw * _EPS_FRAC * 0.5 + if by2 - by1 <= 0: + by1 -= ch * _EPS_FRAC * 0.5 + by2 += ch * _EPS_FRAC * 0.5 + # 与 window 求交,完全在外的直接跳过 + bx1, by1 = max(bx1, min_x), max(by1, min_y) + bx2, by2 = min(bx2, max_x), min(by2, max_y) + if bx1 > bx2 or by1 > by2: + continue + c0 = min(max(int((bx1 - min_x) / cw), 0), cols - 1) + c1 = min(max(int((bx2 - min_x) / cw), 0), cols - 1) + # y 向上 → row 0 在最上方 + r0 = min(max(int((max_y - by2) / ch), 0), rows - 1) + r1 = min(max(int((max_y - by1) / ch), 0), rows - 1) + for r in range(r0, r1 + 1): + cy_top = max_y - r * ch + cy_bot = cy_top - ch + for c in range(c0, c1 + 1): + cx1 = min_x + c * cw + cx2 = cx1 + cw + ix = max(0.0, min(bx2, cx2) - max(bx1, cx1)) + iy = max(0.0, min(by2, cy_top) - max(by1, cy_bot)) + # 用真实交面积判定命中,避免恰好落在格边界上的实体被计入相邻空格 + if ix <= 0 or iy <= 0: + continue + counts[r][c] += 1 + coverage[r][c] = min(1.0, coverage[r][c] + (ix * iy) / (cw * ch)) + + return { + "window": window, "cols": cols, "rows": rows, + "cell_w": cw, "cell_h": ch, "counts": counts, "coverage": coverage, + } + + +def occupied_cells(grid, min_count=1): + """占用格坐标列表 [(row, col), ...](counts >= min_count),行优先。""" + counts = grid.get("counts") or [] + out = [] + for r, row in enumerate(counts): + for c, n in enumerate(row): + if n >= min_count: + out.append((r, c)) + return out + + +def _dilate(mask, radius): + """把布尔占用掩码向外膨胀 radius 格(半径 0 原样返回)。""" + if radius <= 0: + return mask + rows, cols = len(mask), len(mask[0]) if mask else 0 + out = [[False] * cols for _ in range(rows)] + for r in range(rows): + for c in range(cols): + if not mask[r][c]: + continue + for dr in range(-radius, radius + 1): + for dc in range(-radius, radius + 1): + rr, cc = r + dr, c + dc + if 0 <= rr < rows and 0 <= cc < cols: + out[rr][cc] = True + return out + + +def cluster_cells(grid, min_count=1, dilate=0): + """把占用格按 4-连通聚成若干簇(可选先膨胀 dilate 格,合并相近内容)。 + + 返回按「实体计数降序」排列的簇列表,每项: + {"bbox": (minx,miny,maxx,maxy), "cells": [(r,c),...], "count": int} + bbox 为该簇**实际占用格**的并集(不含膨胀出来的空格),故不会虚增范围。 + """ + rows, cols = grid.get("rows", 0), grid.get("cols", 0) + counts = grid.get("counts") or [] + if not rows or not cols or not counts: + return [] + + raw = [[counts[r][c] >= min_count for c in range(cols)] for r in range(rows)] + mask = _dilate(raw, int(dilate)) + + seen = [[False] * cols for _ in range(rows)] + clusters = [] + for r in range(rows): + for c in range(cols): + if not mask[r][c] or seen[r][c]: + continue + stack = [(r, c)] + seen[r][c] = True + cells = [] + while stack: + cr, cc = stack.pop() + if raw[cr][cc]: + cells.append((cr, cc)) + for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nr, nc = cr + dr, cc + dc + if 0 <= nr < rows and 0 <= nc < cols and mask[nr][nc] and not seen[nr][nc]: + seen[nr][nc] = True + stack.append((nr, nc)) + if not cells: + continue # 膨胀出来的孤立空格,无实体,丢弃 + boxes = [_cell_box(grid, cr, cc) for cr, cc in cells] + clusters.append({ + "bbox": union_box(boxes), + "cells": sorted(cells), + "count": sum(counts[cr][cc] for cr, cc in cells), + }) + + clusters.sort(key=lambda cl: (-cl["count"], -cl["bbox"][3], cl["bbox"][0])) + return clusters + + +def _cell_box(grid, r, c): + """格 (r,c) 的世界 bbox(row 0 在上)。""" + win = grid["window"] + min_x, max_y = float(win["min_x"]), float(win["max_y"]) + cw, ch = grid["cell_w"], grid["cell_h"] + x1 = min_x + c * cw + y_top = max_y - r * ch + return (x1, y_top - ch, x1 + cw, y_top) + + +def grid_to_windows(clusters, margin=0.0): + """把簇 bbox 转成 zoom 窗口列表(保持输入顺序,即实体计数降序)。""" + out = [] + for cl in clusters: + x1, y1, x2, y2 = cl["bbox"] + w, h = x2 - x1, y2 - y1 + mx, my = w * margin, h * margin + out.append({ + "min_x": x1 - mx, "min_y": y1 - my, + "max_x": x2 + mx, "max_y": y2 + my, + }) + return out + + +def grid_stats(grid, clusters=None): + """统计摘要(供日志 / 目视对照):占用率、实体数分布、簇数与最大簇。""" + rows, cols = grid.get("rows", 0), grid.get("cols", 0) + total = rows * cols + occ = occupied_cells(grid) + counts = grid.get("counts") or [] + hits = [n for row in counts for n in row if n > 0] + stats = { + "rows": rows, "cols": cols, + "occupied": len(occ), + "occupied_ratio": (len(occ) / total) if total else 0.0, + "entity_cell_hits": len(hits), + "max_count": max(hits) if hits else 0, + } + if clusters is not None: + stats["clusters"] = len(clusters) + stats["max_cluster_cells"] = max((len(cl["cells"]) for cl in clusters), default=0) + stats["max_cluster_count"] = max((cl["count"] for cl in clusters), default=0) + return stats + + +def render_overlay(path, window, boxes, grid, clusters=None, *, size=1200, bg=(18, 18, 18)): + """把热力图渲染成 PNG 供人工目视:窗口边框 + 实体 bbox 轮廓 + 热力着色 + 簇框。 + + - 长边缩放到 size 像素,保持世界坐标长宽比; + - 热力色:占用越密越亮(绿→黄→红); + - 簇框用青色粗线标出。 + 需要 Pillow;缺失时抛 RuntimeError(调用方自行决定是否忽略)。 + """ + try: + from PIL import Image, ImageDraw + except ImportError as exc: # pragma: no cover - 依赖缺失 + raise RuntimeError("渲染热力图需要 Pillow") from exc + + min_x, min_y = float(window["min_x"]), float(window["min_y"]) + max_x, max_y = float(window["max_x"]), float(window["max_y"]) + w, h = max_x - min_x, max_y - min_y + if w <= 0 or h <= 0: + raise ValueError("window 尺寸非法") + + scale = size / max(w, h) + iw, ih = max(1, int(round(w * scale))), max(1, int(round(h * scale))) + img = Image.new("RGB", (iw, ih), bg) + draw = ImageDraw.Draw(img) + + def to_px(x, y): + return (int(round((x - min_x) * scale)), int(round((max_y - y) * scale))) + + # 热力格子 + counts = grid.get("counts") or [] + max_count = max((n for row in counts for n in row), default=0) or 1 + for r, row in enumerate(counts): + for c, n in enumerate(row): + if n <= 0: + continue + x1, y1, x2, y2 = _cell_box(grid, r, c) + p1, p2 = to_px(x1, y2), to_px(x2, y1) + t = min(1.0, n / max_count) + color = (int(60 + 195 * t), int(200 - 140 * t), int(60 - 40 * t)) + draw.rectangle([p1, p2], fill=color) + + # 实体 bbox 轮廓(细线) + for b in boxes: + draw.rectangle([to_px(b[0], b[3]), to_px(b[2], b[1])], outline=(90, 90, 90)) + + # 簇框(青色粗线) + for cl in clusters or []: + b = cl["bbox"] + draw.rectangle([to_px(b[0], b[3]), to_px(b[2], b[1])], outline=(0, 220, 220), width=2) + + # 窗口边框(白色) + draw.rectangle([to_px(min_x, max_y), to_px(max_x, min_y)], outline=(255, 255, 255), width=1) + + img.save(str(path)) + return path diff --git a/tests/test_heatmap.py b/tests/test_heatmap.py new file mode 100644 index 0000000..eeaf7a6 --- /dev/null +++ b/tests/test_heatmap.py @@ -0,0 +1,173 @@ +"""`dwg_review.heatmap` 单元测试(纯函数,不依赖 CAD / LLM / Pillow)。""" + +from dwg_review import heatmap as hm + +WIN = {"min_x": 0.0, "min_y": 0.0, "max_x": 100.0, "max_y": 100.0} + + +def _grid(boxes, cell=50.0): + return hm.build_grid(WIN, boxes, cell) + + +# --- norm_box / entity_boxes ------------------------------------------------ + + +def test_norm_box_valid(): + assert hm.norm_box([0, 0, 10, 20]) == (0.0, 0.0, 10.0, 20.0) + + +def test_norm_box_swaps_reversed(): + assert hm.norm_box([10, 20, 0, 0]) == (0.0, 0.0, 10.0, 20.0) + + +def test_norm_box_invalid(): + assert hm.norm_box([1, 2, 3]) is None + assert hm.norm_box("x") is None + assert hm.norm_box(None) is None + assert hm.norm_box([1, 2, 3, "a"]) is None + assert hm.norm_box([0, 0, float("nan"), 1]) is None + + +def test_entity_boxes_accepts_single_envelope(): + env = {"items": [{"bbox": [0, 0, 1, 1]}, {"bbox": [2, 2, 3, 3]}], "total": 2} + assert hm.entity_boxes(env) == [(0.0, 0.0, 1.0, 1.0), (2.0, 2.0, 3.0, 3.0)] + + +def test_entity_boxes_accepts_iterable_and_skips_invalid(): + envs = [ + {"items": [{"bbox": [0, 0, 1, 1]}, {"nope": 1}, "junk"]}, + {"items": "not-a-list"}, + None, + {"items": [{"bbox": [5, 5, 6, 6]}]}, + ] + assert hm.entity_boxes(envs) == [(0.0, 0.0, 1.0, 1.0), (5.0, 5.0, 6.0, 6.0)] + + +def test_entity_boxes_empty(): + assert hm.entity_boxes(None) == [] + assert hm.entity_boxes({}) == [] + assert hm.entity_boxes({"items": []}) == [] + + +# --- box helpers ------------------------------------------------------------ + + +def test_box_area_and_union(): + assert hm.box_area((0, 0, 10, 5)) == 50.0 + assert hm.box_area((10, 0, 0, 5)) == 0.0 # 退化(未归一) + assert hm.union_box([(0, 0, 10, 10), (5, 5, 20, 30)]) == (0.0, 0.0, 20.0, 30.0) + assert hm.union_box([]) is None + + +def test_div_to_cell_uses_long_edge(): + assert hm.div_to_cell(WIN, 4) == 25.0 + + +# --- build_grid ------------------------------------------------------------- + + +def test_build_grid_shape_and_left_half(): + g = _grid([(0, 0, 50, 100)]) + assert (g["cols"], g["rows"]) == (2, 2) + assert (g["cell_w"], g["cell_h"]) == (50.0, 50.0) + # 左半格两行各命中一次,右半格不命中(恰好落在 x=50 边界上不算越界) + assert [row[0] for row in g["counts"]] == [1, 1] + assert [row[1] for row in g["counts"]] == [0, 0] + assert g["coverage"][0][0] == 1.0 + assert g["coverage"][1][0] == 1.0 + + +def test_build_grid_row0_is_top(): + g = _grid([(0, 60, 10, 70)]) + assert g["counts"][0][0] == 1 + assert g["counts"][1][0] == 0 + + +def test_build_grid_degenerate_horizontal_line_counted(): + # 高度为 0 的线(bbox 退化)也要能被计入,且横跨两列 + g = _grid([(0, 25, 100, 25)]) + assert g["counts"][1] == [1, 1] + assert g["counts"][0] == [0, 0] + + +def test_build_grid_ignores_boxes_outside_window(): + g = _grid([(200, 200, 300, 300)]) + assert sum(sum(row) for row in g["counts"]) == 0 + + +def test_build_grid_invalid_window(): + g = hm.build_grid({"min_x": 0, "min_y": 0, "max_x": 0, "max_y": 10}, [], 5) + assert g["cols"] == 0 and g["rows"] == 0 and g["counts"] == [] + + +# --- occupied / cluster ----------------------------------------------------- + + +def test_occupied_cells_row_major(): + g = _grid([(0, 0, 50, 100)]) + assert hm.occupied_cells(g) == [(0, 0), (1, 0)] + + +def test_cluster_cells_separate_diagonal(): + g = _grid([(5, 5, 15, 15), (85, 85, 95, 95)]) + assert sorted(hm.occupied_cells(g)) == [(0, 1), (1, 0)] + clusters = hm.cluster_cells(g) + assert len(clusters) == 2 # 对角相邻不算连通 + boxes = sorted(cl["bbox"] for cl in clusters) + assert boxes == [(0.0, 0.0, 50.0, 50.0), (50.0, 50.0, 100.0, 100.0)] + + +def test_cluster_cells_dilate_merges_nearby(): + g = _grid([(5, 5, 15, 15), (85, 85, 95, 95)]) + clusters = hm.cluster_cells(g, dilate=1) + assert len(clusters) == 1 + # 两格合并后 bbox 为两真实占用格的并集(膨胀出来的空格不参与) + assert clusters[0]["bbox"] == (0.0, 0.0, 100.0, 100.0) + + +def test_cluster_cells_sorted_by_count_desc(): + # 左下格被 3 个实体命中,右上格 1 个 + g = _grid([(0, 0, 10, 10), (0, 20, 10, 30), (0, 40, 10, 50), (90, 90, 100, 100)]) + clusters = hm.cluster_cells(g) + assert clusters[0]["count"] == 3 + assert clusters[0]["bbox"] == (0.0, 0.0, 50.0, 50.0) + + +def test_cluster_cells_empty_grid(): + assert hm.cluster_cells(_grid([])) == [] + + +# --- windows / stats -------------------------------------------------------- + + +def test_grid_to_windows_applies_margin(): + clusters = [{"bbox": (0.0, 0.0, 100.0, 50.0), "cells": [], "count": 1}] + (win,) = hm.grid_to_windows(clusters, margin=0.1) + assert win == {"min_x": -10.0, "min_y": -5.0, "max_x": 110.0, "max_y": 55.0} + + +def test_grid_to_windows_preserves_order(): + clusters = [ + {"bbox": (0.0, 0.0, 1.0, 1.0), "cells": [], "count": 9}, + {"bbox": (2.0, 2.0, 3.0, 3.0), "cells": [], "count": 1}, + ] + wins = hm.grid_to_windows(clusters) + assert [w["min_x"] for w in wins] == [0.0, 2.0] + + +def test_grid_stats(): + g = _grid([(0, 0, 50, 100)]) + clusters = hm.cluster_cells(g) + st = hm.grid_stats(g, clusters) + assert st["rows"] == 2 and st["cols"] == 2 + assert st["occupied"] == 2 + assert st["occupied_ratio"] == 0.5 + assert st["max_count"] == 1 + assert st["clusters"] == 1 + assert st["max_cluster_cells"] == 2 + + +def test_grid_stats_without_clusters(): + st = hm.grid_stats(_grid([])) + assert st["occupied"] == 0 + assert "clusters" not in st diff --git a/verify_heatmap.py b/verify_heatmap.py new file mode 100644 index 0000000..d573bf2 --- /dev/null +++ b/verify_heatmap.py @@ -0,0 +1,125 @@ +"""实体分布热力图验证:取图框内实体 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())