第三步补全接入
This commit is contained in:
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user