# Project export: Screen Mosaic

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Turn any collection of screens into one living, reactive canvas, controlled by where you stand, where you look, and where you reach!
- Devpost: https://devpost.com/software/screen-mosaic
- GitHub: https://github.com/micahlai/screen-mosaic
- Video: https://www.youtube.com/embed/9e3s38W8MYw?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — micahlai (33 commits), rubiillee (15 commits), Claude Sonnet 4.6 (3 commits)

## Devpost submission (written by the team)

No Devpost description available.

## README (from the GitHub repository)


![Mosaic](read-me-images/thumbnail.png?v=2)

# Screen Mosaic

🏆 [View on Devpost](https://devpost.com/software/screen-mosaic) · 📺 [Demo video](https://youtu.be/9e3s38W8MYw)

Turn several ordinary displays into one coordinated canvas using a single phone
photo. Each screen shows four ArUco markers in its corners; you photograph them
all from one spot; the host then warps content per-screen so that — viewed from
where the photo was taken — every screen lines up into one continuous image.

## Gallery

<p align="center">
  <img src="read-me-images/2.png" height="200">
  <img src="read-me-images/3.PNG" height="200">
  <img src="read-me-images/4.png" height="200">
  <img src="read-me-images/5.png" height="200">
</p>

## Run the host

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

python mosiac
```

This starts the backend and **two web apps** on `:5003` (LAN URLs are printed):

- **Screen slave** — open `http://<host-ip>:5003/display` on each screen. Each
  browser auto-claims the next display slot (1st → `display_1` with marker IDs
  0–3, 2nd → `display_2` with 4–7, …) and shows its four corner markers.
- **Phone** — open `http://<host-ip>:5003/phone`. Take a photo of all screens,
  then map content onto them.

The phone's live camera (used by live calibration and the hand-tracked
visualizations) needs a **secure context**, so the host also serves HTTPS on
`:5004` (`PORT + 1`) with a self-signed cert — open `https://<host-ip>:5004/phone`
and accept the one-time warning. Everything else works over plain HTTP.

## Phases (switched from the phone)

1. **Calibration** — every screen shows ArUco markers flush in its far corners.
   Tapping *Take Photo* on the phone reveals the markers; the photo is detected,
   each screen's corners are recovered (using the marker corner that touches the
   real screen corner), and any screen that didn't fully make the photo is
   highlighted on that screen with a message.
2. **Mapping** (default) — each screen renders mapped content, projectively
   warped by its photographed corners so skewed screens look straight from the
   camera. Content options:
   - **UV map** — x→red, y→green gradient (default).
   - **Uploaded image** — *Fill* (stretch to the screens' bounding box) or
     *Fit* (preserve aspect ratio).
   - **Visualization** — a live animation rendered server-side (GPU when
     available) at a resolution matching the screens' bounding-box orientation,
     streamed (MJPEG) and warped per screen. Built in:
     - **Particle Flow** — flow-field particles.
     - **Smoke** — a stable-fluids fire/smoke sim.
     - **Charges** (plus **Charges 1** / **Charges 2**, independent copies you
       can tune separately) — magnetic-charge particles that chase a cursor.
     - **Fish Boids** / **Bird Boids** — flocking sims (cohesion / alignment /
       separation + edge avoidance) with a *Normal* and a *Game* mode.

The phone's *Content* dropdown offers UV map / Upload image / Visualization;
picking Visualization reveals a second dropdown populated from whatever is
registered in the `mosiac/visualizations/` package.

### Hand-tracked visualizations (red-sticker CV)

Some visualizations are driven by your hand: put a **red sticker** on it, stream
the phone camera, and the host tracks the largest red blob (HSV thresholding in
`red_tracker.py`) and feeds its position to the sim — the fish flee it like a
predator, the charge particles chase it as the cursor. These vizzes
(**Fish Boids**, **Charges** & its copies) set `NEEDS_PHONE_CAMERA`, so selecting
one auto-starts the phone camera stream (use the HTTPS URL).

A **⭕ Hand ring** toggle appears on the phone for these vizzes to show/hide the
translucent gray ring drawn at the tracked hand position. (An older YOLOv8-pose
tracker, `hands.py`, is still available via `HAND_TRACKER = "yolo"`.)

3. **Live calibration** — a camera continuously watches the screens and updates
   each screen's warp live (default **24 fps**). Start it from the phone's
   **🔴 Live calibration** button, then pick the **camera source**:
   - *Phone camera* — the phone streams its own camera frames to the host.
   - *Server device camera* — the host opens a local camera (`cv2.VideoCapture`).

   Each screen keeps four *smaller* markers on screen (over the content, no ID
   labels) so the camera can track them. If a screen isn't fully visible in a
   frame, its warp holds at the last good value instead of blanking.

   Browsers only allow camera access over a **secure context**, so the host
   serves **HTTPS** with a self-signed cert (accept the one-time warning on each
   device). Toggle with `USE_HTTPS` in `consts.py`.

Tunables in `mosiac/consts.py`: `PORT`, `USE_HTTPS` / `HTTPS_PORT`, `MARKER_PX`,
`LIVE_MARKER_PX`, `LIVE_FPS`, `LIVE_MAX_WIDTH`, `CAMERA_INDEX`; hand tracking:
`HAND_FPS`, `HAND_TRACKER` defaults, `FISH_HAND_MARKER_FRAC` (gray-ring size),
plus the `HAND_*` YOLO options.

### Adding a visualization

Drop a new file in `mosiac/visualizations/` and import it from that package's
`__init__.py`:

```python
# mosiac/visualizations/rings.py
from . import Visualization, register, torch, _DEVICE

@register("rings", "Rings")
class Rings(Visualization):
    def step(self): ...
    def render(self): return frame   # H x W x 3 uint8 BGR
```

It appears in the phone dropdown automatically (`GET /visualizations`) — no
server or frontend changes needed. Preview locally with
`python -m mosiac.visualizations rings`.

The UV domain is the bounding box of all detected screen corners (plus a small
margin), so the gradient/image/particles span only the region the screens cover.

## Layout

| Path | Purpose |
|------|---------|
| `mosiac/` | The host. `python mosiac` runs `__main__` → `server.py`. |
| `mosiac/server.py` | Flask host: both web apps, calibration, mapping, content, hand stream. |
| `mosiac/detector.py` | ArUco/AprilTag detection → grouped, ordered, normalized. |
| `mosiac/red_tracker.py` | Red-sticker hand tracker (HSV blob centroid) — drives hand-tracked vizzes. |
| `mosiac/hands.py` | Alternative YOLOv8-pose hand tracker (`HAND_TRACKER = "yolo"`). |
| `mosiac/visualizations/` | Visualization package: framework in `__init__.py`, one file per viz (`particleflow.py`, `smokesim.py`, `charges*.py`, `fishboids.py`, `birdboids.py`). |
| `tools/` | Standalone analysis utilities (`python -m tools.cli IMAGE`, etc.). |
| `legacy/` | Earlier desktop prototype (`master/`, `slave/`, `shared/`). |

## Tools

```bash
python -m tools.make_test_image            # writes a synthetic test image
python -m tools.cli IMAGE --annotated out.png   # detect + visualize one image
python -m tools.app                        # standalone image-analysis web UI
```

## Notes

Coordinates are always the photo's own space (origin top-left, x right, y down);
no real-world depth/scale/pose is estimated. Marker→display grouping lives in
`detector.DEFAULT_DISPLAY_MAPPING`.


## Detected evidence (automated analysis)

Indexed codebase: 43 recognized source files, 284 KB.
- Flask (technology) — detected in the code
- HTML (language) — detected in the code
- Python (language) — detected in the code
- PyTorch (technology) — detected in the code
- JavaScript (language) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (112 of 112)

```
.DS_Store
.gitignore
calibration_debug/calibration-20260620-124505.json
calibration_debug/calibration-20260620-125012.json
calibration_debug/calibration-20260620-130030.json
calibration_debug/calibration-20260620-130419.json
calibration_debug/calibration-20260620-130429.json
calibration_debug/calibration-20260620-130456.json
calibration_debug/calibration-20260620-130728.json
calibration_debug/calibration-20260620-130749.json
calibration_debug/calibration-20260620-130827.json
calibration_debug/calibration-20260620-132604.json
calibration_debug/calibration-20260620-132649.json
calibration_debug/calibration-20260620-132736.json
calibration_debug/calibration-20260620-132837.json
calibration_debug/calibration-20260620-132924.json
calibration_debug/calibration-20260620-133007.json
calibration_debug/calibration-20260620-133058.json
calibration_debug/calibration-20260620-133108.json
calibration_debug/calibration-20260620-133250.json
calibration_debug/calibration-20260620-133418.json
calibration_debug/calibration-20260620-133451.json
calibration_debug/calibration-20260620-133647.json
calibration_debug/calibration-20260620-133706.json
calibration_debug/calibration-20260620-133713.json
calibration_debug/calibration-20260620-134615.json
calibration_debug/calibration-20260620-134706.json
calibration_debug/calibration-20260620-134947.json
calibration_debug/calibration-20260620-135001.json
calibration_debug/calibration-20260620-135409.json
calibration_debug/calibration-20260620-135424.json
calibration_debug/calibration-20260620-135620.json
calibration_debug/calibration-20260620-135731.json
calibration_debug/calibration-20260620-135742.json
calibration_debug/calibration-20260620-135815.json
calibration_debug/calibration-20260620-135828.json
calibration_debug/calibration-20260620-135845.json
calibration_debug/calibration-20260620-140022.json
calibration_debug/calibration-20260620-140041.json
calibration_debug/calibration-20260620-140107.json
calibration_debug/calibration-20260620-140135.json
calibration_debug/calibration-20260620-140147.json
calibration_debug/calibration-20260620-140158.json
calibration_debug/calibration-20260620-140230.json
calibration_debug/calibration-20260620-140317.json
calibration_debug/calibration-20260620-140345.json
calibration_debug/calibration-20260620-140358.json
calibration_debug/calibration-20260620-140501.json
calibration_debug/calibration-20260620-140551.json
calibration_debug/calibration-20260620-140655.json
calibration_debug/calibration-20260620-140722.json
calibration_debug/calibration-20260620-140828.json
calibration_debug/calibration-20260620-141539.json
calibration_debug/calibration-20260620-141559.json
calibration_debug/calibration-20260620-141611.json
calibration_debug/calibration-20260620-141620.json
calibration_debug/calibration-20260620-141640.json
calibration_debug/calibration-20260620-141656.json
calibration_debug/calibration-20260620-141734.json
calibration_debug/calibration-20260620-141755.json
legacy/master/mock_master.py
legacy/master/visualization.py
legacy/shared/transform.py
legacy/slave/calibration.py
legacy/slave/slave.py
legacy/test_transform.py
mosiac/__init__.py
mosiac/__main__.py
mosiac/consts.py
mosiac/detector.py
mosiac/hands.py
mosiac/red_tracker.py
mosiac/server.py
mosiac/visualizations/__init__.py
mosiac/visualizations/__main__.py
mosiac/visualizations/birdboids.py
mosiac/visualizations/charges.py
mosiac/visualizations/charges1.py
mosiac/visualizations/charges2.py
mosiac/visualizations/fishboids.py
mosiac/visualizations/gradients.py
mosiac/visualizations/gradients/gradient1_heatmap.json
mosiac/visualizations/gradients/gradient2_ice.json
mosiac/visualizations/gradients/gradient3_fire.json
mosiac/visualizations/particleflow.py
mosiac/visualizations/smokesim.py
read-me-images/.DS_Store
README.md
requirements.txt
tools/__init__.py
tools/.DS_Store
tools/app.py
tools/cli.py
tools/hand-tracking/.DS_Store
tools/hand-tracking/camera_manager.py
tools/hand-tracking/gestures.py
tools/hand-tracking/hand_state.py
tools/hand-tracking/hand_tracker.py
tools/hand-tracking/main.py
tools/hand-tracking/models/hand_landmarker.task
tools/hand-tracking/particles.py
tools/hand-tracking/README.md
tools/hand-tracking/renderer.py
tools/hand-tracking/requirements.txt
tools/hand-tracking/settings.py
tools/make_test_image.py
tools/visualizations-test/boids.html
tools/visualizations-test/boids.py
tools/visualizations-test/ghost_trail_v2.html
tools/visualizations-test/ghost_trail_v2.py
tools/visualizations-test/ghost_trail.html
tools/visualizations-test/ghost_trail.py
```

### Dependencies

- requirements.txt: cryptography, flask, flask-sock, numpy, opencv-contrib-python@>=4.7, pillow, torch, ultralytics
- tools/hand-tracking/requirements.txt: mediapipe@>=0.10.14, numpy@>=1.26.0, opencv-python@>=4.9.0, pygame@>=2.5.2

### Recent commits (newest first)

- birds update
- links
- huh
- server cache readme
- Update README with project description
- update thumbnail
- Change project title to 'Screen Mosaic'
- update readme images
- update gitignore
- update readme
- docs: update README for hand-tracked vizzes, ports, and layout
- more charge sims
- ring toggle option
- ui
- opt rendering
- update smokesim
- client side photo update
- client side photo
- fish boid param adjust
- fish boid params

## Key source files (fetched from GitHub, selected and truncated for size)

### requirements.txt

```
opencv-contrib-python>=4.7
numpy
flask
pillow
torch
flask-sock
cryptography
ultralytics

```

### tools/hand-tracking/requirements.txt

```
opencv-python>=4.9.0
mediapipe>=0.10.14
numpy>=1.26.0
pygame>=2.5.2

```

### tools/cli.py

```python
"""
Command-line interface for the multi-display marker detector.

Usage:
    python cli.py IMAGE [--corner-mode center|inner|outer]
                        [--dictionary NAME] [--annotated OUT.png]
                        [--full]

By default prints the spec-shaped JSON (image_size + displays). Use --full to
include per-marker diagnostics.
"""

import argparse
import json
import sys

import cv2

from mosiac import detector


def _spec_output(result: dict) -> dict:
    """Trim the analysis to the exact output schema from the spec."""
    return {
        "image_size": result["image_size"],
        "displays": [
            {"id": d["id"], "corners": d["corners"]}
            for d in result["displays"]
            if d.get("complete")
        ],
    }


def annotate(image, result):
    """Draw detected markers and display quadrilaterals onto a copy of image."""
    import numpy as np

    out = image.copy()
    h, w = image.shape[:2]

    for m in result["markers"]:
        pts = np.array(m["corners"], dtype=np.int32)
        cv2.polylines(out, [pts], True, (0, 255, 0), 2)
        cx, cy = int(m["center"][0]), int(m["center"][1])
        cv2.circle(out, (cx, cy), 4, (0, 0, 255), -1)
        cv2.putText(out, str(m["id"]), (cx + 6, cy - 6),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)

    for d in result["displays"]:
        if not d.get("complete"):
            continue
        quad = np.array(
            [
                [d["corners"][slot][0] * w, d["corners"][slot][1] * h]
                for slot in detector.CORNER_SLOTS
            ],
            dtype=np.int32,
        )
        cv2.polylines(out, [quad], True, (255, 128, 0), 3)
        tl = quad[0]
        cv2.putText(out, d["id"], (tl[0], tl[1] - 12),
                    cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 128, 0), 2)
    return out


def main(argv=None):
    parser = argparse.ArgumentParser(description="Detect markers and build display quads.")
    parser.add_argument("image", help="Path to the input image")
    parser.add_argument("--corner-mode", choices=["center", "inner", "outer"],
                        default="center", help="How to derive each screen corner")
    parser.add_argument("--dictionary", default=None,
                        help="Force a marker dictionary (default: auto-detect)")
    parser.add_argument("--annotated", default=None,
                        help="Write an annotated visualization image to this path")
    parser.add_argument("--full", action="store_true",
                        help="Print full diagnostics instead of just the spec output")
    args = parser.parse_args(argv)

    image = cv2.imread(args.image)
    if image is None:
        print(f"error: could not read image: {args.image}", file=sys.stderr)
        return 1

    result = detector.analyze(
        image, dictionary=args.dictionary, corner_mode=args.corner_mode
    )

    if args.annotated:
        cv2.imwrite(args.annotated, annotate(image, result))

    print(json.dumps(result if args.full else _spec_output(result), indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

```

### tools/app.py

```python
"""
Flask web app for the multi-display marker detector.

Upload a photo of one or more displays (each showing four corner markers) and
get back the spec-shaped JSON plus an annotated visualization.

Run:
    python app.py
    # open http://127.0.0.1:5000
"""

import base64
import io
import json

import cv2
import numpy as np
from flask import Flask, jsonify, render_template_string, request

from mosiac import detector
from .cli import _spec_output, annotate

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 32 * 1024 * 1024  # 32 MB uploads

PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Display Marker Detector</title>
<style>
  :root { color-scheme: dark; }
  * { box-sizing: border-box; }
  body { font-family: -apple-system, system-ui, sans-serif; margin: 0;
         background: #0d1117; color: #e6edf3; }
  header { padding: 24px 32px; border-bottom: 1px solid #30363d; }
  h1 { margin: 0; font-size: 20px; }
  p.sub { margin: 4px 0 0; color: #8b949e; font-size: 13px; }
  main { display: grid; grid-template-columns: 360px 1fr; gap: 0;
         height: calc(100vh - 73px); }
  aside { padding: 24px 32px; border-right: 1px solid #30363d; overflow-y: auto; }
  section.view { padding: 24px 32px; overflow-y: auto; }
  label { display: block; font-size: 12px; color: #8b949e; margin: 16px 0 6px;
          text-transform: uppercase; letter-spacing: .04em; }
  select, input[type=file] { width: 100%; padding: 8px; background: #161b22;
          border: 1px solid #30363d; color: #e6edf3; border-radius: 6px; }
  button { margin-top: 20px; width: 100%; padding: 10px; border: 0;
           border-radius: 6px; background: #238636; color: #fff; font-weight: 600;
           cursor: pointer; font-size: 14px; }
  button:disabled { opacity: .5; cursor: default; }
  img { max-width: 100%; border-radius: 8px; border: 1px solid #30363d; }
  pre { background: #161b22; border: 1px solid #30363d; border-radius: 8px;
        padding: 16px; overflow-x: auto; font-size: 12px; line-height: 1.5;
        margin-top: 20px; }
  .stat { display: inline-block; background: #161b22; border: 1px solid #30363d;
          border-radius: 6px; padding: 6px 10px; margin: 4px 8px 4px 0; font-size: 12px; }
  .empty { color: #8b949e; font-size: 14px; margin-top: 40px; text-align: center; }
  .err { color: #f85149; margin-top: 16px; font-size: 13px; }
</style>
</head>
<body>
<header>
  <h1>Display Marker Detector</h1>
  <p class="sub">Detect ArUco / AprilTag markers, group into displays, return normalized screen quads.</p>
</header>
<main>
  <aside>
    <form id="form">
      <label for="file">Image</label>
      <input id="file" name="file" type="file" accept="image/*" required>
      <label for="corner_mode">Screen corner source</label>
      <select id="corner_mode" name="corner_mode">
        <option value="center">Marker center</option>
        <option value="inner">Inner marker corner</option>
        <option value="outer">Outer marker corner</option>
      </select>
      <label for="dictionary">Marker dictionary</label>
      <select id="dictionary" name="dictionary">
        <option value="">Auto-detect</option>
        {% for name in dictionaries %}
        <option value="{{ name }}">{{ name }}</option>
        {% endfor %}
      </select>
      <button id="go" type="submit">Analyze</button>
      <div id="err" class="err"></div>
    </form>
  </aside>
  <section class="view">
    <div id="stats"></div>
    <div id="result"><p class="empty">Upload an image to begin.</p></div>
    <pre id="json" style="display:none"></pre>
  </section>
</main>
<script>
const form = document.getElementById('form');
const go = document.getElementById('go');
const err = document.getElementById('err');
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  err.textContent = '';
  go.disabled = true; go.textContent = 'Analyzing…';
  try {
    const res = await fetch('/analyze', { method: 'POST', body: new FormData(form) });
    const data = await res.json();
    if (!res.ok) throw new Error(data.error || 'Request failed');
    render(data);
  } catch (ex) {
    err.textContent = ex.message;
  } finally {
    go.disabled = false; go.textContent = 'Analyze';
  }
});
function render(data) {
  const stats = document.getElementById('stats');
  const complete = data.full.displays.filter(d => d.complete).length;
  stats.innerHTML =
    `<span class="stat">${data.full.marker_count} markers</span>` +
    `<span class="stat">${complete} displays</span>` +
    `<span class="stat">dict: ${data.full.dictionary || 'none'}</span>` +
    `<span class="stat">${data.full.image_size.width}×${data.full.image_size.height}</span>`;
  document.getElementById('result').innerHTML =
    `<img src="data:image/png;base64,${data.annotated}">`;
  const j = document.getElementById('json');
  j.style.display = 'block';
  j.textContent = JSON.stringify(data.spec, null, 2);
}
</script>
</body>
</html>
"""


@app.get("/")
def index():
    names = [n for n, _ in detector.CANDIDATE_DICTIONARIES]
    return render_template_string(PAGE, dictionaries=names)


@app.post("/analyze")
def analyze_route():
    file = request.files.get("file")
    if file is None or file.filename == "":
        return jsonify({"error": "No image uploaded"}), 400

    corner_mode = request.form.get("corner_mode", "center")
    dictionary = request.form.get("dictionary") or None

    data = np.frombuffer(file.read(), np.uint8)
    image = cv2.imdecode(data, cv2.IMREAD_COLOR)
    if image is None:
        return jsonify({"error": "Could not decode image"}), 400

    result = detector.analyze(image, dictionary=dictionary, corner_mode=corner_mode)
    annotated = annotate(image, result)
    ok, buf = cv2.imencode(".png", annotated)
    annotated_b64 = base64.b64encode(buf).decode("ascii") if ok else ""

    return jsonify(
        {
            "spec": _spec_output(result),
            "full
[truncated — 250 more characters]
```

### tools/hand-tracking/main.py

```python
"""
main.py - Entry point for the Hand-Tracked Interactive Visualizer.

Pipeline:
    Camera Manager -> MediaPipe Tracker -> Hand State Processor ->
    Gesture Recognizer -> Particle System -> Renderer

Run with:
    python main.py

Controls:
    ESC or Q  -> quit
    Close either window also quits
"""

import time

import cv2

from camera_manager import CameraManager
from gestures import Gesture, GestureRecognizer
from hand_state import HandStateManager
from hand_tracker import FINGERTIPS, HAND_CONNECTIONS, HandTracker
from particles import ParticleSystem
from renderer import Renderer
from settings import SETTINGS

BOX_COLOR = (255, 200, 80)
LANDMARK_COLOR = (80, 220, 255)
LINE_COLOR = (180, 180, 180)


def draw_camera_overlay(frame, detections):
    """Annotates the raw camera frame with boxes, landmarks, connections, labels."""
    for det in detections:
        x_min, y_min, x_max, y_max = det.bbox
        cv2.rectangle(frame, (x_min, y_min), (x_max, y_max), BOX_COLOR, 2)

        pts = det.landmarks_px.astype(int)
        for a, b in HAND_CONNECTIONS:
            cv2.line(frame, tuple(pts[a]), tuple(pts[b]), LINE_COLOR, 1)
        for i, pt in enumerate(pts):
            radius = 5 if i in FINGERTIPS else 3
            cv2.circle(frame, tuple(pt), radius, LANDMARK_COLOR, -1)

        label = f"{det.label} ({det.score:.2f})"
        cv2.putText(
            frame, label, (x_min, max(y_min - 10, 15)),
            cv2.FONT_HERSHEY_SIMPLEX, 0.6, BOX_COLOR, 2,
        )
    return frame


def main():
    settings = SETTINGS

    camera = CameraManager(settings.camera)
    tracker = HandTracker(settings.tracker)
    state_manager = HandStateManager(settings.motion)
    gesture_recognizer = GestureRecognizer(settings.gesture)
    particle_system = ParticleSystem(settings.particle)
    renderer = Renderer(settings.render, (settings.camera.width, settings.camera.height))

    landmarks_lookup = {}   # label -> latest normalized (21,3) landmarks, for gesture math
    fps_smooth = 30.0
    prev_time = time.time()

    print("Hand-Tracked Interactive Visualizer running. Press ESC / Q to quit.")

    try:
        while True:
            ok, frame, _ts = camera.read()
            if not ok:
                print("Camera read failed, stopping.")
                break

            detections = tracker.process(frame)
            for det in detections:
                landmarks_lookup[det.label] = det.landmarks

            hand_states = state_manager.update(detections)

            gestures = {}
            for label, state in hand_states.items():
                norm_lm = landmarks_lookup.get(label)
                if norm_lm is not None and state.landmarks_px is not None:
                    gestures[label] = gesture_recognizer.recognize(state, norm_lm)
                else:
                    gestures[label] = Gesture.UNKNOWN

            for state in hand_states.values():
                if state.landmarks_px is None:
                    continue
                for tip in FINGERTIPS:
                    particle_system.spawn_from_fingertip(state.landmarks_px[tip], state.velocity)

            particle_system.apply_hand_forces(hand_states, gestures)
            particle_system.update()

            hand_distance = state_manager.hand_distance()

            now = time.time()
            dt = max(now - prev_time, 1e-6)
            fps_smooth = 0.9 * fps_smooth + 0.1 * (1.0 / dt)
            prev_time = now

            renderer.render(hand_states, gestures, particle_system, hand_distance, fps_smooth)
            if renderer.poll_quit():
                break

            if settings.show_camera_window:
                annotated = draw_camera_overlay(frame.copy(), detections)
                cv2.putText(
                    annotated, f"FPS: {fps_smooth:.1f}", (10, 25),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2,
                )
                cv2.imshow("Camera Feed", annotated)
                key = cv2.waitKey(1) & 0xFF
                if key in (ord("q"), 27):
                    break

            renderer.tick(settings.max_render_fps)

    finally:
        camera.release()
        tracker.close()
        renderer.close()
        cv2.destroyAllWindows()
        print("Shut down cleanly.")


if __name__ == "__main__":
    main()

```

### tools/__init__.py

```python
"""Standalone analysis utilities (run with `python -m tools.<name>`)."""

```

### mosiac/__init__.py

```python
"""Mosiac — multi-display calibration + mapping host.

Run the host (both web apps) with:  python mosiac
"""

```

### mosiac/__main__.py

```python
"""Entry point so `python mosiac` launches the calibration + mapping host."""

try:                       # `python -m mosiac` (package context)
    from .server import main
except ImportError:        # `python mosiac` (directory on sys.path)
    from server import main

if __name__ == "__main__":
    main()

```

### legacy/test_transform.py

```python
import cv2
import numpy as np
import sys
sys.path.insert(0, '.')
from shared.transform import warp_master_to_slave

master = np.zeros((1080, 1920, 3), dtype=np.uint8)
master[:, :640] = [255, 0, 0]
master[:, 640:1280] = [0, 255, 0]
master[:, 1280:] = [0, 0, 255]

# Change these corners to test different slices:
# Left third:   [[0.0, 0.0], [0.333, 0.0], [0.333, 1.0], [0.0, 1.0]]
# Middle third: [[0.333, 0.0], [0.666, 0.0], [0.666, 1.0], [0.333, 1.0]]
# Right third:  [[0.666, 0.0], [1.0, 0.0], [1.0, 1.0], [0.666, 1.0]]
corners_uv = [[0.333, 0.0], [0.666, 0.0], [0.666, 1.0], [0.333, 1.0]]

result = warp_master_to_slave(master, corners_uv, 960, 540)
cv2.imshow('Transform Test', result)
cv2.waitKey(0)
cv2.destroyAllWindows()

```

### tools/make_test_image.py

```python
"""
Generate a synthetic test image: three displays, each with four AprilTag
markers (one per corner), placed and slightly rotated on a photo-like canvas.
Used to exercise the detection pipeline without a real photo.

Usage: python make_test_image.py [out.png]
"""

import sys

import cv2
import numpy as np

from mosiac import detector

DICT = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_APRILTAG_36h11)


def marker_img(marker_id, size=120):
    img = cv2.aruco.generateImageMarker(DICT, marker_id, size)
    return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)


def place_display(canvas, marker_ids, origin, w, h, angle_deg, ms=120):
    """Place 4 markers at the corners of a w*h rect, rotated about its center."""
    ox, oy = origin
    cx, cy = ox + w / 2, oy + h / 2
    theta = np.radians(angle_deg)
    rot = np.array([[np.cos(theta), -np.sin(theta)],
                    [np.sin(theta), np.cos(theta)]])
    # corner anchor points (top-left of each marker), clockwise from TL
    anchors = [
        (ox, oy),
        (ox + w - ms, oy),
        (ox + w - ms, oy + h - ms),
        (ox, oy + h - ms),
    ]
    for mid, (ax, ay) in zip(marker_ids, anchors):
        m = marker_img(mid, ms)
        pts = np.array([[ax, ay], [ax + ms, ay], [ax + ms, ay + ms], [ax, ay + ms]],
                       dtype=np.float32)
        rotated = (rot @ (pts - [cx, cy]).T).T + [cx, cy]
        src = np.array([[0, 0], [ms, 0], [ms, ms], [0, ms]], dtype=np.float32)
        H = cv2.getPerspectiveTransform(src, rotated.astype(np.float32))
        warped = cv2.warpPerspective(m, H, (canvas.shape[1], canvas.shape[0]),
                                     borderValue=(255, 255, 255))
        mask = cv2.warpPerspective(np.ones_like(m) * 255, H,
                                   (canvas.shape[1], canvas.shape[0]))
        canvas[mask[:, :, 0] > 128] = warped[mask[:, :, 0] > 128]


def main(out="test_image.png"):
    canvas = np.full((1400, 1800, 3), 40, dtype=np.uint8)
    place_display(canvas, [0, 1, 2, 3], (120, 120), 500, 360, angle_deg=4)
    place_display(canvas, [4, 5, 6, 7], (760, 150), 520, 380, angle_deg=-3)
    place_display(canvas, [8, 9, 10, 11], (400, 700), 700, 480, angle_deg=2)
    cv2.imwrite(out, canvas)
    print(f"wrote {out} ({canvas.shape[1]}x{canvas.shape[0]})")


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "test_image.png")

```

[32 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]