227 lines
7.5 KiB
Markdown
227 lines
7.5 KiB
Markdown
# AppRobotWebcam – Multiple Kameras
|
||
|
||
> Status: **Implementiert** (Phase 1–4A). 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,
|
||
"liveSize": "1280x720",
|
||
"liveFps": 30,
|
||
"hiresSize": "1920x1080",
|
||
"hiresFps": 30,
|
||
"note": "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### 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 globales `ENCODE_MODE`-Env |
|
||
| `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.
|
||
|
||
Falls der Stream schwarz bleibt obwohl die Auflösung als MJPG gelistet ist:
|
||
`"encode": "mjpeg"` in cameras.json für diese Kamera erzwingt Re-Encode
|
||
(kompatibel mit jedem Kamera-MJPEG, aber höhere CPU-Last).
|
||
|
||
---
|
||
|
||
## 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), 3–4 (praktisch) |
|
||
| 1280×720 | 30 | ~12 MB/s | 2–3 |
|
||
| 1280×960 | 15 | ~15 MB/s | 2–3 |
|
||
| 1920×1080 | 30 | ~25 MB/s | 1–2 |
|
||
|
||
`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:** ~3–4 h.
|
||
|
||
### Option C — Synchroner Einzel-Endpunkt
|
||
|
||
```
|
||
GET /api/snapshot/all/hires
|
||
Response: JSON { "cam0": "<base64>", "cam2": "<base64>" }
|
||
```
|
||
|
||
Blockiert ~8–10 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 |
|
||
| USB-Bandbreite bei >4 aktiven Streams | mittel | `lsusb -t` prüfen, Kameras auf Controller verteilen |
|
||
| Stream-Freeze (selten) | niedrig | bekannt; noch kein reproduzierbarer Fall |
|