Emanon
Een plafond van laserlicht dat reageert op hoe bezoekers door de zaal bewegen.
Boven je hoofd hangt een raster van dunne doeken waar rode laserlijnen doorheen trekken die een structuur vormen die zichzelf stap voor stap lijkt uit te rekenen. Als je door de zaal loopt verschuift het patroon. De bewegingen volgen eenvoudige regels, als een zwerm zonder leider, en voorspellen zo vast de toekomstige bewegingen van de bezoekers.
Mijn deel was de tracker die bezoekers volgt en de TouchDesigner-patches die van hun posities lijnen maken. De tracker is een Python-programma op Intel NUC's met camera; een YOLOv8-model herkent en volgt mensen, en de fisheye-vertekening wordt gecorrigeerd voordat een positie de laserruimte in gaat. Elke persoon gaat als OSC-bericht over UDP naar de centrale machine, maximaal vijf tegelijk, want de lasers hebben vijf lijnen. Er worden alleen coördinaten verstuurd tussen de NUC’s en de TouchDesigner-server. Elke NUC verwerkt zijn eigen camera, zodat uitval van één machine de rest niet raakt. In TouchDesigner bouwde ik de lijnen en de kalibratie van beeld naar doek.
Colofon
- Getoond: voorjaar 2025, Design Museum Den Bosch
- Studio: Scape Agency
- Mijn aandeel: tracking, TouchDesigner-patches, kalibratie, handleiding voor de vloer, opbouw
Code
Het hart van de tracker, ingekort. Van een bounding box naar één punt: midden nemen, normaliseren, de fisheye eruit rekenen en dan als OSC-bericht de deur uit. Nooit meer dan vijf.
def apply_fisheye_correction(x: float, y: float) -> tuple[float, float]:
r = np.sqrt(x**2 + y**2)
theta = np.arctan2(y, x)
r_corrected = np.tanh(2 * r)
return r_corrected * np.cos(theta), r_corrected * np.sin(theta)
def process_frame(frame_data, tracker) -> list[tuple[float, float]]:
frame_height, frame_width = frame_data.shape[:2]
points = []
for x1, y1, x2, y2, confidence in tracker.detect(frame_data):
norm_x = (x1 + x2) / 2 / frame_width - 0.5
norm_y = (y1 + y2) / 2 / frame_height - 0.5
x, y = apply_fisheye_correction(norm_x, norm_y)
points.append((remap_to_domain(x, (-0.5, 0.5), X_DOMAIN),
remap_to_domain(y, (-0.5, 0.5), Y_DOMAIN)))
return points
def send_osc_data(center_points: list[tuple[float, float]]) -> None:
for idx, (cx, cy) in enumerate(center_points[:5]):
osc_sender.send(f"/point/{idx}", cx, cy)