Claude Skill

image-to-psd

将一张或多张图片转换为经过严格质量校验的分层 PSD;自动准备运行环境,通过当前 Agent 执行转换。输出修复背景、独立透明视觉组件和可编辑 Photoshop 文字图层。仅支持图片输入,不用于 PDF 或 PPTX。

LLM Mart · 0 points · 3 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download DSY-Xueai-image2editable-skills_image-to-psd-c0a6049.zip · 196 KB
Part of dsy-xueai/image2editable — 2 skills

Install

skills CLI npx skills add https://github.com/DSY-Xueai/image2editable/tree/main/skills/image-to-psd
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install dsy-xueai-image2editable@llmmart
Git 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.

No comments yet.

Reviews (0)

No reviews yet.

Related