diff --git a/etc/requirements.txt b/etc/requirements.txt index c3abd26..831936a 100644 --- a/etc/requirements.txt +++ b/etc/requirements.txt @@ -19,4 +19,5 @@ opencv-python-headless mysqlclient psycopg2 pyserial +scipy diff --git a/test_tube_scanner/home/settings.py b/test_tube_scanner/home/settings.py index a3ebb59..6ae8f09 100644 --- a/test_tube_scanner/home/settings.py +++ b/test_tube_scanner/home/settings.py @@ -385,8 +385,8 @@ DATETIME_FORMAT = '%d-%m-%Y-%m %H:%M:%S' #=========================== # default configuration # -#=========================== # rpicam 4056x3040 2028x1080 2028x1520 +#=========================== EXPORTS_LOCAL_PATH = config("EXPORTS_LOCAL_PATH") EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH") @@ -394,11 +394,12 @@ EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH") EXPORT_DESTINATIONS = ["local", "remote"] #EXPORT_DESTINATIONS = ["remote"] # only remote -TEST_VIDEOFILE = False -TRACKING = True TRACKER_TUBE_AXIS = "vertical" -TRACKER_MIN_AREA = 200 +TRACKER_MIN_AREA = 20 # surface min planaire +TRACKER_MAX_AREA_RATIO = 0.05 # 5% de la frame = surface max acceptable +TRACKER_MAX_PLANARIANS = 3 + CALIBRATION_AUTO_DURATION = 45.0 CALIBRATION_AUTO_TIMEOUT = 2.5 diff --git a/test_tube_scanner/home/urls.py b/test_tube_scanner/home/urls.py index 7729217..e406c80 100644 --- a/test_tube_scanner/home/urls.py +++ b/test_tube_scanner/home/urls.py @@ -38,6 +38,7 @@ urlpatterns += i18n_patterns( path('', RedirectView.as_view(url='/scanner/calibration/', permanent=True), name='redirect_to_mainboard'), path('scanner/', include('scanner.urls', namespace='scanner')), + path('planarian/', include('planarian.urls', namespace='planarian')), ) if settings.DEBUG: diff --git a/test_tube_scanner/modules/capture_interface.py b/test_tube_scanner/modules/capture_interface.py index 9d653c5..5592f0c 100644 --- a/test_tube_scanner/modules/capture_interface.py +++ b/test_tube_scanner/modules/capture_interface.py @@ -49,16 +49,16 @@ class VideoCaptureInterface(abc.ABC): # Cadence par défaut en images par seconde DEFAULT_FPS: float = 5.0 - def __init__(self, fps: float = DEFAULT_FPS, use_tracking: bool = False, display=None, parent=None): + 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.use_tracking = use_tracking self.display = display self.parent = parent + 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 @@ -69,10 +69,15 @@ class VideoCaptureInterface(abc.ABC): self._active_crop = False self._error_occured = False - self._tracker = PlanarianTracker( - tube_axis = settings.TRACKER_TUBE_AXIS, - min_area_px = settings.TRACKER_MIN_AREA, - ) + self._tracker = None + if use_tracking: + self._tracker = PlanarianTracker( + 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, + ) + self._aligner = TubeAligner( grbl_threshold_px = 20, # au-delà → correction GRBL dead_zone_px = 5, # en-dessous → rien à faire @@ -80,12 +85,14 @@ class VideoCaptureInterface(abc.ABC): ) self.align_detection = None # résultat du test + def on_well_change(self): """ Appelé par le CNC lors du changement de puits. Réinitialise le fond appris et l'état inter-frame du tracker. """ - self._tracker.reset() + if self._tracker: + self._tracker.reset() # ------------------------------------------------------------------ @@ -233,24 +240,26 @@ class VideoCaptureInterface(abc.ABC): 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 racking + if self._tracker is not None: + 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 - # 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 racking - if self.use_tracking: - ts = datetime.now(timezone.utc).timestamp() - frame, metrics = self._tracker.process(frame, ts) + except Exception as e: + logger.error(e) - ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) - if ok: - jpeg = buf.tobytes() - - return jpeg, metrics - return jpeg_bytes, metrics def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path: @@ -325,16 +334,12 @@ class VideoCaptureInterface(abc.ABC): ## jpeg, metrics = self.process_frame(jpeg) # Recadrage circulaire si configuré - metrics.update({ - "count": self._frame_count, - }) - self._frame_count += 1 ts = datetime.now(timezone.utc) if self._on_frame: try: - self._on_frame(jpeg, ts, metrics) + 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) diff --git a/test_tube_scanner/modules/picamera2_capture.py b/test_tube_scanner/modules/picamera2_capture.py index b4a3847..f9888c2 100644 --- a/test_tube_scanner/modules/picamera2_capture.py +++ b/test_tube_scanner/modules/picamera2_capture.py @@ -59,7 +59,7 @@ class PiCamera2Capture(VideoCaptureInterface): :param use_video_config: True = VideoConfiguration (flux continu, basse latence) False = StillConfiguration (haute résolution, plus lent) """ - super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent) + super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent, jpeg_quality=jpeg_quality) self._width: int = width self._height: int = height self._jpeg_quality: int = jpeg_quality diff --git a/test_tube_scanner/modules/planarian_metrics.py b/test_tube_scanner/modules/planarian_metrics.py index ca16e75..24b9961 100644 --- a/test_tube_scanner/modules/planarian_metrics.py +++ b/test_tube_scanner/modules/planarian_metrics.py @@ -30,6 +30,7 @@ import time from datetime import datetime, timezone from typing import Optional +from modules.reductstore import ReductStore logger = logging.getLogger(__name__) @@ -468,6 +469,7 @@ class ReductStoreClient: token : token d'authentification (vide si pas d'auth) bucket : nom du bucket cible """ + self.url = url self.token = token self.bucket_name = bucket diff --git a/test_tube_scanner/modules/planarian_tracker.py b/test_tube_scanner/modules/planarian_tracker.py index 2f9cedb..2927bc3 100644 --- a/test_tube_scanner/modules/planarian_tracker.py +++ b/test_tube_scanner/modules/planarian_tracker.py @@ -1,36 +1,208 @@ -# modules/planarian_tracker.py -''' -Created on 16 avr. 2026 +""" +modules/planarian_tracker.py +Détection et suivi multi-individus de planaires dans un tube. +Supporte de 1 à MAX_PLANARIANS planaires par tube. + +Etat inter-frame indépendant par individu : position, timestamp, compteur de perte (lost), flag active. +Quand un individu n'est pas détecté pendant MAX_LOST_FRAMES (5) frames consécutives, il est marqué perdu et son slot se libère. + +Algorithme hongrois (scipy.optimize.linear_sum_assignment) dans _hungarian_assign() + — construit une matrice de coût distance euclidienne entre les slots actifs et les nouvelles détections, puis trouve l'association de coût minimal. +Une association est rejetée si la distance dépasse MAX_ASSOC_DIST_PX (80px) + — évite les sauts aberrants entre planaires proches. + + +Stratégie : + - Soustraction de fond MOG2 (léger sur Raspberry Pi 4) + - Détection de tous les contours valides (surface >= min_area_px) + - Association frame-à-frame par distance euclidienne minimale + via algorithme hongrois (scipy.optimize.linear_sum_assignment) + - Un état inter-frame indépendant par individu (PlanarianState) + - Retourne une liste de résultats, un par individu suivi: champ planarian_id (index 0-based). + +Created on 25 avr. 2026 @author: denis -''' +""" import cv2 -import logging import numpy as np -logger = logging.getLogger(__name__) +from scipy.optimize import linear_sum_assignment +# Nombre maximum de planaires suivis simultanément par tube +MAX_PLANARIANS = 10 + +# Distance maximale en pixels entre deux positions consécutives +# pour qu'une association soit acceptée (évite les sauts aberrants) +MAX_ASSOC_DIST_PX = 80 + +# Couleurs d'annotation par individu (BGR) +# Cycle automatique si plus de planaires que de couleurs +INDIVIDUAL_COLORS = [ + (255, 255, 0), # cyan + ( 0, 165, 255), # orange + (255, 0, 255), # magenta + ( 0, 255, 255), # jaune + (128, 0, 255), # violet + ( 0, 255, 128), # vert clair + (255, 128, 0), # bleu clair + ( 0, 128, 255), # orange foncé + (128, 255, 0), # vert-jaune + (255, 0, 128), # rose +] + +# Couleur du contour principal (individu le plus grand) +COLOR_LARGEST = (255, 255, 0) # cyan +COLOR_OTHER = ( 0, 255, 0) # vert +COLOR_CENTER = ( 0, 0, 255) # rouge + + +# --------------------------------------------------------------------------- +# État inter-frame d'un individu +# --------------------------------------------------------------------------- + +class PlanarianState: + """ + Mémorise la position et le timestamp de la dernière détection + pour un planaire individuel. + + Un PlanarianState par slot (index 0 à max_planarians-1). + Quand un slot n'est pas associé à un contour sur plusieurs frames + consécutives, il est marqué comme perdu (lost). + """ + + # Nombre de frames sans détection avant de considérer l'individu perdu + MAX_LOST_FRAMES = 5 + + def __init__(self, idx: int): + """ + Args: + idx : index de l'individu (0-based) + """ + self.idx = idx + self.cx = None + self.cy = None + self.ts = None + self.lost = 0 # compteur de frames sans détection + self.active = False # vrai si l'individu a été détecté au moins une fois + + def update(self, cx: int, cy: int, ts: float): + """ + Met à jour la position suite à une association réussie. + + Args: + cx, cy : position du centre de masse en pixels + ts : timestamp de la frame + """ + self.cx = cx + self.cy = cy + self.ts = ts + self.lost = 0 + self.active = True + + def mark_lost(self): + """Incrémente le compteur de perte — appelé quand aucun contour n'est associé.""" + self.lost += 1 + + @property + def is_lost(self) -> bool: + """Retourne True si l'individu est considéré perdu (trop de frames sans détection).""" + return self.lost >= self.MAX_LOST_FRAMES + + def compute_speed(self, cx: int, cy: int, ts: float, tube_axis: str) -> tuple: + """ + Calcule la vitesse instantanée depuis la position précédente. + + Args: + cx, cy : position courante en pixels + ts : timestamp courant + tube_axis : "vertical" ou "horizontal" + + Returns: + tuple (speed_px_s, axial_speed) ou (0.0, 0.0) si état vide + """ + if self.cx is None or self.ts is None: + return 0.0, 0.0 + + dt = ts - self.ts + if dt <= 0: + return 0.0, 0.0 + + dx = cx - self.cx + dy = cy - self.cy + speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt) + axial_speed = float((dy / dt) if tube_axis == "vertical" else (dx / dt)) + + return speed_px_s, axial_speed + + def reset(self): + """Réinitialise l'état de cet individu.""" + self.cx = None + self.cy = None + self.ts = None + self.lost = 0 + self.active = False + + +# --------------------------------------------------------------------------- +# Tracker multi-individus +# --------------------------------------------------------------------------- class PlanarianTracker: """ - Détection et suivi d'une planaire dans un tube. + Détection et suivi multi-individus de planaires dans un tube. + Instancié une fois par caméra active, réutilisé frame à frame. Utilise la soustraction de fond MOG2 — léger sur Raspberry Pi 4. + Association frame-à-frame par algorithme hongrois (distance euclidienne). + + Usage : + tracker = PlanarianTracker(tube_axis="vertical", max_planarians=3) + while capturing: + frame_out, results = tracker.process(frame, ts) + # results : liste de dicts, un par individu détecté + for r in results: + metrics.update(r, planarian_id=r["planarian_id"]) """ - def __init__(self, tube_axis: str = "vertical", min_area_px: int = 20): - # Axe du tube : "vertical" (cy) ou "horizontal" (cx) - self.tube_axis = tube_axis - self.min_area_px = min_area_px + # Nombre de frames d'initialisation MOG2 ignorées (fond non appris) + WARMUP_FRAMES = 10 - # Etat inter-frame - self._prev_cx = None - self._prev_cy = None - self._prev_ts = None + def __init__( + self, + tube_axis: str = "vertical", + min_area_px: int = 20, + max_area_ratio: float = 0.10, + max_planarians: int = 1, + ): + """ + Args: + tube_axis : axe principal du tube — "vertical" (cy) ou "horizontal" (cx) + min_area_px : surface minimale d'un contour pour être considéré valide (px²) + max_area_ratio : surface maximale d'un contour en fraction de la frame (défaut 10%) + filtre les faux positifs du fond non encore appris par MOG2 + max_planarians : nombre maximum de planaires à suivre simultanément (1-10) + """ + self.tube_axis = tube_axis + self.min_area_px = min_area_px + self.max_area_ratio = max_area_ratio + self.max_planarians = max(1, min(max_planarians, MAX_PLANARIANS)) + + # Un état inter-frame par slot individu + self._states = [PlanarianState(i) for i in range(self.max_planarians)] # Soustracteur de fond adaptatif MOG2 - self._bg_sub = cv2.createBackgroundSubtractorMOG2( + self._bg_sub = self._make_bg_sub() + + # Compteur de frames d'initialisation — MOG2 retourne du bruit + # pendant les premières WARMUP_FRAMES frames + self._warmup_count = 0 + + @staticmethod + def _make_bg_sub(): + """Crée et retourne un soustracteur de fond MOG2.""" + return cv2.createBackgroundSubtractorMOG2( history = 50, varThreshold = 25, detectShadows= False, @@ -38,205 +210,283 @@ class PlanarianTracker: def reset(self): """ - Réinitialise l'état inter-frame — appeler lors du changement de puits. + Réinitialise l'état inter-frame complet. + À appeler lors du changement de puits. """ - self._prev_cx = None - self._prev_cy = None - self._prev_ts = None - # Réinitialise le fond appris - self._bg_sub = cv2.createBackgroundSubtractorMOG2( - history = 50, - varThreshold = 25, - detectShadows= False, - ) + for s in self._states: + s.reset() + self._bg_sub = self._make_bg_sub() + self._warmup_count = 0 - ''' - def process(self, frame: np.ndarray, ts: float) -> dict: - """ - Analyse une frame décodée numpy. - Retourne un dict de métriques attachable aux labels ReductStore. - - :param frame: Frame BGR décodée (numpy array) - :param ts: Timestamp epoch secondes (float) - :return: dict métriques - """ - result = self._empty_result(ts) - - gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - fg_mask = self._bg_sub.apply(gray) - - # Nettoyage morphologique du masque - kernel = np.ones((3, 3), np.uint8) - fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel) - fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel) - - contours, _ = cv2.findContours( - fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE - ) - - if not contours: - self._update_prev(None, None, ts) - return result - - # Plus grand contour = planaire - largest = max(contours, key=cv2.contourArea) - area = cv2.contourArea(largest) - - if area < self.min_area_px: - self._update_prev(None, None, ts) - return result - - # Centre de masse - M = cv2.moments(largest) - if M["m00"] == 0: - return result - - cx = int(M["m10"] / M["m00"]) - cy = int(M["m01"] / M["m00"]) - h, w = frame.shape[:2] - - # Position normalisée sur l'axe du tube (0.0 → 1.0) - axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w) - - # Vitesse calculée entre frames - speed_px_s = None - axial_speed = None - - if self._prev_cx is not None and self._prev_ts is not None: - dt = ts - self._prev_ts - if dt > 0: - dx = cx - self._prev_cx - dy = cy - self._prev_cy - speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt) - # Vitesse signée sur l'axe du tube - # + = vers bas/droite, - = vers haut/gauche - axial_speed = float((dy / dt) if self.tube_axis == "vertical" else (dx / dt)) - - result.update({ - "detected" : True, - "cx" : cx, - "cy" : cy, - "area_px" : int(area), - "speed_px_s" : round(speed_px_s, 3) if speed_px_s is not None else 0.0, - "axial_speed" : round(axial_speed, 3) if axial_speed is not None else 0.0, - "axial_pos" : round(axial_pos, 4), - }) - - self._update_prev(cx, cy, ts) - return result - ''' - - def process(self, frame: np.ndarray, ts: float) -> tuple[np.ndarray, dict]: - """ - Analyse une frame et dessine les contours détectés directement sur l'image. - Retourne (frame_annotée, métriques). - - Contours fins Vert (0,255,0) Tous les contours valides détectés - Contour épais Cyan (255,255,0) Planaire principale (plus grand contour) - Croix + cercle Rouge (0,0,255) Centre de masse exact - Texte Blanc Vitesse px/s + position axiale normalisée - """ - result = self._empty_result(ts) - frame_out = frame.copy() # copie pour ne pas modifier l'original - - gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - fg_mask = self._bg_sub.apply(gray) - - kernel = np.ones((3, 3), np.uint8) - fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel) - fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel) - - contours, _ = cv2.findContours( - fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE - ) - - if not contours: - self._update_prev(None, None, ts) - return frame_out, result - - # Filtre les contours significatifs - valid_contours = [c for c in contours if cv2.contourArea(c) >= self.min_area_px] - - if not valid_contours: - self._update_prev(None, None, ts) - return frame_out, result - - # Dessine tous les contours valides en vert fin - cv2.drawContours(frame_out, valid_contours, -1, (0, 255, 0), 1) - - # Plus grand contour = planaire principale - largest = max(valid_contours, key=cv2.contourArea) - area = cv2.contourArea(largest) - - # Contour principal en cyan plus épais - cv2.drawContours(frame_out, [largest], -1, (255, 255, 0), 2) - - M = cv2.moments(largest) - if M["m00"] == 0: - return frame_out, result - - cx = int(M["m10"] / M["m00"]) - cy = int(M["m01"] / M["m00"]) - h, w = frame.shape[:2] - - axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w) - speed_px_s = None - axial_speed = None - - if self._prev_cx is not None and self._prev_ts is not None: - dt = ts - self._prev_ts - if dt > 0: - dx = cx - self._prev_cx - dy = cy - self._prev_cy - speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt) - axial_speed = float((dy / dt) if self.tube_axis == "vertical" else (dx / dt)) - - # Croix sur le centre de masse - cross_size = 8 - cv2.line(frame_out, (cx - cross_size, cy), (cx + cross_size, cy), (0, 0, 255), 1) - cv2.line(frame_out, (cx, cy - cross_size), (cx, cy + cross_size), (0, 0, 255), 1) - - # Cercle centré sur la planaire - cv2.circle(frame_out, (cx, cy), 12, (0, 0, 255), 1) - - # Texte vitesse + position axiale - label = f"v={speed_px_s:.1f}px/s ax={axial_pos:.2f}" if speed_px_s is not None else f"ax={axial_pos:.2f}" - cv2.putText( - frame_out, label, - (max(cx - 60, 0), max(cy - 18, 12)), - cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1, cv2.LINE_AA, - ) - - result.update({ - "detected" : True, - "cx" : cx, - "cy" : cy, - "area_px" : int(area), - "speed_px_s" : round(speed_px_s, 3) if speed_px_s is not None else 0.0, - "axial_speed" : round(axial_speed, 3) if axial_speed is not None else 0.0, - "axial_pos" : round(axial_pos, 4), - }) - - self._update_prev(cx, cy, ts) - return frame_out, result - - # ------------------------------------------------------------------ # - def _empty_result(self, ts: float) -> dict: - return { - "timestamp" : ts, - "detected" : False, - "cx" : 0, - "cy" : 0, - "area_px" : 0, - "speed_px_s" : 0.0, - "axial_speed": 0.0, - "axial_pos" : 0.0, - } + # Interface principale + # ------------------------------------------------------------------ # - def _update_prev(self, cx, cy, ts): - self._prev_cx = cx - self._prev_cy = cy - self._prev_ts = ts - + def process(self, frame: np.ndarray, ts: float) -> tuple: + """ + Analyse une frame, associe les contours aux individus connus, + dessine les annotations et retourne les métriques. + + Args: + frame : image BGR (numpy array) + ts : timestamp de la frame (float, secondes epoch) + + Returns: + tuple (frame_annotée, results) + + frame_annotée : copie BGR avec contours, croix et textes + results : liste de dicts — un dict par planaire actif détecté. + Chaque dict contient : + planarian_id int index de l'individu (0-based) + detected bool True si détecté cette frame + cx, cy int centre de masse en pixels + area_px int surface du contour (px²) + speed_px_s float vitesse totale (px/s) + axial_speed float vitesse axiale (px/s) + axial_pos float position axiale normalisée (0-1) + timestamp float ts de la frame + """ + frame_out = frame.copy() + h, w = frame.shape[:2] + + # --- Extraction du premier plan --- + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + fg_mask = self._bg_sub.apply(gray) + + kernel = np.ones((3, 3), np.uint8) + fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel) + fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel) + + # Warmup MOG2 : les premières WARMUP_FRAMES frames retournent du bruit + # (fond non encore appris) — on les alimente mais on ne détecte rien + self._warmup_count += 1 + if self._warmup_count <= self.WARMUP_FRAMES: + return frame_out, [] + + contours, _ = cv2.findContours( + fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + + # Surface maximale admissible : fraction de la frame + # Filtre les faux positifs du fond (contours couvrant toute l'image) + max_area_px = h * w * self.max_area_ratio + + # Filtrage : surface min ET surface max, triés par surface décroissante + valid = sorted( + [c for c in contours + if self.min_area_px <= cv2.contourArea(c) <= max_area_px], + key=cv2.contourArea, + reverse=True, + ) + + # Limiter au nombre maximum de planaires attendus + valid = valid[:self.max_planarians] + + # --- Calcul des centres de masse des contours détectés --- + detections = [] # liste de (cx, cy, area, contour) + for c in valid: + M = cv2.moments(c) + if M["m00"] == 0: + continue + cx = int(M["m10"] / M["m00"]) + cy = int(M["m01"] / M["m00"]) + area = cv2.contourArea(c) + detections.append((cx, cy, int(area), c)) + + # --- Association hongroise détections → slots individus --- + assignments = self._hungarian_assign(detections) + + # --- Mise à jour des états et construction des résultats --- + results = [] + + for slot_idx, det_idx in assignments.items(): + state = self._states[slot_idx] + + if det_idx is None: + # Aucune détection associée à ce slot + state.mark_lost() + if state.active and not state.is_lost: + # L'individu était suivi : on retourne un résultat "perdu" + results.append(self._lost_result(slot_idx, ts)) + continue + + cx, cy, area, contour = detections[det_idx] + + # Calcul de la vitesse depuis la position précédente + speed_px_s, axial_speed = state.compute_speed(cx, cy, ts, self.tube_axis) + + axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w) + + # Mise à jour de l'état + state.update(cx, cy, ts) + + # Annotation visuelle + color = INDIVIDUAL_COLORS[slot_idx % len(INDIVIDUAL_COLORS)] + cv2.drawContours(frame_out, [contour], -1, color, 2) + self._draw_center(frame_out, cx, cy, slot_idx, speed_px_s, axial_pos, color) + + results.append({ + "planarian_id": slot_idx, + "detected": True, + "cx": cx, + "cy": cy, + "area_px": area, + "speed_px_s": round(speed_px_s, 3), + "axial_speed": round(axial_speed, 3), + "axial_pos": round(axial_pos, 4), + "timestamp": ts, + }) + + # Marquer les slots non présents dans les assignments comme perdus + assigned_slots = set(assignments.keys()) + for state in self._states: + if state.idx not in assigned_slots: + state.mark_lost() + + return frame_out, results + + # ------------------------------------------------------------------ # + # Association hongroise + # ------------------------------------------------------------------ # + + def _hungarian_assign(self, detections: list) -> dict: + """ + Associe les détections courantes aux slots individus connus + via l'algorithme hongrois (coût = distance euclidienne). + + Contrainte : une association n'est acceptée que si la distance + est inférieure à MAX_ASSOC_DIST_PX (évite les sauts aberrants). + + Args: + detections : liste de (cx, cy, area, contour) + + Returns: + dict {slot_idx: det_idx | None} + det_idx = None si aucune détection assignée à ce slot + """ + n_slots = self.max_planarians + n_dets = len(detections) + + if n_dets == 0: + # Aucune détection : tous les slots sont "perdus" + return {i: None for i in range(n_slots)} + + # Slots actifs (déjà vus au moins une fois et non perdus) + active_slots = [s for s in self._states if s.active and not s.is_lost] + + if not active_slots: + # Première frame ou tous perdus : attribution séquentielle simple + assignment = {} + for i in range(n_slots): + assignment[i] = i if i < n_dets else None + return assignment + + # --- Construction de la matrice de coût (distance euclidienne) --- + cost = np.full((len(active_slots), n_dets), fill_value=1e6) + + for si, state in enumerate(active_slots): + for di, (cx, cy, _, _) in enumerate(detections): + dist = np.sqrt((cx - state.cx)**2 + (cy - state.cy)**2) + cost[si, di] = dist + + # --- Algorithme hongrois --- + row_ind, col_ind = linear_sum_assignment(cost) + + # Construire le dict d'association + assignment = {i: None for i in range(n_slots)} + + assigned_dets = set() + for ri, ci in zip(row_ind, col_ind): + if cost[ri, ci] <= MAX_ASSOC_DIST_PX: + slot_idx = active_slots[ri].idx + assignment[slot_idx] = ci + assigned_dets.add(ci) + + # --- Nouvelles détections non assignées → slots inactifs libres --- + free_slots = [s for s in self._states if not s.active or s.is_lost] + new_dets = [di for di in range(n_dets) if di not in assigned_dets] + + for state, det_idx in zip(free_slots, new_dets): + assignment[state.idx] = det_idx + + return assignment + + # ------------------------------------------------------------------ # + # Dessin des annotations + # ------------------------------------------------------------------ # + + def _draw_center( + self, + frame: np.ndarray, + cx: int, + cy: int, + idx: int, + speed_px_s: float, + axial_pos: float, + color: tuple, + ): + """ + Dessine la croix, le cercle et le label de vitesse/position + pour un individu. + + Args: + frame : image à annoter (en place) + cx, cy : centre de masse en pixels + idx : index de l'individu + speed_px_s : vitesse en px/s + axial_pos : position axiale normalisée + color : couleur BGR de l'individu + """ + cross = 8 + cv2.line(frame, (cx - cross, cy), (cx + cross, cy), COLOR_CENTER, 1) + cv2.line(frame, (cx, cy - cross), (cx, cy + cross), COLOR_CENTER, 1) + cv2.circle(frame, (cx, cy), 12, color, 1) + + # Badge numéro individu + cv2.circle(frame, (cx + 14, cy - 14), 8, color, -1) + cv2.putText( + frame, str(idx), + (cx + 10, cy - 10), + cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 0, 0), 1, cv2.LINE_AA, + ) + + # Texte vitesse + position axiale + label = ( + f"#{idx} v={speed_px_s:.1f}px/s ax={axial_pos:.2f}" + if speed_px_s > 0 + else f"#{idx} ax={axial_pos:.2f}" + ) + cv2.putText( + frame, label, + (max(cx - 60, 0), max(cy - 22, 12)), + cv2.FONT_HERSHEY_SIMPLEX, 0.35, (255, 255, 255), 1, cv2.LINE_AA, + ) + + # ------------------------------------------------------------------ # + # Résultats vides / perdus + # ------------------------------------------------------------------ # + + def _lost_result(self, planarian_id: int, ts: float) -> dict: + """ + Retourne un résultat pour un individu temporairement non détecté. + + Args: + planarian_id : index de l'individu + ts : timestamp de la frame courante + + Returns: + dict avec detected=False et les dernières coordonnées connues + """ + state = self._states[planarian_id] + return { + "planarian_id": planarian_id, + "detected": False, + "cx": state.cx or 0, + "cy": state.cy or 0, + "area_px": 0, + "speed_px_s": 0.0, + "axial_speed": 0.0, + "axial_pos": 0.0, + "timestamp": ts, + } \ No newline at end of file diff --git a/test_tube_scanner/modules/planarian_tracker2.py b/test_tube_scanner/modules/planarian_tracker2.py new file mode 100644 index 0000000..f6937e9 --- /dev/null +++ b/test_tube_scanner/modules/planarian_tracker2.py @@ -0,0 +1,166 @@ +# modules/planarian_tracker.py +''' +Created on 16 avr. 2026 + +@author: denis +''' + +import cv2 +import logging +import numpy as np + +logger = logging.getLogger(__name__) + + +class PlanarianTracker: + """ + Détection et suivi d'une planaire dans un tube. + Instancié une fois par caméra active, réutilisé frame à frame. + Utilise la soustraction de fond MOG2 — léger sur Raspberry Pi 4. + """ + + def __init__(self, tube_axis: str = "vertical", min_area_px: int = 20): + # Axe du tube : "vertical" (cy) ou "horizontal" (cx) + self.tube_axis = tube_axis + self.min_area_px = min_area_px + + # Etat inter-frame + self._prev_cx = None + self._prev_cy = None + self._prev_ts = None + + # Soustracteur de fond adaptatif MOG2 + self._bg_sub = cv2.createBackgroundSubtractorMOG2( + history = 50, + varThreshold = 25, + detectShadows= False, + ) + + def reset(self): + """ + Réinitialise l'état inter-frame — appeler lors du changement de puits. + """ + self._prev_cx = None + self._prev_cy = None + self._prev_ts = None + # Réinitialise le fond appris + self._bg_sub = cv2.createBackgroundSubtractorMOG2( + history = 50, + varThreshold = 25, + detectShadows= False, + ) + + def process(self, frame: np.ndarray, ts: float) -> tuple[np.ndarray, dict]: + """ + Analyse une frame et dessine les contours détectés directement sur l'image. + Retourne (frame_annotée, métriques). + + Contours fins Vert (0,255,0) Tous les contours valides détectés + Contour épais Cyan (255,255,0) Planaire principale (plus grand contour) + Croix + cercle Rouge (0,0,255) Centre de masse exact + Texte Blanc Vitesse px/s + position axiale normalisée + """ + result = self._empty_result(ts) + frame_out = frame.copy() # copie pour ne pas modifier l'original + + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + fg_mask = self._bg_sub.apply(gray) + + kernel = np.ones((3, 3), np.uint8) + fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel) + fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel) + + contours, _ = cv2.findContours( + fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + + if not contours: + self._update_prev(None, None, ts) + return frame_out, result + + # Filtre les contours significatifs + valid_contours = [c for c in contours if cv2.contourArea(c) >= self.min_area_px] + + if not valid_contours: + self._update_prev(None, None, ts) + return frame_out, result + + # Dessine tous les contours valides en vert fin + cv2.drawContours(frame_out, valid_contours, -1, (0, 255, 0), 1) + + # Plus grand contour = planaire principale + largest = max(valid_contours, key=cv2.contourArea) + area = cv2.contourArea(largest) + + # Contour principal en cyan plus épais + cv2.drawContours(frame_out, [largest], -1, (255, 255, 0), 2) + + M = cv2.moments(largest) + if M["m00"] == 0: + return frame_out, result + + cx = int(M["m10"] / M["m00"]) + cy = int(M["m01"] / M["m00"]) + h, w = frame.shape[:2] + + axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w) + speed_px_s = None + axial_speed = None + + if self._prev_cx is not None and self._prev_ts is not None: + dt = ts - self._prev_ts + if dt > 0: + dx = cx - self._prev_cx + dy = cy - self._prev_cy + speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt) + axial_speed = float((dy / dt) if self.tube_axis == "vertical" else (dx / dt)) + + # Croix sur le centre de masse + cross_size = 8 + cv2.line(frame_out, (cx - cross_size, cy), (cx + cross_size, cy), (0, 0, 255), 1) + cv2.line(frame_out, (cx, cy - cross_size), (cx, cy + cross_size), (0, 0, 255), 1) + + # Cercle centré sur la planaire + cv2.circle(frame_out, (cx, cy), 12, (0, 0, 255), 1) + + # Texte vitesse + position axiale + label = f"v={speed_px_s:.1f}px/s ax={axial_pos:.2f}" if speed_px_s is not None else f"ax={axial_pos:.2f}" + cv2.putText( + frame_out, label, + (max(cx - 60, 0), max(cy - 18, 12)), + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1, cv2.LINE_AA, + ) + + result.update({ + "detected" : True, + "cx" : cx, + "cy" : cy, + "area_px" : int(area), + "speed_px_s" : round(speed_px_s, 3) if speed_px_s is not None else 0.0, + "axial_speed" : round(axial_speed, 3) if axial_speed is not None else 0.0, + "axial_pos" : round(axial_pos, 4), + }) + + self._update_prev(cx, cy, ts) + return frame_out, result + + + # ------------------------------------------------------------------ # + def _empty_result(self, ts: float) -> dict: + return { + "timestamp" : ts, + "detected" : False, + "cx" : 0, + "cy" : 0, + "area_px" : 0, + "speed_px_s" : 0.0, + "axial_speed": 0.0, + "axial_pos" : 0.0, + } + + def _update_prev(self, cx, cy, ts): + self._prev_cx = cx + self._prev_cy = cy + self._prev_ts = ts + + \ No newline at end of file diff --git a/test_tube_scanner/modules/planarian_tracker3.py b/test_tube_scanner/modules/planarian_tracker3.py new file mode 100644 index 0000000..30705e2 --- /dev/null +++ b/test_tube_scanner/modules/planarian_tracker3.py @@ -0,0 +1,473 @@ +""" +modules/planarian_tracker.py + +Détection et suivi multi-individus de planaires dans un tube. +Supporte de 1 à MAX_PLANARIANS planaires par tube. + +Etat inter-frame indépendant par individu : position, timestamp, compteur de perte (lost), flag active. +Quand un individu n'est pas détecté pendant MAX_LOST_FRAMES (5) frames consécutives, il est marqué perdu et son slot se libère. + +Algorithme hongrois (scipy.optimize.linear_sum_assignment) dans _hungarian_assign() + — construit une matrice de coût distance euclidienne entre les slots actifs et les nouvelles détections, puis trouve l'association de coût minimal. +Une association est rejetée si la distance dépasse MAX_ASSOC_DIST_PX (80px) + — évite les sauts aberrants entre planaires proches. + + +Stratégie : + - Soustraction de fond MOG2 (léger sur Raspberry Pi 4) + - Détection de tous les contours valides (surface >= min_area_px) + - Association frame-à-frame par distance euclidienne minimale + via algorithme hongrois (scipy.optimize.linear_sum_assignment) + - Un état inter-frame indépendant par individu (PlanarianState) + - Retourne une liste de résultats, un par individu suivi: champ planarian_id (index 0-based). + +Created on 25 avr. 2026 +@author: denis +""" + +import cv2 +import logging +import numpy as np +logger = logging.getLogger(__name__) + +from scipy.optimize import linear_sum_assignment # @UnresolvedImport + +# Nombre maximum de planaires suivis simultanément par tube +MAX_PLANARIANS = 10 + +# Distance maximale en pixels entre deux positions consécutives +# pour qu'une association soit acceptée (évite les sauts aberrants) +MAX_ASSOC_DIST_PX = 80 + +# Couleurs d'annotation par individu (BGR) +# Cycle automatique si plus de planaires que de couleurs +INDIVIDUAL_COLORS = [ + (255, 255, 0), # cyan + ( 0, 165, 255), # orange + (255, 0, 255), # magenta + ( 0, 255, 255), # jaune + (128, 0, 255), # violet + ( 0, 255, 128), # vert clair + (255, 128, 0), # bleu clair + ( 0, 128, 255), # orange foncé + (128, 255, 0), # vert-jaune + (255, 0, 128), # rose +] + + + +# Couleur du contour principal (individu le plus grand) +COLOR_LARGEST = (255, 255, 0) # cyan +COLOR_OTHER = ( 0, 255, 0) # vert +COLOR_CENTER = ( 0, 0, 255) # rouge + + +# --------------------------------------------------------------------------- +# État inter-frame d'un individu +# --------------------------------------------------------------------------- + +class PlanarianState: + """ + Mémorise la position et le timestamp de la dernière détection + pour un planaire individuel. + + Un PlanarianState par slot (index 0 à max_planarians-1). + Quand un slot n'est pas associé à un contour sur plusieurs frames + consécutives, il est marqué comme perdu (lost). + """ + + # Nombre de frames sans détection avant de considérer l'individu perdu + MAX_LOST_FRAMES = 5 + + def __init__(self, idx: int): + """ + Args: + idx : index de l'individu (0-based) + """ + self.idx = idx + self.cx = None + self.cy = None + self.ts = None + self.lost = 0 # compteur de frames sans détection + self.active = False # vrai si l'individu a été détecté au moins une fois + + def update(self, cx: int, cy: int, ts: float): + """ + Met à jour la position suite à une association réussie. + + Args: + cx, cy : position du centre de masse en pixels + ts : timestamp de la frame + """ + self.cx = cx + self.cy = cy + self.ts = ts + self.lost = 0 + self.active = True + + def mark_lost(self): + """Incrémente le compteur de perte — appelé quand aucun contour n'est associé.""" + self.lost += 1 + + @property + def is_lost(self) -> bool: + """Retourne True si l'individu est considéré perdu (trop de frames sans détection).""" + return self.lost >= self.MAX_LOST_FRAMES + + def compute_speed(self, cx: int, cy: int, ts: float, tube_axis: str) -> tuple: + """ + Calcule la vitesse instantanée depuis la position précédente. + + Args: + cx, cy : position courante en pixels + ts : timestamp courant + tube_axis : "vertical" ou "horizontal" + + Returns: + tuple (speed_px_s, axial_speed) ou (0.0, 0.0) si état vide + """ + if self.cx is None or self.ts is None: + return 0.0, 0.0 + + dt = ts - self.ts + if dt <= 0: + return 0.0, 0.0 + + dx = cx - self.cx + dy = cy - self.cy + speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt) + axial_speed = float((dy / dt) if tube_axis == "vertical" else (dx / dt)) + + return speed_px_s, axial_speed + + def reset(self): + """Réinitialise l'état de cet individu.""" + self.cx = None + self.cy = None + self.ts = None + self.lost = 0 + self.active = False + + +# --------------------------------------------------------------------------- +# Tracker multi-individus +# --------------------------------------------------------------------------- + +class PlanarianTracker: + """ + Détection et suivi multi-individus de planaires dans un tube. + + Instancié une fois par caméra active, réutilisé frame à frame. + Utilise la soustraction de fond MOG2 — léger sur Raspberry Pi 4. + Association frame-à-frame par algorithme hongrois (distance euclidienne). + + Usage : + tracker = PlanarianTracker(tube_axis="vertical", max_planarians=3) + while capturing: + frame_out, results = tracker.process(frame, ts) + # results : liste de dicts, un par individu détecté + for r in results: + metrics.update(r, planarian_id=r["planarian_id"]) + """ + + def __init__( + self, + tube_axis: str = "vertical", + min_area_px: int = 20, + max_planarians: int = 1, + ): + """ + Args: + tube_axis : axe principal du tube — "vertical" (cy) ou "horizontal" (cx) + min_area_px : surface minimale d'un contour pour être considéré valide (px²) + max_planarians : nombre maximum de planaires à suivre simultanément (1-10) + """ + self.tube_axis = tube_axis + self.min_area_px = min_area_px + self.max_planarians = max(1, min(max_planarians, MAX_PLANARIANS)) + + # Un état inter-frame par slot individu + self._states = [PlanarianState(i) for i in range(self.max_planarians)] + + # Soustracteur de fond adaptatif MOG2 + self._bg_sub = self._make_bg_sub() + + @staticmethod + def _make_bg_sub(): + """Crée et retourne un soustracteur de fond MOG2.""" + return cv2.createBackgroundSubtractorMOG2( + history = 50, + varThreshold = 25, + detectShadows= False, + ) + + def reset(self): + """ + Réinitialise l'état inter-frame complet. + À appeler lors du changement de puits. + """ + for s in self._states: + s.reset() + self._bg_sub = self._make_bg_sub() + + # ------------------------------------------------------------------ # + # Interface principale + # ------------------------------------------------------------------ # + + def process(self, frame: np.ndarray, ts: float) -> tuple: + """ + Analyse une frame, associe les contours aux individus connus, + dessine les annotations et retourne les métriques. + + Args: + frame : image BGR (numpy array) + ts : timestamp de la frame (float, secondes epoch) + + Returns: + tuple (frame_annotée, results) + + frame_annotée : copie BGR avec contours, croix et textes + results : liste de dicts — un dict par planaire actif détecté. + Chaque dict contient : + planarian_id int index de l'individu (0-based) + detected bool True si détecté cette frame + cx, cy int centre de masse en pixels + area_px int surface du contour (px²) + speed_px_s float vitesse totale (px/s) + axial_speed float vitesse axiale (px/s) + axial_pos float position axiale normalisée (0-1) + timestamp float ts de la frame + """ + frame_out = frame.copy() + h, w = frame.shape[:2] + + # --- Extraction du premier plan --- + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + fg_mask = self._bg_sub.apply(gray) + + kernel = np.ones((3, 3), np.uint8) + fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel) + fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel) + + contours, _ = cv2.findContours( + fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + + # Filtrage des contours significatifs, triés par surface décroissante + valid = sorted( + [c for c in contours if cv2.contourArea(c) >= self.min_area_px], + key=cv2.contourArea, + reverse=True, + ) + + # Limiter au nombre maximum de planaires attendus + valid = valid[:self.max_planarians] + + # --- Calcul des centres de masse des contours détectés --- + detections = [] # liste de (cx, cy, area, contour) + for c in valid: + M = cv2.moments(c) + if M["m00"] == 0: + continue + cx = int(M["m10"] / M["m00"]) + cy = int(M["m01"] / M["m00"]) + area = cv2.contourArea(c) + detections.append((cx, cy, int(area), c)) + + # --- Association hongroise détections → slots individus --- + assignments = self._hungarian_assign(detections) + + # --- Mise à jour des états et construction des résultats --- + results = [] + + for slot_idx, det_idx in assignments.items(): + state = self._states[slot_idx] + + if det_idx is None: + # Aucune détection associée à ce slot + state.mark_lost() + if state.active and not state.is_lost: + # L'individu était suivi : on retourne un résultat "perdu" + results.append(self._lost_result(slot_idx, ts)) + continue + + cx, cy, area, contour = detections[det_idx] + + # Calcul de la vitesse depuis la position précédente + speed_px_s, axial_speed = state.compute_speed(cx, cy, ts, self.tube_axis) + + axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w) + + # Mise à jour de l'état + state.update(cx, cy, ts) + + # Annotation visuelle + color = INDIVIDUAL_COLORS[slot_idx % len(INDIVIDUAL_COLORS)] + cv2.drawContours(frame_out, [contour], -1, color, 2) + self._draw_center(frame_out, cx, cy, slot_idx, speed_px_s, axial_pos, color) + + results.append({ + "planarian_id": slot_idx, + "detected": True, + "cx": cx, + "cy": cy, + "area_px": area, + "speed_px_s": round(speed_px_s, 3), + "axial_speed": round(axial_speed, 3), + "axial_pos": round(axial_pos, 4), + "timestamp": ts, + }) + + # Marquer les slots non présents dans les assignments comme perdus + assigned_slots = set(assignments.keys()) + for state in self._states: + if state.idx not in assigned_slots: + state.mark_lost() + + + return frame_out, results + + # ------------------------------------------------------------------ # + # Association hongroise + # ------------------------------------------------------------------ # + + def _hungarian_assign(self, detections: list) -> dict: + """ + Associe les détections courantes aux slots individus connus + via l'algorithme hongrois (coût = distance euclidienne). + + Contrainte : une association n'est acceptée que si la distance + est inférieure à MAX_ASSOC_DIST_PX (évite les sauts aberrants). + + Args: + detections : liste de (cx, cy, area, contour) + + Returns: + dict {slot_idx: det_idx | None} + det_idx = None si aucune détection assignée à ce slot + """ + n_slots = self.max_planarians + n_dets = len(detections) + + if n_dets == 0: + # Aucune détection : tous les slots sont "perdus" + return {i: None for i in range(n_slots)} + + # Slots actifs (déjà vus au moins une fois et non perdus) + active_slots = [s for s in self._states if s.active and not s.is_lost] + + if not active_slots: + # Première frame ou tous perdus : attribution séquentielle simple + assignment = {} + for i in range(n_slots): + assignment[i] = i if i < n_dets else None + return assignment + + # --- Construction de la matrice de coût (distance euclidienne) --- + cost = np.full((len(active_slots), n_dets), fill_value=1e6) + + for si, state in enumerate(active_slots): + for di, (cx, cy, _, _) in enumerate(detections): + dist = np.sqrt((cx - state.cx)**2 + (cy - state.cy)**2) + cost[si, di] = dist + + # --- Algorithme hongrois --- + row_ind, col_ind = linear_sum_assignment(cost) + + # Construire le dict d'association + assignment = {i: None for i in range(n_slots)} + + assigned_dets = set() + for ri, ci in zip(row_ind, col_ind): + if cost[ri, ci] <= MAX_ASSOC_DIST_PX: + slot_idx = active_slots[ri].idx + assignment[slot_idx] = ci + assigned_dets.add(ci) + + # --- Nouvelles détections non assignées → slots inactifs libres --- + free_slots = [s for s in self._states if not s.active or s.is_lost] + new_dets = [di for di in range(n_dets) if di not in assigned_dets] + + for state, det_idx in zip(free_slots, new_dets): + assignment[state.idx] = det_idx + + return assignment + + # ------------------------------------------------------------------ # + # Dessin des annotations + # ------------------------------------------------------------------ # + + def _draw_center( + self, + frame: np.ndarray, + cx: int, + cy: int, + idx: int, + speed_px_s: float, + axial_pos: float, + color: tuple, + ): + """ + Dessine la croix, le cercle et le label de vitesse/position + pour un individu. + + Args: + frame : image à annoter (en place) + cx, cy : centre de masse en pixels + idx : index de l'individu + speed_px_s : vitesse en px/s + axial_pos : position axiale normalisée + color : couleur BGR de l'individu + """ + cross = 8 + cv2.line(frame, (cx - cross, cy), (cx + cross, cy), COLOR_CENTER, 1) + cv2.line(frame, (cx, cy - cross), (cx, cy + cross), COLOR_CENTER, 1) + cv2.circle(frame, (cx, cy), 12, color, 1) + + # Badge numéro individu + cv2.circle(frame, (cx + 14, cy - 14), 8, color, -1) + cv2.putText( + frame, str(idx), + (cx + 10, cy - 10), + cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 0, 0), 1, cv2.LINE_AA, + ) + + # Texte vitesse + position axiale + label = ( + f"#{idx} v={speed_px_s:.1f}px/s ax={axial_pos:.2f}" + if speed_px_s > 0 + else f"#{idx} ax={axial_pos:.2f}" + ) + cv2.putText( + frame, label, + (max(cx - 60, 0), max(cy - 22, 12)), + cv2.FONT_HERSHEY_SIMPLEX, 0.35, (255, 255, 255), 1, cv2.LINE_AA, + ) + + # ------------------------------------------------------------------ # + # Résultats vides / perdus + # ------------------------------------------------------------------ # + + def _lost_result(self, planarian_id: int, ts: float) -> dict: + """ + Retourne un résultat pour un individu temporairement non détecté. + + Args: + planarian_id : index de l'individu + ts : timestamp de la frame courante + + Returns: + dict avec detected=False et les dernières coordonnées connues + """ + state = self._states[planarian_id] + return { + "planarian_id": planarian_id, + "detected": False, + "cx": state.cx or 0, + "cy": state.cy or 0, + "area_px": 0, + "speed_px_s": 0.0, + "axial_speed": 0.0, + "axial_pos": 0.0, + "timestamp": ts, + } diff --git a/test_tube_scanner/modules/videofile_capture.py b/test_tube_scanner/modules/videofile_capture.py index 59dd24d..3e23b22 100644 --- a/test_tube_scanner/modules/videofile_capture.py +++ b/test_tube_scanner/modules/videofile_capture.py @@ -50,7 +50,7 @@ class VideoFileCapture(VideoCaptureInterface): :param width: Largeur souhaitée (None = valeur par défaut du pilote) :param height: Hauteur souhaitée (None = valeur par défaut du pilote) """ - super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent) + super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent, jpeg_quality=jpeg_quality) self._video_file: str = video_file self._jpeg_quality: int = jpeg_quality self._width: Optional[int] = width diff --git a/test_tube_scanner/modules/webcam_capture.py b/test_tube_scanner/modules/webcam_capture.py index 281d819..7b7cd25 100644 --- a/test_tube_scanner/modules/webcam_capture.py +++ b/test_tube_scanner/modules/webcam_capture.py @@ -49,7 +49,7 @@ class WebcamCapture(VideoCaptureInterface): :param width: Largeur souhaitée (None = valeur par défaut du pilote) :param height: Hauteur souhaitée (None = valeur par défaut du pilote) """ - super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent) + super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent, jpeg_quality=jpeg_quality) self._device_index: int = device_index self._jpeg_quality: int = jpeg_quality self._width: Optional[int] = width diff --git a/test_tube_scanner/planarian/admin.py b/test_tube_scanner/planarian/admin.py index 646a9be..4f2ffb1 100644 --- a/test_tube_scanner/planarian/admin.py +++ b/test_tube_scanner/planarian/admin.py @@ -8,7 +8,8 @@ from .models import ExperimentConfig @admin.register(ExperimentConfig) class ExperimentConfigAdmin(admin.ModelAdmin): """Admin Django pour les configurations d'expérience.""" - readonly_fields = ('experiment', ) + #readonly_fields = ('experiment', ) + readonly_fields = ("identifier", 'px_per_mm', 'fps', 'well_radius_mm',) list_display = ("experiment", "well", "px_per_mm", "fps", "thresh_immobile", "thresh_mobile", "photo_mode", "chemo_strength", "created_at") @@ -18,19 +19,23 @@ class ExperimentConfigAdmin(admin.ModelAdmin): fieldsets = ( (_("Identification"), { - "fields": ("experiment", "well", "description"), - }), + "fields": ("identifier", "experiment", "well", "description"), + }), (_("Calibration optique"), { "fields": ("px_per_mm", "fps", "well_radius_mm"), + "classes": ("collapse",), }), (_("Seuils de mobilité EthoVision"), { - "fields": ("thresh_immobile", "thresh_mobile"), + "fields": ("thresh_immobile", "thresh_mobile"), + "classes": ("collapse",), }), (_("Tracker"), { "fields": ("tube_axis", "min_area_px", "planarian_count"), + "classes": ("collapse",), }), (_("Thigmotactisme"), { "fields": ("thigmotaxis_wall_dist_mm",), + "classes": ("collapse",), }), (_("Phototactisme"), { "fields": ("photo_mode", "photo_strength", "photo_x", "photo_y"), diff --git a/test_tube_scanner/planarian/forms.py b/test_tube_scanner/planarian/forms.py index 67b773e..1776ff7 100644 --- a/test_tube_scanner/planarian/forms.py +++ b/test_tube_scanner/planarian/forms.py @@ -10,6 +10,10 @@ from .models import ExperimentConfig class ExperimentConfigForm(forms.ModelForm): """Formulaire de saisie/modification d'un ExperimentConfig.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['identifier'].disabled = True class Meta: model = ExperimentConfig diff --git a/test_tube_scanner/planarian/models.py b/test_tube_scanner/planarian/models.py index ede3498..0bf4738 100644 --- a/test_tube_scanner/planarian/models.py +++ b/test_tube_scanner/planarian/models.py @@ -3,7 +3,8 @@ from django.db import models from django.dispatch import receiver from django.db.models.signals import post_save -from django.conf import settings +#from django.conf import settings + from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User from scanner.models import Experiment, Well, WellPosition @@ -17,19 +18,10 @@ class ExperimentConfig(models.Model): author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) # --- Identification --- - idendifier = models.CharField( - max_length=100, - verbose_name=_("Identifiant d'expérience"), - help_text=_("Ex : exp_2026_04_25_ctrl"), - ) - - experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="experiment_well" , null=True, blank=True) + identifier = models.CharField( max_length=100, verbose_name=_("Identifiant d'expérience"), help_text=_("session_1-HD-2026-04-27"), ) + experiment = models.ForeignKey(Experiment, verbose_name="Expérience", on_delete=models.CASCADE, related_name="experiment_well" , null=True, blank=True) well = models.ForeignKey(Well, verbose_name="Puit", on_delete=models.CASCADE, related_name="well_experiment", null=True, blank=True ) - - description = models.TextField( - blank=True, - verbose_name=_("Description"), - ) + description = models.TextField( blank=True, verbose_name=_("Description"), ) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le")) # --- Calibration optique --- @@ -42,10 +34,12 @@ class ExperimentConfig(models.Model): fps = models.FloatField( default=5.0, verbose_name=_("FPS de capture"), + help_text=_("Image de capture en img/s"), ) well_radius_mm = models.FloatField( default=8.0, - verbose_name=_("Rayon du puits (mm)"), + verbose_name=_("Rayon du puits"), + help_text=_("En mm"), ) # --- Seuils de mobilité EthoVision --- @@ -109,7 +103,7 @@ class ExperimentConfig(models.Model): class Meta: verbose_name = _("Configuration expérience") - verbose_name_plural = _("Configurations expériences") + verbose_name_plural = _("Configuration des expériences") unique_together = ("experiment", "well") ordering = ["-created_at"] @@ -145,21 +139,25 @@ class ExperimentConfig(models.Model): def save(self, *args, **kwargs): session = self.get_session() - position = self.experiment.multiwell.position - dte = self.experiment.multiwell.finished.isoformat() - self.idendifier = f'{session}-{position}-{self.well.name}-{dte}' + dte = self.experiment.created.isoformat()[:19] + self.identifier = f'{dte}-{session.id}-{self.experiment.id}-{self.experiment.multiwell.position}-{self.well.name}' + + print(self.identifier) + super().save(*args, **kwargs) + @receiver(post_save, sender=ExperimentConfig) def create_well_position(sender, instance, created, **kwargs): - active_well = WellPosition.active_well(instance.multiwel, instance.well) - instance.px_per_mm = active_well.px_per_mm - instance.well_radius_mm = instance.experiment.multiwell.diameter / 2 - conf = ScannerConstants().get() - instance.fps = conf.video_frame_rate - instance.save() - + if created: + active_well = WellPosition.active_well(instance.experiment.multiwell, instance.well) + instance.px_per_mm = active_well.px_per_mm + instance.well_radius_mm = instance.experiment.multiwell.diameter / 2 + conf = ScannerConstants().get() + instance.fps = conf.video_frame_rate + instance.save() + diff --git a/test_tube_scanner/planarian/templates/planarian/base.html b/test_tube_scanner/planarian/templates/planarian/base.html new file mode 100644 index 0000000..fa7ff4e --- /dev/null +++ b/test_tube_scanner/planarian/templates/planarian/base.html @@ -0,0 +1,53 @@ +{% extends "scanner/base.html" %} +{% load i18n home_tags scanner_tags %} + +{% block columns %}{% endblock %} + +{% block sidebar_list %} + {% trans "Retour accueil" %} +
+ {% if current_session.id %} + + {% trans "Balayage multi-puits" %} + + {% endif %} +{% endblock %} + +{% block js_footer %} + {{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/test_tube_scanner/planarian/templates/planarian/experiment_form.html b/test_tube_scanner/planarian/templates/planarian/experiment_form.html index 1833c4a..38d5f82 100644 --- a/test_tube_scanner/planarian/templates/planarian/experiment_form.html +++ b/test_tube_scanner/planarian/templates/planarian/experiment_form.html @@ -1,9 +1,9 @@ -{% extends "scanner/base.html" %} +{% extends "planarian/base.html" %} {% load i18n %} {% block content %} -{{ form.experiment.help_text }}
- {% endif %} {{ form.experiment }} {% if form.experiment.errors %} {{ form.experiment.errors|join:", " }} @@ -65,11 +79,8 @@{{ form.well.help_text }}
- {% endif %} {{ form.well }} {% if form.well.errors %} {{ form.well.errors|join:", " }} @@ -79,7 +90,7 @@{{ form.fps.help_text }}
+ {% endif %} {{ form.fps }} {% if form.fps.errors %} {{ form.fps.errors|join:", " }} @@ -121,6 +135,9 @@{{ form.well_radius_mm.help_text }}
+ {% endif %} {{ form.well_radius_mm }} {% if form.well_radius_mm.errors %} {{ form.well_radius_mm.errors|join:", " }} @@ -130,12 +147,48 @@