image_util 0.2.0 → 0.3.0

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +3 -0
  3. data/CHANGELOG.md +31 -0
  4. data/README.md +125 -24
  5. data/Rakefile +5 -0
  6. data/docs/cli.md +5 -0
  7. data/docs/samples/background.png +0 -0
  8. data/docs/samples/bitmap_text.png +0 -0
  9. data/docs/samples/colors.png +0 -0
  10. data/docs/samples/constructor.png +0 -0
  11. data/docs/samples/dither.png +0 -0
  12. data/docs/samples/iterator.png +0 -0
  13. data/docs/samples/pdither.png +0 -0
  14. data/docs/samples/pipe.png +0 -0
  15. data/docs/samples/range.png +0 -0
  16. data/docs/samples/redimension.png +0 -0
  17. data/docs/samples/resize.png +0 -0
  18. data/docs/samples/transform.png +0 -0
  19. data/exe/image_util +7 -0
  20. data/lib/image_util/benchmarking.rb +25 -0
  21. data/lib/image_util/bitmap_font/fonts/smfont/charset.txt +1 -0
  22. data/lib/image_util/bitmap_font/fonts/smfont/font.png +0 -0
  23. data/lib/image_util/bitmap_font.rb +72 -0
  24. data/lib/image_util/cli.rb +54 -0
  25. data/lib/image_util/codec/chunky_png.rb +67 -0
  26. data/lib/image_util/codec/image_magick.rb +76 -18
  27. data/lib/image_util/codec/kitty.rb +81 -0
  28. data/lib/image_util/codec/libpng.rb +2 -10
  29. data/lib/image_util/codec/libsixel.rb +14 -14
  30. data/lib/image_util/codec/libturbojpeg.rb +1 -11
  31. data/lib/image_util/codec/pam.rb +24 -22
  32. data/lib/image_util/codec/ruby_sixel.rb +11 -12
  33. data/lib/image_util/codec.rb +5 -1
  34. data/lib/image_util/color/css_colors.rb +3 -1
  35. data/lib/image_util/color.rb +62 -9
  36. data/lib/image_util/extension.rb +24 -0
  37. data/lib/image_util/filter/_mixin.rb +9 -0
  38. data/lib/image_util/filter/background.rb +4 -4
  39. data/lib/image_util/filter/bitmap_text.rb +17 -0
  40. data/lib/image_util/filter/colors.rb +21 -0
  41. data/lib/image_util/filter/draw.rb +2 -11
  42. data/lib/image_util/filter/palette.rb +197 -0
  43. data/lib/image_util/filter/paste.rb +1 -1
  44. data/lib/image_util/filter/redimension.rb +83 -0
  45. data/lib/image_util/filter/resize.rb +1 -1
  46. data/lib/image_util/filter/transform.rb +48 -0
  47. data/lib/image_util/filter.rb +5 -1
  48. data/lib/image_util/generator/bitmap_text.rb +38 -0
  49. data/lib/image_util/generator/example/rose.png +0 -0
  50. data/lib/image_util/generator/example.rb +9 -0
  51. data/lib/image_util/generator.rb +8 -0
  52. data/lib/image_util/image/buffer.rb +11 -11
  53. data/lib/image_util/image.rb +49 -23
  54. data/lib/image_util/magic.rb +8 -6
  55. data/lib/image_util/statistic/{color.rb → colors.rb} +2 -2
  56. data/lib/image_util/statistic.rb +1 -1
  57. data/lib/image_util/terminal.rb +61 -0
  58. data/lib/image_util/version.rb +1 -1
  59. data/lib/image_util/view/interpolated.rb +1 -1
  60. data/lib/image_util.rb +6 -0
  61. metadata +75 -4
  62. data/lib/image_util/filter/dither.rb +0 -96
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Filter
5
+ module BitmapText
6
+ extend ImageUtil::Filter::Mixin
7
+
8
+ def bitmap_text!(text, *location, **kwargs)
9
+ loc = location.dup
10
+ loc += [0] * (dimensions.length - loc.length)
11
+ paste!(Image.bitmap_text(text, **kwargs), *loc, respect_alpha: true)
12
+ end
13
+
14
+ define_immutable_version :bitmap_text
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Filter
5
+ module Colors
6
+ extend ImageUtil::Filter::Mixin
7
+
8
+ def color_multiply!(color)
9
+ col = Color.from(color)
10
+ set_each_pixel_by_location! do |loc|
11
+ self[*loc] * col
12
+ end
13
+ self
14
+ end
15
+
16
+ define_immutable_version :color_multiply
17
+
18
+ alias * color_multiply
19
+ end
20
+ end
21
+ end
@@ -16,12 +16,12 @@ module ImageUtil
16
16
  )
17
17
  fp = self.view(view)
18
18
 
19
- axis = axis_to_number(axis)
19
+ axis = Filter::Mixin.axis_to_number(axis)
20
20
  draw_axis ||= case axis
21
21
  when 0 then 1
22
22
  when 1 then 0
23
23
  end
24
- draw_axis = axis_to_number(draw_axis)
24
+ draw_axis = Filter::Mixin.axis_to_number(draw_axis)
25
25
 
26
26
  limit ||= (0..)
27
27
  limit = Range.new(limit.begin, dimensions[axis]-1, false) if limit.end == nil
@@ -85,15 +85,6 @@ module ImageUtil
85
85
  end
86
86
 
87
87
  define_immutable_version :draw_function, :draw_line, :draw_circle
88
-
89
- private
90
-
91
- def axis_to_number(axis)
92
- axis = 0 if axis == :x
93
- axis = 1 if axis == :y
94
- axis = 2 if axis == :z
95
- axis
96
- end
97
88
  end
98
89
  end
99
90
  end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Filter
5
+ module Palette
6
+ extend ImageUtil::Filter::Mixin
7
+
8
+ # A more descriptive name for this structure would be
9
+ # N-tree, where N = 2**color_components. Let's pretend
10
+ # we are dealing with 3-color component trees though,
11
+ # so Octree it is.
12
+ class ColorOctree < Array
13
+ def dig(nexthop, *rest)
14
+ self[nexthop] ||= ColorOctree.new(length)
15
+ super
16
+ end
17
+
18
+ def number_bits(number)
19
+ array = Array.new(8)
20
+ i = 0
21
+ number = number.to_i
22
+ while i < 8
23
+ array[7 - i] = ((number >> i) & 1)
24
+ i += 1
25
+ end
26
+ array
27
+ end
28
+
29
+ def generate_key(component_bits)
30
+ number = 0
31
+ component_bits.reverse_each do |i|
32
+ number <<= 1
33
+ number |= i
34
+ end
35
+ number
36
+ end
37
+
38
+ def key_array(color)
39
+ color.map do |component|
40
+ number_bits(component)
41
+ end.transpose.map do |i|
42
+ generate_key(i)
43
+ end
44
+ end
45
+
46
+ # rubocop:disable Style/Semicolon
47
+
48
+ # Optimized path for (r,g,b) colors.
49
+ def key_array3(color)
50
+ i = 0
51
+ r = color[0].to_i; g = color[1].to_i; b = color[2].to_i
52
+ array = Array.new(8)
53
+ while i < 8
54
+ array[7 - i] = (r & 1) | (g & 1) << 1 | (b & 1) << 2
55
+ r >>= 1; g >>= 1; b >>= 1
56
+ i += 1
57
+ end
58
+ array
59
+ end
60
+
61
+ # Optimized path for (r,g,b,a) colors.
62
+ def key_array4(color)
63
+ i = 0
64
+ r = color[0].to_i; g = color[1].to_i; b = color[2].to_i; a = color[3].to_i
65
+ array = Array.new(8)
66
+ while i < 8
67
+ array[7 - i] = (r & 1) | (g & 1) << 1 | (b & 1) << 2 | (a & 1) << 3
68
+ r >>= 1; g >>= 1; b >>= 1; a >>= 1
69
+ i += 1
70
+ end
71
+ array
72
+ end
73
+
74
+ # rubocop:enable Style/Semicolon
75
+
76
+ def build_from(colors)
77
+ colors.each do |color|
78
+ color_path = case color.length
79
+ when 3
80
+ key_array3(color)
81
+ when 4
82
+ key_array4(color)
83
+ else
84
+ key_array(color)
85
+ end
86
+
87
+ dig(*color_path[0..-2])[color_path[-1]] = color
88
+ end
89
+
90
+ self
91
+ end
92
+
93
+ # rubocop:disable Style/StringConcatenation
94
+ def inspect
95
+ "#<Octree:[" +
96
+ filter_map.with_index do |i,idx|
97
+ "0b#{idx.to_s(2)} => #{i.inspect}," if i
98
+ end.join(", ") +
99
+ "]>"
100
+ end
101
+ # rubocop:enable Style/StringConcatenation
102
+
103
+ def available_with_index
104
+ each_with_index.select(&:first)
105
+ end
106
+
107
+ def pretty_print(pp)
108
+ pp.group(1, "#<Octree:", ">") do
109
+ pp.breakable ""
110
+ pp.group(1, "[", "]") do
111
+ pp.seplist(available_with_index) do |i,idx|
112
+ pp.text "0b#{idx.to_s(2)}"
113
+ pp.text " => "
114
+ pp.pp i
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ def empty?
121
+ compact.empty?
122
+ end
123
+
124
+ def take(n)
125
+ taken = []
126
+ length.times do |idx|
127
+ next unless (i = self[idx])
128
+
129
+ taken << i
130
+ self[idx] = nil
131
+
132
+ if taken.length >= n - 1 && !empty?
133
+ taken << self
134
+ break
135
+ elsif taken.length >= n
136
+ break
137
+ end
138
+ end
139
+
140
+ raise if taken.length > n
141
+
142
+ taken
143
+ end
144
+
145
+ def colors
146
+ select { |i| i.is_a? Color } +
147
+ select { |i| i.is_a? ColorOctree }.map { |i| i.colors }.flatten(1)
148
+ end
149
+ end
150
+
151
+ def palette_reduce!(count)
152
+ colors = unique_colors
153
+
154
+ return self if colors.length <= count
155
+
156
+ octree = ColorOctree.new
157
+ octree.build_from(colors)
158
+
159
+ queue = [octree]
160
+ while queue.length < count
161
+ elem = queue.shift
162
+ case elem
163
+ when Color
164
+ queue << elem
165
+ when ColorOctree
166
+ needed = count - queue.length
167
+ got = elem.take(needed)
168
+ queue += got
169
+ end
170
+ end
171
+
172
+ equiv = {}
173
+
174
+ queue.each do |i|
175
+ case i
176
+ when Color
177
+ equiv[i] = i
178
+ when ColorOctree
179
+ colors = i.colors
180
+ picked = colors[colors.length / 2]
181
+ colors.each do |color|
182
+ equiv[color] = picked
183
+ end
184
+ end
185
+ end
186
+
187
+ set_each_pixel_by_location! do |loc|
188
+ equiv[self[*loc]]
189
+ end
190
+
191
+ self
192
+ end
193
+
194
+ define_immutable_version :palette_reduce
195
+ end
196
+ end
197
+ end
@@ -11,7 +11,7 @@ module ImageUtil
11
11
  if !respect_alpha &&
12
12
  image.dimensions.length == 1 &&
13
13
  image.color_bits == color_bits &&
14
- image.color_length == color_length &&
14
+ image.channels == channels &&
15
15
  buffer.respond_to?(:copy_1d)
16
16
  loc = location.map(&:to_i)
17
17
  begin
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Filter
5
+ module Redimension
6
+ extend ImageUtil::Filter::Mixin
7
+
8
+ def redimension!(*new_dimensions)
9
+ if fast_redimension?(new_dimensions)
10
+ begin
11
+ resize_buffer!(new_dimensions)
12
+ return self
13
+ rescue StandardError
14
+ # fall back to generic implementation
15
+ end
16
+ end
17
+
18
+ out = Image.new(*new_dimensions, color_bits: color_bits, channels: channels)
19
+
20
+ copy_counts = new_dimensions.map.with_index do |dim, idx|
21
+ [dim, dimensions[idx] || 1].min
22
+ end
23
+
24
+ ranges = copy_counts[1..] || []
25
+ each_coordinates(ranges) do |coords|
26
+ src_buf = row_buffer(coords)
27
+ out.buffer.copy_1d(src_buf, 0, *coords)
28
+ end
29
+
30
+ initialize_from_buffer(out.buffer)
31
+ self
32
+ end
33
+
34
+ define_immutable_version :redimension
35
+
36
+ private
37
+
38
+ def fast_redimension?(new_dimensions)
39
+ io = buffer.io_buffer
40
+ return false unless io.respond_to?(:resize)
41
+ return false if io.external? || io.locked?
42
+
43
+ dims = dimensions
44
+
45
+ min_len = [dims.length, new_dimensions.length].min
46
+ idx = 0
47
+ idx += 1 while idx < min_len && dims[idx] == new_dimensions[idx]
48
+
49
+ return false if idx == dims.length && idx == new_dimensions.length
50
+ return false unless (dims[(idx + 1)..] || []).all? { |d| d == 1 }
51
+ return false unless (dims[new_dimensions.length..] || []).all? { |d| d == 1 }
52
+
53
+ true
54
+ end
55
+
56
+ def resize_buffer!(new_dimensions)
57
+ new_size = new_dimensions.reduce(1, :*)
58
+ new_size *= channels
59
+ new_size *= color_bits / 8
60
+ buffer.io_buffer.resize(new_size)
61
+ initialize_from_buffer(Image::Buffer.new(new_dimensions, color_bits, channels, buffer.io_buffer))
62
+ end
63
+
64
+ def each_coordinates(ranges, prefix = [], &block)
65
+ if ranges.empty?
66
+ yield prefix
67
+ else
68
+ ranges.first.times do |i|
69
+ each_coordinates(ranges[1..], prefix + [i], &block)
70
+ end
71
+ end
72
+ end
73
+
74
+ def row_buffer(coords)
75
+ buf = buffer
76
+ coords_src = coords[0, dimensions.length - 1]
77
+ coords_src += [0] * (dimensions.length - 1 - coords_src.length)
78
+ coords_src.reverse_each { |c| buf = buf.last_dimension(c) }
79
+ buf
80
+ end
81
+ end
82
+ end
83
+ end
@@ -10,7 +10,7 @@ module ImageUtil
10
10
  new_dim == 1 ? 0.0 : (old_dim - 1).to_f / (new_dim - 1)
11
11
  end
12
12
 
13
- Image.new(*new_dimensions, color_bits: color_bits, color_length: color_length) do |loc|
13
+ Image.new(*new_dimensions, color_bits: color_bits, channels: channels) do |loc|
14
14
  src_loc = loc.zip(factors).map { |coord, factor| coord * factor }
15
15
  src[*src_loc]
16
16
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Filter
5
+ module Transform
6
+ extend ImageUtil::Filter::Mixin
7
+
8
+ def flip!(axis = :x)
9
+ axis = Filter::Mixin.axis_to_number(axis)
10
+ dims = dimensions
11
+ out = Image.new(*dims, color_bits: color_bits, channels: channels)
12
+ each_pixel_location do |loc|
13
+ new_loc = loc.dup
14
+ new_loc[axis] = dims[axis] - 1 - loc[axis]
15
+ out[*new_loc] = self[*loc]
16
+ end
17
+ initialize_from_buffer(out.buffer)
18
+ self
19
+ end
20
+
21
+ def rotate!(angle, axes: %i[x y])
22
+ axes = axes.map { |a| Filter::Mixin.axis_to_number(a) }
23
+ turns = (angle.to_f / 90).round % 4
24
+ turns += 4 if turns.negative?
25
+ turns.times { rotate90_once!(*axes) }
26
+ self
27
+ end
28
+
29
+ define_immutable_version :flip, :rotate
30
+
31
+ private
32
+
33
+ def rotate90_once!(axis1 = 0, axis2 = 1)
34
+ dims = dimensions
35
+ new_dims = dims.dup
36
+ new_dims[axis1], new_dims[axis2] = dims[axis2], dims[axis1] # rubocop:disable Style/ParallelAssignment
37
+ out = Image.new(*new_dims, color_bits: color_bits, channels: channels)
38
+ each_pixel_location do |loc|
39
+ new_loc = loc.dup
40
+ new_loc[axis1] = dims[axis2] - 1 - loc[axis2]
41
+ new_loc[axis2] = loc[axis1]
42
+ out[*new_loc] = self[*loc]
43
+ end
44
+ initialize_from_buffer(out.buffer)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -2,11 +2,15 @@
2
2
 
3
3
  module ImageUtil
4
4
  module Filter
5
- autoload :Dither, "image_util/filter/dither"
5
+ autoload :Palette, "image_util/filter/palette"
6
6
  autoload :Background, "image_util/filter/background"
7
7
  autoload :Paste, "image_util/filter/paste"
8
8
  autoload :Draw, "image_util/filter/draw"
9
9
  autoload :Resize, "image_util/filter/resize"
10
+ autoload :Transform, "image_util/filter/transform"
11
+ autoload :Redimension, "image_util/filter/redimension"
12
+ autoload :Colors, "image_util/filter/colors"
13
+ autoload :BitmapText, "image_util/filter/bitmap_text"
10
14
 
11
15
  autoload :Mixin, "image_util/filter/_mixin"
12
16
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Generator
5
+ module BitmapText
6
+ def bitmap_text(text, font: BitmapFont.default_font, color: nil, align: :left)
7
+ fnt = BitmapFont.cached_load(font)
8
+ lines = text.split("\n")
9
+
10
+ rendered = lines.map { |line| fnt.render_line_of_text(line) }
11
+
12
+ width = rendered.map(&:width).max || 0
13
+ height = rendered.map(&:height).first.to_i * rendered.length
14
+ height += rendered.length - 1 if rendered.length > 1
15
+
16
+ out = Image.new(width, height)
17
+ y = 0
18
+ rendered.each do |img|
19
+ x = case align
20
+ when :left
21
+ 0
22
+ when :center
23
+ (width - img.width) / 2
24
+ when :right
25
+ width - img.width
26
+ else
27
+ raise ArgumentError, "invalid alignment #{align.inspect}"
28
+ end
29
+ out.paste!(img, x, y)
30
+ y += img.height + 1
31
+ end
32
+
33
+ out *= color if color
34
+ out
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Generator
5
+ module Example
6
+ def example_rose = Image.from_file("#{__dir__}/example/rose.png", :png)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Generator
5
+ autoload :BitmapText, "image_util/generator/bitmap_text"
6
+ autoload :Example, "image_util/generator/example"
7
+ end
8
+ end
@@ -6,7 +6,7 @@ Warning[:experimental] = false
6
6
  module ImageUtil
7
7
  class Image
8
8
  class Buffer
9
- def initialize(dimensions, color_bits, color_length, buffer = nil)
9
+ def initialize(dimensions, color_bits, channels, buffer = nil)
10
10
  @color_type = case color_bits
11
11
  when 8
12
12
  :U8
@@ -21,13 +21,15 @@ module ImageUtil
21
21
  @dimensions = dimensions.freeze
22
22
  @color_bits = color_bits
23
23
  @color_bytes = color_bits / 8
24
- @color_length = color_length
24
+ @channels = channels
25
25
 
26
26
  @buffer_size = @dimensions.reduce(&:*)
27
- @buffer_size *= @color_length
27
+ @buffer_size *= @channels
28
28
  @buffer_size *= @color_bytes
29
29
 
30
- @io_buffer_types = ([@color_type]*@color_length).freeze
30
+ @pixel_bytes = @channels * @color_bytes
31
+
32
+ @io_buffer_types = ([@color_type]*@channels).freeze
31
33
 
32
34
  @buffer = buffer || IO::Buffer.new(@buffer_size)
33
35
 
@@ -36,7 +38,7 @@ module ImageUtil
36
38
  freeze
37
39
  end
38
40
 
39
- attr_reader :dimensions, :color_bits, :color_bytes, :color_length
41
+ attr_reader :dimensions, :color_bits, :color_bytes, :channels, :pixel_bytes
40
42
 
41
43
  def offset_of(*location)
42
44
  location.length == @dimensions.length or raise ArgumentError, "wrong number of dimensions"
@@ -50,8 +52,6 @@ module ImageUtil
50
52
  offset * pixel_bytes
51
53
  end
52
54
 
53
- def pixel_bytes = @color_length * @color_bytes
54
-
55
55
  def initialize_copy(_other)
56
56
  @buffer = @buffer.dup
57
57
  end
@@ -62,7 +62,7 @@ module ImageUtil
62
62
 
63
63
  def get_index(index)
64
64
  value = @buffer.get_values(@io_buffer_types, index)
65
- Color.from_buffer(value, @color_bits)
65
+ Color.from_buffer(value, @color_bits).freeze
66
66
  end
67
67
 
68
68
  def set(location, value)
@@ -70,7 +70,7 @@ module ImageUtil
70
70
  end
71
71
 
72
72
  def set_index(index, value)
73
- value = Color.from(value).to_buffer(@color_bits, @color_length)
73
+ value = Color.from_any_to_buffer(value, @color_bits, @channels)
74
74
  @buffer.set_values(@io_buffer_types, index, value)
75
75
  end
76
76
 
@@ -82,7 +82,7 @@ module ImageUtil
82
82
  Buffer.new(
83
83
  dimensions_without_last,
84
84
  @color_bits,
85
- @color_length,
85
+ @channels,
86
86
  @buffer.slice(o0, o1 - o0)
87
87
  )
88
88
  end
@@ -126,7 +126,7 @@ module ImageUtil
126
126
  }.freeze
127
127
 
128
128
  OPT_GET_INDEX = {
129
- 8 => ->(index) { Color.new(*@buffer.get_values(@io_buffer_types, index)) }
129
+ 8 => ->(index) { Color.new(*@buffer.get_values(@io_buffer_types, index)).freeze }
130
130
  }.freeze
131
131
  end
132
132
  end