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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
module Kernels
|
|
5
|
+
module LUT
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def apply(image, operation, value = nil, amount: 1.0)
|
|
9
|
+
bytes = image.bytes.dup
|
|
10
|
+
number = value.nil? || operation.to_sym == :tint ? nil : Float(value)
|
|
11
|
+
raise ArgumentError, "value must be finite" if number && !number.finite?
|
|
12
|
+
|
|
13
|
+
case operation.to_sym
|
|
14
|
+
when :grayscale
|
|
15
|
+
bytes.bytes.each_slice(4).with_index do |(red, green, blue, _alpha), index|
|
|
16
|
+
gray = (((red * 2126) + (green * 7152) + (blue * 722)) / 10_000.0).round
|
|
17
|
+
bytes.setbyte(index * 4, gray)
|
|
18
|
+
bytes.setbyte((index * 4) + 1, gray)
|
|
19
|
+
bytes.setbyte((index * 4) + 2, gray)
|
|
20
|
+
end
|
|
21
|
+
when :invert
|
|
22
|
+
lut = Array.new(256) { |channel| 255 - channel }
|
|
23
|
+
transform_rgb(bytes) { |channel| lut[channel] }
|
|
24
|
+
when :brightness
|
|
25
|
+
raise ArgumentError, "brightness must be between -255 and 255" unless number&.between?(-255, 255)
|
|
26
|
+
|
|
27
|
+
lut = Array.new(256) { |channel| (channel + number).round.clamp(0, 255) }
|
|
28
|
+
transform_rgb(bytes) { |channel| lut[channel] }
|
|
29
|
+
when :contrast
|
|
30
|
+
raise ArgumentError, "contrast must be between 0 and 10" unless number&.between?(0, 10)
|
|
31
|
+
|
|
32
|
+
lut = Array.new(256) { |channel| (((channel - 127.5) * number) + 127.5).round.clamp(0, 255) }
|
|
33
|
+
transform_rgb(bytes) { |channel| lut[channel] }
|
|
34
|
+
when :gamma
|
|
35
|
+
raise ArgumentError, "gamma must be positive and at most 10" unless number&.positive? && number <= 10
|
|
36
|
+
|
|
37
|
+
lut = Array.new(256) { |channel| (255 * ((channel / 255.0)**(1.0 / number))).round }
|
|
38
|
+
transform_rgb(bytes) { |channel| lut[channel] }
|
|
39
|
+
when :saturate
|
|
40
|
+
raise ArgumentError, "saturation must be between 0 and 10" unless number&.between?(0, 10)
|
|
41
|
+
|
|
42
|
+
bytes.bytes.each_slice(4).with_index do |(red, green, blue, _alpha), index|
|
|
43
|
+
gray = (red * 0.2126) + (green * 0.7152) + (blue * 0.0722)
|
|
44
|
+
[red, green, blue].each_with_index do |channel, component|
|
|
45
|
+
bytes.setbyte((index * 4) + component, (gray + ((channel - gray) * number)).round.clamp(0, 255))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
when :tint
|
|
49
|
+
raise ArgumentError, "tint amount must be between 0 and 1" unless amount.is_a?(Numeric) && amount.finite? && amount.between?(0, 1)
|
|
50
|
+
|
|
51
|
+
tint = Tessel::Color.pack(value).bytes.first(3)
|
|
52
|
+
bytes.bytes.each_slice(4).with_index do |(red, green, blue, _alpha), index|
|
|
53
|
+
[red, green, blue].each_with_index do |channel, component|
|
|
54
|
+
bytes.setbyte((index * 4) + component, ((channel * (1 - amount)) + (tint[component] * amount)).round)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
when :opacity
|
|
58
|
+
raise ArgumentError, "opacity must be between 0 and 1" unless number&.between?(0, 1)
|
|
59
|
+
|
|
60
|
+
(3...bytes.bytesize).step(4) { |index| bytes.setbyte(index, (bytes.getbyte(index) * number).round) }
|
|
61
|
+
else
|
|
62
|
+
raise ArgumentError, "unknown LUT operation: #{operation}"
|
|
63
|
+
end
|
|
64
|
+
Tessel::Image.from_rgba(image.width, image.height, bytes, metadata: image.metadata)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def transform_rgb(bytes)
|
|
68
|
+
bytes.bytesize.times do |index|
|
|
69
|
+
bytes.setbyte(index, yield(bytes.getbyte(index))) if index % 4 != 3
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_class_method :transform_rgb
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
module Operations
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
%i[grayscale invert brightness contrast gamma saturate opacity].each do |operation|
|
|
8
|
+
define_method(operation) do |image, value = nil|
|
|
9
|
+
Kernels::LUT.apply(image, operation, value)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def tint(image, color, amount: 1.0)
|
|
14
|
+
Kernels::LUT.apply(image, :tint, color, amount: Float(amount))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def quantize(image, colors: 256, dither: :none, palette: nil)
|
|
18
|
+
Kernels.quantize(image, colors:, dither:, palette:)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
module Operations
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
BLENDS = %i[normal multiply screen overlay darken lighten add].freeze
|
|
8
|
+
|
|
9
|
+
def overlay(image, source, x: nil, y: nil, gravity: :center, opacity: 1.0, mode: :normal)
|
|
10
|
+
source = ImageIO.read(source) unless source.is_a?(Tessel::Image)
|
|
11
|
+
opacity = Float(opacity)
|
|
12
|
+
mode = mode.to_sym
|
|
13
|
+
raise ArgumentError, "opacity must be between 0 and 1" unless opacity.finite? && opacity.between?(0, 1)
|
|
14
|
+
raise ArgumentError, "unknown blend mode: #{mode}" unless BLENDS.include?(mode)
|
|
15
|
+
|
|
16
|
+
offset_x, offset_y = Gravity.offset(image.width, image.height, source.width, source.height, gravity)
|
|
17
|
+
offset_x = Integer(x) unless x.nil?
|
|
18
|
+
offset_y = Integer(y) unless y.nil?
|
|
19
|
+
output = image.dup
|
|
20
|
+
source.height.times do |source_y|
|
|
21
|
+
source.width.times do |source_x|
|
|
22
|
+
target_x = source_x + offset_x
|
|
23
|
+
target_y = source_y + offset_y
|
|
24
|
+
next unless target_x.between?(0, image.width - 1) && target_y.between?(0, image.height - 1)
|
|
25
|
+
|
|
26
|
+
source_pixel = source[source_x, source_y]
|
|
27
|
+
source_pixel[3] = (source_pixel[3] * opacity).round
|
|
28
|
+
next if source_pixel[3].zero?
|
|
29
|
+
|
|
30
|
+
output[target_x, target_y] = blend_pixel(output[target_x, target_y], source_pixel, mode)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
output
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def watermark(image, source, **options)
|
|
37
|
+
overlay(image, source, **options)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def rect(image, x, y, width, height, color = "#ffffff", fill: false)
|
|
41
|
+
x = Integer(x)
|
|
42
|
+
y = Integer(y)
|
|
43
|
+
width = Integer(width)
|
|
44
|
+
height = Integer(height)
|
|
45
|
+
raise ArgumentError, "rectangle dimensions must be positive" unless width.positive? && height.positive?
|
|
46
|
+
|
|
47
|
+
output = image.dup
|
|
48
|
+
if fill
|
|
49
|
+
output.fill_rect(x, y, width, height, color, blend: :alpha)
|
|
50
|
+
else
|
|
51
|
+
output.fill_rect(x, y, width, 1, color, blend: :alpha)
|
|
52
|
+
output.fill_rect(x, y + height - 1, width, 1, color, blend: :alpha)
|
|
53
|
+
output.fill_rect(x, y, 1, height, color, blend: :alpha)
|
|
54
|
+
output.fill_rect(x + width - 1, y, 1, height, color, blend: :alpha)
|
|
55
|
+
end
|
|
56
|
+
output
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def arrow(image, x1, y1, x2, y2, color = "#ffffff", width: 1, head: 8)
|
|
60
|
+
x1, y1, x2, y2 = [x1, y1, x2, y2].map { |value| Integer(value) }
|
|
61
|
+
width = Integer(width)
|
|
62
|
+
head = Float(head)
|
|
63
|
+
raise ArgumentError, "arrow width must be positive" unless width.positive?
|
|
64
|
+
raise ArgumentError, "arrow head length must be non-negative and finite" unless head.finite? && head >= 0
|
|
65
|
+
|
|
66
|
+
output = image.dup
|
|
67
|
+
draw_line(output, x1, y1, x2, y2, color, width)
|
|
68
|
+
distance = Math.hypot(x2 - x1, y2 - y1)
|
|
69
|
+
if head.positive? && distance.positive?
|
|
70
|
+
angle = Math.atan2(y2 - y1, x2 - x1)
|
|
71
|
+
[angle + (0.65 * Math::PI), angle - (0.65 * Math::PI)].each do |side|
|
|
72
|
+
draw_line(output, x2, y2, (x2 + (Math.cos(side) * head)).round, (y2 + (Math.sin(side) * head)).round, color, width)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
output
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def text(image, string, x: 0, y: 0, at: nil, size: 16, font: nil, color: "#ffffff", background: nil, padding: 4)
|
|
79
|
+
begin
|
|
80
|
+
require "glyphic"
|
|
81
|
+
rescue LoadError => e
|
|
82
|
+
raise Error, "text requires the glyphic gem (gem install glyphic)", cause: e
|
|
83
|
+
end
|
|
84
|
+
size = Integer(size)
|
|
85
|
+
raise ArgumentError, "font size must be positive" unless size.positive?
|
|
86
|
+
|
|
87
|
+
font_object = font ? Glyphic.load(font, size: Integer(size)) : Glyphic.default
|
|
88
|
+
rendered = font_object.render(String(string), color: Tessel::Color.pack(color).bytes)
|
|
89
|
+
if !font && size != font_object.line_height
|
|
90
|
+
scale = size.to_f / font_object.line_height
|
|
91
|
+
rendered = rendered.scale_nearest([(rendered.width * scale).round, 1].max, [(rendered.height * scale).round, 1].max)
|
|
92
|
+
end
|
|
93
|
+
text_width = rendered.width
|
|
94
|
+
text_height = rendered.height
|
|
95
|
+
padding = Integer(padding)
|
|
96
|
+
raise ArgumentError, "text padding must not be negative" if padding.negative?
|
|
97
|
+
|
|
98
|
+
if at
|
|
99
|
+
x, y = Gravity.offset(image.width, image.height, text_width + (padding * 2), text_height + (padding * 2), at)
|
|
100
|
+
x += padding
|
|
101
|
+
y += padding
|
|
102
|
+
else
|
|
103
|
+
x = Integer(x)
|
|
104
|
+
y = Integer(y)
|
|
105
|
+
end
|
|
106
|
+
output = image.dup
|
|
107
|
+
output.fill_rect(x - padding, y - padding, text_width + (padding * 2), text_height + (padding * 2), background, blend: :alpha) if background
|
|
108
|
+
output.blit(rendered, x, y)
|
|
109
|
+
output
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def blend_pixel(destination, source, mode)
|
|
113
|
+
source_alpha = source[3] / 255.0
|
|
114
|
+
destination_alpha = destination[3] / 255.0
|
|
115
|
+
output_alpha = source_alpha + (destination_alpha * (1 - source_alpha))
|
|
116
|
+
return [0, 0, 0, 0] if output_alpha.zero?
|
|
117
|
+
|
|
118
|
+
rgb = 3.times.map do |channel|
|
|
119
|
+
source_color = source[channel] / 255.0
|
|
120
|
+
destination_color = destination[channel] / 255.0
|
|
121
|
+
blended = case mode
|
|
122
|
+
when :normal then source_color
|
|
123
|
+
when :multiply then source_color * destination_color
|
|
124
|
+
when :screen then 1 - ((1 - source_color) * (1 - destination_color))
|
|
125
|
+
when :overlay then destination_color <= 0.5 ? 2 * source_color * destination_color : 1 - (2 * (1 - source_color) * (1 - destination_color))
|
|
126
|
+
when :darken then [source_color, destination_color].min
|
|
127
|
+
when :lighten then [source_color, destination_color].max
|
|
128
|
+
when :add then [source_color + destination_color, 1].min
|
|
129
|
+
end
|
|
130
|
+
premultiplied = ((1 - source_alpha) * destination_alpha * destination_color) + ((1 - destination_alpha) * source_alpha * source_color) + (source_alpha * destination_alpha * blended)
|
|
131
|
+
(premultiplied / output_alpha * 255).round.clamp(0, 255)
|
|
132
|
+
end
|
|
133
|
+
[*rgb, (output_alpha * 255).round.clamp(0, 255)]
|
|
134
|
+
end
|
|
135
|
+
private_class_method :blend_pixel
|
|
136
|
+
|
|
137
|
+
def draw_line(image, x1, y1, x2, y2, color, width)
|
|
138
|
+
dx = (x2 - x1).abs
|
|
139
|
+
sx = x1 < x2 ? 1 : -1
|
|
140
|
+
dy = -(y2 - y1).abs
|
|
141
|
+
sy = y1 < y2 ? 1 : -1
|
|
142
|
+
error = dx + dy
|
|
143
|
+
loop do
|
|
144
|
+
image.fill_rect(x1 - (width / 2), y1 - (width / 2), width, width, color, blend: :alpha)
|
|
145
|
+
break if x1 == x2 && y1 == y2
|
|
146
|
+
|
|
147
|
+
doubled = 2 * error
|
|
148
|
+
if doubled >= dy
|
|
149
|
+
error += dy
|
|
150
|
+
x1 += sx
|
|
151
|
+
end
|
|
152
|
+
if doubled <= dx
|
|
153
|
+
error += dx
|
|
154
|
+
y1 += sy
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
private_class_method :draw_line
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
module Operations
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def blur(image, sigma)
|
|
8
|
+
Kernels::Convolve.gaussian(image, sigma)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def sharpen(image, sigma, amount: 1.0)
|
|
12
|
+
amount = Float(amount)
|
|
13
|
+
raise ArgumentError, "sharpen amount must be between 0 and 10" unless amount.finite? && amount.between?(0, 10)
|
|
14
|
+
|
|
15
|
+
blurred = blur(image, sigma)
|
|
16
|
+
source = image.bytes
|
|
17
|
+
smooth = blurred.bytes
|
|
18
|
+
output = source.dup
|
|
19
|
+
(0...(image.width * image.height)).each do |index|
|
|
20
|
+
offset = index * 4
|
|
21
|
+
original_alpha = source.getbyte(offset + 3) / 255.0
|
|
22
|
+
smooth_alpha = smooth.getbyte(offset + 3) / 255.0
|
|
23
|
+
3.times do |channel|
|
|
24
|
+
original = source.getbyte(offset + channel) * original_alpha
|
|
25
|
+
blurred_color = smooth.getbyte(offset + channel) * smooth_alpha
|
|
26
|
+
sharpened = original + (amount * (original - blurred_color))
|
|
27
|
+
color = original_alpha.positive? ? (sharpened / original_alpha).round.clamp(0, 255) : 0
|
|
28
|
+
output.setbyte(offset + channel, color)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
Tessel::Image.from_rgba(image.width, image.height, output, metadata: image.metadata)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def pixelate(image, size)
|
|
35
|
+
size = Integer(size)
|
|
36
|
+
raise ArgumentError, "pixel size must be positive" unless size.positive?
|
|
37
|
+
return image.dup if image.width.zero? || image.height.zero?
|
|
38
|
+
|
|
39
|
+
output = image.dup
|
|
40
|
+
(0...image.height).step(size) do |y|
|
|
41
|
+
height = [size, image.height - y].min
|
|
42
|
+
(0...image.width).step(size) do |x|
|
|
43
|
+
width = [size, image.width - x].min
|
|
44
|
+
alpha_sum = red = green = blue = 0.0
|
|
45
|
+
(y...(y + height)).each do |py|
|
|
46
|
+
(x...(x + width)).each do |px|
|
|
47
|
+
color = image[px, py]
|
|
48
|
+
alpha = color[3] / 255.0
|
|
49
|
+
red += color[0] * alpha
|
|
50
|
+
green += color[1] * alpha
|
|
51
|
+
blue += color[2] * alpha
|
|
52
|
+
alpha_sum += color[3]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
count = width * height
|
|
56
|
+
alpha = (alpha_sum / count).round
|
|
57
|
+
color = alpha_sum.positive? ? [(red * 255 / alpha_sum).round, (green * 255 / alpha_sum).round, (blue * 255 / alpha_sum).round, alpha] : [0, 0, 0, 0]
|
|
58
|
+
output.fill_rect(x, y, width, height, color, blend: :copy)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
output
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
module Operations
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def append(images, direction: :vertical, gap: 0, background: [0, 0, 0, 0])
|
|
8
|
+
images = validate_images(images)
|
|
9
|
+
direction = direction.to_sym
|
|
10
|
+
gap = Integer(gap)
|
|
11
|
+
raise ArgumentError, "direction must be vertical or horizontal" unless %i[vertical horizontal].include?(direction)
|
|
12
|
+
raise ArgumentError, "gap must not be negative" if gap.negative?
|
|
13
|
+
|
|
14
|
+
if direction == :vertical
|
|
15
|
+
width = images.map(&:width).max
|
|
16
|
+
height = images.sum(&:height) + (gap * (images.length - 1))
|
|
17
|
+
else
|
|
18
|
+
width = images.sum(&:width) + (gap * (images.length - 1))
|
|
19
|
+
height = images.map(&:height).max
|
|
20
|
+
end
|
|
21
|
+
output = Tessel::Image.new(width, height, fill: background)
|
|
22
|
+
x = y = 0
|
|
23
|
+
images.each do |image|
|
|
24
|
+
dx, dy = Gravity.offset(width, height, image.width, image.height, :center)
|
|
25
|
+
output.blit(image, direction == :vertical ? dx : x, direction == :vertical ? y : dy)
|
|
26
|
+
direction == :vertical ? y += image.height + gap : x += image.width + gap
|
|
27
|
+
end
|
|
28
|
+
output
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def montage(images, columns: nil, cols: nil, gap: 0, background: [0, 0, 0, 0], labels: nil)
|
|
32
|
+
images = validate_images(images)
|
|
33
|
+
columns = Integer(columns || cols || Math.sqrt(images.length).ceil)
|
|
34
|
+
gap = Integer(gap)
|
|
35
|
+
raise ArgumentError, "columns must be positive" unless columns.positive?
|
|
36
|
+
raise ArgumentError, "gap must not be negative" if gap.negative?
|
|
37
|
+
|
|
38
|
+
column_widths = Array.new([images.length, columns].min, 0)
|
|
39
|
+
row_heights = Array.new((images.length.to_f / columns).ceil, 0)
|
|
40
|
+
images.each_with_index do |image, index|
|
|
41
|
+
column = index % columns
|
|
42
|
+
row = index / columns
|
|
43
|
+
column_widths[column] = [column_widths[column], image.width].max
|
|
44
|
+
row_heights[row] = [row_heights[row], image.height + (labels ? 15 : 0)].max
|
|
45
|
+
end
|
|
46
|
+
output = Tessel::Image.new(column_widths.sum + (gap * (column_widths.length - 1)), row_heights.sum + (gap * (row_heights.length - 1)), fill: background)
|
|
47
|
+
row_y = 0
|
|
48
|
+
row_heights.each_with_index do |row_height, row|
|
|
49
|
+
column_x = 0
|
|
50
|
+
column_widths.each_with_index do |column_width, column|
|
|
51
|
+
index = (row * columns) + column
|
|
52
|
+
break unless images[index]
|
|
53
|
+
|
|
54
|
+
image = images[index]
|
|
55
|
+
image_y = row_y + ((row_height - image.height - (labels ? 15 : 0)) / 2)
|
|
56
|
+
output.blit(image, column_x + ((column_width - image.width) / 2), image_y)
|
|
57
|
+
if labels
|
|
58
|
+
label = labels == true ? index.to_s : String(labels.fetch(index))
|
|
59
|
+
output.fill_rect(column_x, row_y + row_height - 15, column_width, 15, [0, 0, 0, 192], blend: :alpha)
|
|
60
|
+
output = text(output, label, x: column_x + 3, y: row_y + row_height - 12, size: 9, color: "#ffffff")
|
|
61
|
+
end
|
|
62
|
+
column_x += column_width + gap
|
|
63
|
+
end
|
|
64
|
+
row_y += row_height + gap
|
|
65
|
+
end
|
|
66
|
+
output
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def spritesheet(images, max_width: 2048, gap: 0, background: [0, 0, 0, 0])
|
|
70
|
+
images = validate_images(images)
|
|
71
|
+
max_width = Integer(max_width)
|
|
72
|
+
gap = Integer(gap)
|
|
73
|
+
raise ArgumentError, "max_width must be positive" unless max_width.positive?
|
|
74
|
+
raise ArgumentError, "gap must not be negative" if gap.negative?
|
|
75
|
+
raise ArgumentError, "an image is wider than max_width" if images.any? { |image| image.width > max_width }
|
|
76
|
+
|
|
77
|
+
bound_height = images.sum(&:height) + (gap * images.length)
|
|
78
|
+
free = [{ x: 0, y: 0, width: max_width, height: bound_height }]
|
|
79
|
+
placements = Array.new(images.length)
|
|
80
|
+
images.each_with_index.sort_by { |image, index| [-image.width * image.height, -[image.width, image.height].max, index] }.each do |image, index|
|
|
81
|
+
packed_width = image.width + gap
|
|
82
|
+
packed_height = image.height + gap
|
|
83
|
+
position = free.select { |rect| packed_width <= rect[:width] && packed_height <= rect[:height] }
|
|
84
|
+
.map do |rect|
|
|
85
|
+
leftover_width = rect[:width] - packed_width
|
|
86
|
+
leftover_height = rect[:height] - packed_height
|
|
87
|
+
score = [
|
|
88
|
+
[leftover_width, leftover_height].min,
|
|
89
|
+
rect[:y],
|
|
90
|
+
rect[:x],
|
|
91
|
+
[leftover_width, leftover_height].max
|
|
92
|
+
]
|
|
93
|
+
[score, rect[:x], rect[:y]]
|
|
94
|
+
end.min_by(&:first)
|
|
95
|
+
raise ArgumentError, "could not pack images within max_width" unless position
|
|
96
|
+
|
|
97
|
+
_score, x, y = position
|
|
98
|
+
packed = { x:, y:, width: packed_width, height: packed_height }
|
|
99
|
+
placements[index] = { index:, x:, y:, width: image.width, height: image.height }
|
|
100
|
+
free = split_free_rectangles(free, packed)
|
|
101
|
+
end
|
|
102
|
+
used_width = placements.map { |item| item[:x] + item[:width] }.max || 0
|
|
103
|
+
height = placements.map { |item| item[:y] + item[:height] }.max || 0
|
|
104
|
+
output = Tessel::Image.new(used_width, height, fill: background)
|
|
105
|
+
images.zip(placements).each { |image, item| output.blit(image, item[:x], item[:y]) }
|
|
106
|
+
[output, placements]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def diff(expected, actual, threshold: 0, color: "#ff00ff")
|
|
110
|
+
raise TypeError, "expected and actual must be Tessel::Image values" unless expected.is_a?(Tessel::Image) && actual.is_a?(Tessel::Image)
|
|
111
|
+
raise ArgumentError, "images must have the same dimensions" unless expected.width == actual.width && expected.height == actual.height
|
|
112
|
+
|
|
113
|
+
threshold = Float(threshold)
|
|
114
|
+
raise ArgumentError, "threshold must be between 0 and 255" unless threshold.finite? && threshold.between?(0, 255)
|
|
115
|
+
|
|
116
|
+
marker = Tessel::Color.pack(color).bytes
|
|
117
|
+
output = Tessel::Image.new(expected.width, expected.height)
|
|
118
|
+
different = 0
|
|
119
|
+
expected.height.times do |y|
|
|
120
|
+
expected.width.times do |x|
|
|
121
|
+
left = expected[x, y]
|
|
122
|
+
right = actual[x, y]
|
|
123
|
+
if left.zip(right).map { |a, b| (a - b).abs }.max > threshold
|
|
124
|
+
output[x, y] = marker
|
|
125
|
+
different += 1
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
{ image: output, pixels: different, total: expected.width * expected.height,
|
|
130
|
+
rate: expected.width.zero? || expected.height.zero? ? 0.0 : different.to_f / (expected.width * expected.height) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def animate(frames, path, fps: nil, delay: nil, loop: true, colors: 256, palette: :global, dither: :floyd_steinberg)
|
|
134
|
+
require_flipbook
|
|
135
|
+
Flipbook.write(path, validate_images(frames), fps:, delay:, loop:, colors:, palette:, dither:)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def open_gif(path, frame: :all, max_pixels: 16_384 * 16_384, max_frames: 10_000, max_total_pixels: 100_000_000)
|
|
139
|
+
require_flipbook
|
|
140
|
+
frames = Flipbook.read(path, max_pixels:, max_frames:, max_total_pixels:)
|
|
141
|
+
return frames if frame == :all
|
|
142
|
+
|
|
143
|
+
frames.fetch(frame == :first ? 0 : Integer(frame))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_images(images)
|
|
147
|
+
images = Array(images)
|
|
148
|
+
raise ArgumentError, "at least one image is required" if images.empty?
|
|
149
|
+
raise TypeError, "images must contain only Tessel::Image values" unless images.all?(Tessel::Image)
|
|
150
|
+
raise ArgumentError, "images must have positive dimensions" unless images.all? { |image| image.width.positive? && image.height.positive? }
|
|
151
|
+
|
|
152
|
+
images
|
|
153
|
+
end
|
|
154
|
+
private_class_method :validate_images
|
|
155
|
+
|
|
156
|
+
def split_free_rectangles(free, placed)
|
|
157
|
+
split = free.flat_map do |rect|
|
|
158
|
+
overlaps = placed[:x] < rect[:x] + rect[:width] && placed[:x] + placed[:width] > rect[:x] &&
|
|
159
|
+
placed[:y] < rect[:y] + rect[:height] && placed[:y] + placed[:height] > rect[:y]
|
|
160
|
+
next [rect] unless overlaps
|
|
161
|
+
|
|
162
|
+
pieces = []
|
|
163
|
+
pieces << { x: rect[:x], y: rect[:y], width: rect[:width], height: placed[:y] - rect[:y] } if placed[:y] > rect[:y]
|
|
164
|
+
pieces << { x: rect[:x], y: placed[:y] + placed[:height], width: rect[:width], height: rect[:y] + rect[:height] - placed[:y] - placed[:height] } if placed[:y] + placed[:height] < rect[:y] + rect[:height]
|
|
165
|
+
pieces << { x: rect[:x], y: rect[:y], width: placed[:x] - rect[:x], height: rect[:height] } if placed[:x] > rect[:x]
|
|
166
|
+
pieces << { x: placed[:x] + placed[:width], y: rect[:y], width: rect[:x] + rect[:width] - placed[:x] - placed[:width], height: rect[:height] } if placed[:x] + placed[:width] < rect[:x] + rect[:width]
|
|
167
|
+
pieces.select { |piece| piece[:width].positive? && piece[:height].positive? }
|
|
168
|
+
end
|
|
169
|
+
# ponytail: free-rectangle pruning is O(n²); replace it if large atlases make it slow.
|
|
170
|
+
split.reject.with_index do |rect, index|
|
|
171
|
+
split.each_with_index.any? do |other, other_index|
|
|
172
|
+
next false if index == other_index
|
|
173
|
+
|
|
174
|
+
contained = other[:x] <= rect[:x] && other[:y] <= rect[:y] &&
|
|
175
|
+
other[:x] + other[:width] >= rect[:x] + rect[:width] &&
|
|
176
|
+
other[:y] + other[:height] >= rect[:y] + rect[:height]
|
|
177
|
+
contained && (other != rect || other_index < index)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
private_class_method :split_free_rectangles
|
|
182
|
+
|
|
183
|
+
def require_flipbook
|
|
184
|
+
require "flipbook"
|
|
185
|
+
version = Gem::Version.new(Flipbook::VERSION)
|
|
186
|
+
raise Error, "GIF/APNG support requires flipbook >= 0.4.0 (found #{version})" if version < Gem::Version.new("0.4.0")
|
|
187
|
+
rescue LoadError => e
|
|
188
|
+
raise Error, "GIF/APNG support requires flipbook >= 0.4.0 (gem install flipbook)", cause: e
|
|
189
|
+
end
|
|
190
|
+
private_class_method :require_flipbook
|
|
191
|
+
end
|
|
192
|
+
end
|