From c4602b337e728d8f9427fcb1975906aabd9074d2 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 11 Jun 2025 18:41:06 +1000 Subject: [PATCH 1/7] Updated documentation --- docs/handbook/image-file-formats.rst | 29 +++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 5ca549c37..9ce0ec13e 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -1082,6 +1082,26 @@ Pillow reads and writes PBM, PGM, PPM and PNM files containing ``1``, ``L``, ``I Since Pillow 9.2.0, "plain" (P1 to P3) formats can be read as well. +QOI +^^^ + +.. versionadded:: 9.5.0 + +Pillow reads and writes images in Quite OK Image format using a Python decoder. If you +wish to write code specifically for this format, :pypi:`qoi` is an alternative library +that uses C to decode the image and interfaces with NumPy. + +.. _qoi-saving: + +Saving +~~~~~~ + +The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: + +**qoi_colorspace** + If set to "sRGB", the colorspace will be written as sRGB with linear alpha, instead + of all channels being linear. + SGI ^^^ @@ -1578,15 +1598,6 @@ PSD Pillow identifies and reads PSD files written by Adobe Photoshop 2.5 and 3.0. -QOI -^^^ - -.. versionadded:: 9.5.0 - -Pillow reads images in Quite OK Image format using a Python decoder. If you wish to -write code specifically for this format, :pypi:`qoi` is an alternative library that -uses C to decode the image and interfaces with NumPy. - SUN ^^^ From 8ca44425c80a8c74268d53268d9661dd0d9f5cb3 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 12 Jun 2025 19:08:45 +1000 Subject: [PATCH 2/7] Removed qoi_ prefix from save argument --- Tests/test_file_qoi.py | 8 ++++---- docs/handbook/image-file-formats.rst | 2 +- src/PIL/QoiImagePlugin.py | 5 +---- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/Tests/test_file_qoi.py b/Tests/test_file_qoi.py index 4222d2b94..1f415eaaa 100644 --- a/Tests/test_file_qoi.py +++ b/Tests/test_file_qoi.py @@ -41,13 +41,13 @@ def test_op_index() -> None: def test_save(tmp_path: Path) -> None: f = tmp_path / "temp.qoi" - im = hopper("RGB") - im.save(f, qoi_colorspace="sRGB") + im = hopper() + im.save(f, colorspace="sRGB") assert_image_equal_tofile(im, f) - for image in ["Tests/images/default_font.png", "Tests/images/pil123rgba.png"]: - with Image.open(image) as im: + for path in ("Tests/images/default_font.png", "Tests/images/pil123rgba.png"): + with Image.open(path) as im: im.save(f) assert_image_equal_tofile(im, f) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 9ce0ec13e..38016be96 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -1098,7 +1098,7 @@ Saving The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: -**qoi_colorspace** +**colorspace** If set to "sRGB", the colorspace will be written as sRGB with linear alpha, instead of all channels being linear. diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index aab0533e3..03b6a4b98 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -122,10 +122,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: msg = "Unsupported QOI image mode" raise ValueError(msg) - if im.encoderinfo.get("qoi_colorspace") == "sRGB": - colorspace = 0 - else: - colorspace = 1 + colorspace = 0 if im.encoderinfo.get("colorspace") == "sRGB" else 1 fp.write(b"qoif") fp.write(o32(im.size[0])) From dcf674a48f98ecc15e3aee368316383ff57e91bc Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 11 Jun 2025 18:59:34 +1000 Subject: [PATCH 3/7] Use binary to make comparing to the specification easier --- src/PIL/QoiImagePlugin.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index 03b6a4b98..b09f500b8 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -139,7 +139,7 @@ class QoiEncoder(ImageFile.PyEncoder): _previously_seen_pixels: dict[int, tuple[int, int, int, int]] = {} def _write_run(self, run: int) -> bytes: - return o8(0xC0 | (run - 1)) # QOI_OP_RUN + return o8(0b11000000 | (run - 1)) # QOI_OP_RUN def _delta(self, left: int, right: int) -> int: result = (left - right) & 0xFF @@ -191,16 +191,19 @@ class QoiEncoder(ImageFile.PyEncoder): if -2 <= dr < 2 and -2 <= dg < 2 and -2 <= db < 2: data += o8( - 0x40 | (dr + 2) << 4 | (dg + 2) << 2 | (db + 2) + 0b01000000 + | (dr + 2) << 4 + | (dg + 2) << 2 + | (db + 2) ) # QOI_OP_DIFF elif -8 <= dgr < 8 and -32 <= dg < 32 and -8 <= dgb < 8: - data += o8(0x80 | (dg + 32)) # QOI_OP_LUMA + data += o8(0b10000000 | (dg + 32)) # QOI_OP_LUMA data += o8((dgr + 8) << 4 | (dgb + 8)) else: - data += o8(0xFE) # QOI_OP_RGB + data += o8(0b11111110) # QOI_OP_RGB data += bytes(pixel[:3]) else: - data += o8(0xFF) # QOI_OP_RGBA + data += o8(0b11111111) # QOI_OP_RGBA data += bytes(pixel) self._previous_pixel = pixel From 23a7fefcedc64a0a77a067bab4e73dc9488c441a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 11 Jun 2025 19:42:24 +1000 Subject: [PATCH 4/7] Reset run from within _write_run --- src/PIL/QoiImagePlugin.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index b09f500b8..5a62c62a5 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -137,9 +137,12 @@ class QoiEncoder(ImageFile.PyEncoder): _pushes_fd = True _previous_pixel: tuple[int, int, int, int] | None = None _previously_seen_pixels: dict[int, tuple[int, int, int, int]] = {} + _run = 0 - def _write_run(self, run: int) -> bytes: - return o8(0b11000000 | (run - 1)) # QOI_OP_RUN + def _write_run(self) -> bytes: + data = o8(0b11000000 | (self._run - 1)) # QOI_OP_RUN + self._run = 0 + return data def _delta(self, left: int, right: int) -> int: result = (left - right) & 0xFF @@ -155,7 +158,6 @@ class QoiEncoder(ImageFile.PyEncoder): data = bytearray() w, h = self.im.size - run = 0 bands = Image.getmodebands(self.im.mode) for y in range(h): @@ -165,14 +167,12 @@ class QoiEncoder(ImageFile.PyEncoder): pixel = (*pixel, 255) if pixel == self._previous_pixel: - run += 1 - if run == 62: - data += self._write_run(run) - run = 0 + self._run += 1 + if self._run == 62: + data += self._write_run() else: - if run > 0: - data += self._write_run(run) - run = 0 + if self._run > 0: + data += self._write_run() r, g, b, a = pixel hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64 @@ -208,8 +208,8 @@ class QoiEncoder(ImageFile.PyEncoder): self._previous_pixel = pixel - if run > 0: - data += self._write_run(run) + if self._run > 0: + data += self._write_run() data += bytes((0, 0, 0, 0, 0, 0, 0, 1)) # padding return len(data), 0, data From a714db9057c1377ed0bc5c0f731ceed93467a27e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 11 Jun 2025 19:38:49 +1000 Subject: [PATCH 5/7] Simplified code --- src/PIL/QoiImagePlugin.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index 5a62c62a5..95c3d03c0 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -145,9 +145,9 @@ class QoiEncoder(ImageFile.PyEncoder): return data def _delta(self, left: int, right: int) -> int: - result = (left - right) & 0xFF - if result >= 0x80: - result -= 0x100 + result = (left - right) & 255 + if result >= 128: + result -= 256 return result def encode(self, bufsize: int) -> tuple[int, int, bytes]: @@ -158,7 +158,7 @@ class QoiEncoder(ImageFile.PyEncoder): data = bytearray() w, h = self.im.size - bands = Image.getmodebands(self.im.mode) + bands = Image.getmodebands(self.mode) for y in range(h): for x in range(w): @@ -171,7 +171,7 @@ class QoiEncoder(ImageFile.PyEncoder): if self._run == 62: data += self._write_run() else: - if self._run > 0: + if self._run: data += self._write_run() r, g, b, a = pixel @@ -208,7 +208,7 @@ class QoiEncoder(ImageFile.PyEncoder): self._previous_pixel = pixel - if self._run > 0: + if self._run: data += self._write_run() data += bytes((0, 0, 0, 0, 0, 0, 0, 1)) # padding From 2e8e86cba1a421ab150ed259d413ae5eea238d3a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 11 Jun 2025 19:29:04 +1000 Subject: [PATCH 6/7] Only calculate LUMA deltas when necessary --- src/PIL/QoiImagePlugin.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index 95c3d03c0..1d6412a45 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -182,12 +182,10 @@ class QoiEncoder(ImageFile.PyEncoder): self._previously_seen_pixels[hash_value] = pixel pr, pg, pb, pa = self._previous_pixel - if a == pa: + if pa == a: dr = self._delta(r, pr) dg = self._delta(g, pg) db = self._delta(b, pb) - dgr = self._delta(dr, dg) - dgb = self._delta(db, dg) if -2 <= dr < 2 and -2 <= dg < 2 and -2 <= db < 2: data += o8( @@ -196,12 +194,15 @@ class QoiEncoder(ImageFile.PyEncoder): | (dg + 2) << 2 | (db + 2) ) # QOI_OP_DIFF - elif -8 <= dgr < 8 and -32 <= dg < 32 and -8 <= dgb < 8: - data += o8(0b10000000 | (dg + 32)) # QOI_OP_LUMA - data += o8((dgr + 8) << 4 | (dgb + 8)) else: - data += o8(0b11111110) # QOI_OP_RGB - data += bytes(pixel[:3]) + dgr = self._delta(dr, dg) + dgb = self._delta(db, dg) + if -8 <= dgr < 8 and -32 <= dg < 32 and -8 <= dgb < 8: + data += o8(0b10000000 | (dg + 32)) # QOI_OP_LUMA + data += o8((dgr + 8) << 4 | (dgb + 8)) + else: + data += o8(0b11111110) # QOI_OP_RGB + data += bytes(pixel[:3]) else: data += o8(0b11111111) # QOI_OP_RGBA data += bytes(pixel) From ebf029f87978f60f96d3e17b3eddbfba1afa2fbb Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 11 Jun 2025 19:37:02 +1000 Subject: [PATCH 7/7] Expanded variable names --- src/PIL/QoiImagePlugin.py | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index 1d6412a45..dba5d809f 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -181,25 +181,35 @@ class QoiEncoder(ImageFile.PyEncoder): elif self._previous_pixel: self._previously_seen_pixels[hash_value] = pixel - pr, pg, pb, pa = self._previous_pixel - if pa == a: - dr = self._delta(r, pr) - dg = self._delta(g, pg) - db = self._delta(b, pb) + prev_r, prev_g, prev_b, prev_a = self._previous_pixel + if prev_a == a: + delta_r = self._delta(r, prev_r) + delta_g = self._delta(g, prev_g) + delta_b = self._delta(b, prev_b) - if -2 <= dr < 2 and -2 <= dg < 2 and -2 <= db < 2: + if ( + -2 <= delta_r < 2 + and -2 <= delta_g < 2 + and -2 <= delta_b < 2 + ): data += o8( 0b01000000 - | (dr + 2) << 4 - | (dg + 2) << 2 - | (db + 2) + | (delta_r + 2) << 4 + | (delta_g + 2) << 2 + | (delta_b + 2) ) # QOI_OP_DIFF else: - dgr = self._delta(dr, dg) - dgb = self._delta(db, dg) - if -8 <= dgr < 8 and -32 <= dg < 32 and -8 <= dgb < 8: - data += o8(0b10000000 | (dg + 32)) # QOI_OP_LUMA - data += o8((dgr + 8) << 4 | (dgb + 8)) + delta_gr = self._delta(delta_r, delta_g) + delta_gb = self._delta(delta_b, delta_g) + if ( + -8 <= delta_gr < 8 + and -32 <= delta_g < 32 + and -8 <= delta_gb < 8 + ): + data += o8( + 0b10000000 | (delta_g + 32) + ) # QOI_OP_LUMA + data += o8((delta_gr + 8) << 4 | (delta_gb + 8)) else: data += o8(0b11111110) # QOI_OP_RGB data += bytes(pixel[:3])