Files
PlanarianScanner/test_tube_scanner/modules/capture_interface.py
T
2026-05-17 19:42:49 +02:00

426 lines
16 KiB
Python

"""
Interface abstraite de capture vidéo.
Définit le contrat que toutes les implémentations doivent respecter.
4 méthodes @abstractmethod à implémenter : open(), close(), capture_frame(), is_available()
Boucle de capture dans un thread daemon avec compensation de latence pour tenir les 5 fps
Callback set_frame_callback(fn) appelé à chaque frame avec (bytes, datetime)
save_frame() avec horodatage, start()/stop(), gestionnaire de contexte (with)
CaptureError exception dédiée
"""
import os
os.environ['OPENCV_LOG_LEVEL']="0"
os.environ['OPENCV_FFMPEG_LOGLEVEL']="0"
import cv2
import numpy as np
import abc
import time
import threading
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Callable, TYPE_CHECKING
from asgiref.sync import async_to_sync
from django.conf import settings
from modules.planarian_tracker import PlanarianTracker
from modules.planarian_metrics import ExperimentParams
from modules.tube_aligner import TubeAligner
if TYPE_CHECKING:
from .circular_crop import CircularCrop # Evite l'import circulaire au runtime
logger = logging.getLogger(__name__)
class CaptureError(Exception):
"""Exception levée lors d'une erreur de capture."""
pass
class VideoCaptureInterface(abc.ABC):
"""
Interface abstraite pour la capture d'images vidéo en JPEG.
Cadence cible : 5 images par seconde (configurable).
Les sous-classes doivent implémenter les méthodes abstraites
pour gérer le matériel spécifique.
"""
# Cadence par défaut en images par seconde
DEFAULT_FPS: float = 5.0
DEFAULT_TRACKER_CONFIG = dict(
tube_axis = settings.TRACKER_TUBE_AXIS,
min_area_px = settings.TRACKER_MIN_AREA,
max_area_ratio = settings.TRACKER_MAX_AREA_RATIO,
max_planarians = settings.TRACKER_MAX_PLANARIANS,
merge_kernel_size = settings.TRACKER_MERGE_KERNEL_SIZE,
min_contour_dist_px = settings.TRACKER_MIN_CONTOUR_DIST_PX,
)
def __init__(self, fps: float = DEFAULT_FPS, use_tracking: bool = False, display=None, parent=None, jpeg_quality=85):
"""
Initialise l'interface de capture.
:param fps: Cadence cible en images par seconde
"""
self._fps: float = fps
self.display = display
self.parent = parent
self.use_tracking = use_tracking
self.jpeg_quality = jpeg_quality
self._interval: float = 1.0 / fps # Intervalle en secondes entre chaque capture
self._running: bool = False # Indique si la capture est en cours
self._thread: Optional[threading.Thread] = None
self._frame_count: int = 0 # Compteur total d'images capturées
self._on_frame: Optional[Callable[[bytes, datetime], None]] = None # Callback image
self._circular_crop: Optional["CircularCrop"] = None # Recadrage circulaire optionnel
self._active_median = False
self._active_crop = False
self._error_occured = False
self._tracker = None
self._metrics = None
self._params = None
self._clientDB = self.parent.metricDB
# Tracker générique, pour simulation
self.on_test_well_change(**self.DEFAULT_TRACKER_CONFIG)
self._aligner = TubeAligner(
grbl_threshold_px = 20, # au-delà → correction GRBL
dead_zone_px = 5, # en-dessous → rien à faire
display = display,
)
self.align_detection = None # résultat du test
def on_test_well_change(self, **cfg):
try:
if self.use_tracking and cfg:
self._tracker = PlanarianTracker(**cfg)
logger.info(f"Create test with conf: {cfg}")
except Exception as e:
logger.error(f"Error creating tracker with conf {cfg}: {e}")
self._tracker = None
def _flush_current_well(self, uuid=""):
"""Stocke les résumés du puits courant — appelé avant tout changement."""
if not self._metrics or not self._params:
return
for pid, m in enumerate(self._metrics):
async_to_sync(self._clientDB.store_summary)(
summary = m.summary(),
experiment = self._params.experiment,
well = self._params.well,
planarian = pid,
uuid = uuid,
)
logger.warning(f'_flush_current_well: {self._params.well} planaire: {pid}')
self._metrics.clear()
def on_well_change(self, cfg, uuid="", draw_contours=False):
"""
Appelé par la CNC lors du changement de puits.
Réinitialise le fond appris et l'état inter-frame du tracker.
Construit les métriques aussi
"""
if not self.use_tracking or not cfg:
return
# 1. Sauvegarder les résumés du puits qu'on quitte
self._flush_current_well(uuid) # ← ferme le puits courant
# 2. Reconstruire pour le nouveau puit _metrics_list
params = cfg.to_params_dict()
self._params = ExperimentParams(params)
self._metrics = [self._params.build_metrics() for _ in range(self._params.planarian_count)]
self._tracker = PlanarianTracker(
tube_axis = self._params.tube_axis,
min_area_px = self._params.min_area_px,
max_area_ratio = self._params.max_area_ratio,
max_planarians = self._params.planarian_count,
merge_kernel_size = self._params.merge_kernel_size,
min_contour_dist_px = self._params.min_contour_dist_px,
draw_contours = draw_contours,
)
def on_scan_complete(self):
if self.use_tracking:
self._flush_current_well() # ← ferme le dernier puits
def set_draw_contours(self, draw: bool = True):
if self._tracker:
self._tracker.draw_contours = draw
# ------------------------------------------------------------------
# Méthodes abstraites — obligatoires dans les sous-classes
# ------------------------------------------------------------------
@abc.abstractmethod
def open(self) -> None:
"""
Ouvre et initialise le périphérique de capture.
Doit lever CaptureError si le périphérique n'est pas disponible.
"""
@abc.abstractmethod
def close(self) -> None:
"""
Libère le périphérique de capture et toutes les ressources associées.
"""
@abc.abstractmethod
def capture_frame(self) -> bytes:
"""
Capture une seule image et la retourne en JPEG brut.
:return: Données JPEG de l'image sous forme de bytes
:raises CaptureError: Si la capture échoue
"""
@abc.abstractmethod
def is_available(self) -> bool:
"""
Vérifie si le périphérique est prêt à capturer.
:return: True si le périphérique est opérationnel
"""
# ------------------------------------------------------------------
# Méthodes concrètes communes à toutes les implémentations
# ------------------------------------------------------------------
@property
def fps(self) -> float:
"""Cadence actuelle en images par seconde."""
return self._fps
@fps.setter
def fps(self, value: float) -> None:
"""Modifie la cadence de capture à la volée."""
if value <= 0:
raise ValueError("La cadence doit être un nombre positif")
self._fps = value
self._interval = 1.0 / value
logger.debug("Cadence mise à jour : %.1f fps (intervalle %.3f s)", value, self._interval)
@property
def frame_count(self) -> int:
"""Nombre total d'images capturées depuis le démarrage."""
return self._frame_count
def set_frame_callback(self, callback: Callable[[bytes, datetime], None]) -> None:
"""
Définit la fonction appelée à chaque nouvelle image capturée.
:param callback: Fonction(jpeg_bytes, timestamp) appelée pour chaque frame
"""
self._on_frame = callback
def start(self) -> None:
"""
Démarre la capture en continu dans un thread dédié.
Appelle open() si le périphérique n'est pas encore disponible.
"""
if self._running:
logger.warning("La capture est déjà en cours")
return
if not self.is_available():
logger.info("Ouverture du périphérique avant démarrage")
self.open()
self._running = True
self._frame_count = 0
self._thread = threading.Thread(
target=self._capture_loop,
name=f"{self.__class__.__name__}-capture",
daemon=True, # Thread démon : s'arrête avec le processus principal
)
self._thread.start()
logger.info("%s : capture démarrée à %.1f fps", self.__class__.__name__, self._fps)
def stop(self) -> None:
"""
Arrête la capture et attend la fin du thread.
Appelle close() pour libérer les ressources.
"""
if not self._running:
return
self._running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5.0) # Attente max 5 secondes
self.close()
logger.info(
"%s : capture arrêtée — %d images capturées",
self.__class__.__name__,
self._frame_count,
)
def set_circular_crop(self, crop: Optional["CircularCrop"]) -> None:
"""
Active ou désactive le recadrage circulaire appliqué à chaque frame.
Lorsqu'un CircularCrop est défini, chaque appel à capture_frame()
passe automatiquement par process_frame() avant d'être transmis
au callback ou sauvegardé.
:param crop: Instance CircularCrop configurée, ou None pour désactiver
"""
self._circular_crop = crop
if crop is not None:
self._active_crop = True
msg = f"{self.__class__.__name__}: recadrage circulaire activé (R={crop.radius}, stratégie={crop.strategy.name})"
else:
self._active_crop = False
msg= f"{self.__class__.__name__}: recadrage circulaire désactivé"
logger.info(msg)
self.display(state='circular_crop', msg=msg)
def process_frame(self, jpeg_bytes: bytes) -> bytes:
"""
Applique le post-traitement configuré sur une image brute.
Actuellement : recadrage circulaire si un CircularCrop est défini.
Peut être surchargé dans une sous-classe pour des traitements spécifiques.
:param jpeg_bytes: Image JPEG brute issue du capteur
:return: Image traitée (JPEG ou PNG selon la stratégie)
"""
metrics = {"detected": False}
if self._circular_crop is not None:
jpeg = self._circular_crop.process(jpeg_bytes)
nparr = np.frombuffer(jpeg, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if frame is None:
return jpeg, metrics
try:
# Mode debug
if self._aligner.debug:
self.align_detection = self._aligner.detect_tube(frame)
annotated = self.align_detection.get('frame_annotated')
frame = annotated if annotated is not None else frame
##
# mode tracking
if self.use_tracking:
ts = datetime.now(timezone.utc).timestamp()
frame, metrics = self._tracker.process(frame, ts)
##
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
if ok:
jpeg = buf.tobytes()
return jpeg, metrics
except Exception as e:
logger.error(e)
return jpeg_bytes, metrics
def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path:
"""
Enregistre une image JPEG sur le disque avec un horodatage.
:param jpeg_bytes: Données brutes JPEG
:param directory: Dossier de destination
:param prefix: Préfixe du nom de fichier
:return: Chemin du fichier créé
"""
dest = Path(directory)
dest.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
filepath = dest / f"{prefix}_{timestamp}.jpg"
filepath.write_bytes(jpeg_bytes)
logger.debug("Image sauvegardée : %s (%d octets)", filepath, len(jpeg_bytes))
return filepath
def __enter__(self) -> "VideoCaptureInterface":
"""Permet l'utilisation avec le gestionnaire de contexte 'with'."""
self.open()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""Ferme proprement le périphérique à la sortie du bloc 'with'."""
self.close()
def __repr__(self) -> str:
status = "actif" if self._running else "arrêté"
return f"<{self.__class__.__name__} fps={self._fps} status={status}>"
# ------------------------------------------------------------------
# tracer médianes
# ------------------------------------------------------------------
def display_median(self, jpeg):
if self._active_median:
nparr = np.frombuffer(jpeg, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
height, width = frame.shape[:2]
center_x = width // 2
center_y = height // 2
cv2.line(frame, (center_x, 0), (center_x, height), (0, 255, 0), 1)
cv2.line(frame, (0, center_y), (width, center_y), (0, 255, 0), 1)
cv2.circle(frame, (center_x, center_y), 2, (0, 0, 255), -1)
_, buffer = cv2.imencode('.jpg', frame)
jpeg_bytes = buffer.tobytes()
return jpeg_bytes
return jpeg
# ------------------------------------------------------------------
# Boucle interne de capture (privée)
# ------------------------------------------------------------------
def _capture_loop(self) -> None:
"""
Boucle principale de capture tournant dans le thread dédié.
Respecte la cadence cible et appelle le callback si défini.
"""
while self._running:
loop_start = time.monotonic()
try:
jpeg = self.capture_frame()
jpeg = self.display_median(jpeg)
##
jpeg, metrics = self.process_frame(jpeg) # Recadrage circulaire si configuré
self._frame_count += 1
ts = datetime.now(timezone.utc)
if self._on_frame:
try:
self._on_frame(jpeg, ts, metrics, self._frame_count)
except Exception as cb_err: # noqa: BLE001
logger.error("Erreur dans le callback image : %s", cb_err)
except CaptureError as err:
logger.error("Échec de capture (#%d) : %s", self._frame_count, err)
self._error_occured = True
# Compensation du temps d'exécution pour tenir la cadence
elapsed = time.monotonic() - loop_start
sleep_time = self._interval - elapsed
if sleep_time > 0:
time.sleep(sleep_time)
else:
logger.debug(
"Cadence non tenue : %.3f s de retard (traitement=%.3f s)",
-sleep_time,
elapsed,
)