From 17e11d99936398c66a40486f5cd69aefb24427dd Mon Sep 17 00:00:00 2001 From: chk <79915315+ChKendel@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:45:41 +0200 Subject: [PATCH] Script Callibrate --- public/calibration.html | 89 ++++++++++++++++++++- scripts/callibriate.py | 119 +++++++++++++++++++++++++++++ scripts/checkerboard_11x8_25mm.pdf | 95 +++++++++++++++++++++++ server/server.js | 74 ++++++++++++++++++ 4 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 scripts/callibriate.py create mode 100644 scripts/checkerboard_11x8_25mm.pdf diff --git a/public/calibration.html b/public/calibration.html index 319491a..3fa5157 100644 --- a/public/calibration.html +++ b/public/calibration.html @@ -229,7 +229,12 @@
- + + + +
@@ -371,18 +376,36 @@ } function updateCalibInfo(meta) { + const sel = document.getElementById('cam-select-calib'); + if (!meta) { document.getElementById('info-timestamp').textContent = '(keine Session vorhanden)'; document.getElementById('info-created').textContent = '–'; document.getElementById('info-images').textContent = '–'; + // Selector leeren + sel.innerHTML = ''; return; } document.getElementById('info-timestamp').textContent = meta.timestamp ?? '–'; document.getElementById('info-created').textContent = formatDate(meta.createdAt); - const imgTxt = meta.imageCount != null - ? `${meta.imageCount} Bilder total. ${(meta.cameras ?? []).length} Kamera(s) verwendet.` + const cameras = meta.cameras ?? []; + const imgTxt = meta.imageCount != null + ? `${meta.imageCount} Bilder total. ${cameras.length} Kamera(s) verwendet.` : '–'; document.getElementById('info-images').textContent = imgTxt; + + // Kamera-Selector aktualisieren + const prev = sel.value; + sel.innerHTML = ''; + for (const cam of cameras) { + const opt = document.createElement('option'); + opt.value = cam; + opt.textContent = cam; + if (cam === prev) opt.selected = true; + sel.appendChild(opt); + } + // Falls nur eine Kamera vorhanden – automatisch vorwählen + if (cameras.length === 1) sel.value = cameras[0]; } // Beim Laden aktuelle Session holen @@ -427,6 +450,66 @@ logC(`Fehler: ${err}`); } }); + + // "Kalibrierung berechnen" – SSE-Stream lesen + document.getElementById('btn-compute-calib').addEventListener('click', async () => { + const camera = document.getElementById('cam-select-calib').value; + if (!camera) { logC('⚠ Bitte zuerst eine Kamera auswählen.'); return; } + + logC(`Starte Kalibrierung für ${camera} …`); + const btn = document.getElementById('btn-compute-calib'); + btn.disabled = true; + + try { + const response = await fetch('/api/calibration/compute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ camera }), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({ error: response.statusText })); + logC(`Fehler: ${err.error}`); + return; + } + + // SSE-Stream zeilenweise verarbeiten + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Vollständige SSE-Ereignisse (getrennt durch \n\n) extrahieren + const parts = buffer.split('\n\n'); + buffer = parts.pop(); // letztes unvollständiges Fragment behalten + + for (const part of parts) { + for (const line of part.split('\n')) { + if (!line.startsWith('data: ')) continue; + try { + const evt = JSON.parse(line.slice(6)); + if (evt.type === 'log') { + if (evt.text !== '') logC(evt.text); + } else if (evt.type === 'done') { + logC(evt.exitCode === 0 + ? '✅ Kalibrierung abgeschlossen.' + : `❌ Script beendet mit Exit-Code ${evt.exitCode}`); + } + } catch { /* ungültiges JSON überspringen */ } + } + } + } + } catch (err) { + logC(`Fehler: ${err}`); + } finally { + btn.disabled = false; + } + }); diff --git a/scripts/callibriate.py b/scripts/callibriate.py new file mode 100644 index 0000000..3ced242 --- /dev/null +++ b/scripts/callibriate.py @@ -0,0 +1,119 @@ + +import cv2 +import numpy as np +import glob +import os +import sys +import argparse + +# +# Create calibration .npz from checkerboard images +# Usage: +# python callibriate.py --camera cam0 --input-dir data/calibration/20260610_143022 [--output-dir ...] +# + +# ── CLI-Parameter ────────────────────────────────────────────────────────────── +parser = argparse.ArgumentParser(description='Camera calibration from checkerboard images') +parser.add_argument('--camera', required=True, help='Camera ID prefix, e.g. cam0') +parser.add_argument('--input-dir', default='.', help='Directory containing calibration images') +parser.add_argument('--output-dir', default=None, help='Directory to save .npz (default: same as --input-dir)') +args = parser.parse_args() + +camera = args.camera +input_dir = os.path.abspath(args.input_dir) +output_dir = os.path.abspath(args.output_dir) if args.output_dir else input_dir + +def log(msg): + print(msg, flush=True) # flush=True → Node.js sieht die Zeile sofort + +# ── Parameters ──────────────────────────────────────────────────────────────── +CHECKERBOARD = (10, 7) # inner corners +square_size = 25.0 / 1000.0 # 25 mm -> meters + +# Prepare object points +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_size + +objpoints = [] # 3D points +imgpoints = [] # 2D points + +# ── Load images ─────────────────────────────────────────────────────────────── +pattern = os.path.join(input_dir, f"{camera}_*.jpg") +images = sorted(glob.glob(pattern)) +log(f"Camera: {camera}") +log(f"Input-Dir: {input_dir}") +log(f"Output-Dir: {output_dir}") +log(f"Pattern: {pattern}") +log(f"Found images: {len(images)}") + +img_size = None + +for fname in images: + img = cv2.imread(fname) + + log(f"Processing {os.path.basename(fname)} ...") + if img is None: + log(f" Warning: could not read {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, + flags=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) + log(f" ✅ Corners found") + else: + log(f" ❌ No corners found") + +log(f"\nTotal valid images: {len(objpoints)} / {len(images)}") + +# ── Sanity checks ───────────────────────────────────────────────────────────── +if img_size is None: + log("ERROR: No images were successfully loaded.") + sys.exit(1) + +if len(objpoints) == 0: + log("ERROR: No chessboard corners detected in any image. Calibration failed.") + sys.exit(1) + +log("\n=== Sanity Checks Passed ===") + +# ── Calibration ─────────────────────────────────────────────────────────────── +ret, K, D, rvecs, tvecs = cv2.calibrateCamera( + objpoints, + imgpoints, + img_size, + None, + None +) + +log("\n=== Calibration Results ===") +log(f"RMS reprojection error: {ret}") +log(f"Camera matrix:\n{K}") +log(f"Distortion coefficients:\n{D}") + +# ── Save calibration ────────────────────────────────────────────────────────── +os.makedirs(output_dir, exist_ok=True) +output_path = os.path.join(output_dir, f"{camera}_calibration.npz") +np.savez(output_path, camera_matrix=K, dist_coeffs=D) + +log(f"\n✅ Calibration saved to {output_path}") diff --git a/scripts/checkerboard_11x8_25mm.pdf b/scripts/checkerboard_11x8_25mm.pdf new file mode 100644 index 0000000..da4defd --- /dev/null +++ b/scripts/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/server/server.js b/server/server.js index e6f8878..87fe677 100755 --- a/server/server.js +++ b/server/server.js @@ -6,6 +6,7 @@ import fs from 'fs'; import fsPromises from 'fs/promises'; import { fileURLToPath } from 'url'; import process from 'process'; +import { spawn } from 'child_process'; import { WebcamClient } from './webcamClient.js'; const __filename = fileURLToPath(import.meta.url); @@ -332,6 +333,79 @@ app.post('/api/calibration/foto', async (req, res) => { } }); +/** + * POST /api/calibration/compute + * Führt scripts/callibriate.py für eine Kamera aus. + * Body: { camera: "cam0" } + * Antwortet als Server-Sent Events (SSE): jede Zeile stdout/stderr als + * data: {"type":"log","text":"..."} + * Abschluss: + * data: {"type":"done","exitCode":0} + */ +const PYTHON_BIN = process.env.PYTHON_BIN || 'python'; +const calibScriptPath = path.join(__dirname, '..', 'scripts', 'callibriate.py'); + +app.post('/api/calibration/compute', async (req, res) => { + const { camera } = req.body ?? {}; + if (!camera) return res.status(400).json({ error: '"camera" parameter fehlt' }); + + const session = await findLatestCalibSession(); + if (!session) return res.status(400).json({ error: 'Keine Kalibrierungs-Session vorhanden' }); + + const sessionDir = path.join(calibDataDir, session); + + // SSE-Header + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const send = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`); + + send({ type: 'log', text: `▶ Session: ${session}` }); + send({ type: 'log', text: `▶ Kamera: ${camera}` }); + send({ type: 'log', text: `▶ Script: ${calibScriptPath}` }); + send({ type: 'log', text: '' }); + + const proc = spawn(PYTHON_BIN, [ + calibScriptPath, + '--camera', camera, + '--input-dir', sessionDir, + '--output-dir', sessionDir, + ]); + + // stdout zeilenweise weiterleiten + let stdoutBuf = ''; + proc.stdout.on('data', (chunk) => { + stdoutBuf += chunk.toString(); + const lines = stdoutBuf.split('\n'); + stdoutBuf = lines.pop(); // letztes (unvollständiges) Fragment behalten + for (const line of lines) send({ type: 'log', text: line }); + }); + + // stderr als Warnung weiterleiten + let stderrBuf = ''; + proc.stderr.on('data', (chunk) => { + stderrBuf += chunk.toString(); + const lines = stderrBuf.split('\n'); + stderrBuf = lines.pop(); + for (const line of lines) send({ type: 'log', text: `[stderr] ${line}` }); + }); + + proc.on('error', (err) => { + send({ type: 'log', text: `Fehler beim Starten: ${err.message}` }); + send({ type: 'done', exitCode: -1 }); + res.end(); + }); + + proc.on('close', (code) => { + if (stdoutBuf) send({ type: 'log', text: stdoutBuf }); // Rest ausgeben + if (stderrBuf) send({ type: 'log', text: `[stderr] ${stderrBuf}` }); + send({ type: 'done', exitCode: code }); + res.end(); + }); +}); + async function checkServiceReachability(name, url) { try { const controller = new AbortController();