From 3ecf0a1b6b6215d3cdca10a0155edf287a61b1d2 Mon Sep 17 00:00:00 2001 From: denis defolie Date: Mon, 4 May 2026 10:32:14 +0200 Subject: [PATCH] simulation --- test_tube_scanner/make_videos.sh | 55 +- .../modules/planarian_metrics.py | 787 ++++++++++-------- test_tube_scanner/planarian_sim.py | 209 +++-- 3 files changed, 554 insertions(+), 497 deletions(-) diff --git a/test_tube_scanner/make_videos.sh b/test_tube_scanner/make_videos.sh index 0f64ea7..d85bcac 100755 --- a/test_tube_scanner/make_videos.sh +++ b/test_tube_scanner/make_videos.sh @@ -4,53 +4,36 @@ # A1..A6, B1..B6, C1..C6, D1..D6 # -PATH="data" +PATH="./media/simulation" default_width=1000 # px default_height=1000 # px default_diameter=16.0 # mm declare -A arguments=( - # key count len width sec fps seed thigmotaxis bg-color arena-color arena-border shadow-color body-color body-dark body-light head-color - ["F0"]="1 0.90 0.30 60 5 64 0.45 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5A7896 #3C5570 #8CA0B4 #46645F" -) + # key count length width fps duration seed bg-color arena-color arena-border shadow-color body-color body-dark body-light head-color thresh-immobile thresh-mobile thigmotaxis photo-mode photo-strength photo-x photo-y photo-sine-freq photo-radius chemo-strength chemo-x chemo-y chemo-radius avoid-strength avoid-radius aggreg-strength aggreg-radius chem-repulsion chem-decay + ["A1"]="3 0.40 0.30 5 60 64 #EBEBEB #FAFAFA #8C8C8C #C8C8C8 #A5A5A5 #373737 #D2D2D2 #828282 0.2 1.5 0.45 none 0.50 0.50 0.50 0.10 0.30 0.0 0.70 0.70 2.0 1.0 3.0 0.0 6.0 0.0 0.95" + ["A2"]="1 0.42 0.32 5 60 96 #EBEBEB #FAFAFA #8C8C8C #C8C8C8 #A5A5A5 #373737 #D2D2D2 #828282 0.2 1.5 0.70 fixed 0.50 0.50 0.50 0.10 0.30 0.0 0.70 0.70 2.0 0.0 3.0 0.0 6.0 0.0 0.95" + ["A3"]="1 0.50 0.40 5 60 128 #EBEBEB #FAFAFA #8C8C8C #C8C8C8 #A5A5A5 #373737 #D2D2D2 #828282 0.2 1.5 0.70 radial 0.50 0.50 0.50 0.10 0.30 0.5 0.70 0.70 2.0 0.0 3.0 0.0 6.0 0.0 0.95" -declare -A arguments2=( - # key count len width sec fps seed thigmotaxis bg-color arena-color arena-border shadow-color body-color body-dark body-light head-color - ["D1"]="3 0.90 0.30 60 5 64 0.45 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5A7896 #3C5570 #8CA0B4 #46645F" - ["D2"]="2 0.75 0.40 60 5 96 0.50 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5B7896 #3D5570 #8DA0B4 #47645F" - ["D3"]="1 0.80 0.50 60 5 42 0.60 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["D4"]="1 0.85 0.40 60 5 28 0.70 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["D5"]="3 0.60 0.35 60 5 132 0.65 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["D6"]="2 0.65 0.35 60 5 256 0.85 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["C6"]="1 0.90 0.30 60 5 64 0.45 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5A7896 #3C5570 #8CA0B4 #46645F" - ["C5"]="3 0.75 0.40 60 5 96 0.50 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5B7896 #3D5570 #8DA0B4 #47645F" - ["C4"]="2 0.80 0.50 60 5 42 0.60 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["C3"]="1 0.85 0.40 60 5 28 0.70 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["C2"]="2 0.60 0.35 60 5 132 0.65 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["C1"]="3 0.65 0.35 60 5 256 0.85 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["B1"]="2 0.90 0.30 60 5 64 0.45 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5A7896 #3C5570 #8CA0B4 #46645F" - ["B2"]="1 0.75 0.40 60 5 96 0.50 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5B7896 #3D5570 #8DA0B4 #47645F" - ["B3"]="1 0.80 0.50 60 5 42 0.60 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["B4"]="3 0.85 0.40 60 5 28 0.70 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["B5"]="1 0.60 0.35 60 5 132 0.65 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["B6"]="2 0.65 0.35 60 5 256 0.85 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["A6"]="1 0.90 0.30 60 5 64 0.45 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5A7896 #3C5570 #8CA0B4 #46645F" - ["A5"]="1 0.75 0.40 60 5 96 0.50 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5B7896 #3D5570 #8DA0B4 #47645F" - ["A4"]="3 0.80 0.50 60 5 42 0.60 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["A3"]="1 0.85 0.40 60 5 28 0.70 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["A2"]="1 0.60 0.35 60 5 132 0.65 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" - ["A1"]="4 0.65 0.35 60 5 256 0.85 #D4DADC #BDBDF0 #B4AFA8 #BEBCB6 #5C7896 #3E5570 #8EA0B4 #48645F" ) for key in "${!arguments[@]}"; do args="${arguments[$key]}" - read -r count length width duration fps seed thigmotaxis bg_color arena_color arena_border shadow_color body_color body_dark body_light head_color <<< "$args" - + read -r count length width fps duration seed bg_color arena_color arena_border shadow_color \ + body_color body_dark body_light head_color thresh_immobile thresh_mobile thigmotaxis \ + photo_mode photo_strength photo_x photo_y photo_sine_freq photo_radius chemo_strength chemo_x chemo_y chemo_radius \ + avoid_strength avoid_radius aggreg_strength aggreg_radius chem_repulsion chem_decay <<< "$args" + echo "==== Exécution de $PATH/$key.mp4" - ./planarian_sim.py --output "$PATH/$key.mp4" --default_width "$default_width" --default_height "$default_height" --default_diameter "$default_diameter" \ - --count "$count" --length "$length" --width "$width" --duration "$duration" --fps "$fps" --seed "$seed" --thigmotaxis "$thigmotaxis" \ - --bg-color "$bg_color" --arena-color "$arena_color" --arena-border "$arena_border" --shadow-color "$shadow_color" \ - --body-color "$body_color" --body-dark "$body_dark" --body-light "$body_light" --head-color "$head_color" --no-csv + ./planarian_sim.py --output "$PATH/$key.mp4" --default_width "$default_width" --default_height "$default_height" --default_diameter "$default_diameter" --no-csv \ + --count "$count" --length "$length" --width "$width" --duration "$duration" --fps "$fps" --seed "$seed" \ + --bg-color "$bg_color" --arena-color "$arena_color" --arena-border "$arena_border" --shadow-color "$shadow_color" \ + --body-color "$body_color" --body-dark "$body_dark" --body-light "$body_light" --head-color "$head_color" \ + --thresh-immobile "$thresh_immobile" --thresh-mobile "$thresh_mobile" --thigmotaxis "$thigmotaxis" \ + --photo-mode "$photo_mode" --photo-strength "$photo_strength" --photo-x "$photo_x" --photo-y "$photo_y" --photo-sine-freq "$photo_sine_freq" --photo-radius "$photo_radius" \ + --chemo-strength "$chemo_strength" --chemo-x "$chemo_x" --chemo-y "$chemo_y" --chemo-radius "$chemo_radius" \ + --avoid-strength "$avoid_strength" --avoid-radius "$avoid_radius" --aggreg-strength "$aggreg_strength" --aggreg-radius "$aggreg_radius" \ + --chem-repulsion "$chem_repulsion" --chem-decay "$chem_decay" done diff --git a/test_tube_scanner/modules/planarian_metrics.py b/test_tube_scanner/modules/planarian_metrics.py index 24b9961..5a653d2 100644 --- a/test_tube_scanner/modules/planarian_metrics.py +++ b/test_tube_scanner/modules/planarian_metrics.py @@ -1,25 +1,40 @@ """ modules/planarian_metrics.py -Intégration des métriques EthoVision XT dans PlanarianScanner. +Intégration des métriques EthoVision XT + comportementales dans PlanarianScanner. -Architecture : +Métriques par frame : + Mobilité : velocity, distance, moving, mobility_state + Thigmo : dist_to_wall_mm, near_wall + Photo : dist_to_light_mm, heading_to_light_deg, fleeing_light + Chemo : dist_to_food_mm, heading_to_food_deg, approaching_food, in_food_zone + Social : nearest_neighbour_mm, in_avoid_zone, in_aggreg_zone, chem_repulsion_level + +Métriques résumé (summary) : + Mobilité : movedCenter_pointTotal_mm, velocity_mean_mm_s, durations par état + Thigmo : thigmotaxis_pct_time_near_wall + Photo : photo_pct_time_fleeing, photo_mean_dist_mm, photo_latency_s + Chemo : chemo_pct_time_approaching, chemo_pct_time_in_zone, + chemo_latency_s, chemo_mean_dist_mm + Social : social_pct_time_avoiding, social_pct_time_aggregating, + social_mean_nn_mm, social_contact_events + + 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 : + 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") - + bucket : nom du bucket (ex: "planarian_metrics") + Created on 25 avr. 2026 @author: denis """ -import asyncio import csv import io import json @@ -28,9 +43,8 @@ import math import os import time -from datetime import datetime, timezone +from datetime import datetime from typing import Optional -from modules.reductstore import ReductStore logger = logging.getLogger(__name__) @@ -39,68 +53,129 @@ 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_IMMOBILE_DEFAULT = 0.2 # en-dessous : Immobile (mm/s) 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) +# Paramètres comportementaux (défauts) BEHAVIOUR_DEFAULTS = { # Thigmotactisme - "thigmotaxis_wall_dist_mm": 1.0, # distance à la paroi considérée "near wall" + "thigmotaxis_wall_dist_mm": 1.0, # Phototactisme - "photo_mode": "none", # none | fixed | sine | radial + "photo_mode": "none", "photo_strength": 0.0, + "photo_x": 0.5, + "photo_y": 0.5, + "photo_flee_angle_deg": 90.0, # angle max tête/source pour considérer "fuite" # Chimiotactisme "chemo_strength": 0.0, - "chemo_x": 0.5, # fraction 0-1 + "chemo_x": 0.5, "chemo_y": 0.5, "chemo_radius_mm": 2.0, + "chemo_approach_angle_deg": 90.0, # angle max tête/nourriture pour considérer "approche" # Interactions inter-individus "avoid_radius_mm": 3.0, "aggreg_radius_mm": 6.0, } +# --------------------------------------------------------------------------- +# Helpers géométriques +# --------------------------------------------------------------------------- + +def _angle_between_deg(vx1: float, vy1: float, vx2: float, vy2: float) -> float: + """ + Calcule l'angle en degrés entre deux vecteurs 2D. + Retourne 0.0 si l'un des vecteurs est nul. + + Args: + vx1, vy1 : premier vecteur + vx2, vy2 : second vecteur + + Returns: + angle en degrés [0, 180] + """ + n1 = math.sqrt(vx1**2 + vy1**2) + n2 = math.sqrt(vx2**2 + vy2**2) + if n1 < 1e-9 or n2 < 1e-9: + return 0.0 + cos_a = max(-1.0, min(1.0, (vx1 * vx2 + vy1 * vy2) / (n1 * n2))) + return math.degrees(math.acos(cos_a)) + + +def _heading_to_target_deg( + cx: float, cy: float, + tx: float, ty: float, + dx: float, dy: float, +) -> float: + """ + Calcule l'angle entre la direction de déplacement et le vecteur vers une cible. + + Args: + cx, cy : position courante + tx, ty : position cible + dx, dy : vecteur de déplacement (cx - prev_cx, cy - prev_cy) + + Returns: + angle en degrés [0, 180] — 0 = va droit vers la cible, 180 = fuit + """ + to_target_x = tx - cx + to_target_y = ty - cy + return _angle_between_deg(dx, dy, to_target_x, to_target_y) + + # --------------------------------------------------------------------------- # Classe EthoVisionMetrics # --------------------------------------------------------------------------- class EthoVisionMetrics: """ - Calcule et accumule les métriques compatibles EthoVision XT - à partir des données brutes de PlanarianTracker. + Calcule et accumule toutes les métriques comportementales 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). + Métriques calculées : + - Mobilité EthoVision (distance, vitesse, états Immobile/Mobile/Très mobile) + - Thigmotactisme (distance paroi, % temps près du bord) + - Phototactisme (distance source, orientation, % fuite, latence) + - Chimiotactisme (distance nourriture, % approche, % zone, latence) + - Interactions inter-individus (voisin le plus proche, évitement, + agrégation, répulsion chimique, événements de contact) + + Une instance par planaire suivi. 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=...) + metrics = EthoVisionMetrics(px_per_mm=26.25, fps=10, behaviour={...}) + for frame in capture: + raw = tracker.process(frame, ts) + record = metrics.update( + raw, + well_radius_mm = 8.0, + arena_center_px = (250, 250), + photo_source_px = (100, 100), + others_pos_mm = [(x1,y1), (x2,y2)], + chem_level = 0.3, + ) + await client.store_metric(record, ...) summary = metrics.summary() """ def __init__( self, - px_per_mm: float, - fps: float, + px_per_mm: float, + fps: float, thresh_immobile: float = THRESH_IMMOBILE_DEFAULT, thresh_mobile: float = THRESH_MOBILE_DEFAULT, - behaviour: Optional[dict] = None, + 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 + px_per_mm : facteur de conversion pixels → mm + fps : fréquence de capture (images/s) + thresh_immobile : seuil vitesse Immobile/Mobile (mm/s) + thresh_mobile : seuil vitesse Mobile/Très mobile (mm/s) behaviour : dict de paramètres comportementaux (cf. BEHAVIOUR_DEFAULTS) """ self.px_per_mm = px_per_mm @@ -108,142 +183,278 @@ class EthoVisionMetrics: self.dt = 1.0 / fps self.thresh_immobile = thresh_immobile self.thresh_mobile = thresh_mobile - self.behaviour = {**BEHAVIOUR_DEFAULTS, **(behaviour or {})} + self.beh = {**BEHAVIOUR_DEFAULTS, **(behaviour or {})} - # --- Accumulateurs globaux --- + # --- Accumulateurs mobilité --- 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._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 --- + # --- Accumulateurs thigmotactisme --- self._near_wall_frames = 0 - # --- Historique positions (pour calcul vitesse inter-frame) --- - self._prev_cx_px = None - self._prev_cy_px = None + # --- Accumulateurs phototactisme --- + self._flee_frames = 0 # frames en fuite + self._photo_dist_sum = 0.0 # somme distances source + self._photo_dist_count = 0 + self._photo_latency_s = None # temps avant 1ère fuite (s) + + # --- Accumulateurs chimiotactisme --- + self._approach_frames = 0 # frames en approche nourriture + self._in_zone_frames = 0 # frames dans la zone nourriture + self._chemo_dist_sum = 0.0 + self._chemo_dist_count = 0 + self._chemo_latency_s = None # temps avant 1ère entrée zone (s) + + # --- Accumulateurs interactions inter-individus --- + self._avoid_frames = 0 # frames en zone d'évitement + self._aggreg_frames = 0 # frames en zone d'agrégation + self._nn_sum = 0.0 # somme distances voisin le plus proche + self._nn_count = 0 + self._contact_events = 0 # transitions False→True de in_avoid_zone + self._prev_in_avoid = False + + # --- Position précédente (vecteur de déplacement) --- + self._prev_cx_mm = None + self._prev_cy_mm = None self._prev_ts = None + # ------------------------------------------------------------------ # + # Helpers internes + # ------------------------------------------------------------------ # + 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: + def _classify(self, v: float) -> str: + """Classifie la vitesse en état de mobilité EthoVision.""" + if v <= self.thresh_immobile: return STATE_IMMOBILE - elif velocity_mm_s <= self.thresh_mobile: + elif v <= self.thresh_mobile: return STATE_MOBILE return STATE_HIGH_MOBILE - def update(self, raw: dict, well_radius_mm: float = 8.0) -> dict: + def _elapsed_s(self) -> float: + """Temps écoulé depuis le début de la session (s).""" + return self.frame_count * self.dt + + # ------------------------------------------------------------------ # + # Méthode principale + # ------------------------------------------------------------------ # + + def update( + self, + raw: dict, + well_radius_mm: float = 8.0, + arena_center_px: tuple = (250, 250), + photo_source_px: Optional[tuple] = None, + others_pos_mm: Optional[list] = None, + chem_level: float = 0.0, + ) -> dict: """ - Calcule les métriques EthoVision pour une frame à partir - du résultat brut de PlanarianTracker.process(). + Calcule toutes les métriques comportementales pour une frame. 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) + raw : dict brut de PlanarianTracker.process() + clés : detected, cx, cy, speed_px_s, ts + well_radius_mm : rayon du puits en mm + arena_center_px : centre de l'arène en pixels (cx, cy) + photo_source_px : position de la source lumineuse en pixels (ou None) + others_pos_mm : liste de (x_mm, y_mm) des autres planaires + chem_level : concentration chimique locale [0-1] (depuis ChemicalMap) Returns: - dict complet avec métriques EthoVision prêtes pour ReductStore + dict complet prêt 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) + return {"timestamp": ts, "detected": False} - cx_px = raw["cx"] - cy_px = raw["cy"] - - # --- Conversion en mm --- + # --- Position en mm (relative au centre de l'arène) --- + cx_px = raw["cx"] - arena_center_px[0] + cy_px = raw["cy"] - arena_center_px[1] 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 --- + # --- Vitesse / distance --- 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 + dist_mm = velocity_mm_s * self.dt self.total_distance_mm += dist_mm - # --- Mouvement / arrêt --- + # Vecteur de déplacement (pour calculs d'angle) + if self._prev_cx_mm is not None: + move_dx = cx_mm - self._prev_cx_mm + move_dy = cy_mm - self._prev_cy_mm + else: + move_dx, move_dy = 0.0, 0.0 + + # --- Mobilité --- 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 + # ================================================================ + # THIGMOTACTISME + # ================================================================ + 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_thr = self.beh.get("thigmotaxis_wall_dist_mm", 1.0) + is_near_wall = dist_wall_mm < near_wall_thr if is_near_wall: self._near_wall_frames += 1 - self._prev_cx_px = cx_px - self._prev_cy_px = cy_px + # ================================================================ + # PHOTOTACTISME + # ================================================================ + photo_mode = self.beh.get("photo_mode", "none") + dist_light_mm = 0.0 + heading_light_deg = 0.0 + fleeing_light = False + + if photo_mode != "none" and photo_source_px is not None: + lx_px = photo_source_px[0] - arena_center_px[0] + ly_px = photo_source_px[1] - arena_center_px[1] + lx_mm = self._px_to_mm(lx_px) + ly_mm = self._px_to_mm(ly_px) + + dl = math.sqrt((cx_mm - lx_mm)**2 + (cy_mm - ly_mm)**2) + dist_light_mm = dl + + self._photo_dist_sum += dl + self._photo_dist_count += 1 + + # Angle entre déplacement et direction vers la source + heading_light_deg = _heading_to_target_deg( + cx_mm, cy_mm, lx_mm, ly_mm, move_dx, move_dy + ) + # Fuite = planaire s'éloigne de la source (angle > seuil) + flee_thr = self.beh.get("photo_flee_angle_deg", 90.0) + fleeing_light = (heading_light_deg > flee_thr) and is_moving + + if fleeing_light: + self._flee_frames += 1 + if self._photo_latency_s is None: + self._photo_latency_s = self._elapsed_s() + + # ================================================================ + # CHIMIOTACTISME + # ================================================================ + chemo_x_frac = self.beh.get("chemo_x", 0.5) + chemo_y_frac = self.beh.get("chemo_y", 0.5) + chemo_r_mm = self.beh.get("chemo_radius_mm", 2.0) + chemo_strength= self.beh.get("chemo_strength", 0.0) + + dist_food_mm = 0.0 + heading_food_deg = 0.0 + approaching_food = False + in_food_zone = False + + if chemo_strength > 0.0: + # Position nourriture en mm relative au centre + fx_mm = (chemo_x_frac - 0.5) * 2.0 * well_radius_mm + fy_mm = (chemo_y_frac - 0.5) * 2.0 * well_radius_mm + + df = math.sqrt((cx_mm - fx_mm)**2 + (cy_mm - fy_mm)**2) + dist_food_mm = df + + self._chemo_dist_sum += df + self._chemo_dist_count += 1 + + in_food_zone = df <= chemo_r_mm + if in_food_zone: + self._in_zone_frames += 1 + if self._chemo_latency_s is None: + self._chemo_latency_s = self._elapsed_s() + + heading_food_deg = _heading_to_target_deg( + cx_mm, cy_mm, fx_mm, fy_mm, move_dx, move_dy + ) + approach_thr = self.beh.get("chemo_approach_angle_deg", 90.0) + approaching_food = (heading_food_deg < approach_thr) and is_moving + + if approaching_food: + self._approach_frames += 1 + + # ================================================================ + # INTERACTIONS INTER-INDIVIDUS + # ================================================================ + avoid_r_mm = self.beh.get("avoid_radius_mm", 3.0) + aggreg_r_mm = self.beh.get("aggreg_radius_mm", 6.0) + + nearest_nn_mm = float("inf") + in_avoid_zone = False + in_aggreg_zone = False + + if others_pos_mm: + for ox_mm, oy_mm in others_pos_mm: + d = math.sqrt((cx_mm - ox_mm)**2 + (cy_mm - oy_mm)**2) + if d < nearest_nn_mm: + nearest_nn_mm = d + + if nearest_nn_mm < avoid_r_mm: + in_avoid_zone = True + self._avoid_frames += 1 + elif nearest_nn_mm < aggreg_r_mm: + in_aggreg_zone = True + self._aggreg_frames += 1 + + self._nn_sum += nearest_nn_mm + self._nn_count += 1 + + # Événement de contact : transition vers zone d'évitement + if in_avoid_zone and not self._prev_in_avoid: + self._contact_events += 1 + else: + nearest_nn_mm = 0.0 + + self._prev_in_avoid = in_avoid_zone + + # --- Mise à jour position précédente --- + self._prev_cx_mm = cx_mm + self._prev_cy_mm = cy_mm self._prev_ts = ts - # --- Record complet --- + # ================================================================ + # RECORD COMPLET + # ================================================================ return { - # Identification temporelle + # Identification "timestamp": ts, "detected": True, - # Position brute (pixels) - "cx_px": cx_px, - "cy_px": cy_px, - # Position en mm + # Position (mm, relative au centre) "x_mm": round(cx_mm, 4), "y_mm": round(cy_mm, 4), - # Vitesse + # Position brute pixels + "cx_px": raw["cx"], + "cy_px": raw["cy"], + # Mobilité EthoVision "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), @@ -254,164 +465,161 @@ class EthoVisionMetrics: # Thigmotactisme "dist_to_wall_mm": round(dist_wall_mm, 4), "near_wall": int(is_near_wall), - # Données brutes tracker (passthrough) + # Phototactisme + "dist_to_light_mm": round(dist_light_mm, 4), + "heading_to_light_deg": round(heading_light_deg, 2), + "fleeing_light": int(fleeing_light), + # Chimiotactisme + "dist_to_food_mm": round(dist_food_mm, 4), + "heading_to_food_deg": round(heading_food_deg, 2), + "approaching_food": int(approaching_food), + "in_food_zone": int(in_food_zone), + # Interactions inter-individus + "nearest_neighbour_mm": round(nearest_nn_mm, 4) if nearest_nn_mm != float("inf") else 0.0, + "in_avoid_zone": int(in_avoid_zone), + "in_aggreg_zone": int(in_aggreg_zone), + "chem_repulsion_level": round(chem_level, 4), + # Passthrough tracker "area_px": raw.get("area_px", 0), "axial_pos": raw.get("axial_pos", 0.0), "axial_speed": raw.get("axial_speed", 0.0), } + # ------------------------------------------------------------------ # + # Résumé de session + # ------------------------------------------------------------------ # + 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. + Retourne le résumé global de la session. + Nomenclature EthoVision XT + métriques comportementales. + À appeler en fin d'expérience. Returns: dict avec toutes les métriques agrégées """ total_s = self.frame_count * self.dt + det = max(self._photo_dist_count, 1) # frames avec détection + return { + # Identification session "total_frames": self.frame_count, "total_duration_s": round(total_s, 3), - # Distance / vitesse (EthoVision : movedCenter-pointTotalmm / VelocityCenter-pointMeanmm/s) + # --- Mobilité EthoVision --- "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 + self.total_distance_mm / total_s if total_s > 0 else 0.0, 4), "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 + # --- Thigmotactisme --- "thigmotaxis_pct_time_near_wall": round( - 100.0 * self._near_wall_frames / max(self.frame_count, 1), 2 - ), + 100.0 * self._near_wall_frames / max(self.frame_count, 1), 2), + # --- Phototactisme --- + "photo_pct_time_fleeing": round( + 100.0 * self._flee_frames / max(self.frame_count, 1), 2), + "photo_mean_dist_mm": round( + self._photo_dist_sum / max(self._photo_dist_count, 1), 4), + "photo_latency_s": round(self._photo_latency_s, 3) + if self._photo_latency_s is not None else None, + # --- Chimiotactisme --- + "chemo_pct_time_approaching": round( + 100.0 * self._approach_frames / max(self.frame_count, 1), 2), + "chemo_pct_time_in_zone": round( + 100.0 * self._in_zone_frames / max(self.frame_count, 1), 2), + "chemo_latency_s": round(self._chemo_latency_s, 3) + if self._chemo_latency_s is not None else None, + "chemo_mean_dist_mm": round( + self._chemo_dist_sum / max(self._chemo_dist_count, 1), 4), + # --- Interactions inter-individus --- + "social_pct_time_avoiding": round( + 100.0 * self._avoid_frames / max(self.frame_count, 1), 2), + "social_pct_time_aggregating": round( + 100.0 * self._aggreg_frames / max(self.frame_count, 1), 2), + "social_mean_nn_mm": round( + self._nn_sum / max(self._nn_count, 1), 4), + "social_contact_events": self._contact_events, } def reset(self): - """ - Réinitialise tous les accumulateurs. - À appeler lors d'un changement de puits ou de planaire. - """ + """Réinitialise tous les accumulateurs (changement de puits ou planaire).""" self.__init__( - self.px_per_mm, - self.fps, - self.thresh_immobile, - self.thresh_mobile, - self.behaviour, + self.px_per_mm, self.fps, + self.thresh_immobile, self.thresh_mobile, self.beh, ) @staticmethod def _empty_record(ts: float) -> dict: - """Retourne un enregistrement vide (planaire non détecté).""" - return { - "timestamp": ts, - "detected": False, - } + """Enregistrement vide (planaire non détecté).""" + return {"timestamp": ts, "detected": False} # --------------------------------------------------------------------------- -# Paramètres expérimentaux (importables depuis CSV ou Django) +# Paramètres expérimentaux # --------------------------------------------------------------------------- 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. + Instanciable depuis un dict, un fichier CSV ou un modèle Django. """ 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, + "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, + "max_area_ratio": 0.10, **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 - """ + """Cast automatique des valeurs CSV (toutes en string) vers le bon type.""" 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", + "photo_strength", "photo_x", "photo_y", "photo_flee_angle_deg", + "chemo_strength", "chemo_x", "chemo_y", "chemo_radius_mm", + "chemo_approach_angle_deg", "thigmotaxis_wall_dist_mm", + "avoid_radius_mm", "aggreg_radius_mm", "max_area_ratio", } 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 - """ + """Instancie depuis une ligne de csv.DictReader.""" 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 - """ + """Charge toutes les expériences d'un fichier CSV.""" results = [] with open(filepath, newline="", encoding="utf-8") as f: - reader = csv.DictReader(f) - for row in reader: + for row in csv.DictReader(f): try: results.append(cls.from_csv_row(row)) except ValueError as e: @@ -419,16 +627,12 @@ class ExperimentParams: 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}}} + """Sérialise en dict.""" + 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 - """ + def build_metrics(self) -> EthoVisionMetrics: + """Construit l'instance EthoVisionMetrics pour ces paramètres.""" behaviour = {k: getattr(self, k) for k in BEHAVIOUR_DEFAULTS if hasattr(self, k)} return EthoVisionMetrics( px_per_mm = self.px_per_mm, @@ -447,14 +651,7 @@ 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. + Labels : experiment | well | planarian | record_type (frame|summary) """ def __init__( @@ -463,146 +660,80 @@ class ReductStoreClient: 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 + 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. - """ + """Initialise la connexion et crée le bucket si nécessaire.""" 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}") + logger.info(f"ReductStore connecté : {self.url} / {self.bucket_name}") async def store_metric( self, record: dict, experiment: str, well: str, - planarian: int = 0, - record_type: str = "frame", + 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) - """ + """Stocke un enregistrement dans ReductStore.""" 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", + entry_name = "metrics", + data = json.dumps(record).encode("utf-8"), + timestamp = ts_us, + labels = { + "experiment": experiment, + "well": well, + "planarian": str(planarian), + "record_type": record_type, + }, + 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 store_summary(self, summary: dict, experiment: str, + well: str, planarian: int = 0): + """Stocke le résumé de fin de session.""" + await self.store_metric(summary, experiment, well, planarian, "summary") async def get_tracking_data( self, experiment: str, well: str, - planarian: int = 0, - record_type: str = "frame", + 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 - """ + """Récupère les enregistrements filtrés par labels.""" if self._bucket is None: await self.connect() - - labels = { - "experiment": experiment, - "well": well, - "planarian": str(planarian), - "record_type": record_type, - } - - kwargs = {"include": labels} + kwargs = {"include": { + "experiment": experiment, "well": well, + "planarian": str(planarian), "record_type": record_type, + }} 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): + async for rec in self._bucket.query("metrics", **kwargs): try: - data = json.loads(await record.read_all()) - records.append(data) + records.append(json.loads(await rec.read_all())) except Exception as e: logger.warning(f"Entrée illisible ignorée : {e}") - return records async def export_csv( @@ -610,49 +741,23 @@ class ReductStoreClient: filepath: str, experiment: str, well: str, - planarian: int = 0, - record_type: str = "frame", + 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 - """ + """Exporte les données vers un fichier CSV.""" records = await self.get_tracking_data( - experiment = experiment, - well = well, - planarian = planarian, - record_type = record_type, - start = start, - stop = stop, - ) - + experiment, well, planarian, record_type, start, 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) - + writer.writerows(records) logger.info(f"Export CSV : {len(records)} lignes → {filepath}") return len(records) @@ -660,40 +765,22 @@ class ReductStoreClient: self, experiment: str, well: str, - planarian: int = 0, - record_type: str = "frame", + 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) - """ + ) -> tuple: + """Génère le contenu CSV en mémoire (pour réponse HTTP Django).""" records = await self.get_tracking_data( - experiment = experiment, - well = well, - planarian = planarian, - record_type = record_type, - start = start, - stop = stop, - ) - + experiment, well, planarian, record_type, start, 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") + out = io.StringIO() + writer = csv.DictWriter(out, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() - for r in records: - writer.writerow(r) - - return output.getvalue(), len(records) + writer.writerows(records) + return out.getvalue(), len(records) async def close(self): """Ferme la connexion ReductStore.""" diff --git a/test_tube_scanner/planarian_sim.py b/test_tube_scanner/planarian_sim.py index 2a29f03..c6ff9a0 100755 --- a/test_tube_scanner/planarian_sim.py +++ b/test_tube_scanner/planarian_sim.py @@ -1,9 +1,7 @@ #!../.venv/bin/python """ -Planaire simulation de mouvement aléatoire -========================================== - -Espace circulaire de 16mm de diamètre, 500x500px paramètrable +Planaria random movement simulation - top view +Espace circulaire de 16mm de diamètre, 500x500px Supporte plusieurs planaires avec paramètres configurables via arguments CLI. Export CSV par planaire compatible EthoVision XT. @@ -12,56 +10,18 @@ Comportements simulés : - Phototactisme : fuite de la lumière (--photo-mode, --photo-strength) - Chimiotactisme : attraction vers une source de nourriture (--chemo-strength) - Inter-individus : évitement de contact, agrégation, répulsion chimique - - Seuils EthoVision par défaut (configurables en arguments) : - - Immobile : déplacement < 0.2 mm/s - Mobile : 0.2 à 1.5 mm/s - Très mobile : > 1.5 mm/s - - EthoVision CSV frames CSV summary - ========== ========== =========== - movedCenter-pointTotalmm total_distance_mm movedCenter_pointTotal_mm - VelocityCenter-pointMeanmm/s velocity_mm_s velocity_mean_mm_s - MovementMoving moving, duration_moving_s movement_moving_duration_s - MovementNot Moving duration_stopped_s movement_not_moving_duration_s - ImmobileFrequency / Duration mobility_state mobility_immobile_frequency/duration_s - MobileFrequency / Duration mobility_state mobility_mobile_frequency/duration_s - Highly mobileFrequency / Duration mobility_state mobility_highly_mobile_frequency/duration_s - - - Métriques calculées : - - Distance totale parcourue (mm) → movedCenter-pointTotalmm - - Vitesse instantanée (mm/s) → VelocityCenter-pointMeanmm/s - - Durée cumulée en mouvement (s) → MovementMoving - - Durée cumulée à l'arrêt (s) → MovementNot Moving - - Fréquence et durée par état de mobilité → Mobility state (EthoVision) - - Distance à la paroi (mm) → thigmotactisme - - Comportements simulés : - - Thigmotactisme : attraction vers la paroi (--thigmotaxis) - - Phototactisme : fuite de la lumière (--photo-mode, --photo-strength) - - Chimiotactisme : attraction vers une source de nourriture (--chemo-strength) - - Inter-individus : évitement de contact, agrégation, répulsion chimique Usage: - python3 planarian_sim.py [options] + python3 planaire_sim.py [options] Exemples: - python3 planarian_sim.py - python3 planarian_sim.py --count 5 --fps 25 --duration 20 - python3 planarian_sim.py --count 3 --length 8.0 --width 1.2 - - python3 planarian_sim.py --bg-color "#E0DAD4" --arena-color "#F0EBE0"- -thigmotaxis 0.7 - python3 planarian_sim.py --bg-color beige --arena-color ivory --shadow-color lightgray - python3 planarian_sim.py --bg-color beige --arena-color "#FAF0E0" --shadow-color "160 155 148" - - python3 planarian_sim.py --count 5 --thigmotaxis 0.4 - python3 planarian_sim.py --count 5 --photo-mode fixed --photo-x 0.2 --photo-y 0.2 --photo-strength 0.6 - python3 planarian_sim.py --count 5 --chemo-x 0.7 --chemo-y 0.5 --chemo-strength 0.5 - python3 planarian_sim .py --count 5 --avoid-strength 0.6 --aggreg-strength 0.2 - + python3 planaire_sim.py --count 5 --thigmotaxis 0.4 + python3 planaire_sim.py --count 5 --photo-mode fixed --photo-x 0.2 --photo-y 0.2 --photo-strength 0.6 + python3 planaire_sim.py --count 5 --chemo-x 0.7 --chemo-y 0.5 --chemo-strength 0.5 + python3 planaire_sim.py --count 5 --avoid-strength 0.6 --aggreg-strength 0.2 """ +import os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'home.settings') import csv import cv2 @@ -72,11 +32,15 @@ except ImportError: HAS_METRICS = False import numpy as np import math -import os import random import argparse import re +from django.conf import settings + +CSV_DIR = str(settings.MEDIA_ROOT / "simulation" / "planarian_sim_csv") +VIDEO_PATH = str(settings.MEDIA_ROOT / "simulation" / "planarian_simulation.mp4") + # --------------------------------------------------------------------------- # Noms CSS courants → BGR # --------------------------------------------------------------------------- @@ -154,20 +118,19 @@ def parse_args(): # --- Paramètres vidéo --- vg = parser.add_argument_group("Paramètres vidéo") - vg.add_argument("--fps", type=int, default=10, help="Images par seconde") - vg.add_argument("--duration", type=int, default=10, help="Durée en secondes") - vg.add_argument("--output", type=str, default="planaire_simulation.mp4", - help="Fichier vidéo de sortie") - vg.add_argument("--seed", type=int, default=42, help="Graine aléatoire") - vg.add_argument("--default_width", type=int, default=500, help="Image: largeur par défaut px") vg.add_argument("--default_height", type=int, default=500, help="Image: hauteur par défautpx") - vg.add_argument("--default_diameter", type=float, default=16.0, help="Diamètre tube par défaut mm") + vg.add_argument("--default_diameter", type=float, default=16.0, help="Diamètre tube par défaut mm") + vg.add_argument("--fps", type=int, default=10, help="Images par seconde") + vg.add_argument("--duration", type=int, default=10, help="Durée en secondes") + vg.add_argument("--output", type=str, default=VIDEO_PATH, help="Fichier vidéo de sortie") + vg.add_argument("--seed", type=int, default=42, help="Graine aléatoire") + # --- Morphologie --- pg = parser.add_argument_group("Morphologie du planaire") - pg.add_argument("--length", type=float, default=6.0, help="Longueur en mm") - pg.add_argument("--width", type=float, default=0.8, help="Largeur max en mm") + pg.add_argument("--length", type=float, default=1.0, help="Longueur en mm") + pg.add_argument("--width", type=float, default=0.35, help="Largeur max en mm") pg.add_argument("--count", type=int, default=1, help="Nombre de planaires (1-20)") # --- Thigmotactisme --- @@ -239,7 +202,7 @@ def parse_args(): # --- Export CSV --- eg = parser.add_argument_group("Export métriques") - eg.add_argument("--csv-dir", type=str, default=".", help="Répertoire de sortie CSV") + eg.add_argument("--csv-dir", type=str, default=CSV_DIR, help="Répertoire de sortie CSV") eg.add_argument("--no-csv", action="store_true", help="Désactiver l'export CSV") # --- Couleurs --- @@ -248,21 +211,29 @@ def parse_args(): "Formats : #RRGGBB | R G B (RGB) | nom CSS (beige, tan, white…)" ) kg.add_argument("--bg-color", nargs='+', action=ColorAction, - default=(212, 218, 220), metavar="COULEUR", help="Fond extérieur") + default=(235, 235, 235), metavar="COULEUR", + help="Fond extérieur (vue dessous, lumière transmise) $EBEBEB") kg.add_argument("--arena-color", nargs='+', action=ColorAction, - default=(222, 228, 230), metavar="COULEUR", help="Intérieur de l'arène") + default=(250, 250, 250), metavar="COULEUR", + help="Intérieur arène — blanc éclairé par transmission $FAFAFA") kg.add_argument("--arena-border", nargs='+', action=ColorAction, - default=(168, 175, 180), metavar="COULEUR", help="Bordure de l'arène") + default=(140, 140, 140), metavar="COULEUR", + help="Bordure arène $8C8C8C — légèrement plus sombre que l'arène") kg.add_argument("--shadow-color", nargs='+', action=ColorAction, - default=(182, 188, 190), metavar="COULEUR", help="Ombre portée") + default=(200, 200, 200), metavar="COULEUR", + help="Ombre portée — très légère sous lumière transmise $C8C8C8") kg.add_argument("--body-color", nargs='+', action=ColorAction, - default=(150, 120, 90), metavar="COULEUR", help="Corps principal") + default=(165, 165, 165), metavar="COULEUR", + help="Corps — gris translucide moyen $A5A5A5") kg.add_argument("--body-dark", nargs='+', action=ColorAction, - default=(110, 85, 60), metavar="COULEUR", help="Pigmentation sombre") + default=(55, 55, 55), metavar="COULEUR", + help="Contour sombre net du corps $373737 — pour le contraste et la lisibilité") kg.add_argument("--body-light", nargs='+', action=ColorAction, - default=(180, 160, 140), metavar="COULEUR", help="Reflet ventral") + default=(210, 210, 210), metavar="COULEUR", + help="Centre du corps — plus clair par transparence $D2D2D2") kg.add_argument("--head-color", nargs='+', action=ColorAction, - default=(130, 100, 70), metavar="COULEUR", help="Tête") + default=(130, 130, 130), metavar="COULEUR", + help="Tête — légèrement plus sombre que le corps $828282 — pour la différencier du reste du corps") args = parser.parse_args() @@ -544,23 +515,27 @@ class Planaire: self.width_px = max(3, int(cfg.planaire_width_px * random.uniform(0.75, 1.25))) # --- Palette de couleur individuelle (5 familles naturalistes) --- + # Palettes grises — vue de dessous, lumière transmise par le dessus. + # Teinte uniforme gris moyen, seul le niveau de gris varie légèrement + # entre individus pour les distinguer visuellement. PALETTES = [ - {"body": ( 90, 120, 150), "dark": (55, 80, 105), "light": (140, 160, 180), "head": ( 65, 95, 125)}, - {"body": ( 70, 110, 160), "dark": (45, 75, 120), "light": (120, 150, 185), "head": ( 50, 85, 140)}, - {"body": ( 55, 80, 110), "dark": (35, 55, 80), "light": (100, 130, 155), "head": ( 40, 60, 90)}, - {"body": (105, 118, 132), "dark": (70, 85, 98), "light": (150, 162, 172), "head": ( 85, 100, 115)}, - {"body": ( 60, 115, 155), "dark": (40, 80, 115), "light": (110, 155, 185), "head": ( 45, 90, 135)}, + {"body": (165, 165, 165), "dark": (50, 50, 50), "light": (210, 210, 210), "head": (130, 130, 130)}, + {"body": (150, 150, 150), "dark": (45, 45, 45), "light": (200, 200, 200), "head": (118, 118, 118)}, + {"body": (178, 178, 178), "dark": (58, 58, 58), "light": (218, 218, 218), "head": (142, 142, 142)}, + {"body": (158, 158, 158), "dark": (48, 48, 48), "light": (205, 205, 205), "head": (125, 125, 125)}, + {"body": (172, 172, 172), "dark": (55, 55, 55), "light": (215, 215, 215), "head": (138, 138, 138)}, ] palette = PALETTES[random.randint(0, len(PALETTES) - 1)] - def jitter(color, amount=12): - """Ajoute une légère variation aléatoire à une couleur BGR.""" - return tuple(max(0, min(255, c + random.randint(-amount, amount))) for c in color) + def jitter(color, amount=5): + """Variation individuelle minimale — teinte grise très uniforme.""" + v = random.randint(-amount, amount) + return tuple(max(0, min(255, c + v)) for c in color) self.body_color = jitter(palette["body"]) - self.body_dark = jitter(palette["dark"], 8) - self.body_light = jitter(palette["light"], 8) - self.head_color = jitter(palette["head"], 8) + self.body_dark = jitter(palette["dark"], 3) + self.body_light = jitter(palette["light"], 3) + self.head_color = jitter(palette["head"], 3) self.shadow_color = tuple(cfg.shadow_color) # --- Sensibilités individuelles (variation ±30% autour des valeurs globales) --- @@ -927,39 +902,43 @@ class Planaire: if n < 2: return - shadow_offset = (2, 2) + # --- Vue de dessous, lumière transmise par le dessus --- + # Couche 1 : ombre très légère (décalée 1px) — lumière quasi-uniforme for i in range(n - 1): t = i / max(n - 1, 1) - w = max(1, int(self._body_width_at(t) * 0.85)) - p1 = (int(self.body_history[i][0]) + shadow_offset[0], - int(self.body_history[i][1]) + shadow_offset[1]) - p2 = (int(self.body_history[i+1][0]) + shadow_offset[0], - int(self.body_history[i+1][1]) + shadow_offset[1]) + w = max(1, int(self._body_width_at(t))) + p1 = (int(self.body_history[i][0]) + 1, + int(self.body_history[i][1]) + 1) + p2 = (int(self.body_history[i+1][0]) + 1, + int(self.body_history[i+1][1]) + 1) cv2.line(frame, p1, p2, self.shadow_color, w) + # Couche 2 : corps gris uniforme (teinte de base, sans gradient) for i in range(n - 1): t = i / max(n - 1, 1) w = max(1, int(self._body_width_at(t))) p1 = (int(self.body_history[i][0]), int(self.body_history[i][1])) p2 = (int(self.body_history[i+1][0]), int(self.body_history[i+1][1])) - color = tuple(int(self.head_color[c] * (1-t) + self.body_light[c] * t) for c in range(3)) - cv2.line(frame, p1, p2, color, w) + cv2.line(frame, p1, p2, self.body_color, w) + # Couche 3 : contour sombre net (liseré caractéristique vue de dessous) + # Dessiné en 2 passes : largeur w+2 (contour) puis w-2 (remplissage corps) + for i in range(n - 1): + t = i / max(n - 1, 1) + w = max(1, int(self._body_width_at(t))) + p1 = (int(self.body_history[i][0]), int(self.body_history[i][1])) + p2 = (int(self.body_history[i+1][0]), int(self.body_history[i+1][1])) + cv2.line(frame, p1, p2, self.body_dark, w + 2) # contour + cv2.line(frame, p1, p2, self.body_color, max(1, w - 1)) # remplissage + + # Couche 4 : centre clair — lumière transmise au travers du corps for i in range(n - 1): t = i / max(n - 1, 1) - if 0.08 < t < 0.85: - w = max(1, int(self._body_width_at(t) * 0.28)) + if 0.10 < t < 0.90: + w = max(1, int(self._body_width_at(t) * 0.35)) p1 = (int(self.body_history[i][0]), int(self.body_history[i][1])) p2 = (int(self.body_history[i+1][0]), int(self.body_history[i+1][1])) - cv2.line(frame, p1, p2, self.body_dark, w) - - for i in range(n - 1): - t = i / max(n - 1, 1) - if 0.15 < t < 0.75: - w = max(1, int(self._body_width_at(t) * 0.18)) - p1 = (int(self.body_history[i][0]), int(self.body_history[i][1])) - p2 = (int(self.body_history[i+1][0]), int(self.body_history[i+1][1])) - cv2.line(frame, p1, p2, (160, 175, 190), w) + cv2.line(frame, p1, p2, self.body_light, w) head = self.body_history[0] neck = self.body_history[min(3, n - 1)] @@ -972,14 +951,27 @@ class Planaire: right_ear = (int(head[0] + math.cos(head_angle - 1.8) * lw), int(head[1] + math.sin(head_angle - 1.8) * lw)) pts = np.array([tip, left_ear, right_ear], dtype=np.int32) - cv2.fillPoly(frame, [pts], self.head_color) - cv2.polylines(frame, [pts], True, self.body_dark, 1) + # Contour sombre net puis remplissage gris uniforme + cv2.fillPoly(frame, [pts], self.body_dark) + # Remplissage légèrement rétréci pour laisser le contour visible + inner_tip = ( + int(head[0] + math.cos(head_angle) * (self.width_px * 0.3)), + int(head[1] + math.sin(head_angle) * (self.width_px * 0.3)) + ) + ilw = lw * 0.6 + inner_l = (int(head[0] + math.cos(head_angle + 1.8) * ilw), + int(head[1] + math.sin(head_angle + 1.8) * ilw)) + inner_r = (int(head[0] + math.cos(head_angle - 1.8) * ilw), + int(head[1] + math.sin(head_angle - 1.8) * ilw)) + pts_inner = np.array([inner_tip, inner_l, inner_r], dtype=np.int32) + cv2.fillPoly(frame, [pts_inner], self.body_color) - eye_d = lw * 0.6 + # Yeux (photorécepteurs) : points sombres nets + eye_d = lw * 0.55 for side in [1.3, -1.3]: - ex = int(head[0] + math.cos(head_angle + side) * eye_d * 0.7) - ey = int(head[1] + math.sin(head_angle + side) * eye_d * 0.7) - cv2.circle(frame, (ex, ey), max(1, self.width_px // 5), (30, 40, 50), -1) + ex = int(head[0] + math.cos(head_angle + side) * eye_d * 0.65) + ey = int(head[1] + math.sin(head_angle + side) * eye_d * 0.65) + cv2.circle(frame, (ex, ey), max(1, self.width_px // 6), self.body_dark, -1) # --------------------------------------------------------------------------- @@ -1152,14 +1144,9 @@ def main(): np.random.seed(args.seed) # --- Constantes dérivées --- - #WIDTH, HEIGHT = 500, 500 - #TOTAL_FRAMES = args.fps * args.duration - #MM_TO_PX = 420 / 16.0 # ~26.25 px/mm - - WIDTH, HEIGHT = args.default_width, args.default_height + WIDTH, HEIGHT = 500, 500 TOTAL_FRAMES = args.fps * args.duration - MM_TO_PX = (args.default_width - 80) / args.default_diameter # ~26.25 px/mm - + MM_TO_PX = 420 / 16.0 # ~26.25 px/mm ARENA_RADIUS_PX = int(8 * MM_TO_PX) ARENA_CENTER = (WIDTH // 2, HEIGHT // 2)