Files
appRobotWebcam/doc/07_multipleCam_roadmap.md
2026-06-05 07:32:05 +02:00

15 KiB
Raw Blame History

⚙ ARCHITEKTUR-UPDATE (2026-06-05) — der Plan wird durch den Node-MJPEG-Schalter EINFACHER

Diese Roadmap wurde für den go2rtc-Aufbau geschrieben. Der ist seit 2026-06-05 ersetzt: Node besitzt alle Kameras selbst (src/cameraSwitch.js, eine CameraSwitch pro Gerät), go2rtc ist weg. Das ändert mehrere Grundannahmen — überwiegend zugunsten weniger Aufwand:

Roadmap-Annahme (alt) Neue Realität Folge für den Plan
Zwei Kamera-Klassen (stream:true via go2rtc, stream:false via eigenem FFmpeg) Eine Klasse: jede Kamera ist eine CameraSwitch mit On-Demand Phase 2 (separater one-shot-Pfad) entfälltgetFrame()/grabHires() gelten für alle
go2rtc-Config parallel pflegen (Redeploy je Kamera) nur cameras.json → erzeugt CameraSwitch-Instanzen Phase 1 wird einfacher, keine Doppelpflege
„~25 % CPU pro Live-Stream, dauerhaft" On-Demand: 0 % idle, ~35 %/Kamera nur während aktiv beobachtet „23 live" kostet nur was, wenn wirklich jemand zuschaut
HD-Grab = „Phase-2-Dance" (Consumer release → cam_hires) grabHires() (Live stoppen → 1280 → zurück), fertig Phase-2-Dance-Beschreibungen sind überholt
ffmpeg im Node-Container „noch zu ergänzen" bereits drin (+ Geräte durchgereicht) Phase-2-Voraussetzung schon erfüllt
stream: false = „kein Live möglich" jede Kamera kann live (On-Demand); stream:false = nur Viewer zeigt keine Live-Box reine Anzeige-Entscheidung, kein anderer Grab-Pfad

Netto: Phasen 1, 3, 4 bleiben sinnvoll (cameras.json/Metadaten, „Snapshot alle", WebService). Phase 2 ist großteils erledigt/obsolet. USB-Bandbreite (unten) gilt unverändert. Details der Architektur: 05_screenShot_roadmap.md (Abschnitt „Node-MJPEG-Schalter"). Die folgenden Abschnitte sind als Konzept erhalten; go2rtc-spezifische Stellen sind inline mit ⚠ markiert.


AppRobotWebcam Multiple Kameras

Status: Konzept (teils überholt, s. Banner oben). Aktuelle Implementierung: Node-MJPEG- Schalter, alle Kameras On-Demand, HD-Grab via grabHires() (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
23 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 Klasse (aktualisiert 2026-06-05)

Eine USB-Kamera kann gleichzeitig nur einmal geöffnet werden — das bleibt der harte Constraint. ⚠ Die frühere Zwei-Klassen-Aufteilung ist mit dem Node-Schalter hinfällig: Jede Kamera ist eine CameraSwitch, die das Gerät on-demand öffnet. Es gibt einen Grab-Pfad für alle:

stream: true stream: false
Was ist es CameraSwitch (wie alle) CameraSwitch (wie alle)
Live-View im Viewer ja (Live-Box) nein (nur Snapshot-Symbol) — reine Anzeige-Wahl
Snapshot 640 getFrame() (startet Gerät on-demand) identisch getFrame()
Hires 1280 grabHires() identisch grabHires()
CPU im Idle 0 % (On-Demand, niemand schaut) 0 %
CPU aktiv ~35 %/Kamera nur solange beobachtet/gegrabbt nur während eines Grabs (~2 s)

stream steuert jetzt nur, ob der Viewer eine Live-Box aufmacht — nicht mehr den Grab-Mechanismus. Das alte „Phase-2-Dance" und der separate FFmpeg-one-shot-Pfad entfallen; der Schalter macht das einheitlich.

Faustregel (unverändert sinnvoll): dauernd zu beobachtende Kameras → stream: true (Live-Box); nur beim Homing/Trigger relevante → stream: false (spart Viewer-Last und Bandbreite, da kein Dauer-<img>).


Kamera-Konfigurationsdatei (cameras.json)

Statt hardcodierter Gerätenamen eine maschinenlesbare Liste:

{
  "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 → Viewer zeigt Live-Box; false → nur Snapshot-Symbol (Grab-Pfad identisch, On-Demand)
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 (aktualisiert 2026-06-05)

cameras.json
    │
    └── server.js erzeugt je Eintrag EINE CameraSwitch (besitzt /dev/videoN, On-Demand)
            │
            ├── stream:true  → Viewer zeigt Live-Box  → GET /api/stream/:id (MJPEG multipart)
            └── stream:false → Viewer zeigt nur Snapshot-Symbol (kein Dauer-<img>)
                 (Grab-Pfad für beide identisch — getFrame / grabHires)

Node.js / Express :8444   (go2rtc ENTFERNT)
    ├── GET /api/cameras            → Liste aus cameras.json (mit Metadaten)   [Phase 4A]
    ├── GET /api/stream/:id         → MJPEG multipart/x-mixed-replace (Live, On-Demand)
    ├── GET /api/snapshot/:id       → 640er JPEG  (getFrame  startet Gerät bei Bedarf)
    ├── GET /api/snapshot/:id/hires → 1280er JPEG (grabHires  Live kurz pausieren)
    └── POST /api/snapshot/all      → alle Kameras grabben, JSON mit Metadaten   [Phase 4B]

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: 34 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
  • Je Eintrag eine CameraSwitch erzeugen (ersetzt das heutige detectDevices()-Mapping). ⚠ Keine go2rtc-Config mehr — die Kamera-Definition lebt nur noch in cameras.json. Geräte müssen weiterhin in docker-compose.yaml durchgereicht werden (devices:).

src/snapshotService.js

  • GET /api/snapshot → liest Kamera-Metadaten aus cameras.json (Felder name, position, stream mitliefern). Die Switch-Map kommt aus server.js.

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) — ⚠ GRÖSSTENTEILS OBSOLET

Hinweis (2026-06-05): Mit dem Node-Schalter ist dieser separate Pfad nicht mehr nötig. Jede CameraSwitch macht Snapshots on-demand (getFrame/grabHires) — egal ob stream:true oder false. ffmpeg im Container + Geräte-Durchreichung sind bereits erledigt. Der einzige offene Teil aus dieser Phase: bei stream:false im Viewer keine Live-Box rendern. Der untenstehende one-shot-Plan ist nur noch historischer Kontext.

Ziel (alt): 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:

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:

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):

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.

Vereinfacht (2026-06-05): kein Unterschied mehr zwischen den Klassen. Alle Kameras nutzen grabHires() (Live kurz pausieren → 1280 → zurück). Da jede CameraSwitch ihr eigenes Gerät besitzt, laufen die Grabs gefahrlos parallel — genau das macht snapshotAllHires() im Viewer heute schon.

public/viewer.jssnapshotAllHires() erweitern:

Promise.allSettled([
  ...streamingCams.map(c => hiresGrab(c)),     // Phase-2-Dance
  ...snapshotOnlyCams.map(c => oneshotGrab(c)) // direkter Fetch
])

Zeitbudget:

  • Streaming-Kameras: ~810 s (release + warmup)
  • Snapshot-only-Kameras: ~23 s (FFmpeg one-shot + warmup)
  • Gesamtdauer: ~810 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:

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: ~34 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 ~810 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_*
CPU bei vielen Kameras niedriger als gedacht On-Demand: nur gleichzeitig beobachtete Streams kosten CPU (~35 %/Kam). Idle = 0 %. Grenze ist die Zahl der parallel offenen Live-Views + USB-Bandbreite, nicht die Gesamtzahl der Kameras
Warmup-Schwarzbild bei Hi-Res bekannt, gelöst CameraSwitch.grabHires verwirft die ersten Frames (settleFrames/minSize)
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 + Switch-Erzeugung)  ~2 h   Grundlage, kein Risiko
    ↓
Phase 2 (Snapshot-only, ffmpeg)            ~0 h   ⚠ erledigt durch Schalter; nur noch:
                                                   Viewer rendert bei stream:false keine Live-Box
    ↓
Phase 3 (Snapshot alle erweitert)          ~1 h   Logik existiert (snapshotAllHires), nur Liste erweitern
    ↓
Phase 4A (GET /api/cameras)                ~1 h   sofort nützlich für andere Container
    ↓
Phase 4B oder 4C                           ~34 h nur wenn Push-Trigger gebraucht wird

Phase 4B/C erst wenn ein konkreter aufrufender Container existiert — nicht auf Vorrat bauen.