Files
AI_spine/tests/test_completion.py
T
2026-09-20 22:22:09 +08:00

144 lines
7.4 KiB
Python

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()