1
0

images.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  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, ImageMath, 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. # Image en flottants : une valeur infinie, NaN ou plus grande que ceci (en valeur absolue) n'est pas une
  27. # mesure mais un trou (zone « sans donnée » d'une carte, souvent ±3,4e38). Assez haut pour garder les vraies
  28. # valeurs géantes (densités de colonne en astronomie, autour de 1e21).
  29. _FLOAT_HOLE = 1e30
  30. # Valeur « manquante » standard des données climatiques (modèles CMIP) : un trou aussi, mais seulement pile
  31. # (une mesure n'y tombe pratiquement jamais au chiffre près).
  32. _FLOAT_FILL = 1e20
  33. _FLOAT32_MAX = 3.4e38
  34. # Recherche des trous par bandes d'environ ce nombre de pixels : jamais une copie entière d'une grande image.
  35. _STRIP_PIXELS = 1_000_000
  36. class ImageError(UserFacingError):
  37. """Image illisible ou impossible à préparer, avec un message affichable sur Discord."""
  38. def prepare_image(data: bytes) -> ImageData:
  39. """Renvoie une image acceptée par l'API, en la réduisant ou la convertissant si besoin.
  40. Fonction bloquante (calculs sur l'image) : à appeler via asyncio.to_thread.
  41. """
  42. try:
  43. img = Image.open(io.BytesIO(data)) # ne lit que l'en-tête
  44. fmt = (img.format or "").upper()
  45. baseline_jpeg = fmt in ("JPEG", "MPO") and not img.info.get("progressive")
  46. if img.width * img.height > (MAX_JPEG_PIXELS if baseline_jpeg else MAX_PIXELS):
  47. raise Image.DecompressionBombError
  48. as_is = fmt in _DIRECT_FORMATS and len(data) <= API_MAX_BYTES and max(img.size) <= MAX_DIMENSION
  49. original_size, original_dims = len(data), img.size
  50. if not as_is:
  51. # JPEG : décodé directement à 1/2, 1/4 ou 1/8 de sa taille, sans passer sous la taille
  52. # finale (sans effet sur les autres formats).
  53. ratio = MAX_DIMENSION / max(img.size)
  54. img.draft("RGB", (max(1, int(img.width * ratio)), max(1, int(img.height * ratio))))
  55. img.load()
  56. except Image.DecompressionBombError:
  57. raise ImageError("Cette image est beaucoup trop grande (en pixels) pour que je puisse la lire.")
  58. except (UnidentifiedImageError, OSError, ValueError):
  59. raise ImageError("Je n'arrive pas à lire cette image. Formats conseillés : JPEG, PNG, GIF ou WebP.")
  60. if as_is:
  61. return ImageData(media_type=_DIRECT_FORMATS[fmt], data=data) # déjà parfaite : envoyée telle quelle
  62. img.seek(0) # GIF ou WebP animé : première image uniquement
  63. if img.mode in ("1", "P"): # sinon réduite au plus proche voisin, qui abîme le texte
  64. img = img.convert("RGBA" if "transparency" in img.info else "RGB")
  65. filled = _fill_float_holes(img) if img.mode == "F" else None
  66. img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.Resampling.LANCZOS)
  67. if filled is not None:
  68. img = _restore_float_holes(img, *filled)
  69. # Après la réduction : la copie éventuelle est petite. Respecte l'orientation des photos de téléphone.
  70. img = ImageOps.exif_transpose(img)
  71. if img.mode.startswith("I") or img.mode == "F":
  72. img = _to_8_bits(img)
  73. has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
  74. result: ImageData | None = None
  75. # PNG si la taille le permet : garde la transparence, et le texte net des captures d'écran
  76. # (le JPEG le brouille). Les photos, déjà en JPEG, passent directement au JPEG.
  77. if has_alpha or fmt in _LOSSLESS_FORMATS:
  78. if has_alpha:
  79. png = _encode(img.convert("RGBA"), "PNG", optimize=True)
  80. else:
  81. png = _encode(img if img.mode in _PNG_MODES else img.convert("RGB"), "PNG", optimize=True)
  82. if len(png) <= API_MAX_BYTES:
  83. result = ImageData("image/png", png)
  84. elif has_alpha: # sinon fond blanc + JPEG
  85. background = Image.new("RGB", img.size, "white")
  86. background.paste(img.convert("RGBA"), mask=img.convert("RGBA").getchannel("A"))
  87. img = background
  88. if result is None:
  89. rgb = img.convert("RGB")
  90. for quality in (85, 75, 60, 45):
  91. jpeg = _encode(rgb, "JPEG", quality=quality, optimize=True)
  92. if len(jpeg) <= API_MAX_BYTES:
  93. result = ImageData("image/jpeg", jpeg)
  94. break
  95. else:
  96. raise ImageError("Je n'ai pas réussi à compresser suffisamment cette image.")
  97. logger.info("Image adaptée : %s %dx%d (%.1f Mo) → %s %dx%d (%.1f Mo)",
  98. fmt or "?", *original_dims, original_size / 1e6,
  99. result.media_type, *img.size, len(result.data) / 1e6)
  100. return result
  101. def _float_measures(img: Image.Image) -> Image.Image:
  102. """Masque d'une image en flottants : 255 pour une mesure, 0 pour un trou (NaN : la comparaison est fausse).
  103. Calculé par Pillow : lire les pixels un par un en Python serait lent et gourmand en mémoire.
  104. """
  105. return ImageMath.lambda_eval(
  106. lambda a: (abs(a["img"]) < _FLOAT_HOLE) * a["notequal"](a["img"], _FLOAT_FILL) * 255, img=img
  107. ).convert("L")
  108. def _fill_float_holes(img: Image.Image) -> tuple[Image.Image, float | None, float | None] | None:
  109. """Bouche les trous d'une image en flottants avant sa réduction.
  110. Réduits tels quels, les trous laisseraient autour d'eux des traces (lobes du filtre LANCZOS) de toutes
  111. tailles, jusqu'à quelques dizaines : impossibles à distinguer des mesures, elles fausseraient les gris.
  112. Ils sont remplacés par la plus petite mesure (bord sombre, comme le noir des trous). Parcours par bandes
  113. de lignes : chaque calcul copie la bande, et une copie entière d'une grande image pèserait des centaines
  114. de Mo. Renvoie le masque des trous (255 = trou) et la plus petite et la plus grande mesure (None s'il n'y
  115. en a aucune), ou None s'il n'y a aucun trou (l'image n'est alors pas modifiée).
  116. """
  117. holes = None
  118. lowest = highest = None
  119. rows = max(1, _STRIP_PIXELS // img.width)
  120. for top in range(0, img.height, rows):
  121. strip = img.crop((0, top, img.width, min(top + rows, img.height)))
  122. measures = _float_measures(strip)
  123. fewest, most = measures.getextrema()
  124. if fewest == 255: # aucun trou dans cette bande
  125. strip_low, strip_high = strip.getextrema()
  126. else:
  127. if holes is None:
  128. holes = Image.new("L", img.size, 0)
  129. strip_holes = ImageOps.invert(measures)
  130. holes.paste(strip_holes, (0, top))
  131. if most == 0: # que des trous
  132. continue
  133. # Trous ignorés : remplacés par le plus grand flottant pour le minimum, le plus petit pour le maximum.
  134. strip.paste(_FLOAT32_MAX, mask=strip_holes)
  135. strip_low = strip.getextrema()[0]
  136. strip.paste(-_FLOAT32_MAX, mask=strip_holes)
  137. strip_high = strip.getextrema()[1]
  138. lowest = strip_low if lowest is None else min(lowest, strip_low)
  139. highest = strip_high if highest is None else max(highest, strip_high)
  140. if holes is None:
  141. return None
  142. img.paste(0.0 if lowest is None else lowest, mask=holes)
  143. return holes, lowest, highest
  144. def _restore_float_holes(img: Image.Image, holes: Image.Image, lowest: float | None,
  145. highest: float | None) -> Image.Image:
  146. """Remet dans l'image réduite les trous bouchés par _fill_float_holes, en NaN (_to_8_bits les rend noirs).
  147. Le masque des trous est réduit par moyenne (part de trous de chaque pixel, sans rebond) : un pixel surtout
  148. fait de trous en redevient un. Autour des trous, le filtre LANCZOS peut faire déborder les valeurs
  149. au-delà des mesures : l'image est ramenée entre la plus petite et la plus grande mesure d'origine (aucune
  150. vraie mesure n'en sort), pour ne pas fausser l'échelle des gris.
  151. """
  152. holes = holes.resize(img.size, Image.Resampling.BOX)
  153. if lowest is not None:
  154. img = ImageMath.lambda_eval(lambda a: a["min"](a["max"](a["img"], lowest), highest), img=img)
  155. img.paste(float("nan"), mask=holes.point(lambda v: 255 if v > 127 else 0))
  156. return img
  157. def _to_8_bits(img: Image.Image) -> Image.Image:
  158. """Ramène une image en gris 16 bits, entière ou flottante (scans, radios, astronomie) sur 8 bits.
  159. Les conversions de Pillow écrêtent au lieu de remettre à l'échelle : tout ce qui dépasse 255
  160. deviendrait blanc. L'échelle dépend des valeurs présentes : 0–255 gardé tel quel, 0–1 (flottants),
  161. 0–65 535 (entiers 16 bits), sinon de la plus petite à la plus grande valeur ; gris moyen si l'image
  162. est uniforme. Les trous d'une image flottante (voir _FLOAT_HOLE) ne comptent pas et deviennent noirs.
  163. """
  164. holes = None
  165. if img.mode.startswith("I;16"):
  166. low, high = 0, 65535
  167. else:
  168. if img.mode == "F":
  169. measures = _float_measures(img)
  170. fewest, most = measures.getextrema()
  171. if fewest == 255: # aucun trou
  172. low, high = img.getextrema()
  173. elif most == 0: # que des trous : tout noir
  174. holes = ImageOps.invert(measures)
  175. low, high = 0, 0
  176. else:
  177. holes = ImageOps.invert(measures)
  178. # Bornes des seules mesures : les trous remplacés par le plus grand flottant pour le
  179. # minimum, par le plus petit pour le maximum.
  180. low_side, high_side = img.copy(), img.copy()
  181. low_side.paste(_FLOAT32_MAX, mask=holes)
  182. high_side.paste(-_FLOAT32_MAX, mask=holes)
  183. low, high = low_side.getextrema()[0], high_side.getextrema()[1]
  184. else:
  185. low, high = img.getextrema()
  186. if low >= 0 and high <= 1:
  187. low, high = 0, 1
  188. elif low >= 0 and high <= 255:
  189. low, high = 0, 255
  190. elif low >= 0 and high <= 65535 and img.mode != "F": # flottants : mètres, degrés…, pas 16 bits
  191. low, high = 0, 65535
  192. elif high == low: # image uniforme hors de ces plages : gris moyen, pas noir
  193. # Écart proportionnel à la valeur : ajouter 1 à un très grand flottant ne le change plus.
  194. margin = max(1.0, abs(low))
  195. low, high = low - margin, high + margin
  196. img = img.convert("F")
  197. scale = 255 / (high - low)
  198. result = img.point(lambda v: (v - low) * scale + 0.5).convert("L") # + 0.5 : arrondi, pas troncature
  199. if holes:
  200. result.paste(0, mask=holes)
  201. return result
  202. def _encode(img: Image.Image, fmt: str, **options) -> bytes:
  203. buffer = io.BytesIO()
  204. img.save(buffer, format=fmt, **options)
  205. return buffer.getvalue()