1
0

images.py 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  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 .claude_client import ImageData
  7. logger = logging.getLogger("chatbot.images")
  8. # Claude redimensionne lui-même toute image dont le grand côté dépasse ~1568 px :
  9. # envoyer plus grand ne lui apporte rien.
  10. MAX_DIMENSION = 1568
  11. # Limite de l'API : 5 Mo une fois encodé en base64, soit environ 3,75 Mo bruts.
  12. API_MAX_BYTES = 3_750_000
  13. # Protection contre les images piégées (des milliards de pixels qui saturent la mémoire).
  14. Image.MAX_IMAGE_PIXELS = 80_000_000
  15. _DIRECT_FORMATS = {"JPEG": "image/jpeg", "PNG": "image/png", "GIF": "image/gif", "WEBP": "image/webp"}
  16. class ImageError(Exception):
  17. """Image illisible ou impossible à préparer, avec un message affichable sur Discord."""
  18. def __init__(self, user_message: str):
  19. super().__init__(user_message)
  20. self.user_message = user_message
  21. def prepare_image(data: bytes) -> ImageData:
  22. """Renvoie une image acceptée par l'API, en la réduisant ou la convertissant si besoin.
  23. Fonction bloquante (calculs sur l'image) : à appeler via asyncio.to_thread.
  24. """
  25. try:
  26. img = Image.open(io.BytesIO(data))
  27. img.load()
  28. except Image.DecompressionBombError:
  29. raise ImageError("Cette image est beaucoup trop grande (en pixels) pour que je puisse la lire.")
  30. except (UnidentifiedImageError, OSError, ValueError):
  31. raise ImageError("Je n'arrive pas à lire cette image. Formats conseillés : JPEG, PNG, GIF ou WebP.")
  32. fmt = (img.format or "").upper()
  33. if fmt in _DIRECT_FORMATS and len(data) <= API_MAX_BYTES and max(img.size) <= MAX_DIMENSION:
  34. return ImageData(media_type=_DIRECT_FORMATS[fmt], data=data) # déjà parfaite : envoyée telle quelle
  35. original_size, original_dims = len(data), img.size
  36. img.seek(0) # GIF ou WebP animé : première image uniquement
  37. img = ImageOps.exif_transpose(img) # respecte l'orientation des photos de téléphone
  38. img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.Resampling.LANCZOS)
  39. has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
  40. result: ImageData | None = None
  41. if has_alpha: # garde la transparence en PNG si la taille le permet
  42. png = _encode(img.convert("RGBA"), "PNG", optimize=True)
  43. if len(png) <= API_MAX_BYTES:
  44. result = ImageData("image/png", png)
  45. else: # sinon fond blanc + JPEG
  46. background = Image.new("RGB", img.size, "white")
  47. background.paste(img.convert("RGBA"), mask=img.convert("RGBA").getchannel("A"))
  48. img = background
  49. if result is None:
  50. rgb = img.convert("RGB")
  51. for quality in (85, 75, 60, 45):
  52. jpeg = _encode(rgb, "JPEG", quality=quality, optimize=True)
  53. if len(jpeg) <= API_MAX_BYTES:
  54. result = ImageData("image/jpeg", jpeg)
  55. break
  56. else:
  57. raise ImageError("Je n'ai pas réussi à compresser suffisamment cette image.")
  58. logger.info("Image adaptée : %s %dx%d (%.1f Mo) → %s %dx%d (%.1f Mo)",
  59. fmt or "?", *original_dims, original_size / 1e6,
  60. result.media_type, *img.size, len(result.data) / 1e6)
  61. return result
  62. def _encode(img: Image.Image, fmt: str, **options) -> bytes:
  63. buffer = io.BytesIO()
  64. img.save(buffer, format=fmt, **options)
  65. return buffer.getvalue()