Explorar o código

Images : une image en flottants est convertie sans lire ses pixels en Python

- La recherche des trous et des bornes d'une image en flottants passe par
  Pillow (ImageMath et getextrema) au lieu d'une boucle Python sur chaque
  pixel : environ 7 fois plus rapide sur 1568 × 1568, sans la copie des
  pixels en mémoire. Même masque et mêmes bornes qu'avant.
- Le seuil des trous passe de 1e20 à 1e30 : de vraies valeurs autour de
  1e21 (densités de colonne en astronomie) arrivaient toutes noires. Les
  traces de trous laissées par la réduction LANCZOS restent au-dessus
  de 1e35.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet hai 1 día
pai
achega
7bb4e8a4f8
Modificáronse 2 ficheiros con 39 adicións e 10 borrados
  1. 24 10
      bot/images.py
  2. 15 0
      tests/test_attachments_prep.py

+ 24 - 10
bot/images.py

@@ -4,9 +4,8 @@ from __future__ import annotations
 
 import io
 import logging
-from array import array
 
-from PIL import Image, ImageOps, UnidentifiedImageError
+from PIL import Image, ImageMath, ImageOps, UnidentifiedImageError
 
 from .errors import UserFacingError
 from .media import ImageData
@@ -32,8 +31,11 @@ _DIRECT_FORMATS = {"JPEG": "image/jpeg", "PNG": "image/png", "GIF": "image/gif",
 _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
+# mesure mais un trou (zone « sans donnée » d'une carte, souvent ±3,4e38) ou sa trace après la réduction
+# (mesurée au-dessus de 1e35). Assez haut pour garder les vraies valeurs géantes (densités de colonne en
+# astronomie, autour de 1e21).
+_FLOAT_HOLE = 1e30
+_FLOAT32_MAX = 3.4e38
 
 
 class ImageError(UserFacingError):
@@ -122,12 +124,24 @@ def _to_8_bits(img: Image.Image) -> Image.Image:
         low, high = 0, 65535
     else:
         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)
+            # Mesures à 255, trous à 0 (NaN : la comparaison est fausse). Calculé par Pillow : lire les
+            # pixels un par un en Python serait lent et gourmand en mémoire pour une grande image.
+            measures = ImageMath.lambda_eval(lambda a: (abs(a["img"]) < _FLOAT_HOLE) * 255,
+                                             img=img).convert("L")
+            fewest, most = measures.getextrema()
+            if fewest == 255:  # aucun trou
+                low, high = img.getextrema()
+            elif most == 0:  # que des trous : tout noir
+                holes = ImageOps.invert(measures)
+                low, high = 0, 0
+            else:
+                holes = ImageOps.invert(measures)
+                # Bornes des seules mesures : les trous remplacés par le plus grand flottant pour le
+                # minimum, par le plus petit pour le maximum.
+                low_side, high_side = img.copy(), img.copy()
+                low_side.paste(_FLOAT32_MAX, mask=holes)
+                high_side.paste(-_FLOAT32_MAX, mask=holes)
+                low, high = low_side.getextrema()[0], high_side.getextrema()[1]
         else:
             low, high = img.getextrema()
         if low >= 0 and high <= 1:

+ 15 - 0
tests/test_attachments_prep.py

@@ -108,6 +108,21 @@ class PrepareImageTest(unittest.TestCase):
                 for x, expected in ((650, 255), (1050, 128), (1567, 0)):
                     self.assertAlmostEqual(result.getpixel((x, 0)), expected, delta=12)
 
+    def test_float_only_holes_black(self):
+        img = Image.new("F", (200, 100), float("nan"))
+        img.paste(float("inf"), (100, 0, 200, 100))
+        result = opened(prepare_image(encode(img, "TIFF")).data).convert("L")
+        self.assertEqual(result.getextrema(), (0, 0))
+
+    def test_huge_float_values_not_holes(self):
+        # Densités de colonne en astronomie : de vraies mesures de 1e21 à 5e21, pas des trous à noircir.
+        img = Image.new("F", (300, 100), 1e21)
+        img.paste(3e21, (100, 0, 200, 100))
+        img.paste(5e21, (200, 0, 300, 100))
+        result = opened(prepare_image(encode(img, "TIFF")).data).convert("L")
+        for x, expected in ((50, 0), (150, 128), (250, 255)):
+            self.assertAlmostEqual(result.getpixel((x, 0)), expected, delta=2)
+
     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)):