Files
cad-agent/dwg_review/sources.py
T
gjm 6143bb9ab2 feat(review): PDF/图片离线源适配(C1)—— 无需 CAD 的整条审查链路
支持直接审查 PDF 与位图图纸,全链路离线(不连 CAD、不连 8080 端口)。
渲染在内存内完成,复用既有墨迹热力图 + 自适应瓦片规划。

- 新增 dwg_review/sources.py
  - detect_source / plan_file / collect 统一入口
  - PdfReader:pypdfium2 惰性导入,页序即图框序,处理 /Rotate 与 crop 边距
  - ImageReader:世界单位 = 源像素,snap_box 整数对齐保证窗口与像素严格一致
  - ink_grid:纯 PIL 灰度阈值 → 占用网格(不引入 numpy)
- ReviewContext.create(connect_cad=False):离线时 client/tools 均为 None
- 全链路 ctx.tools is None 守卫:review 只读子循环 / inspect_region / annotator /
  checks 的窗口类检查 / 绘图区最大化
- checklist.build_system_prompt(allow_tools=False, offline=True):离线提示词
- __main__:位置参数 source,按扩展名自动判定源类型并决定是否连 CAD
- config 新增 8 个常量(PDF_MAX_PAGES / 文本最小字数 / 各源缩放上限 / 墨迹阈值等)
- tiling.content_predicate 与 heatmap.cell_box 提升为公开函数

验证:
- pytest 457 passed
- verify_pdf_source.py:PDF 23/23、图片 9/9(不需 CAD),含实测瓦片字高像素与
  旋转映射自检(文本区墨迹 0.311 vs 忽略 /Rotate 反向对照 0.145)
- 真实 LLM 端到端:20 页 HVAC 图纸前 3 页跑通,结论具体可核验且未编造 handle

已知局限:页边线在墨迹网格里会把整页连成单簇,瓦片常退化为整页象限(CAD 侧
靠 drop_oversized 丢图框块,离线侧暂无等价手段),留待后续打磨。
2026-10-16 23:07:23 +08:00

594 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""非 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