second commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user