#!/usr/bin/env python3
"""Reproducible procedural composition of three overlapping iridescent glass flowers.
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 flower,curve_points
from material import paint,blend,g,sigmoid,SPECS
W,H=972,1202
SEED=47
COMPOSITIONS={
'a':[
dict(center=(590,395),radius=325,rotation=.14,lengths=(1.01,.98,1.08,.94,1.04),widths=(.25,.25,.25,.27,.24),valleys=(.36,.37,.34,.38,.37),material=dict(warmth=0.,hue=0.,opacity=.94)),
dict(center=(355,700),radius=300,rotation=-.42,lengths=(1.05,.96,1.04,1.00,.94),widths=(.25,.25,.27,.24,.26),valleys=(.37,.36,.40,.35,.38),material=dict(warmth=.07,hue=.07,opacity=.89)),
dict(center=(650,820),radius=220,rotation=.50,lengths=(.94,1.10,.98,1.05,1.0),widths=(.26,.25,.24,.26,.25),valleys=(.36,.40,.37,.38,.35),material=dict(warmth=-.025,hue=-.025,opacity=.92,cap=.35))],
'b':[
dict(center=(295,375),radius=213,rotation=-.17,lengths=(1.08,.93,1.02,.99,1.0),widths=(.25,.27,.24,.26,.25),valleys=(.35,.39,.36,.40,.38),material=dict(role="outer",warmth=.02,hue=.10,opacity=.88)),
dict(center=(580,540),radius=335,rotation=.26,lengths=(1.01,.94,1.06,.93,1.02),widths=(.26,.25,.27,.25,.25),valleys=(.37,.36,.39,.35,.39),material=dict(role="middle",warmth=0.,hue=0.,opacity=.92)),
dict(center=(385,790),radius=288,rotation=-.56,lengths=(.98,1.04,.94,1.03,1.04),widths=(.26,.24,.27,.26,.24),valleys=(.39,.35,.38,.37,.36),material=dict(role="front",warmth=-.04,hue=.045,opacity=.91))]
}
def silhouette(curve,width,height,scale):
im=Image.new('L',(width*2,height*2));draw=ImageDraw.Draw(im)
draw.polygon([tuple(p*scale*2) for p in curve_points(curve)],fill=255)
mask=np.asarray(im.resize((width,height),Image.Resampling.LANCZOS),dtype=np.float32)/255
inside=np.asarray(im)>127
distance=(distance_transform_edt(inside)-distance_transform_edt(~inside))/(scale*2)
distance=np.asarray(Image.fromarray(distance.astype(np.float32)).resize((width,height),Image.Resampling.BILINEAR))
dy,dx=np.gradient(gaussian_filter(distance,1.2*scale))
return distance,dx,dy,mask
def render(scale=1.,calibration=True,composition='b'):
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]:
options={k:v for k,v in spec.items() if k!='material'}
curve=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 flower has independent normalized spectral lighting and petal 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 petal 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')
args=ap.parse_args();args.output.parent.mkdir(parents=True,exist_ok=True)
render(args.scale,not args.no_calibration,args.composition).save(args.output)
print(args.output)
if __name__=='__main__':main()