Explorar o código

Images : les trous d'une image en flottants sont bouchés avant la réduction

- d69d5a0 supposait que les traces laissées par un trou après la réduction
  LANCZOS restaient au-dessus de 1e26. C'est faux : le noyau, calculé en
  flottants, ne vaut pas exactement zéro à ses zéros (sinc(n) ≈ 1e-17), et
  au coin d'un trou la trace descend à quelques dizaines. Prises pour des
  mesures, ces traces faussaient l'échelle : environ 11 % des scènes
  inclinées entourées de « sans donnée » arrivaient délavées ou sombres, et
  un seul trou de 1×3 pixels suffisait.
- Les trous sont maintenant repérés avant la réduction (par bandes, pour la
  mémoire), bouchés par la plus petite mesure, puis remis en NaN après. Les
  pixels à 4 pixels ou moins d'un trou sont ramenés dans la plage des autres.
  Plus de traces : le seuil remonte à 1e30 et garde les densités de colonne
  autour de 1e21.
- Coût sur une carte de 40 Mpx : +40 Mo au pic, environ +0,3 s sans trou et
  +0,8 s avec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet hai 1 día
pai
achega
aba2b202d0
Modificáronse 2 ficheiros con 97 adicións e 19 borrados
  1. 68 8
      bot/images.py
  2. 29 11
      tests/test_attachments_prep.py

+ 68 - 8
bot/images.py

@@ -5,7 +5,7 @@ from __future__ import annotations
 import io
 import logging
 
-from PIL import Image, ImageMath, ImageOps, UnidentifiedImageError
+from PIL import Image, ImageFilter, ImageMath, ImageOps, UnidentifiedImageError
 
 from .errors import UserFacingError
 from .media import ImageData
@@ -31,10 +31,12 @@ _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.
-# Ces traces descendent vers 1e26 au bout d'une fine bande de trous : le seuil garde de la marge en dessous.
-_FLOAT_HOLE = 1e20
+# mesure mais un trou (zone « sans donnée » d'une carte, souvent ±3,4e38). 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
+# Recherche des trous par bandes d'environ ce nombre de pixels : jamais une copie entière d'une grande image.
+_STRIP_PIXELS = 1_000_000
 
 
 class ImageError(UserFacingError):
@@ -71,7 +73,10 @@ def prepare_image(data: bytes) -> ImageData:
     img.seek(0)                      # GIF ou WebP animé : première image uniquement
     if img.mode in ("1", "P"):  # sinon réduite au plus proche voisin, qui abîme le texte
         img = img.convert("RGBA" if "transparency" in img.info else "RGB")
+    holes = _fill_float_holes(img) if img.mode == "F" else None
     img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.Resampling.LANCZOS)
+    if holes is not None:
+        _restore_float_holes(img, holes.resize(img.size, Image.Resampling.BOX))
     # 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":
@@ -110,6 +115,64 @@ def prepare_image(data: bytes) -> ImageData:
     return result
 
 
+def _float_measures(img: Image.Image) -> Image.Image:
+    """Masque d'une image en flottants : 255 pour une mesure, 0 pour un trou (NaN : la comparaison est fausse).
+
+    Calculé par Pillow : lire les pixels un par un en Python serait lent et gourmand en mémoire.
+    """
+    return ImageMath.lambda_eval(lambda a: (abs(a["img"]) < _FLOAT_HOLE) * 255, img=img).convert("L")
+
+
+def _fill_float_holes(img: Image.Image) -> Image.Image | None:
+    """Bouche les trous d'une image en flottants avant sa réduction, et renvoie leur masque (255 = trou).
+
+    Réduits tels quels, les trous laisseraient autour d'eux des traces (lobes du filtre LANCZOS) de toutes
+    tailles, jusqu'à quelques dizaines : impossibles à distinguer des mesures, elles fausseraient les gris.
+    Ils sont remplacés par la plus petite mesure (bord sombre, comme le noir des trous). Parcours par bandes
+    de lignes : chaque calcul copie la bande, et une copie entière d'une grande image pèserait des centaines
+    de Mo. Renvoie None s'il n'y a aucun trou (l'image n'est alors pas modifiée).
+    """
+    holes = None
+    lowest = None
+    rows = max(1, _STRIP_PIXELS // img.width)
+    for top in range(0, img.height, rows):
+        strip = img.crop((0, top, img.width, min(top + rows, img.height)))
+        measures = _float_measures(strip)
+        fewest, most = measures.getextrema()
+        if fewest < 255:  # des trous dans cette bande
+            if holes is None:
+                holes = Image.new("L", img.size, 0)
+            strip_holes = ImageOps.invert(measures)
+            holes.paste(strip_holes, (0, top))
+            if most == 0:  # que des trous
+                continue
+            strip.paste(_FLOAT32_MAX, mask=strip_holes)  # ignorés pour le minimum
+        strip_low = strip.getextrema()[0]
+        lowest = strip_low if lowest is None else min(lowest, strip_low)
+    if holes is not None:
+        img.paste(0.0 if lowest is None else lowest, mask=holes)
+    return holes
+
+
+def _restore_float_holes(img: Image.Image, holes: Image.Image) -> None:
+    """Remet dans l'image réduite les trous bouchés par _fill_float_holes, en NaN (_to_8_bits les rend noirs).
+
+    `holes` : leur masque réduit à la taille de l'image, par moyenne (part de trous de chaque pixel, sans
+    rebond) ; un pixel surtout fait de trous en redevient un. Autour, sur la portée du filtre LANCZOS
+    (3 pixels), les valeurs mêlent le bouchage aux mesures et peuvent déborder : elles sont ramenées dans la
+    plage des pixels plus loin des trous, pour ne pas fausser l'échelle des gris.
+    """
+    # À 4 pixels ou moins d'un trou (moyenne sur 9 × 9 non nulle : plus rapide qu'un MaxFilter).
+    near = holes.point(lambda v: 255 if v else 0).filter(ImageFilter.BoxBlur(4)).point(lambda v: 255 if v else 0)
+    low_side, high_side = img.copy(), img.copy()  # bornes des pixels loin des trous (voir _to_8_bits)
+    low_side.paste(_FLOAT32_MAX, mask=near)
+    high_side.paste(-_FLOAT32_MAX, mask=near)
+    low, high = low_side.getextrema()[0], high_side.getextrema()[1]
+    if low <= high:  # sinon aucun pixel loin des trous : rien à quoi se ramener
+        img.paste(ImageMath.lambda_eval(lambda a: a["min"](a["max"](a["img"], low), high), img=img), mask=near)
+    img.paste(float("nan"), mask=holes.point(lambda v: 255 if v > 127 else 0))
+
+
 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.
 
@@ -123,10 +186,7 @@ def _to_8_bits(img: Image.Image) -> Image.Image:
         low, high = 0, 65535
     else:
         if img.mode == "F":
-            # 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")
+            measures = _float_measures(img)
             fewest, most = measures.getextrema()
             if fewest == 255:  # aucun trou
                 low, high = img.getextrema()

+ 29 - 11
tests/test_attachments_prep.py

@@ -3,6 +3,7 @@
 import asyncio
 import codecs
 import io
+import math
 import random
 import struct
 import threading
@@ -13,7 +14,7 @@ from types import SimpleNamespace
 from unittest import mock
 
 import aiohttp
-from PIL import Image
+from PIL import Image, ImageDraw
 from pypdf import PdfWriter
 
 from bot import attachments, images, pdf_files
@@ -115,21 +116,38 @@ class PrepareImageTest(unittest.TestCase):
         self.assertEqual(result.getextrema(), (0, 0))
 
     def test_huge_float_values_not_holes(self):
-        # De très grandes mesures, sous le seuil des trous : étalées comme les autres, pas noircies.
-        img = Image.new("F", (300, 100), 1e18)
-        img.paste(3e18, (100, 0, 200, 100))
-        img.paste(5e18, (200, 0, 300, 100))
+        # 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_float_hole_trace_not_a_measure(self):
-        # Au bout d'une fine bande de trous, la réduction laisse des traces bien plus petites que le trou
-        # (vers 1e26 ici) : elles ne doivent pas compter comme des mesures et écraser les gris du reste.
-        img = Image.new("F", (5003, 60), 0.5)
-        img.paste(3.4e38, (33, 10, 800, 18))
-        result = opened(prepare_image(encode(img, "TIFF")).data).convert("L")
-        self.assertEqual(result.getpixel((1000, 15)), 128)
+        # Réduits tels quels, les trous laisseraient autour d'eux des traces de toutes tailles (jusqu'à
+        # quelques dizaines), prises pour des mesures : les gris du reste seraient écrasés.
+        cases = []
+        band = Image.new("F", (5003, 60), 0.5)  # fine bande de trous
+        band.paste(3.4e38, (33, 10, 800, 18))
+        cases.append((band, (1000, 15), 128))
+        tiny = Image.new("F", (12057, 81), 0.5)  # un seul trou de 1×3 pixels
+        tiny.paste(-3.4028234663852886e38, (511, 40, 512, 43))
+        cases.append((tiny, (1000, 3), 128))
+        # Scène satellite inclinée, entourée de « sans donnée » : bords en escalier.
+        w, h, angle = 1794, 2854, math.radians(6)
+        corners = [(w / 2 + dx * math.cos(angle) - dy * math.sin(angle),
+                    h / 2 + dx * math.sin(angle) + dy * math.cos(angle))
+                   for dx, dy in ((-0.3 * w, -0.4 * h), (0.3 * w, -0.4 * h), (0.3 * w, 0.4 * h), (-0.3 * w, 0.4 * h))]
+        inside = Image.new("L", (w, h), 0)
+        ImageDraw.Draw(inside).polygon(corners, fill=255)
+        tilted = Image.new("F", (w, h), -3.4028234663852886e38)
+        tilted.paste(100.0, mask=inside)
+        cases.append((tilted, (493, 784), 100))  # centre de l'image réduite
+        for img, pixel, expected in cases:
+            with self.subTest(size=img.size):
+                result = opened(prepare_image(encode(img, "TIFF")).data).convert("L")
+                self.assertEqual(result.getpixel(pixel), expected)
 
     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.