planarian tracker
This commit is contained in:
@@ -22,6 +22,8 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, TYPE_CHECKING
|
||||
|
||||
from modules.planarian_tracker import PlanarianTracker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .circular_crop import CircularCrop # Evite l'import circulaire au runtime
|
||||
|
||||
@@ -59,6 +61,20 @@ class VideoCaptureInterface(abc.ABC):
|
||||
self._on_frame: Optional[Callable[[bytes, datetime], None]] = None # Callback image
|
||||
self._circular_crop: Optional["CircularCrop"] = None # Recadrage circulaire optionnel
|
||||
self._active_median = False
|
||||
self._error_occured = False
|
||||
|
||||
self._tracker = PlanarianTracker(
|
||||
tube_axis = "vertical", # à rendre configurable via settings
|
||||
min_area_px = 20,
|
||||
)
|
||||
|
||||
|
||||
def on_well_change(self):
|
||||
"""
|
||||
Appelé par le CNC lors du changement de puits.
|
||||
Réinitialise le fond appris et l'état inter-frame du tracker.
|
||||
"""
|
||||
self._tracker.reset()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Méthodes abstraites — obligatoires dans les sous-classes
|
||||
@@ -196,8 +212,25 @@ class VideoCaptureInterface(abc.ABC):
|
||||
:return: Image traitée (JPEG ou PNG selon la stratégie)
|
||||
"""
|
||||
if self._circular_crop is not None:
|
||||
return self._circular_crop.process(jpeg_bytes)
|
||||
return jpeg_bytes
|
||||
jpeg = self._circular_crop.process(jpeg_bytes)
|
||||
|
||||
# --- tracking ---
|
||||
nparr = np.frombuffer(jpeg, np.uint8)
|
||||
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
ts = datetime.now(timezone.utc).timestamp()
|
||||
#metrics = self._tracker.process(frame, ts) if frame is not None else {}
|
||||
if frame is not None:
|
||||
frame_annotated, metrics = self._tracker.process(frame, ts)
|
||||
# Ré-encodage JPEG de la frame annotée
|
||||
ok, buf = cv2.imencode(".jpg", frame_annotated, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
if ok:
|
||||
jpeg = buf.tobytes()
|
||||
else:
|
||||
metrics = {"detected": False}
|
||||
|
||||
return jpeg, metrics
|
||||
|
||||
return jpeg_bytes, {"detected": False}
|
||||
|
||||
def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path:
|
||||
"""
|
||||
@@ -250,9 +283,6 @@ class VideoCaptureInterface(abc.ABC):
|
||||
cv2.line(frame, (0, center_y), (width, center_y), (0, 255, 0), 1)
|
||||
cv2.circle(frame, (center_x, center_y), 2, (0, 0, 255), -1)
|
||||
|
||||
cv2.putText(frame, f"Num: {self._frame_count}", (10, 30),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
|
||||
|
||||
_, buffer = cv2.imencode('.jpg', frame)
|
||||
jpeg_bytes = buffer.tobytes()
|
||||
return jpeg_bytes
|
||||
@@ -276,20 +306,25 @@ class VideoCaptureInterface(abc.ABC):
|
||||
try:
|
||||
jpeg = self.capture_frame()
|
||||
jpeg = self.display_median(jpeg)
|
||||
jpeg = 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
|
||||
|
||||
ts = datetime.now(timezone.utc)
|
||||
|
||||
if self._on_frame:
|
||||
try:
|
||||
self._on_frame(jpeg, ts)
|
||||
self._on_frame(jpeg, ts, metrics)
|
||||
except Exception as cb_err: # noqa: BLE001
|
||||
logger.error("Erreur dans le callback image : %s", cb_err)
|
||||
|
||||
except CaptureError as err:
|
||||
logger.error("Échec de capture (#%d) : %s", self._frame_count, err)
|
||||
self._error_occured = True
|
||||
|
||||
# Compensation du temps d'exécution pour tenir la cadence
|
||||
elapsed = time.monotonic() - loop_start
|
||||
|
||||
@@ -234,7 +234,7 @@ class GRBLController:
|
||||
|
||||
class GridScanner:
|
||||
|
||||
def __init__(self, grbl, proc=None, **config):
|
||||
def __init__(self, grbl, process=None, **config):
|
||||
'''
|
||||
xbase # Position X de départ (col 0) en mm
|
||||
ybase # Position Y de départ (row 0) en mm
|
||||
@@ -246,7 +246,7 @@ class GridScanner:
|
||||
feed # Vitesse de déplacement entre éprouvettes (mm/min)
|
||||
'''
|
||||
self.grbl = grbl
|
||||
self.proc = proc
|
||||
self.process = process
|
||||
|
||||
self.position = config.get('position', 'HG')
|
||||
self.xbase = config.get('xbase', 50)
|
||||
@@ -266,7 +266,7 @@ class GridScanner:
|
||||
|
||||
|
||||
def halt(self):
|
||||
self.proc.record = False
|
||||
self.process.tag.record = False
|
||||
return self.stop_playing.set()
|
||||
|
||||
def _capture(self, uuid: str, duration: float, stop_running: Optional[threading.Event]) -> None:
|
||||
@@ -274,8 +274,10 @@ class GridScanner:
|
||||
Déclenche la caméra ArduCam et attend la fin de l'acquisition.
|
||||
"""
|
||||
print(f"# démarrer l'enregistrement {uuid}")
|
||||
self.proc.uuid = uuid
|
||||
self.proc.record = True
|
||||
self.process.cam.on_well_change()
|
||||
|
||||
self.process.tag.uuid = uuid
|
||||
self.process.tag.record = True
|
||||
|
||||
start = time.monotonic()
|
||||
while not stop_running.is_set():
|
||||
@@ -284,8 +286,8 @@ class GridScanner:
|
||||
self.grbl.wait_for(1.0)
|
||||
|
||||
print("# arrêter l'enregistrement")
|
||||
self.proc.record = False
|
||||
self.proc.uuid = None
|
||||
self.process.tag.record = False
|
||||
self.process.tag.uuid = None
|
||||
|
||||
def start(self, xnext=None, ynext=None, position=None):
|
||||
"""
|
||||
@@ -347,7 +349,7 @@ class GridScanner:
|
||||
|
||||
self.grbl.move_to(x, y, feed=self.feed)
|
||||
|
||||
uuid = f'{self.proc.session}-{position}-{self.row_to_char[row]}{col+1}'
|
||||
uuid = f'{self.process.tag.session}-{position}-{self.row_to_char[row]}{col+1}'
|
||||
self._capture(uuid, self.duration, self.stop_playing)
|
||||
|
||||
# Retour à nexr après le scan
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# 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) -> 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:
|
||||
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,171 @@
|
||||
"""
|
||||
Implémentation de la capture vidéo à partir d'un fichier video via OpenCV (cv2).
|
||||
Dépendance : opencv-python (pip install opencv-python)
|
||||
|
||||
OpenCV (cv2) avec import local pour éviter une dépendance globale
|
||||
Résolution configurable, qualité JPEG réglable à chaud, accès V4L2 par index
|
||||
get_resolution() pour lire la résolution effective appliquée par le pilote
|
||||
"""
|
||||
import os
|
||||
os.environ['OPENCV_LOG_LEVEL']="0"
|
||||
os.environ['OPENCV_FFMPEG_LOGLEVEL']="0"
|
||||
import cv2
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from .capture_interface import CaptureError, VideoCaptureInterface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoFileCapture(VideoCaptureInterface):
|
||||
"""
|
||||
Capture JPEG depuis une webcam USB/intégrée via OpenCV.
|
||||
|
||||
Exemple d'utilisation ::
|
||||
|
||||
cam = VideoFileCapture(video_file=0, fps=5)
|
||||
cam.set_frame_callback(lambda data, ts: print(f"{ts}: {len(data)} octets"))
|
||||
cam.start()
|
||||
time.sleep(10)
|
||||
cam.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
video_file: str = None,
|
||||
fps: float = VideoCaptureInterface.DEFAULT_FPS,
|
||||
jpeg_quality: int = 85,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
video_lists = []
|
||||
):
|
||||
"""
|
||||
:param video_file: fichier video
|
||||
:param fps: Cadence cible en images par seconde
|
||||
:param jpeg_quality: Qualité de compression JPEG [0-100]
|
||||
:param width: Largeur 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)
|
||||
self._video_file: str = video_file
|
||||
self._jpeg_quality: int = jpeg_quality
|
||||
self._width: Optional[int] = width
|
||||
self._height: Optional[int] = height
|
||||
self._video_lists = video_lists
|
||||
self.ptf = 0
|
||||
self._cap = None # Instance cv2.VideoCapture
|
||||
|
||||
def get_file(self):
|
||||
if self._video_lists:
|
||||
self._video_file = self._video_lists[self.ptf]
|
||||
self.ptf += 1
|
||||
if self.ptf >= len(self._video_lists):
|
||||
self.ptf = 0
|
||||
return self._video_file
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Implémentation des méthodes abstraites
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def open(self) -> None:
|
||||
"""Ouvre le flux V4L2 via OpenCV et configure la résolution."""
|
||||
self.get_file()
|
||||
|
||||
self._cap = cv2.VideoCapture(self._video_file)
|
||||
|
||||
if not self._cap.isOpened():
|
||||
raise CaptureError(
|
||||
f"Impossible d'ouvrir le fichier (index={self._video_file})"
|
||||
)
|
||||
|
||||
# Application de la résolution demandée
|
||||
if self._width:
|
||||
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self._width)
|
||||
if self._height:
|
||||
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self._height)
|
||||
|
||||
# Lecture de la résolution effectivement appliquée par le pilote
|
||||
actual_w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
actual_h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
logger.info(
|
||||
f"Fichier ouvert: index=%s résolution=%dx%d",
|
||||
self._video_file, actual_w, actual_h,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Libère le flux OpenCV."""
|
||||
if self._cap and self._cap.isOpened():
|
||||
self._cap.release()
|
||||
logger.info("Fichier fermé (index=%s)", self._video_file)
|
||||
self._cap = None
|
||||
|
||||
def capture_frame(self) -> bytes:
|
||||
"""
|
||||
Lit une trame brute depuis OpenCV et l'encode en JPEG.
|
||||
|
||||
:return: Données JPEG brutes
|
||||
:raises CaptureError: Si la lecture ou l'encodage échoue
|
||||
"""
|
||||
#import cv2
|
||||
#import numpy as np # noqa: F401 — utilisé implicitement par cv2
|
||||
|
||||
if self._error_occured:
|
||||
self.close()
|
||||
self.open()
|
||||
self._error_occured = False
|
||||
|
||||
|
||||
if not self._cap or not self._cap.isOpened():
|
||||
raise CaptureError("Le fichier n'est pas ouvert")
|
||||
|
||||
ret, frame = self._cap.read()
|
||||
if not ret or frame is None:
|
||||
raise CaptureError("Échec de lecture de la trame ou fin de fichier")
|
||||
|
||||
# Encodage BGR → JPEG avec la qualité configurée
|
||||
encode_params = [cv2.IMWRITE_JPEG_QUALITY, self._jpeg_quality]
|
||||
success, buffer = cv2.imencode(".jpg", frame, encode_params)
|
||||
|
||||
if not success:
|
||||
raise CaptureError("Échec d'encodage JPEG")
|
||||
|
||||
return buffer.tobytes()
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Retourne True si le flux OpenCV est ouvert et prêt."""
|
||||
return self._cap is not None and self._cap.isOpened()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Accesseurs spécifiques à la webcam
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def video_file(self) -> int:
|
||||
"""Index du périphérique V4L2."""
|
||||
return self._video_file
|
||||
|
||||
@property
|
||||
def jpeg_quality(self) -> int:
|
||||
"""Qualité JPEG [0-100]."""
|
||||
return self._jpeg_quality
|
||||
|
||||
@jpeg_quality.setter
|
||||
def jpeg_quality(self, value: int) -> None:
|
||||
if not 0 <= value <= 100:
|
||||
raise ValueError("La qualité JPEG doit être comprise entre 0 et 100")
|
||||
self._jpeg_quality = value
|
||||
|
||||
def get_resolution(self) -> Optional[tuple[int, int]]:
|
||||
"""
|
||||
Retourne la résolution effective du flux.
|
||||
|
||||
:return: Tuple (largeur, hauteur) ou None si la webcam est fermée
|
||||
"""
|
||||
if not self.is_available():
|
||||
return None
|
||||
|
||||
w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
return (w, h)
|
||||
@@ -134,11 +134,12 @@ class CameraRecordManager():
|
||||
|
||||
class MultiWellManager:
|
||||
|
||||
def __init__(self, position, feed=None, step=None, proc=None):
|
||||
def __init__(self, position, feed=None, step=None, process=None):
|
||||
self.set_multiwell(position)
|
||||
self._feed = feed
|
||||
self._step = step
|
||||
self.proc = proc
|
||||
self.process = process
|
||||
self.tag = process.tag
|
||||
self.scanner = None
|
||||
|
||||
def set_multiwell(self, position):
|
||||
@@ -159,11 +160,11 @@ class MultiWellManager:
|
||||
xynext.append((0, 0))
|
||||
|
||||
pos = 1
|
||||
self.proc.session = session.id
|
||||
self.tag.session = session.id
|
||||
started = timezone.now()
|
||||
for obs in observations:
|
||||
conf = obs.multiwell.config()
|
||||
self.scanner = grbl.GridScanner(machine, proc=self.proc, **conf)
|
||||
self.scanner = grbl.GridScanner(machine, process=self.process, **conf)
|
||||
obs.started = timezone.now()
|
||||
obs.save()
|
||||
|
||||
@@ -187,8 +188,8 @@ class MultiWellManager:
|
||||
conf['xnext'] = self._xbase
|
||||
conf['ynext'] = self._ybase
|
||||
|
||||
self.proc.session = 0
|
||||
self.scanner = grbl.GridScanner(machine, proc=self.proc, **conf)
|
||||
self.tag.session = 0
|
||||
self.scanner = grbl.GridScanner(machine, process=self.process, **conf)
|
||||
Thread(target=self._start_test, daemon=True).start()
|
||||
|
||||
def scan(self, machine, sid):
|
||||
@@ -293,7 +294,7 @@ class ScannerProcess(Task):
|
||||
self.multiwel = None
|
||||
self.conf = None
|
||||
self.record_queue = Queue()
|
||||
self.proc = ProcTag()
|
||||
self.tag = ProcTag()
|
||||
self.manager = None
|
||||
self.recordDB = CameraRecordManager(cameraDB)
|
||||
|
||||
@@ -325,6 +326,7 @@ class ScannerProcess(Task):
|
||||
|
||||
#self.crop = CircularCrop(radius=self.crop_radius, strategy=CropStrategy.CROP_JPEG, jpeg_quality=self.image_quality)
|
||||
self.crop = self.set_crop_radius(self.crop_radius)
|
||||
'''
|
||||
if not self.conf.use_rpicam:
|
||||
from modules.webcam_capture import WebcamCapture
|
||||
self.cam = WebcamCapture(
|
||||
@@ -342,6 +344,23 @@ class ScannerProcess(Task):
|
||||
height=self.video_height,
|
||||
jpeg_quality=self.video_quality,
|
||||
)
|
||||
'''
|
||||
from modules.videofile_capture import VideoFileCapture
|
||||
self.cam = VideoFileCapture(
|
||||
video_file=settings.MEDIA_ROOT / 'simulation' / 'part2-5fps.mp4',
|
||||
fps=self.video_fps,
|
||||
width=self.video_width,
|
||||
height=self.video_height,
|
||||
jpeg_quality=self.video_quality,
|
||||
video_lists = [
|
||||
settings.MEDIA_ROOT / 'simulation' / 'part1-5fps.mp4',
|
||||
settings.MEDIA_ROOT / 'simulation' / 'part2-5fps.mp4',
|
||||
settings.MEDIA_ROOT / 'simulation' / 'part3-5fps.mp4',
|
||||
settings.MEDIA_ROOT / 'simulation' / 'part4-5fps.mp4',
|
||||
settings.MEDIA_ROOT / 'simulation' / 'part5-5fps.mp4',
|
||||
]
|
||||
)
|
||||
|
||||
self.cam.set_frame_callback(self._on_frame)
|
||||
self.cam.set_median(False)
|
||||
self.cam.set_circular_crop(None)
|
||||
@@ -379,20 +398,29 @@ class ScannerProcess(Task):
|
||||
if self.grbl:
|
||||
self._send(**msg)
|
||||
|
||||
def _on_frame(self, jpeg_bytes: bytes, ts: datetime) -> None:
|
||||
if self.proc.record:
|
||||
def _on_frame(self, jpeg_bytes: bytes, ts: datetime, metrics: dict) -> None:
|
||||
if self.tag.record:
|
||||
# record images
|
||||
self.record_queue.put((self.proc.uuid, ts, jpeg_bytes))
|
||||
if self.proc.play:
|
||||
self.record_queue.put((self.tag.uuid, ts, jpeg_bytes, metrics))
|
||||
if self.tag.play:
|
||||
# play image
|
||||
self._send(ts=ts.timestamp(), jpeg=base64.b64encode(jpeg_bytes).decode(), )
|
||||
self._send(ts=ts.timestamp(), jpeg=base64.b64encode(jpeg_bytes).decode(), **metrics)
|
||||
|
||||
def _recording(self):
|
||||
logger.info(f"Scanner {self.group}: start recorder")
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
(uuid, ts, frame) = self.record_queue.get()
|
||||
labels = dict(fps=self.video_fps, session=self.proc.session)
|
||||
(uuid, ts, frame, metrics) = self.record_queue.get()
|
||||
labels = dict(fps=self.video_fps, session=self.tag.session, detected="1" if metrics.get("detected") else "0")
|
||||
if metrics.get("detected"):
|
||||
labels.update({
|
||||
"cx" : str(metrics["cx"]),
|
||||
"cy" : str(metrics["cy"]),
|
||||
"area_px" : str(metrics["area_px"]),
|
||||
"speed_px_s" : str(metrics["speed_px_s"]),
|
||||
"axial_pos" : str(metrics["axial_pos"]),
|
||||
"axial_speed" : str(metrics["axial_speed"]),
|
||||
})
|
||||
self.recordDB.write(uuid, frame, labels, ts=ts)
|
||||
self.record_queue.task_done()
|
||||
except Exception as e:
|
||||
@@ -418,7 +446,7 @@ class ScannerProcess(Task):
|
||||
self.default_multiwell,
|
||||
feed=self.default_feed,
|
||||
step=self.default_step,
|
||||
proc=self.proc
|
||||
process=self
|
||||
)
|
||||
|
||||
for message in pubsub.listen():
|
||||
|
||||
@@ -14,6 +14,14 @@ class ScannerManager {
|
||||
|
||||
init_controls() {
|
||||
this.ts = sId("_ts");
|
||||
this.cx = sId("_cx");
|
||||
this.cy = sId("_cy");
|
||||
this.speed_px_s = sId("_speed_px_s");
|
||||
this.axial_speed = sId("_axial_speed");
|
||||
this.axial_pos = sId("_axial_pos");
|
||||
this.area_px = sId("_area_px");
|
||||
this.frame_count = sId("_count");
|
||||
|
||||
const goto_0 = sId("_goto-0");
|
||||
const goto_xy = sId("_goto-xy");
|
||||
const xy_base = sId("_xy-base");
|
||||
@@ -76,6 +84,16 @@ class ScannerManager {
|
||||
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
|
||||
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
|
||||
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
|
||||
|
||||
if (payload.detected) {
|
||||
this.cx.textContent = payload.cx; this.cy.textContent = payload.cy;
|
||||
this.speed_px_s.textContent = payload.speed_px_s;
|
||||
this.axial_speed.textContent = payload.axial_speed;
|
||||
this.axial_pos.textContent = payload.axial_pos;
|
||||
this.area_px.textContent = payload.area_px;
|
||||
this.frame_count.textContent = payload.count;
|
||||
}
|
||||
|
||||
} catch(e) { console.log(e); }
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@ class ScannerManager {
|
||||
toggle_crop() { this.croping = !this.croping; return this.croping; }
|
||||
|
||||
init_controls() {
|
||||
this.cx = sId("_cx");
|
||||
this.cy = sId("_cy");
|
||||
this.speed_px_s = sId("_speed_px_s");
|
||||
this.axial_speed = sId("_axial_speed");
|
||||
this.axial_pos = sId("_axial_pos");
|
||||
this.area_px = sId("_area_px");
|
||||
this.frame_count = sId("_count");
|
||||
|
||||
this.session= sId("_session");
|
||||
this.ts = sId("_ts");
|
||||
this.x = sId("_x");
|
||||
@@ -40,6 +48,14 @@ class ScannerManager {
|
||||
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
|
||||
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
|
||||
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
|
||||
if (payload.detected) {
|
||||
this.cx.textContent = payload.cx; this.cy.textContent = payload.cy;
|
||||
this.speed_px_s.textContent = payload.speed_px_s;
|
||||
this.axial_speed.textContent = payload.axial_speed;
|
||||
this.axial_pos.textContent = payload.axial_pos;
|
||||
this.area_px.textContent = payload.area_px;
|
||||
this.frame_count.textContent = payload.count;
|
||||
}
|
||||
} catch(e) { console.log(e); }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
|
||||
class ScannerManager {
|
||||
|
||||
constructor(container, multiwells=null) {
|
||||
this.container = container;
|
||||
this.socket = null;
|
||||
this.multiweels = multiwells;
|
||||
this.axes = 0;
|
||||
this.cropping = 0;
|
||||
}
|
||||
|
||||
toggle_median() { this.axes = !this.axes; return this.axes; }
|
||||
toggle_crop() { this.croping = !this.croping; return this.croping; }
|
||||
|
||||
init_controls() {
|
||||
const goto_0 = sId("_goto-0");
|
||||
const goto_xy = sId("_goto-xy");
|
||||
const xy_base = sId("_xy-base");
|
||||
const xy_step = sId("_xy-step");
|
||||
const up = sId("_up");
|
||||
const down = sId("_down");
|
||||
const left = sId("_left");
|
||||
const right = sId("_right");
|
||||
this.feed = sId("_feed");
|
||||
this.step = sId("_step");
|
||||
this.well = sId("_well");
|
||||
this.x = sId("_x");
|
||||
this.y = sId("_y");
|
||||
this.dx = sId("_dx");
|
||||
this.dy = sId("_dy");
|
||||
this.xbase = sId("_xbase");
|
||||
this.ybase = sId("_ybase");
|
||||
const test = sId("_test");
|
||||
const halt = sId("_halt");
|
||||
|
||||
const median = sId("_median");
|
||||
const crop = sId("_crop");
|
||||
|
||||
up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); });
|
||||
down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); });
|
||||
left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); });
|
||||
right.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "right" }); });
|
||||
|
||||
goto_0.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_0" }); });
|
||||
goto_xy.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_xy" }); });
|
||||
xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
|
||||
xy_step.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_step" }); });
|
||||
|
||||
median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median", value: this.toggle_median() }); });
|
||||
crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop", value: this.toggle_crop() }); });
|
||||
this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "well", value: e.target.value }); });
|
||||
this.step.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "step", value: e.target.value }); });
|
||||
this.feed.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "feed", value: e.target.value }); });
|
||||
|
||||
this.dx.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dx", value: e.target.value }); });
|
||||
this.dy.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dy", value: e.target.value }); });
|
||||
|
||||
test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
|
||||
halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
|
||||
|
||||
}
|
||||
|
||||
registerSocket(socket) {
|
||||
this.socket = socket;
|
||||
this.init_controls();
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
try {
|
||||
if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; }
|
||||
if (payload.xbase) { this.xbase.textContent = payload.xbase; this.ybase.textContent = payload.ybase; }
|
||||
if (payload.dxy) { this.dy.value=payload.dy; this.dx.value=payload.dx; }
|
||||
if (payload.xy) { this.x.textContent=payload.x; this.y.textContent=payload.y; }
|
||||
//if (payload.ts) { console.log(payload.ts); }
|
||||
} catch(e) { console.log(e); }
|
||||
}
|
||||
|
||||
init() {
|
||||
this._send({
|
||||
type: 'scanner',
|
||||
topic: "init",
|
||||
feed: this.feed.value,
|
||||
step: this.step.value,
|
||||
well: this.well.value
|
||||
});
|
||||
}
|
||||
start() { this._send({ type: 'scanner', topic: "start"}); }
|
||||
halt() { this._send({ type: 'scanner', topic: "halt" }); }
|
||||
|
||||
_send(message) { this.socket.send(message); }
|
||||
}
|
||||
|
||||
class MetadataSocket {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.ws = null;
|
||||
this.manager = null;
|
||||
this.reconnectDelay = 1000;
|
||||
this.shouldReconnect = true;
|
||||
this.reconnect = false;
|
||||
}
|
||||
|
||||
setManager(manager) { this.manager = manager; }
|
||||
|
||||
connect() {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
this.manager.update(data);
|
||||
};
|
||||
|
||||
this.ws.onopen = (event) => {
|
||||
if (this.manager && !this.reconnect)
|
||||
this.manager['init']();
|
||||
this.reconnect = false;
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.warn(`WebSocket closed...`);
|
||||
if (this.shouldReconnect) {
|
||||
this.reconnect = true;
|
||||
setTimeout(() => {
|
||||
console.log("Reconnect WebSocket...");
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
send(obj) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(obj)); } }
|
||||
}
|
||||
@@ -96,7 +96,9 @@
|
||||
<button id="_halt" class="w3-button w3-red w3-round-large w3-margin-small w3-block"><i class="fa-solid fa-hand"></i> {% trans 'ARRET' %}</button>
|
||||
|
||||
</div>
|
||||
<div class="scanner"><img id="scan-img" class="w3-image"></div>
|
||||
{% include 'scanner/scan-image.html' %}
|
||||
|
||||
|
||||
</div>
|
||||
<ul id="_debug" class="w3-scroll-y" style="height: 30vh"></ul>
|
||||
{% endblock %}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</button>
|
||||
<button id="_halt" class="w3-button w3-red w3-round-large w3-padding-16 w3-block w3-margin-top"><i class="fa-solid fa-hand"></i><br>{% trans 'ARRET' %}</button>
|
||||
</div>
|
||||
<div class="scanner"><img id="scan-img" class="w3-image"></div>
|
||||
{% include 'scanner/scan-image.html' %}
|
||||
</div>
|
||||
<ul id="_debug" class="w3-scroll-y" style="height: 50vh"></ul>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
<div class="scanner w3-row">
|
||||
<div class="w3-col w3-small" style="width:180px">
|
||||
<div>Num: <span id="_count"></span></div>
|
||||
<div>Aire: <span id="_area_px"></span></div>
|
||||
|
||||
<div>cx: <span id="_cx"></span></div>
|
||||
<div>cy: <span id="_cy"></span></div>
|
||||
<div>V: <span id="_speed_px_s"></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>
|
||||
<div class="w3-rest">
|
||||
<img id="scan-img" class="w3-image">
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user