|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
import io
|
|
import io
|
|
|
import logging
|
|
import logging
|
|
|
|
|
|
|
|
-from PIL import Image, ImageMath, ImageOps, UnidentifiedImageError
|
|
|
|
|
|
|
+from PIL import Image, ImageFilter, ImageMath, ImageOps, UnidentifiedImageError
|
|
|
|
|
|
|
|
from .errors import UserFacingError
|
|
from .errors import UserFacingError
|
|
|
from .media import ImageData
|
|
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"}
|
|
_LOSSLESS_FORMATS = {"PNG", "GIF", "BMP", "TIFF"}
|
|
|
_PNG_MODES = ("L", "RGB") # modes sans transparence enregistrables tels quels en PNG
|
|
_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
|
|
# 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
|
|
_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):
|
|
class ImageError(UserFacingError):
|
|
@@ -71,7 +73,10 @@ def prepare_image(data: bytes) -> ImageData:
|
|
|
img.seek(0) # GIF ou WebP animé : première image uniquement
|
|
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
|
|
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")
|
|
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)
|
|
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.
|
|
# Après la réduction : la copie éventuelle est petite. Respecte l'orientation des photos de téléphone.
|
|
|
img = ImageOps.exif_transpose(img)
|
|
img = ImageOps.exif_transpose(img)
|
|
|
if img.mode.startswith("I") or img.mode == "F":
|
|
if img.mode.startswith("I") or img.mode == "F":
|
|
@@ -110,6 +115,64 @@ def prepare_image(data: bytes) -> ImageData:
|
|
|
return result
|
|
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:
|
|
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.
|
|
"""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
|
|
low, high = 0, 65535
|
|
|
else:
|
|
else:
|
|
|
if img.mode == "F":
|
|
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()
|
|
fewest, most = measures.getextrema()
|
|
|
if fewest == 255: # aucun trou
|
|
if fewest == 255: # aucun trou
|
|
|
low, high = img.getextrema()
|
|
low, high = img.getextrema()
|