renderer.py
At d794128Copy the commit ID
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()