Add AI Spine animation pipeline
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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)。
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "genarrative-genielabs-spine-pipeline",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {"run": "node pipeline.mjs"}
|
||||
}
|
||||
+317
@@ -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; });
|
||||
@@ -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 单角色预览
|
||||
|
||||

|
||||
|
||||
来源页面:
|
||||
|
||||
- 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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
Pillow>=10.0
|
||||
numpy>=1.26
|
||||
opencv-python>=4.9
|
||||
@@ -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
@@ -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())
|
||||
Reference in New Issue
Block a user