262 lines
13 KiB
Python
262 lines
13 KiB
Python
"""Synthetic only: no actual SHIP15 image, Source or recipe is rendered."""
|
|
from pathlib import Path
|
|
import copy
|
|
import importlib.util
|
|
import json
|
|
import struct
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
HERE=Path(__file__).resolve().parent
|
|
spec=importlib.util.spec_from_file_location('private_ship15', HERE/'ship15_component_cleanup.py')
|
|
tool=importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(tool)
|
|
DECODER=HERE.parent.parent/'tools/hd_frame_policy.py'
|
|
TONE=[(38,7,7,6),(42,7,3,1),(43,7,4,3),(46,13,31,30),(47,13,30,28),(48,13,29,27),
|
|
(49,13,28,26),(50,13,29,27),(51,13,27,24),(42,14,27,25),(43,14,29,27),(44,14,27,25)]
|
|
|
|
|
|
def binding(p):
|
|
return dict(path=str(p),sha256=tool.sha(p.read_bytes()))
|
|
|
|
|
|
def encode_source(arr):
|
|
out=bytearray(struct.pack('<5i',0,2,18,72,24))
|
|
for y in range(24):
|
|
x=0
|
|
while x<72:
|
|
if arr[y,x]==256:
|
|
x+=1
|
|
continue
|
|
end=x+1
|
|
while end<72 and arr[y,end]<256:end+=1
|
|
out.extend(struct.pack('<4i',x,y,y*72+x,end-x))
|
|
out.extend(bytes(arr[y,x:end].tolist()))
|
|
x=end
|
|
out.extend(struct.pack('<4i',0,0,-1,0))
|
|
return bytes(out)
|
|
|
|
|
|
class SyntheticFamily(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp=tempfile.TemporaryDirectory()
|
|
self.root=Path(self.temp.name)
|
|
self.palette=np.zeros((256,3),np.uint8)
|
|
for i in range(32):self.palette[i]=63-2*i
|
|
self.palette[144:158]=[5,25,30]
|
|
self.palette[255]=[54,54,54]
|
|
pp=self.root/'PALETTE';pp.write_bytes(self.palette.tobytes())
|
|
base=np.full((24,72),256,np.uint16)
|
|
base[2:20]=144
|
|
base[7:15,36:53]=10
|
|
base[4:7,39:42]=10
|
|
base[15:18,39:42]=10
|
|
base[7:15,21:29]=10
|
|
base[7:15,55:63]=10
|
|
base[10:12,37]=255
|
|
tone_set={(x,y)for x,y,_,_ in TONE}
|
|
black=[]
|
|
for y,x in np.argwhere(base<32):
|
|
if (int(x),int(y))not in tone_set:
|
|
black.append((int(x),int(y)))
|
|
if len(black)==15:break
|
|
for x,y in black:base[y,x]=31
|
|
self.sources=[]
|
|
self.raw=np.zeros((144,144,4),np.uint8)
|
|
frames=[]
|
|
for i in range(3):
|
|
s=base.copy()
|
|
for x,y,v0,v1 in TONE:s[y,x]=v0 if i==0 else v1
|
|
self.sources.append(s)
|
|
p=self.root/f'SOURCE{i}';p.write_bytes(encode_source(s))
|
|
alpha=np.repeat(np.repeat(s<256,2,axis=0),2,axis=1)
|
|
gray=np.repeat(np.repeat(s<32,2,axis=0),2,axis=1)
|
|
yy,xx=np.indices(alpha.shape)
|
|
phase=np.zeros((48,144,4),np.uint8)
|
|
phase[:,:,:3][alpha]=np.stack([10+xx%5,90+yy%10,110+xx%10],axis=2)[alpha]
|
|
neutral=(60+xx%70+yy%11+i*7).astype(np.uint8)
|
|
for c in range(3):phase[:,:,c][gray]=neutral[gray]
|
|
phase[:,:,3][alpha]=255
|
|
self.raw[i*48:(i+1)*48]=phase
|
|
comps={}
|
|
for name,(sx,sy)in tool.AXES.items():
|
|
comps[name]=dict(source_x=sx,raw_x=[v*2 for v in sx],source_y=sy,raw_y=[v*2 for v in sy],
|
|
donor_bounds=[sx[0]*2,sy[0]*2,sx[-1]*2,sy[-1]*2])
|
|
frames.append(dict(asset=f'{0x1030b+i:05x}',frame=i,source=binding(p),logical_canvas=[72,24],origin=[0,0],
|
|
raw_crop=[0,i*48,144,48],components=comps,functional_original_cells=[]))
|
|
phases=np.any(np.stack(self.sources)!=self.sources[0],axis=0)&(self.sources[0]<32)
|
|
for f,s in zip(frames,self.sources):
|
|
f['functional_original_cells']=[[int(x),int(y)]for y,x in np.argwhere((s==31)|phases|(s==255))]
|
|
self.assertEqual(len(f['functional_original_cells']),29)
|
|
self.parent=self.root/'parent.png'
|
|
Image.fromarray(self.raw).save(self.parent)
|
|
self.recipe=dict(format_version=1,label='SYNTHETIC ONLY',decoder_module=binding(DECODER),palette=binding(pp),
|
|
parent_raw=binding(self.parent),output_scale=8,caps=copy.deepcopy(tool.CAPS),frames=frames)
|
|
|
|
def tearDown(self):
|
|
self.temp.cleanup()
|
|
|
|
def save_parent(self):
|
|
Image.fromarray(self.raw).save(self.parent)
|
|
self.recipe['parent_raw']=binding(self.parent)
|
|
|
|
def save_source(self,i):
|
|
p=self.root/f'SOURCE{i}';p.write_bytes(encode_source(self.sources[i]));self.recipe['frames'][i]['source']=binding(p)
|
|
|
|
def assert_fails_without_output(self,recipe,pattern):
|
|
rp=self.root/'bad-recipe.json';rp.write_text(json.dumps(recipe))
|
|
out=self.root/'MUST-NOT-EXIST'
|
|
with self.assertRaisesRegex((ValueError,AssertionError),pattern):tool.run_recipe(rp,out)
|
|
self.assertFalse(out.exists())
|
|
|
|
def test_identity_preserves_genuine_RGB_alpha_and29_Original_only(self):
|
|
arrays,report=tool.render_family(self.recipe,self.root)
|
|
pal=((self.palette.astype(np.uint16)<<2)|(self.palette.astype(np.uint16)>>4)).astype(np.uint8)
|
|
for i,(s,out)in enumerate(zip(self.sources,arrays)):
|
|
alpha=np.repeat(np.repeat(s<256,8,axis=0),8,axis=1)
|
|
self.assertTrue(np.array_equal(out[:,:,3]==255,alpha))
|
|
self.assertFalse(out[~alpha].any())
|
|
yy,xx=np.indices(alpha.shape);expected=self.raw[i*48+(yy//4),xx//4,:3]
|
|
f=np.zeros((24,72),bool)
|
|
for x,y in self.recipe['frames'][i]['functional_original_cells']:f[y,x]=True
|
|
fm=np.repeat(np.repeat(f,8,axis=0),8,axis=1)
|
|
srgb=np.repeat(np.repeat(pal[np.minimum(s,255)],8,axis=0),8,axis=1)
|
|
self.assertTrue(np.array_equal(out[:,:,:3][alpha&~fm],expected[alpha&~fm]))
|
|
self.assertTrue(np.array_equal(out[:,:,:3][fm],srgb[fm]))
|
|
self.assertEqual(sum(m['nearest_parent_extension_samples']for m in report['frames'][i]['component_donor_metrics']),0)
|
|
self.assertFalse(np.array_equal(arrays[0],arrays[1]))
|
|
|
|
def test_nonuniform_left_pod_transform_keeps_own_genuine_donor(self):
|
|
for i in range(3):
|
|
ph=self.raw[i*48:(i+1)*48]
|
|
original=ph[14:30,42:58].copy()
|
|
ph[14:30,42:60,:3]=[10,95,115]
|
|
ph[14:30,42:60,3]=255
|
|
ph[14:30,44:60]=original
|
|
m=self.recipe['frames'][i]['components']['left_pod']
|
|
m['raw_x']=[44,60];m['donor_bounds']=[44,14,60,30]
|
|
self.save_parent()
|
|
arrays,_=tool.render_family(self.recipe,self.root)
|
|
# An ordinary non-functional bottom-right pod point reads translated raw.
|
|
x,y=28*8+2,14*8+2
|
|
self.assertNotIn([28,14],self.recipe['frames'][0]['functional_original_cells'])
|
|
self.assertEqual(arrays[0][y,x,:3].tolist(),self.raw[28,58,:3].tolist())
|
|
self.assertEqual(int(arrays[0][y,x,3]),255)
|
|
|
|
def test_malformed_knots_and_relaxed_caps_fail_before_output(self):
|
|
mutations=[('raw_x',[72,78,78,106]),('raw_y',[8,float('nan'),30,36]),('source_x',[36,39,42,54]),
|
|
('raw_x',[True,78,84,106]),('donor_bounds',[73,8,106,36])]
|
|
for key,value in mutations:
|
|
with self.subTest(key=key,value=value):
|
|
r=copy.deepcopy(self.recipe);r['frames'][0]['components']['main_machinery_mast_hull'][key]=value
|
|
self.assert_fails_without_output(r,'knots|bounds')
|
|
r=copy.deepcopy(self.recipe);r['caps']['metal_max_logical_distance']=2
|
|
self.assert_fails_without_output(r,'caps')
|
|
r=copy.deepcopy(self.recipe);r['caps']['metal_max_logical_distance']=True
|
|
self.assert_fails_without_output(r,'caps')
|
|
|
|
def test_missing_alpha_no_donor_and_wrong_material_fail(self):
|
|
original=self.raw.copy()
|
|
self.raw[14:30,42:58,3]=0;self.save_parent()
|
|
self.assert_fails_without_output(self.recipe,'no genuine')
|
|
self.raw=original.copy();self.raw[14:30,42:58,:3]=[0,130,150];self.save_parent()
|
|
self.assert_fails_without_output(self.recipe,'no genuine')
|
|
self.raw=original.copy();self.raw[:,:,3]=255;self.save_parent()
|
|
self.assert_fails_without_output(self.recipe,'transparent alpha')
|
|
|
|
def test_donor_fraction_and_distance_are_separate_hard_stops(self):
|
|
original=self.raw.copy()
|
|
self.raw[14:30,42:46,3]=0;self.save_parent()
|
|
self.assert_fails_without_output(self.recipe,'missing fraction')
|
|
self.raw=original.copy();self.raw[18:24,86:92,3]=0;self.save_parent()
|
|
self.assert_fails_without_output(self.recipe,'distance exceeds')
|
|
|
|
def test_Original_selectors_and_Source_support_are_fail_closed(self):
|
|
for value in [self.recipe['frames'][0]['functional_original_cells'][:-1],
|
|
self.recipe['frames'][0]['functional_original_cells']+[self.recipe['frames'][0]['functional_original_cells'][0]]]:
|
|
r=copy.deepcopy(self.recipe);r['frames'][0]['functional_original_cells']=value
|
|
self.assert_fails_without_output(r,'Original cells')
|
|
original=self.sources[0].copy()
|
|
self.sources[0][1,20]=144;self.save_source(0)
|
|
self.assert_fails_without_output(self.recipe,'support outside')
|
|
self.sources[0]=original.copy();self.sources[0][10,37]=10;self.save_source(0)
|
|
self.assert_fails_without_output(self.recipe,'indicator changed')
|
|
self.sources[0]=original.copy();self.sources[0][5,5]=10;self.save_source(0)
|
|
self.assert_fails_without_output(self.recipe,'footprint changed|support outside')
|
|
self.sources[0]=original.copy();self.sources[0][5,5]=100;self.save_source(0)
|
|
self.assert_fails_without_output(self.recipe,'class outside')
|
|
|
|
def test_source_tone12_family_order_and_full_crop_not_optional(self):
|
|
r=copy.deepcopy(self.recipe);r['frames'][0]['frame']=False
|
|
self.assert_fails_without_output(r,'order')
|
|
r=copy.deepcopy(self.recipe);r['frames'][1]['raw_crop']=[0,49,144,47]
|
|
self.assert_fails_without_output(r,'full strips')
|
|
for i in (1,2):
|
|
self.sources[i]=self.sources[0].copy();self.save_source(i)
|
|
self.assert_fails_without_output(self.recipe,'phase12')
|
|
|
|
def test_hash_check_precedes_any_output_and_preserves_parent(self):
|
|
before={p:tool.sha(p.read_bytes())for p in self.root.iterdir()if p.is_file()}
|
|
r=copy.deepcopy(self.recipe);r['parent_raw']['sha256']='0'*64
|
|
self.assert_fails_without_output(r,'hash mismatch')
|
|
for p,sha in before.items():self.assertEqual(tool.sha(p.read_bytes()),sha)
|
|
|
|
def test_nearest_ties_are_raster_seeded_and_never_cascade(self):
|
|
trust=np.zeros((5,5),bool);trust[0,0]=True;trust[4,4]=True
|
|
distance,oy,ox=tool.nearest_owners(trust)
|
|
self.assertTrue(np.all(trust[oy,ox]))
|
|
self.assertEqual((int(oy[2,2]),int(ox[2,2])),(0,0))
|
|
self.assertEqual(int(distance[2,2]),4)
|
|
for y in range(5):
|
|
for x in range(5):self.assertEqual(int(distance[y,x]),min(x+y,8-x-y))
|
|
|
|
def test_verified_decoder_bytes_execute_after_temporary_path_replacement(self):
|
|
# Only a temporary decoder copy is replaced; no shared input is changed.
|
|
decoder_copy=self.root/'copied_decoder.py'
|
|
verified_bytes=DECODER.read_bytes()
|
|
decoder_copy.write_bytes(verified_bytes)
|
|
self.recipe['decoder_module']=binding(decoder_copy)
|
|
expected,_=tool.render_family(self.recipe,self.root)
|
|
checked_original=tool.checked
|
|
replaced=[]
|
|
malicious=b"raise RuntimeError('UNVERIFIED_TEMP_DECODER_MUST_NOT_EXECUTE')\n"
|
|
|
|
def verify_then_replace(binding_value,base):
|
|
path,data=checked_original(binding_value,base)
|
|
if path==decoder_copy.resolve():
|
|
self.assertEqual(data,verified_bytes)
|
|
self.assertEqual(tool.sha(data),tool.DECODER_SHA)
|
|
path.write_bytes(malicious)
|
|
replaced.append(path)
|
|
return path,data
|
|
|
|
with patch.object(tool,'checked',side_effect=verify_then_replace):
|
|
actual,report=tool.render_family(self.recipe,self.root)
|
|
self.assertEqual(replaced,[decoder_copy.resolve()])
|
|
self.assertEqual(decoder_copy.read_bytes(),malicious)
|
|
self.assertEqual(report['decoder_module']['sha256'],tool.DECODER_SHA)
|
|
for before,after in zip(expected,actual):
|
|
self.assertTrue(np.array_equal(before,after))
|
|
self.assertFalse(any(p.name.endswith('-derived.png')for p in self.root.iterdir()))
|
|
|
|
def test_deterministic_recipe_receipts_and_new_output_only(self):
|
|
rp=self.root/'recipe.json';rp.write_text(json.dumps(self.recipe,sort_keys=True))
|
|
before={p:tool.sha(p.read_bytes())for p in self.root.iterdir()if p.is_file()}
|
|
first=tool.run_recipe(rp,self.root/'first')
|
|
second=tool.run_recipe(rp,self.root/'second')
|
|
self.assertEqual(first,second)
|
|
self.assertEqual(set(first['execution']),{'status','method','recipe_sha256','tool_sha256','source_sha256','parent_raw_sha256','output_sha256'})
|
|
self.assertEqual(first['decoder_module']['sha256'],tool.DECODER_SHA)
|
|
self.assertIn('numpy_version',first)
|
|
for name in ['1030b-derived.png','1030c-derived.png','1030d-derived.png','derived-provenance.json']:
|
|
self.assertEqual((self.root/'first'/name).read_bytes(),(self.root/'second'/name).read_bytes())
|
|
with self.assertRaisesRegex(ValueError,'output exists'):tool.run_recipe(rp,self.root/'first')
|
|
for p,sha in before.items():self.assertEqual(tool.sha(p.read_bytes()),sha)
|
|
|
|
|
|
if __name__=='__main__':unittest.main()
|