Multicam Roadmap
This commit is contained in:
359
doc/07_multipleCam_roadmap.md
Normal file
359
doc/07_multipleCam_roadmap.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# AppRobotWebcam – Multiple Kameras
|
||||
|
||||
> Status: **Konzept**. Aktuelle Implementierung: 2 Streaming-Kameras + HD-Grab via Phase 2
|
||||
> (`05_screenShot_roadmap.md`). Diese Datei beschreibt den Ausbau auf bis zu 10 Kameras.
|
||||
|
||||
---
|
||||
|
||||
## Ziel
|
||||
|
||||
| Anforderung | Detail |
|
||||
|---|---|
|
||||
| Bis zu **10 USB-Kameras** anschliessen | Nur ein Teil streamt live; alle liefern Snapshots |
|
||||
| **2–3 Live-Streams** im Viewer | CPU-Budget: ~25 % pro MJPEG-Stream (gemessen) |
|
||||
| **Alle** Kameras bei „Snapshot alle" | Streaming- und Nur-Snapshot-Kameras |
|
||||
| **Identifizierbare Bilder** | Jede Kamera hat Namen, Position, Rolle – im Dateinamen sichtbar |
|
||||
| **Download im Browser** | wie bisher |
|
||||
| **Später: WebService-Weiterleitung** | Konzept ausarbeiten, noch nicht implementieren |
|
||||
|
||||
---
|
||||
|
||||
## Grundproblem: Zwei Kamera-Klassen
|
||||
|
||||
Eine USB-Kamera kann gleichzeitig nur **einmal** geöffnet werden. go2rtc hält
|
||||
Streaming-Kameras offen. Für Nicht-Streaming-Kameras steht das Gerät dagegen
|
||||
jederzeit frei. Daraus ergeben sich zwei Klassen mit unterschiedlichem Grab-Pfad:
|
||||
|
||||
| Klasse | `stream: true` | `stream: false` |
|
||||
|---|---|---|
|
||||
| In go2rtc-Config | ja (`cam_front`, `cam_front_hires`) | nein |
|
||||
| Live-View im Viewer | ja | nein (nur Snapshot-Symbol) |
|
||||
| Hires-Grab | Phase-2-Dance (release → cam_hires) | FFmpeg one-shot direkt |
|
||||
| CPU im Idle | ~25 % (solange Client verbunden) | 0 % |
|
||||
| Grab-Komplexität | hoch | niedrig |
|
||||
|
||||
**Faustregel:** Kameras, die permanent beobachtet werden müssen → `stream: true`.
|
||||
Kameras, die nur beim Homing / Trigger relevant sind → `stream: false`.
|
||||
|
||||
---
|
||||
|
||||
## Kamera-Konfigurationsdatei (`cameras.json`)
|
||||
|
||||
Statt hardcodierter Gerätenamen eine maschinenlesbare Liste:
|
||||
|
||||
```json
|
||||
{
|
||||
"cameras": [
|
||||
{
|
||||
"id": "cam_front",
|
||||
"device": "/dev/video0",
|
||||
"name": "Vorderseite",
|
||||
"position": "front",
|
||||
"stream": true,
|
||||
"hires": true,
|
||||
"note": "Logitech C270, Arm-Spitze"
|
||||
},
|
||||
{
|
||||
"id": "cam_left",
|
||||
"device": "/dev/video2",
|
||||
"name": "Links",
|
||||
"position": "left",
|
||||
"stream": true,
|
||||
"hires": true,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "cam_top",
|
||||
"device": "/dev/video4",
|
||||
"name": "Draufsicht",
|
||||
"position": "top",
|
||||
"stream": false,
|
||||
"hires": true,
|
||||
"note": "Nur Snapshot, kein Live-Stream"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Felder
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
|---|---|---|
|
||||
| `id` | string | stabiler Bezeichner, wird im Dateinamen verwendet |
|
||||
| `device` | string | `/dev/videoN` — oder besser persistenter Pfad (s.u.) |
|
||||
| `name` | string | Anzeigename im Viewer |
|
||||
| `position` | string | frei; hilfreich für Homing-Auswertung |
|
||||
| `stream` | bool | `true` → Live-Stream in go2rtc; `false` → nur Snapshot |
|
||||
| `hires` | bool | `false` → nur 640er-Snapshot verfügbar (z.B. bei alter Kamera) |
|
||||
| `note` | string | Freitext, erscheint nicht im Viewer |
|
||||
|
||||
### Persistente Gerätenamen (empfohlen)
|
||||
|
||||
`/dev/video0` kann nach Reboot wechseln. Stabiler:
|
||||
|
||||
```
|
||||
/dev/v4l/by-id/usb-Logitech_HD_Webcam_C270_<serial>-video-index0
|
||||
```
|
||||
|
||||
Ausgabe: `ls /dev/v4l/by-id/` auf dem Server.
|
||||
Symlinks auf `/dev/videoN` — einmal prüfen, dann in `cameras.json` eintragen.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Überblick
|
||||
|
||||
```
|
||||
cameras.json
|
||||
│
|
||||
├── stream: true → go2rtc-Config (cam_front, cam_front_hires, …)
|
||||
│ │
|
||||
│ └── Live-View (Browser WS → go2rtc :1984)
|
||||
│ Hires-Grab (Phase-2-Dance)
|
||||
│
|
||||
└── stream: false → kein go2rtc-Eintrag
|
||||
│
|
||||
└── Hires-Grab (FFmpeg one-shot direkt, Node.js)
|
||||
Kein Live-View
|
||||
|
||||
Node.js / Express :8444
|
||||
├── GET /api/cameras → Liste aus cameras.json (mit Metadaten)
|
||||
├── GET /api/snapshot/:id → 640er JPEG (streaming: via go2rtc; non-streaming: one-shot)
|
||||
├── GET /api/snapshot/:id/hires → 1280er JPEG (streaming: Phase-2; non-streaming: one-shot)
|
||||
└── POST /api/snapshot/all → alle Kameras grabben, JSON-Antwort mit Metadaten [Phase 4]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## USB-Hardware: Bandbreite
|
||||
|
||||
**10 Kameras brauchen mehrere USB-Controller.** Faustregel:
|
||||
|
||||
| Auflösung | fps | MJPEG-Bitrate (ca.) | Kameras pro USB-2.0-Controller |
|
||||
|---|---|---|---|
|
||||
| 640×480 | 30 | ~5 MB/s | 8 (theoretisch) |
|
||||
| 1280×960 | 15 | ~15 MB/s | 3 |
|
||||
|
||||
Praktisch: 3–4 Kameras pro Controller ratsam (Headroom für Bursts).
|
||||
`lsusb -t` zeigt die Controller-Topologie.
|
||||
|
||||
Streaming-Kameras (dauernd aktiv) auf **getrennten Controllern** halten.
|
||||
Snapshot-only-Kameras teilen sich einen Controller ohne Probleme (selten aktiv).
|
||||
|
||||
---
|
||||
|
||||
## Phasenplan
|
||||
|
||||
### Phase 1 — `cameras.json` + Viewer-Anpassung
|
||||
|
||||
**Ziel:** bestehende 2 Kameras aus `cameras.json` lesen statt hardcodern.
|
||||
Kein funktionaler Unterschied, aber Grundlage für alles Folgende.
|
||||
|
||||
**Änderungen:**
|
||||
|
||||
**`cameras.json`** (neu, im Projektverzeichnis)
|
||||
- Startet mit den bestehenden cam0/cam1-Einträgen
|
||||
|
||||
**`server.js`**
|
||||
- `cameras.json` beim Start laden, validieren
|
||||
- go2rtc-Config-Block in `docker-compose.yaml` weiterhin manuell pflegen
|
||||
(Redeploy nötig bei Kamera-Änderung — akzeptiert, da Infrastruktur)
|
||||
|
||||
**`src/snapshotService.js`**
|
||||
- `GET /api/snapshot` → liest Kamera-Metadaten aus `cameras.json` statt aus go2rtc
|
||||
- Gefilterte Liste (kein `_hires`) bleibt; Felder `name`, `position`, `stream` mitliefern
|
||||
|
||||
**`public/viewer.js`**
|
||||
- Kamera-Box zeigt `name` + `position` statt rohem `id`
|
||||
- Nicht-Streaming-Kameras (`stream: false`): keine Live-Box, nur Snapshot-Symbol
|
||||
(Platzhalter-Box mit Kamera-Name und einem Snapshot-Button)
|
||||
|
||||
**Dateinamen:** `${cam.id}_hires_${timestamp}.jpg` → bereits korrekt wenn `id` sprechend ist
|
||||
|
||||
**Erfolgskriterium:** Viewer zeigt `name`/`position`, Dateinamen enthalten `id`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Snapshot-only-Kameras (FFmpeg one-shot)
|
||||
|
||||
**Ziel:** Kameras mit `stream: false` können Snapshots liefern, ohne go2rtc zu berühren.
|
||||
|
||||
**Voraussetzung:** `ffmpeg` im Node-Container verfügbar.
|
||||
|
||||
Aktuelles Dockerfile (`dockerfile_inline` in `docker-compose.yaml`) installiert kein ffmpeg.
|
||||
Erweiterung:
|
||||
```dockerfile
|
||||
FROM node:lts-bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /usr/src/app
|
||||
EXPOSE 8444
|
||||
```
|
||||
|
||||
**`docker-compose.yaml`** — Node-Container braucht Gerätezugang:
|
||||
```yaml
|
||||
webcam:
|
||||
devices:
|
||||
- /dev/video4:/dev/video4 # je Snapshot-only-Kamera
|
||||
group_add:
|
||||
- video
|
||||
```
|
||||
|
||||
**`src/snapshotService.js`** — neuer Grab-Pfad für `stream: false`:
|
||||
```
|
||||
GET /api/snapshot/:id → ffmpeg one-shot @ 640×480, -frames:v 1
|
||||
GET /api/snapshot/:id/hires → ffmpeg one-shot @ 1280×960, -vf select=gte(n,15)
|
||||
(ersten 15 Frames verwerfen = Warmup, vgl. 04_*)
|
||||
```
|
||||
|
||||
FFmpeg-Befehl (bewährt, vgl. `04_Delay_roadmap.md`):
|
||||
```bash
|
||||
ffmpeg -f v4l2 -input_format mjpeg -video_size 1280x960 -framerate 15 \
|
||||
-i /dev/video4 -vf select=gte(n\\,15) -frames:v 1 -q:v 2 -f image2 pipe:1
|
||||
```
|
||||
|
||||
**Mutex:** pro `device` (nicht pro `id`), damit parallele Aufrufe auf demselben Gerät
|
||||
nicht kollidieren.
|
||||
|
||||
**Erfolgskriterium:**
|
||||
- `GET /api/snapshot/cam_top/hires` liefert 1280er-JPEG
|
||||
- go2rtc-CPU unverändert (~25 % für Streaming-Kameras)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — „Snapshot alle" inkl. Snapshot-only-Kameras
|
||||
|
||||
**Ziel:** Ein Button-Klick erzeugt Bilder **aller** Kameras gleichzeitig.
|
||||
|
||||
**Streaming-Kameras** (`stream: true`): bisheriger paralleler Phase-2-Dance (bereits implementiert).
|
||||
|
||||
**Snapshot-only-Kameras** (`stream: false`): direkter FFmpeg one-shot, kein „Release" nötig
|
||||
→ können parallel zu den Streaming-Grabs laufen (verschiedene Geräte).
|
||||
|
||||
**`public/viewer.js` — `snapshotAllHires()` erweitern:**
|
||||
```
|
||||
Promise.allSettled([
|
||||
...streamingCams.map(c => hiresGrab(c)), // Phase-2-Dance
|
||||
...snapshotOnlyCams.map(c => oneshotGrab(c)) // direkter Fetch
|
||||
])
|
||||
```
|
||||
|
||||
**Zeitbudget:**
|
||||
- Streaming-Kameras: ~8–10 s (release + warmup)
|
||||
- Snapshot-only-Kameras: ~2–3 s (FFmpeg one-shot + warmup)
|
||||
- Gesamtdauer: ~8–10 s (parallel, limitiert durch Streaming-Kameras)
|
||||
|
||||
**Erfolgskriterium:** Alle Kameras liefern Bilder; Dateinamen enthalten `id`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 (Option) — WebService-Schnittstelle
|
||||
|
||||
#### Option A — Pull: Bestehende REST-Endpunkte (fast fertig)
|
||||
|
||||
Andere Container (gleicher Host, `network_mode: host`) rufen bereits:
|
||||
```
|
||||
GET http://localhost:8444/api/snapshot/cam_front → 640er JPEG
|
||||
GET http://localhost:8444/api/snapshot/cam_front/hires → 1280er JPEG
|
||||
```
|
||||
|
||||
Das funktioniert **jetzt schon**. Einzige Ergänzung nötig:
|
||||
|
||||
```
|
||||
GET /api/cameras → JSON-Liste aller Kameras mit Metadaten
|
||||
```
|
||||
|
||||
Damit kann ein fremder Container die verfügbaren Kameras abfragen und gezielt
|
||||
einzelne Snapshots holen.
|
||||
|
||||
**Aufwand:** ~1 h (neuer Endpunkt, Metadaten aus `cameras.json`).
|
||||
|
||||
---
|
||||
|
||||
#### Option B — Push-Trigger: „Mache Screenshot" für andere Container
|
||||
|
||||
Ein fremder Container ruft einen Endpunkt auf; AppRobotWebcam grabbt alle Kameras
|
||||
und legt die Bilder auf ein **gemeinsames Volume**:
|
||||
|
||||
```
|
||||
POST /api/snapshot/trigger
|
||||
Body (optional): { "cameras": ["cam_front", "cam_top"] } ← leer = alle
|
||||
Response: { "job": "abc123", "status": "grabbing" }
|
||||
|
||||
GET /api/snapshot/job/abc123
|
||||
Response: { "status": "done", "files": [
|
||||
{ "id": "cam_front", "name": "Vorderseite", "path": "/snapshots/cam_front_hires_1234.jpg" },
|
||||
{ "id": "cam_top", "name": "Draufsicht", "path": "/snapshots/cam_top_hires_1234.jpg" }
|
||||
]}
|
||||
```
|
||||
|
||||
**Nötige Änderungen:**
|
||||
|
||||
`docker-compose.yaml` — gemeinsames Volume:
|
||||
```yaml
|
||||
volumes:
|
||||
snapshots: # benanntes Volume
|
||||
|
||||
webcam:
|
||||
volumes:
|
||||
- snapshots:/snapshots
|
||||
homing-service: # der aufrufende Container
|
||||
volumes:
|
||||
- snapshots:/snapshots:ro
|
||||
```
|
||||
|
||||
`src/snapshotService.js`:
|
||||
- Bilder nicht nur als HTTP-Response, sondern auch nach `/snapshots/` schreiben
|
||||
- Job-Queue (In-Memory reicht für Single-Operator-Betrieb): Map von `jobId → status`
|
||||
- Dateiname: `${cam.id}_hires_${timestamp}.jpg`
|
||||
|
||||
**Aufwand:** ~3–4 h.
|
||||
|
||||
**Wann sinnvoll:** wenn der aufrufende Container die Bilder weiterverarbeiten soll
|
||||
(OCR, ArUco-Erkennung, ML) ohne Browser-Interaktion.
|
||||
|
||||
---
|
||||
|
||||
#### Option C — Synchroner All-in-One-Endpunkt (einfachste WebService-Form)
|
||||
|
||||
```
|
||||
GET /api/snapshot/all/hires
|
||||
Response: multipart/form-data mit je einem JPEG pro Kamera
|
||||
ODER: JSON { "cam_front": "<base64>", "cam_top": "<base64>" }
|
||||
```
|
||||
|
||||
Nachteil: Antwort blockiert ~8–10 s (Grab-Dauer). Nur sinnvoll wenn der Aufrufer
|
||||
synchron warten kann und keine grossen Bilder überträgt.
|
||||
|
||||
**Aufwand:** ~2 h.
|
||||
|
||||
---
|
||||
|
||||
## Offene Punkte / Risiken
|
||||
|
||||
| Punkt | Risiko | Umgang |
|
||||
|---|---|---|
|
||||
| Gerätenamen `/dev/videoN` wechseln nach Reboot | mittel | persistente by-id-Pfade in `cameras.json` |
|
||||
| USB-Bandbreite bei >4 Kameras gleichzeitig | mittel | separate USB-Controller; `lsusb -t` prüfen |
|
||||
| ffmpeg im Node-Container (Phase 2) | niedrig | einmalige Dockerfile-Änderung; bewährt in `04_*` |
|
||||
| go2rtc-Config bei >3 Streaming-Kameras | CPU | max. 2–3 `stream: true`; Rest `stream: false` |
|
||||
| Warmup-Schwarzbild bei Snapshot-only (Phase 2) | bekannt | `select=gte(n,15)` bewährt aus `04_*` |
|
||||
| Parallele Grabs auf gleichem Gerät | beherrschbar | Mutex pro Device (nicht pro ID) |
|
||||
| Job-Queue Phase 4B bei mehreren Clients | gering | für Single-Operator akzeptiert; sonst persistente Queue |
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Reihenfolge
|
||||
|
||||
```
|
||||
Phase 1 (cameras.json) ~2 h Grundlage, kein Risiko
|
||||
↓
|
||||
Phase 2 (Snapshot-only, ffmpeg) ~3 h ffmpeg-Abhängigkeit klären
|
||||
↓
|
||||
Phase 3 (Snapshot alle erweitert) ~1 h baut auf Phase 1+2
|
||||
↓
|
||||
Phase 4A (GET /api/cameras) ~1 h sofort nützlich für andere Container
|
||||
↓
|
||||
Phase 4B oder 4C ~3–4 h nur wenn Push-Trigger gebraucht wird
|
||||
```
|
||||
|
||||
Phase 4B/C **erst wenn** ein konkreter aufrufender Container existiert —
|
||||
nicht auf Vorrat bauen.
|
||||
Reference in New Issue
Block a user