# Project export: HiveSight: Real-Time Multi-Camera Spatial Intelligence

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: TreeHacks 2026
- Tagline: A real-time multi-phone camera fusion system that reconstructs a shared 2D map of people and teammates, enabling coordinated tracking for emergency response and tactical teams.
- Devpost: https://devpost.com/software/hivesight-real-time-multi-camera-spatial-intelligence
- GitHub: https://github.com/nayred3/TreeHacks2026
- Demo: https://treehacks2026-j7ti.vercel.app/
- Video: https://www.youtube.com/embed/IQzMgEn3UsE?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 4 GitHub contributor(s) — grawcsun (70 commits), Justin Huang (14 commits), Cursor (13 commits), Logan Wang (4 commits)

## Devpost submission (written by the team)

### Inspiration

HiveSight started with a simple question: how do teams share awareness when visibility is low and information is fragmented? We were inspired by high-stakes situations like firefighters in smoke-filled buildings and police officers coordinating through bodycams—scenarios where communication can be chaotic and each person only sees a small slice of the environment. We wondered what would happen if those individual viewpoints could be combined into one shared, live map. Instead of describing what you see over the radio, what if your team could just see it?

### What it does

HiveSight is a real-time multi-camera spatial awareness system that turns several iPhones into a collaborative tracking network. One iPhone acts as a spatial anchor, while the others act as mobile “bodycams.” Each phone: Determines its relative position to the anchor using Nearby Interaction (UWB) Measures its camera orientation using ARKit Streams its live camera feed via MJPEG to a central HTTPS server On a central computer, we run YOLO with BoT-SORT-ReID to detect and consistently track people across frames—even through partial occlusion or temporary exits from view. We then combine each phone’s detections to estimate the 2D positions of target people. The frontend is a map built with HTML, CSS, and JavaScript that shows team member locations and directions, detected people (including last-seen positions), and shortest paths from each team member to a target. The idea is similar to a minimap in a video game—but with real-world sensor and vision data!

### How we built it

The project had four main components, developed in parallel: Mobile app (Swift) We built a custom iOS app using SwiftUI that uses Nearby Interaction for relative positioning, ARKit for orientation data, and AVFoundation to stream camera feeds via MJPEG. This creates our own lightweight streaming pipeline that updates every video frame. Detection and tracking (Python) On the backend, we used PyTorch with Ultralytics YOLO for person detection and integrated BoT-SORT with ReID for a consistent identity tracking method. OpenCV and NumPy helped with frame handling and preprocessing data. Spatial fusion logic We used bounding box detections to estimate 2D positions of detected people relative to each camera. We also implemented simple persistence logic to maintain “last known” positions when someone left the frame. Frontend visualization (Web stack) We built a 2D map using HTML, CSS, and JavaScript intended to display the final scene. Phones are rendered as dots with direction, and detected individuals are plotted in the space. We also added shortest-path visualization to demonstrate potential tactical use cases.

### Challenges we ran into

Pivoting from laptops to phones Our original plan used laptops as cameras, but we quickly realized they couldn’t provide accurate indoor positioning or orientation data. Switching to iPhones meant rebuilding our streaming pipeline in Swift to ensure a more robust solution. Maintaining consistent IDs Early on, YOLO frequently reassigned IDs when someone left and re-entered the frame. We spent a significant amount of time tuning BoT-SORT-ReID parameters and experimenting with different configurations to improve stability. System integration Each component initially worked independently, and getting everything to operate together in real time was a significant challenge. Much of our hackathon time went into testing, refactoring, and reconnecting pieces, and we ultimately didn’t have the opportunity to fully combine each phone’s data into the live map.

### Accomplishments we're proud of

Learning how to code custom apps with Swift in a weekend Successfully creating a pipeline to perform real-time computer vision across multiple devices Achieving persistent person tracking using computer vision

### What we learned

This project taught us how challenging multi-view spatial reasoning can be in practice, and how finicky tracking systems across devices can be! Other things we learned include: How to connect mobile hardware sensing with ML pipelines How to debug cross-platform issues quickly under hackathon constraints How important clear delegation is in team development Overall, it was a great exercise in full-stack development and rapid prototyping and debugging.

### What's next

If we continue developing HiveSight, we would focus on: Fully integrating camera data into a live global map Utilizing multiple anchor phones or a different tracking system for better positional data Incorporating a data gathering tool for unknown indoor environments Reducing latency for camera streams Experimenting with AR overlays for smart glasses HiveSight is still an early prototype, but we believe its real-world applications would be very impactful!

## README (from the GitHub repository)

# Priority Assignment Engine

## Running the Frontend

1. **Install dependencies:**
   ```bash
   npm install
   ```

2. **Start the dev server:**
   ```bash
   ./node_modules/.bin/vite
   ```

3. **Open in browser:**
   Visit the URL shown in the terminal (typically `http://localhost:5173/`).

## Live Demo

When **LIVE DEMO** is pressed, the frontend polls fusion camera data and plots agents (cameras) and targets (fused tracks) on the map.

### Option A: Real-time camera pipeline (camera position + target detection)

1. **Start the live fusion server** (optionally spawns camera automatically):
   ```bash
   python -m fusion.live_fusion --with-camera
   ```
   With `--with-camera`, live_fusion spawns camera.py (webcam + YOLO + MJPEG stream). Without it, start camera.py separately (see step 2).

2. **Start camera.py** (if not using `--with-camera`):
   ```bash
   python computervision/camera.py --camera-id cam1 --source 0 --show \
       --emit camera+tracks --cam-x 0 --cam-y 0 --yaw-deg 0 --hfov-deg 70 \
       --udp-port 5055 --stream-port 5056
   ```

3. **Start the frontend** (`npm run dev` or `./node_modules/.bin/vite`) and click **LIVE DEMO**.
   The Live Demo page shows camera feeds at the top and the fusion map (agents + targets) below.

### Option B: Simulated data (no camera)

1. **Start the cam_view server** (simulated fusion data):
   ```bash
   python -m fusion.cam_view.app
   ```
   Runs on port 5051 by default.

2. **Start the frontend** and click **LIVE DEMO**.

If no server is running on 5051, the frontend falls back to mock data. The frontend proxies `/api/fusion/map` → `http://127.0.0.1:5051/api/map`. Cameras become agents; fused_tracks become targets. Coordinates are converted from the fusion room (0–12 m × 0–10 m) to the frontend canvas.

## Running the Backend (optional)

```bash
python -m assignment_model.assignment              # Demo simulation
python -m assignment_model.assignment --serve      # Start HTTP + WebSocket server
uvicorn assignment_model.assignment:app --reload --port 8001   # Alternative server launch
```

## Project Structure

```
├── index.html              # HTML entry point
├── main.jsx                # React entry point
├── frontend/
│   ├── App.jsx             # Main React component (UI, controls, tabs)
│   ├── config.js           # World constants, colors, thresholds
│   ├── utils.js            # Math utilities (euclidean, randomWalk)
│   ├── assignment.js       # Priority assignment algorithm (P1/P2/proximity)
│   ├── canvas.js           # Canvas renderer
│   ├── distances.js        # Distance matrix computation
│   └── pathfinding.js      # A* pathfinding
├── assignment_model/
│   ├── assignment.py       # Python entry point
│   ├── engine.py           # Assignment engine
│   ├── server.py           # FastAPI + WebSocket server
│   ├── demo.py             # Demo simulation
│   └── models.py           # Data models
├── package.json
└── vite.config.js
```


## Detected evidence (automated analysis)

Indexed codebase: 76 recognized source files, 493 KB.
- CSS (language) — detected in the code
- Flask (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Swift (language) — detected in the code
- PyTorch (technology) — claimed on Devpost, not found in the code
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (98 of 98)

```
.DS_Store
.gitignore
assignment_model/__init__.py
assignment_model/assignment.py
assignment_model/demo.py
assignment_model/engine.py
assignment_model/models.py
assignment_model/server.py
computervision/.DS_Store
computervision/botsort_reid.yaml
computervision/camera.py
computervision/yolov8n.pt
frontend/.gitignore
frontend/App.jsx
frontend/assignment.js
frontend/canvas.js
frontend/config.js
frontend/distances.js
frontend/eslint.config.js
frontend/index.html
frontend/liveDemo.js
frontend/package.json
frontend/pathfinding.js
frontend/README.md
frontend/src/App.css
frontend/src/App.jsx
frontend/src/index.css
frontend/src/main.jsx
frontend/utils.js
frontend/vercel.json
frontend/vite.config.js
fusion/__init__.py
fusion/cam_view/__init__.py
fusion/cam_view/app.py
fusion/cam_view/static/app.js
fusion/cam_view/static/index.html
fusion/cam_view/static/style.css
fusion/camera_estimator.py
fusion/camera_feed_pipeline.py
fusion/demo_results.py
fusion/distance.py
fusion/fusion_engine.py
fusion/live_fusion.py
fusion/logan_script.py
fusion/mock_person1.py
fusion/projection.py
fusion/README.md
fusion/run_fusion.py
fusion/run_viz.py
fusion/sample_person1_frames.json
fusion/schemas.py
fusion/test_camera_estimator.py
fusion/test_estimator_results.json
fusion/test_fusion.py
fusion/viz/app.py
fusion/viz/README.md
fusion/viz/requirements.txt
fusion/viz/static/app.js
fusion/viz/static/index.html
fusion/viz/static/style.css
fusion/viz/walls.py
fusion/write_mock_person1_data.py
index.html
main.jsx
package.json
phonecamstream/bridge_to_fusion.py
phonecamstream/frame_receiver.py
phonecamstream/PhoneCamStream.xcodeproj/project.pbxproj
PhoneCamStream/PhoneCamStream.xcodeproj/project.pbxproj
phonecamstream/PhoneCamStream.xcodeproj/project.xcworkspace/contents.xcworkspacedata
PhoneCamStream/PhoneCamStream.xcodeproj/project.xcworkspace/contents.xcworkspacedata
PhoneCamStream/PhoneCamStream.xcodeproj/project.xcworkspace/xcuserdata/logan.xcuserdatad/UserInterfaceState.xcuserstate
PhoneCamStream/PhoneCamStream.xcodeproj/xcuserdata/logan.xcuserdatad/xcschemes/xcschememanagement.plist
phonecamstream/PhoneCamStream/AnchorManager.swift
phonecamstream/PhoneCamStream/AnchorView.swift
phonecamstream/PhoneCamStream/ARCameraManager.swift
PhoneCamStream/PhoneCamStream/Assets.xcassets/AccentColor.colorset/Contents.json
PhoneCamStream/PhoneCamStream/Assets.xcassets/AppIcon.appiconset/Contents.json
PhoneCamStream/PhoneCamStream/Assets.xcassets/Contents.json
phonecamstream/PhoneCamStream/CameraManager.swift
phonecamstream/PhoneCamStream/CameraPreview.swift
phonecamstream/PhoneCamStream/ContentView.swift
PhoneCamStream/PhoneCamStream/ContentView.swift
phonecamstream/PhoneCamStream/FrameStreamer.swift
phonecamstream/PhoneCamStream/HeadingTracker.swift
phonecamstream/PhoneCamStream/Info.plist
phonecamstream/PhoneCamStream/MoverPeerClient.swift
phonecamstream/PhoneCamStream/PhoneCamStreamApp.swift
PhoneCamStream/PhoneCamStream/PhoneCamStreamApp.swift
phonecamstream/PhoneCamStream/PositionSender.swift
phonecamstream/PhoneCamStream/StreamingView.swift
phonecamstream/position_receiver.py
phonecamstream/project.yml
phonecamstream/README.md
README.md
scripts/demo.sh
vite.config.js
yolov8n.pt
```

### Dependencies

- frontend/package.json: @eslint/js@^9.39.1, @types/react@^19.2.7, @types/react-dom@^19.2.3, @vitejs/plugin-react@^5.1.1, eslint@^9.39.1, eslint-plugin-react-hooks@^7.0.1, eslint-plugin-react-refresh@^0.4.24, globals@^16.5.0, react@^19.2.0, react-dom@^19.2.0, vite@^7.3.1
- fusion/viz/requirements.txt: flask@>=2.0
- package.json: @vitejs/plugin-react@^4.2.1, react@^18.2.0, react-dom@^18.2.0, vite@^5.0.0

### Recent commits (newest first)

- commit
- Bob speed
- 75
- FAster
- 2
- alc
- jl
- LG
- jl
- Revert "lj"
- lj
- Revert Live Demo 1 Logan/Justin and Bob Higher changes
- lj
- Bob Higher
- Push 20
- feet
- add two agents
- add target
- Add firefighter
- Remove walls

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

### package.json

```
{
  "name": "treehacks2026",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.2.1",
    "vite": "^5.0.0"
  }
}

```

### frontend/package.json

```
{
  "name": "frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^19.2.0",
    "react-dom": "^19.2.0"
  },
  "devDependencies": {
    "@eslint/js": "^9.39.1",
    "@types/react": "^19.2.7",
    "@types/react-dom": "^19.2.3",
    "@vitejs/plugin-react": "^5.1.1",
    "eslint": "^9.39.1",
    "eslint-plugin-react-hooks": "^7.0.1",
    "eslint-plugin-react-refresh": "^0.4.24",
    "globals": "^16.5.0",
    "vite": "^7.3.1"
  }
}

```

### fusion/viz/requirements.txt

```
flask>=2.0

```

### main.jsx

```javascript
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./frontend/App.jsx";

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

```

### assignment_model/server.py

```python
"""
Assignment & Coordination Engine — WebSocket / HTTP Server
==========================================================

Owns: FastAPI app exposing REST and WebSocket endpoints.

Endpoints:
  POST /agents         — update agent position
  POST /targets        — update target position
  DELETE /targets/{id} — remove a target
  GET  /assignments    — get current assignments
  GET  /matrix         — get distance matrix (debug)
  WS   /ws             — real-time push of assignments at 10 Hz

Install: pip install fastapi uvicorn websockets
Run:     uvicorn assignment_model.assignment:app --reload --port 8001
"""

import asyncio
import logging

from .engine import AssignmentEngine

log = logging.getLogger("AssignmentEngine")


def create_app(engine: AssignmentEngine):
    """Creates a FastAPI app wired to the given AssignmentEngine."""
    try:
        from fastapi import FastAPI, WebSocket, WebSocketDisconnect
        from fastapi.middleware.cors import CORSMiddleware
        from pydantic import BaseModel
    except ImportError:
        log.warning("FastAPI not installed. HTTP server unavailable.")
        return None

    app = FastAPI(title="Assignment Engine API", version="1.0")
    app.add_middleware(
        CORSMiddleware,
        allow_origins=["*"],
        allow_methods=["*"],
        allow_headers=["*"],
    )

    connected_websockets: list[WebSocket] = []

    class AgentUpdate(BaseModel):
        agent_id: str
        x: float
        y: float
        max_assignments: int = 1

    class TargetUpdate(BaseModel):
        target_id: int
        x: float
        y: float
        confidence: float = 1.0

    @app.post("/agents")
    async def update_agent(body: AgentUpdate):
        engine.update_agent(body.agent_id, body.x, body.y, body.max_assignments)
        engine.run()
        payload = engine.get_output()
        # Push update to all WS clients
        for ws in connected_websockets:
            try:
                await ws.send_json(payload)
            except Exception:
                pass
        return payload

    @app.post("/targets")
    async def update_target(body: TargetUpdate):
        engine.update_target(body.target_id, body.x, body.y, body.confidence)
        engine.run()
        payload = engine.get_output()
        for ws in connected_websockets:
            try:
                await ws.send_json(payload)
            except Exception:
                pass
        return payload

    @app.delete("/targets/{target_id}")
    async def remove_target(target_id: int):
        engine.remove_target(target_id)
        payload = engine.get_output()
        for ws in connected_websockets:
            try:
                await ws.send_json(payload)
            except Exception:
                pass
        return {"removed": target_id}

    @app.get("/assignments")
    async def get_assignments():
        engine.run()
        return engine.get_output()

    @app.get("/matrix")
    async def get_matrix():
        return engine.get_distance_matrix_output()

    @app.websocket("/ws")
    async def websocket_endpoint(websocket: WebSocket):
        await websocket.accept()
        connected_websockets.append(websocket)
        log.info(f"WS client connected ({len(connected_websockets)} total)")
        try:
            while True:
                # Push at 10 Hz
                engine.run()
                await websocket.send_json(engine.get_output())
                await asyncio.sleep(0.1)
        except WebSocketDisconnect:
            connected_websockets.remove(websocket)
            log.info("WS client disconnected")

    return app

```

### frontend/src/main.jsx

```javascript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from '../App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

```

### frontend/src/App.jsx

```javascript
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'

function App() {
  const [count, setCount] = useState(0)

  return (
    <>
      <div>
        <a href="https://vite.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.jsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  )
}

export default App

```

### fusion/cam_view/app.py

```python
#!/usr/bin/env python3
"""
Camera Perspective Visualization Server.
Shows the map from each camera's point of view — camera centred,
heading pointing up, with all tracked people and other cameras around it.

Run:  python -m fusion.cam_view.app
"""

import sys
import os

ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, ROOT)

from flask import Flask, send_from_directory, jsonify

# Reuse the data-generation logic from the main viz
from fusion.viz.app import get_fusion_data

app = Flask(__name__, static_folder="static", static_url_path="")


@app.route("/")
def index():
    return send_from_directory(app.static_folder, "index.html")


@app.route("/api/map")
def api_map():
    return jsonify(get_fusion_data())


def main():
    port = int(os.environ.get("CAM_VIEW_PORT", 5051))
    print(f"Camera Perspective Viz → http://127.0.0.1:{port}")
    app.run(host="127.0.0.1", port=port, debug=False)


if __name__ == "__main__":
    main()

```

### fusion/viz/app.py

```python
#!/usr/bin/env python3
"""
Local server for the fusion map viz.

Runs the CameraFeedPipeline automatically and serves both:
  - Ground truth (circles) for validation
  - Pipeline output (diamonds) from camera feeds only

Single command:  python -m fusion.viz.app
"""

import sys
import os
import math

# Ensure repo root on path
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, ROOT)

from flask import Flask, send_from_directory, jsonify

from fusion.schemas import CameraState
from fusion.mock_person1 import get_ground_truth_positions
from fusion.camera_estimator import CameraConfig, estimate_position, world_to_bbox
from fusion.camera_feed_pipeline import (
    CameraFeedPipeline,
    simulate_camera_detections,
    STATIC_CAMERAS,
    CONE_RANGE,
    IMAGE_WIDTH,
    IMAGE_HEIGHT,
    HFOV_DEG,
    NUM_PEOPLE,
)
from fusion.viz.walls import WALLS, has_los

app = Flask(__name__, static_folder="static", static_url_path="")

# ── Camera layout ─────────────────────────────────────────────
CAMERA_IDS = ["cam_1", "cam_2", "cam_3"]
CAMERA_STATES = [
    CameraState(agent_id=cc.camera_id, position=[cc.x, cc.y],
                heading=cc.heading_deg, timestamp=0.0)
    for cc in STATIC_CAMERAS
]

NUM_FRAMES = 600   # 20 seconds at 30 fps
FPS = 30.0

# Build CameraConfig objects (re-use from pipeline)
CAMERA_CONFIGS = list(STATIC_CAMERAS)
_CAM_CONFIG_BY_ID = {cc.camera_id: cc for cc in CAMERA_CONFIGS}

# ── Mobile camera: walks a patrol path through the room ───────
MOBILE_CAM_ID = "cam_mobile"

from fusion.mock_person1 import _precompute_walk, _smooth_positions

_MOBILE_RAW = _precompute_walk(
    seed=999,
    start=(8.0, 8.0),
    num_steps=int(FPS * (NUM_FRAMES / FPS)),
    dt=1.0 / FPS,
    speed=1.0,
    wander=0.5,
)
_MOBILE_PATH = _smooth_positions(_MOBILE_RAW, window=11)


def _mobile_camera_state(frame_idx: int) -> tuple:
    n = len(_MOBILE_PATH)
    idx = min(frame_idx, n - 1)
    x, y = _MOBILE_PATH[idx]
    look = min(idx + 3, n - 1)
    if look > idx:
        dx = _MOBILE_PATH[look][0] - x
        dy = _MOBILE_PATH[look][1] - y
        heading_deg = math.degrees(math.atan2(dy, dx)) % 360
    else:
        heading_deg = 0.0
    return (x, y, heading_deg)


ALL_CAMERA_IDS = CAMERA_IDS + [MOBILE_CAM_ID]


# ── Helpers ───────────────────────────────────────────────────

def _cameras_that_see(position, camera_states, walls):
    """Return list of camera IDs that have LOS + FOV + range."""
    half_fov = math.radians(HFOV_DEG / 2.0)
    out = []
    for cs in camera_states:
        cam_pos = (cs.position[0], cs.position[1])
        dx = position[0] - cam_pos[0]
        dy = position[1] - cam_pos[1]
        dist = math.sqrt(dx * dx + dy * dy)
        if dist < 1e-6:
            out.append(cs.agent_id); continue
        if dist > CONE_RANGE:
            continue
        angle_to_target = math.atan2(dy, dx)
        heading_rad = math.radians(cs.heading)
        diff = angle_to_target - heading_rad
        diff = (diff + math.pi) % (2 * math.pi) - math.pi
        if abs(diff) > half_fov:
            continue
        if not has_los(cam_pos, (position[0], position[1]), walls):
            continue
        out.append(cs.agent_id)
    return out


def _build_camera_feeds(ground_truth, camera_configs, walls):
    """
    For each camera, compute bboxes it would see + run estimator.
    Returns dict: camera_id -> { image_width, image_height, detections: [...] }
    """
    feeds = {}
    for cc in camera_configs:
        detections = []
        cam_pos = (cc.x, cc.y)
        for gt in ground_truth:
            pid = gt["id"]
            pos = gt["position"]
            dx = pos[0] - cc.x
            dy = pos[1] - cc.y
            dist = math.sqrt(dx * dx + dy * dy)
            if dist > CONE_RANGE:
                continue
            if not has_los(cam_pos, (pos[0], pos[1]), walls):
                continue
            bbox = world_to_bbox(cc, pos[0], pos[1])
            if bbox is None:
                continue
            bbox = [
                max(0, min(cc.image_width, bbox[0])),
                max(0, min(cc.image_height, bbox[1])),
                max(0, min(cc.image_width, bbox[2])),
                max(0, min(cc.image_height, bbox[3])),
            ]
            est = estimate_position(cc, bbox)
            detections.append({
                "person_id": pid,
                "bbox": [round(b, 1) for b in bbox],
                "estimated_distance": round(est.distance_m, 2),
                "estimated_position": [round(est.world_x, 2), round(est.world_y, 2)],
                "actual_position": [round(pos[0], 2), round(pos[1], 2)],
                "bearing_deg": round(est.bearing_deg, 1),
                "angle_in_fov_deg": round(est.angle_in_fov_deg, 1),
                "uncertainty_m": round(est.uncertainty_m, 2),
                "error_m": round(
                    math.sqrt((est.world_x - pos[0])**2 + (est.world_y - pos[1])**2), 3
                ),
            })
        feeds[cc.camera_id] = {
            "image_width": cc.image_width,
            "image_height": cc.image_height,
            "detections": detections,
        }
    return feeds


def _match_to_persons(fused_tracks_raw, ground_truth):
    """Greedy match each fused track to the nearest ground-truth person."""
    gt_positions = {p["id"]: p["position"] for p in ground_truth}
    used_pids = set()
    fused_tracks = []
    for ft in sorted(fused_tracks_raw, key=lambda f: min(
        (math.sqrt((f["position"][0] - gp[0])**2 + (f["position"][1] - gp[1])**2)
         for gp in gt_positions.values()), default=999,
    )):
        best_pid = None
        best_dist = float("inf")
        for pid, gp in gt_positions.items():
            if pid in used_pids:
                continue
            d = math.sqrt((ft["position"][0] - gp[0])**2 + (ft["position"][1] - gp[1])**2)
            if d < best_dist:
                best_dist = d
                best_pid = pid
        ft
[truncated — 5254 more characters]
```

### fusion/cam_view/static/app.js

```javascript
(function () {
  "use strict";

  // ────────────────────────────────────────────────
  //  Constants
  // ────────────────────────────────────────────────
  var FEED_W = 640, FEED_H = 480;          // simulated camera image dims
  var MAP_W = 600, MAP_H = 500;            // SVG viewport
  var ROOM_X = [0, 12], ROOM_Y = [0, 10]; // room bounds in metres
  var PAD = 40;                            // map padding in px
  var HFOV = 60;
  var HALF_FOV = (HFOV / 2) * Math.PI / 180;
  var CONE_R = 8;                          // camera effective range (m)

  // Person colours (consistent across views)
  var PERSON_COLORS = ["#22c55e", "#3b82f6", "#f59e0b", "#ef4444", "#8b5cf6"];
  var MOBILE_COLOR  = "#f472b6";  // pink for mobile camera

  // ────────────────────────────────────────────────
  //  State
  // ────────────────────────────────────────────────
  var _data = null;
  var _step = 0;
  var _camId = null;
  var _play = null;

  // ────────────────────────────────────────────────
  //  Map coordinate helpers
  // ────────────────────────────────────────────────
  var scaleX = (MAP_W - 2 * PAD) / (ROOM_X[1] - ROOM_X[0]);
  var scaleY = (MAP_H - 2 * PAD) / (ROOM_Y[1] - ROOM_Y[0]);
  var mapScale = Math.min(scaleX, scaleY);

  function m2px(wx, wy) {
    return {
      x: PAD + (wx - ROOM_X[0]) * mapScale,
      y: MAP_H - PAD - (wy - ROOM_Y[0]) * mapScale   // flip y for screen
    };
  }

  function ns(tag) { return document.createElementNS("http://www.w3.org/2000/svg", tag); }

  /**
   * Get camera state for the CURRENT timestep.
   * For mobile cameras, reads from camera_positions in the current frame.
   * For static cameras, returns the initial camera data.
   */
  function getCamAtStep(id, step) {
    if (!_data || !_data.cameras) return null;
    // Find base camera object
    var baseCam = null;
    for (var i = 0; i < _data.cameras.length; i++)
      if (_data.cameras[i].id === id) { baseCam = _data.cameras[i]; break; }
    if (!baseCam) return null;

    // If not mobile, return as-is
    if (!baseCam.mobile) return baseCam;

    // For mobile cameras, overlay per-timestep position/heading
    var ts = _data.timesteps || [];
    var frame = ts[step];
    if (!frame || !frame.camera_positions || !frame.camera_positions[id]) return baseCam;
    var cp = frame.camera_positions[id];
    return {
      id: baseCam.id,
      position: cp.position,
      heading: cp.heading,
      image_width: baseCam.image_width,
      image_height: baseCam.image_height,
      hfov_deg: baseCam.hfov_deg,
      mobile: true,
    };
  }

  function getCam(id) {
    return getCamAtStep(id, _step);
  }

  // ────────────────────────────────────────────────
  //  LOS / FOV helpers
  // ────────────────────────────────────────────────
  function segIntersect(ax, ay, bx, by, cx, cy, dx, dy) {
    function cross(ox, oy, px, py, qx, qy) {
      return (px - ox) * (qy - oy) - (py - oy) * (qx - ox);
    }
    return (cross(ax, ay, bx, by, cx, cy) * cross(ax, ay, bx, by, dx, dy) < 0) &&
           (cross(cx, cy, dx, dy, ax, ay) * cross(cx, cy, dx, dy, bx, by) < 0);
  }

  function rayFirstWall(ox, oy, tx, ty, walls) {
    if (!walls || !walls.length) return null;
    var best = null, bestT = Infinity;
    for (var i = 0; i < walls.length; i++) {
      var w = walls[i];
      var cx = w[0][0], cy = w[0][1], dx = w[1][0], dy = w[1][1];
      var den = (tx - ox) * (dy - cy) - (ty - oy) * (dx - cx);
      if (Math.abs(den) < 1e-10) continue;
      var t = ((cx - ox) * (dy - cy) - (cy - oy) * (dx - cx)) / den;
      var s = ((cx - ox) * (ty - oy) - (cy - oy) * (tx - ox)) / den;
      if (t > 1e-5 && t <= 1 && s >= 0 && s <= 1) {
        if (t < bestT) {
          bestT = t;
          best = { x: ox + t * (tx - ox), y: oy + t * (ty - oy) };
        }
      }
    }
    return best;
  }

  // ────────────────────────────────────────────────
  //  Camera Feed drawing (Canvas)
  // ────────────────────────────────────────────────

  function drawFeed(cam, feed) {
    var canvas = document.getElementById("feed-canvas");
    var ctx = canvas.getContext("2d");
    var w = canvas.width, h = canvas.height;

    // Background gradient (simulates a dark room)
    var grad = ctx.createRadialGradient(w / 2, h / 2, 50, w / 2, h / 2, w * 0.7);
    grad.addColorStop(0, "#1a1a28");
    grad.addColorStop(1, "#08080d");
    ctx.fillStyle = grad;
    ctx.fillRect(0, 0, w, h);

    // Grid overlay (subtle scanlines)
    ctx.strokeStyle = "rgba(60,60,90,0.06)";
    ctx.lineWidth = 0.5;
    for (var gy = 0; gy < h; gy += 40) {
      ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(w, gy); ctx.stroke();
    }
    for (var gx = 0; gx < w; gx += 40) {
      ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, h); ctx.stroke();
    }

    // Centre crosshair
    ctx.strokeStyle = "rgba(99,102,241,0.15)";
    ctx.lineWidth = 0.5;
    ctx.setLineDash([6, 4]);
    ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke();
    ctx.setLineDash([]);

    // FOV edge markers
    ctx.strokeStyle = "rgba(99,102,241,0.25)";
    ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, h); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(w, 0); ctx.lineTo(w, h); ctx.stroke();

    // Camera label
    var camColor = cam.mobile ? MOBILE_COLOR : "rgba(99,102,241,0.5)";
    ctx.fillStyle = camColor;
    ctx.font = "bold 13px 'DM Sans', system-ui, sans-serif";
    ctx.fillText(cam.id.toUpperCase(), 12, 22);

    // Mobile badge
    if (cam.mobile) {
      ctx.fillStyle = "rgba(244,114,182,0.2)";
      ctx.beginPath();
      roundRect(ctx, 12, 28, 60, 16, 3);
      ctx.fill();
      ctx.fillStyle = MOBILE_COLOR;
      ctx.font = "bold 9px 'DM Sans', system-ui, sans-serif";
      ctx.fillText("MOBILE", 18, 40);

      // Position readout
      ctx.fillStyle = "rgba(244,114,182,0.4)";
      ctx.font = "10px 'DM Sans', system-ui, sans-serif";
      ctx.fillText(

[truncated — 21406 more characters]
```

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