1
0

images.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. """Préparation des images avant l'envoi à Claude (redimensionnement et compression)."""
  2. from __future__ import annotations
  3. import io
  4. import logging
  5. from PIL import Image, ImageOps, UnidentifiedImageError
  6. from .errors import UserFacingError
  7. from .media import ImageData
  8. logger = logging.getLogger("chatbot.images")
  9. # Claude redimensionne lui-même toute image dont le grand côté dépasse ~1568 px :
  10. # envoyer plus grand ne lui apporte rien.
  11. MAX_DIMENSION = 1568
  12. # Limite de l'API : 5 Mo une fois encodé en base64, soit environ 3,75 Mo bruts.
  13. API_MAX_BYTES = 3_750_000
  14. # Protection contre les images piégées : un petit fichier peut cacher des centaines de millions
  15. # de pixels, qui saturent la mémoire une fois décodés. Refusées dès la lecture de l'en-tête,
  16. # avant tout décodage (Pillow, lui, ne refuse qu'au double de sa limite et avertit seulement entre les deux).
  17. MAX_PIXELS = 40_000_000
  18. # JPEG classique (non progressif) : décodé directement en petit, il coûte peu même très grand
  19. # (photos de 50 à 108 Mpx des téléphones récents). Un JPEG progressif, lui, est gardé en entier.
  20. MAX_JPEG_PIXELS = 120_000_000
  21. Image.MAX_IMAGE_PIXELS = MAX_JPEG_PIXELS
  22. _DIRECT_FORMATS = {"JPEG": "image/jpeg", "PNG": "image/png", "GIF": "image/gif", "WEBP": "image/webp"}
  23. # Formats sans perte (captures d'écran, dessins…) : PNG essayé avant le JPEG.
  24. _LOSSLESS_FORMATS = {"PNG", "GIF", "BMP", "TIFF"}
  25. _PNG_MODES = ("L", "RGB") # modes sans transparence enregistrables tels quels en PNG
  26. class ImageError(UserFacingError):
  27. """Image illisible ou impossible à préparer, avec un message affichable sur Discord."""
  28. def prepare_image(data: bytes) -> ImageData:
  29. """Renvoie une image acceptée par l'API, en la réduisant ou la convertissant si besoin.
  30. Fonction bloquante (calculs sur l'image) : à appeler via asyncio.to_thread.
  31. """
  32. try:
  33. img = Image.open(io.BytesIO(data)) # ne lit que l'en-tête
  34. fmt = (img.format or "").upper()
  35. baseline_jpeg = fmt in ("JPEG", "MPO") and not img.info.get("progressive")
  36. if img.width * img.height > (MAX_JPEG_PIXELS if baseline_jpeg else MAX_PIXELS):
  37. raise Image.DecompressionBombError
  38. as_is = fmt in _DIRECT_FORMATS and len(data) <= API_MAX_BYTES and max(img.size) <= MAX_DIMENSION
  39. original_size, original_dims = len(data), img.size
  40. if not as_is:
  41. # JPEG : décodé directement à 1/2, 1/4 ou 1/8 de sa taille, sans passer sous la taille
  42. # finale (sans effet sur les autres formats).
  43. ratio = MAX_DIMENSION / max(img.size)
  44. img.draft("RGB", (max(1, int(img.width * ratio)), max(1, int(img.height * ratio))))
  45. img.load()
  46. except Image.DecompressionBombError:
  47. raise ImageError("Cette image est beaucoup trop grande (en pixels) pour que je puisse la lire.")
  48. except (UnidentifiedImageError, OSError, ValueError):
  49. raise ImageError("Je n'arrive pas à lire cette image. Formats conseillés : JPEG, PNG, GIF ou WebP.")
  50. if as_is:
  51. return ImageData(media_type=_DIRECT_FORMATS[fmt], data=data) # déjà parfaite : envoyée telle quelle
  52. img.seek(0) # GIF ou WebP animé : première image uniquement
  53. if img.mode in ("1", "P"): # sinon réduite au plus proche voisin, qui abîme le texte
  54. img = img.convert("RGBA" if "transparency" in img.info else "RGB")
  55. img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.Resampling.LANCZOS)
  56. # Après la réduction : la copie éventuelle est petite. Respecte l'orientation des photos de téléphone.
  57. img = ImageOps.exif_transpose(img)
  58. has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
  59. result: ImageData | None = None
  60. # PNG si la taille le permet : garde la transparence, et le texte net des captures d'écran
  61. # (le JPEG le brouille). Les photos, déjà en JPEG, passent directement au JPEG.
  62. if has_alpha or fmt in _LOSSLESS_FORMATS:
  63. if has_alpha:
  64. png = _encode(img.convert("RGBA"), "PNG", optimize=True)
  65. else:
  66. png = _encode(img if img.mode in _PNG_MODES else img.convert("RGB"), "PNG", optimize=True)
  67. if len(png) <= API_MAX_BYTES:
  68. result = ImageData("image/png", png)
  69. elif has_alpha: # sinon fond blanc + JPEG
  70. background = Image.new("RGB", img.size, "white")
  71. background.paste(img.convert("RGBA"), mask=img.convert("RGBA").getchannel("A"))
  72. img = background
  73. if result is None:
  74. rgb = img.convert("RGB")
  75. for quality in (85, 75, 60, 45):
  76. jpeg = _encode(rgb, "JPEG", quality=quality, optimize=True)
  77. if len(jpeg) <= API_MAX_BYTES:
  78. result = ImageData("image/jpeg", jpeg)
  79. break
  80. else:
  81. raise ImageError("Je n'ai pas réussi à compresser suffisamment cette image.")
  82. logger.info("Image adaptée : %s %dx%d (%.1f Mo) → %s %dx%d (%.1f Mo)",
  83. fmt or "?", *original_dims, original_size / 1e6,
  84. result.media_type, *img.size, len(result.data) / 1e6)
  85. return result
  86. def _encode(img: Image.Image, fmt: str, **options) -> bytes:
  87. buffer = io.BytesIO()
  88. img.save(buffer, format=fmt, **options)
  89. return buffer.getvalue()