Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Binary file added Tests/images/hopper.qoi
Binary file not shown.
Binary file added Tests/images/pil123rgba.qoi
Binary file not shown.
28 changes: 28 additions & 0 deletions Tests/test_file_qoi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import pytest

from PIL import Image, QoiImagePlugin

from .helper import assert_image_equal_tofile, assert_image_similar_tofile


class TestFileQOI:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could maybe skip the class here and use plain, top-level functions?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I've added a commit for this.

def test_sanity(self):
with Image.open("Tests/images/hopper.qoi") as im:
assert im.mode == "RGB"
assert im.size == (128, 128)
assert im.format == "QOI"

assert_image_equal_tofile(im, "Tests/images/hopper.png")

with Image.open("Tests/images/pil123rgba.qoi") as im:
assert im.mode == "RGBA"
assert im.size == (162, 150)
assert im.format == "QOI"

assert_image_similar_tofile(im, "Tests/images/pil123rgba.png", 0.03)

def test_invalid_file(self):
invalid_file = "Tests/images/flower.jpg"

with pytest.raises(SyntaxError):
QoiImagePlugin.QoiImageFile(invalid_file)
7 changes: 7 additions & 0 deletions docs/handbook/image-file-formats.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1544,6 +1544,13 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum

.. versionadded:: 5.3.0

QOI
^^^

.. versionadded:: 9.5.0

Pillow identifies and reads QOI images.

XV Thumbnails
^^^^^^^^^^^^^

Expand Down
5 changes: 5 additions & 0 deletions docs/releasenotes/9.5.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ TODO
API Additions
=============

QOI file format
^^^^^^^^^^^^^^^

Pillow can now read QOI images.

Added ``dpi`` argument when saving PDFs
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
106 changes: 106 additions & 0 deletions src/PIL/QoiImagePlugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#
# The Python Imaging Library.
# $Id$
#
# QOI support for PIL
#
# See the README file for information on usage and redistribution.
#

import os

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


def _accept(prefix):
return prefix[:4] == b"qoif"


class QoiImageFile(ImageFile.ImageFile):
format = "QOI"
format_description = "Quite OK Image"

def _open(self):
if not _accept(self.fp.read(4)):
msg = "not a QOI file"
raise SyntaxError(msg)

self._size = tuple(i32(self.fp.read(4)) for i in range(2))

channels = self.fp.read(1)[0]
self.mode = "RGB" if channels == 3 else "RGBA"

self.fp.seek(1, os.SEEK_CUR) # colorspace
self.tile = [("qoi", (0, 0) + self._size, self.fp.tell(), None)]


class QoiDecoder(ImageFile.PyDecoder):
_pulls_fd = True

def _add_to_previous_pixels(self, value):
self._previous_pixel = value

r, g, b, a = value
hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64
self._previously_seen_pixels[hash_value] = value

def decode(self, buffer):
self._previously_seen_pixels = {}
self._previous_pixel = None
self._add_to_previous_pixels(b"".join(o8(i) for i in (0, 0, 0, 255)))

data = bytearray()
bands = Image.getmodebands(self.mode)
while len(data) < self.state.xsize * self.state.ysize * bands:
byte = self.fd.read(1)[0]
if byte == 0b11111110: # QOI_OP_RGB
value = self.fd.read(3) + o8(255)
elif byte == 0b11111111: # QOI_OP_RGBA
value = self.fd.read(4)
else:
op = byte >> 6
if op == 0: # QOI_OP_INDEX
op_index = byte & 0b00111111
value = self._previously_seen_pixels.get(op_index, (0, 0, 0, 0))
elif op == 1: # QOI_OP_DIFF
value = (
(self._previous_pixel[0] + ((byte & 0b00110000) >> 4) - 2)
% 256,
(self._previous_pixel[1] + ((byte & 0b00001100) >> 2) - 2)
% 256,
(self._previous_pixel[2] + (byte & 0b00000011) - 2) % 256,
)
value += (self._previous_pixel[3],)
elif op == 2: # QOI_OP_LUMA
second_byte = self.fd.read(1)[0]
diff_green = (byte & 0b00111111) - 32
diff_red = ((second_byte & 0b11110000) >> 4) - 8
diff_blue = (second_byte & 0b00001111) - 8

value = tuple(
(self._previous_pixel[i] + diff_green + diff) % 256
for i, diff in enumerate((diff_red, 0, diff_blue))
)
value += (self._previous_pixel[3],)
elif op == 3: # QOI_OP_RUN
run_length = (byte & 0b00111111) + 1
value = self._previous_pixel
if bands == 3:
value = value[:3]
data += value * run_length
continue
value = b"".join(o8(i) for i in value)
self._add_to_previous_pixels(value)

if bands == 3:
value = value[:3]
data += value
self.set_as_raw(bytes(data))
return -1, 0


Image.register_open(QoiImageFile.format, QoiImageFile, _accept)
Image.register_decoder("qoi", QoiDecoder)
Image.register_extension(QoiImageFile.format, ".qoi")
1 change: 1 addition & 0 deletions src/PIL/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"PngImagePlugin",
"PpmImagePlugin",
"PsdImagePlugin",
"QoiImagePlugin",
"SgiImagePlugin",
"SpiderImagePlugin",
"SunImagePlugin",
Expand Down