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

Images : les trous d'une image en flottants ne faussent plus ses gris

Suite de la conversion sur 8 bits des images en gris 16 ou 32 bits :

- Une image en flottants peut marquer ses zones sans donnée par NaN,
  l'infini ou un nombre géant (souvent -3,4e38, courant dans les cartes
  d'altitude). Ces valeurs entraient dans l'échelle : l'image arrivait
  toute grise ou toute noire. Toute valeur non finie ou au-delà de 1e20
  en valeur absolue est maintenant un trou : ignorée pour l'échelle et
  rendue en noir. Le seuil attrape aussi les traces que la réduction
  LANCZOS laisse autour d'un trou (mesurées au-dessus de 1e36).
- La plage 0–65 535 est réservée aux entiers : une carte en flottants
  de 120 à 850 m arrivait presque noire. Les flottants hors de 0–1 et
  0–255 prennent leur échelle de la plus petite à la plus grande valeur.
- Une image uniforme hors des plages connues arrive en gris moyen et
  non plus en noir, désormais réservé aux trous.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 1 день тому
батько
коміт
4aa60a94b9
2 змінених файлів з 46 додано та 6 видалено
  1. 24 6
      bot/images.py
  2. 22 0
      tests/test_attachments_prep.py

+ 24 - 6
bot/images.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 
 import io
 import logging
+from array import array
 
 from PIL import Image, ImageOps, UnidentifiedImageError
 
@@ -30,6 +31,9 @@ _DIRECT_FORMATS = {"JPEG": "image/jpeg", "PNG": "image/png", "GIF": "image/gif",
 # Formats sans perte (captures d'écran, dessins…) : PNG essayé avant le JPEG.
 _LOSSLESS_FORMATS = {"PNG", "GIF", "BMP", "TIFF"}
 _PNG_MODES = ("L", "RGB")  # modes sans transparence enregistrables tels quels en PNG
+# Image en flottants : une valeur infinie, NaN ou plus grande que ceci (en valeur absolue) n'est pas une
+# mesure mais un trou (zone « sans donnée » d'une carte, souvent ±3,4e38) ou sa trace après la réduction.
+_FLOAT_HOLE = 1e20
 
 
 class ImageError(UserFacingError):
@@ -110,22 +114,36 @@ def _to_8_bits(img: Image.Image) -> Image.Image:
 
     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.
+    0–65 535 (entiers 16 bits), sinon de la plus petite à la plus grande valeur ; gris moyen si l'image
+    est uniforme. Les trous d'une image flottante (voir _FLOAT_HOLE) ne comptent pas et deviennent noirs.
     """
+    holes = None
     if img.mode.startswith("I;16"):
         low, high = 0, 65535
     else:
-        img = img.convert("F")
-        low, high = img.getextrema()
+        if img.mode == "F":
+            values = array("f", img.tobytes())  # mode F : float32 natif
+            valid = [v for v in values if -_FLOAT_HOLE < v < _FLOAT_HOLE]  # NaN : comparaisons fausses
+            if len(valid) < len(values):
+                holes = Image.frombytes("L", img.size, bytes(0 if -_FLOAT_HOLE < v < _FLOAT_HOLE else 255
+                                                             for v in values))
+            low, high = (min(valid), max(valid)) if valid else (0, 0)
+        else:
+            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:
+        elif low >= 0 and high <= 65535 and img.mode != "F":  # flottants : mètres, degrés…, pas 16 bits
             low, high = 0, 65535
+        elif high == low:  # image uniforme hors de ces plages : gris moyen, pas noir
+            low, high = low - 1, high + 1
     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
+    scale = 255 / (high - low)
+    result = img.point(lambda v: (v - low) * scale + 0.5).convert("L")  # + 0.5 : arrondi, pas troncature
+    if holes:
+        result.paste(0, mask=holes)
+    return result
 
 
 def _encode(img: Image.Image, fmt: str, **options) -> bytes:

+ 22 - 0
tests/test_attachments_prep.py

@@ -93,6 +93,28 @@ class PrepareImageTest(unittest.TestCase):
                 result = opened(prepare_image(encode(img, fmt)).data)
                 self.assertEqual(result.convert("L").getpixel((0, 0)), expected)
 
+    def test_float_holes_ignored(self):
+        # Carte d'altitude (120 à 850 m) dont un tiers est un trou : le trou devient noir, sans écraser
+        # l'échelle du reste, étalée de la plus basse à la plus haute altitude (pas lue comme du 16 bits).
+        for hole in (-3.4e38, float("inf"), float("-inf"), float("nan")):
+            with self.subTest(hole=hole):
+                img = Image.new("F", (3000, 100), 485.0)
+                img.paste(850.0, (1000, 0, 1500, 100))
+                img.paste(120.0, (2500, 0, 3000, 100))
+                img.paste(hole, (0, 0, 1000, 100))
+                result = opened(prepare_image(encode(img, "TIFF")).data).convert("L")
+                self.assertEqual(result.getpixel((0, 0)), 0)
+                # À quelques niveaux près : la réduction dépasse un peu 850 et 120 au bord des zones.
+                for x, expected in ((650, 255), (1050, 128), (1567, 0)):
+                    self.assertAlmostEqual(result.getpixel((x, 0)), expected, delta=12)
+
+    def test_uniform_out_of_range_grey(self):
+        # Image uniforme hors des plages connues : rien à voir, mais un gris moyen plutôt qu'un noir de trou.
+        for img in (Image.new("I", (200, 100), 100000), Image.new("F", (200, 100), -5.0)):
+            with self.subTest(mode=img.mode):
+                result = opened(prepare_image(encode(img, "TIFF")).data)
+                self.assertEqual(result.convert("L").getpixel((0, 0)), 128)
+
     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")