Video plate capture: calibration, edge enhance, auto-detect well borders

This commit is contained in:
2026-06-03 17:56:23 +02:00
parent 4b42c03756
commit 9bb8fc1bce
58 changed files with 1699 additions and 274 deletions
+44 -9
View File
@@ -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:
+1 -1
View File
@@ -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
+7 -7
View File
@@ -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
+16 -10
View File
@@ -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
+31 -1
View File
@@ -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):
-1
View File
@@ -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)
+1 -1
View File
@@ -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
+23 -8
View File
@@ -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,
+12 -9
View File
@@ -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