100 lines
8.3 KiB
Python
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')
|