"""Film the wall while the disc is being cut. The single most surprising thing the metal does is stop being a mirror. At eight hours the wall shows the lamp and almost nothing else; at eighty the lamp is a third dimmer and the grooves throw eleven times more light than it does. A pair of stills states that. A sequence shows the crossing. Each step is a real render at a real machine time: the stroke is truncated at that time, `groove_field.build` re-measures the direction field of what has been cut so far, and `project.render` throws it at the wall. Nothing is interpolated between steps, so the film is stepped rather than smooth — every frame you see is a computed installation, not a tween. The exposure is FIXED for the whole sequence, calibrated on the last step. This matters more than it sounds: `project.tone` normally auto-exposes each picture, and auto-exposure would divide out precisely the quantity the film exists to show. With a fixed curve the lamp's core clips early on, which is what a bright specular highlight actually does on a wall. Usage: python3 film_wall.py ../plate/polar80/descent --hours 80 --steps 40 \ --out ../plate/film/wall80.mp4 """ from __future__ import annotations import argparse import json import math import os import shutil import subprocess import tempfile import numpy as np import groove_field as gf import optics as op import project as pj BG = (11, 13, 14) TEXT = (150, 156, 160) LABEL = (226, 232, 235) def font(size): from PIL import ImageFont for path in ("/System/Library/Fonts/Supplemental/Futura.ttc", "/System/Library/Fonts/HelveticaNeue.ttc", "/System/Library/Fonts/Helvetica.ttc"): if os.path.exists(path): try: return ImageFont.truetype(path, size) except OSError: continue return ImageFont.load_default() def load(plate_dir): npy = os.path.join(plate_dir, "stroke.npy") if os.path.exists(npy): return np.load(npy) with open(os.path.join(plate_dir, "stroke.json")) as f: return np.asarray(json.load(f)["pts"], dtype="float32") def one_step(pts, upto, scratch, inst, px, samples, cell_mm, bins): """Field + projection for the stroke truncated at `upto` points.""" prefix = pts[:max(2, upto)] length, stats = gf.build(prefix, cell_mm=cell_mm, n_bins=bins) fdir = os.path.join(scratch, "field") os.makedirs(fdir, exist_ok=True) np.savez_compressed(os.path.join(fdir, "groove_length.npz"), length=length, cell_mm=cell_mm, n_bins=bins) img, spec, meta = pj.render(scratch, inst, px=px, samples=samples, verbose=False) px_per_rad = px / (2.0 * math.atan(inst.wall_w * 0.5 / inst.wall_y)) lamp_px = px_per_rad * inst.lamp_half_angle * 0.5 rough_px = px_per_rad * op.SLOPE_GROOVE_ALONG * 2.0 img = pj.blur(img, math.hypot(lamp_px, rough_px)) spec = pj.blur(spec, math.hypot(lamp_px, px_per_rad * op.SLOPE_MIRROR * 2)) return img + spec, { "groove_m": meta["groove_length_m"], "mirror": meta["mean_mirror_fraction"], "e_groove": float(img.sum()), "e_spec": float(spec.sum()), } def stamp(arr, ref, hours, info, header): from PIL import Image, ImageDraw v = pj.tone(arr, ref=ref) im = Image.fromarray((v * 255).astype("uint8"), "RGB") W, H = im.size strip = max(52, H // 12) from PIL import Image as I sheet = I.new("RGB", (W + W % 2, H + strip + (H + strip) % 2), BG) sheet.paste(im, (0, 0)) dr = ImageDraw.Draw(sheet) fb = font(max(14, strip // 3)) fs = font(max(11, strip // 4)) y = H + strip * 0.22 ratio = info["e_groove"] / max(info["e_spec"], 1e-12) # laid out by measured advance, not by guessed offsets, because the strings # change width with the numbers in them and a fixed grid collides pad, gap = 14, max(18, strip // 4) x = float(pad) for text, f, col, dy in ( ("%5.1f h" % hours, fb, LABEL, 0), ("grooves / lamp image %5.2f" % ratio, fb, LABEL if ratio >= 1.0 else TEXT, 0), ("%.0f m cut mirror left %.0f%%" % (info["groove_m"], info["mirror"] * 100.0), fs, TEXT, 2)): dr.text((x, y + dy), text, font=f, fill=col) x += dr.textlength(text, font=f) + gap if header: w = dr.textlength(header, font=fs) if W - pad - w > x: # only if it cannot touch the left group dr.text((W - pad - w, y + 2), header, font=fs, fill=TEXT) return sheet def main(): ap = argparse.ArgumentParser() ap.add_argument("plate_dir") ap.add_argument("--out", required=True) ap.add_argument("--hours", type=float, default=80.0) ap.add_argument("--steps", type=int, default=40) ap.add_argument("--px", type=int, default=1000) ap.add_argument("--samples", type=int, default=121) ap.add_argument("--cell", type=float, default=2.0) ap.add_argument("--bins", type=int, default=36) ap.add_argument("--tilt", type=float, default=20.0) ap.add_argument("--lamp-half-angle", type=float, default=0.6) ap.add_argument("--hold-frames", type=int, default=16, help="video frames each computed step is shown for") ap.add_argument("--fps", type=int, default=24) ap.add_argument("--header", default=None) args = ap.parse_args() d = os.path.abspath(args.plate_dir) pts = load(d) inst = pj.Install(tilt_deg=args.tilt, lamp_half_angle_deg=args.lamp_half_angle) print("filming the wall for %s: %d pts, %d steps" % (os.path.basename(d), len(pts), args.steps)) scratch = tempfile.mkdtemp(prefix="wallfield-") frames = tempfile.mkdtemp(prefix="wallframes-") try: # last step first, to fix the exposure everything else is measured against counts = [int(round(len(pts) * (k + 1) / args.steps)) for k in range(args.steps)] arr, info = one_step(pts, counts[-1], scratch, inst, args.px, args.samples, args.cell, args.bins) ref = pj.exposure_ref(arr) print(" exposure fixed on the last step: ref=%.4g " "(grooves/lamp %.2f)" % (ref, info["e_groove"] / info["e_spec"])) cache = {args.steps - 1: (arr, info)} log = [] for k, n in enumerate(counts): if k in cache: arr, info = cache.pop(k) else: arr, info = one_step(pts, n, scratch, inst, args.px, args.samples, args.cell, args.bins) hours = args.hours * (k + 1) / args.steps sheet = stamp(arr, ref, hours, info, args.header) for j in range(args.hold_frames): sheet.save(os.path.join( frames, "f%06d.png" % (k * args.hold_frames + j))) ratio = info["e_groove"] / max(info["e_spec"], 1e-12) log.append({"hours": round(hours, 2), "groove_m": info["groove_m"], "mirror": info["mirror"], "ratio": round(ratio, 3)}) print(" step %2d/%d %5.1f h %6.0f m mirror %.3f ratio %6.2f" % (k + 1, args.steps, hours, info["groove_m"], info["mirror"], ratio)) out = os.path.abspath(args.out) os.makedirs(os.path.dirname(out), exist_ok=True) ff = ("/opt/homebrew/bin/ffmpeg" if os.path.exists("/opt/homebrew/bin/ffmpeg") else "ffmpeg") subprocess.run([ff, "-y", "-loglevel", "error", "-framerate", str(args.fps), "-i", os.path.join(frames, "f%06d.png"), "-c:v", "libx264", "-preset", "slow", "-crf", "19", "-pix_fmt", "yuv420p", "-movflags", "+faststart", out], check=True) with open(os.path.splitext(out)[0] + ".json", "w") as f: json.dump({"steps": log, "exposure_ref": ref, "note": "exposure fixed on the last step; no frame is " "interpolated"}, f, ensure_ascii=False, indent=1) print("wrote %s (%.1f MB)" % (out, os.path.getsize(out) / 1e6)) finally: shutil.rmtree(scratch, ignore_errors=True) shutil.rmtree(frames, ignore_errors=True) if __name__ == "__main__": main()