Files
Sanctification/spikes/rejected-compositions/the-fall-extraordinary-v7-mask-comparison-clean/build-mask.py

134 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""Mask-only extraction experiment; frozen card/art/text/overlay inputs."""
from pathlib import Path
import hashlib,json
import numpy as np
from PIL import Image,ImageFilter,ImageChops
ROOT=Path(__file__).resolve().parent
SOURCE=ROOT/'source'
OUT=ROOT/'output'
for folder in ['authoring','masks','review','runtime']:
(OUT/folder).mkdir(parents=True,exist_ok=True)
RECIPE=json.loads((ROOT/'recipe.json').read_text())
INPUTS=json.loads((SOURCE/'inputs.json').read_text())
W,H=RECIPE['canvas']
def sha(p): return hashlib.sha256(p.read_bytes()).hexdigest()
for name,record in INPUTS.items():
assert sha(SOURCE/name)==record['sha256'],name
def smooth(lo,hi,x):
t=np.clip((x-lo)/(hi-lo),0,1)
return t*t*(3-2*t)
def extrema(values,radius,maximum):
"""Separable square morphology; no dependency on CV libraries."""
hh,ww=values.shape
op=np.maximum if maximum else np.minimum
fill=0 if maximum else 255
padded=np.pad(values,((0,0),(radius,radius)),mode='edge')
horizontal=np.full_like(values,fill)
for offset in range(radius*2+1): op(horizontal,padded[:,offset:offset+ww],out=horizontal)
padded=np.pad(horizontal,((radius,radius),(0,0)),mode='edge')
result=np.full_like(values,fill)
for offset in range(radius*2+1): op(result,padded[offset:offset+hh,:],out=result)
return result
# Check morphology against independent Pillow implementations, including edges.
probe=np.random.default_rng(327).integers(0,256,(37,41),dtype=np.uint8)
for maximum,filter_type in [(True,ImageFilter.MaxFilter),(False,ImageFilter.MinFilter)]:
assert np.array_equal(extrema(probe,3,maximum),np.asarray(Image.fromarray(probe).filter(filter_type(7))))
art=Image.open(SOURCE/'art-master.png').convert('RGB')
assert art.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)
# Saturation influences coating weight softly, but no longer cuts neutral glass out.
soft_sat=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_sat)
# Morphological closing estimates a dark line's local surroundings. Black-hat
# contrast distinguishes locally thin dark ridges from large uniformly dark panes.
def extract_leads(image):
line_art=image.filter(ImageFilter.GaussianBlur(RECIPE['lineAnalysisSmoothingRadius']))
gray=np.asarray(line_art.convert('L'))
line_v=np.asarray(line_art,dtype=np.float32).max(axis=2)/255.
r=RECIPE['darkRidgeClosingRadius']
closed=extrema(extrema(gray,r,True),r,False)
contrast=(closed.astype(np.float32)-gray)/255.
leads=smooth(*RECIPE['darkRidgeContrastSmoothstep'],contrast)*(1-smooth(*RECIPE['darkRidgeValueGateSmoothstep'],line_v))
# Opening only the extracted protection suppresses isolated texture specks; pane
# weights and final mask are not globally blurred across the glass divisions.
q=RECIPE['lineCoherenceOpeningRadius']
lead_u8=np.rint(leads*255).astype(np.uint8)
leads=extrema(extrema(lead_u8,q,False),q,True).astype(np.float32)/255.
# Gamma below one strengthens partial protection without shifting geometry
# or lowering the coherent pane-coverage floor.
leads=np.power(leads,RECIPE['leadProtectionGamma'])
leads[leads>=RECIPE['highConfidenceProtectionThreshold']]=1.
# Discard uncertain texture confidence instead of turning it into broad
# partially coated patches. Preserve strong seam cores with a narrow AA edge.
core=Image.fromarray((leads>=RECIPE['leadCoreThreshold']).astype(np.uint8)*255)
edge=core.filter(ImageFilter.GaussianBlur(RECIPE['leadEdgeFeatherRadius']))
return np.asarray(ImageChops.lighter(core,edge),dtype=np.float32)/255.
# Functional probes: neutral glass must not become a hole, sustained dark
# divisions must stay protected, and isolated dark texture must not become lead.
neutral=Image.new('RGB',(112,96),(28,28,28))
assert not np.any(extract_leads(neutral))
seam=np.full((96,112,3),128,dtype=np.uint8)
seam[:,50:57]=10
assert np.all(extract_leads(Image.fromarray(seam))[:,53]>=.97)
speck=np.full((96,112,3),128,dtype=np.uint8)
speck[48,53]=0
assert extract_leads(Image.fromarray(speck))[48,53]<.5
leads=extract_leads(art)
# These are extracted authoring inputs, not a claim of semantic pane tracing.
Image.fromarray(np.rint(panes*255).astype(np.uint8)).save(OUT/'authoring/pane-coverage.png')
Image.fromarray(np.rint(leads*255).astype(np.uint8)).save(OUT/'authoring/lead-protection.png')
base=panes*(1-leads)
overlay=Image.open(SOURCE/'normal-overlay.png').convert('RGBA')
text=Image.open(SOURCE/'text.png').convert('RGBA')
assert overlay.size==text.size==(W,H)
oa=np.asarray(overlay)[:,:,3]/255.
ta=np.asarray(text)[:,:,3]/255.
active_protection=1-(1-oa)*(1-ta)
coverage=np.rint(base*(1-active_protection)*255).astype(np.uint8)
assert np.all(coverage[active_protection==1]==0)
assert np.all(coverage[leads==1]==0)
assert np.min(panes)>=RECIPE['paneCoverageFloor']-1e-6
master=Image.fromarray(coverage)
exports=[]
for size in [2000,1000,500]:
target=master if size==W else master.resize((size,size*7//5),Image.Resampling.BILINEAR)
p=OUT/'masks'/f'normal-pane-coverage-finish-{size}.png'
# Explicit opaque RGB scalar texture; bilinear filtering avoids ringing.
target.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])
for y,x in [(0,0),(0,size-1),(size*7//5-1,0),(size*7//5-1,size-1)]:
assert rgba[y,x,0]==0
factor=size/W
for y0,y1 in [(40,340),(2200,2760)]:
assert not np.any(rgba[int(y0*factor):int(y1*factor),int(50*factor):int(1950*factor),0])
exports.append({'path':str(p.relative_to(ROOT)),'size':[size,size*7//5],'sha256':sha(p),'opaqueGrayscale':True})
name=RECIPE['runtimeName']
for src,filename in [(SOURCE/'runtime-art.png',f'{name}.png'),(OUT/'masks/normal-pane-coverage-finish-1000.png',f'{name}-mask.png'),(SOURCE/'runtime-text-mask.png',f'{name}-text-mask.png')]:
(OUT/'runtime'/filename).write_bytes(src.read_bytes())
assert sha(OUT/'runtime'/f'{name}.png')==INPUTS['runtime-art.png']['sha256']
assert sha(OUT/'runtime'/f'{name}-text-mask.png')==INPUTS['runtime-text-mask.png']['sha256']
# Unlabeled data comparison; README identifies baseline left and candidate right.
baseline=Image.open(SOURCE/'baseline-finish-1000.png').convert('RGB')
candidate=Image.open(OUT/'masks/normal-pane-coverage-finish-1000.png').convert('RGB')
review=Image.new('RGB',(2000,1400));review.paste(baseline,(0,0));review.paste(candidate,(1000,0))
review.save(OUT/'review/baseline-candidate-1000.png')
# Source-aligned close-up of the problematic sky/path, never used as a runtime map.
box=(670,225,980,1040)
details=Image.new('RGB',((box[2]-box[0])*2,box[3]-box[1]))
details.paste(baseline.crop(box),(0,0));details.paste(candidate.crop(box),(box[2]-box[0],0))
details.save(OUT/'review/sky-path-before-after.png')
roi=(oa==0)&(ta==0)
report={'status':'passed','scope':'Normal mask-only comparison','recipe':RECIPE,'inputs':INPUTS,'checks':{'registeredCanvas':True,'morphologyMatchesPillow':True,'neutralPaneNotTreatedAsHole':True,'syntheticDarkSeamProtected':True,'isolatedDarkSpeckRejected':True,'activeOverlayAndGlyphProtection':True,'highConfidenceLeadProtection':True,'allFourCornersProtected':True,'opaqueGrayscaleExports':True,'runtimeArtworkUnchanged':True,'runtimeTextMaskUnchanged':True},'exports':exports,'statistics':{'paneCoverageRange':[float(panes.min()),float(panes.max())],'leadProtectionFractionAboveHalf':float((leads>.5).mean()),'visibleCandidateMean':float((coverage[roi]/255).mean()),'visibleBlackFraction':float((coverage[roi]==0).mean())},'visualReview':{'static':'Pending','movingLight':'User will compare in harness','approval':'Comparison candidate, not selected final recipe'},'limitations':['Local dark-ridge extraction is not manually traced semantic lead/pane geometry.','Dark illustrative details can still be partly protected; inspect faces and sky/path in motion.','No shader, roughness, normal-map or artwork changes.','Only Normal printing is tested; other printing masks retain their existing recipe until the comparison is approved.']}
(OUT/'validation.json').write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps({'status':report['status'],'statistics':report['statistics'],'runtimeName':name},indent=2))