6.9 KiB
AppRobotWebcam
Webcam-Service für den AppRobot. Liefert Live-MJPEG-Streams und HD-Standbilder über einen einzelnen HTTP-Port — als Docker-Container, ohne externe Streaming-Server.
Was es tut
| Live-Stream | MJPEG multipart im Browser <img>, ~139 ms Latenz |
| HD-Snapshot | Ein JPEG pro Kamera auf Knopfdruck oder per HTTP GET |
| Snapshot alle | Alle Kameras parallel in einem Schritt |
| REST-API | Kameraliste, Snapshots, Streams — für andere Container nutzbar |
Kameras (aktuell)
| ID | Modell | Live | HD-Grab |
|---|---|---|---|
| cam0 | Logitech C270 | 640×480 | 1280×960 |
| cam1 | Logitech C270 | 640×480 | 1280×960 |
| cam2 | Logitech C920 | 640×480 | 1920×1080 |
Konfiguration ausschliesslich über cameras.json — kein Redeploy bei Kamera-Änderungen.
Zugriff
http://<host>:8444/ Viewer
http://<host>:8444/api/stream/cam0 Live-MJPEG
http://<host>:8444/api/snapshot/cam0 640er JPEG
http://<host>:8444/api/snapshot/cam0/hires HD-JPEG
http://<host>:8444/api/cameras Kamera-Metadaten (JSON)
http://<host>:8444/health Status
API-Referenz
Basis-URL: http://<host>:8444
GET /api/cameras
Vollständige Kamera-Metadaten. Primärer Einstiegspunkt für andere Container.
{
"cameras": [
{
"id": "cam0",
"name": "Kamera 0",
"position": "front",
"stream": true,
"hires": true,
"encode": "copybsf",
"mseCodec": null,
"note": "usb-046d_0825_3BB3FE20-video-index0",
"calibrationUrl": "/api/cameras/cam0/calibration"
}
]
}
calibrationUrl fehlt, solange noch keine .npz für diese Kamera vorhanden ist.
encode: "copybsf" (MJPEG-Copy, Default) | "mjpeg" (Re-Encode) | "h264" (GPU, MSE).
mseCodec: nur bei encode="h264" gesetzt, z.B. "avc1.4D001F".
GET /api/cameras/{id}/calibration
Liefert die .npz-Kalibrierungsdatei (Kameramatrix + Verzerrungskoeffizienten) als Binary.
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="cam0_calibration.npz"
Cache-Control: public, max-age=86400
404 wenn keine Kalibrierung vorhanden. Einlesen in Python:
import numpy as np, requests
d = np.load(requests.get(".../api/cameras/cam0/calibration", stream=True).raw)
K, D = d["camera_matrix"], d["dist_coeffs"]
PUT /api/cameras/{id}/calibration
Nimmt eine neue .npz-Datei entgegen (Homing-Prozess → Webcam-Service).
Schreibt zwei Dateien nach data/calibration/{id}/:
| Datei | Zweck |
|---|---|
calibration_YYYYMMDD_HHMMSS.npz |
Archiv (bleibt erhalten) |
calibration.npz |
Aktuell — wird bei jedem PUT überschrieben |
Existiert beim ersten Aufruf nur calibration.npz (noch kein Archiv), wird sie
anhand ihres mtime automatisch in calibration_<ts>.npz umbenannt, bevor die
neue Datei abgelegt wird.
Content-Type: application/octet-stream
Body: rohe .npz-Bytes
// Response 200
{ "id": "cam0", "saved": "calibration_20260610_143022.npz", "size": 1284, "calibrationUrl": "/api/cameras/cam0/calibration" }
Der In-Memory-Buffer wird sofort aktualisiert — GET /api/cameras/cam0/calibration
liefert ab diesem Moment die neue Datei, ohne Server-Neustart.
400 bei leerem Body, 404 bei unbekannter Kamera-ID.
Aufruf aus Python (Homing):
import requests
with open("cam0_calibration.npz", "rb") as f:
r = requests.put(
"http://thinkcentre.local:8444/api/cameras/cam0/calibration",
data=f,
headers={"Content-Type": "application/octet-stream"},
)
r.raise_for_status()
print(r.json()) # {'id': 'cam0', 'saved': 'calibration_20260610_143022.npz', ...}
GET /api/snapshot/{id}
Letztes Live-JPEG (Live-Auflösung, z.B. 640×480) aus dem RAM-Puffer.
Bei stream: false: one-shot — öffnet Gerät kurz, schließt es wieder.
Content-Type: image/jpeg
X-Camera-Id: cam0
X-Frame-Width: 640
X-Timestamp: 2026-06-10T07:30:00.000Z
Cache-Control: no-store
503 wenn kein Frame verfügbar (Kamera nicht erreichbar).
GET /api/snapshot/{id}/hires
HD-JPEG (volle Auflösung, z.B. 1280×960 oder 1920×1080).
Pausiert den Live-Stream kurz, nimmt ein Einzelbild auf, startet Live neu.
Gleiche Response-Headers wie /api/snapshot/{id}.
GET /api/stream/{id}
Live-Stream. Format hängt vom encode-Modus ab:
| encode | Content-Type | Player |
|---|---|---|
copybsf / mjpeg |
multipart/x-mixed-replace; boundary=frame |
<img src="..."> |
h264 |
video/mp4 (fragmentiertes MP4) |
<video> via MSE |
Verbindung läuft bis der Client trennt. Langsame Clients droppen Frames (MJPEG) bzw. werden getrennt (H.264, um Decode-Lücken zu vermeiden).
GET /api/config
Laufzeit-Konfiguration aller Kameras.
{
"liveSizes": ["320x240", "640x480", "1280x960"],
"encodes": ["copybsf", "mjpeg", "h264"],
"cameras": [
{
"id": "cam0",
"name": "Kamera 0",
"liveSize": "640x480",
"stream": true,
"encode": "copybsf"
}
]
}
POST /api/config
Ändert Auflösung, Stream-Zustand oder Encoder einer oder mehrerer Kameras.
Änderungen werden in cameras.json gespeichert und sofort aktiv (Hot-Reload).
// Request Body
{ "cameras": [{ "id": "cam0", "liveSize": "320x240", "stream": true, "encode": "copybsf" }] }
Alle Felder ausser id sind optional. Antwort: wie GET /api/config.
400 bei ungültiger Auflösung, unbekannter Kamera-ID oder falschem Typ.
GET /health
{
"status": "ok",
"cameras": [
{ "id": "cam0", "name": "Kamera 0", "device": "/dev/video0", "state": "running", "hasFrame": true }
]
}
state: "running" | "idle" | "stopping" — Zustand des FFmpeg-Prozesses.
Deploy (Portainer)
- Portainer → Stacks → Web editor →
docker-compose.yamleinfügen APP_PATHauf den absoluten Pfad des Projektverzeichnisses setzen- Deploy — der Container baut sich selbst (Node + FFmpeg)
# Minimal-Konfiguration:
APP_PATH=/home/user/appRobotWebcam
Architektur
cameras.json → server.js → CameraSwitch (/dev/videoN)
├── Live: ffmpeg → MJPEG → Browser
└── Grab: Live stoppen → hires → zurück
Ein FFmpeg pro Kamera, nie zwei gleichzeitig. Das close-Event ist der harte Beweis
„Gerät frei" — kein Race, kein 106%-CPU-Bug (der mit go2rtc aufgetreten war).
Dokumentation
| Datei | Inhalt |
|---|---|
doc/01_WebcamRoadmap.md |
Ziel, Architektur, Entwicklungsgeschichte |
doc/05_screenShot_roadmap.md |
HD-Grab, Encode-Qualität, Kamera-Eigenheiten |
doc/07_multipleCam_roadmap.md |
cameras.json-Referenz, Multi-Kamera-Setup |
doc/09_Bug_reports.md |
Bug-Dokumentation |