#!/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')