81 lines
5.4 KiB
Python
81 lines
5.4 KiB
Python
"""Compose the release art from native Blender renders and original typography."""
|
|
from pathlib import Path
|
|
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageChops, ImageOps
|
|
import numpy as np, math, json, argparse
|
|
|
|
work=Path(__file__).parent
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--output-dir',type=Path,default=work.parent/'outputs/FunctionalTurrets-release-art')
|
|
out=parser.parse_args().output_dir
|
|
W=2048;coral=(241,100,101);cream=(232,226,209);rng=np.random.default_rng(271026)
|
|
impact=Path(r'C:\Windows\Fonts\impact.ttf');mono=Path(r'C:\Windows\Fonts\courbd.ttf')
|
|
font=lambda n:ImageFont.truetype(str(impact),n)
|
|
small=lambda n:ImageFont.truetype(str(mono),n)
|
|
def fit(text,maxwidth,maxsize,fontpath=impact):
|
|
for n in range(maxsize,10,-1):
|
|
f=ImageFont.truetype(str(fontpath),n)
|
|
if f.getlength(text)<=maxwidth:return f
|
|
def cutout(path):
|
|
im=Image.open(path).convert('RGBA');return im.crop(im.getchannel('A').getbbox())
|
|
def arrows(layer,box,color,width):
|
|
d=ImageDraw.Draw(layer);cx=(box[0]+box[2])/2;cy=(box[1]+box[3])/2;rx=(box[2]-box[0])/2;ry=(box[3]-box[1])/2
|
|
for start,end in [(12,155),(195,338)]:
|
|
d.arc(box,start,end,fill=color,width=width)
|
|
a=math.radians(end);x=cx+rx*math.cos(a);y=cy+ry*math.sin(a)
|
|
tangent=np.array([-rx*math.sin(a),ry*math.cos(a)]);tangent=tangent/np.linalg.norm(tangent)
|
|
side=np.array([-tangent[1],tangent[0]])
|
|
tip=np.array([x,y])+tangent*width*1.6;base=np.array([x,y])-tangent*width*1.9
|
|
d.polygon([tuple(tip),tuple(base+side*width*1.55),tuple(base-side*width*1.55)],fill=color)
|
|
def distressed_text(canvas,position,text,typeface,color):
|
|
mask=Image.new('L',canvas.size);d=ImageDraw.Draw(mask);d.text(position,text,font=typeface,fill=255,anchor='lt')
|
|
a=np.asarray(mask).copy();noise=rng.random(a.shape)
|
|
a[(noise<.025)&(a>0)]=30
|
|
# Thin uneven scratches cut through the lettering without sacrificing shape.
|
|
scratches=Image.new('L',canvas.size);sd=ImageDraw.Draw(scratches)
|
|
for _ in range(40):
|
|
x=int(rng.integers(70,W-70));y=int(rng.integers(170,650));sd.line((x,y,x+int(rng.integers(5,100)),y+int(rng.integers(-3,4))),fill=150,width=int(rng.integers(1,3)))
|
|
mask=ImageChops.subtract(Image.fromarray(a),scratches)
|
|
canvas.alpha_composite(Image.composite(Image.new('RGBA',canvas.size,(*color,255)),Image.new('RGBA',canvas.size),mask))
|
|
|
|
# Weathered dark olive paper, with a restrained warm vignette.
|
|
yy,xx=np.mgrid[0:W,0:W];rad=((xx/W-.5)**2+(yy/W-.5)**2)
|
|
grain=rng.normal(0,2.3,(W,W));coarse=Image.fromarray(rng.integers(0,255,(64,64),dtype=np.uint8)).resize((W,W),Image.Resampling.BICUBIC)
|
|
tone=(np.asarray(coarse).astype(float)-127)/35
|
|
background=np.stack([22+grain+tone-10*rad,27+grain+tone-12*rad,24+grain+tone-11*rad],axis=2)
|
|
poster=Image.fromarray(np.clip(background,0,255).astype('uint8')).convert('RGBA')
|
|
texture=Image.new('RGBA',poster.size);td=ImageDraw.Draw(texture)
|
|
for x in range(82,W,128):td.line((x,725,x,1770),fill=(190,190,160,9),width=1)
|
|
for y in range(770,1790,128):td.line((85,y,1960,y),fill=(190,190,160,9),width=1)
|
|
for _ in range(180):
|
|
x,y=rng.integers(15,W-15,2);td.line((int(x),int(y),int(x+rng.integers(-2,3)),int(y+rng.integers(2,55))),fill=(150,145,120,int(rng.integers(5,20))),width=1)
|
|
td.rectangle((36,36,2011,2011),outline=(213,209,185,40),width=2)
|
|
poster.alpha_composite(texture)
|
|
distressed_text(poster,(88,175),'FUNCTIONAL',fit('FUNCTIONAL',1872,262),cream)
|
|
distressed_text(poster,(82,384),'TURRETS',fit('TURRETS',1884,377),cream)
|
|
|
|
ferret=cutout(out/'ferret-turret-render.png');lav=cutout(out/'lav-turret-render.png')
|
|
ferret=ImageOps.contain(ferret,(840,890),Image.Resampling.LANCZOS)
|
|
lav=ImageOps.contain(lav,(1260,1020),Image.Resampling.LANCZOS)
|
|
def place(im,x,y):
|
|
shadow=Image.new('RGBA',poster.size);a=im.getchannel('A').filter(ImageFilter.GaussianBlur(9))
|
|
shade=Image.new('RGBA',im.size,(0,0,0,0));shade.putalpha(a.point(lambda p:int(p*.65)))
|
|
shadow.alpha_composite(shade,(x+14,y+24));poster.alpha_composite(shadow);poster.alpha_composite(im,(x,y))
|
|
place(ferret,82,822)
|
|
place(lav,1945-lav.width,775)
|
|
poster.convert('RGB').save(out/'preview-master.png')
|
|
poster.convert('RGB').resize((1024,1024),Image.Resampling.LANCZOS).save(out/'preview.png')
|
|
|
|
# Shared icon: transparent model silhouette and rotation arrows, no tiny text.
|
|
icon=Image.new('RGBA',(1024,1024));ring=Image.new('RGBA',icon.size)
|
|
arrows(ring,(90,637,930,960),(*coral,240),24);icon.alpha_composite(ring)
|
|
model=ImageOps.contain(cutout(out/'lav-turret-render.png'),(880,865),Image.Resampling.LANCZOS)
|
|
a=model.getchannel('A');outline=ImageChops.subtract(a.filter(ImageFilter.MaxFilter(9)),a)
|
|
outline_layer=Image.new('RGBA',model.size,(*cream,255));outline_layer.putalpha(outline.point(lambda p:int(p*.78)))
|
|
x=(1024-model.width)//2;y=32
|
|
icon.alpha_composite(outline_layer,(x,y));icon.alpha_composite(model,(x,y))
|
|
icon.save(out/'icon-master.png');icon.resize((512,512),Image.Resampling.LANCZOS).save(out/'icon.png')
|
|
icon.resize((128,128),Image.Resampling.LANCZOS).save(out/'icon-128.png')
|
|
poster.convert('RGB').resize((256,256),Image.Resampling.LANCZOS).save(out/'preview-256.png')
|
|
(out/'artwork.json').write_text(json.dumps({'style':'Original distressed survival-poster treatment; actual game-model renders','pink':'#F16465','preview_master':[2048,2048],'preview':[1024,1024],'icon_master':[1024,1024],'icon':[512,512],'icon_transparent':True,'no_image_generation':True},indent=2))
|
|
print('Composed release preview and transparent icon:',out)
|