retouch 0.3.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +12 -4
- data/LICENSE +1 -1
- data/README.md +74 -67
- data/bench/retouch.rb +4 -2
- data/docs/index.html +28 -0
- data/lib/retouch/batch.rb +61 -41
- data/lib/retouch/cli.rb +178 -187
- data/lib/retouch/io.rb +5 -19
- data/lib/retouch/kernels/color.rb +18 -0
- data/lib/retouch/kernels/convolve.rb +60 -0
- data/lib/retouch/kernels/lut.rb +75 -0
- data/lib/retouch/operations/color.rb +21 -0
- data/lib/retouch/operations/draw.rb +160 -0
- data/lib/retouch/operations/filter.rb +64 -0
- data/lib/retouch/operations/multiple.rb +192 -0
- data/lib/retouch/operations.rb +31 -482
- data/lib/retouch/pipeline.rb +3 -4
- data/lib/retouch/version.rb +1 -1
- data/lib/retouch.rb +13 -5
- data/retouch.gemspec +2 -2
- data/sig/retouch.rbs +43 -11
- metadata +12 -4
data/lib/retouch/operations.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Retouch
|
|
4
4
|
module Operations
|
|
5
|
+
WEIGHT_SCALE = 1 << 20
|
|
6
|
+
|
|
5
7
|
module_function
|
|
6
8
|
|
|
7
9
|
def apply(image, name, *arguments, **options)
|
|
@@ -43,7 +45,7 @@ module Retouch
|
|
|
43
45
|
Tessel::Image.from_rgba(result.width, result.height, result.bytes)
|
|
44
46
|
end
|
|
45
47
|
|
|
46
|
-
def crop(image, geometry, gravity: :north_west
|
|
48
|
+
def crop(image, geometry, gravity: :north_west)
|
|
47
49
|
geometry = Geometry.parse(geometry) unless geometry.is_a?(Geometry)
|
|
48
50
|
width, height = geometry.crop_size(image.width, image.height)
|
|
49
51
|
x, y = if geometry.offset_x.zero? && geometry.offset_y.zero?
|
|
@@ -89,7 +91,7 @@ module Retouch
|
|
|
89
91
|
dy = y - target_cy
|
|
90
92
|
sx = (cosine * dx) + (sine * dy) + source_cx
|
|
91
93
|
sy = (-sine * dx) + (cosine * dy) + source_cy
|
|
92
|
-
output[x, y] = sample_bilinear(image, sx, sy, background) if sx.between?(0, image.width - 1) && sy
|
|
94
|
+
output[x, y] = sample_bilinear(image, sx, sy, background) if sx.between?(0, image.width - 1) && sy.between?(0, image.height - 1)
|
|
93
95
|
end
|
|
94
96
|
end
|
|
95
97
|
output
|
|
@@ -112,7 +114,7 @@ module Retouch
|
|
|
112
114
|
Tessel::Image.new(width, height, fill: color, metadata: image.metadata).tap { |canvas| canvas.blit(image, x, y) }
|
|
113
115
|
end
|
|
114
116
|
|
|
115
|
-
def border(image, width, color = "#000000"
|
|
117
|
+
def border(image, width, color = "#000000")
|
|
116
118
|
width = Integer(width)
|
|
117
119
|
raise ArgumentError, "border width must be positive" unless width.positive?
|
|
118
120
|
|
|
@@ -141,299 +143,6 @@ module Retouch
|
|
|
141
143
|
copy_metadata(image.crop(bounds[0], bounds[1], bounds[2] - bounds[0] + 1, bounds[3] - bounds[1] + 1), image)
|
|
142
144
|
end
|
|
143
145
|
|
|
144
|
-
def grayscale(image)
|
|
145
|
-
map_rgb(image) do |r, g, b|
|
|
146
|
-
v = ((0.2126 * r) + (0.7152 * g) + (0.0722 * b)).round
|
|
147
|
-
[v, v, v]
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def invert(image) = map_lut(image, (0..255).map { |value| 255 - value })
|
|
152
|
-
|
|
153
|
-
def brightness(image, amount)
|
|
154
|
-
offset = Float(amount)
|
|
155
|
-
offset *= 255 if offset.abs <= 1
|
|
156
|
-
map_lut(image, (0..255).map { |value| (value + offset).round.clamp(0, 255) })
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def contrast(image, amount)
|
|
160
|
-
factor = Float(amount)
|
|
161
|
-
factor = (1.0 + factor) if factor.abs <= 1
|
|
162
|
-
map_lut(image, (0..255).map { |value| (((value - 127.5) * factor) + 127.5).round.clamp(0, 255) })
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def gamma(image, value)
|
|
166
|
-
gamma = Float(value)
|
|
167
|
-
raise ArgumentError, "gamma must be positive" unless gamma.positive?
|
|
168
|
-
|
|
169
|
-
lut = (0..255).map { |v| (255 * ((v / 255.0)**(1.0 / gamma))).round }
|
|
170
|
-
map_lut(image, lut)
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def saturate(image, amount)
|
|
174
|
-
factor = Float(amount)
|
|
175
|
-
map_rgb(image) do |r, g, b|
|
|
176
|
-
gray = (0.2126 * r) + (0.7152 * g) + (0.0722 * b)
|
|
177
|
-
[r, g, b].map { |v| (gray + ((v - gray) * factor)).round.clamp(0, 255) }
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def tint(image, color, amount: 0.5)
|
|
182
|
-
tint = Tessel::Color.pack(color).bytes
|
|
183
|
-
amount = Float(amount)
|
|
184
|
-
raise ArgumentError, "tint amount must be between 0 and 1" unless amount.between?(0, 1)
|
|
185
|
-
|
|
186
|
-
map_rgb(image) { |r, g, b| [r, g, b].zip(tint.first(3)).map { |a, c| ((a * (1 - amount)) + (c * amount)).round } }
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def opacity(image, amount)
|
|
190
|
-
factor = Float(amount)
|
|
191
|
-
factor /= 100 if factor > 1
|
|
192
|
-
raise ArgumentError, "opacity must be between 0 and 1 (or 0 and 100)" unless factor.between?(0, 1)
|
|
193
|
-
|
|
194
|
-
map_rgba(image) { |r, g, b, a| [r, g, b, (a * factor).round] }
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
def quantize(image, colors: 256, dither: :none)
|
|
198
|
-
indices, palette = Tessel::Quantize.quantize(image, colors: Integer(colors), dither: dither.to_sym)
|
|
199
|
-
bytes = image.bytes
|
|
200
|
-
output = Tessel::Image.new(image.width, image.height, metadata: image.metadata)
|
|
201
|
-
indices.bytes.each_with_index do |index, pixel|
|
|
202
|
-
r, g, b = palette[index]
|
|
203
|
-
output[pixel % image.width, pixel / image.width] = [r, g, b, bytes.getbyte((pixel * 4) + 3)]
|
|
204
|
-
end
|
|
205
|
-
output
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def blur(image, sigma: 1.0)
|
|
209
|
-
sigma = Float(sigma)
|
|
210
|
-
raise ArgumentError, "sigma must be positive and at most 100" unless sigma.positive? && sigma <= 100
|
|
211
|
-
|
|
212
|
-
radius = (sigma * 3).ceil
|
|
213
|
-
weights = (-radius..radius).map { |n| Math.exp(-(n * n) / (2 * sigma * sigma)) }
|
|
214
|
-
total = weights.sum
|
|
215
|
-
weights.map! { |weight| weight / total }
|
|
216
|
-
convolve(convolve(image, weights, horizontal: true), weights, horizontal: false)
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def sharpen(image, amount: 1.0, sigma: 1.0)
|
|
220
|
-
blurred = blur(image, sigma:)
|
|
221
|
-
original = image.bytes
|
|
222
|
-
soft = blurred.bytes
|
|
223
|
-
result = String.new(capacity: original.bytesize, encoding: Encoding::BINARY)
|
|
224
|
-
original.bytesize.times do |i|
|
|
225
|
-
result << (i % 4 == 3 ? original.getbyte(i) : (original.getbyte(i) + (Float(amount) * (original.getbyte(i) - soft.getbyte(i)))).round.clamp(0, 255))
|
|
226
|
-
end
|
|
227
|
-
Tessel::Image.from_rgba(image.width, image.height, result, metadata: image.metadata)
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def pixelate(image, size)
|
|
231
|
-
size = Integer(size)
|
|
232
|
-
raise ArgumentError, "pixel size must be positive" unless size.positive?
|
|
233
|
-
|
|
234
|
-
output = image.dup
|
|
235
|
-
(0...image.height).step(size) do |y|
|
|
236
|
-
(0...image.width).step(size) do |x|
|
|
237
|
-
sw = [size, image.width - x].min
|
|
238
|
-
sh = [size, image.height - y].min
|
|
239
|
-
sums = [0.0, 0.0, 0.0, 0.0]
|
|
240
|
-
(y...(y + sh)).each do |row|
|
|
241
|
-
(x...(x + sw)).each do |col|
|
|
242
|
-
red, green, blue, alpha = image[col, row]
|
|
243
|
-
sums[0] += red * alpha / 255.0
|
|
244
|
-
sums[1] += green * alpha / 255.0
|
|
245
|
-
sums[2] += blue * alpha / 255.0
|
|
246
|
-
sums[3] += alpha
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
alpha = (sums[3] / (sw * sh)).round
|
|
250
|
-
color = if sums[3].positive?
|
|
251
|
-
[*sums.first(3).map { |value| (value * 255 / sums[3]).round }, alpha]
|
|
252
|
-
else
|
|
253
|
-
[0, 0, 0, 0]
|
|
254
|
-
end
|
|
255
|
-
output.fill_rect(x, y, sw, sh, color)
|
|
256
|
-
end
|
|
257
|
-
end
|
|
258
|
-
output
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
def overlay(image, source, x: nil, y: nil, gravity: :center, opacity: 1.0, blend: :normal)
|
|
262
|
-
source = source.to_image if source.is_a?(Pipeline)
|
|
263
|
-
raise TypeError, "overlay must be a Tessel::Image or Retouch::Pipeline" unless source.is_a?(Tessel::Image)
|
|
264
|
-
|
|
265
|
-
default_x, default_y = Gravity.offset(image.width, image.height, source.width, source.height, gravity)
|
|
266
|
-
x ||= default_x
|
|
267
|
-
y ||= default_y
|
|
268
|
-
opacity = Float(opacity)
|
|
269
|
-
opacity /= 100 if opacity > 1
|
|
270
|
-
raise ArgumentError, "opacity must be between 0 and 1" unless opacity.between?(0, 1)
|
|
271
|
-
|
|
272
|
-
output = image.dup
|
|
273
|
-
source.height.times do |sy|
|
|
274
|
-
source.width.times do |sx|
|
|
275
|
-
dx = Integer(x) + sx
|
|
276
|
-
dy = Integer(y) + sy
|
|
277
|
-
next unless dx.between?(0, image.width - 1) && dy.between?(0, image.height - 1)
|
|
278
|
-
|
|
279
|
-
source_pixel = source[sx, sy]
|
|
280
|
-
next if source_pixel[3].zero?
|
|
281
|
-
|
|
282
|
-
source_pixel[3] = (source_pixel[3] * opacity).round
|
|
283
|
-
output[dx, dy] = composite_pixel(output[dx, dy], source_pixel, blend.to_sym)
|
|
284
|
-
end
|
|
285
|
-
end
|
|
286
|
-
output
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
def watermark(image, source, gravity: :south_east, opacity: 0.5, **options)
|
|
290
|
-
overlay(image, source, gravity:, opacity:, **options)
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def text(image, value, at: :north_west, x: nil, y: nil, size: 16, color: "#ffffff", font: nil, background: nil, padding: 4, **_options)
|
|
294
|
-
padding = Integer(padding)
|
|
295
|
-
raise ArgumentError, "padding must not be negative" if padding.negative?
|
|
296
|
-
|
|
297
|
-
font = font_for(font, size, "text")
|
|
298
|
-
width, height = font.measure(String(value))
|
|
299
|
-
x ||= Gravity.offset(image.width, image.height, width + (padding * 2), height + (padding * 2), at).first
|
|
300
|
-
y ||= Gravity.offset(image.width, image.height, width + (padding * 2), height + (padding * 2), at).last
|
|
301
|
-
output = image.dup
|
|
302
|
-
output.fill_rect(x, y, width + (padding * 2), height + (padding * 2), background) if background
|
|
303
|
-
font.draw(output, x + padding, y + padding, String(value), color:)
|
|
304
|
-
output
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
def rect(image, geometry, color: "#ffffff", fill: false, width: 1)
|
|
308
|
-
geometry = Geometry.parse(geometry) unless geometry.is_a?(Geometry)
|
|
309
|
-
x = geometry.offset_x
|
|
310
|
-
y = geometry.offset_y
|
|
311
|
-
w, h = geometry.crop_size(image.width, image.height)
|
|
312
|
-
output = image.dup
|
|
313
|
-
if fill
|
|
314
|
-
output.fill_rect(x, y, w, h, color, blend: :alpha)
|
|
315
|
-
else
|
|
316
|
-
width = Integer(width)
|
|
317
|
-
raise ArgumentError, "stroke width must be positive" unless width.positive?
|
|
318
|
-
|
|
319
|
-
output.fill_rect(x, y, w, width, color, blend: :alpha)
|
|
320
|
-
output.fill_rect(x, y + h - width, w, width, color, blend: :alpha)
|
|
321
|
-
output.fill_rect(x, y, width, h, color, blend: :alpha)
|
|
322
|
-
output.fill_rect(x + w - width, y, width, h, color, blend: :alpha)
|
|
323
|
-
end
|
|
324
|
-
output
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
def arrow(image, x1, y1, x2, y2, color: "#ffffff", width: 2, head: 10)
|
|
328
|
-
output = image.dup
|
|
329
|
-
x1, y1, x2, y2, width, head = [x1, y1, x2, y2, width, head].map { |v| Float(v) }
|
|
330
|
-
raise ArgumentError, "arrow width and head must be positive" unless width.positive? && head.positive?
|
|
331
|
-
|
|
332
|
-
draw_line(output, x1, y1, x2, y2, color, width)
|
|
333
|
-
angle = Math.atan2(y2 - y1, x2 - x1)
|
|
334
|
-
[angle + 2.6, angle - 2.6].each do |side|
|
|
335
|
-
draw_line(output, x2, y2, x2 - (head * Math.cos(side)), y2 - (head * Math.sin(side)), color, width)
|
|
336
|
-
end
|
|
337
|
-
output
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
def montage(images, cols: 3, gap: 0, background: "#ffffff", label: false, labels: nil, font: nil)
|
|
341
|
-
images = images.map { |image| image.is_a?(Pipeline) ? image.to_image : image }
|
|
342
|
-
raise ArgumentError, "montage needs at least one image" if images.empty?
|
|
343
|
-
|
|
344
|
-
cols = Integer(cols)
|
|
345
|
-
gap = Integer(gap)
|
|
346
|
-
raise ArgumentError, "cols must be positive and gap non-negative" unless cols.positive? && gap >= 0
|
|
347
|
-
|
|
348
|
-
if label
|
|
349
|
-
font = font_for(font, 12, "montage labels")
|
|
350
|
-
labels ||= images.each_index.map(&:to_s)
|
|
351
|
-
raise ArgumentError, "montage needs one label per image" unless labels.length == images.length
|
|
352
|
-
end
|
|
353
|
-
cell_width = images.map(&:width).max
|
|
354
|
-
label_height = label ? font.line_height + 4 : 0
|
|
355
|
-
cell_height = images.map(&:height).max + label_height
|
|
356
|
-
rows = (images.length.to_f / cols).ceil
|
|
357
|
-
output = Tessel::Image.new((cols * cell_width) + ((cols - 1) * gap), (rows * cell_height) + ((rows - 1) * gap), fill: background)
|
|
358
|
-
images.each_with_index do |image, index|
|
|
359
|
-
col = index % cols
|
|
360
|
-
row = index / cols
|
|
361
|
-
x = (col * (cell_width + gap)) + ((cell_width - image.width) / 2)
|
|
362
|
-
y = (row * (cell_height + gap)) + ((cell_height - label_height - image.height) / 2)
|
|
363
|
-
output.blit(image, x, y)
|
|
364
|
-
font.draw(output, col * (cell_width + gap), y + image.height + 2, String(labels[index]), color: "#000000") if label
|
|
365
|
-
end
|
|
366
|
-
output
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
def append(images, direction: :horizontal, gap: 0, background: [0, 0, 0, 0])
|
|
370
|
-
images = images.map { |image| image.is_a?(Pipeline) ? image.to_image : image }
|
|
371
|
-
raise ArgumentError, "append needs at least one image" if images.empty?
|
|
372
|
-
|
|
373
|
-
gap = Integer(gap)
|
|
374
|
-
raise ArgumentError, "gap must not be negative" if gap.negative?
|
|
375
|
-
|
|
376
|
-
raise ArgumentError, "direction must be :horizontal or :vertical" unless %i[horizontal vertical].include?(direction.to_sym)
|
|
377
|
-
|
|
378
|
-
horizontal = direction.to_sym == :horizontal
|
|
379
|
-
width = horizontal ? images.sum(&:width) + (gap * (images.length - 1)) : images.map(&:width).max
|
|
380
|
-
height = horizontal ? images.map(&:height).max : images.sum(&:height) + (gap * (images.length - 1))
|
|
381
|
-
output = Tessel::Image.new(width, height, fill: background)
|
|
382
|
-
offset = 0
|
|
383
|
-
images.each do |image|
|
|
384
|
-
x, y = horizontal ? [offset, (height - image.height) / 2] : [(width - image.width) / 2, offset]
|
|
385
|
-
output.blit(image, x, y)
|
|
386
|
-
offset += (horizontal ? image.width : image.height) + gap
|
|
387
|
-
end
|
|
388
|
-
output
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
def spritesheet(images, cols: nil, gap: 0, background: [0, 0, 0, 0])
|
|
392
|
-
images = images.map { |image| image.is_a?(Pipeline) ? image.to_image : image }
|
|
393
|
-
raise ArgumentError, "spritesheet needs at least one image" if images.empty?
|
|
394
|
-
|
|
395
|
-
gap = Integer(gap)
|
|
396
|
-
raise ArgumentError, "gap must not be negative" if gap.negative?
|
|
397
|
-
|
|
398
|
-
if cols
|
|
399
|
-
cols = Integer(cols)
|
|
400
|
-
raise ArgumentError, "cols must be positive" unless cols.positive?
|
|
401
|
-
|
|
402
|
-
cell_width = images.map(&:width).max
|
|
403
|
-
cell_height = images.map(&:height).max
|
|
404
|
-
positions = images.each_index.map do |i|
|
|
405
|
-
[(i % cols) * (cell_width + gap), (i / cols) * (cell_height + gap)]
|
|
406
|
-
end
|
|
407
|
-
columns = [cols, images.length].min
|
|
408
|
-
width = (columns * cell_width) + ((columns - 1) * gap)
|
|
409
|
-
height = ((images.length.to_f / cols).ceil * cell_height) + (((images.length.to_f / cols).ceil - 1) * gap)
|
|
410
|
-
else
|
|
411
|
-
positions, width, height = pack_sprites(images, gap)
|
|
412
|
-
end
|
|
413
|
-
sheet = Tessel::Image.new(width, height, fill: background)
|
|
414
|
-
images.each_with_index { |image, i| sheet.blit(image, *positions[i]) }
|
|
415
|
-
frames = images.each_with_index.to_h do |image, i|
|
|
416
|
-
[i.to_s, { "x" => positions[i][0], "y" => positions[i][1], "width" => image.width, "height" => image.height }]
|
|
417
|
-
end
|
|
418
|
-
[sheet, { "frames" => frames, "meta" => { "size" => { "w" => sheet.width, "h" => sheet.height }, "scale" => "1" } }]
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
def animate(images, path, fps: nil, delay: nil, loop: true, format: nil, **options)
|
|
422
|
-
require_optional("flipbook", "animate") { require "flipbook" }
|
|
423
|
-
Flipbook.write(path, images.map { |image| image.is_a?(Pipeline) ? image.to_image : image }, fps:, delay:, loop:, format:, **options)
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
def diff(expected, actual, max_delta: 0)
|
|
427
|
-
expected = expected.to_image if expected.is_a?(Pipeline)
|
|
428
|
-
actual = actual.to_image if actual.is_a?(Pipeline)
|
|
429
|
-
require_optional("lookalike", "diff") { require "lookalike" }
|
|
430
|
-
comparison = Lookalike.compare(expected, actual, max_delta:)
|
|
431
|
-
diff = comparison.diff_image || Tessel::Image.new(expected.width, expected.height)
|
|
432
|
-
ratio = comparison.diff_pixels.to_f / ([expected.width, actual.width].max * [expected.height, actual.height].max)
|
|
433
|
-
metadata = diff.metadata.merge("diff_pixels" => comparison.diff_pixels.to_s, "diff_ratio" => ratio.to_s)
|
|
434
|
-
Tessel::Image.from_rgba(diff.width, diff.height, diff.bytes, metadata:)
|
|
435
|
-
end
|
|
436
|
-
|
|
437
146
|
def resample(image, width, height, filter)
|
|
438
147
|
filter = filter.to_sym
|
|
439
148
|
raise ArgumentError, "unknown resize filter: #{filter}" unless %i[nearest bilinear bicubic lanczos3].include?(filter)
|
|
@@ -445,29 +154,30 @@ module Retouch
|
|
|
445
154
|
intermediate = String.new(capacity: width * image.height * 4, encoding: Encoding::BINARY)
|
|
446
155
|
image.height.times do |y|
|
|
447
156
|
width.times do |x|
|
|
448
|
-
sums = [0
|
|
157
|
+
sums = [0, 0, 0, 0]
|
|
449
158
|
horizontal[x].each do |sx, weight|
|
|
450
159
|
offset = ((y * image.width) + sx) * 4
|
|
451
|
-
alpha = source.getbyte(offset + 3)
|
|
160
|
+
alpha = source.getbyte(offset + 3)
|
|
452
161
|
sums[0] += source.getbyte(offset) * alpha * weight
|
|
453
162
|
sums[1] += source.getbyte(offset + 1) * alpha * weight
|
|
454
163
|
sums[2] += source.getbyte(offset + 2) * alpha * weight
|
|
455
164
|
sums[3] += source.getbyte(offset + 3) * weight
|
|
456
165
|
end
|
|
457
|
-
|
|
166
|
+
3.times { |channel| intermediate << round_divide(sums[channel], 255 * WEIGHT_SCALE).clamp(0, 255) }
|
|
167
|
+
intermediate << round_divide(sums[3], WEIGHT_SCALE).clamp(0, 255)
|
|
458
168
|
end
|
|
459
169
|
end
|
|
460
170
|
result = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
|
|
461
171
|
height.times do |y|
|
|
462
172
|
width.times do |x|
|
|
463
|
-
sums = [0
|
|
173
|
+
sums = [0, 0, 0, 0]
|
|
464
174
|
vertical[y].each do |sy, weight|
|
|
465
175
|
offset = ((sy * width) + x) * 4
|
|
466
176
|
4.times { |channel| sums[channel] += intermediate.getbyte(offset + channel) * weight }
|
|
467
177
|
end
|
|
468
|
-
alpha = sums[3].
|
|
178
|
+
alpha = round_divide(sums[3], WEIGHT_SCALE).clamp(0, 255)
|
|
469
179
|
if alpha.positive?
|
|
470
|
-
3.times { |channel| result << (sums[channel] * 255
|
|
180
|
+
3.times { |channel| result << round_divide(sums[channel] * 255, WEIGHT_SCALE * alpha).clamp(0, 255) }
|
|
471
181
|
else
|
|
472
182
|
result << "\0\0\0".b
|
|
473
183
|
end
|
|
@@ -495,92 +205,31 @@ module Retouch
|
|
|
495
205
|
weights[index.clamp(0, source - 1)] += weight
|
|
496
206
|
end
|
|
497
207
|
total = weights.values.sum
|
|
498
|
-
weights.map { |index, weight| [index, weight / total] }
|
|
208
|
+
fixed = weights.map { |index, weight| [index, ((weight / total) * WEIGHT_SCALE).round] }
|
|
209
|
+
largest = fixed.each_index.max_by { |i| fixed[i][1].abs }
|
|
210
|
+
fixed[largest][1] += WEIGHT_SCALE - fixed.sum { |_, weight| weight }
|
|
211
|
+
fixed
|
|
499
212
|
end
|
|
500
213
|
end
|
|
501
214
|
|
|
502
|
-
def
|
|
503
|
-
|
|
504
|
-
return (((1.5 * x) - 2.5) * x * x) + 1 if x < 1
|
|
505
|
-
return (((((-0.5 * x) + 2.5) * x) - 4) * x) + 2 if x < 2
|
|
506
|
-
|
|
507
|
-
0.0
|
|
215
|
+
def round_divide(value, divisor)
|
|
216
|
+
value.negative? ? -((-value + (divisor / 2)) / divisor) : (value + (divisor / 2)) / divisor
|
|
508
217
|
end
|
|
509
218
|
|
|
510
|
-
def
|
|
511
|
-
|
|
512
|
-
return 1.
|
|
513
|
-
return 0.
|
|
219
|
+
def cubic(value)
|
|
220
|
+
value = value.abs
|
|
221
|
+
return (((1.5 * value) - 2.5) * value * value) + 1 if value < 1
|
|
222
|
+
return (((((-0.5 * value) + 2.5) * value) - 4) * value) + 2 if value < 2
|
|
514
223
|
|
|
515
|
-
|
|
516
|
-
end
|
|
517
|
-
|
|
518
|
-
def convolve(image, weights, horizontal:)
|
|
519
|
-
radius = weights.length / 2
|
|
520
|
-
source = image.bytes.unpack("C*")
|
|
521
|
-
output = String.new(capacity: image.width * image.height * 4, encoding: Encoding::BINARY)
|
|
522
|
-
span = horizontal ? image.width : image.height
|
|
523
|
-
contributions = Array.new(span) do |position|
|
|
524
|
-
weights.each_index.map { |tap| (position + tap - radius).clamp(0, span - 1) }
|
|
525
|
-
end
|
|
526
|
-
y = 0
|
|
527
|
-
while y < image.height
|
|
528
|
-
x = 0
|
|
529
|
-
while x < image.width
|
|
530
|
-
red = 0.0
|
|
531
|
-
green = 0.0
|
|
532
|
-
blue = 0.0
|
|
533
|
-
alpha_sum = 0.0
|
|
534
|
-
taps = contributions[horizontal ? x : y]
|
|
535
|
-
i = 0
|
|
536
|
-
while i < weights.length
|
|
537
|
-
offset = horizontal ? ((y * image.width) + taps[i]) * 4 : ((taps[i] * image.width) + x) * 4
|
|
538
|
-
alpha = source[offset + 3] / 255.0
|
|
539
|
-
weight = weights[i]
|
|
540
|
-
red += source[offset] * alpha * weight
|
|
541
|
-
green += source[offset + 1] * alpha * weight
|
|
542
|
-
blue += source[offset + 2] * alpha * weight
|
|
543
|
-
alpha_sum += source[offset + 3] * weight
|
|
544
|
-
i += 1
|
|
545
|
-
end
|
|
546
|
-
alpha = alpha_sum.round.clamp(0, 255)
|
|
547
|
-
if alpha.positive?
|
|
548
|
-
output << (red * 255 / alpha).round.clamp(0, 255)
|
|
549
|
-
output << (green * 255 / alpha).round.clamp(0, 255)
|
|
550
|
-
output << (blue * 255 / alpha).round.clamp(0, 255)
|
|
551
|
-
else
|
|
552
|
-
output << "\0\0\0".b
|
|
553
|
-
end
|
|
554
|
-
output << alpha
|
|
555
|
-
x += 1
|
|
556
|
-
end
|
|
557
|
-
y += 1
|
|
558
|
-
end
|
|
559
|
-
Tessel::Image.from_rgba(image.width, image.height, output, metadata: image.metadata)
|
|
560
|
-
end
|
|
561
|
-
|
|
562
|
-
def map_rgba(image)
|
|
563
|
-
output = String.new(capacity: image.width * image.height * 4, encoding: Encoding::BINARY)
|
|
564
|
-
image.bytes.bytes.each_slice(4) { |rgba| yield(*rgba).each { |v| output << Integer(v).clamp(0, 255) } }
|
|
565
|
-
Tessel::Image.from_rgba(image.width, image.height, output, metadata: image.metadata)
|
|
224
|
+
0.0
|
|
566
225
|
end
|
|
567
226
|
|
|
568
|
-
def
|
|
569
|
-
|
|
570
|
-
|
|
227
|
+
def lanczos(value)
|
|
228
|
+
value = value.abs
|
|
229
|
+
return 1.0 if value.zero?
|
|
230
|
+
return 0.0 if value >= 3
|
|
571
231
|
|
|
572
|
-
|
|
573
|
-
source = image.bytes
|
|
574
|
-
output = String.new(capacity: source.bytesize, encoding: Encoding::BINARY)
|
|
575
|
-
offset = 0
|
|
576
|
-
while offset < source.bytesize
|
|
577
|
-
output << lookup[source.getbyte(offset)]
|
|
578
|
-
output << lookup[source.getbyte(offset + 1)]
|
|
579
|
-
output << lookup[source.getbyte(offset + 2)]
|
|
580
|
-
output << source.getbyte(offset + 3)
|
|
581
|
-
offset += 4
|
|
582
|
-
end
|
|
583
|
-
Tessel::Image.from_rgba(image.width, image.height, output, metadata: image.metadata)
|
|
232
|
+
Math.sin(Math::PI * value) * Math.sin(Math::PI * value / 3) * 3 / (Math::PI * Math::PI * value * value)
|
|
584
233
|
end
|
|
585
234
|
|
|
586
235
|
def copy_metadata(output, source)
|
|
@@ -613,113 +262,13 @@ module Retouch
|
|
|
613
262
|
pixels.each do |px, py, weight|
|
|
614
263
|
color = image[px.clamp(0, image.width - 1), py.clamp(0, image.height - 1)] || Tessel::Color.pack(background).bytes
|
|
615
264
|
alpha = color[3] / 255.0
|
|
616
|
-
3.times { |
|
|
265
|
+
3.times { |channel| sums[channel] += color[channel] * alpha * weight }
|
|
617
266
|
sums[3] += color[3] * weight
|
|
618
267
|
end
|
|
619
268
|
alpha = sums[3].round.clamp(0, 255)
|
|
620
|
-
[*(alpha.positive? ? sums.first(3).map { |
|
|
621
|
-
end
|
|
622
|
-
|
|
623
|
-
def composite_pixel(destination, source, mode)
|
|
624
|
-
raise ArgumentError, "unknown blend mode: #{mode}" unless %i[normal multiply screen overlay darken lighten add].include?(mode)
|
|
625
|
-
|
|
626
|
-
sa = source[3] / 255.0
|
|
627
|
-
da = destination[3] / 255.0
|
|
628
|
-
out_a = sa + (da * (1 - sa))
|
|
629
|
-
return [0, 0, 0, 0] if out_a.zero?
|
|
630
|
-
|
|
631
|
-
rgb = 3.times.map do |c|
|
|
632
|
-
s = source[c]
|
|
633
|
-
d = destination[c]
|
|
634
|
-
mixed = case mode
|
|
635
|
-
when :normal then s
|
|
636
|
-
when :multiply then s * d / 255.0
|
|
637
|
-
when :screen then 255 - ((255 - s) * (255 - d) / 255.0)
|
|
638
|
-
when :overlay then d < 128 ? 2 * s * d / 255.0 : 255 - (2 * (255 - s) * (255 - d) / 255.0)
|
|
639
|
-
when :darken then [s, d].min
|
|
640
|
-
when :lighten then [s, d].max
|
|
641
|
-
when :add then [s + d, 255].min
|
|
642
|
-
end
|
|
643
|
-
source_only = (1 - da) * sa * s
|
|
644
|
-
destination_only = (1 - sa) * da * d
|
|
645
|
-
overlap = sa * da * mixed
|
|
646
|
-
((source_only + destination_only + overlap) / out_a).round.clamp(0, 255)
|
|
647
|
-
end
|
|
648
|
-
[*rgb, (out_a * 255).round]
|
|
269
|
+
[*(alpha.positive? ? sums.first(3).map { |value| (value * 255 / alpha).round.clamp(0, 255) } : [0, 0, 0]), alpha]
|
|
649
270
|
end
|
|
650
271
|
|
|
651
|
-
|
|
652
|
-
steps = [(x2 - x1).abs, (y2 - y1).abs].max.ceil
|
|
653
|
-
radius = [width / 2.0, 0.5].max
|
|
654
|
-
(0..steps).each do |n|
|
|
655
|
-
t = steps.zero? ? 0 : n.to_f / steps
|
|
656
|
-
x = (x1 + ((x2 - x1) * t)).round
|
|
657
|
-
y = (y1 + ((y2 - y1) * t)).round
|
|
658
|
-
image.fill_rect((x - radius).floor, (y - radius).floor, radius.ceil * 2, radius.ceil * 2, color, blend: :alpha)
|
|
659
|
-
end
|
|
660
|
-
end
|
|
661
|
-
|
|
662
|
-
def font_for(font, size, operation)
|
|
663
|
-
require_optional("glyphic", operation) { require "glyphic" }
|
|
664
|
-
font ||= ENV.fetch("RETOUCH_FONT", nil)
|
|
665
|
-
raise ArgumentError, "#{operation} needs font: or RETOUCH_FONT pointing to a BDF/TTF font" unless font
|
|
666
|
-
return font if font.respond_to?(:draw)
|
|
667
|
-
return Glyphic::BDF.load(font) if File.extname(font).downcase == ".bdf"
|
|
668
|
-
|
|
669
|
-
Glyphic::TrueType.load(font, size: Integer(size))
|
|
670
|
-
end
|
|
671
|
-
|
|
672
|
-
def pack_sprites(images, gap)
|
|
673
|
-
sizes = images.map { |image| [image.width + gap, image.height + gap] }
|
|
674
|
-
bin_width = [sizes.map(&:first).max, Math.sqrt(sizes.sum { |w, h| w * h }).ceil].max
|
|
675
|
-
free = [[0, 0, bin_width, sizes.sum(&:last)]]
|
|
676
|
-
positions = Array.new(images.length)
|
|
677
|
-
order = images.each_index.sort_by { |i| [-[sizes[i][0], sizes[i][1]].max, -(sizes[i][0] * sizes[i][1]), i] }
|
|
678
|
-
order.each do |index|
|
|
679
|
-
width, height = sizes[index]
|
|
680
|
-
choice = free.filter_map do |x, y, free_width, free_height|
|
|
681
|
-
next if width > free_width || height > free_height
|
|
682
|
-
|
|
683
|
-
leftover_width = free_width - width
|
|
684
|
-
leftover_height = free_height - height
|
|
685
|
-
[[[leftover_width, leftover_height].min, [leftover_width, leftover_height].max, y, x], [x, y, width, height]]
|
|
686
|
-
end.min_by(&:first)
|
|
687
|
-
raise Error, "could not pack sprite #{index}" unless choice
|
|
688
|
-
|
|
689
|
-
placed = choice.last
|
|
690
|
-
positions[index] = placed.first(2)
|
|
691
|
-
free = free.flat_map { |rect| split_free_rect(rect, placed) }
|
|
692
|
-
free.uniq!
|
|
693
|
-
free.reject! { |rect| free.any? { |other| other != rect && contains_rect?(other, rect) } }
|
|
694
|
-
end
|
|
695
|
-
width = images.each_index.map { |i| positions[i][0] + images[i].width }.max
|
|
696
|
-
height = images.each_index.map { |i| positions[i][1] + images[i].height }.max
|
|
697
|
-
[positions, width, height]
|
|
698
|
-
end
|
|
699
|
-
|
|
700
|
-
def split_free_rect(free, placed)
|
|
701
|
-
x, y, width, height = free
|
|
702
|
-
px, py, pwidth, pheight = placed
|
|
703
|
-
return [free] if px >= x + width || px + pwidth <= x || py >= y + height || py + pheight <= y
|
|
704
|
-
|
|
705
|
-
result = []
|
|
706
|
-
result << [x, y, px - x, height] if px > x
|
|
707
|
-
result << [px + pwidth, y, x + width - px - pwidth, height] if px + pwidth < x + width
|
|
708
|
-
result << [x, y, width, py - y] if py > y
|
|
709
|
-
result << [x, py + pheight, width, y + height - py - pheight] if py + pheight < y + height
|
|
710
|
-
result.select { |_, _, w, h| w.positive? && h.positive? }
|
|
711
|
-
end
|
|
712
|
-
|
|
713
|
-
def contains_rect?(outer, inner)
|
|
714
|
-
x, y, width, height = outer
|
|
715
|
-
ix, iy, iwidth, iheight = inner
|
|
716
|
-
ix >= x && iy >= y && ix + iwidth <= x + width && iy + iheight <= y + height
|
|
717
|
-
end
|
|
718
|
-
|
|
719
|
-
def require_optional(gem, operation)
|
|
720
|
-
yield
|
|
721
|
-
rescue LoadError => e
|
|
722
|
-
raise Error, "#{operation} requires the #{gem} gem", cause: e
|
|
723
|
-
end
|
|
272
|
+
private_class_method :resample, :axis_weights, :round_divide, :cubic, :lanczos, :copy_metadata, :rotate_quarter, :sample_bilinear
|
|
724
273
|
end
|
|
725
274
|
end
|
data/lib/retouch/pipeline.rb
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
module Retouch
|
|
4
4
|
class Pipeline
|
|
5
5
|
OPERATIONS = %i[
|
|
6
|
-
resize thumbnail crop flip flop rotate pad extend border trim
|
|
7
|
-
brightness contrast gamma saturate tint opacity quantize
|
|
8
|
-
overlay watermark text rect arrow
|
|
6
|
+
resize thumbnail crop flip flop rotate pad extend border trim
|
|
7
|
+
grayscale invert brightness contrast gamma saturate tint opacity quantize
|
|
8
|
+
blur sharpen pixelate overlay watermark text rect arrow
|
|
9
9
|
].freeze
|
|
10
10
|
|
|
11
11
|
def initialize(image, operations = [])
|
|
@@ -18,7 +18,6 @@ module Retouch
|
|
|
18
18
|
def to_image
|
|
19
19
|
@operations.reduce(@source.dup) do |image, (name, args, options)|
|
|
20
20
|
args = args.dup
|
|
21
|
-
args[0] = ImageIO.read(args[0]) if %i[overlay watermark].include?(name) && args[0].is_a?(String)
|
|
22
21
|
Operations.apply(image, name, *args, **options)
|
|
23
22
|
end
|
|
24
23
|
end
|
data/lib/retouch/version.rb
CHANGED
data/lib/retouch.rb
CHANGED
|
@@ -7,22 +7,30 @@ require_relative "retouch/geometry"
|
|
|
7
7
|
require_relative "retouch/gravity"
|
|
8
8
|
require_relative "retouch/operations"
|
|
9
9
|
require_relative "retouch/pipeline"
|
|
10
|
+
require_relative "retouch/io"
|
|
11
|
+
require_relative "retouch/kernels/lut"
|
|
12
|
+
require_relative "retouch/kernels/convolve"
|
|
13
|
+
require_relative "retouch/kernels/color"
|
|
14
|
+
require_relative "retouch/operations/color"
|
|
15
|
+
require_relative "retouch/operations/filter"
|
|
16
|
+
require_relative "retouch/operations/draw"
|
|
17
|
+
require_relative "retouch/operations/multiple"
|
|
10
18
|
require_relative "retouch/batch"
|
|
11
19
|
require_relative "retouch/cli"
|
|
12
|
-
require_relative "retouch/io"
|
|
13
20
|
|
|
14
21
|
module Retouch
|
|
15
22
|
class Error < StandardError; end
|
|
16
23
|
|
|
17
|
-
def self.open(path
|
|
18
|
-
Pipeline.new(ImageIO.read(path
|
|
24
|
+
def self.open(path)
|
|
25
|
+
Pipeline.new(ImageIO.read(path))
|
|
19
26
|
end
|
|
20
27
|
|
|
21
28
|
def self.from_image(image)
|
|
22
29
|
Pipeline.new(image)
|
|
23
30
|
end
|
|
24
31
|
|
|
25
|
-
def self.
|
|
26
|
-
|
|
32
|
+
def self.open_gif(path, frame: :all, **limits)
|
|
33
|
+
result = Operations.open_gif(path, frame:, **limits)
|
|
34
|
+
frame == :all ? result : Pipeline.new(result)
|
|
27
35
|
end
|
|
28
36
|
end
|
data/retouch.gemspec
CHANGED
|
@@ -7,8 +7,8 @@ Gem::Specification.new do |spec|
|
|
|
7
7
|
spec.version = Retouch::VERSION
|
|
8
8
|
spec.authors = ["Yudai Takada"]
|
|
9
9
|
spec.email = ["t.yudai92@gmail.com"]
|
|
10
|
-
spec.summary = "
|
|
11
|
-
spec.description = "A pure Ruby image editor
|
|
10
|
+
spec.summary = "Pure Ruby image editing and batch tools"
|
|
11
|
+
spec.description = "A small pure Ruby image editor for color, filter, composite, annotation, and batch operations on PNG, PPM, and BMP images."
|
|
12
12
|
spec.homepage = "https://github.com/rbgfx/retouch"
|
|
13
13
|
spec.license = "MIT"
|
|
14
14
|
spec.required_ruby_version = ">= 3.1.0"
|