delete me
This commit is contained in:
BIN
app/__pycache__/main.cpython-314.pyc
Normal file
BIN
app/__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
411
app/main.py
411
app/main.py
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
|
||||
// Get frame as base64
|
||||
currentFrameBase64 = canvasEl.toDataURL("image/jpeg", 0.7);
|
||||
|
||||
// Auto-detect if enabled
|
||||
if (autoDetectEnabled && !isDetecting) {
|
||||
runDetection();
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 (autoDetectEnabled && !isDetecting) {
|
||||
runDetection();
|
||||
if (activeTab === 'checkout') {
|
||||
ctx.drawImage(img, 0, 0, canvasEl.width, canvasEl.height);
|
||||
currentFrameBase64 = data.image;
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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 > 10%</p>
|
||||
<p class="card-subtitle">Vorschläge über Confidence-Schwellenwert > 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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user