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.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/thumbhash.rb +321 -0
  3. 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: []