From 144a5c288a3bfabb4207045b56aef00a692676e1 Mon Sep 17 00:00:00 2001 From: Dom Date: Wed, 1 Jul 2026 12:36:47 +0200 Subject: [PATCH] fix(agent): capture JPEG+downscale (allege CPU/disque, frequence intacte) + robustesse chemin _background/shots Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_v0/agent_v1/vision/capture_io.py | 110 ++++++ agent_v0/agent_v1/vision/capturer.py | 47 ++- tests/unit/test_capturer_capture_io_format.py | 320 ++++++++++++++++++ 3 files changed, 465 insertions(+), 12 deletions(-) create mode 100644 agent_v0/agent_v1/vision/capture_io.py create mode 100644 tests/unit/test_capturer_capture_io_format.py diff --git a/agent_v0/agent_v1/vision/capture_io.py b/agent_v0/agent_v1/vision/capture_io.py new file mode 100644 index 000000000..089e52ff9 --- /dev/null +++ b/agent_v0/agent_v1/vision/capture_io.py @@ -0,0 +1,110 @@ +"""Politique de sauvegarde des captures — réduction du poids disque. + +Constat : tous les shots étaient sauvés en PNG plein écran lossless +(``img.save(path, "PNG", quality=...)`` — PNG ignore ``quality``), d'où +~90 Go pour 13 sessions. La majorité de ce poids n'a aucune valeur de +grounding (full + full_blurred en doublon, heartbeats plein écran). + +Cette politique distingue le **type** de shot et écrit le format adapté : + +- ``crop`` → PNG lossless. C'est la cible de grounding qwen3-vl ; on + préserve chaque pixel (perte JPEG = bruit sur de petites icônes). Le crop + fait 80×80 → poids négligeable, aucun intérêt à le dégrader. +- ``full`` / ``window`` / ``context`` → JPEG ``quality=SCREENSHOT_QUALITY, + optimize=True``. Ce sont des vues contextuelles / humaines : la + compression JPEG (~5-10x) est sans impact fonctionnel. +- ``heartbeat`` → JPEG **downscalé** (largeur max ``HEARTBEAT_MAX_WIDTH``, + ratio préservé). C'est de la *liveness* (le serveur vérifie juste qu'un + écran a changé), pas du grounding → la pleine résolution est du gaspillage. + +``save_capture`` retourne le chemin RÉELLEMENT écrit, extension ajustée selon +le format. L'appelant doit utiliser ce retour (et non un chemin ``.png`` +présumé) pour streamer / référencer le bon fichier. + +⚠️ Contrat avec le serveur : l'extension du crop NE DOIT PAS changer (le +serveur retrouve le crop par basename via ``vision_info.crop`` — voir +``stream_processor._extract_crop_b64`` stratégie 1). C'est pourquoi ``crop`` +reste PNG. Les full/window/context/heartbeat sont retrouvés par +``screenshot_id`` avec extension ``.png`` hardcodée côté serveur, mais le +serveur réécrit toujours l'upload sous ``{shot_id}.png`` (le suffixe envoyé +sur le fil est ignoré) → changer l'extension LOCALE de ces types est sûr. +""" + +from __future__ import annotations + +import os +from typing import Iterable + +from PIL import Image + +from ..config import SCREENSHOT_QUALITY + +# Types sauvés en JPEG (vue contextuelle / humaine, pas de grounding pixel). +_JPEG_KINDS: frozenset = frozenset({"full", "window", "context"}) + +# Largeur max d'un heartbeat downscalé. 1280 px suffit largement pour de la +# liveness (détecter qu'un écran a changé) ; on divise le poids d'un 2560 px +# par ~4 (surface) avant compression JPEG. +HEARTBEAT_MAX_WIDTH = 1280 + + +def _ensure_jpeg_ready(img: Image.Image) -> Image.Image: + """Convertit en RGB si nécessaire (JPEG ne supporte ni alpha ni palette).""" + if img.mode in ("RGBA", "LA", "P"): + return img.convert("RGB") + return img + + +def _downscale_to_width(img: Image.Image, max_width: int) -> Image.Image: + """Réduit l'image à ``max_width`` en préservant le ratio (no-op si plus petite).""" + if img.width <= max_width: + return img + new_height = max(1, round(img.height * max_width / img.width)) + return img.resize((max_width, new_height), Image.LANCZOS) + + +def save_capture(img: Image.Image, path_base: str, kind: str) -> str: + """Sauve ``img`` selon la politique du ``kind`` et retourne le chemin écrit. + + Args: + img: image PIL à sauvegarder. + path_base: chemin SANS extension (ex. + ``.../shots/shot_0001_full``). L'extension finale (``.png`` ou + ``.jpg``) est ajoutée par la politique. + kind: type de shot — ``"crop"`` | ``"full"`` | ``"window"`` | + ``"context"`` | ``"heartbeat"``. + + Returns: + Le chemin RÉELLEMENT écrit, avec la bonne extension. + + Raises: + ValueError: si ``kind`` n'est pas reconnu (fail-closed : on refuse + d'écrire un fichier dont la politique est indéterminée). + """ + if kind == "crop": + out_path = f"{path_base}.png" + img.save(out_path, "PNG") + return out_path + + if kind in _JPEG_KINDS: + out_path = f"{path_base}.jpg" + _ensure_jpeg_ready(img).save( + out_path, "JPEG", quality=SCREENSHOT_QUALITY, optimize=True + ) + return out_path + + if kind == "heartbeat": + out_path = f"{path_base}.jpg" + small = _downscale_to_width(_ensure_jpeg_ready(img), HEARTBEAT_MAX_WIDTH) + small.save(out_path, "JPEG", quality=SCREENSHOT_QUALITY) + return out_path + + raise ValueError( + f"kind de capture inconnu : {kind!r} " + f"(attendu: crop, full, window, context, heartbeat)" + ) + + +def known_kinds() -> Iterable[str]: + """Retourne les ``kind`` supportés (utile pour la validation appelant).""" + return ("crop", *sorted(_JPEG_KINDS), "heartbeat") diff --git a/agent_v0/agent_v1/vision/capturer.py b/agent_v0/agent_v1/vision/capturer.py index b7abeaa2a..afb3a8b80 100644 --- a/agent_v0/agent_v1/vision/capturer.py +++ b/agent_v0/agent_v1/vision/capturer.py @@ -18,8 +18,9 @@ import platform from typing import Any, Dict, List, Optional, Tuple from PIL import Image, ImageFilter, ImageStat import mss -from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE +from ..config import TARGETED_CROP_SIZE, BLUR_SENSITIVE from .blur_sensitive import blur_sensitive_regions +from .capture_io import save_capture logger = logging.getLogger(__name__) @@ -425,6 +426,18 @@ class VisionCapturer: # On ne crée plus self.sct ici car mss n'est pas thread-safe sous Windows self.last_img_hash = None + def _ensure_shots_dir(self) -> None: + """Garantit l'existence de `shots/` avant toute écriture. + + Le dossier est créé dans `__init__`, mais l'auto-cleanup de + `SessionStorage` (`shutil.rmtree` par âge/taille) peut supprimer tout + le dossier de session — y compris la session permanente `_background`. + Sans ce garde, la capture suivante lève `[Errno 2] No such file or + directory` (bug observé poste Émilie). On recrée donc le répertoire + cible juste avant chaque sauvegarde. + """ + os.makedirs(self.shots_dir, exist_ok=True) + def capture_full_context(self, name_suffix: str, force=False) -> str: """ Capture l'écran complet. @@ -460,9 +473,15 @@ class VisionCapturer: 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 + # Politique d'écriture : les heartbeats sont de la liveness pure + # (le serveur vérifie juste qu'un écran a changé) → JPEG downscalé. + # Les autres contextes (focus_change, result_of_*) → JPEG q85. + kind = "heartbeat" if "heartbeat" in name_suffix else "context" + self._ensure_shots_dir() + path_base = os.path.join( + self.shots_dir, f"context_{int(time.time())}_{name_suffix}" + ) + return save_capture(img, path_base, kind) except Exception as e: logger.error(f"Erreur Context Capture: {e}") return "" @@ -506,10 +525,10 @@ class VisionCapturer: return result return {} - full_path = os.path.join(self.shots_dir, f"{screenshot_id}_full.png") + full_base = os.path.join(self.shots_dir, f"{screenshot_id}_full") # Capture du Crop (Cœur de l'apprentissage qwen3-vl) - crop_path = os.path.join(self.shots_dir, f"{screenshot_id}_crop.png") + crop_base = os.path.join(self.shots_dir, f"{screenshot_id}_crop") w, h = TARGETED_CROP_SIZE left = max(0, x - w // 2) top = max(0, y - h // 2) @@ -523,8 +542,11 @@ class VisionCapturer: 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) + # Politique d'écriture : full = vue contextuelle → JPEG q85 ; + # crop = cible de grounding qwen3-vl → PNG lossless (contrat serveur). + self._ensure_shots_dir() + full_path = save_capture(img, full_base, "full") + crop_path = save_capture(crop_img, crop_base, "crop") # Mise à jour du hash pour le prochain heartbeat self.last_img_hash = self._compute_quick_hash(img) @@ -648,11 +670,12 @@ class VisionCapturer: if BLUR_SENSITIVE: blur_sensitive_regions(window_img) - # Sauvegarde - window_path = os.path.join( - self.shots_dir, f"{screenshot_id}_window.png" + # Sauvegarde — fenêtre = vue contextuelle → JPEG q85 (politique). + self._ensure_shots_dir() + window_base = os.path.join( + self.shots_dir, f"{screenshot_id}_window" ) - window_img.save(window_path, "PNG", quality=SCREENSHOT_QUALITY) + window_path = save_capture(window_img, window_base, "window") result = { "window_image": window_path, diff --git a/tests/unit/test_capturer_capture_io_format.py b/tests/unit/test_capturer_capture_io_format.py new file mode 100644 index 000000000..64bcf6a75 --- /dev/null +++ b/tests/unit/test_capturer_capture_io_format.py @@ -0,0 +1,320 @@ +"""Politique de format des captures + robustesse du répertoire shots. + +Deux corrections testées ici (agent_v0/agent_v1/vision) : + +1. FORMAT (allègement) : `capturer.py` doit déléguer l'écriture à + `capture_io.save_capture`, qui applique la politique : + - crop → PNG lossless (cible de grounding qwen3-vl) + - full/window/context → JPEG q85 + - heartbeat → JPEG downscalé (largeur max ~1280) + Aujourd'hui tout était sauvé en `img.save(path, "PNG", quality=...)` + (le `quality` est ignoré par PNG → PNG lossless plein écran, ~90 Go). + +2. BUG chemin (poste Émilie) : ``[Errno 2] No such file or directory: + ..._background/shots/context...``. Le répertoire `shots/` est créé une + seule fois dans `__init__`, mais l'auto-cleanup (`SessionStorage`, + `shutil.rmtree`) peut supprimer tout le dossier de session `_background`. + Les sauvegardes suivantes doivent recréer le répertoire cible + (`os.makedirs(dir, exist_ok=True)`) avant chaque écriture. + +Tests 100% mockés : aucune vraie capture écran (mss est patché). +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from PIL import Image + + +# --------------------------------------------------------------------------- +# Helpers (repris du style de test_capturer_monitor_guard.py) +# --------------------------------------------------------------------------- + + +def _make_mock_mss(monitors): + """Mock `mss.mss()` renvoyant un monitor sain unique (image grise unie).""" + + def factory(): + instance = MagicMock() + instance.monitors = monitors + grab_result = MagicMock() + m = monitors[1] if len(monitors) > 1 else monitors[0] + w, h = m["width"], m["height"] + grab_result.size = (w, h) + grab_result.bgra = b"\x80\x80\x80\x00" * (w * h) + instance.grab = MagicMock(return_value=grab_result) + cm = MagicMock() + cm.__enter__ = MagicMock(return_value=instance) + cm.__exit__ = MagicMock(return_value=False) + return cm + + return factory + + +_NORMAL_MONITORS = [ + {"left": 0, "top": 0, "width": 800, "height": 600}, # composite + {"left": 0, "top": 0, "width": 800, "height": 600}, # primaire sain +] + + +def _vision_capturer(tmp_path): + from agent_v0.agent_v1.vision.capturer import VisionCapturer + + return VisionCapturer(str(tmp_path)) + + +def _patch_mss(): + """Contexte : mss patché + time.sleep no-op + pas de floutage. + + Le floutage est désactivé pour isoler la politique d'écriture (le blur + ouvre/modifie l'image mais n'impacte pas le format de sortie ; on le coupe + pour rester déterministe). + """ + return ( + patch( + "agent_v0.agent_v1.vision.capturer.mss.mss", + side_effect=_make_mock_mss(_NORMAL_MONITORS), + ), + patch("agent_v0.agent_v1.vision.capturer.time.sleep"), + patch("agent_v0.agent_v1.vision.capturer.BLUR_SENSITIVE", False), + ) + + +# =========================================================================== +# PARTIE A — Politique save_capture (unité capture_io) +# =========================================================================== + + +def test_save_capture_crop_stays_png(tmp_path: Path): + from agent_v0.agent_v1.vision import capture_io + + img = Image.new("RGB", (80, 80), (10, 20, 30)) + out = capture_io.save_capture(img, str(tmp_path / "shot_crop"), "crop") + + assert out.endswith(".png"), f"crop doit rester PNG, got {out!r}" + assert Path(out).exists() + with Image.open(out) as reopened: + assert reopened.format == "PNG" + + +@pytest.mark.parametrize("kind", ["full", "window", "context"]) +def test_save_capture_context_kinds_are_jpeg(tmp_path: Path, kind: str): + from agent_v0.agent_v1.vision import capture_io + + img = Image.new("RGB", (640, 480), (120, 130, 140)) + out = capture_io.save_capture(img, str(tmp_path / f"shot_{kind}"), kind) + + assert out.endswith(".jpg"), f"{kind} doit être JPEG, got {out!r}" + assert Path(out).exists() + with Image.open(out) as reopened: + assert reopened.format == "JPEG" + + +def test_save_capture_heartbeat_is_downscaled_jpeg(tmp_path: Path): + from agent_v0.agent_v1.vision import capture_io + + # Image large (2560) → doit être réduite à HEARTBEAT_MAX_WIDTH. + img = Image.new("RGB", (2560, 1440), (50, 60, 70)) + out = capture_io.save_capture(img, str(tmp_path / "hb"), "heartbeat") + + assert out.endswith(".jpg") + with Image.open(out) as reopened: + assert reopened.format == "JPEG" + assert reopened.width == capture_io.HEARTBEAT_MAX_WIDTH, ( + f"heartbeat doit être downscalé à {capture_io.HEARTBEAT_MAX_WIDTH}, " + f"got {reopened.width}" + ) + # ratio préservé (1440 * 1280/2560 = 720) + assert reopened.height == 720 + + +def test_save_capture_heartbeat_smaller_than_max_is_not_upscaled(tmp_path: Path): + from agent_v0.agent_v1.vision import capture_io + + img = Image.new("RGB", (640, 360), (1, 2, 3)) + out = capture_io.save_capture(img, str(tmp_path / "hb_small"), "heartbeat") + with Image.open(out) as reopened: + assert reopened.width == 640, "no-op si déjà plus petit que le max" + + +def test_save_capture_heartbeat_downscale_reduces_pixel_count(tmp_path: Path): + """Preuve de l'allègement heartbeat par la mesure objective du code : + le downscale réduit le nombre de pixels (2560×1440 → 1280×720 = /4 surface). + On mesure la géométrie de sortie (déterministe), pas le poids d'un JPEG + synthétique (qui dépend de libjpeg et n'est pas représentatif d'un vrai + écran).""" + from agent_v0.agent_v1.vision import capture_io + + src = Image.new("RGB", (2560, 1440)) + out = capture_io.save_capture(src, str(tmp_path / "hb_measure"), "heartbeat") + with Image.open(out) as small: + src_pixels = src.width * src.height + out_pixels = small.width * small.height + assert out_pixels < src_pixels / 3, ( + f"Le downscale heartbeat doit diviser la surface par ~4 " + f"({src_pixels} → {out_pixels})" + ) + + +def test_save_capture_rejects_unknown_kind(tmp_path: Path): + from agent_v0.agent_v1.vision import capture_io + + img = Image.new("RGB", (10, 10)) + with pytest.raises(ValueError): + capture_io.save_capture(img, str(tmp_path / "x"), "bogus") + + +# =========================================================================== +# PARTIE B — Câblage dans capturer.py (format des sorties runtime) +# =========================================================================== + + +def test_capture_full_context_writes_jpeg(tmp_path: Path): + """capture_full_context (context / focus_change / result_of_*) → JPEG.""" + p1, p2, p3 = _patch_mss() + with p1, p2, p3: + cap = _vision_capturer(tmp_path) + out = cap.capture_full_context("focus_change", force=True) + + assert out, "capture attendue" + assert out.endswith(".jpg"), f"context doit être JPEG, got {out!r}" + assert Path(out).exists() + with Image.open(out) as im: + assert im.format == "JPEG" + + +def test_capture_full_context_heartbeat_is_jpeg(tmp_path: Path): + """Un suffixe 'heartbeat' doit produire un JPEG (downscalé côté politique).""" + p1, p2, p3 = _patch_mss() + with p1, p2, p3: + cap = _vision_capturer(tmp_path) + out = cap.capture_full_context("heartbeat", force=True) + + assert out.endswith(".jpg"), f"heartbeat doit être JPEG, got {out!r}" + with Image.open(out) as im: + assert im.format == "JPEG" + + +def test_capture_dual_full_is_jpeg_crop_is_png(tmp_path: Path): + """capture_dual : full/window en JPEG, crop en PNG (contrat serveur).""" + p1, p2, p3 = _patch_mss() + with p1, p2, p3, patch( + # Neutraliser la capture fenêtre (dépend d'API OS) pour isoler full+crop + "agent_v0.agent_v1.vision.capturer.VisionCapturer.capture_active_window", + return_value=None, + ): + cap = _vision_capturer(tmp_path) + result = cap.capture_dual(x=100, y=200, screenshot_id="shot42") + + assert "full" in result and "crop" in result + assert result["full"].endswith(".jpg"), f"full doit être JPEG, got {result['full']!r}" + assert result["crop"].endswith(".png"), f"crop doit rester PNG, got {result['crop']!r}" + assert Path(result["full"]).exists() + assert Path(result["crop"]).exists() + with Image.open(result["full"]) as im: + assert im.format == "JPEG" + with Image.open(result["crop"]) as im: + assert im.format == "PNG" + + +def test_capture_active_window_writes_jpeg(tmp_path: Path): + """La fenêtre active est une vue contextuelle → JPEG.""" + p1, p2, p3 = _patch_mss() + fake_rect = { + "rect": [100, 100, 500, 400], + "size": [400, 300], + "title": "Bloc-notes", + "app_name": "notepad.exe", + } + full_img = Image.new("RGB", (800, 600), (90, 90, 90)) + with p1, p2, p3, patch( + "agent_v0.agent_v1.window_info_crossplatform.get_active_window_rect", + return_value=fake_rect, + ): + cap = _vision_capturer(tmp_path) + result = cap.capture_active_window( + x=200, y=200, screenshot_id="shotW", full_img=full_img + ) + + assert result is not None + assert result["window_image"].endswith(".jpg"), ( + f"window doit être JPEG, got {result['window_image']!r}" + ) + with Image.open(result["window_image"]) as im: + assert im.format == "JPEG" + + +# =========================================================================== +# PARTIE C — BUG chemin : shots/ recréé si supprimé par l'auto-cleanup +# =========================================================================== + + +def test_capture_full_context_recreates_shots_dir_after_rmtree(tmp_path: Path): + """Reproduction du bug poste Émilie. + + L'auto-cleanup (`SessionStorage.shutil.rmtree`) supprime tout le dossier + de session `_background` (donc `shots/`). Une capture ultérieure ne doit + PAS lever `[Errno 2] No such file or directory` : le répertoire cible + doit être recréé avant l'écriture. + """ + p1, p2, p3 = _patch_mss() + with p1, p2, p3: + cap = _vision_capturer(tmp_path) + + # Simule l'auto-cleanup : la session entière est purgée après ACK. + shutil.rmtree(cap.shots_dir) + assert not Path(cap.shots_dir).exists() + + out = cap.capture_full_context("context_after_purge", force=True) + + assert out, "La capture doit réussir même après purge du dossier shots" + assert Path(out).exists(), "Le fichier doit être physiquement écrit" + assert Path(cap.shots_dir).exists(), "shots/ doit avoir été recréé" + + +def test_capture_dual_recreates_shots_dir_after_rmtree(tmp_path: Path): + """capture_dual doit aussi survivre à la purge du dossier shots.""" + p1, p2, p3 = _patch_mss() + with p1, p2, p3, patch( + "agent_v0.agent_v1.vision.capturer.VisionCapturer.capture_active_window", + return_value=None, + ): + cap = _vision_capturer(tmp_path) + shutil.rmtree(cap.shots_dir) + + result = cap.capture_dual(x=50, y=60, screenshot_id="shot_purge") + + assert result.get("full") and result.get("crop"), ( + "capture_dual doit produire full+crop même après purge" + ) + assert Path(result["full"]).exists() + assert Path(result["crop"]).exists() + + +def test_capture_active_window_recreates_shots_dir_after_rmtree(tmp_path: Path): + """capture_active_window (crop fenêtre depuis full fourni) survit à la purge.""" + p1, p2, p3 = _patch_mss() + fake_rect = { + "rect": [10, 10, 210, 210], + "size": [200, 200], + "title": "W", + "app_name": "w.exe", + } + full_img = Image.new("RGB", (400, 400), (70, 70, 70)) + with p1, p2, p3, patch( + "agent_v0.agent_v1.window_info_crossplatform.get_active_window_rect", + return_value=fake_rect, + ): + cap = _vision_capturer(tmp_path) + shutil.rmtree(cap.shots_dir) + + result = cap.capture_active_window( + x=50, y=50, screenshot_id="shotW_purge", full_img=full_img + ) + + assert result is not None, "capture fenêtre doit réussir après purge" + assert Path(result["window_image"]).exists()