planarian
This commit is contained in:
@@ -62,6 +62,7 @@ INSTALLED_APPS = [
|
||||
'celery',
|
||||
'home',
|
||||
'scanner',
|
||||
'planarian',
|
||||
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
"""
|
||||
modules/planarian_metrics.py
|
||||
|
||||
Intégration des métriques EthoVision XT dans PlanarianScanner.
|
||||
|
||||
Architecture :
|
||||
PlanarianTracker.process() → dict brut (cx, cy, speed_px_s, ...)
|
||||
EthoVisionMetrics.update() → enrichit avec métriques EthoVision
|
||||
ReductStoreClient.store() → stocke dans ReductStore avec labels
|
||||
ReductStoreClient.export_csv() → exporte vers CSV
|
||||
|
||||
Schéma des labels ReductStore :
|
||||
experiment : identifiant de l'expérience (ex: "exp_2026_04_25")
|
||||
well : identifiant du puits (ex: "A1", "B3")
|
||||
planarian : index du planaire dans le puits (ex: "0", "1")
|
||||
bucket : nom du bucket (ex: "planarian_metrics")
|
||||
|
||||
Created on 25 avr. 2026
|
||||
@author: denis
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constantes EthoVision (seuils de mobilité par défaut)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Seuils en mm/s — identiques à ceux de la simulation
|
||||
THRESH_IMMOBILE_DEFAULT = 0.2 # en-dessous : Immobile
|
||||
THRESH_MOBILE_DEFAULT = 1.5 # entre les deux : Mobile, au-delà : Highly mobile
|
||||
|
||||
# États de mobilité (nomenclature EthoVision XT)
|
||||
STATE_IMMOBILE = "Immobile"
|
||||
STATE_MOBILE = "Mobile"
|
||||
STATE_HIGH_MOBILE = "Highly mobile"
|
||||
|
||||
# Paramètres comportementaux (défauts — peuvent être importés depuis CSV/Django)
|
||||
BEHAVIOUR_DEFAULTS = {
|
||||
# Thigmotactisme
|
||||
"thigmotaxis_wall_dist_mm": 1.0, # distance à la paroi considérée "near wall"
|
||||
# Phototactisme
|
||||
"photo_mode": "none", # none | fixed | sine | radial
|
||||
"photo_strength": 0.0,
|
||||
# Chimiotactisme
|
||||
"chemo_strength": 0.0,
|
||||
"chemo_x": 0.5, # fraction 0-1
|
||||
"chemo_y": 0.5,
|
||||
"chemo_radius_mm": 2.0,
|
||||
# Interactions inter-individus
|
||||
"avoid_radius_mm": 3.0,
|
||||
"aggreg_radius_mm": 6.0,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Classe EthoVisionMetrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class EthoVisionMetrics:
|
||||
"""
|
||||
Calcule et accumule les métriques compatibles EthoVision XT
|
||||
à partir des données brutes de PlanarianTracker.
|
||||
|
||||
Gère la conversion pixels → mm via le facteur px_per_mm.
|
||||
Une instance par planaire suivi (un puits = une instance).
|
||||
|
||||
Usage :
|
||||
metrics = EthoVisionMetrics(px_per_mm=26.25, fps=10)
|
||||
for frame, ts in capture:
|
||||
annotated, raw = tracker.process(frame, ts)
|
||||
record = metrics.update(raw, well_radius_mm=8.0)
|
||||
await reduct_client.store(record, labels=...)
|
||||
summary = metrics.summary()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
px_per_mm: float,
|
||||
fps: float,
|
||||
thresh_immobile: float = THRESH_IMMOBILE_DEFAULT,
|
||||
thresh_mobile: float = THRESH_MOBILE_DEFAULT,
|
||||
behaviour: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
px_per_mm : facteur de conversion pixels → mm (calibration optique)
|
||||
fps : fréquence de capture en images/seconde
|
||||
thresh_immobile : seuil vitesse Immobile/Mobile en mm/s
|
||||
thresh_mobile : seuil vitesse Mobile/Très mobile en mm/s
|
||||
behaviour : dict de paramètres comportementaux (cf. BEHAVIOUR_DEFAULTS)
|
||||
"""
|
||||
self.px_per_mm = px_per_mm
|
||||
self.fps = fps
|
||||
self.dt = 1.0 / fps
|
||||
self.thresh_immobile = thresh_immobile
|
||||
self.thresh_mobile = thresh_mobile
|
||||
self.behaviour = {**BEHAVIOUR_DEFAULTS, **(behaviour or {})}
|
||||
|
||||
# --- Accumulateurs globaux ---
|
||||
self.total_distance_mm = 0.0
|
||||
self.duration_moving_s = 0.0
|
||||
self.duration_stopped_s = 0.0
|
||||
self.frame_count = 0
|
||||
|
||||
# --- Accumulateurs par état de mobilité ---
|
||||
self._mob_counts = {
|
||||
STATE_IMMOBILE: 0,
|
||||
STATE_MOBILE: 0,
|
||||
STATE_HIGH_MOBILE: 0,
|
||||
}
|
||||
self._mob_durations = {
|
||||
STATE_IMMOBILE: 0.0,
|
||||
STATE_MOBILE: 0.0,
|
||||
STATE_HIGH_MOBILE: 0.0,
|
||||
}
|
||||
self._current_state = None
|
||||
|
||||
# --- Thigmotactisme ---
|
||||
self._near_wall_frames = 0
|
||||
|
||||
# --- Historique positions (pour calcul vitesse inter-frame) ---
|
||||
self._prev_cx_px = None
|
||||
self._prev_cy_px = None
|
||||
self._prev_ts = None
|
||||
|
||||
def _px_to_mm(self, px: float) -> float:
|
||||
"""Convertit des pixels en millimètres."""
|
||||
return px / self.px_per_mm
|
||||
|
||||
def _classify(self, velocity_mm_s: float) -> str:
|
||||
"""
|
||||
Classifie la vitesse selon les seuils EthoVision.
|
||||
|
||||
Args:
|
||||
velocity_mm_s : vitesse instantanée en mm/s
|
||||
|
||||
Returns:
|
||||
str : STATE_IMMOBILE | STATE_MOBILE | STATE_HIGH_MOBILE
|
||||
"""
|
||||
if velocity_mm_s <= self.thresh_immobile:
|
||||
return STATE_IMMOBILE
|
||||
elif velocity_mm_s <= self.thresh_mobile:
|
||||
return STATE_MOBILE
|
||||
return STATE_HIGH_MOBILE
|
||||
|
||||
def update(self, raw: dict, well_radius_mm: float = 8.0) -> dict:
|
||||
"""
|
||||
Calcule les métriques EthoVision pour une frame à partir
|
||||
du résultat brut de PlanarianTracker.process().
|
||||
|
||||
Args:
|
||||
raw : dict retourné par PlanarianTracker.process()
|
||||
clés attendues : detected, cx, cy, speed_px_s, ts
|
||||
well_radius_mm : rayon du puits en mm (pour le thigmotactisme)
|
||||
|
||||
Returns:
|
||||
dict complet avec métriques EthoVision prêtes pour ReductStore
|
||||
"""
|
||||
self.frame_count += 1
|
||||
ts = raw.get("timestamp", time.time())
|
||||
|
||||
if not raw.get("detected", False):
|
||||
# Planaire non détecté : on accumule l'arrêt et on retourne vide
|
||||
self.duration_stopped_s += self.dt
|
||||
state = self._current_state or STATE_IMMOBILE
|
||||
self._mob_durations[state] += self.dt
|
||||
return self._empty_record(ts)
|
||||
|
||||
cx_px = raw["cx"]
|
||||
cy_px = raw["cy"]
|
||||
|
||||
# --- Conversion en mm ---
|
||||
cx_mm = self._px_to_mm(cx_px)
|
||||
cy_mm = self._px_to_mm(cy_px)
|
||||
|
||||
# --- Vitesse en mm/s depuis la vitesse brute pixels/s ---
|
||||
speed_px_s = raw.get("speed_px_s", 0.0)
|
||||
velocity_mm_s = self._px_to_mm(speed_px_s)
|
||||
|
||||
# --- Distance parcourue cette frame ---
|
||||
dist_mm = velocity_mm_s * self.dt
|
||||
self.total_distance_mm += dist_mm
|
||||
|
||||
# --- Mouvement / arrêt ---
|
||||
is_moving = velocity_mm_s > self.thresh_immobile
|
||||
if is_moving:
|
||||
self.duration_moving_s += self.dt
|
||||
else:
|
||||
self.duration_stopped_s += self.dt
|
||||
|
||||
# --- État de mobilité ---
|
||||
new_state = self._classify(velocity_mm_s)
|
||||
if new_state != self._current_state:
|
||||
self._mob_counts[new_state] += 1
|
||||
self._current_state = new_state
|
||||
self._mob_durations[new_state] += self.dt
|
||||
|
||||
# --- Thigmotactisme ---
|
||||
# Distance à la paroi du puits (centre = 0, paroi = well_radius_mm)
|
||||
well_radius_px = well_radius_mm * self.px_per_mm
|
||||
dist_center_px = math.sqrt(cx_px**2 + cy_px**2)
|
||||
dist_wall_mm = self._px_to_mm(well_radius_px - dist_center_px)
|
||||
near_wall_dist = self.behaviour.get("thigmotaxis_wall_dist_mm", 1.0)
|
||||
is_near_wall = dist_wall_mm < near_wall_dist
|
||||
if is_near_wall:
|
||||
self._near_wall_frames += 1
|
||||
|
||||
self._prev_cx_px = cx_px
|
||||
self._prev_cy_px = cy_px
|
||||
self._prev_ts = ts
|
||||
|
||||
# --- Record complet ---
|
||||
return {
|
||||
# Identification temporelle
|
||||
"timestamp": ts,
|
||||
"detected": True,
|
||||
# Position brute (pixels)
|
||||
"cx_px": cx_px,
|
||||
"cy_px": cy_px,
|
||||
# Position en mm
|
||||
"x_mm": round(cx_mm, 4),
|
||||
"y_mm": round(cy_mm, 4),
|
||||
# Vitesse
|
||||
"velocity_mm_s": round(velocity_mm_s, 4),
|
||||
"distance_mm": round(dist_mm, 4),
|
||||
# Distance totale cumulée (EthoVision : movedCenter-pointTotalmm)
|
||||
"total_distance_mm": round(self.total_distance_mm, 4),
|
||||
# Mouvement / arrêt (EthoVision : MovementMoving / Not Moving)
|
||||
"moving": int(is_moving),
|
||||
"duration_moving_s": round(self.duration_moving_s, 3),
|
||||
"duration_stopped_s": round(self.duration_stopped_s, 3),
|
||||
# État de mobilité (EthoVision : Mobility state)
|
||||
"mobility_state": new_state,
|
||||
"mobility_immobile_freq": self._mob_counts[STATE_IMMOBILE],
|
||||
"mobility_immobile_duration_s": round(self._mob_durations[STATE_IMMOBILE], 3),
|
||||
"mobility_mobile_freq": self._mob_counts[STATE_MOBILE],
|
||||
"mobility_mobile_duration_s": round(self._mob_durations[STATE_MOBILE], 3),
|
||||
"mobility_high_mobile_freq": self._mob_counts[STATE_HIGH_MOBILE],
|
||||
"mobility_high_mobile_duration_s": round(self._mob_durations[STATE_HIGH_MOBILE], 3),
|
||||
# Thigmotactisme
|
||||
"dist_to_wall_mm": round(dist_wall_mm, 4),
|
||||
"near_wall": int(is_near_wall),
|
||||
# Données brutes tracker (passthrough)
|
||||
"area_px": raw.get("area_px", 0),
|
||||
"axial_pos": raw.get("axial_pos", 0.0),
|
||||
"axial_speed": raw.get("axial_speed", 0.0),
|
||||
}
|
||||
|
||||
def summary(self) -> dict:
|
||||
"""
|
||||
Retourne le résumé global de la session (nomenclature EthoVision XT).
|
||||
À appeler en fin d'expérience pour stocker le résumé dans ReductStore.
|
||||
|
||||
Returns:
|
||||
dict avec toutes les métriques agrégées
|
||||
"""
|
||||
total_s = self.frame_count * self.dt
|
||||
return {
|
||||
"total_frames": self.frame_count,
|
||||
"total_duration_s": round(total_s, 3),
|
||||
# Distance / vitesse (EthoVision : movedCenter-pointTotalmm / VelocityCenter-pointMeanmm/s)
|
||||
"movedCenter_pointTotal_mm": round(self.total_distance_mm, 4),
|
||||
"velocity_mean_mm_s": round(
|
||||
self.total_distance_mm / total_s if total_s > 0 else 0.0, 4
|
||||
),
|
||||
# Mouvement / arrêt
|
||||
"movement_moving_duration_s": round(self.duration_moving_s, 3),
|
||||
"movement_not_moving_duration_s": round(self.duration_stopped_s, 3),
|
||||
# Immobile
|
||||
"mobility_immobile_frequency": self._mob_counts[STATE_IMMOBILE],
|
||||
"mobility_immobile_duration_s": round(self._mob_durations[STATE_IMMOBILE], 3),
|
||||
# Mobile
|
||||
"mobility_mobile_frequency": self._mob_counts[STATE_MOBILE],
|
||||
"mobility_mobile_duration_s": round(self._mob_durations[STATE_MOBILE], 3),
|
||||
# Très mobile
|
||||
"mobility_highly_mobile_frequency": self._mob_counts[STATE_HIGH_MOBILE],
|
||||
"mobility_highly_mobile_duration_s": round(self._mob_durations[STATE_HIGH_MOBILE], 3),
|
||||
# Thigmotactisme
|
||||
"thigmotaxis_pct_time_near_wall": round(
|
||||
100.0 * self._near_wall_frames / max(self.frame_count, 1), 2
|
||||
),
|
||||
}
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
Réinitialise tous les accumulateurs.
|
||||
À appeler lors d'un changement de puits ou de planaire.
|
||||
"""
|
||||
self.__init__(
|
||||
self.px_per_mm,
|
||||
self.fps,
|
||||
self.thresh_immobile,
|
||||
self.thresh_mobile,
|
||||
self.behaviour,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _empty_record(ts: float) -> dict:
|
||||
"""Retourne un enregistrement vide (planaire non détecté)."""
|
||||
return {
|
||||
"timestamp": ts,
|
||||
"detected": False,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paramètres expérimentaux (importables depuis CSV ou Django)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ExperimentParams:
|
||||
"""
|
||||
Conteneur des paramètres d'une expérience.
|
||||
Peut être instancié depuis un dict, un fichier CSV ou un modèle Django.
|
||||
|
||||
Champs obligatoires : experiment, well, px_per_mm, fps
|
||||
Tous les autres ont des valeurs par défaut.
|
||||
"""
|
||||
|
||||
REQUIRED = {"experiment", "well", "px_per_mm", "fps"}
|
||||
|
||||
DEFAULTS = {
|
||||
"well_radius_mm": 8.0,
|
||||
"thresh_immobile": THRESH_IMMOBILE_DEFAULT,
|
||||
"thresh_mobile": THRESH_MOBILE_DEFAULT,
|
||||
"planarian_count": 1,
|
||||
"tube_axis": "vertical",
|
||||
"min_area_px": 20,
|
||||
**BEHAVIOUR_DEFAULTS,
|
||||
}
|
||||
|
||||
def __init__(self, data: dict):
|
||||
"""
|
||||
Args:
|
||||
data : dict contenant au moins les champs REQUIRED
|
||||
"""
|
||||
missing = self.REQUIRED - set(data.keys())
|
||||
if missing:
|
||||
raise ValueError(f"Paramètres manquants : {missing}")
|
||||
|
||||
merged = {**self.DEFAULTS, **data}
|
||||
for k, v in merged.items():
|
||||
# Conversion de type automatique si valeur string (vient du CSV)
|
||||
setattr(self, k, self._cast(k, v))
|
||||
|
||||
@staticmethod
|
||||
def _cast(key: str, value):
|
||||
"""
|
||||
Convertit la valeur en type approprié.
|
||||
Les valeurs CSV sont toutes des strings — on les cast automatiquement.
|
||||
|
||||
Args:
|
||||
key : nom du paramètre
|
||||
value : valeur brute (str ou type natif)
|
||||
|
||||
Returns:
|
||||
valeur convertie
|
||||
"""
|
||||
float_keys = {
|
||||
"px_per_mm", "fps", "well_radius_mm", "thresh_immobile", "thresh_mobile",
|
||||
"photo_strength", "chemo_strength", "chemo_x", "chemo_y", "chemo_radius_mm",
|
||||
"thigmotaxis_wall_dist_mm", "avoid_radius_mm", "aggreg_radius_mm",
|
||||
}
|
||||
int_keys = {"planarian_count", "min_area_px"}
|
||||
if key in float_keys:
|
||||
return float(value)
|
||||
if key in int_keys:
|
||||
return int(value)
|
||||
# Booléens CSV ("true"/"false")
|
||||
if isinstance(value, str) and value.lower() in ("true", "false"):
|
||||
return value.lower() == "true"
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_csv_row(cls, row: dict) -> "ExperimentParams":
|
||||
"""
|
||||
Instancie depuis une ligne de DictReader CSV.
|
||||
|
||||
Args:
|
||||
row : dict issu de csv.DictReader
|
||||
|
||||
Returns:
|
||||
ExperimentParams
|
||||
"""
|
||||
return cls(row)
|
||||
|
||||
@classmethod
|
||||
def from_csv_file(cls, filepath: str) -> list:
|
||||
"""
|
||||
Charge tous les paramètres d'un fichier CSV (une expérience par ligne).
|
||||
|
||||
Args:
|
||||
filepath : chemin vers le fichier CSV
|
||||
|
||||
Returns:
|
||||
liste d'ExperimentParams
|
||||
"""
|
||||
results = []
|
||||
with open(filepath, newline="", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
try:
|
||||
results.append(cls.from_csv_row(row))
|
||||
except ValueError as e:
|
||||
logger.warning(f"Ligne ignorée : {e} — {row}")
|
||||
return results
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Sérialise les paramètres en dict (pour stockage ou affichage Django)."""
|
||||
return {k: getattr(self, k) for k in {**self.DEFAULTS, **{r: None for r in self.REQUIRED}}}
|
||||
|
||||
def build_metrics(self) -> "EthoVisionMetrics":
|
||||
"""
|
||||
Construit l'instance EthoVisionMetrics correspondant à ces paramètres.
|
||||
|
||||
Returns:
|
||||
EthoVisionMetrics configurée
|
||||
"""
|
||||
behaviour = {k: getattr(self, k) for k in BEHAVIOUR_DEFAULTS if hasattr(self, k)}
|
||||
return EthoVisionMetrics(
|
||||
px_per_mm = self.px_per_mm,
|
||||
fps = self.fps,
|
||||
thresh_immobile = self.thresh_immobile,
|
||||
thresh_mobile = self.thresh_mobile,
|
||||
behaviour = behaviour,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client ReductStore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ReductStoreClient:
|
||||
"""
|
||||
Interface asynchrone avec ReductStore pour PlanarianScanner.
|
||||
|
||||
Schéma des labels :
|
||||
experiment → identifiant de l'expérience
|
||||
well → identifiant du puits (A1, B3, ...)
|
||||
planarian → index du planaire dans le puits
|
||||
record_type → "frame" | "summary"
|
||||
|
||||
Chaque entrée stockée contient un payload JSON avec toutes les métriques.
|
||||
Le timestamp ReductStore est l'epoch µs de la frame.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "http://localhost:8383",
|
||||
token: str = "",
|
||||
bucket: str = "planarian_metrics",
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
url : URL du serveur ReductStore
|
||||
token : token d'authentification (vide si pas d'auth)
|
||||
bucket : nom du bucket cible
|
||||
"""
|
||||
self.url = url
|
||||
self.token = token
|
||||
self.bucket_name = bucket
|
||||
self._client = None
|
||||
self._bucket = None
|
||||
|
||||
async def connect(self):
|
||||
"""
|
||||
Initialise la connexion et crée le bucket s'il n'existe pas.
|
||||
À appeler une fois au démarrage.
|
||||
"""
|
||||
from reduct import Client, BucketSettings, QuotaType
|
||||
|
||||
self._client = Client(self.url, api_token=self.token)
|
||||
self._bucket = await self._client.create_bucket(
|
||||
self.bucket_name,
|
||||
BucketSettings(quota_type=QuotaType.NONE),
|
||||
exist_ok=True,
|
||||
)
|
||||
logger.info(f"ReductStore connecté : {self.url} / bucket={self.bucket_name}")
|
||||
|
||||
async def store_metric(
|
||||
self,
|
||||
record: dict,
|
||||
experiment: str,
|
||||
well: str,
|
||||
planarian: int = 0,
|
||||
record_type: str = "frame",
|
||||
ts_us: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
Stocke un enregistrement de métriques dans ReductStore.
|
||||
|
||||
Args:
|
||||
record : dict de métriques (issu de EthoVisionMetrics.update())
|
||||
experiment : identifiant de l'expérience
|
||||
well : identifiant du puits
|
||||
planarian : index du planaire (défaut 0)
|
||||
record_type : "frame" ou "summary"
|
||||
ts_us : timestamp en microsecondes (défaut : maintenant)
|
||||
"""
|
||||
if self._bucket is None:
|
||||
await self.connect()
|
||||
|
||||
ts_us = ts_us or int(time.time() * 1_000_000)
|
||||
|
||||
labels = {
|
||||
"experiment": experiment,
|
||||
"well": well,
|
||||
"planarian": str(planarian),
|
||||
"record_type": record_type,
|
||||
}
|
||||
|
||||
payload = json.dumps(record).encode("utf-8")
|
||||
|
||||
await self._bucket.write(
|
||||
entry_name = "metrics",
|
||||
data = payload,
|
||||
timestamp = ts_us,
|
||||
labels = labels,
|
||||
content_type= "application/json",
|
||||
)
|
||||
|
||||
async def store_summary(
|
||||
self,
|
||||
summary: dict,
|
||||
experiment: str,
|
||||
well: str,
|
||||
planarian: int = 0,
|
||||
):
|
||||
"""
|
||||
Stocke le résumé de fin de session dans ReductStore.
|
||||
|
||||
Args:
|
||||
summary : dict issu de EthoVisionMetrics.summary()
|
||||
experiment : identifiant de l'expérience
|
||||
well : identifiant du puits
|
||||
planarian : index du planaire
|
||||
"""
|
||||
await self.store_metric(
|
||||
record = summary,
|
||||
experiment = experiment,
|
||||
well = well,
|
||||
planarian = planarian,
|
||||
record_type = "summary",
|
||||
)
|
||||
|
||||
async def get_tracking_data(
|
||||
self,
|
||||
experiment: str,
|
||||
well: str,
|
||||
planarian: int = 0,
|
||||
record_type: str = "frame",
|
||||
start: Optional[datetime] = None,
|
||||
stop: Optional[datetime] = None,
|
||||
) -> list:
|
||||
"""
|
||||
Récupère les enregistrements depuis ReductStore avec filtrage par labels.
|
||||
|
||||
Args:
|
||||
experiment : identifiant de l'expérience
|
||||
well : identifiant du puits
|
||||
planarian : index du planaire
|
||||
record_type : "frame" | "summary"
|
||||
start, stop : plage temporelle (datetime UTC, optionnel)
|
||||
|
||||
Returns:
|
||||
liste de dicts métriques
|
||||
"""
|
||||
if self._bucket is None:
|
||||
await self.connect()
|
||||
|
||||
labels = {
|
||||
"experiment": experiment,
|
||||
"well": well,
|
||||
"planarian": str(planarian),
|
||||
"record_type": record_type,
|
||||
}
|
||||
|
||||
kwargs = {"include": labels}
|
||||
if start:
|
||||
kwargs["start"] = int(start.timestamp() * 1_000_000)
|
||||
if stop:
|
||||
kwargs["stop"] = int(stop.timestamp() * 1_000_000)
|
||||
|
||||
records = []
|
||||
async for record in self._bucket.query("metrics", **kwargs):
|
||||
try:
|
||||
data = json.loads(await record.read_all())
|
||||
records.append(data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Entrée illisible ignorée : {e}")
|
||||
|
||||
return records
|
||||
|
||||
async def export_csv(
|
||||
self,
|
||||
filepath: str,
|
||||
experiment: str,
|
||||
well: str,
|
||||
planarian: int = 0,
|
||||
record_type: str = "frame",
|
||||
start: Optional[datetime] = None,
|
||||
stop: Optional[datetime] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Exporte les données depuis ReductStore vers un fichier CSV.
|
||||
|
||||
Args:
|
||||
filepath : chemin du fichier CSV de sortie
|
||||
experiment : identifiant de l'expérience
|
||||
well : identifiant du puits
|
||||
planarian : index du planaire
|
||||
record_type : "frame" | "summary"
|
||||
start, stop : plage temporelle (datetime UTC, optionnel)
|
||||
|
||||
Returns:
|
||||
nombre de lignes exportées
|
||||
"""
|
||||
records = await self.get_tracking_data(
|
||||
experiment = experiment,
|
||||
well = well,
|
||||
planarian = planarian,
|
||||
record_type = record_type,
|
||||
start = start,
|
||||
stop = stop,
|
||||
)
|
||||
|
||||
if not records:
|
||||
logger.warning(f"Aucune donnée pour {experiment}/{well}/{planarian}")
|
||||
return 0
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
|
||||
|
||||
# Collecte de toutes les clés présentes (union de tous les records)
|
||||
fieldnames = list(dict.fromkeys(k for r in records for k in r.keys()))
|
||||
|
||||
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for r in records:
|
||||
writer.writerow(r)
|
||||
|
||||
logger.info(f"Export CSV : {len(records)} lignes → {filepath}")
|
||||
return len(records)
|
||||
|
||||
async def export_csv_response(
|
||||
self,
|
||||
experiment: str,
|
||||
well: str,
|
||||
planarian: int = 0,
|
||||
record_type: str = "frame",
|
||||
start: Optional[datetime] = None,
|
||||
stop: Optional[datetime] = None,
|
||||
) -> tuple[str, int]:
|
||||
"""
|
||||
Génère le contenu CSV en mémoire (pour une réponse HTTP Django).
|
||||
|
||||
Args:
|
||||
experiment, well, planarian, record_type, start, stop : cf. export_csv
|
||||
|
||||
Returns:
|
||||
tuple (contenu_csv_str, nb_lignes)
|
||||
"""
|
||||
records = await self.get_tracking_data(
|
||||
experiment = experiment,
|
||||
well = well,
|
||||
planarian = planarian,
|
||||
record_type = record_type,
|
||||
start = start,
|
||||
stop = stop,
|
||||
)
|
||||
|
||||
if not records:
|
||||
return "", 0
|
||||
|
||||
fieldnames = list(dict.fromkeys(k for r in records for k in r.keys()))
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for r in records:
|
||||
writer.writerow(r)
|
||||
|
||||
return output.getvalue(), len(records)
|
||||
|
||||
async def close(self):
|
||||
"""Ferme la connexion ReductStore."""
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
logger.info("ReductStore déconnecté")
|
||||
@@ -51,10 +51,12 @@ class TubeAligner:
|
||||
"tube_cx" : None,
|
||||
"tube_cy" : None,
|
||||
"tube_radius" : None,
|
||||
"radius_mm" : self.TUBE_DIAMETER_MM / 2,
|
||||
"offset_x_px" : 0,
|
||||
"offset_y_px" : 0,
|
||||
"offset_x_mm" : 0.0,
|
||||
"offset_y_mm" : 0.0,
|
||||
"px_per_mm" : 0.0,
|
||||
"action" : "none",
|
||||
"frame_annotated": None,
|
||||
"msg" : None,
|
||||
@@ -134,6 +136,8 @@ class TubeAligner:
|
||||
"tube_cx" : tx,
|
||||
"tube_cy" : ty,
|
||||
"tube_radius" : tr,
|
||||
"radius_mm" : self.TUBE_DIAMETER_MM / 2,
|
||||
"px_per_mm" : self.px_per_mm,
|
||||
"offset_x_px" : offset_x_px,
|
||||
"offset_y_px" : offset_y_px,
|
||||
"offset_x_mm" : dx_mm,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# planarian/admin.py
|
||||
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from .models import ExperimentConfig
|
||||
|
||||
|
||||
@admin.register(ExperimentConfig)
|
||||
class ExperimentConfigAdmin(admin.ModelAdmin):
|
||||
"""Admin Django pour les configurations d'expérience."""
|
||||
readonly_fields = ('experiment', )
|
||||
list_display = ("experiment", "well", "px_per_mm", "fps",
|
||||
"thresh_immobile", "thresh_mobile",
|
||||
"photo_mode", "chemo_strength", "created_at")
|
||||
list_filter = ("photo_mode", "tube_axis")
|
||||
search_fields = ("experiment", "well", "description")
|
||||
ordering = ("-created_at",)
|
||||
|
||||
fieldsets = (
|
||||
(_("Identification"), {
|
||||
"fields": ("experiment", "well", "description"),
|
||||
}),
|
||||
(_("Calibration optique"), {
|
||||
"fields": ("px_per_mm", "fps", "well_radius_mm"),
|
||||
}),
|
||||
(_("Seuils de mobilité EthoVision"), {
|
||||
"fields": ("thresh_immobile", "thresh_mobile"),
|
||||
}),
|
||||
(_("Tracker"), {
|
||||
"fields": ("tube_axis", "min_area_px", "planarian_count"),
|
||||
}),
|
||||
(_("Thigmotactisme"), {
|
||||
"fields": ("thigmotaxis_wall_dist_mm",),
|
||||
}),
|
||||
(_("Phototactisme"), {
|
||||
"fields": ("photo_mode", "photo_strength", "photo_x", "photo_y"),
|
||||
"classes": ("collapse",),
|
||||
}),
|
||||
(_("Chimiotactisme"), {
|
||||
"fields": ("chemo_strength", "chemo_x", "chemo_y", "chemo_radius_mm"),
|
||||
"classes": ("collapse",),
|
||||
}),
|
||||
(_("Interactions inter-individus"), {
|
||||
"fields": ("avoid_radius_mm", "aggreg_radius_mm"),
|
||||
"classes": ("collapse",),
|
||||
}),
|
||||
)
|
||||
|
||||
# Action : export CSV template
|
||||
actions = ["export_csv_template"]
|
||||
|
||||
@admin.action(description=_("Exporter un template CSV de ces configurations"))
|
||||
def export_csv_template(self, request, queryset):
|
||||
import csv
|
||||
from django.http import HttpResponse
|
||||
from io import StringIO
|
||||
|
||||
output = StringIO()
|
||||
fields = [f.name for f in ExperimentConfig._meta.fields if f.name != "id"] # @UndefinedVariable
|
||||
writer = csv.DictWriter(output, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
for obj in queryset:
|
||||
row = {f: getattr(obj, f) for f in fields}
|
||||
writer.writerow(row)
|
||||
|
||||
response = HttpResponse(output.getvalue(), content_type="text/csv")
|
||||
response["Content-Disposition"] = 'attachment; filename="experiment_configs.csv"'
|
||||
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PlanarianConfig(AppConfig):
|
||||
name = 'planarian'
|
||||
@@ -0,0 +1,91 @@
|
||||
# planarian/forms.py
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from .models import ExperimentConfig
|
||||
|
||||
|
||||
class ExperimentConfigForm(forms.ModelForm):
|
||||
"""Formulaire de saisie/modification d'un ExperimentConfig."""
|
||||
|
||||
class Meta:
|
||||
model = ExperimentConfig
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"description": forms.Textarea(attrs={"rows": 3}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
if cleaned.get("thresh_immobile", 0) >= cleaned.get("thresh_mobile", 1):
|
||||
raise forms.ValidationError(
|
||||
_("Le seuil Immobile doit être inférieur au seuil Mobile.")
|
||||
)
|
||||
if cleaned.get("avoid_radius_mm", 0) >= cleaned.get("aggreg_radius_mm", 1):
|
||||
raise forms.ValidationError(
|
||||
_("Le rayon d'évitement doit être inférieur au rayon d'agrégation.")
|
||||
)
|
||||
return cleaned
|
||||
|
||||
|
||||
class CsvImportForm(forms.Form):
|
||||
"""Formulaire d'import de paramètres depuis un fichier CSV."""
|
||||
|
||||
csv_file = forms.FileField(
|
||||
label=_("Fichier CSV"),
|
||||
help_text=_(
|
||||
"Colonnes obligatoires : experiment, well, px_per_mm, fps. "
|
||||
"Toutes les autres colonnes sont optionnelles."
|
||||
),
|
||||
)
|
||||
overwrite = forms.BooleanField(
|
||||
required=False,
|
||||
initial=False,
|
||||
label=_("Écraser les configurations existantes"),
|
||||
)
|
||||
|
||||
def clean_csv_file(self):
|
||||
f = self.cleaned_data["csv_file"]
|
||||
try:
|
||||
content = f.read().decode("utf-8")
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
rows = list(reader)
|
||||
except Exception as e:
|
||||
raise forms.ValidationError(_("Fichier CSV invalide : %(err)s") % {"err": e})
|
||||
|
||||
required = {"experiment", "well", "px_per_mm", "fps"}
|
||||
if rows:
|
||||
missing = required - set(rows[0].keys())
|
||||
if missing:
|
||||
raise forms.ValidationError(
|
||||
_("Colonnes manquantes : %(cols)s") % {"cols": ", ".join(missing)}
|
||||
)
|
||||
self.csv_rows = rows
|
||||
return f
|
||||
|
||||
|
||||
class ExportCsvForm(forms.Form):
|
||||
"""Formulaire de demande d'export CSV depuis ReductStore."""
|
||||
|
||||
experiment = forms.CharField(label=_("Expérience"), max_length=100)
|
||||
well = forms.CharField(label=_("Puits"), max_length=20)
|
||||
planarian = forms.IntegerField(label=_("Index planaire"), initial=0, min_value=0)
|
||||
record_type = forms.ChoiceField(
|
||||
label=_("Type d'enregistrement"),
|
||||
choices=[("frame", _("Frame par frame")), ("summary", _("Résumé"))],
|
||||
initial="frame",
|
||||
)
|
||||
start_dt = forms.DateTimeField(
|
||||
label=_("Début (UTC)"),
|
||||
required=False,
|
||||
widget=forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
)
|
||||
stop_dt = forms.DateTimeField(
|
||||
label=_("Fin (UTC)"),
|
||||
required=False,
|
||||
widget=forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# planarian/models.py
|
||||
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
from django.db.models.signals import post_save
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.auth.models import User
|
||||
from scanner.models import Experiment, Well, WellPosition, Configuration
|
||||
|
||||
class ExperimentConfig(models.Model):
|
||||
"""
|
||||
Paramètres d'une expérience PlanarianScanner.
|
||||
Peut être créé depuis Django admin, une vue formulaire ou un import CSV.
|
||||
"""
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
||||
|
||||
# --- Identification ---
|
||||
idendifier = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name=_("Identifiant d'expérience"),
|
||||
help_text=_("Ex : exp_2026_04_25_ctrl"),
|
||||
)
|
||||
|
||||
experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="experiment_well" , null=True, blank=True)
|
||||
well = models.ForeignKey(Well, verbose_name="Puit", on_delete=models.CASCADE, related_name="well_experiment", null=True, blank=True )
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name=_("Description"),
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le"))
|
||||
|
||||
# --- Calibration optique ---
|
||||
# px_per_mm, fps, well_radius_mm
|
||||
px_per_mm = models.FloatField(
|
||||
default=26.25,
|
||||
verbose_name=_("Pixels par mm"),
|
||||
help_text=_("Facteur de calibration optique"),
|
||||
)
|
||||
fps = models.FloatField(
|
||||
default=5.0,
|
||||
verbose_name=_("FPS de capture"),
|
||||
)
|
||||
well_radius_mm = models.FloatField(
|
||||
default=8.0,
|
||||
verbose_name=_("Rayon du puits (mm)"),
|
||||
)
|
||||
|
||||
# --- Seuils de mobilité EthoVision ---
|
||||
thresh_immobile = models.FloatField(
|
||||
default=0.2,
|
||||
verbose_name=_("Seuil Immobile (mm/s)"),
|
||||
)
|
||||
thresh_mobile = models.FloatField(
|
||||
default=1.5,
|
||||
verbose_name=_("Seuil Mobile (mm/s)"),
|
||||
)
|
||||
|
||||
# --- Tracker ---
|
||||
tube_axis = models.CharField(
|
||||
max_length=10,
|
||||
default="vertical",
|
||||
choices=[("vertical", _("Vertical")), ("horizontal", _("Horizontal"))],
|
||||
verbose_name=_("Axe du tube"),
|
||||
)
|
||||
min_area_px = models.IntegerField(
|
||||
default=20,
|
||||
verbose_name=_("Surface min détection (px²)"),
|
||||
)
|
||||
planarian_count = models.IntegerField(
|
||||
default=1,
|
||||
verbose_name=_("Nombre de planaires"),
|
||||
)
|
||||
|
||||
# --- Thigmotactisme ---
|
||||
thigmotaxis_wall_dist_mm = models.FloatField(
|
||||
default=1.0,
|
||||
verbose_name=_("Distance paroi thigmotactisme (mm)"),
|
||||
)
|
||||
|
||||
# --- Phototactisme ---
|
||||
PHOTO_MODES = [
|
||||
("none", _("Désactivé")),
|
||||
("fixed", _("Source fixe")),
|
||||
("sine", _("Source sinusoïdale")),
|
||||
("radial", _("Gradient radial")),
|
||||
]
|
||||
photo_mode = models.CharField(
|
||||
max_length=10,
|
||||
default="none",
|
||||
choices=PHOTO_MODES,
|
||||
verbose_name=_("Mode phototactisme"),
|
||||
)
|
||||
photo_strength = models.FloatField(default=0.0, verbose_name=_("Intensité phototactisme"))
|
||||
photo_x = models.FloatField(default=0.5, verbose_name=_("Source lumière X (0-1)"))
|
||||
photo_y = models.FloatField(default=0.5, verbose_name=_("Source lumière Y (0-1)"))
|
||||
|
||||
# --- Chimiotactisme ---
|
||||
chemo_strength = models.FloatField(default=0.0, verbose_name=_("Intensité chimiotactisme"))
|
||||
chemo_x = models.FloatField(default=0.5, verbose_name=_("Nourriture X (0-1)"))
|
||||
chemo_y = models.FloatField(default=0.5, verbose_name=_("Nourriture Y (0-1)"))
|
||||
chemo_radius_mm = models.FloatField(default=2.0, verbose_name=_("Rayon nourriture (mm)"))
|
||||
|
||||
# --- Interactions inter-individus ---
|
||||
avoid_radius_mm = models.FloatField(default=3.0, verbose_name=_("Rayon évitement (mm)"))
|
||||
aggreg_radius_mm = models.FloatField(default=6.0, verbose_name=_("Rayon agrégation (mm)"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Configuration expérience")
|
||||
verbose_name_plural = _("Configurations expériences")
|
||||
unique_together = ("experiment", "well")
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.experiment} / {self.well.name}"
|
||||
|
||||
def get_session(self):
|
||||
return self.experiment.session_experiments.first() if self.experiment else None
|
||||
|
||||
def to_params_dict(self) -> dict:
|
||||
"""Retourne un dict compatible avec ExperimentParams."""
|
||||
return {
|
||||
"experiment": self.idendifier,
|
||||
"well": self.well.name,
|
||||
"px_per_mm": self.px_per_mm,
|
||||
"fps": self.fps,
|
||||
"well_radius_mm": self.well_radius_mm,
|
||||
"thresh_immobile": self.thresh_immobile,
|
||||
"thresh_mobile": self.thresh_mobile,
|
||||
"tube_axis": self.tube_axis,
|
||||
"min_area_px": self.min_area_px,
|
||||
"planarian_count": self.planarian_count,
|
||||
"thigmotaxis_wall_dist_mm": self.thigmotaxis_wall_dist_mm,
|
||||
"photo_mode": self.photo_mode,
|
||||
"photo_strength": self.photo_strength,
|
||||
"chemo_strength": self.chemo_strength,
|
||||
"chemo_x": self.chemo_x,
|
||||
"chemo_y": self.chemo_y,
|
||||
"chemo_radius_mm": self.chemo_radius_mm,
|
||||
"avoid_radius_mm": self.avoid_radius_mm,
|
||||
"aggreg_radius_mm": self.aggreg_radius_mm,
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
session = self.get_session()
|
||||
position = self.experiment.multiwell.position
|
||||
dte = self.experiment.multiwell.finished.isoformat()
|
||||
self.idendifier = f'{session}-{position}-{self.well.name}-{dte}'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@receiver(post_save, sender=ExperimentConfig)
|
||||
def create_well_position(sender, instance, created, **kwargs):
|
||||
active_well = WellPosition.active_well(instance.multiwel, instance.well)
|
||||
instance.px_per_mm = active_well.px_per_mm
|
||||
instance.well_radius_mm = instance.experiment.multiwell.diameter / 2
|
||||
conf = Configuration.active_config()
|
||||
instance.fps = conf.video_frame_rate
|
||||
instance.save()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
{% extends "scanner/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="w3-container w3-padding-32" style="max-width:960px; margin:auto;">
|
||||
|
||||
<!-- En-tête de la page -->
|
||||
<div class="w3-panel w3-teal w3-round-large w3-padding-16" style="margin-bottom:2rem;">
|
||||
<div class="w3-row w3-bar-item">
|
||||
<span class="w3-xlarge">
|
||||
<i class="w3-margin-right">🔬</i>
|
||||
{% if object %}
|
||||
{% trans "Modifier la configuration" %} — {{ object }}
|
||||
{% else %}
|
||||
{% trans "Nouvelle configuration d'expérience" %}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Django -->
|
||||
{% for message in messages %}
|
||||
<div class="w3-panel w3-round
|
||||
{% if message.tags == 'success' %}w3-green
|
||||
{% elif message.tags == 'error' %}w3-red
|
||||
{% elif message.tags == 'warning' %}w3-orange
|
||||
{% else %}w3-blue{% endif %}">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Erreurs globales du formulaire -->
|
||||
{% if form.non_field_errors %}
|
||||
<div class="w3-panel w3-red w3-round">
|
||||
{% for error in form.non_field_errors %}
|
||||
<p>⚠ {{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ============================================================
|
||||
Section 1 : Identification
|
||||
============================================================ -->
|
||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
||||
<header class="w3-container w3-teal w3-round-top-large">
|
||||
<h3 class="w3-text-white">{% trans "Identification" %}</h3>
|
||||
</header>
|
||||
<div class="w3-container w3-padding-24">
|
||||
<div class="w3-row-padding">
|
||||
|
||||
<!-- Experiment -->
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.experiment.label }}</b></label>
|
||||
{% if form.experiment.help_text %}
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.experiment.help_text }}</p>
|
||||
{% endif %}
|
||||
{{ form.experiment }}
|
||||
{% if form.experiment.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.experiment.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Well -->
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.well.label }}</b></label>
|
||||
{% if form.well.help_text %}
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.well.help_text }}</p>
|
||||
{% endif %}
|
||||
{{ form.well }}
|
||||
{% if form.well.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.well.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.description.label }}</b></label>
|
||||
{{ form.description }}
|
||||
{% if form.description.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.description.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
Section 2 : Calibration optique
|
||||
============================================================ -->
|
||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
||||
<header class="w3-container w3-blue-grey w3-round-top-large">
|
||||
<h3 class="w3-text-white">{% trans "Calibration optique" %}</h3>
|
||||
</header>
|
||||
<div class="w3-container w3-padding-24">
|
||||
<div class="w3-row-padding">
|
||||
|
||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||
<label class="w3-text-blue-grey"><b>{{ form.px_per_mm.label }}</b></label>
|
||||
{% if form.px_per_mm.help_text %}
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.px_per_mm.help_text }}</p>
|
||||
{% endif %}
|
||||
{{ form.px_per_mm }}
|
||||
{% if form.px_per_mm.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.px_per_mm.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||
<label class="w3-text-blue-grey"><b>{{ form.fps.label }}</b></label>
|
||||
{{ form.fps }}
|
||||
{% if form.fps.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.fps.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||
<label class="w3-text-blue-grey"><b>{{ form.well_radius_mm.label }}</b></label>
|
||||
{{ form.well_radius_mm }}
|
||||
{% if form.well_radius_mm.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.well_radius_mm.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
Section 3 : Seuils de mobilité EthoVision
|
||||
============================================================ -->
|
||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
||||
<header class="w3-container w3-indigo w3-round-top-large">
|
||||
<h3 class="w3-text-white">{% trans "Seuils de mobilité EthoVision XT" %}</h3>
|
||||
</header>
|
||||
<div class="w3-container w3-padding-24">
|
||||
|
||||
<!-- Visualisation des seuils -->
|
||||
<div class="w3-light-grey w3-round w3-margin-bottom" style="height:32px; position:relative; overflow:hidden;">
|
||||
<div style="position:absolute; left:0; width:13%; height:100%; background:#c0392b; display:flex; align-items:center; justify-content:center;">
|
||||
<span class="w3-small w3-text-white"><b>{% trans "Immobile" %}</b></span>
|
||||
</div>
|
||||
<div style="position:absolute; left:13%; width:27%; height:100%; background:#e67e22; display:flex; align-items:center; justify-content:center;">
|
||||
<span class="w3-small w3-text-white"><b>{% trans "Mobile" %}</b></span>
|
||||
</div>
|
||||
<div style="position:absolute; left:40%; right:0; height:100%; background:#27ae60; display:flex; align-items:center; justify-content:center;">
|
||||
<span class="w3-small w3-text-white"><b>{% trans "Très mobile" %}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="w3-small w3-text-grey" style="margin-top:-8px; margin-bottom:16px;">
|
||||
{% trans "Représentation indicative des zones de mobilité (défauts EthoVision : 0.2 / 1.5 mm/s)" %}
|
||||
</p>
|
||||
|
||||
<div class="w3-row-padding">
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-indigo"><b>{{ form.thresh_immobile.label }}</b></label>
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "En-dessous : Immobile (mm/s)" %}</p>
|
||||
{{ form.thresh_immobile }}
|
||||
{% if form.thresh_immobile.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.thresh_immobile.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-indigo"><b>{{ form.thresh_mobile.label }}</b></label>
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "En-dessous : Mobile, au-delà : Très mobile (mm/s)" %}</p>
|
||||
{{ form.thresh_mobile }}
|
||||
{% if form.thresh_mobile.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.thresh_mobile.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
Section 4 : Tracker
|
||||
============================================================ -->
|
||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
||||
<header class="w3-container w3-dark-grey w3-round-top-large">
|
||||
<h3 class="w3-text-white">{% trans "Tracker" %}</h3>
|
||||
</header>
|
||||
<div class="w3-container w3-padding-24">
|
||||
<div class="w3-row-padding">
|
||||
|
||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||
<label class="w3-text-dark-grey"><b>{{ form.tube_axis.label }}</b></label>
|
||||
{{ form.tube_axis }}
|
||||
{% if form.tube_axis.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.tube_axis.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||
<label class="w3-text-dark-grey"><b>{{ form.min_area_px.label }}</b></label>
|
||||
{{ form.min_area_px }}
|
||||
{% if form.min_area_px.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.min_area_px.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||
<label class="w3-text-dark-grey"><b>{{ form.planarian_count.label }}</b></label>
|
||||
{{ form.planarian_count }}
|
||||
{% if form.planarian_count.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.planarian_count.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
Section 5 : Comportements (accordéon W3.CSS)
|
||||
============================================================ -->
|
||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
||||
<header class="w3-container w3-teal w3-round-top-large">
|
||||
<h3 class="w3-text-white">{% trans "Comportements" %}</h3>
|
||||
</header>
|
||||
|
||||
<!-- --- Thigmotactisme --- -->
|
||||
<div class="w3-container w3-padding-16 w3-border-bottom">
|
||||
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
|
||||
onclick="toggleSection('thigmo')">
|
||||
<span class="w3-large">🫧</span>
|
||||
<b class="w3-margin-left">{% trans "Thigmotactisme" %}</b>
|
||||
<span class="w3-small w3-text-grey w3-margin-left">
|
||||
{% trans "Attraction vers la paroi du puits" %}
|
||||
</span>
|
||||
<span id="thigmo-icon" class="w3-right">▼</span>
|
||||
</button>
|
||||
<div id="thigmo" class="w3-padding-top">
|
||||
<div class="w3-row-padding">
|
||||
<div class="w3-col m6 s12">
|
||||
<label class="w3-text-teal"><b>{{ form.thigmotaxis_wall_dist_mm.label }}</b></label>
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">
|
||||
{% trans "Distance à la paroi considérée « près du bord » (mm)" %}
|
||||
</p>
|
||||
{{ form.thigmotaxis_wall_dist_mm }}
|
||||
{% if form.thigmotaxis_wall_dist_mm.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.thigmotaxis_wall_dist_mm.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- --- Phototactisme --- -->
|
||||
<div class="w3-container w3-padding-16 w3-border-bottom">
|
||||
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
|
||||
onclick="toggleSection('photo')">
|
||||
<span class="w3-large">💡</span>
|
||||
<b class="w3-margin-left">{% trans "Phototactisme" %}</b>
|
||||
<span class="w3-small w3-text-grey w3-margin-left">
|
||||
{% trans "Fuite de la lumière" %}
|
||||
</span>
|
||||
<span id="photo-icon" class="w3-right">▼</span>
|
||||
</button>
|
||||
<div id="photo" class="w3-hide w3-padding-top">
|
||||
<div class="w3-row-padding">
|
||||
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.photo_mode.label }}</b></label>
|
||||
{{ form.photo_mode }}
|
||||
{% if form.photo_mode.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.photo_mode.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.photo_strength.label }}</b></label>
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "0.0 = désactivé → 1.0 = fort" %}</p>
|
||||
{{ form.photo_strength }}
|
||||
{% if form.photo_strength.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.photo_strength.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.photo_x.label }}</b></label>
|
||||
{{ form.photo_x }}
|
||||
{% if form.photo_x.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.photo_x.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.photo_y.label }}</b></label>
|
||||
{{ form.photo_y }}
|
||||
{% if form.photo_y.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.photo_y.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- --- Chimiotactisme --- -->
|
||||
<div class="w3-container w3-padding-16 w3-border-bottom">
|
||||
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
|
||||
onclick="toggleSection('chemo')">
|
||||
<span class="w3-large">🍖</span>
|
||||
<b class="w3-margin-left">{% trans "Chimiotactisme" %}</b>
|
||||
<span class="w3-small w3-text-grey w3-margin-left">
|
||||
{% trans "Attraction vers une source de nourriture" %}
|
||||
</span>
|
||||
<span id="chemo-icon" class="w3-right">▼</span>
|
||||
</button>
|
||||
<div id="chemo" class="w3-hide w3-padding-top">
|
||||
<div class="w3-row-padding">
|
||||
|
||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.chemo_strength.label }}</b></label>
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "0.0 = désactivé → 1.0 = fort" %}</p>
|
||||
{{ form.chemo_strength }}
|
||||
{% if form.chemo_strength.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.chemo_strength.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.chemo_x.label }}</b></label>
|
||||
{{ form.chemo_x }}
|
||||
{% if form.chemo_x.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.chemo_x.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.chemo_y.label }}</b></label>
|
||||
{{ form.chemo_y }}
|
||||
{% if form.chemo_y.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.chemo_y.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.chemo_radius_mm.label }}</b></label>
|
||||
{{ form.chemo_radius_mm }}
|
||||
{% if form.chemo_radius_mm.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.chemo_radius_mm.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- --- Interactions inter-individus --- -->
|
||||
<div class="w3-container w3-padding-16">
|
||||
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
|
||||
onclick="toggleSection('social')">
|
||||
<span class="w3-large">🔀</span>
|
||||
<b class="w3-margin-left">{% trans "Interactions inter-individus" %}</b>
|
||||
<span class="w3-small w3-text-grey w3-margin-left">
|
||||
{% trans "Évitement et agrégation" %}
|
||||
</span>
|
||||
<span id="social-icon" class="w3-right">▼</span>
|
||||
</button>
|
||||
<div id="social" class="w3-hide w3-padding-top">
|
||||
<div class="w3-row-padding">
|
||||
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.avoid_radius_mm.label }}</b></label>
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "Rayon de répulsion courte portée (mm)" %}</p>
|
||||
{{ form.avoid_radius_mm }}
|
||||
{% if form.avoid_radius_mm.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.avoid_radius_mm.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-teal"><b>{{ form.aggreg_radius_mm.label }}</b></label>
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "Rayon d'attraction longue portée — doit être > rayon évitement (mm)" %}</p>
|
||||
{{ form.aggreg_radius_mm }}
|
||||
{% if form.aggreg_radius_mm.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.aggreg_radius_mm.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- fin card comportements -->
|
||||
|
||||
<!-- ============================================================
|
||||
Boutons d'action
|
||||
============================================================ -->
|
||||
<div class="w3-row-padding w3-margin-top">
|
||||
|
||||
<div class="w3-col m8 s12 w3-margin-bottom">
|
||||
<button type="submit" class="w3-button w3-teal w3-round w3-large w3-padding-large">
|
||||
{% if object %}
|
||||
💾 {% trans "Enregistrer les modifications" %}
|
||||
{% else %}
|
||||
➕ {% trans "Créer la configuration" %}
|
||||
{% endif %}
|
||||
</button>
|
||||
<a href="{% url 'planarian:experiment-list' %}"
|
||||
class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left">
|
||||
✖ {% trans "Annuler" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if object %}
|
||||
<div class="w3-col m4 s12 w3-right-align w3-margin-bottom">
|
||||
<a href="{% url 'planarian:export-csv' %}?experiment={{ object.experiment }}&well={{ object.well }}"
|
||||
class="w3-button w3-blue w3-round w3-large w3-padding-large">
|
||||
📥 {% trans "Exporter CSV" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
</form><!-- fin form -->
|
||||
|
||||
</div><!-- fin container -->
|
||||
|
||||
<!-- ============================================================
|
||||
Styles W3.CSS pour les champs de formulaire Django
|
||||
(Django génère des <input> bruts sans classes — on les stylise ici)
|
||||
============================================================ -->
|
||||
<style>
|
||||
/* Champs texte, number, select */
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 2px;
|
||||
background-color: #fafafa;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input[type="text"]:focus,
|
||||
input[type="number"]:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: #009688; /* teal W3.CSS */
|
||||
outline: none;
|
||||
background-color: #fff;
|
||||
}
|
||||
/* Champ en erreur */
|
||||
input.errorfield, select.errorfield, textarea.errorfield {
|
||||
border-color: #f44336;
|
||||
background-color: #fff8f8;
|
||||
}
|
||||
/* Label au-dessus du champ */
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
font-size: 14px;
|
||||
}
|
||||
/* Sections accordéon */
|
||||
[id$="-icon"] {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Bascule la visibilité d'une section accordéon.
|
||||
* @param {string} id - Identifiant de la section (sans le préfixe)
|
||||
*/
|
||||
function toggleSection(id) {
|
||||
const section = document.getElementById(id);
|
||||
const icon = document.getElementById(id + '-icon');
|
||||
if (section.classList.contains('w3-hide')) {
|
||||
section.classList.remove('w3-hide');
|
||||
if (icon) icon.textContent = '▲';
|
||||
} else {
|
||||
section.classList.add('w3-hide');
|
||||
if (icon) icon.textContent = '▼';
|
||||
}
|
||||
}
|
||||
|
||||
/* Ouvrir automatiquement les sections qui contiennent des erreurs */
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
['thigmo', 'photo', 'chemo', 'social'].forEach(function (id) {
|
||||
const section = document.getElementById(id);
|
||||
if (section && section.querySelector('.w3-text-red')) {
|
||||
section.classList.remove('w3-hide');
|
||||
const icon = document.getElementById(id + '-icon');
|
||||
if (icon) icon.textContent = '▲';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,334 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="w3-container w3-padding-32" style="max-width:1200px; margin:auto;">
|
||||
|
||||
<!-- En-tête -->
|
||||
<div class="w3-panel w3-teal w3-round-large w3-padding-16 w3-margin-bottom">
|
||||
<div class="w3-row">
|
||||
<div class="w3-col s12 m8 w3-bar-item">
|
||||
<span class="w3-xlarge">
|
||||
<i class="w3-margin-right">🔬</i>
|
||||
{% trans "Configurations d'expériences" %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w3-col s12 m4 w3-bar-item w3-right-align" style="padding-top:6px;">
|
||||
<a href="{% url 'planarian:experiment-new' %}"
|
||||
class="w3-button w3-white w3-round w3-text-teal">
|
||||
➕ {% trans "Nouvelle configuration" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Django -->
|
||||
{% for message in messages %}
|
||||
<div class="w3-panel w3-round
|
||||
{% if message.tags == 'success' %}w3-green
|
||||
{% elif message.tags == 'error' %}w3-red
|
||||
{% elif message.tags == 'warning' %}w3-orange
|
||||
{% else %}w3-blue{% endif %}">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Barre d'outils : recherche + import -->
|
||||
<div class="w3-row-padding w3-margin-bottom">
|
||||
|
||||
<!-- Recherche côté client -->
|
||||
<div class="w3-col m7 s12 w3-margin-bottom">
|
||||
<div class="w3-border w3-round" style="display:flex; align-items:center; background:#fafafa;">
|
||||
<span style="padding:0 10px; font-size:18px; color:#888;">🔍</span>
|
||||
<input type="text" id="search-input"
|
||||
placeholder="{% trans 'Filtrer par expérience, puits…' %}"
|
||||
class="w3-input w3-border-0"
|
||||
style="background:transparent;"
|
||||
oninput="filterTable(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Boutons import / export -->
|
||||
<div class="w3-col m5 s12 w3-right-align w3-margin-bottom">
|
||||
<a href="{% url 'planarian:import-params' %}"
|
||||
class="w3-button w3-blue-grey w3-round w3-margin-right">
|
||||
📂 {% trans "Importer CSV" %}
|
||||
</a>
|
||||
<a href="{% url 'planarian:export-csv' %}"
|
||||
class="w3-button w3-blue w3-round">
|
||||
📥 {% trans "Exporter CSV" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
Tableau des configurations
|
||||
============================================================ -->
|
||||
{% if configs %}
|
||||
|
||||
<!-- Compteur -->
|
||||
<p class="w3-text-grey w3-small" id="row-count">
|
||||
{{ configs|length }} {% trans "configuration(s)" %}
|
||||
</p>
|
||||
|
||||
<div class="w3-responsive w3-card-4 w3-round-large">
|
||||
<table class="w3-table w3-striped w3-hoverable w3-bordered" id="config-table">
|
||||
<thead>
|
||||
<tr class="w3-teal">
|
||||
<th onclick="sortTable(0)" class="sortable" title="{% trans 'Trier' %}">
|
||||
{% trans "Expérience" %} <span class="sort-icon">↕</span>
|
||||
</th>
|
||||
<th onclick="sortTable(1)" class="sortable" title="{% trans 'Trier' %}">
|
||||
{% trans "Puits" %} <span class="sort-icon">↕</span>
|
||||
</th>
|
||||
<th onclick="sortTable(2)" class="sortable w3-hide-small" title="{% trans 'Trier' %}">
|
||||
{% trans "px/mm" %} <span class="sort-icon">↕</span>
|
||||
</th>
|
||||
<th class="w3-hide-small">{% trans "FPS" %}</th>
|
||||
<th class="w3-hide-small">{% trans "Seuils (mm/s)" %}</th>
|
||||
<th class="w3-hide-small">{% trans "Comportements" %}</th>
|
||||
<th onclick="sortTable(6)" class="sortable w3-hide-small" title="{% trans 'Trier' %}">
|
||||
{% trans "Créé le" %} <span class="sort-icon">↕</span>
|
||||
</th>
|
||||
<th>{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="config-tbody">
|
||||
{% for cfg in configs %}
|
||||
<tr class="config-row">
|
||||
|
||||
<!-- Expérience -->
|
||||
<td>
|
||||
<b>{{ cfg.experiment }}</b>
|
||||
{% if cfg.description %}
|
||||
<br><span class="w3-small w3-text-grey">{{ cfg.description|truncatechars:40 }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Puits -->
|
||||
<td>
|
||||
<span class="w3-tag w3-teal w3-round">{{ cfg.well }}</span>
|
||||
</td>
|
||||
|
||||
<!-- px/mm -->
|
||||
<td class="w3-hide-small">{{ cfg.px_per_mm }}</td>
|
||||
|
||||
<!-- FPS -->
|
||||
<td class="w3-hide-small">{{ cfg.fps }}</td>
|
||||
|
||||
<!-- Seuils de mobilité -->
|
||||
<td class="w3-hide-small">
|
||||
<span class="w3-tag w3-red w3-round w3-small"
|
||||
title="{% trans 'Immobile' %}">
|
||||
< {{ cfg.thresh_immobile }}
|
||||
</span>
|
||||
<span class="w3-tag w3-orange w3-round w3-small"
|
||||
title="{% trans 'Mobile' %}">
|
||||
< {{ cfg.thresh_mobile }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Comportements actifs -->
|
||||
<td class="w3-hide-small">
|
||||
{% if cfg.thigmotaxis_wall_dist_mm > 0 %}
|
||||
<span class="w3-tag w3-blue-grey w3-round w3-small w3-margin-right"
|
||||
title="{% trans 'Thigmotactisme actif' %}">🫧</span>
|
||||
{% endif %}
|
||||
{% if cfg.photo_mode != 'none' and cfg.photo_strength > 0 %}
|
||||
<span class="w3-tag w3-yellow w3-round w3-small w3-margin-right"
|
||||
title="{% trans 'Phototactisme actif' %} ({{ cfg.photo_mode }})">💡</span>
|
||||
{% endif %}
|
||||
{% if cfg.chemo_strength > 0 %}
|
||||
<span class="w3-tag w3-green w3-round w3-small w3-margin-right"
|
||||
title="{% trans 'Chimiotactisme actif' %}">🍖</span>
|
||||
{% endif %}
|
||||
{% if cfg.avoid_radius_mm > 0 or cfg.aggreg_radius_mm > 0 %}
|
||||
<span class="w3-tag w3-purple w3-round w3-small"
|
||||
title="{% trans 'Interactions inter-individus' %}">🔀</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Date de création -->
|
||||
<td class="w3-hide-small w3-small w3-text-grey">
|
||||
{{ cfg.created_at|date:"d/m/Y H:i" }}
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td style="white-space:nowrap;">
|
||||
<a href="{% url 'planarian:experiment-edit' cfg.pk %}"
|
||||
class="w3-button w3-small w3-teal w3-round"
|
||||
title="{% trans 'Modifier' %}">✏</a>
|
||||
<a href="{% url 'planarian:export-csv' %}?experiment={{ cfg.experiment }}&well={{ cfg.well }}"
|
||||
class="w3-button w3-small w3-blue w3-round"
|
||||
title="{% trans 'Exporter CSV' %}">📥</a>
|
||||
<button type="button"
|
||||
class="w3-button w3-small w3-red w3-round"
|
||||
title="{% trans 'Supprimer' %}"
|
||||
onclick="confirmDelete('{{ cfg.pk }}', '{{ cfg.experiment }}', '{{ cfg.well }}')">
|
||||
🗑
|
||||
</button>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div><!-- fin responsive -->
|
||||
|
||||
<!-- Message "aucun résultat" (filtrage JS) -->
|
||||
<div id="no-results" class="w3-panel w3-pale-yellow w3-round w3-margin-top" style="display:none;">
|
||||
<p>{% trans "Aucune configuration ne correspond à la recherche." %}</p>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Liste vide -->
|
||||
<div class="w3-panel w3-pale-blue w3-round-large w3-padding-32" style="text-align:center;">
|
||||
<p style="font-size:3rem; margin:0;">🔬</p>
|
||||
<h3>{% trans "Aucune configuration pour l'instant." %}</h3>
|
||||
<p class="w3-text-grey">
|
||||
{% trans "Créez une première configuration ou importez un fichier CSV." %}
|
||||
</p>
|
||||
<a href="{% url 'planarian:experiment-new' %}"
|
||||
class="w3-button w3-teal w3-round w3-large w3-margin-right">
|
||||
➕ {% trans "Nouvelle configuration" %}
|
||||
</a>
|
||||
<a href="{% url 'planarian:import-params' %}"
|
||||
class="w3-button w3-blue-grey w3-round w3-large">
|
||||
📂 {% trans "Importer CSV" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div><!-- fin container -->
|
||||
|
||||
<!-- ============================================================
|
||||
Modal de confirmation de suppression
|
||||
============================================================ -->
|
||||
<div id="delete-modal" class="w3-modal">
|
||||
<div class="w3-modal-content w3-round-large w3-card-4" style="max-width:420px; margin:auto; margin-top:10%;">
|
||||
<header class="w3-container w3-red w3-round-top-large">
|
||||
<h3 class="w3-text-white">🗑 {% trans "Confirmer la suppression" %}</h3>
|
||||
</header>
|
||||
<div class="w3-container w3-padding-24">
|
||||
<p id="delete-msg"></p>
|
||||
<form id="delete-form" method="post" action="">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="_method" value="delete">
|
||||
<button type="submit" class="w3-button w3-red w3-round w3-margin-right">
|
||||
🗑 {% trans "Supprimer" %}
|
||||
</button>
|
||||
<button type="button" class="w3-button w3-light-grey w3-round"
|
||||
onclick="document.getElementById('delete-modal').style.display='none'">
|
||||
✖ {% trans "Annuler" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
Styles
|
||||
============================================================ -->
|
||||
<style>
|
||||
/* En-têtes de colonnes triables */
|
||||
th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
th.sortable:hover {
|
||||
background-color: #00796b;
|
||||
}
|
||||
.sort-icon {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
margin-left: 4px;
|
||||
}
|
||||
th.sort-asc .sort-icon::after { content: " ▲"; opacity: 1; }
|
||||
th.sort-desc .sort-icon::after { content: " ▼"; opacity: 1; }
|
||||
|
||||
/* Tableau responsive */
|
||||
#config-table td, #config-table th {
|
||||
vertical-align: middle;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- ============================================================
|
||||
JavaScript : filtrage + tri + modal suppression
|
||||
============================================================ -->
|
||||
<script>
|
||||
// --- Filtrage dynamique ---
|
||||
function filterTable(query) {
|
||||
const q = query.toLowerCase().trim();
|
||||
const rows = document.querySelectorAll('.config-row');
|
||||
const noRes = document.getElementById('no-results');
|
||||
const count = document.getElementById('row-count');
|
||||
let visible = 0;
|
||||
|
||||
rows.forEach(function (row) {
|
||||
const text = row.textContent.toLowerCase();
|
||||
if (!q || text.includes(q)) {
|
||||
row.style.display = '';
|
||||
visible++;
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
noRes.style.display = (visible === 0 && q) ? 'block' : 'none';
|
||||
if (count) {
|
||||
count.textContent = visible + ' {% trans "configuration(s)" %}';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tri de colonnes ---
|
||||
let sortDir = {};
|
||||
|
||||
function sortTable(colIdx) {
|
||||
const tbody = document.getElementById('config-tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('.config-row'));
|
||||
const th = document.querySelectorAll('#config-table thead th')[colIdx];
|
||||
|
||||
// Alterner ascendant / descendant
|
||||
sortDir[colIdx] = sortDir[colIdx] === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
// Réinitialiser les classes de toutes les colonnes
|
||||
document.querySelectorAll('#config-table thead th').forEach(function (h) {
|
||||
h.classList.remove('sort-asc', 'sort-desc');
|
||||
});
|
||||
th.classList.add(sortDir[colIdx] === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
rows.sort(function (a, b) {
|
||||
const cellA = a.querySelectorAll('td')[colIdx]?.textContent.trim() || '';
|
||||
const cellB = b.querySelectorAll('td')[colIdx]?.textContent.trim() || '';
|
||||
// Tri numérique si possible, sinon alphabétique
|
||||
const numA = parseFloat(cellA);
|
||||
const numB = parseFloat(cellB);
|
||||
const cmp = (!isNaN(numA) && !isNaN(numB))
|
||||
? numA - numB
|
||||
: cellA.localeCompare(cellB, '{{ LANGUAGE_CODE|default:"fr" }}');
|
||||
return sortDir[colIdx] === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
rows.forEach(function (row) { tbody.appendChild(row); });
|
||||
}
|
||||
|
||||
// --- Modal de suppression ---
|
||||
function confirmDelete(pk, experiment, well) {
|
||||
document.getElementById('delete-msg').textContent =
|
||||
'{% trans "Supprimer la configuration" %} ' + experiment + ' / ' + well + ' ?';
|
||||
document.getElementById('delete-form').action =
|
||||
'{% url "planarian:experiment-list" %}' + pk + '/delete/';
|
||||
document.getElementById('delete-modal').style.display = 'block';
|
||||
}
|
||||
|
||||
// Fermer le modal en cliquant en dehors
|
||||
window.onclick = function (e) {
|
||||
const modal = document.getElementById('delete-modal');
|
||||
if (e.target === modal) modal.style.display = 'none';
|
||||
};
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,254 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="w3-container w3-padding-32" style="max-width:760px; margin:auto;">
|
||||
|
||||
<!-- En-tête -->
|
||||
<div class="w3-panel w3-blue w3-round-large w3-padding-16 w3-margin-bottom">
|
||||
<div class="w3-row">
|
||||
<div class="w3-col s12 m8 w3-bar-item">
|
||||
<span class="w3-xlarge">
|
||||
📥 {% trans "Exporter les données vers CSV" %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w3-col s12 m4 w3-bar-item w3-right-align" style="padding-top:6px;">
|
||||
<a href="{% url 'planarian:experiment-list' %}"
|
||||
class="w3-button w3-white w3-round w3-text-blue">
|
||||
← {% trans "Retour à la liste" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Django -->
|
||||
{% for message in messages %}
|
||||
<div class="w3-panel w3-round
|
||||
{% if message.tags == 'success' %}w3-green
|
||||
{% elif message.tags == 'error' %}w3-red
|
||||
{% elif message.tags == 'warning' %}w3-orange
|
||||
{% else %}w3-blue{% endif %}">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Explication -->
|
||||
<div class="w3-panel w3-pale-blue w3-round w3-margin-bottom">
|
||||
<p>
|
||||
{% trans "Sélectionnez l'expérience, le puits et le type d'enregistrement à exporter." %}
|
||||
{% trans "Le fichier CSV sera généré depuis ReductStore et téléchargé directement." %}
|
||||
</p>
|
||||
<p class="w3-small w3-text-grey" style="margin:0;">
|
||||
{% trans "Colonnes exportées compatibles EthoVision XT : distance, vitesse, états de mobilité, thigmotactisme." %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire -->
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="w3-panel w3-red w3-round">
|
||||
{% for error in form.non_field_errors %}
|
||||
<p>⚠ {{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="w3-card-4 w3-round-large">
|
||||
<header class="w3-container w3-blue w3-round-top-large">
|
||||
<h3 class="w3-text-white">{% trans "Paramètres d'export" %}</h3>
|
||||
</header>
|
||||
<div class="w3-container w3-padding-24">
|
||||
|
||||
<!-- Ligne 1 : expérience + puits -->
|
||||
<div class="w3-row-padding w3-margin-bottom">
|
||||
|
||||
<div class="w3-col m7 s12 w3-margin-bottom">
|
||||
<label class="w3-text-blue"><b>{{ form.experiment.label }}</b></label>
|
||||
{% if form.experiment.help_text %}
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.experiment.help_text }}</p>
|
||||
{% endif %}
|
||||
<!-- Saisie libre ou sélection depuis les configs existantes -->
|
||||
<input list="experiment-list" name="experiment" id="id_experiment"
|
||||
value="{{ form.experiment.value|default:'' }}"
|
||||
class="w3-input w3-border w3-round"
|
||||
placeholder="{% trans 'Ex : exp_2026_04_25' %}">
|
||||
<datalist id="experiment-list">
|
||||
{% for cfg in experiment_choices %}
|
||||
<option value="{{ cfg }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
{% if form.experiment.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.experiment.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m5 s12 w3-margin-bottom">
|
||||
<label class="w3-text-blue"><b>{{ form.well.label }}</b></label>
|
||||
<input list="well-list" name="well" id="id_well"
|
||||
value="{{ form.well.value|default:'' }}"
|
||||
class="w3-input w3-border w3-round"
|
||||
placeholder="{% trans 'Ex : A1' %}">
|
||||
<datalist id="well-list">
|
||||
{% for w in well_choices %}
|
||||
<option value="{{ w }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
{% if form.well.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.well.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Ligne 2 : planaire + type d'enregistrement -->
|
||||
<div class="w3-row-padding w3-margin-bottom">
|
||||
|
||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||
<label class="w3-text-blue"><b>{{ form.planarian.label }}</b></label>
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">
|
||||
{% trans "Index du planaire dans le puits (commence à 0)" %}
|
||||
</p>
|
||||
{{ form.planarian }}
|
||||
{% if form.planarian.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.planarian.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m8 s12 w3-margin-bottom">
|
||||
<label class="w3-text-blue"><b>{{ form.record_type.label }}</b></label>
|
||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">
|
||||
{% trans "Frame par frame : une ligne par image. Résumé : métriques agrégées de la session." %}
|
||||
</p>
|
||||
<!-- Radio buttons stylisés W3 -->
|
||||
<div class="w3-row-padding" style="margin-top:6px;">
|
||||
{% for radio in form.record_type %}
|
||||
<div class="w3-col m6 s12">
|
||||
<label class="w3-button w3-block w3-round w3-border
|
||||
{% if radio.data.value == form.record_type.value %}w3-blue w3-text-white{% else %}w3-white{% endif %}"
|
||||
style="text-align:center; margin-bottom:6px; cursor:pointer;">
|
||||
{{ radio.tag }} {{ radio.choice_label }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.record_type.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.record_type.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Ligne 3 : plage temporelle (optionnel) -->
|
||||
<div class="w3-border-top w3-padding-top w3-margin-top">
|
||||
<p class="w3-text-blue-grey">
|
||||
<b>{% trans "Plage temporelle" %}</b>
|
||||
<span class="w3-small w3-text-grey w3-margin-left">
|
||||
{% trans "(optionnel — laisser vide pour exporter toute la session)" %}
|
||||
</span>
|
||||
</p>
|
||||
<div class="w3-row-padding">
|
||||
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-blue-grey">{{ form.start_dt.label }}</label>
|
||||
{{ form.start_dt }}
|
||||
{% if form.start_dt.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.start_dt.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||
<label class="w3-text-blue-grey">{{ form.stop_dt.label }}</label>
|
||||
{{ form.stop_dt }}
|
||||
{% if form.stop_dt.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.stop_dt.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- fin padding -->
|
||||
</div><!-- fin card -->
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="w3-row-padding w3-margin-top">
|
||||
<div class="w3-col s12">
|
||||
<button type="submit" class="w3-button w3-blue w3-round w3-large w3-padding-large">
|
||||
📥 {% trans "Générer et télécharger le CSV" %}
|
||||
</button>
|
||||
<a href="{% url 'planarian:experiment-list' %}"
|
||||
class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left">
|
||||
✖ {% trans "Annuler" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- Rappel des colonnes exportées -->
|
||||
<div class="w3-card w3-round-large w3-margin-top">
|
||||
<header class="w3-container w3-blue-grey w3-round-top-large">
|
||||
<h4 class="w3-text-white" style="margin:8px 0;">
|
||||
{% trans "Colonnes du fichier CSV exporté" %}
|
||||
</h4>
|
||||
</header>
|
||||
<div class="w3-container w3-padding-16">
|
||||
<div class="w3-row-padding">
|
||||
|
||||
<div class="w3-col m6 s12">
|
||||
<p class="w3-text-blue-grey w3-small"><b>{% trans "Identification / position" %}</b></p>
|
||||
<ul class="w3-ul w3-small w3-border w3-round">
|
||||
<li><code>timestamp</code></li>
|
||||
<li><code>x_mm</code> / <code>y_mm</code></li>
|
||||
<li><code>cx_px</code> / <code>cy_px</code></li>
|
||||
<li><code>area_px</code></li>
|
||||
<li><code>axial_pos</code> / <code>axial_speed</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="w3-col m6 s12">
|
||||
<p class="w3-text-blue-grey w3-small"><b>{% trans "Métriques EthoVision XT" %}</b></p>
|
||||
<ul class="w3-ul w3-small w3-border w3-round">
|
||||
<li><code>velocity_mm_s</code> — {% trans "vitesse instantanée" %}</li>
|
||||
<li><code>total_distance_mm</code> — {% trans "distance cumulée" %}</li>
|
||||
<li><code>moving</code> / <code>duration_moving_s</code></li>
|
||||
<li><code>mobility_state</code> — {% trans "Immobile / Mobile / Highly mobile" %}</li>
|
||||
<li><code>dist_to_wall_mm</code> / <code>near_wall</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- fin container -->
|
||||
|
||||
<style>
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="datetime-local"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
box-sizing: border-box;
|
||||
background-color: #fafafa;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
border-color: #2196F3;
|
||||
outline: none;
|
||||
background-color: #fff;
|
||||
}
|
||||
/* Masquer le radio natif, laisser le label stylisé */
|
||||
.w3-button input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,326 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="w3-container w3-padding-32" style="max-width:760px; margin:auto;">
|
||||
|
||||
<!-- En-tête -->
|
||||
<div class="w3-panel w3-blue-grey w3-round-large w3-padding-16 w3-margin-bottom">
|
||||
<div class="w3-row">
|
||||
<div class="w3-col s12 m8 w3-bar-item">
|
||||
<span class="w3-xlarge">
|
||||
📂 {% trans "Importer des configurations depuis CSV" %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w3-col s12 m4 w3-bar-item w3-right-align" style="padding-top:6px;">
|
||||
<a href="{% url 'planarian:experiment-list' %}"
|
||||
class="w3-button w3-white w3-round w3-text-blue-grey">
|
||||
← {% trans "Retour à la liste" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Django -->
|
||||
{% for message in messages %}
|
||||
<div class="w3-panel w3-round
|
||||
{% if message.tags == 'success' %}w3-green
|
||||
{% elif message.tags == 'error' %}w3-red
|
||||
{% elif message.tags == 'warning' %}w3-orange
|
||||
{% else %}w3-blue{% endif %}">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Formulaire d'import -->
|
||||
<form method="post" enctype="multipart/form-data" novalidate id="import-form">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="w3-panel w3-red w3-round">
|
||||
{% for error in form.non_field_errors %}
|
||||
<p>⚠ {{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
||||
<header class="w3-container w3-blue-grey w3-round-top-large">
|
||||
<h3 class="w3-text-white">{% trans "Fichier CSV à importer" %}</h3>
|
||||
</header>
|
||||
<div class="w3-container w3-padding-24">
|
||||
|
||||
<!-- Zone de dépôt de fichier -->
|
||||
<div id="drop-zone"
|
||||
class="w3-border w3-border-blue-grey w3-round-large w3-padding-32"
|
||||
style="border-style:dashed !important; border-width:2px !important;
|
||||
text-align:center; cursor:pointer; transition:background 0.2s;"
|
||||
onclick="document.getElementById('id_csv_file').click()"
|
||||
ondragover="dragOver(event)"
|
||||
ondragleave="dragLeave(event)"
|
||||
ondrop="dropFile(event)">
|
||||
|
||||
<p style="font-size:2.5rem; margin:0;">📄</p>
|
||||
<p id="drop-label" class="w3-text-blue-grey">
|
||||
<b>{% trans "Glissez-déposez votre fichier CSV ici" %}</b><br>
|
||||
<span class="w3-small w3-text-grey">
|
||||
{% trans "ou cliquez pour sélectionner" %}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<!-- Input fichier caché -->
|
||||
<input type="file" id="id_csv_file" name="csv_file"
|
||||
accept=".csv,text/csv"
|
||||
style="display:none;"
|
||||
onchange="fileSelected(this)">
|
||||
</div>
|
||||
|
||||
{% if form.csv_file.errors %}
|
||||
<div class="w3-panel w3-red w3-round w3-margin-top">
|
||||
{% for error in form.csv_file.errors %}
|
||||
<p>⚠ {{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Aperçu du fichier sélectionné -->
|
||||
<div id="file-info" class="w3-panel w3-pale-green w3-round w3-margin-top" style="display:none;">
|
||||
<p id="file-name" class="w3-text-green"><b></b></p>
|
||||
<p id="file-size" class="w3-small w3-text-grey" style="margin:0;"></p>
|
||||
</div>
|
||||
|
||||
<!-- Option : écraser -->
|
||||
<div class="w3-margin-top w3-padding-top w3-border-top">
|
||||
<label class="w3-text-blue-grey" style="cursor:pointer; display:flex; align-items:center; gap:10px;">
|
||||
{{ form.overwrite }}
|
||||
<span>
|
||||
<b>{{ form.overwrite.label }}</b><br>
|
||||
<span class="w3-small w3-text-grey">
|
||||
{% trans "Si décoché, les configurations déjà existantes (même experiment + well) seront ignorées." %}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{% if form.overwrite.errors %}
|
||||
<span class="w3-text-red w3-small">{{ form.overwrite.errors|join:", " }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="w3-row-padding w3-margin-bottom">
|
||||
<div class="w3-col s12">
|
||||
<button type="submit" id="submit-btn"
|
||||
class="w3-button w3-blue-grey w3-round w3-large w3-padding-large"
|
||||
disabled>
|
||||
📂 {% trans "Importer" %}
|
||||
</button>
|
||||
<a href="{% url 'planarian:experiment-list' %}"
|
||||
class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left">
|
||||
✖ {% trans "Annuler" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- ============================================================
|
||||
Format attendu du CSV
|
||||
============================================================ -->
|
||||
<div class="w3-card-4 w3-round-large">
|
||||
<header class="w3-container w3-teal w3-round-top-large">
|
||||
<h3 class="w3-text-white">{% trans "Format du fichier CSV" %}</h3>
|
||||
</header>
|
||||
<div class="w3-container w3-padding-24">
|
||||
|
||||
<!-- Colonnes obligatoires -->
|
||||
<p><b>{% trans "Colonnes obligatoires" %}</b></p>
|
||||
<div class="w3-responsive w3-margin-bottom">
|
||||
<table class="w3-table w3-bordered w3-small">
|
||||
<thead>
|
||||
<tr class="w3-teal">
|
||||
<th>{% trans "Colonne" %}</th>
|
||||
<th>{% trans "Type" %}</th>
|
||||
<th>{% trans "Exemple" %}</th>
|
||||
<th>{% trans "Description" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>experiment</code></td>
|
||||
<td>{% trans "texte" %}</td>
|
||||
<td><code>exp_2026_04_25</code></td>
|
||||
<td>{% trans "Identifiant unique de l'expérience" %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>well</code></td>
|
||||
<td>{% trans "texte" %}</td>
|
||||
<td><code>A1</code></td>
|
||||
<td>{% trans "Identifiant du puits" %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>px_per_mm</code></td>
|
||||
<td>{% trans "flottant" %}</td>
|
||||
<td><code>26.25</code></td>
|
||||
<td>{% trans "Calibration optique (pixels/mm)" %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>fps</code></td>
|
||||
<td>{% trans "flottant" %}</td>
|
||||
<td><code>10.0</code></td>
|
||||
<td>{% trans "Fréquence de capture (images/s)" %}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Colonnes optionnelles -->
|
||||
<p><b>{% trans "Colonnes optionnelles" %}</b>
|
||||
<span class="w3-small w3-text-grey">{% trans "(valeurs par défaut utilisées si absentes)" %}</span>
|
||||
</p>
|
||||
<div class="w3-responsive w3-margin-bottom">
|
||||
<table class="w3-table w3-bordered w3-small w3-striped">
|
||||
<thead>
|
||||
<tr class="w3-blue-grey">
|
||||
<th>{% trans "Colonne" %}</th>
|
||||
<th>{% trans "Défaut" %}</th>
|
||||
<th>{% trans "Description" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>well_radius_mm</code></td> <td>8.0</td> <td>{% trans "Rayon du puits (mm)" %}</td></tr>
|
||||
<tr><td><code>thresh_immobile</code></td> <td>0.2</td> <td>{% trans "Seuil Immobile EthoVision (mm/s)" %}</td></tr>
|
||||
<tr><td><code>thresh_mobile</code></td> <td>1.5</td> <td>{% trans "Seuil Mobile EthoVision (mm/s)" %}</td></tr>
|
||||
<tr><td><code>tube_axis</code></td> <td>vertical</td> <td>{% trans "Axe du tube : vertical | horizontal" %}</td></tr>
|
||||
<tr><td><code>min_area_px</code></td> <td>20</td> <td>{% trans "Surface min de détection (px²)" %}</td></tr>
|
||||
<tr><td><code>planarian_count</code></td> <td>1</td> <td>{% trans "Nombre de planaires par puits" %}</td></tr>
|
||||
<tr><td><code>photo_mode</code></td> <td>none</td> <td>{% trans "Phototactisme : none | fixed | sine | radial" %}</td></tr>
|
||||
<tr><td><code>photo_strength</code></td> <td>0.0</td> <td>{% trans "Intensité phototactisme (0-1)" %}</td></tr>
|
||||
<tr><td><code>chemo_strength</code></td> <td>0.0</td> <td>{% trans "Intensité chimiotactisme (0-1)" %}</td></tr>
|
||||
<tr><td><code>chemo_radius_mm</code></td> <td>2.0</td> <td>{% trans "Rayon zone nourriture (mm)" %}</td></tr>
|
||||
<tr><td><code>avoid_radius_mm</code></td> <td>3.0</td> <td>{% trans "Rayon évitement inter-individus (mm)" %}</td></tr>
|
||||
<tr><td><code>aggreg_radius_mm</code></td> <td>6.0</td> <td>{% trans "Rayon agrégation inter-individus (mm)" %}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Exemple de fichier -->
|
||||
<p><b>{% trans "Exemple minimal" %}</b></p>
|
||||
<div class="w3-code w3-round" style="font-size:12px; overflow-x:auto;">
|
||||
experiment,well,px_per_mm,fps,thresh_immobile,thresh_mobile,photo_mode<br>
|
||||
exp_ctrl_01,A1,26.25,10,0.2,1.5,none<br>
|
||||
exp_ctrl_01,A2,26.25,10,0.2,1.5,none<br>
|
||||
exp_light_01,B1,26.25,10,0.2,1.5,fixed<br>
|
||||
exp_light_01,B2,26.25,10,0.2,1.5,fixed<br>
|
||||
</div>
|
||||
|
||||
<!-- Téléchargement du template -->
|
||||
<div class="w3-margin-top">
|
||||
<a href="{% url 'planarian:experiment-list' %}?export_template=1"
|
||||
class="w3-button w3-teal w3-round w3-small">
|
||||
⬇ {% trans "Télécharger un template CSV vide" %}
|
||||
</a>
|
||||
<span class="w3-small w3-text-grey w3-margin-left">
|
||||
{% trans "Ou exportez vos configurations existantes depuis l'admin Django." %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- fin container -->
|
||||
|
||||
<style>
|
||||
/* Zone de dépôt active */
|
||||
#drop-zone.dragover {
|
||||
background-color: #e3f2fd;
|
||||
border-color: #1565c0 !important;
|
||||
}
|
||||
/* Checkbox alignée */
|
||||
input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Bouton désactivé */
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// --- Drag & drop ---
|
||||
function dragOver(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('drop-zone').classList.add('dragover');
|
||||
}
|
||||
|
||||
function dragLeave(e) {
|
||||
document.getElementById('drop-zone').classList.remove('dragover');
|
||||
}
|
||||
|
||||
function dropFile(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('drop-zone').classList.remove('dragover');
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const input = document.getElementById('id_csv_file');
|
||||
// Transfert du fichier vers l'input natif via DataTransfer
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(files[0]);
|
||||
input.files = dt.files;
|
||||
showFileInfo(files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sélection via clic ---
|
||||
function fileSelected(input) {
|
||||
if (input.files.length > 0) {
|
||||
showFileInfo(input.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Affichage du fichier sélectionné ---
|
||||
function showFileInfo(file) {
|
||||
const info = document.getElementById('file-info');
|
||||
const nameEl = document.getElementById('file-name').querySelector('b');
|
||||
const sizeEl = document.getElementById('file-size');
|
||||
const label = document.getElementById('drop-label');
|
||||
const btn = document.getElementById('submit-btn');
|
||||
|
||||
// Vérification extension
|
||||
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||
alert('{% trans "Le fichier doit être au format .csv" %}');
|
||||
return;
|
||||
}
|
||||
|
||||
nameEl.textContent = '📄 ' + file.name;
|
||||
sizeEl.textContent = formatSize(file.size);
|
||||
info.style.display = 'block';
|
||||
label.innerHTML = '<b>{% trans "Fichier prêt à importer" %}</b>';
|
||||
btn.disabled = false;
|
||||
|
||||
// Aperçu des premières lignes
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
const lines = e.target.result.split('\n').slice(0, 4);
|
||||
const preview = document.getElementById('csv-preview');
|
||||
if (preview) {
|
||||
preview.textContent = lines.join('\n');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file, 'utf-8');
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,21 @@
|
||||
# planarian/urls.py
|
||||
|
||||
from django.urls import path
|
||||
from planarian import views
|
||||
|
||||
app_name = "planarian"
|
||||
|
||||
urlpatterns = [
|
||||
# Configurations expériences
|
||||
path("experiments/", views.ExperimentConfigListView.as_view(), name="experiment-list"),
|
||||
path("experiments/new/", views.ExperimentConfigFormView.as_view(), name="experiment-new"),
|
||||
path("experiments/<int:pk>/",views.ExperimentConfigFormView.as_view(), name="experiment-edit"),
|
||||
|
||||
# Import / export
|
||||
path("import/", views.ImportParamsView.as_view(), name="import-params"),
|
||||
path("export/", views.ExportCsvView.as_view(), name="export-csv"),
|
||||
|
||||
# API JSON pour le front-end
|
||||
path("api/tracking/", views.TrackingDataView.as_view(), name="tracking-data-api"),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# planarian/views.py
|
||||
|
||||
#import asyncio
|
||||
import logging
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect #, render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import FormView, ListView
|
||||
|
||||
|
||||
from .forms import CsvImportForm, ExperimentConfigForm, ExportCsvForm
|
||||
from .models import ExperimentConfig
|
||||
from modules.planarian_metrics import ExperimentParams, ReductStoreClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_reduct_client() -> ReductStoreClient:
|
||||
"""Instancie le client ReductStore depuis les settings Django."""
|
||||
return ReductStoreClient(
|
||||
url = getattr(settings, "REDUCTSTORE_URL", "http://localhost:8383"),
|
||||
token = getattr(settings, "REDUCTSTORE_TOKEN", ""),
|
||||
bucket = getattr(settings, "REDUCTSTORE_BUCKET", "planarian_metrics"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vue : liste des configurations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ExperimentConfigListView(ListView):
|
||||
"""Liste toutes les configurations expériences."""
|
||||
|
||||
model = ExperimentConfig
|
||||
template_name = "planarian/experiment_list.html"
|
||||
context_object_name = "configs"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vue : création / modification d'une configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ExperimentConfigFormView(FormView):
|
||||
"""Formulaire de saisie des paramètres d'une expérience."""
|
||||
|
||||
template_name = "planarian/experiment_form.html"
|
||||
form_class = ExperimentConfigForm
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
pk = self.kwargs.get("pk")
|
||||
if pk:
|
||||
instance = get_object_or_404(ExperimentConfig, pk=pk)
|
||||
return ExperimentConfigForm(self.request.POST or None, instance=instance)
|
||||
return ExperimentConfigForm(self.request.POST or None)
|
||||
|
||||
def form_valid(self, form):
|
||||
form.save()
|
||||
messages.success(self.request, _("Configuration sauvegardée."))
|
||||
return redirect("planarian:experiment-list")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vue : import CSV de paramètres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ImportParamsView(FormView):
|
||||
"""
|
||||
Import de configurations d'expérience depuis un fichier CSV.
|
||||
Une ligne CSV = un puits = un ExperimentConfig.
|
||||
|
||||
Colonnes CSV obligatoires : experiment, well, px_per_mm, fps
|
||||
Toutes les autres colonnes correspondent aux champs du modèle.
|
||||
"""
|
||||
|
||||
template_name = "planarian/import_params.html"
|
||||
form_class = CsvImportForm
|
||||
|
||||
def form_valid(self, form):
|
||||
rows = form.csv_rows
|
||||
overwrite = form.cleaned_data["overwrite"]
|
||||
created = 0
|
||||
updated = 0
|
||||
errors = 0
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
params = ExperimentParams.from_csv_row(row)
|
||||
d = params.to_dict()
|
||||
|
||||
obj, is_new = ExperimentConfig.objects.get_or_create(
|
||||
experiment = d["experiment"],
|
||||
well = d["well"],
|
||||
)
|
||||
|
||||
if is_new or overwrite:
|
||||
for k, v in d.items():
|
||||
if k not in ("experiment", "well") and hasattr(obj, k):
|
||||
setattr(obj, k, v)
|
||||
obj.save()
|
||||
if is_new:
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Ligne ignorée ({row}): {e}")
|
||||
errors += 1
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
_("Import terminé : %(c)d créés, %(u)d mis à jour, %(e)d erreurs.")
|
||||
% {"c": created, "u": updated, "e": errors},
|
||||
)
|
||||
return redirect("planarian:experiment-list")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vue : export CSV depuis ReductStore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ExportCsvView(FormView):
|
||||
"""
|
||||
Export des données de tracking depuis ReductStore vers un fichier CSV.
|
||||
Retourne le fichier en téléchargement HTTP.
|
||||
"""
|
||||
|
||||
template_name = "planarian/export_csv.html"
|
||||
form_class = ExportCsvForm
|
||||
|
||||
def form_valid(self, form):
|
||||
d = form.cleaned_data
|
||||
|
||||
@async_to_sync
|
||||
async def _do_export():
|
||||
client = _get_reduct_client()
|
||||
await client.connect()
|
||||
try:
|
||||
csv_content, n = await client.export_csv_response(
|
||||
experiment = d["experiment"],
|
||||
well = d["well"],
|
||||
planarian = d["planarian"],
|
||||
record_type = d["record_type"],
|
||||
start = d.get("start_dt"),
|
||||
stop = d.get("stop_dt"),
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
return csv_content, n
|
||||
|
||||
csv_content, n = _do_export()
|
||||
|
||||
if not csv_content:
|
||||
messages.warning(self.request, _("Aucune donnée trouvée."))
|
||||
return self.form_invalid(form)
|
||||
|
||||
filename = (
|
||||
f"{d['experiment']}_{d['well']}_planaire{d['planarian']}"
|
||||
f"_{d['record_type']}.csv"
|
||||
)
|
||||
response = HttpResponse(csv_content, content_type="text/csv; charset=utf-8")
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
messages.success(self.request, _("%(n)d lignes exportées.") % {"n": n})
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vue API JSON : données de tracking (pour polling front-end)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TrackingDataView(View):
|
||||
"""
|
||||
API JSON retournant les métriques de tracking d'un planaire.
|
||||
Utilisable pour un affichage temps réel ou un graphe front-end.
|
||||
|
||||
GET /tracking-data/?experiment=X&well=Y&planarian=0&record_type=frame
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
experiment = request.GET.get("experiment", "")
|
||||
well = request.GET.get("well", "")
|
||||
planarian = int(request.GET.get("planarian", 0))
|
||||
record_type = request.GET.get("record_type", "frame")
|
||||
|
||||
if not experiment or not well:
|
||||
return JsonResponse({"error": "experiment et well requis"}, status=400)
|
||||
|
||||
@async_to_sync
|
||||
async def _fetch():
|
||||
client = _get_reduct_client()
|
||||
await client.connect()
|
||||
try:
|
||||
return await client.get_tracking_data(
|
||||
experiment = experiment,
|
||||
well = well,
|
||||
planarian = planarian,
|
||||
record_type = record_type,
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
records = _fetch()
|
||||
return JsonResponse({"count": len(records), "records": records})
|
||||
|
||||
@@ -2,12 +2,13 @@ from django.contrib import admin
|
||||
from django.db.models import Q
|
||||
from . import models
|
||||
|
||||
|
||||
class WellAdmin(admin.ModelAdmin):
|
||||
model = models.Well
|
||||
list_display = ('name', 'author',)
|
||||
|
||||
class ConfigurationAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'author', 'use_rpicam', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'active',)
|
||||
list_display = ('name', 'author', 'capture_type', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'active',)
|
||||
|
||||
class MultiWellAdmin(admin.ModelAdmin):
|
||||
list_filter = ('author', )
|
||||
@@ -15,33 +16,33 @@ class MultiWellAdmin(admin.ModelAdmin):
|
||||
|
||||
class WellPositionAdmin(admin.ModelAdmin):
|
||||
list_filter = ('author', 'multiwell')
|
||||
list_display = ('multiwell__position', 'well__name', 'order', 'x', 'y', 'author',)
|
||||
list_display = ('multiwell__position', 'well__name', 'order', 'x', 'y', 'px_per_mm', 'author',)
|
||||
|
||||
|
||||
class ObservationMultiWellDetailInline(admin.TabularInline):
|
||||
model = models.ObservationMultiWellDetail
|
||||
extra = 0
|
||||
#class ExperimentConfigInline(admin.TabularInline):
|
||||
# model = models.ExperimentConfig
|
||||
# extra = 0
|
||||
|
||||
class ObservationAdmin(admin.ModelAdmin):
|
||||
inlines = (ObservationMultiWellDetailInline,)
|
||||
list_filter = ('sessionobservation__session', 'author', )
|
||||
class ExperimentAdmin(admin.ModelAdmin):
|
||||
#inlines = (ExperimenConfigInline,)
|
||||
list_filter = ('session_experiments__session', 'author', )
|
||||
list_display = ('title', 'author', 'multiwell', 'created', 'started', 'finished')
|
||||
readonly_fields = ('created', 'started', 'finished', )
|
||||
|
||||
class SessionObservationInlineAdmin(admin.TabularInline):
|
||||
model = models.SessionObservation
|
||||
class SessionExperimentInlineAdmin(admin.TabularInline):
|
||||
model = models.SessionExperiment
|
||||
fk_name = 'session'
|
||||
extra = 0
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
if db_field.name == "observation":
|
||||
if db_field.name == "experiment":
|
||||
obj_id = request.resolver_match.kwargs.get("object_id")
|
||||
|
||||
qs = models.Observation.objects.filter(sessionobservation__isnull=True)
|
||||
qs = models.Experiment.objects.filter(session_experiments__isnull=True)
|
||||
if obj_id:
|
||||
qs = models.Observation.objects.filter(
|
||||
Q(sessionobservation__isnull=True) |
|
||||
Q(sessionobservation__session_id=obj_id)
|
||||
qs = models.Experiment.objects.filter(
|
||||
Q(session_experiments__isnull=True) |
|
||||
Q(session_experiments__session_id=obj_id)
|
||||
)
|
||||
kwargs["queryset"] = qs.distinct()
|
||||
|
||||
@@ -49,7 +50,7 @@ class SessionObservationInlineAdmin(admin.TabularInline):
|
||||
|
||||
class SessionAdmin(admin.ModelAdmin):
|
||||
list_filter = ('author',)
|
||||
inlines = (SessionObservationInlineAdmin, )
|
||||
inlines = (SessionExperimentInlineAdmin, )
|
||||
list_display = ('name', 'author', 'created', 'finished', 'active', 'expected_export', 'expected_scanning', )
|
||||
readonly_fields = (
|
||||
'created',
|
||||
@@ -65,6 +66,7 @@ class SessionAdmin(admin.ModelAdmin):
|
||||
admin.site.register(models.Configuration, ConfigurationAdmin)
|
||||
admin.site.register(models.Well, WellAdmin)
|
||||
admin.site.register(models.MultiWell, MultiWellAdmin)
|
||||
admin.site.register(models.WellPostion, WellPositionAdmin)
|
||||
admin.site.register(models.Observation, ObservationAdmin)
|
||||
admin.site.register(models.WellPosition, WellPositionAdmin)
|
||||
admin.site.register(models.Experiment, ExperimentAdmin)
|
||||
admin.site.register(models.Session, SessionAdmin)
|
||||
|
||||
|
||||
@@ -512,93 +512,5 @@ def _resize_frame(
|
||||
|
||||
return frame
|
||||
|
||||
# tasks/export_tasks.py
|
||||
|
||||
from celery import shared_task, group
|
||||
from django.utils import timezone
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def run_session_exports(self, session_id: str):
|
||||
"""
|
||||
Point d'entrée déclenché par django_celery_beat.
|
||||
Lance en parallèle l'export images et l'export vidéo de la session.
|
||||
"""
|
||||
from cameras.models import ExportSession
|
||||
|
||||
try:
|
||||
session = ExportSession.objects.get(session_id=session_id)
|
||||
except ExportSession.DoesNotExist:
|
||||
logger.error("run_session_exports: session %s introuvable", session_id)
|
||||
return {"status": "error", "message": "Session introuvable"}
|
||||
|
||||
session.status = ExportSession.Status.RUNNING
|
||||
session.save(update_fields=["status"])
|
||||
|
||||
try:
|
||||
# Lancement en parallèle avec group Celery
|
||||
job = group(
|
||||
export_all_images.s(session_id),
|
||||
export_all_videos.s(session_id),
|
||||
).apply_async()
|
||||
|
||||
results = job.get(timeout=7200) # 2h max pour les deux
|
||||
|
||||
session.status = ExportSession.Status.DONE
|
||||
session.exported_at = timezone.now()
|
||||
session.save(update_fields=["status", "exported_at"])
|
||||
|
||||
return {"status": "success", "results": results}
|
||||
|
||||
except Exception as exc:
|
||||
session.status = ExportSession.Status.ERROR
|
||||
session.save(update_fields=["status"])
|
||||
logger.error("run_session_exports [%s]: %s", session_id, exc, exc_info=True)
|
||||
return {"status": "error", "message": str(exc)}
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def export_all_images(self, session_id: str):
|
||||
"""
|
||||
Export ZIP de toutes les images de la session.
|
||||
"""
|
||||
from cameras.models import ExportSession
|
||||
|
||||
session = ExportSession.objects.get(session_id=session_id)
|
||||
|
||||
return export_images_zip(
|
||||
session.camera_uuid,
|
||||
session.start_ts,
|
||||
session.end_ts,
|
||||
max_zip_size_mb = session.max_zip_size_mb,
|
||||
jpeg_quality = session.jpeg_quality,
|
||||
max_image_width = session.max_image_width,
|
||||
max_image_height = session.max_image_height,
|
||||
)
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def export_all_videos(self, session_id: str):
|
||||
"""
|
||||
Export MP4 de toutes les vidéos de la session.
|
||||
"""
|
||||
from cameras.models import ExportSession
|
||||
|
||||
session = ExportSession.objects.get(session_id=session_id)
|
||||
|
||||
return export_video_mp4(
|
||||
session.camera_uuid,
|
||||
session.start_ts,
|
||||
session.end_ts,
|
||||
frame_rate = session.frame_rate,
|
||||
max_video_size_mb = session.max_video_size_mb,
|
||||
max_width = session.max_width,
|
||||
max_height = session.max_height,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.utils import timezone
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
# Multi-well positions on the table for calibration and observation
|
||||
|
||||
MULTIWELL_POSITION = [
|
||||
('HG', _("HG-Haut gauche")),
|
||||
('HD', _("HD-Haut droit")),
|
||||
@@ -51,9 +51,7 @@ class Configuration(models.Model):
|
||||
grbl_xmax = models.FloatField(_("Grbl Xmax"), help_text=_("CNC Grbl Xmax en mm"), blank=False, default=350.0)
|
||||
grbl_ymax = models.FloatField(_("Grbl Ymax"), help_text=_("CNC Grbl Ymax en mm"), blank=False, default=250.0)
|
||||
# camera configuration
|
||||
use_rpicam = models.BooleanField(_("Utiliser rpicam"), help_text=_("Par défaaut. Sinon USB webcam"), default=True)
|
||||
capture_type = models.CharField(_("Capture"), help_text=_("Type de capture"), default='rpi', max_length=8, choices=CAPTURE_TYPE, null=True, blank=False)
|
||||
|
||||
webcam_device_index = models.PositiveSmallIntegerField(_("Index de la webcam"), help_text=_("Index de la webcam (0, 1, ...) si présente"), default=2)
|
||||
image_quality = models.PositiveSmallIntegerField(_("Qualité JPEG"), help_text=_("Qualité JPEG (1-100) pour les images exportées"), default=90)
|
||||
video_jpeg_quality = models.PositiveSmallIntegerField(_("Qualité JPEG pour les vidéos"), help_text=_("Qualité JPEG (1-100) pour les images extraites des vidéos"), default=90)
|
||||
@@ -68,9 +66,13 @@ class Configuration(models.Model):
|
||||
calibration_default_duration = models.FloatField(_("Duruée calibration"), help_text=_("Durée de pose entre chaque puits en s"), default=3.0)
|
||||
# tracking
|
||||
tracking = models.BooleanField(_("Suivi"), help_text=_("Suivi et analyse des planaires"), default=False)
|
||||
|
||||
#
|
||||
active = models.BooleanField(_("Actif"), default=False)
|
||||
|
||||
|
||||
@classmethod
|
||||
def active_config(cls):
|
||||
return Configuration.objects.filter(active=True).first()
|
||||
|
||||
class Meta:
|
||||
ordering = ['id', ]
|
||||
@@ -116,7 +118,7 @@ class MultiWell(models.Model):
|
||||
dy = models.FloatField(_("Pas Y"), help_text=_('Pas ou interval sur Y en mm'), blank=False, default=19.5)
|
||||
feed = models.PositiveIntegerField(_("Vitesse"), help_text=_('Vitesse déplacement en mm/mn '), blank=False, default=1000)
|
||||
|
||||
well_position = models.BooleanField(_("Positions"), help_text=_('Positions des puits générées ?. Non => efface WellPostion et recalcule les positions'), default=False)
|
||||
well_position = models.BooleanField(_("Positions"), help_text=_('Positions des puits générées ?. Non => efface WellPosition et recalcule les positions'), default=False)
|
||||
active = models.BooleanField(_("Active"), default=True)
|
||||
|
||||
|
||||
@@ -161,7 +163,7 @@ class MultiWell(models.Model):
|
||||
return f'{self.position}: {self.label}'
|
||||
|
||||
|
||||
class WellPostion(models.Model):
|
||||
class WellPosition(models.Model):
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
||||
well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True)
|
||||
multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True)
|
||||
@@ -169,8 +171,13 @@ class WellPostion(models.Model):
|
||||
order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du puit'), blank=False, default=0)
|
||||
x = models.FloatField(_("X"), help_text=_('Axe X en mm'), blank=False, default=10.0)
|
||||
y = models.FloatField(_("Y"), help_text=_('Axe Y en mm'), blank=False, default=10.0)
|
||||
px_per_mm = models.FloatField( default=50.0, verbose_name=_("Pixels par mm"), help_text=_("Facteur de calibration optique"))
|
||||
|
||||
|
||||
@classmethod
|
||||
def active_well(cls, multiwel, well):
|
||||
return WellPosition.objects.filter(multiwel_id=multiwel.id, well_id=well.id).first()
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['order']
|
||||
unique_together = ["multiwell", "well"]
|
||||
@@ -183,6 +190,8 @@ class WellPostion(models.Model):
|
||||
|
||||
@receiver(post_save, sender=MultiWell)
|
||||
def create_well_position(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
pass
|
||||
if not instance.well_position:
|
||||
row_order = instance.row_order.split(',')
|
||||
n = 0
|
||||
@@ -197,7 +206,7 @@ def create_well_position(sender, instance, created, **kwargs):
|
||||
try:
|
||||
name = f'{row_order[row]}{col+1}'
|
||||
well = Well.objects.get(name__exact=name)
|
||||
WellPostion.objects.update_or_create(
|
||||
WellPosition.objects.update_or_create(
|
||||
multiwell=instance,
|
||||
well=well,
|
||||
author=instance.author,
|
||||
@@ -210,9 +219,9 @@ def create_well_position(sender, instance, created, **kwargs):
|
||||
instance.save()
|
||||
|
||||
|
||||
class Observation(models.Model):
|
||||
title = models.CharField(_("Titre de l'observation"), max_length=100, null=True, blank=False)
|
||||
comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'observations"), null=True, blank=True)
|
||||
class Experiment(models.Model):
|
||||
title = models.CharField(_("Titre de l'expérience"), max_length=100, null=True, blank=False)
|
||||
comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'expérience"), null=True, blank=True)
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
||||
multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True)
|
||||
created = models.DateTimeField(_("Date de création"), default=timezone.now)
|
||||
@@ -221,28 +230,12 @@ class Observation(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created', ]
|
||||
verbose_name = _("Observation")
|
||||
verbose_name_plural = _("Observations")
|
||||
verbose_name = _("Expérience")
|
||||
verbose_name_plural = _("Expériences")
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.title}: {self.created} {self.multiwell.order}'
|
||||
|
||||
class ObservationMultiWellDetail(models.Model):
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
||||
observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="multiwell_details" , null=True, blank=True)
|
||||
well = models.ForeignKey(Well, verbose_name="Puit", on_delete=models.CASCADE, related_name="observation_details", null=True, blank=True )
|
||||
detail = models.CharField("Détail", max_length=255)
|
||||
comment = models.TextField("Commentaire", blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['observation', 'well__name']
|
||||
unique_together = ["observation", "well"]
|
||||
verbose_name = _("Observation multi-puits détail")
|
||||
verbose_name_plural = _("Observations multi-puits détails")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.observation.title} - {self.well} - {self.detail}"
|
||||
|
||||
|
||||
class Session(models.Model):
|
||||
|
||||
@@ -252,7 +245,7 @@ class Session(models.Model):
|
||||
DONE = "done", _("Terminé")
|
||||
ERROR = "error", _("Erreur")
|
||||
|
||||
name = models.CharField(_("Nom de la session"), help_text=_("Session d'observations. 4 Multi-puits maximum"), max_length=100, null=True, blank=False)
|
||||
name = models.CharField(_("Nom de la session"), help_text=_("Session d'expérience. 4 Multi-puits maximum"), max_length=100, null=True, blank=False)
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
||||
active = models.BooleanField(_("Active"), default=True)
|
||||
expected_export = models.DateTimeField(_("Date d'exportation"), help_text=_("Date d'exportation prévue"), null=True, blank=True)
|
||||
@@ -280,8 +273,8 @@ class Session(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created', ]
|
||||
verbose_name = _("Session d'observation")
|
||||
verbose_name_plural = _("Sessions d'observation")
|
||||
verbose_name = _("Session d'expérience")
|
||||
verbose_name_plural = _("Sessions d'expériences")
|
||||
|
||||
def __str__(self):
|
||||
state = _("Terminée") if not self.active else _("Active")
|
||||
@@ -346,20 +339,21 @@ def delete_periodic_task(sender, instance, **kwargs):
|
||||
if instance.scanning_task:
|
||||
instance.scanning_task.delete()
|
||||
|
||||
class SessionObservation(models.Model):
|
||||
|
||||
class SessionExperiment(models.Model):
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
||||
session = models.ForeignKey(Session, verbose_name=_("Session"), on_delete=models.SET_NULL, null=True, blank=True)
|
||||
observation = models.ForeignKey(Observation, verbose_name=_("Observation"), on_delete=models.SET_NULL, null=True, blank=True)
|
||||
experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.SET_NULL, null=True, blank=True, related_name="session_experiments")
|
||||
|
||||
@classmethod
|
||||
def observation_by_session(cls, session_id, active=True):
|
||||
return [ ss.observation for ss in SessionObservation.objects.filter(session__id=session_id, session__active=active).order_by('observation__multiwell__order') ]
|
||||
def experiment_by_session(cls, session_id, active=True):
|
||||
return [ ss.experiment for ss in SessionExperiment.objects.filter(session__id=session_id, session__active=active).order_by('experiment__multiwell__order') ]
|
||||
|
||||
@classmethod
|
||||
def uuid_from_session(cls, sid):
|
||||
observations = [ss.observation for ss in SessionObservation.objects.filter(session__id=sid, session__active=False)]
|
||||
experiments = [ss.experiment for ss in SessionExperiment.objects.filter(session__id=sid, session__active=False)]
|
||||
uuid_list = []
|
||||
for obs in observations:
|
||||
for obs in experiments:
|
||||
row_def = obs.multiwell.row_def.split(',')
|
||||
for row in range(obs.multiwell.rows):
|
||||
for col in range(obs.multiwell.cols):
|
||||
@@ -369,9 +363,9 @@ class SessionObservation(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['session',]
|
||||
unique_together = ["session", "observation"]
|
||||
verbose_name = _("Session observations")
|
||||
verbose_name_plural = _("Sessions observations")
|
||||
unique_together = ["session", "experiment"]
|
||||
verbose_name = _("Session expérience")
|
||||
verbose_name_plural = _("Sessions expériences")
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.session.name}'
|
||||
|
||||
@@ -89,6 +89,7 @@ class MultiWellManager:
|
||||
self._feed = feed or self.process.conf.calibration_default_feed
|
||||
self._step = step or self.process.conf.calibration_default_step
|
||||
self._duration = duration or self.process.conf.calibration_default_duration
|
||||
self.px_per_mm = 50.0
|
||||
|
||||
|
||||
def set_multiwell(self, position=None):
|
||||
@@ -97,7 +98,7 @@ class MultiWellManager:
|
||||
else:
|
||||
self.multiwell = models.MultiWell.by_position(position)
|
||||
|
||||
wells = models.WellPostion.objects.filter(multiwell_id=self.multiwell.id).order_by('order').all()
|
||||
wells = models.WellPosition.objects.filter(multiwell_id=self.multiwell.id).order_by('order').all()
|
||||
self.well_iterator = WellIterator(wells)
|
||||
|
||||
self.position = self.multiwell.position
|
||||
@@ -132,11 +133,11 @@ class MultiWellManager:
|
||||
logger.info(f"Arrêter l'enregistrement {uuid}")
|
||||
self.process.data.record = False
|
||||
self.process.data.uuid = None
|
||||
|
||||
|
||||
|
||||
def _grid_scanning(self, observation, xnext=0, ynext=0):
|
||||
multiwell = observation.multiwell
|
||||
wells = models.WellPostion.objects.filter(multiwell_id=multiwell.id).order_by('order').all()
|
||||
def _grid_scanning(self, experiment, xnext=0, ynext=0):
|
||||
multiwell = experiment.multiwell
|
||||
wells = models.WellPosition.objects.filter(multiwell_id=multiwell.id).order_by('order').all()
|
||||
cam = self.process.cam
|
||||
cam._aligner.set_tube_diameter(multiwell.diameter)
|
||||
|
||||
@@ -149,38 +150,48 @@ class MultiWellManager:
|
||||
uuid = f'{self.process.data.session}-{multiwell.position}-{wl.well.name}'
|
||||
self._grid_scanning_capture(uuid, multiwell.duration)
|
||||
|
||||
self.process._send(uuid=uuid)
|
||||
## change file
|
||||
if self.process.conf.capture_type == 'file':
|
||||
self.process.cam._error_occured = True
|
||||
|
||||
self.process._send(scan_state=f"{uuid}: capture")
|
||||
|
||||
logger.info(f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})")
|
||||
self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2)
|
||||
|
||||
|
||||
def _start_scanning(self, session, observations):
|
||||
def _start_scanning(self, session, experiments):
|
||||
self.process.cam._aligner.debug = False
|
||||
|
||||
xynext = []
|
||||
for obs in observations:
|
||||
for obs in experiments:
|
||||
xynext.append((obs.multiwell.xbase, obs.multiwell.ybase))
|
||||
xynext.append((0, 0))
|
||||
|
||||
pos = 1
|
||||
self.process.data.session = session.id
|
||||
started = timezone.now()
|
||||
for obs in observations:
|
||||
for obs in experiments:
|
||||
if self.stop_playing.is_set():
|
||||
break
|
||||
obs.started = timezone.now()
|
||||
obs.save()
|
||||
|
||||
xnext, ynext = xynext[pos]
|
||||
pos +=1
|
||||
self._grid_scanning(obs, xnext=xnext, ynext=ynext)
|
||||
|
||||
obs.finished = timezone.now()
|
||||
obs.save()
|
||||
|
||||
session.finished = timezone.now()
|
||||
session.active = False
|
||||
session.scanning_task.enabled = False
|
||||
session.save()
|
||||
logger.info(f"==== Session {session.name} terminée à {session.finished} après {session.finished - started} secondes.")
|
||||
if self.stop_playing.is_set():
|
||||
msg = f"Session {session.name} abandonnée à {session.finished} après {session.finished - started} secondes."
|
||||
else:
|
||||
session.active = False
|
||||
if session.scanning_task:
|
||||
session.scanning_task.enabled = False
|
||||
session.save()
|
||||
msg = f"Session {session.name} terminée à {session.finished} après {session.finished - started} secondes."
|
||||
logger.info(msg)
|
||||
self.process._send(scan_state=msg)
|
||||
self.scan_thread = None
|
||||
|
||||
|
||||
@@ -188,7 +199,7 @@ class MultiWellManager:
|
||||
self.process.data.record = False
|
||||
self.stop_playing.set()
|
||||
self.well_iterator.reset()
|
||||
self.process.cam._aligner.debugg = False
|
||||
self.process.cam._aligner.debug = False
|
||||
|
||||
|
||||
def scanning(self, sid):
|
||||
@@ -196,8 +207,8 @@ class MultiWellManager:
|
||||
if self.scan_thread:
|
||||
return
|
||||
session = models.Session.objects.get(pk=sid)
|
||||
observations = models.SessionObservation.observation_by_session(sid)
|
||||
self.scan_thread = Thread(target=self._start_scanning, args=(session, observations, ), daemon=True).start()
|
||||
experiments = models.SessionExperiment.experiment_by_session(sid)
|
||||
self.scan_thread = Thread(target=self._start_scanning, args=(session, experiments, ), daemon=True).start()
|
||||
except Exception as e:
|
||||
print("MultiWellManager::scan error", e)
|
||||
|
||||
@@ -253,6 +264,7 @@ class MultiWellManager:
|
||||
if auto:
|
||||
msg = cam.align_detection["msg"]
|
||||
if cam.align_detection.get('detected'):
|
||||
|
||||
if cam.align_detection.get('action')=="grbl":
|
||||
self.cnc_controller.wait_for(settings.CALIBRATION_AUTO_TIMEOUT)
|
||||
dx_mm, dy_mm = cam.align_detection["offset_x_mm"], cam.align_detection["offset_y_mm"]
|
||||
@@ -265,6 +277,7 @@ class MultiWellManager:
|
||||
logger.info(msg)
|
||||
self.process._send(state='save', msg=msg)
|
||||
wl.x, wl.y = self.cnc_controller.x, self.cnc_controller.y
|
||||
wl.px_per_mm = cam.align_detection.get('px_per_mm')
|
||||
wl.save()
|
||||
if wl.order == 0:
|
||||
models.MultiWell.objects.filter(position__exact=self.position).update(xbase=wl.x, ybase=wl.y)
|
||||
@@ -371,6 +384,7 @@ class MultiWellManager:
|
||||
self._xbase, self._ybase = x, y
|
||||
wl = self.well_iterator.seek(0) # base puit 0
|
||||
wl.x, wl.y = x, y
|
||||
wl.px_per_mm = self.px_per_mm
|
||||
wl.save()
|
||||
|
||||
|
||||
|
||||
@@ -421,6 +421,7 @@ class ScannerProcess(Task):
|
||||
elif topic == 'center':
|
||||
dx_mm = self.cam.align_detection["offset_x_mm"]
|
||||
dy_mm = self.cam.align_detection["offset_y_mm"]
|
||||
self.manager.px_per_mm = self.cam.align_detection["px_per_mm"]
|
||||
self.grbl.move_to(self.grbl.x + dx_mm, self.grbl.y + dy_mm, feed=150)
|
||||
self._send(state='center', msg=self.cam.align_detection["msg"])
|
||||
continue
|
||||
|
||||
@@ -22,7 +22,7 @@ class ScannerManager {
|
||||
this.axial_pos = options.axial_pos;
|
||||
this.area_px = options.area_px;
|
||||
this.frame_count = options.frame_count;
|
||||
this.uuid = options.uuid;
|
||||
this.scan_state = options.scan_state;
|
||||
}
|
||||
|
||||
init_controls() {
|
||||
@@ -43,6 +43,8 @@ class ScannerManager {
|
||||
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
|
||||
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
|
||||
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
|
||||
if (payload.scan_state) { this.scan_state.textContent=payload.scan_state;}
|
||||
|
||||
if (payload.detected && use_tracking) {
|
||||
this.cx.textContent = payload.cx; this.cy.textContent = payload.cy;
|
||||
this.speed_px_s.textContent = payload.speed_px_s;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
const mem_used = sId('mem-used');
|
||||
const disk_used = sId('disk-used');
|
||||
const ramdisk_used = sId('ramdisk-used');
|
||||
const swap_used = sId('swap-used');
|
||||
|
||||
let autoTimer = null;
|
||||
|
||||
@@ -12,10 +13,11 @@
|
||||
const r = await fetch(stats_endpoint, { credentials: 'same-origin' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const j = await r.json();
|
||||
//console.log(j);
|
||||
const cpu_percent = j.cpu_info.cpu_percent+'%'; cpu_used.style.setProperty("--cpu-used", cpu_percent); cpu_used.title=`Cpu: ${cpu_percent}`;
|
||||
const shm_length = j.shm.length; shm_used.style.setProperty("--shm_used", shm_length); shm_used.title= `Shm: ${shm_length}`;
|
||||
const virtual_memory = j.memory_info.virtual_memory.percent+'%'; mem_used.style.setProperty("--mem-used", virtual_memory); mem_used.title=`Mem: ${virtual_memory}`;
|
||||
const virtual_memory = j.memory_info.virtual_memory.percent+'%'; mem_used.style.setProperty("--mem-used", virtual_memory); mem_used.title=`Mem: ${virtual_memory}`;
|
||||
const swap_memory = j.memory_info.swap_memory.percent+'%'; swap_used.style.setProperty("--swap-used", swap_memory); swap_used.title=`swap: ${swap_memory}`;
|
||||
|
||||
const root_percent = j.disk_info.root.percent+'%'; disk_used.style.setProperty("--disk-used", root_percent); disk_used.title=`Disk: ${root_percent}`;
|
||||
let ramdisk_percent = "0%";
|
||||
if (! j.ramdisk_info) ramdisk_percent = j.ramdisk_info.percent+'%';
|
||||
|
||||
@@ -88,7 +88,7 @@ def export_all_images(session_id=None):
|
||||
sessions = [session_id]
|
||||
|
||||
for session_id in sessions:
|
||||
uuid_list = models.SessionObservation.uuid_from_session(session_id)
|
||||
uuid_list = models.SessionExperiment.uuid_from_session(session_id)
|
||||
job_zip = []
|
||||
for uuid in uuid_list:
|
||||
job = export_images.delay( # @UndefinedVariable
|
||||
@@ -129,7 +129,7 @@ def export_all_videos(session_id=None):
|
||||
sessions = [session_id]
|
||||
|
||||
for session_id in sessions:
|
||||
uuid_list = models.SessionObservation.uuid_from_session(session_id)
|
||||
uuid_list = models.SessionExperiment.uuid_from_session(session_id)
|
||||
job_mp4 = []
|
||||
for uuid in uuid_list:
|
||||
job = export_videos.delay( # @UndefinedVariable
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<div class="w3-light-blue w3-padding-large">
|
||||
<div id="shm-used" class="w3-green w3-round" style="height:0.5em; width: var(--shm-used, 0%);" title="{% trans 'Mémoire partagée' %}"></div>
|
||||
<div id="ramdisk-used" class="w3-purple w3-round" style="height:0.5em;width: var(--ramdisk-used, 0%); margin: 0.35em 0" title="{% trans 'Disque RAM' %}"></div>
|
||||
<div id="swap-used" class="w3-deep-purple w3-round" style="height:0.5em;width: var(--swap-used, 0%); margin: 0.35em 0" title="{% trans 'Swap disk' %}"></div>
|
||||
<div id="mem-used" class="w3-deep-orange w3-round" style="height:0.5em;width: var(--mem-used, 0%); margin: 0.35em 0" title="{% trans 'Mémoire RAM' %}"></div>
|
||||
<div id="cpu-used" class="w3-indigo w3-round" style="height:0.5em;width: var(--cpu-used, 0%); margin: 0.35em 0" title="{% trans 'Charge CPU' %}"></div>
|
||||
<div id="disk-used" class="w3-amber w3-round" style="height:0.5em;width: var(--disk-used, 0%); margin: 0.35em 0" title="{% trans 'Disque /' %}"></div>
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
<form method="post" class="w3-bar-item" action="">
|
||||
{% csrf_token %}
|
||||
<select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()">
|
||||
<option value="0">---- {% trans "Session d'observations" %}</option>
|
||||
<option value="0">---- {% trans "Session d'expérience" %}</option>
|
||||
{% for s in sessions %}
|
||||
<option value="{{ s.id }}" {% if s.id == cursid %}selected{% endif %}>{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if cursid %}
|
||||
{% multiwell_cards cursid observations %}
|
||||
{% multiwell_cards cursid experiments %}
|
||||
{% endif %}
|
||||
</form>
|
||||
{% if cursid %}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="w3-row w3-row-padding w3-black">
|
||||
<div class="w3-col w3-center" style="width: 20%">
|
||||
<div class="w3-col w3-center" style="width: 30%">
|
||||
<div class="w3-col">
|
||||
<div>{% trans "Choix de la session" %}</div>
|
||||
<select id="_session" class="w3-select">
|
||||
@@ -50,9 +50,12 @@
|
||||
<div class="w3-margin-top w3-padding w3-small">
|
||||
<span id="_ts"></span>
|
||||
</div>
|
||||
<div class="w3-margin-top w3-padding w3-small">
|
||||
<span id="_scan_state" class="w3-text-amber w3-border"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w3-col" style="width: 80%">
|
||||
<div class="w3-col" style="width: 70%">
|
||||
{% include 'scanner/scan-image.html' %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +85,7 @@
|
||||
axial_pos : sId("_axial_pos"),
|
||||
area_px : sId("_area_px"),
|
||||
frame_count : sId("_count"),
|
||||
uuid: sId("_uuid")
|
||||
scan_state: sId("_scan_state")
|
||||
};
|
||||
</script>
|
||||
<script src="/static/scanner/js/main.js"></script>
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
<form method="post" class="w3-bar-item" action="">
|
||||
{% csrf_token %}
|
||||
<select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()">
|
||||
<option value="0">---- {% trans "Session d'observations" %}</option>
|
||||
<option value="0">---- {% trans "Session d'expériences" %}</option>
|
||||
{% for s in sessions %}
|
||||
<option value="{{ s.id }}" {% if s.id == cursid %}selected{% endif %}>{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if cursid %}
|
||||
{% multiwell_cards cursid observations %}
|
||||
{% multiwell_cards cursid experiments %}
|
||||
{% endif %}
|
||||
</form>
|
||||
{% if cursid %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
<div class="w3-row">
|
||||
{% if use_tracking %}
|
||||
{% if conf.tracking %}
|
||||
<div class="w3-col w3-small" style="width:180px">
|
||||
<div>Num: <span id="_count"></span></div>
|
||||
<div>Aire: <span id="_area_px"></span></div>
|
||||
|
||||
@@ -5,9 +5,9 @@ from django.utils.html import mark_safe
|
||||
register = template.Library()
|
||||
|
||||
@register.simple_tag
|
||||
def multiwell_cards(sid, observations):
|
||||
def multiwell_cards(sid, experiments):
|
||||
multiwells = []
|
||||
for obs in observations:
|
||||
for obs in experiments:
|
||||
row_def = obs.multiwell.row_def.split(',')
|
||||
multiwells.append(
|
||||
f'''
|
||||
|
||||
@@ -11,16 +11,40 @@ from django.conf import settings
|
||||
from reduct.time import unix_timestamp_to_iso
|
||||
from modules.system_stats import get_cached_stats, start_background_updater
|
||||
from modules import reductstore
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .tasks import download_video, export_all_images, export_all_videos
|
||||
from .process import CameraRecordManager, cameraDB
|
||||
from . import models
|
||||
|
||||
@dataclass
|
||||
class DefaultConfig:
|
||||
sidebar_width: str = "25%"
|
||||
default_grid_columns: int = 3
|
||||
opencv_fourcc_format: str = 'mp4v'
|
||||
opencv_video_type: str = 'mp4'
|
||||
grbl_xmax: float = 350.0
|
||||
grbl_ymax: float = 250.0
|
||||
capture_type: str = 'rpi'
|
||||
webcam_device_index: int = 2
|
||||
image_quality: int = 90
|
||||
video_jpeg_quality: int = 90
|
||||
video_frame_rate: int = 5.0
|
||||
video_width_capture: int = 2028
|
||||
video_height_capture: int = 1520
|
||||
calibration_crop_radius: int = 500
|
||||
calibration_default_multiwell: str = 'HD'
|
||||
calibration_default_feed: int = 1000
|
||||
calibration_default_step: float = 1.0
|
||||
calibration_default_duration: float = 3.0
|
||||
tracking: bool = False
|
||||
|
||||
|
||||
default_conf = DefaultConfig()
|
||||
record_manager = CameraRecordManager(cameraDB)
|
||||
start_background_updater()
|
||||
|
||||
|
||||
|
||||
|
||||
@require_GET
|
||||
def stats_view(request):
|
||||
"""
|
||||
@@ -34,12 +58,12 @@ def stats_view(request):
|
||||
|
||||
def global_context(request, **ctx):
|
||||
default_multiwell = models.MultiWell.objects.filter(default=True).first()
|
||||
conf = models.Configuration.active_config() or default_conf
|
||||
return dict(
|
||||
app_title=settings.APP_TITLE,
|
||||
app_sub_title=settings.APP_SUB_TITLE,
|
||||
domain_server=settings.DOMAIN_SERVER,
|
||||
use_tracking=settings.TRACKING,
|
||||
conf=models.Configuration.objects.filter(active=True).first(),
|
||||
conf=conf,
|
||||
default_position = default_multiwell.position or 'HD',
|
||||
**ctx
|
||||
)
|
||||
@@ -131,7 +155,7 @@ def images_view(request):
|
||||
ctx = dict(
|
||||
choice_title=_("Gestionnaire d'images"),
|
||||
sessions=models.Session.objects.filter(active=False).all(),
|
||||
observations=models.SessionObservation.observation_by_session(cursid, active=False),
|
||||
experiments=models.SessionExperiment.experiment_by_session(cursid, active=False),
|
||||
cursid=int(cursid),
|
||||
images=images,
|
||||
uuid=uuid,
|
||||
@@ -174,7 +198,7 @@ def replay_view(request):
|
||||
choice_title=_("Gestionnaire de vidéos"),
|
||||
ws_route=settings.REPLAY_WEBSOCKET_ROUTE,
|
||||
sessions=models.Session.objects.filter(active=False).all(),
|
||||
observations=models.SessionObservation.observation_by_session(cursid, active=False),
|
||||
experiments=models.SessionExperiment.experiment_by_session(cursid, active=False),
|
||||
cursid=int(cursid),
|
||||
image=image,
|
||||
uuid=uuid,
|
||||
|
||||
Reference in New Issue
Block a user