diff --git a/doc/ToDo.md b/doc/ToDo.md index b510b19..9b99413 100644 --- a/doc/ToDo.md +++ b/doc/ToDo.md @@ -104,12 +104,10 @@ BodyTracker hält in `/v1/config` nur Solver-Parameter, keine Intrinsics. Truth) und werden mitgeliefert. Homing reicht sie an den BodyTracker durch. Aufgaben WebCam-Service: -- [ ] Kameras kalibrieren (Schachbrett) → K + Distortion je Kamera. **Achtung: - Intrinsics sind auflösungsabhängig** – sie müssen zur **`hires`-Auflösung** - passen, die `/api/snapshot/{id}/hires` ausliefert (C270 1280×960, - C920 1920×1080). -- [ ] Intrinsics in `/api/cameras` je Kamera ausgeben (K, dist, `calib_size`), - stabil gekeyt über `id`/`note`-Serial. +- [x] **Kalibrierung vorhanden** (verifiziert 2026-06-10): `/api/cameras` liefert + `calibrationUrl` für alle 3 Kameras (cam0/cam1/cam2). `.npz`-Abruf über + `GET /api/cameras/{id}/calibration` → `application/octet-stream` funktioniert. + Kein Neu-Kalibrieren nötig. Aufgaben Homing-Backend (`server/server.js`): - [ ] `robotIntrinsics`/`_two_cam.json`-Pfad aus `/api/estimate` entfernen. diff --git a/public/client.js b/public/client.js index 97e4b0c..65de84c 100755 --- a/public/client.js +++ b/public/client.js @@ -281,6 +281,53 @@ async function onCalculateClick() { } } +// ── Foto ───────────────────────────────────────────────────────────────────── + +/** + * Kameraliste vom Backend holen und + + + + + + +
+ +

Ausgabe

diff --git a/server/server.js b/server/server.js index e4325c7..30c82ed 100755 --- a/server/server.js +++ b/server/server.js @@ -1,10 +1,12 @@ import express from 'express'; import https from 'https'; +import { Readable } from 'node:stream'; import path from 'path'; import fs from 'fs'; import fsPromises from 'fs/promises'; import { fileURLToPath } from 'url'; import process from 'process'; +import { WebcamClient } from './webcamClient.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -27,6 +29,53 @@ app.get('/api/health', (req, res) => { res.json({ ok: true, mode: 'backend-proxy', webcamUrl: WEBCAM_URL || null, bodyTrackerUrl: BODYTRACKER_URL || null }); }); +// ── WebCam-Proxy ───────────────────────────────────────────────────────────── + +/** Kameraliste mit Metadaten (inkl. calibrationUrl falls Kalibrierung vorhanden). */ +app.get('/api/webcam/cameras', async (req, res) => { + if (!WEBCAM_URL) return res.status(501).json({ error: 'WEBCAM_URL ist nicht konfiguriert' }); + try { + const wc = new WebcamClient(WEBCAM_URL); + const data = await wc.getCameras(); + return res.json(data); + } catch (err) { + console.error('webcam/cameras error:', err); + return res.status(502).json({ error: 'WebCam-Fehler', details: String(err) }); + } +}); + +/** + * HD-JPEG einer Kamera (per Default hires). + * Streamt die JPEG-Antwort direkt durch — kein Buffering im Backend. + * Query-Parameter: ?hires=false für Live-Auflösung. + */ +app.get('/api/webcam/snapshot/:id', async (req, res) => { + if (!WEBCAM_URL) return res.status(501).json({ error: 'WEBCAM_URL ist nicht konfiguriert' }); + const hires = req.query.hires !== 'false'; + try { + const wc = new WebcamClient(WEBCAM_URL); + const upstream = await wc.getSnapshot(req.params.id, hires); + + // Relevante Response-Header durchreichen + res.setHeader('Content-Type', upstream.headers.get('content-type') || 'image/jpeg'); + res.setHeader('Cache-Control', 'no-store'); + for (const header of ['x-camera-id', 'x-frame-width', 'x-timestamp', 'content-length']) { + const val = upstream.headers.get(header); + if (val) res.setHeader(header, val); + } + + const nodeStream = Readable.fromWeb(upstream.body); + nodeStream.on('error', (err) => { + console.error(`webcam/snapshot/${req.params.id} stream error:`, err); + if (!res.headersSent) res.status(502).json({ error: 'Stream-Fehler' }); + }); + nodeStream.pipe(res); + } catch (err) { + console.error(`webcam/snapshot/${req.params.id} error:`, err); + if (!res.headersSent) res.status(502).json({ error: 'WebCam-Fehler', details: String(err) }); + } +}); + async function findLatestSnapshotFile() { const files = await fsPromises.readdir(snapshotsDir); const entries = await Promise.all( diff --git a/server/webcamClient.js b/server/webcamClient.js new file mode 100644 index 0000000..fb39563 --- /dev/null +++ b/server/webcamClient.js @@ -0,0 +1,100 @@ +/** + * webcamClient.js + * Kapselt alle Zugriffe auf den WebCam-Service. + * Basis-URL kommt von aussen (WEBCAM_URL) — kein Hartkodieren hier. + * + * Verwendung: + * import { WebcamClient } from './webcamClient.js'; + * const wc = new WebcamClient(process.env.WEBCAM_URL); + * const cameras = await wc.getCameras(); + * const response = await wc.getSnapshot('cam0'); // Response-Objekt, JPEG-Body + */ + +const TIMEOUT_MS = 15_000; + +export class WebcamClient { + /** @param {string} baseUrl z.B. "http://appRobotWebcam:8444" */ + constructor(baseUrl) { + if (!baseUrl) throw new Error('WebcamClient: baseUrl ist erforderlich'); + this.baseUrl = baseUrl.replace(/\/$/, ''); + } + + /** + * Liste aller Kameras mit Metadaten. + * `calibrationUrl` ist enthalten, wenn eine .npz unter data/calibration/{id}/ liegt. + * @returns {Promise<{cameras: CameraMeta[]}>} + */ + async getCameras() { + const res = await this.#get('/api/cameras'); + if (!res.ok) throw new Error(`getCameras: HTTP ${res.status}`); + return res.json(); + } + + /** + * HD-JPEG einer Kamera als fetch-Response. + * Caller kann res.body direkt pipen (kein Buffering). + * @param {string} id Kamera-ID, z.B. "cam0" + * @param {boolean} hires true = /hires (Default), false = Live-Auflösung + * @returns {Promise} + */ + async getSnapshot(id, hires = true) { + const path = hires ? `/api/snapshot/${id}/hires` : `/api/snapshot/${id}`; + const res = await this.#get(path); + if (!res.ok) throw new Error(`getSnapshot(${id}): HTTP ${res.status}`); + return res; + } + + /** + * Kalibrierungsdatei (.npz) als ArrayBuffer. + * Kann direkt an den BodyTracker weitergereicht werden. + * Wirft bei 404 (noch keine Kalibrierung vorhanden). + * @param {string} id Kamera-ID + * @returns {Promise} + */ + async getCalibration(id) { + const res = await this.#get(`/api/cameras/${id}/calibration`); + if (res.status === 404) throw new Error(`Keine Kalibrierung für Kamera "${id}"`); + if (!res.ok) throw new Error(`getCalibration(${id}): HTTP ${res.status}`); + return res.arrayBuffer(); + } + + /** + * Gesundheitsstatus des WebCam-Service inkl. Kamera-Zustände. + * @returns {Promise<{status: string, cameras: CameraState[]}>} + */ + async health() { + const res = await this.#get('/health'); + if (!res.ok) throw new Error(`health: HTTP ${res.status}`); + return res.json(); + } + + // ── intern ────────────────────────────────────────────────────────────────── + + #get(path, options = {}) { + const url = `${this.baseUrl}${path}`; + return fetch(url, { + ...options, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } +} + +/** + * @typedef {Object} CameraMeta + * @property {string} id + * @property {string} name + * @property {string} position "front" | "left" | "right" + * @property {boolean} stream + * @property {boolean} hires + * @property {string} encode + * @property {string|null} mseCodec + * @property {string} note Hardware-Serial (stable key) + * @property {string|null} [calibrationUrl] vorhanden wenn .npz existiert + * + * @typedef {Object} CameraState + * @property {string} id + * @property {string} name + * @property {string} device + * @property {string} state "running" | "idle" | "stopping" + * @property {boolean} hasFrame + */