Button Foto
This commit is contained in:
10
doc/ToDo.md
10
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.
|
||||
|
||||
@@ -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) {
|
||||
const cmd = btn.dataset.cmd;
|
||||
const payloadSelector = btn.dataset.payload;
|
||||
@@ -307,11 +354,19 @@ function setupUi() {
|
||||
calculateBtn.addEventListener("click", onCalculateClick);
|
||||
}
|
||||
|
||||
const fotoBtn = document.getElementById("btn-foto");
|
||||
if (fotoBtn) {
|
||||
fotoBtn.addEventListener("click", onFotoClick);
|
||||
}
|
||||
|
||||
document.querySelectorAll("button[data-cmd]").forEach(btn => {
|
||||
if (btn.id === "btn-calculate") return;
|
||||
btn.addEventListener("click", () => onCommandClick(btn));
|
||||
});
|
||||
|
||||
// Kameraliste vom Backend laden (befüllt #cam-select dynamisch)
|
||||
loadCameras();
|
||||
|
||||
// ===== SECTION COLLAPSE/EXPAND =====
|
||||
document.querySelectorAll(".section h2").forEach(heading => {
|
||||
heading.addEventListener("click", () => {
|
||||
|
||||
@@ -36,6 +36,20 @@
|
||||
</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) -->
|
||||
<div class="section full">
|
||||
<h2>Ausgabe</h2>
|
||||
|
||||
@@ -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(
|
||||
|
||||
100
server/webcamClient.js
Normal file
100
server/webcamClient.js
Normal 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
|
||||
*/
|
||||
Reference in New Issue
Block a user