mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Merge pull request #158 from maziggy/feature/empty_plate_detection
Add build plate empty detection feature
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user