image-to-psd
将一张或多张图片转换为经过严格质量校验的分层 PSD;自动准备运行环境,通过当前 Agent 执行转换。输出修复背景、独立透明视觉组件和可编辑 Photoshop 文字图层。仅支持图片输入,不用于 PDF 或 PPTX。
Install
npx skills add https://github.com/DSY-Xueai/image2editable/tree/main/skills/image-to-psd
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install dsy-xueai-image2editable@llmmart
git clone https://github.com/DSY-Xueai/image2editable.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole dsy-xueai/image2editable collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Image to PSD
把图片重建为分层 PSD。文字只由可编辑文字图层贡献一次;视觉组件和背景不得残留文字像素。质量检查未通过时继续针对性修复并复检,不把整页图片伪装成分层结果。
全部文字包括艺术字均须可编辑,保留原有曲线、描边及多色;不得用文字截图、转曲轮廓或透明文字覆盖冒充。复用有效识别和组件资产,避免重复推理;只有实际渲染和编辑验收通过才能作为成品交付。
局部 OCR 找回文字后,先验证源图、manifest、资产哈希和文字增量依赖。可证明安全时只更新受影响像素及背景,保留其余有效组件;并行路径也必须使用新增文字清理后的图像。重建范围覆盖实际文字清理边缘,不能仅依据原 OCR 框。
输入与授权
识别阶段复用 text-context-cache 中匹配像素、语言和 OCR 实现的整行复核结果,避免重复处理相同冲突;不同栏位的文字不得因宽检测结果而丢失独立位置与样式。共享 OCR 的 words/runs 元数据不代表 PSD 已具备对应的艺术字渲染能力,必须核验实际文字图层效果。
- 仅支持 PNG、JPEG、BMP、TIFF 和 WebP。
- 单图输出一个
.psd;多图输出到目录,同名文件使用稳定序号区分。 - 每个 PSD 包含修复背景、按 z-order 排列的透明视觉组件和可编辑文字图层。
- PSD 写入依赖已授权的 Aspose.PSD。模型推理前必须设置
ASPOSE_PSD_LICENSE;授权缺失或无效时立即停止。
Windows PowerShell:
$env:ASPOSE_PSD_LICENSE="C:\path\to\Aspose.PSD.lic"
Linux/macOS:
export ASPOSE_PSD_LICENSE=/path/to/Aspose.PSD.lic
授权文件、模型权重、OCR 缓存和运行产物都不存放在此 skill 中。
环境准备与图片兼容入口
转换前必须阅读并执行 自动环境准备。完整仓库和仅安装 Skill 在 Windows、macOS、Linux 都自动准备缺少的 Python、Git、项目 Runtime、依赖、OCR 和模型。不得为依赖或模型安装向用户询问确认;遵循宿主实际审批与权限限制。Windows 新安装优先 D 盘,再选其他非 C 本地磁盘,仅在不存在其他本地磁盘时使用 C 盘;macOS/Linux 优先其他已挂载的本地磁盘,否则使用用户目录。使用 scripts/skill_environment.py 统一环境、模型、下载缓存与临时目录。已有可用环境和模型继续复用。
准备后默认使用下文产品 Runtime 的 Agent 流程;下面的 standalone CLI 是图片兼容入口。两者都使用自动安装的模型,不要求使用者手动配置 SAM2_MODEL、LAMA_MODEL 或 GROUNDING_DINO_MODEL。推理不会下载模型或回退 Hugging Face cache;准备阶段先校验 runtime receipt,已有显式模型路径须满足固定身份约束。LaMa 缺失或初始化失败时停止该无效路径并修复环境,不降低修复质量。
检查当前设备后再运行:
python -c "import sys, torch; print({'platform': sys.platform, 'cuda': torch.cuda.is_available(), 'rocm': torch.version.hip})"
CPU 仍使用完整模型和相同质量门,速度会明显慢于 GPU。macOS 在真实 Apple Silicon 回归完成前不自动把 MPS 设为默认。
从 skill 根目录运行 module,不要直接执行脚本文件:
cd skills/image-to-psd
python -m scripts.image_to_psd input.png
python -m scripts.image_to_psd input.png -o output.psd
python -m scripts.image_to_psd img1.png img2.png -o psd-output
python -m scripts.image_to_psd images/ -o psd-output --lang en
standalone CLI 只负责图片重建,不接受 --agent-provider。它先完成全部页面的严格准备,再发布 PSD;任一页面失败时不会留下部分输出。
产品 Runtime
完整仓库或已安装的 image2editable 只支持 host Provider,使用统一的组件动作、最多 5 批修复和相同质量门。
完整仓库中缺少 PSD 依赖时,在仓库根目录安装对应 extra:
python -m pip install -e ".[psd]"
仅已安装 image2editable distribution、没有仓库源码时,直接安装同一 PSD writer 依赖,不对调用者的当前项目执行 editable install:
python -m pip install "aspose-psd>=26.5.0"
随后以非交互方式安装并校验固定的 SAM、LaMa 和 DINO runtime:
image2editable models install runtime --yes
image2editable doctor
host 直接使用当前支持视觉、本地文件读取、工具调用和结构化 JSON 的宿主,不探测、下载或要求配置其他组件决策模型。处理敏感文件前,确认宿主服务的数据策略符合要求。
Host 模式先准备 Run,再推进到 awaiting_agent:
image2editable prepare input.png -o output.psd \
--run-dir runs/psd-job --format psd --agent-provider host
image2editable run execute runs/psd-job
image2editable agent next runs/psd-job
image2editable agent record runs/psd-job --plan response.json
image2editable run execute runs/psd-job
第一次 agent next 返回视觉能力 challenge。必须实际查看 image_path,记录观察到的 shape、color 和 count,不能从文件名或 metadata 猜测。之后每轮只查看 request 中按顺序列出的 review_evidence,同时核验完整 request、hash、组件图、候选和冻结状态;quality-report.json 作为质量证据读取,不能当图片发送。
计划必须绑定当前 request_sha256。每个 action 只使用请求组件图中的 ID,并限定为现有十四类动作:accept、discard、merge、split、expand、shrink、retry_with_box、retry_with_points、attach_text、suppress_text、collapse_to_parent、rebuild_background、absorb_residual、absorb_into_parent。Agent confidence 不能放宽硬失败。
若绑定的 unexplained-mask.png 中有经验证的结构碎片,可用 absorb_residual 并入相关候选;请求图中的 inactive visual 有对应来源证据时,该动作仅恢复绑定残差,不恢复整个已停用复合对象,也不调用 SAM。随后按需 rebuild_background 并重新验证。只有残差证据不足以确定结构时才重新分割,不得将残差归为背景来消除违规。
质量与失败
卡片底色连接多个独立纯色图形时,split 可复用原图色块边界拆分,保留全部像素,不调用 SAM。parts 对应实际完整单元并包含底色,不能按期望数量任意切块。文字框外的标点应修复 OCR 字形范围,不能作为图形残差吸收。
- 每张图片独立判断,不能跨图片套用拆分结果。
- 每个视觉组件应是可独立移动的最小完整单元,不得残缺、重叠、吸收相邻对象或只保留阴影碎片。
- 已通过组件立即冻结并复用;检测无进展和重复产物,停止无效策略并切换针对性修复,不为耗尽轮数重复执行。有实际进展的任务不因总耗时较长而放弃。
rebuild_background.margin_ratio使用能覆盖残影且不触及相邻结构的最小值,不固定写死。unexplained_visual_residual必须由 active visual owner 覆盖;不能用accept、discard或归为背景来消除违规。- 可靠 OCR 文字必须全部写为可编辑文字图层,并且只能出现一次。
preserved_with_warning是内部未完成状态,不是分层交付。当前运行时仍有修复周期耗尽后无法继续的路径;须解决该交付能力缺口并补通用回归,不能将低质量结果标为成功或宣称已具备发布条件。- standalone 质量异常包含指标和诊断路径,由宿主检查
source.png、ownership.png、reconstructed.png和report.json并修复。诊断不能代替最终文件,不把修复责任交给使用者;不得放宽门禁、删内容、伪造通过或回退为整页图片。用户主动取消时停止处理并保留恢复依据。
Files (image2editable)
-
references
-
requirements.txt 375 B
# Standalone image reconstruction opencv-python>=4.10.0.84,<5 Pillow>=10.4,<12 numpy>=1.26.4,<2 psutil>=7,<8 torch>=2.5.1,<3 torchvision>=0.20.1,<1 SAM-2 @ git+https://github.com/facebookresearch/sam2.git@2b90b9f5ceec907a1c18123530e92e794ad901a4 transformers>=4.57,<5 accelerate>=1.8,<2 # Layered PSD writer; a valid Aspose.PSD license is still required. aspose-psd>=26.5.0 -
setup.md 8.5 KB
# 转换环境自动准备 首次使用或预检发现缺项时执行本流程,完整仓库与仅安装 Skill 使用相同步骤。准备完成后继续当前转换任务。缺少时直接安装,不把安装步骤转交给使用者;不得为依赖或模型安装向用户询问确认。宿主明确要求审批、网络阻断或安装校验失败时,遵循实际限制并报告原始原因,不把未安装状态当作成功。 ## 存储与基础工具 先解析 `<skill-root>` 为当前 SKILL.md 所在绝对目录。Windows 新安装优先 `D:\image2editable`,再按盘符顺序选择其他可写的非 C 本地固定磁盘;仅在不存在非 C 本地固定磁盘时使用用户目录下的 `image2editable`。macOS 从 `/Volumes` 选择其他已挂载的本地数据卷,排除系统/用户目录所在卷、网络盘及安装镜像;Linux 用 `lsblk` 选择已挂载的其他本地磁盘,排除根文件系统所在磁盘、光盘和 loop 设备。都使用数据卷下的 `image2editable`,没有其他已挂载本地磁盘时才使用 `~/.local/share/image2editable`。其他盘存在但无法写入时修复权限或选择另一个数据盘,不静默回退系统盘。已有可用 Python、Git、转换环境和模型继续复用,不移动或重新下载;用户显式指定的模型路径优先。 1. 检查 Python 3.10–3.12 和 Git。Python 不存在时,先用系统工具按上述规则选定 `<root>`:Windows PowerShell 用 `Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3'` 枚举本地固定磁盘,再实际检查目标目录可写。下载 python.org 的 Python 3.12 Windows 安装器,验证 Authenticode 签名后以 `InstallAllUsers=0 TargetDir="<root>\tools\python" Include_launcher=0 PrependPath=0 Include_test=0` 安装;下载和临时文件同样放在 `<root>`。Git 缺失时从 Git for Windows 官方发布安装到 `<root>\tools\git`,使用安装器 `/DIR="<root>\tools\git"` 指定位置。后台安装窗口隐藏,等待退出码并执行 `--version` 验证。macOS/Linux 用 `diskutil`/`lsblk` 先选盘,缺少 Python 时下载与平台及 CPU 架构匹配的官方 uv 发行文件并校验发布的校验和,解压到 `<root>/tools/uv`,设置 `UV_PYTHON_INSTALL_DIR=<root>/tools/python`、`UV_CACHE_DIR=<root>/cache/uv`,执行 `uv python install 3.12`,用 `uv python find 3.12` 获取实际路径。Git 先复用平台自带版本;缺少时自动从官方源码构建并以 `prefix=<root>/tools/git` 安装,缺失的系统构建工具通过平台包管理器准备。系统包管理器有固定安装位置的基础工具遵循平台规则,转换 Python、依赖、模型和缓存仍放在选定数据盘。 2. 以该 Python 执行 `python "<skill-root>/scripts/skill_environment.py"`,读取 JSON 的 `root` 和 `environment`。后续准备及转换命令均继承这组环境变量,或通过 `python "<skill-root>/scripts/skill_environment.py" --run <命令及参数>` 执行。不要修改 `HOME`、`USERPROFILE` 或系统级 PATH。工具将 pip、Hugging Face、Torch、Paddle/OCR 缓存及 TEMP/TMP 指向所选盘;已有 runtime receipt 和显式模型缓存继续复用。 3. 复用已安装且可用的项目 Python 环境;没有时运行 `python -m venv "<root>/venv"`,之后使用其绝对 Python 路径 `<python>`。Windows 为 `venv/Scripts/python.exe`,Linux/macOS 为 `venv/bin/python`。依赖必须安装到该环境,不能因为当前系统 Python 在 C 盘就把新转换依赖装到 C 盘。 ## 项目、依赖和模型 完整仓库使用已验证包含 `pyproject.toml`、`image2editable/` 和 `constraints/runtime.txt` 的当前仓库根目录作为 `<source>`,包括本地未提交修改;不要把调用者的任意当前目录当成项目源码。已有项目包必须核对实际代码,不能仅凭版本号或 `doctor` 通过就复用。 仅安装 Skill 时,在所选盘用自带工具获取 GitHub `main` 当前提交。工具在检出前设置浅层部分克隆(`--filter=blob:none`)与非 cone 稀疏文件清单,只获取运行模块、入口、依赖配置、包元数据必需的英文README及许可证;不获取 benchmarks、tests、docs图片、其他Skills、CI、基准生成/发布/开发脚本。即使已安装同版本项目包,也先检查远端当前提交: ```bash <python> "<skill-root>/scripts/fetch_skill_source.py" "<root>/source/image2editable-runtime" ``` 读取输出JSON的 `source` 和 `commit`,将完整SHA记入准备记录,后续安装和验收绑定此源码,不跟随中途变化的分支。工具只更新自己管理的干净克隆;遇到旧完整克隆或本地修改时保留它并另建专用目录,不执行 reset/clean,也不改用完整克隆或ZIP下载。项目主程序只能来自上述 `<source>`,不得运行 `pip install image2editable` 从 PyPI 取得可能滞后的代码。先执行 `<python> -I "<skill-root>/scripts/verify_skill_runtime.py" "<source>"`,逐文件检查已安装代码及实际导入位置;不一致或未安装时安装当前源码。第三方依赖仍可从 PyPI 安装。所有下列命令均继承上一步的缓存和临时目录设置: ```bash <python> -m pip install --constraint "<source>/constraints/runtime.txt" torch torchvision setuptools==84.0.0 <python> -m pip install --constraint "<source>/constraints/runtime.txt" --no-build-isolation --no-binary antlr4-python3-runtime "<source>" <python> -m pip install --no-deps --no-build-isolation --force-reinstall "<source>" <python> -I "<skill-root>/scripts/verify_skill_runtime.py" "<source>" <python> -m pip install --constraint "<source>/constraints/runtime.txt" "paddleocr==3.7.0" "paddlepaddle==3.3.1" "PaddleX==3.7.2" "PyYAML==6.0.2" <python> -m image2editable models install runtime --yes <python> -m image2editable doctor ``` 只安装预检缺少的部分。源码核验一致时跳过项目重装;需要覆盖同版本旧代码时仅对项目使用 `--no-deps --force-reinstall`,保留已满足的第三方依赖、有效模型和缓存。代码核验、`doctor` 均通过才继续转换,不能退回旧 PyPI 包或仅记录 warning。已有可用 CUDA/ROCm PyTorch 时保留对应构建,不用 CPU wheel 覆盖;新 CPU 环境安装 torch/torchvision 时使用官方 CPU index。SAM 采用约束中的固定 Git commit。LaMa 使用本地 TorchScript adapter,依赖 `torch>=2.5.1,<3`。模型命令下载并校验 SAM 2.1 large、Big-LaMa 和 Grounding DINO,不要求使用者手工设置 `SAM2_MODEL`、`LAMA_MODEL`、`GROUNDING_DINO_MODEL`。已有显式路径需校验身份;未配置的模型由 runtime receipt 解析。 `doctor` 检查依赖导入但不代表 OCR 权重已下载。准备阶段以相同环境执行 `<python> -c "from scripts.text_detect import _get_paddleocr; _get_paddleocr('ch')"`,预热默认 OCR 并确认模型加载成功;其他语言按实际任务预热。不要从 Skill 的 `scripts/` 目录执行该命令,以免遮蔽已安装模块。推理不会下载 SAM/LaMa/DINO 模型或回退 Hugging Face cache。 PSD 任务额外安装 `aspose-psd>=26.5.0` 并预检已有 `ASPOSE_PSD_LICENSE`。商业授权不能通过下载安装取得,不伪造许可证或使用带水印试用输出。 ## PPTX 实际渲染 原生 PDF 在交付前需要实际渲染。先复用 PowerPoint 或 LibreOffice。Windows 有 PowerPoint 时,在所选 Python 环境安装 `pywin32>=306`;没有可用渲染器时自动安装 LibreOffice:所选源码自带 `scripts/install_release_renderer.ps1`,其中下载 URL 和 SHA-256 已固定。 ```powershell $env:RUNNER_TEMP = "<root>/tools" $env:GITHUB_ENV = "<root>/renderer.env" & "<source>/scripts/install_release_renderer.ps1" ``` 该命令须继承所选 TEMP/TMP。成功后验证 `<root>/tools/native-renderer/extracted/program/soffice.com --version`;路径工具会自动向后续子进程设置 `IMAGE2EDITABLE_LIBREOFFICE`。不得把 LibreOffice 的 program 目录前置到 PATH,其中的 Python 会干扰转换环境。macOS 下载匹配 Intel/Apple Silicon 的官方 LibreOffice DMG 并校验发布的 SHA-256,将应用复制到 `<root>/tools/LibreOffice.app`;Linux 下载对应平台官方 LibreOffice 归档并校验,以 `dpkg-deb -x` 或 `rpm2cpio` 解包到 `<root>/tools/libreoffice`,补齐实际缺失的系统共享库。均以实际 `soffice --version` 和试渲染验证,设置 `IMAGE2EDITABLE_LIBREOFFICE` 为该可执行文件的绝对路径并传给后续转换,不能仅以文件存在作为安装成功。 在同一环境继续原 Run,复用有效 OCR、分割及冻结组件。依赖准备通过不等于转换验收通过,最终仍执行原有完整质量门禁。
-
-
scripts
-
art_text.py 11.1 KB
"""Recover outlined text styling from local pixels without model inference.""" from __future__ import annotations import cv2 import numpy as np from functools import lru_cache @lru_cache(maxsize=8) def _font_metrics(font_name): from scripts.font_match import resolve_font return resolve_font(font_name, bold=True) def _line_ink(region): if region.size == 0 or min(region.shape[:2]) < 8: return None gray = cv2.cvtColor(region, cv2.COLOR_RGB2GRAY) threshold, dark = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) contours, hierarchy = cv2.findContours(dark, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) if hierarchy is None: return None fill_mask = np.zeros(gray.shape, np.uint8) # Filled strokes form holes inside dark outlines. Sample the background # outside the enclosing contour, since image borders may be decorative. for i, contour in enumerate(contours): parent = hierarchy[0, i, 3] depth, ancestor = 0, parent while ancestor >= 0: depth += 1 ancestor = hierarchy[0, ancestor, 3] if depth % 2 != 1 or cv2.contourArea(contour) < 15: continue bx, by, bw, bh = cv2.boundingRect(contour) hole = np.zeros(gray.shape, np.uint8) cv2.drawContours(hole, [contour], -1, 255, -1) interior_mask = hole & cv2.bitwise_not(dark) child = hierarchy[0, i, 2] while child >= 0: cv2.drawContours(interior_mask, [contours[child]], -1, 0, -1) child = hierarchy[0, child, 0] interior = cv2.erode(interior_mask, np.ones((3, 3), np.uint8)) > 0 if np.count_nonzero(interior) < 8: continue fill = np.median(region[interior], axis=0) outer = np.zeros(gray.shape, np.uint8) enclosing = parent while hierarchy[0, enclosing, 3] >= 0: enclosing = hierarchy[0, enclosing, 3] cv2.drawContours(outer, [contours[enclosing]], -1, 255, -1) ex, ey, ew, eh = cv2.boundingRect(contours[enclosing]) margin = max(8, min(ew, eh)) ys = slice(max(0, ey-margin), min(gray.shape[0], ey+eh+margin)) xs = slice(max(0, ex-margin), min(gray.shape[1], ex+ew+margin)) outside = outer[ys, xs] == 0 if np.count_nonzero(outside) < 8: continue background = np.median(region[ys, xs][outside], axis=0) # Contrast against the external background is the ownership signal. # A fixed Otsu cutoff drops valid pastel fills whose luminance is near # the row threshold (common in outlined Chinese lettering). if np.linalg.norm(fill-background) < 30: continue fill_mask[interior_mask > 0] = 255 if np.count_nonzero(fill_mask) < 16: return None # Restrict connected outline pixels to the neighborhood of verified fills. distance = cv2.distanceTransform(dark, cv2.DIST_L2, 5) near = cv2.dilate(fill_mask, np.ones((5, 5), np.uint8)) > 0 stroke_samples = distance[near & (dark > 0)] if len(stroke_samples) < 8: return None radius = max(2, int(round(float(np.percentile(stroke_samples, 90)) * 2))) near = cv2.dilate(fill_mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*radius+1, 2*radius+1))) > 0 ink = (fill_mask > 0) | (near & (dark > 0)) return fill_mask > 0, ink, dark > 0 def _outlined_ink(region, *, ownership=None, return_mask=False): measured = _line_ink(region) if measured is None: return None fill_mask, ink, dark = measured if ownership is not None: ink &= ownership > 0 fill_mask &= ownership > 0 if return_mask: return ink.astype(np.uint8) * 255 return _measure_ink(region, fill_mask, ink, dark) def _linear_gradient(region, interior, fill_mask, rotation): yy, xx = np.nonzero(interior) coords = np.column_stack((xx, yy)).astype(float) samples = region[interior].astype(float) design = np.column_stack((coords, np.ones(len(coords)))) coefficients = np.linalg.lstsq(design, samples, rcond=None)[0] errors = np.linalg.norm(design @ coefficients-samples, axis=1) inliers = errors <= max(8, float(np.percentile(errors, 85))) coefficients = np.linalg.lstsq(design[inliers], samples[inliers], rcond=None)[0] errors = np.linalg.norm(design @ coefficients-samples, axis=1) if np.mean(errors < 20) < .95 or np.sqrt(np.mean(errors[inliers]**2)) > 8: return None directions, strengths, _ = np.linalg.svd(coefficients[:2], full_matrices=False) if strengths[0] < .05 or strengths[1] > strengths[0]*.15: return None direction = directions[:, 0] # Orient predominantly vertical/horizontal gradients top-to-bottom/left-to-right. if direction[np.argmax(np.abs(direction))] < 0: direction = -direction angle = np.radians(rotation) unrotate = np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]]) local_direction = direction @ unrotate fy, fx = np.nonzero(fill_mask) local = np.column_stack((fx, fy)) @ unrotate low, high = local.min(axis=0), local.max(axis=0) corners = np.array([[low[0], low[1]], [high[0], low[1]], [low[0], high[1]], [high[0], high[1]]]) limits = corners @ local_direction center = coords.mean(axis=0) base = np.append(center, 1) @ coefficients slope = direction @ coefficients[:2] colors = [np.clip(base+(position-center @ direction)*slope, 0, 255) for position in (limits.min(), limits.max())] if np.linalg.norm(colors[0]-colors[1]) < 20: return None return {"angle": float(np.degrees(np.arctan2(local_direction[1], local_direction[0])) % 360), "colors": ["#"+"".join(f"{round(value):02x}" for value in color) for color in colors]} def _measure_ink(region, fill_mask, ink, dark, *, rotation=0, return_gradient=False): near = cv2.dilate(fill_mask.astype(np.uint8), np.ones((9, 9), np.uint8)) > 0 ink = fill_mask | (ink & dark & near) interior = cv2.erode(fill_mask.astype(np.uint8), np.ones((3, 3), np.uint8)) > 0 stroke_mask = ink & dark if np.count_nonzero(interior) < 8 or np.count_nonzero(stroke_mask) < 8: return None samples = region[interior] quantized, counts = np.unique(samples // 16, axis=0, return_counts=True) dominant = quantized[np.argmax(counts)] fill = np.median(samples[np.all(samples // 16 == dominant, axis=1)], axis=0) gradient = None variation = np.linalg.norm(np.percentile(samples, 90, axis=0)-np.percentile(samples, 10, axis=0)) consistent = np.mean(np.linalg.norm(samples.astype(float)-fill, axis=1) < 35) >= .75 if variation > 20: gradient = _linear_gradient(region, interior, fill_mask, rotation) if gradient is None and not consistent: return None elif not consistent: return None stroke_color = np.median(region[stroke_mask], axis=0) distance = cv2.distanceTransform(stroke_mask.astype(np.uint8), cv2.DIST_L2, 5) stroke = max(.5, 2 * float(np.percentile(distance[stroke_mask], 90)) - 1) hex_color = lambda color: "#" + "".join(f"{round(value):02x}" for value in color) result = cv2.boundingRect(ink.astype(np.uint8)), hex_color(fill), hex_color(stroke_color), stroke return (*result, gradient) if return_gradient else result def estimate_art_text_runs(pixels: np.ndarray, item: dict, *, reference_width: int): from scripts.font_match import match_glyph from scripts.text_runs import validate_text_words if not item.get("words") or len(item["text"].splitlines()) != 1: return None validate_text_words(item) x, y, width, height = item["box"] left, top = max(0, int(x)), max(0, int(y)) line = pixels[top:min(pixels.shape[0], int(y+height)), left:min(pixels.shape[1], int(x+width))] measured = _line_ink(line) if measured is None: return None fill_mask, ink, dark = measured bx, by, bw, bh = cv2.boundingRect(ink.astype(np.uint8)) words = item["words"] centers = [(word["box"][0]+word["box"][2]/2)*width+x-left for word in words] word_left = min(word["box"][0] for word in words)*width+x-left word_right = max(word["box"][0]+word["box"][2] for word in words)*width+x-left offset = ((bx-word_left)+(bx+bw-word_right))/2 centers = np.clip(np.asarray(centers)+offset, bx, bx+bw-1) profile = fill_mask.sum(axis=0).astype(float) boundaries = [bx] for first, second in zip(centers, centers[1:]): low, high = max(boundaries[-1]+1, int(first)), min(line.shape[1], int(second)+1) if high <= low: return None costs = profile[low:high] + .03*np.abs(np.arange(low, high)-(first+second)/2) boundaries.append(low+int(np.argmin(costs))) boundaries.append(bx+bw) positioned = [] for word, start_x, end_x in zip(words, boundaries, boundaries[1:]): text = word["text"] characters = [char for char in text if not char.isspace()] if len(characters) > 1: local_x, _, local_width, _ = cv2.boundingRect(ink[:, start_x:end_x].astype(np.uint8)) char_edges = [start_x] for index in range(1, len(characters)): target = start_x + local_x + local_width*index/len(characters) margin = local_width/len(characters)/3 low, high = max(char_edges[-1]+1, int(target-margin)), min(end_x, int(target+margin)+1) if high <= low: return None costs = profile[low:high] + .03*np.abs(np.arange(low, high)-target) char_edges.append(low+int(np.argmin(costs))) char_edges.append(end_x) positioned.extend(zip(characters, char_edges, char_edges[1:])) else: positioned.append((text, start_x, end_x)) runs, cursor = [], 0 for word_text, start_x, end_x in positioned: region = line[:, start_x:end_x] fitted = match_glyph(fill_mask[:, start_x:end_x], word_text, item.get("font", "Arial")) if fitted is None: return None measured = _measure_ink(region, fill_mask[:, start_x:end_x], ink[:, start_x:end_x], dark[:, start_x:end_x], rotation=fitted["rotation"], return_gradient=True) if measured is None: return None bounds, color, outline, stroke, gradient = measured rx, ry, rw, rh = bounds start = item["text"].find(word_text, cursor) if start < cursor or item["text"][cursor:start].strip(): return None text = item["text"][cursor:start+len(word_text)] cursor = start+len(word_text) runs.append({"text": text, "box": [(left+start_x+rx-x)/width, (top+ry-y)/height, rw/width, rh/height], "box_kind": "ink", "font": fitted["font"], "bold": fitted["bold"], "rotation": fitted["rotation"], "font_size": fitted["font_size"]*960/reference_width, "color": color, "outline_color": outline, "outline_width": 2*stroke*960/reference_width}) if gradient is not None: runs[-1]["gradient"] = gradient if not runs or item["text"][cursor:].strip(): return None runs[-1]["text"] += item["text"][cursor:] return runs -
bg_model.py 41.6 KB
#!/usr/bin/env python3 """Background modeling and repair module. Builds a clean background image by: 1. Adaptive background color detection (edge sampling) 2. Using the original image as base (preserving real background) 3. Inpainting foreground/text regions from surrounding pixels Usage: from bg_model import build_background bg = build_background(img_rgb, text_mask=mask) """ from __future__ import annotations import logging import math import cv2 import numpy as np logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def _corner_colors(background: np.ndarray) -> tuple[np.ndarray, ...]: """Return median RGB colors from the four 5% corner regions.""" source = np.asarray(background) if source.ndim != 3 or source.shape[2] != 3: raise ValueError("background must be an RGB image") height, width = source.shape[:2] if height <= 0 or width <= 0: raise ValueError("background must not be empty") corner_height = max(1, int(round(height * 0.05))) corner_width = max(1, int(round(width * 0.05))) regions = ( source[:corner_height, :corner_width], source[:corner_height, -corner_width:], source[-corner_height:, :corner_width], source[-corner_height:, -corner_width:], ) return tuple( np.median(region.reshape(-1, 3), axis=0).astype(np.float32) for region in regions ) def _bilinear_gradient( colors: tuple[np.ndarray, ...], width: int, height: int, ) -> np.ndarray: """Build an RGB canvas interpolated between four corner colors.""" if width <= 0 or height <= 0: raise ValueError("canvas dimensions must be positive") corner_values = np.asarray(colors, dtype=np.float32) if corner_values.shape != (4, 3): raise ValueError("colors must contain four RGB values") top_left, top_right, bottom_left, bottom_right = corner_values x = np.linspace(0.0, 1.0, width, dtype=np.float32)[None, :, None] y = np.linspace(0.0, 1.0, height, dtype=np.float32)[:, None, None] top = top_left * (1.0 - x) + top_right * x bottom = bottom_left * (1.0 - x) + bottom_right * x gradient = top * (1.0 - y) + bottom * y return np.clip(np.rint(gradient), 0, 255).astype(np.uint8) def extend_background_to_widescreen( background: np.ndarray, canvas_width: int = 1920, canvas_height: int = 1080, ) -> np.ndarray: """Extend a background to widescreen while preserving its centered content.""" source = np.asarray(background) if source.ndim != 3 or source.shape[2] != 3: raise ValueError("background must be an RGB image") height, width = source.shape[:2] canvas = _bilinear_gradient( _corner_colors(source), canvas_width, canvas_height, ) contain_scale = min(canvas_width / width, canvas_height / height) contain_width = min(canvas_width, int(round(width * contain_scale))) contain_height = min(canvas_height, int(round(height * contain_scale))) contained = cv2.resize( source, (contain_width, contain_height), interpolation=cv2.INTER_AREA ) offset_x = (canvas_width - contain_width) // 2 offset_y = (canvas_height - contain_height) // 2 canvas[ offset_y:offset_y + contain_height, offset_x:offset_x + contain_width, ] = contained return canvas def compute_widescreen_canvas(width: int, height: int) -> tuple[int, int, int, int]: """Return the smallest centered integer 16:9 canvas containing the source.""" if width <= 0 or height <= 0: raise ValueError("source dimensions must be positive") units = max(math.ceil(width / 16), math.ceil(height / 9)) canvas_width = 16 * units canvas_height = 9 * units return ( canvas_width, canvas_height, (canvas_width - width) // 2, (canvas_height - height) // 2, ) def _resize_cover(source: np.ndarray, width: int, height: int) -> np.ndarray: """Sample a centered cover with one uniform scale and no large intermediate.""" source_height, source_width = source.shape[:2] scale = max(width / source_width, height / source_height) transform = np.array( ( ( scale, 0.0, ((width - 1) - (source_width - 1) * scale) / 2.0, ), ( 0.0, scale, ((height - 1) - (source_height - 1) * scale) / 2.0, ), ), dtype=np.float32, ) return cv2.warpAffine( source, transform, (width, height), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE, ) def _build_ambient_backdrop( source: np.ndarray, canvas_width: int, canvas_height: int, offset_x: int, offset_y: int, ) -> np.ndarray: """Build a soft decorative extension without repeating source objects.""" height, width = source.shape[:2] canvas_ratio = canvas_width / canvas_height work_short = min(256, canvas_width, canvas_height) if canvas_width >= canvas_height: work_height = work_short work_width = max(1, int(round(work_short * canvas_ratio))) else: work_width = work_short work_height = max(1, int(round(work_short / canvas_ratio))) decorative = _resize_cover(source, work_width, work_height) sigma = max(1.0, 0.025 * min(work_width, work_height)) decorative = cv2.GaussianBlur(decorative, (0, 0), sigmaX=sigma, sigmaY=sigma) decorative_float = decorative.astype(np.float32) gray = cv2.cvtColor(decorative, cv2.COLOR_RGB2GRAY).astype(np.float32) decorative_float = decorative_float * 0.8 + gray[:, :, None] * 0.2 decorative = np.clip( np.rint(decorative_float * 0.92), 0, 255 ).astype(np.uint8) result = cv2.resize( decorative, (canvas_width, canvas_height), interpolation=cv2.INTER_LINEAR ) right_pad = canvas_width - offset_x - width bottom_pad = canvas_height - offset_y - height horizontal_extension = offset_x + right_pad vertical_extension = offset_y + bottom_pad x = np.arange(canvas_width, dtype=np.float32)[None, :] dx = np.maximum( np.maximum(offset_x - x, x - (offset_x + width - 1)), 0 ) for row_start in range(0, canvas_height, 64): row_end = min(canvas_height, row_start + 64) if horizontal_extension >= vertical_extension: farthest = max(1, offset_x, right_pad) ratio = np.broadcast_to(dx / farthest, (row_end - row_start, canvas_width)) else: y = np.arange(row_start, row_end, dtype=np.float32)[:, None] dy = np.maximum( np.maximum(offset_y - y, y - (offset_y + height - 1)), 0 ) farthest = max(1, offset_y, bottom_pad) ratio = np.broadcast_to(dy / farthest, (row_end - row_start, canvas_width)) values = result[row_start:row_end].astype(np.float32) values *= (1.0 - 0.08 * np.clip(ratio, 0, 1))[:, :, None] result[row_start:row_end] = np.clip(np.rint(values), 0, 255).astype(np.uint8) band_limit = min(64, max(4, int(round(min(canvas_width, canvas_height) * 0.02)))) def lowpass(edge: np.ndarray) -> np.ndarray: length = edge.shape[0] edge_sigma = max(1.0, min(12.0, length * 0.025)) shaped = edge[:, None, :].astype(np.float32) blurred = cv2.GaussianBlur( shaped, (0, 0), sigmaX=1.0, sigmaY=edge_sigma, )[:, 0] return blurred def smooth_weights(depth: int) -> np.ndarray: if depth <= 0: return np.empty(0, dtype=np.float32) value = 1.0 - np.arange(1, depth + 1, dtype=np.float32) / (depth + 1) return value * value * (3.0 - 2.0 * value) source_x2 = offset_x + width source_y2 = offset_y + height def blend_vertical_side(pad: int, seam_x: int, step: int, source_edge: np.ndarray) -> None: depth = min(pad, band_limit) - 1 if depth <= 0: return columns = seam_x + step * np.arange(1, depth + 1) ambient_edge = result[offset_y:source_y2, columns[0]] delta = np.clip( lowpass(source_edge) - lowpass(ambient_edge), -48, 48 ) weights = smooth_weights(depth) strip = result[offset_y:source_y2, columns].astype(np.float32) strip += delta[:, None, :] * weights[None, :, None] result[offset_y:source_y2, columns] = np.clip( np.rint(strip), 0, 255 ).astype(np.uint8) def blend_horizontal_side(pad: int, seam_y: int, step: int, source_edge: np.ndarray) -> None: depth = min(pad, band_limit) - 1 if depth <= 0: return rows = seam_y + step * np.arange(1, depth + 1) ambient_edge = result[rows[0], offset_x:source_x2] delta = np.clip( lowpass(source_edge) - lowpass(ambient_edge), -48, 48 ) weights = smooth_weights(depth) strip = result[rows, offset_x:source_x2].astype(np.float32) strip += delta[None, :, :] * weights[:, None, None] result[rows, offset_x:source_x2] = np.clip( np.rint(strip), 0, 255 ).astype(np.uint8) blend_vertical_side(offset_x, offset_x - 1, -1, source[:, 0]) blend_vertical_side(right_pad, source_x2, 1, source[:, -1]) blend_horizontal_side(offset_y, offset_y - 1, -1, source[0]) blend_horizontal_side(bottom_pad, source_y2, 1, source[-1]) if offset_x: result[offset_y:source_y2, offset_x - 1] = source[:, 0] if right_pad: result[offset_y:source_y2, source_x2] = source[:, -1] if offset_y: result[offset_y - 1, offset_x:source_x2] = source[0] if bottom_pad: result[source_y2, offset_x:source_x2] = source[-1] if offset_x and offset_y: result[offset_y - 1, offset_x - 1] = source[0, 0] if right_pad and offset_y: result[offset_y - 1, source_x2] = source[0, -1] if offset_x and bottom_pad: result[source_y2, offset_x - 1] = source[-1, 0] if right_pad and bottom_pad: result[source_y2, source_x2] = source[-1, -1] result[offset_y:offset_y + height, offset_x:offset_x + width] = source return np.ascontiguousarray(result) def _outpaint_in_stages( source: np.ndarray, canvas_width: int, canvas_height: int, large_inpainter, ) -> np.ndarray: """Grow a centered source by at most 25% per axis in each inpaint pass.""" source_height, source_width = source.shape[:2] current = source.copy() current_width = source_width current_height = source_height while current_width < canvas_width or current_height < canvas_height: if ((current_width < canvas_width and current_width < 4) or (current_height < canvas_height and current_height < 4)): raise ValueError("source axis is too small for a 25% outpaint stage") next_width = min(canvas_width, current_width + max(1, current_width // 4)) next_height = min(canvas_height, current_height + max(1, current_height // 4)) current_source_x = (current_width - source_width) // 2 current_source_y = (current_height - source_height) // 2 next_source_x = (next_width - source_width) // 2 next_source_y = (next_height - source_height) // 2 left = next_source_x - current_source_x top = next_source_y - current_source_y seed = _build_ambient_backdrop(current, next_width, next_height, left, top) mask = np.full((next_height, next_width), 255, dtype=np.uint8) mask[top:top + current_height, left:left + current_width] = 0 candidate = large_inpainter(seed, mask) if (not isinstance(candidate, np.ndarray) or candidate.shape != seed.shape or candidate.dtype != np.uint8): raise ValueError("outpaint result must be a same-shape uint8 RGB array") candidate = np.ascontiguousarray(candidate) candidate[top:top + current_height, left:left + current_width] = current if not _extension_quality_passes(candidate, current, left, top): raise ValueError("outpaint stage failed extension quality checks") current = candidate current_width = next_width current_height = next_height return current def _extension_quality_passes( candidate: np.ndarray, source: np.ndarray, offset_x: int, offset_y: int, ) -> bool: """Reject extensions with broken seams, flat detail, or lost sharpness.""" source_height, source_width = source.shape[:2] source_x2 = offset_x + source_width source_y2 = offset_y + source_height if not np.array_equal(candidate[offset_y:source_y2, offset_x:source_x2], source): return False def side_passes(new_region, new_seam, source_edge, new_strip, source_strip) -> bool: difference = np.abs( new_seam.astype(np.float32) - source_edge.astype(np.float32) ) if difference.mean() > 18 or np.percentile(difference, 95) > 48: return False if np.std(source_edge.astype(np.float32)) >= 8 and np.std(new_region) < 3: return False source_gray = cv2.cvtColor(source_strip, cv2.COLOR_RGB2GRAY) new_gray = cv2.cvtColor(new_strip, cv2.COLOR_RGB2GRAY) source_detail = cv2.Laplacian(source_gray, cv2.CV_32F).var() if source_detail >= 10: new_detail = cv2.Laplacian(new_gray, cv2.CV_32F).var() if new_detail / source_detail < 0.25: return False return True checks = [] depth = min(32, source_height) if offset_y: checks.append((candidate[:offset_y, offset_x:source_x2], candidate[offset_y - 1, offset_x:source_x2], source[0], candidate[max(0, offset_y - 32):offset_y, offset_x:source_x2], source[:depth])) if source_y2 < candidate.shape[0]: checks.append((candidate[source_y2:, offset_x:source_x2], candidate[source_y2, offset_x:source_x2], source[-1], candidate[source_y2:min(candidate.shape[0], source_y2 + 32), offset_x:source_x2], source[-depth:])) depth = min(32, source_width) if offset_x: checks.append((candidate[offset_y:source_y2, :offset_x], candidate[offset_y:source_y2, offset_x - 1], source[:, 0], candidate[offset_y:source_y2, max(0, offset_x - 32):offset_x], source[:, :depth])) if source_x2 < candidate.shape[1]: checks.append((candidate[offset_y:source_y2, source_x2:], candidate[offset_y:source_y2, source_x2], source[:, -1], candidate[offset_y:source_y2, source_x2:min(candidate.shape[1], source_x2 + 32)], source[:, -depth:])) return all(side_passes(*check) for check in checks) def build_widescreen_background( background: np.ndarray, large_inpainter=None, ) -> tuple[np.ndarray, int, int, str]: """Create a lossless centered 16:9 background, preferring LaMa outpaint.""" source = np.asarray(background) if source.ndim != 3 or source.shape[2] != 3 or source.dtype != np.uint8: raise ValueError("background must be a uint8 RGB image") height, width = source.shape[:2] canvas_width, canvas_height, offset_x, offset_y = compute_widescreen_canvas( width, height ) if (canvas_width, canvas_height) == (width, height): return source.copy(), 0, 0, "identity" from scripts.lama_inpaint import LargeMaskInpaintError, inpaint_large_mask inpainter = large_inpainter or inpaint_large_mask try: candidate = _outpaint_in_stages( source, canvas_width, canvas_height, inpainter ) except (LargeMaskInpaintError, ValueError): return ( _build_ambient_backdrop( source, canvas_width, canvas_height, offset_x, offset_y ), offset_x, offset_y, "ambient", ) candidate[offset_y:offset_y + height, offset_x:offset_x + width] = source if not _extension_quality_passes(candidate, source, offset_x, offset_y): return ( _build_ambient_backdrop( source, canvas_width, canvas_height, offset_x, offset_y ), offset_x, offset_y, "ambient", ) return candidate, offset_x, offset_y, "outpaint" def build_background( img: np.ndarray, text_mask: np.ndarray | None = None, fg_hint_mask: np.ndarray | None = None, period: int = 32, ) -> np.ndarray: """Build a clean background image from the input. Args: img: Input image (H, W, 3) RGB uint8. text_mask: Binary mask (H, W) where text regions = 255. fg_hint_mask: Optional binary mask of known foreground regions. period: Tile period (kept for API compatibility, unused in new approach). Returns: Clean background image (H, W, 3) RGB uint8. """ h, w = img.shape[:2] if text_mask is None: text_mask = np.zeros((h, w), dtype=np.uint8) if fg_hint_mask is None: fg_hint_mask = np.zeros((h, w), dtype=np.uint8) elif not _should_use_fg_hint( nonzero_pixels=int(np.count_nonzero(fg_hint_mask)), total_pixels=h * w, ): logger.warning("Ignoring oversized foreground hint for background refinement.") fg_hint_mask = np.zeros((h, w), dtype=np.uint8) # Combined exclusion mask exclude = ((text_mask > 0) | (fg_hint_mask > 0)).astype(np.uint8) * 255 # Step 1: Detect background color adaptively bg_color, bg_std, candidate_mask = _detect_background(img, exclude) logger.info( "Background color: RGB(%d,%d,%d), std=%.1f", int(bg_color[0]), int(bg_color[1]), int(bg_color[2]), bg_std, ) # Step 2: Build background — strategy depends on whether we have fg hints has_fg_hint = np.any(fg_hint_mask > 0) if has_fg_hint: # Refinement pass: use original image + inpainting for pixel-accurate bg bg = _original_based_background( img, exclude, bg_color, text_mask=text_mask, fg_mask=fg_hint_mask, ) else: # Initial pass: smooth background for foreground detection bg = _smooth_background(img, bg_color, candidate_mask, text_mask) return bg def build_clean_background( img: np.ndarray, element_masks: list[np.ndarray], text_mask: np.ndarray, large_inpainter=None, text_clean_image: np.ndarray | None = None, text_restore_mask: np.ndarray | None = None, ) -> np.ndarray: """Remove visual elements and text from an image.""" removal = build_removal_mask(element_masks, text_mask) repaired = repair_masked_background(img, removal, large_inpainter) if text_clean_image is None: return repaired trusted = np.asarray(text_clean_image) if trusted.shape != repaired.shape: raise ValueError("text-clean image must match the source image shape") restore_source_mask = ( text_mask if text_restore_mask is None else np.asarray(text_restore_mask) ) if restore_source_mask.shape != repaired.shape[:2]: raise ValueError("text restore mask must match the image height and width") text_removal = build_removal_mask([], restore_source_mask) > 0 # Text cleanup preserves graphics, so never restore it over removed elements. element_removal = build_removal_mask(element_masks, np.zeros_like(text_mask)) > 0 text_removal &= ~element_removal if np.any(text_removal): try: from scripts.component_quality import ( _residual_text_ink_mask, _text_ink_mask, calibrate_page, ) except ModuleNotFoundError as error: if error.name != "scripts.component_quality": raise from image2editable.component_quality import ( _residual_text_ink_mask, _text_ink_mask, calibrate_page, ) calibration = calibrate_page(img, restore_source_mask) ink = _text_ink_mask(img, restore_source_mask > 0, calibration) residual = _residual_text_ink_mask(trusted, ink, ink, calibration) # A failed text-clean region must not overwrite a repaired background. _, labels = cv2.connectedComponents(text_removal.astype(np.uint8), 8) dirty_labels = np.unique(labels[residual]) dirty_labels = dirty_labels[dirty_labels > 0] text_removal &= ~np.isin(labels, dirty_labels) repaired[text_removal] = trusted[text_removal] return repaired def build_removal_mask( element_masks: list[np.ndarray], text_mask: np.ndarray, ) -> np.ndarray: """Combine visual-element and text masks for background repair.""" removal = (text_mask > 0).astype(np.uint8) * 255 for mask in element_masks: removal[np.asarray(mask, dtype=bool)] = 255 return cv2.dilate( removal, np.ones((5, 5), dtype=np.uint8), iterations=1, ) def needs_large_mask_inpaint(mask: np.ndarray) -> bool: """Return whether a mask is too large or deep for local OpenCV inpainting.""" binary = (np.asarray(mask) > 0).astype(np.uint8) if not np.any(binary): return False h, w = binary.shape mask_ratio = np.count_nonzero(binary) / binary.size depth = cv2.distanceTransform(binary, cv2.DIST_L2, 5) max_depth_ratio = float(depth.max()) / np.hypot(h, w) count, _, stats, _ = cv2.connectedComponentsWithStats( binary, connectivity=8, ) largest_component_ratio = ( int(np.max(stats[1:, cv2.CC_STAT_AREA])) / binary.size if count > 1 else 0.0 ) return ( max_depth_ratio > 0.015 or ( mask_ratio > 0.08 and largest_component_ratio > 0.04 ) ) def repair_masked_background( image: np.ndarray, mask: np.ndarray, large_inpainter=None, ) -> np.ndarray: """Repair a mask with OpenCV or LaMa according to its scale.""" source = np.asarray(image) binary = (np.asarray(mask) > 0).astype(np.uint8) * 255 if binary.shape != source.shape[:2]: raise ValueError("mask must match the image height and width") if not np.any(binary): return source.copy() if needs_large_mask_inpaint(binary): if large_inpainter is None: from scripts.lama_inpaint import inpaint_large_mask large_inpainter = inpaint_large_mask repaired = large_inpainter(source, binary) else: repaired = _inpaint(source, binary) repaired = np.asarray(repaired) if repaired.shape != source.shape: raise ValueError( f"inpaint output shape {repaired.shape} does not match {source.shape}" ) output = repaired.astype(np.uint8, copy=True) output[binary == 0] = source[binary == 0] return output def build_text_only_background( img: np.ndarray, text_items: list[dict], padding: int = 2, ) -> np.ndarray: """Remove detected text while preserving all non-text slide content.""" h, w = img.shape[:2] mask = np.zeros((h, w), dtype=np.uint8) for item in text_items: x, y, bw, bh = item["box"] x1 = max(0, int(x - padding)) y1 = max(0, int(y - padding)) x2 = min(w, int(x + bw + padding)) y2 = min(h, int(y + bh + padding)) mask[y1:y2, x1:x2] = 255 if not np.any(mask): return img.copy() return _inpaint(img, mask) def _should_use_fg_hint( nonzero_pixels: int, total_pixels: int, max_foreground_ratio: float = 0.45, ) -> bool: """Reject failed foreground hints that cover too much of the slide.""" if total_pixels <= 0: return False return nonzero_pixels / total_pixels <= max_foreground_ratio # --------------------------------------------------------------------------- # Step 1: Adaptive background detection # --------------------------------------------------------------------------- def _detect_background( img: np.ndarray, exclude_mask: np.ndarray ) -> tuple[np.ndarray, float, np.ndarray]: """Detect the dominant background color by sampling image edges. Returns: bg_color: (3,) float array — dominant background RGB. bg_std: float — standard deviation of background pixels. candidate_mask: (H, W) bool — pixels likely belonging to background. """ h, w = img.shape[:2] # Sample from edges (5% border on each side) margin_y = max(5, int(h * 0.05)) margin_x = max(5, int(w * 0.05)) edge_mask = np.zeros((h, w), dtype=bool) edge_mask[:margin_y, :] = True # top edge_mask[-margin_y:, :] = True # bottom edge_mask[:, :margin_x] = True # left edge_mask[:, -margin_x:] = True # right # Exclude known text/foreground from edge sampling edge_mask &= (exclude_mask == 0) edge_pixels = img[edge_mask].reshape(-1, 3).astype(np.float32) if len(edge_pixels) < 10: # Fallback: use all non-excluded pixels valid = exclude_mask == 0 edge_pixels = img[valid].reshape(-1, 3).astype(np.float32) if len(edge_pixels) < 10: # Ultimate fallback bg_color = np.array([255.0, 255.0, 255.0]) return bg_color, 30.0, np.ones((h, w), dtype=bool) # Find dominant color via histogram peak (faster than KMeans) bg_color = np.median(edge_pixels, axis=0) bg_std = float(np.mean(np.std(edge_pixels, axis=0))) # Adaptive threshold: pixels within N standard deviations of bg_color threshold = max(35.0, bg_std * 2.5) all_pixels = img.reshape(-1, 3).astype(np.float32) dists = np.linalg.norm(all_pixels - bg_color, axis=1) candidate_flat = dists < threshold candidate_mask = candidate_flat.reshape(h, w) # Exclude known foreground/text candidate_mask &= (exclude_mask == 0) return bg_color, bg_std, candidate_mask # --------------------------------------------------------------------------- # Step 2a: Smooth background for initial foreground detection # --------------------------------------------------------------------------- def _smooth_background( img: np.ndarray, bg_color: np.ndarray, candidate_mask: np.ndarray, text_mask: np.ndarray, ) -> np.ndarray: """Build a smooth background for the initial foreground detection pass. Replaces non-background pixels with bg_color and applies smoothing. This creates enough contrast for diff-based foreground detection while preserving the general background appearance. Args: img: Original image (H, W, 3) RGB uint8. bg_color: Detected background color (3,) float. candidate_mask: (H, W) bool — pixels likely belonging to background. text_mask: Binary mask (H, W) uint8 where text regions = 255. Returns: Smooth background (H, W, 3) RGB uint8. """ bg = img.copy() fill = np.clip(bg_color, 0, 255).astype(np.uint8) # Replace non-candidate pixels (likely foreground) with bg_color bg[~candidate_mask] = fill # Also replace text regions if text_mask is not None: bg[text_mask > 0] = fill # Smooth to blend transitions and reduce artifacts bg = cv2.GaussianBlur(bg, (21, 21), 0) return bg # --------------------------------------------------------------------------- # Step 2b: Original-based background with inpainting (refinement pass) # --------------------------------------------------------------------------- def _original_based_background( img: np.ndarray, exclude_mask: np.ndarray, bg_color: np.ndarray, text_mask: np.ndarray | None = None, fg_mask: np.ndarray | None = None, ) -> np.ndarray: """Build background by starting from original image and inpainting excluded regions. This preserves the original background pixel-for-pixel in areas without foreground/text, and uses inpainting to fill the excluded regions from surrounding real background pixels. Args: img: Original image (H, W, 3) RGB uint8. exclude_mask: Binary mask (H, W) uint8, regions to repair = 255. bg_color: Detected background color (3,) float. Returns: Clean background (H, W, 3) RGB uint8. """ bg = img.copy() # If nothing to repair, return original if not np.any(exclude_mask > 0): return bg if text_mask is not None: bg = _fill_text_regions(bg, text_mask) repair_mask = fg_mask if fg_mask is not None else exclude_mask if fg_mask is not None: bg = _replace_unrecoverable_large_regions(bg, fg_mask, bg_color) repair_mask = _build_component_repair_mask(fg_mask) if not np.any(repair_mask > 0): return bg # Pre-fill foreground regions with bg_color for better inpainting seed fill_color = np.clip(bg_color, 0, 255).astype(np.uint8) bg[repair_mask > 0] = fill_color # Build inpaint mask from the exact excluded regions. inpaint_mask = _build_inpaint_mask(repair_mask) # Inpaint to blend filled regions with surrounding real background bg = _inpaint(bg, inpaint_mask) return bg def _build_inpaint_mask(exclude_mask: np.ndarray) -> np.ndarray: """Build inpaint mask from the exact exclusion mask.""" return exclude_mask.copy() def _build_component_repair_mask(fg_mask: np.ndarray) -> np.ndarray: """Build a repair mask that also covers small component shadows.""" repair_mask = np.zeros_like(fg_mask) safe_mask = _mask_for_destructive_repair(fg_mask) total_area = max(int(fg_mask.shape[0] * fg_mask.shape[1]), 1) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( (safe_mask > 0).astype(np.uint8), connectivity=8 ) for i in range(1, num_labels): area = int(stats[i, cv2.CC_STAT_AREA]) x = int(stats[i, cv2.CC_STAT_LEFT]) y = int(stats[i, cv2.CC_STAT_TOP]) bw = int(stats[i, cv2.CC_STAT_WIDTH]) bh = int(stats[i, cv2.CC_STAT_HEIGHT]) if _is_unrecoverable_large_region(area, bw, bh, total_area): continue repair_mask[labels == i] = 255 if not _should_expand_shadow_halo(bw, bh, fg_mask.shape): continue pad = max(2, min(10, max(bw, bh) // 8)) x1 = max(0, x - pad) y1 = max(0, y - pad) x2 = min(fg_mask.shape[1], x + bw + pad) y2 = min(fg_mask.shape[0], y + bh + pad) repair_mask[y1:y2, x1:x2] = np.maximum( repair_mask[y1:y2, x1:x2], cv2.dilate( (labels[y1:y2, x1:x2] == i).astype(np.uint8) * 255, np.ones((pad * 2 + 1, pad * 2 + 1), np.uint8), iterations=1, ), ) return repair_mask def _should_expand_shadow_halo( width: int, height: int, mask_shape: tuple[int, int], max_bbox_area_ratio: float = 0.08, max_width_ratio: float = 0.25, max_height_ratio: float = 0.30, ) -> bool: """Only expand repair for compact objects likely to have drop shadows.""" img_h, img_w = mask_shape total_area = max(img_h * img_w, 1) return ( width * height / total_area <= max_bbox_area_ratio and width / max(img_w, 1) <= max_width_ratio and height / max(img_h, 1) <= max_height_ratio ) def _mask_for_destructive_repair(fg_mask: np.ndarray) -> np.ndarray: """Keep only foreground regions that are small enough to repair safely.""" repair_mask = fg_mask.copy() total_area = max(int(fg_mask.shape[0] * fg_mask.shape[1]), 1) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( (fg_mask > 0).astype(np.uint8), connectivity=8 ) for i in range(1, num_labels): area = int(stats[i, cv2.CC_STAT_AREA]) bw = int(stats[i, cv2.CC_STAT_WIDTH]) bh = int(stats[i, cv2.CC_STAT_HEIGHT]) if _is_unrecoverable_large_region(area, bw, bh, total_area): repair_mask[labels == i] = 0 return repair_mask def _replace_unrecoverable_large_regions( bg: np.ndarray, fg_mask: np.ndarray, bg_color: np.ndarray, ) -> np.ndarray: """Replace foreground bboxes that are unlikely to reveal true hidden pixels.""" output = bg.copy() total_area = max(int(fg_mask.shape[0] * fg_mask.shape[1]), 1) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( (fg_mask > 0).astype(np.uint8), connectivity=8 ) for i in range(1, num_labels): area = int(stats[i, cv2.CC_STAT_AREA]) x = int(stats[i, cv2.CC_STAT_LEFT]) y = int(stats[i, cv2.CC_STAT_TOP]) bw = int(stats[i, cv2.CC_STAT_WIDTH]) bh = int(stats[i, cv2.CC_STAT_HEIGHT]) if not _should_replace_region_bbox(area, bw, bh, total_area): continue pad = max(2, min(10, max(bw, bh) // 16)) x1 = max(0, x - pad) y1 = max(0, y - pad) x2 = min(fg_mask.shape[1], x + bw + pad) y2 = min(fg_mask.shape[0], y + bh + pad) fill = _fill_region_from_low_frequency_context( output, fg_mask, x1, y1, x2 - x1, y2 - y1, bg_color ) output[y1:y2, x1:x2] = fill return output def _should_replace_region_bbox( area: int, width: int, height: int, total_area: int, min_dense_fill_ratio: float = 0.18, max_dense_bbox_ratio: float = 0.12, ) -> bool: """Choose regions where bbox fill is more stable than local inpaint.""" bbox_area = max(width * height, 1) if _is_unrecoverable_large_region(area, width, height, total_area): return True return ( bbox_area / max(total_area, 1) <= max_dense_bbox_ratio and area / bbox_area >= min_dense_fill_ratio ) def _sample_large_region_fill( img: np.ndarray, fg_mask: np.ndarray, x: int, y: int, width: int, height: int, bg_color: np.ndarray, ) -> np.ndarray: """Estimate a clean fill color from the ring around a large region.""" h, w = fg_mask.shape pad = max(12, min(80, max(width, height) // 12)) sx1 = max(0, x - pad) sy1 = max(0, y - pad) sx2 = min(w, x + width + pad) sy2 = min(h, y + height + pad) ring = np.zeros((sy2 - sy1, sx2 - sx1), dtype=bool) ring[:, :] = True ring[ y - sy1:y + height - sy1, x - sx1:x + width - sx1, ] = False ring &= fg_mask[sy1:sy2, sx1:sx2] == 0 pixels = img[sy1:sy2, sx1:sx2][ring] if len(pixels) < 20: fill = bg_color else: fill = np.median(pixels.reshape(-1, 3), axis=0) return np.clip(fill, 0, 255).astype(np.uint8) def _fill_region_from_low_frequency_context( img: np.ndarray, fg_mask: np.ndarray, x: int, y: int, width: int, height: int, bg_color: np.ndarray, ) -> np.ndarray: """Fill a hidden region from low-frequency local context.""" h, w = fg_mask.shape context = max(20, min(180, max(width, height) // 2)) sx1 = max(0, x - context) sy1 = max(0, y - context) sx2 = min(w, x + width + context) sy2 = min(h, y + height + context) roi = img[sy1:sy2, sx1:sx2].copy() if roi.size == 0: fill = np.clip(bg_color, 0, 255).astype(np.uint8) return np.tile(fill, (height, width, 1)) mask = np.zeros((sy2 - sy1, sx2 - sx1), dtype=np.uint8) mask[y - sy1:y + height - sy1, x - sx1:x + width - sx1] = 255 max_dim = max(roi.shape[:2]) scale = min(1.0, 220.0 / max(max_dim, 1)) if scale < 1.0: small_size = ( max(1, int(roi.shape[1] * scale)), max(1, int(roi.shape[0] * scale)), ) small_roi = cv2.resize(roi, small_size, interpolation=cv2.INTER_AREA) small_mask = cv2.resize(mask, small_size, interpolation=cv2.INTER_NEAREST) else: small_roi = roi small_mask = mask repaired = cv2.inpaint( cv2.cvtColor(small_roi, cv2.COLOR_RGB2BGR), small_mask, inpaintRadius=5, flags=cv2.INPAINT_TELEA, ) repaired = cv2.cvtColor(repaired, cv2.COLOR_BGR2RGB) if scale < 1.0: repaired = cv2.resize( repaired, (roi.shape[1], roi.shape[0]), interpolation=cv2.INTER_CUBIC ) return repaired[y - sy1:y + height - sy1, x - sx1:x + width - sx1] def _is_unrecoverable_large_region( area: int, width: int, height: int, total_area: int, min_bbox_area_ratio: float = 0.12, min_fill_ratio: float = 0.30, ) -> bool: """Identify large dense regions where local inpainting leaves visible scars.""" bbox_area = max(width * height, 1) return ( bbox_area / max(total_area, 1) >= min_bbox_area_ratio and area / bbox_area >= min_fill_ratio ) def _fill_text_regions(img: np.ndarray, text_mask: np.ndarray) -> np.ndarray: """Clean OCR text boxes with nearby non-text background color.""" output = img.copy() num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( (text_mask > 0).astype(np.uint8), connectivity=8 ) h, w = text_mask.shape for i in range(1, num_labels): x = stats[i, cv2.CC_STAT_LEFT] y = stats[i, cv2.CC_STAT_TOP] bw = stats[i, cv2.CC_STAT_WIDTH] bh = stats[i, cv2.CC_STAT_HEIGHT] x1 = max(0, x) y1 = max(0, y) x2 = min(w, x + bw) y2 = min(h, y + bh) pad = max(4, min(16, max(bw, bh) // 6)) sx1 = max(0, x1 - pad) sy1 = max(0, y1 - pad) sx2 = min(w, x2 + pad) sy2 = min(h, y2 + pad) box = output[y1:y2, x1:x2] ink = _estimate_text_ink(box) if not np.any(ink): continue fill = _sample_text_background(box, ink) if fill is None: local_mask = text_mask[sy1:sy2, sx1:sx2] == 0 local_pixels = output[sy1:sy2, sx1:sx2][local_mask] if len(local_pixels) == 0: fill = np.median(output.reshape(-1, 3), axis=0) else: fill = np.median(local_pixels.reshape(-1, 3), axis=0) output[y1:y2, x1:x2][ink] = np.clip(fill, 0, 255).astype(np.uint8) return output def _sample_text_background( region: np.ndarray, ink: np.ndarray ) -> np.ndarray | None: """Sample the text box's own background, avoiding outside-page colors.""" background = (~ink).astype(np.uint8) * 255 if background.shape[0] >= 3 and background.shape[1] >= 3: background = cv2.erode(background, np.ones((3, 3), np.uint8), iterations=1) pixels = region[background > 0] if len(pixels) < 10: pixels = region[~ink] if len(pixels) < 10: return None return np.median(pixels.reshape(-1, 3), axis=0) def _estimate_text_ink(region: np.ndarray) -> np.ndarray: """Estimate glyph pixels in an OCR text box.""" if region.size == 0 or region.shape[0] < 3 or region.shape[1] < 3: return np.zeros(region.shape[:2], dtype=bool) gray = cv2.cvtColor(region, cv2.COLOR_RGB2GRAY) if float(np.std(gray)) < 8.0: return np.zeros(gray.shape, dtype=bool) thresh, _ = cv2.threshold( gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU ) ink = _select_text_ink(gray, float(thresh)) ink_uint8 = ink.astype(np.uint8) * 255 ink_uint8 = cv2.dilate(ink_uint8, np.ones((3, 3), np.uint8), iterations=1) return ink_uint8 > 0 def _select_text_ink(gray: np.ndarray, thresh: float) -> np.ndarray: """Pick the glyph class after dropping background connected to box edges.""" dark = gray <= thresh light = gray > thresh dark_inner = _remove_border_connected(dark) light_inner = _remove_border_connected(light) if np.count_nonzero(dark_inner) or np.count_nonzero(light_inner): ink = ( dark_inner if np.count_nonzero(dark_inner) >= np.count_nonzero(light_inner) else light_inner ) else: ink = dark if np.count_nonzero(dark) <= np.count_nonzero(light) else light return _add_antialiased_text_edges(gray, ink) def _add_antialiased_text_edges(gray: np.ndarray, ink: np.ndarray) -> np.ndarray: """Include same-direction antialiased text pixels without taking the background.""" if not np.any(ink): return ink border = np.concatenate([ gray[0, :], gray[-1, :], gray[:, 0], gray[:, -1] ]).astype(np.float32) border_mean = float(np.mean(border)) ink_mean = float(np.mean(gray[ink])) if ink_mean < border_mean: candidate = gray <= max(0.0, border_mean - 25.0) else: candidate = gray >= min(255.0, border_mean + 25.0) candidate_inner = _remove_border_connected(candidate) if np.any(candidate_inner): return ink | candidate_inner return ink def _remove_border_connected(mask: np.ndarray) -> np.ndarray: """Remove mask components touching the OCR box edge.""" if not np.any(mask): return mask.copy() num_labels, labels = cv2.connectedComponents(mask.astype(np.uint8), connectivity=8) border_labels = set(labels[0, :]) border_labels.update(labels[-1, :]) border_labels.update(labels[:, 0]) border_labels.update(labels[:, -1]) keep = np.ones(num_labels, dtype=bool) keep[list(border_labels)] = False keep[0] = False return keep[labels] # --------------------------------------------------------------------------- # Inpainting # --------------------------------------------------------------------------- def _inpaint(bg: np.ndarray, mask: np.ndarray) -> np.ndarray: """Inpaint masked regions using dual-pass approach.""" original = bg.copy() bgr = cv2.cvtColor(bg, cv2.COLOR_RGB2BGR) # First pass: Telea algorithm with larger radius for structural fill repaired = cv2.inpaint(bgr, mask, inpaintRadius=7, flags=cv2.INPAINT_TELEA) # Second pass: NS method for smoother blending on the same regions repaired = cv2.inpaint(repaired, mask, inpaintRadius=5, flags=cv2.INPAINT_NS) result = cv2.cvtColor(repaired, cv2.COLOR_BGR2RGB) output = result.copy() output[mask == 0] = original[mask == 0] return output -
component_contracts.py 41.6 KB
from __future__ import annotations from pathlib import PurePosixPath import math AGENT_PROVIDERS = frozenset({"host"}) MAX_REPAIR_ROUNDS = 5 MAX_COMPONENT_PROMPT_POINTS = 256 COMPONENT_STATES = frozenset( {"pending", "pending_gate", "failed", "frozen", "inactive"} ) COMPONENT_KINDS = frozenset({"parent", "child", "text"}) LEGACY_COMPONENT_EVIDENCE_NAMES = frozenset( { "source.png", "numbered-masks.png", "ocr-overlay.png", "component-isolation.png", "ownership.png", "reconstructed.png", "difference.png", "component-graph.json", "quality-report.json", "presentation-manifest.json", } ) COMPONENT_EVIDENCE_NAMES = LEGACY_COMPONENT_EVIDENCE_NAMES | { "unexplained-mask.png" } ROUND_REVIEW_EVIDENCE_NAME = "round-review.png" FULL_COMPONENT_REVIEW_EVIDENCE = ( "source.png", "numbered-masks.png", "ocr-overlay.png", "component-isolation.png", "ownership.png", "reconstructed.png", "difference.png", "unexplained-mask.png", "quality-report.json", ) INCREMENTAL_COMPONENT_REVIEW_EVIDENCE = ( "source.png", "reconstructed.png", "difference.png", "unexplained-mask.png", "quality-report.json", ROUND_REVIEW_EVIDENCE_NAME, ) _COMPONENT_AGENT_REQUEST_FIELDS = frozenset( { "schema_version", "page_id", "provider", "repair_round", "source_sha256", "graph_sha256", "candidate_ids", "frozen_ids", "evidence", "review_evidence", } ) _COMPONENT_NODE_FIELDS = frozenset( { "id", "kind", "parent_id", "state", "mask", "mask_sha256", "bbox", "z_index", "text_ids", } ) _FROZEN_FIELDS = ( "state", "kind", "mask", "mask_sha256", "bbox", "z_index", "parent_id", "text_ids", ) _RENDER_STATES = frozenset({"pending", "pending_gate", "frozen"}) COMPONENT_REPAIR_PHASES = frozenset({ "request_published", "awaiting_plan", "plan_recorded", "actions_executed", "quality_recorded", "freeze_committed", "fallback_required", "fallback_executed", "fallback_quality_recorded", "ready_for_assembly", "preserved_with_warning", }) def validate_component_repair_state(state: object) -> dict: fields = { "schema_version", "page_id", "provider", "source_sha256", "initial_component_count", "quality_gate_version", "revision", "phase", "status", "repair_round", "plan_count", "stop_reason", "graph_ref", "current_round", "frozen", "candidate_ids", "failed_ids", "fallback", "last_normalized_plan_sha256", "result_ref", "delivery_checks", "updated_at", "round_history", "parent_assets", "fallback_graph_ref", "fallback_quality_ref", "fallback_input_refs", } if not isinstance(state, dict) or set(state) != fields: raise ValueError("component repair state fields are invalid") if state["schema_version"] != 1 or type(state["schema_version"]) is not int: raise ValueError("component repair state schema_version is invalid") page_id = state["page_id"] if type(page_id) is not str or not page_id or "/" in page_id or "\\" in page_id: raise ValueError("component repair state page_id is invalid") validate_agent_provider(state["provider"]) _validate_sha256(state["source_sha256"], "source_sha256") if type(state["initial_component_count"]) is not int or state["initial_component_count"] < 0: raise ValueError("component repair initial count is invalid") for name in ("quality_gate_version", "revision"): if type(state[name]) is not int or state[name] < 1: raise ValueError(f"component repair {name} is invalid") if state["phase"] not in COMPONENT_REPAIR_PHASES: raise ValueError("component repair phase is invalid") if state["status"] not in {"active", "ready_for_assembly", "preserved_with_warning"}: raise ValueError("component repair status is invalid") validate_repair_round(state["repair_round"]) if type(state["plan_count"]) is not int or not 0 <= state["plan_count"] <= MAX_REPAIR_ROUNDS: raise ValueError("component repair plan_count is invalid") if state["stop_reason"] not in { None, "empty_plan", "repeated_plan", "no_executable_actions", "round_limit", "no_quality_improvement", "unowned_raster_text", "page_quality_failed", "fast_strict_escalation_exhausted", }: raise ValueError("component repair stop_reason is invalid") _validate_artifact_ref(state["graph_ref"], "graph_ref") current = state["current_round"] if not isinstance(current, dict) or set(current) != { "round", "request_ref", "plan_ref", "execution_ref", "quality_ref" }: raise ValueError("component repair current_round is invalid") if current["round"] != state["repair_round"]: raise ValueError("component repair current round is inconsistent") _validate_artifact_ref(current["request_ref"], "request_ref") for name in ("plan_ref", "execution_ref", "quality_ref"): if current[name] is not None: _validate_artifact_ref(current[name], name) for name in ("candidate_ids", "failed_ids"): values = state[name] if not isinstance(values, list) or values != sorted(set(values)) or any(type(value) is not str or not value for value in values): raise ValueError(f"component repair {name} is invalid") frozen = state["frozen"] if not isinstance(frozen, dict) or any(type(key) is not str for key in frozen): raise ValueError("component repair frozen map is invalid") for digest in frozen.values(): _validate_sha256(digest, "frozen mask sha256") if set(state["candidate_ids"]) & set(frozen): raise ValueError("component repair candidate cannot be frozen") parent_assets = state["parent_assets"] if not isinstance(parent_assets, dict) or any(type(key) is not str for key in parent_assets): raise ValueError("component repair parent assets are invalid") for reference in parent_assets.values(): _validate_artifact_ref(reference, "parent asset ref") history = state["round_history"] if not isinstance(history, list) or len(history) > MAX_REPAIR_ROUNDS: raise ValueError("component repair round history is invalid") for entry in history: if not isinstance(entry, dict) or set(entry) != { "round", "plan_sha256", "normalized_plan_sha256", "execution_sha256", "quality_sha256", "frozen_ids", "failed_ids", }: raise ValueError("component repair round history entry is invalid") validate_repair_round(entry["round"]) for name in ("plan_sha256", "normalized_plan_sha256", "execution_sha256", "quality_sha256"): if entry[name] is not None: _validate_sha256(entry[name], f"component repair history {name}") for name in ("frozen_ids", "failed_ids"): if not isinstance(entry[name], list) or entry[name] != sorted(set(entry[name])): raise ValueError("component repair history component ids are invalid") fallback = state["fallback"] if not isinstance(fallback, dict) or set(fallback) != {"status", "parent_ids"}: raise ValueError("component repair fallback is invalid") if fallback["status"] not in {"none", "required", "parent_pending", "parent_preserved", "warning"}: raise ValueError("component repair fallback status is invalid") if not isinstance(fallback["parent_ids"], list) or fallback["parent_ids"] != sorted(set(fallback["parent_ids"])): raise ValueError("component repair fallback parents are invalid") if state["last_normalized_plan_sha256"] is not None: _validate_sha256(state["last_normalized_plan_sha256"], "last plan sha256") if state["result_ref"] is not None: _validate_artifact_ref(state["result_ref"], "result_ref") for name in ("fallback_graph_ref", "fallback_quality_ref"): if state[name] is not None: _validate_artifact_ref(state[name], name) if state["fallback_input_refs"] is not None: _validate_quality_input_refs(state["fallback_input_refs"]) if state["delivery_checks"] != {"pptx_reopen": "unknown"}: raise ValueError("component repair delivery checks are invalid") if type(state["updated_at"]) is not str or not state["updated_at"]: raise ValueError("component repair updated_at is invalid") if state["candidate_ids"] != state["failed_ids"]: raise ValueError("component repair candidates and failures are inconsistent") plan_ref = current["plan_ref"] execution_ref = current["execution_ref"] quality_ref = current["quality_ref"] phase = state["phase"] if phase in {"request_published", "awaiting_plan"} and any( value is not None for value in (plan_ref, execution_ref, quality_ref) ): raise ValueError("component repair awaiting phase has premature references") if phase == "plan_recorded" and ( plan_ref is None or execution_ref is not None or quality_ref is not None ): raise ValueError("component repair plan phase references are invalid") if phase == "actions_executed" and ( plan_ref is None or execution_ref is None or quality_ref is not None ): raise ValueError("component repair execution phase references are invalid") if phase in {"quality_recorded", "freeze_committed"} and any( value is None for value in (plan_ref, execution_ref, quality_ref) ): raise ValueError("component repair quality phase references are invalid") terminal = phase in {"ready_for_assembly", "preserved_with_warning"} expected_status = phase if terminal else "active" if state["status"] != expected_status: raise ValueError("component repair status and phase are inconsistent") if (phase == "ready_for_assembly") != (state["result_ref"] is not None): raise ValueError("component repair result reference is inconsistent") if state["plan_count"] > state["repair_round"]: raise ValueError("component repair plan count exceeds page rounds") history_rounds = [entry["round"] for entry in history] if history_rounds != sorted(set(history_rounds)) or any( value > state["repair_round"] for value in history_rounds ): raise ValueError("component repair round history order is invalid") if len(history) > state["plan_count"] or state["plan_count"] - len(history) > 1: raise ValueError("component repair plan count and history are inconsistent") fallback_status = fallback["status"] if phase == "fallback_required" and fallback_status != "required": raise ValueError("component repair fallback requirement is inconsistent") if phase in {"fallback_executed", "fallback_quality_recorded"} and fallback_status != "parent_pending": raise ValueError("component repair parent fallback phase is inconsistent") if phase == "ready_for_assembly" and fallback_status not in {"none", "parent_preserved"}: raise ValueError("component repair ready fallback is inconsistent") if phase == "preserved_with_warning" and fallback_status != "warning": raise ValueError("component repair warning fallback is inconsistent") fallback_phase = phase in { "fallback_required", "fallback_executed", "fallback_quality_recorded", "preserved_with_warning", } or fallback_status == "parent_preserved" resumed_progress_override = ( phase == "freeze_committed" and fallback_status == "none" and state["stop_reason"] in { "round_limit", "no_quality_improvement", } ) if (fallback_phase or resumed_progress_override) != ( state["stop_reason"] is not None ): raise ValueError("component repair fallback stop reason is inconsistent") if phase == "fallback_required" and any( state[name] is not None for name in ( "fallback_graph_ref", "fallback_quality_ref", "fallback_input_refs" ) ): raise ValueError("component repair fallback has premature references") if phase == "fallback_executed" and ( state["fallback_graph_ref"] != state["graph_ref"] or state["fallback_quality_ref"] is not None or state["fallback_input_refs"] is None ): raise ValueError("component repair fallback execution references are invalid") if phase == "fallback_quality_recorded" and ( state["fallback_graph_ref"] != state["graph_ref"] or state["fallback_quality_ref"] is None or state["fallback_input_refs"] is None ): raise ValueError("component repair fallback quality references are invalid") if phase == "preserved_with_warning" and state["result_ref"] is not None: raise ValueError("component repair warning cannot have a result reference") return state def _validate_quality_input_refs(value: object) -> dict: legacy_fields = { "background", "reconstructed", "text_mask", "native_check", "presentation_manifest", } if not isinstance(value, dict) or frozenset(value) not in { frozenset(legacy_fields), frozenset({*legacy_fields, "foreground_evidence"}), frozenset({ *legacy_fields, "foreground_evidence", "background_responsibility", }), }: raise ValueError("component quality input refs are invalid") for reference in value.values(): _validate_artifact_ref(reference, "quality input ref") return value def _validate_artifact_ref(value: object, field: str) -> dict: if not isinstance(value, dict) or set(value) != {"path", "sha256"}: raise ValueError(f"component repair {field} is invalid") path = value["path"] if type(path) is not str or not path or "\\" in path or ":" in path: raise ValueError(f"component repair {field} path is invalid") pure = PurePosixPath(path) if pure.is_absolute() or ".." in pure.parts: raise ValueError(f"component repair {field} path is invalid") _validate_sha256(value["sha256"], f"component repair {field} sha256") return value def validate_agent_provider(value: object) -> str: if type(value) is not str or value not in AGENT_PROVIDERS: raise ValueError("Invalid agent_provider; expected: host") return value def _validate_sha256(value: object, field: str) -> str: if ( type(value) is not str or len(value) != 64 or any(character not in "0123456789abcdef" for character in value) ): raise ValueError(f"{field} is invalid") return value def validate_repair_round(value: object) -> int: if type(value) is not int or not 1 <= value <= MAX_REPAIR_ROUNDS: raise ValueError( f"repair_round must be between 1 and {MAX_REPAIR_ROUNDS}" ) return value _COMPONENT_PLAN_FIELDS = frozenset( {"schema_version", "kind", "page_id", "provider", "repair_round", "request_sha256", "actions"} ) _COMPONENT_ACTION_FIELDS = frozenset( {"action", "object_ids", "parameters", "confidence", "evidence"} ) _ACTION_PARAMETERS = { "accept": frozenset(), "discard": frozenset(), "merge": frozenset(), "split": frozenset({"parts"}), "expand": frozenset({"margin_ratio"}), "shrink": frozenset({"margin_ratio"}), "retry_with_box": frozenset({"box"}), "retry_with_points": frozenset({"positive", "negative"}), "attach_text": frozenset(), "suppress_text": frozenset(), "collapse_to_parent": frozenset(), "rebuild_background": frozenset({"margin_ratio"}), "absorb_residual": frozenset(), "absorb_into_parent": frozenset(), } _OPTIONAL_ACTION_PARAMETERS = { "accept": frozenset({"independent", "preserve_mask"}), "retry_with_box": frozenset({"independent"}), "retry_with_points": frozenset({"independent"}), } _SINGLE_OBJECT_ACTIONS = frozenset( {"accept", "discard", "split", "expand", "shrink", "retry_with_box", "retry_with_points", "suppress_text", "collapse_to_parent", "absorb_residual"} ) def _validate_normalized_point(value: object, field: str) -> None: if ( not isinstance(value, list) or len(value) != 2 or any(type(item) not in {int, float} or not math.isfinite(item) or not 0 <= item <= 1 for item in value) ): raise ValueError(f"component action {field} coordinates are invalid") def validate_component_action(action: object, *, graph: dict | None = None) -> dict: object_ids = action.get("object_ids", []) if isinstance(action, dict) else [] if ( not isinstance(object_ids, list) or not object_ids or any(type(value) is not str for value in object_ids) ): raise ValueError("component action object_ids are invalid") validated_graph = validate_component_graph(graph) if graph is not None else None graph_nodes = validated_graph["nodes"] if validated_graph is not None else [] frozen_ids = sorted( node["id"] for node in graph_nodes if isinstance(node, dict) and node.get("state") == "frozen" ) if ( validated_graph is None and isinstance(action, dict) and action.get("action") in {"attach_text", "suppress_text"} and len(object_ids) == (2 if action.get("action") == "attach_text" else 1) ): frozen_ids = [object_ids[-1]] candidate_ids = sorted( (set(object_ids) | { node["id"] for node in graph_nodes if isinstance(node, dict) and isinstance(node.get("id"), str) }) - set(frozen_ids) ) request = { "schema_version": 1, "page_id": "action_validation", "provider": "host", "repair_round": 1, "source_sha256": "0" * 64, "graph_sha256": "0" * 64, "candidate_ids": candidate_ids, "frozen_ids": frozen_ids, "evidence": { name: {"path": name, "sha256": "0" * 64} for name in COMPONENT_EVIDENCE_NAMES }, "review_evidence": list(FULL_COMPONENT_REVIEW_EVIDENCE), } validate_component_plan( { "schema_version": 1, "kind": "component_plan", "page_id": "action_validation", "provider": "host", "repair_round": 1, "request_sha256": "0" * 64, "actions": [action], }, request=request, graph=validated_graph, ) return action def validate_component_plan(plan: object, *, request: dict, graph: dict | None = None) -> dict: validate_component_agent_request(request) if not isinstance(plan, dict) or set(plan) != _COMPONENT_PLAN_FIELDS: raise ValueError("component plan fields are invalid") if plan["schema_version"] != 1 or type(plan["schema_version"]) is not int: raise ValueError("component plan schema_version is invalid") if plan["kind"] != "component_plan": raise ValueError("component plan kind is invalid") for field in ("page_id", "provider", "repair_round"): if plan[field] != request[field]: raise ValueError(f"component plan {field} does not match current request") validate_agent_provider(plan["provider"]) validate_repair_round(plan["repair_round"]) _validate_sha256(plan["request_sha256"], "request_sha256") actions = plan["actions"] if not isinstance(actions, list): raise ValueError("component plan actions must be a list") known_ids = set(request["candidate_ids"]) | set(request["frozen_ids"]) collapsible_parent_ids = set() recoverable_parent_ids = set() recoverable_retry_ids = set() if graph is not None: candidate_ids = set(request["candidate_ids"]) collapsible_parent_ids = { node["parent_id"] for node in graph["nodes"] if node.get("id") in candidate_ids and node.get("parent_id") is not None } recoverable_parent_ids = { node["id"] for node in graph["nodes"] if node.get("kind") == "parent" and node.get("state") == "inactive" } recoverable_retry_ids = { node["id"] for node in graph["nodes"] if node.get("kind") != "text" and node.get("state") == "inactive" } touched = {} retried_ids = set() for action in actions: if not isinstance(action, dict) or set(action) != _COMPONENT_ACTION_FIELDS: raise ValueError("component action fields are invalid") name = action["action"] if type(name) is not str or name not in _ACTION_PARAMETERS: raise ValueError("component action is invalid") object_ids = action["object_ids"] if ( not isinstance(object_ids, list) or not object_ids or any(type(value) is not str for value in object_ids) or len(object_ids) != len(set(object_ids)) or any( value not in known_ids and not ( name in {"collapse_to_parent", "absorb_into_parent"} and value in collapsible_parent_ids ) and not ( name == "absorb_into_parent" and value == object_ids[0] and value in recoverable_parent_ids ) and not ( name in {"retry_with_box", "retry_with_points", "absorb_residual"} and value in recoverable_retry_ids ) and not ( name == "rebuild_background" and value in retried_ids ) for value in object_ids ) ): raise ValueError("component action object_ids are invalid") if ( (name in _SINGLE_OBJECT_ACTIONS and len(object_ids) != 1) or (name == "merge" and len(object_ids) < 2) or (name == "absorb_into_parent" and len(object_ids) < 2) or (name == "attach_text" and len(object_ids) != 2) ): raise ValueError("component action object count is invalid") if graph is not None: _validate_action_graph_roles(name, object_ids, graph) if name == "attach_text" and object_ids[1] not in request["frozen_ids"]: raise ValueError("attach_text requires a frozen text object") frozen_targets = set(object_ids) & set(request["frozen_ids"]) if frozen_targets and not ( ( name == "attach_text" and frozen_targets == {object_ids[1]} ) or ( name == "suppress_text" and frozen_targets == {object_ids[0]} ) or name == "rebuild_background" ): raise ValueError("component action object is frozen") if name != "rebuild_background": if any( value in touched and not (touched[value] == "accept" and name == "absorb_residual") for value in object_ids ): raise ValueError("component plan has conflicting object actions") touched.update({value: name for value in object_ids}) parameters = action["parameters"] optional_parameters = _OPTIONAL_ACTION_PARAMETERS.get(name, frozenset()) if ( not isinstance(parameters, dict) or not _ACTION_PARAMETERS[name] <= set(parameters) or not set(parameters) <= _ACTION_PARAMETERS[name] | optional_parameters ): raise ValueError("component action parameters are invalid") if "independent" in parameters and type(parameters["independent"]) is not bool: raise ValueError("component action independent parameter is invalid") if "preserve_mask" in parameters and type(parameters["preserve_mask"]) is not bool: raise ValueError("component action preserve_mask parameter is invalid") confidence = action["confidence"] if type(confidence) not in {int, float} or not math.isfinite(confidence) or not 0 <= confidence <= 1: raise ValueError("component action confidence is invalid") evidence = action["evidence"] if not isinstance(evidence, list) or not evidence or any(type(item) is not str or not item.strip() for item in evidence): raise ValueError("component action evidence is invalid") if name == "split" and (type(parameters["parts"]) is not int or parameters["parts"] < 2): raise ValueError("component action split parts are invalid") if name in {"expand", "shrink"} and ( type(parameters["margin_ratio"]) not in {int, float} or not math.isfinite(parameters["margin_ratio"]) or not 0 < parameters["margin_ratio"] <= 1 ): raise ValueError("component action margin_ratio is invalid") if name == "rebuild_background" and ( type(parameters["margin_ratio"]) not in {int, float} or not math.isfinite(parameters["margin_ratio"]) or not 0 < parameters["margin_ratio"] <= 0.1 ): raise ValueError("component action background margin_ratio is invalid") if name == "retry_with_box": box = parameters["box"] if not isinstance(box, list) or len(box) != 4: raise ValueError("component action box coordinates are invalid") _validate_normalized_point(box[:2], "box") _validate_normalized_point(box[2:], "box") if box[0] >= box[2] or box[1] >= box[3]: raise ValueError("component action box coordinates are invalid") if name == "retry_with_points": for field in ("positive", "negative"): points = parameters[field] if not isinstance(points, list): raise ValueError(f"component action {field} coordinates are invalid") for point in points: _validate_normalized_point(point, field) if ( len(parameters["positive"]) + len(parameters["negative"]) > MAX_COMPONENT_PROMPT_POINTS ): raise ValueError("component action has too many prompt points") if not parameters["positive"]: raise ValueError("component action positive coordinates are invalid") if name in {"retry_with_box", "retry_with_points"}: retried_ids.update(object_ids) elif name == "absorb_residual": retried_ids.update(set(object_ids) & recoverable_retry_ids) return plan def _validate_action_graph_roles(action: str, object_ids: list[str], graph: dict) -> None: if not isinstance(graph, dict) or not isinstance(graph.get("nodes"), list): raise ValueError("component plan graph is invalid") nodes = {node.get("id"): node for node in graph["nodes"] if isinstance(node, dict)} try: selected = [nodes[object_id] for object_id in object_ids] except KeyError as error: raise ValueError("component action object is missing from graph") from error if action == "attach_text": if selected[0].get("kind") == "text" or selected[1].get("kind") != "text": raise ValueError("attach_text requires visual then text roles") if selected[1].get("state") != "frozen": raise ValueError("attach_text requires a frozen text object") return if action == "suppress_text": if selected[0].get("kind") != "text" or selected[0].get("state") != "frozen": raise ValueError("suppress_text requires a frozen text object") return if action == "collapse_to_parent": if selected[0].get("kind") != "parent": raise ValueError("collapse_to_parent requires parent kind") return if action == "absorb_into_parent": if selected[0].get("kind") != "parent": raise ValueError("absorb_into_parent requires parent first") if any(node.get("kind") == "text" for node in selected[1:]): raise ValueError("absorb_into_parent cannot absorb text kind") if any(node.get("state") != "pending" for node in selected[1:]): raise ValueError("absorb_into_parent requires pending absorbed components") return if action == "rebuild_background": if any( node.get("kind") == "text" and node.get("state") != "frozen" for node in selected ): raise ValueError("rebuild_background requires frozen text objects") return if any(node.get("kind") == "text" for node in selected): raise ValueError("component action cannot target text kind") if action == "merge": kinds = {node.get("kind") for node in selected} if len(kinds) != 1: raise ValueError("merge requires the same component kind") if kinds == {"child"} and len({node.get("parent_id") for node in selected}) != 1: raise ValueError("merge child components must share one parent") def validate_component_agent_request(request: object) -> dict: if not isinstance(request, dict) or set(request) != _COMPONENT_AGENT_REQUEST_FIELDS: raise ValueError("component agent request fields are invalid") if type(request["schema_version"]) is not int or request["schema_version"] != 1: raise ValueError("component agent request schema_version is invalid") page_id = request["page_id"] if ( type(page_id) is not str or not page_id or "/" in page_id or "\\" in page_id or page_id in {".", ".."} ): raise ValueError("component agent request page_id is invalid") validate_agent_provider(request["provider"]) validate_repair_round(request["repair_round"]) _validate_sha256(request["source_sha256"], "source_sha256") _validate_sha256(request["graph_sha256"], "graph_sha256") for field in ("candidate_ids", "frozen_ids"): values = request[field] if ( not isinstance(values, list) or any(type(value) is not str or not value for value in values) or values != sorted(set(values)) ): raise ValueError(f"component agent request {field} is invalid") if set(request["candidate_ids"]) & set(request["frozen_ids"]): raise ValueError("candidate_ids and frozen_ids must be disjoint") evidence = request["evidence"] evidence_names = frozenset(evidence) if isinstance(evidence, dict) else frozenset() if evidence_names not in { LEGACY_COMPONENT_EVIDENCE_NAMES, COMPONENT_EVIDENCE_NAMES, LEGACY_COMPONENT_EVIDENCE_NAMES | {ROUND_REVIEW_EVIDENCE_NAME}, COMPONENT_EVIDENCE_NAMES | {ROUND_REVIEW_EVIDENCE_NAME}, }: raise ValueError("component agent request evidence fields are invalid") for name, record in evidence.items(): if not isinstance(record, dict) or set(record) != {"path", "sha256"}: raise ValueError(f"component evidence record is invalid: {name}") path = record["path"] if type(path) is not str or not path or "\\" in path or ":" in path: raise ValueError(f"component evidence path is invalid: {name}") pure_path = PurePosixPath(path) if ( pure_path.is_absolute() or ".." in pure_path.parts or pure_path != PurePosixPath(name) ): raise ValueError(f"component evidence path is invalid: {name}") _validate_sha256(record["sha256"], f"component evidence sha256: {name}") review_evidence = request["review_evidence"] if ( not isinstance(review_evidence, list) or any(type(name) is not str for name in review_evidence) or len(review_evidence) != len(set(review_evidence)) or any(name not in evidence for name in review_evidence) ): raise ValueError("component agent request review_evidence is invalid") canonical = [ name for name in (*FULL_COMPONENT_REVIEW_EVIDENCE, ROUND_REVIEW_EVIDENCE_NAME) if name in review_evidence ] full = [name for name in FULL_COMPONENT_REVIEW_EVIDENCE if name in evidence] if review_evidence != canonical: raise ValueError("component agent request review_evidence order is invalid") if request["repair_round"] == 1: if review_evidence != full or ROUND_REVIEW_EVIDENCE_NAME in evidence: raise ValueError("component agent request review_evidence is invalid") elif ROUND_REVIEW_EVIDENCE_NAME not in evidence: if review_evidence != full: raise ValueError("component agent request review_evidence fallback is invalid") else: required = { "source.png", "reconstructed.png", "difference.png", "quality-report.json", ROUND_REVIEW_EVIDENCE_NAME, } if not required <= set(review_evidence): raise ValueError("component agent request review_evidence is incomplete") return request def _validate_component_node(node: object) -> dict: if not isinstance(node, dict) or set(node) != _COMPONENT_NODE_FIELDS: raise ValueError("component node fields are invalid") component_id = node["id"] if type(component_id) is not str or not component_id.strip(): raise ValueError("component id must be a non-empty string") if type(node["kind"]) is not str or node["kind"] not in COMPONENT_KINDS: raise ValueError("component kind is invalid") if type(node["state"]) is not str or node["state"] not in COMPONENT_STATES: raise ValueError("component state is invalid") parent_id = node["parent_id"] if parent_id is not None and ( type(parent_id) is not str or not parent_id.strip() ): raise ValueError("component parent_id is invalid") mask = node["mask"] if type(mask) is not str or not mask or "\\" in mask or ":" in mask: raise ValueError("component mask path is invalid") mask_path = PurePosixPath(mask) if ( mask_path.is_absolute() or ".." in mask_path.parts or not mask_path.parts or mask_path.parts[0] != "masks" ): raise ValueError("component mask path is invalid") digest = node["mask_sha256"] if ( type(digest) is not str or len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest) ): raise ValueError("component mask_sha256 is invalid") bbox = node["bbox"] if ( not isinstance(bbox, list) or len(bbox) != 4 or not all(type(value) is int for value in bbox) or min(bbox) < 0 or bbox[0] >= bbox[2] or bbox[1] >= bbox[3] ): raise ValueError("component bbox is invalid") if type(node["z_index"]) is not int or node["z_index"] < 0: raise ValueError("component z_index is invalid") text_ids = node["text_ids"] if ( not isinstance(text_ids, list) or any(type(text_id) is not str or not text_id for text_id in text_ids) or len(text_ids) != len(set(text_ids)) ): raise ValueError("component text_ids are invalid") return node def is_render_active_component(node: object) -> bool: validated = _validate_component_node(node) return ( validated["kind"] != "text" and validated["state"] in _RENDER_STATES ) def validate_component_graph(graph: object) -> dict: if not isinstance(graph, dict) or set(graph) != {"nodes"}: raise ValueError("component graph fields are invalid") if not isinstance(graph["nodes"], list): raise ValueError("component graph nodes must be a list") nodes = [_validate_component_node(node) for node in graph["nodes"]] by_id = {node["id"]: node for node in nodes} if len(by_id) != len(nodes): raise ValueError("component ids must be unique") if len({node["mask"] for node in nodes}) != len(nodes): raise ValueError("component mask paths must be unique") for node in nodes: parent_id = node["parent_id"] if node["kind"] in {"parent", "text"} and parent_id is not None: raise ValueError(f"{node['kind']} component cannot have a parent") if node["kind"] == "child": if parent_id is None or parent_id not in by_id: raise ValueError("child component parent is missing") if by_id[parent_id]["kind"] == "text": raise ValueError("child component parent cannot be text") for text_id in node["text_ids"]: if text_id not in by_id or by_id[text_id]["kind"] != "text": raise ValueError("component text ownership references unknown text") if node["state"] == "frozen" and by_id[text_id]["state"] != "frozen": raise ValueError("frozen component requires frozen linked text") for node in nodes: ancestors = set() parent_id = node["parent_id"] while parent_id is not None: if parent_id in ancestors: raise ValueError("component graph contains a parent cycle") ancestors.add(parent_id) parent = by_id[parent_id] if is_render_active_component(node) and parent["state"] != "inactive": raise ValueError("parent and child cannot render together") if is_render_active_component(parent) and node["state"] != "inactive": raise ValueError("parent and child cannot render together") parent_id = parent["parent_id"] text_owners: dict[str, str] = {} active_z_indexes = set() for node in nodes: if not is_render_active_component(node): continue if node["z_index"] in active_z_indexes: raise ValueError("active component z_index values must be unique") active_z_indexes.add(node["z_index"]) for text_id in node["text_ids"]: previous = text_owners.setdefault(text_id, node["id"]) if previous != node["id"]: raise ValueError("text component has multiple active owners") return graph def validate_graph_transition( *, before: object, after: object, allowed_suppressed_text_ids: set[str] | frozenset[str] | None = None, allowed_reactivated_ids: set[str] | frozenset[str] | None = None, ) -> dict: before_graph = validate_component_graph(before) allowed = ( frozenset() if allowed_suppressed_text_ids is None else frozenset(allowed_suppressed_text_ids) ) reactivated = ( frozenset() if allowed_reactivated_ids is None else frozenset(allowed_reactivated_ids) ) if ( allowed_suppressed_text_ids is not None and not isinstance(allowed_suppressed_text_ids, (set, frozenset)) ) or any(type(value) is not str or not value for value in allowed): raise ValueError("suppressed text authorization is invalid") if ( allowed_reactivated_ids is not None and not isinstance(allowed_reactivated_ids, (set, frozenset)) ) or any(type(value) is not str or not value for value in reactivated): raise ValueError("component reactivation authorization is invalid") before_nodes = {node["id"]: node for node in before_graph["nodes"]} if any( component_id not in before_nodes or before_nodes[component_id]["kind"] != "text" or before_nodes[component_id]["state"] != "frozen" for component_id in allowed ): raise ValueError("suppressed text authorization is invalid") if any( component_id not in before_nodes or before_nodes[component_id]["kind"] == "text" or before_nodes[component_id]["state"] not in {"inactive", "frozen"} for component_id in reactivated ): raise ValueError("component reactivation authorization is invalid") if not isinstance(after, dict) or set(after) != {"nodes"}: raise ValueError("component graph fields are invalid") if not isinstance(after["nodes"], list): raise ValueError("component graph nodes must be a list") after_nodes = { node["id"]: node for node in after["nodes"] if isinstance(node, dict) and type(node.get("id")) is str } actual_reactivated = { component_id for component_id, node in before_nodes.items() if node["state"] in {"inactive", "frozen"} and after_nodes.get(component_id, {}).get("state") == "pending" } if actual_reactivated != set(reactivated): if any( before_nodes[component_id]["state"] == "frozen" for component_id in actual_reactivated - set(reactivated) ): raise ValueError("frozen component reactivation is not authorized") raise ValueError("inactive component reactivation is not authorized") if any( after_nodes[component_id].get("state") != "pending" for component_id in actual_reactivated ): raise ValueError("inactive component reactivation is not authorized") for node in before_graph["nodes"]: if node["state"] != "frozen": continue replacement = after_nodes.get(node["id"]) fields = _FROZEN_FIELDS if node["id"] in reactivated: fields = tuple(field for field in fields if field != "state") valid = replacement is not None and replacement.get("state") == "pending" elif node["id"] in allowed: fields = tuple(field for field in fields if field != "state") valid = replacement is not None and replacement.get("state") == "inactive" elif node["kind"] != "text" and set(node["text_ids"]) & allowed: fields = tuple(field for field in fields if field != "text_ids") valid = replacement is not None and replacement.get("text_ids") == [ text_id for text_id in node["text_ids"] if text_id not in allowed ] else: valid = replacement is not None if not valid or any( replacement.get(field) != node[field] for field in fields ): raise ValueError(f"frozen component {node['id']} cannot change") return validate_component_graph(after) -
component_quality.py 53.1 KB
from __future__ import annotations from collections.abc import Iterable from dataclasses import dataclass import cv2 import numpy as np @dataclass(frozen=True) class PageCalibration: noise_l1: float local_contrast: float edge_width_px: int text_halo_px: int min_component_pixels: int @dataclass(frozen=True) class _PageQualityContext: source_rgb: np.ndarray background_rgb: np.ndarray reconstructed_rgb: np.ndarray reconstruction_delta: np.ndarray background_delta: np.ndarray source_luma: np.ndarray text: np.ndarray text_ink: np.ndarray text_ink_neighborhood: np.ndarray background_residual_text_ink: np.ndarray reconstructed_residual_text_ink: np.ndarray text_labels: np.ndarray reconstructed_residual_region_counts: np.ndarray background_text_residual_ratio: float exterior_owner_count: np.ndarray component_owner_count: np.ndarray @dataclass(frozen=True) class _AbsorbedMaskSummary: bbox: tuple[int, int, int, int] crop: np.ndarray area: int def resolve_visual_mask_ownership( nodes: list[dict], masks: list[np.ndarray] ) -> list[np.ndarray]: if len(nodes) != len(masks): raise ValueError("visual ownership node and mask counts differ") if not masks: return [] owned = [np.asarray(mask, dtype=bool).copy() for mask in masks] shape = owned[0].shape if any(mask.shape != shape for mask in owned): raise ValueError("visual ownership mask dimensions differ") claimed = np.zeros(shape, dtype=bool) order = sorted( range(len(owned)), key=lambda index: ( int(nodes[index]["z_index"]), -int(np.count_nonzero(owned[index])), -index, ), reverse=True, ) for index in order: owned[index] &= ~claimed claimed |= owned[index] return owned def contained_active_parent_pairs( nodes: list[dict], masks: list[np.ndarray] ) -> set[tuple[str, str]]: if len(nodes) != len(masks): raise ValueError("contained parent node and mask counts differ") if not masks: return set() prepared = [np.asarray(mask, dtype=bool) for mask in masks] shape = prepared[0].shape if any(mask.shape != shape for mask in prepared): raise ValueError("contained parent mask dimensions differ") parents = [ (index, int(np.count_nonzero(prepared[index]))) for index, node in enumerate(nodes) if node.get("kind") == "parent" and np.any(prepared[index]) ] pairs = set() for left in range(len(parents)): left_index, left_area = parents[left] for right in range(left + 1, len(parents)): right_index, right_area = parents[right] overlap = int(np.count_nonzero( prepared[left_index] & prepared[right_index] )) smaller_area = min(left_area, right_area) if overlap / smaller_area < 0.95: continue pairs.add(tuple(sorted(( nodes[left_index]["id"], nodes[right_index]["id"] )))) return pairs _CHECK_STATES = frozenset({"pass", "fail", "unknown"}) _UNDERLAY_METRIC_FIELDS = frozenset({ "boundary_color_mae", "gradient_jump_p95", "added_high_frequency_pixels", }) _METRIC_FIELDS = frozenset({ "component_pixels", "missing_pixels", "missing_ratio", "duplicate_pixels", "duplicate_ratio", "edge_missing_ratio", "shadow_duplicate_ratio", "alpha_duplicate_ratio", "exterior_shadow_pixels", "exterior_alpha_pixels", "orphan_residual_pixels", "text_support_pixels", "text_duplicate_ratio", "component_text_residual_ratio", "background_text_residual_ratio", "parent_coverage_ratio", "component_overlap_pixels", "ownership_out_of_bounds_pixels", "parent_child_double", "noise_l1", "local_contrast", "edge_width_px", "text_halo_px", "adaptive_pixel_tolerance", "hard_pixel_tolerance", "generated_underlay_pixels", "underlay_out_of_bounds_pixels", "underlay_boundary_color_mae", "underlay_gradient_jump_p95", "underlay_added_high_frequency_pixels", }) def validate_component_quality_report( report: object, *, expected_component_ids: list[str], initial_component_count: int, active_visual_count: int, ) -> dict: if not isinstance(report, dict) or set(report) != { "accepted", "violations", "component_reports", "visual_metrics", "checks" }: raise ValueError("component quality report fields are invalid") for component in report["component_reports"]: component_fields = { "component_id", "accepted", "metrics", "improvement", "violations", "checks", "agent_confidence", } if ( not isinstance(component, dict) or not component_fields <= set(component) or set(component) - component_fields != ( {"overlap_component_ids"} if "overlap_component_ids" in component else set() ) ): raise ValueError("component quality report entry fields are invalid") overlap_component_ids = component.get("overlap_component_ids", []) if ( not isinstance(overlap_component_ids, list) or overlap_component_ids != sorted(set(overlap_component_ids)) or any(type(value) is not str for value in overlap_component_ids) or component["component_id"] in overlap_component_ids ): raise ValueError("component quality overlap component IDs are invalid") metrics = component["metrics"] if not isinstance(metrics, dict) or set(metrics) != _METRIC_FIELDS: raise ValueError("component quality metrics fields are invalid") for name, value in metrics.items(): if name == "parent_child_double": if type(value) is not bool: raise ValueError("component quality metric type is invalid") elif type(value) not in {int, float} or not np.isfinite(value) or value < 0: raise ValueError("component quality metric value is invalid") improvement = component["improvement"] if not isinstance(improvement, dict) or any( key not in _METRIC_FIELDS or type(value) not in {int, float} or not np.isfinite(value) for key, value in improvement.items() ): raise ValueError("component quality improvement is invalid") if component["checks"].get("protected_native_overlap") not in _CHECK_STATES: raise ValueError("component quality native check is invalid") confidence = component["agent_confidence"] if confidence is not None and ( type(confidence) not in {int, float} or not np.isfinite(confidence) or not 0 <= confidence <= 1 ): raise ValueError("component quality confidence is invalid") if component["accepted"] != (not component["violations"]): raise ValueError("component quality accepted state is inconsistent") rebuilt = evaluate_page_quality( report["component_reports"], visual_metrics=report["visual_metrics"], page_checks=report["checks"], expected_component_ids=expected_component_ids, initial_component_count=initial_component_count, active_visual_count=active_visual_count, ) if rebuilt != report: raise ValueError("component quality page report is inconsistent") return report def calibrate_page(source: np.ndarray, text_mask: np.ndarray) -> PageCalibration: image = np.asarray(source) if image.ndim != 3 or image.shape[2] != 3 or image.dtype.kind not in "buif": raise ValueError("source must be an RGB numeric image") text = _exact_mask(text_mask, image.shape[:2], "text mask") rgb = np.clip(image, 0, 255).astype(np.uint8) gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) median = cv2.medianBlur(gray, 3) noise_l1 = float(np.median(np.abs(gray.astype(np.float32) - median.astype(np.float32)))) lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB).astype(np.float32) local_mean = cv2.blur(lab, (9, 9)) local_contrast = float(np.median(np.linalg.norm(lab - local_mean, axis=2))) if np.any(text): distance = cv2.distanceTransform(text.astype(np.uint8), cv2.DIST_L2, 5) text_halo_px = max(1, int(round(float(np.percentile(distance[text], 50))))) else: text_halo_px = 1 edge_width_px = max( 1, text_halo_px, int(round(max(noise_l1, 1.0) ** 0.5)), ) min_component_pixels = max(1, int(round(image.shape[0] * image.shape[1] * 1e-5))) return PageCalibration(noise_l1, local_contrast, edge_width_px, text_halo_px, min_component_pixels) def absorbed_leaf_cluster_count( masks: Iterable[np.ndarray], calibration: PageCalibration ) -> int: """Count independently movable entities among masks absorbed into a parent.""" if not isinstance(calibration, PageCalibration): raise ValueError("calibration must be PageCalibration") summaries = [] shape = None for mask in masks: array = _numeric_mask(mask, "absorbed mask") if shape is None: shape = array.shape elif array.shape != shape: raise ValueError("absorbed masks must share one page shape") support = array if array.dtype == np.bool_ else array > 0 area = int(np.count_nonzero(support)) if area < calibration.min_component_pixels: continue ys, xs = np.nonzero(support) y1, y2 = int(ys.min()), int(ys.max()) + 1 x1, x2 = int(xs.min()), int(xs.max()) + 1 summaries.append(_AbsorbedMaskSummary( (y1, x1, y2, x2), support[y1:y2, x1:x2].copy(), area )) parents = list(range(len(summaries))) def find(index: int) -> int: while parents[index] != index: parents[index] = parents[parents[index]] index = parents[index] return index for left in range(len(summaries)): for right in range(left + 1, len(summaries)): if _same_absorbed_entity( summaries[left], summaries[right], calibration ): left_root = find(left) right_root = find(right) parents[right_root] = left_root gap_candidates = [] for left in range(len(summaries)): for right in range(left + 1, len(summaries)): if not _gap_fragment_pair(summaries[left], summaries[right], calibration): continue fragment, primary = ( (left, right) if summaries[left].area < summaries[right].area else (right, left) ) gap_candidates.append((fragment, primary)) attached_fragments = set() for fragment, primary in sorted( gap_candidates, key=lambda pair: summaries[pair[1]].area, reverse=True ): if fragment in attached_fragments or primary in attached_fragments: continue fragment_root = find(fragment) primary_root = find(primary) if fragment_root != primary_root: parents[fragment_root] = primary_root attached_fragments.add(fragment) return len({find(index) for index in range(len(summaries))}) def _same_absorbed_entity( left: _AbsorbedMaskSummary, right: _AbsorbedMaskSummary, calibration: PageCalibration, ) -> bool: ly1, lx1, ly2, lx2 = left.bbox ry1, rx1, ry2, rx2 = right.bbox iy1, ix1 = max(ly1, ry1), max(lx1, rx1) iy2, ix2 = min(ly2, ry2), min(lx2, rx2) intersection = 0 if iy1 < iy2 and ix1 < ix2: left_crop = left.crop[iy1 - ly1:iy2 - ly1, ix1 - lx1:ix2 - lx1] right_crop = right.crop[iy1 - ry1:iy2 - ry1, ix1 - rx1:ix2 - rx1] intersection = int(np.count_nonzero(left_crop & right_crop)) left_cover = intersection / left.area right_cover = intersection / right.area if left_cover >= 0.8 and right_cover >= 0.8: return True smaller, larger = sorted((left.area, right.area)) if max(left_cover, right_cover) >= 0.95 and smaller / larger >= 0.5: return True radius = max(calibration.edge_width_px, calibration.text_halo_px) left_center = ((ly1 + ly2) / 2, (lx1 + lx2) / 2) right_center = ((ry1 + ry2) / 2, (rx1 + rx2) / 2) similar_scale = smaller / larger >= 0.67 if ( similar_scale and intersection / smaller >= 0.4 and abs(left_center[0] - right_center[0]) <= max(radius * 3, max(ly2 - ly1, ry2 - ry1) * 0.5) and abs(left_center[1] - right_center[1]) <= max(radius * 3, max(lx2 - lx1, rx2 - rx1) * 0.5) ): return True return False def _gap_fragment_pair( left: _AbsorbedMaskSummary, right: _AbsorbedMaskSummary, calibration: PageCalibration, ) -> bool: smaller, larger = ( (left, right) if left.area < right.area else (right, left) ) if smaller.area / larger.area > 0.35: return False sy1, sx1, sy2, sx2 = smaller.bbox ly1, lx1, ly2, lx2 = larger.bbox height, width = sy2 - sy1, sx2 - sx1 if min(height, width) / max(height, width) > 0.4: return False radius = max(calibration.edge_width_px, calibration.text_halo_px) horizontal_gap = max(sx1 - lx2, lx1 - sx2, 0) vertical_gap = max(sy1 - ly2, ly1 - sy2, 0) vertical_overlap = max(0, min(sy2, ly2) - max(sy1, ly1)) horizontal_overlap = max(0, min(sx2, lx2) - max(sx1, lx1)) return ( 0 < horizontal_gap <= radius and vertical_overlap / height >= 0.5 ) or ( 0 < vertical_gap <= radius and horizontal_overlap / width >= 0.5 ) def _check_state(checks: dict | None, name: str) -> str: if checks is None or name not in checks: return "unknown" state = checks[name] if state not in _CHECK_STATES: raise ValueError(f"{name} check state is invalid") return state def _ratio(numerator: np.ndarray, denominator: int) -> float: return float(np.count_nonzero(numerator)) / max(denominator, 1) def _largest_region(mask: np.ndarray) -> tuple[int, np.ndarray]: count, labels, stats, _ = cv2.connectedComponentsWithStats( np.asarray(mask, dtype=np.uint8), 8 ) if count <= 1: return 0, np.zeros(mask.shape, dtype=bool) label = max( range(1, count), key=lambda value: int(stats[value, cv2.CC_STAT_AREA]), ) return int(stats[label, cv2.CC_STAT_AREA]), labels == label def _largest_text_region_pixels(residual: np.ndarray, text: np.ndarray) -> int: count, labels = cv2.connectedComponents( np.asarray(text, dtype=np.uint8), 8 ) if count <= 1 or not np.any(residual): return 0 pixels = np.bincount(labels[np.asarray(residual, dtype=bool)], minlength=count) return int(np.max(pixels[1:], initial=0)) def _text_region_labels( text: np.ndarray, text_items: list[dict] | None ) -> tuple[int, np.ndarray]: if not text_items: return cv2.connectedComponents(text.astype(np.uint8), 8) labels = np.zeros(text.shape, dtype=np.int32) next_label = 1 height, width = text.shape for item in text_items: box = item.get("box") if isinstance(item, dict) else None if not isinstance(box, (list, tuple)) or len(box) != 4: continue x, y, box_width, box_height = (int(value) for value in box) x1, y1 = max(0, x), max(0, y) x2, y2 = min(width, x + box_width), min(height, y + box_height) if x1 >= x2 or y1 >= y2: continue local_labels = labels[y1:y2, x1:x2] unassigned = text[y1:y2, x1:x2] & (local_labels == 0) if not np.any(unassigned): continue local_labels[unassigned] = next_label next_label += 1 return next_label, labels def _rgb_image(value: object, shape: tuple[int, int] | None, label: str) -> np.ndarray: image = np.asarray(value) if ( image.ndim != 3 or image.shape[2] != 3 or image.dtype.kind not in "buif" or (image.dtype.kind == "f" and not np.all(np.isfinite(image))) ): raise ValueError(f"{label} must be a finite RGB numeric image") if shape is not None and image.shape[:2] != shape: raise ValueError(f"{label} shape must match source") return np.clip(image, 0, 255).astype(np.uint8) def _prepare_page_quality_context( source: np.ndarray, background: np.ndarray, reconstructed: np.ndarray, text_mask: np.ndarray, *, calibration: PageCalibration, component_masks: list[np.ndarray] | None = None, text_items: list[dict] | None = None, ) -> _PageQualityContext: source_rgb = _rgb_image(source, None, "source") shape = source_rgb.shape[:2] background_rgb = _rgb_image(background, shape, "background") reconstructed_rgb = _rgb_image(reconstructed, shape, "reconstructed") reconstruction_delta = np.max( np.abs(source_rgb.astype(np.int16) - reconstructed_rgb.astype(np.int16)), axis=2 ) background_delta = np.max( np.abs(source_rgb.astype(np.int16) - background_rgb.astype(np.int16)), axis=2 ) text = _exact_mask(text_mask, shape, "text mask") text_ink = _text_ink_mask(source_rgb, text, calibration) alignment_radius = max( 1, (max(calibration.text_halo_px, calibration.edge_width_px) + 1) // 2, ) text_ink_neighborhood = cv2.dilate( text_ink.astype(np.uint8), np.ones((2 * alignment_radius + 1,) * 2, dtype=np.uint8), ) > 0 background_residual_text_ink = _residual_text_ink_mask( background_rgb, text_ink, text_ink_neighborhood, calibration ) reconstructed_residual_text_ink = _residual_text_ink_mask( reconstructed_rgb, text_ink, text_ink_neighborhood, calibration ) text_count, text_labels = _text_region_labels(text, text_items) reconstructed_residual_region_counts = np.bincount( text_labels[reconstructed_residual_text_ink], minlength=text_count ) if text_count: reconstructed_residual_region_counts[0] = 0 exterior_owner_count = np.zeros(shape, dtype=np.uint16) component_owner_count = np.zeros(shape, dtype=np.uint16) boundary_kernel = np.ones((3, 3), dtype=np.uint8) for mask in component_masks or []: support, _ = _project_component_mask(mask, shape) component_owner_count += support.astype(np.uint16) adjacent = cv2.dilate(support.astype(np.uint8), boundary_kernel) > 0 adjacent &= ~support exterior_owner_count += adjacent.astype(np.uint16) background_text_residual = ( background_residual_text_ink & (component_owner_count == 0) ) background_residual_pixels = _largest_text_region_pixels( background_text_residual, text ) return _PageQualityContext( source_rgb=source_rgb, background_rgb=background_rgb, reconstructed_rgb=reconstructed_rgb, reconstruction_delta=reconstruction_delta, background_delta=background_delta, source_luma=cv2.cvtColor(source_rgb, cv2.COLOR_RGB2GRAY).astype(np.float32), text=text, text_ink=text_ink, text_ink_neighborhood=text_ink_neighborhood, background_residual_text_ink=background_residual_text_ink, reconstructed_residual_text_ink=reconstructed_residual_text_ink, text_labels=text_labels, reconstructed_residual_region_counts=reconstructed_residual_region_counts, background_text_residual_ratio=( background_residual_pixels / max(int(np.count_nonzero(text_ink)), 1) ), exterior_owner_count=exterior_owner_count, component_owner_count=component_owner_count, ) def _text_ink_mask( source_rgb: np.ndarray, text: np.ndarray, calibration: PageCalibration, ) -> np.ndarray: shape = text.shape text_radius = max(calibration.text_halo_px, calibration.edge_width_px) text_kernel = np.ones((2 * text_radius + 1, 2 * text_radius + 1), dtype=np.uint8) text_count, text_labels = cv2.connectedComponents(text.astype(np.uint8), 8) ink_threshold = max(12.0, calibration.noise_l1 * 4.0) dense_core = cv2.distanceTransform( text.astype(np.uint8), cv2.DIST_L2, 5 ) >= 3.0 dense_text = ( cv2.dilate(dense_core.astype(np.uint8), np.ones((5, 5), dtype=np.uint8)) > 0 ) & text local_kernel_size = min(31, 2 * text_radius + 1) local_delta = np.zeros(shape, dtype=np.uint8) for channel in range(3): local_background = cv2.medianBlur( source_rgb[:, :, channel], local_kernel_size ) local_delta = np.maximum( local_delta, cv2.absdiff(source_rgb[:, :, channel], local_background), ) local_ink = local_delta > ink_threshold structural_line = np.zeros(shape, dtype=bool) line_count, line_labels, line_stats, _ = cv2.connectedComponentsWithStats( local_ink.astype(np.uint8), 8 ) for line_label in range(1, line_count): width = int(line_stats[line_label, cv2.CC_STAT_WIDTH]) height = int(line_stats[line_label, cv2.CC_STAT_HEIGHT]) if width < max(8, height * 6) and height < max(8, width * 6): continue component = line_labels == line_label if np.any(component & text) and np.any(component & ~text): structural_line |= component text_ink = np.zeros(shape, dtype=bool) for label in range(1, text_count): region = text_labels == label ring = cv2.dilate(region.astype(np.uint8), text_kernel) > 0 ring &= ~region ys, xs = np.where(region) y1, y2 = int(ys.min()), int(ys.max()) + 1 x1, x2 = int(xs.min()), int(xs.max()) + 1 samples = source_rgb[ring] if not len(samples): samples = source_rgb[region] local_fill = np.median(samples.astype(np.float32), axis=0) local_region = region[y1:y2, x1:x2] candidate = np.zeros(local_region.shape, dtype=np.uint8) candidate[local_region] = ( np.max( np.abs(source_rgb[region].astype(np.float32) - local_fill), axis=1, ) > ink_threshold ) dense_region = local_region & dense_text[y1:y2, x1:x2] candidate[dense_region] &= local_ink[y1:y2, x1:x2][dense_region] candidate[structural_line[y1:y2, x1:x2] & local_region] = 0 count, labels, stats, _ = cv2.connectedComponentsWithStats(candidate, 8) for component_label in range(1, count): component = labels == component_label component_width = int(stats[component_label, cv2.CC_STAT_WIDTH]) component_height = int(stats[component_label, cv2.CC_STAT_HEIGHT]) crosses_vertically = ( np.any(component[0]) and np.any(component[-1]) and component_width <= max(3, round(candidate.shape[1] * 0.2)) ) crosses_horizontally = ( np.any(component[:, 0]) and np.any(component[:, -1]) and component_height <= max(3, round(candidate.shape[0] * 0.2)) ) if crosses_vertically or crosses_horizontally: candidate[component] = 0 text_ink[y1:y2, x1:x2] |= candidate > 0 return text_ink def _residual_text_ink_mask( image: np.ndarray, text: np.ndarray, text_ink_neighborhood: np.ndarray, calibration: PageCalibration, ) -> np.ndarray: text_radius = max(calibration.text_halo_px, calibration.edge_width_px) kernel_size = min(31, 2 * text_radius + 1) local_delta = np.zeros(text.shape, dtype=np.uint8) for channel in range(3): local_fill = cv2.medianBlur(image[:, :, channel], kernel_size) local_delta = np.maximum( local_delta, cv2.absdiff(image[:, :, channel], local_fill) ) threshold = max(2.0, calibration.noise_l1 * 2.0) gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) edge_strength = cv2.morphologyEx( gray, cv2.MORPH_GRADIENT, np.ones((3, 3), dtype=np.uint8) ) edge_threshold = max(1.0, calibration.noise_l1) return ( text & text_ink_neighborhood & (local_delta > threshold) & (edge_strength > edge_threshold) ) def component_metrics( source: np.ndarray, background: np.ndarray, reconstructed: np.ndarray, node: dict, graph: dict, calibration: PageCalibration, *, component_mask: np.ndarray, parent_mask: np.ndarray | None = None, text_mask: np.ndarray, _page_context: _PageQualityContext | None = None, ) -> dict: context = _page_context or _prepare_page_quality_context( source, background, reconstructed, text_mask, calibration=calibration, ) source_rgb = context.source_rgb shape = source_rgb.shape[:2] full_support, outside = _project_component_mask(component_mask, shape) support = full_support.copy() support &= ~context.text support_pixels = int(np.count_nonzero(support)) parent_coverage_ratio = 1.0 if parent_mask is not None: parent_support, _ = _project_component_mask(parent_mask, shape) parent_support &= ~context.text child_support = support & ~context.text parent_pixels = int(np.count_nonzero(parent_support)) if parent_pixels: parent_coverage_ratio = float( np.count_nonzero(child_support & parent_support) / parent_pixels ) adaptive_tolerance = max( 3.0, calibration.noise_l1 * 4.0 + calibration.local_contrast * 0.08, ) hard_tolerance = 3.0 reconstruction_delta = context.reconstruction_delta background_delta = context.background_delta missing = support & (reconstruction_delta > hard_tolerance) duplicate = support & (background_delta <= hard_tolerance) missing_pixels, missing_region = _largest_region(missing) radius = calibration.edge_width_px edge_kernel = np.ones((2 * radius + 1, 2 * radius + 1), dtype=np.uint8) edge = cv2.morphologyEx(support.astype(np.uint8), cv2.MORPH_GRADIENT, edge_kernel) > 0 far_radius = max(4, radius * 4) far_kernel = np.ones((2 * far_radius + 1, 2 * far_radius + 1), dtype=np.uint8) far = cv2.dilate(support.astype(np.uint8), far_kernel) > 0 far &= ~support baseline_pixels = context.background_rgb[far] if baseline_pixels.size == 0: baseline_pixels = context.background_rgb.reshape(-1, 3) baseline = np.median(baseline_pixels.astype(np.float32), axis=0) source_luma = context.source_luma baseline_luma = float(cv2.cvtColor( np.asarray([[baseline]], dtype=np.uint8), cv2.COLOR_RGB2GRAY )[0, 0]) if support_pixels >= max(20, calibration.min_component_pixels): duplicate &= np.abs(source_luma - baseline_luma) > 6.0 duplicate_pixels, _ = _largest_region(duplicate) largest_inner_shadow, _ = _largest_region( duplicate & (source_luma < baseline_luma - 6.0) ) largest_inner_alpha, _ = _largest_region( duplicate & edge & (source_luma >= baseline_luma - 3.0) ) adjacent = cv2.dilate(support.astype(np.uint8), np.ones((3, 3), dtype=np.uint8)) > 0 adjacent &= ~support exterior_duplicate = adjacent & (background_delta <= hard_tolerance) exterior_duplicate &= reconstruction_delta <= hard_tolerance exterior_changed = np.abs(source_luma - baseline_luma) > 6.0 unique_exterior = exterior_duplicate & exterior_changed & ( context.exterior_owner_count == 1 ) ambiguous_exterior = exterior_duplicate & exterior_changed & ( context.exterior_owner_count > 1 ) exterior_shadow, _ = _largest_region( unique_exterior & (source_luma < baseline_luma - 6.0) ) exterior_alpha, _ = _largest_region( unique_exterior & (source_luma >= baseline_luma - 3.0) ) largest_shadow = max(largest_inner_shadow, exterior_shadow) largest_alpha = max(largest_inner_alpha, exterior_alpha) text_radius = max(calibration.text_halo_px, calibration.edge_width_px) text_kernel = np.ones((2 * text_radius + 1, 2 * text_radius + 1), dtype=np.uint8) text_support = context.text & ( cv2.dilate(support.astype(np.uint8), text_kernel) > 0 ) text_ghost = ( text_support & context.text_ink & (background_delta > hard_tolerance) & (reconstruction_delta <= hard_tolerance) ) component_text_residual = ( full_support & context.reconstructed_residual_text_ink ) touched_text_labels = np.unique(context.text_labels[component_text_residual]) touched_text_labels = touched_text_labels[touched_text_labels > 0] component_text_residual_pixels = ( int(np.max(context.reconstructed_residual_region_counts[touched_text_labels])) if len(touched_text_labels) else 0 ) background_text_residual = ( text_support & context.background_residual_text_ink ) background_text_residual_pixels = _largest_text_region_pixels( background_text_residual, text_support ) active_states = {"pending", "pending_gate", "frozen"} nodes = {value.get("id"): value for value in graph.get("nodes", []) if isinstance(value, dict)} parent_child_double = any( value.get("parent_id") == node.get("id") and value.get("state") in active_states and node.get("state") in active_states for value in nodes.values() ) or ( node.get("parent_id") in nodes and node.get("state") in active_states and nodes[node["parent_id"]].get("state") in active_states ) return { "component_pixels": support_pixels, "missing_pixels": missing_pixels, "missing_ratio": missing_pixels / max(support_pixels, 1), "duplicate_pixels": duplicate_pixels, "duplicate_ratio": duplicate_pixels / max(support_pixels, 1), "edge_missing_ratio": _ratio(missing_region & edge, int(np.count_nonzero(edge))), "shadow_duplicate_ratio": largest_shadow / max(support_pixels, 1), "alpha_duplicate_ratio": largest_alpha / max(int(np.count_nonzero(edge)), 1), "exterior_shadow_pixels": exterior_shadow, "exterior_alpha_pixels": exterior_alpha, "orphan_residual_pixels": int(np.count_nonzero(ambiguous_exterior)), "text_support_pixels": int(np.count_nonzero(text_support)), "text_duplicate_ratio": _ratio(text_ghost, int(np.count_nonzero(text_support))), "component_text_residual_ratio": ( component_text_residual_pixels / max(int(np.count_nonzero(text_support)), 1) ), "background_text_residual_ratio": ( background_text_residual_pixels / max(int(np.count_nonzero(text_support)), 1) ), "parent_coverage_ratio": parent_coverage_ratio, "component_overlap_pixels": int(np.count_nonzero( support & (context.component_owner_count > 1) )), "ownership_out_of_bounds_pixels": outside, "parent_child_double": parent_child_double, "noise_l1": calibration.noise_l1, "local_contrast": calibration.local_contrast, "edge_width_px": calibration.edge_width_px, "text_halo_px": calibration.text_halo_px, "adaptive_pixel_tolerance": adaptive_tolerance, "hard_pixel_tolerance": hard_tolerance, } def evaluate_component( source: np.ndarray, background: np.ndarray, reconstructed: np.ndarray, node: dict, graph: dict, calibration: PageCalibration, *, component_mask: np.ndarray, parent_mask: np.ndarray | None = None, presentation_alpha_mask: np.ndarray | None = None, generated_underlay_mask: np.ndarray | None = None, underlay_metrics: dict | None = None, other_component_masks: Iterable[np.ndarray] = (), text_mask: np.ndarray, page_checks: dict | None = None, agent_confidence: float | None = None, previous_metrics: dict | None = None, over_merged_component: bool = False, contained_parent_review: bool = False, overlap_component_ids: Iterable[str] = (), _page_context: _PageQualityContext | None = None, ) -> dict: source_shape = np.asarray(source).shape[:2] presentation_values = ( presentation_alpha_mask, generated_underlay_mask, underlay_metrics, ) if any(value is not None for value in presentation_values) and not all( value is not None for value in presentation_values ): raise ValueError("component presentation inputs must be provided together") if presentation_alpha_mask is None: ownership, _ = _project_component_mask(component_mask, source_shape) alpha = ownership generated = np.zeros(source_shape, dtype=bool) normalized_underlay_metrics = { "boundary_color_mae": 0.0, "gradient_jump_p95": 0.0, "added_high_frequency_pixels": 0.0, } else: ownership = _strict_binary_mask( component_mask, source_shape, "component ownership mask" ) alpha = _strict_binary_mask( presentation_alpha_mask, source_shape, "presentation alpha mask" ) generated = _strict_binary_mask( generated_underlay_mask, source_shape, "generated underlay mask" ) _validate_presentation_mask_union( ownership, alpha, generated, label="component presentation" ) normalized_underlay_metrics = _validate_underlay_metrics( underlay_metrics ) direct_ownership_overlap = np.zeros(source_shape, dtype=bool) for index, other_owner_value in enumerate(other_component_masks): if presentation_alpha_mask is None: raise ValueError( "other presentation inputs require component presentation inputs" ) other_owner = _strict_binary_mask( other_owner_value, source_shape, f"other component {index} ownership mask" ) direct_ownership_overlap |= ownership & other_owner if parent_mask is None: if np.any(generated): raise ValueError("generated underlay requires a parent semantic mask") underlay_outside = np.zeros(source_shape, dtype=bool) else: semantic_parent = _strict_binary_mask( parent_mask, source_shape, "parent semantic mask" ) text_semantic = _strict_binary_mask( text_mask, source_shape, "component text mask" ) radius = max(1, calibration.text_halo_px) text_semantic = cv2.dilate( text_semantic.astype(np.uint8), np.ones((2 * radius + 1, 2 * radius + 1), dtype=np.uint8), ).astype(bool) underlay_outside = generated & ~(semantic_parent | text_semantic) metrics = component_metrics( source, background, reconstructed, node, graph, calibration, component_mask=component_mask, parent_mask=parent_mask, text_mask=text_mask, _page_context=_page_context, ) if presentation_alpha_mask is not None: context_overlap = np.zeros(source_shape, dtype=bool) if _page_context is not None: context_overlap = ownership & (_page_context.component_owner_count > 1) metrics["component_overlap_pixels"] = int(np.count_nonzero( context_overlap | direct_ownership_overlap )) metrics.update({ "generated_underlay_pixels": int(np.count_nonzero(generated)), "underlay_out_of_bounds_pixels": int(np.count_nonzero(underlay_outside)), "underlay_boundary_color_mae": normalized_underlay_metrics[ "boundary_color_mae" ], "underlay_gradient_jump_p95": normalized_underlay_metrics[ "gradient_jump_p95" ], "underlay_added_high_frequency_pixels": normalized_underlay_metrics[ "added_high_frequency_pixels" ], }) violations = [] empty_visual_component = presentation_alpha_mask is not None and ( metrics["component_pixels"] == 0 or not np.any(ownership) or not np.any(alpha) ) if empty_visual_component: violations.append("empty_component") hard_pixel_ratio = max( 0.01, max(20, calibration.min_component_pixels) / max(metrics["component_pixels"], 1), ) hard_pixel_ratio = min(1.0, hard_pixel_ratio) if metrics["shadow_duplicate_ratio"] >= hard_pixel_ratio: violations.append("duplicate_shadow") if metrics["missing_ratio"] > 0.02 or metrics["edge_missing_ratio"] > 0.05: violations.append("missing_edge") text_pixel_floor = calibration.text_halo_px ** 2 if ( metrics["text_duplicate_ratio"] >= 0.02 and metrics["text_duplicate_ratio"] * metrics["text_support_pixels"] >= text_pixel_floor ): violations.append("text_ghost") residual_pixel_floor = max( calibration.min_component_pixels, calibration.text_halo_px * 2, ) if ( metrics["component_pixels"] > 0 and metrics["component_text_residual_ratio"] * max(metrics["text_support_pixels"], 1) >= residual_pixel_floor ): violations.append("component_text_residual") if ( metrics["background_text_residual_ratio"] * metrics["text_support_pixels"] >= residual_pixel_floor ): violations.append("background_text_residual") if metrics["alpha_duplicate_ratio"] >= 0.02: violations.append("alpha_halo") if metrics["parent_child_double"]: violations.append("parent_child_double") overlap_pixel_limit = max( calibration.min_component_pixels, round(metrics["component_pixels"] * 0.0002), ) if metrics["component_overlap_pixels"] > overlap_pixel_limit: violations.append("component_overlap") if metrics["duplicate_ratio"] >= hard_pixel_ratio: violations.append("duplicate_pixels") if metrics["ownership_out_of_bounds_pixels"]: violations.append("out_of_bounds") underlay_out_of_bounds_limit = max( 4, round(metrics["generated_underlay_pixels"] * 0.0002) ) if metrics["underlay_out_of_bounds_pixels"] > underlay_out_of_bounds_limit: violations.append("underlay_out_of_bounds") if ( metrics["underlay_boundary_color_mae"] > metrics["hard_pixel_tolerance"] * 2 ): violations.append("underlay_seam") if ( metrics["underlay_gradient_jump_p95"] > metrics["hard_pixel_tolerance"] * 4 ): violations.append("underlay_gradient_break") high_frequency_limit = max( 4, round(metrics["generated_underlay_pixels"] * 0.005) ) if metrics["underlay_added_high_frequency_pixels"] > high_frequency_limit: violations.append("underlay_patch") if node.get("kind") == "child" and metrics["parent_coverage_ratio"] < 0.25: violations.append("incomplete_child") if over_merged_component: violations.append("over_merged_component") if contained_parent_review: violations.append("contained_parent_review") native_state = _check_state(page_checks, "protected_native_overlap") if native_state == "unknown": violations.append("protected_native_overlap_unknown") elif native_state == "fail": violations.append("protected_native_overlap") if empty_visual_component: violations = ["empty_component"] previous = previous_metrics or {} improvement = {} for key in ( "missing_ratio", "duplicate_ratio", "edge_missing_ratio", "shadow_duplicate_ratio", "alpha_duplicate_ratio", "text_duplicate_ratio", "component_text_residual_ratio", "background_text_residual_ratio", "underlay_out_of_bounds_pixels", "underlay_boundary_color_mae", "underlay_gradient_jump_p95", "underlay_added_high_frequency_pixels", ): if key not in previous: continue prior = float(previous[key]) if not np.isfinite(prior): raise ValueError("previous component metrics must be finite") improvement[key] = prior - float(metrics[key]) report = { "component_id": node["id"], "accepted": not violations, "metrics": metrics, "improvement": improvement, "violations": sorted(set(violations)), "checks": {"protected_native_overlap": native_state}, "agent_confidence": agent_confidence, } overlap_ids = sorted(set(overlap_component_ids)) if overlap_ids: report["overlap_component_ids"] = overlap_ids return report def evaluate_page_quality( component_reports: list[dict], *, visual_metrics: dict, page_checks: dict | None = None, expected_component_ids: list[str], initial_component_count: int, active_visual_count: int, ) -> dict: required_visual = {"mae", "p95", "changed_ratio"} if not isinstance(visual_metrics, dict) or not required_visual <= set(visual_metrics): raise ValueError("visual_metrics fields are incomplete") for key in required_visual: value = visual_metrics[key] if type(value) not in {int, float} or not np.isfinite(value) or value < 0: raise ValueError("visual_metrics values must be finite and non-negative") if ( type(expected_component_ids) is not list or any(type(value) is not str for value in expected_component_ids) or len(expected_component_ids) != len(set(expected_component_ids)) or type(initial_component_count) is not int or initial_component_count < 0 or type(active_visual_count) is not int or active_visual_count < len(expected_component_ids) ): raise ValueError("component report expectations are invalid") if initial_component_count and active_visual_count == 0: raise ValueError("active visual components cannot be empty for a nonempty page") report_ids = [] violations = [] for report in component_reports: if ( not isinstance(report, dict) or type(report.get("component_id")) is not str or type(report.get("accepted")) is not bool or type(report.get("violations")) is not list or (not report["accepted"] and not report["violations"]) ): raise ValueError("component reports are invalid") report_ids.append(report["component_id"]) violations.extend(report["violations"]) metrics = report.get("metrics") if not isinstance(metrics, dict): raise ValueError("component report metrics are invalid") orphan_pixels = int(metrics.get("orphan_residual_pixels", 0)) orphan_floor = max(1, int(metrics.get("edge_width_px", 1)) ** 2) if orphan_pixels >= orphan_floor: violations.append("orphan_residual") if sorted(report_ids) != sorted(expected_component_ids): raise ValueError("component reports do not match expected IDs") reopen_state = _check_state(page_checks, "pptx_reopen") if reopen_state == "unknown": violations.append("pptx_reopen_unknown") elif reopen_state == "fail": violations.append("pptx_reopen") if page_checks is not None and "editable_text_once" in page_checks: editable_state = _check_state(page_checks, "editable_text_once") if editable_state != "pass": violations.append( "editable_text_once_unknown" if editable_state == "unknown" else "editable_text_once" ) if page_checks is not None and "background_text_clean" in page_checks: background_state = _check_state(page_checks, "background_text_clean") if background_state != "pass": violations.append( "background_text_residual_unknown" if background_state == "unknown" else "background_text_residual" ) if page_checks is not None and "unowned_raster_text" in page_checks: unowned_state = _check_state(page_checks, "unowned_raster_text") if unowned_state != "pass": violations.append("unowned_raster_text") if page_checks is not None and "visual_ownership" in page_checks: ownership_state = _check_state(page_checks, "visual_ownership") if ownership_state != "pass": violations.append("unexplained_visual_residual") if ( float(visual_metrics["mae"]) > 8.0 or float(visual_metrics["p95"]) > 32.0 or float(visual_metrics["changed_ratio"]) > 0.02 ): violations.append("visual_difference") return { "accepted": not violations, "violations": sorted(set(violations)), "component_reports": component_reports, "visual_metrics": dict(visual_metrics), "checks": { "pptx_reopen": reopen_state, **( {"editable_text_once": _check_state(page_checks, "editable_text_once")} if page_checks is not None and "editable_text_once" in page_checks else {} ), **( {"background_text_clean": _check_state(page_checks, "background_text_clean")} if page_checks is not None and "background_text_clean" in page_checks else {} ), **( {"unowned_raster_text": _check_state(page_checks, "unowned_raster_text")} if page_checks is not None and "unowned_raster_text" in page_checks else {} ), **( {"visual_ownership": _check_state(page_checks, "visual_ownership")} if page_checks is not None and "visual_ownership" in page_checks else {} ), }, } def material_ownership_metrics( material_foreground: np.ndarray, component_masks: Iterable[np.ndarray], text_mask: np.ndarray, calibration: PageCalibration, *, generated_underlay_masks: Iterable[np.ndarray] = (), ) -> tuple[dict, np.ndarray]: material = np.asarray(material_foreground, dtype=bool).copy() text = np.asarray(text_mask, dtype=bool) if material.ndim != 2 or text.shape != material.shape: raise ValueError("material foreground and text mask dimensions differ") material &= ~text owned = np.zeros(material.shape, dtype=bool) for mask in component_masks: projected, _ = _project_component_mask(mask, material.shape) owned |= projected generated = np.zeros(material.shape, dtype=bool) for mask in generated_underlay_masks: projected, _ = _project_component_mask(mask, material.shape) generated |= projected generated_responsibility = material & generated & ~owned unexplained = material & ~owned & ~generated count, labels, stats, _ = cv2.connectedComponentsWithStats( unexplained.astype(np.uint8), 8 ) keep = np.zeros(material.shape, dtype=bool) largest = 0 for label in range(1, count): area = int(stats[label, cv2.CC_STAT_AREA]) if area < calibration.min_component_pixels: continue keep |= labels == label largest = max(largest, area) material_pixels = int(np.count_nonzero(material)) unexplained_pixels = int(np.count_nonzero(keep)) owned_pixels = int(np.count_nonzero(material & owned)) generated_pixels = int(np.count_nonzero(generated_responsibility)) return { "material_foreground_pixels": material_pixels, "owned_visual_pixels": owned_pixels, "generated_underlay_visual_pixels": generated_pixels, "unexplained_visual_pixels": unexplained_pixels, "largest_unexplained_region_pixels": largest, "visual_ownership_coverage": ( owned_pixels / material_pixels if material_pixels else 1.0 ), "visual_responsibility_coverage": ( (owned_pixels + generated_pixels) / material_pixels if material_pixels else 1.0 ), }, keep def refine_material_foreground( material_foreground: np.ndarray, source: np.ndarray, background: np.ndarray, calibration: PageCalibration, ) -> np.ndarray: material = np.asarray(material_foreground, dtype=bool) if material.ndim != 2: raise ValueError("material foreground must be a two-dimensional mask") source_rgb = _rgb_image(source, material.shape, "source") background_rgb = _rgb_image(background, material.shape, "background") tolerance = max( 3.0, calibration.noise_l1 * 4.0 + calibration.local_contrast * 0.08, ) background_delta = np.max( np.abs(source_rgb.astype(np.int16) - background_rgb.astype(np.int16)), axis=2, ) source_luma = cv2.cvtColor(source_rgb, cv2.COLOR_RGB2GRAY) background_luma = cv2.cvtColor(background_rgb, cv2.COLOR_RGB2GRAY) source_structure = cv2.morphologyEx( source_luma, cv2.MORPH_GRADIENT, np.ones((3, 3), dtype=np.uint8), ) background_structure = cv2.morphologyEx( background_luma, cv2.MORPH_GRADIENT, np.ones((3, 3), dtype=np.uint8), ) retained_structure = ( (background_delta <= tolerance) & (source_structure > tolerance) & (background_structure > tolerance) ) return material & ( (background_delta > tolerance) | retained_structure ) def _page_shape(shape: object) -> tuple[int, int]: if ( not isinstance(shape, (tuple, list)) or len(shape) != 2 or any(type(value) is not int or value <= 0 for value in shape) ): raise ValueError("shape must contain positive integer height and width") return shape[0], shape[1] def _numeric_mask(mask: object, label: str) -> np.ndarray: array = np.asarray(mask) if array.ndim != 2 or array.dtype.kind not in "biuf": raise ValueError(f"{label} must be a two-dimensional numeric mask") if array.dtype.kind == "f" and not np.all(np.isfinite(array)): raise ValueError(f"{label} contains non-finite values") if array.dtype.kind in "if" and np.any(array < 0): raise ValueError(f"{label} contains negative values") return array def _exact_mask(mask: object, shape: tuple[int, int], label: str) -> np.ndarray: array = _numeric_mask(mask, label) if array.shape != shape: raise ValueError(f"{label} shape must match page shape") return array if array.dtype == np.bool_ else array > 0 def _strict_binary_mask( mask: object, shape: tuple[int, int], label: str ) -> np.ndarray: array = np.asarray(mask) if array.ndim != 2 or array.dtype.kind not in "biu": raise ValueError(f"{label} must be a two-dimensional binary mask") if array.shape != shape: raise ValueError(f"{label} shape must match page shape") if array.dtype == np.bool_: return array binary_one = np.all((array == 0) | (array == 1)) binary_255 = np.all((array == 0) | (array == 255)) if not binary_one and not binary_255: raise ValueError(f"{label} must contain binary values") return array != 0 def _validate_presentation_mask_union( ownership: np.ndarray, alpha: np.ndarray, generated: np.ndarray, *, label: str, ) -> None: if np.any(ownership & generated): raise ValueError(f"{label} ownership and generated masks overlap") if not np.array_equal(alpha, ownership | generated): raise ValueError(f"{label} alpha union is invalid") def _validate_underlay_metrics(value: object) -> dict[str, float]: if not isinstance(value, dict) or set(value) != _UNDERLAY_METRIC_FIELDS: raise ValueError("underlay metrics fields are invalid") normalized = {} for name, metric in value.items(): if ( type(metric) not in {int, float} or not np.isfinite(metric) or metric < 0 ): raise ValueError("underlay metrics values must be finite and non-negative") normalized[name] = float(metric) return normalized def _project_component_mask( mask: object, shape: tuple[int, int], ) -> tuple[np.ndarray, int]: array = _numeric_mask(mask, "component mask") active = array if array.dtype == np.bool_ else array > 0 if active.shape == shape: return active, 0 height = min(shape[0], active.shape[0]) width = min(shape[1], active.shape[1]) projected = np.zeros(shape, dtype=bool) projected[:height, :width] = active[:height, :width] out_of_bounds = int(np.count_nonzero(active)) - int( np.count_nonzero(active[:height, :width]) ) return projected, out_of_bounds def validate_pixel_ownership( component_masks: list[np.ndarray], text_mask: np.ndarray, shape: tuple[int, int], *, foreground_mask: np.ndarray | None = None, ) -> dict: """Report ownership defects without modifying or repairing any mask. ``missing_pixels`` is meaningful only when ``foreground_mask`` is given. Every non-zero alpha value counts as source evidence owned by that component. """ page_shape = _page_shape(shape) text = _exact_mask(text_mask, page_shape, "text mask") claimed = np.zeros(page_shape, dtype=bool) duplicate = np.zeros(page_shape, dtype=bool) out_of_bounds = 0 for mask in component_masks: projected, outside = _project_component_mask(mask, page_shape) duplicate |= claimed & projected claimed |= projected out_of_bounds += outside if foreground_mask is None: missing_pixels = 0 else: foreground = _exact_mask( foreground_mask, page_shape, "foreground mask", ) missing_pixels = int(np.count_nonzero(foreground & ~claimed)) report = { "duplicate_pixels": int(np.count_nonzero(duplicate)), "missing_pixels": missing_pixels, "text_duplicate_pixels": int(np.count_nonzero(text & claimed)), "out_of_bounds_pixels": out_of_bounds, } return {"valid": not any(report.values()), **report} -
component_underlay.py 25.1 KB
"""Deterministic presentation-layer underlay reconstruction.""" from __future__ import annotations import cv2 import numpy as np def _rgb_array(name: str, value: np.ndarray) -> np.ndarray: array = np.asarray(value) if array.ndim != 3 or array.shape[2] != 3: raise ValueError(f"{name} must have shape (height, width, 3)") if array.dtype != np.uint8: raise TypeError(f"{name} must have dtype uint8") return array def _mask_array(name: str, value: np.ndarray, shape: tuple[int, int]) -> np.ndarray: array = np.asarray(value) if array.shape != shape: raise ValueError(f"{name} must have shape {shape}") if array.dtype.kind not in "biu": raise TypeError(f"{name} must have a boolean or integer dtype") return array.astype(bool, copy=False) def _visual_metrics( candidate: np.ndarray, source: np.ndarray, donor_mask: np.ndarray, visual_hole: np.ndarray, ) -> dict[str, float]: empty = { "boundary_color_mae": 0.0, "gradient_jump_p95": 0.0, "added_high_frequency_pixels": 0.0, } if not np.any(visual_hole): return empty inside_y, inside_x = np.nonzero(visual_hole) # Two-pixel donor ring plus one pixel for its gradient/erosion support. top = max(0, int(inside_y.min()) - 3) bottom = min(visual_hole.shape[0], int(inside_y.max()) + 4) left = max(0, int(inside_x.min()) - 3) right = min(visual_hole.shape[1], int(inside_x.max()) + 4) candidate = candidate[top:bottom, left:right] source = source[top:bottom, left:right] donor_mask = donor_mask[top:bottom, left:right] visual_hole = visual_hole[top:bottom, left:right] inside_y, inside_x = inside_y - top, inside_x - left height, width = visual_hole.shape donor_counts = np.zeros(len(inside_y), dtype=np.uint8) donor_min = np.full((len(inside_y), 3), 255, dtype=np.int16) donor_max = np.zeros((len(inside_y), 3), dtype=np.int16) for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)): outside_y, outside_x = inside_y + dy, inside_x + dx valid = ( (outside_y >= 0) & (outside_y < height) & (outside_x >= 0) & (outside_x < width) ) valid_indices = np.flatnonzero(valid) if not valid_indices.size: continue oy, ox = outside_y[valid_indices], outside_x[valid_indices] valid_indices = valid_indices[donor_mask[oy, ox]] if not valid_indices.size: continue colors = source[ outside_y[valid_indices], outside_x[valid_indices] ].astype(np.int16) donor_counts[valid_indices] += 1 donor_min[valid_indices] = np.minimum( donor_min[valid_indices], colors ) donor_max[valid_indices] = np.maximum( donor_max[valid_indices], colors ) # A one-pixel antialias cannot satisfy two distinct adjacent surfaces. conflicting_edges = ( (donor_counts >= 2) & (np.max(donor_max - donor_min, axis=1) >= 48) ) boundary_errors: list[np.ndarray] = [] gradient_errors: list[np.ndarray] = [] for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)): outside_y, outside_x = inside_y + dy, inside_x + dx valid = ( (outside_y >= 0) & (outside_y < height) & (outside_x >= 0) & (outside_x < width) ) valid_indices = np.flatnonzero(valid) if not valid_indices.size: continue oy, ox = outside_y[valid_indices], outside_x[valid_indices] visible = donor_mask[oy, ox] valid_indices = valid_indices[visible] valid_indices = valid_indices[~conflicting_edges[valid_indices]] if not valid_indices.size: continue iy, ix = inside_y[valid_indices], inside_x[valid_indices] oy, ox = outside_y[valid_indices], outside_x[valid_indices] boundary_errors.append(np.abs( candidate[iy, ix].astype(np.int16) - source[oy, ox].astype(np.int16) )) outer_y, outer_x = oy + dy, ox + dx has_outer = ( (outer_y >= 0) & (outer_y < height) & (outer_x >= 0) & (outer_x < width) ) gradient_indices = np.flatnonzero(has_outer) if not gradient_indices.size: continue o2y, o2x = outer_y[gradient_indices], outer_x[gradient_indices] visible_outer = donor_mask[o2y, o2x] gradient_indices = gradient_indices[visible_outer] if not gradient_indices.size: continue i = candidate[iy[gradient_indices], ix[gradient_indices]].astype(np.int16) o = source[oy[gradient_indices], ox[gradient_indices]].astype(np.int16) o2 = source[ outer_y[gradient_indices], outer_x[gradient_indices] ].astype(np.int16) target = 2 * o - o2 feasible = np.all((target >= 0) & (target <= 255), axis=1) if np.any(feasible): gradient_errors.append(np.mean( np.abs((i[feasible] - o[feasible]) - (o[feasible] - o2[feasible])), axis=1, )) if not boundary_errors: return empty boundary_mae = float(np.concatenate(boundary_errors).mean()) gradient_values = np.concatenate(gradient_errors) if gradient_errors else np.array([]) gradient_jump_p95 = float(np.percentile(gradient_values, 95)) if gradient_values.size else 0.0 candidate_gray = cv2.cvtColor(candidate, cv2.COLOR_RGB2GRAY) source_gray = cv2.cvtColor(source, cv2.COLOR_RGB2GRAY) candidate_detail = np.abs(cv2.Laplacian(candidate_gray, cv2.CV_32F)) source_detail = np.abs(cv2.Laplacian(source_gray, cv2.CV_32F)) kernel3 = np.ones((3, 3), dtype=np.uint8) donor_ring = ( cv2.dilate(visual_hole.astype(np.uint8), np.ones((5, 5), dtype=np.uint8)).astype(bool) & ~cv2.dilate(visual_hole.astype(np.uint8), kernel3).astype(bool) & cv2.erode(donor_mask.astype(np.uint8), kernel3).astype(bool) ) detail_threshold = ( float(np.percentile(source_detail[donor_ring], 95)) + 12.0 if np.any(donor_ring) else 12.0 ) interior = cv2.erode( visual_hole.astype(np.uint8), np.ones((5, 5), dtype=np.uint8) ).astype(bool) high_frequency = float(np.count_nonzero( interior & (candidate_detail > detail_threshold) & (candidate_detail > source_detail + 12.0) )) return { "boundary_color_mae": boundary_mae, "gradient_jump_p95": gradient_jump_p95, "added_high_frequency_pixels": high_frequency, } def _continue_boundary_gradient( rgb: np.ndarray, donor_mask: np.ndarray, hole_mask: np.ndarray, ) -> np.ndarray: output = rgb.astype(np.float32).copy() height, width = hole_mask.shape hole_y, hole_x = np.nonzero(hole_mask) sums = np.zeros_like(output, dtype=np.float32) counts = np.zeros((height, width), dtype=np.uint8) for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)): outside_y, outside_x = hole_y + dy, hole_x + dx outer_y, outer_x = hole_y + 2 * dy, hole_x + 2 * dx valid = ( (outside_y >= 0) & (outside_y < height) & (outside_x >= 0) & (outside_x < width) & (outer_y >= 0) & (outer_y < height) & (outer_x >= 0) & (outer_x < width) ) inside_y, inside_x = hole_y[valid], hole_x[valid] outside_y, outside_x = outside_y[valid], outside_x[valid] outer_y, outer_x = outer_y[valid], outer_x[valid] has_gradient = ( donor_mask[outside_y, outside_x] & donor_mask[outer_y, outer_x] ) inside_y, inside_x = inside_y[has_gradient], inside_x[has_gradient] outside_y, outside_x = ( outside_y[has_gradient], outside_x[has_gradient] ) outer_y, outer_x = outer_y[has_gradient], outer_x[has_gradient] outside = output[outside_y, outside_x] prediction = 2 * outside - output[outer_y, outer_x] feasible = np.all((prediction >= 0) & (prediction <= 255), axis=1) sums[inside_y, inside_x] += np.where( feasible[:, None], prediction, outside, ) counts[inside_y, inside_x] += 1 boundary = hole_mask & (counts > 0) if not np.any(boundary): return rgb.copy() output[boundary] = sums[boundary] / counts[boundary, None] continued = np.clip(np.rint(output), 0, 255).astype(np.uint8) remaining = hole_mask & ~boundary if np.any(remaining): continued = cv2.inpaint( continued, remaining.astype(np.uint8) * 255, 3, cv2.INPAINT_NS, ) smoothed = cv2.GaussianBlur(continued, (7, 7), 0) continued[remaining] = smoothed[remaining] return continued def _choose_visual_fill( *, rgb: np.ndarray, source_rgb: np.ndarray, semantic_mask: np.ndarray, donor_mask: np.ndarray, visual_hole: np.ndarray, allow_smooth_surface: bool = False, allow_original: bool = True, ) -> tuple[np.ndarray, dict[str, float]]: ys, xs = np.nonzero(semantic_mask) if not len(ys): return rgb.copy(), _visual_metrics(rgb, source_rgb, donor_mask, visual_hole) y0, y1 = max(0, int(ys.min()) - 8), min(rgb.shape[0], int(ys.max()) + 9) x0, x1 = max(0, int(xs.min()) - 8), min(rgb.shape[1], int(xs.max()) + 9) crop = rgb[y0:y1, x0:x1] mask = visual_hole[y0:y1, x0:x1].astype(np.uint8) * 255 candidates = [ cv2.inpaint(crop, mask, 3, cv2.INPAINT_TELEA), cv2.inpaint(crop, mask, 3, cv2.INPAINT_NS), ] if allow_original: candidates.append(crop.copy()) hole_crop = visual_hole[y0:y1, x0:x1] donor_crop = donor_mask[y0:y1, x0:x1] semantic_crop = semantic_mask[y0:y1, x0:x1] hole_area = int(np.count_nonzero(hole_crop)) if hole_area and allow_smooth_surface: candidates.append(_continue_boundary_gradient( crop, donor_crop, hole_crop, )) semantic_y, semantic_x = np.nonzero(semantic_crop) short_side = min( int(semantic_y.max() - semantic_y.min() + 1), int(semantic_x.max() - semantic_x.min() + 1), ) edge_radius = max(2, min(6, int(round(short_side * 0.06)))) ring_radius = max(8, min(24, int(round(np.sqrt(hole_area) * 0.65)))) core = cv2.erode( semantic_crop.astype(np.uint8), np.ones((2 * edge_radius + 1, 2 * edge_radius + 1), dtype=np.uint8), ).astype(bool) ring = ( cv2.dilate( hole_crop.astype(np.uint8), np.ones((2 * ring_radius + 1, 2 * ring_radius + 1), dtype=np.uint8), ).astype(bool) & donor_crop & core & cv2.erode( donor_crop.astype(np.uint8), np.ones((3, 3), dtype=np.uint8) ).astype(bool) ) if np.count_nonzero(ring) < 32 and np.array_equal( semantic_crop, hole_crop ): ring = ( cv2.dilate( hole_crop.astype(np.uint8), np.ones( (2 * ring_radius + 1, 2 * ring_radius + 1), dtype=np.uint8, ), ).astype(bool) & donor_crop & ~cv2.dilate( hole_crop.astype(np.uint8), np.ones((3, 3), dtype=np.uint8) ).astype(bool) ) if np.count_nonzero(ring) >= 32: gray = cv2.cvtColor(crop, cv2.COLOR_RGB2GRAY) gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0) gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1) gradient = np.sqrt(gx * gx + gy * gy) if float(np.percentile(gradient[ring], 95)) <= 24.0: safe_donor = donor_crop & ~cv2.dilate( hole_crop.astype(np.uint8), np.ones((3, 3), dtype=np.uint8) ).astype(bool) ring &= safe_donor ring_y, ring_x = np.nonzero(ring) hole_y, hole_x = np.nonzero(hole_crop) mean_x, mean_y = float(ring_x.mean()), float(ring_y.mean()) scale_x = max(1.0, float(ring_x.std())) scale_y = max(1.0, float(ring_y.std())) design = np.column_stack(( np.ones(ring_x.size, dtype=np.float32), ((ring_x - mean_x) / scale_x).astype(np.float32), ((ring_y - mean_y) / scale_y).astype(np.float32), )) normalized_hole_x = ( (hole_x - mean_x) / scale_x ).astype(np.float32) normalized_hole_y = ( (hole_y - mean_y) / scale_y ).astype(np.float32) smooth = crop.copy() for channel in range(3): coefficients = np.linalg.lstsq( design, crop[ring_y, ring_x, channel].astype(np.float32), rcond=None, )[0] prediction = ( coefficients[0] + normalized_hole_x * coefficients[1] + normalized_hole_y * coefficients[2] ) smooth[hole_y, hole_x, channel] = np.clip( np.rint(prediction), 0, 255 ).astype(np.uint8) smooth_full = rgb.copy() smooth_full[y0:y1, x0:x1][hole_crop] = smooth[hole_crop] smooth_metrics = _visual_metrics( smooth_full, source_rgb, donor_mask, visual_hole ) smooth_limits = ( 6.0, 12.0, float(max(4, round(np.count_nonzero(visual_hole) * 0.005))), ) smooth_values = ( smooth_metrics["boundary_color_mae"], smooth_metrics["gradient_jump_p95"], smooth_metrics["added_high_frequency_pixels"], ) if all( value <= limit for value, limit in zip(smooth_values, smooth_limits) ): return smooth_full, smooth_metrics count, labels = cv2.connectedComponents(hole_crop.astype(np.uint8), 8) areas = [int(np.count_nonzero(labels == label)) for label in range(1, count)] if any(area >= 25 for area in areas): local_fill = candidates[1].copy() filled = False for label, area in zip(range(1, count), areas): if area < 25: continue component = labels == label radius = max(3, min(21, int(np.ceil(np.sqrt(area) * 0.15)))) ring = ( cv2.dilate( component.astype(np.uint8), np.ones((2 * radius + 1, 2 * radius + 1), dtype=np.uint8), ).astype(bool) & donor_crop ) if not np.any(ring): continue local_fill[component] = np.median(crop[ring], axis=0).astype(np.uint8) filled = True if filled: candidates.append(local_fill) selected = rgb.copy() selected_metrics: dict[str, float] | None = None selected_key: tuple[float, ...] | None = None for candidate_crop in candidates: candidate = rgb.copy() candidate[y0:y1, x0:x1][visual_hole[y0:y1, x0:x1]] = candidate_crop[ visual_hole[y0:y1, x0:x1] ] metrics = _visual_metrics(candidate, source_rgb, donor_mask, visual_hole) limits = ( 6.0, 12.0, float(max(4, round(np.count_nonzero(visual_hole) * 0.005))), ) values = ( metrics["boundary_color_mae"], metrics["gradient_jump_p95"], metrics["added_high_frequency_pixels"], ) ratios = tuple(value / limit for value, limit in zip(values, limits)) key = ( float(sum(value > limit for value, limit in zip(values, limits))), max(ratios), sum(ratios), *values, ) if selected_key is None or key < selected_key: selected, selected_metrics, selected_key = candidate, metrics, key boundary_candidate = selected.copy() hole_y, hole_x = np.nonzero(visual_hole) boundary_sums = np.zeros_like(boundary_candidate, dtype=np.float32) boundary_counts = np.zeros(visual_hole.shape, dtype=np.uint8) for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)): donor_y, donor_x = hole_y + dy, hole_x + dx valid = ( (donor_y >= 0) & (donor_y < visual_hole.shape[0]) & (donor_x >= 0) & (donor_x < visual_hole.shape[1]) ) inside_y, inside_x = hole_y[valid], hole_x[valid] donor_y, donor_x = donor_y[valid], donor_x[valid] owned = donor_mask[donor_y, donor_x] inside_y, inside_x = inside_y[owned], inside_x[owned] donor_y, donor_x = donor_y[owned], donor_x[owned] boundary_sums[inside_y, inside_x] += source_rgb[donor_y, donor_x] boundary_counts[inside_y, inside_x] += 1 boundary = visual_hole & (boundary_counts > 0) if np.any(boundary): boundary_candidate[boundary] = np.clip(np.rint( boundary_sums[boundary] / boundary_counts[boundary, None] ), 0, 255).astype(np.uint8) boundary_metrics = _visual_metrics( boundary_candidate, source_rgb, donor_mask, visual_hole ) limits = ( 6.0, 12.0, float(max(4, round(np.count_nonzero(visual_hole) * 0.005))), ) values = ( boundary_metrics["boundary_color_mae"], boundary_metrics["gradient_jump_p95"], boundary_metrics["added_high_frequency_pixels"], ) ratios = tuple(value / limit for value, limit in zip(values, limits)) key = ( float(sum(value > limit for value, limit in zip(values, limits))), max(ratios), sum(ratios), *values, ) if selected_key is None or key < selected_key: selected, selected_metrics = boundary_candidate, boundary_metrics return selected, selected_metrics or _visual_metrics( selected, source_rgb, donor_mask, visual_hole, ) def _embedded_higher_layer( semantic: np.ndarray, higher_layer: np.ndarray, ) -> np.ndarray: interior = cv2.erode( semantic.astype(np.uint8), np.ones((3, 3), dtype=np.uint8) ).astype(bool) if not np.any(higher_layer & interior): return np.zeros_like(semantic) count, labels, stats, _ = cv2.connectedComponentsWithStats( higher_layer.astype(np.uint8), 8, ) interior_pixels = np.bincount(labels[interior], minlength=count) embedded = interior_pixels * 2 >= stats[:, cv2.CC_STAT_AREA] embedded[0] = False return embedded[labels] def _higher_layer_halo( ownership: np.ndarray, semantic: np.ndarray, higher_layer: np.ndarray, source_rgb: np.ndarray, ) -> np.ndarray: ys, xs = np.nonzero(semantic) if not len(ys) or not np.any(higher_layer): return np.zeros_like(semantic) short_side = min(int(ys.max() - ys.min() + 1), int(xs.max() - xs.min() + 1)) if short_side < 20: return np.zeros_like(semantic) radius = max(1, min(4, int(np.ceil(short_side * 0.02)))) kernel = np.ones((2 * radius + 1, 2 * radius + 1), dtype=np.uint8) halo = ( cv2.dilate(higher_layer.astype(np.uint8), kernel).astype(bool) & ownership & semantic ) if not np.any(halo): return halo y0, y1 = max(0, int(ys.min()) - radius), min(halo.shape[0], int(ys.max()) + radius + 1) x0, x1 = max(0, int(xs.min()) - radius), min(halo.shape[1], int(xs.max()) + radius + 1) higher_crop = higher_layer[y0:y1, x0:x1] _, nearest = cv2.distanceTransformWithLabels( (~higher_crop).astype(np.uint8), cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL, ) colors = source_rgb[y0:y1, x0:x1] higher_colors = colors[higher_crop] # Geometric proximity alone cannot distinguish a lower outline from bleed. halo_crop = halo[y0:y1, x0:x1] color_delta = np.max(np.abs( colors[halo_crop].astype(np.int16) - higher_colors[nearest[halo_crop] - 1].astype(np.int16) ), axis=1) # Shared outlines need an interior surface match before counting as bleed. higher_interior = cv2.erode(higher_crop.astype(np.uint8), kernel).astype(bool) if not np.any(higher_interior): return np.zeros_like(halo) _, nearest_interior = cv2.distanceTransformWithLabels( (~higher_interior).astype(np.uint8), cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL, ) interior_delta = np.max(np.abs( colors[halo_crop].astype(np.int16) - colors[higher_interior][nearest_interior[halo_crop] - 1].astype(np.int16) ), axis=1) donor = ownership[y0:y1, x0:x1] & semantic[y0:y1, x0:x1] & ~higher_crop & ~halo_crop if not np.any(donor): return np.zeros_like(halo) _, nearest_donor = cv2.distanceTransformWithLabels( (~donor).astype(np.uint8), cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL, ) donor_delta = np.max(np.abs( colors[halo_crop].astype(np.int16) - colors[donor][nearest_donor[halo_crop] - 1].astype(np.int16) ), axis=1) halo_crop[halo_crop] = (color_delta <= 3) & (interior_delta <= 3) & (donor_delta > 6) return halo def build_presentation_layer( *, source_rgb: np.ndarray, text_clean_rgb: np.ndarray, ownership_mask: np.ndarray, semantic_mask: np.ndarray, higher_layer_mask: np.ndarray, text_mask: np.ndarray, ) -> dict: """Build a movable component appearance without changing owned pixels.""" source = _rgb_array("source_rgb", source_rgb) text_clean = _rgb_array("text_clean_rgb", text_clean_rgb) if source.shape != text_clean.shape: raise ValueError("source_rgb and text_clean_rgb must have the same shape") shape = source.shape[:2] ownership = _mask_array("ownership_mask", ownership_mask, shape) semantic = _mask_array("semantic_mask", semantic_mask, shape) higher_layer = _mask_array("higher_layer_mask", higher_layer_mask, shape) text = _mask_array("text_mask", text_mask, shape) if np.any(ownership & ~semantic): raise ValueError("ownership_mask must be contained by semantic_mask") embedded_higher = _embedded_higher_layer(semantic, higher_layer) expanded_higher = embedded_higher | _higher_layer_halo( ownership, semantic, embedded_higher, source, ) visible_ownership = ownership & ~higher_layer & ~text # A halo is only a repair hint; it must not erase the entire visible rim. if np.any(visible_ownership) and not np.any(visible_ownership & ~expanded_higher): expanded_higher = embedded_higher ownership = visible_ownership & ~expanded_higher if not np.any(ownership): empty = np.zeros(shape, dtype=bool) return { "rgb": np.asarray(text_clean_rgb, dtype=np.uint8).copy(), "ownership_mask": empty, "presentation_alpha_mask": empty.copy(), "generated_underlay_mask": empty.copy(), "metrics": { "boundary_color_mae": 0.0, "gradient_jump_p95": 0.0, "added_high_frequency_pixels": 0.0, }, } text_hole = semantic & ~ownership & text & ~higher_layer visual_hole = ( semantic & ~ownership & expanded_higher & ~higher_layer & ~text_hole ) generated = text_hole | visual_hole rgb = np.asarray(text_clean_rgb, dtype=np.uint8).copy() rgb[ownership] = source[ownership] if np.any(text_hole): text_metrics = _visual_metrics(rgb, source, ownership, text_hole) text_limits = ( 6.0, 12.0, float(max(4, round(np.count_nonzero(text_hole) * 0.005))), ) text_values = ( text_metrics["boundary_color_mae"], text_metrics["gradient_jump_p95"], text_metrics["added_high_frequency_pixels"], ) if any(value > limit for value, limit in zip(text_values, text_limits)): repaired, repair_metrics = _choose_visual_fill( rgb=rgb, source_rgb=source, semantic_mask=semantic, donor_mask=ownership, visual_hole=text_hole, allow_smooth_surface=True, ) repair_values = ( repair_metrics["boundary_color_mae"], repair_metrics["gradient_jump_p95"], repair_metrics["added_high_frequency_pixels"], ) if all( value <= limit for value, limit in zip(repair_values, text_limits) ): rgb[text_hole] = repaired[text_hole] if np.any(visual_hole): visual_fill, metrics = _choose_visual_fill( rgb=rgb, source_rgb=source, semantic_mask=semantic, donor_mask=ownership, visual_hole=visual_hole, allow_smooth_surface=True, ) rgb[visual_hole] = visual_fill[visual_hole] else: metrics = _visual_metrics(rgb, source, ownership, generated) return { "rgb": rgb, "ownership_mask": ownership, "presentation_alpha_mask": ownership | generated, "generated_underlay_mask": generated, "metrics": metrics, } -
fetch_skill_source.py 3.6 KB
"""Fetch only the source files needed to install and run image2editable.""" from __future__ import annotations import argparse import json from pathlib import Path import subprocess REPOSITORY = "https://github.com/DSY-Xueai/image2editable.git" RUNTIME_SCRIPTS = ( "__init__.py", "art_text.py", "bg_model.py", "component_underlay.py", "fg_extract.py", "font_match.py", "initial_diagnostics.py", "lama_inpaint.py", "lama_worker.py", "object_detect.py", "object_worker.py", "ocr_worker.py", "page_routing.py", "performance_trace.py", "ppt_assemble.py", "psd_assemble.py", "runtime_model_paths.py", "sam_worker.py", "text_context.py", "text_detect.py", "text_runs.py", "visual_compare_qa.py", "visual_segment.py", "visual_worker.py", "worker_pool.py", "worker_resources.py", "install_release_renderer.ps1", ) SOURCE_PATTERNS = ( "/image2editable/", "/pyproject.toml", "/README_EN.md", "/LICENSE", "/.gitignore", "/.gitattributes", "/THIRD_PARTY_NOTICES.md", "/third_party/licenses/", "/constraints/runtime.txt", "/image_to_ppt.py", "/image_to_psd.py", *(f"/scripts/{name}" for name in RUNTIME_SCRIPTS), ) def git(directory: Path, *args: str, input: str | None = None) -> str: result = subprocess.run( ["git", "-C", str(directory), *args], input=input, capture_output=True, text=True, encoding="utf-8", check=False, ) if result.returncode: raise RuntimeError(result.stderr.strip()) return result.stdout.strip() def fetch_source(destination: Path, repository: str = REPOSITORY, *, skill: str | None = None) -> str: destination = destination.resolve() if destination.exists() and any(destination.iterdir()): if not (destination / ".git").is_dir(): raise ValueError("Source directory is not a managed Git checkout") if git(destination, "config", "--get", "image2editable.skillSource") != "true": raise ValueError("Preserve existing checkout; choose a new source directory") if git(destination, "remote", "get-url", "origin") != repository: raise ValueError("Source remote differs from the expected repository") if git(destination, "status", "--porcelain", "--untracked-files=all"): raise ValueError("Preserve local changes; choose a new source directory") else: destination.mkdir(parents=True, exist_ok=True) git(destination, "init") git(destination, "remote", "add", "origin", repository) git(destination, "config", "image2editable.skillSource", "true") git(destination, "config", "remote.origin.promisor", "true") git(destination, "config", "remote.origin.partialclonefilter", "blob:none") patterns = (f"/skills/{skill}/",) if skill else SOURCE_PATTERNS git(destination, "sparse-checkout", "set", "--no-cone", "--stdin", input="\n".join(patterns) + "\n") git(destination, "fetch", "--depth=1", "--filter=blob:none", "origin", "main") commit = git(destination, "rev-parse", "FETCH_HEAD") git(destination, "-c", "advice.detachedHead=false", "checkout", "--detach", commit) return commit def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("destination", type=Path) parser.add_argument("--skill", choices=("image-to-ppt", "image-to-psd"), help="Fetch only this Skill for initial installation") args = parser.parse_args() commit = fetch_source(args.destination, skill=args.skill) print(json.dumps({"source": str(args.destination.resolve()), "commit": commit}, indent=2)) if __name__ == "__main__": main() -
fg_extract.py 47.1 KB
#!/usr/bin/env python3 """Foreground extraction and component splitting module. Extracts non-background foreground elements from an image by comparing against a background model, then splits the foreground into independent transparent PNG components via connected-component analysis. Usage: from fg_extract import extract_foreground_mask, split_components mask = extract_foreground_mask(img, bg, text_mask) components = split_components(img, mask, output_dir) """ from __future__ import annotations import hashlib import io import json import logging import os import shutil import stat import tempfile from pathlib import Path import cv2 import numpy as np from PIL import Image logger = logging.getLogger(__name__) class ComponentExtractionError(RuntimeError): pass # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def extract_foreground_mask( img: np.ndarray, bg: np.ndarray, text_mask: np.ndarray, diff_threshold: float = 20.0, ) -> np.ndarray: """Extract foreground binary mask using multiple detection methods. Uses three complementary approaches: 1. Direct color distance from background color (primary, model-independent) 2. Diff against background model (secondary, benefits from iterative refinement) 3. Edge detection with color-aware filtering (captures fine details) Args: img: Original image (H, W, 3) RGB uint8. bg: Background model (H, W, 3) RGB uint8. text_mask: Binary mask (H, W) where text regions = 255. diff_threshold: Base threshold for foreground detection. Returns: Cleaned foreground mask (H, W) uint8, foreground = 255. """ h, w = img.shape[:2] # === Method 1: Direct color distance from background color === # This is the PRIMARY method — it doesn't depend on background model quality. # Estimate bg_color from the bg image edges (robust to foreground contamination) bg_color = _estimate_bg_color(bg) color_dist = np.linalg.norm( img.astype(np.float32) - bg_color, axis=2 ) # Threshold: slightly above diff_threshold to avoid background noise color_threshold = diff_threshold * 1.25 color_mask = color_dist > color_threshold # === Method 2: Diff against background model === # Complements color distance — effective when bg model is accurate (2nd pass) diff = np.linalg.norm( img.astype(np.float32) - bg.astype(np.float32), axis=2 ) diff_mask = diff > diff_threshold # HSV saturation boost: catch colored elements with moderate diff hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV) sat = hsv[:, :, 1].astype(np.float32) bg_hsv = cv2.cvtColor(bg, cv2.COLOR_RGB2HSV) bg_sat_mean = float(np.mean(bg_hsv[:, :, 1])) sat_threshold = max(30.0, bg_sat_mean + 20.0) sat_mask = (diff > diff_threshold * 0.65) & (sat > sat_threshold) # Brightness diff gray_img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY).astype(np.float32) gray_bg = cv2.cvtColor(bg, cv2.COLOR_RGB2GRAY).astype(np.float32) brightness_diff = np.abs(gray_img - gray_bg) bright_mask = brightness_diff > diff_threshold * 1.2 # === Method 3: Edge-based detection === # Captures fine details (thin lines, icon outlines) using color distance gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) edges = cv2.Canny(gray, 50, 150) # Dilate edges slightly to include adjacent pixels edges_dilated = cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations=1) # Edge pixels with any color difference from bg are foreground edge_fg = (edges_dilated > 0) & (color_dist > diff_threshold * 0.5) # === Combine all methods === detector_masks = [color_mask, diff_mask, sat_mask, bright_mask, edge_fg] detector_masks = [ m for m in detector_masks if _keep_detector_mask( nonzero_pixels=int(np.count_nonzero(m)), total_pixels=h * w, ) ] if detector_masks: mask = np.logical_or.reduce(detector_masks) else: mask = np.zeros((h, w), dtype=bool) mask = _limit_combined_mask(mask, edge_fg) text_ink_mask = None if text_mask is not None: text_ink_mask = _build_text_ink_mask(img, text_mask) mask = mask.astype(np.uint8) * 255 # Morphological cleanup # CLOSE: fill small holes inside foreground regions (beneficial) kernel_close = np.ones((3, 3), np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_close, iterations=2) # Remove small noise blobs and edge artifacts mask = _remove_noise(mask, img_shape=(h, w)) # Exclude only likely text ink, not the whole OCR bbox. Whole-box removal # cuts holes into graphics that sit behind editable text. if text_ink_mask is not None: mask[text_ink_mask > 0] = 0 logger.info( "Foreground mask: %d non-zero pixels (%.1f%%)", int(np.count_nonzero(mask)), np.count_nonzero(mask) / (h * w) * 100, ) return mask def _keep_detector_mask( nonzero_pixels: int, total_pixels: int, max_mask_ratio: float = 0.45, ) -> bool: """Reject detector masks that classify most of the slide as foreground.""" if total_pixels <= 0: return False return nonzero_pixels / total_pixels <= max_mask_ratio def _limit_combined_mask( mask: np.ndarray, fallback_mask: np.ndarray, max_mask_ratio: float = 0.45, ) -> np.ndarray: """Reject combined masks that still cover too much of the slide.""" total_pixels = int(mask.size) if _keep_detector_mask(int(np.count_nonzero(mask)), total_pixels, max_mask_ratio): return mask if _keep_detector_mask( int(np.count_nonzero(fallback_mask)), total_pixels, max_mask_ratio ): return fallback_mask return np.zeros(mask.shape, dtype=bool) def export_visual_components( img: np.ndarray, element_masks: list[np.ndarray], output_dir: str | Path, text_mask: np.ndarray, padding: int = 3, semantic_masks: list[np.ndarray] | None = None, text_items: list[dict] | None = None, text_clean_image: np.ndarray | None = None, ) -> list[dict]: """Export each visual element as an independent transparent PNG.""" output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) img_h, img_w = img.shape[:2] has_text_item_boxes = bool(text_items) and all( "box" in item for item in text_items ) text_ink = ( _build_text_ink_mask(img, text_mask) if not has_text_item_boxes else _build_text_ink_mask(img, text_mask, text_items=text_items) ) text_removal = cv2.dilate( ( text_ink if has_text_item_boxes else (text_mask > 0).astype(np.uint8) * 255 ), np.ones((3, 3), dtype=np.uint8), iterations=1, ) if text_clean_image is None: text_clean_img = _repair_component_rgb(img, text_removal) else: text_clean_img = np.asarray(text_clean_image, dtype=np.uint8) if text_clean_img.shape != img.shape: raise ValueError("text-clean image shape must match image") ownership_masks = [np.asarray(mask, dtype=bool) for mask in element_masks] if any(mask.shape != (img_h, img_w) for mask in ownership_masks): raise ValueError("element mask shape must match image") semantic_masks = ownership_masks if semantic_masks is None else [ np.asarray(mask, dtype=bool) for mask in semantic_masks ] if len(semantic_masks) != len(ownership_masks): raise ValueError("semantic mask count must match element mask count") if any(mask.shape != (img_h, img_w) for mask in semantic_masks): raise ValueError("semantic mask shape must match image") owned_union = np.zeros((img_h, img_w), dtype=bool) for ownership_mask in ownership_masks: owned_union |= ownership_mask repair_owners = _assign_text_hole_repairs( ownership_masks, text_ink, text_mask, semantic_masks=semantic_masks, owned_union=owned_union, ) components: list[dict] = [] for position, ownership_mask in enumerate(ownership_masks): refined = _refine_visual_mask(img, ownership_mask) occupied_by_other = owned_union & ~ownership_mask refined = _restore_supported_holes( refined, ownership_mask, semantic_masks[position], occupied_by_other, ) unsupported_holes = _remove_border_connected(~refined) & ownership_mask if np.any(unsupported_holes): raise ComponentExtractionError( f"component {position + 1} still has unsupported internal holes" ) semantic_underlay = ( _remove_border_connected(~ownership_mask) & semantic_masks[position] ) alpha_mask = refined | semantic_underlay if repair_owners is not None: alpha_mask |= repair_owners == position + 1 area = int(np.count_nonzero(alpha_mask)) if area == 0: continue ys, xs = np.where(alpha_mask) x1 = max(0, int(xs.min()) - padding) y1 = max(0, int(ys.min()) - padding) x2 = min(img_w, int(xs.max()) + 1 + padding) y2 = min(img_h, int(ys.max()) + 1 + padding) local_mask = alpha_mask[y1:y2, x1:x2] rgb = text_clean_img[y1:y2, x1:x2].copy() local_underlay = semantic_underlay[y1:y2, x1:x2] if np.any(local_underlay): rgb = _fill_component_underlay( rgb, local_underlay, refined[y1:y2, x1:x2], ) alpha = _soft_alpha(local_mask) rgba = np.dstack([rgb, alpha]) component_path = output_dir / f"component_{position + 1:04d}.png" Image.fromarray(rgba.astype(np.uint8)).save(str(component_path)) components.append({ "path": str(component_path), "x": x1, "y": y1, "w": x2 - x1, "h": y2 - y1, "area": area, "z_index": position, }) return components def _component_graph_api(): try: from .component_contracts import ( is_render_active_component, validate_component_graph, ) from .component_quality import validate_pixel_ownership except ModuleNotFoundError as error: if error.name not in { f"{__package__}.component_contracts", f"{__package__}.component_quality", }: raise from image2editable.component_contracts import ( is_render_active_component, validate_component_graph, ) from image2editable.component_quality import validate_pixel_ownership except ImportError as error: if error.name is not None: raise from component_contracts import ( # type: ignore[no-redef] is_render_active_component, validate_component_graph, ) from component_quality import ( # type: ignore[no-redef] validate_pixel_ownership, ) return ( is_render_active_component, validate_component_graph, validate_pixel_ownership, ) def _is_link_or_reparse(info: os.stat_result) -> bool: return stat.S_ISLNK(info.st_mode) or bool( getattr(info, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) ) def _plain_directory(path: Path, label: str) -> None: info = path.lstat() if not stat.S_ISDIR(info.st_mode) or _is_link_or_reparse(info): raise ValueError(f"{label} must be a plain directory") def _read_bound_mask(graph_root: Path, node: dict, shape: tuple[int, int]) -> np.ndarray: relative = Path(*node["mask"].split("/")) candidate = graph_root / relative current = graph_root _plain_directory(current, "graph root") for part in relative.parts[:-1]: current = current / part _plain_directory(current, "mask parent") before = candidate.lstat() if ( not stat.S_ISREG(before.st_mode) or _is_link_or_reparse(before) or before.st_nlink != 1 ): raise ValueError("component mask must be a plain single-link file") with candidate.open("rb") as stream: opened = os.fstat(stream.fileno()) if ( opened.st_dev != before.st_dev or opened.st_ino != before.st_ino or opened.st_nlink != 1 ): raise ValueError("component mask changed while opening") payload = stream.read() after = candidate.lstat() if ( after.st_dev != opened.st_dev or after.st_ino != opened.st_ino or after.st_size != opened.st_size or _is_link_or_reparse(after) ): raise ValueError("component mask changed while reading") if hashlib.sha256(payload).hexdigest() != node["mask_sha256"]: raise ValueError("component mask hash does not match graph") with Image.open(io.BytesIO(payload)) as image: mask = np.asarray(image.convert("L")) > 0 if mask.shape != shape or not np.any(mask): raise ValueError("component mask shape must match image and be non-empty") ys, xs = np.nonzero(mask) bbox = [ int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1, ] if bbox != node["bbox"]: raise ValueError("component mask bbox does not match graph") return mask def _load_bound_graph_masks( graph_root: Path, graph: dict, shape: tuple[int, int], active_ids: set[str], ) -> dict[str, np.ndarray]: declared = {node["mask"] for node in graph["nodes"]} if len(declared) != len(graph["nodes"]): raise ValueError("component graph mask paths must be unique") if any(not path.startswith("masks/") for path in declared): raise ValueError("component graph masks must stay in masks directory") mask_dir = graph_root / "masks" _plain_directory(mask_dir, "mask directory") actual = set() for directory, directories, files in os.walk(mask_dir, followlinks=False): directory_path = Path(directory) for name in directories: _plain_directory(directory_path / name, "mask directory") for name in files: path = directory_path / name relative = path.relative_to(graph_root).as_posix() actual.add(relative) if actual != declared: raise ValueError("undeclared or missing component mask asset") loaded = {} for node in graph["nodes"]: mask = _read_bound_mask(graph_root, node, shape) if node["id"] in active_ids: loaded[node["id"]] = mask return loaded def _new_staging_directory(output_dir: Path) -> Path: output_dir.parent.mkdir(parents=True, exist_ok=True) _plain_directory(output_dir.parent, "output parent") if os.path.lexists(output_dir): raise FileExistsError(str(output_dir)) return Path( tempfile.mkdtemp( prefix=f".{output_dir.name}-staging-", dir=output_dir.parent, ) ) def _publish_directory(staging: Path, output_dir: Path) -> None: if os.path.lexists(output_dir): raise FileExistsError(str(output_dir)) staging.rename(output_dir) def export_component_graph( img: np.ndarray, graph: dict, graph_root: str | Path, output_dir: str | Path, *, text_mask: np.ndarray, foreground_mask: np.ndarray, text_items: list[dict] | None = None, text_clean_image: np.ndarray | None = None, ) -> list[dict]: """Export hash-bound active nodes into a newly published directory.""" ( is_render_active_component, validate_component_graph, validate_pixel_ownership, ) = _component_graph_api() validate_component_graph(graph) graph_root = Path(graph_root) output_dir = Path(output_dir) mask_root = (graph_root / "masks").resolve(strict=True) resolved_output = output_dir.resolve(strict=False) if resolved_output == mask_root or mask_root in resolved_output.parents: raise ValueError("component output cannot be inside graph masks") active_nodes = sorted( ( node for node in graph["nodes"] if is_render_active_component(node) ), key=lambda node: node["z_index"], ) masks = _load_bound_graph_masks( graph_root, graph, img.shape[:2], {node["id"] for node in active_nodes}, ) active_masks = [masks[node["id"]] for node in active_nodes] ownership = validate_pixel_ownership( active_masks, text_mask=text_mask, shape=img.shape[:2], foreground_mask=foreground_mask, ) blocking = { key: ownership[key] for key in ( "duplicate_pixels", "missing_pixels", "out_of_bounds_pixels", ) if ownership[key] } if blocking: raise ComponentExtractionError( "active component ownership is invalid: " + json.dumps(blocking, sort_keys=True) ) if ownership["text_duplicate_pixels"] and ( text_clean_image is None or not text_items or any("box" not in item for item in text_items) ): raise ComponentExtractionError( "text overlap requires text_items and text_clean_image" ) staging = _new_staging_directory(output_dir) try: components = export_visual_components( img, active_masks, staging, text_mask, semantic_masks=active_masks, text_items=text_items, text_clean_image=text_clean_image, ) if len(components) != len(active_nodes): raise ComponentExtractionError( "active component graph nodes must all produce visual components" ) for component, node in zip(components, active_nodes): component["component_id"] = node["id"] component["z_index"] = node["z_index"] _publish_directory(staging, output_dir) for component in components: component["path"] = str(output_dir / Path(component["path"]).name) except BaseException: if staging.exists(): shutil.rmtree(staging) raise return components def export_component_tree( img: np.ndarray, layers: list[dict], output_dir: str | Path, *, text_mask: np.ndarray, text_items: list[dict] | None = None, text_clean_image: np.ndarray | None = None, ) -> dict: """Persist intact parent masks and export only active child objects.""" _, validate_component_graph, _ = _component_graph_api() output_dir = Path(output_dir) staging = _new_staging_directory(output_dir) try: mask_dir = staging / "masks" mask_dir.mkdir() nodes = [] foreground = np.zeros(img.shape[:2], dtype=bool) for index, layer in enumerate(layers, start=1): if not isinstance(layer, dict) or set(layer) != { "parent_mask", "child_mask", "z_index", }: raise ValueError("component mask layer fields are invalid") parent_id = f"parent_{index:04d}" child_id = f"component_{index:04d}" for component_id, kind, state, parent_id_value, mask in ( (parent_id, "parent", "inactive", None, layer["parent_mask"]), (child_id, "child", "pending", parent_id, layer["child_mask"]), ): source_mask = np.asarray(mask) if ( source_mask.ndim != 2 or source_mask.dtype.kind not in "biuf" or ( source_mask.dtype.kind == "f" and not np.all(np.isfinite(source_mask)) ) or ( source_mask.dtype.kind in "if" and np.any(source_mask < 0) ) ): raise ValueError("component mask must be finite and non-negative") normalized = ( source_mask if source_mask.dtype == np.bool_ else source_mask > 0 ) if normalized.shape != img.shape[:2] or not np.any(normalized): raise ValueError("component mask shape must match image") mask_path = mask_dir / f"{component_id}.png" Image.fromarray(normalized.astype(np.uint8) * 255, mode="L").save( mask_path ) ys, xs = np.nonzero(normalized) nodes.append({ "id": component_id, "kind": kind, "parent_id": parent_id_value, "state": state, "mask": mask_path.relative_to(staging).as_posix(), "mask_sha256": hashlib.sha256(mask_path.read_bytes()).hexdigest(), "bbox": [ int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1, ], "z_index": layer["z_index"], "text_ids": [], }) if state == "pending": foreground |= normalized graph = {"nodes": nodes} validate_component_graph(graph) (staging / "component-graph.json").write_text( json.dumps(graph, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) components = export_component_graph( img, graph, staging, staging / "components", text_mask=text_mask, foreground_mask=foreground, text_items=text_items, text_clean_image=text_clean_image, ) _publish_directory(staging, output_dir) for component in components: component["path"] = str( output_dir / "components" / Path(component["path"]).name ) return {"graph": graph, "components": components} except BaseException: if staging.exists(): shutil.rmtree(staging) raise def _restore_supported_holes( refined: np.ndarray, ownership_mask: np.ndarray, semantic: np.ndarray, occupied_by_other: np.ndarray, ) -> np.ndarray: restored = np.asarray(refined, dtype=bool).copy() new_internal_loss = ( _remove_border_connected(~restored) & ownership_mask & ~occupied_by_other ) count, labels = cv2.connectedComponents( new_internal_loss.astype(np.uint8), connectivity=8, ) for label in range(1, count): recoverable = labels == label recoverable_area = int(np.count_nonzero(recoverable)) if recoverable_area == 0: continue supported = recoverable & semantic if np.count_nonzero(supported) / recoverable_area >= 0.90: restored |= supported return restored def _assign_text_hole_repairs( ownership_masks: list[np.ndarray], text_ink: np.ndarray, text_mask: np.ndarray, semantic_masks: list[np.ndarray] | None = None, owned_union: np.ndarray | None = None, ) -> np.ndarray | None: """Assign each removed glyph hole to the nearest underlying visual element.""" if not ownership_masks: return None if not np.any(text_ink) and not np.any(text_mask): return None semantic_masks = ownership_masks if semantic_masks is None else semantic_masks if owned_union is None: owned_union = np.zeros(text_mask.shape, dtype=bool) for ownership_mask in ownership_masks: owned_union |= ownership_mask unowned_text_ink = (text_ink > 0) & ~owned_union unowned_text_region = (text_mask > 0) & ~owned_union claimed_holes = np.zeros(text_mask.shape, dtype=bool) repair_owners = np.zeros(text_mask.shape, dtype=np.uint32) for position in reversed(range(len(ownership_masks))): ownership_mask = ownership_masks[position] relevant_text = _filter_text_ink_over_components( ownership_mask, text_ink, text_mask ) nearby_holes = _find_component_text_repairs( ownership_mask, relevant_text ) enclosed_holes = _remove_border_connected(~ownership_mask) relevant_region = _filter_text_ink_over_components( ownership_mask, text_mask, text_mask, ) supported_region = ( (relevant_region > 0) & semantic_masks[position] & unowned_text_region ) supported_region = _solidify_text_repairs( supported_region, semantic_masks[position], relevant_region, ) repair = ( ( unowned_text_ink & (nearby_holes | enclosed_holes) ) | supported_region ) & ~claimed_holes repair_owners[repair] = position + 1 claimed_holes |= repair return repair_owners def _solidify_text_repairs( repair_seed: np.ndarray, semantic_mask: np.ndarray, text_regions: np.ndarray, ) -> np.ndarray: """Replace glyph-shaped alpha repairs with solid semantic underlays.""" solid = np.zeros(repair_seed.shape, dtype=bool) count, labels, _, _ = cv2.connectedComponentsWithStats( (text_regions > 0).astype(np.uint8), connectivity=8, ) for label in range(1, count): region_seed = repair_seed & (labels == label) if not np.any(region_seed): continue ys, xs = np.where(region_seed) x = int(xs.min()) y = int(ys.min()) width = int(xs.max()) + 1 - x height = int(ys.max()) + 1 - y solid[y:y + height, x:x + width] |= semantic_mask[ y:y + height, x:x + width, ] return solid def _fill_component_underlay( crop_rgb: np.ndarray, repair_mask: np.ndarray, donor_mask: np.ndarray, ) -> np.ndarray: """Fill hidden pixels from the nearest visible pixel of the same component.""" repair_mask = np.asarray(repair_mask, dtype=bool) donor_mask = np.asarray(donor_mask, dtype=bool) & ~repair_mask if not np.any(repair_mask) or not np.any(donor_mask): return crop_rgb _, labels = cv2.distanceTransformWithLabels( (~donor_mask).astype(np.uint8), cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL, ) donor_colors = crop_rgb[donor_mask] filled = crop_rgb.copy() filled[repair_mask] = donor_colors[labels[repair_mask] - 1] return filled def repair_exported_component_text( components: list[dict], text_mask: np.ndarray, source_rgb: np.ndarray, text_items: list[dict] | None = None, cleaned_rgb: np.ndarray | None = None, clear_alpha: bool = False, ) -> None: """Inpaint OCR-detected raster text that remains in exported RGBA layers.""" if cleaned_rgb is not None and text_items: cleaned_rgb = np.asarray(cleaned_rgb, dtype=np.uint8) if cleaned_rgb.shape != source_rgb.shape: raise ValueError("cleaned RGB image shape must match source RGB image") loaded = [] for position, component in enumerate(components): with Image.open(component["path"]) as image: rgba = np.asarray(image.convert("RGBA")).copy() alpha_support = rgba[:, :, 3] > 0 alpha_support |= _remove_border_connected(~alpha_support) loaded.append((component, rgba, alpha_support, position)) for item in text_items: box_x, box_y, box_width, box_height = ( int(value) for value in item["box"] ) box_x2 = box_x + box_width box_y2 = box_y + box_height best = None for component, rgba, alpha_support, position in loaded: x = int(component["x"]) y = int(component["y"]) x1 = max(x, box_x) y1 = max(y, box_y) x2 = min(x + rgba.shape[1], box_x2) y2 = min(y + rgba.shape[0], box_y2) if x1 >= x2 or y1 >= y2: continue alpha = rgba[y1 - y:y2 - y, x1 - x:x2 - x, 3] overlap = int(np.count_nonzero(alpha)) score = (overlap, int(component.get("z_index", position))) if overlap and (best is None or score > best[0]): best = ( score, component, rgba, alpha_support, x1, y1, x2, y2, ) if best is None: continue _, component, rgba, alpha_support, x1, y1, x2, y2 = best x = int(component["x"]) y = int(component["y"]) region = rgba[y1 - y:y2 - y, x1 - x:x2 - x] local_support = alpha_support[y1 - y:y2 - y, x1 - x:x2 - x] region[local_support, :3] = cleaned_rgb[y1:y2, x1:x2][local_support] region[local_support, 3] = 255 for component, rgba, _, _ in loaded: Image.fromarray(rgba, "RGBA").save(component["path"]) return has_text_item_boxes = bool(text_items) and all( "box" in item for item in text_items ) text_ink = ( (text_mask > 0).astype(np.uint8) * 255 if clear_alpha else ( _build_text_ink_mask(source_rgb, text_mask) if not has_text_item_boxes else _build_text_ink_mask( source_rgb, text_mask, text_items=text_items, ) ) ) text_ink = cv2.dilate( text_ink, np.ones((3, 3), dtype=np.uint8), iterations=1, ) image_height, image_width = text_mask.shape for component in components: path = Path(component["path"]) with Image.open(path) as image: rgba = np.asarray(image.convert("RGBA")).copy() x = int(component["x"]) y = int(component["y"]) height, width = rgba.shape[:2] x2 = min(image_width, x + width) y2 = min(image_height, y + height) local = np.zeros((height, width), dtype=np.uint8) local[: y2 - y, : x2 - x] = text_ink[y:y2, x:x2] repair = ((local > 0) & (rgba[:, :, 3] > 0)).astype(np.uint8) * 255 if not np.any(repair): continue rgba[:, :, :3] = _repair_component_rgb(rgba[:, :, :3], repair) if clear_alpha: rgba[:, :, 3][repair > 0] = 0 Image.fromarray(rgba, "RGBA").save(path) def _refine_visual_mask(img: np.ndarray, mask: np.ndarray) -> np.ndarray: """Refine one visual-element mask without merging it with other elements.""" binary = np.asarray(mask, dtype=np.uint8) original = binary > 0 original_area = int(np.count_nonzero(original)) if original_area < 20: return original ys, xs = np.nonzero(original) x1 = max(0, int(xs.min()) - 8) y1 = max(0, int(ys.min()) - 8) x2 = min(binary.shape[1], int(xs.max()) + 9) y2 = min(binary.shape[0], int(ys.max()) + 9) local_binary = binary[y1:y2, x1:x2] local_original = original[y1:y2, x1:x2] dilated = cv2.dilate(local_binary, np.ones((5, 5), np.uint8), iterations=1) eroded = cv2.erode(local_binary, np.ones((3, 3), np.uint8), iterations=1) trimap = np.full(local_binary.shape, cv2.GC_BGD, dtype=np.uint8) trimap[dilated > 0] = cv2.GC_PR_BGD trimap[local_binary > 0] = cv2.GC_PR_FGD trimap[eroded > 0] = cv2.GC_FGD bg_model = np.zeros((1, 65), dtype=np.float64) fg_model = np.zeros((1, 65), dtype=np.float64) try: cv2.grabCut( img[y1:y2, x1:x2], trimap, None, bg_model, fg_model, 2, cv2.GC_INIT_WITH_MASK, ) except cv2.error: return original local_refined = (trimap == cv2.GC_FGD) | (trimap == cv2.GC_PR_FGD) local_refined &= local_original refined_area = int(np.count_nonzero(local_refined)) if refined_area == 0 or refined_area < original_area * 0.5: return original local_refined |= eroded > 0 refined = np.zeros_like(original) refined[y1:y2, x1:x2] = local_refined return refined def _soft_alpha(mask: np.ndarray) -> np.ndarray: """Feather a hard mask while preserving its eroded interior.""" hard = mask.astype(np.uint8) * 255 eroded = cv2.erode(hard, np.ones((3, 3), np.uint8), iterations=1) alpha = cv2.GaussianBlur(hard, (3, 3), 0) alpha[eroded > 0] = 255 alpha[hard == 0] = 0 return alpha.astype(np.uint8) def split_components( img: np.ndarray, fg_mask: np.ndarray, output_dir: str | Path, min_area: int = 20, padding: int = 3, text_mask: np.ndarray | None = None, ) -> list[dict]: """Split foreground mask into independent transparent PNG components. Uses connected-component analysis so a connected shape stays intact. Args: img: Original image (H, W, 3) RGB uint8. fg_mask: Foreground binary mask (H, W) uint8. output_dir: Directory to save component PNGs. min_area: Minimum component area in pixels. padding: Pixels to pad around each component bounding box. text_mask: Optional OCR text mask used to repair text over components. Returns: List of component dicts with keys: path, x, y, w, h, area. Sorted by area descending. """ output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) img_h, img_w = img.shape[:2] text_ink_mask = ( _build_text_ink_mask(img, text_mask) if text_mask is not None else np.zeros_like(fg_mask) ) component_text_ink_mask = _filter_text_ink_over_components( fg_mask, text_ink_mask, text_mask ) # Label on a grouping mask that closes narrow text gaps. grouping_mask = _build_component_grouping_mask(fg_mask) label_map = _label_connected_components(grouping_mask, min_area) # Extract each labeled component components: list[dict] = [] num_labels = label_map.max() for i in range(1, num_labels + 1): label_bool = label_map == i label_area = int(np.count_nonzero(label_bool)) label_ys, label_xs = np.where(label_bool) if len(label_ys) == 0: continue label_x_min, label_x_max = int(label_xs.min()), int(label_xs.max()) label_y_min, label_y_max = int(label_ys.min()), int(label_ys.max()) label_w = label_x_max - label_x_min + 1 label_h = label_y_max - label_y_min + 1 original_bool = label_bool & (fg_mask > 0) repair_bool = _find_component_text_repairs( original_bool, component_text_ink_mask ) if _should_use_solid_bbox_alpha( label_area, label_w, label_h, img_h * img_w ): solid_bool = np.zeros_like(label_bool) solid_bool[ label_y_min:label_y_max + 1, label_x_min:label_x_max + 1, ] = True comp_mask_full = (solid_bool | repair_bool).astype(np.uint8) * 255 else: comp_mask_full = (original_bool | repair_bool).astype(np.uint8) * 255 area = int(np.count_nonzero(comp_mask_full)) if area < min_area: continue # Bounding box ys, xs = np.where(comp_mask_full > 0) if len(ys) == 0: continue x_min, x_max = int(xs.min()), int(xs.max()) y_min, y_max = int(ys.min()), int(ys.max()) # Pad bounding box x1 = max(0, x_min - padding) y1 = max(0, y_min - padding) x2 = min(img_w, x_max + 1 + padding) y2 = min(img_h, y_max + 1 + padding) # Use hard alpha and repair text pixels over components, so # editable text does not sit on top of original raster text. comp_mask = comp_mask_full[y1:y2, x1:x2] repair_mask = repair_bool[y1:y2, x1:x2].astype(np.uint8) * 255 comp_alpha = comp_mask # Crop RGB and combine with alpha crop_rgb = _repair_component_rgb(img[y1:y2, x1:x2], repair_mask) rgba = np.dstack([crop_rgb, comp_alpha]) # Save as transparent PNG comp_path = output_dir / f"component_{i:04d}.png" Image.fromarray(rgba.astype(np.uint8)).save(str(comp_path)) components.append({ "path": str(comp_path), "x": x1, "y": y1, "w": x2 - x1, "h": y2 - y1, "area": area, }) # Sort by area descending (largest first) components.sort(key=lambda c: c["area"], reverse=True) logger.info("Split into %d foreground components.", len(components)) return components def _label_connected_components( fg_mask: np.ndarray, min_area: int = 20 ) -> np.ndarray: """Label connected foreground regions without splitting connected shapes. Args: fg_mask: Binary foreground mask (H, W) uint8. min_area: Minimum area for a component. Returns: Label map (H, W) int32 where each pixel is assigned a component ID. """ num_orig, orig_labels, orig_stats, _ = cv2.connectedComponentsWithStats( fg_mask, connectivity=8 ) label_map = np.zeros_like(orig_labels, dtype=np.int32) next_label = 1 for i in range(1, num_orig): area = orig_stats[i, cv2.CC_STAT_AREA] if area < min_area: continue label_map[orig_labels == i] = next_label next_label += 1 return label_map def _build_component_grouping_mask(fg_mask: np.ndarray) -> np.ndarray: """Close narrow text-shaped gaps only for component grouping.""" kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)) return cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel, iterations=1) def _should_use_solid_bbox_alpha( area: int, width: int, height: int, total_area: int, min_bbox_area_ratio: float = 0.12, min_fill_ratio: float = 0.30, ) -> bool: """Use a solid crop for large image-like regions with unreliable holes.""" bbox_area = max(width * height, 1) return ( bbox_area / max(total_area, 1) >= min_bbox_area_ratio and area / bbox_area >= min_fill_ratio ) def _find_component_text_repairs( component_mask: np.ndarray, text_ink_mask: np.ndarray ) -> np.ndarray: """Find raster text pixels that sit on top of an existing component.""" if not np.any(component_mask) or not np.any(text_ink_mask > 0): return np.zeros(component_mask.shape, dtype=bool) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 17)) nearby_component = cv2.dilate( component_mask.astype(np.uint8) * 255, kernel, iterations=1 ) > 0 return nearby_component & (text_ink_mask > 0) def _filter_text_ink_over_components( fg_mask: np.ndarray, text_ink_mask: np.ndarray, text_mask: np.ndarray | None, min_component_ratio: float = 0.25, ) -> np.ndarray: """Keep text ink only where the OCR box sits on a foreground component.""" if text_mask is None or not np.any(text_ink_mask > 0): return np.zeros_like(text_ink_mask) keep = np.zeros_like(text_ink_mask) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( (text_mask > 0).astype(np.uint8), connectivity=8 ) for i in range(1, num_labels): x = stats[i, cv2.CC_STAT_LEFT] y = stats[i, cv2.CC_STAT_TOP] w = stats[i, cv2.CC_STAT_WIDTH] h = stats[i, cv2.CC_STAT_HEIGHT] if w <= 0 or h <= 0: continue box_area = max(w * h, 1) fg_pixels = int(np.count_nonzero(fg_mask[y:y + h, x:x + w])) if fg_pixels / box_area >= min_component_ratio: keep[y:y + h, x:x + w] = text_ink_mask[y:y + h, x:x + w] return keep def _repair_component_rgb(crop_rgb: np.ndarray, repair_mask: np.ndarray) -> np.ndarray: """Remove raster text pixels from a component while keeping its base shape.""" if not np.any(repair_mask > 0): return crop_rgb bgr = cv2.cvtColor(crop_rgb, cv2.COLOR_RGB2BGR) repaired = cv2.inpaint(bgr, repair_mask, inpaintRadius=3, flags=cv2.INPAINT_TELEA) return cv2.cvtColor(repaired, cv2.COLOR_BGR2RGB) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _build_text_ink_mask( img: np.ndarray, text_mask: np.ndarray, text_items: list[dict] | None = None, ) -> np.ndarray: """Estimate actual glyph pixels inside OCR boxes.""" ink_mask = np.zeros(text_mask.shape, dtype=np.uint8) gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) if text_items and any("box" not in item for item in text_items): text_items = None if text_items is None: count, _, stats, _ = cv2.connectedComponentsWithStats( (text_mask > 0).astype(np.uint8), connectivity=8 ) regions = [ (*tuple(int(value) for value in stats[i, :4]), None) for i in range(1, count) ] else: regions = [ ( *tuple(int(value) for value in item["box"]), item.get("color"), ) for item in text_items ] for x, y, w, h, color in regions: x = max(0, x) y = max(0, y) w = min(gray.shape[1] - x, w) h = min(gray.shape[0] - y, h) if w < 3 or h < 3: continue region = gray[y:y + h, x:x + w] low, high = np.percentile(region, (2, 98)) if float(high - low) < 8.0: continue thresh, _ = cv2.threshold( region, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU ) ink = None if isinstance(color, str) and len(color) == 7 and color.startswith("#"): target = np.asarray( [int(color[index:index + 2], 16) for index in (1, 3, 5)], dtype=np.float32, ) rgb_region = img[y:y + h, x:x + w].astype(np.float32) color_match = np.linalg.norm(rgb_region - target, axis=2) <= 45.0 isolated_match = _remove_border_connected(color_match) if np.count_nonzero(isolated_match) >= 4: border = np.concatenate( [region[0, :], region[-1, :], region[:, 0], region[:, -1]] ).astype(np.float32) target_gray = float(np.mean(target)) if target_gray < float(np.mean(border)): ink = isolated_match | _remove_border_connected(region <= thresh) else: ink = isolated_match | _remove_border_connected(region > thresh) if ink is None: ink = _select_text_ink(region, float(thresh)) ink_uint8 = ink.astype(np.uint8) * 255 ink_uint8 = cv2.dilate(ink_uint8, np.ones((3, 3), np.uint8), iterations=1) box_mask = text_mask[y:y + h, x:x + w] > 0 ink_mask[y:y + h, x:x + w][(ink_uint8 > 0) & box_mask] = 255 return ink_mask def _select_text_ink(gray: np.ndarray, thresh: float) -> np.ndarray: """Pick the glyph class after dropping background connected to box edges.""" dark = gray <= thresh light = gray > thresh dark_inner = _remove_border_connected(dark) light_inner = _remove_border_connected(light) if np.count_nonzero(dark_inner) or np.count_nonzero(light_inner): ink = ( dark_inner if np.count_nonzero(dark_inner) >= np.count_nonzero(light_inner) else light_inner ) else: ink = dark if np.count_nonzero(dark) <= np.count_nonzero(light) else light return _add_antialiased_text_edges(gray, ink) def _add_antialiased_text_edges(gray: np.ndarray, ink: np.ndarray) -> np.ndarray: """Include same-direction antialiased text pixels without taking the background.""" if not np.any(ink): return ink border = np.concatenate([ gray[0, :], gray[-1, :], gray[:, 0], gray[:, -1] ]).astype(np.float32) border_mean = float(np.mean(border)) ink_mean = float(np.mean(gray[ink])) if ink_mean < border_mean: candidate = gray <= max(0.0, border_mean - 25.0) else: candidate = gray >= min(255.0, border_mean + 25.0) candidate_inner = _remove_border_connected(candidate) if np.any(candidate_inner): return ink | candidate_inner return ink def _remove_border_connected(mask: np.ndarray) -> np.ndarray: """Remove mask components touching the OCR box edge.""" if not np.any(mask): return mask.copy() num_labels, labels = cv2.connectedComponents(mask.astype(np.uint8), connectivity=8) border_labels = set(labels[0, :]) border_labels.update(labels[-1, :]) border_labels.update(labels[:, 0]) border_labels.update(labels[:, -1]) keep = np.ones(num_labels, dtype=bool) keep[list(border_labels)] = False keep[0] = False return keep[labels] def _estimate_bg_color(bg: np.ndarray) -> np.ndarray: """Estimate background color from edge pixels of the background image. Uses the 5% border on each side, which is least likely to contain foreground elements. Returns the median color as a (3,) float array. """ h, w = bg.shape[:2] margin_y = max(5, int(h * 0.05)) margin_x = max(5, int(w * 0.05)) edge_mask = np.zeros((h, w), dtype=bool) edge_mask[:margin_y, :] = True edge_mask[-margin_y:, :] = True edge_mask[:, :margin_x] = True edge_mask[:, -margin_x:] = True edge_pixels = bg[edge_mask].reshape(-1, 3).astype(np.float32) if len(edge_pixels) < 10: return np.median(bg.reshape(-1, 3).astype(np.float32), axis=0) return np.median(edge_pixels, axis=0) def _remove_noise( mask: np.ndarray, min_area: int = 15, img_shape: tuple | None = None ) -> np.ndarray: """Remove small noise blobs and edge artifacts from foreground mask. Filters: - Components smaller than min_area (noise) - Components spanning >=80% of image height/width with low fill ratio (edge artifacts) """ num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( mask, connectivity=8 ) clean = np.zeros_like(mask) img_h, img_w = img_shape if img_shape else (mask.shape[0], mask.shape[1]) for i in range(1, num_labels): area = stats[i, cv2.CC_STAT_AREA] w = stats[i, cv2.CC_STAT_WIDTH] h = stats[i, cv2.CC_STAT_HEIGHT] # Skip tiny noise if area < min_area: continue # Skip edge artifacts: components spanning most of the image # with low fill ratio (real foreground has higher density) bbox_area = max(w * h, 1) fill_ratio = area / bbox_area is_full_span = (h >= img_h * 0.8) or (w >= img_w * 0.8) if is_full_span and fill_ratio < 0.30: continue clean[labels == i] = 255 return clean def connected_mask_proposals(mask: np.ndarray, count: int) -> list[np.ndarray]: """Return largest real connected regions; never synthesize rectangular splits.""" labels_count, labels, stats, _ = cv2.connectedComponentsWithStats( np.asarray(mask, dtype=np.uint8), 8 ) ordered = sorted( range(1, labels_count), key=lambda value: int(stats[value, cv2.CC_STAT_AREA]), reverse=True, ) return [labels == value for value in ordered] -
font_match.py 10.2 KB
"""Match visible glyphs to installed, editable font faces and rotations.""" from functools import lru_cache import os from pathlib import Path import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont @lru_cache(maxsize=1) def installed_faces(): roots = [Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts", Path(os.environ.get("LOCALAPPDATA", ".")) / "Microsoft/Windows/Fonts", Path("/usr/share/fonts"), Path.home() / ".local/share/fonts"] faces = {} for root in roots: if not root.is_dir(): continue for path in sorted(root.rglob("*")): if path.suffix.lower() not in {".ttf", ".ttc", ".otf"}: continue for index in range(32 if path.suffix.lower() == ".ttc" else 1): try: family, style = ImageFont.truetype(str(path), 32, index=index).getname() except OSError: break bold = any(name in style.lower() for name in ("bold", "black", "heavy")) italic = any(name in style.lower() for name in ("italic", "oblique")) faces.setdefault((family, bold, italic), (str(path), index)) try: variations = ImageFont.truetype(str(path), 32, index=index).get_variation_names() except OSError: variations = [] if b'Regular' in variations and b'Bold' in variations: faces.setdefault((family, True, italic), (str(path), index)) return tuple((*key, *value) for key, value in faces.items()) @lru_cache(maxsize=128) def resolve_font(font_name, bold=False, italic=False, size=1000): candidates = [face for face in installed_faces() if face[0].casefold() == font_name.casefold()] if not candidates: return None face = min(candidates, key=lambda face: (face[2] != italic, face[1] != bold)) try: return _load_face(face, size) except OSError: return None def _load_face(face, size): font = ImageFont.truetype(face[3], size, index=face[4]) try: weight = b'Bold' if face[1] else b'Regular' if weight in font.get_variation_names(): font.set_variation_by_name(weight) except OSError: pass return font @lru_cache(maxsize=4096) def _glyph(face, text, size=128): font = _load_face(face, size) missing = font.getmask("\U0010ffff") missing_signature = (missing.size, bytes(missing)) for char in text: mask = font.getmask(char) if not char.isspace() and (mask.size, bytes(mask)) == missing_signature: return None left, top, right, bottom = font.getbbox(text) image = Image.new("L", (right-left+16, bottom-top+16)) ImageDraw.Draw(image).text((8-left, 8-top), text, font=font, fill=255) return image def _normalize(mask): bounds = cv2.boundingRect(mask.astype(np.uint8)) x, y, width, height = bounds if not width or not height: return None cropped = mask[y:y+height, x:x+width].astype(np.float32) return cv2.resize(cropped, (64, 64), interpolation=cv2.INTER_AREA), width, height @lru_cache(maxsize=128) def match_text_face(pixels: bytes, width: int, height: int, text: str): """Match a straight text line locally; whitespace does not determine weight.""" from scripts.text_detect import _normalized_ink region = np.frombuffer(pixels, dtype=np.uint8).reshape(height, width, 3) gray = cv2.cvtColor(region, cv2.COLOR_RGB2GRAY).astype(np.float32) border = np.concatenate((gray[0], gray[-1], gray[:, 0], gray[:, -1])) contrast = np.abs(gray - np.median(border)) # A cell border at the OCR crop edge is not part of the glyph height. count, labels, stats, _ = cv2.connectedComponentsWithStats( (contrast > contrast.max() * .2).astype(np.uint8), 8, ) for label in range(1, count): x, y, w, h, _ = stats[label] if (h == height and w <= 2 and (x == 0 or x + w == width)) or ( w == width and h <= 2 and (y == 0 or y + h == height) ): contrast[labels == label] = 0 # OCR boxes may include the descenders of the preceding line. Do not # measure that fragment as part of this line's font height. rows = np.flatnonzero((contrast > contrast.max() * .2).any(axis=1)) bands = np.split(rows, np.flatnonzero(np.diff(rows) > max(2, height * .05)) + 1) if len(bands) > 1: weights = [float(contrast[band].sum()) for band in bands] selected = int(np.argmax(weights)) if sum(weights) - weights[selected] > weights[selected] * .25: return None keep = bands[selected] contrast[:keep[0]] = 0 contrast[keep[-1] + 1:] = 0 target = _normalized_ink(contrast) if target is None: return None compact = target[:, target.max(axis=0) > .2] edges = np.diff(np.pad((target.max(axis=0) > .2).astype(np.int8), 1)) intervals = list(zip(np.flatnonzero(edges == 1), np.flatnonzero(edges == -1))) chars = [char for char in text if not char.isspace()] letters = {} if len(chars) == len(intervals): for char, (left, right) in zip(chars, intervals): if char.isalnum() and len(letters) < 6: letters.setdefault(char, _normalized_ink(target[:, left:right])) def similarity(observed, reference): fitted = cv2.resize(reference, (observed.shape[1], observed.shape[0]), interpolation=cv2.INTER_AREA) fitted = _normalized_ink(fitted) if fitted.shape != observed.shape: fitted = cv2.resize(fitted, (observed.shape[1], observed.shape[0]), interpolation=cv2.INTER_AREA) overlap = np.minimum(observed, fitted).sum() / np.maximum(observed, fitted).sum() aspect = abs(np.log((reference.shape[1] / reference.shape[0]) / (observed.shape[1] / observed.shape[0]))) return float(overlap - .15 * aspect) best = None measured_faces = [] for face in installed_faces(): if face[2]: continue try: glyph = _glyph.__wrapped__(face, text) if glyph is None: continue reference = _normalized_ink(np.asarray(glyph, dtype=np.float32)) if reference is None: continue size = 128 * target.shape[0] / reference.shape[0] measured_faces.append((face, round(size))) score = max(similarity(target, reference), similarity( compact, reference[:, reference.max(axis=0) > .2], )) if len(letters) >= 3: letter_scores = [] for char, observed in letters.items(): letter = _glyph(face, char) if letter is None: break letter_scores.append(similarity(observed, _normalized_ink(np.asarray(letter, dtype=np.float32)))) if len(letter_scores) == len(letters): score = max(score, sum(letter_scores) / len(letter_scores)) if best is None or score > best[0]: best = (score, face, size) except OSError: continue if best is not None and best[0] < .75: # Small raster text is hinted at its actual size. Downsampling a large # reference can reject the right face, so retry at the measured size. for face, size in measured_faces: try: for pixels in range(max(1, size - 1), size + 2): glyph = _glyph.__wrapped__(face, text, pixels) reference = _normalized_ink(np.asarray(glyph, dtype=np.float32)) if reference is None: continue score = similarity(target, reference) if score > best[0]: best = (score, face, pixels) except OSError: continue if best is None or best[0] < .75: return None score, face, size = best ys, xs = np.nonzero(contrast > contrast.max() * .2) return {'font': face[0], 'bold': face[1], 'font_size_px': size, 'ink_box': [int(xs.min()), int(ys.min()), int(xs.max()-xs.min()+1), int(ys.max()-ys.min()+1)]} def match_glyph(mask, text, preferred_font="Arial"): """Return native face, clockwise angle, pixel size and measured fit IoU. The score measures a local glyph match, not final slide quality. """ target = _normalize(mask) if target is None: return None target_pixels, target_width, target_height = target faces = sorted((face for face in installed_faces() if not face[2]), key=lambda face: (face[0] != preferred_font, not face[1])) best = None def measure(face, glyph, angle): nonlocal best rotated = glyph.rotate(-angle, Image.Resampling.BICUBIC, expand=True) candidate = _normalize(np.asarray(rotated) > 127) if candidate is None: return candidate_pixels, width, height = candidate intersection = np.minimum(candidate_pixels, target_pixels).sum() union = np.maximum(candidate_pixels, target_pixels).sum() iou = float(intersection / max(1, union)) aspect_error = abs(np.log((width/height)/(target_width/target_height))) score = iou - .15*aspect_error if face[0].casefold() == preferred_font.casefold(): score += .05 if best is None or score > best[0]: size = 128*(width*target_width+height*target_height)/(width*width+height*height) best = (score, face, glyph, angle, size, iou) for face in faces: try: glyph = _glyph(face, text) except OSError: # Some installed color/bitmap faces cannot render a text mask. continue if glyph is None: continue for angle in range(-35, 36, 5): measure(face, glyph, angle) if best is not None and best[0] > .975: break if best is None: return None _, face, glyph, coarse_angle, _, _ = best for angle in range(coarse_angle-4, coarse_angle+5): measure(face, glyph, angle) _, face, _, angle, size, iou = best return {"font": face[0], "bold": face[1], "rotation": angle, "font_size": size, "fit_iou": iou} -
image_to_ppt.py 225.4 KB
#!/usr/bin/env python3 """Image-to-PPT converter — main entry point. Converts one or more images into an editable PowerPoint presentation using: 1. OCR text detection with style estimation 2. Adaptive background modeling and inpainting repair 3. Foreground extraction and component splitting 4. Layered PPTX assembly (background + components + text boxes) Usage: python image_to_ppt.py input.png python image_to_ppt.py img1.png img2.png img3.png python image_to_ppt.py ./slides_folder/ python image_to_ppt.py input.png -o output.pptx python image_to_ppt.py input.png --lang ch """ from __future__ import annotations import argparse import base64 import gc import hashlib import io import json import logging import math import os import shutil import stat import sys import tempfile import traceback import unicodedata from dataclasses import asdict from difflib import SequenceMatcher from pathlib import Path, PureWindowsPath import cv2 import numpy as np from PIL import Image from scripts.bg_model import ( _inpaint, build_clean_background, build_removal_mask, build_widescreen_background, repair_masked_background, ) from scripts.fg_extract import ( _build_text_ink_mask, _remove_border_connected, export_visual_components, repair_exported_component_text, ) from scripts.lama_inpaint import ( inpaint_large_mask, inpaint_large_mask_isolated, release_model, ) from scripts.object_detect import ( ObjectProposal, create_object_detector, filter_text_overlapping_proposals, generate_object_proposals, ) from scripts.text_detect import close_ocr_engines, detect_text, detect_text_batch from scripts import text_detect as text_detection from scripts.initial_diagnostics import ( MAX_INITIAL_DIAGNOSTICS, validate_initial_diagnostics, ) from scripts.visual_segment import ( MaskCandidate, VisualSegmentationError, background_residual_metrics, complete_initial_visual_element_masks, combine_residual_candidates, create_sam_generator, filter_prompt_free_candidates, generate_flat_color_candidates, generate_geometry_candidates, generate_mask_candidates, generate_prompted_mask_candidates, recheck_visual_element_holes, has_background_residual, needs_text_only_fallback, require_visual_quality, resolve_sam_checkpoint, resolve_visual_elements, validate_visual_masks, visual_difference, write_segmentation_diagnostics, ) from scripts.worker_resources import run_isolated_worker from scripts.sam_worker import ( sam_candidate_batch_output_supported, sam_candidate_batch_max_automatic_candidates, sam_candidate_batch_max_prompted_candidates, sam_candidate_batch_max_proposals, sam_candidate_batch_result_max_bytes, ) try: from image2editable.page_routing import ( PagePolicy, PageSignals, classify_page, strict_page_policy, ) from image2editable.worker_pool import JsonLineWorker, TaskWorkerPool except ModuleNotFoundError as error: if error.name not in { "image2editable", "image2editable.page_routing", "image2editable.worker_pool", }: raise from scripts.page_routing import ( PagePolicy, PageSignals, classify_page, strict_page_policy, ) from scripts.worker_pool import JsonLineWorker, TaskWorkerPool logger = logging.getLogger(__name__) def _worker_script_path(name: str) -> Path: module_dir = Path(__file__).resolve().parent worker_path = module_dir / "scripts" / name if worker_path.is_file(): return worker_path return module_dir / name def create_ocr_worker_pool() -> TaskWorkerPool: """Create the one task-scoped resident PaddleOCR worker.""" worker_path = _worker_script_path("ocr_worker.py") return TaskWorkerPool( lambda: JsonLineWorker([sys.executable, str(worker_path), "--serve"]), queue_limit=1, worker_name="ocr", ) def create_visual_worker_pool() -> TaskWorkerPool: """Create the one task-scoped resident DINO/SAM/LaMa worker.""" worker_path = _worker_script_path("visual_worker.py") return TaskWorkerPool( lambda: JsonLineWorker([sys.executable, str(worker_path), "--serve"]), queue_limit=1, worker_name="visual", ) def assemble_pptx(*args, **kwargs): from scripts.ppt_assemble import assemble_pptx as writer return writer(*args, **kwargs) def assemble_pptx_multi(*args, **kwargs): from scripts.ppt_assemble import assemble_pptx_multi as writer return writer(*args, **kwargs) IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp"} _TARGETED_OCR_VIEW_SCALES = (2.0, 3.0) _TARGETED_OCR_VIEW_EDGE_LIMITS = (512, 448) _TARGETED_OCR_MIN_CONFIDENCE = 0.88 _TARGETED_OCR_MAX_CANDIDATES = 24 _TARGETED_OCR_SINGLE_CROP_PIXELS = 512 * 512 _TARGETED_OCR_TOTAL_CROP_PIXELS = 6 * 1024 * 1024 _TARGETED_OCR_MAX_ITEMS_PER_VIEW = 32 _TEXT_DELTA_CACHE_SCHEMA_VERSION = 1 _TEXT_DELTA_MAX_NODES = 4096 _TEXT_DELTA_CACHE_NAME = "first-visual-cache.json" _TEXT_DELTA_CACHE_MAX_BYTES = 8 * 1024 * 1024 _TEXT_DELTA_MAX_MASK_CROP_PIXELS = 128 * 1024 * 1024 _TEXT_DELTA_MAX_PAIRWISE_CANDIDATES = 100_000 _TEXT_DELTA_MAX_PAIRWISE_PIXELS = 128 * 1024 * 1024 _TEXT_DELTA_SAM_PROTOCOL_SHA256 = hashlib.sha256( b"sam2.1_hiera_large|candidate_batch_v1|visual_pipeline_v1" ).hexdigest() _TEXT_DELTA_DINO_PROTOCOL_SHA256 = hashlib.sha256( b"IDEA-Research/grounding-dino-tiny|visual_pipeline_v1" ).hexdigest() # --------------------------------------------------------------------------- # Core pipeline # --------------------------------------------------------------------------- def _normalized_candidate_text(value: object) -> str: normalized = unicodedata.normalize("NFKC", str(value)).casefold() return "".join(normalized.split()) def _box_intersection_ratio(left: list[int], right: list[int]) -> float: lx, ly, lw, lh = left rx, ry, rw, rh = right intersection = max(0, min(lx + lw, rx + rw) - max(lx, rx)) * max( 0, min(ly + lh, ry + rh) - max(ly, ry) ) return intersection / max(1, min(lw * lh, rw * rh)) def _matches_known_text(item: dict, known_items: list[dict]) -> bool: for known in known_items: known_box = [int(value) for value in known.get("box", [0, 0, 0, 0])] item_box = [int(value) for value in item["box"]] overlap = _box_intersection_ratio( known_box, item_box, ) known_text = _normalized_candidate_text(known.get("text", "")) item_text = item["normalized_text"] same_text = known_text == item_text contained_text = ( min(len(known_text), len(item_text)) >= 4 and (known_text in item_text or item_text in known_text) ) _, _, known_width, known_height = known_box _, _, item_width, item_height = item_box width_ratio = min(known_width, item_width) / max(1, known_width, item_width) height_ratio = min(known_height, item_height) / max(1, known_height, item_height) known_center = ( known_box[0] + known_width / 2, known_box[1] + known_height / 2, ) item_center = ( item_box[0] + item_width / 2, item_box[1] + item_height / 2, ) similar_geometry = ( width_ratio >= 0.75 and height_ratio >= 0.70 and abs(known_center[0] - item_center[0]) <= max(known_width, item_width) * 0.20 and abs(known_center[1] - item_center[1]) <= max(known_height, item_height) * 0.25 ) text_similarity = SequenceMatcher(None, known_text, item_text).ratio() length_ratio = min(len(known_text), len(item_text)) / max( 1, len(known_text), len(item_text) ) rotation = item.get("rotation") same_rotated_orientation = ( rotation in {90, 180, 270} and rotation == known.get("rotation") ) similar_text = not same_rotated_orientation and similar_geometry and ( text_similarity >= 0.88 or ( max(len(known_text), len(item_text)) >= 20 and length_ratio <= 0.75 and text_similarity >= 0.50 ) ) if len(item_text) <= len(known_text): shorter_text, longer_text = item_text, known_text else: shorter_text, longer_text = known_text, item_text same_rotated_line = ( width_ratio >= 0.75 and abs(known_center[0] - item_center[0]) <= max(known_width, item_width) * 0.25 if rotation in {90, 270} else height_ratio >= 0.70 and abs(known_center[1] - item_center[1]) <= max(known_height, item_height) * 0.25 ) rotated_long_fragment = ( same_rotated_orientation and max(len(known_text), len(item_text)) >= 20 and length_ratio <= 0.75 and ( longer_text.startswith(shorter_text) or longer_text.endswith(shorter_text) ) and same_rotated_line ) if overlap >= 0.80 and ( same_text or (contained_text and similar_geometry) or similar_text or rotated_long_fragment ): return True if overlap >= 0.50 and same_text: return True return False def _extends_known_text_with_terminal_punctuation( item: dict, known_items: list[dict], ) -> bool: item_text = item["normalized_text"] base_text = item_text.rstrip(".!?") if not base_text or base_text == item_text: return False item_box = [int(value) for value in item["box"]] for known in known_items: if _normalized_candidate_text(known.get("text", "")) != base_text: continue known_box = [int(value) for value in known.get("box", [0, 0, 0, 0])] if _box_intersection_ratio(known_box, item_box) >= 0.80: return True return False def _split_cross_style_ocr(items: list[dict]) -> list[dict]: """Keep independently styled lines when a wider OCR view joins them.""" result = [] for item in items: text, box = item.get("text", ""), item.get("box") if not text or not box or item.get("rotation") or "runs" in item: result.append(item) continue fragments = [] for other in items: part, bounds = other.get("text", ""), other.get("box") if (other is item or not part or not bounds or len(part) >= len(text) or other.get("rotation") or "runs" in other): continue start = text.find(part) if start >= 0 and _box_intersection_ratio(box, bounds) >= .8: fragments.append((start, start + len(part), other)) fragments.sort(key=lambda fragment: (fragment[2]["box"][0], -len(fragment[2]["text"]))) selected, cursor = [], 0 for start, end, other in fragments: start = text.find(other["text"], cursor) end = start + len(other["text"]) if (start < cursor or any(char.isalnum() for char in text[cursor:start]) or selected and selected[-1][2]["box"][0] + selected[-1][2]["box"][2] > other["box"][0]): continue selected.append((start, end, other)) cursor = end if (len(selected) < 2 or any(char.isalnum() for char in text[cursor:]) or any(a[2]["box"][0] + a[2]["box"][2] > b[2]["box"][0] for a, b in zip(selected, selected[1:]))): result.append(item) continue heights = [fragment[2]["box"][3] for fragment in selected] separated = any( b[2]["box"][0] - (a[2]["box"][0] + a[2]["box"][2]) > max(6, .45 * max(heights)) or a[2].get("color") and b[2].get("color") and a[2]["color"] != b[2]["color"] for a, b in zip(selected, selected[1:]) ) if min(heights) / max(1, max(heights)) >= .65 and not separated: result.append(item) continue for index, (start, end, other) in enumerate(selected): stop = selected[index + 1][0] if index + 1 < len(selected) else len(text) replacement = dict(other) replacement["text"] = text[0 if index == 0 else start:stop].strip() if replacement["text"] != other["text"]: replacement.pop("words", None) result.append(replacement) return result def _deduplicate_overlapping_text_items(items: list[dict]) -> list[dict]: """Keep the most complete OCR reading for the same spatial text line.""" kept: list[dict] = [] for item in _split_cross_style_ocr(items): text = _normalized_candidate_text(item.get("text", "")) box = item.get("box") if not text or not isinstance(box, (list, tuple)) or len(box) != 4: kept.append(item) continue duplicate_indices = [] for index, existing in enumerate(kept): existing_text = _normalized_candidate_text(existing.get("text", "")) existing_box = existing.get("box") if ( not existing_text or not isinstance(existing_box, (list, tuple)) or len(existing_box) != 4 ): continue overlap = _box_intersection_ratio( [int(value) for value in existing_box], [int(value) for value in box], ) if overlap >= 0.80 and ( text in existing_text or existing_text in text ): duplicate_indices.append(index) if not duplicate_indices: kept.append(item) continue rank = (len(text), float(item.get("confidence", 0.0))) if any( rank <= ( len(_normalized_candidate_text(kept[index].get("text", ""))), float(kept[index].get("confidence", 0.0)), ) for index in duplicate_indices ): continue kept[duplicate_indices[0]] = item for index in reversed(duplicate_indices[1:]): kept.pop(index) return kept def _targeted_candidate_ocr_sweep( image_path: str | Path, components: list[dict], known_items: list[dict], known_mask: np.ndarray, work_dir: str | Path, *, lang: str, isolated: bool, ocr_rotation: int = 0, ocr_worker_pool=None, performance_trace=None, page_id: str | None = None, ) -> dict: """Recheck bounded visual candidates without retaining page-size copies.""" source_path = Path(image_path).resolve() work_dir = Path(work_dir).resolve() mask = np.asarray(known_mask, dtype=np.uint8) with Image.open(source_path) as source: page_width, page_height = source.size if type(ocr_rotation) is not int or ocr_rotation not in {0, 90, 180, 270}: raise ValueError("ocr_rotation must be one of 0, 90, 180, or 270") if mask.shape != (page_height, page_width): raise ValueError("targeted OCR text mask must match the source image") page_pixels = page_width * page_height candidate_limit = min( _TARGETED_OCR_MAX_CANDIDATES, max(16, page_pixels // 120_000), ) total_pixel_limit = min( _TARGETED_OCR_TOTAL_CROP_PIXELS, max(_TARGETED_OCR_SINGLE_CROP_PIXELS, page_pixels * 2), ) selected = [] for index, component in enumerate(components, start=1): try: x, y, width, height = ( int(component[name]) for name in ("x", "y", "w", "h") ) alpha_area = int(component.get("area", width * height)) except (KeyError, TypeError, ValueError): continue if ( x < 0 or y < 0 or width < 8 or height < 8 or x + width > page_width or y + height > page_height or ( page_pixels >= 200_000 and width * height > page_pixels * 0.10 and width < height * 3 ) or width * height > page_pixels * 0.25 or max(width / height, height / width) > 14 or alpha_area / max(width * height, 1) < 0.04 ): continue if width * height > page_pixels * 0.10 and width >= height * 3: # Segmentation can omit punctuation at the edge of a wide heading. padding = max(8, int(round(height * 0.25))) right = min(page_width, x + width + padding) bottom = min(page_height, y + height + padding) x, y = max(0, x - padding), max(0, y - padding) width, height = right - x, bottom - y selected.append((index, [x, y, width, height])) if len(selected) >= candidate_limit: break diagnostics = [] consistent = [] used_pixels = 0 with source_path.open("rb") as source_file: source_sha256 = hashlib.file_digest(source_file, "sha256").hexdigest() exception_boundary = sys.exc_info()[1] primary_exception = None primary_traceback = None try: with tempfile.TemporaryDirectory(prefix="targeted-ocr-", dir=work_dir) as temporary: crop_root = Path(temporary) pending_views = [] with Image.open(source_path) as source: for component_index, box in selected: x, y, width, height = box ocr_width, ocr_height = ( (height, width) if ocr_rotation in {90, 270} else (width, height) ) views = [] candidate_pixels = [] for target_scale, edge_limit in zip( _TARGETED_OCR_VIEW_SCALES, _TARGETED_OCR_VIEW_EDGE_LIMITS, ): bounded_scale = min( target_scale, edge_limit / ocr_width, edge_limit / ocr_height, math.sqrt( _TARGETED_OCR_SINGLE_CROP_PIXELS / max(width * height, 1) ), ) view_width = max(1, int(round(ocr_width * bounded_scale))) view_height = max(1, int(round(ocr_height * bounded_scale))) candidate_pixels.append(view_width * view_height) views.append((bounded_scale, view_width, view_height)) if used_pixels + sum(candidate_pixels) > total_pixel_limit: break with source.crop((x, y, x + width, y + height)) as raw_crop: with raw_crop.convert("RGB") as base_crop: if ocr_rotation: transpose = { 90: Image.Transpose.ROTATE_90, 180: Image.Transpose.ROTATE_180, 270: Image.Transpose.ROTATE_270, }[ocr_rotation] ocr_crop = base_crop.transpose(transpose) else: ocr_crop = base_crop.copy() try: for view_index, (scale, view_width, view_height) in enumerate(views): crop_path = crop_root / ( f"candidate-{component_index:04d}-view-{view_index + 1}.png" ) with ocr_crop.resize( (view_width, view_height), Image.Resampling.LANCZOS ) as resized: resized.save(crop_path) used_pixels += view_width * view_height pending_views.append({ "component_index": component_index, "component_box": box, "scale": scale, "path": crop_path, }) finally: ocr_crop.close() batch_kwargs = { "lang": lang, "confidence_threshold": 0.70, "isolated": isolated, "worker_root": work_dir if isolated else None, "recover_empty": True, } if ocr_worker_pool is not None: batch_kwargs.update({ "worker_pool": ocr_worker_pool, "performance_trace": performance_trace, "page_id": page_id, }) view_results = detect_text_batch( [view["path"] for view in pending_views], **batch_kwargs, ) recognized_by_component = {} for view, (items, _) in zip(pending_views, view_results): x, y, component_width, component_height = view["component_box"] scale = view["scale"] mapped_items = [] for item in text_detection._merge_adjacent_text_items(items): raw_box = item.get("box") if not isinstance(raw_box, (list, tuple)) or len(raw_box) != 4: continue upright_box = [ int(round(raw_box[0] / scale)), int(round(raw_box[1] / scale)), max(1, int(round(raw_box[2] / scale))), max(1, int(round(raw_box[3] / scale))), ] ux, uy, uw, uh = upright_box if ocr_rotation == 90: local_box = [component_width - uy - uh, ux, uh, uw] elif ocr_rotation == 180: local_box = [ component_width - ux - uw, component_height - uy - uh, uw, uh, ] elif ocr_rotation == 270: local_box = [uy, component_height - ux - uw, uh, uw] else: local_box = upright_box mapped_box = [ max(0, x + local_box[0]), max(0, y + local_box[1]), local_box[2], local_box[3], ] mapped_box[2] = min(mapped_box[2], page_width - mapped_box[0]) mapped_box[3] = min(mapped_box[3], page_height - mapped_box[1]) normalized = _normalized_candidate_text(item.get("text", "")) confidence = float(item.get("confidence", 0.0)) if ( normalized and confidence >= _TARGETED_OCR_MIN_CONFIDENCE and _box_intersection_ratio( mapped_box, view["component_box"], ) >= 0.50 ): mapped_item = { "text": str(item.get("text", "")).strip(), "normalized_text": normalized, "confidence": confidence, "box": mapped_box, } words = text_detection._validated_words(mapped_item["text"], item.get("words")) if words: mapped_item["words"] = words if ocr_rotation: mapped_item["rotation"] = ocr_rotation mapped_items.append(mapped_item) recognized_by_component.setdefault( view["component_index"], [] ).append(sorted( mapped_items, key=lambda value: (value["box"][1], value["box"][0]), )[:_TARGETED_OCR_MAX_ITEMS_PER_VIEW]) for component_index, recognized in recognized_by_component.items(): if len(recognized) != 2: continue unmatched = set(range(len(recognized[1]))) pairs = [] for left in recognized[0]: choices = [ (index, _box_intersection_ratio( left["box"], recognized[1][index]["box"], )) for index in unmatched ] choices = [choice for choice in choices if choice[1] >= 0.50] if not choices: continue right_index = max(choices, key=lambda choice: choice[1])[0] unmatched.remove(right_index) pairs.append((left, recognized[1][right_index])) pairs.sort(key=lambda pair: ( min(pair[0]["box"][1], pair[1]["box"][1]), min(pair[0]["box"][0], pair[1]["box"][0]), )) for pair_index, (left, right) in enumerate(pairs, start=1): known_match = _matches_known_text( left, known_items ) or _matches_known_text(right, known_items) punctuation_upgrade = ( _extends_known_text_with_terminal_punctuation(left, known_items) or _extends_known_text_with_terminal_punctuation(right, known_items) ) if known_match and not punctuation_upgrade: continue same_text = left["normalized_text"] == right["normalized_text"] left_words = unicodedata.normalize( "NFKC", left["text"] ).casefold().split() right_words = unicodedata.normalize( "NFKC", right["text"] ).casefold().split() contained_text = ( min(len(left["normalized_text"]), len(right["normalized_text"])) >= 4 and ( len(left_words) < len(right_words) and left_words in ( right_words[:len(left_words)], right_words[-len(left_words):], ) or len(right_words) < len(left_words) and right_words in ( left_words[:len(right_words)], left_words[-len(right_words):], ) ) ) if same_text or contained_text: consistent.append(max( (left, right), key=lambda item: ( len(item["normalized_text"]), item["confidence"] ) )) continue left_box, right_box = left["box"], right["box"] if len(diagnostics) >= MAX_INITIAL_DIAGNOSTICS: continue diagnostics.append({ "kind": "unowned_raster_text", "source_sha256": source_sha256, "candidate_id": ( f"candidate_{component_index:04d}_{pair_index:02d}" ), "bbox": [ min(left_box[0], right_box[0]), min(left_box[1], right_box[1]), max(left_box[0] + left_box[2], right_box[0] + right_box[2]), max(left_box[1] + left_box[3], right_box[1] + right_box[3]), ], "views": [ {"normalized_text": left["normalized_text"], "confidence": left["confidence"]}, {"normalized_text": right["normalized_text"], "confidence": right["confidence"]}, ], }) except BaseException as exc: primary_exception = exc primary_traceback = exc.__traceback__ raise finally: _run_cleanup_preserving_exception( close_ocr_engines, "targeted OCR", primary_exception, primary_traceback, exception_boundary, ) recovered = [] if consistent: with Image.open(source_path) as source: for item in consistent: x, y, width, height = item["box"] left, top = max(0, x - 6), max(0, y - 6) right = min(page_width, x + width + 6) bottom = min(page_height, y + height + 6) with source.crop((left, top, right, bottom)).convert("RGB") as crop: display_box = (x - left, y - top, width, height) if ocr_rotation: transpose = { 90: Image.Transpose.ROTATE_90, 180: Image.Transpose.ROTATE_180, 270: Image.Transpose.ROTATE_270, }[ocr_rotation] with crop.transpose(transpose) as upright_crop: pixels = np.asarray(upright_crop).copy() dx, dy, dw, dh = display_box if ocr_rotation == 90: local_box = (dy, crop.width - dx - dw, dh, dw) elif ocr_rotation == 180: local_box = ( crop.width - dx - dw, crop.height - dy - dh, dw, dh, ) else: local_box = (crop.height - dy - dh, dx, dh, dw) else: pixels = np.asarray(crop).copy() local_box = display_box style = text_detection._estimate_style( pixels, local_box, reference_width=page_width, text=item["text"], ) font_size = text_detection._adjust_font_size( item["text"], style["font_size"], bbox_height=height, reference_width=page_width, ) recovered_item = { "box": item["box"], "text": item["text"], "font_size": font_size, "color": style["color"], "bold": ( False if text_detection._should_force_regular_weight( item["text"], font_size ) else style["bold"] ), "font": text_detection._select_font(item["text"], font_size), "align": 1, "confidence": item["confidence"], } if ocr_rotation: recovered_item["rotation"] = ocr_rotation if item.get("words"): recovered_item["words"] = item["words"] recovered.append(recovered_item) combined_items = [dict(item) for item in known_items] + recovered if isolated and not ocr_rotation: from scripts.text_context import refine_overlapping_text combined_items = refine_overlapping_text( source_path, _deduplicate_overlapping_text_items(combined_items), work_dir, lang=lang, worker_pool=ocr_worker_pool, performance_trace=performance_trace, page_id=page_id, ) if ocr_rotation: all_items = _deduplicate_overlapping_text_items(combined_items) else: all_items = _deduplicate_overlapping_text_items( text_detection._merge_adjacent_text_items(combined_items) ) all_items = text_detection._refine_alignment(all_items, page_width) updated_mask = text_detection._build_text_mask( (page_height, page_width), all_items, padding=6 ) return { "items": all_items, "recovered_items": recovered, "text_mask": updated_mask, "diagnostics": diagnostics, "resource_stats": { "candidate_limit": candidate_limit, "selected_candidates": len(selected), "single_crop_pixel_limit": _TARGETED_OCR_SINGLE_CROP_PIXELS, "total_crop_pixel_limit": total_pixel_limit, "processed_crop_pixels": used_pixels, }, } def _remove_owned_first_visual_assets(slide_data: dict, work_dir: Path) -> None: owned_root = Path(os.path.abspath(work_dir)) root_identity = owned_root.lstat() if _is_link_or_reparse(root_identity) or not stat.S_ISDIR(root_identity.st_mode): raise ValueError("first visual work directory identity is unsafe") groups = ( ("components", [component.get("path") for component in slide_data.get("components", [])]), ("element-masks", slide_data.get("_element_mask_paths", [])), ("semantic-masks", slide_data.get("_semantic_mask_paths", [])), ) validated = [] for directory, paths in groups: if not paths: continue owned_directory = owned_root / directory directory_status = owned_directory.lstat() if (_is_link_or_reparse(directory_status) or not stat.S_ISDIR(directory_status.st_mode)): raise ValueError("first visual owned directory identity is unsafe") for value in paths: if not value: continue path = Path(os.path.abspath(value)) try: path.relative_to(owned_root) except ValueError: raise ValueError("first visual asset is outside the work directory") if path.parent != owned_directory: raise ValueError("first visual asset is outside its owned directory") before = path.lstat() if (_is_link_or_reparse(before) or not stat.S_ISREG(before.st_mode) or before.st_nlink != 1): raise ValueError("first visual asset identity is unsafe") after = path.lstat() if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): raise RuntimeError("first visual asset identity changed") current_directory = owned_directory.lstat() if ( _is_link_or_reparse(current_directory) or not stat.S_ISDIR(current_directory.st_mode) or (directory_status.st_dev, directory_status.st_ino) != (current_directory.st_dev, current_directory.st_ino) ): raise RuntimeError("first visual owned directory identity changed") validated.append((path, before, owned_directory, directory_status)) for path, expected, owned_directory, expected_directory in validated: current_directory = owned_directory.lstat() current = path.lstat() if ( _is_link_or_reparse(current_directory) or not stat.S_ISDIR(current_directory.st_mode) or (expected_directory.st_dev, expected_directory.st_ino) != (current_directory.st_dev, current_directory.st_ino) or _is_link_or_reparse(current) or not stat.S_ISREG(current.st_mode) or current.st_nlink != 1 or (expected.st_dev, expected.st_ino) != (current.st_dev, current.st_ino) ): raise RuntimeError("first visual asset identity changed") path.unlink() def _filter_probable_icon_text_items(items: list[dict]) -> list[dict]: """Keep ambiguous compact OCR glyphs in the raster layer as icons.""" return [ item for item in items if not _is_probable_icon_text_item(item) ] def _is_probable_icon_text_item(item: dict) -> bool: box = item.get("box") text = "".join(str(item.get("text", "")).split()) confidence = item.get("confidence") if ( not isinstance(box, (list, tuple)) or len(box) != 4 or not isinstance(confidence, (int, float)) or confidence >= 0.9 or len(text) > 2 ): return False width = max(1, int(box[2])) height = max(1, int(box[3])) return 0.65 <= width / height <= 1.5 def _filter_probable_icon_text_analysis( items: list[dict], text_mask: np.ndarray, ) -> tuple[list[dict], np.ndarray]: detected = np.asarray(text_mask, dtype=np.uint8) filtered = _filter_probable_icon_text_items(items) deduplicated = _deduplicate_overlapping_text_items(filtered) if len(filtered) == len(items): return deduplicated, detected.copy() result = detected.copy() height, width = result.shape for item in items: if not _is_probable_icon_text_item(item): continue x, y, box_width, box_height = ( int(value) for value in item["box"] ) result[ max(0, y):min(height, y + box_height), max(0, x):min(width, x + box_width), ] = 0 for item in deduplicated: box = item.get("box") if not isinstance(box, (list, tuple)) or len(box) != 4: continue x, y, box_width, box_height = (int(value) for value in box) y1, y2 = max(0, y), min(height, y + box_height) x1, x2 = max(0, x), min(width, x + box_width) result[y1:y2, x1:x2] = detected[y1:y2, x1:x2] return deduplicated, result def _build_text_cleanup_mask( image: np.ndarray, text_mask: np.ndarray, text_items: list[dict], ) -> np.ndarray: """Cover raster glyphs and antialiasing without replacing whole OCR boxes.""" source = np.asarray(image, dtype=np.uint8) detected = np.asarray(text_mask, dtype=np.uint8) if detected.shape != source.shape[:2]: raise ValueError("text mask must match image") ink = _build_text_ink_mask( source, detected, text_items=text_items or None, ) extended_ink = ink.copy() for item in text_items: box = item.get("box") color = item.get("color") if ( not isinstance(box, (list, tuple)) or len(box) != 4 or not isinstance(color, str) or len(color) != 7 or not color.startswith("#") ): continue x, y, box_width, box_height = (int(value) for value in box) search_pad = max( 4, min(12, int(round(max(box_height, 1) * 0.15))), ) x1 = max(0, x - search_pad) y1 = max(0, y - search_pad) x2 = min(source.shape[1], x + box_width + search_pad) y2 = min(source.shape[0], y + box_height + search_pad) from scripts.art_text import _outlined_ink outlined = _outlined_ink(source[y1:y2, x1:x2], return_mask=True) if outlined is not None: extended_ink[y1:y2, x1:x2] |= outlined target = np.asarray( [int(color[index:index + 2], 16) for index in (1, 3, 5)], dtype=np.float32, ) region = source[y1:y2, x1:x2].astype(np.float32) border_pixels = np.concatenate( (region[0], region[-1], region[:, 0], region[:, -1]), axis=0, ) background = np.median(border_pixels, axis=0) if outlined is not None: # A low-contrast offset shadow can lie outside the dark stroke. radius = max(3, min(14, round(box_height * .12))) nearby = cv2.dilate(outlined, cv2.getStructuringElement( cv2.MORPH_ELLIPSE, (2 * radius + 1, 2 * radius + 1))) > 0 delta = background - region shadow = nearby & (delta.mean(axis=2) > 5) & (delta.min(axis=2) > -5) extended_ink[y1:y2, x1:x2][shadow] = 255 target_distance = float(np.linalg.norm(target - background)) color_axis = target - background axis_length = float(np.dot(color_axis, color_axis)) if axis_length > 0: opacity = np.sum( (region - background) * color_axis, axis=2, ) / axis_length expected = background + np.clip(opacity, 0.0, 1.0)[..., None] * color_axis corridor_error = np.linalg.norm(region - expected, axis=2) corridor_tolerance = min(28.0, max(12.0, target_distance * 0.08)) matching = ( (opacity >= 0.05) & (opacity <= 1.15) & (corridor_error <= corridor_tolerance) ).astype(np.uint8) else: matching = ( np.linalg.norm(region - target, axis=2) <= 18.0 ).astype(np.uint8) gray_region = cv2.cvtColor(region.astype(np.uint8), cv2.COLOR_RGB2GRAY) gray_threshold, _ = cv2.threshold( gray_region, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU, ) target_gray = float(np.mean(target)) background_gray = float(np.mean(background)) secondary = ( gray_region <= gray_threshold if target_gray < background_gray else gray_region > gray_threshold ) matching |= _remove_border_connected(secondary).astype(np.uint8) count, labels, stats, _ = cv2.connectedComponentsWithStats( matching, connectivity=8, ) join_radius = max(3, min(4, search_pad // 2)) seed = cv2.dilate( (ink[y1:y2, x1:x2] > 0).astype(np.uint8), np.ones( (join_radius * 2 + 1, join_radius * 2 + 1), dtype=np.uint8, ), iterations=1, ) > 0 box_support = np.zeros(matching.shape, dtype=bool) box_support[ max(0, y - y1):min(y2 - y1, y + box_height - y1), max(0, x - x1):min(x2 - x1, x + box_width - x1), ] = True for label in range(1, count): component = labels == label area = int(stats[label, cv2.CC_STAT_AREA]) inside_ratio = ( np.count_nonzero(component & box_support) / area if area else 0.0 ) boundary_glyph = ( inside_ratio >= 0.35 and stats[label, cv2.CC_STAT_WIDTH] <= max(8, box_width // 2) and stats[label, cv2.CC_STAT_HEIGHT] <= max(8, int(box_height * 1.5)) ) if np.any(component & seed) or boundary_glyph: local_extended = extended_ink[y1:y2, x1:x2] local_extended[component] = 255 ink = extended_ink cleanup = np.zeros_like(detected) if not np.any(ink): return cleanup if not text_items: return cv2.dilate( ink, np.ones((5, 5), dtype=np.uint8), iterations=1, ) height, width = detected.shape for item in text_items: if "box" not in item: continue x, y, box_width, box_height = (int(value) for value in item["box"]) font_size = item.get("font_size") if isinstance(font_size, (int, float)) and font_size > 0: radius = max(1, min(3, int(round(font_size * 0.08)))) else: radius = max(1, min(2, int(round(max(box_height, 1) * 0.05)))) search_pad = max( radius, max(4, min(12, int(round(max(box_height, 1) * 0.15)))), ) x1 = max(0, x - search_pad) y1 = max(0, y - search_pad) x2 = min(width, x + box_width + search_pad) y2 = min(height, y + box_height + search_pad) local = ink[y1:y2, x1:x2] if not np.any(local): continue local_cleanup = cv2.dilate( local, np.ones((radius * 2 + 1, radius * 2 + 1), dtype=np.uint8), iterations=1, ) local_gray = cv2.cvtColor( source[y1:y2, x1:x2], cv2.COLOR_RGB2GRAY, ) edge_map = cv2.Canny(local_gray, 24, 72) count, labels, stats, _ = cv2.connectedComponentsWithStats( (edge_map > 0).astype(np.uint8), connectivity=8, ) protected = np.zeros_like(edge_map) box_support = np.zeros_like(edge_map, dtype=bool) box_support[ max(0, y - y1):min(y2 - y1, y + box_height - y1), max(0, x - x1):min(x2 - x1, x + box_width - x1), ] = True for label in range(1, count): component_width = int(stats[label, cv2.CC_STAT_WIDTH]) component_height = int(stats[label, cv2.CC_STAT_HEIGHT]) component = labels == label component_area = int(stats[label, cv2.CC_STAT_AREA]) inside_ratio = ( np.count_nonzero(component & box_support) / component_area if component_area else 0.0 ) if ( ( component_width >= max(12, int(box_width * 0.65)) or component_height >= max(12, int(box_height * 0.8)) ) and ( min(component_width, component_height) <= 3 or inside_ratio < 0.25 ) ): protected[component] = 255 if np.any(protected): protected = cv2.dilate( protected, np.ones((3, 3), dtype=np.uint8), iterations=1, ) local_cleanup[protected > 0] = 0 cleanup[y1:y2, x1:x2] |= local_cleanup return cleanup def _repair_text_background( image: np.ndarray, cleanup_mask: np.ndarray, text_items: list[dict] | None = None, large_inpainter=None, ) -> np.ndarray: source = np.asarray(image, dtype=np.uint8) if text_items: modeled = _repair_text_with_local_planes( source, cleanup_mask, text_items, ) modeled_residual = background_residual_metrics( source, modeled, cleanup_mask, ) if not has_background_residual(modeled_residual): return modeled repaired = repair_masked_background( image, cleanup_mask, large_inpainter=large_inpainter, ) residual = background_residual_metrics( source, repaired, cleanup_mask, ) if not has_background_residual(residual): return repaired escalated_inpainter = large_inpainter or inpaint_large_mask escalated = np.asarray( escalated_inpainter(source, cleanup_mask), dtype=np.uint8, ) if escalated.shape != source.shape: raise ValueError( "escalated text inpaint output must match the source image" ) result = escalated.copy() result[np.asarray(cleanup_mask) == 0] = source[ np.asarray(cleanup_mask) == 0 ] return result def _repair_text_with_local_planes( image: np.ndarray, cleanup_mask: np.ndarray, text_items: list[dict], ) -> np.ndarray: source = np.asarray(image, dtype=np.uint8) cleanup = np.asarray(cleanup_mask) > 0 output = source.astype(np.float32).copy() height, width = cleanup.shape for item in text_items: box = item.get("box") if not isinstance(box, (list, tuple)) or len(box) != 4: continue x, y, box_width, box_height = (int(value) for value in box) padding = max(8, min(24, int(round(box_height * 0.4)))) x1 = max(0, x - padding) y1 = max(0, y - padding) x2 = min(width, x + box_width + padding) y2 = min(height, y + box_height + padding) target = cleanup[y1:y2, x1:x2] if not np.any(target): continue region = source[y1:y2, x1:x2].astype(np.float32) gray = cv2.cvtColor( region.astype(np.uint8), cv2.COLOR_RGB2GRAY, ) edges = cv2.dilate( cv2.Canny(gray, 24, 72), np.ones((3, 3), dtype=np.uint8), iterations=1, ) > 0 valid = ~target & ~edges colors = region[valid] if len(colors) < 20: continue color_median = np.median(colors, axis=0) color_spread = float( np.percentile( np.linalg.norm(colors - color_median, axis=1), 90, ) ) distance = cv2.distanceTransform( (~target).astype(np.uint8), cv2.DIST_L2, 3, ) near_background = valid & (distance <= 8) if np.count_nonzero(near_background) >= 20: background_color = np.median( region[near_background], axis=0, ) if ( float(np.max(background_color) - np.min(background_color)) >= 32 or float(np.mean(background_color)) < 210 or color_spread >= 8 ): interpolated = _interpolate_masked_region(region, target) local_output = output[y1:y2, x1:x2] local_output[target] = interpolated[target] continue median = np.median(colors, axis=0) distances = np.linalg.norm(colors - median, axis=1) color_limit = max(24.0, float(np.percentile(distances, 65))) keep = distances <= color_limit sample_y, sample_x = np.nonzero(valid) sample_y = sample_y[keep] sample_x = sample_x[keep] colors = colors[keep] if len(colors) < 20: continue region_height, region_width = target.shape sample_matrix = np.column_stack( ( np.ones(len(sample_x)), sample_x / max(1, region_width - 1), sample_y / max(1, region_height - 1), ) ) coefficients = np.linalg.lstsq( sample_matrix, colors, rcond=None, )[0] grid_y, grid_x = np.indices(target.shape) grid_matrix = np.column_stack( ( np.ones(grid_x.size), grid_x.ravel() / max(1, region_width - 1), grid_y.ravel() / max(1, region_height - 1), ) ) modeled = np.clip( grid_matrix @ coefficients, 0, 255, ).reshape(region.shape) local_output = output[y1:y2, x1:x2] local_output[target] = modeled[target] return np.clip(output, 0, 255).astype(np.uint8) def _interpolate_masked_region( region: np.ndarray, target: np.ndarray, ) -> np.ndarray: """Interpolate smooth colored backgrounds across glyph-sized holes.""" source = np.asarray(region, dtype=np.float32) target = np.asarray(target, dtype=bool) horizontal = np.full_like(source, np.nan) vertical = np.full_like(source, np.nan) for row in range(target.shape[0]): missing = np.flatnonzero(target[row]) known = np.flatnonzero(~target[row]) if len(missing) and len(known) >= 2: for channel in range(3): horizontal[row, missing, channel] = np.interp( missing, known, source[row, known, channel], ) for column in range(target.shape[1]): missing = np.flatnonzero(target[:, column]) known = np.flatnonzero(~target[:, column]) if len(missing) and len(known) >= 2: for channel in range(3): vertical[missing, column, channel] = np.interp( missing, known, source[known, column, channel], ) result = source.copy() horizontal_valid = np.isfinite(horizontal[:, :, 0]) vertical_valid = np.isfinite(vertical[:, :, 0]) target_rows = target[np.any(target, axis=1)] target_columns = target[:, np.any(target, axis=0)] horizontal_occupancy = ( float(np.mean(target_rows)) if target_rows.size else 1.0 ) vertical_occupancy = ( float(np.mean(target_columns)) if target_columns.size else 1.0 ) if horizontal_occupancy <= vertical_occupancy: primary, primary_valid = horizontal, horizontal_valid fallback, fallback_valid = vertical, vertical_valid else: primary, primary_valid = vertical, vertical_valid fallback, fallback_valid = horizontal, horizontal_valid use_primary = target & primary_valid result[use_primary] = primary[use_primary] use_fallback = target & ~primary_valid & fallback_valid result[use_fallback] = fallback[use_fallback] return result def _interpolate_text_item_boxes( image: np.ndarray, text_items: list[dict], padding: int = 4, ) -> np.ndarray: repaired = np.asarray(image, dtype=np.float32).copy() height, width = repaired.shape[:2] for item in text_items: x, y, box_width, box_height = (int(value) for value in item["box"]) x1 = max(1, x - padding) y1 = max(1, y - padding) x2 = min(width - 1, x + box_width + padding) y2 = min(height - 1, y + box_height + padding) if x1 >= x2 or y1 >= y2: continue region_width = x2 - x1 region_height = y2 - y1 horizontal_weight = np.linspace( 0.0, 1.0, region_width, dtype=np.float32 )[None, :, None] horizontal = ( repaired[y1:y2, x1 - 1][:, None] * (1.0 - horizontal_weight) + repaired[y1:y2, x2][:, None] * horizontal_weight ) vertical_weight = np.linspace( 0.0, 1.0, region_height, dtype=np.float32 )[:, None, None] vertical = ( repaired[y1 - 1, x1:x2][None] * (1.0 - vertical_weight) + repaired[y2, x1:x2][None] * vertical_weight ) repaired[y1:y2, x1:x2] = (horizontal + vertical) * 0.5 return np.clip(repaired, 0, 255).astype(np.uint8) def _compose_exported_components( clean_background: np.ndarray, components: list[dict], ) -> np.ndarray: from PIL import Image canvas = Image.fromarray(clean_background).convert("RGBA") for component in components: with Image.open(component["path"]) as component_image: layer = component_image.convert("RGBA") canvas.alpha_composite( layer, dest=(int(component["x"]), int(component["y"])), ) return np.asarray(canvas.convert("RGB")) def _persist_visual_masks( work_dir: Path, directory_name: str, masks: list[np.ndarray], ) -> list[str]: masks_dir = (work_dir / directory_name).resolve() masks_dir.mkdir(parents=True, exist_ok=True) paths = [] for index, mask in enumerate(masks): mask_path = (masks_dir / f"{index:04d}.png").resolve() Image.fromarray( (np.asarray(mask) > 0).astype(np.uint8) * 255, mode="L", ).save(mask_path) paths.append(str(mask_path)) return paths def _isolated_large_inpainter(work_dir: Path): def isolated_inpainter( image: np.ndarray, mask: np.ndarray, ) -> np.ndarray: with tempfile.TemporaryDirectory( prefix="lama-", dir=work_dir, ) as temporary_dir: temporary_path = Path(temporary_dir) input_path = temporary_path / "input.png" mask_path = temporary_path / "mask.png" output_path = temporary_path / "output.png" _save_rgb(str(input_path), image) Image.fromarray( (np.asarray(mask) > 0).astype(np.uint8) * 255, mode="L", ).save(mask_path) inpaint_large_mask_isolated( input_path, mask_path, output_path, ) return _load_rgb(output_path) return isolated_inpainter def _finalize_slide_quality( slide_data: dict, lang: str, _resource_isolation: bool = False, ) -> dict: slide_data = slide_data.copy() slide_data.pop("_prepared_schema_version", None) slide_data.pop("_semantic_mask_paths", None) slide_data.pop("_foreground_evidence_mask_path", None) work_dir = Path(slide_data.pop("_work_dir")) text_mask_path = Path(slide_data.pop("_text_mask_path")) text_clean_path_value = slide_data.pop("_text_clean_path", None) element_mask_paths = slide_data.pop("_element_mask_paths") element_masks = [] img = None clean_background = None text_mask = None visual_only = None try: img = _load_rgb(slide_data["original_image_path"]) clean_background = _load_rgb(slide_data["background_original_path"]) with Image.open(text_mask_path) as stored_text_mask: text_mask = np.asarray(stored_text_mask.convert("L")).copy() for mask_path in element_mask_paths: with Image.open(mask_path) as stored_mask: element_masks.append(np.asarray(stored_mask).copy()) quality_text_items = slide_data.get("text_items") or [] quality_text_mask = _build_text_cleanup_mask( img, text_mask, quality_text_items, ) forced_fallback_reason = None has_component_text_overlap = quality_text_items and _has_component_text_overlap( element_masks, quality_text_mask, ) overlap_reports = _component_text_overlap_reports( element_masks, quality_text_mask, ) if has_component_text_overlap else [] if has_component_text_overlap: if not overlap_reports: overlap_reports = [{ "component_id": f"component_{index:04d}", "accepted": False, "metrics": {}, "violations": ["component_text_overlap"], } for index in range(1, len(element_masks) + 1)] slide_data["component_quality_reports"] = overlap_reports failed_ids = ",".join(report["component_id"] for report in overlap_reports) raise VisualSegmentationError( f"component quality failed: component_text_overlap:{failed_ids}" ) components = slide_data["components"] visual_only = _compose_exported_components(clean_background, components) visual_only_path = work_dir / "visual-only.png" _save_rgb(str(visual_only_path), visual_only) ocr_kwargs = {} if _resource_isolation: ocr_kwargs = {"isolated": True, "worker_root": work_dir} raster_te -
image_to_psd.py 7 KB
#!/usr/bin/env python3 """Standalone image-to-PSD entry point.""" from __future__ import annotations import argparse import os from pathlib import Path import sys import tempfile from typing import Sequence skill_root = Path(__file__).resolve().parents[1] if str(skill_root) not in sys.path: sys.path.insert(0, str(skill_root)) from scripts.image_to_ppt import ( # noqa: E402 _prepare_multiple_images, _prepare_single_image, _resolve_inputs, ) from scripts.psd_assemble import ( # noqa: E402 assemble_psd, preflight_psd_runtime, ) _STANDALONE_MODEL_PATHS = { "SAM2_MODEL": "file", "LAMA_MODEL": "file", "GROUNDING_DINO_MODEL": "directory", } def convert( image_path: str | Path, output_path: str | Path | None = None, *, lang: str = "ch", _work_root: str | Path | None = None, _resource_isolation: bool = False, ) -> str: """Convert one image to a layered PSD without the product runtime.""" source = Path(image_path).resolve() target = _single_output_path(source, output_path) _require_available_outputs([target]) _preflight_standalone_runtime() prepare_kwargs = {"_work_root": _work_root} if _resource_isolation: prepare_kwargs["_resource_isolation"] = True slide, _work_dir = _prepare_single_image(source, lang, **prepare_kwargs) _assemble_and_publish([slide], [target]) return str(target) def convert_batch( image_paths: list[str | Path], output_path: str | Path | None = None, *, lang: str = "ch", _work_root: str | Path | None = None, _resource_isolation: bool = False, ) -> list[str]: """Convert multiple images to one layered PSD per image.""" sources = [Path(path).resolve() for path in image_paths] if not sources: raise ValueError("No valid images provided") targets = _batch_output_paths(sources, output_path) _require_available_outputs(targets) _preflight_standalone_runtime() prepare_kwargs = {"_work_root": _work_root} if _resource_isolation: prepare_kwargs["_resource_isolation"] = True slides = _prepare_multiple_images(sources, lang, **prepare_kwargs) if len(slides) != len(targets): raise RuntimeError("Prepared page count does not match PSD output count") _assemble_and_publish(slides, targets) return [str(path) for path in targets] def _single_output_path( source: Path, output_path: str | Path | None, ) -> Path: if output_path is None: output = source.with_suffix(".psd") _require_output_directory(output.parent) return output output = Path(output_path).resolve() if output.suffix.casefold() == ".psd": _require_output_directory(output.parent) return output _require_output_directory(output) return output / f"{source.stem}.psd" def _batch_output_paths( sources: list[Path], output_path: str | Path | None, ) -> list[Path]: output_dir = ( sources[0].parent if output_path is None else Path(output_path).resolve() ) if output_dir.suffix.casefold() == ".psd": raise ValueError("Multiple images require an output directory") _require_output_directory(output_dir) used_names: set[str] = set() outputs = [] for source in sources: suffix = 1 candidate = f"{source.stem}.psd" while candidate.casefold() in used_names: suffix += 1 candidate = f"{source.stem}_{suffix}.psd" used_names.add(candidate.casefold()) outputs.append((output_dir / candidate).resolve()) return outputs def _require_output_directory(path: Path) -> None: current = path while not os.path.lexists(current): parent = current.parent if parent == current: break current = parent if not current.is_dir(): raise NotADirectoryError( f"PSD output directory is not a directory: {current}" ) def _require_available_outputs(outputs: list[Path]) -> None: for output in outputs: if os.path.lexists(output): raise FileExistsError(f"PSD output already exists: {output}") def _preflight_standalone_runtime() -> None: preflight_psd_runtime() for env_name, expected_type in _STANDALONE_MODEL_PATHS.items(): raw_path = os.environ.get(env_name, "") path = Path(raw_path).expanduser() if not raw_path or not path.is_absolute(): raise RuntimeError(f"{env_name} must be an absolute local path") valid = path.is_file() if expected_type == "file" else path.is_dir() if not valid: raise RuntimeError(f"{env_name} must point to a local {expected_type}") def _assemble_and_publish(slides: list[dict], outputs: list[Path]) -> None: output_dir = outputs[0].parent output_dir.mkdir(parents=True, exist_ok=True) published: list[Path] = [] try: with tempfile.TemporaryDirectory( prefix=".image2editable-psd-", dir=output_dir, ) as staging_dir: staging_root = Path(staging_dir) staged_outputs = [] for index, (slide, output) in enumerate(zip(slides, outputs), start=1): staged_output = staging_root / f"{index:04d}-{output.name}" assemble_psd( background_path=slide["background_original_path"], components=slide["components"], text_items=slide["text_items"], img_width=slide["img_width"], img_height=slide["img_height"], output_path=staged_output, ) staged_outputs.append(staged_output) for staged_output, output in zip(staged_outputs, outputs): os.link(staged_output, output) published.append(output) except BaseException: for output in published: output.unlink(missing_ok=True) raise def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Convert image(s) to strictly validated layered PSD files" ) parser.add_argument( "images", nargs="+", help="Input image file(s) or directory containing images", ) parser.add_argument( "-o", "--output", default=None, help="Output PSD path for one image, or output directory for multiple images", ) parser.add_argument("--lang", default="ch", help="OCR language (default: ch)") return parser def main(argv: Sequence[str] | None = None) -> int: args = _build_parser().parse_args(argv) image_files = _resolve_inputs(args.images) if not image_files: raise SystemExit("No valid image files found") if len(image_files) == 1: convert( image_files[0], args.output, lang=args.lang, _resource_isolation=True, ) else: convert_batch( image_files, args.output, lang=args.lang, _resource_isolation=True, ) return 0 if __name__ == "__main__": raise SystemExit(main()) -
initial_diagnostics.py 3 KB
from __future__ import annotations import math import unicodedata MAX_INITIAL_DIAGNOSTICS = 96 def _normalized_text(value: str) -> str: return "".join(unicodedata.normalize("NFKC", value).casefold().split()) def validate_initial_diagnostics( diagnostics: object, *, source_sha256: str, image_size: tuple[int, int] | None = None, ) -> list[dict]: if not isinstance(diagnostics, list) or len(diagnostics) > MAX_INITIAL_DIAGNOSTICS: raise ValueError("initial diagnostics are invalid") seen_ids = set() for diagnostic in diagnostics: if not isinstance(diagnostic, dict) or set(diagnostic) != { "kind", "source_sha256", "candidate_id", "bbox", "views" }: raise ValueError("initial diagnostic fields are invalid") candidate_id = diagnostic["candidate_id"] parts = candidate_id.split("_") if isinstance(candidate_id, str) else [] valid_id = ( len(parts) in {2, 3} and parts[0] == "candidate" and len(parts[1]) == 4 and parts[1].isdigit() and int(parts[1]) > 0 and ( len(parts) == 2 or len(parts[2]) == 2 and parts[2].isdigit() and int(parts[2]) > 0 ) ) if ( diagnostic["kind"] != "unowned_raster_text" or diagnostic["source_sha256"] != source_sha256 or not valid_id or candidate_id in seen_ids ): raise ValueError("initial diagnostic identity is invalid") seen_ids.add(candidate_id) bbox = diagnostic["bbox"] if ( not isinstance(bbox, list) or len(bbox) != 4 or any(type(value) is not int for value in bbox) or bbox[0] < 0 or bbox[1] < 0 or bbox[2] <= bbox[0] or bbox[3] <= bbox[1] or image_size is not None and (bbox[2] > image_size[0] or bbox[3] > image_size[1]) ): raise ValueError("initial diagnostic bbox is invalid") views = diagnostic["views"] if not isinstance(views, list) or len(views) != 2: raise ValueError("initial diagnostic views are invalid") for view in views: if not isinstance(view, dict) or set(view) != { "normalized_text", "confidence" }: raise ValueError("initial diagnostic view fields are invalid") text = view["normalized_text"] confidence = view["confidence"] if ( not isinstance(text, str) or not 1 <= len(text) <= 256 or _normalized_text(text) != text or not isinstance(confidence, (int, float)) or isinstance(confidence, bool) or not math.isfinite(confidence) or not 0.88 <= confidence <= 1 ): raise ValueError("initial diagnostic view values are invalid") return diagnostics -
lama_inpaint.py 7.5 KB
"""LaMa adapter for repairing large masked image regions.""" from __future__ import annotations import importlib import sys from pathlib import Path import numpy as np from PIL import Image _MODULE_ROOT = str(Path(__file__).resolve().parent.parent) if not sys.path or sys.path[0] != _MODULE_ROOT: while _MODULE_ROOT in sys.path: sys.path.remove(_MODULE_ROOT) sys.path.insert(0, _MODULE_ROOT) from scripts.runtime_model_paths import ( RuntimeModelPathError, resolve_runtime_model_path, ) from scripts.worker_resources import run_isolated_worker class LargeMaskInpaintError(RuntimeError): """Raised when LaMa cannot repair a large masked region.""" _MODEL = None def _dependency_error(detail: str) -> LargeMaskInpaintError: return LargeMaskInpaintError( f"{detail} Install torch." ) def resolve_lama_checkpoint() -> Path: try: return resolve_runtime_model_path("big_lama") except RuntimeModelPathError as exc: raise LargeMaskInpaintError(str(exc)) from None def _prepare_image_and_mask(image, mask, *, device): if isinstance(image, Image.Image): image_array = np.array(image, copy=True) elif isinstance(image, np.ndarray): image_array = image.copy() else: raise TypeError("image must be a NumPy array or PIL image") if isinstance(mask, Image.Image): mask_array = np.array(mask, copy=True) elif isinstance(mask, np.ndarray): mask_array = mask.copy() else: raise TypeError("mask must be a NumPy array or PIL image") if image_array.ndim != 3 or image_array.shape[2] != 3: raise ValueError("image must be an RGB array with shape (H, W, 3)") if mask_array.ndim != 2: raise ValueError("mask must be an L array with shape (H, W)") if mask_array.shape != image_array.shape[:2]: raise ValueError("mask must match the image height and width") image_array = np.transpose( image_array.astype(np.float32) / 255, (2, 0, 1), ) mask_array = (mask_array.astype(np.float32) / 255)[None, ...] height, width = mask_array.shape[1:] padding = ((0, 0), (0, (-height) % 8), (0, (-width) % 8)) if padding[1][1] or padding[2][1]: image_array = np.pad(image_array, padding, mode="symmetric") mask_array = np.pad(mask_array, padding, mode="symmetric") torch = importlib.import_module("torch") image_tensor = torch.from_numpy(image_array).unsqueeze(0).to(device) mask_tensor = torch.from_numpy(mask_array).unsqueeze(0).to(device) mask_tensor = (mask_tensor > 0) * 1 return image_tensor, mask_tensor class _BigLama: def __init__(self, checkpoint: str | Path, device) -> None: self._torch = importlib.import_module("torch") self.device = device try: self.model = self._torch.jit.load( str(checkpoint), map_location=device, ) self.model.eval() self.model.to(device) except Exception: raise LargeMaskInpaintError( "LaMa model initialization failed." ) from None def __call__(self, image, mask) -> Image.Image: image_tensor, mask_tensor = _prepare_image_and_mask( image, mask, device=self.device, ) with self._torch.inference_mode(): try: output = self.model(image_tensor, mask_tensor) except Exception: raise LargeMaskInpaintError("LaMa inference failed.") from None try: result = output[0].permute(1, 2, 0).detach().cpu().numpy() except Exception: raise LargeMaskInpaintError( "LaMa returned invalid output." ) from None if not isinstance(result, np.ndarray) or ( result.ndim != 3 or result.shape[2] != 3 ): raise LargeMaskInpaintError("LaMa returned invalid output.") result = np.clip(result * 255, 0, 255).astype(np.uint8) return Image.fromarray(result, mode="RGB") def _create_model(): try: torch = importlib.import_module("torch") except ModuleNotFoundError as exc: raise _dependency_error("LaMa dependency is unavailable.") from exc device = torch.device("cuda" if torch.cuda.is_available() else "cpu") return _BigLama(resolve_lama_checkpoint(), device) def _get_model(): global _MODEL if _MODEL is None: try: _MODEL = _create_model() except LargeMaskInpaintError: raise except Exception as exc: raise _dependency_error("LaMa model initialization failed.") from exc return _MODEL def release_model() -> None: global _MODEL _MODEL = None def inpaint_large_mask_isolated( image_path: str | Path, mask_path: str | Path, output_path: str | Path, ) -> None: output_path = Path(output_path).resolve() completed = run_isolated_worker( [ sys.executable, str(Path(__file__).with_name("lama_worker.py").resolve()), "--image", str(Path(image_path).resolve()), "--mask", str(Path(mask_path).resolve()), "--output", str(output_path), ], capture_output=True, text=True, ) if completed.returncode != 0: detail = completed.stderr.strip() or completed.stdout.strip() raise LargeMaskInpaintError( f"Isolated LaMa worker failed: {detail}" ) if not output_path.is_file(): raise LargeMaskInpaintError( "Isolated LaMa worker did not create the output image" ) def inpaint_large_mask(image: np.ndarray, mask: np.ndarray) -> np.ndarray: """Repair a large mask with LaMa while preserving every unmasked pixel.""" source = np.asarray(image) removal = np.asarray(mask) if source.ndim != 3 or source.shape[2] != 3: raise ValueError("image must be an RGB array with shape (H, W, 3)") if removal.ndim != 2 or removal.shape != source.shape[:2]: raise ValueError("mask must match the image height and width") source = source.astype(np.uint8, copy=False) binary = (removal > 0).astype(np.uint8) * 255 model = _get_model() try: repaired = model( Image.fromarray(source, mode="RGB"), Image.fromarray(binary, mode="L"), ) repaired = np.asarray(repaired, dtype=np.uint8) except Exception as exc: raise LargeMaskInpaintError("LaMa inference failed.") from exc if repaired.ndim != 3 or repaired.shape[2] != source.shape[2]: raise LargeMaskInpaintError( f"LaMa returned shape {repaired.shape}, expected {source.shape}." ) source_height, source_width = source.shape[:2] padded_height = ((source_height + 7) // 8) * 8 padded_width = ((source_width + 7) // 8) * 8 actual_spatial = repaired.shape[:2] allowed_spatial = ( (source_height, source_width), (padded_height, padded_width), ) if actual_spatial not in allowed_spatial: raise LargeMaskInpaintError( f"LaMa returned invalid spatial shape: actual={actual_spatial}, " f"allowed={allowed_spatial}." ) repaired = repaired[:source_height, :source_width] if repaired.shape != source.shape: raise LargeMaskInpaintError( f"LaMa returned shape {repaired.shape}, expected {source.shape}." ) output = repaired.copy() output[binary == 0] = source[binary == 0] return output -
lama_worker.py 808 B
from __future__ import annotations import argparse from pathlib import Path import numpy as np from PIL import Image from lama_inpaint import inpaint_large_mask def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--image", required=True) parser.add_argument("--mask", required=True) parser.add_argument("--output", required=True) args = parser.parse_args() with Image.open(args.image) as stored_image: image = np.asarray(stored_image.convert("RGB")).copy() with Image.open(args.mask) as stored_mask: mask = np.asarray(stored_mask.convert("L")).copy() repaired = inpaint_large_mask(image, mask) Image.fromarray(repaired, mode="RGB").save(Path(args.output)) return 0 if __name__ == "__main__": raise SystemExit(main()) -
object_detect.py 11.2 KB
from __future__ import annotations import importlib import inspect from dataclasses import dataclass import numpy as np from PIL import Image from scripts.runtime_model_paths import ( RuntimeModelPathError, resolve_runtime_model_path, ) from scripts.visual_segment import VisualSegmentationError MODEL_REVISION = "a2bb814dd30d776dcf7e30523b00659f4f141c71" OBJECT_PROMPT = ( "person. portrait. player. card. panel. flag. icon. information icon. logo. badge. trophy. " "medal. wreath. table. chart. frame. border. line. decoration." ) PERSON_TOKENS = {"person", "player", "portrait"} CONTAINER_TOKENS = {"card", "panel", "chart", "table", "frame", "border"} STRONG_CONTAINER_TOKENS = {"card", "panel", "table", "frame"} @dataclass(frozen=True) class ObjectProposal: box_xyxy: tuple[float, float, float, float] score: float label: str role: str source: str crop_box: tuple[int, int, int, int] touches_crop_edge: bool = False class _LazyGroundingDino: def __init__(self, device: str | None) -> None: self.device = device self.processor = None self.model = None def _load(self) -> None: if self.model is not None: return try: model_path = resolve_runtime_model_path("grounding_dino") except RuntimeModelPathError as exc: raise VisualSegmentationError(str(exc)) from None torch = importlib.import_module("torch") transformers = importlib.import_module("transformers") self.device = self.device or ("cuda" if torch.cuda.is_available() else "cpu") try: self.processor = transformers.AutoProcessor.from_pretrained( str(model_path), revision=MODEL_REVISION, local_files_only=True, ) model = transformers.AutoModelForZeroShotObjectDetection.from_pretrained( str(model_path), revision=MODEL_REVISION, local_files_only=True, ) except Exception: raise VisualSegmentationError( "Grounding DINO local snapshot could not be loaded; run " "`image2editable models install runtime` or check GROUNDING_DINO_MODEL" ) from None self.model = model.to(self.device).eval() def detect( self, image: np.ndarray, prompt: str, box_threshold: float, text_threshold: float, ) -> list[dict]: self._load() torch = importlib.import_module("torch") pil_image = Image.fromarray(image) inputs = self.processor( images=pil_image, text=prompt, return_tensors="pt", ).to(self.device) with torch.inference_mode(): outputs = self.model(**inputs) post_process = self.processor.post_process_grounded_object_detection threshold_name = ( "box_threshold" if "box_threshold" in inspect.signature(post_process).parameters else "threshold" ) result = post_process( outputs, inputs.input_ids, **{ threshold_name: box_threshold, "text_threshold": text_threshold, "target_sizes": [(pil_image.height, pil_image.width)], }, )[0] labels = result.get("text_labels") if labels is None: labels = result.get("labels", []) if hasattr(labels, "detach"): labels = labels.detach().cpu().tolist() scores = result["scores"].detach().cpu().tolist() boxes = result["boxes"].detach().cpu().tolist() return [ { "box_xyxy": tuple(float(value) for value in box), "score": float(score), "label": str(label), } for label, score, box in zip(labels, scores, boxes, strict=True) ] def create_object_detector(device: str | None = None): detector = _LazyGroundingDino(device) detector._load() return detector def classify_object_role(label: str) -> str: tokens = _tokens(label) person = bool(tokens & PERSON_TOKENS) container = bool(tokens & CONTAINER_TOKENS) if person and container: return "mixed" if person: return "person" if container: return "container" return "object" def _tokens(label: str) -> set[str]: return {token.strip(".,").lower() for token in label.split()} def _box_area(box: tuple[float, float, float, float]) -> float: return max(0.0, box[2] - box[0]) * max(0.0, box[3] - box[1]) def _box_iou( first: tuple[float, float, float, float], second: tuple[float, float, float, float], ) -> float: x1 = max(first[0], second[0]) y1 = max(first[1], second[1]) x2 = min(first[2], second[2]) y2 = min(first[3], second[3]) intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1) return intersection / max(_box_area(first) + _box_area(second) - intersection, 1.0) def _crop_origins(length: int, crop_size: int, overlap: int) -> list[int]: if length <= crop_size: return [0] step = crop_size - overlap origins = list(range(0, length - crop_size + 1, step)) last = length - crop_size if origins[-1] != last: origins.append(last) return origins def _contains_center( container: tuple[float, float, float, float], child: tuple[float, float, float, float], ) -> bool: center_x = (child[0] + child[2]) / 2 center_y = (child[1] + child[3]) / 2 return ( container[0] <= center_x <= container[2] and container[1] <= center_y <= container[3] ) def filter_object_proposals( proposals: list[ObjectProposal], image_shape: tuple[int, int], ) -> list[ObjectProposal]: height, width = image_shape canvas_area = max(height * width, 1) eligible = [] for proposal in proposals: area = _box_area(proposal.box_xyxy) box_width = max(0.0, proposal.box_xyxy[2] - proposal.box_xyxy[0]) box_height = max(0.0, proposal.box_xyxy[3] - proposal.box_xyxy[1]) crop_area = max( (proposal.crop_box[2] - proposal.crop_box[0]) * (proposal.crop_box[3] - proposal.crop_box[1]), 1, ) if area / canvas_area >= 0.85: continue if "portrait" in _tokens(proposal.label) and area / crop_area >= 0.80: continue if ( _tokens(proposal.label) == {"information", "icon"} and min(box_width, box_height) / max(box_width, box_height, 1.0) < 0.85 ): continue eligible.append(proposal) retained = [] for proposal in sorted( eligible, key=lambda item: (item.touches_crop_edge, -item.score), ): duplicate = False for kept in retained: if proposal.role != kept.role: continue if ( proposal.role == "object" and not (_tokens(proposal.label) & _tokens(kept.label)) ): continue smaller = min(_box_area(proposal.box_xyxy), _box_area(kept.box_xyxy)) larger = max(_box_area(proposal.box_xyxy), _box_area(kept.box_xyxy), 1.0) if ( smaller / larger >= 0.85 and _box_iou(proposal.box_xyxy, kept.box_xyxy) >= 0.75 ): duplicate = True break if not duplicate: retained.append(proposal) people = [proposal for proposal in retained if proposal.role == "person"] result = [] for proposal in retained: tokens = _tokens(proposal.label) if proposal.role not in {"container", "mixed"} or tokens & STRONG_CONTAINER_TOKENS: result.append(proposal) continue contained = [ person for person in people if _contains_center(proposal.box_xyxy, person.box_xyxy) ] spans_two_people = any( _box_iou(first.box_xyxy, second.box_xyxy) <= 0.05 for index, first in enumerate(contained) for second in contained[index + 1 :] ) if not spans_two_people: result.append(proposal) return sorted(result, key=lambda item: (item.box_xyxy[1], item.box_xyxy[0], -item.score)) def filter_text_overlapping_proposals( proposals: list[ObjectProposal], text_mask: np.ndarray, max_text_fraction: float = 0.50, ) -> list[ObjectProposal]: """Drop object proposals that are predominantly OCR text boxes.""" height, width = text_mask.shape retained = [] for proposal in proposals: if proposal.role != "object": retained.append(proposal) continue x1 = max(0, int(np.floor(proposal.box_xyxy[0]))) y1 = max(0, int(np.floor(proposal.box_xyxy[1]))) x2 = min(width, int(np.ceil(proposal.box_xyxy[2]))) y2 = min(height, int(np.ceil(proposal.box_xyxy[3]))) area = max((x2 - x1) * (y2 - y1), 1) text_fraction = int(np.count_nonzero(text_mask[y1:y2, x1:x2])) / area if text_fraction <= max_text_fraction: retained.append(proposal) return retained def generate_object_proposals( image: np.ndarray, detector, crop_size: int = 768, overlap: int = 128, box_threshold: float = 0.18, text_threshold: float = 0.15, prompt: str = OBJECT_PROMPT, ) -> list[ObjectProposal]: if crop_size <= 0 or overlap < 0 or overlap >= crop_size: raise ValueError("crop_size must be > 0 and 0 <= overlap < crop_size") height, width = image.shape[:2] crop_width = min(crop_size, width) crop_height = min(crop_size, height) crop_boxes = [(0, 0, width, height)] for y in _crop_origins(height, crop_height, overlap): for x in _crop_origins(width, crop_width, overlap): box = (x, y, x + crop_width, y + crop_height) if box not in crop_boxes: crop_boxes.append(box) proposals = [] for index, crop_box in enumerate(crop_boxes): x1, y1, x2, y2 = crop_box crop = image[y1:y2, x1:x2] source = "full" if index == 0 else f"tile_{index}" for record in detector.detect( crop, prompt, box_threshold, text_threshold, ): local = tuple(float(value) for value in record["box_xyxy"]) global_box = ( local[0] + x1, local[1] + y1, local[2] + x1, local[3] + y1, ) touches_crop_edge = bool( (x1 > 0 and local[0] <= 1) or (x2 < width and local[2] >= crop.shape[1] - 1) or (y1 > 0 and local[1] <= 1) or (y2 < height and local[3] >= crop.shape[0] - 1) ) label = str(record["label"]) proposals.append( ObjectProposal( box_xyxy=global_box, score=float(record["score"]), label=label, role=classify_object_role(label), source=source, crop_box=crop_box, touches_crop_edge=touches_crop_edge, ) ) return filter_object_proposals(proposals, (height, width)) -
object_worker.py 1.7 KB
from __future__ import annotations import argparse from dataclasses import asdict import json import os from pathlib import Path import sys import numpy as np from PIL import Image def _load_object_tools(): script_dir = Path(__file__).resolve().parent sys.path.insert(0, str(script_dir.parent)) from scripts.object_detect import ( create_object_detector, filter_text_overlapping_proposals, generate_object_proposals, ) return ( create_object_detector, filter_text_overlapping_proposals, generate_object_proposals, ) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--image", required=True) parser.add_argument("--text-mask", required=True) parser.add_argument("--result", required=True) args = parser.parse_args() with Image.open(args.image) as stored_image: image = np.asarray(stored_image.convert("RGB")).copy() with Image.open(args.text_mask) as stored_mask: text_mask = np.asarray(stored_mask.convert("L")).copy() create_detector, filter_proposals, generate_proposals = _load_object_tools() detector = create_detector() proposals = filter_proposals( generate_proposals(image, detector), text_mask, ) result_path = Path(args.result) temporary_path = result_path.with_name(f".{result_path.name}.tmp") temporary_path.write_text( json.dumps( [asdict(proposal) for proposal in proposals], ensure_ascii=False, ), encoding="utf-8", ) os.replace(temporary_path, result_path) return 0 if __name__ == "__main__": raise SystemExit(main()) -
ocr_worker.py 19.3 KB
from __future__ import annotations import os def _cpu_threads() -> int: for name in ("FLAGS_paddle_num_threads", "OMP_NUM_THREADS"): try: value = int(os.environ.get(name, "")) except ValueError: continue if 1 <= value <= 8: return value return min(8, max(1, (os.cpu_count() or 1) // 2)) os.environ.setdefault("OMP_NUM_THREADS", str(_cpu_threads())) os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True" import argparse from contextlib import redirect_stdout import json from pathlib import Path import sys import cv2 import numpy as np def _load_detection_tools(): from paddleocr import TextDetection from paddlex.inference.pipelines.components import ( CropByPolys, SortQuadBoxes, ) return TextDetection, SortQuadBoxes, CropByPolys def _load_recognition_model(): from paddleocr import TextRecognition return TextRecognition def _resolve_recognition_model_name(lang: str) -> str: from paddleocr import PaddleOCR _, model_name = PaddleOCR._get_ocr_model_names(None, lang, None) if model_name is None: raise ValueError(f"No PaddleOCR recognition model for language: {lang}") return model_name def _value(result: object, name: str, default: object) -> object: if isinstance(result, dict): return result.get(name, default) return getattr(result, name, default) def _read_bgr(path: Path) -> np.ndarray: image = cv2.imdecode( np.fromfile(path, dtype=np.uint8), cv2.IMREAD_COLOR, ) if image is None: raise RuntimeError(f"Cannot read OCR image: {path}") return image def _validated_words(text: str, words: object) -> list[dict]: if not isinstance(words, list) or not words: return [] cleaned = [] for word in words: if not isinstance(word, dict) or not isinstance(word.get("text"), str): return [] try: box = np.asarray(word.get("box"), dtype=float) except (TypeError, ValueError): return [] if box.shape != (4,) or not np.isfinite(box).all(): return [] x, y, width, height = box.tolist() if width <= 0 or height <= 0 or min(x, y) < -0.01 or max(x + width, y + height) > 1.01: return [] x1, y1 = max(0.0, x), max(0.0, y) x2, y2 = min(1.0, x + width), min(1.0, y + height) if x2 <= x1 or y2 <= y1: return [] cleaned.append({"text": word["text"], "box": [x1, y1, x2 - x1, y2 - y1]}) if "".join("".join(word["text"].split()) for word in cleaned) != "".join(text.split()): return [] return cleaned def _words_from_polys(text: str, tokens: object, regions: object, box: object) -> list[dict]: if tokens is None or regions is None or len(tokens) != len(regions): return [] x, y, width, height = box if width <= 0 or height <= 0: return [] words = [] for token, region in zip(tokens, regions): points = np.asarray(region, dtype=float) if points.shape != (4, 2) or not np.isfinite(points).all(): return [] low, high = points.min(axis=0), points.max(axis=0) words.append({"text": token, "box": [(low[0] - x) / width, (low[1] - y) / height, (high[0] - low[0]) / width, (high[1] - low[1]) / height]}) axis = 1 if height / width >= 1.5 else 0 centers = [word["box"][axis] + word["box"][axis + 2] / 2 for word in words] if any(a > b + 1e-6 for a, b in zip(centers, centers[1:])): return [] return _validated_words(text, words) def _recognition_item(result: object, poly: object) -> dict: rec_text = _value(result, "rec_text", "") word_info = None if isinstance(rec_text, (tuple, list)) and len(rec_text) == 2: rec_text, word_info = rec_text item = {"text": str(rec_text), "score": float(_value(result, "rec_score", 0.0))} if word_info is None: return item from paddlex.inference.pipelines.components import cal_ocr_word_box columns, groups, positions, states = word_info if not columns or len(groups) != len(positions) or len(groups) != len(states): return item decoded_positions = [position for group in positions for position in group] if (not decoded_positions or min(decoded_positions) < 0 or max(decoded_positions) >= columns or any(a >= b for a, b in zip(decoded_positions, decoded_positions[1:]))): return item tokens, regions = [], [] # The Paddle helper sorts boxes separately from text. Calculate each decoded # group on a horizontal crop before projecting it back to the source quad. crop_quad = np.asarray([[0, 0], [1000, 0], [1000, 100], [0, 100]], dtype=np.float32) for group, position, state in zip(groups, positions, states): if not group or len(group) != len(position) or any(a >= b for a, b in zip(position, position[1:])): return item group_tokens, group_regions = cal_ocr_word_box( item["text"], crop_quad, [columns, [group], [position], [state]], ) tokens.extend(group_tokens) regions.extend(group_regions) points = sorted(cv2.boxPoints(cv2.minAreaRect(np.asarray(poly, dtype=np.int32))), key=lambda point: point[0]) left = sorted(points[:2], key=lambda point: point[1]) right = sorted(points[2:], key=lambda point: point[1]) quad = np.asarray([left[0], right[0], right[1], left[1]], dtype=np.float32) width = max(np.linalg.norm(quad[0] - quad[1]), np.linalg.norm(quad[2] - quad[3])) height = max(np.linalg.norm(quad[0] - quad[3]), np.linalg.norm(quad[1] - quad[2])) if width <= 0 or height <= 0 or not regions: return item if height / width >= 1.5: quad = quad[[1, 2, 3, 0]] transform = cv2.getPerspectiveTransform(crop_quad, quad) projected = cv2.perspectiveTransform(np.asarray(regions, dtype=np.float32).reshape(-1, 1, 2), transform).reshape(-1, 4, 2) low, high = np.asarray(poly).min(axis=0), np.asarray(poly).max(axis=0) box = [int(low[0]), int(low[1]), int(high[0] - low[0]), int(high[1] - low[1])] words = _words_from_polys(item["text"], tokens, projected, box) if words: item["words"] = words return item def _write_image(path: Path, image: np.ndarray) -> None: success, encoded = cv2.imencode(".png", image) if not success: raise RuntimeError(f"Cannot encode OCR crop: {path}") encoded.tofile(path) def _write_json(path: Path, value: object) -> None: temporary = path.with_name(f".{path.name}.tmp") temporary.write_text( json.dumps(value, ensure_ascii=False), encoding="utf-8", ) os.replace(temporary, path) class _ResidentOcrProcessor: """Keep PaddleOCR detector and recognizers alive for one task.""" def __init__(self) -> None: self._detector = None self._sorter_type = None self._cropper_type = None self._recognizers: dict[str, object] = {} def detection_tools(self): if self._detector is None: detector_type, self._sorter_type, self._cropper_type = ( _load_detection_tools() ) self._detector = detector_type( model_name="PP-OCRv5_mobile_det", cpu_threads=_cpu_threads(), enable_mkldnn=False, limit_side_len=64, limit_type="min", thresh=0.3, box_thresh=0.6, unclip_ratio=1.5, ) return self._detector, self._sorter_type, self._cropper_type def recognizer(self, lang: str): recognizer = self._recognizers.get(lang) if recognizer is None: recognizer_type = _load_recognition_model() recognizer = recognizer_type( model_name=_resolve_recognition_model_name(lang), cpu_threads=_cpu_threads(), enable_mkldnn=False, ) self._recognizers[lang] = recognizer return recognizer def close(self) -> None: if self._detector is not None: self._detector.close() self._detector = None for recognizer in self._recognizers.values(): recognizer.close() self._recognizers.clear() def run_detection( image_path: str | Path, work_dir: str | Path, result_path: str | Path, *, recover_empty: bool = False, ) -> None: image_path = Path(image_path) work_dir = Path(work_dir) result_path = Path(result_path) detector_type, sorter_type, cropper_type = _load_detection_tools() detector = detector_type( model_name="PP-OCRv5_mobile_det", cpu_threads=_cpu_threads(), enable_mkldnn=False, limit_side_len=64, limit_type="min", thresh=0.3, box_thresh=0.6, unclip_ratio=1.5, ) try: polys, recovered = _detect_polys(detector, image_path, recover_empty) finally: detector.close() polys = list(sorter_type()(polys)) image = _read_bgr(image_path) crops = cropper_type(det_box_type="quad")( image, polys, ) saved_polys = [] crop_paths = [] for index, (crop, poly) in enumerate(zip(crops, polys)): if crop.size == 0 or crop.shape[0] == 0 or crop.shape[1] == 0: continue crop_path = (work_dir / f"crop-{index:04d}.png").resolve() _write_image(crop_path, crop) saved_polys.append(np.asarray(poly).tolist()) crop_paths.append(str(crop_path)) _write_json( result_path, {"polys": saved_polys, "crops": crop_paths, **({"recovered": True} if recovered else {})}, ) def run_recognition( detection_result: str | Path, result_path: str | Path, lang: str = "ch", ) -> None: result_path = Path(result_path) detection = json.loads( Path(detection_result).read_text(encoding="utf-8") ) polys = detection["polys"] crops = [_read_bgr(Path(path)) for path in detection["crops"]] if len(polys) != len(crops): raise RuntimeError("OCR detection crop count does not match polygons") if not crops: _write_json(result_path, {"items": []}) return order = sorted( range(len(crops)), key=lambda index: crops[index].shape[1] / crops[index].shape[0], ) recognizer_type = _load_recognition_model() recognizer = recognizer_type( model_name=_resolve_recognition_model_name(lang), cpu_threads=_cpu_threads(), enable_mkldnn=False, ) try: results = list(recognizer.predict([crops[index] for index in order], return_word_box=True)) finally: recognizer.close() if len(results) != len(order): raise RuntimeError("OCR recognition result count does not match crops") mapped = [None] * len(order) for index, result in zip(order, results): mapped[index] = { "poly": polys[index], **_recognition_item(result, polys[index]), } if detection.get("recovered"): mapped = [item for item in mapped if item["score"] >= 0.9] _write_json(result_path, {"items": mapped}) def _detect_polys(detector, image_path: Path, recover_empty: bool): results = list(detector.predict(str(image_path), max_side_limit=4000)) polys = _value(results[0], "dt_polys", []) if results else [] recovered = recover_empty and len(polys) == 0 if recovered: results = list(detector.predict( str(image_path), max_side_limit=4000, thresh=0.15, box_thresh=0.3, )) polys = _value(results[0], "dt_polys", []) if results else [] return polys, recovered def run_batch( image_paths: list[str | Path], result_path: str | Path, lang: str = "ch", *, processor: _ResidentOcrProcessor | None = None, recover_empty: bool = False, recognition_only: bool = False, ) -> None: if recognition_only: detector = None elif processor is None: detector_type, sorter_type, cropper_type = _load_detection_tools() detector = detector_type( model_name="PP-OCRv5_mobile_det", cpu_threads=_cpu_threads(), enable_mkldnn=False, limit_side_len=64, limit_type="min", thresh=0.3, box_thresh=0.6, unclip_ratio=1.5, ) else: detector, sorter_type, cropper_type = processor.detection_tools() records = [] all_crops = [] try: for image_path in map(Path, image_paths): if recognition_only: image = _read_bgr(image_path) height, width = image.shape[:2] polys = [[[0, 0], [width, 0], [width, height], [0, height]]] crops, recovered = [image], False else: polys, recovered = _detect_polys(detector, image_path, recover_empty) polys = list(sorter_type()(polys)) crops = cropper_type(det_box_type="quad")( _read_bgr(image_path), polys, ) kept_polys = [] crop_indices = [] for crop, poly in zip(crops, polys): if crop.size == 0 or crop.shape[0] == 0 or crop.shape[1] == 0: continue kept_polys.append(np.asarray(poly).tolist()) crop_indices.append(len(all_crops)) all_crops.append(crop) records.append({ "path": str(image_path), "polys": kept_polys, "crop_indices": crop_indices, "recovered": recovered, }) finally: if processor is None and detector is not None: detector.close() recognized = [None] * len(all_crops) if all_crops: order = sorted( range(len(all_crops)), key=lambda index: all_crops[index].shape[1] / all_crops[index].shape[0], ) if processor is None: recognizer_type = _load_recognition_model() recognizer = recognizer_type( model_name=_resolve_recognition_model_name(lang), cpu_threads=_cpu_threads(), enable_mkldnn=False, ) else: recognizer = processor.recognizer(lang) try: for start in range(0, len(order), 64): batch = order[start:start + 64] results = list(recognizer.predict([all_crops[index] for index in batch], return_word_box=True)) if len(results) != len(batch): raise RuntimeError("OCR recognition result count does not match crops") for index, result in zip(batch, results): recognized[index] = result finally: if processor is None: recognizer.close() images = [] for record in records: items = [] for poly, crop_index in zip(record["polys"], record["crop_indices"]): item = _recognition_item(recognized[crop_index], poly) if record["recovered"] and item["score"] < 0.9: continue items.append({"poly": poly, **item}) images.append({"path": record["path"], "items": items}) _write_json(Path(result_path), {"images": images}) def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser() parser.add_argument("--serve", action="store_true") subparsers = parser.add_subparsers(dest="mode") detect = subparsers.add_parser("detect") detect.add_argument("--image", required=True) detect.add_argument("--work-dir", required=True) detect.add_argument("--result", required=True) detect.add_argument("--recover-empty", action="store_true") recognize = subparsers.add_parser("recognize") recognize.add_argument("--detection-result", required=True) recognize.add_argument("--result", required=True) recognize.add_argument("--lang", default="ch") batch = subparsers.add_parser("batch") batch.add_argument("--manifest", required=True) batch.add_argument("--result", required=True) batch.add_argument("--lang", default="ch") batch.add_argument("--recover-empty", action="store_true") batch.add_argument("--recognition-only", action="store_true") return parser def _serve() -> int: processor = _ResidentOcrProcessor() try: for line in sys.stdin: request_id = None try: envelope = json.loads(line) if envelope == {"control": "close"}: break if not isinstance(envelope, dict): raise ValueError("OCR worker envelope is invalid") request_id = envelope.get("request_id") payload = envelope.get("payload") if not isinstance(request_id, str) or not request_id: raise ValueError("OCR worker request id is invalid") if not isinstance(payload, dict) or set(payload) - {"recover_empty", "recognition_only"} != { "images", "result", "lang", }: raise ValueError("OCR worker payload is invalid") if ( not isinstance(payload["images"], list) or not payload["images"] or any(not isinstance(path, str) or not path for path in payload["images"]) or not isinstance(payload["result"], str) or not isinstance(payload["lang"], str) or type(payload.get("recover_empty", False)) is not bool or type(payload.get("recognition_only", False)) is not bool ): raise ValueError("OCR worker payload is invalid") with redirect_stdout(sys.stderr): run_batch( payload["images"], payload["result"], payload["lang"], processor=processor, **({"recover_empty": True} if payload.get("recover_empty") else {}), **({"recognition_only": True} if payload.get("recognition_only") else {}), ) response = {"request_id": request_id, "result": {}} except Exception as error: response = { "request_id": request_id, "error": {"type": type(error).__name__, "message": str(error)}, } sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n") sys.stdout.flush() finally: processor.close() return 0 def main() -> int: parser = _build_parser() args = parser.parse_args() if args.serve: return _serve() if args.mode is None: parser.error("OCR worker requires a mode or --serve") try: if args.mode == "detect": run_detection(args.image, args.work_dir, args.result, recover_empty=args.recover_empty) elif args.mode == "recognize": run_recognition(args.detection_result, args.result, args.lang) else: manifest = json.loads(Path(args.manifest).read_text(encoding="utf-8")) run_batch(manifest["images"], args.result, args.lang, recover_empty=args.recover_empty, recognition_only=args.recognition_only) except Exception as error: print(f"OCR {args.mode} worker failed: {error}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main()) -
page_routing.py 6.8 KB
"""Deterministic, content-free page routing for fast reconstruction. The router intentionally uses only bounded numeric signals. It does not call an LLM or a vision model, so deciding whether a page needs segmentation is cheap and reproducible. """ from __future__ import annotations from dataclasses import dataclass import math _SOURCE_KINDS = frozenset({"image", "pdf", "pdf_native", "pptx"}) _ROUTES = frozenset({"native", "direct", "local_refine", "strict"}) @dataclass(frozen=True) class PageSignals: source_kind: str ocr_items: int = 0 ocr_mean_confidence: float = 0.0 text_coverage: float = 0.0 regular_geometry_ratio: float = 0.0 overlap_ratio: float = 0.0 transparency_ratio: float = 0.0 edge_density: float = 0.0 scan_noise: float = 0.0 visual_regions: int = 0 @dataclass(frozen=True) class PagePolicy: route: str confidence: float reasons: tuple[str, ...] automatic_sam: bool max_residual_rounds: int hole_recheck: bool max_lama_calls: int host_agent_allowed: bool def strict_page_policy() -> PagePolicy: """Return the legacy behavior contract for callers without routing.""" return PagePolicy( route="strict", confidence=0.0, reasons=("strict_mode",), automatic_sam=True, max_residual_rounds=3, hole_recheck=True, max_lama_calls=2, host_agent_allowed=True, ) def _bounded_float(value: object, name: str) -> float: if not isinstance(value, (int, float)) or isinstance(value, bool): raise ValueError(f"{name} must be numeric") result = float(value) if not math.isfinite(result): raise ValueError(f"{name} must be finite") return min(1.0, max(0.0, result)) def _validate_signals(signals: PageSignals) -> None: if signals.source_kind not in _SOURCE_KINDS: raise ValueError(f"Unsupported page source kind: {signals.source_kind}") if type(signals.ocr_items) is not int or signals.ocr_items < 0: raise ValueError("ocr_items must be a non-negative integer") if type(signals.visual_regions) is not int or signals.visual_regions < 0: raise ValueError("visual_regions must be a non-negative integer") for name in ( "ocr_mean_confidence", "text_coverage", "regular_geometry_ratio", "overlap_ratio", "transparency_ratio", "edge_density", "scan_noise", ): _bounded_float(getattr(signals, name), name) def classify_page(signals: PageSignals) -> PagePolicy: """Select the cheapest route that has enough evidence to preserve quality.""" _validate_signals(signals) if signals.source_kind == "pdf_native": return PagePolicy( route="native", confidence=0.99, reasons=("native_pdf_objects",), automatic_sam=False, max_residual_rounds=0, hole_recheck=False, max_lama_calls=0, host_agent_allowed=False, ) # A raster page with no usable OCR is commonly an illustration/photo. OCR # recovery cannot add information here; route it through deterministic # geometry once and let the normal quality gate decide acceptance. if ( signals.source_kind == "pdf" and signals.ocr_items == 0 and signals.text_coverage < 0.01 and signals.scan_noise <= 0.30 ): return PagePolicy( route="direct", confidence=0.40, reasons=("pdf_no_ocr_visual",), automatic_sam=False, max_residual_rounds=0, hole_recheck=False, max_lama_calls=1, host_agent_allowed=False, ) confidence = 0.0 reasons: list[str] = [] if signals.ocr_items and signals.ocr_mean_confidence >= 0.90: confidence += 0.25 reasons.append("high_ocr_confidence") if signals.regular_geometry_ratio >= 0.70: confidence += 0.25 reasons.append("regular_geometry") if signals.text_coverage >= 0.05: confidence += 0.10 reasons.append("structured_text") if signals.overlap_ratio <= 0.08: confidence += 0.15 reasons.append("low_overlap") if signals.transparency_ratio <= 0.12: confidence += 0.10 reasons.append("low_transparency") if signals.edge_density <= 0.45: confidence += 0.05 if signals.scan_noise <= 0.12: confidence += 0.10 confidence = min(1.0, max(0.0, confidence)) # Raster PDFs often contain repeated OCR boxes over a clean, structured # page. Their overlap is not enough evidence to justify the full strict # repair loop; start with deterministic geometry and retain the quality gate. if ( signals.source_kind == "pdf" and signals.ocr_items >= 3 and signals.ocr_mean_confidence >= 0.85 and signals.text_coverage >= 0.03 and signals.edge_density <= 0.65 and signals.scan_noise <= 0.20 and signals.visual_regions < 128 ): return PagePolicy( route="direct", confidence=max(confidence, 0.55), reasons=tuple(reasons + ["pdf_raster_structured"]), automatic_sam=False, max_residual_rounds=0, hole_recheck=False, max_lama_calls=1, host_agent_allowed=False, ) difficult = ( signals.ocr_mean_confidence < 0.55 or signals.overlap_ratio > 0.30 or signals.transparency_ratio > 0.35 or signals.scan_noise > 0.28 or signals.edge_density > 0.75 or signals.visual_regions >= 128 ) if ( signals.ocr_items >= 8 and signals.ocr_mean_confidence >= 0.90 and signals.text_coverage >= 0.04 and signals.regular_geometry_ratio < 0.70 ): return PagePolicy( route="local_refine", confidence=max(confidence, 0.55), reasons=tuple(reasons + ["high_confidence_structured_visual"]), automatic_sam=False, max_residual_rounds=1, hole_recheck=False, max_lama_calls=1, host_agent_allowed=False, ) if difficult or confidence < 0.35: return PagePolicy( route="strict", confidence=confidence, reasons=tuple(reasons or ("low_confidence",)), automatic_sam=True, max_residual_rounds=3, hole_recheck=True, max_lama_calls=2, host_agent_allowed=False, ) if confidence >= 0.75 and signals.visual_regions < 24: return PagePolicy( route="direct", confidence=confidence, reasons=tuple(reasons), automatic_sam=False, max_residual_rounds=0, hole_recheck=False, max_lama_calls=1, host_agent_allowed=False, ) return PagePolicy( route="local_refine", confidence=confidence, reasons=tuple(reasons or ("partial_confidence",)), automatic_sam=False, max_residual_rounds=1, hole_recheck=False, max_lama_calls=1, host_agent_allowed=False, ) __all__ = ["PageSignals", "PagePolicy", "classify_page", "strict_page_policy"] -
performance_trace.py 5.6 KB
"""Content-free performance trace records for conversion workers.""" from __future__ import annotations from contextlib import contextmanager import json import logging from pathlib import Path import platform import re import time _LOGGER = logging.getLogger(__name__) _SCHEMA_VERSION = 1 _IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") _PLATFORM = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,31}$") _EVENT_FIELDS = { "span": ({"stage", "page_id", "model", "operation_count", "duration_ms"}, {"stage", "duration_ms"}), "worker": ({"stage", "page_id", "model", "operation_count", "duration_ms", "status"}, {"duration_ms", "status"}), "worker_start": ({"stage", "page_id", "model", "operation_count"}, {"stage"}), "worker_finish": ({"stage", "page_id", "model", "operation_count", "duration_ms", "status"}, {"stage", "duration_ms", "status"}), "model_load_start": ({"page_id", "model"}, {"model"}), "model_load_finish": ({"page_id", "model", "duration_ms", "status"}, {"model", "duration_ms", "status"}), "inference_start": ({"stage", "page_id", "model", "operation_count"}, {"stage"}), "inference_finish": ({"stage", "page_id", "model", "operation_count", "duration_ms", "status"}, {"stage", "duration_ms", "status"}), "agent_request_published": ({"page_id", "operation_count"}, {"page_id"}), "agent_plan_recorded": ({"page_id", "operation_count", "duration_ms", "status"}, {"page_id", "duration_ms", "status"}), "device_summary": ({"platform", "device", "cuda_available", "mps_available"}, {"platform", "device", "cuda_available", "mps_available"}), "page_summary": ( { "page_id", "route", "duration_ms", "sam_calls", "lama_calls", "worker_starts", "host_wait_ms", "token_count", }, { "page_id", "route", "duration_ms", "sam_calls", "lama_calls", "worker_starts", "host_wait_ms", "token_count", }, ), } class PerformanceTrace: def __init__(self, path: str | Path, *, clock=time.perf_counter) -> None: self.path = Path(path) self.clock = clock def event(self, event: str, **fields) -> None: _validate_event(event, fields) document = {"schema_version": _SCHEMA_VERSION, "event": event, **fields} if not self.path.parent.is_dir(): raise FileNotFoundError(self.path.parent) with self.path.open("a", encoding="utf-8", newline="\n") as target: target.write(json.dumps(document, ensure_ascii=False, separators=(",", ":"))) target.write("\n") target.flush() def span(self, stage: str, **fields): if "duration_ms" in fields: raise ValueError("span duration is recorded internally") _validate_event("span", {"stage": stage, **fields, "duration_ms": 0}) @contextmanager def timer(): started = self.clock() try: yield finally: try: self.event( "span", stage=stage, **fields, duration_ms=round((self.clock() - started) * 1000), ) except Exception: _LOGGER.warning("Performance trace recording failed", exc_info=True) return timer() def _validate_event(event: object, fields: dict) -> None: if not isinstance(event, str) or event not in _EVENT_FIELDS: raise ValueError("unknown performance event") allowed, required = _EVENT_FIELDS[event] unknown = set(fields) - allowed if unknown: raise ValueError(f"unknown performance field: {sorted(unknown)[0]}") missing = required - set(fields) if missing: raise ValueError(f"missing performance field: {sorted(missing)[0]}") for name, value in fields.items(): _validate_field(name, value) def _validate_field(name: str, value: object) -> None: if isinstance(value, (list, dict, tuple, set)): raise ValueError(f"invalid performance field: {name}") if name in {"page_id", "stage", "model", "route"}: if not isinstance(value, str) or not _IDENTIFIER.fullmatch(value): raise ValueError(f"invalid performance field: {name}") elif name == "platform": if not isinstance(value, str) or not _PLATFORM.fullmatch(value): raise ValueError("invalid performance field: platform") elif name == "status": if value not in {"success", "failed", "error"}: raise ValueError("invalid performance field: status") elif name == "device": if value not in {"cuda", "cpu", "unknown"}: raise ValueError("invalid performance field: device") elif name in {"operation_count", "duration_ms", "image_count", "total_bytes"}: if type(value) is not int or value < 0: raise ValueError(f"invalid performance field: {name}") elif name in {"cuda_available", "mps_available"} and type(value) is not bool: raise ValueError(f"invalid performance field: {name}") def device_summary(torch_module=None, *, platform_name=platform.system()) -> dict: summary = { "platform": platform_name, "device": "unknown", "cuda_available": False, "mps_available": False, } try: if torch_module is None: import torch as torch_module summary["cuda_available"] = bool(torch_module.cuda.is_available()) summary["mps_available"] = bool(torch_module.backends.mps.is_available()) except Exception: return summary summary["device"] = "cuda" if summary["cuda_available"] else "cpu" return summary -
psd_assemble.py 4.3 KB
#!/usr/bin/env python3 """PSD assembly module. Builds a layered PSD with repaired background, foreground pixel layers, and real Photoshop text layers. Text-layer creation requires a licensed Aspose.PSD runtime configured through ASPOSE_PSD_LICENSE. """ from __future__ import annotations import os from io import BytesIO from pathlib import Path from PIL import Image class AsposePsdLicenseError(RuntimeError): """Raised when PSD text-layer export cannot use a licensed Aspose.PSD.""" def preflight_psd_runtime() -> None: """Validate the licensed PSD runtime before expensive reconstruction.""" ensure_aspose_psd_license() def assemble_psd( background_path: str | Path, components: list[dict], text_items: list[dict], img_width: int, img_height: int, output_path: str | Path, ) -> str: """Assemble a layered PSD from background, foreground components, and text.""" ensure_aspose_psd_license() from aspose.psd import Color, Rectangle from aspose.psd.fileformats.psd import PsdImage output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) psd = PsdImage(int(img_width), int(img_height)) try: psd.layers = [] psd.add_layer(_make_pixel_layer(background_path, "Background")) for idx, comp in enumerate(components, start=1): layer = _make_pixel_layer(comp["path"], f"Foreground {idx:03d}") layer.left = int(comp["x"]) layer.top = int(comp["y"]) layer.right = int(comp["x"] + comp["w"]) layer.bottom = int(comp["y"] + comp["h"]) psd.add_layer(layer) for idx, item in enumerate(text_items, start=1): x, y, w, h = [int(v) for v in item["box"]] rect = Rectangle(x, y, max(w, 1), max(h, 1)) layer = psd.add_text_layer(item.get("text", ""), rect) layer.display_name = f"Text {idx:03d}" _style_text_layer(layer, item, Color) psd.save(str(output_path)) finally: psd.dispose() return str(output_path) def ensure_aspose_psd_license() -> None: license_path = os.environ.get("ASPOSE_PSD_LICENSE") if not license_path: raise AsposePsdLicenseError( "PSD export requires a licensed Aspose.PSD runtime. " "Set ASPOSE_PSD_LICENSE to your Aspose.PSD .lic file." ) path = Path(license_path).expanduser() if not path.exists(): raise AsposePsdLicenseError(f"ASPOSE_PSD_LICENSE does not exist: {path}") try: from aspose.psd import License License().set_license(str(path)) except Exception as exc: raise AsposePsdLicenseError( f"Failed to load Aspose.PSD license from {path}: {exc}" ) from exc def _make_pixel_layer(image_path: str | Path, name: str): from aspose.psd.fileformats.psd.layers import Layer with Image.open(image_path) as img: rgba = img.convert("RGBA") buffer = BytesIO() rgba.save(buffer, format="PNG") buffer.seek(0) layer = Layer(buffer) layer.display_name = name return layer def _style_text_layer(layer, item: dict, color_cls) -> None: color = color_cls.from_argb(255, *_hex_to_rgb(item.get("color", "#000000"))) font_size = float(item.get("font_size", 12)) bold = bool(item.get("bold", False)) text_data = getattr(layer, "text_data", None) if text_data is not None: try: portions = list(getattr(text_data, "items", [])) if not portions: portions = [text_data.produce_portion()] for portion in portions: style = portion.style style.fill_color = color style.font_size = font_size if hasattr(style, "faux_bold"): style.faux_bold = bold text_data.update_layer_data() return except Exception: pass try: layer.text_color = color except Exception: pass try: layer.font_size = font_size except Exception: pass def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]: hex_color = hex_color.lstrip("#") if len(hex_color) != 6: return (0, 0, 0) return ( int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16), ) -
runtime_model_paths.py 5.4 KB
from __future__ import annotations import hashlib import os import stat from pathlib import Path class RuntimeModelPathError(RuntimeError): """Raised when inference cannot resolve a verified local model.""" FILE_MODELS = { "sam2_large": ( "SAM2_MODEL", 898083611, "2647878d5dfa5098f2f8649825738a9345572bae2d4350a2468587ece47dd318", ), "big_lama": ( "LAMA_MODEL", 205803670, "7ba7aa7ac37a4d41fdbbeba3a2af7ead18058552997e3a3cd1a3b2210c9e6b4c", ), } MODEL_ENV = { "sam2_large": "SAM2_MODEL", "big_lama": "LAMA_MODEL", "grounding_dino": "GROUNDING_DINO_MODEL", } _CHUNK_SIZE = 1024 * 1024 def _is_link_or_reparse(status: os.stat_result) -> bool: reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) return stat.S_ISLNK(status.st_mode) or bool( getattr(status, "st_file_attributes", 0) & reparse ) def _absolute_override(value: str, env_name: str) -> Path: path = Path(value).expanduser() if not path.is_absolute(): raise RuntimeModelPathError(f"{env_name} must be an absolute local path") return path def _verified_file(path: Path, env_name: str, size: int, sha256: str) -> Path: try: before = path.lstat() except OSError: raise RuntimeModelPathError(f"{env_name} model file is missing") from None if ( _is_link_or_reparse(before) or not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 ): raise RuntimeModelPathError(f"{env_name} must name a regular non-link file") flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) try: descriptor = os.open(path, flags) except OSError: raise RuntimeModelPathError( f"{env_name} model file cannot be opened safely" ) from None try: opened = os.fstat(descriptor) if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino): raise RuntimeModelPathError(f"{env_name} model file identity changed") digest = hashlib.sha256() while chunk := os.read(descriptor, _CHUNK_SIZE): digest.update(chunk) after = os.fstat(descriptor) try: current = path.lstat() except OSError: raise RuntimeModelPathError( f"{env_name} model file identity changed" ) from None finally: os.close(descriptor) actual_sha256 = digest.hexdigest() expected = (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns) if ( (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns) != expected or (current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns) != expected or opened.st_nlink != 1 or after.st_nlink != 1 or current.st_nlink != 1 or opened.st_size != size or actual_sha256 != sha256 ): raise RuntimeModelPathError( f"{env_name} model file failed integrity verification " f"(expected size={size}, sha256={sha256}; " f"actual size={opened.st_size}, sha256={actual_sha256})" ) return path.resolve() def _explicit_model_path(name: str, value: str) -> Path: env_name = MODEL_ENV[name] path = _absolute_override(value, env_name) if name in FILE_MODELS: _, size, sha256 = FILE_MODELS[name] return _verified_file(path, env_name, size, sha256) # The explicit directory is an operator-trusted override; product defaults # remain bound to the strict runtime receipt resolver below. try: status = path.lstat() except OSError: raise RuntimeModelPathError(f"{env_name} snapshot directory is missing") from None if _is_link_or_reparse(status) or not stat.S_ISDIR(status.st_mode): raise RuntimeModelPathError(f"{env_name} must name a non-link directory") return path.resolve() def _product_runtime_model_path(name: str) -> Path: from image2editable.runtime_models import runtime_model_path return runtime_model_path(name) def resolve_runtime_model_path(name: str) -> Path: try: env_name = MODEL_ENV[name] except KeyError: raise RuntimeModelPathError(f"Unknown runtime model: {name}") from None override = os.environ.get(env_name) if override: try: return _explicit_model_path(name, override) except RuntimeModelPathError as error: # A rejected override may use the fully verified managed installation. try: return Path(_product_runtime_model_path(name)) except (ImportError, OSError, RuntimeError): raise error from None try: return Path(_product_runtime_model_path(name)) except ModuleNotFoundError as exc: if exc.name not in {"image2editable", "image2editable.runtime_models"}: if exc.name == "scripts.psd_assemble": raise RuntimeModelPathError( "image2editable package is incomplete or shadowed: " "scripts.psd_assemble cannot be imported; verify the current " "GitHub source installation with Python -I" ) from None raise raise RuntimeModelPathError( f"image2editable is unavailable; set {env_name} to an absolute local path" ) from None except RuntimeError as exc: raise RuntimeModelPathError(str(exc)) from None -
sam_worker.py 75.8 KB
from __future__ import annotations import argparse import base64 import errno import io import json import math import os from pathlib import Path import secrets import stat import sys import tempfile import unicodedata import numpy as np from PIL import Image _MODULE_ROOT = str(Path(__file__).resolve().parent.parent) if not sys.path or sys.path[0] != _MODULE_ROOT: while _MODULE_ROOT in sys.path: sys.path.remove(_MODULE_ROOT) sys.path.insert(0, _MODULE_ROOT) from scripts.worker_resources import run_isolated_worker def _load_tools(): from scripts.object_detect import ObjectProposal from scripts.visual_segment import ( VisualElement, create_sam_generator, generate_mask_candidates, generate_prompted_mask_candidates, recheck_visual_element_holes, resolve_sam_checkpoint, ) return ( ObjectProposal, create_sam_generator, generate_mask_candidates, generate_prompted_mask_candidates, resolve_sam_checkpoint, VisualElement, recheck_visual_element_holes, ) def _mask_record(mask, name: str = "mask") -> dict: binary = np.asarray(mask, dtype=bool) return { name: base64.b64encode(np.packbits(binary, axis=None).tobytes()).decode( "ascii" ), f"{name}_shape": list(binary.shape), } def _decode_mask(record: dict, name: str = "mask") -> np.ndarray: shape = tuple(record[f"{name}_shape"]) packed = np.frombuffer( base64.b64decode(record[name]), dtype=np.uint8, ) return ( np.unpackbits( packed, count=int(np.prod(shape)), ) .reshape(shape) .astype(bool, copy=False) ) def _decode_expected_mask(record: dict, expected_shape: tuple[int, int]) -> np.ndarray: shape = record.get("mask_shape") if ( not isinstance(shape, list) or len(shape) != 2 or any(type(value) is not int for value in shape) or tuple(shape) != expected_shape ): raise RuntimeError("SAM component worker returned the wrong mask shape") expected_bytes = (expected_shape[0] * expected_shape[1] + 7) // 8 expected_base64_length = ((expected_bytes + 2) // 3) * 4 encoded = record.get("mask") if not isinstance(encoded, str) or len(encoded) != expected_base64_length: raise RuntimeError("SAM component worker returned an invalid mask length") try: packed = base64.b64decode(encoded, validate=True) except (TypeError, ValueError) as exc: raise RuntimeError("SAM component worker returned an invalid mask") from exc if len(packed) != expected_bytes: raise RuntimeError("SAM component worker returned an invalid mask length") return np.unpackbits( np.frombuffer(packed, dtype=np.uint8), count=expected_shape[0] * expected_shape[1], ).reshape(expected_shape).astype(bool, copy=False) def _candidate_record(candidate) -> dict: return { **_mask_record(candidate.mask), "score": candidate.score, "source": candidate.source, "crop_box": ( list(candidate.crop_box) if candidate.crop_box is not None else None ), "touches_crop_edge": candidate.touches_crop_edge, "label": candidate.label, "role": candidate.role, "object_box": ( list(candidate.object_box) if candidate.object_box is not None else None ), } _BATCH_SCHEMA_VERSION = 1 _BATCH_MAX_OPERATIONS = 2 _BATCH_MAX_REQUEST_BYTES = 64 * 1024 _BATCH_MAX_INPUT_BYTES = 256 * 1024 * 1024 # generate_object_proposals defaults plus GroundingDINO Tiny's num_queries. _BATCH_DINO_CROP_SIZE = 768 _BATCH_DINO_OVERLAP = 128 _BATCH_DINO_MAX_QUERIES = 900 # create_sam_generator uses a 16x16 point grid and SAM2 defaults to 3 masks/point. _BATCH_SAM_POINTS_PER_SIDE = 16 _BATCH_SAM_MASKS_PER_POINT = 3 _BATCH_PROMPTED_MASKS_PER_PROPOSAL = 2 _BATCH_MAX_STRING_LENGTH = 256 _BATCH_JSON_ENVELOPE_BYTES = 4096 _BATCH_JSON_RECORD_OVERHEAD_BYTES = 512 # Python's default JSON integer conversion limit is 4300 decimal digits. _BATCH_JSON_NUMBER_BYTES = 4300 _COMPONENT_BATCH_FIELDS = {"component_id", "box", "positive", "negative"} _COMPONENT_BATCH_MAX_PROMPTS = 256 _COMPONENT_MAX_POINTS_PER_PROMPT = 256 _BATCH_CANDIDATE_FIELDS = { "mask", "mask_shape", "score", "source", "crop_box", "touches_crop_edge", "label", "role", "object_box", } _BATCH_PROPOSAL_FIELDS = { "box_xyxy", "score", "label", "role", "source", "crop_box", "touches_crop_edge", } class _BatchResultPublishingUnsupported(RuntimeError): pass def _batch_image_shape(image_shape: tuple[int, int]) -> tuple[int, int]: if ( not isinstance(image_shape, tuple) or len(image_shape) != 2 or any(type(value) is not int or value <= 0 for value in image_shape) ): raise ValueError("SAM candidate batch image shape is invalid") return image_shape def _batch_dino_axis_tiles(length: int) -> int: crop = min(_BATCH_DINO_CROP_SIZE, length) if length <= crop: return 1 step = _BATCH_DINO_CROP_SIZE - _BATCH_DINO_OVERLAP return (length - crop + step - 1) // step + 1 def sam_candidate_batch_max_proposals(image_shape: tuple[int, int]) -> int: height, width = _batch_image_shape(image_shape) tiled_crops = _batch_dino_axis_tiles(height) * _batch_dino_axis_tiles(width) crop_count = ( 1 if height <= _BATCH_DINO_CROP_SIZE and width <= _BATCH_DINO_CROP_SIZE else 1 + tiled_crops ) return crop_count * _BATCH_DINO_MAX_QUERIES def sam_candidate_batch_max_prompted_candidates(proposal_count: int) -> int: if type(proposal_count) is not int or proposal_count < 0: raise ValueError("SAM candidate batch proposal count is invalid") return proposal_count * _BATCH_PROMPTED_MASKS_PER_PROPOSAL def sam_candidate_batch_max_automatic_candidates() -> int: return ( _BATCH_SAM_POINTS_PER_SIDE * _BATCH_SAM_POINTS_PER_SIDE * _BATCH_SAM_MASKS_PER_POINT ) def _batch_json_record_budget( image_shape: tuple[int, int], *, string_fields: int, number_fields: int, ) -> int: height, width = _batch_image_shape(image_shape) number_bytes = max( _BATCH_JSON_NUMBER_BYTES, len(str(max(height, width))), ) return ( _BATCH_JSON_RECORD_OVERHEAD_BYTES + string_fields * _BATCH_MAX_STRING_LENGTH * 4 + number_fields * number_bytes ) def sam_candidate_batch_proposals_max_bytes(image_shape: tuple[int, int]) -> int: record_bytes = _batch_json_record_budget( image_shape, string_fields=3, number_fields=9, ) return ( _BATCH_JSON_ENVELOPE_BYTES + sam_candidate_batch_max_proposals(image_shape) * record_bytes ) def sam_candidate_batch_result_max_bytes( image_shape: tuple[int, int], proposal_count: int, ) -> int: height, width = _batch_image_shape(image_shape) maximum_proposals = sam_candidate_batch_max_proposals(image_shape) if ( type(proposal_count) is not int or proposal_count < 0 or proposal_count > maximum_proposals ): raise ValueError("SAM candidate batch proposal count exceeds its limit") packed_bytes = (height * width + 7) // 8 encoded_mask_bytes = ((packed_bytes + 2) // 3) * 4 record_bytes = encoded_mask_bytes + _batch_json_record_budget( image_shape, string_fields=3, number_fields=11, ) candidate_count = ( sam_candidate_batch_max_prompted_candidates(proposal_count) + sam_candidate_batch_max_automatic_candidates() ) return _BATCH_JSON_ENVELOPE_BYTES + candidate_count * record_bytes def _is_link_or_reparse(status: os.stat_result) -> bool: reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) return stat.S_ISLNK(status.st_mode) or bool( getattr(status, "st_file_attributes", 0) & reparse ) def _validate_batch_directory_chain(directory: Path) -> os.stat_result: current = directory chain = [] while True: chain.append(current) if current == current.parent: break current = current.parent for path in reversed(chain): try: status = path.lstat() except OSError as exc: raise ValueError(f"SAM batch directory does not exist: {path}") from exc if _is_link_or_reparse(status) or not stat.S_ISDIR(status.st_mode): raise ValueError(f"SAM batch directory is unsafe: {path}") return status def _bind_batch_regular_file(path: Path, limit: int | None, label: str) -> dict: try: status = path.lstat() except OSError as exc: raise ValueError(f"{label} does not exist") from exc if ( _is_link_or_reparse(status) or not stat.S_ISREG(status.st_mode) or status.st_nlink != 1 or (limit is not None and status.st_size > limit) ): raise ValueError(f"{label} is unsafe or exceeds its size limit") return { "path": path, "identity": (status.st_dev, status.st_ino), "size": status.st_size, "mtime_ns": status.st_mtime_ns, "limit": limit, "label": label, } def _read_batch_bound_bytes(binding: dict) -> bytes: path = binding["path"] label = binding["label"] try: status = path.lstat() except OSError as exc: raise ValueError(f"{label} changed before it was read") from exc if ( _is_link_or_reparse(status) or not stat.S_ISREG(status.st_mode) or status.st_nlink != 1 or (status.st_dev, status.st_ino) != binding["identity"] or status.st_size != binding["size"] or status.st_mtime_ns != binding["mtime_ns"] or ( binding["limit"] is not None and status.st_size > binding["limit"] ) ): raise ValueError(f"{label} changed before it was read") flags = os.O_RDONLY for name in ("O_BINARY", "O_NOINHERIT", "O_NOFOLLOW"): flags |= getattr(os, name, 0) descriptor = os.open(path, flags) try: opened = os.fstat(descriptor) if ( not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1 or (opened.st_dev, opened.st_ino) != binding["identity"] or opened.st_size != binding["size"] ): raise ValueError(f"{label} identity changed") chunks = [] total = 0 limit = binding["limit"] if limit is None: raise ValueError(f"{label} size limit was not bound") while True: chunk = os.read( descriptor, min(1024 * 1024, limit + 1 - total), ) if not chunk: break chunks.append(chunk) total += len(chunk) if total > limit: raise ValueError(f"{label} exceeds its size limit") stable = os.fstat(descriptor) if ( opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns, ) != ( stable.st_dev, stable.st_ino, stable.st_size, stable.st_mtime_ns, ): raise ValueError(f"{label} changed while it was read") try: after = path.lstat() except OSError as exc: raise ValueError(f"{label} changed while it was read") from exc if ( _is_link_or_reparse(after) or not stat.S_ISREG(after.st_mode) or after.st_nlink != 1 or (after.st_dev, after.st_ino) != binding["identity"] or after.st_size != binding["size"] or after.st_mtime_ns != binding["mtime_ns"] ): raise ValueError(f"{label} changed while it was read") return b"".join(chunks) finally: os.close(descriptor) def _reject_json_constant(value: str): raise ValueError(f"non-finite JSON number is not allowed: {value}") def _read_batch_bound_json(binding: dict): try: return json.loads( _read_batch_bound_bytes(binding).decode("utf-8"), parse_constant=_reject_json_constant, ) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise ValueError(f"{binding['label']} is invalid JSON") from exc def _batch_file(root: Path, value, field: str) -> Path: if not isinstance(value, str) or not value or Path(value).name != value: raise ValueError(f"batch {field} must be a relative file name") lexical = root / value try: lexical_status = lexical.lstat() path = lexical.resolve(strict=True) except OSError as exc: raise ValueError(f"batch {field} does not exist") from exc if _is_link_or_reparse(lexical_status): raise ValueError(f"batch {field} must not be a link or reparse point") try: path.relative_to(root) except ValueError as exc: raise ValueError(f"batch {field} must stay inside the request directory") from exc if not path.is_file(): raise ValueError(f"batch {field} does not exist") return path def _validate_batch_request(request_path: Path, result_path: Path) -> tuple[list[dict], dict]: request_path = Path(os.path.abspath(request_path)) result_path = Path(os.path.abspath(result_path)) lexical_root = request_path.parent if result_path.parent != lexical_root: raise ValueError("batch result must stay beside its request") root_status_before = _validate_batch_directory_chain(lexical_root) root = lexical_root.resolve(strict=True) if root != lexical_root: raise ValueError("SAM batch request directory must not resolve through a link") try: result_path.lstat() except FileNotFoundError: pass except OSError as exc: raise ValueError("SAM batch result path is unsafe") from exc else: raise ValueError("SAM batch result already exists") request_binding = _bind_batch_regular_file( request_path, _BATCH_MAX_REQUEST_BYTES, "SAM batch request", ) root_status = lexical_root.lstat() if ( _is_link_or_reparse(root_status) or not stat.S_ISDIR(root_status.st_mode) or (root_status.st_dev, root_status.st_ino) != (root_status_before.st_dev, root_status_before.st_ino) ): raise ValueError("SAM batch request directory changed during validation") result_binding = { "path": root / result_path.name, "parent": root, "parent_identity": (root_status.st_dev, root_status.st_ino), } if sys.platform == "win32": result_binding["parent_handle_identity"] = _windows_path_identity(root) request = _read_batch_bound_json(request_binding) if not isinstance(request, dict) or set(request) != { "schema_version", "operations", }: raise ValueError("invalid SAM batch request") if ( type(request["schema_version"]) is not int or request["schema_version"] != _BATCH_SCHEMA_VERSION ): raise ValueError("unsupported SAM batch schema version") operations = request["operations"] if not isinstance(operations, list) or len(operations) != _BATCH_MAX_OPERATIONS: raise ValueError("SAM batch must contain prompted and automatic operations") validated = [] bound_inputs = {request_binding["path"]: request_binding} expected_operations = (("prompted", "prompted"), ("automatic", "automatic")) for operation, (expected_id, expected_kind) in zip( operations, expected_operations, ): if not isinstance(operation, dict): raise ValueError("invalid SAM batch operation") operation_id = operation.get("id") kind = operation.get("kind") if (operation_id, kind) != (expected_id, expected_kind): raise ValueError("SAM batch operations must be prompted then automatic") if kind == "prompted": expected_fields = {"id", "kind", "image", "text_mask", "proposals"} elif kind == "automatic": expected_fields = {"id", "kind", "image"} else: raise ValueError(f"unsupported SAM batch operation kind: {kind}") if set(operation) != expected_fields: raise ValueError(f"invalid {kind} SAM batch operation") image_path = _batch_file(root, operation["image"], "image") image_binding = bound_inputs.get(image_path) if image_binding is None: image_binding = _bind_batch_regular_file( image_path, _BATCH_MAX_INPUT_BYTES, "SAM batch image", ) bound_inputs[image_path] = image_binding validated_operation = { "id": operation_id, "kind": kind, "image_binding": image_binding, } if kind == "prompted": text_mask_path = _batch_file( root, operation["text_mask"], "text_mask", ) proposals_path = _batch_file( root, operation["proposals"], "proposals", ) text_mask_binding = _bind_batch_regular_file( text_mask_path, _BATCH_MAX_INPUT_BYTES, "SAM batch text mask", ) proposals_binding = _bind_batch_regular_file( proposals_path, None, "SAM batch proposals", ) validated_operation["text_mask_binding"] = text_mask_binding validated_operation["proposals_binding"] = proposals_binding bound_inputs[text_mask_path] = text_mask_binding bound_inputs[proposals_path] = proposals_binding validated.append(validated_operation) if ( validated[0]["image_binding"]["identity"] != validated[1]["image_binding"]["identity"] ): raise ValueError("SAM batch operations must use the same image") if result_binding["path"] in bound_inputs: raise ValueError("SAM batch result must not alias a request input") return validated, result_binding def _validate_batch_string(value, label: str, *, allow_empty: bool = False) -> str: if ( not isinstance(value, str) or (not allow_empty and not value) or len(value) > _BATCH_MAX_STRING_LENGTH or any(unicodedata.category(character).startswith("C") for character in value) ): raise ValueError(f"invalid {label}") return value def _validate_batch_finite_number(value, label: str): if type(value) is int: return value try: finite = math.isfinite(value) except (TypeError, OverflowError): finite = False if type(value) is not float or not finite: raise ValueError(f"invalid {label}") return value def _validate_batch_probability(value, label: str): value = _validate_batch_finite_number(value, label) if not 0.0 <= value <= 1.0: raise ValueError(f"invalid {label}") return value def _validate_batch_crop_box( value, label: str, image_shape: tuple[int, int], *, allow_none: bool, ) -> tuple[int, int, int, int] | None: if value is None and allow_none: return None if ( not isinstance(value, list) or len(value) != 4 or any(type(coordinate) is not int for coordinate in value) ): raise ValueError(f"invalid {label}") x1, y1, x2, y2 = value height, width = image_shape if not (0.0 <= x1 < x2 <= width and 0.0 <= y1 < y2 <= height): raise ValueError(f"invalid {label}") return tuple(value) def _validate_batch_intersecting_box( value, label: str, image_shape: tuple[int, int], *, allow_none: bool, ) -> tuple[float, float, float, float] | None: if value is None and allow_none: return None if not isinstance(value, list) or len(value) != 4: raise ValueError(f"invalid {label}") coordinates = tuple( _validate_batch_finite_number(coordinate, label) for coordinate in value ) x1, y1, x2, y2 = coordinates height, width = image_shape if not ( x1 < x2 and y1 < y2 and x1 < width and y1 < height and x2 > 0 and y2 > 0 ): raise ValueError(f"invalid {label}") return coordinates def _validate_batch_proposals(records, image_shape: tuple[int, int]) -> list[dict]: maximum = sam_candidate_batch_max_proposals(image_shape) if not isinstance(records, list) or len(records) > maximum: raise ValueError("prompted proposal count exceeds its limit") validated = [] for record in records: if not isinstance(record, dict) or set(record) != _BATCH_PROPOSAL_FIELDS: raise ValueError("invalid prompted proposal record") if type(record["touches_crop_edge"]) is not bool: raise ValueError("invalid prompted proposal crop-edge flag") validated.append( { "box_xyxy": _validate_batch_intersecting_box( record["box_xyxy"], "prompted proposal box", image_shape, allow_none=False, ), "score": _validate_batch_probability( record["score"], "prompted proposal score", ), "label": _validate_batch_string( record["label"], "prompted proposal label", ), "role": _validate_batch_string( record["role"], "prompted proposal role", ), "source": _validate_batch_string( record["source"], "prompted proposal source", ), "crop_box": _validate_batch_crop_box( record["crop_box"], "prompted proposal crop box", image_shape, allow_none=False, ), "touches_crop_edge": record["touches_crop_edge"], } ) return validated def _load_batch_inputs(operations: list[dict]) -> None: images = {} for operation in operations: image_binding = operation["image_binding"] image_path = image_binding["path"] if image_path not in images: with Image.open( io.BytesIO(_read_batch_bound_bytes(image_binding)) ) as stored_image: images[image_path] = np.asarray(stored_image.convert("RGB")).copy() operation["image"] = images[image_path] if operation["kind"] == "prompted": with Image.open( io.BytesIO( _read_batch_bound_bytes(operation["text_mask_binding"]) ) ) as stored_mask: text_mask = np.asarray(stored_mask.convert("L")).copy() if text_mask.shape != operation["image"].shape[:2]: raise ValueError("prompted text mask shape does not match the image") proposal_binding = operation["proposals_binding"] proposal_binding["limit"] = sam_candidate_batch_proposals_max_bytes( tuple(operation["image"].shape[:2]) ) if proposal_binding["size"] > proposal_binding["limit"]: raise ValueError("SAM batch proposals exceed their size limit") proposal_records = _validate_batch_proposals( _read_batch_bound_json(proposal_binding), tuple(operation["image"].shape[:2]), ) operation["text_mask"] = text_mask operation["proposal_records"] = proposal_records def _validate_batch_output(payload: dict, operations: list[dict]) -> None: if not isinstance(payload, dict) or set(payload) != { "schema_version", "operations", }: raise RuntimeError("invalid SAM batch output") if ( type(payload["schema_version"]) is not int or payload["schema_version"] != _BATCH_SCHEMA_VERSION ): raise RuntimeError("invalid SAM batch output schema version") output_operations = payload["operations"] if not isinstance(output_operations, list) or len(output_operations) != len( operations ): raise RuntimeError("invalid SAM batch output operation count") operation_records = [] for expected, actual in zip(operations, output_operations): if not isinstance(actual, dict) or set(actual) != { "id", "kind", "candidates", }: raise RuntimeError("invalid SAM batch output operation") if (actual["id"], actual["kind"]) != ( expected["id"], expected["kind"], ): raise RuntimeError("invalid SAM batch output operation order") records = actual["candidates"] if not isinstance(records, list): raise RuntimeError("invalid SAM batch candidate records") candidate_limit = ( sam_candidate_batch_max_prompted_candidates( len(expected.get("proposal_records", [])) ) if expected["kind"] == "prompted" else sam_candidate_batch_max_automatic_candidates() ) if len(records) > candidate_limit: raise RuntimeError("SAM batch candidate count exceeds its limit") operation_records.append((expected, records)) for expected, records in operation_records: expected_shape = tuple(expected["image"].shape[:2]) expected_bytes = (int(np.prod(expected_shape)) + 7) // 8 expected_base64_length = ((expected_bytes + 2) // 3) * 4 for record in records: if not isinstance(record, dict) or set(record) != _BATCH_CANDIDATE_FIELDS: raise RuntimeError("invalid SAM batch candidate record") shape = record["mask_shape"] if ( not isinstance(shape, list) or len(shape) != 2 or any(type(value) is not int for value in shape) or tuple(shape) != expected_shape ): raise RuntimeError("SAM batch candidate mask shape does not match image") if ( not isinstance(record["mask"], str) or len(record["mask"]) != expected_base64_length ): raise RuntimeError("invalid SAM batch candidate mask length") try: packed = base64.b64decode(record["mask"], validate=True) except (TypeError, ValueError) as exc: raise RuntimeError("invalid SAM batch candidate mask") from exc if len(packed) != expected_bytes: raise RuntimeError("invalid SAM batch candidate mask length") try: _validate_batch_finite_number( record["score"], "SAM batch candidate score", ) _validate_batch_string( record["source"], "SAM batch candidate source", ) _validate_batch_crop_box( record["crop_box"], "SAM batch candidate crop box", expected_shape, allow_none=True, ) _validate_batch_string( record["label"], "SAM batch candidate label", allow_empty=True, ) _validate_batch_string( record["role"], "SAM batch candidate role", allow_empty=True, ) _validate_batch_intersecting_box( record["object_box"], "SAM batch candidate object box", expected_shape, allow_none=True, ) except ValueError as exc: raise RuntimeError("invalid SAM batch candidate metadata") from exc if type(record["touches_crop_edge"]) is not bool: raise RuntimeError("invalid SAM batch candidate crop-edge flag") def _windows_handle_identity(handle) -> tuple[int, int]: import ctypes from ctypes import wintypes class FileTime(ctypes.Structure): _fields_ = [("low", wintypes.DWORD), ("high", wintypes.DWORD)] class FileInformation(ctypes.Structure): _fields_ = [ ("attributes", wintypes.DWORD), ("creation_time", FileTime), ("access_time", FileTime), ("write_time", FileTime), ("volume_serial", wintypes.DWORD), ("size_high", wintypes.DWORD), ("size_low", wintypes.DWORD), ("links", wintypes.DWORD), ("index_high", wintypes.DWORD), ("index_low", wintypes.DWORD), ] kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.GetFileInformationByHandle.argtypes = [ wintypes.HANDLE, ctypes.POINTER(FileInformation), ] kernel32.GetFileInformationByHandle.restype = wintypes.BOOL information = FileInformation() if not kernel32.GetFileInformationByHandle(handle, ctypes.byref(information)): raise RuntimeError("SAM batch result directory changed") return ( information.volume_serial, (information.index_high << 32) | information.index_low, ) def _open_windows_directory_handle(parent: Path): import ctypes from ctypes import wintypes kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.CreateFileW.argtypes = [ wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE, ] kernel32.CreateFileW.restype = wintypes.HANDLE handle = kernel32.CreateFileW( str(parent), 0x80000000, 0x00000001 | 0x00000002 | 0x00000004, None, 3, 0x02000000, None, ) if handle == ctypes.c_void_p(-1).value: raise RuntimeError("SAM batch result directory changed") return handle def _windows_path_identity(parent: Path) -> tuple[int, int]: handle = _open_windows_directory_handle(parent) try: return _windows_handle_identity(handle) finally: _close_batch_result_parent(handle) def _open_batch_result_parent(result_binding: dict): parent = result_binding["parent"] if sys.platform == "win32": handle = _open_windows_directory_handle(parent) try: actual = _windows_handle_identity(handle) except Exception: _close_batch_result_parent(handle) raise expected = result_binding.get("parent_handle_identity") if expected is not None: matches = actual == expected else: matches = actual[1] == result_binding["parent_identity"][1] if not matches: _close_batch_result_parent(handle) raise RuntimeError("SAM batch result directory changed") return handle flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) try: descriptor = os.open(parent, flags) except OSError as exc: raise RuntimeError("SAM batch result directory changed") from exc status = os.fstat(descriptor) if (status.st_dev, status.st_ino) != result_binding["parent_identity"]: os.close(descriptor) raise RuntimeError("SAM batch result directory changed") return descriptor def _close_batch_result_parent(parent_handle) -> None: if sys.platform == "win32": import ctypes ctypes.WinDLL("kernel32").CloseHandle(parent_handle) else: os.close(parent_handle) def _windows_extended_path(path: Path) -> str: value = os.path.abspath(path) if value.startswith("\\\\?\\"): return value if value.startswith("\\\\"): return "\\\\?\\UNC\\" + value[2:] return "\\\\?\\" + value def _create_batch_result_file( result_binding: dict, parent_handle, *, delete_on_close: bool = False, ): result_path = result_binding["path"] if sys.platform == "win32": import ctypes import msvcrt from ctypes import wintypes kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.CreateFileW.argtypes = [ wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE, ] kernel32.CreateFileW.restype = wintypes.HANDLE invalid_handle = ctypes.c_void_p(-1).value last_error = None for _ in range(16): temporary_path = result_path.with_name( f".{result_path.name}.{secrets.token_hex(16)}.tmp" ) handle = kernel32.CreateFileW( _windows_extended_path(temporary_path), 0x80000000 | 0x40000000 | 0x00010000, 0x00000001 | 0x00000004, None, 1, 0x00000080 | (0x04000000 if delete_on_close else 0), None, ) if handle != invalid_handle: try: descriptor = msvcrt.open_osfhandle( handle, os.O_RDWR | getattr(os, "O_BINARY", 0), ) except Exception: kernel32.CloseHandle(handle) raise return descriptor, temporary_path, False last_error = ctypes.get_last_error() if last_error not in {80, 183}: break if last_error in {1, 50}: raise _BatchResultPublishingUnsupported( "SAM batch result publishing is unsupported" ) raise RuntimeError("SAM batch result temp could not be created") if sys.platform.startswith("linux") and hasattr(os, "O_TMPFILE"): try: descriptor = os.open( ".", os.O_RDWR | os.O_TMPFILE, 0o600, dir_fd=parent_handle, ) return descriptor, None, False except OSError as exc: raise RuntimeError( "SAM batch result publishing is unsupported on this Linux filesystem" ) from exc if sys.platform == "darwin": raise RuntimeError( "SAM batch anonymous result source is not supported on Darwin" ) raise RuntimeError("SAM batch result publishing is unsupported platform") def _publish_windows_batch_result( file_descriptor: int, parent_handle, result_name: str, ) -> None: import ctypes import msvcrt from ctypes import wintypes class IoStatusBlock(ctypes.Structure): _fields_ = [ ("status", ctypes.c_void_p), ("information", ctypes.c_size_t), ] class FileRenameInformation(ctypes.Structure): _fields_ = [ ("replace_if_exists", ctypes.c_ubyte), ("root_directory", wintypes.HANDLE), ("file_name_length", wintypes.ULONG), ("file_name", wintypes.WCHAR * 1), ] try: ntdll = ctypes.WinDLL("ntdll") set_information = ntdll.NtSetInformationFile except (AttributeError, OSError) as exc: raise _BatchResultPublishingUnsupported( "SAM batch result publishing API is unsupported" ) from exc set_information.argtypes = [ wintypes.HANDLE, ctypes.POINTER(IoStatusBlock), ctypes.c_void_p, wintypes.ULONG, ctypes.c_int, ] set_information.restype = ctypes.c_long encoded_name = result_name.encode("utf-16-le") file_name_offset = FileRenameInformation.file_name.offset information = ctypes.create_string_buffer( file_name_offset + len(encoded_name) + 2 ) header = FileRenameInformation.from_buffer(information) header.replace_if_exists = 0 header.root_directory = parent_handle header.file_name_length = len(encoded_name) ctypes.memmove( ctypes.addressof(information) + file_name_offset, encoded_name, len(encoded_name), ) io_status = IoStatusBlock() status = set_information( msvcrt.get_osfhandle(file_descriptor), ctypes.byref(io_status), information, len(information), 10, ) if status == 0: return if status & 0xFFFFFFFF == 0xC0000035: raise RuntimeError("SAM batch result path already exists") if status & 0xFFFFFFFF in {0xC0000002, 0xC0000003, 0xC0000010, 0xC00000BB}: raise _BatchResultPublishingUnsupported( "SAM batch result publishing is unsupported" ) raise RuntimeError("SAM batch result could not be published") def _delete_windows_batch_result(file_descriptor: int) -> None: import ctypes import msvcrt from ctypes import wintypes class IoStatusBlock(ctypes.Structure): _fields_ = [ ("status", ctypes.c_void_p), ("information", ctypes.c_size_t), ] try: ntdll = ctypes.WinDLL("ntdll") set_information = ntdll.NtSetInformationFile except (AttributeError, OSError) as exc: raise _BatchResultPublishingUnsupported( "SAM batch result deletion API is unsupported" ) from exc set_information.argtypes = [ wintypes.HANDLE, ctypes.POINTER(IoStatusBlock), ctypes.c_void_p, wintypes.ULONG, ctypes.c_int, ] set_information.restype = ctypes.c_long delete_file = ctypes.c_ubyte(1) io_status = IoStatusBlock() status = set_information( msvcrt.get_osfhandle(file_descriptor), ctypes.byref(io_status), ctypes.byref(delete_file), 1, 13, ) if status & 0xFFFFFFFF in {0xC0000002, 0xC0000003, 0xC0000010, 0xC00000BB}: raise _BatchResultPublishingUnsupported( "SAM batch result deletion is unsupported" ) if status != 0: raise RuntimeError("SAM batch result could not be cleaned") def _create_linux_batch_capability_directory(root: Path) -> tuple[Path, tuple[int, int]]: probe = Path( tempfile.mkdtemp( prefix=".sam-batch-capability-", dir=root, ) ) status = probe.lstat() if ( _is_link_or_reparse(status) or not stat.S_ISDIR(status.st_mode) or stat.S_IMODE(status.st_mode) != 0o700 ): error = RuntimeError("SAM batch capability directory is unsafe") try: probe.rmdir() except BaseException as exc: error.add_note(f"SAM batch capability cleanup failed: {exc}") raise error return probe, (status.st_dev, status.st_ino) def _remove_linux_batch_capability_directory( probe: Path, identity: tuple[int, int], ) -> None: status = probe.lstat() if ( _is_link_or_reparse(status) or not stat.S_ISDIR(status.st_mode) or (status.st_dev, status.st_ino) != identity ): raise RuntimeError("SAM batch capability directory changed") probe.rmdir() def _unlink_linux_batch_capability_result( file_descriptor: int, parent_handle: int, result_name: str, ) -> None: descriptor_status = os.fstat(file_descriptor) result_status = os.stat( result_name, dir_fd=parent_handle, follow_symlinks=False, ) if ( not stat.S_ISREG(result_status.st_mode) or (result_status.st_dev, result_status.st_ino) != (descriptor_status.st_dev, descriptor_status.st_ino) ): raise RuntimeError("SAM batch capability result identity changed") # The random 0700 directory is private to this probe. Same-UID malicious # replacement is outside the isolation boundary, so dirfd-relative unlink # safely removes the entry just published from this descriptor. os.unlink(result_name, dir_fd=parent_handle) if os.fstat(file_descriptor).st_nlink != 0 or os.listdir(parent_handle): raise RuntimeError("SAM batch capability result cleanup failed") def sam_candidate_batch_output_supported(work_dir: Path) -> bool: work_dir = Path(os.path.abspath(work_dir)) if sys.platform.startswith("linux"): if not hasattr(os, "O_TMPFILE"): return False root_status_before = _validate_batch_directory_chain(work_dir) root = work_dir.resolve(strict=True) root_status = root.lstat() if ( root != work_dir or _is_link_or_reparse(root_status) or not stat.S_ISDIR(root_status.st_mode) or (root_status.st_dev, root_status.st_ino) != (root_status_before.st_dev, root_status_before.st_ino) ): raise RuntimeError("SAM batch capability directory changed") probe, probe_identity = _create_linux_batch_capability_directory(root) result_binding = { "path": probe / f"result-{secrets.token_hex(16)}", "parent": probe, "parent_identity": probe_identity, } parent_handle = None descriptor = None primary_error = None unsupported = False published = False result_cleaned = False try: parent_handle = _open_batch_result_parent(result_binding) descriptor = os.open( ".", os.O_RDWR | os.O_TMPFILE, 0o600, dir_fd=parent_handle, ) _publish_batch_result(descriptor, parent_handle, result_binding) published = True _unlink_linux_batch_capability_result( descriptor, parent_handle, result_binding["path"].name, ) result_cleaned = True except _BatchResultPublishingUnsupported as exc: unsupported = True primary_error = exc except OSError as exc: # With the directory already bound and the flags fixed, these are the # documented old-kernel/filesystem O_TMPFILE unsupported outcomes. unsupported_errors = { errno.EINVAL, errno.EISDIR, errno.ENOENT, errno.ENOSYS, errno.EOPNOTSUPP, } if exc.errno in unsupported_errors: unsupported = True primary_error = exc else: primary_error = exc except BaseException as exc: primary_error = exc finally: cleanup_errors = [] try: if descriptor is not None and published and not result_cleaned: try: _unlink_linux_batch_capability_result( descriptor, parent_handle, result_binding["path"].name, ) result_cleaned = True except BaseException as exc: cleanup_errors.append(exc) finally: try: if descriptor is not None: try: os.close(descriptor) except BaseException as exc: cleanup_errors.append(exc) finally: if parent_handle is not None: try: _close_batch_result_parent(parent_handle) except BaseException as exc: cleanup_errors.append(exc) try: _remove_linux_batch_capability_directory(probe, probe_identity) except BaseException as exc: cleanup_errors.append(exc) if cleanup_errors: if primary_error is not None and not unsupported: for cleanup_error in cleanup_errors: primary_error.add_note( f"SAM batch capability cleanup failed: {cleanup_error}" ) raise primary_error raise cleanup_errors[0] if unsupported: return False if primary_error is not None: raise primary_error return True if sys.platform != "win32": return False root_status_before = _validate_batch_directory_chain(work_dir) root = work_dir.resolve(strict=True) if root != work_dir: raise RuntimeError("SAM batch capability directory is unsafe") root_status = root.lstat() if ( _is_link_or_reparse(root_status) or not stat.S_ISDIR(root_status.st_mode) or (root_status.st_dev, root_status.st_ino) != (root_status_before.st_dev, root_status_before.st_ino) ): raise RuntimeError("SAM batch capability directory changed") result_binding = { "path": root / f".sam-batch-capability-{secrets.token_hex(16)}.json", "parent": root, "parent_identity": (root_status.st_dev, root_status.st_ino), "parent_handle_identity": _windows_path_identity(root), } _verify_batch_result_binding(result_binding) parent_handle = _open_batch_result_parent(result_binding) file_descriptor = None delete_requested = False primary_error = None try: file_descriptor, _, _ = _create_batch_result_file( result_binding, parent_handle, delete_on_close=True, ) opened_status = os.fstat(file_descriptor) if not stat.S_ISREG(opened_status.st_mode): raise RuntimeError("SAM batch capability temp is not a regular file") _publish_windows_batch_result( file_descriptor, parent_handle, result_binding["path"].name, ) _delete_windows_batch_result(file_descriptor) delete_requested = True except BaseException as exc: primary_error = exc finally: cleanup_errors = [] try: if file_descriptor is not None and not delete_requested: try: _delete_windows_batch_result(file_descriptor) delete_requested = True except BaseException as exc: cleanup_errors.append(exc) finally: try: if file_descriptor is not None: try: os.close(file_descriptor) except BaseException as exc: cleanup_errors.append(exc) finally: try: _close_batch_result_parent(parent_handle) except BaseException as exc: cleanup_errors.append(exc) if primary_error is not None: for cleanup_error in cleanup_errors: primary_error.add_note( f"SAM batch capability cleanup failed: {cleanup_error}" ) try: result_binding["path"].lstat() except FileNotFoundError: if primary_error is not None: if ( isinstance(primary_error, _BatchResultPublishingUnsupported) and cleanup_errors ): raise cleanup_errors[0] if isinstance(primary_error, _BatchResultPublishingUnsupported): return False raise primary_error if cleanup_errors: raise cleanup_errors[0] return True raise RuntimeError("SAM batch capability probe left a result path") def _publish_batch_result( file_descriptor: int, parent_handle, result_binding: dict, ) -> None: result_name = result_binding["path"].name if sys.platform == "win32": _publish_windows_batch_result(file_descriptor, parent_handle, result_name) return import errno if sys.platform == "darwin": error = _fclonefileat_batch_result( file_descriptor, parent_handle, os.fsencode(result_name), 0, ) if error == 0: return if error == errno.EEXIST: raise RuntimeError("SAM batch result path already exists") if error in {errno.ENOTSUP, errno.EOPNOTSUPP}: raise RuntimeError("SAM batch result cloning is not supported") raise RuntimeError("SAM batch result could not be published") if not sys.platform.startswith("linux"): raise RuntimeError("SAM batch result publishing is unsupported platform") error = _linkat_batch_result( file_descriptor, b"", parent_handle, os.fsencode(result_name), 0x1000, ) unsupported_errors = { errno.EINVAL, errno.ENOENT, errno.ENOSYS, errno.ENOTSUP, errno.EOPNOTSUPP, errno.EPERM, } if error in unsupported_errors: error = _linkat_batch_result( -100, os.fsencode(f"/proc/self/fd/{file_descriptor}"), parent_handle, os.fsencode(result_name), 0x400, ) if error == 0: return if error == errno.EEXIST: raise RuntimeError("SAM batch result path already exists") if error in unsupported_errors: raise _BatchResultPublishingUnsupported( "SAM batch result publishing is unsupported" ) raise RuntimeError("SAM batch result could not be published") def _fclonefileat_batch_result( source_fd: int, parent_fd: int, result_name: bytes, flags: int, ) -> int: import ctypes import errno libc = ctypes.CDLL(None, use_errno=True) try: fclonefileat = libc.fclonefileat except AttributeError: return errno.ENOTSUP fclonefileat.argtypes = [ ctypes.c_int, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32, ] fclonefileat.restype = ctypes.c_int if fclonefileat(source_fd, parent_fd, result_name, flags) == 0: return 0 return ctypes.get_errno() def _linkat_batch_result( old_fd: int, old_path: bytes, new_fd: int, new_path: bytes, flags: int, ) -> int: import ctypes libc = ctypes.CDLL(None, use_errno=True) linkat = libc.linkat linkat.argtypes = [ ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ] linkat.restype = ctypes.c_int if linkat(old_fd, old_path, new_fd, new_path, flags) == 0: return 0 return ctypes.get_errno() def _write_bound_json_result( result_binding: dict, payload, result_limit: int, ) -> None: _verify_batch_result_binding(result_binding) parent_handle = _open_batch_result_parent(result_binding) file_descriptor = None descriptor_identity = None published = False primary_error = None try: file_descriptor, _, published = _create_batch_result_file( result_binding, parent_handle, ) opened_status = os.fstat(file_descriptor) if not stat.S_ISREG(opened_status.st_mode): raise RuntimeError("SAM batch result temp is not a regular file") descriptor_identity = (opened_status.st_dev, opened_status.st_ino) written = 0 encoder = json.JSONEncoder(ensure_ascii=False) with os.fdopen(file_descriptor, "wb", closefd=False) as temporary_file: for chunk in encoder.iterencode(payload): encoded = chunk.encode("utf-8") written += len(encoded) if written > result_limit: raise RuntimeError("SAM batch result exceeds its size limit") temporary_file.write(encoded) temporary_file.flush() os.fsync(temporary_file.fileno()) descriptor_status = os.fstat(file_descriptor) if ( not stat.S_ISREG(descriptor_status.st_mode) or descriptor_status.st_size != written ): raise RuntimeError("SAM batch result temp identity changed") final_identity = ( descriptor_status.st_dev, descriptor_status.st_ino, ) if final_identity != descriptor_identity: raise RuntimeError("SAM batch result temp identity changed") _verify_batch_result_binding(result_binding) _publish_batch_result(file_descriptor, parent_handle, result_binding) _verify_batch_result_parent(result_binding) published = True except BaseException as exc: primary_error = exc raise finally: cleanup_errors = [] try: if ( file_descriptor is not None and not published and sys.platform == "win32" ): try: _delete_windows_batch_result(file_descriptor) except BaseException as exc: cleanup_errors.append(exc) finally: try: if file_descriptor is not None: try: os.close(file_descriptor) except BaseException as exc: cleanup_errors.append(exc) finally: try: _close_batch_result_parent(parent_handle) except BaseException as exc: cleanup_errors.append(exc) if primary_error is not None: for cleanup_error in cleanup_errors: primary_error.add_note(f"SAM batch cleanup failed: {cleanup_error}") elif not published and cleanup_errors: raise cleanup_errors[0] def _write_batch_result( result_binding: dict, payload: dict, operations: list[dict], ) -> None: _validate_batch_output(payload, operations) prompted = operations[0] result_limit = sam_candidate_batch_result_max_bytes( tuple(prompted["image"].shape[:2]), len(prompted.get("proposal_records", [])), ) _write_bound_json_result(result_binding, payload, result_limit) def _verify_batch_result_binding(result_binding: dict) -> None: _verify_batch_result_parent(result_binding) try: result_binding["path"].lstat() except FileNotFoundError: return except OSError as exc: raise RuntimeError("SAM batch result path changed") from exc raise RuntimeError("SAM batch result path already exists") def _verify_batch_result_parent(result_binding: dict) -> None: parent = result_binding["parent"] try: parent_status = parent.lstat() except OSError as exc: raise RuntimeError("SAM batch result directory changed") from exc if ( _is_link_or_reparse(parent_status) or not stat.S_ISDIR(parent_status.st_mode) or (parent_status.st_dev, parent_status.st_ino) != result_binding["parent_identity"] ): raise RuntimeError("SAM batch result directory changed") def _run_candidate_batch(request_path: Path, result_path: Path) -> int: operations, result_binding = _validate_batch_request(request_path, result_path) _load_batch_inputs(operations) ( proposal_type, create_generator, generate_automatic, generate_prompted, resolve_checkpoint, _, _, ) = _load_tools() generator = create_generator(resolve_checkpoint(), resource_safe=True) output_operations = [] for operation in operations: image = operation["image"] if operation["kind"] == "prompted": proposals = [ proposal_type( **{ **record, "box_xyxy": tuple(record["box_xyxy"]), "crop_box": tuple(record["crop_box"]), } ) for record in operation["proposal_records"] ] candidates = generate_prompted( image, proposals, generator, operation["text_mask"], ) else: candidates = generate_automatic( image, generator, crop_size=max(image.shape[:2]), include_geometry=False, min_score=0.90, ) candidate_limit = ( sam_candidate_batch_max_prompted_candidates( len(operation.get("proposal_records", [])) ) if operation["kind"] == "prompted" else sam_candidate_batch_max_automatic_candidates() ) if not isinstance(candidates, list) or len(candidates) > candidate_limit: raise RuntimeError("SAM batch generated candidate count exceeds its limit") output_operations.append( { "id": operation["id"], "kind": operation["kind"], "candidates": [ _candidate_record(candidate) for candidate in candidates ], } ) payload = { "schema_version": _BATCH_SCHEMA_VERSION, "operations": output_operations, } _write_batch_result(result_binding, payload, operations) return 0 def _component_prompt_predict(predictor, prompt: dict) -> np.ndarray: positive = prompt.get("positive", []) negative = prompt.get("negative", []) points = np.asarray(positive + negative, dtype=np.float32) labels = np.asarray([1] * len(positive) + [0] * len(negative), dtype=np.int32) box = prompt.get("box") masks, scores, _ = predictor.predict( point_coords=points if len(points) else None, point_labels=labels if len(points) else None, box=np.asarray(box, dtype=np.float32) if box is not None else None, multimask_output=True, ) return np.asarray(masks[int(np.argmax(scores))], dtype=bool) def component_prompt_masks( generator, image: np.ndarray, prompts: list[dict], ) -> list[np.ndarray]: """Run ordered component prompts after computing the source embedding once.""" predictor = generator.predictor predictor.set_image(image) return [_component_prompt_predict(predictor, prompt) for prompt in prompts] def component_prompt_mask(generator, image: np.ndarray, prompt: dict) -> np.ndarray: """Run one box/point prompt inside the isolated SAM worker process.""" return component_prompt_masks(generator, image, [prompt])[0] def _validate_component_prompt_batch( prompts, image_shape: tuple[int, int], *, max_prompts: int | None = None, ) -> list[dict]: if not isinstance(prompts, list) or not prompts: raise ValueError("SAM component prompt batch must be a non-empty list") if max_prompts is not None and len(prompts) > max_prompts: raise ValueError("SAM component prompt batch has too many prompts") height, width = image_shape validated = [] component_ids = set() for prompt in prompts: if not isinstance(prompt, dict) or set(prompt) != _COMPONENT_BATCH_FIELDS: raise ValueError("SAM component prompt batch item is invalid") component_id = _validate_batch_string( prompt[" -
skill_environment.py 5.3 KB
"""Choose Skill storage and run preparation/conversion in the same environment.""" from __future__ import annotations import argparse import ctypes import json import os from pathlib import Path import plistlib import subprocess import sys import tempfile def windows_data_drives() -> list[Path]: # DRIVE_FIXED excludes optical drives, removable media and network shares. return [ Path(f"{letter}:/") for letter in "DEFGHIJKLMNOPQRSTUVWXYZAB" if ctypes.windll.kernel32.GetDriveTypeW(f"{letter}:\\") == 3 ] def writable_root(path: Path) -> Path: path.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryFile(dir=path): pass return path.resolve() def mounted_data_volumes() -> list[Path]: if sys.platform == "darwin": volumes = [] system_devices = {Path("/").stat().st_dev, Path.home().stat().st_dev} for path in sorted(Path("/Volumes").iterdir()): if not path.is_mount() or path.stat().st_dev in system_devices: continue result = subprocess.run(["diskutil", "info", "-plist", str(path)], capture_output=True, check=False) if result.returncode: continue info = plistlib.loads(result.stdout) if (info.get("DeviceNode", "").startswith("/dev/") and info.get("BusProtocol") != "Disk Image"): volumes.append(path) return volumes result = subprocess.run( ["lsblk", "--json", "--output", "TYPE,MOUNTPOINTS"], capture_output=True, text=True, check=True, ) volumes = [] for device in json.loads(result.stdout)["blockdevices"]: if device["type"] != "disk": continue pending = [device] mounts = [] while pending: node = pending.pop() mounts.extend(value for value in node.get("mountpoints", []) if value) pending.extend(node.get("children", [])) if "/" not in mounts: volumes.extend(Path(value) for value in mounts if value.startswith("/") and not value.startswith("/boot")) return sorted(set(volumes)) def installation_root() -> Path: drives = windows_data_drives() if sys.platform == "win32" else mounted_data_volumes() if not drives: fallback = (Path.home() / "image2editable" if sys.platform == "win32" else Path.home() / ".local" / "share" / "image2editable") return writable_root(fallback) errors = [] for drive in drives: try: return writable_root(drive / "image2editable") except OSError as error: errors.append(f"{drive}: {error}") raise OSError("Data drives exist but are not writable: " + "; ".join(errors)) def environment(root: Path) -> dict[str, str]: directories = { "PIP_CACHE_DIR": root / "cache" / "pip", "HF_HOME": root / "cache" / "huggingface", "HF_HUB_CACHE": root / "cache" / "huggingface" / "hub", "TORCH_HOME": root / "cache" / "torch", "PADDLE_HOME": root / "cache" / "paddle", "PADDLE_PDX_CACHE_HOME": root / "cache" / "paddlex", "XDG_CACHE_HOME": root / "cache", "UV_CACHE_DIR": root / "cache" / "uv", "UV_PYTHON_INSTALL_DIR": root / "tools" / "python", "TEMP": root / "tmp", "TMP": root / "tmp", "TMPDIR": root / "tmp", } # Reuse an explicitly configured or previously downloaded model receipt. previous = Path.home() / ".cache" / "image2editable" / "models" / "runtime" configured = os.environ.get("IMAGE2EDITABLE_MODEL_CACHE") directories["IMAGE2EDITABLE_MODEL_CACHE"] = ( Path(configured).expanduser().resolve() if configured else previous if (previous / "runtime-receipt.json").is_file() else root / "models" / "runtime" ) for directory in directories.values(): directory.mkdir(parents=True, exist_ok=True) values = {name: str(path) for name, path in directories.items()} values["PYTHONIOENCODING"] = "utf-8" values["PYTHONNOUSERSITE"] = "1" executable_dirs = [root / "venv" / ("Scripts" if sys.platform == "win32" else "bin")] if sys.platform == "win32": executable_dirs.extend([root / "tools" / "python", root / "tools" / "git" / "cmd"]) renderer = root / "tools" / "native-renderer" / "extracted" / "program" / "soffice.com" if renderer.is_file() and not os.environ.get("IMAGE2EDITABLE_LIBREOFFICE"): values["IMAGE2EDITABLE_LIBREOFFICE"] = str(renderer) else: executable_dirs.append(root / "tools" / "git" / "bin") values["PATH"] = os.pathsep.join([*(str(path) for path in executable_dirs), os.environ.get("PATH", "")]) return values def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--run", nargs=argparse.REMAINDER, help="Run a command with the selected cache and temporary paths") args = parser.parse_args() root = installation_root() values = environment(root) if args.run: return subprocess.run(args.run, env={**os.environ, **values}, check=False).returncode print(json.dumps({"root": str(root), "environment": values}, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main()) -
text_context.py 11.1 KB
"""Resolve overlapping OCR fragments using their shared line context.""" from pathlib import Path import hashlib import json import tempfile import unicodedata import cv2 import numpy as np from PIL import Image from scripts import text_detect def _recognize_context_views(paths, work_dir, *, lang, **kwargs): cache = Path(work_dir) / "text-context-cache" cache.mkdir(exist_ok=True) identity = lang.encode() + (Path(__file__).parent / "ocr_worker.py").read_bytes() identity += Path(text_detect.__file__).read_bytes() prefix = hashlib.sha256(identity).digest() readings, missing, cache_paths = [None] * len(paths), [], [] for index, path in enumerate(paths): key = hashlib.sha256(prefix + path.read_bytes()).hexdigest() cached = cache / f"{key}.json" cache_paths.append(cached) if cached.exists(): try: readings[index] = json.loads(cached.read_text(encoding="utf-8")) except (OSError, ValueError): pass if not isinstance(readings[index], list): missing.append(index) if missing: fresh = text_detect._try_isolated_paddleocr_batch( [paths[index] for index in missing], lang, .98, worker_root=work_dir, recognition_only=True, **kwargs, ) if fresh is None or len(fresh) != len(missing): return None for index, reading in zip(missing, fresh): readings[index] = reading with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=cache, suffix=".json", delete=False) as stream: json.dump(reading, stream, ensure_ascii=False) temporary = Path(stream.name) temporary.replace(cache_paths[index]) return readings def _normalized(text): return "".join(unicodedata.normalize("NFKC", text).casefold().split()) def _restore_label_leaders(source_path, items): candidates = [i for i, item in enumerate(items) if item.get("text", "").startswith("[") and item["text"].endswith("]")] if not candidates: return items result = list(items) with Image.open(source_path) as source: for index in candidates: item = items[index] x, y, w, h = map(int, item["box"]) crop = np.asarray(source.crop((x, y, x+w, y+h)).convert("RGB")) gray = cv2.cvtColor(crop, cv2.COLOR_RGB2GRAY) _, ink = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) _, _, stats, _ = cv2.connectedComponentsWithStats(ink) for left, top, width, height, area in stats[1:]: if (width < h*.75 or height > h*.16 or width/height < 7 or left+width > w*.5 or not .25 < (top+height/2)/h < .8 or area/(width*height) < .6): continue replacement = {**item, "text": "—"+item["text"]} if item.get("words"): replacement["words"] = [{"text": "—", "box": [left/w, top/h, width/w, height/h]}] + item["words"] result[index] = replacement break return result def _restore_context_edges(source_path, items, readings): """Use existing line evidence to recover clipped edges, never rewrite a line.""" replacements, removed = {}, set() pixels = None for reading in readings: text = reading.get("text", "").strip() normalized = _normalized(text) confidence = reading.get("confidence", reading.get("score", 0)) if confidence < .99: continue x, y, w, h = reading["box"] for index, item in enumerate(items): old = _normalized(item["text"]) ix, iy, iw, ih = item["box"] overlap = max(0, min(x+w, ix+iw)-max(x, ix)) * max(0, min(y+h, iy+ih)-max(y, iy)) if (index in removed or item.get("rotation", 0) or "runs" in item or len(old) < 4 or old == normalized or old not in normalized or overlap / max(1, iw*ih) < .85 or not .7 < h/max(1, ih) < 1.5): continue start = normalized.index(old) extra = normalized[:start] + normalized[start+len(old):] if sum(not unicodedata.category(c).startswith("P") for c in extra) > 1: continue if any(not unicodedata.category(c).startswith("P") for c in extra) and confidence < .995: continue words = reading.get("words", []) if not words: continue left = min(word["box"][0] for word in words) right = max(word["box"][0]+word["box"][2] for word in words) box = [int(x+w*left), y, int(w*(right-left)+.999), h] remapped = [{**word, "box": [(word["box"][0]-left)/(right-left), word["box"][1], word["box"][2]/(right-left), word["box"][3]]} for word in words] if pixels is None: with Image.open(source_path) as source: pixels = np.asarray(source.convert("RGB")) styled, _ = text_detect._build_text_result(pixels, [ {"text": text, "box": box, "confidence": confidence, "words": remapped}], .99, 6) if len(styled) != 1 or _normalized(styled[0]["text"]) != normalized: continue replacements[index] = styled[0] removed.add(index) # Tiny detached quotes can be read as digits. Only absorb a fragment # when its actual location is covered by punctuation in the line reading. for other_index, other in enumerate(items): ox, oy, ow, oh = other["box"] if other_index == index or oh > ih*.6 or ow > ih*.7: continue for word in words: if not all(unicodedata.category(c).startswith("P") for c in word["text"]): continue wx, wy, ww, wh = word["box"] px, py, pw, ph = x+wx*w, y+wy*h, ww*w, wh*h covered = max(0, min(px+pw, ox+ow)-max(px, ox)) * max(0, min(py+ph, oy+oh)-max(py, oy)) if covered / max(1, ow*oh) > .5: removed.add(other_index) break return [replacements[i] if i in replacements else item for i, item in enumerate(items) if i not in removed or i in replacements] def refine_overlapping_text(source_path, items, work_dir, *, lang, worker_pool=None, performance_trace=None, page_id=None, context_readings=None): items = _restore_label_leaders(source_path, items) if context_readings: items = _restore_context_edges(source_path, items, context_readings) groups = [[index] for index in range(len(items))] while True: pair = None for i, first in enumerate(groups): for j in range(i+1, len(groups)): for a in first: for b in groups[j]: left, right = items[a], items[b] if any(item.get("rotation", 0) or "runs" in item for item in (left, right)): continue x, y, w, h = left["box"] rx, ry, rw, rh = right["box"] overlap = max(0, min(x+w, rx+rw)-max(x, rx)) * max(0, min(y+h, ry+rh)-max(y, ry)) if (overlap / max(1, min(w*h, rw*rh)) > .5 and min(h, rh)/max(1, h, rh) > .6): pair = (i, j) break if pair: break if pair: break if pair: break if pair is None: break groups[pair[0]].extend(groups.pop(pair[1])) groups = [group for group in groups if len(group) > 1] if not groups: return items with Image.open(source_path) as source, tempfile.TemporaryDirectory(prefix="line-context-", dir=work_dir) as temporary: paths, frames, selected = [], [], [] pixels_used = 0 for group in groups: x = min(items[i]["box"][0] for i in group) y = min(items[i]["box"][1] for i in group) right = max(items[i]["box"][0]+items[i]["box"][2] for i in group) bottom = max(items[i]["box"][1]+items[i]["box"][3] for i in group) height = bottom-y frame = [max(0, int(x-height*.8)), max(0, int(y-height*.05)), min(source.width, int(right+height*.8)), min(source.height, int(bottom+height*.05))] width, height = frame[2]-frame[0], frame[3]-frame[1] if min(width, height) <= 0 or pixels_used+2*width*height > 6_291_456: continue pixels_used += 2*width*height with source.crop(frame).convert("RGB") as crop: for scale in (1, .85): path = Path(temporary) / f"{len(paths):04d}.png" crop.resize((max(1, round(width*scale)), max(1, round(height*scale))), Image.Resampling.LANCZOS).save(path) paths.append(path) selected.append(group) frames.append([frame[0], frame[1], width, height]) if not paths: return items readings = _recognize_context_views( paths, work_dir, lang=lang, worker_pool=worker_pool, performance_trace=performance_trace, page_id=page_id, ) if readings is None: return items replacements, removed = {}, set() pixels = np.asarray(source.convert("RGB")) for index, (group, frame) in enumerate(zip(selected, frames)): first, second = readings[2*index:2*index+2] if len(first) != 1 or len(second) != 1: continue normalized = _normalized(first[0]["text"]) if normalized != _normalized(second[0]["text"]): continue fragments = [_normalized(items[i]["text"]) for i in group] conflicting = [part for part in fragments if part not in normalized] if conflicting: if (min(first[0].get("confidence", 0), second[0].get("confidence", 0)) < .995 or not any(len(part) >= 4 and part in normalized for part in fragments) or any(len(part) < 4 or not any( sum(a != b for a, b in zip(part, normalized[start:start+len(part)])) <= 1 for start in range(len(normalized)-len(part)+1) ) for part in conflicting)): continue raw = {**first[0], "box": frame} styled, _ = text_detect._build_text_result(pixels, [raw], .98, 6) if len(styled) != 1 or _normalized(styled[0]["text"]) != normalized: continue replacements[min(group)] = styled[0] removed.update(group) return [replacements[i] if i in replacements else item for i, item in enumerate(items) if i not in removed or i in replacements] -
text_detect.py 52.8 KB
#!/usr/bin/env python3 """Text detection module — OCR-based text extraction with style estimation. Uses PaddleOCR (preferred) or pytesseract (fallback) to detect text regions, then estimates font size, color, bold, and alignment from the image. Usage: from text_detect import detect_text text_items, text_mask = detect_text("slide.png") """ from __future__ import annotations import logging import json from functools import lru_cache from pathlib import Path import sys import tempfile import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont import re _MODULE_ROOT = str(Path(__file__).resolve().parent.parent) if not sys.path or sys.path[0] != _MODULE_ROOT: while _MODULE_ROOT in sys.path: sys.path.remove(_MODULE_ROOT) sys.path.insert(0, _MODULE_ROOT) from scripts.worker_resources import run_isolated_worker from scripts.ocr_worker import _validated_words, _words_from_polys logger = logging.getLogger(__name__) _PADDLE_OCR_ENGINES: dict[str, object] = {} # Characters considered "noise" — lines consisting only of these are filtered _NOISE_PATTERN = re.compile(r'^[\s\-_=.|/\\:;,!?~`@#$%^&*(){}\[\]<>+\'\"]+$') # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def detect_text( image_path: str | Path, lang: str = "ch", confidence_threshold: float = 0.7, mask_padding: int = 6, *, isolated: bool = False, worker_root: str | Path | None = None, style_reference_width: int | None = None, worker_pool=None, performance_trace=None, page_id: str | None = None, recover_empty: bool = False, ) -> tuple[list[dict], np.ndarray]: """Detect text regions and estimate styling. Args: image_path: Path to the input image. lang: OCR language ("ch" for PaddleOCR, "chi_sim+eng" for Tesseract). confidence_threshold: Minimum confidence to keep a detection. mask_padding: Pixels to pad around each text bbox in the mask. Returns: text_items: List of dicts with keys: box (x, y, w, h), text, font_size, color, bold, font, align, confidence text_mask: Binary mask (H, W) uint8 where text regions = 255. """ image_path = Path(image_path) img_rgb = _load_rgb(image_path) h, w = img_rgb.shape[:2] raw_boxes = _ocr_detect( image_path, lang, confidence_threshold, isolated=isolated, worker_root=worker_root, worker_pool=worker_pool, performance_trace=performance_trace, page_id=page_id, **({"recover_empty": True} if recover_empty else {}), ) return _build_text_result( img_rgb, raw_boxes, confidence_threshold, mask_padding, style_reference_width=style_reference_width, ) def detect_text_batch( image_paths: list[str | Path], lang: str = "ch", confidence_threshold: float = 0.7, mask_padding: int = 6, *, isolated: bool = False, worker_root: str | Path | None = None, worker_pool=None, performance_trace=None, page_id: str | None = None, recover_empty: bool = False, ) -> list[tuple[list[dict], np.ndarray]]: """Detect text in several images while sharing one isolated OCR lifecycle.""" paths = [Path(path) for path in image_paths] if not paths: return [] if not isolated: return [ detect_text( path, lang=lang, confidence_threshold=confidence_threshold, mask_padding=mask_padding, **({"recover_empty": True} if recover_empty else {}), ) for path in paths ] raw_results = _try_isolated_paddleocr_batch( paths, lang, confidence_threshold, worker_root=worker_root, worker_pool=worker_pool, performance_trace=performance_trace, page_id=page_id, **({"recover_empty": True} if recover_empty else {}), ) if raw_results is None: return [ detect_text( path, lang=lang, confidence_threshold=confidence_threshold, mask_padding=mask_padding, isolated=True, worker_root=worker_root, worker_pool=worker_pool, performance_trace=performance_trace, page_id=page_id, **({"recover_empty": True} if recover_empty else {}), ) for path in paths ] return [ _build_text_result( _load_rgb(path), raw_boxes, confidence_threshold, mask_padding, ) for path, raw_boxes in zip(paths, raw_results) ] def _build_text_result( img_rgb: np.ndarray, raw_boxes: list[dict], confidence_threshold: float, mask_padding: int, *, style_reference_width: int | None = None, ) -> tuple[list[dict], np.ndarray]: if style_reference_width is not None and ( type(style_reference_width) is not int or style_reference_width <= 0 ): raise ValueError("reference_width must be a positive integer") h, w = img_rgb.shape[:2] if not raw_boxes: logger.warning("No text detected by OCR.") return [], np.zeros((h, w), dtype=np.uint8) # Filter out noise lines (pure symbols, very short, etc.) raw_boxes = _filter_noise(raw_boxes, confidence_threshold) # Clean up OCR edge noise while preserving semantic sentence endings. for rb in raw_boxes: original_box = tuple(rb["box"]) text = rb["text"].strip() if ( len(text) == 1 and text in "?!" and rb.get("confidence", 0) >= .9 ) or re.fullmatch(r"[+-]\d+(?:[.,]\d+)?%?", text) or ( rb.get("confidence", 0) >= .99 and text.endswith(("/", "\\")) ): rb["text"] = text else: rb["text"] = text.lstrip("|/\\-_=.,:;!?~`'\"").rstrip("|/\\-_=,:;~`'\"") rb["text"], rb["box"] = _recover_trailing_heading_period( img_rgb, rb["text"], rb["box"], ) if tuple(rb["box"]) != original_box: rb.pop("words", None) # Remove boxes that became empty after cleanup raw_boxes = [rb for rb in raw_boxes if rb["text"]] if not raw_boxes: logger.warning("All OCR detections filtered as noise.") return [], np.zeros((h, w), dtype=np.uint8) # Estimate styling for each detection text_items = [] for rb in raw_boxes: box = rb["box"] # (x, y, w, h) in pixels text = rb["text"] if style_reference_width is None: style = _estimate_style(img_rgb, box, text=text) else: style = _estimate_style( img_rgb, box, reference_width=style_reference_width, text=text, ) # Retain confident small labels; size alone is not evidence of noise. if style["font_size"] < 8.0 and rb["confidence"] < 0.9: continue font_size = _adjust_font_size( text, style["font_size"], bbox_height=box[3], reference_width=style_reference_width or w, ) text_items.append({ "box": list(box), "text": text, "font_size": font_size, "color": style["color"], "bold": False if _should_force_regular_weight(text, font_size) else style["bold"], "font": _select_font(text, font_size), "align": 1, # default center; refined below "confidence": rb["confidence"], }) words = _validated_words(text, rb.get("words")) if words: text_items[-1]["words"] = words text_items = _merge_adjacent_text_items(text_items) # Refine alignment by grouping nearby lines text_items = _refine_alignment(text_items, w) text_items = refine_text_ink_bounds(img_rgb, text_items) # Build mask text_mask = _build_text_mask((h, w), text_items, padding=mask_padding) logger.info("Detected %d text regions.", len(text_items)) return text_items, text_mask # --------------------------------------------------------------------------- # OCR backends # --------------------------------------------------------------------------- def _ocr_detect( image_path: Path, lang: str, conf_threshold: float, *, isolated: bool = False, worker_root: str | Path | None = None, worker_pool=None, performance_trace=None, page_id: str | None = None, recover_empty: bool = False, ) -> list[dict]: """Try PaddleOCR first, fall back to pytesseract.""" if isolated: results = _try_isolated_paddleocr( image_path, lang, conf_threshold, worker_root=worker_root, worker_pool=worker_pool, performance_trace=performance_trace, page_id=page_id, **({"recover_empty": True} if recover_empty else {}), ) else: results = _try_paddleocr( image_path, lang, conf_threshold, **({"recover_empty": True} if recover_empty else {}), ) if results is not None: return results results = _try_tesseract(image_path, conf_threshold, lang=lang) if results is not None: return results logger.error("No OCR engine available (tried PaddleOCR, pytesseract).") return [] def _try_isolated_paddleocr( image_path: Path, lang: str, conf_threshold: float, *, worker_root: str | Path | None, worker_pool=None, performance_trace=None, page_id: str | None = None, recover_empty: bool = False, ) -> list[dict] | None: if worker_pool is not None: results = _try_isolated_paddleocr_batch( [image_path], lang, conf_threshold, worker_root=worker_root, worker_pool=worker_pool, performance_trace=performance_trace, page_id=page_id, **({"recover_empty": True} if recover_empty else {}), ) return None if results is None else results[0] try: with tempfile.TemporaryDirectory( prefix="ocr-", dir=worker_root, ) as temporary: work_dir = Path(temporary) detection_result = work_dir / "detection.json" recognition_result = work_dir / "recognition.json" commands = [ [ sys.executable, str(Path(__file__).with_name("ocr_worker.py").resolve()), "detect", "--image", str(image_path), "--work-dir", str(work_dir), "--result", str(detection_result), ], [ sys.executable, str(Path(__file__).with_name("ocr_worker.py").resolve()), "recognize", "--detection-result", str(detection_result), "--result", str(recognition_result), "--lang", lang, ], ] for stage, command, result_path in ( ("detection", commands[0], detection_result), ("recognition", commands[1], recognition_result), ): if recover_empty and stage == "detection": command.append("--recover-empty") completed = run_isolated_worker( command, capture_output=True, text=True, check=False, ) if completed.returncode or not result_path.is_file(): diagnostic = completed.stderr.strip() logger.warning( "Isolated OCR %s failed (exit=%s): %s", stage, completed.returncode, diagnostic or f"missing result {result_path}", ) return None payload = json.loads( recognition_result.read_text(encoding="utf-8") ) except Exception as error: logger.warning("Isolated OCR failed: %s", error) return None boxes = [] for item in payload.get("items", []): confidence = float(item.get("score", 0.0)) text = str(item.get("text", "")).strip() if confidence < conf_threshold or not text: continue poly = item["poly"] bx, by, width, height = _poly_to_box(poly) if width < 2 or height < 2: continue boxes.append( { "box": (bx, by, width, height), "text": text, "confidence": confidence, } ) words = _validated_words(text, item.get("words")) if words: boxes[-1]["words"] = words return boxes def _try_isolated_paddleocr_batch( image_paths: list[Path], lang: str, conf_threshold: float, *, worker_root: str | Path | None, worker_pool=None, performance_trace=None, page_id: str | None = None, recover_empty: bool = False, recognition_only: bool = False, ) -> list[list[dict]] | None: try: with tempfile.TemporaryDirectory( prefix="ocr-batch-", dir=worker_root, ) as temporary: work_dir = Path(temporary) result_path = work_dir / "result.json" resolved_paths = [str(path.resolve()) for path in image_paths] if worker_pool is not None: worker_pool.request( {"images": resolved_paths, "result": str(result_path), "lang": lang, **({"recover_empty": True} if recover_empty else {}), **({"recognition_only": True} if recognition_only else {})}, performance_trace=performance_trace, page_id=page_id, ) else: manifest_path = work_dir / "manifest.json" manifest_path.write_text( json.dumps({"images": resolved_paths}), encoding="utf-8", ) command = [ sys.executable, str(Path(__file__).with_name("ocr_worker.py").resolve()), "batch", "--manifest", str(manifest_path), "--result", str(result_path), "--lang", lang, ] if recover_empty: command.append("--recover-empty") if recognition_only: command.append("--recognition-only") completed = run_isolated_worker( command, capture_output=True, text=True, check=False, ) if completed.returncode or not result_path.is_file(): logger.warning( "Isolated OCR batch failed (exit=%s): %s", completed.returncode, completed.stderr.strip() or f"missing result {result_path}", ) return None if not result_path.is_file(): logger.warning("Isolated OCR batch did not create its result") return None payload = json.loads(result_path.read_text(encoding="utf-8")) except Exception as error: logger.warning("Isolated OCR batch failed: %s", error) return None images = payload.get("images", []) if len(images) != len(image_paths): logger.warning("Isolated OCR batch returned the wrong image count") return None results = [] for image in images: boxes = [] for item in image.get("items", []): confidence = float(item.get("score", 0.0)) text = str(item.get("text", "")).strip() if confidence < conf_threshold or not text: continue bx, by, width, height = _poly_to_box(item["poly"]) if width < 2 or height < 2: continue boxes.append({ "box": (bx, by, width, height), "text": text, "confidence": confidence, }) words = _validated_words(text, item.get("words")) if words: boxes[-1]["words"] = words results.append(boxes) return results def _poly_to_box(poly: object) -> tuple[int, int, int, int]: x_values = [point[0] for point in poly] y_values = [point[1] for point in poly] x1, x2 = min(x_values), max(x_values) y1, y2 = min(y_values), max(y_values) return ( int(x1), int(y1), int(x2 - x1), int(y2 - y1), ) def _try_paddleocr( image_path: Path, lang: str, conf_threshold: float, *, recover_empty: bool = False, ) -> list[dict] | None: """Detect text with PaddleOCR. Returns None if unavailable.""" try: ocr = _get_paddleocr(lang) except ImportError: logger.debug("PaddleOCR not installed, skipping.") return None except Exception as exc: logger.warning("PaddleOCR failed: %s", exc) return None try: result = list(ocr.predict(str(image_path), return_word_box=True)) if recover_empty and not any( item.get("rec_texts", []) if isinstance(item, dict) else getattr(item, "rec_texts", []) for item in result ): result = list(ocr.predict( str(image_path), text_det_thresh=0.15, text_det_box_thresh=0.3, return_word_box=True, )) conf_threshold = max(conf_threshold, 0.9) if not result: return [] boxes: list[dict] = [] for item in result: # PaddleOCR v3.5+ returns dict-like OCRResult texts = item.get("rec_texts", []) if isinstance(item, dict) else getattr(item, "rec_texts", []) scores = item.get("rec_scores", []) if isinstance(item, dict) else getattr(item, "rec_scores", []) polys = item.get("rec_polys", item.get("dt_polys", [])) if isinstance(item, dict) else getattr(item, "rec_polys", getattr(item, "dt_polys", [])) if not texts: continue for i, text in enumerate(texts): conf = float(scores[i]) if i < len(scores) else 0.0 if conf < conf_threshold: continue text = text.strip() if not text: continue poly = polys[i] bx, by, bw, bh = _poly_to_box(poly) if bw < 2 or bh < 2: continue boxes.append({ "box": (bx, by, bw, bh), "text": text, "confidence": conf, }) tokens = item.get("text_word", []) if isinstance(item, dict) else getattr(item, "text_word", []) regions = item.get("text_word_region", []) if isinstance(item, dict) else getattr(item, "text_word_region", []) if i < len(tokens) and i < len(regions): words = _words_from_polys(text, tokens[i], regions[i], (bx, by, bw, bh)) if words: boxes[-1]["words"] = words return boxes except Exception as exc: logger.warning("PaddleOCR failed: %s", exc) return None def _create_paddleocr(lang: str) -> object: from paddleocr import PaddleOCR _patch_paddle_mkldnn() return PaddleOCR( lang=lang, use_doc_orientation_classify=False, use_doc_unwarping=False, use_textline_orientation=False, text_recognition_batch_size=1, cpu_threads=1, enable_mkldnn=False, ) def _get_paddleocr(lang: str) -> object: if lang not in _PADDLE_OCR_ENGINES: _PADDLE_OCR_ENGINES[lang] = _create_paddleocr(lang) return _PADDLE_OCR_ENGINES[lang] def close_ocr_engines() -> None: _PADDLE_OCR_ENGINES.clear() def _patch_paddle_mkldnn() -> None: """Patch PaddlePaddle's default engine config to disable mkldnn. PaddlePaddle 3.x defaults to run_mode='mkldnn' on CPU, which triggers an OneDNN bug (ConvertPirAttribute2RuntimeAttribute) on some Windows systems. This patches the config resolver to force run_mode='paddle'. Also pre-imports torch before paddle to prevent DLL search path conflicts on Windows where paddle's DLL loading can break torch's shm.dll. """ try: # Import torch first to prevent DLL path pollution from paddle try: import torch # noqa: F401 except ImportError: pass import paddlex.inference.models.runners.paddle_static.runner as runner_mod _orig_resolve = runner_mod.resolve_paddle_static_engine_config def _patched_resolve(model_name, config): result = _orig_resolve(model_name, config) if result.get("run_mode") == "mkldnn": result["run_mode"] = "paddle" return result # Only patch once if not getattr(runner_mod, '_mkldnn_patched', False): runner_mod.resolve_paddle_static_engine_config = _patched_resolve runner_mod._mkldnn_patched = True except Exception: pass def _try_tesseract( image_path: Path, conf_threshold: float, lang: str = "ch" ) -> list[dict] | None: """Detect text with pytesseract at line level. Returns None if unavailable. Groups word-level detections by (block, paragraph, line) to produce complete text lines instead of individual characters/words. """ try: import pytesseract except ImportError: logger.debug("pytesseract not installed, skipping.") return None try: # Configure Tesseract path on Windows tesseract_path = Path(r"C:\Program Files\Tesseract-OCR\tesseract.exe") if tesseract_path.exists(): pytesseract.pytesseract.tesseract_cmd = str(tesseract_path) tess_lang = _to_tesseract_lang(lang) img = Image.open(image_path) data = pytesseract.image_to_data( img, lang=tess_lang, output_type=pytesseract.Output.DICT ) # Group words by (block, paragraph, line) lines: dict[tuple, list[int]] = {} n = len(data["text"]) for i in range(n): text = data["text"][i].strip() if not text: continue key = (data["block_num"][i], data["par_num"][i], data["line_num"][i]) if key not in lines: lines[key] = [] lines[key].append(i) boxes: list[dict] = [] for key, indices in lines.items(): # Merge all words in this line texts = [] confs = [] x_min, y_min = float("inf"), float("inf") x_max, y_max = 0, 0 for i in indices: word = data["text"][i].strip() if not word: continue texts.append(word) conf = float(data["conf"][i]) if conf >= 0: confs.append(conf) wx = int(data["left"][i]) wy = int(data["top"][i]) ww = int(data["width"][i]) wh = int(data["height"][i]) x_min = min(x_min, wx) y_min = min(y_min, wy) x_max = max(x_max, wx + ww) y_max = max(y_max, wy + wh) line_text = "".join(texts) if not line_text: continue avg_conf = sum(confs) / len(confs) if confs else 0 if avg_conf < conf_threshold * 100: continue bw = x_max - x_min bh = y_max - y_min if bw < 2 or bh < 2: continue boxes.append({ "box": (int(x_min), int(y_min), int(bw), int(bh)), "text": line_text, "confidence": avg_conf / 100.0, }) return boxes except Exception as exc: logger.warning("pytesseract failed: %s", exc) return None def _to_tesseract_lang(lang: str) -> str: """Map public OCR language names to Tesseract language packs.""" if lang in {"ch", "zh", "cn"}: return "chi_sim+eng" if lang == "en": return "eng" return lang # --------------------------------------------------------------------------- # Noise filtering # --------------------------------------------------------------------------- def _is_spaced_semantic_separator_text(text: str) -> bool: parts = re.split(r"\s+[/\-]\s+", text) if len(parts) == 1: return False for part in parts: meaningful = 0 for index, char in enumerate(part): if ( char.isalnum() or "\u4e00" <= char <= "\u9fff" or "\u3400" <= char <= "\u4dbf" ): meaningful += 1 continue if char.isspace(): continue if ( char in ".-" and index > 0 and index + 1 < len(part) and part[index - 1].isalnum() and part[index + 1].isalnum() ): continue return False if meaningful < 2: return False return True def _filter_noise( boxes: list[dict], confidence_threshold: float = 0.7 ) -> list[dict]: """Filter out OCR detections that are likely noise. Removes: - Lines consisting only of punctuation/symbols - Lines where most characters are symbols/noise - Very short meaningless detections """ filtered = [] for b in boxes: text = b["text"].strip() # Skip empty if not text: continue if float(b.get("confidence", 0.0)) < confidence_threshold: continue preserve_symbol = len(text) == 1 and text in "?!" and float(b.get("confidence", 0.0)) >= .9 if preserve_symbol: filtered.append(b) continue if re.fullmatch(r"[+-]\d+(?:[.,]\d+)?%?", text): filtered.append(b) continue if _NOISE_PATTERN.match(text) and not preserve_symbol: continue if _is_likely_vertical_decorative_fragment(b): continue # Count meaningful characters (letters, digits, CJK) meaningful = sum( 1 for c in text if c.isalnum() or '\u4e00' <= c <= '\u9fff' # CJK unified or '\u3400' <= c <= '\u4dbf' # CJK extension A ) total = len(text.replace(" ", "")) # Paired brackets delimit labels; they are not noise in the label content. if meaningful and text[0] + text[-1] in ( "[]", "()", "{}", "\uff08\uff09", "\u3010\u3011", "\u3014\u3015", "\u3008\u3009", "\u300a\u300b", ): total -= 2 # If less than 60% of characters are meaningful, it's likely noise if total > 0 and meaningful / total < 0.6: continue technical_text = text.rstrip(".!?") or text technical_separators = "-_./" has_technical_separator = any(c in technical_separators for c in technical_text) compact_label = all( c.isalnum() or c in technical_separators for c in technical_text ) valid_technical_label = ( compact_label and technical_text[0].isalnum() and technical_text[-1].isalnum() and not any( left in technical_separators and right in technical_separators for left, right in zip(technical_text, technical_text[1:]) ) and meaningful >= (4 if has_technical_separator else 2) ) if has_technical_separator and compact_label and not valid_technical_label: continue spaced_semantic_separator = _is_spaced_semantic_separator_text(text) if re.search(r"\s[/\-]\s", text) and not spaced_semantic_separator: continue # Skip single-char lines that are common OCR artifacts if len(text) == 1 and not text.isalnum() and not ('\u4e00' <= text <= '\u9fff') and not preserve_symbol: continue # Skip garbled text: mostly uppercase with separators, e.g. # e.g. "MCOULE ST:SETMP", "NOOOLE SX.TEET" alpha_chars = [c for c in text if c.isalpha()] if len(alpha_chars) >= 4: upper_ratio = sum(1 for c in alpha_chars if c.isupper()) / len(alpha_chars) has_cjk = any('\u4e00' <= c <= '\u9fff' for c in text) has_garbled_separator = any(c in text for c in ":;./\\") if ( upper_ratio > 0.8 and has_garbled_separator and not has_cjk and not valid_technical_label and not spaced_semantic_separator ): continue filtered.append(b) return filtered def _is_likely_vertical_decorative_fragment(box: dict) -> bool: """Identify OCR fragments from large vertical/decorative background text.""" text = box["text"].strip() x, y, w, h = box.get("box", (0, 0, 0, 0)) if w <= 0 or h <= 0: return False has_cjk = any('\u4e00' <= c <= '\u9fff' for c in text) has_latin_or_digit = any(c.isascii() and c.isalnum() for c in text) if h / w >= 1.8 and w <= 36 and (has_cjk or has_latin_or_digit): return True if has_cjk and len(text) == 1 and h >= 120 and w >= 80: return True return False def refine_text_ink_bounds(img_rgb: np.ndarray, items: list[dict]) -> list[dict]: """Include a recognized terminal slash whose ink lies outside its OCR box.""" result = [] for item in items: result.append(item) text = item.get("text", "").rstrip() if not text.endswith(("/", "\\")) or item.get("rotation") or item.get("runs"): continue x, y, width, height = map(int, item["box"]) right = x + width left = max(0, right - height) end = min(img_rgb.shape[1], right + height) top, bottom = max(0, y), min(img_rgb.shape[0], y + height) for other in items: if other is item: continue ox, oy, ow, oh = other["box"] if ox >= right and min(bottom, oy + oh) > max(top, oy): end = min(end, int(ox)) color = item.get("color", "") if not re.fullmatch(r"#[0-9a-fA-F]{6}", color) or end <= left or bottom <= top: continue rgb = np.array([int(color[i:i + 2], 16) for i in (1, 3, 5)]) ink = np.max(np.abs(img_rgb[top:bottom, left:end].astype(np.int16) - rgb), axis=2) <= 24 count, labels, stats, _ = cv2.connectedComponentsWithStats(ink.astype(np.uint8), 8) matches = [] for label in range(1, count): bx, by, bw, bh, area = stats[label] if not (.12 * height <= bw <= .65 * height and .5 * height <= bh <= height and area >= 6 and bx + bw < end - left): continue ys, xs = np.nonzero(labels == label) slope = float(np.corrcoef(xs, ys)[0, 1]) if slope * (-1 if text.endswith("/") else 1) > .8: matches.append((int(bx), int(bw))) if len(matches) == 1: bx, bw = matches[0] if left + bx >= right and left + bx + bw > right: updated = {**item, "box": [x, y, left + bx + bw - x, height]} updated.pop("words", None) result[-1] = updated return result def _recover_trailing_heading_period( img_rgb: np.ndarray, text: str, box: tuple, ) -> tuple[str, tuple]: letters = [char for char in text if char.isalpha()] x, y, width, height = (int(value) for value in box) if ( text.endswith((".", "!", "?")) or height < 48 or len(letters) < 4 or not all(char.isascii() and char.isupper() for char in letters) ): return text, box image_height, image_width = img_rgb.shape[:2] right = x + width scan_width = min(image_width - right, max(8, int(round(height * 0.45)))) if scan_width <= 0: return text, box top = max(0, y) bottom = min(image_height, y + height) text_region = img_rgb[top:bottom, max(0, x):min(image_width, right)] candidate_region = img_rgb[top:bottom, right:right + scan_width] if text_region.size == 0 or candidate_region.size == 0: return text, box color = _sample_text_color(text_region) color_rgb = np.array( [int(color[index:index + 2], 16) for index in (1, 3, 5)], dtype=np.int16, ) color_distance = np.linalg.norm( candidate_region.astype(np.int16) - color_rgb, axis=2, ) candidate_mask = (color_distance <= 48.0).astype(np.uint8) count, _, stats, _ = cv2.connectedComponentsWithStats( candidate_mask, connectivity=8, ) matches = [] for index in range(1, count): left = int(stats[index, cv2.CC_STAT_LEFT]) candidate_top = int(stats[index, cv2.CC_STAT_TOP]) candidate_width = int(stats[index, cv2.CC_STAT_WIDTH]) candidate_height = int(stats[index, cv2.CC_STAT_HEIGHT]) area = int(stats[index, cv2.CC_STAT_AREA]) fill_ratio = area / max(1, candidate_width * candidate_height) if ( left <= height * 0.15 and candidate_top >= height * 0.58 and height * 0.08 <= candidate_width <= height * 0.30 and height * 0.08 <= candidate_height <= height * 0.30 and fill_ratio >= 0.45 and left + candidate_width < scan_width ): matches.append((left, candidate_width)) if len(matches) != 1: return text, box left, candidate_width = matches[0] return f"{text}.", (x, y, width + left + candidate_width, height) # --------------------------------------------------------------------------- # Style estimation # --------------------------------------------------------------------------- def _estimate_style( img_rgb: np.ndarray, box: tuple, *, reference_width: int | None = None, text: str = "", ) -> dict: """Estimate font_size, color, bold from the image region.""" if reference_width is not None and ( type(reference_width) is not int or reference_width <= 0 ): raise ValueError("reference_width must be a positive integer") x, y, w, h = box ih, iw = img_rgb.shape[:2] # Clamp x1 = max(0, x) y1 = max(0, y) x2 = min(iw, x + w) y2 = min(ih, y + h) region = img_rgb[y1:y2, x1:x2] if region.size == 0: return {"font_size": 12.0, "color": "#000000", "bold": False} # --- Font size estimation --- # The bbox height in pixels corresponds to the text line height. # To convert to PowerPoint points: # slide_width_inches = 13.333 (our PPTX slide width) # pixels_per_inch = image_width / slide_width_inches # bbox_height_inches = bbox_height_px / pixels_per_inch # font_size_pt = bbox_height_inches * 72 # Apply a correction factor: OCR bboxes include padding around text, # and larger text tends to have proportionally more padding. pixels_per_inch = (reference_width if reference_width is not None else iw) / 13.333 bbox_inches = h / pixels_per_inch raw_pt = bbox_inches * 72.0 # Non-linear correction: larger bboxes have more relative padding # Correction ranges from ~0.75 for small text to ~0.65 for large text correction = 0.75 - 0.001 * min(raw_pt, 100) font_size = raw_pt * correction font_size = max(6.0, min(font_size, 200.0)) # --- Color estimation --- color_hex = _sample_text_color(region) # --- Bold estimation --- bold = _estimate_bold(region, text=text) # OCR rectangles include variable padding; measure glyphs for known fonts. reference_font = _weight_reference_font(_has_cjk(text), bold) if text and "\n" not in text else None if reference_font is not None: gray = cv2.cvtColor(region, cv2.COLOR_RGB2GRAY).astype(np.float32) border = np.concatenate((gray[0], gray[-1], gray[:, 0], gray[:, -1])) contrast = np.abs(gray - float(np.median(border))) foreground = (contrast > float(contrast.max()) * 0.1).astype(np.uint8) horizontal = cv2.morphologyEx( foreground, cv2.MORPH_OPEN, np.ones((1, max(13, int(region.shape[1] * 0.8) | 1)), dtype=np.uint8), ) contrast[horizontal > 0] = 0 rows = np.flatnonzero(np.any(contrast > float(contrast.max()) * 0.5, axis=1)) bounds = reference_font.getbbox(text) glyph_height = bounds[3] - bounds[1] if len(rows) and glyph_height > 0: size_px = (rows[-1] - rows[0] + 1) * reference_font.size / glyph_height font_size = max(6.0, min(size_px * 72.0 / pixels_per_inch, 200.0)) return {"font_size": round(font_size, 1), "color": color_hex, "bold": bold} def _select_font(text: str, font_size: float) -> str: """Choose an editable font that better matches common Chinese slide styles.""" return "Microsoft YaHei" if _has_cjk(text) else "Arial" def refine_plain_text_fonts(image: np.ndarray, items: list[dict]) -> list[dict]: from scripts.font_match import match_text_face refined = [] for item in items: text = item.get('text', '') x, y, width, height = map(int, item['box']) if (item.get('runs') or item.get('rotation') or item.get('italic') or item.get('outline_width') or item.get('gradient') or 'font_size_pt' in item or item.get('box_kind') == 'ink' or not 3 <= len(text) <= 128 or '\n' in text or x < 0 or y < 0 or width * height > 170000): refined.append(item) continue crop = np.ascontiguousarray(image[y:y+height, x:x+width]) if crop.shape != (height, width, 3) or not crop.size: refined.append(item) continue match = match_text_face(crop.tobytes(), width, height, text) if match is None: refined.append(item) continue left, top, ink_width, ink_height = match['ink_box'] refined.append({**item, 'font': match['font'], 'bold': match['bold'], 'font_size': match['font_size_px'] * 13.333 * 72 / image.shape[1], 'box': [x+left, y+top, ink_width, ink_height], 'box_kind': 'ink'}) return refined def _adjust_font_size( text: str, font_size: float, *, bbox_height: int | None = None, reference_width: int | None = None, ) -> float: """Constrain large Chinese title text so editable text does not wrap.""" letters = [char for char in text if char.isalpha()] if ( font_size >= 30.0 and len(letters) >= 4 and all(char.isascii() and char.isupper() for char in letters) and bbox_height is not None and reference_width is not None ): pixels_per_inch = reference_width / 13.333 return round(bbox_height / pixels_per_inch * 72.0 * 1.08, 1) if _has_cjk(text) and font_size >= 80.0: return round(font_size * 0.88, 1) if _has_cjk(text) and font_size >= 48.0: return round(font_size * 0.90, 1) return font_size def _should_force_regular_weight(text: str, font_size: float) -> bool: """Keep the detected weight for the sans-serif fallback fonts.""" return False def _has_cjk(text: str) -> bool: return any('\u4e00' <= c <= '\u9fff' for c in text) def _sample_text_color(region: np.ndarray) -> str: """Sample the dominant text (foreground) color in a text region. Uses Otsu thresholding to separate text from background, then uses border pixels to determine which class is background. This handles both dark-on-light and light-on-dark text correctly. """ if region.size == 0 or region.shape[0] < 3 or region.shape[1] < 3: return "#000000" gray = cv2.cvtColor(region, cv2.COLOR_RGB2GRAY) h, w = gray.shape # Otsu threshold to separate two classes (text vs background) thresh_val, _ = cv2.threshold( gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU ) # Use border pixels to determine which class is background # Border pixels are more likely to be background than text border_vals = np.concatenate([ gray[0, :], gray[-1, :], gray[:, 0], gray[:, -1] ]).astype(np.float32) border_mean = float(np.mean(border_vals)) flat = region.reshape(-1, 3).astype(np.float32) gray_flat = gray.reshape(-1).astype(np.float32) contrast = np.abs(gray.astype(np.float32) - float(np.median(border_vals))) foreground = (contrast > float(contrast.max()) * 0.1).astype(np.uint8) structure = np.zeros(gray.shape, dtype=np.uint8) for shape in ((1, max(13, int(w * 0.8) | 1)), (max(13, int(h * 0.8) | 1), 1)): structure |= cv2.morphologyEx( foreground, cv2.MORPH_OPEN, np.ones(shape, dtype=np.uint8), borderType=cv2.BORDER_CONSTANT, borderValue=0, ) non_structure = structure.reshape(-1) == 0 # A narrow glyph can itself resemble a rule; retain the dominant foreground. if np.count_nonzero(foreground.reshape(-1) & non_structure) >= ( 0.5 * np.count_nonzero(foreground) ): flat = flat[non_structure] gray_flat = gray_flat[non_structure] if border_mean > thresh_val: # Border is bright → background is bright → text is dark class text_pixels = flat[gray_flat <= thresh_val] else: # Border is dark → background is dark → text is bright class text_pixels = flat[gray_flat > thresh_val] if len(text_pixels) < 3: # Fallback: use pixels most different from border bg_color = np.median( flat[np.argsort(np.abs(gray_flat - border_mean))[:max(1, len(flat)//3)]], axis=0, ) dists = np.linalg.norm(flat - bg_color, axis=1) text_pixels = flat[dists > np.percentile(dists, 60)] if len(text_pixels) == 0: return "#000000" # Ink cores preserve the original color; antialiased edges blend with the background. text_luma = text_pixels @ np.array([0.299, 0.587, 0.114], dtype=np.float32) contrast = np.abs(text_luma - border_mean) text_pixels = text_pixels[contrast >= np.percentile(contrast, 90)] median_color = np.median(text_pixels, axis=0).astype(int) r, g, b = np.clip(median_color, 0, 255) return f"#{int(r):02x}{int(g):02x}{int(b):02x}" def _normalized_ink(contrast: np.ndarray) -> np.ndarray | None: """Keep fractional edge coverage and remove background-only padding.""" if contrast.size == 0 or float(contrast.max()) == 0: return None foreground = contrast[contrast > float(contrast.max()) * 0.1] ink = np.clip(contrast / float(np.percentile(foreground, 95)), 0, 1) ys, xs = np.nonzero(ink > 0.2) return ink[ys.min():ys.max() + 1, xs.min():xs.max() + 1] @lru_cache(maxsize=4) def _weight_reference_font(cjk: bool, bold: bool): filenames = ( ("msyhbd.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc") if bold else ("msyh.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc") ) if cjk else ( ("arialbd.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf") if bold else ("arial.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf") ) for filename in filenames: try: return ImageFont.truetype(filename, 128) except OSError: continue return None def _estimate_reference_bold(region: np.ndarray, text: str) -> bool | None: """Compare the same glyphs in the editable regular and bold fallback fonts.""" if not text.strip() or region.size == 0: return None gray = cv2.cvtColor(region, cv2.COLOR_RGB2GRAY).astype(np.float32) border = np.concatenate([gray[0], gray[-1], gray[:, 0], gray[:, -1]]) difference = gray - float(np.median(border)) magnitude = np.abs(difference) foreground = difference[magnitude > float(magnitude.max()) * 0.1] if foreground.size == 0: return None polarity = 1 if float(np.median(foreground)) > 0 else -1 ink = _normalized_ink(np.maximum(difference * polarity, 0)) if ink is None: return None height, width = ink.shape densities = [] for bold in (False, True): font = _weight_reference_font(_has_cjk(text), bold) if font is None: return None left, top, right, bottom = font.getbbox(text) if right <= left or bottom <= top: return None reference = Image.new("L", (right - left + 8, bottom - top + 8), 0) ImageDraw.Draw(reference).text((4 - left, 4 - top), text, font=font, fill=255) reference_ink = _normalized_ink(np.asarray(reference, dtype=np.float32)) if reference_ink is None: return None # Match raster scale before normalizing so small antialiased glyphs are comparable. reference_ink = _normalized_ink(cv2.resize( reference_ink, (width, height), interpolation=cv2.INTER_AREA, )) densities.append(float(reference_ink.mean())) observed = float(ink.mean()) return abs(observed - densities[1]) < abs(observed - densities[0]) def _estimate_bold(region: np.ndarray, *, text: str = "") -> bool: """Estimate bold weight from ink density and relative stroke width.""" if text: reference_bold = _estimate_reference_bold(region, text) if reference_bold is not None: return reference_bold if region.size == 0 or region.shape[0] < 5 or region.shape[1] < 5: return False gray = cv2.cvtColor(region, cv2.COLOR_RGB2GRAY) threshold, _ = cv2.threshold( gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU ) border = np.concatenate([ gray[0, :], gray[-1, :], gray[:, 0], gray[:, -1] ]) if float(np.mean(border)) > threshold: ink = gray <= threshold else: ink = gray > threshold ink_ratio = np.count_nonzero(ink) / ink.size stroke_depth = cv2.distanceTransform( ink.astype(np.uint8), cv2.DIST_L2, 5, ) strokes = stroke_depth[stroke_depth > 0] if strokes.size == 0: return False relative_stroke = float(np.percentile(strokes, 90)) / region.shape[0] return ink_ratio > 0.20 and relative_stroke >= 0.05 # --------------------------------------------------------------------------- # Alignment refinement # --------------------------------------------------------------------------- def _merge_adjacent_text_items(text_items: list[dict]) -> list[dict]: """Merge same-style OCR fragments that belong to one visual line.""" merged = [dict(item) for item in text_items] while True: best_pair = None best_score = None for i in range(len(merged)): for j in range(i + 1, len(merged)): left, right = sorted((merged[i], merged[j]), key=lambda item: item["box"][0]) if not _can_merge_text_items(left, right): continue gap = right["box"][0] - (left["box"][0] + left["box"][2]) center_gap = abs( (left["box"][1] + left["box"][3] / 2) - (right["box"][1] + right["box"][3] / 2) ) score = (max(gap, 0), center_gap) if best_score is None or score < best_score: best_pair = (i, j, left, right) best_score = score if best_pair is None: break i, j, left, right = best_pair for index in sorted((i, j), reverse=True): merged.pop(index) merged.append(_merge_text_pair(left, right)) return sorted(merged, key=lambda item: (item["box"][1], item["box"][0])) def _can_merge_text_items(left: dict, right: dict) -> bool: if "runs" in left or "runs" in right: return False if bool(left.get("words")) != bool(right.get("words")): return False lx, ly, lw, lh = left["box"] rx, ry, rw, rh = right["box"] if rx < lx: return False overlap = max(0, min(ly + lh, ry + rh) - max(ly, ry)) if overlap / max(1, min(lh, rh)) < 0.60: return False gap = rx - (lx + lw) max_height = max(lh, rh) if gap < -0.35 * max_height or gap > max(6, 0.45 * max_height): return False left_size = float(left.get("font_size", 12)) right_size = float(right.get("font_size", 12)) if abs(left_size - right_size) / max(left_size, right_size, 1) > 0.25: return False if left.get("bold", False) != right.get("bold", False): return False return _colors_are_close(left.get("color", "#000000"), right.get("color", "#000000")) def _colors_are_close(left: str, right: str, max_distance: float = 48.0) -> bool: try: lrgb = np.array([int(left[i:i + 2], 16) for i in (1, 3, 5)]) rrgb = np.array([int(right[i:i + 2], 16) for i in (1, 3, 5)]) except (TypeError, ValueError): return left == right return float(np.linalg.norm(lrgb - rrgb)) <= max_distance def _merge_text_pair(left: dict, right: dict) -> dict: lx, ly, lw, lh = left["box"] rx, ry, rw, rh = right["box"] left_char = left["text"][-1:] right_char = right["text"][:1] separator = "" if not ( (_has_cjk(left_char) and _has_cjk(right_char)) or (left_char.isdigit() and _has_cjk(right_char)) ): separator = " " merged = dict(left) merged["box"] = [ min(lx, rx), min(ly, ry), max(lx + lw, rx + rw) - min(lx, rx), max(ly + lh, ry + rh) - min(ly, ry), ] merged["text"] = left["text"] + separator + right["text"] merged["font_size"] = max(float(left.get("font_size", 12)), float(right.get("font_size", 12))) merged["font"] = _select_font(merged["text"], merged["font_size"]) merged["confidence"] = min(float(left.get("confidence", 1)), float(right.get("confidence", 1))) merged.pop("words", None) if left.get("words") and right.get("words"): mx, my, mw, mh = merged["box"] words = [] for item in (left, right): x, y, width, height = item["box"] for word in _validated_words(item["text"], item["words"]): wx, wy, ww, wh = word["box"] words.append({"text": word["text"], "box": [(x + wx * width - mx) / mw, (y + wy * height - my) / mh, ww * width / mw, wh * height / mh]}) words = _validated_words(merged["text"], words) if words: merged["words"] = words return merged def _refine_alignment(text_items: list[dict], img_width: int) -> list[dict]: """Refine text alignment by analyzing horizontal positions. Wide text (>= 50% of image width) near the image center → center aligned with full-width text box for proper PowerPoint centering. Narrow text (< 50% of image width) → placed at detected position using left/right alignment based on which side of the image it's on. This handles column layouts where text is left-aligned within a column. """ for item in text_items: x, y, w, h = item["box"] center_x = x + w / 2 img_center = img_width / 2 is_wide = w >= img_width * 0.5 # Tight center check: any text very close to image center is_near_center = abs(center_x - img_center) < img_width * 0.05 if is_near_center: # Any text (narrow or wide) very close to center → center item["align"] = 1 elif is_wide and abs(center_x - img_center) < img_width * 0.15: # Wide text near center → full-width centered box item["align"] = 1 else: # Non-centered text: position at detected location, left-aligned # The text box is placed at the OCR-detected coordinates, # so left alignment within the box matches the original layout. item["align"] = 0 return text_items # --------------------------------------------------------------------------- # Mask building # --------------------------------------------------------------------------- def _build_text_mask( shape: tuple, text_items: list[dict], padding: int = 6 ) -> np.ndarray: """Build a binary mask covering all text bounding boxes.""" h, w = shape[:2] mask = np.zeros((h, w), dtype=np.uint8) for item in text_items: x, y, bw, bh = item["box"] x1 = max(0, int(x - padding)) y1 = max(0, int(y - padding)) x2 = min(w, int(x + bw + padding)) y2 = min(h, int(y + bh + padding)) mask[y1:y2, x1:x2] = 255 return mask # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _load_rgb(path: Path) -> np.ndarray: """Load image as RGB numpy array.""" img = cv2.imdecode(np.fromfile(str(path), dtype=np.uint8), cv2.IMREAD_COLOR) if img is None: raise FileNotFoundError(f"Cannot read image: {path}") return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) -
text_runs.py 3.9 KB
"""Validated native text runs positioned relative to an OCR line.""" from __future__ import annotations import math def validate_text_words(item: dict) -> None: words = item.get("words") if not isinstance(words, list) or not words: raise ValueError("text words must be a nonempty list") for word in words: if not isinstance(word, dict) or set(word) != {"text", "box"}: raise ValueError("text words fields are invalid") try: recognized = "".join(word["text"] for word in words) validate_text_runs({"text": recognized, "runs": words}) if "".join(recognized.split()) != "".join(item["text"].split()): raise ValueError("text words must preserve source text") except (ValueError, TypeError, AttributeError) as error: raise ValueError("text words content or geometry is invalid") from error def validate_text_runs(item: dict) -> None: runs = item.get("runs") required = {"text", "box"} allowed = required | { "font", "font_size", "color", "bold", "rotation", "outline_color", "outline_width", "box_kind", "gradient", } if not isinstance(runs, list) or not runs: raise ValueError("text runs must be a nonempty list") for run in runs: if not isinstance(run, dict) or not required <= set(run) <= allowed: raise ValueError("text runs fields are invalid") if run.get("box_kind", "layout") not in ("layout", "ink"): raise ValueError("text runs box kind is invalid") box = run["box"] if ( not isinstance(run["text"], str) or not run["text"] or not isinstance(box, list) or len(box) != 4 or any(type(v) not in {int, float} or not math.isfinite(v) for v in box) or min(box[:2]) < 0 or min(box[2:]) <= 0 or box[0] + box[2] > 1.000001 or box[1] + box[3] > 1.000001 ): raise ValueError("text runs content or box is invalid") for key in ("font_size", "rotation", "outline_width"): if key in run and ( type(run[key]) not in {int, float} or not math.isfinite(run[key]) or (key == "font_size" and not 0 < run[key] <= 4000) or (key == "outline_width" and not 0 <= run[key] <= 1584) or (key == "rotation" and not -360 <= run[key] <= 360) ): raise ValueError("text runs numeric style is invalid") for key in ("color", "outline_color"): if key in run and ( not isinstance(run[key], str) or len(run[key]) != 7 or run[key][0] != "#" or any(c not in "0123456789abcdefABCDEF" for c in run[key][1:]) ): raise ValueError("text runs color is invalid") if ("outline_color" in run) != ("outline_width" in run): raise ValueError("text runs outline requires color and width") if "bold" in run and type(run["bold"]) is not bool: raise ValueError("text runs weight is invalid") if "font" in run and (not isinstance(run["font"], str) or not run["font"].strip()): raise ValueError("text runs font is invalid") if "gradient" in run: gradient = run["gradient"] if (not isinstance(gradient, dict) or set(gradient) != {"angle", "colors"} or type(gradient["angle"]) not in {int, float} or not math.isfinite(gradient["angle"]) or not 0 <= gradient["angle"] < 360 or not isinstance(gradient["colors"], list) or len(gradient["colors"]) != 2 or any(not isinstance(color, str) or len(color) != 7 or color[0] != "#" or any(c not in "0123456789abcdefABCDEF" for c in color[1:]) for color in gradient["colors"])): raise ValueError("text runs gradient is invalid") if "".join(run["text"] for run in runs) != item.get("text"): raise ValueError("text runs must preserve all source text in order") -
verify_skill_runtime.py 2.9 KB
"""Check installed runtime contents against the exact source selected by a Skill.""" from __future__ import annotations import argparse import hashlib from importlib import metadata, util import json from pathlib import Path def verify(source: Path, distribution) -> list[str]: expected = { path.relative_to(source).as_posix(): path for package in ("image2editable", "scripts") for path in (source / package).rglob("*.py") } for name in ("image_to_ppt.py", "image_to_psd.py", "image2editable/runtime_model_catalog.json"): expected[name] = source / name if not (source / "image2editable/cli.py").is_file(): raise ValueError("Source is not an image2editable repository") installed = {str(path).replace("\\", "/") for path in distribution.files or []} problems = [] for name, path in expected.items(): target = Path(distribution.locate_file(name)) if name not in installed or not target.is_file(): problems.append(f"missing: {name}") elif hashlib.sha256(path.read_bytes()).digest() != hashlib.sha256(target.read_bytes()).digest(): problems.append(f"different: {name}") for name in installed: if (name.startswith(("image2editable/", "scripts/")) and name.endswith(".py") and name not in expected): problems.append(f"obsolete: {name}") return sorted(problems) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("source", type=Path) args = parser.parse_args() try: distribution = metadata.distribution("image2editable") except metadata.PackageNotFoundError: print(json.dumps({"ready": False, "problems": ["image2editable is not installed"]})) return 1 source = args.source.resolve() required = ("image2editable/cli.py", "image2editable/runtime_models.py", "scripts/__init__.py", "scripts/psd_assemble.py", "image_to_ppt.py", "image_to_psd.py", "image2editable/runtime_model_catalog.json") missing = [name for name in required if not (source / name).is_file()] try: if missing: raise ValueError("Source is incomplete: " + ", ".join(missing)) problems = verify(source, distribution) except (OSError, ValueError) as error: print(json.dumps({"ready": False, "problems": [str(error)]})) return 1 for name in ("image2editable", "scripts"): spec = util.find_spec(name) expected = Path(distribution.locate_file(f"{name}/__init__.py")).resolve() if spec is None or spec.origin is None or Path(spec.origin).resolve() != expected: problems.append(f"import shadowed: {name}") print(json.dumps({"ready": not problems, "version": distribution.version, "problems": problems}, ensure_ascii=False, indent=2)) return 1 if problems else 0 if __name__ == "__main__": raise SystemExit(main()) -
visual_compare_qa.py 3.6 KB
#!/usr/bin/env python3 """Generate visual QA artifacts for a source image and a rendered PPT preview.""" from __future__ import annotations import argparse import json from pathlib import Path from PIL import Image, ImageChops, ImageDraw, ImageOps, ImageStat def write_visual_compare( source_path: str | Path, preview_path: str | Path, out_dir: str | Path, ) -> dict: """Write side-by-side, blend, heatmap, and JSON diff metrics.""" source_path = Path(source_path) preview_path = Path(preview_path) out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) with Image.open(source_path) as src, Image.open(preview_path) as prv: preview = prv.convert("RGB") source = src.convert("RGB").resize(preview.size) source.save(out_dir / "source_resized.png") preview.save(out_dir / "preview.png") side_by_side = Image.new("RGB", (preview.width * 2, preview.height), (18, 18, 18)) side_by_side.paste(source, (0, 0)) side_by_side.paste(preview, (preview.width, 0)) draw = ImageDraw.Draw(side_by_side) draw.rectangle([0, 0, 120, 28], fill=(0, 0, 0)) draw.rectangle([preview.width, 0, preview.width + 120, 28], fill=(0, 0, 0)) draw.text((8, 7), "source", fill=(255, 255, 255)) draw.text((preview.width + 8, 7), "preview", fill=(255, 255, 255)) side_by_side.save(out_dir / "side_by_side.png") blend = Image.blend(source, preview, 0.5) blend.save(out_dir / "blend.png") diff = ImageChops.difference(source, preview) stat = ImageStat.Stat(diff) mean_abs = sum(stat.mean) / 3.0 rms = (sum(v * v for v in stat.rms) / 3.0) ** 0.5 gray = ImageOps.grayscale(diff) hist = gray.histogram() total = max(preview.width * preview.height, 1) changed_32 = sum(hist[32:]) / total changed_64 = sum(hist[64:]) / total heat_alpha = gray.point(lambda v: min(220, int(v * 1.35))) heat = Image.new("RGBA", preview.size, (255, 0, 0, 0)) heat.putalpha(heat_alpha) heat_base = source.convert("RGBA") heat_base.alpha_composite(heat) heat_base.convert("RGB").save(out_dir / "diff_heatmap.png") report = { "source": str(source_path), "preview": str(preview_path), "preview_size": list(preview.size), "mean_abs_diff_0_255": round(mean_abs, 4), "rms_diff_0_255": round(rms, 4), "changed_pixel_fraction_threshold_32": round(changed_32, 6), "changed_pixel_fraction_threshold_64": round(changed_64, 6), "artifacts": { "source_resized": str(out_dir / "source_resized.png"), "preview": str(out_dir / "preview.png"), "side_by_side": str(out_dir / "side_by_side.png"), "blend": str(out_dir / "blend.png"), "diff_heatmap": str(out_dir / "diff_heatmap.png"), }, } (out_dir / "report.json").write_text( json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) return report def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("source", help="Original source image.") parser.add_argument("preview", help="Rendered PPT preview image.") parser.add_argument("--out-dir", required=True, help="Directory for QA artifacts.") args = parser.parse_args() report = write_visual_compare(args.source, args.preview, args.out_dir) print(f"Wrote {report['artifacts']['side_by_side']}") print(f"Wrote {report['artifacts']['blend']}") print(f"Wrote {report['artifacts']['diff_heatmap']}") print(f"Wrote {Path(args.out_dir) / 'report.json'}") if __name__ == "__main__": main() -
visual_segment.py 87.5 KB
from __future__ import annotations import importlib import io import json import math import os import copy import ctypes import errno import hashlib import hmac import stat import sys import uuid from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path import cv2 import numpy as np from PIL import Image from scripts.runtime_model_paths import ( RuntimeModelPathError, resolve_runtime_model_path, ) SAM21_LARGE_CONFIG = "configs/sam2.1/sam2.1_hiera_l.yaml" class VisualSegmentationError(RuntimeError): pass class RecoverableComponentPlanError(VisualSegmentationError): def __init__( self, message: str, *, reason: str = "unrelated_residual_target", ) -> None: super().__init__(message) self.reason = reason def _complete_opaque_mask_regions( mask: np.ndarray, image: np.ndarray | None = None ) -> np.ndarray: """Fill small topology gaps only inside already-solid visual regions.""" source = np.asarray(mask, dtype=bool) completed = source.copy() count, labels, stats, _ = cv2.connectedComponentsWithStats( source.astype(np.uint8), 8 ) for label in range(1, count): x, y, width, height, area = (int(value) for value in stats[label]) box_area = width * height if min(width, height) < 8 or area < 64 or area / max(1, box_area) < 0.78: continue component = (labels[y:y + height, x:x + width] == label).astype(np.uint8) closed = cv2.morphologyEx( component, cv2.MORPH_CLOSE, np.ones((3, 3), dtype=np.uint8) ) if np.count_nonzero(closed) <= area * 1.25: completed[y:y + height, x:x + width] |= closed.astype(bool) if image is None or not np.any(completed): return completed pixels = np.asarray(image, dtype=np.uint8) if pixels.shape[:2] != completed.shape or pixels.ndim != 3: raise ValueError("mask completion image dimensions differ") ys, xs = np.nonzero(completed) pad = max(4, min(12, round(min(ys.max() - ys.min() + 1, xs.max() - xs.min() + 1) * 0.05))) y0, y1 = max(0, int(ys.min()) - pad), min(completed.shape[0], int(ys.max()) + pad + 1) x0, x1 = max(0, int(xs.min()) - pad), min(completed.shape[1], int(xs.max()) + pad + 1) local = completed[y0:y1, x0:x1] dilated = cv2.dilate(local.astype(np.uint8), np.ones((9, 9), np.uint8)) > 0 near = cv2.dilate(local.astype(np.uint8), np.ones((3, 3), np.uint8)) > 0 ring = dilated & ~near if np.count_nonzero(ring) < 32: return completed crop = pixels[y0:y1, x0:x1] background = np.median(crop[ring], axis=0) distance = np.linalg.norm(crop.astype(np.float32) - background, axis=2) quiet = distance[ring] <= 14.0 if np.count_nonzero(quiet) < np.count_nonzero(ring) * 0.6: return completed foreground = distance > 18.0 foreground |= ( (distance > 3.0) & (cv2.dilate(local.astype(np.uint8), np.ones((3, 3), np.uint8)) > 0) ) foreground &= ~local count, labels, stats, _ = cv2.connectedComponentsWithStats( foreground.astype(np.uint8), 8 ) touching = cv2.dilate(local.astype(np.uint8), np.ones((3, 3), np.uint8)) > 0 recovered = local.copy() for label in range(1, count): candidate = labels == label contact = candidate & touching contact_count = int(np.count_nonzero(contact)) if stats[label, cv2.CC_STAT_AREA] < 9 or contact_count < 3: continue compatible = 0 for contact_y, contact_x in zip(*np.nonzero(contact)): neighbor_y0 = max(0, int(contact_y) - 2) neighbor_y1 = min(local.shape[0], int(contact_y) + 3) neighbor_x0 = max(0, int(contact_x) - 2) neighbor_x1 = min(local.shape[1], int(contact_x) + 3) neighbor_mask = local[ neighbor_y0:neighbor_y1, neighbor_x0:neighbor_x1, ] if not np.any(neighbor_mask): continue neighbor_colors = crop[ neighbor_y0:neighbor_y1, neighbor_x0:neighbor_x1, ][neighbor_mask].astype(np.float32) contact_color = crop[contact_y, contact_x].astype(np.float32) color_distance = np.linalg.norm(neighbor_colors - contact_color, axis=1) neighbor_vectors = neighbor_colors - background contact_vector = contact_color - background neighbor_norms = np.linalg.norm(neighbor_vectors, axis=1) contact_norm = float(np.linalg.norm(contact_vector)) alignment = ( neighbor_vectors @ contact_vector / np.maximum(neighbor_norms * contact_norm, 1e-6) ) subtle_aligned_edge = ( contact_norm > 3.0 and contact_norm <= float(np.max(neighbor_norms)) * 0.45 and np.count_nonzero(alignment >= 0.9) >= 3 ) if ( np.count_nonzero(color_distance <= 30.0) >= 3 or subtle_aligned_edge ): compatible += 1 if compatible >= max(3, round(contact_count * 0.6)): recovered |= candidate if np.count_nonzero(recovered) <= np.count_nonzero(local) * 2.0: completed[y0:y1, x0:x1] = recovered return completed def execute_component_actions( image: np.ndarray, graph: dict, actions: list[dict], *, input_dir: str | Path, output_dir: str | Path, sam_runner=None, sam_batch_runner=None, ) -> dict: """Execute requested mask edits; never decide quality-gate outcomes.""" try: from image2editable.component_contracts import ( validate_component_action, validate_component_graph, validate_graph_transition, ) except ModuleNotFoundError: from component_contracts import ( # type: ignore[no-redef] validate_component_action, validate_component_graph, validate_graph_transition, ) source = Path(input_dir) target = Path(output_dir) if target.exists() or target.is_symlink(): raise FileExistsError(f"Component action output already exists: {target}") validated = validate_component_graph(graph) result = copy.deepcopy(validated) nodes = {node["id"]: node for node in result["nodes"]} loaded_masks = { component_id: _read_action_mask(source / node["mask"], image.shape[:2], node["mask_sha256"]) for component_id, node in nodes.items() } masks = {component_id: loaded[0] for component_id, loaded in loaded_masks.items()} mask_payloads = {component_id: loaded[1] for component_id, loaded in loaded_masks.items()} residual_targets = [ action["object_ids"][0] for action in actions if action["action"] == "absorb_residual" ] bound_residuals = _partition_bound_residual_mask( source, image.shape[:2], residual_targets, masks ) if residual_targets else {} touched = {} suppressed_text_ids = set() text_backing = None reactivated_ids = set() planned_retry_ids = set() for action in actions: validate_component_action(action, graph=validated) object_ids = action["object_ids"] name = action["action"] if name == "attach_text": visual, text = object_ids valid_states = ( nodes[visual]["state"] == "pending" and nodes[text]["state"] == "frozen" ) if not valid_states: raise ValueError("attach_text requires pending visual and frozen text") elif name == "suppress_text": valid_states = ( nodes[object_ids[0]]["kind"] == "text" and nodes[object_ids[0]]["state"] == "frozen" ) if not valid_states: raise ValueError("suppress_text requires a frozen text object") suppressed_text_ids.add(object_ids[0]) text_backing = None elif name == "collapse_to_parent": allowed_states = {"inactive", "pending"} valid_states = all( nodes[value]["state"] in allowed_states for value in object_ids ) elif name == "absorb_into_parent": parent, *absorbed = object_ids valid_states = ( nodes[parent]["state"] in {"inactive", "pending"} and all(nodes[value]["state"] == "pending" for value in absorbed) ) elif name == "rebuild_background": valid_states = all( nodes[value]["state"] in {"pending", "frozen"} or value in planned_retry_ids for value in object_ids ) elif name in {"retry_with_box", "retry_with_points", "absorb_residual"}: valid_states = nodes[object_ids[0]]["state"] in { "pending", "inactive" } else: allowed_states = {"pending"} valid_states = all( nodes[value]["state"] in allowed_states for value in object_ids ) if not valid_states: raise ValueError(f"{name} requires a pending component") if name != "rebuild_background": if any( value in touched and not (touched[value] == "accept" and name == "absorb_residual") for value in object_ids ): raise ValueError("component plan has conflicting object actions") touched.update({value: name for value in object_ids}) if name in {"retry_with_box", "retry_with_points"}: planned_retry_ids.update(object_ids) elif name == "absorb_residual" and nodes[object_ids[0]]["state"] == "inactive": planned_retry_ids.update(object_ids) height, width = image.shape[:2] retry_prompts = [] for action in actions: if action["action"] == "suppress_text": left, top, right, bottom = nodes[action["object_ids"][0]]["bbox"] retry_prompts.append({ "component_id": action["object_ids"][0], "box": [float(left), float(top), float(right), float(bottom)], "positive": [], "negative": [], }) continue if action["action"] not in {"retry_with_box", "retry_with_points"}: continue parameters = action["parameters"] box = parameters.get("box") retry_prompts.append({ "component_id": action["object_ids"][0], "box": ( None if box is None else [ box[0] * width, box[1] * height, box[2] * width, box[3] * height, ] ), "positive": [ [point[0] * (width - 1), point[1] * (height - 1)] for point in parameters.get("positive", []) ], "negative": [ [point[0] * (width - 1), point[1] * (height - 1)] for point in parameters.get("negative", []) ], }) retry_masks = {} exact_retry_ids = set() if retry_prompts: known_text = np.zeros(image.shape[:2], dtype=bool) for node in nodes.values(): if node['kind'] == 'text' and node['state'] == 'frozen': known_text |= masks[node['id']] remaining_prompts = [] for prompt in retry_prompts: local_mask = _flat_stroke_prompt_mask(image, prompt, known_text) if local_mask is None: remaining_prompts.append(prompt) else: retry_masks[prompt['component_id']] = local_mask exact_retry_ids.add(prompt['component_id']) retry_prompts = remaining_prompts if retry_prompts: if sam_batch_runner is not None: proposed_results = sam_batch_runner(image=image, prompts=retry_prompts) if type(proposed_results) is not list or len(proposed_results) != len(retry_prompts): raise VisualSegmentationError("SAM component retry returned an invalid mask batch") else: runner = sam_runner if runner is None: from scripts.sam_worker import run_component_prompt_worker def runner(**values): return run_component_prompt_worker( values["image"], box=values["box"], positive=values["positive"], negative=values["negative"], work_dir=target.parent, ) proposed_results = [ { "component_id": prompt["component_id"], "mask": np.asarray( runner( image=image, box=prompt["box"], positive=prompt["positive"], negative=prompt["negative"], ), dtype=bool, ), } for prompt in retry_prompts ] for prompt, proposed_result in zip(retry_prompts, proposed_results): if ( not isinstance(proposed_result, dict) or set(proposed_result) != {"component_id", "mask"} or proposed_result["component_id"] != prompt["component_id"] ): raise VisualSegmentationError("SAM component retry result order is invalid") proposed = proposed_result["mask"] if ( not isinstance(proposed, np.ndarray) or proposed.dtype != np.bool_ or proposed.shape != image.shape[:2] or not proposed.any() ): raise VisualSegmentationError("SAM component retry returned an invalid mask") retry_masks[prompt["component_id"]] = proposed.copy() for action in actions: object_ids = action["object_ids"] name = action["action"] if name == "accept": if nodes[object_ids[0]]["state"] != "pending": raise ValueError("accept requires a pending component") accepted = nodes[object_ids[0]] accepted_mask = masks[object_ids[0]] if action["parameters"].get("independent") is True: parent_id = accepted["parent_id"] if parent_id is not None: if text_backing is None: text_backing = np.zeros(image.shape[:2], dtype=bool) for node in nodes.values(): if ( node["kind"] == "text" and node["state"] == "frozen" and node["id"] not in suppressed_text_ids ): text_backing |= masks[node["id"]] left, top, right, bottom = accepted["bbox"] within_bounds = np.zeros(image.shape[:2], dtype=bool) within_bounds[top:bottom, left:right] = True accepted_mask |= ( masks[parent_id] & text_backing & within_bounds ) accepted["kind"] = "parent" accepted["parent_id"] = None completed_mask = ( accepted_mask if action["parameters"].get("preserve_mask") is True else _complete_opaque_mask_regions(accepted_mask, image) ) active_visual_masks = [ masks[node["id"]] for node in nodes.values() if ( node["id"] != accepted["id"] and node["kind"] != "text" and node["state"] in {"pending", "pending_gate", "frozen"} ) ] if active_visual_masks: active_visual_mask = np.logical_or.reduce(active_visual_masks) completion_delta = completed_mask & ~accepted_mask completed_mask = accepted_mask | ( completion_delta & ~active_visual_mask ) masks[object_ids[0]] = completed_mask accepted["state"] = "pending_gate" elif name == "discard": nodes[object_ids[0]]["state"] = "inactive" elif name == "rebuild_background": pass elif name == "attach_text": visual, text = object_ids nodes[visual]["text_ids"] = sorted(set(nodes[visual]["text_ids"] + [text])) elif name == "suppress_text": text_id = object_ids[0] nodes[text_id]["state"] = "inactive" for node in nodes.values(): node["text_ids"] = [ value for value in node["text_ids"] if value != text_id ] promoted_id = _new_action_id(nodes, "component") nodes[promoted_id] = { "id": promoted_id, "kind": "parent", "parent_id": None, "state": "pending", "mask": f"masks/{promoted_id}.png", "mask_sha256": "", "bbox": [0, 0, 1, 1], "z_index": nodes[text_id]["z_index"], "text_ids": [], } masks[promoted_id] = _complete_opaque_mask_regions( retry_masks[text_id], image ) elif name == "collapse_to_parent": parent = object_ids[0] if nodes[parent]["state"] == "inactive": reactivated_ids.add(parent) nodes[parent]["state"] = "pending" _deactivate_descendants(nodes, parent) elif name == "absorb_into_parent": parent, *absorbed = object_ids masks[parent] = _complete_opaque_mask_regions( np.logical_or.reduce([masks[value] for value in object_ids]), image, ) if nodes[parent]["state"] == "inactive": reactivated_ids.add(parent) nodes[parent]["state"] = "pending" for component_id in absorbed: nodes[component_id]["state"] = "inactive" _deactivate_descendants(nodes, parent) elif name == "merge": selected = [nodes[value] for value in object_ids] merged = np.logical_or.reduce([masks[value] for value in object_ids]) for node in selected: node["state"] = "inactive" new_id = _new_action_id(nodes, "merge") merged_kind = selected[0]["kind"] merged_parent = selected[0]["parent_id"] nodes[new_id] = { "id": new_id, "kind": merged_kind, "parent_id": merged_parent, "state": "pending", "mask": f"masks/{new_id}.png", "mask_sha256": "", "bbox": [0, 0, 1, 1], "z_index": min(node["z_index"] for node in selected), "text_ids": sorted({value for node in selected for value in node["text_ids"]}), } masks[new_id] = merged elif name == "split": component_id = object_ids[0] text_mask = np.zeros(image.shape[:2], dtype=bool) for node in nodes.values(): if node["kind"] == "text" and node["state"] == "frozen": text_mask |= masks[node["id"]] parts = _connected_action_parts( masks[component_id], action["parameters"]["parts"], image=image, text_mask=text_mask, ) nodes[component_id]["state"] = "inactive" next_z = max(node["z_index"] for node in nodes.values()) + 1 for index, part in enumerate(parts, start=1): new_id = _new_action_id(nodes, "split") original = nodes[component_id] kind = "child" if original["kind"] == "parent" else original["kind"] parent_id = component_id if original["kind"] == "parent" else original["parent_id"] nodes[new_id] = { "id": new_id, "kind": kind, "parent_id": parent_id, "state": "pending", "mask": f"masks/{new_id}.png", "mask_sha256": "", "bbox": [0, 0, 1, 1], "z_index": next_z + index - 1, "text_ids": [], } masks[new_id] = part elif name in {"expand", "shrink"}: component_id = object_ids[0] radius = max(1, round(min(image.shape[:2]) * action["parameters"]["margin_ratio"])) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1, 2 * radius + 1)) current = masks[component_id].astype(np.uint8) changed = cv2.dilate(current, kernel) if name == "expand" else cv2.erode(current, kernel) parent_id = nodes[component_id]["parent_id"] if name == "expand": support = masks[parent_id] if parent_id is not None else cv2.dilate(current, kernel) changed = np.asarray(changed, dtype=bool) & np.asarray(support, dtype=bool) # Add uncovered edges without taking pixels from neighboring objects. added = changed & ~current.astype(bool) for other_id, other in nodes.items(): if ( other_id != component_id and other["kind"] != "text" and other["state"] in {"pending", "pending_gate", "frozen"} ): added &= ~masks[other_id] changed = current.astype(bool) | added masks[component_id] = np.asarray(changed, dtype=bool) elif name == "absorb_residual": component_id = object_ids[0] if nodes[component_id]["state"] == "inactive": # Restore only signed residual evidence, not the discarded composite. masks[component_id] = bound_residuals[component_id].copy() nodes[component_id].update( state="pending", kind="parent", parent_id=None, z_index=max(node["z_index"] for node in nodes.values()) + 1, text_ids=[], ) reactivated_ids.add(component_id) else: masks[component_id] |= bound_residuals[component_id] elif name in {"retry_with_box", "retry_with_points"}: component_id = object_ids[0] parameters = action["parameters"] proposed = retry_masks[component_id] if component_id not in exact_retry_ids: proposed = _complete_opaque_mask_regions(proposed, image) masks[component_id] = proposed if nodes[component_id]["state"] == "inactive": reactivated_ids.add(component_id) nodes[component_id]["state"] = "pending" parent_id = nodes[component_id]["parent_id"] if parent_id is not None and ( parameters.get("independent") is True or np.any(proposed & ~masks[parent_id]) ): nodes[component_id]["kind"] = "parent" nodes[component_id]["parent_id"] = None else: raise AssertionError(f"Unsupported component action: {name}") result["nodes"] = list(nodes.values()) staging = target.with_name(f".{target.name}.tmp-{uuid.uuid4().hex}") try: staging.mkdir(parents=False) mask_dir = staging / "masks" mask_dir.mkdir() for node in result["nodes"]: mask = masks[node["id"]] if not mask.any(): raise VisualSegmentationError(f"Component action produced an empty mask: {node['id']}") if node["state"] == "frozen": path = staging / node["mask"] path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(mask_payloads[node["id"]]) continue path = mask_dir / f"{node['id']}.png" Image.fromarray(mask.astype(np.uint8) * 255).save(path) node["mask"] = f"masks/{node['id']}.png" node["mask_sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() ys, xs = np.where(mask) node["bbox"] = [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1] validate_graph_transition( before=validated, after=result, allowed_suppressed_text_ids=suppressed_text_ids, allowed_reactivated_ids=reactivated_ids, ) (staging / "component-graph.json").write_text( json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) _publish_action_directory(staging, target) except BaseException: raise return result def _publish_action_directory(staging: Path, target: Path) -> None: """Atomically publish a directory without replacing an existing target.""" if sys.platform == "darwin": _publish_darwin_action_directory(staging, target) return if os.name == "nt": try: staging.rename(target) except FileExistsError: raise except OSError as error: if target.exists() or target.is_symlink(): raise FileExistsError(f"Component action output already exists: {target}") from error raise return libc = ctypes.CDLL(None, use_errno=True) renameat2 = getattr(libc, "renameat2", None) if renameat2 is None: raise RuntimeError("Atomic no-replace directory publication is unavailable") renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] renameat2.restype = ctypes.c_int if renameat2(-100, os.fsencode(staging), -100, os.fsencode(target), 1) == 0: return error_number = ctypes.get_errno() if error_number == errno.EEXIST: raise FileExistsError(f"Component action output already exists: {target}") raise OSError(error_number, os.strerror(error_number), str(target)) def _publish_darwin_action_directory(staging: Path, target: Path) -> None: parent = Path(os.path.abspath(staging.parent)) if Path(os.path.abspath(target.parent)) != parent: raise RuntimeError("Component action directories have different parents") parent_status = _action_directory_status(parent, "parent") staging_status = _action_directory_status(staging, "staging") libc = ctypes.CDLL(None, use_errno=True) renameatx_np = getattr(libc, "renameatx_np", None) if renameatx_np is None: raise RuntimeError("Atomic renameatx_np directory publication is unavailable") renameatx_np.argtypes = [ ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint, ] renameatx_np.restype = ctypes.c_int directory_flag = getattr(os, "O_DIRECTORY", None) nofollow_flag = getattr(os, "O_NOFOLLOW", None) if directory_flag is None or nofollow_flag is None: raise RuntimeError("Component action parent cannot be opened safely") flags = os.O_RDONLY | directory_flag | nofollow_flag flags |= getattr(os, "O_CLOEXEC", 0) parent_descriptor = os.open(parent, flags) try: _validate_action_parent(parent, parent_descriptor, parent_status) current_staging = _action_directory_status(staging, "staging") if ( current_staging.st_dev, current_staging.st_ino, ) != ( staging_status.st_dev, staging_status.st_ino, ): raise RuntimeError( f"Component action staging identity changed: {staging}" ) result = renameatx_np( parent_descriptor, os.fsencode(staging.name), parent_descriptor, os.fsencode(target.name), 4, ) error_number = ctypes.get_errno() _validate_action_parent(parent, parent_descriptor, parent_status) if result == 0: return if error_number in {errno.EEXIST, errno.ENOTEMPTY}: raise FileExistsError( f"Component action output already exists: {target}" ) raise OSError(error_number, os.strerror(error_number), str(target)) finally: os.close(parent_descriptor) def _action_directory_status(path: Path, label: str): try: status = path.lstat() except FileNotFoundError as error: raise RuntimeError( f"Component action {label} identity changed: {path}" ) from error reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) if ( stat.S_ISLNK(status.st_mode) or bool(getattr(status, "st_file_attributes", 0) & reparse) or not stat.S_ISDIR(status.st_mode) ): raise RuntimeError(f"Component action {label} is unsafe: {path}") return status def _validate_action_parent(parent: Path, descriptor: int, expected) -> None: opened = os.fstat(descriptor) current = _action_directory_status(parent, "parent") identity = (expected.st_dev, expected.st_ino) if ( not stat.S_ISDIR(opened.st_mode) or (opened.st_dev, opened.st_ino) != identity or (current.st_dev, current.st_ino) != identity ): raise RuntimeError(f"Component action parent identity changed: {parent}") def _read_action_mask(path: Path, shape: tuple[int, int], digest: str) -> tuple[np.ndarray, bytes]: status = path.lstat() reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) if ( stat.S_ISLNK(status.st_mode) or bool(getattr(status, "st_file_attributes", 0) & reparse) or not stat.S_ISREG(status.st_mode) or status.st_nlink != 1 ): raise VisualSegmentationError(f"Component action mask path is unsafe: {path}") flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) flags |= getattr(os, "O_NOFOLLOW", 0) descriptor = os.open(path, flags) try: opened = os.fstat(descriptor) if ( not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1 or (opened.st_dev, opened.st_ino) != (status.st_dev, status.st_ino) ): raise VisualSegmentationError(f"Component action mask identity changed: {path}") chunks = [] while True: chunk = os.read(descriptor, 1024 * 1024) if not chunk: break chunks.append(chunk) after = os.fstat(descriptor) if (after.st_dev, after.st_ino, after.st_size) != ( opened.st_dev, opened.st_ino, opened.st_size ): raise VisualSegmentationError(f"Component action mask changed while reading: {path}") payload = b"".join(chunks) finally: os.close(descriptor) if hashlib.sha256(payload).hexdigest() != digest: raise VisualSegmentationError(f"Component action mask hash mismatch: {path}") with Image.open(io.BytesIO(payload)) as stored: mask = np.asarray(stored.convert("L")) > 0 if mask.shape != shape: raise VisualSegmentationError(f"Component action mask shape mismatch: {path}") return mask, payload def _read_bound_residual_mask(source: Path, shape: tuple[int, int]) -> np.ndarray: try: from image2editable.component_repair import load_component_agent_request except ModuleNotFoundError: request = _load_bound_residual_request(source) else: request = load_component_agent_request( source / "component_agent_request.json" ) reference = request.get("evidence", {}).get("unexplained-mask.png") if ( not isinstance(reference, dict) or reference.get("path") != "unexplained-mask.png" or not isinstance(reference.get("sha256"), str) ): raise VisualSegmentationError( "absorb_residual requires bound unexplained-mask evidence" ) mask, _ = _read_action_mask( source / reference["path"], shape, reference["sha256"] ) return mask def _load_bound_residual_request(source: Path) -> dict: source = source if source.is_absolute() else Path.cwd() / source reconstruction = source.parent.parent if ( source.parent.name != "agent" or reconstruction.name != "reconstruction" or reconstruction.parent.parent.name != "pages" or not source.name.startswith("round-") or len(source.name) != 8 or not source.name[6:].isdigit() ): raise VisualSegmentationError( "absorb_residual requires a published component Agent round" ) run_root = reconstruction.parent.parent.parent _validate_safe_directory_chain(source, run_root) marker = _read_bound_json( source / "publication-marker.json", 64 * 1024, "publication marker" ) marker_fields = { "schema_version", "page_id", "provider", "repair_round", "request_path", "request_sha256", "hmac_sha256", } if not isinstance(marker, dict) or set(marker) != marker_fields: raise VisualSegmentationError("Component Agent publication marker is invalid") round_number = int(source.name[6:]) if ( type(marker.get("schema_version")) is not int or marker["schema_version"] != 1 or type(marker.get("repair_round")) is not int or marker["repair_round"] != round_number or marker.get("page_id") != reconstruction.parent.name or marker.get("provider") not in {"host", "local"} or marker.get("request_path") != f"{source.name}/component_agent_request.json" ): raise VisualSegmentationError("Component Agent publication marker is invalid") for field in ("request_sha256", "hmac_sha256"): digest = marker.get(field) if ( not isinstance(digest, str) or len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest) ): raise VisualSegmentationError("Component Agent publication digest is invalid") integrity_directory = run_root / ".component-agent-integrity" _validate_safe_directory_chain(integrity_directory, run_root) key_path = integrity_directory / "key.bin" key = _read_bound_bytes( key_path, 32, "integrity key", ) if len(key) != 32: raise VisualSegmentationError("Component Agent integrity key is damaged") if os.name != "nt" and stat.S_IMODE(key_path.lstat().st_mode) & 0o077: raise VisualSegmentationError("Component Agent integrity key permissions are unsafe") signed_fields = {key: value for key, value in marker.items() if key != "hmac_sha256"} expected_signature = hmac.new( key, json.dumps( signed_fields, ensure_ascii=False, separators=(",", ":"), sort_keys=True, ).encode("utf-8"), hashlib.sha256, ).hexdigest() if not hmac.compare_digest(marker["hmac_sha256"], expected_signature): raise VisualSegmentationError("Component Agent publication signature mismatch") request_bytes = _read_bound_bytes( source / "component_agent_request.json", 4 * 1024 * 1024, "component request", ) if hashlib.sha256(request_bytes).hexdigest() != marker["request_sha256"]: raise VisualSegmentationError("Component Agent request hash mismatch") try: request = json.loads(request_bytes.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as error: raise VisualSegmentationError("Component Agent request is invalid") from error if ( not isinstance(request, dict) or request.get("page_id") != marker["page_id"] or request.get("provider") != marker["provider"] or request.get("repair_round") != marker["repair_round"] ): raise VisualSegmentationError("Component Agent request binding is invalid") return request def _validate_safe_directory_chain(directory: Path, root: Path) -> None: try: relative = directory.relative_to(root) except ValueError as error: raise VisualSegmentationError("Component Agent round is outside its run") from error current = root for part in (Path(), *relative.parts): if part != Path(): current /= part status = current.lstat() reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) if ( stat.S_ISLNK(status.st_mode) or getattr(status, "st_file_attributes", 0) & reparse or not stat.S_ISDIR(status.st_mode) ): raise VisualSegmentationError( f"Component Agent directory is unsafe: {current}" ) def _read_bound_json(path: Path, limit: int, label: str) -> object: payload = _read_bound_bytes(path, limit, label) try: return json.loads(payload.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as error: raise VisualSegmentationError(f"{label} is invalid") from error def _read_bound_bytes(path: Path, limit: int, label: str) -> bytes: status = path.lstat() reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) if ( stat.S_ISLNK(status.st_mode) or getattr(status, "st_file_attributes", 0) & reparse or not stat.S_ISREG(status.st_mode) or status.st_nlink != 1 or status.st_size > limit ): raise VisualSegmentationError(f"{label} is unsafe") flags = os.O_RDONLY for name in ("O_BINARY", "O_NOINHERIT", "O_NOFOLLOW"): flags |= getattr(os, name, 0) descriptor = os.open(path, flags) try: opened = os.fstat(descriptor) if ( not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1 or (opened.st_dev, opened.st_ino) != (status.st_dev, status.st_ino) ): raise VisualSegmentationError(f"{label} identity changed") chunks = [] total = 0 while True: chunk = os.read(descriptor, min(1024 * 1024, limit + 1 - total)) if not chunk: break chunks.append(chunk) total += len(chunk) if total > limit: raise VisualSegmentationError(f"{label} size limit exceeded") payload = b"".join(chunks) stable = os.fstat(descriptor) if ( (opened.st_dev, opened.st_ino, opened.st_size) != (stable.st_dev, stable.st_ino, stable.st_size) ): raise VisualSegmentationError(f"{label} changed while reading") return payload finally: os.close(descriptor) def _partition_bound_residual_mask( source: Path, shape: tuple[int, int], target_ids: list[str], masks: dict[str, np.ndarray], ) -> dict[str, np.ndarray]: residual = _read_bound_residual_mask(source, shape) assignments = { component_id: np.zeros(shape, dtype=bool) for component_id in target_ids } nearby_masks = { component_id: cv2.dilate( masks[component_id].astype(np.uint8), np.ones((7, 7), dtype=np.uint8), ).astype(bool) for component_id in target_ids } bounds = {} for component_id in target_ids: ys, xs = np.nonzero(masks[component_id]) bounds[component_id] = ( int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1, ) distances = { component_id: cv2.distanceTransform( (~masks[component_id]).astype(np.uint8), cv2.DIST_L2, 3 ) for component_id in target_ids } areas = { component_id: int(np.count_nonzero(masks[component_id])) for component_id in target_ids } action_order = { component_id: index for index, component_id in enumerate(target_ids) } count, labels = cv2.connectedComponents(residual.astype(np.uint8), 8) for label in range(1, count): region = labels == label ys, xs = np.nonzero(region) region_bounds = ( int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1, ) eligible = [] for component_id in target_ids: left, top, right, bottom = bounds[component_id] region_left, region_top, region_right, region_bottom = region_bounds contained = ( left <= region_left and top <= region_top and right >= region_right and bottom >= region_bottom ) if contained or np.any(nearby_masks[component_id] & region): eligible.append(component_id) if not eligible: continue owner = min( eligible, key=lambda component_id: ( float(np.min(distances[component_id][region])), areas[component_id], action_order[component_id], ), ) assignments[owner] |= region if any(not np.any(assignments[component_id]) for component_id in target_ids): raise RecoverableComponentPlanError( "absorb_residual target has no related residual region" ) return assignments def _new_action_id(nodes: dict[str, dict], prefix: str) -> str: index = 1 while f"{prefix}_{index:04d}" in nodes: index += 1 return f"{prefix}_{index:04d}" def _connected_action_parts( mask: np.ndarray, expected: int, *, image: np.ndarray | None = None, text_mask: np.ndarray | None = None, ) -> list[np.ndarray]: from scripts.fg_extract import connected_mask_proposals parts = connected_mask_proposals(mask, expected) if parts and len(parts) != expected and image is not None: # A flat card can connect several distinct graphics through its fill. # Reuse local color boundaries; never cut it into arbitrary rectangles. ys, xs = np.nonzero(mask) top, bottom, left, right = ys.min(), ys.max() + 1, xs.min(), xs.max() + 1 region = np.s_[top:bottom, left:right] crop = image[region] support = mask[region] ignored = np.zeros(support.shape, dtype=bool) if text_mask is None else text_mask[region] visible = support & ~ignored if visible.any(): base = np.median(crop[visible], axis=0) separated = [] occupied = np.zeros(support.shape, dtype=bool) for candidate in generate_flat_color_candidates(crop, ignored): foreground = candidate.mask & ~ignored owned = foreground & support if ( np.count_nonzero(owned) < 100 or np.count_nonzero(owned) < 0.98 * np.count_nonzero(foreground) or np.max(np.abs(np.median(crop[owned], axis=0) - base)) <= 16 or np.any(candidate.mask & occupied) ): continue selected = candidate.mask & support separated.append(selected) occupied |= selected remainder = support & ~occupied if len(separated) == expected - 1 and remainder.any(): parts = [] for selected in [remainder, *separated]: part = np.zeros(mask.shape, dtype=bool) part[region] = selected parts.append(part) if len(parts) != expected: raise RecoverableComponentPlanError( "split did not find exact connected proposals", reason="invalid_split_target", ) return parts def _deactivate_descendants(nodes: dict[str, dict], parent_id: str) -> None: children = [node for node in nodes.values() if node["parent_id"] == parent_id] for child in children: child["state"] = "inactive" _deactivate_descendants(nodes, child["id"]) @contextmanager def _sam_inference_context(generator): if not str( getattr(generator, "_image2editable_device", "") ).startswith("cuda"): yield return torch = importlib.import_module("torch") with torch.inference_mode(): with torch.autocast( device_type="cuda", dtype=torch.bfloat16, ): yield def _flat_stroke_prompt_mask(image, prompt, text_mask): """Separate uniform, thin connector branches from signed point evidence.""" positive, negative = prompt['positive'], prompt['negative'] if prompt['box'] is not None or len(positive) != 2 or len(negative) != 2: return None points = np.rint(positive + negative).astype(int) if np.any(points < 0) or np.any(points >= np.array([image.shape[1], image.shape[0]])): return None colors = image[points[:, 1], points[:, 0]].astype(np.int16) if np.max(np.abs(colors - colors[0])) > 3 or np.any(text_mask[points[:, 1], points[:, 0]]): return None color = colors[0] compatible = (np.max(np.abs(image.astype(np.int16) - color), axis=2) <= 3) & ~text_mask _, labels, stats, _ = cv2.connectedComponentsWithStats(compatible.astype(np.uint8), 8) selected_labels = labels[points[:, 1], points[:, 0]] if selected_labels[0] == 0 or np.any(selected_labels != selected_labels[0]): return None left, top, width, height, area = stats[selected_labels[0]] if area < 20 or area > image.shape[0] * image.shape[1] * .1 or area > width * height * .25: return None region = labels[top:top + height, left:left + width] == selected_labels[0] ys, xs = np.nonzero(region) locations = np.column_stack((xs + left, ys + top)) def distance(pair): start, end = np.asarray(pair, dtype=float) direction = end - start length = np.linalg.norm(direction) if length < 10: return None delta = locations - start return np.abs(delta[:, 0] * direction[1] - delta[:, 1] * direction[0]) / length positive_distance, negative_distance = distance(positive), distance(negative) if positive_distance is None or negative_distance is None: return None selected = positive_distance < negative_distance if tuple(map(tuple, positive)) < tuple(map(tuple, negative)): selected |= positive_distance == negative_distance result = np.zeros(image.shape[:2], dtype=bool) result[ys[selected] + top, xs[selected] + left] = True if not np.all(result[points[:2, 1], points[:2, 0]]) or np.any(result[points[2:, 1], points[2:, 0]]): return None return result def _binary_visual_mask(mask: object) -> np.ndarray: array = np.asarray(mask) if array.ndim != 2 or array.dtype.kind not in "biuf": raise ValueError("visual mask must be two-dimensional and numeric") if array.dtype.kind == "f" and not np.all(np.isfinite(array)): raise ValueError("visual mask contains non-finite values") if array.dtype.kind in "if" and np.any(array < 0): raise ValueError("visual mask contains negative values") return array if array.dtype == np.bool_ else array > 0 def validate_visual_masks(element_masks: list[np.ndarray]) -> None: if not element_masks: return first = _binary_visual_mask(element_masks[0]) claimed = np.zeros(first.shape, dtype=bool) duplicate = np.zeros(first.shape, dtype=bool) for mask in element_masks: active = _binary_visual_mask(mask) if active.shape != claimed.shape: raise ValueError("visual mask shapes must match") np.logical_and(claimed, active, out=duplicate) if np.any(duplicate): raise VisualSegmentationError( "overlapping visual ownership detected" ) np.logical_or(claimed, active, out=claimed) def visual_difference( source: np.ndarray, reconstructed: np.ndarray, text_mask: np.ndarray, ) -> dict: valid = text_mask == 0 if not np.any(valid): return { "mae": 0.0, "p95": 0.0, "p99": 0.0, "changed_ratio": 0.0, "largest_artifact_ratio": 0.0, } difference = np.mean( np.abs(source.astype(np.float32) - reconstructed.astype(np.float32)), axis=2, ) pixel_difference = difference[valid] artifact_mask = ((difference > 8.0) & valid).astype(np.uint8) count, _, stats, _ = cv2.connectedComponentsWithStats( artifact_mask, connectivity=8, ) largest_artifact = ( int(np.max(stats[1:, cv2.CC_STAT_AREA])) if count > 1 else 0 ) return { "mae": float(np.mean(pixel_difference)), "p95": float(np.percentile(pixel_difference, 95)), "p99": float(np.percentile(pixel_difference, 99)), "changed_ratio": float(np.mean(pixel_difference > 3.0)), "largest_artifact_ratio": ( largest_artifact / int(np.count_nonzero(valid)) ), } def background_residual_metrics( source: np.ndarray, background: np.ndarray, removal_mask: np.ndarray, ) -> dict: """Measure source edges that remain visible in the repaired background.""" source = np.asarray(source, dtype=np.uint8) background = np.asarray(background, dtype=np.uint8) removal = np.asarray(removal_mask) > 0 if source.shape != background.shape or removal.shape != source.shape[:2]: raise ValueError("background residual inputs must have matching shapes") if not np.any(removal): return { "source_edge_pixels": 0, "retained_edge_pixels": 0, "retained_edge_ratio": 0.0, } support = cv2.dilate( removal.astype(np.uint8), np.ones((5, 5), dtype=np.uint8), iterations=1, ) > 0 source_gray = cv2.cvtColor(source, cv2.COLOR_RGB2GRAY) background_gray = cv2.cvtColor(background, cv2.COLOR_RGB2GRAY) source_edges = cv2.Canny(source_gray, 8, 24) > 0 background_edges = cv2.Canny(background_gray, 8, 24) > 0 background_edges = cv2.dilate( background_edges.astype(np.uint8), np.ones((5, 5), dtype=np.uint8), iterations=1, ) > 0 relevant = source_edges & support source_edge_pixels = int(np.count_nonzero(relevant)) retained_edge_pixels = int( np.count_nonzero(relevant & background_edges) ) return { "source_edge_pixels": source_edge_pixels, "retained_edge_pixels": retained_edge_pixels, "retained_edge_ratio": ( retained_edge_pixels / source_edge_pixels if source_edge_pixels else 0.0 ), } def has_background_residual(metrics: dict) -> bool: """Reject a background that retains a material source-object outline.""" return ( metrics.get("source_edge_pixels", 0) >= 16 and metrics.get("retained_edge_ratio", 0.0) >= 0.45 ) def needs_text_only_fallback(metrics: dict) -> bool: """Prefer the text-clean background when sparse artifacts stay visible.""" return ( ( metrics.get("p99", 0.0) > 5.0 and metrics.get("changed_ratio", 0.0) > 0.01 ) or metrics.get("largest_artifact_ratio", 0.0) > 0.001 ) def require_visual_quality(metrics: dict) -> None: if metrics["mae"] > 12.0 or metrics["p95"] > 48.0: raise VisualSegmentationError( "visual reconstruction did not meet the quality threshold" ) def write_segmentation_diagnostics( output_dir: Path, source: np.ndarray, masks: list[np.ndarray], reconstructed: np.ndarray, metrics: dict, ) -> None: output_dir.mkdir(parents=True, exist_ok=True) Image.fromarray(source).save(output_dir / "source.png") Image.fromarray(reconstructed).save(output_dir / "reconstructed.png") ownership = np.zeros(source.shape[:2], dtype=np.uint16) for index, mask in enumerate(masks, start=1): ownership[np.asarray(mask, dtype=bool)] = index normalized = ((ownership * 37) % 255).astype(np.uint8) Image.fromarray(normalized).save(output_dir / "ownership.png") report = dict(metrics) report["component_count"] = len(masks) (output_dir / "report.json").write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8", ) @dataclass class MaskCandidate: mask: np.ndarray score: float source: str crop_box: tuple[int, int, int, int] | None = None touches_crop_edge: bool = False label: str = "" role: str = "" object_box: tuple[float, float, float, float] | None = None @dataclass class VisualElement: mask: np.ndarray z_index: int score: float source: str semantic_mask: np.ndarray | None = None object_box: tuple[float, float, float, float] | None = None def __post_init__(self) -> None: if self.semantic_mask is None: self.semantic_mask = np.asarray(self.mask, dtype=bool).copy() def build_component_mask_layers(elements: list[VisualElement]) -> list[dict]: """Keep an intact semantic parent beside each detachable visible mask.""" child_masks = [_binary_visual_mask(element.mask) for element in elements] validate_visual_masks(child_masks) layers = [] for element, child_mask in zip(elements, child_masks): parent_mask = _binary_visual_mask(element.semantic_mask) if parent_mask.shape != child_mask.shape: raise VisualSegmentationError( "parent and child component masks must have the same shape" ) if np.any(child_mask & ~parent_mask): raise VisualSegmentationError( "child component mask must stay inside its parent" ) if not np.any(parent_mask) or not np.any(child_mask): raise VisualSegmentationError("component masks cannot be empty") layers.append( { "parent_mask": parent_mask, "child_mask": child_mask, "z_index": element.z_index, } ) return layers def resolve_visual_elements( candidates: list[MaskCandidate], min_area: int = 20, duplicate_iou: float = 0.92, ) -> list[VisualElement]: candidates = _merge_semantic_candidates(candidates) valid = [] for candidate in candidates: if candidate.mask.dtype != bool: continue area = int(np.count_nonzero(candidate.mask)) if area < min_area or area / candidate.mask.size >= 0.95: continue ys, xs = np.nonzero(candidate.mask) bbox = ( int(ys.min()), int(ys.max()) + 1, int(xs.min()), int(xs.max()) + 1, ) valid.append((candidate, area, bbox, candidate.mask)) unique = [] for candidate_stats in sorted( valid, key=lambda item: ( item[0].touches_crop_edge, -item[1] if item[0].touches_crop_edge else 0, -item[0].score, ), ): candidate, candidate_area, candidate_bbox, candidate_support = ( candidate_stats ) duplicate = False for index, ( retained, retained_area, retained_bbox, retained_support, ) in enumerate(unique): smaller_area = min(candidate_area, retained_area) larger_area = max(candidate_area, retained_area) smaller = candidate if candidate_area <= retained_area else retained y1 = max(candidate_bbox[0], retained_bbox[0]) y2 = min(candidate_bbox[1], retained_bbox[1]) x1 = max(candidate_bbox[2], retained_bbox[2]) x2 = min(candidate_bbox[3], retained_bbox[3]) if y1 >= y2 or x1 >= x2: continue area_ratio = smaller_area / larger_area if area_ratio < duplicate_iou and not smaller.touches_crop_edge: continue intersection = int( np.count_nonzero( candidate.mask[y1:y2, x1:x2] & retained.mask[y1:y2, x1:x2] ) ) if ( smaller.touches_crop_edge and smaller_area - intersection < min_area ): duplicate = True unique[index] = ( retained, retained_area, retained_bbox, retained_support | candidate_support, ) break if area_ratio < duplicate_iou: continue union = candidate_area + retained_area - intersection if intersection / max(union, 1) < duplicate_iou: continue parent_child = ( smaller_area - intersection < min_area and larger_area - intersection >= min_area ) if not parent_child or smaller.touches_crop_edge: duplicate = True unique[index] = ( retained, retained_area, retained_bbox, retained_support | candidate_support, ) break if duplicate: continue unique.append(candidate_stats) front_to_back = sorted( unique, key=lambda item: (item[1], -item[0].score), ) if not front_to_back: return [] claimed = np.zeros(front_to_back[0][0].mask.shape, dtype=bool) elements = [] for candidate, _, _, semantic_support in front_to_back: visible = candidate.mask & ~claimed if np.count_nonzero(visible) < min_area: continue elements.append( VisualElement( mask=visible, z_index=0, score=candidate.score, source=candidate.source, semantic_mask=semantic_support, object_box=candidate.object_box, ) ) claimed |= visible elements.reverse() for z_index, element in enumerate(elements): element.z_index = z_index return elements def complete_initial_visual_element_masks( elements: list[VisualElement], image: np.ndarray ) -> None: """Restore antialiased edges before the first background reconstruction.""" if not elements: return claimed = np.zeros(elements[0].mask.shape, dtype=bool) for element in sorted( elements, key=lambda value: getattr(value, "z_index", 0), reverse=True ): semantic = _complete_opaque_mask_regions(element.semantic_mask, image) element.semantic_mask = semantic element.mask = semantic & ~claimed claimed |= element.mask def _enclosed_holes(mask: np.ndarray) -> np.ndarray: background = ~np.asarray(mask, dtype=bool) if not np.any(background): return np.zeros(background.shape, dtype=bool) count, labels = cv2.connectedComponents(background.astype(np.uint8), connectivity=8) border_labels = set(labels[0, :]) border_labels.update(labels[-1, :]) border_labels.update(labels[:, 0]) border_labels.update(labels[:, -1]) keep = np.ones(count, dtype=bool) keep[list(border_labels)] = False keep[0] = False return keep[labels] def recheck_visual_element_holes( image: np.ndarray, elements: lis -
visual_worker.py 13.5 KB
from __future__ import annotations import argparse from contextlib import redirect_stdout from dataclasses import dataclass import hashlib import io import json import os from pathlib import Path import stat import sys import numpy as np from PIL import Image @dataclass(frozen=True) class PagePolicy: route: str confidence: float reasons: tuple[str, ...] automatic_sam: bool max_residual_rounds: int hole_recheck: bool max_lama_calls: int host_agent_allowed: bool def strict_page_policy() -> PagePolicy: return PagePolicy( route="strict", confidence=0.0, reasons=("strict_mode",), automatic_sam=True, max_residual_rounds=3, hole_recheck=True, max_lama_calls=2, host_agent_allowed=True, ) def _is_link_or_reparse(status: os.stat_result) -> bool: reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) return stat.S_ISLNK(status.st_mode) or bool( getattr(status, "st_file_attributes", 0) & reparse_flag ) def _read_source_snapshot( image_path: Path, work_dir: Path, expected_size: int, label: str, ) -> bytes: root = Path(os.path.abspath(work_dir)) source = Path(os.path.abspath(image_path)) if source.parent != root: raise ValueError(f"{label} must be directly inside the work directory") root_before = os.lstat(root) path_before = os.lstat(source) try: with source.open("rb") as stream: handle_before = os.fstat(stream.fileno()) if handle_before.st_size != expected_size: raise ValueError(f"{label} size mismatch") content = stream.read(expected_size + 1) handle_after = os.fstat(stream.fileno()) path_after = os.lstat(source) root_after = os.lstat(root) except OSError as exc: raise ValueError(f"{label} changed while being read") from exc for status in (root_before, root_after): if _is_link_or_reparse(status) or not stat.S_ISDIR(status.st_mode): raise ValueError("visual work directory changed while being read") if (root_before.st_dev, root_before.st_ino) != ( root_after.st_dev, root_after.st_ino ): raise ValueError("visual work directory changed while being read") statuses = (path_before, handle_before, handle_after, path_after) for status in statuses: if ( _is_link_or_reparse(status) or not stat.S_ISREG(status.st_mode) or status.st_nlink != 1 ): raise ValueError(f"{label} changed while being read") identities = { (status.st_dev, status.st_ino, status.st_size, status.st_mtime_ns) for status in statuses } if len(identities) != 1 or len(content) != expected_size: raise ValueError(f"{label} changed while being read") return content def _load_process_image(): script_dir = Path(__file__).resolve().parent sys.path.insert(0, str(script_dir.parent)) if (script_dir / "image_to_ppt.py").is_file(): from scripts.image_to_ppt import _process_image else: from image_to_ppt import _process_image return _process_image def _load_visual_tools(): script_dir = Path(__file__).resolve().parent sys.path.insert(0, str(script_dir.parent)) from scripts.object_detect import create_object_detector from scripts.visual_segment import create_sam_generator, resolve_sam_checkpoint return create_object_detector, create_sam_generator, resolve_sam_checkpoint def _request_page_policy(request: dict) -> PagePolicy: payload = request.get("page_policy") if payload is None: return strict_page_policy() if not isinstance(payload, dict): raise ValueError("visual page policy is invalid") try: values = dict(payload) values["reasons"] = tuple(values.get("reasons", ())) return PagePolicy(**values) except (TypeError, ValueError) as exc: raise ValueError("visual page policy is invalid") from exc class _ResidentVisualProcessor: """Keep one sequential DINO/SAM/LaMa model lifecycle inside --serve.""" def __init__( self, *, process_image=None, create_detector=None, create_generator=None, resolve_checkpoint=None, ) -> None: self._process_image = process_image or _load_process_image() if create_detector is None: create_detector, create_generator, resolve_checkpoint = _load_visual_tools() self._create_detector = create_detector self._create_generator = create_generator self._resolve_checkpoint = resolve_checkpoint self._detector = None self._generator = None def process( self, image_path: Path, work_dir: Path, lang: str, text_analysis: dict, source_image: np.ndarray, text_mask: np.ndarray, text_clean_image: np.ndarray | None, page_policy: PagePolicy, ) -> dict: if page_policy.route == "direct": detector = None generator = None else: if self._detector is None: self._detector = self._create_detector() if self._generator is None: self._generator = self._create_generator( self._resolve_checkpoint(), resource_safe=True, ) detector = self._detector generator = self._generator return self._process_image( image_path, work_dir, detector, generator, lang, text_analysis=text_analysis, defer_quality=True, _resource_isolation=False, _source_image=source_image, _text_mask=text_mask, _text_clean_image=text_clean_image, page_policy=page_policy, ) def component_prompts(self, request_path: Path, result_path: Path) -> None: if self._generator is None: self._generator = self._create_generator( self._resolve_checkpoint(), resource_safe=True, ) from scripts.sam_worker import run_component_prompt_batch_with_generator run_component_prompt_batch_with_generator( request_path, result_path, self._generator, ) def close(self) -> None: self._detector = None self._generator = None try: from scripts.lama_inpaint import release_model release_model() finally: try: import torch except ImportError: return if torch.cuda.is_available(): torch.cuda.empty_cache() def _process_component_prompt_request( payload: dict, processor: _ResidentVisualProcessor, ) -> None: if set(payload) != {"kind", "request", "result"}: raise ValueError("visual component prompt request is invalid") if any( not isinstance(payload[name], str) or not payload[name] for name in ("request", "result") ): raise ValueError("visual component prompt request is invalid") processor.component_prompts(Path(payload["request"]), Path(payload["result"])) def _process_request(payload: dict, processor: _ResidentVisualProcessor | None) -> None: required = { "image", "work_dir", "lang", "request", "request_sha256", "request_size", "source_sha256", "source_size", "result", } if set(payload) != required: raise ValueError("visual worker request is invalid") if ( not isinstance(payload["request_size"], int) or isinstance(payload["request_size"], bool) or payload["request_size"] <= 0 ): raise ValueError("visual request binding is invalid") if ( not isinstance(payload["source_size"], int) or isinstance(payload["source_size"], bool) or payload["source_size"] <= 0 ): raise ValueError("visual source binding is invalid") if any( not isinstance(payload[name], str) or not payload[name] for name in required - {"request_size", "source_size"} ): raise ValueError("visual worker request is invalid") work_dir = Path(payload["work_dir"]) request_content = _read_source_snapshot( Path(payload["request"]), work_dir, payload["request_size"], "visual request" ) if hashlib.sha256(request_content).hexdigest() != payload["request_sha256"]: raise ValueError("visual request sha256 mismatch") request = json.loads(request_content.decode("utf-8")) page_policy = _request_page_policy(request) expected_sha256 = payload["source_sha256"] if ( len(expected_sha256) != 64 or any(character not in "0123456789abcdef" for character in expected_sha256) ): raise ValueError("visual source binding is invalid") source_content = _read_source_snapshot( Path(payload["image"]), work_dir, payload["source_size"], "visual source" ) if hashlib.sha256(source_content).hexdigest() != expected_sha256: raise ValueError("visual source sha256 mismatch") with Image.open(io.BytesIO(source_content)) as stored_source: source_image = np.asarray(stored_source.convert("RGB")).copy() text_analysis = request["text_analysis"] text_mask_content = _read_source_snapshot( Path(text_analysis["mask_path"]), work_dir, request["text_mask_size"], "visual text mask", ) if hashlib.sha256(text_mask_content).hexdigest() != request["text_mask_sha256"]: raise ValueError("visual text mask sha256 mismatch") with Image.open(io.BytesIO(text_mask_content)) as stored_text_mask: text_mask = np.asarray(stored_text_mask.convert("L")).copy() text_clean_image = None if text_analysis.get("text_clean_path") is not None: text_clean_content = _read_source_snapshot( Path(text_analysis["text_clean_path"]), work_dir, request["text_clean_size"], "visual text clean image", ) if ( hashlib.sha256(text_clean_content).hexdigest() != request["text_clean_sha256"] ): raise ValueError("visual text clean image sha256 mismatch") with Image.open(io.BytesIO(text_clean_content)) as stored_text_clean: text_clean_image = np.asarray(stored_text_clean.convert("RGB")).copy() if processor is None: slide_data = _load_process_image()( Path(payload["image"]), work_dir, None, None, payload["lang"], text_analysis=text_analysis, defer_quality=True, _resource_isolation=True, _source_image=source_image, _text_mask=text_mask, _text_clean_image=text_clean_image, page_policy=page_policy, ) else: slide_data = processor.process( Path(payload["image"]), work_dir, payload["lang"], text_analysis, source_image, text_mask, text_clean_image, page_policy, ) result_path = Path(payload["result"]) temporary_path = result_path.with_name(f".{result_path.name}.tmp") temporary_path.write_text(json.dumps(slide_data, ensure_ascii=False), encoding="utf-8") os.replace(temporary_path, result_path) def _serve() -> int: processor = _ResidentVisualProcessor() try: for line in sys.stdin: request_id = None try: envelope = json.loads(line) if envelope == {"control": "close"}: break if not isinstance(envelope, dict): raise ValueError("visual worker envelope is invalid") request_id = envelope.get("request_id") payload = envelope.get("payload") if not isinstance(request_id, str) or not request_id: raise ValueError("visual worker request id is invalid") if not isinstance(payload, dict): raise ValueError("visual worker payload is invalid") with redirect_stdout(sys.stderr): if payload.get("kind") == "component_prompts": _process_component_prompt_request(payload, processor) else: _process_request(payload, processor) response = {"request_id": request_id, "result": {}} except Exception as error: response = { "request_id": request_id, "error": {"type": type(error).__name__, "message": str(error)}, } sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n") sys.stdout.flush() finally: processor.close() return 0 def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--serve", action="store_true") parser.add_argument("--image") parser.add_argument("--work-dir") parser.add_argument("--lang") parser.add_argument("--request") parser.add_argument("--request-sha256") parser.add_argument("--request-size", type=int) parser.add_argument("--source-sha256") parser.add_argument("--source-size", type=int) parser.add_argument("--result") args = parser.parse_args() if args.serve: return _serve() payload = vars(args) payload.pop("serve") if any(value is None for value in payload.values()): parser.error("one-shot visual worker requires all request arguments") _process_request(payload, None) return 0 if __name__ == "__main__": raise SystemExit(main()) -
worker_pool.py 17.2 KB
"""Task-scoped, sequential workers for heavyweight model processes.""" from __future__ import annotations import json import os import queue import subprocess import threading import time from typing import Any, Callable, Protocol _DEFAULT_REQUEST_TIMEOUT_SECONDS = 5 * 60 class WorkerPoolError(RuntimeError): """Base error for task worker lifecycle failures.""" class WorkerCancelled(WorkerPoolError): """The caller cancelled before its request reached a worker.""" class WorkerDeadlineExceeded(WorkerPoolError): """The worker did not complete before the request deadline.""" class WorkerPoolClosed(WorkerPoolError): """The task worker pool is no longer available.""" class WorkerQueueFull(WorkerPoolError): """The task worker has reached its bounded request capacity.""" class WorkerRemoteError(WorkerPoolError): """A worker reported a request-local error and remains usable.""" class _Worker(Protocol): def request(self, envelope: dict[str, Any], *, deadline: float | None) -> dict[str, Any]: ... def close(self) -> None: ... def terminate(self) -> None: ... def _deadline_remaining(deadline: float | None) -> float | None: if deadline is None: return None remaining = deadline - time.monotonic() if remaining <= 0: raise WorkerDeadlineExceeded("worker request deadline exceeded") return remaining class JsonLineWorker: """One ordered JSON-line subprocess request channel.""" def __init__(self, command: list[str]) -> None: env = os.environ.copy() env["PYTHONUTF8"] = "1" self._process = subprocess.Popen( command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env, text=True, encoding="utf-8", errors="replace", bufsize=1, ) self._lock = threading.Lock() def request(self, envelope: dict[str, Any], *, deadline: float | None) -> dict[str, Any]: with self._lock: if self._process.poll() is not None: raise WorkerPoolError("worker process exited before its request") if self._process.stdin is None or self._process.stdout is None: raise WorkerPoolError("worker process streams are unavailable") received: queue.Queue[str | BaseException] = queue.Queue(maxsize=1) def read_response() -> None: try: self._process.stdin.write(json.dumps(envelope, ensure_ascii=False) + "\n") self._process.stdin.flush() received.put(self._process.stdout.readline()) except BaseException as exc: received.put(exc) reader = threading.Thread(target=read_response, daemon=True) reader.start() if deadline is not None: reader.join(_deadline_remaining(deadline)) if reader.is_alive(): raise WorkerDeadlineExceeded("worker request deadline exceeded") else: # A CPU model may legitimately compute for longer than five # minutes. Bound inactivity, not the total inference duration. activity = self._activity() last_active = time.monotonic() interval = min(1.0, _DEFAULT_REQUEST_TIMEOUT_SECONDS / 4) while reader.is_alive(): reader.join(interval) if not reader.is_alive(): break current = self._activity() now = time.monotonic() if current != activity: activity = current last_active = now elif now - last_active >= _DEFAULT_REQUEST_TIMEOUT_SECONDS: raise WorkerDeadlineExceeded("worker process is inactive") try: line = received.get_nowait() except queue.Empty as exc: raise WorkerPoolError("worker did not return a response") from exc if isinstance(line, BaseException): raise WorkerPoolError("worker request I/O failed") from line if not line: raise WorkerPoolError("worker process exited without a response") try: response = json.loads(line) except json.JSONDecodeError as exc: raise WorkerPoolError("worker returned invalid JSON") from exc if not isinstance(response, dict): raise WorkerPoolError("worker returned an invalid response") return response def _activity(self) -> dict[int, tuple]: import psutil activity = {} try: process = psutil.Process(self._process.pid) processes = [process, *process.children(recursive=True)] except psutil.Error: return activity for process in processes: try: cpu = process.cpu_times() counters = (cpu.user, cpu.system) # macOS does not expose per-process I/O counters. if hasattr(process, "io_counters"): io = process.io_counters() counters += (io.read_bytes, io.write_bytes) activity[process.pid] = counters except psutil.Error: continue return activity def close(self) -> None: if self._process.poll() is not None: return try: if self._process.stdin is not None: self._process.stdin.write('{"control":"close"}\n') self._process.stdin.flush() self._process.stdin.close() self._process.wait(timeout=5) except (OSError, subprocess.TimeoutExpired): self.terminate() def terminate(self) -> None: if self._process.poll() is None: self._process.terminate() try: self._process.wait(timeout=5) except subprocess.TimeoutExpired: self._process.kill() self._process.wait(timeout=5) class TaskWorkerPool: """Reuse one task worker with a bounded active-and-waiting request count.""" def __init__( self, worker_factory: Callable[[], _Worker], *, queue_limit: int = 1, worker_name: str, ) -> None: if queue_limit < 1: raise ValueError("worker queue_limit must be positive") if not worker_name: raise ValueError("worker_name is required") self._worker_factory = worker_factory self._worker_name = worker_name self._slots = threading.BoundedSemaphore(queue_limit) self._lock = threading.Lock() self._worker: _Worker | None = None self._closed = False self._next_request = 1 def request( self, payload: dict[str, Any], *, deadline: float | None = None, cancel: threading.Event | None = None, performance_trace=None, page_id: str | None = None, ) -> dict[str, Any]: if not isinstance(payload, dict): raise TypeError("worker payload must be a mapping") explicit_deadline = deadline is not None if deadline is None: deadline = time.monotonic() + _DEFAULT_REQUEST_TIMEOUT_SECONDS queued_at = time.monotonic() slot_acquired = False lock_acquired = False try: self._acquire_slot(deadline, cancel) slot_acquired = True queue_wait_ms = round((time.monotonic() - queued_at) * 1000) self._trace( performance_trace, "span", stage="worker_queue", model=self._worker_name, page_id=page_id, duration_ms=queue_wait_ms, ) self._acquire_worker_lock(deadline, cancel) lock_acquired = True if cancel is not None and cancel.is_set(): raise WorkerCancelled("worker request was cancelled") if self._closed: raise WorkerPoolClosed("worker pool is closed") request_id = f"{self._worker_name}-{self._next_request:08d}" self._next_request += 1 if self._worker is None: model_load_started = time.monotonic() self._trace( performance_trace, "model_load_start", model=self._worker_name, page_id=page_id, ) try: self._worker = self._worker_factory() except Exception: self._trace( performance_trace, "model_load_finish", model=self._worker_name, page_id=page_id, duration_ms=round( (time.monotonic() - model_load_started) * 1000 ), status="error", ) raise self._trace( performance_trace, "model_load_finish", model=self._worker_name, page_id=page_id, duration_ms=round( (time.monotonic() - model_load_started) * 1000 ), status="success", ) self._trace( performance_trace, "worker_start", stage="task_worker", model=self._worker_name, page_id=page_id, operation_count=1, ) else: self._trace( performance_trace, "worker_start", stage="worker_reuse", model=self._worker_name, page_id=page_id, operation_count=1, ) started = time.monotonic() request_deadline = deadline if not explicit_deadline and isinstance(self._worker, JsonLineWorker): request_deadline = None try: response = self._request_worker( self._worker, {"request_id": request_id, "payload": payload}, deadline=request_deadline, cancel=cancel, ) except WorkerCancelled: self._discard_worker() self._trace_finish(performance_trace, page_id, started, "error") raise except WorkerDeadlineExceeded: self._discard_worker() self._trace_finish(performance_trace, page_id, started, "error") raise except Exception as exc: self._discard_worker() self._trace_finish(performance_trace, page_id, started, "error") raise WorkerPoolError("worker request failed") from exc try: result = self._decode_response(response, request_id) except WorkerRemoteError: self._trace_finish(performance_trace, page_id, started, "error") raise except WorkerPoolError: self._discard_worker() self._trace_finish(performance_trace, page_id, started, "error") raise self._trace_finish(performance_trace, page_id, started, "success") return result except WorkerCancelled: self._trace_cancel(performance_trace, page_id) raise finally: if lock_acquired: self._lock.release() if slot_acquired: self._slots.release() def close(self, *, performance_trace=None) -> None: with self._lock: if self._closed: return self._closed = True worker = self._worker self._worker = None if worker is not None: try: worker.close() except Exception: worker.terminate() self._trace( performance_trace, "worker", stage="worker_close", model=self._worker_name, operation_count=0, duration_ms=0, status="success", ) def terminate(self, *, performance_trace=None) -> None: with self._lock: if self._closed: return self._closed = True worker = self._worker self._worker = None if worker is not None: worker.terminate() self._trace( performance_trace, "worker", stage="worker_cancel", model=self._worker_name, operation_count=0, duration_ms=0, status="error", ) def _acquire_slot( self, deadline: float | None, cancel: threading.Event | None, ) -> None: if cancel is not None and cancel.is_set(): raise WorkerCancelled("worker request was cancelled") _deadline_remaining(deadline) if not self._slots.acquire(blocking=False): raise WorkerQueueFull("worker request queue is full") def _acquire_worker_lock( self, deadline: float | None, cancel: threading.Event | None, ) -> None: while True: if cancel is not None and cancel.is_set(): raise WorkerCancelled("worker request was cancelled") remaining = _deadline_remaining(deadline) timeout = 0.05 if remaining is None else min(remaining, 0.05) if self._lock.acquire(timeout=timeout): return @staticmethod def _request_worker( worker: _Worker, envelope: dict[str, Any], *, deadline: float | None, cancel: threading.Event | None, ) -> dict[str, Any]: completed = threading.Event() outcome: dict[str, Any] = {} def execute() -> None: try: outcome["response"] = worker.request(envelope, deadline=deadline) except BaseException as exc: outcome["error"] = exc finally: completed.set() threading.Thread(target=execute, daemon=True).start() while not completed.wait(0.05): if cancel is not None and cancel.is_set(): raise WorkerCancelled("worker request was cancelled") _deadline_remaining(deadline) if cancel is not None and cancel.is_set(): raise WorkerCancelled("worker request was cancelled") error = outcome.get("error") if error is not None: raise error response = outcome.get("response") if not isinstance(response, dict): raise WorkerPoolError("worker returned an invalid response") return response def _discard_worker(self) -> None: worker = self._worker self._worker = None if worker is not None: worker.terminate() @staticmethod def _decode_response(response: dict[str, Any], request_id: str) -> dict[str, Any]: if response.get("request_id") != request_id: raise WorkerPoolError("worker response request id mismatch") if "error" in response: error = response["error"] if not isinstance(error, dict): raise WorkerPoolError("worker returned an invalid error envelope") message = error.get("message") if not isinstance(message, str) or not message: raise WorkerPoolError("worker returned an invalid error envelope") raise WorkerRemoteError(message) result = response.get("result") if not isinstance(result, dict): raise WorkerPoolError("worker returned an invalid result envelope") return result def _trace_cancel(self, performance_trace, page_id: str | None) -> None: self._trace( performance_trace, "worker", stage="worker_cancel", model=self._worker_name, page_id=page_id, operation_count=0, duration_ms=0, status="error", ) def _trace_finish( self, performance_trace, page_id: str | None, started: float, status: str, ) -> None: self._trace( performance_trace, "worker_finish", stage="task_worker", model=self._worker_name, page_id=page_id, operation_count=1, duration_ms=round((time.monotonic() - started) * 1000), status=status, ) @staticmethod def _trace(performance_trace, event: str, **fields) -> None: if performance_trace is None: return fields = {name: value for name, value in fields.items() if value is not None} try: performance_trace.event(event, **fields) except Exception: pass -
worker_resources.py 3.1 KB
"""Resource handling for blocking heavyweight worker processes.""" from __future__ import annotations import ctypes import gc import logging import os import subprocess import time _LOGGER = logging.getLogger(__name__) def _empty_current_process_working_set_windows() -> None: kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) psapi = ctypes.WinDLL("psapi", use_last_error=True) get_current_process = kernel32.GetCurrentProcess get_current_process.argtypes = [] get_current_process.restype = ctypes.c_void_p empty_working_set = psapi.EmptyWorkingSet empty_working_set.argtypes = [ctypes.c_void_p] empty_working_set.restype = ctypes.c_int if not empty_working_set(get_current_process()): raise OSError(ctypes.get_last_error(), "EmptyWorkingSet failed") def trim_parent_working_set_before_worker() -> None: gc.collect() if os.name != "nt": return try: _empty_current_process_working_set_windows() except OSError: return def run_isolated_worker( command: list[str], *, performance_trace=None, stage: str | None = None, model: str | None = None, operation_count: int | None = None, **kwargs, ): trim_parent_working_set_before_worker() env = os.environ.copy() env.update(kwargs.pop("env", {})) env["PYTHONUTF8"] = "1" kwargs["env"] = env if kwargs.get("text") and "encoding" not in kwargs: kwargs["encoding"] = "utf-8" if kwargs.get("text") and "errors" not in kwargs: kwargs["errors"] = "replace" if performance_trace is None: return subprocess.run(command, **kwargs) started = time.perf_counter() try: completed = subprocess.run(command, **kwargs) except subprocess.CalledProcessError: _record_worker_performance( performance_trace, started, stage=stage, model=model, operation_count=operation_count, status="failed", ) raise except BaseException: _record_worker_performance( performance_trace, started, stage=stage, model=model, operation_count=operation_count, status="error", ) raise _record_worker_performance( performance_trace, started, stage=stage, model=model, operation_count=operation_count, status="success" if completed.returncode == 0 else "failed", ) return completed def _record_worker_performance( performance_trace, started: float, *, stage: str | None, model: str | None, operation_count: int | None, status: str, ) -> None: fields = { "duration_ms": round((time.perf_counter() - started) * 1000), "status": status, } if stage is not None: fields["stage"] = stage if model is not None: fields["model"] = model if operation_count is not None: fields["operation_count"] = operation_count try: performance_trace.event("worker", **fields) except Exception: _LOGGER.warning("Performance trace recording failed", exc_info=True) -
__init__.py 44 B
"""Bundled image-to-PPT runtime modules."""
-
-
SKILL.md 8.1 KB
--- name: image-to-psd description: 将一张或多张图片转换为经过严格质量校验的分层 PSD;自动准备运行环境,通过当前 Agent 执行转换。输出修复背景、独立透明视觉组件和可编辑 Photoshop 文字图层。仅支持图片输入,不用于 PDF 或 PPTX。 --- # Image to PSD 把图片重建为分层 PSD。文字只由可编辑文字图层贡献一次;视觉组件和背景不得残留文字像素。质量检查未通过时继续针对性修复并复检,不把整页图片伪装成分层结果。 全部文字包括艺术字均须可编辑,保留原有曲线、描边及多色;不得用文字截图、转曲轮廓或透明文字覆盖冒充。复用有效识别和组件资产,避免重复推理;只有实际渲染和编辑验收通过才能作为成品交付。 局部 OCR 找回文字后,先验证源图、manifest、资产哈希和文字增量依赖。可证明安全时只更新受影响像素及背景,保留其余有效组件;并行路径也必须使用新增文字清理后的图像。重建范围覆盖实际文字清理边缘,不能仅依据原 OCR 框。 ## 输入与授权 识别阶段复用 text-context-cache 中匹配像素、语言和 OCR 实现的整行复核结果,避免重复处理相同冲突;不同栏位的文字不得因宽检测结果而丢失独立位置与样式。共享 OCR 的 words/runs 元数据不代表 PSD 已具备对应的艺术字渲染能力,必须核验实际文字图层效果。 - 仅支持 PNG、JPEG、BMP、TIFF 和 WebP。 - 单图输出一个 `.psd`;多图输出到目录,同名文件使用稳定序号区分。 - 每个 PSD 包含修复背景、按 z-order 排列的透明视觉组件和可编辑文字图层。 - PSD 写入依赖已授权的 Aspose.PSD。模型推理前必须设置 `ASPOSE_PSD_LICENSE`;授权缺失或无效时立即停止。 Windows PowerShell: ```powershell $env:ASPOSE_PSD_LICENSE="C:\path\to\Aspose.PSD.lic" ``` Linux/macOS: ```bash export ASPOSE_PSD_LICENSE=/path/to/Aspose.PSD.lic ``` 授权文件、模型权重、OCR 缓存和运行产物都不存放在此 skill 中。 ## 环境准备与图片兼容入口 转换前必须阅读并执行 [自动环境准备](references/setup.md)。完整仓库和仅安装 Skill 在 Windows、macOS、Linux 都自动准备缺少的 Python、Git、项目 Runtime、依赖、OCR 和模型。不得为依赖或模型安装向用户询问确认;遵循宿主实际审批与权限限制。Windows 新安装优先 D 盘,再选其他非 C 本地磁盘,仅在不存在其他本地磁盘时使用 C 盘;macOS/Linux 优先其他已挂载的本地磁盘,否则使用用户目录。使用 `scripts/skill_environment.py` 统一环境、模型、下载缓存与临时目录。已有可用环境和模型继续复用。 准备后默认使用下文产品 Runtime 的 Agent 流程;下面的 standalone CLI 是图片兼容入口。两者都使用自动安装的模型,不要求使用者手动配置 `SAM2_MODEL`、`LAMA_MODEL` 或 `GROUNDING_DINO_MODEL`。推理不会下载模型或回退 Hugging Face cache;准备阶段先校验 runtime receipt,已有显式模型路径须满足固定身份约束。LaMa 缺失或初始化失败时停止该无效路径并修复环境,不降低修复质量。 检查当前设备后再运行: ```bash python -c "import sys, torch; print({'platform': sys.platform, 'cuda': torch.cuda.is_available(), 'rocm': torch.version.hip})" ``` CPU 仍使用完整模型和相同质量门,速度会明显慢于 GPU。macOS 在真实 Apple Silicon 回归完成前不自动把 MPS 设为默认。 从 skill 根目录运行 module,不要直接执行脚本文件: ```bash cd skills/image-to-psd python -m scripts.image_to_psd input.png python -m scripts.image_to_psd input.png -o output.psd python -m scripts.image_to_psd img1.png img2.png -o psd-output python -m scripts.image_to_psd images/ -o psd-output --lang en ``` standalone CLI 只负责图片重建,不接受 `--agent-provider`。它先完成全部页面的严格准备,再发布 PSD;任一页面失败时不会留下部分输出。 ## 产品 Runtime 完整仓库或已安装的 `image2editable` 只支持 `host` Provider,使用统一的组件动作、最多 5 批修复和相同质量门。 完整仓库中缺少 PSD 依赖时,在仓库根目录安装对应 extra: ```bash python -m pip install -e ".[psd]" ``` 仅已安装 `image2editable` distribution、没有仓库源码时,直接安装同一 PSD writer 依赖,不对调用者的当前项目执行 editable install: ```bash python -m pip install "aspose-psd>=26.5.0" ``` 随后以非交互方式安装并校验固定的 SAM、LaMa 和 DINO runtime: ```bash image2editable models install runtime --yes image2editable doctor ``` `host` 直接使用当前支持视觉、本地文件读取、工具调用和结构化 JSON 的宿主,不探测、下载或要求配置其他组件决策模型。处理敏感文件前,确认宿主服务的数据策略符合要求。 Host 模式先准备 Run,再推进到 `awaiting_agent`: ```bash image2editable prepare input.png -o output.psd \ --run-dir runs/psd-job --format psd --agent-provider host image2editable run execute runs/psd-job image2editable agent next runs/psd-job image2editable agent record runs/psd-job --plan response.json image2editable run execute runs/psd-job ``` 第一次 `agent next` 返回视觉能力 challenge。必须实际查看 `image_path`,记录观察到的 `shape`、`color` 和 `count`,不能从文件名或 metadata 猜测。之后每轮只查看 request 中按顺序列出的 `review_evidence`,同时核验完整 request、hash、组件图、候选和冻结状态;`quality-report.json` 作为质量证据读取,不能当图片发送。 计划必须绑定当前 `request_sha256`。每个 action 只使用请求组件图中的 ID,并限定为现有十四类动作:`accept`、`discard`、`merge`、`split`、`expand`、`shrink`、`retry_with_box`、`retry_with_points`、`attach_text`、`suppress_text`、`collapse_to_parent`、`rebuild_background`、`absorb_residual`、`absorb_into_parent`。Agent confidence 不能放宽硬失败。 若绑定的 `unexplained-mask.png` 中有经验证的结构碎片,可用 `absorb_residual` 并入相关候选;请求图中的 inactive visual 有对应来源证据时,该动作仅恢复绑定残差,不恢复整个已停用复合对象,也不调用 SAM。随后按需 `rebuild_background` 并重新验证。只有残差证据不足以确定结构时才重新分割,不得将残差归为背景来消除违规。 ## 质量与失败 卡片底色连接多个独立纯色图形时,`split` 可复用原图色块边界拆分,保留全部像素,不调用 SAM。`parts` 对应实际完整单元并包含底色,不能按期望数量任意切块。文字框外的标点应修复 OCR 字形范围,不能作为图形残差吸收。 - 每张图片独立判断,不能跨图片套用拆分结果。 - 每个视觉组件应是可独立移动的最小完整单元,不得残缺、重叠、吸收相邻对象或只保留阴影碎片。 - 已通过组件立即冻结并复用;检测无进展和重复产物,停止无效策略并切换针对性修复,不为耗尽轮数重复执行。有实际进展的任务不因总耗时较长而放弃。 - `rebuild_background.margin_ratio` 使用能覆盖残影且不触及相邻结构的最小值,不固定写死。 - `unexplained_visual_residual` 必须由 active visual owner 覆盖;不能用 `accept`、`discard` 或归为背景来消除违规。 - 可靠 OCR 文字必须全部写为可编辑文字图层,并且只能出现一次。 - `preserved_with_warning` 是内部未完成状态,不是分层交付。当前运行时仍有修复周期耗尽后无法继续的路径;须解决该交付能力缺口并补通用回归,不能将低质量结果标为成功或宣称已具备发布条件。 - standalone 质量异常包含指标和诊断路径,由宿主检查 `source.png`、`ownership.png`、`reconstructed.png` 和 `report.json` 并修复。诊断不能代替最终文件,不把修复责任交给使用者;不得放宽门禁、删内容、伪造通过或回退为整页图片。用户主动取消时停止处理并保留恢复依据。
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.