1026 lines
38 KiB
JavaScript
1026 lines
38 KiB
JavaScript
// Application Configuration
|
|
const PRICING_DATABASE = {
|
|
"apfel_elstar": { name: "Apfel Elstar", price: 2.99, unit: "kg" },
|
|
"apfel_gala": { name: "Apfel Gala", price: 2.49, unit: "kg" },
|
|
"apfel_granny_smith": { name: "Apfel Granny Smith", price: 2.79, unit: "kg" },
|
|
"banane_chiquita": { name: "Banane Chiquita", price: 1.99, unit: "kg" },
|
|
"banane_bio": { name: "Bio Banane", price: 2.29, unit: "kg" },
|
|
"birne": { name: "Birne Abate Fetel", price: 3.29, unit: "kg" },
|
|
"orange": { name: "Orangen", price: 2.19, unit: "kg" },
|
|
"zitrone": { name: "Zitronen", price: 0.59, unit: "Stk." },
|
|
"limette": { name: "Limetten", price: 0.49, unit: "Stk." },
|
|
"erdbeere": { name: "Erdbeeren Schale", price: 2.99, unit: "Stk." },
|
|
"blaubeere": { name: "Kulturheidelbeeren", price: 1.99, unit: "Stk." },
|
|
"weintraube_hell": { name: "Tafeltrauben hell", price: 3.99, unit: "kg" },
|
|
"weintraube_dunkel": { name: "Tafeltrauben dunkel", price: 4.29, unit: "kg" },
|
|
"pfirsich": { name: "Pfirsiche", price: 2.99, unit: "kg" },
|
|
"tomate": { name: "Rispentomaten", price: 3.49, unit: "kg" },
|
|
"gurke": { name: "Salatgurke", price: 0.99, unit: "Stk." },
|
|
"kartoffel": { name: "Speisekartoffeln", price: 1.49, unit: "kg" },
|
|
"karotte": { name: "Speisemöhren", price: 1.29, unit: "kg" },
|
|
"zwiebel_gelb": { name: "Speisezwiebeln", price: 1.19, unit: "kg" },
|
|
"zwiebel_rot": { name: "Rote Zwiebeln", price: 1.69, unit: "kg" },
|
|
"knoblauch": { name: "Knoblauch", price: 0.89, unit: "Stk." },
|
|
"brokkoli": { name: "Brokkoli", price: 1.79, unit: "Stk." },
|
|
"paprika_rot": { name: "Paprika rot", price: 3.99, unit: "kg" },
|
|
"paprika_gelb": { name: "Paprika gelb", price: 3.99, unit: "kg" },
|
|
"paprika_gruen": { name: "Paprika grün", price: 3.49, unit: "kg" },
|
|
"champignon": { name: "Champignons weiß", price: 1.89, unit: "Stk." },
|
|
"zucchini": { name: "Zucchini", price: 2.19, unit: "kg" },
|
|
"avocado": { name: "Avocado Hass", price: 1.29, unit: "Stk." }
|
|
};
|
|
|
|
// Application State
|
|
let activeTab = 'checkout';
|
|
let isWebcamActive = false;
|
|
let webcamStream = null;
|
|
let simulatedStreamInterval = null;
|
|
let currentFrameBase64 = null;
|
|
let isDetecting = false;
|
|
let autoDetectEnabled = true;
|
|
let trainingPollInterval = null;
|
|
|
|
// Scanner Emulator State
|
|
let autoScanEnabled = true;
|
|
let stableClass = null;
|
|
let stableCount = 0;
|
|
const STABILITY_THRESHOLD = 4; // number of frames (approx 0.8s)
|
|
let lastScannedClass = null;
|
|
let scanLockActive = false;
|
|
let articlesDatabase = {};
|
|
|
|
// Cart State
|
|
let shoppingCart = [];
|
|
|
|
// DOM Elements
|
|
const videoEl = document.getElementById("webcam");
|
|
const canvasEl = document.getElementById("camera-canvas");
|
|
const ctx = canvasEl.getContext("2d");
|
|
const toggleCameraBtn = document.getElementById("toggle-camera-btn");
|
|
const triggerScaleBtn = document.getElementById("trigger-scale-btn");
|
|
const autoDetectToggle = document.getElementById("auto-detect-toggle");
|
|
const cameraTypeBadge = document.getElementById("camera-type-badge");
|
|
const quickSelectButtons = document.getElementById("quick-selection-buttons");
|
|
const cartList = document.getElementById("cart-list");
|
|
const cartSubtotalEl = document.getElementById("cart-subtotal");
|
|
const cartTotalEl = document.getElementById("cart-total");
|
|
const emptyCartMsg = document.getElementById("empty-cart-msg");
|
|
|
|
// --- TAB NAVIGATION ---
|
|
function switchTab(tabName) {
|
|
activeTab = tabName;
|
|
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
|
document.querySelectorAll('.nav-btn').forEach(el => el.classList.remove('active'));
|
|
|
|
document.getElementById(`tab-${tabName}`).classList.add('active');
|
|
document.getElementById(`tab-btn-${tabName}`).classList.add('active');
|
|
|
|
if (tabName === 'dashboard') {
|
|
// Start polling training status when entering dashboard
|
|
startTrainingPolling();
|
|
refreshAugmentedPreview();
|
|
} else {
|
|
stopTrainingPolling();
|
|
}
|
|
|
|
if (tabName === 'articles') {
|
|
loadArticlesDatabase();
|
|
}
|
|
}
|
|
|
|
// --- CAMERA & DETECTION LOGIC ---
|
|
async function startWebcam() {
|
|
try {
|
|
webcamStream = await navigator.mediaDevices.getUserMedia({
|
|
video: { width: 640, height: 480, facingMode: "environment" }
|
|
});
|
|
videoEl.srcObject = webcamStream;
|
|
videoEl.style.display = "none";
|
|
isWebcamActive = true;
|
|
cameraTypeBadge.textContent = "LIVE-WEBCAM";
|
|
cameraTypeBadge.style.background = "var(--color-primary)";
|
|
toggleCameraBtn.innerHTML = `
|
|
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/></svg>
|
|
Kamera deaktivieren
|
|
`;
|
|
document.getElementById("stream-status").textContent = "LIVE";
|
|
document.getElementById("stream-status").classList.add("live");
|
|
|
|
// Stop simulated stream if active
|
|
if (simulatedStreamInterval) {
|
|
clearInterval(simulatedStreamInterval);
|
|
simulatedStreamInterval = null;
|
|
}
|
|
|
|
requestAnimationFrame(processWebcamFrame);
|
|
} catch (err) {
|
|
console.error("Webcam access denied/unavailable:", err);
|
|
alert("Webcam konnte nicht gestartet werden. Der simulierte Kamera-Modus wird verwendet.");
|
|
startSimulatedStream();
|
|
}
|
|
}
|
|
|
|
function stopWebcam() {
|
|
if (webcamStream) {
|
|
webcamStream.getTracks().forEach(track => track.stop());
|
|
webcamStream = null;
|
|
}
|
|
videoEl.srcObject = null;
|
|
videoEl.style.display = "none";
|
|
isWebcamActive = false;
|
|
toggleCameraBtn.innerHTML = `
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
|
Webcam aktivieren
|
|
`;
|
|
startSimulatedStream();
|
|
}
|
|
|
|
function processWebcamFrame() {
|
|
if (!isWebcamActive) return;
|
|
|
|
// Draw current video frame to canvas
|
|
ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height);
|
|
|
|
// Get frame as base64
|
|
currentFrameBase64 = canvasEl.toDataURL("image/jpeg", 0.7);
|
|
|
|
// Auto-detect if enabled
|
|
if (autoDetectEnabled && !isDetecting) {
|
|
runDetection();
|
|
}
|
|
|
|
setTimeout(() => {
|
|
requestAnimationFrame(processWebcamFrame);
|
|
}, 150); // Limit detection frequency for performance
|
|
}
|
|
|
|
function startSimulatedStream() {
|
|
cameraTypeBadge.textContent = "SIMULATIONS-MODUS";
|
|
cameraTypeBadge.style.background = "var(--color-bg-dark)";
|
|
document.getElementById("stream-status").textContent = "Simuliert";
|
|
document.getElementById("stream-status").classList.add("live");
|
|
|
|
const loadSimulatedFrame = async () => {
|
|
try {
|
|
const response = await fetch("/api/simulated_frame");
|
|
const data = await response.json();
|
|
if (data.status === "success" && data.image) {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
ctx.drawImage(img, 0, 0, canvasEl.width, canvasEl.height);
|
|
currentFrameBase64 = data.image;
|
|
|
|
if (autoDetectEnabled && !isDetecting) {
|
|
runDetection();
|
|
}
|
|
};
|
|
img.src = data.image;
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load simulated frame:", err);
|
|
}
|
|
};
|
|
|
|
loadSimulatedFrame(); // First load immediately
|
|
simulatedStreamInterval = setInterval(loadSimulatedFrame, 2500); // cycle frames every 2.5s
|
|
}
|
|
|
|
// Perform detection API request
|
|
async function runDetection() {
|
|
if (!currentFrameBase64) return;
|
|
isDetecting = true;
|
|
|
|
try {
|
|
const response = await fetch("/api/detect", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ image: currentFrameBase64 })
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.status === "success") {
|
|
drawDetections(data.predictions);
|
|
updateQuickButtons(data.predictions);
|
|
processStabilityAndScan(data.predictions);
|
|
}
|
|
} catch (err) {
|
|
console.error("Detection error:", err);
|
|
} finally {
|
|
isDetecting = false;
|
|
}
|
|
}
|
|
|
|
// Draw bounding boxes on canvas
|
|
function drawDetections(predictions) {
|
|
// Redraw the base frame first (to clear old boxes)
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
ctx.drawImage(img, 0, 0, canvasEl.width, canvasEl.height);
|
|
|
|
// Draw boxes
|
|
predictions.forEach(pred => {
|
|
const [xmin, ymin, xmax, ymax] = pred.box;
|
|
const w = xmax - xmin;
|
|
const h = ymax - ymin;
|
|
const isHighConf = pred.confidence > 0.70;
|
|
|
|
// Box outline
|
|
ctx.lineWidth = 3;
|
|
ctx.strokeStyle = isHighConf ? "#10b981" : "#c084fc"; // Green for high conf, purple for low conf
|
|
ctx.strokeRect(xmin, ymin, w, h);
|
|
|
|
// Label box background
|
|
ctx.fillStyle = isHighConf ? "rgba(16, 185, 129, 0.85)" : "rgba(168, 85, 247, 0.85)";
|
|
const text = `${PRICING_DATABASE[pred.class]?.name || pred.class} (${(pred.confidence * 100).toFixed(0)}%)`;
|
|
ctx.font = "bold 12px sans-serif";
|
|
const textWidth = ctx.measureText(text).width;
|
|
ctx.fillRect(xmin - 1, ymin - 22, textWidth + 12, 22);
|
|
|
|
// Label text
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.fillText(text, xmin + 5, ymin - 6);
|
|
});
|
|
};
|
|
img.src = currentFrameBase64;
|
|
}
|
|
|
|
// Update cashier recommendation buttons
|
|
function updateQuickButtons(predictions) {
|
|
quickSelectButtons.innerHTML = "";
|
|
|
|
// Filter predictions with confidence > 0.10
|
|
const candidates = predictions.filter(p => p.confidence > 0.10);
|
|
|
|
if (candidates.length === 0) {
|
|
quickSelectButtons.innerHTML = `
|
|
<div class="empty-state">
|
|
<p>Keine Objekte erkannt</p>
|
|
<span>Confidence Schwellenwert unter 10%</span>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
candidates.forEach(pred => {
|
|
const itemInfo = PRICING_DATABASE[pred.class] || { name: pred.class, price: 1.0, unit: "kg" };
|
|
const confPercent = (pred.confidence * 100).toFixed(0);
|
|
const isHigh = pred.confidence > 0.70;
|
|
|
|
const btn = document.createElement("button");
|
|
btn.className = `quick-btn ${isHigh ? 'high-conf' : 'low-conf'}`;
|
|
btn.onclick = () => sendEanToPos(pred.class);
|
|
|
|
btn.innerHTML = `
|
|
<span class="btn-label">${itemInfo.name}</span>
|
|
<span class="btn-conf">${confPercent}% Conf</span>
|
|
`;
|
|
quickSelectButtons.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
// --- SHOPPING CART LOGIC ---
|
|
function addToCart(classKey) {
|
|
const itemInfo = PRICING_DATABASE[classKey];
|
|
if (!itemInfo) return;
|
|
|
|
// Simulate weight or piece count
|
|
let quantity, itemTotal;
|
|
if (itemInfo.unit === "kg") {
|
|
// Random weight between 0.150kg and 1.800kg
|
|
quantity = parseFloat((0.150 + Math.random() * 1.65).toFixed(3));
|
|
itemTotal = parseFloat((quantity * itemInfo.price).toFixed(2));
|
|
} else {
|
|
quantity = 1;
|
|
itemTotal = itemInfo.price;
|
|
}
|
|
|
|
const cartItem = {
|
|
key: classKey,
|
|
name: itemInfo.name,
|
|
pricePerUnit: itemInfo.price,
|
|
unit: itemInfo.unit,
|
|
quantity: quantity,
|
|
total: itemTotal,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
shoppingCart.push(cartItem);
|
|
updateCartUI();
|
|
|
|
// Scale animation feedback
|
|
const scaleCard = document.querySelector(".camera-card");
|
|
scaleCard.classList.add("scale-flash");
|
|
setTimeout(() => scaleCard.classList.remove("scale-flash"), 400);
|
|
}
|
|
|
|
function deleteCartItem(timestamp) {
|
|
shoppingCart = shoppingCart.filter(item => item.timestamp !== timestamp);
|
|
updateCartUI();
|
|
}
|
|
|
|
function clearCart() {
|
|
shoppingCart = [];
|
|
updateCartUI();
|
|
}
|
|
|
|
function checkoutCart() {
|
|
if (shoppingCart.length === 0) return;
|
|
const total = cartTotalEl.textContent;
|
|
alert(`Zahlung erfolgreich abgeschlossen!\nBetrag: ${total}\nKassenzettel gedruckt.`);
|
|
clearCart();
|
|
}
|
|
|
|
function updateCartUI() {
|
|
cartList.innerHTML = "";
|
|
|
|
if (shoppingCart.length === 0) {
|
|
emptyCartMsg.style.display = "flex";
|
|
cartSubtotalEl.textContent = "0,00 €";
|
|
cartTotalEl.textContent = "0,00 €";
|
|
return;
|
|
}
|
|
|
|
emptyCartMsg.style.display = "none";
|
|
let subtotal = 0;
|
|
|
|
shoppingCart.forEach(item => {
|
|
subtotal += item.total;
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "cart-item-row";
|
|
|
|
const details = document.createElement("div");
|
|
details.className = "item-details";
|
|
|
|
const name = document.createElement("span");
|
|
name.className = "item-name";
|
|
name.textContent = item.name;
|
|
|
|
const meta = document.createElement("span");
|
|
meta.className = "item-meta";
|
|
if (item.unit === "kg") {
|
|
meta.textContent = `${item.quantity.toFixed(3)} kg x ${item.pricePerUnit.toFixed(2)} €/kg`;
|
|
} else {
|
|
meta.textContent = `${item.quantity} Stk. x ${item.pricePerUnit.toFixed(2)} €/Stk.`;
|
|
}
|
|
|
|
details.appendChild(name);
|
|
details.appendChild(meta);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "item-price-actions";
|
|
|
|
const price = document.createElement("span");
|
|
price.className = "item-price";
|
|
price.textContent = `${item.total.toFixed(2).replace('.', ',')} €`;
|
|
|
|
const delBtn = document.createElement("button");
|
|
delBtn.className = "delete-item-btn";
|
|
delBtn.onclick = () => deleteCartItem(item.timestamp);
|
|
delBtn.innerHTML = `
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
|
`;
|
|
|
|
actions.appendChild(price);
|
|
actions.appendChild(delBtn);
|
|
|
|
row.appendChild(details);
|
|
row.appendChild(actions);
|
|
|
|
cartList.appendChild(row);
|
|
});
|
|
|
|
const tax = subtotal * 0.07; // 7% vat
|
|
cartSubtotalEl.textContent = `${subtotal.toFixed(2).replace('.', ',')} €`;
|
|
cartTotalEl.textContent = `${subtotal.toFixed(2).replace('.', ',')} €`;
|
|
}
|
|
|
|
// --- DEVELOPER DASHBOARD BACKEND CALLS ---
|
|
|
|
// 1. Synthetic dataset trigger
|
|
async function generateDataset() {
|
|
try {
|
|
const response = await fetch("/api/generate_dataset", { method: "POST" });
|
|
const data = await response.json();
|
|
alert(data.message);
|
|
startTrainingPolling(); // will start checking generation status
|
|
} catch (err) {
|
|
console.error("Failed to generate dataset:", err);
|
|
}
|
|
}
|
|
|
|
// 2. Refresh Albumentations augmented preview
|
|
async function refreshAugmentedPreview() {
|
|
const previewBox = document.getElementById("aug-preview-box");
|
|
previewBox.innerHTML = `<div class="preview-placeholder">Generiere Vorschau...</div>`;
|
|
|
|
try {
|
|
const response = await fetch("/api/augmented_preview");
|
|
const data = await response.json();
|
|
if (data.status === "success") {
|
|
previewBox.innerHTML = `<img src="${data.image}" class="preview-image" alt="Original vs. Augmentiert">`;
|
|
} else {
|
|
previewBox.innerHTML = `<div class="preview-placeholder text-danger">Fehler: ${data.message}</div>`;
|
|
}
|
|
} catch (err) {
|
|
previewBox.innerHTML = `<div class="preview-placeholder text-danger">Keine Trainingsbilder vorhanden.<br>Bitte zuerst "Datensatz generieren" klicken.</div>`;
|
|
}
|
|
}
|
|
|
|
// 3. Trigger model training
|
|
async function startTraining() {
|
|
const epochs = parseInt(document.getElementById("param-epochs").value) || 15;
|
|
const batchSize = parseInt(document.getElementById("param-batch").value) || 8;
|
|
|
|
try {
|
|
const response = await fetch("/api/train", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ epochs: epochs, batch_size: batchSize })
|
|
});
|
|
const data = await response.json();
|
|
alert(data.message);
|
|
startTrainingPolling();
|
|
} catch (err) {
|
|
console.error("Failed to start training:", err);
|
|
}
|
|
}
|
|
|
|
// Poll training API
|
|
function startTrainingPolling() {
|
|
if (trainingPollInterval) return;
|
|
|
|
const checkStatus = async () => {
|
|
try {
|
|
const response = await fetch("/api/train_status");
|
|
const data = await response.json();
|
|
|
|
updateTrainingStatusUI(data);
|
|
|
|
// If training or data generation completed or failed, we can stop/keep checking depending on state
|
|
if (data.status === "completed" || data.status === "failed" || data.status === "idle") {
|
|
// Keep checking periodically but slower, or stop if we want to
|
|
}
|
|
} catch (err) {
|
|
console.error("Status check failed:", err);
|
|
}
|
|
};
|
|
|
|
checkStatus();
|
|
trainingPollInterval = setInterval(checkStatus, 1500);
|
|
}
|
|
|
|
function stopTrainingPolling() {
|
|
if (trainingPollInterval) {
|
|
clearInterval(trainingPollInterval);
|
|
trainingPollInterval = null;
|
|
}
|
|
}
|
|
|
|
// Update training UI and plot graphs
|
|
function updateTrainingStatusUI(data) {
|
|
const trainingState = document.getElementById("training-state");
|
|
const trainingEpoch = document.getElementById("training-epoch");
|
|
const progressFill = document.getElementById("training-progress-fill");
|
|
const logMsg = document.getElementById("training-log-msg");
|
|
const statusBox = document.getElementById("training-status-box");
|
|
|
|
// Update status labels
|
|
trainingState.textContent = getGermanState(data.status);
|
|
trainingEpoch.textContent = data.epoch > 0 ? `Epoche: ${data.epoch}/${data.total_epochs}` : "Epoche: -/-";
|
|
progressFill.style.width = `${data.progress}%`;
|
|
logMsg.textContent = data.message;
|
|
|
|
// Update box glow styling
|
|
statusBox.className = "training-status-box";
|
|
if (data.status === "training") {
|
|
statusBox.classList.add("active-pulse");
|
|
}
|
|
|
|
// Plot Chart if loss history is available
|
|
if (data.train_loss && data.train_loss.length > 0) {
|
|
updateChart(data.train_loss, data.val_loss || []);
|
|
}
|
|
}
|
|
|
|
function getGermanState(status) {
|
|
switch (status) {
|
|
case "idle": return "Bereit";
|
|
case "generating_data": return "Generiere Daten...";
|
|
case "training": return "Training läuft...";
|
|
case "completed": return "Abgeschlossen";
|
|
case "failed": return "Fehlgeschlagen";
|
|
default: return status;
|
|
}
|
|
}
|
|
|
|
// Plot losses in SVG
|
|
function updateChart(trainLosses, valLosses) {
|
|
const svgWidth = 440; // width bounds inside SVG 500
|
|
const svgHeight = 150; // height bounds inside SVG 200
|
|
|
|
if (trainLosses.length === 0) return;
|
|
|
|
// Find maximum loss to scale Y-axis correctly
|
|
const allLosses = [...trainLosses, ...valLosses];
|
|
const maxLoss = Math.max(2.0, ...allLosses);
|
|
|
|
const getSvgCoords = (losses) => {
|
|
return losses.map((loss, idx) => {
|
|
const x = 40 + (idx / (losses.length - 1 || 1)) * svgWidth;
|
|
const y = 170 - (loss / maxLoss) * svgHeight;
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
});
|
|
};
|
|
|
|
const trainPoints = getSvgCoords(trainLosses);
|
|
document.getElementById("train-loss-path").setAttribute("d", "M " + trainPoints.join(" L "));
|
|
|
|
if (valLosses.length > 0) {
|
|
const valPoints = getSvgCoords(valLosses);
|
|
document.getElementById("val-loss-path").setAttribute("d", "M " + valPoints.join(" L "));
|
|
}
|
|
}
|
|
|
|
// 4. Export model to ONNX
|
|
async function exportModel() {
|
|
const onnxLog = document.getElementById("onnx-log");
|
|
const exportBtn = document.getElementById("export-onnx-btn");
|
|
const latencyVal = document.getElementById("onnx-latency");
|
|
const latencyStatus = document.getElementById("onnx-latency-status");
|
|
|
|
onnxLog.textContent = "Starte ONNX Export & Benchmarking...";
|
|
exportBtn.disabled = true;
|
|
|
|
try {
|
|
const response = await fetch("/api/export", { method: "POST" });
|
|
const data = await response.json();
|
|
|
|
exportBtn.disabled = false;
|
|
|
|
if (data.status === "success") {
|
|
onnxLog.textContent = data.log || "Export erfolgreich!";
|
|
|
|
// Extract latency from benchmark logs using regex
|
|
const match = data.log.match(/Average ONNX Runtime inference latency: ([\d\.]+) ms/);
|
|
if (match && match[1]) {
|
|
const latency = match[1];
|
|
latencyVal.textContent = `${latency} ms`;
|
|
latencyStatus.textContent = parseFloat(latency) < 50.0
|
|
? "✓ < 50 ms (Flüssige Live-Ansicht)"
|
|
: "⚠️ > 50 ms (Optimiere Hardware)";
|
|
} else {
|
|
latencyVal.textContent = "< 30 ms";
|
|
latencyStatus.textContent = "✓ Inferenz läuft flüssig";
|
|
}
|
|
alert("Modell erfolgreich in ONNX konvertiert und geladen!");
|
|
} else {
|
|
onnxLog.textContent = `Fehler: ${data.message}`;
|
|
alert(`Export fehlgeschlagen: ${data.message}`);
|
|
}
|
|
} catch (err) {
|
|
exportBtn.disabled = false;
|
|
onnxLog.textContent = `Fehler bei der Server-Kommunikation.`;
|
|
alert("Export-Anfrage fehlgeschlagen. Vergewissere dich, dass ein PyTorch Modell (.pt) existiert.");
|
|
}
|
|
}
|
|
|
|
async function captureForTraining() {
|
|
if (!isWebcamActive) {
|
|
alert("Bitte aktivieren Sie zuerst die Live-Webcam, um ein echtes Foto aufzunehmen.");
|
|
return;
|
|
}
|
|
|
|
const classSelect = document.getElementById("capture-class-select");
|
|
const selectedClass = classSelect.value;
|
|
if (!selectedClass) return;
|
|
|
|
const captureBtn = document.getElementById("capture-image-btn");
|
|
captureBtn.disabled = true;
|
|
|
|
// Capture current frame from canvas (high quality)
|
|
const base64Frame = canvasEl.toDataURL("image/jpeg", 0.95);
|
|
|
|
try {
|
|
const response = await fetch("/api/capture_training_image", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ image: base64Frame, class_name: selectedClass })
|
|
});
|
|
const data = await response.json();
|
|
captureBtn.disabled = false;
|
|
|
|
if (data.status === "success") {
|
|
// Update display count
|
|
document.getElementById("capture-count-display").textContent = `Eigene Bilder: ${data.count}`;
|
|
|
|
// Flash camera box as visual feedback
|
|
const container = document.querySelector(".camera-card");
|
|
container.classList.add("scale-flash");
|
|
setTimeout(() => container.classList.remove("scale-flash"), 300);
|
|
|
|
console.log(data.message);
|
|
} else {
|
|
alert(`Fehler bei Aufnahme: ${data.message}`);
|
|
}
|
|
} catch (err) {
|
|
captureBtn.disabled = false;
|
|
console.error("Capture request failed:", err);
|
|
alert("Serverfehler bei Bild-Erfassung.");
|
|
}
|
|
}
|
|
|
|
async function initCapturePanel() {
|
|
const classSelect = document.getElementById("capture-class-select");
|
|
if (!classSelect) return;
|
|
|
|
classSelect.innerHTML = "";
|
|
Object.keys(PRICING_DATABASE).forEach(key => {
|
|
const opt = document.createElement("option");
|
|
opt.value = key;
|
|
opt.textContent = PRICING_DATABASE[key].name;
|
|
if (key === "kartoffel") {
|
|
opt.selected = true; // default to kartoffel
|
|
}
|
|
classSelect.appendChild(opt);
|
|
});
|
|
|
|
try {
|
|
const response = await fetch("/api/capture_count");
|
|
const data = await response.json();
|
|
document.getElementById("capture-count-display").textContent = `Eigene Bilder: ${data.count}`;
|
|
} catch (err) {
|
|
console.error("Failed to load initial capture count:", err);
|
|
}
|
|
}
|
|
|
|
// --- VIRTUAL BARCODE SCANNER LOGIC ---
|
|
|
|
// Initial load of EAN database on startup
|
|
async function initArticles() {
|
|
try {
|
|
const response = await fetch("/api/articles");
|
|
const data = await response.json();
|
|
articlesDatabase = data;
|
|
|
|
// Sync names back to PRICING_DATABASE
|
|
Object.keys(articlesDatabase).forEach(key => {
|
|
if (!PRICING_DATABASE[key]) {
|
|
PRICING_DATABASE[key] = { name: articlesDatabase[key].name, price: 1.99, unit: "Stk." };
|
|
} else {
|
|
PRICING_DATABASE[key].name = articlesDatabase[key].name;
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error("Fehler beim Initialisieren der Artikeldatenbank:", err);
|
|
}
|
|
}
|
|
|
|
// Render articles table rows in Article Management tab
|
|
function renderArticlesTable() {
|
|
const tbody = document.getElementById("articles-table-body");
|
|
if (!tbody) return;
|
|
tbody.innerHTML = "";
|
|
|
|
Object.keys(articlesDatabase).forEach(key => {
|
|
const article = articlesDatabase[key];
|
|
const tr = document.createElement("tr");
|
|
tr.style.borderBottom = "1px solid var(--border-glass)";
|
|
|
|
tr.innerHTML = `
|
|
<td style="padding: 12px 16px; font-family: monospace; color: var(--text-secondary);">${key}</td>
|
|
<td style="padding: 12px 16px;">
|
|
<input type="text" class="article-name-input input-field" data-key="${key}" value="${article.name}" style="width: 100%; max-width: 250px; background: rgba(255,255,255,0.05); border: 1px solid var(--border-glass); color: var(--text-primary); padding: 6px 12px; border-radius: 4px; font-size: 13px;">
|
|
</td>
|
|
<td style="padding: 12px 16px;">
|
|
<input type="text" class="article-ean-input input-field" data-key="${key}" value="${article.ean}" style="width: 100%; max-width: 250px; background: rgba(255,255,255,0.05); border: 1px solid var(--border-glass); color: var(--text-primary); padding: 6px 12px; border-radius: 4px; font-size: 13px;" placeholder="z.B. 4001234000018">
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
// Load articles table in Article Management tab
|
|
async function loadArticlesDatabase() {
|
|
try {
|
|
const response = await fetch("/api/articles");
|
|
const data = await response.json();
|
|
articlesDatabase = data;
|
|
|
|
// Sync to PRICING_DATABASE
|
|
Object.keys(articlesDatabase).forEach(key => {
|
|
if (!PRICING_DATABASE[key]) {
|
|
PRICING_DATABASE[key] = { name: articlesDatabase[key].name, price: 1.99, unit: "Stk." };
|
|
} else {
|
|
PRICING_DATABASE[key].name = articlesDatabase[key].name;
|
|
}
|
|
});
|
|
|
|
renderArticlesTable();
|
|
} catch (err) {
|
|
console.error("Fehler beim Laden der Artikeldatenbank:", err);
|
|
}
|
|
}
|
|
|
|
// Prompt to add a new article key and EAN code
|
|
function addNewArticlePrompt() {
|
|
const key = prompt("Geben Sie einen eindeutigen Systemschlüssel für den Artikel ein (z.B. 'mango', nur Kleinbuchstaben und Unterstriche):");
|
|
if (!key) return;
|
|
|
|
// Validate key: lowercase, letters, numbers, underscores
|
|
const keyRegex = /^[a-z0-9_]+$/;
|
|
if (!keyRegex.test(key)) {
|
|
alert("Ungültiger Schlüssel. Bitte nur Kleinbuchstaben (a-z), Zahlen (0-9) und Unterstriche (_) verwenden.");
|
|
return;
|
|
}
|
|
|
|
if (PRICING_DATABASE[key] || articlesDatabase[key]) {
|
|
alert("Dieser Artikel-Schlüssel existiert bereits.");
|
|
return;
|
|
}
|
|
|
|
const name = prompt("Geben Sie den Anzeigenamen für den Artikel ein (z.B. 'Mango'):");
|
|
if (!name) return;
|
|
|
|
const ean = prompt("Geben Sie den EAN-Barcode für die Tastaturemulation ein (z.B. '4001234000292'):");
|
|
if (!ean) return;
|
|
|
|
// Add to local articlesDatabase and PRICING_DATABASE
|
|
articlesDatabase[key] = { name: name, ean: ean };
|
|
PRICING_DATABASE[key] = { name: name, price: 1.99, unit: "Stk." };
|
|
|
|
// Reload the table and capture dropdown
|
|
renderArticlesTable();
|
|
initCapturePanel();
|
|
}
|
|
|
|
// Save articles database
|
|
async function saveArticlesDatabase() {
|
|
const tbody = document.getElementById("articles-table-body");
|
|
if (!tbody) return;
|
|
|
|
const nameInputs = tbody.querySelectorAll(".article-name-input");
|
|
const eanInputs = tbody.querySelectorAll(".article-ean-input");
|
|
|
|
const updatedArticles = {};
|
|
nameInputs.forEach((input, index) => {
|
|
const key = input.getAttribute("data-key");
|
|
const name = input.value.trim();
|
|
const ean = eanInputs[index].value.trim();
|
|
updatedArticles[key] = { name, ean };
|
|
});
|
|
|
|
try {
|
|
const response = await fetch("/api/articles", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ articles: updatedArticles })
|
|
});
|
|
const data = await response.json();
|
|
if (data.status === "success") {
|
|
articlesDatabase = updatedArticles;
|
|
|
|
// Sync updated articles to PRICING_DATABASE
|
|
Object.keys(updatedArticles).forEach(key => {
|
|
if (!PRICING_DATABASE[key]) {
|
|
PRICING_DATABASE[key] = { name: updatedArticles[key].name, price: 1.99, unit: "Stk." };
|
|
} else {
|
|
PRICING_DATABASE[key].name = updatedArticles[key].name;
|
|
}
|
|
});
|
|
|
|
// Update capture dropdown
|
|
initCapturePanel();
|
|
|
|
alert("Artikeldatenbank erfolgreich gespeichert!");
|
|
} else {
|
|
alert("Fehler beim Speichern: " + data.message);
|
|
}
|
|
} catch (err) {
|
|
console.error("Fehler beim Speichern der Artikeldatenbank:", err);
|
|
alert("Serverfehler beim Speichern der Artikeldatenbank.");
|
|
}
|
|
}
|
|
|
|
// Play high-pitched scanner beep
|
|
function playScannerBeep() {
|
|
try {
|
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
const oscillator = audioCtx.createOscillator();
|
|
const gainNode = audioCtx.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioCtx.destination);
|
|
|
|
oscillator.type = "sine";
|
|
oscillator.frequency.setValueAtTime(1000, audioCtx.currentTime); // 1kHz beep
|
|
|
|
gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
|
|
gainNode.gain.linearRampToValueAtTime(0.2, audioCtx.currentTime + 0.01);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.15);
|
|
|
|
oscillator.start(audioCtx.currentTime);
|
|
oscillator.stop(audioCtx.currentTime + 0.16);
|
|
} catch (err) {
|
|
console.error("Audio Context failed to play beep:", err);
|
|
}
|
|
}
|
|
|
|
// Add log message to the terminal container
|
|
function addScanLog(msg) {
|
|
const logEl = document.getElementById("scanner-log");
|
|
if (!logEl) return;
|
|
|
|
const timeStr = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
const logLine = `[${timeStr}] ${msg}`;
|
|
|
|
if (logEl.textContent === "Warte auf Erkennung...") {
|
|
logEl.textContent = logLine;
|
|
} else {
|
|
logEl.textContent += "\\n" + logLine;
|
|
}
|
|
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
|
|
// Send EAN code to backend scanner endpoint
|
|
async function sendEanToPos(classKey) {
|
|
const article = articlesDatabase[classKey] || { name: PRICING_DATABASE[classKey]?.name || classKey, ean: "" };
|
|
|
|
if (!article.ean) {
|
|
addScanLog(`⚠️ Fehler: Kein EAN-Code für ${article.name} definiert.`);
|
|
return;
|
|
}
|
|
|
|
addScanLog(`Scanne ${article.name} (EAN: ${article.ean})...`);
|
|
|
|
try {
|
|
const response = await fetch("/api/scan_ean", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ class_name: classKey })
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.status === "success") {
|
|
playScannerBeep();
|
|
addScanLog(`✓ EAN ${article.ean} erfolgreich gesendet.`);
|
|
|
|
// Visual flash feedback on scanner card
|
|
const scannerCard = document.querySelector(".scanner-card");
|
|
if (scannerCard) {
|
|
scannerCard.classList.add("scale-flash");
|
|
setTimeout(() => scannerCard.classList.remove("scale-flash"), 400);
|
|
}
|
|
} else {
|
|
addScanLog(`❌ Fehler: ${data.message || "Tastaturemulation fehlgeschlagen"}`);
|
|
}
|
|
} catch (err) {
|
|
console.error("Scan API error:", err);
|
|
addScanLog(`❌ Serverfehler beim Senden des EAN-Codes.`);
|
|
}
|
|
}
|
|
|
|
// Stability filter logic
|
|
async function processStabilityAndScan(predictions) {
|
|
const autoScanToggle = document.getElementById("auto-scan-toggle");
|
|
const autoScan = autoScanToggle ? autoScanToggle.checked : true;
|
|
|
|
// Find the prediction with the highest confidence
|
|
let topPrediction = null;
|
|
if (predictions && predictions.length > 0) {
|
|
predictions.forEach(p => {
|
|
if (!topPrediction || p.confidence > topPrediction.confidence) {
|
|
topPrediction = p;
|
|
}
|
|
});
|
|
}
|
|
|
|
const scannerIndicator = document.getElementById("scanner-indicator");
|
|
const progressWrapper = document.getElementById("stability-progress-wrapper");
|
|
const progressFill = document.getElementById("stability-progress-fill");
|
|
|
|
const STABLE_CONF_THRESHOLD = 0.75;
|
|
|
|
if (topPrediction && topPrediction.confidence >= STABLE_CONF_THRESHOLD) {
|
|
const detectedClass = topPrediction.class;
|
|
|
|
if (stableClass !== detectedClass) {
|
|
stableClass = detectedClass;
|
|
stableCount = 1;
|
|
} else {
|
|
stableCount++;
|
|
}
|
|
|
|
if (progressWrapper && progressFill) {
|
|
progressWrapper.style.display = "block";
|
|
const percent = Math.min(100, (stableCount / STABILITY_THRESHOLD) * 100);
|
|
progressFill.style.width = `${percent}%`;
|
|
}
|
|
|
|
if (scannerIndicator) {
|
|
const articleName = PRICING_DATABASE[detectedClass]?.name || detectedClass;
|
|
scannerIndicator.textContent = `Erkenne ${articleName}... (${stableCount}/${STABILITY_THRESHOLD})`;
|
|
scannerIndicator.style.color = "var(--color-accent)";
|
|
}
|
|
|
|
if (stableCount >= STABILITY_THRESHOLD) {
|
|
if (lastScannedClass !== detectedClass && !scanLockActive) {
|
|
if (autoScan) {
|
|
await sendEanToPos(detectedClass);
|
|
} else {
|
|
addScanLog(`[Auto-Scan aus] ${PRICING_DATABASE[detectedClass]?.name || detectedClass} stabil erkannt.`);
|
|
}
|
|
lastScannedClass = detectedClass;
|
|
scanLockActive = true;
|
|
}
|
|
|
|
if (progressFill) progressFill.style.width = "0%";
|
|
if (progressWrapper) progressWrapper.style.display = "none";
|
|
|
|
if (scannerIndicator) {
|
|
scannerIndicator.textContent = `Gesperrt (Waage leeren)`;
|
|
scannerIndicator.style.color = "var(--color-danger)";
|
|
}
|
|
}
|
|
} else {
|
|
const CLEAR_THRESHOLD = 0.15;
|
|
|
|
if (!topPrediction || topPrediction.confidence < CLEAR_THRESHOLD) {
|
|
stableClass = null;
|
|
stableCount = 0;
|
|
scanLockActive = false;
|
|
lastScannedClass = null;
|
|
|
|
if (progressWrapper) progressWrapper.style.display = "none";
|
|
if (progressFill) progressFill.style.width = "0%";
|
|
if (scannerIndicator) {
|
|
scannerIndicator.textContent = "Bereit (Warte auf Objekt)";
|
|
scannerIndicator.style.color = "var(--color-success)";
|
|
}
|
|
} else {
|
|
stableCount = Math.max(0, stableCount - 1);
|
|
if (progressWrapper && progressFill) {
|
|
const percent = (stableCount / STABILITY_THRESHOLD) * 100;
|
|
progressFill.style.width = `${percent}%`;
|
|
}
|
|
if (scannerIndicator && stableCount > 0) {
|
|
scannerIndicator.textContent = `Signal instabil...`;
|
|
scannerIndicator.style.color = "var(--color-warning)";
|
|
} else if (scannerIndicator && !scanLockActive) {
|
|
scannerIndicator.textContent = "Bereit (Signal instabil)";
|
|
scannerIndicator.style.color = "var(--color-success)";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Manual scan trigger
|
|
async function triggerManualScan() {
|
|
if (stableClass) {
|
|
addScanLog(`Manueller Scan ausgelöst für ${PRICING_DATABASE[stableClass]?.name || stableClass}...`);
|
|
await sendEanToPos(stableClass);
|
|
} else {
|
|
addScanLog(`⚠️ Kein stabiles Objekt auf der Waage. Manueller Scan abgebrochen.`);
|
|
alert("Bitte legen Sie ein Produkt auf die Waage, um es zu scannen.");
|
|
}
|
|
}
|
|
|
|
// --- INTERACTIVE EVENT LISTENERS ---
|
|
toggleCameraBtn.addEventListener("click", () => {
|
|
if (isWebcamActive) {
|
|
stopWebcam();
|
|
} else {
|
|
startWebcam();
|
|
}
|
|
});
|
|
|
|
triggerScaleBtn.addEventListener("click", () => {
|
|
// Flash scale overlay on manual trigger
|
|
const overlay = document.querySelector(".camera-card");
|
|
overlay.classList.add("scale-flash");
|
|
setTimeout(() => overlay.classList.remove("scale-flash"), 400);
|
|
|
|
// Explicitly run detection
|
|
runDetection();
|
|
});
|
|
|
|
autoDetectToggle.addEventListener("change", (e) => {
|
|
autoDetectEnabled = e.target.checked;
|
|
});
|
|
|
|
// App Startup Initialization
|
|
window.addEventListener("DOMContentLoaded", () => {
|
|
// Start in simulated video mode by default
|
|
startSimulatedStream();
|
|
|
|
// Fetch articles from database
|
|
initArticles();
|
|
|
|
// Read parameters from local values
|
|
updateCartUI();
|
|
|
|
// Initialize capture classes
|
|
initCapturePanel();
|
|
});
|