Files
cad-agent/dwg_review/sources.py
T

593 lines
23 KiB
Python
Raw Normal View History

"""非 CAD 源适配:PDF / 图片图纸的**离线**取证(不连 CAD)。
设计要点
--------
1. **世界坐标系**(两种源统一,且都 y 向上,与 `heatmap` / `tiling` 约定一致):
- PDF :页内**点**坐标,原点左下,已应用 `/Rotate`(即 `page.get_size()` 的朝向);
- 图片:**像素**坐标,原点左下。
故 `window` / `bbox` 与 CAD 侧同形,可直接喂给 `tiling` / `heatmap` 的纯函数。
2. **瓦片规划复用 CAD 侧链路**:把整页渲染(图片则读入)成一张缩略图 → 灰度二值化求
墨迹密度 → 量化成与 `heatmap.build_grid` 同形的占用网格 → `heatmap.cluster_cells`
聚类 → `tiling.plan_regions` 出瓦片窗口。**无需实体数据**,只靠像素墨迹。
3. **每张瓦片的渲染倍率由瓦片世界尺寸反推**:`zoom = IMAGE_MAX_EDGE / max(w,h)`,
再夹到源允许的上限(PDF `PDF_TILE_MAX_ZOOM`;图片 `IMAGE_MAX_ZOOM`=1,**不放大**)。
这与 `plan_regions` 的 `s_text` / `s_line` 是同一套数学:规划出的文字簇瓦片恰好
落在 `zoom_text = TEXT_READABLE_PX / (TEXT_HEIGHT_MM · PT_PER_MM)`。
4. **文本层是次要的**:国标图纸的文字多为 SHX 描边(导出后变成线条),文本层几乎为空
(实测某暖通图纸:正文页 0 个文本矩形)。仅当某页确有文本层(目录页 / 表格)时
best-effort 抽取,塞进 `texts` 信封;主力识别交给 VLM 看渲染图。
5. `pypdfium2` **惰性 import**:只在真的处理 PDF 时导入,CAD / 图片路径零额外依赖。
"""
import base64
import io
import math
import os
from . import config, evidence_model, heatmap, tiling
_LABEL_OVERALL = "overall"
_PDF_EXTS = frozenset({".pdf"})
_IMAGE_EXTS = frozenset(
{".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp", ".gif"}
)
_CAD_EXTS = frozenset({".dwg", ".dxf", ".dwt"})
# ---------------------------------------------------------------------------
# 来源判定
# ---------------------------------------------------------------------------
def detect_source(path):
"""按扩展名判定来源类型(`cad` / `pdf` / `image`);未知格式抛 ValueError。"""
ext = os.path.splitext(str(path))[1].lower()
if ext in _PDF_EXTS:
return evidence_model.SOURCE_PDF
if ext in _IMAGE_EXTS:
return evidence_model.SOURCE_IMAGE
if ext in _CAD_EXTS:
return evidence_model.SOURCE_CAD
raise ValueError(
f"无法识别的图纸格式:{path}(支持 .dwg/.dxf/.pdf 与常见图片格式)"
)
def is_file_source(source):
"""是否为文件源(非 CAD)。"""
return source in (evidence_model.SOURCE_PDF, evidence_model.SOURCE_IMAGE)
# ---------------------------------------------------------------------------
# PDF 坐标换算(纯函数,可单测)
# ---------------------------------------------------------------------------
def rotate_point(x, y, media_w, media_h, rotation):
"""PDF **未旋转**坐标 → **已旋转**(显示)坐标。
`media_w`/`media_h` 为 MediaBox 宽高(未旋转)。rotation 取 0/90/180/270。
注:仅 `/Rotate 90` 经实测验证(墨迹命中率 0.335 vs 其它假设 ≤0.064,并目视确认)。
"""
r = int(rotation) % 360
if r == 90:
return (y, media_w - x)
if r == 180:
return (media_w - x, media_h - y)
if r == 270:
return (media_h - y, x)
return (x, y)
def rect_to_world(rect, media_w, media_h, rotation):
"""未旋转 rect(l,b,r,t) → 已旋转世界 bbox(minx,miny,maxx,maxy)。"""
x0, y0, x1, y1 = (float(v) for v in rect)
pts = [
rotate_point(x0, y0, media_w, media_h, rotation),
rotate_point(x0, y1, media_w, media_h, rotation),
rotate_point(x1, y0, media_w, media_h, rotation),
rotate_point(x1, y1, media_w, media_h, rotation),
]
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
return (min(xs), min(ys), max(xs), max(ys))
def crop_of(box, page_w, page_h):
"""世界 bbox(y 向上)→ `page.render(crop=...)` 的 (left, bottom, right, top) 裁边量。
`crop` 是**要裁掉的边距**(不是绝对矩形),单位为点,且在**旋转后**的页面上生效。
"""
x0, y0, x1, y1 = (float(v) for v in box)
return (
max(0.0, x0),
max(0.0, y0),
max(0.0, float(page_w) - x1),
max(0.0, float(page_h) - y1),
)
def guess_sheet_size(w_pt, h_pt, tol=0.02):
"""由页尺寸(点)反查标准幅面名(A0~A4,容差 2%);不匹配返回 None。"""
w_mm, h_mm = float(w_pt) / config.PT_PER_MM, float(h_pt) / config.PT_PER_MM
longest, shortest = max(w_mm, h_mm), min(w_mm, h_mm)
for name, (sw, sh) in tiling.SHEET_SIZES_MM.items():
if abs(longest - sw) <= sw * tol and abs(shortest - sh) <= sh * tol:
return name
return None
# ---------------------------------------------------------------------------
# 墨迹分析(纯 PIL,无 numpy)
# ---------------------------------------------------------------------------
def _empty_grid(box=None):
return {
"window": tiling.box_to_window(box) if box else None,
"cols": 0, "rows": 0, "cell_w": 0.0, "cell_h": 0.0,
"counts": [], "coverage": [],
}
def ink_grid(image, box, *, div=None, threshold=None, max_edge=None, min_frac=None):
"""把一张图按**墨迹密度**量化成占用网格(与 `heatmap.build_grid` 同形)。
返回 `(grid, boxes)`:
- `grid` :可直接交给 `heatmap.cluster_cells`;
- `boxes`:占用格的**世界 bbox** 列表(供 `tiling.content_predicate` 剔除空白瓦片)。
步骤:灰度 → 缩到长边 ≤ `max_edge` → 按 `threshold` 二值化(暗 = 墨迹)→
用 `Image.BOX` 块均值降到网格尺寸(每个像素即该格的墨迹面积占比)。
`counts` 取 `int(frac * 100)`(≥1),故**墨迹越密计数越大**,`cluster_cells`
的降序排列即是「内容密度降序」,密集区优先出图。
"""
from PIL import Image
div = config.HEATMAP_CELL_DIV if div is None else int(div)
threshold = config.INK_THRESHOLD if threshold is None else int(threshold)
max_edge = config.INK_ANALYSIS_MAX_EDGE if max_edge is None else int(max_edge)
min_frac = config.INK_MIN_CELL_FRAC if min_frac is None else float(min_frac)
box = tiling.norm_box(box)
if box is None or box[2] <= box[0] or box[3] <= box[1] or image is None:
return _empty_grid(box), []
win = tiling.box_to_window(box)
w, h = box[2] - box[0], box[3] - box[1]
cell = heatmap.div_to_cell(win, div)
if cell <= 0:
return _empty_grid(box), []
cols = max(1, int(math.ceil(w / cell)))
rows = max(1, int(math.ceil(h / cell)))
gray = image.convert("L")
gw, gh = gray.size
if max_edge > 0 and max(gw, gh) > max_edge:
k = max_edge / float(max(gw, gh))
gray = gray.resize(
(max(1, int(round(gw * k))), max(1, int(round(gh * k)))), Image.BILINEAR
)
# 二值化:暗于阈值 → 墨迹(255)
bw = gray.point(lambda p: 255 if p < threshold else 0, mode="L")
# 块均值降采样到网格尺寸:每个输出像素 = 该格的墨迹面积占比 × 255
small = bw.resize((cols, rows), Image.BOX)
px = small.load()
counts = [[0] * cols for _ in range(rows)]
coverage = [[0.0] * cols for _ in range(rows)]
for r in range(rows):
for c in range(cols):
frac = px[c, r] / 255.0
coverage[r][c] = frac
if frac >= min_frac:
counts[r][c] = max(1, int(round(frac * 100)))
grid = {
"window": win, "cols": cols, "rows": rows,
"cell_w": w / cols, "cell_h": h / rows,
"counts": counts, "coverage": coverage,
}
boxes = [heatmap.cell_box(grid, r, c) for r, c in heatmap.occupied_cells(grid)]
return grid, boxes
# ---------------------------------------------------------------------------
# 渲染倍率
# ---------------------------------------------------------------------------
def _tile_zoom(box, max_zoom, max_edge=None):
"""由瓦片世界尺寸反推渲染倍率:长边恰好落到 `max_edge` 像素,且不超过 `max_zoom`。
与 `tiling.plan_regions` 的 `s_*` 是同一套数学(`zoom = 1/s`):规划器把文字簇切到
`s_text · IMAGE_MAX_EDGE` 世界宽度,这里就恰好还原成 `zoom_text`。
"""
max_edge = config.IMAGE_MAX_EDGE if max_edge is None else int(max_edge)
box = tiling.norm_box(box)
if box is None:
return float(max_zoom)
longest = max(box[2] - box[0], box[3] - box[1])
if longest <= 0 or max_edge <= 0:
return float(max_zoom)
return min(float(max_zoom), max_edge / longest)
def file_scales(source):
"""文件源的 `(s_text, s_line)`——CAD 图形单位 / 像素。
PDF 的世界单位是**点**,且 PDF 内容本身就是**纸面 1:1**(出图比例已烘焙进图形),
故等价于 `frame_scale = 1`、纸面字高 = `TEXT_HEIGHT_MM · PT_PER_MM` 点。
图片的世界单位是**像素**:1 世界单位 = 1 像素,且不允许放大,故 s 恒为 1。
"""
if source == evidence_model.SOURCE_IMAGE:
return 1.0, 1.0
text_pt = config.TEXT_HEIGHT_MM * config.PT_PER_MM
return (
tiling.world_per_px(1.0, text_pt, config.TEXT_READABLE_PX),
tiling.world_per_px(1.0, text_pt, config.LINE_READABLE_PX),
)
def snap_box(box, bounds=None):
"""把 bbox 对齐到**整数世界坐标**(图片源的世界单位就是源像素)。
渲染出来的像素只能是整数,若不对齐,注册的 `window` 与实际像素会差最多 1px,
使「窗口 ↔ 像素」映射不精确(未来按像素反算世界坐标会累积误差)。
`bounds` 给定时顺带夹回边界;退化返回 None。
"""
b = tiling.norm_box(box)
if b is None:
return None
x0, y0 = math.floor(b[0]), math.floor(b[1])
x1, y1 = math.ceil(b[2]), math.ceil(b[3])
if bounds is not None:
bb = tiling.norm_box(bounds)
if bb is not None:
x0, y0 = max(x0, math.floor(bb[0])), max(y0, math.floor(bb[1]))
x1, y1 = min(x1, math.ceil(bb[2])), min(y1, math.ceil(bb[3]))
if x1 <= x0 or y1 <= y0:
return None
return (float(x0), float(y0), float(x1), float(y1))
def _png_b64(img):
buf = io.BytesIO()
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
img.save(buf, format="PNG", optimize=True)
return base64.b64encode(buf.getvalue()).decode("ascii")
# ---------------------------------------------------------------------------
# 读取器:PDF / 图片
# ---------------------------------------------------------------------------
class PdfReader:
"""PDF 源:按页取证(内存渲染 + best-effort 文本层)。惰性 import pypdfium2。"""
def __init__(self, path):
import pypdfium2 as pdfium # 惰性:仅 PDF 路径需要
self.path = str(path)
self.doc = pdfium.PdfDocument(self.path)
self.page_count = len(self.doc)
self._metas = {}
# -- 生命周期 ----------------------------------------------------------
def close(self):
try:
self.doc.close()
except Exception: # noqa: BLE001
pass
# -- 页元数据 ----------------------------------------------------------
def _meta(self, index):
"""(旋转后宽, 旋转后高, MediaBox 宽, MediaBox 高, rotation),均以点计。"""
m = self._metas.get(index)
if m is None:
page = self.doc[index]
media = page.get_mediabox()
mw = float(media[2]) - float(media[0])
mh = float(media[3]) - float(media[1])
size = page.get_size() # 已应用 /Rotate
rw, rh = float(size[0]), float(size[1])
m = (rw, rh, mw, mh, int(page.get_rotation()))
self._metas[index] = m
return m
# -- 规划 --------------------------------------------------------------
def frames(self):
"""每页一个「图框」(bbox = 页内点坐标,y 向上,已应用 /Rotate)。"""
out = []
for i in range(self.page_count):
rw, rh, _mw, _mh, _rot = self._meta(i)
out.append(
{
"bbox": [0.0, 0.0, rw, rh],
"kind": "page",
"page_index": i,
"sheet_size": guess_sheet_size(rw, rh),
"sheet_scale": 1.0,
"layer": None,
"confidence": 1.0,
"source_type": evidence_model.SOURCE_PDF,
}
)
return out
# -- 渲染 / 文本 -------------------------------------------------------
def render(self, box, zoom, page_index=0):
"""把世界 bbox 渲染成 PIL 图(`crop` 为点、在旋转后页面上生效)。
退化 bbox(宽或高 ≤ 0)会让 `crop` 的边距之和等于页尺寸,pdfium 直接报错,
故在此提前拒绝。
"""
box = tiling.norm_box(box)
if box is None or box[2] <= box[0] or box[3] <= box[1]:
return None
rw, rh, _mw, _mh, _rot = self._meta(page_index)
page = self.doc[page_index]
bitmap = page.render(
scale=float(zoom), rotation=0, crop=crop_of(box, rw, rh)
)
img = bitmap.to_pil()
return img.convert("RGB") if img.mode not in ("RGB", "L") else img
def text_items(self, index, box=None, min_chars=None, limit=0):
"""best-effort 抽文本层:逐文本矩形取串 + 世界 bbox。
国标图纸文字多为 SHX 描边(导出成线条),多数页**整页 0 个文本矩形**——
返回空列表属**预期**,不是 bug。真正的识别靠 VLM 看渲染图。
"""
min_chars = config.PDF_TEXT_MIN_CHARS if min_chars is None else int(min_chars)
_rw, _rh, mw, mh, rot = self._meta(index)
page = self.doc[index]
try:
tp = page.get_textpage()
except Exception: # noqa: BLE001 - 无文本层 / 解析失败都按「无文本」处理
return []
try:
total = tp.count_rects() # get_rect 的前置调用(pdfium 要求)
items = []
for k in range(total):
if limit and len(items) >= limit:
break
try:
rect = tp.get_rect(k)
text = (tp.get_text_bounded(*rect) or "").strip()
except Exception: # noqa: BLE001 - 单个矩形失败不影响整页
continue
if len(text) < min_chars:
continue
world = rect_to_world(rect, mw, mh, rot)
if box is not None and not tiling.boxes_intersect(world, box):
continue
items.append({"content": text, "bbox": list(world)})
return items
finally:
try:
tp.close()
except Exception: # noqa: BLE001
pass
class ImageReader:
"""图片源:整图即一页,按需裁剪(**只缩不放**,放大只会插值出假细节)。"""
def __init__(self, path):
from PIL import Image
self.path = str(path)
self.img = Image.open(self.path)
self.img.load()
self.width, self.height = self.img.size
self.page_count = 1
def close(self):
try:
self.img.close()
except Exception: # noqa: BLE001
pass
def frames(self):
return [
{
"bbox": [0.0, 0.0, float(self.width), float(self.height)],
"kind": "image",
"page_index": 0,
"sheet_size": None,
"sheet_scale": 1.0,
"layer": None,
"confidence": 1.0,
"source_type": evidence_model.SOURCE_IMAGE,
}
]
def render(self, box, zoom, page_index=0):
from PIL import Image
# 世界单位 = 源像素 → 对齐到整数,保证「窗口 ↔ 像素」精确对应
box = snap_box(box, (0.0, 0.0, float(self.width), float(self.height)))
if box is None:
return None
# 世界 y 向上 → 图像 y 向下
left = int(box[0])
upper = int(self.height - box[3])
right = int(box[2])
lower = int(self.height - box[1])
if right <= left or lower <= upper:
return None
out = self.img.crop((left, upper, right, lower))
if zoom and 0 < zoom < 1.0:
out = out.resize(
(
max(1, int(round(out.size[0] * zoom))),
max(1, int(round(out.size[1] * zoom))),
),
Image.LANCZOS,
)
return out.convert("RGB") if out.mode not in ("RGB", "L") else out
def text_items(self, index=0, box=None, **kwargs):
"""图片没有文本层。"""
return []
# ---------------------------------------------------------------------------
# 读取器缓存(挂在 ctx 上,避免每页重复打开 PDF)
# ---------------------------------------------------------------------------
def open_reader(ctx):
"""打开(并缓存到 `ctx.source_reader`)文件源读取器。"""
source = ctx.source
if not is_file_source(source):
raise ValueError(f"来源 {source} 不是文件源,无需读取器")
reader = getattr(ctx, "source_reader", None)
if reader is not None:
return reader
path = getattr(ctx, "source_path", None)
if not path:
raise ValueError("文件源未指定路径(ctx.source_path 为空)")
reader = PdfReader(path) if source == evidence_model.SOURCE_PDF else ImageReader(path)
ctx.source_reader = reader
return reader
def close_reader(ctx):
"""释放文件源读取器(幂等)。"""
reader = getattr(ctx, "source_reader", None)
if reader is not None:
reader.close()
ctx.source_reader = None
# ---------------------------------------------------------------------------
# 规划(与 phases.planning 的 CAD 分支同形)
# ---------------------------------------------------------------------------
def plan_file(ctx):
"""文件源的「规划」:产出与 CAD 同形的 plan(每页 / 整图一个图框,保持原始页序)。"""
reader = open_reader(ctx)
frames = reader.frames()
if ctx.source == evidence_model.SOURCE_PDF and config.PDF_MAX_PAGES > 0:
frames = frames[: config.PDF_MAX_PAGES]
if ctx.max_frames > 0:
frames = frames[: ctx.max_frames]
info = {
"source_type": ctx.source,
"path": ctx.source_path,
"page_count": getattr(reader, "page_count", len(frames)),
"unit": "pt" if ctx.source == evidence_model.SOURCE_PDF else "px",
}
return {
"drawing_info": info,
"layers": evidence_model.empty_envelope(),
"layouts": evidence_model.empty_envelope(),
"frames": frames,
}
# ---------------------------------------------------------------------------
# 取证
# ---------------------------------------------------------------------------
def _same_box(a, b, tol=1e-6):
"""两个 bbox 是否(近似)相同。"""
return a is not None and b is not None and all(
abs(float(x) - float(y)) <= tol for x, y in zip(a, b)
)
def _capture_file(ctx, frame, box, reader, source, text_boxes):
"""采集视觉证据:整体一张 + 自适应墨迹瓦片若干。"""
page_index = frame.get("page_index", 0)
is_pdf = source == evidence_model.SOURCE_PDF
max_zoom = config.PDF_TILE_MAX_ZOOM if is_pdf else config.IMAGE_MAX_ZOOM
overview_zoom = config.PDF_OVERVIEW_MAX_ZOOM if is_pdf else config.IMAGE_MAX_ZOOM
images = []
def _add(img, label, win):
if img is None or len(images) >= config.EVIDENCE_MAX_IMAGES:
return False
images.append(
evidence_model.make_image(
_png_b64(img), "image/png", label, window=list(win)
)
)
return True
# 整体图:整页 / 整图渲染(长边 ≈ IMAGE_MAX_EDGE,且不超过 overview 上限)
overall_zoom = _tile_zoom(box, overview_zoom)
_add(reader.render(box, overall_zoom, page_index), _LABEL_OVERALL, box)
if not config.TILING_ENABLED:
return images
# 墨迹分析:低倍缩略图 → 占用网格 → 簇 → 瓦片
thumb = reader.render(
box, _tile_zoom(box, max_zoom, config.INK_ANALYSIS_MAX_EDGE), page_index
)
grid, ink_boxes = ink_grid(thumb, box)
clusters = heatmap.cluster_cells(grid, dilate=config.HEATMAP_CLUSTER_DILATE)
if not clusters:
return images
s_text, s_line = file_scales(source)
if s_text <= 0 or s_line <= 0:
return images
flags = tiling.cluster_text_flags(clusters, text_boxes)
text_idx = {i for i, is_text in enumerate(flags) if is_text}
regions = tiling.plan_regions(
frame, clusters,
text_clusters=text_idx, s_text=s_text, s_line=s_line,
view_px=(config.IMAGE_MAX_EDGE, config.IMAGE_MAX_EDGE),
max_tiles=config.TILE_MAX_COUNT, overlap=config.TILE_OVERLAP,
keep=tiling.content_predicate(ink_boxes),
)
n = 0
for win in regions:
if len(images) >= config.EVIDENCE_MAX_IMAGES:
break
tile_box = tiling.window_to_box(win)
if tile_box is None:
continue
if not is_pdf:
# 图片源世界单位是源像素:先对齐整数,注册的 window 才与像素精确对应
tile_box = snap_box(tile_box, box)
if tile_box is None:
continue
zoom = _tile_zoom(tile_box, max_zoom)
# 整页即一个簇时,瓦片窗口会与整体图重合且分辨率不更高 → 省掉这张重复图
if _same_box(tile_box, box) and zoom <= overall_zoom + 1e-9:
continue
if _add(reader.render(tile_box, zoom, page_index), f"tile-{n + 1}", tile_box):
n += 1
return images
def collect(ctx, index, unit):
"""文件源取证入口;产出与 `phases.evidence.collect_frame` **同形**的 evidence。"""
reader = open_reader(ctx)
box = tiling.frame_bbox(unit)
if box is None:
return None
source = evidence_model.source_type_of(unit)
evidence = {
"source_type": source,
"index": index,
"frame": unit,
"window": tiling.box_to_window(box),
"entities": evidence_model.empty_envelope(),
"texts": evidence_model.empty_envelope(),
"dimensions": evidence_model.empty_envelope(),
"blocks": evidence_model.empty_envelope(),
"polylines": evidence_model.empty_envelope(),
"images": [],
}
# best-effort 文本层(仅 PDF 可能有;图片恒为空)
page_index = unit.get("page_index", 0)
items = reader.text_items(page_index, box, limit=ctx.frame_limit)
evidence["texts"] = {"total": len(items), "items": items}
text_boxes = [i["bbox"] for i in items if i.get("bbox")]
evidence["images"] = _capture_file(ctx, unit, box, reader, source, text_boxes)
return evidence