Files
RustRaptor/tools/test_hd_terrain_cleanup.py
T

120 lines
7.4 KiB
Python

import copy
import json
from pathlib import Path
import struct
import sys
import tempfile
import unittest
import numpy as np
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent))
import hd_terrain_cleanup as c
class TerrainCleanupTests(unittest.TestCase):
def setUp(self):
self.palette = np.zeros((256, 3), np.uint8)
for i in range(32): self.palette[i] = [4*i]*3
self.palette[60:64] = [[56,52,4],[40,40,0],[24,24,0],[12,12,0]]
self.palette[200:208] = [[85,40,36],[81,36,32],[73,32,28],[69,28,24],[60,24,20],[56,16,16],[40,12,8],[20,0,0]]
self.palette[231] = [81,60,52]
self.p = copy.deepcopy(c.DEFAULTS); self.p['output_side'] = 64
def test_constant_parent_has_no_retained_texture(self):
source = np.full((4,4),63,np.uint8)
out, base, labels, m = c.reconstruct(source,self.palette,np.full((64,64,3),180,np.uint8),self.p)
np.testing.assert_array_equal(out,base)
self.assertEqual(m['pixels_changed_from_Source_guided_base'],0)
np.testing.assert_array_equal(out,np.broadcast_to(self.palette[63],out.shape))
def test_parent_colour_and_constant_lighting_are_not_carried(self):
source = np.full((4,4),63,np.uint8)
a = np.full((64,64,3),[200,5,50],np.uint8)
b = np.full((64,64,3),[10,240,70],np.uint8)
np.testing.assert_array_equal(c.reconstruct(source,self.palette,a,self.p)[0],c.reconstruct(source,self.palette,b,self.p)[0])
def test_fine_generated_detail_is_retained_but_bounded(self):
yy,xx = np.indices((64,64)); raw = np.repeat((120+40*np.sin(xx*1.9)+20*np.sin(yy*2.1))[...,None],3,2).clip(0,255).astype(np.uint8)
out,base,labels,m = c.reconstruct(np.full((4,4),202,np.uint8),self.palette,raw,self.p)
self.assertGreater(m['pixels_changed_from_Source_guided_base'],1500)
self.assertGreater(m['mean_absolute_generated_residual'],0.5)
self.assertLessEqual(np.abs(out.astype(float)-base).max(),3)
self.assertLessEqual(m['maximum_retained_luma_residual'],2.5)
def test_exact_family_geometry_and_thin_L_are_preserved(self):
source = np.array([[27,27,231,63],[27,27,231,63],[27,27,231,231],[27,27,27,27]],np.uint8)
raw = np.random.default_rng(9).integers(0,256,(80,80,3),dtype=np.uint8)
out,base,labels,m = c.reconstruct(source,self.palette,raw,self.p)
expected = c.material_families(c.scale_nx(source,4))
np.testing.assert_array_equal(labels,np.repeat(np.repeat(expected,4,0),4,1))
self.assertTrue(np.all(out[labels==0].max(axis=1)-out[labels==0].min(axis=1)==0))
self.assertTrue(np.all(out[labels==1,2] <= 2))
self.assertTrue(np.all(out[labels==3,0] > out[labels==3,1]))
def test_smooth_source_guidance_is_not_nearest_source_copy(self):
source = np.array([[200,200,205,205]]*4,np.uint8)
out,base,_,m = c.reconstruct(source,self.palette,np.full((64,64,3),128,np.uint8),self.p)
nearest=np.repeat(np.repeat(self.palette[source],16,0),16,1)
self.assertGreater(np.any(base!=nearest,axis=2).sum(),0)
self.assertGreater(len(np.unique(base.reshape(-1,3),axis=0)),2)
def test_deterministic_pixels_and_geometry(self):
src=np.array([[27,27,27,27],[27,63,202,27],[27,231,231,27],[27,27,27,27]],np.uint8)
raw=np.random.default_rng(7).integers(0,256,(61,61,3),dtype=np.uint8)
a=c.reconstruct(src,self.palette,raw,self.p);b=c.reconstruct(src,self.palette,raw,self.p)
for x,y in zip(a[:3],b[:3]):np.testing.assert_array_equal(x,y)
self.assertEqual(a[3],b[3])
def test_scale4_is_two_scale2_and_non_nearest(self):
src=np.array([[1,1,2],[1,2,2],[3,3,2]],np.uint8)
np.testing.assert_array_equal(c.scale_nx(src,4),c.scale_nx(c.scale_nx(src,2),2))
self.assertFalse(np.array_equal(c.scale_nx(src,2),np.repeat(np.repeat(src,2,0),2,1)))
def test_decode_rejects_reserved_and_malformed_source(self):
good=struct.pack('<5i',1,1,0,2,2)+bytes([27,63,202,231])
self.assertEqual(c.decode_source(good).shape,(2,2))
for bad in [good[:-1],good[:20]+bytes([27,63,240,231]),struct.pack('<5i',2,1,0,2,2)+good[20:]]:
with self.assertRaises(ValueError):c.decode_source(bad)
def test_rejects_unknown_family_and_unsafe_parameters(self):
with self.assertRaises(ValueError):c.material_families(np.array([[100]],np.uint8))
for name,value in [('output_side',True),('output_side',15),('source_sigma',float('nan')),('residual_sigma_coarse',0.1)]:
p=copy.deepcopy(self.p);p[name]=value
with self.assertRaises(ValueError):c.reconstruct(np.full((4,4),63,np.uint8),self.palette,np.zeros((64,64,3),np.uint8),p)
def test_stable_reader_rejects_symlinks(self):
with tempfile.TemporaryDirectory()as d:
p=Path(d);(p/'a').write_bytes(b'fixed');(p/'link').symlink_to(p/'a')
with self.assertRaises(OSError):c.read_regular(p/'link')
def test_actual_cli_lineage_and_bad_crop_stops_before_mkdir(self):
import hashlib
with tempfile.TemporaryDirectory()as d:
p=Path(d);src=p/'source.pic';src.write_bytes(struct.pack('<5i',1,1,0,4,4)+bytes([63]*16))
pal=p/'palette';pal.write_bytes(bytes([0,0,0])*60+bytes([14,13,1,10,10,0,6,6,0,3,3,0])+bytes([0,0,0])*(256-64))
raw=p/'parent.png';yy,xx=np.indices((64,64));Image.fromarray(np.repeat((80+(xx%2)*80)[...,None],3,2).astype(np.uint8)).save(raw)
def binding(q):return dict(path=str(q),sha256=hashlib.sha256(q.read_bytes()).hexdigest())
recipe=dict(method=c.METHOD,palette=binding(pal),parameters=self.p,frames=[dict(asset='20400',Source=binding(src),parent_raw=binding(raw),crop=[0,0,64,64])])
rp=p/'recipe.json';rp.write_text(json.dumps(recipe));out=p/'result';c.main(['--recipe',str(rp),'--output',str(out)])
report=json.loads((out/'DERIVED-LINEAGE-AND-ACTUAL-RECONSTRUCTION-METRICS.json').read_text());row=report['frames'][0]
self.assertEqual(row['output']['sha256'],hashlib.sha256(Path(row['output']['path']).read_bytes()).hexdigest())
self.assertEqual(row['parent_raw']['sha256'],binding(raw)['sha256'])
self.assertIn('DERIVED',row['lineage'])
with Image.open(row['output']['path']) as saved:
self.assertEqual(saved.size,(64,64))
self.assertEqual(report['execution']['status'],'success')
self.assertEqual(report['execution']['source_sha256'],{'20400':binding(src)['sha256']})
self.assertEqual(report['execution']['output_sha256'],{'20400':row['output']['sha256']})
self.assertEqual(report['execution']['parent_raw_sha256'],[binding(raw)['sha256']])
portable=copy.deepcopy(recipe)
portable['palette']['path']=pal.name
portable['frames'][0]['Source']['path']=src.name
portable['frames'][0]['parent_raw']['path']=raw.name
portable_path=p/'portable.json';portable_path.write_text(json.dumps(portable))
moved=p/'relative-result';c.main(['--recipe',str(portable_path),'--output',str(moved)])
self.assertEqual((moved/Path(row['output']['path']).name).read_bytes(),Path(row['output']['path']).read_bytes())
recipe['frames'][0]['crop']=[1,0,64,64];rp.write_text(json.dumps(recipe));bad=p/'bad'
with self.assertRaises(ValueError):c.main(['--recipe',str(rp),'--output',str(bad)])
self.assertFalse(bad.exists())
if __name__ == '__main__':unittest.main()