feat: floutage auto champs sensibles + fix routing actions fichiers

Floutage (conformité AI Act) :
- Détection OpenCV des champs de saisie (rectangles clairs avec texte)
- Flou gaussien avant stockage/envoi
- Activé par défaut (RPA_BLUR_SENSITIVE=true)
- <200ms par screenshot, 12 tests

Fix actions fichiers VWB :
- Pas de wait 5s pour les actions fichiers (inutile)
- Routing direct vers agent port 5006

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-18 16:24:01 +01:00
parent 40e5fba86c
commit 353c2a347e
6 changed files with 436 additions and 4 deletions

View File

@@ -33,6 +33,11 @@ SESSIONS_ROOT = BASE_DIR / "sessions"
TARGETED_CROP_SIZE = (400, 400)
SCREENSHOT_QUALITY = 85
# Floutage des données sensibles (conformité AI Act)
# Floute les champs de saisie dans les screenshots AVANT stockage/envoi
# Désactiver avec RPA_BLUR_SENSITIVE=false pour le développement/tests
BLUR_SENSITIVE = os.environ.get("RPA_BLUR_SENSITIVE", "true").lower() in ("true", "1", "yes")
# Monitoring
PERF_MONITOR_INTERVAL_S = 30
LOGS_DIR = BASE_DIR / "logs"

View File

@@ -21,6 +21,9 @@ logger = logging.getLogger(__name__)
CAPTURE_PORT = int(os.environ.get("RPA_CAPTURE_PORT", "5006"))
# Floutage des données sensibles (conformité AI Act)
BLUR_SENSITIVE = os.environ.get("RPA_BLUR_SENSITIVE", "true").lower() in ("true", "1", "yes")
class CaptureHandler(BaseHTTPRequestHandler):
"""Retourne un screenshot frais a chaque requete GET /capture.
@@ -95,6 +98,14 @@ class CaptureHandler(BaseHTTPRequestHandler):
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
# Floutage des données sensibles (conformité AI Act)
if BLUR_SENSITIVE:
try:
from ..vision.blur_sensitive import blur_sensitive_regions
blur_sensitive_regions(img)
except ImportError:
logger.warning("Module blur_sensitive non disponible")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=80)
img_b64 = base64.b64encode(buf.getvalue()).decode()

View File

@@ -0,0 +1,203 @@
# agent_v1/vision/blur_sensitive.py
"""
Floutage automatique des zones de texte sensible dans les screenshots.
Conformité AI Act : les screenshots utilisés pour l'apprentissage ne doivent
pas contenir de données patient lisibles, mots de passe, etc.
Stratégie :
- Détecte les champs de saisie (rectangles clairs avec du texte)
- Floute leur CONTENU tout en gardant la structure UI visible
- Rapide (<200ms) : uniquement des opérations OpenCV simples, pas de deep learning
Usage :
from .blur_sensitive import blur_sensitive_regions
blur_sensitive_regions(img) # modifie l'image PIL en place
"""
import logging
import time
logger = logging.getLogger(__name__)
# Seuils configurables pour la détection des champs de saisie
_INPUT_FIELD_MIN_WIDTH = 50 # Largeur minimale en pixels
_INPUT_FIELD_MIN_HEIGHT = 15 # Hauteur minimale
_INPUT_FIELD_MAX_HEIGHT = 80 # Hauteur maximale (exclut les grandes zones)
_INPUT_FIELD_MIN_ASPECT_RATIO = 2.0 # Ratio largeur/hauteur minimum
_INPUT_FIELD_MIN_AREA = 1000 # Surface minimale en pixels²
_INPUT_FIELD_BRIGHTNESS_THRESHOLD = 200 # Luminosité moyenne minimum (fond clair)
# Pour les zones de texte multi-lignes (textarea)
_TEXTAREA_MIN_WIDTH = 100
_TEXTAREA_MIN_HEIGHT = 60
_TEXTAREA_MAX_HEIGHT = 500
_TEXTAREA_MIN_AREA = 8000
_TEXTAREA_MIN_ASPECT_RATIO = 1.2
# Paramètres du flou gaussien
_BLUR_KERNEL_SIZE = (23, 23)
_BLUR_SIGMA = 12
_BLUR_MARGIN = 3 # Marge en pixels pour garder le bord du champ visible
def blur_sensitive_regions(pil_image):
"""Floute les zones de texte sensible dans une image PIL.
Modifie l'image en place et la retourne.
Rapide : ~50-150ms selon la résolution.
Args:
pil_image: Image PIL (mode RGB)
Returns:
L'image PIL modifiée (même objet, modifié en place)
"""
try:
import cv2
import numpy as np
except ImportError:
logger.warning("OpenCV non disponible — floutage désactivé")
return pil_image
t0 = time.perf_counter()
# Conversion PIL → OpenCV (sans copie disque)
img_array = np.array(pil_image)
# PIL est RGB, OpenCV attend BGR
img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR)
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
blurred_count = 0
# --- Passe 1 : Champs de saisie classiques (input text) ---
blurred_count += _blur_input_fields(img_bgr, gray)
# --- Passe 2 : Zones de texte multi-lignes (textarea, éditeurs) ---
blurred_count += _blur_textareas(img_bgr, gray)
if blurred_count > 0:
# Reconversion OpenCV → PIL en place
from PIL import Image as _PILImage
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
pil_image.paste(_PILImage.fromarray(img_rgb))
elapsed_ms = (time.perf_counter() - t0) * 1000
if blurred_count > 0:
logger.debug(f"Floutage : {blurred_count} zones en {elapsed_ms:.0f}ms")
return pil_image
def _blur_input_fields(img_bgr, gray):
"""Détecte et floute les champs de saisie simples (input text).
Les champs de saisie sont typiquement des rectangles à fond clair
(blanc ou gris très clair) avec du texte sombre dedans.
"""
import cv2
import numpy as np
count = 0
# Seuillage : zones quasi-blanches (fond des champs de saisie)
_, white_mask = cv2.threshold(gray, 230, 255, cv2.THRESH_BINARY)
# Nettoyage morphologique : fermer les petits trous dans les champs
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
white_mask = cv2.morphologyEx(white_mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(white_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
aspect_ratio = w / max(h, 1)
area = w * h
# Filtrage : forme typique d'un champ de saisie
if (w < _INPUT_FIELD_MIN_WIDTH or
h < _INPUT_FIELD_MIN_HEIGHT or
h > _INPUT_FIELD_MAX_HEIGHT or
aspect_ratio < _INPUT_FIELD_MIN_ASPECT_RATIO or
area < _INPUT_FIELD_MIN_AREA):
continue
# Vérifier la luminosité moyenne (les boutons ont souvent un fond coloré)
roi = gray[y:y+h, x:x+w]
mean_val = np.mean(roi)
if mean_val < _INPUT_FIELD_BRIGHTNESS_THRESHOLD:
continue # Pas assez clair, probablement pas un champ de saisie
# Vérifier qu'il y a du contenu (variation de luminosité = texte présent)
std_val = np.std(roi)
if std_val < 5:
continue # Zone uniformément blanche, pas de texte à flouter
# Appliquer le flou gaussien sur le contenu (garder le bord visible)
m = _BLUR_MARGIN
y1, y2 = y + m, y + h - m
x1, x2 = x + m, x + w - m
if y2 > y1 and x2 > x1:
roi_color = img_bgr[y1:y2, x1:x2]
if roi_color.size > 0:
blurred = cv2.GaussianBlur(roi_color, _BLUR_KERNEL_SIZE, _BLUR_SIGMA)
img_bgr[y1:y2, x1:x2] = blurred
count += 1
return count
def _blur_textareas(img_bgr, gray):
"""Détecte et floute les zones de texte multi-lignes (textarea, éditeurs).
Ces zones sont plus grandes que les champs simples, avec un fond clair
et beaucoup de texte.
"""
import cv2
import numpy as np
count = 0
# Seuillage un peu plus tolérant pour les textareas (parfois gris clair)
_, light_mask = cv2.threshold(gray, 220, 255, cv2.THRESH_BINARY)
# Nettoyage morphologique plus agressif pour les grandes zones
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (20, 10))
light_mask = cv2.morphologyEx(light_mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(light_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
aspect_ratio = w / max(h, 1)
area = w * h
# Filtrage : forme typique d'une textarea
if (w < _TEXTAREA_MIN_WIDTH or
h < _TEXTAREA_MIN_HEIGHT or
h > _TEXTAREA_MAX_HEIGHT or
aspect_ratio < _TEXTAREA_MIN_ASPECT_RATIO or
area < _TEXTAREA_MIN_AREA):
continue
# Vérifier la luminosité et la présence de texte
roi = gray[y:y+h, x:x+w]
mean_val = np.mean(roi)
std_val = np.std(roi)
if mean_val < 190 or std_val < 8:
continue # Pas un textarea avec du contenu
# Flou sur le contenu
m = _BLUR_MARGIN + 2 # Marge un peu plus grande pour les textarea
y1, y2 = y + m, y + h - m
x1, x2 = x + m, x + w - m
if y2 > y1 and x2 > x1:
roi_color = img_bgr[y1:y2, x1:x2]
if roi_color.size > 0:
blurred = cv2.GaussianBlur(roi_color, _BLUR_KERNEL_SIZE, _BLUR_SIGMA)
img_bgr[y1:y2, x1:x2] = blurred
count += 1
return count

View File

@@ -10,7 +10,8 @@ import logging
import hashlib
from PIL import Image, ImageFilter, ImageStat
import mss
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE
from .blur_sensitive import blur_sensitive_regions
logger = logging.getLogger(__name__)
@@ -40,6 +41,10 @@ class VisionCapturer:
return "" # Pas de changement, on économise la fibre
self.last_img_hash = current_hash
# Floutage des données sensibles (conformité AI Act)
if BLUR_SENSITIVE:
blur_sensitive_regions(img)
path = os.path.join(self.shots_dir, f"context_{int(time.time())}_{name_suffix}.png")
img.save(path, "PNG", quality=SCREENSHOT_QUALITY)
return path
@@ -66,6 +71,11 @@ class VisionCapturer:
if anonymize:
crop_img = crop_img.filter(ImageFilter.GaussianBlur(radius=4))
# Floutage des données sensibles (conformité AI Act)
if BLUR_SENSITIVE:
blur_sensitive_regions(img)
blur_sensitive_regions(crop_img)
img.save(full_path, "PNG", quality=SCREENSHOT_QUALITY)
crop_img.save(crop_path, "PNG", quality=SCREENSHOT_QUALITY)