second commit

This commit is contained in:
2026-04-13 23:14:05 +02:00
parent 1c031ebf63
commit e08597fc72
125 changed files with 26221 additions and 0 deletions
@@ -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,
)
+245
View File
@@ -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
+356
View File
@@ -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)
+122
View File
@@ -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
+138
View File
@@ -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)
+261
View File
@@ -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)}'
+152
View File
@@ -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)