images.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  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 array import array
  6. from PIL import Image, ImageOps, UnidentifiedImageError
  7. from .errors import UserFacingError
  8. from .media import ImageData
  9. logger = logging.getLogger("chatbot.images")
  10. # Claude redimensionne lui-même toute image dont le grand côté dépasse ~1568 px :
  11. # envoyer plus grand ne lui apporte rien.
  12. MAX_DIMENSION = 1568
  13. # Limite de l'API : 5 Mo une fois encodé en base64, soit environ 3,75 Mo bruts.
  14. API_MAX_BYTES = 3_750_000
  15. # Protection contre les images piégées : un petit fichier peut cacher des centaines de millions
  16. # de pixels, qui saturent la mémoire une fois décodés. Refusées dès la lecture de l'en-tête,
  17. # avant tout décodage (Pillow, lui, ne refuse qu'au double de sa limite et avertit seulement entre les deux).
  18. MAX_PIXELS = 40_000_000
  19. # JPEG classique (non progressif) : décodé directement en petit, il coûte peu même très grand
  20. # (photos de 50 à 108 Mpx des téléphones récents). Un JPEG progressif, lui, est gardé en entier.
  21. MAX_JPEG_PIXELS = 120_000_000
  22. Image.MAX_IMAGE_PIXELS = MAX_JPEG_PIXELS
  23. _DIRECT_FORMATS = {"JPEG": "image/jpeg", "PNG": "image/png", "GIF": "image/gif", "WEBP": "image/webp"}
  24. # Formats sans perte (captures d'écran, dessins…) : PNG essayé avant le JPEG.
  25. _LOSSLESS_FORMATS = {"PNG", "GIF", "BMP", "TIFF"}
  26. _PNG_MODES = ("L", "RGB") # modes sans transparence enregistrables tels quels en PNG
  27. # Image en flottants : une valeur infinie, NaN ou plus grande que ceci (en valeur absolue) n'est pas une
  28. # mesure mais un trou (zone « sans donnée » d'une carte, souvent ±3,4e38) ou sa trace après la réduction.
  29. _FLOAT_HOLE = 1e20
  30. class ImageError(UserFacingError):
  31. """Image illisible ou impossible à préparer, avec un message affichable sur Discord."""
  32. def prepare_image(data: bytes) -> ImageData:
  33. """Renvoie une image acceptée par l'API, en la réduisant ou la convertissant si besoin.
  34. Fonction bloquante (calculs sur l'image) : à appeler via asyncio.to_thread.
  35. """
  36. try:
  37. img = Image.open(io.BytesIO(data)) # ne lit que l'en-tête
  38. fmt = (img.format or "").upper()
  39. baseline_jpeg = fmt in ("JPEG", "MPO") and not img.info.get("progressive")
  40. if img.width * img.height > (MAX_JPEG_PIXELS if baseline_jpeg else MAX_PIXELS):
  41. raise Image.DecompressionBombError
  42. as_is = fmt in _DIRECT_FORMATS and len(data) <= API_MAX_BYTES and max(img.size) <= MAX_DIMENSION
  43. original_size, original_dims = len(data), img.size
  44. if not as_is:
  45. # JPEG : décodé directement à 1/2, 1/4 ou 1/8 de sa taille, sans passer sous la taille
  46. # finale (sans effet sur les autres formats).
  47. ratio = MAX_DIMENSION / max(img.size)
  48. img.draft("RGB", (max(1, int(img.width * ratio)), max(1, int(img.height * ratio))))
  49. img.load()
  50. except Image.DecompressionBombError:
  51. raise ImageError("Cette image est beaucoup trop grande (en pixels) pour que je puisse la lire.")
  52. except (UnidentifiedImageError, OSError, ValueError):
  53. raise ImageError("Je n'arrive pas à lire cette image. Formats conseillés : JPEG, PNG, GIF ou WebP.")
  54. if as_is:
  55. return ImageData(media_type=_DIRECT_FORMATS[fmt], data=data) # déjà parfaite : envoyée telle quelle
  56. img.seek(0) # GIF ou WebP animé : première image uniquement
  57. if img.mode in ("1", "P"): # sinon réduite au plus proche voisin, qui abîme le texte
  58. img = img.convert("RGBA" if "transparency" in img.info else "RGB")
  59. img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.Resampling.LANCZOS)
  60. # Après la réduction : la copie éventuelle est petite. Respecte l'orientation des photos de téléphone.
  61. img = ImageOps.exif_transpose(img)
  62. if img.mode.startswith("I") or img.mode == "F":
  63. img = _to_8_bits(img)
  64. has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
  65. result: ImageData | None = None
  66. # PNG si la taille le permet : garde la transparence, et le texte net des captures d'écran
  67. # (le JPEG le brouille). Les photos, déjà en JPEG, passent directement au JPEG.
  68. if has_alpha or fmt in _LOSSLESS_FORMATS:
  69. if has_alpha:
  70. png = _encode(img.convert("RGBA"), "PNG", optimize=True)
  71. else:
  72. png = _encode(img if img.mode in _PNG_MODES else img.convert("RGB"), "PNG", optimize=True)
  73. if len(png) <= API_MAX_BYTES:
  74. result = ImageData("image/png", png)
  75. elif has_alpha: # sinon fond blanc + JPEG
  76. background = Image.new("RGB", img.size, "white")
  77. background.paste(img.convert("RGBA"), mask=img.convert("RGBA").getchannel("A"))
  78. img = background
  79. if result is None:
  80. rgb = img.convert("RGB")
  81. for quality in (85, 75, 60, 45):
  82. jpeg = _encode(rgb, "JPEG", quality=quality, optimize=True)
  83. if len(jpeg) <= API_MAX_BYTES:
  84. result = ImageData("image/jpeg", jpeg)
  85. break
  86. else:
  87. raise ImageError("Je n'ai pas réussi à compresser suffisamment cette image.")
  88. logger.info("Image adaptée : %s %dx%d (%.1f Mo) → %s %dx%d (%.1f Mo)",
  89. fmt or "?", *original_dims, original_size / 1e6,
  90. result.media_type, *img.size, len(result.data) / 1e6)
  91. return result
  92. def _to_8_bits(img: Image.Image) -> Image.Image:
  93. """Ramène une image en gris 16 bits, entière ou flottante (scans, radios, astronomie) sur 8 bits.
  94. Les conversions de Pillow écrêtent au lieu de remettre à l'échelle : tout ce qui dépasse 255
  95. deviendrait blanc. L'échelle dépend des valeurs présentes : 0–255 gardé tel quel, 0–1 (flottants),
  96. 0–65 535 (entiers 16 bits), sinon de la plus petite à la plus grande valeur ; gris moyen si l'image
  97. est uniforme. Les trous d'une image flottante (voir _FLOAT_HOLE) ne comptent pas et deviennent noirs.
  98. """
  99. holes = None
  100. if img.mode.startswith("I;16"):
  101. low, high = 0, 65535
  102. else:
  103. if img.mode == "F":
  104. values = array("f", img.tobytes()) # mode F : float32 natif
  105. valid = [v for v in values if -_FLOAT_HOLE < v < _FLOAT_HOLE] # NaN : comparaisons fausses
  106. if len(valid) < len(values):
  107. holes = Image.frombytes("L", img.size, bytes(0 if -_FLOAT_HOLE < v < _FLOAT_HOLE else 255
  108. for v in values))
  109. low, high = (min(valid), max(valid)) if valid else (0, 0)
  110. else:
  111. low, high = img.getextrema()
  112. if low >= 0 and high <= 1:
  113. low, high = 0, 1
  114. elif low >= 0 and high <= 255:
  115. low, high = 0, 255
  116. elif low >= 0 and high <= 65535 and img.mode != "F": # flottants : mètres, degrés…, pas 16 bits
  117. low, high = 0, 65535
  118. elif high == low: # image uniforme hors de ces plages : gris moyen, pas noir
  119. low, high = low - 1, high + 1
  120. img = img.convert("F")
  121. scale = 255 / (high - low)
  122. result = img.point(lambda v: (v - low) * scale + 0.5).convert("L") # + 0.5 : arrondi, pas troncature
  123. if holes:
  124. result.paste(0, mask=holes)
  125. return result
  126. def _encode(img: Image.Image, fmt: str, **options) -> bytes:
  127. buffer = io.BytesIO()
  128. img.save(buffer, format=fmt, **options)
  129. return buffer.getvalue()