#!/usr/bin/env python3
"""Reproducible procedural compositions of three overlapping iridescent glass shapes.
Geometry and spectral lighting are analytic. No input raster or reference is read.
"""
from pathlib import Path
import argparse
import numpy as np
from PIL import Image,ImageDraw
from scipy.ndimage import distance_transform_edt,gaussian_filter
from geometry import curve_points
from shapes import SHAPES,shape
from material import paint,blend,g,sigmoid,SPECS
W,H=972,1202
SEED=47
COMPOSITIONS={
'a':[
dict(center=(590,395),radius=325,rotation=.14,material=dict(warmth=0.,hue=0.,opacity=.94)),
dict(center=(355,700),radius=300,rotation=-.42,material=dict(warmth=.07,hue=.07,opacity=.89)),
dict(center=(650,820),radius=220,rotation=.50,material=dict(warmth=-.025,hue=-.025,opacity=.92,cap=.35))],
'b':[
dict(center=(295,375),radius=213,rotation=-.17,material=dict(role="outer",warmth=.02,hue=.10,opacity=.88)),
dict(center=(580,540),radius=335,rotation=.26,material=dict(role="middle",warmth=0.,hue=0.,opacity=.92)),
dict(center=(385,790),radius=288,rotation=-.56,material=dict(role="front",warmth=-.04,hue=.045,opacity=.91))]
}
def silhouette(curve,width,height,scale):
# Keep roughly four distance samples per design-space unit. At large output
# sizes the pixels themselves supply that detail, avoiding huge EDT arrays.
distance_scale=max(1,min(4,int(np.ceil(4/scale))))
edge_scale=max(2,distance_scale)
im=Image.new('L',(width*edge_scale,height*edge_scale))
draw=ImageDraw.Draw(im)
draw.polygon([tuple(p*scale*edge_scale) for p in curve_points(curve)],fill=255)
mask=np.asarray(im.resize((width,height),Image.Resampling.LANCZOS),dtype=np.float32)/255
distance_im=(im if distance_scale==edge_scale else
im.resize((width*distance_scale,height*distance_scale),Image.Resampling.BOX))
inside=np.asarray(distance_im)>127
distance=distance_transform_edt(inside).astype(np.float32)
distance-=distance_transform_edt(~inside).astype(np.float32)
distance/=scale*distance_scale
if distance_scale>1:
distance=np.asarray(Image.fromarray(distance).resize((width,height),Image.Resampling.BILINEAR))
# A smooth normal field prevents tiny raster steps from becoming bright
# specks in the glass rim. The coverage mask above is left crisp.
dy,dx=np.gradient(gaussian_filter(distance,max(.9,1.6*scale)))
return distance,dx,dy,mask
def render(scale=1.,calibration=True,composition='b',specs=None):
if not np.isfinite(scale) or not 0.05 <= scale <= 4:
raise ValueError('Scale must be between 0.05 and 4.')
width,height=round(W*scale),round(H*scale)
rng=np.random.default_rng(SEED)
canvas=np.zeros((height,width,3),np.float32)+np.array([241,235,232],np.float32)
canvas+=rng.normal(0,.67,(height,width,1)).astype(np.float32)
for spec in (COMPOSITIONS[composition] if specs is None else specs):
options={k:v for k,v in spec.items() if k not in ('material','shape')}
curve=shape(spec.get('shape','flower'),**options)
outline=curve_points(curve)
bx0,by0=outline.min(0);bx1,by1=outline.max(0)
x0=max(0,int(np.floor((bx0-24)*scale)));y0=max(0,int(np.floor((by0-24)*scale)))
x1=min(width,int(np.ceil((bx1+24)*scale)));y1=min(height,int(np.ceil((by1+24)*scale)))
cw,ch=x1-x0,y1-y0
shift=np.array([x0/scale,y0/scale]);local=[tuple(np.asarray(p)-shift) for p in curve]
d,dx,dy,mask=silhouette(local,cw,ch,scale)
yy,xx=np.mgrid[y0:y1,x0:x1].astype(np.float32);x=(xx+.5)/scale;y=(yy+.5)/scale
# Every shape has independent normalized spectral lighting and geometry.
mat=spec['material']
role=mat.get('role','front')
top,bottom,halfwidth=SPECS[role]
mx=490-halfwidth+(x-bx0)/(bx1-bx0)*2*halfwidth
my=top+(y-by0)/(by1-by0)*(bottom-top)
md=d*(bottom-top)/(by1-by0)*2.4
my+=mat['warmth']*120
color=paint(role,mx,my,md,calibration)
hot=sigmoid((color[...,0]-color[...,2]-20)/18)*sigmoid((220-my)/25)
color=blend(color,np.array([83,133,226]),hot*(1-mat.get('cap',1)))
tint=np.stack([np.zeros_like(d),np.full_like(d,mat['hue']*45),np.full_like(d,mat['hue']*70)],-1)
color+=tint
# Curved glass catches a soft cool key light along the left contour,
# with a darker reflected flank opposite it and a thin spectral lip.
key=np.clip(dx*.7-dy*.4,-1,1)
color+=key[...,None]*g(md,19,16)[...,None]*np.array([8,9,13])
color=blend(color,np.array([225,238,243]),.15*g(md,11,7)*np.maximum(key,0))
color=blend(color,np.array([150,61,239]),.24*g(d,1.5,1.0)*sigmoid((my-145)/45))
color=blend(color,np.array([26,19,73]),.32*g(d,4,2)*np.maximum(-key,0))
color=blend(color,np.array([245,237,251]),.34*g(d,6,2)*np.maximum(key,0))
color+=rng.normal(0,1,color.shape)*np.array([.9,2.25,1.])
target=canvas[y0:y1,x0:x1]
# A restrained contact shadow and partial transmission establish layer order.
shadow=.045*g(d,-3,5)*(1-mask)
target=blend(target,np.array([112,119,143]),shadow)
opacity=mat['opacity']-.065*g(md,24,15)+.05*g(md,2,2)
alpha=mask*np.clip(opacity,.65,.99)
canvas[y0:y1,x0:x1]=blend(target,color,alpha)
out=Image.fromarray(np.uint8(np.clip(canvas,0,255)))
return out
def main():
ap=argparse.ArgumentParser(description=__doc__)
ap.add_argument('--scale',type=float,default=1.)
ap.add_argument('--output',type=Path,default=Path(__file__).parent/'renders'/'final.png')
ap.add_argument('--composition',choices=COMPOSITIONS,default='b')
ap.add_argument('--no-calibration',action='store_true',help='Use uncalibrated analytic spectral fields')
ap.add_argument('--shapes',nargs=3,metavar=('BACK','MIDDLE','FRONT'),help='Three shape names in back-to-front order')
ap.add_argument('--list-shapes',action='store_true',help='Show available shape names')
args=ap.parse_args()
if args.list_shapes:
for key,label in SHAPES.items(): print(f'{key:12} {label}')
return
if not np.isfinite(args.scale) or not 0.05 <= args.scale <= 4: ap.error('--scale must be between 0.05 and 4')
specs=None
if args.shapes:
unknown=[name for name in args.shapes if name not in SHAPES]
if unknown: ap.error('Unknown shape(s): '+', '.join(unknown)+'; use --list-shapes')
specs=[]
for original,name in zip(COMPOSITIONS[args.composition],args.shapes):
specs.append({k:v for k,v in original.items() if k in ('center','radius','rotation','material')} | {'shape':name})
args.output.parent.mkdir(parents=True,exist_ok=True)
render(args.scale,not args.no_calibration,args.composition,specs=specs).save(args.output)
print(args.output)
if __name__=='__main__':main()