Files
rpa_vision_v3/agent_v0/agent_v1/vision/capture_io.py

111 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")