Skip to content

Commit 2d01875

Browse files
committed
Added QOI reading
1 parent cdf5fd4 commit 2d01875

File tree

7 files changed

+147
-0
lines changed

7 files changed

+147
-0
lines changed

Tests/images/hopper.qoi

34.8 KB
Binary file not shown.

Tests/images/pil123rgba.qoi

41.8 KB
Binary file not shown.

Tests/test_file_qoi.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import pytest
2+
3+
from PIL import Image, QoiImagePlugin
4+
5+
from .helper import assert_image_equal_tofile, assert_image_similar_tofile
6+
7+
8+
class TestFileQOI:
9+
def test_sanity(self):
10+
with Image.open("Tests/images/hopper.qoi") as im:
11+
assert im.mode == "RGB"
12+
assert im.size == (128, 128)
13+
assert im.format == "QOI"
14+
15+
assert_image_equal_tofile(im, "Tests/images/hopper.png")
16+
17+
with Image.open("Tests/images/pil123rgba.qoi") as im:
18+
assert im.mode == "RGBA"
19+
assert im.size == (162, 150)
20+
assert im.format == "QOI"
21+
22+
assert_image_similar_tofile(im, "Tests/images/pil123rgba.png", 0.03)
23+
24+
def test_invalid_file(self):
25+
invalid_file = "Tests/images/flower.jpg"
26+
27+
with pytest.raises(SyntaxError):
28+
QoiImagePlugin.QoiImageFile(invalid_file)

docs/handbook/image-file-formats.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,6 +1544,13 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum
15441544

15451545
.. versionadded:: 5.3.0
15461546

1547+
QOI
1548+
^^^
1549+
1550+
.. versionadded:: 9.5.0
1551+
1552+
Pillow identifies and reads QOI images.
1553+
15471554
XV Thumbnails
15481555
^^^^^^^^^^^^^
15491556

docs/releasenotes/9.5.0.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ TODO
2828
API Additions
2929
=============
3030

31+
QOI file format
32+
^^^^^^^^^^^^^^^
33+
34+
Pillow can now read QOI images.
35+
3136
Added ``dpi`` argument when saving PDFs
3237
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
3338

src/PIL/QoiImagePlugin.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#
2+
# The Python Imaging Library.
3+
# $Id$
4+
#
5+
# QOI support for PIL
6+
#
7+
# See the README file for information on usage and redistribution.
8+
#
9+
10+
import os
11+
12+
from . import Image, ImageFile
13+
from ._binary import i32be as i32
14+
from ._binary import o8
15+
16+
17+
def _accept(prefix):
18+
return prefix[:4] == b"qoif"
19+
20+
21+
class QoiImageFile(ImageFile.ImageFile):
22+
format = "QOI"
23+
format_description = "Quite OK Image"
24+
25+
def _open(self):
26+
if not _accept(self.fp.read(4)):
27+
msg = "not a QOI file"
28+
raise SyntaxError(msg)
29+
30+
self._size = tuple(i32(self.fp.read(4)) for i in range(2))
31+
32+
channels = self.fp.read(1)[0]
33+
self.mode = "RGB" if channels == 3 else "RGBA"
34+
35+
self.fp.seek(1, os.SEEK_CUR) # colorspace
36+
self.tile = [("qoi", (0, 0) + self._size, self.fp.tell(), None)]
37+
38+
39+
class QoiDecoder(ImageFile.PyDecoder):
40+
_pulls_fd = True
41+
42+
def _add_to_previous_pixels(self, value):
43+
self._previous_pixel = value
44+
45+
r, g, b, a = value
46+
hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64
47+
self._previously_seen_pixels[hash_value] = value
48+
49+
def decode(self, buffer):
50+
self._previously_seen_pixels = {}
51+
self._previous_pixel = None
52+
self._add_to_previous_pixels(b"".join(o8(i) for i in (0, 0, 0, 255)))
53+
54+
data = bytearray()
55+
bands = Image.getmodebands(self.mode)
56+
while len(data) < self.state.xsize * self.state.ysize * bands:
57+
byte = self.fd.read(1)[0]
58+
if byte == 0b11111110: # QOI_OP_RGB
59+
value = self.fd.read(3) + o8(255)
60+
elif byte == 0b11111111: # QOI_OP_RGBA
61+
value = self.fd.read(4)
62+
else:
63+
op = byte >> 6
64+
if op == 0: # QOI_OP_INDEX
65+
op_index = byte & 0b00111111
66+
value = self._previously_seen_pixels.get(op_index, (0, 0, 0, 0))
67+
elif op == 1: # QOI_OP_DIFF
68+
value = (
69+
(self._previous_pixel[0] + ((byte & 0b00110000) >> 4) - 2)
70+
% 256,
71+
(self._previous_pixel[1] + ((byte & 0b00001100) >> 2) - 2)
72+
% 256,
73+
(self._previous_pixel[2] + (byte & 0b00000011) - 2) % 256,
74+
)
75+
value += (self._previous_pixel[3],)
76+
elif op == 2: # QOI_OP_LUMA
77+
second_byte = self.fd.read(1)[0]
78+
diff_green = (byte & 0b00111111) - 32
79+
diff_red = ((second_byte & 0b11110000) >> 4) - 8
80+
diff_blue = (second_byte & 0b00001111) - 8
81+
82+
value = tuple(
83+
(self._previous_pixel[i] + diff_green + diff) % 256
84+
for i, diff in enumerate((diff_red, 0, diff_blue))
85+
)
86+
value += (self._previous_pixel[3],)
87+
elif op == 3: # QOI_OP_RUN
88+
run_length = (byte & 0b00111111) + 1
89+
value = self._previous_pixel
90+
if bands == 3:
91+
value = value[:3]
92+
data += value * run_length
93+
continue
94+
value = b"".join(o8(i) for i in value)
95+
self._add_to_previous_pixels(value)
96+
97+
if bands == 3:
98+
value = value[:3]
99+
data += value
100+
self.set_as_raw(bytes(data))
101+
return -1, 0
102+
103+
104+
Image.register_open(QoiImageFile.format, QoiImageFile, _accept)
105+
Image.register_decoder("qoi", QoiDecoder)
106+
Image.register_extension(QoiImageFile.format, ".qoi")

src/PIL/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"PngImagePlugin",
6060
"PpmImagePlugin",
6161
"PsdImagePlugin",
62+
"QoiImagePlugin",
6263
"SgiImagePlugin",
6364
"SpiderImagePlugin",
6465
"SunImagePlugin",

0 commit comments

Comments
 (0)