接通第四步
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {mkdtempSync, readFileSync, writeFileSync, realpathSync, rmSync, existsSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {projectRoot} from '../lib/config.mjs';
|
||||
import {selectPython} from '../lib/python-runtime.mjs';
|
||||
import {runSpine} from '../stages/spine-stage.mjs';
|
||||
|
||||
const python = selectPython();
|
||||
const execution = {cwd: projectRoot, windowsHide: true, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']};
|
||||
function fixture(t) {
|
||||
const base = realpathSync(tmpdir());
|
||||
const root = mkdtempSync(path.join(base, 'spine-export-'));
|
||||
t.after(() => {
|
||||
const resolved = realpathSync(root);
|
||||
assert.equal(path.dirname(resolved), base);
|
||||
assert.ok(path.basename(resolved).startsWith('spine-export-'));
|
||||
rmSync(resolved, {recursive: true});
|
||||
});
|
||||
execFileSync(python, ['-c', 'import sys; sys.path.insert(0, "tests"); from test_spine_assembly import make_fixture; make_fixture(sys.argv[1])', root], execution);
|
||||
return {root, inputDir: path.join(root, 'sam'), completionDir: path.join(root, 'complete'), outDir: path.join(root, 'out')};
|
||||
}
|
||||
|
||||
test('Spine assembly preserves coordinates, hidden hierarchy, bindings and validates asset identity', () => {
|
||||
assert.equal(execFileSync(python, ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_spine_assembly.py'], execution), '');
|
||||
});
|
||||
|
||||
test('Spine exports complete assets, atlas, player, diagnostics and verifies resume hashes', async (t) => {
|
||||
const opts = fixture(t);
|
||||
const result = await runSpine(opts);
|
||||
assert.equal(result.parts, 4);
|
||||
assert.equal(result.bones, 4);
|
||||
for (const file of ['assembly.json', 'skeleton.json', 'atlas/skeleton.atlas', 'atlas/skeleton.png',
|
||||
'images/shell.png', 'assembled.png', 'comparison.png', 'coverage-diagnostic.png', 'report.html', 'preview.html']) {
|
||||
assert.ok(existsSync(path.join(opts.outDir, file)), file);
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(path.join(opts.outDir, 'manifest.json')));
|
||||
assert.equal(manifest.status, 'completed');
|
||||
assert.equal(manifest.quality_status, 'needs_review');
|
||||
assert.equal(manifest.network_requested, false);
|
||||
assert.equal(manifest.fallback_part_count, 1);
|
||||
const skeleton = JSON.parse(readFileSync(path.join(opts.outDir, 'skeleton.json')));
|
||||
assert.equal(skeleton.skeleton.images, './images/');
|
||||
const atlas = readFileSync(path.join(opts.outDir, 'atlas/skeleton.atlas'), 'utf8').replaceAll('\r\n', '\n');
|
||||
for (const slot of skeleton.slots) assert.ok(atlas.includes(`\n${slot.attachment}\n`));
|
||||
assert.match(readFileSync(path.join(opts.outDir, 'preview.html'), 'utf8'), /animation: "setup"/u);
|
||||
assert.equal((await runSpine({...opts, resume: true})).reused, true);
|
||||
await assert.rejects(runSpine(opts), /非空/u);
|
||||
await assert.rejects(runSpine({...opts, resume: true, checkAngle: 5}), /变化/u);
|
||||
writeFileSync(path.join(opts.outDir, 'images/shell.png'), 'tampered');
|
||||
await assert.rejects(runSpine({...opts, resume: true}), /产物缺失或被修改/u);
|
||||
});
|
||||
|
||||
test('Spine CLI is independent of model config and dry-run writes nothing', (t) => {
|
||||
const opts = fixture(t);
|
||||
const args = ['tools/model-check.mjs', '--stage', 'spine', '--input-dir', opts.inputDir,
|
||||
'--completion-dir', opts.completionDir, '--out-dir', opts.outDir];
|
||||
const env = {...process.env, VECTOR_ENGINE_API_KEY: '', TIANTOKEN_API_KEY: ''};
|
||||
assert.match(execFileSync(process.execPath, [...args, '--dry-run'], {...execution, env}), /"network_requested": false/u);
|
||||
assert.equal(existsSync(opts.outDir), false);
|
||||
assert.throws(() => execFileSync(process.execPath, [...args, '--config', 'missing-config.json'], execution),
|
||||
(error) => /独立离线阶段/u.test(error.stderr));
|
||||
assert.throws(() => execFileSync(process.execPath, [...args, '--fallback-policy', 'error'], execution),
|
||||
(error) => /Completion required/u.test(error.stderr));
|
||||
assert.equal(existsSync(opts.outDir), false);
|
||||
assert.match(execFileSync(process.execPath, args, {...execution, env}), /导出完成/u);
|
||||
});
|
||||
|
||||
test('Spine rejects malformed semantic hierarchy before export', async (t) => {
|
||||
const opts = fixture(t);
|
||||
const file = path.join(opts.inputDir, 'semantic-parts.json');
|
||||
const plan = JSON.parse(readFileSync(file));
|
||||
plan.parts.find((p) => p.id === 'hub').parent = 'tip_q';
|
||||
writeFileSync(file, JSON.stringify(plan));
|
||||
await assert.rejects(runSpine(opts), /存在环/u);
|
||||
assert.equal(existsSync(opts.outDir), false);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from spine_assembly import load_assets, build_skeleton, export_assets, write_json
|
||||
|
||||
|
||||
def make_fixture(directory):
|
||||
"""An asymmetric mechanical fixture; no humanoid names or assumed part count."""
|
||||
root = Path(directory)
|
||||
sam, completion = root / 'sam', root / 'complete'
|
||||
(sam / 'masks').mkdir(parents=True)
|
||||
source = np.zeros((48, 64, 4), np.uint8)
|
||||
specs = [('shell', [16, 12, 30, 24], [40, 70, 130, 255]),
|
||||
('link_z', [30, 22, 40, 32], [160, 70, 10, 255]),
|
||||
('tip_q', [40, 28, 45, 36], [80, 170, 30, 255]),
|
||||
('decal', [19, 15, 22, 18], [200, 50, 130, 255])]
|
||||
for _, (x0, y0, x1, y1), color in specs:
|
||||
source[y0:y1, x0:x1] = color
|
||||
source[12, 16, 3] = 90
|
||||
source[25, 33] = [230, 140, 40, 255]
|
||||
Image.fromarray(source).save(sam / 'source.png')
|
||||
|
||||
def part(name, parent, role, pivot, box, hidden=False, needs=False, occluded=None):
|
||||
return {'id': name, 'semantic': name, 'parent': parent, 'rig_role': role,
|
||||
'bind_to': parent if role != 'bone_part' else None,
|
||||
'pivot_norm': [pivot[0] / 64, pivot[1] / 48],
|
||||
'visibility': 'hidden' if hidden else 'visible',
|
||||
'bbox_norm_xyxy': None if hidden else [box[0]/64, box[1]/48, box[2]/64, box[3]/48],
|
||||
'positive_points_norm': [], 'negative_points_norm': [],
|
||||
'occluded_by': occluded or [], 'confidence': .9,
|
||||
'needs_completion': hidden or needs,
|
||||
'completion': {'occlusion_fill': hidden, 'joint_extension_parent': needs,
|
||||
'joint_extension_child': False}}
|
||||
|
||||
parts = [part('tip_q', 'link_z', 'bone_part', [40, 29], specs[2][1], needs=True),
|
||||
part('decal', 'hub', 'attachment', [19, 15], specs[3][1]),
|
||||
part('shell', 'hub', 'attachment', [20, 16], specs[0][1], occluded=['decal']),
|
||||
part('link_z', 'hub', 'bone_part', [31, 23], specs[1][1], needs=True),
|
||||
part('hub', None, 'bone_part', [21, 17], None, hidden=True, occluded=['shell'])]
|
||||
write_json(sam / 'semantic-parts.json', {'schema_version': 2, 'coordinate_space': 'normalized_xy_top_left',
|
||||
'parts': parts, 'notes': []})
|
||||
records = []
|
||||
for name, (x0, y0, x1, y1), _ in specs:
|
||||
mask = np.zeros(source.shape[:2], np.uint8)
|
||||
mask[y0:y1, x0:x1] = 255
|
||||
Image.fromarray(mask).save(sam / 'masks' / f'{name}.png')
|
||||
records.append({'id': name, 'mask': f'masks/{name}.png', 'crop_xyxy': [x0, y0, x1, y1]})
|
||||
write_json(sam / 'segmentation.json', {'schema_version': 1, 'parts': records})
|
||||
shutil.copytree(sam, completion)
|
||||
(completion / 'parts').mkdir()
|
||||
# Add pixels to the left of link_z; the original visible pixels are exact.
|
||||
candidate = np.zeros((10, 14, 4), np.uint8)
|
||||
candidate[:, :4] = [160, 70, 10, 255]
|
||||
candidate[:, 4:] = source[22:32, 30:40]
|
||||
Image.fromarray(candidate).save(completion / 'parts' / 'link_z.png')
|
||||
write_json(completion / 'completion-result.json', {'parts': [
|
||||
{'id': 'link_z', 'status': 'geometry_passed_needs_review', 'selected_file': 'tasks/link_z/merged-1.png',
|
||||
'qa': {'accepted': True}, 'image': 'parts/link_z.png', 'crop_xyxy': [26, 22, 40, 32]},
|
||||
{'id': 'tip_q', 'status': 'rejected', 'qa': {'accepted': False, 'reasons': ['VISIBLE_ALIGNMENT_MISMATCH']}}
|
||||
], 'skipped': [{'id': 'hub', 'reason': 'NO_VISIBLE_SAM_MASK'},
|
||||
{'id': 'shell', 'reason': 'NO_COMPLETION_NEEDED'},
|
||||
{'id': 'decal', 'reason': 'NO_COMPLETION_NEEDED'}]})
|
||||
|
||||
|
||||
class AssemblyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
make_fixture(self.root)
|
||||
self.sam, self.complete = self.root / 'sam', self.root / 'complete'
|
||||
|
||||
def load(self, **kwargs):
|
||||
return load_assets(self.sam, self.complete, **kwargs)
|
||||
|
||||
def result(self):
|
||||
return json.loads((self.complete / 'completion-result.json').read_text())
|
||||
|
||||
def test_pose_coordinates_hidden_hierarchy_and_attachment_binding(self):
|
||||
data = self.load()
|
||||
result = export_assets(data, self.root / 'out')
|
||||
self.assertEqual((result['parts'], result['bones'], result['completion_parts'], result['fallback_parts']), (4, 4, 1, 1))
|
||||
skeleton = build_skeleton(data[1], data[2])
|
||||
self.assertEqual([b['name'] for b in skeleton['bones']], ['root', 'hub', 'link_z', 'tip_q'])
|
||||
bones = {b['name']: b for b in skeleton['bones']}
|
||||
self.assertAlmostEqual(bones['hub']['x'], -11)
|
||||
self.assertAlmostEqual(bones['hub']['y'], 31)
|
||||
self.assertAlmostEqual(bones['link_z']['x'], 10)
|
||||
self.assertAlmostEqual(bones['link_z']['y'], -6)
|
||||
self.assertAlmostEqual(bones['tip_q']['x'], 9)
|
||||
self.assertAlmostEqual(bones['tip_q']['y'], -6)
|
||||
slots = {s['name']: s for s in skeleton['slots']}
|
||||
self.assertEqual(slots['shell']['bone'], 'hub')
|
||||
self.assertNotIn('hub', slots)
|
||||
self.assertLess(data[2]['draw_order'].index('shell'), data[2]['draw_order'].index('decal'))
|
||||
# Independent forward kinematics reconstructs each crop centre exactly.
|
||||
world = {'root': (0, 0)}
|
||||
for bone in skeleton['bones'][1:]:
|
||||
px, py = world[bone['parent']]
|
||||
world[bone['name']] = px + bone['x'], py + bone['y']
|
||||
for name, (_, box) in data[3].items():
|
||||
att = skeleton['skins'][0]['attachments'][name][name]
|
||||
bx, by = world[slots[name]['bone']]
|
||||
self.assertAlmostEqual(att['x'] + bx + 32, (box[0] + box[2]) / 2)
|
||||
self.assertAlmostEqual(48 - att['y'] - by, (box[1] + box[3]) / 2)
|
||||
image = np.array(Image.open(self.root / 'out' / 'assembled.png'))
|
||||
np.testing.assert_array_equal(image[12, 16], [40, 70, 130, 90])
|
||||
self.assertEqual(skeleton['animations']['check__link_z']['bones']['link_z']['rotate'][1]['value'], 10)
|
||||
|
||||
def test_rejection_fallback_and_strict_policy(self):
|
||||
data = self.load()
|
||||
assets = {p['id']: p for p in data[2]['parts']}
|
||||
self.assertEqual(assets['tip_q']['source'], 'sam')
|
||||
self.assertEqual(assets['tip_q']['reason'], 'COMPLETION_REJECTED')
|
||||
with self.assertRaisesRegex(ValueError, 'Completion required for tip_q'):
|
||||
self.load(fallback_policy='error')
|
||||
only_sam = load_assets(self.sam)
|
||||
self.assertTrue(all(p['source'] in ['sam', 'hidden'] for p in only_sam[2]['parts']))
|
||||
|
||||
def test_mixed_runs_and_changed_masks_rejected(self):
|
||||
mask = self.complete / 'masks' / 'tip_q.png'
|
||||
Image.new('L', (64, 48), 255).save(mask)
|
||||
with self.assertRaisesRegex(ValueError, 'Completion mask mismatch'):
|
||||
self.load()
|
||||
shutil.copy2(self.sam / 'masks' / 'tip_q.png', mask)
|
||||
(self.complete / 'semantic-parts.json').write_text('{}')
|
||||
with self.assertRaisesRegex(ValueError, 'Completion input mismatch'):
|
||||
self.load()
|
||||
|
||||
def test_bad_crop_alignment_path_and_missing_visible_parts_rejected(self):
|
||||
original = self.result()
|
||||
for box in [[26, 22, 39, 32], [-1, 22, 13, 32], [27, 22, 41, 32]]:
|
||||
result = copy.deepcopy(original)
|
||||
result['parts'][0]['crop_xyxy'] = box
|
||||
write_json(self.complete / 'completion-result.json', result)
|
||||
with self.assertRaises(ValueError):
|
||||
self.load()
|
||||
result = copy.deepcopy(original)
|
||||
result['parts'][0]['image'] = '../sam/source.png'
|
||||
write_json(self.complete / 'completion-result.json', result)
|
||||
with self.assertRaisesRegex(ValueError, 'outside input'):
|
||||
self.load()
|
||||
seg = json.loads((self.sam / 'segmentation.json').read_text())
|
||||
seg['parts'] = seg['parts'][1:]
|
||||
write_json(self.sam / 'segmentation.json', seg)
|
||||
with self.assertRaisesRegex(ValueError, 'Visible part has no SAM mask'):
|
||||
load_assets(self.sam)
|
||||
|
||||
def test_draw_order_override_and_unconstrained_overlap(self):
|
||||
data = self.load()
|
||||
self.assertTrue(data[2]['unconstrained_overlaps'])
|
||||
override = self.root / 'order.json'
|
||||
order = ['decal', 'shell', 'link_z', 'tip_q']
|
||||
write_json(override, {'draw_order': order})
|
||||
modified = self.load(draw_order_file=override)[2]
|
||||
self.assertEqual(modified['draw_order'], order)
|
||||
self.assertIn(['shell', 'decal'], modified['overridden_occlusion_constraints'])
|
||||
write_json(override, {'draw_order': ['shell'] * 4})
|
||||
with self.assertRaisesRegex(ValueError, 'exactly once'):
|
||||
self.load(draw_order_file=override)
|
||||
|
||||
def test_attachment_only_and_root_binding(self):
|
||||
plan = {'parts': [{'id': 'panel', 'rig_role': 'attachment', 'bind_to': None}]}
|
||||
assembly = {'width': 64, 'height': 48, 'draw_order': ['panel'],
|
||||
'parts': [{'id': 'panel', 'crop_xyxy': [1, 2, 10, 12]}]}
|
||||
skeleton = build_skeleton(plan, assembly)
|
||||
self.assertEqual(skeleton['bones'], [{'name': 'root'}])
|
||||
self.assertEqual(skeleton['slots'][0]['bone'], 'root')
|
||||
self.assertEqual(skeleton['animations'], {'setup': {}})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user