retouch 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -4
- data/LICENSE +1 -1
- data/README.md +74 -67
- data/bench/retouch.rb +4 -2
- data/docs/index.html +28 -0
- data/lib/retouch/batch.rb +61 -41
- data/lib/retouch/cli.rb +178 -187
- data/lib/retouch/io.rb +5 -19
- data/lib/retouch/kernels/color.rb +18 -0
- data/lib/retouch/kernels/convolve.rb +60 -0
- data/lib/retouch/kernels/lut.rb +75 -0
- data/lib/retouch/operations/color.rb +21 -0
- data/lib/retouch/operations/draw.rb +160 -0
- data/lib/retouch/operations/filter.rb +64 -0
- data/lib/retouch/operations/multiple.rb +192 -0
- data/lib/retouch/operations.rb +31 -482
- data/lib/retouch/pipeline.rb +3 -4
- data/lib/retouch/version.rb +1 -1
- data/lib/retouch.rb +13 -5
- data/retouch.gemspec +2 -2
- data/sig/retouch.rbs +43 -11
- metadata +12 -4
data/lib/retouch/cli.rb
CHANGED
|
@@ -6,10 +6,42 @@ require "optparse"
|
|
|
6
6
|
module Retouch
|
|
7
7
|
class CLI
|
|
8
8
|
OPERATIONS = %w[
|
|
9
|
-
|
|
10
|
-
contrast gamma saturate tint opacity quantize
|
|
11
|
-
|
|
9
|
+
resize thumbnail crop flip flop rotate pad extend border trim
|
|
10
|
+
grayscale invert brightness contrast gamma saturate tint opacity quantize
|
|
11
|
+
blur sharpen pixelate overlay watermark text rect arrow
|
|
12
|
+
montage append spritesheet animate diff
|
|
12
13
|
].freeze
|
|
14
|
+
MULTI_IMAGE_OPERATIONS = %w[montage append spritesheet animate diff].freeze
|
|
15
|
+
OPERATION_USAGE = {
|
|
16
|
+
"resize" => "GEOMETRY [--filter nearest|bilinear|bicubic|lanczos3] [--gravity POSITION]",
|
|
17
|
+
"thumbnail" => "GEOMETRY [--filter FILTER]",
|
|
18
|
+
"crop" => "GEOMETRY [--gravity POSITION]",
|
|
19
|
+
"rotate" => "DEGREES [--background COLOR]",
|
|
20
|
+
"pad" => "PIXELS [--color COLOR]",
|
|
21
|
+
"extend" => "WIDTH HEIGHT [--color COLOR] [--gravity POSITION]",
|
|
22
|
+
"border" => "PIXELS [COLOR]",
|
|
23
|
+
"trim" => "[--color COLOR] [--fuzz 0..255]",
|
|
24
|
+
"brightness" => "-255..255",
|
|
25
|
+
"contrast" => "FACTOR",
|
|
26
|
+
"gamma" => "GAMMA",
|
|
27
|
+
"saturate" => "FACTOR",
|
|
28
|
+
"tint" => "COLOR [--amount 0..1]",
|
|
29
|
+
"opacity" => "0..1",
|
|
30
|
+
"quantize" => "[--colors 2..256] [--dither none|ordered|floyd-steinberg]",
|
|
31
|
+
"blur" => "SIGMA",
|
|
32
|
+
"sharpen" => "SIGMA [--amount FACTOR]",
|
|
33
|
+
"pixelate" => "BLOCK_SIZE",
|
|
34
|
+
"overlay" => "IMAGE [--x X] [--y Y] [--gravity POSITION] [--opacity 0..1] [--mode MODE]",
|
|
35
|
+
"watermark" => "IMAGE [composite options]",
|
|
36
|
+
"text" => "TEXT [--at POSITION] [--size PIXELS] [--font FILE] [--background COLOR]",
|
|
37
|
+
"rect" => "X Y WIDTH HEIGHT [COLOR] [--fill]",
|
|
38
|
+
"arrow" => "X1 Y1 X2 Y2 [COLOR] [--width PIXELS] [--head PIXELS]",
|
|
39
|
+
"montage" => "[--cols N] [--gap PIXELS] [--background COLOR] [--label]",
|
|
40
|
+
"append" => "[--direction vertical|horizontal] [--gap PIXELS]",
|
|
41
|
+
"spritesheet" => "[--max-width PIXELS] [--gap PIXELS]",
|
|
42
|
+
"animate" => "(--fps RATE | --delay SECONDS) [--no-loop]",
|
|
43
|
+
"diff" => "[--threshold 0..255] [--color COLOR]"
|
|
44
|
+
}.freeze
|
|
13
45
|
|
|
14
46
|
def self.run(argv, out: $stdout, err: $stderr)
|
|
15
47
|
new(argv, out:, err:).run
|
|
@@ -22,39 +54,35 @@ module Retouch
|
|
|
22
54
|
@args = argv.dup
|
|
23
55
|
@out = out
|
|
24
56
|
@err = err
|
|
25
|
-
@
|
|
26
|
-
@options = { jobs: 1, level: 6, frame: 0 }
|
|
57
|
+
@options = { level: 6, jobs: 1, loop: true }
|
|
27
58
|
read_global_options
|
|
28
59
|
raise ArgumentError, "PNG level must be between 0 and 9" unless @options[:level].between?(0, 9)
|
|
29
60
|
raise ArgumentError, "jobs must be positive" unless @options[:jobs].positive?
|
|
30
|
-
raise ArgumentError, "frame must not be negative" if @options[:frame].negative?
|
|
31
61
|
end
|
|
32
62
|
|
|
33
63
|
def run
|
|
34
64
|
return help if @options[:help] || @args.empty?
|
|
35
|
-
|
|
36
|
-
if @args.first == "
|
|
37
|
-
@args.shift
|
|
38
|
-
return info_command
|
|
39
|
-
end
|
|
40
|
-
if @args.first == "help"
|
|
41
|
-
@args.shift
|
|
42
|
-
return help(@args.shift)
|
|
43
|
-
end
|
|
44
|
-
return diff_command if @args.first == "diff"
|
|
65
|
+
return info_command if @args.first == "info"
|
|
66
|
+
return help(@args[1]) if @args.first == "help"
|
|
45
67
|
|
|
46
68
|
operation_at = @args.index { |token| OPERATIONS.include?(token) }
|
|
47
69
|
return fail_usage("missing operation") unless operation_at
|
|
48
70
|
|
|
49
|
-
inputs =
|
|
50
|
-
|
|
51
|
-
return fail_usage("
|
|
52
|
-
|
|
71
|
+
inputs = @args.shift(operation_at)
|
|
72
|
+
return fail_usage("at least one input file is required") if inputs.empty?
|
|
73
|
+
return fail_usage("an output path with -o is required") unless @options[:output]
|
|
74
|
+
|
|
75
|
+
operation = @args.first
|
|
76
|
+
parsed = parse_cli_operations(@args)
|
|
77
|
+
return 2 unless parsed
|
|
53
78
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
79
|
+
if MULTI_IMAGE_OPERATIONS.include?(operation)
|
|
80
|
+
return fail_usage("#{operation} must be the only operation in its command") unless parsed.one?
|
|
81
|
+
|
|
82
|
+
multi_image_command(inputs, operation, parsed.fetch(0))
|
|
83
|
+
else
|
|
84
|
+
image_command(inputs, parsed)
|
|
85
|
+
end
|
|
58
86
|
rescue StandardError => e
|
|
59
87
|
@err.puts("retouch: #{e.message}")
|
|
60
88
|
@err.puts(e.backtrace.first) if @options[:verbose]
|
|
@@ -75,9 +103,10 @@ module Retouch
|
|
|
75
103
|
when "--quiet" then @options[:quiet] = true
|
|
76
104
|
when "--force" then @options[:force] = true
|
|
77
105
|
when "--strip" then @options[:strip] = true
|
|
78
|
-
when "--
|
|
106
|
+
when "--loop" then @options[:loop] = true
|
|
107
|
+
when "--no-loop" then @options[:loop] = false
|
|
79
108
|
when "--level" then @options[:level] = Integer(require_value(token))
|
|
80
|
-
when "--
|
|
109
|
+
when "--jobs" then @options[:jobs] = Integer(require_value(token))
|
|
81
110
|
else remaining << token
|
|
82
111
|
end
|
|
83
112
|
end
|
|
@@ -91,10 +120,6 @@ module Retouch
|
|
|
91
120
|
value
|
|
92
121
|
end
|
|
93
122
|
|
|
94
|
-
def expand_inputs(inputs)
|
|
95
|
-
inputs.flat_map { |item| File.file?(item) ? [item] : Dir.glob(item) }.uniq.sort
|
|
96
|
-
end
|
|
97
|
-
|
|
98
123
|
def parse_operations(tokens)
|
|
99
124
|
operations = []
|
|
100
125
|
until tokens.empty?
|
|
@@ -102,58 +127,68 @@ module Retouch
|
|
|
102
127
|
raise ArgumentError, "unknown operation: #{name}" unless OPERATIONS.include?(name)
|
|
103
128
|
|
|
104
129
|
args, options = case name
|
|
105
|
-
when "resize", "thumbnail", "crop"
|
|
106
|
-
|
|
107
|
-
when "
|
|
108
|
-
when "
|
|
130
|
+
when "resize", "thumbnail", "crop"
|
|
131
|
+
[[required(tokens, "geometry")], read_options(tokens, filter: :symbol, gravity: :symbol)]
|
|
132
|
+
when "rotate" then [[Float(required(tokens, name))], read_options(tokens, background: :color)]
|
|
133
|
+
when "pad" then [[Integer(required(tokens, name))], read_options(tokens, color: :color)]
|
|
134
|
+
when "extend" then [[Integer(required(tokens, name)), Integer(required(tokens, name))], read_options(tokens, color: :color, gravity: :symbol)]
|
|
135
|
+
when "border" then [[Integer(required(tokens, name)), tokens.first&.start_with?("#") ? tokens.shift : "#000000"], {}]
|
|
136
|
+
when "trim" then [[], read_options(tokens, fuzz: :integer, color: :color)]
|
|
137
|
+
when "brightness", "contrast", "gamma", "saturate", "opacity" then [[Float(required(tokens, name))], {}]
|
|
138
|
+
when "tint" then [[required(tokens, "color")], read_options(tokens, amount: :float)]
|
|
109
139
|
when "quantize" then [[], read_options(tokens, colors: :integer, dither: :symbol)]
|
|
110
|
-
when "blur" then [[], read_options(tokens,
|
|
111
|
-
when "
|
|
112
|
-
when "
|
|
113
|
-
|
|
114
|
-
when "
|
|
115
|
-
|
|
116
|
-
when "
|
|
117
|
-
|
|
118
|
-
when "
|
|
119
|
-
|
|
120
|
-
when "
|
|
121
|
-
when "
|
|
122
|
-
|
|
140
|
+
when "blur", "sharpen" then [[Float(required(tokens, name))], read_options(tokens, amount: :float)]
|
|
141
|
+
when "pixelate" then [[Integer(required(tokens, name))], {}]
|
|
142
|
+
when "overlay", "watermark"
|
|
143
|
+
[[ImageIO.read(required(tokens, "overlay image"))], read_options(tokens, x: :integer, y: :integer, gravity: :symbol, opacity: :float, mode: :symbol)]
|
|
144
|
+
when "text"
|
|
145
|
+
[[required(tokens, "text")], read_options(tokens, x: :integer, y: :integer, at: :symbol, size: :integer, font: :string, color: :color, background: :color, padding: :integer)]
|
|
146
|
+
when "rect"
|
|
147
|
+
[[Integer(required(tokens, "x")), Integer(required(tokens, "y")), Integer(required(tokens, "width")), Integer(required(tokens, "height")), tokens.first&.start_with?("#") ? tokens.shift : "#ffffff"], read_options(tokens, fill: :flag)]
|
|
148
|
+
when "arrow"
|
|
149
|
+
[[Integer(required(tokens, "x1")), Integer(required(tokens, "y1")), Integer(required(tokens, "x2")), Integer(required(tokens, "y2")), tokens.first&.start_with?("#") ? tokens.shift : "#ffffff"], read_options(tokens, width: :integer, head: :float)]
|
|
150
|
+
when "montage" then [[], read_options(tokens, cols: :integer, gap: :integer, background: :color, label: :flag)]
|
|
151
|
+
when "append" then [[], read_options(tokens, direction: :symbol, gap: :integer, background: :color)]
|
|
152
|
+
when "spritesheet" then [[], read_options(tokens, max_width: :integer, gap: :integer, background: :color)]
|
|
153
|
+
when "animate"
|
|
154
|
+
options = read_options(tokens, fps: :float, delay: :float, colors: :integer, dither: :symbol)
|
|
155
|
+
raise OptionParser::InvalidArgument, "use either --fps or --delay" if options.key?(:fps) && options.key?(:delay)
|
|
156
|
+
raise OptionParser::MissingArgument, "--fps or --delay" unless options.key?(:fps) || options.key?(:delay)
|
|
157
|
+
|
|
158
|
+
[[], options]
|
|
159
|
+
when "diff" then [[], read_options(tokens, threshold: :float, color: :color)]
|
|
160
|
+
when "grayscale", "invert", "flip", "flop" then [[], {}]
|
|
123
161
|
end
|
|
124
162
|
operations << [name, args, options]
|
|
125
163
|
end
|
|
126
164
|
operations
|
|
127
165
|
end
|
|
128
166
|
|
|
129
|
-
def
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
167
|
+
def parse_cli_operations(tokens)
|
|
168
|
+
parse_operations(tokens)
|
|
169
|
+
rescue ArgumentError, OptionParser::ParseError => e
|
|
170
|
+
@err.puts("retouch: #{e.message}")
|
|
171
|
+
nil
|
|
133
172
|
end
|
|
134
173
|
|
|
135
174
|
def read_options(tokens, specification)
|
|
136
175
|
options = {}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
token = tokens.shift.delete_prefix("--")
|
|
141
|
-
disabled = token.start_with?("no-")
|
|
142
|
-
option = token.delete_prefix("no-").tr("-", "_").to_sym
|
|
176
|
+
while tokens.first&.start_with?("--")
|
|
177
|
+
option = tokens.shift.delete_prefix("--").tr("-", "_").to_sym
|
|
143
178
|
type = specification[option]
|
|
144
179
|
raise OptionParser::InvalidOption, "--#{option}" unless type
|
|
145
180
|
|
|
146
|
-
if type == :
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
181
|
+
options[option] = if type == :flag
|
|
182
|
+
true
|
|
183
|
+
else
|
|
184
|
+
value = required(tokens, option)
|
|
185
|
+
case type
|
|
151
186
|
when :integer then Integer(value)
|
|
152
187
|
when :float then Float(value)
|
|
153
188
|
when :symbol then value.tr("-", "_").to_sym
|
|
154
189
|
else value
|
|
155
190
|
end
|
|
156
|
-
|
|
191
|
+
end
|
|
157
192
|
end
|
|
158
193
|
options
|
|
159
194
|
end
|
|
@@ -165,130 +200,96 @@ module Retouch
|
|
|
165
200
|
value
|
|
166
201
|
end
|
|
167
202
|
|
|
168
|
-
def
|
|
169
|
-
|
|
170
|
-
return fail_usage("
|
|
203
|
+
def image_command(inputs, operations)
|
|
204
|
+
expanded = expand_inputs(inputs)
|
|
205
|
+
return fail_usage("no input files matched") if expanded.empty?
|
|
171
206
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
207
|
+
batch = expanded.length > 1 || inputs.any? { |input| input.match?(/[?*{\[]/) } || @options[:output].include?("{")
|
|
208
|
+
if batch
|
|
209
|
+
return fail_usage("batch output must use a template such as {name}") if expanded.length > 1 && !@options[:output].include?("{")
|
|
175
210
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
211
|
+
outputs = Batch.run(expanded, to: @options[:output], jobs: @options[:jobs], force: @options[:force], dry_run: @options[:dry_run]) do |input, output|
|
|
212
|
+
apply_operations(Retouch.open(input), operations).save(output, level: @options[:level], strip: @options[:strip])
|
|
213
|
+
end
|
|
214
|
+
outputs.each { |path| announce(path) } if @options[:dry_run] || !@options[:quiet]
|
|
215
|
+
return 0
|
|
216
|
+
end
|
|
180
217
|
|
|
181
|
-
output = @options[:output]
|
|
182
|
-
return announce("retouch
|
|
218
|
+
output = @options[:output]
|
|
219
|
+
return announce("retouch #{expanded.first} #{operations.map(&:first).join(" ")} -o #{output}") if @options[:dry_run]
|
|
220
|
+
raise Error, "input file does not exist: #{expanded.first}" unless File.file?(expanded.first)
|
|
221
|
+
raise Error, "refusing to overwrite #{output}; use --force" if File.exist?(output) && !@options[:force]
|
|
183
222
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
announce(format("diff %<ratio>.2f%% (%<pixels>s pixels)", ratio: image.metadata.fetch("diff_ratio").to_f * 100, pixels: image.metadata.fetch("diff_pixels")))
|
|
223
|
+
result = apply_operations(Retouch.open(expanded.first), operations)
|
|
224
|
+
FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
|
|
225
|
+
result.save(output, level: @options[:level], strip: @options[:strip])
|
|
188
226
|
announce(output)
|
|
189
|
-
0
|
|
190
227
|
end
|
|
191
228
|
|
|
192
|
-
def
|
|
193
|
-
|
|
229
|
+
def multi_image_command(inputs, name, (_operation, _arguments, options))
|
|
230
|
+
paths = expand_inputs(inputs)
|
|
231
|
+
return fail_usage("no input files matched") if paths.empty?
|
|
232
|
+
return fail_usage("#{name} expects at least two images") if paths.length < 2
|
|
233
|
+
return fail_usage("diff expects exactly two images") if name == "diff" && paths.length != 2
|
|
234
|
+
|
|
235
|
+
output = @options[:output]
|
|
236
|
+
manifest = spritesheet_manifest_path(output) if name == "spritesheet"
|
|
237
|
+
outputs = name == "spritesheet" ? [output, manifest] : [output]
|
|
238
|
+
return announce("retouch #{paths.join(" ")} #{name} -o #{output}") if @options[:dry_run]
|
|
239
|
+
if !@options[:force] && (existing = outputs.find { |path| File.exist?(path) })
|
|
240
|
+
raise Error, "refusing to overwrite #{existing}; use --force"
|
|
241
|
+
end
|
|
194
242
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
images = files.map { |path| ImageIO.read(path, frame: @options[:frame]) }
|
|
243
|
+
FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
|
|
244
|
+
images = paths.map { |path| ImageIO.read(path) }
|
|
198
245
|
case name
|
|
199
|
-
when "montage"
|
|
200
|
-
options[:labels] =
|
|
201
|
-
|
|
202
|
-
|
|
246
|
+
when "montage"
|
|
247
|
+
options[:labels] = paths.map { |path| File.basename(path) } if options.delete(:label)
|
|
248
|
+
Operations.montage(images, **options).then { |image| ImageIO.write(image, output, level: @options[:level], strip: @options[:strip]) }
|
|
249
|
+
when "append"
|
|
250
|
+
Operations.append(images, **options).then { |image| ImageIO.write(image, output, level: @options[:level], strip: @options[:strip]) }
|
|
203
251
|
when "spritesheet"
|
|
204
|
-
|
|
205
|
-
output
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
prepare_output(output, sidecars: [output.sub(/\.[^.]+\z/, ".json")])
|
|
209
|
-
ImageIO.write(result, output, level: @options[:level], strip: @options[:strip])
|
|
210
|
-
File.write(output.sub(/\.[^.]+\z/, ".json"), JSON.pretty_generate(map))
|
|
211
|
-
announce(output)
|
|
212
|
-
0
|
|
252
|
+
image, placements = Operations.spritesheet(images, **options)
|
|
253
|
+
ImageIO.write(image, output, level: @options[:level], strip: @options[:strip])
|
|
254
|
+
File.write(manifest, JSON.pretty_generate(placements))
|
|
213
255
|
when "animate"
|
|
214
|
-
output
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
announce(output)
|
|
220
|
-
0
|
|
256
|
+
Operations.animate(images, output, **options, loop: @options[:loop])
|
|
257
|
+
when "diff"
|
|
258
|
+
result = Operations.diff(images[0], images[1], **options)
|
|
259
|
+
ImageIO.write(result[:image], output, level: @options[:level], strip: @options[:strip])
|
|
260
|
+
announce(format("%.2f%% pixels differ", result[:rate] * 100))
|
|
221
261
|
end
|
|
262
|
+
announce(output)
|
|
222
263
|
end
|
|
223
264
|
|
|
224
|
-
def
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
raise Error, "refusing to overwrite output; use --force" if !@options[:force] && outputs.any? { |path| File.exist?(path) }
|
|
232
|
-
return 0 if @options[:dry_run] && files.zip(outputs).each { |input, output| announce("#{output} <= #{input}") }
|
|
233
|
-
|
|
234
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
235
|
-
outputs = Batch.run(files, to: @options[:output], force: @options[:force], jobs: @options[:jobs], level: @options[:level], strip: @options[:strip]) do |pipeline|
|
|
236
|
-
pipeline = apply_operations(pipeline, operations)
|
|
237
|
-
pipeline
|
|
265
|
+
def apply_operations(pipeline, operations)
|
|
266
|
+
operations.reduce(pipeline) do |current, (name, args, options)|
|
|
267
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC) if @options[:verbose]
|
|
268
|
+
result = current.public_send(name, *args, **options)
|
|
269
|
+
if started
|
|
270
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
271
|
+
announce(format("%<operation>s %<elapsed>.3fs", operation: name, elapsed:))
|
|
238
272
|
end
|
|
239
|
-
|
|
240
|
-
announce(format("batch completed in %<elapsed>.3fs", elapsed:)) if @options[:verbose]
|
|
241
|
-
outputs.each { |path| announce(path) }
|
|
242
|
-
return 0
|
|
273
|
+
result
|
|
243
274
|
end
|
|
244
|
-
input = files.first
|
|
245
|
-
output = Batch.expand(@options[:output], input, 0)
|
|
246
|
-
return announce("retouch #{input} #{operations.map(&:first).join(" ")} -o #{@options[:output]}") if @options[:dry_run]
|
|
247
|
-
raise Error, "refusing to overwrite #{output}; use --force" if File.exist?(output) && !@options[:force]
|
|
248
|
-
|
|
249
|
-
FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
|
|
250
|
-
|
|
251
|
-
pipeline = Retouch.open(input, frame: @options[:frame])
|
|
252
|
-
pipeline = apply_operations(pipeline, operations)
|
|
253
|
-
pipeline.save(output, level: @options[:level], strip: @options[:strip])
|
|
254
|
-
announce(output)
|
|
255
|
-
0
|
|
256
275
|
end
|
|
257
276
|
|
|
258
|
-
def
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
prepare_output(output)
|
|
263
|
-
ImageIO.write(image, output, level: @options[:level], strip: @options[:strip])
|
|
264
|
-
announce(output)
|
|
265
|
-
0
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
def announce(message)
|
|
269
|
-
@out.puts(message) unless @options[:quiet] || Process.pid != @pid
|
|
270
|
-
0
|
|
277
|
+
def expand_inputs(inputs)
|
|
278
|
+
inputs.flat_map do |input|
|
|
279
|
+
input.match?(/[?*{\[]/) ? Dir.glob(input) : input
|
|
280
|
+
end
|
|
271
281
|
end
|
|
272
282
|
|
|
273
|
-
def
|
|
274
|
-
|
|
275
|
-
operations.each do |name, args, options|
|
|
276
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
277
|
-
args = args.dup
|
|
278
|
-
args[0] = ImageIO.read(args[0]) if %w[overlay watermark].include?(name) && args[0].is_a?(String)
|
|
279
|
-
image = Operations.apply(image, name, *args, **options)
|
|
280
|
-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
281
|
-
announce(format("%<name>s completed in %<elapsed>.3fs", name:, elapsed:)) if @options[:verbose]
|
|
282
|
-
end
|
|
283
|
-
Retouch.from_image(image)
|
|
283
|
+
def spritesheet_manifest_path(output)
|
|
284
|
+
File.extname(output).empty? ? "#{output}.json" : output.sub(%r{\.[^./]+\z}, ".json")
|
|
284
285
|
end
|
|
285
286
|
|
|
286
|
-
def
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
raise Error, "refusing to overwrite #{existing}; use --force" if existing && !@options[:force]
|
|
287
|
+
def info_command
|
|
288
|
+
@args.shift
|
|
289
|
+
return fail_usage("info expects one input file") unless @args.one?
|
|
290
290
|
|
|
291
|
-
|
|
291
|
+
path = @args.first
|
|
292
|
+
announce(JSON.pretty_generate({ path:, **Operations.info(ImageIO.read(path)) }))
|
|
292
293
|
end
|
|
293
294
|
|
|
294
295
|
def fail_usage(message)
|
|
@@ -297,37 +298,27 @@ module Retouch
|
|
|
297
298
|
2
|
|
298
299
|
end
|
|
299
300
|
|
|
301
|
+
def announce(message)
|
|
302
|
+
@out.puts(message) unless @options[:quiet]
|
|
303
|
+
0
|
|
304
|
+
end
|
|
305
|
+
|
|
300
306
|
def help(operation = nil)
|
|
301
307
|
if operation
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
"rotate" => "retouch INPUT rotate DEGREES -o OUTPUT",
|
|
306
|
-
"border" => "retouch INPUT border WIDTH [#RRGGBB] -o OUTPUT",
|
|
307
|
-
"blur" => "retouch INPUT blur [--sigma VALUE] -o OUTPUT",
|
|
308
|
-
"text" => "retouch INPUT text TEXT --font FONT [--at POSITION] [--size PX] -o OUTPUT",
|
|
309
|
-
"montage" => "retouch INPUT... montage [--cols N] [--gap PX] [--label --font FONT] -o OUTPUT",
|
|
310
|
-
"spritesheet" => "retouch INPUT... spritesheet [--cols N] [--gap PX] -o OUTPUT"
|
|
311
|
-
}
|
|
312
|
-
raise ArgumentError, "unknown operation help: #{operation}" unless OPERATIONS.include?(operation) || details.key?(operation)
|
|
313
|
-
|
|
314
|
-
@out.puts(details.fetch(operation, "retouch INPUT #{operation} [OPTIONS] -o OUTPUT"))
|
|
308
|
+
raise ArgumentError, "unknown operation help: #{operation}" unless OPERATIONS.include?(operation)
|
|
309
|
+
|
|
310
|
+
@out.puts("retouch INPUTS #{operation} #{OPERATION_USAGE.fetch(operation, "")} -o OUTPUT")
|
|
315
311
|
return 0
|
|
316
312
|
end
|
|
317
313
|
@out.puts <<~HELP
|
|
318
|
-
Retouch edits PNG, PPM, and BMP images
|
|
314
|
+
Retouch edits PNG, PPM, and BMP images with a Ruby API and CLI.
|
|
319
315
|
|
|
320
|
-
Usage: retouch INPUT... OPERATION [ARGUMENTS] -o OUTPUT
|
|
316
|
+
Usage: retouch INPUT [INPUT ...] OPERATION [ARGUMENTS] -o OUTPUT
|
|
317
|
+
retouch info INPUT
|
|
321
318
|
retouch help OPERATION
|
|
322
|
-
retouch info INPUT...
|
|
323
|
-
retouch diff EXPECTED ACTUAL -o OUTPUT
|
|
324
|
-
|
|
325
|
-
Operations: resize thumbnail crop rotate flip flop border trim grayscale invert
|
|
326
|
-
brightness contrast gamma saturate tint opacity quantize blur sharpen pixelate
|
|
327
|
-
overlay watermark text rect arrow montage append spritesheet animate
|
|
328
319
|
|
|
329
|
-
|
|
330
|
-
|
|
320
|
+
Operations: #{OPERATIONS.join(" ")}
|
|
321
|
+
Global: -o PATH --force --dry-run --verbose --quiet --strip --level 0..9 --jobs N --loop --no-loop
|
|
331
322
|
HELP
|
|
332
323
|
0
|
|
333
324
|
end
|
data/lib/retouch/io.rb
CHANGED
|
@@ -4,24 +4,19 @@ module Retouch
|
|
|
4
4
|
module ImageIO
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
|
-
def read(path
|
|
8
|
-
frame = Integer(frame)
|
|
9
|
-
raise ArgumentError, "frame must not be negative" if frame.negative?
|
|
10
|
-
|
|
7
|
+
def read(path)
|
|
11
8
|
signature = File.binread(path, 8)
|
|
12
|
-
if signature.start_with?("GIF87a", "GIF89a")
|
|
13
|
-
require_optional("flipbook", "GIF input") { require "flipbook" }
|
|
14
|
-
frames = Flipbook.read(path)
|
|
15
|
-
image = frames.fetch(frame) { raise ArgumentError, "GIF frame is out of range: #{frame}" }
|
|
16
|
-
return with_format(image, "GIF")
|
|
17
|
-
end
|
|
18
9
|
format = if signature.start_with?(Tessel::SIGNATURE)
|
|
19
10
|
"PNG"
|
|
20
11
|
elsif signature.start_with?("P3", "P6")
|
|
21
12
|
"PPM"
|
|
22
13
|
elsif signature.start_with?("BM")
|
|
23
14
|
"BMP"
|
|
15
|
+
elsif signature.start_with?("GIF87a", "GIF89a")
|
|
16
|
+
"GIF"
|
|
24
17
|
end
|
|
18
|
+
return with_format(Operations.open_gif(path, frame: 0), format) if format == "GIF"
|
|
19
|
+
|
|
25
20
|
with_format(Tessel.read(path), format || "unknown")
|
|
26
21
|
rescue Tessel::UnsupportedError => e
|
|
27
22
|
raise Error, "unsupported image format for #{path}: #{e.message}", cause: e
|
|
@@ -33,9 +28,6 @@ module Retouch
|
|
|
33
28
|
image = Tessel::Image.from_rgba(image.width, image.height, image.bytes) if strip
|
|
34
29
|
extension = File.extname(path).downcase
|
|
35
30
|
case extension
|
|
36
|
-
when ".gif", ".apng"
|
|
37
|
-
require_optional("flipbook", "GIF/APNG output") { require "flipbook" }
|
|
38
|
-
Flipbook.write(path, [image], format: extension.delete_prefix("."), colors: 256)
|
|
39
31
|
when ".png"
|
|
40
32
|
image.write(path, level: Integer(level))
|
|
41
33
|
when ".ppm", ".pnm"
|
|
@@ -50,12 +42,6 @@ module Retouch
|
|
|
50
42
|
raise Error, e.message, cause: e
|
|
51
43
|
end
|
|
52
44
|
|
|
53
|
-
def require_optional(gem, feature)
|
|
54
|
-
yield
|
|
55
|
-
rescue LoadError => e
|
|
56
|
-
raise Error, "#{feature} requires the #{gem} gem", cause: e
|
|
57
|
-
end
|
|
58
|
-
|
|
59
45
|
def with_format(image, format)
|
|
60
46
|
metadata = image.metadata.merge("format" => format)
|
|
61
47
|
Tessel::Image.from_rgba(image.width, image.height, image.bytes, metadata:)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
module Kernels
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def quantize(image, colors: 256, dither: :none, palette: nil)
|
|
8
|
+
indices, palette = Tessel::Quantize.quantize(image, colors: Integer(colors), dither: dither.to_sym, palette:)
|
|
9
|
+
source = image.bytes
|
|
10
|
+
output = String.new(capacity: source.bytesize, encoding: Encoding::BINARY)
|
|
11
|
+
(image.width * image.height).times do |index|
|
|
12
|
+
red, green, blue = palette.fetch(indices.getbyte(index))
|
|
13
|
+
output << red << green << blue << source.getbyte((index * 4) + 3)
|
|
14
|
+
end
|
|
15
|
+
Tessel::Image.from_rgba(image.width, image.height, output, metadata: image.metadata)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -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
|