delete me

This commit is contained in:
DanielS
2026-05-29 14:14:04 +02:00
parent bd1bd52c6c
commit 5462784c1f
621 changed files with 2047 additions and 750 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -25,6 +25,11 @@ app = FastAPI(title="POS Object Detection API", description="Fruit & Vegetable c
# Initialize detector
detector = FruitVegetableDetector()
# Global variable to track active training process
current_training_process = None
import re
# Paths
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATUS_FILE = os.path.join(BASE_DIR, "training_status.json")
@@ -41,6 +46,7 @@ class TrainRequest(BaseModel):
class CaptureRequest(BaseModel):
image: str
class_name: str
bbox: dict = None # Optional client-side bounding box dictionary
class SaveArticlesRequest(BaseModel):
articles: dict
@@ -48,6 +54,13 @@ class SaveArticlesRequest(BaseModel):
class ScanRequest(BaseModel):
class_name: str
class CalibrateRequest(BaseModel):
image: str
class DeleteCaptureRequest(BaseModel):
filename: str
# Serve frontend static files
static_path = os.path.join(os.path.dirname(__file__), "static")
os.makedirs(static_path, exist_ok=True)
@@ -75,6 +88,78 @@ def write_training_status(status_dict):
with open(STATUS_FILE, "w") as f:
json.dump(status_dict, f, indent=2)
def is_safe_filename(filename: str) -> bool:
return re.match(r"^user_capture_\d+\.jpg$", filename) is not None
def calculate_auto_crop_bbox(image_rgb: np.ndarray, background_path: str, threshold: int = 25) -> list:
"""
Compares the current image with the saved background.
Returns normalized YOLO bounding box [x_center, y_center, width, height] if found, else None.
"""
if not os.path.exists(background_path):
return None
try:
# Load background image
bg_img = cv2.imread(background_path)
if bg_img is None:
return None
# Convert to grayscale
bg_gray = cv2.cvtColor(bg_img, cv2.COLOR_BGR2GRAY)
# Ensure they have the same dimensions
h_img, w_img, _ = image_rgb.shape
if bg_gray.shape[0] != h_img or bg_gray.shape[1] != w_img:
bg_gray = cv2.resize(bg_gray, (w_img, h_img))
# Convert current image to grayscale (assuming input is RGB)
curr_gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
# Calculate absolute difference
diff = cv2.absdiff(bg_gray, curr_gray)
# Apply thresholding
_, thresh = cv2.threshold(diff, threshold, 255, cv2.THRESH_BINARY)
# Morphological operations to remove noise and fill holes
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel)
# Find contours
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
# Filter contours by size and find the largest
large_contours = [c for c in contours if cv2.contourArea(c) > 150]
if not large_contours:
return None
largest_contour = max(large_contours, key=cv2.contourArea)
x, y, w, h = cv2.boundingRect(largest_contour)
# Add padding around the bounding box
padding = 15
x_min = max(0, x - padding)
y_min = max(0, y - padding)
x_max = min(w_img, x + w + padding)
y_max = min(h_img, y + h + padding)
box_w = x_max - x_min
box_h = y_max - y_min
# Calculate YOLO format values
x_center = (x_min + box_w / 2.0) / w_img
y_center = (y_min + box_h / 2.0) / h_img
norm_w = box_w / w_img
norm_h = box_h / h_img
return [x_center, y_center, norm_w, norm_h]
except Exception as e:
print(f"Error in calculate_auto_crop_bbox: {e}")
return None
@app.post("/api/detect")
async def detect_objects(payload: DetectionRequest):
"""
@@ -89,6 +174,18 @@ async def detect_objects(payload: DetectionRequest):
img = Image.open(BytesIO(img_bytes)).convert("RGB")
img_np = np.array(img)
# Check if empty background calibration exists and compare
bg_path = os.path.join(BASE_DIR, "dataset", "background.jpg")
if os.path.exists(bg_path):
# Use a slightly lower threshold (20) to ensure we capture any real item placed
bbox = calculate_auto_crop_bbox(img_np, bg_path, threshold=20)
if bbox is None:
# Scale is empty or no significant object placed
return {
"status": "success",
"predictions": []
}
predictions = detector.detect(img_np)
return {
@@ -153,6 +250,7 @@ async def trigger_training(req: TrainRequest):
"""
Triggers model training.
"""
global current_training_process
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.")
@@ -167,7 +265,8 @@ async def trigger_training(req: TrainRequest):
"message": "Initializing training background process..."
})
# Launch training process in background
# Launch training process in background with reduced CPU priority
# so the FastAPI server stays responsive during CPU-heavy training
cmd = [
sys.executable,
os.path.join(BASE_DIR, "train.py"),
@@ -175,7 +274,25 @@ async def trigger_training(req: TrainRequest):
"--batch-size", str(req.batch_size),
"--lr", str(req.lr)
]
subprocess.Popen(cmd, cwd=BASE_DIR)
env = os.environ.copy()
env["PYTHONUTF8"] = "1"
proc = subprocess.Popen(cmd, cwd=BASE_DIR, env=env)
current_training_process = proc
# On Windows: lower the training process priority so the web server
# remains responsive during CPU-intensive training
if sys.platform == "win32":
try:
import ctypes
BELOW_NORMAL_PRIORITY_CLASS = 0x00004000
ctypes.windll.kernel32.SetPriorityClass(
ctypes.windll.kernel32.OpenProcess(0x0200, False, proc.pid),
BELOW_NORMAL_PRIORITY_CLASS
)
print(f"Training process (PID {proc.pid}) set to BELOW_NORMAL priority.")
except Exception as e:
print(f"Could not set process priority: {e}")
return {"status": "success", "message": f"Training initiated for {req.epochs} epochs."}
@@ -184,11 +301,58 @@ 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.
global current_training_process
status = get_training_status()
# Auto-detect if process died unexpectedly
if status.get("status") == "training":
if current_training_process is None or current_training_process.poll() is not None:
status["status"] = "failed"
status["message"] = "Training-Prozess unerwartet beendet."
write_training_status(status)
current_training_process = None
return status
@app.post("/api/cancel_training")
async def cancel_training():
global current_training_process
if current_training_process is None or current_training_process.poll() is not None:
# Fallback: kill via PowerShell command on Windows
if sys.platform == "win32":
try:
import subprocess as sp
sp.run("powershell -Command \"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*train.py*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }\"", shell=True, capture_output=True)
except Exception:
pass
status = get_training_status()
status["status"] = "failed"
status["message"] = "Training vom Benutzer abgebrochen."
write_training_status(status)
current_training_process = None
return {"status": "success", "message": "Training abgebrochen."}
try:
current_training_process.terminate()
current_training_process.wait(timeout=2.0)
except Exception:
try:
current_training_process.kill()
except Exception:
pass
current_training_process = None
# Read status, set to failed
status = get_training_status()
status["status"] = "failed"
status["message"] = "Training vom Benutzer abgebrochen."
write_training_status(status)
return {"status": "success", "message": "Training erfolgreich abgebrochen."}
@app.post("/api/export")
async def trigger_export():
"""
@@ -200,8 +364,10 @@ async def trigger_export():
try:
# Run export script synchronously since it is fast (few seconds)
env = os.environ.copy()
env["PYTHONUTF8"] = "1"
cmd = [sys.executable, os.path.join(BASE_DIR, "export.py")]
result = subprocess.run(cmd, cwd=BASE_DIR, capture_output=True, text=True, check=True)
result = subprocess.run(cmd, cwd=BASE_DIR, capture_output=True, text=True, check=True, env=env)
# Reload the detector model
detector.load_model()
@@ -317,10 +483,146 @@ async def get_simulated_frame():
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to generate simulated frame: {str(e)}")
@app.post("/api/calibrate_background")
async def calibrate_background(payload: CalibrateRequest):
"""
Saves a webcam snapshot of the empty scale to use as the background baseline.
"""
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")
bg_path = os.path.join(BASE_DIR, "dataset", "background.jpg")
os.makedirs(os.path.dirname(bg_path), exist_ok=True)
img.save(bg_path, "JPEG", quality=95)
return {
"status": "success",
"message": "Hintergrund erfolgreich kalibriert und als Null-Linie gespeichert!"
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Hintergrundkalibrierung fehlgeschlagen: {str(e)}")
@app.get("/api/has_background")
async def has_background():
"""
Checks if a baseline background image exists and returns metadata.
"""
bg_path = os.path.join(BASE_DIR, "dataset", "background.jpg")
exists = os.path.exists(bg_path)
timestamp = None
if exists:
timestamp = os.path.getmtime(bg_path)
return {
"has_background": exists,
"timestamp": timestamp
}
@app.post("/api/reset_background")
async def reset_background():
"""
Removes the baseline background image calibration.
"""
bg_path = os.path.join(BASE_DIR, "dataset", "background.jpg")
if os.path.exists(bg_path):
try:
os.remove(bg_path)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Fehler beim Zurücksetzen: {str(e)}")
return {"status": "success", "message": "Hintergrundkalibrierung zurückgesetzt."}
@app.post("/api/reset_dataset")
async def reset_dataset():
"""
Deletes all user-captured images, labels, and the baseline background image calibration.
"""
images_dir = os.path.join(BASE_DIR, "dataset", "images", "train")
labels_dir = os.path.join(BASE_DIR, "dataset", "labels", "train")
bg_path = os.path.join(BASE_DIR, "dataset", "background.jpg")
count_deleted = 0
try:
# Delete images starting with user_capture_
if os.path.exists(images_dir):
for f in os.listdir(images_dir):
if f.startswith("user_capture_"):
try:
os.remove(os.path.join(images_dir, f))
count_deleted += 1
except Exception:
pass
# Delete labels starting with user_capture_
if os.path.exists(labels_dir):
for f in os.listdir(labels_dir):
if f.startswith("user_capture_"):
try:
os.remove(os.path.join(labels_dir, f))
except Exception:
pass
# Delete background
if os.path.exists(bg_path):
try:
os.remove(bg_path)
except Exception:
pass
return {
"status": "success",
"message": f"Datenbank erfolgreich zurückgesetzt! {count_deleted} selbst erfasste Bilder gelöscht."
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Fehler beim Zurücksetzen: {str(e)}")
@app.post("/api/reset_model")
async def reset_model():
"""
Deletes the trained PyTorch model and exported ONNX model,
resets training status, and reloads the detector.
"""
pytorch_path = os.path.join(BASE_DIR, "models", "model.pt")
onnx_path = os.path.join(BASE_DIR, "models", "model.onnx")
try:
deleted_any = False
if os.path.exists(pytorch_path):
os.remove(pytorch_path)
deleted_any = True
if os.path.exists(onnx_path):
os.remove(onnx_path)
deleted_any = True
# Re-initialize training status to idle
write_training_status({
"status": "idle",
"epoch": 0,
"total_epochs": 0,
"train_loss": [],
"val_loss": [],
"progress": 0.0,
"message": "AI model has been reset to default/simulated state."
})
# Reload detector so it goes back to simulated mode
detector.load_model()
return {
"status": "success",
"message": "KI-Modell erfolgreich zurückgesetzt. Das System läuft nun wieder im Simulations-Modus."
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Fehler beim Zurücksetzen des Modells: {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.
Saves a webcam snapshot and generates a YOLO format bounding box label.
Uses OpenCV background subtraction if calibrated, falls back to client bbox or default center.
"""
try:
class_name = payload.class_name
@@ -335,6 +637,7 @@ async def capture_training_image(payload: CaptureRequest):
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)
# Save image with unique filename
import time
@@ -348,11 +651,34 @@ async def capture_training_image(payload: CaptureRequest):
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
# Calculate bounding box using background subtraction
bg_path = os.path.join(BASE_DIR, "dataset", "background.jpg")
yolo_box = calculate_auto_crop_bbox(img_np, bg_path)
if yolo_box is None:
# Fallback to client-side bounding box if provided
if payload.bbox and all(k in payload.bbox for k in ["xmin", "ymin", "xmax", "ymax"]):
h_img, w_img, _ = img_np.shape
xmin = payload.bbox["xmin"]
ymin = payload.bbox["ymin"]
xmax = payload.bbox["xmax"]
ymax = payload.bbox["ymax"]
box_w = xmax - xmin
box_h = ymax - ymin
x_center = (xmin + box_w / 2.0) / w_img
y_center = (ymin + box_h / 2.0) / h_img
norm_w = box_w / w_img
norm_h = box_h / h_img
yolo_box = [x_center, y_center, norm_w, norm_h]
if yolo_box is None:
# Fallback to default centered box
yolo_box = [0.500000, 0.500000, 0.650000, 0.650000]
# Save YOLO label file
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")
f.write(f"{class_idx} {yolo_box[0]:.6f} {yolo_box[1]:.6f} {yolo_box[2]:.6f} {yolo_box[3]:.6f}\n")
# Count total user captured images
user_images = [f for f in os.listdir(images_dir) if f.startswith("user_capture_")]
@@ -361,11 +687,66 @@ async def capture_training_image(payload: CaptureRequest):
return {
"status": "success",
"message": f"Bild erfolgreich als Training für {class_name} erfasst!",
"count": count
"count": count,
"filename": filename
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Fehler bei Bild-Erfassung: {str(e)}")
@app.post("/api/delete_captured_image")
async def delete_captured_image(payload: DeleteCaptureRequest):
"""
Deletes a user-captured image and its corresponding YOLO label file.
"""
filename = payload.filename
if not is_safe_filename(filename):
raise HTTPException(status_code=400, detail="Ungültiger Dateiname.")
images_dir = os.path.join(BASE_DIR, "dataset", "images", "train")
labels_dir = os.path.join(BASE_DIR, "dataset", "labels", "train")
img_path = os.path.join(images_dir, filename)
lbl_path = os.path.join(labels_dir, filename.replace(".jpg", ".txt"))
deleted = False
try:
if os.path.exists(img_path):
os.remove(img_path)
deleted = True
if os.path.exists(lbl_path):
os.remove(lbl_path)
deleted = True
if not deleted:
raise HTTPException(status_code=404, detail="Datei nicht gefunden.")
# Recount 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 {filename} und Label erfolgreich gelöscht.",
"count": count
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Fehler beim Löschen der Datei: {str(e)}")
@app.get("/api/captured_images/{filename}")
async def get_captured_image(filename: str):
"""
Serves a user-captured training image file securely.
"""
if not is_safe_filename(filename):
raise HTTPException(status_code=400, detail="Ungültiger Dateiname.")
img_path = os.path.join(BASE_DIR, "dataset", "images", "train", filename)
if not os.path.exists(img_path):
raise HTTPException(status_code=404, detail="Bild nicht gefunden.")
return FileResponse(img_path)
@app.get("/api/capture_count")
async def get_capture_count():
"""
@@ -428,6 +809,16 @@ def simulate_keyboard_type(text: str):
print(f"Failed to type keyboard input: {e}", file=sys.stderr)
return False
@app.get("/api/background_image")
async def get_background_image():
"""
Serves the calibrated background image.
"""
bg_path = os.path.join(BASE_DIR, "dataset", "background.jpg")
if not os.path.exists(bg_path):
raise HTTPException(status_code=404, detail="Hintergrund nicht kalibriert.")
return FileResponse(bg_path)
@app.get("/api/articles")
async def get_articles():
if not os.path.exists(ARTICLES_FILE):

View File

@@ -36,10 +36,17 @@ let isWebcamActive = false;
let webcamStream = null;
let simulatedStreamInterval = null;
let currentFrameBase64 = null;
let activeCameraDeviceId = null; // stores selected camera device ID
let isDetecting = false;
let autoDetectEnabled = true;
let trainingPollInterval = null;
// Learn/Anlern-Modus State
let learnBackgroundFrame = null; // stores ImageData or Image object for canvas subtraction
let learnSelectedClass = null; // selected article key for training
let learnSessionImages = []; // list of filenames in the current session
let isSerialCapturing = false;
// Scanner Emulator State
let autoScanEnabled = true;
let stableClass = null;
@@ -56,6 +63,9 @@ let shoppingCart = [];
const videoEl = document.getElementById("webcam");
const canvasEl = document.getElementById("camera-canvas");
const ctx = canvasEl.getContext("2d");
const learnCanvasEl = document.getElementById("learn-camera-canvas");
const learnCtx = learnCanvasEl ? learnCanvasEl.getContext("2d") : null;
const toggleCameraBtn = document.getElementById("toggle-camera-btn");
const triggerScaleBtn = document.getElementById("trigger-scale-btn");
const autoDetectToggle = document.getElementById("auto-detect-toggle");
@@ -86,19 +96,121 @@ function switchTab(tabName) {
if (tabName === 'articles') {
loadArticlesDatabase();
}
if (tabName === 'learn') {
checkBackgroundCalibration();
loadLearnArticles();
updateLearnBadge();
}
}
// --- CAMERA & DETECTION LOGIC ---
async function startWebcam() {
async function initCameraSelection() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(device => device.kind === 'videoinput');
const selectCheckout = document.getElementById("camera-select");
const selectLearn = document.getElementById("learn-camera-select");
if (selectCheckout && selectLearn) {
selectCheckout.innerHTML = "";
selectLearn.innerHTML = "";
if (videoDevices.length === 0) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "Keine Kamera gefunden";
selectCheckout.appendChild(opt);
selectLearn.appendChild(opt.cloneNode(true));
return;
}
videoDevices.forEach((device, idx) => {
const label = device.label || `Kamera ${idx + 1}`;
const opt = document.createElement("option");
opt.value = device.deviceId;
opt.textContent = label;
selectCheckout.appendChild(opt);
selectLearn.appendChild(opt.cloneNode(true));
});
// Sync values to current active camera
if (activeCameraDeviceId) {
selectCheckout.value = activeCameraDeviceId;
selectLearn.value = activeCameraDeviceId;
} else {
activeCameraDeviceId = videoDevices[0].deviceId;
selectCheckout.value = activeCameraDeviceId;
selectLearn.value = activeCameraDeviceId;
}
// Hook up change handlers
selectCheckout.onchange = (e) => switchCameraDevice(e.target.value);
selectLearn.onchange = (e) => switchCameraDevice(e.target.value);
}
} catch (err) {
console.error("Fehler beim Abrufen der Kameras:", err);
}
}
async function switchCameraDevice(deviceId) {
activeCameraDeviceId = deviceId;
// Sync dropdown values across tabs
const selectCheckout = document.getElementById("camera-select");
const selectLearn = document.getElementById("learn-camera-select");
if (selectCheckout) selectCheckout.value = deviceId;
if (selectLearn) selectLearn.value = deviceId;
if (isWebcamActive) {
// Stop current tracks and restart webcam stream with the new device
if (webcamStream) {
webcamStream.getTracks().forEach(track => track.stop());
webcamStream = null;
}
await startWebcam(deviceId);
}
}
async function startWebcam(deviceId = null) {
try {
const targetDeviceId = deviceId || activeCameraDeviceId;
const videoConstraints = {
width: 640,
height: 480
};
if (targetDeviceId) {
videoConstraints.deviceId = { exact: targetDeviceId };
} else {
videoConstraints.facingMode = "environment";
}
webcamStream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480, facingMode: "environment" }
video: videoConstraints
});
videoEl.srcObject = webcamStream;
videoEl.style.display = "none";
isWebcamActive = true;
// Show camera selector dropdowns
const selectCheckout = document.getElementById("camera-select");
const selectLearn = document.getElementById("learn-camera-select");
if (selectCheckout) selectCheckout.style.display = "inline-block";
if (selectLearn) selectLearn.style.display = "inline-block";
cameraTypeBadge.textContent = "LIVE-WEBCAM";
cameraTypeBadge.style.background = "var(--color-primary)";
const learnStreamBadge = document.getElementById("learn-camera-type-badge");
if (learnStreamBadge) {
learnStreamBadge.textContent = "LIVE-WEBCAM";
learnStreamBadge.style.background = "var(--color-primary)";
}
toggleCameraBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/></svg>
Kamera deaktivieren
@@ -106,6 +218,9 @@ async function startWebcam() {
document.getElementById("stream-status").textContent = "LIVE";
document.getElementById("stream-status").classList.add("live");
// Initialize options list
await initCameraSelection();
// Stop simulated stream if active
if (simulatedStreamInterval) {
clearInterval(simulatedStreamInterval);
@@ -128,25 +243,42 @@ function stopWebcam() {
videoEl.srcObject = null;
videoEl.style.display = "none";
isWebcamActive = false;
// Hide camera selectors when webcam is inactive
const selectCheckout = document.getElementById("camera-select");
const selectLearn = document.getElementById("learn-camera-select");
if (selectCheckout) selectCheckout.style.display = "none";
if (selectLearn) selectLearn.style.display = "none";
toggleCameraBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
Webcam aktivieren
`;
startSimulatedStream();
}
function processWebcamFrame() {
if (!isWebcamActive) return;
// Draw current video frame to canvas
ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height);
if (activeTab === 'checkout') {
// Draw current video frame to canvas
ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height);
// Get frame as base64
currentFrameBase64 = canvasEl.toDataURL("image/jpeg", 0.7);
// Get frame as base64
currentFrameBase64 = canvasEl.toDataURL("image/jpeg", 0.7);
// Auto-detect if enabled
if (autoDetectEnabled && !isDetecting) {
runDetection();
}
} else if (activeTab === 'learn') {
if (learnCanvasEl && learnCtx) {
// Draw current video frame to learn canvas
learnCtx.drawImage(videoEl, 0, 0, learnCanvasEl.width, learnCanvasEl.height);
// Get frame as base64
currentFrameBase64 = learnCanvasEl.toDataURL("image/jpeg", 0.7);
// Auto-detect if enabled
if (autoDetectEnabled && !isDetecting) {
runDetection();
// Draw dynamic green bounding box
drawLearnFrameGreenBox();
}
}
setTimeout(() => {
@@ -155,10 +287,22 @@ function processWebcamFrame() {
}
function startSimulatedStream() {
cameraTypeBadge.textContent = "SIMULATIONS-MODUS";
cameraTypeBadge.style.background = "var(--color-bg-dark)";
document.getElementById("stream-status").textContent = "Simuliert";
document.getElementById("stream-status").classList.add("live");
const streamBadge = document.getElementById("camera-type-badge");
const learnStreamBadge = document.getElementById("learn-camera-type-badge");
if (streamBadge) {
streamBadge.textContent = "SIMULATIONS-MODUS";
streamBadge.style.background = "var(--color-bg-dark)";
}
if (learnStreamBadge) {
learnStreamBadge.textContent = "SIMULATIONS-MODUS";
learnStreamBadge.style.background = "var(--color-bg-dark)";
}
const streamStatus = document.getElementById("stream-status");
if (streamStatus) {
streamStatus.textContent = "Simuliert";
streamStatus.classList.add("live");
}
const loadSimulatedFrame = async () => {
try {
@@ -167,11 +311,21 @@ function startSimulatedStream() {
if (data.status === "success" && data.image) {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0, canvasEl.width, canvasEl.height);
currentFrameBase64 = data.image;
if (activeTab === 'checkout') {
ctx.drawImage(img, 0, 0, canvasEl.width, canvasEl.height);
currentFrameBase64 = data.image;
if (autoDetectEnabled && !isDetecting) {
runDetection();
if (autoDetectEnabled && !isDetecting) {
runDetection();
}
} else if (activeTab === 'learn') {
if (learnCanvasEl && learnCtx) {
learnCtx.drawImage(img, 0, 0, learnCanvasEl.width, learnCanvasEl.height);
currentFrameBase64 = data.image;
// Draw dynamic green bounding box
drawLearnFrameGreenBox();
}
}
};
img.src = data.image;
@@ -248,14 +402,14 @@ function drawDetections(predictions) {
function updateQuickButtons(predictions) {
quickSelectButtons.innerHTML = "";
// Filter predictions with confidence > 0.10
const candidates = predictions.filter(p => p.confidence > 0.10);
// Filter predictions with confidence > 0.85
const candidates = predictions.filter(p => p.confidence > 0.85);
if (candidates.length === 0) {
quickSelectButtons.innerHTML = `
<div class="empty-state">
<p>Keine Objekte erkannt</p>
<span>Confidence Schwellenwert unter 10%</span>
<span>Confidence Schwellenwert unter 85%</span>
</div>
`;
return;
@@ -446,6 +600,23 @@ async function startTraining() {
}
}
// Cancel active model training
async function cancelTraining() {
const cancelBtn = document.getElementById("cancel-training-btn");
if (cancelBtn) cancelBtn.disabled = true;
try {
const response = await fetch("/api/cancel_training", { method: "POST" });
const data = await response.json();
alert(data.message);
} catch (err) {
console.error("Failed to cancel training:", err);
alert("Fehler beim Abbrechen des Trainings.");
} finally {
if (cancelBtn) cancelBtn.disabled = false;
}
}
// Poll training API
function startTrainingPolling() {
if (trainingPollInterval) return;
@@ -497,6 +668,19 @@ function updateTrainingStatusUI(data) {
statusBox.classList.add("active-pulse");
}
// Toggle start and cancel buttons based on status
const startBtn = document.getElementById("start-training-btn");
const cancelBtn = document.getElementById("cancel-training-btn");
if (startBtn && cancelBtn) {
if (data.status === "training") {
startBtn.style.display = "none";
cancelBtn.style.display = "inline-block";
} else {
startBtn.style.display = "inline-block";
cancelBtn.style.display = "none";
}
}
// Plot Chart if loss history is available
if (data.train_loss && data.train_loss.length > 0) {
updateChart(data.train_loss, data.val_loss || []);
@@ -900,7 +1084,7 @@ async function processStabilityAndScan(predictions) {
const progressWrapper = document.getElementById("stability-progress-wrapper");
const progressFill = document.getElementById("stability-progress-fill");
const STABLE_CONF_THRESHOLD = 0.75;
const STABLE_CONF_THRESHOLD = 0.85;
if (topPrediction && topPrediction.confidence >= STABLE_CONF_THRESHOLD) {
const detectedClass = topPrediction.class;
@@ -986,6 +1170,518 @@ async function triggerManualScan() {
}
}
// --- LEARN ARTICLE TAB (ANLERN-MODUS) LOGIC ---
let learnCurrentBBox = null; // stores {xmin, ymin, xmax, ymax}
function computeBBoxFromDiff(currData, bgData, width, height, threshold = 25) {
let minX = width, minY = height, maxX = 0, maxY = 0;
let changedCount = 0;
for (let y = 0; y < height; y += 4) { // sample every 4th pixel for speed
for (let x = 0; x < width; x += 4) {
const idx = (y * width + x) * 4;
const rDiff = Math.abs(currData[idx] - bgData[idx]);
const gDiff = Math.abs(currData[idx+1] - bgData[idx+1]);
const bDiff = Math.abs(currData[idx+2] - bgData[idx+2]);
if (rDiff + gDiff + bDiff > threshold * 3) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
changedCount++;
}
}
}
// If too few pixels changed, it's probably noise or empty
if (changedCount < 100) return null;
// Add padding
const padding = 15;
minX = Math.max(0, minX - padding);
minY = Math.max(0, minY - padding);
maxX = Math.min(width, maxX + padding);
maxY = Math.min(height, maxY + padding);
return { xmin: minX, ymin: minY, xmax: maxX, ymax: maxY };
}
function drawLearnFrameGreenBox() {
if (!learnCanvasEl || !learnCtx || !currentFrameBase64) return;
if (learnBackgroundFrame) {
try {
const width = learnCanvasEl.width;
const height = learnCanvasEl.height;
// Get current frame image data
const currImgData = learnCtx.getImageData(0, 0, width, height);
// Calculate bbox
const bbox = computeBBoxFromDiff(currImgData.data, learnBackgroundFrame.data, width, height);
learnCurrentBBox = bbox;
if (bbox) {
// Draw green bounding box outline
learnCtx.lineWidth = 3;
learnCtx.strokeStyle = "#10b981"; // Green
learnCtx.strokeRect(bbox.xmin, bbox.ymin, bbox.xmax - bbox.xmin, bbox.ymax - bbox.ymin);
// Draw label box background
learnCtx.fillStyle = "rgba(16, 185, 129, 0.85)";
const labelText = "Ware erkannt (Auto-Crop)";
learnCtx.font = "bold 12px sans-serif";
const textWidth = learnCtx.measureText(labelText).width;
learnCtx.fillRect(bbox.xmin - 1, bbox.ymin - 22, textWidth + 12, 22);
// Label text
learnCtx.fillStyle = "#ffffff";
learnCtx.fillText(labelText, bbox.xmin + 5, bbox.ymin - 6);
}
} catch (e) {
console.error("Error drawing green box:", e);
}
} else {
learnCurrentBBox = null;
}
}
async function calibrateBackground() {
if (!currentFrameBase64) {
alert("Warte auf Kamerabild...");
return;
}
try {
const response = await fetch("/api/calibrate_background", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: currentFrameBase64 })
});
const data = await response.json();
if (data.status === "success") {
// Save background frame locally as ImageData for real-time diffing
const width = learnCanvasEl.width;
const height = learnCanvasEl.height;
learnBackgroundFrame = learnCtx.getImageData(0, 0, width, height);
// Update UI indicators
const statusIndicator = document.getElementById("learn-calibration-indicator");
if (statusIndicator) {
statusIndicator.textContent = "Kalibriert";
statusIndicator.style.color = "var(--color-success)";
statusIndicator.classList.add("live");
}
const checkbox = document.getElementById("learn-bg-ok-indicator");
if (checkbox) {
checkbox.checked = true;
}
alert(data.message);
} else {
alert("Kalibrierung fehlgeschlagen: " + data.message);
}
} catch (err) {
console.error("Calibration error:", err);
alert("Serverfehler bei Kalibrierung.");
}
}
async function checkBackgroundCalibration() {
try {
const response = await fetch("/api/has_background");
const data = await response.json();
const statusIndicator = document.getElementById("learn-calibration-indicator");
const checkbox = document.getElementById("learn-bg-ok-indicator");
if (data.has_background) {
if (statusIndicator) {
statusIndicator.textContent = "Kalibriert";
statusIndicator.style.color = "var(--color-success)";
statusIndicator.classList.add("live");
}
if (checkbox) {
checkbox.checked = true;
}
// Load background image from backend and draw it on a hidden canvas to get ImageData
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const hiddenCanvas = document.createElement("canvas");
hiddenCanvas.width = learnCanvasEl.width;
hiddenCanvas.height = learnCanvasEl.height;
const hiddenCtx = hiddenCanvas.getContext("2d");
hiddenCtx.drawImage(img, 0, 0, hiddenCanvas.width, hiddenCanvas.height);
learnBackgroundFrame = hiddenCtx.getImageData(0, 0, hiddenCanvas.width, hiddenCanvas.height);
};
img.src = "/api/background_image?t=" + Date.now(); // Cache busting
} else {
learnBackgroundFrame = null;
if (statusIndicator) {
statusIndicator.textContent = "Nicht kalibriert";
statusIndicator.style.color = "var(--text-muted)";
statusIndicator.classList.remove("live");
}
if (checkbox) {
checkbox.checked = false;
}
}
} catch (err) {
console.error("Failed to check background calibration:", err);
}
}
async function resetBackgroundCalibration() {
if (!confirm("Hintergrundkalibrierung wirklich zurücksetzen?")) return;
try {
const response = await fetch("/api/reset_background", { method: "POST" });
const data = await response.json();
if (data.status === "success") {
learnBackgroundFrame = null;
const statusIndicator = document.getElementById("learn-calibration-indicator");
if (statusIndicator) {
statusIndicator.textContent = "Nicht kalibriert";
statusIndicator.style.color = "var(--text-muted)";
statusIndicator.classList.remove("live");
}
const checkbox = document.getElementById("learn-bg-ok-indicator");
if (checkbox) {
checkbox.checked = false;
}
alert(data.message);
}
} catch (err) {
console.error("Reset calibration error:", err);
}
}
function loadLearnArticles() {
const listContainer = document.getElementById("learn-articles-list");
if (!listContainer) return;
listContainer.innerHTML = "";
// Check if database loaded, if not, wait
if (Object.keys(articlesDatabase).length === 0) {
setTimeout(loadLearnArticles, 200);
return;
}
Object.keys(articlesDatabase).forEach(key => {
const art = articlesDatabase[key];
const item = document.createElement("div");
item.className = "learn-article-item";
item.id = `learn-item-${key}`;
if (learnSelectedClass === key) {
item.classList.add("selected");
}
item.onclick = () => selectLearnArticle(key);
item.innerHTML = `
<span>${art.name}</span>
<span class="item-plu">EAN: ${art.ean || 'Keine'}</span>
`;
listContainer.appendChild(item);
});
}
function filterLearnArticles() {
const query = document.getElementById("learn-search-input").value.toLowerCase().trim();
const items = document.querySelectorAll(".learn-article-item");
items.forEach(item => {
const text = item.textContent.toLowerCase();
if (text.includes(query)) {
item.style.display = "flex";
} else {
item.style.display = "none";
}
});
}
function selectLearnArticle(classKey) {
learnSelectedClass = classKey;
document.querySelectorAll(".learn-article-item").forEach(el => el.classList.remove("selected"));
const selectedEl = document.getElementById(`learn-item-${classKey}`);
if (selectedEl) {
selectedEl.classList.add("selected");
}
const display = document.getElementById("learn-selected-article-display");
if (display) {
const name = articlesDatabase[classKey]?.name || classKey;
display.textContent = name;
}
}
function updateLearnBadge() {
fetch("/api/capture_count")
.then(r => r.json())
.then(data => {
const badge = document.getElementById("learn-session-count-badge");
if (badge) badge.textContent = `Eigene Bilder Gesamt: ${data.count}`;
const displayCheckout = document.getElementById("capture-count-display");
if (displayCheckout) displayCheckout.textContent = `Eigene Bilder: ${data.count}`;
})
.catch(err => console.error(err));
}
async function startSerialCapture() {
if (!learnSelectedClass) {
alert("Bitte wählen Sie zuerst einen Artikel aus der Liste aus.");
return;
}
if (!learnBackgroundFrame) {
alert("Bitte führen Sie zuerst die Hintergrund-Kalibrierung durch (Hintergrund speichern).");
return;
}
if (isSerialCapturing) return;
isSerialCapturing = true;
// Clear session gallery UI and list
const galleryGrid = document.getElementById("learn-gallery-grid");
galleryGrid.innerHTML = "";
learnSessionImages = [];
const startBtn = document.getElementById("learn-start-capture-btn");
startBtn.disabled = true;
startBtn.innerHTML = `<span>Aufnahme läuft...</span>`;
const progressText = document.getElementById("learn-progress-text");
const progressFill = document.getElementById("learn-progress-fill");
let count = 0;
const total = 10;
const captureNext = async () => {
// Sound feedback
playScannerBeep();
// Capture frame
const frameData = learnCanvasEl.toDataURL("image/jpeg", 0.95);
const currentBbox = learnCurrentBBox ? { ...learnCurrentBBox } : null;
try {
const response = await fetch("/api/capture_training_image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
image: frameData,
class_name: learnSelectedClass,
bbox: currentBbox
})
});
const data = await response.json();
if (data.status === "success" && data.filename) {
learnSessionImages.push(data.filename);
addPhotoToGallery(data.filename);
}
} catch (err) {
console.error("Serial capture step failed:", err);
}
count++;
if (progressText) progressText.textContent = `${count} / ${total} Bilder`;
if (progressFill) progressFill.style.width = `${(count / total) * 100}%`;
if (count >= total) {
clearInterval(captureInterval);
isSerialCapturing = false;
startBtn.disabled = false;
startBtn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>
START (SERIENAUFNAHME)
`;
updateLearnBadge();
alert("Serienaufnahme abgeschlossen! Sie können fehlerhafte Bilder unten in der Galerie löschen.");
}
};
// Start interval
await captureNext(); // First capture immediately
const captureInterval = setInterval(captureNext, 1000);
}
function addPhotoToGallery(filename) {
const galleryGrid = document.getElementById("learn-gallery-grid");
const emptyState = document.getElementById("learn-gallery-empty");
if (emptyState) emptyState.remove();
const wrapper = document.createElement("div");
wrapper.className = "gallery-item-wrapper";
wrapper.id = `gallery-item-${filename.replace(".", "_")}`;
wrapper.innerHTML = `
<img class="gallery-item-img" src="/api/captured_images/${filename}" alt="${filename}">
<button class="gallery-item-delete-btn" onclick="deleteSessionImage('${filename}')" title="Bild löschen">X</button>
`;
galleryGrid.appendChild(wrapper);
}
async function deleteSessionImage(filename) {
if (!confirm("Dieses Bild aus der Session löschen?")) return;
try {
const response = await fetch("/api/delete_captured_image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: filename })
});
const data = await response.json();
if (data.status === "success") {
// Remove from UI
const wrapper = document.getElementById(`gallery-item-${filename.replace(".", "_")}`);
if (wrapper) wrapper.remove();
// Remove from list
learnSessionImages = learnSessionImages.filter(f => f !== filename);
// If empty, show empty state
const galleryGrid = document.getElementById("learn-gallery-grid");
if (galleryGrid && galleryGrid.children.length === 0) {
galleryGrid.innerHTML = `
<div class="empty-state" id="learn-gallery-empty" style="grid-column: 1 / -1; padding: 40px 0;">
<p>Noch keine Aufnahmen in dieser Session</p>
<span>Wähle ein Produkt und starte die Serienaufnahme</span>
</div>
`;
}
updateLearnBadge();
console.log(data.message);
} else {
alert("Löschen fehlgeschlagen: " + data.message);
}
} catch (err) {
console.error("Delete image error:", err);
alert("Serverfehler beim Löschen des Bildes.");
}
}
// --- RESET ALL CAPTURED TRAINING DATA ---
async function resetAllCapturedData() {
const confirmed = confirm(
"⚠️ Alle selbst erfassten Trainingsbilder löschen?\n\n" +
"Dies entfernt alle user_capture_* Bilder und Labels sowie die Hintergrundkalibrierung.\n" +
"Das vortrainierte Basis-Modell bleibt erhalten.\n\n" +
"Dieser Vorgang kann NICHT rückgängig gemacht werden!"
);
if (!confirmed) return;
const resetBtnLearn = document.getElementById("reset-all-captures-btn");
const resetBtnDash = document.getElementById("dashboard-reset-btn");
if (resetBtnLearn) { resetBtnLearn.disabled = true; resetBtnLearn.textContent = "Wird gelöscht..."; }
if (resetBtnDash) { resetBtnDash.disabled = true; resetBtnDash.textContent = "Wird gelöscht..."; }
try {
const response = await fetch("/api/reset_dataset", { method: "POST" });
const data = await response.json();
if (data.status === "success") {
// 1. Clear gallery UI
const galleryGrid = document.getElementById("learn-gallery-grid");
if (galleryGrid) {
galleryGrid.innerHTML = `
<div class="empty-state" id="learn-gallery-empty" style="grid-column: 1 / -1; padding: 40px 0;">
<p>Noch keine Aufnahmen in dieser Session</p>
<span>Wähle ein Produkt und starte die Serienaufnahme</span>
</div>
`;
}
learnSessionImages = [];
// 2. Reset background calibration state
learnBackgroundFrame = null;
learnCurrentBBox = null;
const statusIndicator = document.getElementById("learn-calibration-indicator");
if (statusIndicator) {
statusIndicator.textContent = "Nicht kalibriert";
statusIndicator.style.color = "var(--text-muted)";
statusIndicator.classList.remove("live");
}
const checkbox = document.getElementById("learn-bg-ok-indicator");
if (checkbox) checkbox.checked = false;
// 3. Update count badges
const badge = document.getElementById("learn-session-count-badge");
if (badge) badge.textContent = "Eigene Bilder Gesamt: 0";
const countDisplay = document.getElementById("capture-count-display");
if (countDisplay) countDisplay.textContent = "Eigene Bilder: 0";
alert("✅ " + data.message);
} else {
alert("Fehler beim Zurücksetzen: " + (data.detail || data.message));
}
} catch (err) {
console.error("Reset error:", err);
alert("Serverfehler beim Zurücksetzen der Trainingsdaten.");
} finally {
if (resetBtnLearn) {
resetBtnLearn.disabled = false;
resetBtnLearn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Alle Trainingsbilder löschen
`;
}
if (resetBtnDash) {
resetBtnDash.disabled = false;
resetBtnDash.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Eigene Bilder & Kalibrierung zurücksetzen
`;
}
}
}
// --- RESET TRAINED AI MODEL ---
async function resetAIModel() {
const confirmed = confirm(
"⚠️ Trainiertes KI-Modell wirklich zurücksetzen?\n\n" +
"Dies löscht das trainierte PyTorch- und ONNX-Modell vom Server.\n" +
"Das System läuft danach im Simulations-Modus, bis Sie ein neues Modell trainieren.\n\n" +
"Dieser Vorgang kann NICHT rückgängig gemacht werden!"
);
if (!confirmed) return;
const resetBtn = document.getElementById("dashboard-reset-model-btn");
if (resetBtn) {
resetBtn.disabled = true;
resetBtn.textContent = "Zurücksetzen...";
}
try {
const response = await fetch("/api/reset_model", { method: "POST" });
const data = await response.json();
if (response.ok && data.status === "success") {
alert("✅ " + data.message);
// Refresh dashboard training status if polling is active
if (activeTab === 'dashboard') {
const statusResponse = await fetch("/api/train_status");
const statusData = await statusResponse.json();
updateTrainingStatusUI(statusData);
}
} else {
alert("Fehler beim Zurücksetzen des Modells: " + (data.detail || data.message));
}
} catch (err) {
console.error("Reset model error:", err);
alert("Serverfehler beim Zurücksetzen des KI-Modells.");
} finally {
if (resetBtn) {
resetBtn.disabled = false;
resetBtn.innerHTML = `
<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="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
Trainiertes KI-Modell zurücksetzen
`;
}
}
}
// --- INTERACTIVE EVENT LISTENERS ---
toggleCameraBtn.addEventListener("click", () => {
if (isWebcamActive) {
@@ -1022,4 +1718,8 @@ window.addEventListener("DOMContentLoaded", () => {
// Initialize capture classes
initCapturePanel();
// Check background calibration on startup
checkBackgroundCalibration();
updateLearnBadge();
});

View File

@@ -27,6 +27,10 @@
<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-learn" onclick="switchTab('learn')">
<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="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M8 11h8"/><path d="M12 7v8"/></svg>
Artikel anlernen
</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
@@ -71,6 +75,9 @@
<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>
<select id="camera-select" class="btn btn-secondary" style="background: var(--color-bg-input); border-color: var(--border-glass); color: var(--text-primary); outline: none; padding: 10px 14px; max-width: 180px; display: none;">
<option value="">Standard-Kamera</option>
</select>
<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)
@@ -108,7 +115,7 @@
<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>
<p class="card-subtitle">Vorschläge über Confidence-Schwellenwert &gt; 85%</p>
</div>
<div class="quick-buttons-container" id="quick-selection-buttons">
<div class="empty-state">
@@ -179,6 +186,123 @@
</div>
</section>
<!-- LEARN ARTICLE TAB (ANLERN-MODUS) -->
<section id="tab-learn" class="tab-content">
<div class="checkout-grid">
<!-- Left: Camera Preview & Calibration -->
<div class="card camera-card">
<div class="card-header">
<div>
<h2 class="card-title">Live-Kamerabild (Von oben)</h2>
<p class="card-subtitle">Kalibriere die Null-Linie und richte das Produkt aus</p>
</div>
<span class="status-indicator" id="learn-calibration-indicator">Nicht kalibriert</span>
</div>
<div class="camera-viewport-container">
<canvas id="learn-camera-canvas" width="640" height="480"></canvas>
<div class="scale-target-overlay">
<div class="scale-target-box"></div>
</div>
<div class="stream-badge" id="learn-camera-type-badge">SIMULATION-MODUS</div>
</div>
<div class="camera-controls" style="justify-content: space-between;">
<div style="display: flex; gap: 12px; align-items: center;">
<button id="learn-calibrate-btn" class="btn btn-secondary" onclick="calibrateBackground()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
Hintergrund speichern
</button>
<button id="learn-reset-calibrate-btn" class="btn btn-danger" style="padding: 10px 14px;" onclick="resetBackgroundCalibration()" title="Hintergrundkalibrierung löschen">
Reset
</button>
<select id="learn-camera-select" class="btn btn-secondary" style="background: var(--color-bg-input); border-color: var(--border-glass); color: var(--text-primary); outline: none; padding: 10px 14px; max-width: 180px; display: none;">
<option value="">Standard-Kamera</option>
</select>
</div>
<label class="toggle-control">
<input type="checkbox" id="learn-bg-ok-indicator" disabled>
<span class="toggle-slider"></span>
<span class="toggle-label">Hintergrund OK</span>
</label>
</div>
</div>
<!-- Right: Product Selection & Controls -->
<div class="checkout-sidebar">
<!-- Product Selection -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Artikel-Details</h2>
<p class="card-subtitle">Wähle den anzulernenden Artikel aus</p>
</div>
<div class="card-body" style="display: flex; flex-direction: column; gap: 16px;">
<div class="control-group" style="margin-bottom: 0;">
<label for="learn-search-input">Suche / PLU-Nummer</label>
<input type="text" id="learn-search-input" class="input-field" placeholder="z.B. 4011 oder Banane..." oninput="filterLearnArticles()" style="width: 100%; background: var(--color-bg-input); border: 1px solid var(--border-glass); color: var(--text-primary); padding: 10px 14px; border-radius: var(--border-radius-md); font-size: 14px;">
</div>
<div class="learn-articles-list" id="learn-articles-list" style="max-height: 180px; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; border: 1px solid var(--border-glass); padding: 8px; border-radius: var(--border-radius-md); background: rgba(0,0,0,0.15);">
<!-- Populated by JS -->
</div>
<div style="display: flex; flex-direction: column; gap: 4px; padding-top: 8px; border-top: 1px solid var(--border-glass);">
<span style="font-size: 12px; color: var(--text-secondary);">Ausgewählter Artikel:</span>
<strong id="learn-selected-article-display" style="font-size: 16px; color: var(--color-primary);">Kein Artikel ausgewählt</strong>
</div>
</div>
</div>
<!-- Recording Controls -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Aufnahme-Steuerung</h2>
<p class="card-subtitle">Serienaufnahme starten und Artikel drehen</p>
</div>
<div class="card-body" style="display: flex; flex-direction: column; gap: 16px;">
<button id="learn-start-capture-btn" class="btn btn-accent btn-glow" style="width: 100%; height: 50px; font-size: 15px;" onclick="startSerialCapture()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>
START (SERIENAUFNAHME)
</button>
<div class="progress-info" style="display: flex; justify-content: space-between; font-size: 13px; color: var(--text-secondary);">
<span>Fortschritt:</span>
<span id="learn-progress-text">0 / 10 Bilder</span>
</div>
<div class="progress-bar-container" style="height: 10px;">
<div class="progress-bar-fill" id="learn-progress-fill" style="width: 0%; background: var(--color-accent); box-shadow: 0 0 10px var(--color-accent-glow);"></div>
</div>
<p style="font-size: 12px; color: var(--text-muted); line-height: 1.4;">
Das System nimmt im Sekundentakt automatisch 10 Bilder auf. Stupsen Sie das Produkt zwischen den Signaltönen leicht an, um verschiedene Ansichten zu erfassen.
</p>
</div>
</div>
</div>
</div>
<!-- Gallery Section -->
<div class="card" style="margin-top: 24px;">
<div class="card-header">
<div>
<h2 class="card-title">Bilder-Galerie (Aktuelle Session)</h2>
<p class="card-subtitle">Hier werden die 10 aufgenommenen Bilder angezeigt. Lösche fehlerhafte Bilder per Klick auf das X.</p>
</div>
<div style="display: flex; gap: 12px; align-items: center;">
<span class="capture-count-badge" id="learn-session-count-badge">Session Bilder: 0</span>
<button id="reset-all-captures-btn" class="btn btn-danger" onclick="resetAllCapturedData()" style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-size: 13px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Alle Trainingsbilder löschen
</button>
</div>
</div>
<div class="card-body">
<div class="gallery-grid" id="learn-gallery-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 16px;">
<div class="empty-state" id="learn-gallery-empty" style="grid-column: 1 / -1; padding: 40px 0;">
<p>Noch keine Aufnahmen in dieser Session</p>
<span>Wähle ein Produkt und starte die Serienaufnahme</span>
</div>
</div>
</div>
</div>
</section>
<!-- DEVELOPER DASHBOARD TAB -->
<section id="tab-dashboard" class="tab-content">
<div class="dashboard-grid">
@@ -217,6 +341,49 @@
</div>
</div>
<!-- Step 0: Reset Captured Training Data -->
<div class="card" style="border: 1px solid rgba(239, 68, 68, 0.3); background: rgba(239, 68, 68, 0.04);">
<div class="card-header">
<div>
<h2 class="card-title" style="color: var(--color-danger);">⚠ KI Gefahrenzone & Reset-Optionen</h2>
<p class="card-subtitle">Trainingsdaten löschen oder das trainierte KI-Modell zurücksetzen</p>
</div>
</div>
<div class="card-body" style="display: flex; flex-direction: column; gap: 20px;">
<!-- Option 1: Dataset reset -->
<div style="display: flex; flex-direction: column; gap: 8px;">
<h3 style="margin: 0; font-size: 14px; color: var(--text-primary);">1. Trainingsbilder & Kalibrierung</h3>
<p style="font-size: 13px; color: var(--text-secondary); margin: 0; line-height: 1.5;">
Löscht alle selbst erfassten Trainingsbilder und setzt den kalibrierten Hintergrund zurück.
</p>
<div class="action-row" style="margin-top: 4px;">
<button id="dashboard-reset-btn" class="btn btn-danger" onclick="resetAllCapturedData()" style="display: flex; align-items: center; gap: 8px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Eigene Bilder & Kalibrierung zurücksetzen
</button>
<span class="action-desc" style="color: var(--color-danger); font-weight: 500;">Nicht umkehrbar!</span>
</div>
</div>
<div style="border-top: 1px solid rgba(239, 68, 68, 0.2); width: 100%;"></div>
<!-- Option 2: Model reset -->
<div style="display: flex; flex-direction: column; gap: 8px;">
<h3 style="margin: 0; font-size: 14px; color: var(--text-primary);">2. KI-Modell zurücksetzen</h3>
<p style="font-size: 13px; color: var(--text-secondary); margin: 0; line-height: 1.5;">
Löscht das trainierte PyTorch- und ONNX-Modell. Das System fällt danach in den Simulations-Modus zurück, bis ein neues Modell trainiert wird.
</p>
<div class="action-row" style="margin-top: 4px;">
<button id="dashboard-reset-model-btn" class="btn btn-danger" onclick="resetAIModel()" style="display: flex; align-items: center; gap: 8px; background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(239, 68, 68, 0.3)); border: 1px solid rgba(239, 68, 68, 0.5);">
<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="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
Trainiertes KI-Modell zurücksetzen
</button>
<span class="action-desc" style="color: var(--color-danger); font-weight: 500;">Setzt Inferenz auf Simulation zurück.</span>
</div>
</div>
</div>
</div>
<!-- Step 3: Export & Hardware-Performance -->
<div class="card">
<div class="card-header">
@@ -272,14 +439,17 @@
<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>
<option value="4" selected>4 (CPU empfohlen)</option>
<option value="8">8 (GPU)</option>
<option value="16">16 (GPU)</option>
</select>
</div>
<button class="btn btn-primary" id="start-training-btn" onclick="startTraining()">
Training starten
</button>
<button class="btn btn-danger" id="cancel-training-btn" onclick="cancelTraining()" style="display: none;">
Abbrechen
</button>
</div>
<!-- Training Status -->
@@ -373,7 +543,7 @@
<!-- Footer -->
<footer class="app-footer">
<p>Lizenz-Fokus: 100% kommerziell nutzbar, BSD & MIT Open-Source Lizenzen. ONNX Runtime Inference.</p>
<p>Made by Hephex Industries</p>
</footer>
</div>

View File

@@ -1073,3 +1073,105 @@ input:checked + .toggle-slider::before {
color: var(--text-muted);
font-size: 11px;
}
/* Learn Tab specific styles */
.learn-articles-list {
scrollbar-width: thin;
scrollbar-color: var(--border-glass) transparent;
}
.learn-article-item {
padding: 8px 12px;
border-radius: var(--border-radius-sm);
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-glass);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s ease;
}
.learn-article-item:hover {
background: rgba(99, 102, 241, 0.06);
border-color: rgba(99, 102, 241, 0.3);
color: var(--text-primary);
}
.learn-article-item.selected {
background: rgba(99, 102, 241, 0.15);
border-color: var(--color-primary);
color: var(--text-primary);
box-shadow: 0 0 10px rgba(99, 102, 241, 0.15);
font-weight: 500;
}
.learn-article-item .item-plu {
font-family: monospace;
font-size: 11px;
background: rgba(255, 255, 255, 0.05);
padding: 2px 6px;
border-radius: 4px;
color: var(--text-muted);
}
.learn-article-item.selected .item-plu {
background: rgba(99, 102, 241, 0.3);
color: #a5b4fc;
}
.gallery-item-wrapper {
position: relative;
border-radius: var(--border-radius-md);
border: 1px solid var(--border-glass);
background: rgba(0, 0, 0, 0.3);
aspect-ratio: 4/3;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
transition: all 0.2s ease;
}
.gallery-item-wrapper:hover {
transform: scale(1.03);
border-color: var(--color-accent);
box-shadow: 0 4px 12px rgba(168, 85, 247, 0.2);
}
.gallery-item-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.gallery-item-delete-btn {
position: absolute;
top: 6px;
right: 6px;
width: 22px;
height: 22px;
background: rgba(239, 68, 68, 0.9);
border: none;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
font-size: 11px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
.gallery-item-wrapper:hover .gallery-item-delete-btn {
opacity: 1;
}
.gallery-item-delete-btn:hover {
background: var(--color-danger);
transform: scale(1.1);
}

View File

@@ -1,15 +1,15 @@
{
"apfel_elstar": {
"name": "Apfel Elstar",
"ean": "4001234000018"
"ean": "4001234000163"
},
"apfel_gala": {
"name": "Apfel Gala",
"ean": "4001234000025"
"ean": "4005096027968"
},
"apfel_granny_smith": {
"name": "Apfel Granny Smith",
"ean": "4001234000032"
"ean": "4018178051126"
},
"banane_chiquita": {
"name": "Banane Chiquita",
@@ -17,7 +17,7 @@
},
"banane_bio": {
"name": "Bio Banane",
"ean": "4001234000056"
"ean": "4004545329462"
},
"birne": {
"name": "Birne Abate Fetel",
@@ -25,19 +25,19 @@
},
"orange": {
"name": "Orangen",
"ean": "4001234000070"
"ean": "4040328034000"
},
"zitrone": {
"name": "Zitronen",
"ean": "4001234000087"
"ean": "4001021530701"
},
"limette": {
"name": "Limetten",
"ean": "4001234000094"
"ean": "4001165020311"
},
"erdbeere": {
"name": "Erdbeeren Schale",
"ean": "4001234000100"
"ean": "6903929829957"
},
"blaubeere": {
"name": "Kulturheidelbeeren",
@@ -110,5 +110,9 @@
"avocado": {
"name": "Avocado Hass",
"ean": "4001234000285"
},
"handy": {
"name": "Iphone",
"ean": "6903929829940"
}
}

BIN
dataset/background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -26,3 +26,4 @@ paprika_gruen
champignon
zucchini
avocado
handy

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 39 KiB

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