retouch 0.1.0 → 0.3.0
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 +6 -2
- data/LICENSE +1 -1
- data/README.md +77 -30
- data/bench/retouch.rb +2 -1
- data/lib/retouch/batch.rb +62 -0
- data/lib/retouch/cli.rb +213 -54
- data/lib/retouch/io.rb +19 -1
- data/lib/retouch/operations.rb +477 -16
- data/lib/retouch/pipeline.rb +6 -1
- data/lib/retouch/version.rb +1 -1
- data/lib/retouch.rb +7 -2
- data/retouch.gemspec +2 -2
- data/sig/retouch.rbs +18 -4
- metadata +10 -6
data/lib/retouch/operations.rb
CHANGED
|
@@ -43,7 +43,7 @@ module Retouch
|
|
|
43
43
|
Tessel::Image.from_rgba(result.width, result.height, result.bytes)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
def crop(image, geometry, gravity: :north_west)
|
|
46
|
+
def crop(image, geometry, gravity: :north_west, **_options)
|
|
47
47
|
geometry = Geometry.parse(geometry) unless geometry.is_a?(Geometry)
|
|
48
48
|
width, height = geometry.crop_size(image.width, image.height)
|
|
49
49
|
x, y = if geometry.offset_x.zero? && geometry.offset_y.zero?
|
|
@@ -89,7 +89,7 @@ module Retouch
|
|
|
89
89
|
dy = y - target_cy
|
|
90
90
|
sx = (cosine * dx) + (sine * dy) + source_cx
|
|
91
91
|
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
|
|
92
|
+
output[x, y] = sample_bilinear(image, sx, sy, background) if sx.between?(0, image.width - 1) && sy >= 0 && sy <= image.height - 1
|
|
93
93
|
end
|
|
94
94
|
end
|
|
95
95
|
output
|
|
@@ -112,7 +112,7 @@ module Retouch
|
|
|
112
112
|
Tessel::Image.new(width, height, fill: color, metadata: image.metadata).tap { |canvas| canvas.blit(image, x, y) }
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
-
def border(image, width, color = "#000000")
|
|
115
|
+
def border(image, width, color = "#000000", **_options)
|
|
116
116
|
width = Integer(width)
|
|
117
117
|
raise ArgumentError, "border width must be positive" unless width.positive?
|
|
118
118
|
|
|
@@ -141,6 +141,299 @@ module Retouch
|
|
|
141
141
|
copy_metadata(image.crop(bounds[0], bounds[1], bounds[2] - bounds[0] + 1, bounds[3] - bounds[1] + 1), image)
|
|
142
142
|
end
|
|
143
143
|
|
|
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
|
+
|
|
144
437
|
def resample(image, width, height, filter)
|
|
145
438
|
filter = filter.to_sym
|
|
146
439
|
raise ArgumentError, "unknown resize filter: #{filter}" unless %i[nearest bilinear bicubic lanczos3].include?(filter)
|
|
@@ -161,7 +454,7 @@ module Retouch
|
|
|
161
454
|
sums[2] += source.getbyte(offset + 2) * alpha * weight
|
|
162
455
|
sums[3] += source.getbyte(offset + 3) * weight
|
|
163
456
|
end
|
|
164
|
-
sums.each { |
|
|
457
|
+
sums.each { |v| intermediate << v.round.clamp(0, 255) }
|
|
165
458
|
end
|
|
166
459
|
end
|
|
167
460
|
result = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
|
|
@@ -206,20 +499,88 @@ module Retouch
|
|
|
206
499
|
end
|
|
207
500
|
end
|
|
208
501
|
|
|
209
|
-
def cubic(
|
|
210
|
-
|
|
211
|
-
return (((1.5 *
|
|
212
|
-
return (((((-0.5 *
|
|
502
|
+
def cubic(x)
|
|
503
|
+
x = x.abs
|
|
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
|
|
213
506
|
|
|
214
507
|
0.0
|
|
215
508
|
end
|
|
216
509
|
|
|
217
|
-
def lanczos(
|
|
218
|
-
|
|
219
|
-
return 1.0 if
|
|
220
|
-
return 0.0 if
|
|
510
|
+
def lanczos(x)
|
|
511
|
+
x = x.abs
|
|
512
|
+
return 1.0 if x.zero?
|
|
513
|
+
return 0.0 if x >= 3
|
|
221
514
|
|
|
222
|
-
Math.sin(Math::PI *
|
|
515
|
+
Math.sin(Math::PI * x) * Math.sin(Math::PI * x / 3) * 3 / (Math::PI * Math::PI * x * x)
|
|
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)
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def map_rgb(image, &block)
|
|
569
|
+
map_rgba(image) { |r, g, b, a| [*block.call(r, g, b), a] }
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def map_lut(image, lookup)
|
|
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)
|
|
223
584
|
end
|
|
224
585
|
|
|
225
586
|
def copy_metadata(output, source)
|
|
@@ -252,13 +613,113 @@ module Retouch
|
|
|
252
613
|
pixels.each do |px, py, weight|
|
|
253
614
|
color = image[px.clamp(0, image.width - 1), py.clamp(0, image.height - 1)] || Tessel::Color.pack(background).bytes
|
|
254
615
|
alpha = color[3] / 255.0
|
|
255
|
-
3.times { |
|
|
616
|
+
3.times { |c| sums[c] += color[c] * alpha * weight }
|
|
256
617
|
sums[3] += color[3] * weight
|
|
257
618
|
end
|
|
258
619
|
alpha = sums[3].round.clamp(0, 255)
|
|
259
|
-
[*(alpha.positive? ? sums.first(3).map { |
|
|
620
|
+
[*(alpha.positive? ? sums.first(3).map { |v| (v * 255 / alpha).round.clamp(0, 255) } : [0, 0, 0]), alpha]
|
|
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]
|
|
260
649
|
end
|
|
261
650
|
|
|
262
|
-
|
|
651
|
+
def draw_line(image, x1, y1, x2, y2, color, width)
|
|
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
|
|
263
724
|
end
|
|
264
725
|
end
|
data/lib/retouch/pipeline.rb
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Retouch
|
|
4
4
|
class Pipeline
|
|
5
|
-
OPERATIONS = %i[
|
|
5
|
+
OPERATIONS = %i[
|
|
6
|
+
resize thumbnail crop flip flop rotate pad extend border trim grayscale invert
|
|
7
|
+
brightness contrast gamma saturate tint opacity quantize blur sharpen pixelate
|
|
8
|
+
overlay watermark text rect arrow
|
|
9
|
+
].freeze
|
|
6
10
|
|
|
7
11
|
def initialize(image, operations = [])
|
|
8
12
|
raise TypeError, "image must be a Tessel::Image" unless image.is_a?(Tessel::Image)
|
|
@@ -14,6 +18,7 @@ module Retouch
|
|
|
14
18
|
def to_image
|
|
15
19
|
@operations.reduce(@source.dup) do |image, (name, args, options)|
|
|
16
20
|
args = args.dup
|
|
21
|
+
args[0] = ImageIO.read(args[0]) if %i[overlay watermark].include?(name) && args[0].is_a?(String)
|
|
17
22
|
Operations.apply(image, name, *args, **options)
|
|
18
23
|
end
|
|
19
24
|
end
|
data/lib/retouch/version.rb
CHANGED
data/lib/retouch.rb
CHANGED
|
@@ -7,17 +7,22 @@ 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/batch"
|
|
10
11
|
require_relative "retouch/cli"
|
|
11
12
|
require_relative "retouch/io"
|
|
12
13
|
|
|
13
14
|
module Retouch
|
|
14
15
|
class Error < StandardError; end
|
|
15
16
|
|
|
16
|
-
def self.open(path)
|
|
17
|
-
Pipeline.new(ImageIO.read(path))
|
|
17
|
+
def self.open(path, frame: 0)
|
|
18
|
+
Pipeline.new(ImageIO.read(path, frame:))
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def self.from_image(image)
|
|
21
22
|
Pipeline.new(image)
|
|
22
23
|
end
|
|
24
|
+
|
|
25
|
+
def self.batch(pattern, to:, force: false, jobs: 1, level: 6, strip: false, &)
|
|
26
|
+
Batch.run(pattern, to:, force:, jobs:, level:, strip:, &)
|
|
27
|
+
end
|
|
23
28
|
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 = "Ruby image
|
|
11
|
-
spec.description = "A
|
|
10
|
+
spec.summary = "Small Ruby image editing tools for PNG, PPM, and BMP"
|
|
11
|
+
spec.description = "A pure Ruby image editor and command line tool built on Tessel, with optional animation, text, and visual diff integrations."
|
|
12
12
|
spec.homepage = "https://github.com/rbgfx/retouch"
|
|
13
13
|
spec.license = "MIT"
|
|
14
14
|
spec.required_ruby_version = ">= 3.1.0"
|
data/sig/retouch.rbs
CHANGED
|
@@ -30,14 +30,28 @@ module Retouch
|
|
|
30
30
|
def flip: -> Pipeline
|
|
31
31
|
def flop: -> Pipeline
|
|
32
32
|
def rotate: (Numeric degrees, **untyped options) -> Pipeline
|
|
33
|
-
def pad: (Integer amount, **untyped options) -> Pipeline
|
|
34
|
-
def extend: (Integer width, Integer height, **untyped options) -> Pipeline
|
|
35
33
|
def border: (Integer width, ?String color, **untyped options) -> Pipeline
|
|
36
|
-
def
|
|
34
|
+
def grayscale: -> Pipeline
|
|
35
|
+
def invert: -> Pipeline
|
|
36
|
+
def brightness: (Numeric amount) -> Pipeline
|
|
37
|
+
def contrast: (Numeric amount) -> Pipeline
|
|
38
|
+
def gamma: (Numeric amount) -> Pipeline
|
|
39
|
+
def saturate: (Numeric amount) -> Pipeline
|
|
40
|
+
def tint: (String color, **untyped options) -> Pipeline
|
|
41
|
+
def opacity: (Numeric amount) -> Pipeline
|
|
42
|
+
def blur: (**untyped options) -> Pipeline
|
|
43
|
+
def sharpen: (**untyped options) -> Pipeline
|
|
44
|
+
def pixelate: (Integer size) -> Pipeline
|
|
45
|
+
def overlay: (String | untyped image, **untyped options) -> Pipeline
|
|
46
|
+
def watermark: (String | untyped image, **untyped options) -> Pipeline
|
|
47
|
+
def text: (String text, **untyped options) -> Pipeline
|
|
48
|
+
def rect: (String geometry, **untyped options) -> Pipeline
|
|
49
|
+
def arrow: (Numeric x1, Numeric y1, Numeric x2, Numeric y2, **untyped options) -> Pipeline
|
|
37
50
|
end
|
|
38
51
|
|
|
39
|
-
def self.open: (String path) -> Pipeline
|
|
52
|
+
def self.open: (String path, ?frame: Integer) -> Pipeline
|
|
40
53
|
def self.from_image: (untyped image) -> Pipeline
|
|
54
|
+
def self.batch: (String | Array[String] pattern, to: String, ?force: bool, ?jobs: Integer, ?level: Integer, ?strip: bool) { (Pipeline) -> Pipeline } -> Array[String]
|
|
41
55
|
|
|
42
56
|
class CLI
|
|
43
57
|
def self.run: (Array[String] argv, ?out: untyped, ?err: untyped) -> Integer
|