second commit
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Interface abstraite de capture vidéo.
|
||||
Définit le contrat que toutes les implémentations doivent respecter.
|
||||
|
||||
4 méthodes @abstractmethod à implémenter : open(), close(), capture_frame(), is_available()
|
||||
Boucle de capture dans un thread daemon avec compensation de latence pour tenir les 5 fps
|
||||
Callback set_frame_callback(fn) appelé à chaque frame avec (bytes, datetime)
|
||||
save_frame() avec horodatage, start()/stop(), gestionnaire de contexte (with)
|
||||
CaptureError exception dédiée
|
||||
"""
|
||||
import os
|
||||
os.environ['OPENCV_LOG_LEVEL']="0"
|
||||
os.environ['OPENCV_FFMPEG_LOGLEVEL']="0"
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
import abc
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .circular_crop import CircularCrop # Evite l'import circulaire au runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CaptureError(Exception):
|
||||
"""Exception levée lors d'une erreur de capture."""
|
||||
pass
|
||||
|
||||
|
||||
class VideoCaptureInterface(abc.ABC):
|
||||
"""
|
||||
Interface abstraite pour la capture d'images vidéo en JPEG.
|
||||
|
||||
Cadence cible : 5 images par seconde (configurable).
|
||||
Les sous-classes doivent implémenter les méthodes abstraites
|
||||
pour gérer le matériel spécifique.
|
||||
"""
|
||||
|
||||
# Cadence par défaut en images par seconde
|
||||
DEFAULT_FPS: float = 5.0
|
||||
|
||||
def __init__(self, fps: float = DEFAULT_FPS):
|
||||
"""
|
||||
Initialise l'interface de capture.
|
||||
|
||||
:param fps: Cadence cible en images par seconde
|
||||
"""
|
||||
self._fps: float = fps
|
||||
self._interval: float = 1.0 / fps # Intervalle en secondes entre chaque capture
|
||||
self._running: bool = False # Indique si la capture est en cours
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._frame_count: int = 0 # Compteur total d'images capturées
|
||||
self._on_frame: Optional[Callable[[bytes, datetime], None]] = None # Callback image
|
||||
self._circular_crop: Optional["CircularCrop"] = None # Recadrage circulaire optionnel
|
||||
self._active_median = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Méthodes abstraites — obligatoires dans les sous-classes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@abc.abstractmethod
|
||||
def open(self) -> None:
|
||||
"""
|
||||
Ouvre et initialise le périphérique de capture.
|
||||
Doit lever CaptureError si le périphérique n'est pas disponible.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Libère le périphérique de capture et toutes les ressources associées.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def capture_frame(self) -> bytes:
|
||||
"""
|
||||
Capture une seule image et la retourne en JPEG brut.
|
||||
|
||||
:return: Données JPEG de l'image sous forme de bytes
|
||||
:raises CaptureError: Si la capture échoue
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""
|
||||
Vérifie si le périphérique est prêt à capturer.
|
||||
|
||||
:return: True si le périphérique est opérationnel
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Méthodes concrètes communes à toutes les implémentations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def fps(self) -> float:
|
||||
"""Cadence actuelle en images par seconde."""
|
||||
return self._fps
|
||||
|
||||
@fps.setter
|
||||
def fps(self, value: float) -> None:
|
||||
"""Modifie la cadence de capture à la volée."""
|
||||
if value <= 0:
|
||||
raise ValueError("La cadence doit être un nombre positif")
|
||||
self._fps = value
|
||||
self._interval = 1.0 / value
|
||||
logger.debug("Cadence mise à jour : %.1f fps (intervalle %.3f s)", value, self._interval)
|
||||
|
||||
@property
|
||||
def frame_count(self) -> int:
|
||||
"""Nombre total d'images capturées depuis le démarrage."""
|
||||
return self._frame_count
|
||||
|
||||
def set_frame_callback(self, callback: Callable[[bytes, datetime], None]) -> None:
|
||||
"""
|
||||
Définit la fonction appelée à chaque nouvelle image capturée.
|
||||
|
||||
:param callback: Fonction(jpeg_bytes, timestamp) appelée pour chaque frame
|
||||
"""
|
||||
self._on_frame = callback
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Démarre la capture en continu dans un thread dédié.
|
||||
Appelle open() si le périphérique n'est pas encore disponible.
|
||||
"""
|
||||
if self._running:
|
||||
logger.warning("La capture est déjà en cours")
|
||||
return
|
||||
|
||||
if not self.is_available():
|
||||
logger.info("Ouverture du périphérique avant démarrage")
|
||||
self.open()
|
||||
|
||||
self._running = True
|
||||
self._frame_count = 0
|
||||
self._thread = threading.Thread(
|
||||
target=self._capture_loop,
|
||||
name=f"{self.__class__.__name__}-capture",
|
||||
daemon=True, # Thread démon : s'arrête avec le processus principal
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info("%s : capture démarrée à %.1f fps", self.__class__.__name__, self._fps)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Arrête la capture et attend la fin du thread.
|
||||
Appelle close() pour libérer les ressources.
|
||||
"""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5.0) # Attente max 5 secondes
|
||||
self.close()
|
||||
logger.info(
|
||||
"%s : capture arrêtée — %d images capturées",
|
||||
self.__class__.__name__,
|
||||
self._frame_count,
|
||||
)
|
||||
|
||||
def set_circular_crop(self, crop: Optional["CircularCrop"]) -> None:
|
||||
"""
|
||||
Active ou désactive le recadrage circulaire appliqué à chaque frame.
|
||||
|
||||
Lorsqu'un CircularCrop est défini, chaque appel à capture_frame()
|
||||
passe automatiquement par process_frame() avant d'être transmis
|
||||
au callback ou sauvegardé.
|
||||
|
||||
:param crop: Instance CircularCrop configurée, ou None pour désactiver
|
||||
"""
|
||||
self._circular_crop = crop
|
||||
if crop is not None:
|
||||
logger.info(
|
||||
"%s : recadrage circulaire activé (R=%d, stratégie=%s)",
|
||||
self.__class__.__name__, crop.radius, crop.strategy.name,
|
||||
)
|
||||
else:
|
||||
logger.info("%s : recadrage circulaire désactivé", self.__class__.__name__)
|
||||
|
||||
def process_frame(self, jpeg_bytes: bytes) -> bytes:
|
||||
"""
|
||||
Applique le post-traitement configuré sur une image brute.
|
||||
|
||||
Actuellement : recadrage circulaire si un CircularCrop est défini.
|
||||
Peut être surchargé dans une sous-classe pour des traitements spécifiques.
|
||||
|
||||
:param jpeg_bytes: Image JPEG brute issue du capteur
|
||||
: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
|
||||
|
||||
def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path:
|
||||
"""
|
||||
Enregistre une image JPEG sur le disque avec un horodatage.
|
||||
|
||||
:param jpeg_bytes: Données brutes JPEG
|
||||
:param directory: Dossier de destination
|
||||
:param prefix: Préfixe du nom de fichier
|
||||
:return: Chemin du fichier créé
|
||||
"""
|
||||
dest = Path(directory)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
filepath = dest / f"{prefix}_{timestamp}.jpg"
|
||||
filepath.write_bytes(jpeg_bytes)
|
||||
logger.debug("Image sauvegardée : %s (%d octets)", filepath, len(jpeg_bytes))
|
||||
return filepath
|
||||
|
||||
def __enter__(self) -> "VideoCaptureInterface":
|
||||
"""Permet l'utilisation avec le gestionnaire de contexte 'with'."""
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""Ferme proprement le périphérique à la sortie du bloc 'with'."""
|
||||
self.close()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "actif" if self._running else "arrêté"
|
||||
return f"<{self.__class__.__name__} fps={self._fps} status={status}>"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# tracer médianes
|
||||
# ------------------------------------------------------------------
|
||||
def set_median(self, is_median=False):
|
||||
"""
|
||||
Active ou désactive les médianes
|
||||
"""
|
||||
self._active_median = is_median
|
||||
|
||||
def display_median(self, jpeg):
|
||||
if self._active_median:
|
||||
nparr = np.frombuffer(jpeg, np.uint8)
|
||||
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
height, width = frame.shape[:2]
|
||||
center_x = width // 2
|
||||
center_y = height // 2
|
||||
|
||||
cv2.line(frame, (center_x, 0), (center_x, height), (0, 255, 0), 1)
|
||||
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
|
||||
return jpeg
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Boucle interne de capture (privée)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _capture_loop(self) -> None:
|
||||
"""
|
||||
Boucle principale de capture tournant dans le thread dédié.
|
||||
Respecte la cadence cible et appelle le callback si défini.
|
||||
"""
|
||||
|
||||
while self._running:
|
||||
loop_start = time.monotonic()
|
||||
|
||||
try:
|
||||
jpeg = self.capture_frame()
|
||||
jpeg = self.display_median(jpeg)
|
||||
jpeg = self.process_frame(jpeg) # Recadrage circulaire si configuré
|
||||
|
||||
self._frame_count += 1
|
||||
|
||||
ts = datetime.now(timezone.utc)
|
||||
|
||||
if self._on_frame:
|
||||
try:
|
||||
self._on_frame(jpeg, ts)
|
||||
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)
|
||||
|
||||
# Compensation du temps d'exécution pour tenir la cadence
|
||||
elapsed = time.monotonic() - loop_start
|
||||
sleep_time = self._interval - elapsed
|
||||
if sleep_time > 0:
|
||||
time.sleep(sleep_time)
|
||||
else:
|
||||
logger.debug(
|
||||
"Cadence non tenue : %.3f s de retard (traitement=%.3f s)",
|
||||
-sleep_time,
|
||||
elapsed,
|
||||
)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Utilitaire de recadrage circulaire centré sur une image JPEG.
|
||||
|
||||
Trois stratégies disponibles :
|
||||
- MASK_BLACK : image originale, pixels hors cercle mis à noir, sortie JPEG
|
||||
- CROP_PNG : carré 2R×2R centré, canal alpha = masque circulaire, sortie PNG
|
||||
- CROP_JPEG : carré 2R×2R centré sans transparence, sortie JPEG (le plus compact)
|
||||
|
||||
Masque noir : image JPEG de taille originale, pixels hors cercle = noir → simple mais pas économe
|
||||
Crop circulaire + PNG : on crop au carré 2R×2R, on applique le masque alpha → PNG plus petit, transparence vraie, mais PNG > JPEG en taille
|
||||
Crop carré JPEG : on extrait juste le carré 2R×2R centré → JPEG compact, pas de transparence
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from enum import Enum, auto
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CropStrategy(Enum):
|
||||
"""Stratégie de recadrage circulaire."""
|
||||
MASK_BLACK = auto() # Masque noir — taille originale, sortie JPEG
|
||||
CROP_PNG = auto() # Carré cropé + alpha circulaire — sortie PNG
|
||||
CROP_JPEG = auto() # Carré cropé sans alpha — sortie JPEG (défaut recommandé)
|
||||
|
||||
|
||||
class CircularCrop:
|
||||
"""
|
||||
Applique un recadrage circulaire centré sur une image fournie en bytes JPEG.
|
||||
|
||||
Utilise uniquement NumPy + Pillow pour rester léger et compatible
|
||||
aussi bien sur PC que sur Raspberry Pi.
|
||||
|
||||
Exemple ::
|
||||
|
||||
crop = CircularCrop(radius=200, strategy=CropStrategy.CROP_JPEG, quality=80)
|
||||
result_bytes = crop.process(jpeg_bytes)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
radius: int,
|
||||
strategy: CropStrategy = CropStrategy.CROP_JPEG,
|
||||
jpeg_quality: int = 85,
|
||||
center: Optional[tuple[int, int]] = None,
|
||||
):
|
||||
"""
|
||||
:param radius: Rayon du cercle de recadrage en pixels
|
||||
:param strategy: Stratégie de sortie (voir CropStrategy)
|
||||
:param jpeg_quality: Qualité JPEG pour les sorties JPEG [0-100]
|
||||
:param center: Centre du cercle (col, row) — None = centre de l'image
|
||||
"""
|
||||
if radius <= 0:
|
||||
raise ValueError("Le rayon doit être un entier strictement positif")
|
||||
if not 0 <= jpeg_quality <= 100:
|
||||
raise ValueError("La qualité JPEG doit être comprise entre 0 et 100")
|
||||
|
||||
self._radius = radius
|
||||
self._strategy = strategy
|
||||
self._jpeg_quality = jpeg_quality
|
||||
self._center = center # None = calcul automatique au premier appel
|
||||
|
||||
# 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)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API publique
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def process(self, jpeg_bytes: bytes) -> bytes:
|
||||
"""
|
||||
Applique le recadrage circulaire sur une image JPEG.
|
||||
|
||||
:param jpeg_bytes: Image source en bytes JPEG
|
||||
:return: Image recadrée selon la stratégie choisie (JPEG ou PNG)
|
||||
:raises ValueError: Si les bytes ne sont pas une image valide
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
# Décodage JPEG → tableau NumPy RGB
|
||||
img = Image.open(io.BytesIO(jpeg_bytes)).convert("RGB")
|
||||
arr = np.asarray(img, dtype=np.uint8) # shape (H, W, 3)
|
||||
|
||||
h, w = arr.shape[:2]
|
||||
cx, cy = self._resolve_center(w, h)
|
||||
|
||||
if self._strategy == CropStrategy.MASK_BLACK:
|
||||
return self._apply_mask_black(arr, cx, cy)
|
||||
elif self._strategy == CropStrategy.CROP_PNG:
|
||||
return self._apply_crop_png(arr, cx, cy, w, h)
|
||||
else: # CROP_JPEG par défaut
|
||||
return self._apply_crop_jpeg(arr, cx, cy, w, h)
|
||||
|
||||
@property
|
||||
def radius(self) -> int:
|
||||
return self._radius
|
||||
|
||||
@radius.setter
|
||||
def radius(self, value: int) -> None:
|
||||
"""Modifie le rayon et invalide le cache du masque."""
|
||||
if value <= 0:
|
||||
raise ValueError("Le rayon doit être un entier strictement positif")
|
||||
self._radius = value
|
||||
self._invalidate_cache()
|
||||
|
||||
@property
|
||||
def strategy(self) -> CropStrategy:
|
||||
return self._strategy
|
||||
|
||||
@strategy.setter
|
||||
def strategy(self, value: CropStrategy) -> None:
|
||||
self._strategy = value
|
||||
self._invalidate_cache()
|
||||
|
||||
@property
|
||||
def jpeg_quality(self) -> int:
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stratégies internes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_mask_black(self, arr: np.ndarray, cx: int, cy: int) -> bytes:
|
||||
"""
|
||||
Pixels hors cercle remplacés par du noir.
|
||||
Sortie : JPEG de la même taille que l'original.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
mask = self._get_circle_mask(arr.shape[:2], cx, cy) # shape (H, W) bool
|
||||
result = arr.copy()
|
||||
result[~mask] = 0 # Tout ce qui est hors cercle → noir RGB
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(result).save(buf, format="JPEG", quality=self._jpeg_quality)
|
||||
return buf.getvalue()
|
||||
|
||||
def _apply_crop_png(self, arr: np.ndarray, cx: int, cy: int, w: int, h: int) -> bytes:
|
||||
"""
|
||||
Crop carré 2R×2R centré + canal alpha circulaire.
|
||||
Sortie : PNG avec transparence (pixels hors cercle = transparent).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
x1, y1, x2, y2 = self._crop_box(cx, cy, w, h)
|
||||
cropped = arr[y1:y2, x1:x2] # shape (2R, 2R, 3) ou moins si bord
|
||||
|
||||
# Canal alpha : 255 dans le cercle, 0 à l'extérieur
|
||||
ch, cw = cropped.shape[:2]
|
||||
local_cx = cx - x1
|
||||
local_cy = cy - y1
|
||||
alpha_mask = self._get_circle_mask((ch, cw), local_cx, local_cy)
|
||||
alpha = np.where(alpha_mask, 255, 0).astype(np.uint8)
|
||||
|
||||
rgba = np.dstack([cropped, alpha]) # shape (H, W, 4)
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(rgba, mode="RGBA").save(buf, format="PNG", optimize=True)
|
||||
return buf.getvalue()
|
||||
|
||||
def _apply_crop_jpeg(self, arr: np.ndarray, cx: int, cy: int, w: int, h: int) -> bytes:
|
||||
"""
|
||||
Crop carré 2R×2R centré, pixels hors cercle mis à noir.
|
||||
Sortie : JPEG compact sans canal alpha (meilleur compromis taille/qualité).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
x1, y1, x2, y2 = self._crop_box(cx, cy, w, h)
|
||||
cropped = arr[y1:y2, x1:x2].copy()
|
||||
|
||||
ch, cw = cropped.shape[:2]
|
||||
local_cx = cx - x1
|
||||
local_cy = cy - y1
|
||||
mask = self._get_circle_mask((ch, cw), local_cx, local_cy)
|
||||
cropped[~mask] = 0 # Hors cercle → noir dans le crop
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(cropped).save(buf, format="JPEG", quality=self._jpeg_quality)
|
||||
return buf.getvalue()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_center(self, w: int, h: int) -> tuple[int, int]:
|
||||
"""Retourne le centre configuré ou le centre géométrique de l'image."""
|
||||
if self._center is not None:
|
||||
return self._center
|
||||
return (w // 2, h // 2)
|
||||
|
||||
def _crop_box(self, cx: int, cy: int, w: int, h: int) -> tuple[int, int, int, int]:
|
||||
"""
|
||||
Calcule la boîte de crop 2R×2R clampée aux bords de l'image.
|
||||
|
||||
:return: (x1, y1, x2, y2) en coordonnées image
|
||||
"""
|
||||
r = self._radius
|
||||
x1 = max(cx - r, 0)
|
||||
y1 = max(cy - r, 0)
|
||||
x2 = min(cx + r, w)
|
||||
y2 = min(cy + r, h)
|
||||
return (x1, y1, x2, y2)
|
||||
|
||||
def _get_circle_mask(self, shape: tuple[int, int], cx: int, cy: int) -> np.ndarray:
|
||||
"""
|
||||
Construit (ou récupère du cache) le masque booléen circulaire.
|
||||
|
||||
Le masque est recalculé uniquement si la taille ou le centre change.
|
||||
|
||||
:param shape: (hauteur, largeur) du tableau cible
|
||||
:param cx: Colonne du centre dans ce tableau
|
||||
:param cy: Ligne du centre dans ce tableau
|
||||
:return: Tableau bool shape (H, W) — True = dans le cercle
|
||||
"""
|
||||
cache_key = (shape[0], shape[1], cx, cy, self._radius)
|
||||
|
||||
if self._mask_cache is None or self._mask_shape != cache_key:
|
||||
h, w = shape
|
||||
# Coordonnées entières de chaque pixel via meshgrid
|
||||
ys, xs = np.ogrid[:h, :w]
|
||||
dist_sq = (xs - cx) ** 2 + (ys - cy) ** 2
|
||||
self._mask_cache = dist_sq <= self._radius ** 2
|
||||
self._mask_shape = cache_key
|
||||
logger.debug(
|
||||
"Masque circulaire recalculé : shape=%s centre=(%d,%d) R=%d",
|
||||
shape, cx, cy, self._radius,
|
||||
)
|
||||
|
||||
return self._mask_cache
|
||||
|
||||
def _invalidate_cache(self) -> None:
|
||||
"""Invalide le cache du masque (après changement de rayon ou stratégie)."""
|
||||
self._mask_cache = None
|
||||
self._mask_shape = None
|
||||
@@ -0,0 +1,356 @@
|
||||
'''
|
||||
GCode pour piloter la L2544 Laser Engraving Machine
|
||||
|
||||
GRBLController:
|
||||
Commande uniquement les mouvements (X, Y)
|
||||
Le mode absolue est retenu
|
||||
|
||||
GridScanner
|
||||
Balayage complet de la grille d'éprouvettes en mode serpentin
|
||||
|
||||
Usage:
|
||||
grbl = GRBLController()
|
||||
scan = GridScanner(grbl, xbase=100, ybase=100, duration=5)
|
||||
scan.start()
|
||||
|
||||
Created on 25 mars 2026
|
||||
|
||||
@author: denis@miraceti.net
|
||||
'''
|
||||
import logging
|
||||
import serial
|
||||
import time
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GRBLController:
|
||||
'''
|
||||
Contrôleur pour machine de gravure laser L2544 (GRBL 1.1f)
|
||||
Fonctions de base pour la calibration : déplacement manuel et gestion de la position.
|
||||
'''
|
||||
X_MAX = 350
|
||||
Y_MAX = 250
|
||||
X_MIN = 0
|
||||
Y_MIN = 0
|
||||
|
||||
def __init__(self, port='/dev/ttyUSB0', baudrate=115200, timeout=1, send_callback=None, x_max=None, y_max=None):
|
||||
self.lock = threading.Lock()
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
|
||||
if x_max is not None:
|
||||
self.X_MAX = x_max
|
||||
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.x, self.y = 0, 0
|
||||
self.start_connection()
|
||||
self._wake_up()
|
||||
self._init_machine()
|
||||
|
||||
def wait_for(self, delay=1.0):
|
||||
threading.Event().wait(delay*1.0)
|
||||
|
||||
def _send_msg(self, **msg):
|
||||
print(msg)
|
||||
|
||||
def clear_buffer(self):
|
||||
while self.ser.in_waiting >0:
|
||||
msg = self.ser.readline().decode().strip()
|
||||
print(f"Buffer: {msg}")
|
||||
self._state(state='serial', msg=msg)
|
||||
|
||||
def start_connection(self):
|
||||
n = 0
|
||||
while True:
|
||||
try:
|
||||
self.ser = serial.Serial(self.port, self.baudrate, timeout=self.timeout, exclusive=True)
|
||||
# CRITIQUE :
|
||||
self.ser.setDTR(False)
|
||||
self.ser.setRTS(False)
|
||||
self.clear_buffer()
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Erreur de connexion (essai {n}): {e}")
|
||||
n += 1
|
||||
self.wait_for(1.0)
|
||||
|
||||
def _init_machine(self):
|
||||
self.send("G21") # Unités en mm
|
||||
self.send("G90") # Mode absolu
|
||||
|
||||
def _clamp(self, x, y):
|
||||
self.clear_buffer()
|
||||
x = max(self.X_MIN, min(self.X_MAX, x))
|
||||
y = max(self.Y_MIN, min(self.Y_MAX, y))
|
||||
return x, y
|
||||
|
||||
def _wake_up(self):
|
||||
#with self.lock:
|
||||
self.ser.write(b"\r\n\r\n")
|
||||
self.wait_for(1)
|
||||
self.clear_buffer()
|
||||
|
||||
|
||||
def send(self, cmd, wait_ok=True, timeout=5):
|
||||
try:
|
||||
return self._send(cmd, wait_ok, timeout)
|
||||
except Exception as e:
|
||||
#print("Send error:", e)
|
||||
self._state(state='error', msg=f"Error send {cmd} command: {e}")
|
||||
self.close()
|
||||
self.start_connection()
|
||||
self._wake_up()
|
||||
self._init_machine()
|
||||
'''
|
||||
self.recover()
|
||||
self.reset_grbl()
|
||||
raise'''
|
||||
|
||||
def recover(self):
|
||||
#print("Récupération de GRBL...")
|
||||
self._state(state='recover', msg=f"Erreur, récupération de GRBL...")
|
||||
self.wait_for(1)
|
||||
self._wake_up()
|
||||
|
||||
def _send(self, cmd, wait_ok=True, timeout=5):
|
||||
#print(f">>> {cmd}")
|
||||
self._state(state='send', msg=f">>> {cmd}")
|
||||
self.ser.write((cmd + "\n").encode())
|
||||
|
||||
if not wait_ok:
|
||||
return None
|
||||
|
||||
start = time.time()
|
||||
while True:
|
||||
if time.time() - start > timeout:
|
||||
raise TimeoutError(f"Timeout sur la commande: {cmd}")
|
||||
|
||||
raw = self.ser.readline()
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
line = raw.decode(errors="ignore").strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("<"):
|
||||
continue # Ignorer les messages de status asynchrones
|
||||
if "ok" in line.lower():
|
||||
return line
|
||||
if "error" in line.lower():
|
||||
raise Exception(f"Erreur GRBL: {line}")
|
||||
|
||||
def get_status(self):
|
||||
#with self.lock:
|
||||
self.ser.write(b"?\n")
|
||||
while True:
|
||||
line = self.ser.readline()
|
||||
if not line:
|
||||
continue
|
||||
line = line.decode().strip()
|
||||
if line.startswith("<"):
|
||||
return line
|
||||
|
||||
def reset_grbl(self):
|
||||
self.send("$X") # Réinitialise les alarmes
|
||||
self.wait_idle()
|
||||
self.send("$H") # Homing
|
||||
self.wait_idle()
|
||||
|
||||
def _mpos(self, status):
|
||||
if "MPos" in status:
|
||||
mpos = status.split("MPos:")[1].split("|")[0]
|
||||
x, y, *_ = mpos.split(",")
|
||||
return float(x), float(y)
|
||||
return None, None
|
||||
|
||||
def get_mpos(self):
|
||||
return self._mpos(self.get_status())
|
||||
|
||||
def wait_idle(self, timeout=10):
|
||||
start = time.time()
|
||||
while True:
|
||||
if time.time() - start > timeout:
|
||||
raise TimeoutError("Délai d'attente pour Idle dépassé")
|
||||
status = self.get_status()
|
||||
self.x, self.y = self._mpos(status)
|
||||
self._state(xy=True, x=self.x, y=self.y)
|
||||
if status and "Idle" in status:
|
||||
break
|
||||
self.wait_for(0.1)
|
||||
|
||||
def move_to(self, x, y, feed=1000):
|
||||
x, y = self._clamp(x, y)
|
||||
#cmd = f"G0 X{x:.2f} Y{y:.2f} F{feed}" # feed is not updated in G0 mode
|
||||
cmd = f"G53 G1 X{x:.2f} Y{y:.2f} F{feed}"
|
||||
self.send(cmd)
|
||||
self.wait_idle()
|
||||
|
||||
def move_relative_(self, dx=0, dy=0, feed=1000):
|
||||
self.send("G91") # Mode relatif
|
||||
cmd = f"G0 X{dx} Y{dy} F{feed}"
|
||||
self.send(cmd)
|
||||
self.send("G90") # Retour en mode absolu
|
||||
self.wait_idle()
|
||||
|
||||
def move_relative(self, dx=0, dy=0, feed=1000):
|
||||
x, y = self.get_mpos() # Position actuelle
|
||||
self.move_to(x + dx, y + dy)
|
||||
|
||||
def go_origin(self, feed=1000):
|
||||
self.move_to(0, 0, feed=feed)
|
||||
self.wait_for(2.0)
|
||||
|
||||
def set_position(self, x, y):
|
||||
x, y = self._clamp(x, y)
|
||||
cmd = f"G92 X{x:.2f} Y{y:.2f}"
|
||||
self.send(cmd)
|
||||
self.wait_for(2.0)
|
||||
|
||||
def move_up(self, step=10, feed=1000):
|
||||
self.move_relative(dy=step, feed=feed)
|
||||
|
||||
def move_down(self, step=10, feed=1000):
|
||||
self.move_relative(dy=-step, feed=feed)
|
||||
|
||||
def move_left(self, step=10, feed=1000):
|
||||
self.move_relative(dx=-step, feed=feed)
|
||||
|
||||
def move_right(self, step=10, feed=1000):
|
||||
self.move_relative(dx=step, feed=feed)
|
||||
|
||||
def close(self):
|
||||
self.ser.close()
|
||||
|
||||
|
||||
class GridScanner:
|
||||
|
||||
def __init__(self, grbl, proc=None, **config):
|
||||
'''
|
||||
xbase # Position X de départ (col 0) en mm
|
||||
ybase # Position Y de départ (row 0) en mm
|
||||
cols # Nombre de colonnes
|
||||
rows # Nombre de lignes
|
||||
dx # Pas entre colonnes en mm
|
||||
dy # Pas entre lignes en mm
|
||||
duration # Durée de filmage par éprouvette en secondes
|
||||
feed # Vitesse de déplacement entre éprouvettes (mm/min)
|
||||
'''
|
||||
self.grbl = grbl
|
||||
self.proc = proc
|
||||
|
||||
self.position = config.get('position', 'HG')
|
||||
self.xbase = config.get('xbase', 50)
|
||||
self.ybase = config.get('ybase', 50)
|
||||
self.cols = config.get('cols', 6)
|
||||
self.rows = config.get('rows', 4)
|
||||
self.dx = config.get('dx', 20)
|
||||
self.dy = config.get('dy', 19)
|
||||
self.feed = config.get('feed', 1000)
|
||||
self.duration = config.get('duration', 120) # secondes
|
||||
self.xnext = config.get('xnext', 50)
|
||||
self.ynext = config.get('ynext', 50)
|
||||
|
||||
row_to_char = config.get('row_to_char', 'D,C,B,A')
|
||||
self.row_to_char = row_to_char.split(',')
|
||||
self.stop_playing = None
|
||||
|
||||
|
||||
def halt(self):
|
||||
self.proc.record = False
|
||||
return self.stop_playing.set()
|
||||
|
||||
def _capture(self, uuid: str, duration: float, stop_running: Optional[threading.Event]) -> None:
|
||||
"""
|
||||
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
|
||||
|
||||
start = time.monotonic()
|
||||
while not stop_running.is_set():
|
||||
if time.monotonic() - start > duration:
|
||||
break
|
||||
self.grbl.wait_for(1.0)
|
||||
|
||||
print("# arrêter l'enregistrement")
|
||||
self.proc.record = False
|
||||
self.proc.uuid = None
|
||||
|
||||
def start(self, xnext=None, ynext=None, position=None):
|
||||
"""
|
||||
Balayage complet de la grille d'éprouvettes en mode serpentin.
|
||||
|
||||
Parcours :
|
||||
- Lignes paires (0, 2) : gauche → droite (col 0 → col 5)
|
||||
- Lignes impaires (1, 3) : droite → gauche (col 5 → col 0)
|
||||
|
||||
Le déplacement entre éprouvettes se fait en mode absolu via move_to().
|
||||
La caméra filme pendant `` secondes sur chaque position.
|
||||
|
||||
Grille : 6 colonnes × 4 lignes = 24 éprouvettes
|
||||
- x = XBASE + col * PAS_X
|
||||
- y = YBASE + row * PAS_Y
|
||||
"""
|
||||
try:
|
||||
if xnext is None:
|
||||
xnext = self.xnext
|
||||
if ynext is None:
|
||||
ynext = self.ynext
|
||||
if position is None:
|
||||
position = self.position
|
||||
|
||||
max_cells = self.cols * self.rows
|
||||
cell = 0
|
||||
|
||||
logger.info("Début du scan serpentin : %d éprouvettes, %d s/éprouvette, durée totale estimée : %d min",
|
||||
max_cells,
|
||||
self.duration,
|
||||
(max_cells * self.duration) // 60,
|
||||
)
|
||||
|
||||
self.stop_playing = threading.Event()
|
||||
for row in range(self.rows):
|
||||
if self.stop_playing.is_set():
|
||||
break
|
||||
|
||||
# Ordre des colonnes selon la parité de la ligne (serpentin)
|
||||
if row % 2 == 0:
|
||||
# Ligne paire : gauche → droite
|
||||
cols = range(self.cols)
|
||||
else:
|
||||
# Ligne impaire : droite → gauche
|
||||
cols = range(self.cols - 1, -1, -1)
|
||||
|
||||
for col in cols:
|
||||
if self.stop_playing.is_set():
|
||||
break
|
||||
# Calcul de la position absolue en mm
|
||||
x = self.xbase + col * self.dx
|
||||
y = self.ybase + row * self.dy
|
||||
cell += 1
|
||||
logger.info(
|
||||
"[%02d/%02d] row=%d col=%d → X=%.1f mm Y=%.1f mm",
|
||||
cell, max_cells, row, col, x, y,
|
||||
)
|
||||
self.grbl.move_to(x, y, feed=self.feed)
|
||||
|
||||
uuid = f'{self.proc.session}-{position}-{self.row_to_char[row]}{col+1}'
|
||||
self._capture(uuid, self.duration, self.stop_playing)
|
||||
|
||||
# Retour à nexr après le scan
|
||||
logger.info("Scan terminé — retour à l'origine (X=%.1f Y=%.1f)", xnext, ynext)
|
||||
self.grbl.move_to(xnext, ynext, feed=self.feed*2)
|
||||
except Exception as e:
|
||||
logger.error(f"scan error: {e}")
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
Implémentation de la capture vidéo pour Raspberry Pi via PiCamera2.
|
||||
Dépendance : picamera2 (sudo apt install python3-picamera2)
|
||||
Compatible Pi 4 / Pi 5 avec le module caméra officiel (v2, v3, HQ).
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from .capture_interface import CaptureError, VideoCaptureInterface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PiCamera2Capture(VideoCaptureInterface):
|
||||
"""
|
||||
Capture JPEG depuis la caméra officielle Raspberry Pi via PiCamera2.
|
||||
|
||||
Utilise le pipeline libcamera pour la capture basse latence.
|
||||
Supporte les modules Camera Module v1, v2, v3 et HQ Camera.
|
||||
|
||||
La résolution demandée (width, height) est approchée au mieux :
|
||||
libcamera sélectionne automatiquement le mode sensor dont la résolution
|
||||
native est la plus proche, puis redimensionne en ISP.
|
||||
Utiliser list_sensor_modes() pour connaître les modes disponibles.
|
||||
|
||||
Exemple d'utilisation ::
|
||||
|
||||
# Afficher les modes disponibles avant d'instancier
|
||||
PiCamera2Capture.list_sensor_modes()
|
||||
|
||||
cam = PiCamera2Capture(fps=5, width=1280, height=720)
|
||||
cam.set_frame_callback(lambda data, ts: print(f"{ts}: {len(data)} octets"))
|
||||
cam.start()
|
||||
time.sleep(10)
|
||||
cam.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fps: float = VideoCaptureInterface.DEFAULT_FPS,
|
||||
width: int = 1280,
|
||||
height: int = 720,
|
||||
jpeg_quality: int = 85,
|
||||
camera_index: int = 0,
|
||||
use_video_config: bool = True,
|
||||
):
|
||||
"""
|
||||
:param fps: Cadence cible en images par seconde
|
||||
:param width: Largeur souhaitée en pixels (approchée au mode sensor le plus proche)
|
||||
:param height: Hauteur souhaitée en pixels (approchée au mode sensor le plus proche)
|
||||
:param jpeg_quality: Qualité de compression JPEG [0-100]
|
||||
:param camera_index: Index de la caméra (0 par défaut, utile sur Pi 5 dual-cam)
|
||||
:param use_video_config: True = VideoConfiguration (flux continu, basse latence)
|
||||
False = StillConfiguration (haute résolution, plus lent)
|
||||
"""
|
||||
super().__init__(fps=fps)
|
||||
self._width: int = width
|
||||
self._height: int = height
|
||||
self._jpeg_quality: int = jpeg_quality
|
||||
self._camera_index: int = camera_index
|
||||
self._use_video_config: bool = use_video_config
|
||||
self._picam2 = None # Instance Picamera2
|
||||
self._effective_size: Optional[tuple[int, int]] = None # Résolution réellement appliquée
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Méthode statique utilitaire — à appeler avant d'instancier
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def list_sensor_modes(camera_index: int = 0) -> list[dict]:
|
||||
"""
|
||||
Affiche et retourne tous les modes sensor disponibles pour la caméra.
|
||||
|
||||
À appeler avant d'instancier PiCamera2Capture pour choisir
|
||||
une résolution compatible avec un mode sensor natif.
|
||||
|
||||
:param camera_index: Index de la caméra à interroger
|
||||
:return: Liste de dicts décrivant chaque mode sensor
|
||||
:raises CaptureError: Si picamera2 n'est pas disponible
|
||||
"""
|
||||
try:
|
||||
from picamera2 import Picamera2
|
||||
except ImportError as exc:
|
||||
raise CaptureError(
|
||||
"picamera2 introuvable — installez-le avec : "
|
||||
"sudo apt install python3-picamera2"
|
||||
) from exc
|
||||
|
||||
picam2 = Picamera2(camera_index)
|
||||
try:
|
||||
# sensor_modes doit être interrogé avant configure()
|
||||
modes = picam2.sensor_modes
|
||||
print(f"\n=== Modes sensor disponibles (caméra index={camera_index}) ===")
|
||||
for i, mode in enumerate(modes):
|
||||
size = mode.get("size", "?")
|
||||
fps = mode.get("fps", "?")
|
||||
crop = mode.get("crop_limits", "?")
|
||||
fmt = mode.get("format", "?")
|
||||
print(
|
||||
f" [{i}] {size[0]}×{size[1]}px "
|
||||
f"fps_max={fps:.1f} format={fmt} crop={crop}"
|
||||
)
|
||||
print()
|
||||
return modes
|
||||
finally:
|
||||
picam2.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Implémentation des méthodes abstraites
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def open(self) -> None:
|
||||
"""
|
||||
Initialise PiCamera2, sélectionne le mode sensor le plus adapté
|
||||
à la résolution demandée, configure le flux et démarre libcamera.
|
||||
|
||||
Le mode sensor est choisi en minimisant la distance euclidienne
|
||||
entre la résolution native du mode et (width, height) demandés.
|
||||
"""
|
||||
try:
|
||||
from picamera2 import Picamera2
|
||||
except ImportError as exc:
|
||||
raise CaptureError(
|
||||
"picamera2 introuvable — installez-le avec : "
|
||||
"sudo apt install python3-picamera2"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
self._picam2 = Picamera2(self._camera_index)
|
||||
|
||||
# Sélection du mode sensor le plus proche de la résolution demandée
|
||||
best_mode = self._select_best_sensor_mode(self._picam2)
|
||||
|
||||
# Construction de la configuration avec le mode sensor forcé
|
||||
if self._use_video_config:
|
||||
config = self._picam2.create_video_configuration(
|
||||
main={"size": (self._width, self._height), "format": "RGB888"},
|
||||
raw=best_mode, # Force le mode sensor natif
|
||||
)
|
||||
logger.debug("Configuration VideoConfiguration sélectionnée")
|
||||
else:
|
||||
config = self._picam2.create_still_configuration(
|
||||
main={"size": (self._width, self._height), "format": "RGB888"},
|
||||
raw=best_mode,
|
||||
)
|
||||
logger.debug("Configuration StillConfiguration sélectionnée")
|
||||
|
||||
self._picam2.configure(config)
|
||||
self._picam2.start()
|
||||
|
||||
# Lecture de la résolution effectivement appliquée par l'ISP
|
||||
actual = config["main"]["size"]
|
||||
self._effective_size = actual
|
||||
|
||||
logger.info(
|
||||
"PiCamera2 ouverte : index=%d demandé=%dx%d effectif=%dx%d "
|
||||
"mode_sensor=%dx%d mode=%s",
|
||||
self._camera_index,
|
||||
self._width, self._height,
|
||||
actual[0], actual[1],
|
||||
best_mode["size"][0], best_mode["size"][1],
|
||||
"video" if self._use_video_config else "still",
|
||||
)
|
||||
|
||||
# Avertissement si la résolution effective diffère de la demande
|
||||
if actual != (self._width, self._height):
|
||||
logger.warning(
|
||||
"Résolution ajustée par libcamera : %dx%d → %dx%d. "
|
||||
"Utilisez list_sensor_modes() pour connaître les tailles compatibles.",
|
||||
self._width, self._height, actual[0], actual[1],
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
if self._picam2 is not None:
|
||||
try:
|
||||
self._picam2.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._picam2 = None
|
||||
raise CaptureError(f"Impossible d'ouvrir PiCamera2 : {exc}") from exc
|
||||
|
||||
def close(self) -> None:
|
||||
"""Arrête le pipeline libcamera et libère les ressources."""
|
||||
if self._picam2 is not None:
|
||||
try:
|
||||
self._picam2.stop()
|
||||
self._picam2.close()
|
||||
logger.info("PiCamera2 fermée (index=%d)", self._camera_index)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Erreur lors de la fermeture de PiCamera2 : %s", exc)
|
||||
finally:
|
||||
self._picam2 = None
|
||||
self._effective_size = None
|
||||
|
||||
def capture_frame(self) -> bytes:
|
||||
"""
|
||||
Capture une image depuis le flux libcamera et l'encode en JPEG.
|
||||
|
||||
Stratégie : capture_array() → tableau NumPy RGB → encodage Pillow.
|
||||
capture_file() ne supporte pas le paramètre quality ; on encode
|
||||
manuellement pour contrôler le taux de compression.
|
||||
|
||||
:return: Données JPEG brutes
|
||||
:raises CaptureError: Si la capture ou l'encodage échoue
|
||||
"""
|
||||
if self._picam2 is None:
|
||||
raise CaptureError("PiCamera2 n'est pas initialisée")
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
# Récupération du tableau RGB depuis le flux libcamera
|
||||
arr = self._picam2.capture_array("main") # shape (H, W, 3) uint8
|
||||
|
||||
# Encodage manuel en JPEG avec la qualité configurée
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(arr).save(buf, format="JPEG", quality=self._jpeg_quality)
|
||||
return buf.getvalue()
|
||||
|
||||
except Exception as exc:
|
||||
raise CaptureError(f"Échec de capture PiCamera2 : {exc}") from exc
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Retourne True si le pipeline libcamera est démarré."""
|
||||
return self._picam2 is not None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Méthodes spécifiques à PiCamera2
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def capture_high_res(self, width: int, height: int) -> bytes:
|
||||
"""
|
||||
Capture une image haute résolution hors flux principal (photo ponctuelle).
|
||||
|
||||
Utile pour déclencher une capture pleine résolution pendant un flux 5 fps.
|
||||
Utilise capture_request() + make_array() + encodage Pillow.
|
||||
|
||||
:param width: Largeur souhaitée en pixels (indicatif, dépend de la config active)
|
||||
:param height: Hauteur souhaitée en pixels
|
||||
:return: Données JPEG brutes
|
||||
:raises CaptureError: Si PiCamera2 n'est pas initialisée
|
||||
"""
|
||||
if self._picam2 is None:
|
||||
raise CaptureError("PiCamera2 n'est pas initialisée")
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
# Capture d'une requête unique depuis le flux actif
|
||||
request = self._picam2.capture_request()
|
||||
arr = request.make_array("main") # shape (H, W, 3) uint8 RGB
|
||||
request.release()
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(arr).save(buf, format="JPEG", quality=self._jpeg_quality)
|
||||
return buf.getvalue()
|
||||
|
||||
except Exception as exc:
|
||||
raise CaptureError(f"Échec de capture haute résolution : {exc}") from exc
|
||||
|
||||
def set_controls(self, **kwargs) -> None:
|
||||
"""
|
||||
Applique des contrôles libcamera directement (exposition, gain, balance des blancs…).
|
||||
|
||||
Exemple ::
|
||||
|
||||
cam.set_controls(ExposureTime=10000, AnalogueGain=2.0)
|
||||
|
||||
:param kwargs: Contrôles libcamera valides pour le module connecté
|
||||
"""
|
||||
if self._picam2 is None:
|
||||
raise CaptureError("PiCamera2 n'est pas initialisée")
|
||||
self._picam2.set_controls(kwargs)
|
||||
logger.debug("Contrôles appliqués : %s", kwargs)
|
||||
|
||||
def get_camera_properties(self) -> dict:
|
||||
"""
|
||||
Retourne les métadonnées du module caméra détecté.
|
||||
|
||||
:return: Dictionnaire des propriétés (modèle, résolution max, etc.)
|
||||
"""
|
||||
if self._picam2 is None:
|
||||
raise CaptureError("PiCamera2 n'est pas initialisée")
|
||||
return self._picam2.camera_properties
|
||||
|
||||
@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
|
||||
|
||||
@property
|
||||
def resolution(self) -> tuple[int, int]:
|
||||
"""Résolution de capture demandée (largeur, hauteur)."""
|
||||
return (self._width, self._height)
|
||||
|
||||
@property
|
||||
def effective_resolution(self) -> Optional[tuple[int, int]]:
|
||||
"""
|
||||
Résolution effectivement appliquée par l'ISP après ouverture.
|
||||
None si la caméra n'est pas encore ouverte.
|
||||
"""
|
||||
return self._effective_size
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers privés
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _select_best_sensor_mode(self, picam2) -> dict:
|
||||
"""
|
||||
Choisit le mode sensor dont la résolution native est la plus proche
|
||||
de (width, height) en minimisant la distance euclidienne.
|
||||
|
||||
:param picam2: Instance Picamera2 déjà créée mais pas encore configurée
|
||||
:return: Dict du mode sensor sélectionné
|
||||
"""
|
||||
modes = picam2.sensor_modes
|
||||
if not modes:
|
||||
raise CaptureError("Aucun mode sensor disponible")
|
||||
|
||||
def distance(mode: dict) -> float:
|
||||
mw, mh = mode["size"]
|
||||
# Distance euclidienne normalisée entre la résolution du mode et la cible
|
||||
return ((mw - self._width) ** 2 + (mh - self._height) ** 2) ** 0.5
|
||||
|
||||
best = min(modes, key=distance)
|
||||
logger.debug(
|
||||
"Mode sensor sélectionné : %dx%d (demandé : %dx%d)",
|
||||
best["size"][0], best["size"][1],
|
||||
self._width, self._height,
|
||||
)
|
||||
return best
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Implémentation de la capture vidéo pour Raspberry Pi via PiCamera2.
|
||||
Dépendance : picamera2 (sudo apt install python3-picamera2)
|
||||
Compatible Pi 4 / Pi 5 avec le module caméra officiel.
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from .capture_interface import CaptureError, VideoCaptureInterface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PiCamera2Capture(VideoCaptureInterface):
|
||||
"""
|
||||
Capture JPEG depuis la caméra officielle Raspberry Pi via PiCamera2.
|
||||
|
||||
Utilise le pipeline libcamera pour la capture basse latence.
|
||||
Supporte les modules Camera Module v1, v2, v3 et HQ Camera.
|
||||
|
||||
Exemple d'utilisation ::
|
||||
|
||||
cam = PiCamera2Capture(fps=5, width=1280, height=720)
|
||||
cam.set_frame_callback(lambda data, ts: print(f"{ts}: {len(data)} octets"))
|
||||
cam.start()
|
||||
time.sleep(10)
|
||||
cam.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fps: float = VideoCaptureInterface.DEFAULT_FPS,
|
||||
width: int = 1280,
|
||||
height: int = 720,
|
||||
jpeg_quality: int = 85,
|
||||
camera_index: int = 0,
|
||||
use_video_config: bool = True,
|
||||
):
|
||||
"""
|
||||
:param fps: Cadence cible en images par seconde
|
||||
:param width: Largeur du flux de capture en pixels
|
||||
:param height: Hauteur du flux de capture en pixels
|
||||
:param jpeg_quality: Qualité de compression JPEG [0-100]
|
||||
:param camera_index: Index de la caméra (0 par défaut, utile sur Pi 5 dual-cam)
|
||||
:param use_video_config: True = configuration VideoConfiguration (flux continu)
|
||||
False = StillConfiguration (haute résolution, plus lent)
|
||||
"""
|
||||
super().__init__(fps=fps)
|
||||
self._width: int = width
|
||||
self._height: int = height
|
||||
self._jpeg_quality: int = jpeg_quality
|
||||
self._camera_index: int = camera_index
|
||||
self._use_video_config: bool = use_video_config
|
||||
self._picam2 = None # Instance Picamera2
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Implémentation des méthodes abstraites
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def open(self) -> None:
|
||||
"""
|
||||
Initialise PiCamera2, configure le flux et démarre le pipeline libcamera.
|
||||
"""
|
||||
try:
|
||||
from picamera2 import Picamera2 # Import local : disponible uniquement sur Pi
|
||||
except ImportError as exc:
|
||||
raise CaptureError(
|
||||
"picamera2 introuvable — installez-le avec : "
|
||||
"sudo apt install python3-picamera2"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
self._picam2 = Picamera2(self._camera_index)
|
||||
|
||||
# Choix de la configuration selon le mode sélectionné
|
||||
if self._use_video_config:
|
||||
config = self._picam2.create_video_configuration(
|
||||
main={"size": (self._width, self._height), "format": "RGB888"},
|
||||
)
|
||||
logger.debug("Configuration VideoConfiguration sélectionnée")
|
||||
else:
|
||||
config = self._picam2.create_still_configuration(
|
||||
main={"size": (self._width, self._height), "format": "RGB888"},
|
||||
)
|
||||
logger.debug("Configuration StillConfiguration sélectionnée")
|
||||
|
||||
self._picam2.configure(config)
|
||||
self._picam2.start()
|
||||
|
||||
logger.info(
|
||||
"PiCamera2 ouverte : index=%d résolution=%dx%d mode=%s",
|
||||
self._camera_index,
|
||||
self._width,
|
||||
self._height,
|
||||
"video" if self._use_video_config else "still",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
# Nettoyage en cas d'échec partiel d'initialisation
|
||||
if self._picam2 is not None:
|
||||
try:
|
||||
self._picam2.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._picam2 = None
|
||||
raise CaptureError(f"Impossible d'ouvrir PiCamera2 : {exc}") from exc
|
||||
|
||||
def close(self) -> None:
|
||||
"""Arrête le pipeline libcamera et libère les ressources."""
|
||||
if self._picam2 is not None:
|
||||
try:
|
||||
self._picam2.stop()
|
||||
self._picam2.close()
|
||||
logger.info("PiCamera2 fermée (index=%d)", self._camera_index)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Erreur lors de la fermeture de PiCamera2 : %s", exc)
|
||||
finally:
|
||||
self._picam2 = None
|
||||
|
||||
def capture_frame(self) -> bytes:
|
||||
"""
|
||||
Capture une image depuis le flux libcamera et l'encode en JPEG.
|
||||
|
||||
Stratégie : capture_array() → tableau NumPy RGB → encodage Pillow.
|
||||
capture_file() ne supporte pas le paramètre quality ; on encode
|
||||
manuellement pour contrôler le taux de compression.
|
||||
|
||||
:return: Données JPEG brutes
|
||||
:raises CaptureError: Si la capture ou l'encodage échoue
|
||||
"""
|
||||
if self._picam2 is None:
|
||||
raise CaptureError("PiCamera2 n'est pas initialisée")
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
# Récupération du tableau RGB depuis le flux libcamera
|
||||
arr = self._picam2.capture_array("main") # shape (H, W, 3) uint8
|
||||
|
||||
# Encodage manuel en JPEG avec la qualité configurée
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(arr).save(buf, format="JPEG", quality=self._jpeg_quality)
|
||||
return buf.getvalue()
|
||||
|
||||
except Exception as exc:
|
||||
raise CaptureError(f"Échec de capture PiCamera2 : {exc}") from exc
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Retourne True si le pipeline libcamera est démarré."""
|
||||
return self._picam2 is not None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Méthodes spécifiques à PiCamera2
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def capture_high_res(self, width: int, height: int) -> bytes:
|
||||
"""
|
||||
Capture une image haute résolution hors flux principal (photo ponctuelle).
|
||||
|
||||
Utile pour déclencher une capture pleine résolution pendant un flux 5 fps.
|
||||
Utilise capture_request() + make_array() + encodage Pillow.
|
||||
|
||||
:param width: Largeur souhaitée en pixels (indicatif, dépend de la config active)
|
||||
:param height: Hauteur souhaitée en pixels
|
||||
:return: Données JPEG brutes
|
||||
:raises CaptureError: Si PiCamera2 n'est pas initialisée
|
||||
"""
|
||||
if self._picam2 is None:
|
||||
raise CaptureError("PiCamera2 n'est pas initialisée")
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
# Capture d'une requête unique depuis le flux actif
|
||||
request = self._picam2.capture_request()
|
||||
arr = request.make_array("main") # shape (H, W, 3) uint8 RGB
|
||||
request.release()
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(arr).save(buf, format="JPEG", quality=self._jpeg_quality)
|
||||
return buf.getvalue()
|
||||
|
||||
except Exception as exc:
|
||||
raise CaptureError(f"Échec de capture haute résolution : {exc}") from exc
|
||||
|
||||
def set_controls(self, **kwargs) -> None:
|
||||
"""
|
||||
Applique des contrôles libcamera directement (exposition, gain, balance des blancs…).
|
||||
|
||||
Exemple ::
|
||||
|
||||
cam.set_controls(ExposureTime=10000, AnalogueGain=2.0)
|
||||
|
||||
:param kwargs: Contrôles libcamera valides pour le module connecté
|
||||
"""
|
||||
if self._picam2 is None:
|
||||
raise CaptureError("PiCamera2 n'est pas initialisée")
|
||||
self._picam2.set_controls(kwargs)
|
||||
logger.debug("Contrôles appliqués : %s", kwargs)
|
||||
|
||||
def get_camera_properties(self) -> dict:
|
||||
"""
|
||||
Retourne les métadonnées du module caméra détecté.
|
||||
|
||||
:return: Dictionnaire des propriétés (modèle, résolution max, etc.)
|
||||
"""
|
||||
if self._picam2 is None:
|
||||
raise CaptureError("PiCamera2 n'est pas initialisée")
|
||||
return self._picam2.camera_properties
|
||||
|
||||
@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
|
||||
|
||||
@property
|
||||
def resolution(self) -> tuple[int, int]:
|
||||
"""Résolution de capture configurée (largeur, hauteur)."""
|
||||
return (self._width, self._height)
|
||||
@@ -0,0 +1,122 @@
|
||||
'''
|
||||
Created on 21 août 2024
|
||||
|
||||
@author: denis
|
||||
'''
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Union
|
||||
from abc import ABC
|
||||
from reduct import Client, Bucket, BucketSettings #, QuotaType
|
||||
from reduct.time import unix_timestamp_from_any, TIME_PRECISION #, unix_timestamp_to_datetime
|
||||
from datetime import datetime, timedelta
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ReductStoreBase(ABC):
|
||||
|
||||
def __init__(self, url, api_token, name, quota_type=None, quota_size=1000_000_000):
|
||||
self.bucket_name = name
|
||||
self.client = Client(url, api_token=api_token)
|
||||
self.quota_type = quota_type
|
||||
self.quota_size = quota_size
|
||||
self.bucket: Bucket = asyncio.run(self.create_bucket())
|
||||
logger.info(f"==== {url} token:{api_token}")
|
||||
|
||||
|
||||
async def create_bucket(self):
|
||||
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)
|
||||
|
||||
|
||||
async def change_bucket(self, **settings):
|
||||
new_settings = BucketSettings(**settings)
|
||||
await self.bucket.set_settings(new_settings)
|
||||
|
||||
|
||||
async def remove_bucket(self):
|
||||
await self.bucket.remove()
|
||||
|
||||
|
||||
async def write(self, entry_name, data, timestamp=None, content_type=None, labels=None):
|
||||
await self.bucket.write(entry_name, data, timestamp=timestamp, content_type=content_type, labels=labels)
|
||||
|
||||
|
||||
def query(self, entry_name, start, stop, ttl=None, when=None):
|
||||
return self.bucket.query(entry_name, start=start, stop=stop, ttl=ttl, when=when)
|
||||
|
||||
|
||||
async def remove_query(self, entry_name, start, stop, when=None):
|
||||
return await self.bucket.remove_query(entry_name, start=start, stop=stop, when=when)
|
||||
|
||||
|
||||
async def read(self, entry_name, timestamp, head=False):
|
||||
async with self.bucket.read(entry_name, timestamp=timestamp, head=head) as record:
|
||||
return await record.read_all()
|
||||
|
||||
|
||||
async def record_content(self, entry_name, timestamp, head=False):
|
||||
async with self.bucket.read(entry_name, timestamp=timestamp, head=head) as record:
|
||||
content = await record.read_all()
|
||||
return record, content
|
||||
|
||||
|
||||
class ReductStore(ReductStoreBase):
|
||||
def __init__(self, name):
|
||||
super().__init__(settings.REDUCTSTORE_URL, settings.REDUCTSTORE_TOKEN, name=name)
|
||||
|
||||
|
||||
async def old_last_dates(client_db, entry_name='uuid'):
|
||||
oldest, latest = 0, 0
|
||||
infos = await client_db.bucket.get_entry_list()
|
||||
for info in infos:
|
||||
if info.name == entry_name:
|
||||
oldest, latest = info.oldest_record, info.latest_record
|
||||
break
|
||||
return oldest, latest
|
||||
|
||||
|
||||
async def date_posterior_to(client_db, uuid: str, post: Union[int, str]):
|
||||
last = None
|
||||
oldest, latest = await old_last_dates(client_db, uuid)
|
||||
if oldest and post:
|
||||
dtpost = timezone.now() - timedelta(seconds=int(post))
|
||||
ts = unix_timestamp_from_any(dtpost)
|
||||
last = ts if ts < latest else None
|
||||
return oldest, last
|
||||
|
||||
|
||||
async def dates_filter(client_db, uuid: str, begin: Union[int, datetime, float, str],
|
||||
end: Union[int, datetime, float, str] = None, duration: int = 0):
|
||||
oldest, latest = await old_last_dates(client_db, uuid)
|
||||
|
||||
ts_from, ts_to = 0, 0
|
||||
if latest:
|
||||
ts_from = unix_timestamp_from_any(begin) if begin else oldest
|
||||
if ts_from < oldest:
|
||||
ts_from = oldest
|
||||
|
||||
if end is None:
|
||||
if not duration:
|
||||
ts_to = latest
|
||||
else:
|
||||
ts_to = ts_from + (duration * TIME_PRECISION)
|
||||
if ts_to > latest:
|
||||
ts_to = latest
|
||||
else:
|
||||
ts_to = unix_timestamp_from_any(end) if end else latest
|
||||
if ts_to > latest:
|
||||
ts_to = latest
|
||||
|
||||
return ts_from, ts_to if ts_from<ts_to else latest
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'''
|
||||
Created on 3 févr. 2026
|
||||
|
||||
@author: denis
|
||||
'''
|
||||
from django.conf import settings
|
||||
|
||||
# myapp/system_stats.py
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
import psutil
|
||||
|
||||
# intervale de mise à jour (secondes)
|
||||
REFRESH_INTERVAL = 5
|
||||
RAMDISK = "/mnt/ramdisk"
|
||||
|
||||
_cache = {
|
||||
"shm": [],
|
||||
"cpu_info": {},
|
||||
"memory_info": {},
|
||||
"disk_info": {},
|
||||
"ramdisk_info": {},
|
||||
"updated_at": None
|
||||
}
|
||||
|
||||
_lock = threading.Lock()
|
||||
_timer = None
|
||||
|
||||
|
||||
def _collect_once():
|
||||
data = {}
|
||||
|
||||
# shm: liste /dev/shm si disponible
|
||||
try:
|
||||
path = "/dev/shm"
|
||||
data["shm"] = os.listdir(path) if os.path.exists(path) and os.path.isdir(path) else []
|
||||
except Exception as e:
|
||||
data["shm_error"] = str(e)
|
||||
|
||||
|
||||
# cpu_info
|
||||
try:
|
||||
cpu_times = psutil.cpu_times_percent(interval=None, percpu=False)._asdict()
|
||||
data["cpu_info"] = {
|
||||
"cpu_count": psutil.cpu_count(logical=True),
|
||||
"cpu_count_physical": psutil.cpu_count(logical=False),
|
||||
"cpu_percent": psutil.cpu_percent(interval=None),
|
||||
"cpu_times_percent": cpu_times
|
||||
}
|
||||
except Exception as e:
|
||||
data["cpu_info_error"] = str(e)
|
||||
|
||||
|
||||
# memory_info
|
||||
try:
|
||||
vm = psutil.virtual_memory()._asdict()
|
||||
sm = psutil.swap_memory()._asdict()
|
||||
data["memory_info"] = {"virtual_memory": vm, "swap_memory": sm}
|
||||
except Exception as e:
|
||||
data["memory_info_error"] = str(e)
|
||||
|
||||
|
||||
# disk_info (root and partitions)
|
||||
# ex: if mountpoint == "/ramdisk" and fstype=="tmpfs" then usage.percent, usage.free, etc ...
|
||||
try:
|
||||
usage_root = psutil.disk_usage("/")._asdict()
|
||||
parts = []
|
||||
for p in psutil.disk_partitions(all=False):
|
||||
try:
|
||||
du = psutil.disk_usage(p.mountpoint)._asdict()
|
||||
except Exception:
|
||||
du = {}
|
||||
parts.append({"device": p.device, "mountpoint": p.mountpoint, "fstype": p.fstype, "usage": du})
|
||||
data["disk_info"] = {"root": usage_root, "partitions": parts}
|
||||
except Exception as e:
|
||||
data["disk_info_error"] = str(e)
|
||||
|
||||
# ramdisk
|
||||
# ex: if mountpoint == "/ramdisk" and fstype=="tmpfs" then usage.percent, usage.free, etc ...
|
||||
try:
|
||||
for part in psutil.disk_partitions(all=True):
|
||||
if part.mountpoint == RAMDISK and part.fstype.lower() == "tmpfs":
|
||||
usage = psutil.disk_usage(part.mountpoint)
|
||||
data["ramdisk_info"] = {
|
||||
"percent": usage.percent,
|
||||
"mount": part.mountpoint,
|
||||
"device": part.device,
|
||||
"fstype": part.fstype,
|
||||
"total": usage.total,
|
||||
"used": usage.used,
|
||||
"free": usage.free,
|
||||
}
|
||||
except Exception as e:
|
||||
data["ramdisk_info_error"] = str(e)
|
||||
|
||||
data["updated_at"] = time.time()
|
||||
return data
|
||||
|
||||
|
||||
def _update_cache():
|
||||
global _timer
|
||||
try:
|
||||
new = _collect_once()
|
||||
with _lock:
|
||||
_cache.update(new)
|
||||
finally:
|
||||
# reprogrammer
|
||||
_timer = threading.Timer(REFRESH_INTERVAL, _update_cache)
|
||||
_timer.daemon = True
|
||||
_timer.start()
|
||||
|
||||
|
||||
def start_background_updater(interval_seconds: int = None):
|
||||
global REFRESH_INTERVAL, _timer
|
||||
if interval_seconds:
|
||||
REFRESH_INTERVAL = interval_seconds
|
||||
if _timer is not None:
|
||||
return
|
||||
# première collecte synchronisée
|
||||
with _lock:
|
||||
_cache.update(_collect_once())
|
||||
_timer = threading.Timer(REFRESH_INTERVAL, _update_cache)
|
||||
_timer.daemon = True
|
||||
_timer.start()
|
||||
|
||||
|
||||
def stop_background_updater():
|
||||
global _timer
|
||||
if _timer is not None:
|
||||
_timer.cancel()
|
||||
_timer = None
|
||||
|
||||
|
||||
def get_cached_stats():
|
||||
with _lock:
|
||||
# retourner copie pour sécurité
|
||||
return dict(_cache)
|
||||
@@ -0,0 +1,261 @@
|
||||
'''
|
||||
Created on 20 avr. 2022
|
||||
|
||||
@author: denis
|
||||
'''
|
||||
import yaml
|
||||
import time
|
||||
import importlib
|
||||
from datetime import datetime
|
||||
import string, secrets
|
||||
import uuid
|
||||
from threading import Event, Thread
|
||||
from urllib.parse import urlsplit
|
||||
import asyncio
|
||||
# sysutils.py
|
||||
import os
|
||||
import mmap
|
||||
import fcntl
|
||||
import psutil
|
||||
|
||||
|
||||
SHM_DIR = "/dev/shm"
|
||||
|
||||
|
||||
def open_shm(name: str, size: int, create=True):
|
||||
path = os.path.join(SHM_DIR, name)
|
||||
flags = os.O_RDWR | (os.O_CREAT if create else 0)
|
||||
fd = os.open(path, flags)
|
||||
try:
|
||||
if create:
|
||||
os.ftruncate(fd, size)
|
||||
mm = mmap.mmap(fd, size)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return mm, path
|
||||
|
||||
|
||||
def read_shm(name: str, size: int):
|
||||
mm, path = open_shm(name, size, create=False) # @UnusedVariable
|
||||
try:
|
||||
mm.seek(0)
|
||||
data = mm.read(size)
|
||||
return data.rstrip(b"\0")
|
||||
finally:
|
||||
mm.close()
|
||||
|
||||
|
||||
def write_shm(name: str, size: int, data: bytes):
|
||||
mm, path = open_shm(name, size, create=True)
|
||||
fd = os.open(path, os.O_RDWR)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
try:
|
||||
mm.seek(0)
|
||||
mm.write(data.ljust(size, b"\0")[:size])
|
||||
mm.flush()
|
||||
finally:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
mm.close()
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def get_tmpfs_info(mount_point="/ramdisk"):
|
||||
def sizeof(n):
|
||||
for unit in ['B','KB','MB','GB','TB']:
|
||||
if n < 1024:
|
||||
return f"{n:.1f}{unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f}PB"
|
||||
|
||||
usage = 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)
|
||||
print(f"Mount: {part.mountpoint}")
|
||||
print(f" Device: {part.device}")
|
||||
print(f" Fstype: {part.fstype}")
|
||||
print(f" Total: {usage.total} bytes ({sizeof(usage.total)})")
|
||||
print(f" Used: {usage.used} bytes ({sizeof(usage.used)})")
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def get_cpu_info():
|
||||
# cpu percent par coeur et moyennes load
|
||||
return {
|
||||
"cpu_percent_per_cpu": psutil.cpu_percent(interval=0.5, percpu=True),
|
||||
"cpu_percent_total": psutil.cpu_percent(interval=None),
|
||||
"load_avg": os.getloadavg(), # (1,5,15)
|
||||
"cpu_count": psutil.cpu_count(logical=True),
|
||||
}
|
||||
|
||||
|
||||
def get_memory_info():
|
||||
vm = psutil.virtual_memory()
|
||||
sm = psutil.swap_memory()
|
||||
return {
|
||||
"total": vm.total,
|
||||
"available": vm.available,
|
||||
"used": vm.used,
|
||||
"free": vm.free,
|
||||
"percent": vm.percent,
|
||||
"swap_total": sm.total,
|
||||
"swap_used": sm.used,
|
||||
"swap_free": sm.free,
|
||||
"swap_percent": sm.percent,
|
||||
}
|
||||
|
||||
|
||||
def get_disk_info(path="/"):
|
||||
du = psutil.disk_usage(path)
|
||||
return {
|
||||
"path": path,
|
||||
"total": du.total,
|
||||
"used": du.used,
|
||||
"free": du.free,
|
||||
"percent": du.percent,
|
||||
}
|
||||
|
||||
|
||||
def extract_host_port_path(url, default_port=None):
|
||||
"""
|
||||
Retoure (host, port, path) où:
|
||||
- host: string (IP ou hostname) ou None
|
||||
- port: int ou None (utilise default_port si fourni et aucun port explicite)
|
||||
- path: string (chemin + query + fragment si présents), ou '' si absent
|
||||
"""
|
||||
parts = urlsplit(url if '://' in url else '//' + url, scheme='')
|
||||
host = parts.hostname
|
||||
port = parts.port or default_port
|
||||
# Reconstruire path complet: path + ('?' + query) + ('#' + fragment)
|
||||
path = parts.path or ''
|
||||
if parts.query:
|
||||
path += '?' + parts.query
|
||||
if parts.fragment:
|
||||
path += '#' + parts.fragment
|
||||
return host, port, path
|
||||
|
||||
|
||||
def image_path(imagefile):
|
||||
image_path = imagefile.path
|
||||
pdir = os.path.dirname(image_path)
|
||||
os.makedirs(pdir, exist_ok=True)
|
||||
return str(image_path)
|
||||
|
||||
|
||||
def serialize_datetime(obj):
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
raise TypeError("Type not serializable")
|
||||
|
||||
|
||||
def start_async():
|
||||
loop = asyncio.new_event_loop()
|
||||
Thread(target=loop.run_forever, daemon=True).start()
|
||||
return loop
|
||||
|
||||
|
||||
def stop_async(loop):
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
|
||||
|
||||
def submit_async(loop, awaitable):
|
||||
return asyncio.run_coroutine_threadsafe(awaitable, loop)
|
||||
|
||||
|
||||
def to_choice(d):
|
||||
choices = []
|
||||
for k, v in d.items():
|
||||
choices.append((k, v))
|
||||
return choices
|
||||
|
||||
|
||||
def get_instance_class(module):
|
||||
modulename, classname = module.rsplit(".", 1)
|
||||
return getattr(importlib.import_module(modulename), classname)
|
||||
|
||||
|
||||
def wait_for(timeout):
|
||||
Event().wait(timeout)
|
||||
|
||||
|
||||
def yaml_load(f):
|
||||
with open(f, 'r') as stream:
|
||||
return yaml.safe_load(stream)
|
||||
return {}
|
||||
|
||||
|
||||
def yaml_save(f, context):
|
||||
with open(f, 'w') as stream:
|
||||
yaml.dump(context, stream, default_flow_style = False)
|
||||
|
||||
|
||||
def get_uuid():
|
||||
return str(hex(uuid.getnode()))[2:]
|
||||
|
||||
|
||||
def millis():
|
||||
return round(time.time() * 1000)
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def ts_now():
|
||||
# float second
|
||||
return now().timestamp()
|
||||
|
||||
|
||||
def ts_now_s():
|
||||
return int(ts_now())
|
||||
|
||||
|
||||
def ts_now_ms():
|
||||
return int(ts_now()*1000)
|
||||
|
||||
|
||||
def ts_now_us():
|
||||
return int(ts_now()*1000000)
|
||||
|
||||
|
||||
def random_num(n=16):
|
||||
alphabet = string.digits
|
||||
return ''.join(secrets.choice(alphabet) for i in range(n)) # @UnusedVariable
|
||||
|
||||
|
||||
def random_chars(n=6):
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for i in range(n)) # @UnusedVariable
|
||||
|
||||
|
||||
def get_apikey(n=32):
|
||||
chars = 'abcdefgh01234ijklABCD4567EFGHIJKLmnopqrstuvwxyz0123456789MNOPQRS789TUVWXYZ'
|
||||
return ''.join(secrets.choice(chars) for i in range(n)) # @UnusedVariable
|
||||
|
||||
|
||||
def gen_keywords(s):
|
||||
c = s.replace(',', ' ').replace('+', ' ')
|
||||
return [ w.strip() for w in c.split(' ') if w]
|
||||
|
||||
|
||||
def gen_device_uuid(n=19):
|
||||
return hex(int(random_num(n)))[2:]
|
||||
|
||||
|
||||
def get_device_uuid(n=19):
|
||||
return f'0x{gen_device_uuid(n)}'
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Implémentation de la capture vidéo pour webcam 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 WebcamCapture(VideoCaptureInterface):
|
||||
"""
|
||||
Capture JPEG depuis une webcam USB/intégrée via OpenCV.
|
||||
|
||||
Exemple d'utilisation ::
|
||||
|
||||
cam = WebcamCapture(device_index=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,
|
||||
device_index: int = 0,
|
||||
fps: float = VideoCaptureInterface.DEFAULT_FPS,
|
||||
jpeg_quality: int = 85,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
:param device_index: Index du périphérique V4L2 (0 = première webcam)
|
||||
: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._device_index: int = device_index
|
||||
self._jpeg_quality: int = jpeg_quality
|
||||
self._width: Optional[int] = width
|
||||
self._height: Optional[int] = height
|
||||
self._cap = None # Instance cv2.VideoCapture
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Implémentation des méthodes abstraites
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def open(self) -> None:
|
||||
"""Ouvre le flux V4L2 via OpenCV et configure la résolution."""
|
||||
|
||||
self._cap = cv2.VideoCapture(self._device_index)
|
||||
|
||||
if not self._cap.isOpened():
|
||||
raise CaptureError(
|
||||
f"Impossible d'ouvrir la webcam (index={self._device_index})"
|
||||
)
|
||||
|
||||
# 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(
|
||||
"Webcam ouverte : index=%d résolution=%dx%d",
|
||||
self._device_index, 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("Webcam fermée (index=%d)", self._device_index)
|
||||
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 not self._cap or not self._cap.isOpened():
|
||||
raise CaptureError("La webcam n'est pas ouverte")
|
||||
|
||||
ret, frame = self._cap.read()
|
||||
if not ret or frame is None:
|
||||
raise CaptureError("Échec de lecture de la trame")
|
||||
|
||||
# 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 device_index(self) -> int:
|
||||
"""Index du périphérique V4L2."""
|
||||
return self._device_index
|
||||
|
||||
@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)
|
||||
Reference in New Issue
Block a user