renderer.py

Preview

Code · 5.5 KiB
#!/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()

Files