planarian
This commit is contained in:
@@ -19,4 +19,5 @@ opencv-python-headless
|
|||||||
mysqlclient
|
mysqlclient
|
||||||
psycopg2
|
psycopg2
|
||||||
pyserial
|
pyserial
|
||||||
|
scipy
|
||||||
|
|
||||||
|
|||||||
@@ -385,8 +385,8 @@ DATETIME_FORMAT = '%d-%m-%Y-%m %H:%M:%S'
|
|||||||
#===========================
|
#===========================
|
||||||
# default configuration
|
# default configuration
|
||||||
#
|
#
|
||||||
#===========================
|
|
||||||
# rpicam 4056x3040 2028x1080 2028x1520
|
# rpicam 4056x3040 2028x1080 2028x1520
|
||||||
|
#===========================
|
||||||
|
|
||||||
EXPORTS_LOCAL_PATH = config("EXPORTS_LOCAL_PATH")
|
EXPORTS_LOCAL_PATH = config("EXPORTS_LOCAL_PATH")
|
||||||
EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH")
|
EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH")
|
||||||
@@ -394,11 +394,12 @@ EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH")
|
|||||||
EXPORT_DESTINATIONS = ["local", "remote"]
|
EXPORT_DESTINATIONS = ["local", "remote"]
|
||||||
#EXPORT_DESTINATIONS = ["remote"] # only remote
|
#EXPORT_DESTINATIONS = ["remote"] # only remote
|
||||||
|
|
||||||
TEST_VIDEOFILE = False
|
|
||||||
|
|
||||||
TRACKING = True
|
|
||||||
TRACKER_TUBE_AXIS = "vertical"
|
TRACKER_TUBE_AXIS = "vertical"
|
||||||
TRACKER_MIN_AREA = 200
|
TRACKER_MIN_AREA = 20 # surface min planaire
|
||||||
|
TRACKER_MAX_AREA_RATIO = 0.05 # 5% de la frame = surface max acceptable
|
||||||
|
TRACKER_MAX_PLANARIANS = 3
|
||||||
|
|
||||||
|
|
||||||
CALIBRATION_AUTO_DURATION = 45.0
|
CALIBRATION_AUTO_DURATION = 45.0
|
||||||
CALIBRATION_AUTO_TIMEOUT = 2.5
|
CALIBRATION_AUTO_TIMEOUT = 2.5
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ urlpatterns += i18n_patterns(
|
|||||||
|
|
||||||
path('', RedirectView.as_view(url='/scanner/calibration/', permanent=True), name='redirect_to_mainboard'),
|
path('', RedirectView.as_view(url='/scanner/calibration/', permanent=True), name='redirect_to_mainboard'),
|
||||||
path('scanner/', include('scanner.urls', namespace='scanner')),
|
path('scanner/', include('scanner.urls', namespace='scanner')),
|
||||||
|
path('planarian/', include('planarian.urls', namespace='planarian')),
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
@@ -49,16 +49,16 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
# Cadence par défaut en images par seconde
|
# Cadence par défaut en images par seconde
|
||||||
DEFAULT_FPS: float = 5.0
|
DEFAULT_FPS: float = 5.0
|
||||||
|
|
||||||
def __init__(self, fps: float = DEFAULT_FPS, use_tracking: bool = False, display=None, parent=None):
|
def __init__(self, fps: float = DEFAULT_FPS, use_tracking: bool = False, display=None, parent=None, jpeg_quality=85):
|
||||||
"""
|
"""
|
||||||
Initialise l'interface de capture.
|
Initialise l'interface de capture.
|
||||||
|
|
||||||
:param fps: Cadence cible en images par seconde
|
:param fps: Cadence cible en images par seconde
|
||||||
"""
|
"""
|
||||||
self._fps: float = fps
|
self._fps: float = fps
|
||||||
self.use_tracking = use_tracking
|
|
||||||
self.display = display
|
self.display = display
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
|
self.jpeg_quality = jpeg_quality
|
||||||
self._interval: float = 1.0 / fps # Intervalle en secondes entre chaque capture
|
self._interval: float = 1.0 / fps # Intervalle en secondes entre chaque capture
|
||||||
self._running: bool = False # Indique si la capture est en cours
|
self._running: bool = False # Indique si la capture est en cours
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
@@ -69,10 +69,15 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
self._active_crop = False
|
self._active_crop = False
|
||||||
self._error_occured = False
|
self._error_occured = False
|
||||||
|
|
||||||
|
self._tracker = None
|
||||||
|
if use_tracking:
|
||||||
self._tracker = PlanarianTracker(
|
self._tracker = PlanarianTracker(
|
||||||
tube_axis = settings.TRACKER_TUBE_AXIS,
|
tube_axis = settings.TRACKER_TUBE_AXIS,
|
||||||
min_area_px = settings.TRACKER_MIN_AREA,
|
min_area_px = settings.TRACKER_MIN_AREA,
|
||||||
|
max_area_ratio = settings.TRACKER_MAX_AREA_RATIO,
|
||||||
|
max_planarians = settings.TRACKER_MAX_PLANARIANS,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._aligner = TubeAligner(
|
self._aligner = TubeAligner(
|
||||||
grbl_threshold_px = 20, # au-delà → correction GRBL
|
grbl_threshold_px = 20, # au-delà → correction GRBL
|
||||||
dead_zone_px = 5, # en-dessous → rien à faire
|
dead_zone_px = 5, # en-dessous → rien à faire
|
||||||
@@ -80,11 +85,13 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
)
|
)
|
||||||
self.align_detection = None # résultat du test
|
self.align_detection = None # résultat du test
|
||||||
|
|
||||||
|
|
||||||
def on_well_change(self):
|
def on_well_change(self):
|
||||||
"""
|
"""
|
||||||
Appelé par le CNC lors du changement de puits.
|
Appelé par le CNC lors du changement de puits.
|
||||||
Réinitialise le fond appris et l'état inter-frame du tracker.
|
Réinitialise le fond appris et l'état inter-frame du tracker.
|
||||||
"""
|
"""
|
||||||
|
if self._tracker:
|
||||||
self._tracker.reset()
|
self._tracker.reset()
|
||||||
|
|
||||||
|
|
||||||
@@ -233,24 +240,26 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
if frame is None:
|
if frame is None:
|
||||||
return jpeg, metrics
|
return jpeg, metrics
|
||||||
|
try:
|
||||||
# Mode debug
|
# Mode debug
|
||||||
if self._aligner.debug:
|
if self._aligner.debug:
|
||||||
self.align_detection = self._aligner.detect_tube(frame)
|
self.align_detection = self._aligner.detect_tube(frame)
|
||||||
annotated = self.align_detection.get('frame_annotated')
|
annotated = self.align_detection.get('frame_annotated')
|
||||||
frame = annotated if annotated is not None else frame
|
frame = annotated if annotated is not None else frame
|
||||||
|
##
|
||||||
# mode racking
|
# mode racking
|
||||||
if self.use_tracking:
|
if self._tracker is not None:
|
||||||
ts = datetime.now(timezone.utc).timestamp()
|
ts = datetime.now(timezone.utc).timestamp()
|
||||||
frame, metrics = self._tracker.process(frame, ts)
|
frame, metrics = self._tracker.process(frame, ts)
|
||||||
|
##
|
||||||
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
|
||||||
if ok:
|
if ok:
|
||||||
jpeg = buf.tobytes()
|
jpeg = buf.tobytes()
|
||||||
|
|
||||||
return jpeg, metrics
|
return jpeg, metrics
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
|
||||||
return jpeg_bytes, metrics
|
return jpeg_bytes, metrics
|
||||||
|
|
||||||
def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path:
|
def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path:
|
||||||
@@ -325,16 +334,12 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
##
|
##
|
||||||
jpeg, metrics = self.process_frame(jpeg) # Recadrage circulaire si configuré
|
jpeg, metrics = self.process_frame(jpeg) # Recadrage circulaire si configuré
|
||||||
|
|
||||||
metrics.update({
|
|
||||||
"count": self._frame_count,
|
|
||||||
})
|
|
||||||
|
|
||||||
self._frame_count += 1
|
self._frame_count += 1
|
||||||
ts = datetime.now(timezone.utc)
|
ts = datetime.now(timezone.utc)
|
||||||
|
|
||||||
if self._on_frame:
|
if self._on_frame:
|
||||||
try:
|
try:
|
||||||
self._on_frame(jpeg, ts, metrics)
|
self._on_frame(jpeg, ts, metrics, self._frame_count)
|
||||||
except Exception as cb_err: # noqa: BLE001
|
except Exception as cb_err: # noqa: BLE001
|
||||||
logger.error("Erreur dans le callback image : %s", cb_err)
|
logger.error("Erreur dans le callback image : %s", cb_err)
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class PiCamera2Capture(VideoCaptureInterface):
|
|||||||
:param use_video_config: True = VideoConfiguration (flux continu, basse latence)
|
:param use_video_config: True = VideoConfiguration (flux continu, basse latence)
|
||||||
False = StillConfiguration (haute résolution, plus lent)
|
False = StillConfiguration (haute résolution, plus lent)
|
||||||
"""
|
"""
|
||||||
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent)
|
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent, jpeg_quality=jpeg_quality)
|
||||||
self._width: int = width
|
self._width: int = width
|
||||||
self._height: int = height
|
self._height: int = height
|
||||||
self._jpeg_quality: int = jpeg_quality
|
self._jpeg_quality: int = jpeg_quality
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import time
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from modules.reductstore import ReductStore
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -468,6 +469,7 @@ class ReductStoreClient:
|
|||||||
token : token d'authentification (vide si pas d'auth)
|
token : token d'authentification (vide si pas d'auth)
|
||||||
bucket : nom du bucket cible
|
bucket : nom du bucket cible
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.url = url
|
self.url = url
|
||||||
self.token = token
|
self.token = token
|
||||||
self.bucket_name = bucket
|
self.bucket_name = bucket
|
||||||
|
|||||||
@@ -1,36 +1,208 @@
|
|||||||
# modules/planarian_tracker.py
|
"""
|
||||||
'''
|
modules/planarian_tracker.py
|
||||||
Created on 16 avr. 2026
|
|
||||||
|
|
||||||
|
Détection et suivi multi-individus de planaires dans un tube.
|
||||||
|
Supporte de 1 à MAX_PLANARIANS planaires par tube.
|
||||||
|
|
||||||
|
Etat inter-frame indépendant par individu : position, timestamp, compteur de perte (lost), flag active.
|
||||||
|
Quand un individu n'est pas détecté pendant MAX_LOST_FRAMES (5) frames consécutives, il est marqué perdu et son slot se libère.
|
||||||
|
|
||||||
|
Algorithme hongrois (scipy.optimize.linear_sum_assignment) dans _hungarian_assign()
|
||||||
|
— construit une matrice de coût distance euclidienne entre les slots actifs et les nouvelles détections, puis trouve l'association de coût minimal.
|
||||||
|
Une association est rejetée si la distance dépasse MAX_ASSOC_DIST_PX (80px)
|
||||||
|
— évite les sauts aberrants entre planaires proches.
|
||||||
|
|
||||||
|
|
||||||
|
Stratégie :
|
||||||
|
- Soustraction de fond MOG2 (léger sur Raspberry Pi 4)
|
||||||
|
- Détection de tous les contours valides (surface >= min_area_px)
|
||||||
|
- Association frame-à-frame par distance euclidienne minimale
|
||||||
|
via algorithme hongrois (scipy.optimize.linear_sum_assignment)
|
||||||
|
- Un état inter-frame indépendant par individu (PlanarianState)
|
||||||
|
- Retourne une liste de résultats, un par individu suivi: champ planarian_id (index 0-based).
|
||||||
|
|
||||||
|
Created on 25 avr. 2026
|
||||||
@author: denis
|
@author: denis
|
||||||
'''
|
"""
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import logging
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
from scipy.optimize import linear_sum_assignment
|
||||||
|
|
||||||
|
# Nombre maximum de planaires suivis simultanément par tube
|
||||||
|
MAX_PLANARIANS = 10
|
||||||
|
|
||||||
|
# Distance maximale en pixels entre deux positions consécutives
|
||||||
|
# pour qu'une association soit acceptée (évite les sauts aberrants)
|
||||||
|
MAX_ASSOC_DIST_PX = 80
|
||||||
|
|
||||||
|
# Couleurs d'annotation par individu (BGR)
|
||||||
|
# Cycle automatique si plus de planaires que de couleurs
|
||||||
|
INDIVIDUAL_COLORS = [
|
||||||
|
(255, 255, 0), # cyan
|
||||||
|
( 0, 165, 255), # orange
|
||||||
|
(255, 0, 255), # magenta
|
||||||
|
( 0, 255, 255), # jaune
|
||||||
|
(128, 0, 255), # violet
|
||||||
|
( 0, 255, 128), # vert clair
|
||||||
|
(255, 128, 0), # bleu clair
|
||||||
|
( 0, 128, 255), # orange foncé
|
||||||
|
(128, 255, 0), # vert-jaune
|
||||||
|
(255, 0, 128), # rose
|
||||||
|
]
|
||||||
|
|
||||||
|
# Couleur du contour principal (individu le plus grand)
|
||||||
|
COLOR_LARGEST = (255, 255, 0) # cyan
|
||||||
|
COLOR_OTHER = ( 0, 255, 0) # vert
|
||||||
|
COLOR_CENTER = ( 0, 0, 255) # rouge
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# État inter-frame d'un individu
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PlanarianState:
|
||||||
|
"""
|
||||||
|
Mémorise la position et le timestamp de la dernière détection
|
||||||
|
pour un planaire individuel.
|
||||||
|
|
||||||
|
Un PlanarianState par slot (index 0 à max_planarians-1).
|
||||||
|
Quand un slot n'est pas associé à un contour sur plusieurs frames
|
||||||
|
consécutives, il est marqué comme perdu (lost).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Nombre de frames sans détection avant de considérer l'individu perdu
|
||||||
|
MAX_LOST_FRAMES = 5
|
||||||
|
|
||||||
|
def __init__(self, idx: int):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
idx : index de l'individu (0-based)
|
||||||
|
"""
|
||||||
|
self.idx = idx
|
||||||
|
self.cx = None
|
||||||
|
self.cy = None
|
||||||
|
self.ts = None
|
||||||
|
self.lost = 0 # compteur de frames sans détection
|
||||||
|
self.active = False # vrai si l'individu a été détecté au moins une fois
|
||||||
|
|
||||||
|
def update(self, cx: int, cy: int, ts: float):
|
||||||
|
"""
|
||||||
|
Met à jour la position suite à une association réussie.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cx, cy : position du centre de masse en pixels
|
||||||
|
ts : timestamp de la frame
|
||||||
|
"""
|
||||||
|
self.cx = cx
|
||||||
|
self.cy = cy
|
||||||
|
self.ts = ts
|
||||||
|
self.lost = 0
|
||||||
|
self.active = True
|
||||||
|
|
||||||
|
def mark_lost(self):
|
||||||
|
"""Incrémente le compteur de perte — appelé quand aucun contour n'est associé."""
|
||||||
|
self.lost += 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_lost(self) -> bool:
|
||||||
|
"""Retourne True si l'individu est considéré perdu (trop de frames sans détection)."""
|
||||||
|
return self.lost >= self.MAX_LOST_FRAMES
|
||||||
|
|
||||||
|
def compute_speed(self, cx: int, cy: int, ts: float, tube_axis: str) -> tuple:
|
||||||
|
"""
|
||||||
|
Calcule la vitesse instantanée depuis la position précédente.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cx, cy : position courante en pixels
|
||||||
|
ts : timestamp courant
|
||||||
|
tube_axis : "vertical" ou "horizontal"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple (speed_px_s, axial_speed) ou (0.0, 0.0) si état vide
|
||||||
|
"""
|
||||||
|
if self.cx is None or self.ts is None:
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
dt = ts - self.ts
|
||||||
|
if dt <= 0:
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
dx = cx - self.cx
|
||||||
|
dy = cy - self.cy
|
||||||
|
speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt)
|
||||||
|
axial_speed = float((dy / dt) if tube_axis == "vertical" else (dx / dt))
|
||||||
|
|
||||||
|
return speed_px_s, axial_speed
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Réinitialise l'état de cet individu."""
|
||||||
|
self.cx = None
|
||||||
|
self.cy = None
|
||||||
|
self.ts = None
|
||||||
|
self.lost = 0
|
||||||
|
self.active = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tracker multi-individus
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class PlanarianTracker:
|
class PlanarianTracker:
|
||||||
"""
|
"""
|
||||||
Détection et suivi d'une planaire dans un tube.
|
Détection et suivi multi-individus de planaires dans un tube.
|
||||||
|
|
||||||
Instancié une fois par caméra active, réutilisé frame à frame.
|
Instancié une fois par caméra active, réutilisé frame à frame.
|
||||||
Utilise la soustraction de fond MOG2 — léger sur Raspberry Pi 4.
|
Utilise la soustraction de fond MOG2 — léger sur Raspberry Pi 4.
|
||||||
|
Association frame-à-frame par algorithme hongrois (distance euclidienne).
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
tracker = PlanarianTracker(tube_axis="vertical", max_planarians=3)
|
||||||
|
while capturing:
|
||||||
|
frame_out, results = tracker.process(frame, ts)
|
||||||
|
# results : liste de dicts, un par individu détecté
|
||||||
|
for r in results:
|
||||||
|
metrics.update(r, planarian_id=r["planarian_id"])
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, tube_axis: str = "vertical", min_area_px: int = 20):
|
# Nombre de frames d'initialisation MOG2 ignorées (fond non appris)
|
||||||
# Axe du tube : "vertical" (cy) ou "horizontal" (cx)
|
WARMUP_FRAMES = 10
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
tube_axis: str = "vertical",
|
||||||
|
min_area_px: int = 20,
|
||||||
|
max_area_ratio: float = 0.10,
|
||||||
|
max_planarians: int = 1,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
tube_axis : axe principal du tube — "vertical" (cy) ou "horizontal" (cx)
|
||||||
|
min_area_px : surface minimale d'un contour pour être considéré valide (px²)
|
||||||
|
max_area_ratio : surface maximale d'un contour en fraction de la frame (défaut 10%)
|
||||||
|
filtre les faux positifs du fond non encore appris par MOG2
|
||||||
|
max_planarians : nombre maximum de planaires à suivre simultanément (1-10)
|
||||||
|
"""
|
||||||
self.tube_axis = tube_axis
|
self.tube_axis = tube_axis
|
||||||
self.min_area_px = min_area_px
|
self.min_area_px = min_area_px
|
||||||
|
self.max_area_ratio = max_area_ratio
|
||||||
|
self.max_planarians = max(1, min(max_planarians, MAX_PLANARIANS))
|
||||||
|
|
||||||
# Etat inter-frame
|
# Un état inter-frame par slot individu
|
||||||
self._prev_cx = None
|
self._states = [PlanarianState(i) for i in range(self.max_planarians)]
|
||||||
self._prev_cy = None
|
|
||||||
self._prev_ts = None
|
|
||||||
|
|
||||||
# Soustracteur de fond adaptatif MOG2
|
# Soustracteur de fond adaptatif MOG2
|
||||||
self._bg_sub = cv2.createBackgroundSubtractorMOG2(
|
self._bg_sub = self._make_bg_sub()
|
||||||
|
|
||||||
|
# Compteur de frames d'initialisation — MOG2 retourne du bruit
|
||||||
|
# pendant les premières WARMUP_FRAMES frames
|
||||||
|
self._warmup_count = 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_bg_sub():
|
||||||
|
"""Crée et retourne un soustracteur de fond MOG2."""
|
||||||
|
return cv2.createBackgroundSubtractorMOG2(
|
||||||
history = 50,
|
history = 50,
|
||||||
varThreshold = 25,
|
varThreshold = 25,
|
||||||
detectShadows= False,
|
detectShadows= False,
|
||||||
@@ -38,205 +210,283 @@ class PlanarianTracker:
|
|||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
"""
|
"""
|
||||||
Réinitialise l'état inter-frame — appeler lors du changement de puits.
|
Réinitialise l'état inter-frame complet.
|
||||||
|
À appeler lors du changement de puits.
|
||||||
"""
|
"""
|
||||||
self._prev_cx = None
|
for s in self._states:
|
||||||
self._prev_cy = None
|
s.reset()
|
||||||
self._prev_ts = None
|
self._bg_sub = self._make_bg_sub()
|
||||||
# Réinitialise le fond appris
|
self._warmup_count = 0
|
||||||
self._bg_sub = cv2.createBackgroundSubtractorMOG2(
|
|
||||||
history = 50,
|
|
||||||
varThreshold = 25,
|
|
||||||
detectShadows= False,
|
|
||||||
)
|
|
||||||
|
|
||||||
'''
|
|
||||||
def process(self, frame: np.ndarray, ts: float) -> dict:
|
|
||||||
"""
|
|
||||||
Analyse une frame décodée numpy.
|
|
||||||
Retourne un dict de métriques attachable aux labels ReductStore.
|
|
||||||
|
|
||||||
:param frame: Frame BGR décodée (numpy array)
|
|
||||||
:param ts: Timestamp epoch secondes (float)
|
|
||||||
:return: dict métriques
|
|
||||||
"""
|
|
||||||
result = self._empty_result(ts)
|
|
||||||
|
|
||||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
||||||
fg_mask = self._bg_sub.apply(gray)
|
|
||||||
|
|
||||||
# Nettoyage morphologique du masque
|
|
||||||
kernel = np.ones((3, 3), np.uint8)
|
|
||||||
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel)
|
|
||||||
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel)
|
|
||||||
|
|
||||||
contours, _ = cv2.findContours(
|
|
||||||
fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
|
||||||
)
|
|
||||||
|
|
||||||
if not contours:
|
|
||||||
self._update_prev(None, None, ts)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Plus grand contour = planaire
|
|
||||||
largest = max(contours, key=cv2.contourArea)
|
|
||||||
area = cv2.contourArea(largest)
|
|
||||||
|
|
||||||
if area < self.min_area_px:
|
|
||||||
self._update_prev(None, None, ts)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Centre de masse
|
|
||||||
M = cv2.moments(largest)
|
|
||||||
if M["m00"] == 0:
|
|
||||||
return result
|
|
||||||
|
|
||||||
cx = int(M["m10"] / M["m00"])
|
|
||||||
cy = int(M["m01"] / M["m00"])
|
|
||||||
h, w = frame.shape[:2]
|
|
||||||
|
|
||||||
# Position normalisée sur l'axe du tube (0.0 → 1.0)
|
|
||||||
axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w)
|
|
||||||
|
|
||||||
# Vitesse calculée entre frames
|
|
||||||
speed_px_s = None
|
|
||||||
axial_speed = None
|
|
||||||
|
|
||||||
if self._prev_cx is not None and self._prev_ts is not None:
|
|
||||||
dt = ts - self._prev_ts
|
|
||||||
if dt > 0:
|
|
||||||
dx = cx - self._prev_cx
|
|
||||||
dy = cy - self._prev_cy
|
|
||||||
speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt)
|
|
||||||
# Vitesse signée sur l'axe du tube
|
|
||||||
# + = vers bas/droite, - = vers haut/gauche
|
|
||||||
axial_speed = float((dy / dt) if self.tube_axis == "vertical" else (dx / dt))
|
|
||||||
|
|
||||||
result.update({
|
|
||||||
"detected" : True,
|
|
||||||
"cx" : cx,
|
|
||||||
"cy" : cy,
|
|
||||||
"area_px" : int(area),
|
|
||||||
"speed_px_s" : round(speed_px_s, 3) if speed_px_s is not None else 0.0,
|
|
||||||
"axial_speed" : round(axial_speed, 3) if axial_speed is not None else 0.0,
|
|
||||||
"axial_pos" : round(axial_pos, 4),
|
|
||||||
})
|
|
||||||
|
|
||||||
self._update_prev(cx, cy, ts)
|
|
||||||
return result
|
|
||||||
'''
|
|
||||||
|
|
||||||
def process(self, frame: np.ndarray, ts: float) -> tuple[np.ndarray, dict]:
|
|
||||||
"""
|
|
||||||
Analyse une frame et dessine les contours détectés directement sur l'image.
|
|
||||||
Retourne (frame_annotée, métriques).
|
|
||||||
|
|
||||||
Contours fins Vert (0,255,0) Tous les contours valides détectés
|
|
||||||
Contour épais Cyan (255,255,0) Planaire principale (plus grand contour)
|
|
||||||
Croix + cercle Rouge (0,0,255) Centre de masse exact
|
|
||||||
Texte Blanc Vitesse px/s + position axiale normalisée
|
|
||||||
"""
|
|
||||||
result = self._empty_result(ts)
|
|
||||||
frame_out = frame.copy() # copie pour ne pas modifier l'original
|
|
||||||
|
|
||||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
||||||
fg_mask = self._bg_sub.apply(gray)
|
|
||||||
|
|
||||||
kernel = np.ones((3, 3), np.uint8)
|
|
||||||
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel)
|
|
||||||
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel)
|
|
||||||
|
|
||||||
contours, _ = cv2.findContours(
|
|
||||||
fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
|
||||||
)
|
|
||||||
|
|
||||||
if not contours:
|
|
||||||
self._update_prev(None, None, ts)
|
|
||||||
return frame_out, result
|
|
||||||
|
|
||||||
# Filtre les contours significatifs
|
|
||||||
valid_contours = [c for c in contours if cv2.contourArea(c) >= self.min_area_px]
|
|
||||||
|
|
||||||
if not valid_contours:
|
|
||||||
self._update_prev(None, None, ts)
|
|
||||||
return frame_out, result
|
|
||||||
|
|
||||||
# Dessine tous les contours valides en vert fin
|
|
||||||
cv2.drawContours(frame_out, valid_contours, -1, (0, 255, 0), 1)
|
|
||||||
|
|
||||||
# Plus grand contour = planaire principale
|
|
||||||
largest = max(valid_contours, key=cv2.contourArea)
|
|
||||||
area = cv2.contourArea(largest)
|
|
||||||
|
|
||||||
# Contour principal en cyan plus épais
|
|
||||||
cv2.drawContours(frame_out, [largest], -1, (255, 255, 0), 2)
|
|
||||||
|
|
||||||
M = cv2.moments(largest)
|
|
||||||
if M["m00"] == 0:
|
|
||||||
return frame_out, result
|
|
||||||
|
|
||||||
cx = int(M["m10"] / M["m00"])
|
|
||||||
cy = int(M["m01"] / M["m00"])
|
|
||||||
h, w = frame.shape[:2]
|
|
||||||
|
|
||||||
axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w)
|
|
||||||
speed_px_s = None
|
|
||||||
axial_speed = None
|
|
||||||
|
|
||||||
if self._prev_cx is not None and self._prev_ts is not None:
|
|
||||||
dt = ts - self._prev_ts
|
|
||||||
if dt > 0:
|
|
||||||
dx = cx - self._prev_cx
|
|
||||||
dy = cy - self._prev_cy
|
|
||||||
speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt)
|
|
||||||
axial_speed = float((dy / dt) if self.tube_axis == "vertical" else (dx / dt))
|
|
||||||
|
|
||||||
# Croix sur le centre de masse
|
|
||||||
cross_size = 8
|
|
||||||
cv2.line(frame_out, (cx - cross_size, cy), (cx + cross_size, cy), (0, 0, 255), 1)
|
|
||||||
cv2.line(frame_out, (cx, cy - cross_size), (cx, cy + cross_size), (0, 0, 255), 1)
|
|
||||||
|
|
||||||
# Cercle centré sur la planaire
|
|
||||||
cv2.circle(frame_out, (cx, cy), 12, (0, 0, 255), 1)
|
|
||||||
|
|
||||||
# Texte vitesse + position axiale
|
|
||||||
label = f"v={speed_px_s:.1f}px/s ax={axial_pos:.2f}" if speed_px_s is not None else f"ax={axial_pos:.2f}"
|
|
||||||
cv2.putText(
|
|
||||||
frame_out, label,
|
|
||||||
(max(cx - 60, 0), max(cy - 18, 12)),
|
|
||||||
cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1, cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
|
|
||||||
result.update({
|
|
||||||
"detected" : True,
|
|
||||||
"cx" : cx,
|
|
||||||
"cy" : cy,
|
|
||||||
"area_px" : int(area),
|
|
||||||
"speed_px_s" : round(speed_px_s, 3) if speed_px_s is not None else 0.0,
|
|
||||||
"axial_speed" : round(axial_speed, 3) if axial_speed is not None else 0.0,
|
|
||||||
"axial_pos" : round(axial_pos, 4),
|
|
||||||
})
|
|
||||||
|
|
||||||
self._update_prev(cx, cy, ts)
|
|
||||||
return frame_out, result
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
def _empty_result(self, ts: float) -> dict:
|
# Interface principale
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def process(self, frame: np.ndarray, ts: float) -> tuple:
|
||||||
|
"""
|
||||||
|
Analyse une frame, associe les contours aux individus connus,
|
||||||
|
dessine les annotations et retourne les métriques.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame : image BGR (numpy array)
|
||||||
|
ts : timestamp de la frame (float, secondes epoch)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple (frame_annotée, results)
|
||||||
|
|
||||||
|
frame_annotée : copie BGR avec contours, croix et textes
|
||||||
|
results : liste de dicts — un dict par planaire actif détecté.
|
||||||
|
Chaque dict contient :
|
||||||
|
planarian_id int index de l'individu (0-based)
|
||||||
|
detected bool True si détecté cette frame
|
||||||
|
cx, cy int centre de masse en pixels
|
||||||
|
area_px int surface du contour (px²)
|
||||||
|
speed_px_s float vitesse totale (px/s)
|
||||||
|
axial_speed float vitesse axiale (px/s)
|
||||||
|
axial_pos float position axiale normalisée (0-1)
|
||||||
|
timestamp float ts de la frame
|
||||||
|
"""
|
||||||
|
frame_out = frame.copy()
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
|
||||||
|
# --- Extraction du premier plan ---
|
||||||
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||||
|
fg_mask = self._bg_sub.apply(gray)
|
||||||
|
|
||||||
|
kernel = np.ones((3, 3), np.uint8)
|
||||||
|
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel)
|
||||||
|
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
# Warmup MOG2 : les premières WARMUP_FRAMES frames retournent du bruit
|
||||||
|
# (fond non encore appris) — on les alimente mais on ne détecte rien
|
||||||
|
self._warmup_count += 1
|
||||||
|
if self._warmup_count <= self.WARMUP_FRAMES:
|
||||||
|
return frame_out, []
|
||||||
|
|
||||||
|
contours, _ = cv2.findContours(
|
||||||
|
fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Surface maximale admissible : fraction de la frame
|
||||||
|
# Filtre les faux positifs du fond (contours couvrant toute l'image)
|
||||||
|
max_area_px = h * w * self.max_area_ratio
|
||||||
|
|
||||||
|
# Filtrage : surface min ET surface max, triés par surface décroissante
|
||||||
|
valid = sorted(
|
||||||
|
[c for c in contours
|
||||||
|
if self.min_area_px <= cv2.contourArea(c) <= max_area_px],
|
||||||
|
key=cv2.contourArea,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Limiter au nombre maximum de planaires attendus
|
||||||
|
valid = valid[:self.max_planarians]
|
||||||
|
|
||||||
|
# --- Calcul des centres de masse des contours détectés ---
|
||||||
|
detections = [] # liste de (cx, cy, area, contour)
|
||||||
|
for c in valid:
|
||||||
|
M = cv2.moments(c)
|
||||||
|
if M["m00"] == 0:
|
||||||
|
continue
|
||||||
|
cx = int(M["m10"] / M["m00"])
|
||||||
|
cy = int(M["m01"] / M["m00"])
|
||||||
|
area = cv2.contourArea(c)
|
||||||
|
detections.append((cx, cy, int(area), c))
|
||||||
|
|
||||||
|
# --- Association hongroise détections → slots individus ---
|
||||||
|
assignments = self._hungarian_assign(detections)
|
||||||
|
|
||||||
|
# --- Mise à jour des états et construction des résultats ---
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for slot_idx, det_idx in assignments.items():
|
||||||
|
state = self._states[slot_idx]
|
||||||
|
|
||||||
|
if det_idx is None:
|
||||||
|
# Aucune détection associée à ce slot
|
||||||
|
state.mark_lost()
|
||||||
|
if state.active and not state.is_lost:
|
||||||
|
# L'individu était suivi : on retourne un résultat "perdu"
|
||||||
|
results.append(self._lost_result(slot_idx, ts))
|
||||||
|
continue
|
||||||
|
|
||||||
|
cx, cy, area, contour = detections[det_idx]
|
||||||
|
|
||||||
|
# Calcul de la vitesse depuis la position précédente
|
||||||
|
speed_px_s, axial_speed = state.compute_speed(cx, cy, ts, self.tube_axis)
|
||||||
|
|
||||||
|
axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w)
|
||||||
|
|
||||||
|
# Mise à jour de l'état
|
||||||
|
state.update(cx, cy, ts)
|
||||||
|
|
||||||
|
# Annotation visuelle
|
||||||
|
color = INDIVIDUAL_COLORS[slot_idx % len(INDIVIDUAL_COLORS)]
|
||||||
|
cv2.drawContours(frame_out, [contour], -1, color, 2)
|
||||||
|
self._draw_center(frame_out, cx, cy, slot_idx, speed_px_s, axial_pos, color)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"planarian_id": slot_idx,
|
||||||
|
"detected": True,
|
||||||
|
"cx": cx,
|
||||||
|
"cy": cy,
|
||||||
|
"area_px": area,
|
||||||
|
"speed_px_s": round(speed_px_s, 3),
|
||||||
|
"axial_speed": round(axial_speed, 3),
|
||||||
|
"axial_pos": round(axial_pos, 4),
|
||||||
|
"timestamp": ts,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Marquer les slots non présents dans les assignments comme perdus
|
||||||
|
assigned_slots = set(assignments.keys())
|
||||||
|
for state in self._states:
|
||||||
|
if state.idx not in assigned_slots:
|
||||||
|
state.mark_lost()
|
||||||
|
|
||||||
|
return frame_out, results
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Association hongroise
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _hungarian_assign(self, detections: list) -> dict:
|
||||||
|
"""
|
||||||
|
Associe les détections courantes aux slots individus connus
|
||||||
|
via l'algorithme hongrois (coût = distance euclidienne).
|
||||||
|
|
||||||
|
Contrainte : une association n'est acceptée que si la distance
|
||||||
|
est inférieure à MAX_ASSOC_DIST_PX (évite les sauts aberrants).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
detections : liste de (cx, cy, area, contour)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {slot_idx: det_idx | None}
|
||||||
|
det_idx = None si aucune détection assignée à ce slot
|
||||||
|
"""
|
||||||
|
n_slots = self.max_planarians
|
||||||
|
n_dets = len(detections)
|
||||||
|
|
||||||
|
if n_dets == 0:
|
||||||
|
# Aucune détection : tous les slots sont "perdus"
|
||||||
|
return {i: None for i in range(n_slots)}
|
||||||
|
|
||||||
|
# Slots actifs (déjà vus au moins une fois et non perdus)
|
||||||
|
active_slots = [s for s in self._states if s.active and not s.is_lost]
|
||||||
|
|
||||||
|
if not active_slots:
|
||||||
|
# Première frame ou tous perdus : attribution séquentielle simple
|
||||||
|
assignment = {}
|
||||||
|
for i in range(n_slots):
|
||||||
|
assignment[i] = i if i < n_dets else None
|
||||||
|
return assignment
|
||||||
|
|
||||||
|
# --- Construction de la matrice de coût (distance euclidienne) ---
|
||||||
|
cost = np.full((len(active_slots), n_dets), fill_value=1e6)
|
||||||
|
|
||||||
|
for si, state in enumerate(active_slots):
|
||||||
|
for di, (cx, cy, _, _) in enumerate(detections):
|
||||||
|
dist = np.sqrt((cx - state.cx)**2 + (cy - state.cy)**2)
|
||||||
|
cost[si, di] = dist
|
||||||
|
|
||||||
|
# --- Algorithme hongrois ---
|
||||||
|
row_ind, col_ind = linear_sum_assignment(cost)
|
||||||
|
|
||||||
|
# Construire le dict d'association
|
||||||
|
assignment = {i: None for i in range(n_slots)}
|
||||||
|
|
||||||
|
assigned_dets = set()
|
||||||
|
for ri, ci in zip(row_ind, col_ind):
|
||||||
|
if cost[ri, ci] <= MAX_ASSOC_DIST_PX:
|
||||||
|
slot_idx = active_slots[ri].idx
|
||||||
|
assignment[slot_idx] = ci
|
||||||
|
assigned_dets.add(ci)
|
||||||
|
|
||||||
|
# --- Nouvelles détections non assignées → slots inactifs libres ---
|
||||||
|
free_slots = [s for s in self._states if not s.active or s.is_lost]
|
||||||
|
new_dets = [di for di in range(n_dets) if di not in assigned_dets]
|
||||||
|
|
||||||
|
for state, det_idx in zip(free_slots, new_dets):
|
||||||
|
assignment[state.idx] = det_idx
|
||||||
|
|
||||||
|
return assignment
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Dessin des annotations
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _draw_center(
|
||||||
|
self,
|
||||||
|
frame: np.ndarray,
|
||||||
|
cx: int,
|
||||||
|
cy: int,
|
||||||
|
idx: int,
|
||||||
|
speed_px_s: float,
|
||||||
|
axial_pos: float,
|
||||||
|
color: tuple,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Dessine la croix, le cercle et le label de vitesse/position
|
||||||
|
pour un individu.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame : image à annoter (en place)
|
||||||
|
cx, cy : centre de masse en pixels
|
||||||
|
idx : index de l'individu
|
||||||
|
speed_px_s : vitesse en px/s
|
||||||
|
axial_pos : position axiale normalisée
|
||||||
|
color : couleur BGR de l'individu
|
||||||
|
"""
|
||||||
|
cross = 8
|
||||||
|
cv2.line(frame, (cx - cross, cy), (cx + cross, cy), COLOR_CENTER, 1)
|
||||||
|
cv2.line(frame, (cx, cy - cross), (cx, cy + cross), COLOR_CENTER, 1)
|
||||||
|
cv2.circle(frame, (cx, cy), 12, color, 1)
|
||||||
|
|
||||||
|
# Badge numéro individu
|
||||||
|
cv2.circle(frame, (cx + 14, cy - 14), 8, color, -1)
|
||||||
|
cv2.putText(
|
||||||
|
frame, str(idx),
|
||||||
|
(cx + 10, cy - 10),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 0, 0), 1, cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Texte vitesse + position axiale
|
||||||
|
label = (
|
||||||
|
f"#{idx} v={speed_px_s:.1f}px/s ax={axial_pos:.2f}"
|
||||||
|
if speed_px_s > 0
|
||||||
|
else f"#{idx} ax={axial_pos:.2f}"
|
||||||
|
)
|
||||||
|
cv2.putText(
|
||||||
|
frame, label,
|
||||||
|
(max(cx - 60, 0), max(cy - 22, 12)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (255, 255, 255), 1, cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Résultats vides / perdus
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _lost_result(self, planarian_id: int, ts: float) -> dict:
|
||||||
|
"""
|
||||||
|
Retourne un résultat pour un individu temporairement non détecté.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
planarian_id : index de l'individu
|
||||||
|
ts : timestamp de la frame courante
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict avec detected=False et les dernières coordonnées connues
|
||||||
|
"""
|
||||||
|
state = self._states[planarian_id]
|
||||||
return {
|
return {
|
||||||
"timestamp" : ts,
|
"planarian_id": planarian_id,
|
||||||
"detected" : False,
|
"detected": False,
|
||||||
"cx" : 0,
|
"cx": state.cx or 0,
|
||||||
"cy" : 0,
|
"cy": state.cy or 0,
|
||||||
"area_px" : 0,
|
"area_px": 0,
|
||||||
"speed_px_s" : 0.0,
|
"speed_px_s": 0.0,
|
||||||
"axial_speed": 0.0,
|
"axial_speed": 0.0,
|
||||||
"axial_pos" : 0.0,
|
"axial_pos": 0.0,
|
||||||
|
"timestamp": ts,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _update_prev(self, cx, cy, ts):
|
|
||||||
self._prev_cx = cx
|
|
||||||
self._prev_cy = cy
|
|
||||||
self._prev_ts = ts
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# modules/planarian_tracker.py
|
||||||
|
'''
|
||||||
|
Created on 16 avr. 2026
|
||||||
|
|
||||||
|
@author: denis
|
||||||
|
'''
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import logging
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanarianTracker:
|
||||||
|
"""
|
||||||
|
Détection et suivi d'une planaire dans un tube.
|
||||||
|
Instancié une fois par caméra active, réutilisé frame à frame.
|
||||||
|
Utilise la soustraction de fond MOG2 — léger sur Raspberry Pi 4.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, tube_axis: str = "vertical", min_area_px: int = 20):
|
||||||
|
# Axe du tube : "vertical" (cy) ou "horizontal" (cx)
|
||||||
|
self.tube_axis = tube_axis
|
||||||
|
self.min_area_px = min_area_px
|
||||||
|
|
||||||
|
# Etat inter-frame
|
||||||
|
self._prev_cx = None
|
||||||
|
self._prev_cy = None
|
||||||
|
self._prev_ts = None
|
||||||
|
|
||||||
|
# Soustracteur de fond adaptatif MOG2
|
||||||
|
self._bg_sub = cv2.createBackgroundSubtractorMOG2(
|
||||||
|
history = 50,
|
||||||
|
varThreshold = 25,
|
||||||
|
detectShadows= False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""
|
||||||
|
Réinitialise l'état inter-frame — appeler lors du changement de puits.
|
||||||
|
"""
|
||||||
|
self._prev_cx = None
|
||||||
|
self._prev_cy = None
|
||||||
|
self._prev_ts = None
|
||||||
|
# Réinitialise le fond appris
|
||||||
|
self._bg_sub = cv2.createBackgroundSubtractorMOG2(
|
||||||
|
history = 50,
|
||||||
|
varThreshold = 25,
|
||||||
|
detectShadows= False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, frame: np.ndarray, ts: float) -> tuple[np.ndarray, dict]:
|
||||||
|
"""
|
||||||
|
Analyse une frame et dessine les contours détectés directement sur l'image.
|
||||||
|
Retourne (frame_annotée, métriques).
|
||||||
|
|
||||||
|
Contours fins Vert (0,255,0) Tous les contours valides détectés
|
||||||
|
Contour épais Cyan (255,255,0) Planaire principale (plus grand contour)
|
||||||
|
Croix + cercle Rouge (0,0,255) Centre de masse exact
|
||||||
|
Texte Blanc Vitesse px/s + position axiale normalisée
|
||||||
|
"""
|
||||||
|
result = self._empty_result(ts)
|
||||||
|
frame_out = frame.copy() # copie pour ne pas modifier l'original
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||||
|
fg_mask = self._bg_sub.apply(gray)
|
||||||
|
|
||||||
|
kernel = np.ones((3, 3), np.uint8)
|
||||||
|
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel)
|
||||||
|
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
contours, _ = cv2.findContours(
|
||||||
|
fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||||
|
)
|
||||||
|
|
||||||
|
if not contours:
|
||||||
|
self._update_prev(None, None, ts)
|
||||||
|
return frame_out, result
|
||||||
|
|
||||||
|
# Filtre les contours significatifs
|
||||||
|
valid_contours = [c for c in contours if cv2.contourArea(c) >= self.min_area_px]
|
||||||
|
|
||||||
|
if not valid_contours:
|
||||||
|
self._update_prev(None, None, ts)
|
||||||
|
return frame_out, result
|
||||||
|
|
||||||
|
# Dessine tous les contours valides en vert fin
|
||||||
|
cv2.drawContours(frame_out, valid_contours, -1, (0, 255, 0), 1)
|
||||||
|
|
||||||
|
# Plus grand contour = planaire principale
|
||||||
|
largest = max(valid_contours, key=cv2.contourArea)
|
||||||
|
area = cv2.contourArea(largest)
|
||||||
|
|
||||||
|
# Contour principal en cyan plus épais
|
||||||
|
cv2.drawContours(frame_out, [largest], -1, (255, 255, 0), 2)
|
||||||
|
|
||||||
|
M = cv2.moments(largest)
|
||||||
|
if M["m00"] == 0:
|
||||||
|
return frame_out, result
|
||||||
|
|
||||||
|
cx = int(M["m10"] / M["m00"])
|
||||||
|
cy = int(M["m01"] / M["m00"])
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
|
||||||
|
axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w)
|
||||||
|
speed_px_s = None
|
||||||
|
axial_speed = None
|
||||||
|
|
||||||
|
if self._prev_cx is not None and self._prev_ts is not None:
|
||||||
|
dt = ts - self._prev_ts
|
||||||
|
if dt > 0:
|
||||||
|
dx = cx - self._prev_cx
|
||||||
|
dy = cy - self._prev_cy
|
||||||
|
speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt)
|
||||||
|
axial_speed = float((dy / dt) if self.tube_axis == "vertical" else (dx / dt))
|
||||||
|
|
||||||
|
# Croix sur le centre de masse
|
||||||
|
cross_size = 8
|
||||||
|
cv2.line(frame_out, (cx - cross_size, cy), (cx + cross_size, cy), (0, 0, 255), 1)
|
||||||
|
cv2.line(frame_out, (cx, cy - cross_size), (cx, cy + cross_size), (0, 0, 255), 1)
|
||||||
|
|
||||||
|
# Cercle centré sur la planaire
|
||||||
|
cv2.circle(frame_out, (cx, cy), 12, (0, 0, 255), 1)
|
||||||
|
|
||||||
|
# Texte vitesse + position axiale
|
||||||
|
label = f"v={speed_px_s:.1f}px/s ax={axial_pos:.2f}" if speed_px_s is not None else f"ax={axial_pos:.2f}"
|
||||||
|
cv2.putText(
|
||||||
|
frame_out, label,
|
||||||
|
(max(cx - 60, 0), max(cy - 18, 12)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1, cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
|
||||||
|
result.update({
|
||||||
|
"detected" : True,
|
||||||
|
"cx" : cx,
|
||||||
|
"cy" : cy,
|
||||||
|
"area_px" : int(area),
|
||||||
|
"speed_px_s" : round(speed_px_s, 3) if speed_px_s is not None else 0.0,
|
||||||
|
"axial_speed" : round(axial_speed, 3) if axial_speed is not None else 0.0,
|
||||||
|
"axial_pos" : round(axial_pos, 4),
|
||||||
|
})
|
||||||
|
|
||||||
|
self._update_prev(cx, cy, ts)
|
||||||
|
return frame_out, result
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def _empty_result(self, ts: float) -> dict:
|
||||||
|
return {
|
||||||
|
"timestamp" : ts,
|
||||||
|
"detected" : False,
|
||||||
|
"cx" : 0,
|
||||||
|
"cy" : 0,
|
||||||
|
"area_px" : 0,
|
||||||
|
"speed_px_s" : 0.0,
|
||||||
|
"axial_speed": 0.0,
|
||||||
|
"axial_pos" : 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _update_prev(self, cx, cy, ts):
|
||||||
|
self._prev_cx = cx
|
||||||
|
self._prev_cy = cy
|
||||||
|
self._prev_ts = ts
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
"""
|
||||||
|
modules/planarian_tracker.py
|
||||||
|
|
||||||
|
Détection et suivi multi-individus de planaires dans un tube.
|
||||||
|
Supporte de 1 à MAX_PLANARIANS planaires par tube.
|
||||||
|
|
||||||
|
Etat inter-frame indépendant par individu : position, timestamp, compteur de perte (lost), flag active.
|
||||||
|
Quand un individu n'est pas détecté pendant MAX_LOST_FRAMES (5) frames consécutives, il est marqué perdu et son slot se libère.
|
||||||
|
|
||||||
|
Algorithme hongrois (scipy.optimize.linear_sum_assignment) dans _hungarian_assign()
|
||||||
|
— construit une matrice de coût distance euclidienne entre les slots actifs et les nouvelles détections, puis trouve l'association de coût minimal.
|
||||||
|
Une association est rejetée si la distance dépasse MAX_ASSOC_DIST_PX (80px)
|
||||||
|
— évite les sauts aberrants entre planaires proches.
|
||||||
|
|
||||||
|
|
||||||
|
Stratégie :
|
||||||
|
- Soustraction de fond MOG2 (léger sur Raspberry Pi 4)
|
||||||
|
- Détection de tous les contours valides (surface >= min_area_px)
|
||||||
|
- Association frame-à-frame par distance euclidienne minimale
|
||||||
|
via algorithme hongrois (scipy.optimize.linear_sum_assignment)
|
||||||
|
- Un état inter-frame indépendant par individu (PlanarianState)
|
||||||
|
- Retourne une liste de résultats, un par individu suivi: champ planarian_id (index 0-based).
|
||||||
|
|
||||||
|
Created on 25 avr. 2026
|
||||||
|
@author: denis
|
||||||
|
"""
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import logging
|
||||||
|
import numpy as np
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from scipy.optimize import linear_sum_assignment # @UnresolvedImport
|
||||||
|
|
||||||
|
# Nombre maximum de planaires suivis simultanément par tube
|
||||||
|
MAX_PLANARIANS = 10
|
||||||
|
|
||||||
|
# Distance maximale en pixels entre deux positions consécutives
|
||||||
|
# pour qu'une association soit acceptée (évite les sauts aberrants)
|
||||||
|
MAX_ASSOC_DIST_PX = 80
|
||||||
|
|
||||||
|
# Couleurs d'annotation par individu (BGR)
|
||||||
|
# Cycle automatique si plus de planaires que de couleurs
|
||||||
|
INDIVIDUAL_COLORS = [
|
||||||
|
(255, 255, 0), # cyan
|
||||||
|
( 0, 165, 255), # orange
|
||||||
|
(255, 0, 255), # magenta
|
||||||
|
( 0, 255, 255), # jaune
|
||||||
|
(128, 0, 255), # violet
|
||||||
|
( 0, 255, 128), # vert clair
|
||||||
|
(255, 128, 0), # bleu clair
|
||||||
|
( 0, 128, 255), # orange foncé
|
||||||
|
(128, 255, 0), # vert-jaune
|
||||||
|
(255, 0, 128), # rose
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Couleur du contour principal (individu le plus grand)
|
||||||
|
COLOR_LARGEST = (255, 255, 0) # cyan
|
||||||
|
COLOR_OTHER = ( 0, 255, 0) # vert
|
||||||
|
COLOR_CENTER = ( 0, 0, 255) # rouge
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# État inter-frame d'un individu
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PlanarianState:
|
||||||
|
"""
|
||||||
|
Mémorise la position et le timestamp de la dernière détection
|
||||||
|
pour un planaire individuel.
|
||||||
|
|
||||||
|
Un PlanarianState par slot (index 0 à max_planarians-1).
|
||||||
|
Quand un slot n'est pas associé à un contour sur plusieurs frames
|
||||||
|
consécutives, il est marqué comme perdu (lost).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Nombre de frames sans détection avant de considérer l'individu perdu
|
||||||
|
MAX_LOST_FRAMES = 5
|
||||||
|
|
||||||
|
def __init__(self, idx: int):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
idx : index de l'individu (0-based)
|
||||||
|
"""
|
||||||
|
self.idx = idx
|
||||||
|
self.cx = None
|
||||||
|
self.cy = None
|
||||||
|
self.ts = None
|
||||||
|
self.lost = 0 # compteur de frames sans détection
|
||||||
|
self.active = False # vrai si l'individu a été détecté au moins une fois
|
||||||
|
|
||||||
|
def update(self, cx: int, cy: int, ts: float):
|
||||||
|
"""
|
||||||
|
Met à jour la position suite à une association réussie.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cx, cy : position du centre de masse en pixels
|
||||||
|
ts : timestamp de la frame
|
||||||
|
"""
|
||||||
|
self.cx = cx
|
||||||
|
self.cy = cy
|
||||||
|
self.ts = ts
|
||||||
|
self.lost = 0
|
||||||
|
self.active = True
|
||||||
|
|
||||||
|
def mark_lost(self):
|
||||||
|
"""Incrémente le compteur de perte — appelé quand aucun contour n'est associé."""
|
||||||
|
self.lost += 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_lost(self) -> bool:
|
||||||
|
"""Retourne True si l'individu est considéré perdu (trop de frames sans détection)."""
|
||||||
|
return self.lost >= self.MAX_LOST_FRAMES
|
||||||
|
|
||||||
|
def compute_speed(self, cx: int, cy: int, ts: float, tube_axis: str) -> tuple:
|
||||||
|
"""
|
||||||
|
Calcule la vitesse instantanée depuis la position précédente.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cx, cy : position courante en pixels
|
||||||
|
ts : timestamp courant
|
||||||
|
tube_axis : "vertical" ou "horizontal"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple (speed_px_s, axial_speed) ou (0.0, 0.0) si état vide
|
||||||
|
"""
|
||||||
|
if self.cx is None or self.ts is None:
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
dt = ts - self.ts
|
||||||
|
if dt <= 0:
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
dx = cx - self.cx
|
||||||
|
dy = cy - self.cy
|
||||||
|
speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt)
|
||||||
|
axial_speed = float((dy / dt) if tube_axis == "vertical" else (dx / dt))
|
||||||
|
|
||||||
|
return speed_px_s, axial_speed
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Réinitialise l'état de cet individu."""
|
||||||
|
self.cx = None
|
||||||
|
self.cy = None
|
||||||
|
self.ts = None
|
||||||
|
self.lost = 0
|
||||||
|
self.active = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tracker multi-individus
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PlanarianTracker:
|
||||||
|
"""
|
||||||
|
Détection et suivi multi-individus de planaires dans un tube.
|
||||||
|
|
||||||
|
Instancié une fois par caméra active, réutilisé frame à frame.
|
||||||
|
Utilise la soustraction de fond MOG2 — léger sur Raspberry Pi 4.
|
||||||
|
Association frame-à-frame par algorithme hongrois (distance euclidienne).
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
tracker = PlanarianTracker(tube_axis="vertical", max_planarians=3)
|
||||||
|
while capturing:
|
||||||
|
frame_out, results = tracker.process(frame, ts)
|
||||||
|
# results : liste de dicts, un par individu détecté
|
||||||
|
for r in results:
|
||||||
|
metrics.update(r, planarian_id=r["planarian_id"])
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
tube_axis: str = "vertical",
|
||||||
|
min_area_px: int = 20,
|
||||||
|
max_planarians: int = 1,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
tube_axis : axe principal du tube — "vertical" (cy) ou "horizontal" (cx)
|
||||||
|
min_area_px : surface minimale d'un contour pour être considéré valide (px²)
|
||||||
|
max_planarians : nombre maximum de planaires à suivre simultanément (1-10)
|
||||||
|
"""
|
||||||
|
self.tube_axis = tube_axis
|
||||||
|
self.min_area_px = min_area_px
|
||||||
|
self.max_planarians = max(1, min(max_planarians, MAX_PLANARIANS))
|
||||||
|
|
||||||
|
# Un état inter-frame par slot individu
|
||||||
|
self._states = [PlanarianState(i) for i in range(self.max_planarians)]
|
||||||
|
|
||||||
|
# Soustracteur de fond adaptatif MOG2
|
||||||
|
self._bg_sub = self._make_bg_sub()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_bg_sub():
|
||||||
|
"""Crée et retourne un soustracteur de fond MOG2."""
|
||||||
|
return cv2.createBackgroundSubtractorMOG2(
|
||||||
|
history = 50,
|
||||||
|
varThreshold = 25,
|
||||||
|
detectShadows= False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""
|
||||||
|
Réinitialise l'état inter-frame complet.
|
||||||
|
À appeler lors du changement de puits.
|
||||||
|
"""
|
||||||
|
for s in self._states:
|
||||||
|
s.reset()
|
||||||
|
self._bg_sub = self._make_bg_sub()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Interface principale
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def process(self, frame: np.ndarray, ts: float) -> tuple:
|
||||||
|
"""
|
||||||
|
Analyse une frame, associe les contours aux individus connus,
|
||||||
|
dessine les annotations et retourne les métriques.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame : image BGR (numpy array)
|
||||||
|
ts : timestamp de la frame (float, secondes epoch)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple (frame_annotée, results)
|
||||||
|
|
||||||
|
frame_annotée : copie BGR avec contours, croix et textes
|
||||||
|
results : liste de dicts — un dict par planaire actif détecté.
|
||||||
|
Chaque dict contient :
|
||||||
|
planarian_id int index de l'individu (0-based)
|
||||||
|
detected bool True si détecté cette frame
|
||||||
|
cx, cy int centre de masse en pixels
|
||||||
|
area_px int surface du contour (px²)
|
||||||
|
speed_px_s float vitesse totale (px/s)
|
||||||
|
axial_speed float vitesse axiale (px/s)
|
||||||
|
axial_pos float position axiale normalisée (0-1)
|
||||||
|
timestamp float ts de la frame
|
||||||
|
"""
|
||||||
|
frame_out = frame.copy()
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
|
||||||
|
# --- Extraction du premier plan ---
|
||||||
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||||
|
fg_mask = self._bg_sub.apply(gray)
|
||||||
|
|
||||||
|
kernel = np.ones((3, 3), np.uint8)
|
||||||
|
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel)
|
||||||
|
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
contours, _ = cv2.findContours(
|
||||||
|
fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filtrage des contours significatifs, triés par surface décroissante
|
||||||
|
valid = sorted(
|
||||||
|
[c for c in contours if cv2.contourArea(c) >= self.min_area_px],
|
||||||
|
key=cv2.contourArea,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Limiter au nombre maximum de planaires attendus
|
||||||
|
valid = valid[:self.max_planarians]
|
||||||
|
|
||||||
|
# --- Calcul des centres de masse des contours détectés ---
|
||||||
|
detections = [] # liste de (cx, cy, area, contour)
|
||||||
|
for c in valid:
|
||||||
|
M = cv2.moments(c)
|
||||||
|
if M["m00"] == 0:
|
||||||
|
continue
|
||||||
|
cx = int(M["m10"] / M["m00"])
|
||||||
|
cy = int(M["m01"] / M["m00"])
|
||||||
|
area = cv2.contourArea(c)
|
||||||
|
detections.append((cx, cy, int(area), c))
|
||||||
|
|
||||||
|
# --- Association hongroise détections → slots individus ---
|
||||||
|
assignments = self._hungarian_assign(detections)
|
||||||
|
|
||||||
|
# --- Mise à jour des états et construction des résultats ---
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for slot_idx, det_idx in assignments.items():
|
||||||
|
state = self._states[slot_idx]
|
||||||
|
|
||||||
|
if det_idx is None:
|
||||||
|
# Aucune détection associée à ce slot
|
||||||
|
state.mark_lost()
|
||||||
|
if state.active and not state.is_lost:
|
||||||
|
# L'individu était suivi : on retourne un résultat "perdu"
|
||||||
|
results.append(self._lost_result(slot_idx, ts))
|
||||||
|
continue
|
||||||
|
|
||||||
|
cx, cy, area, contour = detections[det_idx]
|
||||||
|
|
||||||
|
# Calcul de la vitesse depuis la position précédente
|
||||||
|
speed_px_s, axial_speed = state.compute_speed(cx, cy, ts, self.tube_axis)
|
||||||
|
|
||||||
|
axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w)
|
||||||
|
|
||||||
|
# Mise à jour de l'état
|
||||||
|
state.update(cx, cy, ts)
|
||||||
|
|
||||||
|
# Annotation visuelle
|
||||||
|
color = INDIVIDUAL_COLORS[slot_idx % len(INDIVIDUAL_COLORS)]
|
||||||
|
cv2.drawContours(frame_out, [contour], -1, color, 2)
|
||||||
|
self._draw_center(frame_out, cx, cy, slot_idx, speed_px_s, axial_pos, color)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"planarian_id": slot_idx,
|
||||||
|
"detected": True,
|
||||||
|
"cx": cx,
|
||||||
|
"cy": cy,
|
||||||
|
"area_px": area,
|
||||||
|
"speed_px_s": round(speed_px_s, 3),
|
||||||
|
"axial_speed": round(axial_speed, 3),
|
||||||
|
"axial_pos": round(axial_pos, 4),
|
||||||
|
"timestamp": ts,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Marquer les slots non présents dans les assignments comme perdus
|
||||||
|
assigned_slots = set(assignments.keys())
|
||||||
|
for state in self._states:
|
||||||
|
if state.idx not in assigned_slots:
|
||||||
|
state.mark_lost()
|
||||||
|
|
||||||
|
|
||||||
|
return frame_out, results
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Association hongroise
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _hungarian_assign(self, detections: list) -> dict:
|
||||||
|
"""
|
||||||
|
Associe les détections courantes aux slots individus connus
|
||||||
|
via l'algorithme hongrois (coût = distance euclidienne).
|
||||||
|
|
||||||
|
Contrainte : une association n'est acceptée que si la distance
|
||||||
|
est inférieure à MAX_ASSOC_DIST_PX (évite les sauts aberrants).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
detections : liste de (cx, cy, area, contour)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {slot_idx: det_idx | None}
|
||||||
|
det_idx = None si aucune détection assignée à ce slot
|
||||||
|
"""
|
||||||
|
n_slots = self.max_planarians
|
||||||
|
n_dets = len(detections)
|
||||||
|
|
||||||
|
if n_dets == 0:
|
||||||
|
# Aucune détection : tous les slots sont "perdus"
|
||||||
|
return {i: None for i in range(n_slots)}
|
||||||
|
|
||||||
|
# Slots actifs (déjà vus au moins une fois et non perdus)
|
||||||
|
active_slots = [s for s in self._states if s.active and not s.is_lost]
|
||||||
|
|
||||||
|
if not active_slots:
|
||||||
|
# Première frame ou tous perdus : attribution séquentielle simple
|
||||||
|
assignment = {}
|
||||||
|
for i in range(n_slots):
|
||||||
|
assignment[i] = i if i < n_dets else None
|
||||||
|
return assignment
|
||||||
|
|
||||||
|
# --- Construction de la matrice de coût (distance euclidienne) ---
|
||||||
|
cost = np.full((len(active_slots), n_dets), fill_value=1e6)
|
||||||
|
|
||||||
|
for si, state in enumerate(active_slots):
|
||||||
|
for di, (cx, cy, _, _) in enumerate(detections):
|
||||||
|
dist = np.sqrt((cx - state.cx)**2 + (cy - state.cy)**2)
|
||||||
|
cost[si, di] = dist
|
||||||
|
|
||||||
|
# --- Algorithme hongrois ---
|
||||||
|
row_ind, col_ind = linear_sum_assignment(cost)
|
||||||
|
|
||||||
|
# Construire le dict d'association
|
||||||
|
assignment = {i: None for i in range(n_slots)}
|
||||||
|
|
||||||
|
assigned_dets = set()
|
||||||
|
for ri, ci in zip(row_ind, col_ind):
|
||||||
|
if cost[ri, ci] <= MAX_ASSOC_DIST_PX:
|
||||||
|
slot_idx = active_slots[ri].idx
|
||||||
|
assignment[slot_idx] = ci
|
||||||
|
assigned_dets.add(ci)
|
||||||
|
|
||||||
|
# --- Nouvelles détections non assignées → slots inactifs libres ---
|
||||||
|
free_slots = [s for s in self._states if not s.active or s.is_lost]
|
||||||
|
new_dets = [di for di in range(n_dets) if di not in assigned_dets]
|
||||||
|
|
||||||
|
for state, det_idx in zip(free_slots, new_dets):
|
||||||
|
assignment[state.idx] = det_idx
|
||||||
|
|
||||||
|
return assignment
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Dessin des annotations
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _draw_center(
|
||||||
|
self,
|
||||||
|
frame: np.ndarray,
|
||||||
|
cx: int,
|
||||||
|
cy: int,
|
||||||
|
idx: int,
|
||||||
|
speed_px_s: float,
|
||||||
|
axial_pos: float,
|
||||||
|
color: tuple,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Dessine la croix, le cercle et le label de vitesse/position
|
||||||
|
pour un individu.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame : image à annoter (en place)
|
||||||
|
cx, cy : centre de masse en pixels
|
||||||
|
idx : index de l'individu
|
||||||
|
speed_px_s : vitesse en px/s
|
||||||
|
axial_pos : position axiale normalisée
|
||||||
|
color : couleur BGR de l'individu
|
||||||
|
"""
|
||||||
|
cross = 8
|
||||||
|
cv2.line(frame, (cx - cross, cy), (cx + cross, cy), COLOR_CENTER, 1)
|
||||||
|
cv2.line(frame, (cx, cy - cross), (cx, cy + cross), COLOR_CENTER, 1)
|
||||||
|
cv2.circle(frame, (cx, cy), 12, color, 1)
|
||||||
|
|
||||||
|
# Badge numéro individu
|
||||||
|
cv2.circle(frame, (cx + 14, cy - 14), 8, color, -1)
|
||||||
|
cv2.putText(
|
||||||
|
frame, str(idx),
|
||||||
|
(cx + 10, cy - 10),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 0, 0), 1, cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Texte vitesse + position axiale
|
||||||
|
label = (
|
||||||
|
f"#{idx} v={speed_px_s:.1f}px/s ax={axial_pos:.2f}"
|
||||||
|
if speed_px_s > 0
|
||||||
|
else f"#{idx} ax={axial_pos:.2f}"
|
||||||
|
)
|
||||||
|
cv2.putText(
|
||||||
|
frame, label,
|
||||||
|
(max(cx - 60, 0), max(cy - 22, 12)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (255, 255, 255), 1, cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Résultats vides / perdus
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _lost_result(self, planarian_id: int, ts: float) -> dict:
|
||||||
|
"""
|
||||||
|
Retourne un résultat pour un individu temporairement non détecté.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
planarian_id : index de l'individu
|
||||||
|
ts : timestamp de la frame courante
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict avec detected=False et les dernières coordonnées connues
|
||||||
|
"""
|
||||||
|
state = self._states[planarian_id]
|
||||||
|
return {
|
||||||
|
"planarian_id": planarian_id,
|
||||||
|
"detected": False,
|
||||||
|
"cx": state.cx or 0,
|
||||||
|
"cy": state.cy or 0,
|
||||||
|
"area_px": 0,
|
||||||
|
"speed_px_s": 0.0,
|
||||||
|
"axial_speed": 0.0,
|
||||||
|
"axial_pos": 0.0,
|
||||||
|
"timestamp": ts,
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ class VideoFileCapture(VideoCaptureInterface):
|
|||||||
:param width: Largeur souhaitée (None = valeur par défaut du pilote)
|
:param width: Largeur souhaitée (None = valeur par défaut du pilote)
|
||||||
:param height: Hauteur souhaitée (None = valeur par défaut du pilote)
|
:param height: Hauteur souhaitée (None = valeur par défaut du pilote)
|
||||||
"""
|
"""
|
||||||
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent)
|
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent, jpeg_quality=jpeg_quality)
|
||||||
self._video_file: str = video_file
|
self._video_file: str = video_file
|
||||||
self._jpeg_quality: int = jpeg_quality
|
self._jpeg_quality: int = jpeg_quality
|
||||||
self._width: Optional[int] = width
|
self._width: Optional[int] = width
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class WebcamCapture(VideoCaptureInterface):
|
|||||||
:param width: Largeur souhaitée (None = valeur par défaut du pilote)
|
:param width: Largeur souhaitée (None = valeur par défaut du pilote)
|
||||||
:param height: Hauteur souhaitée (None = valeur par défaut du pilote)
|
:param height: Hauteur souhaitée (None = valeur par défaut du pilote)
|
||||||
"""
|
"""
|
||||||
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent)
|
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent, jpeg_quality=jpeg_quality)
|
||||||
self._device_index: int = device_index
|
self._device_index: int = device_index
|
||||||
self._jpeg_quality: int = jpeg_quality
|
self._jpeg_quality: int = jpeg_quality
|
||||||
self._width: Optional[int] = width
|
self._width: Optional[int] = width
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from .models import ExperimentConfig
|
|||||||
@admin.register(ExperimentConfig)
|
@admin.register(ExperimentConfig)
|
||||||
class ExperimentConfigAdmin(admin.ModelAdmin):
|
class ExperimentConfigAdmin(admin.ModelAdmin):
|
||||||
"""Admin Django pour les configurations d'expérience."""
|
"""Admin Django pour les configurations d'expérience."""
|
||||||
readonly_fields = ('experiment', )
|
#readonly_fields = ('experiment', )
|
||||||
|
readonly_fields = ("identifier", 'px_per_mm', 'fps', 'well_radius_mm',)
|
||||||
list_display = ("experiment", "well", "px_per_mm", "fps",
|
list_display = ("experiment", "well", "px_per_mm", "fps",
|
||||||
"thresh_immobile", "thresh_mobile",
|
"thresh_immobile", "thresh_mobile",
|
||||||
"photo_mode", "chemo_strength", "created_at")
|
"photo_mode", "chemo_strength", "created_at")
|
||||||
@@ -18,19 +19,23 @@ class ExperimentConfigAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(_("Identification"), {
|
(_("Identification"), {
|
||||||
"fields": ("experiment", "well", "description"),
|
"fields": ("identifier", "experiment", "well", "description"),
|
||||||
}),
|
}),
|
||||||
(_("Calibration optique"), {
|
(_("Calibration optique"), {
|
||||||
"fields": ("px_per_mm", "fps", "well_radius_mm"),
|
"fields": ("px_per_mm", "fps", "well_radius_mm"),
|
||||||
|
"classes": ("collapse",),
|
||||||
}),
|
}),
|
||||||
(_("Seuils de mobilité EthoVision"), {
|
(_("Seuils de mobilité EthoVision"), {
|
||||||
"fields": ("thresh_immobile", "thresh_mobile"),
|
"fields": ("thresh_immobile", "thresh_mobile"),
|
||||||
|
"classes": ("collapse",),
|
||||||
}),
|
}),
|
||||||
(_("Tracker"), {
|
(_("Tracker"), {
|
||||||
"fields": ("tube_axis", "min_area_px", "planarian_count"),
|
"fields": ("tube_axis", "min_area_px", "planarian_count"),
|
||||||
|
"classes": ("collapse",),
|
||||||
}),
|
}),
|
||||||
(_("Thigmotactisme"), {
|
(_("Thigmotactisme"), {
|
||||||
"fields": ("thigmotaxis_wall_dist_mm",),
|
"fields": ("thigmotaxis_wall_dist_mm",),
|
||||||
|
"classes": ("collapse",),
|
||||||
}),
|
}),
|
||||||
(_("Phototactisme"), {
|
(_("Phototactisme"), {
|
||||||
"fields": ("photo_mode", "photo_strength", "photo_x", "photo_y"),
|
"fields": ("photo_mode", "photo_strength", "photo_x", "photo_y"),
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ from .models import ExperimentConfig
|
|||||||
class ExperimentConfigForm(forms.ModelForm):
|
class ExperimentConfigForm(forms.ModelForm):
|
||||||
"""Formulaire de saisie/modification d'un ExperimentConfig."""
|
"""Formulaire de saisie/modification d'un ExperimentConfig."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['identifier'].disabled = True
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ExperimentConfig
|
model = ExperimentConfig
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.conf import settings
|
#from django.conf import settings
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from scanner.models import Experiment, Well, WellPosition
|
from scanner.models import Experiment, Well, WellPosition
|
||||||
@@ -17,19 +18,10 @@ class ExperimentConfig(models.Model):
|
|||||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
||||||
|
|
||||||
# --- Identification ---
|
# --- Identification ---
|
||||||
idendifier = models.CharField(
|
identifier = models.CharField( max_length=100, verbose_name=_("Identifiant d'expérience"), help_text=_("session_1-HD-2026-04-27"), )
|
||||||
max_length=100,
|
experiment = models.ForeignKey(Experiment, verbose_name="Expérience", on_delete=models.CASCADE, related_name="experiment_well" , null=True, blank=True)
|
||||||
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 )
|
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"), )
|
||||||
description = models.TextField(
|
|
||||||
blank=True,
|
|
||||||
verbose_name=_("Description"),
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le"))
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le"))
|
||||||
|
|
||||||
# --- Calibration optique ---
|
# --- Calibration optique ---
|
||||||
@@ -42,10 +34,12 @@ class ExperimentConfig(models.Model):
|
|||||||
fps = models.FloatField(
|
fps = models.FloatField(
|
||||||
default=5.0,
|
default=5.0,
|
||||||
verbose_name=_("FPS de capture"),
|
verbose_name=_("FPS de capture"),
|
||||||
|
help_text=_("Image de capture en img/s"),
|
||||||
)
|
)
|
||||||
well_radius_mm = models.FloatField(
|
well_radius_mm = models.FloatField(
|
||||||
default=8.0,
|
default=8.0,
|
||||||
verbose_name=_("Rayon du puits (mm)"),
|
verbose_name=_("Rayon du puits"),
|
||||||
|
help_text=_("En mm"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Seuils de mobilité EthoVision ---
|
# --- Seuils de mobilité EthoVision ---
|
||||||
@@ -109,7 +103,7 @@ class ExperimentConfig(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Configuration expérience")
|
verbose_name = _("Configuration expérience")
|
||||||
verbose_name_plural = _("Configurations expériences")
|
verbose_name_plural = _("Configuration des expériences")
|
||||||
unique_together = ("experiment", "well")
|
unique_together = ("experiment", "well")
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
@@ -145,15 +139,19 @@ class ExperimentConfig(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
session = self.get_session()
|
session = self.get_session()
|
||||||
position = self.experiment.multiwell.position
|
dte = self.experiment.created.isoformat()[:19]
|
||||||
dte = self.experiment.multiwell.finished.isoformat()
|
self.identifier = f'{dte}-{session.id}-{self.experiment.id}-{self.experiment.multiwell.position}-{self.well.name}'
|
||||||
self.idendifier = f'{session}-{position}-{self.well.name}-{dte}'
|
|
||||||
|
print(self.identifier)
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=ExperimentConfig)
|
@receiver(post_save, sender=ExperimentConfig)
|
||||||
def create_well_position(sender, instance, created, **kwargs):
|
def create_well_position(sender, instance, created, **kwargs):
|
||||||
active_well = WellPosition.active_well(instance.multiwel, instance.well)
|
if created:
|
||||||
|
active_well = WellPosition.active_well(instance.experiment.multiwell, instance.well)
|
||||||
instance.px_per_mm = active_well.px_per_mm
|
instance.px_per_mm = active_well.px_per_mm
|
||||||
instance.well_radius_mm = instance.experiment.multiwell.diameter / 2
|
instance.well_radius_mm = instance.experiment.multiwell.diameter / 2
|
||||||
conf = ScannerConstants().get()
|
conf = ScannerConstants().get()
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "scanner/base.html" %}
|
||||||
|
{% load i18n home_tags scanner_tags %}
|
||||||
|
|
||||||
|
{% block columns %}{% endblock %}
|
||||||
|
|
||||||
|
{% block sidebar_list %}
|
||||||
|
<a href="/" class="w3-bar-item w3-btn w3-hover-opacity"><i class="fa-solid fa-house w3-text-orange w3-xlarge"></i> {% trans "Retour accueil" %}</a>
|
||||||
|
<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()" title="{% trans "Ensemble d'expériences" %}">
|
||||||
|
<option value="0">---- {% trans "Session" %}</option>
|
||||||
|
{% for s in sessions %}
|
||||||
|
<option value="{{ s.id }}" {% if s.id == current_session.id %}selected{% endif %}>{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="w3-margin-left w3-margin-bottom">
|
||||||
|
{% for ss in experiments %}
|
||||||
|
<input class="" type="radio" name="_expid" value="{{ ss.id }}" {% if ss.id == current_experiment.id %}checked{% endif %} onchange="this.form.submit()" >
|
||||||
|
<label>{{ ss.multiwell }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if current_session.id and current_experiment %}
|
||||||
|
{% multiwell_cards current_session.id current_experiment %}
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% if current_session.id %}
|
||||||
|
<a href="{% url 'scanner:main' %}" class="w3-bar-item w3-btn w3-hover-opacity">
|
||||||
|
<i class="fa-solid fa-film w3-text-green w3-xlarge""></i> {% trans "Balayage multi-puits" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_footer %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
// Auto-dismiss messages after 5 seconds
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const messages = document.querySelectorAll('.alert');
|
||||||
|
messages.forEach(function(message) {
|
||||||
|
setTimeout(function() {
|
||||||
|
// Fade out effect
|
||||||
|
message.style.transition = 'opacity 0.5s ease-out';
|
||||||
|
message.style.opacity = '0';
|
||||||
|
|
||||||
|
// Remove from DOM after fade
|
||||||
|
setTimeout(function() {
|
||||||
|
message.remove();
|
||||||
|
}, 500);
|
||||||
|
}, 5000); // 5 seconds
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{% extends "scanner/base.html" %}
|
{% extends "planarian/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="w3-container w3-padding-32" style="max-width:960px; margin:auto;">
|
<div class="w3-container w3-padding-small" style="max-width:960px; margin:auto;">
|
||||||
|
|
||||||
<!-- En-tête de la page -->
|
<!-- 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-panel w3-teal w3-round-large w3-padding-16" style="margin-bottom:2rem;">
|
||||||
@@ -45,19 +45,33 @@
|
|||||||
<!-- ============================================================
|
<!-- ============================================================
|
||||||
Section 1 : Identification
|
Section 1 : Identification
|
||||||
============================================================ -->
|
============================================================ -->
|
||||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
<div class="w3-card-4 w3-round-large w3-margin-bottom w3-border w3-round w3-round-large">
|
||||||
<header class="w3-container w3-teal w3-round-top-large">
|
<header class="w3-container w3-teal w3-round w3-round-large">
|
||||||
<h3 class="w3-text-white">{% trans "Identification" %}</h3>
|
<h3 class="w3-text-white">{% trans "Identification" %}</h3>
|
||||||
</header>
|
</header>
|
||||||
<div class="w3-container w3-padding-24">
|
<div class="w3-container w3-padding">
|
||||||
<div class="w3-row-padding">
|
<div class="w3-row w3-row-padding">
|
||||||
|
<!-- identifier -->
|
||||||
|
<div class="w3-half">
|
||||||
|
<label class="w3-text-teal"><b>{{ form.identifier.label }}</b></label>
|
||||||
|
{{ form.identifier }}
|
||||||
|
{% if form.identifier.errors %}
|
||||||
|
<span class="w3-text-red w3-small">{{ form.identifier.errors|join:", " }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- author -->
|
||||||
|
<div class="w3-half w3-margin-bottom">
|
||||||
|
<label class="w3-text-teal"><b>{{ form.author.label }}</b></label>
|
||||||
|
{{ form.author }}
|
||||||
|
{% if form.author.errors %}
|
||||||
|
<span class="w3-text-red w3-small">{{ form.author.errors|join:", " }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Experiment -->
|
<!-- Experiment -->
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
<div class="w3-half w3-margin-bottom">
|
||||||
<label class="w3-text-teal"><b>{{ form.experiment.label }}</b></label>
|
<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 }}
|
{{ form.experiment }}
|
||||||
{% if form.experiment.errors %}
|
{% if form.experiment.errors %}
|
||||||
<span class="w3-text-red w3-small">{{ form.experiment.errors|join:", " }}</span>
|
<span class="w3-text-red w3-small">{{ form.experiment.errors|join:", " }}</span>
|
||||||
@@ -65,11 +79,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Well -->
|
<!-- Well -->
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
<div class="w3-half w3-margin-bottom">
|
||||||
<label class="w3-text-teal"><b>{{ form.well.label }}</b></label>
|
<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 }}
|
{{ form.well }}
|
||||||
{% if form.well.errors %}
|
{% if form.well.errors %}
|
||||||
<span class="w3-text-red w3-small">{{ form.well.errors|join:", " }}</span>
|
<span class="w3-text-red w3-small">{{ form.well.errors|join:", " }}</span>
|
||||||
@@ -79,7 +90,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="w3-margin-bottom">
|
<div class="w3-margin-bottom w3-padding">
|
||||||
<label class="w3-text-teal"><b>{{ form.description.label }}</b></label>
|
<label class="w3-text-teal"><b>{{ form.description.label }}</b></label>
|
||||||
{{ form.description }}
|
{{ form.description }}
|
||||||
{% if form.description.errors %}
|
{% if form.description.errors %}
|
||||||
@@ -93,12 +104,12 @@
|
|||||||
<!-- ============================================================
|
<!-- ============================================================
|
||||||
Section 2 : Calibration optique
|
Section 2 : Calibration optique
|
||||||
============================================================ -->
|
============================================================ -->
|
||||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
<div class="w3-card-4 w3-round-large w3-margin-bottom w3-border w3-round w3-round-large">
|
||||||
<header class="w3-container w3-blue-grey w3-round-top-large">
|
<header class="w3-container w3-blue-grey w3-round w3-round-top-large">
|
||||||
<h3 class="w3-text-white">{% trans "Calibration optique" %}</h3>
|
<h3 class="w3-text-white">{% trans "Calibration optique" %}</h3>
|
||||||
</header>
|
</header>
|
||||||
<div class="w3-container w3-padding-24">
|
<div class="w3-container w3-padding-24">
|
||||||
<div class="w3-row-padding">
|
<div class="w3-row w3-row-padding">
|
||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||||
<label class="w3-text-blue-grey"><b>{{ form.px_per_mm.label }}</b></label>
|
<label class="w3-text-blue-grey"><b>{{ form.px_per_mm.label }}</b></label>
|
||||||
@@ -113,6 +124,9 @@
|
|||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||||
<label class="w3-text-blue-grey"><b>{{ form.fps.label }}</b></label>
|
<label class="w3-text-blue-grey"><b>{{ form.fps.label }}</b></label>
|
||||||
|
{% if form.fps.help_text %}
|
||||||
|
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.fps.help_text }}</p>
|
||||||
|
{% endif %}
|
||||||
{{ form.fps }}
|
{{ form.fps }}
|
||||||
{% if form.fps.errors %}
|
{% if form.fps.errors %}
|
||||||
<span class="w3-text-red w3-small">{{ form.fps.errors|join:", " }}</span>
|
<span class="w3-text-red w3-small">{{ form.fps.errors|join:", " }}</span>
|
||||||
@@ -121,6 +135,9 @@
|
|||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||||
<label class="w3-text-blue-grey"><b>{{ form.well_radius_mm.label }}</b></label>
|
<label class="w3-text-blue-grey"><b>{{ form.well_radius_mm.label }}</b></label>
|
||||||
|
{% if form.well_radius_mm.help_text %}
|
||||||
|
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.well_radius_mm.help_text }}</p>
|
||||||
|
{% endif %}
|
||||||
{{ form.well_radius_mm }}
|
{{ form.well_radius_mm }}
|
||||||
{% if form.well_radius_mm.errors %}
|
{% if form.well_radius_mm.errors %}
|
||||||
<span class="w3-text-red w3-small">{{ form.well_radius_mm.errors|join:", " }}</span>
|
<span class="w3-text-red w3-small">{{ form.well_radius_mm.errors|join:", " }}</span>
|
||||||
@@ -130,12 +147,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- ============================================================
|
||||||
|
Section 4 : Tracker
|
||||||
|
============================================================ -->
|
||||||
|
<div class="w3-card-4 w3-round-large w3-margin-bottom w3-border w3-round w3-round-large">
|
||||||
|
<header class="w3-container w3-dark-grey w3-round 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-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-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-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 3 : Seuils de mobilité EthoVision
|
Section 3 : Seuils de mobilité EthoVision
|
||||||
============================================================ -->
|
============================================================ -->
|
||||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
<div class="w3-card-4 w3-round-large w3-margin-bottom w3-border w3-round w3-round-large">
|
||||||
<header class="w3-container w3-indigo w3-round-top-large">
|
<header class="w3-container w3-indigo w3-round w3-round-top-large">
|
||||||
<h3 class="w3-text-white">{% trans "Seuils de mobilité EthoVision XT" %}</h3>
|
<h3 class="w3-text-white">{% trans "Seuils de mobilité EthoVision XT" %}</h3>
|
||||||
</header>
|
</header>
|
||||||
<div class="w3-container w3-padding-24">
|
<div class="w3-container w3-padding-24">
|
||||||
@@ -177,49 +230,11 @@
|
|||||||
</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)
|
Section 5 : Comportements (accordéon W3.CSS)
|
||||||
============================================================ -->
|
============================================================ -->
|
||||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
<div class="w3-card-4 w3-round-large w3-margin-bottom w3-border w3-round w3-round-large">
|
||||||
<header class="w3-container w3-teal w3-round-top-large">
|
<header class="w3-container w3-teal w3-round w3-round-top-large">
|
||||||
<h3 class="w3-text-white">{% trans "Comportements" %}</h3>
|
<h3 class="w3-text-white">{% trans "Comportements" %}</h3>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -252,8 +267,7 @@
|
|||||||
|
|
||||||
<!-- --- Phototactisme --- -->
|
<!-- --- Phototactisme --- -->
|
||||||
<div class="w3-container w3-padding-16 w3-border-bottom">
|
<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"
|
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey" onclick="toggleSection('photo')">
|
||||||
onclick="toggleSection('photo')">
|
|
||||||
<span class="w3-large">💡</span>
|
<span class="w3-large">💡</span>
|
||||||
<b class="w3-margin-left">{% trans "Phototactisme" %}</b>
|
<b class="w3-margin-left">{% trans "Phototactisme" %}</b>
|
||||||
<span class="w3-small w3-text-grey w3-margin-left">
|
<span class="w3-small w3-text-grey w3-margin-left">
|
||||||
@@ -266,6 +280,7 @@
|
|||||||
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||||
<label class="w3-text-teal"><b>{{ form.photo_mode.label }}</b></label>
|
<label class="w3-text-teal"><b>{{ form.photo_mode.label }}</b></label>
|
||||||
|
<p class="w3-small w3-text-grey" style="margin:0 0 4px;"> </p>
|
||||||
{{ form.photo_mode }}
|
{{ form.photo_mode }}
|
||||||
{% if form.photo_mode.errors %}
|
{% if form.photo_mode.errors %}
|
||||||
<span class="w3-text-red w3-small">{{ form.photo_mode.errors|join:", " }}</span>
|
<span class="w3-text-red w3-small">{{ form.photo_mode.errors|join:", " }}</span>
|
||||||
@@ -283,6 +298,7 @@
|
|||||||
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||||
<label class="w3-text-teal"><b>{{ form.photo_x.label }}</b></label>
|
<label class="w3-text-teal"><b>{{ form.photo_x.label }}</b></label>
|
||||||
|
<p class="w3-small w3-text-grey" style="margin:0 0 4px;"> </p>
|
||||||
{{ form.photo_x }}
|
{{ form.photo_x }}
|
||||||
{% if form.photo_x.errors %}
|
{% if form.photo_x.errors %}
|
||||||
<span class="w3-text-red w3-small">{{ form.photo_x.errors|join:", " }}</span>
|
<span class="w3-text-red w3-small">{{ form.photo_x.errors|join:", " }}</span>
|
||||||
@@ -291,6 +307,7 @@
|
|||||||
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
<div class="w3-col m6 s12 w3-margin-bottom">
|
||||||
<label class="w3-text-teal"><b>{{ form.photo_y.label }}</b></label>
|
<label class="w3-text-teal"><b>{{ form.photo_y.label }}</b></label>
|
||||||
|
<p class="w3-small w3-text-grey" style="margin:0 0 4px;"> </p>
|
||||||
{{ form.photo_y }}
|
{{ form.photo_y }}
|
||||||
{% if form.photo_y.errors %}
|
{% if form.photo_y.errors %}
|
||||||
<span class="w3-text-red w3-small">{{ form.photo_y.errors|join:", " }}</span>
|
<span class="w3-text-red w3-small">{{ form.photo_y.errors|join:", " }}</span>
|
||||||
@@ -326,6 +343,7 @@
|
|||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||||
<label class="w3-text-teal"><b>{{ form.chemo_x.label }}</b></label>
|
<label class="w3-text-teal"><b>{{ form.chemo_x.label }}</b></label>
|
||||||
|
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "Mini = 0, maxi = 1" %}</p>
|
||||||
{{ form.chemo_x }}
|
{{ form.chemo_x }}
|
||||||
{% if form.chemo_x.errors %}
|
{% if form.chemo_x.errors %}
|
||||||
<span class="w3-text-red w3-small">{{ form.chemo_x.errors|join:", " }}</span>
|
<span class="w3-text-red w3-small">{{ form.chemo_x.errors|join:", " }}</span>
|
||||||
@@ -334,6 +352,7 @@
|
|||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||||
<label class="w3-text-teal"><b>{{ form.chemo_y.label }}</b></label>
|
<label class="w3-text-teal"><b>{{ form.chemo_y.label }}</b></label>
|
||||||
|
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "Mini = 0, maxi = 1" %}</p>
|
||||||
{{ form.chemo_y }}
|
{{ form.chemo_y }}
|
||||||
{% if form.chemo_y.errors %}
|
{% if form.chemo_y.errors %}
|
||||||
<span class="w3-text-red w3-small">{{ form.chemo_y.errors|join:", " }}</span>
|
<span class="w3-text-red w3-small">{{ form.chemo_y.errors|join:", " }}</span>
|
||||||
@@ -397,7 +416,7 @@
|
|||||||
|
|
||||||
<div class="w3-col m8 s12 w3-margin-bottom">
|
<div class="w3-col m8 s12 w3-margin-bottom">
|
||||||
<button type="submit" class="w3-button w3-teal w3-round w3-large w3-padding-large">
|
<button type="submit" class="w3-button w3-teal w3-round w3-large w3-padding-large">
|
||||||
{% if object %}
|
{% if is_update %}
|
||||||
💾 {% trans "Enregistrer les modifications" %}
|
💾 {% trans "Enregistrer les modifications" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
➕ {% trans "Créer la configuration" %}
|
➕ {% trans "Créer la configuration" %}
|
||||||
@@ -405,7 +424,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<a href="{% url 'planarian:experiment-list' %}"
|
<a href="{% url 'planarian:experiment-list' %}"
|
||||||
class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left">
|
class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left">
|
||||||
✖ {% trans "Annuler" %}
|
✖ {% trans "Retour" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "planarian/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n home_tags scanner_tags %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="w3-container w3-padding-32" style="max-width:1200px; margin:auto;">
|
<div class="w3-container w3-padding-32" style="max-width:1200px; margin:auto;">
|
||||||
|
|
||||||
<!-- En-tête -->
|
<!-- En-tête -->
|
||||||
@@ -36,7 +35,6 @@
|
|||||||
|
|
||||||
<!-- Barre d'outils : recherche + import -->
|
<!-- Barre d'outils : recherche + import -->
|
||||||
<div class="w3-row-padding w3-margin-bottom">
|
<div class="w3-row-padding w3-margin-bottom">
|
||||||
|
|
||||||
<!-- Recherche côté client -->
|
<!-- Recherche côté client -->
|
||||||
<div class="w3-col m7 s12 w3-margin-bottom">
|
<div class="w3-col m7 s12 w3-margin-bottom">
|
||||||
<div class="w3-border w3-round" style="display:flex; align-items:center; background:#fafafa;">
|
<div class="w3-border w3-round" style="display:flex; align-items:center; background:#fafafa;">
|
||||||
@@ -48,7 +46,6 @@
|
|||||||
oninput="filterTable(this.value)">
|
oninput="filterTable(this.value)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Boutons import / export -->
|
<!-- Boutons import / export -->
|
||||||
<div class="w3-col m5 s12 w3-right-align w3-margin-bottom">
|
<div class="w3-col m5 s12 w3-right-align w3-margin-bottom">
|
||||||
<a href="{% url 'planarian:import-params' %}"
|
<a href="{% url 'planarian:import-params' %}"
|
||||||
@@ -60,19 +57,15 @@
|
|||||||
📥 {% trans "Exporter CSV" %}
|
📥 {% trans "Exporter CSV" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============================================================
|
<!-- ============================================================
|
||||||
Tableau des configurations
|
Tableau des configurations
|
||||||
============================================================ -->
|
============================================================ -->
|
||||||
{% if configs %}
|
{% if configs %}
|
||||||
|
|
||||||
<!-- Compteur -->
|
<!-- Compteur -->
|
||||||
<p class="w3-text-grey w3-small" id="row-count">
|
<p class="w3-text-grey w3-small" id="row-count">
|
||||||
{{ configs|length }} {% trans "configuration(s)" %}
|
{{ configs|length }} {% trans "configuration(s)" %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="w3-responsive w3-card-4 w3-round-large">
|
<div class="w3-responsive w3-card-4 w3-round-large">
|
||||||
<table class="w3-table w3-striped w3-hoverable w3-bordered" id="config-table">
|
<table class="w3-table w3-striped w3-hoverable w3-bordered" id="config-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -98,26 +91,21 @@
|
|||||||
<tbody id="config-tbody">
|
<tbody id="config-tbody">
|
||||||
{% for cfg in configs %}
|
{% for cfg in configs %}
|
||||||
<tr class="config-row">
|
<tr class="config-row">
|
||||||
|
|
||||||
<!-- Expérience -->
|
<!-- Expérience -->
|
||||||
<td>
|
<td>
|
||||||
<b>{{ cfg.experiment }}</b>
|
<b><span class="w3-text-grey">{{ cfg.experiment }}</span></b>
|
||||||
{% if cfg.description %}
|
{% if cfg.description %}
|
||||||
<br><span class="w3-small w3-text-grey">{{ cfg.description|truncatechars:40 }}</span>
|
<br><span class="w3-small w3-text-blue-grey">{{ cfg.description|truncatechars:40 }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Puits -->
|
<!-- Puits -->
|
||||||
<td>
|
<td>
|
||||||
<span class="w3-tag w3-teal w3-round">{{ cfg.well }}</span>
|
<span class="w3-tag w3-teal w3-round">{{ cfg.well }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- px/mm -->
|
<!-- px/mm -->
|
||||||
<td class="w3-hide-small">{{ cfg.px_per_mm }}</td>
|
<td class="w3-hide-small w3-text-grey">{{ cfg.px_per_mm }}</td>
|
||||||
|
|
||||||
<!-- FPS -->
|
<!-- FPS -->
|
||||||
<td class="w3-hide-small">{{ cfg.fps }}</td>
|
<td class="w3-hide-small w3-text-grey">{{ cfg.fps }}</td>
|
||||||
|
|
||||||
<!-- Seuils de mobilité -->
|
<!-- Seuils de mobilité -->
|
||||||
<td class="w3-hide-small">
|
<td class="w3-hide-small">
|
||||||
<span class="w3-tag w3-red w3-round w3-small"
|
<span class="w3-tag w3-red w3-round w3-small"
|
||||||
@@ -129,7 +117,6 @@
|
|||||||
< {{ cfg.thresh_mobile }}
|
< {{ cfg.thresh_mobile }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Comportements actifs -->
|
<!-- Comportements actifs -->
|
||||||
<td class="w3-hide-small">
|
<td class="w3-hide-small">
|
||||||
{% if cfg.thigmotaxis_wall_dist_mm > 0 %}
|
{% if cfg.thigmotaxis_wall_dist_mm > 0 %}
|
||||||
@@ -166,9 +153,7 @@
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
class="w3-button w3-small w3-red w3-round"
|
class="w3-button w3-small w3-red w3-round"
|
||||||
title="{% trans 'Supprimer' %}"
|
title="{% trans 'Supprimer' %}"
|
||||||
onclick="confirmDelete('{{ cfg.pk }}', '{{ cfg.experiment }}', '{{ cfg.well }}')">
|
onclick="confirmDelete('{{ cfg.pk }}', '{{ cfg.experiment }}', '{{ cfg.well }}')"> 🗑 </button>
|
||||||
🗑
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
@@ -181,7 +166,6 @@
|
|||||||
<div id="no-results" class="w3-panel w3-pale-yellow w3-round w3-margin-top" style="display:none;">
|
<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>
|
<p>{% trans "Aucune configuration ne correspond à la recherche." %}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Liste vide -->
|
<!-- Liste vide -->
|
||||||
<div class="w3-panel w3-pale-blue w3-round-large w3-padding-32" style="text-align:center;">
|
<div class="w3-panel w3-pale-blue w3-round-large w3-padding-32" style="text-align:center;">
|
||||||
@@ -194,13 +178,9 @@
|
|||||||
class="w3-button w3-teal w3-round w3-large w3-margin-right">
|
class="w3-button w3-teal w3-round w3-large w3-margin-right">
|
||||||
➕ {% trans "Nouvelle configuration" %}
|
➕ {% trans "Nouvelle configuration" %}
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'planarian:import-params' %}"
|
<a href="{% url 'planarian:import-params' %}" class="w3-button w3-blue-grey w3-round w3-large"> 📂 {% trans "Importer CSV" %} </a>
|
||||||
class="w3-button w3-blue-grey w3-round w3-large">
|
|
||||||
📂 {% trans "Importer CSV" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div><!-- fin container -->
|
</div><!-- fin container -->
|
||||||
|
|
||||||
<!-- ============================================================
|
<!-- ============================================================
|
||||||
@@ -216,9 +196,7 @@
|
|||||||
<form id="delete-form" method="post" action="">
|
<form id="delete-form" method="post" action="">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="_method" value="delete">
|
<input type="hidden" name="_method" value="delete">
|
||||||
<button type="submit" class="w3-button w3-red w3-round w3-margin-right">
|
<button type="submit" class="w3-button w3-red w3-round w3-margin-right"> 🗑 {% trans "Supprimer" %}</button>
|
||||||
🗑 {% trans "Supprimer" %}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="w3-button w3-light-grey w3-round"
|
<button type="button" class="w3-button w3-light-grey w3-round"
|
||||||
onclick="document.getElementById('delete-modal').style.display='none'">
|
onclick="document.getElementById('delete-modal').style.display='none'">
|
||||||
✖ {% trans "Annuler" %}
|
✖ {% trans "Annuler" %}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "planarian/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="w3-container w3-padding-32" style="max-width:760px; margin:auto;">
|
<div class="w3-container w3-padding" style="max-width:760px; margin:auto;">
|
||||||
|
|
||||||
<!-- En-tête -->
|
<!-- En-tête -->
|
||||||
<div class="w3-panel w3-blue w3-round-large w3-padding-16 w3-margin-bottom">
|
<div class="w3-panel w3-blue w3-round-large w3-padding-16 w3-margin-bottom">
|
||||||
@@ -56,8 +56,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="w3-card-4 w3-round-large">
|
<div class="w3-card-4 w3-border w3-round w3-round-large">
|
||||||
<header class="w3-container w3-blue w3-round-top-large">
|
<header class="w3-container w3-blue w3-round w3-round-top-large">
|
||||||
<h3 class="w3-text-white">{% trans "Paramètres d'export" %}</h3>
|
<h3 class="w3-text-white">{% trans "Paramètres d'export" %}</h3>
|
||||||
</header>
|
</header>
|
||||||
<div class="w3-container w3-padding-24">
|
<div class="w3-container w3-padding-24">
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
<div class="w3-col m4 s12 w3-margin-bottom">
|
||||||
<label class="w3-text-blue"><b>{{ form.planarian.label }}</b></label>
|
<label class="w3-text-blue"><b>{{ form.planarian.label }}</b></label>
|
||||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">
|
<p class="w3-small w3-text-light-grey" style="margin:0 0 4px;">
|
||||||
{% trans "Index du planaire dans le puits (commence à 0)" %}
|
{% trans "Index du planaire dans le puits (commence à 0)" %}
|
||||||
</p>
|
</p>
|
||||||
{{ form.planarian }}
|
{{ form.planarian }}
|
||||||
@@ -119,20 +119,12 @@
|
|||||||
|
|
||||||
<div class="w3-col m8 s12 w3-margin-bottom">
|
<div class="w3-col m8 s12 w3-margin-bottom">
|
||||||
<label class="w3-text-blue"><b>{{ form.record_type.label }}</b></label>
|
<label class="w3-text-blue"><b>{{ form.record_type.label }}</b></label>
|
||||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">
|
<p class="w3-small w3-text-light-grey" style="margin:0 0 4px;">
|
||||||
{% trans "Frame par frame : une ligne par image. Résumé : métriques agrégées de la session." %}
|
{% trans "Frame par frame : une ligne par image. Résumé : métriques agrégées de la session." %}
|
||||||
</p>
|
</p>
|
||||||
<!-- Radio buttons stylisés W3 -->
|
<!-- Radio buttons stylisés W3 -->
|
||||||
<div class="w3-row-padding" style="margin-top:6px;">
|
<div class="w3-row-padding" style="margin-top:6px;">
|
||||||
{% for radio in form.record_type %}
|
{{ 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>
|
</div>
|
||||||
{% if form.record_type.errors %}
|
{% if form.record_type.errors %}
|
||||||
<span class="w3-text-red w3-small">{{ form.record_type.errors|join:", " }}</span>
|
<span class="w3-text-red w3-small">{{ form.record_type.errors|join:", " }}</span>
|
||||||
@@ -176,7 +168,7 @@
|
|||||||
<!-- Boutons -->
|
<!-- Boutons -->
|
||||||
<div class="w3-row-padding w3-margin-top">
|
<div class="w3-row-padding w3-margin-top">
|
||||||
<div class="w3-col s12">
|
<div class="w3-col s12">
|
||||||
<button type="submit" class="w3-button w3-blue w3-round w3-large w3-padding-large">
|
<button type="submit" class="w3-button w3-blue w3-large w3-padding-large">
|
||||||
📥 {% trans "Générer et télécharger le CSV" %}
|
📥 {% trans "Générer et télécharger le CSV" %}
|
||||||
</button>
|
</button>
|
||||||
<a href="{% url 'planarian:experiment-list' %}"
|
<a href="{% url 'planarian:experiment-list' %}"
|
||||||
@@ -189,8 +181,8 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Rappel des colonnes exportées -->
|
<!-- Rappel des colonnes exportées -->
|
||||||
<div class="w3-card w3-round-large w3-margin-top">
|
<div class="w3-card w3-round-large w3-margin-top w3-border w3-round w3-round-large w3-margin-bottom">
|
||||||
<header class="w3-container w3-blue-grey w3-round-top-large">
|
<header class="w3-container w3-blue-grey w3-round w3-round-top-large">
|
||||||
<h4 class="w3-text-white" style="margin:8px 0;">
|
<h4 class="w3-text-white" style="margin:8px 0;">
|
||||||
{% trans "Colonnes du fichier CSV exporté" %}
|
{% trans "Colonnes du fichier CSV exporté" %}
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "planarian/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="w3-container w3-padding-32" style="max-width:760px; margin:auto;">
|
<div class="w3-container w3-padding-32" style="max-width:760px; margin:auto;">
|
||||||
|
|
||||||
<!-- En-tête -->
|
<!-- En-tête -->
|
||||||
@@ -45,8 +44,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
<div class="w3-card-4 w3-margin-bottom w3-border w3-round w3-round-large">
|
||||||
<header class="w3-container w3-blue-grey w3-round-top-large">
|
<header class="w3-container w3-blue-grey w3-round w3-round-top-large">
|
||||||
<h3 class="w3-text-white">{% trans "Fichier CSV à importer" %}</h3>
|
<h3 class="w3-text-white">{% trans "Fichier CSV à importer" %}</h3>
|
||||||
</header>
|
</header>
|
||||||
<div class="w3-container w3-padding-24">
|
<div class="w3-container w3-padding-24">
|
||||||
@@ -129,8 +128,8 @@
|
|||||||
<!-- ============================================================
|
<!-- ============================================================
|
||||||
Format attendu du CSV
|
Format attendu du CSV
|
||||||
============================================================ -->
|
============================================================ -->
|
||||||
<div class="w3-card-4 w3-round-large">
|
<div class="w3-card-4 w3-round-large w3-border w3-round w3-round-large">
|
||||||
<header class="w3-container w3-teal w3-round-top-large">
|
<header class="w3-container w3-teal w3-round w3-round-top-large">
|
||||||
<h3 class="w3-text-white">{% trans "Format du fichier CSV" %}</h3>
|
<h3 class="w3-text-white">{% trans "Format du fichier CSV" %}</h3>
|
||||||
</header>
|
</header>
|
||||||
<div class="w3-container w3-padding-24">
|
<div class="w3-container w3-padding-24">
|
||||||
@@ -191,24 +190,24 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><code>well_radius_mm</code></td> <td>8.0</td> <td>{% trans "Rayon du puits (mm)" %}</td></tr>
|
<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 class="w3-grey"><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>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 class="w3-grey"><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>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 class="w3-grey"><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_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 class="w3-grey"><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_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 class="w3-grey"><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>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>
|
<tr class="w3-grey"><td><code>aggreg_radius_mm</code></td> <td>6.0</td> <td>{% trans "Rayon agrégation inter-individus (mm)" %}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Exemple de fichier -->
|
<!-- Exemple de fichier -->
|
||||||
<p><b>{% trans "Exemple minimal" %}</b></p>
|
<p><b>{% trans "Exemple minimal" %}</b></p>
|
||||||
<div class="w3-code w3-round" style="font-size:12px; overflow-x:auto;">
|
<div class="w3-code w3-round w3-text-grey" style="font-size:12px; overflow-x:auto;">
|
||||||
experiment,well,px_per_mm,fps,thresh_immobile,thresh_mobile,photo_mode<br>
|
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,A1,26.25,10,0.2,1.5,none<br>
|
||||||
exp_ctrl_01,A2,26.25,10,0.2,1.5,none<br>
|
exp_ctrl_01,A2,26.25,10,0.2,1.5,none<br>
|
||||||
|
|||||||
@@ -11,15 +11,41 @@ from django.shortcuts import get_object_or_404, redirect #, render
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.generic import FormView, ListView
|
from django.views.generic import FormView, ListView
|
||||||
|
from django.views.decorators.http import require_GET
|
||||||
|
|
||||||
from .forms import CsvImportForm, ExperimentConfigForm, ExportCsvForm
|
from .forms import CsvImportForm, ExperimentConfigForm, ExportCsvForm
|
||||||
from .models import ExperimentConfig
|
from .models import ExperimentConfig
|
||||||
from modules.planarian_metrics import ExperimentParams, ReductStoreClient
|
from modules.planarian_metrics import ExperimentParams, ReductStoreClient
|
||||||
|
from modules.system_stats import get_cached_stats, start_background_updater
|
||||||
|
from scanner.constants import ScannerConstants
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
start_background_updater()
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def stats_view(request):
|
||||||
|
"""
|
||||||
|
Retourne tout le cache (shm, cpu_info, memory_info, disk_info, updated_at)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = get_cached_stats()
|
||||||
|
return JsonResponse(data, safe=False)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
def global_context(request, **ctx):
|
||||||
|
conf = ScannerConstants().get()
|
||||||
|
return dict(
|
||||||
|
app_title=settings.APP_TITLE,
|
||||||
|
app_sub_title=settings.APP_SUB_TITLE,
|
||||||
|
domain_server=settings.DOMAIN_SERVER,
|
||||||
|
conf=conf,
|
||||||
|
**ctx
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_reduct_client() -> ReductStoreClient:
|
def _get_reduct_client() -> ReductStoreClient:
|
||||||
"""Instancie le client ReductStore depuis les settings Django."""
|
"""Instancie le client ReductStore depuis les settings Django."""
|
||||||
@@ -42,6 +68,10 @@ class ExperimentConfigListView(ListView):
|
|||||||
context_object_name = "configs"
|
context_object_name = "configs"
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
return global_context(self.request, **context)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Vue : création / modification d'une configuration
|
# Vue : création / modification d'une configuration
|
||||||
@@ -53,6 +83,7 @@ class ExperimentConfigFormView(FormView):
|
|||||||
template_name = "planarian/experiment_form.html"
|
template_name = "planarian/experiment_form.html"
|
||||||
form_class = ExperimentConfigForm
|
form_class = ExperimentConfigForm
|
||||||
|
|
||||||
|
|
||||||
def get_form(self, form_class=None):
|
def get_form(self, form_class=None):
|
||||||
pk = self.kwargs.get("pk")
|
pk = self.kwargs.get("pk")
|
||||||
if pk:
|
if pk:
|
||||||
@@ -65,6 +96,24 @@ class ExperimentConfigFormView(FormView):
|
|||||||
messages.success(self.request, _("Configuration sauvegardée."))
|
messages.success(self.request, _("Configuration sauvegardée."))
|
||||||
return redirect("planarian:experiment-list")
|
return redirect("planarian:experiment-list")
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
# Called when form validation fails
|
||||||
|
print(f"Form validation failed: {form.errors}")
|
||||||
|
messages.error(self.request, form.errors)
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
|
#def post(self, request, *args, **kwargs):
|
||||||
|
# # Custom logic before processing the form
|
||||||
|
# print(f"Received POST data: {request.POST}")
|
||||||
|
# return response
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['is_creation'] = 'pk' not in self.kwargs
|
||||||
|
context['is_update'] = 'pk' in self.kwargs
|
||||||
|
return global_context(self.request, choice_title=_("Paramètres d'une expérience"), **context)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Vue : import CSV de paramètres
|
# Vue : import CSV de paramètres
|
||||||
@@ -120,6 +169,11 @@ class ImportParamsView(FormView):
|
|||||||
)
|
)
|
||||||
return redirect("planarian:experiment-list")
|
return redirect("planarian:experiment-list")
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
return global_context(self.request, choice_title=_("configurations d'expérience depuis un fichier CSV"), **context)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Vue : export CSV depuis ReductStore
|
# Vue : export CSV depuis ReductStore
|
||||||
@@ -169,6 +223,10 @@ class ExportCsvView(FormView):
|
|||||||
messages.success(self.request, _("%(n)d lignes exportées.") % {"n": n})
|
messages.success(self.request, _("%(n)d lignes exportées.") % {"n": n})
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
return global_context(self.request, choice_title=_("Tracking depuis ReductStore vers un fichier CSV"), **context)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Vue API JSON : données de tracking (pour polling front-end)
|
# Vue API JSON : données de tracking (pour polling front-end)
|
||||||
@@ -208,3 +266,7 @@ class TrackingDataView(View):
|
|||||||
records = _fetch()
|
records = _fetch()
|
||||||
return JsonResponse({"count": len(records), "records": records})
|
return JsonResponse({"count": len(records), "records": records})
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
return global_context(self.request, choice_title=_("Métriques de tracking d'un planaire"), **context)
|
||||||
|
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ class WellPosition(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def active_well(cls, multiwel, well):
|
def active_well(cls, multiwell, well):
|
||||||
return WellPosition.objects.filter(multiwel_id=multiwel.id, well_id=well.id).first()
|
return WellPosition.objects.filter(multiwell_id=multiwell.id, well_id=well.id).first()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['order']
|
ordering = ['order']
|
||||||
@@ -235,7 +235,7 @@ class Experiment(models.Model):
|
|||||||
verbose_name_plural = _("Expériences")
|
verbose_name_plural = _("Expériences")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.title}: {self.created} {self.multiwell.order}'
|
return f'{self.id}:{self.title}-{self.created}'
|
||||||
|
|
||||||
|
|
||||||
class Session(models.Model):
|
class Session(models.Model):
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ class MultiWellManager:
|
|||||||
self.process.data.uuid = uuid
|
self.process.data.uuid = uuid
|
||||||
self.process.data.record = True
|
self.process.data.record = True
|
||||||
|
|
||||||
|
# reset PlanarianTracker => on_well_change
|
||||||
|
self.process.cam.on_well_change()
|
||||||
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
while not self.stop_playing.is_set():
|
while not self.stop_playing.is_set():
|
||||||
if time.monotonic() - start > duration:
|
if time.monotonic() - start > duration:
|
||||||
|
|||||||
@@ -189,9 +189,10 @@ class ScannerProcess(Task):
|
|||||||
for wl in wells:
|
for wl in wells:
|
||||||
video_lists.append(str( settings.MEDIA_ROOT / 'simulation' / f'{wl.name}.mp4') )
|
video_lists.append(str( settings.MEDIA_ROOT / 'simulation' / f'{wl.name}.mp4') )
|
||||||
|
|
||||||
|
|
||||||
from modules.videofile_capture import VideoFileCapture
|
from modules.videofile_capture import VideoFileCapture
|
||||||
self.cam = VideoFileCapture(
|
self.cam = VideoFileCapture(
|
||||||
video_file=settings.MEDIA_ROOT / 'simulation' / 'A1.mp4',
|
video_file=settings.MEDIA_ROOT / 'simulation' / 'D6.mp4',
|
||||||
fps=self.video_fps,
|
fps=self.video_fps,
|
||||||
width=self.video_width,
|
width=self.video_width,
|
||||||
height=self.video_height,
|
height=self.video_height,
|
||||||
@@ -273,17 +274,23 @@ class ScannerProcess(Task):
|
|||||||
if self.grbl:
|
if self.grbl:
|
||||||
self._send(**msg)
|
self._send(**msg)
|
||||||
|
|
||||||
def _on_frame(self, jpeg_bytes: bytes, ts: datetime, metrics: dict) -> None:
|
def _on_frame(self, jpeg_bytes: bytes, ts: datetime, metrics: dict, frame_count: int = 0) -> None:
|
||||||
if self.data.record:
|
if self.data.record:
|
||||||
self.record_queue.put((self.data.uuid, ts, jpeg_bytes, metrics))
|
self.record_queue.put((self.data.uuid, ts, jpeg_bytes, metrics, frame_count))
|
||||||
if self.data.play:
|
if self.data.play:
|
||||||
self._send(ts=ts.timestamp(), jpeg=base64.b64encode(jpeg_bytes).decode(), **metrics)
|
try:
|
||||||
|
jpeg=base64.b64encode(jpeg_bytes).decode()
|
||||||
|
logger.warning(f"{jpeg[:100]}")
|
||||||
|
self._send(ts=ts.timestamp(), jpeg=jpeg, frame_count=frame_count)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"----_on_frame: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _recording(self):
|
def _recording(self):
|
||||||
logger.info(f"Scanner {self.group}: start recorder")
|
logger.info(f"Scanner {self.group}: start recorder")
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
(uuid, ts, frame, metrics) = self.record_queue.get()
|
(uuid, ts, frame, metrics, frame_count) = self.record_queue.get()
|
||||||
labels = dict(fps=self.video_fps, session=self.data.session, detected="1" if metrics.get("detected") else "0")
|
labels = dict(fps=self.video_fps, session=self.data.session, detected="1" if metrics.get("detected") else "0")
|
||||||
|
|
||||||
if metrics.get("detected"):
|
if metrics.get("detected"):
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class ScannerManager {
|
|||||||
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
|
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.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
|
||||||
|
|
||||||
|
/*
|
||||||
if (payload.detected && use_tracking) {
|
if (payload.detected && use_tracking) {
|
||||||
this.cx.textContent = payload.cx; this.cy.textContent = payload.cy;
|
this.cx.textContent = payload.cx; this.cy.textContent = payload.cy;
|
||||||
this.speed_px_s.textContent = payload.speed_px_s;
|
this.speed_px_s.textContent = payload.speed_px_s;
|
||||||
@@ -93,7 +94,8 @@ class ScannerManager {
|
|||||||
this.axial_pos.textContent = payload.axial_pos;
|
this.axial_pos.textContent = payload.axial_pos;
|
||||||
this.area_px.textContent = payload.area_px;
|
this.area_px.textContent = payload.area_px;
|
||||||
this.frame_count.textContent = payload.count;
|
this.frame_count.textContent = payload.count;
|
||||||
}
|
}*/
|
||||||
|
|
||||||
if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; }
|
if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; }
|
||||||
if (payload.current >= 0) {
|
if (payload.current >= 0) {
|
||||||
document.querySelectorAll('button.w3-button.well').forEach(btn => {
|
document.querySelectorAll('button.w3-button.well').forEach(btn => {
|
||||||
|
|||||||
@@ -103,8 +103,12 @@
|
|||||||
<i class="fa-solid fa-wrench w3-text-red w3-xlarge"></i> {% trans "Calibration" %}
|
<i class="fa-solid fa-wrench w3-text-red w3-xlarge"></i> {% trans "Calibration" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a href="{% url 'scanner:main' %}" class="w3-bar-item w3-btn w3-hover-opacity">
|
<a href="{% url 'scanner:main' %}" class="w3-bar-item w3-btn w3-hover-opacity">
|
||||||
<i class="fa-solid fa-film w3-text-green w3-xlarge""></i> {% trans "Balayage multi-puits" %}
|
<i class="fa-solid fa-film w3-text-green w3-xlarge""></i> {% trans "Balayage multi-puits" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'planarian:experiment-list' %}" class="w3-bar-item w3-btn w3-hover-opacity">
|
||||||
|
<i class="fa-solid fa-vials w3-text-lime w3-xlarge"></i> {% trans "Préparation des expériences" %}
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'scanner:images' %}" class="w3-bar-item w3-btn w3-hover-opacity">
|
<a href="{% url 'scanner:images' %}" class="w3-bar-item w3-btn w3-hover-opacity">
|
||||||
<i class="fa-regular fa-images w3-text-cyan w3-xlarge" w3-xlarge""></i> {% trans "Gestionnaire d'images" %}
|
<i class="fa-regular fa-images w3-text-cyan w3-xlarge" w3-xlarge""></i> {% trans "Gestionnaire d'images" %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
<div class="w3-row">
|
<div class="w3-row">
|
||||||
{% if conf.tracking %}
|
{#% if conf.tracking %#}
|
||||||
<div class="w3-col w3-small" style="width:180px">
|
<!--div class="w3-col w3-small" style="width:180px">
|
||||||
<div>Num: <span id="_count"></span></div>
|
<div>Num: <span id="_count"></span></div>
|
||||||
<div>Aire: <span id="_area_px"></span></div>
|
<div>Aire: <span id="_area_px"></span></div>
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
<div>V: <span id="_speed_px_s"></span> px/s</div>
|
<div>V: <span id="_speed_px_s"></span> px/s</div>
|
||||||
<div>V.Ax: <span id="_axial_speed"></span> px/s</div>
|
<div>V.Ax: <span id="_axial_speed"></span> px/s</div>
|
||||||
<div>Ax pos: <span id="_axial_pos"></span></div>
|
<div>Ax pos: <span id="_axial_pos"></span></div>
|
||||||
</div>
|
</div-->
|
||||||
{% endif %}
|
{#% endif %#}
|
||||||
<div class="w3-rest">
|
<div class="w3-rest">
|
||||||
<img id="scan-img" class="w3-image">
|
<img id="scan-img" class="w3-image">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user