A single-file browser app that extracts your body silhouette from a live webcam feed and renders it as an inward-radiating ROYGBIV rainbow aura. Ghost frames accumulate and fade, giving movement a persistent afterimage trail. No build step, no server — open the HTML file and stand in frame.
Open index.html in a modern browser and grant camera access.
Stand back from the camera so your full silhouette is visible. The aura appears within a few seconds as the segmentation model loads.
- Live webcam → body silhouette → ROYGBIV contour aura
- GPU-accelerated colorization via WebGL fragment shader with JS erosion fallback
- Color bands cycle continuously, rotating inward through the spectrum
- Ghost frame compositor — 50-frame pool with position-based alpha falloff
- Mirror mode (horizontal flip)
- Dark navy gradient background
- Debug view with 5 live pipeline panels and all parameters exposed as sliders
- Stats overlay showing render FPS, segmentation FPS, foreground pixel count, ghost frame count, and pipeline latency
Webcam → MediaPipe segmentation → mask pipeline → WebGL shader → ghost buffer → canvas output
Two frame rates run in parallel: the render loop runs at 60fps for smooth ghost compositing, while segmentation runs at a lower rate (configurable via frame skip) to keep CPU load manageable.
MediaPipe Selfie Segmentation (model 1) processes frames at 320×240 — a deliberately small resolution that keeps segmentation fast without sacrificing mask quality. The raw mask is then bilinear-upscaled to the 480×360 processing resolution, which naturally softens the pixelated mask edge before any further processing.
Each new mask frame goes through four stages:
-
Binary threshold — the MediaPipe confidence channel is thresholded (default 80/255) to produce a hard foreground/background mask.
-
Morphological close — a dilate followed by an erode pass fills small holes in the mask and smooths jagged edges. Radius is adjustable.
-
Gaussian blur + re-threshold — a separable 1D Gaussian blur softens the mask, which is then re-thresholded to produce a clean anti-aliased silhouette edge.
-
Colorization — the processed mask is passed to either the WebGL shader (primary path) or the JS erosion fallback.
The fragment shader takes the binary mask as a luminance texture and estimates each foreground pixel's distance from the silhouette edge using a 16-tap multi-radius neighbourhood search (a jump flood approximation). Distance is divided by the stripe width to get a band index, which is then mapped to the ROYGBIV palette. A uTime uniform drives continuous color cycling so the bands appear to rotate inward through the body.
Pixels deeper than 7 band-widths from the edge are rendered solid black, giving the silhouette interior a dark core with the rainbow concentrated at the contour.
If WebGL is unavailable, the colorization falls back to iterated morphological erosion on the CPU. Each erosion pass peels one ring off the mask; the pixels shed between passes are colored with the corresponding ROYGBIV band. Functionally equivalent to the shader but significantly slower.
Colored frames are captured into a pre-allocated pool of 50 canvases at a configurable interval (default every 100ms). On every render tick, the pool is composited oldest-to-newest with position-based alpha: the newest frame gets topAlpha (default 0.85), and each older frame is multiplied by falloff (default 0.78). Frames older than ghostFade (default 4s) are skipped.
Position-based falloff means the visible depth of the trail is consistent regardless of how fast you're moving — you always see a clean gradient through the stack rather than a flat cluster of equally-bright frames.
The main canvas draws a black-to-dark-navy vertical gradient on every frame before compositing the ghost stack. This gives the output its characteristic dark glow rather than a plain black background.
Click DEBUG (bottom right) to open the debug view: a 3×2 grid of live pipeline panels plus a control panel.
| Panel | Contents |
|---|---|
| A1 · MORPH CLOSE | Binary mask after morphological close |
| B1 · BLUR + THRESHOLD | Final cleaned mask after gaussian blur and re-threshold |
| C1 · EROSION RINGS (JS) | JS erosion ring colorization, throttled to every 8 pipeline calls |
| A2 · GHOST COMPOSITE | Ghost stack composited on black |
| B2 · GHOST + GRADIENT BG | Ghost stack on the navy gradient background |
| Controls | All pipeline parameters as live sliders |
| Parameter | Description |
|---|---|
| stripe px | Width of each color band in pixels |
| morph r | Morphological close radius |
| blur r | Gaussian blur radius for edge softening |
| threshold | Re-threshold level after blur |
| conf | MediaPipe confidence threshold |
| frame skip | rAF ticks between segmentation dispatches |
| ghost | Interval between ghost frame captures (ms) |
| fade | Time before a ghost frame expires (ms) |
| top alpha | Alpha of the newest (top) ghost frame |
| falloff | Per-step alpha multiplier toward older frames |
| Control | Location | Action |
|---|---|---|
| DEBUG | Bottom right | Open debug view |
| PERF MODE | Bottom right (debug only) | Return to full-screen output |
| MIRROR | Bottom center / debug controls | Toggle horizontal flip |
Any modern browser with:
getUserMedia(webcam access, requires HTTPS or localhost)- WebGL (used for GPU colorization — JS fallback active if unavailable)
- MediaPipe Selfie Segmentation loaded from CDN
The MediaPipe model loads from cdn.jsdelivr.net on first run — an internet connection is required.
MIT
SW = 480, SH = 360 is the processing resolution. All typed buffers (binA, dilA, eroA, finM, blrT, blrO, rgba, ringPrev, ringCur, lumData) are allocated once at SW × SH at startup. The WebGL texture, ghost pool canvases, and debug display canvases are all sized to SW × SH. Increasing SW/SH to something like 640×480 increases per-frame pixel work by ~78% across every pipeline stage — morphological close, gaussian blur, GL texture upload, ghost compositing, and debug rendering all scale with pixel count. Profile before increasing. If you change SW/SH you must also update the gl.viewport() call and the gl.uniform2f(glUniRes, ...) uniform.
initGL() compiles the shader, links the program, uploads static uniforms, and sets up the mask texture. If it is not called — or called after startCamera() kicks off the loop — glProgram will be null, useGL will be true (WebGL context exists), and every pipeline call will silently skip colorization while glRenderMask() crashes on a null program reference. initGL() and startCamera() are both called at the bottom of the script in that order. Keep it that way.
glRenderMask() calls gl.disable(gl.BLEND) before clearing, then gl.drawArrays, then the caller composites to canvas. The blend must be disabled during the clear so that the clear actually writes transparent black rather than blending with the previous frame. If you add any draw calls between the clear and gl.drawArrays, re-check whether blend state is correct for each one. Getting this wrong produces red/dirty color artifacts in the ghost composite — the transparent shader background pixels write garbage into the canvas buffer.
The erosion ring visualization in debug panel C1 runs every 8th pipeline call (ringThrottle % 8). Running it every call means 7 full morphErode passes per frame at SW×SH resolution — at stripeW=9, that's roughly 580 million comparisons per frame on the main thread, dropping render FPS to single digits. The throttle is not a bug. Do not remove it without replacing the JS erosion with a GPU approach.
ghostPool is an array of 50 pre-allocated canvases created at startup. The pipeline writes into the pool via index cycling (ghostPoolIdx++ % GHOST_MAX). Creating new canvas elements inside pipeline() causes per-frame GC pressure that accumulates into stutter, especially on mobile. If you need more ghost frames, increase GHOST_MAX and extend the pre-allocation array — do not add document.createElement('canvas') calls inside the hot path.
mp.onResults() fires asynchronously and stores the mask in pendingMask. The main loop() checks pendingMask each tick and processes it if present. This decoupling is what allows the render loop to run at 60fps while segmentation runs at a lower rate. If you add synchronous work inside mp.onResults(), you block the MediaPipe callback thread and degrade segmentation fps.