1
0

images.py 11 KB

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