← Selectie van werk

In opdracht · 2025–2026 · Scape Agency · #installatie #ai #hardware

Posse

Bezoeker voor de schermen
Bezoeker voor de schermen (foto: Scape Agency / Interactivist)

Camera's zetten elke bezoeker om in een gegenereerd figuur in dezelfde houding, teruggebracht tot 18 bij 32 pixels op een wand van schermen.

Ga voor een van de schermen staan. Even later verschijnt er een figuur dat jouw houding aanneemt, gekleed in de stijl van de tentoonstelling Supreme – Resampling the World. Van dichtbij is het een vlak van kleur; van een afstand, of door de camera van je telefoon, herken je jezelf. Beweeg, en het figuur beweegt mee. In vier maanden in de lobby van Design Museum Den Bosch, van 23 november 2024 tot 23 maart 2025, registreerde Posse ruim 150.000 interacties. Daarna reisde de installatie naar TORCH in Amsterdam.

In Den Bosch bestond Posse uit tien Raspberry Pi 5's met Pi-camera's, elk met een YOLO-posemodel, en een centrale Django-server op het Mesmera-platform die de Pi's over websockets aanstuurde. Ik bouwde de pose-detectie op de Pi's, de server, de stap van pose naar gegenereerd beeld en de omzetting naar pixel-SVG. Voor de vervolgshows heb ik Posse opnieuw gebouwd als één GPU-machine met USB-camera's en vier portretschermen, die in een dag op een nieuwe locatie staat. Eén Django-applicatie met twee apps: sense detecteert poses met YOLO11 op dertig frames per seconde, synth genereert beelden met Stable Diffusion 1.5, ControlNet OpenPose en een IP-Adapter, in twaalf stappen binnen 16 GB videogeheugen. Elk beeld bouwt voort op het vorige. Het resultaat wordt zonder interpolatie opgeschaald en als SVG van 18 bij 32 blokken naar het scherm gestuurd; zes systemd-services houden het geheel draaiend. Van de bezoeker wordt alleen het skelet bewaard, geen camerabeeld.

Colofon

  • Getoond: 2025 Design Museum Den Bosch, lobby, bij Supreme – Resampling the World; 2026 TORCH, Amsterdam
  • Studio: Scape Agency, op het Mesmera-platform
  • Mijn aandeel: pose-detectie, server, beeldpipeline, pixel-SVG; herbouw als reizende versie, installatie en services

Code

Twee stappen uit de keten, ingekort. Eerst het skelet: YOLO geeft zeventien COCO-keypoints in camerapixels, maar ControlNet wil elke bezoeker op dezelfde plek in hetzelfde kader. Het hoofd komt op een kwart van boven, de knieën op de onderrand, de heupen in het midden; wie te weinig in beeld is, valt terug op de bounding box.

def normalize_pose(kp, width=540, height=960, head_ratio=0.25):
    """YOLO-keypoints [[x, y, conf], ...] naar het skeletbeeld voor ControlNet."""
    head = _valid(kp, NOSE) or _avg(kp, LEFT_EYE, RIGHT_EYE) or _avg(kp, LEFT_SHOULDER, RIGHT_SHOULDER)
    bottom = _avg(kp, LEFT_KNEE, RIGHT_KNEE) or _avg(kp, LEFT_ANKLE, RIGHT_ANKLE) or _avg(kp, LEFT_HIP, RIGHT_HIP)
    if head is None or bottom is None or bottom[1] <= head[1]:
        return None  # te weinig gezien: de aanroeper valt terug op de bounding box
    top = height * head_ratio
    scale = (height - top) / (bottom[1] - head[1])  # hoofd op een kwart, knieën op de onderrand
    out = kp.copy()
    out[:, 0] = (kp[:, 0] - head[0]) * scale
    out[:, 1] = (kp[:, 1] - head[1]) * scale + top
    center = _avg(kp, LEFT_HIP, RIGHT_HIP) or _avg(kp, LEFT_SHOULDER, RIGHT_SHOULDER)
    out[:, 0] += width / 2 - (center[0] - head[0]) * scale  # de heupen in het midden
    return out

Dan het einde: van gegenereerd beeld naar de SVG op het scherm. Eén rechthoek per blok, 18 breed en 32 hoog.

def image_to_svg(image: Image.Image, pixel_size: int = 120) -> bytes:
    width, height = image.size
    svg = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}">']
    for y in range(0, height, pixel_size):
        h = min(pixel_size, height - y)
        for x in range(0, width, pixel_size):
            w = min(pixel_size, width - x)
            r, g, b = image.getpixel((x, y))
            svg.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="rgb({r},{g},{b})" />')
    svg.append("</svg>")
    return "\n".join(svg).encode("utf-8")


# In the synth loop: upscale without interpolation, then 1440 / 80 = 18 blocks wide, 2560 / 80 = 32 high
upscaled = generated.resize((1440, 2560), Image.Resampling.NEAREST)
svg_bytes = image_to_svg(upscaled, pixel_size=80)
pose.pixelated_image.save(f"{pose.uuid}_pixelated.svg", ContentFile(svg_bytes), save=False)