10 Commits

Author SHA1 Message Date
Ukawka b3b51a48cf 增加测试数据 2026-09-23 20:54:36 +08:00
Ukawka 00f476022d 第三阶段改进 2026-09-22 22:18:34 +08:00
Ukawka 8080d52a99 接通第四步 2026-09-22 01:02:21 +08:00
Ukawka e9072aecf2 文档整理 2026-09-21 00:23:16 +08:00
Ukawka fe4ee1a08e 修改文档执行命令 2026-09-20 23:55:25 +08:00
Ukawka a00a5621cf 处理尺寸不一致 2026-09-20 22:36:20 +08:00
Ukawka 99d65377ab 第三步补全接入 2026-09-20 22:22:09 +08:00
Ukawka f095c28858 第二步SAM模型接入 2026-09-18 20:29:29 +08:00
Ukawka 1a7b2f14a9 增加几何校验 2026-09-17 18:50:43 +08:00
Ukawka 4a49fe14e9 文档整理 2026-09-17 18:02:06 +08:00
47 changed files with 4602 additions and 303 deletions
+10 -2
View File
@@ -4,10 +4,18 @@ VECTOR_ENGINE_API_KEY=replace-me
# Optional request timeout in milliseconds # Optional request timeout in milliseconds
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1200000 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1200000
# Optional independent relay: select with --config configs/tiantoken.json
TIANTOKEN_BASE_URL=https://api.tiantoken.com
TIANTOKEN_API_KEY=replace-me
# Defaults in tiantoken.json; independent from SPINE_* model overrides.
# TIANTOKEN_UNDERSTANDING_MODEL=gpt-6-astra
# TIANTOKEN_COMPLETION_MODEL=gpt-image-2
# Independent model IDs for the annotated-parts pipeline. Use EXACT relay IDs. # Independent model IDs for the annotated-parts pipeline. Use EXACT relay IDs.
# Confirm with: node tools/model-check.mjs --list-models # Confirm with: node tools/model-check.mjs --list-models
SPINE_UNDERSTANDING_MODEL=gpt-6-astra SPINE_UNDERSTANDING_MODEL=gpt-6-astra
SPINE_COMPLETION_MODEL=qwen-image-3.0 SPINE_COMPLETION_MODEL=qwen-image-3.0
# Checkpoint location only; the SAM runtime is integrated in milestone 2. # Local source + checkpoint root. SAM runs in a temporary ai-spine GPU process.
SAM_MODEL_DIR=F:/Study/sam3.1 SAM_MODEL_DIR=F:/Study/SAM3
SAM_CODE_DIR=F:/Study/SAM3/sam3
+3
View File
@@ -16,3 +16,6 @@ references/external/kenney-platformer/
# OS/editor noise # OS/editor noise
.DS_Store .DS_Store
#data
data/
+159
View File
@@ -0,0 +1,159 @@
# 项目修改约定
本文件适用于 `AI_spine/` 及其所有子目录。每次修改代码、文档、依赖或运行测试前,都必须阅读并遵守。
## Python 环境
- 本项目固定使用名为 `ai-spine` 的 Conda 环境。
- 不创建、不使用 `.venv`、`venv` 或其他 Python 虚拟环境;不把依赖安装到 Conda base 或系统 Python。
- 交互式终端先执行 `conda activate ai-spine`;自动化命令使用 `conda run -n ai-spine ...`。
- Python 依赖统一记录在 `requirements.txt`。安装命令使用 `conda run -n ai-spine python -m pip install -r requirements.txt`,避免裸 `pip` 指向错误环境。
- Node 管线调用 Python 时也必须使用此 Conda 环境。`--python` 只能指向 `ai-spine` 环境中的解释器。
- 不在代码中硬编码本机 Conda 安装路径,不自动创建环境或自动安装依赖。缺少环境或依赖时给出明确的安装/激活命令。
## 修改与验证
- 修改环境选择、依赖或启动方式时,同步更新 README、相关文档和 CLI 帮助,保持一致。
- 测试在 `ai-spine` 环境下执行:`conda run -n ai-spine npm test`。不要为了绕过依赖问题临时新建环境。
- 验证结果必须如实记录;缺少依赖导致未运行的检查,不得声称通过。
- 保留用户已有修改和实验产物,不覆盖旧实验输出。
## 跨会话上下文与阅读顺序
本节于 2026-09-22 接入通用第四阶段及 adaptive 补全后更新。它是后续对话的上下文入口;后续修改应同步更新对应状态,实际源码、配置和具体实验 manifest 优先于历史总结。不要把旧实验统计套到新输出上。
1. 先阅读本文件和 `git status --short`,保留用户已有修改。
2. 阅读 [README](README.md) 了解项目、环境、配置与命令。
3. 按任务阅读 [第一阶段](docs/1-understanding-stage.md)、[第二阶段](docs/2-segmentation-stage.md)、[第三阶段](docs/3-completion-stage.md)、[第四阶段](docs/4-spine-stage.md),再核对相关实现与实际产物。
4. 用户说“继续”时,先核对工作区和最近任务进展,不能只凭历史总结重复请求付费模型或覆盖实验。
## 用户目标与固定偏好
- 目标是将单张 2D 角色图拆成可以用于 Spine 的部件,保留画风、姿态、左右、不对称性及原有像素关系,补足遮挡和关节连接。
- 第二阶段使用本地 SAM 代码和权重,通过临时 Conda 子进程在本机 GPU 推理,完成后退出;不部署常驻独立服务。
- 需要可直接执行的 PowerShell 命令;说明当前已实现行为、实测结果与未来计划,不能把几何通过写成资产合格。
- 不确定的部件语义、遮挡和候选点需要复核,不得用前景检查、覆盖率或模型分数代替语义准确性。
- 输出使用独立目录,保留原候选和诊断;已有候选优先离线复检,避免无意义重复生成。
## 文档维护约定
- 原有文档文件名保持不变。`docs/` 保留前三份阶段文档,并新增 `4-spine-stage.md`;`docs/model-stages.md` 在此前文档整理前已处于用户删除状态,不自行恢复,相关说明归入 README 和阶段文档。
- 四份阶段文档有且仅有以下八个二级章节,细节必须归入对应章节:
1. 阶段简介
2. 输入、输出与执行链路
3. 阶段执行命令示例及参数说明
4. 输入格式要求
5. 执行链路中重要部分代码文件及原理说明(包括重点说明)
6. 各个代码文件职责索引
7. 产物详细说明
8. 当前阶段可改进点说明
- 外层 README 负责整体介绍、Conda 准备、配置及每阶段命令;本文件负责约定和后续会话必要上下文。已存在的环境与修改约定必须保留。
- 改进项明确写为待实现/待验证;实现后更新状态、命令、产物说明及本文件。PowerShell 反引号后不能有空格;示例变量先定义,示例输出目录相互独立。
## 工作区、环境与配置
- 当前实际项目目录:`F:\Study\taonier\AI_spine`。用户历史命令也出现过 `F:\Study\taonier\AI\_spine`,不要盲目复制旧路径,先确认 cwd。
- 当前 SAM 根目录为 `F:\Study\SAM3`;源码仓库 `F:\Study\SAM3\sam3`;权重 `F:\Study\SAM3\sam3.1checkpoint\sam3.1_multiplex.pt`。早期 `F:\Study\sam3.1` 已不是当前约定路径。
- 已验证组合:Python 3.11、torch 2.7.1+cu126、torchvision 0.22.1、NumPy 1.26.4、OpenCV 4.11.0.86;RTX 3060 Laptop 6 GB。机器环境可能变化,执行前按需探测。
- Node.js >=20,无额外 npm 运行时依赖。自动化使用 Conda ai-spine 执行 Node/Python;运行 SAM 可用 `conda run --no-capture-output -n ai-spine node ...`。
- 默认配置是 `configs/astra-qwen.json`;示例推荐明确传 config,避免环境变量导致选错提供方。
- `configs/deepseek-qwen.json`:deepseek-flash / Chat Completions / 非流式;补全 qwen-image-3.0。
- `configs/astra-qwen.json`:gpt-6-astra / Responses / 流式;补全 qwen-image-3.0。
- 上述两份读取 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY,模型变量为 SPINE_UNDERSTANDING_MODEL / SPINE_COMPLETION_MODEL。
- `configs/tiantoken.json`:独立 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY,地址 https://api.tiantoken.com;理解 gpt-6-astra,补全 gpt-image-2;模型变量为 TIANTOKEN_UNDERSTANDING_MODEL / TIANTOKEN_COMPLETION_MODEL。
- CLI 模型 > stage.model > 对应模型环境变量 > default_model;shell 同名变量优先于 .env。模型 ID 原样发送,不加隐式别名。
- 用户提供的密钥仅留在本地忽略的 `.env`;禁止在文档、命令日志、聊天或提交中暴露,不要读取/打印完整 .env 来诊断。
- 三份配置的图片编辑能力仍为 multiple_images=false、mask=false。adaptive auto 在此配置下发送单图参考板,包含局部和完整角色参考,mask 仅本地约束。独立多图/mask 和单部件探针已接入,但本轮未调用付费模型,不能宣称中转已支持。Astra 理解接口与图片编辑接口不同,当前没有 Astra Responses 图片生成工具链。
## 四阶段实现与不能丢失的边界
### 第一阶段:理解与几何候选
- 入口 `tools/model-check.mjs --stage understanding`,或 `pipeline.mjs --mode annotated-parts`;后者仅转发新入口。
- `stages/model-stage.mjs` 调用视觉模型,`lib/validate-parts.mjs` 校验 v2:rig_role、bind_to、三项 completion 及 needs_completion 一致性。
- `normalize_reference.py` 保留原画布,使用 Alpha 或边界连通近白背景处理;`geometry_grounding.py` 用 Alpha/ROI 距离变换生成候选,不是再次请求 AI。
- 模型原始点仅诊断;程序候选也未获语义确认;pivot 独立保留,不吸附前景。
- 第二阶段输入必须为同一实验的 source.png、semantic-parts.json、segmentation-prompts.json。
- `--parts-file` 可离线迁移 v1/检查 v2 并生成新候选;旧 annotated.svg 不会自动更新。
### 第二阶段:SAM 可见拆件
- 入口 `--stage segmentation --input-dir 理解目录`;实现为 stages/segmentation-stage.mjs、sam_runtime.py、sam_segment.py、segmentation_outputs.py。
- 环境探测与推理分别为短期子进程,禁止在线下载权重。multiplex detector 适配实际严格加载 1130 个张量,忽略 36 个未使用视频 neck 张量;不要因此改成 strict=False 或修改外部 SAM 源码。
- BF16 仅用于推理 autocast;不要整体转换模型而破坏复数缓冲区/FP32 路径。
- 使用文本和文本+粗框两组候选;当前图像概念接口未接收点提示,Alpha 种子仅参与 mask 排序。
- 与原图 Alpha 取交集后提取原像素;mask 为原画布二值图,parts 为紧裁剪 RGBA。重叠保留、覆盖率仅为像素覆盖,semantic_verified=false。
- annotated.png 为原图纹理上半透明颜色叠加,parts-sheet.png 为白底拆件;纯色遮罩角色图与可选大模型去毛边尚未实现。
### 第三阶段:受限部件补全
- 入口 `--stage completion --input-dir SAM目录`;实现为 stages/completion-stage.mjs、completion_geometry.py、completion_planning.py、providers/image-edits.mjs。默认 completion-profile=adaptive,legacy 保留旧行为和候选兼容。
- 输入须含 source.png、v2 计划、segmentation.json 及所有记录的全画布 masks;单独 parts/ 不够。
- adaptive 从实际 mask 重建遮挡/关节、闭合漏分孔洞、附件底图清理需求,不单凭 needs_completion 筛选。自动孔洞只取原图前景且无其他 mask 归属的区域;开放缺口和歧义归属仍需复核。无明确遮挡的近重复 mask(IoU≥0.8)要求人工确认。
- adaptive preserve=SAM−editable−removed;仅保留区锁定原 RGBA,清理和替换区可重绘,人工 remove 可删除。completion-review JSON(schema_version=1、parts 映射、confirmed=true)支持 repair/replace/remove 全画布二值 masks 与部件半径,原文件及哈希留档,不改 SAM。
- 默认请求边长 1024;adaptive 半径按 mask 短边×0.15 向上取整并限于 4~96 原像素,CLI 或部件 review 可覆盖。native 裁剪边长约目标包围框最长边×1.15;小部件放大、大部件缩小,保存正反仿射和工作区。逆缩放后恢复保留原像素,坐标可供第四阶段使用。
- reference auto:multiple_images=true 时 input→context→reference,默认 false 时发送 board(左目标,右局部和全角色参考),参考面板不进入最终资产。支持 none/local/full/both/board;mask auto/required/off 独立选择。request-plan.json 记录计划,request.json 仅实际发送时生成,含有序图片角色、哈希、尺寸、字段、mask 哈希与透明语义。
- legacy 才沿用 needs_completion、原像素不缩放、默认 32 半径和原可见像素全保护。旧候选复检必须显式 --completion-profile legacy。新旧画布/参考布局/复核文件不同不能混用候选。
- 图片解码已修复“空 b64_json 但有有效 URL”情况,回退下载 URL;该问题与画布尺寸不符不同。
- 在线请求现显式传 size,配置冲突失败。strict 默认拒绝尺寸不同;uniform 仅恢复同宽高比整画布尺寸,保留原候选,继续原有验收,不单独移动/缩放物体。
- 对齐阈值保持 RGB MAE≤30、保留率≥95%;adaptive 在请求工作区的保留不透明像素上检查,再逆采样,避免把插值 Alpha 误作位移。编辑像素必须位于允许区且连接保留区;可见缺口使用原图纹理,移除区清零。
- adaptive 清理/人工替换要求≥95% 有效填充,其余 unfulfilled_regions 仍仅诊断。语义、接缝和关节质量不能由填充率证明。未明确编辑的误分仍被保护,需复核文件修正。
- --probe-edit-capabilities 要求 adaptive 在线且恰好一个 --part,发送主图/局部/全角色+mask,n=1、maxRetries=0。记录 capability-evidence 和合并前 raw_outside_edit 指标;不会把 reference_use_verified/mask_semantics_verified 自动置 true 或修改配置。prepare-only/mock 不是中转实测。
- prepare-only 不联网;candidates-dir 可读平铺 ID.png 或匹配 manifest 的旧任务首张候选。--resume 不是失败断点续发,准备/生成/复检使用独立目录。
- parts/ 只含通过检查的补全部件,不包含全部原拆件。完整角色组装、Spine 导出与旋转检查动画已独立接入第四阶段;运动质量自动验收仍未实现。
- 旧 `--stage completion --image ... --prompt-file ...` 是接口探针,不能和 input-dir 混用,不执行完整几何验收。
### 第四阶段:通用组装与 Spine 导出
- 用户明确要求可复用管线,不能写死具体角色、部件 ID、数量或人形比例。入口 `--stage spine --input-dir SAM目录 --completion-dir 补全目录 --out-dir 新目录`;completion-dir 可省略。
- `stages/spine-stage.mjs` 与 `spine_assembly.py` 完全离线,校验 v2、输入快照及 masks 哈希、PNG 和裁剪坐标;不读取模型配置/密钥,不调用 SAM。
- 原资产从 source + mask 重建,采用补全结果前检查 selected_file、状态、qa.accepted、image、crop_xyxy。legacy 全可见 RGBA 保持;adaptive 验证 preservation 分区、保留 RGBA 精确匹配、编辑/移除互斥及输出不越界,契约 mask 哈希留档。不能混用其他实验。
- 默认补全失败回退 SAM 并记录原因;fallback-policy=error 拒绝需补全但无通过结果的可见部件。缺可见 mask 直接报错;隐藏骨骼保留但不生成图片。
- pivot 转换为底边中点原点的 Spine 坐标;骨骼 setup 旋转为零,局部位置取父子 pivot 差;附件中心相对绑定骨骼偏移。bone_part 使用 parent,其他角色使用 bind_to。
- occluded_by 稳定拓扑排序;无约束重叠写入诊断,可用 draw-order-file 全量覆盖,冲突留档。骨骼/遮挡环路拒绝。
- 导出完整 images、assembly.json、静态组装/对比/覆盖诊断、skeleton.json、atlas、report.html 和真实 preview.html。依赖外部只读图集和播放器脚本,其哈希纳入复用指纹。
- 目标 Spine 4.2;setup 静态姿态及 check__ID 单骨骼检查动画。没有成品 idle/walk、mesh/weights/IK,semantic_verified=false、quality_status=needs_review;几何通过和可加载不代表资产合格。
- 生成离线;播放器从 CDN 加载资源,打开骨架预览需要网络。静态报告可离线查看。
## 已检查实验与质量结论
以下为已有实验记录,不代表后续每次运行的结果;不必为了文档修改重跑 GPU 或付费模型。
| 目录(相对于项目) | 事实与意义 |
| --- | --- |
| output/sombrero-grounding-v2 | 旧计划离线几何检查,28 个程序候选通过前景检查;未证明语义正确 |
| output/sombrero-sam31-v3 | 较早 SAM 实验,28 个 mask,约 67.18% 覆盖率;肢体/鞋子重复、漏分,不合格 |
| output/20260918-183532/sam | 较早基线:32 个计划项、10 个 hidden、22 个 mask;约 83.3% 覆盖,32719 像素未分配;前臂/手混入衣料、袖子与上衣缺损 |
| output/20260918-183532/complete2_gpt | 真实 Tiantoken gpt-image-2 请求 12 部件;旧请求未传 size,返回 1248/1254,预期 1024,全部 CANVAS_SIZE_MISMATCH |
| output/20260918-183532/complete2_gpt-size-recheck-v2 | 离线 uniform 复检,未重生成:8 个几何通过;head、pants_thigh_l、pants_thigh_r、shoe_l 4 个拒绝;通过项仍有误分和未完成区域 |
| output/20260918-183532/complete-recovered-head-check | 更早 Qwen 头部候选恢复检查,不能与 GPT 批次混为一谈 |
| output/20260920-225137 | 较新实验:27 个语义项、2 个 hidden、25 个 SAM 部件;补全 16 项中 13 个几何通过、3 个对齐拒绝 |
| output/spine-validation-latest-20260922 | 第四阶段离线验证:25 个 slot、16 个骨骼(含 root);采用 13 个补全部件,5 个需补全部件回退 SAM(含两袖无允许区域);上游缺口仍存在 |
| output/spine-validation-baseline-20260922 | 另一批输入验证:22 个 slot、16 个骨骼(含 root);采用 8 个补全部件、4 个回退;输入源为旧基线和 size-recheck-v2 |
| output/completion-adaptive-prepare-20260922 | adaptive 离线准备:16 个任务、11 个跳过;上衣规划 3708 个封闭漏分像素,头部规划 10205 个附件清理像素;这些是待复核区域,不是补全成功结果,未请求模型 |
- 当前 22→12 的原因:sombrero、sleeve_l、sleeve_r、eye_l、eye_r、eyebrow_l、eyebrow_r、nose、mouth、mustache 共 10 个被第一阶段标记 NO_COMPLETION_NEEDED;不能解释为它们的实际 SAM mask 已完整。
- 2026-09-20 尺寸修复后曾通过 22 项 Node 测试(含 Python 补全回归)。这是历史验证;新改动按范围重新验证,不自动沿用“全部通过”。显式 size 构造已测试,修复后中转是否遵守尚无新实测。
- 总体判断:上游部件归属、模型缺少原图参考与遮罩、区域规则粗糙、验收不足共同导致低质量。不能仅换模型、强制处理全部 mask、放宽阈值或解决尺寸错误就宣称修好。
- 2026-09-22 第四阶段接入后,Conda ai-spine 下 npm test 共 26 项通过(含新增 6 项 Python 组装用例)。另使用官方 Spine 4.2 core IIFE 对上述两份导出分别验证 setup 顶点和 30 个单关节旋转采样,全部通过。最新导出的 preview.html 已在 headless Chrome 实际加载渲染,截图为 output/spine-runtime-validation-20260922/preview.png;这不是动作或资产质量验收。复检工具为 `tools/verify-spine-runtime.mjs`,需调用者提供本地官方 runtime 文件,不自动下载/安装依赖。
- 随后接入 adaptive 补全,Conda ai-spine 下 npm test 共 29 项全部通过(含 8 项新增 Python adaptive 用例):覆盖参考顺序/mask mock、放大/缩小、位移拒绝、实际缺口、清理与区域契约、候选身份及第四阶段 strict 回退。未进行付费中转试验,真实参考使用与 mask 语义仍待验证。
## 当前改进方向与后续顺序
当前用户已要求落实第三阶段的参考图/区域重建/自适应画布,必须保持可复用且与第四阶段贯通。当前状态:
1. 第二阶段输出仅色彩遮罩组成的完整角色图,保留当前原图叠色预览,并明确未分配/重叠区域。
2. 第二阶段每个 SAM 部件可选经过一次大模型去毛边,保留原始拆件、原图参考、坐标、变更区域与前后对照。
3. 第三阶段参考图、实际 mask 规划、自适应区域/可逆缩放、发送审计和单部件能力探针已实现并离线验证;真实多图/mask 语义和新布局生成质量仍待付费小样实测,默认 capabilities 不变。
4. 完整角色组装及失败回退记录已在第四阶段实现;接缝与关节旋转质量自动验收仍待实现。
5. 后续质量工作:复核自动规划和 SAM 归属、实测参考图与遮罩、加强接缝/动作验收。第三/四阶段遵循统一输入契约,不为某个实验硬编码修正。
详细改进说明保留在各阶段文档第 8 节;若后续用户改变优先级,应更新此处。
## 旧管线与范围识别
- `pipeline.mjs` 未指定 --mode annotated-parts 时走旧“生成拆件图→CV 定位→Spine”流程,不是新四阶段自动串联。
- `segment.py` 为旧拆件图分割;`compose_preview.py` 为旧布局组装;`run_case_matrix.py` 为旧定位对照工具。不要因为 IDE 当前打开这些文件就误改新阶段入口。
- 相邻 `spine-animation-ai` 为外部只读依赖,保留其 PolyForm Noncommercial 许可证约束;第四阶段复用图集和播放器脚本,不复用旧特征定位或 root 平铺骨架逻辑。
- 不要把文档改写任务扩大成付费图片生成、SAM 推理、依赖安装或功能实现;需要实现时遵从用户后续任务范围。
+235 -54
View File
@@ -1,73 +1,254 @@
# Genarrative / Genielabs Spine Animation Pipeline # AI Spine 角色拆件与动画管线
## 新增:可替换模型与角色结构理解 本项目探索将单张 2D 角色图转换为可用于 Spine 动画的部件与骨骼资源。当前主线分为四个阶段:视觉 LLM 理解结构、本地 SAM 分割可见部件、图片编辑模型补全隐藏区域和关节连接、离线组装与 Spine 导出。
已新增 `--mode annotated-parts`,默认 GPT6Astra 做结构理解,Qwen Image 3.0 作为独立图片编辑适配器。模型、协议和提示词可分别配置;第一阶段输出 JSON、粗标注 SVG 和检查报告。SAM 分割与补全合并尚未接入。 | 阶段 | 输入 → 输出 | 当前状态 |
| --- | --- | --- |
| 1. 结构理解 | PNG → v2 语义计划、粗框、pivot、程序候选点 | 可在线理解或离线复检;语义待复核 |
| 2. SAM 分割 | 第一阶段目录 → mask、原像素透明部件、标注图与拆解图 | 本机 GPU 实测可运行;存在误分与漏分 |
| 3. 部件补全 | SAM 目录 → 局部编辑候选、几何 QA、通过检查的补全部件 | 已真实调用图片模型;结果质量尚不合格 |
| 4. Spine 导出 | SAM + 可选补全目录 → 完整资产、原位组装、骨骼、图集、预览 | 通用离线入口;含单骨骼旋转检查动画,资产与动作质量需复核 |
第三阶段输出通过几何检查的补全部件;第四阶段整合所有可绘制部件、组装并导出 Spine 4.2。执行成功、mask 覆盖率高、几何检查通过,均不等于动画资产可用。
仓库保留旧版“生成白底拆件图 → OpenCV 拆分 → Genielabs 定位 → Spine 导出”作为对照;它与当前四阶段主线并存,产物不能直接混用。
## 文档入口
- [AGENTS.md](AGENTS.md):修改约定、跨会话上下文、当前基线及后续优先级。
- [第一阶段](docs/1-understanding-stage.md):理解、数据契约、Alpha 候选与粗标注。
- [第二阶段](docs/2-segmentation-stage.md):本地 SAM、mask 选择、拆件与质量边界。
- [第三阶段](docs/3-completion-stage.md):筛选、局部画布、图片请求、几何验收及补全改进。
- [第四阶段](docs/4-spine-stage.md):通用资产整合、坐标与骨骼、绘制顺序、Spine 导出与回退策略。
阶段文档统一采用八节:简介;输入输出与链路;命令参数;输入格式;重要代码原理;职责索引;产物;可改进点。配置与实验说明已归入上述文档。
## Conda 环境准备
所有 Python 操作固定使用 **ai-spine**,不使用 `.venv`、其他虚拟环境、Conda base 或系统 Python。Node.js 要求 20 或以上;项目没有额外 npm 运行时依赖。
在 PowerShell 中进入实际项目根目录。本工作区路径如下;若本机使用其他目录,仅替换这一行:
```powershell ```powershell
node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-astra --stop-after understand Set-Location F:\Study\taonier\AI_spine
node --version
conda env list
``` ```
完整配置、模型切换、独立图片编辑测试和产物说明见 [docs/model-stages.md](docs/model-stages.md)。以下为旧版拆件管线的说明。 仅在没有 ai-spine 环境的新机器上,手动创建一次;当前机器已有环境则跳过:
这是独立目录,不修改 Genarrative。它只参考 Genarrative 的 VectorEngine 图片生成协议:后端环境变量提供 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`,使用 OpenAI-compatible `POST /v1/images/edits` multipart 请求。 ```powershell
conda create -n ai-spine python=3.11 -y
```
模型 ID 原样传给中转站,不再进行隐式映射。可通过 `node tools/model-check.mjs --list-models` 查询当前服务实际列出的 ID。 补齐项目依赖。以下是本机已验证的 CUDA 12.6 组合;已有匹配 PyTorch 时可跳过第一条安装命令。代码不会自动安装依赖或创建环境。
拆件提示词明确要求保留参考图的原始姿态、左右方向、遮挡关系和非对称结构,禁止镜像、拉直和生成对称备件。图片接口对 429/5xx 会自动重试 3 次;使用新输出目录即可重新生成,旧目录可用 `--resume` 复用已生成的拆件图。 ```powershell
conda activate ai-spine
python -m pip install torch==2.7.1 torchvision==0.22.1 --index-url https://download.pytorch.org/whl/cu126
python -m pip install -r .\requirements.txt
python -m pip install --no-deps --no-build-isolation -e F:\Study\SAM3\sam3
```
正式运行默认尝试 3 个通用拆件提示词变体,分别按“严格姿态保持”“Spine 生产拆件”“技术 ground truth”描述同一要求;脚本按前景部件数量选择最接近完整角色的一份。可用 `--attempts 1` 关闭多候选尝试。 不激活终端时,明确指定目标环境:
## 运行 ```powershell
conda run -n ai-spine python -m pip install -r .\requirements.txt
```
```bash 本地 SAM 代码和权重由用户提供,当前位置为:
node pipeline.mjs \
--reference /absolute/path/character.png \ ```text
--out-dir ./output/character-spine \ F:/Study/SAM3/sam3/ 源码仓库
--genielabs-dir /absolute/path/spine-animation-ai \ F:/Study/SAM3/sam3.1checkpoint/sam3.1_multiplex.pt 权重
--env-file /absolute/path/.env \ ```
--image-model qwen-image-3.0 \
检查解释器、基础依赖与 GPU:
```powershell
conda run -n ai-spine python -c "import sys, cv2, PIL, numpy, torch; print(sys.executable); print('torch', torch.__version__); print('CUDA', torch.cuda.is_available()); print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'No CUDA GPU')"
```
第二阶段要求可用 CUDA GPU。本机已验证 RTX 3060 Laptop 6 GB;默认 BF16 推理保留模型参数和缓冲区所需精度。第一、三阶段的本地几何处理不运行 SAM。Node 入口会验证 Python 属于 ai-spine;`--python` 也不能绕过此约定。
## 中转站配置
仅在本地 `.env` 不存在时复制模板,随后自行填写密钥:
```powershell
if (-not (Test-Path .\.env)) { Copy-Item .\.env.example .\.env }
```
```dotenv
# 原中转站
VECTOR_ENGINE_BASE_URL=https://router.genarrative.world/v1
VECTOR_ENGINE_API_KEY=填写你的令牌
# Tiantoken 独立配置
TIANTOKEN_BASE_URL=https://api.tiantoken.com
TIANTOKEN_API_KEY=填写你的令牌
# 本地 SAM
SAM_MODEL_DIR=F:/Study/SAM3
SAM_CODE_DIR=F:/Study/SAM3/sam3
```
不要把密钥写入文档、JSON 配置或提交到版本库;不要输出 `.env` 内容。
| 配置文件 | 第一阶段 | 第三阶段 | 模型环境变量 |
| --- | --- | --- | --- |
| [deepseek-qwen.json](configs/deepseek-qwen.json) | deepseek-flash,Chat Completions,非流式 | qwen-image-3.0,images/edits | SPINE_UNDERSTANDING_MODEL / SPINE_COMPLETION_MODEL |
| [astra-qwen.json](configs/astra-qwen.json) | gpt-6-astra,Responses,流式 | qwen-image-3.0,images/edits | 同上 |
| [tiantoken.json](configs/tiantoken.json) | gpt-6-astra,Responses,流式 | gpt-image-2,images/edits | TIANTOKEN_UNDERSTANDING_MODEL / TIANTOKEN_COMPLETION_MODEL |
CLI > 配置 stage.model > 对应模型环境变量 > default_model;shell 环境变量优先于 `.env`。不指定配置时入口默认 astra-qwen.json,建议实验始终显式指定。模型 ID 原样发送,中转能力以实际请求为准。Tiantoken 图片编辑已真实执行;第三阶段尚未适配 Astra Responses 图片生成工具。
三个配置当前仍关闭原生多图与编辑 mask 能力。第三阶段默认 adaptive:实际 mask 重建需求、自适应半径和可逆缩放;当前配置下 auto 会发送含目标、局部参考、完整角色参考的单张参考板。只有支持原生多图时才独立发送参考图;mask 是否发送由配置和 completion-mask 决定,未发送时明确标记 local_only。不能把本地裁剪与恢复当成模型遵循 mask 的证据。
查询模型列表和检查配置:
```powershell
node .\tools\model-check.mjs --config .\configs\tiantoken.json --list-models
node .\tools\model-check.mjs --config .\configs\tiantoken.json --dry-run
```
前者联网,后者不请求模型;列表存在某个 ID 不代表其所有能力已验证。
## 四阶段执行命令
以下命令按顺序在同一个 PowerShell 会话执行。反引号后不能带空格。每次实验使用新的 runId;分阶段重开终端时,将路径变量设置成已有的对应目录。
```powershell
conda activate ai-spine
$runId = Get-Date -Format "yyyyMMdd-HHmmss"
$runRoot = ".\output\$runId"
$reference = ".\output\20260918-183532\sam\source.png"
```
示例复用现有角色原图,可替换 `$reference` 为自己的 PNG。不要混用不同实验的 source、计划和 mask。
### 第一阶段:理解角色结构
此命令调用视觉模型:
```powershell
node .\tools\model-check.mjs --stage understanding `
--config .\configs\deepseek-qwen.json `
--reference $reference `
--out-dir "$runRoot\understand"
```
检查 `understand/report.html`、`semantic-parts.json`、`geometry-validation.json`。程序候选点来自 Alpha 距离变换,落在前景不代表属于正确部件。
已有计划可离线重新检查;输出独立目录,不重跑模型:
```powershell
node .\tools\model-check.mjs --stage understanding `
--reference .\output\20260918-183532\sam\source.png `
--parts-file .\output\20260918-183532\sam\semantic-parts.json `
--out-dir "$runRoot\understand-offline"
```
### 第二阶段:本地 SAM 分割
```powershell
node .\tools\model-check.mjs --stage segmentation `
--input-dir "$runRoot\understand" `
--out-dir "$runRoot\sam" `
--sam-dir F:/Study/SAM3
```
若使用前面的离线计划,input-dir 改为 understand-offline。该命令只运行本地临时 GPU 子进程;添加 `--dry-run` 可探测环境而不加载模型。检查 `sam/report.html`、`annotated.png`、`parts-sheet.png`、漏分图和 mask 归属,错误部件应先修正。
### 第三阶段:准备与补全
默认 `--completion-profile adaptive` 会重新检查实际 SAM 缺口、骨骼连接和附件重叠,即使旧计划 needs_completion=false 也可能生成修复任务。先检查 preparation 报告的区域与参考图,再请求模型;缺口归属和附件底图仍需语义复核。
先离线准备任务,不请求模型:
```powershell
node .\tools\model-check.mjs --stage completion `
--config .\configs\tiantoken.json `
--input-dir "$runRoot\sam" `
--out-dir "$runRoot\complete-prepare" `
--prepare-only
```
复核允许区域后,按实际存在的 ID 试一个部件;下面会调用图片模型:
```powershell
node .\tools\model-check.mjs --stage completion `
--config .\configs\tiantoken.json `
--input-dir "$runRoot\sam" `
--out-dir "$runRoot\complete-head" `
--part head
```
处理所有具备可见 mask、补全需求和允许区域的部件:
```powershell
node .\tools\model-check.mjs --stage completion `
--config .\configs\tiantoken.json `
--input-dir "$runRoot\sam" `
--out-dir "$runRoot\complete"
```
省略 part 不意味着强制补全全部 mask。检查 `completion-plan.json` 的 assessments/跳过原因、每个任务的 input.png、request-plan.json 和编辑区域;parts/ 仅包含通过检查的补全部件。所有结果仍需语义复核。`--completion-review FILE` 可按 ID 提供修复/替换/移除区域及部件半径,格式见[第三阶段文档](docs/3-completion-stage.md)。
`--completion-reference auto` 默认在原生多图可用时发送主图→局部参考→全角色参考,否则使用 board;可显式选择 board/local/full/both/none。`--completion-mask auto|required|off` 控制 mask 请求,auto 遵循配置。`--probe-edit-capabilities --part ID` 是一次真实多图+mask 小样,固定 n=1、无 HTTP 重试,保存原候选及能力证据,不自动启用 capabilities。该实测会产生费用;当前实现只完成离线验证。
旧候选尺寸不符时,可离线导入复检,避免重新生成:
```powershell
node .\tools\model-check.mjs --stage completion `
--config .\configs\tiantoken.json `
--input-dir .\output\20260918-183532\sam `
--candidates-dir .\output\20260918-183532\complete2_gpt `
--out-dir "$runRoot\complete-size-recheck" `
--completion-profile legacy `
--candidate-size-policy uniform
```
旧画布候选必须指定 legacy。adaptive 候选需匹配源图、masks、profile、参考布局、半径和复核文件哈希;不能把旧候选直接映射到新画布。uniform 仅恢复同宽高比整画布尺寸,不纠正物体位移、变形或语义。在线请求显式传 size;参数及中转实测边界见[第三阶段文档](docs/3-completion-stage.md)。
### 第四阶段:离线组装与 Spine 导出
接续本轮 SAM 与补全目录,无需模型配置、密钥或 GPU:
```powershell
node .\tools\model-check.mjs --stage spine `
--input-dir "$runRoot\sam" `
--completion-dir "$runRoot\complete" `
--out-dir "$runRoot\spine"
```
省略 `--completion-dir` 可只用 SAM 资产;加 `--dry-run` 完整检查输入而不写输出。默认补全失败回退 SAM 并记录原因;`--fallback-policy error` 要求需补全的可见部件全部有通过结果。可见部件缺 mask、混用不同输入或非法补全坐标会直接报错。
检查 `report.html`、`comparison.png` 和 `assembly.json`;`preview.html` 显示真实骨架,setup 为静态姿态,`check__骨骼ID` 为单关节旋转检查动画。生成过程离线;播放器页面从 CDN 加载 Spine Player,需要网络。`skeleton.json` 配合 `images/` 供编辑器导入,`atlas/` 用于运行时。
部件数量、名称、父子关系均由 v2 计划驱动;隐藏骨骼保留,附件按 bind_to 绑定。第四阶段只复用外部只读 `spine-animation-ai` 的图集和播放器脚本,保留其 PolyForm Noncommercial 约束。完整参数、输入契约和绘制顺序覆盖见[第四阶段文档](docs/4-spine-stage.md)。
## 旧管线与验证
旧版依赖相邻 `spine-animation-ai` 外部项目,使用时遵守其 PolyForm Noncommercial 许可证;不要修改外部依赖。旧版命令会调用图片生成并尝试定位、导出:
```powershell
node .\pipeline.mjs `
--reference $reference `
--out-dir "$runRoot\legacy" `
--genielabs-dir ..\spine-animation-ai `
--image-model qwen-image-3.0 `
--attempts 1 `
--animations idle,walk,wave --animations idle,walk,wave
``` ```
脚本会依次执行: `segment.py` 为旧版拆件分割,`compose_preview.py` 组装预览,`run_case_matrix.py` 对照定位方案;当前 SAM 入口为 sam_segment.py。旧产物检查 assembled.png 和 preview-rigged.html;新管线导出使用独立的第四阶段入口。
1. VectorEngine 图片编辑,生成白底拆件工作图; 测试与命令帮助:
2. OpenCV 连通域拆件,输出透明部件 PNG;
3. 默认调用 Genielabs `position_parts.py`,使用 SIFT/RANSAC、模板匹配回退和 z-order 计算把部件放回参考图;只有显式传入 `--layout-mode anchored` 才启用按画布锚点的保守回退;
4. 生成 Spine 配置并运行 `build_spine_json.py`;
5. 运行 `make_atlas.py` 和 `generate_spine_player.py`。
为避免“部件爆炸”,定位后默认只保留 SIFT 高置信部件和模板匹配分数不低于 `0.3` 的部件,最多保留 18 个;可用 `--min-score`、`--max-parts` 调整。若模型仍输出多套角色,先删除输出目录重新生成,不要直接把低质量拆件导入 Spine。 ```powershell
conda run --no-capture-output -n ai-spine npm test
`matched` 依赖拆件图与参考图保持相同画风和可辨识纹理;低纹理部件会进入模板匹配回退,并进行白底归一化以避免透明像素产生 `NaN/inf`。`anchored` 只按拆件图的行布局做通用兜底,不应作为正式定位结果。 node .\tools\model-check.mjs --help
旧流程的 Python 环境默认探测 `python`、`python3` 和项目 `.venv`;显式 `--python` 时只使用指定环境。要求导入 `cv2`、`PIL`、`numpy`。自动创建虚拟环境时区分 Windows 的 `Scripts/python.exe` 和 Unix 的 `bin/python`。新增的结构理解模式不需要 Python。
脚本默认只读取本目录 `.env` 和当前 shell 环境,不会自动读取 Genarrative 的 secrets 文件。可以通过 `--env-file` 显式指定一个只在受控后端使用的环境文件。
输出目录包含 `generated-parts-sheet.png`、`parts/`、`layout.json`、`assembled.png`、`skeleton.json`、`atlas/`、`preview.html` 和 `manifest.json`。`assembled.png` 是按同一 layout 坐标生成的静态 QA 合成图;自动结果是 region attachments,mesh、weights、IK、FFD 和骨骼语义仍需在 Spine 4.2 中校正。
`preview.html` 现在显示参考图的真实姿态,适合确认形体是否正常;自动拆件结果放在 `preview-rigged.html`。这是因为参考图可能是左右不对称动作,不能用对称锚点强行还原,否则会出现手臂漂浮或错接。
## 离线多 case 回归
`run_case_matrix.py` 可以复用已经生成的多个拆件候选,不调用图片 API,只测试 Genielabs 定位和合成:
```bash
python run_case_matrix.py \
--reference output/demo-character-final/source.png \
--genielabs-dir /absolute/path/spine-animation-ai \
--out-dir output/cases-matrix \
--candidate final output/demo-character-final/parts \
--candidate attempt1 output/demo-character-multi/candidates/attempt-1/parts \
--candidate attempt2 output/demo-character-multi/candidates/attempt-2/parts
``` ```
每个 case 会输出 `layout.json`、`assembled.png` 和 `case-summary.json`,总报告为 `case-report.json`。当前回归中,`final`/`matched` 保留 9 个有效部件,前景 MAE 约 17.68;`attempt1` 只有 7 个部件且出现手臂误拆,MAE 30.28;`attempt2` 有 15 个部件但包含重复/漂浮部件,MAE 27.59。默认候选选择和重叠过滤会优先保留 `final`/`matched` 这类结果。 测试使用本地 mock 或注入响应,不请求真实付费模型。自动化运行任一阶段可用 `conda run --no-capture-output -n ai-spine node ...`。保留旧实验;resume 只复用完整匹配产物,失败任务另建输出目录。
Genielabs 以外部只读目录引用,需遵守其 PolyForm Noncommercial 许可证。生产集成时应把输出上传对象存储,只向前端返回 object key 或 asset object id。
## 外部参考角色
已下载 5 个 Kenney CC0 参考素材到 `references/characters/`,可直接作为下一轮图片拆件的 `--reference` 输入;来源、许可证和 SHA-256 见 [references/index.json](references/index.json),原始包及许可证文件见 `references/external/`。缩略图对比见 [references/characters/contact-sheet.jpg](references/characters/contact-sheet.jpg)。
+285
View File
@@ -0,0 +1,285 @@
"""Prepare bounded completion canvases and merge generated pixels conservatively."""
import argparse
import html
import json
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
from normalize_reference import prepare_rgba
def write_json(file, value):
Path(file).write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding='utf-8')
def read_json(file):
return json.loads(Path(file).read_text(encoding='utf-8-sig'))
def localize(array, origin, size):
"""Copy source coordinates to a padded local canvas, without scaling."""
out = np.zeros((size, size) + array.shape[2:], dtype=array.dtype)
ox, oy = origin
h, w = array.shape[:2]
x0, y0, x1, y1 = max(0, ox), max(0, oy), min(w, ox + size), min(h, oy + size)
if x1 > x0 and y1 > y0:
out[y0-oy:y1-oy, x0-ox:x1-ox] = array[y0:y1, x0:x1]
return out
def allowed_regions(part, plan, masks, visible, radius):
h, w = visible.shape
distance = cv2.distanceTransform((~visible).astype(np.uint8), cv2.DIST_L2, 5)
band = (distance <= radius) & ~visible
regions, warnings = {}, []
flags = part['completion']
if flags['occlusion_fill']:
occluders = np.zeros_like(visible)
for name in part['occluded_by']:
if name in masks:
occluders |= masks[name]
regions['occlusion_fill'] = band & occluders
if not regions['occlusion_fill'].any():
warnings.append('NO_OCCLUSION_REGION')
anchors = {}
if flags['joint_extension_parent']:
anchors['joint_extension_parent'] = [part['pivot_norm']]
if flags['joint_extension_child']:
anchors['joint_extension_child'] = [p['pivot_norm'] for p in plan['parts']
if p['parent'] == part['id'] and p['rig_role'] == 'bone_part']
for kind, points in anchors.items():
disks = np.zeros((h, w), np.uint8)
for x, y in points:
cv2.circle(disks, (min(w-1, round(x*w)), min(h-1, round(y*h))), radius, 1, -1)
regions[kind] = band & (disks > 0)
if not regions[kind].any():
warnings.append(f'NO_{kind.upper()}_REGION')
allowed = np.zeros_like(visible)
for region in regions.values():
allowed |= region
return allowed, regions, warnings
def prepare(directory, selected_ids, size=1024, radius=32):
out = Path(directory)
plan, segmentation = read_json(out/'semantic-parts.json'), read_json(out/'segmentation.json')
source = np.array(Image.open(out/'source.png').convert('RGBA'))
h, w = source.shape[:2]
known = {p['id']: p for p in plan['parts']}
if set(selected_ids) - set(known):
raise ValueError('Unknown requested part ID')
records = {p['id']: p for p in segmentation['parts']}
if len(records) != len(segmentation['parts']) or set(records) - set(known):
raise ValueError('Segmentation IDs do not match semantic plan')
masks = {}
for name in records:
mask = np.array(Image.open(out/'masks'/f'{name}.png').convert('L'))
if mask.shape != (h, w) or not np.isin(mask, [0, 255]).all():
raise ValueError(f'{name}: mask must be binary and match the source canvas')
mask = mask > 0
if not mask.any() or (mask & (source[:, :, 3] == 0)).any():
raise ValueError(f'{name}: empty mask or mask outside original alpha')
masks[name] = mask
tasks, skipped = [], []
for part in plan['parts']:
name = part['id']
if selected_ids and name not in selected_ids:
continue
if not part['needs_completion']:
skipped.append({'id': name, 'reason': 'NO_COMPLETION_NEEDED'})
continue
if name not in masks or part['visibility'] == 'hidden':
skipped.append({'id': name, 'reason': 'NO_VISIBLE_SAM_MASK'})
continue
visible = masks[name]
allowed, regions, warnings = allowed_regions(part, plan, masks, visible, radius)
if not allowed.any():
skipped.append({'id': name, 'reason': 'NO_ALLOWED_EXTENSION', 'warnings': warnings})
continue
ys, xs = np.nonzero(visible | allowed)
bw, bh = int(xs.max()-xs.min()+1), int(ys.max()-ys.min()+1)
if max(bw, bh) + 32 > size:
raise ValueError(f'{name}: completion canvas too small; increase --completion-size (no resizing is performed)')
origin = [int(xs.min()) - (size-bw)//2, int(ys.min()) - (size-bh)//2]
folder = out/'tasks'/name
folder.mkdir(parents=True, exist_ok=True)
cut = source.copy()
cut[~visible] = 0
original = localize(cut, origin, size)
editable = localize(allowed, origin, size)
Image.fromarray(original).save(folder/'visible.png')
white = Image.new('RGBA', (size, size), 'white')
Image.alpha_composite(white, Image.fromarray(original)).convert('RGB').save(folder/'input.png')
context = Image.fromarray(localize(source, origin, size))
Image.alpha_composite(white, context).convert('RGB').save(folder/'context.png')
edit_mask = np.full((size, size, 4), 255, np.uint8)
edit_mask[editable, 3] = 0
Image.fromarray(edit_mask).save(folder/'edit-mask.png')
Image.fromarray(editable.astype(np.uint8)*255).save(folder/'allowed-mask.png')
local_regions, region_bounds = {}, {}
for kind, region in regions.items():
local = localize(region, origin, size)
Image.fromarray(local.astype(np.uint8)*255).save(folder/f'{kind}.png')
local_regions[kind] = int(local.sum())
ry, rx = np.nonzero(local)
region_bounds[kind] = [int(rx.min()), int(ry.min()), int(rx.max())+1, int(ry.max())+1] if len(rx) else None
task = {'id': name, 'semantic': part['semantic'], 'completion': part['completion'],
'origin_xy': origin, 'canvas_size': [size, size], 'source_size': [w, h],
'allowed_pixels': int(editable.sum()), 'regions': local_regions,
'region_bounds_xyxy': region_bounds, 'occluded_by': part['occluded_by'],
'warnings': records[name].get('warnings', []) + warnings,
'semantic_verified': False, 'status': 'prepared'}
write_json(folder/'task.json', task)
tasks.append(task)
result = {'schema_version': 1, 'tasks': tasks, 'skipped': skipped,
'quality_status': 'needs_review', 'canvas_size': size, 'extension_radius': radius}
write_json(out/'completion-plan.json', result)
return result
def merge_candidate(folder, candidate_file, output_file, size_policy='strict'):
if size_policy not in ['strict', 'uniform']:
raise ValueError('Unknown candidate size policy')
folder = Path(folder)
task = read_json(folder/'task.json')
if task.get('profile') == 'adaptive':
from completion_planning import merge_adaptive
return merge_adaptive(folder, candidate_file, output_file, size_policy)
original = np.array(Image.open(folder/'visible.png').convert('RGBA'))
allowed = np.array(Image.open(folder/'allowed-mask.png').convert('L')) > 0
candidate = Image.open(candidate_file).convert('RGBA')
qa = {'accepted': False, 'semantic_verified': False, 'reasons': [],
'expected_size': task['canvas_size'], 'returned_size': list(candidate.size),
'size_policy': size_policy, 'canvas_resampled': False}
if list(candidate.size) != task['canvas_size']:
if size_policy == 'strict':
qa['reasons'].append('CANVAS_SIZE_MISMATCH')
return qa
tw, th = task['canvas_size']
cw, ch = candidate.size
if cw * th != ch * tw:
qa['reasons'].append('CANVAS_ASPECT_RATIO_MISMATCH')
return qa
# Only reverse uniform whole-canvas scaling. Do not translate, stretch,
# crop, or register the object to make a changed pose appear correct.
candidate = candidate.resize((tw, th), Image.Resampling.LANCZOS)
qa.update(canvas_resampled=True, canvas_scale=tw/cw,
resampling='LANCZOS', warnings=['CANDIDATE_CANVAS_RESAMPLED'])
candidate.save(Path(output_file).with_name(Path(output_file).stem + '-normalized.png'))
try:
candidate, preprocessing = prepare_rgba(candidate)
except ValueError as error:
qa['reasons'].append('CANNOT_EXTRACT_FOREGROUND')
qa['detail'] = str(error)
return qa
generated = np.array(candidate)
visible = original[:, :, 3] > 0
opaque = original[:, :, 3] >= 240
if not opaque.any():
qa['reasons'].append('NO_OPAQUE_ALIGNMENT_ANCHOR')
return qa
# Detect gross movement/redesign before restoring original pixels, otherwise
# restoration could hide a shifted generation attached at the wrong place.
white = Image.new('RGBA', candidate.size, 'white')
generated_rgb = np.array(Image.alpha_composite(white, candidate))[:, :, :3].astype(float)
mae = float(np.abs(generated_rgb[opaque] - original[:, :, :3][opaque]).mean())
retained = float((generated[:, :, 3][opaque] >= 240).mean())
proposed = (generated[:, :, 3] > 0) & ~visible
added = proposed & allowed
_, labels = cv2.connectedComponents((visible | added).astype(np.uint8), connectivity=8)
touching = np.unique(labels[visible])
connected = added & np.isin(labels, touching[touching != 0])
region_additions = {kind: int((connected & (np.array(Image.open(folder/f'{kind}.png')) > 0)).sum())
for kind in task['regions']}
missing_regions = [kind for kind, pixels in task['regions'].items() if not pixels or not region_additions[kind]]
qa.update(alignment_mae=mae, visible_retention=retained,
new_pixels=int(connected.sum()), discarded_outside_allowed=int((proposed & ~allowed).sum()),
disconnected_pixels=int((added & ~connected).sum()), region_additions=region_additions,
unfulfilled_regions=missing_regions, preprocessing=preprocessing)
if mae > 30 or retained < .95:
qa['reasons'].append('VISIBLE_ALIGNMENT_MISMATCH')
if not connected.any():
qa['reasons'].append('NO_CONNECTED_NEW_PIXELS')
if qa['reasons']:
return qa
merged = np.zeros_like(original)
merged[connected] = generated[connected]
merged[visible] = original[visible]
qa['visible_pixels_exact'] = bool(np.array_equal(merged[visible], original[visible]))
qa['accepted'] = True
qa['quality_status'] = 'needs_review'
Image.fromarray(merged).save(output_file)
return qa
def finalize(directory):
out = Path(directory)
result = read_json(out/'completion-result.json')
rows = []
for task in result['parts']:
name = task['id']
qa = task.get('qa', {})
if task.get('selected_file'):
im = Image.open(out/task['selected_file']).convert('RGBA')
bbox = im.getbbox()
crop = im.crop(bbox)
(out/'parts').mkdir(exist_ok=True)
crop.save(out/'parts'/f'{name}.png')
meta = read_json(out/'tasks'/name/'task.json')
ox, oy = meta['origin_xy']
task.update(image=f'parts/{name}.png', crop_xyxy=[bbox[0]+ox, bbox[1]+oy, bbox[2]+ox, bbox[3]+oy])
if meta.get('preservation'):
task['preservation'] = meta['preservation']
task['transform'] = meta['transform']
view = f'<img src="{task["image"]}">' if task.get('image') else ''
for index, item in enumerate(task.get('candidates', []), 1):
view += f'<p><a href="{html.escape(item["file"], quote=True)}">原候选 {index}</a> · <a href="tasks/{name}/qa-{index}.json">QA</a>'
if item['qa'].get('canvas_resampled'):
view += f' · <a href="tasks/{name}/merged-{index}-normalized.png">缩放后候选</a>'
view += '</p>'
warnings = html.escape(', '.join(task.get('upstream_warnings', [])))
reference = f'<p><a href="tasks/{name}/input.png">实际请求主图</a> · <a href="tasks/{name}/request-plan.json">发送计划</a></p>'
if (out/'tasks'/name/'reference.png').exists():
reference += f'<a href="tasks/{name}/reference.png">完整角色参考</a>'
rows.append(f'<tr><td>{html.escape(name)}<p>{warnings}</p><a href="tasks/{name}/task.json">任务参数</a> · <a href="tasks/{name}/prompt.txt">提示词</a>{reference}</td><td>{html.escape(task["status"])}</td><td><a href="tasks/{name}/visible.png"><img src="tasks/{name}/visible.png"></a></td><td><a href="tasks/{name}/allowed-mask.png"><img src="tasks/{name}/allowed-mask.png"></a><p><a href="tasks/{name}/context.png">原图局部参考</a></p></td><td>{view}</td><td><pre>{html.escape(json.dumps(qa, ensure_ascii=False, indent=2))}</pre></td></tr>')
write_json(out/'completion-result.json', result)
(out/'report.html').write_text('''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>部件补全</title>
<style>body{font:16px system-ui;margin:24px}td,th{border:1px solid #ccc;padding:8px}img{max-width:260px;max-height:300px;background:#eee}pre{white-space:pre-wrap}table{border-collapse:collapse}</style>
<h1>第三阶段:部件补全</h1><p>输出受允许掩码限制,保留区域逐像素恢复;adaptive 允许明确规划的附件清理、替换和移除,原始 SAM 始终保留。几何通过仅表示候选可供复核,不代表遮挡和关节纹理已正确补全。</p>
<table><tr><th>部件 / 上游告警</th><th>状态</th><th>原可见区域</th><th>允许新增区域(白色)</th><th>补全候选</th><th>几何检查</th></tr>'''+''.join(rows)+'</table><h2>跳过</h2><pre>'+html.escape(json.dumps(result['skipped'], ensure_ascii=False, indent=2))+'</pre></html>', encoding='utf-8')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=['prepare', 'merge', 'finalize'])
parser.add_argument('--out', required=True)
parser.add_argument('--part', action='append', default=[])
parser.add_argument('--size', type=int, default=1024)
parser.add_argument('--radius', type=int)
parser.add_argument('--profile', choices=['legacy', 'adaptive'], default='legacy')
parser.add_argument('--reference-mode', choices=['none', 'local', 'full', 'both', 'board'], default='board')
parser.add_argument('--review-file')
parser.add_argument('--candidate')
parser.add_argument('--merged')
parser.add_argument('--qa')
parser.add_argument('--candidate-size-policy', choices=['strict', 'uniform'], default='strict')
args = parser.parse_args()
if args.action == 'prepare':
if not 128 <= args.size <= 4096 or (args.radius is not None and not 1 <= args.radius <= 256):
parser.error('size must be 128..4096 and radius 1..256')
if args.profile == 'adaptive':
from completion_planning import prepare_adaptive
prepare_adaptive(args.out, args.part, args.size, args.radius, args.reference_mode, args.review_file)
else:
prepare(args.out, args.part, args.size, args.radius or 32)
elif args.action == 'merge':
write_json(args.qa, merge_candidate(args.out, args.candidate, args.merged, args.candidate_size_policy))
else:
finalize(args.out)
if __name__ == '__main__':
main()
+343
View File
@@ -0,0 +1,343 @@
"""Adaptive completion planning, reference canvases and reversible native-pixel merging."""
import hashlib
import json
import math
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageDraw
from normalize_reference import prepare_rgba
def read(file):
return json.loads(Path(file).read_text(encoding='utf-8-sig'))
def write(file, value):
Path(file).write_text(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False), encoding='utf-8')
def binary(file, shape):
a = np.array(Image.open(file))
if a.shape != shape or not np.isin(a, [0, 255]).all():
raise ValueError(f'Expected full-canvas binary PNG: {file}')
return a > 0
def review_inputs(file, ids, shape):
"""Review masks are explicitly authored data; never guess ownership from scores."""
if not file:
return {}, {}
file = Path(file).resolve()
data = read(file)
if data.get('schema_version') != 1 or not isinstance(data.get('parts'), dict):
raise ValueError('Completion review requires schema_version=1 and parts object')
hashes = {'review.json': hashlib.sha256(file.read_bytes()).hexdigest()}
result = {}
for name, entry in data['parts'].items():
if name not in ids or not isinstance(entry, dict) or entry.get('confirmed') is not True:
raise ValueError(f'Review entry requires known ID and confirmed=true: {name}')
if set(entry) - {'confirmed', 'extension_radius', 'repair_mask', 'replace_mask', 'remove_mask'}:
raise ValueError(f'Unknown review fields: {name}')
item = {'confirmed': True}
if 'extension_radius' in entry:
radius = entry['extension_radius']
if type(radius) is not int or not 1 <= radius <= 256:
raise ValueError(f'Invalid reviewed radius: {name}')
item['extension_radius'] = radius
for key in ['repair_mask', 'replace_mask', 'remove_mask']:
if key not in entry:
continue
ref = entry[key]
if not isinstance(ref, str) or Path(ref).is_absolute():
raise ValueError('Review mask must be relative to review file')
mask_file = (file.parent / ref).resolve()
if not mask_file.is_relative_to(file.parent):
raise ValueError('Review mask path escapes review directory')
item[key] = binary(mask_file, shape)
hashes[f'{name}/{key}'] = hashlib.sha256(mask_file.read_bytes()).hexdigest()
result[name] = item
return result, hashes
def bounding(mask):
ys, xs = np.nonzero(mask)
return [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1] if len(xs) else None
def enclosed(mask):
padded = np.pad(mask.astype(np.uint8), 1)
fill = padded.copy()
cv2.floodFill(fill, None, (0, 0), 1)
return ~fill[1:-1, 1:-1].astype(bool)
def white_image(array):
im = Image.fromarray(array).convert('RGBA')
return Image.alpha_composite(Image.new('RGBA', im.size, 'white'), im).convert('RGB')
def prepare_adaptive(directory, selected_ids, size=1024, radius=None, reference_mode='board', review_file=None):
# Import shared native coordinate helpers lazily to avoid a module cycle.
from completion_geometry import localize, allowed_regions
out = Path(directory)
plan, seg = read(out / 'semantic-parts.json'), read(out / 'segmentation.json')
rgba = np.array(Image.open(out / 'source.png').convert('RGBA'))
h, w = rgba.shape[:2]
ids = {p['id']: p for p in plan['parts']}
if set(selected_ids) - set(ids):
raise ValueError('Unknown requested part ID')
records = {p['id']: p for p in seg['parts']}
if len(records) != len(seg['parts']) or set(records) - set(ids):
raise ValueError('Invalid segmentation IDs')
masks = {name: binary(out / 'masks' / f'{name}.png', (h, w)) for name in records}
for name, mask in masks.items():
if not mask.any() or np.any(mask & (rgba[:, :, 3] == 0)):
raise ValueError(f'Empty mask or mask outside original alpha: {name}')
reviews, review_hashes = review_inputs(review_file, ids, (h, w))
coverage = np.zeros((h, w), np.uint16)
for mask in masks.values():
coverage += mask
tasks, skipped, assessments = [], [], []
(out / 'preservation').mkdir(exist_ok=True)
for part in plan['parts']:
name = part['id']
if selected_ids and name not in selected_ids:
continue
if name not in masks or part['visibility'] == 'hidden':
skipped.append({'id': name, 'reason': 'NO_VISIBLE_SAM_MASK'})
continue
visible = masks[name]
box = bounding(visible)
review = reviews.get(name, {})
resolved_radius = review.get('extension_radius', radius if radius is not None else
int(np.clip(math.ceil(min(box[2]-box[0], box[3]-box[1]) * .15), 4, 96)))
inferred = dict(part)
inferred['completion'] = {
'occlusion_fill': bool(part['occluded_by']),
'joint_extension_parent': part['rig_role'] == 'bone_part' and part['parent'] is not None,
'joint_extension_child': part['rig_role'] == 'bone_part' and any(
p['parent'] == name and p['rig_role'] == 'bone_part' for p in plan['parts'])}
_, regions, warnings = allowed_regions(inferred, plan, masks, visible, resolved_radius)
# Only enclosed source foreground without another mask owner is an automatic
# repair candidate. Open gaps and ambiguous ownership stay diagnostic.
gap = enclosed(visible) & (rgba[:, :, 3] > 0) & (coverage == 0)
cleanup = np.zeros_like(visible)
for front in part['occluded_by']:
p = ids[front]
if front in masks and p['rig_role'] != 'bone_part' and p['bind_to'] == name:
cleanup |= visible & masks[front]
replace = review.get('replace_mask', np.zeros_like(visible))
removed = review.get('remove_mask', np.zeros_like(visible))
repair = review.get('repair_mask', np.zeros_like(visible))
if np.any((replace | removed) & ~visible) or np.any(repair & visible) or np.any(replace & removed):
raise ValueError(f'{name}: replacement/removal must be disjoint subsets of SAM; repair must be outside SAM')
regions['segmentation_gap'] = gap | repair
regions['attachment_cleanup'] = cleanup & ~removed
regions['reviewed_replacement'] = replace
allowed = np.zeros_like(visible)
for region in regions.values():
allowed |= region
allowed &= ~removed
protected = visible & ~allowed & ~removed
unexpected = []
duplicates = []
for other, mask in masks.items():
if other == name:
continue
overlap = int((mask & visible).sum())
related = other in part['occluded_by'] or name in ids[other]['occluded_by']
if overlap and not related:
unexpected.append({'id': other, 'pixels': overlap})
if overlap / max(1, int((mask | visible).sum())) >= .8:
duplicates.append(other)
assessment = {'id': name, 'original_needs_completion': part['needs_completion'],
'inferred_completion': inferred['completion'], 'radius': resolved_radius,
'region_pixels': {k: int(v.sum()) for k, v in regions.items()},
'protected_pixels': int(protected.sum()), 'removed_pixels': int(removed.sum()),
'unexpected_overlaps': unexpected, 'review_confirmed': bool(review)}
assessments.append(assessment)
if duplicates and not review:
skipped.append({'id': name, 'reason': 'AMBIGUOUS_MASK_REQUIRES_REVIEW', 'overlaps': duplicates})
continue
if not protected.any() or not (protected & (rgba[:, :, 3] >= 240)).any():
skipped.append({'id': name, 'reason': 'NO_PRESERVED_ALIGNMENT_ANCHOR'})
continue
if not allowed.any() and not removed.any():
skipped.append({'id': name, 'reason': 'NO_OBSERVED_EDIT_REGION'})
continue
region_box = bounding(visible | allowed)
bw, bh = region_box[2] - region_box[0], region_box[3] - region_box[1]
native_size = max(8, math.ceil(max(bw, bh) * 1.15))
origin = [region_box[0] - (native_size-bw)//2, region_box[1] - (native_size-bh)//2]
work_size = int(size * .68) if reference_mode == 'board' else size
offset = [int(size * .02), (size-work_size)//2] if reference_mode == 'board' else [0, 0]
scale = work_size / native_size
folder = out / 'tasks' / name
folder.mkdir(parents=True, exist_ok=True)
native_source = localize(rgba, origin, native_size)
native_visible = localize(visible, origin, native_size)
native_keep = localize(protected, origin, native_size)
native_edit = localize(allowed, origin, native_size)
native_remove = localize(removed, origin, native_size)
cut = native_source.copy()
cut[~native_keep] = 0
Image.fromarray(cut).save(folder / 'native-visible.png')
Image.fromarray(native_source).save(folder / 'native-source.png')
for label, mask in [('keep', native_keep), ('allowed', native_edit), ('remove', native_remove), ('sam', native_visible)]:
Image.fromarray(mask.astype(np.uint8)*255).save(folder / f'native-{label}.png')
def project(array, is_mask=False):
small = np.array(Image.fromarray(array.astype(np.uint8)*255 if is_mask else array).resize(
(work_size, work_size), Image.Resampling.NEAREST if is_mask else Image.Resampling.LANCZOS))
canvas = np.zeros((size, size) + small.shape[2:], np.uint8)
canvas[offset[1]:offset[1]+work_size, offset[0]:offset[0]+work_size] = small
return canvas
model_visible = project(cut)
Image.fromarray(model_visible).save(folder / 'visible.png')
model_allowed = project(native_edit, True) > 0
model_remove = project(native_remove, True) > 0
Image.fromarray(model_allowed.astype(np.uint8)*255).save(folder / 'allowed-mask.png')
mask_rgba = np.full((size, size, 4), 255, np.uint8)
mask_rgba[model_allowed | model_remove, 3] = 0
Image.fromarray(mask_rgba).save(folder / 'edit-mask.png')
context = white_image(project(native_source))
context.save(folder / 'context.png')
full_reference = white_image(rgba)
full_reference.thumbnail((size, size), Image.Resampling.LANCZOS)
full_reference.save(folder / 'reference.png')
model_input = white_image(model_visible)
panel_boxes = []
if reference_mode == 'board':
draw = ImageDraw.Draw(model_input)
x = int(size * .74)
draw.line([(x-4, 0), (x-4, size)], fill='#777777', width=1)
for label, image, top in [('LOCAL REFERENCE', white_image(native_source), 0),
('FULL REFERENCE', full_reference.copy(), size//2)]:
draw.text((x, top+2), label, fill='black')
image.thumbnail((size-x-4, size//2-24), Image.Resampling.LANCZOS)
model_input.paste(image, (x, top+22))
panel_boxes.append([x, top+22, x+image.width, top+22+image.height])
model_input.save(folder / 'input.png')
bounds, native_counts = {}, {}
for kind, region in regions.items():
native = localize(region & allowed, origin, native_size)
model = project(native, True)
Image.fromarray(native.astype(np.uint8)*255).save(folder / f'native-{kind}.png')
Image.fromarray(model).save(folder / f'{kind}.png')
bounds[kind] = bounding(model > 0)
native_counts[kind] = int(native.sum())
preservation = {}
for key, mask in [('preserve_mask', protected), ('editable_mask', allowed), ('removed_mask', removed)]:
relative = f'preservation/{name}-{key}.png'
Image.fromarray(mask.astype(np.uint8)*255).save(out / relative)
preservation[key] = relative
if unexpected:
warnings.append('UNEXPECTED_OVERLAP_REVIEW_REQUIRED')
if cleanup.any():
warnings.append('ATTACHMENT_BASE_CLEANUP_NEEDS_REVIEW')
task = {'id': name, 'semantic': part['semantic'], 'profile': 'adaptive',
'completion': inferred['completion'], 'original_completion': part['completion'],
'origin_xy': origin, 'native_size': native_size, 'canvas_size': [size, size], 'source_size': [w, h],
'extension_radius': resolved_radius, 'allowed_pixels': int(native_edit.sum()),
'removed_pixels': int(native_remove.sum()), 'regions': native_counts,
'region_bounds_xyxy': bounds, 'occluded_by': part['occluded_by'],
'warnings': records[name].get('warnings', []) + warnings, 'preservation': preservation,
'reference_mode': reference_mode, 'reference_panel_boxes': panel_boxes,
'transform': {'scale': scale, 'working_xyxy': [*offset, offset[0]+work_size, offset[1]+work_size],
'source_to_canvas': [scale, 0, offset[0]-scale*origin[0]+(scale-1)/2,
0, scale, offset[1]-scale*origin[1]+(scale-1)/2],
'canvas_to_source': [1/scale, 0, origin[0]-offset[0]/scale+(1/scale-1)/2,
0, 1/scale, origin[1]-offset[1]/scale+(1/scale-1)/2]},
'semantic_verified': False, 'status': 'prepared'}
write(folder / 'task.json', task)
tasks.append(task)
result = {'schema_version': 2, 'profile': 'adaptive', 'tasks': tasks, 'skipped': skipped,
'assessments': assessments, 'review_hashes': review_hashes, 'reference_mode': reference_mode,
'canvas_size': size, 'quality_status': 'needs_review'}
write(out / 'completion-plan.json', result)
return result
def merge_adaptive(folder, candidate_file, output_file, size_policy):
folder = Path(folder)
task = read(folder / 'task.json')
candidate = Image.open(candidate_file).convert('RGBA')
qa = {'accepted': False, 'semantic_verified': False, 'reasons': [], 'profile': 'adaptive',
'expected_size': task['canvas_size'], 'returned_size': list(candidate.size),
'size_policy': size_policy, 'canvas_resampled': False, 'transform': task['transform']}
tw, th = task['canvas_size']
if candidate.size != (tw, th):
if size_policy == 'strict' or candidate.width * th != candidate.height * tw:
qa['reasons'].append('CANVAS_SIZE_MISMATCH' if size_policy == 'strict' else 'CANVAS_ASPECT_RATIO_MISMATCH')
return qa
qa.update(canvas_resampled=True, canvas_scale=tw/candidate.width, resampling='LANCZOS')
candidate = candidate.resize((tw, th), Image.Resampling.LANCZOS)
candidate.save(Path(output_file).with_name(Path(output_file).stem + '-normalized.png'))
# Record raw behavior before masking/restoration; an accepted HTTP response is
# not evidence that the relay/model respected the supplied edit mask.
expected_input = np.array(Image.open(folder / 'input.png').convert('RGB')).astype(float)
raw = np.array(white_image(np.array(candidate))).astype(float)
editable = np.array(Image.open(folder / 'edit-mask.png'))[:, :, 3] == 0
outside_delta = np.abs(raw - expected_input).mean(axis=2)
qa['raw_outside_edit_mae'] = float(outside_delta[~editable].mean()) if (~editable).any() else 0
qa['raw_outside_edit_changed_pixels'] = int(((outside_delta > 30) & ~editable).sum())
rect = task['transform']['working_xyxy']
try:
cropped, preprocessing = prepare_rgba(candidate.crop(rect))
except ValueError as error:
qa.update(reasons=['CANNOT_EXTRACT_FOREGROUND'], detail=str(error))
return qa
side = task['native_size']
generated = np.array(cropped.resize((side, side), Image.Resampling.LANCZOS))
original = np.array(Image.open(folder / 'native-visible.png').convert('RGBA'))
source = np.array(Image.open(folder / 'native-source.png').convert('RGBA'))
keep = binary(folder / 'native-keep.png', (side, side))
allowed = binary(folder / 'native-allowed.png', (side, side))
removed = binary(folder / 'native-remove.png', (side, side))
# Judge alignment before the inverse resampling. Alpha at native boundary
# pixels changes during interpolation even for an exactly aligned candidate.
model_original = np.array(Image.open(folder / 'visible.png').convert('RGBA').crop(rect))
model_generated = np.array(cropped)
opaque = model_original[:, :, 3] >= 240
if not opaque.any():
qa['reasons'].append('NO_OPAQUE_ALIGNMENT_ANCHOR')
return qa
rgb = np.array(white_image(model_generated)).astype(float)
mae = float(np.abs(rgb[opaque] - model_original[:, :, :3][opaque]).mean())
retained = float((model_generated[:, :, 3][opaque] >= 240).mean())
proposed = (generated[:, :, 3] > 0) & ~keep
additions = proposed & allowed
_, labels = cv2.connectedComponents((keep | additions).astype(np.uint8), connectivity=8)
touching = np.unique(labels[keep])
connected = additions & np.isin(labels, touching[touching != 0])
counts = {kind: int((connected & binary(folder / f'native-{kind}.png', (side, side))).sum())
for kind in task['regions']}
qa.update(alignment_mae=mae, visible_retention=retained, alignment_space='request_work_panel', new_pixels=int(connected.sum()),
removed_pixels=int(removed.sum()), region_additions=counts,
unfulfilled_regions=[k for k, n in task['regions'].items() if n and not counts[k]],
discarded_outside_allowed=int((proposed & ~allowed).sum()), preprocessing=preprocessing)
if mae > 30 or retained < .95:
qa['reasons'].append('VISIBLE_ALIGNMENT_MISMATCH')
if not connected.any() and not removed.any():
qa['reasons'].append('NO_CONNECTED_NEW_PIXELS')
for kind in ['attachment_cleanup', 'reviewed_replacement']:
if task['regions'][kind] and counts[kind] / task['regions'][kind] < .95:
qa['reasons'].append(f'INCOMPLETE_{kind.upper()}')
if qa['reasons']:
return qa
merged = np.zeros_like(original)
merged[connected] = generated[connected]
# Visible segmentation gaps already have ground-truth texture in source.
# Restore it where the proposed fill connects, rather than trusting a repaint.
repair = binary(folder / 'native-segmentation_gap.png', (side, side)) & connected & (source[:, :, 3] > 0)
merged[repair] = source[repair]
merged[keep] = original[keep]
merged[removed] = 0
qa.update(accepted=True, quality_status='needs_review', visible_pixels_exact=True,
preservation_scope='preserve_mask', repaired_source_pixels=int(repair.sum()))
Image.fromarray(merged).save(output_file)
return qa
+1 -1
View File
@@ -39,7 +39,7 @@
"segmentation": { "segmentation": {
"provider": "sam-local", "provider": "sam-local",
"model_dir_env": "SAM_MODEL_DIR", "model_dir_env": "SAM_MODEL_DIR",
"default_model_dir": "F:/Study/sam3.1" "default_model_dir": "F:/Study/SAM3"
} }
} }
} }
+1 -1
View File
@@ -39,7 +39,7 @@
"segmentation": { "segmentation": {
"provider": "sam-local", "provider": "sam-local",
"model_dir_env": "SAM_MODEL_DIR", "model_dir_env": "SAM_MODEL_DIR",
"default_model_dir": "F:/Study/sam3.1" "default_model_dir": "F:/Study/SAM3"
} }
} }
} }
+45
View File
@@ -0,0 +1,45 @@
{
"schema_version": 1,
"providers": {
"tiantoken-vision": {
"protocol": "responses",
"base_url_env": "TIANTOKEN_BASE_URL",
"api_key_env": "TIANTOKEN_API_KEY",
"timeout_ms": 180000,
"max_retries": 2
},
"tiantoken-image-edit": {
"protocol": "images-edits",
"base_url_env": "TIANTOKEN_BASE_URL",
"api_key_env": "TIANTOKEN_API_KEY",
"timeout_ms": 1200000,
"max_retries": 2,
"capabilities": {"multiple_images": false, "mask": false},
"image_field": "image",
"mask_editable": "transparent"
}
},
"stages": {
"understanding": {
"provider": "tiantoken-vision",
"model_env": "TIANTOKEN_UNDERSTANDING_MODEL",
"default_model": "gpt-6-astra",
"prompt": "../prompts/understand-v1.txt",
"output_mode": "text-json",
"stream": true,
"params": {"store": false}
},
"completion": {
"provider": "tiantoken-image-edit",
"model_env": "TIANTOKEN_COMPLETION_MODEL",
"default_model": "gpt-image-2",
"prompt": "../prompts/complete-v1.txt",
"params": {"n": 1}
},
"segmentation": {
"provider": "sam-local",
"model_dir_env": "SAM_MODEL_DIR",
"default_model_dir": "F:/Study/SAM3"
}
}
}
+147
View File
@@ -0,0 +1,147 @@
# 第一阶段:视觉 LLM 角色结构理解
## 1. 阶段简介
将单张角色图转换为部件语义计划:确定部件名称、骨骼与附件分类、运动层级、遮挡、可见区域粗框、关节点和补全需求。视觉模型负责结构理解;本地程序负责格式校验和前景候选点生成。
本阶段不生成精确 mask、透明拆件、隐藏纹理或 Spine 文件。结构合法不等于识别正确,结果仍为 `quality_status=needs_review`。
## 2. 输入、输出与执行链路
输入为 PNG 角色图;在线模式需要模型配置与密钥,离线模式需要已有语义计划。核心输出为语义计划和 SAM 提示。
```text
tools/model-check.mjs(或 pipeline.mjs --mode annotated-parts)
→ 读取配置、图片、提示词与 Schema,计算实验指纹
→ Python 保留 Alpha / 去除边界连通白底,保持原画布
→ 在线:视觉模型理解;离线:读取计划并按需迁移 v1
→ 提取 JSON,校验字段、引用、层级和补全一致性
→ 在 Alpha 前景与粗框交集中生成距离变换候选点
→ 保存 semantic-parts.json、segmentation-prompts.json、诊断及报告
→ 第二阶段读取 source.png + 计划 + SAM 提示
```
`--parts-file` 不请求模型,也不运行 SAM。`--stop-after segment` 可接续第二阶段,输出到理解目录的 `segmentation/`;第三阶段独立调用。
## 3. 阶段执行命令示例及参数说明
以下 PowerShell 命令在项目根目录执行,环境准备见 [README](../README.md)。反引号必须在行尾,后面不能有空格。
```powershell
conda activate ai-spine
$runId = Get-Date -Format "yyyyMMdd-HHmmss"
node .\tools\model-check.mjs --stage understanding `
--config .\configs\deepseek-qwen.json `
--reference .\output\20260918-183532\sam\source.png `
--out-dir ".\output\$runId\understand"
```
参考图可替换成自己的 PNG。切换 Tiantoken:将配置改为 `--config .\configs\tiantoken.json`,默认理解模型为 `gpt-6-astra`,也可显式传入 `--understanding-model gpt-6-astra`。
已有计划离线生成新候选,不重跑 AI:
```powershell
node .\tools\model-check.mjs --stage understanding `
--reference .\output\20260918-183532\sam\source.png `
--parts-file .\output\20260918-183532\sam\semantic-parts.json `
--out-dir ".\output\$runId\understand-offline"
```
| 参数 | 说明 |
| --- | --- |
| `--stage understanding` | 第一阶段,也是入口默认阶段 |
| `--reference FILE` | 必需,角色 PNG |
| `--out-dir DIR` | 实际执行必需,使用独立新目录 |
| `--config FILE` | JSON 配置;默认 `configs/astra-qwen.json` |
| `--env-file FILE` | 默认项目 `.env`;shell 同名变量优先 |
| `--understanding-model ID` | 原样发送模型 ID,不转换别名 |
| `--understanding-protocol NAME` | `chat-completions` 或 `responses` |
| `--stream true/false` | 覆盖配置流式设置 |
| `--output-mode NAME` | `text-json`、`json-object`、`json-schema`;需确认中转能力 |
| `--parts-file FILE` | 离线读取有效 v2 或保守迁移 v1 |
| `--python FILE` | 仅允许 Conda ai-spine 的解释器 |
| `--stop-after understand/segment` | 理解结束或接续 SAM |
| `--dry-run` | 检查配置与图片格式,不生成产物;不代表完整 Python 几何链路已验证 |
| `--resume` | 仅复用已完成且指纹、产物哈希一致的结果,不是失败请求续发 |
| `--list-models` | 联网查询所选提供方模型列表,不能与 dry-run 同用 |
模型优先级:CLI > `stage.model` > 配置指定的模型环境变量 > `stage.default_model`。提示词路径相对于配置文件。DeepSeek 预设为 Chat Completions 非流式;两个 Astra 预设为 Responses 流式。配置不代表中转所有能力均已验证。
## 4. 输入格式要求
参考图必须是有效 PNG,建议单角色、透明背景、完整画布。透明图保留 Alpha;不透明白底只去除与边界连通、RGB 各通道均 ≥240 的区域。复杂背景需先抠图,空前景会失败;白色主体接触边界可能被误删。
离线计划采用 [v2 Schema](../schemas/semantic-parts.schema.json):顶层 `schema_version=2`、`coordinate_space=normalized_xy_top_left`、`parts` 数组和 `notes`。实际部件数量为 1~64。
| 部件字段 | 格式与含义 |
| --- | --- |
| `id` / `semantic` | 唯一小写 ASCII ID / 语义说明 |
| `parent` | 父骨骼 ID 或 null;运动关系 |
| `rig_role` | `bone_part`、`attachment`、`facial_feature` |
| `bind_to` | 附件绑定的骨骼 ID 或 null(root);骨骼自身为 null |
| `visibility` | `visible`、`partial`、`hidden` |
| `bbox_norm_xyxy` | `[xmin,ymin,xmax,ymax]` 可见粗框;隐藏为 null |
| `pivot_norm` | `[x,y]` 关节或连接点,可处于遮挡区域 |
| `positive_points_norm` / `negative_points_norm` | 旧模型点兼容字段,新提示词要求留空 |
| `occluded_by` | 遮挡者 ID 数组,与运动关系分开 |
| `completion` | `occlusion_fill`、`joint_extension_parent`、`joint_extension_child` 三个布尔值 |
| `needs_completion` | 三项 completion 的逻辑或 |
| `confidence` | 0~1 自报置信度;说明与不确定性写入顶层 notes,部件内没有 notes 字段 |
坐标以完整画布左上角为原点,归一化到 0~1。可见框须为正面积;partial/hidden 必须声明遮挡补全,hidden 无可见框和正点。附件及面部特征不能声明独立骨骼关节延展。引用必须存在,不能自引用或形成父子/遮挡环路,绑定目标必须是骨骼。完整类型与必填项以 Schema 为准。
v1 迁移依据 ID 推断角色分类和补全需求,推断写入 notes,不重新识别图片、不覆盖旧文件。计划必须对应同一张图、同一画布。
## 5. 执行链路中重要部分代码文件及原理说明(包括重点说明)
**请求和结构校验。** `stages/model-stage.mjs` 调度;`providers/vision.mjs` 将图片 Data URL、画布尺寸、提示词与 Schema 转成对应协议,再提取 JSON,拒绝 refusal、异常结束和未完成响应。`lib/validate-parts.mjs` 校验字段、引用、层级和补全一致性,成功后写正式计划。低置信度等产生告警,不能据结构校验认定语义正确。
**候选点并非重新由 AI 生成。** `geometry_grounding.py` 对粗框与 `alpha>0` 的交集计算距离变换,选取远离边界的像素中心;无有效前景则 blocked。归一化点转像素采用 floor,1 映射到最后一个像素;粗框按 floor/ceil 转半开范围。候选仍可能属于框内的其他部件。
**pivot 与种子独立。** 关节点不吸附到前景,模型原始正负点仅作诊断。第二阶段当前只用程序种子排序返回的 mask,未把它们作为 SAM 点提示。
**通信、可视化与留档。** `providers/http.mjs` 负责 HTTP、SSE、超时和脱敏;明确的 429/5xx 有限重试,超时/网络中断不自动重复请求。`lib/report.mjs` 用红/黄圆区分原模型点、青色方块表示程序候选、空心圆表示 pivot。旧输出不会随代码修改自动更新。`lib/run-artifacts.mjs` 按输入、模型、参数、提示词、Schema、代码和产物哈希复用结果;completed 仅表示执行完成。
## 6. 各个代码文件职责索引
| 文件 | 职责 |
| --- | --- |
| [pipeline.mjs](../pipeline.mjs) / [tools/model-check.mjs](../tools/model-check.mjs) | 新管线转发 / CLI、模型列表和阶段分发 |
| [stages/model-stage.mjs](../stages/model-stage.mjs) | 理解总调度与留档;另含图片编辑探针 |
| [lib/config.mjs](../lib/config.mjs) / [lib/images.mjs](../lib/images.mjs) | 配置解析 / 图片格式、尺寸、哈希和 Data URL |
| [lib/python-runtime.mjs](../lib/python-runtime.mjs) / [lib/geometry.mjs](../lib/geometry.mjs) | Conda 检查 / Python 几何调用 |
| [normalize_reference.py](../normalize_reference.py) / [geometry_grounding.py](../geometry_grounding.py) | Alpha 与去白底 / ROI 诊断和候选生成 |
| [providers/vision.mjs](../providers/vision.mjs) / [providers/http.mjs](../providers/http.mjs) | 视觉协议 / 通信与脱敏 |
| [lib/validate-parts.mjs](../lib/validate-parts.mjs) | 解析、Schema/关系校验、v1 迁移 |
| [lib/report.mjs](../lib/report.mjs) / [lib/run-artifacts.mjs](../lib/run-artifacts.mjs) | SVG/HTML / 状态、指纹与缓存 |
| [prompts/understand-v1.txt](../prompts/understand-v1.txt) | 理解提示词;文件名保留 v1,当前使用 v2 契约 |
| [v2 Schema](../schemas/semantic-parts.schema.json) / [v1 Schema](../schemas/semantic-parts-v1.schema.json) | 正式契约 / 兼容输入 |
| [DeepSeek](../configs/deepseek-qwen.json)、[Astra](../configs/astra-qwen.json)、[Tiantoken](../configs/tiantoken.json) 配置 | 模型、协议和环境变量预设 |
| [tests/model-stages.test.mjs](../tests/model-stages.test.mjs) / [tests/test_geometry.py](../tests/test_geometry.py) | 协议、计划、留档及几何回归 |
## 7. 产物详细说明
| 路径 | 内容与用途 |
| --- | --- |
| `source-original.png` | 原始输入副本 |
| `source.png` | 预处理 RGBA,在线模型所见图片,后续坐标基准 |
| `preprocessing.json` | 背景处理方式与像素统计 |
| `semantic-parts.json` / `validation.json` | 正式 v2 计划 / 字段、关系校验及告警 |
| `geometry-validation.json` | 原点诊断、ROI 前景比例、连通域、候选与阻塞原因 |
| `segmentation-prompts.json` | 画布、部件 ID、candidate/hidden/blocked 状态、粗框、程序种子和 QA 要求 |
| `annotated.svg` / `report.html` | 粗标注 / 综合复核页;不是 SAM 分割图 |
| `prompt.txt` / `request.json` | 阶段提示词副本 / 请求身份摘要,不是完整 HTTP body |
| `response.raw.json` / `response.txt` | 响应留档 / 提取文本;离线模式使用本地计划记录 |
| `request-metadata.json` | 请求与返回模型、request ID、usage、耗时、次数和协议元数据 |
| `response.error.json` | 可获取时保存的脱敏请求失败诊断 |
| `manifest.json` | 模式、状态、输入/代码/产物哈希;SAM 路径仅记录,不加载模型 |
失败可能只有部分产物。第二阶段前应复核左右、粗框、层级、遮挡、分类和补全需求,不能只看点是否落在前景。
## 8. 当前阶段可改进点说明
- 明确角色左右与画面左右命名约定,提升粗框、遮挡及隐藏状态判断,减少 SAM 衣料/肢体混淆。
- 根据预期动画审查拆分粒度与附件绑定,区分隐藏骨骼占位项和真正可见拆件。
- 增加可审阅的计划修正流程,修正后重新生成几何提示;Alpha 候选不能代替语义复核。
- 第二阶段后根据实际 mask 重新评估补全需求,不能因第一阶段 needs_completion=false 永久跳过有缺损的拆件。
- 增加结构和像素证据一致性检查,区分“不确定”“结构合法”“人工确认正确”。
+156
View File
@@ -0,0 +1,156 @@
# 第二阶段:本地 SAM 分割
## 1. 阶段简介
读取第一阶段语义计划,用本地 SAM 3.1 图像检测分支生成部件 mask,再从原图提取可见像素。当前输出半透明颜色叠加的角色图、白底拆解图和透明部件。
SAM 在 Conda `ai-spine` 的临时 GPU 子进程中运行,结束后退出;不部署常驻服务、不下载权重、不调用中转站、不自动回退 CPU。本阶段尚无大模型去毛边步骤,也不补画隐藏纹理。
## 2. 输入、输出与执行链路
输入为第一阶段目录,加本地 SAM 代码和权重;输出为可见部件 mask、原像素拆件、诊断和预览。
```text
tools/model-check.mjs --stage segmentation
→ stages/segmentation-stage.mjs 检查输入、路径、参数与 Conda
→ 临时探测子进程检查依赖和 CUDA,不加载权重
→ 计算输入、代码、权重指纹,复制输入到新输出目录
→ 临时推理子进程:sam_segment.py → sam_runtime.py
→ 严格加载检测分支,一次编码原图
→ 每部件分别取得文本、文本+粗框候选,排序选 mask
→ 与原图 Alpha 取交集,提取原像素并渲染报告
→ 子进程退出,manifest 记录产物与退出状态
→ 第三阶段读取 source.png、计划、segmentation.json 和 masks/
```
`hidden/blocked` 不进入推理;没有合格候选会记录跳过原因,不用矩形或种子伪造 mask。所有输出仍为 `semantic_verified=false`、`quality_status=needs_review`。
## 3. 阶段执行命令示例及参数说明
在项目根目录执行,环境安装见 [README](../README.md)。以下独立命令复用已有计划,无需模型密钥:
```powershell
conda activate ai-spine
$runId = Get-Date -Format "yyyyMMdd-HHmmss"
node .\tools\model-check.mjs --stage segmentation `
--input-dir .\output\sombrero-grounding-v2 `
--out-dir ".\output\$runId\sam" `
--sam-dir F:/Study/SAM3
```
将 `--input-dir` 替换为本次第一阶段的 `understand` 目录,即可接续新实验。自动化可以使用 `conda run --no-capture-output -n ai-spine node ...`。
仅探测环境与输入,不进行模型推理:
```powershell
conda run --no-capture-output -n ai-spine node .\tools\model-check.mjs `
--stage segmentation `
--input-dir .\output\sombrero-grounding-v2 `
--sam-dir F:/Study/SAM3 `
--dry-run
```
| 参数 | 说明 |
| --- | --- |
| `--stage segmentation` | 独立运行第二阶段 |
| `--input-dir DIR` | 必需,第一阶段输出目录 |
| `--out-dir DIR` | 推理必需,使用独立新目录;dry-run 可省略 |
| `--sam-dir DIR` | SAM 根目录,优先于 SAM_MODEL_DIR 和配置默认值 |
| `--sam-code-dir DIR` | 源码仓库,优先于 SAM_CODE_DIR 和根目录下 sam3 |
| `--sam-checkpoint FILE` | 显式本地权重,覆盖根目录自动查找 |
| `--sam-confidence FLOAT` | 候选模型分数阈值,默认 0.2,严格介于 0 和 1;不是语义准确率 |
| `--sam-precision NAME` | 默认 bf16,可选 fp32;后者显存占用更高 |
| `--config FILE` / `--env-file FILE` | 配置与环境变量文件,默认 astra-qwen.json / 项目 .env |
| `--python FILE` | 仅允许 ai-spine 的解释器 |
| `--dry-run` | 检查路径、Conda、依赖导入和 CUDA;不校验完整权重加载或推理显存 |
| `--resume` | 输入、参数、代码、权重和完整产物一致时复用;失败任务另建目录 |
也可在第一阶段命令上使用 `--stop-after segment --sam-dir F:/Study/SAM3`,输出位于理解目录的 `segmentation/`。该方式会先执行理解阶段,可能调用付费模型。
## 4. 输入格式要求
| 输入 | 要求 |
| --- | --- |
| `source.png` | 第一阶段预处理 RGBA,保留完整原画布 |
| `semantic-parts.json` | 通过结构校验的 v2 计划,旧版须先离线迁移 |
| `segmentation-prompts.json` | 宽高匹配 source;部件 ID 与计划完整一致 |
| candidate 提示 | 非隐藏部件,seed_source 为 alpha_roi_distance_transform;整数粗框在画布内;至少一个整数种子在粗框与 Alpha 前景内 |
| SAM 代码 | 源码仓库内部必须有 sam3/model_builder.py |
| SAM 权重 | 本地 sam3.1_multiplex.pt,与本项目适配的检测分支结构匹配 |
当前本机布局:
```text
F:/Study/SAM3/
sam3/
sam3/model_builder.py
sam3.1checkpoint/
sam3.1_multiplex.pt
```
权重默认先查找上述子目录,再查找 SAM 根目录下同名文件。当前已验证环境为 Python 3.11、PyTorch 2.7.1+cu126、torchvision 0.22.1、NumPy 1.26.4、OpenCV 4.11.0.86,RTX 3060 Laptop 6 GB。这是本机验证组合,不是所有机器的兼容保证。
## 5. 执行链路中重要部分代码文件及原理说明(包括重点说明)
**本地生命周期与权重。** `stages/segmentation-stage.mjs` 先探测,再启动一次推理进程;离线环境变量禁止 Hugging Face 下载。推理超时 20 分钟,结束、报错或取消后退出。`sam_runtime.py` 从 multiplex 权重提取 detector 分支,适配三个特征尺度,移除未使用的第四尺度并取消 scalp;忽略不参与当前推理的视频 neck,实际加载参数使用 strict=True。外部 SAM 源码不作修改。
**精度不能简单全模型转换。** 模型参数和复数位置编码缓冲区保持原精度,在推理上下文开启 BF16 autocast;直接对整个模型调用 BF16 转换会影响复数缓冲区及要求 FP32 的路径。
**提示和选择。** `sam_segment.py` 将原图合成白底 RGB,GPU 只编码一次;每个部件分别收集 text_only 与 text_and_box 候选。排序结合模型分数、粗框内比例、Alpha 越界比例和种子命中;前景不足 4 像素或框内比例低于 10% 的候选被拒绝。粗框是提示,并非强制裁剪边界。
**重点:当前不是 SAM 点提示分割。** 所用图像概念接口接受文本和框。第一阶段程序种子仅参与返回 mask 排序,模型原始点不传入 SAM。提高种子前景命中率不能保证选中正确部件。
**像素与质量。** `segmentation_outputs.py` 用原图 Alpha 裁掉背景,从原图复制 RGB/Alpha,不重绘、不缩放;保留多部件重叠。记录越界、碎片、未命中种子、漏分、重叠和 IoU≥0.8 的疑似重复。覆盖率不是语义准确率,原像素保真也不能修复错误归属。
**当前预览含原图纹理。** `annotated.png` 是原图上 Alpha=100 的彩色 mask 叠加,不是仅用纯色区域组成的角色图;`parts-sheet.png` 为带 ID 的白底拆件。纯色遮罩角色图属于第 8 节的待实现项。
## 6. 各个代码文件职责索引
| 文件 | 职责 |
| --- | --- |
| [tools/model-check.mjs](../tools/model-check.mjs) | 独立分割入口、参数解析 |
| [stages/segmentation-stage.mjs](../stages/segmentation-stage.mjs) | 输入校验、环境探测、子进程、哈希、日志与失败处理 |
| [lib/config.mjs](../lib/config.mjs) | SAM 路径与配置解析 |
| [lib/python-runtime.mjs](../lib/python-runtime.mjs) | ai-spine 解释器与基础依赖检查 |
| [lib/images.mjs](../lib/images.mjs) / [lib/validate-parts.mjs](../lib/validate-parts.mjs) | PNG 元数据 / v2 计划结构校验 |
| [lib/run-artifacts.mjs](../lib/run-artifacts.mjs) | 输出目录、复用、产物哈希 |
| [sam_runtime.py](../sam_runtime.py) | 依赖/CUDA 探测、源码路径、检测权重适配与严格加载 |
| [sam_segment.py](../sam_segment.py) | 提示校验、GPU 推理、候选排序、诊断与阶段组织 |
| [segmentation_outputs.py](../segmentation_outputs.py) | mask QA、原像素提取、标注图、拆件图、HTML |
| [tests/test_segmentation.py](../tests/test_segmentation.py) | 候选、提示、Alpha、裁剪保真和重叠回归 |
| [tests/model-stages.test.mjs](../tests/model-stages.test.mjs) | 阶段入口和相关本地回归调度 |
`segment.py` 是旧版白底拆件图分割器,不是本阶段 SAM 入口;`compose_preview.py`、`run_case_matrix.py` 是旧版定位/组装对照工具,不应与新阶段混用。
## 7. 产物详细说明
| 路径 | 内容与用途 |
| --- | --- |
| `source.png` / `semantic-parts.json` / `segmentation-prompts.json` | 输入副本,保留坐标与语义来源 |
| `annotated.png` | 原尺寸原图 + 半透明彩色 mask,背景合成白色 |
| `parts-sheet.png` | 原尺寸拆件分开排布的白底图,含 ID 与颜色标记 |
| `parts/<id>.png` | 紧裁剪 RGBA 部件,保留原图像素 |
| `masks/<id>.png` | 原画布尺寸的二值 mask,0/255,已裁掉 Alpha 背景 |
| `candidates/<id>-raw.png` | 选中的原始 SAM mask,尚未裁 Alpha,用于越界诊断 |
| `unassigned-mask.png` | 白色为没有任何部件覆盖的角色前景 |
| `overlap-mask.png` | 白色为多个部件共同覆盖的区域 |
| `segmentation.json` | 部件路径、crop_xyxy、sheet_xyxy、颜色、QA、跳过列表、覆盖率、运行环境、加载记录 |
| `candidate-diagnostics.json` | 两种提示候选的分数、排序、拒绝原因和最终选择 |
| `report.html` | 预览、部件链接、告警及漏分/重复复核 |
| `worker.log` / `worker-error.json` | 日志 / 子进程错误诊断(失败时) |
| `manifest.json` | 状态、输入/代码/权重/产物哈希;完成后 gpu_worker_exited=true |
`crop_xyxy` 为原图坐标,`sheet_xyxy` 为拆解图坐标,右下角均为开区间。第三阶段依赖全画布 masks,不以紧裁剪 parts 反推位置。
当前质量基线是 `output/20260918-183532/sam`:32 个计划项中 10 个 hidden 跳过,22 个 mask,前景覆盖率约 83.3%,32719 像素未分配。图中 forearm_r/hand_r 存在衣料误分,前臂混入手部,上衣及袖子存在缺失。更早 `sombrero-sam31-v3` 为另一批 28 个 mask、约 67.18% 覆盖率,不能混用统计。真实 GPU 推理跑通不代表拆件可直接用于动画。
## 8. 当前阶段可改进点说明
以下均为待实现或待验证的改进方向:
- **输出仅色彩遮罩的完整角色图。** 在原画布上以各部件纯色展示分区,不叠加原图纹理;保留现有 annotated.png。需明确重叠显示顺序,将未分配区域单独标识,不能为了“完整”伪造已识别区域。
- **可选的大模型去毛边。** 对每个通过部件归属复核的 SAM 拆件增加一次可选边缘清理,提供对应原图局部参考,约束修改范围,保存清理前后、mask 差异和坐标。默认保留原始拆件;清理不能冒充隐藏补全,也不能把重绘结果称为原像素。
- **先修正语义误分。** 明确左右、隐藏状态和粗框,复核衣料/皮肤/手部归属;对明显错误、缺失或疑似重复输出阻断性状态,再进入补全。
- **改善覆盖和边界。** 对未分配前景、过度重叠及大孔洞做定位复核;区分合理遮挡与分割缺口,避免一刀切强制所有 mask 互斥。
- **重评补全需求。** 基于实际 mask 检查袖子、衣服等缺损,反馈第三阶段;不能仅沿用第一阶段 needs_completion。
- **增加语义验收。** 模型分数、前景命中和覆盖率只作辅助;将原图、候选、最终 mask 并排复核,记录人工修正及来源。
+245
View File
@@ -0,0 +1,245 @@
# 第三阶段:部件补全
## 1. 阶段简介
读取 SAM 可见 mask,规划分割缺口修复、遮挡补全、关节延伸和附件底图清理,向图片模型提供部件与原图参考,再把结果映射回原图坐标。Python 几何处理固定使用 Conda `ai-spine`;图片生成在远端完成,本阶段不加载本地 SAM。
默认 `--completion-profile adaptive` 使用实际 mask 重建需求、自适应半径和可逆缩放;`legacy` 保留旧画布与筛选规则,供旧候选复检。原 SAM 输入始终留档,只有保留区域锁定原像素;明确规划的编辑区域可以替换,人工确认的移除区域可删除。
当前已实现准备、真实图片请求、离线候选导入、几何检查与透明补全部件输出。[第四阶段](4-spine-stage.md) 独立整合 SAM 与补全部件,输出完整角色组装、Spine 骨架和预览。几何通过仍为 `needs_review`,不能等同于部件可用。
## 2. 输入、输出与执行链路
输入是第二阶段目录;输出是任务画布、候选、检查报告及通过几何检查的补全部件。
```text
tools/model-check.mjs --stage completion --input-dir SAM目录
→ stages/completion-stage.mjs 校验计划、mask 文件、参数及 Conda
→ completion_geometry.py prepare
adaptive:completion_planning.py 重建需求 → 区分保留/编辑/移除 → 缩放工作区并加入参考
legacy:沿用 needs_completion → 固定半径 → 原像素平移
→ prepare-only:保存任务和报告后结束
→ 否则:串行请求 images/edits,或读取离线候选
→ completion_geometry.py merge
检查画布/前景/对齐 → 逆变换回原像素尺寸 → 限制编辑和连通性 → 恢复保留像素
→ 每部件选第一个通过几何检查的候选
→ finalize 输出透明部件、原图坐标和报告
```
adaptive 检查所有选中部件的实际 mask,不再以第一阶段 needs_completion 为唯一门槛。必须有可见 mask、保留对齐锚点,以及非空编辑或移除区域。隐藏/缺 mask、无观察到的编辑区域、无保留锚点分别跳过;无明确遮挡关系的近重复 mask(IoU≥0.8)要求人工复核。一般有歧义的重叠只记录诊断,不自动猜测归属。指定 `--part` 只缩小处理范围。
legacy 才要求 `needs_completion=true` 且允许新增非空。旧实验 `output/20260918-183532/sam` 的 22 个可见部件中 12 个进入补全,是旧筛选结果,不能套用于 adaptive。
## 3. 阶段执行命令示例及参数说明
以下命令在项目根目录执行;输入示例是已有实验,不代表其 mask 已合格。
```powershell
conda activate ai-spine
$runId = Get-Date -Format "yyyyMMdd-HHmmss"
$samInput = ".\output\20260918-183532\sam"
# 离线准备所有符合条件的任务,无需密钥
node .\tools\model-check.mjs --stage completion `
--config .\configs\tiantoken.json `
--input-dir $samInput `
--out-dir ".\output\$runId\complete-prepare" `
--prepare-only
# 单部件真实图片编辑请求
node .\tools\model-check.mjs --stage completion `
--config .\configs\tiantoken.json `
--input-dir $samInput `
--out-dir ".\output\$runId\complete-head" `
--completion-model gpt-image-2 `
--part head
# 所有符合条件的部件,逐部件请求模型
node .\tools\model-check.mjs --stage completion `
--config .\configs\tiantoken.json `
--input-dir $samInput `
--out-dir ".\output\$runId\complete"
```
使用 Qwen 时改为 `--config .\configs\deepseek-qwen.json --completion-model qwen-image-3.0`,读取原中转配置。Tiantoken 配置第三阶段为 `gpt-image-2`,第一阶段的 `gpt-6-astra` 不是当前图片编辑接入的替代品;未实现 Astra Responses 图片生成工具链。
旧候选离线复检,不重新请求模型:
```powershell
node .\tools\model-check.mjs --stage completion `
--config .\configs\tiantoken.json `
--input-dir $samInput `
--candidates-dir .\output\20260918-183532\complete2_gpt `
--out-dir ".\output\$runId\complete-recheck" `
--completion-profile legacy `
--candidate-size-policy uniform
```
仅测试图片编辑接口的独立探针:
```powershell
node .\tools\model-check.mjs --stage completion `
--config .\configs\tiantoken.json `
--image .\output\20260918-183532\complete2_gpt\tasks\head\input.png `
--prompt-file .\output\20260918-183532\complete2_gpt\tasks\head\prompt.txt `
--out-dir ".\output\$runId\edit-probe"
```
探针只保存候选,不执行部件准备与几何验收,不能与 `--input-dir` 混用。
新的单部件多图/mask 能力探针会产生一次真实请求,并执行新几何链路;不会修改默认 capabilities。仅在决定进行付费小样时运行,替换 part 为实际存在的可执行任务:
```powershell
node .\tools\model-check.mjs --stage completion `
--config .\configs\tiantoken.json `
--input-dir $samInput `
--out-dir ".\output\$runId\capability-probe" `
--part head --probe-edit-capabilities
```
该探针发送主图、局部参考、全角色参考和透明可编辑 mask,强制 n=1、禁用 HTTP 重试。查看 request.json、原候选、QA 和 capability-evidence.json;HTTP 接受不等于参考图被使用或 mask 语义正确,因此不会自动启用能力。prepare-only 可配 `--completion-reference both --completion-mask required` 检查计划发送内容而不联网。
| 参数 | 说明 |
| --- | --- |
| `--input-dir DIR` | 完整 SAM 输出目录,部件补全必需 |
| `--out-dir DIR` | 独立新目录,准备与生成使用不同目录 |
| `--part ID` | 可重复,ID 必须存在且不重复;省略时处理所有符合筛选条件的部件 |
| `--prepare-only` | 只生成画布、区域、提示词和报告,不请求模型 |
| `--candidates-dir DIR` | 离线导入候选,与 prepare-only 互斥 |
| `--completion-profile adaptive/legacy` | 默认 adaptive;旧候选需显式 legacy |
| `--completion-reference auto/board/local/full/both/none` | auto:支持多图时 local+full,否则单图参考板;legacy auto 为旧单图/局部参考行为 |
| `--completion-mask auto/required/off` | auto 遵循配置;required 要求已配置支持,或用单部件能力探针试验;off 不发送但仍本地限制编辑 |
| `--completion-review FILE` | 人工确认的补全区域文件,格式见第 4 节;只用于 adaptive |
| `--probe-edit-capabilities` | adaptive 在线单部件试验多图+mask,不持久修改配置,不自动认定能力支持 |
| `--completion-size N` | 请求方形画布边长,默认 1024,整数 128~4096;adaptive 记录显式缩放和逆变换 |
| `--extension-radius N` | 原图像素半径覆盖,整数 1~256;adaptive 默认 min(mask宽,高)×0.15 向上取整并限于 4~96;legacy 默认 32 |
| `--candidate-size-policy NAME` | strict 默认拒绝尺寸不符;uniform 允许同宽高比整画布缩放后复检 |
| `--completion-model ID` | 图片模型 ID,原样发送 |
| `--config FILE` / `--env-file FILE` | 默认 astra-qwen.json / 项目 .env;shell 同名变量优先 |
| `--python FILE` | 仅允许 ai-spine 解释器 |
| `--dry-run` | 检查输入文件、计划、参数和 Python 基础环境,不准备画布或请求模型;完整像素级 mask 校验在 prepare 中执行 |
| `--resume` | 仅复用已完成、输入/配置/源码/产物一致的任务,不自动续发失败请求 |
| `--image FILE` / `--prompt-file FILE` | 仅用于探针:PNG 图片 / 文本提示词;多次 image 需配置多图能力 |
| `--mask FILE` | 仅用于探针:透明处可编辑的 RGBA PNG,需配置 mask 能力 |
配置中默认 `n=1`,逐部件串行调用。明确的 429/5xx 有限重试,超时/断网不自动重试;请求失败保留诊断并停止后续部件,几何拒绝会记录结果并继续。`--stop-after` 不支持 completion。
## 4. 输入格式要求
| 输入 | 要求 |
| --- | --- |
| `source.png` | 原画布 PNG,使用第二阶段的 RGBA 原图 |
| `semantic-parts.json` | 有效 v2 计划;部件分类、遮挡、pivot 和补全需求合法 |
| `segmentation.json` | parts 数组,ID 唯一且存在于计划中 |
| `masks/<id>.png` | segmentation.parts 中每个 ID 都必须有文件;原画布尺寸,灰度值仅 0/255,非空,不覆盖原图 Alpha=0 区域 |
| 中转配置 | 在线模式需地址、密钥、images-edits 提供方及图片模型;prepare-only/离线导入无需密钥 |
只提供 SAM 的 `parts/` 或白底拆解图不满足要求。输入目录中无需存在 annotated.png 才能运行,但建议先通过报告复核语义。
离线候选支持两种形式:
- 平铺目录:`<id>.png`,必须对应本次局部画布坐标、尺寸与部件;平铺文件没有旧任务 manifest 可自动核对,需自行保证一致。
- 旧补全目录:含 manifest 和 `tasks/<id>/candidate-1.png`,也支持 jpg/webp;逐项核对源图、计划、segmentation.json、全部 mask 哈希以及画布/延展参数。每部件只取首张候选。缺失记为 missing_candidate,不联网补发。
不同原图、mask 或参数的候选不能混用。尺寸之外,部件位置、朝向、比例也必须保持一致。
adaptive 的旧运行候选还要匹配 profile、参考布局、复核文件/掩码哈希和规划版本;board 与独立多图的画布不同,不可混用。平铺候选仍需人工保证它对应当前请求画布。
人工复核文件示例(JSON 中的 ID 取自当前语义计划,字段均可省略但 confirmed 必须为 true):
```json
{
"schema_version": 1,
"parts": {
"part_id": {
"confirmed": true,
"extension_radius": 24,
"repair_mask": "review-masks/repair.png",
"replace_mask": "review-masks/replace.png",
"remove_mask": "review-masks/remove.png"
}
}
}
```
mask 是与 source 同尺寸的单通道 0/255 PNG,白色表示区域;路径相对于复核 JSON 且不得越出其目录。repair 必须在当前 SAM mask 外;replace/remove 必须位于 SAM 内且互不重叠。replace 用于误分重绘或额外底图清理,remove 用于明确不属于该部件的像素。半径优先级为部件复核值 > CLI 全局值 > 自适应值。文件及 masks 会复制进输出,哈希纳入复用身份。
## 5. 执行链路中重要部分代码文件及原理说明(包括重点说明)
**按实际 mask 重建需求。** 遮挡延伸使用轮廓外半径带与 occluded_by 的实际 mask 交集;父/子关节需求来自骨骼层级与 pivot。自动分割修复仅选择 mask 内部闭合孔洞、原图有前景且无其他 mask 归属的像素;开放缺口和重叠归属不自动猜测,可用复核文件指定。自动底图清理仅针对 occluded_by 中直接 bind_to 当前骨骼的附件/五官与当前 mask 的交集。这些仍是几何候选,语义不自动获证。
**保留与编辑。** editable 为遮挡/关节/修复/清理/人工替换区域的并集,扣除 removed;preserve = SAM − editable − removed。保留原 mask 和源图,并另存三种全画布区域供第四阶段核对。只有原始 SAM 或单一图片目录不足以描述这个契约。
**可逆画布。** native 方形裁剪边长为可见+编辑区域最长边的 1.15 倍向上取整,记录 origin。工作区缩放到请求画布(board 工作区边长为请求边长约 68%,其余为参考板;独立参考时工作区占整幅)。RGBA 用 LANCZOS,区域 mask 用最近邻。task.json 保存 native_size、scale、working_xyxy 和含像素中心约定的正反仿射变换。返回后只取工作区,逆缩放到 native,再裁剪回原图坐标;保留像素从原图恢复,所以缩放不改变最终保留像素。legacy 仍仅平移补边。
**实际发送内容。** 当前三个提供方默认仍关闭 multiple_images/mask。adaptive auto 因此发送一张 input.png:左侧是待编辑工作区,右侧为局部及完整角色参考,参考区域不参与最终资产。支持多图时 auto 顺序为 input.png → context.png → reference.png;也可选 local/full/both。request-plan.json 保存计划;只有实际请求才写 request.json,包含有序文件角色、SHA-256、尺寸、image 字段、mask 哈希、透明可编辑语义与是否发送。mask 不支持时明确标 local_only,不能宣称模型侧已受 mask 约束。
**图片解码与尺寸。** `providers/image-edits.mjs` 使用 multipart images/edits,支持非空 b64_json 或返回 URL 下载;空 b64_json 会回退有效 URL。当前部件请求显式发送 size=画布宽x高,并写入 request.json;配置 params.size 冲突会报错。strict 模式拒绝 CANVAS_SIZE_MISMATCH;uniform 仅将同宽高比整画布 LANCZOS 缩放到目标尺寸,保留原候选,不平移、不裁剪、不单独缩放物体,也不改变原图/mask。
**几何验收与合并。** adaptive 在请求工作区的保留不透明像素处检查 RGB MAE≤30、保留率≥95%,先验对齐再逆采样,避免把逆采样边界 Alpha 变化误判成位移。返回前景只保留 editable 内且连接 preserve 的像素;清理/人工替换区域至少 95% 有效填充,否则拒绝。没有有效修改且没有人工移除则拒绝。最后恢复 preserve 的原 RGBA,已连接的可见缺口使用原图纹理,removed 清零。legacy 沿用原可见像素全保护规则。
**能力证据与质量边界。** adaptive QA 记录合并前的 raw_outside_edit_mae / changed_pixels,避免本地恢复掩盖模型越界修改。capability-evidence.json 记录探针的输入顺序、尺寸、候选和 QA,但 reference_use_verified、mask_semantics_verified 不自动置 true,仍需视觉验证。区域填满不能证明生成的是正确底图;未明确划入 editable/remove 的误分仍受保护。接缝、材质、关节补足和运动露底尚无可靠自动验收。
## 6. 各个代码文件职责索引
| 文件 | 职责 |
| --- | --- |
| [tools/model-check.mjs](../tools/model-check.mjs) | 部件补全与独立探针分发、参数互斥检查 |
| [stages/completion-stage.mjs](../stages/completion-stage.mjs) | 输入指纹、准备、串行请求/离线读取、候选选择、状态留档 |
| [completion_geometry.py](../completion_geometry.py) | allowed_regions、prepare、merge_candidate、finalize 与报告 |
| [completion_planning.py](../completion_planning.py) | adaptive 需求重建、复核输入、参考板、正反变换、编辑分区与合并 |
| [normalize_reference.py](../normalize_reference.py) | 候选 Alpha 保留、边界连通白底提取 |
| [providers/image-edits.mjs](../providers/image-edits.mjs) | multipart 编辑请求、能力检查、Base64/URL 图片解码 |
| [providers/http.mjs](../providers/http.mjs) | HTTP、超时、重试、诊断和脱敏 |
| [stages/model-stage.mjs](../stages/model-stage.mjs) | runCompletionProbe,仅验证编辑接口 |
| [lib/config.mjs](../lib/config.mjs) / [lib/images.mjs](../lib/images.mjs) | 提供方、模型与能力配置 / 图片格式、尺寸和哈希 |
| [lib/python-runtime.mjs](../lib/python-runtime.mjs) / [lib/validate-parts.mjs](../lib/validate-parts.mjs) | Conda 检查 / v2 结构校验 |
| [lib/run-artifacts.mjs](../lib/run-artifacts.mjs) | 输出目录、复用、状态和产物哈希 |
| [prompts/complete-v1.txt](../prompts/complete-v1.txt) | 保持姿势、尺寸、可见纹理与隐藏区域补全约束 |
| [Tiantoken](../configs/tiantoken.json)、[DeepSeek/Qwen](../configs/deepseek-qwen.json)、[Astra/Qwen](../configs/astra-qwen.json) 配置 | 图片模型、认证变量、请求参数与能力开关 |
| [tests/completion-stage.test.mjs](../tests/completion-stage.test.mjs) / [tests/test_completion.py](../tests/test_completion.py) | mock 编辑、离线复用、尺寸/对齐拒绝、像素保真与区域回归 |
| [tests/test_completion_adaptive.py](../tests/test_completion_adaptive.py) | 放大/缩小回贴、漏分重建、附件清理、区域复核、拒绝位移和第四阶段契约 |
| [tests/model-stages.test.mjs](../tests/model-stages.test.mjs) | 提供方与探针相关回归 |
## 7. 产物详细说明
| 路径 | 内容与用途 |
| --- | --- |
| `source.png`、`semantic-parts.json`、`segmentation.json`、`masks/` | 输入快照,用于复现与离线候选身份核对 |
| `completion-plan.json` | 准备任务、区域、跳过原因;adaptive 含 assessments、重建需求、半径及歧义重叠 |
| `tasks/<id>/input.png` | 实际请求主图;board 含目标、局部参考与全角色参考 |
| `tasks/<id>/visible.png` | 请求坐标的保留 RGBA;legacy 为原可见 RGBA |
| `tasks/<id>/context.png` / `reference.png` | 局部 / 全角色原图参考,由 reference mode 决定是否独立发送 |
| `tasks/<id>/edit-mask.png` | RGBA 编辑遮罩,透明处为 editable 或 removed;是否发送以 request.json 为准 |
| `tasks/<id>/allowed-mask.png` | 白色表示允许新增区域 |
| `tasks/<id>/<补全字段>.png` | 启用的补全子区域 mask |
| `tasks/<id>/task.json` / `prompt.txt` | origin、区域、变换、参考布局、重建需求和告警 / 实际提示词 |
| `tasks/<id>/native-*.png` | 原像素尺寸的源图、保留像素与各区域,用于逆映射和精确恢复 |
| `preservation/<id>-*.png` | 原图全画布 preserve/editable/removed 契约,第四阶段核对用 |
| `review-source.json`、`review.json`、`review/` | 可选原始复核文件、路径规范化快照及对应 masks |
| `tasks/<id>/request-plan.json` / `request.json` | 计划 / 实际请求;记录有序图片角色、哈希、尺寸、mask 语义和发送状态 |
| `tasks/<id>/capability-evidence.json` | 仅能力探针:请求接受情况及原始越界指标,能力需人工确认 |
| `tasks/<id>/response.raw.json` / `request-metadata.json` | 在线响应留档 / 请求 ID、耗时等元数据 |
| `tasks/<id>/response.error.json` | 可获取时保存的脱敏请求失败诊断 |
| `tasks/<id>/candidate-N.*` | 模型或离线导入的原始候选,拒绝也保留 |
| `tasks/<id>/merged-N-normalized.png` | uniform 实际重采样时保存的候选;不代表通过 |
| `tasks/<id>/qa-N.json` | 尺寸、对齐、新增/丢弃像素、各区域完成情况、拒绝原因 |
| `tasks/<id>/merged-N.png` | 原像素尺寸的局部 RGBA;adaptive 已逆采样并恢复 preserve |
| `parts/<id>.png` | 每部件选中结果的紧裁剪透明 PNG |
| `completion-result.json` | 每部件状态、候选 QA、原图 crop_xyxy、preservation 路径和变换 |
| `report.html` / `manifest.json` | 人工检查页 / 模式、网络标记、状态、输入/代码/产物哈希 |
parts/ 只含本次选中且通过检查的补全部件,不是全部 SAM 部件集合。无需补全、失败和未选中的部件不会自动复制进来;完整角色组装由第四阶段读取 SAM 与本目录后生成。
当前实验:`output/20260918-183532/complete2_gpt` 已真实请求 Tiantoken gpt-image-2 共 12 个部件。旧请求未传 size,返回 1248/1254 方形图,预期为 1024,全部严格尺寸拒绝。`complete2_gpt-size-recheck-v2` 离线复用原候选后,8 个几何通过;head、pants_thigh_l、pants_thigh_r、shoe_l 共 4 个因对齐或无有效新增拒绝。通过项中仍有衣料误分、错误手部和未补全子区域。显式 size 的请求构造已验证,但尚无修复后中转遵守 size 的新实测结论。
2026-09-22 adaptive 验证:Conda ai-spine 下 npm test 共 29 项通过(包括 8 项新 Python adaptive 用例),覆盖 board/独立参考顺序、mask mock、放大与缩小回贴、位移拒绝、实际孔洞、附件清理、人工编辑契约及第四阶段接续。`output/completion-adaptive-prepare-20260922` 使用既有 SAM 数据离线生成 16 个任务、11 个跳过,上衣规划 3708 个孔洞像素、头部规划 10205 个底图清理像素。它们是准备阶段的候选区域,不代表补全质量;本轮未请求付费模型,也未验证中转真实能力。
## 8. 当前阶段可改进点说明
- **完整角色组装已接入第四阶段。** 按原坐标与遮挡约束组装,复用原拆件并记录失败回退,输出补全前后对比、覆盖与重叠诊断及单骨骼旋转检查动画。可靠的接缝与运动质量自动验收仍待实现。
- **参考输入已实现,真实能力待验证。** 支持 board 与独立 local/full/both、mask 发送策略、有序请求记录及单部件能力探针。离线 mock 已覆盖图片顺序、mask、返回尺寸和越界记录;本轮未请求付费模型,未宣称中转支持或生成质量提升。
- **实际 mask 需求重建已实现。** 分别规划缺口、遮挡、关节和附件清理,保存 preserve/editable/removed,并支持人工复核区域。保守孔洞检测不会自动解决所有开放缺口、误分与语义歧义;这些仍需复核。
- **自适应画布与区域已实现。** 半径随部件尺寸变化并可覆盖;小部件放大、大部件缩小,请求与原图间变换留档,最终恢复原像素尺寸和保护区。离线测试覆盖放大/缩小、Alpha 保真、位移拒绝及第四阶段接续;真实模型效果待小样验证。
- **加强验收。** 对必需区域定义有效覆盖要求,增加部件语义、接缝、材质、回贴和关节旋转露底检查;不能放宽阈值来掩盖重绘、位移或错误输入。
- **按小样验证后批量。** 先修正 SAM 输入,再用头、前臂、上衣验证参考图与遮罩,比较原输入、原候选、合并图和回贴图,确认改善后再跑全量。
+128
View File
@@ -0,0 +1,128 @@
# 第四阶段:角色组装与 Spine 导出
## 1. 阶段简介
将第二阶段的可见部件与第三阶段通过几何检查的补全部件整合,按原图坐标组装,并依据 v2 语义计划生成 Spine 4.2 骨架、region 附件、图集及预览。所有部件名称、数量、骨骼父子关系和附件绑定来自输入,不使用角色名称或人形比例模板。
生成过程完全离线,不调用图片模型、不运行 SAM。骨架预览页面嵌入资产,但会从 CDN 加载 Spine Player 4.2,打开播放器需要网络。静态报告与 PNG 可离线查看。
导出完成仍标记 `quality_status=needs_review`。当前只有 setup 与单骨骼旋转检查动画;没有成品 idle/walk,也没有 mesh、权重或 IK。关节、语义、接缝及运动质量需人工复核。
## 2. 输入、输出与执行链路
```text
SAM 目录 + 可选补全目录 + 可选绘制顺序覆盖
→ 校验 v2 计划与骨骼关系
→ 对比两阶段 source / 计划 / segmentation / masks 的 SHA-256
→ 合并部件:已选补全 / 原 SAM 像素 / 隐藏无纹理节点
→ 根据遮挡约束稳定拓扑排序,记录没有约束的重叠
→ 按 crop_xyxy 组装,输出来源与覆盖诊断
→ 根据 parent / bind_to / pivot_norm 创建骨架与附件
→ 生成 skeleton.json、图集、播放器、报告与 manifest
```
SAM 原部件直接由 source + mask 重建,保持 RGB/Alpha 和原坐标;无需依赖 SAM 的紧裁剪 parts/。补全部件使用 `completion-result.json` 中选中结果的 `image` 和 `crop_xyxy`,同时检查尺寸及可见 RGBA 是否保持不变。
## 3. 阶段执行命令示例及参数说明
在项目根目录运行;环境统一为 Conda `ai-spine`。以下路径仅是现有输入示例,换角色时替换输入目录,不修改代码。输出目录每次独立。
```powershell
Set-Location F:\Study\taonier\AI_spine
conda activate ai-spine
$samDir = '.\output\20260920-225137\sam'
$completionDir = '.\output\20260920-225137\complete'
$spineOut = '.\output\spine-' + (Get-Date -Format 'yyyyMMdd-HHmmss')
node .\tools\model-check.mjs --stage spine `
--input-dir $samDir `
--completion-dir $completionDir `
--out-dir $spineOut
Start-Process "$spineOut\report.html"
```
| 参数 | 含义 |
| --- | --- |
| `--input-dir DIR` | 必需,SAM 输出目录 |
| `--completion-dir DIR` | 可选,同批输入的补全输出;省略则使用 SAM 资产 |
| `--out-dir DIR` | 独立输出目录;dry-run 时可省略 |
| `--fallback-policy sam/error` | 默认 sam;需要补全但没有通过结果时回退可见像素并记录原因。error 则终止 |
| `--draw-order-file FILE` | JSON 对象,`draw_order` 包含所有可绘制 ID,按从后到前排列,各出现一次 |
| `--check-angle FLOAT` | 单骨骼检查动画正负角度,默认 10,范围 (0,45] |
| `--genielabs-dir DIR` | 默认相邻 `spine-animation-ai`;复用其图集和播放器脚本,保留 PolyForm Noncommercial 约束 |
| `--python FILE` | 仅允许 ai-spine 解释器 |
| `--dry-run` | 完整检查输入、PNG、坐标及依赖脚本,不写输出,不联网 |
| `--resume` | 仅复用输入、参数、代码与产物哈希全部匹配的完整运行 |
该阶段不读取模型配置与密钥,也不接受 `--config`、模型参数或补全请求参数。自动化可用 `conda run --no-capture-output -n ai-spine node ...`。
可选 runtime 复检:将已有的官方 Spine 4.2 core IIFE 文件路径赋给 `$runtimeFile`,执行以下命令。工具不下载或安装依赖;检查图集尺寸、静态附件顶点和正负旋转下的后代跟随,不验证浏览器 WebGL 渲染。
```powershell
$runtimeFile = '.\output\spine-runtime-validation-20260922\spine-core.js'
conda run -n ai-spine node .\tools\verify-spine-runtime.mjs `
--runtime-file $runtimeFile --input-dir $spineOut
```
## 4. 输入格式要求
SAM 目录需含 `source.png`、有效 v2 `semantic-parts.json`、`segmentation.json`、其中记录的全画布二值 masks。可见部件缺少 mask 直接报错;hidden 不得有可见 mask。
补全目录需含相同输入快照、对应 masks 和 `completion-result.json`。只有 `selected_file` 非空、`qa.accepted=true` 且 `status=geometry_passed_needs_review` 的结果会被采用;其 `image` 文件必须存在,裁剪坐标为原画布内的整数半开区间,尺寸与 PNG 一致。legacy 可见像素必须逐像素匹配 SAM;adaptive 使用 preservation 的 preserve/editable/removed 全画布二值区域,要求 preserve=SAM−editable−removed、编辑与移除不重叠、保留像素精确匹配、输出没有超出保留/编辑区域。输入哈希不一致时拒绝混用。图片和契约路径必须位于补全目录内。
补全失败、未处理、未提供或没有允许区域的需补全部件遵守 fallback-policy;adaptive 已重建并尝试修复的部件也视为有需求,不因原计划 needs_completion=false 就忽略失败。hidden 保留语义骨骼但不生成纹理,也不因缺纹理触发严格回退错误。
可选绘制顺序文件格式为 `{"draw_order":["部件ID一","部件ID二"]}`。必须列出本次全部可绘制部件,不包含 hidden。显式顺序允许覆盖模型遮挡关系,冲突会在报告中列出。
## 5. 执行链路中重要部分代码文件及原理说明(包括重点说明)
**资产整合。** 按 ID 合并,保留来源、跳过或拒绝原因、补全 QA 与上游告警。通过几何检查的补全仍是待复核资产,失败结果不会当作补全成功使用。缺少图片或非法坐标不会被静默回退掩盖。
**位置与骨骼。** 图像坐标原点为左上角,Spine 原点设为原画布底边中点:`X=x-W/2`,`Y=H-y`。pivot 使用连续坐标 `x=pivot_norm[0]*W`、`y=pivot_norm[1]*H`。所有骨骼 setup 旋转为零、缩放为一,因此子骨骼局部位置是世界 pivot 减父 pivot;附件位置是裁剪中心减绑定骨骼 pivot。骨骼按父先子后输出,hidden bone_part 也创建骨骼。attachment/facial_feature 绑定 bind_to,null 绑定 root,不创建额外独立骨骼。
**绘制顺序。** `A.occluded_by` 包含 B 时,A 排在 B 后方。排序保留隐藏节点的传递约束,最终只输出有纹理的 slot。没有约束时使用计划中的稳定顺序;若这些部件确有 Alpha 重叠,记录 unconstrained_overlaps,不能声称已确定遮挡。循环关系拒绝导出。
**检查动画。** 每个语义骨骼生成 `check__ID`:0→正角度→0→负角度→0,时长 2 秒,子骨骼与附件按层级跟随。setup 是静态姿态。它们供人工检查绑定和露底,不自动判定动作质量。
Spine 字段依据 [官方 JSON 格式说明](https://esotericsoftware.com/spine-json-format),导出目标为 4.2;旋转时间线使用 4.2 的 `value` 字段。
## 6. 各个代码文件职责索引
| 文件 | 职责 |
| --- | --- |
| `tools/model-check.mjs` | 独立离线入口、参数与其他阶段隔离 |
| `stages/spine-stage.mjs` | 验证计划、Conda 调用、指纹、导出调度与复用 |
| `spine_assembly.py` | 资产核对、顺序、组装、诊断、骨架及报告 |
| `lib/validate-parts.mjs` | v2 Schema、引用、骨骼和遮挡环路校验 |
| `lib/run-artifacts.mjs` | 独立输出目录和产物哈希 |
| 外部 `scripts/make_atlas.py` | 透明 PNG 图集打包,不修改外部依赖 |
| 外部 `scripts/generate_spine_player.py` | 嵌入骨架和图集的 Spine Player HTML |
| `tests/test_spine_assembly.py` / `tests/spine-stage.test.mjs` | 非人形合成数据、坐标、父子绑定、输入身份、回退、CLI 与完整导出回归 |
| `tools/verify-spine-runtime.mjs` | 可选官方 runtime 加载、图集匹配与世界顶点/旋转复检,需提供本地 core IIFE |
## 7. 产物详细说明
| 文件 | 用途 |
| --- | --- |
| `source.png` / `semantic-parts.json` | 原图与语义计划快照 |
| `images/<id>.png` | 完整可绘制部件集合,供 Spine 编辑器导入 |
| `assembly.json` | 坐标、来源、回退、顺序、QA、输入哈希与覆盖诊断 |
| `assembled-sam.png` / `assembled.png` | 同一绘制顺序的原 SAM / 最终资产透明组装图 |
| `comparison.png` | 左原图、中 SAM 组装、右最终组装 |
| `coverage-diagnostic.png` | 红色为原图前景未覆盖,黄色为多部件重叠;重叠不一定是错误 |
| `skeleton.json` | Spine 4.2 骨骼、slot、region、setup 和关节检查动画;images 路径为 `./images/` |
| `atlas/skeleton.atlas` / `atlas/skeleton.png` | 运行时图集 |
| `preview.html` | 真正的骨架预览,可切换检查动画;需要联网加载播放器 |
| `report.html` | 静态对比、诊断、来源列表与预览入口 |
| `manifest.json` | 执行状态、质量状态、输入/代码/产物哈希、计数、依赖及限制 |
2026-09-22 验证记录:`conda run --no-capture-output -n ai-spine npm test` 的 26 项测试全部通过,包含 6 项新增 Python 组装用例。`output/spine-validation-latest-20260922` 和 `output/spine-validation-baseline-20260922` 分别导出 25/22 个 slot、各 16 个骨骼(含 root);官方 Spine 4.2 runtime 对两者的 setup 顶点与各 30 个旋转采样验证通过。最新导出的播放器另经 headless Chrome 实际加载,显示 Loaded successfully 并渲染角色,截图位于 `output/spine-runtime-validation-20260922/preview.png`。这不代表资产或动作质量合格,现有输入的漏分仍显示在组装对比图中。
## 8. 当前阶段可改进点说明
- 待实现:针对未确定遮挡、pivot 和绑定的交互修正界面;目前可通过语义计划与绘制顺序文件调整。
- 待实现:可靠的接缝、露底与旋转质量自动验收;目前输出检查动画与诊断,质量状态始终 needs_review。
- 待实现:mesh、权重、IK,以及由动作语义驱动的成品动画;不通过固定部件名称硬套人形模板。
- 待验证:不同角色类别与更复杂遮挡关系的资产质量。合成回归只验证数据契约及几何逻辑。
- 待实现:本地播放器资源打包,使骨架预览也可完全离线打开。
-165
View File
@@ -1,165 +0,0 @@
# 第一阶段:可替换模型接口与角色结构理解
本轮实现 GPT6Astra 结构理解、Qwen Image 3.0 图片编辑适配器、结构 JSON 校验、粗标注预览、实验记录和缓存。新增模式不需要 Python,不运行旧的“拆件图 → SIFT 定位”流程。
结构理解当前停在 `understand`;SAM 分割、掩码修正、隐藏部件合并、Spine 骨骼导出是后续阶段。图片编辑测试保存的是原始候选,不是验收通过的最终资产。
## 环境与模型
要求 Node.js 20 或更高版本,无新增 npm 依赖。在 `AI_spine` 目录运行。
复用现有 `.env` 的地址和密钥,可增加:
```dotenv
SPINE_UNDERSTANDING_MODEL=gpt-6-astra
SPINE_COMPLETION_MODEL=qwen-image-3.0
SAM_MODEL_DIR=F:/Study/sam3.1
```
不配置这三项时,`configs/astra-qwen.json` 已提供相同默认值。不会自动改写用户的 `.env`。
查询中转站模型 ID(只读取列表,不调用生成):
```powershell
node .\tools\model-check.mjs --list-models
```
模型展示名不等于 API ID。2026-09-17 的本机接口查询包含 `gpt-6-astra` 和 `qwen-image-3.0`。支持列表不保证所有端点/多图/mask 参数可用,需要分别验证。
本地配置检查(不联网,不生成图片):
```powershell
node .\tools\model-check.mjs --dry-run
```
## 运行结构理解
```powershell
node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-astra --stop-after understand
```
首次调用会向配置的中转站发送参考图。每个实验使用新输出目录;本轮不会自动覆盖旧产物。
切换理解模型:
```powershell
node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-other --understanding-model YOUR_EXACT_MODEL_ID --stop-after understand
```
切换协议(需中转站实际支持):
```powershell
node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-responses --understanding-protocol responses --stop-after understand
```
协议可选 `chat-completions` / `responses`,默认 `responses`:本机中转站实测 Astra 的 codex channel 明确拒绝 `/v1/chat/completions`。输出模式默认 `text-json`:在提示词中提供 Schema,并始终在本地严格校验。显式确认中转站支持后,可使用 `--output-mode json-object` 或 `--output-mode json-schema`。不会因 400/不支持的协议自动换模型或偷偷降低输出约束。
该 Astra 通道还要求 `stream=true`,所以默认配置启用流式 Responses,并设置 `store=false`。适配器收集 SSE 后只接受完整的终止事件,输出仍是普通 JSON 文件。新模型可在阶段配置中设置 `stream: false`。`response.raw.json` 对 SSE 保存终止事件中的 response 对象,非逐字节 SSE 日志;`request-metadata.json` 记录 wire_format。
切换到非流式 Chat Completions 模型时可使用 `--understanding-protocol chat-completions --stream false`,不会沿用 Astra 的流式设置。
项目同时提供 `configs/deepseek-qwen.json`。它把第一阶段固定为
`deepseek-flash + chat-completions + stream=false`,第三阶段仍使用
`qwen-image-3.0`,适合与默认的 `configs/astra-qwen.json` 做对照实验:
```powershell
node .\pipeline.mjs --mode annotated-parts `
--config .\configs\deepseek-qwen.json `
--reference ..\spine-animation-ai\demo\character_reference.png `
--out-dir .\output\sombrero-understand-deepseek `
--stop-after understand
```
主要产物:
| 文件 | 含义 |
| --- | --- |
| `source.png` | 实际提交的原图,不缩放、不裁剪 |
| `semantic-parts.json` | 已通过结构校验的部件清单 |
| `annotated.svg` | 原图上的粗框、关节点、正点和父子连线,可直接浏览器打开 |
| `report.html` | 自包含检查页面,带部件表和模型记录 |
| `validation.json` | 字段、坐标、引用、父子环和遮挡环校验结果 |
| `response.raw.json` / `response.txt` | 原始服务端响应 / 提取的文本 |
| `prompt.txt` / `request.json` | 提示词 / 请求设置与输入哈希;图片内容单独保存在 source.png |
| `request-metadata.json` | requested/returned model、request ID、usage、耗时和请求次数 |
| `manifest.json` | 运行状态、配置、代码版本、源文件哈希和产物哈希 |
`status=completed` 仅表示这一步执行完成。`quality_status=needs_review` 表示仍需人工检查语义和框的位置。粗框不是像素 mask,输出也不是自动批准的骨骼。
校验失败保存原始响应、validation 和报告,退出码为非零,不继续分割。网络/API 失败保存失败 manifest。拒绝、截断和不完整 Responses 都不会被当作成功。
HTTP/SSE/网络失败还保存 `response.error.json`:状态码、请求 ID、耗时、尝试次数、响应体(最多 2 MB)或网络错误代码,密钥会脱敏。若服务端返回 overloaded,使用新目录稍后重跑;这不表示 schema 或分割模型出错。
## 独立测试图片编辑接口
先准备一个局部部件 PNG 和任务文本 `task.txt`,例如:
```text
The input canvas contains the screen-left forearm. Complete the hidden elbow
connection behind the sleeve. Keep the visible hand and forearm in their exact
positions. Preserve the original outline and shading. Do not add other parts.
```
调用 Qwen:
```powershell
node .\tools\model-check.mjs --stage completion --image .\local-part.png --prompt-file .\task.txt --out-dir .\output\qwen-edit-test --completion-model qwen-image-3.0
```
改 `--completion-model` 即可对比另一个兼容的图片编辑模型。此命令直接调用图片接口并可能计费,不调用结构理解。
结果保存 `candidate-1.png`(或服务端实际返回的 JPEG/WebP 扩展名)、输入图、任务提示词、原始响应和运行记录。PNG 输出会记录是否与输入画布同尺寸;尺寸相同不代表内容对齐。
默认图片适配器只发送一张图。若中转站确认支持多图,在配置中启用 `capabilities.multiple_images`,并使用重复 `--image`:第一张是局部画布,其余是原图/标注图参考。字段名通过 `image_field` 选择 `image` 或 `image[]`。
若确认支持编辑 mask,再启用 `capabilities.mask` 并使用 `--mask edit-mask.png`。当前适配器约定透明像素可编辑,要求 mask 与第一张图同尺寸、包含 alpha 通道。SAM 的普通黑白 mask 不能直接当作编辑 alpha mask。未启用能力时参数会明确报错,不静默忽略。
本轮没有恢复原始可见像素,也没有生成隐藏区域的透明掩码;因此候选始终标记 `raw_completion_probe` 和 `needs_review`,不能直接接入最终 atlas。
## 配置与切换规则
`configs/astra-qwen.json` 分离 `providers` 和 `stages`:
- provider:协议、base URL/key 的环境变量名、超时、重试次数、多图和 mask 声明。
- stage:provider 名称、模型、提示词文件、输出模式和模型特定参数。
- 提示词路径相对于配置 JSON 所在目录解析;命令行输入/输出路径相对于当前目录。
- 模型优先级:CLI > 阶段显式 `model` > `model_env` 环境变量 > `default_model`。
- shell 环境覆盖 `.env`。实验 JSON 不应存放密钥,仅引用密钥环境变量名。
- 不同服务可配置各自的 base URL/key 环境变量。
- 模型 ID 原样发送,不再把 `gpt-image-2.5` 映射成其他模型。
- `params` 允许模型特定参数,但不能覆盖 model、prompt、messages、input 等适配器保留字段。
- 新模式的超时/重试来自 provider 配置;旧模式的图片超时仍用 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`。
例如把理解阶段改为固定模型:
```json
{
"provider": "relay-vision",
"model": "YOUR_EXACT_MODEL_ID",
"prompt": "../prompts/understand-v1.txt",
"output_mode": "text-json",
"params": {}
}
```
## 重跑与对比
同一命令增加 `--resume`,仅当上次完整结束,且模型、协议、参数、提示词、Schema、源图、适配器代码和所有产物校验一致时复用。模型变化时要求新的输出目录,防止覆盖对照组。失败任务也保留原目录,使用新目录重跑。
本轮实现单阶段缓存;跨 SAM/补全/导出的依赖缓存留到后续阶段接入。对比补全模型时,固定相同局部画布、任务文本与掩码。
显式 429/5xx 最多按配置重试,超时或网络断开不自动重试,因为服务端可能已经执行任务。完整请求不保证模型内容可复现;seed 只在提供方确认支持时放入 params,usage 不存在时记录 null,不估造价格或 token 数。
## SAM 路径
默认 `F:/Study/sam3.1`,检查 `sam3.1_multiplex.pt` 是否存在。该目录 README 明确说明它仅提供 checkpoint,不是可直接调用的推理服务。本轮仅记录路径和 checkpoint 存在性,状态为 `not_integrated`,不加载权重、不占用 GPU。
## 测试
```powershell
npm test
```
测试使用本地 mock HTTP 服务或注入响应,不调用真实付费模型。覆盖模型优先级、协议请求/响应、multipart、能力限制、数值/引用/环校验、错误响应、重试、缓存和 CLI 全链路。
2026-09-17 的集成检查:模型列表请求成功;Astra 要求 Responses + stream=true,切换后服务端返回 overloaded,尚未得到成功的真实角色标注。Qwen 适配器通过 mock 请求/响应测试,本轮未执行真实图片编辑生成。此状态不等于视觉质量已验证。
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Alpha constraints and deterministic seed candidates, never semantic masks."""
import argparse
import json
import math
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
from normalize_reference import prepare_rgba
def pixel(point, width, height):
# Normalized points map to their containing pixel; 1 maps to the last pixel.
return min(width - 1, int(point[0] * width)), min(height - 1, int(point[1] * height))
def ground_parts(image, plan):
alpha = np.array(image.convert('RGBA'))[:, :, 3]
foreground = alpha > 0
height, width = alpha.shape
usable_alpha = bool(foreground.any() and not foreground.all())
silhouette_distance = cv2.distanceTransform(np.pad(foreground.astype(np.uint8), 1), cv2.DIST_L2, cv2.DIST_MASK_PRECISE)[1:-1, 1:-1]
diagnostics, prompts = [], []
for part in plan['parts']:
box = part['bbox_norm_xyxy']
entry = {
'id': part['id'], 'status': 'hidden' if part['visibility'] == 'hidden' else 'blocked',
'bbox_px_xyxy': None, 'bbox_foreground_ratio': None,
'foreground_bbox_px_xyxy': None, 'foreground_components': 0,
'model_positive_points': [], 'candidate_points': [], 'warnings': [],
'semantic_verified': False,
}
for point in part.get('positive_points_norm', []):
x, y = pixel(point, width, height)
in_box = bool(box and box[0] <= point[0] <= box[2] and box[1] <= point[1] <= box[3])
on_foreground = bool(foreground[y, x])
entry['model_positive_points'].append({
'point_norm': point, 'point_px': [x, y], 'alpha': int(alpha[y, x]),
'inside_bbox': in_box, 'on_foreground': on_foreground,
'status': 'UNTRUSTED_SEED' if usable_alpha and on_foreground and in_box else 'INVALID_SEED',
})
if part['visibility'] != 'hidden' and box:
x0, y0 = math.floor(box[0] * width), math.floor(box[1] * height)
x1, y1 = math.ceil(box[2] * width), math.ceil(box[3] * height)
entry['bbox_px_xyxy'] = [x0, y0, x1, y1]
roi = foreground[y0:y1, x0:x1]
entry['bbox_foreground_ratio'] = float(roi.mean()) if roi.size else 0
if not usable_alpha:
entry['warnings'].append('NO_USABLE_ALPHA')
elif not roi.any():
entry['warnings'].append('EMPTY_FOREGROUND_ROI')
else:
ys, xs = np.nonzero(roi)
entry['foreground_bbox_px_xyxy'] = [int(xs.min()) + x0, int(ys.min()) + y0, int(xs.max()) + x0 + 1, int(ys.max()) + y0 + 1]
count, _ = cv2.connectedComponents(roi.astype(np.uint8), connectivity=8)
entry['foreground_components'] = count - 1
# Pad the ROI with background so an opaque ROI has a finite
# distance, and candidates stay away from both silhouette/box edges.
distance = cv2.distanceTransform(np.pad(roi.astype(np.uint8), 1), cv2.DIST_L2, cv2.DIST_MASK_PRECISE)[1:-1, 1:-1]
# Only centers strictly inside the original continuous ROI qualify.
eligible_x = ((np.arange(x0, x1) + .5) / width >= box[0]) & ((np.arange(x0, x1) + .5) / width <= box[2])
eligible_y = ((np.arange(y0, y1) + .5) / height >= box[1]) & ((np.arange(y0, y1) + .5) / height <= box[3])
eligible = roi & eligible_y[:, None] & eligible_x[None, :]
if eligible.any():
scores = np.where(eligible, distance, -1)
cy, cx = np.unravel_index(np.argmax(scores), scores.shape)
x, y = int(cx) + x0, int(cy) + y0
entry['candidate_points'] = [{
'point_px': [x, y], 'point_norm': [(x + .5) / width, (y + .5) / height],
'distance_to_roi_boundary_px': float(distance[cy, cx]),
'distance_to_silhouette_px': float(silhouette_distance[y, x]),
'source': 'alpha_roi_distance_transform',
}]
entry['status'] = 'candidate'
else:
entry['warnings'].append('NO_PIXEL_CENTER_IN_ROI')
if entry['bbox_foreground_ratio'] < .5:
entry['warnings'].append('LOW_FOREGROUND_RATIO')
if count > 2:
entry['warnings'].append('MULTIPLE_FOREGROUND_COMPONENTS')
entry['warnings'].append('SEMANTIC_REVIEW_REQUIRED')
diagnostics.append(entry)
prompts.append({
'id': part['id'], 'text_prompt': part['semantic'], 'status': entry['status'],
'bbox_px_xyxy': entry['bbox_px_xyxy'],
'positive_points_px': [p['point_px'] for p in entry['candidate_points']],
'positive_points_norm': [p['point_norm'] for p in entry['candidate_points']],
'negative_points_px': [], 'seed_source': 'alpha_roi_distance_transform',
'semantic_verified': False, 'requires_mask_qa': True,
})
points = [p for entry in diagnostics for p in entry['model_positive_points']]
return {
'schema_version': 1, 'width': width, 'height': height,
'alpha_threshold': 0, 'coordinate_mapping': 'floor(norm * size), clamped at size - 1; boxes are half-open',
'summary': {'model_points': len(points), 'invalid_model_points': sum(p['status'] == 'INVALID_SEED' for p in points),
'background_model_points': sum(not p['on_foreground'] for p in points),
'candidate_parts': sum(p['status'] == 'candidate' for p in diagnostics),
'blocked_parts': sum(p['status'] == 'blocked' for p in diagnostics)},
'parts': diagnostics,
}, {'schema_version': 1, 'width': width, 'height': height, 'quality_status': 'needs_review', 'parts': prompts}
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--input', required=True)
parser.add_argument('--output', required=True)
parser.add_argument('--prepare', action='store_true')
parser.add_argument('--parts')
parser.add_argument('--prompts')
args = parser.parse_args()
with Image.open(args.input) as image:
if args.prepare:
prepared, metadata = prepare_rgba(image)
prepared.save(args.output, format='PNG')
print(json.dumps(metadata))
else:
if not args.parts or not args.prompts:
parser.error('--parts and --prompts are required for grounding')
plan = json.loads(Path(args.parts).read_text(encoding='utf-8'))
diagnostics, prompts = ground_parts(image, plan)
Path(args.output).write_text(json.dumps(diagnostics, indent=2), encoding='utf-8')
Path(args.prompts).write_text(json.dumps(prompts, indent=2), encoding='utf-8')
if __name__ == '__main__':
main()
+8 -4
View File
@@ -84,9 +84,13 @@ export function publicStage(stage) {
}; };
} }
export function samInfo(loaded) { export function samInfo(loaded, overrides = {}) {
const stage = loaded.config.stages.segmentation || {}; const stage = loaded.config.stages.segmentation || {};
const modelDir = loaded.env[stage.model_dir_env] || stage.default_model_dir || ''; const modelDir = overrides.samDir || loaded.env[stage.model_dir_env] || stage.default_model_dir || '';
const checkpoint = modelDir ? path.join(modelDir, 'sam3.1_multiplex.pt') : ''; const checkpoint = overrides.samCheckpoint || (modelDir ? [path.join(modelDir, 'sam3.1checkpoint', 'sam3.1_multiplex.pt'), path.join(modelDir, 'sam3.1_multiplex.pt')].find(existsSync) || path.join(modelDir, 'sam3.1checkpoint', 'sam3.1_multiplex.pt') : '');
return {model_dir: modelDir, checkpoint, checkpoint_exists: Boolean(checkpoint && existsSync(checkpoint)), runtime_status: 'not_integrated'}; const codeDir = overrides.samCodeDir || loaded.env.SAM_CODE_DIR || (modelDir ? path.join(modelDir, 'sam3') : '');
return {model_dir: modelDir, code_dir: codeDir, checkpoint,
checkpoint_exists: Boolean(checkpoint && existsSync(checkpoint)),
code_exists: Boolean(codeDir && existsSync(path.join(codeDir, 'sam3', 'model_builder.py'))),
runtime_status: 'local_subprocess_not_probed'};
} }
+16
View File
@@ -0,0 +1,16 @@
import {execFileSync} from 'node:child_process';
import {readFileSync} from 'node:fs';
import path from 'node:path';
import {projectRoot} from './config.mjs';
export {selectPython as geometryPython} from './python-runtime.mjs';
export function prepareReference(python, input, output) {
return JSON.parse(execFileSync(python, [path.join(projectRoot, 'geometry_grounding.py'), '--prepare', '--input', input, '--output', output], {encoding: 'utf8', windowsHide: true}));
}
export function groundParts(python, out) {
const diagnostics = path.join(out, 'geometry-validation.json');
execFileSync(python, [path.join(projectRoot, 'geometry_grounding.py'), '--input', path.join(out, 'source.png'), '--parts', path.join(out, 'semantic-parts.json'), '--output', diagnostics, '--prompts', path.join(out, 'segmentation-prompts.json')], {encoding: 'utf8', windowsHide: true});
return JSON.parse(readFileSync(diagnostics, 'utf8'));
}
+23
View File
@@ -0,0 +1,23 @@
import {spawnSync} from 'node:child_process';
import path from 'node:path';
export function selectPython(requested) {
const command = requested || (process.env.CONDA_PREFIX
? path.join(process.env.CONDA_PREFIX, process.platform === 'win32' ? 'python.exe' : 'bin/python')
: 'python');
const probe = spawnSync(command, ['-c', [
'import json, sys',
'from pathlib import Path',
"print(json.dumps({'executable': sys.executable, 'name': Path(sys.prefix).name, 'conda': (Path(sys.prefix) / 'conda-meta').is_dir()}))",
].join('\n')], {encoding: 'utf8', windowsHide: true});
let runtime;
try { if (probe.status === 0) runtime = JSON.parse(probe.stdout.trim()); } catch { /* Report an actionable environment error below. */ }
if (!runtime?.conda || runtime.name !== 'ai-spine') {
throw new Error('本项目固定使用 Conda 环境 ai-spine。请先 conda activate ai-spine,或用 conda run -n ai-spine node ...;--python 也必须指向该环境的解释器');
}
const dependencies = spawnSync(runtime.executable, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore', windowsHide: true});
if (dependencies.status !== 0) {
throw new Error('ai-spine 环境缺少或无法加载 Python 依赖。请在 AI_spine 目录执行:conda run -n ai-spine python -m pip install -r requirements.txt');
}
return runtime.executable;
}
+18 -7
View File
@@ -2,11 +2,12 @@ import {dataUrl} from './images.mjs';
const escape = (value) => String(value).replace(/[&<>"']/gu, (c) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c])); const escape = (value) => String(value).replace(/[&<>"']/gu, (c) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
export function annotatedSvg(image, parts) { export function annotatedSvg(image, parts, geometry = null) {
const {width: w, height: h} = image; const {width: w, height: h} = image;
const stroke = Math.max(w, h) / 400; const stroke = Math.max(w, h) / 400;
const font = Math.max(w, h) / 65; const font = Math.max(w, h) / 65;
const byId = new Map(parts.map((part) => [part.id, part])); const byId = new Map(parts.map((part) => [part.id, part]));
const diagnostics = new Map((geometry?.parts || []).map((part) => [part.id, part]));
const overlays = parts.map((part, index) => { const overlays = parts.map((part, index) => {
const color = `hsl(${(index * 137.5) % 360} 85% 55%)`; const color = `hsl(${(index * 137.5) % 360} 85% 55%)`;
const [px, py] = [part.pivot_norm[0] * w, part.pivot_norm[1] * h]; const [px, py] = [part.pivot_norm[0] * w, part.pivot_norm[1] * h];
@@ -17,14 +18,24 @@ export function annotatedSvg(image, parts) {
const [x0, y0, x1, y1] = part.bbox_norm_xyxy; const [x0, y0, x1, y1] = part.bbox_norm_xyxy;
box = `<rect x="${x0 * w}" y="${y0 * h}" width="${(x1 - x0) * w}" height="${(y1 - y0) * h}"/>`; box = `<rect x="${x0 * w}" y="${y0 * h}" width="${(x1 - x0) * w}" height="${(y1 - y0) * h}"/>`;
} }
const points = part.positive_points_norm.map(([x, y]) => `<circle cx="${x * w}" cy="${y * h}" r="${stroke * 1.5}" fill="${color}"/>`).join(''); const diagnostic = diagnostics.get(part.id);
return `<g fill="none" stroke="${color}" stroke-width="${stroke}">${line}${box}${points}<circle cx="${px}" cy="${py}" r="${stroke * 3}"/><text x="${Math.min(px + 4, w * 0.6)}" y="${Math.max(font, py - 5)}" fill="${color}" stroke="#111" stroke-width="${stroke / 2}" paint-order="stroke" font-size="${font}" font-family="sans-serif">${escape(part.id)}</text></g>`; const points = part.positive_points_norm.map(([x, y], i) => {
const status = diagnostic?.model_positive_points[i]?.status;
const fill = status === 'INVALID_SEED' ? '#ff4444' : status === 'UNTRUSTED_SEED' ? '#ffc857' : '#999';
return `<circle cx="${x * w}" cy="${y * h}" r="${stroke * 2}" stroke="#111" fill="${fill}"><title>${escape(part.id)}: ${status || 'UNTRUSTED_MODEL_POINT'}</title></circle>`;
}).join('');
const candidates = (diagnostic?.candidate_points || []).map(({point_norm: [x, y]}) => `<rect x="${x * w - stroke * 2}" y="${y * h - stroke * 2}" width="${stroke * 4}" height="${stroke * 4}" stroke="#111" fill="#44e5ff"><title>${escape(part.id)}: Alpha candidate, semantic unverified</title></rect>`).join('');
return `<g fill="none" stroke="${color}" stroke-width="${stroke}">${line}${box}${points}${candidates}<circle cx="${px}" cy="${py}" r="${stroke * 3}"/><text x="${Math.min(px + 4, w * 0.6)}" y="${Math.max(font, py - 5)}" fill="${color}" stroke="#111" stroke-width="${stroke / 2}" paint-order="stroke" font-size="${font}" font-family="sans-serif">${escape(part.id)}</text></g>`;
}).join('\n'); }).join('\n');
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="100%" height="100%" fill="#262632"/><image href="${dataUrl(image)}" width="${w}" height="${h}"/>${overlays}</svg>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="100%" height="100%" fill="#262632"/><image href="${dataUrl(image)}" width="${w}" height="${h}"/>${overlays}</svg>`;
} }
export function understandingReport(image, data, validation, metadata) { export function understandingReport(image, data, validation, metadata, geometry = null) {
const svg = data && validation.valid ? annotatedSvg(image, data.parts) : `<img src="${dataUrl(image)}" alt="source">`; const svg = data && validation.valid ? annotatedSvg(image, data.parts, geometry) : `<img src="${dataUrl(image)}" alt="source">`;
const rows = data && validation.valid ? data.parts.map((part) => `<tr><td>${escape(part.id)}</td><td>${escape(part.parent ?? 'root')}</td><td>${escape(part.visibility)}</td><td>${escape(part.occluded_by.join(', '))}</td><td>${part.confidence}</td></tr>`).join('') : ''; const diagnostics = new Map((geometry?.parts || []).map((part) => [part.id, part]));
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>角色结构理解检查</title><style>body{font:16px system-ui;max-width:1100px;margin:24px auto;padding:16px;background:#f5f5f5}svg,img{max-width:100%;height:auto;max-height:85vh}td,th{padding:8px;border:1px solid #ccc}table{border-collapse:collapse}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style><h1>角色结构理解</h1><p>此图仅包含模型预测的粗框、关节点(空心圆)和正点(实心圆),尚未执行 SAM 分割或补全。结构校验通过不代表语义正确。</p><p>状态:${validation.valid ? 'needs_review(待检查)' : 'failed'}</p>${svg}<h2>校验</h2><pre>${escape(JSON.stringify(validation, null, 2))}</pre><h2>部件</h2><table><tr><th>ID</th><th>父部件</th><th>可见性</th><th>遮挡物</th><th>自报置信度</th></tr>${rows}</table><h2>模型说明</h2><pre>${escape(JSON.stringify(data?.notes || [], null, 2))}</pre><h2>请求记录</h2><pre>${escape(JSON.stringify(metadata, null, 2))}</pre></html>`; const rows = data && validation.valid ? data.parts.map((part) => {
const g = diagnostics.get(part.id);
return `<tr><td>${escape(part.id)}</td><td>${escape(part.rig_role || '')}<br>${escape(part.bind_to ?? part.parent ?? 'root')}</td><td>${escape(part.visibility)}</td><td>${escape(part.occluded_by.join(', '))}</td><td>${escape(Object.entries(part.completion || {}).filter(([, value]) => value).map(([key]) => key).join(', ') || '无')}</td><td>${g?.bbox_foreground_ratio == null ? '—' : `${(g.bbox_foreground_ratio * 100).toFixed(1)}%`}</td><td>${escape(g?.status || 'pending')}<br>${escape((g?.warnings || []).join(', '))}</td><td>${part.confidence}</td></tr>`;
}).join('') : '';
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>角色结构与几何检查</title><style>body{font:16px system-ui;max-width:1400px;margin:24px auto;padding:16px;background:#f5f5f5}svg,img{max-width:100%;height:auto;max-height:85vh}td,th{padding:8px;border:1px solid #ccc}table{border-collapse:collapse}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style><h1>角色结构与几何检查</h1><p>尚未执行 SAM 分割或补全。红色实心圆:无效模型点;黄色实心圆:通过前景检查但不可信的模型点;青色方块:程序生成的 Alpha 候选。空心圆是原始关节点,不做前景吸附。粗框与候选均需语义复核,Alpha 不能证明点属于目标部件。</p><p>状态:${validation.valid ? 'needs_review(待检查)' : 'failed'}</p>${svg}<h2>几何摘要</h2><pre>${escape(JSON.stringify(geometry?.summary || {}, null, 2))}</pre><h2>部件</h2><table><tr><th>ID</th><th>角色/绑定</th><th>可见性</th><th>遮挡物</th><th>补全项</th><th>框内前景</th><th>候选状态</th><th>自报置信度</th></tr>${rows}</table><h2>校验</h2><pre>${escape(JSON.stringify(validation, null, 2))}</pre><h2>模型说明</h2><pre>${escape(JSON.stringify(data?.notes || [], null, 2))}</pre><h2>请求记录</h2><pre>${escape(JSON.stringify(metadata, null, 2))}</pre></html>`;
} }
+54 -4
View File
@@ -3,6 +3,7 @@ import path from 'node:path';
import {projectRoot} from './config.mjs'; import {projectRoot} from './config.mjs';
export const partsSchema = JSON.parse(readFileSync(path.join(projectRoot, 'schemas', 'semantic-parts.schema.json'), 'utf8')); export const partsSchema = JSON.parse(readFileSync(path.join(projectRoot, 'schemas', 'semantic-parts.schema.json'), 'utf8'));
const legacySchema = JSON.parse(readFileSync(path.join(projectRoot, 'schemas', 'semantic-parts-v1.schema.json'), 'utf8'));
// Validate the subset of JSON Schema used by the checked-in contract. Keeping // Validate the subset of JSON Schema used by the checked-in contract. Keeping
// the validation driven by that contract avoids two independent field lists. // the validation driven by that contract avoids two independent field lists.
@@ -46,13 +47,16 @@ export function parseParts(text) {
export function validateParts(data) { export function validateParts(data) {
const errors = []; const errors = [];
const warnings = []; const warnings = [];
validate(data, partsSchema, '$', errors); const legacy = data?.schema_version === 1;
validate(data, legacy ? legacySchema : partsSchema, '$', errors);
if (errors.length) return {valid: false, errors, warnings}; if (errors.length) return {valid: false, errors, warnings};
const ids = new Set(data.parts.map((part) => part.id)); const ids = new Set(data.parts.map((part) => part.id));
const byId = new Map(data.parts.map((part) => [part.id, part]));
if (legacy) warnings.push('旧版计划:rig_role、bind_to 和 completion 将按保守规则迁移,需复核');
if (ids.size !== data.parts.length) errors.push('部件 ID 重复'); if (ids.size !== data.parts.length) errors.push('部件 ID 重复');
for (const part of data.parts) { for (const part of data.parts) {
if (part.id === 'root') errors.push('root 为后续导出保留 ID'); if (part.id === 'root') errors.push('root 为后续导出保留 ID');
for (const ref of [part.parent, ...part.occluded_by].filter((item) => item !== null)) { for (const ref of [part.parent, ...part.occluded_by, ...(!legacy ? [part.bind_to] : [])].filter((item) => item !== null)) {
if (!ids.has(ref) || ref === part.id) errors.push(`${part.id}: 引用了不存在的部件或自身 ${ref}`); if (!ids.has(ref) || ref === part.id) errors.push(`${part.id}: 引用了不存在的部件或自身 ${ref}`);
} }
if (new Set(part.occluded_by).size !== part.occluded_by.length) errors.push(`${part.id}: occluded_by 重复`); if (new Set(part.occluded_by).size !== part.occluded_by.length) errors.push(`${part.id}: occluded_by 重复`);
@@ -62,11 +66,19 @@ export function validateParts(data) {
warnings.push(`${part.id}: 完全隐藏部件需要人工确认,不能直接生成可见 mask`); warnings.push(`${part.id}: 完全隐藏部件需要人工确认,不能直接生成可见 mask`);
} else { } else {
if (!box || box[0] >= box[2] || box[1] >= box[3]) errors.push(`${part.id}: 可见 bbox 必须具有正面积`); if (!box || box[0] >= box[2] || box[1] >= box[3]) errors.push(`${part.id}: 可见 bbox 必须具有正面积`);
if (!part.positive_points_norm.length) errors.push(`${part.id}: 可见部件需要至少一个正点`);
if (box) for (const [x, y] of part.positive_points_norm) { if (box) for (const [x, y] of part.positive_points_norm) {
if (x < box[0] || x > box[2] || y < box[1] || y > box[3]) errors.push(`${part.id}: 正点不在可见框内`); if (x < box[0] || x > box[2] || y < box[1] || y > box[3]) warnings.push(`${part.id}: 模型正点不在可见框内,将丢弃并重新生成候选`);
} }
} }
if (part.positive_points_norm.length || part.negative_points_norm.length) warnings.push(`${part.id}: 模型提示点仅用于诊断,不直接传给 SAM`);
if (!legacy) {
if (part.rig_role === 'bone_part' && part.bind_to !== null) errors.push(`${part.id}: bone_part 使用 parent,bind_to 必须为 null`);
if (part.bind_to !== null && byId.get(part.bind_to)?.rig_role !== 'bone_part') errors.push(`${part.id}: bind_to 必须引用 bone_part`);
if (part.rig_role === 'bone_part' && part.parent !== null && byId.get(part.parent)?.rig_role !== 'bone_part') errors.push(`${part.id}: bone_part 的 parent 必须引用 bone_part`);
if (part.needs_completion !== Object.values(part.completion).some(Boolean)) errors.push(`${part.id}: needs_completion 必须等于 completion 各项的逻辑或`);
if (part.visibility !== 'visible' && !part.completion.occlusion_fill) errors.push(`${part.id}: partial/hidden 部件需要 occlusion_fill`);
if (part.rig_role !== 'bone_part' && (part.completion.joint_extension_parent || part.completion.joint_extension_child)) errors.push(`${part.id}: 附件/面部特征不应声明独立骨骼关节延展`);
}
if (part.confidence < 0.6) warnings.push(`${part.id}: 模型自报低置信度,需要复核`); if (part.confidence < 0.6) warnings.push(`${part.id}: 模型自报低置信度,需要复核`);
} }
if (!errors.length) { if (!errors.length) {
@@ -89,3 +101,41 @@ export function validateParts(data) {
} }
return {valid: errors.length === 0, errors, warnings}; return {valid: errors.length === 0, errors, warnings};
} }
// Legacy results remain readable. These inferred rig/completion decisions are
// explicit review candidates, not a claim that the model supplied them.
export function upgradeParts(data) {
if (data.schema_version !== 1) return structuredClone(data);
const result = structuredClone(data);
result.schema_version = 2;
const byId = new Map(result.parts.map((part) => [part.id, part]));
for (const part of result.parts) {
part.rig_role = /^(eye|eyes|eyebrow|nose|mouth|mustache)(_|$)/u.test(part.id) ? 'facial_feature'
: /^(pelvis|torso|neck|head|upper_arm|forearm|hand|thigh|shin|foot)(_|$)/u.test(part.id) ? 'bone_part' : 'attachment';
}
const nearestBone = (id) => {
const seen = new Set();
while (id && !seen.has(id)) {
seen.add(id);
const part = byId.get(id);
if (!part) return null;
if (part.rig_role === 'bone_part') return id;
id = part.parent;
}
return null;
};
for (const part of result.parts) {
const sleeveBone = part.id.replace(/^sleeve_/u, 'upper_arm_');
part.bind_to = part.rig_role === 'bone_part' ? null
: part.id.startsWith('sleeve_') && byId.get(sleeveBone)?.rig_role === 'bone_part' ? sleeveBone : nearestBone(part.parent);
if (part.rig_role === 'bone_part') part.parent = nearestBone(part.parent);
part.completion = {
occlusion_fill: part.visibility !== 'visible' || part.needs_completion,
joint_extension_parent: part.rig_role === 'bone_part' && part.parent !== null,
joint_extension_child: part.rig_role === 'bone_part' && result.parts.some((child) => child.rig_role === 'bone_part' && nearestBone(child.parent) === part.id),
};
part.needs_completion = Object.values(part.completion).some(Boolean);
}
result.notes.push('Migrated from schema v1: rig roles/bindings and completion are heuristic review candidates. Original model points are diagnostic only; use segmentation-prompts.json for geometry candidates.');
return result;
}
+36
View File
@@ -3,14 +3,50 @@
import argparse import argparse
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image
import numpy as np
import cv2
def prepare_rgba(image, white_threshold=240):
"""Keep existing alpha; remove only border-connected near-white background.
No crop or rescale here: semantic coordinates must retain the full canvas.
White regions enclosed by character outlines are deliberately preserved.
"""
rgba = np.array(image.convert('RGBA'))
alpha = rgba[:, :, 3]
method = 'existing_alpha'
removed = 0
if not np.any(alpha == 0):
white = np.all(rgba[:, :, :3] >= white_threshold, axis=2).astype(np.uint8)
_, labels = cv2.connectedComponents(white, connectivity=4)
border = np.unique(np.concatenate((labels[0], labels[-1], labels[:, 0], labels[:, -1])))
background = np.isin(labels, border[border != 0])
removed = int(background.sum())
rgba[background, 3] = 0
method = 'border_connected_white'
foreground = rgba[:, :, 3] > 0
if not foreground.any():
raise ValueError('Reference has no foreground after background removal')
if foreground.all():
raise ValueError('No transparent background or border-connected white background; supply a cutout RGBA PNG')
return Image.fromarray(rgba), {
'method': method, 'white_threshold': white_threshold,
'removed_pixels': removed, 'foreground_pixels': int(foreground.sum()),
'width': image.width, 'height': image.height,
'needs_review': method != 'existing_alpha',
}
def main(): def main():
p = argparse.ArgumentParser() p = argparse.ArgumentParser()
p.add_argument('--input', required=True) p.add_argument('--input', required=True)
p.add_argument('--output', required=True) p.add_argument('--output', required=True)
p.add_argument('--target', type=int, default=1024) p.add_argument('--target', type=int, default=1024)
p.add_argument('--remove-background', action='store_true')
a = p.parse_args() a = p.parse_args()
im = Image.open(a.input).convert('RGBA') im = Image.open(a.input).convert('RGBA')
if a.remove_background:
im, _ = prepare_rgba(im)
longest = max(im.width, im.height) longest = max(im.width, im.height)
if longest < a.target: if longest < a.target:
scale = a.target / longest scale = a.target / longest
+1 -21
View File
@@ -11,6 +11,7 @@ import {spawnSync} from 'node:child_process';
import {cpSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync} from 'node:fs'; import {cpSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
import path from 'node:path'; import path from 'node:path';
import {selectPython} from './lib/python-runtime.mjs';
const repoRoot = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.dirname(fileURLToPath(import.meta.url));
const defaultGenielabsDir = path.resolve(repoRoot, '..', 'spine-animation-ai'); const defaultGenielabsDir = path.resolve(repoRoot, '..', 'spine-animation-ai');
@@ -82,27 +83,6 @@ function loadEnvFile(file) {
return result; return result;
} }
function selectPython(requested) {
const venvPython = path.join(repoRoot, '.venv', process.platform === 'win32' ? 'Scripts' : 'bin', process.platform === 'win32' ? 'python.exe' : 'python');
const candidates = requested ? [requested] : ['python', 'python3', venvPython];
for (const candidate of candidates) {
const check = spawnSync(candidate, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore'});
if (check.status === 0) return candidate;
}
if (requested) throw new Error(`指定的 Python 无法导入 cv2、PIL、numpy:${requested};请在该环境安装 requirements.txt`);
if (!existsSync(venvPython)) {
const bootstrapPython = ['python', 'python3'].find((candidate) => spawnSync(candidate, ['-c', 'import sys'], {stdio: 'ignore'}).status === 0);
if (!bootstrapPython) throw new Error('未找到 Python,请激活环境或通过 --python 指定');
const bootstrap = spawnSync(bootstrapPython, ['-m', 'venv', path.join(repoRoot, '.venv')], {stdio: 'inherit'});
if (bootstrap.status !== 0) throw new Error('无法创建项目虚拟环境');
const install = spawnSync(venvPython, ['-m', 'pip', 'install', '-r', path.join(repoRoot, 'requirements.txt')], {stdio: 'inherit'});
if (install.status !== 0) throw new Error('Python 依赖安装失败,请检查网络或手动安装 requirements.txt');
}
const check = spawnSync(venvPython, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore'});
if (check.status === 0) return venvPython;
throw new Error('虚拟环境安装后仍缺少 cv2、Pillow、numpy');
}
async function requestAtlas(reference, model, size, config, prompt) { async function requestAtlas(reference, model, size, config, prompt) {
for (let attempt = 0; attempt < 3; attempt += 1) { for (let attempt = 0; attempt < 3; attempt += 1) {
const form = new FormData(); const form = new FormData();
+11 -5
View File
@@ -1,10 +1,16 @@
Complete only the hidden portion of the specified character part on the first Repair and complete the specified character part on the first input image.
input image (the local canvas). Any subsequent images are context references. The task declares editable regions for segmentation gaps, occlusion, joint
extensions, and removal of overlaid attachments from their underlying base.
Only regions explicitly marked editable may be changed. Preserve all other
visible pixels. Any subsequent images or labeled reference panels are context.
Preserve the exact original pose, scale, screen direction, perspective, colors, Preserve the exact original pose, scale, screen direction, perspective, colors,
line thickness and shading. Do not mirror, rotate, resize, recenter or redesign. line thickness and shading. Do not mirror, rotate, resize, recenter or redesign.
Keep the canvas dimensions and the position of every already-visible feature. Keep the canvas dimensions and the position of every already-visible feature.
Extend the hidden joint area naturally behind its occluder, without adding other Extend the hidden joint area naturally behind its occluder, without adding other
body parts, props, labels, annotations or extra versions of the same part. body parts, props, labels, annotations or extra versions of the same part.
If an edit mask is supplied, transparent mask pixels are editable. If no edit If an edit mask is supplied, transparent mask pixels are editable. Otherwise,
mask is supplied, this request is only an adapter experiment, not a guaranteed use the task's bounded extension instructions and keep the remaining background
pixel-preserving final asset. A later stage will restore source visible pixels. pure white or transparent. Return the same canvas size without recentering.
Downstream checks reject misalignment and constrain modifications to edit regions,
restore protected source pixels, and reverse the documented canvas transform.
Do not rely on restoration to hide movement or redesign.
+19 -5
View File
@@ -3,18 +3,32 @@ Return a JSON part plan, not an image. Preserve the exact pose, asymmetry, cloth
and screen-left/screen-right orientation. Do not require a fixed number of parts. and screen-left/screen-right orientation. Do not require a fixed number of parts.
Use stable lowercase ASCII IDs (letters, numbers, underscores, hyphens). Use stable lowercase ASCII IDs (letters, numbers, underscores, hyphens).
Separate parts only where independent motion is useful and the image supports it. Separate parts only where independent motion is useful and the image supports it.
Parent refers to the motion hierarchy; null means attached to the future root bone. You are a semantic planner, not a pixel-grounding detector.
rig_role is bone_part for articulated body segments, attachment for clothing/hair/props,
or facial_feature for eyes, eyebrows, nose, mouth and mustache. Facial features and
decorations do not each need a bone. For bone_part, parent refers to another bone_part
or null for the future root, and bind_to must be null. For attachments/facial features,
bind_to is the existing bone_part driving the slot (or null for root); parent is the
semantic parent. Bind sleeves to the appropriate upper arm, facial features to head.
Occlusion is a separate relation: occluded_by lists parts visually in front. Occlusion is a separate relation: occluded_by lists parts visually in front.
All coordinates are normalized to the full supplied canvas, including transparent All coordinates are normalized to the full supplied canvas, including transparent
margins. bbox_norm_xyxy is [xmin,ymin,xmax,ymax], never [x,y,width,height]. margins. bbox_norm_xyxy is [xmin,ymin,xmax,ymax], never [x,y,width,height].
pivot_norm is the estimated attachment joint, not necessarily the bbox center. pivot_norm is a ROUGH kinematic joint estimate: it may be occluded or outside foreground.
For visible or partly visible parts give a coarse visible bbox and positive points For visible or partly visible parts give only a coarse visible bbox.
inside visible pixels; optional negative points identify nearby other parts. Leave positive_points_norm=[] and negative_points_norm=[]. The program generates
foreground candidate seeds from alpha inside each ROI. Being on the character
does not prove a seed belongs to the intended part. Do not invent precision.
Do not claim precise pixel masks. A segmentation stage will refine boundaries. Do not claim precise pixel masks. A segmentation stage will refine boundaries.
If a part is fully hidden, mark visibility="hidden", bbox_norm_xyxy=null, If a part is fully hidden, mark visibility="hidden", bbox_norm_xyxy=null,
positive_points_norm=[], needs_completion=true and explain the uncertainty. positive_points_norm=[], needs_completion=true and explain the uncertainty.
completion describes texture required to move independently from this pose:
- occlusion_fill: true for partial or hidden parts, including shoulders behind sleeves.
- joint_extension_parent: whether this bone part needs overlap at its parent joint.
- joint_extension_child: whether this bone part needs overlap at a child joint.
Attachments/facial features have both joint_extension flags false.
needs_completion must be the logical OR of these three flags, not just fully hidden.
Do not invent hidden limbs or accessories without visual evidence. Put assumptions Do not invent hidden limbs or accessories without visual evidence. Put assumptions
in notes. Confidence is your uncertainty estimate, not a measured segmentation score. in notes. Confidence is your uncertainty estimate, not a measured segmentation score.
Avoid cycles in parent and occluded_by relations. If an object crosses in front Avoid cycles in parent and occluded_by relations. If an object crosses in front
and behind another, split its visual layers or flag the ambiguity in notes. and behind another, split its visual layers or flag the ambiguity in notes.
Return schema_version=1, coordinate_space="normalized_xy_top_left", parts, notes. Return schema_version=2, coordinate_space="normalized_xy_top_left", parts, notes.
+4 -2
View File
@@ -36,8 +36,10 @@ export async function decodeEditedImages(payload, stage, {fetchImpl = fetch} = {
const decoded = []; const decoded = [];
for (const item of items) { for (const item of items) {
let bytes; let bytes;
if (typeof item.b64_json === 'string') bytes = Buffer.from(item.b64_json, 'base64'); // Some relays return both fields, with an empty b64_json for URL output.
else if (typeof item.url === 'string') bytes = await downloadImage(item.url, stage, fetchImpl); // Only a nonempty inline value takes precedence over the download URL.
if (typeof item.b64_json === 'string' && item.b64_json.trim()) bytes = Buffer.from(item.b64_json.trim(), 'base64');
else if (typeof item.url === 'string' && item.url.trim()) bytes = await downloadImage(item.url.trim(), stage, fetchImpl);
else throw new Error('图片编辑响应缺少 b64_json/url'); else throw new Error('图片编辑响应缺少 b64_json/url');
decoded.push({bytes, ...imageFormat(bytes)}); decoded.push({bytes, ...imageFormat(bytes)});
} }

Some files were not shown because too many files have changed in this diff Show More