initial commit
This commit is contained in:
BIN
app/__pycache__/main.cpython-311.pyc
Normal file
BIN
app/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
523
app/main.py
Normal file
523
app/main.py
Normal file
@@ -0,0 +1,523 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import base64
|
||||
import subprocess
|
||||
from io import BytesIO
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
import cv2
|
||||
import albumentations as A
|
||||
import ctypes
|
||||
import time
|
||||
import socket
|
||||
import threading
|
||||
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from inference import FruitVegetableDetector, CLASSES
|
||||
|
||||
app = FastAPI(title="POS Object Detection API", description="Fruit & Vegetable checkout detection microservice")
|
||||
|
||||
# Initialize detector
|
||||
detector = FruitVegetableDetector()
|
||||
|
||||
# Paths
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
STATUS_FILE = os.path.join(BASE_DIR, "training_status.json")
|
||||
|
||||
# Data Models
|
||||
class DetectionRequest(BaseModel):
|
||||
image: str # Base64 encoded image or Data URL
|
||||
|
||||
class TrainRequest(BaseModel):
|
||||
epochs: int = 15
|
||||
batch_size: int = 8
|
||||
lr: float = 0.001
|
||||
|
||||
class CaptureRequest(BaseModel):
|
||||
image: str
|
||||
class_name: str
|
||||
|
||||
class SaveArticlesRequest(BaseModel):
|
||||
articles: dict
|
||||
|
||||
class ScanRequest(BaseModel):
|
||||
class_name: str
|
||||
|
||||
# Serve frontend static files
|
||||
static_path = os.path.join(os.path.dirname(__file__), "static")
|
||||
os.makedirs(static_path, exist_ok=True)
|
||||
|
||||
# Helper function to read training status
|
||||
def get_training_status():
|
||||
if not os.path.exists(STATUS_FILE):
|
||||
return {
|
||||
"status": "idle",
|
||||
"epoch": 0,
|
||||
"total_epochs": 0,
|
||||
"train_loss": [],
|
||||
"val_loss": [],
|
||||
"progress": 0.0,
|
||||
"message": "No training history found."
|
||||
}
|
||||
try:
|
||||
with open(STATUS_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {"status": "error", "message": "Failed to read status file."}
|
||||
|
||||
# Helper to write status
|
||||
def write_training_status(status_dict):
|
||||
with open(STATUS_FILE, "w") as f:
|
||||
json.dump(status_dict, f, indent=2)
|
||||
|
||||
@app.post("/api/detect")
|
||||
async def detect_objects(payload: DetectionRequest):
|
||||
"""
|
||||
Accepts base64 encoded frame, runs ONNX inference, and returns detections.
|
||||
"""
|
||||
try:
|
||||
data_url = payload.image
|
||||
if "," in data_url:
|
||||
data_url = data_url.split(",")[1]
|
||||
|
||||
img_bytes = base64.b64decode(data_url)
|
||||
img = Image.open(BytesIO(img_bytes)).convert("RGB")
|
||||
img_np = np.array(img)
|
||||
|
||||
predictions = detector.detect(img_np)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"predictions": predictions
|
||||
}
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": f"Inference failed: {str(e)}"}
|
||||
)
|
||||
|
||||
def bg_generate_dataset():
|
||||
try:
|
||||
from data_generator import build_dataset
|
||||
build_dataset(base_dir=os.path.join(BASE_DIR, "dataset"), train_count=200, val_count=50)
|
||||
write_training_status({
|
||||
"status": "idle",
|
||||
"epoch": 0,
|
||||
"total_epochs": 0,
|
||||
"train_loss": [],
|
||||
"val_loss": [],
|
||||
"progress": 100.0,
|
||||
"message": "Dataset generation completed successfully!"
|
||||
})
|
||||
except Exception as e:
|
||||
write_training_status({
|
||||
"status": "failed",
|
||||
"epoch": 0,
|
||||
"total_epochs": 0,
|
||||
"train_loss": [],
|
||||
"val_loss": [],
|
||||
"progress": 0.0,
|
||||
"message": f"Dataset generation failed: {str(e)}"
|
||||
})
|
||||
|
||||
@app.post("/api/generate_dataset")
|
||||
async def trigger_dataset_generation(background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Triggers dataset generation asynchronously.
|
||||
"""
|
||||
status = get_training_status()
|
||||
if status.get("status") in ["training", "generating_data"]:
|
||||
raise HTTPException(status_code=400, detail="An operation is already in progress.")
|
||||
|
||||
write_training_status({
|
||||
"status": "generating_data",
|
||||
"epoch": 0,
|
||||
"total_epochs": 0,
|
||||
"train_loss": [],
|
||||
"val_loss": [],
|
||||
"progress": 10.0,
|
||||
"message": "Starting synthetic data generation process..."
|
||||
})
|
||||
|
||||
background_tasks.add_task(bg_generate_dataset)
|
||||
|
||||
return {"status": "success", "message": "Dataset generation started in the background."}
|
||||
|
||||
@app.post("/api/train")
|
||||
async def trigger_training(req: TrainRequest):
|
||||
"""
|
||||
Triggers model training.
|
||||
"""
|
||||
status = get_training_status()
|
||||
if status.get("status") in ["training", "generating_data"]:
|
||||
raise HTTPException(status_code=400, detail="Training or data generation is already in progress.")
|
||||
|
||||
write_training_status({
|
||||
"status": "training",
|
||||
"epoch": 0,
|
||||
"total_epochs": req.epochs,
|
||||
"train_loss": [],
|
||||
"val_loss": [],
|
||||
"progress": 5.0,
|
||||
"message": "Initializing training background process..."
|
||||
})
|
||||
|
||||
# Launch training process in background
|
||||
cmd = [
|
||||
sys.executable,
|
||||
os.path.join(BASE_DIR, "train.py"),
|
||||
"--epochs", str(req.epochs),
|
||||
"--batch-size", str(req.batch_size),
|
||||
"--lr", str(req.lr)
|
||||
]
|
||||
subprocess.Popen(cmd, cwd=BASE_DIR)
|
||||
|
||||
return {"status": "success", "message": f"Training initiated for {req.epochs} epochs."}
|
||||
|
||||
@app.get("/api/train_status")
|
||||
async def get_status():
|
||||
"""
|
||||
Returns current training status.
|
||||
"""
|
||||
# Check if background processes are actually running
|
||||
# If status says training/generating but no process matches, we can update status to failed/idle.
|
||||
status = get_training_status()
|
||||
return status
|
||||
|
||||
@app.post("/api/export")
|
||||
async def trigger_export():
|
||||
"""
|
||||
Exports trained PyTorch weights to ONNX format and reloads the detector.
|
||||
"""
|
||||
pytorch_path = os.path.join(BASE_DIR, "models", "model.pt")
|
||||
if not os.path.exists(pytorch_path):
|
||||
raise HTTPException(status_code=404, detail="No trained PyTorch weights found at models/model.pt. Train a model first.")
|
||||
|
||||
try:
|
||||
# Run export script synchronously since it is fast (few seconds)
|
||||
cmd = [sys.executable, os.path.join(BASE_DIR, "export.py")]
|
||||
result = subprocess.run(cmd, cwd=BASE_DIR, capture_output=True, text=True, check=True)
|
||||
|
||||
# Reload the detector model
|
||||
detector.load_model()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Model successfully exported to ONNX and loaded into memory.",
|
||||
"log": result.stdout
|
||||
}
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": f"ONNX Export failed: {str(e)}"}
|
||||
)
|
||||
|
||||
@app.get("/api/augmented_preview")
|
||||
async def get_augmented_preview():
|
||||
"""
|
||||
Generates a sample preview image from the generator, applies augmentations,
|
||||
draws bounding boxes on both, and returns them side-by-side.
|
||||
"""
|
||||
try:
|
||||
from data_generator import generate_sample
|
||||
|
||||
# Generate a raw sample image and its YOLO annotations
|
||||
img, annotations = generate_sample(width=320, height=320)
|
||||
img_np = np.array(img)
|
||||
|
||||
# Setup the same augmentation pipeline used in training
|
||||
transform = A.Compose([
|
||||
A.ShiftScaleRotate(shift_limit=0.1, scale_limit=0.15, rotate_limit=30, p=1.0),
|
||||
A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=1.0),
|
||||
A.GaussNoise(p=1.0)
|
||||
], bbox_params=A.BboxParams(format='pascal_voc', label_fields=['category_ids']))
|
||||
|
||||
# Parse annotations to box absolute coords
|
||||
boxes = []
|
||||
classes = []
|
||||
for ann in annotations:
|
||||
parts = ann.strip().split()
|
||||
if len(parts) == 5:
|
||||
class_id = int(parts[0])
|
||||
xc, yc, w, h = map(float, parts[1:])
|
||||
xmin = (xc - w/2) * 320
|
||||
ymin = (yc - h/2) * 320
|
||||
xmax = (xc + w/2) * 320
|
||||
ymax = (yc + h/2) * 320
|
||||
boxes.append([xmin, ymin, xmax, ymax])
|
||||
classes.append(class_id)
|
||||
|
||||
boxes = np.array(boxes, dtype=np.float32).reshape(-1, 4)
|
||||
|
||||
# Create augmented image
|
||||
augmented_np = img_np.copy()
|
||||
aug_boxes = boxes.copy()
|
||||
if len(boxes) > 0:
|
||||
try:
|
||||
augmented = transform(image=img_np, bboxes=boxes, category_ids=classes)
|
||||
augmented_np = augmented["image"]
|
||||
aug_boxes = np.array(augmented["bboxes"], dtype=np.float32).reshape(-1, 4)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Draw bounding boxes on original image
|
||||
orig_draw = img.copy()
|
||||
draw_tool = ImageDraw.Draw(orig_draw)
|
||||
for idx, box in enumerate(boxes):
|
||||
draw_tool.rectangle(box.tolist(), outline=(255, 0, 0), width=3)
|
||||
draw_tool.text((box[0]+2, box[1]+2), CLASSES[classes[idx]], fill=(255, 0, 0))
|
||||
|
||||
# Draw bounding boxes on augmented image
|
||||
aug_img = Image.fromarray(augmented_np)
|
||||
draw_tool_aug = ImageDraw.Draw(aug_img)
|
||||
for idx, box in enumerate(aug_boxes):
|
||||
draw_tool_aug.rectangle(box.tolist(), outline=(0, 255, 0), width=3)
|
||||
draw_tool_aug.text((box[0]+2, box[1]+2), CLASSES[classes[idx]], fill=(0, 255, 0))
|
||||
|
||||
# Create side-by-side preview canvas
|
||||
preview = Image.new("RGB", (640, 320))
|
||||
preview.paste(orig_draw, (0, 0))
|
||||
preview.paste(aug_img, (320, 0))
|
||||
|
||||
# Buffer and return as base64
|
||||
buffered = BytesIO()
|
||||
preview.save(buffered, format="JPEG")
|
||||
preview_base64 = base64.b64encode(buffered.getvalue()).decode()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"image": f"data:image/jpeg;base64,{preview_base64}"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to generate preview: {str(e)}")
|
||||
|
||||
@app.get("/api/simulated_frame")
|
||||
async def get_simulated_frame():
|
||||
"""
|
||||
Generates a raw synthetic scale frame (without annotations) for client-side POS testing.
|
||||
"""
|
||||
try:
|
||||
from data_generator import generate_sample
|
||||
# Generate raw sample image (640x480)
|
||||
img, _ = generate_sample(width=640, height=480)
|
||||
|
||||
buffered = BytesIO()
|
||||
img.save(buffered, format="JPEG")
|
||||
img_base64 = base64.b64encode(buffered.getvalue()).decode()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"image": f"data:image/jpeg;base64,{img_base64}"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to generate simulated frame: {str(e)}")
|
||||
|
||||
@app.post("/api/capture_training_image")
|
||||
async def capture_training_image(payload: CaptureRequest):
|
||||
"""
|
||||
Saves a webcam snapshot and generates a centered YOLO format bounding box label.
|
||||
"""
|
||||
try:
|
||||
class_name = payload.class_name
|
||||
if class_name not in CLASSES:
|
||||
raise HTTPException(status_code=400, detail=f"Ungültige Produktklasse: {class_name}")
|
||||
|
||||
class_idx = CLASSES.index(class_name)
|
||||
|
||||
# Decode base64
|
||||
data_url = payload.image
|
||||
if "," in data_url:
|
||||
data_url = data_url.split(",")[1]
|
||||
img_bytes = base64.b64decode(data_url)
|
||||
img = Image.open(BytesIO(img_bytes)).convert("RGB")
|
||||
|
||||
# Save image with unique filename
|
||||
import time
|
||||
timestamp = int(time.time() * 1000)
|
||||
filename = f"user_capture_{timestamp}.jpg"
|
||||
|
||||
images_dir = os.path.join(BASE_DIR, "dataset", "images", "train")
|
||||
labels_dir = os.path.join(BASE_DIR, "dataset", "labels", "train")
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
os.makedirs(labels_dir, exist_ok=True)
|
||||
|
||||
img.save(os.path.join(images_dir, filename), quality=95)
|
||||
|
||||
# Write YOLO label centered at scale
|
||||
# Bounding box: class_idx, x_center=0.5, y_center=0.5, width=0.6, height=0.6
|
||||
label_filename = f"user_capture_{timestamp}.txt"
|
||||
with open(os.path.join(labels_dir, label_filename), "w") as f:
|
||||
f.write(f"{class_idx} 0.500000 0.500000 0.650000 0.650000\n")
|
||||
|
||||
# Count total user captured images
|
||||
user_images = [f for f in os.listdir(images_dir) if f.startswith("user_capture_")]
|
||||
count = len(user_images)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Bild erfolgreich als Training für {class_name} erfasst!",
|
||||
"count": count
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Fehler bei Bild-Erfassung: {str(e)}")
|
||||
|
||||
@app.get("/api/capture_count")
|
||||
async def get_capture_count():
|
||||
"""
|
||||
Returns the number of user captured training images.
|
||||
"""
|
||||
images_dir = os.path.join(BASE_DIR, "dataset", "images", "train")
|
||||
if not os.path.exists(images_dir):
|
||||
return {"count": 0}
|
||||
user_images = [f for f in os.listdir(images_dir) if f.startswith("user_capture_")]
|
||||
return {"count": len(user_images)}
|
||||
|
||||
# --- BARCODE SCANNER EMULATION (Windows Keyboard Hook) ---
|
||||
KEYEVENTF_KEYUP = 0x0002
|
||||
VK_RETURN = 0x0D
|
||||
ARTICLES_FILE = os.path.join(BASE_DIR, "articles.json")
|
||||
|
||||
def simulate_keyboard_type(text: str):
|
||||
"""
|
||||
Types the EAN code and presses Enter globally on Windows.
|
||||
Uses hardware scan codes mapped from virtual key codes for highest compatibility.
|
||||
"""
|
||||
try:
|
||||
# Give a small 200ms delay to allow cashier window to gain focus if clicked manually
|
||||
time.sleep(0.2)
|
||||
|
||||
KEYEVENTF_KEYUP = 0x0002
|
||||
MAPVK_VK_TO_VSC = 0
|
||||
|
||||
for char in text:
|
||||
vk = None
|
||||
if '0' <= char <= '9':
|
||||
vk = 0x30 + int(char)
|
||||
elif 'A' <= char <= 'Z':
|
||||
vk = ord(char)
|
||||
elif 'a' <= char <= 'z':
|
||||
vk = ord(char.upper())
|
||||
|
||||
if vk is not None:
|
||||
# Map to hardware scan code
|
||||
scan_code = ctypes.windll.user32.MapVirtualKeyW(vk, MAPVK_VK_TO_VSC)
|
||||
|
||||
# Key Down
|
||||
ctypes.windll.user32.keybd_event(vk, scan_code, 0, 0)
|
||||
time.sleep(0.001)
|
||||
|
||||
# Key Up
|
||||
ctypes.windll.user32.keybd_event(vk, scan_code, KEYEVENTF_KEYUP, 0)
|
||||
time.sleep(0.001)
|
||||
|
||||
# Send VK_RETURN (Enter) with scan code
|
||||
vk_ret = 0x0D
|
||||
scan_ret = ctypes.windll.user32.MapVirtualKeyW(vk_ret, MAPVK_VK_TO_VSC)
|
||||
|
||||
ctypes.windll.user32.keybd_event(vk_ret, scan_ret, 0, 0)
|
||||
time.sleep(0.001)
|
||||
ctypes.windll.user32.keybd_event(vk_ret, scan_ret, KEYEVENTF_KEYUP, 0)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to type keyboard input: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
@app.get("/api/articles")
|
||||
async def get_articles():
|
||||
if not os.path.exists(ARTICLES_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(ARTICLES_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Laden der Artikeldatenbank: {str(e)}")
|
||||
|
||||
@app.post("/api/articles")
|
||||
async def save_articles(payload: SaveArticlesRequest):
|
||||
try:
|
||||
with open(ARTICLES_FILE, "w") as f:
|
||||
json.dump(payload.articles, f, indent=2)
|
||||
from data_generator import reload_classes
|
||||
reload_classes()
|
||||
return {"status": "success", "message": "Artikeldatenbank erfolgreich gespeichert!"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Speichern der Artikeldatenbank: {str(e)}")
|
||||
|
||||
# --- TCP SOCKET CLIENT SENDER ---
|
||||
TCP_PORT = 9000
|
||||
|
||||
def send_ean_via_tcp(ean_code: str):
|
||||
"""
|
||||
Sends the EAN code to the cash register over TCP on port 9000.
|
||||
Since the cash register is the server (listening on port 9000), we connect
|
||||
as a client, transmit the barcode, and close the connection.
|
||||
"""
|
||||
message = (ean_code + "\r\n").encode('utf-8')
|
||||
|
||||
print(f"TCP Client Mode: Connecting to cash register at 127.0.0.1:{TCP_PORT}...", flush=True)
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(0.5) # Fast timeout
|
||||
s.connect(('127.0.0.1', TCP_PORT))
|
||||
s.sendall(message)
|
||||
print(f"TCP Client: Successfully sent EAN {ean_code} to cash register.", flush=True)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"TCP Client: Connection to cash register failed (is register listening on port {TCP_PORT}?): {e}", flush=True)
|
||||
return False
|
||||
|
||||
@app.post("/api/scan_ean")
|
||||
async def scan_ean_endpoint(payload: ScanRequest):
|
||||
try:
|
||||
class_name = payload.class_name
|
||||
if not os.path.exists(ARTICLES_FILE):
|
||||
raise HTTPException(status_code=404, detail="Artikeldatenbank existiert nicht.")
|
||||
|
||||
with open(ARTICLES_FILE, "r") as f:
|
||||
articles = json.load(f)
|
||||
|
||||
if class_name not in articles:
|
||||
raise HTTPException(status_code=404, detail=f"Klasse {class_name} nicht in Artikeldatenbank.")
|
||||
|
||||
ean_code = articles[class_name].get("ean", "")
|
||||
if not ean_code:
|
||||
raise HTTPException(status_code=400, detail=f"Kein EAN-Code für {class_name} hinterlegt.")
|
||||
|
||||
# 1. Send via TCP (Direct socket integration)
|
||||
tcp_sent = send_ean_via_tcp(ean_code)
|
||||
|
||||
# 2. Parallel keyboard emulation fallback on Windows
|
||||
keyboard_sent = simulate_keyboard_type(ean_code)
|
||||
|
||||
if tcp_sent or keyboard_sent:
|
||||
methods = []
|
||||
if tcp_sent:
|
||||
methods.append("TCP")
|
||||
if keyboard_sent:
|
||||
methods.append("Tastatur")
|
||||
method_desc = " + ".join(methods)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"class": class_name,
|
||||
"name": articles[class_name].get("name", class_name),
|
||||
"ean": ean_code,
|
||||
"message": f"Barcode {ean_code} gesendet via {method_desc}!"
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Übertragung fehlgeschlagen (TCP und Tastaturemulation gescheitert).")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Mount static files (must be after api definitions)
|
||||
app.mount("/", StaticFiles(directory=static_path, html=True), name="static")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return FileResponse(os.path.join(static_path, "index.html"))
|
||||
1025
app/static/app.js
Normal file
1025
app/static/app.js
Normal file
File diff suppressed because it is too large
Load Diff
382
app/static/index.html
Normal file
382
app/static/index.html
Normal file
@@ -0,0 +1,382 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FruitVision AI - POS Objekterkennung</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-logo">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 20C7.59 20 4 16.41 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 16.41 16.41 20 12 20Z" fill="var(--color-primary)"/>
|
||||
<path d="M11 6H13V12H11V6ZM11 14H13V16H11V14Z" fill="var(--color-primary)"/>
|
||||
<circle cx="12" cy="12" r="3" fill="var(--color-accent)"/>
|
||||
</svg>
|
||||
<h1>FruitVision <span class="badge">POS-AI</span></h1>
|
||||
</div>
|
||||
|
||||
<nav class="header-nav">
|
||||
<button class="nav-btn active" id="tab-btn-checkout" onclick="switchTab('checkout')">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="2" y1="12" x2="22" y2="12"/></svg>
|
||||
Kassensystem
|
||||
</button>
|
||||
<button class="nav-btn" id="tab-btn-articles" onclick="switchTab('articles')">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><circle cx="10" cy="9" r="1"/></svg>
|
||||
Artikel-Verwaltung
|
||||
</button>
|
||||
<button class="nav-btn" id="tab-btn-dashboard" onclick="switchTab('dashboard')">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||
Developer-Dashboard
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<main class="app-main">
|
||||
|
||||
<!-- CHECKOUT TAB -->
|
||||
<section id="tab-checkout" class="tab-content active">
|
||||
<div class="checkout-grid">
|
||||
<!-- Left: Camera Frame & Scale -->
|
||||
<div class="card camera-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2 class="card-title">Kamera-Vorschau</h2>
|
||||
<p class="card-subtitle">Positioniere das lose Obst & Gemüse auf der Waage</p>
|
||||
</div>
|
||||
<span class="status-indicator live" id="stream-status">Simuliert</span>
|
||||
</div>
|
||||
|
||||
<div class="camera-viewport-container">
|
||||
<video id="webcam" autoplay playsinline muted style="display: none;"></video>
|
||||
<canvas id="camera-canvas" width="640" height="480"></canvas>
|
||||
|
||||
<!-- Calibration Overlay / Grid -->
|
||||
<div class="scale-target-overlay">
|
||||
<div class="scale-target-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="stream-badge" id="camera-type-badge">SIMULATION-MODUS</div>
|
||||
</div>
|
||||
|
||||
<div class="camera-controls">
|
||||
<button id="toggle-camera-btn" class="btn btn-secondary">
|
||||
<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
|
||||
</button>
|
||||
<button id="trigger-scale-btn" class="btn btn-primary btn-glow">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M12 18h.01"/></svg>
|
||||
Waage auslösen (Trigger)
|
||||
</button>
|
||||
<label class="toggle-control">
|
||||
<input type="checkbox" id="auto-detect-toggle" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
<span class="toggle-label">Auto-Detect</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Real Data Capture Panel -->
|
||||
<div class="capture-training-panel">
|
||||
<h3>Reales Produktbild erfassen (Datensammler)</h3>
|
||||
<div class="capture-controls-row">
|
||||
<select id="capture-class-select">
|
||||
<!-- Options populated by JS -->
|
||||
</select>
|
||||
<button id="capture-image-btn" class="btn btn-accent" onclick="captureForTraining()">
|
||||
<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>
|
||||
Bild aufnehmen
|
||||
</button>
|
||||
<span class="capture-count-badge" id="capture-count-display">Eigene Bilder: 0</span>
|
||||
</div>
|
||||
<p class="panel-info-text">
|
||||
Legen Sie Ihre echte Kartoffel mittig in das Zielkreuz und nehmen Sie 10-15 Bilder aus verschiedenen Winkeln/Entfernungen auf.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Quick Selection & Shopping Cart -->
|
||||
<div class="checkout-sidebar">
|
||||
|
||||
<!-- AI Recommendations / Schnellwahl -->
|
||||
<div class="card quick-select-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">KI Schnellwahltasten</h2>
|
||||
<p class="card-subtitle">Vorschläge über Confidence-Schwellenwert > 10%</p>
|
||||
</div>
|
||||
<div class="quick-buttons-container" id="quick-selection-buttons">
|
||||
<div class="empty-state">
|
||||
<p>Warte auf Objekterkennung...</p>
|
||||
<span>Legen Sie Obst auf die Waage</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual Barcode-Scanner (Tastatur-Emulator) -->
|
||||
<div class="card scanner-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Virtueller Barcode-Scanner</h2>
|
||||
<label class="toggle-control">
|
||||
<input type="checkbox" id="auto-scan-toggle" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
<span class="toggle-label">Auto-Scan</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="card-body" style="padding: 16px 20px;">
|
||||
<div class="scanner-status-box" style="display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 10px;">
|
||||
<span class="scanner-status-label" style="color: var(--text-secondary);">Scanner-Status:</span>
|
||||
<span class="scanner-indicator" id="scanner-indicator" style="font-weight: 600; color: var(--color-success);">Bereit</span>
|
||||
</div>
|
||||
<div class="progress-bar-container" id="stability-progress-wrapper" style="height: 6px; margin-bottom: 12px; display: none;">
|
||||
<div class="progress-bar-fill" id="stability-progress-fill" style="width: 0%; background: var(--color-accent); box-shadow: 0 0 10px var(--color-accent-glow);"></div>
|
||||
</div>
|
||||
<div class="terminal-container" style="margin-top: 0; margin-bottom: 10px;">
|
||||
<div class="terminal-header">Scanner-Verlauf (Keystroke Logs)</div>
|
||||
<div class="scanner-log-output" id="scanner-log" style="font-family: monospace; font-size: 11px; color: var(--color-success); height: 80px; overflow-y: auto; line-height: 1.4;">Warte auf Erkennung...</div>
|
||||
</div>
|
||||
<button id="manual-scan-btn" class="btn btn-accent btn-glow" style="width: 100%;" onclick="triggerManualScan()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7V4h3M4 17v3h3m10-16h3v3m-3 13v3h3M8 12h8m-4-4v8"/></svg>
|
||||
EAN an aktive Kasse senden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shopping Cart / Bon -->
|
||||
<div class="card cart-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Kassenzettel</h2>
|
||||
<p class="card-subtitle">Aktuelle Transaktion</p>
|
||||
</div>
|
||||
<div class="cart-items" id="cart-list">
|
||||
<div class="empty-cart-state" id="empty-cart-msg">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>
|
||||
<p>Einkaufswagen ist leer</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cart-summary">
|
||||
<div class="summary-row">
|
||||
<span>Zwischensumme:</span>
|
||||
<span id="cart-subtotal">0,00 €</span>
|
||||
</div>
|
||||
<div class="summary-row total">
|
||||
<span>Gesamtsumme:</span>
|
||||
<span id="cart-total">0,00 €</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cart-actions">
|
||||
<button id="cancel-cart-btn" class="btn btn-danger" onclick="clearCart()">Storno</button>
|
||||
<button id="pay-btn" class="btn btn-success btn-glow" onclick="checkoutCart()">Bezahlen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DEVELOPER DASHBOARD TAB -->
|
||||
<section id="tab-dashboard" class="tab-content">
|
||||
<div class="dashboard-grid">
|
||||
|
||||
<!-- Left: Pipeline Steps -->
|
||||
<div class="dashboard-column">
|
||||
|
||||
<!-- Step 1: Synthetic Dataset & Augmentation -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="step-num">Schritt 1</span>
|
||||
<h2 class="card-title">Synthetische Datenpipeline</h2>
|
||||
<p class="card-subtitle">Generiere Trainingsdaten & Augmentierungen (Albumentations)</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="action-row">
|
||||
<button class="btn btn-secondary" onclick="generateDataset()">
|
||||
Datensatz generieren
|
||||
</button>
|
||||
<span class="action-desc">Erstellt 200 Trainings- & 50 Validierungsbilder (YOLO-Format)</span>
|
||||
</div>
|
||||
|
||||
<div class="augmentation-section">
|
||||
<div class="section-title-row">
|
||||
<h3>Augmentierungs-Vorschau (Live-Pipeline)</h3>
|
||||
<button class="btn btn-text" onclick="refreshAugmentedPreview()">Aktualisieren</button>
|
||||
</div>
|
||||
<p class="section-desc">Echtzeit-Störung (Shift, Rotate, Scale, Brightness, Noise) zur Vermeidung von Overfitting an Lichtbedingungen.</p>
|
||||
|
||||
<div class="preview-container" id="aug-preview-box">
|
||||
<div class="preview-placeholder">
|
||||
Klicke auf "Aktualisieren" um ein Bild und seine Albumentations-Störung anzuzeigen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Export & Hardware-Performance -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="step-num">Schritt 3</span>
|
||||
<h2 class="card-title">ONNX-Export & Hardware-Inferenz</h2>
|
||||
<p class="card-subtitle">Modell-Optimierung für flüssigen Kassenbetrieb unter 50 ms</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="action-row">
|
||||
<button class="btn btn-accent" id="export-onnx-btn" onclick="exportModel()">
|
||||
Modell in ONNX exportieren
|
||||
</button>
|
||||
<span class="action-desc">Konvertiert PyTorch (.pt) in ONNX (.onnx) für die Inferenz-Engine.</span>
|
||||
</div>
|
||||
|
||||
<div class="terminal-container">
|
||||
<div class="terminal-header">ONNX Compiler Logs</div>
|
||||
<pre class="terminal-output" id="onnx-log">Warte auf Export-Trigger...</pre>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span class="metric-label">Inferenz-Zeit (CPU/NPU)</span>
|
||||
<span class="metric-value text-accent" id="onnx-latency">- ms</span>
|
||||
<span class="metric-desc" id="onnx-latency-status">Warte auf Export</span>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span class="metric-label">Lizenzen & Schutzrecht</span>
|
||||
<span class="metric-value text-success">100% OK</span>
|
||||
<span class="metric-desc">Royalty-Free (BSD / MIT), kein GPL-Copyleft</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Model Training Dashboard -->
|
||||
<div class="dashboard-column">
|
||||
|
||||
<!-- Step 2: Model Training -->
|
||||
<div class="card training-card">
|
||||
<div class="card-header">
|
||||
<span class="step-num">Schritt 2</span>
|
||||
<h2 class="card-title">Transfer Learning (PyTorch)</h2>
|
||||
<p class="card-subtitle">Modelltraining auf SSDLite MobileNetV3-Large Basis</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="training-controls-row">
|
||||
<div class="control-group">
|
||||
<label for="param-epochs">Epochen</label>
|
||||
<input type="number" id="param-epochs" value="15" min="5" max="100">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="param-batch">Batch Size</label>
|
||||
<select id="param-batch">
|
||||
<option value="4">4</option>
|
||||
<option value="8" selected>8</option>
|
||||
<option value="16">16</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="start-training-btn" onclick="startTraining()">
|
||||
Training starten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Training Status -->
|
||||
<div class="training-status-box" id="training-status-box">
|
||||
<div class="status-header-row">
|
||||
<span class="status-title">Status: <strong id="training-state">Bereit</strong></span>
|
||||
<span class="status-epoch" id="training-epoch">Epoche: -/-</span>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill" id="training-progress-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
|
||||
<p class="status-log-message" id="training-log-msg">Klicke "Training starten" um den Feintuning-Prozess anzustoßen.</p>
|
||||
</div>
|
||||
|
||||
<!-- Chart Area -->
|
||||
<div class="chart-container">
|
||||
<h3>Lernkurve (Loss History)</h3>
|
||||
<div class="chart-wrapper">
|
||||
<svg viewBox="0 0 500 200" class="training-chart" id="training-loss-chart">
|
||||
<!-- Grid Lines -->
|
||||
<line x1="40" y1="20" x2="480" y2="20" stroke="#333842" stroke-dasharray="4"/>
|
||||
<line x1="40" y1="70" x2="480" y2="70" stroke="#333842" stroke-dasharray="4"/>
|
||||
<line x1="40" y1="120" x2="480" y2="120" stroke="#333842" stroke-dasharray="4"/>
|
||||
<line x1="40" y1="170" x2="480" y2="170" stroke="#4a505e"/>
|
||||
|
||||
<!-- Y-Axis Ticks -->
|
||||
<text x="30" y="25" fill="#8a92a3" font-size="10" text-anchor="end">2.0</text>
|
||||
<text x="30" y="75" fill="#8a92a3" font-size="10" text-anchor="end">1.0</text>
|
||||
<text x="30" y="125" fill="#8a92a3" font-size="10" text-anchor="end">0.5</text>
|
||||
<text x="30" y="175" fill="#8a92a3" font-size="10" text-anchor="end">0.0</text>
|
||||
|
||||
<!-- Chart Curves -->
|
||||
<path id="train-loss-path" d="" fill="none" stroke="var(--color-primary)" stroke-width="3"/>
|
||||
<path id="val-loss-path" d="" fill="none" stroke="var(--color-accent)" stroke-width="2" stroke-dasharray="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
<div class="legend-item"><span class="legend-color train"></span>Train Loss</div>
|
||||
<div class="legend-item"><span class="legend-color val"></span>Validation Loss</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ARTICLE MANAGEMENT TAB -->
|
||||
<section id="tab-articles" class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2 class="card-title">Artikel- & EAN-Verwaltung</h2>
|
||||
<p class="card-subtitle">Hinterlegen Sie die Barcodes (EAN) Ihrer Produkte für den virtuellen Scanner</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
<button class="btn btn-secondary" onclick="addNewArticlePrompt()">
|
||||
+ Artikel hinzufügen
|
||||
</button>
|
||||
<button class="btn btn-primary btn-glow" onclick="saveArticlesDatabase()">
|
||||
Änderungen speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p style="font-size: 13px; color: var(--text-secondary); margin-bottom: 20px; line-height: 1.5;">
|
||||
Geben Sie für jede Produktklasse den gewünschten Barcode (EAN) ein. Wenn das System dieses Produkt stabil erkennt, simuliert der <strong>virtuelle Tastatur-Scanner</strong> diesen Barcode direkt in Ihr Kassenfenster (gefolgt von Enter).
|
||||
</p>
|
||||
|
||||
<div class="articles-table-wrapper" style="overflow-x: auto;">
|
||||
<table class="articles-table" style="width: 100%; border-collapse: collapse; text-align: left;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid var(--border-glass); color: var(--text-secondary); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
<th style="padding: 12px 16px;">Klasse (ML-Key)</th>
|
||||
<th style="padding: 12px 16px;">Name (Anzeige)</th>
|
||||
<th style="padding: 12px 16px;">EAN-Barcode (Tastaturemulation)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="articles-table-body" style="font-size: 14px;">
|
||||
<!-- Dynamic rows loaded by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="app-footer">
|
||||
<p>Lizenz-Fokus: 100% kommerziell nutzbar, BSD & MIT Open-Source Lizenzen. ONNX Runtime Inference.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1075
app/static/styles.css
Normal file
1075
app/static/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user