initial commit

This commit is contained in:
DanielS
2026-05-29 00:47:49 +02:00
commit bd1bd52c6c
571 changed files with 4877 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

523
app/main.py Normal file
View 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

File diff suppressed because it is too large Load Diff

382
app/static/index.html Normal file
View 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 &gt; 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

File diff suppressed because it is too large Load Diff

114
articles.json Normal file
View File

@@ -0,0 +1,114 @@
{
"apfel_elstar": {
"name": "Apfel Elstar",
"ean": "4001234000018"
},
"apfel_gala": {
"name": "Apfel Gala",
"ean": "4001234000025"
},
"apfel_granny_smith": {
"name": "Apfel Granny Smith",
"ean": "4001234000032"
},
"banane_chiquita": {
"name": "Banane Chiquita",
"ean": "4001234000049"
},
"banane_bio": {
"name": "Bio Banane",
"ean": "4001234000056"
},
"birne": {
"name": "Birne Abate Fetel",
"ean": "4001234000063"
},
"orange": {
"name": "Orangen",
"ean": "4001234000070"
},
"zitrone": {
"name": "Zitronen",
"ean": "4001234000087"
},
"limette": {
"name": "Limetten",
"ean": "4001234000094"
},
"erdbeere": {
"name": "Erdbeeren Schale",
"ean": "4001234000100"
},
"blaubeere": {
"name": "Kulturheidelbeeren",
"ean": "4001234000117"
},
"weintraube_hell": {
"name": "Tafeltrauben hell",
"ean": "4001234000124"
},
"weintraube_dunkel": {
"name": "Tafeltrauben dunkel",
"ean": "4001234000131"
},
"pfirsich": {
"name": "Pfirsiche",
"ean": "4001234000148"
},
"tomate": {
"name": "Rispentomaten",
"ean": "4001234000155"
},
"gurke": {
"name": "Salatgurke",
"ean": "4001234000162"
},
"kartoffel": {
"name": "Speisekartoffeln",
"ean": "4001234000163"
},
"karotte": {
"name": "Speisem\u00c3\u00b6hren",
"ean": "4001234000186"
},
"zwiebel_gelb": {
"name": "Speisezwiebeln",
"ean": "4001234000193"
},
"zwiebel_rot": {
"name": "Rote Zwiebeln",
"ean": "4001234000209"
},
"knoblauch": {
"name": "Knoblauch",
"ean": "4001234000216"
},
"brokkoli": {
"name": "Brokkoli",
"ean": "4001234000223"
},
"paprika_rot": {
"name": "Paprika rot",
"ean": "4001234000230"
},
"paprika_gelb": {
"name": "Paprika gelb",
"ean": "4001234000247"
},
"paprika_gruen": {
"name": "Paprika gr\u00c3\u00bcn",
"ean": "4001234000254"
},
"champignon": {
"name": "Champignons wei\u00c3\u0178",
"ean": "4001234000261"
},
"zucchini": {
"name": "Zucchini",
"ean": "4001234000278"
},
"avocado": {
"name": "Avocado Hass",
"ean": "4001234000285"
}
}

475
data_generator.py Normal file
View File

@@ -0,0 +1,475 @@
import os
import random
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
import json
# Define expanded classes (28 classes)
DEFAULT_CLASSES = [
"apfel_elstar",
"apfel_gala",
"apfel_granny_smith",
"banane_chiquita",
"banane_bio",
"birne",
"orange",
"zitrone",
"limette",
"erdbeere",
"blaubeere",
"weintraube_hell",
"weintraube_dunkel",
"pfirsich",
"tomate",
"gurke",
"kartoffel",
"karotte",
"zwiebel_gelb",
"zwiebel_rot",
"knoblauch",
"brokkoli",
"paprika_rot",
"paprika_gelb",
"paprika_gruen",
"champignon",
"zucchini",
"avocado"
]
def get_classes_from_db():
base_dir = os.path.dirname(os.path.abspath(__file__))
articles_path = os.path.join(base_dir, "articles.json")
if os.path.exists(articles_path):
try:
with open(articles_path, "r") as f:
articles = json.load(f)
keys = list(articles.keys())
if keys:
return keys
except Exception:
pass
return DEFAULT_CLASSES.copy()
CLASSES = get_classes_from_db()
def reload_classes():
new_classes = get_classes_from_db()
CLASSES.clear()
CLASSES.extend(new_classes)
print(f"Reloaded CLASSES: {len(CLASSES)} classes in memory.")
def draw_apple_elstar(draw):
draw.ellipse([45, 50, 155, 160], fill=(210, 45, 45, 255))
draw.ellipse([80, 45, 120, 60], fill=(210, 45, 45, 255))
draw.ellipse([55, 70, 110, 140], fill=(230, 140, 40, 180))
draw.line([(100, 55), (105, 25)], fill=(101, 67, 33, 255), width=4)
draw.ellipse([93, 50, 107, 57], fill=(60, 20, 20, 255))
def draw_apple_gala(draw):
draw.ellipse([45, 50, 155, 160], fill=(240, 190, 60, 255))
draw.ellipse([80, 45, 120, 60], fill=(240, 190, 60, 255))
for offset in range(-35, 45, 15):
draw.arc([45 + offset, 50, 155 + offset, 160], start=30, end=150, fill=(210, 40, 40, 200), width=4)
draw.arc([45 - offset, 50, 155 - offset, 160], start=30, end=150, fill=(210, 40, 40, 200), width=4)
draw.line([(100, 55), (103, 25)], fill=(101, 67, 33, 255), width=4)
draw.ellipse([93, 50, 107, 57], fill=(70, 50, 20, 255))
def draw_apple_granny_smith(draw):
# Bright green apple
draw.ellipse([45, 50, 155, 160], fill=(120, 200, 40, 255))
draw.ellipse([80, 45, 120, 60], fill=(120, 200, 40, 255))
# Light yellow highlight
draw.ellipse([60, 70, 95, 110], fill=(160, 230, 80, 180))
# Stem
draw.line([(100, 55), (105, 25)], fill=(101, 67, 33, 255), width=4)
draw.ellipse([93, 50, 107, 57], fill=(40, 70, 15, 255))
def draw_banana_chiquita(draw):
banana_pts = [
(40, 130), (50, 100), (70, 75), (100, 60), (130, 60),
(160, 75), (170, 95), (160, 95), (135, 80), (110, 80),
(85, 90), (65, 110), (50, 140)
]
draw.polygon(banana_pts, fill=(245, 215, 50, 255))
draw.polygon([(40, 130), (50, 140), (45, 145), (35, 135)], fill=(75, 100, 30, 255))
draw.polygon([(160, 75), (170, 95), (175, 90), (165, 70)], fill=(90, 75, 30, 255))
draw.ellipse([100, 65, 112, 75], fill=(10, 50, 150, 255))
draw.ellipse([103, 68, 109, 72], fill=(245, 215, 50, 255))
def draw_banana_bio(draw):
banana_pts = [
(45, 125), (55, 100), (75, 80), (100, 70), (125, 70),
(150, 82), (160, 98), (152, 98), (130, 88), (110, 85),
(88, 93), (70, 110), (53, 133)
]
draw.polygon(banana_pts, fill=(225, 220, 60, 255))
draw.polygon([(45, 125), (53, 133), (48, 138), (40, 130)], fill=(50, 90, 20, 255))
draw.polygon([(150, 82), (160, 98), (163, 93), (153, 77)], fill=(70, 90, 20, 255))
spots = [(70, 105), (85, 92), (100, 83), (115, 83), (130, 86)]
for sx, sy in spots:
draw.ellipse([sx-2, sy-2, sx+2, sy+2], fill=(80, 50, 20, 200))
draw.polygon([(95, 70), (105, 70), (102, 87), (92, 86)], fill=(120, 140, 90, 255))
def draw_pear(draw):
# Birne: pear shape (narrow top, wide bottom)
draw.ellipse([65, 50, 135, 110], fill=(180, 190, 50, 255)) # top
draw.ellipse([50, 90, 150, 170], fill=(180, 190, 50, 255)) # bottom
draw.ellipse([60, 75, 140, 145], fill=(180, 190, 50, 255)) # mid fill
# Orange-red blush
draw.ellipse([95, 100, 140, 150], fill=(210, 110, 30, 120))
# Stem
draw.line([(100, 55), (105, 25)], fill=(101, 67, 33, 255), width=3)
def draw_orange(draw):
draw.ellipse([45, 45, 155, 155], fill=(245, 120, 20, 255))
# Peel texture (little dots)
for _ in range(15):
tx = random.randint(60, 140)
ty = random.randint(60, 140)
draw.ellipse([tx, ty, tx+2, ty+2], fill=(215, 95, 5, 200))
def draw_lemon(draw):
# Zitrone: yellow oval with pointed ends
draw.ellipse([50, 65, 150, 135], fill=(245, 225, 40, 255))
draw.polygon([(40, 100), (52, 90), (52, 110)], fill=(245, 225, 40, 255)) # left tip
draw.polygon([(160, 100), (148, 90), (148, 110)], fill=(245, 225, 40, 255)) # right tip
def draw_lime(draw):
# Limette: green oval with pointed ends
draw.ellipse([55, 70, 145, 130], fill=(60, 160, 30, 255))
draw.polygon([(47, 100), (57, 92), (57, 108)], fill=(60, 160, 30, 255))
draw.polygon([(153, 100), (143, 92), (143, 108)], fill=(60, 160, 30, 255))
def draw_strawberry(draw):
# Erdbeere: heart shape
draw.polygon([(100, 165), (55, 80), (100, 60), (145, 80)], fill=(225, 30, 50, 255))
draw.ellipse([55, 70, 105, 100], fill=(225, 30, 50, 255))
draw.ellipse([95, 70, 145, 100], fill=(225, 30, 50, 255))
# Seeds (yellow dots)
for _ in range(20):
sx = random.randint(65, 135)
sy = random.randint(75, 140)
draw.ellipse([sx, sy, sx+2, sy+2], fill=(240, 220, 100, 255))
# Leafy top
draw.polygon([(100, 65), (85, 45), (95, 60), (100, 40), (105, 60), (115, 45)], fill=(40, 160, 50, 255))
def draw_blueberry(draw):
# Blaubeere: small blue circle
draw.ellipse([70, 70, 130, 130], fill=(45, 80, 160, 255))
# Crown (dark crown tip)
draw.polygon([(90, 70), (100, 62), (110, 70), (105, 75), (95, 75)], fill=(20, 45, 95, 255))
def draw_grape_green(draw):
# Weintraube hell: cluster of small green circles
coords = [
(100, 60), (85, 75), (115, 75), (70, 95), (100, 95), (130, 95),
(85, 115), (115, 115), (100, 135), (100, 155)
]
# Stem
draw.line([(100, 60), (100, 35)], fill=(110, 140, 50, 255), width=3)
for cx, cy in coords:
draw.ellipse([cx-16, cy-16, cx+16, cy+16], fill=(160, 210, 80, 255))
draw.ellipse([cx-16, cy-16, cx+16, cy+16], outline=(130, 180, 60, 255), width=1)
def draw_grape_dark(draw):
# Weintraube dunkel: cluster of small dark purple circles
coords = [
(100, 60), (85, 75), (115, 75), (70, 95), (100, 95), (130, 95),
(85, 115), (115, 115), (100, 135), (100, 155)
]
draw.line([(100, 60), (100, 35)], fill=(90, 70, 110, 255), width=3)
for cx, cy in coords:
draw.ellipse([cx-16, cy-16, cx+16, cy+16], fill=(65, 30, 105, 255))
draw.ellipse([cx-16, cy-16, cx+16, cy+16], outline=(45, 15, 80, 255), width=1)
def draw_peach(draw):
# Pfirsich: fuzzy orange-pink circle
draw.ellipse([45, 50, 155, 160], fill=(245, 130, 80, 255))
draw.ellipse([47, 50, 120, 160], fill=(245, 80, 110, 255)) # pink cheek
# Indentation line
draw.arc([40, 50, 150, 160], start=270, end=90, fill=(185, 45, 45, 255), width=2)
# stem
draw.line([(100, 53), (102, 30)], fill=(90, 70, 40, 255), width=3)
def draw_tomato(draw):
draw.ellipse([50, 55, 150, 155], fill=(225, 30, 30, 255))
draw.ellipse([65, 70, 85, 85], fill=(255, 255, 255, 180))
cx, cy = 100, 55
stem_pts = [
(cx, cy), (cx-15, cy-10), (cx-5, cy-2),
(cx, cy-20), (cx+5, cy-2), (cx+15, cy-10),
(cx+8, cy+5), (cx, cy+8), (cx-8, cy+5)
]
draw.polygon(stem_pts, fill=(34, 139, 34, 255))
draw.line([(cx, cy), (cx-5, cy-25)], fill=(34, 139, 34, 255), width=4)
def draw_cucumber(draw):
draw.rounded_rectangle([35, 80, 165, 120], radius=20, fill=(20, 100, 40, 255))
for x in range(50, 150, 15):
draw.line([(x, 85), (x+5, 115)], fill=(30, 130, 55, 255), width=2)
draw.ellipse([32, 95, 38, 105], fill=(139, 115, 85, 255))
draw.ellipse([162, 95, 168, 105], fill=(100, 80, 40, 255))
def draw_potato(draw):
# Kartoffel: irregular brown-yellow oval
draw.rounded_rectangle([45, 65, 155, 135], radius=35, fill=(190, 160, 95, 255))
# Add potato eyes (dark brown dots)
eyes = [(65, 80), (135, 90), (115, 120), (80, 125), (100, 85)]
for ex, ey in eyes:
draw.ellipse([ex-2, ey-1, ex+2, ey+1], fill=(110, 85, 45, 255))
draw.arc([ex-3, ey-3, ex+3, ey+3], start=0, end=180, fill=(110, 85, 45, 255), width=1)
def draw_carrot(draw):
# Karotte: tapered orange shape
draw.polygon([(40, 85), (160, 100), (40, 115)], fill=(245, 110, 20, 255))
draw.ellipse([35, 85, 45, 115], fill=(245, 110, 20, 255))
# Green leaves on one end (left)
draw.polygon([(38, 100), (15, 80), (30, 95), (10, 100), (30, 105), (15, 120)], fill=(45, 155, 55, 255))
# Texture lines
for x in range(60, 140, 20):
draw.line([(x, 92), (x, 108)], fill=(215, 85, 10, 255), width=2)
def draw_onion_yellow(draw):
# Zwiebel gelb: golden bulb shape
draw.ellipse([50, 60, 150, 150], fill=(195, 145, 75, 255))
draw.polygon([(100, 40), (80, 65), (120, 65)], fill=(195, 145, 75, 255)) # top tip
# Root hairs at bottom
draw.line([(90, 150), (85, 162)], fill=(230, 210, 170, 255), width=2)
draw.line([(100, 150), (100, 165)], fill=(230, 210, 170, 255), width=2)
draw.line([(110, 150), (115, 162)], fill=(230, 210, 170, 255), width=2)
def draw_onion_red(draw):
# Zwiebel rot: red-purple bulb shape
draw.ellipse([50, 60, 150, 150], fill=(140, 35, 95, 255))
draw.polygon([(100, 40), (80, 65), (120, 65)], fill=(140, 35, 95, 255))
# Light stripes
draw.arc([55, 60, 145, 150], start=180, end=360, fill=(185, 75, 135, 255), width=2)
# Root hairs
draw.line([(95, 150), (95, 162)], fill=(210, 190, 180, 255), width=2)
draw.line([(105, 150), (105, 162)], fill=(210, 190, 180, 255), width=2)
def draw_garlic(draw):
# Knoblauch: off-white bulb with segment lines
draw.ellipse([50, 60, 150, 150], fill=(235, 230, 220, 255))
draw.polygon([(100, 42), (85, 65), (115, 65)], fill=(235, 230, 220, 255))
# Segment lines
draw.arc([65, 60, 135, 150], start=270, end=90, fill=(195, 190, 180, 255), width=2)
draw.arc([80, 60, 120, 150], start=270, end=90, fill=(195, 190, 180, 255), width=2)
# Roots
draw.line([(100, 150), (100, 160)], fill=(160, 150, 130, 255), width=2)
def draw_broccoli(draw):
# Brokkoli: green tree-like structure
# Stalk
draw.rounded_rectangle([80, 110, 120, 170], radius=8, fill=(110, 175, 75, 255))
# Florets (overlapping circles)
draw.ellipse([60, 60, 110, 110], fill=(30, 110, 50, 255))
draw.ellipse([90, 50, 150, 100], fill=(30, 110, 50, 255))
draw.ellipse([75, 80, 135, 130], fill=(30, 110, 50, 255))
# Add tiny dots/texture
for _ in range(30):
tx = random.randint(65, 135)
ty = random.randint(55, 120)
draw.ellipse([tx, ty, tx+2, ty+2], fill=(60, 150, 80, 255))
def draw_pepper_red(draw):
# Paprika rot: blocky rounded shape
draw.rounded_rectangle([50, 60, 150, 150], radius=25, fill=(210, 30, 30, 255))
# Lobes indentations
draw.arc([45, 60, 155, 150], start=230, end=310, fill=(160, 15, 15, 255), width=3)
# Green stem
draw.line([(100, 60), (100, 35)], fill=(45, 130, 45, 255), width=5)
def draw_pepper_yellow(draw):
# Paprika gelb: blocky rounded shape
draw.rounded_rectangle([50, 60, 150, 150], radius=25, fill=(240, 190, 20, 255))
draw.arc([45, 60, 155, 150], start=230, end=310, fill=(195, 145, 10, 255), width=3)
draw.line([(100, 60), (100, 35)], fill=(45, 130, 45, 255), width=5)
def draw_pepper_green(draw):
# Paprika grün: blocky rounded shape
draw.rounded_rectangle([50, 60, 150, 150], radius=25, fill=(35, 115, 45, 255))
draw.arc([45, 60, 155, 150], start=230, end=310, fill=(20, 85, 25, 255), width=3)
draw.line([(100, 60), (100, 35)], fill=(25, 80, 25, 255), width=5)
def draw_mushroom(draw):
# Champignon: white-brown stalk and cap
# Stalk
draw.rounded_rectangle([85, 100, 115, 160], radius=6, fill=(225, 215, 205, 255))
# Cap (umbrella)
draw.ellipse([45, 60, 155, 120], fill=(210, 200, 190, 255))
draw.rectangle([45, 90, 155, 120], fill=(210, 200, 190, 255)) # bottom flat
# Gills (brown under cap)
draw.line([(45, 120), (155, 120)], fill=(120, 100, 90, 255), width=3)
def draw_zucchini(draw):
# Zucchini: long cylinder, thicker than cucumber, dark green
draw.rounded_rectangle([30, 75, 170, 125], radius=22, fill=(15, 75, 35, 255))
# Speckled light green stripes
for x in range(45, 155, 20):
draw.line([(x, 80), (x+8, 120)], fill=(35, 115, 60, 180), width=3)
# Thick stem
draw.ellipse([167, 95, 173, 105], fill=(80, 95, 50, 255))
def draw_avocado(draw):
# Halved avocado: pear outline, yellow-green center, brown seed
# Outer skin
draw.ellipse([60, 50, 140, 120], fill=(15, 45, 20, 255))
draw.ellipse([45, 85, 155, 170], fill=(15, 45, 20, 255))
# Light green inner meat
draw.ellipse([63, 53, 137, 117], fill=(185, 215, 110, 255))
draw.ellipse([49, 89, 151, 166], fill=(185, 215, 110, 255))
# Yellow center
draw.ellipse([58, 98, 142, 158], fill=(215, 235, 135, 255))
# Brown pit (seed) in center
draw.ellipse([78, 105, 122, 149], fill=(110, 65, 30, 255))
draw.ellipse([82, 109, 100, 125], fill=(255, 255, 255, 80)) # Pit reflection
def create_object_image(class_idx, angle, scale):
obj_img = Image.new("RGBA", (200, 200), (0, 0, 0, 0))
draw = ImageDraw.Draw(obj_img)
# Map class index to drawing functions
draw_funcs = [
draw_apple_elstar, draw_apple_gala, draw_apple_granny_smith,
draw_banana_chiquita, draw_banana_bio, draw_pear,
draw_orange, draw_lemon, draw_lime,
draw_strawberry, draw_blueberry, draw_grape_green,
draw_grape_dark, draw_peach, draw_tomato,
draw_cucumber, draw_potato, draw_carrot,
draw_onion_yellow, draw_onion_red, draw_garlic,
draw_broccoli, draw_pepper_red, draw_pepper_yellow,
draw_pepper_green, draw_mushroom, draw_zucchini,
draw_avocado
]
if class_idx < len(draw_funcs):
draw_funcs[class_idx](draw)
rotated = obj_img.rotate(angle, resample=Image.Resampling.BILINEAR, expand=True)
if scale != 1.0:
new_w = int(rotated.width * scale)
new_h = int(rotated.height * scale)
rotated = rotated.resize((new_w, new_h), resample=Image.Resampling.BILINEAR)
bbox = rotated.getbbox()
if bbox is None:
return None, None
cropped = rotated.crop(bbox)
return cropped, bbox
def generate_background(width, height):
bg = Image.new("RGB", (width, height), (210, 215, 220))
draw = ImageDraw.Draw(bg)
grid_color = (185, 190, 195)
for x in range(0, width, 80):
draw.line([(x, 0), (x, height)], fill=grid_color, width=2)
for y in range(0, height, 80):
draw.line([(0, y), (width, y)], fill=grid_color, width=2)
draw.rectangle([10, 10, width-10, height-10], outline=(170, 175, 180), width=3)
arr = np.array(bg, dtype=np.float32)
x_grad = np.linspace(-15, 15, width)
y_grad = np.linspace(-15, 15, height)
xx, yy = np.meshgrid(x_grad, y_grad)
arr[:, :, 0] += xx + yy
arr[:, :, 1] += xx + yy
arr[:, :, 2] += xx + yy
noise = np.random.normal(0, 3, (height, width, 3))
arr += noise
arr = np.clip(arr, 0, 255).astype(np.uint8)
return Image.fromarray(arr)
def generate_sample(width=640, height=480):
bg = generate_background(width, height)
num_objects = random.randint(1, 3)
annotations = []
placed_boxes = []
for _ in range(num_objects):
class_idx = random.randint(0, len(CLASSES)-1)
angle = random.randint(0, 360)
scale = random.uniform(0.7, 1.2)
obj_img, _ = create_object_image(class_idx, angle, scale)
if obj_img is None:
continue
ow, oh = obj_img.size
placed = False
for attempt in range(25):
x = random.randint(30, width - ow - 30)
y = random.randint(30, height - oh - 30)
overlap = False
for px1, py1, px2, py2 in placed_boxes:
ix1 = max(x, px1)
iy1 = max(y, py1)
ix2 = min(x + ow, px2)
iy2 = min(y + oh, py2)
if ix2 > ix1 and iy2 > iy1:
inter_area = (ix2 - ix1) * (iy2 - iy1)
box_area = ow * oh
p_area = (px2 - px1) * (py2 - py1)
iou = inter_area / float(box_area + p_area - inter_area)
if iou > 0.12:
overlap = True
break
if not overlap:
bg.paste(obj_img, (x, y), obj_img)
placed_boxes.append((x, y, x + ow, y + oh))
x_center = (x + ow / 2.0) / width
y_center = (y + oh / 2.0) / height
w_norm = ow / float(width)
h_norm = oh / float(height)
annotations.append(f"{class_idx} {x_center:.6f} {y_center:.6f} {w_norm:.6f} {h_norm:.6f}")
placed = True
break
if not placed and len(placed_boxes) == 0:
x = random.randint(30, width - ow - 30)
y = random.randint(30, height - oh - 30)
bg.paste(obj_img, (x, y), obj_img)
placed_boxes.append((x, y, x + ow, y + oh))
x_center = (x + ow / 2.0) / width
y_center = (y + oh / 2.0) / height
w_norm = ow / float(width)
h_norm = oh / float(height)
annotations.append(f"{class_idx} {x_center:.6f} {y_center:.6f} {w_norm:.6f} {h_norm:.6f}")
bg = bg.filter(ImageFilter.GaussianBlur(0.5))
return bg, annotations
def build_dataset(base_dir="dataset", train_count=200, val_count=50):
os.makedirs(os.path.join(base_dir, "images", "train"), exist_ok=True)
os.makedirs(os.path.join(base_dir, "images", "val"), exist_ok=True)
os.makedirs(os.path.join(base_dir, "labels", "train"), exist_ok=True)
os.makedirs(os.path.join(base_dir, "labels", "val"), exist_ok=True)
with open(os.path.join(base_dir, "classes.txt"), "w") as f:
f.write("\n".join(CLASSES))
print(f"Generating {train_count} training samples for {len(CLASSES)} classes...")
for idx in range(train_count):
img, ann = generate_sample()
img.save(os.path.join(base_dir, "images", "train", f"train_{idx:04d}.jpg"), quality=90)
with open(os.path.join(base_dir, "labels", "train", f"train_{idx:04d}.txt"), "w") as f:
f.write("\n".join(ann))
print(f"Generating {val_count} validation samples...")
for idx in range(val_count):
img, ann = generate_sample()
img.save(os.path.join(base_dir, "images", "val", f"val_{idx:04d}.jpg"), quality=90)
with open(os.path.join(base_dir, "labels", "val", f"val_{idx:04d}.txt"), "w") as f:
f.write("\n".join(ann))
print("Dataset generation complete!")
if __name__ == "__main__":
build_dataset(train_count=200, val_count=50)

103
dataset.py Normal file
View File

@@ -0,0 +1,103 @@
import os
import cv2
import numpy as np
import torch
from torch.utils.data import Dataset
class FruitVegetableDataset(Dataset):
def __init__(self, images_dir, labels_dir, transform=None):
"""
Args:
images_dir (str): Path to images directory.
labels_dir (str): Path to labels directory.
transform (albumentations.Compose, optional): Albumentations transform pipeline.
"""
self.images_dir = images_dir
self.labels_dir = labels_dir
self.transform = transform
# List all image files
self.image_files = sorted([
f for f in os.listdir(images_dir)
if f.lower().endswith(('.jpg', '.jpeg', '.png'))
])
def __len__(self):
return len(self.image_files)
def __getitem__(self, idx):
img_name = self.image_files[idx]
img_path = os.path.join(self.images_dir, img_name)
# Load image via OpenCV (BGR) and convert to RGB
image = cv2.imread(img_path)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
height, width, _ = image.shape
# Load corresponding label file
label_name = os.path.splitext(img_name)[0] + ".txt"
label_path = os.path.join(self.labels_dir, label_name)
boxes = []
class_ids = []
if os.path.exists(label_path):
with open(label_path, "r") as f:
for line in f:
parts = line.strip().split()
if len(parts) == 5:
class_id = int(parts[0])
# YOLO format: x_center, y_center, width, height (normalized)
x_c, y_c, w, h = map(float, parts[1:])
# Convert to Pascal VOC absolute coordinates [x_min, y_min, x_max, y_max]
xmin = (x_c - w / 2.0) * width
ymin = (y_c - h / 2.0) * height
xmax = (x_c + w / 2.0) * width
ymax = (y_c + h / 2.0) * height
# Ensure coordinates are within image boundaries
xmin = max(0.0, min(xmin, float(width - 1)))
ymin = max(0.0, min(ymin, float(height - 1)))
xmax = max(xmin + 1.0, min(xmax, float(width)))
ymax = max(ymin + 1.0, min(ymax, float(height)))
boxes.append([xmin, ymin, xmax, ymax])
class_ids.append(class_id)
boxes = np.array(boxes, dtype=np.float32).reshape(-1, 4)
class_ids = np.array(class_ids, dtype=np.int64)
# Apply Albumentations transformations
if self.transform:
augmented = self.transform(
image=image,
bboxes=boxes,
category_ids=class_ids
)
image = augmented["image"]
boxes = np.array(augmented["bboxes"], dtype=np.float32).reshape(-1, 4)
class_ids = np.array(augmented["category_ids"], dtype=np.int64)
# Convert image to CHW tensor and scale to [0, 1]
image_tensor = torch.from_numpy(image).permute(2, 0, 1).float() / 255.0
# Convert targets to PyTorch Tensors
# NOTE: torchvision models reserve class 0 for background.
# We must shift our class IDs by +1 (0 -> 1, 1 -> 2, etc.)
torch_boxes = torch.as_tensor(boxes, dtype=torch.float32)
torch_labels = torch.as_tensor(class_ids + 1, dtype=torch.int64)
target = {
"boxes": torch_boxes,
"labels": torch_labels,
"image_id": torch.tensor([idx])
}
return image_tensor, target
def collate_fn(batch):
"""
Collate function for DataLoader. Returns a tuple of (images, targets).
"""
return tuple(zip(*batch))

28
dataset/classes.txt Normal file
View File

@@ -0,0 +1,28 @@
apfel_elstar
apfel_gala
apfel_granny_smith
banane_chiquita
banane_bio
birne
orange
zitrone
limette
erdbeere
blaubeere
weintraube_hell
weintraube_dunkel
pfirsich
tomate
gurke
kartoffel
karotte
zwiebel_gelb
zwiebel_rot
knoblauch
brokkoli
paprika_rot
paprika_gelb
paprika_gruen
champignon
zucchini
avocado

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Some files were not shown because too many files have changed in this diff Show More