fix(agent): capture JPEG+downscale (allege CPU/disque, frequence intacte) + robustesse chemin _background/shots

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-07-01 12:36:47 +02:00
parent 2a1b1ed80e
commit 144a5c288a
3 changed files with 465 additions and 12 deletions

View File

@@ -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'
~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")

View File

@@ -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,

View File

@@ -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()