thumbhash 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/thumbhash.rb +321 -0
- metadata +43 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 8a3aead19626a9f7edcc71fb0fb42d4be8d4536ea82bbefdb1ca619edd284b29
|
4
|
+
data.tar.gz: ccd866a6a98dd8bc26b2c419186996f8b44a186333feda099b44fd5b9db90874
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d1f6a165487dee2dc21451f1103c3af660bc25dd570396129121137b882eb7f1186acdfa83ccf585f6352d5f3fb5bf05b1dbd3cd875f26078c1609f7ea0f3b0a
|
7
|
+
data.tar.gz: 57c8d8b6e489861d11be492a8c7f1b1c2026ea4818c10ce4e986c3dc0ec084f87b52d030b1c92f3d038818e2d0e81ed3abe0206eca561b0fbbcce48848a450f2
|
data/lib/thumbhash.rb
ADDED
@@ -0,0 +1,321 @@
|
|
1
|
+
require "base64"
|
2
|
+
|
3
|
+
class ThumbHash
|
4
|
+
|
5
|
+
# Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A.
|
6
|
+
def self.rgba_to_thumb_hash(w, h, rgba)
|
7
|
+
# Encoding an image larger than 100x100 is slow with no benefit
|
8
|
+
raise "#{w}x#{h} doesn't fit in 100x100" if w > 100 || h > 100
|
9
|
+
|
10
|
+
# Determine the average color
|
11
|
+
avg_r, avg_g, avg_b, avg_a = 0, 0, 0, 0
|
12
|
+
(0...(w * h)).each do |i|
|
13
|
+
j = i * 4
|
14
|
+
alpha = rgba[j + 3] / 255.0
|
15
|
+
avg_r += alpha / 255.0 * rgba[j]
|
16
|
+
avg_g += alpha / 255.0 * rgba[j + 1]
|
17
|
+
avg_b += alpha / 255.0 * rgba[j + 2]
|
18
|
+
avg_a += alpha
|
19
|
+
end
|
20
|
+
|
21
|
+
if avg_a > 0
|
22
|
+
avg_r /= avg_a
|
23
|
+
avg_g /= avg_a
|
24
|
+
avg_b /= avg_a
|
25
|
+
end
|
26
|
+
|
27
|
+
has_alpha = avg_a < w * h
|
28
|
+
l_limit = has_alpha ? 5 : 7 # Use fewer luminance bits if there's alpha
|
29
|
+
lx = [1, (l_limit * w / [w, h].max).round].max
|
30
|
+
ly = [1, (l_limit * h / [w, h].max).round].max
|
31
|
+
l = [] # luminance
|
32
|
+
p = [] # yellow - blue
|
33
|
+
q = [] # red - green
|
34
|
+
a = [] # alpha
|
35
|
+
|
36
|
+
# Convert the image from RGBA to LPQA (composite atop the average color)
|
37
|
+
(0...(w * h)).each do |i|
|
38
|
+
j = i * 4
|
39
|
+
alpha = rgba[j + 3] / 255.0
|
40
|
+
r = avg_r * (1 - alpha) + alpha / 255.0 * rgba[j]
|
41
|
+
g = avg_g * (1 - alpha) + alpha / 255.0 * rgba[j + 1]
|
42
|
+
b = avg_b * (1 - alpha) + alpha / 255.0 * rgba[j + 2]
|
43
|
+
l[i] = (r + g + b) / 3
|
44
|
+
p[i] = (r + g) / 2 - b
|
45
|
+
q[i] = r - g
|
46
|
+
a[i] = alpha
|
47
|
+
end
|
48
|
+
|
49
|
+
encode_channel = ->(channel, nx, ny) do
|
50
|
+
dc, ac, scale, fx = 0, [], 0, Array.new(w)
|
51
|
+
|
52
|
+
(0...ny).each do |cy|
|
53
|
+
(0...((nx * (ny - cy)).to_f / ny).ceil).each do |cx|
|
54
|
+
f = 0
|
55
|
+
|
56
|
+
w.times do |x|
|
57
|
+
fx[x] = Math.cos(Math::PI / w * cx * (x + 0.5))
|
58
|
+
end
|
59
|
+
|
60
|
+
h.times do |y|
|
61
|
+
h_scale = Math.cos(Math::PI / h * cy * (y + 0.5))
|
62
|
+
w.times do |x|
|
63
|
+
f += channel[x + y * w] * fx[x] * h_scale
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
f /= w * h
|
68
|
+
|
69
|
+
if cx.zero? && cy.zero?
|
70
|
+
dc = f
|
71
|
+
else
|
72
|
+
ac.push(f)
|
73
|
+
scale = [scale, f.abs].max
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
ac.map! { |x| 0.5 + 0.5 / scale * x } if scale > 0
|
79
|
+
|
80
|
+
[dc, ac, scale]
|
81
|
+
end
|
82
|
+
|
83
|
+
l_dc, l_ac, l_scale = encode_channel.(l, [3, lx].max, [3, ly].max)
|
84
|
+
p_dc, p_ac, p_scale = encode_channel.(p, 3, 3)
|
85
|
+
q_dc, q_ac, q_scale = encode_channel.(q, 3, 3)
|
86
|
+
a_dc, a_ac, a_scale = has_alpha ? encode_channel.(a, 5, 5) : []
|
87
|
+
|
88
|
+
# Write the constants
|
89
|
+
is_landscape = w > h
|
90
|
+
header24 = (63 * l_dc).round | ((31.5 + 31.5 * p_dc).round << 6) | ((31.5 + 31.5 * q_dc).round << 12) | ((31 * l_scale).round << 18) | (has_alpha ? (1 << 23) : 0)
|
91
|
+
header16 = (is_landscape ? ly : lx) | ((63 * p_scale).round << 3) | ((63 * q_scale).round << 9) | (is_landscape ? (1 << 15) : 0)
|
92
|
+
hash = [header24 & 255, (header24 >> 8) & 255, header24 >> 16, header16 & 255, header16 >> 8]
|
93
|
+
ac_start = has_alpha ? 6 : 5
|
94
|
+
ac_index = 0
|
95
|
+
hash.push((15 * a_dc).round | ((15 * a_scale).round << 4)) if has_alpha
|
96
|
+
|
97
|
+
# Write the varying factors
|
98
|
+
ac = has_alpha ? [l_ac, p_ac, q_ac, a_ac] : [l_ac, p_ac, q_ac]
|
99
|
+
|
100
|
+
ac.each do |a|
|
101
|
+
a.each do |f|
|
102
|
+
index = ac_start + (ac_index >> 1)
|
103
|
+
result = (15 * f).round << ((ac_index & 1) * 4)
|
104
|
+
hash[index] = hash[index].nil? ? result : hash[index] | result
|
105
|
+
ac_index += 1
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
hash.pack("C*")
|
110
|
+
end
|
111
|
+
|
112
|
+
# Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
|
113
|
+
def self.thumb_hash_to_rgba(hash)
|
114
|
+
hash = hash.unpack("C*")
|
115
|
+
|
116
|
+
# Read the constants
|
117
|
+
header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16)
|
118
|
+
header16 = hash[3] | (hash[4] << 8)
|
119
|
+
l_dc = (header24 & 63) / 63.0
|
120
|
+
p_dc = ((header24 >> 6) & 63) / 31.5 - 1
|
121
|
+
q_dc = ((header24 >> 12) & 63) / 31.5 - 1
|
122
|
+
l_scale = ((header24 >> 18) & 31) / 31.0
|
123
|
+
has_alpha = header24 >> 23
|
124
|
+
p_scale = ((header16 >> 3) & 63) / 63.0
|
125
|
+
q_scale = ((header16 >> 9) & 63) / 63.0
|
126
|
+
is_landscape = header16 >> 15
|
127
|
+
lx = [3, is_landscape.zero? ? header16 & 7 : (has_alpha.zero? ? 7 : 5)].max
|
128
|
+
ly = [3, is_landscape.zero? ? has_alpha.zero? ? 7 : 5 : header16 & 7].max
|
129
|
+
a_dc = has_alpha.zero? ? 1 : (hash[5] & 15) / 15
|
130
|
+
a_scale = (hash[5] >> 4) / 15.0
|
131
|
+
|
132
|
+
# Read the varying factors (boost saturation by 1.25x to compensate for quantization)
|
133
|
+
ac_start = has_alpha.zero? ? 5 : 6
|
134
|
+
ac_index = 0
|
135
|
+
|
136
|
+
decode_channel = ->(nx, ny, scale) do
|
137
|
+
ac = []
|
138
|
+
(0...ny).each do |cy|
|
139
|
+
cx = cy.zero? ? 1 : 0
|
140
|
+
while cx * ny < nx * (ny - cy)
|
141
|
+
ac.push(
|
142
|
+
(((hash[ac_start + (ac_index >> 1)] >> ((ac_index & 1) << 2)) & 15) / 7.5 - 1) * scale
|
143
|
+
)
|
144
|
+
ac_index += 1
|
145
|
+
cx += 1
|
146
|
+
end
|
147
|
+
end
|
148
|
+
ac
|
149
|
+
end
|
150
|
+
l_ac = decode_channel.(lx, ly, l_scale)
|
151
|
+
p_ac = decode_channel.(3, 3, p_scale * 1.25)
|
152
|
+
q_ac = decode_channel.(3, 3, q_scale * 1.25)
|
153
|
+
a_ac = !has_alpha.zero? && decode_channel.(5, 5, a_scale)
|
154
|
+
|
155
|
+
# Decode using the DCT into RGB
|
156
|
+
ratio = thumb_hash_to_approximate_aspect_ratio(hash)
|
157
|
+
w = (ratio > 1 ? 32 : 32 * ratio).round
|
158
|
+
h = (ratio > 1 ? 32 / ratio : 32).round
|
159
|
+
fx, fy, rgba = [], [], Array.new(w * h * 4, 0)
|
160
|
+
|
161
|
+
(0...h).each do |y|
|
162
|
+
i = y * w * 4
|
163
|
+
(0...w).each do |x|
|
164
|
+
l, p, q, a = l_dc, p_dc, q_dc, a_dc
|
165
|
+
|
166
|
+
# Precompute the coefficients
|
167
|
+
(0...[lx, has_alpha ? 5 : 3].max).each do |cx|
|
168
|
+
fx[cx] = Math.cos((Math::PI / w) * (x + 0.5) * cx)
|
169
|
+
end
|
170
|
+
(0...[ly, has_alpha ? 5 : 3].max).each do |cy|
|
171
|
+
fy[cy] = Math.cos((Math::PI / h) * (y + 0.5) * cy)
|
172
|
+
end
|
173
|
+
|
174
|
+
# Decode L
|
175
|
+
j = 0
|
176
|
+
(0...ly).each do |cy|
|
177
|
+
fy2 = fy[cy] * 2
|
178
|
+
cx = cy.zero? ? 1 : 0
|
179
|
+
|
180
|
+
while cx * ly < lx * (ly - cy)
|
181
|
+
l += l_ac[j] * fx[cx] * fy2
|
182
|
+
cx += 1
|
183
|
+
j += 1
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# Decode P and Q
|
188
|
+
j = 0
|
189
|
+
(0...3).each do |cy|
|
190
|
+
fy2 = fy[cy] * 2
|
191
|
+
cx = cy.zero? ? 1 : 0
|
192
|
+
while cx < 3 - cy
|
193
|
+
f = fx[cx] * fy2
|
194
|
+
p += p_ac[j] * f
|
195
|
+
q += q_ac[j] * f
|
196
|
+
cx += 1
|
197
|
+
j += 1
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
# Decode A
|
202
|
+
if !has_alpha.zero?
|
203
|
+
j = 0
|
204
|
+
(0...5).each do |cy|
|
205
|
+
fy2 = fy[cy] * 2
|
206
|
+
cx = cy.zero? ? 1 : 0
|
207
|
+
while cx < 5 - cy
|
208
|
+
a += a_ac[j] * fx[cx] * fy2
|
209
|
+
cx += 1
|
210
|
+
j += 1
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# Convert to RGB
|
216
|
+
b = l - (2 / 3.0) * p
|
217
|
+
r = (3 * l - b + q) / 2.0
|
218
|
+
g = r - q
|
219
|
+
rgba[i] = [0, 255 * [1, r].min].max.to_i
|
220
|
+
rgba[i + 1] = [0, 255 * [1, g].min].max.to_i
|
221
|
+
rgba[i + 2] = [0, 255 * [1, b].min].max.to_i
|
222
|
+
rgba[i + 3] = [0, 255 * [1, a].min].max.to_i
|
223
|
+
|
224
|
+
i += 4
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
[w, h, rgba]
|
229
|
+
end
|
230
|
+
|
231
|
+
# Extracts the approximate aspect ratio of the original image.
|
232
|
+
def self.thumb_hash_to_approximate_aspect_ratio(hash)
|
233
|
+
header = hash[3]
|
234
|
+
has_alpha = (hash[2] & 0x80) != 0
|
235
|
+
is_landscape = (hash[4] & 0x80) != 0
|
236
|
+
lx = is_landscape ? (has_alpha ? 5 : 7) : header & 7
|
237
|
+
ly = is_landscape ? header & 7 : (has_alpha ? 5 : 7)
|
238
|
+
lx.to_f / ly.to_f
|
239
|
+
end
|
240
|
+
|
241
|
+
# Decodes a ThumbHash to a PNG data URL. This is a convenience function that
|
242
|
+
# just calls "thumb_hash_to_rgba" followed by "rgba_to_data_url".
|
243
|
+
def self.thumb_hash_to_data_url(hash)
|
244
|
+
width, height, rgba = thumb_hash_to_rgba(hash)
|
245
|
+
rgba_to_data_url(width, height, rgba)
|
246
|
+
end
|
247
|
+
|
248
|
+
# Encodes an RGBA image to a PNG data URL. RGB should not be premultiplied by
|
249
|
+
# A. This is optimized for speed and simplicity and does not optimize for size
|
250
|
+
# at all. This doesn't do any compression (all values are stored uncompressed).
|
251
|
+
def self.rgba_to_data_url(w, h, rgba)
|
252
|
+
row = w * 4 + 1
|
253
|
+
idat = 6 + h * (5 + row)
|
254
|
+
|
255
|
+
unsigned_right_shift = ->(value, amount) do
|
256
|
+
mask = (1 << (32 - amount)) - 1
|
257
|
+
(value >> amount) & mask
|
258
|
+
end
|
259
|
+
|
260
|
+
bytes = [
|
261
|
+
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0,
|
262
|
+
w >> 8, w & 255, 0, 0, h >> 8, h & 255, 8, 6, 0, 0, 0, 0, 0, 0, 0,
|
263
|
+
unsigned_right_shift.(idat, 24), (idat >> 16) & 255, (idat >> 8) & 255, idat & 255,
|
264
|
+
73, 68, 65, 84, 120, 1,
|
265
|
+
]
|
266
|
+
table = [
|
267
|
+
0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960,
|
268
|
+
1342533948, -306674912, -267414716, -690576408, -882789492, -1687895376,
|
269
|
+
-2032938284, -1609899400, -1111625188,
|
270
|
+
]
|
271
|
+
|
272
|
+
a = 1
|
273
|
+
b = 0
|
274
|
+
i = 0
|
275
|
+
end_index = row - 1
|
276
|
+
for y in 0...h
|
277
|
+
bytes.push(
|
278
|
+
y + 1 < h ? 0 : 1,
|
279
|
+
row & 255,
|
280
|
+
row >> 8,
|
281
|
+
~row & 255,
|
282
|
+
(row >> 8) ^ 255,
|
283
|
+
0
|
284
|
+
)
|
285
|
+
b = (b + a) % 65521
|
286
|
+
while i < end_index
|
287
|
+
u = rgba[i] & 255
|
288
|
+
bytes.push(u)
|
289
|
+
a = (a + u) % 65521
|
290
|
+
b = (b + a) % 65521
|
291
|
+
i += 1
|
292
|
+
end
|
293
|
+
|
294
|
+
end_index += row - 1
|
295
|
+
end
|
296
|
+
|
297
|
+
bytes.push(b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0,
|
298
|
+
0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130)
|
299
|
+
|
300
|
+
[[12, 29], [37, 41 + idat]].each do |start_index, end_index|
|
301
|
+
c = ~0
|
302
|
+
for i in (start_index...end_index)
|
303
|
+
c ^= bytes[i]
|
304
|
+
c = unsigned_right_shift.(c, 4) ^ table[c & 15]
|
305
|
+
c = unsigned_right_shift.(c, 4) ^ table[c & 15]
|
306
|
+
end
|
307
|
+
c = ~c
|
308
|
+
|
309
|
+
bytes[end_index] = unsigned_right_shift.(c, 24)
|
310
|
+
end_index += 1
|
311
|
+
bytes[end_index] = (c >> 16) & 255
|
312
|
+
end_index += 1
|
313
|
+
bytes[end_index] = (c >> 8) & 255
|
314
|
+
end_index += 1
|
315
|
+
bytes[end_index] = c & 255
|
316
|
+
end_index += 1
|
317
|
+
end
|
318
|
+
|
319
|
+
"data:image/png;base64," + Base64.encode64(bytes.map(&:chr).join).delete("\n")
|
320
|
+
end
|
321
|
+
end
|
metadata
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: thumbhash
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- David Newell
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-03-28 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description:
|
14
|
+
email: david@newellis.cool
|
15
|
+
executables: []
|
16
|
+
extensions: []
|
17
|
+
extra_rdoc_files: []
|
18
|
+
files:
|
19
|
+
- lib/thumbhash.rb
|
20
|
+
homepage: https://github.com/daibhin/thumbhash
|
21
|
+
licenses:
|
22
|
+
- MIT
|
23
|
+
metadata: {}
|
24
|
+
post_install_message:
|
25
|
+
rdoc_options: []
|
26
|
+
require_paths:
|
27
|
+
- lib
|
28
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
34
|
+
requirements:
|
35
|
+
- - ">="
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
requirements: []
|
39
|
+
rubygems_version: 3.1.4
|
40
|
+
signing_key:
|
41
|
+
specification_version: 4
|
42
|
+
summary: A Ruby implememntation of ThumbHash
|
43
|
+
test_files: []
|