Files
Sanctification/spikes/rejected-compositions/finish-mask-line-continuity-proof/build.py

100 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Shared mask-only experiment. Rebuild stages assets; installation is separate."""
from pathlib import Path
import hashlib,json,subprocess,sys,time
import numpy as np
from PIL import Image,ImageFilter
from line_detector import detect,smooth
ROOT=Path(__file__).resolve().parent
RECIPE=json.loads((ROOT/'recipe.json').read_text())
CONTROL=json.loads((ROOT/'control-recipe.json').read_text())
CARDS=json.loads((ROOT/'cards.json').read_text())
W,H=RECIPE['canvas']
def sha(p): return hashlib.sha256(p.read_bytes()).hexdigest()
def extrema(values,radius,maximum):
hh,ww=values.shape;op=np.maximum if maximum else np.minimum;fill=0 if maximum else 255
p=np.pad(values,((0,0),(radius,radius)),mode='edge');hor=np.full_like(values,fill)
for i in range(radius*2+1): op(hor,p[:,i:i+ww],out=hor)
p=np.pad(hor,((radius,radius),(0,0)),mode='edge');res=np.full_like(values,fill)
for i in range(radius*2+1): op(res,p[i:i+hh,:],out=res)
return res
def control_leads(art):
r=CONTROL;line=art.filter(ImageFilter.GaussianBlur(r['lineAnalysisSmoothingRadius']));g=np.asarray(line.convert('L'))
v=np.asarray(line,dtype=np.float32).max(axis=2)/255.
closed=extrema(extrema(g,r['darkRidgeClosingRadius'],True),r['darkRidgeClosingRadius'],False)
contrast=(closed.astype(np.float32)-g)/255.
l=smooth(*r['darkRidgeContrastSmoothstep'],contrast)*(1-smooth(*r['darkRidgeValueGateSmoothstep'],v))
q=r['lineCoherenceOpeningRadius'];l=np.rint(l*255).astype(np.uint8)
l=extrema(extrema(l,q,False),q,True).astype(np.float32)/255.
l[l>=r['highConfidenceProtectionThreshold']]=1.
return l
def save_scalar(a,path):
Image.fromarray(np.rint(np.clip(a,0,1)*255).astype(np.uint8)).save(path)
def make_mask(panes,leads,protection):
return Image.fromarray(np.rint(panes*(1-leads)*(1-protection)*255).astype(np.uint8))
def export(master,out,prefix):
files=[]
for size in [2000,1000,500]:
im=master if size==W else master.resize((size,size*7//5),Image.Resampling.BILINEAR)
p=out/'masks'/f'{prefix}-{size}.png';im.convert('RGBA').save(p);rgba=np.asarray(Image.open(p))
assert rgba.shape==(size*7//5,size,4) and np.all(rgba[:,:,3]==255)
assert np.array_equal(rgba[:,:,0],rgba[:,:,1]) and np.array_equal(rgba[:,:,0],rgba[:,:,2])
assert all(rgba[y,x,0]==0 for y,x in [(0,0),(0,size-1),(size*7//5-1,0),(size*7//5-1,size-1)])
files.append({'path':str(p.relative_to(ROOT)),'sha256':sha(p),'size':[size,size*7//5]})
return files
def pair(left,right,path,box=None):
if box: left=left.crop(box);right=right.crop(box)
panel=Image.new('RGB',(left.width*2,left.height));panel.paste(left.convert('RGB'),(0,0));panel.paste(right.convert('RGB'),(left.width,0));panel.save(path)
subprocess.run([sys.executable,str(ROOT/'check-detector.py')],check=True)
reports={}
for key,card in CARDS.items():
print('Building '+key,flush=True);started=time.monotonic();source=ROOT/key/'source';out=ROOT/key/'output'
for folder in ['authoring','masks','review','runtime']: (out/folder).mkdir(parents=True,exist_ok=True)
for name,record in card['inputs'].items(): assert sha(source/name)==record['sha256'],name
art=Image.open(source/'art-master.png').convert('RGB');overlay=Image.open(source/'normal-overlay.png').convert('RGBA');text=Image.open(source/'text.png').convert('RGBA')
assert art.size==overlay.size==text.size==(W,H)
rgb=np.asarray(art,dtype=np.float32)/255.;v=rgb.max(axis=2);sat=(v-rgb.min(axis=2))/np.maximum(v,1e-8)
soft=np.asarray(Image.fromarray(np.rint(sat*255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(RECIPE['saturationSmoothingRadius'])),dtype=np.float32)/255.
panes=RECIPE['paneCoverageFloor']+RECIPE['paneCoverageRange']*smooth(*RECIPE['saturationWeightSmoothstep'],soft)
oa=np.asarray(overlay)[:,:,3]/255.;ta=np.asarray(text)[:,:,3]/255.;protection=1-(1-oa)*(1-ta)
old_leads=control_leads(art);leads,diagnostics=detect(art,RECIPE,diagnostics=True)
for name,a in dict(diagnostics,**{'lead-protection':leads,'control-lead-protection':old_leads,'pane-coverage':panes}).items(): save_scalar(a,out/'authoring'/f'{name}.png')
mask=make_mask(panes,leads,protection);control=make_mask(panes,old_leads,protection)
exports=export(mask,out,'line-continuity-finish');control_exports=export(control,out,'control-finish')
coverage=np.asarray(mask);assert np.all(coverage[protection==1]==0) and np.all(coverage[leads==1]==0)
assert panes.min()>=RECIPE['paneCoverageFloor']-1e-6
# Differential control proves that The Fall uses the exact preferred mask.
if 'baseline-finish-1000.png' in card['inputs']:
actual=np.asarray(Image.open(source/'baseline-finish-1000.png').convert('RGBA'))
reproduced=np.asarray(Image.open(out/'masks/control-finish-1000.png'))
assert np.array_equal(actual,reproduced),'Preferred control must match exactly'
names=[(card['fixture'],'line-continuity-finish')]
if key=='burning-bush': names.append((card['controlFixture'],'control-finish'))
for fixture,prefix in names:
for src,filename in [(source/'runtime-art.png',fixture+'.png'),(out/'masks'/f'{prefix}-1000.png',fixture+'-mask.png'),(source/'runtime-text-mask.png',fixture+'-text-mask.png')]:
(out/'runtime'/filename).write_bytes(src.read_bytes())
assert sha(out/'runtime'/(fixture+'.png'))==card['inputs']['runtime-art.png']['sha256']
assert sha(out/'runtime'/(fixture+'-text-mask.png'))==card['inputs']['runtime-text-mask.png']['sha256']
old=Image.open(out/'masks/control-finish-1000.png').convert('RGB');new=Image.open(out/'masks/line-continuity-finish-1000.png').convert('RGB')
pair(old,new,out/'review/control-candidate-1000.png')
pair(Image.open(source/'original-finish-1000.png'),new,out/'review/original-candidate-1000.png')
if key=='the-fall':
pair(old,new,out/'review/tree-before-after.png',(300,225,720,1010))
pair(old,new,out/'review/head-before-after.png',(255,225,565,465))
else: pair(old,new,out/'review/bush-before-after.png',(460,340,920,960))
face=Image.open(source/'runtime-art.png').convert('RGB');face.save(out/'review/unchanged-card.png')
# Show the detected lines over source art; diagnostic tint has no runtime use.
overlay_lead=Image.new('RGBA',(W,H),(50,235,255,0));overlay_lead.putalpha(Image.fromarray(np.rint(leads*170).astype(np.uint8)))
Image.alpha_composite(art.convert('RGBA'),overlay_lead).resize((1000,1400)).convert('RGB').save(out/'review/detected-lines-on-art.png')
visible=protection==0;delta=np.asarray(new)[:,:,0].astype(int)-np.asarray(old)[:,:,0].astype(int)
report={'status':'passed','fixture':card['fixture'],'controlFixture':card['controlFixture'],'recipeSha256':sha(ROOT/'recipe.json'),'inputs':card['inputs'],'checks':{'sameCanvasAndRegistration':True,'actualOverlayAndGlyphSuppression':True,'fullConfidenceDividersBlack':True,'allCornersProtected':True,'opaqueGrayscaleAtAllSizes':True,'runtimeArtworkByteIdentical':True,'runtimeTextMaskByteIdentical':True,'preferredControlPixelIdentical':True if key=='the-fall' else 'Not applicable; generated shared-coverage control'},'exports':exports,'controlExports':control_exports,'statistics':{'leadFractionAboveHalf':float((leads>.5).mean()),'visibleMeanFinish':float((coverage[visible]/255).mean()),'visibleBlackFraction':float((coverage[visible]==0).mean()),'runtimeChangedPixelsFromControl':int(np.count_nonzero(delta)),'runtimeMeanAbsoluteChange':float(np.abs(delta).mean())},'buildSeconds':round(time.monotonic()-started,2),'visualReview':{'static':'Pending inspection','movingLight':'User comparison pending','approval':'Experimental candidate'},'limitations':['Oriented local support estimates continuity; no global graph tracing or semantic pane recognition.','Elongated painted marks may still resemble dividers; inspect subjects and fine bush detail.','Line widths beyond the sampled range and very sharp curves may lose protection.','All processing is offline; runtime shader and texture count are unchanged.']}
(out/'validation.json').write_text(json.dumps(report,indent=2)+'\n');reports[key]=report
print(json.dumps({'card':key,'statistics':report['statistics'],'seconds':report['buildSeconds']}),flush=True)
(ROOT/'validation.json').write_text(json.dumps({'status':'passed','sharedRecipe':RECIPE,'detectorChecks':json.loads((ROOT/'detector-checks.json').read_text()),'cards':reports,'userMovingLightReview':'Pending'},indent=2)+'\n')