Coverage for thumbhash / encode.py: 98%

95 statements  

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

1import math 

2from base64 import b64encode 

3from collections.abc import Sequence 

4from itertools import chain 

5from pathlib import Path 

6from typing import BinaryIO 

7 

8from PIL import Image 

9from PIL.ImageOps import exif_transpose 

10 

11from thumbhash.hash import Hash 

12 

13 

14def image_to_thumbhash( 

15 image: str | bytes | Path | BinaryIO | Image.Image, 

16) -> str: 

17 if not isinstance(image, Image.Image): 

18 image = Image.open(image) 

19 

20 m_image = exif_transpose(image).convert("RGBA") 

21 

22 m_image.thumbnail((100, 100)) 

23 

24 red_band = m_image.get_flattened_data(band=0) 

25 green_band = m_image.get_flattened_data(band=1) 

26 blue_band = m_image.get_flattened_data(band=2) 

27 alpha_band = m_image.get_flattened_data(band=3) 

28 rgb_data = list( 

29 chain.from_iterable( 

30 zip(red_band, green_band, blue_band, alpha_band, strict=True) 

31 ) 

32 ) 

33 width, height = m_image.size 

34 m_image.close() 

35 

36 hash = rgba_to_thumbhash(width, height, rgb_data) # type: ignore 

37 

38 return b64encode(hash).decode("utf-8") 

39 

40 

41def rgba_to_thumbhash(w: int, h: int, rgba: Sequence[int]) -> bytes: 

42 if w > 100 or h > 100: 

43 raise ValueError(f"{w}x{h} doesn't fit in 100x100") 

44 if len(rgba) != w * h * 4: 

45 raise ValueError("Image data does not fit the given size") 

46 

47 nb_pixels = w * h 

48 is_landscape = w > h 

49 avg_r = 0.0 

50 avg_g = 0.0 

51 avg_b = 0.0 

52 avg_a = 0.0 

53 

54 for i in range(nb_pixels): 

55 alpha = float(rgba[i * 4 + 3]) / 255.0 

56 avg_r += alpha / 255.0 * float(rgba[i * 4]) 

57 avg_g += alpha / 255.0 * float(rgba[i * 4 + 1]) 

58 avg_b += alpha / 255.0 * float(rgba[i * 4 + 2]) 

59 avg_a += alpha 

60 

61 if avg_a > 0.0: 

62 avg_r /= avg_a 

63 avg_g /= avg_a 

64 avg_b /= avg_a 

65 

66 has_alpha = avg_a < float(nb_pixels) 

67 l_limit = 5.0 if has_alpha else 7.0 

68 max_wh = max(w, h) 

69 lx = max(1, round((l_limit * w) / max_wh)) 

70 ly = max(1, round((l_limit * h) / max_wh)) 

71 l_channel: list[float] = [0.0] * nb_pixels 

72 p_channel: list[float] = [0.0] * nb_pixels 

73 q_channel: list[float] = [0.0] * nb_pixels 

74 a_channel: list[float] = [0.0] * nb_pixels 

75 

76 for i in range(nb_pixels): 

77 alpha = float(rgba[i * 4 + 3]) / 255.0 

78 

79 r = avg_r * (1.0 - alpha) + alpha / 255.0 * float(rgba[i * 4]) 

80 g = avg_g * (1.0 - alpha) + alpha / 255.0 * float(rgba[i * 4 + 1]) 

81 b = avg_b * (1.0 - alpha) + alpha / 255.0 * float(rgba[i * 4 + 2]) 

82 

83 l_channel[i] = (r + g + b) / 3.0 

84 p_channel[i] = (r + g) / 2.0 - b 

85 q_channel[i] = r - g 

86 a_channel[i] = alpha 

87 

88 def encode_channel( 

89 channel: list[float], nx: int, ny: int 

90 ) -> tuple[float, list[float], float]: 

91 dc = 0.0 

92 ac: list[float] = [] 

93 scale = 0.0 

94 fx: list[float] = [0.0] * w 

95 

96 for cy in range(ny): 

97 cyf = float(cy) 

98 cx = 0 

99 

100 while cx * ny < nx * (ny - cy): 

101 cxf = float(cx) 

102 f = 0.0 

103 

104 for x in range(w): 

105 fx[x] = math.cos(math.pi / w * cxf * (float(x) + 0.5)) 

106 

107 for y in range(h): 

108 fy = math.cos(math.pi / h * cyf * (float(y) + 0.5)) 

109 

110 for x in range(w): 

111 f += channel[x + y * w] * fx[x] * fy 

112 

113 f /= float(nb_pixels) 

114 

115 if cx > 0 or cy > 0: 

116 ac.append(f) 

117 scale = max(scale, abs(f)) 

118 else: 

119 dc = f 

120 

121 cx += 1 

122 

123 if scale > 0.0: 

124 for i in range(len(ac)): 

125 ac[i] = 0.5 + 0.5 / scale * ac[i] 

126 

127 return (dc, ac, scale) 

128 

129 l_dc, l_ac, l_scale = encode_channel(l_channel, max(lx, 3), max(ly, 3)) 

130 p_dc, p_ac, p_scale = encode_channel(p_channel, 3, 3) 

131 q_dc, q_ac, q_scale = encode_channel(q_channel, 3, 3) 

132 a_dc, a_ac, a_scale = ( 

133 encode_channel(a_channel, 5, 5) if has_alpha else (1.0, [], 1.0) 

134 ) 

135 

136 hash = Hash( 

137 l_dc=l_dc, 

138 p_dc=p_dc, 

139 q_dc=q_dc, 

140 l_scale=l_scale, 

141 has_alpha=has_alpha, 

142 lx=lx, 

143 ly=ly, 

144 p_scale=p_scale, 

145 q_scale=q_scale, 

146 is_landscape=is_landscape, 

147 a_dc=a_dc, 

148 a_scale=a_scale, 

149 l_ac=l_ac, 

150 p_ac=p_ac, 

151 q_ac=q_ac, 

152 a_ac=a_ac, 

153 l_count=0, 

154 ) 

155 

156 return hash.encode()