接通第四步

This commit is contained in:
Ukawka
2026-09-22 01:02:21 +08:00
parent e9072aecf2
commit 8080d52a99
10 changed files with 960 additions and 22 deletions
+23 -2
View File
@@ -7,12 +7,14 @@ import {runUnderstanding, runCompletionProbe} from '../stages/model-stage.mjs';
import {readImage} from '../lib/images.mjs';
import {runSegmentation} from '../stages/segmentation-stage.mjs';
import {runCompletion} from '../stages/completion-stage.mjs';
import {runSpine} from '../stages/spine-stage.mjs';
const help = `模型接入与结构理解(新增模式,不调用旧拆件流程)
node pipeline.mjs --mode annotated-parts --reference image.png --out-dir output/run --stop-after understand
node tools/model-check.mjs --stage completion --image local.png --prompt-file task.txt --out-dir output/edit
node tools/model-check.mjs --stage segmentation --input-dir output/understand --out-dir output/segment --sam-dir F:/Study/SAM3
node tools/model-check.mjs --stage completion --input-dir output/segment --out-dir output/complete --part head --prepare-only
node tools/model-check.mjs --stage spine --input-dir output/segment --completion-dir output/complete --out-dir output/spine
node tools/model-check.mjs --list-models
node tools/model-check.mjs --dry-run
@@ -28,6 +30,11 @@ const help = `模型接入与结构理解(新增模式,不调用旧拆件流
--parts-file FILE 离线读取已有 v1/v2 计划,做 Alpha 校验与种子生成,不请求模型
--python FILE ai-spine Conda 环境解释器(默认使用已激活环境)
--input-dir DIR SAM 阶段读理解目录;补全阶段读 SAM 目录
--completion-dir DIR Spine 阶段可选补全目录;input-dir 指向同批 SAM 目录
--fallback-policy sam|error Spine 未完成补全时回退 SAM(默认)或报错
--draw-order-file FILE Spine 绘制顺序覆盖:{"draw_order":[全部可绘制ID,从后到前]}
--check-angle FLOAT Spine 单骨骼检查动画角度,默认 10,范围 (0,45]
--genielabs-dir DIR 外部 spine-animation-ai 路径,仅用于 Spine 图集和播放器
--part ID 补全部件 ID,可重复;默认处理所有需补全部件
--prepare-only 仅准备补全画布、掩码和提示词,不请求模型
--candidates-dir DIR 离线导入 ID.png,或旧补全目录的首张候选,不请求模型
@@ -44,7 +51,7 @@ const help = `模型接入与结构理解(新增模式,不调用旧拆件流
--dry-run 检查配置与本地输入,不请求模型、不生成产物
--stop-after 支持 understand / segment。SAM 在临时 GPU 子进程运行,完成后退出。
部件补全使用 --stage completion --input-dir;可见像素恢复与几何检查已接入,Spine 导出尚未接入。`;
部件补全使用 --stage completion --input-dir;--stage spine 离线组装并导出 Spine 4.2,资产和动作质量仍需复核。`;
export function parseOptions(argv) {
const options = {stage: 'understanding', imagePaths: [], partIds: []};
@@ -58,6 +65,8 @@ export function parseOptions(argv) {
['--sam-checkpoint', 'samCheckpoint'], ['--sam-confidence', 'samConfidence'], ['--sam-precision', 'samPrecision'],
['--candidates-dir', 'candidatesDir'], ['--completion-size', 'completionSize'], ['--extension-radius', 'extensionRadius'],
['--candidate-size-policy', 'candidateSizePolicy'],
['--completion-dir', 'completionDir'], ['--fallback-policy', 'fallbackPolicy'],
['--draw-order-file', 'drawOrderFile'], ['--check-angle', 'checkAngle'], ['--genielabs-dir', 'genielabsDir'],
]);
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
@@ -80,7 +89,19 @@ export function parseOptions(argv) {
export async function main(argv = process.argv.slice(2)) {
const options = parseOptions(argv);
if (options.help) { console.log(help); return; }
if (!['understanding', 'completion', 'segmentation'].includes(options.stage)) throw new Error('--stage 仅支持 understanding / completion / segmentation');
if (!['understanding', 'completion', 'segmentation', 'spine'].includes(options.stage)) throw new Error('--stage 仅支持 understanding / completion / segmentation / spine');
if (options.stage === 'spine') {
const allowed = new Set(['stage', 'inputDir', 'outDir', 'completionDir', 'fallbackPolicy', 'drawOrderFile',
'checkAngle', 'genielabsDir', 'python', 'resume', 'dryRun', 'mode', 'imagePaths', 'partIds']);
if (Object.keys(options).some((key) => !allowed.has(key)) || options.imagePaths.length || options.partIds.length ||
(options.mode && options.mode !== 'annotated-parts')) throw new Error('Spine 为独立离线阶段,不接受模型配置、请求或补全参数');
const result = await runSpine(options);
if (!result.dryRun) console.log(`Spine ${result.reused ? '复用完成' : '导出完成,需复核'}:${path.join(result.out, 'report.html')}`);
return result;
}
if (['completionDir', 'fallbackPolicy', 'drawOrderFile', 'checkAngle', 'genielabsDir'].some((key) => options[key] !== undefined)) {
throw new Error('Spine 导出参数仅用于 --stage spine');
}
if (options.partsFile && (options.stage !== 'understanding' || options.listModels)) throw new Error('--parts-file 仅用于离线结构理解');
if (options.streamRaw !== undefined && !['true', 'false'].includes(options.streamRaw)) throw new Error('--stream 仅支持 true 或 false');
options.stream = options.streamRaw === undefined ? undefined : options.streamRaw === 'true';
+90
View File
@@ -0,0 +1,90 @@
// Optional, offline compatibility check using a caller-provided official Spine 4.2 core IIFE.
// node tools/verify-spine-runtime.mjs --runtime-file /path/spine-core.js --input-dir output/spine
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import {readImage} from '../lib/images.mjs';
const options = {};
for (let i = 2; i < process.argv.length; i += 2) {
const flag = process.argv[i];
if (!['--runtime-file', '--input-dir'].includes(flag) || !process.argv[i + 1]) {
throw new Error('Usage: node tools/verify-spine-runtime.mjs --runtime-file spine-core.js --input-dir output/spine');
}
options[flag] = process.argv[i + 1];
}
if (!options['--runtime-file'] || !options['--input-dir']) throw new Error('Both --runtime-file and --input-dir are required');
const context = vm.createContext({console});
vm.runInContext(readFileSync(options['--runtime-file'], 'utf8'), context, {timeout: 10000});
const spine = context.spine;
const input = path.resolve(options['--input-dir']);
const assembly = JSON.parse(readFileSync(path.join(input, 'assembly.json')));
const json = JSON.parse(readFileSync(path.join(input, 'skeleton.json')));
assert.match(json.skeleton.spine, /^4\.2\./u);
const atlas = new spine.TextureAtlas(readFileSync(path.join(input, 'atlas/skeleton.atlas'), 'utf8'));
for (const page of atlas.pages) {
const png = readImage(path.join(input, 'atlas', page.name), {pngOnly: true});
assert.equal(page.width, png.width);
assert.equal(page.height, png.height);
// CPU geometry needs region metadata, not a WebGL texture upload.
page.setTexture({setFilters() {}, setWraps() {}, getImage() { return {width: png.width, height: png.height}; }});
}
const data = new spine.SkeletonJson(new spine.AtlasAttachmentLoader(atlas)).readSkeletonData(json);
const skeleton = new spine.Skeleton(data);
function vertices(slot) {
const values = new Float32Array(8);
slot.getAttachment().computeWorldVertices(slot, values, 0, 2);
assert.ok([...values].every(Number.isFinite));
return values;
}
function close(actual, expected, label) {
assert.ok(Math.abs(actual - expected) < .002, `${label}: ${actual} != ${expected}`);
}
skeleton.setToSetupPose();
skeleton.updateWorldTransform(spine.Physics.none);
const originals = new Map();
const assets = new Map(assembly.parts.map((p) => [p.id, p]));
for (const slot of skeleton.slots) {
const values = vertices(slot);
originals.set(slot.data.name, values);
const [x0, y0, x1, y1] = assets.get(slot.data.name).crop_xyxy;
const xs = [values[0], values[2], values[4], values[6]];
const ys = [values[1], values[3], values[5], values[7]];
close(Math.min(...xs), x0 - assembly.width / 2, 'setup x0');
close(Math.max(...xs), x1 - assembly.width / 2, 'setup x1');
close(Math.min(...ys), assembly.height - y1, 'setup y0');
close(Math.max(...ys), assembly.height - y0, 'setup y1');
const region = atlas.findRegion(slot.data.name);
assert.equal(region.originalWidth, x1 - x0);
assert.equal(region.originalHeight, y1 - y0);
}
let samples = 0;
for (const animation of data.animations) {
if (animation.name === 'setup') continue;
const boneName = animation.name.replace(/^check__/u, '');
const frames = json.animations[animation.name].bones[boneName].rotate;
for (const time of [.5, 1.5]) {
skeleton.setToSetupPose();
skeleton.updateWorldTransform(spine.Physics.none);
const bone = skeleton.findBone(boneName);
const px = bone.worldX, py = bone.worldY;
const angle = frames.find((f) => f.time === time).value * Math.PI / 180;
animation.apply(skeleton, 0, time, false, [], 1, spine.MixBlend.replace, spine.MixDirection.mixIn);
skeleton.updateWorldTransform(spine.Physics.none);
for (const slot of skeleton.slots) {
let parent = slot.bone;
while (parent && parent !== bone) parent = parent.parent;
const before = originals.get(slot.data.name), after = vertices(slot);
for (let i = 0; i < 8; i += 2) {
const x = before[i] - px, y = before[i + 1] - py;
close(after[i], parent ? px + x * Math.cos(angle) - y * Math.sin(angle) : before[i], 'rotated x');
close(after[i + 1], parent ? py + x * Math.sin(angle) + y * Math.cos(angle) : before[i + 1], 'rotated y');
}
}
samples++;
}
}
console.log(JSON.stringify({input, spine_version: data.version, bones: data.bones.length,
slots: data.slots.length, animations: data.animations.length, rotation_samples: samples,
setup_geometry_verified: true, descendant_rotation_verified: true, browser_render_verified: false}, null, 2));