593 lines
23 KiB
Python
593 lines
23 KiB
Python
"""非 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
|