Jelajahi Sumber

Images : une image aux dimensions énormes ne fait plus exploser la mémoire

Pillow ne refusait une image qu'au-delà de 2 × MAX_IMAGE_PIXELS (160 millions
de pixels) : en dessous, l'image était décodée en entier par img.load(), puis
copiée par ImageOps.exif_transpose(). Un PNG de 0,6 Mo (12 000 × 12 000 px)
faisait monter le processus à 1,3 Go, et les 4 images d'un message (plus
celles d'un message cité) étaient préparées en même temps : quelques Mo
suffisaient à faire tuer le conteneur.

- La taille en pixels est vérifiée dès l'en-tête, avant tout décodage :
  40 millions de pixels au plus, 120 millions pour un JPEG non progressif.
- Un JPEG à réduire est décodé directement en petit (draft), à une taille
  calculée selon ses proportions (le grand côté, pas le petit).
- exif_transpose() passe après la réduction : sa copie est petite.
- Un sémaphore limite à une les images préparées à la fois, tous messages
  confondus.

Mesures : photo JPEG de 50 Mpx 416 → 91 Mo, PNG de 40 Mpx 335 → 245 Mo ;
pire cas accepté (PNG transparent de 40 Mpx) environ 400 Mo.

Ouvre aussi la version 5.13 (CHANGELOG et VERSION).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 1 hari lalu
induk
melakukan
fb54c8f35c
6 mengubah file dengan 129 tambahan dan 17 penghapusan
  1. 9 1
      CHANGELOG.md
  2. 1 1
      README.md
  3. 1 1
      bot/__init__.py
  4. 9 3
      bot/attachments.py
  5. 23 7
      bot/images.py
  6. 86 4
      tests/test_attachments_prep.py

+ 9 - 1
CHANGELOG.md

@@ -2,7 +2,15 @@
 
 Les versions les plus récentes sont en haut. Chaque entrée commence par un résumé en gras, suivi du détail.
 
-**Versions :** [5.12](#version-512) · [5.11](#version-511) · [5.10](#version-510) · [5.9](#version-59) · [5.8](#version-58) · [5.7](#version-57) · [5.6](#version-56) · [5.5](#version-55) · [5.4.0](#version-540) · [5.3.0](#version-530) · [5.2.0](#version-520) · [5.1.0](#version-510-1) · [5.0.0](#version-500) · [Avant la 5.0](#avant-la-version-50)
+**Versions :** [5.13](#version-513) · [5.12](#version-512) · [5.11](#version-511) · [5.10](#version-510) · [5.9](#version-59) · [5.8](#version-58) · [5.7](#version-57) · [5.6](#version-56) · [5.5](#version-55) · [5.4.0](#version-540) · [5.3.0](#version-530) · [5.2.0](#version-520) · [5.1.0](#version-510-1) · [5.0.0](#version-500) · [Avant la 5.0](#avant-la-version-50)
+
+---
+
+## Version 5.13
+
+### Corrections
+
+- **Une image aux dimensions énormes ne fait plus tomber le bot.** Un fichier léger mais de dizaines de milliers de pixels de côté pouvait occuper plus d'1 Go de mémoire une fois décodé, et faire redémarrer le bot. Il est maintenant refusé d'emblée au-delà de 40 millions de pixels (120 millions pour la plupart des photos JPEG), et les images sont préparées une à la fois.
 
 ---
 

+ 1 - 1
README.md

@@ -19,7 +19,7 @@ Le bot parle français. Sa façon de parler, son caractère et son nom sont ceux
 
 Dans son salon comme dans les mentions, le bot lit jusqu'à 4 images ou un PDF, et un fichier texte, par message :
 
-- **Images** (JPEG, PNG, GIF, WebP, BMP, TIFF) : réduites et converties automatiquement si besoin. Avec plusieurs images, il peut les comparer ou parler de « la deuxième ».
+- **Images** (JPEG, PNG, GIF, WebP, BMP, TIFF) : réduites et converties automatiquement si besoin, 40 millions de pixels au plus (120 millions pour la plupart des photos JPEG). Avec plusieurs images, il peut les comparer ou parler de « la deuxième ».
 - **PDF** : texte, tableaux, schémas et pages scannées, 20 pages (`PDF_MAX_PAGES`) et 20 Mo au plus. Les PDF protégés par un mot de passe sont refusés.
 - **Fichiers texte** (`.txt`, `.md`, `.csv`, `.json`, `.log`, sous-titres, configuration, HTML, code…), 100 Ko au plus.
 

+ 1 - 1
bot/__init__.py

@@ -1,3 +1,3 @@
 """Chatbot Discord propulsé par Claude."""
 
-VERSION = "5.12"
+VERSION = "5.13"

+ 9 - 3
bot/attachments.py

@@ -20,6 +20,9 @@ logger = logging.getLogger("chatbot.discord")  # même nom qu'avant : le journal
 IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff")
 # Images lues par message : chacune coûte jusqu'à ~1 600 tokens.
 MAX_IMAGES = 4
+# Images décodées à la fois, tous messages confondus : chacune peut occuper jusqu'à 400 Mo
+# le temps d'être réduite, pour une fraction de seconde.
+MAX_IMAGES_AT_ONCE = 1
 
 
 class AttachmentRefused(UserFacingError):
@@ -60,6 +63,7 @@ class AttachmentReader:
 
     def __init__(self, settings: Settings):
         self.settings = settings
+        self._image_slots = asyncio.Semaphore(MAX_IMAGES_AT_ONCE)
 
     async def read(
         self, attachments: list[discord.Attachment]
@@ -133,11 +137,13 @@ class AttachmentReader:
             logger.error("Téléchargement de l'image impossible : %s", exc)
             raise ImageError("Je n'ai pas réussi à télécharger ton image, réessaie.")
 
-        # Réduit / convertit l'image si besoin (dans un thread : ne bloque pas le bot).
-        return await asyncio.to_thread(prepare_image, data)
+        # Réduit / convertit l'image si besoin (dans un thread : ne bloque pas le bot),
+        # une à la fois pour borner la mémoire. Les téléchargements, eux, restent en parallèle.
+        async with self._image_slots:
+            return await asyncio.to_thread(prepare_image, data)
 
     async def download_images(self, attachments: list[discord.Attachment]) -> list[ImageData]:
-        """Télécharge et prépare plusieurs images en parallèle (lève ImageError si l'une pose problème).
+        """Télécharge plusieurs images en parallèle et les prépare (lève ImageError si l'une pose problème).
 
         Si plusieurs échouent, l'erreur nomme la première dans l'ordre du message, pas la plus rapide.
         """

+ 23 - 7
bot/images.py

@@ -17,8 +17,14 @@ logger = logging.getLogger("chatbot.images")
 MAX_DIMENSION = 1568
 # Limite de l'API : 5 Mo une fois encodé en base64, soit environ 3,75 Mo bruts.
 API_MAX_BYTES = 3_750_000
-# Protection contre les images piégées (des milliards de pixels qui saturent la mémoire).
-Image.MAX_IMAGE_PIXELS = 80_000_000
+# Protection contre les images piégées : un petit fichier peut cacher des centaines de millions
+# de pixels, qui saturent la mémoire une fois décodés. Refusées dès la lecture de l'en-tête,
+# avant tout décodage (Pillow, lui, ne refuse qu'au double de sa limite et avertit seulement entre les deux).
+MAX_PIXELS = 40_000_000
+# JPEG classique (non progressif) : décodé directement en petit, il coûte peu même très grand
+# (photos de 50 à 108 Mpx des téléphones récents). Un JPEG progressif, lui, est gardé en entier.
+MAX_JPEG_PIXELS = 120_000_000
+Image.MAX_IMAGE_PIXELS = MAX_JPEG_PIXELS
 
 _DIRECT_FORMATS = {"JPEG": "image/jpeg", "PNG": "image/png", "GIF": "image/gif", "WEBP": "image/webp"}
 # Formats sans perte (captures d'écran, dessins…) : PNG essayé avant le JPEG.
@@ -36,23 +42,33 @@ def prepare_image(data: bytes) -> ImageData:
     Fonction bloquante (calculs sur l'image) : à appeler via asyncio.to_thread.
     """
     try:
-        img = Image.open(io.BytesIO(data))
+        img = Image.open(io.BytesIO(data))  # ne lit que l'en-tête
+        fmt = (img.format or "").upper()
+        baseline_jpeg = fmt in ("JPEG", "MPO") and not img.info.get("progressive")
+        if img.width * img.height > (MAX_JPEG_PIXELS if baseline_jpeg else MAX_PIXELS):
+            raise Image.DecompressionBombError
+        as_is = fmt in _DIRECT_FORMATS and len(data) <= API_MAX_BYTES and max(img.size) <= MAX_DIMENSION
+        original_size, original_dims = len(data), img.size
+        if not as_is:
+            # JPEG : décodé directement à 1/2, 1/4 ou 1/8 de sa taille, sans passer sous la taille
+            # finale (sans effet sur les autres formats).
+            ratio = MAX_DIMENSION / max(img.size)
+            img.draft("RGB", (max(1, int(img.width * ratio)), max(1, int(img.height * ratio))))
         img.load()
     except Image.DecompressionBombError:
         raise ImageError("Cette image est beaucoup trop grande (en pixels) pour que je puisse la lire.")
     except (UnidentifiedImageError, OSError, ValueError):
         raise ImageError("Je n'arrive pas à lire cette image. Formats conseillés : JPEG, PNG, GIF ou WebP.")
 
-    fmt = (img.format or "").upper()
-    if fmt in _DIRECT_FORMATS and len(data) <= API_MAX_BYTES and max(img.size) <= MAX_DIMENSION:
+    if as_is:
         return ImageData(media_type=_DIRECT_FORMATS[fmt], data=data)  # déjà parfaite : envoyée telle quelle
 
-    original_size, original_dims = len(data), img.size
     img.seek(0)                      # GIF ou WebP animé : première image uniquement
-    img = ImageOps.exif_transpose(img)  # respecte l'orientation des photos de téléphone
     if img.mode in ("1", "P"):  # sinon réduite au plus proche voisin, qui abîme le texte
         img = img.convert("RGBA" if "transparency" in img.info else "RGB")
     img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.Resampling.LANCZOS)
+    # Après la réduction : la copie éventuelle est petite. Respecte l'orientation des photos de téléphone.
+    img = ImageOps.exif_transpose(img)
 
     has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
     result: ImageData | None = None

+ 86 - 4
tests/test_attachments_prep.py

@@ -1,16 +1,24 @@
 """Tests de la préparation des pièces jointes (images, PDF, fichiers texte). Lancer depuis la racine du projet : python -m unittest"""
 
+import asyncio
 import codecs
 import io
 import random
+import struct
+import threading
+import time
 import unittest
+import zlib
+from types import SimpleNamespace
 from unittest import mock
 
 from PIL import Image
 from pypdf import PdfWriter
 
-from bot import images
-from bot.images import MAX_DIMENSION, ImageError, prepare_image
+from bot import attachments, images
+from bot.attachments import AttachmentReader
+from bot.images import MAX_DIMENSION, MAX_PIXELS, ImageError, prepare_image
+from bot.media import ImageData
 from bot.pdf_files import PdfError, prepare_pdf
 from bot.text_files import TextFileError, decode_text_file
 
@@ -33,6 +41,14 @@ def opened(data: bytes) -> Image.Image:
     return img
 
 
+def png_header(width: int, height: int) -> bytes:
+    """PNG qui annonce ses dimensions mais ne contient aucun pixel."""
+    def chunk(kind: bytes, content: bytes) -> bytes:
+        return struct.pack(">I", len(content)) + kind + content + struct.pack(">I", zlib.crc32(kind + content))
+    return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
+            + chunk(b"IDAT", b""))
+
+
 class PrepareImageTest(unittest.TestCase):
     def test_small_jpeg_and_png_sent_as_is(self):
         for fmt, media_type in (("JPEG", "image/jpeg"), ("PNG", "image/png"),
@@ -98,12 +114,78 @@ class PrepareImageTest(unittest.TestCase):
                 prepare_image(data)
             self.assertIn("Je n'arrive pas à lire cette image", ctx.exception.user_message)
 
-    def test_too_many_pixels(self):
-        # Seuil abaissé pour ne pas fabriquer une vraie image géante.
+    def test_too_many_pixels_refused_before_decoding(self):
+        # En-tête seul, sans pixels : refusée d'après l'en-tête, sinon le décodage échouerait.
+        with self.assertRaises(ImageError) as ctx:
+            prepare_image(png_header(MAX_PIXELS // 1000 + 1, 1000))
+        self.assertIn("beaucoup trop grande", ctx.exception.user_message)
+        with self.assertRaises(ImageError) as ctx:  # juste à la limite : décodage tenté
+            prepare_image(png_header(MAX_PIXELS // 1000, 1000))
+        self.assertIn("Je n'arrive pas à lire", ctx.exception.user_message)
+
+    def test_pillow_limit_also_refused(self):
         with mock.patch.object(Image, "MAX_IMAGE_PIXELS", 1000), self.assertRaises(ImageError) as ctx:
             prepare_image(encode(Image.new("RGB", (100, 100)), "PNG"))
         self.assertIn("beaucoup trop grande", ctx.exception.user_message)
 
+    def test_baseline_jpeg_allowed_more_pixels(self):
+        # Seuils abaissés pour ne pas fabriquer une vraie image géante.
+        img = Image.new("RGB", (3136, 1000), "green")
+        with mock.patch.object(images, "MAX_PIXELS", 1000):
+            self.assertEqual(opened(prepare_image(encode(img, "JPEG")).data).size, (MAX_DIMENSION, 500))
+            with self.assertRaises(ImageError) as ctx:  # progressif : gardé en entier au décodage
+                prepare_image(encode(img, "JPEG", progressive=True))
+            self.assertIn("beaucoup trop grande", ctx.exception.user_message)
+        with mock.patch.object(images, "MAX_JPEG_PIXELS", 1000), self.assertRaises(ImageError):
+            prepare_image(encode(img, "JPEG"))
+
+    def test_jpeg_decoded_small(self):
+        # Panorama : réduit au décodage d'après son grand côté, pas son petit.
+        decoded_sizes = []
+        thumbnail = Image.Image.thumbnail
+
+        def spy(img, *args, **kwargs):
+            decoded_sizes.append(img.size)
+            return thumbnail(img, *args, **kwargs)
+
+        with mock.patch.object(Image.Image, "thumbnail", autospec=True, side_effect=spy):
+            result = prepare_image(encode(Image.new("RGB", (4 * MAX_DIMENSION, 1000), "green"), "JPEG"))
+        self.assertEqual(decoded_sizes, [(MAX_DIMENSION, 250)])
+        self.assertEqual(opened(result.data).size, (MAX_DIMENSION, 250))
+
+    def test_exif_orientation_applied(self):
+        img = Image.new("RGB", (3136, 1000), "green")
+        exif = img.getexif()
+        exif[0x0112] = 6  # photo prise téléphone tourné : à pivoter d'un quart de tour
+        result = prepare_image(encode(img, "JPEG", exif=exif))
+        self.assertEqual(opened(result.data).size, (500, MAX_DIMENSION))
+
+
+class ImagesAtOnceTest(unittest.IsolatedAsyncioTestCase):
+    async def test_images_prepared_one_at_a_time(self):
+        running = peak = 0
+        lock = threading.Lock()
+
+        def slow_prepare(data: bytes) -> ImageData:
+            nonlocal running, peak
+            with lock:
+                running += 1
+                peak = max(peak, running)
+            time.sleep(0.05)
+            with lock:
+                running -= 1
+            return ImageData("image/png", data)
+
+        reader = AttachmentReader(SimpleNamespace(max_image_size=10_000_000))
+        files = [SimpleNamespace(filename=f"{i}.png", content_type="image/png", size=10,
+                                 read=mock.AsyncMock(return_value=bytes([i])))
+                 for i in range(4)]
+        with mock.patch.object(attachments, "prepare_image", slow_prepare):
+            results = await asyncio.gather(reader.download_images(files[:2]),
+                                           reader.quoted_images(files[2:], "cité"))
+        self.assertEqual(peak, attachments.MAX_IMAGES_AT_ONCE)  # tous messages confondus
+        self.assertEqual([r.data for batch in results for r in batch], [b"\0", b"\1", b"\2", b"\3"])
+
 
 def pdf(pages: int, password: str | None = None, owner_password: str | None = None) -> bytes:
     writer = PdfWriter()