Переглянути джерело

Images : une grande image en gris 16 bits n'arrive plus toute blanche

Pillow ouvre un PNG gris 16 bits en mode I;16 (et un TIFF 32 bits ou
flottant en I ou F). Quand l'image devait être réduite, convert("RGB")
ou convert("L") écrêtait les valeurs au lieu de les remettre à l'échelle :
tout ce qui dépassait 255 devenait blanc, et des flottants entre 0 et 1
devenaient noirs. Claude recevait alors une image vide.

Après la réduction, _to_8_bits ramène ces modes sur 0–255 : 0–65 535
pour I;16, et pour I et F une échelle choisie d'après les valeurs
présentes (0–1, 0–255, 0–65 535, sinon minimum–maximum), avec arrondi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 1 день тому
батько
коміт
8990138695
3 змінених файлів з 38 додано та 0 видалено
  1. 2 0
      CHANGELOG.md
  2. 25 0
      bot/images.py
  3. 11 0
      tests/test_attachments_prep.py

+ 2 - 0
CHANGELOG.md

@@ -18,6 +18,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **Les PDF protégés seulement contre la modification sont lus.** Un PDF chiffré en AES sans mot de passe d'ouverture (courant avec Acrobat ou LibreOffice) était refusé comme « illisible ». Il est maintenant lu, grâce à la nouvelle dépendance `cryptography` installée avec les autres.
 
+- **Les grandes images en gris 16 bits ne deviennent plus toutes blanches.** Un scan, une radio ou une photo d'astronomie en PNG ou TIFF 16 bits (ou 32 bits), une fois réduit, arrivait tout blanc ou tout noir chez Claude. Ses gris sont maintenant ramenés sur 8 bits sans être écrêtés.
+
 ---
 
 ## Version 5.12

+ 25 - 0
bot/images.py

@@ -69,6 +69,8 @@ def prepare_image(data: bytes) -> ImageData:
     img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.Resampling.LANCZOS)
     # Après la réduction : la copie éventuelle est petite. Respecte l'orientation des photos de téléphone.
     img = ImageOps.exif_transpose(img)
+    if img.mode.startswith("I") or img.mode == "F":
+        img = _to_8_bits(img)
 
     has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
     result: ImageData | None = None
@@ -103,6 +105,29 @@ def prepare_image(data: bytes) -> ImageData:
     return result
 
 
+def _to_8_bits(img: Image.Image) -> Image.Image:
+    """Ramène une image en gris 16 bits, entière ou flottante (scans, radios, astronomie) sur 8 bits.
+
+    Les conversions de Pillow écrêtent au lieu de remettre à l'échelle : tout ce qui dépasse 255
+    deviendrait blanc. L'échelle dépend des valeurs présentes : 0–255 gardé tel quel, 0–1 (flottants),
+    0–65 535 (16 bits), et au-delà, de la plus petite à la plus grande valeur.
+    """
+    if img.mode.startswith("I;16"):
+        low, high = 0, 65535
+    else:
+        img = img.convert("F")
+        low, high = img.getextrema()
+        if low >= 0 and high <= 1:
+            low, high = 0, 1
+        elif low >= 0 and high <= 255:
+            low, high = 0, 255
+        elif low >= 0 and high <= 65535:
+            low, high = 0, 65535
+    img = img.convert("F")
+    scale = 255 / (high - low) if high > low else 0
+    return img.point(lambda v: (v - low) * scale + 0.5).convert("L")  # + 0.5 : arrondi, pas troncature
+
+
 def _encode(img: Image.Image, fmt: str, **options) -> bytes:
     buffer = io.BytesIO()
     img.save(buffer, format=fmt, **options)

+ 11 - 0
tests/test_attachments_prep.py

@@ -82,6 +82,17 @@ class PrepareImageTest(unittest.TestCase):
                 img = opened(result.data)
                 self.assertEqual((img.format, img.size), ("PNG", (200, 100)))
 
+    def test_high_bit_depth_grey_rescaled(self):
+        # Gris 16 bits (scan, radio, astronomie) : ramené sur 8 bits, pas écrêté en blanc.
+        cases = (("PNG", Image.new("I;16", (2000, 1000), 20000), 78),   # 20000/65535 ≈ 78/255
+                 ("TIFF", Image.new("I", (200, 100), 20000), 78),       # entiers dans la plage 16 bits
+                 ("TIFF", Image.new("F", (200, 100), 0.5), 128),        # flottants entre 0 et 1
+                 ("TIFF", Image.new("F", (200, 100), 100.0), 100))      # déjà dans la plage 8 bits
+        for fmt, img, expected in cases:
+            with self.subTest(fmt=fmt, mode=img.mode, value=img.getpixel((0, 0))):
+                result = opened(prepare_image(encode(img, fmt)).data)
+                self.assertEqual(result.convert("L").getpixel((0, 0)), expected)
+
     def test_transparency_kept(self):
         result = prepare_image(encode(Image.new("RGBA", (3000, 100), (255, 0, 0, 0)), "PNG"))
         self.assertEqual(result.media_type, "image/png")