"""Film the disc being cut. The stroke is one line that never lifts, so a still picture of the finished disc is the one thing that cannot show what the work is. This renders the same stroke as time: each frame adds the metal the machine cut during one interval of MACHINE time, which here means an equal number of steps, because every step is one microstep of the same two axes. Drawing is incremental — a frame draws only its own new segment onto the image the previous frame left behind. So the whole film costs one pass over the path, not one pass per frame. At 80 hours the path is 12 million points and the difference is the difference between minutes and days. Two layouts: --mode single one disc, filling the frame. --mode grid several discs at once, same clock, same magnification. This is the only honest way to compare rules: they are not competing, they are the same amount of machine time spent differently. The counter in the corner is machine hours and metres cut, not frame number. Nothing in the picture is a groove: at this magnification 0.10 mm is a fraction of a pixel, so the white is a locus, not a width. The metal figures live in depth_disc.py and render_cut.py. Usage: python3 film_polar.py ../plate/polar/descent --out ../plate/polar/film/disc8.mp4 python3 film_polar.py ../plate/polar80/descent --hours 80 --seconds 30 \ --out ../plate/polar80/film/disc80.mp4 python3 film_polar.py ../plate/polar/descent ../plate/polar/phase ... \ --mode grid --out ../plate/polar/film/five.mp4 """ from __future__ import annotations import argparse import math import os import shutil import subprocess import tempfile DISC_R = 200.0 R_MIN, R_MAX = 5.0, 195.0 GROOVE_MM = 0.10 # the tool's specification; see stylus.py BG = (11, 13, 14) PLATE = (22, 24, 26) INK = (242, 244, 245) NOW = (109, 215, 255) # where the single point of contact is, this instant TEXT = (150, 156, 160) LABEL = (226, 232, 235) FONT_CANDIDATES = ( "/System/Library/Fonts/Supplemental/Futura.ttc", "/System/Library/Fonts/HelveticaNeue.ttc", "/System/Library/Fonts/Helvetica.ttc", ) def font(size): from PIL import ImageFont for path in FONT_CANDIDATES: if os.path.exists(path): try: return ImageFont.truetype(path, size) except OSError: continue return ImageFont.load_default() def load(plate_dir): """The path in millimetres. float32 on disk, kept as float32 in memory.""" import numpy as np npy = os.path.join(plate_dir, "stroke.npy") if os.path.exists(npy): return np.load(npy) import json with open(os.path.join(plate_dir, "stroke.json")) as f: return np.asarray(json.load(f)["pts"], dtype="float32") def path_length_mm(pts): import numpy as np d = np.diff(pts.astype("float64"), axis=0) return float(np.hypot(d[:, 0], d[:, 1]).sum()) class Panel: """One disc, accumulated as density rather than drawn as a line. A pixel here is ~0.4 mm across and the groove is 0.10 mm, so a binary line saturates to solid white long before the disc is full and the film stops saying anything. Instead each frame adds the cut length that fell inside each pixel, and brightness is a fixed function of the total. So the picture shows WHERE THE MACHINE SPENT ITS TIME — which is the same quantity the machine itself reads to decide where to go. The transfer curve is constant for the whole film, so brightness is comparable between frames. """ def __init__(self, pts, side_px, label=None, step_mm=0.15): import numpy as np self.pts = pts self.label = label self.side = side_px self.scale = side_px / (2.0 * DISC_R) self.step_mm = step_mm self.acc = np.zeros(side_px * side_px, dtype="float32") self.cut_mm = 0.0 self.drawn = 0 self.k = 1.0 self.plate = self._plate_mask() def _plate_mask(self): """The reachable annulus, so the body of the disc reads before any ink.""" import numpy as np g = (np.arange(self.side) + 0.5) / self.scale - DISC_R rr = np.hypot(g[None, :], g[:, None]) return ((rr <= R_MAX) & (rr >= R_MIN)).ravel() def px(self, p): return ((p[0] + DISC_R) * self.scale, (p[1] + DISC_R) * self.scale) def _deposit(self, seg): """Add one polyline's length into the density array.""" import numpy as np if len(seg) < 2: return 0.0 p0 = seg[:-1].astype("float64") p1 = seg[1:].astype("float64") d = p1 - p0 L = np.hypot(d[:, 0], d[:, 1]) total_mm = float(L.sum()) n = np.maximum(1, np.ceil(L / self.step_mm)).astype("int64") m = int(n.sum()) if m == 0: return total_mm idx = np.repeat(np.arange(len(n)), n) start = np.concatenate(([0], np.cumsum(n)[:-1])) t = (np.arange(m) - start[idx]) / n[idx] qx = p0[idx, 0] + d[idx, 0] * t qy = p0[idx, 1] + d[idx, 1] * t ix = np.clip(((qx + DISC_R) * self.scale).astype("int64"), 0, self.side - 1) iy = np.clip(((qy + DISC_R) * self.scale).astype("int64"), 0, self.side - 1) flat = iy * self.side + ix self.acc += np.bincount(flat, minlength=self.side * self.side ).astype("float32") return total_mm def calibrate(self, mult=2.0): """Fix the transfer curve from the FINISHED disc, then start over. Chosen this way so the last frame is a dense felt rather than a white plate, and so the same curve holds for every earlier frame. """ import numpy as np CH = 400_000 for c0 in range(0, len(self.pts) - 1, CH): self._deposit(self.pts[c0:c0 + CH + 1]) covered = self.acc[self.acc > 0] self.k = float(covered.mean()) * mult if covered.size else 1.0 self.acc[:] = 0.0 return self.k def advance_to(self, i): import numpy as np if i <= self.drawn: return i = min(i, len(self.pts)) lo = max(0, self.drawn - 1) seg = self.pts[lo:i] CH = 400_000 for c0 in range(0, len(seg) - 1, CH): self.cut_mm += self._deposit(seg[c0:c0 + CH + 1]) self.drawn = i def frame(self): import numpy as np from PIL import Image, ImageDraw v = 1.0 - np.exp(-self.acc / self.k) # 0..1, constant curve base = np.where(self.plate, PLATE[0] / 255.0, BG[0] / 255.0 ).astype("float32") rgb = np.empty((self.side * self.side, 3), dtype="float32") for c in range(3): b = np.where(self.plate, PLATE[c] / 255.0, BG[c] / 255.0) rgb[:, c] = b + (INK[c] / 255.0 - b) * v del base im = Image.fromarray( (np.clip(rgb, 0, 1) * 255.0 + 0.5).astype("uint8") .reshape(self.side, self.side, 3)) dr = ImageDraw.Draw(im) if self.drawn: x, y = self.px(self.pts[min(self.drawn, len(self.pts)) - 1]) r = max(2.0, self.side / 260.0) dr.ellipse([x - r, y - r, x + r, y + r], fill=NOW) if self.label: f = font(max(11, self.side // 30)) dr.text((self.side * 0.045, self.side * 0.035), self.label, font=f, fill=LABEL) return im def compose(panels, cols, pad, hours, header): """Lay the panels out and stamp the shared clock underneath.""" from PIL import Image, ImageDraw rows = int(math.ceil(len(panels) / cols)) side = panels[0].side W = cols * side + (cols + 1) * pad strip = max(56, side // 9) H = rows * side + (rows + 1) * pad + strip sheet = Image.new("RGB", (W, H), BG) for k, p in enumerate(panels): r, c = divmod(k, cols) sheet.paste(p.frame(), (pad + c * (side + pad), pad + r * (side + pad))) dr = ImageDraw.Draw(sheet) y = H - strip + strip * 0.22 fb = font(max(13, strip // 3)) fs = font(max(11, strip // 4)) total_m = sum(p.cut_mm for p in panels) / 1000.0 left = "%5.2f h" % hours dr.text((pad + 2, y), left, font=fb, fill=LABEL) right = "%.0f m cut" % total_m dr.text((pad + 2 + max(96, strip * 1.7), y), right, font=fb, fill=TEXT) if header: w = dr.textlength(header, font=fs) dr.text((W - pad - 2 - w, y + 2), header, font=fs, fill=TEXT) return sheet def even_pad(im): """H.264 yuv420p needs even dimensions.""" from PIL import Image W, H = im.size if W % 2 == 0 and H % 2 == 0: return im out = Image.new("RGB", (W + W % 2, H + H % 2), BG) out.paste(im, (0, 0)) return out def encode(frame_dir, out, fps): os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) ff = "/opt/homebrew/bin/ffmpeg" if os.path.exists("/opt/homebrew/bin/ffmpeg") else "ffmpeg" cmd = [ff, "-y", "-loglevel", "error", "-framerate", str(fps), "-i", os.path.join(frame_dir, "f%06d.png"), "-c:v", "libx264", "-preset", "slow", "-crf", "19", "-pix_fmt", "yuv420p", "-movflags", "+faststart", out] subprocess.run(cmd, check=True) def main(): ap = argparse.ArgumentParser() ap.add_argument("plate_dirs", nargs="+") ap.add_argument("--out", required=True) ap.add_argument("--mode", default="single", choices=["single", "grid"]) ap.add_argument("--hours", type=float, default=8.0, help="machine hours the stroke represents (for the clock)") ap.add_argument("--seconds", type=float, default=20.0) ap.add_argument("--fps", type=int, default=24) ap.add_argument("--side", type=int, default=0, help="px per disc; 0 = auto") ap.add_argument("--cols", type=int, default=3) ap.add_argument("--hold", type=float, default=1.5, help="seconds to hold the finished disc") ap.add_argument("--labels", default=None, help="comma separated") ap.add_argument("--header", default=None) ap.add_argument("--step", type=float, default=0.15, help="mm between deposited samples along the path") ap.add_argument("--contrast", type=float, default=2.0, help="transfer curve: k = mean final density x this") args = ap.parse_args() labels = args.labels.split(",") if args.labels else [ os.path.basename(os.path.abspath(d)) for d in args.plate_dirs] if args.mode == "single" and len(args.plate_dirs) == 1 and not args.labels: labels = [None] side = args.side or (1024 if args.mode == "single" else 512) cols = 1 if args.mode == "single" else min(args.cols, len(args.plate_dirs)) pad = max(4, side // 64) panels = [] for d, lab in zip(args.plate_dirs, labels): pts = load(os.path.abspath(d)) p = Panel(pts, side, lab, step_mm=args.step) k = p.calibrate(args.contrast) panels.append(p) print("loaded %-22s %9d pts %8.1f m own k=%.2f" % (os.path.basename(os.path.abspath(d)), len(pts), path_length_mm(pts) / 1000.0, k)) if len(panels) > 1: # One transfer curve for every panel, or brightness would mean a # slightly different number of millimetres in each and the panels # would not be comparable — which is the only reason to show them # side by side at all. shared = sum(p.k for p in panels) / len(panels) spread = max(p.k for p in panels) / min(p.k for p in panels) - 1.0 for p in panels: p.k = shared print("shared transfer k=%.2f (own values spanned %.2f%%)" % (shared, spread * 100.0)) n_frames = int(round(args.seconds * args.fps)) hold = int(round(args.hold * args.fps)) longest = max(len(p.pts) for p in panels) tmp = tempfile.mkdtemp(prefix="film-") try: for k in range(n_frames): # equal machine time per frame: the same fraction of every stroke frac = (k + 1) / n_frames for p in panels: p.advance_to(int(round(frac * len(p.pts)))) sheet = even_pad(compose(panels, cols, pad, args.hours * frac, args.header)) sheet.save(os.path.join(tmp, "f%06d.png" % k)) if (k + 1) % 40 == 0 or k + 1 == n_frames: print(" frame %4d/%d %5.2f h %.0f m" % (k + 1, n_frames, args.hours * frac, sum(q.cut_mm for q in panels) / 1000.0)) last = os.path.join(tmp, "f%06d.png" % (n_frames - 1)) for j in range(hold): shutil.copyfile(last, os.path.join(tmp, "f%06d.png" % (n_frames + j))) encode(tmp, os.path.abspath(args.out), args.fps) finally: shutil.rmtree(tmp, ignore_errors=True) size = os.path.getsize(args.out) print("wrote %s (%.1f MB, %d frames, %.1f s, %d pts total)" % (args.out, size / 1e6, n_frames + hold, (n_frames + hold) / args.fps, longest)) if __name__ == "__main__": main()