Skip to content

Commit 264b19b

Browse files
authored
Merge pull request #6852 from radarhere/qoi
Added QOI reading
2 parents d0f3167 + 56f9b85 commit 264b19b

File tree

7 files changed

+146
-0
lines changed

7 files changed

+146
-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

+28
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+
def test_sanity():
9+
with Image.open("Tests/images/hopper.qoi") as im:
10+
assert im.mode == "RGB"
11+
assert im.size == (128, 128)
12+
assert im.format == "QOI"
13+
14+
assert_image_equal_tofile(im, "Tests/images/hopper.png")
15+
16+
with Image.open("Tests/images/pil123rgba.qoi") as im:
17+
assert im.mode == "RGBA"
18+
assert im.size == (162, 150)
19+
assert im.format == "QOI"
20+
21+
assert_image_similar_tofile(im, "Tests/images/pil123rgba.png", 0.03)
22+
23+
24+
def test_invalid_file():
25+
invalid_file = "Tests/images/flower.jpg"
26+
27+
with pytest.raises(SyntaxError):
28+
QoiImagePlugin.QoiImageFile(invalid_file)

docs/handbook/image-file-formats.rst

+7
Original file line numberDiff line numberDiff line change
@@ -1549,6 +1549,13 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum
15491549

15501550
.. versionadded:: 5.3.0
15511551

1552+
QOI
1553+
^^^
1554+
1555+
.. versionadded:: 9.5.0
1556+
1557+
Pillow identifies and reads images in Quite OK Image format.
1558+
15521559
XV Thumbnails
15531560
^^^^^^^^^^^^^
15541561

docs/releasenotes/9.5.0.rst

+5
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 images in Quite OK Image format.
35+
3136
Added ``dpi`` argument when saving PDFs
3237
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
3338

src/PIL/QoiImagePlugin.py

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

src/PIL/__init__.py

+1
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)