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.
@@ -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, **_options)
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 >= 0 && sy <= image.height - 1
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", **_options)
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.0, 0.0, 0.0, 0.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) / 255.0
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
- sums.each { |v| intermediate << v.round.clamp(0, 255) }
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.0, 0.0, 0.0, 0.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].round.clamp(0, 255)
178
+ alpha = round_divide(sums[3], WEIGHT_SCALE).clamp(0, 255)
469
179
  if alpha.positive?
470
- 3.times { |channel| result << (sums[channel] * 255 / alpha).round.clamp(0, 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 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
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 lanczos(x)
511
- x = x.abs
512
- return 1.0 if x.zero?
513
- return 0.0 if x >= 3
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
- 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)
224
+ 0.0
566
225
  end
567
226
 
568
- def map_rgb(image, &block)
569
- map_rgba(image) { |r, g, b, a| [*block.call(r, g, b), a] }
570
- end
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
- 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)
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 { |c| sums[c] += color[c] * alpha * weight }
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 { |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]
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
- 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
272
+ private_class_method :resample, :axis_weights, :round_divide, :cubic, :lanczos, :copy_metadata, :rotate_quarter, :sample_bilinear
724
273
  end
725
274
  end
@@ -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 grayscale invert
7
- brightness contrast gamma saturate tint opacity quantize blur sharpen pixelate
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retouch
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
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, frame: 0)
18
- Pipeline.new(ImageIO.read(path, frame:))
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.batch(pattern, to:, force: false, jobs: 1, level: 6, strip: false, &)
26
- Batch.run(pattern, to:, force:, jobs:, level:, strip:, &)
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 = "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."
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"