Video plate capture: calibration, edge enhance, auto-detect well borders
This commit is contained in:
@@ -25,7 +25,7 @@ from asgiref.sync import async_to_sync
|
||||
|
||||
from django.conf import settings
|
||||
from modules.planarian_tracker import PlanarianTracker
|
||||
from modules.planarian_metrics import ExperimentParams
|
||||
from modules.planarian_metrics import ExperimentParams, EthoVisionMetrics
|
||||
from modules.tube_aligner import TubeAligner
|
||||
|
||||
|
||||
@@ -81,11 +81,12 @@ class VideoCaptureInterface(abc.ABC):
|
||||
self._circular_crop: Optional["CircularCrop"] = None # Recadrage circulaire optionnel
|
||||
self._active_median = False
|
||||
self._active_crop = False
|
||||
self._active_edge_enhance = False
|
||||
self._error_occured = False
|
||||
|
||||
self._tracker = None
|
||||
self._metrics = None
|
||||
self._params = None
|
||||
self._tracker: PlanarianTracker | None = None
|
||||
self._metrics: list[EthoVisionMetrics] | None = None
|
||||
self._params: ExperimentParams | None = None
|
||||
self._clientDB = self.parent.metricDB
|
||||
|
||||
# Tracker générique, pour simulation
|
||||
@@ -284,10 +285,30 @@ class VideoCaptureInterface(abc.ABC):
|
||||
msg= f"{self.__class__.__name__}: recadrage circulaire désactivé"
|
||||
|
||||
logger.info(msg)
|
||||
self.display(state='circular_crop', msg=msg)
|
||||
if self.display is not None:
|
||||
self.display(state='circular_crop', msg=msg)
|
||||
|
||||
|
||||
def process_frame(self, jpeg_bytes: bytes) -> bytes:
|
||||
def set_edge_enhance(self, enabled: bool) -> None:
|
||||
"""Active ou désactive le filtre de mise en évidence des contours (calibration)."""
|
||||
self._active_edge_enhance = enabled
|
||||
logger.info(f"{self.__class__.__name__}: edge_enhance={enabled}")
|
||||
if self.display is not None:
|
||||
self.display(state='edge_enhance', value=enabled, msg=f"Edge enhance: {enabled}")
|
||||
|
||||
def _apply_edge_enhance(self, frame: np.ndarray) -> np.ndarray:
|
||||
"""Overlay Canny vert additif sur l'image originale.
|
||||
Flou fort avant détection pour ne garder que les bords dominants (rebord du puit).
|
||||
"""
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
blurred = cv2.GaussianBlur(gray, (9, 9), 2)
|
||||
edges = cv2.Canny(blurred, 80, 200)
|
||||
edges = cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations=1)
|
||||
overlay = np.zeros_like(frame)
|
||||
overlay[edges > 0] = [0, 255, 0] # vert sur fond noir
|
||||
return cv2.addWeighted(frame, 1.0, overlay, 1.0, 0) # additif : image inchangée hors bords
|
||||
|
||||
def process_frame(self, jpeg_bytes: bytes) -> tuple[bytes, dict]:
|
||||
"""
|
||||
Applique le post-traitement configuré sur une image brute.
|
||||
|
||||
@@ -305,8 +326,12 @@ class VideoCaptureInterface(abc.ABC):
|
||||
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
if frame is None:
|
||||
return jpeg, metrics
|
||||
try:
|
||||
# Mode debug
|
||||
try:
|
||||
# Edge enhance sur la frame propre, avant les annotations
|
||||
if self._active_edge_enhance:
|
||||
frame = self._apply_edge_enhance(frame)
|
||||
##
|
||||
# Mode debug (annotations par-dessus)
|
||||
if self._aligner.debug:
|
||||
self.align_detection = self._aligner.detect_tube(frame)
|
||||
annotated = self.align_detection.get('frame_annotated')
|
||||
@@ -324,7 +349,17 @@ class VideoCaptureInterface(abc.ABC):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
|
||||
# Pas de circular crop — appliquer edge enhance si actif
|
||||
if self._active_edge_enhance:
|
||||
nparr = np.frombuffer(jpeg_bytes, np.uint8)
|
||||
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
if frame is not None:
|
||||
frame = self._apply_edge_enhance(frame)
|
||||
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
|
||||
if ok:
|
||||
return buf.tobytes(), metrics
|
||||
|
||||
return jpeg_bytes, metrics
|
||||
|
||||
def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path:
|
||||
|
||||
@@ -66,7 +66,7 @@ class CircularCrop:
|
||||
|
||||
# Cache du masque pour éviter de le recalculer à chaque frame
|
||||
self._mask_cache: Optional[np.ndarray] = None
|
||||
self._mask_shape: Optional[tuple[int, int, int]] = None # (H, W, strategy)
|
||||
self._mask_shape: Optional[tuple[int, ...]] = None # (H, W, cx, cy, radius)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API publique
|
||||
|
||||
@@ -12,6 +12,7 @@ import logging
|
||||
import serial
|
||||
import time
|
||||
import threading
|
||||
from typing import Callable, Any
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -41,11 +42,10 @@ class GRBLController:
|
||||
if y_max is not None:
|
||||
self.Y_MAX = y_max
|
||||
|
||||
self._state = send_callback
|
||||
if self._state is None:
|
||||
self._state = self._send_msg
|
||||
self._state: Callable[..., Any] = send_callback if send_callback is not None else self._send_msg
|
||||
|
||||
self.x, self.y = 0, 0
|
||||
self.x: float | None = None
|
||||
self.y: float | None = None
|
||||
|
||||
#self.start_connection()
|
||||
|
||||
@@ -67,8 +67,8 @@ class GRBLController:
|
||||
try:
|
||||
self.ser = serial.Serial(self.port, self.baudrate, timeout=self.timeout, exclusive=True)
|
||||
# CRITIQUE :
|
||||
self.ser.setDTR(False)
|
||||
self.ser.setRTS(False)
|
||||
self.ser.setDTR(False) # type: ignore[attr-defined]
|
||||
self.ser.setRTS(False) # type: ignore[attr-defined]
|
||||
self.clear_buffer()
|
||||
|
||||
self._wake_up()
|
||||
@@ -192,7 +192,7 @@ class GRBLController:
|
||||
|
||||
def move_relative(self, dx=0, dy=0, feed=1000):
|
||||
x, y = self.get_mpos() # Position actuelle
|
||||
self.move_to(x + dx, y + dy, feed=feed)
|
||||
self.move_to((x or 0) + dx, (y or 0) + dy, feed=feed)
|
||||
|
||||
def move_relative__(self, dx=0, dy=0, feed=1000):
|
||||
self.send("G91") # Mode relatif
|
||||
|
||||
@@ -15,6 +15,7 @@ import logging
|
||||
import time
|
||||
import threading
|
||||
import math
|
||||
from typing import Callable, Any
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -47,12 +48,11 @@ class GRBLController:
|
||||
if y_max is not None:
|
||||
self.Y_MAX = y_max
|
||||
|
||||
self._state = send_callback
|
||||
if self._state is None:
|
||||
self._state = self._send_msg
|
||||
self._state: Callable[..., Any] = send_callback if send_callback is not None else self._send_msg
|
||||
|
||||
# Position courante simulée
|
||||
self.x, self.y = 0.0, 0.0
|
||||
self.x: float | None = None
|
||||
self.y: float | None = None
|
||||
|
||||
# État interne de la machine simulée
|
||||
self._machine_state = 'Idle' # Idle | Run | Alarm
|
||||
@@ -150,7 +150,7 @@ class GRBLController:
|
||||
# Homing : retour à l'origine avec délai simulé
|
||||
self._machine_state = 'Run'
|
||||
self._state(state='send', msg="SIMULATOR: homing...")
|
||||
distance = math.hypot(self.x, self.y)
|
||||
distance = math.hypot(self.x or 0.0, self.y or 0.0)
|
||||
self._simulate_move_delay(distance, feed=3000)
|
||||
self.x, self.y = 0.0, 0.0
|
||||
self._machine_state = 'Idle'
|
||||
@@ -158,7 +158,9 @@ class GRBLController:
|
||||
|
||||
# --- Extraction des coordonnées X, Y et du feed F ---
|
||||
tokens = cmd_upper.replace(',', ' ').split()
|
||||
new_x, new_y, feed = self.x, self.y, 1000.0
|
||||
new_x: float = self.x or 0.0
|
||||
new_y: float = self.y or 0.0
|
||||
feed: float = 1000.0
|
||||
|
||||
for token in tokens:
|
||||
if token.startswith('X'):
|
||||
@@ -186,8 +188,10 @@ class GRBLController:
|
||||
|
||||
# --- Mouvement effectif (G0, G1, G53 G1, etc.) ---
|
||||
has_move = any(t in tokens for t in ('G0', 'G1', 'G53'))
|
||||
if has_move and (new_x != self.x or new_y != self.y):
|
||||
distance = math.hypot(new_x - self.x, new_y - self.y)
|
||||
cur_x = self.x or 0.0
|
||||
cur_y = self.y or 0.0
|
||||
if has_move and (new_x != cur_x or new_y != cur_y):
|
||||
distance = math.hypot(new_x - cur_x, new_y - cur_y)
|
||||
self._machine_state = 'Run'
|
||||
self._simulate_move_delay(distance, feed)
|
||||
self.x = new_x
|
||||
@@ -208,7 +212,9 @@ class GRBLController:
|
||||
|
||||
def get_status(self):
|
||||
'''Retourne un status GRBL simulé au format <State|MPos:x,y,z>.'''
|
||||
status = f"<{self._machine_state}|MPos:{self.x:.3f},{self.y:.3f},0.000|FS:0,0>"
|
||||
x = self.x or 0.0
|
||||
y = self.y or 0.0
|
||||
status = f"<{self._machine_state}|MPos:{x:.3f},{y:.3f},0.000|FS:0,0>"
|
||||
logger.debug(f"SIMULATOR::get_status → {status}")
|
||||
return status
|
||||
|
||||
@@ -257,7 +263,7 @@ class GRBLController:
|
||||
|
||||
def move_relative(self, dx=0, dy=0, feed=1000):
|
||||
x, y = self.get_mpos() # Position actuelle
|
||||
self.move_to(x + dx, y + dy, feed=feed)
|
||||
self.move_to((x or 0.0) + dx, (y or 0.0) + dy, feed=feed)
|
||||
|
||||
def move_relative__(self, dx=0, dy=0, feed=1000):
|
||||
self.send("G91") # Mode relatif
|
||||
|
||||
@@ -282,7 +282,7 @@ class EthoVisionMetrics:
|
||||
self.total_distance_mm += dist_mm
|
||||
|
||||
# Vecteur de déplacement (pour calculs d'angle)
|
||||
if self._prev_cx_mm is not None:
|
||||
if self._prev_cx_mm is not None and self._prev_cy_mm is not None:
|
||||
move_dx = cx_mm - self._prev_cx_mm
|
||||
move_dy = cy_mm - self._prev_cy_mm
|
||||
else:
|
||||
@@ -570,6 +570,36 @@ class ExperimentParams:
|
||||
**BEHAVIOUR_DEFAULTS,
|
||||
}
|
||||
|
||||
# Attributs issus de REQUIRED
|
||||
experiment: str
|
||||
well: str
|
||||
px_per_mm: float
|
||||
fps: float
|
||||
# Attributs issus de DEFAULTS
|
||||
well_radius_mm: float
|
||||
thresh_immobile: float
|
||||
thresh_mobile: float
|
||||
planarian_count: int
|
||||
tube_axis: str
|
||||
min_area_px: int
|
||||
max_area_ratio: float
|
||||
merge_kernel_size: int
|
||||
min_contour_dist_px: int
|
||||
# Attributs issus de BEHAVIOUR_DEFAULTS
|
||||
thigmotaxis_wall_dist_mm: float
|
||||
photo_mode: str
|
||||
photo_strength: float
|
||||
photo_x: float
|
||||
photo_y: float
|
||||
photo_flee_angle_deg: float
|
||||
chemo_strength: float
|
||||
chemo_x: float
|
||||
chemo_y: float
|
||||
chemo_radius_mm: float
|
||||
chemo_approach_angle_deg: float
|
||||
avoid_radius_mm: float
|
||||
aggreg_radius_mm: float
|
||||
|
||||
def __init__(self, data: dict):
|
||||
missing = self.REQUIRED - set(data.keys())
|
||||
if missing:
|
||||
|
||||
@@ -75,12 +75,12 @@ class PlanarianState:
|
||||
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
|
||||
self.idx: int = idx
|
||||
self.cx: int | None = None
|
||||
self.cy: int | None = None
|
||||
self.ts: float | None = None
|
||||
self.lost: int = 0 # compteur de frames sans détection
|
||||
self.active: bool = False # vrai si l'individu a été détecté au moins une fois
|
||||
|
||||
def update(self, cx: int, cy: int, ts: float):
|
||||
"""
|
||||
@@ -117,7 +117,7 @@ class PlanarianState:
|
||||
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:
|
||||
if self.cx is None or self.cy is None or self.ts is None:
|
||||
return 0.0, 0.0
|
||||
|
||||
dt = ts - self.ts
|
||||
@@ -445,7 +445,7 @@ class PlanarianTracker:
|
||||
row_ind, col_ind = linear_sum_assignment(cost)
|
||||
|
||||
# Construire le dict d'association
|
||||
assignment = {i: None for i in range(n_slots)}
|
||||
assignment: dict[int, int | None] = {i: None for i in range(n_slots)}
|
||||
|
||||
assigned_dets = set()
|
||||
for ri, ci in zip(row_ind, col_ind):
|
||||
|
||||
@@ -32,7 +32,6 @@ class ReductStoreBase(ABC):
|
||||
settings = BucketSettings(
|
||||
quota_type=self.quota_type,
|
||||
quota_size=self.quota_size,
|
||||
exist_ok=True,
|
||||
)
|
||||
return await self.client.create_bucket(self.bucket_name, settings, exist_ok=True)
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ def _update_cache():
|
||||
_timer.start()
|
||||
|
||||
|
||||
def start_background_updater(interval_seconds: int = None):
|
||||
def start_background_updater(interval_seconds: int = 0):
|
||||
global REFRESH_INTERVAL, _timer
|
||||
if interval_seconds:
|
||||
REFRESH_INTERVAL = interval_seconds
|
||||
|
||||
@@ -22,13 +22,24 @@ class TubeAligner:
|
||||
grbl_threshold_px : int = 20,
|
||||
dead_zone_px : int = 5,
|
||||
debug : bool = False, # ← activable depuis la vue
|
||||
display = None, # display function
|
||||
display = None, # display function
|
||||
):
|
||||
self.grbl_threshold_px = grbl_threshold_px
|
||||
self.dead_zone_px = dead_zone_px
|
||||
self.debug = debug
|
||||
self.display = display
|
||||
self.TUBE_DIAMETER_MM = 16.0
|
||||
# Plage de recherche du rayon en fraction de min(w,h)
|
||||
# Défaut : tube occupe ~30% du champ (camera).
|
||||
# Mode vidéo : puit remplit le crop → ratio ~0.50 → appeler set_radius_range(0.35, 0.52)
|
||||
self._min_radius_ratio = 0.26
|
||||
self._max_radius_ratio = 0.37
|
||||
self.draw_annotations = True # masquer l'overlay sans couper la détection
|
||||
|
||||
def set_radius_range(self, min_ratio: float, max_ratio: float) -> None:
|
||||
"""Ajuste la plage de recherche HoughCircles. Appeler avant detect_tube()."""
|
||||
self._min_radius_ratio = min_ratio
|
||||
self._max_radius_ratio = max_ratio
|
||||
|
||||
|
||||
def set_tube_diameter(self, tube_diameter: float = 16.0) -> None:
|
||||
@@ -64,14 +75,18 @@ class TubeAligner:
|
||||
frame_out = frame.copy()
|
||||
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
# CLAHE pour renforcer le contraste local (paroi du puit sur fond gris uniforme)
|
||||
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
|
||||
gray = clahe.apply(gray)
|
||||
blurred = cv2.GaussianBlur(gray, (15, 15), 3)
|
||||
|
||||
|
||||
lo = self._min_radius_ratio
|
||||
hi = self._max_radius_ratio
|
||||
# 3 configurations légèrement différentes — vote majoritaire
|
||||
# Fonctionne sur fond sombre ET fond clair
|
||||
configs = [
|
||||
dict(param1=50, param2=30, minRadius=int(min(w,h)*0.26), maxRadius=int(min(w,h)*0.36)),
|
||||
dict(param1=60, param2=30, minRadius=int(min(w,h)*0.26), maxRadius=int(min(w,h)*0.37)),
|
||||
dict(param1=50, param2=28, minRadius=int(min(w,h)*0.25), maxRadius=int(min(w,h)*0.365)),
|
||||
dict(param1=50, param2=30, minRadius=int(min(w,h)*lo), maxRadius=int(min(w,h)*hi)),
|
||||
dict(param1=60, param2=28, minRadius=int(min(w,h)*lo), maxRadius=int(min(w,h)*(hi+0.01))),
|
||||
dict(param1=50, param2=26, minRadius=int(min(w,h)*(lo-0.01)), maxRadius=int(min(w,h)*(hi+0.005))),
|
||||
]
|
||||
|
||||
all_cx, all_cy, all_r = [], [], []
|
||||
@@ -93,7 +108,7 @@ class TubeAligner:
|
||||
if not all_cx:
|
||||
msg = f"TubeAligner: aucun cercle détecté ({w}x{h})"
|
||||
result["msg"] =msg
|
||||
if self.debug:
|
||||
if self.debug and self.draw_annotations:
|
||||
frame_out = self._draw_debug_no_detection(frame_out, cx_img, cy_img)
|
||||
result["frame_annotated"] = frame_out
|
||||
return result
|
||||
@@ -120,7 +135,7 @@ class TubeAligner:
|
||||
else:
|
||||
action = "grbl"
|
||||
|
||||
if self.debug:
|
||||
if self.debug and self.draw_annotations:
|
||||
frame_out = self._draw_debug(
|
||||
frame_out, cx_img, cy_img,
|
||||
tx, ty, tr,
|
||||
|
||||
@@ -70,6 +70,7 @@ def get_tmpfs_info(mount_point="/ramdisk"):
|
||||
return f"{n:.1f}PB"
|
||||
|
||||
usage = None
|
||||
part = None
|
||||
for part in psutil.disk_partitions(all=True):
|
||||
if part.mountpoint == mount_point and part.fstype.lower() == "tmpfs":
|
||||
usage = psutil.disk_usage(part.mountpoint)
|
||||
@@ -81,15 +82,17 @@ def get_tmpfs_info(mount_point="/ramdisk"):
|
||||
print(f" Free: {usage.free} bytes ({sizeof(usage.free)})")
|
||||
print(f" Percent used: {usage.percent}%")
|
||||
break
|
||||
return {
|
||||
"percent": usage.percent,
|
||||
"mount": part.mountpoint,
|
||||
"device": part.device,
|
||||
"fstype": part.fstype,
|
||||
"total": usage.total,
|
||||
"used": usage.used,
|
||||
"free": usage.free,
|
||||
}
|
||||
if usage and part:
|
||||
return {
|
||||
"percent": usage.percent,
|
||||
"mount": part.mountpoint,
|
||||
"device": part.device,
|
||||
"fstype": part.fstype,
|
||||
"total": usage.total,
|
||||
"used": usage.used,
|
||||
"free": usage.free,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def get_cpu_info():
|
||||
|
||||
@@ -33,7 +33,7 @@ class VideoFileCapture(VideoCaptureInterface):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
video_file: str = None,
|
||||
video_file: str | None = None,
|
||||
fps: float = VideoCaptureInterface.DEFAULT_FPS,
|
||||
jpeg_quality: int = 85,
|
||||
width: Optional[int] = None,
|
||||
@@ -50,7 +50,7 @@ class VideoFileCapture(VideoCaptureInterface):
|
||||
:param height: Hauteur souhaitée (None = valeur par défaut du pilote)
|
||||
"""
|
||||
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 | None = video_file
|
||||
self._jpeg_quality: int = jpeg_quality
|
||||
self._width: Optional[int] = width
|
||||
self._height: Optional[int] = height
|
||||
@@ -134,8 +134,8 @@ class VideoFileCapture(VideoCaptureInterface):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def video_file(self) -> int:
|
||||
"""Index du périphérique V4L2."""
|
||||
def video_file(self) -> str | None:
|
||||
"""Fichier vidéo."""
|
||||
return self._video_file
|
||||
|
||||
@property
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
VideoPlateCapture — capture par extraction de région dans une vidéo plaque entière.
|
||||
|
||||
La vidéo montre l'ensemble de la plaque multi-puits (vue de dessus).
|
||||
La position GRBL (x, y en mm) détermine la région extraite via px_per_mm.
|
||||
Le résultat est un carré centré sur le puits courant, compatible avec
|
||||
le recadrage circulaire de process_frame() comme pour toute autre capture.
|
||||
|
||||
Flux : video frame → crop carré (GRBL pos) → CircularCrop → tracking/display
|
||||
|
||||
Hot swap : set_video_file(path) remplace la vidéo sans arrêter la capture.
|
||||
Thread-safe via _cap_lock.
|
||||
"""
|
||||
import os
|
||||
os.environ['OPENCV_LOG_LEVEL'] = "0"
|
||||
os.environ['OPENCV_FFMPEG_LOGLEVEL'] = "0"
|
||||
import cv2
|
||||
import numpy as np
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from modules.capture_interface import VideoCaptureInterface, CaptureError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoPlateCapture(VideoCaptureInterface):
|
||||
"""
|
||||
Lecture d'une vidéo de plaque complète avec crop dynamique à la position GRBL.
|
||||
|
||||
La position GRBL (x_mm, y_mm) est convertie en coordonnées pixel via px_per_mm.
|
||||
Un carré de côté 2*crop_radius_px est extrait à cette position, puis
|
||||
process_frame() applique le masque circulaire habituel.
|
||||
|
||||
La vidéo boucle automatiquement. La cadence est adaptée via frame_step
|
||||
pour correspondre au fps cible.
|
||||
|
||||
Calibration : set_px_per_mm() met à jour le facteur de conversion à chaud.
|
||||
Hot swap : set_video_file(path) change la vidéo sans interruption.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
video_dir,
|
||||
fps: float = 5.0,
|
||||
jpeg_quality: int = 90,
|
||||
use_tracking: bool = False,
|
||||
display=None,
|
||||
parent=None,
|
||||
crop_radius_px: int = 150,
|
||||
px_per_mm: float = 15.0,
|
||||
x_offset_mm: float = 0.0,
|
||||
y_offset_mm: float = 0.0,
|
||||
initial_video_path: str | None = None,
|
||||
):
|
||||
super().__init__(fps, use_tracking, display, parent, jpeg_quality)
|
||||
self._video_dir = Path(video_dir)
|
||||
self._cap: cv2.VideoCapture | None = None
|
||||
self._cap_lock = threading.Lock()
|
||||
self._video_path: Path | None = (
|
||||
Path(initial_video_path) if initial_video_path else None
|
||||
)
|
||||
self._frame_w: int = 0
|
||||
self._frame_h: int = 0
|
||||
self._crop_radius_px: int = crop_radius_px
|
||||
self._px_per_mm: float = px_per_mm
|
||||
self._x_offset_mm: float = x_offset_mm
|
||||
self._y_offset_mm: float = y_offset_mm
|
||||
self._frame_step: int = 1
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API publique
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_px_per_mm(self, px_per_mm: float) -> None:
|
||||
"""Met à jour le facteur de conversion mm → pixel à chaud (depuis WellPosition)."""
|
||||
self._px_per_mm = px_per_mm
|
||||
logger.info(f"VideoPlateCapture: px_per_mm={px_per_mm:.3f}")
|
||||
|
||||
def set_crop_radius_px(self, r: int) -> None:
|
||||
"""Met à jour le rayon de découpe en pixels à chaud (depuis MultiWell.crop_radius)."""
|
||||
self._crop_radius_px = r
|
||||
logger.info(f"VideoPlateCapture: crop_radius_px={r}")
|
||||
|
||||
def set_offset_mm(self, x_offset_mm: float, y_offset_mm: float) -> None:
|
||||
"""Met à jour l'offset d'origine (xbase, ybase du MultiWell) à chaud."""
|
||||
self._x_offset_mm = x_offset_mm
|
||||
self._y_offset_mm = y_offset_mm
|
||||
|
||||
def set_video_file(self, path: str) -> None:
|
||||
"""
|
||||
Hot swap : remplace la vidéo courante sans arrêter la capture.
|
||||
Thread-safe — peut être appelé depuis n'importe quel thread.
|
||||
"""
|
||||
new_cap = cv2.VideoCapture(path)
|
||||
if not new_cap.isOpened():
|
||||
logger.error(f"VideoPlateCapture hot swap: impossible d'ouvrir {path}")
|
||||
return
|
||||
|
||||
new_w = int(new_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
new_h = int(new_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
nat_fps = new_cap.get(cv2.CAP_PROP_FPS) or self._fps
|
||||
new_step = max(1, round(nat_fps / self._fps))
|
||||
|
||||
with self._cap_lock:
|
||||
old_cap = self._cap
|
||||
self._cap = new_cap
|
||||
self._video_path = Path(path)
|
||||
self._frame_w = new_w
|
||||
self._frame_h = new_h
|
||||
self._frame_step = new_step
|
||||
|
||||
if old_cap:
|
||||
old_cap.release()
|
||||
|
||||
logger.info(f"VideoPlateCapture: hot swap → {Path(path).name} {new_w}×{new_h}")
|
||||
|
||||
@property
|
||||
def video_path(self) -> Path | None:
|
||||
return self._video_path
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Implémentation VideoCaptureInterface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def open(self) -> None:
|
||||
path = self._video_path if (self._video_path and self._video_path.exists()) \
|
||||
else self._find_video()
|
||||
if path is None:
|
||||
raise CaptureError(f"Aucune vidéo trouvée dans {self._video_dir}")
|
||||
|
||||
cap = cv2.VideoCapture(str(path))
|
||||
if not cap.isOpened():
|
||||
raise CaptureError(f"Impossible d'ouvrir {path.name}")
|
||||
|
||||
nat_fps = cap.get(cv2.CAP_PROP_FPS) or self._fps
|
||||
|
||||
with self._cap_lock:
|
||||
self._cap = cap
|
||||
self._video_path = path
|
||||
self._frame_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
self._frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
self._frame_step = max(1, round(nat_fps / self._fps))
|
||||
|
||||
logger.info(
|
||||
f"VideoPlateCapture: {path.name} {self._frame_w}×{self._frame_h} "
|
||||
f"@ {nat_fps:.1f} fps → step={self._frame_step}, "
|
||||
f"px_per_mm={self._px_per_mm:.2f}"
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._cap_lock:
|
||||
cap, self._cap = self._cap, None
|
||||
if cap:
|
||||
cap.release()
|
||||
|
||||
def is_available(self) -> bool:
|
||||
with self._cap_lock:
|
||||
return self._cap is not None and self._cap.isOpened()
|
||||
|
||||
def capture_frame(self) -> bytes:
|
||||
with self._cap_lock:
|
||||
if self._cap is None or not self._cap.isOpened():
|
||||
raise CaptureError("Vidéo non disponible")
|
||||
|
||||
# Avancer dans la vidéo pour correspondre au fps cible
|
||||
for _ in range(self._frame_step - 1):
|
||||
self._cap.grab()
|
||||
|
||||
ret, frame = self._cap.read()
|
||||
if not ret:
|
||||
self._cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
ret, frame = self._cap.read()
|
||||
if not ret:
|
||||
raise CaptureError("Impossible de relire la vidéo")
|
||||
|
||||
frame_w = self._frame_w
|
||||
frame_h = self._frame_h
|
||||
|
||||
# Position GRBL (mm) → coordonnées pixel dans la vidéo plaque
|
||||
grbl = getattr(self.parent, 'grbl', None) if self.parent else None
|
||||
x_mm = float(getattr(grbl, 'x', None) or 0.0)
|
||||
y_mm = float(getattr(grbl, 'y', None) or 0.0)
|
||||
|
||||
# À l'origine CNC (0, 0) : retourner la plaque entière à sa résolution native
|
||||
if x_mm == 0.0 and y_mm == 0.0:
|
||||
ok, buf = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
|
||||
if not ok:
|
||||
raise CaptureError("Encodage JPEG échoué")
|
||||
return buf.tobytes()
|
||||
|
||||
cx = int((x_mm - self._x_offset_mm) * self._px_per_mm)
|
||||
cy = int((y_mm - self._y_offset_mm) * self._px_per_mm)
|
||||
r = self._crop_radius_px
|
||||
|
||||
# Extraction du carré centré sur le puits courant
|
||||
x1 = max(0, cx - r)
|
||||
y1 = max(0, cy - r)
|
||||
x2 = min(frame_w, cx + r)
|
||||
y2 = min(frame_h, cy + r)
|
||||
crop = frame[y1:y2, x1:x2]
|
||||
|
||||
# Padding noir si le crop déborde du bord de la vidéo
|
||||
if crop.shape[0] != 2 * r or crop.shape[1] != 2 * r:
|
||||
padded = np.zeros((2 * r, 2 * r, 3), dtype=np.uint8)
|
||||
padded[:crop.shape[0], :crop.shape[1]] = crop
|
||||
crop = padded
|
||||
|
||||
ok, buf = cv2.imencode('.jpg', crop, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
|
||||
if not ok:
|
||||
raise CaptureError("Encodage JPEG échoué")
|
||||
return buf.tobytes()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Privé
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _find_video(self) -> Path | None:
|
||||
"""Retourne le premier fichier vidéo trouvé dans video_dir."""
|
||||
self._video_dir.mkdir(parents=True, exist_ok=True)
|
||||
for ext in ('*.mp4', '*.avi', '*.MP4', '*.AVI'):
|
||||
files = sorted(self._video_dir.glob(ext))
|
||||
if files:
|
||||
return files[0]
|
||||
return None
|
||||
Reference in New Issue
Block a user