retouch 0.1.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 -0
- data/README.md +65 -11
- data/bench/retouch.rb +3 -0
- data/docs/index.html +28 -0
- data/lib/retouch/batch.rb +82 -0
- data/lib/retouch/cli.rb +190 -40
- data/lib/retouch/io.rb +4 -0
- 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 +18 -8
- data/lib/retouch/pipeline.rb +5 -1
- data/lib/retouch/version.rb +1 -1
- data/lib/retouch.rb +14 -1
- data/retouch.gemspec +2 -2
- data/sig/retouch.rbs +46 -0
- metadata +18 -6
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
module Kernels
|
|
5
|
+
module Convolve
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def gaussian(image, sigma)
|
|
9
|
+
sigma = Float(sigma)
|
|
10
|
+
raise ArgumentError, "sigma must be positive and at most 100" unless sigma.finite? && sigma.positive? && sigma <= 100
|
|
11
|
+
|
|
12
|
+
radius = (sigma * 3).ceil
|
|
13
|
+
weights = (-radius..radius).map { |offset| Math.exp(-(offset * offset) / (2 * sigma * sigma)) }
|
|
14
|
+
total = weights.sum
|
|
15
|
+
weights.map! { |weight| weight / total }
|
|
16
|
+
convolve_axis(convolve_axis(image, weights, horizontal: true), weights, horizontal: false)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def convolve_axis(image, weights, horizontal:)
|
|
20
|
+
width = image.width
|
|
21
|
+
height = image.height
|
|
22
|
+
source = image.bytes
|
|
23
|
+
output = String.new(capacity: source.bytesize, encoding: Encoding::BINARY)
|
|
24
|
+
axis_length = horizontal ? width : height
|
|
25
|
+
radius = weights.length / 2
|
|
26
|
+
entries = Array.new(axis_length) do |position|
|
|
27
|
+
weights.each_with_index.map do |weight, index|
|
|
28
|
+
sample = (position + index - radius).clamp(0, axis_length - 1)
|
|
29
|
+
[horizontal ? sample * 4 : sample * width * 4, weight]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
height.times do |y|
|
|
33
|
+
width.times do |x|
|
|
34
|
+
red = green = blue = alpha = 0.0
|
|
35
|
+
base = horizontal ? y * width * 4 : x * 4
|
|
36
|
+
entries[horizontal ? x : y].each do |sample_offset, weight|
|
|
37
|
+
offset = base + sample_offset
|
|
38
|
+
sample_alpha = source.getbyte(offset + 3) / 255.0
|
|
39
|
+
red += source.getbyte(offset) * sample_alpha * weight
|
|
40
|
+
green += source.getbyte(offset + 1) * sample_alpha * weight
|
|
41
|
+
blue += source.getbyte(offset + 2) * sample_alpha * weight
|
|
42
|
+
alpha += source.getbyte(offset + 3) * weight
|
|
43
|
+
end
|
|
44
|
+
alpha_byte = alpha.round.clamp(0, 255)
|
|
45
|
+
if alpha_byte.positive?
|
|
46
|
+
output << (red * 255 / alpha).round.clamp(0, 255)
|
|
47
|
+
output << (green * 255 / alpha).round.clamp(0, 255)
|
|
48
|
+
output << (blue * 255 / alpha).round.clamp(0, 255)
|
|
49
|
+
else
|
|
50
|
+
output << "\0\0\0".b
|
|
51
|
+
end
|
|
52
|
+
output << alpha_byte
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
Tessel::Image.from_rgba(width, height, output, metadata: image.metadata)
|
|
56
|
+
end
|
|
57
|
+
private_class_method :convolve_axis
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -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
|
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)
|
|
@@ -152,29 +154,30 @@ module Retouch
|
|
|
152
154
|
intermediate = String.new(capacity: width * image.height * 4, encoding: Encoding::BINARY)
|
|
153
155
|
image.height.times do |y|
|
|
154
156
|
width.times do |x|
|
|
155
|
-
sums = [0
|
|
157
|
+
sums = [0, 0, 0, 0]
|
|
156
158
|
horizontal[x].each do |sx, weight|
|
|
157
159
|
offset = ((y * image.width) + sx) * 4
|
|
158
|
-
alpha = source.getbyte(offset + 3)
|
|
160
|
+
alpha = source.getbyte(offset + 3)
|
|
159
161
|
sums[0] += source.getbyte(offset) * alpha * weight
|
|
160
162
|
sums[1] += source.getbyte(offset + 1) * alpha * weight
|
|
161
163
|
sums[2] += source.getbyte(offset + 2) * alpha * weight
|
|
162
164
|
sums[3] += source.getbyte(offset + 3) * weight
|
|
163
165
|
end
|
|
164
|
-
|
|
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)
|
|
165
168
|
end
|
|
166
169
|
end
|
|
167
170
|
result = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
|
|
168
171
|
height.times do |y|
|
|
169
172
|
width.times do |x|
|
|
170
|
-
sums = [0
|
|
173
|
+
sums = [0, 0, 0, 0]
|
|
171
174
|
vertical[y].each do |sy, weight|
|
|
172
175
|
offset = ((sy * width) + x) * 4
|
|
173
176
|
4.times { |channel| sums[channel] += intermediate.getbyte(offset + channel) * weight }
|
|
174
177
|
end
|
|
175
|
-
alpha = sums[3].
|
|
178
|
+
alpha = round_divide(sums[3], WEIGHT_SCALE).clamp(0, 255)
|
|
176
179
|
if alpha.positive?
|
|
177
|
-
3.times { |channel| result << (sums[channel] * 255
|
|
180
|
+
3.times { |channel| result << round_divide(sums[channel] * 255, WEIGHT_SCALE * alpha).clamp(0, 255) }
|
|
178
181
|
else
|
|
179
182
|
result << "\0\0\0".b
|
|
180
183
|
end
|
|
@@ -202,10 +205,17 @@ module Retouch
|
|
|
202
205
|
weights[index.clamp(0, source - 1)] += weight
|
|
203
206
|
end
|
|
204
207
|
total = weights.values.sum
|
|
205
|
-
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
|
|
206
212
|
end
|
|
207
213
|
end
|
|
208
214
|
|
|
215
|
+
def round_divide(value, divisor)
|
|
216
|
+
value.negative? ? -((-value + (divisor / 2)) / divisor) : (value + (divisor / 2)) / divisor
|
|
217
|
+
end
|
|
218
|
+
|
|
209
219
|
def cubic(value)
|
|
210
220
|
value = value.abs
|
|
211
221
|
return (((1.5 * value) - 2.5) * value * value) + 1 if value < 1
|
|
@@ -259,6 +269,6 @@ module Retouch
|
|
|
259
269
|
[*(alpha.positive? ? sums.first(3).map { |value| (value * 255 / alpha).round.clamp(0, 255) } : [0, 0, 0]), alpha]
|
|
260
270
|
end
|
|
261
271
|
|
|
262
|
-
private_class_method :resample, :axis_weights, :cubic, :lanczos, :copy_metadata, :rotate_quarter, :sample_bilinear
|
|
272
|
+
private_class_method :resample, :axis_weights, :round_divide, :cubic, :lanczos, :copy_metadata, :rotate_quarter, :sample_bilinear
|
|
263
273
|
end
|
|
264
274
|
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
|
|
7
|
+
grayscale invert brightness contrast gamma saturate tint opacity quantize
|
|
8
|
+
blur sharpen pixelate 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)
|
data/lib/retouch/version.rb
CHANGED