""" modules/planarian_metrics.py Intégration des métriques EthoVision XT dans PlanarianScanner. Architecture : PlanarianTracker.process() → dict brut (cx, cy, speed_px_s, ...) EthoVisionMetrics.update() → enrichit avec métriques EthoVision ReductStoreClient.store() → stocke dans ReductStore avec labels ReductStoreClient.export_csv() → exporte vers CSV Schéma des labels ReductStore : experiment : identifiant de l'expérience (ex: "exp_2026_04_25") well : identifiant du puits (ex: "A1", "B3") planarian : index du planaire dans le puits (ex: "0", "1") bucket : nom du bucket (ex: "planarian_metrics") Created on 25 avr. 2026 @author: denis """ import asyncio import csv import io import json import logging import math import os import time from datetime import datetime, timezone from typing import Optional from modules.reductstore import ReductStore logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Constantes EthoVision (seuils de mobilité par défaut) # --------------------------------------------------------------------------- # Seuils en mm/s — identiques à ceux de la simulation THRESH_IMMOBILE_DEFAULT = 0.2 # en-dessous : Immobile THRESH_MOBILE_DEFAULT = 1.5 # entre les deux : Mobile, au-delà : Highly mobile # États de mobilité (nomenclature EthoVision XT) STATE_IMMOBILE = "Immobile" STATE_MOBILE = "Mobile" STATE_HIGH_MOBILE = "Highly mobile" # Paramètres comportementaux (défauts — peuvent être importés depuis CSV/Django) BEHAVIOUR_DEFAULTS = { # Thigmotactisme "thigmotaxis_wall_dist_mm": 1.0, # distance à la paroi considérée "near wall" # Phototactisme "photo_mode": "none", # none | fixed | sine | radial "photo_strength": 0.0, # Chimiotactisme "chemo_strength": 0.0, "chemo_x": 0.5, # fraction 0-1 "chemo_y": 0.5, "chemo_radius_mm": 2.0, # Interactions inter-individus "avoid_radius_mm": 3.0, "aggreg_radius_mm": 6.0, } # --------------------------------------------------------------------------- # Classe EthoVisionMetrics # --------------------------------------------------------------------------- class EthoVisionMetrics: """ Calcule et accumule les métriques compatibles EthoVision XT à partir des données brutes de PlanarianTracker. Gère la conversion pixels → mm via le facteur px_per_mm. Une instance par planaire suivi (un puits = une instance). Usage : metrics = EthoVisionMetrics(px_per_mm=26.25, fps=10) for frame, ts in capture: annotated, raw = tracker.process(frame, ts) record = metrics.update(raw, well_radius_mm=8.0) await reduct_client.store(record, labels=...) summary = metrics.summary() """ def __init__( self, px_per_mm: float, fps: float, thresh_immobile: float = THRESH_IMMOBILE_DEFAULT, thresh_mobile: float = THRESH_MOBILE_DEFAULT, behaviour: Optional[dict] = None, ): """ Args: px_per_mm : facteur de conversion pixels → mm (calibration optique) fps : fréquence de capture en images/seconde thresh_immobile : seuil vitesse Immobile/Mobile en mm/s thresh_mobile : seuil vitesse Mobile/Très mobile en mm/s behaviour : dict de paramètres comportementaux (cf. BEHAVIOUR_DEFAULTS) """ self.px_per_mm = px_per_mm self.fps = fps self.dt = 1.0 / fps self.thresh_immobile = thresh_immobile self.thresh_mobile = thresh_mobile self.behaviour = {**BEHAVIOUR_DEFAULTS, **(behaviour or {})} # --- Accumulateurs globaux --- self.total_distance_mm = 0.0 self.duration_moving_s = 0.0 self.duration_stopped_s = 0.0 self.frame_count = 0 # --- Accumulateurs par état de mobilité --- self._mob_counts = { STATE_IMMOBILE: 0, STATE_MOBILE: 0, STATE_HIGH_MOBILE: 0, } self._mob_durations = { STATE_IMMOBILE: 0.0, STATE_MOBILE: 0.0, STATE_HIGH_MOBILE: 0.0, } self._current_state = None # --- Thigmotactisme --- self._near_wall_frames = 0 # --- Historique positions (pour calcul vitesse inter-frame) --- self._prev_cx_px = None self._prev_cy_px = None self._prev_ts = None def _px_to_mm(self, px: float) -> float: """Convertit des pixels en millimètres.""" return px / self.px_per_mm def _classify(self, velocity_mm_s: float) -> str: """ Classifie la vitesse selon les seuils EthoVision. Args: velocity_mm_s : vitesse instantanée en mm/s Returns: str : STATE_IMMOBILE | STATE_MOBILE | STATE_HIGH_MOBILE """ if velocity_mm_s <= self.thresh_immobile: return STATE_IMMOBILE elif velocity_mm_s <= self.thresh_mobile: return STATE_MOBILE return STATE_HIGH_MOBILE def update(self, raw: dict, well_radius_mm: float = 8.0) -> dict: """ Calcule les métriques EthoVision pour une frame à partir du résultat brut de PlanarianTracker.process(). Args: raw : dict retourné par PlanarianTracker.process() clés attendues : detected, cx, cy, speed_px_s, ts well_radius_mm : rayon du puits en mm (pour le thigmotactisme) Returns: dict complet avec métriques EthoVision prêtes pour ReductStore """ self.frame_count += 1 ts = raw.get("timestamp", time.time()) if not raw.get("detected", False): # Planaire non détecté : on accumule l'arrêt et on retourne vide self.duration_stopped_s += self.dt state = self._current_state or STATE_IMMOBILE self._mob_durations[state] += self.dt return self._empty_record(ts) cx_px = raw["cx"] cy_px = raw["cy"] # --- Conversion en mm --- cx_mm = self._px_to_mm(cx_px) cy_mm = self._px_to_mm(cy_px) # --- Vitesse en mm/s depuis la vitesse brute pixels/s --- speed_px_s = raw.get("speed_px_s", 0.0) velocity_mm_s = self._px_to_mm(speed_px_s) # --- Distance parcourue cette frame --- dist_mm = velocity_mm_s * self.dt self.total_distance_mm += dist_mm # --- Mouvement / arrêt --- is_moving = velocity_mm_s > self.thresh_immobile if is_moving: self.duration_moving_s += self.dt else: self.duration_stopped_s += self.dt # --- État de mobilité --- new_state = self._classify(velocity_mm_s) if new_state != self._current_state: self._mob_counts[new_state] += 1 self._current_state = new_state self._mob_durations[new_state] += self.dt # --- Thigmotactisme --- # Distance à la paroi du puits (centre = 0, paroi = well_radius_mm) well_radius_px = well_radius_mm * self.px_per_mm dist_center_px = math.sqrt(cx_px**2 + cy_px**2) dist_wall_mm = self._px_to_mm(well_radius_px - dist_center_px) near_wall_dist = self.behaviour.get("thigmotaxis_wall_dist_mm", 1.0) is_near_wall = dist_wall_mm < near_wall_dist if is_near_wall: self._near_wall_frames += 1 self._prev_cx_px = cx_px self._prev_cy_px = cy_px self._prev_ts = ts # --- Record complet --- return { # Identification temporelle "timestamp": ts, "detected": True, # Position brute (pixels) "cx_px": cx_px, "cy_px": cy_px, # Position en mm "x_mm": round(cx_mm, 4), "y_mm": round(cy_mm, 4), # Vitesse "velocity_mm_s": round(velocity_mm_s, 4), "distance_mm": round(dist_mm, 4), # Distance totale cumulée (EthoVision : movedCenter-pointTotalmm) "total_distance_mm": round(self.total_distance_mm, 4), # Mouvement / arrêt (EthoVision : MovementMoving / Not Moving) "moving": int(is_moving), "duration_moving_s": round(self.duration_moving_s, 3), "duration_stopped_s": round(self.duration_stopped_s, 3), # État de mobilité (EthoVision : Mobility state) "mobility_state": new_state, "mobility_immobile_freq": self._mob_counts[STATE_IMMOBILE], "mobility_immobile_duration_s": round(self._mob_durations[STATE_IMMOBILE], 3), "mobility_mobile_freq": self._mob_counts[STATE_MOBILE], "mobility_mobile_duration_s": round(self._mob_durations[STATE_MOBILE], 3), "mobility_high_mobile_freq": self._mob_counts[STATE_HIGH_MOBILE], "mobility_high_mobile_duration_s": round(self._mob_durations[STATE_HIGH_MOBILE], 3), # Thigmotactisme "dist_to_wall_mm": round(dist_wall_mm, 4), "near_wall": int(is_near_wall), # Données brutes tracker (passthrough) "area_px": raw.get("area_px", 0), "axial_pos": raw.get("axial_pos", 0.0), "axial_speed": raw.get("axial_speed", 0.0), } def summary(self) -> dict: """ Retourne le résumé global de la session (nomenclature EthoVision XT). À appeler en fin d'expérience pour stocker le résumé dans ReductStore. Returns: dict avec toutes les métriques agrégées """ total_s = self.frame_count * self.dt return { "total_frames": self.frame_count, "total_duration_s": round(total_s, 3), # Distance / vitesse (EthoVision : movedCenter-pointTotalmm / VelocityCenter-pointMeanmm/s) "movedCenter_pointTotal_mm": round(self.total_distance_mm, 4), "velocity_mean_mm_s": round( self.total_distance_mm / total_s if total_s > 0 else 0.0, 4 ), # Mouvement / arrêt "movement_moving_duration_s": round(self.duration_moving_s, 3), "movement_not_moving_duration_s": round(self.duration_stopped_s, 3), # Immobile "mobility_immobile_frequency": self._mob_counts[STATE_IMMOBILE], "mobility_immobile_duration_s": round(self._mob_durations[STATE_IMMOBILE], 3), # Mobile "mobility_mobile_frequency": self._mob_counts[STATE_MOBILE], "mobility_mobile_duration_s": round(self._mob_durations[STATE_MOBILE], 3), # Très mobile "mobility_highly_mobile_frequency": self._mob_counts[STATE_HIGH_MOBILE], "mobility_highly_mobile_duration_s": round(self._mob_durations[STATE_HIGH_MOBILE], 3), # Thigmotactisme "thigmotaxis_pct_time_near_wall": round( 100.0 * self._near_wall_frames / max(self.frame_count, 1), 2 ), } def reset(self): """ Réinitialise tous les accumulateurs. À appeler lors d'un changement de puits ou de planaire. """ self.__init__( self.px_per_mm, self.fps, self.thresh_immobile, self.thresh_mobile, self.behaviour, ) @staticmethod def _empty_record(ts: float) -> dict: """Retourne un enregistrement vide (planaire non détecté).""" return { "timestamp": ts, "detected": False, } # --------------------------------------------------------------------------- # Paramètres expérimentaux (importables depuis CSV ou Django) # --------------------------------------------------------------------------- class ExperimentParams: """ Conteneur des paramètres d'une expérience. Peut être instancié depuis un dict, un fichier CSV ou un modèle Django. Champs obligatoires : experiment, well, px_per_mm, fps Tous les autres ont des valeurs par défaut. """ REQUIRED = {"experiment", "well", "px_per_mm", "fps"} DEFAULTS = { "well_radius_mm": 8.0, "thresh_immobile": THRESH_IMMOBILE_DEFAULT, "thresh_mobile": THRESH_MOBILE_DEFAULT, "planarian_count": 1, "tube_axis": "vertical", "min_area_px": 20, **BEHAVIOUR_DEFAULTS, } def __init__(self, data: dict): """ Args: data : dict contenant au moins les champs REQUIRED """ missing = self.REQUIRED - set(data.keys()) if missing: raise ValueError(f"Paramètres manquants : {missing}") merged = {**self.DEFAULTS, **data} for k, v in merged.items(): # Conversion de type automatique si valeur string (vient du CSV) setattr(self, k, self._cast(k, v)) @staticmethod def _cast(key: str, value): """ Convertit la valeur en type approprié. Les valeurs CSV sont toutes des strings — on les cast automatiquement. Args: key : nom du paramètre value : valeur brute (str ou type natif) Returns: valeur convertie """ float_keys = { "px_per_mm", "fps", "well_radius_mm", "thresh_immobile", "thresh_mobile", "photo_strength", "chemo_strength", "chemo_x", "chemo_y", "chemo_radius_mm", "thigmotaxis_wall_dist_mm", "avoid_radius_mm", "aggreg_radius_mm", } int_keys = {"planarian_count", "min_area_px"} if key in float_keys: return float(value) if key in int_keys: return int(value) # Booléens CSV ("true"/"false") if isinstance(value, str) and value.lower() in ("true", "false"): return value.lower() == "true" return value @classmethod def from_csv_row(cls, row: dict) -> "ExperimentParams": """ Instancie depuis une ligne de DictReader CSV. Args: row : dict issu de csv.DictReader Returns: ExperimentParams """ return cls(row) @classmethod def from_csv_file(cls, filepath: str) -> list: """ Charge tous les paramètres d'un fichier CSV (une expérience par ligne). Args: filepath : chemin vers le fichier CSV Returns: liste d'ExperimentParams """ results = [] with open(filepath, newline="", encoding="utf-8") as f: reader = csv.DictReader(f) for row in reader: try: results.append(cls.from_csv_row(row)) except ValueError as e: logger.warning(f"Ligne ignorée : {e} — {row}") return results def to_dict(self) -> dict: """Sérialise les paramètres en dict (pour stockage ou affichage Django).""" return {k: getattr(self, k) for k in {**self.DEFAULTS, **{r: None for r in self.REQUIRED}}} def build_metrics(self) -> "EthoVisionMetrics": """ Construit l'instance EthoVisionMetrics correspondant à ces paramètres. Returns: EthoVisionMetrics configurée """ behaviour = {k: getattr(self, k) for k in BEHAVIOUR_DEFAULTS if hasattr(self, k)} return EthoVisionMetrics( px_per_mm = self.px_per_mm, fps = self.fps, thresh_immobile = self.thresh_immobile, thresh_mobile = self.thresh_mobile, behaviour = behaviour, ) # --------------------------------------------------------------------------- # Client ReductStore # --------------------------------------------------------------------------- class ReductStoreClient: """ Interface asynchrone avec ReductStore pour PlanarianScanner. Schéma des labels : experiment → identifiant de l'expérience well → identifiant du puits (A1, B3, ...) planarian → index du planaire dans le puits record_type → "frame" | "summary" Chaque entrée stockée contient un payload JSON avec toutes les métriques. Le timestamp ReductStore est l'epoch µs de la frame. """ def __init__( self, url: str = "http://localhost:8383", token: str = "", bucket: str = "planarian_metrics", ): """ Args: url : URL du serveur ReductStore token : token d'authentification (vide si pas d'auth) bucket : nom du bucket cible """ self.url = url self.token = token self.bucket_name = bucket self._client = None self._bucket = None async def connect(self): """ Initialise la connexion et crée le bucket s'il n'existe pas. À appeler une fois au démarrage. """ from reduct import Client, BucketSettings, QuotaType self._client = Client(self.url, api_token=self.token) self._bucket = await self._client.create_bucket( self.bucket_name, BucketSettings(quota_type=QuotaType.NONE), exist_ok=True, ) logger.info(f"ReductStore connecté : {self.url} / bucket={self.bucket_name}") async def store_metric( self, record: dict, experiment: str, well: str, planarian: int = 0, record_type: str = "frame", ts_us: Optional[int] = None, ): """ Stocke un enregistrement de métriques dans ReductStore. Args: record : dict de métriques (issu de EthoVisionMetrics.update()) experiment : identifiant de l'expérience well : identifiant du puits planarian : index du planaire (défaut 0) record_type : "frame" ou "summary" ts_us : timestamp en microsecondes (défaut : maintenant) """ if self._bucket is None: await self.connect() ts_us = ts_us or int(time.time() * 1_000_000) labels = { "experiment": experiment, "well": well, "planarian": str(planarian), "record_type": record_type, } payload = json.dumps(record).encode("utf-8") await self._bucket.write( entry_name = "metrics", data = payload, timestamp = ts_us, labels = labels, content_type= "application/json", ) async def store_summary( self, summary: dict, experiment: str, well: str, planarian: int = 0, ): """ Stocke le résumé de fin de session dans ReductStore. Args: summary : dict issu de EthoVisionMetrics.summary() experiment : identifiant de l'expérience well : identifiant du puits planarian : index du planaire """ await self.store_metric( record = summary, experiment = experiment, well = well, planarian = planarian, record_type = "summary", ) async def get_tracking_data( self, experiment: str, well: str, planarian: int = 0, record_type: str = "frame", start: Optional[datetime] = None, stop: Optional[datetime] = None, ) -> list: """ Récupère les enregistrements depuis ReductStore avec filtrage par labels. Args: experiment : identifiant de l'expérience well : identifiant du puits planarian : index du planaire record_type : "frame" | "summary" start, stop : plage temporelle (datetime UTC, optionnel) Returns: liste de dicts métriques """ if self._bucket is None: await self.connect() labels = { "experiment": experiment, "well": well, "planarian": str(planarian), "record_type": record_type, } kwargs = {"include": labels} if start: kwargs["start"] = int(start.timestamp() * 1_000_000) if stop: kwargs["stop"] = int(stop.timestamp() * 1_000_000) records = [] async for record in self._bucket.query("metrics", **kwargs): try: data = json.loads(await record.read_all()) records.append(data) except Exception as e: logger.warning(f"Entrée illisible ignorée : {e}") return records async def export_csv( self, filepath: str, experiment: str, well: str, planarian: int = 0, record_type: str = "frame", start: Optional[datetime] = None, stop: Optional[datetime] = None, ) -> int: """ Exporte les données depuis ReductStore vers un fichier CSV. Args: filepath : chemin du fichier CSV de sortie experiment : identifiant de l'expérience well : identifiant du puits planarian : index du planaire record_type : "frame" | "summary" start, stop : plage temporelle (datetime UTC, optionnel) Returns: nombre de lignes exportées """ records = await self.get_tracking_data( experiment = experiment, well = well, planarian = planarian, record_type = record_type, start = start, stop = stop, ) if not records: logger.warning(f"Aucune donnée pour {experiment}/{well}/{planarian}") return 0 os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True) # Collecte de toutes les clés présentes (union de tous les records) fieldnames = list(dict.fromkeys(k for r in records for k in r.keys())) with open(filepath, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() for r in records: writer.writerow(r) logger.info(f"Export CSV : {len(records)} lignes → {filepath}") return len(records) async def export_csv_response( self, experiment: str, well: str, planarian: int = 0, record_type: str = "frame", start: Optional[datetime] = None, stop: Optional[datetime] = None, ) -> tuple[str, int]: """ Génère le contenu CSV en mémoire (pour une réponse HTTP Django). Args: experiment, well, planarian, record_type, start, stop : cf. export_csv Returns: tuple (contenu_csv_str, nb_lignes) """ records = await self.get_tracking_data( experiment = experiment, well = well, planarian = planarian, record_type = record_type, start = start, stop = stop, ) if not records: return "", 0 fieldnames = list(dict.fromkeys(k for r in records for k in r.keys())) output = io.StringIO() writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() for r in records: writer.writerow(r) return output.getvalue(), len(records) async def close(self): """Ferme la connexion ReductStore.""" if self._client: await self._client.close() logger.info("ReductStore déconnecté")