plate_detection.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848
  1. """Build plate empty detection using OpenCV.
  2. Analyzes camera frames to detect if there are objects on the build plate.
  3. Uses calibration-based difference detection - compares current frame to
  4. a reference image of the empty plate.
  5. """
  6. from __future__ import annotations
  7. import logging
  8. import os
  9. import re
  10. from pathlib import Path
  11. logger = logging.getLogger(__name__)
  12. # Optional OpenCV import - feature disabled if not available
  13. try:
  14. import cv2
  15. import numpy as np
  16. OPENCV_AVAILABLE = True
  17. except ImportError:
  18. OPENCV_AVAILABLE = False
  19. logger.info("OpenCV not available - plate detection feature disabled")
  20. def _get_calibration_dir() -> Path:
  21. """Get the calibration directory from settings (ensures persistence in Docker)."""
  22. from backend.app.core.config import settings
  23. return settings.plate_calibration_dir
  24. _REFERENCE_FILE = re.compile(r"printer_(\d+)_(?:ref_\d+\.jpg|metadata\.json)")
  25. def _reference_files() -> list[tuple[int, Path]]:
  26. directory = _get_calibration_dir()
  27. if not directory.is_dir():
  28. return []
  29. return [
  30. (int(m[1]), path) for path in directory.iterdir() if (m := _REFERENCE_FILE.fullmatch(path.name)) is not None
  31. ]
  32. def _remove(path: Path) -> None:
  33. try:
  34. path.unlink()
  35. except OSError as e:
  36. logger.warning("Could not remove plate reference %s: %s", path, e)
  37. def remove_printer_references(printer_id: int) -> None:
  38. """Remove a deleted printer's plate references (camera images of its bed).
  39. Named by printer id, so the next printer given the id (SQLite reuses it)
  40. would show them and compare its plate against them.
  41. """
  42. for owner, path in _reference_files():
  43. if owner == printer_id:
  44. _remove(path)
  45. class PlateDetectionResult:
  46. """Result of plate detection analysis."""
  47. def __init__(
  48. self,
  49. is_empty: bool,
  50. confidence: float,
  51. difference_percent: float,
  52. message: str,
  53. debug_image: bytes | None = None,
  54. needs_calibration: bool = False,
  55. failure_reason: str | None = None,
  56. ):
  57. self.is_empty = is_empty
  58. self.confidence = confidence # 0.0 to 1.0
  59. self.difference_percent = difference_percent # How different from reference
  60. self.message = message
  61. self.debug_image = debug_image # Optional annotated image for debugging
  62. self.needs_calibration = needs_calibration # True if no reference image exists
  63. # Why the check could not run (no_camera_frame, decode_failed,
  64. # analysis_error, opencv_missing); None when it ran. is_empty is then
  65. # True only so that a print is not blocked.
  66. self.failure_reason = failure_reason
  67. def to_dict(self) -> dict:
  68. return {
  69. "is_empty": bool(self.is_empty),
  70. "confidence": float(round(self.confidence, 2)),
  71. "difference_percent": float(round(self.difference_percent, 2)),
  72. "message": self.message,
  73. "has_debug_image": self.debug_image is not None,
  74. "needs_calibration": bool(self.needs_calibration),
  75. "failure_reason": self.failure_reason,
  76. }
  77. class PlateDetector:
  78. """Detects if the build plate is empty using calibration-based difference detection."""
  79. # Default region of interest (ROI) as percentage of image dimensions
  80. # These define where the build plate typically appears in the camera view
  81. # Format: (x_start%, y_start%, width%, height%)
  82. DEFAULT_ROI = (0.15, 0.35, 0.70, 0.55) # Center-lower portion of frame
  83. # Detection thresholds for difference detection
  84. # Using mean pixel difference (0-100% scale)
  85. # Small objects may only cause 1-2% mean difference
  86. DEFAULT_DIFFERENCE_THRESHOLD = 1.0
  87. DEFAULT_BLUR_SIZE = 21 # Gaussian blur kernel size (must be odd) - unused with edge detection
  88. def __init__(
  89. self,
  90. roi: tuple[float, float, float, float] | None = None,
  91. difference_threshold: float = DEFAULT_DIFFERENCE_THRESHOLD,
  92. blur_size: int = DEFAULT_BLUR_SIZE,
  93. ):
  94. """Initialize the plate detector.
  95. Args:
  96. roi: Region of interest as (x%, y%, w%, h%) - percentages of image size
  97. difference_threshold: Percentage of pixels that must differ to trigger "not empty"
  98. blur_size: Gaussian blur kernel size for noise reduction
  99. """
  100. if not OPENCV_AVAILABLE:
  101. raise RuntimeError("OpenCV is not installed. Install with: pip install opencv-python-headless")
  102. self.roi = roi or self.DEFAULT_ROI
  103. self.difference_threshold = difference_threshold
  104. self.blur_size = blur_size if blur_size % 2 == 1 else blur_size + 1 # Must be odd
  105. # Maximum number of reference images to store per printer
  106. MAX_REFERENCES = 5
  107. def _get_metadata_path(self, printer_id: int) -> Path:
  108. """Get the path to the metadata JSON file for a printer."""
  109. _get_calibration_dir().mkdir(parents=True, exist_ok=True)
  110. return _get_calibration_dir() / f"printer_{printer_id}_metadata.json"
  111. def _load_metadata(self, printer_id: int) -> dict:
  112. """Load metadata for a printer's references."""
  113. import json
  114. meta_path = self._get_metadata_path(printer_id)
  115. if meta_path.exists():
  116. try:
  117. with open(meta_path) as f:
  118. return json.load(f)
  119. except (json.JSONDecodeError, OSError, KeyError, ValueError):
  120. pass
  121. return {"references": {}}
  122. def _save_metadata(self, printer_id: int, metadata: dict) -> None:
  123. """Save metadata for a printer's references."""
  124. import json
  125. meta_path = self._get_metadata_path(printer_id)
  126. with open(meta_path, "w") as f:
  127. json.dump(metadata, f, indent=2)
  128. def _get_reference_paths(self, printer_id: int) -> list[Path]:
  129. """Get all existing reference image paths for a printer."""
  130. _get_calibration_dir().mkdir(parents=True, exist_ok=True)
  131. paths = []
  132. for i in range(self.MAX_REFERENCES):
  133. path = _get_calibration_dir() / f"printer_{printer_id}_ref_{i}.jpg"
  134. if path.exists():
  135. paths.append(path)
  136. return paths
  137. def _rotate_references(self, printer_id: int) -> None:
  138. """Rotate references: delete oldest (0), shift others down."""
  139. # Delete slot 0
  140. slot0 = _get_calibration_dir() / f"printer_{printer_id}_ref_0.jpg"
  141. if slot0.exists():
  142. logger.info("Rotating references: removing oldest %s", slot0)
  143. slot0.unlink()
  144. # Shift others down
  145. for i in range(1, self.MAX_REFERENCES):
  146. old_path = _get_calibration_dir() / f"printer_{printer_id}_ref_{i}.jpg"
  147. new_path = _get_calibration_dir() / f"printer_{printer_id}_ref_{i - 1}.jpg"
  148. if old_path.exists():
  149. old_path.rename(new_path)
  150. # Also rotate metadata
  151. metadata = self._load_metadata(printer_id)
  152. refs = metadata.get("references", {})
  153. new_refs = {}
  154. for i in range(1, self.MAX_REFERENCES):
  155. if str(i) in refs:
  156. new_refs[str(i - 1)] = refs[str(i)]
  157. metadata["references"] = new_refs
  158. self._save_metadata(printer_id, metadata)
  159. def get_references(self, printer_id: int) -> list[dict]:
  160. """Get all references with metadata for a printer.
  161. Returns list of dicts with: index, label, timestamp, has_image
  162. """
  163. metadata = self._load_metadata(printer_id)
  164. refs = metadata.get("references", {})
  165. result = []
  166. for i in range(self.MAX_REFERENCES):
  167. path = _get_calibration_dir() / f"printer_{printer_id}_ref_{i}.jpg"
  168. if path.exists():
  169. ref_meta = refs.get(str(i), {})
  170. result.append(
  171. {
  172. "index": i,
  173. "label": ref_meta.get("label", ""),
  174. "timestamp": ref_meta.get("timestamp", ""),
  175. "has_image": True,
  176. }
  177. )
  178. return result
  179. def update_reference_label(self, printer_id: int, index: int, label: str) -> bool:
  180. """Update the label for a reference."""
  181. if index < 0 or index >= self.MAX_REFERENCES:
  182. return False
  183. path = _get_calibration_dir() / f"printer_{printer_id}_ref_{index}.jpg"
  184. if not path.exists():
  185. return False
  186. metadata = self._load_metadata(printer_id)
  187. if "references" not in metadata:
  188. metadata["references"] = {}
  189. if str(index) not in metadata["references"]:
  190. metadata["references"][str(index)] = {}
  191. metadata["references"][str(index)]["label"] = label
  192. self._save_metadata(printer_id, metadata)
  193. return True
  194. def delete_reference(self, printer_id: int, index: int) -> bool:
  195. """Delete a specific reference by index."""
  196. if index < 0 or index >= self.MAX_REFERENCES:
  197. return False
  198. path = _get_calibration_dir() / f"printer_{printer_id}_ref_{index}.jpg"
  199. if not path.exists():
  200. return False
  201. # Delete image
  202. logger.info("Deleting reference %s for printer %s: %s", index, printer_id, path)
  203. path.unlink()
  204. # Remove from metadata
  205. metadata = self._load_metadata(printer_id)
  206. refs = metadata.get("references", {})
  207. if str(index) in refs:
  208. del refs[str(index)]
  209. metadata["references"] = refs
  210. self._save_metadata(printer_id, metadata)
  211. # Shift remaining references down to fill the gap
  212. for i in range(index + 1, self.MAX_REFERENCES):
  213. old_img = _get_calibration_dir() / f"printer_{printer_id}_ref_{i}.jpg"
  214. new_img = _get_calibration_dir() / f"printer_{printer_id}_ref_{i - 1}.jpg"
  215. if old_img.exists():
  216. old_img.rename(new_img)
  217. # Also shift metadata
  218. if str(i) in refs:
  219. refs[str(i - 1)] = refs[str(i)]
  220. del refs[str(i)]
  221. metadata["references"] = refs
  222. self._save_metadata(printer_id, metadata)
  223. return True
  224. def get_reference_thumbnail(self, printer_id: int, index: int, max_size: int = 150) -> bytes | None:
  225. """Get a thumbnail of a reference image.
  226. Returns JPEG bytes or None if not found.
  227. """
  228. path = _get_calibration_dir() / f"printer_{printer_id}_ref_{index}.jpg"
  229. if not path.exists():
  230. return None
  231. try:
  232. img = cv2.imread(str(path))
  233. if img is None:
  234. return None
  235. # Calculate thumbnail size maintaining aspect ratio
  236. h, w = img.shape[:2]
  237. if w > h:
  238. new_w = max_size
  239. new_h = int(h * max_size / w)
  240. else:
  241. new_h = max_size
  242. new_w = int(w * max_size / h)
  243. thumb = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
  244. _, buffer = cv2.imencode(".jpg", thumb, [cv2.IMWRITE_JPEG_QUALITY, 80])
  245. return buffer.tobytes()
  246. except Exception as e:
  247. logger.error("Error creating thumbnail: %s", e)
  248. return None
  249. def _extract_roi(self, frame: np.ndarray) -> tuple[np.ndarray, int, int, int, int]:
  250. """Extract the region of interest from a frame.
  251. Returns:
  252. Tuple of (roi_frame, x_start, y_start, roi_width, roi_height)
  253. """
  254. height, width = frame.shape[:2]
  255. x_start = int(width * self.roi[0])
  256. y_start = int(height * self.roi[1])
  257. roi_width = int(width * self.roi[2])
  258. roi_height = int(height * self.roi[3])
  259. roi_frame = frame[y_start : y_start + roi_height, x_start : x_start + roi_width]
  260. return roi_frame, x_start, y_start, roi_width, roi_height
  261. def _preprocess_for_comparison(self, frame: np.ndarray) -> np.ndarray:
  262. """Preprocess a frame for comparison.
  263. Uses heavy blur to create "blob" representation - smooths out texture
  264. and noise while preserving large objects. Then normalizes brightness
  265. to reduce lighting sensitivity.
  266. """
  267. gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
  268. # Very heavy blur to smooth texture, keep only large shapes
  269. blurred = cv2.GaussianBlur(gray, (51, 51), 0)
  270. # Normalize to 0-255 range to reduce brightness sensitivity
  271. normalized = cv2.normalize(blurred, None, 0, 255, cv2.NORM_MINMAX)
  272. return normalized
  273. def calibrate(self, image_data: bytes, printer_id: int, label: str | None = None) -> tuple[bool, str, int]:
  274. """Calibrate by saving a reference image of the empty plate.
  275. Stores up to MAX_REFERENCES (5) images per printer. When all slots are full,
  276. the oldest reference is removed and others are shifted.
  277. Args:
  278. image_data: JPEG image data as bytes
  279. printer_id: Printer database ID
  280. label: Optional label for this reference (e.g., "High Temp Plate")
  281. Returns:
  282. Tuple of (success, message, index) where index is the slot used
  283. """
  284. from datetime import datetime
  285. try:
  286. # Decode image
  287. nparr = np.frombuffer(image_data, np.uint8)
  288. frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
  289. if frame is None:
  290. return False, "Failed to decode image", -1
  291. # Get existing references count
  292. existing_refs = self._get_reference_paths(printer_id)
  293. num_existing = len(existing_refs)
  294. # If all slots are full, rotate (remove oldest)
  295. if num_existing >= self.MAX_REFERENCES:
  296. self._rotate_references(printer_id)
  297. num_existing = self.MAX_REFERENCES - 1
  298. # Save to next available slot
  299. slot_index = num_existing
  300. reference_path = _get_calibration_dir() / f"printer_{printer_id}_ref_{slot_index}.jpg"
  301. write_success = cv2.imwrite(str(reference_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 95])
  302. if not write_success:
  303. logger.error("cv2.imwrite failed for %s", reference_path)
  304. return False, "Failed to save reference image", -1
  305. # Verify the file actually exists and has content
  306. if not reference_path.exists():
  307. logger.error("Reference image not found after save: %s", reference_path)
  308. return False, "Reference image not found after save", -1
  309. file_size = reference_path.stat().st_size
  310. if file_size < 1000: # JPEG should be at least 1KB
  311. logger.error("Reference image too small (%s bytes): %s", file_size, reference_path)
  312. reference_path.unlink() # Clean up invalid file
  313. return False, f"Reference image corrupted (only {file_size} bytes)", -1
  314. logger.info("Saved reference image: %s (%s bytes)", reference_path, file_size)
  315. # Save metadata
  316. metadata = self._load_metadata(printer_id)
  317. if "references" not in metadata:
  318. metadata["references"] = {}
  319. metadata["references"][str(slot_index)] = {
  320. "label": label or "",
  321. "timestamp": datetime.now().isoformat(),
  322. }
  323. self._save_metadata(printer_id, metadata)
  324. logger.info(
  325. f"Saved plate calibration reference {slot_index + 1}/{self.MAX_REFERENCES} for printer {printer_id}"
  326. )
  327. return True, f"Calibration saved ({slot_index + 1}/{self.MAX_REFERENCES} references)", slot_index
  328. except Exception as e:
  329. logger.exception("Error during plate calibration")
  330. # Don't expose exception details to user - log has full info
  331. error_type = type(e).__name__
  332. return False, f"Calibration error: {error_type}", -1
  333. def get_calibration_count(self, printer_id: int) -> int:
  334. """Get the number of calibration references for a printer."""
  335. return len(self._get_reference_paths(printer_id))
  336. def has_calibration(self, printer_id: int, plate_type: str | None = None) -> bool:
  337. """Check if a printer has any calibration reference images."""
  338. return len(self._get_reference_paths(printer_id)) > 0
  339. def analyze_frame(
  340. self, image_data: bytes, printer_id: int, plate_type: str | None = None, include_debug_image: bool = False
  341. ) -> PlateDetectionResult:
  342. """Analyze a camera frame to detect if the plate is empty.
  343. Compares the current frame to all calibration reference images and uses
  344. the best match (lowest difference) for the final result.
  345. Args:
  346. image_data: JPEG image data as bytes
  347. printer_id: Printer database ID (for reference lookup)
  348. plate_type: Unused - kept for API compatibility
  349. include_debug_image: If True, include annotated image in result
  350. Returns:
  351. PlateDetectionResult with analysis results
  352. """
  353. try:
  354. # Check for calibration
  355. reference_paths = self._get_reference_paths(printer_id)
  356. if not reference_paths:
  357. return PlateDetectionResult(
  358. is_empty=True, # Default to empty when not calibrated
  359. confidence=0.0,
  360. difference_percent=0.0,
  361. message="No calibration - please calibrate with empty plate first",
  362. needs_calibration=True,
  363. )
  364. # Decode current image
  365. nparr = np.frombuffer(image_data, np.uint8)
  366. current_frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
  367. if current_frame is None:
  368. return PlateDetectionResult(
  369. is_empty=True,
  370. confidence=0.0,
  371. difference_percent=0.0,
  372. message="Failed to decode current image",
  373. failure_reason="decode_failed",
  374. )
  375. # Extract ROI from current frame
  376. current_roi, x_start, y_start, roi_width, roi_height = self._extract_roi(current_frame)
  377. current_processed = self._preprocess_for_comparison(current_roi)
  378. # Compare against all references, find best match (lowest difference)
  379. best_difference_percent = float("inf")
  380. best_ref_idx = -1
  381. best_diff = None
  382. for idx, ref_path in enumerate(reference_paths):
  383. # Load reference image
  384. reference_frame = cv2.imread(str(ref_path), cv2.IMREAD_COLOR)
  385. if reference_frame is None:
  386. continue
  387. # Ensure same dimensions
  388. if current_frame.shape != reference_frame.shape:
  389. reference_frame = cv2.resize(reference_frame, (current_frame.shape[1], current_frame.shape[0]))
  390. # Extract ROI and preprocess
  391. reference_roi, _, _, _, _ = self._extract_roi(reference_frame)
  392. reference_processed = self._preprocess_for_comparison(reference_roi)
  393. # Calculate absolute difference
  394. diff = cv2.absdiff(current_processed, reference_processed)
  395. # Calculate mean difference as percentage
  396. mean_diff = np.mean(diff)
  397. difference_percent = (mean_diff / 255.0) * 100
  398. if difference_percent < best_difference_percent:
  399. best_difference_percent = difference_percent
  400. best_ref_idx = idx
  401. best_diff = diff
  402. if best_ref_idx == -1:
  403. return PlateDetectionResult(
  404. is_empty=True,
  405. confidence=0.0,
  406. difference_percent=0.0,
  407. message="Failed to load any reference images - please recalibrate",
  408. needs_calibration=True,
  409. )
  410. difference_percent = best_difference_percent
  411. # Determine if plate is empty (use best match)
  412. is_empty = difference_percent < self.difference_threshold
  413. # Calculate confidence
  414. if is_empty:
  415. # Higher confidence when very little difference
  416. confidence = 1.0 - min(1.0, difference_percent / self.difference_threshold)
  417. else:
  418. # Higher confidence when clearly different
  419. confidence = min(1.0, difference_percent / (self.difference_threshold * 2))
  420. # Generate message
  421. num_refs = len(reference_paths)
  422. if is_empty:
  423. message = (
  424. f"Plate appears empty (difference: {difference_percent:.1f}%, ref {best_ref_idx + 1}/{num_refs})"
  425. )
  426. else:
  427. message = f"Objects detected on plate (difference: {difference_percent:.1f}%, best ref {best_ref_idx + 1}/{num_refs})"
  428. # Generate debug image if requested
  429. debug_image = None
  430. if include_debug_image and best_diff is not None:
  431. debug_frame = current_frame.copy()
  432. # Draw ROI rectangle
  433. cv2.rectangle(
  434. debug_frame,
  435. (x_start, y_start),
  436. (x_start + roi_width, y_start + roi_height),
  437. (0, 255, 0),
  438. 2,
  439. )
  440. # Create colored difference overlay
  441. # Red = areas that are different from reference
  442. # Amplify diff for visibility (multiply by 3, cap at 255)
  443. diff_amplified = np.minimum(best_diff * 3, 255).astype(np.uint8)
  444. diff_colored = cv2.cvtColor(diff_amplified, cv2.COLOR_GRAY2BGR)
  445. diff_colored[:, :, 0] = 0 # Remove blue
  446. diff_colored[:, :, 1] = 0 # Remove green
  447. # Red channel has the diff
  448. # Overlay difference on ROI
  449. roi_overlay = debug_frame[y_start : y_start + roi_height, x_start : x_start + roi_width]
  450. cv2.addWeighted(diff_colored, 0.5, roi_overlay, 0.5, 0, roi_overlay)
  451. # Add status text
  452. status_text = "EMPTY" if is_empty else "OBJECTS DETECTED"
  453. color = (0, 255, 0) if is_empty else (0, 0, 255)
  454. cv2.putText(debug_frame, status_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, color, 2)
  455. cv2.putText(
  456. debug_frame,
  457. f"Diff: {difference_percent:.1f}% (ref {best_ref_idx + 1}/{num_refs})",
  458. (10, 60),
  459. cv2.FONT_HERSHEY_SIMPLEX,
  460. 0.7,
  461. color,
  462. 2,
  463. )
  464. cv2.putText(
  465. debug_frame,
  466. f"Confidence: {confidence:.0%}",
  467. (10, 90),
  468. cv2.FONT_HERSHEY_SIMPLEX,
  469. 0.7,
  470. color,
  471. 2,
  472. )
  473. # Encode debug image as JPEG
  474. _, buffer = cv2.imencode(".jpg", debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
  475. debug_image = buffer.tobytes()
  476. return PlateDetectionResult(
  477. is_empty=is_empty,
  478. confidence=confidence,
  479. difference_percent=difference_percent,
  480. message=message,
  481. debug_image=debug_image,
  482. )
  483. except Exception as e:
  484. logger.exception("Error analyzing frame for plate detection")
  485. return PlateDetectionResult(
  486. is_empty=True, # Default to empty on error (don't block prints)
  487. confidence=0.0,
  488. difference_percent=0.0,
  489. message=f"Analysis error: {e!s}",
  490. failure_reason="analysis_error",
  491. )
  492. async def capture_camera_image(
  493. printer_id: int,
  494. ip_address: str,
  495. access_code: str,
  496. model: str,
  497. external_camera_url: str | None = None,
  498. external_camera_type: str | None = None,
  499. use_external: bool = False,
  500. external_camera_snapshot_url: str | None = None,
  501. ) -> tuple[bytes | None, str]:
  502. """Capture an image from the printer camera.
  503. If there's an active camera stream, uses the buffered frame instead of
  504. creating a new connection (which would fail while stream is active).
  505. Returns:
  506. Tuple of (image_data, camera_source) or (None, error_message)
  507. """
  508. image_data: bytes | None = None
  509. camera_source = "unknown"
  510. # Try external camera first if requested and available
  511. if use_external and external_camera_url and external_camera_type:
  512. try:
  513. from backend.app.api.routes.camera import live_frame_for_capture
  514. from backend.app.services.external_camera import capture_frame
  515. # What this function's docstring already promised, but only the
  516. # built-in fallback below delivered: an external camera is
  517. # single-reader too, so capturing while a viewer watches fails
  518. # (#2707).
  519. defer, buffered = live_frame_for_capture(printer_id)
  520. if defer:
  521. if buffered:
  522. image_data = buffered
  523. camera_source = "external (buffered)"
  524. logger.debug("Using buffered external frame for printer %s", printer_id)
  525. else:
  526. image_data = await capture_frame(
  527. external_camera_url,
  528. external_camera_type,
  529. snapshot_url=external_camera_snapshot_url,
  530. )
  531. if image_data:
  532. camera_source = "external"
  533. logger.debug("Captured frame from external camera for printer %s", printer_id)
  534. except Exception as e:
  535. logger.warning("Failed to capture from external camera: %s", e)
  536. # Fall back to built-in camera
  537. if image_data is None:
  538. # First, check if there's an active stream with a buffered frame
  539. # This avoids blocking when camera viewer is open
  540. try:
  541. from backend.app.api.routes.camera import get_buffered_frame
  542. buffered = get_buffered_frame(printer_id)
  543. if buffered:
  544. image_data = buffered
  545. camera_source = "built-in (buffered)"
  546. logger.debug("Using buffered frame from active stream for printer %s", printer_id)
  547. except Exception as e:
  548. logger.debug("Could not get buffered frame: %s", e)
  549. # If no buffered frame, try to capture a new one
  550. if image_data is None:
  551. import tempfile
  552. from backend.app.services.camera import capture_camera_frame
  553. fd, tmp_name = tempfile.mkstemp(suffix=".jpg")
  554. os.close(fd)
  555. tmp_path = Path(tmp_name)
  556. tmp_path.chmod(0o600)
  557. try:
  558. success = await capture_camera_frame(ip_address, access_code, model, tmp_path, timeout=10)
  559. if success:
  560. with open(tmp_path, "rb") as f:
  561. image_data = f.read()
  562. camera_source = "built-in"
  563. logger.debug("Captured frame from built-in camera for printer %s", printer_id)
  564. finally:
  565. try:
  566. tmp_path.unlink()
  567. except OSError:
  568. pass # Best-effort cleanup of temporary camera capture file
  569. return image_data, camera_source
  570. async def check_plate_empty(
  571. printer_id: int,
  572. ip_address: str,
  573. access_code: str,
  574. model: str,
  575. plate_type: str | None = None,
  576. include_debug_image: bool = False,
  577. external_camera_url: str | None = None,
  578. external_camera_type: str | None = None,
  579. use_external: bool = False,
  580. roi: tuple[float, float, float, float] | None = None,
  581. external_camera_snapshot_url: str | None = None,
  582. ) -> PlateDetectionResult:
  583. """Check if the build plate is empty for a printer.
  584. Args:
  585. printer_id: Printer database ID
  586. ip_address: Printer IP address
  587. access_code: Printer access code
  588. model: Printer model string
  589. plate_type: Type of build plate for calibration lookup
  590. include_debug_image: If True, include annotated image in result
  591. external_camera_url: URL of external camera (if configured)
  592. external_camera_type: Type of external camera (mjpeg, rtsp, snapshot)
  593. use_external: If True, prefer external camera over built-in
  594. roi: Region of interest as (x%, y%, w%, h%) - percentages of image size
  595. Returns:
  596. PlateDetectionResult with analysis results
  597. """
  598. if not OPENCV_AVAILABLE:
  599. return PlateDetectionResult(
  600. is_empty=True,
  601. confidence=0.0,
  602. difference_percent=0.0,
  603. message="OpenCV not available - plate detection disabled",
  604. failure_reason="opencv_missing",
  605. )
  606. image_data, camera_source = await capture_camera_image(
  607. printer_id,
  608. ip_address,
  609. access_code,
  610. model,
  611. external_camera_url,
  612. external_camera_type,
  613. use_external,
  614. external_camera_snapshot_url=external_camera_snapshot_url,
  615. )
  616. if image_data is None:
  617. return PlateDetectionResult(
  618. is_empty=True, # Default to empty on error
  619. confidence=0.0,
  620. difference_percent=0.0,
  621. message="Failed to capture camera frame from any source",
  622. failure_reason="no_camera_frame",
  623. )
  624. # Analyze the captured frame
  625. detector = PlateDetector(roi=roi)
  626. result = detector.analyze_frame(image_data, printer_id, plate_type, include_debug_image)
  627. # Add camera source to message
  628. result.message = f"[{camera_source}] {result.message}"
  629. return result
  630. async def calibrate_plate(
  631. printer_id: int,
  632. ip_address: str,
  633. access_code: str,
  634. model: str,
  635. label: str | None = None,
  636. external_camera_url: str | None = None,
  637. external_camera_type: str | None = None,
  638. use_external: bool = False,
  639. external_camera_snapshot_url: str | None = None,
  640. ) -> tuple[bool, str, int]:
  641. """Calibrate plate detection by capturing a reference image of the empty plate.
  642. Args:
  643. printer_id: Printer database ID
  644. ip_address: Printer IP address
  645. access_code: Printer access code
  646. model: Printer model string
  647. label: Optional label for this reference (e.g., "High Temp Plate")
  648. external_camera_url: URL of external camera (if configured)
  649. external_camera_type: Type of external camera (mjpeg, rtsp, snapshot)
  650. use_external: If True, prefer external camera over built-in
  651. Returns:
  652. Tuple of (success, message, index)
  653. """
  654. if not OPENCV_AVAILABLE:
  655. return False, "OpenCV not available - plate detection disabled", -1
  656. image_data, camera_source = await capture_camera_image(
  657. printer_id,
  658. ip_address,
  659. access_code,
  660. model,
  661. external_camera_url,
  662. external_camera_type,
  663. use_external,
  664. external_camera_snapshot_url=external_camera_snapshot_url,
  665. )
  666. if image_data is None:
  667. return False, "Failed to capture camera frame for calibration", -1
  668. detector = PlateDetector()
  669. success, message, index = detector.calibrate(image_data, printer_id, label)
  670. if success:
  671. message = f"[{camera_source}] {message}"
  672. return success, message, index
  673. def get_calibration_status(printer_id: int, plate_type: str | None = None) -> dict:
  674. """Get calibration status for a printer.
  675. Returns:
  676. Dict with calibration info including reference count
  677. """
  678. if not OPENCV_AVAILABLE:
  679. return {
  680. "available": False,
  681. "calibrated": False,
  682. "reference_count": 0,
  683. "max_references": 5,
  684. "message": "OpenCV not available",
  685. }
  686. detector = PlateDetector()
  687. calibrated = detector.has_calibration(printer_id)
  688. ref_count = detector.get_calibration_count(printer_id)
  689. if calibrated:
  690. message = f"Calibrated with {ref_count}/{detector.MAX_REFERENCES} reference(s)"
  691. else:
  692. message = "Not calibrated - please calibrate with empty plate"
  693. return {
  694. "available": True,
  695. "calibrated": calibrated,
  696. "reference_count": ref_count,
  697. "max_references": detector.MAX_REFERENCES,
  698. "message": message,
  699. }
  700. def is_plate_detection_available() -> bool:
  701. """Check if plate detection feature is available (OpenCV installed)."""
  702. return OPENCV_AVAILABLE