Coverage for thumbhash / hash.py: 45%

111 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-28 17:06 +0000

1import math 

2from dataclasses import dataclass 

3 

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 

27 

28 

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 

37 

38 lx: int 

39 ly: int 

40 l_count: int 

41 p_scale: float 

42 q_scale: float 

43 is_landscape: bool 

44 

45 a_dc: float # if has_alpha 

46 a_scale: float # if has_alpha 

47 

48 l_ac: list[float] 

49 p_ac: list[float] 

50 q_ac: list[float] 

51 a_ac: list[float] # if has_alpha 

52 

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 

61 

62 hash: bytearray = bytearray(int(hash_size)) 

63 

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 

71 

72 hash[0] = header24 & 255 

73 hash[1] = (header24 >> 8) & 255 

74 hash[2] = header24 >> 16 

75 

76 # Second block (2 bytes) 

77 self.l_count = self.lx 

78 if self.is_landscape: 

79 self.l_count = self.ly 

80 

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 

88 

89 hash[3] = header16 & 255 

90 hash[4] = header16 >> 8 

91 

92 if self.has_alpha: 

93 hash[5] = round(15.0 * self.a_dc) | (round(15.0 * self.a_scale) << 4) 

94 

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) 

98 

99 start = 6 if self.has_alpha else 5 

100 idx = 0 

101 

102 for i in range(len(acs)): 

103 ac = acs[i] 

104 

105 for j in range(len(ac)): 

106 f = ac[j] 

107 

108 hash[start + math.floor(idx / 2)] |= round(15.0 * f) << ((idx & 1) * 4) 

109 idx += 1 

110 

111 return bytes(hash) 

112 

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") 

117 

118 # First block 

119 header24 = int(hash[0]) | int(hash[1]) << 8 | int(hash[2]) << 16 

120 

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 

126 

127 # Second block 

128 header16 = int(hash[3]) | int(hash[4]) << 8 

129 

130 p_scale = float((header16 >> 3) & 63) / 63.0 

131 q_scale = float((header16 >> 9) & 63) / 63.0 

132 is_landscape = (header16 >> 15) != 0 

133 

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) 

138 

139 a_dc = 1.0 

140 a_scale = 1.0 

141 

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 

147 

148 start = 6 if has_alpha else 5 

149 idx = 0 

150 

151 def decode_channel(nx: int, ny: int, scale: float) -> list[float]: 

152 ac: list[float] = [] 

153 nonlocal idx 

154 

155 for cy in range(ny): 

156 cx = 1 if cy == 0 else 0 

157 

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!") 

162 

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 

170 

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) 

174 

175 a_ac = [] 

176 if has_alpha: 

177 a_ac = decode_channel(5, 5, a_scale) 

178 

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 ) 

198 

199 def size(self, base_size: int) -> tuple[int, int]: 

200 ratio = float(self.lx) / float(self.ly) 

201 

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) 

206 

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) 

211 

212 n = 5 if self.has_alpha else 3 

213 n = max(self.lx, n) 

214 

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

218 

219 n = 5 if self.has_alpha else 3 

220 n = max(self.ly, n) 

221 

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

225 

226 return fx, fy