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

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):