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 %} + +
+ + +
+
+ + 🔬 + {% if object %} + {% trans "Modifier la configuration" %} — {{ object }} + {% else %} + {% trans "Nouvelle configuration d'expérience" %} + {% endif %} + +
+
+ + + {% for message in messages %} +
+

{{ message }}

+
+ {% endfor %} + +
+ {% csrf_token %} + + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +

⚠ {{ error }}

+ {% endfor %} +
+ {% endif %} + + +
+
+

{% trans "Identification" %}

+
+
+
+ + +
+ + {% if form.experiment.help_text %} +

{{ form.experiment.help_text }}

+ {% endif %} + {{ form.experiment }} + {% if form.experiment.errors %} + {{ form.experiment.errors|join:", " }} + {% endif %} +
+ + +
+ + {% if form.well.help_text %} +

{{ form.well.help_text }}

+ {% endif %} + {{ form.well }} + {% if form.well.errors %} + {{ form.well.errors|join:", " }} + {% endif %} +
+ +
+ + +
+ + {{ form.description }} + {% if form.description.errors %} + {{ form.description.errors|join:", " }} + {% endif %} +
+ +
+
+ + +
+
+

{% trans "Calibration optique" %}

+
+
+
+ +
+ + {% if form.px_per_mm.help_text %} +

{{ form.px_per_mm.help_text }}

+ {% endif %} + {{ form.px_per_mm }} + {% if form.px_per_mm.errors %} + {{ form.px_per_mm.errors|join:", " }} + {% endif %} +
+ +
+ + {{ form.fps }} + {% if form.fps.errors %} + {{ form.fps.errors|join:", " }} + {% endif %} +
+ +
+ + {{ form.well_radius_mm }} + {% if form.well_radius_mm.errors %} + {{ form.well_radius_mm.errors|join:", " }} + {% endif %} +
+ +
+
+
+ + +
+
+

{% trans "Seuils de mobilité EthoVision XT" %}

+
+
+ + +
+
+ {% trans "Immobile" %} +
+
+ {% trans "Mobile" %} +
+
+ {% trans "Très mobile" %} +
+
+

+ {% trans "Représentation indicative des zones de mobilité (défauts EthoVision : 0.2 / 1.5 mm/s)" %} +

+ +
+
+ +

{% trans "En-dessous : Immobile (mm/s)" %}

+ {{ form.thresh_immobile }} + {% if form.thresh_immobile.errors %} + {{ form.thresh_immobile.errors|join:", " }} + {% endif %} +
+
+ +

{% trans "En-dessous : Mobile, au-delà : Très mobile (mm/s)" %}

+ {{ form.thresh_mobile }} + {% if form.thresh_mobile.errors %} + {{ form.thresh_mobile.errors|join:", " }} + {% endif %} +
+
+
+
+ + +
+
+

{% trans "Tracker" %}

+
+
+
+ +
+ + {{ form.tube_axis }} + {% if form.tube_axis.errors %} + {{ form.tube_axis.errors|join:", " }} + {% endif %} +
+ +
+ + {{ form.min_area_px }} + {% if form.min_area_px.errors %} + {{ form.min_area_px.errors|join:", " }} + {% endif %} +
+ +
+ + {{ form.planarian_count }} + {% if form.planarian_count.errors %} + {{ form.planarian_count.errors|join:", " }} + {% endif %} +
+ +
+
+
+ + +
+
+

{% trans "Comportements" %}

+
+ + +
+ +
+
+
+ +

+ {% trans "Distance à la paroi considérée « près du bord » (mm)" %} +

+ {{ form.thigmotaxis_wall_dist_mm }} + {% if form.thigmotaxis_wall_dist_mm.errors %} + {{ form.thigmotaxis_wall_dist_mm.errors|join:", " }} + {% endif %} +
+
+
+
+ + +
+ +
+
+ +
+ + {{ form.photo_mode }} + {% if form.photo_mode.errors %} + {{ form.photo_mode.errors|join:", " }} + {% endif %} +
+ +
+ +

{% trans "0.0 = désactivé → 1.0 = fort" %}

+ {{ form.photo_strength }} + {% if form.photo_strength.errors %} + {{ form.photo_strength.errors|join:", " }} + {% endif %} +
+ +
+ + {{ form.photo_x }} + {% if form.photo_x.errors %} + {{ form.photo_x.errors|join:", " }} + {% endif %} +
+ +
+ + {{ form.photo_y }} + {% if form.photo_y.errors %} + {{ form.photo_y.errors|join:", " }} + {% endif %} +
+ +
+
+
+ + +
+ +
+
+ +
+ +

{% trans "0.0 = désactivé → 1.0 = fort" %}

+ {{ form.chemo_strength }} + {% if form.chemo_strength.errors %} + {{ form.chemo_strength.errors|join:", " }} + {% endif %} +
+ +
+ + {{ form.chemo_x }} + {% if form.chemo_x.errors %} + {{ form.chemo_x.errors|join:", " }} + {% endif %} +
+ +
+ + {{ form.chemo_y }} + {% if form.chemo_y.errors %} + {{ form.chemo_y.errors|join:", " }} + {% endif %} +
+ +
+ + {{ form.chemo_radius_mm }} + {% if form.chemo_radius_mm.errors %} + {{ form.chemo_radius_mm.errors|join:", " }} + {% endif %} +
+ +
+
+
+ + +
+ +
+
+ +
+ +

{% trans "Rayon de répulsion courte portée (mm)" %}

+ {{ form.avoid_radius_mm }} + {% if form.avoid_radius_mm.errors %} + {{ form.avoid_radius_mm.errors|join:", " }} + {% endif %} +
+ +
+ +

{% trans "Rayon d'attraction longue portée — doit être > rayon évitement (mm)" %}

+ {{ form.aggreg_radius_mm }} + {% if form.aggreg_radius_mm.errors %} + {{ form.aggreg_radius_mm.errors|join:", " }} + {% endif %} +
+ +
+
+
+ +
+ + +
+ +
+ + + ✖ {% trans "Annuler" %} + +
+ + {% if object %} + + {% endif %} + +
+ +
+ +
+ + + + + + +{% endblock %} diff --git a/test_tube_scanner/planarian/templates/planarian/experiment_list.html b/test_tube_scanner/planarian/templates/planarian/experiment_list.html new file mode 100644 index 0000000..403d567 --- /dev/null +++ b/test_tube_scanner/planarian/templates/planarian/experiment_list.html @@ -0,0 +1,334 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} + +
+ + +
+
+
+ + 🔬 + {% trans "Configurations d'expériences" %} + +
+ +
+
+ + + {% for message in messages %} +
+

{{ message }}

+
+ {% endfor %} + + +
+ + +
+
+ 🔍 + +
+
+ + + + +
+ + + {% if configs %} + + +

+ {{ configs|length }} {% trans "configuration(s)" %} +

+ +
+ + + + + + + + + + + + + + + {% for cfg in configs %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
+ {% trans "Expérience" %} + + {% trans "Puits" %} + + {% trans "px/mm" %} + {% trans "FPS" %}{% trans "Seuils (mm/s)" %}{% trans "Comportements" %} + {% trans "Créé le" %} + {% trans "Actions" %}
+ {{ cfg.experiment }} + {% if cfg.description %} +
{{ cfg.description|truncatechars:40 }} + {% endif %} +
+ {{ cfg.well }} + {{ cfg.px_per_mm }}{{ cfg.fps }} + + < {{ cfg.thresh_immobile }} + + + < {{ cfg.thresh_mobile }} + + + {% if cfg.thigmotaxis_wall_dist_mm > 0 %} + 🫧 + {% endif %} + {% if cfg.photo_mode != 'none' and cfg.photo_strength > 0 %} + 💡 + {% endif %} + {% if cfg.chemo_strength > 0 %} + 🍖 + {% endif %} + {% if cfg.avoid_radius_mm > 0 or cfg.aggreg_radius_mm > 0 %} + 🔀 + {% endif %} + + {{ cfg.created_at|date:"d/m/Y H:i" }} + + + 📥 + +
+
+ + + + + {% else %} + +
+

🔬

+

{% trans "Aucune configuration pour l'instant." %}

+

+ {% trans "Créez une première configuration ou importez un fichier CSV." %} +

+ + ➕ {% trans "Nouvelle configuration" %} + + + 📂 {% trans "Importer CSV" %} + +
+ {% endif %} + +
+ + +
+
+
+

🗑 {% trans "Confirmer la suppression" %}

+
+
+

+
+ {% csrf_token %} + + + +
+
+
+
+ + + + + + + +{% endblock %} diff --git a/test_tube_scanner/planarian/templates/planarian/export_csv.html b/test_tube_scanner/planarian/templates/planarian/export_csv.html new file mode 100644 index 0000000..3ef2ceb --- /dev/null +++ b/test_tube_scanner/planarian/templates/planarian/export_csv.html @@ -0,0 +1,254 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} + +
+ + +
+
+
+ + 📥 {% trans "Exporter les données vers CSV" %} + +
+ +
+
+ + + {% for message in messages %} +
+

{{ message }}

+
+ {% endfor %} + + +
+

+ {% 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." %} +

+
+ + +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +

⚠ {{ error }}

+ {% endfor %} +
+ {% endif %} + +
+
+

{% trans "Paramètres d'export" %}

+
+
+ + +
+ +
+ + {% if form.experiment.help_text %} +

{{ form.experiment.help_text }}

+ {% endif %} + + + + {% for cfg in experiment_choices %} + + {% if form.experiment.errors %} + {{ form.experiment.errors|join:", " }} + {% endif %} +
+ +
+ + + + {% for w in well_choices %} + + {% if form.well.errors %} + {{ form.well.errors|join:", " }} + {% endif %} +
+ +
+ + +
+ +
+ +

+ {% trans "Index du planaire dans le puits (commence à 0)" %} +

+ {{ form.planarian }} + {% if form.planarian.errors %} + {{ form.planarian.errors|join:", " }} + {% endif %} +
+ +
+ +

+ {% trans "Frame par frame : une ligne par image. Résumé : métriques agrégées de la session." %} +

+ +
+ {% for radio in form.record_type %} +
+ +
+ {% endfor %} +
+ {% if form.record_type.errors %} + {{ form.record_type.errors|join:", " }} + {% endif %} +
+ +
+ + +
+

+ {% trans "Plage temporelle" %} + + {% trans "(optionnel — laisser vide pour exporter toute la session)" %} + +

+
+ +
+ + {{ form.start_dt }} + {% if form.start_dt.errors %} + {{ form.start_dt.errors|join:", " }} + {% endif %} +
+ +
+ + {{ form.stop_dt }} + {% if form.stop_dt.errors %} + {{ form.stop_dt.errors|join:", " }} + {% endif %} +
+ +
+
+ +
+
+ + +
+
+ + + ✖ {% trans "Annuler" %} + +
+
+ +
+ + +
+
+

+ {% trans "Colonnes du fichier CSV exporté" %} +

+
+
+
+ +
+

{% trans "Identification / position" %}

+
    +
  • timestamp
  • +
  • x_mm / y_mm
  • +
  • cx_px / cy_px
  • +
  • area_px
  • +
  • axial_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_s
  • +
  • mobility_state — {% trans "Immobile / Mobile / Highly mobile" %}
  • +
  • dist_to_wall_mm / near_wall
  • +
+
+ +
+
+
+ +
+ + + +{% endblock %} diff --git a/test_tube_scanner/planarian/templates/planarian/import_params.html b/test_tube_scanner/planarian/templates/planarian/import_params.html new file mode 100644 index 0000000..3e235fd --- /dev/null +++ b/test_tube_scanner/planarian/templates/planarian/import_params.html @@ -0,0 +1,326 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} + +
+ + +
+
+
+ + 📂 {% trans "Importer des configurations depuis CSV" %} + +
+ +
+
+ + + {% for message in messages %} +
+

{{ message }}

+
+ {% endfor %} + + +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +

⚠ {{ error }}

+ {% endfor %} +
+ {% endif %} + +
+
+

{% trans "Fichier CSV à importer" %}

+
+
+ + +
+ +

📄

+

+ {% trans "Glissez-déposez votre fichier CSV ici" %}
+ + {% trans "ou cliquez pour sélectionner" %} + +

+ + + +
+ + {% if form.csv_file.errors %} +
+ {% for error in form.csv_file.errors %} +

⚠ {{ error }}

+ {% endfor %} +
+ {% endif %} + + + + + +
+ + {% if form.overwrite.errors %} + {{ form.overwrite.errors|join:", " }} + {% endif %} +
+ +
+
+ + +
+
+ + + ✖ {% trans "Annuler" %} + +
+
+ +
+ + +
+
+

{% trans "Format du fichier CSV" %}

+
+
+ + +

{% 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
+
+ + +
+ + ⬇ {% trans "Télécharger un template CSV vide" %} + + + {% trans "Ou exportez vos configurations existantes depuis l'admin Django." %} + +
+ +
+
+ +
+ + + + + +{% endblock %} diff --git a/test_tube_scanner/planarian/tests.py b/test_tube_scanner/planarian/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/test_tube_scanner/planarian/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/test_tube_scanner/planarian/urls.py b/test_tube_scanner/planarian/urls.py new file mode 100644 index 0000000..dd9c784 --- /dev/null +++ b/test_tube_scanner/planarian/urls.py @@ -0,0 +1,21 @@ +# planarian/urls.py + +from django.urls import path +from planarian import views + +app_name = "planarian" + +urlpatterns = [ + # Configurations expériences + path("experiments/", views.ExperimentConfigListView.as_view(), name="experiment-list"), + path("experiments/new/", views.ExperimentConfigFormView.as_view(), name="experiment-new"), + path("experiments//",views.ExperimentConfigFormView.as_view(), name="experiment-edit"), + + # Import / export + path("import/", views.ImportParamsView.as_view(), name="import-params"), + path("export/", views.ExportCsvView.as_view(), name="export-csv"), + + # API JSON pour le front-end + path("api/tracking/", views.TrackingDataView.as_view(), name="tracking-data-api"), +] + diff --git a/test_tube_scanner/planarian/views.py b/test_tube_scanner/planarian/views.py new file mode 100644 index 0000000..583d961 --- /dev/null +++ b/test_tube_scanner/planarian/views.py @@ -0,0 +1,210 @@ +# planarian/views.py + +#import asyncio +import logging + +from asgiref.sync import async_to_sync +from django.conf import settings +from django.contrib import messages +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect #, render +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.generic import FormView, ListView + + +from .forms import CsvImportForm, ExperimentConfigForm, ExportCsvForm +from .models import ExperimentConfig +from modules.planarian_metrics import ExperimentParams, ReductStoreClient + + +logger = logging.getLogger(__name__) + + +def _get_reduct_client() -> ReductStoreClient: + """Instancie le client ReductStore depuis les settings Django.""" + return ReductStoreClient( + url = getattr(settings, "REDUCTSTORE_URL", "http://localhost:8383"), + token = getattr(settings, "REDUCTSTORE_TOKEN", ""), + bucket = getattr(settings, "REDUCTSTORE_BUCKET", "planarian_metrics"), + ) + + +# --------------------------------------------------------------------------- +# Vue : liste des configurations +# --------------------------------------------------------------------------- + +class ExperimentConfigListView(ListView): + """Liste toutes les configurations expériences.""" + + model = ExperimentConfig + template_name = "planarian/experiment_list.html" + context_object_name = "configs" + ordering = ["-created_at"] + + +# --------------------------------------------------------------------------- +# Vue : création / modification d'une configuration +# --------------------------------------------------------------------------- + +class ExperimentConfigFormView(FormView): + """Formulaire de saisie des paramètres d'une expérience.""" + + template_name = "planarian/experiment_form.html" + form_class = ExperimentConfigForm + + def get_form(self, form_class=None): + pk = self.kwargs.get("pk") + if pk: + instance = get_object_or_404(ExperimentConfig, pk=pk) + return ExperimentConfigForm(self.request.POST or None, instance=instance) + return ExperimentConfigForm(self.request.POST or None) + + def form_valid(self, form): + form.save() + messages.success(self.request, _("Configuration sauvegardée.")) + return redirect("planarian:experiment-list") + + +# --------------------------------------------------------------------------- +# Vue : import CSV de paramètres +# --------------------------------------------------------------------------- + +class ImportParamsView(FormView): + """ + Import de configurations d'expérience depuis un fichier CSV. + Une ligne CSV = un puits = un ExperimentConfig. + + Colonnes CSV obligatoires : experiment, well, px_per_mm, fps + Toutes les autres colonnes correspondent aux champs du modèle. + """ + + template_name = "planarian/import_params.html" + form_class = CsvImportForm + + def form_valid(self, form): + rows = form.csv_rows + overwrite = form.cleaned_data["overwrite"] + created = 0 + updated = 0 + errors = 0 + + for row in rows: + try: + params = ExperimentParams.from_csv_row(row) + d = params.to_dict() + + obj, is_new = ExperimentConfig.objects.get_or_create( + experiment = d["experiment"], + well = d["well"], + ) + + if is_new or overwrite: + for k, v in d.items(): + if k not in ("experiment", "well") and hasattr(obj, k): + setattr(obj, k, v) + obj.save() + if is_new: + created += 1 + else: + updated += 1 + + except Exception as e: + logger.warning(f"Ligne ignorée ({row}): {e}") + errors += 1 + + messages.success( + self.request, + _("Import terminé : %(c)d créés, %(u)d mis à jour, %(e)d erreurs.") + % {"c": created, "u": updated, "e": errors}, + ) + return redirect("planarian:experiment-list") + + +# --------------------------------------------------------------------------- +# Vue : export CSV depuis ReductStore +# --------------------------------------------------------------------------- + +class ExportCsvView(FormView): + """ + Export des données de tracking depuis ReductStore vers un fichier CSV. + Retourne le fichier en téléchargement HTTP. + """ + + template_name = "planarian/export_csv.html" + form_class = ExportCsvForm + + def form_valid(self, form): + d = form.cleaned_data + + @async_to_sync + async def _do_export(): + client = _get_reduct_client() + await client.connect() + try: + csv_content, n = await client.export_csv_response( + experiment = d["experiment"], + well = d["well"], + planarian = d["planarian"], + record_type = d["record_type"], + start = d.get("start_dt"), + stop = d.get("stop_dt"), + ) + finally: + await client.close() + return csv_content, n + + csv_content, n = _do_export() + + if not csv_content: + messages.warning(self.request, _("Aucune donnée trouvée.")) + return self.form_invalid(form) + + filename = ( + f"{d['experiment']}_{d['well']}_planaire{d['planarian']}" + f"_{d['record_type']}.csv" + ) + response = HttpResponse(csv_content, content_type="text/csv; charset=utf-8") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + messages.success(self.request, _("%(n)d lignes exportées.") % {"n": n}) + return response + + +# --------------------------------------------------------------------------- +# Vue API JSON : données de tracking (pour polling front-end) +# --------------------------------------------------------------------------- + +class TrackingDataView(View): + """ + API JSON retournant les métriques de tracking d'un planaire. + Utilisable pour un affichage temps réel ou un graphe front-end. + + GET /tracking-data/?experiment=X&well=Y&planarian=0&record_type=frame + """ + + def get(self, request): + experiment = request.GET.get("experiment", "") + well = request.GET.get("well", "") + planarian = int(request.GET.get("planarian", 0)) + record_type = request.GET.get("record_type", "frame") + + if not experiment or not well: + return JsonResponse({"error": "experiment et well requis"}, status=400) + + @async_to_sync + async def _fetch(): + client = _get_reduct_client() + await client.connect() + try: + return await client.get_tracking_data( + experiment = experiment, + well = well, + planarian = planarian, + record_type = record_type, + ) + finally: + await client.close() + + records = _fetch() + return JsonResponse({"count": len(records), "records": records}) + diff --git a/test_tube_scanner/scanner/admin.py b/test_tube_scanner/scanner/admin.py index 784693b..e9b4b77 100644 --- a/test_tube_scanner/scanner/admin.py +++ b/test_tube_scanner/scanner/admin.py @@ -2,12 +2,13 @@ from django.contrib import admin from django.db.models import Q from . import models + class WellAdmin(admin.ModelAdmin): model = models.Well list_display = ('name', 'author',) class ConfigurationAdmin(admin.ModelAdmin): - list_display = ('name', 'author', 'use_rpicam', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'active',) + list_display = ('name', 'author', 'capture_type', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'active',) class MultiWellAdmin(admin.ModelAdmin): list_filter = ('author', ) @@ -15,33 +16,33 @@ class MultiWellAdmin(admin.ModelAdmin): class WellPositionAdmin(admin.ModelAdmin): list_filter = ('author', 'multiwell') - list_display = ('multiwell__position', 'well__name', 'order', 'x', 'y', 'author',) + list_display = ('multiwell__position', 'well__name', 'order', 'x', 'y', 'px_per_mm', 'author',) -class ObservationMultiWellDetailInline(admin.TabularInline): - model = models.ObservationMultiWellDetail - extra = 0 +#class ExperimentConfigInline(admin.TabularInline): +# model = models.ExperimentConfig +# extra = 0 -class ObservationAdmin(admin.ModelAdmin): - inlines = (ObservationMultiWellDetailInline,) - list_filter = ('sessionobservation__session', 'author', ) +class ExperimentAdmin(admin.ModelAdmin): + #inlines = (ExperimenConfigInline,) + list_filter = ('session_experiments__session', 'author', ) list_display = ('title', 'author', 'multiwell', 'created', 'started', 'finished') readonly_fields = ('created', 'started', 'finished', ) -class SessionObservationInlineAdmin(admin.TabularInline): - model = models.SessionObservation +class SessionExperimentInlineAdmin(admin.TabularInline): + model = models.SessionExperiment fk_name = 'session' extra = 0 def formfield_for_foreignkey(self, db_field, request, **kwargs): - if db_field.name == "observation": + if db_field.name == "experiment": obj_id = request.resolver_match.kwargs.get("object_id") - qs = models.Observation.objects.filter(sessionobservation__isnull=True) + qs = models.Experiment.objects.filter(session_experiments__isnull=True) if obj_id: - qs = models.Observation.objects.filter( - Q(sessionobservation__isnull=True) | - Q(sessionobservation__session_id=obj_id) + qs = models.Experiment.objects.filter( + Q(session_experiments__isnull=True) | + Q(session_experiments__session_id=obj_id) ) kwargs["queryset"] = qs.distinct() @@ -49,7 +50,7 @@ class SessionObservationInlineAdmin(admin.TabularInline): class SessionAdmin(admin.ModelAdmin): list_filter = ('author',) - inlines = (SessionObservationInlineAdmin, ) + inlines = (SessionExperimentInlineAdmin, ) list_display = ('name', 'author', 'created', 'finished', 'active', 'expected_export', 'expected_scanning', ) readonly_fields = ( 'created', @@ -65,6 +66,7 @@ class SessionAdmin(admin.ModelAdmin): admin.site.register(models.Configuration, ConfigurationAdmin) admin.site.register(models.Well, WellAdmin) admin.site.register(models.MultiWell, MultiWellAdmin) -admin.site.register(models.WellPostion, WellPositionAdmin) -admin.site.register(models.Observation, ObservationAdmin) +admin.site.register(models.WellPosition, WellPositionAdmin) +admin.site.register(models.Experiment, ExperimentAdmin) admin.site.register(models.Session, SessionAdmin) + diff --git a/test_tube_scanner/scanner/export_tasks.py b/test_tube_scanner/scanner/export_tasks.py index abc5ad9..8af0bb9 100644 --- a/test_tube_scanner/scanner/export_tasks.py +++ b/test_tube_scanner/scanner/export_tasks.py @@ -512,93 +512,5 @@ def _resize_frame( return frame -# tasks/export_tasks.py - -from celery import shared_task, group -from django.utils import timezone -import logging - -logger = logging.getLogger(__name__) - - -@shared_task(bind=True) -def run_session_exports(self, session_id: str): - """ - Point d'entrée déclenché par django_celery_beat. - Lance en parallèle l'export images et l'export vidéo de la session. - """ - from cameras.models import ExportSession - - try: - session = ExportSession.objects.get(session_id=session_id) - except ExportSession.DoesNotExist: - logger.error("run_session_exports: session %s introuvable", session_id) - return {"status": "error", "message": "Session introuvable"} - - session.status = ExportSession.Status.RUNNING - session.save(update_fields=["status"]) - - try: - # Lancement en parallèle avec group Celery - job = group( - export_all_images.s(session_id), - export_all_videos.s(session_id), - ).apply_async() - - results = job.get(timeout=7200) # 2h max pour les deux - - session.status = ExportSession.Status.DONE - session.exported_at = timezone.now() - session.save(update_fields=["status", "exported_at"]) - - return {"status": "success", "results": results} - - except Exception as exc: - session.status = ExportSession.Status.ERROR - session.save(update_fields=["status"]) - logger.error("run_session_exports [%s]: %s", session_id, exc, exc_info=True) - return {"status": "error", "message": str(exc)} - - -@shared_task(bind=True) -def export_all_images(self, session_id: str): - """ - Export ZIP de toutes les images de la session. - """ - from cameras.models import ExportSession - - session = ExportSession.objects.get(session_id=session_id) - - return export_images_zip( - session.camera_uuid, - session.start_ts, - session.end_ts, - max_zip_size_mb = session.max_zip_size_mb, - jpeg_quality = session.jpeg_quality, - max_image_width = session.max_image_width, - max_image_height = session.max_image_height, - ) - - -@shared_task(bind=True) -def export_all_videos(self, session_id: str): - """ - Export MP4 de toutes les vidéos de la session. - """ - from cameras.models import ExportSession - - session = ExportSession.objects.get(session_id=session_id) - - return export_video_mp4( - session.camera_uuid, - session.start_ts, - session.end_ts, - frame_rate = session.frame_rate, - max_video_size_mb = session.max_video_size_mb, - max_width = session.max_width, - max_height = session.max_height, - ) - - diff --git a/test_tube_scanner/scanner/models.py b/test_tube_scanner/scanner/models.py index 741ee72..abaacd1 100644 --- a/test_tube_scanner/scanner/models.py +++ b/test_tube_scanner/scanner/models.py @@ -12,7 +12,7 @@ from django.utils import timezone from django.db import models from django.contrib.auth.models import User -# Multi-well positions on the table for calibration and observation + MULTIWELL_POSITION = [ ('HG', _("HG-Haut gauche")), ('HD', _("HD-Haut droit")), @@ -51,9 +51,7 @@ class Configuration(models.Model): grbl_xmax = models.FloatField(_("Grbl Xmax"), help_text=_("CNC Grbl Xmax en mm"), blank=False, default=350.0) grbl_ymax = models.FloatField(_("Grbl Ymax"), help_text=_("CNC Grbl Ymax en mm"), blank=False, default=250.0) # camera configuration - use_rpicam = models.BooleanField(_("Utiliser rpicam"), help_text=_("Par défaaut. Sinon USB webcam"), default=True) capture_type = models.CharField(_("Capture"), help_text=_("Type de capture"), default='rpi', max_length=8, choices=CAPTURE_TYPE, null=True, blank=False) - webcam_device_index = models.PositiveSmallIntegerField(_("Index de la webcam"), help_text=_("Index de la webcam (0, 1, ...) si présente"), default=2) image_quality = models.PositiveSmallIntegerField(_("Qualité JPEG"), help_text=_("Qualité JPEG (1-100) pour les images exportées"), default=90) video_jpeg_quality = models.PositiveSmallIntegerField(_("Qualité JPEG pour les vidéos"), help_text=_("Qualité JPEG (1-100) pour les images extraites des vidéos"), default=90) @@ -68,9 +66,13 @@ class Configuration(models.Model): calibration_default_duration = models.FloatField(_("Duruée calibration"), help_text=_("Durée de pose entre chaque puits en s"), default=3.0) # tracking tracking = models.BooleanField(_("Suivi"), help_text=_("Suivi et analyse des planaires"), default=False) - # active = models.BooleanField(_("Actif"), default=False) + + + @classmethod + def active_config(cls): + return Configuration.objects.filter(active=True).first() class Meta: ordering = ['id', ] @@ -116,7 +118,7 @@ class MultiWell(models.Model): dy = models.FloatField(_("Pas Y"), help_text=_('Pas ou interval sur Y en mm'), blank=False, default=19.5) feed = models.PositiveIntegerField(_("Vitesse"), help_text=_('Vitesse déplacement en mm/mn '), blank=False, default=1000) - well_position = models.BooleanField(_("Positions"), help_text=_('Positions des puits générées ?. Non => efface WellPostion et recalcule les positions'), default=False) + well_position = models.BooleanField(_("Positions"), help_text=_('Positions des puits générées ?. Non => efface WellPosition et recalcule les positions'), default=False) active = models.BooleanField(_("Active"), default=True) @@ -161,7 +163,7 @@ class MultiWell(models.Model): return f'{self.position}: {self.label}' -class WellPostion(models.Model): +class WellPosition(models.Model): author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True) multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True) @@ -169,8 +171,13 @@ class WellPostion(models.Model): order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du puit'), blank=False, default=0) x = models.FloatField(_("X"), help_text=_('Axe X en mm'), blank=False, default=10.0) y = models.FloatField(_("Y"), help_text=_('Axe Y en mm'), blank=False, default=10.0) + px_per_mm = models.FloatField( default=50.0, verbose_name=_("Pixels par mm"), help_text=_("Facteur de calibration optique")) + + + @classmethod + def active_well(cls, multiwel, well): + return WellPosition.objects.filter(multiwel_id=multiwel.id, well_id=well.id).first() - class Meta: ordering = ['order'] unique_together = ["multiwell", "well"] @@ -183,6 +190,8 @@ class WellPostion(models.Model): @receiver(post_save, sender=MultiWell) def create_well_position(sender, instance, created, **kwargs): + if created: + pass if not instance.well_position: row_order = instance.row_order.split(',') n = 0 @@ -197,7 +206,7 @@ def create_well_position(sender, instance, created, **kwargs): try: name = f'{row_order[row]}{col+1}' well = Well.objects.get(name__exact=name) - WellPostion.objects.update_or_create( + WellPosition.objects.update_or_create( multiwell=instance, well=well, author=instance.author, @@ -210,9 +219,9 @@ def create_well_position(sender, instance, created, **kwargs): instance.save() -class Observation(models.Model): - title = models.CharField(_("Titre de l'observation"), max_length=100, null=True, blank=False) - comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'observations"), null=True, blank=True) +class Experiment(models.Model): + title = models.CharField(_("Titre de l'expérience"), max_length=100, null=True, blank=False) + comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'expérience"), null=True, blank=True) author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True) created = models.DateTimeField(_("Date de création"), default=timezone.now) @@ -221,28 +230,12 @@ class Observation(models.Model): class Meta: ordering = ['-created', ] - verbose_name = _("Observation") - verbose_name_plural = _("Observations") + verbose_name = _("Expérience") + verbose_name_plural = _("Expériences") def __str__(self): return f'{self.title}: {self.created} {self.multiwell.order}' -class ObservationMultiWellDetail(models.Model): - author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) - observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="multiwell_details" , null=True, blank=True) - well = models.ForeignKey(Well, verbose_name="Puit", on_delete=models.CASCADE, related_name="observation_details", null=True, blank=True ) - detail = models.CharField("Détail", max_length=255) - comment = models.TextField("Commentaire", blank=True) - - class Meta: - ordering = ['observation', 'well__name'] - unique_together = ["observation", "well"] - verbose_name = _("Observation multi-puits détail") - verbose_name_plural = _("Observations multi-puits détails") - - def __str__(self): - return f"{self.observation.title} - {self.well} - {self.detail}" - class Session(models.Model): @@ -252,7 +245,7 @@ class Session(models.Model): DONE = "done", _("Terminé") ERROR = "error", _("Erreur") - name = models.CharField(_("Nom de la session"), help_text=_("Session d'observations. 4 Multi-puits maximum"), max_length=100, null=True, blank=False) + name = models.CharField(_("Nom de la session"), help_text=_("Session d'expérience. 4 Multi-puits maximum"), max_length=100, null=True, blank=False) author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) active = models.BooleanField(_("Active"), default=True) expected_export = models.DateTimeField(_("Date d'exportation"), help_text=_("Date d'exportation prévue"), null=True, blank=True) @@ -280,8 +273,8 @@ class Session(models.Model): class Meta: ordering = ['-created', ] - verbose_name = _("Session d'observation") - verbose_name_plural = _("Sessions d'observation") + verbose_name = _("Session d'expérience") + verbose_name_plural = _("Sessions d'expériences") def __str__(self): state = _("Terminée") if not self.active else _("Active") @@ -346,20 +339,21 @@ def delete_periodic_task(sender, instance, **kwargs): if instance.scanning_task: instance.scanning_task.delete() -class SessionObservation(models.Model): + +class SessionExperiment(models.Model): author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) session = models.ForeignKey(Session, verbose_name=_("Session"), on_delete=models.SET_NULL, null=True, blank=True) - observation = models.ForeignKey(Observation, verbose_name=_("Observation"), on_delete=models.SET_NULL, null=True, blank=True) + experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.SET_NULL, null=True, blank=True, related_name="session_experiments") @classmethod - def observation_by_session(cls, session_id, active=True): - return [ ss.observation for ss in SessionObservation.objects.filter(session__id=session_id, session__active=active).order_by('observation__multiwell__order') ] + def experiment_by_session(cls, session_id, active=True): + return [ ss.experiment for ss in SessionExperiment.objects.filter(session__id=session_id, session__active=active).order_by('experiment__multiwell__order') ] @classmethod def uuid_from_session(cls, sid): - observations = [ss.observation for ss in SessionObservation.objects.filter(session__id=sid, session__active=False)] + experiments = [ss.experiment for ss in SessionExperiment.objects.filter(session__id=sid, session__active=False)] uuid_list = [] - for obs in observations: + for obs in experiments: row_def = obs.multiwell.row_def.split(',') for row in range(obs.multiwell.rows): for col in range(obs.multiwell.cols): @@ -369,9 +363,9 @@ class SessionObservation(models.Model): class Meta: ordering = ['session',] - unique_together = ["session", "observation"] - verbose_name = _("Session observations") - verbose_name_plural = _("Sessions observations") + unique_together = ["session", "experiment"] + verbose_name = _("Session expérience") + verbose_name_plural = _("Sessions expériences") def __str__(self): return f'{self.session.name}' diff --git a/test_tube_scanner/scanner/multiwell.py b/test_tube_scanner/scanner/multiwell.py index bf09fe5..4883f58 100644 --- a/test_tube_scanner/scanner/multiwell.py +++ b/test_tube_scanner/scanner/multiwell.py @@ -89,6 +89,7 @@ class MultiWellManager: self._feed = feed or self.process.conf.calibration_default_feed self._step = step or self.process.conf.calibration_default_step self._duration = duration or self.process.conf.calibration_default_duration + self.px_per_mm = 50.0 def set_multiwell(self, position=None): @@ -97,7 +98,7 @@ class MultiWellManager: else: self.multiwell = models.MultiWell.by_position(position) - wells = models.WellPostion.objects.filter(multiwell_id=self.multiwell.id).order_by('order').all() + wells = models.WellPosition.objects.filter(multiwell_id=self.multiwell.id).order_by('order').all() self.well_iterator = WellIterator(wells) self.position = self.multiwell.position @@ -132,11 +133,11 @@ class MultiWellManager: logger.info(f"Arrêter l'enregistrement {uuid}") self.process.data.record = False self.process.data.uuid = None + - - def _grid_scanning(self, observation, xnext=0, ynext=0): - multiwell = observation.multiwell - wells = models.WellPostion.objects.filter(multiwell_id=multiwell.id).order_by('order').all() + def _grid_scanning(self, experiment, xnext=0, ynext=0): + multiwell = experiment.multiwell + wells = models.WellPosition.objects.filter(multiwell_id=multiwell.id).order_by('order').all() cam = self.process.cam cam._aligner.set_tube_diameter(multiwell.diameter) @@ -149,38 +150,48 @@ class MultiWellManager: uuid = f'{self.process.data.session}-{multiwell.position}-{wl.well.name}' self._grid_scanning_capture(uuid, multiwell.duration) - self.process._send(uuid=uuid) + ## change file + if self.process.conf.capture_type == 'file': + self.process.cam._error_occured = True + + self.process._send(scan_state=f"{uuid}: capture") logger.info(f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})") self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2) - def _start_scanning(self, session, observations): + def _start_scanning(self, session, experiments): self.process.cam._aligner.debug = False - xynext = [] - for obs in observations: + for obs in experiments: xynext.append((obs.multiwell.xbase, obs.multiwell.ybase)) xynext.append((0, 0)) pos = 1 self.process.data.session = session.id started = timezone.now() - for obs in observations: + for obs in experiments: + if self.stop_playing.is_set(): + break obs.started = timezone.now() obs.save() - xnext, ynext = xynext[pos] pos +=1 self._grid_scanning(obs, xnext=xnext, ynext=ynext) - obs.finished = timezone.now() obs.save() + session.finished = timezone.now() - session.active = False - session.scanning_task.enabled = False - session.save() - logger.info(f"==== Session {session.name} terminée à {session.finished} après {session.finished - started} secondes.") + if self.stop_playing.is_set(): + msg = f"Session {session.name} abandonnée à {session.finished} après {session.finished - started} secondes." + else: + session.active = False + if session.scanning_task: + session.scanning_task.enabled = False + session.save() + msg = f"Session {session.name} terminée à {session.finished} après {session.finished - started} secondes." + logger.info(msg) + self.process._send(scan_state=msg) self.scan_thread = None @@ -188,7 +199,7 @@ class MultiWellManager: self.process.data.record = False self.stop_playing.set() self.well_iterator.reset() - self.process.cam._aligner.debugg = False + self.process.cam._aligner.debug = False def scanning(self, sid): @@ -196,8 +207,8 @@ class MultiWellManager: if self.scan_thread: return session = models.Session.objects.get(pk=sid) - observations = models.SessionObservation.observation_by_session(sid) - self.scan_thread = Thread(target=self._start_scanning, args=(session, observations, ), daemon=True).start() + experiments = models.SessionExperiment.experiment_by_session(sid) + self.scan_thread = Thread(target=self._start_scanning, args=(session, experiments, ), daemon=True).start() except Exception as e: print("MultiWellManager::scan error", e) @@ -253,6 +264,7 @@ class MultiWellManager: if auto: msg = cam.align_detection["msg"] if cam.align_detection.get('detected'): + if cam.align_detection.get('action')=="grbl": self.cnc_controller.wait_for(settings.CALIBRATION_AUTO_TIMEOUT) dx_mm, dy_mm = cam.align_detection["offset_x_mm"], cam.align_detection["offset_y_mm"] @@ -265,6 +277,7 @@ class MultiWellManager: logger.info(msg) self.process._send(state='save', msg=msg) wl.x, wl.y = self.cnc_controller.x, self.cnc_controller.y + wl.px_per_mm = cam.align_detection.get('px_per_mm') wl.save() if wl.order == 0: models.MultiWell.objects.filter(position__exact=self.position).update(xbase=wl.x, ybase=wl.y) @@ -371,6 +384,7 @@ class MultiWellManager: self._xbase, self._ybase = x, y wl = self.well_iterator.seek(0) # base puit 0 wl.x, wl.y = x, y + wl.px_per_mm = self.px_per_mm wl.save() diff --git a/test_tube_scanner/scanner/process.py b/test_tube_scanner/scanner/process.py index dd931a5..7d0b90a 100644 --- a/test_tube_scanner/scanner/process.py +++ b/test_tube_scanner/scanner/process.py @@ -421,6 +421,7 @@ class ScannerProcess(Task): elif topic == 'center': dx_mm = self.cam.align_detection["offset_x_mm"] dy_mm = self.cam.align_detection["offset_y_mm"] + self.manager.px_per_mm = self.cam.align_detection["px_per_mm"] self.grbl.move_to(self.grbl.x + dx_mm, self.grbl.y + dy_mm, feed=150) self._send(state='center', msg=self.cam.align_detection["msg"]) continue diff --git a/test_tube_scanner/scanner/static/scanner/js/main.js b/test_tube_scanner/scanner/static/scanner/js/main.js index 7ff9e0f..4530138 100644 --- a/test_tube_scanner/scanner/static/scanner/js/main.js +++ b/test_tube_scanner/scanner/static/scanner/js/main.js @@ -22,7 +22,7 @@ class ScannerManager { this.axial_pos = options.axial_pos; this.area_px = options.area_px; this.frame_count = options.frame_count; - this.uuid = options.uuid; + this.scan_state = options.scan_state; } init_controls() { @@ -43,6 +43,8 @@ class ScannerManager { if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); } if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `
  • [ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}
  • `); } if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); } + if (payload.scan_state) { this.scan_state.textContent=payload.scan_state;} + if (payload.detected && use_tracking) { this.cx.textContent = payload.cx; this.cy.textContent = payload.cy; this.speed_px_s.textContent = payload.speed_px_s; diff --git a/test_tube_scanner/scanner/static/scanner/js/stats.js b/test_tube_scanner/scanner/static/scanner/js/stats.js index 73b974f..f33f74c 100644 --- a/test_tube_scanner/scanner/static/scanner/js/stats.js +++ b/test_tube_scanner/scanner/static/scanner/js/stats.js @@ -4,6 +4,7 @@ const mem_used = sId('mem-used'); const disk_used = sId('disk-used'); const ramdisk_used = sId('ramdisk-used'); + const swap_used = sId('swap-used'); let autoTimer = null; @@ -12,10 +13,11 @@ const r = await fetch(stats_endpoint, { credentials: 'same-origin' }); if (!r.ok) throw new Error('HTTP ' + r.status); const j = await r.json(); - //console.log(j); const cpu_percent = j.cpu_info.cpu_percent+'%'; cpu_used.style.setProperty("--cpu-used", cpu_percent); cpu_used.title=`Cpu: ${cpu_percent}`; const shm_length = j.shm.length; shm_used.style.setProperty("--shm_used", shm_length); shm_used.title= `Shm: ${shm_length}`; - const virtual_memory = j.memory_info.virtual_memory.percent+'%'; mem_used.style.setProperty("--mem-used", virtual_memory); mem_used.title=`Mem: ${virtual_memory}`; + const virtual_memory = j.memory_info.virtual_memory.percent+'%'; mem_used.style.setProperty("--mem-used", virtual_memory); mem_used.title=`Mem: ${virtual_memory}`; + const swap_memory = j.memory_info.swap_memory.percent+'%'; swap_used.style.setProperty("--swap-used", swap_memory); swap_used.title=`swap: ${swap_memory}`; + const root_percent = j.disk_info.root.percent+'%'; disk_used.style.setProperty("--disk-used", root_percent); disk_used.title=`Disk: ${root_percent}`; let ramdisk_percent = "0%"; if (! j.ramdisk_info) ramdisk_percent = j.ramdisk_info.percent+'%'; diff --git a/test_tube_scanner/scanner/tasks.py b/test_tube_scanner/scanner/tasks.py index c1e3b09..2f375eb 100644 --- a/test_tube_scanner/scanner/tasks.py +++ b/test_tube_scanner/scanner/tasks.py @@ -88,7 +88,7 @@ def export_all_images(session_id=None): sessions = [session_id] for session_id in sessions: - uuid_list = models.SessionObservation.uuid_from_session(session_id) + uuid_list = models.SessionExperiment.uuid_from_session(session_id) job_zip = [] for uuid in uuid_list: job = export_images.delay( # @UndefinedVariable @@ -129,7 +129,7 @@ def export_all_videos(session_id=None): sessions = [session_id] for session_id in sessions: - uuid_list = models.SessionObservation.uuid_from_session(session_id) + uuid_list = models.SessionExperiment.uuid_from_session(session_id) job_mp4 = [] for uuid in uuid_list: job = export_videos.delay( # @UndefinedVariable diff --git a/test_tube_scanner/scanner/templates/scanner/base.html b/test_tube_scanner/scanner/templates/scanner/base.html index cfb855c..311c485 100644 --- a/test_tube_scanner/scanner/templates/scanner/base.html +++ b/test_tube_scanner/scanner/templates/scanner/base.html @@ -31,6 +31,7 @@
    +
    diff --git a/test_tube_scanner/scanner/templates/scanner/images.html b/test_tube_scanner/scanner/templates/scanner/images.html index a330ea2..d22862c 100644 --- a/test_tube_scanner/scanner/templates/scanner/images.html +++ b/test_tube_scanner/scanner/templates/scanner/images.html @@ -10,13 +10,13 @@
    {% csrf_token %} {% if cursid %} - {% multiwell_cards cursid observations %} + {% multiwell_cards cursid experiments %} {% endif %}
    {% if cursid %} diff --git a/test_tube_scanner/scanner/templates/scanner/main.html b/test_tube_scanner/scanner/templates/scanner/main.html index a2697e4..5af8e69 100644 --- a/test_tube_scanner/scanner/templates/scanner/main.html +++ b/test_tube_scanner/scanner/templates/scanner/main.html @@ -9,7 +9,7 @@ {% block content %}
    -
    +
    {% trans "Choix de la session" %}
    - + {% for s in sessions %} {% endfor %} {% if cursid %} - {% multiwell_cards cursid observations %} + {% multiwell_cards cursid experiments %} {% endif %} {% if cursid %} diff --git a/test_tube_scanner/scanner/templates/scanner/scan-image.html b/test_tube_scanner/scanner/templates/scanner/scan-image.html index 1c45470..1d0b10c 100644 --- a/test_tube_scanner/scanner/templates/scanner/scan-image.html +++ b/test_tube_scanner/scanner/templates/scanner/scan-image.html @@ -1,6 +1,6 @@
    - {% if use_tracking %} + {% if conf.tracking %}
    Num:
    Aire:
    diff --git a/test_tube_scanner/scanner/templatetags/scanner_tags.py b/test_tube_scanner/scanner/templatetags/scanner_tags.py index b42250d..9a66f63 100644 --- a/test_tube_scanner/scanner/templatetags/scanner_tags.py +++ b/test_tube_scanner/scanner/templatetags/scanner_tags.py @@ -5,9 +5,9 @@ from django.utils.html import mark_safe register = template.Library() @register.simple_tag -def multiwell_cards(sid, observations): +def multiwell_cards(sid, experiments): multiwells = [] - for obs in observations: + for obs in experiments: row_def = obs.multiwell.row_def.split(',') multiwells.append( f''' diff --git a/test_tube_scanner/scanner/views.py b/test_tube_scanner/scanner/views.py index 484d938..9fa61aa 100644 --- a/test_tube_scanner/scanner/views.py +++ b/test_tube_scanner/scanner/views.py @@ -11,16 +11,40 @@ from django.conf import settings from reduct.time import unix_timestamp_to_iso from modules.system_stats import get_cached_stats, start_background_updater from modules import reductstore +from dataclasses import dataclass from .tasks import download_video, export_all_images, export_all_videos from .process import CameraRecordManager, cameraDB from . import models +@dataclass +class DefaultConfig: + sidebar_width: str = "25%" + default_grid_columns: int = 3 + opencv_fourcc_format: str = 'mp4v' + opencv_video_type: str = 'mp4' + grbl_xmax: float = 350.0 + grbl_ymax: float = 250.0 + capture_type: str = 'rpi' + webcam_device_index: int = 2 + image_quality: int = 90 + video_jpeg_quality: int = 90 + video_frame_rate: int = 5.0 + video_width_capture: int = 2028 + video_height_capture: int = 1520 + calibration_crop_radius: int = 500 + calibration_default_multiwell: str = 'HD' + calibration_default_feed: int = 1000 + calibration_default_step: float = 1.0 + calibration_default_duration: float = 3.0 + tracking: bool = False + +default_conf = DefaultConfig() record_manager = CameraRecordManager(cameraDB) start_background_updater() - - + + @require_GET def stats_view(request): """ @@ -34,12 +58,12 @@ def stats_view(request): def global_context(request, **ctx): default_multiwell = models.MultiWell.objects.filter(default=True).first() + conf = models.Configuration.active_config() or default_conf return dict( app_title=settings.APP_TITLE, app_sub_title=settings.APP_SUB_TITLE, domain_server=settings.DOMAIN_SERVER, - use_tracking=settings.TRACKING, - conf=models.Configuration.objects.filter(active=True).first(), + conf=conf, default_position = default_multiwell.position or 'HD', **ctx ) @@ -131,7 +155,7 @@ def images_view(request): ctx = dict( choice_title=_("Gestionnaire d'images"), sessions=models.Session.objects.filter(active=False).all(), - observations=models.SessionObservation.observation_by_session(cursid, active=False), + experiments=models.SessionExperiment.experiment_by_session(cursid, active=False), cursid=int(cursid), images=images, uuid=uuid, @@ -174,7 +198,7 @@ def replay_view(request): choice_title=_("Gestionnaire de vidéos"), ws_route=settings.REPLAY_WEBSOCKET_ROUTE, sessions=models.Session.objects.filter(active=False).all(), - observations=models.SessionObservation.observation_by_session(cursid, active=False), + experiments=models.SessionExperiment.experiment_by_session(cursid, active=False), cursid=int(cursid), image=image, uuid=uuid,