第三步补全接入

This commit is contained in:
Ukawka
2026-09-20 22:21:50 +08:00
parent f095c28858
commit 99d65377ab
14 changed files with 912 additions and 14 deletions
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {execFileSync} from 'node:child_process';
import {mkdtempSync, readFileSync, writeFileSync, mkdirSync, realpathSync, rmSync} from 'node:fs';
import path from 'node:path';
import {tmpdir} from 'node:os';
import {projectRoot, loadConfig, resolveStage} from '../lib/config.mjs';
import {selectPython} from '../lib/python-runtime.mjs';
import {runCompletion} from '../stages/completion-stage.mjs';
const python = selectPython();
function fixture(t) {
const base = realpathSync(tmpdir());
const root = mkdtempSync(path.join(base, 'spine-completion-'));
t.after(() => {
const resolved = realpathSync(root);
assert.equal(path.dirname(resolved), base);
assert.ok(path.basename(resolved).startsWith('spine-completion-'));
rmSync(resolved, {recursive: true});
});
const input = path.join(root, 'input');
execFileSync(python, ['-c', 'import sys; sys.path.insert(0, "tests"); from test_completion import make_fixture; make_fixture(sys.argv[1])', input], {cwd: projectRoot, windowsHide: true});
const stage = resolveStage(loadConfig({envFile: '__no_test_env__', env: {VECTOR_ENGINE_BASE_URL: 'https://relay.invalid/v1', VECTOR_ENGINE_API_KEY: 'test-secret'}}), 'completion');
return {root, input, stage};
}
test('completion geometry rejects moved/empty outputs and preserves original alpha and coordinates', () => {
const result = execFileSync(python, ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_completion.py'], {cwd: projectRoot, windowsHide: true, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']});
assert.equal(result, '');
});
test('part completion prepares without HTTP, sends declared capabilities, merges, resumes, and detects changed masks', async (t) => {
const {root, input, stage} = fixture(t);
const opts = {stage, inputDir: input, outDir: path.join(root, 'run'), completionSize: 128, extensionRadius: 16, partIds: ['torso']};
let requests = 0;
const transport = {fetchImpl: async (_url, request) => {
requests++;
assert.equal(request.body.get('model'), stage.model);
assert.equal(request.body.getAll('image').length, 1);
assert.equal(request.body.has('mask'), false);
return new Response(JSON.stringify({data: [{b64_json: readFileSync(path.join(input, 'candidate.png')).toString('base64')}]}), {headers: {'Content-Type': 'application/json'}});
}};
await runCompletion({...opts, outDir: path.join(root, 'prepared'), prepareOnly: true}, transport);
assert.equal(requests, 0);
const run = await runCompletion(opts, transport);
assert.equal(run.accepted, 1);
const result = JSON.parse(readFileSync(path.join(opts.outDir, 'completion-result.json')));
assert.equal(result.parts[0].qa.visible_pixels_exact, true);
assert.equal(result.parts[0].semantic_verified, false);
assert.deepEqual(result.parts[0].crop_xyxy, [20, 20, 50, 40]);
assert.equal((await runCompletion({...opts, resume: true}, transport)).reused, true);
assert.equal(requests, 1);
writeFileSync(path.join(input, 'masks', 'torso.png'), readFileSync(path.join(input, 'masks', 'collar.png')));
await assert.rejects(runCompletion({...opts, resume: true}, transport), /变化/u);
assert.equal(requests, 1);
});
test('offline candidate import needs no HTTP; mismatched candidate is retained and rejected', async (t) => {
const {root, input, stage} = fixture(t);
const candidates = path.join(root, 'candidates'); mkdirSync(candidates);
writeFileSync(path.join(candidates, 'torso.png'), readFileSync(path.join(input, 'source.png')));
const outDir = path.join(root, 'run');
const result = await runCompletion({stage, inputDir: input, outDir, candidatesDir: candidates, completionSize: 128}, {fetchImpl: () => { throw new Error('Must remain offline'); }});
assert.equal(result.rejected, 1);
assert.equal(result.accepted, 0);
const data = JSON.parse(readFileSync(path.join(outDir, 'completion-result.json')));
assert.deepEqual(data.parts[0].qa.reasons, ['CANVAS_SIZE_MISMATCH']);
assert.ok(readFileSync(path.join(outDir, 'tasks', 'torso', 'candidate-1.png')).length);
});
test('provider mask/context capabilities are explicit and failure preserves diagnostic report', async (t) => {
const {root, input, stage} = fixture(t);
stage.capabilities = {mask: true, multiple_images: true};
stage.maxRetries = 0;
const outDir = path.join(root, 'run');
await assert.rejects(runCompletion({stage, inputDir: input, outDir, completionSize: 128}, {fetchImpl: async (_url, request) => {
assert.equal(request.body.getAll('image').length, 2);
assert.ok(request.body.get('mask'));
return new Response(JSON.stringify({error: {message: 'test-secret failed'}}), {status: 400});
}}), /HTTP 400/u);
const manifest = readFileSync(path.join(outDir, 'manifest.json'), 'utf8');
assert.equal(JSON.parse(manifest).status, 'failed');
assert.ok(!manifest.includes('test-secret'));
assert.ok(readFileSync(path.join(outDir, 'report.html')).length);
assert.ok(readFileSync(path.join(outDir, 'tasks', 'torso', 'response.error.json')).length);
});
test('completion CLI prepares without credentials and rejects mixed probe arguments', (t) => {
const {root, input} = fixture(t);
const envFile = path.join(root, 'empty.env'); writeFileSync(envFile, '');
const outDir = path.join(root, 'cli');
const args = ['tools/model-check.mjs', '--stage', 'completion', '--input-dir', input,
'--out-dir', outDir, '--prepare-only', '--part', 'torso', '--completion-size', '128', '--env-file', envFile];
const options = {cwd: projectRoot, windowsHide: true, encoding: 'utf8',
env: {...process.env, VECTOR_ENGINE_BASE_URL: '', VECTOR_ENGINE_API_KEY: ''}, stdio: ['ignore', 'pipe', 'pipe']};
assert.match(execFileSync(process.execPath, args, options), /未请求模型/u);
const manifest = JSON.parse(readFileSync(path.join(outDir, 'manifest.json')));
assert.equal(manifest.network_requested, false);
assert.equal(manifest.prepared_count, 1);
assert.throws(() => execFileSync(process.execPath, [...args, '--image', path.join(input, 'source.png')], options), (error) => /不接受探针输入/u.test(error.stderr));
});
+14
View File
@@ -177,6 +177,20 @@ test('image adapter sends multipart, blocks unconfirmed capabilities, decodes in
}});
assert.equal(decoded.length, 2);
assert.equal(decoded[0].width, 128);
let downloads = 0;
for (const inline of ['', ' \r\n ', null]) {
const recovered = await decodeEditedImages({data: [{b64_json: inline, url: 'https://cdn.invalid/generated.png'}]}, stage, {
fetchImpl: async (url, request) => {
downloads += 1;
assert.equal(String(url), 'https://cdn.invalid/generated.png');
assert.equal(request.headers, undefined);
return new Response(image.bytes);
},
});
assert.deepEqual(recovered[0].bytes, image.bytes);
}
assert.equal(downloads, 3);
await assert.rejects(decodeEditedImages({data: [{b64_json: ' ', url: ''}]}, stage), /缺少 b64_json\/url/u);
await assert.rejects(decodeEditedImages({data: [{b64_json: Buffer.from('<html>error</html>').toString('base64')}]}, stage), /图片数据/u);
await assert.rejects(decodeEditedImages({data: [{url: 'https://cdn.invalid/404'}]}, stage, {fetchImpl: async () => new Response('', {status: 404})}), /404/u);
});
+143
View File
@@ -0,0 +1,143 @@
import copy
import json
from pathlib import Path
import tempfile
import unittest
import numpy as np
from PIL import Image
from completion_geometry import prepare, merge_candidate, localize, read_json, write_json, finalize
def make_fixture(directory):
root = Path(directory)
root.mkdir(parents=True, exist_ok=True)
(root/'masks').mkdir(exist_ok=True)
source = np.zeros((80, 80, 4), np.uint8)
source[20:40, 20:40] = [180, 50, 20, 255]
source[20, 20, 3] = 80
source[20:40, 40:50] = [20, 80, 180, 255]
Image.fromarray(source).save(root/'source.png')
part = {'id': 'torso', 'semantic': 'torso', 'parent': None, 'rig_role': 'bone_part', 'bind_to': None,
'visibility': 'partial', 'bbox_norm_xyxy': [.25, .25, .5, .5], 'pivot_norm': [.4, .4],
'positive_points_norm': [], 'negative_points_norm': [], 'occluded_by': ['collar'],
'needs_completion': True, 'confidence': .8,
'completion': {'occlusion_fill': True, 'joint_extension_parent': False, 'joint_extension_child': False}}
collar = copy.deepcopy(part)
collar.update(id='collar', semantic='collar', parent='torso', bind_to='torso', rig_role='attachment',
visibility='visible', occluded_by=[], needs_completion=False,
completion={key: False for key in part['completion']})
write_json(root/'semantic-parts.json', {'schema_version': 2, 'coordinate_space': 'normalized_xy_top_left', 'parts': [part, collar], 'notes': []})
write_json(root/'segmentation.json', {'schema_version': 1, 'parts': [{'id': 'torso', 'warnings': ['SEMANTIC_REVIEW_REQUIRED']}, {'id': 'collar'}]})
for name, x0, x1 in [('torso', 20, 40), ('collar', 40, 50)]:
mask = np.zeros((80, 80), np.uint8)
mask[20:40, x0:x1] = 255
Image.fromarray(mask).save(root/'masks'/f'{name}.png')
# Expected 128 canvas: union bbox (20,20,50,40) -> origin (-29,-34).
# Construct candidate independently from production prepare/merge logic.
candidate = np.zeros((128, 128, 4), np.uint8)
candidate[54:74, 49:79] = [180, 50, 20, 255]
candidate[54, 49, 3] = 80
Image.fromarray(candidate).save(root/'candidate.png')
return source
class CompletionTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.source = make_fixture(self.root)
self.plan = prepare(self.root, ['torso'], size=128, radius=16)
self.folder = self.root/'tasks'/'torso'
def test_canvas_mapping_and_only_occluder_region_editable(self):
task = self.plan['tasks'][0]
self.assertEqual(task['origin_xy'], [-29, -34])
self.assertEqual(task['allowed_pixels'], 200)
visible = np.array(Image.open(self.folder/'visible.png'))
self.assertEqual(visible[54, 49, 3], 80)
mask = np.array(Image.open(self.folder/'edit-mask.png'))
self.assertTrue(np.all(mask[54:74, 69:79, 3] == 0))
self.assertTrue(np.all(mask[54:74, 49:69, 3] == 255))
self.assertEqual(int((mask[:, :, 3] == 0).sum()), 200)
def test_merge_restores_rgba_exactly_and_clips_extra_pixels(self):
candidate = np.array(Image.open(self.root/'candidate.png'))
candidate[54:74, 49:69, :3] += 5
candidate[10:14, 10:14] = [255, 0, 0, 255]
Image.fromarray(candidate).save(self.root/'candidate.png')
output = self.folder/'merged.png'
qa = merge_candidate(self.folder, self.root/'candidate.png', output)
self.assertTrue(qa['accepted'])
self.assertEqual(qa['new_pixels'], 200)
self.assertEqual(qa['discarded_outside_allowed'], 16)
self.assertTrue(qa['visible_pixels_exact'])
merged = np.array(Image.open(output))
np.testing.assert_array_equal(merged[54:74, 49:69], self.source[20:40, 20:40])
self.assertEqual(merged[11, 11, 3], 0)
write_json(self.root/'completion-result.json', {'parts': [{'id': 'torso', 'status': 'geometry_passed_needs_review',
'selected_file': 'tasks/torso/merged.png', 'qa': qa}], 'skipped': []})
finalize(self.root)
result = read_json(self.root/'completion-result.json')
self.assertEqual(result['parts'][0]['crop_xyxy'], [20, 20, 50, 40])
def test_reject_resize_shift_no_additions_and_background_only(self):
candidate = Image.open(self.root/'candidate.png').convert('RGBA')
variants = [(candidate.resize((64, 64)), 'CANVAS_SIZE_MISMATCH'),
(Image.fromarray(np.roll(np.array(candidate), 12, axis=1)), 'VISIBLE_ALIGNMENT_MISMATCH'),
(Image.open(self.folder/'visible.png'), 'NO_CONNECTED_NEW_PIXELS'),
(Image.new('RGB', (128, 128), 'white'), 'CANNOT_EXTRACT_FOREGROUND')]
for number, (image, reason) in enumerate(variants):
file = self.root/f'bad-{number}.png'
image.save(file)
output = self.root/f'rejected-{number}.png'
qa = merge_candidate(self.folder, file, output)
self.assertFalse(qa['accepted'])
self.assertIn(reason, qa['reasons'])
self.assertFalse(output.exists())
def test_white_background_extraction_preserves_visible_and_adds_only_allowed(self):
candidate = Image.open(self.root/'candidate.png').convert('RGBA')
rgb = Image.alpha_composite(Image.new('RGBA', candidate.size, 'white'), candidate).convert('RGB')
rgb.save(self.root/'white.png')
qa = merge_candidate(self.folder, self.root/'white.png', self.folder/'merged.png')
self.assertTrue(qa['accepted'])
self.assertEqual(qa['new_pixels'], 200)
def test_disconnected_additions_are_not_merged(self):
candidate = np.array(Image.open(self.folder/'visible.png'))
candidate[54:57, 76:79] = [180, 50, 20, 255]
Image.fromarray(candidate).save(self.root/'detached.png')
qa = merge_candidate(self.folder, self.root/'detached.png', self.folder/'detached-merged.png')
self.assertFalse(qa['accepted'])
self.assertEqual(qa['disconnected_pixels'], 9)
self.assertIn('NO_CONNECTED_NEW_PIXELS', qa['reasons'])
def test_block_missing_occluder_and_reject_canvas_or_masks(self):
plan = read_json(self.root/'semantic-parts.json')
plan['parts'][0]['occluded_by'] = []
write_json(self.root/'semantic-parts.json', plan)
self.assertEqual(prepare(self.root, [], 128, 16)['skipped'][0]['reason'], 'NO_ALLOWED_EXTENSION')
with self.assertRaises(ValueError):
prepare(self.root, ['missing'], 128, 16)
Image.new('L', (12, 12), 255).save(self.root/'masks'/'torso.png')
with self.assertRaisesRegex(ValueError, 'mask must'):
prepare(self.root, [], 128, 16)
def test_joint_regions_use_parent_and_child_pivots(self):
plan = read_json(self.root/'semantic-parts.json')
plan['parts'][0]['completion'] = {'occlusion_fill': False, 'joint_extension_parent': True, 'joint_extension_child': True}
plan['parts'][0]['pivot_norm'] = [.25, .4]
child = copy.deepcopy(plan['parts'][0])
child.update(id='arm', parent='torso', pivot_norm=[.5, .4])
plan['parts'].append(child)
write_json(self.root/'semantic-parts.json', plan)
task = prepare(self.root, ['torso'], 128, 8)['tasks'][0]
self.assertGreater(task['regions']['joint_extension_parent'], 0)
self.assertGreater(task['regions']['joint_extension_child'], 0)
if __name__ == '__main__':
unittest.main()