singe/util/mirrorballArt.py

212 lines
7.9 KiB
Python

# Draws the art for demos/mirrorball: every picture is made from shapes and arithmetic here, so it is
# ours and can be drawn again.
#
# The club is an equirectangular picture, the whole room round the camera: the 3D scene lights with
# it and every facet of the mirror ball reflects a piece of it, so it is mostly dark with bright
# coloured lights round the walls and along a truss overhead, which is what makes a mirror ball
# sparkle. The rest are sprites and textures for the oldschool effects.
#
# Usage: python3 util/mirrorballArt.py
import math
import os
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from scipy import ndimage
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(REPO, "demos", "mirrorball", "art")
SS = 4 # Sprites are drawn this many times larger and shrunk, for smooth edges
FONT_WIDE = "/usr/share/fonts/truetype/lato/Lato-BlackItalic.ttf"
GOLD_TOP = np.array([255, 250, 210])
GOLD_MID = np.array([200, 130, 30])
GOLD_LOW = np.array([255, 210, 120])
GOLD_DARK = np.array([90, 40, 10])
DISCO = [(255, 40, 80), (255, 160, 20), (255, 240, 60), (60, 255, 120), (40, 200, 255), (160, 60, 255)]
def save(image, name):
image.save(os.path.join(OUT, name))
print("%-16s %d x %d" % (name, image.width, image.height))
# The room: near-black walls, a band of coloured lamps round them at eye height and a second band
# higher up, a truss of white lamps across the ceiling, and a floor that faintly carries the colours.
def club():
w, h = 2048, 1024
lon = (np.arange(w) + 0.5) / w * 2 * math.pi - math.pi
lat = math.pi / 2 - (np.arange(h) + 0.5) / h * math.pi
LON, LAT = np.meshgrid(lon, lat)
img = np.zeros((h, w, 3))
img[:] = np.array([8, 6, 16])
img += (np.clip(LAT, 0, None) / (math.pi / 2))[..., None] * np.array([10, 6, 24])
def lamps(count, latDeg, size, bright, colours):
out = np.zeros_like(img)
for i in range(count):
c = np.array(colours[i % len(colours)])
lo = -math.pi + (i + 0.5) / count * 2 * math.pi
dl = np.angle(np.exp(1j * (LON - lo)))
d = np.sqrt((dl * np.cos(LAT)) ** 2 + (LAT - math.radians(latDeg)) ** 2) / math.radians(size)
out += (np.exp(-d * d * 3) * bright + np.exp(-d * 1.2) * 0.25)[..., None] * c
return out
img += lamps(24, 4, 2.2, 1.6, DISCO)
img += lamps(16, 28, 3.0, 1.2, DISCO[::-1])
img += lamps(12, 70, 2.0, 2.0, [(255, 255, 255)])
floor = LAT < -0.05
img[floor] = img[floor] * 0.35 + np.array([14, 8, 22])
return Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), "RGB")
# A floor tile: white with a bevelled edge and a dark seam, as both colour and emissive map, so the
# tile's own emissive colour lights it.
def tile(size=128):
y, x = np.mgrid[0:size, 0:size] + 0.5
d = np.minimum(np.minimum(x, size - x), np.minimum(y, size - y))
v = np.clip((d - 3) / 10, 0, 1) * 0.8 + 0.2 * (d > 3)
v *= 1 - 0.25 * ((x + y) / (2 * size))
img = np.zeros((size, size, 4))
img[..., :3] = (np.clip(v, 0, 1) * 255)[..., None]
img[..., 3] = 255
return Image.fromarray(img.astype(np.uint8), "RGBA")
def chromeText(text, size, pad=24):
f = ImageFont.truetype(FONT_WIDE, size * SS)
box = f.getbbox(text)
w = (box[2] - box[0]) // SS + pad * 2
h = (box[3] - box[1]) // SS + pad * 2
mask = Image.new("L", (w * SS, h * SS), 0)
ImageDraw.Draw(mask).text((pad * SS - box[0], pad * SS - box[1]), text, font=f, fill=255)
m = np.array(mask).astype(np.float64) / 255
rows = np.linspace(0, 1, h * SS)
top = (pad * SS) / (h * SS)
t = np.clip((rows - top) / (1 - 2 * top), 0, 1)[:, None]
upper = GOLD_TOP + (GOLD_MID - GOLD_TOP) * (t / 0.5)
lower = GOLD_DARK + (GOLD_LOW - GOLD_DARK) * ((t - 0.5) / 0.5)
grad = np.repeat(np.where(t < 0.5, upper, lower)[:, None, :], w * SS, axis=1)
outer = ndimage.maximum_filter(m, size=5 * SS)
ring = ndimage.maximum_filter(m, size=2 * SS)
halo = ndimage.gaussian_filter(outer, 7 * SS)
img = np.zeros((h * SS, w * SS, 4))
img[..., :3] = np.array([255, 160, 40])
img[..., 3] = np.clip(halo * 1.4, 0, 1) * 170
img[..., :3] = np.where(outer[..., None] > 0.5, [30, 10, 4], img[..., :3])
img[..., 3] = np.where(outer > 0.5, 255, img[..., 3])
img[..., :3] = np.where(ring[..., None] > 0.5, [255, 255, 230], img[..., :3])
img[..., :3] = np.where(m[..., None] > 0.5, grad, img[..., :3])
big = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), "RGBA")
return big.resize((w, h), Image.LANCZOS)
# The twister's four faces side by side, 64 pixels each: stripes, a checker, chevrons, and dots,
# each in its own disco colour, so a turning bar shows which face is which.
def twister():
fw, fh = 64, 64
img = np.zeros((fh, fw * 4, 4))
y, x = np.mgrid[0:fh, 0:fw] + 0.5
pats = [
((np.floor(y / 8) % 2) == 0),
((np.floor(x / 16) + np.floor(y / 16)) % 2 == 0),
((np.abs((x - 32)) + y) % 32 < 16),
(((x % 16 - 8) ** 2 + (y % 16 - 8) ** 2) < 30),
]
cols = [DISCO[0], DISCO[2], DISCO[4], DISCO[5]]
for i, (p, c) in enumerate(zip(pats, cols)):
face = np.where(p[..., None], np.array(c), np.array(c) * 0.35)
edge = (x < 2) | (x > fw - 2)
face[edge] = [255, 255, 255]
img[:, i * fw:(i + 1) * fw, :3] = face
img[..., 3] = 255
return Image.fromarray(img.astype(np.uint8), "RGBA")
# One copper bar: a vertical gradient, white in the middle to dark at its edges, tinted per bar.
def copper(h=32):
y = (np.arange(h) + 0.5) / h
v = np.sin(y * math.pi) ** 1.5
img = np.zeros((h, 4, 4))
img[..., :3] = (v * 255)[:, None, None]
img[..., 3] = 255
return Image.fromarray(img.astype(np.uint8), "RGBA")
# The rotozoomer's tile: a disco checker with a four-pointed star, colourful enough to read when it
# is turned and squeezed.
def rotoTile(size=128):
y, x = np.mgrid[0:size, 0:size] + 0.5
c = ((np.floor(x / 64) + np.floor(y / 64)) % 2)
img = np.zeros((size, size, 3))
img[:] = np.where(c[..., None] > 0, np.array([40, 10, 60]), np.array([255, 120, 30]))
cx = (x % 64) - 32
cy = (y % 64) - 32
star = (np.abs(cx) * np.abs(cy) < 30) & (np.abs(cx) + np.abs(cy) < 30)
img[star] = [255, 250, 200]
return Image.fromarray(img.astype(np.uint8), "RGB")
# The boing ball's skin: red and white in sixteen by eight, as an equirectangular texture.
def boing(w=256, h=128):
y, x = np.mgrid[0:h, 0:w]
c = ((x // (w // 16)) + (y // (h // 8))) % 2
img = np.where(c[..., None] > 0, np.array([230, 20, 30]), np.array([250, 250, 250]))
return Image.fromarray(img.astype(np.uint8), "RGB")
def bob(size=64):
y, x = np.mgrid[0:size, 0:size] + 0.5
r = size / 2 - 1
dx = (x - size / 2) / r
dy = (y - size / 2) / r
d2 = dx * dx + dy * dy
nz = np.sqrt(np.clip(1 - d2, 0, 1))
light = np.clip(dx * -0.4 + dy * -0.5 + nz * 0.77, 0, 1)
v = 0.18 + 0.7 * light + 0.6 * light ** 24
a = np.clip((1 - np.sqrt(d2)) * r, 0, 1) * (d2 <= 1)
img = np.zeros((size, size, 4))
img[..., :3] = np.clip(v, 0, 1)[..., None] * 255
img[..., 3] = a * 255
return Image.fromarray(img.astype(np.uint8), "RGBA")
def glow(size=128, power=2.2):
y, x = np.mgrid[0:size, 0:size] + 0.5
d = np.sqrt((x - size / 2) ** 2 + (y - size / 2) ** 2) / (size / 2)
img = np.zeros((size, size, 4))
img[..., :3] = 255
img[..., 3] = np.clip(1 - d, 0, 1) ** power * 255
return Image.fromarray(img.astype(np.uint8), "RGBA")
# A star-shaped sparkle, for the light the ball throws.
def sparkle(size=64):
y, x = np.mgrid[0:size, 0:size] + 0.5
dx = np.abs(x - size / 2) / (size / 2)
dy = np.abs(y - size / 2) / (size / 2)
v = np.exp(-dx * 14) * np.exp(-dy * 2.5) + np.exp(-dy * 14) * np.exp(-dx * 2.5) + np.exp(-(dx * dx + dy * dy) * 18)
img = np.zeros((size, size, 4))
img[..., :3] = 255
img[..., 3] = np.clip(v, 0, 1) * 255
return Image.fromarray(img.astype(np.uint8), "RGBA")
def main():
os.makedirs(OUT, exist_ok=True)
save(club(), "club.png")
save(tile(), "tile.png")
save(chromeText("MIRRORBALL", 96), "logo.png")
save(chromeText("SINGE", 120), "singe.png")
save(twister(), "twister.png")
save(copper(), "copper.png")
save(rotoTile(), "roto.png")
save(boing(), "boing.png")
save(bob(), "bob.png")
save(glow(), "glow.png")
save(sparkle(), "sparkle.png")
if __name__ == "__main__":
main()