From 4283a604c01efecd504b6ce0b33c56f328c194f1 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 9 Mar 2022 22:29:45 +1100 Subject: [PATCH 1/4] Added support for arbitrary maxval --- Tests/test_file_ppm.py | 75 ++++++++++++++++++++++++++++++--------- src/PIL/PpmImagePlugin.py | 54 ++++++++++++++++++++++------ 2 files changed, 101 insertions(+), 28 deletions(-) diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 0e4f1ba6804..b3374291157 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -13,16 +13,64 @@ def test_sanity(): with Image.open(TEST_FILE) as im: - im.load() assert im.mode == "RGB" assert im.size == (128, 128) - assert im.format, "PPM" + assert im.format == "PPM" assert im.get_format_mimetype() == "image/x-portable-pixmap" +def test_arbitrary_maxval(): + # P5 L mode + fp = BytesIO(b"P5 3 1 4 \x00\x02\x04") + with Image.open(fp) as im: + assert im.size == (3, 1) + assert im.mode == "L" + + px = im.load() + assert tuple(px[x, 0] for x in range(3)) == (0, 128, 255) + + # P5 I mode + fp = BytesIO(b"P5 3 1 257 \x00\x00\x00\x80\x01\x01") + with Image.open(fp) as im: + assert im.size == (3, 1) + assert im.mode == "I" + + px = im.load() + assert tuple(px[x, 0] for x in range(3)) == (0, 32640, 65535) + + # P6 with maxval < 255 + fp = BytesIO(b"P6 3 1 17 \x00\x01\x02\x08\x09\x0A\x0F\x10\x11") + with Image.open(fp) as im: + assert im.size == (3, 1) + assert im.mode == "RGB" + + px = im.load() + assert tuple(px[x, 0] for x in range(3)) == ( + (0, 15, 30), + (120, 135, 150), + (225, 240, 255), + ) + + # P6 with maxval > 255 + # Scale down to 255, since there is no RGB mode with more than 8-bit + fp = BytesIO( + b"P6 3 1 257 \x00\x00\x00\x01\x00\x02" + b"\x00\x80\x00\x81\x00\x82\x01\x00\x01\x01\xFF\xFF" + ) + with Image.open(fp) as im: + assert im.size == (3, 1) + assert im.mode == "RGB" + + px = im.load() + assert tuple(px[x, 0] for x in range(3)) == ( + (0, 1, 2), + (127, 128, 129), + (254, 255, 255), + ) + + def test_16bit_pgm(): with Image.open("Tests/images/16_bit_binary.pgm") as im: - im.load() assert im.mode == "I" assert im.size == (20, 100) assert im.get_format_mimetype() == "image/x-portable-graymap" @@ -32,8 +80,6 @@ def test_16bit_pgm(): def test_16bit_pgm_write(tmp_path): with Image.open("Tests/images/16_bit_binary.pgm") as im: - im.load() - f = str(tmp_path / "temp.pgm") im.save(f, "PPM") @@ -91,19 +137,8 @@ def test_token_too_long(tmp_path): assert str(e.value) == "Token too long in file header: b'01234567890'" -def test_too_many_colors(tmp_path): - path = str(tmp_path / "temp.ppm") - with open(path, "wb") as f: - f.write(b"P6\n1 1\n1000\n") - - with pytest.raises(ValueError) as e: - with Image.open(path): - pass - - assert str(e.value) == "Too many colors for band: 1000" - - def test_truncated_file(tmp_path): + # Test EOF in header path = str(tmp_path / "temp.pgm") with open(path, "w") as f: f.write("P6") @@ -114,6 +149,12 @@ def test_truncated_file(tmp_path): assert str(e.value) == "Reached EOF while reading header" + # Test EOF for PyDecoder + fp = BytesIO(b"P5 3 1 4") + with Image.open(fp) as im: + with pytest.raises(ValueError): + im.load() + def test_neg_ppm(): # Storage.c accepted negative values for xsize, ysize. the diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 9e962cac88b..2401dbc497d 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -16,6 +16,9 @@ from . import Image, ImageFile +from ._binary import i16be as i16 +from ._binary import o8 +from ._binary import o32le as o32 # # -------------------------------------------------------------------- @@ -102,6 +105,7 @@ def _open(self): else: self.mode = rawmode = mode + decoder_name = "raw" for ix in range(3): token = int(self._read_token()) if ix == 0: # token is the x size @@ -112,18 +116,44 @@ def _open(self): break elif ix == 2: # token is maxval maxval = token - if maxval > 255: - if not mode == "L": - raise ValueError(f"Too many colors for band: {token}") - if maxval < 2**16: - self.mode = "I" - rawmode = "I;16B" - else: - self.mode = "I" - rawmode = "I;32B" + if maxval > 255 and mode == "L": + self.mode = "I" + + # If maxval matches a bit depth, + # use the raw decoder directly + if maxval == 65535 and mode == "L": + rawmode = "I;16B" + elif maxval != 255: + decoder_name = "ppm" + args = (rawmode, 0, 1) if decoder_name == "raw" else (rawmode, maxval) self._size = xsize, ysize - self.tile = [("raw", (0, 0, xsize, ysize), self.fp.tell(), (rawmode, 0, 1))] + self.tile = [(decoder_name, (0, 0, xsize, ysize), self.fp.tell(), args)] + + +class PpmDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer): + data = bytearray() + maxval = min(self.args[-1], 65535) + in_byte_count = 1 if maxval < 256 else 2 + out_byte_count = 4 if self.mode == "I" else 1 + out_max = 65535 if self.mode == "I" else 255 + bands = Image.getmodebands(self.mode) + while len(data) < self.state.xsize * self.state.ysize * bands * out_byte_count: + pixels = self.fd.read(in_byte_count * bands) + if len(pixels) < in_byte_count * bands: + # eof + break + for b in range(bands): + value = ( + pixels[b] if in_byte_count == 1 else i16(pixels, b * in_byte_count) + ) + value = min(out_max, round(value / maxval * out_max)) + data += o32(value) if self.mode == "I" else o8(value) + self.set_as_raw(bytes(data), (self.mode, 0, 1)) + return -1, 0 # @@ -149,7 +179,7 @@ def _save(im, fp, filename): fp.write(head + ("\n%d %d\n" % im.size).encode("ascii")) if head == b"P6": fp.write(b"255\n") - if head == b"P5": + elif head == b"P5": if rawmode == "L": fp.write(b"255\n") elif rawmode == "I;16B": @@ -169,6 +199,8 @@ def _save(im, fp, filename): Image.register_open(PpmImageFile.format, PpmImageFile, _accept) Image.register_save(PpmImageFile.format, _save) +Image.register_decoder("ppm", PpmDecoder) + Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm"]) Image.register_mime(PpmImageFile.format, "image/x-portable-anymap") From 60de3b7d748995f84e5b68155a48b945416f84fe Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 9 Mar 2022 19:21:51 +1100 Subject: [PATCH 2/4] I mode data can also be read from PPM --- docs/handbook/image-file-formats.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 17808dbc463..08af189da82 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -743,7 +743,7 @@ parameter must be set to ``True``. The following parameters can also be set: PPM ^^^ -Pillow reads and writes PBM, PGM, PPM and PNM files containing ``1``, ``L`` or +Pillow reads and writes PBM, PGM, PPM and PNM files containing ``1``, ``L``, ``I`` or ``RGB`` data. SGI From ad07b04678c96417e438267442b53c30b1858aab Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 10 Mar 2022 09:55:47 +1100 Subject: [PATCH 3/4] Maximum maxval is 65535 --- src/PIL/PpmImagePlugin.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 2401dbc497d..b760e228dbc 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -119,8 +119,7 @@ def _open(self): if maxval > 255 and mode == "L": self.mode = "I" - # If maxval matches a bit depth, - # use the raw decoder directly + # If maxval matches a bit depth, use the raw decoder directly if maxval == 65535 and mode == "L": rawmode = "I;16B" elif maxval != 255: @@ -152,7 +151,8 @@ def decode(self, buffer): ) value = min(out_max, round(value / maxval * out_max)) data += o32(value) if self.mode == "I" else o8(value) - self.set_as_raw(bytes(data), (self.mode, 0, 1)) + rawmode = "I;32" if self.mode == "I" else self.mode + self.set_as_raw(bytes(data), (rawmode, 0, 1)) return -1, 0 @@ -166,26 +166,19 @@ def _save(im, fp, filename): elif im.mode == "L": rawmode, head = "L", b"P5" elif im.mode == "I": - if im.getextrema()[1] < 2**16: - rawmode, head = "I;16B", b"P5" - else: - rawmode, head = "I;32B", b"P5" - elif im.mode == "RGB": - rawmode, head = "RGB", b"P6" - elif im.mode == "RGBA": + rawmode, head = "I;16B", b"P5" + elif im.mode in ("RGB", "RGBA"): rawmode, head = "RGB", b"P6" else: raise OSError(f"cannot write mode {im.mode} as PPM") - fp.write(head + ("\n%d %d\n" % im.size).encode("ascii")) + fp.write(head + b"\n%d %d\n" % im.size) if head == b"P6": fp.write(b"255\n") elif head == b"P5": if rawmode == "L": fp.write(b"255\n") - elif rawmode == "I;16B": + else: fp.write(b"65535\n") - elif rawmode == "I;32B": - fp.write(b"2147483648\n") ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))]) # ALTERNATIVE: save via builtin debug function From 55be0ae6f47198fb8db3ae0c58c2327dc61b6a51 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 14 Mar 2022 08:07:13 +1100 Subject: [PATCH 4/4] Parametrized test --- Tests/test_file_ppm.py | 77 ++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 44 deletions(-) diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index b3374291157..2c965318b10 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -19,54 +19,43 @@ def test_sanity(): assert im.get_format_mimetype() == "image/x-portable-pixmap" -def test_arbitrary_maxval(): - # P5 L mode - fp = BytesIO(b"P5 3 1 4 \x00\x02\x04") +@pytest.mark.parametrize( + "data, mode, pixels", + ( + (b"P5 3 1 4 \x00\x02\x04", "L", (0, 128, 255)), + (b"P5 3 1 257 \x00\x00\x00\x80\x01\x01", "I", (0, 32640, 65535)), + # P6 with maxval < 255 + ( + b"P6 3 1 17 \x00\x01\x02\x08\x09\x0A\x0F\x10\x11", + "RGB", + ( + (0, 15, 30), + (120, 135, 150), + (225, 240, 255), + ), + ), + # P6 with maxval > 255 + # Scale down to 255, since there is no RGB mode with more than 8-bit + ( + b"P6 3 1 257 \x00\x00\x00\x01\x00\x02" + b"\x00\x80\x00\x81\x00\x82\x01\x00\x01\x01\xFF\xFF", + "RGB", + ( + (0, 1, 2), + (127, 128, 129), + (254, 255, 255), + ), + ), + ), +) +def test_arbitrary_maxval(data, mode, pixels): + fp = BytesIO(data) with Image.open(fp) as im: assert im.size == (3, 1) - assert im.mode == "L" + assert im.mode == mode px = im.load() - assert tuple(px[x, 0] for x in range(3)) == (0, 128, 255) - - # P5 I mode - fp = BytesIO(b"P5 3 1 257 \x00\x00\x00\x80\x01\x01") - with Image.open(fp) as im: - assert im.size == (3, 1) - assert im.mode == "I" - - px = im.load() - assert tuple(px[x, 0] for x in range(3)) == (0, 32640, 65535) - - # P6 with maxval < 255 - fp = BytesIO(b"P6 3 1 17 \x00\x01\x02\x08\x09\x0A\x0F\x10\x11") - with Image.open(fp) as im: - assert im.size == (3, 1) - assert im.mode == "RGB" - - px = im.load() - assert tuple(px[x, 0] for x in range(3)) == ( - (0, 15, 30), - (120, 135, 150), - (225, 240, 255), - ) - - # P6 with maxval > 255 - # Scale down to 255, since there is no RGB mode with more than 8-bit - fp = BytesIO( - b"P6 3 1 257 \x00\x00\x00\x01\x00\x02" - b"\x00\x80\x00\x81\x00\x82\x01\x00\x01\x01\xFF\xFF" - ) - with Image.open(fp) as im: - assert im.size == (3, 1) - assert im.mode == "RGB" - - px = im.load() - assert tuple(px[x, 0] for x in range(3)) == ( - (0, 1, 2), - (127, 128, 129), - (254, 255, 255), - ) + assert tuple(px[x, 0] for x in range(3)) == pixels def test_16bit_pgm():