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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ded00b784bc38feace65825e78c1a054c85e25deaa24c3646f0bb42413cbb678
|
|
4
|
+
data.tar.gz: 25e71fe884b758478af207538e4652d9c3d9186a10d7333045d8f9c7f85e9fe8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fef149f89fed473a1f2e648bb0162e7957bc28a799850026743ec872777ba7bc87b1e82ae3db15b43beb927a39fa93a2dd40af297c37495f1de245591ff140d2
|
|
7
|
+
data.tar.gz: d3d5e48248efadcd180e51085a4d92cd8b4b560d21ea1a968ca4c95fe07953794db2cdd29db007109170548d680e10e1ec495fe17a061583956a259b23ee321e
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Retouch are recorded here.
|
|
4
4
|
|
|
5
|
+
## [0.3.1] - 2026-09-25
|
|
6
|
+
|
|
7
|
+
- Use normalized fixed-point weights for resampling with integer interpolation.
|
|
8
|
+
- Require Flipbook 0.4.0 for GIF and APNG integration.
|
|
9
|
+
|
|
10
|
+
## [0.3.0] - 2026-09-25
|
|
11
|
+
|
|
12
|
+
- Add color, quantization, convolution, pixelation, compositing, annotation, and multi-image operations.
|
|
13
|
+
- Add glob-based batch transforms, output templates, and process workers.
|
|
14
|
+
- Add optional Glyphic text and Flipbook 0.3.0+ GIF reading, GIF output, and APNG output integrations.
|
|
15
|
+
- Document current format and GIF Reader limitations.
|
|
16
|
+
|
|
5
17
|
## [0.1.0] - 2026-09-25
|
|
6
18
|
|
|
7
19
|
Initial release
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<h1 align="center">Retouch</h1>
|
|
2
2
|
|
|
3
|
-
<p align="center">
|
|
3
|
+
<p align="center">A small Ruby image editor for PNG, PPM, and BMP.</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
<a href="https://github.com/rbgfx/retouch/actions/workflows/main.yml"><img src="https://github.com/rbgfx/retouch/actions/workflows/main.yml/badge.svg" alt="CI"></a>
|
|
@@ -8,41 +8,95 @@
|
|
|
8
8
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-750014.svg" alt="MIT license"></a>
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
-
Retouch
|
|
11
|
+
Retouch provides a small image-processing API and CLI backed by [Tessel](https://github.com/rbgfx/tessel). It runs in pure Ruby and is intended for screenshots, pixel art, and CI image tasks. JPEG, WebP, and color management are outside its current scope.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
```sh
|
|
16
|
+
gem install retouch
|
|
17
|
+
```
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
Install optional integrations only when needed:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
gem install glyphic # text and montage labels
|
|
23
|
+
gem install flipbook -v '>= 0.4.0' # GIF reading, GIF output, and APNG output
|
|
19
24
|
```
|
|
20
25
|
|
|
21
26
|
## Ruby API
|
|
22
27
|
|
|
28
|
+
Pipelines are lazy: each operation returns a new pipeline, and rendering starts at `to_image` or `save`.
|
|
29
|
+
|
|
23
30
|
```ruby
|
|
24
31
|
require "retouch"
|
|
25
32
|
|
|
26
33
|
Retouch.open("screenshot.png")
|
|
27
34
|
.resize("640x")
|
|
35
|
+
.brightness(8)
|
|
28
36
|
.border(1, "#303846")
|
|
29
37
|
.save("small.png")
|
|
30
38
|
```
|
|
31
39
|
|
|
32
|
-
|
|
40
|
+
Available operations include `resize`, `thumbnail`, `crop`, `trim`, `pad`, `extend`, `border`, `rotate`, `flip`, `flop`, `grayscale`, `invert`, `brightness`, `contrast`, `gamma`, `saturate`, `tint`, `opacity`, `quantize`, `blur`, `sharpen`, `pixelate`, `overlay`, `watermark`, `text`, `rect`, and `arrow`.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
image = Retouch.open("screen.png")
|
|
44
|
+
.resize("800x")
|
|
45
|
+
.overlay("logo.png", gravity: :south_east, opacity: 0.8, mode: :multiply)
|
|
46
|
+
.text("Build passed", at: :north_west, size: 18, background: "#18202ddd")
|
|
47
|
+
image.save("annotated.png")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`brightness` adds a channel value from -255 to 255. `contrast` and `saturate` use 1 as unchanged; `gamma` also uses 1 as unchanged. `tint` takes `amount: 0..1`, and `opacity` takes a multiplier from 0 to 1. `quantize` accepts 2–256 colors and Tessel's `:none`, `:ordered`, or `:floyd_steinberg` dithering. `blur` and `sharpen` take a Gaussian sigma. `pixelate(size)` averages each size-by-size block.
|
|
51
|
+
|
|
52
|
+
GIF frames can be read as composited Tessel images:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
frames = Retouch.open_gif("animation.gif")
|
|
56
|
+
first_frame = Retouch.open_gif("animation.gif", frame: :first).to_image
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Drawing and composite methods accept hex colors, including alpha (`#rrggbbaa`). Blend modes are `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten`, and `add`. `text` and `montage` labels need Glyphic; without it, those calls explain how to install the optional gem.
|
|
33
60
|
|
|
34
61
|
## CLI
|
|
35
62
|
|
|
36
63
|
```sh
|
|
37
|
-
retouch
|
|
38
|
-
retouch
|
|
39
|
-
retouch
|
|
40
|
-
retouch
|
|
64
|
+
retouch screen.png resize 640x --filter lanczos3 -o small.png
|
|
65
|
+
retouch screen.png grayscale contrast 1.1 -o adjusted.png
|
|
66
|
+
retouch screen.png crop 320x200+40+20 text "v1.2" --at south-east --size 18 -o crop.png
|
|
67
|
+
retouch base.png overlay logo.png --gravity south-east --opacity 0.8 -o marked.png
|
|
68
|
+
retouch a.png b.png c.png montage --cols 3 --gap 8 -o sheet.png
|
|
69
|
+
retouch a.png b.png append --direction horizontal -o row.png
|
|
70
|
+
retouch 'frames/*.png' animate --fps 24 -o preview.gif
|
|
71
|
+
retouch 'frames/*.png' animate --delay 0.08 -o preview.apng
|
|
72
|
+
retouch expected.png actual.png diff -o diff.png
|
|
73
|
+
retouch info screen.png
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Quote a glob to let Retouch expand it. One-to-one processing accepts output templates and `--jobs`:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
retouch 'screens/*.png' thumbnail 320x -o 'thumbs/{name}-{index:03}.png' --jobs 4
|
|
41
80
|
```
|
|
42
81
|
|
|
82
|
+
Templates support `{name}`, `{ext}`, `{dir}`, `{index}`, and zero-padded `{index:03}`. Duplicate output paths and accidental overwrites are rejected; use `--force` to replace existing files. Without `Process.fork`, jobs run sequentially. `spritesheet` writes a JSON sidecar with each image's coordinates and packs with a simple maximum-rectangles heuristic.
|
|
83
|
+
|
|
43
84
|
Geometry accepts `WIDTHxHEIGHT`, `WIDTHx`, `xHEIGHT`, percentages, `!` to stretch, `^` to cover, and `>` to shrink only. Crop offsets may be positive or negative. Resize filters are `nearest`, `bilinear`, `bicubic`, and `lanczos3`.
|
|
44
85
|
|
|
45
|
-
|
|
86
|
+
## How Retouch differs from ImageMagick
|
|
87
|
+
|
|
88
|
+
Retouch uses ImageMagick-style geometry as a familiar shorthand, but it does not implement the full ImageMagick command language or promise pixel-for-pixel compatibility. It is a small Ruby library and CLI built on Tessel, supports a narrower set of formats and operations, and does not provide JPEG/WebP codecs or color management. Choose ImageMagick when broad format support or mature photo-processing filters matter; choose Retouch for lightweight PNG-centered Ruby and CI workflows.
|
|
89
|
+
|
|
90
|
+
Global options include `--dry-run`, `--verbose`, `--quiet`, `--strip`, and `--level 0..9`. `retouch help OPERATION` shows that operation's syntax.
|
|
91
|
+
|
|
92
|
+
## Formats and limits
|
|
93
|
+
|
|
94
|
+
- PNG, PPM, and BMP input and output through Tessel.
|
|
95
|
+
- GIF input and GIF/APNG output through optional Flipbook 0.4.0 or newer. `animate` selects GIF or APNG from the output extension; `--fps` and `--delay` are mutually exclusive, and delay is in seconds.
|
|
96
|
+
- Multi-image `diff` compares RGBA channels exactly by default and returns a magenta diff image plus the changed-pixel ratio in the Ruby API.
|
|
97
|
+
- Large photographic images and high-quality arbitrary-angle rotation can be slow in pure Ruby. JPEG, WebP, and color-managed workflows are not supported.
|
|
98
|
+
|
|
99
|
+
On Ruby 4.0.6 with YJIT, the local benchmark measured a 1920×1080 Gaussian blur at σ=3 in 5.836 seconds, above the 3-second target. Results depend on Ruby and hardware; use `--verbose` to measure your own workload.
|
|
46
100
|
|
|
47
101
|
## Development
|
|
48
102
|
|
data/bench/retouch.rb
CHANGED
|
@@ -14,6 +14,9 @@ image = Tessel::Image.new(1920, 1080, fill: [80, 120, 160, 255])
|
|
|
14
14
|
measure("1920x1080 -> 960x540 bilinear") { Retouch::Operations.resize(image, "960x540", filter: :bilinear) }
|
|
15
15
|
measure("1920x1080 -> 960x540 lanczos3") { Retouch::Operations.resize(image, "960x540", filter: :lanczos3) }
|
|
16
16
|
measure("1920x1080 -> 960x540 nearest") { Retouch::Operations.resize(image, "960x540", filter: :nearest) }
|
|
17
|
+
measure("1920x1080 brightness") { Retouch::Operations.brightness(image, 8) }
|
|
18
|
+
measure("1920x1080 contrast") { Retouch::Operations.contrast(image, 1.1) }
|
|
19
|
+
measure("1920x1080 blur sigma=3") { Retouch::Operations.blur(image, 3) }
|
|
17
20
|
measure("256x256 -> 1024x1024 nearest") do
|
|
18
21
|
Retouch::Operations.resize(Tessel::Image.new(256, 256), "400%", filter: :nearest)
|
|
19
22
|
end
|
data/docs/index.html
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="theme-color" content="#111315">
|
|
7
|
+
<meta name="description" content="A small Ruby image editor for PNG, PPM, and BMP.">
|
|
8
|
+
<title>Retouch — image work in Ruby</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root{color-scheme:dark;--bg:#111315;--panel:#1a1d20;--line:#2c3034;--text:#f1f1ec;--muted:#a2a7a9;--accent:#79d9c5;--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
11
|
+
*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 20% -20%,#1d3934,transparent 44%),var(--bg);color:var(--text);font:16px/1.65 var(--sans)}a{color:inherit;text-decoration:none}a:hover{color:var(--accent)}.wrap{width:min(1040px,calc(100% - 40px));margin:auto}.nav{height:76px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{font-size:20px;font-weight:750;letter-spacing:-.04em}.brand span,.label{color:var(--accent)}.links{display:flex;gap:24px;color:var(--muted);font-size:14px}.hero{padding:88px 0 70px;display:grid;grid-template-columns:1.1fr .9fr;align-items:center;gap:50px}.label{font:12px var(--mono);letter-spacing:.14em;text-transform:uppercase}h1{font-size:clamp(48px,7vw,78px);line-height:.99;letter-spacing:-.075em;margin:19px 0}.hero p{font-size:18px;color:var(--muted)}.actions{display:flex;gap:12px;flex-wrap:wrap;margin:28px 0 22px}.button{padding:10px 16px;border:1px solid var(--line);border-radius:5px;font-weight:650}.primary{background:var(--accent);border-color:var(--accent);color:#10211d}.install{font:14px var(--mono);background:#090b0c;border:1px solid var(--line);padding:12px 15px;border-radius:6px;display:inline-flex;gap:16px}.install span{color:#737a7b}.art{aspect-ratio:1;border:1px solid var(--line);border-radius:12px;background:linear-gradient(145deg,#202825,#141719);padding:28px;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:12px;transform:rotate(2deg)}.tile{border:1px solid #36433f;border-radius:7px;background:linear-gradient(145deg,#87dbc5,#36594f);position:relative;overflow:hidden}.tile:nth-child(2){background:linear-gradient(145deg,#e0a577,#704d39)}.tile:nth-child(3){background:linear-gradient(145deg,#6d92c7,#354764)}.tile:nth-child(4){background:linear-gradient(145deg,#d8d49c,#6e7150)}.tile:after{content:"";position:absolute;width:80%;height:80%;border:1px solid #ffffff70;inset:10%;border-radius:50%}.tile:nth-child(even):after{border-radius:2px;transform:rotate(24deg)}.section{border-top:1px solid var(--line);padding:42px 0 66px}.section h2{font-size:28px;letter-spacing:-.04em;margin:8px 0 10px}.section>p{max-width:650px;color:var(--muted)}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:25px}.card{background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:19px}.card strong{display:block;margin-bottom:7px}.card p{color:var(--muted);font-size:14px;margin:0}.code{border:1px solid var(--line);background:#090b0c;border-radius:7px;padding:18px;overflow:auto;font:14px/1.8 var(--mono);color:#d3eee5}.foot{border-top:1px solid var(--line);padding:24px 0 36px;display:flex;justify-content:space-between;gap:16px;color:var(--muted);font-size:13px}
|
|
12
|
+
@media(max-width:740px){.hero{grid-template-columns:1fr;gap:30px;padding:66px 0 54px}.art{max-width:400px}.links{gap:14px}.cards{grid-template-columns:1fr}.foot{flex-direction:column}}
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body><div class="wrap">
|
|
16
|
+
<nav class="nav" aria-label="Main navigation"><a class="brand" href="#top">re<span>touch</span></a><div class="links"><a href="#pipeline">Pipeline</a><a href="https://github.com/rbgfx/retouch">GitHub</a><a href="https://rubygems.org/gems/retouch">RubyGems</a></div></nav>
|
|
17
|
+
<main id="top">
|
|
18
|
+
<section class="hero"><div><div class="label">Image tools for Ruby</div><h1>Small edits.<br>Clean pipeline.</h1><p>Resize, crop, annotate, and compose PNG-centered images from Ruby or the command line. Retouch is pure Ruby and built on Tessel.</p><div class="actions"><a class="button primary" href="https://github.com/rbgfx/retouch#ruby-api">Read the guide ↗</a><a class="button" href="https://github.com/rbgfx/retouch#cli">Explore the CLI</a></div><div class="install"><span>$</span> gem install retouch</div></div><div class="art" aria-hidden="true"><div class="tile"></div><div class="tile"></div><div class="tile"></div><div class="tile"></div></div></section>
|
|
19
|
+
<section class="section" id="pipeline"><div class="label">Lazy, composable operations</div><h2>Keep image steps readable</h2><p>Build a pipeline with chainable transformations. Processing starts only when you render or save the result.</p><pre class="code"><code>require "retouch"
|
|
20
|
+
|
|
21
|
+
Retouch.open("screenshot.png")
|
|
22
|
+
.resize("640x")
|
|
23
|
+
.brightness(8)
|
|
24
|
+
.border(1, "#303846")
|
|
25
|
+
.save("small.png")</code></pre><div class="cards"><article class="card"><strong>Ruby API</strong><p>Compose resizing, color adjustments, drawing, overlays, and image comparisons.</p></article><article class="card"><strong>Practical CLI</strong><p>Process globs in batches, create montages, and automate image checks in CI.</p></article><article class="card"><strong>Focused formats</strong><p>PNG, PPM, and BMP through Tessel, with optional GIF and APNG through Flipbook.</p></article></div></section>
|
|
26
|
+
</main><footer class="foot"><span>Retouch · Image work in Ruby</span><span><a href="https://github.com/rbgfx/retouch">Source</a> · MIT License</span></footer>
|
|
27
|
+
</div></body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
class Batch
|
|
5
|
+
def self.expand(inputs)
|
|
6
|
+
Array(inputs).flat_map do |input|
|
|
7
|
+
pattern = String(input)
|
|
8
|
+
if pattern.match?(/[?*{\[]/)
|
|
9
|
+
Dir.glob(pattern)
|
|
10
|
+
else
|
|
11
|
+
pattern
|
|
12
|
+
end
|
|
13
|
+
end.sort
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.output_path(template, input, index)
|
|
17
|
+
path = String(template).gsub("{name}", File.basename(input, File.extname(input)))
|
|
18
|
+
.gsub("{ext}", File.extname(input).delete_prefix("."))
|
|
19
|
+
.gsub("{dir}", File.dirname(input))
|
|
20
|
+
.gsub(/\{index(?::(\d+))?\}/) { format("%0*d", Regexp.last_match(1).to_i, index) }
|
|
21
|
+
raise ArgumentError, "unknown output template token in #{path}" if path.match?(/\{[^}]+\}/)
|
|
22
|
+
|
|
23
|
+
path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.run(inputs, to:, jobs: 1, force: false, dry_run: false)
|
|
27
|
+
paths = expand(inputs)
|
|
28
|
+
raise Error, "no input files matched" if paths.empty?
|
|
29
|
+
raise Error, "input file does not exist: #{paths.find { |path| !File.file?(path) }}" unless paths.all? { |path| File.file?(path) }
|
|
30
|
+
|
|
31
|
+
outputs = paths.each_with_index.map { |path, index| output_path(to, path, index) }
|
|
32
|
+
raise Error, "output template produces duplicate paths" unless outputs.uniq.length == outputs.length
|
|
33
|
+
|
|
34
|
+
jobs = Integer(jobs)
|
|
35
|
+
raise ArgumentError, "jobs must be positive" unless jobs.positive?
|
|
36
|
+
if !force && (existing = outputs.find { |path| File.exist?(path) })
|
|
37
|
+
raise Error, "refusing to overwrite #{existing}; use --force"
|
|
38
|
+
end
|
|
39
|
+
return outputs if dry_run
|
|
40
|
+
|
|
41
|
+
worker_count = Process.respond_to?(:fork) ? jobs : 1
|
|
42
|
+
children = []
|
|
43
|
+
failed = false
|
|
44
|
+
paths.zip(outputs).each do |input, output|
|
|
45
|
+
FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
|
|
46
|
+
if worker_count > 1
|
|
47
|
+
children << Process.fork do
|
|
48
|
+
yield input, output
|
|
49
|
+
exit! 0
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
warn("retouch: #{e.message}")
|
|
52
|
+
exit! 1
|
|
53
|
+
end
|
|
54
|
+
if children.length >= worker_count
|
|
55
|
+
_pid, status = Process.wait2(children.shift)
|
|
56
|
+
failed ||= !status.success?
|
|
57
|
+
end
|
|
58
|
+
else
|
|
59
|
+
yield input, output
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
children.each do |pid|
|
|
63
|
+
_child, status = Process.wait2(pid)
|
|
64
|
+
failed ||= !status.success?
|
|
65
|
+
end
|
|
66
|
+
raise Error, "one or more batch jobs failed" if failed
|
|
67
|
+
|
|
68
|
+
outputs
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.batch(inputs, to:, jobs: 1, force: false, &block)
|
|
73
|
+
raise ArgumentError, "a transformation block is required" unless block
|
|
74
|
+
|
|
75
|
+
Batch.run(inputs, to:, jobs:, force:) do |input, output|
|
|
76
|
+
pipeline = block.call(Retouch.open(input))
|
|
77
|
+
raise TypeError, "batch block must return a Retouch::Pipeline" unless pipeline.is_a?(Pipeline)
|
|
78
|
+
|
|
79
|
+
pipeline.save(output)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/retouch/cli.rb
CHANGED
|
@@ -5,7 +5,43 @@ require "optparse"
|
|
|
5
5
|
|
|
6
6
|
module Retouch
|
|
7
7
|
class CLI
|
|
8
|
-
OPERATIONS = %w[
|
|
8
|
+
OPERATIONS = %w[
|
|
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
|
|
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
|
|
9
45
|
|
|
10
46
|
def self.run(argv, out: $stdout, err: $stderr)
|
|
11
47
|
new(argv, out:, err:).run
|
|
@@ -18,9 +54,10 @@ module Retouch
|
|
|
18
54
|
@args = argv.dup
|
|
19
55
|
@out = out
|
|
20
56
|
@err = err
|
|
21
|
-
@options = { level: 6 }
|
|
57
|
+
@options = { level: 6, jobs: 1, loop: true }
|
|
22
58
|
read_global_options
|
|
23
59
|
raise ArgumentError, "PNG level must be between 0 and 9" unless @options[:level].between?(0, 9)
|
|
60
|
+
raise ArgumentError, "jobs must be positive" unless @options[:jobs].positive?
|
|
24
61
|
end
|
|
25
62
|
|
|
26
63
|
def run
|
|
@@ -32,25 +69,20 @@ module Retouch
|
|
|
32
69
|
return fail_usage("missing operation") unless operation_at
|
|
33
70
|
|
|
34
71
|
inputs = @args.shift(operation_at)
|
|
35
|
-
return fail_usage("
|
|
72
|
+
return fail_usage("at least one input file is required") if inputs.empty?
|
|
36
73
|
return fail_usage("an output path with -o is required") unless @options[:output]
|
|
37
74
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return
|
|
41
|
-
raise Error, "input file does not exist: #{input}" unless File.file?(input)
|
|
42
|
-
raise Error, "refusing to overwrite #{@options[:output]}; use --force" if File.exist?(@options[:output]) && !@options[:force]
|
|
75
|
+
operation = @args.first
|
|
76
|
+
parsed = parse_cli_operations(@args)
|
|
77
|
+
return 2 unless parsed
|
|
43
78
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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)
|
|
48
85
|
end
|
|
49
|
-
pipeline.save(@options[:output], level: @options[:level], strip: @options[:strip])
|
|
50
|
-
announce(@options[:output])
|
|
51
|
-
0
|
|
52
|
-
rescue ArgumentError, OptionParser::ParseError
|
|
53
|
-
raise
|
|
54
86
|
rescue StandardError => e
|
|
55
87
|
@err.puts("retouch: #{e.message}")
|
|
56
88
|
@err.puts(e.backtrace.first) if @options[:verbose]
|
|
@@ -71,7 +103,10 @@ module Retouch
|
|
|
71
103
|
when "--quiet" then @options[:quiet] = true
|
|
72
104
|
when "--force" then @options[:force] = true
|
|
73
105
|
when "--strip" then @options[:strip] = true
|
|
106
|
+
when "--loop" then @options[:loop] = true
|
|
107
|
+
when "--no-loop" then @options[:loop] = false
|
|
74
108
|
when "--level" then @options[:level] = Integer(require_value(token))
|
|
109
|
+
when "--jobs" then @options[:jobs] = Integer(require_value(token))
|
|
75
110
|
else remaining << token
|
|
76
111
|
end
|
|
77
112
|
end
|
|
@@ -92,19 +127,50 @@ module Retouch
|
|
|
92
127
|
raise ArgumentError, "unknown operation: #{name}" unless OPERATIONS.include?(name)
|
|
93
128
|
|
|
94
129
|
args, options = case name
|
|
95
|
-
when "resize", "thumbnail", "crop"
|
|
96
|
-
|
|
97
|
-
when "
|
|
98
|
-
when "
|
|
99
|
-
when "
|
|
100
|
-
when "
|
|
101
|
-
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)]
|
|
139
|
+
when "quantize" then [[], read_options(tokens, colors: :integer, dither: :symbol)]
|
|
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 [[], {}]
|
|
102
161
|
end
|
|
103
162
|
operations << [name, args, options]
|
|
104
163
|
end
|
|
105
164
|
operations
|
|
106
165
|
end
|
|
107
166
|
|
|
167
|
+
def parse_cli_operations(tokens)
|
|
168
|
+
parse_operations(tokens)
|
|
169
|
+
rescue ArgumentError, OptionParser::ParseError => e
|
|
170
|
+
@err.puts("retouch: #{e.message}")
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
108
174
|
def read_options(tokens, specification)
|
|
109
175
|
options = {}
|
|
110
176
|
while tokens.first&.start_with?("--")
|
|
@@ -112,11 +178,16 @@ module Retouch
|
|
|
112
178
|
type = specification[option]
|
|
113
179
|
raise OptionParser::InvalidOption, "--#{option}" unless type
|
|
114
180
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
181
|
+
options[option] = if type == :flag
|
|
182
|
+
true
|
|
183
|
+
else
|
|
184
|
+
value = required(tokens, option)
|
|
185
|
+
case type
|
|
186
|
+
when :integer then Integer(value)
|
|
187
|
+
when :float then Float(value)
|
|
188
|
+
when :symbol then value.tr("-", "_").to_sym
|
|
189
|
+
else value
|
|
190
|
+
end
|
|
120
191
|
end
|
|
121
192
|
end
|
|
122
193
|
options
|
|
@@ -129,16 +200,96 @@ module Retouch
|
|
|
129
200
|
value
|
|
130
201
|
end
|
|
131
202
|
|
|
203
|
+
def image_command(inputs, operations)
|
|
204
|
+
expanded = expand_inputs(inputs)
|
|
205
|
+
return fail_usage("no input files matched") if expanded.empty?
|
|
206
|
+
|
|
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?("{")
|
|
210
|
+
|
|
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
|
|
217
|
+
|
|
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]
|
|
222
|
+
|
|
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])
|
|
226
|
+
announce(output)
|
|
227
|
+
end
|
|
228
|
+
|
|
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
|
|
242
|
+
|
|
243
|
+
FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
|
|
244
|
+
images = paths.map { |path| ImageIO.read(path) }
|
|
245
|
+
case name
|
|
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]) }
|
|
251
|
+
when "spritesheet"
|
|
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))
|
|
255
|
+
when "animate"
|
|
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))
|
|
261
|
+
end
|
|
262
|
+
announce(output)
|
|
263
|
+
end
|
|
264
|
+
|
|
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:))
|
|
272
|
+
end
|
|
273
|
+
result
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def expand_inputs(inputs)
|
|
278
|
+
inputs.flat_map do |input|
|
|
279
|
+
input.match?(/[?*{\[]/) ? Dir.glob(input) : input
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def spritesheet_manifest_path(output)
|
|
284
|
+
File.extname(output).empty? ? "#{output}.json" : output.sub(%r{\.[^./]+\z}, ".json")
|
|
285
|
+
end
|
|
286
|
+
|
|
132
287
|
def info_command
|
|
133
288
|
@args.shift
|
|
134
289
|
return fail_usage("info expects one input file") unless @args.one?
|
|
135
290
|
|
|
136
291
|
path = @args.first
|
|
137
292
|
announce(JSON.pretty_generate({ path:, **Operations.info(ImageIO.read(path)) }))
|
|
138
|
-
0
|
|
139
|
-
rescue StandardError => e
|
|
140
|
-
@err.puts("retouch: #{e.message}")
|
|
141
|
-
1
|
|
142
293
|
end
|
|
143
294
|
|
|
144
295
|
def fail_usage(message)
|
|
@@ -156,19 +307,18 @@ module Retouch
|
|
|
156
307
|
if operation
|
|
157
308
|
raise ArgumentError, "unknown operation help: #{operation}" unless OPERATIONS.include?(operation)
|
|
158
309
|
|
|
159
|
-
@out.puts("retouch
|
|
310
|
+
@out.puts("retouch INPUTS #{operation} #{OPERATION_USAGE.fetch(operation, "")} -o OUTPUT")
|
|
160
311
|
return 0
|
|
161
312
|
end
|
|
162
313
|
@out.puts <<~HELP
|
|
163
|
-
Retouch
|
|
314
|
+
Retouch edits PNG, PPM, and BMP images with a Ruby API and CLI.
|
|
164
315
|
|
|
165
|
-
Usage: retouch INPUT OPERATION [ARGUMENTS] -o OUTPUT
|
|
166
|
-
retouch help OPERATION
|
|
316
|
+
Usage: retouch INPUT [INPUT ...] OPERATION [ARGUMENTS] -o OUTPUT
|
|
167
317
|
retouch info INPUT
|
|
318
|
+
retouch help OPERATION
|
|
168
319
|
|
|
169
|
-
Operations:
|
|
170
|
-
|
|
171
|
-
--strip --level 0..9 -h, --help
|
|
320
|
+
Operations: #{OPERATIONS.join(" ")}
|
|
321
|
+
Global: -o PATH --force --dry-run --verbose --quiet --strip --level 0..9 --jobs N --loop --no-loop
|
|
172
322
|
HELP
|
|
173
323
|
0
|
|
174
324
|
end
|
data/lib/retouch/io.rb
CHANGED
|
@@ -12,7 +12,11 @@ module Retouch
|
|
|
12
12
|
"PPM"
|
|
13
13
|
elsif signature.start_with?("BM")
|
|
14
14
|
"BMP"
|
|
15
|
+
elsif signature.start_with?("GIF87a", "GIF89a")
|
|
16
|
+
"GIF"
|
|
15
17
|
end
|
|
18
|
+
return with_format(Operations.open_gif(path, frame: 0), format) if format == "GIF"
|
|
19
|
+
|
|
16
20
|
with_format(Tessel.read(path), format || "unknown")
|
|
17
21
|
rescue Tessel::UnsupportedError => e
|
|
18
22
|
raise Error, "unsupported image format for #{path}: #{e.message}", cause: e
|
|
@@ -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
|