Compare commits
1 Commits
30e82af4f1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3753f110f4 |
@@ -3,11 +3,3 @@ VECTOR_ENGINE_BASE_URL=https://your-vector-engine.example
|
|||||||
VECTOR_ENGINE_API_KEY=replace-me
|
VECTOR_ENGINE_API_KEY=replace-me
|
||||||
# Optional request timeout in milliseconds
|
# Optional request timeout in milliseconds
|
||||||
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1200000
|
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1200000
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Checkpoint location only; the SAM runtime is integrated in milestone 2.
|
|
||||||
SAM_MODEL_DIR=F:/Study/sam3.1
|
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
# Genarrative / Genielabs Spine Animation Pipeline
|
# Genarrative / Genielabs Spine Animation Pipeline
|
||||||
|
|
||||||
## 新增:可替换模型与角色结构理解
|
|
||||||
|
|
||||||
已新增 `--mode annotated-parts`,默认 GPT6Astra 做结构理解,Qwen Image 3.0 作为独立图片编辑适配器。模型、协议和提示词可分别配置;第一阶段输出 JSON、粗标注 SVG 和检查报告。SAM 分割与补全合并尚未接入。
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-astra --stop-after understand
|
|
||||||
```
|
|
||||||
|
|
||||||
完整配置、模型切换、独立图片编辑测试和产物说明见 [docs/model-stages.md](docs/model-stages.md)。以下为旧版拆件管线的说明。
|
|
||||||
|
|
||||||
这是独立目录,不修改 Genarrative。它只参考 Genarrative 的 VectorEngine 图片生成协议:后端环境变量提供 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`,使用 OpenAI-compatible `POST /v1/images/edits` multipart 请求。
|
这是独立目录,不修改 Genarrative。它只参考 Genarrative 的 VectorEngine 图片生成协议:后端环境变量提供 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`,使用 OpenAI-compatible `POST /v1/images/edits` multipart 请求。
|
||||||
|
|
||||||
模型 ID 原样传给中转站,不再进行隐式映射。可通过 `node tools/model-check.mjs --list-models` 查询当前服务实际列出的 ID。
|
当前上游没有 `gpt-image-2.5`,脚本会把它映射为 `gpt-image-2`,并在 manifest 中保留 `requestedModel`。
|
||||||
|
|
||||||
拆件提示词明确要求保留参考图的原始姿态、左右方向、遮挡关系和非对称结构,禁止镜像、拉直和生成对称备件。图片接口对 429/5xx 会自动重试 3 次;使用新输出目录即可重新生成,旧目录可用 `--resume` 复用已生成的拆件图。
|
拆件提示词是角色无关的通用模板:以参考图为唯一 ground truth,保留原始注册位置、左右方向、遮挡关系和非对称结构,只在自然关节/遮挡边界切分,禁止镜像、拉直、补全隐藏部件和生成重复备件。图片接口对 429/5xx 会自动重试 3 次;使用新输出目录即可重新生成,旧目录可用 `--resume` 复用已生成的拆件图。
|
||||||
|
|
||||||
正式运行默认尝试 3 个通用拆件提示词变体,分别按“严格姿态保持”“Spine 生产拆件”“技术 ground truth”描述同一要求;脚本按前景部件数量选择最接近完整角色的一份。可用 `--attempts 1` 关闭多候选尝试。
|
正式运行默认尝试 6 个通用拆件提示词变体,分别强调注册位置、关节边界、遮挡审计、保守拆分和像素保真;脚本按微碎片、巨型粘连、前景覆盖率和极端部件数量选择候选,不假定角色必须有固定部件数。可用 `--attempts 1` 只运行一个候选。
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
@@ -26,7 +16,7 @@ node pipeline.mjs \
|
|||||||
--out-dir ./output/character-spine \
|
--out-dir ./output/character-spine \
|
||||||
--genielabs-dir /absolute/path/spine-animation-ai \
|
--genielabs-dir /absolute/path/spine-animation-ai \
|
||||||
--env-file /absolute/path/.env \
|
--env-file /absolute/path/.env \
|
||||||
--image-model qwen-image-3.0 \
|
--image-model gpt-image-2.5 \
|
||||||
--animations idle,walk,wave
|
--animations idle,walk,wave
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -38,11 +28,11 @@ node pipeline.mjs \
|
|||||||
4. 生成 Spine 配置并运行 `build_spine_json.py`;
|
4. 生成 Spine 配置并运行 `build_spine_json.py`;
|
||||||
5. 运行 `make_atlas.py` 和 `generate_spine_player.py`。
|
5. 运行 `make_atlas.py` 和 `generate_spine_player.py`。
|
||||||
|
|
||||||
为避免“部件爆炸”,定位后默认只保留 SIFT 高置信部件和模板匹配分数不低于 `0.3` 的部件,最多保留 18 个;可用 `--min-score`、`--max-parts` 调整。若模型仍输出多套角色,先删除输出目录重新生成,不要直接把低质量拆件导入 Spine。
|
为避免“部件爆炸”,定位后默认只保留 SIFT 高置信部件和模板匹配分数不低于 `0.3` 的部件,最多保留 28 个;可用 `--min-score`、`--max-parts` 调整。多候选选择还会比较 Genielabs 定位置信度和部件框重叠,降低重复角色层被选中的概率。若模型仍输出多套角色,先删除输出目录重新生成,不要直接把低质量拆件导入 Spine。
|
||||||
|
|
||||||
`matched` 依赖拆件图与参考图保持相同画风和可辨识纹理;低纹理部件会进入模板匹配回退,并进行白底归一化以避免透明像素产生 `NaN/inf`。`anchored` 只按拆件图的行布局做通用兜底,不应作为正式定位结果。
|
`matched` 依赖拆件图与参考图保持相同画风和可辨识纹理;低纹理部件会进入模板匹配回退,并进行白底归一化以避免透明像素产生 `NaN/inf`。`anchored` 只按拆件图的行布局做通用兜底,不应作为正式定位结果。
|
||||||
|
|
||||||
旧流程的 Python 环境默认探测 `python`、`python3` 和项目 `.venv`;显式 `--python` 时只使用指定环境。要求导入 `cv2`、`PIL`、`numpy`。自动创建虚拟环境时区分 Windows 的 `Scripts/python.exe` 和 Unix 的 `bin/python`。新增的结构理解模式不需要 Python。
|
Python 环境探测顺序为显式 `--python`、Anaconda、Homebrew Python、系统 Python;要求导入 `cv2`、`PIL`、`numpy`。如果全部不可用,脚本会在本目录创建 `.venv` 并安装 `requirements.txt`。
|
||||||
|
|
||||||
脚本默认只读取本目录 `.env` 和当前 shell 环境,不会自动读取 Genarrative 的 secrets 文件。可以通过 `--env-file` 显式指定一个只在受控后端使用的环境文件。
|
脚本默认只读取本目录 `.env` 和当前 shell 环境,不会自动读取 Genarrative 的 secrets 文件。可以通过 `--env-file` 显式指定一个只在受控后端使用的环境文件。
|
||||||
|
|
||||||
@@ -71,3 +61,33 @@ Genielabs 以外部只读目录引用,需遵守其 PolyForm Noncommercial 许
|
|||||||
## 外部参考角色
|
## 外部参考角色
|
||||||
|
|
||||||
已下载 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)。
|
已下载 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)。
|
||||||
|
|
||||||
|
另外新增了高分辨率单角色回归素材,位于 `references/highres-characters/`。其中 `tiddybub-toon/crops-768/` 包含 6 个 768×1024、透明背景、CC0 的 Kenney Toon Characters 单角色图,适合测试头发、手臂、手、腿、鞋和机器人关节等不同结构。
|
||||||
|
|
||||||
|
## GPT Image 2 prompt 优化回归
|
||||||
|
|
||||||
|
已对原始 sombrero/cowboy 参考图执行 6 个通用 prompt 变体(每个变体一次请求),并对 6 个高分辨率角色执行最多 3 个候选:female-adventurer、male-adventurer、female-person、male-person、robot、zombie。所有 prompt 都不包含角色名称、左右手硬编码或单个案例的外观描述;候选只由参考图的像素、姿态、遮挡和非对称关系决定。
|
||||||
|
|
||||||
|
当前离线复评结果保存在被忽略的 `output/prompt-optimization/`:
|
||||||
|
|
||||||
|
| 参考角色 | 选中候选 | 拆件数 | 定位方式 | 备注 |
|
||||||
|
| --- | --- | ---: | --- | --- |
|
||||||
|
| cowboy | attempt-4 | 8 | 8 个 SIFT | 当前最佳;平均定位 0.793,重叠惩罚 0 |
|
||||||
|
| female-adventurer | attempt-3 | 16 | 2 个 SIFT + 模板回退 | 形体基本完整,仍需检查衣服和手套层 |
|
||||||
|
| male-adventurer | attempt-2 | 14 | 1 个 SIFT + 模板回退 | 可用作回归输入,细部依赖模板定位 |
|
||||||
|
| male-person | attempt-2 | 17 | 4 个 SIFT + 模板回退 | 需要检查头发、脸和躯干的层级 |
|
||||||
|
| robot | attempt-2 | 17 | 5 个 SIFT + 模板回退 | 机械关节拆分较清楚,部分小件会漂浮 |
|
||||||
|
| zombie | attempt-2 | 11 | 模板回退 | 仍有明显错层,不能视为通过 |
|
||||||
|
| female-person | attempt-1 | 21 | 3 个 SIFT + 模板回退 | 检测到完整人物副本风险,不能直接采用 |
|
||||||
|
|
||||||
|
综合对照图为 `output/prompt-optimization/reselection-contact-sheet.jpg`,机器报告为 `output/prompt-optimization/reselection-report.json`。这些评分用于缩小人工复核范围,不代表自动通过,也不会把完整人物副本误选为拆件。
|
||||||
|
|
||||||
|
如需在不调用图片 API 的情况下重新评估已有候选,可运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python reselect_candidates.py \
|
||||||
|
--out-root output/prompt-optimization \
|
||||||
|
--genielabs-dir /absolute/path/spine-animation-ai
|
||||||
|
```
|
||||||
|
|
||||||
|
本轮新的 GPT Image 请求在 female-person、robot 和 zombie 的后续尝试中收到 VectorEngine `403 user quota is not enough`,因此没有继续重试或伪造新结果。补充额度后,可直接用同一组 `prompts/v2-tested.json` 和 `--attempts 6` 继续 A/B 测试。
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"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.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"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.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# 第一阶段:可替换模型接口与角色结构理解
|
|
||||||
|
|
||||||
本轮实现 GPT6Astra 结构理解、Qwen Image 3.0 图片编辑适配器、结构 JSON 校验、粗标注预览、实验记录和缓存。新增模式不需要 Python,不运行旧的“拆件图 → SIFT 定位”流程。
|
|
||||||
|
|
||||||
结构理解当前停在 `understand`;SAM 分割、掩码修正、隐藏部件合并、Spine 骨骼导出是后续阶段。图片编辑测试保存的是原始候选,不是验收通过的最终资产。
|
|
||||||
|
|
||||||
## 环境与模型
|
|
||||||
|
|
||||||
要求 Node.js 20 或更高版本,无新增 npm 依赖。在 `AI_spine` 目录运行。
|
|
||||||
|
|
||||||
复用现有 `.env` 的地址和密钥,可增加:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
SPINE_UNDERSTANDING_MODEL=gpt-6-astra
|
|
||||||
SPINE_COMPLETION_MODEL=qwen-image-3.0
|
|
||||||
SAM_MODEL_DIR=F:/Study/sam3.1
|
|
||||||
```
|
|
||||||
|
|
||||||
不配置这三项时,`configs/astra-qwen.json` 已提供相同默认值。不会自动改写用户的 `.env`。
|
|
||||||
|
|
||||||
查询中转站模型 ID(只读取列表,不调用生成):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
node .\tools\model-check.mjs --list-models
|
|
||||||
```
|
|
||||||
|
|
||||||
模型展示名不等于 API ID。2026-09-17 的本机接口查询包含 `gpt-6-astra` 和 `qwen-image-3.0`。支持列表不保证所有端点/多图/mask 参数可用,需要分别验证。
|
|
||||||
|
|
||||||
本地配置检查(不联网,不生成图片):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
node .\tools\model-check.mjs --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
## 运行结构理解
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-astra --stop-after understand
|
|
||||||
```
|
|
||||||
|
|
||||||
首次调用会向配置的中转站发送参考图。每个实验使用新输出目录;本轮不会自动覆盖旧产物。
|
|
||||||
|
|
||||||
切换理解模型:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-other --understanding-model YOUR_EXACT_MODEL_ID --stop-after understand
|
|
||||||
```
|
|
||||||
|
|
||||||
切换协议(需中转站实际支持):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-responses --understanding-protocol responses --stop-after understand
|
|
||||||
```
|
|
||||||
|
|
||||||
协议可选 `chat-completions` / `responses`,默认 `responses`:本机中转站实测 Astra 的 codex channel 明确拒绝 `/v1/chat/completions`。输出模式默认 `text-json`:在提示词中提供 Schema,并始终在本地严格校验。显式确认中转站支持后,可使用 `--output-mode json-object` 或 `--output-mode json-schema`。不会因 400/不支持的协议自动换模型或偷偷降低输出约束。
|
|
||||||
|
|
||||||
该 Astra 通道还要求 `stream=true`,所以默认配置启用流式 Responses,并设置 `store=false`。适配器收集 SSE 后只接受完整的终止事件,输出仍是普通 JSON 文件。新模型可在阶段配置中设置 `stream: false`。`response.raw.json` 对 SSE 保存终止事件中的 response 对象,非逐字节 SSE 日志;`request-metadata.json` 记录 wire_format。
|
|
||||||
|
|
||||||
切换到非流式 Chat Completions 模型时可使用 `--understanding-protocol chat-completions --stream false`,不会沿用 Astra 的流式设置。
|
|
||||||
|
|
||||||
项目同时提供 `configs/deepseek-qwen.json`。它把第一阶段固定为
|
|
||||||
`deepseek-flash + chat-completions + stream=false`,第三阶段仍使用
|
|
||||||
`qwen-image-3.0`,适合与默认的 `configs/astra-qwen.json` 做对照实验:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
node .\pipeline.mjs --mode annotated-parts `
|
|
||||||
--config .\configs\deepseek-qwen.json `
|
|
||||||
--reference ..\spine-animation-ai\demo\character_reference.png `
|
|
||||||
--out-dir .\output\sombrero-understand-deepseek `
|
|
||||||
--stop-after understand
|
|
||||||
```
|
|
||||||
|
|
||||||
主要产物:
|
|
||||||
|
|
||||||
| 文件 | 含义 |
|
|
||||||
| --- | --- |
|
|
||||||
| `source.png` | 实际提交的原图,不缩放、不裁剪 |
|
|
||||||
| `semantic-parts.json` | 已通过结构校验的部件清单 |
|
|
||||||
| `annotated.svg` | 原图上的粗框、关节点、正点和父子连线,可直接浏览器打开 |
|
|
||||||
| `report.html` | 自包含检查页面,带部件表和模型记录 |
|
|
||||||
| `validation.json` | 字段、坐标、引用、父子环和遮挡环校验结果 |
|
|
||||||
| `response.raw.json` / `response.txt` | 原始服务端响应 / 提取的文本 |
|
|
||||||
| `prompt.txt` / `request.json` | 提示词 / 请求设置与输入哈希;图片内容单独保存在 source.png |
|
|
||||||
| `request-metadata.json` | requested/returned model、request ID、usage、耗时和请求次数 |
|
|
||||||
| `manifest.json` | 运行状态、配置、代码版本、源文件哈希和产物哈希 |
|
|
||||||
|
|
||||||
`status=completed` 仅表示这一步执行完成。`quality_status=needs_review` 表示仍需人工检查语义和框的位置。粗框不是像素 mask,输出也不是自动批准的骨骼。
|
|
||||||
|
|
||||||
校验失败保存原始响应、validation 和报告,退出码为非零,不继续分割。网络/API 失败保存失败 manifest。拒绝、截断和不完整 Responses 都不会被当作成功。
|
|
||||||
|
|
||||||
HTTP/SSE/网络失败还保存 `response.error.json`:状态码、请求 ID、耗时、尝试次数、响应体(最多 2 MB)或网络错误代码,密钥会脱敏。若服务端返回 overloaded,使用新目录稍后重跑;这不表示 schema 或分割模型出错。
|
|
||||||
|
|
||||||
## 独立测试图片编辑接口
|
|
||||||
|
|
||||||
先准备一个局部部件 PNG 和任务文本 `task.txt`,例如:
|
|
||||||
|
|
||||||
```text
|
|
||||||
The input canvas contains the screen-left forearm. Complete the hidden elbow
|
|
||||||
connection behind the sleeve. Keep the visible hand and forearm in their exact
|
|
||||||
positions. Preserve the original outline and shading. Do not add other parts.
|
|
||||||
```
|
|
||||||
|
|
||||||
调用 Qwen:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
node .\tools\model-check.mjs --stage completion --image .\local-part.png --prompt-file .\task.txt --out-dir .\output\qwen-edit-test --completion-model qwen-image-3.0
|
|
||||||
```
|
|
||||||
|
|
||||||
改 `--completion-model` 即可对比另一个兼容的图片编辑模型。此命令直接调用图片接口并可能计费,不调用结构理解。
|
|
||||||
|
|
||||||
结果保存 `candidate-1.png`(或服务端实际返回的 JPEG/WebP 扩展名)、输入图、任务提示词、原始响应和运行记录。PNG 输出会记录是否与输入画布同尺寸;尺寸相同不代表内容对齐。
|
|
||||||
|
|
||||||
默认图片适配器只发送一张图。若中转站确认支持多图,在配置中启用 `capabilities.multiple_images`,并使用重复 `--image`:第一张是局部画布,其余是原图/标注图参考。字段名通过 `image_field` 选择 `image` 或 `image[]`。
|
|
||||||
|
|
||||||
若确认支持编辑 mask,再启用 `capabilities.mask` 并使用 `--mask edit-mask.png`。当前适配器约定透明像素可编辑,要求 mask 与第一张图同尺寸、包含 alpha 通道。SAM 的普通黑白 mask 不能直接当作编辑 alpha mask。未启用能力时参数会明确报错,不静默忽略。
|
|
||||||
|
|
||||||
本轮没有恢复原始可见像素,也没有生成隐藏区域的透明掩码;因此候选始终标记 `raw_completion_probe` 和 `needs_review`,不能直接接入最终 atlas。
|
|
||||||
|
|
||||||
## 配置与切换规则
|
|
||||||
|
|
||||||
`configs/astra-qwen.json` 分离 `providers` 和 `stages`:
|
|
||||||
|
|
||||||
- provider:协议、base URL/key 的环境变量名、超时、重试次数、多图和 mask 声明。
|
|
||||||
- stage:provider 名称、模型、提示词文件、输出模式和模型特定参数。
|
|
||||||
- 提示词路径相对于配置 JSON 所在目录解析;命令行输入/输出路径相对于当前目录。
|
|
||||||
- 模型优先级:CLI > 阶段显式 `model` > `model_env` 环境变量 > `default_model`。
|
|
||||||
- shell 环境覆盖 `.env`。实验 JSON 不应存放密钥,仅引用密钥环境变量名。
|
|
||||||
- 不同服务可配置各自的 base URL/key 环境变量。
|
|
||||||
- 模型 ID 原样发送,不再把 `gpt-image-2.5` 映射成其他模型。
|
|
||||||
- `params` 允许模型特定参数,但不能覆盖 model、prompt、messages、input 等适配器保留字段。
|
|
||||||
- 新模式的超时/重试来自 provider 配置;旧模式的图片超时仍用 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`。
|
|
||||||
|
|
||||||
例如把理解阶段改为固定模型:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"provider": "relay-vision",
|
|
||||||
"model": "YOUR_EXACT_MODEL_ID",
|
|
||||||
"prompt": "../prompts/understand-v1.txt",
|
|
||||||
"output_mode": "text-json",
|
|
||||||
"params": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 重跑与对比
|
|
||||||
|
|
||||||
同一命令增加 `--resume`,仅当上次完整结束,且模型、协议、参数、提示词、Schema、源图、适配器代码和所有产物校验一致时复用。模型变化时要求新的输出目录,防止覆盖对照组。失败任务也保留原目录,使用新目录重跑。
|
|
||||||
|
|
||||||
本轮实现单阶段缓存;跨 SAM/补全/导出的依赖缓存留到后续阶段接入。对比补全模型时,固定相同局部画布、任务文本与掩码。
|
|
||||||
|
|
||||||
显式 429/5xx 最多按配置重试,超时或网络断开不自动重试,因为服务端可能已经执行任务。完整请求不保证模型内容可复现;seed 只在提供方确认支持时放入 params,usage 不存在时记录 null,不估造价格或 token 数。
|
|
||||||
|
|
||||||
## SAM 路径
|
|
||||||
|
|
||||||
默认 `F:/Study/sam3.1`,检查 `sam3.1_multiplex.pt` 是否存在。该目录 README 明确说明它仅提供 checkpoint,不是可直接调用的推理服务。本轮仅记录路径和 checkpoint 存在性,状态为 `not_integrated`,不加载权重、不占用 GPU。
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
测试使用本地 mock HTTP 服务或注入响应,不调用真实付费模型。覆盖模型优先级、协议请求/响应、multipart、能力限制、数值/引用/环校验、错误响应、重试、缓存和 CLI 全链路。
|
|
||||||
|
|
||||||
2026-09-17 的集成检查:模型列表请求成功;Astra 要求 Responses + stream=true,切换后服务端返回 overloaded,尚未得到成功的真实角色标注。Qwen 适配器通过 mock 请求/响应测试,本轮未执行真实图片编辑生成。此状态不等于视觉质量已验证。
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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) {
|
|
||||||
const stage = loaded.config.stages.segmentation || {};
|
|
||||||
const modelDir = loaded.env[stage.model_dir_env] || stage.default_model_dir || '';
|
|
||||||
const checkpoint = modelDir ? path.join(modelDir, 'sam3.1_multiplex.pt') : '';
|
|
||||||
return {model_dir: modelDir, checkpoint, checkpoint_exists: Boolean(checkpoint && existsSync(checkpoint)), runtime_status: 'not_integrated'};
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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')}`; }
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import {dataUrl} from './images.mjs';
|
|
||||||
|
|
||||||
const escape = (value) => String(value).replace(/[&<>"']/gu, (c) => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
|
||||||
|
|
||||||
export function annotatedSvg(image, parts) {
|
|
||||||
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 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 points = part.positive_points_norm.map(([x, y]) => `<circle cx="${x * w}" cy="${y * h}" r="${stroke * 1.5}" fill="${color}"/>`).join('');
|
|
||||||
return `<g fill="none" stroke="${color}" stroke-width="${stroke}">${line}${box}${points}<circle cx="${px}" cy="${py}" r="${stroke * 3}"/><text x="${Math.min(px + 4, w * 0.6)}" y="${Math.max(font, py - 5)}" fill="${color}" stroke="#111" stroke-width="${stroke / 2}" paint-order="stroke" font-size="${font}" font-family="sans-serif">${escape(part.id)}</text></g>`;
|
|
||||||
}).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) {
|
|
||||||
const svg = data && validation.valid ? annotatedSvg(image, data.parts) : `<img src="${dataUrl(image)}" alt="source">`;
|
|
||||||
const rows = data && validation.valid ? data.parts.map((part) => `<tr><td>${escape(part.id)}</td><td>${escape(part.parent ?? 'root')}</td><td>${escape(part.visibility)}</td><td>${escape(part.occluded_by.join(', '))}</td><td>${part.confidence}</td></tr>`).join('') : '';
|
|
||||||
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>角色结构理解检查</title><style>body{font:16px system-ui;max-width:1100px;margin:24px auto;padding:16px;background:#f5f5f5}svg,img{max-width:100%;height:auto;max-height:85vh}td,th{padding:8px;border:1px solid #ccc}table{border-collapse:collapse}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style><h1>角色结构理解</h1><p>此图仅包含模型预测的粗框、关节点(空心圆)和正点(实心圆),尚未执行 SAM 分割或补全。结构校验通过不代表语义正确。</p><p>状态:${validation.valid ? 'needs_review(待检查)' : 'failed'}</p>${svg}<h2>校验</h2><pre>${escape(JSON.stringify(validation, null, 2))}</pre><h2>部件</h2><table><tr><th>ID</th><th>父部件</th><th>可见性</th><th>遮挡物</th><th>自报置信度</th></tr>${rows}</table><h2>模型说明</h2><pre>${escape(JSON.stringify(data?.notes || [], null, 2))}</pre><h2>请求记录</h2><pre>${escape(JSON.stringify(metadata, null, 2))}</pre></html>`;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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'));
|
|
||||||
|
|
||||||
// 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 = [];
|
|
||||||
validate(data, partsSchema, '$', errors);
|
|
||||||
if (errors.length) return {valid: false, errors, warnings};
|
|
||||||
const ids = new Set(data.parts.map((part) => part.id));
|
|
||||||
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].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 (!part.positive_points_norm.length) errors.push(`${part.id}: 可见部件需要至少一个正点`);
|
|
||||||
if (box) for (const [x, y] of part.positive_points_norm) {
|
|
||||||
if (x < box[0] || x > box[2] || y < box[1] || y > box[3]) errors.push(`${part.id}: 正点不在可见框内`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (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};
|
|
||||||
}
|
|
||||||
+1
-6
@@ -2,10 +2,5 @@
|
|||||||
"name": "genarrative-genielabs-spine-pipeline",
|
"name": "genarrative-genielabs-spine-pipeline",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {"run": "node pipeline.mjs"}
|
||||||
"run": "node pipeline.mjs",
|
|
||||||
"models": "node tools/model-check.mjs --list-models",
|
|
||||||
"test": "node --test"
|
|
||||||
},
|
|
||||||
"engines": {"node": ">=20"}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+147
-39
@@ -8,29 +8,39 @@
|
|||||||
*/
|
*/
|
||||||
import {Buffer} from 'node:buffer';
|
import {Buffer} from 'node:buffer';
|
||||||
import {spawnSync} from 'node:child_process';
|
import {spawnSync} from 'node:child_process';
|
||||||
import {cpSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync} from 'node:fs';
|
import {cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
|
||||||
import {fileURLToPath} from 'node:url';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
const repoRoot = path.dirname(fileURLToPath(import.meta.url));
|
const repoRoot = path.dirname(new URL(import.meta.url).pathname);
|
||||||
const defaultGenielabsDir = path.resolve(repoRoot, '..', 'spine-animation-ai');
|
const defaultGenielabsDir = '/Users/zhaoziyu/Documents/Codex/2026-09-10/diao/work/repos/spine-animation-ai';
|
||||||
const defaultModel = 'gpt-image-2';
|
const defaultModel = 'gpt-image-2';
|
||||||
|
// These prompts are deliberately character-agnostic. The reference is the
|
||||||
|
// only source of truth; the model must not infer a conventional T-pose or fill
|
||||||
|
// in a hidden/missing counterpart. Keeping several phrasings is useful since
|
||||||
|
// image models sometimes satisfy one wording while ignoring another.
|
||||||
const positivePrompt = [
|
const positivePrompt = [
|
||||||
'Create a complete 2D game sprite sheet for Spine animation from the exact reference character.',
|
'Create a production-ready 2D cutout sheet for a skeletal animation rig from the supplied reference image.',
|
||||||
'Copy the exact single pose from the reference, including camera angle, body lean, limb bends, hand direction and occlusion.',
|
'Use the reference as the only ground truth. Preserve the exact camera view, proportions, relative scale, handedness, limb bends, body lean and every visible asymmetry.',
|
||||||
'Preserve every visible asymmetry, occlusion, limb bend, hand direction and body lean exactly as shown. Do not mirror, straighten, normalize or symmetrize the pose.',
|
'Make individually movable layers at natural articulation or occlusion boundaries: include only visible regions that actually exist in the reference (for example head, hair, face, hat, torso, clothing, sleeves, upper/lower limbs, hands, feet and accessories as applicable).',
|
||||||
'Output one set of isolated visible parts only. Do not add alternate poses, duplicate parts, extra props, effects or text. Keep the original style and lay parts on a white background.',
|
'Move cutout pieces apart by translation only. Keep each piece in its original orientation and preserve screen-left/screen-right ownership. Extract visible surfaces; do not invent a fully hidden limb or a mirrored counterpart.',
|
||||||
'Preserve the exact art style, face, shading and color palette; do not redesign the character.',
|
'Render one copy of every layer only. Do not mirror, straighten, normalize, symmetrize, pose, redesign, or complete the character. Do not move parts into a different pose or make a second version of any part.',
|
||||||
|
'Output isolated cutout pieces ONLY. Do not include the original assembled figure, a full-body preview, a reference thumbnail or a before/after comparison anywhere in the output. Keep eyes, pupils, eyebrows, nose and mouth painted on the face rather than emitting separate facial fragments.',
|
||||||
|
'Preserve the original rendering technique, linework, texture, shading and colors. Leave clear white gaps between every pair of pieces. Use a loose exploded arrangement following the original anatomy, with every piece fully inside the canvas. Do not join neighboring pieces just to preserve their original coordinates.',
|
||||||
|
].join(' ');
|
||||||
|
const negativePrompt = [
|
||||||
|
'assembled preview figure, reference thumbnail, duplicate complete character, redesign, new outfit, alternate pose, T-pose, mirrored pose, duplicated limb, invented hidden part, merged pieces, cut-off layers, isolated pupils, isolated facial features, scenery, background shadows, labels, arrows, text, watermark',
|
||||||
].join(' ');
|
].join(' ');
|
||||||
const negativePrompt = '3D, realistic, altered style, redesign, connected limbs, full body pose, scenery, shadows on background, missing limbs, merged layers, text, watermark';
|
|
||||||
const promptVariants = [
|
const promptVariants = [
|
||||||
positivePrompt,
|
positivePrompt,
|
||||||
`${positivePrompt} Use a production Spine decomposition: separate each visible upper limb, forearm, hand, thigh, shin, foot, head, hair and clothing layer while retaining the exact reference pose.`,
|
`${positivePrompt} Registration pass: keep scale and angle fixed across pieces; only their translation changes to open gaps. Retain the exact screen-left/screen-right ownership.`,
|
||||||
`Technical 2D cutout sheet. Treat the reference as ground truth. Extract only the parts that exist in the reference, with original left/right orientation and occlusion. Never invent a symmetric counterpart or a second version of any part. ${positivePrompt}`,
|
`${positivePrompt} Articulation pass: make cuts only at real joints, clothing seams or occlusion boundaries. Keep hands, shoes, hair and accessories as coherent shapes; never shatter a single shape into noise fragments.`,
|
||||||
|
`${positivePrompt} Occlusion audit: inspect what is actually visible before cutting. Move visible rear surfaces apart for this sheet; never add an assembled view to demonstrate how they fit together.`,
|
||||||
|
`${positivePrompt} Conservative pass: prefer fewer complete, useful layers over many tiny fragments. Each output layer must be a meaningful movable part with enough surrounding pixels for animation.`,
|
||||||
|
`${positivePrompt} Pixel-fidelity pass: preserve the reference's exact edge contours, facial features, costume details, colors and non-symmetric construction. Do not redraw or reinterpret the artwork.`,
|
||||||
];
|
];
|
||||||
|
|
||||||
function args(argv) {
|
function args(argv) {
|
||||||
const out = {reference: '', outDir: '', genielabsDir: defaultGenielabsDir, python: '', envFile: '', model: process.env.GENARRATIVE_SPINE_IMAGE_MODEL || defaultModel, size: '1024x1024', animations: 'idle,walk,wave', layoutMode: 'matched', minScore: 0.3, maxParts: 18, attempts: 3, resume: false};
|
const out = {reference: '', outDir: '', genielabsDir: defaultGenielabsDir, python: '', envFile: '', promptFile: '', model: process.env.GENARRATIVE_SPINE_IMAGE_MODEL || defaultModel, size: '1024x1024', animations: 'idle,walk,wave', layoutMode: 'matched', minScore: 0.3, maxParts: 28, attempts: 6, resume: false};
|
||||||
for (let i = 2; i < argv.length; i += 1) {
|
for (let i = 2; i < argv.length; i += 1) {
|
||||||
const key = argv[i];
|
const key = argv[i];
|
||||||
if (key === '--reference') out.reference = argv[++i] || '';
|
if (key === '--reference') out.reference = argv[++i] || '';
|
||||||
@@ -38,13 +48,14 @@ function args(argv) {
|
|||||||
else if (key === '--genielabs-dir') out.genielabsDir = argv[++i] || '';
|
else if (key === '--genielabs-dir') out.genielabsDir = argv[++i] || '';
|
||||||
else if (key === '--python') out.python = argv[++i] || 'python3';
|
else if (key === '--python') out.python = argv[++i] || 'python3';
|
||||||
else if (key === '--env-file') out.envFile = argv[++i] || '';
|
else if (key === '--env-file') out.envFile = argv[++i] || '';
|
||||||
|
else if (key === '--prompt-file') out.promptFile = argv[++i] || '';
|
||||||
else if (key === '--image-model') out.model = argv[++i] || defaultModel;
|
else if (key === '--image-model') out.model = argv[++i] || defaultModel;
|
||||||
else if (key === '--size') out.size = argv[++i] || '1024x1024';
|
else if (key === '--size') out.size = argv[++i] || '1024x1024';
|
||||||
else if (key === '--animations') out.animations = argv[++i] || 'idle';
|
else if (key === '--animations') out.animations = argv[++i] || 'idle';
|
||||||
else if (key === '--layout-mode') out.layoutMode = argv[++i] || 'matched';
|
else if (key === '--layout-mode') out.layoutMode = argv[++i] || 'matched';
|
||||||
else if (key === '--min-score') out.minScore = Number(argv[++i] || 0.55);
|
else if (key === '--min-score') out.minScore = Number(argv[++i] || 0.55);
|
||||||
else if (key === '--max-parts') out.maxParts = Number(argv[++i] || 18);
|
else if (key === '--max-parts') out.maxParts = Number(argv[++i] || 18);
|
||||||
else if (key === '--attempts') out.attempts = Math.max(1, Math.min(3, Number(argv[++i] || 3)));
|
else if (key === '--attempts') out.attempts = Math.max(1, Math.min(promptVariants.length, Number(argv[++i] || promptVariants.length)));
|
||||||
else if (key === '--resume') out.resume = true;
|
else if (key === '--resume') out.resume = true;
|
||||||
else if (key === '--help' || key === '-h') { console.log('用法:node pipeline.mjs --reference ref.png --out-dir ./output --genielabs-dir /path/to/spine-animation-ai'); process.exit(0); }
|
else if (key === '--help' || key === '-h') { console.log('用法:node pipeline.mjs --reference ref.png --out-dir ./output --genielabs-dir /path/to/spine-animation-ai'); process.exit(0); }
|
||||||
}
|
}
|
||||||
@@ -57,8 +68,12 @@ function run(command, commandArgs, cwd = repoRoot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveModel(model) {
|
function resolveModel(model) {
|
||||||
// Preserve exact provider IDs so experiments never silently use another model.
|
const normalized = String(model || '').trim();
|
||||||
return String(model || '').trim() || defaultModel;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function endpoint(base, suffix) { return base.endsWith('/v1') ? `${base}/${suffix}` : `${base}/v1/${suffix}`; }
|
function endpoint(base, suffix) { return base.endsWith('/v1') ? `${base}/${suffix}` : `${base}/v1/${suffix}`; }
|
||||||
@@ -83,17 +98,14 @@ function loadEnvFile(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectPython(requested) {
|
function selectPython(requested) {
|
||||||
const venvPython = path.join(repoRoot, '.venv', process.platform === 'win32' ? 'Scripts' : 'bin', process.platform === 'win32' ? 'python.exe' : 'python');
|
const candidates = [requested, '/Users/zhaoziyu/anaconda3/bin/python', '/opt/homebrew/bin/python3', 'python3'].filter(Boolean);
|
||||||
const candidates = requested ? [requested] : ['python', 'python3', venvPython];
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const check = spawnSync(candidate, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore'});
|
const check = spawnSync(candidate, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore'});
|
||||||
if (check.status === 0) return candidate;
|
if (check.status === 0) return candidate;
|
||||||
}
|
}
|
||||||
if (requested) throw new Error(`指定的 Python 无法导入 cv2、PIL、numpy:${requested};请在该环境安装 requirements.txt`);
|
const venvPython = path.join(repoRoot, '.venv', 'bin', 'python');
|
||||||
if (!existsSync(venvPython)) {
|
if (!existsSync(venvPython)) {
|
||||||
const bootstrapPython = ['python', 'python3'].find((candidate) => spawnSync(candidate, ['-c', 'import sys'], {stdio: 'ignore'}).status === 0);
|
const bootstrap = spawnSync('python3', ['-m', 'venv', path.join(repoRoot, '.venv')], {stdio: 'inherit'});
|
||||||
if (!bootstrapPython) throw new Error('未找到 Python,请激活环境或通过 --python 指定');
|
|
||||||
const bootstrap = spawnSync(bootstrapPython, ['-m', 'venv', path.join(repoRoot, '.venv')], {stdio: 'inherit'});
|
|
||||||
if (bootstrap.status !== 0) throw new Error('无法创建项目虚拟环境');
|
if (bootstrap.status !== 0) throw new Error('无法创建项目虚拟环境');
|
||||||
const install = spawnSync(venvPython, ['-m', 'pip', 'install', '-r', path.join(repoRoot, 'requirements.txt')], {stdio: 'inherit'});
|
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');
|
if (install.status !== 0) throw new Error('Python 依赖安装失败,请检查网络或手动安装 requirements.txt');
|
||||||
@@ -115,7 +127,7 @@ async function requestAtlas(reference, model, size, config, prompt) {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = payload.error?.message || 'unknown error';
|
const message = payload.error?.message || 'unknown error';
|
||||||
if ((response.status === 429 || response.status >= 500) && attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 2000)); continue; }
|
if ((response.status === 429 || response.status >= 500) && attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 2000)); continue; }
|
||||||
throw new Error(`VectorEngine 图片编辑失败(${response.status}):${message}`);
|
throw Object.assign(new Error(`VectorEngine 图片编辑失败(${response.status}):${message}`), {status: response.status});
|
||||||
}
|
}
|
||||||
const item = payload.data?.[0] || payload.images?.[0] || payload.output?.[0];
|
const item = payload.data?.[0] || payload.images?.[0] || payload.output?.[0];
|
||||||
if (!item) throw new Error('VectorEngine 未返回图片');
|
if (!item) throw new Error('VectorEngine 未返回图片');
|
||||||
@@ -189,8 +201,6 @@ function filterLayout(layoutPath, partsDir, minScore, maxParts) {
|
|||||||
}
|
}
|
||||||
for (const name of Object.keys(layout.parts || {})) {
|
for (const name of Object.keys(layout.parts || {})) {
|
||||||
if (!keep.has(name)) {
|
if (!keep.has(name)) {
|
||||||
const file = path.join(partsDir, `${name}.png`);
|
|
||||||
if (existsSync(file)) unlinkSync(file);
|
|
||||||
delete layout.parts[name];
|
delete layout.parts[name];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,6 +216,98 @@ function pngSize(file) {
|
|||||||
throw new Error('参考图目前需要 PNG 格式');
|
throw new Error('参考图目前需要 PNG 格式');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Score a generated sheet without assuming a particular character or an
|
||||||
|
// expected number of limbs. Count-only selection used to prefer exactly 12
|
||||||
|
// parts, which rewarded hallucinated duplicates on simple characters and
|
||||||
|
// dropped valid parts on unusual silhouettes. This score instead penalizes
|
||||||
|
// obvious failure modes: tiny noise components, one giant merged silhouette,
|
||||||
|
// implausible foreground coverage and extreme component counts.
|
||||||
|
function candidateQuality(stats) {
|
||||||
|
const width = Number(stats.atlas_width || 0);
|
||||||
|
const height = Number(stats.atlas_height || 0);
|
||||||
|
const area = Math.max(1, width * height);
|
||||||
|
const parts = Array.isArray(stats.parts) ? stats.parts : [];
|
||||||
|
const count = parts.length;
|
||||||
|
if (!count) return {score: Number.POSITIVE_INFINITY, count, tinyRatio: 1, giantRatio: 1, borderRatio: 1, coverage: 0};
|
||||||
|
const componentAreas = parts.map((part) => Math.max(0, Number(part.area || 0)));
|
||||||
|
const minUsefulArea = Math.max(500, area * 0.00045);
|
||||||
|
const tiny = componentAreas.filter((value) => value < minUsefulArea).length;
|
||||||
|
const giant = componentAreas.filter((value) => value > area * 0.42).length;
|
||||||
|
const border = parts.filter((part) => Number(part.x || 0) <= 1 || Number(part.y || 0) <= 1 || Number(part.x || 0) + Number(part.width || 0) >= width - 1 || Number(part.y || 0) + Number(part.height || 0) >= height - 1).length;
|
||||||
|
const coverage = componentAreas.reduce((sum, value) => sum + value, 0) / area;
|
||||||
|
const countPenalty = count < 4 ? (4 - count) * 5 : count > 28 ? (count - 28) * 1.5 : 0;
|
||||||
|
const tinyPenalty = (tiny / count) * 18;
|
||||||
|
const giantPenalty = giant * 20;
|
||||||
|
const borderPenalty = (border / count) * 8;
|
||||||
|
const coveragePenalty = coverage < 0.015 ? 8 : coverage > 0.88 ? 8 : 0;
|
||||||
|
return {
|
||||||
|
score: countPenalty + tinyPenalty + giantPenalty + borderPenalty + coveragePenalty,
|
||||||
|
count,
|
||||||
|
tinyRatio: tiny / count,
|
||||||
|
giantRatio: giant / count,
|
||||||
|
borderRatio: border / count,
|
||||||
|
coverage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateCandidatePlacement(reference, partsDir, outputPath, python, positionScript) {
|
||||||
|
const result = spawnSync(python, [positionScript, '--reference', reference, '--parts', partsDir, '--output', outputPath], {encoding: 'utf8'});
|
||||||
|
if (result.status !== 0 || !existsSync(outputPath)) return {score: 50, matchedParts: 0, meanMatch: 0, overlapPenalty: 0, error: (result.stderr || '').trim().slice(-400)};
|
||||||
|
try {
|
||||||
|
const layout = JSON.parse(readFileSync(outputPath, 'utf8').replace(/\b(?:NaN|Infinity|-Infinity)\b/gu, 'null'));
|
||||||
|
const parts = Object.values(layout.parts || {});
|
||||||
|
const matchScores = parts.map((part) => Number(part.score || 0)).filter(Number.isFinite);
|
||||||
|
let overlapPenalty = 0;
|
||||||
|
for (let i = 0; i < parts.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < parts.length; j += 1) {
|
||||||
|
const a = parts[i]; const b = parts[j];
|
||||||
|
const ix = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
|
||||||
|
const iy = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
|
||||||
|
const intersection = ix * iy;
|
||||||
|
const union = Math.max(1, a.width * a.height + b.width * b.height - intersection);
|
||||||
|
const iou = intersection / union;
|
||||||
|
if (iou > 0.55) overlapPenalty += (iou - 0.55) * 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const meanMatch = matchScores.length ? matchScores.reduce((sum, value) => sum + value, 0) / matchScores.length : 0;
|
||||||
|
const score = (1 - meanMatch) * 10 + overlapPenalty + Math.max(0, 5 - parts.length) * 2;
|
||||||
|
return {score, matchedParts: parts.length, meanMatch, overlapPenalty};
|
||||||
|
} catch (error) {
|
||||||
|
return {score: 50, matchedParts: 0, meanMatch: 0, overlapPenalty: 0, error: error.message};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairLayerOrder(layoutPath) {
|
||||||
|
const layout = JSON.parse(readFileSync(layoutPath, 'utf8').replace(/\b(?:NaN|Infinity|-Infinity)\b/gu, 'null'));
|
||||||
|
const order = [...(layout.z_order || Object.keys(layout.parts || {}))];
|
||||||
|
const index = new Map(order.map((name, i) => [name, i]));
|
||||||
|
const parts = layout.parts || {};
|
||||||
|
// Broad top accessories such as a hat brim should sit behind the face. A
|
||||||
|
// visual occlusion vote can invert this relation because both layers share
|
||||||
|
// similar colours. Enforce only the distinctive geometry pattern: a part
|
||||||
|
// above the canvas midpoint, at least 1.45x wider than a lower overlapping
|
||||||
|
// part. This remains character-agnostic and leaves hair/torso relations
|
||||||
|
// untouched in ordinary cases.
|
||||||
|
for (const [wideName, wide] of Object.entries(parts)) {
|
||||||
|
const canvasHeight = Number(layout.canvas_height || 0);
|
||||||
|
if (Number(wide.height) > Number(wide.width) * 0.65 || Number(wide.y) + Number(wide.height) / 2 > canvasHeight * 0.35) continue;
|
||||||
|
for (const [narrowName, narrow] of Object.entries(parts)) {
|
||||||
|
if (wideName === narrowName || Number(wide.y) >= Number(narrow.y) || Number(wide.width) < Number(narrow.width) * 1.45) continue;
|
||||||
|
const ix = Math.max(0, Math.min(wide.x + wide.width, narrow.x + narrow.width) - Math.max(wide.x, narrow.x));
|
||||||
|
const iy = Math.max(0, Math.min(wide.y + wide.height, narrow.y + narrow.height) - Math.max(wide.y, narrow.y));
|
||||||
|
const overlap = (ix * iy) / Math.max(1, Number(narrow.width) * Number(narrow.height));
|
||||||
|
if (overlap < 0.08) continue;
|
||||||
|
const wi = index.get(wideName); const ni = index.get(narrowName);
|
||||||
|
if (wi === undefined || ni === undefined || wi < ni) continue;
|
||||||
|
order.splice(wi, 1);
|
||||||
|
order.splice(order.indexOf(narrowName), 0, wideName);
|
||||||
|
order.forEach((name, i) => index.set(name, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout.z_order = order;
|
||||||
|
writeFileSync(layoutPath, JSON.stringify(layout, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
function buildAnchoredLayout(reference, partsDir, layoutPath) {
|
function buildAnchoredLayout(reference, partsDir, layoutPath) {
|
||||||
const source = pngSize(reference);
|
const source = pngSize(reference);
|
||||||
const manifest = JSON.parse(readFileSync(path.join(partsDir, 'parts-manifest.json'), 'utf8'));
|
const manifest = JSON.parse(readFileSync(path.join(partsDir, 'parts-manifest.json'), 'utf8'));
|
||||||
@@ -247,12 +349,6 @@ function writeReferencePreview(reference, outputPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
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);
|
const options = args(process.argv);
|
||||||
if (!options.reference || !existsSync(options.reference) || !options.outDir || !options.genielabsDir) throw new Error('必须提供 --reference、--out-dir、--genielabs-dir');
|
if (!options.reference || !existsSync(options.reference) || !options.outDir || !options.genielabsDir) throw new Error('必须提供 --reference、--out-dir、--genielabs-dir');
|
||||||
options.python = selectPython(options.python);
|
options.python = selectPython(options.python);
|
||||||
@@ -264,7 +360,7 @@ async function main() {
|
|||||||
for (const file of [positionScript, buildScript, atlasScript, playerScript]) if (!existsSync(file)) throw new Error(`Genielabs 脚本不存在:${file}`);
|
for (const file of [positionScript, buildScript, atlasScript, playerScript]) if (!existsSync(file)) throw new Error(`Genielabs 脚本不存在:${file}`);
|
||||||
const out = path.resolve(options.outDir); const parts = path.join(out, 'parts'); const atlasDir = path.join(out, 'atlas');
|
const out = path.resolve(options.outDir); const parts = path.join(out, 'parts'); const atlasDir = path.join(out, 'atlas');
|
||||||
mkdirSync(out, {recursive: true}); mkdirSync(parts, {recursive: true}); mkdirSync(atlasDir, {recursive: true});
|
mkdirSync(out, {recursive: true}); mkdirSync(parts, {recursive: true}); mkdirSync(atlasDir, {recursive: true});
|
||||||
const model = resolveModel(options.model); const config = envConfig(options.envFile);
|
const model = resolveModel(options.model);
|
||||||
const atlasPath = path.join(out, 'generated-parts-sheet.png');
|
const atlasPath = path.join(out, 'generated-parts-sheet.png');
|
||||||
writeFileSync(path.join(out, 'source-original.png'), readFileSync(options.reference));
|
writeFileSync(path.join(out, 'source-original.png'), readFileSync(options.reference));
|
||||||
const positionReference = path.join(out, 'reference-position.png');
|
const positionReference = path.join(out, 'reference-position.png');
|
||||||
@@ -273,10 +369,12 @@ async function main() {
|
|||||||
writeFileSync(path.join(out, 'manifest.json'), JSON.stringify({status: 'generating', model, requestedModel: options.model, pipelineVersion: 'genarrative-genielabs-spine-v1', source: path.basename(options.reference)}, null, 2));
|
writeFileSync(path.join(out, 'manifest.json'), JSON.stringify({status: 'generating', model, requestedModel: options.model, pipelineVersion: 'genarrative-genielabs-spine-v1', source: path.basename(options.reference)}, null, 2));
|
||||||
const candidateRoot = path.join(out, 'candidates'); mkdirSync(candidateRoot, {recursive: true});
|
const candidateRoot = path.join(out, 'candidates'); mkdirSync(candidateRoot, {recursive: true});
|
||||||
let selectedCandidate = null;
|
let selectedCandidate = null;
|
||||||
|
let candidateDiagnostics = [];
|
||||||
if (options.resume && existsSync(atlasPath) && existsSync(path.join(parts, 'parts-manifest.json'))) {
|
if (options.resume && existsSync(atlasPath) && existsSync(path.join(parts, 'parts-manifest.json'))) {
|
||||||
selectedCandidate = {atlas: atlasPath, parts};
|
selectedCandidate = {atlas: atlasPath, parts};
|
||||||
console.log(`复用已有拆件工作图:${atlasPath}`);
|
console.log(`复用已有拆件工作图:${atlasPath}`);
|
||||||
} else {
|
} else {
|
||||||
|
const config = envConfig(options.envFile);
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
for (let index = 0; index < options.attempts; index += 1) {
|
for (let index = 0; index < options.attempts; index += 1) {
|
||||||
const candidateDir = path.join(candidateRoot, `attempt-${index + 1}`); mkdirSync(candidateDir, {recursive: true});
|
const candidateDir = path.join(candidateRoot, `attempt-${index + 1}`); mkdirSync(candidateDir, {recursive: true});
|
||||||
@@ -284,13 +382,22 @@ async function main() {
|
|||||||
const candidateParts = path.join(candidateDir, 'parts'); mkdirSync(candidateParts, {recursive: true});
|
const candidateParts = path.join(candidateDir, 'parts'); mkdirSync(candidateParts, {recursive: true});
|
||||||
try {
|
try {
|
||||||
console.log(`尝试第 ${index + 1}/${options.attempts} 个通用拆件提示词`);
|
console.log(`尝试第 ${index + 1}/${options.attempts} 个通用拆件提示词`);
|
||||||
writeFileSync(candidateAtlas, await requestAtlas(options.reference, model, options.size, config, promptVariants[index % promptVariants.length]));
|
const prompt = options.promptFile ? readFileSync(options.promptFile, 'utf8') : promptVariants[index % promptVariants.length];
|
||||||
|
writeFileSync(path.join(candidateDir, 'prompt.txt'), `${prompt}\n\nAvoid: ${negativePrompt}\n`);
|
||||||
|
writeFileSync(candidateAtlas, await requestAtlas(options.reference, model, options.size, config, prompt));
|
||||||
run(options.python, [splitScript, '--atlas', candidateAtlas, '--output-dir', candidateParts]);
|
run(options.python, [splitScript, '--atlas', candidateAtlas, '--output-dir', candidateParts]);
|
||||||
const stats = JSON.parse(readFileSync(path.join(candidateParts, 'parts-manifest.json'), 'utf8'));
|
const stats = JSON.parse(readFileSync(path.join(candidateParts, 'parts-manifest.json'), 'utf8'));
|
||||||
const count = stats.parts.length; const score = Math.abs(count - 12) + (count > 24 ? count - 24 : 0);
|
const quality = candidateQuality(stats);
|
||||||
candidates.push({atlas: candidateAtlas, parts: candidateParts, count, score});
|
const placement = evaluateCandidatePlacement(positionReference, candidateParts, path.join(candidateDir, 'layout-eval.json'), options.python, positionScript);
|
||||||
console.log(`候选 ${index + 1}: ${count} 个部件,质量分 ${score}`);
|
const totalScore = quality.score + placement.score;
|
||||||
} catch (error) { console.warn(`候选 ${index + 1} 失败:${error.message}`); }
|
candidates.push({atlas: candidateAtlas, parts: candidateParts, count: quality.count, score: totalScore, quality, placement});
|
||||||
|
candidateDiagnostics.push({attempt: index + 1, count: quality.count, score: totalScore, quality, placement});
|
||||||
|
console.log(`候选 ${index + 1}: ${quality.count} 个部件,综合分 ${totalScore.toFixed(2)}(拆件 ${quality.score.toFixed(2)},定位 ${placement.score.toFixed(2)},匹配 ${(placement.meanMatch * 100).toFixed(0)}%,重叠罚分 ${placement.overlapPenalty.toFixed(2)})`);
|
||||||
|
} catch (error) {
|
||||||
|
candidateDiagnostics.push({attempt: index + 1, status: 'failed', error: error.message});
|
||||||
|
console.warn(`候选 ${index + 1} 失败:${error.message}`);
|
||||||
|
if (error.status === 401 || error.status === 403) break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!candidates.length) throw new Error('所有拆件候选均失败');
|
if (!candidates.length) throw new Error('所有拆件候选均失败');
|
||||||
selectedCandidate = candidates.sort((a, b) => a.score - b.score)[0];
|
selectedCandidate = candidates.sort((a, b) => a.score - b.score)[0];
|
||||||
@@ -301,6 +408,7 @@ async function main() {
|
|||||||
if (options.layoutMode === 'matched') {
|
if (options.layoutMode === 'matched') {
|
||||||
run(options.python, [positionScript, '--reference', positionReference, '--parts', parts, '--output', layout]);
|
run(options.python, [positionScript, '--reference', positionReference, '--parts', parts, '--output', layout]);
|
||||||
filterLayout(layout, parts, options.minScore, options.maxParts);
|
filterLayout(layout, parts, options.minScore, options.maxParts);
|
||||||
|
repairLayerOrder(layout);
|
||||||
} else {
|
} else {
|
||||||
buildAnchoredLayout(options.reference, parts, layout);
|
buildAnchoredLayout(options.reference, parts, layout);
|
||||||
}
|
}
|
||||||
@@ -315,8 +423,8 @@ async function main() {
|
|||||||
writeFileSync(skeleton, JSON.stringify(skeletonJson, null, 2));
|
writeFileSync(skeleton, JSON.stringify(skeletonJson, null, 2));
|
||||||
run(options.python, [atlasScript, '--parts', parts, '--output', atlasDir, '--name', 'skeleton']);
|
run(options.python, [atlasScript, '--parts', parts, '--output', atlasDir, '--name', 'skeleton']);
|
||||||
const riggedPreview = path.join(out, 'preview-rigged.html'); run(options.python, [playerScript, '--skeleton', skeleton, '--atlas', path.join(atlasDir, 'skeleton.atlas'), '--atlas-image', path.join(atlasDir, 'skeleton.png'), '--output', riggedPreview, '--animation', options.animations.split(',')[0] || 'idle']);
|
const riggedPreview = path.join(out, 'preview-rigged.html'); run(options.python, [playerScript, '--skeleton', skeleton, '--atlas', path.join(atlasDir, 'skeleton.atlas'), '--atlas-image', path.join(atlasDir, 'skeleton.png'), '--output', riggedPreview, '--animation', options.animations.split(',')[0] || 'idle']);
|
||||||
const preview = path.join(out, 'preview.html'); writeReferencePreview(options.reference, preview);
|
const preview = path.join(out, 'preview.html'); writeReferencePreview(path.join(out, 'assembled.png'), preview);
|
||||||
const manifest = {status: 'completed', pipelineVersion: 'genarrative-genielabs-spine-v1', requestedModel: options.model, model, source: path.basename(options.reference), attempts: options.attempts, selectedCandidate: selectedCandidate.count ?? null, promptVariants: promptVariants.length, outputs: {parts, layout, skeleton, atlas: atlasDir, preview, riggedPreview}, limitations: ['默认 preview.html 显示参考图的真实非对称姿态,保证形体结构正常;preview-rigged.html 显示自动拆件 rig,需人工校正。', '当前自动生成的是 region attachments;mesh/weights/IK 仍需在 Spine 中人工校正。', 'Genielabs 脚本为外部依赖,请按其 PolyForm Noncommercial 许可证处理商业授权。']};
|
const manifest = {status: 'completed', pipelineVersion: 'genarrative-genielabs-spine-v1', requestedModel: options.model, model, source: path.basename(options.reference), attempts: options.attempts, selectedCandidate: selectedCandidate.count ?? null, selectedCandidateScore: selectedCandidate.score ?? null, candidateDiagnostics, promptVariants: promptVariants.length, outputs: {parts, layout, skeleton, atlas: atlasDir, preview, riggedPreview}, limitations: ['默认 preview.html 显示参考图的真实非对称姿态,保证形体结构正常;preview-rigged.html 显示自动拆件 rig,需人工校正。', '当前自动生成的是 region attachments;mesh/weights/IK 仍需在 Spine 中人工校正。', 'Genielabs 脚本为外部依赖,请按其 PolyForm Noncommercial 许可证处理商业授权。']};
|
||||||
writeFileSync(path.join(out, 'manifest.json'), JSON.stringify(manifest, null, 2)); console.log(`Spine pipeline 完成:${out}`);
|
writeFileSync(path.join(out, 'manifest.json'), JSON.stringify(manifest, null, 2)); console.log(`Spine pipeline 完成:${out}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
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. If no edit
|
|
||||||
mask is supplied, this request is only an adapter experiment, not a guaranteed
|
|
||||||
pixel-preserving final asset. A later stage will restore source visible pixels.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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.
|
|
||||||
Parent refers to the motion hierarchy; null means attached to the future root bone.
|
|
||||||
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 the estimated attachment joint, not necessarily the bbox center.
|
|
||||||
For visible or partly visible parts give a coarse visible bbox and positive points
|
|
||||||
inside visible pixels; optional negative points identify nearby other parts.
|
|
||||||
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.
|
|
||||||
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=1, coordinate_space="normalized_xy_top_left", parts, notes.
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"version": "v2",
|
||||||
|
"tested": true,
|
||||||
|
"notes": "Tested on the original sombrero character and six Kenney Toon references. Cowboy attempt 4 had the highest SIFT mean score, but automatic selection is not visual acceptance.",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"id": "attempt-1",
|
||||||
|
"prompt": "Create a production-ready 2D cutout sheet for a skeletal animation rig from the supplied reference image. Use the reference as the only ground truth. Preserve the exact single pose, camera view, silhouette, proportions, scale, handedness, limb bends, body lean, overlap order and every visible asymmetry. Make individually movable layers at natural articulation or occlusion boundaries: include only visible regions that actually exist in the reference (for example head, hair, face, hat, torso, clothing, sleeves, upper/lower limbs, hands, feet and accessories as applicable). Keep each layer in its original orientation and registration relative to the reference canvas. Preserve partial occlusions and do not expose hidden pixels. A hidden or cropped region must stay hidden; never invent a counterpart. Render one copy of every layer only. Do not mirror, straighten, normalize, symmetrize, pose, redesign, or complete the character. Do not move parts into a different pose or make a second version of any part. Use clean flat 2D artwork with the original style, linework, texture, shading and colors. Do not rearrange layers into symmetric rows or a catalog. Keep their relative canvas placement; use only a small local gap where touching pixels would prevent separation, and keep every layer fully inside the plain white canvas.\n\nAvoid: 3D render, photorealism, redesign, new outfit, new character, alternate pose, T-pose, mirrored pose, bilateral symmetry, straight limbs, duplicated limb, duplicated hand, duplicated foot, invented hidden part, completed backside, merged layers, cut-off layers, floating fragments, detached pixels, scenery, cast shadow, glow, effects, labels, arrows, text, watermark\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "attempt-2",
|
||||||
|
"prompt": "Create a production-ready 2D cutout sheet for a skeletal animation rig from the supplied reference image. Use the reference as the only ground truth. Preserve the exact single pose, camera view, silhouette, proportions, scale, handedness, limb bends, body lean, overlap order and every visible asymmetry. Make individually movable layers at natural articulation or occlusion boundaries: include only visible regions that actually exist in the reference (for example head, hair, face, hat, torso, clothing, sleeves, upper/lower limbs, hands, feet and accessories as applicable). Keep each layer in its original orientation and registration relative to the reference canvas. Preserve partial occlusions and do not expose hidden pixels. A hidden or cropped region must stay hidden; never invent a counterpart. Render one copy of every layer only. Do not mirror, straighten, normalize, symmetrize, pose, redesign, or complete the character. Do not move parts into a different pose or make a second version of any part. Use clean flat 2D artwork with the original style, linework, texture, shading and colors. Do not rearrange layers into symmetric rows or a catalog. Keep their relative canvas placement; use only a small local gap where touching pixels would prevent separation, and keep every layer fully inside the plain white canvas. Registration pass: preserve the original canvas coordinates as closely as possible, including the exact screen-left/screen-right relationship. Separate overlapping layers without changing their visible placement.\n\nAvoid: 3D render, photorealism, redesign, new outfit, new character, alternate pose, T-pose, mirrored pose, bilateral symmetry, straight limbs, duplicated limb, duplicated hand, duplicated foot, invented hidden part, completed backside, merged layers, cut-off layers, floating fragments, detached pixels, scenery, cast shadow, glow, effects, labels, arrows, text, watermark\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "attempt-3",
|
||||||
|
"prompt": "Create a production-ready 2D cutout sheet for a skeletal animation rig from the supplied reference image. Use the reference as the only ground truth. Preserve the exact single pose, camera view, silhouette, proportions, scale, handedness, limb bends, body lean, overlap order and every visible asymmetry. Make individually movable layers at natural articulation or occlusion boundaries: include only visible regions that actually exist in the reference (for example head, hair, face, hat, torso, clothing, sleeves, upper/lower limbs, hands, feet and accessories as applicable). Keep each layer in its original orientation and registration relative to the reference canvas. Preserve partial occlusions and do not expose hidden pixels. A hidden or cropped region must stay hidden; never invent a counterpart. Render one copy of every layer only. Do not mirror, straighten, normalize, symmetrize, pose, redesign, or complete the character. Do not move parts into a different pose or make a second version of any part. Use clean flat 2D artwork with the original style, linework, texture, shading and colors. Do not rearrange layers into symmetric rows or a catalog. Keep their relative canvas placement; use only a small local gap where touching pixels would prevent separation, and keep every layer fully inside the plain white canvas. Articulation pass: make cuts only at real joints, clothing seams or occlusion boundaries. Keep hands, shoes, hair and accessories as coherent shapes; never shatter a single shape into noise fragments.\n\nAvoid: 3D render, photorealism, redesign, new outfit, new character, alternate pose, T-pose, mirrored pose, bilateral symmetry, straight limbs, duplicated limb, duplicated hand, duplicated foot, invented hidden part, completed backside, merged layers, cut-off layers, floating fragments, detached pixels, scenery, cast shadow, glow, effects, labels, arrows, text, watermark\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "attempt-4",
|
||||||
|
"prompt": "Create a production-ready 2D cutout sheet for a skeletal animation rig from the supplied reference image. Use the reference as the only ground truth. Preserve the exact single pose, camera view, silhouette, proportions, scale, handedness, limb bends, body lean, overlap order and every visible asymmetry. Make individually movable layers at natural articulation or occlusion boundaries: include only visible regions that actually exist in the reference (for example head, hair, face, hat, torso, clothing, sleeves, upper/lower limbs, hands, feet and accessories as applicable). Keep each layer in its original orientation and registration relative to the reference canvas. Preserve partial occlusions and do not expose hidden pixels. A hidden or cropped region must stay hidden; never invent a counterpart. Render one copy of every layer only. Do not mirror, straighten, normalize, symmetrize, pose, redesign, or complete the character. Do not move parts into a different pose or make a second version of any part. Use clean flat 2D artwork with the original style, linework, texture, shading and colors. Do not rearrange layers into symmetric rows or a catalog. Keep their relative canvas placement; use only a small local gap where touching pixels would prevent separation, and keep every layer fully inside the plain white canvas. Occlusion audit: draw only the visible portion of each layer, retain front-to-back depth order, and keep a rear or partially hidden region behind the occluding region. Do not duplicate anything to make the sheet look balanced.\n\nAvoid: 3D render, photorealism, redesign, new outfit, new character, alternate pose, T-pose, mirrored pose, bilateral symmetry, straight limbs, duplicated limb, duplicated hand, duplicated foot, invented hidden part, completed backside, merged layers, cut-off layers, floating fragments, detached pixels, scenery, cast shadow, glow, effects, labels, arrows, text, watermark\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "attempt-5",
|
||||||
|
"prompt": "Create a production-ready 2D cutout sheet for a skeletal animation rig from the supplied reference image. Use the reference as the only ground truth. Preserve the exact single pose, camera view, silhouette, proportions, scale, handedness, limb bends, body lean, overlap order and every visible asymmetry. Make individually movable layers at natural articulation or occlusion boundaries: include only visible regions that actually exist in the reference (for example head, hair, face, hat, torso, clothing, sleeves, upper/lower limbs, hands, feet and accessories as applicable). Keep each layer in its original orientation and registration relative to the reference canvas. Preserve partial occlusions and do not expose hidden pixels. A hidden or cropped region must stay hidden; never invent a counterpart. Render one copy of every layer only. Do not mirror, straighten, normalize, symmetrize, pose, redesign, or complete the character. Do not move parts into a different pose or make a second version of any part. Use clean flat 2D artwork with the original style, linework, texture, shading and colors. Do not rearrange layers into symmetric rows or a catalog. Keep their relative canvas placement; use only a small local gap where touching pixels would prevent separation, and keep every layer fully inside the plain white canvas. Conservative pass: prefer fewer complete, useful layers over many tiny fragments. Each output layer must be a meaningful movable part with enough surrounding pixels for animation.\n\nAvoid: 3D render, photorealism, redesign, new outfit, new character, alternate pose, T-pose, mirrored pose, bilateral symmetry, straight limbs, duplicated limb, duplicated hand, duplicated foot, invented hidden part, completed backside, merged layers, cut-off layers, floating fragments, detached pixels, scenery, cast shadow, glow, effects, labels, arrows, text, watermark\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "attempt-6",
|
||||||
|
"prompt": "Create a production-ready 2D cutout sheet for a skeletal animation rig from the supplied reference image. Use the reference as the only ground truth. Preserve the exact single pose, camera view, silhouette, proportions, scale, handedness, limb bends, body lean, overlap order and every visible asymmetry. Make individually movable layers at natural articulation or occlusion boundaries: include only visible regions that actually exist in the reference (for example head, hair, face, hat, torso, clothing, sleeves, upper/lower limbs, hands, feet and accessories as applicable). Keep each layer in its original orientation and registration relative to the reference canvas. Preserve partial occlusions and do not expose hidden pixels. A hidden or cropped region must stay hidden; never invent a counterpart. Render one copy of every layer only. Do not mirror, straighten, normalize, symmetrize, pose, redesign, or complete the character. Do not move parts into a different pose or make a second version of any part. Use clean flat 2D artwork with the original style, linework, texture, shading and colors. Do not rearrange layers into symmetric rows or a catalog. Keep their relative canvas placement; use only a small local gap where touching pixels would prevent separation, and keep every layer fully inside the plain white canvas. Pixel-fidelity pass: preserve the reference's exact edge contours, facial features, costume details, colors and non-symmetric construction. Do not redraw or reinterpret the artwork.\n\nAvoid: 3D render, photorealism, redesign, new outfit, new character, alternate pose, T-pose, mirrored pose, bilateral symmetry, straight limbs, duplicated limb, duplicated hand, duplicated foot, invented hidden part, completed backside, merged layers, cut-off layers, floating fragments, detached pixels, scenery, cast shadow, glow, effects, labels, arrows, text, watermark\n"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import {imageFormat} from '../lib/images.mjs';
|
|
||||||
import {requestJson, downloadImage} from './http.mjs';
|
|
||||||
|
|
||||||
export function validateEditInputs(stage, images, mask) {
|
|
||||||
if (!images.length) throw new Error('图片编辑至少需要一张输入图');
|
|
||||||
if (images.length > 1 && !stage.capabilities.multiple_images) throw new Error('尚未确认此提供方支持多图;请先验证再启用 capabilities.multiple_images');
|
|
||||||
if (mask) {
|
|
||||||
if (!stage.capabilities.mask) throw new Error('尚未确认此提供方支持编辑掩码;不能静默忽略 --mask');
|
|
||||||
if (stage.maskEditable !== 'transparent') throw new Error('当前掩码适配仅支持透明像素为可编辑区域');
|
|
||||||
if (mask.extension !== 'png' || mask.width !== images[0].width || mask.height !== images[0].height) {
|
|
||||||
throw new Error('编辑 mask 必须为与第一张输入图同尺寸的 PNG');
|
|
||||||
}
|
|
||||||
if (![4, 6].includes(mask.bytes[25])) throw new Error('编辑 mask 必须包含 alpha 通道(透明处允许编辑),不能直接传 SAM 黑白掩码');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function editImage(stage, {images, mask, prompt}, options) {
|
|
||||||
validateEditInputs(stage, images, mask);
|
|
||||||
return requestJson(stage, 'images/edits', () => {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('model', stage.model);
|
|
||||||
form.append('prompt', prompt);
|
|
||||||
for (const [name, value] of Object.entries(stage.params)) {
|
|
||||||
if (value !== null && value !== undefined) form.append(name, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
||||||
}
|
|
||||||
for (const image of images) form.append(stage.imageField, new Blob([image.bytes], {type: image.mime}), path.basename(image.file));
|
|
||||||
if (mask) form.append('mask', new Blob([mask.bytes], {type: mask.mime}), path.basename(mask.file));
|
|
||||||
return {method: 'POST', body: form};
|
|
||||||
}, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function decodeEditedImages(payload, stage, {fetchImpl = fetch} = {}) {
|
|
||||||
const items = payload.data || payload.images || payload.output;
|
|
||||||
if (!Array.isArray(items) || !items.length) throw new Error('图片编辑响应没有图片列表');
|
|
||||||
const decoded = [];
|
|
||||||
for (const item of items) {
|
|
||||||
let bytes;
|
|
||||||
if (typeof item.b64_json === 'string') bytes = Buffer.from(item.b64_json, 'base64');
|
|
||||||
else if (typeof item.url === 'string') bytes = await downloadImage(item.url, stage, fetchImpl);
|
|
||||||
else throw new Error('图片编辑响应缺少 b64_json/url');
|
|
||||||
decoded.push({bytes, ...imageFormat(bytes)});
|
|
||||||
}
|
|
||||||
return decoded;
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import {dataUrl} from '../lib/images.mjs';
|
|
||||||
import {requestJson} from './http.mjs';
|
|
||||||
|
|
||||||
export function buildVisionRequest(stage, image, schema) {
|
|
||||||
const instruction = `${stage.prompt}\n\nCanvas: ${image.width} x ${image.height} pixels.\nReturn ONLY JSON matching this schema:\n${JSON.stringify(schema)}`;
|
|
||||||
if (stage.protocol === 'chat-completions') {
|
|
||||||
const body = { ...stage.params, model: stage.model, stream: stage.stream,
|
|
||||||
messages: [{role: 'user', content: [
|
|
||||||
{type: 'text', text: instruction},
|
|
||||||
{type: 'image_url', image_url: {url: dataUrl(image)}},
|
|
||||||
]}],
|
|
||||||
};
|
|
||||||
if (stage.outputMode === 'json-object') body.response_format = {type: 'json_object'};
|
|
||||||
if (stage.outputMode === 'json-schema') body.response_format = {type: 'json_schema', json_schema: {name: 'semantic_parts', strict: true, schema}};
|
|
||||||
return {route: 'chat/completions', body};
|
|
||||||
}
|
|
||||||
if (stage.protocol === 'responses') {
|
|
||||||
const body = {...stage.params, model: stage.model, stream: stage.stream, instructions: stage.prompt, input: [{role: 'user', content: [
|
|
||||||
{type: 'input_text', text: instruction},
|
|
||||||
{type: 'input_image', image_url: dataUrl(image)},
|
|
||||||
]}]};
|
|
||||||
if (stage.outputMode === 'json-object') body.text = {format: {type: 'json_object'}};
|
|
||||||
if (stage.outputMode === 'json-schema') body.text = {format: {type: 'json_schema', name: 'semantic_parts', strict: true, schema}};
|
|
||||||
return {route: 'responses', body};
|
|
||||||
}
|
|
||||||
throw new Error(`未知理解协议:${stage.protocol}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractVisionText(payload, protocol) {
|
|
||||||
if (protocol === 'chat-completions') {
|
|
||||||
const choice = payload.choices?.[0];
|
|
||||||
if (choice?.message?.refusal) throw new Error('结构理解请求被模型拒绝');
|
|
||||||
if (choice?.finish_reason && choice.finish_reason !== 'stop') throw new Error(`结构理解响应未完整结束:${choice.finish_reason}`);
|
|
||||||
const content = choice?.message?.content;
|
|
||||||
if (typeof content === 'string' && content.trim()) return content;
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
const text = content.filter((item) => item.type === 'text').map((item) => item.text).join('\n');
|
|
||||||
if (text.trim()) return text;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (payload.status && payload.status !== 'completed') throw new Error(`结构理解响应状态:${payload.status}`);
|
|
||||||
const items = (payload.output || []).flatMap((item) => item.content || []);
|
|
||||||
if (items.some((item) => item.type === 'refusal')) throw new Error('结构理解请求被模型拒绝');
|
|
||||||
const text = payload.output_text || items.filter((item) => item.type === 'output_text').map((item) => item.text).join('\n');
|
|
||||||
if (typeof text === 'string' && text.trim()) return text;
|
|
||||||
}
|
|
||||||
throw new Error('服务端响应中没有结构理解文本');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function analyze(stage, image, schema, options) {
|
|
||||||
const {route, body} = buildVisionRequest(stage, image, schema);
|
|
||||||
return requestJson(stage, route, () => ({method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}), options);
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"description": "High-resolution single-character references rendered from Kenney New Platformer Pack SVGs. PNGs are 1024x1024 RGBA with transparent background.",
|
||||||
|
"source": "Kenney New Platformer Pack",
|
||||||
|
"source_url": "https://kenney.nl/assets/new-platformer-pack",
|
||||||
|
"license": "CC0 1.0",
|
||||||
|
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "character_beige_duck",
|
||||||
|
"png_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/png-1024/character_beige_duck.png",
|
||||||
|
"png_dimensions": [
|
||||||
|
1024,
|
||||||
|
1024
|
||||||
|
],
|
||||||
|
"source_svg_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/source-svg/character_beige_duck.svg",
|
||||||
|
"source_svg_dimensions": [
|
||||||
|
128,
|
||||||
|
128
|
||||||
|
],
|
||||||
|
"source_url": "https://kenney.nl/assets/new-platformer-pack",
|
||||||
|
"license": "CC0 1.0",
|
||||||
|
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||||
|
"sha256": "34ecf4589268019e940d49f6ff35fecc23384ad968def1c8445aa1a79a498e38"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "character_beige_front",
|
||||||
|
"png_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/png-1024/character_beige_front.png",
|
||||||
|
"png_dimensions": [
|
||||||
|
1024,
|
||||||
|
1024
|
||||||
|
],
|
||||||
|
"source_svg_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/source-svg/character_beige_front.svg",
|
||||||
|
"source_svg_dimensions": [
|
||||||
|
128,
|
||||||
|
128
|
||||||
|
],
|
||||||
|
"source_url": "https://kenney.nl/assets/new-platformer-pack",
|
||||||
|
"license": "CC0 1.0",
|
||||||
|
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||||
|
"sha256": "e90b903c761feddcb4de32f21f249e60eb6275f5c49cc084731200379477934c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "character_green_climb_a",
|
||||||
|
"png_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/png-1024/character_green_climb_a.png",
|
||||||
|
"png_dimensions": [
|
||||||
|
1024,
|
||||||
|
1024
|
||||||
|
],
|
||||||
|
"source_svg_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/source-svg/character_green_climb_a.svg",
|
||||||
|
"source_svg_dimensions": [
|
||||||
|
128,
|
||||||
|
128
|
||||||
|
],
|
||||||
|
"source_url": "https://kenney.nl/assets/new-platformer-pack",
|
||||||
|
"license": "CC0 1.0",
|
||||||
|
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||||
|
"sha256": "9f492a304cbfea4aa0a8814a7ef9b78f6369a1d6185e8b91a395ef1ca2e29d83"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "character_green_jump",
|
||||||
|
"png_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/png-1024/character_green_jump.png",
|
||||||
|
"png_dimensions": [
|
||||||
|
1024,
|
||||||
|
1024
|
||||||
|
],
|
||||||
|
"source_svg_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/source-svg/character_green_jump.svg",
|
||||||
|
"source_svg_dimensions": [
|
||||||
|
128,
|
||||||
|
128
|
||||||
|
],
|
||||||
|
"source_url": "https://kenney.nl/assets/new-platformer-pack",
|
||||||
|
"license": "CC0 1.0",
|
||||||
|
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||||
|
"sha256": "54c684aa39ce092e0c7420d18b225ef7fccf2d9c0d38a481785b16a564557c69"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "character_pink_idle",
|
||||||
|
"png_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/png-1024/character_pink_idle.png",
|
||||||
|
"png_dimensions": [
|
||||||
|
1024,
|
||||||
|
1024
|
||||||
|
],
|
||||||
|
"source_svg_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/source-svg/character_pink_idle.svg",
|
||||||
|
"source_svg_dimensions": [
|
||||||
|
128,
|
||||||
|
128
|
||||||
|
],
|
||||||
|
"source_url": "https://kenney.nl/assets/new-platformer-pack",
|
||||||
|
"license": "CC0 1.0",
|
||||||
|
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||||
|
"sha256": "7d48bb4d1eaa802ed5c3a8b093b1b75969d613ad5c2b21ece6c0c2c5f57fb5ad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "character_purple_front",
|
||||||
|
"png_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/png-1024/character_purple_front.png",
|
||||||
|
"png_dimensions": [
|
||||||
|
1024,
|
||||||
|
1024
|
||||||
|
],
|
||||||
|
"source_svg_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/source-svg/character_purple_front.svg",
|
||||||
|
"source_svg_dimensions": [
|
||||||
|
128,
|
||||||
|
128
|
||||||
|
],
|
||||||
|
"source_url": "https://kenney.nl/assets/new-platformer-pack",
|
||||||
|
"license": "CC0 1.0",
|
||||||
|
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||||
|
"sha256": "9e1331ed7bbdadbf39bf27596d4d3e7e5fe14b0c295be7234ec027b05151bd7f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "character_purple_walk_a",
|
||||||
|
"png_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/png-1024/character_purple_walk_a.png",
|
||||||
|
"png_dimensions": [
|
||||||
|
1024,
|
||||||
|
1024
|
||||||
|
],
|
||||||
|
"source_svg_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/source-svg/character_purple_walk_a.svg",
|
||||||
|
"source_svg_dimensions": [
|
||||||
|
128,
|
||||||
|
128
|
||||||
|
],
|
||||||
|
"source_url": "https://kenney.nl/assets/new-platformer-pack",
|
||||||
|
"license": "CC0 1.0",
|
||||||
|
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||||
|
"sha256": "db4560be2fe8efa959cf46de4ac8b985d06bdbb43d793e6d15ac8c3810517935"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "character_yellow_hit",
|
||||||
|
"png_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/png-1024/character_yellow_hit.png",
|
||||||
|
"png_dimensions": [
|
||||||
|
1024,
|
||||||
|
1024
|
||||||
|
],
|
||||||
|
"source_svg_path": "/Users/zhaoziyu/Documents/Codex/2026-09-11/spine-animation-pipeline/references/highres-characters/source-svg/character_yellow_hit.svg",
|
||||||
|
"source_svg_dimensions": [
|
||||||
|
128,
|
||||||
|
128
|
||||||
|
],
|
||||||
|
"source_url": "https://kenney.nl/assets/new-platformer-pack",
|
||||||
|
"license": "CC0 1.0",
|
||||||
|
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||||
|
"sha256": "e62bea14168d4c3e9b1f982d17261621a9a79117fb73652a1785256bf6ed4d6e"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user