6 Commits

Author SHA1 Message Date
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
Ukawka 30e82af4f1 新方案v1角色结构理解 2026-09-17 17:44:24 +08:00
Ukawka 2e79bc4a12 路径调整 2026-09-15 23:47:33 +08:00
44 changed files with 4036 additions and 82 deletions
+16
View File
@@ -3,3 +3,19 @@ VECTOR_ENGINE_BASE_URL=https://your-vector-engine.example
VECTOR_ENGINE_API_KEY=replace-me
# Optional request timeout in milliseconds
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.
# Confirm with: node tools/model-check.mjs --list-models
SPINE_UNDERSTANDING_MODEL=gpt-6-astra
SPINE_COMPLETION_MODEL=qwen-image-3.0
# Local source + checkpoint root. SAM runs in a temporary ai-spine GPU process.
SAM_MODEL_DIR=F:/Study/SAM3
SAM_CODE_DIR=F:/Study/SAM3/sam3
+19
View File
@@ -0,0 +1,19 @@
# 项目修改约定
本文件适用于 `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`。不要为了绕过依赖问题临时新建环境。
- 验证结果必须如实记录;缺少依赖导致未运行的检查,不得声称通过。
- 保留用户已有修改和实验产物,不覆盖旧实验输出。
+244 -50
View File
@@ -1,63 +1,257 @@
# Genarrative / Genielabs Spine Animation Pipeline
# AI Spine 角色拆件与动画管线
这是独立目录,不修改 Genarrative。它只参考 Genarrative 的 VectorEngine 图片生成协议:后端环境变量提供 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`,使用 OpenAI-compatible `POST /v1/images/edits` multipart 请求。
本项目用于把单张 2D 角色参考图转换为可供 Spine 动画使用的语义部件、透明拆件和骨骼资源。
当前上游没有 `gpt-image-2.5`,脚本会把它映射为 `gpt-image-2`,并在 manifest 中保留 `requestedModel`。
当前正在实现的新管线分为三个核心阶段:
拆件提示词明确要求保留参考图的原始姿态、左右方向、遮挡关系和非对称结构,禁止镜像、拉直和生成对称备件。图片接口对 429/5xx 会自动重试 3 次;使用新输出目录即可重新生成,旧目录可用 `--resume` 复用已生成的拆件图。
1. 视觉 LLM 输出语义、层级、骨骼/附件分类、遮挡、粗框和粗关节;程序用 Alpha 检查 ROI 并生成候选种子。
2. 使用 SAM 根据语义、粗框和程序候选生成 mask,经语义/几何检查后从原图提取可见像素。
3. 使用图片编辑模型补全被遮挡的部件,再进行几何验收和 Spine 导出。
正式运行默认尝试 3 个通用拆件提示词变体,分别按“严格姿态保持”“Spine 生产拆件”“技术 ground truth”描述同一要求;脚本按前景部件数量选择最接近完整角色的一份。可用 `--attempts 1` 关闭多候选尝试。
目前第一、二阶段可运行,第三阶段已接入部件画布准备、图片编辑请求、候选几何检查及可见像素恢复。第二阶段在 `ai-spine` Conda 环境的临时子进程中加载本地 SAM 3.1 权重,使用 GPU 分割并生成半透明标注图、白底拆解图和透明部件。分割与补全结果仍需语义复核;新管线的 Spine 导出尚未完成。第三阶段已通过离线及 mock 接口验证,真实图片编辑质量尚待验证。
## 运行
仓库仍保留旧版“AI 生成白底拆件图 → OpenCV 拆分 → Genielabs 定位 → Spine 导出”流程,用于对照和回归测试。
```bash
node pipeline.mjs \
--reference /absolute/path/character.png \
--out-dir ./output/character-spine \
--genielabs-dir /absolute/path/spine-animation-ai \
--env-file /absolute/path/.env \
--image-model gpt-image-2.5 \
## 文档
- [项目修改约定](AGENTS.md):每次修改都必须遵守,Python 固定使用 `ai-spine` Conda 环境。
- [第一阶段:视觉 LLM 角色结构理解](docs/1-understanding-stage.md):执行流程、代码对应关系、数据格式和输出文件。
- [第二阶段:本地 SAM 分割](docs/2-segmentation-stage.md):环境、运行命令、权重适配、输出和质量边界。
- [第三阶段:部件补全](docs/3-completion-stage.md):局部画布、遮挡/关节延展区域、生成调用、可见像素恢复及几何检查。
- [模型阶段配置与实验说明](docs/model-stages.md):模型切换、协议适配、图片编辑探针、缓存和错误处理。
## 环境准备
### Node.js
第一阶段要求 Node.js 20 或更高版本,以及下方 Python 几何依赖;没有额外 npm 运行时依赖。
```powershell
node --version
conda run -n ai-spine npm test
```
### 中转站配置
在项目目录复制环境变量模板:
```powershell
Copy-Item .\.env.example .\.env
```
编辑 `.env`:
```dotenv
VECTOR_ENGINE_BASE_URL=https://router.genarrative.world/v1
VECTOR_ENGINE_API_KEY=你的令牌
# 可选:覆盖配置文件中的默认模型
SPINE_UNDERSTANDING_MODEL=deepseek-flash
SPINE_COMPLETION_MODEL=qwen-image-3.0
# 本地 SAM 根目录和源码仓库;分割结束后子进程退出
SAM_MODEL_DIR=F:/Study/SAM3
SAM_CODE_DIR=F:/Study/SAM3/sam3
```
模型 ID 会原样发送给中转站,不进行隐式别名转换。不要把 API Key 写进 JSON 配置或提交到版本库。
另一个中转站 Tiantoken 使用独立配置 [configs/tiantoken.json](configs/tiantoken.json)。在本地 `.env` 设置 `TIANTOKEN_BASE_URL=https://api.tiantoken.com` 和 `TIANTOKEN_API_KEY`,运行时增加 `--config .\configs\tiantoken.json`;原中转站配置保持独立。
```powershell
node .\tools\model-check.mjs --config .\configs\tiantoken.json --list-models
node .\tools\model-check.mjs --config .\configs\tiantoken.json --dry-run
```
该配置默认第一阶段使用 `gpt-6-astra`(Responses),第三阶段使用 `gpt-image-2`(images/edits),可通过 `TIANTOKEN_UNDERSTANDING_MODEL`、`TIANTOKEN_COMPLETION_MODEL` 或对应 CLI 参数覆盖。2026-09-20 已验证认证与模型列表访问,返回 113 个模型,含上述两个 ID;尚未验证实际理解、图片编辑或 Responses 图片生成工具能力。此配置不等于已接入 Astra 主导的第三阶段图片生成。
### Python 与外部项目
本项目固定使用 **Conda 环境 `ai-spine`**,不使用或创建 `.venv`。第一阶段的 Alpha 校验需要 Python 3、`Pillow`、`numpy`、`opencv-python`。
执行项目命令前先 `conda activate ai-spine`。Node 管线会检查 Python 属于该 Conda 环境;`--python` 也只能指定该环境的解释器。未激活环境或缺少依赖时会报错,不会自动创建环境或安装依赖。
旧版拆件和 Spine 导出还需要:
- Python 3;
- `Pillow`、`numpy`、`opencv-python`;
- 相邻目录中的 `spine-animation-ai` 项目。
当前本机验证组合为 Python 3.11、PyTorch 2.7.1 + CUDA 12.6、torchvision 0.22.1。在 `AI_spine` 目录补全依赖;已有此 CUDA 版本时跳过第一条安装命令:
```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
```
不激活终端也可明确指定安装目标:
```powershell
conda run -n ai-spine python -m pip install -r .\requirements.txt
```
检查解释器和依赖:
```powershell
conda run -n ai-spine python -c "import sys, cv2, PIL, numpy; print(sys.executable); print('Dependencies OK')"
```
`spine-animation-ai` 是外部只读依赖,使用时需要遵守其 PolyForm Noncommercial 许可证。
## 常用命令
以下命令默认在 `AI_spine` 目录、已激活的 `ai-spine` Conda 环境中运行。自动化可使用 `conda run -n ai-spine node ...`。
### 查询中转站模型
```powershell
node .\tools\model-check.mjs --list-models
```
### 检查本地配置
不请求模型,也不生成付费内容:
```powershell
node .\pipeline.mjs --mode annotated-parts `
--config .\configs\deepseek-qwen.json `
--reference ..\spine-animation-ai\demo\character_reference.png `
--dry-run
```
### 使用 DeepSeek Flash 理解角色结构
当前已验证可用的配置是 `deepseek-flash + chat-completions + stream=false`:
```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
```
重点检查输出目录中的:
- `report.html`:人工复核页面;
- `semantic-parts.json`:v2 语义计划,包含 `rig_role`、`bind_to`、三项 `completion`;
- `geometry-validation.json`:原始点诊断、框内前景比例、候选及阻塞原因;
- `segmentation-prompts.json`:程序生成的 SAM 候选提示;模型原始正负点不会传入;
- `validation.json`:本地结构校验结果;
- `manifest.json`:模型、输入、代码版本和产物记录。
输入保留画布尺寸。已有透明背景直接保留 Alpha;不透明白底只移除与边界连通的近白区域。其他背景需先提供抠图 PNG。候选来自粗框内前景的距离变换,不能保证属于目标部件,仍需语义复核。关节点不会吸附到前景。
已有结果可离线检查,无需再次请求模型:
```powershell
node .\tools\model-check.mjs `
--reference .\output\sombrero-understand-deepseek-flash\source.png `
--parts-file .\output\sombrero-understand-deepseek-flash\semantic-parts.json `
--out-dir .\output\sombrero-grounding-review
```
旧版 v1 计划会保守迁移到 v2,迁移推断写入 notes,仍需复核;原文件不覆盖。
### 使用 GPT-6 Astra 做对照实验
```powershell
node .\pipeline.mjs --mode annotated-parts `
--config .\configs\astra-qwen.json `
--reference ..\spine-animation-ai\demo\character_reference.png `
--out-dir .\output\sombrero-understand-astra `
--stop-after understand
```
当前中转站的 Astra 通道要求 Responses 协议和流式响应。若中转站返回 overloaded,应使用新的输出目录稍后重试。
### 临时覆盖理解模型
```powershell
node .\pipeline.mjs --mode annotated-parts `
--config .\configs\deepseek-qwen.json `
--understanding-model YOUR_EXACT_MODEL_ID `
--understanding-protocol chat-completions `
--stream false `
--reference .\reference.png `
--out-dir .\output\model-experiment `
--stop-after understand
```
每组模型实验应使用独立的输出目录,避免覆盖对照结果。只有输入、配置、代码和产物哈希全部一致时,才可以通过 `--resume` 复用结果。
### 使用本地 SAM 生成标注和拆解图
复用已有第一阶段结果,不重新请求 DeepSeek,也不需要中转站密钥:
```powershell
conda run --no-capture-output -n ai-spine node .\tools\model-check.mjs `
--stage segmentation `
--input-dir .\output\sombrero-grounding-v2 `
--out-dir .\output\sombrero-sam-local `
--sam-dir F:/Study/SAM3
```
打开输出目录的 `report.html`:`annotated.png` 是原图上的不同半透明颜色标注,`parts-sheet.png` 是白底拆解图,`parts/` 保存原像素透明部件。运行期间加载 GPU 权重,完成后子进程退出,不启动常驻服务。
加入 `--dry-run` 可检查环境与本地路径,不加载权重推理。输入必须含 `source.png`、v2 `semantic-parts.json`、`segmentation-prompts.json`。完整参数与质量检查见[第二阶段文档](docs/2-segmentation-stage.md)。
### 第三阶段:按部件补全
先从已有 SAM 输出准备画布、允许新增区域和提示词,不请求模型:
```powershell
node .\tools\model-check.mjs --stage completion `
--input-dir .\output\sombrero-sam31-v3 `
--out-dir .\output\sombrero-completion-prepare `
--completion-model qwen-image-3.0 `
--prepare-only
```
打开 `report.html` 检查部件和允许新增区域。以下命令对 `head` 发起一次部件编辑任务,再检查候选并恢复原图可见像素;会调用中转站图片模型:
```powershell
node .\tools\model-check.mjs --stage completion `
--input-dir .\output\sombrero-sam31-v3 `
--out-dir .\output\sombrero-completion-head `
--completion-model qwen-image-3.0 `
--part head
```
`--part` 可重复;省略则处理所有具备可见 mask、补全需求和允许区域的部件。当前 SAM 示例仍有错分,应先复核。补全不会自动纠正上游语义;通过几何检查的 PNG 保存到 `parts/`,仍需人工复核。完整参数、离线候选导入与限制见[第三阶段文档](docs/3-completion-stage.md)。
### 单独测试图片补全接口
此命令只验证图片编辑适配器并保存原始候选,不代表补全部件已经验收通过:
```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
```
### 运行旧版完整管线
```powershell
node .\pipeline.mjs `
--reference ..\spine-animation-ai\demo\character_reference.png `
--out-dir .\output\legacy-character `
--genielabs-dir ..\spine-animation-ai `
--image-model qwen-image-3.0 `
--attempts 1 `
--animations idle,walk,wave
```
脚本会依次执行:
旧流程依赖生成式拆件与传统 CV 定位,对低纹理、低分辨率和生成后外观变化较大的角色不稳定。其输出应通过 `assembled.png` 和 `preview-rigged.html` 人工检查后再导入 Spine。
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。
`matched` 依赖拆件图与参考图保持相同画风和可辨识纹理;低纹理部件会进入模板匹配回退,并进行白底归一化以避免透明像素产生 `NaN/inf`。`anchored` 只按拆件图的行布局做通用兜底,不应作为正式定位结果。
Python 环境探测顺序为显式 `--python`、Anaconda、Homebrew Python、系统 Python;要求导入 `cv2`、`PIL`、`numpy`。如果全部不可用,脚本会在本目录创建 `.venv` 并安装 `requirements.txt`。
脚本默认只读取本目录 `.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
```powershell
conda run -n ai-spine npm test
```
每个 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` 这类结果。
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)。
测试使用本地 mock HTTP 服务或注入响应,不调用真实付费模型。
+247
View File
@@ -0,0 +1,247 @@
"""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):
folder = Path(folder)
task = read_json(folder/'task.json')
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': []}
if list(candidate.size) != task['canvas_size']:
qa['reasons'].append('CANVAS_SIZE_MISMATCH')
return qa
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])
view = f'<img src="{task["image"]}">' if task.get('image') else ''
warnings = html.escape(', '.join(task.get('upstream_warnings', [])))
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></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>新增区域受允许掩码限制,原图可见像素逐像素恢复。几何通过仅表示候选可供复核,不代表遮挡和关节纹理已正确补全。上游 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, default=32)
parser.add_argument('--candidate')
parser.add_argument('--merged')
parser.add_argument('--qa')
args = parser.parse_args()
if args.action == 'prepare':
if not 128 <= args.size <= 4096 or not 1 <= args.radius <= 256:
parser.error('size must be 128..4096 and radius 1..256')
prepare(args.out, args.part, args.size, args.radius)
elif args.action == 'merge':
write_json(args.qa, merge_candidate(args.out, args.candidate, args.merged))
else:
finalize(args.out)
if __name__ == '__main__':
main()
+45
View File
@@ -0,0 +1,45 @@
{
"schema_version": 1,
"providers": {
"relay-vision": {
"protocol": "responses",
"base_url_env": "VECTOR_ENGINE_BASE_URL",
"api_key_env": "VECTOR_ENGINE_API_KEY",
"timeout_ms": 180000,
"max_retries": 2
},
"relay-image-edit": {
"protocol": "images-edits",
"base_url_env": "VECTOR_ENGINE_BASE_URL",
"api_key_env": "VECTOR_ENGINE_API_KEY",
"timeout_ms": 1200000,
"max_retries": 2,
"capabilities": {"multiple_images": false, "mask": false},
"image_field": "image",
"mask_editable": "transparent"
}
},
"stages": {
"understanding": {
"provider": "relay-vision",
"model_env": "SPINE_UNDERSTANDING_MODEL",
"default_model": "gpt-6-astra",
"prompt": "../prompts/understand-v1.txt",
"output_mode": "text-json",
"stream": true,
"params": {"store": false}
},
"completion": {
"provider": "relay-image-edit",
"model_env": "SPINE_COMPLETION_MODEL",
"default_model": "qwen-image-3.0",
"prompt": "../prompts/complete-v1.txt",
"params": {"n": 1}
},
"segmentation": {
"provider": "sam-local",
"model_dir_env": "SAM_MODEL_DIR",
"default_model_dir": "F:/Study/SAM3"
}
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"schema_version": 1,
"providers": {
"relay-vision": {
"protocol": "chat-completions",
"base_url_env": "VECTOR_ENGINE_BASE_URL",
"api_key_env": "VECTOR_ENGINE_API_KEY",
"timeout_ms": 180000,
"max_retries": 2
},
"relay-image-edit": {
"protocol": "images-edits",
"base_url_env": "VECTOR_ENGINE_BASE_URL",
"api_key_env": "VECTOR_ENGINE_API_KEY",
"timeout_ms": 1200000,
"max_retries": 2,
"capabilities": {"multiple_images": false, "mask": false},
"image_field": "image",
"mask_editable": "transparent"
}
},
"stages": {
"understanding": {
"provider": "relay-vision",
"model_env": "SPINE_UNDERSTANDING_MODEL",
"default_model": "deepseek-flash",
"prompt": "../prompts/understand-v1.txt",
"output_mode": "text-json",
"stream": false,
"params": {}
},
"completion": {
"provider": "relay-image-edit",
"model_env": "SPINE_COMPLETION_MODEL",
"default_model": "qwen-image-3.0",
"prompt": "../prompts/complete-v1.txt",
"params": {"n": 1}
},
"segmentation": {
"provider": "sam-local",
"model_dir_env": "SAM_MODEL_DIR",
"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"
}
}
}
+370
View File
@@ -0,0 +1,370 @@
# 第一阶段:视觉 LLM 角色结构理解
第一阶段把单张角色参考图转换为结构化的语义部件计划,供后续 SAM 分割、遮挡补全和 Spine 骨骼生成使用。
本阶段只做角色结构理解和粗标注,不生成精确 mask、透明拆件 PNG 或补全后的隐藏部件。
## 1. 输入、输出与执行链路
输入是一张完整的 PNG 角色参考图。核心输出是 <code>semantic-parts.json</code>,其中记录:
- 角色由哪些可动部件组成;
- 每个部件的语义名称;
- 部件之间的运动父子关系;
- 部件之间的视觉遮挡关系;
- 可见、部分可见或完全隐藏状态;
- 可见区域粗框、预计关节位置,骨骼/附件角色与绑定目标;
- 程序用 Alpha 校验后另行输出的 SAM 候选点;
- 是否需要在第三阶段补全;
- 模型对判断结果的自报置信度。
整体执行链路:
~~~text
角色原图
↓
保留 Alpha 或去除边界连通白底(不缩放裁剪)
↓
读取模型配置、提示词和输出 Schema
↓
构造视觉 LLM 请求
↓
DeepSeek Flash / GPT-6 Astra
↓
提取模型返回的 JSON 文本
↓
Schema 校验和部件关系校验
↓
semantic-parts.json
↓
Alpha / ROI 校验、距离变换候选点、segmentation-prompts.json
↓
生成 annotated.svg 和 report.html
↓
人工复核后交给 SAM 分割阶段
~~~
## 2. 命令入口
示例:
~~~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
~~~
第一步由以下两个文件负责:
1. [pipeline.mjs](../pipeline.mjs) 检测 <code>--mode annotated-parts</code>,把命令转交给新管线,不进入旧版“生成拆件图 → CV 定位”流程。
2. [tools/model-check.mjs](../tools/model-check.mjs) 解析参数、加载配置、应用命令行覆盖、检查输入并调用 <code>runUnderstanding()</code>。
## 3. 配置解析
当前提供两组实验配置:
| 配置 | 理解模型 | 协议 | 流式 |
| --- | --- | --- | --- |
| [configs/deepseek-qwen.json](../configs/deepseek-qwen.json) | <code>deepseek-flash</code> | <code>chat-completions</code> | <code>false</code> |
| [configs/astra-qwen.json](../configs/astra-qwen.json) | <code>gpt-6-astra</code> | <code>responses</code> | <code>true</code> |
[lib/config.mjs](../lib/config.mjs) 负责读取:
- JSON 配置文件;
- 项目 <code>.env</code> 或 <code>--env-file</code>;
- 当前 shell 环境变量;
- 模型、协议、输出模式、超时和重试设置;
- 阶段提示词;
- SAM checkpoint 路径。
模型选择优先级:
~~~text
命令行 --understanding-model
> stage.model
> SPINE_UNDERSTANDING_MODEL
> stage.default_model
~~~
Shell 环境变量优先于 <code>.env</code>。模型 ID 会原样传给中转站,不做隐式别名转换。
配置中的 <code>params</code> 不能覆盖模型、消息体、输入、流式选项和认证字段,避免实验配置改变基础请求语义或泄漏密钥。
## 4. 读取参考图
[lib/images.mjs](../lib/images.mjs) 读取参考图,并得到:
- 原始文件字节;
- MIME 类型;
- 图片宽高;
- SHA-256;
- 模型请求使用的 Base64 Data URL。
本阶段要求参考图为 PNG。原始输入保存为 <code>source-original.png</code>,模型实际看到的预处理 RGBA 保存为 <code>source.png</code>,不缩放、不裁剪。已有透明背景时保留原像素;不透明白底使用连通域仅去除与画布边界相连、RGB 各通道均 ≥240 的区域。其他背景或空前景会在请求模型前失败,需先提供抠图 PNG。去白底存在白色主体接触边界的局限,预处理记录保存在 <code>preprocessing.json</code>。
这一层调用 <code>normalize_reference.py</code> 和 <code>geometry_grounding.py</code>,固定使用 Conda 环境 <code>ai-spine</code> 及 requirements.txt 中的依赖。先执行 <code>conda activate ai-spine</code>;自动化使用 <code>conda run -n ai-spine node ...</code>。--python 也只能指定该环境的解释器,不创建其他环境。每次修改须遵守 [AGENTS.md](../AGENTS.md)。
## 5. 理解提示词
提示词位于 [prompts/understand-v1.txt](../prompts/understand-v1.txt),要求视觉 LLM:
- 保留角色原始姿势、朝向和不对称性;
- 仅在独立运动有意义且图像支持时拆分部件;
- 区分运动父子关系和视觉遮挡关系;
- 使用完整画布的归一化坐标;
- 输出语义、粗框、预计关节和 rig_role/bind_to;正负提示点留空;
- 不把粗框声称为像素级 mask;
- 不凭空创造没有视觉证据的隐藏肢体或附件;
- 将不确定性和假设写入 <code>notes</code>。
请求适配器还会附加原图画布尺寸和完整 JSON Schema,并要求模型只返回符合 Schema 的 JSON。最终提示词副本保存在 <code>prompt.txt</code>。
## 6. 输出数据契约
结构定义位于 [schemas/semantic-parts.schema.json](../schemas/semantic-parts.schema.json)。顶层格式:
~~~json
{
"schema_version": 2,
"coordinate_space": "normalized_xy_top_left",
"parts": [],
"notes": []
}
~~~
每个部件包含:
| 字段 | 含义 |
| --- | --- |
| <code>id</code> | 稳定、唯一的小写 ASCII 部件 ID |
| <code>semantic</code> | 部件语义说明 |
| <code>parent</code> | 运动层级中的父部件;<code>null</code> 表示未来挂到 root |
| <code>rig_role</code> | bone_part / attachment / facial_feature,附件不自动创建骨骼 |
| <code>bind_to</code> | 附件绑定的 bone_part;骨骼部件使用 parent,bind_to 为 null |
| <code>visibility</code> | <code>visible</code>、<code>partial</code> 或 <code>hidden</code> |
| <code>bbox_norm_xyxy</code> | 可见区域粗框 <code>[xmin, ymin, xmax, ymax]</code> |
| <code>pivot_norm</code> | 预计的关节或连接点 |
| <code>positive_points_norm</code> | 模型粗估点,仅为旧版诊断兼容保留,新请求留空 |
| <code>negative_points_norm</code> | 模型负点,仅保留作记录,不传给 SAM |
| <code>occluded_by</code> | 视觉上遮挡当前部件的其他部件 |
| <code>completion</code> | occlusion_fill / joint_extension_parent / joint_extension_child 三项 boolean |
| <code>needs_completion</code> | 三项 completion 的逻辑或;包括独立旋转需要的隐藏纹理和关节重叠 |
| <code>confidence</code> | LLM 自报置信度,范围 0~1 |
所有坐标都以完整画布左上角为原点并归一化到 0~1。例如:
~~~json
"bbox_norm_xyxy": [0.36, 0.36, 0.70, 0.62]
~~~
对应像素坐标:
~~~text
xmin = canvas_width × 0.36
ymin = canvas_height × 0.36
xmax = canvas_width × 0.70
ymax = canvas_height × 0.62
~~~
## 7. 构造视觉模型请求
[providers/vision.mjs](../providers/vision.mjs) 根据配置选择请求格式。
### Chat Completions
DeepSeek Flash 使用:
~~~text
POST /v1/chat/completions
~~~
一个用户消息中同时包含文本提示词、画布尺寸、Schema 和采用 Data URL 编码的角色原图。
### Responses
GPT-6 Astra 使用:
~~~text
POST /v1/responses
~~~
适配器把同一任务转换成 <code>input_text</code> 和 <code>input_image</code> 格式,因此模型业务逻辑与具体 API 协议保持分离。
<code>output_mode</code> 支持:
- <code>text-json</code>:通过提示词约束 JSON,再在本地严格校验;
- <code>json-object</code>:中转站确认支持后使用 JSON Object 模式;
- <code>json-schema</code>:中转站确认支持后使用 Structured Outputs。
当前已验证的 DeepSeek 配置使用 <code>text-json</code>。
## 8. 中转站通信
[providers/http.mjs](../providers/http.mjs) 负责:
- 拼接 base URL 和协议路由;
- 添加 Bearer Authorization;
- 控制请求超时;
- 对明确的 HTTP 429 和 5xx 有限重试;
- 解析普通 JSON 或完整 SSE;
- 拒绝被截断的流式响应;
- 记录请求 ID、模型、token、耗时和尝试次数;
- 从错误信息和落盘文件中脱敏 API Key。
超时或网络中断不会自动重试,因为服务端可能已经执行并计费。失败时会尽量生成 <code>response.error.json</code>,保存脱敏后的状态码、响应片段或网络错误码。
## 9. 提取与校验模型结果
[providers/vision.mjs](../providers/vision.mjs) 从不同协议的响应中提取模型文本,并拒绝:
- 模型 refusal;
- 非正常 <code>finish_reason</code>;
- 未完成的 Responses 状态;
- 没有结构理解文本的响应。
提取后的内容保存在 <code>response.txt</code>。
[lib/validate-parts.mjs](../lib/validate-parts.mjs) 首先去掉模型可能添加的 Markdown JSON 代码块,然后执行 JSON 解析和两层本地校验。
### Schema 校验
- 必填字段是否齐全;
- 类型与枚举是否正确;
- ID 格式是否合法;
- 坐标和置信度是否在 0~1;
- 部件数量是否在 1~64;
- 是否出现未知字段。
### 结构语义校验
- 部件 ID 不能重复;
- <code>parent</code> 和 <code>occluded_by</code> 必须引用已有部件;
- 部件不能引用自己;
- 父子关系和遮挡关系不能形成环;
- 可见部件必须具有正面积 bbox;
- 模型正点可为空;落在 bbox 外只产生警告并丢弃其提示作用;
- bind_to 必须引用骨骼部件,骨骼的 parent 也必须是骨骼部件;
- partial/hidden 必须声明 occlusion_fill,needs_completion 与三项补全字段一致;
- 附件和面部特征不能声明独立骨骼关节延展;
- 完全隐藏部件必须没有可见框和正点,并设置 <code>needs_completion=true</code>;
- 自报置信度低于 0.6 时产生人工复核警告。
校验结果写入 <code>validation.json</code>。只有校验通过时才会写出正式的 <code>semantic-parts.json</code>。
随后进行像素几何检查:以 <code>alpha > 0</code> 为前景,记录原始模型点是否落在前景和粗框内。在粗框与前景交集中,用距离变换选择远离轮廓和 ROI 边界的像素作为候选。像素坐标使用 floor(norm × size),1 映射到最后一个像素;候选归一化坐标使用像素中心。粗框按 floor/ceil 转为半开像素范围。
框内无前景、没有像素中心或 Alpha 不可用时标记 blocked,不生成点。隐藏部件标记 hidden。记录框内前景比例、连通域数量和候选到轮廓距离,不把候选声称为已确认的语义种子;原始 pivot 完全保留。
## 10. 可视化与人工复核
[lib/report.mjs](../lib/report.mjs) 将结构理解结果叠加到原图:
- 彩色矩形:模型预测的可见区域粗框;
- 红色实心圆:无效模型点;黄色实心圆:前景上的不可信模型点;
- 青色方块:程序生成的 Alpha 候选,仍需确认部件语义;
- 空心圆:部件 pivot;
- 虚线:当前部件到父部件的关系;
- 文本:部件 ID。
它会生成:
- <code>annotated.svg</code>:可缩放的原图标注;
- <code>report.html</code>:包含标注图、校验结果、部件表、模型说明和请求元数据的自包含检查页。
<code>status=completed</code> 只表示请求、解析和本地校验完成。成功结果仍设置 <code>quality_status=needs_review</code>,因为结构合法不等于语义和框位置一定正确。
人工复核至少应检查:
1. 是否遗漏身体、头部或关键肢体;
2. 左右肢体是否符合画面方向;
3. 是否过度拆分面部或服饰细节;
4. 父子关系能否支持预期动画;
5. 遮挡关系是否与原图一致;
6. bbox 和提示点是否落在正确部件上;
7. <code>needs_completion</code> 是否覆盖确实需要补全的部分。
## 11. 阶段调度与实验留档
[stages/model-stage.mjs](../stages/model-stage.mjs) 是第一阶段总调度器:
~~~text
读取图片
→ 计算输入、Schema、配置和代码指纹
→ 创建运行目录
→ 保存输入、提示词和请求摘要
→ 调用视觉 LLM
→ 保存原始响应和请求元数据
→ 提取并校验 JSON
→ 保存正式语义结果
→ 生成标注和检查报告
→ 更新 manifest
~~~
[lib/run-artifacts.mjs](../lib/run-artifacts.mjs) 负责输出目录、恢复校验、产物哈希和最终状态。
<code>--resume</code> 只在原图、模型、协议、参数、提示词、Schema、相关源代码和已有产物全部一致时复用结果。不同模型或提示词实验应使用不同输出目录。
## 12. 输出文件
| 文件 | 作用 |
| --- | --- |
| <code>source-original.png</code> | 原始输入副本 |
| <code>source.png</code> | 模型实际看到的预处理 RGBA |
| <code>preprocessing.json</code> | 背景处理方式和像素统计 |
| <code>geometry-validation.json</code> | 原始点诊断、框内前景比例、连通域、候选和阻塞原因 |
| <code>segmentation-prompts.json</code> | 语义、粗框、程序生成的正点;负点为空,requires_mask_qa=true |
| <code>prompt.txt</code> | 本次解析后的阶段提示词 |
| <code>request.json</code> | 可复现实验的请求摘要,不包含 API Key 或完整 Base64 请求体 |
| <code>response.raw.json</code> | 中转站原始成功响应 |
| <code>response.txt</code> | 从协议响应中提取的模型文本 |
| <code>request-metadata.json</code> | 请求模型、返回模型、request ID、usage、耗时、次数和 wire format |
| <code>validation.json</code> | Schema 与结构语义校验结果 |
| <code>semantic-parts.json</code> | 校验通过后的正式结构理解结果 |
| <code>annotated.svg</code> | 原图上的粗框、pivot、提示点和父子连线 |
| <code>report.html</code> | 用于人工语义复核的综合页面 |
| <code>manifest.json</code> | 状态、配置、输入哈希、代码版本、SAM 信息和全部产物哈希 |
| <code>response.error.json</code> | HTTP、SSE 或网络失败时的脱敏诊断信息;成功时不存在 |
<code>request.json</code> 是请求身份摘要,不等同于线上发送的完整 HTTP body。图片内容单独保存在 <code>source.png</code>,密钥不会写入产物。
## 13. 代码职责索引
| 文件 | 职责 |
| --- | --- |
| [pipeline.mjs](../pipeline.mjs) | 顶层入口;把 annotated-parts 模式转发到新管线 |
| [tools/model-check.mjs](../tools/model-check.mjs) | CLI 参数解析、配置解析入口、模型列表和阶段分发 |
| [configs/deepseek-qwen.json](../configs/deepseek-qwen.json) | DeepSeek 理解 + Qwen 补全实验预设 |
| [configs/astra-qwen.json](../configs/astra-qwen.json) | Astra 理解 + Qwen 补全实验预设 |
| [prompts/understand-v1.txt](../prompts/understand-v1.txt) | 第一阶段任务约束 |
| [schemas/semantic-parts.schema.json](../schemas/semantic-parts.schema.json) | 第一阶段输出数据契约 |
| [lib/config.mjs](../lib/config.mjs) | JSON、环境变量和阶段配置解析 |
| [lib/images.mjs](../lib/images.mjs) | 图片读取、格式识别、尺寸、哈希和 Data URL |
| [providers/vision.mjs](../providers/vision.mjs) | Chat Completions/Responses 请求构造与文本提取 |
| [providers/http.mjs](../providers/http.mjs) | HTTP、认证、超时、重试、SSE、元数据和脱敏 |
| [lib/validate-parts.mjs](../lib/validate-parts.mjs) | JSON 解析、Schema 校验和关系校验 |
| [lib/geometry.mjs](../lib/geometry.mjs) | Python 环境选择与几何阶段调用 |
| [geometry_grounding.py](../geometry_grounding.py) | Alpha/ROI 诊断与距离变换候选生成 |
| [normalize_reference.py](../normalize_reference.py) | RGBA 保留与边界连通白底去除 |
| [lib/report.mjs](../lib/report.mjs) | SVG 标注和 HTML 检查报告 |
| [lib/run-artifacts.mjs](../lib/run-artifacts.mjs) | 输出目录、恢复校验、状态和产物哈希 |
| [stages/model-stage.mjs](../stages/model-stage.mjs) | 第一阶段总调度与失败处理 |
## 14. 当前边界与下一阶段接口
第一阶段输出的是语义计划,而不是分割结果:
~~~text
视觉 LLM 输出
= 部件语义 + 层级 + rig_role/bind_to + 遮挡 + 粗框 + 粗关节 + 补全需求
视觉 LLM 不输出
= 可信的像素 mask + 透明拆件 + 隐藏区域补全 + 最终骨骼
~~~
已实现的[第二阶段](2-segmentation-stage.md)读取 <code>source.png</code>、<code>semantic-parts.json</code> 和 <code>segmentation-prompts.json</code>,仅处理 status=candidate 的可见部件。SAM 图像概念接口接收文本和框;程序候选点参与返回 mask 的排序,不作为点提示传入此接口。模型原始正负点不传给 SAM。候选始终 semantic_verified=false:框内可能混有多个部件,Alpha、重叠和连通域检查不能替代语义复核。
已有 v1 结果可以通过 <code>--parts-file 旧计划.json --reference 原图.png --out-dir 新目录</code> 离线迁移与校验,不请求模型。迁移按 ID 推断骨骼/附件类型,袖子优先绑定同侧上臂,面部绑定头部;部分可见部件开启遮挡补全,骨骼连接处保守建议延展。这些推断在 notes 标注为待复核,不替代模型或人工判断。输入计划哈希和所有新产物参与 resume 校验。
第一阶段 <code>manifest.json</code> 只记录 SAM 路径信息,<code>runtime_status=local_subprocess_not_probed</code>,第一阶段不加载 SAM。第二阶段单独探测 Conda/CUDA、严格加载本地权重,并在自己的 manifest 中记录运行环境、权重哈希及子进程退出状态。
+110
View File
@@ -0,0 +1,110 @@
# 第二阶段:本地 SAM 分割
本阶段读取第一阶段的语义计划,使用本地 SAM 3.1 图像检测分支生成部件 mask,从原图提取可见像素,并输出两张预览图。Python 固定使用 `ai-spine` Conda 环境,遵守 [AGENTS.md](../AGENTS.md)。
## 运行方式
在 `AI_spine` 目录执行:
```powershell
conda run --no-capture-output -n ai-spine node .\tools\model-check.mjs `
--stage segmentation `
--input-dir .\output\sombrero-grounding-v2 `
--out-dir .\output\sombrero-sam-local `
--sam-dir F:/Study/SAM3
```
输出目录必须是新目录。相同输入、代码、权重、参数及完整产物可加 `--resume` 复用;失败结果保留,另建目录重跑。独立分割不调用视觉 LLM 或图片编辑接口,不需要 API Key。
输入目录必须包含:
| 文件 | 要求 |
| --- | --- |
| `source.png` | 第一阶段预处理后的原尺寸 RGBA |
| `semantic-parts.json` | 通过结构校验的 v2 语义计划 |
| `segmentation-prompts.json` | 与计划 ID、画布匹配的程序候选提示 |
仅 `status=candidate` 的可见部件进入推理。hidden/blocked 记入跳过列表;无可用 SAM mask 的部件记录失败原因,不用矩形或候选点伪造分割。
可以在命令末尾加 `--dry-run` 检查代码路径、权重存在性、Conda、依赖导入和 CUDA;它不加载权重,因此不验证权重内容或完整推理显存需求。
如需从第一阶段接续运行,在原结构理解命令中使用 `--stop-after segment --sam-dir F:/Study/SAM3`。第二阶段输出到理解目录下的 `segmentation/`。原理解命令仍可能请求付费模型;已有计划优先使用上面的独立命令。
## 环境与权重
本机已经验证:Python 3.11、PyTorch `2.7.1+cu126`、torchvision `0.22.1+cu126`、NumPy `1.26.4`、OpenCV `4.11.0.86`、Windows Triton `3.3.1.post21`,GPU 为 RTX 3060 Laptop 6 GB。
环境补全命令见 [README](../README.md#python-与外部项目),依赖记录在 [requirements.txt](../requirements.txt)。程序不会自动安装依赖、创建环境或切换到 CPU。
默认布局:
```text
F:/Study/SAM3/
sam3/ 源码仓库
sam3/model_builder.py
sam3.1checkpoint/
sam3.1_multiplex.pt
```
路径优先级为 CLI 覆盖、环境变量、配置默认值。`SAM_MODEL_DIR` 指定根目录,`SAM_CODE_DIR` 可单独指定源码仓库。若 `.env` 中仍是旧路径,修改为上述路径或显式传入 CLI 参数。
| 参数 | 含义 |
| --- | --- |
| `--sam-dir` | SAM 根目录 |
| `--sam-code-dir` | 源码仓库目录,内部应包含 `sam3/model_builder.py` |
| `--sam-checkpoint` | 本地权重文件 |
| `--sam-confidence` | SAM 候选分数阈值,默认 0.2,不是语义正确率 |
| `--sam-precision` | 默认 bf16 自动混合精度;可设 fp32,可能需要更多显存 |
Node 先启动环境探测子进程,再启动一次推理子进程。模型只加载到后者;没有监听端口或常驻服务,正常结束、报错、超时或收到取消信号后退出。推理超时为 20 分钟。禁用 Hugging Face 在线下载,权重只能来自本地。
`sam_runtime.py` 从 multiplex checkpoint 提取 `detector.*`。SAM 3.1 的检测分支有三个特征尺度;图像 builder 的第四尺度原本会被 scalp 丢弃,因此适配器移除该未使用尺度并取消 scalp。忽略视频交互/传播 neck,随后 `strict=True` 加载所有实际使用参数;结构不匹配会报错。未修改外部 SAM 源码。
保留 FP32 参数和复数位置编码缓冲区,仅在推理上下文开启 BF16 autocast。不能直接把整个模型转换为 BF16,这会破坏复数缓冲区及上游要求 FP32 的解码器路径。
## 提示、选择与质量检查
1. 在白色背景上合成参考图,保持原尺寸;GPU 只编码一次图像。
2. 每个部件先用语义文本推理,再加入归一化粗框推理,保留两组候选。
3. 根据模型分数、粗框内比例、越出角色 Alpha 的比例及候选点命中情况排序。拒绝前景不足 4 像素或框内比例低于 10% 的结果。
4. 最终 mask 与原图 Alpha 前景取交集,从原图提取 RGB 和 Alpha,不重绘、不缩放。原始选中 mask 另存用于检查越界。
5. 记录漏分、重叠、碎片化、候选点未覆盖,以及不同部件 mask IoU ≥ 0.8 的疑似重复。
当前使用的 SAM 图像概念接口接受文本与框,不接受点。第一阶段的 Alpha 候选点仅用于 mask 排序;模型原始点仍只用于第一阶段诊断。这次 mask 来自真实 GPU 模型推理。
粗框、候选点及模型分数都不能保证部件语义正确。因此所有结果保持 `quality_status=needs_review`、`semantic_verified=false`。重叠 mask 保留,未强行分配成互斥部件,也未补全遮挡纹理。人工复核通过后才能继续补全或导出。
## 输出
| 文件 | 内容 |
| --- | --- |
| `annotated.png` | 原尺寸角色图,各候选部件用不同半透明颜色叠加,透明背景显示为白色 |
| `parts-sheet.png` | 白底拆解图,部件保持原尺寸、分开排布,并标注 ID 和颜色 |
| `parts/<id>.png` | 紧裁剪的 RGBA 部件,保留原图像素及 Alpha |
| `masks/<id>.png` | 原图尺寸黑白 mask,已裁掉 Alpha 背景 |
| `candidates/<id>-raw.png` | 选中的 SAM 原始 mask,裁掉背景前 |
| `unassigned-mask.png` | 白色表示没有被任何部件 mask 覆盖的角色像素 |
| `overlap-mask.png` | 白色表示被多个部件 mask 覆盖的像素 |
| `segmentation.json` | 裁剪坐标、拆解图坐标、颜色、质量指标、运行环境和模型加载信息 |
| `candidate-diagnostics.json` | 两种提示的候选分数、排序、拒绝原因及最终选择 |
| `report.html` | 两张预览、部件链接、告警、漏分及重复检查 |
| `worker.log` / `worker-error.json` | 子进程日志 / 失败诊断(发生错误时) |
| `manifest.json` | 状态、输入/代码/权重/产物哈希,完成时记录子进程已退出 |
`crop_xyxy` 使用原图坐标,右下角为开区间;`sheet_xyxy` 使用白底拆解图坐标。覆盖率是像素覆盖比例,不是准确率。
## 代码与验证记录
- [stages/segmentation-stage.mjs](../stages/segmentation-stage.mjs):输入校验、Conda 子进程、哈希、缓存、日志和失败处理。
- [sam_runtime.py](../sam_runtime.py):环境探测和本地权重严格加载。
- [sam_segment.py](../sam_segment.py):GPU 推理、两组候选选择和诊断。
- [segmentation_outputs.py](../segmentation_outputs.py):mask QA、原像素拆件、两张预览和报告。
- [tests/test_segmentation.py](../tests/test_segmentation.py):Alpha 越界、候选选择、提示完整性、像素保真和重叠/重复检查。
2026-09-18 实测 `output/sombrero-grounding-v2` → `output/sombrero-sam31-v3`:28 个部件返回 mask,严格加载 1130 个检测参数/缓冲张量;推理及渲染约 11.64 秒(不含环境探测和加载权重),PyTorch 峰值已分配显存约 4.97 GiB,结束后无 SAM 子进程。
这组结果覆盖原图前景约 67.18%,仍有 64286 像素未覆盖、30569 像素重叠。`shin_left` 和 `foot_left` 的 mask IoU 约 0.843,实际都分到了鞋子;躯干及其他肢体也存在碎片或错分。文本候选对照未解决这些问题。这说明推理和输出链路已跑通,但当前旧语义计划与提示策略尚不能产出合格的最终拆件;需要修正部件粗框并继续验证提示及 mask 语义。
同日 `conda run --no-capture-output -n ai-spine npm test` 的 17 项测试全部通过(包括 Python 分割回归用例);真实产物 `--resume` 哈希校验复用通过。测试通过仅验证程序行为,不表示上述分割质量合格。
后续已接入[第三阶段部件补全](3-completion-stage.md)。该阶段直接读取本目录的 `source.png`、`semantic-parts.json`、`segmentation.json` 和全画布 `masks/`;保留本阶段的告警,不能通过生成隐藏纹理来修复错误的部件归属。
+127
View File
@@ -0,0 +1,127 @@
# 第三阶段:部件补全
本阶段处理第一阶段声明的 `occlusion_fill`、`joint_extension_parent`、`joint_extension_child`,读取第二阶段的可见 mask,为部件生成局部画布,调用图片编辑模型并合并受限的新增像素。原图已可见区域的 RGBA 像素始终从原图恢复,不使用模型重绘的版本。
使用 [AGENTS.md](../AGENTS.md) 规定的 Conda `ai-spine` 环境,不需要额外安装依赖。第三阶段几何处理使用本地 Python;图片生成通过现有中转站 `images/edits` 接口完成。
切换 Tiantoken 中转站时,在命令中增加 `--config .\configs\tiantoken.json`,并删除示例中的 `--completion-model qwen-image-3.0`,或改为 `--completion-model gpt-image-2`。该配置读取独立的 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`,第三阶段默认 `gpt-image-2`。目前仅验证模型列表可访问,实际图片编辑能力尚未实测;`gpt-6-astra` 的 Responses 图片生成工具仍需另行适配。
## 命令
在 PowerShell 中进入项目并激活环境:
```powershell
Set-Location F:\Study\taonier\AI_spine
conda activate ai-spine
$runId = Get-Date -Format "yyyyMMdd-HHmmss"
```
只准备任务(离线、无需 API Key):
```powershell
node .\tools\model-check.mjs --stage completion `
--input-dir .\output\sombrero-sam31-v3 `
--out-dir ".\output\$runId-prepare" `
--completion-model qwen-image-3.0 `
--prepare-only
```
真正调用模型补全一个部件,需要 `.env` 中的中转站地址与密钥,会产生图片请求:
```powershell
node .\tools\model-check.mjs --stage completion `
--input-dir .\output\sombrero-sam31-v3 `
--out-dir ".\output\$runId-complete-head" `
--completion-model qwen-image-3.0 `
--part head
```
`--part head --part neck` 指定多个部件。省略 `--part` 时处理所有可用且需要补全的部件,每个部件单独发出请求;当前模型配置 `n=1`。程序串行调用,沿用提供方的超时及显式 429/5xx 重试策略,超时/网络中断不自动重复生成。发生请求错误保留候选和诊断,停止后续部件。
离线导入已经生成的候选(无需 API Key):
```powershell
node .\tools\model-check.mjs --stage completion `
--input-dir .\output\sombrero-sam31-v3 `
--out-dir ".\output\$runId-merge" `
--part head `
--candidates-dir .\completion-candidates
```
候选文件名为 `completion-candidates/head.png`,必须与对应任务的局部画布尺寸、坐标完全一致。准备与合并必须使用相同输入、`--completion-size` 和 `--extension-radius`。缺失候选记为 `missing_candidate`,不会联网补发。可将先前任务保存的原始候选复制为 `ID.png`,离线检查它,避免重复计费。
每个输出目录必须是新目录;`--resume` 仅复用已完整结束且输入、参数、源码和产物哈希一致的任务。准备模式与生成模式使用不同目录。失败任务不自动续发请求。
| 参数 | 作用 |
| --- | --- |
| `--input-dir` | SAM 输出目录,必须含 `source.png`、v2 计划、`segmentation.json`、`masks/<id>.png` |
| `--part ID` | 指定部件,可重复;省略时处理全部有补全需求的部件 |
| `--prepare-only` | 只准备画布、区域与提示词,不请求图片模型 |
| `--candidates-dir` | 离线读取 `ID.png` 候选,与 prepare-only 互斥 |
| `--completion-size` | 方形局部画布边长,默认 1024,128~4096;不缩放原像素 |
| `--extension-radius` | 新增区域距可见 mask 的最大距离,默认 32 像素,1~256 |
| `--completion-model` | 图片模型原始 ID,当前示例使用 qwen-image-3.0 |
| `--dry-run` | 检查输入文件、计划、参数和 Python 环境,不生成画布或请求模型 |
旧的 `--stage completion --image ... --prompt-file ...` 保留为独立接口探针,不能与 `--input-dir` 混用。`--stop-after` 目前仍只支持 understand/segment;第三阶段独立调用。
## 允许新增区域
候选不可以随意扩大部件。程序在原图坐标中构造以下区域,并始终排除原可见 mask:
- 遮挡补全:可见轮廓外 `extension-radius` 范围,与 `occluded_by` 指向的 SAM mask 的交集。
- 向父关节延展:同一距离范围,与本部件 `pivot_norm` 附近半径为 `extension-radius` 的圆盘的交集。
- 向子关节延展:同一距离范围,与直接子骨骼部件的 pivot 圆盘的交集。
这些是保守的几何候选区域,不是推断出的完整隐藏形状。缺少遮挡 mask、关节过远或没有子骨骼时,相应区域为空并记录告警。所有区域都为空时跳过该部件。完全隐藏或没有可见 SAM mask 的部件也跳过,不凭空生成完整肢体。
局部画布包含可见区域与允许新增区域,至少留 16 像素边距。`origin_xy` 记录局部左上角在原图中的坐标,可为负数;只有平移和补透明边,不进行缩放。所需范围放不下时失败,需增大画布。新增区域目前限定在原图画布范围内。
输入给模型的是白底可见部件。只有配置明确声明 `capabilities.mask=true` 时才发送 RGBA 编辑 mask(透明处可编辑);只有 `multiple_images=true` 时才发送第二张局部原图参考。默认配置二者均为 false,不假定中转站支持这些能力。无论是否发送编辑 mask,返回候选都经过本地允许区域约束。
## 返回候选的检查和合并
1. 尺寸必须与局部画布完全一致。拒绝自动缩放、重定位或裁切来迎合返回图。
2. 已有透明背景保留;不透明白底使用边界连通近白区域去底。无法提取前景则拒绝。
3. 使用原部件 Alpha ≥240 的像素检查对齐:候选保留率至少 95%,RGB 平均绝对误差不大于 30。无不透明锚点则拒绝。这只能发现明显偏移或重绘,不能保证语义正确。
4. 只保留允许区域中的新增像素,并去掉与原可见部分不连通的新增块。越界和不连通像素数量写入 QA。没有新增像素则拒绝。
5. 原可见 mask 内 RGBA 逐像素恢复;候选仅提供新增像素。记录各补全子区域新增数量及 `unfulfilled_regions`,存在空缺的要求不会冒充已完成。
6. 每个返回候选分别检查,选择第一个通过几何条件的候选。输出始终 `semantic_verified=false`、`quality_status=needs_review`。
上游 SAM mask 有碎片、重复或语义错分时会原样影响补全,告警保存在 JSON 和报告中。颜色/位置检查也不能验证隐藏纹理是否合理。白底去除可能影响贴边白色细节,仍需人工检查。
## 产物
| 路径 | 内容 |
| --- | --- |
| `completion-plan.json` | 所有已准备任务、区域及跳过原因 |
| `tasks/<id>/input.png` | 发给模型的白底局部部件 |
| `tasks/<id>/visible.png` | 从原图保存的 RGBA 可见部件,合并时恢复像素 |
| `tasks/<id>/context.png` | 同坐标局部原图参考 |
| `tasks/<id>/edit-mask.png` | RGBA 编辑 mask,透明处允许编辑 |
| `tasks/<id>/allowed-mask.png` | 白色表示允许新增像素的区域 |
| `tasks/<id>/<补全字段>.png` | 三种补全要求各自的允许区域 |
| `tasks/<id>/task.json` / `prompt.txt` | 坐标、补全需求、上游告警 / 实际提示词 |
| `tasks/<id>/candidate-N.*` | 模型或离线导入的原始候选,拒绝时也保留 |
| `tasks/<id>/qa-N.json` | 单候选检查结果与拒绝原因 |
| `tasks/<id>/merged-N.png` | 通过几何检查后的局部 RGBA,已恢复可见像素 |
| `parts/<id>.png` | 被选中的补全候选,紧裁剪透明 PNG |
| `completion-result.json` | 每部件状态、候选 QA、原图裁剪坐标和输出路径 |
| `report.html` | 原可见部件、允许新增区域、补全候选和告警 |
| `manifest.json` | 运行模式、网络请求标记、输入/代码/产物哈希及计数 |
`parts/` 只包含本次被选中且通过几何检查的补全部件。无需补全、未选中和被拒绝的部件不会被伪装成新补全结果;最终组装需结合原 SAM 部件及这里的替换文件。Spine 导出尚未接入。
## 实现和验证
[stages/completion-stage.mjs](../stages/completion-stage.mjs) 调度准备、请求、候选检查及缓存;[completion_geometry.py](../completion_geometry.py) 实现区域构造、Alpha 提取、原像素恢复和报告;[providers/image-edits.mjs](../providers/image-edits.mjs) 复用现有图片接口。
测试覆盖原尺寸坐标映射、Alpha 保真、三个补全字段、越界裁剪、尺寸/位移/空白候选拒绝、离线模式、显式多图与 mask 能力、错误诊断和缓存失效。使用 `conda run --no-capture-output -n ai-spine npm test` 执行,不调用真实付费模型。
最初接入时,sombrero-sam31-v3 输出可离线准备 17 个补全任务,11 个部件因无需补全而跳过。当时验证了真实输入的画布准备与 mock 编辑响应的完整合并链路,尚未执行真实 Qwen 图片生成。
本轮验证结果:22 项 Node 测试通过(其中包含 7 个 Python 补全用例),真实输入的 `--prepare-only` 和 `--resume` 均成功。准备产物位于 `output/sombrero-completion-prepared-v2/`,打开其中 `report.html` 可检查每个部件的画布与允许新增区域。
2026-09-20 用户运行 `output/20260918-183532/sam` 的补全任务时,head 的真实响应同时含有有效 `url` 和空字符串 `b64_json`。原解码器将空字符串误当图片,报“不支持的 PNG/JPEG/WebP 格式”。已修复为空值时使用 URL 下载,并覆盖空串、空白字符串及 null 的回归测试;22 项测试通过。
本次已从保存的 `complete/tasks/head/response.raw.json` 下载原生成结果,没有再次调用生成接口。图片保存在同级 `complete-recovered-candidates/head.png`。真实输出为 2048×2048,输入画布为 1024×1024,因此离线合并被 `CANVAS_SIZE_MISMATCH` 拒绝,报告位于 `complete-recovered-head-check/report.html`。这次格式解析问题已解决,但尺寸约束尚未通过;不能把已有 1024 输入任务的候选直接改用 `--completion-size 2048` 合并,因为新画布的像素坐标也会变化。
+172
View File
@@ -0,0 +1,172 @@
# 第一阶段:可替换模型接口与角色结构理解
本轮实现结构理解、图片编辑适配器、结构 JSON 校验、Alpha 几何校验、候选种子、诊断预览和实验缓存。几何层需要 Python 与 requirements.txt,不运行旧的“拆件图 → SIFT 定位”流程。
结构理解支持停在 `understand`,也可通过 `--stop-after segment` 接续本地 SAM 分割,或使用 `--stage segmentation --input-dir` 独立执行第二阶段。第三阶段使用 `--stage completion --input-dir SAM目录` 完成部件画布准备、候选生成、限制新增区域和原像素恢复,详见[第三阶段文档](3-completion-stage.md)。掩码语义修正、补全的语义验收和 Spine 骨骼导出尚未完成。旧的 `--image --prompt-file` 图片编辑探针仍只保存原始候选。
## 环境与模型
要求 Node.js 20 或更高版本,无新增 npm 依赖。在 `AI_spine` 目录、已激活的 `ai-spine` Conda 环境中运行。每次修改须遵守 [AGENTS.md](../AGENTS.md)。
复用现有 `.env` 的地址和密钥,可增加:
```dotenv
SPINE_UNDERSTANDING_MODEL=gpt-6-astra
SPINE_COMPLETION_MODEL=qwen-image-3.0
SAM_MODEL_DIR=F:/Study/SAM3
```
不配置这三项时,`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-original.png` / `source.png` | 原始输入 / 预处理 RGBA,不缩放、不裁剪 |
| `preprocessing.json` | Alpha 保留或边界连通白底去除记录 |
| `semantic-parts.json` | 已通过结构校验的部件清单 |
| `geometry-validation.json` | 原始点 Alpha 检查、ROI 前景比例、候选来源和阻塞原因 |
| `segmentation-prompts.json` | 语义 + 粗框 + 程序候选点;不转发模型原始点,始终需要 mask QA |
| `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,输出也不是自动批准的骨骼。
v2 计划新增 `rig_role`、`bind_to` 和 `completion`(遮挡补全、父关节延展、子关节延展),`needs_completion` 必须等于三项逻辑或。新提示词让模型正负点留空;旧点仅用于诊断,程序从 ROI ∩ Alpha 前景重新生成候选,pivot 不参与前景吸附。空 ROI 标记 blocked,不生成替代点。
使用 `--parts-file semantic-parts.json --reference source.png --out-dir 新目录` 可离线迁移 v1 或检查 v2,无需密钥或网络。Python 固定使用 Conda 环境 `ai-spine`,`--python` 也必须指向该环境。依赖安装命令为 `conda run -n ai-spine python -m pip install -r requirements.txt`,完整说明见 README。背景预处理无法获得可用 Alpha 时在模型请求前失败。边界连通白底去除是一种保守启发式,贴到画布边缘的白色角色区域仍需人工检查。
校验失败保存原始响应、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、部件补全和补全探针各自记录独立缓存。SAM 额外校验输入计划、候选提示、本地源码和权重哈希;部件补全校验语义计划、所有输入 mask、画布参数、源码及离线候选哈希。对比补全模型时,固定相同局部画布、任务文本与掩码。
显式 429/5xx 最多按配置重试,超时或网络断开不自动重试,因为服务端可能已经执行任务。完整请求不保证模型内容可复现;seed 只在提供方确认支持时放入 params,usage 不存在时记录 null,不估造价格或 token 数。
## SAM 路径
默认根目录 `F:/Study/SAM3`:源码位于 `sam3/`,权重位于 `sam3.1checkpoint/sam3.1_multiplex.pt`。可通过 `--sam-dir`、`--sam-code-dir`、`--sam-checkpoint` 覆盖。独立分割无需中转站凭据,在 `ai-spine` Conda 临时子进程加载 GPU 模型,结束即退出。命令、权重适配及当前质量限制见[第二阶段文档](2-segmentation-stage.md)。
## 测试
```powershell
conda run -n ai-spine 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()
+96
View File
@@ -0,0 +1,96 @@
import {existsSync, readFileSync} from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export function readEnvFile(file) {
if (!existsSync(file)) return {};
const values = {};
for (const line of readFileSync(file, 'utf8').replace(/^\uFEFF/u, '').split(/\r?\n/u)) {
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/u);
if (!match) continue;
let value = match[2];
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
} else value = value.replace(/\s+#.*$/u, '');
values[match[1]] = value;
}
return values;
}
export function loadConfig({configFile, envFile, env = process.env} = {}) {
const file = path.resolve(configFile || path.join(projectRoot, 'configs', 'astra-qwen.json'));
const config = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/u, ''));
if (config.schema_version !== 1 || !config.providers || !config.stages) {
throw new Error('配置必须声明 schema_version: 1、providers 和 stages');
}
return {config, file, env: {...readEnvFile(envFile || path.join(projectRoot, '.env')), ...env}};
}
export function resolveStage(loaded, name, overrides = {}, {requireKey = true} = {}) {
const stage = loaded.config.stages[name];
if (!stage) throw new Error(`没有配置阶段:${name}`);
const provider = loaded.config.providers[stage.provider];
if (!provider) throw new Error(`没有配置提供方:${stage.provider}`);
const model = String(overrides.model || stage.model || loaded.env[stage.model_env] || stage.default_model || '').trim();
if (!model) throw new Error(`缺少 ${name} 的模型 ID`);
const baseUrl = String(loaded.env[provider.base_url_env] || '').trim().replace(/\/+$/u, '');
const apiKey = String(loaded.env[provider.api_key_env] || '').trim();
if (requireKey && (!baseUrl || !apiKey)) throw new Error(`需要配置 ${provider.base_url_env} 和 ${provider.api_key_env}`);
if (baseUrl) {
const url = new URL(baseUrl);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
throw new Error('提供方 base URL 必须是无用户名、密码、查询参数的 HTTP(S) 地址');
}
}
const protocol = overrides.protocol || provider.protocol;
const allowed = name === 'understanding' ? ['chat-completions', 'responses'] : ['images-edits'];
if (!allowed.includes(protocol)) throw new Error(`${name} 不支持协议:${protocol}`);
const params = {...stage.params, ...overrides.params};
// Semantic fields are owned by adapters, never overridden via opaque params.
for (const key of ['model', 'messages', 'input', 'instructions', 'prompt', 'image', 'image[]', 'mask', 'response_format', 'text', 'stream', 'Authorization', 'api_key', 'apiKey']) {
if (Object.hasOwn(params, key)) throw new Error(`params 不能覆盖保留字段:${key}`);
}
const timeoutMs = provider.timeout_ms ?? 180000;
const maxRetries = provider.max_retries ?? 2;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 5) {
throw new Error('timeout_ms 必须为正数,max_retries 必须为 0~5 的整数');
}
const outputMode = overrides.outputMode || stage.output_mode || 'text-json';
if (!['text-json', 'json-object', 'json-schema'].includes(outputMode)) throw new Error(`未知 output_mode:${outputMode}`);
const stream = overrides.stream ?? stage.stream ?? false;
if (typeof stream !== 'boolean') throw new Error('stream 必须为 boolean');
const promptPath = path.resolve(path.dirname(loaded.file), overrides.prompt || stage.prompt);
const capabilities = {multiple_images: false, mask: false, ...provider.capabilities};
if (typeof capabilities.mask !== 'boolean' || typeof capabilities.multiple_images !== 'boolean') {
throw new Error('capabilities.mask 和 multiple_images 必须为 boolean');
}
const imageField = provider.image_field || 'image';
if (!['image', 'image[]'].includes(imageField)) throw new Error('image_field 必须是 image 或 image[]');
return {
name, provider: stage.provider, protocol, model, baseUrl, apiKey, timeoutMs, maxRetries,
params, outputMode, stream, promptPath, prompt: readFileSync(promptPath, 'utf8'), capabilities,
imageField, maskEditable: provider.mask_editable || 'transparent',
};
}
export function publicStage(stage) {
return {
provider: stage.provider, protocol: stage.protocol, model: stage.model,
params: stage.params, output_mode: stage.outputMode, stream: stage.stream, timeout_ms: stage.timeoutMs,
max_retries: stage.maxRetries, capabilities: stage.capabilities,
image_field: stage.imageField, mask_editable: stage.maskEditable,
};
}
export function samInfo(loaded, overrides = {}) {
const stage = loaded.config.stages.segmentation || {};
const modelDir = overrides.samDir || loaded.env[stage.model_dir_env] || stage.default_model_dir || '';
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') : '');
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 {readFileSync} from 'node:fs';
import {createHash} from 'node:crypto';
import path from 'node:path';
export function sha256(value) { return createHash('sha256').update(value).digest('hex'); }
export function imageFormat(bytes) {
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
return {mime: 'image/png', extension: 'png', width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20)};
}
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return {mime: 'image/jpeg', extension: 'jpg'};
if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return {mime: 'image/webp', extension: 'webp'};
throw new Error('图片数据不是支持的 PNG/JPEG/WebP 格式');
}
export function readImage(file, {pngOnly = false} = {}) {
const bytes = readFileSync(file);
const format = imageFormat(bytes);
if (pngOnly && (format.extension !== 'png' || !format.width || !format.height)) throw new Error('结构理解与局部画布当前要求非空 PNG 图片');
return {file: path.resolve(file), bytes, ...format, sha256: sha256(bytes)};
}
export function dataUrl(image) { return `data:${image.mime};base64,${image.bytes.toString('base64')}`; }
+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;
}
+41
View File
@@ -0,0 +1,41 @@
import {dataUrl} from './images.mjs';
const escape = (value) => String(value).replace(/[&<>"']/gu, (c) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
export function annotatedSvg(image, parts, geometry = null) {
const {width: w, height: h} = image;
const stroke = Math.max(w, h) / 400;
const font = Math.max(w, h) / 65;
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 color = `hsl(${(index * 137.5) % 360} 85% 55%)`;
const [px, py] = [part.pivot_norm[0] * w, part.pivot_norm[1] * h];
const parent = byId.get(part.parent);
const line = parent ? `<line x1="${px}" y1="${py}" x2="${parent.pivot_norm[0] * w}" y2="${parent.pivot_norm[1] * h}" stroke-dasharray="${stroke * 3}"/>` : '';
let box = '';
if (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}"/>`;
}
const diagnostic = diagnostics.get(part.id);
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');
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, geometry = null) {
const svg = data && validation.valid ? annotatedSvg(image, data.parts, geometry) : `<img src="${dataUrl(image)}" alt="source">`;
const diagnostics = new Map((geometry?.parts || []).map((part) => [part.id, part]));
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>`;
}
+34
View File
@@ -0,0 +1,34 @@
import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
import path from 'node:path';
import {sha256} from './images.mjs';
import {redact} from '../providers/http.mjs';
export function jsonFile(file, value, secret = '') {
writeFileSync(file, `${redact(JSON.stringify(value, null, 2), secret)}\n`, 'utf8');
}
export function prepareRun(outDir, fingerprint, resume) {
const out = path.resolve(outDir);
const manifestPath = path.join(out, 'manifest.json');
if (existsSync(out) && readdirSync(out).length) {
if (!resume) throw new Error(`输出目录非空,请使用新目录或 --resume:${out}`);
if (!existsSync(manifestPath)) throw new Error('无法复用:目录没有 manifest.json');
const previous = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (previous.fingerprint !== fingerprint) throw new Error('无法复用:模型、配置、输入或代码版本变化,请使用新的输出目录');
if (previous.status !== 'completed') throw new Error('上次任务没有完整结束,请保留诊断文件并使用新目录重跑');
for (const [name, hash] of Object.entries(previous.artifact_hashes || {})) {
const file = path.join(out, name);
if (!existsSync(file) || sha256(readFileSync(file)) !== hash) throw new Error(`缓存产物缺失或被修改:${name}`);
}
if (!Object.keys(previous.artifact_hashes || {}).length) throw new Error('缓存缺少产物校验记录');
return {out, manifestPath, reused: true};
}
mkdirSync(out, {recursive: true});
return {out, manifestPath, reused: false};
}
export function finishRun(run, manifest, artifacts, secret) {
manifest.artifact_hashes = Object.fromEntries(artifacts.map((name) => [name, sha256(readFileSync(path.join(run.out, name)))]));
manifest.status = 'completed';
jsonFile(run.manifestPath, manifest, secret);
}
+141
View File
@@ -0,0 +1,141 @@
import {readFileSync} from 'node:fs';
import path from 'node:path';
import {projectRoot} from './config.mjs';
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
// the validation driven by that contract avoids two independent field lists.
function validate(value, schema, at, errors) {
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
const matches = (type) => {
if (type === 'null') return value === null;
if (type === 'array') return Array.isArray(value);
if (type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
if (type === 'integer') return Number.isInteger(value);
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
return typeof value === type;
};
if (!types.some(matches)) { errors.push(`${at}: 类型必须为 ${types.join('/')}`); return; }
if (schema.enum && !schema.enum.includes(value)) errors.push(`${at}: 值不在允许列表中`);
if (value === null) return;
if (typeof value === 'number') {
if (value < (schema.minimum ?? -Infinity) || value > (schema.maximum ?? Infinity)) errors.push(`${at}: 数值越界`);
}
if (typeof value === 'string') {
if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) errors.push(`${at}: 格式不合法`);
if (value.length < (schema.minLength ?? 0) || value.length > (schema.maxLength ?? Infinity)) errors.push(`${at}: 字符串长度不合法`);
}
if (Array.isArray(value)) {
if (value.length < (schema.minItems ?? 0) || value.length > (schema.maxItems ?? Infinity)) errors.push(`${at}: 数组长度不合法`);
value.forEach((item, index) => validate(item, schema.items, `${at}[${index}]`, errors));
} else if (typeof value === 'object') {
for (const key of schema.required || []) if (!Object.hasOwn(value, key)) errors.push(`${at}.${key}: 必填`);
for (const [key, item] of Object.entries(value)) {
if (schema.properties[key]) validate(item, schema.properties[key], `${at}.${key}`, errors);
else if (schema.additionalProperties === false) errors.push(`${at}.${key}: 未知字段`);
}
}
}
export function parseParts(text) {
const cleaned = text.trim().replace(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/u, '$1');
return JSON.parse(cleaned);
}
export function validateParts(data) {
const errors = [];
const warnings = [];
const legacy = data?.schema_version === 1;
validate(data, legacy ? legacySchema : partsSchema, '$', errors);
if (errors.length) return {valid: false, errors, warnings};
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 重复');
for (const part of data.parts) {
if (part.id === 'root') errors.push('root 为后续导出保留 ID');
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 (new Set(part.occluded_by).size !== part.occluded_by.length) errors.push(`${part.id}: occluded_by 重复`);
const box = part.bbox_norm_xyxy;
if (part.visibility === 'hidden') {
if (box !== null || part.positive_points_norm.length || !part.needs_completion) errors.push(`${part.id}: 完全隐藏部件应无可见框/正点,且需要补全`);
warnings.push(`${part.id}: 完全隐藏部件需要人工确认,不能直接生成可见 mask`);
} else {
if (!box || box[0] >= box[2] || box[1] >= box[3]) errors.push(`${part.id}: 可见 bbox 必须具有正面积`);
if (box) for (const [x, y] of part.positive_points_norm) {
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 (!errors.length) {
const parts = new Map(data.parts.map((part) => [part.id, part]));
for (const relation of ['parent', 'occluded_by']) {
const visiting = new Set();
const done = new Set();
const visit = (id) => {
if (visiting.has(id)) { errors.push(`${relation} 存在环:${id}`); return; }
if (done.has(id)) return;
visiting.add(id);
const part = parts.get(id);
const refs = relation === 'parent' ? [part.parent].filter(Boolean) : part.occluded_by;
refs.forEach(visit);
visiting.delete(id);
done.add(id);
};
ids.forEach(visit);
}
}
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
from pathlib import Path
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():
p = argparse.ArgumentParser()
p.add_argument('--input', required=True)
p.add_argument('--output', required=True)
p.add_argument('--target', type=int, default=1024)
p.add_argument('--remove-background', action='store_true')
a = p.parse_args()
im = Image.open(a.input).convert('RGBA')
if a.remove_background:
im, _ = prepare_rgba(im)
longest = max(im.width, im.height)
if longest < a.target:
scale = a.target / longest
+6 -1
View File
@@ -2,5 +2,10 @@
"name": "genarrative-genielabs-spine-pipeline",
"private": true,
"type": "module",
"scripts": {"run": "node pipeline.mjs"}
"scripts": {
"run": "node pipeline.mjs",
"models": "node tools/model-check.mjs --list-models",
"test": "node --test"
},
"engines": {"node": ">=20"}
}
+12 -26
View File
@@ -9,10 +9,12 @@
import {Buffer} from 'node:buffer';
import {spawnSync} from 'node:child_process';
import {cpSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import path from 'node:path';
import {selectPython} from './lib/python-runtime.mjs';
const repoRoot = path.dirname(new URL(import.meta.url).pathname);
const defaultGenielabsDir = '/Users/zhaoziyu/Documents/Codex/2026-09-10/diao/work/repos/spine-animation-ai';
const repoRoot = path.dirname(fileURLToPath(import.meta.url));
const defaultGenielabsDir = path.resolve(repoRoot, '..', 'spine-animation-ai');
const defaultModel = 'gpt-image-2';
const positivePrompt = [
'Create a complete 2D game sprite sheet for Spine animation from the exact reference character.',
@@ -56,12 +58,8 @@ function run(command, commandArgs, cwd = repoRoot) {
}
function resolveModel(model) {
const normalized = String(model || '').trim();
if (normalized === 'gpt-image-2.5' || normalized === 'gpt image 2.5') {
console.warn('提示:VectorEngine 当前没有 gpt-image-2.5,已兼容映射为 gpt-image-2。');
return defaultModel;
}
return normalized || defaultModel;
// Preserve exact provider IDs so experiments never silently use another model.
return String(model || '').trim() || defaultModel;
}
function endpoint(base, suffix) { return base.endsWith('/v1') ? `${base}/${suffix}` : `${base}/v1/${suffix}`; }
@@ -85,24 +83,6 @@ function loadEnvFile(file) {
return result;
}
function selectPython(requested) {
const candidates = [requested, '/Users/zhaoziyu/anaconda3/bin/python', '/opt/homebrew/bin/python3', 'python3'].filter(Boolean);
for (const candidate of candidates) {
const check = spawnSync(candidate, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore'});
if (check.status === 0) return candidate;
}
const venvPython = path.join(repoRoot, '.venv', 'bin', 'python');
if (!existsSync(venvPython)) {
const bootstrap = spawnSync('python3', ['-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) {
for (let attempt = 0; attempt < 3; attempt += 1) {
const form = new FormData();
@@ -247,6 +227,12 @@ function writeReferencePreview(reference, outputPath) {
}
async function main() {
const modeIndex = process.argv.indexOf('--mode');
if (modeIndex >= 0) {
if (process.argv[modeIndex + 1] !== 'annotated-parts') throw new Error('新增 --mode 仅支持 annotated-parts;旧流程无需 --mode');
const {main: modelMain} = await import('./tools/model-check.mjs');
return modelMain(process.argv.slice(2));
}
const options = args(process.argv);
if (!options.reference || !existsSync(options.reference) || !options.outDir || !options.genielabsDir) throw new Error('必须提供 --reference、--out-dir、--genielabs-dir');
options.python = selectPython(options.python);
+12
View File
@@ -0,0 +1,12 @@
Complete only the hidden portion of the specified character part on the first
input image (the local canvas). Any subsequent images are context references.
Preserve the exact original pose, scale, screen direction, perspective, colors,
line thickness and shading. Do not mirror, rotate, resize, recenter or redesign.
Keep the canvas dimensions and the position of every already-visible feature.
Extend the hidden joint area naturally behind its occluder, without adding other
body parts, props, labels, annotations or extra versions of the same part.
If an edit mask is supplied, transparent mask pixels are editable. Otherwise,
use the task's bounded extension instructions and keep the remaining background
pure white or transparent. Return the same canvas size without recentering.
Downstream checks will reject misalignment, restrict additions to allowed regions,
and restore the source visible pixels. Do not rely on restoration to hide changes.
+34
View File
@@ -0,0 +1,34 @@
Analyze the supplied single character image for a Spine cutout animation pipeline.
Return a JSON part plan, not an image. Preserve the exact pose, asymmetry, clothing,
and screen-left/screen-right orientation. Do not require a fixed number of parts.
Use stable lowercase ASCII IDs (letters, numbers, underscores, hyphens).
Separate parts only where independent motion is useful and the image supports it.
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.
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].
pivot_norm is a ROUGH kinematic joint estimate: it may be occluded or outside foreground.
For visible or partly visible parts give only a coarse visible bbox.
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.
If a part is fully hidden, mark visibility="hidden", bbox_norm_xyxy=null,
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
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
and behind another, split its visual layers or flag the ambiguity in notes.
Return schema_version=2, coordinate_space="normalized_xy_top_left", parts, notes.
+112
View File
@@ -0,0 +1,112 @@
export function endpoint(baseUrl, route) {
const base = baseUrl.replace(/\/+$/u, '');
return `${base.endsWith('/v1') ? base : `${base}/v1`}/${route}`;
}
export function redact(value, secret = '') {
let text = typeof value === 'string' ? value : JSON.stringify(value);
if (secret) text = text.split(secret).join('[REDACTED]');
return text.replace(/Bearer\s+[^\s"<>]+/giu, 'Bearer [REDACTED]');
}
// Some relay channels require SSE even for offline jobs. Accept a terminal
// Responses event (not only text deltas) so truncated streams cannot pass QA.
export function parseEventStream(raw) {
let terminal = null;
let text = '';
let model = null;
let usage = null;
let id = null;
let finishReason = null;
let refusal = '';
for (const block of raw.replace(/\r\n/gu, '\n').split(/\n\n/u)) {
const data = block.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
if (!data || data.trim() === '[DONE]') continue;
let event;
try { event = JSON.parse(data); } catch { throw new Error('SSE 包含不完整或非法 JSON 事件'); }
if (event.error || event.type === 'error') throw new Error(`SSE 服务端错误:${event.error?.message || event.message || 'unknown error'}`);
if (['response.completed', 'response.incomplete', 'response.failed'].includes(event.type)) {
if (!event.response || typeof event.response !== 'object') throw new Error('SSE 终止事件缺少 response');
terminal = event.response;
terminal.status ||= event.type.slice('response.'.length);
}
if (event.choices) {
model = event.model || model; id = event.id || id; usage = event.usage || usage;
const choice = event.choices.find((item) => (item.index ?? 0) === 0);
if (typeof choice?.delta?.content === 'string') text += choice.delta.content;
if (typeof choice?.delta?.refusal === 'string') refusal += choice.delta.refusal;
if (choice?.finish_reason) finishReason = choice.finish_reason;
}
}
if (terminal) return terminal;
if (finishReason) return {id, model, usage, choices: [{finish_reason: finishReason, message: {content: text, ...(refusal ? {refusal} : {})}}]};
throw new Error('SSE 未收到完整终止事件,可能连接中断;不接受部分输出');
}
// Retry only explicit 429/5xx responses. A timeout/network failure may already
// have generated and billed an image; do not automatically duplicate that job.
export async function requestJson(stage, route, makeRequest, {
fetchImpl = fetch,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
} = {}) {
const started = Date.now();
for (let attempt = 0; attempt <= stage.maxRetries; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), stage.timeoutMs);
let diagnostics = null;
try {
const request = makeRequest();
const response = await fetchImpl(endpoint(stage.baseUrl, route), {
...request,
headers: {...request.headers, Authorization: `Bearer ${stage.apiKey}`},
signal: controller.signal,
redirect: 'error',
});
const raw = await response.text();
diagnostics = {
http_status: response.status, request_id: response.headers.get('x-request-id'),
elapsed_ms: Date.now() - started, attempts: attempt + 1,
// Bounded diagnostics; normal successful artifacts retain full payloads.
response_body: raw.slice(0, 2_000_000), truncated: raw.length > 2_000_000,
};
let payload;
try { payload = JSON.parse(raw); } catch { payload = null; }
if (!response.ok) {
if ((response.status === 429 || response.status >= 500) && attempt < stage.maxRetries) {
clearTimeout(timer);
await sleep(Math.min(10000, 2000 * (2 ** attempt)));
continue;
}
const message = payload?.error?.message || `请求未成功 (${response.status})`;
throw new Error(redact(`HTTP ${response.status}: ${message}`, stage.apiKey));
}
const isSse = response.headers.get('content-type')?.includes('text/event-stream') || /^data:|\nevent:|^event:/u.test(raw);
if (!payload && isSse) payload = parseEventStream(raw);
if (!payload || typeof payload !== 'object') throw new Error('服务端没有返回 JSON 对象或完整 SSE');
if (payload.error) throw new Error(redact(`服务端错误:${payload.error.message || 'unknown error'}`, stage.apiKey));
return {payload, metadata: {
requested_model: stage.model, returned_model: payload.model || null,
request_id: response.headers.get('x-request-id') || payload.id || null,
usage: payload.usage || null, elapsed_ms: Date.now() - started, attempts: attempt + 1, wire_format: isSse ? 'sse' : 'json',
}};
} catch (error) {
const cause = error.cause?.code || error.cause?.message;
const failure = new Error(controller.signal.aborted
? `请求超时 (${stage.timeoutMs} ms),未自动重试;请检查中转站任务状态`
: redact(`${error.message}${cause ? ` (${cause})` : ''}`, stage.apiKey));
failure.diagnostics = diagnostics
? JSON.parse(redact(diagnostics, stage.apiKey))
: {elapsed_ms: Date.now() - started, attempts: attempt + 1, network_code: cause || null};
throw failure;
} finally { clearTimeout(timer); }
}
}
export async function downloadImage(url, stage, fetchImpl = fetch) {
const parsed = new URL(url);
if (!['https:', 'http:'].includes(parsed.protocol)) throw new Error('图片 URL 必须使用 HTTP(S)');
const response = await fetchImpl(parsed, {signal: AbortSignal.timeout(stage.timeoutMs)});
if (!response.ok) throw new Error(`下载图片失败:HTTP ${response.status}`);
// Never forward the relay API key to image download hosts.
return Buffer.from(await response.arrayBuffer());
}

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