renderer.py

At d794128

d794128History

Preview

Code · 6.6 KiB
#!/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 flower,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,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',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['shape'],**options) if 'shape' in spec else 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()

Files