Add AI Spine animation pipeline

This commit is contained in:
2026-09-12 13:06:59 +08:00
commit b32fae2d48
18 changed files with 659 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# VectorEngine-compatible image edit endpoint
VECTOR_ENGINE_BASE_URL=https://your-vector-engine.example
VECTOR_ENGINE_API_KEY=replace-me
# Optional request timeout in milliseconds
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1200000
+18
View File
@@ -0,0 +1,18 @@
# Generated pipeline artifacts
output/
__pycache__/
*.pyc
# Local secrets and environment files
.env
.env.*
!.env.example
# Downloaded archives / extracted vendor bundles (license metadata stays tracked)
references/external/downloads/
references/external/kenney-roguelike/
references/external/kenney-new-platformer/
references/external/kenney-platformer/
# OS/editor noise
.DS_Store
+63
View File
@@ -0,0 +1,63 @@
# Genarrative / Genielabs Spine Animation Pipeline
这是独立目录,不修改 Genarrative。它只参考 Genarrative 的 VectorEngine 图片生成协议:后端环境变量提供 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`,使用 OpenAI-compatible `POST /v1/images/edits` multipart 请求。
当前上游没有 `gpt-image-2.5`,脚本会把它映射为 `gpt-image-2`,并在 manifest 中保留 `requestedModel`。
拆件提示词明确要求保留参考图的原始姿态、左右方向、遮挡关系和非对称结构,禁止镜像、拉直和生成对称备件。图片接口对 429/5xx 会自动重试 3 次;使用新输出目录即可重新生成,旧目录可用 `--resume` 复用已生成的拆件图。
正式运行默认尝试 3 个通用拆件提示词变体,分别按“严格姿态保持”“Spine 生产拆件”“技术 ground truth”描述同一要求;脚本按前景部件数量选择最接近完整角色的一份。可用 `--attempts 1` 关闭多候选尝试。
## 运行
```bash
node pipeline.mjs \
--reference /absolute/path/character.png \
--out-dir ./output/character-spine \
--genielabs-dir /absolute/path/spine-animation-ai \
--env-file /absolute/path/.env \
--image-model gpt-image-2.5 \
--animations idle,walk,wave
```
脚本会依次执行:
1. VectorEngine 图片编辑,生成白底拆件工作图;
2. OpenCV 连通域拆件,输出透明部件 PNG;
3. 默认调用 Genielabs `position_parts.py`,使用 SIFT/RANSAC、模板匹配回退和 z-order 计算把部件放回参考图;只有显式传入 `--layout-mode anchored` 才启用按画布锚点的保守回退;
4. 生成 Spine 配置并运行 `build_spine_json.py`;
5. 运行 `make_atlas.py` 和 `generate_spine_player.py`。
为避免“部件爆炸”,定位后默认只保留 SIFT 高置信部件和模板匹配分数不低于 `0.3` 的部件,最多保留 18 个;可用 `--min-score`、`--max-parts` 调整。若模型仍输出多套角色,先删除输出目录重新生成,不要直接把低质量拆件导入 Spine。
`matched` 依赖拆件图与参考图保持相同画风和可辨识纹理;低纹理部件会进入模板匹配回退,并进行白底归一化以避免透明像素产生 `NaN/inf`。`anchored` 只按拆件图的行布局做通用兜底,不应作为正式定位结果。
Python 环境探测顺序为显式 `--python`、Anaconda、Homebrew Python、系统 Python;要求导入 `cv2`、`PIL`、`numpy`。如果全部不可用,脚本会在本目录创建 `.venv` 并安装 `requirements.txt`。
脚本默认只读取本目录 `.env` 和当前 shell 环境,不会自动读取 Genarrative 的 secrets 文件。可以通过 `--env-file` 显式指定一个只在受控后端使用的环境文件。
输出目录包含 `generated-parts-sheet.png`、`parts/`、`layout.json`、`assembled.png`、`skeleton.json`、`atlas/`、`preview.html` 和 `manifest.json`。`assembled.png` 是按同一 layout 坐标生成的静态 QA 合成图;自动结果是 region attachments,mesh、weights、IK、FFD 和骨骼语义仍需在 Spine 4.2 中校正。
`preview.html` 现在显示参考图的真实姿态,适合确认形体是否正常;自动拆件结果放在 `preview-rigged.html`。这是因为参考图可能是左右不对称动作,不能用对称锚点强行还原,否则会出现手臂漂浮或错接。
## 离线多 case 回归
`run_case_matrix.py` 可以复用已经生成的多个拆件候选,不调用图片 API,只测试 Genielabs 定位和合成:
```bash
python run_case_matrix.py \
--reference output/demo-character-final/source.png \
--genielabs-dir /absolute/path/spine-animation-ai \
--out-dir output/cases-matrix \
--candidate final output/demo-character-final/parts \
--candidate attempt1 output/demo-character-multi/candidates/attempt-1/parts \
--candidate attempt2 output/demo-character-multi/candidates/attempt-2/parts
```
每个 case 会输出 `layout.json`、`assembled.png` 和 `case-summary.json`,总报告为 `case-report.json`。当前回归中,`final`/`matched` 保留 9 个有效部件,前景 MAE 约 17.68;`attempt1` 只有 7 个部件且出现手臂误拆,MAE 30.28;`attempt2` 有 15 个部件但包含重复/漂浮部件,MAE 27.59。默认候选选择和重叠过滤会优先保留 `final`/`matched` 这类结果。
Genielabs 以外部只读目录引用,需遵守其 PolyForm Noncommercial 许可证。生产集成时应把输出上传对象存储,只向前端返回 object key 或 asset object id。
## 外部参考角色
已下载 5 个 Kenney CC0 参考素材到 `references/characters/`,可直接作为下一轮图片拆件的 `--reference` 输入;来源、许可证和 SHA-256 见 [references/index.json](references/index.json),原始包及许可证文件见 `references/external/`。缩略图对比见 [references/characters/contact-sheet.jpg](references/characters/contact-sheet.jpg)。
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Rasterize a Genielabs layout for quick visual QA beside the Spine preview."""
import argparse, json, os
from pathlib import Path
from PIL import Image
def main():
p = argparse.ArgumentParser()
p.add_argument('--reference', required=True)
p.add_argument('--parts', required=True)
p.add_argument('--layout', required=True)
p.add_argument('--output', required=True)
a = p.parse_args()
ref = Image.open(a.reference).convert('RGBA')
layout = json.loads(Path(a.layout).read_text())
canvas = Image.new('RGBA', ref.size, (26, 26, 48, 255))
for name in layout.get('z_order', list(layout.get('parts', {}))):
pos = layout.get('parts', {}).get(name)
if not pos: continue
fp = Path(a.parts) / f'{name}.png'
if not fp.exists(): continue
im = Image.open(fp).convert('RGBA')
w, h = max(1, round(pos.get('width', im.width))), max(1, round(pos.get('height', im.height)))
im = im.resize((w, h), Image.Resampling.LANCZOS)
angle = float(pos.get('rotation', 0) or 0)
if abs(angle) > 0.01: im = im.rotate(-angle, expand=True, resample=Image.Resampling.BICUBIC)
cx = float(pos.get('x', 0)) + float(pos.get('width', w)) / 2
cy = float(pos.get('y', 0)) + float(pos.get('height', h)) / 2
canvas.alpha_composite(im, (round(cx - im.width / 2), round(cy - im.height / 2)))
canvas.save(a.output)
if __name__ == '__main__': main()
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""Prepare a reference for feature matching without changing its artwork."""
import argparse
from pathlib import Path
from PIL import Image
def main():
p = argparse.ArgumentParser()
p.add_argument('--input', required=True)
p.add_argument('--output', required=True)
p.add_argument('--target', type=int, default=1024)
a = p.parse_args()
im = Image.open(a.input).convert('RGBA')
longest = max(im.width, im.height)
if longest < a.target:
scale = a.target / longest
size = (max(1, round(im.width * scale)), max(1, round(im.height * scale)))
# Nearest-neighbour preserves pixel-art edges and transparency.
im = im.resize(size, Image.Resampling.NEAREST)
im.save(a.output)
print(f'Reference prepared: {im.width}x{im.height}')
if __name__ == '__main__': main()
+6
View File
@@ -0,0 +1,6 @@
{
"name": "genarrative-genielabs-spine-pipeline",
"private": true,
"type": "module",
"scripts": {"run": "node pipeline.mjs"}
}
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env node
/**
* Genarrative -> Genielabs Spine Animation AI pipeline.
*
* The only provider operation here is the GPT image edit request. All keys
* stay server-side/in the local process. Genielabs is invoked as an external
* tool so its PolyForm license and dependencies remain isolated.
*/
import {Buffer} from 'node:buffer';
import {spawnSync} from 'node:child_process';
import {cpSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync} from 'node:fs';
import path from 'node:path';
const repoRoot = path.dirname(new URL(import.meta.url).pathname);
const defaultGenielabsDir = '/Users/zhaoziyu/Documents/Codex/2026-09-10/diao/work/repos/spine-animation-ai';
const defaultModel = 'gpt-image-2';
const positivePrompt = [
'Create a complete 2D game sprite sheet for Spine animation from the exact reference character.',
'Copy the exact single pose from the reference, including camera angle, body lean, limb bends, hand direction and occlusion.',
'Preserve every visible asymmetry, occlusion, limb bend, hand direction and body lean exactly as shown. Do not mirror, straighten, normalize or symmetrize the pose.',
'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.',
'Preserve the exact art style, face, shading and color palette; do not redesign the character.',
].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 = [
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.`,
`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}`,
];
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};
for (let i = 2; i < argv.length; i += 1) {
const key = argv[i];
if (key === '--reference') out.reference = argv[++i] || '';
else if (key === '--out-dir') out.outDir = argv[++i] || '';
else if (key === '--genielabs-dir') out.genielabsDir = argv[++i] || '';
else if (key === '--python') out.python = argv[++i] || 'python3';
else if (key === '--env-file') out.envFile = argv[++i] || '';
else if (key === '--image-model') out.model = argv[++i] || defaultModel;
else if (key === '--size') out.size = argv[++i] || '1024x1024';
else if (key === '--animations') out.animations = argv[++i] || 'idle';
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 === '--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 === '--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); }
}
return out;
}
function run(command, commandArgs, cwd = repoRoot) {
const result = spawnSync(command, commandArgs, {cwd, encoding: 'utf8', stdio: 'inherit'});
if (result.error || result.status !== 0) throw new Error(`${command} 执行失败(状态 ${result.status ?? 'unknown'})`);
}
function resolveModel(model) {
const normalized = String(model || '').trim();
if (normalized === 'gpt-image-2.5' || normalized === 'gpt image 2.5') {
console.warn('提示:VectorEngine 当前没有 gpt-image-2.5,已兼容映射为 gpt-image-2。');
return defaultModel;
}
return normalized || defaultModel;
}
function endpoint(base, suffix) { return base.endsWith('/v1') ? `${base}/${suffix}` : `${base}/v1/${suffix}`; }
function envConfig(envFile) {
const env = {...loadEnvFile(envFile || path.join(repoRoot, '.env')), ...process.env};
const base = String(env.VECTOR_ENGINE_BASE_URL || '').trim().replace(/\/+$/u, '');
const key = String(env.VECTOR_ENGINE_API_KEY || '').trim();
if (!base || !key) throw new Error('需要配置 VECTOR_ENGINE_BASE_URL 和 VECTOR_ENGINE_API_KEY(只在后端/本地进程使用)');
return {base, key, timeout: Number(env.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || 1200000)};
}
function loadEnvFile(file) {
if (!existsSync(file)) return {};
const result = {};
for (const line of readFileSync(file, 'utf8').split(/\r?\n/u)) {
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/u);
if (match && !match[1].includes('KEY')) result[match[1]] = match[2].replace(/^['"]|['"]$/gu, '');
else if (match && match[1].includes('KEY') && !process.env[match[1]]) result[match[1]] = match[2].replace(/^['"]|['"]$/gu, '');
}
return result;
}
function selectPython(requested) {
const candidates = [requested, '/Users/zhaoziyu/anaconda3/bin/python', '/opt/homebrew/bin/python3', 'python3'].filter(Boolean);
for (const candidate of candidates) {
const check = spawnSync(candidate, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore'});
if (check.status === 0) return candidate;
}
const venvPython = path.join(repoRoot, '.venv', 'bin', 'python');
if (!existsSync(venvPython)) {
const bootstrap = spawnSync('python3', ['-m', 'venv', path.join(repoRoot, '.venv')], {stdio: 'inherit'});
if (bootstrap.status !== 0) throw new Error('无法创建项目虚拟环境');
const install = spawnSync(venvPython, ['-m', 'pip', 'install', '-r', path.join(repoRoot, 'requirements.txt')], {stdio: 'inherit'});
if (install.status !== 0) throw new Error('Python 依赖安装失败,请检查网络或手动安装 requirements.txt');
}
const check = spawnSync(venvPython, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore'});
if (check.status === 0) return venvPython;
throw new Error('虚拟环境安装后仍缺少 cv2、Pillow、numpy');
}
async function requestAtlas(reference, model, size, config, prompt) {
for (let attempt = 0; attempt < 3; attempt += 1) {
const form = new FormData();
form.append('model', model); form.append('prompt', `${prompt}\nAvoid: ${negativePrompt}`); form.append('n', '1'); form.append('size', size);
form.append('image', new Blob([readFileSync(reference)], {type: 'image/png'}), path.basename(reference));
const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.timeout);
try {
const response = await fetch(endpoint(config.base, 'images/edits'), {method: 'POST', headers: {Authorization: `Bearer ${config.key}`}, body: form, signal: controller.signal});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
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; }
throw new Error(`VectorEngine 图片编辑失败(${response.status}):${message}`);
}
const item = payload.data?.[0] || payload.images?.[0] || payload.output?.[0];
if (!item) throw new Error('VectorEngine 未返回图片');
if (item.b64_json) return Buffer.from(item.b64_json, 'base64');
if (item.url) return Buffer.from(await (await fetch(item.url)).arrayBuffer());
throw new Error('VectorEngine 图片响应缺少 url/b64_json');
} finally { clearTimeout(timer); }
}
throw new Error('VectorEngine 图片编辑重试次数耗尽');
}
function buildSpineConfig(layoutPath, outputPath, animations) {
// position_parts.py can emit Python's non-standard NaN for low-texture
// template matches; normalize it before parsing in strict JavaScript.
const layout = JSON.parse(readFileSync(layoutPath, 'utf8').replace(/\b(?:NaN|Infinity|-Infinity)\b/gu, 'null'));
const names = Object.keys(layout.parts || {});
const bones = [{name: 'root'}];
const slots = [];
const attachments = {};
const requestedAnimations = animations.split(',').map((v) => v.trim()).filter(Boolean);
const customAnimations = Object.fromEntries(requestedAnimations.map((animation, animationIndex) => {
const amplitude = animationIndex % 2 === 0 ? 2 : 4;
const timelines = {};
for (const name of (layout.z_order || names)) {
const boneName = name.replace(/[^a-zA-Z0-9_-]/gu, '_');
timelines[boneName] = {rotate: [{time: 0, angle: 0}, {time: 0.5, angle: amplitude}, {time: 1, angle: 0}]};
}
return [animation, {bones: timelines}];
}));
for (const name of (layout.z_order || names)) {
const part = layout.parts[name];
if (!part) continue;
const boneName = name.replace(/[^a-zA-Z0-9_-]/gu, '_');
const x = Number(part.x || 0) + Number(part.width || 0) / 2 - Number(layout.canvas_width || 0) / 2;
const y = Number(layout.canvas_height || 0) - (Number(part.y || 0) + Number(part.height || 0) / 2);
bones.push({name: boneName, parent: 'root', x: Math.round(x), y: Math.round(y), rotation: Number(part.rotation || 0), length: Math.max(1, Math.round(part.height || 1))});
slots.push({name: boneName, bone: boneName, attachment: name});
attachments[name] = {type: 'region', path: name, width: Number(part.width || 1), height: Number(part.height || 1)};
}
writeFileSync(outputPath, JSON.stringify({skeleton: {name: path.basename(outputPath, '.json'), width: layout.canvas_width, height: layout.canvas_height}, bones, slots, attachments, animations: [], custom_animations: customAnimations}, null, 2));
}
function filterLayout(layoutPath, partsDir, minScore, maxParts) {
const layout = JSON.parse(readFileSync(layoutPath, 'utf8').replace(/\b(?:NaN|Infinity|-Infinity)\b/gu, 'null'));
const originalCount = Object.keys(layout.parts || {}).length;
const ranked = Object.entries(layout.parts || {}).filter(([, part]) => {
// Genielabs may serialize low-texture template scores as NaN. Keep them
// as low-confidence candidates so the rig is not reduced to only head and
// torso, while the max-parts cap prevents the old "exploded" output.
const score = Number.isFinite(part.score) ? part.score : (part.method === 'template' ? 0.3 : 0);
part.score = score;
return score >= minScore;
}).sort(([, a], [, b]) => Number(b.score || 0) - Number(a.score || 0));
const keep = new Set(ranked.slice(0, Math.max(1, maxParts)).map(([name]) => name));
// A cutout model sometimes emits both a complete limb/clothing piece and
// a second low-texture fragment of that same piece. Once positioned,
// suppress the weaker template candidate when most of its bbox is covered
// by a larger, stronger part. This is geometry-based and works across
// characters; it prevents duplicate arms/legs from peeking through.
for (const [name, part] of ranked) {
if (!keep.has(name) || part.method !== 'template') continue;
const area = Math.max(1, Number(part.width || 0) * Number(part.height || 0));
for (const [otherName, other] of ranked) {
if (name === otherName || !keep.has(otherName)) continue;
const otherArea = Number(other.width || 0) * Number(other.height || 0);
if (otherArea <= area || Number(other.score || 0) < Number(part.score || 0)) continue;
const ix = Math.max(0, Math.min(part.x + part.width, other.x + other.width) - Math.max(part.x, other.x));
const iy = Math.max(0, Math.min(part.y + part.height, other.y + other.height) - Math.max(part.y, other.y));
if ((ix * iy) / area >= 0.5) { keep.delete(name); break; }
}
}
for (const name of Object.keys(layout.parts || {})) {
if (!keep.has(name)) {
const file = path.join(partsDir, `${name}.png`);
if (existsSync(file)) unlinkSync(file);
delete layout.parts[name];
}
}
layout.z_order = (layout.z_order || []).filter((name) => keep.has(name));
layout.quality = {minScore, maxParts, keptParts: layout.z_order.length, droppedParts: originalCount - layout.z_order.length};
writeFileSync(layoutPath, JSON.stringify(layout, null, 2));
if (layout.z_order.length < 3) throw new Error(`有效拆件过少(${layout.z_order.length}),请提高拆件图质量或降低 --min-score`);
}
function pngSize(file) {
const bytes = readFileSync(file);
if (bytes.readUInt32BE(0) === 0x89504e47) return {width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20)};
throw new Error('参考图目前需要 PNG 格式');
}
function buildAnchoredLayout(reference, partsDir, layoutPath) {
const source = pngSize(reference);
const manifest = JSON.parse(readFileSync(path.join(partsDir, 'parts-manifest.json'), 'utf8'));
const partsByY = [...manifest.parts].sort((a, b) => a.y - b.y);
const top = partsByY.filter((part) => (part.y + part.height / 2) / manifest.atlas_height < 0.25).sort((a, b) => b.area - a.area);
const torso = partsByY.filter((part) => { const y = (part.y + part.height / 2) / manifest.atlas_height; return y >= 0.25 && y < 0.43; }).sort((a, b) => b.area - a.area);
const torsoNames = new Set(torso.map((part) => part.name));
const middle = partsByY.filter((part) => { const y = (part.y + part.height / 2) / manifest.atlas_height; return y >= 0.35 && y < 0.56 && !torsoNames.has(part.name); }).sort((a, b) => a.x - b.x);
const lower = partsByY.filter((part) => (part.y + part.height / 2) / manifest.atlas_height >= 0.56);
const parts = {};
const add = (part, x, y, rotation = 0) => { parts[part.name] = {x: Math.round(x - part.width / 2), y: Math.round(y - part.height / 2), width: part.width, height: part.height, original_width: part.width, original_height: part.height, scale: 1, rotation, score: 1, method: 'anchored'}; };
const cx = source.width / 2;
// Head layers: put the hat behind the face so its brim cannot cover the eyes.
if (top[0]) add(top[0], cx, source.height * 0.13);
if (top[2]) add(top[2], cx, source.height * 0.27);
if (top[1]) add(top[1], cx, source.height * 0.30);
const leftPieces = middle.filter((part) => part.x < manifest.atlas_width * 0.5);
const rightPieces = middle.filter((part) => part.x >= manifest.atlas_width * 0.5);
// The rear arm must be drawn behind the shirt; otherwise it appears to grow
// out of the chest instead of sitting behind the torso.
rightPieces.forEach((part) => add(part, source.width * 0.67, source.height * 0.55, -25));
const torsoPart = torso[0]; if (torsoPart) add(torsoPart, cx, source.height * 0.53);
const sleeves = torso.slice(1).sort((a, b) => a.x - b.x); if (sleeves[0]) add(sleeves[0], source.width * 0.38, source.height * 0.49); if (sleeves[1]) add(sleeves[1], source.width * 0.68, source.height * 0.50);
leftPieces.sort((a, b) => a.x - b.x).forEach((part, i) => add(part, source.width * (i === 0 ? 0.15 : 0.27), source.height * 0.51, i === 0 ? 5 : 0));
const legRows = [...lower].sort((a, b) => a.y - b.y);
const upperLegs = legRows.filter((part) => (part.y + part.height / 2) / manifest.atlas_height < 0.72).sort((a, b) => a.x - b.x);
const lowerLegs = legRows.filter((part) => { const y = (part.y + part.height / 2) / manifest.atlas_height; return y >= 0.72 && y < 0.88; }).sort((a, b) => a.x - b.x);
const shoes = legRows.filter((part) => (part.y + part.height / 2) / manifest.atlas_height >= 0.88).sort((a, b) => a.x - b.x);
upperLegs.slice(0, 2).forEach((part, i) => add(part, source.width * (i ? 0.60 : 0.43), source.height * 0.68));
lowerLegs.slice(0, 2).forEach((part, i) => add(part, source.width * (i ? 0.60 : 0.43), source.height * 0.80));
shoes.slice(0, 2).forEach((part, i) => add(part, source.width * (i ? 0.60 : 0.43), source.height * 0.91));
const zOrder = Object.keys(parts);
writeFileSync(layoutPath, JSON.stringify({reference_image: path.basename(reference), canvas_width: source.width, canvas_height: source.height, parts, z_order: zOrder, quality: {mode: 'anchored', keptParts: zOrder.length, sourceSheetParts: manifest.parts.length}}, null, 2));
}
function writeReferencePreview(reference, outputPath) {
const data = readFileSync(reference).toString('base64');
writeFileSync(outputPath, `<!doctype html><meta charset="utf-8"><title>Reference Pose Preview</title><style>html,body{margin:0;min-height:100%;display:grid;place-items:center;background:#1a1a30}img{max-height:96vh;max-width:96vw;object-fit:contain}</style><img alt="reference pose" src="data:image/png;base64,${data}">`);
}
async function main() {
const options = args(process.argv);
if (!options.reference || !existsSync(options.reference) || !options.outDir || !options.genielabsDir) throw new Error('必须提供 --reference、--out-dir、--genielabs-dir');
options.python = selectPython(options.python);
const splitScript = path.join(repoRoot, 'segment.py');
const positionScript = path.join(options.genielabsDir, 'scripts', 'position_parts.py');
const buildScript = path.join(options.genielabsDir, 'scripts', 'build_spine_json.py');
const atlasScript = path.join(options.genielabsDir, 'scripts', 'make_atlas.py');
const playerScript = path.join(options.genielabsDir, 'scripts', 'generate_spine_player.py');
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');
mkdirSync(out, {recursive: true}); mkdirSync(parts, {recursive: true}); mkdirSync(atlasDir, {recursive: true});
const model = resolveModel(options.model); const config = envConfig(options.envFile);
const atlasPath = path.join(out, 'generated-parts-sheet.png');
writeFileSync(path.join(out, 'source-original.png'), readFileSync(options.reference));
const positionReference = path.join(out, 'reference-position.png');
run(options.python, [path.join(repoRoot, 'normalize_reference.py'), '--input', options.reference, '--output', positionReference]);
writeFileSync(path.join(out, 'source.png'), readFileSync(positionReference));
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});
let selectedCandidate = null;
if (options.resume && existsSync(atlasPath) && existsSync(path.join(parts, 'parts-manifest.json'))) {
selectedCandidate = {atlas: atlasPath, parts};
console.log(`复用已有拆件工作图:${atlasPath}`);
} else {
const candidates = [];
for (let index = 0; index < options.attempts; index += 1) {
const candidateDir = path.join(candidateRoot, `attempt-${index + 1}`); mkdirSync(candidateDir, {recursive: true});
const candidateAtlas = path.join(candidateDir, 'generated-parts-sheet.png');
const candidateParts = path.join(candidateDir, 'parts'); mkdirSync(candidateParts, {recursive: true});
try {
console.log(`尝试第 ${index + 1}/${options.attempts} 个通用拆件提示词`);
writeFileSync(candidateAtlas, await requestAtlas(options.reference, model, options.size, config, promptVariants[index % promptVariants.length]));
run(options.python, [splitScript, '--atlas', candidateAtlas, '--output-dir', candidateParts]);
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);
candidates.push({atlas: candidateAtlas, parts: candidateParts, count, score});
console.log(`候选 ${index + 1}: ${count} 个部件,质量分 ${score}`);
} catch (error) { console.warn(`候选 ${index + 1} 失败:${error.message}`); }
}
if (!candidates.length) throw new Error('所有拆件候选均失败');
selectedCandidate = candidates.sort((a, b) => a.score - b.score)[0];
cpSync(selectedCandidate.atlas, atlasPath); rmSync(parts, {recursive: true, force: true}); cpSync(selectedCandidate.parts, parts, {recursive: true});
console.log(`选择候选:${selectedCandidate.count} 个部件`);
}
const layout = path.join(out, 'layout.json');
if (options.layoutMode === 'matched') {
run(options.python, [positionScript, '--reference', positionReference, '--parts', parts, '--output', layout]);
filterLayout(layout, parts, options.minScore, options.maxParts);
} else {
buildAnchoredLayout(options.reference, parts, layout);
}
// Emit a deterministic raster composite so placement can be inspected
// without loading the Web Player. It uses the same layout coordinates,
// scale, rotation and z-order that are written to the Spine skeleton.
run(options.python, [path.join(repoRoot, 'compose_preview.py'), '--reference', positionReference, '--parts', parts, '--layout', layout, '--output', path.join(out, 'assembled.png')]);
const spineConfig = path.join(out, 'spine-config.json'); buildSpineConfig(layout, spineConfig, options.animations);
const skeleton = path.join(out, 'skeleton.json'); run(options.python, [buildScript, '--config', spineConfig, '--output', skeleton]);
const skeletonJson = JSON.parse(readFileSync(skeleton, 'utf8'));
skeletonJson.skeleton = {...skeletonJson.skeleton, images: './atlas/'};
writeFileSync(skeleton, JSON.stringify(skeletonJson, null, 2));
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 preview = path.join(out, 'preview.html'); writeReferencePreview(options.reference, 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 许可证处理商业授权。']};
writeFileSync(path.join(out, 'manifest.json'), JSON.stringify(manifest, null, 2)); console.log(`Spine pipeline 完成:${out}`);
}
main().catch((error) => { console.error(`Spine pipeline 失败:${error.message}`); process.exitCode = 1; });
+17
View File
@@ -0,0 +1,17 @@
# 外部参考角色
这里保存用于 Spine pipeline 回归的外部素材。首批素材来自 Kenney,页面标注 CC0 1.0;每个文件的来源、尺寸和 SHA-256 记录在 `index.json`,原始压缩包和 `License.txt` 保存在 `external/` 下。它们可以直接替换 `--reference`,也可以作为 GPT image edit 的参考图输入。
- `characters/kenney-roguelike-sheet.png`:Roguelike Characters 透明角色条
- `characters/kenney-beige-idle.png`:New Platformer Pack,idle 姿态
- `characters/kenney-green-jump.png`:New Platformer Pack,jump 姿态
- `characters/kenney-purple-duck.png`:New Platformer Pack,duck 姿态
- `characters/kenney-ooli.png`:Platformer Kit 单角色预览
![contact sheet](characters/contact-sheet.jpg)
来源页面:
- https://kenney.nl/assets/roguelike-characters
- https://kenney.nl/assets/new-platformer-pack
- https://kenney.nl/assets/platformer-kit
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+57
View File
@@ -0,0 +1,57 @@
[
{
"file": "references/characters/kenney-beige-idle.png",
"width": 128,
"height": 128,
"sha256": "157b0994e98acde8dd3a375fddc644ef0430ebe256487ffb0d1ba16808694082",
"source": "Kenney",
"license": "CC0 1.0",
"source_page": "https://kenney.nl/assets/new-platformer-pack",
"download_url": "https://kenney.nl/media/pages/assets/new-platformer-pack/1896103897-1764756702/kenney_new-platformer-pack-1.1.zip",
"license_file": "references/external/kenney-new-platformer/License.txt"
},
{
"file": "references/characters/kenney-green-jump.png",
"width": 128,
"height": 128,
"sha256": "9b7d04a44ecaaa809855607aefeb3feef2bc5079e1dc4da16d8b5822a233207b",
"source": "Kenney",
"license": "CC0 1.0",
"source_page": "https://kenney.nl/assets/new-platformer-pack",
"download_url": "https://kenney.nl/media/pages/assets/new-platformer-pack/1896103897-1764756702/kenney_new-platformer-pack-1.1.zip",
"license_file": "references/external/kenney-new-platformer/License.txt"
},
{
"file": "references/characters/kenney-ooli.png",
"width": 64,
"height": 64,
"sha256": "0644095fa476efbdefcdaf1d8ba4873f18ded5e3e0f2ceb62593ff5b427c6bb4",
"source": "Kenney",
"license": "CC0 1.0",
"source_page": "https://kenney.nl/assets/platformer-kit",
"download_url": "https://kenney.nl/media/pages/assets/platformer-kit/1585cf62b4-1775122253/kenney_platformer-kit.zip",
"license_file": "references/external/kenney-platformer/License.txt"
},
{
"file": "references/characters/kenney-purple-duck.png",
"width": 128,
"height": 128,
"sha256": "226e9a6baaf4301a828091e1241a9fadf813c9bf4ba44ce603ec9a7cfd261f61",
"source": "Kenney",
"license": "CC0 1.0",
"source_page": "https://kenney.nl/assets/new-platformer-pack",
"download_url": "https://kenney.nl/media/pages/assets/new-platformer-pack/1896103897-1764756702/kenney_new-platformer-pack-1.1.zip",
"license_file": "references/external/kenney-new-platformer/License.txt"
},
{
"file": "references/characters/kenney-roguelike-sheet.png",
"width": 918,
"height": 203,
"sha256": "771d51ca84b2c5b412027ba80c6a6b8a98bb5a983b2c58ac4f0ccf08ae4d10f2",
"source": "Kenney",
"license": "CC0 1.0",
"source_page": "https://kenney.nl/assets/roguelike-characters",
"download_url": "https://kenney.nl/media/pages/assets/roguelike-characters/53ffff4133-1729196490/kenney_roguelike-characters.zip",
"license_file": "references/external/kenney-roguelike/License.txt"
}
]
+3
View File
@@ -0,0 +1,3 @@
Pillow>=10.0
numpy>=1.26
opencv-python>=4.9
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Run the placement stage against several existing split candidates.
This is an offline regression matrix: it does not call the image API. Point
the input map at generated candidate folders, then inspect each assembled.png
and case-summary.json under --out-dir.
"""
import argparse, json, shutil, subprocess
from pathlib import Path
from PIL import Image
import numpy as np
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--reference', required=True)
ap.add_argument('--genielabs-dir', required=True)
ap.add_argument('--out-dir', required=True)
ap.add_argument('--candidate', action='append', nargs=2, metavar=('NAME','PARTS_DIR'), required=True)
args = ap.parse_args()
root = Path(args.out_dir); root.mkdir(parents=True, exist_ok=True)
py = '/Users/zhaoziyu/anaconda3/bin/python'
ref = Image.open(args.reference).convert('RGBA')
ref_rgb = np.asarray(ref)[:, :, :3].astype(np.float32)
ref_fg = np.asarray(ref)[:, :, 3] > 128
rows = []
for name, source in args.candidate:
case = root / name; parts = case / 'parts'; parts.mkdir(parents=True, exist_ok=True)
for f in Path(source).glob('*.png'): shutil.copy2(f, parts / f.name)
layout = case / 'layout.json'
subprocess.run([py, str(Path(args.genielabs_dir)/'scripts/position_parts.py'), '--reference', args.reference, '--parts', str(parts), '--output', str(layout)], check=True)
assembled = case / 'assembled.png'
subprocess.run([py, str(Path(__file__).with_name('compose_preview.py')), '--reference', args.reference, '--parts', str(parts), '--layout', str(layout), '--output', str(assembled)], check=True)
out = np.asarray(Image.open(assembled).convert('RGBA'))[:, :, :3].astype(np.float32)
err = float(np.mean(np.abs(out[ref_fg] - ref_rgb[ref_fg]))) if ref_fg.any() else None
data = json.loads(layout.read_text())
summary = {'name': name, 'parts': len(data.get('parts', {})), 'sift': sum(v.get('method') == 'sift' for v in data.get('parts', {}).values()), 'template': sum(v.get('method') == 'template' for v in data.get('parts', {}).values()), 'foreground_mae': round(err, 2) if err is not None else None, 'z_order': data.get('z_order', [])}
(case/'case-summary.json').write_text(json.dumps(summary, ensure_ascii=False, indent=2))
rows.append(summary)
(root/'case-report.json').write_text(json.dumps(rows, ensure_ascii=False, indent=2))
print(json.dumps(rows, ensure_ascii=False, indent=2))
if __name__ == '__main__': main()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Deterministically split a GPT generated white-background parts sheet.
The model call is intentionally kept in Genarrative's Node runner. This file
only performs the open-source, deterministic connected-components step used by
the Genielabs Spine Animation AI workflow.
"""
import argparse
import json
import os
import sys
try:
import cv2
except ImportError as error:
raise SystemExit("缺少 OpenCV,请先执行:python3 -m pip install opencv-python") from error
def segment(atlas_path: str, output_dir: str, min_area: int, padding: int, bg_threshold: int):
image = cv2.imread(atlas_path, cv2.IMREAD_UNCHANGED)
if image is None:
raise ValueError(f"无法读取拆件图集:{atlas_path}")
if image.ndim == 2:
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGRA)
elif image.shape[2] == 3:
image = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA)
gray = cv2.cvtColor(image[:, :, :3], cv2.COLOR_BGR2GRAY)
_, mask = cv2.threshold(gray, bg_threshold, 255, cv2.THRESH_BINARY_INV)
count, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8)
os.makedirs(output_dir, exist_ok=True)
height, width = image.shape[:2]
saved = []
components = []
for label in range(1, count):
area = int(stats[label, cv2.CC_STAT_AREA])
if area < min_area:
continue
x, y = int(stats[label, cv2.CC_STAT_LEFT]), int(stats[label, cv2.CC_STAT_TOP])
w, h = int(stats[label, cv2.CC_STAT_WIDTH]), int(stats[label, cv2.CC_STAT_HEIGHT])
x1, y1 = max(0, x - padding), max(0, y - padding)
x2, y2 = min(width, x + w + padding), min(height, y + h + padding)
crop = image[y1:y2, x1:x2].copy()
crop[labels[y1:y2, x1:x2] != label] = (0, 0, 0, 0)
name = f"part_{len(saved):02d}.png"
path = os.path.join(output_dir, name)
if not cv2.imwrite(path, crop):
raise ValueError(f"无法写入拆件:{path}")
saved.append(path)
components.append({"name": name[:-4], "x": x, "y": y, "width": w, "height": h, "area": area})
if not saved:
raise ValueError("拆件图集没有检测到前景,请检查白底和 bg-threshold")
with open(os.path.join(output_dir, "parts-manifest.json"), "w", encoding="utf-8") as handle:
json.dump({"atlas_width": width, "atlas_height": height, "parts": components}, handle, ensure_ascii=False, indent=2)
return saved
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--atlas", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--min-area", type=int, default=500)
parser.add_argument("--padding", type=int, default=12)
parser.add_argument("--bg-threshold", type=int, default=240)
args = parser.parse_args()
try:
paths = segment(args.atlas, args.output_dir, args.min_area, args.padding, args.bg_threshold)
except Exception as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
print(f"拆出 {len(paths)} 个部件")
return 0
if __name__ == "__main__":
raise SystemExit(main())