Coverage for thumbhash / encode.py: 98%
95 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 base64 import b64encode
3from collections.abc import Sequence
4from itertools import chain
5from pathlib import Path
6from typing import BinaryIO
8from PIL import Image
9from PIL.ImageOps import exif_transpose
11from thumbhash.hash import Hash
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)
20 m_image = exif_transpose(image).convert("RGBA")
22 m_image.thumbnail((100, 100))
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()
36 hash = rgba_to_thumbhash(width, height, rgb_data) # type: ignore
38 return b64encode(hash).decode("utf-8")
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")
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
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
61 if avg_a > 0.0:
62 avg_r /= avg_a
63 avg_g /= avg_a
64 avg_b /= avg_a
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
76 for i in range(nb_pixels):
77 alpha = float(rgba[i * 4 + 3]) / 255.0
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])
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
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
96 for cy in range(ny):
97 cyf = float(cy)
98 cx = 0
100 while cx * ny < nx * (ny - cy):
101 cxf = float(cx)
102 f = 0.0
104 for x in range(w):
105 fx[x] = math.cos(math.pi / w * cxf * (float(x) + 0.5))
107 for y in range(h):
108 fy = math.cos(math.pi / h * cyf * (float(y) + 0.5))
110 for x in range(w):
111 f += channel[x + y * w] * fx[x] * fy
113 f /= float(nb_pixels)
115 if cx > 0 or cy > 0:
116 ac.append(f)
117 scale = max(scale, abs(f))
118 else:
119 dc = f
121 cx += 1
123 if scale > 0.0:
124 for i in range(len(ac)):
125 ac[i] = 0.5 + 0.5 / scale * ac[i]
127 return (dc, ac, scale)
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 )
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 )
156 return hash.encode()