#!/usr/bin/env python3 """ Render the contact-sheet frames for hipa. Each frame is composed as a scene (geometry + texture), then put through a film pipeline: lens softness, vignette, chromatic aberration, halation out of the highlights, dye-fade colour science for its era, grain that peaks in the midtones, and physical dust. Nothing here is a flat gradient. Output: /img/*.jpg """ import os import numpy as np from PIL import Image OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "img") SS = 2 # supersample factor, downsampled before grain # ----------------------------------------------------------------- utilities def _box(a, r, axis): """Box blur along one axis, float, edge-padded.""" if r < 1: return a k = 2 * r + 1 pad = [(0, 0)] * a.ndim pad[axis] = (r + 1, r) ap = np.pad(a, pad, mode="edge") c = np.cumsum(ap, axis=axis, dtype=np.float32) sl_hi = [slice(None)] * a.ndim sl_lo = [slice(None)] * a.ndim sl_hi[axis] = slice(k, None) sl_lo[axis] = slice(None, -k) return (c[tuple(sl_hi)] - c[tuple(sl_lo)]) / k def gblur(a, r, passes=3): """Three box passes approximate a gaussian. Works on 2D or (h,w,3).""" if r <= 0: return a br = max(1, int(round(r / 1.6))) for _ in range(passes): a = _box(a, br, 0) a = _box(a, br, 1) return a def fnoise(h, w, octaves=6, persistence=0.55, seed=0, aspect=True): """Fractal value noise, built from upsampled random grids.""" rng = np.random.default_rng(seed) out = np.zeros((h, w), np.float32) amp, tot = 1.0, 0.0 for o in range(octaves): gh = 2 ** (o + 1) gw = max(2, int(gh * w / h)) if aspect else gh g = rng.random((gh, gw)).astype(np.float32) g = np.asarray( Image.fromarray((g * 255).astype(np.uint8), "L").resize((w, h), Image.BICUBIC), np.float32, ) / 255.0 out += g * amp tot += amp amp *= persistence return out / tot def grid(h, w): y, x = np.mgrid[0:h, 0:w].astype(np.float32) return x / w, y / h def radial(h, w, cx, cy, rx, ry, power=2.0): x, y = grid(h, w) d = ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 return np.clip(1.0 - d, 0.0, 1.0) ** power def figure(h, w, cx, cy, scale, lean=0.0): """A soft human-ish mass: head over shoulders widening downward.""" x, y = grid(h, w) X = (x - cx) / scale + lean * (y - cy) / scale Y = (y - cy) / scale head = np.clip(1 - ((X / 0.20) ** 2 + ((Y + 0.58) / 0.24) ** 2), 0, 1) shoulder = np.clip(0.30 + 0.30 * np.clip(Y + 0.3, 0, 1.4), 0.30, 0.62) body = np.clip(1 - ((X / shoulder) ** 2 + ((Y - 0.34) / 0.78) ** 2), 0, 1) return np.maximum(head ** 0.6, body ** 0.6) def over(base, mask, colour, amount=1.0): """Composite a flat colour through a mask.""" m = (mask * amount)[..., None] return base * (1 - m) + np.asarray(colour, np.float32) * m def band(h, w, y0, y1, soft=0.01): _, y = grid(h, w) a = np.clip((y - y0) / max(soft, 1e-5), 0, 1) b = np.clip((y1 - y) / max(soft, 1e-5), 0, 1) return np.minimum(a, b) # ------------------------------------------------------------ film pipeline def linearize(img): return np.clip(img, 0, None) ** 2.2 def halation(img, threshold=0.70, radius=26, amount=0.32, tint=(1.0, 0.52, 0.26)): lum = linearize(img).mean(2) hi = np.clip((lum - threshold) / max(1e-5, 1 - threshold), 0, 1) glow = gblur(hi, radius) return img + glow[..., None] * np.asarray(tint, np.float32) * amount def vignette(img, amount=0.40, power=1.6): h, w, _ = img.shape x, y = grid(h, w) r = np.sqrt(((x - 0.5) * 1.06) ** 2 + ((y - 0.5) * 1.0) ** 2) / 0.72 v = 1.0 - amount * np.clip(r, 0, 1) ** power return img * v[..., None] def chroma_ab(img, amount=0.0016): """Scale R up and B down about the centre — lateral colour fringing.""" if amount <= 0: return img h, w, _ = img.shape out = img.copy() for ch, s in ((0, 1.0 + amount), (2, 1.0 - amount)): nw, nh = max(2, int(round(w * s))), max(2, int(round(h * s))) im = Image.fromarray((np.clip(img[..., ch], 0, 1) * 255).astype(np.uint8), "L") im = im.resize((nw, nh), Image.BICUBIC) a = np.asarray(im, np.float32) / 255.0 if s >= 1.0: oy, ox = (nh - h) // 2, (nw - w) // 2 out[..., ch] = a[oy:oy + h, ox:ox + w] else: pad = np.zeros((h, w), np.float32) oy, ox = (h - nh) // 2, (w - nw) // 2 pad[oy:oy + nh, ox:ox + nw] = a pad[:oy, :] = pad[oy:oy + 1, :] pad[oy + nh:, :] = pad[oy + nh - 1:oy + nh, :] out[..., ch] = pad return out def dye(img, matrix, lift, gain, gamma, sat): """Channel crosstalk + per-channel lift/gain/gamma, then desaturate.""" m = np.asarray(matrix, np.float32) img = np.einsum("ij,hwj->hwi", m, img) img = np.clip(img, 0, None) img = img ** np.asarray(gamma, np.float32) img = img * np.asarray(gain, np.float32) + np.asarray(lift, np.float32) lum = img @ np.array([0.2126, 0.7152, 0.0722], np.float32) return lum[..., None] + (img - lum[..., None]) * sat def scurve(img, strength=0.35, pivot=0.45): x = np.clip(img, 0, 1.6) s = np.clip((x - pivot) / max(1e-5, 1 - pivot) * 0.5 + 0.5, 0, 1) smooth = s * s * (3 - 2 * s) curved = (smooth - 0.5) * 2 * (1 - pivot) + pivot return x * (1 - strength) + curved * strength def grain(img, amount=0.030, size=0.62, seed=1, mono=0.65): h, w, _ = img.shape rng = np.random.default_rng(seed) n_mono = rng.normal(0, 1, (h, w)).astype(np.float32) n_rgb = rng.normal(0, 1, (h, w, 3)).astype(np.float32) n = n_mono[..., None] * mono + n_rgb * (1 - mono) if size > 0: n = gblur(n, size, passes=2) n /= max(1e-5, n.std()) lum = np.clip(img.mean(2), 0, 1) # silver grain is loudest in the midtones, quiet in blacks and blown areas weight = 1.0 - (2.0 * lum - 1.0) ** 2 return img + n * (amount * weight[..., None]) def dust(img, specks=90, hairs=2, scratches=1, seed=3): h, w, _ = img.shape rng = np.random.default_rng(seed) out = img.copy() for _ in range(specks): cy, cx = rng.integers(0, h), rng.integers(0, w) r = int(rng.integers(1, max(2, h // 260))) bright = rng.random() < 0.62 y0, y1 = max(0, cy - r), min(h, cy + r + 1) x0, x1 = max(0, cx - r), min(w, cx + r + 1) v = rng.uniform(0.10, 0.34) out[y0:y1, x0:x1] += v if bright else -v for _ in range(hairs): y = rng.integers(int(h * 0.1), int(h * 0.9)) x = rng.integers(0, int(w * 0.7)) ln = int(rng.integers(w // 12, w // 4)) drift = rng.uniform(-0.35, 0.35) for i in range(ln): yy = int(y + np.sin(i / 22.0) * 5 + drift * i * 0.1) xx = x + i if 0 <= yy < h and 0 <= xx < w: out[yy, xx] -= 0.18 for _ in range(scratches): x = rng.integers(int(w * 0.15), int(w * 0.85)) out[:, max(0, x - 1):x + 1] += rng.uniform(0.05, 0.12) return out def scanlines(img, strength=0.14, chroma_bleed=16, jitter=0.6, seed=5): """Interlaced-tape artefacts: line structure, smeared chroma, line jitter.""" h, w, _ = img.shape rng = np.random.default_rng(seed) lum = img @ np.array([0.2126, 0.7152, 0.0722], np.float32) chroma = img - lum[..., None] chroma = _box(chroma, max(1, chroma_bleed), 1) chroma = _box(chroma, max(1, chroma_bleed // 2), 1) img = lum[..., None] + chroma rows = np.arange(h) mod = 1.0 - strength * (rows % 2) img = img * mod[:, None, None] if jitter > 0: shifts = np.round(gblur(rng.normal(0, 1, (h, 4)), 3)[:, 0] * jitter).astype(int) for y in range(h): s = int(shifts[y]) if s: img[y] = np.roll(img[y], s, axis=0) return img def save(img, name, quality=82): os.makedirs(OUT, exist_ok=True) a = (np.clip(img, 0, 1) ** (1 / 1.0) * 255.0 + 0.5).astype(np.uint8) path = os.path.join(OUT, name) Image.fromarray(a, "RGB").save(path, "JPEG", quality=quality, optimize=True, progressive=True, subsampling=1) return path, os.path.getsize(path) def downsample(img, w, h): a = (np.clip(img, 0, 1) * 255.0 + 0.5).astype(np.uint8) im = Image.fromarray(a, "RGB").resize((w, h), Image.LANCZOS) return np.asarray(im, np.float32) / 255.0 # ---------------------------------------------------------------- the scenes # Subjects are places and things. People appear only as deep-distance specks # or as silhouettes the frame edge cuts through — a rendered human at any # size reads as a chess pawn, and a chess pawn is not a memory. def rect(h, w, x0, x1, y0, y1, soft=0.005): x, y = grid(h, w) return (np.clip((x - x0) / soft, 0, 1) * np.clip((x1 - x) / soft, 0, 1) * np.clip((y - y0) / soft, 0, 1) * np.clip((y1 - y) / soft, 0, 1)) def sag_line(h, w, x0, y0, x1, y1, sag=0.05, thick=0.004): x, y = grid(h, w) t = np.clip((x - x0) / max(1e-5, x1 - x0), 0, 1) yl = y0 + (y1 - y0) * t + sag * np.sin(np.pi * t) inside = ((x >= x0) & (x <= x1)).astype(np.float32) return inside * np.clip(1 - np.abs(y - yl) / thick, 0, 1) def dome(h, w, cx, cy, r, squash=1.25): return radial(h, w, cx, cy, r, r * squash, power=1.4) def scene_firstroll(h, w): """1974. Washing on the line, middle of the afternoon, sun in the lens.""" x, y = grid(h, w) img = np.zeros((h, w, 3), np.float32) img = over(img, np.ones((h, w), np.float32), (0.66, 0.68, 0.62)) img = over(img, np.clip((0.62 - y) / 0.62, 0, 1) ** 0.9, (0.80, 0.82, 0.78), 0.9) wall = fnoise(h, w, octaves=6, seed=101) img += ((wall - 0.5) * np.clip((0.70 - y) / 0.70, 0, 1))[..., None] * 0.07 img = over(img, sag_line(h, w, 0.0, 0.283, 1.0, 0.268, sag=0.018, thick=0.006), (0.22, 0.20, 0.17), 0.75) sheets = ((0.075, 0.225, 0.655, 0.96), (0.265, 0.395, 0.585, 0.90), (0.435, 0.585, 0.700, 0.99), (0.625, 0.735, 0.560, 0.92), (0.780, 0.930, 0.630, 0.94)) folds = fnoise(h, w, octaves=7, seed=103) for x0, x1, y1, shade in sheets: hem = 0.012 * np.sin((x - x0) / max(1e-5, x1 - x0) * 9.0) m = rect(h, w, x0, x1, 0.285, y1, 0.004) * np.clip((y1 + hem - y) / 0.010, 0, 1) img = over(img, m, (shade, shade * 0.985, shade * 0.94), 0.97) img -= (m * np.clip(folds - 0.52, 0, 1))[..., None] * 0.42 hedge = np.clip((y - 0.745) / 0.05, 0, 1) leaf = fnoise(h, w, octaves=8, seed=107) img = over(img, hedge, (0.24, 0.27, 0.16), 0.94) img += (hedge * (leaf - 0.5))[..., None] * np.array([0.20, 0.26, 0.12], np.float32) sun = radial(h, w, 0.885, 0.075, 0.42, 0.48, power=2.5) img += sun[..., None] * np.array([1.0, 0.93, 0.72], np.float32) * 0.95 return img def scene_reel8mm(h, w): """1981. Super 8: the window in the front room, nets drawn, a plant on the sill.""" x, y = grid(h, w) img = np.zeros((h, w, 3), np.float32) img = over(img, np.ones((h, w), np.float32), (0.20, 0.14, 0.08)) win = rect(h, w, 0.205, 0.795, 0.095, 0.615, 0.006) img = over(img, win, (0.90, 0.88, 0.78), 1.0) nets = fnoise(h, w, octaves=4, seed=113) weave = 0.5 + 0.5 * np.sin(x * 260.0) img -= (win * (0.35 * weave + 0.30 * nets))[..., None] * 0.30 img += gblur(win, 24 * SS)[..., None] * np.array([1.0, 0.84, 0.52], np.float32) * 0.95 frame_bar = (rect(h, w, 0.487, 0.513, 0.095, 0.615, 0.003) + rect(h, w, 0.205, 0.795, 0.335, 0.358, 0.003)) img = over(img, np.clip(frame_bar, 0, 1), (0.34, 0.24, 0.13), 0.85) sill = rect(h, w, 0.155, 0.845, 0.615, 0.700, 0.006) img = over(img, sill, (0.44, 0.32, 0.17), 0.92) rng = np.random.default_rng(117) plant = np.zeros((h, w), np.float32) for _ in range(22): a = rng.uniform(0, 6.283) r = rng.uniform(0.0, 0.085) plant = np.maximum(plant, radial(h, w, 0.735 + np.cos(a) * r, 0.520 + np.sin(a) * r * 0.85, rng.uniform(0.020, 0.040), rng.uniform(0.012, 0.024), power=1.3)) img = over(img, gblur(plant, 1.6 * SS), (0.16, 0.19, 0.10), 0.90) img = over(img, rect(h, w, 0.690, 0.780, 0.600, 0.660, 0.006), (0.36, 0.22, 0.14), 0.85) return img def scene_shore(h, w): """1989. The lake at Issyk: sky, water, wet sand, footprints going in.""" x, y = grid(h, w) img = np.zeros((h, w, 3), np.float32) HOR = 0.400 img = over(img, np.ones((h, w), np.float32), (0.60, 0.70, 0.76)) img = over(img, np.clip((HOR - y) / HOR, 0, 1) ** 1.4, (0.79, 0.85, 0.88), 0.80) far = np.clip((HOR - y) / 0.030, 0, 1) * np.clip((y - 0.330) / 0.030, 0, 1) img = over(img, far, (0.50, 0.54, 0.55), 0.60) water = band(h, w, HOR, 0.655, soft=0.004) deep = np.clip((0.655 - y) / 0.255, 0, 1) img = over(img, water, (0.34, 0.46, 0.50), 0.95) img += (water * (1 - deep))[..., None] * np.array([0.14, 0.15, 0.13], np.float32) streak = _box(fnoise(h, w, octaves=8, seed=41), max(1, w // 70), 1) img += (water * np.clip(streak - 0.58, 0, 1) * 1.3)[..., None] * \ np.array([0.90, 0.93, 0.90], np.float32) surf = band(h, w, 0.628, 0.668, soft=0.012) img += (surf * (0.4 + 0.6 * fnoise(h, w, octaves=7, seed=43)))[..., None] * \ np.array([0.34, 0.35, 0.33], np.float32) sand = np.clip((y - 0.655) / 0.345, 0, 1) img = over(img, sand, (0.70, 0.62, 0.45), 0.95) wet = band(h, w, 0.655, 0.735, soft=0.030) img -= wet[..., None] * np.array([0.13, 0.12, 0.08], np.float32) img += (sand * (fnoise(h, w, octaves=8, seed=47) - 0.5))[..., None] * 0.15 prints = np.zeros((h, w), np.float32) for i in range(8): t = i / 7.0 px = 0.315 + t * 0.185 + (0.026 if i % 2 else -0.026) py = 0.985 - t * 0.290 sc = 0.032 - t * 0.017 prints = np.maximum(prints, radial(h, w, px, py, sc, sc * 0.52, power=1.2)) img -= gblur(prints, 1.3 * SS)[..., None] * np.array([0.20, 0.17, 0.11], np.float32) for sx, sy, sr in ((0.245, 0.418, 0.007), (0.320, 0.410, 0.005), (0.735, 0.414, 0.006)): img = over(img, radial(h, w, sx, sy, sr, sr * 1.8, power=1.1), (0.22, 0.20, 0.19), 0.75) sun = radial(h, w, 0.855, 0.105, 0.32, 0.38, power=2.8) img += sun[..., None] * np.array([1.0, 0.96, 0.80], np.float32) * 0.88 return img def scene_kitchen(h, w): """1996. The long table laid, one window doing all the work.""" x, y = grid(h, w) img = np.zeros((h, w, 3), np.float32) img = over(img, np.ones((h, w), np.float32), (0.15, 0.095, 0.055)) win = rect(h, w, 0.055, 0.245, 0.115, 0.575, 0.008) img = over(img, win, (0.93, 0.94, 0.89), 1.0) img += gblur(win, 20 * SS)[..., None] * np.array([1.0, 0.86, 0.60], np.float32) * 1.25 lamp = radial(h, w, 0.74, 0.13, 0.20, 0.22, power=2.0) img += lamp[..., None] * np.array([1.0, 0.72, 0.36], np.float32) * 0.72 tw = 0.16 + 0.62 * np.clip((y - 0.575) / 0.425, 0, 1) top = ((np.abs(x - 0.52) < tw) * np.clip((y - 0.575) / 0.015, 0, 1)).astype(np.float32) img = over(img, gblur(top, 1.6 * SS), (0.58, 0.42, 0.25), 0.93) img += (top * (fnoise(h, w, octaves=7, seed=53) - 0.5))[..., None] * 0.10 # chair backs, cut off by the frame for cx0, cx1 in ((0.02, 0.135), (0.865, 0.985)): img = over(img, rect(h, w, cx0, cx1, 0.60, 1.02, 0.008), (0.20, 0.13, 0.07), 0.88) settings = np.zeros((h, w), np.float32) for px, py, pr in ((0.395, 0.700, 0.042), (0.610, 0.715, 0.040), (0.330, 0.860, 0.055), (0.700, 0.880, 0.052)): settings += radial(h, w, px, py, pr, pr * 0.42, power=1.3) img += np.clip(settings, 0, 1)[..., None] * np.array([1.0, 0.92, 0.76], np.float32) * 0.50 for gx, gy in ((0.455, 0.660), (0.560, 0.672)): g = radial(h, w, gx, gy, 0.016, 0.038, power=1.2) img += g[..., None] * np.array([1.0, 0.86, 0.62], np.float32) * 0.60 return img def scene_bouquet(h, w): """1998. Marta and Ilya's day, photographed as the flowers on the table.""" x, y = grid(h, w) img = np.zeros((h, w, 3), np.float32) img = over(img, np.ones((h, w), np.float32), (0.235, 0.205, 0.190)) img += radial(h, w, 0.20, 0.16, 0.60, 0.55, power=1.6)[..., None] * \ np.array([0.62, 0.58, 0.52], np.float32) * 0.42 img += radial(h, w, 0.86, 0.86, 0.50, 0.45, power=1.8)[..., None] * \ np.array([0.40, 0.34, 0.28], np.float32) * 0.28 stem = np.clip(1 - np.abs((x - 0.505) - (y - 0.66) * 0.10) / 0.032, 0, 1) * \ np.clip((y - 0.670) / 0.02, 0, 1) img = over(img, gblur(stem, 2.0 * SS), (0.24, 0.22, 0.17), 0.80) rng = np.random.default_rng(91) spots = [] for _ in range(15): a, r = rng.uniform(0, 6.283), rng.uniform(0.0, 0.175) spots.append((0.500 + np.cos(a) * r * 1.30, 0.420 + np.sin(a) * r * 0.94, rng.uniform(0.048, 0.078))) spots.sort(key=lambda s: s[1]) for cx, cy, rr in spots: shade = radial(h, w, cx, cy + 0.010, rr * 1.30, rr * 1.15, power=1.2) img = over(img, gblur(shade, 2.2 * SS), (0.26, 0.22, 0.20), 0.60) core = radial(h, w, cx, cy, rr, rr * 0.86, power=1.35) img = over(img, gblur(core, 1.1 * SS), (0.93, 0.90, 0.85), 0.96) img -= gblur(core * (fnoise(h, w, octaves=7, seed=int(cx * 9991) % 999) - 0.5), 1.0 * SS)[..., None] * 0.26 eye = radial(h, w, cx, cy, rr * 0.30, rr * 0.26, power=1.4) img = over(img, gblur(eye, 1.0 * SS), (0.74, 0.60, 0.34), 0.72) leaves = np.zeros((h, w), np.float32) for _ in range(10): a, r = rng.uniform(0, 6.283), rng.uniform(0.155, 0.265) leaves = np.maximum(leaves, radial(h, w, 0.50 + np.cos(a) * r * 1.30, 0.43 + np.sin(a) * r * 0.94, rng.uniform(0.028, 0.052), rng.uniform(0.013, 0.024), power=1.3)) img = over(img, gblur(leaves, 2.2 * SS), (0.30, 0.34, 0.26), 0.55) return img def scene_camcorder(h, w): """2003. The school hall from the back row: a lit stage, heads in the way.""" x, y = grid(h, w) img = np.zeros((h, w, 3), np.float32) img = over(img, np.ones((h, w), np.float32), (0.055, 0.060, 0.082)) stage = gblur(rect(h, w, 0.215, 0.785, 0.230, 0.665, 0.050), 3.0 * SS) img = over(img, np.clip(stage, 0, 1), (0.52, 0.525, 0.495), 1.0) img += gblur(stage, 18 * SS)[..., None] * \ np.array([0.80, 0.79, 0.72], np.float32) * 0.34 img -= (stage * (0.5 + 0.5 * np.sin(x * 78.0)))[..., None] * 0.06 img += radial(h, w, 0.50, 0.375, 0.24, 0.20, power=1.8)[..., None] * \ np.array([1.0, 0.95, 0.80], np.float32) * 0.26 # dark marks on a bright stage: the contrast does the work rng = np.random.default_rng(211) for cx in (0.330, 0.408, 0.487, 0.566, 0.648): hh, wd = rng.uniform(0.064, 0.100), rng.uniform(0.012, 0.020) base = 0.616 + rng.uniform(-0.008, 0.008) img = over(img, gblur(rect(h, w, cx - wd, cx + wd, base - hh, base, 0.008), 1.5 * SS), (0.15, 0.13, 0.13), 0.80) img = over(img, gblur(radial(h, w, cx, base - hh, wd * 0.8, wd * 0.95, 1.3), 1.3 * SS), (0.20, 0.16, 0.15), 0.72) # the floor between camera and stage catches enough light to silhouette against img += band(h, w, 0.760, 1.02, soft=0.14)[..., None] * \ np.array([0.150, 0.155, 0.185], np.float32) heads = np.zeros((h, w), np.float32) for cx, rr in ((0.070, 0.155), (0.300, 0.128), (0.535, 0.168), (0.775, 0.136), (0.968, 0.130)): heads = np.maximum(heads, dome(h, w, cx, 1.020, rr)) heads = gblur(heads, 2.4 * SS) img = over(img, heads, (0.018, 0.020, 0.030), 0.99) crown = np.clip(heads - gblur(heads, 6.0 * SS), 0, 1) img += crown[..., None] * np.array([0.80, 0.80, 0.76], np.float32) * 0.38 return img def scene_candles(h, w): """2011. Nine candles, and the room going as dark as the camera can make it.""" x, y = grid(h, w) img = np.zeros((h, w, 3), np.float32) img = over(img, np.ones((h, w), np.float32), (0.050, 0.036, 0.030)) flames = np.zeros((h, w), np.float32) glow = np.zeros((h, w), np.float32) for i in range(9): t = (i - 4) / 4.0 cx = 0.50 + t * 0.205 cy = 0.575 + (t ** 2) * 0.048 flames = np.maximum(flames, radial(h, w, cx, cy, 0.0115, 0.027, power=1.5)) glow += radial(h, w, cx, cy, 0.105, 0.115, power=2.0) img = over(img, rect(h, w, cx - 0.006, cx + 0.006, cy + 0.020, cy + 0.075, 0.004), (0.62, 0.55, 0.44), 0.55) img += flames[..., None] * np.array([1.0, 0.90, 0.62], np.float32) * 1.55 img += gblur(glow, 10 * SS)[..., None] * np.array([1.0, 0.62, 0.26], np.float32) * 0.44 cake = band(h, w, 0.655, 0.815, soft=0.012) * np.clip(1 - np.abs(x - 0.5) / 0.335, 0, 1) img = over(img, gblur(cake, 2.0 * SS), (0.56, 0.47, 0.38), 0.60) icing = band(h, w, 0.655, 0.685, soft=0.008) * np.clip(1 - np.abs(x - 0.5) / 0.335, 0, 1) img += icing[..., None] * np.array([1.0, 0.72, 0.40], np.float32) * 0.22 return img def scene_snow(h, w): """2019. Chimbulak: the slope, the lift, and nobody close to the camera.""" x, y = grid(h, w) img = np.zeros((h, w, 3), np.float32) img = over(img, np.ones((h, w), np.float32), (0.60, 0.69, 0.79)) img = over(img, np.clip((0.40 - y) / 0.40, 0, 1) ** 1.1, (0.44, 0.56, 0.72), 0.55) prof = _box(fnoise(h, w, octaves=5, seed=61), max(1, w // 26), 1)[0:1, :] ridge_y = 0.330 + 0.105 * prof.repeat(h, 0) peaks = np.clip((ridge_y - y) / 0.005, 0, 1) * np.clip((y - 0.100) / 0.045, 0, 1) img = over(img, peaks, (0.40, 0.46, 0.57), 0.90) rock = fnoise(h, w, octaves=8, seed=67) img -= (peaks * np.clip(rock - 0.44, 0, 1))[..., None] * 0.52 img += (peaks * np.clip(0.40 - rock, 0, 1))[..., None] * \ np.array([0.34, 0.36, 0.38], np.float32) slope = np.clip((y - ridge_y) / 0.085, 0, 1) img = over(img, slope, (0.86, 0.90, 0.945), 0.94) img -= (slope * np.clip(0.5 - fnoise(h, w, octaves=8, seed=71), 0, 1))[..., None] * \ np.array([0.34, 0.26, 0.14], np.float32) img -= (slope * np.clip((y - 0.60) / 0.40, 0, 1))[..., None] * \ np.array([0.10, 0.07, 0.02], np.float32) for off, wd, dp in ((0.0, 0.050, 0.16), (0.120, 0.032, 0.12), (-0.145, 0.026, 0.10)): tr = np.clip(1 - np.abs((x - 0.46 - off) - (y - 0.5) * 0.42) / wd, 0, 1) * slope img -= gblur(tr, 3 * SS)[..., None] * np.array([dp, dp * 0.72, dp * 0.30], np.float32) cable = sag_line(h, w, 0.0, 0.300, 1.0, 0.210, sag=0.055, thick=0.0035) img = over(img, cable, (0.22, 0.25, 0.31), 0.85) for px, py in ((0.185, 0.340), (0.615, 0.286)): img = over(img, rect(h, w, px - 0.006, px + 0.006, py, py + 0.250, 0.004), (0.19, 0.22, 0.28), 0.90) img = over(img, rect(h, w, px - 0.040, px + 0.040, py, py + 0.014, 0.004), (0.19, 0.22, 0.28), 0.90) for cx, cy in ((0.075, 0.316), (0.355, 0.276), (0.520, 0.264), (0.845, 0.254)): img = over(img, rect(h, w, cx - 0.012, cx + 0.012, cy, cy + 0.032, 0.004), (0.14, 0.16, 0.21), 0.92) for sx, sy, sc, col in ((0.330, 0.760, 0.019, (0.58, 0.16, 0.13)), (0.402, 0.700, 0.015, (0.16, 0.20, 0.34))): img -= gblur(radial(h, w, sx + 0.012, sy + 0.016, sc * 1.5, sc * 0.7, 1.2), 2.0 * SS)[..., None] * np.array([0.16, 0.13, 0.06], np.float32) img = over(img, gblur(radial(h, w, sx, sy, sc * 0.62, sc * 1.45, power=1.3), 1.0 * SS), col, 0.90) return img # ------------------------------------------------------------------- recipes IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] FRAMES = [ dict(name="01-first-roll.jpg", scene=scene_firstroll, size=(720, 480), soft=1.5, vig=0.44, ca=0.0022, halo=dict(threshold=0.62, radius=30, amount=0.40), dye=dict(matrix=[[1.02, 0.07, 0.02], [0.03, 0.95, 0.05], [0.02, 0.12, 0.80]], lift=(0.070, 0.052, 0.088), gain=(1.00, 0.98, 0.90), gamma=(1.00, 1.03, 1.14), sat=0.68), curve=0.26, grain=0.034, dust=dict(specks=110, hairs=2, scratches=1)), dict(name="02-grandads-8mm.jpg", scene=scene_reel8mm, size=(720, 540), soft=2.4, vig=0.78, ca=0.0030, halo=dict(threshold=0.58, radius=26, amount=0.34), dye=dict(matrix=[[1.06, 0.05, 0.00], [0.04, 0.98, 0.02], [0.00, 0.09, 0.74]], lift=(0.040, 0.036, 0.040), gain=(1.04, 0.98, 0.80), gamma=(0.96, 1.00, 1.12), sat=0.86), curve=0.40, grain=0.058, dust=dict(specks=210, hairs=4, scratches=2)), dict(name="03-issyk.jpg", scene=scene_shore, size=(720, 480), soft=1.2, vig=0.34, ca=0.0018, halo=dict(threshold=0.68, radius=28, amount=0.38), dye=dict(matrix=[[1.00, 0.05, 0.02], [0.02, 0.98, 0.03], [0.02, 0.06, 0.92]], lift=(0.052, 0.048, 0.062), gain=(1.02, 1.00, 0.96), gamma=(1.00, 1.01, 1.06), sat=0.80), curve=0.30, grain=0.028, dust=dict(specks=80, hairs=1, scratches=0)), dict(name="04-long-table.jpg", scene=scene_kitchen, size=(720, 480), soft=1.6, vig=0.52, ca=0.0020, halo=dict(threshold=0.55, radius=32, amount=0.50), dye=dict(matrix=[[1.04, 0.06, 0.01], [0.03, 0.97, 0.03], [0.01, 0.07, 0.82]], lift=(0.048, 0.038, 0.044), gain=(1.06, 0.97, 0.84), gamma=(0.97, 1.02, 1.10), sat=0.78), curve=0.34, grain=0.036, dust=dict(specks=120, hairs=2, scratches=1)), dict(name="05-marta-and-ilya.jpg", scene=scene_bouquet, size=(720, 480), soft=1.5, vig=0.46, ca=0.0026, halo=dict(threshold=0.60, radius=30, amount=0.30), dye=dict(matrix=[[1.03, 0.06, 0.03], [0.03, 0.96, 0.04], [0.03, 0.08, 0.88]], lift=(0.080, 0.062, 0.074), gain=(1.00, 0.97, 0.94), gamma=(1.02, 1.04, 1.08), sat=0.66), curve=0.34, grain=0.030, dust=dict(specks=95, hairs=2, scratches=0)), dict(name="06-school-concert.jpg", scene=scene_camcorder, size=(720, 540), soft=2.4, vig=0.36, ca=0.0014, halo=dict(threshold=0.50, radius=30, amount=0.42), dye=dict(matrix=[[0.98, 0.06, 0.04], [0.04, 0.96, 0.05], [0.05, 0.08, 1.00]], lift=(0.062, 0.066, 0.086), gain=(0.98, 0.99, 1.04), gamma=(1.04, 1.03, 0.99), sat=0.52), curve=0.22, grain=0.044, tape=dict(strength=0.16, chroma_bleed=18, jitter=0.8), dust=dict(specks=30, hairs=0, scratches=0)), dict(name="07-nine-candles.jpg", scene=scene_candles, size=(720, 540), soft=1.8, vig=0.58, ca=0.0016, halo=dict(threshold=0.42, radius=34, amount=0.70), dye=dict(matrix=[[1.05, 0.04, 0.01], [0.03, 0.98, 0.03], [0.01, 0.05, 0.94]], lift=(0.052, 0.044, 0.052), gain=(1.05, 0.98, 0.90), gamma=(0.97, 1.01, 1.05), sat=0.74), curve=0.24, grain=0.062, dust=dict(specks=40, hairs=0, scratches=0)), dict(name="08-chimbulak.jpg", scene=scene_snow, size=(720, 480), soft=0.7, vig=0.30, ca=0.0008, halo=dict(threshold=0.78, radius=22, amount=0.22), dye=dict(matrix=IDENTITY, lift=(0.014, 0.016, 0.022), gain=(1.00, 1.00, 1.02), gamma=(1.00, 1.00, 0.99), sat=0.92), curve=0.40, grain=0.013, dust=dict(specks=12, hairs=0, scratches=0)), ] def build(spec, index): w, h = spec["size"] hs, ws = h * SS, w * SS img = spec["scene"](hs, ws) img = gblur(img, spec.get("soft", 1.0) * SS) # lens softness img = downsample(np.clip(img, 0, 1.4), w, h) img = halation(img, **spec["halo"]) img = vignette(img, amount=spec["vig"]) img = chroma_ab(img, spec.get("ca", 0.0)) img = dye(img, **spec["dye"]) img = scurve(img, strength=spec.get("curve", 0.3)) if "tape" in spec: img = scanlines(img, seed=index * 7 + 5, **spec["tape"]) img = grain(img, amount=spec["grain"], seed=index * 13 + 1) img = dust(img, seed=index * 17 + 3, **spec["dust"]) return np.clip(img, 0, 1) if __name__ == "__main__": total = 0 for i, spec in enumerate(FRAMES): img = build(spec, i) path, size = save(img, spec["name"]) total += size print(f"{spec['name']:26s} {spec['size'][0]}x{spec['size'][1]} {size/1024:6.1f} KB") print(f"{'total':26s} {'':9s} {total/1024:6.1f} KB")