127 lines
7.9 KiB
Python
127 lines
7.9 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
|
|
|
|
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.
|
|
leads[leads>=RECIPE['highConfidenceProtectionThreshold']]=1.
|
|
return leads
|
|
|
|
# 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))
|