增加几何校验
This commit is contained in:
@@ -8,7 +8,8 @@ import {execFile} from 'node:child_process';
|
||||
import {promisify} from 'node:util';
|
||||
import {loadConfig, resolveStage, projectRoot, publicStage} from '../lib/config.mjs';
|
||||
import {readImage} from '../lib/images.mjs';
|
||||
import {validateParts, partsSchema} from '../lib/validate-parts.mjs';
|
||||
import {validateParts, partsSchema, upgradeParts} from '../lib/validate-parts.mjs';
|
||||
import {geometryPython} from '../lib/geometry.mjs';
|
||||
import {requestJson, parseEventStream} from '../providers/http.mjs';
|
||||
import {buildVisionRequest, extractVisionText} from '../providers/vision.mjs';
|
||||
import {editImage, decodeEditedImages, validateEditInputs} from '../providers/image-edits.mjs';
|
||||
@@ -17,8 +18,10 @@ import {runUnderstanding, runCompletionProbe} from '../stages/model-stage.mjs';
|
||||
const exec = promisify(execFile);
|
||||
const reference = path.join(projectRoot, 'references', 'characters', 'kenney-beige-idle.png');
|
||||
const image = readImage(reference, {pngOnly: true});
|
||||
const part = (id, parent = null) => ({id, semantic: id, parent, visibility: 'visible', bbox_norm_xyxy: [0.1, 0.1, 0.9, 0.9], pivot_norm: [0.5, 0.5], positive_points_norm: [[0.5, 0.5]], negative_points_norm: [], occluded_by: [], needs_completion: false, confidence: 0.8});
|
||||
const goodParts = () => ({schema_version: 1, coordinate_space: 'normalized_xy_top_left', parts: [part('torso'), part('head', 'torso')], notes: []});
|
||||
const part = (id, parent = null) => ({id, semantic: id, parent, rig_role: 'bone_part', bind_to: null,
|
||||
completion: {occlusion_fill: false, joint_extension_parent: false, joint_extension_child: false},
|
||||
visibility: 'visible', bbox_norm_xyxy: [0.1, 0.1, 0.9, 0.9], pivot_norm: [0.5, 0.5], positive_points_norm: [], negative_points_norm: [], occluded_by: [], needs_completion: false, confidence: 0.8});
|
||||
const goodParts = () => ({schema_version: 2, coordinate_space: 'normalized_xy_top_left', parts: [part('torso'), part('head', 'torso')], notes: []});
|
||||
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {status, headers: {'Content-Type': 'application/json', 'x-request-id': 'test-request'}});
|
||||
const stageFor = (name = 'understanding') => resolveStage(loadConfig({envFile: '__no_test_env__', env: {VECTOR_ENGINE_BASE_URL: 'https://relay.invalid/v1', VECTOR_ENGINE_API_KEY: 'test-secret'}}), name, name === 'understanding' ? {protocol: 'chat-completions'} : {});
|
||||
const chatPayload = (parts = goodParts()) => ({model: 'gpt-6-astra', choices: [{finish_reason: 'stop', message: {content: JSON.stringify(parts)}}], usage: {total_tokens: 12}});
|
||||
@@ -77,7 +80,10 @@ test('schema + semantic validation reject invalid coordinates, references and bo
|
||||
(d) => { d.parts[0].occluded_by = ['head']; d.parts[1].occluded_by = ['torso']; },
|
||||
(d) => { d.parts[1].id = 'torso'; },
|
||||
(d) => { d.parts[0].visibility = 'hidden'; },
|
||||
(d) => { d.parts[0].positive_points_norm = [[0, 0]]; },
|
||||
(d) => { d.parts[0].positive_points_norm = [[-0.1, 0]]; },
|
||||
(d) => { d.parts[0].bind_to = 'head'; },
|
||||
(d) => { d.parts[0].completion.occlusion_fill = true; },
|
||||
(d) => { d.parts[0].visibility = 'partial'; },
|
||||
(d) => { d.parts[0].unknown = 1; },
|
||||
]) {
|
||||
const data = goodParts(); mutate(data);
|
||||
@@ -85,6 +91,39 @@ test('schema + semantic validation reject invalid coordinates, references and bo
|
||||
}
|
||||
});
|
||||
|
||||
test('v1 migration separates facial attachments and completion, while preserving pivots and diagnostics', () => {
|
||||
const data = goodParts();
|
||||
data.parts.push({...part('eye_left', 'head'), visibility: 'partial'});
|
||||
data.schema_version = 1;
|
||||
for (const p of data.parts) { delete p.rig_role; delete p.bind_to; delete p.completion; }
|
||||
data.parts[0].positive_points_norm = [[0, 0]];
|
||||
assert.equal(validateParts(data).valid, true);
|
||||
const migrated = upgradeParts(data);
|
||||
assert.equal(validateParts(migrated).valid, true);
|
||||
assert.equal(migrated.parts[2].rig_role, 'facial_feature');
|
||||
assert.equal(migrated.parts[2].bind_to, 'head');
|
||||
assert.equal(migrated.parts[2].completion.occlusion_fill, true);
|
||||
assert.equal(migrated.parts[2].needs_completion, true);
|
||||
assert.equal(migrated.parts[0].completion.joint_extension_child, true);
|
||||
assert.deepEqual(migrated.parts.map((p) => p.pivot_norm), data.parts.map((p) => p.pivot_norm));
|
||||
assert.equal(data.parts[2].needs_completion, false);
|
||||
});
|
||||
|
||||
test('attachments cannot bind to other attachments or declare bone overlap', () => {
|
||||
const data = goodParts();
|
||||
data.parts.push({...part('eye_left', 'head'), rig_role: 'facial_feature', bind_to: 'head'});
|
||||
assert.equal(validateParts(data).valid, true);
|
||||
data.parts[2].bind_to = 'missing';
|
||||
assert.equal(validateParts(data).valid, false);
|
||||
data.parts[2].bind_to = 'head';
|
||||
data.parts[1].rig_role = 'attachment';
|
||||
assert.equal(validateParts(data).valid, false);
|
||||
data.parts[1].rig_role = 'bone_part';
|
||||
data.parts[2].completion.joint_extension_parent = true;
|
||||
data.parts[2].needs_completion = true;
|
||||
assert.equal(validateParts(data).valid, false);
|
||||
});
|
||||
|
||||
test('SSE requires terminal completion, supports Responses and Chat, rejects truncated/error streams', async () => {
|
||||
const payload = {status: 'completed', model: 'gpt-6-astra', output: [{content: [{type: 'output_text', text: '{}'}]}]};
|
||||
const stream = `event: response.completed\r\ndata: ${JSON.stringify({type: 'response.completed', response: payload})}\r\n\r\ndata: [DONE]\r\n\r\n`;
|
||||
@@ -156,6 +195,11 @@ test('understanding run saves report and provenance; resume checks inputs/model/
|
||||
assert.equal(manifest.quality_status, 'needs_review');
|
||||
assert.equal(manifest.request.returned_model, 'gpt-6-astra');
|
||||
assert.equal(manifest.inputs[0].width, 128);
|
||||
assert.equal(manifest.geometry.candidate_parts, 2);
|
||||
const prompts = JSON.parse(readFileSync(path.join(outDir, 'segmentation-prompts.json')));
|
||||
assert.equal(prompts.parts[0].semantic_verified, false);
|
||||
assert.equal(prompts.parts[0].positive_points_px.length, 1);
|
||||
assert.equal(JSON.parse(readFileSync(path.join(outDir, 'semantic-parts.json'))).parts[0].positive_points_norm.length, 0);
|
||||
assert.match(readFileSync(path.join(outDir, 'report.html'), 'utf8'), /尚未执行 SAM/u);
|
||||
await assert.rejects(runUnderstanding(options, transport), /非空/u);
|
||||
assert.equal((await runUnderstanding({...options, resume: true}, transport)).reused, true);
|
||||
@@ -202,7 +246,7 @@ test('completion probe produces raw candidates and never claims final assets', a
|
||||
assert.deepEqual(readFileSync(path.join(outDir, 'candidate-1.png')), image.bytes);
|
||||
});
|
||||
|
||||
test('pipeline new mode reaches a mock relay without Python; model listing and unsupported stages', async (t) => {
|
||||
test('pipeline new mode reaches a mock relay with geometry; model listing and unsupported stages', async (t) => {
|
||||
const dir = temporary(t);
|
||||
let requests = 0;
|
||||
const server = createServer(async (req, res) => {
|
||||
@@ -218,7 +262,7 @@ test('pipeline new mode reaches a mock relay without Python; model listing and u
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const env = {...process.env, VECTOR_ENGINE_BASE_URL: `http://127.0.0.1:${server.address().port}`, VECTOR_ENGINE_API_KEY: 'mock-key'};
|
||||
const outDir = path.join(dir, 'cli');
|
||||
const result = await exec(process.execPath, ['pipeline.mjs', '--mode', 'annotated-parts', '--reference', reference, '--out-dir', outDir, '--stop-after', 'understand', '--understanding-model', 'relay-vision-test', '--python', 'deliberately-nonexistent-python'], {cwd: projectRoot, env});
|
||||
const result = await exec(process.execPath, ['pipeline.mjs', '--mode', 'annotated-parts', '--reference', reference, '--out-dir', outDir, '--stop-after', 'understand', '--understanding-model', 'relay-vision-test'], {cwd: projectRoot, env});
|
||||
assert.match(result.stdout, /结构理解完成/u);
|
||||
const list = await exec(process.execPath, ['tools/model-check.mjs', '--list-models'], {cwd: projectRoot, env});
|
||||
assert.match(list.stdout, /relay-vision-test/u);
|
||||
@@ -226,3 +270,23 @@ test('pipeline new mode reaches a mock relay without Python; model listing and u
|
||||
await assert.rejects(exec(process.execPath, ['pipeline.mjs', '--mode', 'annotated-parts', '--stop-after', 'segment'], {cwd: projectRoot, env}), (error) => /仅支持 understand/u.test(error.stderr));
|
||||
assert.equal(requests, 2);
|
||||
});
|
||||
|
||||
test('offline CLI needs no credentials or network and fingerprints imported plans', async (t) => {
|
||||
const dir = temporary(t);
|
||||
const plan = path.join(dir, 'plan.json'); writeFileSync(plan, JSON.stringify(goodParts()));
|
||||
const envFile = path.join(dir, 'empty.env'); writeFileSync(envFile, '');
|
||||
const env = {...process.env, VECTOR_ENGINE_BASE_URL: '', VECTOR_ENGINE_API_KEY: ''};
|
||||
const args = ['tools/model-check.mjs', '--env-file', envFile, '--reference', reference,
|
||||
'--parts-file', plan, '--out-dir', path.join(dir, 'offline')];
|
||||
await exec(process.execPath, args, {cwd: projectRoot, env});
|
||||
const metadata = JSON.parse(readFileSync(path.join(dir, 'offline', 'request-metadata.json')));
|
||||
assert.equal(metadata.network_requested, false);
|
||||
await exec(process.execPath, [...args, '--resume'], {cwd: projectRoot, env});
|
||||
writeFileSync(plan, JSON.stringify({...goodParts(), notes: ['changed']}));
|
||||
await assert.rejects(exec(process.execPath, [...args, '--resume'], {cwd: projectRoot, env}), (e) => /变化/u.test(e.stderr));
|
||||
});
|
||||
|
||||
test('Python geometry regression suite', async () => {
|
||||
const result = await exec(geometryPython(), ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_geometry.py'], {cwd: projectRoot});
|
||||
assert.match(result.stderr, /OK/u);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import copy
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from geometry_grounding import ground_parts, pixel
|
||||
from normalize_reference import prepare_rgba
|
||||
|
||||
|
||||
def plan(box=None, points=None):
|
||||
return {'parts': [{'id': 'hand_left', 'semantic': 'screen-left hand',
|
||||
'visibility': 'visible', 'bbox_norm_xyxy': box or [0, 0, 1, 1],
|
||||
'positive_points_norm': points or [], 'pivot_norm': [0, 0]}]}
|
||||
|
||||
|
||||
class GeometryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.rgba = np.zeros((20, 30, 4), dtype=np.uint8)
|
||||
self.rgba[5:15, 10:20] = [20, 40, 60, 255]
|
||||
self.image = Image.fromarray(self.rgba)
|
||||
|
||||
def test_existing_alpha_and_visible_pixels_unchanged(self):
|
||||
self.rgba[6, 12] = [255, 255, 255, 128]
|
||||
image, meta = prepare_rgba(Image.fromarray(self.rgba))
|
||||
np.testing.assert_array_equal(image, self.rgba)
|
||||
self.assertEqual(meta['method'], 'existing_alpha')
|
||||
|
||||
def test_only_border_connected_white_is_removed(self):
|
||||
rgb = np.full((20, 20, 3), 255, dtype=np.uint8)
|
||||
rgb[4:16, 4:16] = 20
|
||||
rgb[7:13, 7:13] = 255
|
||||
prepared, meta = prepare_rgba(Image.fromarray(rgb))
|
||||
data = np.array(prepared)
|
||||
self.assertEqual(data[0, 0, 3], 0)
|
||||
self.assertEqual(data[8, 8, 3], 255)
|
||||
np.testing.assert_array_equal(data[:, :, :3], rgb)
|
||||
self.assertEqual(meta['method'], 'border_connected_white')
|
||||
self.assertEqual(prepared.size, (20, 20))
|
||||
|
||||
def test_colored_opaque_and_blank_inputs_rejected(self):
|
||||
for color in [(0, 30, 80, 255), (255, 255, 255, 255), (0, 0, 0, 0)]:
|
||||
with self.assertRaises(ValueError):
|
||||
prepare_rgba(Image.new('RGBA', (10, 10), color))
|
||||
|
||||
def test_invalid_model_points_replaced_without_changing_pivot(self):
|
||||
data = plan(points=[[0, 0], [1, 1], [.4, .4]])
|
||||
original = copy.deepcopy(data)
|
||||
report, prompts = ground_parts(self.image, data)
|
||||
self.assertEqual(report['summary']['invalid_model_points'], 2)
|
||||
self.assertEqual(report['summary']['background_model_points'], 2)
|
||||
candidate = report['parts'][0]['candidate_points'][0]
|
||||
x, y = candidate['point_px']
|
||||
self.assertGreater(self.rgba[y, x, 3], 0)
|
||||
self.assertGreaterEqual(candidate['distance_to_roi_boundary_px'], 5)
|
||||
self.assertEqual(pixel(candidate['point_norm'], 30, 20), (x, y))
|
||||
self.assertFalse(prompts['parts'][0]['semantic_verified'])
|
||||
self.assertEqual(prompts['parts'][0]['negative_points_px'], [])
|
||||
self.assertEqual(data, original)
|
||||
self.assertEqual(report['parts'][0]['model_positive_points'][2]['status'], 'UNTRUSTED_SEED')
|
||||
|
||||
def test_bbox_with_no_foreground_has_no_seed(self):
|
||||
report, prompts = ground_parts(self.image, plan([0, 0, .2, .2]))
|
||||
self.assertEqual(report['summary']['blocked_parts'], 1)
|
||||
self.assertEqual(prompts['parts'][0]['positive_points_px'], [])
|
||||
self.assertIn('EMPTY_FOREGROUND_ROI', report['parts'][0]['warnings'])
|
||||
|
||||
def test_solid_roi_has_finite_interior_distance(self):
|
||||
report, _ = ground_parts(self.image, plan([10/30, 5/20, 20/30, 15/20]))
|
||||
part = report['parts'][0]
|
||||
self.assertEqual(part['bbox_foreground_ratio'], 1)
|
||||
self.assertEqual(part['candidate_points'][0]['distance_to_roi_boundary_px'], 5)
|
||||
|
||||
def test_disconnected_roi_remains_semantically_unverified(self):
|
||||
self.rgba[2:4, 2:4] = [255, 0, 0, 255]
|
||||
report, _ = ground_parts(Image.fromarray(self.rgba), plan())
|
||||
self.assertEqual(report['parts'][0]['foreground_components'], 2)
|
||||
self.assertIn('MULTIPLE_FOREGROUND_COMPONENTS', report['parts'][0]['warnings'])
|
||||
self.assertFalse(report['parts'][0]['semantic_verified'])
|
||||
|
||||
def test_opaque_image_does_not_validate_every_point(self):
|
||||
report, prompts = ground_parts(Image.new('RGBA', (30, 20), 'black'), plan(points=[[.5, .5]]))
|
||||
self.assertEqual(report['parts'][0]['status'], 'blocked')
|
||||
self.assertEqual(prompts['parts'][0]['positive_points_px'], [])
|
||||
|
||||
def test_subpixel_roi_without_pixel_center_is_blocked(self):
|
||||
report, _ = ground_parts(self.image, plan([.4, .4, .401, .401]))
|
||||
self.assertEqual(report['parts'][0]['status'], 'blocked')
|
||||
self.assertIn('NO_PIXEL_CENTER_IN_ROI', report['parts'][0]['warnings'])
|
||||
|
||||
def test_hidden_part_never_gets_foreground_seed(self):
|
||||
data = plan()
|
||||
data['parts'][0].update(visibility='hidden', bbox_norm_xyxy=None)
|
||||
report, prompts = ground_parts(self.image, data)
|
||||
self.assertEqual(report['parts'][0]['status'], 'hidden')
|
||||
self.assertEqual(prompts['parts'][0]['positive_points_px'], [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user