diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cf0f0bab..5e936385a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to Bambuddy will be documented in this file. +## [0.1.7] - 2026-01-26 + +### New Features +- **Build Plate Empty Detection** - Automatically detect if objects are on the build plate before printing: + - Per-printer toggle to enable/disable plate detection + - Multi-reference calibration: Store up to 5 reference images of empty plates (different plate types) + - Automatic print pause when objects detected on plate at print start + - Push notification and WebSocket alert when print is paused due to plate detection + - ROI (Region of Interest) calibration UI with sliders to focus detection on build plate area + - Reference management: View thumbnails, add labels, delete references + - Works with both built-in and external cameras + - Uses buffered camera frames when stream is active (no blocking) + - Split button UI: Main button opens calibration modal, chevron toggles detection on/off + - Green visual indicator when plate detection is enabled + - Included in backup/restore + ## [0.1.6] - 2026-01-24 ### New Features diff --git a/README.md b/README.md index 605dbe7a4..d7b8d7ff1 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ - Real-time printer status via WebSocket - Live camera streaming (MJPEG) & snapshots with multi-viewer support - External network camera support (MJPEG, RTSP, HTTP snapshot) with layer-based timelapse +- **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment) - Fan status monitoring (part cooling, auxiliary, chamber) - Printer control (stop, pause, resume, chamber light) - Resizable printer cards (S/M/L/XL) diff --git a/backend/app/api/routes/camera.py b/backend/app/api/routes/camera.py index 67aa413f4..e70f15c16 100644 --- a/backend/app/api/routes/camera.py +++ b/backend/app/api/routes/camera.py @@ -675,3 +675,360 @@ async def test_external_camera( from backend.app.services.external_camera import test_connection return await test_connection(url, camera_type) + + +@router.get("/{printer_id}/camera/check-plate") +async def check_plate_empty( + printer_id: int, + plate_type: str | None = None, + use_external: bool = False, + include_debug_image: bool = False, + db: AsyncSession = Depends(get_db), +): + """Check if the build plate is empty using camera vision. + + Uses calibration-based difference detection - compares current frame + to a reference image of the empty plate. + + IMPORTANT: Chamber light must be ON for reliable detection. + + Args: + printer_id: Printer ID + plate_type: Type of build plate (e.g., "High Temp Plate") for calibration lookup + use_external: If True, prefer external camera over built-in + include_debug_image: If True, return URL to annotated debug image + + Returns: + Dict with detection results: + - is_empty: bool - Whether plate appears empty + - confidence: float - Confidence level (0.0 to 1.0) + - difference_percent: float - How different from calibration reference + - message: str - Human-readable result message + - needs_calibration: bool - True if calibration is required + - light_warning: bool - True if chamber light is off + """ + from backend.app.services.plate_detection import ( + check_plate_empty as do_check, + is_plate_detection_available, + ) + from backend.app.services.printer_manager import printer_manager + + if not is_plate_detection_available(): + raise HTTPException( + status_code=503, + detail="Plate detection not available. Install opencv-python-headless to enable.", + ) + + printer = await get_printer_or_404(printer_id, db) + + # Check chamber light status + light_warning = False + state = printer_manager.get_status(printer_id) + if state and not state.chamber_light: + light_warning = True + + from backend.app.services.plate_detection import PlateDetector + + # Build ROI tuple from printer settings if available + roi = None + if all( + [ + printer.plate_detection_roi_x is not None, + printer.plate_detection_roi_y is not None, + printer.plate_detection_roi_w is not None, + printer.plate_detection_roi_h is not None, + ] + ): + roi = ( + printer.plate_detection_roi_x, + printer.plate_detection_roi_y, + printer.plate_detection_roi_w, + printer.plate_detection_roi_h, + ) + + result = await do_check( + printer_id=printer.id, + ip_address=printer.ip_address, + access_code=printer.access_code, + model=printer.model, + plate_type=plate_type, + include_debug_image=include_debug_image, + external_camera_url=printer.external_camera_url if printer.external_camera_enabled else None, + external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None, + use_external=use_external, + roi=roi, + ) + + # Get reference count for the response + detector = PlateDetector() + ref_count = detector.get_calibration_count(printer.id) + + response = result.to_dict() + response["light_warning"] = light_warning + response["reference_count"] = ref_count + response["max_references"] = detector.MAX_REFERENCES + # Include current ROI in response + if roi: + response["roi"] = {"x": roi[0], "y": roi[1], "w": roi[2], "h": roi[3]} + else: + # Return default ROI + response["roi"] = {"x": 0.15, "y": 0.35, "w": 0.70, "h": 0.55} + + # If debug image requested and available, encode as base64 data URL + if include_debug_image and result.debug_image: + import base64 + + b64_image = base64.b64encode(result.debug_image).decode("utf-8") + response["debug_image_url"] = f"data:image/jpeg;base64,{b64_image}" + + return response + + +@router.post("/{printer_id}/camera/plate-detection/calibrate") +async def calibrate_plate_detection( + printer_id: int, + label: str | None = None, + use_external: bool = False, + db: AsyncSession = Depends(get_db), +): + """Calibrate plate detection by capturing a reference image of the empty plate. + + The plate MUST be empty when calling this endpoint. The captured image + will be used as the reference for future detection comparisons. + + Supports up to 5 reference images per printer. When adding a 6th, the oldest + is automatically removed. + + IMPORTANT: Chamber light should be ON for calibration. + + Args: + printer_id: Printer ID + label: Optional label for this reference (e.g., "High Temp Plate", "Wham Bam") + use_external: If True, prefer external camera over built-in + + Returns: + Dict with: + - success: bool - Whether calibration succeeded + - message: str - Status message + - index: int - The reference slot used (0-4) + """ + from backend.app.services.plate_detection import ( + calibrate_plate, + is_plate_detection_available, + ) + from backend.app.services.printer_manager import printer_manager + + if not is_plate_detection_available(): + raise HTTPException( + status_code=503, + detail="Plate detection not available. Install opencv-python-headless to enable.", + ) + + printer = await get_printer_or_404(printer_id, db) + + # Check chamber light - warn but don't block + state = printer_manager.get_status(printer_id) + light_warning = state and not state.chamber_light + + success, message, index = await calibrate_plate( + printer_id=printer.id, + ip_address=printer.ip_address, + access_code=printer.access_code, + model=printer.model, + label=label, + external_camera_url=printer.external_camera_url if printer.external_camera_enabled else None, + external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None, + use_external=use_external, + ) + + if light_warning and success: + message += " (Warning: Chamber light was off)" + + return {"success": success, "message": message, "index": index} + + +@router.delete("/{printer_id}/camera/plate-detection/calibrate") +async def delete_plate_calibration( + printer_id: int, + plate_type: str | None = None, + db: AsyncSession = Depends(get_db), +): + """Delete the plate detection calibration for a printer and plate type. + + Args: + printer_id: Printer ID + plate_type: Type of build plate (if None, deletes legacy non-plate-specific calibration) + + Returns: + Dict with: + - success: bool - Whether deletion succeeded + - message: str - Status message + """ + from backend.app.services.plate_detection import ( + delete_calibration, + is_plate_detection_available, + ) + + if not is_plate_detection_available(): + raise HTTPException( + status_code=503, + detail="Plate detection not available. Install opencv-python-headless to enable.", + ) + + # Verify printer exists + await get_printer_or_404(printer_id, db) + + deleted = delete_calibration(printer_id, plate_type) + plate_msg = f" for '{plate_type}'" if plate_type else "" + + return { + "success": deleted, + "message": f"Calibration deleted{plate_msg}" if deleted else f"No calibration found{plate_msg}", + } + + +@router.get("/{printer_id}/camera/plate-detection/status") +async def get_plate_detection_status( + printer_id: int, + plate_type: str | None = None, + db: AsyncSession = Depends(get_db), +): + """Check plate detection status for a printer and plate type. + + Returns: + Dict with: + - available: bool - Whether OpenCV is installed + - calibrated: bool - Whether printer has calibration for this plate type + - plate_type: str - The plate type queried + - chamber_light: bool - Whether chamber light is on + - message: str - Status message + """ + from backend.app.services.plate_detection import ( + get_calibration_status, + is_plate_detection_available, + ) + from backend.app.services.printer_manager import printer_manager + + if not is_plate_detection_available(): + return { + "available": False, + "calibrated": False, + "plate_type": plate_type, + "chamber_light": False, + "message": "OpenCV not installed", + } + + # Verify printer exists + await get_printer_or_404(printer_id, db) + + # Get chamber light status + state = printer_manager.get_status(printer_id) + chamber_light = state.chamber_light if state else False + + status = get_calibration_status(printer_id, plate_type) + status["chamber_light"] = chamber_light + + return status + + +@router.get("/{printer_id}/camera/plate-detection/references") +async def get_plate_references( + printer_id: int, + db: AsyncSession = Depends(get_db), +): + """Get all calibration references for a printer with metadata. + + Returns list of references with index, label, timestamp, and thumbnail URL. + """ + from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available + + if not is_plate_detection_available(): + raise HTTPException(503, "Plate detection not available") + + await get_printer_or_404(printer_id, db) + + detector = PlateDetector() + references = detector.get_references(printer_id) + + # Add thumbnail URLs + for ref in references: + ref["thumbnail_url"] = ( + f"/api/v1/printers/{printer_id}/camera/plate-detection/references/{ref['index']}/thumbnail" + ) + + return { + "references": references, + "max_references": detector.MAX_REFERENCES, + } + + +@router.get("/{printer_id}/camera/plate-detection/references/{index}/thumbnail") +async def get_reference_thumbnail( + printer_id: int, + index: int, + db: AsyncSession = Depends(get_db), +): + """Get thumbnail image for a calibration reference.""" + from fastapi.responses import Response + + from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available + + if not is_plate_detection_available(): + raise HTTPException(503, "Plate detection not available") + + await get_printer_or_404(printer_id, db) + + detector = PlateDetector() + thumbnail = detector.get_reference_thumbnail(printer_id, index) + + if thumbnail is None: + raise HTTPException(404, "Reference not found") + + return Response(content=thumbnail, media_type="image/jpeg") + + +@router.put("/{printer_id}/camera/plate-detection/references/{index}") +async def update_reference_label( + printer_id: int, + index: int, + label: str, + db: AsyncSession = Depends(get_db), +): + """Update the label for a calibration reference.""" + from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available + + if not is_plate_detection_available(): + raise HTTPException(503, "Plate detection not available") + + await get_printer_or_404(printer_id, db) + + detector = PlateDetector() + success = detector.update_reference_label(printer_id, index, label) + + if not success: + raise HTTPException(404, "Reference not found") + + return {"success": True, "index": index, "label": label} + + +@router.delete("/{printer_id}/camera/plate-detection/references/{index}") +async def delete_reference( + printer_id: int, + index: int, + db: AsyncSession = Depends(get_db), +): + """Delete a specific calibration reference.""" + from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available + + if not is_plate_detection_available(): + raise HTTPException(503, "Plate detection not available") + + await get_printer_or_404(printer_id, db) + + detector = PlateDetector() + success = detector.delete_reference(printer_id, index) + + if not success: + raise HTTPException(404, "Reference not found") + + return {"success": True, "message": "Reference deleted"} diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 0237580b4..c455b3937 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -92,6 +92,22 @@ async def update_printer( raise HTTPException(404, "Printer not found") update_data = printer_data.model_dump(exclude_unset=True) + + # Handle nested ROI object - flatten to individual columns + if "plate_detection_roi" in update_data: + roi = update_data.pop("plate_detection_roi") + if roi: + update_data["plate_detection_roi_x"] = roi.get("x") + update_data["plate_detection_roi_y"] = roi.get("y") + update_data["plate_detection_roi_w"] = roi.get("w") + update_data["plate_detection_roi_h"] = roi.get("h") + else: + # Clear ROI if set to null + update_data["plate_detection_roi_x"] = None + update_data["plate_detection_roi_y"] = None + update_data["plate_detection_roi_w"] = None + update_data["plate_detection_roi_h"] = None + for field, value in update_data.items(): setattr(printer, field, value) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 988527e05..2e4c66907 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -416,6 +416,11 @@ async def export_backup( "external_camera_url": printer.external_camera_url, "external_camera_type": printer.external_camera_type, "external_camera_enabled": printer.external_camera_enabled, + "plate_detection_enabled": printer.plate_detection_enabled, + "plate_detection_roi_x": printer.plate_detection_roi_x, + "plate_detection_roi_y": printer.plate_detection_roi_y, + "plate_detection_roi_w": printer.plate_detection_roi_w, + "plate_detection_roi_h": printer.plate_detection_roi_h, } if include_access_codes: printer_data["access_code"] = printer.access_code @@ -1000,6 +1005,11 @@ async def import_backup( external_camera_url=printer_data.get("external_camera_url"), external_camera_type=printer_data.get("external_camera_type"), external_camera_enabled=printer_data.get("external_camera_enabled", False), + plate_detection_enabled=printer_data.get("plate_detection_enabled", False), + plate_detection_roi_x=printer_data.get("plate_detection_roi_x"), + plate_detection_roi_y=printer_data.get("plate_detection_roi_y"), + plate_detection_roi_w=printer_data.get("plate_detection_roi_w"), + plate_detection_roi_h=printer_data.get("plate_detection_roi_h"), ) db.add(printer) restored["printers"] += 1 diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 27b149a2f..6833b47f0 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -729,6 +729,30 @@ async def run_migrations(conn): except Exception: pass + # Migration: Add plate_detection_enabled column to printers + try: + await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_enabled BOOLEAN DEFAULT 0")) + except Exception: + pass + + # Migration: Add plate detection ROI columns to printers + try: + await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_x REAL")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_y REAL")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_w REAL")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_h REAL")) + except Exception: + pass + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" diff --git a/backend/app/main.py b/backend/app/main.py index 9008d0851..ab4a5d08f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -505,6 +505,80 @@ async def on_print_start(printer_id: int, data: dict): result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() + # Plate detection check - pause if objects detected on build plate + if printer and printer.plate_detection_enabled: + try: + from backend.app.services.plate_detection import check_plate_empty + + # Build ROI tuple from printer settings if available + roi = None + if all( + [ + printer.plate_detection_roi_x is not None, + printer.plate_detection_roi_y is not None, + printer.plate_detection_roi_w is not None, + printer.plate_detection_roi_h is not None, + ] + ): + roi = ( + printer.plate_detection_roi_x, + printer.plate_detection_roi_y, + printer.plate_detection_roi_w, + printer.plate_detection_roi_h, + ) + + logger.info(f"[PLATE CHECK] Running plate detection for printer {printer_id}") + plate_result = await check_plate_empty( + printer_id=printer_id, + ip_address=printer.ip_address, + access_code=printer.access_code, + model=printer.model, + include_debug_image=False, + external_camera_url=printer.external_camera_url, + external_camera_type=printer.external_camera_type, + use_external=printer.external_camera_enabled, + roi=roi, + ) + + if not plate_result.needs_calibration and not plate_result.is_empty: + # Objects detected - pause the print! + logger.warning( + f"[PLATE CHECK] Objects detected on plate for printer {printer_id}! " + f"Confidence: {plate_result.confidence:.0%}, Diff: {plate_result.difference_percent:.1f}%" + ) + client = printer_manager.get_client(printer_id) + if client: + client.pause_print() + logger.info(f"[PLATE CHECK] Print paused for printer {printer_id}") + + # Send notification about plate not empty + await ws_manager.broadcast( + { + "type": "plate_not_empty", + "printer_id": printer_id, + "printer_name": printer.name, + "message": f"Objects detected on build plate! Print paused. (Diff: {plate_result.difference_percent:.1f}%)", + } + ) + + # Also send push notification + try: + await notification_service.send_notification( + printer_id=printer_id, + printer_name=printer.name, + event_type="plate_not_empty", + title=f"⚠️ {printer.name}: Plate Not Empty!", + body="Objects detected on build plate. Print has been paused. Please clear the plate and resume.", + db=db, + ) + except Exception as notif_err: + logger.warning(f"[PLATE CHECK] Failed to send notification: {notif_err}") + else: + logger.info(f"[PLATE CHECK] Plate is empty for printer {printer_id}, proceeding with print") + except Exception as plate_err: + # Don't block print on plate detection errors + logger.warning(f"[PLATE CHECK] Plate detection failed for printer {printer_id}: {plate_err}") + if not printer or not printer.auto_archive: # Send notification without archive data (auto-archive disabled) logger.info( diff --git a/backend/app/models/printer.py b/backend/app/models/printer.py index f1105d3c7..0d56b206e 100644 --- a/backend/app/models/printer.py +++ b/backend/app/models/printer.py @@ -28,6 +28,13 @@ class Printer(Base): external_camera_url: Mapped[str | None] = mapped_column(String(500), nullable=True) external_camera_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # mjpeg, rtsp, snapshot external_camera_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + # Plate detection - check if build plate is empty before starting print + plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + # ROI for plate detection (percentages: 0.0-1.0) + plate_detection_roi_x: Mapped[float | None] = mapped_column(Float, nullable=True) # X start % + plate_detection_roi_y: Mapped[float | None] = mapped_column(Float, nullable=True) # Y start % + plate_detection_roi_w: Mapped[float | None] = mapped_column(Float, nullable=True) # Width % + plate_detection_roi_h: Mapped[float | None] = mapped_column(Float, nullable=True) # Height % created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index 5daf6a508..69974319f 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -20,6 +20,15 @@ class PrinterCreate(PrinterBase): pass +class PlateDetectionROI(BaseModel): + """Region of interest for plate detection (percentages 0.0-1.0).""" + + x: float = Field(..., ge=0.0, le=1.0) # X start % + y: float = Field(..., ge=0.0, le=1.0) # Y start % + w: float = Field(..., ge=0.0, le=1.0) # Width % + h: float = Field(..., ge=0.0, le=1.0) # Height % + + class PrinterUpdate(BaseModel): name: str | None = None ip_address: str | None = None @@ -32,6 +41,8 @@ class PrinterUpdate(BaseModel): external_camera_url: str | None = None external_camera_type: str | None = None external_camera_enabled: bool | None = None + plate_detection_enabled: bool | None = None + plate_detection_roi: PlateDetectionROI | None = None class PrinterResponse(PrinterBase): @@ -42,12 +53,53 @@ class PrinterResponse(PrinterBase): external_camera_url: str | None = None external_camera_type: str | None = None external_camera_enabled: bool = False + plate_detection_enabled: bool = False + plate_detection_roi: PlateDetectionROI | None = None created_at: datetime updated_at: datetime class Config: from_attributes = True + @classmethod + def from_orm_with_roi(cls, printer) -> "PrinterResponse": + """Create response from ORM model, converting ROI fields to nested object.""" + data = { + "id": printer.id, + "name": printer.name, + "serial_number": printer.serial_number, + "ip_address": printer.ip_address, + "access_code": printer.access_code, + "model": printer.model, + "location": printer.location, + "auto_archive": printer.auto_archive, + "external_camera_url": printer.external_camera_url, + "external_camera_type": printer.external_camera_type, + "external_camera_enabled": printer.external_camera_enabled, + "is_active": printer.is_active, + "nozzle_count": printer.nozzle_count, + "print_hours_offset": printer.print_hours_offset, + "plate_detection_enabled": printer.plate_detection_enabled, + "created_at": printer.created_at, + "updated_at": printer.updated_at, + } + # Build ROI object if any ROI field is set + if any( + [ + printer.plate_detection_roi_x is not None, + printer.plate_detection_roi_y is not None, + printer.plate_detection_roi_w is not None, + printer.plate_detection_roi_h is not None, + ] + ): + data["plate_detection_roi"] = PlateDetectionROI( + x=printer.plate_detection_roi_x or 0.15, + y=printer.plate_detection_roi_y or 0.35, + w=printer.plate_detection_roi_w or 0.70, + h=printer.plate_detection_roi_h or 0.55, + ) + return cls(**data) + class HMSErrorResponse(BaseModel): code: str diff --git a/backend/app/services/plate_detection.py b/backend/app/services/plate_detection.py new file mode 100644 index 000000000..d7b2d3a25 --- /dev/null +++ b/backend/app/services/plate_detection.py @@ -0,0 +1,774 @@ +"""Build plate empty detection using OpenCV. + +Analyzes camera frames to detect if there are objects on the build plate. +Uses calibration-based difference detection - compares current frame to +a reference image of the empty plate. +""" + +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Optional OpenCV import - feature disabled if not available +try: + import cv2 + import numpy as np + + OPENCV_AVAILABLE = True +except ImportError: + OPENCV_AVAILABLE = False + logger.info("OpenCV not available - plate detection feature disabled") + +# Directory to store calibration reference images +CALIBRATION_DIR = Path(__file__).parent.parent.parent.parent / "data" / "plate_calibration" + + +class PlateDetectionResult: + """Result of plate detection analysis.""" + + def __init__( + self, + is_empty: bool, + confidence: float, + difference_percent: float, + message: str, + debug_image: bytes | None = None, + needs_calibration: bool = False, + ): + self.is_empty = is_empty + self.confidence = confidence # 0.0 to 1.0 + self.difference_percent = difference_percent # How different from reference + self.message = message + self.debug_image = debug_image # Optional annotated image for debugging + self.needs_calibration = needs_calibration # True if no reference image exists + + def to_dict(self) -> dict: + return { + "is_empty": bool(self.is_empty), + "confidence": float(round(self.confidence, 2)), + "difference_percent": float(round(self.difference_percent, 2)), + "message": self.message, + "has_debug_image": self.debug_image is not None, + "needs_calibration": bool(self.needs_calibration), + } + + +class PlateDetector: + """Detects if the build plate is empty using calibration-based difference detection.""" + + # Default region of interest (ROI) as percentage of image dimensions + # These define where the build plate typically appears in the camera view + # Format: (x_start%, y_start%, width%, height%) + DEFAULT_ROI = (0.15, 0.35, 0.70, 0.55) # Center-lower portion of frame + + # Detection thresholds for difference detection + # Using mean pixel difference (0-100% scale) + # Small objects may only cause 1-2% mean difference + DEFAULT_DIFFERENCE_THRESHOLD = 1.0 + DEFAULT_BLUR_SIZE = 21 # Gaussian blur kernel size (must be odd) - unused with edge detection + + def __init__( + self, + roi: tuple[float, float, float, float] | None = None, + difference_threshold: float = DEFAULT_DIFFERENCE_THRESHOLD, + blur_size: int = DEFAULT_BLUR_SIZE, + ): + """Initialize the plate detector. + + Args: + roi: Region of interest as (x%, y%, w%, h%) - percentages of image size + difference_threshold: Percentage of pixels that must differ to trigger "not empty" + blur_size: Gaussian blur kernel size for noise reduction + """ + if not OPENCV_AVAILABLE: + raise RuntimeError("OpenCV is not installed. Install with: pip install opencv-python-headless") + + self.roi = roi or self.DEFAULT_ROI + self.difference_threshold = difference_threshold + self.blur_size = blur_size if blur_size % 2 == 1 else blur_size + 1 # Must be odd + + # Maximum number of reference images to store per printer + MAX_REFERENCES = 5 + + def _get_metadata_path(self, printer_id: int) -> Path: + """Get the path to the metadata JSON file for a printer.""" + CALIBRATION_DIR.mkdir(parents=True, exist_ok=True) + return CALIBRATION_DIR / f"printer_{printer_id}_metadata.json" + + def _load_metadata(self, printer_id: int) -> dict: + """Load metadata for a printer's references.""" + import json + + meta_path = self._get_metadata_path(printer_id) + if meta_path.exists(): + try: + with open(meta_path) as f: + return json.load(f) + except Exception: + pass + return {"references": {}} + + def _save_metadata(self, printer_id: int, metadata: dict) -> None: + """Save metadata for a printer's references.""" + import json + + meta_path = self._get_metadata_path(printer_id) + with open(meta_path, "w") as f: + json.dump(metadata, f, indent=2) + + def _get_reference_paths(self, printer_id: int) -> list[Path]: + """Get all existing reference image paths for a printer.""" + CALIBRATION_DIR.mkdir(parents=True, exist_ok=True) + paths = [] + for i in range(self.MAX_REFERENCES): + path = CALIBRATION_DIR / f"printer_{printer_id}_ref_{i}.jpg" + if path.exists(): + paths.append(path) + return paths + + def _get_next_reference_slot(self, printer_id: int) -> Path: + """Get the path for the next reference image slot (cycles through slots).""" + CALIBRATION_DIR.mkdir(parents=True, exist_ok=True) + # Find first empty slot, or use oldest (slot 0) and shift others + for i in range(self.MAX_REFERENCES): + path = CALIBRATION_DIR / f"printer_{printer_id}_ref_{i}.jpg" + if not path.exists(): + return path + # All slots full - return slot 0 (will be overwritten, but we rotate first) + return CALIBRATION_DIR / f"printer_{printer_id}_ref_0.jpg" + + def _rotate_references(self, printer_id: int) -> None: + """Rotate references: delete oldest (0), shift others down.""" + # Delete slot 0 + slot0 = CALIBRATION_DIR / f"printer_{printer_id}_ref_0.jpg" + if slot0.exists(): + slot0.unlink() + # Shift others down + for i in range(1, self.MAX_REFERENCES): + old_path = CALIBRATION_DIR / f"printer_{printer_id}_ref_{i}.jpg" + new_path = CALIBRATION_DIR / f"printer_{printer_id}_ref_{i - 1}.jpg" + if old_path.exists(): + old_path.rename(new_path) + + # Also rotate metadata + metadata = self._load_metadata(printer_id) + refs = metadata.get("references", {}) + new_refs = {} + for i in range(1, self.MAX_REFERENCES): + if str(i) in refs: + new_refs[str(i - 1)] = refs[str(i)] + metadata["references"] = new_refs + self._save_metadata(printer_id, metadata) + + def get_references(self, printer_id: int) -> list[dict]: + """Get all references with metadata for a printer. + + Returns list of dicts with: index, label, timestamp, has_image + """ + + metadata = self._load_metadata(printer_id) + refs = metadata.get("references", {}) + result = [] + + for i in range(self.MAX_REFERENCES): + path = CALIBRATION_DIR / f"printer_{printer_id}_ref_{i}.jpg" + if path.exists(): + ref_meta = refs.get(str(i), {}) + result.append( + { + "index": i, + "label": ref_meta.get("label", ""), + "timestamp": ref_meta.get("timestamp", ""), + "has_image": True, + } + ) + + return result + + def update_reference_label(self, printer_id: int, index: int, label: str) -> bool: + """Update the label for a reference.""" + if index < 0 or index >= self.MAX_REFERENCES: + return False + + path = CALIBRATION_DIR / f"printer_{printer_id}_ref_{index}.jpg" + if not path.exists(): + return False + + metadata = self._load_metadata(printer_id) + if "references" not in metadata: + metadata["references"] = {} + if str(index) not in metadata["references"]: + metadata["references"][str(index)] = {} + + metadata["references"][str(index)]["label"] = label + self._save_metadata(printer_id, metadata) + return True + + def delete_reference(self, printer_id: int, index: int) -> bool: + """Delete a specific reference by index.""" + if index < 0 or index >= self.MAX_REFERENCES: + return False + + path = CALIBRATION_DIR / f"printer_{printer_id}_ref_{index}.jpg" + if not path.exists(): + return False + + # Delete image + path.unlink() + + # Remove from metadata + metadata = self._load_metadata(printer_id) + refs = metadata.get("references", {}) + if str(index) in refs: + del refs[str(index)] + metadata["references"] = refs + self._save_metadata(printer_id, metadata) + + # Shift remaining references down to fill the gap + for i in range(index + 1, self.MAX_REFERENCES): + old_img = CALIBRATION_DIR / f"printer_{printer_id}_ref_{i}.jpg" + new_img = CALIBRATION_DIR / f"printer_{printer_id}_ref_{i - 1}.jpg" + if old_img.exists(): + old_img.rename(new_img) + # Also shift metadata + if str(i) in refs: + refs[str(i - 1)] = refs[str(i)] + del refs[str(i)] + + metadata["references"] = refs + self._save_metadata(printer_id, metadata) + return True + + def get_reference_thumbnail(self, printer_id: int, index: int, max_size: int = 150) -> bytes | None: + """Get a thumbnail of a reference image. + + Returns JPEG bytes or None if not found. + """ + path = CALIBRATION_DIR / f"printer_{printer_id}_ref_{index}.jpg" + if not path.exists(): + return None + + try: + img = cv2.imread(str(path)) + if img is None: + return None + + # Calculate thumbnail size maintaining aspect ratio + h, w = img.shape[:2] + if w > h: + new_w = max_size + new_h = int(h * max_size / w) + else: + new_h = max_size + new_w = int(w * max_size / h) + + thumb = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA) + _, buffer = cv2.imencode(".jpg", thumb, [cv2.IMWRITE_JPEG_QUALITY, 80]) + return buffer.tobytes() + except Exception as e: + logger.error(f"Error creating thumbnail: {e}") + return None + + def _extract_roi(self, frame: np.ndarray) -> tuple[np.ndarray, int, int, int, int]: + """Extract the region of interest from a frame. + + Returns: + Tuple of (roi_frame, x_start, y_start, roi_width, roi_height) + """ + height, width = frame.shape[:2] + x_start = int(width * self.roi[0]) + y_start = int(height * self.roi[1]) + roi_width = int(width * self.roi[2]) + roi_height = int(height * self.roi[3]) + roi_frame = frame[y_start : y_start + roi_height, x_start : x_start + roi_width] + return roi_frame, x_start, y_start, roi_width, roi_height + + def _preprocess_for_comparison(self, frame: np.ndarray) -> np.ndarray: + """Preprocess a frame for comparison. + + Uses heavy blur to create "blob" representation - smooths out texture + and noise while preserving large objects. Then normalizes brightness + to reduce lighting sensitivity. + """ + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + # Very heavy blur to smooth texture, keep only large shapes + blurred = cv2.GaussianBlur(gray, (51, 51), 0) + # Normalize to 0-255 range to reduce brightness sensitivity + normalized = cv2.normalize(blurred, None, 0, 255, cv2.NORM_MINMAX) + return normalized + + def calibrate(self, image_data: bytes, printer_id: int, label: str | None = None) -> tuple[bool, str, int]: + """Calibrate by saving a reference image of the empty plate. + + Stores up to MAX_REFERENCES (5) images per printer. When all slots are full, + the oldest reference is removed and others are shifted. + + Args: + image_data: JPEG image data as bytes + printer_id: Printer database ID + label: Optional label for this reference (e.g., "High Temp Plate") + + Returns: + Tuple of (success, message, index) where index is the slot used + """ + from datetime import datetime + + try: + # Decode image + nparr = np.frombuffer(image_data, np.uint8) + frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + + if frame is None: + return False, "Failed to decode image", -1 + + # Get existing references count + existing_refs = self._get_reference_paths(printer_id) + num_existing = len(existing_refs) + + # If all slots are full, rotate (remove oldest) + if num_existing >= self.MAX_REFERENCES: + self._rotate_references(printer_id) + num_existing = self.MAX_REFERENCES - 1 + + # Save to next available slot + slot_index = num_existing + reference_path = CALIBRATION_DIR / f"printer_{printer_id}_ref_{slot_index}.jpg" + cv2.imwrite(str(reference_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 95]) + + # Save metadata + metadata = self._load_metadata(printer_id) + if "references" not in metadata: + metadata["references"] = {} + metadata["references"][str(slot_index)] = { + "label": label or "", + "timestamp": datetime.now().isoformat(), + } + self._save_metadata(printer_id, metadata) + + logger.info( + f"Saved plate calibration reference {slot_index + 1}/{self.MAX_REFERENCES} for printer {printer_id}" + ) + return True, f"Calibration saved ({slot_index + 1}/{self.MAX_REFERENCES} references)", slot_index + + except Exception as e: + logger.exception("Error during plate calibration") + return False, f"Calibration error: {e!s}", -1 + + def get_calibration_count(self, printer_id: int) -> int: + """Get the number of calibration references for a printer.""" + return len(self._get_reference_paths(printer_id)) + + def has_calibration(self, printer_id: int, plate_type: str | None = None) -> bool: + """Check if a printer has any calibration reference images.""" + return len(self._get_reference_paths(printer_id)) > 0 + + def delete_calibration(self, printer_id: int, plate_type: str | None = None) -> bool: + """Delete all calibration reference images for a printer.""" + paths = self._get_reference_paths(printer_id) + if not paths: + return False + for path in paths: + path.unlink() + logger.info(f"Deleted {len(paths)} plate calibration reference(s) for printer {printer_id}") + return True + + def analyze_frame( + self, image_data: bytes, printer_id: int, plate_type: str | None = None, include_debug_image: bool = False + ) -> PlateDetectionResult: + """Analyze a camera frame to detect if the plate is empty. + + Compares the current frame to all calibration reference images and uses + the best match (lowest difference) for the final result. + + Args: + image_data: JPEG image data as bytes + printer_id: Printer database ID (for reference lookup) + plate_type: Unused - kept for API compatibility + include_debug_image: If True, include annotated image in result + + Returns: + PlateDetectionResult with analysis results + """ + try: + # Check for calibration + reference_paths = self._get_reference_paths(printer_id) + if not reference_paths: + return PlateDetectionResult( + is_empty=True, # Default to empty when not calibrated + confidence=0.0, + difference_percent=0.0, + message="No calibration - please calibrate with empty plate first", + needs_calibration=True, + ) + + # Decode current image + nparr = np.frombuffer(image_data, np.uint8) + current_frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + + if current_frame is None: + return PlateDetectionResult( + is_empty=True, + confidence=0.0, + difference_percent=0.0, + message="Failed to decode current image", + ) + + # Extract ROI from current frame + current_roi, x_start, y_start, roi_width, roi_height = self._extract_roi(current_frame) + current_processed = self._preprocess_for_comparison(current_roi) + + # Compare against all references, find best match (lowest difference) + best_difference_percent = float("inf") + best_ref_idx = -1 + best_diff = None + + for idx, ref_path in enumerate(reference_paths): + # Load reference image + reference_frame = cv2.imread(str(ref_path), cv2.IMREAD_COLOR) + if reference_frame is None: + continue + + # Ensure same dimensions + if current_frame.shape != reference_frame.shape: + reference_frame = cv2.resize(reference_frame, (current_frame.shape[1], current_frame.shape[0])) + + # Extract ROI and preprocess + reference_roi, _, _, _, _ = self._extract_roi(reference_frame) + reference_processed = self._preprocess_for_comparison(reference_roi) + + # Calculate absolute difference + diff = cv2.absdiff(current_processed, reference_processed) + + # Calculate mean difference as percentage + mean_diff = np.mean(diff) + difference_percent = (mean_diff / 255.0) * 100 + + if difference_percent < best_difference_percent: + best_difference_percent = difference_percent + best_ref_idx = idx + best_diff = diff + + if best_ref_idx == -1: + return PlateDetectionResult( + is_empty=True, + confidence=0.0, + difference_percent=0.0, + message="Failed to load any reference images - please recalibrate", + needs_calibration=True, + ) + + difference_percent = best_difference_percent + + # Determine if plate is empty (use best match) + is_empty = difference_percent < self.difference_threshold + + # Calculate confidence + if is_empty: + # Higher confidence when very little difference + confidence = 1.0 - min(1.0, difference_percent / self.difference_threshold) + else: + # Higher confidence when clearly different + confidence = min(1.0, difference_percent / (self.difference_threshold * 2)) + + # Generate message + num_refs = len(reference_paths) + if is_empty: + message = ( + f"Plate appears empty (difference: {difference_percent:.1f}%, ref {best_ref_idx + 1}/{num_refs})" + ) + else: + message = f"Objects detected on plate (difference: {difference_percent:.1f}%, best ref {best_ref_idx + 1}/{num_refs})" + + # Generate debug image if requested + debug_image = None + if include_debug_image and best_diff is not None: + debug_frame = current_frame.copy() + + # Draw ROI rectangle + cv2.rectangle( + debug_frame, + (x_start, y_start), + (x_start + roi_width, y_start + roi_height), + (0, 255, 0), + 2, + ) + + # Create colored difference overlay + # Red = areas that are different from reference + # Amplify diff for visibility (multiply by 3, cap at 255) + diff_amplified = np.minimum(best_diff * 3, 255).astype(np.uint8) + diff_colored = cv2.cvtColor(diff_amplified, cv2.COLOR_GRAY2BGR) + diff_colored[:, :, 0] = 0 # Remove blue + diff_colored[:, :, 1] = 0 # Remove green + # Red channel has the diff + + # Overlay difference on ROI + roi_overlay = debug_frame[y_start : y_start + roi_height, x_start : x_start + roi_width] + cv2.addWeighted(diff_colored, 0.5, roi_overlay, 0.5, 0, roi_overlay) + + # Add status text + status_text = "EMPTY" if is_empty else "OBJECTS DETECTED" + color = (0, 255, 0) if is_empty else (0, 0, 255) + cv2.putText(debug_frame, status_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, color, 2) + cv2.putText( + debug_frame, + f"Diff: {difference_percent:.1f}% (ref {best_ref_idx + 1}/{num_refs})", + (10, 60), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + color, + 2, + ) + cv2.putText( + debug_frame, + f"Confidence: {confidence:.0%}", + (10, 90), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + color, + 2, + ) + + # Encode debug image as JPEG + _, buffer = cv2.imencode(".jpg", debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) + debug_image = buffer.tobytes() + + return PlateDetectionResult( + is_empty=is_empty, + confidence=confidence, + difference_percent=difference_percent, + message=message, + debug_image=debug_image, + ) + + except Exception as e: + logger.exception("Error analyzing frame for plate detection") + return PlateDetectionResult( + is_empty=True, # Default to empty on error (don't block prints) + confidence=0.0, + difference_percent=0.0, + message=f"Analysis error: {e!s}", + ) + + +async def capture_camera_image( + printer_id: int, + ip_address: str, + access_code: str, + model: str, + external_camera_url: str | None = None, + external_camera_type: str | None = None, + use_external: bool = False, +) -> tuple[bytes | None, str]: + """Capture an image from the printer camera. + + If there's an active camera stream, uses the buffered frame instead of + creating a new connection (which would fail while stream is active). + + Returns: + Tuple of (image_data, camera_source) or (None, error_message) + """ + image_data: bytes | None = None + camera_source = "unknown" + + # Try external camera first if requested and available + if use_external and external_camera_url and external_camera_type: + try: + from backend.app.services.external_camera import capture_frame + + image_data = await capture_frame(external_camera_url, external_camera_type) + if image_data: + camera_source = "external" + logger.debug(f"Captured frame from external camera for printer {printer_id}") + except Exception as e: + logger.warning(f"Failed to capture from external camera: {e}") + + # Fall back to built-in camera + if image_data is None: + # First, check if there's an active stream with a buffered frame + # This avoids blocking when camera viewer is open + try: + from backend.app.api.routes.camera import get_buffered_frame + + buffered = get_buffered_frame(printer_id) + if buffered: + image_data = buffered + camera_source = "built-in (buffered)" + logger.debug(f"Using buffered frame from active stream for printer {printer_id}") + except Exception as e: + logger.debug(f"Could not get buffered frame: {e}") + + # If no buffered frame, try to capture a new one + if image_data is None: + import tempfile + + from backend.app.services.camera import capture_camera_frame + + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: + tmp_path = Path(tmp.name) + + try: + success = await capture_camera_frame(ip_address, access_code, model, tmp_path, timeout=10) + if success: + with open(tmp_path, "rb") as f: + image_data = f.read() + camera_source = "built-in" + logger.debug(f"Captured frame from built-in camera for printer {printer_id}") + finally: + try: + tmp_path.unlink() + except Exception: + pass + + return image_data, camera_source + + +async def check_plate_empty( + printer_id: int, + ip_address: str, + access_code: str, + model: str, + plate_type: str | None = None, + include_debug_image: bool = False, + external_camera_url: str | None = None, + external_camera_type: str | None = None, + use_external: bool = False, + roi: tuple[float, float, float, float] | None = None, +) -> PlateDetectionResult: + """Check if the build plate is empty for a printer. + + Args: + printer_id: Printer database ID + ip_address: Printer IP address + access_code: Printer access code + model: Printer model string + plate_type: Type of build plate for calibration lookup + include_debug_image: If True, include annotated image in result + external_camera_url: URL of external camera (if configured) + external_camera_type: Type of external camera (mjpeg, rtsp, snapshot) + use_external: If True, prefer external camera over built-in + roi: Region of interest as (x%, y%, w%, h%) - percentages of image size + + Returns: + PlateDetectionResult with analysis results + """ + if not OPENCV_AVAILABLE: + return PlateDetectionResult( + is_empty=True, + confidence=0.0, + difference_percent=0.0, + message="OpenCV not available - plate detection disabled", + ) + + image_data, camera_source = await capture_camera_image( + printer_id, ip_address, access_code, model, external_camera_url, external_camera_type, use_external + ) + + if image_data is None: + return PlateDetectionResult( + is_empty=True, # Default to empty on error + confidence=0.0, + difference_percent=0.0, + message="Failed to capture camera frame from any source", + ) + + # Analyze the captured frame + detector = PlateDetector(roi=roi) + result = detector.analyze_frame(image_data, printer_id, plate_type, include_debug_image) + + # Add camera source to message + result.message = f"[{camera_source}] {result.message}" + + return result + + +async def calibrate_plate( + printer_id: int, + ip_address: str, + access_code: str, + model: str, + label: str | None = None, + external_camera_url: str | None = None, + external_camera_type: str | None = None, + use_external: bool = False, +) -> tuple[bool, str, int]: + """Calibrate plate detection by capturing a reference image of the empty plate. + + Args: + printer_id: Printer database ID + ip_address: Printer IP address + access_code: Printer access code + model: Printer model string + label: Optional label for this reference (e.g., "High Temp Plate") + external_camera_url: URL of external camera (if configured) + external_camera_type: Type of external camera (mjpeg, rtsp, snapshot) + use_external: If True, prefer external camera over built-in + + Returns: + Tuple of (success, message, index) + """ + if not OPENCV_AVAILABLE: + return False, "OpenCV not available - plate detection disabled", -1 + + image_data, camera_source = await capture_camera_image( + printer_id, ip_address, access_code, model, external_camera_url, external_camera_type, use_external + ) + + if image_data is None: + return False, "Failed to capture camera frame for calibration", -1 + + detector = PlateDetector() + success, message, index = detector.calibrate(image_data, printer_id, label) + + if success: + message = f"[{camera_source}] {message}" + + return success, message, index + + +def get_calibration_status(printer_id: int, plate_type: str | None = None) -> dict: + """Get calibration status for a printer. + + Returns: + Dict with calibration info including reference count + """ + if not OPENCV_AVAILABLE: + return { + "available": False, + "calibrated": False, + "reference_count": 0, + "max_references": 5, + "message": "OpenCV not available", + } + + detector = PlateDetector() + calibrated = detector.has_calibration(printer_id) + ref_count = detector.get_calibration_count(printer_id) + + if calibrated: + message = f"Calibrated with {ref_count}/{detector.MAX_REFERENCES} reference(s)" + else: + message = "Not calibrated - please calibrate with empty plate" + + return { + "available": True, + "calibrated": calibrated, + "reference_count": ref_count, + "max_references": detector.MAX_REFERENCES, + "message": message, + } + + +def delete_calibration(printer_id: int, plate_type: str | None = None) -> bool: + """Delete calibration for a printer and plate type.""" + if not OPENCV_AVAILABLE: + return False + + detector = PlateDetector() + return detector.delete_calibration(printer_id, plate_type) + + +def is_plate_detection_available() -> bool: + """Check if plate detection feature is available (OpenCV installed).""" + return OPENCV_AVAILABLE diff --git a/backend/tests/integration/test_camera_api.py b/backend/tests/integration/test_camera_api.py index 124fcf898..98d28ef45 100644 --- a/backend/tests/integration/test_camera_api.py +++ b/backend/tests/integration/test_camera_api.py @@ -223,3 +223,188 @@ class TestCameraAPI: ) # Response will be a streaming response with error assert response.status_code == 200 + + # ======================================================================== + # Plate Detection Endpoints + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_plate_detection_status_printer_not_found(self, async_client: AsyncClient): + """Verify 404 when checking plate detection status for non-existent printer.""" + response = await async_client.get("/api/v1/printers/99999/camera/plate-detection/status") + + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_plate_detection_status_opencv_not_available(self, async_client: AsyncClient, printer_factory): + """Verify plate detection status returns unavailable when OpenCV not installed.""" + printer = await printer_factory() + + with patch("backend.app.services.plate_detection.OPENCV_AVAILABLE", False): + response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/status") + + assert response.status_code == 200 + result = response.json() + assert result["available"] is False + assert result["calibrated"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_plate_detection_status_success(self, async_client: AsyncClient, printer_factory): + """Verify plate detection status returns correctly when OpenCV available.""" + printer = await printer_factory() + + # OpenCV is available in test environment, just check the response structure + response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/status") + + assert response.status_code == 200 + result = response.json() + assert "available" in result + assert "calibrated" in result + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_check_plate_empty_printer_not_found(self, async_client: AsyncClient): + """Verify 404 when checking plate for non-existent printer.""" + response = await async_client.get("/api/v1/printers/99999/camera/check-plate") + + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_check_plate_empty_success_structure(self, async_client: AsyncClient, printer_factory): + """Verify check plate returns proper structure when OpenCV available.""" + printer = await printer_factory() + + # Mock PlateDetectionResult to avoid camera timeout + mock_result = MagicMock() + mock_result.is_empty = True + mock_result.confidence = 0.95 + mock_result.difference_percent = 0.5 + mock_result.message = "Plate appears empty" + mock_result.needs_calibration = False + mock_result.to_dict.return_value = { + "is_empty": True, + "confidence": 0.95, + "difference_percent": 0.5, + "message": "Plate appears empty", + "has_debug_image": False, + "needs_calibration": False, + } + + with patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check: + mock_check.return_value = mock_result + response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate") + + assert response.status_code == 200 + result = response.json() + assert "is_empty" in result + assert "confidence" in result + assert "message" in result + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_calibrate_plate_printer_not_found(self, async_client: AsyncClient): + """Verify 404 when calibrating plate for non-existent printer.""" + response = await async_client.post("/api/v1/printers/99999/camera/plate-detection/calibrate") + + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_calibrate_plate_success_structure(self, async_client: AsyncClient, printer_factory): + """Verify calibrate endpoint responds with proper structure.""" + printer = await printer_factory() + + # Mock calibrate_plate at the source module to avoid camera timeout + with patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate: + mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0) + response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate") + + assert response.status_code == 200 + result = response.json() + assert result["success"] is True + assert "index" in result + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_calibration_printer_not_found(self, async_client: AsyncClient): + """Verify 404 when deleting calibration for non-existent printer.""" + response = await async_client.delete("/api/v1/printers/99999/camera/plate-detection/calibrate") + + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_calibration_success(self, async_client: AsyncClient, printer_factory): + """Verify delete calibration returns proper structure.""" + printer = await printer_factory() + + response = await async_client.delete(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate") + + assert response.status_code == 200 + result = response.json() + assert "success" in result + assert "message" in result + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_references_printer_not_found(self, async_client: AsyncClient): + """Verify 404 when getting references for non-existent printer.""" + response = await async_client.get("/api/v1/printers/99999/camera/plate-detection/references") + + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_references_opencv_not_available(self, async_client: AsyncClient, printer_factory): + """Verify get references returns unavailable when OpenCV not installed.""" + printer = await printer_factory() + + with patch("backend.app.services.plate_detection.OPENCV_AVAILABLE", False): + response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/references") + + assert response.status_code == 503 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_references_success(self, async_client: AsyncClient, printer_factory): + """Verify get references returns proper structure.""" + printer = await printer_factory() + + # OpenCV is available in test environment, just check the response structure + response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/references") + + assert response.status_code == 200 + result = response.json() + assert "references" in result + assert "max_references" in result + assert isinstance(result["references"], list) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_reference_label_printer_not_found(self, async_client: AsyncClient): + """Verify 404 when updating reference label for non-existent printer.""" + response = await async_client.put( + "/api/v1/printers/99999/camera/plate-detection/references/0", params={"label": "New Label"} + ) + + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_reference_printer_not_found(self, async_client: AsyncClient): + """Verify 404 when deleting reference for non-existent printer.""" + response = await async_client.delete("/api/v1/printers/99999/camera/plate-detection/references/0") + + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_reference_thumbnail_printer_not_found(self, async_client: AsyncClient): + """Verify 404 when getting reference thumbnail for non-existent printer.""" + response = await async_client.get("/api/v1/printers/99999/camera/plate-detection/references/0/thumbnail") + + assert response.status_code == 404 diff --git a/backend/tests/unit/services/test_plate_detection.py b/backend/tests/unit/services/test_plate_detection.py new file mode 100644 index 000000000..6896b73bd --- /dev/null +++ b/backend/tests/unit/services/test_plate_detection.py @@ -0,0 +1,185 @@ +"""Unit tests for plate detection service.""" + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Mock cv2 and numpy before importing the module +cv2_mock = MagicMock() +np_mock = MagicMock() + + +class TestPlateDetectionResult: + """Tests for PlateDetectionResult class.""" + + def test_result_to_dict(self): + """Verify PlateDetectionResult.to_dict() returns correct structure.""" + with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}): + from backend.app.services.plate_detection import PlateDetectionResult + + result = PlateDetectionResult( + is_empty=True, + confidence=0.95, + difference_percent=0.5, + message="Test message", + debug_image=None, + needs_calibration=False, + ) + + d = result.to_dict() + + assert d["is_empty"] is True + assert d["confidence"] == 0.95 + assert d["difference_percent"] == 0.5 + assert d["message"] == "Test message" + assert d["has_debug_image"] is False + assert d["needs_calibration"] is False + + def test_result_with_debug_image(self): + """Verify has_debug_image is True when debug_image is provided.""" + with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}): + from backend.app.services.plate_detection import PlateDetectionResult + + result = PlateDetectionResult( + is_empty=False, + confidence=0.8, + difference_percent=5.0, + message="Objects detected", + debug_image=b"fake_image_data", + needs_calibration=False, + ) + + d = result.to_dict() + assert d["has_debug_image"] is True + + def test_result_needs_calibration(self): + """Verify needs_calibration flag is preserved.""" + with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}): + from backend.app.services.plate_detection import PlateDetectionResult + + result = PlateDetectionResult( + is_empty=True, + confidence=0.0, + difference_percent=0.0, + message="No calibration", + needs_calibration=True, + ) + + d = result.to_dict() + assert d["needs_calibration"] is True + + +class TestPlateDetector: + """Tests for PlateDetector class.""" + + def test_detector_initialization(self): + """Verify PlateDetector initializes with default values.""" + with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}): + # Re-import to get fresh module + import importlib + + import backend.app.services.plate_detection as pd_module + + importlib.reload(pd_module) + + # Mock OPENCV_AVAILABLE + pd_module.OPENCV_AVAILABLE = True + + detector = pd_module.PlateDetector() + assert detector.roi == (0.15, 0.35, 0.70, 0.55) + assert detector.difference_threshold == 1.0 + + def test_detector_custom_roi(self): + """Verify PlateDetector accepts custom ROI.""" + with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}): + import importlib + + import backend.app.services.plate_detection as pd_module + + importlib.reload(pd_module) + + pd_module.OPENCV_AVAILABLE = True + + custom_roi = (0.1, 0.2, 0.8, 0.6) + detector = pd_module.PlateDetector(roi=custom_roi) + assert detector.roi == custom_roi + + def test_detector_raises_without_opencv(self): + """Verify PlateDetector raises when OpenCV not available.""" + with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}): + import importlib + + import backend.app.services.plate_detection as pd_module + + importlib.reload(pd_module) + + pd_module.OPENCV_AVAILABLE = False + + with pytest.raises(RuntimeError, match="OpenCV is not installed"): + pd_module.PlateDetector() + + +class TestCalibrationStatus: + """Tests for calibration status functions.""" + + def test_get_calibration_status_no_opencv(self): + """Verify calibration status when OpenCV not available.""" + with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}): + import importlib + + import backend.app.services.plate_detection as pd_module + + importlib.reload(pd_module) + + pd_module.OPENCV_AVAILABLE = False + + status = pd_module.get_calibration_status(1) + + assert status["available"] is False + assert status["calibrated"] is False + assert status["reference_count"] == 0 + assert "OpenCV not available" in status["message"] + + def test_is_plate_detection_available_true(self): + """Verify is_plate_detection_available returns True when OpenCV available.""" + with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}): + import importlib + + import backend.app.services.plate_detection as pd_module + + importlib.reload(pd_module) + + pd_module.OPENCV_AVAILABLE = True + assert pd_module.is_plate_detection_available() is True + + def test_is_plate_detection_available_false(self): + """Verify is_plate_detection_available returns False when OpenCV not available.""" + with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}): + import importlib + + import backend.app.services.plate_detection as pd_module + + importlib.reload(pd_module) + + pd_module.OPENCV_AVAILABLE = False + assert pd_module.is_plate_detection_available() is False + + +class TestDeleteCalibration: + """Tests for delete_calibration function.""" + + def test_delete_calibration_no_opencv(self): + """Verify delete_calibration returns False when OpenCV not available.""" + with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}): + import importlib + + import backend.app.services.plate_detection as pd_module + + importlib.reload(pd_module) + + pd_module.OPENCV_AVAILABLE = False + + result = pd_module.delete_calibration(1) + assert result is False diff --git a/data/plate_calibration/printer_1_metadata.json b/data/plate_calibration/printer_1_metadata.json new file mode 100644 index 000000000..5435dc678 --- /dev/null +++ b/data/plate_calibration/printer_1_metadata.json @@ -0,0 +1,20 @@ +{ + "references": { + "0": { + "label": "CyroGrip", + "timestamp": "2026-01-26T12:06:09.612288" + }, + "1": { + "label": "Carbon", + "timestamp": "2026-01-26T12:07:01.878776" + }, + "2": { + "label": "Textured PEI", + "timestamp": "2026-01-26T12:07:31.460596" + }, + "3": { + "label": "Wham Bam", + "timestamp": "2026-01-26T12:07:53.889845" + } + } +} diff --git a/data/plate_calibration/printer_3_metadata.json b/data/plate_calibration/printer_3_metadata.json new file mode 100644 index 000000000..c1a80a5db --- /dev/null +++ b/data/plate_calibration/printer_3_metadata.json @@ -0,0 +1,12 @@ +{ + "references": { + "0": { + "label": "Textured PEI", + "timestamp": "2026-01-26T12:11:30.814062" + }, + "1": { + "label": "", + "timestamp": "2026-01-26T12:34:31.393095" + } + } +} diff --git a/data/plate_calibration/printer_3_ref_0.jpg b/data/plate_calibration/printer_3_ref_0.jpg new file mode 100644 index 000000000..b546529b2 Binary files /dev/null and b/data/plate_calibration/printer_3_ref_0.jpg differ diff --git a/data/plate_calibration/printer_3_ref_1.jpg b/data/plate_calibration/printer_3_ref_1.jpg new file mode 100644 index 000000000..209a4e0c7 Binary files /dev/null and b/data/plate_calibration/printer_3_ref_1.jpg differ diff --git a/frontend/src/__tests__/components/Layout.test.tsx b/frontend/src/__tests__/components/Layout.test.tsx index e63b73e3f..f32634762 100644 --- a/frontend/src/__tests__/components/Layout.test.tsx +++ b/frontend/src/__tests__/components/Layout.test.tsx @@ -122,4 +122,65 @@ describe('Layout', () => { }); }); }); + + describe('plate detection alert modal', () => { + it('shows modal when plate-not-empty event is dispatched', async () => { + render(); + + // Dispatch the plate-not-empty event + window.dispatchEvent( + new CustomEvent('plate-not-empty', { + detail: { + printer_id: 1, + printer_name: 'Test Printer', + message: 'Objects detected on build plate', + }, + }) + ); + + await waitFor(() => { + // Modal should appear with "Print Paused!" text + expect(document.body.textContent).toContain('Print Paused!'); + expect(document.body.textContent).toContain('Test Printer'); + }); + }); + + it('closes modal when I Understand button is clicked', async () => { + render(); + + // Dispatch the plate-not-empty event + window.dispatchEvent( + new CustomEvent('plate-not-empty', { + detail: { + printer_id: 1, + printer_name: 'Test Printer', + message: 'Objects detected on build plate', + }, + }) + ); + + await waitFor(() => { + expect(document.body.textContent).toContain('Print Paused!'); + }); + + // Click the "I Understand" button + const button = document.querySelector('button'); + if (button && button.textContent?.includes('I Understand')) { + button.click(); + } + + // Find and click the "I Understand" button by searching all buttons + const buttons = document.querySelectorAll('button'); + buttons.forEach((btn) => { + if (btn.textContent?.includes('I Understand')) { + btn.click(); + } + }); + + await waitFor(() => { + // Modal should be closed + expect(document.body.textContent).not.toContain('Print Paused!'); + }); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f7975aba3..c51a52586 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -68,6 +68,8 @@ export interface Printer { external_camera_url: string | null; external_camera_type: string | null; // "mjpeg", "rtsp", "snapshot" external_camera_enabled: boolean; + plate_detection_enabled: boolean; // Check plate before print + plate_detection_roi?: PlateDetectionROI; // ROI for plate detection created_at: string; updated_at: string; } @@ -215,6 +217,51 @@ export interface PrinterCreate { external_camera_url?: string | null; external_camera_type?: string | null; external_camera_enabled?: boolean; + plate_detection_enabled?: boolean; + plate_detection_roi?: PlateDetectionROI; +} + +// Plate Detection +export interface PlateDetectionROI { + x: number; // X start % (0.0-1.0) + y: number; // Y start % (0.0-1.0) + w: number; // Width % (0.0-1.0) + h: number; // Height % (0.0-1.0) +} + +export interface PlateDetectionResult { + is_empty: boolean; + confidence: number; + difference_percent: number; + message: string; + has_debug_image: boolean; + debug_image_url?: string; + needs_calibration: boolean; + light_warning?: boolean; + reference_count?: number; + max_references?: number; + roi?: PlateDetectionROI; +} + +export interface PlateDetectionStatus { + available: boolean; + calibrated: boolean; + reference_count: number; + max_references: number; + message: string; +} + +export interface CalibrationResult { + success: boolean; + message: string; +} + +export interface PlateReference { + index: number; + label: string; + timestamp: string; + has_image: boolean; + thumbnail_url: string; } // Archive types @@ -2596,6 +2643,59 @@ export const api = { testCameraConnection: (printerId: number) => request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`), + // Plate Detection - Multi-reference calibration (stores up to 5 references per printer) + checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => { + const params = new URLSearchParams(); + params.set('use_external', String(options?.useExternal ?? false)); + params.set('include_debug_image', String(options?.includeDebugImage ?? false)); + return request( + `/printers/${printerId}/camera/check-plate?${params.toString()}` + ); + }, + getPlateDetectionStatus: (printerId: number) => { + return request( + `/printers/${printerId}/camera/plate-detection/status` + ); + }, + calibratePlateDetection: (printerId: number, options?: { label?: string; useExternal?: boolean }) => { + const params = new URLSearchParams(); + if (options?.label) params.set('label', options.label); + params.set('use_external', String(options?.useExternal ?? false)); + return request( + `/printers/${printerId}/camera/plate-detection/calibrate?${params.toString()}`, + { method: 'POST' } + ); + }, + deletePlateCalibration: (printerId: number) => { + return request( + `/printers/${printerId}/camera/plate-detection/calibrate`, + { method: 'DELETE' } + ); + }, + getPlateReferences: (printerId: number) => { + return request<{ + references: PlateReference[]; + max_references: number; + }>(`/printers/${printerId}/camera/plate-detection/references`); + }, + getPlateReferenceThumbnailUrl: (printerId: number, index: number) => { + return `${API_BASE}/printers/${printerId}/camera/plate-detection/references/${index}/thumbnail`; + }, + updatePlateReferenceLabel: (printerId: number, index: number, label: string) => { + const params = new URLSearchParams(); + params.set('label', label); + return request<{ success: boolean; index: number; label: string }>( + `/printers/${printerId}/camera/plate-detection/references/${index}?${params.toString()}`, + { method: 'PUT' } + ); + }, + deletePlateReference: (printerId: number, index: number) => { + return request<{ success: boolean; message: string }>( + `/printers/${printerId}/camera/plate-detection/references/${index}`, + { method: 'DELETE' } + ); + }, + // External Links getExternalLinks: () => request('/external-links/'), getExternalLink: (id: number) => request(`/external-links/${id}`), diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c40fa051b..0975f061b 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -84,6 +84,11 @@ export function Layout() { const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState(() => sessionStorage.getItem('dismissedUpdateVersion') ); + const [plateDetectionAlert, setPlateDetectionAlert] = useState<{ + printer_id: number; + printer_name: string; + message: string; + } | null>(null); // Check for updates const { data: versionInfo } = useQuery({ @@ -300,6 +305,20 @@ export function Layout() { } }, [location.pathname, isMobile]); + // Listen for plate detection warnings (objects on plate, print paused) + useEffect(() => { + const handlePlateNotEmpty = (event: Event) => { + const detail = (event as CustomEvent).detail; + setPlateDetectionAlert({ + printer_id: detail.printer_id, + printer_name: detail.printer_name, + message: detail.message, + }); + }; + window.addEventListener('plate-not-empty', handlePlateNotEmpty); + return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty); + }, []); + // Global keyboard shortcuts for navigation const handleKeyDown = useCallback((e: KeyboardEvent) => { const target = e.target as HTMLElement; @@ -750,6 +769,37 @@ export function Layout() { }).filter(Boolean) as { type: 'nav' | 'external'; label: string; labelKey?: string }[]} /> )} + + {/* Plate Detection Alert Modal */} + {plateDetectionAlert && ( +
+
+
+
+ + + +
+

+ Print Paused! +

+

+ {plateDetectionAlert.printer_name} +

+

+ Objects detected on build plate. The print has been automatically paused. + Please clear the plate and resume the print. +

+ +
+
+
+ )} ); } diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 872b9f744..755ca2b6c 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -214,6 +214,18 @@ export function useWebSocket() { case 'pong': // Keepalive response, ignore break; + + case 'plate_not_empty': + // Plate detection found objects - print was paused + // Dispatch event for toast notification + window.dispatchEvent(new CustomEvent('plate-not-empty', { + detail: { + printer_id: message.printer_id, + printer_name: (message as unknown as { printer_name?: string }).printer_name, + message: (message as unknown as { message?: string }).message, + } + })); + break; } }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]); diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 44e732d72..d6400be72 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -36,6 +36,9 @@ import { Wind, AirVent, Download, + ScanSearch, + CheckCircle, + XCircle, } from 'lucide-react'; // Custom Skip Objects icon - arrow jumping over boxes @@ -965,6 +968,22 @@ function PrinterCard({ trayInfoIdx?: string; } | null>(null); const [showFirmwareModal, setShowFirmwareModal] = useState(false); + const [plateCheckResult, setPlateCheckResult] = useState<{ + is_empty: boolean; + confidence: number; + difference_percent: number; + message: string; + debug_image_url?: string; + needs_calibration: boolean; + light_warning?: boolean; + reference_count?: number; + max_references?: number; + roi?: { x: number; y: number; w: number; h: number }; + } | null>(null); + const [isCheckingPlate, setIsCheckingPlate] = useState(false); + const [isCalibrating, setIsCalibrating] = useState(false); + const [editingRoi, setEditingRoi] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + const [isSavingRoi, setIsSavingRoi] = useState(false); const { data: status } = useQuery({ queryKey: ['printerStatus', printer.id], @@ -1187,6 +1206,16 @@ function PrinterCard({ }, }); + // Plate detection setting mutation + const plateDetectionMutation = useMutation({ + mutationFn: (enabled: boolean) => api.updatePrinter(printer.id, { plate_detection_enabled: enabled }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['printers'] }); + showToast(plateDetectionMutation.variables ? 'Plate check enabled' : 'Plate check disabled'); + }, + onError: (error: Error) => showToast(error.message || 'Failed to update setting', 'error'), + }); + // Query for printable objects (for skip functionality) // Fetch when printing with 2+ objects OR when modal is open const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2; @@ -1250,6 +1279,118 @@ function PrinterCard({ }, }); + // Plate references state + const [plateReferences, setPlateReferences] = useState<{ + references: Array<{ index: number; label: string; timestamp: string; has_image: boolean; thumbnail_url: string }>; + max_references: number; + } | null>(null); + const [editingRefLabel, setEditingRefLabel] = useState<{ index: number; label: string } | null>(null); + + // Fetch plate references + const fetchPlateReferences = async () => { + try { + const data = await api.getPlateReferences(printer.id); + setPlateReferences(data); + } catch { + // Ignore errors - references will show as empty + } + }; + + // Toggle plate detection enabled/disabled + const handleTogglePlateDetection = () => { + plateDetectionMutation.mutate(!printer.plate_detection_enabled); + }; + + // Open plate detection management modal (for calibration/references) + const handleOpenPlateManagement = async () => { + setIsCheckingPlate(true); + setPlateCheckResult(null); + try { + const result = await api.checkPlateEmpty(printer.id, { includeDebugImage: true }); + setPlateCheckResult(result); + fetchPlateReferences(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to check plate', 'error'); + } finally { + setIsCheckingPlate(false); + } + }; + + // Calibrate plate detection handler + const handleCalibratePlate = async (label?: string) => { + setIsCalibrating(true); + try { + const result = await api.calibratePlateDetection(printer.id, { label }); + if (result.success) { + showToast(result.message || 'Calibration saved!', 'success'); + // Refresh references and re-check + fetchPlateReferences(); + const checkResult = await api.checkPlateEmpty(printer.id, { includeDebugImage: true }); + setPlateCheckResult(checkResult); + } else { + showToast(result.message || 'Calibration failed', 'error'); + } + } catch (error) { + showToast(error instanceof Error ? error.message : 'Calibration failed', 'error'); + } finally { + setIsCalibrating(false); + } + }; + + // Update reference label + const handleUpdateRefLabel = async (index: number, label: string) => { + try { + await api.updatePlateReferenceLabel(printer.id, index, label); + setEditingRefLabel(null); + fetchPlateReferences(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to update label', 'error'); + } + }; + + // Delete reference + const handleDeleteRef = async (index: number) => { + try { + await api.deletePlateReference(printer.id, index); + showToast('Reference deleted', 'success'); + fetchPlateReferences(); + // Re-check to update counts + const checkResult = await api.checkPlateEmpty(printer.id, { includeDebugImage: true }); + setPlateCheckResult(checkResult); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to delete reference', 'error'); + } + }; + + // Save ROI settings + const handleSaveRoi = async () => { + if (!editingRoi) return; + setIsSavingRoi(true); + try { + await api.updatePrinter(printer.id, { plate_detection_roi: editingRoi }); + showToast('Detection area saved', 'success'); + setEditingRoi(null); + // Re-check to see new ROI in action + const checkResult = await api.checkPlateEmpty(printer.id, { includeDebugImage: true }); + setPlateCheckResult(checkResult); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to save detection area', 'error'); + } finally { + setIsSavingRoi(false); + } + }; + + // Close plate check modal on Escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && plateCheckResult) { + setPlateCheckResult(null); + } + }; + window.addEventListener('keydown', handleEscape); + return () => window.removeEventListener('keydown', handleEscape); + }, [plateCheckResult]); + // Watch ams_status_main to detect when RFID read completes // ams_status_main: 0=idle, 2=rfid_identifying const deferredClearRef = useRef | null>(null); @@ -2560,6 +2701,37 @@ function PrinterCard({ >