Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion Tests/test_file_qoi.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

from pathlib import Path

import pytest

from PIL import Image, QoiImagePlugin

from .helper import assert_image_equal_tofile
from .helper import assert_image_equal_tofile, hopper


def test_sanity() -> None:
Expand Down Expand Up @@ -34,3 +36,22 @@ def test_op_index() -> None:
# QOI_OP_INDEX as the first chunk
with Image.open("Tests/images/op_index.qoi") as im:
assert im.getpixel((0, 0)) == (0, 0, 0, 0)


def test_save(tmp_path: Path) -> None:
f = tmp_path / "temp.qoi"

im = hopper()
im.save(f, colorspace="sRGB")

assert_image_equal_tofile(im, f)

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)

im = hopper("P")
with pytest.raises(ValueError, match="Unsupported QOI image mode"):
im.save(f)
29 changes: 20 additions & 9 deletions docs/handbook/image-file-formats.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 codec. 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:

**colorspace**
If set to "sRGB", the colorspace will be written as sRGB with linear alpha, instead
of all channels being linear.

SGI
^^^

Expand Down Expand Up @@ -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
^^^

Expand Down
7 changes: 7 additions & 0 deletions docs/releasenotes/11.3.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ TODO
Other changes
=============

Added QOI saving
^^^^^^^^^^^^^^^^

Support has been added for saving QOI images. ``colorspace`` can be used to specify the
colorspace as sRGB with linear alpha, e.g. ``im.save("out.qoi", colorspace="sRGB")``.
By default, all channels will be linear.

Support using more screenshot utilities with ImageGrab on Linux
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
119 changes: 119 additions & 0 deletions src/PIL/QoiImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
from __future__ import annotations

import os
from typing import IO

from . import Image, ImageFile
from ._binary import i32be as i32
from ._binary import o8
from ._binary import o32be as o32


def _accept(prefix: bytes) -> bool:
Expand Down Expand Up @@ -110,6 +113,122 @@ def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int
return -1, 0


def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode == "RGB":
channels = 3
elif im.mode == "RGBA":
channels = 4
else:
msg = "Unsupported QOI image mode"
raise ValueError(msg)

colorspace = 0 if im.encoderinfo.get("colorspace") == "sRGB" else 1

fp.write(b"qoif")
fp.write(o32(im.size[0]))
fp.write(o32(im.size[1]))
fp.write(o8(channels))
fp.write(o8(colorspace))

ImageFile._save(im, fp, [ImageFile._Tile("qoi", (0, 0) + im.size)])


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) -> 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) & 255
if result >= 128:
result -= 256
return result

def encode(self, bufsize: int) -> tuple[int, int, bytes]:
assert self.im is not None

self._previously_seen_pixels = {0: (0, 0, 0, 0)}
self._previous_pixel = (0, 0, 0, 255)

data = bytearray()
w, h = self.im.size
bands = Image.getmodebands(self.mode)

for y in range(h):
for x in range(w):
pixel = self.im.getpixel((x, y))
if bands == 3:
pixel = (*pixel, 255)

if pixel == self._previous_pixel:
self._run += 1
if self._run == 62:
data += self._write_run()
else:
if self._run:
data += self._write_run()

r, g, b, a = pixel
hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64
if self._previously_seen_pixels.get(hash_value) == pixel:
data += o8(hash_value) # QOI_OP_INDEX
elif self._previous_pixel:
self._previously_seen_pixels[hash_value] = pixel

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 <= delta_r < 2
and -2 <= delta_g < 2
and -2 <= delta_b < 2
):
data += o8(
0b01000000
| (delta_r + 2) << 4
| (delta_g + 2) << 2
| (delta_b + 2)
) # QOI_OP_DIFF
else:
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])
else:
data += o8(0b11111111) # QOI_OP_RGBA
data += bytes(pixel)

self._previous_pixel = pixel

if self._run:
data += self._write_run()
data += bytes((0, 0, 0, 0, 0, 0, 0, 1)) # padding

return len(data), 0, data


Image.register_open(QoiImageFile.format, QoiImageFile, _accept)
Image.register_decoder("qoi", QoiDecoder)
Image.register_extension(QoiImageFile.format, ".qoi")

Image.register_save(QoiImageFile.format, _save)
Image.register_encoder("qoi", QoiEncoder)
Loading