Button Foto

This commit is contained in:
chk
2026-06-10 09:39:32 +02:00
parent 5ff560fd03
commit b15f7f2ce1
5 changed files with 222 additions and 6 deletions

View File

@@ -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. Truth) und werden mitgeliefert. Homing reicht sie an den BodyTracker durch.
Aufgaben WebCam-Service: Aufgaben WebCam-Service:
- [ ] Kameras kalibrieren (Schachbrett) → K + Distortion je Kamera. **Achtung: - [x] **Kalibrierung vorhanden** (verifiziert 2026-06-10): `/api/cameras` liefert
Intrinsics sind auflösungsabhängig** sie müssen zur **`hires`-Auflösung** `calibrationUrl` für alle 3 Kameras (cam0/cam1/cam2). `.npz`-Abruf über
passen, die `/api/snapshot/{id}/hires` ausliefert (C270 1280×960, `GET /api/cameras/{id}/calibration` → `application/octet-stream` funktioniert.
C920 1920×1080). Kein Neu-Kalibrieren nötig.
- [ ] Intrinsics in `/api/cameras` je Kamera ausgeben (K, dist, `calib_size`),
stabil gekeyt über `id`/`note`-Serial.
Aufgaben Homing-Backend (`server/server.js`): Aufgaben Homing-Backend (`server/server.js`):
- [ ] `robotIntrinsics`/`_two_cam.json`-Pfad aus `/api/estimate` entfernen. - [ ] `robotIntrinsics`/`_two_cam.json`-Pfad aus `/api/estimate` entfernen.

View File

@@ -281,6 +281,53 @@ async function onCalculateClick() {
} }
} }
// ── Foto ─────────────────────────────────────────────────────────────────────
/**
* Kameraliste vom Backend holen und <select id="cam-select"> befüllen.
* Schlägt das Laden fehl, bleiben die hardkodierten Fallback-Optionen erhalten.
*/
async function loadCameras() {
const select = document.getElementById('cam-select');
if (!select) return;
try {
const res = await fetch('/api/webcam/cameras');
if (!res.ok) return;
const { cameras } = await res.json();
select.innerHTML = cameras
.map(c => `<option value="${c.id}">${c.id} (${c.position || c.name})</option>`)
.join('');
} catch {
// Fallback: hardkodierte Optionen aus index.html bleiben erhalten
}
}
/**
* Foto-Button: holt ein HD-JPEG der gewählten Kamera und zeigt es an.
* Der Endpoint /api/webcam/snapshot/:id liefert das JPEG direkt (kein base64).
*/
async function onFotoClick() {
const camId = document.getElementById('cam-select')?.value || 'cam0';
const display = document.getElementById('foto-display');
if (!display) return;
appendLog(`Foto: ${camId}`);
// Cache-Buster über Timestamp-Parameter, damit der Browser nicht aus dem Cache lädt
const url = `/api/webcam/snapshot/${camId}?t=${Date.now()}`;
const img = document.createElement('img');
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.alt = camId;
img.onload = () => appendLog(`Foto ${camId}: OK (${img.naturalWidth}×${img.naturalHeight}px)`);
img.onerror = () => appendLog(`Foto ${camId}: Fehler beim Laden`);
img.src = url;
display.innerHTML = '';
display.appendChild(img);
}
async function onCommandClick(btn) { async function onCommandClick(btn) {
const cmd = btn.dataset.cmd; const cmd = btn.dataset.cmd;
const payloadSelector = btn.dataset.payload; const payloadSelector = btn.dataset.payload;
@@ -307,11 +354,19 @@ function setupUi() {
calculateBtn.addEventListener("click", onCalculateClick); calculateBtn.addEventListener("click", onCalculateClick);
} }
const fotoBtn = document.getElementById("btn-foto");
if (fotoBtn) {
fotoBtn.addEventListener("click", onFotoClick);
}
document.querySelectorAll("button[data-cmd]").forEach(btn => { document.querySelectorAll("button[data-cmd]").forEach(btn => {
if (btn.id === "btn-calculate") return; if (btn.id === "btn-calculate") return;
btn.addEventListener("click", () => onCommandClick(btn)); btn.addEventListener("click", () => onCommandClick(btn));
}); });
// Kameraliste vom Backend laden (befüllt #cam-select dynamisch)
loadCameras();
// ===== SECTION COLLAPSE/EXPAND ===== // ===== SECTION COLLAPSE/EXPAND =====
document.querySelectorAll(".section h2").forEach(heading => { document.querySelectorAll(".section h2").forEach(heading => {
heading.addEventListener("click", () => { heading.addEventListener("click", () => {

View File

@@ -36,6 +36,20 @@
</div> </div>
</div> </div>
<!-- FOTO -->
<div class="section full">
<h2>Foto</h2>
<div class="controls">
<select id="cam-select" title="Kamera wählen">
<option value="cam0">cam0 (front)</option>
<option value="cam1">cam1 (left)</option>
<option value="cam2">cam2 (right)</option>
</select>
<button id="btn-foto">Foto</button>
</div>
<div id="foto-display"></div>
</div>
<!-- AUSGABE / LOG (default collapsed) --> <!-- AUSGABE / LOG (default collapsed) -->
<div class="section full"> <div class="section full">
<h2>Ausgabe</h2> <h2>Ausgabe</h2>

View File

@@ -1,10 +1,12 @@
import express from 'express'; import express from 'express';
import https from 'https'; import https from 'https';
import { Readable } from 'node:stream';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import fsPromises from 'fs/promises'; import fsPromises from 'fs/promises';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import process from 'process'; import process from 'process';
import { WebcamClient } from './webcamClient.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); 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 }); 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() { async function findLatestSnapshotFile() {
const files = await fsPromises.readdir(snapshotsDir); const files = await fsPromises.readdir(snapshotsDir);
const entries = await Promise.all( const entries = await Promise.all(

100
server/webcamClient.js Normal file
View File

@@ -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<Response>}
*/
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<ArrayBuffer>}
*/
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
*/