This commit is contained in:
2026-05-16 12:20:25 +02:00
parent da44ab5340
commit cb10957fa6
10 changed files with 1759 additions and 438 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -21,6 +21,7 @@ import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional, Callable, TYPE_CHECKING from typing import Optional, Callable, TYPE_CHECKING
from asgiref.sync import async_to_sync
from django.conf import settings from django.conf import settings
from modules.planarian_tracker import PlanarianTracker from modules.planarian_tracker import PlanarianTracker
@@ -85,6 +86,7 @@ class VideoCaptureInterface(abc.ABC):
self._tracker = None self._tracker = None
self._metrics = None self._metrics = None
self._params = None self._params = None
self._clientDB = self.parent.metricDB
# Tracker générique, pour simulation # Tracker générique, pour simulation
self.on_test_well_change(**self.DEFAULT_TRACKER_CONFIG) self.on_test_well_change(**self.DEFAULT_TRACKER_CONFIG)
@@ -105,8 +107,9 @@ class VideoCaptureInterface(abc.ABC):
except Exception as e: except Exception as e:
logger.error(f"Error creating tracker with conf {cfg}: {e}") logger.error(f"Error creating tracker with conf {cfg}: {e}")
self._tracker = None self._tracker = None
def on_well_change(self, cfg, draw_contours=False): def on_well_change(self, cfg, uuid="", draw_contours=False):
""" """
Appelé par la CNC lors du changement de puits. Appelé par la CNC lors du changement de puits.
Réinitialise le fond appris et l'état inter-frame du tracker. Réinitialise le fond appris et l'état inter-frame du tracker.
@@ -115,10 +118,20 @@ class VideoCaptureInterface(abc.ABC):
if not self.use_tracking or not cfg: if not self.use_tracking or not cfg:
return return
params = cfg.to_params_dict() # 1. Sauvegarder les résumés du puits qu'on quitte
self._params = ExperimentParams(params) if self._metrics and self._params:
#self._metrics = self._params.build_metrics() for pid, m in enumerate(self._metrics):
async_to_sync(self._clientDB.store_summary)(
summary = m.summary(),
experiment = self._params.experiment,
well = self._params.well,
planarian = pid,
uuid = uuid,
)
# 2. Reconstruire pour le nouveau puits
params = cfg.to_params_dict()
self._params = ExperimentParams(params)
self._metrics = [self._params.build_metrics() for _ in range(self._params.planarian_count)] self._metrics = [self._params.build_metrics() for _ in range(self._params.planarian_count)]
self._tracker = PlanarianTracker( self._tracker = PlanarianTracker(
+100 -47
View File
@@ -18,23 +18,11 @@ Métriques résumé (summary) :
chemo_latency_s, chemo_mean_dist_mm chemo_latency_s, chemo_mean_dist_mm
Social : social_pct_time_avoiding, social_pct_time_aggregating, Social : social_pct_time_avoiding, social_pct_time_aggregating,
social_mean_nn_mm, social_contact_events social_mean_nn_mm, social_contact_events
Architecture :
PlanarianTracker.process() → dict brut (cx, cy, speed_px_s, ...)
EthoVisionMetrics.update() → enrichit avec métriques EthoVision
ReductStoreClient.store() → stocke dans ReductStore avec labels
ReductStoreClient.export_csv() → exporte vers CSV
Schéma des labels ReductStore :
experiment : identifiant de l'expérience (ex: "exp_2026_04_25")
well : identifiant du puits (ex: "A1", "B3")
planarian : index du planaire dans le puits (ex: "0", "1")
bucket : nom du bucket (ex: "planarian_metrics")
Created on 25 avr. 2026 Created on 25 avr. 2026
@author: denis @author: denis
""" """
#import asyncio
import csv import csv
import io import io
import json import json
@@ -42,8 +30,8 @@ import logging
import math import math
import os import os
import time import time
from datetime import datetime, timezone
from datetime import datetime, timezone
from typing import Optional from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -618,7 +606,7 @@ class ExperimentParams:
def from_csv_file(cls, filepath: str) -> list: def from_csv_file(cls, filepath: str) -> list:
"""Charge toutes les expériences d'un fichier CSV.""" """Charge toutes les expériences d'un fichier CSV."""
results = [] results = []
with open(filepath, newline="", encoding="utf-8-sig") as f: with open(filepath, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f): for row in csv.DictReader(f):
try: try:
results.append(cls.from_csv_row(row)) results.append(cls.from_csv_row(row))
@@ -666,12 +654,11 @@ class ReductStoreClient:
self.token = token self.token = token
self.bucket_name = bucket self.bucket_name = bucket
self.quota_type = quota_type self.quota_type = quota_type
self.quota_size = quota_size self.quota_size = quota_size
self.entry_name = "metrics" self._client = None
self._client = None self._bucket = None
self._bucket = None
async def _create_bucket(self): async def _create_bucket(self):
from reduct import Client, BucketSettings from reduct import Client, BucketSettings
self._client = Client(self.url, api_token=self.token) self._client = Client(self.url, api_token=self.token)
@@ -681,13 +668,25 @@ class ReductStoreClient:
exist_ok=True, exist_ok=True,
) )
return await self._client.create_bucket(self.bucket_name, settings, exist_ok=True) return await self._client.create_bucket(self.bucket_name, settings, exist_ok=True)
async def connect(self): async def connect(self):
"""Initialise la connexion et crée le bucket si nécessaire.""" """Initialise la connexion et crée le bucket si nécessaire."""
self._bucket = await self._create_bucket() self._bucket = await self._create_bucket()
logger.info(f"ReductStore connecté : {self.url} / {self.bucket_name}") logger.info(f"ReductStore connecté : {self.url} / {self.bucket_name}")
'''
async def connect(self):
"""Initialise la connexion et crée le bucket si nécessaire."""
from reduct import Client, BucketSettings, QuotaType
self._client = Client(self.url, api_token=self.token)
self._bucket = await self._client.create_bucket(
self.bucket_name,
BucketSettings(quota_type=QuotaType.NONE),
exist_ok=True,
)
logger.info(f"ReductStore connecté : {self.url} / {self.bucket_name}")
'''
async def store_metric( async def store_metric(
self, self,
record: dict, record: dict,
@@ -704,48 +703,99 @@ class ReductStoreClient:
Le timestamp est rendu unique par planaire en ajoutant l'index Le timestamp est rendu unique par planaire en ajoutant l'index
du planaire comme offset sub-microseconde — évite le 409 Conflict du planaire comme offset sub-microseconde — évite le 409 Conflict
quand plusieurs planaires du même puits écrivent dans la même frame. quand plusieurs planaires du même puits écrivent dans la même frame.
Args:
record : dict de métriques (issu de EthoVisionMetrics.update())
experiment : identifiant de l'expérience
well : identifiant du puits
planarian : index du planaire (défaut 0)
record_type : "frame" ou "summary"
uuid : identifiant unique de session (permet de filtrer
plusieurs sessions d'un même puits/expérience)
ts_us : timestamp en microsecondes (défaut : maintenant)
""" """
if self._bucket is None: if self._bucket is None:
await self.connect() await self.connect()
# ts_us de base + offset planaire (0, 1, 2…) pour unicité garantie # ts_us de base + offset planaire (0, 1, 2…) pour unicité garantie
base_ts = ts_us or int(time.time() * 1_000_000) base_ts = ts_us or int(time.time() * 1_000_000)
unique_ts = base_ts + planarian unique_ts = base_ts + planarian
labels = {
"experiment": experiment,
"well": well,
"planarian": str(planarian),
"record_type": record_type,
}
if uuid:
labels["uuid"] = uuid
await self._bucket.write( await self._bucket.write(
entry_name = "metrics", entry_name = "metrics",
data = json.dumps(record).encode("utf-8"), data = json.dumps(record).encode("utf-8"),
timestamp = unique_ts, timestamp = unique_ts,
labels = { labels = labels,
"experiment": experiment,
"well": well,
"planarian": str(planarian),
"record_type": record_type,
"uuid": uuid,
},
content_type = "application/json", content_type = "application/json",
) )
async def store_summary(self, summary: dict, experiment: str, async def store_summary(
well: str, planarian: int = 0): self,
"""Stocke le résumé de fin de session.""" summary: dict,
await self.store_metric(summary, experiment, well, planarian, "summary") experiment: str,
well: str,
planarian: int = 0,
uuid: str = "",
):
"""
Stocke le résumé de fin de session dans ReductStore.
Args:
summary : dict issu de EthoVisionMetrics.summary()
experiment : identifiant de l'expérience
well : identifiant du puits
planarian : index du planaire
uuid : identifiant unique de session (même valeur que
celle utilisée dans store_metric pour cette session)
"""
await self.store_metric(
record = summary,
experiment = experiment,
well = well,
planarian = planarian,
record_type = "summary",
uuid = uuid,
)
async def get_tracking_data( async def get_tracking_data(
self, self,
experiment: str, experiment: str,
well: str, well: str,
planarian: int = 0, planarian: int = 0,
record_type: str = "metrics", record_type: str = "frame",
uuid: str = "",
start: Optional[datetime] = None, start: Optional[datetime] = None,
stop: Optional[datetime] = None, stop: Optional[datetime] = None,
) -> list: ) -> list:
"""Récupère les enregistrements filtrés par labels.""" """
Récupère les enregistrements filtrés par labels.
Args:
experiment : identifiant de l'expérience
well : identifiant du puits
planarian : index du planaire
record_type : "frame" | "summary"
uuid : filtre sur une session spécifique (optionnel —
si vide, retourne toutes les sessions)
start, stop : plage temporelle (datetime UTC, optionnel)
"""
if self._bucket is None: if self._bucket is None:
await self.connect() await self.connect()
kwargs = {"include": { labels = {
"experiment": experiment, "well": well, "experiment": experiment,
"planarian": str(planarian), "record_type": record_type, "well": well,
}} "planarian": str(planarian),
"record_type": record_type,
}
if uuid:
labels["uuid"] = uuid
kwargs = {"include": labels}
if start: if start:
kwargs["start"] = int(start.timestamp() * 1_000_000) kwargs["start"] = int(start.timestamp() * 1_000_000)
if stop: if stop:
@@ -812,7 +862,8 @@ class ReductStoreClient:
experiment: str, experiment: str,
well: str, well: str,
planarian: int = 0, planarian: int = 0,
record_type: str = "metrics", record_type: str = "frame",
uuid: str = "",
output_dir: str = ".", output_dir: str = ".",
start: Optional[datetime] = None, start: Optional[datetime] = None,
stop: Optional[datetime] = None, stop: Optional[datetime] = None,
@@ -828,13 +879,15 @@ class ReductStoreClient:
planarian : index du planaire planarian : index du planaire
record_type : "frame" | "summary" record_type : "frame" | "summary"
output_dir : répertoire de sortie (défaut : répertoire courant) output_dir : répertoire de sortie (défaut : répertoire courant)
uuid : filtre sur une session spécifique (optionnel —
si vide, retourne toutes les sessions)
start, stop : plage temporelle (datetime UTC, optionnel) start, stop : plage temporelle (datetime UTC, optionnel)
Returns: Returns:
tuple (filepath, nb_lignes) tuple (filepath, nb_lignes)
""" """
records = await self.get_tracking_data( records = await self.get_tracking_data(
experiment, well, planarian, record_type, start, stop) experiment, well, planarian, record_type, uuid, start, stop)
if not records: if not records:
logger.warning(f"Aucune donnée pour {experiment}/{well}/{planarian}") logger.warning(f"Aucune donnée pour {experiment}/{well}/{planarian}")
return "", 0 return "", 0
@@ -857,7 +910,8 @@ class ReductStoreClient:
experiment: str, experiment: str,
well: str, well: str,
planarian: int = 0, planarian: int = 0,
record_type: str = "metrics", record_type: str = "frame",
uuid: str = "",
start: Optional[datetime] = None, start: Optional[datetime] = None,
stop: Optional[datetime] = None, stop: Optional[datetime] = None,
) -> tuple: ) -> tuple:
@@ -869,7 +923,7 @@ class ReductStoreClient:
tuple (contenu_csv_str, nb_lignes) tuple (contenu_csv_str, nb_lignes)
""" """
records = await self.get_tracking_data( records = await self.get_tracking_data(
experiment, well, planarian, record_type, start, stop) experiment, well, planarian, record_type, uuid, start, stop)
if not records: if not records:
return "", 0 return "", 0
records = self._convert_timestamps(records) records = self._convert_timestamps(records)
@@ -888,5 +942,4 @@ class ReductStoreClient:
""" """
self._client = None self._client = None
self._bucket = None self._bucket = None
logger.info("ReductStore déconnecté") logger.info("ReductStore déconnecté")
@@ -0,0 +1,891 @@
"""
modules/planarian_metrics.py
Intégration des métriques EthoVision XT + comportementales dans PlanarianScanner.
Métriques par frame :
Mobilité : velocity, distance, moving, mobility_state
Thigmo : dist_to_wall_mm, near_wall
Photo : dist_to_light_mm, heading_to_light_deg, fleeing_light
Chemo : dist_to_food_mm, heading_to_food_deg, approaching_food, in_food_zone
Social : nearest_neighbour_mm, in_avoid_zone, in_aggreg_zone, chem_repulsion_level
Métriques résumé (summary) :
Mobilité : movedCenter_pointTotal_mm, velocity_mean_mm_s, durations par état
Thigmo : thigmotaxis_pct_time_near_wall
Photo : photo_pct_time_fleeing, photo_mean_dist_mm, photo_latency_s
Chemo : chemo_pct_time_approaching, chemo_pct_time_in_zone,
chemo_latency_s, chemo_mean_dist_mm
Social : social_pct_time_avoiding, social_pct_time_aggregating,
social_mean_nn_mm, social_contact_events
Architecture :
PlanarianTracker.process() → dict brut (cx, cy, speed_px_s, ...)
EthoVisionMetrics.update() → enrichit avec métriques EthoVision
ReductStoreClient.store() → stocke dans ReductStore avec labels
ReductStoreClient.export_csv() → exporte vers CSV
Schéma des labels ReductStore :
experiment : identifiant de l'expérience (ex: "exp_2026_04_25")
well : identifiant du puits (ex: "A1", "B3")
planarian : index du planaire dans le puits (ex: "0", "1")
bucket : nom du bucket (ex: "planarian_metrics")
Created on 25 avr. 2026
@author: denis
"""
#import asyncio
import csv
import io
import json
import logging
import math
import os
import time
from datetime import datetime, timezone
from typing import Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constantes EthoVision (seuils de mobilité par défaut)
# ---------------------------------------------------------------------------
THRESH_IMMOBILE_DEFAULT = 0.2 # en-dessous : Immobile (mm/s)
THRESH_MOBILE_DEFAULT = 1.5 # entre les deux : Mobile, au-delà : Highly mobile
STATE_IMMOBILE = "Immobile"
STATE_MOBILE = "Mobile"
STATE_HIGH_MOBILE = "Highly mobile"
# Paramètres comportementaux (défauts)
BEHAVIOUR_DEFAULTS = {
# Thigmotactisme
"thigmotaxis_wall_dist_mm": 1.0,
# Phototactisme
"photo_mode": "none",
"photo_strength": 0.0,
"photo_x": 0.5,
"photo_y": 0.5,
"photo_flee_angle_deg": 90.0, # angle max tête/source pour considérer "fuite"
# Chimiotactisme
"chemo_strength": 0.0,
"chemo_x": 0.5,
"chemo_y": 0.5,
"chemo_radius_mm": 2.0,
"chemo_approach_angle_deg": 90.0, # angle max tête/nourriture pour considérer "approche"
# Interactions inter-individus
"avoid_radius_mm": 3.0,
"aggreg_radius_mm": 6.0,
}
# ---------------------------------------------------------------------------
# Helpers géométriques
# ---------------------------------------------------------------------------
def _angle_between_deg(vx1: float, vy1: float, vx2: float, vy2: float) -> float:
"""
Calcule l'angle en degrés entre deux vecteurs 2D.
Retourne 0.0 si l'un des vecteurs est nul.
Args:
vx1, vy1 : premier vecteur
vx2, vy2 : second vecteur
Returns:
angle en degrés [0, 180]
"""
n1 = math.sqrt(vx1**2 + vy1**2)
n2 = math.sqrt(vx2**2 + vy2**2)
if n1 < 1e-9 or n2 < 1e-9:
return 0.0
cos_a = max(-1.0, min(1.0, (vx1 * vx2 + vy1 * vy2) / (n1 * n2)))
return math.degrees(math.acos(cos_a))
def _heading_to_target_deg(
cx: float, cy: float,
tx: float, ty: float,
dx: float, dy: float,
) -> float:
"""
Calcule l'angle entre la direction de déplacement et le vecteur vers une cible.
Args:
cx, cy : position courante
tx, ty : position cible
dx, dy : vecteur de déplacement (cx - prev_cx, cy - prev_cy)
Returns:
angle en degrés [0, 180] — 0 = va droit vers la cible, 180 = fuit
"""
to_target_x = tx - cx
to_target_y = ty - cy
return _angle_between_deg(dx, dy, to_target_x, to_target_y)
# ---------------------------------------------------------------------------
# Classe EthoVisionMetrics
# ---------------------------------------------------------------------------
class EthoVisionMetrics:
"""
Calcule et accumule toutes les métriques comportementales compatibles
EthoVision XT à partir des données brutes de PlanarianTracker.
Métriques calculées :
- Mobilité EthoVision (distance, vitesse, états Immobile/Mobile/Très mobile)
- Thigmotactisme (distance paroi, % temps près du bord)
- Phototactisme (distance source, orientation, % fuite, latence)
- Chimiotactisme (distance nourriture, % approche, % zone, latence)
- Interactions inter-individus (voisin le plus proche, évitement,
agrégation, répulsion chimique, événements de contact)
Une instance par planaire suivi.
Usage :
metrics = EthoVisionMetrics(px_per_mm=26.25, fps=10, behaviour={...})
for frame in capture:
raw = tracker.process(frame, ts)
record = metrics.update(
raw,
well_radius_mm = 8.0,
arena_center_px = (250, 250),
photo_source_px = (100, 100),
others_pos_mm = [(x1,y1), (x2,y2)],
chem_level = 0.3,
)
await client.store_metric(record, ...)
summary = metrics.summary()
"""
def __init__(
self,
px_per_mm: float,
fps: float,
thresh_immobile: float = THRESH_IMMOBILE_DEFAULT,
thresh_mobile: float = THRESH_MOBILE_DEFAULT,
behaviour: Optional[dict] = None,
):
"""
Args:
px_per_mm : facteur de conversion pixels → mm
fps : fréquence de capture (images/s)
thresh_immobile : seuil vitesse Immobile/Mobile (mm/s)
thresh_mobile : seuil vitesse Mobile/Très mobile (mm/s)
behaviour : dict de paramètres comportementaux (cf. BEHAVIOUR_DEFAULTS)
"""
self.px_per_mm = px_per_mm
self.fps = fps
self.dt = 1.0 / fps
self.thresh_immobile = thresh_immobile
self.thresh_mobile = thresh_mobile
self.beh = {**BEHAVIOUR_DEFAULTS, **(behaviour or {})}
# --- Accumulateurs mobilité ---
self.total_distance_mm = 0.0
self.duration_moving_s = 0.0
self.duration_stopped_s = 0.0
self.frame_count = 0
self._mob_counts = {STATE_IMMOBILE: 0, STATE_MOBILE: 0, STATE_HIGH_MOBILE: 0}
self._mob_durations = {STATE_IMMOBILE: 0.0, STATE_MOBILE: 0.0, STATE_HIGH_MOBILE: 0.0}
self._current_state = None
# --- Accumulateurs thigmotactisme ---
self._near_wall_frames = 0
# --- Accumulateurs phototactisme ---
self._flee_frames = 0 # frames en fuite
self._photo_dist_sum = 0.0 # somme distances source
self._photo_dist_count = 0
self._photo_latency_s = None # temps avant 1ère fuite (s)
# --- Accumulateurs chimiotactisme ---
self._approach_frames = 0 # frames en approche nourriture
self._in_zone_frames = 0 # frames dans la zone nourriture
self._chemo_dist_sum = 0.0
self._chemo_dist_count = 0
self._chemo_latency_s = None # temps avant 1ère entrée zone (s)
# --- Accumulateurs interactions inter-individus ---
self._avoid_frames = 0 # frames en zone d'évitement
self._aggreg_frames = 0 # frames en zone d'agrégation
self._nn_sum = 0.0 # somme distances voisin le plus proche
self._nn_count = 0
self._contact_events = 0 # transitions False→True de in_avoid_zone
self._prev_in_avoid = False
# --- Position précédente (vecteur de déplacement) ---
self._prev_cx_mm = None
self._prev_cy_mm = None
self._prev_ts = None
# ------------------------------------------------------------------ #
# Helpers internes
# ------------------------------------------------------------------ #
def _px_to_mm(self, px: float) -> float:
"""Convertit des pixels en millimètres."""
return px / self.px_per_mm
def _classify(self, v: float) -> str:
"""Classifie la vitesse en état de mobilité EthoVision."""
if v <= self.thresh_immobile:
return STATE_IMMOBILE
elif v <= self.thresh_mobile:
return STATE_MOBILE
return STATE_HIGH_MOBILE
def _elapsed_s(self) -> float:
"""Temps écoulé depuis le début de la session (s)."""
return self.frame_count * self.dt
# ------------------------------------------------------------------ #
# Méthode principale
# ------------------------------------------------------------------ #
def update(
self,
raw: dict,
well_radius_mm: float = 8.0,
arena_center_px: tuple = (250, 250),
photo_source_px: Optional[tuple] = None,
others_pos_mm: Optional[list] = None,
chem_level: float = 0.0,
) -> dict:
"""
Calcule toutes les métriques comportementales pour une frame.
Args:
raw : dict brut de PlanarianTracker.process()
clés : detected, cx, cy, speed_px_s, ts
well_radius_mm : rayon du puits en mm
arena_center_px : centre de l'arène en pixels (cx, cy)
photo_source_px : position de la source lumineuse en pixels (ou None)
others_pos_mm : liste de (x_mm, y_mm) des autres planaires
chem_level : concentration chimique locale [0-1] (depuis ChemicalMap)
Returns:
dict complet prêt pour ReductStore
"""
self.frame_count += 1
ts = raw.get("timestamp", time.time())
if not raw.get("detected", False):
self.duration_stopped_s += self.dt
state = self._current_state or STATE_IMMOBILE
self._mob_durations[state] += self.dt
return {"timestamp": ts, "detected": False}
# --- Position en mm (relative au centre de l'arène) ---
cx_px = raw["cx"] - arena_center_px[0]
cy_px = raw["cy"] - arena_center_px[1]
cx_mm = self._px_to_mm(cx_px)
cy_mm = self._px_to_mm(cy_px)
# --- Vitesse / distance ---
speed_px_s = raw.get("speed_px_s", 0.0)
velocity_mm_s = self._px_to_mm(speed_px_s)
dist_mm = velocity_mm_s * self.dt
self.total_distance_mm += dist_mm
# Vecteur de déplacement (pour calculs d'angle)
if self._prev_cx_mm is not None:
move_dx = cx_mm - self._prev_cx_mm
move_dy = cy_mm - self._prev_cy_mm
else:
move_dx, move_dy = 0.0, 0.0
# --- Mobilité ---
is_moving = velocity_mm_s > self.thresh_immobile
if is_moving:
self.duration_moving_s += self.dt
else:
self.duration_stopped_s += self.dt
new_state = self._classify(velocity_mm_s)
if new_state != self._current_state:
self._mob_counts[new_state] += 1
self._current_state = new_state
self._mob_durations[new_state] += self.dt
# ================================================================
# THIGMOTACTISME
# ================================================================
well_radius_px = well_radius_mm * self.px_per_mm
dist_center_px = math.sqrt(cx_px**2 + cy_px**2)
dist_wall_mm = self._px_to_mm(well_radius_px - dist_center_px)
near_wall_thr = self.beh.get("thigmotaxis_wall_dist_mm", 1.0)
is_near_wall = dist_wall_mm < near_wall_thr
if is_near_wall:
self._near_wall_frames += 1
# ================================================================
# PHOTOTACTISME
# ================================================================
photo_mode = self.beh.get("photo_mode", "none")
dist_light_mm = 0.0
heading_light_deg = 0.0
fleeing_light = False
if photo_mode != "none" and photo_source_px is not None:
lx_px = photo_source_px[0] - arena_center_px[0]
ly_px = photo_source_px[1] - arena_center_px[1]
lx_mm = self._px_to_mm(lx_px)
ly_mm = self._px_to_mm(ly_px)
dl = math.sqrt((cx_mm - lx_mm)**2 + (cy_mm - ly_mm)**2)
dist_light_mm = dl
self._photo_dist_sum += dl
self._photo_dist_count += 1
# Angle entre déplacement et direction vers la source
heading_light_deg = _heading_to_target_deg(
cx_mm, cy_mm, lx_mm, ly_mm, move_dx, move_dy
)
# Fuite = planaire s'éloigne de la source (angle > seuil)
flee_thr = self.beh.get("photo_flee_angle_deg", 90.0)
fleeing_light = (heading_light_deg > flee_thr) and is_moving
if fleeing_light:
self._flee_frames += 1
if self._photo_latency_s is None:
self._photo_latency_s = self._elapsed_s()
# ================================================================
# CHIMIOTACTISME
# ================================================================
chemo_x_frac = self.beh.get("chemo_x", 0.5)
chemo_y_frac = self.beh.get("chemo_y", 0.5)
chemo_r_mm = self.beh.get("chemo_radius_mm", 2.0)
chemo_strength= self.beh.get("chemo_strength", 0.0)
dist_food_mm = 0.0
heading_food_deg = 0.0
approaching_food = False
in_food_zone = False
if chemo_strength > 0.0:
# Position nourriture en mm relative au centre
fx_mm = (chemo_x_frac - 0.5) * 2.0 * well_radius_mm
fy_mm = (chemo_y_frac - 0.5) * 2.0 * well_radius_mm
df = math.sqrt((cx_mm - fx_mm)**2 + (cy_mm - fy_mm)**2)
dist_food_mm = df
self._chemo_dist_sum += df
self._chemo_dist_count += 1
in_food_zone = df <= chemo_r_mm
if in_food_zone:
self._in_zone_frames += 1
if self._chemo_latency_s is None:
self._chemo_latency_s = self._elapsed_s()
heading_food_deg = _heading_to_target_deg(
cx_mm, cy_mm, fx_mm, fy_mm, move_dx, move_dy
)
approach_thr = self.beh.get("chemo_approach_angle_deg", 90.0)
approaching_food = (heading_food_deg < approach_thr) and is_moving
if approaching_food:
self._approach_frames += 1
# ================================================================
# INTERACTIONS INTER-INDIVIDUS
# ================================================================
avoid_r_mm = self.beh.get("avoid_radius_mm", 3.0)
aggreg_r_mm = self.beh.get("aggreg_radius_mm", 6.0)
nearest_nn_mm = float("inf")
in_avoid_zone = False
in_aggreg_zone = False
if others_pos_mm:
for ox_mm, oy_mm in others_pos_mm:
d = math.sqrt((cx_mm - ox_mm)**2 + (cy_mm - oy_mm)**2)
if d < nearest_nn_mm:
nearest_nn_mm = d
if nearest_nn_mm < avoid_r_mm:
in_avoid_zone = True
self._avoid_frames += 1
elif nearest_nn_mm < aggreg_r_mm:
in_aggreg_zone = True
self._aggreg_frames += 1
self._nn_sum += nearest_nn_mm
self._nn_count += 1
# Événement de contact : transition vers zone d'évitement
if in_avoid_zone and not self._prev_in_avoid:
self._contact_events += 1
else:
nearest_nn_mm = 0.0
self._prev_in_avoid = in_avoid_zone
# --- Mise à jour position précédente ---
self._prev_cx_mm = cx_mm
self._prev_cy_mm = cy_mm
self._prev_ts = ts
# ================================================================
# RECORD COMPLET
# ================================================================
return {
# Identification
"timestamp": ts,
"detected": True,
# Position (mm, relative au centre)
"x_mm": round(cx_mm, 4),
"y_mm": round(cy_mm, 4),
# Position brute pixels
"cx_px": raw["cx"],
"cy_px": raw["cy"],
# Mobilité EthoVision
"velocity_mm_s": round(velocity_mm_s, 4),
"distance_mm": round(dist_mm, 4),
"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,
"mobility_immobile_freq": self._mob_counts[STATE_IMMOBILE],
"mobility_immobile_duration_s": round(self._mob_durations[STATE_IMMOBILE], 3),
"mobility_mobile_freq": self._mob_counts[STATE_MOBILE],
"mobility_mobile_duration_s": round(self._mob_durations[STATE_MOBILE], 3),
"mobility_high_mobile_freq": self._mob_counts[STATE_HIGH_MOBILE],
"mobility_high_mobile_duration_s": round(self._mob_durations[STATE_HIGH_MOBILE], 3),
# Thigmotactisme
"dist_to_wall_mm": round(dist_wall_mm, 4),
"near_wall": int(is_near_wall),
# Phototactisme
"dist_to_light_mm": round(dist_light_mm, 4),
"heading_to_light_deg": round(heading_light_deg, 2),
"fleeing_light": int(fleeing_light),
# Chimiotactisme
"dist_to_food_mm": round(dist_food_mm, 4),
"heading_to_food_deg": round(heading_food_deg, 2),
"approaching_food": int(approaching_food),
"in_food_zone": int(in_food_zone),
# Interactions inter-individus
"nearest_neighbour_mm": round(nearest_nn_mm, 4) if nearest_nn_mm != float("inf") else 0.0,
"in_avoid_zone": int(in_avoid_zone),
"in_aggreg_zone": int(in_aggreg_zone),
"chem_repulsion_level": round(chem_level, 4),
# Passthrough tracker
"area_px": raw.get("area_px", 0),
"axial_pos": raw.get("axial_pos", 0.0),
"axial_speed": raw.get("axial_speed", 0.0),
}
# ------------------------------------------------------------------ #
# Résumé de session
# ------------------------------------------------------------------ #
def summary(self) -> dict:
"""
Retourne le résumé global de la session.
Nomenclature EthoVision XT + métriques comportementales.
À appeler en fin d'expérience.
Returns:
dict avec toutes les métriques agrégées
"""
total_s = self.frame_count * self.dt
det = max(self._photo_dist_count, 1) # frames avec détection
return {
# Identification session
"total_frames": self.frame_count,
"total_duration_s": round(total_s, 3),
# --- Mobilité EthoVision ---
"movedCenter_pointTotal_mm": round(self.total_distance_mm, 4),
"velocity_mean_mm_s": round(
self.total_distance_mm / total_s if total_s > 0 else 0.0, 4),
"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._mob_counts[STATE_IMMOBILE],
"mobility_immobile_duration_s": round(self._mob_durations[STATE_IMMOBILE], 3),
"mobility_mobile_frequency": self._mob_counts[STATE_MOBILE],
"mobility_mobile_duration_s": round(self._mob_durations[STATE_MOBILE], 3),
"mobility_highly_mobile_frequency": self._mob_counts[STATE_HIGH_MOBILE],
"mobility_highly_mobile_duration_s": round(self._mob_durations[STATE_HIGH_MOBILE], 3),
# --- Thigmotactisme ---
"thigmotaxis_pct_time_near_wall": round(
100.0 * self._near_wall_frames / max(self.frame_count, 1), 2),
# --- Phototactisme ---
"photo_pct_time_fleeing": round(
100.0 * self._flee_frames / max(self.frame_count, 1), 2),
"photo_mean_dist_mm": round(
self._photo_dist_sum / max(self._photo_dist_count, 1), 4),
"photo_latency_s": round(self._photo_latency_s, 3)
if self._photo_latency_s is not None else None,
# --- Chimiotactisme ---
"chemo_pct_time_approaching": round(
100.0 * self._approach_frames / max(self.frame_count, 1), 2),
"chemo_pct_time_in_zone": round(
100.0 * self._in_zone_frames / max(self.frame_count, 1), 2),
"chemo_latency_s": round(self._chemo_latency_s, 3)
if self._chemo_latency_s is not None else None,
"chemo_mean_dist_mm": round(
self._chemo_dist_sum / max(self._chemo_dist_count, 1), 4),
# --- Interactions inter-individus ---
"social_pct_time_avoiding": round(
100.0 * self._avoid_frames / max(self.frame_count, 1), 2),
"social_pct_time_aggregating": round(
100.0 * self._aggreg_frames / max(self.frame_count, 1), 2),
"social_mean_nn_mm": round(
self._nn_sum / max(self._nn_count, 1), 4),
"social_contact_events": self._contact_events,
}
def reset(self):
"""Réinitialise tous les accumulateurs (changement de puits ou planaire)."""
self.__init__(
self.px_per_mm, self.fps,
self.thresh_immobile, self.thresh_mobile, self.beh,
)
@staticmethod
def _empty_record(ts: float) -> dict:
"""Enregistrement vide (planaire non détecté)."""
return {"timestamp": ts, "detected": False}
# ---------------------------------------------------------------------------
# Paramètres expérimentaux
# ---------------------------------------------------------------------------
class ExperimentParams:
"""
Conteneur des paramètres d'une expérience.
Instanciable depuis un dict, un fichier CSV ou un modèle Django.
"""
REQUIRED = {"experiment", "well", "px_per_mm", "fps"}
DEFAULTS = {
"well_radius_mm": 8.0,
"thresh_immobile": THRESH_IMMOBILE_DEFAULT,
"thresh_mobile": THRESH_MOBILE_DEFAULT,
"planarian_count": 1,
"tube_axis": "vertical",
"min_area_px": 20,
"max_area_ratio": 0.10,
**BEHAVIOUR_DEFAULTS,
}
def __init__(self, data: dict):
missing = self.REQUIRED - set(data.keys())
if missing:
raise ValueError(f"Paramètres manquants : {missing}")
merged = {**self.DEFAULTS, **data}
for k, v in merged.items():
setattr(self, k, self._cast(k, v))
@staticmethod
def _cast(key: str, value):
"""Cast automatique des valeurs CSV (toutes en string) vers le bon type."""
float_keys = {
"px_per_mm", "fps", "well_radius_mm", "thresh_immobile", "thresh_mobile",
"photo_strength", "photo_x", "photo_y", "photo_flee_angle_deg",
"chemo_strength", "chemo_x", "chemo_y", "chemo_radius_mm",
"chemo_approach_angle_deg", "thigmotaxis_wall_dist_mm",
"avoid_radius_mm", "aggreg_radius_mm", "max_area_ratio",
}
int_keys = {"planarian_count", "min_area_px"}
if key in float_keys:
return float(value)
if key in int_keys:
return int(value)
if isinstance(value, str) and value.lower() in ("true", "false"):
return value.lower() == "true"
return value
@classmethod
def from_csv_row(cls, row: dict) -> "ExperimentParams":
"""Instancie depuis une ligne de csv.DictReader."""
return cls(row)
@classmethod
def from_csv_file(cls, filepath: str) -> list:
"""Charge toutes les expériences d'un fichier CSV."""
results = []
with open(filepath, newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f):
try:
results.append(cls.from_csv_row(row))
except ValueError as e:
logger.warning(f"Ligne ignorée : {e}{row}")
return results
def to_dict(self) -> dict:
"""Sérialise en dict."""
return {k: getattr(self, k)
for k in {**self.DEFAULTS, **{r: None for r in self.REQUIRED}}}
def build_metrics(self) -> EthoVisionMetrics:
"""Construit l'instance EthoVisionMetrics pour ces paramètres."""
behaviour = {k: getattr(self, k) for k in BEHAVIOUR_DEFAULTS if hasattr(self, k)}
return EthoVisionMetrics(
px_per_mm = self.px_per_mm,
fps = self.fps,
thresh_immobile = self.thresh_immobile,
thresh_mobile = self.thresh_mobile,
behaviour = behaviour,
)
# ---------------------------------------------------------------------------
# Client ReductStore
# ---------------------------------------------------------------------------
class ReductStoreClient:
"""
Interface asynchrone avec ReductStore pour PlanarianScanner.
Labels : experiment | well | planarian | record_type (frame|summary)
"""
def __init__(
self,
url: str = "http://localhost:8383",
token: str = "",
bucket: str = "planarian_metrics",
quota_type=None,
quota_size=1000_000_000
):
self.url = url
self.token = token
self.bucket_name = bucket
self.quota_type = quota_type
self.quota_size = quota_size
self.entry_name = "metrics"
self._client = None
self._bucket = None
async def _create_bucket(self):
from reduct import Client, BucketSettings
self._client = Client(self.url, api_token=self.token)
settings = BucketSettings(
quota_type=self.quota_type,
quota_size=self.quota_size,
exist_ok=True,
)
return await self._client.create_bucket(self.bucket_name, settings, exist_ok=True)
async def connect(self):
"""Initialise la connexion et crée le bucket si nécessaire."""
self._bucket = await self._create_bucket()
logger.info(f"ReductStore connecté : {self.url} / {self.bucket_name}")
async def store_metric(
self,
record: dict,
experiment: str,
well: str,
planarian: int = 0,
record_type: str = "frame",
uuid: str = "",
ts_us: Optional[int] = None,
):
"""
Stocke un enregistrement dans ReductStore.
Le timestamp est rendu unique par planaire en ajoutant l'index
du planaire comme offset sub-microseconde — évite le 409 Conflict
quand plusieurs planaires du même puits écrivent dans la même frame.
"""
if self._bucket is None:
await self.connect()
# ts_us de base + offset planaire (0, 1, 2…) pour unicité garantie
base_ts = ts_us or int(time.time() * 1_000_000)
unique_ts = base_ts + planarian
await self._bucket.write(
entry_name = "metrics",
data = json.dumps(record).encode("utf-8"),
timestamp = unique_ts,
labels = {
"experiment": experiment,
"well": well,
"planarian": str(planarian),
"record_type": record_type,
"uuid": uuid,
},
content_type = "application/json",
)
async def store_summary(self, summary: dict, experiment: str, well: str, planarian: int = 0):
"""Stocke le résumé de fin de session."""
await self.store_metric(summary, experiment, well, planarian, "summary")
async def get_tracking_data(
self,
experiment: str,
well: str,
planarian: int = 0,
record_type: str = "metrics",
start: Optional[datetime] = None,
stop: Optional[datetime] = None,
) -> list:
"""Récupère les enregistrements filtrés par labels."""
if self._bucket is None:
await self.connect()
kwargs = {"include": {
"experiment": experiment, "well": well,
"planarian": str(planarian), "record_type": record_type,
}}
if start:
kwargs["start"] = int(start.timestamp() * 1_000_000)
if stop:
kwargs["stop"] = int(stop.timestamp() * 1_000_000)
records = []
async for rec in self._bucket.query("metrics", **kwargs):
try:
records.append(json.loads(await rec.read_all()))
except Exception as e:
logger.warning(f"Entrée illisible ignorée : {e}")
return records
@staticmethod
def _convert_timestamps(records: list) -> list:
"""
Convertit le champ 'timestamp' (epoch float secondes) en ISO 8601 UTC
dans chaque enregistrement.
Args:
records : liste de dicts issus de ReductStore
Returns:
nouvelle liste avec timestamp converti (originaux non modifiés)
"""
converted = []
for r in records:
row = dict(r)
ts = row.get("timestamp")
if ts is not None:
try:
row["timestamp"] = (
datetime.fromtimestamp(float(ts), tz=timezone.utc)
.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
)
except (ValueError, TypeError, OSError):
pass
converted.append(row)
return converted
@staticmethod
def _build_filepath(output_dir: str, experiment: str,
well: str, planarian: int, record_type: str) -> str:
"""
Construit le chemin du fichier CSV de sortie.
Nom : <experiment>_<well>_planaire<NN>_<record_type>.csv
Args:
output_dir : répertoire de sortie (créé si absent)
experiment : identifiant de l'expérience
well : identifiant du puits
planarian : index du planaire
record_type : "frame" ou "summary"
Returns:
chemin absolu du fichier CSV
"""
dirpath = os.path.abspath(output_dir)
os.makedirs(dirpath, exist_ok=True)
filename = f"{experiment}_{well}_planaire{planarian:02d}_{record_type}.csv"
return os.path.join(dirpath, filename)
async def export_csv(
self,
experiment: str,
well: str,
planarian: int = 0,
record_type: str = "metrics",
output_dir: str = ".",
start: Optional[datetime] = None,
stop: Optional[datetime] = None,
) -> tuple:
"""
Exporte les données depuis ReductStore vers un fichier CSV.
Le répertoire de sortie est choisi via output_dir.
Le champ timestamp est converti en ISO 8601 UTC.
Args:
experiment : identifiant de l'expérience
well : identifiant du puits
planarian : index du planaire
record_type : "frame" | "summary"
output_dir : répertoire de sortie (défaut : répertoire courant)
start, stop : plage temporelle (datetime UTC, optionnel)
Returns:
tuple (filepath, nb_lignes)
"""
records = await self.get_tracking_data(
experiment, well, planarian, record_type, start, stop)
if not records:
logger.warning(f"Aucune donnée pour {experiment}/{well}/{planarian}")
return "", 0
records = self._convert_timestamps(records)
filepath = self._build_filepath(output_dir, experiment, well,
planarian, record_type)
fieldnames = list(dict.fromkeys(k for r in records for k in r.keys()))
with open(filepath, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(records)
logger.info(f"Export CSV : {len(records)} lignes → {filepath}")
return filepath, len(records)
async def export_csv_response(
self,
experiment: str,
well: str,
planarian: int = 0,
record_type: str = "metrics",
start: Optional[datetime] = None,
stop: Optional[datetime] = None,
) -> tuple:
"""
Génère le contenu CSV en mémoire (pour réponse HTTP Django).
Le champ timestamp est converti en ISO 8601 UTC.
Returns:
tuple (contenu_csv_str, nb_lignes)
"""
records = await self.get_tracking_data(
experiment, well, planarian, record_type, start, stop)
if not records:
return "", 0
records = self._convert_timestamps(records)
fieldnames = list(dict.fromkeys(k for r in records for k in r.keys()))
out = io.StringIO()
writer = csv.DictWriter(out, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(records)
return out.getvalue(), len(records)
async def close(self):
"""
Ferme la connexion ReductStore.
Note : reduct-py >= 1.x ne nécessite pas de fermeture explicite —
la méthode est conservée pour compatibilité d'interface.
"""
self._client = None
self._bucket = None
logger.info("ReductStore déconnecté")
+6 -17
View File
@@ -21,7 +21,7 @@ from modules.planarian_metrics import ExperimentParams, ReductStoreClient
from modules.system_stats import get_cached_stats, start_background_updater from modules.system_stats import get_cached_stats, start_background_updater
from scanner.constants import ScannerConstants from scanner.constants import ScannerConstants
from .tasks import export_experiment_metrics, export_session_metrics from .tasks import export_experiment_metrics, export_session_metrics
from scanner import models from scanner import models, views as scanner_views
from .models import ExperimentConfig from .models import ExperimentConfig
@@ -64,30 +64,17 @@ def global_context(request, **ctx):
) )
def get_active_experiments(session, expid=None):
if session:
experiments = models.SessionExperiment.experiment_by_session(session.id, active=True) or []
eid = [str(e.id) for e in experiments]
if experiments and not expid or expid not in eid:
return experiments, experiments[0]
for e in experiments:
if expid == str(e.id):
return experiments, e
return [], None
def get_active_session(request, session_id=None, experiment_id=None): def get_active_session(request, session_id=None, experiment_id=None):
cursid = session_id or request.POST.get('_sid') cursid = session_id or request.POST.get('_sid')
expid = experiment_id or request.POST.get('_expid') expid = experiment_id or request.POST.get('_expid')
current_session = models.Session.get_session(cursid) current_session = models.Session.get_session(cursid)
experiments, current_experiment = get_active_experiments(current_session, expid) experiments, current_experiment = scanner_views.get_not_active_experiments(current_session, expid)
context = dict( context = dict(
current_session = current_session, current_session = current_session,
current_experiment = current_experiment, current_experiment = current_experiment,
experiments=experiments or [], experiments=experiments or [],
sessions=models.Session.objects.filter(active=True).all(), sessions=models.Session.objects.filter(active=False).all(),
well_choices=models.Well.objects.order_by('name').all(), well_choices=models.Well.objects.order_by('name').all(),
) )
return context return context
@@ -118,6 +105,8 @@ def export_metrics(request):
def export_csv(request): def export_csv(request):
d = request.POST d = request.POST
print("export_csv===========", d)
@async_to_sync @async_to_sync
async def _do_export(): async def _do_export():
client = _get_reduct_client() client = _get_reduct_client()
@@ -141,7 +130,7 @@ def export_csv(request):
csv_content, n = _do_export() csv_content, n = _do_export()
logger.info(f"Export CSV: {n} lignes, content size={len(csv_content)}") logger.info(f"Export CSV: {n} lignes, content size={len(csv_content)}")
filename = ( filename = (
f"{d['experiment']}_{d['well']}-planaire{d['planarian']}" f"{d['experiment']}_{d['well']}-planaire-{d['planarian']}"
f"_{d['record_type']}.csv" f"_{d['record_type']}.csv"
) )
return csv_content, filename return csv_content, filename
+4 -3
View File
@@ -53,7 +53,8 @@ class Configuration(models.Model):
grbl_xmax = models.FloatField(_("Grbl Xmax"), help_text=_("CNC Grbl Xmax en mm"), blank=False, default=350.0) grbl_xmax = models.FloatField(_("Grbl Xmax"), help_text=_("CNC Grbl Xmax en mm"), blank=False, default=350.0)
grbl_ymax = models.FloatField(_("Grbl Ymax"), help_text=_("CNC Grbl Ymax en mm"), blank=False, default=250.0) grbl_ymax = models.FloatField(_("Grbl Ymax"), help_text=_("CNC Grbl Ymax en mm"), blank=False, default=250.0)
# camera configuration # camera configuration
capture_type = models.CharField(_("Capture"), help_text=_("Type de capture"), default='rpi', max_length=8, choices=CAPTURE_TYPE, null=True, blank=False) capture_type = models.CharField(_("Capture"), help_text=_("Type de capture. Nécessite un redémarrage en cas de modification à chaud!"), default='rpi', max_length=8, choices=CAPTURE_TYPE, null=True, blank=False)
webcam_device_index = models.PositiveSmallIntegerField(_("Index de la webcam"), help_text=_("Index de la webcam (0, 1, ...) si présente"), default=2) webcam_device_index = models.PositiveSmallIntegerField(_("Index de la webcam"), help_text=_("Index de la webcam (0, 1, ...) si présente"), default=2)
image_quality = models.PositiveSmallIntegerField(_("Qualité JPEG"), help_text=_("Qualité JPEG (1-100) pour les images exportées"), default=90) image_quality = models.PositiveSmallIntegerField(_("Qualité JPEG"), help_text=_("Qualité JPEG (1-100) pour les images exportées"), default=90)
video_jpeg_quality = models.PositiveSmallIntegerField(_("Qualité JPEG pour les vidéos"), help_text=_("Qualité JPEG (1-100) pour les images extraites des vidéos"), default=90) video_jpeg_quality = models.PositiveSmallIntegerField(_("Qualité JPEG pour les vidéos"), help_text=_("Qualité JPEG (1-100) pour les images extraites des vidéos"), default=90)
@@ -278,8 +279,8 @@ class Session(models.Model):
name = models.CharField(_("Nom de la session"), help_text=_("Session d'expérience. 4 Multi-puits maximum"), max_length=100, null=True, blank=False) name = models.CharField(_("Nom de la session"), help_text=_("Session d'expérience. 4 Multi-puits maximum"), max_length=100, null=True, blank=False)
author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
active = models.BooleanField(_("Active"), default=True) active = models.BooleanField(_("Active"), default=True)
expected_export = models.DateTimeField(_("Date d'exportation"), help_text=_("Date d'exportation prévue"), null=True, blank=True) expected_export = models.DateTimeField(_("Exportation auto"), help_text=_("Date d'exportation prévue"), null=True, blank=True)
expected_scanning = models.DateTimeField(_("Date du balayage"), help_text=_("Date du balayage prévue"), null=True, blank=True) expected_scanning = models.DateTimeField(_("Bbalayage auto"), help_text=_("Date du balayage prévue"), null=True, blank=True)
created = models.DateTimeField(_("Date de création"), default=timezone.now) created = models.DateTimeField(_("Date de création"), default=timezone.now)
finished = models.DateTimeField (_("Date de fin"), null=True, blank=True) finished = models.DateTimeField (_("Date de fin"), null=True, blank=True)
+3
View File
@@ -238,6 +238,9 @@ class MultiWellManager:
def _start_scanning(self, session, experiments, simulate=False): def _start_scanning(self, session, experiments, simulate=False):
result = False result = False
try: try:
conf = self.process.cam.get_config()
self.process.cam.use_tracking = conf.use_tracking
self.process.cam._aligner.debug = False self.process.cam._aligner.debug = False
self.stop_playing.clear() self.stop_playing.clear()
+22 -12
View File
@@ -160,27 +160,37 @@ class ScannerProcess(Task):
self.data = ProcessData() self.data = ProcessData()
self.manager = None self.manager = None
self.recordDB = CameraRecordManager(cameraDB) self.recordDB = CameraRecordManager(cameraDB)
self.metricDB = planarianDB
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
return self.start(*args, **kwargs) return self.start(*args, **kwargs)
def set_crop_radius(self, radius): def set_crop_radius(self, radius):
return CircularCrop(radius=radius, strategy=CropStrategy.CROP_JPEG, jpeg_quality=self.image_quality) return CircularCrop(radius=radius, strategy=CropStrategy.CROP_JPEG, jpeg_quality=self.image_quality)
def get_config(self):
'''
Constants:
reset si besoin les constantes vidéo
'''
self.conf = ScannerConstants().get()
self.use_tracking = self.conf.tracking
self.video_quality = self.conf.video_jpeg_quality
self.image_quality = self.conf.image_quality
self.video_fps = self.conf.video_frame_rate
self.video_width = self.conf.video_width_capture
self.video_height = self.conf.video_height_capture
self.crop_radius = self.conf.calibration_crop_radius
self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality]
self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality]
return self.conf
def start(self, *args, **kwargs): def start(self, *args, **kwargs):
try: try:
self.conf = ScannerConstants().get() self.get_config()
self.use_tracking = self.conf.tracking
self.video_quality = self.conf.video_jpeg_quality
self.image_quality = self.conf.image_quality
self.video_fps = self.conf.video_frame_rate
self.video_width = self.conf.video_width_capture
self.video_height = self.conf.video_height_capture
self.crop_radius = self.conf.calibration_crop_radius
self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality]
self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality]
self.grbl_xmax = self.conf.grbl_xmax self.grbl_xmax = self.conf.grbl_xmax
self.grbl_ymax = self.conf.grbl_ymax self.grbl_ymax = self.conf.grbl_ymax