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

9.5 KiB
Raw Permalink Blame History

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.

{
  "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.

ls -la /dev/v4l/by-id/    # zeigt die stabilen Namen

In docker-compose.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).

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:

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