diff --git a/Tests/images/hopper.pfm b/Tests/images/hopper.pfm new file mode 100644 index 00000000000..b5766156401 Binary files /dev/null and b/Tests/images/hopper.pfm differ diff --git a/Tests/images/hopper_be.pfm b/Tests/images/hopper_be.pfm new file mode 100644 index 00000000000..93c75e26fda Binary files /dev/null and b/Tests/images/hopper_be.pfm differ diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index bb49a46d376..d8e259b1cf8 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -6,7 +6,12 @@ from PIL import Image, PpmImagePlugin -from .helper import assert_image_equal_tofile, assert_image_similar, hopper +from .helper import ( + assert_image_equal, + assert_image_equal_tofile, + assert_image_similar, + hopper, +) # sample ppm stream TEST_FILE = "Tests/images/hopper.ppm" @@ -84,20 +89,58 @@ def test_16bit_pgm(): def test_16bit_pgm_write(tmp_path): with Image.open("Tests/images/16_bit_binary.pgm") as im: - f = str(tmp_path / "temp.pgm") - im.save(f, "PPM") + filename = str(tmp_path / "temp.pgm") + im.save(filename, "PPM") - assert_image_equal_tofile(im, f) + assert_image_equal_tofile(im, filename) def test_pnm(tmp_path): with Image.open("Tests/images/hopper.pnm") as im: assert_image_similar(im, hopper(), 0.0001) - f = str(tmp_path / "temp.pnm") - im.save(f) + filename = str(tmp_path / "temp.pnm") + im.save(filename) + + assert_image_equal_tofile(im, filename) + + +def test_pfm(tmp_path): + with Image.open("Tests/images/hopper.pfm") as im: + assert im.info["scale"] == 1.0 + assert_image_equal(im, hopper("F")) + + filename = str(tmp_path / "tmp.pfm") + im.save(filename) + + assert_image_equal_tofile(im, filename) + + +def test_pfm_big_endian(tmp_path): + with Image.open("Tests/images/hopper_be.pfm") as im: + assert im.info["scale"] == 2.5 + assert_image_equal(im, hopper("F")) - assert_image_equal_tofile(im, f) + filename = str(tmp_path / "tmp.pfm") + im.save(filename) + + assert_image_equal_tofile(im, filename) + + +@pytest.mark.parametrize( + "data", + [ + b"Pf 1 1 NaN \0\0\0\0", + b"Pf 1 1 inf \0\0\0\0", + b"Pf 1 1 -inf \0\0\0\0", + b"Pf 1 1 0.0 \0\0\0\0", + b"Pf 1 1 -0.0 \0\0\0\0", + ], +) +def test_pfm_invalid(data): + with pytest.raises(ValueError): + with Image.open(BytesIO(data)): + pass @pytest.mark.parametrize( diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 276838bed1b..569ccb7691b 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -696,6 +696,25 @@ PCX Pillow reads and writes PCX files containing ``1``, ``L``, ``P``, or ``RGB`` data. +PFM +^^^ + +.. versionadded:: 10.3.0 + +Pillow reads and writes grayscale (Pf format) Portable FloatMap (PFM) files +containing ``F`` data. + +Color (PF format) PFM files are not supported. + +Opening +~~~~~~~ + +The :py:func:`~PIL.Image.open` function sets the following +:py:attr:`~PIL.Image.Image.info` properties: + +**scale** + The absolute value of the number stored in the *Scale Factor / Endianness* line. + PNG ^^^ diff --git a/docs/releasenotes/10.3.0.rst b/docs/releasenotes/10.3.0.rst new file mode 100644 index 00000000000..34afbe4b844 --- /dev/null +++ b/docs/releasenotes/10.3.0.rst @@ -0,0 +1,49 @@ +10.3.0 +------ + +Backwards Incompatible Changes +============================== + +TODO +^^^^ + +Deprecations +============ + +TODO +^^^^ + +TODO + +API Changes +=========== + +TODO +^^^^ + +TODO + +API Additions +============= + +TODO +^^^^ + +TODO + +Security +======== + +TODO +^^^^ + +TODO + +Other Changes +============= + +Portable FloatMap (PFM) images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Support has been added for reading and writing grayscale (Pf format) +Portable FloatMap (PFM) files containing ``F`` data. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index d8034853cc2..e86f8082b48 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -14,6 +14,7 @@ expected to be backported to earlier versions. .. toctree:: :maxdepth: 2 + 10.3.0 10.2.0 10.1.0 10.0.1 diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 25dbfa5b0bc..d43e21e14db 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -15,6 +15,8 @@ # from __future__ import annotations +import math + from . import Image, ImageFile from ._binary import i16be as i16 from ._binary import o8 @@ -35,6 +37,7 @@ b"P6": "RGB", # extensions b"P0CMYK": "CMYK", + b"Pf": "F", # PIL extensions (for test purposes only) b"PyP": "P", b"PyRGBA": "RGBA", @@ -43,7 +46,7 @@ def _accept(prefix): - return prefix[0:1] == b"P" and prefix[1] in b"0123456y" + return prefix[0:1] == b"P" and prefix[1] in b"0123456fy" ## @@ -110,6 +113,14 @@ def _open(self): if magic_number in (b"P1", b"P2", b"P3"): decoder_name = "ppm_plain" for ix in range(3): + if mode == "F" and ix == 2: + scale = float(self._read_token()) + if scale == 0.0 or not math.isfinite(scale): + msg = "scale must be finite and non-zero" + raise ValueError(msg) + rawmode = "F;32F" if scale < 0 else "F;32BF" + self.info["scale"] = abs(scale) + continue token = int(self._read_token()) if ix == 0: # token is the x size xsize = token @@ -136,7 +147,8 @@ def _open(self): elif maxval != 255: decoder_name = "ppm" - args = (rawmode, 0, 1) if decoder_name == "raw" else (rawmode, maxval) + row_order = -1 if mode == "F" else 1 + args = (rawmode, 0, row_order) if decoder_name == "raw" else (rawmode, maxval) self._size = xsize, ysize self.tile = [(decoder_name, (0, 0, xsize, ysize), self.fp.tell(), args)] @@ -307,6 +319,7 @@ def decode(self, buffer): def _save(im, fp, filename): + row_order = 1 if im.mode == "1": rawmode, head = "1;I", b"P4" elif im.mode == "L": @@ -315,6 +328,9 @@ def _save(im, fp, filename): rawmode, head = "I;16B", b"P5" elif im.mode in ("RGB", "RGBA"): rawmode, head = "RGB", b"P6" + elif im.mode == "F": + rawmode, head = "F;32F", b"Pf" + row_order = -1 else: msg = f"cannot write mode {im.mode} as PPM" raise OSError(msg) @@ -326,7 +342,9 @@ def _save(im, fp, filename): fp.write(b"255\n") else: fp.write(b"65535\n") - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))]) + elif head == b"Pf": + fp.write(b"-1.0\n") + ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, row_order))]) # @@ -339,6 +357,6 @@ def _save(im, fp, filename): Image.register_decoder("ppm", PpmDecoder) Image.register_decoder("ppm_plain", PpmPlainDecoder) -Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm"]) +Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm", ".pfm"]) Image.register_mime(PpmImageFile.format, "image/x-portable-anymap")