Coverage for thumbhash / hash.py: 45%
111 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-28 17:06 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-28 17:06 +0000
1import math
2from dataclasses import dataclass
4# Hash binary representation:
5#
6# L DC: 6 bit
7# P DC: 6 bit
8# Q DC: 6 bit
9# L scale: 5 bit
10# HasAlpha: 1 bit
11#
12# L count: 3 bit
13# P scale: 6 bit
14# Q scale: 6 bit
15# IsLandscape: 1 bit
16#
17# If HasAlpha:
18# A DC: 4 bit
19# A scale: 4 bit
20#
21# L AC: 4 bit each
22# P AC: 4 bit each
23# Q AC: 4 bit each
24#
25# If HasAlpha:
26# A AC: 4 bit each
29# Hash represents the set of data stored in an image hash.
30@dataclass
31class Hash:
32 l_dc: float
33 p_dc: float
34 q_dc: float
35 l_scale: float
36 has_alpha: bool
38 lx: int
39 ly: int
40 l_count: int
41 p_scale: float
42 q_scale: float
43 is_landscape: bool
45 a_dc: float # if has_alpha
46 a_scale: float # if has_alpha
48 l_ac: list[float]
49 p_ac: list[float]
50 q_ac: list[float]
51 a_ac: list[float] # if has_alpha
53 def encode(self) -> bytes:
54 # Compute the size of the hash
55 nb_ac = len(self.l_ac) + len(self.p_ac) + len(self.q_ac)
56 if self.has_alpha:
57 nb_ac += len(self.a_ac)
58 hash_size = 3 + 2 + (nb_ac + 1) / 2
59 if self.has_alpha:
60 hash_size += 1
62 hash: bytearray = bytearray(int(hash_size))
64 # First block (3 bytes)
65 header24 = round(63.0 * self.l_dc)
66 header24 |= round(31.5 + 31.5 * self.p_dc) << 6
67 header24 |= round(31.5 + 31.5 * self.q_dc) << 12
68 header24 |= round(31.0 * self.l_scale) << 18
69 if self.has_alpha:
70 header24 |= 1 << 23
72 hash[0] = header24 & 255
73 hash[1] = (header24 >> 8) & 255
74 hash[2] = header24 >> 16
76 # Second block (2 bytes)
77 self.l_count = self.lx
78 if self.is_landscape:
79 self.l_count = self.ly
81 header16 = self.l_count
82 header16 |= round(63.0 * self.p_scale) << 3
83 header16 |= round(63.0 * self.q_scale) << 9
84 if self.is_landscape:
85 header16 |= 1 << 15
86 else:
87 header16 |= 0
89 hash[3] = header16 & 255
90 hash[4] = header16 >> 8
92 if self.has_alpha:
93 hash[5] = round(15.0 * self.a_dc) | (round(15.0 * self.a_scale) << 4)
95 acs: list[list[float]] = [self.l_ac, self.p_ac, self.q_ac]
96 if self.has_alpha:
97 acs.append(self.a_ac)
99 start = 6 if self.has_alpha else 5
100 idx = 0
102 for i in range(len(acs)):
103 ac = acs[i]
105 for j in range(len(ac)):
106 f = ac[j]
108 hash[start + math.floor(idx / 2)] |= round(15.0 * f) << ((idx & 1) * 4)
109 idx += 1
111 return bytes(hash)
113 @staticmethod
114 def decode(hash: bytes, saturation_boost: float) -> "Hash":
115 if len(hash) < 5:
116 raise ValueError("Invalid size hash! Must be at least 5 bytes")
118 # First block
119 header24 = int(hash[0]) | int(hash[1]) << 8 | int(hash[2]) << 16
121 l_dc = float(header24 & 63) / 63.0
122 p_dc = float((header24 >> 6) & 63) / 31.5 - 1.0
123 q_dc = float((header24 >> 12) & 63) / 31.5 - 1.0
124 l_scale = float((header24 >> 18) & 31) / 31.0
125 has_alpha = (header24 >> 23) != 0
127 # Second block
128 header16 = int(hash[3]) | int(hash[4]) << 8
130 p_scale = float((header16 >> 3) & 63) / 63.0
131 q_scale = float((header16 >> 9) & 63) / 63.0
132 is_landscape = (header16 >> 15) != 0
134 l_count = int(header16 & 7)
135 l_max = 5 if has_alpha else 7
136 lx = max(3, l_max if is_landscape else l_count)
137 ly = max(3, l_count if is_landscape else l_max)
139 a_dc = 1.0
140 a_scale = 1.0
142 if has_alpha:
143 if len(hash) < 6:
144 raise ValueError("Invalid size hash! Must be at least 6 bytes")
145 a_dc = float(hash[5] & 15) / 15.0
146 a_scale = float(hash[5] >> 4) / 15.0
148 start = 6 if has_alpha else 5
149 idx = 0
151 def decode_channel(nx: int, ny: int, scale: float) -> list[float]:
152 ac: list[float] = []
153 nonlocal idx
155 for cy in range(ny):
156 cx = 1 if cy == 0 else 0
158 while cx * ny < nx * (ny - cy):
159 hidx = start + math.floor(idx / 2)
160 if hidx >= len(hash):
161 raise ValueError("Invalid size hash!")
163 f = (
164 float((hash[hidx] >> ((idx & 1) * 4)) & 15) / 7.5 - 1.0
165 ) * scale
166 ac.append(f)
167 idx += 1
168 cx += 1
169 return ac
171 l_ac = decode_channel(lx, ly, l_scale)
172 p_ac = decode_channel(3, 3, p_scale * saturation_boost)
173 q_ac = decode_channel(3, 3, q_scale * saturation_boost)
175 a_ac = []
176 if has_alpha:
177 a_ac = decode_channel(5, 5, a_scale)
179 return Hash(
180 l_dc=l_dc,
181 p_dc=p_dc,
182 q_dc=q_dc,
183 l_scale=l_scale,
184 has_alpha=has_alpha,
185 lx=lx,
186 ly=ly,
187 l_count=l_count,
188 p_scale=p_scale,
189 q_scale=q_scale,
190 is_landscape=is_landscape,
191 a_dc=a_dc,
192 a_scale=a_scale,
193 l_ac=l_ac,
194 p_ac=p_ac,
195 q_ac=q_ac,
196 a_ac=a_ac,
197 )
199 def size(self, base_size: int) -> tuple[int, int]:
200 ratio = float(self.lx) / float(self.ly)
202 if ratio > 1.0:
203 return (base_size, round(float(base_size) / ratio))
204 else:
205 return (round(float(base_size) * ratio), base_size)
207 def coefficients(
208 self, x: int, y: int, w: int, h: int
209 ) -> tuple[list[float], list[float]]:
210 xf, yf, wf, hf = float(x), float(y), float(w), float(h)
212 n = 5 if self.has_alpha else 3
213 n = max(self.lx, n)
215 fx: list[float] = [0.0] * n
216 for cx in range(n):
217 fx[cx] = math.cos(math.pi / wf * (xf + 0.5) * float(cx))
219 n = 5 if self.has_alpha else 3
220 n = max(self.ly, n)
222 fy: list[float] = [0.0] * n
223 for cy in range(n):
224 fy[cy] = math.cos(math.pi / hf * (yf + 0.5) * float(cy))
226 return fx, fy