initial commit
BIN
__pycache__/data_generator.cpython-311.pyc
Normal file
BIN
__pycache__/dataset.cpython-311.pyc
Normal file
BIN
__pycache__/inference.cpython-311.pyc
Normal file
BIN
app/__pycache__/main.cpython-311.pyc
Normal file
523
app/main.py
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import subprocess
|
||||||
|
from io import BytesIO
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
import cv2
|
||||||
|
import albumentations as A
|
||||||
|
import ctypes
|
||||||
|
import time
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from inference import FruitVegetableDetector, CLASSES
|
||||||
|
|
||||||
|
app = FastAPI(title="POS Object Detection API", description="Fruit & Vegetable checkout detection microservice")
|
||||||
|
|
||||||
|
# Initialize detector
|
||||||
|
detector = FruitVegetableDetector()
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
STATUS_FILE = os.path.join(BASE_DIR, "training_status.json")
|
||||||
|
|
||||||
|
# Data Models
|
||||||
|
class DetectionRequest(BaseModel):
|
||||||
|
image: str # Base64 encoded image or Data URL
|
||||||
|
|
||||||
|
class TrainRequest(BaseModel):
|
||||||
|
epochs: int = 15
|
||||||
|
batch_size: int = 8
|
||||||
|
lr: float = 0.001
|
||||||
|
|
||||||
|
class CaptureRequest(BaseModel):
|
||||||
|
image: str
|
||||||
|
class_name: str
|
||||||
|
|
||||||
|
class SaveArticlesRequest(BaseModel):
|
||||||
|
articles: dict
|
||||||
|
|
||||||
|
class ScanRequest(BaseModel):
|
||||||
|
class_name: str
|
||||||
|
|
||||||
|
# Serve frontend static files
|
||||||
|
static_path = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
os.makedirs(static_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Helper function to read training status
|
||||||
|
def get_training_status():
|
||||||
|
if not os.path.exists(STATUS_FILE):
|
||||||
|
return {
|
||||||
|
"status": "idle",
|
||||||
|
"epoch": 0,
|
||||||
|
"total_epochs": 0,
|
||||||
|
"train_loss": [],
|
||||||
|
"val_loss": [],
|
||||||
|
"progress": 0.0,
|
||||||
|
"message": "No training history found."
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(STATUS_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {"status": "error", "message": "Failed to read status file."}
|
||||||
|
|
||||||
|
# Helper to write status
|
||||||
|
def write_training_status(status_dict):
|
||||||
|
with open(STATUS_FILE, "w") as f:
|
||||||
|
json.dump(status_dict, f, indent=2)
|
||||||
|
|
||||||
|
@app.post("/api/detect")
|
||||||
|
async def detect_objects(payload: DetectionRequest):
|
||||||
|
"""
|
||||||
|
Accepts base64 encoded frame, runs ONNX inference, and returns detections.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data_url = payload.image
|
||||||
|
if "," in data_url:
|
||||||
|
data_url = data_url.split(",")[1]
|
||||||
|
|
||||||
|
img_bytes = base64.b64decode(data_url)
|
||||||
|
img = Image.open(BytesIO(img_bytes)).convert("RGB")
|
||||||
|
img_np = np.array(img)
|
||||||
|
|
||||||
|
predictions = detector.detect(img_np)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"predictions": predictions
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"status": "error", "message": f"Inference failed: {str(e)}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def bg_generate_dataset():
|
||||||
|
try:
|
||||||
|
from data_generator import build_dataset
|
||||||
|
build_dataset(base_dir=os.path.join(BASE_DIR, "dataset"), train_count=200, val_count=50)
|
||||||
|
write_training_status({
|
||||||
|
"status": "idle",
|
||||||
|
"epoch": 0,
|
||||||
|
"total_epochs": 0,
|
||||||
|
"train_loss": [],
|
||||||
|
"val_loss": [],
|
||||||
|
"progress": 100.0,
|
||||||
|
"message": "Dataset generation completed successfully!"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
write_training_status({
|
||||||
|
"status": "failed",
|
||||||
|
"epoch": 0,
|
||||||
|
"total_epochs": 0,
|
||||||
|
"train_loss": [],
|
||||||
|
"val_loss": [],
|
||||||
|
"progress": 0.0,
|
||||||
|
"message": f"Dataset generation failed: {str(e)}"
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.post("/api/generate_dataset")
|
||||||
|
async def trigger_dataset_generation(background_tasks: BackgroundTasks):
|
||||||
|
"""
|
||||||
|
Triggers dataset generation asynchronously.
|
||||||
|
"""
|
||||||
|
status = get_training_status()
|
||||||
|
if status.get("status") in ["training", "generating_data"]:
|
||||||
|
raise HTTPException(status_code=400, detail="An operation is already in progress.")
|
||||||
|
|
||||||
|
write_training_status({
|
||||||
|
"status": "generating_data",
|
||||||
|
"epoch": 0,
|
||||||
|
"total_epochs": 0,
|
||||||
|
"train_loss": [],
|
||||||
|
"val_loss": [],
|
||||||
|
"progress": 10.0,
|
||||||
|
"message": "Starting synthetic data generation process..."
|
||||||
|
})
|
||||||
|
|
||||||
|
background_tasks.add_task(bg_generate_dataset)
|
||||||
|
|
||||||
|
return {"status": "success", "message": "Dataset generation started in the background."}
|
||||||
|
|
||||||
|
@app.post("/api/train")
|
||||||
|
async def trigger_training(req: TrainRequest):
|
||||||
|
"""
|
||||||
|
Triggers model training.
|
||||||
|
"""
|
||||||
|
status = get_training_status()
|
||||||
|
if status.get("status") in ["training", "generating_data"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Training or data generation is already in progress.")
|
||||||
|
|
||||||
|
write_training_status({
|
||||||
|
"status": "training",
|
||||||
|
"epoch": 0,
|
||||||
|
"total_epochs": req.epochs,
|
||||||
|
"train_loss": [],
|
||||||
|
"val_loss": [],
|
||||||
|
"progress": 5.0,
|
||||||
|
"message": "Initializing training background process..."
|
||||||
|
})
|
||||||
|
|
||||||
|
# Launch training process in background
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
os.path.join(BASE_DIR, "train.py"),
|
||||||
|
"--epochs", str(req.epochs),
|
||||||
|
"--batch-size", str(req.batch_size),
|
||||||
|
"--lr", str(req.lr)
|
||||||
|
]
|
||||||
|
subprocess.Popen(cmd, cwd=BASE_DIR)
|
||||||
|
|
||||||
|
return {"status": "success", "message": f"Training initiated for {req.epochs} epochs."}
|
||||||
|
|
||||||
|
@app.get("/api/train_status")
|
||||||
|
async def get_status():
|
||||||
|
"""
|
||||||
|
Returns current training status.
|
||||||
|
"""
|
||||||
|
# Check if background processes are actually running
|
||||||
|
# If status says training/generating but no process matches, we can update status to failed/idle.
|
||||||
|
status = get_training_status()
|
||||||
|
return status
|
||||||
|
|
||||||
|
@app.post("/api/export")
|
||||||
|
async def trigger_export():
|
||||||
|
"""
|
||||||
|
Exports trained PyTorch weights to ONNX format and reloads the detector.
|
||||||
|
"""
|
||||||
|
pytorch_path = os.path.join(BASE_DIR, "models", "model.pt")
|
||||||
|
if not os.path.exists(pytorch_path):
|
||||||
|
raise HTTPException(status_code=404, detail="No trained PyTorch weights found at models/model.pt. Train a model first.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run export script synchronously since it is fast (few seconds)
|
||||||
|
cmd = [sys.executable, os.path.join(BASE_DIR, "export.py")]
|
||||||
|
result = subprocess.run(cmd, cwd=BASE_DIR, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
# Reload the detector model
|
||||||
|
detector.load_model()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Model successfully exported to ONNX and loaded into memory.",
|
||||||
|
"log": result.stdout
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"status": "error", "message": f"ONNX Export failed: {str(e)}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/augmented_preview")
|
||||||
|
async def get_augmented_preview():
|
||||||
|
"""
|
||||||
|
Generates a sample preview image from the generator, applies augmentations,
|
||||||
|
draws bounding boxes on both, and returns them side-by-side.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from data_generator import generate_sample
|
||||||
|
|
||||||
|
# Generate a raw sample image and its YOLO annotations
|
||||||
|
img, annotations = generate_sample(width=320, height=320)
|
||||||
|
img_np = np.array(img)
|
||||||
|
|
||||||
|
# Setup the same augmentation pipeline used in training
|
||||||
|
transform = A.Compose([
|
||||||
|
A.ShiftScaleRotate(shift_limit=0.1, scale_limit=0.15, rotate_limit=30, p=1.0),
|
||||||
|
A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=1.0),
|
||||||
|
A.GaussNoise(p=1.0)
|
||||||
|
], bbox_params=A.BboxParams(format='pascal_voc', label_fields=['category_ids']))
|
||||||
|
|
||||||
|
# Parse annotations to box absolute coords
|
||||||
|
boxes = []
|
||||||
|
classes = []
|
||||||
|
for ann in annotations:
|
||||||
|
parts = ann.strip().split()
|
||||||
|
if len(parts) == 5:
|
||||||
|
class_id = int(parts[0])
|
||||||
|
xc, yc, w, h = map(float, parts[1:])
|
||||||
|
xmin = (xc - w/2) * 320
|
||||||
|
ymin = (yc - h/2) * 320
|
||||||
|
xmax = (xc + w/2) * 320
|
||||||
|
ymax = (yc + h/2) * 320
|
||||||
|
boxes.append([xmin, ymin, xmax, ymax])
|
||||||
|
classes.append(class_id)
|
||||||
|
|
||||||
|
boxes = np.array(boxes, dtype=np.float32).reshape(-1, 4)
|
||||||
|
|
||||||
|
# Create augmented image
|
||||||
|
augmented_np = img_np.copy()
|
||||||
|
aug_boxes = boxes.copy()
|
||||||
|
if len(boxes) > 0:
|
||||||
|
try:
|
||||||
|
augmented = transform(image=img_np, bboxes=boxes, category_ids=classes)
|
||||||
|
augmented_np = augmented["image"]
|
||||||
|
aug_boxes = np.array(augmented["bboxes"], dtype=np.float32).reshape(-1, 4)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Draw bounding boxes on original image
|
||||||
|
orig_draw = img.copy()
|
||||||
|
draw_tool = ImageDraw.Draw(orig_draw)
|
||||||
|
for idx, box in enumerate(boxes):
|
||||||
|
draw_tool.rectangle(box.tolist(), outline=(255, 0, 0), width=3)
|
||||||
|
draw_tool.text((box[0]+2, box[1]+2), CLASSES[classes[idx]], fill=(255, 0, 0))
|
||||||
|
|
||||||
|
# Draw bounding boxes on augmented image
|
||||||
|
aug_img = Image.fromarray(augmented_np)
|
||||||
|
draw_tool_aug = ImageDraw.Draw(aug_img)
|
||||||
|
for idx, box in enumerate(aug_boxes):
|
||||||
|
draw_tool_aug.rectangle(box.tolist(), outline=(0, 255, 0), width=3)
|
||||||
|
draw_tool_aug.text((box[0]+2, box[1]+2), CLASSES[classes[idx]], fill=(0, 255, 0))
|
||||||
|
|
||||||
|
# Create side-by-side preview canvas
|
||||||
|
preview = Image.new("RGB", (640, 320))
|
||||||
|
preview.paste(orig_draw, (0, 0))
|
||||||
|
preview.paste(aug_img, (320, 0))
|
||||||
|
|
||||||
|
# Buffer and return as base64
|
||||||
|
buffered = BytesIO()
|
||||||
|
preview.save(buffered, format="JPEG")
|
||||||
|
preview_base64 = base64.b64encode(buffered.getvalue()).decode()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"image": f"data:image/jpeg;base64,{preview_base64}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to generate preview: {str(e)}")
|
||||||
|
|
||||||
|
@app.get("/api/simulated_frame")
|
||||||
|
async def get_simulated_frame():
|
||||||
|
"""
|
||||||
|
Generates a raw synthetic scale frame (without annotations) for client-side POS testing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from data_generator import generate_sample
|
||||||
|
# Generate raw sample image (640x480)
|
||||||
|
img, _ = generate_sample(width=640, height=480)
|
||||||
|
|
||||||
|
buffered = BytesIO()
|
||||||
|
img.save(buffered, format="JPEG")
|
||||||
|
img_base64 = base64.b64encode(buffered.getvalue()).decode()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"image": f"data:image/jpeg;base64,{img_base64}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to generate simulated frame: {str(e)}")
|
||||||
|
|
||||||
|
@app.post("/api/capture_training_image")
|
||||||
|
async def capture_training_image(payload: CaptureRequest):
|
||||||
|
"""
|
||||||
|
Saves a webcam snapshot and generates a centered YOLO format bounding box label.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
class_name = payload.class_name
|
||||||
|
if class_name not in CLASSES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Ungültige Produktklasse: {class_name}")
|
||||||
|
|
||||||
|
class_idx = CLASSES.index(class_name)
|
||||||
|
|
||||||
|
# Decode base64
|
||||||
|
data_url = payload.image
|
||||||
|
if "," in data_url:
|
||||||
|
data_url = data_url.split(",")[1]
|
||||||
|
img_bytes = base64.b64decode(data_url)
|
||||||
|
img = Image.open(BytesIO(img_bytes)).convert("RGB")
|
||||||
|
|
||||||
|
# Save image with unique filename
|
||||||
|
import time
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
filename = f"user_capture_{timestamp}.jpg"
|
||||||
|
|
||||||
|
images_dir = os.path.join(BASE_DIR, "dataset", "images", "train")
|
||||||
|
labels_dir = os.path.join(BASE_DIR, "dataset", "labels", "train")
|
||||||
|
os.makedirs(images_dir, exist_ok=True)
|
||||||
|
os.makedirs(labels_dir, exist_ok=True)
|
||||||
|
|
||||||
|
img.save(os.path.join(images_dir, filename), quality=95)
|
||||||
|
|
||||||
|
# Write YOLO label centered at scale
|
||||||
|
# Bounding box: class_idx, x_center=0.5, y_center=0.5, width=0.6, height=0.6
|
||||||
|
label_filename = f"user_capture_{timestamp}.txt"
|
||||||
|
with open(os.path.join(labels_dir, label_filename), "w") as f:
|
||||||
|
f.write(f"{class_idx} 0.500000 0.500000 0.650000 0.650000\n")
|
||||||
|
|
||||||
|
# Count total user captured images
|
||||||
|
user_images = [f for f in os.listdir(images_dir) if f.startswith("user_capture_")]
|
||||||
|
count = len(user_images)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Bild erfolgreich als Training für {class_name} erfasst!",
|
||||||
|
"count": count
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Fehler bei Bild-Erfassung: {str(e)}")
|
||||||
|
|
||||||
|
@app.get("/api/capture_count")
|
||||||
|
async def get_capture_count():
|
||||||
|
"""
|
||||||
|
Returns the number of user captured training images.
|
||||||
|
"""
|
||||||
|
images_dir = os.path.join(BASE_DIR, "dataset", "images", "train")
|
||||||
|
if not os.path.exists(images_dir):
|
||||||
|
return {"count": 0}
|
||||||
|
user_images = [f for f in os.listdir(images_dir) if f.startswith("user_capture_")]
|
||||||
|
return {"count": len(user_images)}
|
||||||
|
|
||||||
|
# --- BARCODE SCANNER EMULATION (Windows Keyboard Hook) ---
|
||||||
|
KEYEVENTF_KEYUP = 0x0002
|
||||||
|
VK_RETURN = 0x0D
|
||||||
|
ARTICLES_FILE = os.path.join(BASE_DIR, "articles.json")
|
||||||
|
|
||||||
|
def simulate_keyboard_type(text: str):
|
||||||
|
"""
|
||||||
|
Types the EAN code and presses Enter globally on Windows.
|
||||||
|
Uses hardware scan codes mapped from virtual key codes for highest compatibility.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Give a small 200ms delay to allow cashier window to gain focus if clicked manually
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
KEYEVENTF_KEYUP = 0x0002
|
||||||
|
MAPVK_VK_TO_VSC = 0
|
||||||
|
|
||||||
|
for char in text:
|
||||||
|
vk = None
|
||||||
|
if '0' <= char <= '9':
|
||||||
|
vk = 0x30 + int(char)
|
||||||
|
elif 'A' <= char <= 'Z':
|
||||||
|
vk = ord(char)
|
||||||
|
elif 'a' <= char <= 'z':
|
||||||
|
vk = ord(char.upper())
|
||||||
|
|
||||||
|
if vk is not None:
|
||||||
|
# Map to hardware scan code
|
||||||
|
scan_code = ctypes.windll.user32.MapVirtualKeyW(vk, MAPVK_VK_TO_VSC)
|
||||||
|
|
||||||
|
# Key Down
|
||||||
|
ctypes.windll.user32.keybd_event(vk, scan_code, 0, 0)
|
||||||
|
time.sleep(0.001)
|
||||||
|
|
||||||
|
# Key Up
|
||||||
|
ctypes.windll.user32.keybd_event(vk, scan_code, KEYEVENTF_KEYUP, 0)
|
||||||
|
time.sleep(0.001)
|
||||||
|
|
||||||
|
# Send VK_RETURN (Enter) with scan code
|
||||||
|
vk_ret = 0x0D
|
||||||
|
scan_ret = ctypes.windll.user32.MapVirtualKeyW(vk_ret, MAPVK_VK_TO_VSC)
|
||||||
|
|
||||||
|
ctypes.windll.user32.keybd_event(vk_ret, scan_ret, 0, 0)
|
||||||
|
time.sleep(0.001)
|
||||||
|
ctypes.windll.user32.keybd_event(vk_ret, scan_ret, KEYEVENTF_KEYUP, 0)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to type keyboard input: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@app.get("/api/articles")
|
||||||
|
async def get_articles():
|
||||||
|
if not os.path.exists(ARTICLES_FILE):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(ARTICLES_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Fehler beim Laden der Artikeldatenbank: {str(e)}")
|
||||||
|
|
||||||
|
@app.post("/api/articles")
|
||||||
|
async def save_articles(payload: SaveArticlesRequest):
|
||||||
|
try:
|
||||||
|
with open(ARTICLES_FILE, "w") as f:
|
||||||
|
json.dump(payload.articles, f, indent=2)
|
||||||
|
from data_generator import reload_classes
|
||||||
|
reload_classes()
|
||||||
|
return {"status": "success", "message": "Artikeldatenbank erfolgreich gespeichert!"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Fehler beim Speichern der Artikeldatenbank: {str(e)}")
|
||||||
|
|
||||||
|
# --- TCP SOCKET CLIENT SENDER ---
|
||||||
|
TCP_PORT = 9000
|
||||||
|
|
||||||
|
def send_ean_via_tcp(ean_code: str):
|
||||||
|
"""
|
||||||
|
Sends the EAN code to the cash register over TCP on port 9000.
|
||||||
|
Since the cash register is the server (listening on port 9000), we connect
|
||||||
|
as a client, transmit the barcode, and close the connection.
|
||||||
|
"""
|
||||||
|
message = (ean_code + "\r\n").encode('utf-8')
|
||||||
|
|
||||||
|
print(f"TCP Client Mode: Connecting to cash register at 127.0.0.1:{TCP_PORT}...", flush=True)
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.settimeout(0.5) # Fast timeout
|
||||||
|
s.connect(('127.0.0.1', TCP_PORT))
|
||||||
|
s.sendall(message)
|
||||||
|
print(f"TCP Client: Successfully sent EAN {ean_code} to cash register.", flush=True)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"TCP Client: Connection to cash register failed (is register listening on port {TCP_PORT}?): {e}", flush=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@app.post("/api/scan_ean")
|
||||||
|
async def scan_ean_endpoint(payload: ScanRequest):
|
||||||
|
try:
|
||||||
|
class_name = payload.class_name
|
||||||
|
if not os.path.exists(ARTICLES_FILE):
|
||||||
|
raise HTTPException(status_code=404, detail="Artikeldatenbank existiert nicht.")
|
||||||
|
|
||||||
|
with open(ARTICLES_FILE, "r") as f:
|
||||||
|
articles = json.load(f)
|
||||||
|
|
||||||
|
if class_name not in articles:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Klasse {class_name} nicht in Artikeldatenbank.")
|
||||||
|
|
||||||
|
ean_code = articles[class_name].get("ean", "")
|
||||||
|
if not ean_code:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Kein EAN-Code für {class_name} hinterlegt.")
|
||||||
|
|
||||||
|
# 1. Send via TCP (Direct socket integration)
|
||||||
|
tcp_sent = send_ean_via_tcp(ean_code)
|
||||||
|
|
||||||
|
# 2. Parallel keyboard emulation fallback on Windows
|
||||||
|
keyboard_sent = simulate_keyboard_type(ean_code)
|
||||||
|
|
||||||
|
if tcp_sent or keyboard_sent:
|
||||||
|
methods = []
|
||||||
|
if tcp_sent:
|
||||||
|
methods.append("TCP")
|
||||||
|
if keyboard_sent:
|
||||||
|
methods.append("Tastatur")
|
||||||
|
method_desc = " + ".join(methods)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"class": class_name,
|
||||||
|
"name": articles[class_name].get("name", class_name),
|
||||||
|
"ean": ean_code,
|
||||||
|
"message": f"Barcode {ean_code} gesendet via {method_desc}!"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=500, detail="Übertragung fehlgeschlagen (TCP und Tastaturemulation gescheitert).")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
# Mount static files (must be after api definitions)
|
||||||
|
app.mount("/", StaticFiles(directory=static_path, html=True), name="static")
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return FileResponse(os.path.join(static_path, "index.html"))
|
||||||
1025
app/static/app.js
Normal file
382
app/static/index.html
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FruitVision AI - POS Objekterkennung</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-logo">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 20C7.59 20 4 16.41 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 16.41 16.41 20 12 20Z" fill="var(--color-primary)"/>
|
||||||
|
<path d="M11 6H13V12H11V6ZM11 14H13V16H11V14Z" fill="var(--color-primary)"/>
|
||||||
|
<circle cx="12" cy="12" r="3" fill="var(--color-accent)"/>
|
||||||
|
</svg>
|
||||||
|
<h1>FruitVision <span class="badge">POS-AI</span></h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="header-nav">
|
||||||
|
<button class="nav-btn active" id="tab-btn-checkout" onclick="switchTab('checkout')">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="2" y1="12" x2="22" y2="12"/></svg>
|
||||||
|
Kassensystem
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" id="tab-btn-articles" onclick="switchTab('articles')">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><circle cx="10" cy="9" r="1"/></svg>
|
||||||
|
Artikel-Verwaltung
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" id="tab-btn-dashboard" onclick="switchTab('dashboard')">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||||
|
Developer-Dashboard
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content Grid -->
|
||||||
|
<main class="app-main">
|
||||||
|
|
||||||
|
<!-- CHECKOUT TAB -->
|
||||||
|
<section id="tab-checkout" class="tab-content active">
|
||||||
|
<div class="checkout-grid">
|
||||||
|
<!-- Left: Camera Frame & Scale -->
|
||||||
|
<div class="card camera-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title">Kamera-Vorschau</h2>
|
||||||
|
<p class="card-subtitle">Positioniere das lose Obst & Gemüse auf der Waage</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-indicator live" id="stream-status">Simuliert</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="camera-viewport-container">
|
||||||
|
<video id="webcam" autoplay playsinline muted style="display: none;"></video>
|
||||||
|
<canvas id="camera-canvas" width="640" height="480"></canvas>
|
||||||
|
|
||||||
|
<!-- Calibration Overlay / Grid -->
|
||||||
|
<div class="scale-target-overlay">
|
||||||
|
<div class="scale-target-box"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stream-badge" id="camera-type-badge">SIMULATION-MODUS</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="camera-controls">
|
||||||
|
<button id="toggle-camera-btn" class="btn btn-secondary">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||||
|
Webcam aktivieren
|
||||||
|
</button>
|
||||||
|
<button id="trigger-scale-btn" class="btn btn-primary btn-glow">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M12 18h.01"/></svg>
|
||||||
|
Waage auslösen (Trigger)
|
||||||
|
</button>
|
||||||
|
<label class="toggle-control">
|
||||||
|
<input type="checkbox" id="auto-detect-toggle" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
<span class="toggle-label">Auto-Detect</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Real Data Capture Panel -->
|
||||||
|
<div class="capture-training-panel">
|
||||||
|
<h3>Reales Produktbild erfassen (Datensammler)</h3>
|
||||||
|
<div class="capture-controls-row">
|
||||||
|
<select id="capture-class-select">
|
||||||
|
<!-- Options populated by JS -->
|
||||||
|
</select>
|
||||||
|
<button id="capture-image-btn" class="btn btn-accent" onclick="captureForTraining()">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||||
|
Bild aufnehmen
|
||||||
|
</button>
|
||||||
|
<span class="capture-count-badge" id="capture-count-display">Eigene Bilder: 0</span>
|
||||||
|
</div>
|
||||||
|
<p class="panel-info-text">
|
||||||
|
Legen Sie Ihre echte Kartoffel mittig in das Zielkreuz und nehmen Sie 10-15 Bilder aus verschiedenen Winkeln/Entfernungen auf.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Quick Selection & Shopping Cart -->
|
||||||
|
<div class="checkout-sidebar">
|
||||||
|
|
||||||
|
<!-- AI Recommendations / Schnellwahl -->
|
||||||
|
<div class="card quick-select-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">KI Schnellwahltasten</h2>
|
||||||
|
<p class="card-subtitle">Vorschläge über Confidence-Schwellenwert > 10%</p>
|
||||||
|
</div>
|
||||||
|
<div class="quick-buttons-container" id="quick-selection-buttons">
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Warte auf Objekterkennung...</p>
|
||||||
|
<span>Legen Sie Obst auf die Waage</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Virtual Barcode-Scanner (Tastatur-Emulator) -->
|
||||||
|
<div class="card scanner-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Virtueller Barcode-Scanner</h2>
|
||||||
|
<label class="toggle-control">
|
||||||
|
<input type="checkbox" id="auto-scan-toggle" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
<span class="toggle-label">Auto-Scan</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" style="padding: 16px 20px;">
|
||||||
|
<div class="scanner-status-box" style="display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 10px;">
|
||||||
|
<span class="scanner-status-label" style="color: var(--text-secondary);">Scanner-Status:</span>
|
||||||
|
<span class="scanner-indicator" id="scanner-indicator" style="font-weight: 600; color: var(--color-success);">Bereit</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar-container" id="stability-progress-wrapper" style="height: 6px; margin-bottom: 12px; display: none;">
|
||||||
|
<div class="progress-bar-fill" id="stability-progress-fill" style="width: 0%; background: var(--color-accent); box-shadow: 0 0 10px var(--color-accent-glow);"></div>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-container" style="margin-top: 0; margin-bottom: 10px;">
|
||||||
|
<div class="terminal-header">Scanner-Verlauf (Keystroke Logs)</div>
|
||||||
|
<div class="scanner-log-output" id="scanner-log" style="font-family: monospace; font-size: 11px; color: var(--color-success); height: 80px; overflow-y: auto; line-height: 1.4;">Warte auf Erkennung...</div>
|
||||||
|
</div>
|
||||||
|
<button id="manual-scan-btn" class="btn btn-accent btn-glow" style="width: 100%;" onclick="triggerManualScan()">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7V4h3M4 17v3h3m10-16h3v3m-3 13v3h3M8 12h8m-4-4v8"/></svg>
|
||||||
|
EAN an aktive Kasse senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shopping Cart / Bon -->
|
||||||
|
<div class="card cart-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Kassenzettel</h2>
|
||||||
|
<p class="card-subtitle">Aktuelle Transaktion</p>
|
||||||
|
</div>
|
||||||
|
<div class="cart-items" id="cart-list">
|
||||||
|
<div class="empty-cart-state" id="empty-cart-msg">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>
|
||||||
|
<p>Einkaufswagen ist leer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cart-summary">
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Zwischensumme:</span>
|
||||||
|
<span id="cart-subtotal">0,00 €</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row total">
|
||||||
|
<span>Gesamtsumme:</span>
|
||||||
|
<span id="cart-total">0,00 €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cart-actions">
|
||||||
|
<button id="cancel-cart-btn" class="btn btn-danger" onclick="clearCart()">Storno</button>
|
||||||
|
<button id="pay-btn" class="btn btn-success btn-glow" onclick="checkoutCart()">Bezahlen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- DEVELOPER DASHBOARD TAB -->
|
||||||
|
<section id="tab-dashboard" class="tab-content">
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
|
||||||
|
<!-- Left: Pipeline Steps -->
|
||||||
|
<div class="dashboard-column">
|
||||||
|
|
||||||
|
<!-- Step 1: Synthetic Dataset & Augmentation -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="step-num">Schritt 1</span>
|
||||||
|
<h2 class="card-title">Synthetische Datenpipeline</h2>
|
||||||
|
<p class="card-subtitle">Generiere Trainingsdaten & Augmentierungen (Albumentations)</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="action-row">
|
||||||
|
<button class="btn btn-secondary" onclick="generateDataset()">
|
||||||
|
Datensatz generieren
|
||||||
|
</button>
|
||||||
|
<span class="action-desc">Erstellt 200 Trainings- & 50 Validierungsbilder (YOLO-Format)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="augmentation-section">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<h3>Augmentierungs-Vorschau (Live-Pipeline)</h3>
|
||||||
|
<button class="btn btn-text" onclick="refreshAugmentedPreview()">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
<p class="section-desc">Echtzeit-Störung (Shift, Rotate, Scale, Brightness, Noise) zur Vermeidung von Overfitting an Lichtbedingungen.</p>
|
||||||
|
|
||||||
|
<div class="preview-container" id="aug-preview-box">
|
||||||
|
<div class="preview-placeholder">
|
||||||
|
Klicke auf "Aktualisieren" um ein Bild und seine Albumentations-Störung anzuzeigen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Export & Hardware-Performance -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="step-num">Schritt 3</span>
|
||||||
|
<h2 class="card-title">ONNX-Export & Hardware-Inferenz</h2>
|
||||||
|
<p class="card-subtitle">Modell-Optimierung für flüssigen Kassenbetrieb unter 50 ms</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="action-row">
|
||||||
|
<button class="btn btn-accent" id="export-onnx-btn" onclick="exportModel()">
|
||||||
|
Modell in ONNX exportieren
|
||||||
|
</button>
|
||||||
|
<span class="action-desc">Konvertiert PyTorch (.pt) in ONNX (.onnx) für die Inferenz-Engine.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="terminal-container">
|
||||||
|
<div class="terminal-header">ONNX Compiler Logs</div>
|
||||||
|
<pre class="terminal-output" id="onnx-log">Warte auf Export-Trigger...</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-grid">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-label">Inferenz-Zeit (CPU/NPU)</span>
|
||||||
|
<span class="metric-value text-accent" id="onnx-latency">- ms</span>
|
||||||
|
<span class="metric-desc" id="onnx-latency-status">Warte auf Export</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-label">Lizenzen & Schutzrecht</span>
|
||||||
|
<span class="metric-value text-success">100% OK</span>
|
||||||
|
<span class="metric-desc">Royalty-Free (BSD / MIT), kein GPL-Copyleft</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Model Training Dashboard -->
|
||||||
|
<div class="dashboard-column">
|
||||||
|
|
||||||
|
<!-- Step 2: Model Training -->
|
||||||
|
<div class="card training-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="step-num">Schritt 2</span>
|
||||||
|
<h2 class="card-title">Transfer Learning (PyTorch)</h2>
|
||||||
|
<p class="card-subtitle">Modelltraining auf SSDLite MobileNetV3-Large Basis</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="training-controls-row">
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="param-epochs">Epochen</label>
|
||||||
|
<input type="number" id="param-epochs" value="15" min="5" max="100">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="param-batch">Batch Size</label>
|
||||||
|
<select id="param-batch">
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="8" selected>8</option>
|
||||||
|
<option value="16">16</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="start-training-btn" onclick="startTraining()">
|
||||||
|
Training starten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Training Status -->
|
||||||
|
<div class="training-status-box" id="training-status-box">
|
||||||
|
<div class="status-header-row">
|
||||||
|
<span class="status-title">Status: <strong id="training-state">Bereit</strong></span>
|
||||||
|
<span class="status-epoch" id="training-epoch">Epoche: -/-</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar-fill" id="training-progress-fill" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="status-log-message" id="training-log-msg">Klicke "Training starten" um den Feintuning-Prozess anzustoßen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart Area -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Lernkurve (Loss History)</h3>
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
<svg viewBox="0 0 500 200" class="training-chart" id="training-loss-chart">
|
||||||
|
<!-- Grid Lines -->
|
||||||
|
<line x1="40" y1="20" x2="480" y2="20" stroke="#333842" stroke-dasharray="4"/>
|
||||||
|
<line x1="40" y1="70" x2="480" y2="70" stroke="#333842" stroke-dasharray="4"/>
|
||||||
|
<line x1="40" y1="120" x2="480" y2="120" stroke="#333842" stroke-dasharray="4"/>
|
||||||
|
<line x1="40" y1="170" x2="480" y2="170" stroke="#4a505e"/>
|
||||||
|
|
||||||
|
<!-- Y-Axis Ticks -->
|
||||||
|
<text x="30" y="25" fill="#8a92a3" font-size="10" text-anchor="end">2.0</text>
|
||||||
|
<text x="30" y="75" fill="#8a92a3" font-size="10" text-anchor="end">1.0</text>
|
||||||
|
<text x="30" y="125" fill="#8a92a3" font-size="10" text-anchor="end">0.5</text>
|
||||||
|
<text x="30" y="175" fill="#8a92a3" font-size="10" text-anchor="end">0.0</text>
|
||||||
|
|
||||||
|
<!-- Chart Curves -->
|
||||||
|
<path id="train-loss-path" d="" fill="none" stroke="var(--color-primary)" stroke-width="3"/>
|
||||||
|
<path id="val-loss-path" d="" fill="none" stroke="var(--color-accent)" stroke-width="2" stroke-dasharray="2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="chart-legend">
|
||||||
|
<div class="legend-item"><span class="legend-color train"></span>Train Loss</div>
|
||||||
|
<div class="legend-item"><span class="legend-color val"></span>Validation Loss</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ARTICLE MANAGEMENT TAB -->
|
||||||
|
<section id="tab-articles" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title">Artikel- & EAN-Verwaltung</h2>
|
||||||
|
<p class="card-subtitle">Hinterlegen Sie die Barcodes (EAN) Ihrer Produkte für den virtuellen Scanner</p>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 12px; align-items: center;">
|
||||||
|
<button class="btn btn-secondary" onclick="addNewArticlePrompt()">
|
||||||
|
+ Artikel hinzufügen
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-glow" onclick="saveArticlesDatabase()">
|
||||||
|
Änderungen speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p style="font-size: 13px; color: var(--text-secondary); margin-bottom: 20px; line-height: 1.5;">
|
||||||
|
Geben Sie für jede Produktklasse den gewünschten Barcode (EAN) ein. Wenn das System dieses Produkt stabil erkennt, simuliert der <strong>virtuelle Tastatur-Scanner</strong> diesen Barcode direkt in Ihr Kassenfenster (gefolgt von Enter).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="articles-table-wrapper" style="overflow-x: auto;">
|
||||||
|
<table class="articles-table" style="width: 100%; border-collapse: collapse; text-align: left;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-glass); color: var(--text-secondary); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
<th style="padding: 12px 16px;">Klasse (ML-Key)</th>
|
||||||
|
<th style="padding: 12px 16px;">Name (Anzeige)</th>
|
||||||
|
<th style="padding: 12px 16px;">EAN-Barcode (Tastaturemulation)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="articles-table-body" style="font-size: 14px;">
|
||||||
|
<!-- Dynamic rows loaded by JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="app-footer">
|
||||||
|
<p>Lizenz-Fokus: 100% kommerziell nutzbar, BSD & MIT Open-Source Lizenzen. ONNX Runtime Inference.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1075
app/static/styles.css
Normal file
114
articles.json
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
{
|
||||||
|
"apfel_elstar": {
|
||||||
|
"name": "Apfel Elstar",
|
||||||
|
"ean": "4001234000018"
|
||||||
|
},
|
||||||
|
"apfel_gala": {
|
||||||
|
"name": "Apfel Gala",
|
||||||
|
"ean": "4001234000025"
|
||||||
|
},
|
||||||
|
"apfel_granny_smith": {
|
||||||
|
"name": "Apfel Granny Smith",
|
||||||
|
"ean": "4001234000032"
|
||||||
|
},
|
||||||
|
"banane_chiquita": {
|
||||||
|
"name": "Banane Chiquita",
|
||||||
|
"ean": "4001234000049"
|
||||||
|
},
|
||||||
|
"banane_bio": {
|
||||||
|
"name": "Bio Banane",
|
||||||
|
"ean": "4001234000056"
|
||||||
|
},
|
||||||
|
"birne": {
|
||||||
|
"name": "Birne Abate Fetel",
|
||||||
|
"ean": "4001234000063"
|
||||||
|
},
|
||||||
|
"orange": {
|
||||||
|
"name": "Orangen",
|
||||||
|
"ean": "4001234000070"
|
||||||
|
},
|
||||||
|
"zitrone": {
|
||||||
|
"name": "Zitronen",
|
||||||
|
"ean": "4001234000087"
|
||||||
|
},
|
||||||
|
"limette": {
|
||||||
|
"name": "Limetten",
|
||||||
|
"ean": "4001234000094"
|
||||||
|
},
|
||||||
|
"erdbeere": {
|
||||||
|
"name": "Erdbeeren Schale",
|
||||||
|
"ean": "4001234000100"
|
||||||
|
},
|
||||||
|
"blaubeere": {
|
||||||
|
"name": "Kulturheidelbeeren",
|
||||||
|
"ean": "4001234000117"
|
||||||
|
},
|
||||||
|
"weintraube_hell": {
|
||||||
|
"name": "Tafeltrauben hell",
|
||||||
|
"ean": "4001234000124"
|
||||||
|
},
|
||||||
|
"weintraube_dunkel": {
|
||||||
|
"name": "Tafeltrauben dunkel",
|
||||||
|
"ean": "4001234000131"
|
||||||
|
},
|
||||||
|
"pfirsich": {
|
||||||
|
"name": "Pfirsiche",
|
||||||
|
"ean": "4001234000148"
|
||||||
|
},
|
||||||
|
"tomate": {
|
||||||
|
"name": "Rispentomaten",
|
||||||
|
"ean": "4001234000155"
|
||||||
|
},
|
||||||
|
"gurke": {
|
||||||
|
"name": "Salatgurke",
|
||||||
|
"ean": "4001234000162"
|
||||||
|
},
|
||||||
|
"kartoffel": {
|
||||||
|
"name": "Speisekartoffeln",
|
||||||
|
"ean": "4001234000163"
|
||||||
|
},
|
||||||
|
"karotte": {
|
||||||
|
"name": "Speisem\u00c3\u00b6hren",
|
||||||
|
"ean": "4001234000186"
|
||||||
|
},
|
||||||
|
"zwiebel_gelb": {
|
||||||
|
"name": "Speisezwiebeln",
|
||||||
|
"ean": "4001234000193"
|
||||||
|
},
|
||||||
|
"zwiebel_rot": {
|
||||||
|
"name": "Rote Zwiebeln",
|
||||||
|
"ean": "4001234000209"
|
||||||
|
},
|
||||||
|
"knoblauch": {
|
||||||
|
"name": "Knoblauch",
|
||||||
|
"ean": "4001234000216"
|
||||||
|
},
|
||||||
|
"brokkoli": {
|
||||||
|
"name": "Brokkoli",
|
||||||
|
"ean": "4001234000223"
|
||||||
|
},
|
||||||
|
"paprika_rot": {
|
||||||
|
"name": "Paprika rot",
|
||||||
|
"ean": "4001234000230"
|
||||||
|
},
|
||||||
|
"paprika_gelb": {
|
||||||
|
"name": "Paprika gelb",
|
||||||
|
"ean": "4001234000247"
|
||||||
|
},
|
||||||
|
"paprika_gruen": {
|
||||||
|
"name": "Paprika gr\u00c3\u00bcn",
|
||||||
|
"ean": "4001234000254"
|
||||||
|
},
|
||||||
|
"champignon": {
|
||||||
|
"name": "Champignons wei\u00c3\u0178",
|
||||||
|
"ean": "4001234000261"
|
||||||
|
},
|
||||||
|
"zucchini": {
|
||||||
|
"name": "Zucchini",
|
||||||
|
"ean": "4001234000278"
|
||||||
|
},
|
||||||
|
"avocado": {
|
||||||
|
"name": "Avocado Hass",
|
||||||
|
"ean": "4001234000285"
|
||||||
|
}
|
||||||
|
}
|
||||||
475
data_generator.py
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
import os
|
||||||
|
import random
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Define expanded classes (28 classes)
|
||||||
|
DEFAULT_CLASSES = [
|
||||||
|
"apfel_elstar",
|
||||||
|
"apfel_gala",
|
||||||
|
"apfel_granny_smith",
|
||||||
|
"banane_chiquita",
|
||||||
|
"banane_bio",
|
||||||
|
"birne",
|
||||||
|
"orange",
|
||||||
|
"zitrone",
|
||||||
|
"limette",
|
||||||
|
"erdbeere",
|
||||||
|
"blaubeere",
|
||||||
|
"weintraube_hell",
|
||||||
|
"weintraube_dunkel",
|
||||||
|
"pfirsich",
|
||||||
|
"tomate",
|
||||||
|
"gurke",
|
||||||
|
"kartoffel",
|
||||||
|
"karotte",
|
||||||
|
"zwiebel_gelb",
|
||||||
|
"zwiebel_rot",
|
||||||
|
"knoblauch",
|
||||||
|
"brokkoli",
|
||||||
|
"paprika_rot",
|
||||||
|
"paprika_gelb",
|
||||||
|
"paprika_gruen",
|
||||||
|
"champignon",
|
||||||
|
"zucchini",
|
||||||
|
"avocado"
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_classes_from_db():
|
||||||
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
articles_path = os.path.join(base_dir, "articles.json")
|
||||||
|
if os.path.exists(articles_path):
|
||||||
|
try:
|
||||||
|
with open(articles_path, "r") as f:
|
||||||
|
articles = json.load(f)
|
||||||
|
keys = list(articles.keys())
|
||||||
|
if keys:
|
||||||
|
return keys
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return DEFAULT_CLASSES.copy()
|
||||||
|
|
||||||
|
CLASSES = get_classes_from_db()
|
||||||
|
|
||||||
|
def reload_classes():
|
||||||
|
new_classes = get_classes_from_db()
|
||||||
|
CLASSES.clear()
|
||||||
|
CLASSES.extend(new_classes)
|
||||||
|
print(f"Reloaded CLASSES: {len(CLASSES)} classes in memory.")
|
||||||
|
|
||||||
|
def draw_apple_elstar(draw):
|
||||||
|
draw.ellipse([45, 50, 155, 160], fill=(210, 45, 45, 255))
|
||||||
|
draw.ellipse([80, 45, 120, 60], fill=(210, 45, 45, 255))
|
||||||
|
draw.ellipse([55, 70, 110, 140], fill=(230, 140, 40, 180))
|
||||||
|
draw.line([(100, 55), (105, 25)], fill=(101, 67, 33, 255), width=4)
|
||||||
|
draw.ellipse([93, 50, 107, 57], fill=(60, 20, 20, 255))
|
||||||
|
|
||||||
|
def draw_apple_gala(draw):
|
||||||
|
draw.ellipse([45, 50, 155, 160], fill=(240, 190, 60, 255))
|
||||||
|
draw.ellipse([80, 45, 120, 60], fill=(240, 190, 60, 255))
|
||||||
|
for offset in range(-35, 45, 15):
|
||||||
|
draw.arc([45 + offset, 50, 155 + offset, 160], start=30, end=150, fill=(210, 40, 40, 200), width=4)
|
||||||
|
draw.arc([45 - offset, 50, 155 - offset, 160], start=30, end=150, fill=(210, 40, 40, 200), width=4)
|
||||||
|
draw.line([(100, 55), (103, 25)], fill=(101, 67, 33, 255), width=4)
|
||||||
|
draw.ellipse([93, 50, 107, 57], fill=(70, 50, 20, 255))
|
||||||
|
|
||||||
|
def draw_apple_granny_smith(draw):
|
||||||
|
# Bright green apple
|
||||||
|
draw.ellipse([45, 50, 155, 160], fill=(120, 200, 40, 255))
|
||||||
|
draw.ellipse([80, 45, 120, 60], fill=(120, 200, 40, 255))
|
||||||
|
# Light yellow highlight
|
||||||
|
draw.ellipse([60, 70, 95, 110], fill=(160, 230, 80, 180))
|
||||||
|
# Stem
|
||||||
|
draw.line([(100, 55), (105, 25)], fill=(101, 67, 33, 255), width=4)
|
||||||
|
draw.ellipse([93, 50, 107, 57], fill=(40, 70, 15, 255))
|
||||||
|
|
||||||
|
def draw_banana_chiquita(draw):
|
||||||
|
banana_pts = [
|
||||||
|
(40, 130), (50, 100), (70, 75), (100, 60), (130, 60),
|
||||||
|
(160, 75), (170, 95), (160, 95), (135, 80), (110, 80),
|
||||||
|
(85, 90), (65, 110), (50, 140)
|
||||||
|
]
|
||||||
|
draw.polygon(banana_pts, fill=(245, 215, 50, 255))
|
||||||
|
draw.polygon([(40, 130), (50, 140), (45, 145), (35, 135)], fill=(75, 100, 30, 255))
|
||||||
|
draw.polygon([(160, 75), (170, 95), (175, 90), (165, 70)], fill=(90, 75, 30, 255))
|
||||||
|
draw.ellipse([100, 65, 112, 75], fill=(10, 50, 150, 255))
|
||||||
|
draw.ellipse([103, 68, 109, 72], fill=(245, 215, 50, 255))
|
||||||
|
|
||||||
|
def draw_banana_bio(draw):
|
||||||
|
banana_pts = [
|
||||||
|
(45, 125), (55, 100), (75, 80), (100, 70), (125, 70),
|
||||||
|
(150, 82), (160, 98), (152, 98), (130, 88), (110, 85),
|
||||||
|
(88, 93), (70, 110), (53, 133)
|
||||||
|
]
|
||||||
|
draw.polygon(banana_pts, fill=(225, 220, 60, 255))
|
||||||
|
draw.polygon([(45, 125), (53, 133), (48, 138), (40, 130)], fill=(50, 90, 20, 255))
|
||||||
|
draw.polygon([(150, 82), (160, 98), (163, 93), (153, 77)], fill=(70, 90, 20, 255))
|
||||||
|
spots = [(70, 105), (85, 92), (100, 83), (115, 83), (130, 86)]
|
||||||
|
for sx, sy in spots:
|
||||||
|
draw.ellipse([sx-2, sy-2, sx+2, sy+2], fill=(80, 50, 20, 200))
|
||||||
|
draw.polygon([(95, 70), (105, 70), (102, 87), (92, 86)], fill=(120, 140, 90, 255))
|
||||||
|
|
||||||
|
def draw_pear(draw):
|
||||||
|
# Birne: pear shape (narrow top, wide bottom)
|
||||||
|
draw.ellipse([65, 50, 135, 110], fill=(180, 190, 50, 255)) # top
|
||||||
|
draw.ellipse([50, 90, 150, 170], fill=(180, 190, 50, 255)) # bottom
|
||||||
|
draw.ellipse([60, 75, 140, 145], fill=(180, 190, 50, 255)) # mid fill
|
||||||
|
# Orange-red blush
|
||||||
|
draw.ellipse([95, 100, 140, 150], fill=(210, 110, 30, 120))
|
||||||
|
# Stem
|
||||||
|
draw.line([(100, 55), (105, 25)], fill=(101, 67, 33, 255), width=3)
|
||||||
|
|
||||||
|
def draw_orange(draw):
|
||||||
|
draw.ellipse([45, 45, 155, 155], fill=(245, 120, 20, 255))
|
||||||
|
# Peel texture (little dots)
|
||||||
|
for _ in range(15):
|
||||||
|
tx = random.randint(60, 140)
|
||||||
|
ty = random.randint(60, 140)
|
||||||
|
draw.ellipse([tx, ty, tx+2, ty+2], fill=(215, 95, 5, 200))
|
||||||
|
|
||||||
|
def draw_lemon(draw):
|
||||||
|
# Zitrone: yellow oval with pointed ends
|
||||||
|
draw.ellipse([50, 65, 150, 135], fill=(245, 225, 40, 255))
|
||||||
|
draw.polygon([(40, 100), (52, 90), (52, 110)], fill=(245, 225, 40, 255)) # left tip
|
||||||
|
draw.polygon([(160, 100), (148, 90), (148, 110)], fill=(245, 225, 40, 255)) # right tip
|
||||||
|
|
||||||
|
def draw_lime(draw):
|
||||||
|
# Limette: green oval with pointed ends
|
||||||
|
draw.ellipse([55, 70, 145, 130], fill=(60, 160, 30, 255))
|
||||||
|
draw.polygon([(47, 100), (57, 92), (57, 108)], fill=(60, 160, 30, 255))
|
||||||
|
draw.polygon([(153, 100), (143, 92), (143, 108)], fill=(60, 160, 30, 255))
|
||||||
|
|
||||||
|
def draw_strawberry(draw):
|
||||||
|
# Erdbeere: heart shape
|
||||||
|
draw.polygon([(100, 165), (55, 80), (100, 60), (145, 80)], fill=(225, 30, 50, 255))
|
||||||
|
draw.ellipse([55, 70, 105, 100], fill=(225, 30, 50, 255))
|
||||||
|
draw.ellipse([95, 70, 145, 100], fill=(225, 30, 50, 255))
|
||||||
|
# Seeds (yellow dots)
|
||||||
|
for _ in range(20):
|
||||||
|
sx = random.randint(65, 135)
|
||||||
|
sy = random.randint(75, 140)
|
||||||
|
draw.ellipse([sx, sy, sx+2, sy+2], fill=(240, 220, 100, 255))
|
||||||
|
# Leafy top
|
||||||
|
draw.polygon([(100, 65), (85, 45), (95, 60), (100, 40), (105, 60), (115, 45)], fill=(40, 160, 50, 255))
|
||||||
|
|
||||||
|
def draw_blueberry(draw):
|
||||||
|
# Blaubeere: small blue circle
|
||||||
|
draw.ellipse([70, 70, 130, 130], fill=(45, 80, 160, 255))
|
||||||
|
# Crown (dark crown tip)
|
||||||
|
draw.polygon([(90, 70), (100, 62), (110, 70), (105, 75), (95, 75)], fill=(20, 45, 95, 255))
|
||||||
|
|
||||||
|
def draw_grape_green(draw):
|
||||||
|
# Weintraube hell: cluster of small green circles
|
||||||
|
coords = [
|
||||||
|
(100, 60), (85, 75), (115, 75), (70, 95), (100, 95), (130, 95),
|
||||||
|
(85, 115), (115, 115), (100, 135), (100, 155)
|
||||||
|
]
|
||||||
|
# Stem
|
||||||
|
draw.line([(100, 60), (100, 35)], fill=(110, 140, 50, 255), width=3)
|
||||||
|
for cx, cy in coords:
|
||||||
|
draw.ellipse([cx-16, cy-16, cx+16, cy+16], fill=(160, 210, 80, 255))
|
||||||
|
draw.ellipse([cx-16, cy-16, cx+16, cy+16], outline=(130, 180, 60, 255), width=1)
|
||||||
|
|
||||||
|
def draw_grape_dark(draw):
|
||||||
|
# Weintraube dunkel: cluster of small dark purple circles
|
||||||
|
coords = [
|
||||||
|
(100, 60), (85, 75), (115, 75), (70, 95), (100, 95), (130, 95),
|
||||||
|
(85, 115), (115, 115), (100, 135), (100, 155)
|
||||||
|
]
|
||||||
|
draw.line([(100, 60), (100, 35)], fill=(90, 70, 110, 255), width=3)
|
||||||
|
for cx, cy in coords:
|
||||||
|
draw.ellipse([cx-16, cy-16, cx+16, cy+16], fill=(65, 30, 105, 255))
|
||||||
|
draw.ellipse([cx-16, cy-16, cx+16, cy+16], outline=(45, 15, 80, 255), width=1)
|
||||||
|
|
||||||
|
def draw_peach(draw):
|
||||||
|
# Pfirsich: fuzzy orange-pink circle
|
||||||
|
draw.ellipse([45, 50, 155, 160], fill=(245, 130, 80, 255))
|
||||||
|
draw.ellipse([47, 50, 120, 160], fill=(245, 80, 110, 255)) # pink cheek
|
||||||
|
# Indentation line
|
||||||
|
draw.arc([40, 50, 150, 160], start=270, end=90, fill=(185, 45, 45, 255), width=2)
|
||||||
|
# stem
|
||||||
|
draw.line([(100, 53), (102, 30)], fill=(90, 70, 40, 255), width=3)
|
||||||
|
|
||||||
|
def draw_tomato(draw):
|
||||||
|
draw.ellipse([50, 55, 150, 155], fill=(225, 30, 30, 255))
|
||||||
|
draw.ellipse([65, 70, 85, 85], fill=(255, 255, 255, 180))
|
||||||
|
cx, cy = 100, 55
|
||||||
|
stem_pts = [
|
||||||
|
(cx, cy), (cx-15, cy-10), (cx-5, cy-2),
|
||||||
|
(cx, cy-20), (cx+5, cy-2), (cx+15, cy-10),
|
||||||
|
(cx+8, cy+5), (cx, cy+8), (cx-8, cy+5)
|
||||||
|
]
|
||||||
|
draw.polygon(stem_pts, fill=(34, 139, 34, 255))
|
||||||
|
draw.line([(cx, cy), (cx-5, cy-25)], fill=(34, 139, 34, 255), width=4)
|
||||||
|
|
||||||
|
def draw_cucumber(draw):
|
||||||
|
draw.rounded_rectangle([35, 80, 165, 120], radius=20, fill=(20, 100, 40, 255))
|
||||||
|
for x in range(50, 150, 15):
|
||||||
|
draw.line([(x, 85), (x+5, 115)], fill=(30, 130, 55, 255), width=2)
|
||||||
|
draw.ellipse([32, 95, 38, 105], fill=(139, 115, 85, 255))
|
||||||
|
draw.ellipse([162, 95, 168, 105], fill=(100, 80, 40, 255))
|
||||||
|
|
||||||
|
def draw_potato(draw):
|
||||||
|
# Kartoffel: irregular brown-yellow oval
|
||||||
|
draw.rounded_rectangle([45, 65, 155, 135], radius=35, fill=(190, 160, 95, 255))
|
||||||
|
# Add potato eyes (dark brown dots)
|
||||||
|
eyes = [(65, 80), (135, 90), (115, 120), (80, 125), (100, 85)]
|
||||||
|
for ex, ey in eyes:
|
||||||
|
draw.ellipse([ex-2, ey-1, ex+2, ey+1], fill=(110, 85, 45, 255))
|
||||||
|
draw.arc([ex-3, ey-3, ex+3, ey+3], start=0, end=180, fill=(110, 85, 45, 255), width=1)
|
||||||
|
|
||||||
|
def draw_carrot(draw):
|
||||||
|
# Karotte: tapered orange shape
|
||||||
|
draw.polygon([(40, 85), (160, 100), (40, 115)], fill=(245, 110, 20, 255))
|
||||||
|
draw.ellipse([35, 85, 45, 115], fill=(245, 110, 20, 255))
|
||||||
|
# Green leaves on one end (left)
|
||||||
|
draw.polygon([(38, 100), (15, 80), (30, 95), (10, 100), (30, 105), (15, 120)], fill=(45, 155, 55, 255))
|
||||||
|
# Texture lines
|
||||||
|
for x in range(60, 140, 20):
|
||||||
|
draw.line([(x, 92), (x, 108)], fill=(215, 85, 10, 255), width=2)
|
||||||
|
|
||||||
|
def draw_onion_yellow(draw):
|
||||||
|
# Zwiebel gelb: golden bulb shape
|
||||||
|
draw.ellipse([50, 60, 150, 150], fill=(195, 145, 75, 255))
|
||||||
|
draw.polygon([(100, 40), (80, 65), (120, 65)], fill=(195, 145, 75, 255)) # top tip
|
||||||
|
# Root hairs at bottom
|
||||||
|
draw.line([(90, 150), (85, 162)], fill=(230, 210, 170, 255), width=2)
|
||||||
|
draw.line([(100, 150), (100, 165)], fill=(230, 210, 170, 255), width=2)
|
||||||
|
draw.line([(110, 150), (115, 162)], fill=(230, 210, 170, 255), width=2)
|
||||||
|
|
||||||
|
def draw_onion_red(draw):
|
||||||
|
# Zwiebel rot: red-purple bulb shape
|
||||||
|
draw.ellipse([50, 60, 150, 150], fill=(140, 35, 95, 255))
|
||||||
|
draw.polygon([(100, 40), (80, 65), (120, 65)], fill=(140, 35, 95, 255))
|
||||||
|
# Light stripes
|
||||||
|
draw.arc([55, 60, 145, 150], start=180, end=360, fill=(185, 75, 135, 255), width=2)
|
||||||
|
# Root hairs
|
||||||
|
draw.line([(95, 150), (95, 162)], fill=(210, 190, 180, 255), width=2)
|
||||||
|
draw.line([(105, 150), (105, 162)], fill=(210, 190, 180, 255), width=2)
|
||||||
|
|
||||||
|
def draw_garlic(draw):
|
||||||
|
# Knoblauch: off-white bulb with segment lines
|
||||||
|
draw.ellipse([50, 60, 150, 150], fill=(235, 230, 220, 255))
|
||||||
|
draw.polygon([(100, 42), (85, 65), (115, 65)], fill=(235, 230, 220, 255))
|
||||||
|
# Segment lines
|
||||||
|
draw.arc([65, 60, 135, 150], start=270, end=90, fill=(195, 190, 180, 255), width=2)
|
||||||
|
draw.arc([80, 60, 120, 150], start=270, end=90, fill=(195, 190, 180, 255), width=2)
|
||||||
|
# Roots
|
||||||
|
draw.line([(100, 150), (100, 160)], fill=(160, 150, 130, 255), width=2)
|
||||||
|
|
||||||
|
def draw_broccoli(draw):
|
||||||
|
# Brokkoli: green tree-like structure
|
||||||
|
# Stalk
|
||||||
|
draw.rounded_rectangle([80, 110, 120, 170], radius=8, fill=(110, 175, 75, 255))
|
||||||
|
# Florets (overlapping circles)
|
||||||
|
draw.ellipse([60, 60, 110, 110], fill=(30, 110, 50, 255))
|
||||||
|
draw.ellipse([90, 50, 150, 100], fill=(30, 110, 50, 255))
|
||||||
|
draw.ellipse([75, 80, 135, 130], fill=(30, 110, 50, 255))
|
||||||
|
# Add tiny dots/texture
|
||||||
|
for _ in range(30):
|
||||||
|
tx = random.randint(65, 135)
|
||||||
|
ty = random.randint(55, 120)
|
||||||
|
draw.ellipse([tx, ty, tx+2, ty+2], fill=(60, 150, 80, 255))
|
||||||
|
|
||||||
|
def draw_pepper_red(draw):
|
||||||
|
# Paprika rot: blocky rounded shape
|
||||||
|
draw.rounded_rectangle([50, 60, 150, 150], radius=25, fill=(210, 30, 30, 255))
|
||||||
|
# Lobes indentations
|
||||||
|
draw.arc([45, 60, 155, 150], start=230, end=310, fill=(160, 15, 15, 255), width=3)
|
||||||
|
# Green stem
|
||||||
|
draw.line([(100, 60), (100, 35)], fill=(45, 130, 45, 255), width=5)
|
||||||
|
|
||||||
|
def draw_pepper_yellow(draw):
|
||||||
|
# Paprika gelb: blocky rounded shape
|
||||||
|
draw.rounded_rectangle([50, 60, 150, 150], radius=25, fill=(240, 190, 20, 255))
|
||||||
|
draw.arc([45, 60, 155, 150], start=230, end=310, fill=(195, 145, 10, 255), width=3)
|
||||||
|
draw.line([(100, 60), (100, 35)], fill=(45, 130, 45, 255), width=5)
|
||||||
|
|
||||||
|
def draw_pepper_green(draw):
|
||||||
|
# Paprika grün: blocky rounded shape
|
||||||
|
draw.rounded_rectangle([50, 60, 150, 150], radius=25, fill=(35, 115, 45, 255))
|
||||||
|
draw.arc([45, 60, 155, 150], start=230, end=310, fill=(20, 85, 25, 255), width=3)
|
||||||
|
draw.line([(100, 60), (100, 35)], fill=(25, 80, 25, 255), width=5)
|
||||||
|
|
||||||
|
def draw_mushroom(draw):
|
||||||
|
# Champignon: white-brown stalk and cap
|
||||||
|
# Stalk
|
||||||
|
draw.rounded_rectangle([85, 100, 115, 160], radius=6, fill=(225, 215, 205, 255))
|
||||||
|
# Cap (umbrella)
|
||||||
|
draw.ellipse([45, 60, 155, 120], fill=(210, 200, 190, 255))
|
||||||
|
draw.rectangle([45, 90, 155, 120], fill=(210, 200, 190, 255)) # bottom flat
|
||||||
|
# Gills (brown under cap)
|
||||||
|
draw.line([(45, 120), (155, 120)], fill=(120, 100, 90, 255), width=3)
|
||||||
|
|
||||||
|
def draw_zucchini(draw):
|
||||||
|
# Zucchini: long cylinder, thicker than cucumber, dark green
|
||||||
|
draw.rounded_rectangle([30, 75, 170, 125], radius=22, fill=(15, 75, 35, 255))
|
||||||
|
# Speckled light green stripes
|
||||||
|
for x in range(45, 155, 20):
|
||||||
|
draw.line([(x, 80), (x+8, 120)], fill=(35, 115, 60, 180), width=3)
|
||||||
|
# Thick stem
|
||||||
|
draw.ellipse([167, 95, 173, 105], fill=(80, 95, 50, 255))
|
||||||
|
|
||||||
|
def draw_avocado(draw):
|
||||||
|
# Halved avocado: pear outline, yellow-green center, brown seed
|
||||||
|
# Outer skin
|
||||||
|
draw.ellipse([60, 50, 140, 120], fill=(15, 45, 20, 255))
|
||||||
|
draw.ellipse([45, 85, 155, 170], fill=(15, 45, 20, 255))
|
||||||
|
# Light green inner meat
|
||||||
|
draw.ellipse([63, 53, 137, 117], fill=(185, 215, 110, 255))
|
||||||
|
draw.ellipse([49, 89, 151, 166], fill=(185, 215, 110, 255))
|
||||||
|
# Yellow center
|
||||||
|
draw.ellipse([58, 98, 142, 158], fill=(215, 235, 135, 255))
|
||||||
|
# Brown pit (seed) in center
|
||||||
|
draw.ellipse([78, 105, 122, 149], fill=(110, 65, 30, 255))
|
||||||
|
draw.ellipse([82, 109, 100, 125], fill=(255, 255, 255, 80)) # Pit reflection
|
||||||
|
|
||||||
|
def create_object_image(class_idx, angle, scale):
|
||||||
|
obj_img = Image.new("RGBA", (200, 200), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(obj_img)
|
||||||
|
|
||||||
|
# Map class index to drawing functions
|
||||||
|
draw_funcs = [
|
||||||
|
draw_apple_elstar, draw_apple_gala, draw_apple_granny_smith,
|
||||||
|
draw_banana_chiquita, draw_banana_bio, draw_pear,
|
||||||
|
draw_orange, draw_lemon, draw_lime,
|
||||||
|
draw_strawberry, draw_blueberry, draw_grape_green,
|
||||||
|
draw_grape_dark, draw_peach, draw_tomato,
|
||||||
|
draw_cucumber, draw_potato, draw_carrot,
|
||||||
|
draw_onion_yellow, draw_onion_red, draw_garlic,
|
||||||
|
draw_broccoli, draw_pepper_red, draw_pepper_yellow,
|
||||||
|
draw_pepper_green, draw_mushroom, draw_zucchini,
|
||||||
|
draw_avocado
|
||||||
|
]
|
||||||
|
|
||||||
|
if class_idx < len(draw_funcs):
|
||||||
|
draw_funcs[class_idx](draw)
|
||||||
|
|
||||||
|
rotated = obj_img.rotate(angle, resample=Image.Resampling.BILINEAR, expand=True)
|
||||||
|
|
||||||
|
if scale != 1.0:
|
||||||
|
new_w = int(rotated.width * scale)
|
||||||
|
new_h = int(rotated.height * scale)
|
||||||
|
rotated = rotated.resize((new_w, new_h), resample=Image.Resampling.BILINEAR)
|
||||||
|
|
||||||
|
bbox = rotated.getbbox()
|
||||||
|
if bbox is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
cropped = rotated.crop(bbox)
|
||||||
|
return cropped, bbox
|
||||||
|
|
||||||
|
def generate_background(width, height):
|
||||||
|
bg = Image.new("RGB", (width, height), (210, 215, 220))
|
||||||
|
draw = ImageDraw.Draw(bg)
|
||||||
|
grid_color = (185, 190, 195)
|
||||||
|
for x in range(0, width, 80):
|
||||||
|
draw.line([(x, 0), (x, height)], fill=grid_color, width=2)
|
||||||
|
for y in range(0, height, 80):
|
||||||
|
draw.line([(0, y), (width, y)], fill=grid_color, width=2)
|
||||||
|
draw.rectangle([10, 10, width-10, height-10], outline=(170, 175, 180), width=3)
|
||||||
|
|
||||||
|
arr = np.array(bg, dtype=np.float32)
|
||||||
|
x_grad = np.linspace(-15, 15, width)
|
||||||
|
y_grad = np.linspace(-15, 15, height)
|
||||||
|
xx, yy = np.meshgrid(x_grad, y_grad)
|
||||||
|
arr[:, :, 0] += xx + yy
|
||||||
|
arr[:, :, 1] += xx + yy
|
||||||
|
arr[:, :, 2] += xx + yy
|
||||||
|
noise = np.random.normal(0, 3, (height, width, 3))
|
||||||
|
arr += noise
|
||||||
|
arr = np.clip(arr, 0, 255).astype(np.uint8)
|
||||||
|
return Image.fromarray(arr)
|
||||||
|
|
||||||
|
def generate_sample(width=640, height=480):
|
||||||
|
bg = generate_background(width, height)
|
||||||
|
num_objects = random.randint(1, 3)
|
||||||
|
annotations = []
|
||||||
|
placed_boxes = []
|
||||||
|
|
||||||
|
for _ in range(num_objects):
|
||||||
|
class_idx = random.randint(0, len(CLASSES)-1)
|
||||||
|
angle = random.randint(0, 360)
|
||||||
|
scale = random.uniform(0.7, 1.2)
|
||||||
|
|
||||||
|
obj_img, _ = create_object_image(class_idx, angle, scale)
|
||||||
|
if obj_img is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ow, oh = obj_img.size
|
||||||
|
placed = False
|
||||||
|
|
||||||
|
for attempt in range(25):
|
||||||
|
x = random.randint(30, width - ow - 30)
|
||||||
|
y = random.randint(30, height - oh - 30)
|
||||||
|
|
||||||
|
overlap = False
|
||||||
|
for px1, py1, px2, py2 in placed_boxes:
|
||||||
|
ix1 = max(x, px1)
|
||||||
|
iy1 = max(y, py1)
|
||||||
|
ix2 = min(x + ow, px2)
|
||||||
|
iy2 = min(y + oh, py2)
|
||||||
|
|
||||||
|
if ix2 > ix1 and iy2 > iy1:
|
||||||
|
inter_area = (ix2 - ix1) * (iy2 - iy1)
|
||||||
|
box_area = ow * oh
|
||||||
|
p_area = (px2 - px1) * (py2 - py1)
|
||||||
|
iou = inter_area / float(box_area + p_area - inter_area)
|
||||||
|
if iou > 0.12:
|
||||||
|
overlap = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not overlap:
|
||||||
|
bg.paste(obj_img, (x, y), obj_img)
|
||||||
|
placed_boxes.append((x, y, x + ow, y + oh))
|
||||||
|
x_center = (x + ow / 2.0) / width
|
||||||
|
y_center = (y + oh / 2.0) / height
|
||||||
|
w_norm = ow / float(width)
|
||||||
|
h_norm = oh / float(height)
|
||||||
|
annotations.append(f"{class_idx} {x_center:.6f} {y_center:.6f} {w_norm:.6f} {h_norm:.6f}")
|
||||||
|
placed = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not placed and len(placed_boxes) == 0:
|
||||||
|
x = random.randint(30, width - ow - 30)
|
||||||
|
y = random.randint(30, height - oh - 30)
|
||||||
|
bg.paste(obj_img, (x, y), obj_img)
|
||||||
|
placed_boxes.append((x, y, x + ow, y + oh))
|
||||||
|
x_center = (x + ow / 2.0) / width
|
||||||
|
y_center = (y + oh / 2.0) / height
|
||||||
|
w_norm = ow / float(width)
|
||||||
|
h_norm = oh / float(height)
|
||||||
|
annotations.append(f"{class_idx} {x_center:.6f} {y_center:.6f} {w_norm:.6f} {h_norm:.6f}")
|
||||||
|
|
||||||
|
bg = bg.filter(ImageFilter.GaussianBlur(0.5))
|
||||||
|
return bg, annotations
|
||||||
|
|
||||||
|
def build_dataset(base_dir="dataset", train_count=200, val_count=50):
|
||||||
|
os.makedirs(os.path.join(base_dir, "images", "train"), exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(base_dir, "images", "val"), exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(base_dir, "labels", "train"), exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(base_dir, "labels", "val"), exist_ok=True)
|
||||||
|
|
||||||
|
with open(os.path.join(base_dir, "classes.txt"), "w") as f:
|
||||||
|
f.write("\n".join(CLASSES))
|
||||||
|
|
||||||
|
print(f"Generating {train_count} training samples for {len(CLASSES)} classes...")
|
||||||
|
for idx in range(train_count):
|
||||||
|
img, ann = generate_sample()
|
||||||
|
img.save(os.path.join(base_dir, "images", "train", f"train_{idx:04d}.jpg"), quality=90)
|
||||||
|
with open(os.path.join(base_dir, "labels", "train", f"train_{idx:04d}.txt"), "w") as f:
|
||||||
|
f.write("\n".join(ann))
|
||||||
|
|
||||||
|
print(f"Generating {val_count} validation samples...")
|
||||||
|
for idx in range(val_count):
|
||||||
|
img, ann = generate_sample()
|
||||||
|
img.save(os.path.join(base_dir, "images", "val", f"val_{idx:04d}.jpg"), quality=90)
|
||||||
|
with open(os.path.join(base_dir, "labels", "val", f"val_{idx:04d}.txt"), "w") as f:
|
||||||
|
f.write("\n".join(ann))
|
||||||
|
|
||||||
|
print("Dataset generation complete!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
build_dataset(train_count=200, val_count=50)
|
||||||
103
dataset.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import os
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from torch.utils.data import Dataset
|
||||||
|
|
||||||
|
class FruitVegetableDataset(Dataset):
|
||||||
|
def __init__(self, images_dir, labels_dir, transform=None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
images_dir (str): Path to images directory.
|
||||||
|
labels_dir (str): Path to labels directory.
|
||||||
|
transform (albumentations.Compose, optional): Albumentations transform pipeline.
|
||||||
|
"""
|
||||||
|
self.images_dir = images_dir
|
||||||
|
self.labels_dir = labels_dir
|
||||||
|
self.transform = transform
|
||||||
|
|
||||||
|
# List all image files
|
||||||
|
self.image_files = sorted([
|
||||||
|
f for f in os.listdir(images_dir)
|
||||||
|
if f.lower().endswith(('.jpg', '.jpeg', '.png'))
|
||||||
|
])
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.image_files)
|
||||||
|
|
||||||
|
def __getitem__(self, idx):
|
||||||
|
img_name = self.image_files[idx]
|
||||||
|
img_path = os.path.join(self.images_dir, img_name)
|
||||||
|
|
||||||
|
# Load image via OpenCV (BGR) and convert to RGB
|
||||||
|
image = cv2.imread(img_path)
|
||||||
|
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||||
|
height, width, _ = image.shape
|
||||||
|
|
||||||
|
# Load corresponding label file
|
||||||
|
label_name = os.path.splitext(img_name)[0] + ".txt"
|
||||||
|
label_path = os.path.join(self.labels_dir, label_name)
|
||||||
|
|
||||||
|
boxes = []
|
||||||
|
class_ids = []
|
||||||
|
|
||||||
|
if os.path.exists(label_path):
|
||||||
|
with open(label_path, "r") as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.strip().split()
|
||||||
|
if len(parts) == 5:
|
||||||
|
class_id = int(parts[0])
|
||||||
|
# YOLO format: x_center, y_center, width, height (normalized)
|
||||||
|
x_c, y_c, w, h = map(float, parts[1:])
|
||||||
|
|
||||||
|
# Convert to Pascal VOC absolute coordinates [x_min, y_min, x_max, y_max]
|
||||||
|
xmin = (x_c - w / 2.0) * width
|
||||||
|
ymin = (y_c - h / 2.0) * height
|
||||||
|
xmax = (x_c + w / 2.0) * width
|
||||||
|
ymax = (y_c + h / 2.0) * height
|
||||||
|
|
||||||
|
# Ensure coordinates are within image boundaries
|
||||||
|
xmin = max(0.0, min(xmin, float(width - 1)))
|
||||||
|
ymin = max(0.0, min(ymin, float(height - 1)))
|
||||||
|
xmax = max(xmin + 1.0, min(xmax, float(width)))
|
||||||
|
ymax = max(ymin + 1.0, min(ymax, float(height)))
|
||||||
|
|
||||||
|
boxes.append([xmin, ymin, xmax, ymax])
|
||||||
|
class_ids.append(class_id)
|
||||||
|
|
||||||
|
boxes = np.array(boxes, dtype=np.float32).reshape(-1, 4)
|
||||||
|
class_ids = np.array(class_ids, dtype=np.int64)
|
||||||
|
|
||||||
|
# Apply Albumentations transformations
|
||||||
|
if self.transform:
|
||||||
|
augmented = self.transform(
|
||||||
|
image=image,
|
||||||
|
bboxes=boxes,
|
||||||
|
category_ids=class_ids
|
||||||
|
)
|
||||||
|
image = augmented["image"]
|
||||||
|
boxes = np.array(augmented["bboxes"], dtype=np.float32).reshape(-1, 4)
|
||||||
|
class_ids = np.array(augmented["category_ids"], dtype=np.int64)
|
||||||
|
|
||||||
|
# Convert image to CHW tensor and scale to [0, 1]
|
||||||
|
image_tensor = torch.from_numpy(image).permute(2, 0, 1).float() / 255.0
|
||||||
|
|
||||||
|
# Convert targets to PyTorch Tensors
|
||||||
|
# NOTE: torchvision models reserve class 0 for background.
|
||||||
|
# We must shift our class IDs by +1 (0 -> 1, 1 -> 2, etc.)
|
||||||
|
torch_boxes = torch.as_tensor(boxes, dtype=torch.float32)
|
||||||
|
torch_labels = torch.as_tensor(class_ids + 1, dtype=torch.int64)
|
||||||
|
|
||||||
|
target = {
|
||||||
|
"boxes": torch_boxes,
|
||||||
|
"labels": torch_labels,
|
||||||
|
"image_id": torch.tensor([idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
return image_tensor, target
|
||||||
|
|
||||||
|
def collate_fn(batch):
|
||||||
|
"""
|
||||||
|
Collate function for DataLoader. Returns a tuple of (images, targets).
|
||||||
|
"""
|
||||||
|
return tuple(zip(*batch))
|
||||||
28
dataset/classes.txt
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
apfel_elstar
|
||||||
|
apfel_gala
|
||||||
|
apfel_granny_smith
|
||||||
|
banane_chiquita
|
||||||
|
banane_bio
|
||||||
|
birne
|
||||||
|
orange
|
||||||
|
zitrone
|
||||||
|
limette
|
||||||
|
erdbeere
|
||||||
|
blaubeere
|
||||||
|
weintraube_hell
|
||||||
|
weintraube_dunkel
|
||||||
|
pfirsich
|
||||||
|
tomate
|
||||||
|
gurke
|
||||||
|
kartoffel
|
||||||
|
karotte
|
||||||
|
zwiebel_gelb
|
||||||
|
zwiebel_rot
|
||||||
|
knoblauch
|
||||||
|
brokkoli
|
||||||
|
paprika_rot
|
||||||
|
paprika_gelb
|
||||||
|
paprika_gruen
|
||||||
|
champignon
|
||||||
|
zucchini
|
||||||
|
avocado
|
||||||
BIN
dataset/images/train/train_0000.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0001.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0002.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0003.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
dataset/images/train/train_0004.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0005.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0006.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0007.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0008.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0009.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0010.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0011.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0012.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0013.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0014.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0015.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0016.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0017.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0018.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0019.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0020.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0021.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0022.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0023.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0024.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0025.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0026.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0027.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0028.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0029.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0030.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
dataset/images/train/train_0031.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0032.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0033.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0034.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0035.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0036.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
dataset/images/train/train_0037.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0038.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0039.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0040.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0041.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
dataset/images/train/train_0042.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0043.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0044.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0045.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0046.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0047.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0048.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0049.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0050.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
dataset/images/train/train_0051.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0052.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0053.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0054.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0055.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
dataset/images/train/train_0056.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0057.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0058.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
dataset/images/train/train_0059.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0060.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0061.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0062.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0063.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0064.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
dataset/images/train/train_0065.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0066.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
dataset/images/train/train_0067.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0068.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0069.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0070.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0071.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0072.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
dataset/images/train/train_0073.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0074.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0075.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0076.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0077.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0078.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dataset/images/train/train_0079.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0080.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
dataset/images/train/train_0081.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
dataset/images/train/train_0082.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0083.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0084.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0085.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0086.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
dataset/images/train/train_0087.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |