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#
8from __future__ import annotations
9
10import os
11
12from . import Image, ImageFile
13from ._binary import i32be as i32
14
15
16def _accept(prefix: bytes) -> bool:
17 return prefix[:4] == b"qoif"
18
19
20class QoiImageFile(ImageFile.ImageFile):
21 format = "QOI"
22 format_description = "Quite OK Image"
23
24 def _open(self) -> None:
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
38class QoiDecoder(ImageFile.PyDecoder):
39 _pulls_fd = True
40 _previous_pixel: bytes | bytearray | None = None
41 _previously_seen_pixels: dict[int, bytes | bytearray] = {}
42
43 def _add_to_previous_pixels(self, value: bytes | bytearray) -> None:
44 self._previous_pixel = value
45
46 r, g, b, a = value
47 hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64
48 self._previously_seen_pixels[hash_value] = value
49
50 def decode(self, buffer: bytes) -> tuple[int, int]:
51 assert self.fd is not None
52
53 self._previously_seen_pixels = {}
54 self._add_to_previous_pixels(bytearray((0, 0, 0, 255)))
55
56 data = bytearray()
57 bands = Image.getmodebands(self.mode)
58 dest_length = self.state.xsize * self.state.ysize * bands
59 while len(data) < dest_length:
60 byte = self.fd.read(1)[0]
61 value: bytes | bytearray
62 if byte == 0b11111110 and self._previous_pixel: # QOI_OP_RGB
63 value = bytearray(self.fd.read(3)) + self._previous_pixel[3:]
64 elif byte == 0b11111111: # QOI_OP_RGBA
65 value = self.fd.read(4)
66 else:
67 op = byte >> 6
68 if op == 0: # QOI_OP_INDEX
69 op_index = byte & 0b00111111
70 value = self._previously_seen_pixels.get(
71 op_index, bytearray((0, 0, 0, 0))
72 )
73 elif op == 1 and self._previous_pixel: # QOI_OP_DIFF
74 value = bytearray(
75 (
76 (self._previous_pixel[0] + ((byte & 0b00110000) >> 4) - 2)
77 % 256,
78 (self._previous_pixel[1] + ((byte & 0b00001100) >> 2) - 2)
79 % 256,
80 (self._previous_pixel[2] + (byte & 0b00000011) - 2) % 256,
81 self._previous_pixel[3],
82 )
83 )
84 elif op == 2 and self._previous_pixel: # QOI_OP_LUMA
85 second_byte = self.fd.read(1)[0]
86 diff_green = (byte & 0b00111111) - 32
87 diff_red = ((second_byte & 0b11110000) >> 4) - 8
88 diff_blue = (second_byte & 0b00001111) - 8
89
90 value = bytearray(
91 tuple(
92 (self._previous_pixel[i] + diff_green + diff) % 256
93 for i, diff in enumerate((diff_red, 0, diff_blue))
94 )
95 )
96 value += self._previous_pixel[3:]
97 elif op == 3 and self._previous_pixel: # QOI_OP_RUN
98 run_length = (byte & 0b00111111) + 1
99 value = self._previous_pixel
100 if bands == 3:
101 value = value[:3]
102 data += value * run_length
103 continue
104 self._add_to_previous_pixels(value)
105
106 if bands == 3:
107 value = value[:3]
108 data += value
109 self.set_as_raw(data)
110 return -1, 0
111
112
113Image.register_open(QoiImageFile.format, QoiImageFile, _accept)
114Image.register_decoder("qoi", QoiDecoder)
115Image.register_extension(QoiImageFile.format, ".qoi")