From b32fae2d48fadb82bf204b8cc6711e62f858d0c9 Mon Sep 17 00:00:00 2001 From: ziyuzhao-zzy Date: Sat, 12 Sep 2026 13:06:59 +0800 Subject: [PATCH] Add AI Spine animation pipeline --- .env.example | 5 + .gitignore | 18 + README.md | 63 ++++ compose_preview.py | 32 ++ normalize_reference.py | 23 ++ package.json | 6 + pipeline.mjs | 317 ++++++++++++++++++ references/README.md | 17 + references/characters/contact-sheet.jpg | Bin 0 -> 25996 bytes references/characters/kenney-beige-idle.png | Bin 0 -> 3323 bytes references/characters/kenney-green-jump.png | Bin 0 -> 3382 bytes references/characters/kenney-ooli.png | Bin 0 -> 1852 bytes references/characters/kenney-purple-duck.png | Bin 0 -> 3237 bytes .../characters/kenney-roguelike-sheet.png | Bin 0 -> 11137 bytes references/index.json | 57 ++++ requirements.txt | 3 + run_case_matrix.py | 42 +++ segment.py | 76 +++++ 18 files changed, 659 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 compose_preview.py create mode 100644 normalize_reference.py create mode 100644 package.json create mode 100644 pipeline.mjs create mode 100644 references/README.md create mode 100644 references/characters/contact-sheet.jpg create mode 100644 references/characters/kenney-beige-idle.png create mode 100644 references/characters/kenney-green-jump.png create mode 100644 references/characters/kenney-ooli.png create mode 100644 references/characters/kenney-purple-duck.png create mode 100644 references/characters/kenney-roguelike-sheet.png create mode 100644 references/index.json create mode 100644 requirements.txt create mode 100644 run_case_matrix.py create mode 100644 segment.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ae1c43 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc96b03 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..83ebd42 --- /dev/null +++ b/README.md @@ -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)。 diff --git a/compose_preview.py b/compose_preview.py new file mode 100644 index 0000000..abfdb6a --- /dev/null +++ b/compose_preview.py @@ -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() diff --git a/normalize_reference.py b/normalize_reference.py new file mode 100644 index 0000000..9fb1165 --- /dev/null +++ b/normalize_reference.py @@ -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() diff --git a/package.json b/package.json new file mode 100644 index 0000000..0ecd1fe --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "name": "genarrative-genielabs-spine-pipeline", + "private": true, + "type": "module", + "scripts": {"run": "node pipeline.mjs"} +} diff --git a/pipeline.mjs b/pipeline.mjs new file mode 100644 index 0000000..a9eb95f --- /dev/null +++ b/pipeline.mjs @@ -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, `Reference Pose Previewreference pose`); +} + +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; }); diff --git a/references/README.md b/references/README.md new file mode 100644 index 0000000..da3aba7 --- /dev/null +++ b/references/README.md @@ -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 diff --git a/references/characters/contact-sheet.jpg b/references/characters/contact-sheet.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6d6edaf8532f9fe870fdf69eb0d12895e5af2eec GIT binary patch literal 25996 zcmeFYbyQnXw=WusTXDA*FWTZ(D5XfCKyin*Kq-XaE}_s;910X@X>r$J!Ci|NcS&$h z&;S7*-#t&pz4x5+edqmg-*{u(l`;3oSXn!J&9&y9@|$zr&EBm7$kkPVDgZ1j008U$ z1GrlNC;_k^{Gj5?nE*?GsArbNY02n#o!Tqxz;9%q8;^5qm4!r*zfJ1>x`S_V4 z9+l2Je0EoAk>L0&0*;rJ9W=UQNKVoBZXtw3v~=_gj9lD2yifSV#3dx9q-B&|DXXZe z0oC>N4GfKpO-!wAY(Ln2w0Cg#@bvQb@%8)sCG=}pctm7E;`gNFlpjA+vvYFu@(T)! zimR$?V6}Di4UL^$-95d1fBFZ;Cnl$+XJ+T-;fS^Mjm@p?on6%N$?4ho1^V*pAADf} zu>Xy%`|H0k_CN4NanIKSockrk{|8@K54`UQn*s;-@iRP1MIHQiu2k$I!35MVk!#)xP+#o%11f3Wu7IQ!Qa3;BP=+5gSh|HIcjfCL-se)F&?015!KNF@KZ zFePNT_IS^d{A=fp7=5z^d|24HGSXm$I(f_oLVRF&wv}q2h7wMz5^1M2%gVQ;eDBr4DnNv_n_~_Y3$+7R?og_yo()c zanGz=n>1?hi8_D#tJ9Ei7RA<0$~d7v5mcK#K3DEICd@J6Tzv&=7g=o*TM$!`mmw+k$)tzw`KTPpb4JTa3 z-Kz>o94agfuL_QIM#(GJCylBW0=SWoxK%R76_Dw0&qd_vDDDm`M_nbYCJQWK#evZ- ziG7hUDBuoY+cN(Lg^TANGW3&$r$=}W`tq;m5tEfMx?1BeSP;fWv4{==rk_t9PMluv z)zfl|S`b%rFiGo3bnt#4Zs;{GI$)=j_sWjsYv;|51pJ2t)j?As%UT69n6?eQh+T_k zeVhc9w36224aXHr?8h`^`P_DEC(JjzDj#8r%>_RSw_7ke;|;D6$aBmolWu&?EBP32 z#l&wSTzbHnF4rvVdmg@#RQ5q%4gS*`jN5Bm+K{{7*%zl{S&0Y=X{3#SAE=*bqLzAY zVL3i}N+_?a_g62z5*&EaRCWUue)nv|oY9NORg^Y>+c8PwyH-_)KW>FkWM7*3Q9Re` z+5|oc&SZNjl8xqGc$uB>ym4czw8OW?%tG|YjDAaP@M52;9KPc&9M>s?|g<;2()shnHdPeJ>48c z4WXKV5rgFibtk|FXT-m18$h2O$4&d(NGm>;amOdCF4qJ26P}!8R&I(JH7!Dh3@bn> z%S?BG&`=5 zQ3~qvthKe3UM;al>Y`jdlPPN7q`-<`Z!BxsZz);}Bz8_3IF{qjJNv$lSVgO8QFrEPjXL-xw~}Hv>+Ne68?fli+Ph{GTb#4auK^R?qrw(=8~gRKKEINrRgt$4@(GP&&u%e7%yh)i z!Pk7!jOVFH;*nY!=%*$$0-2K>8ZrRbA*4z5cvMShW>+sW)f96hx6>xtGaN9Wc?bs?=B>Y#Gt#i|hj2l2^( zgt@1^g=P<&gDq*RC&l}nA-pf=g`(iEP#R0Krt>Q!-VQFaf$<5;D7cBnaPxaFCNHPC z`u$j+ipsU`Mit8eP#9X4Hl!RZ)gcKnb>R%j@ERGqRQ1F z-P?TH@Azz9avS|$)}di?FaNRm83^YP(QOII)KF-ofY4+A`rg=?1jVImE0PQhUTFBeP4=HcVEtkn?9qfyn^@4JDrqdZGJfi?_ zDTAp)h0IyaK(D=hvuLz~Cv%G!+-5Mz4IX z!BrK`%liUvijn~zm}E3e7HqCeovQ63lctC8j)8qHB3?43pe@T&&NlCGwsyvO&@20+ zT$dm_hUT%397X>%OxIY#y`@gE1oILFX~|pMo-c=P%`M#lY%a%c(z%vyettOjGA|C1 zYw~50QjqYL-^40O%Z_>^Tc)=b)P3N= zDp+pHOgp+&_OqqT{``0FrkSbziRprwvK`rM+0oHl2aO<*uNb-|L9|Nuk64O+b+qZ6RL)~kA0{p#4DMRN(4c( z+F%fLFADKoT?d3GZ*?wd;6yy0KvH zXHF~v=Lx5f2@|-drTZ18?;!_e)^!56xn#zeMiTDGi8*?1?Z&Q04_zs)UzM&+;n2zx zN&hywkMuBSBdV=Ax7q1i_g6`QQ2pqD;f4x~BRUQ*M<_eB<+E=s()1=lld*r_Iu=iu zAa*QE0`=X_%TTR@l=BO76IJJ!D&Ic7fgMVXKz^{YpMYM9!s*)Mn4 zY23V8Ch@Vgy_0(D#(>A=uV-khUUDDjF8QZ_dy_BdkIyOh zhwUjpH@pnFBvD%!8q@kirc!^C1rahIwsYr+C`>a+l_mcuC6#L7C-coa1Gq_aXN0BPAL< zRA#xp)AqW?bQ`6QDT^f-{zERG67gn#`u9lIM_xQYv+i0U1>qj0QuUFh?9n^AIf3MX zN10gwCVe#<7H$J8F?n2_DR~~B1!I|SEmK((4)gk^Ttl?y637<<)(w#xk0N=;bZMwM zm@}FTxIWv-E_e$n3mMElj50!pB5wRO8pdE2R-@HTxBVwM*K!(7*CNCF`wa#-zFx;N zE)d7Vz#(kI-Xc^R!VC5g`mhKgwFP$bUx?}-$#4)7^?jVY!~R2cQ<1uS#FY1^wd^9e zi`>VQcf;b-UV!)7CC1hoUPjAg7G)va+4yEcAS-=cn}dpfP#_jgPHFkFSOQboM`>H@ zBxAQW+9N?w<@0s^)%>74bE@XAzM$^}rYI6KXC{Ye@%H%Ob#h;*LUsxtc5TL*LU&0# zZ#{}WaU@aqdvaAB7wJvp>{2nC@Uxux>{k>n#S}!ie7UY-I+~od|G7o~k1YNFXZ?QA z_Kdk4M8*MW(Ww2ZrpPisu>`Sii326Q${05@!=%J}qkk}D#V@I3Y{;RVWn^=q6j;zs0rv{#<7a&XkM zpc}Qc(24hY!i@PqaYSA2*9(D#7u;@-wl#N3oc{A_L@4rZV#)1V6;EGI(o)Qgu?3METVsdt4M0-R+}`Dz z0=h{7o$T)+gg9?wCzIa(N80^=>eoG6H5d%!K6Di2jnVenyVL~>w&y8(LDpUI6s6VKx{HJFis;g_J0?2HiH{i5s$Iuy6vg&sj4@vVk zvO+3!Bhh3CXt;B;T~U-f3v8-o{rE|L#(_?V3!ak^wUYgBg8e|f|g%&np_ z?zisIV}vA&JQT}_R_*dSh_bL!YHa(dswg1(qA;+ED{&XJ>Z1^OA~*9imiVcB?iR*kt^LC;S(j+W z_8R;4Pi;EMxW^sf-f)qht}Z`n`=Xg;JRmhumIg7IbG>btTqj#KWb0P5{CwVhQ=f^J zlv^RarT=wrFO5 zA#y%_eXLK>q2-zgZ6Tl-FEG&raxp-oV6GMfxGw~V}q7^D7 z+sUG7PnOz3A0ankd{nF@S=kGbr#(};CjYvnQ@!PVB3pYOJ}K@2 z&{ixAXI))m6LJwEIP)IRnpf6aEp!JkJ{Wl)jXm-8UEh%3j6)2v;M@=`3kxWt>s)52 z1H|EYAJja4J@Jx59gDj2!Sgs- z_2iA$LR8ke7mF*juGtDKpWNi#%Sv0An?yVmqmLZ^GM&)`lJ(C^Rdel~pqBmlZ2Lti zanOSIxoN}CM{#QplNm;^5|%f`F(x?x2zMJD2&9TkFDX?K;?Vz!Jq{#fYW?IElq76T z2~z-^5sHBSK~*$qtc0UTi$Oqv=ntA!{S8hMgcuskOxs;u<{O zoOsKaqtY~0GfX>UJZW8#v~XaQ!mGp1^sr|&SwTWX8ymYE$Si!ofzT${J!ce8k3)dK z*19IS>CRPZ+>!JHt9bERqxpRXd4|y?{%ZZoC50vKUQJETss-@M)4Buf5+6HDv^0XS*_q{6@P}jj33b4` zz=}&&oxS=B?2X;L>f_BexuRZG8{@gz;`QRh_EB>^&^FqiBVAQJm&at%Ju%T))AGt< z_rmEaL*eHp#Sov0Ye2yUwieJ_>D>WvYLNRB9cxFlat^kF*7@yAV=t0{_=wk5 z7o!_7MtKp58&P&+E4BE2eQ11S?3%$?8h$veNTFKHcI`q}B;h1}Q6=a3v-(UqM$Ffm z>KTj`RIuzjU1}|~*qo9ATjP262#}}Yy%tbG7qrKQeo_#%HP!lb+mZrkJil$qK3tKG z+tXcceg{~g=(;rWyk^3XPBe4DnH0hsrxJh&Hu9I;Fkd{)ykBwOg#1LIL)sRl_~iln zhTKvz=0}h)E#il$hl86g86-h(r_-keH4ts#+_g!_mAO>2EF1bA&LMiLbb=SE%+E$y zPvAjwOsf(RPUFg}J9f!0s0StT-0OpH_dk)O`_Swq8iyl<%7Kykxv7wy>|`?880f@DO#q(DF6xh3IX&eiOCIOiDrok$g;l`uDK;If99^v8qY4 zJZ4J!gC0hJ>``peSlAHXcuMPGtluo3CtZE%=qPkfbuDdWABC#)*ju>JCfJ+kvo7g8 zI(`**Tf8-wEtue+l&P(^*>sEhc@pauv1`Bp46GVU!zA}`4+qv3i!HT6)o`Ddu_RJ<{e5> z@ec3>)WZl3J4wvl^)_TUJ6^ZB1I&Tq%KylASNyYFMnm2Uo@R?KezuXIhZ$v%UPuQU zL0Kz(0P)1xb<;D}hP4-|E$cBCp-tH5#wRyp3@fq2{6_0mA!0@1WtJ=I!6Ab|o}}5& zScq%Kb`~RVn(lD|f>t^V2}%o^9akOn*2fDjh77wgk04Y7TuBUUmX%bdnV4`bVpebJ z?*JeDkD^zog_=ckmnAXm5X$=GfnG7kyy4EYYV+To{kVG2KUnmOQ=1CJ5PY9L#P!pQ zn5^VO+e>8OS;S1q21<57AT+sfBqx z^$%fU(*m7(wL0EZw6dAjec?g1#yA#vB)n=&$n5(?;eu0tiv(w;udd(B&!?&Si|)Iz za+T;7cdl7n*68j*BCbyN+;@X!f1Q77KyM(E=(;5#-jQ-ocM2TJhg0fxb35Dumkff> zd=`-eG?&-cz8IETv=yq&I83d^SS*gBTl_#?>;)wU$J11xWNEb6EHr?*Ji= z=*BfA^pQ6lDt7a$+f)oC7O*-u)CW`TIMl0M5y7{q#JK>?}R7G7~52~KT&fePWc zpqn(FbtQILEF`j%U9XO!SG`M%1*1!XNN1sU09>Yu^ojFuvj)Yvpv*aKHu_5?4Th@| z257z5HGiN`?}Ap9wh*ye0=<3^(IYI?bzC2340R$LJlzH-B}0@aIoc$4C5Xr7RoFzC z{P1;Svq`y6Gt@3G`U{5&g<+LVxr-sawhZ7y$Cu3e_zhSX zsWeTR4c2Jt{(V(P8RTlC;<4!3&S|qsl7I{OIAZ|+g0hP_OC#=RW&PB0V(}{Z1CH!i zN4pnCrp^iN8i3Nza7v_+Id&K-`%~#@*-*{v^E<%8a_>A@q@UnEu-2@M=-CstZbRP} z3g*m^2d#%U-vr9VpmQbd5_Q-Quin)&TW=&A#Rs8gXi$;|_Ha!zgw??_xD}QUgbs9| zphlL2WK(wGin5$F|RU=rBZ#%IqfpFys~?LLhCJ_GjJ2hrRh07NF=d zVP4#n@|L)ghcm(d`aZU`HOj;N__afs+TcG<)G1SA%?60HeSVEnNh)0Y#$G*H=ETVv z9BM+Hu8sW(bZ&H>U6o;QlXm;q^aY{kU#9}N`*C-xH6$E_5>-mE?dsOP>RH=W=!Y(_ zg)BdtkQWUgLf*oB40i=VDzv7lRMWOfqyubd# zx{5!U8QswORsAc(0v2tw=yjsWdCXwzSXpA2nmawfVS7UEfpe_Dh(RIgqR%vm^);RP zj3)DDdOB~C9}C0^X;56(vCvDee%|X7zrt3}lYG5C>ZabTpi5UYJQ@fFm-&~pZkFtY zIUgP%12v3jQ1YBp0ayqhXVo87J9NIRKO(~W{;oz|TAyd3tim_Z`OBl?RjqWxw}kZV0DjVT?@;a6^pOO`u~9}+nYMa@Mi%LFJRI7k+g z%vIXGmi^zC%!z$3_fyzC=T3hZYs*)()N#Dv^q~xDRNPpbdj918#d*HZ({z*P(Nw6& z|1RHZQKQ!-pM&i-lzry!9RR5}{E`L@TD=huKPi~uOv6)Mid{;o-nM zs-ehw4pg|c546#V_DzXQSuvkSA3dM^!=V$4Qh;aOt0gkzJbLg&^&}X^Kkp8kmP9 zDktEbjkt6bcB~?q*uLZ-y;9y~8~^02sHlw`pI4Oz$7dRl1hssUuQ^~`{iwmr=t0p1 zrW63-IM6=*{3-Jx9{y{O^49s=QN2%J9tYF=EEHZv9@D5xPbqv@5tv??(NRwe_*w^x z+|0kYZS?h)+cQTVg<-U9Z+;>yh_$Cm2l20;pA~Df!v7w)sHm3t_QlGx1jr+MVEF~P z7(%31$%m@=LQYeb%5*7o7kl{SQASs-+C@nlsUinm&5xc8xRdJEBn0*f?feEG3$B3V zxTV<`73|XqsMK11eg5PFnrQE5KhLHQ{#T4Ra-wJX(bomFn{<_2233Zv!_+hk}qgqY+p8$@2RNxHQl8D=%BSn%`?4$;YIF zO)TwF^1H4bP(COmMr|tOx0jvV>P~;JS!Q-5zwUn)Y2tVBEApwQz1eKty7x;0Z~vaB zQ%rB5H4vbwC{+|QB9PCQ9=ERT(Ub7>&NtK#U07c9jc5kg!sCb?SUKBj?+cuy%MYE_ zDa;l+8|;}d3hn7hXKh{1bNE`-b)R+6VTC}hzJWEZibNaNi>?7_>%D6F=kznr9V$Oh z9*YJn;3n(dCkc~&%?X;FT9s7E@1lc|P#}uCrd4_ZtgAqwWaDxEjXZmE#m6OX*C2Ra zTXRSjwINyiBI5Msw$tnXYUmG0aDG)0*p zpW>#Al#z*+K!(5el{H~oF6&fho$E8QSw;ukzIq*N#n<)oqD5TQn>+Kj=jq9ECA~o; z7-AIuA9-QqspJtjo;uyShdu7_C?+ZLqC;v_stnAwD)bYvf@gt|Ms9rfV-0o5=8u5C zzH~6r_@<@tN8i)&BpPMemV@D~4xR=(U>2=2xG7-TQ;iv2G~@T1ik$hfZ@N_Q^|51y zU|uUpdOPo7;B9_wL0G~?aTBNa#+)TzNa*X%l29pmTRK4@*!;QG4<)wHTPh#M1&wim zoXT+zGuzr~m=>Ci@AG6eD^M;$gLSi&`KAoQ)lc_jYdp7pqyP~WVnYO3C+hfpO|t*u zIpHZ(58Z`Icnnqu572fK@EBz2od3)A^T+8MvX%JCJHRd=6?1_uLXqjB`of)H3+Mb? z?Z*dj(s*aUb+LkOAdoxlrg^GXwIQVlMf;9fIin)`@Yl8O&V}>NuFL$F`w+0n7k>%g z;Re?*JMvI{pl=&XnOg6kV!he_Lfy>&Fy=vT249yib*u|khx||aTi6xTJq7|szQo8> zp+V&!!&JkvoI%%h!O0M@#;-t2@W8=wGM);q`)`2nW}8g$L3xocfpH4u!$^7~T3LK% zTX~gzk9U=nUE1k9JKlYx^WH$D@^1(Y+PQFc&r)b|2@EG!tM0LG0?4lMjR2Y6WO%J% zwTv)HPj@aagXUs}o@HLEOUwx25wCm8-FjZ4u9*Y)8c|2xP0m!iw3p*@eEt5iYa0oB zr4-RuPnQD|@tHy);WGo)MS7hBVnr>6WtZuPwC>%#dWAK9oD#z1=q4>Do7e^YXb+wX z-L(~%=6${&{|&R`m&214%5{i8s7G9Qeh@D78HH_(Bnv+}tl1I!*;6@5!fj^HIZvFg zCy;0SUE17MTYSz8r?u_DBE{l+;BUUTj->0o=wpDl&yjn?(9OKn@Tj`TG& zGu1pUxs-*_$$F!f8>HrJ;dUywrzh`J;lUXrMOs(q z)yi{|2f;@h&Xo(@vhPR^QCIckzZSL_%{=x71T8=EpKdKHUk24iCaayFCq8mk_2iI} z+LqoipKb19;DwykWF(b)tR9z=N-uvmm>F5|J!v}#M5$UK3!*V(2sVOUJlYAZ%uk+? zaChexmY@hG00a>7kYKllZ9!pmaGPS+w8^2?FFeYl(aod=x`{QA6a5(Hlq+S0W<8@e zEx!65Pmz6uxdY5A6wKWLdRrA3W40}_H&93Yn7#4A<16ERE@}NGD`!LN=wAI!gdL-^ zrUIv*bKgCv6NfVD^DBr-9o=_bzI2&4O(q#L>&-H9J(Cg$oLvjH)KecC=}C*CnoLXt zTB)jk|JdQ&h>mGOhsP^wx=lxsG9_y56KdE>-)w_F1Znvt3$-;OL^x>Es zP!FzWcut5l>6eV%O14NRX*g|oY_V*vj6+`J%azob&ASVf!#c7`VuTb3Mp-An-U6(C z-sLZALZ=``DcVz23J__2SF>y67AyUnO4!$>-L9wQjI-WP%+dSTp_cAZvE1Um9>5YS zr@lo$s{^c6ZF2PVL$vnF>tAu84 zwZy5zQV-7!G(bM{CM>ZJ*rO9OUf{X`uciH?(IoTKZofPJ4%*ywCwfl}malj=<+RqJ zr^?!!#1-%(GT8P6om>iTALz$lP8O`i^hzN$H~l}X2BB!+5LWahGNs$4H2GGvvdT?l z%z^aW(CWQSXGVMKY$5{MMKG4@9Nx@MMvezI1`@PeURo%&z<=hH_rxN zu0;5=;i0{5Encp@@rqvJv=u}=X85#-7687<`N$zS1M^&Ql!wItN1RvWO2_l%btC(@ ze;ENw>|qDT@>VyMZ&Z1r#r3jdH)Tv&0K};TLM=lrjf2$EOK$tAqHaJa z_$D8tp1RVlX*BQy$eKZI>PGHgI2hNl)CaeGY#Ga7N^leRD36c9fOET1*L8JKg>x1b zYL&_o+f$699OPI`)P>KzCHR*ilDB`;$ zXNq_jddK>q1J{~bh$%0ZZJ8$rr$u(thnz3t+56ISnNv|EHpnuo`MLsqKk5^PiM;*s z%Vb!K*ZG=GvR9+;hxmEmhI>P9e5`ST-|vev?&27?eX<4$3>kVl$WoKbZqg zYl^e+caN0vpMeAVe(1!?7X{i@7K|d&Fs(=#p(!$O%jA>YR$1R*oLl#UmqelxC+pF{ zyAIaX;LA6Jx(q-ZH-a*Pv!?Gapp4Qiqj=32hy}9)u*rD8)0!(3oadkM26C@psGGUg zgeiASg}hd7+t18m9YReX}JJ#~F9wz2Z$!|Fd(S{rU0F z>m2@j@bG_+|KIEJ|4SM8xVOwYE%z*t_E)*dP3k6y|G6U7kkN3w8zdFV#)>7H@0}JNAKc7 zQoE6=+6{zNVQ={HZx4gkU`hEohy!aClxH^R!iwzRlvvXU7^TZ*-*&rL(-4?KSALj5b)G-GLse+SVZI%A?F%v`wiyfAq=alu%f@KJro^8=Lf;-7 zISL3{#}b~AK3Ja6n*9(;pOVgO=R|%y`-VOed`iqFYZwLo zB}2Ql?u1sG4?*|r&#!Mujw10%oXjq2&}?Y4=fgN8sC^J&6;39eT!g#_Exra3_tSL8 z6fmi;AXXt1Z3 zu@A=d#0}T~>~_uHdTb=nZX1p*_l;ks^*b6D71aTuoowcS1ZZ0$=btn6zHw-MTwD2B zC;d6-wE>Kfjn!W|Cadv%f=I!Y);do4d@nL6TAmpi)+}DK$0}7fafFdX9`#t#fS>F; z<)$O9&OE=?+IU1{Q%IhFS2y`BQYE1;JgWWkoFoHjP-9R~|QlFz+N;2GG{D_A?;RaO=Uv?zC zO$Db~T7L^M3M?GjXVvx8`?Ok#{{Z7PD<@}C_1m0ch;L{WJtAh+8LGJj>^j-@jsoB(Jf+>D--Sj+LNbD{=5R?&kyAGO6_L= zp?qU?$;ro>Rj*Zfk~S)tE{_gdpQ6=g;D;smvWtFw8uQ^8#Nsw#kh(cz49Xz4S{#+G zYg23fbMTNOd4X{Agx}X~_V%8~pHP>CVx_{YAD>+vw0qu(D^EAnqJE zg|D;MUlU`XeQ9^yvuxZ19N^}7emr^ZkhS3(3qso&eJdku(sM~wyLOVaWNBx{&7!9jbjiADl+3_2F%GOvo8gUYG60@fkzzjhyhSHM z7d7z05YALSwxtPIJ>}6?X{YnUp+krJBYR9W&&(5y249n4)qgTg41CZRuVZDi4N(|t z_Gp#-+f23Qdk7l_N#0_EK>QO5!Ta&whwT}2RV+q{1zQD@9SKa8f*ZnkP;sWn{JJvq zOgn@h9flj5Ba5ay2WuS;Z1fDxI|Xy)ohq}(ED6ailFYKb8wy{NKt)+7`gcF$K^SSYFw}of0LKt_? zZ)I3%r%vN4L6Yfw_FyNlxV3x9uz!#@R~q7Fm8xy%SAWOn1D9jstp>(lO#6{r_jUhY z8=WJXg-a~ZSva(3DH}j1NdbA{!_hl!&xu+_t~vk z_iCe8JIs)3X%X$+Z74(7R58vm2A}LLje;*2Gp<_hbb_Du3B(9gDZ6muLYev{bx6gtwBH@30xgDl}H9WtVqor4srQ= z9pckk1V6YxMlyCLs5RPOJ5&1W&BV%(;Yryv0`wlOtA2YmZ`<`2^Rt(LN2WDtx>efe z-PS|^N4^i@YOABtN1<1O&TGP?hS0On!UE)O-|NG!$ndS^$|KU?wM&zW9%wCdTK+kP zzY+EDr$Soha8^F_`DTnwa_Ufm4}^B&Fsqh|p1z~ zKPi*`l%Oa7f&2h)bzIQ!{OT36BDN+q*23(d9PrFlzhrxqBH-ir8O2%@L4Y?1cLat! zW+7PKDJhN$RKI#|N}NO0?=imgS~D|sQA0|(<9KBD*7_vP+{{1x{)}nnzjs99e|;a+ z7fo08G1dE%II-rdfafA@;rl#&;euQK357=A#ws3&5599fp-tp`Yb4LzHdS;of6X|S zcaS&e&q}K7wL+5*n}YEBd`0~E2qx{p#1?396LbIRf3nG(uLZ z)#T~TD{3@qAF;cxCt7^fv8X!_&5X27XTyJ`S=L~vVdq9DEl~gGL{=spu+6WI-X@Rw zA>bK=WhvwEMwu+d+Lu0oJ;$A2Jrd^;vgc3Yql48MPS->?jrfIIroo}zqR=mo)(eWw zz$>Fy*Q1izEo8vPvBUgo*rbyj@!M9?>Qe+2d0X9huBCbc%Cp?-%$Y^0TUz6O`by&*O~#$U2WamohpOLksX9DM_!OUf%87f)z&tUBcYvz$nv!rs zTjRXHg1M1X6@Vc^_L8-wPn_!)S^XSkol9CDA{T2Vjo7SVk)T=~=D8;i11j8@7Ngy! zXu4xK?KhSd)j4Wjfxmq!#(u~|Kd^o#EnV6IODnqx>^lFTO!K#Can#+@Rd8xH^2sW< z*C!fI<|lG4E^C(uyx`A>7)9ZO0$R#J|I6KJnp&7Lv4pt&n2xi zIZX3?(qr{uo{+Ntz0cq~G-er^Z?#;WWKC^aTOXo8w(IH`rwpXfNgGrto62_F*pSN- zjNHP2UB!nFQztA|7OC&CW<6h<+M!~003Yr(D)_?uxC^hwKv>9X?^twWd`ni@D+R(f zA?VTa!Pv8naNxYQ(XBV$d}!kJF<9uY6TMJ9`x_qySylDL@qLeBcOwhd^ELQphO9=ww)DioEjBuzu7SqMF+0k zA20TH>E26FtE(Q;=Z#4aYY^^39@Q=Me_)CQB{zEd$35($HdPn%b1?+V(Rp(NwVbf- zH+@!$<2s9PN$XD%)9+!L1{=10)UoT$FW2av1^0`ivjd~U2u4UTP7XeB!i{qA?H!9DbOWbZ}z|zpuVKHPSBV z#myG&uZBc<7;SL)Qnq1dSSRkQiVqp6n9ood-m8sLMP=$Y_aOTJIr%p38Q(uS>-}i+6$vmR~&H4h-;0czOV0VVbB8Wh zwMEv&0g6pidWKaEu=;vm+9RE6+t3b6lK5A-TXn?!TQnmUj-8HAnUhDqGtIOHsqe@I zTIbCLC{693w$Sb=S6=JUI~o%%FEM!IvPlj#qfV%5Vu9uF4Hp4Rzo{@-AV2kI(bg=b zZDnHAPHVohmn|zrJMWyM^}ub@S)`Oo;Q_pnz?Z--yi2QcV>e&?NaLL5`lQ@2eH6|5 zeNMyV6&U`?XWih1M$*c~3+DW%KHRytV=WDl@cEU#PUUKt=e0_6ae=#&ZqGN!ULz~S z2{U~*jvno*wlI%Ju@aVgYWUNBY}RD^T?M3n?D8o?Dnw$H;`6Bb<&~D$mXn$M9iXXk z0Td8>RX-Dc!R7DORDJB7h>;r#s@%I?3p;>tepSm$ux5sky|F$a>{U6`KY?izX%Fhn zb(HLQ^@wGlfp!U-Lvyc%1L&B)Qns^HR(i2(8AssJKX^7dHA=P%5uEY_nZ?jdFr{vtp+tJ;-|}dj`tJZ~R~Hg=dNabS^~o1K@=pze zxSx@xgs>ZY?|rdG$!my1xy9gqQJrXMjR@wth-DrPDtv9{qhY={r}|FxLDUIBougu0 z(+NO@jG;V3ds?2;Owm^;9zjO8FxNi)en-#p_}Jz#6^F~xUtB3EfZc-zC?$91t$gZ) zSwp%#yk5gQ(CujCZSjL(vG~MxK;T6CD&tP53L<-Hskym{Mps?4$;?V&>gV|_W1C0F zCW26xxUifv@|91&HD%)8iuON83!HY;gG=0+BD|X8%P*Y8(1%$hg*aKiVq-3z60#i2 zc$bmN1U@t?ljo1mQ^IZ53oO*$isyHdv(;by_Tr_Kxovpqjn>xd#@K~@#=w@MuT#3_ zYTW~C49*v$(g$&QtfzS&>^$O41ab^Ke-@Y+8prF<7}Gc&N{?FN;m>fs&zmT-4)hh2 z`z+&Ip2gjuq1$f9lxqzmTZ^3{@YfyxGe~GL2$O&7n%>9qtYg@CjH9-GI&mmPvo74p z`ScDTy1zvPF`OlKUsz$Dju2bmYAT^S-NQ}5^_-|n%wJMh2T{DzTEuKqDrj;gioDyx4ho4%vm+0x0-DDF)rU%#%zQFrZFti5{armZ5j~PbDi#t4XUXzx-i%sE-{guL}aryf)j44*$Qsvo8@Aq|jMN?vX ze@+8kp=VFvJ~e5Neo%EmDgPW=cWXa3I94zEKzaHDigP%uHn06fP89J(oDCP9dZF7q zj?n<)eXqMB7W0+}HktQ*|F+A;Q*oVKgP%=(4n-FY16wPhq;KcYNxtX9Y+Xm9yOJLt z+dtwhp5en1dC^qqsEA_=JTjT_zAxB6fJK#Q_gq0EBvcVTYzE21c?s*gqfB7i%h;VE zvv!4Gxf#Z-Xij)e?Yy(?#_?@@wLNUOGTzsn8&>df-aA4Zm^qQtIK>mLm_u+b3Vv_U zj`w0D%j-0brP{>B-jKg=n|xc;YGr9`et>W{E%ffcB<65*}~s0 zdwyFF9X^bcn(NgX*iKPuHcuf+;is4nfp5DT>sw>fXpj3(T4^}Bvk~EfinG0_*qw5ly%UGTtlys z%{5(F2H!#&Tap+k^jka-v<*+-YtW+%qY_q96wxt%b)^7288S<+4KbaQ@K}uuG{dQt7uYgMsv{0HZy5s~`5@xcFxJt% zt5PGP$6*AQ<8@Mw!&oLn4QUzpnNf<*MySEvl>VV$|6+j?U-vHnyL(oUu(M%O8S`wU zez+34cjbFuV@5D$r+PVFOlo=bYGr4uc=5HYrRN9#LC%Iey1_|SX7P;NdyBQ5hnd5S z(r?45vDnZu@Fdf&Jcsy+8}XWu^7~k5U;ly4(-IX+o$c1_WANpIK1jX0Lb3B6dPbW! z(6@Igx@Unu*7I6`Q(oI_VruS2T%+?*ut{wjxcQQ8HRw7yuq8y<^C-#OjY_+R6Eg!u zW*E@LMVedw0zfT8vXDPdSYBOx*^V1~lK&vKy@TJB)n|?YseAOT0cG;a*kj+AvaJ@Y0m|W z+kcdJRn4Zwu&)$?!=2xAhSGAQmu7G1#%NvBe8h<4!)k*4HpIrpqMNfoUwaHA#9uqz z>ymoJN;a~$_o_rQDF3LRh`!=(hsL%d*)I>6i`C}jb zRBDEgMBAULGFUR_3mPE(TujD#SN-xXQo+$I?bR?`o00=8aOVTLdP0n}j<;_YT+`Y8 z^>ThiQ5L+T>9zj>7Q3dnO26s%Blt>mBFn$# z1;0vs%7GmRmErsSk7I zaTAN@rCvfa{Wm|tXP(7m{n0mO&alm%=*^|NN3ERrn8LBq1cCHZw%CBuMU=C(f&-6_CM263Xzy`ZY?i&L3G`|-DERyp&46p~yw_2yRgL8x!KBeb- z6Fs3YE&d-F+fJyE{=Ft~JI7+V3GfCJ^Q3q^xTIe8*h=TFk@q0k`{p4{zK)i{h9mzj zN4buHrXtyws6{a$RXIIOlda1#CR`?}ucJ}Xj z!2Ze+_80F&T_rpv^>4)^O7~T{r%7k0cV3MP3&G0DVjo%#iYz25D>q!ygE-fW*n%8% z0AwR_KUE1Ndqc@xgJcDU9|6$hV7=CmI4PFlqaGebIo z?4Jfvjd~8d;td4%tg#G2jiuUjTPT1_H7%efY;1+M%HbJ?NPDT7uE1q(z_83_zyHLP z@Yu7Hia4>%qs?tVi)YdirYXv=KN&vdl_fC&p%1DU_i*P|u!0Z>n70XU#Kd9CP%ClMzJ%^%s}$q@!@WthQ+YmmeSd`VwF#yY=1>^>LL@C^uTt zQOF3>b6I|_&>P+)wLi6Cm0JI`^St3wji#Qh@r5e~1Ro8&_NKwIdOqG^bX2!S5c+xAd`5sY zSNlF2hx0!Pm`|S=9QzccKkobOS-dw4k5PWRGnxdANa3M&=j9EV_wl;Z0RIg{bNT+P zs^qDOB~4u1y~_U}e-mrKLIxqR5GL8p=@6dvfEf?rSraphITXh)09cKD7@RLcrZuX+ z;;HBJ>glMcrR=rIc%c2dszBf;(j6+l>_+Emr%p?>OhiuAxGad?VVtxG)>99&vfZZouzPVo=O#B z;9XaEM5`j@kf^kF7s$*mR9%j5IX^s1kONu2NM~WT%q0Jq_!OK|((PT)7 zDPCSwCJIiwhN{OfeV4-NG~k22ScJG75p&uGNFZ@m;rwVbajY>p@MQ6l z3Rw`r?WkU_Itb$oxtlV#Goah%$7j?u#+Yt!S#P=tyc+gOdpPW%4FO%%5ml{2Xz^N) zaM2G9Zn_`y$!L4hzKMg~8DxS;ybf;~Ndmba3r)|M9kEyPTkM3@B0Qg^`6YORgv;dc{f5pD8&1-tShf)v(IT<+OkRR7ZTGu^aj?=Nda`ILa)$Y zhUVjQ7kXY7zdhshYn%Rp8XH}@O^5}+7h|}nleAZVl9nlLxWltGg4Fx4J9{%I8_+IL z$^YYC>s?CbZQbtpoiY-i_Qw1>d#=FLffOc0!w3nm|H|7iZhWzq zJAcQceHIJx$T{~Qwc|0&Qkq~?PWj=G<%PofPIn@6hyp10U691i2UCfKEZZ&%b@zQJ>XV`oVF`=o|&?-SpYkN~)YuH0!3P zdX-l#URvU* ztA-d_!R3Me>`2)W@~!jUqQV3NlTL{}Xmk^QZ~O2vlg70RXO~)hwmN0xeJxAfjtaF~ zZIT7Cpu?dKqpHn=Dnn9Y@2_Q%e^Chj(S_x2-+L*!O^5b@6hW|H1EwCfyu{0=Kj7vm zefrUDUrN$81s+?f61KbK9B(<9J))q1z`L;E@y8t4>cm$AsvS{=bvZ3WF5zVJ#0ou& zZ*zlwIi*cO#h0vO12FUo*QvqviVBZc5)8M$`$p%ko4W|3W0|&W1omC9mVF@Xp8_n6 z<@dJc{BrvArRlFykaq&43Pwgm2w7@_7W{O?>x%G6XM)C^&9C!SQ zA18P4$T=dQ>75={>z(-iS1q=tG&f#d)o3#|iVO4VWcv-{hGMC@hO_5*r)(3Y%+n24 zkS5H>R(p`NvlDdpjyQfBscKm&pD#WY@Z z+>TLC^HbfaHVk4r%GfVG3JdPy+YRHKnJ7>~`=4}{)3xc)t62ZuYn01+ND~2_j6!wx zHP70b&U5dZ%kTDyz~zKI^AK;D%$@HBXV=fL3`-T<}M zc(&qc;dQOC3oTERuP1i5h56ym_umySx~bMfB0MD38Gk}hAdreqQIPpB0G|)!L1I2P zXY4d|jL5q_fTFn!#^>it#k8B3<3sg9ureD9itJ4-VG-h(wFl`kgu9Vb4)R__WVUkh zpxSSb@E+@X+;GsBd&z7~rK$S%D`~aW{PWvX|G81@U&lc(A=367u`@A**fwJ;FPERH zNY2nRdp^8&z_k2X@yy^Mld;+%gxhUO9GP4YU!@~Vx{ArqBZb&|x@dO8Xr{yNz2Nq1 z%CCm}iS&m}awb{4FLE}x#~g8?H_*Bnq2ya7KnwJMxG<6e1Q>^PKN65dLRzB(RBmHI z2}fWZgqsMqKf$epl1tSwGs8X4DT(Yf6*dFiS<@Rr5jg@?_MT5FtIcdKWN*m-@cD%M z`qLo3UDc-;o$kH#Y^58AXsWB)eC3Dw(bp`P*Nx1x3Q{*szb%KkHRWlKmODeUyp<1w~na6_;QgqK*e>j6{g+R z$5yd-8j=RjnZ(_Mwf|HU&F_ugO%v(j|A3{lMc~&~4{roguN$PTk4f-ho=Hi1+iIrj zL`ycSI0QLeM8xz#UrGnBd=RMb>YI#LXhK!T-Xu;MHL2fdpMh&{rrb(lJ})Q-=m>78 z;MWsmdPpcHd5D-B{8APRLo2;rlx>CYf|tjWs6WvNJlNo*0FXso(>^SY>c4tvDyngyhmb@rlCS(dHS@>0Y=0GD8$s6ea1E!{T_8wfU>W=5=>L99X@ z-v7Lt;|AVg8)YG<@J-msjp7#B4JXFt32ATN!D~TzG+{$4v_OgVws01Uo{ASN3lk!Z z_3bS>f^ll|kJ*f#HD%k$DCqlw`6B!E3;DUNYi-!mk-?O(A3e%yI2JeK>9 zP5^SBgy`rka&HAN6TZ^e6!J#4OoWD|ZJPFh?+;FX-`knd7%P>kL~h0mU%DZ8q!)!` z*zM>Zh}?#eu#Jk=Q>l33^$*>-@<^ZrD@Mune3H`SejiXPRpPLrD%j;=^`q)Xn!QKC zm5f6Lf7_l${(G|b|G<;hqgZ~Xu-nCL7p;t(Rjr@ISgH1ov$ac9?HccKGku(~yvkLp zN6GNEMl4FhV3F#SFj%(vm|W>+%!%{lGU~zUJ9!K?ssZS$GR>NEGc<93{dN}uTTh8v*g?Jrt^!@@c$*y1wxZE9jXB7@^gH*yl zs_vvz|2dSqL~SdE_Ti*i|4p>vah1&C3V{snkn zF|)b^R+Shp<=*x;4X$?4bTD3#QN14pO#=Q}CDk|6aR+}~z*?wl9tx!Sm=29mZpHN8PVrD^ z^Kc8B+lP)m-H5TSAxuOKOGpc`)po?046mV>u5I7-~JG* zFUuNg9oXuZu|>D0R@0Ra-OkX*kh0lhuq5>zp$;&Sdu+WJCxTPGyu&_!++N*ym7p?% zK>DlT9xlb7PXtv{d}%B;tK=alHiqsG7ETpapif@F+Ec){qZda=D20SDETunJXTn`R jPE58!@GmXa|7&i`zhl+@|M8Fv#s5Dy|KvF!`!)YB*xw}m literal 0 HcmV?d00001 diff --git a/references/characters/kenney-beige-idle.png b/references/characters/kenney-beige-idle.png new file mode 100644 index 0000000000000000000000000000000000000000..58c896266ef9526802d5120447f495aaba3ec5fe GIT binary patch literal 3323 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 FV1kL~vH<`9 literal 0 HcmV?d00001 diff --git a/references/characters/kenney-green-jump.png b/references/characters/kenney-green-jump.png new file mode 100644 index 0000000000000000000000000000000000000000..79f990c062cebe5e6df24c6b3388aee3c68761e6 GIT binary patch literal 3382 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 M07*qoM6N<$f;)YpiU0rr literal 0 HcmV?d00001 diff --git a/references/characters/kenney-ooli.png b/references/characters/kenney-ooli.png new file mode 100644 index 0000000000000000000000000000000000000000..2d416eef60fb8507468d943fa15eec322836c1eb GIT binary patch literal 1852 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 O03&^Ky$T(NsQ&_Ku9W}) literal 0 HcmV?d00001 diff --git a/references/characters/kenney-roguelike-sheet.png b/references/characters/kenney-roguelike-sheet.png new file mode 100644 index 0000000000000000000000000000000000000000..25d17ee7c52304f5be73d22d2bc920632359d812 GIT binary patch literal 11137 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 eZ57iWl|V$fzfXq$4CyKGsQl24Za literal 0 HcmV?d00001 diff --git a/references/index.json b/references/index.json new file mode 100644 index 0000000..9595612 --- /dev/null +++ b/references/index.json @@ -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" + } +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1655c70 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Pillow>=10.0 +numpy>=1.26 +opencv-python>=4.9 diff --git a/run_case_matrix.py b/run_case_matrix.py new file mode 100644 index 0000000..d3ee97f --- /dev/null +++ b/run_case_matrix.py @@ -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() diff --git a/segment.py b/segment.py new file mode 100644 index 0000000..db2e5dc --- /dev/null +++ b/segment.py @@ -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())