diff --git a/etc/6-install_django_app.sh b/etc/6-install_django_app.sh index 4c0f2d4..a71c40c 100755 --- a/etc/6-install_django_app.sh +++ b/etc/6-install_django_app.sh @@ -20,7 +20,7 @@ touch $APP_DIR/logs/celery.log $APP_DIR/logs/test_tube.log echo "---- Migration de la base de données ..." ./manage.py migrate -./manage.py makemigrations scanner +./manage.py makemigrations ./manage.py migrate echo "---- User / django_celery_beat ..." diff --git a/etc/db/configuration.json b/etc/db/configuration.json index 2e7080b..c5dc793 100644 --- a/etc/db/configuration.json +++ b/etc/db/configuration.json @@ -1 +1 @@ -[{"model": "scanner.configuration", "pk": 1, "fields": {"name": "Configuration par défaut", "author": 1, "sidebar_width": "25%", "default_grid_columns": 3, "opencv_fourcc_format": "mp4v", "opencv_video_type": "mp4", "grbl_xmax": 350.0, "grbl_ymax": 250.0, "use_rpicam": true, "webcam_device_index": 2, "image_quality": 90, "video_jpeg_quality": 90, "video_frame_rate": 5.0, "video_width_capture": 2028, "video_height_capture": 1520, "calibration_crop_radius": 500, "calibration_default_multiwell": "HG", "calibration_default_feed": 1000, "calibration_default_step": 1.0, "calibration_default_duration": 3.0, "active": true}}] +[{"model": "scanner.configuration", "pk": 1, "fields": {"name": "Configuration par défaut", "author": 1, "sidebar_width": "25%", "default_grid_columns": 3, "opencv_fourcc_format": "mp4v", "opencv_video_type": "mp4", "grbl_xmax": 350.0, "grbl_ymax": 250.0, "capture_type": "rpi", "webcam_device_index": 2, "image_quality": 90, "video_jpeg_quality": 90, "video_frame_rate": 5.0, "video_width_capture": 2028, "video_height_capture": 1520, "calibration_crop_radius": 500, "calibration_default_multiwell": "HG", "calibration_default_feed": 1000, "calibration_default_step": 1.0, "calibration_default_duration": 3.0, "tracking": false, "active": true}}] diff --git a/etc/db/multiwell.json b/etc/db/multiwell.json index a3f2777..f813dd1 100644 --- a/etc/db/multiwell.json +++ b/etc/db/multiwell.json @@ -1 +1 @@ -[{"model": "scanner.multiwell", "pk": 2, "fields": {"label": "Haut gauche", "author": 1, "position": "HG", "default": false, "cols": 6, "rows": 4, "diameter": 16.0, "row_def": "A,B,C,D", "row_order": "D,C,B,A", "order": 2, "duration": 12, "xbase": 156.45, "ybase": 12.75, "dx": 19.3, "dy": 19.3, "feed": 1000, "well_position": true, "active": true}}, {"model": "scanner.multiwell", "pk": 3, "fields": {"label": "Haut droit", "author": 1, "position": "HD", "default": true, "cols": 6, "rows": 4, "diameter": 16.0, "row_def": "A,B,C,D", "row_order": "D,C,B,A", "order": 0, "duration": 3, "xbase": 10.963, "ybase": 15.825, "dx": 19.4, "dy": 19.4, "feed": 1000, "well_position": true, "active": true}}, {"model": "scanner.multiwell", "pk": 4, "fields": {"label": "Bas gauche", "author": 1, "position": "BG", "default": false, "cols": 6, "rows": 4, "diameter": 16.0, "row_def": "A,B,C,D", "row_order": "D,C,B,A", "order": 3, "duration": 12, "xbase": 150.15, "ybase": 112.55, "dx": 19.5, "dy": 19.5, "feed": 1000, "well_position": true, "active": true}}, {"model": "scanner.multiwell", "pk": 5, "fields": {"label": "Bas droit", "author": 1, "position": "BD", "default": false, "cols": 6, "rows": 4, "diameter": 16.0, "row_def": "A,B,C,D", "row_order": "D,C,B,A", "order": 1, "duration": 12, "xbase": 10.15, "ybase": 111.05, "dx": 19.55, "dy": 19.55, "feed": 1000, "well_position": true, "active": true}}] \ No newline at end of file +[{"model": "scanner.multiwell", "pk": 2, "fields": {"label": "Haut gauche", "author": 1, "position": "HG", "default": false, "cols": 6, "rows": 4, "diameter": 16.0, "row_def": "A,B,C,D", "row_order": "D,C,B,A", "order": 2, "duration": 12, "xbase": 156.45, "ybase": 12.75, "dx": 19.3, "dy": 19.3, "feed": 1000, "well_position": true, "active": true}}, {"model": "scanner.multiwell", "pk": 3, "fields": {"label": "Haut droit", "author": 1, "position": "HD", "default": true, "cols": 6, "rows": 4, "diameter": 16.0, "row_def": "A,B,C,D", "row_order": "D,C,B,A", "order": 0, "duration": 3, "xbase": 10.6, "ybase": 15.825, "dx": 19.4, "dy": 19.4, "feed": 1000, "well_position": true, "active": true}}, {"model": "scanner.multiwell", "pk": 4, "fields": {"label": "Bas gauche", "author": 1, "position": "BG", "default": false, "cols": 6, "rows": 4, "diameter": 16.0, "row_def": "A,B,C,D", "row_order": "D,C,B,A", "order": 3, "duration": 12, "xbase": 150.15, "ybase": 112.55, "dx": 19.5, "dy": 19.5, "feed": 1000, "well_position": true, "active": true}}, {"model": "scanner.multiwell", "pk": 5, "fields": {"label": "Bas droit", "author": 1, "position": "BD", "default": false, "cols": 6, "rows": 4, "diameter": 16.0, "row_def": "A,B,C,D", "row_order": "D,C,B,A", "order": 1, "duration": 12, "xbase": 10.15, "ybase": 111.05, "dx": 19.55, "dy": 19.55, "feed": 1000, "well_position": true, "active": true}}] diff --git a/etc/db/well.json b/etc/db/well.json index 28b6d12..1c32ba1 100644 --- a/etc/db/well.json +++ b/etc/db/well.json @@ -1 +1 @@ -[{"model": "scanner.well", "pk": 1, "fields": {"author": 1, "name": "A1"}}, {"model": "scanner.well", "pk": 2, "fields": {"author": 1, "name": "A2"}}, {"model": "scanner.well", "pk": 3, "fields": {"author": 1, "name": "A3"}}, {"model": "scanner.well", "pk": 4, "fields": {"author": 1, "name": "A4"}}, {"model": "scanner.well", "pk": 5, "fields": {"author": 1, "name": "A5"}}, {"model": "scanner.well", "pk": 6, "fields": {"author": 1, "name": "A6"}}, {"model": "scanner.well", "pk": 7, "fields": {"author": 1, "name": "B1"}}, {"model": "scanner.well", "pk": 8, "fields": {"author": 1, "name": "B2"}}, {"model": "scanner.well", "pk": 9, "fields": {"author": 1, "name": "B3"}}, {"model": "scanner.well", "pk": 10, "fields": {"author": 1, "name": "B4"}}, {"model": "scanner.well", "pk": 11, "fields": {"author": 1, "name": "B5"}}, {"model": "scanner.well", "pk": 12, "fields": {"author": 1, "name": "B6"}}, {"model": "scanner.well", "pk": 13, "fields": {"author": 1, "name": "C1"}}, {"model": "scanner.well", "pk": 14, "fields": {"author": 1, "name": "C2"}}, {"model": "scanner.well", "pk": 15, "fields": {"author": 1, "name": "C3"}}, {"model": "scanner.well", "pk": 16, "fields": {"author": 1, "name": "C4"}}, {"model": "scanner.well", "pk": 17, "fields": {"author": 1, "name": "C5"}}, {"model": "scanner.well", "pk": 18, "fields": {"author": 1, "name": "C6"}}, {"model": "scanner.well", "pk": 19, "fields": {"author": 1, "name": "D1"}}, {"model": "scanner.well", "pk": 20, "fields": {"author": 1, "name": "D2"}}, {"model": "scanner.well", "pk": 21, "fields": {"author": 1, "name": "D3"}}, {"model": "scanner.well", "pk": 22, "fields": {"author": 1, "name": "D4"}}, {"model": "scanner.well", "pk": 23, "fields": {"author": 1, "name": "D5"}}, {"model": "scanner.well", "pk": 24, "fields": {"author": 1, "name": "D6"}}] \ No newline at end of file +[{"model": "scanner.well", "pk": 1, "fields": {"author": 1, "name": "A1"}}, {"model": "scanner.well", "pk": 2, "fields": {"author": 1, "name": "A2"}}, {"model": "scanner.well", "pk": 3, "fields": {"author": 1, "name": "A3"}}, {"model": "scanner.well", "pk": 4, "fields": {"author": 1, "name": "A4"}}, {"model": "scanner.well", "pk": 5, "fields": {"author": 1, "name": "A5"}}, {"model": "scanner.well", "pk": 6, "fields": {"author": 1, "name": "A6"}}, {"model": "scanner.well", "pk": 7, "fields": {"author": 1, "name": "B1"}}, {"model": "scanner.well", "pk": 8, "fields": {"author": 1, "name": "B2"}}, {"model": "scanner.well", "pk": 9, "fields": {"author": 1, "name": "B3"}}, {"model": "scanner.well", "pk": 10, "fields": {"author": 1, "name": "B4"}}, {"model": "scanner.well", "pk": 11, "fields": {"author": 1, "name": "B5"}}, {"model": "scanner.well", "pk": 12, "fields": {"author": 1, "name": "B6"}}, {"model": "scanner.well", "pk": 13, "fields": {"author": 1, "name": "C1"}}, {"model": "scanner.well", "pk": 14, "fields": {"author": 1, "name": "C2"}}, {"model": "scanner.well", "pk": 15, "fields": {"author": 1, "name": "C3"}}, {"model": "scanner.well", "pk": 16, "fields": {"author": 1, "name": "C4"}}, {"model": "scanner.well", "pk": 17, "fields": {"author": 1, "name": "C5"}}, {"model": "scanner.well", "pk": 18, "fields": {"author": 1, "name": "C6"}}, {"model": "scanner.well", "pk": 19, "fields": {"author": 1, "name": "D1"}}, {"model": "scanner.well", "pk": 20, "fields": {"author": 1, "name": "D2"}}, {"model": "scanner.well", "pk": 21, "fields": {"author": 1, "name": "D3"}}, {"model": "scanner.well", "pk": 22, "fields": {"author": 1, "name": "D4"}}, {"model": "scanner.well", "pk": 23, "fields": {"author": 1, "name": "D5"}}, {"model": "scanner.well", "pk": 24, "fields": {"author": 1, "name": "D6"}}] diff --git a/test_tube_scanner/home/settings.py b/test_tube_scanner/home/settings.py index d99863b..8fd1d94 100644 --- a/test_tube_scanner/home/settings.py +++ b/test_tube_scanner/home/settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ 'celery', 'home', 'scanner', + 'planarian', ] diff --git a/test_tube_scanner/modules/planarian_metrics.py b/test_tube_scanner/modules/planarian_metrics.py new file mode 100644 index 0000000..ca16e75 --- /dev/null +++ b/test_tube_scanner/modules/planarian_metrics.py @@ -0,0 +1,700 @@ +""" +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 + +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é") diff --git a/test_tube_scanner/modules/tube_aligner.py b/test_tube_scanner/modules/tube_aligner.py index c72694a..31f4673 100644 --- a/test_tube_scanner/modules/tube_aligner.py +++ b/test_tube_scanner/modules/tube_aligner.py @@ -51,10 +51,12 @@ class TubeAligner: "tube_cx" : None, "tube_cy" : None, "tube_radius" : None, + "radius_mm" : self.TUBE_DIAMETER_MM / 2, "offset_x_px" : 0, "offset_y_px" : 0, "offset_x_mm" : 0.0, "offset_y_mm" : 0.0, + "px_per_mm" : 0.0, "action" : "none", "frame_annotated": None, "msg" : None, @@ -134,6 +136,8 @@ class TubeAligner: "tube_cx" : tx, "tube_cy" : ty, "tube_radius" : tr, + "radius_mm" : self.TUBE_DIAMETER_MM / 2, + "px_per_mm" : self.px_per_mm, "offset_x_px" : offset_x_px, "offset_y_px" : offset_y_px, "offset_x_mm" : dx_mm, diff --git a/test_tube_scanner/planarian/__init__.py b/test_tube_scanner/planarian/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_tube_scanner/planarian/admin.py b/test_tube_scanner/planarian/admin.py new file mode 100644 index 0000000..646a9be --- /dev/null +++ b/test_tube_scanner/planarian/admin.py @@ -0,0 +1,70 @@ +# planarian/admin.py + +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from .models import ExperimentConfig + + +@admin.register(ExperimentConfig) +class ExperimentConfigAdmin(admin.ModelAdmin): + """Admin Django pour les configurations d'expérience.""" + readonly_fields = ('experiment', ) + list_display = ("experiment", "well", "px_per_mm", "fps", + "thresh_immobile", "thresh_mobile", + "photo_mode", "chemo_strength", "created_at") + list_filter = ("photo_mode", "tube_axis") + search_fields = ("experiment", "well", "description") + ordering = ("-created_at",) + + fieldsets = ( + (_("Identification"), { + "fields": ("experiment", "well", "description"), + }), + (_("Calibration optique"), { + "fields": ("px_per_mm", "fps", "well_radius_mm"), + }), + (_("Seuils de mobilité EthoVision"), { + "fields": ("thresh_immobile", "thresh_mobile"), + }), + (_("Tracker"), { + "fields": ("tube_axis", "min_area_px", "planarian_count"), + }), + (_("Thigmotactisme"), { + "fields": ("thigmotaxis_wall_dist_mm",), + }), + (_("Phototactisme"), { + "fields": ("photo_mode", "photo_strength", "photo_x", "photo_y"), + "classes": ("collapse",), + }), + (_("Chimiotactisme"), { + "fields": ("chemo_strength", "chemo_x", "chemo_y", "chemo_radius_mm"), + "classes": ("collapse",), + }), + (_("Interactions inter-individus"), { + "fields": ("avoid_radius_mm", "aggreg_radius_mm"), + "classes": ("collapse",), + }), + ) + + # Action : export CSV template + actions = ["export_csv_template"] + + @admin.action(description=_("Exporter un template CSV de ces configurations")) + def export_csv_template(self, request, queryset): + import csv + from django.http import HttpResponse + from io import StringIO + + output = StringIO() + fields = [f.name for f in ExperimentConfig._meta.fields if f.name != "id"] # @UndefinedVariable + writer = csv.DictWriter(output, fieldnames=fields) + writer.writeheader() + for obj in queryset: + row = {f: getattr(obj, f) for f in fields} + writer.writerow(row) + + response = HttpResponse(output.getvalue(), content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="experiment_configs.csv"' + + return response + diff --git a/test_tube_scanner/planarian/apps.py b/test_tube_scanner/planarian/apps.py new file mode 100644 index 0000000..d69b6e8 --- /dev/null +++ b/test_tube_scanner/planarian/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PlanarianConfig(AppConfig): + name = 'planarian' diff --git a/test_tube_scanner/planarian/forms.py b/test_tube_scanner/planarian/forms.py new file mode 100644 index 0000000..67b773e --- /dev/null +++ b/test_tube_scanner/planarian/forms.py @@ -0,0 +1,91 @@ +# planarian/forms.py + +import csv +import io + +from django import forms +from django.utils.translation import gettext_lazy as _ +from .models import ExperimentConfig + + +class ExperimentConfigForm(forms.ModelForm): + """Formulaire de saisie/modification d'un ExperimentConfig.""" + + class Meta: + model = ExperimentConfig + fields = "__all__" + widgets = { + "description": forms.Textarea(attrs={"rows": 3}), + } + + def clean(self): + cleaned = super().clean() + if cleaned.get("thresh_immobile", 0) >= cleaned.get("thresh_mobile", 1): + raise forms.ValidationError( + _("Le seuil Immobile doit être inférieur au seuil Mobile.") + ) + if cleaned.get("avoid_radius_mm", 0) >= cleaned.get("aggreg_radius_mm", 1): + raise forms.ValidationError( + _("Le rayon d'évitement doit être inférieur au rayon d'agrégation.") + ) + return cleaned + + +class CsvImportForm(forms.Form): + """Formulaire d'import de paramètres depuis un fichier CSV.""" + + csv_file = forms.FileField( + label=_("Fichier CSV"), + help_text=_( + "Colonnes obligatoires : experiment, well, px_per_mm, fps. " + "Toutes les autres colonnes sont optionnelles." + ), + ) + overwrite = forms.BooleanField( + required=False, + initial=False, + label=_("Écraser les configurations existantes"), + ) + + def clean_csv_file(self): + f = self.cleaned_data["csv_file"] + try: + content = f.read().decode("utf-8") + reader = csv.DictReader(io.StringIO(content)) + rows = list(reader) + except Exception as e: + raise forms.ValidationError(_("Fichier CSV invalide : %(err)s") % {"err": e}) + + required = {"experiment", "well", "px_per_mm", "fps"} + if rows: + missing = required - set(rows[0].keys()) + if missing: + raise forms.ValidationError( + _("Colonnes manquantes : %(cols)s") % {"cols": ", ".join(missing)} + ) + self.csv_rows = rows + return f + + +class ExportCsvForm(forms.Form): + """Formulaire de demande d'export CSV depuis ReductStore.""" + + experiment = forms.CharField(label=_("Expérience"), max_length=100) + well = forms.CharField(label=_("Puits"), max_length=20) + planarian = forms.IntegerField(label=_("Index planaire"), initial=0, min_value=0) + record_type = forms.ChoiceField( + label=_("Type d'enregistrement"), + choices=[("frame", _("Frame par frame")), ("summary", _("Résumé"))], + initial="frame", + ) + start_dt = forms.DateTimeField( + label=_("Début (UTC)"), + required=False, + widget=forms.DateTimeInput(attrs={"type": "datetime-local"}), + ) + stop_dt = forms.DateTimeField( + label=_("Fin (UTC)"), + required=False, + widget=forms.DateTimeInput(attrs={"type": "datetime-local"}), + ) + diff --git a/test_tube_scanner/planarian/models.py b/test_tube_scanner/planarian/models.py new file mode 100644 index 0000000..ed2a7fd --- /dev/null +++ b/test_tube_scanner/planarian/models.py @@ -0,0 +1,164 @@ +# planarian/models.py + +from django.db import models +from django.dispatch import receiver +from django.db.models.signals import post_save + +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.models import User +from scanner.models import Experiment, Well, WellPosition, Configuration + +class ExperimentConfig(models.Model): + """ + Paramètres d'une expérience PlanarianScanner. + Peut être créé depuis Django admin, une vue formulaire ou un import CSV. + """ + 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) + 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"), + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le")) + + # --- Calibration optique --- + # px_per_mm, fps, well_radius_mm + px_per_mm = models.FloatField( + default=26.25, + verbose_name=_("Pixels par mm"), + help_text=_("Facteur de calibration optique"), + ) + fps = models.FloatField( + default=5.0, + verbose_name=_("FPS de capture"), + ) + well_radius_mm = models.FloatField( + default=8.0, + verbose_name=_("Rayon du puits (mm)"), + ) + + # --- Seuils de mobilité EthoVision --- + thresh_immobile = models.FloatField( + default=0.2, + verbose_name=_("Seuil Immobile (mm/s)"), + ) + thresh_mobile = models.FloatField( + default=1.5, + verbose_name=_("Seuil Mobile (mm/s)"), + ) + + # --- Tracker --- + tube_axis = models.CharField( + max_length=10, + default="vertical", + choices=[("vertical", _("Vertical")), ("horizontal", _("Horizontal"))], + verbose_name=_("Axe du tube"), + ) + min_area_px = models.IntegerField( + default=20, + verbose_name=_("Surface min détection (px²)"), + ) + planarian_count = models.IntegerField( + default=1, + verbose_name=_("Nombre de planaires"), + ) + + # --- Thigmotactisme --- + thigmotaxis_wall_dist_mm = models.FloatField( + default=1.0, + verbose_name=_("Distance paroi thigmotactisme (mm)"), + ) + + # --- Phototactisme --- + PHOTO_MODES = [ + ("none", _("Désactivé")), + ("fixed", _("Source fixe")), + ("sine", _("Source sinusoïdale")), + ("radial", _("Gradient radial")), + ] + photo_mode = models.CharField( + max_length=10, + default="none", + choices=PHOTO_MODES, + verbose_name=_("Mode phototactisme"), + ) + photo_strength = models.FloatField(default=0.0, verbose_name=_("Intensité phototactisme")) + photo_x = models.FloatField(default=0.5, verbose_name=_("Source lumière X (0-1)")) + photo_y = models.FloatField(default=0.5, verbose_name=_("Source lumière Y (0-1)")) + + # --- Chimiotactisme --- + chemo_strength = models.FloatField(default=0.0, verbose_name=_("Intensité chimiotactisme")) + chemo_x = models.FloatField(default=0.5, verbose_name=_("Nourriture X (0-1)")) + chemo_y = models.FloatField(default=0.5, verbose_name=_("Nourriture Y (0-1)")) + chemo_radius_mm = models.FloatField(default=2.0, verbose_name=_("Rayon nourriture (mm)")) + + # --- Interactions inter-individus --- + avoid_radius_mm = models.FloatField(default=3.0, verbose_name=_("Rayon évitement (mm)")) + aggreg_radius_mm = models.FloatField(default=6.0, verbose_name=_("Rayon agrégation (mm)")) + + class Meta: + verbose_name = _("Configuration expérience") + verbose_name_plural = _("Configurations expériences") + unique_together = ("experiment", "well") + ordering = ["-created_at"] + + def __str__(self): + return f"{self.experiment} / {self.well.name}" + + def get_session(self): + return self.experiment.session_experiments.first() if self.experiment else None + + def to_params_dict(self) -> dict: + """Retourne un dict compatible avec ExperimentParams.""" + return { + "experiment": self.idendifier, + "well": self.well.name, + "px_per_mm": self.px_per_mm, + "fps": self.fps, + "well_radius_mm": self.well_radius_mm, + "thresh_immobile": self.thresh_immobile, + "thresh_mobile": self.thresh_mobile, + "tube_axis": self.tube_axis, + "min_area_px": self.min_area_px, + "planarian_count": self.planarian_count, + "thigmotaxis_wall_dist_mm": self.thigmotaxis_wall_dist_mm, + "photo_mode": self.photo_mode, + "photo_strength": self.photo_strength, + "chemo_strength": self.chemo_strength, + "chemo_x": self.chemo_x, + "chemo_y": self.chemo_y, + "chemo_radius_mm": self.chemo_radius_mm, + "avoid_radius_mm": self.avoid_radius_mm, + "aggreg_radius_mm": self.aggreg_radius_mm, + } + + 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}' + 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 = Configuration.active_config() + instance.fps = conf.video_frame_rate + instance.save() + + + + diff --git a/test_tube_scanner/planarian/templates/planarian/experiment_form.html b/test_tube_scanner/planarian/templates/planarian/experiment_form.html new file mode 100644 index 0000000..1833c4a --- /dev/null +++ b/test_tube_scanner/planarian/templates/planarian/experiment_form.html @@ -0,0 +1,503 @@ +{% extends "scanner/base.html" %} +{% load i18n %} + +{% block content %} + +
+ {{ configs|length }} {% trans "configuration(s)" %} +
+ +| + {% trans "Expérience" %} + | ++ {% trans "Puits" %} + | + + + + + +{% trans "Actions" %} | +
|---|---|---|
|
+ {{ cfg.experiment }}
+ {% if cfg.description %}
+ {{ cfg.description|truncatechars:40 }} + {% endif %} + |
+
+
+ + {{ cfg.well }} + | + + + + + + + + + + + + + + + + + ++ ✏ + 📥 + + | + +
🔬
++ {% trans "Créez une première configuration ou importez un fichier CSV." %} +
+ + ➕ {% trans "Nouvelle configuration" %} + + + 📂 {% trans "Importer CSV" %} + ++ {% trans "Sélectionnez l'expérience, le puits et le type d'enregistrement à exporter." %} + {% trans "Le fichier CSV sera généré depuis ReductStore et téléchargé directement." %} +
++ {% trans "Colonnes exportées compatibles EthoVision XT : distance, vitesse, états de mobilité, thigmotactisme." %} +
+{% trans "Identification / position" %}
+timestampx_mm / y_mmcx_px / cy_pxarea_pxaxial_pos / axial_speed{% trans "Métriques EthoVision XT" %}
+velocity_mm_s — {% trans "vitesse instantanée" %}total_distance_mm — {% trans "distance cumulée" %}moving / duration_moving_smobility_state — {% trans "Immobile / Mobile / Highly mobile" %}dist_to_wall_mm / near_wall{% trans "Colonnes obligatoires" %}
+| {% trans "Colonne" %} | +{% trans "Type" %} | +{% trans "Exemple" %} | +{% trans "Description" %} | +
|---|---|---|---|
experiment |
+ {% trans "texte" %} | +exp_2026_04_25 |
+ {% trans "Identifiant unique de l'expérience" %} | +
well |
+ {% trans "texte" %} | +A1 |
+ {% trans "Identifiant du puits" %} | +
px_per_mm |
+ {% trans "flottant" %} | +26.25 |
+ {% trans "Calibration optique (pixels/mm)" %} | +
fps |
+ {% trans "flottant" %} | +10.0 |
+ {% trans "Fréquence de capture (images/s)" %} | +
{% trans "Colonnes optionnelles" %} + {% trans "(valeurs par défaut utilisées si absentes)" %} +
+| {% trans "Colonne" %} | +{% trans "Défaut" %} | +{% trans "Description" %} | +
|---|---|---|
well_radius_mm | 8.0 | {% trans "Rayon du puits (mm)" %} |
thresh_immobile | 0.2 | {% trans "Seuil Immobile EthoVision (mm/s)" %} |
thresh_mobile | 1.5 | {% trans "Seuil Mobile EthoVision (mm/s)" %} |
tube_axis | vertical | {% trans "Axe du tube : vertical | horizontal" %} |
min_area_px | 20 | {% trans "Surface min de détection (px²)" %} |
planarian_count | 1 | {% trans "Nombre de planaires par puits" %} |
photo_mode | none | {% trans "Phototactisme : none | fixed | sine | radial" %} |
photo_strength | 0.0 | {% trans "Intensité phototactisme (0-1)" %} |
chemo_strength | 0.0 | {% trans "Intensité chimiotactisme (0-1)" %} |
chemo_radius_mm | 2.0 | {% trans "Rayon zone nourriture (mm)" %} |
avoid_radius_mm | 3.0 | {% trans "Rayon évitement inter-individus (mm)" %} |
aggreg_radius_mm | 6.0 | {% trans "Rayon agrégation inter-individus (mm)" %} |
{% trans "Exemple minimal" %}
+
+experiment,well,px_per_mm,fps,thresh_immobile,thresh_mobile,photo_mode
+exp_ctrl_01,A1,26.25,10,0.2,1.5,none
+exp_ctrl_01,A2,26.25,10,0.2,1.5,none
+exp_light_01,B1,26.25,10,0.2,1.5,fixed
+exp_light_01,B2,26.25,10,0.2,1.5,fixed
+
+
+
+