Python/OpenCV proof of concept plus first native Swift port
Snapshot before the Python POC is removed. The POC (main.py, effects.py, panel.py, shaders.py, effects/*.glsl, flake.nix) is the reference for the native app under native/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6591138bbf
27 changed files with 2076 additions and 0 deletions
191
main.py
Normal file
191
main.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""motionlab - a tiny TouchDesigner-style playground: live camera in, motion-reactive graphics out."""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from effects import Context, default_layers, hue_bgr
|
||||
from panel import Panel
|
||||
from shaders import ShaderBank
|
||||
|
||||
SIZE = (960, 540)
|
||||
VCAM_SIZE = (1280, 720)
|
||||
WINDOW = "motionlab"
|
||||
|
||||
|
||||
class Synthetic:
|
||||
"""Drifting blobs, so the app (and --headless) works without a camera."""
|
||||
|
||||
def __init__(self):
|
||||
self.i = 0
|
||||
|
||||
def read(self):
|
||||
w, h = SIZE
|
||||
t = self.i / 30
|
||||
self.i += 1
|
||||
img = np.full((h, w, 3), (30, 20, 20), np.uint8)
|
||||
for k in range(5):
|
||||
x = int(w * (0.5 + 0.4 * np.sin(t * (0.7 + 0.2 * k) + k)))
|
||||
y = int(h * (0.5 + 0.4 * np.cos(t * (0.5 + 0.3 * k) + 2 * k)))
|
||||
cv2.circle(img, (x, y), 40 + 12 * k, hue_bgr(k / 5) * 255, -1)
|
||||
return True, cv2.GaussianBlur(img, (0, 0), 4)
|
||||
|
||||
def release(self):
|
||||
pass
|
||||
|
||||
|
||||
def open_source(args):
|
||||
if args.demo or args.headless:
|
||||
return Synthetic()
|
||||
backend = cv2.CAP_AVFOUNDATION if sys.platform == "darwin" else cv2.CAP_ANY
|
||||
cap = cv2.VideoCapture(args.camera, backend)
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
|
||||
# without camera permission macOS can hand back all-black frames instead of failing
|
||||
for _ in range(10):
|
||||
ok, frame = cap.read()
|
||||
if ok and frame.any():
|
||||
return cap
|
||||
cap.release()
|
||||
print("Could not read from the camera - falling back to the synthetic demo source.\n"
|
||||
"On macOS: allow your terminal under System Settings > Privacy & Security > Camera, then rerun.",
|
||||
file=sys.stderr)
|
||||
return Synthetic()
|
||||
|
||||
|
||||
def open_virtualcam():
|
||||
"""Virtual camera other apps (Meet, Zoom, ...) can pick as their webcam."""
|
||||
import pyvirtualcam
|
||||
try:
|
||||
cam = pyvirtualcam.Camera(*VCAM_SIZE, 30, fmt=pyvirtualcam.PixelFormat.BGR)
|
||||
except RuntimeError as e:
|
||||
sys.exit(f"Could not start the virtual camera: {e}")
|
||||
print(f"virtual camera on: pick '{cam.device}' as the camera in your call app")
|
||||
return cam
|
||||
|
||||
|
||||
def fit(frame):
|
||||
"""Centre-crop to the output aspect ratio, then resize."""
|
||||
w, h = SIZE
|
||||
fh, fw = frame.shape[:2]
|
||||
cw = min(fw, fh * w // h)
|
||||
ch = min(fh, fw * h // w)
|
||||
x, y = (fw - cw) // 2, (fh - ch) // 2
|
||||
return cv2.resize(frame[y:y + ch, x:x + cw], SIZE, interpolation=cv2.INTER_AREA)
|
||||
|
||||
|
||||
def capture_path(ext):
|
||||
os.makedirs("captures", exist_ok=True)
|
||||
return os.path.join("captures", time.strftime(f"motionlab-%Y%m%d-%H%M%S.{ext}"))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--camera", type=int, default=0, help="camera index (default 0)")
|
||||
ap.add_argument("--demo", action="store_true", help="use the synthetic source instead of the camera")
|
||||
ap.add_argument("--virtualcam", action="store_true", help="also send the output to a virtual camera for video calls")
|
||||
ap.add_argument("--headless", type=int, metavar="N", help="render N synthetic frames without a window, then exit")
|
||||
ap.add_argument("--out", default="headless.png", help="where --headless writes its last frame")
|
||||
ap.add_argument("--effects", default="effects", help="folder of .glsl shader effects (default ./effects)")
|
||||
ap.add_argument("--only", metavar="NAMES", help="enable only these layers, comma separated (e.g. --only liquid,halftone)")
|
||||
args = ap.parse_args()
|
||||
|
||||
w, h = SIZE
|
||||
src = open_source(args)
|
||||
layers = default_layers()
|
||||
# prefer ./effects so edits are picked up live; fall back to the copy shipped next to this file
|
||||
effects_dir = args.effects if os.path.isdir(args.effects) else os.path.join(os.path.dirname(__file__), "effects")
|
||||
bank = ShaderBank(effects_dir, layers)
|
||||
if args.only:
|
||||
wanted = {name.strip().lower() for name in args.only.split(",")}
|
||||
unknown = wanted - {layer.name.lower() for layer in layers}
|
||||
if unknown:
|
||||
sys.exit(f"unknown layer(s): {', '.join(sorted(unknown))}. Available: {', '.join(l.name for l in layers)}")
|
||||
for layer in layers:
|
||||
layer.enabled = layer.name.lower() in wanted
|
||||
if args.headless and bank.errors:
|
||||
sys.exit(f"shader compile failed: {', '.join(bank.errors)}")
|
||||
ctx = Context(SIZE)
|
||||
panel = Panel(layers)
|
||||
canvas = np.empty((h, w + Panel.WIDTH, 3), np.uint8)
|
||||
show_panel, writer, fps = True, None, 30.0
|
||||
vcam = open_virtualcam() if args.virtualcam else None
|
||||
|
||||
if not args.headless:
|
||||
cv2.namedWindow(WINDOW, cv2.WINDOW_NORMAL | cv2.WINDOW_KEEPRATIO)
|
||||
cv2.resizeWindow(WINDOW, w + Panel.WIDTH, h)
|
||||
cv2.setMouseCallback(WINDOW, panel.on_mouse)
|
||||
|
||||
start = last = time.perf_counter()
|
||||
frames = 0
|
||||
while True:
|
||||
ok, frame = src.read()
|
||||
if not ok:
|
||||
break
|
||||
now = time.perf_counter()
|
||||
dt = 1 / 30 if args.headless else max(now - last, 1e-3)
|
||||
last = now
|
||||
fps += (1 / dt - fps) * 0.1
|
||||
|
||||
frame = cv2.flip(fit(frame), 1) # mirror, so it behaves like a mirror
|
||||
if frames % 15 == 0:
|
||||
bank.refresh() # pick up new and edited shader files
|
||||
ctx.update(frame, dt)
|
||||
out = frame
|
||||
for layer in layers:
|
||||
if layer.enabled:
|
||||
out = layer.apply(out, ctx)
|
||||
|
||||
if writer:
|
||||
writer.write(out)
|
||||
if vcam:
|
||||
# un-mirror: the preview is a mirror, but the people on the call should see the real thing
|
||||
vcam.send(cv2.resize(cv2.flip(out, 1), VCAM_SIZE))
|
||||
view = out
|
||||
if show_panel:
|
||||
canvas[:, :w] = out
|
||||
panel.draw(canvas, w, f"{fps:3.0f} fps" + (" REC" if writer else "") + (" CAM" if vcam else ""))
|
||||
view = canvas
|
||||
else:
|
||||
panel.hits = []
|
||||
|
||||
frames += 1
|
||||
if args.headless:
|
||||
if frames >= args.headless:
|
||||
cv2.imwrite(args.out, view)
|
||||
print(f"{frames} frames, {(time.perf_counter() - start) / frames * 1000:.1f} ms/frame -> {args.out}")
|
||||
break
|
||||
continue
|
||||
|
||||
cv2.imshow(WINDOW, view)
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
if key in (ord("q"), 27) or cv2.getWindowProperty(WINDOW, cv2.WND_PROP_VISIBLE) < 1:
|
||||
break
|
||||
elif key == ord("h"):
|
||||
show_panel = not show_panel
|
||||
elif key == ord("s"):
|
||||
path = capture_path("png")
|
||||
cv2.imwrite(path, out)
|
||||
print("saved", path)
|
||||
elif key == ord("r"):
|
||||
if writer:
|
||||
writer.release()
|
||||
writer = None
|
||||
else:
|
||||
path = capture_path("mp4")
|
||||
writer = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"mp4v"), round(fps), SIZE)
|
||||
print("recording", path)
|
||||
|
||||
if writer:
|
||||
writer.release()
|
||||
if vcam:
|
||||
vcam.close()
|
||||
src.release()
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue