diff --git a/test_tube_scanner/home/settings.py b/test_tube_scanner/home/settings.py index af79300..25c4ed4 100644 --- a/test_tube_scanner/home/settings.py +++ b/test_tube_scanner/home/settings.py @@ -407,3 +407,4 @@ CALIBRATION_AUTO_DURATION = 45.0 CALIBRATION_AUTO_TIMEOUT = 2.5 + diff --git a/test_tube_scanner/make_videos.sh b/test_tube_scanner/make_videos.sh new file mode 100755 index 0000000..0f64ea7 --- /dev/null +++ b/test_tube_scanner/make_videos.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# Génère 24 vidéos pour simuler le balayage d'un multi-puit de 6x24 +# A1..A6, B1..B6, C1..C6, D1..D6 +# + +PATH="data" +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" +) + +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" + + 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 +done diff --git a/test_tube_scanner/planarian_sim.py b/test_tube_scanner/planarian_sim.py new file mode 100755 index 0000000..2a29f03 --- /dev/null +++ b/test_tube_scanner/planarian_sim.py @@ -0,0 +1,1341 @@ +#!../.venv/bin/python +""" +Planaire simulation de mouvement aléatoire +========================================== + +Espace circulaire de 16mm de diamètre, 500x500px paramètrable +Supporte plusieurs planaires avec paramètres configurables via arguments CLI. +Export CSV par planaire compatible EthoVision XT. + +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 + + 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] + +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 + +""" + +import csv +import cv2 +try: + from planarian_metrics import EthoVisionMetrics + HAS_METRICS = True +except ImportError: + HAS_METRICS = False +import numpy as np +import math +import os +import random +import argparse +import re + +# --------------------------------------------------------------------------- +# Noms CSS courants → BGR +# --------------------------------------------------------------------------- +CSS_COLORS = { + "white": (255, 255, 255), "black": ( 0, 0, 0), + "red": ( 0, 0, 255), "green": ( 0, 128, 0), + "blue": (255, 0, 0), "yellow": ( 0, 255, 255), + "cyan": (255, 255, 0), "magenta": (255, 0, 255), + "orange": ( 0, 165, 255), "pink": (203, 192, 255), + "purple": (128, 0, 128), "brown": ( 42, 42, 165), + "gray": (128, 128, 128), "grey": (128, 128, 128), + "lightgray": (211, 211, 211), "darkgray": (169, 169, 169), + "beige": (220, 245, 245), "ivory": (240, 255, 255), + "khaki": (140, 230, 240), "olive": ( 0, 128, 128), + "teal": (128, 128, 0), "navy": (128, 0, 0), + "coral": ( 80, 127, 255), "salmon": (114, 128, 250), + "tan": (140, 180, 210), "wheat": (179, 222, 245), + "linen": (230, 240, 250), "lavender": (250, 230, 230), + "transparent": ( 0, 0, 0), +} + + +def parse_color(value): + """ + Convertit une couleur CLI en tuple BGR pour OpenCV. + Formats : "#RRGGBB" | "R G B" (RGB) | nom CSS. + """ + if isinstance(value, list): + value = " ".join(str(v) for v in value) + value = value.strip() + + hex_match = re.fullmatch(r"#?([0-9A-Fa-f]{6})", value) + if hex_match: + h = hex_match.group(1) + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + return (b, g, r) + + rgb_match = re.fullmatch(r"(\d+)\s+(\d+)\s+(\d+)", value) + if rgb_match: + r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3)) + for v in (r, g, b): + if not 0 <= v <= 255: + raise argparse.ArgumentTypeError(f"Valeur RGB hors plage [0-255] : {v}") + return (b, g, r) + + key = value.lower().replace(" ", "").replace("-", "") + if key in CSS_COLORS: + return CSS_COLORS[key] + + raise argparse.ArgumentTypeError( + f"Couleur non reconnue : '{value}'. " + f"Formats : #RRGGBB | R G B | nom CSS (beige, tan, white…)" + ) + + +class ColorAction(argparse.Action): + """Action argparse pour les couleurs — accepte hex, RGB ou nom CSS.""" + def __call__(self, parser, namespace, values, option_string=None): + try: + setattr(namespace, self.dest, parse_color(values)) + except argparse.ArgumentTypeError as e: + parser.error(str(e)) + + +# --------------------------------------------------------------------------- +# Parsing des arguments CLI +# --------------------------------------------------------------------------- + +def parse_args(): + """Définit et parse tous les arguments de la simulation.""" + parser = argparse.ArgumentParser( + description="Simulation du déplacement aléatoire de planaires (vue de dessus)", + formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + + # --- 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") + + # --- 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("--count", type=int, default=1, help="Nombre de planaires (1-20)") + + # --- Thigmotactisme --- + bg = parser.add_argument_group( + "Thigmotactisme", + "Attraction vers la paroi circulaire (0=désactivé, 1=fort)" + ) + bg.add_argument("--thigmotaxis", type=float, default=0.0, + help="Intensité (0.0-1.0, typique : 0.3-0.6)") + + # --- Phototactisme --- + lg = parser.add_argument_group( + "Phototactisme", + "Les planaires fuient la lumière. " + "Modes : fixed (source fixe x,y) | sine (source sinusoïdale) | radial (gradient depuis le centre)" + ) + lg.add_argument("--photo-mode", type=str, default="none", + choices=["none", "fixed", "sine", "radial"], + help="Mode de source lumineuse") + lg.add_argument("--photo-strength", type=float, default=0.5, + help="Intensité de la fuite (0.0-1.0)") + lg.add_argument("--photo-x", type=float, default=0.5, + help="Position X source fixe (fraction 0-1 de l'arène, 0=gauche)") + lg.add_argument("--photo-y", type=float, default=0.5, + help="Position Y source fixe (fraction 0-1 de l'arène, 0=haut)") + lg.add_argument("--photo-sine-freq",type=float, default=0.1, + help="Fréquence du mouvement sinusoïdal de la source (Hz)") + lg.add_argument("--photo-radius", type=float, default=0.3, + help="Rayon du gradient radial (fraction du rayon de l'arène, mode radial)") + + # --- Chimiotactisme --- + cg = parser.add_argument_group( + "Chimiotactisme", + "Attraction vers une source de nourriture (point unique dans l'arène)" + ) + cg.add_argument("--chemo-strength", type=float, default=0.0, + help="Intensité de l'attraction chimique (0=désactivé, 1=fort)") + cg.add_argument("--chemo-x", type=float, default=0.7, + help="Position X de la nourriture (fraction 0-1)") + cg.add_argument("--chemo-y", type=float, default=0.7, + help="Position Y de la nourriture (fraction 0-1)") + cg.add_argument("--chemo-radius", type=float, default=2.0, + help="Rayon d'influence du chimiotactisme en mm") + + # --- Interactions inter-individus --- + ig = parser.add_argument_group( + "Interactions inter-individus", + "Évitement de contact, agrégation et répulsion chimique entre planaires" + ) + ig.add_argument("--avoid-strength", type=float, default=0.0, + help="Force d'évitement de contact (0=désactivé, 1=fort)") + ig.add_argument("--avoid-radius", type=float, default=3.0, + help="Rayon d'évitement en mm") + ig.add_argument("--aggreg-strength", type=float, default=0.0, + help="Force d'agrégation — attraction vers les congénères (0=désactivé)") + ig.add_argument("--aggreg-radius", type=float, default=6.0, + help="Rayon d'agrégation en mm (doit être > --avoid-radius)") + ig.add_argument("--chem-repulsion", type=float, default=0.0, + help="Répulsion chimique — fuite des traces laissées par les congénères (0=désactivé)") + ig.add_argument("--chem-decay", type=float, default=0.95, + help="Facteur de décroissance des traces chimiques par frame (0-1)") + + # --- Seuils de mobilité EthoVision --- + mg = parser.add_argument_group("Seuils de mobilité (EthoVision XT)") + mg.add_argument("--thresh-immobile", type=float, default=0.2, + help="Vitesse max état Immobile (mm/s)") + mg.add_argument("--thresh-mobile", type=float, default=1.5, + help="Vitesse max état Mobile (mm/s). Au-delà = Très mobile") + + # --- 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("--no-csv", action="store_true", help="Désactiver l'export CSV") + + # --- Couleurs --- + kg = parser.add_argument_group( + "Couleurs", + "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") + kg.add_argument("--arena-color", nargs='+', action=ColorAction, + default=(222, 228, 230), metavar="COULEUR", help="Intérieur de l'arène") + kg.add_argument("--arena-border", nargs='+', action=ColorAction, + default=(168, 175, 180), metavar="COULEUR", help="Bordure de l'arène") + kg.add_argument("--shadow-color", nargs='+', action=ColorAction, + default=(182, 188, 190), metavar="COULEUR", help="Ombre portée") + kg.add_argument("--body-color", nargs='+', action=ColorAction, + default=(150, 120, 90), metavar="COULEUR", help="Corps principal") + kg.add_argument("--body-dark", nargs='+', action=ColorAction, + default=(110, 85, 60), metavar="COULEUR", help="Pigmentation sombre") + kg.add_argument("--body-light", nargs='+', action=ColorAction, + default=(180, 160, 140), metavar="COULEUR", help="Reflet ventral") + kg.add_argument("--head-color", nargs='+', action=ColorAction, + default=(130, 100, 70), metavar="COULEUR", help="Tête") + + args = parser.parse_args() + + # Validations + if args.count < 1 or args.count > 20: + parser.error("--count doit être compris entre 1 et 20") + if args.thresh_immobile >= args.thresh_mobile: + parser.error("--thresh-immobile doit être < --thresh-mobile") + if args.aggreg_radius <= args.avoid_radius: + parser.error("--aggreg-radius doit être > --avoid-radius") + + return args + + +# --------------------------------------------------------------------------- +# Carte chimique partagée (traces de répulsion inter-individus) +# --------------------------------------------------------------------------- + +class ChemicalMap: + """ + Carte de concentration chimique en 2D simulant les traces de mucus + laissées par les planaires (répulsion chimique inter-individus). + + Chaque planaire dépose une trace à sa position courante. + La concentration décroît exponentiellement à chaque frame (decay). + """ + + def __init__(self, width, height, decay): + """ + Args: + width, height : dimensions en pixels de l'arène + decay : facteur de décroissance par frame (ex: 0.95) + """ + self.map = np.zeros((height, width), dtype=np.float32) + self.decay = decay + + def deposit(self, x_px, y_px, radius_px=4, amount=1.0): + """ + Dépose une trace chimique à la position (x_px, y_px). + + Args: + x_px, y_px : position en pixels + radius_px : rayon de dépôt en pixels + amount : quantité déposée (0-1) + """ + xi, yi = int(round(x_px)), int(round(y_px)) + cv2.circle(self.map, (xi, yi), radius_px, amount, -1) + # Clamp à 1.0 + np.clip(self.map, 0.0, 1.0, out=self.map) + + def step(self): + """Applique la décroissance temporelle (à appeler une fois par frame).""" + self.map *= self.decay + + def gradient_at(self, x_px, y_px, radius_px=8): + """ + Calcule le gradient de concentration autour du point (x_px, y_px). + Retourne l'angle de montée du gradient (direction de concentration croissante) + et l'intensité locale. + + Args: + x_px, y_px : position en pixels + radius_px : rayon de lecture du gradient + + Returns: + tuple (angle_rad, intensity) ou (None, 0) si hors carte + """ + h, w = self.map.shape + xi, yi = int(round(x_px)), int(round(y_px)) + + # Lecture des concentrations dans les 4 directions cardinales + def safe_get(x, y): + """Accès sécurisé à la carte avec rebord à zéro.""" + if 0 <= x < w and 0 <= y < h: + return float(self.map[y, x]) + return 0.0 + + dx = safe_get(xi + radius_px, yi) - safe_get(xi - radius_px, yi) + dy = safe_get(xi, yi + radius_px) - safe_get(xi, yi - radius_px) + intensity = safe_get(xi, yi) + + if abs(dx) < 1e-6 and abs(dy) < 1e-6: + return None, intensity + + return math.atan2(dy, dx), intensity + + +# --------------------------------------------------------------------------- +# Classe Tracker — métriques EthoVision par planaire +# --------------------------------------------------------------------------- + +class Tracker: + """ + Calcule et accumule les métriques de déplacement compatibles EthoVision XT. + Une instance par planaire. + """ + + IMMOBILE = "Immobile" + MOBILE = "Mobile" + HIGH_MOBILE = "Highly mobile" + + def __init__(self, planaire_id, mm_to_px, fps, thresh_immobile, thresh_mobile, + arena_center, arena_radius_px): + self.planaire_id = planaire_id + self.mm_to_px = mm_to_px + self.fps = fps + self.thresh_immobile = thresh_immobile + self.thresh_mobile = thresh_mobile + self.arena_center = arena_center + self.arena_radius_px = arena_radius_px + self.dt = 1.0 / fps + + self.total_distance_mm = 0.0 + self.duration_moving_s = 0.0 + self.duration_stopped_s = 0.0 + + self._mobility_counts = {self.IMMOBILE: 0, self.MOBILE: 0, self.HIGH_MOBILE: 0} + self._mobility_durations = {self.IMMOBILE: 0.0, self.MOBILE: 0.0, self.HIGH_MOBILE: 0.0} + self._current_state = None + self._prev_x = None + self._prev_y = None + + self.records = [] + + def _px_to_mm(self, dist_px): + """Convertit des pixels en millimètres.""" + return dist_px / self.mm_to_px + + def _classify_mobility(self, velocity_mm_s): + """Classe la vitesse selon les seuils EthoVision.""" + if velocity_mm_s <= self.thresh_immobile: + return self.IMMOBILE + elif velocity_mm_s <= self.thresh_mobile: + return self.MOBILE + return self.HIGH_MOBILE + + def update(self, frame_idx, x_px, y_px): + """ + Met à jour les métriques pour la frame courante. + + Args: + frame_idx : index de la frame (0-based) + x_px, y_px: position du centre du planaire en pixels + """ + t_s = frame_idx * self.dt + + if self._prev_x is not None: + dx_px = x_px - self._prev_x + dy_px = y_px - self._prev_y + dist_mm = self._px_to_mm(math.sqrt(dx_px**2 + dy_px**2)) + velocity_mm_s = dist_mm / self.dt + else: + dist_mm = 0.0 + velocity_mm_s = 0.0 + + self.total_distance_mm += dist_mm + is_moving = velocity_mm_s > self.thresh_immobile + if is_moving: + self.duration_moving_s += self.dt + else: + self.duration_stopped_s += self.dt + + new_state = self._classify_mobility(velocity_mm_s) + if new_state != self._current_state: + self._mobility_counts[new_state] += 1 + self._current_state = new_state + self._mobility_durations[new_state] += self.dt + + dx_arena = x_px - self.arena_center[0] + dy_arena = y_px - self.arena_center[1] + dist_center_px = math.sqrt(dx_arena**2 + dy_arena**2) + dist_wall_mm = self._px_to_mm(self.arena_radius_px - dist_center_px) + + self.records.append({ + "frame": frame_idx, + "time_s": round(t_s, 3), + "x_mm": round(self._px_to_mm(x_px), 4), + "y_mm": round(self._px_to_mm(y_px), 4), + "velocity_mm_s": round(velocity_mm_s, 4), + "distance_mm": round(dist_mm, 4), + "total_distance_mm": round(self.total_distance_mm, 4), + "moving": int(is_moving), + "duration_moving_s": round(self.duration_moving_s, 3), + "duration_stopped_s": round(self.duration_stopped_s, 3), + "mobility_state": new_state, + "dist_to_wall_mm": round(dist_wall_mm, 4), + "dist_to_center_mm": round(self._px_to_mm(dist_center_px), 4), + }) + + self._prev_x = x_px + self._prev_y = y_px + + def summary(self): + """Retourne le dictionnaire de résumé global (nomenclature EthoVision).""" + total_s = len(self.records) / self.fps + return { + "planaire_id": self.planaire_id, + "total_duration_s": round(total_s, 3), + "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), + "movement_moving_duration_s": round(self.duration_moving_s, 3), + "movement_not_moving_duration_s": round(self.duration_stopped_s, 3), + "mobility_immobile_frequency": self._mobility_counts[self.IMMOBILE], + "mobility_immobile_duration_s": round(self._mobility_durations[self.IMMOBILE], 3), + "mobility_mobile_frequency": self._mobility_counts[self.MOBILE], + "mobility_mobile_duration_s": round(self._mobility_durations[self.MOBILE], 3), + "mobility_highly_mobile_frequency": self._mobility_counts[self.HIGH_MOBILE], + "mobility_highly_mobile_duration_s": round(self._mobility_durations[self.HIGH_MOBILE], 3), + "thigmotaxis_pct_time_near_wall": round( + 100.0 * sum(1 for r in self.records if r["dist_to_wall_mm"] < 1.0) + / max(len(self.records), 1), 2), + } + + def write_csv(self, csv_dir, output_stem): + """ + Écrit les CSV frames et summary pour ce planaire. + + Args: + csv_dir : répertoire de sortie + output_stem : nom de base du fichier vidéo (sans extension) + """ + os.makedirs(csv_dir, exist_ok=True) + base = f"{output_stem}_planaire_{self.planaire_id:02d}" + + frames_path = os.path.join(csv_dir, f"{base}_frames.csv") + if self.records: + with open(frames_path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=list(self.records[0].keys())) + writer.writeheader() + writer.writerows(self.records) + + summary_path = os.path.join(csv_dir, f"{base}_summary.csv") + s = self.summary() + with open(summary_path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=list(s.keys())) + writer.writeheader() + writer.writerow(s) + + print(f" CSV [{self.planaire_id:02d}] → {frames_path}") + print(f" CSV [{self.planaire_id:02d}] → {summary_path}") + + +# --------------------------------------------------------------------------- +# Classe Planaire +# --------------------------------------------------------------------------- + +class Planaire: + """ + Simule le déplacement aléatoire d'un planaire dans une arène circulaire. + + Comportements intégrés : + - Locomotion aléatoire avec ondulation + - Thigmotactisme (paroi) + - Phototactisme (fuite lumière) + - Chimiotactisme (attraction nourriture) + - Interactions inter-individus (évitement, agrégation, répulsion chimique) + """ + + def __init__(self, planaire_id, cfg, arena_center, arena_radius_px, + mm_to_px, start_x=None, start_y=None): + """ + Args: + planaire_id : identifiant numérique (0-based) + cfg : namespace argparse + arena_center : tuple (cx, cy) en pixels + arena_radius_px : rayon de l'arène en pixels + mm_to_px : facteur de conversion mm → pixels + start_x, start_y: position initiale en pixels (None = aléatoire) + """ + self.planaire_id = planaire_id + self.cfg = cfg + self.arena_center = arena_center + self.arena_radius_px = arena_radius_px + self.mm_to_px = mm_to_px + + # --- Variation individuelle de morphologie (±20% longueur, ±25% largeur) --- + self.length_px = max(20, int(cfg.planaire_length_px * random.uniform(0.80, 1.20))) + self.width_px = max(3, int(cfg.planaire_width_px * random.uniform(0.75, 1.25))) + + # --- Palette de couleur individuelle (5 familles naturalistes) --- + 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)}, + ] + 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) + + 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.shadow_color = tuple(cfg.shadow_color) + + # --- Sensibilités individuelles (variation ±30% autour des valeurs globales) --- + def indiv(val): + """Variation individuelle ±30% clampée à [0, 1].""" + return max(0.0, min(1.0, val * random.uniform(0.70, 1.30))) + + self.thigmotaxis = indiv(cfg.thigmotaxis) + self.photo_strength = indiv(cfg.photo_strength) + self.chemo_strength = indiv(cfg.chemo_strength) + self.avoid_strength = indiv(cfg.avoid_strength) + self.aggreg_strength = indiv(cfg.aggreg_strength) + self.chem_repulsion = indiv(cfg.chem_repulsion) + + # --- Position initiale --- + if start_x is not None and start_y is not None: + self.x = float(start_x) + self.y = float(start_y) + else: + a = random.uniform(0, 2 * math.pi) + r = random.uniform(0, arena_radius_px * 0.5) + self.x = arena_center[0] + r * math.cos(a) + self.y = arena_center[1] + r * math.sin(a) + + # --- État cinématique --- + self.angle = random.uniform(0, 2 * math.pi) + self.speed = random.uniform(2.5, 5.0) + self.wave_phase = random.uniform(0, 2 * math.pi) + self.wave_freq = random.uniform(0.6, 1.0) + self.wave_amp = random.uniform(0.14, 0.22) + self.turn_rate = 0.0 + self.frames_to_turn = 0 + self.pause_frames = 0 + + # --- Historique de positions pour le rendu du corps courbé --- + self.body_history = [] + self._init_body() + + def _init_body(self): + """Initialise l'historique de positions du corps en ligne droite.""" + for i in range(self.length_px): + self.body_history.append(( + self.x - i * math.cos(self.angle), + self.y - i * math.sin(self.angle) + )) + + # --- Utilitaire : déviation angulaire vers une cible --- + @staticmethod + def _steer_toward(current_angle, target_angle, weight): + """ + Calcule la correction angulaire vers target_angle pondérée par weight. + + Args: + current_angle : angle courant en radians + target_angle : angle cible en radians + weight : force de la déviation (0=aucune, 1=totale) + + Returns: + correction angulaire en radians + """ + diff = (target_angle - current_angle + math.pi) % (2 * math.pi) - math.pi + return weight * diff + + @staticmethod + def _steer_away(current_angle, threat_angle, weight): + """ + Calcule la correction angulaire pour fuir threat_angle. + + Args: + current_angle : angle courant en radians + threat_angle : angle vers la menace en radians + weight : force de la fuite (0=aucune, 1=totale) + + Returns: + correction angulaire en radians + """ + away_angle = threat_angle + math.pi + diff = (away_angle - current_angle + math.pi) % (2 * math.pi) - math.pi + return weight * diff + + def _photo_angle(self, frame_idx, photo_source_x, photo_source_y): + """ + Calcule la correction angulaire due au phototactisme. + Le planaire fuit la source lumineuse. + + Args: + frame_idx : frame courante (pour mode sine) + photo_source_x, _y : position de la source lumineuse en pixels + + Returns: + correction angulaire en radians (0 si phototactisme désactivé) + """ + if self.cfg.photo_mode == "none" or self.photo_strength == 0.0: + return 0.0 + + if self.cfg.photo_mode == "radial": + # Gradient radial : fuite vers la périphérie de l'arène + dx = self.x - self.arena_center[0] + dy = self.y - self.arena_center[1] + dist_c = math.sqrt(dx**2 + dy**2) + if dist_c < 1.0: + return 0.0 + # Intensité inversement proportionnelle à la distance au centre + zone = self.arena_radius_px * self.cfg.photo_radius + intensity = max(0.0, 1.0 - dist_c / zone) * self.photo_strength + away_center = math.atan2(dy, dx) # fuite vers la périphérie + return self._steer_toward(self.angle, away_center, intensity) + + # Modes fixed et sine : fuite depuis la source ponctuelle + dx = self.x - photo_source_x + dy = self.y - photo_source_y + dist = math.sqrt(dx**2 + dy**2) + if dist < 1.0: + return 0.0 + + # Intensité inversement proportionnelle à la distance (décroissance linéaire) + influence_radius = self.arena_radius_px * 1.2 + intensity = max(0.0, 1.0 - dist / influence_radius) * self.photo_strength + toward_source = math.atan2(dy, dx) + return self._steer_away(self.angle, toward_source - math.pi, intensity) + + def _chemo_angle(self, chemo_x_px, chemo_y_px): + """ + Calcule la correction angulaire due au chimiotactisme. + Le planaire est attiré vers la source de nourriture. + + Args: + chemo_x_px, chemo_y_px : position de la nourriture en pixels + + Returns: + correction angulaire en radians + """ + if self.chemo_strength == 0.0: + return 0.0 + + dx = chemo_x_px - self.x + dy = chemo_y_px - self.y + dist = math.sqrt(dx**2 + dy**2) + if dist < 1.0: + return 0.0 + + # Influence décroissante avec la distance (rayon paramétrable) + influence_radius = self.cfg.chemo_radius * self.mm_to_px + intensity = max(0.0, 1.0 - dist / influence_radius) * self.chemo_strength + toward_food = math.atan2(dy, dx) + return self._steer_toward(self.angle, toward_food, intensity) + + def _social_angle(self, others): + """ + Calcule la correction angulaire due aux interactions inter-individus : + - Évitement de contact (répulsion courte portée) + - Agrégation (attraction longue portée) + + Args: + others : liste de tuples (x, y) des positions des autres planaires + + Returns: + correction angulaire en radians + """ + if not others: + return 0.0 + + avoid_radius_px = self.cfg.avoid_radius * self.mm_to_px + aggreg_radius_px = self.cfg.aggreg_radius * self.mm_to_px + + avoid_dx, avoid_dy = 0.0, 0.0 # vecteur de répulsion cumulé + aggreg_dx, aggreg_dy = 0.0, 0.0 # vecteur d'attraction cumulé + n_avoid, n_aggreg = 0, 0 + + for ox, oy in others: + dx = self.x - ox + dy = self.y - oy + dist = math.sqrt(dx**2 + dy**2) + + if dist < 1.0: + continue + + if dist < avoid_radius_px: + # Répulsion inversement proportionnelle à la distance + force = (1.0 - dist / avoid_radius_px) + avoid_dx += dx / dist * force + avoid_dy += dy / dist * force + n_avoid += 1 + + elif dist < aggreg_radius_px: + # Attraction proportionnelle à la distance (plus fort si loin) + force = (dist - avoid_radius_px) / (aggreg_radius_px - avoid_radius_px) + aggreg_dx += -dx / dist * force + aggreg_dy += -dy / dist * force + n_aggreg += 1 + + correction = 0.0 + + if n_avoid > 0 and self.avoid_strength > 0.0: + avoid_angle = math.atan2(avoid_dy / n_avoid, avoid_dx / n_avoid) + correction += self._steer_toward(self.angle, avoid_angle, + self.avoid_strength * 0.8) + + if n_aggreg > 0 and self.aggreg_strength > 0.0: + aggreg_angle = math.atan2(aggreg_dy / n_aggreg, aggreg_dx / n_aggreg) + correction += self._steer_toward(self.angle, aggreg_angle, + self.aggreg_strength * 0.4) + + return correction + + def _chem_repulsion_angle(self, chem_map): + """ + Calcule la correction angulaire due à la répulsion chimique (traces de mucus). + Le planaire fuit les zones de forte concentration chimique. + + Args: + chem_map : instance ChemicalMap + + Returns: + correction angulaire en radians + """ + if self.chem_repulsion == 0.0 or chem_map is None: + return 0.0 + + grad_angle, intensity = chem_map.gradient_at(self.x, self.y) + if grad_angle is None or intensity < 0.01: + return 0.0 + + # Fuite dans la direction opposée au gradient + return self._steer_away(self.angle, grad_angle, self.chem_repulsion * intensity) + + def update(self, frame_idx, others_positions, chem_map, photo_source): + """ + Met à jour la position et l'orientation du planaire pour une frame. + + Args: + frame_idx : index de la frame courante + others_positions: liste de (x, y) des autres planaires + chem_map : instance ChemicalMap (ou None) + photo_source : tuple (x, y) de la source lumineuse en pixels + """ + fps = self.cfg.fps + + # --- Gestion des pauses (immobilité momentanée) --- + if self.pause_frames > 0: + self.pause_frames -= 1 + self.wave_phase += self.wave_freq * (2 * math.pi / fps) * 0.3 + self.body_history.insert(0, (self.x, self.y)) + self.body_history.pop() + if chem_map is not None: + chem_map.deposit(self.x, self.y) + return + + # --- Choix du prochain comportement locomoteur de base --- + if self.frames_to_turn <= 0: + r = random.random() + delta = 0.0 + if r < 0.05: + self.pause_frames = random.randint(3, 8) + return + elif r < 0.35: + delta = random.uniform(-math.pi * 0.7, math.pi * 0.7) + self.frames_to_turn = random.randint(6, 18) + self.speed = random.uniform(2.0, 5.5) + else: + delta = random.uniform(-math.pi * 0.2, math.pi * 0.2) + self.frames_to_turn = random.randint(3, 10) + self.speed = random.uniform(3.0, 6.0) + self.turn_rate = delta / max(self.frames_to_turn, 1) + + if self.frames_to_turn > 0: + self.angle += self.turn_rate + self.frames_to_turn -= 1 + + # --- Ondulation du corps --- + self.wave_phase += self.wave_freq * (2 * math.pi / fps) + effective_angle = self.angle + self.wave_amp * math.sin(self.wave_phase) + + # --- Thigmotactisme --- + if self.thigmotaxis > 0.0: + dx_cur = self.x - self.arena_center[0] + dy_cur = self.y - self.arena_center[1] + dist_c = math.sqrt(dx_cur**2 + dy_cur**2) + zone_start = self.arena_radius_px * 0.60 + zone_end = self.arena_radius_px * 0.90 + if dist_c > zone_start: + influence = min(1.0, (dist_c - zone_start) / (zone_end - zone_start)) + radial_angle = math.atan2(dy_cur, dx_cur) + tangent_angle = radial_angle + math.pi / 2 + diff = (tangent_angle - effective_angle + math.pi) % (2 * math.pi) - math.pi + if diff > math.pi / 2 or diff < -math.pi / 2: + tangent_angle += math.pi + effective_angle += influence * self.thigmotaxis * ( + (tangent_angle - effective_angle + math.pi) % (2 * math.pi) - math.pi + ) + + # --- Accumulation des corrections comportementales --- + photo_x, photo_y = photo_source + chemo_x = self.arena_center[0] + (self.cfg.chemo_x - 0.5) * 2 * self.arena_radius_px + chemo_y = self.arena_center[1] + (self.cfg.chemo_y - 0.5) * 2 * self.arena_radius_px + + effective_angle += self._photo_angle(frame_idx, photo_x, photo_y) + effective_angle += self._chemo_angle(chemo_x, chemo_y) + effective_angle += self._social_angle(others_positions) + effective_angle += self._chem_repulsion_angle(chem_map) + + # --- Calcul de la nouvelle position --- + new_x = self.x + self.speed * math.cos(effective_angle) + new_y = self.y + self.speed * math.sin(effective_angle) + + # --- Rebond sur la paroi circulaire --- + dx = new_x - self.arena_center[0] + dy = new_y - self.arena_center[1] + dist = math.sqrt(dx**2 + dy**2) + margin = self.length_px // 2 + + if dist + margin > self.arena_radius_px: + toward_center = math.atan2( + self.arena_center[1] - self.y, self.arena_center[0] - self.x) + self.angle = toward_center + random.uniform(-0.4, 0.4) + self.frames_to_turn = 0 + new_x = self.x + self.speed * math.cos(self.angle) + new_y = self.y + self.speed * math.sin(self.angle) + dx2 = new_x - self.arena_center[0] + dy2 = new_y - self.arena_center[1] + if math.sqrt(dx2**2 + dy2**2) + margin > self.arena_radius_px: + new_x, new_y = self.x, self.y + + self.x = new_x + self.y = new_y + + # --- Dépôt de trace chimique --- + if chem_map is not None: + chem_map.deposit(self.x, self.y) + + # --- Mise à jour de l'historique --- + self.body_history.insert(0, (self.x, self.y)) + if len(self.body_history) > self.length_px: + self.body_history.pop() + + def _body_width_at(self, t): + """ + Profil de largeur le long du corps (t ∈ [0,1], 0=tête, 1=queue). + + Args: + t : position normalisée le long du corps + + Returns: + largeur en pixels (float) + """ + w = self.width_px + if t < 0.12: + return w * (t / 0.12) * 0.6 + elif t < 0.25: + return w * (0.6 + 0.4 * ((t - 0.12) / 0.13)) + elif t < 0.6: + return w * (1.0 - 0.15 * abs((t - 0.4) / 0.35)) + else: + return w * (1.0 - t) / 0.4 * 0.85 + + def draw(self, frame): + """ + Dessine le planaire sur la frame OpenCV (BGR). + + Args: + frame : image numpy (H, W, 3) sur laquelle dessiner + """ + n = len(self.body_history) + if n < 2: + return + + shadow_offset = (2, 2) + 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]) + cv2.line(frame, p1, p2, self.shadow_color, w) + + 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) + + 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)) + 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) + + head = self.body_history[0] + neck = self.body_history[min(3, n - 1)] + head_angle = math.atan2(head[1] - neck[1], head[0] - neck[0]) + tip = (int(head[0] + math.cos(head_angle) * self.width_px * 0.5), + int(head[1] + math.sin(head_angle) * self.width_px * 0.5)) + lw = self.width_px * 0.45 + left_ear = (int(head[0] + math.cos(head_angle + 1.8) * lw), + int(head[1] + math.sin(head_angle + 1.8) * lw)) + 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) + + eye_d = lw * 0.6 + 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) + + +# --------------------------------------------------------------------------- +# Rendu de l'arène et des stimuli +# --------------------------------------------------------------------------- + +def draw_arena(frame, cfg, width, height, arena_center, arena_radius_px, mm_to_px): + """ + Dessine l'arène circulaire (boîte de Pétri vue de dessus). + + Args: + frame : image numpy à modifier en place + cfg : namespace argparse + width, height : dimensions en pixels + arena_center : tuple (cx, cy) + arena_radius_px : rayon en pixels + mm_to_px : facteur mm → pixels + """ + bg_color = (int(cfg.bg_color[0]), int(cfg.bg_color[1]), int(cfg.bg_color[2])) + arena_color = (int(cfg.arena_color[0]), int(cfg.arena_color[1]), int(cfg.arena_color[2])) + arena_border = (int(cfg.arena_border[0]), int(cfg.arena_border[1]), int(cfg.arena_border[2])) + + frame[:, :, 0] = bg_color[0] + frame[:, :, 1] = bg_color[1] + frame[:, :, 2] = bg_color[2] + + cv2.circle(frame, arena_center, arena_radius_px, arena_color, -1) + + for r_off, alpha in [(0, 80), (1, 50), (2, 30)]: + overlay = frame.copy() + cv2.circle(overlay, arena_center, arena_radius_px + r_off, (245, 243, 238), 3) + cv2.addWeighted(overlay, alpha / 255.0, frame, 1 - alpha / 255.0, 0, frame) + + cv2.circle(frame, arena_center, arena_radius_px, arena_border, 2) + cv2.circle(frame, arena_center, arena_radius_px + 4, (200, 198, 192), 1) + + bar_len = int(mm_to_px) + bx, by = width - 40, height - 25 + cv2.line(frame, (bx - bar_len, by), (bx, by), (100, 100, 100), 1) + cv2.line(frame, (bx - bar_len, by - 3), (bx - bar_len, by + 3), (100, 100, 100), 1) + cv2.line(frame, (bx, by - 3), (bx, by + 3), (100, 100, 100), 1) + cv2.putText(frame, "1mm", (bx - bar_len - 5, by - 6), + cv2.FONT_HERSHEY_SIMPLEX, 0.3, (80, 80, 80), 1, cv2.LINE_AA) + cv2.putText(frame, "o 16mm", (arena_center[0] - 28, height - 10), + cv2.FONT_HERSHEY_SIMPLEX, 0.3, (120, 118, 112), 1, cv2.LINE_AA) + + +def draw_stimuli(frame, cfg, arena_center, arena_radius_px, mm_to_px, + photo_source, chem_map): + """ + Superpose les indicateurs visuels des stimuli actifs sur la frame. + + Args: + frame : image numpy BGR + cfg : namespace argparse + arena_center : tuple (cx, cy) + arena_radius_px : rayon de l'arène en pixels + mm_to_px : facteur mm → pixels + photo_source : tuple (x, y) source lumineuse en pixels + chem_map : instance ChemicalMap (ou None) + """ + # --- Carte chimique (traces de mucus en rouge très transparent) --- + if chem_map is not None and cfg.chem_repulsion > 0.0: + heat = (chem_map.map * 80).astype(np.uint8) + overlay = frame.copy() + overlay[:, :, 2] = np.clip(overlay[:, :, 2].astype(np.int16) + heat, 0, 255).astype(np.uint8) + cv2.addWeighted(overlay, 0.3, frame, 0.7, 0, frame) + + # --- Source de nourriture (chimiotactisme) --- + if cfg.chemo_strength > 0.0: + cx = int(arena_center[0] + (cfg.chemo_x - 0.5) * 2 * arena_radius_px) + cy = int(arena_center[1] + (cfg.chemo_y - 0.5) * 2 * arena_radius_px) + # Halo vert dégradé + for r, alpha in [(int(cfg.chemo_radius * mm_to_px), 30), (6, 80), (3, 180)]: + overlay = frame.copy() + cv2.circle(overlay, (cx, cy), r, (0, 180, 60), -1) + cv2.addWeighted(overlay, alpha / 255.0, frame, 1 - alpha / 255.0, 0, frame) + cv2.circle(frame, (cx, cy), 3, (0, 200, 80), -1) + + # --- Source lumineuse (phototactisme) --- + if cfg.photo_mode != "none" and cfg.photo_strength > 0.0: + px_src, py_src = int(photo_source[0]), int(photo_source[1]) + if cfg.photo_mode == "radial": + # Gradient radial depuis le centre : cercle central jaune + r_zone = int(arena_radius_px * cfg.photo_radius) + overlay = frame.copy() + cv2.circle(overlay, arena_center, r_zone, (0, 220, 255), -1) + cv2.addWeighted(overlay, 0.12, frame, 0.88, 0, frame) + cv2.circle(frame, arena_center, 5, (0, 200, 255), -1) + else: + # Source ponctuelle : halo jaune + for r, alpha in [(30, 20), (12, 50), (5, 140)]: + overlay = frame.copy() + cv2.circle(overlay, (px_src, py_src), r, (0, 220, 255), -1) + cv2.addWeighted(overlay, alpha / 255.0, frame, 1 - alpha / 255.0, 0, frame) + cv2.circle(frame, (px_src, py_src), 4, (0, 200, 255), -1) + + +def compute_photo_source(cfg, frame_idx, arena_center, arena_radius_px): + """ + Calcule la position de la source lumineuse pour la frame courante. + + Args: + cfg : namespace argparse + frame_idx : index de la frame courante + arena_center : tuple (cx, cy) en pixels + arena_radius_px : rayon de l'arène en pixels + + Returns: + tuple (x, y) en pixels + """ + if cfg.photo_mode == "fixed": + x = arena_center[0] + (cfg.photo_x - 0.5) * 2 * arena_radius_px + y = arena_center[1] + (cfg.photo_y - 0.5) * 2 * arena_radius_px + return (x, y) + + elif cfg.photo_mode == "sine": + t = frame_idx * cfg.photo_sine_freq * 2 * math.pi / cfg.fps + x = arena_center[0] + math.cos(t) * arena_radius_px * 0.6 + y = arena_center[1] + math.sin(t * 0.7) * arena_radius_px * 0.6 + return (x, y) + + else: + # Radial ou none : source au centre (utilisé pour le dessin uniquement) + return (float(arena_center[0]), float(arena_center[1])) + + +# --------------------------------------------------------------------------- +# Utilitaires +# --------------------------------------------------------------------------- + +def spawn_positions(count, arena_center, arena_radius_px, min_dist): + """ + Génère `count` positions bien séparées à l'intérieur de l'arène. + + Args: + count : nombre de positions à générer + arena_center : tuple (cx, cy) + arena_radius_px : rayon en pixels + min_dist : distance minimale entre deux planaires en pixels + + Returns: + liste de tuples (x, y) + """ + positions = [] + for _ in range(count): + placed = False + for _ in range(1000): + a = random.uniform(0, 2 * math.pi) + r = random.uniform(0, arena_radius_px * 0.6) + x = arena_center[0] + r * math.cos(a) + y = arena_center[1] + r * math.sin(a) + if all(math.sqrt((x - px)**2 + (y - py)**2) >= min_dist for px, py in positions): + positions.append((x, y)) + placed = True + break + if not placed: + positions.append((float(arena_center[0]), float(arena_center[1]))) + return positions + + +# --------------------------------------------------------------------------- +# Point d'entrée principal +# --------------------------------------------------------------------------- + +def main(): + args = parse_args() + + random.seed(args.seed) + 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 + TOTAL_FRAMES = args.fps * args.duration + MM_TO_PX = (args.default_width - 80) / args.default_diameter # ~26.25 px/mm + + ARENA_RADIUS_PX = int(8 * MM_TO_PX) + ARENA_CENTER = (WIDTH // 2, HEIGHT // 2) + + args.planaire_length_px = int(args.length * MM_TO_PX) + args.planaire_width_px = max(4, int(args.width * MM_TO_PX)) + + # --- Positions initiales espacées --- + min_distance = args.planaire_length_px * 1.5 + positions = spawn_positions(args.count, ARENA_CENTER, ARENA_RADIUS_PX, min_distance) + + # --- Instanciation planaires + trackers --- + planaires = [] + trackers = [] + for i, pos in enumerate(positions): + p = Planaire(i, args, ARENA_CENTER, ARENA_RADIUS_PX, MM_TO_PX, + start_x=pos[0], start_y=pos[1]) + t = Tracker(i, MM_TO_PX, args.fps, args.thresh_immobile, args.thresh_mobile, + ARENA_CENTER, ARENA_RADIUS_PX) + planaires.append(p) + trackers.append(t) + + # --- Carte chimique partagée --- + chem_map = ChemicalMap(WIDTH, HEIGHT, args.chem_decay) if args.chem_repulsion > 0.0 else None + + # --- Métriques comportementales (une instance par planaire) --- + behaviour = { + "thigmotaxis_wall_dist_mm": 1.0, + "photo_mode": args.photo_mode, + "photo_strength": args.photo_strength, + "photo_x": args.photo_x, + "photo_y": args.photo_y, + "photo_flee_angle_deg": 90.0, + "chemo_strength": args.chemo_strength, + "chemo_x": args.chemo_x, + "chemo_y": args.chemo_y, + "chemo_radius_mm": args.chemo_radius, + "chemo_approach_angle_deg": 90.0, + "avoid_radius_mm": args.avoid_radius, + "aggreg_radius_mm": args.aggreg_radius, + } + metrics_list = [] + if HAS_METRICS: + for _ in planaires: + metrics_list.append(EthoVisionMetrics( + px_per_mm = MM_TO_PX, + fps = args.fps, + thresh_immobile = args.thresh_immobile, + thresh_mobile = args.thresh_mobile, + behaviour = behaviour, + )) + + # --- Arène de base --- + arena_base = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8) + draw_arena(arena_base, args, WIDTH, HEIGHT, ARENA_CENTER, ARENA_RADIUS_PX, MM_TO_PX) + + # --- Encodeur vidéo --- + output_path = args.output + if not output_path.endswith(".mp4"): + output_path += ".mp4" + fourcc = cv2.VideoWriter_fourcc(*'mp4v') + out = cv2.VideoWriter(output_path, fourcc, args.fps, (WIDTH, HEIGHT)) + + print(f"Simulation : {args.count} planaire(s), {TOTAL_FRAMES} frames ({args.duration}s à {args.fps} fps)") + print(f"Morphologie : {args.length}mm × {args.width}mm") + print(f"Thigmotactisme : {args.thigmotaxis}") + print(f"Phototactisme : mode={args.photo_mode} force={args.photo_strength}") + print(f"Chimiotactisme : force={args.chemo_strength} pos=({args.chemo_x:.2f},{args.chemo_y:.2f})") + print(f"Évitement : force={args.avoid_strength} rayon={args.avoid_radius}mm") + print(f"Agrégation : force={args.aggreg_strength} rayon={args.aggreg_radius}mm") + print(f"Répulsion chim. : force={args.chem_repulsion} decay={args.chem_decay}") + print(f"Sortie vidéo : {output_path}") + + # --- Boucle de rendu --- + for frame_idx in range(TOTAL_FRAMES): + frame = arena_base.copy() + + # Calcul de la position de la source lumineuse pour cette frame + photo_source = compute_photo_source(args, frame_idx, ARENA_CENTER, ARENA_RADIUS_PX) + + # Positions courantes de tous les planaires (pour interactions inter-individus) + all_positions = [(p.x, p.y) for p in planaires] + + # Mise à jour cinématique + tracking + for i, (p, t) in enumerate(zip(planaires, trackers)): + others = [pos for j, pos in enumerate(all_positions) if j != i] + p.update(frame_idx, others, chem_map, photo_source) + t.update(frame_idx, p.x, p.y) + + # Métriques comportementales frame par frame + if HAS_METRICS and metrics_list: + for i, (p, m) in enumerate(zip(planaires, metrics_list)): + others_mm = [ + ((q.x - ARENA_CENTER[0]) / MM_TO_PX, + (q.y - ARENA_CENTER[1]) / MM_TO_PX) + for j, q in enumerate(planaires) if j != i + ] + chem_level = 0.0 + if chem_map is not None: + _, chem_level = chem_map.gradient_at(p.x, p.y) + raw_sim = { + 'detected': True, + 'timestamp': frame_idx / args.fps, + 'cx': int(p.x), + 'cy': int(p.y), + 'speed_px_s': p.speed * args.fps, + 'area_px': p.length_px * p.width_px, + 'axial_pos': (p.y - ARENA_CENTER[1]) / max(ARENA_RADIUS_PX, 1), + 'axial_speed': 0.0, + } + m.update( + raw_sim, + well_radius_mm = 8.0, + arena_center_px = ARENA_CENTER, + photo_source_px = (int(photo_source[0]), int(photo_source[1])) + if args.photo_mode != 'none' else None, + others_pos_mm = others_mm, + chem_level = float(chem_level), + ) + + # Décroissance de la carte chimique + if chem_map is not None: + chem_map.step() + + # Dessin des stimuli puis des planaires + draw_stimuli(frame, args, ARENA_CENTER, ARENA_RADIUS_PX, MM_TO_PX, + photo_source, chem_map) + for p in planaires: + p.draw(frame) + + # Timecode et compteur + t_sec = frame_idx / args.fps + cv2.putText(frame, f"{t_sec:.1f}s", (8, 16), + cv2.FONT_HERSHEY_SIMPLEX, 0.35, (140, 138, 132), 1, cv2.LINE_AA) + cv2.putText(frame, f"n={args.count}", (8, HEIGHT - 8), + cv2.FONT_HERSHEY_SIMPLEX, 0.3, (140, 138, 132), 1, cv2.LINE_AA) + + out.write(frame) + if frame_idx % args.fps == 0: + print(f" {t_sec:.0f}s / {args.duration}s") + + out.release() + print(f"Terminé → {output_path}") + + # --- Export CSV --- + if not args.no_csv: + output_stem = os.path.splitext(os.path.basename(output_path))[0] + print(f"Export CSV → {args.csv_dir}/") + for t in trackers: + t.write_csv(args.csv_dir, output_stem) + s = t.summary() + print(f" [{t.planaire_id:02d}] dist={s['movedCenter_pointTotal_mm']:.2f}mm " + f"v={s['velocity_mean_mm_s']:.2f}mm/s " + f"imm={s['mobility_immobile_duration_s']:.1f}s " + f"mob={s['mobility_mobile_duration_s']:.1f}s " + f"hmob={s['mobility_highly_mobile_duration_s']:.1f}s " + f"paroi={s['thigmotaxis_pct_time_near_wall']:.1f}%") + + # --- Export CSV métriques comportementales --- + if HAS_METRICS and metrics_list and not args.no_csv: + output_stem = os.path.splitext(os.path.basename(output_path))[0] + os.makedirs(args.csv_dir, exist_ok=True) + print(f"Export CSV comportemental → {args.csv_dir}/") + for i, m in enumerate(metrics_list): + s = m.summary() + path = os.path.join( + args.csv_dir, f"{output_stem}_planaire_{i:02d}_behaviour_summary.csv" + ) + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=list(s.keys())) + writer.writeheader() + writer.writerow(s) + print(f" [{i:02d}] photo={s['photo_pct_time_fleeing']:.1f}% " + f"chemo_zone={s['chemo_pct_time_in_zone']:.1f}% " + f"evit={s['social_pct_time_avoiding']:.1f}% " + f"contacts={s['social_contact_events']} → {path}") + + +if __name__ == "__main__": + main() diff --git a/test_tube_scanner/scanner/process.py b/test_tube_scanner/scanner/process.py index 3a68d96..8079371 100644 --- a/test_tube_scanner/scanner/process.py +++ b/test_tube_scanner/scanner/process.py @@ -468,6 +468,10 @@ class ScannerProcess(Task): msg = self.manager.set_well_position() self._send(**msg) + elif topic == 'track': + self.cam.use_tracking = value=="1" + self._send(state=topic, msg=f"Tracking: {self.cam.use_tracking}") + elif topic in ['min_area_px', 'max_area_ratio', 'max_planarians', 'merge_kernel_size', 'min_contour_dist_px']: value = int(value) if topic in ['min_area_px', 'max_planarians', 'merge_kernel_size', 'min_contour_dist_px'] else float(value) self.manager.tracker_config[topic] = value diff --git a/test_tube_scanner/scanner/static/scanner/js/calibration.js b/test_tube_scanner/scanner/static/scanner/js/calibration.js index b893042..8f1e59b 100644 --- a/test_tube_scanner/scanner/static/scanner/js/calibration.js +++ b/test_tube_scanner/scanner/static/scanner/js/calibration.js @@ -48,6 +48,7 @@ class ScannerManager { this.max_planarians = options.max_planarians; this.merge_kernel_size = options.merge_kernel_size; this.min_contour_dist_px = options.min_contour_dist_px; + this.track = options.track; } @@ -84,7 +85,7 @@ class ScannerManager { this.max_planarians.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_planarians", value: e.target.value}); }); this.merge_kernel_size.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "merge_kernel_size", value: e.target.value}); }); this.min_contour_dist_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_contour_dist_px", value: e.target.value }); }); - + this.track.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "track", value: e.target.value }); }); } registerSocket(socket) { diff --git a/test_tube_scanner/scanner/templates/scanner/calibration.html b/test_tube_scanner/scanner/templates/scanner/calibration.html index 5835f16..c7ffe0c 100644 --- a/test_tube_scanner/scanner/templates/scanner/calibration.html +++ b/test_tube_scanner/scanner/templates/scanner/calibration.html @@ -150,7 +150,9 @@ {% if conf.tracking %}