Files
appRobotWebcam/doc/07_multipleCam_roadmap.md
2026-06-06 13:12:18 +02:00

254 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AppRobotWebcam Multiple Kameras
> Status: **Implementiert** (Phase 14A). Phase 4B/C offen.
> Aktueller Stand: 3 Kameras (2× C270, 1× C920), gemischte stream-Konfiguration.
---
## Architektur
```
cameras.json
└── server.js → je Eintrag EINE CameraSwitch (besitzt /dev/videoN, On-Demand)
├── stream:true → Viewer zeigt Live-Box → GET /api/stream/:id
└── stream:false → Viewer zeigt Platzhalter (kein Dauer-<img>)
Grab-Pfad identisch: getFrame() / grabHires()
Node.js / Express :8444
├── GET /api/cameras → Metadaten aller Kameras aus cameras.json
├── GET /api/snapshot → Liste mit Metadaten (id, name, position, stream …)
├── GET /api/snapshot/:id → 640er JPEG (on-demand, getFrame)
├── GET /api/snapshot/:id/hires → hires JPEG (grabHires Live kurz pausieren)
├── GET /api/stream/:id → MJPEG multipart/x-mixed-replace (Live)
└── GET /health → Status aller Switches
```
**Eine Kamera = eine `CameraSwitch`.** Es gibt keinen separaten Grab-Pfad für
Live- vs. Snapshot-Kameras. Das Feld `stream` entscheidet nur, ob der Viewer
eine Live-Box aufbaut — der Server behandelt alle Kameras gleich.
**CPU-Verbrauch:** On-Demand — 0 % idle, ~35 %/Kamera nur solange jemand schaut
oder ein Grab läuft. Limit ist USB-Bandbreite + Zahl gleichzeitiger Live-Views,
nicht die Gesamtzahl der Kameras.
---
## `cameras.json`
Einzige Konfigurationsquelle für Geräte, Namen und Auflösungen.
Liegt im Projektverzeichnis, wird beim Start geladen und validiert.
```json
{
"cameras": [
{
"id": "cam0",
"device": "/dev/video0",
"name": "Vorderseite",
"position": "front",
"stream": true,
"hires": true,
"note": "usb-046d_0825_3BB3FE20-video-index0"
},
{
"id": "cam1",
"device": "/dev/video2",
"name": "Links",
"position": "left",
"stream": false,
"hires": true,
"note": "usb-046d_081b_342D4F40-video-index0"
},
{
"id": "cam2",
"device": "/dev/video4",
"name": "Rechts",
"position": "right",
"stream": true,
"hires": true,
"hiresSize": "1920x1080",
"note": "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0"
}
]
}
```
> **cam2 (C920):** Live läuft auf dem globalen Default 640×480 (USB-schonend, flüssig),
> nur der HD-Grab geht auf 1920×1080. `encode`/`hiresEncode` sind **nicht** gesetzt →
> beide nutzen `copybsf` (Kamera-JPEG ohne Re-Encode = beste Standbild-Qualität, s.u.).
> Der Wechsel 640→1920 beim Grab funktioniert direkt; **kein** Warmup/Zwischenformat nötig
> (auf dem Host verifiziert, 2026-06-06). Details: `05_screenShot_roadmap.md`.
### Felder
| Feld | Typ | Pflicht | Bedeutung |
|---|---|---|---|
| `id` | string | ✓ | Stabiler Bezeichner; wird in Dateinamen und API-Pfaden verwendet |
| `device` | string | ✓ | `/dev/videoN` — der Pfad **im Container** (siehe by-id-Mapping unten) |
| `name` | string | | Anzeigename im Viewer (Fallback: `id`) |
| `position` | string | | Frei; wird im Viewer-Label angezeigt (`name · position`) |
| `stream` | bool | | `true` → Live-Box im Viewer; `false` → Platzhalter (Default: `true`) |
| `hires` | bool | | `false` → kein hires-Grab verfügbar, z.B. bei schwacher Kamera (Default: `true`) |
| `liveSize` | string | | z.B. `"1280x720"` — überschreibt globales `LIVE_SIZE`-Env |
| `liveFps` | int | | Überschreibt globales `LIVE_FPS`-Env |
| `hiresSize` | string | | Überschreibt globales `HIRES_SIZE`-Env |
| `hiresFps` | int | | Überschreibt globales `HIRES_FPS`-Env |
| `encode` | string | | `"copybsf"` oder `"mjpeg"` — überschreibt `ENCODE_MODE`-Env (gilt für Live; und für Grab, falls `hiresEncode` fehlt) |
| `hiresEncode` | string | | Encode **nur** für den HD-Grab; überschreibt `encode`. Default = `encode`. Für Standbilder `copybsf` bevorzugen (s.u.) |
| `note` | string | | Freitext; empfohlen: by-id-Name des Geräts (Dokumentation) |
**Globale Env-Defaults** (gelten wenn das Feld in cameras.json fehlt):
`LIVE_SIZE=640x480`, `LIVE_FPS=30`, `HIRES_SIZE=1280x960`, `HIRES_FPS=15`,
`ENCODE_MODE=copybsf`.
---
## Stabile Gerätepfade (`docker-compose.yaml`)
`/dev/videoN` kann nach einem Reboot eine andere Kamera bezeichnen.
Lösung: by-id auf dem **Host** → festes `/dev/videoN` im **Container**.
```bash
ls -la /dev/v4l/by-id/ # zeigt die stabilen Namen
```
In `docker-compose.yaml`:
```yaml
devices:
- /dev/v4l/by-id/usb-046d_0825_3BB3FE20-video-index0:/dev/video0 # cam0
- /dev/v4l/by-id/usb-046d_081b_342D4F40-video-index0:/dev/video2 # cam1
- /dev/v4l/by-id/usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0:/dev/video4 # cam2
```
Der by-id-Name gehört zusätzlich ins `note`-Feld von cameras.json (Dokumentation).
`cameras.json` selbst verwendet `/dev/videoN` — den Pfad den der Container sieht.
---
## MJPEG-Auflösung prüfen
Nicht jede Auflösung ist bei jeder Kamera nativ MJPEG. Nicht-native Auflösungen
erzwingen Software-Re-Encode (~50 % CPU extra).
```bash
v4l2-ctl --list-formats-ext -d /dev/video4 | grep -A 20 MJPG
```
Nur Auflösungen unter `'MJPG'` in `liveSize`/`hiresSize` eintragen.
Falls eine Auflösung nur unter `'YUYV'` erscheint → andere Auflösung wählen.
> **Lehrgeld (2026-06-06):** Beim Einbau der C920 schien 1920×1080 „nur 720 zu liefern".
> Ursache war **nicht** die Kamera (1920×1080 MJPEG ist nativ, auf dem Host verifiziert),
> sondern (a) veralteter Code im Container und (b) ein nachträglich eingebauter
> „Warmup-Zwischenformat"-Schritt, der einen zweiten FFmpeg auf dem Gerät startete
> (`Device or resource busy`). Beides entfernt. Lehre: erst auf dem **Host** mit
> `ffmpeg`/`ffprobe` verifizieren, was die Kamera real liefert, bevor man Code-Theorien baut.
---
## Standbild-Qualität: Encode-Wahl
Eine USB-Kamera liefert das Bild bei `input_format mjpeg` bereits **JPEG-komprimiert**
(Hardware, unvermeidbar). Wie der HD-Grab das ausgibt, entscheidet `hiresEncode`/`encode`:
| Modus | Was passiert | Qualität / Größe | Wann |
|---|---|---|---|
| `copybsf` (Default) | Kamera-JPEG **durchreichen** (`-c:v copy -bsf:v mjpeg2jpeg`), keine zweite Kompression | beste JPEG-Qualität, klein, niedrigste CPU | **Standardwahl, auch für Standbilder** |
| `mjpeg` | Re-Encode (`-c:v mjpeg -q:v 5`) — **zweite** Kompression | sichtbare Artefakte bei Standbildern, ~50 % CPU | nur als Fallback, wenn copybsf bei einer Kamera zickt |
→ Für scharfe Standbilder **`copybsf`** verwenden (kein `hiresEncode` setzen). `mjpeg`
war ein Workaround und erzeugte bei cam2 sichtbare Artefakte (131 kB vs. ~350 kB copybsf).
**Wirklich verlustfrei** ginge nur über das rohe **YUYV**-Format → PNG (kein JPEG-Verlust
überhaupt). Das ist ein eigener Grab-Pfad (große Dateien ~36 MB, langsamer) und noch
**nicht implementiert** — bei Bedarf siehe „Offene Punkte".
---
## USB-Hardware: Bandbreite
Mehrere aktive Live-Streams brauchen genug USB-Kapazität.
| Auflösung | fps | MJPEG-Bitrate | Streams pro USB-2.0-Controller |
|---|---|---|---|
| 640×480 | 30 | ~5 MB/s | 8 (theoretisch), 34 (praktisch) |
| 1280×720 | 30 | ~12 MB/s | 23 |
| 1280×960 | 15 | ~15 MB/s | 23 |
| 1920×1080 | 30 | ~25 MB/s | 12 |
`lsusb -t` zeigt welche Kameras am selben Controller hängen.
Streaming-Kameras (`stream:true`) möglichst auf **getrennten Controllern**.
Snapshot-only-Kameras (`stream:false`) teilen einen Controller problemlos.
---
## Neue Kamera hinzufügen
1. `ls -la /dev/v4l/by-id/` → by-id-Namen identifizieren
2. `v4l2-ctl --list-formats-ext -d /dev/videoN | grep -A 20 MJPG` → Auflösung wählen
3. Eintrag in `cameras.json` ergänzen (id, device, name, position, stream, liveSize …)
4. `docker-compose.yaml` → neues Device (`by-id → /dev/videoN`) eintragen
5. Stack in Portainer redeployen
---
## Phase 4B/C — WebService-Push (offen)
**Erst umsetzen wenn ein konkreter aufrufender Container existiert.**
### Option B — Push-Trigger mit Job-ID
Ein fremder Container löst einen Grab aus; Bilder landen auf einem gemeinsamen Volume.
```
POST /api/snapshot/trigger
Body (optional): { "cameras": ["cam0", "cam2"] } ← leer = alle
Response: { "job": "abc123", "status": "grabbing" }
GET /api/snapshot/job/abc123
Response: { "status": "done", "files": [
{ "id": "cam0", "path": "/snapshots/cam0_hires_1234.jpg" },
{ "id": "cam2", "path": "/snapshots/cam2_hires_1234.jpg" }
]}
```
`docker-compose.yaml` — gemeinsames Volume:
```yaml
volumes:
snapshots:
webcam:
volumes:
- snapshots:/snapshots
homing-service:
volumes:
- snapshots:/snapshots:ro
```
`src/snapshotService.js`: Bilder nach `/snapshots/` schreiben, In-Memory Job-Queue.
**Aufwand:** ~34 h.
### Option C — Synchroner Einzel-Endpunkt
```
GET /api/snapshot/all/hires
Response: JSON { "cam0": "<base64>", "cam2": "<base64>" }
```
Blockiert ~810 s. Nur sinnvoll wenn der Aufrufer synchron warten kann.
**Aufwand:** ~2 h.
---
## Offene Punkte
| Punkt | Priorität | Massnahme |
|---|---|---|
| Phase 4B/C WebService-Push | niedrig | erst wenn aufrufender Container konkret |
| Verlustfreie Standbilder (YUYV→PNG) | niedrig | eigener Grab-Pfad: rohes YUYV lesen → PNG; nur wenn JPEG-Qualität nicht reicht |
| USB-Bandbreite bei >4 aktiven Streams | mittel | `lsusb -t` prüfen, Kameras auf Controller verteilen |
| Stream-Freeze (selten) | niedrig | bekannt; noch kein reproduzierbarer Fall |