thumbhash 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|