#!../.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()