|
1 | 1 | #!/usr/bin/env python3 |
2 | | -"""Render the KERN hero as a locked-camera mechanical loop. |
| 2 | +"""Render the KERN hero as a coherent, seamless-loop camera move. |
3 | 3 |
|
4 | | -The outer housing and background remain pixel-stable. Only circular internal |
5 | | -subassemblies rotate/oscillate; the monochrome master is derived from the exact |
6 | | -colour encode so both website layers remain frame-matched. |
| 4 | +The whole frame moves as ONE integrated shot — the entire mechanism and its cast |
| 5 | +shadow drift, breathe and sway together under a gently floating camera. There are |
| 6 | +no isolated composited "moving centre" patches over a frozen plate, so the motion |
| 7 | +never reads as pasted on: every pixel belongs to the same live beauty shot. |
| 8 | +
|
| 9 | +The camera path is built from a single sine/cosine cycle, so the last frame's |
| 10 | +position and velocity exactly match the first — the loop is seamless with no |
| 11 | +crossfade. The monochrome master is a desaturated grade of the exact colour |
| 12 | +encode, so both website layers stay frame-matched by construction. |
| 13 | +
|
| 14 | +Note: a single 2-D still cannot reveal new faces of the object. A true turntable |
| 15 | +(the mechanism itself rotating) needs either a 3-D model or an image-to-video |
| 16 | +generation — see README. This renderer delivers the honest best from one plate. |
7 | 17 | """ |
8 | 18 | from pathlib import Path |
9 | 19 | from subprocess import Popen, run, PIPE |
10 | | -from math import sin, tau |
11 | | -from PIL import Image, ImageDraw, ImageFilter, ImageEnhance |
| 20 | +from math import sin, cos, tau, radians |
| 21 | +from PIL import Image, ImageEnhance, ImageFilter |
12 | 22 |
|
13 | 23 | HERE = Path(__file__).resolve().parent |
14 | 24 | SOURCE = HERE / "kern-calibration-unit.png" |
15 | 25 | COLOR = HERE / "kern-color.mp4" |
16 | 26 | MONO = HERE / "kern-mono.mp4" |
17 | | -WIDTH, HEIGHT, FPS, FRAMES = 1920, 1080, 30, 350 |
| 27 | +OW, OH, FPS, FRAMES = 2560, 1440, 30, 350 |
18 | 28 | DURATION = FRAMES / FPS |
19 | 29 |
|
20 | | -base = Image.open(SOURCE).convert("RGB").resize((WIDTH, HEIGHT), Image.Resampling.LANCZOS) |
21 | | -base = ImageEnhance.Contrast(base).enhance(1.035) |
22 | | -base = ImageEnhance.Color(base).enhance(1.06) |
| 30 | +# Prepare the plate once: grade + sharpen so every resampled frame is crisp and consistent. |
| 31 | +src = Image.open(SOURCE).convert("RGB") |
| 32 | +src = ImageEnhance.Contrast(src).enhance(1.035) |
| 33 | +src = ImageEnhance.Color(src).enhance(1.05) |
| 34 | +src = src.filter(ImageFilter.UnsharpMask(radius=2.2, percent=90, threshold=2)) |
| 35 | +SW, SH = src.size |
| 36 | +base_fit = max(OW / SW, OH / SH) # cover the output frame from the plate |
| 37 | +OCX, OCY = OW / 2.0, OH / 2.0 |
| 38 | +SCX, SCY = SW / 2.0, SH / 2.0 |
23 | 39 |
|
24 | | -# center-x, center-y and radius are normalized to the output frame. |
25 | | -parts = [ |
26 | | - # Tourbillon-like inner carrier: one calm revolution per seamless loop. |
27 | | - {"name": "carrier", "x": .505, "y": .515, "r": .112, "cycles": 1.0, "phase": 0}, |
28 | | - # Visible pinions counter-rotate at integer loop ratios. |
29 | | - {"name": "upper-pinion", "x": .548, "y": .455, "r": .034, "cycles": -7.0, "phase": 8}, |
30 | | - {"name": "lower-pinion", "x": .458, "y": .555, "r": .031, "cycles": 10.0, "phase": -5}, |
31 | | - # Central gold escapement receives continuous rotation plus a tick oscillation. |
32 | | - {"name": "escapement", "x": .505, "y": .515, "r": .052, "cycles": 4.0, "phase": 0, "tick": 14}, |
33 | | -] |
34 | | - |
35 | | -prepared = [] |
36 | | -for part in parts: |
37 | | - cx, cy = int(part["x"] * WIDTH), int(part["y"] * HEIGHT) |
38 | | - radius = int(part["r"] * HEIGHT) |
39 | | - pad = int(radius * 1.18) |
40 | | - box = (cx - pad, cy - pad, cx + pad, cy + pad) |
41 | | - patch = base.crop(box) |
42 | | - mask = Image.new("L", patch.size, 0) |
43 | | - d = ImageDraw.Draw(mask) |
44 | | - feather = max(5, int(radius * .10)) |
45 | | - d.ellipse((pad-radius+feather, pad-radius+feather, pad+radius-feather, pad+radius-feather), fill=255) |
46 | | - mask = mask.filter(ImageFilter.GaussianBlur(feather)) |
47 | | - prepared.append((part, box, patch, mask)) |
| 40 | +# Floating-camera path — one closed sine cycle → seamless loop (start == end, matching velocity). |
| 41 | +ZOOM0, ZOOM_A = 1.09, 0.020 # breathe 1.07 <-> 1.11 (headroom hides translate/rotate) |
| 42 | +DX_A = 0.008 * OW # gentle horizontal orbit |
| 43 | +DY_A = 0.006 * OH # gentle vertical bob |
| 44 | +ROT_A = 1.2 # degrees of sway |
48 | 45 |
|
49 | 46 | cmd = [ |
50 | 47 | "ffmpeg", "-loglevel", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", |
51 | | - "-s", f"{WIDTH}x{HEIGHT}", "-r", str(FPS), "-i", "-", "-an", "-c:v", "libx264", |
52 | | - "-preset", "slow", "-crf", "18", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(COLOR), |
| 48 | + "-s", f"{OW}x{OH}", "-r", str(FPS), "-i", "-", "-an", |
| 49 | + "-vf", "gradfun=1.0:16,format=yuv420p", |
| 50 | + "-c:v", "libx264", "-preset", "slow", "-crf", "19", "-movflags", "+faststart", str(COLOR), |
53 | 51 | ] |
54 | 52 | encoder = Popen(cmd, stdin=PIPE) |
55 | 53 | assert encoder.stdin is not None |
56 | 54 | stream = encoder.stdin |
57 | 55 |
|
58 | | -for frame_index in range(FRAMES): |
59 | | - progress = frame_index / FRAMES |
60 | | - frame = base.copy() |
61 | | - for part, box, patch, mask in prepared: |
62 | | - angle = part["phase"] + 360 * part["cycles"] * progress |
63 | | - if part.get("tick"): |
64 | | - angle += 8.5 * sin(tau * part["tick"] * progress) |
65 | | - rotated = patch.rotate(angle, resample=Image.Resampling.BICUBIC, expand=False) |
66 | | - rotated_mask = mask.rotate(angle, resample=Image.Resampling.BICUBIC, expand=False) |
67 | | - frame.paste(rotated, box[:2], rotated_mask) |
68 | | - |
69 | | - # Energy is dynamic, not camera movement: coil and emitter breathe in place. |
70 | | - glow = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0)) |
71 | | - gd = ImageDraw.Draw(glow) |
72 | | - coil_pulse = .5 + .5 * sin(tau * 6 * progress) |
73 | | - emit_pulse = .5 + .5 * sin(tau * 8 * progress + 1.2) |
74 | | - for x, y, radius, color, strength in [ |
75 | | - (.36, .35, .052, (224, 148, 66), coil_pulse), |
76 | | - (.69, .57, .046, (0, 230, 210), emit_pulse), |
77 | | - ]: |
78 | | - cx, cy, rr = int(x*WIDTH), int(y*HEIGHT), int(radius*HEIGHT) |
79 | | - gd.ellipse((cx-rr, cy-rr, cx+rr, cy+rr), fill=(*color, int(16 + strength*24))) |
80 | | - glow = glow.filter(ImageFilter.GaussianBlur(26)) |
81 | | - frame = Image.alpha_composite(frame.convert("RGBA"), glow).convert("RGB") |
| 56 | +for i in range(FRAMES): |
| 57 | + a = tau * i / FRAMES |
| 58 | + theta = radians(ROT_A * sin(a)) |
| 59 | + zoom = ZOOM0 + ZOOM_A * (-cos(a)) # min at loop start, max at half, back — smooth |
| 60 | + dx = DX_A * cos(a) |
| 61 | + dy = DY_A * sin(a) |
| 62 | + k = base_fit * zoom # total plate->frame scale |
| 63 | + ct, st = cos(theta) / k, sin(theta) / k |
| 64 | + # Inverse affine: output pixel (x,y) samples the plate at (a*x+b*y+c, d*x+e*y+f). |
| 65 | + ax, bx = ct, st |
| 66 | + dxc, ex = -st, ct |
| 67 | + c = SCX - (ax * (OCX + dx) + bx * (OCY + dy)) |
| 68 | + f = SCY - (dxc * (OCX + dx) + ex * (OCY + dy)) |
| 69 | + frame = src.transform((OW, OH), Image.AFFINE, (ax, bx, c, dxc, ex, f), resample=Image.BICUBIC) |
82 | 70 | stream.write(frame.tobytes()) |
83 | 71 |
|
84 | 72 | stream.close() |
|
87 | 75 |
|
88 | 76 | result = run([ |
89 | 77 | "ffmpeg", "-loglevel", "error", "-y", "-i", str(COLOR), |
90 | | - "-vf", "hue=s=0,eq=contrast=1.12:brightness=-0.015,format=yuv420p", |
91 | | - "-c:v", "libx264", "-preset", "slow", "-crf", "18", "-movflags", "+faststart", "-an", str(MONO), |
| 78 | + "-vf", "hue=s=0,eq=contrast=1.06:brightness=-0.012,format=yuv420p", |
| 79 | + "-c:v", "libx264", "-preset", "slow", "-crf", "19", "-movflags", "+faststart", "-an", str(MONO), |
92 | 80 | ]) |
93 | 81 | if result.returncode: |
94 | 82 | raise SystemExit("monochrome encode failed") |
95 | 83 |
|
96 | | -print(f"rendered {FRAMES} frames / {DURATION:.3f}s -> {COLOR.name}, {MONO.name}") |
| 84 | +print(f"rendered {FRAMES} frames / {DURATION:.3f}s coherent camera move -> {COLOR.name}, {MONO.name}") |
0 commit comments