This commit is contained in:
chk
2026-06-10 11:20:10 +02:00
parent 3741ad9f6a
commit 9650dee02e
2 changed files with 261 additions and 14 deletions

View File

@@ -155,6 +155,31 @@
.status-badge.done { color: #34d399; background: #064e3b; }
.status-badge.wip { color: #60a5fa; }
/* ===== INFO GRID ===== */
.info-grid {
display: grid;
grid-template-columns: 160px 1fr;
gap: 6px 12px;
margin-top: 14px;
font-size: 13px;
}
.info-label {
color: var(--muted);
}
.info-value {
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* Buttons: aktiv vs. deaktiviert visuell unterscheiden */
.controls button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
</style>
</head>
<body>
@@ -183,22 +208,33 @@
<div class="tab-panel active" id="tab-camera-npz">
<div class="sections">
<!-- Info-Box: Aktuelle Kalibrierung -->
<div class="section full">
<h2>Camera NPZ <span class="status-badge open">offen</span></h2>
<div class="placeholder-note">
Ziel: Intrinsische Kameraparameter (Kameramatrix, Verzerrungskoeffizienten) für jede
Kamera ermitteln und als <code>.npz</code>-Datei speichern.<br><br>
Geplante Aktionen: Fotos aufnehmen (verschiedene Posen) · Kalibrierung berechnen ·
Reprojektionsfehler anzeigen · Datei speichern.<br><br>
<em>Aktionen werden ergänzt sobald das Konzept feststeht.</em>
</div>
<div class="controls" style="margin-top: 14px;">
<button disabled>Fotos aufnehmen</button>
<button disabled>Kalibrierung berechnen</button>
<button disabled>NPZ speichern</button>
<h2>Aktuelle Kalibrierung</h2>
<div id="calib-info" class="info-grid">
<span class="info-label">Timestamp</span>
<span class="info-value" id="info-timestamp"></span>
<span class="info-label">Erstellt am</span>
<span class="info-value" id="info-created"></span>
<span class="info-label">Bilder / Kameras</span>
<span class="info-value" id="info-images"></span>
</div>
</div>
<!-- Aktionen -->
<div class="section full">
<h2>Aktionen</h2>
<div class="controls" style="margin-top: 14px;">
<button id="btn-new-calib">Neue Kalibrierung anlegen</button>
<button id="btn-foto-calib">Foto aufnehmen</button>
<button disabled title="Folgt später">Kalibrierung berechnen</button>
<button disabled title="Folgt später">NPZ speichern</button>
</div>
</div>
<!-- Ausgabe -->
<div class="section full">
<h2>Ausgabe / Log</h2>
<textarea id="log-camera" readonly placeholder="(Ausgabe erscheint hier)"></textarea>
@@ -304,7 +340,7 @@
</div><!-- /.calib-body -->
<script>
// Tab-Switching
// ── Tab-Switching ──────────────────────────────────────────────────────────
document.getElementById('tabSidebar').addEventListener('click', e => {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
@@ -314,10 +350,83 @@
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
});
// Section collapse (gleiche Logik wie Hauptseite)
// ── Section collapse ───────────────────────────────────────────────────────
document.querySelectorAll('.section h2').forEach(h2 => {
h2.addEventListener('click', () => h2.closest('.section').classList.toggle('collapsed'));
});
// ── Camera NPZ ─────────────────────────────────────────────────────────────
const logCamera = document.getElementById('log-camera');
function logC(msg) {
const ts = new Date().toLocaleTimeString('de-CH');
logCamera.value += `[${ts}] ${msg}\n`;
logCamera.scrollTop = logCamera.scrollHeight;
}
function formatDate(isoString) {
if (!isoString) return '';
return new Date(isoString).toLocaleString('de-CH');
}
function updateCalibInfo(meta) {
if (!meta) {
document.getElementById('info-timestamp').textContent = '(keine Session vorhanden)';
document.getElementById('info-created').textContent = '';
document.getElementById('info-images').textContent = '';
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.`
: '';
document.getElementById('info-images').textContent = imgTxt;
}
// Beim Laden aktuelle Session holen
async function loadCalibCurrent() {
try {
const r = await fetch('/api/calibration/current');
const d = await r.json();
updateCalibInfo(d.meta);
if (d.session) logC(`Session geladen: ${d.session}`);
else logC('Noch keine Kalibrierungs-Session vorhanden.');
} catch (err) {
logC(`Fehler beim Laden: ${err}`);
}
}
loadCalibCurrent();
// "Neue Kalibrierung anlegen"
document.getElementById('btn-new-calib').addEventListener('click', async () => {
logC('Neue Kalibrierung wird angelegt …');
try {
const r = await fetch('/api/calibration/new', { method: 'POST' });
const d = await r.json();
if (d.error) { logC(`Fehler: ${d.error}`); return; }
updateCalibInfo(d.meta);
if (d.warning) logC(`Warnung: ${d.warning}`);
else logC(`Session angelegt: ${d.session} | Fotos: ${(d.savedFiles ?? []).join(', ')}`);
} catch (err) {
logC(`Fehler: ${err}`);
}
});
// "Foto aufnehmen"
document.getElementById('btn-foto-calib').addEventListener('click', async () => {
logC('Fotos werden aufgenommen …');
try {
const r = await fetch('/api/calibration/foto', { method: 'POST' });
const d = await r.json();
if (d.error) { logC(`Fehler: ${d.error}`); return; }
updateCalibInfo(d.meta);
logC(`Gespeichert: ${(d.savedFiles ?? []).join(', ')}`);
} catch (err) {
logC(`Fehler: ${err}`);
}
});
</script>
</body>

View File

@@ -194,6 +194,144 @@ app.post('/api/estimate', async (req, res) => {
}
});
// ── Kalibrierung ─────────────────────────────────────────────────────────────
const calibDataDir = path.join(__dirname, '..', 'data', 'calibration');
/** Timestamp-String im Format YYYYMMDD_HHmmss */
function makeTimestamp() {
const now = new Date();
const p = (n, l = 2) => String(n).padStart(l, '0');
return `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}_${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
}
/** Neueste Kalibrierungs-Session (Verzeichnisname) oder null */
async function findLatestCalibSession() {
try {
await fsPromises.access(calibDataDir);
const entries = await fsPromises.readdir(calibDataDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort().reverse();
return dirs[0] ?? null;
} catch {
return null;
}
}
/** Liest meta.json einer Session */
async function readCalibMeta(sessionName) {
try {
const raw = await fsPromises.readFile(path.join(calibDataDir, sessionName, 'meta.json'), 'utf8');
return JSON.parse(raw);
} catch {
return null;
}
}
/** Schreibt meta.json einer Session */
async function writeCalibMeta(sessionName, meta) {
await fsPromises.writeFile(
path.join(calibDataDir, sessionName, 'meta.json'),
JSON.stringify(meta, null, 2),
'utf8'
);
}
/**
* Holt Snapshots aller verfügbaren Kameras und speichert sie im Session-Verzeichnis.
* Dateiname: {cameraId}_{setNr}.jpg (setNr = 001, 002, …)
*/
async function capturePhotos(sessionName) {
if (!WEBCAM_URL) throw new Error('WEBCAM_URL ist nicht konfiguriert keine Kameras erreichbar');
const wc = new WebcamClient(WEBCAM_URL);
const data = await wc.getCameras();
const cameraIds = (data.cameras ?? []).map(c => c.id);
if (cameraIds.length === 0) throw new Error('Keine Kameras vom WebCam-Service gemeldet');
// Nächste Set-Nummer bestimmen (höchste vorhandene + 1)
const sessionDir = path.join(calibDataDir, sessionName);
const existing = await fsPromises.readdir(sessionDir);
const maxSet = existing.reduce((max, f) => {
const m = f.match(/_(\d+)\.jpg$/);
return m ? Math.max(max, parseInt(m[1], 10)) : max;
}, 0);
const setNr = String(maxSet + 1).padStart(3, '0');
const savedFiles = [];
for (const camId of cameraIds) {
const response = await new WebcamClient(WEBCAM_URL).getSnapshot(camId, true);
const buffer = Buffer.from(await response.arrayBuffer());
const filename = `${camId}_${setNr}.jpg`;
await fsPromises.writeFile(path.join(sessionDir, filename), buffer);
savedFiles.push(filename);
}
return { cameraIds, savedFiles, setNr: parseInt(setNr, 10) };
}
/** GET /api/calibration/current — aktuelle Session-Info */
app.get('/api/calibration/current', async (req, res) => {
try {
const session = await findLatestCalibSession();
if (!session) return res.json({ session: null, meta: null });
const meta = await readCalibMeta(session);
return res.json({ session, meta });
} catch (err) {
return res.status(500).json({ error: String(err) });
}
});
/** POST /api/calibration/new — neue Session anlegen + erste Fotos */
app.post('/api/calibration/new', async (req, res) => {
try {
const ts = makeTimestamp();
const sessionDir = path.join(calibDataDir, ts);
await fsPromises.mkdir(sessionDir, { recursive: true });
const meta = { timestamp: ts, createdAt: new Date().toISOString(), cameras: [], imageSets: 0, imageCount: 0 };
await writeCalibMeta(ts, meta);
try {
const capture = await capturePhotos(ts);
meta.cameras = capture.cameraIds;
meta.imageSets = capture.setNr;
meta.imageCount = capture.setNr * capture.cameraIds.length;
await writeCalibMeta(ts, meta);
return res.json({ session: ts, meta, savedFiles: capture.savedFiles });
} catch (captureErr) {
// Session angelegt, aber Fotos nicht verfügbar → trotzdem OK zurück
return res.json({ session: ts, meta, warning: String(captureErr) });
}
} catch (err) {
return res.status(500).json({ error: String(err) });
}
});
/** POST /api/calibration/foto — weitere Fotos für aktuelle Session */
app.post('/api/calibration/foto', async (req, res) => {
try {
const session = await findLatestCalibSession();
if (!session) {
return res.status(400).json({ error: 'Keine Session vorhanden. Bitte zuerst "Neue Kalibrierung anlegen".' });
}
const capture = await capturePhotos(session);
const meta = await readCalibMeta(session) ?? {
timestamp: session, createdAt: new Date().toISOString(),
cameras: [], imageSets: 0, imageCount: 0
};
meta.cameras = capture.cameraIds;
meta.imageSets = capture.setNr;
meta.imageCount = capture.setNr * capture.cameraIds.length;
await writeCalibMeta(session, meta);
return res.json({ session, meta, savedFiles: capture.savedFiles });
} catch (err) {
return res.status(500).json({ error: String(err) });
}
});
async function checkServiceReachability(name, url) {
try {
const controller = new AbortController();