diff --git a/data/calibration/cam0/calibration.npz b/data/calibration/cam0/calibration.npz new file mode 100644 index 0000000..f705b71 Binary files /dev/null and b/data/calibration/cam0/calibration.npz differ diff --git a/data/calibration/cam1/calibration.npz b/data/calibration/cam1/calibration.npz new file mode 100644 index 0000000..2887aa7 Binary files /dev/null and b/data/calibration/cam1/calibration.npz differ diff --git a/data/calibration/cam2/calibration.npz b/data/calibration/cam2/calibration.npz new file mode 100644 index 0000000..6128aa0 Binary files /dev/null and b/data/calibration/cam2/calibration.npz differ diff --git a/server.js b/server.js index 3cf8b72..8725381 100644 --- a/server.js +++ b/server.js @@ -36,6 +36,20 @@ const H264 = { }; const MSE_CODEC = process.env.H264_MSE_CODEC ?? mseCodecString(H264.profile, process.env.H264_LEVEL ?? '1F'); +// ── Kalibrierungsdaten laden (data/calibration/{id}/calibration.npz) ────────── +// Einmalig beim Start in den RAM; kein fs-Zugriff pro Request. +function loadCalibrations(camsConfig) { + const calib = {}; + for (const cam of camsConfig) { + const p = path.join(__dirname, 'data', 'calibration', cam.id, 'calibration.npz'); + if (fs.existsSync(p)) { + calib[cam.id] = fs.readFileSync(p); + console.log(` Kalibrierung: ${cam.id} (${calib[cam.id].length} Bytes)`); + } + } + return calib; +} + // ── cameras.json → CameraSwitch-Instanzen ───────────────────────────────────── const CAMERAS_PATH = path.join(__dirname, 'cameras.json'); let camerasJson; @@ -56,6 +70,8 @@ if (!Array.isArray(camsConfig) || camsConfig.length === 0) { console.error('cameras.json: "cameras" muss ein nicht-leeres Array sein'); process.exit(1); } +const calibrations = loadCalibrations(camsConfig); + const switches = {}; const camsMeta = []; // { id, device, name, position, stream, hires, note } for (const cam of camsConfig) { @@ -95,7 +111,7 @@ app.use(express.json()); // POST /api/config liest JSON-Body // ── 1. Eigene Endpunkte ─────────────────────────────────────────────────────── app.use('/api/snapshot', createSnapshotRouter(switches, camsMeta)); app.use('/api/stream', createStreamRouter(switches)); -app.use('/api/cameras', createCamerasRouter(camsMeta)); +app.use('/api/cameras', createCamerasRouter(camsMeta, calibrations)); app.use('/api/config', createConfigRouter({ switches, camsMeta, getCamerasJson: () => camerasJson, diff --git a/setup/README.md b/setup/README.md new file mode 100644 index 0000000..71be141 --- /dev/null +++ b/setup/README.md @@ -0,0 +1,79 @@ +# Kamera-Kalibrierung einrichten + +Jede Kamera braucht eine Kalibrierungsdatei (`calibration.npz`), die +Kameramatrix und Verzerrungskoeffizienten enthält. +Die Datei wird beim Serverstart einmalig in den RAM geladen und über +`GET /api/cameras/{id}/calibration` ausgeliefert. + +--- + +## Voraussetzungen + +``` +pip install opencv-python numpy requests +``` + +--- + +## Schritt 1 – Schachbrettmuster drucken + +Datei: [`checkerboard_11x8_25mm.pdf`](checkerboard_11x8_25mm.pdf) + +- **A4 Querformat, 100 % drucken** (keine Skalierung, kein „An Seite anpassen") +- Muster: 11 × 8 Felder → **10 × 7 innere Ecken**, 25 mm pro Feld +- Auf festes Papier oder Karton drucken; flach und verwindungssteif halten + +--- + +## Schritt 2 – Fotos aufnehmen + +Server läuft auf `thinkcentre.local:8444`. Skript schießt für alle Kameras +gleichzeitig Hires-Aufnahmen und legt sie in `test/files/{camId}/` ab: + +```bash +python test/grabSnapShot.py +``` + +Das Schachbrettmuster dabei aus **verschiedenen Winkeln und Positionen** halten +(mind. 15–20 Aufnahmen pro Kamera). +Bilder landen unter `test/files/cam0/`, `test/files/cam1/`, `test/files/cam2/`. + +--- + +## Schritt 3 – Kalibrierung berechnen + +Für jede Kamera einmal ausführen: + +```bash +python setup/calibrate.py test/files/cam0 data/calibration/cam0/calibration.npz +python setup/calibrate.py test/files/cam1 data/calibration/cam1/calibration.npz +python setup/calibrate.py test/files/cam2 data/calibration/cam2/calibration.npz +``` + +Ein guter RMS-Wert liegt unter **0.5 px**. Wenn der Wert zu hoch ist: +- Bilder mit schlechter Beleuchtung oder Bewegungsunschärfe löschen +- Schachbrettmuster flacher halten +- Mehr Aufnahmen aus verschiedenen Winkeln machen + +--- + +## Schritt 4 – Dateien committen + +Die `.npz`-Dateien sind Deployment-Konfiguration und gehören ins Repository: + +```bash +git add data/calibration/ +git commit -m "Kalibrierungsdaten für cam0–cam2" +``` + +--- + +## Ergebnis prüfen + +```bash +curl http://thinkcentre.local:8444/api/cameras +# → jede Kamera mit "calibrationUrl": "/api/cameras/cam0/calibration" + +curl -o cam0.npz http://thinkcentre.local:8444/api/cameras/cam0/calibration +python -c "import numpy as np; d=np.load('cam0.npz'); print(d['camera_matrix'])" +``` diff --git a/setup/calibrate.py b/setup/calibrate.py new file mode 100644 index 0000000..88e47ad --- /dev/null +++ b/setup/calibrate.py @@ -0,0 +1,77 @@ +""" +Berechnet eine Kamera-Kalibrierung aus Schachbrett-Fotos und speichert das +Ergebnis als .npz. + +Aufruf: + python calibrate.py + +Beispiel: + python setup/calibrate.py test/files/cam0 data/calibration/cam0/calibration.npz + +Erwartet *.jpg-Dateien im Bildordner, die ein 10x7-Eck-Schachbrettmuster +(= 11x8 Felder, 25 mm/Feld) zeigen. +""" + +import sys +import glob +import pathlib +import numpy as np +import cv2 + +CHECKERBOARD = (10, 7) # innere Ecken +SQUARE_MM = 25.0 / 1000 # 25 mm in Metern + +def calibrate(image_dir: str, out_path: str) -> None: + images = sorted(glob.glob(str(pathlib.Path(image_dir) / "*.jpg"))) + if not images: + sys.exit(f"Keine .jpg-Dateien in {image_dir!r}") + print(f"Gefundene Bilder: {len(images)}") + + objp = np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32) + objp[:, :2] = np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2) + objp *= SQUARE_MM + + objpoints, imgpoints = [], [] + img_size = None + + for fname in images: + img = cv2.imread(fname) + if img is None: + print(f" WARNUNG: konnte nicht lesen: {fname}") + continue + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + if img_size is None: + img_size = gray.shape[::-1] + + ret, corners = cv2.findChessboardCorners( + gray, CHECKERBOARD, + cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE | cv2.CALIB_CB_FAST_CHECK, + ) + if ret: + corners2 = cv2.cornerSubPix( + gray, corners, (11, 11), (-1, -1), + (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6), + ) + objpoints.append(objp) + imgpoints.append(corners2) + print(f" OK {pathlib.Path(fname).name}") + else: + print(f" -- {pathlib.Path(fname).name} (keine Ecken gefunden)") + + print(f"\nGültige Bilder: {len(objpoints)} / {len(images)}") + if not objpoints: + sys.exit("Kalibrierung fehlgeschlagen: keine Ecken in keinem Bild gefunden.") + + rms, K, D, _rvecs, _tvecs = cv2.calibrateCamera(objpoints, imgpoints, img_size, None, None) + print(f"RMS-Fehler: {rms:.4f} px") + print(f"Kameramatrix:\n{K}") + print(f"Verzerrungskoeff: {D}") + + pathlib.Path(out_path).parent.mkdir(parents=True, exist_ok=True) + np.savez(out_path, camera_matrix=K, dist_coeffs=D) + print(f"\nGespeichert: {out_path}") + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit(__doc__) + calibrate(sys.argv[1], sys.argv[2]) diff --git a/setup/checkerboard_11x8_25mm.pdf b/setup/checkerboard_11x8_25mm.pdf new file mode 100644 index 0000000..da4defd --- /dev/null +++ b/setup/checkerboard_11x8_25mm.pdf @@ -0,0 +1,95 @@ +%PDF-1.4 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +4 0 obj +<< /Length 1465 >> +stream +q +0 g +31.18 14.18 70.87 70.87 re +172.91 14.18 70.87 70.87 re +314.65 14.18 70.87 70.87 re +456.38 14.18 70.87 70.87 re +598.11 14.18 70.87 70.87 re +739.84 14.18 70.87 70.87 re +102.05 85.04 70.87 70.87 re +243.78 85.04 70.87 70.87 re +385.51 85.04 70.87 70.87 re +527.24 85.04 70.87 70.87 re +668.98 85.04 70.87 70.87 re +31.18 155.91 70.87 70.87 re +172.91 155.91 70.87 70.87 re +314.65 155.91 70.87 70.87 re +456.38 155.91 70.87 70.87 re +598.11 155.91 70.87 70.87 re +739.84 155.91 70.87 70.87 re +102.05 226.77 70.87 70.87 re +243.78 226.77 70.87 70.87 re +385.51 226.77 70.87 70.87 re +527.24 226.77 70.87 70.87 re +668.98 226.77 70.87 70.87 re +31.18 297.64 70.87 70.87 re +172.91 297.64 70.87 70.87 re +314.65 297.64 70.87 70.87 re +456.38 297.64 70.87 70.87 re +598.11 297.64 70.87 70.87 re +739.84 297.64 70.87 70.87 re +102.05 368.51 70.87 70.87 re +243.78 368.51 70.87 70.87 re +385.51 368.51 70.87 70.87 re +527.24 368.51 70.87 70.87 re +668.98 368.51 70.87 70.87 re +31.18 439.37 70.87 70.87 re +172.91 439.37 70.87 70.87 re +314.65 439.37 70.87 70.87 re +456.38 439.37 70.87 70.87 re +598.11 439.37 70.87 70.87 re +739.84 439.37 70.87 70.87 re +102.05 510.24 70.87 70.87 re +243.78 510.24 70.87 70.87 re +385.51 510.24 70.87 70.87 re +527.24 510.24 70.87 70.87 re +668.98 510.24 70.87 70.87 re +f +Q +q +0.5 w +0 G +31.18 14.18 779.53 566.93 re +S +Q +BT +/F1 7.5 Tf +31.18 3.18 Td +(Kalibrierungsmuster | 11x8 Felder | 10x7 innere Ecken | 25 mm/Feld | A4 Querformat, 100% drucken ohne Skalierung) Tj +ET +endstream +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R + /MediaBox [0 0 841.89 595.28] + /Contents 4 0 R + /Resources << /ProcSet [/PDF /Text] /Font << /F1 5 0 R >> >> +>> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000001707 00000 n +0000000191 00000 n +0000000121 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +1870 +%%EOF diff --git a/src/snapshotService.js b/src/snapshotService.js index 53af369..eba9e51 100644 --- a/src/snapshotService.js +++ b/src/snapshotService.js @@ -190,14 +190,32 @@ function createStreamRouter(switches) { return router; } -// GET /api/cameras → vollständige Kamera-Metadaten (ohne device-Pfad) -function createCamerasRouter(cameras) { +// GET /api/cameras → Kamera-Metadaten (ohne device-Pfad) +// GET /api/cameras/:id/calibration → .npz aus RAM (geladen beim Start) +function createCamerasRouter(cameras, calibrations = {}) { const router = express.Router(); + router.get('/', (_req, res) => { res.json({ - cameras: cameras.map(({ device: _d, ...rest }) => rest), + cameras: cameras.map(({ device: _d, ...rest }) => ({ + ...rest, + ...(calibrations[rest.id] ? { calibrationUrl: `/api/cameras/${rest.id}/calibration` } : {}), + })), }); }); + + router.get('/:id/calibration', (req, res) => { + const buf = calibrations[req.params.id]; + if (!buf) return res.status(404).json({ error: `Keine Kalibrierdaten für: ${req.params.id}` }); + res.set({ + 'Content-Type': 'application/octet-stream', + 'Content-Length': buf.length, + 'Content-Disposition': `attachment; filename="${req.params.id}_calibration.npz"`, + 'Cache-Control': 'public, max-age=86400', + }); + res.end(buf); + }); + return router; }