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