Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
# production
/build

# Python sidecar
__pycache__/
*.py[cod]
*.egg-info/
/sidecar/build/
/sidecar/dist/

# misc
.DS_Store
*.pem
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0005-freemocap-sidecar-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ The sidecar is a local Python process exposing:
- `POST http://localhost:8765/session/start` — arms capture, returns `{ "sessionId": "<uuid>", "calibrationId": "<uuid>" }`
- `POST http://localhost:8765/session/stop` — flushes, closes stream

No Docker is required. Users install the Python sidecar via `pip install rowing-tracker-sidecar` (separate PyPI package). The app polls the health endpoint during the readiness gate.
No Docker is required. The sidecar is a repo-owned Python package installed locally with `python -m pip install -e sidecar` unless a public package release is explicitly published. The app polls the health endpoint during the readiness gate.

Port 8765 is the default; configurable in `UserSettings.sidecarPort`.

Expand Down
250 changes: 141 additions & 109 deletions docs/sidecar-local-setup.md
Original file line number Diff line number Diff line change
@@ -1,143 +1,175 @@
# Running the freemocap sidecar locally
# Running the FreeMoCap sidecar locally

This guide covers the freemocap sidecar integration for local development and testing.
This guide covers the Rowing Tracker sidecar used by **Multi-camera sidecar**
capture. The sidecar is a local Python process that exposes the ADR-0005
HTTP/WebSocket contract on localhost.

The package is owned by this repository. It is not currently published on
public PyPI, so do not install it with `pip install rowing-tracker-sidecar`
unless a release note says public publishing has happened.

## Prerequisites

- Python 3.10+ with `venv`
- The app running locally (`npm run dev`)

## Option A — real freemocap sidecar
- The app running locally with `npm run dev`

Install the sidecar from the distribution available to your environment:
## Install from this repository

```bash
python3 -m venv .venv
source .venv/bin/activate
python3 -m venv .venv-sidecar
source .venv-sidecar/bin/activate
python -m pip install --upgrade pip
python -m pip install rowing-tracker-sidecar
rowing-tracker-sidecar --port 8765
python -m pip install -e sidecar
```

If the sidecar package is not available in your environment, use Option B for local app development.

The sidecar exposes:
- `ws://localhost:8765/pose-stream` — streams `KeypointFrame` JSON
- `GET http://localhost:8765/health` — returns `{ status, fps, cameras, schemaVersion }`
- `POST http://localhost:8765/session/start` — arms capture
- `POST http://localhost:8765/session/stop` — flushes and closes

## Option B — minimal mock server (for UI/API dev without hardware)

```python
#!/usr/bin/env python3
"""Minimal sidecar mock — runs without freemocap or cameras."""
import asyncio, json, math, random, time
import websockets
from http.server import BaseHTTPRequestHandler, HTTPServer
import threading

PORT = 8765
FPS = 30

def health():
return {"status": "ready", "fps": FPS, "cameras": 3, "schemaVersion": 2}

class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
body = json.dumps(health()).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def do_POST(self):
body = json.dumps({"sessionId": "mock-session", "calibrationId": "mock-calib"}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *a): pass

async def pose_stream(websocket):
frame_index = 0
while True:
ts = time.time() * 1000
keypoints = [
{"index": i, "x": 50 + math.sin(i * 0.5) * 200,
"y": 500 + math.cos(i * 0.3 + frame_index * 0.05) * 300,
"z": 1000 + random.gauss(0, 20),
"confidence": 0.85 + random.gauss(0, 0.05)}
for i in range(33)
]
frame = {"frameIndex": frame_index, "timestampMs": ts,
"keypoints": keypoints,
"quality": {"trackedCount": 33, "meanConfidence": 0.85,
"reprojectionErrorMm": 1.2, "cameraCount": 3}}
try:
await websocket.send(json.dumps(frame))
except websockets.exceptions.ConnectionClosed:
break
frame_index += 1
await asyncio.sleep(1 / FPS)

async def main():
http = HTTPServer(("", PORT), Handler)
threading.Thread(target=http.serve_forever, daemon=True).start()
print(f"Sidecar mock running on port {PORT}")
async with websockets.serve(pose_stream, "localhost", PORT, path="/pose-stream"):
await asyncio.Future()

asyncio.run(main())
Verify the command is available:

```bash
rowing-tracker-sidecar --help
```

Save as `scripts/sidecar-mock.py` and run:
## Synthetic mode

Synthetic mode runs without cameras or FreeMoCap. Use it for local UI/API
development and CI-style contract checks.

```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install websockets
python scripts/sidecar-mock.py
rowing-tracker-sidecar --source synthetic --port 8765
```

Expected health response:

```bash
curl http://localhost:8765/health
```

```json
{
"status": "ready",
"fps": 30.0,
"cameras": 3,
"schemaVersion": 2,
"source": "synthetic",
"calibrationId": "synthetic-calibration"
}
```

## FreeMoCap recorded-data mode

Recorded-data mode streams FreeMoCap-style 3D output through the same live
sidecar contract. This is useful for validating the Rowing Tracker integration
against real `world-mm-3d` coordinates before a live camera runtime is wired in.

```bash
rowing-tracker-sidecar \
--source freemocap \
--freemocap-data /path/to/freemocap/output/mediapipe_body_3d_xyz.npy \
--camera-count 3 \
--fps 30 \
--port 8765
```

Supported input formats:

- `.json` or `.jsonl` containing ADR-0005-style keypoint frames or raw
`(frames, 33, 4)` arrays
- `.npy` containing `(frames, 33, 4)` FreeMoCap body keypoints; this requires
`numpy` in the sidecar environment
- a directory containing a known FreeMoCap output file such as
`mediapipe_body_3d_xyz.npy`

If you select `--source freemocap` without `--freemocap-data`, health reports
`status: "error"` with diagnostics. That failure is intentional: the current
repo-owned sidecar has a stable adapter boundary for FreeMoCap data, while live
camera capture depends on the FreeMoCap runtime available in the user's
environment.

## Using the sidecar in the app

1. Start the sidecar (real or mock) on port 8765.
2. Open the app at `http://localhost:3000/mocap`.
3. Check **Multi-camera sidecar** — the UI polls health and shows "Sidecar ready — 3 cameras, 30 fps".
4. Click **Start sidecar capture** — the app creates a session with `source=sidecar`, `capturePerspective=sidecar-3d`, and a v2 `PoseFrameStream`.
5. The session detail page opens as normal. Sidecar-3D-only posture faults appear with `severity=pending` until tuned thresholds are defined.
1. Start the sidecar on port `8765`.
2. Start Rowing Tracker with `npm run dev`.
3. Open `http://localhost:3000/mocap`.
4. Check **Multi-camera sidecar**.
5. Wait for `Sidecar ready - 3 cameras, 30 fps`.
6. Click **Start sidecar capture**.
7. Row the session.
8. Click **Stop** and open **View replay**.

Sidecar capture skips browser catch/finish calibration. Calibration traceability
comes from the sidecar's `calibrationId`.

## API endpoints
## Local sidecar contract

| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/api/mocap/sessions/:id/sidecar/connect` | Verify sidecar health and arm capture |
| `GET` | `/api/mocap/sessions/:id/sidecar/status` | Proxy to `localhost:8765/health` |
| `POST` | `/api/mocap/sessions/:id/sidecar/stop` | Stop the sidecar session and flush the stream |

All routes require an authenticated session and an owned `MocapSession`. `connect` also requires the session to be in `capturing` status and the sidecar schema to match `keypointSchemaVersion = 2`.
| `GET` | `/health` | Report readiness, fps, camera count, schema version, source, calibration id, and diagnostics |
| `POST` | `/session/start` | Arm capture and return `{ sessionId, calibrationId }` |
| `POST` | `/session/stop` | Stop capture and flush/close the stream |
| `WS` | `/pose-stream` | Stream one schema-v2 `sidecar-3d` keypoint frame per message |

## PoseFrameStream v2 blob format
The app also proxies lifecycle calls through:

v2 blobs are written when `source=sidecar`. Key differences from v1:

- `keypointSchemaVersion = 2` in header
- `coordinateSpace = "world-mm-3d"`
- Each keypoint is `[x, y, z, confidence]` (4 × Float32 per keypoint, vs 3 × Float32 in v1)
- Header byte 20: `coordinateSpace` (0 = normalized-2d, 1 = world-mm-3d)
- Header byte 21: `cameraCount`
- `calibrationId` is stored on `MocapSession` for traceability
- v1 blobs are unchanged and remain readable
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/api/mocap/sessions/:id/sidecar/connect` | Verify sidecar health and arm capture |
| `GET` | `/api/mocap/sessions/:id/sidecar/status` | Proxy sidecar health |
| `POST` | `/api/mocap/sessions/:id/sidecar/stop` | Stop the sidecar session |

All app routes require an authenticated session and an owned `MocapSession`.
`connect` also requires the app-side session to be in `capturing` status and the
sidecar schema to match `keypointSchemaVersion = 2`.

## Troubleshooting

- **`pip install rowing-tracker-sidecar` cannot find a package**: install from
this repository with `python -m pip install -e sidecar`.
- **`Sidecar not reachable on port 8765`**: start the sidecar, check the port,
and confirm `curl http://localhost:8765/health` works.
- **Wrong port**: run the sidecar with `--port <port>` and configure
`UserSettings.sidecarPort` to the same value.
- **`status: "error"` with FreeMoCap diagnostics**: the FreeMoCap source is not
configured or the data path cannot be read. Pass `--freemocap-data`.
- **Incompatible schema**: Rowing Tracker expects schema version `2`.
- **Missing cameras or calibration**: health should remain `initializing` or
`error`; do not start capture until it is `ready`.
- **Low or zero fps**: reduce camera load, check USB bandwidth, and verify the
source reports a stable fps before recording.
- **Stream errors during capture**: stop the app capture, stop the sidecar, and
restart both. The sidecar logs include session start/stop and WebSocket
connect/disconnect events.

## Hardware-gated smoke test

Use this manual path when a real camera rig and FreeMoCap output are available:

1. Capture or locate a FreeMoCap `(frames, 33, 4)` output file.
2. Start the sidecar in recorded-data mode with that file.
3. Confirm `/health` is `ready`, `schemaVersion` is `2`, and `cameras` matches
the rig.
4. Record a Rowing Tracker sidecar session from `/mocap`.
5. Stop capture and open the replay.
6. Confirm the stored session uses `source=sidecar`,
`capturePerspective=sidecar-3d`, has pose frames, and runs post-session
analysis.

This smoke test is hardware/data-gated and should not block normal CI.

## Privacy and licensing

The sidecar binds to `127.0.0.1` by default and makes no cloud calls. Raw video,
raw keypoints, and reconstructed body geometry stay local unless the user later
chooses separate Rowing Tracker sharing settings.

FreeMoCap is AGPL-licensed. Keep it as a separate local process and document the
dependency boundary before distributing bundled artifacts.

## Tests

```bash
npx tsx --test tests/sidecarCliContract.test.ts
npx tsx --test tests/sidecarPackageInstall.test.ts
npx tsx --test tests/sidecarTracer.test.ts
npx tsx --test tests/freemocapSidecarSource.test.ts
npx tsx --test tests/sidecarMockContract.test.ts
npm run test:e2e -- tests/e2e/mocap-capture.spec.ts
```

`tests/sidecarMockContract.test.ts` uses the in-process `tests/helpers/testSidecarMock.ts` fixture. It installs ADR-0005-compatible health, session/start, session/stop, and pose-stream behavior, persists uploaded v2 bytes to `LocalDiskStorage`, finalizes the blob, and runs post-session analysis without a real freemocap install or camera rig.
44 changes: 44 additions & 0 deletions sidecar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Rowing Tracker Sidecar

Local sidecar service for Rowing Tracker multi-camera mocap capture. It exposes
the ADR-0005 localhost contract expected by the app and streams schema-v2
`sidecar-3d` pose frames.

Install from this repository:

```bash
python3 -m venv .venv-sidecar
source .venv-sidecar/bin/activate
python -m pip install -e sidecar
```

Run the deterministic hardware-free source:

```bash
rowing-tracker-sidecar --source synthetic --port 8765
```

Run recorded FreeMoCap-style output through the same live contract:

```bash
rowing-tracker-sidecar \
--source freemocap \
--freemocap-data /path/to/mediapipe_body_3d_xyz.npy \
--camera-count 3 \
--fps 30 \
--port 8765
```

The service binds to `127.0.0.1` by default and exposes:

- `GET /health`
- `POST /session/start`
- `POST /session/stop`
- `ws://localhost:<port>/pose-stream`

Synthetic mode is hardware-free and intended for development and CI. FreeMoCap
recorded-data mode supports JSON, JSONL, and NPY `(frames, 33, 4)` sources.
Selecting `--source freemocap` without `--freemocap-data` fails readiness with a
clear diagnostic instead of pretending the camera rig is ready.

The sidecar makes no cloud calls and is local-only by default.
25 changes: 25 additions & 0 deletions sidecar/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "rowing-tracker-sidecar"
version = "0.1.0"
description = "Local FreeMoCap-compatible sidecar service for Rowing Tracker mocap capture"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "Rowing Tracker" }]
dependencies = []

[project.optional-dependencies]
freemocap = [
"freemocap>=1.8,<2",
"numpy>=1.24",
]

[project.scripts]
rowing-tracker-sidecar = "rowing_tracker_sidecar.cli:main"

[tool.setuptools.packages.find]
where = ["src"]
3 changes: 3 additions & 0 deletions sidecar/src/rowing_tracker_sidecar/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Rowing Tracker local mocap sidecar."""

__version__ = "0.1.0"
5 changes: 5 additions & 0 deletions sidecar/src/rowing_tracker_sidecar/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .cli import main


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