Compare commits
6 Commits
main
...
99d65377ab
| Author | SHA1 | Date | |
|---|---|---|---|
| 99d65377ab | |||
| f095c28858 | |||
| 1a7b2f14a9 | |||
| 4a49fe14e9 | |||
| 30e82af4f1 | |||
| 2e79bc4a12 |
@@ -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
|
||||
|
||||
@@ -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`。不要为了绕过依赖问题临时新建环境。
|
||||
- 验证结果必须如实记录;缺少依赖导致未运行的检查,不得声称通过。
|
||||
- 保留用户已有修改和实验产物,不覆盖旧实验输出。
|
||||
@@ -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 服务或注入响应,不调用真实付费模型。
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 中记录运行环境、权重哈希及子进程退出状态。
|
||||
@@ -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/`;保留本阶段的告警,不能通过生成隐藏纹理来修复错误的部件归属。
|
||||
@@ -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` 合并,因为新画布的像素坐标也会变化。
|
||||
@@ -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 请求/响应测试,本轮未执行真实图片编辑生成。此状态不等于视觉质量已验证。
|
||||
@@ -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()
|
||||
@@ -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'};
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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')}`; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {dataUrl} from './images.mjs';
|
||||
|
||||
const escape = (value) => String(value).replace(/[&<>"']/gu, (c) => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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>`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user