image_util 0.1.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.
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Filter
5
+ module Dither
6
+ extend ImageUtil::Filter::Mixin
7
+
8
+ private
9
+
10
+ def dither_distance_sq(c1, c2)
11
+ len = [c1.length, c2.length].max
12
+
13
+ case len
14
+ when 1
15
+ d = (c1[0] || 255) - (c2[0] || 255)
16
+ d * d
17
+ when 2
18
+ d0 = (c1[0] || 255) - (c2[0] || 255)
19
+ d1 = (c1[1] || 255) - (c2[1] || 255)
20
+ d0 * d0 + d1 * d1
21
+ when 3
22
+ d0 = (c1[0] || 255) - (c2[0] || 255)
23
+ d1 = (c1[1] || 255) - (c2[1] || 255)
24
+ d2 = (c1[2] || 255) - (c2[2] || 255)
25
+ d0 * d0 + d1 * d1 + d2 * d2
26
+ when 4
27
+ d0 = (c1[0] || 255) - (c2[0] || 255)
28
+ d1 = (c1[1] || 255) - (c2[1] || 255)
29
+ d2 = (c1[2] || 255) - (c2[2] || 255)
30
+ d3 = (c1[3] || 255) - (c2[3] || 255)
31
+ d0 * d0 + d1 * d1 + d2 * d2 + d3 * d3
32
+ else
33
+ sum = 0
34
+ len.times do |i|
35
+ d = (c1[i] || 255) - (c2[i] || 255)
36
+ sum += d * d
37
+ end
38
+ sum
39
+ end
40
+ end
41
+
42
+ public
43
+
44
+ def dither!(count)
45
+ palette = histogram.sort_by { |_, v| -v - rand }.first(count).map(&:first)
46
+
47
+ cache = {}
48
+
49
+ nearest = lambda do |color|
50
+ key = (color[0] || 255) |
51
+ ((color[1] || 255) << 8) |
52
+ ((color[2] || 255) << 16) |
53
+ ((color[3] || 255) << 24)
54
+ cache[key] ||= begin
55
+ best = palette.first
56
+ best_dist = dither_distance_sq(color, best)
57
+ idx = 1
58
+ while idx < palette.length
59
+ c = palette[idx]
60
+ dist = dither_distance_sq(color, c)
61
+ if dist < best_dist
62
+ best = c
63
+ best_dist = dist
64
+ end
65
+ idx += 1
66
+ end
67
+ best
68
+ end
69
+ end
70
+
71
+ if dimensions.length == 2
72
+ w = width
73
+ h = height
74
+ buf = buffer
75
+ idx = 0
76
+ step = buf.pixel_bytes
77
+ h.times do
78
+ w.times do
79
+ color = buf.get_index(idx)
80
+ buf.set_index(idx, nearest.call(color))
81
+ idx += step
82
+ end
83
+ end
84
+ else
85
+ set_each_pixel_by_location do |loc|
86
+ color = self[*loc]
87
+ nearest.call(color)
88
+ end
89
+ end
90
+ self
91
+ end
92
+
93
+ define_immutable_version :dither
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Filter
5
+ module Draw
6
+ extend ImageUtil::Filter::Mixin
7
+
8
+ # rubocop:disable Metrics/ParameterLists
9
+ def draw_function!(
10
+ color = Color[:black],
11
+ limit = nil,
12
+ axis:,
13
+ draw_axis: nil,
14
+ view: View::Interpolated,
15
+ plane: [0,0]
16
+ )
17
+ fp = self.view(view)
18
+
19
+ axis = axis_to_number(axis)
20
+ draw_axis ||= case axis
21
+ when 0 then 1
22
+ when 1 then 0
23
+ end
24
+ draw_axis = axis_to_number(draw_axis)
25
+
26
+ limit ||= (0..)
27
+ limit = Range.new(limit.begin, dimensions[axis]-1, false) if limit.end == nil
28
+
29
+ limit.each do |axispoint|
30
+ draw = yield(axispoint)
31
+
32
+ loc = plane.dup
33
+ loc[axis] = axispoint
34
+ loc[draw_axis] = draw
35
+
36
+ fp[*loc] = color
37
+ end
38
+
39
+ self
40
+ end
41
+ # rubocop:enable Metrics/ParameterLists
42
+
43
+ def draw_line!(begin_loc, end_loc, color = Color[:black], view: View::Interpolated)
44
+ begin_x, begin_y = begin_loc
45
+ end_x, end_y = end_loc
46
+
47
+ dist_x = (end_x - begin_x).abs
48
+ dist_y = (end_y - begin_y).abs
49
+
50
+ if dist_x < dist_y
51
+ begin_x, end_x, begin_y, end_y = end_x, begin_x, end_y, begin_y if begin_y > end_y
52
+ a = (end_x - begin_x).to_f / (end_y - begin_y)
53
+ draw_function!(color, begin_y..end_y, axis: :y, view: view) do |y|
54
+ begin_x + (y - begin_y) * a
55
+ end
56
+ else
57
+ begin_x, end_x, begin_y, end_y = end_x, begin_x, end_y, begin_y if begin_x > end_x
58
+ a = (end_y - begin_y).to_f / (end_x - begin_x)
59
+ draw_function!(color, begin_x..end_x, axis: :x, view: view) do |x|
60
+ begin_y + (x - begin_x) * a
61
+ end
62
+ end
63
+ end
64
+
65
+ define_immutable_version :draw_function, :draw_line
66
+
67
+ private
68
+
69
+ def axis_to_number(axis)
70
+ axis = 0 if axis == :x
71
+ axis = 1 if axis == :y
72
+ axis = 2 if axis == :z
73
+ axis
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Filter
5
+ module Paste
6
+ extend ImageUtil::Filter::Mixin
7
+
8
+ def paste!(image, *location, respect_alpha: false)
9
+ raise TypeError, "image must be an Image" unless image.is_a?(Image)
10
+
11
+ if !respect_alpha &&
12
+ image.dimensions.length == 1 &&
13
+ image.color_bits == color_bits &&
14
+ image.color_length == color_length &&
15
+ buffer.respond_to?(:copy_1d)
16
+ loc = location.map(&:to_i)
17
+ begin
18
+ check_bounds!(loc)
19
+ rescue IndexError
20
+ return self
21
+ end
22
+
23
+ if loc.first + image.length <= width
24
+ buffer.copy_1d(image.buffer, *loc)
25
+ return self
26
+ end
27
+ end
28
+
29
+ last_dim = image.dimensions.length - 1
30
+
31
+ image.each_with_index do |val, idx|
32
+ new_loc = location.dup
33
+ new_loc[last_dim] += idx
34
+
35
+ begin
36
+ if val.is_a?(Image)
37
+ paste!(val, *new_loc, respect_alpha: respect_alpha)
38
+ elsif respect_alpha
39
+ self[*new_loc] += val
40
+ else
41
+ self[*new_loc] = val
42
+ end
43
+ rescue IndexError
44
+ # do nothing, image overlaps
45
+ end
46
+ end
47
+
48
+ self
49
+ end
50
+
51
+ define_immutable_version :paste
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Filter
5
+ module Resize
6
+ def resize(*new_dimensions, view: View::Interpolated)
7
+ src = self.view(view)
8
+
9
+ factors = new_dimensions.zip(dimensions).map do |new_dim, old_dim|
10
+ new_dim == 1 ? 0.0 : (old_dim - 1).to_f / (new_dim - 1)
11
+ end
12
+
13
+ Image.new(*new_dimensions, color_bits: color_bits, color_length: color_length) do |loc|
14
+ src_loc = loc.zip(factors).map { |coord, factor| coord * factor }
15
+ src[*src_loc]
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Filter
5
+ autoload :Dither, "image_util/filter/dither"
6
+ autoload :Background, "image_util/filter/background"
7
+ autoload :Paste, "image_util/filter/paste"
8
+ autoload :Draw, "image_util/filter/draw"
9
+ autoload :Resize, "image_util/filter/resize"
10
+
11
+ autoload :Mixin, "image_util/filter/_mixin"
12
+ end
13
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Silence a warning.
4
+ Warning[:experimental] = false
5
+
6
+ module ImageUtil
7
+ class Image
8
+ class Buffer
9
+ def initialize(dimensions, color_bits, color_length, buffer = nil)
10
+ @color_type = case color_bits
11
+ when 8
12
+ :U8
13
+ when 16
14
+ :u16
15
+ when 32
16
+ :u32
17
+ else
18
+ raise ArgumentError, "wrong color bits provided: #{color_bits.inspect}"
19
+ end
20
+
21
+ @dimensions = dimensions.freeze
22
+ @color_bits = color_bits
23
+ @color_bytes = color_bits / 8
24
+ @color_length = color_length
25
+
26
+ @buffer_size = @dimensions.reduce(&:*)
27
+ @buffer_size *= @color_length
28
+ @buffer_size *= @color_bytes
29
+
30
+ @io_buffer_types = ([@color_type]*@color_length).freeze
31
+
32
+ @buffer = buffer || IO::Buffer.new(@buffer_size)
33
+
34
+ apply_singleton_optimizations!
35
+
36
+ freeze
37
+ end
38
+
39
+ attr_reader :dimensions, :color_bits, :color_bytes, :color_length
40
+
41
+ def offset_of(*location)
42
+ location.length == @dimensions.length or raise ArgumentError, "wrong number of dimensions"
43
+
44
+ offset = 0
45
+ location.reverse.zip(@dimensions.reverse) do |i,max|
46
+ offset *= max
47
+ offset += i
48
+ end
49
+
50
+ offset * pixel_bytes
51
+ end
52
+
53
+ def pixel_bytes = @color_length * @color_bytes
54
+
55
+ def initialize_copy(_other)
56
+ @buffer = @buffer.dup
57
+ end
58
+
59
+ def get(location)
60
+ get_index(offset_of(*location))
61
+ end
62
+
63
+ def get_index(index)
64
+ value = @buffer.get_values(@io_buffer_types, index)
65
+ Color.from_buffer(value, @color_bits)
66
+ end
67
+
68
+ def set(location, value)
69
+ set_index(offset_of(*location), value)
70
+ end
71
+
72
+ def set_index(index, value)
73
+ value = Color.from(value).to_buffer(@color_bits, @color_length)
74
+ @buffer.set_values(@io_buffer_types, index, value)
75
+ end
76
+
77
+ def last_dimension(i)
78
+ dimensions_without_last = dimensions[0..-2]
79
+ remaining_dimensions = dimensions_without_last.length
80
+ o0 = offset_of(*[0] * remaining_dimensions, i)
81
+ o1 = offset_of(*[0] * remaining_dimensions, i + 1)
82
+ Buffer.new(
83
+ dimensions_without_last,
84
+ @color_bits,
85
+ @color_length,
86
+ @buffer.slice(o0, o1 - o0)
87
+ )
88
+ end
89
+
90
+ def last_dimension_split
91
+ dimensions.last.times.map do |i|
92
+ last_dimension(i)
93
+ end
94
+ end
95
+
96
+ def get_string = @buffer.get_string
97
+
98
+ def io_buffer = @buffer
99
+
100
+ def copy_1d(other, *location)
101
+ index = offset_of(*location)
102
+ length = [other.width, width - location.first].min
103
+ return if length <= 0
104
+
105
+ @buffer.copy(other.io_buffer, index, length * pixel_bytes)
106
+ end
107
+
108
+ # Optimizations for most common usecases:
109
+ def apply_singleton_optimizations!
110
+ # rubocop:disable Style/GuardClause
111
+ if OPT_OFFSET_OF.key?(@dimensions.length)
112
+ singleton_class.define_method(:offset_of, &OPT_OFFSET_OF[@dimensions.length])
113
+ end
114
+
115
+ if OPT_GET_INDEX.key?(@color_bits)
116
+ singleton_class.define_method(:get_index, &OPT_GET_INDEX[@color_bits])
117
+ end
118
+ # rubocop:enable Style/GuardClause
119
+ end
120
+
121
+ def width = dimensions[0]
122
+
123
+ OPT_OFFSET_OF = {
124
+ 1 => ->(x) { x * pixel_bytes },
125
+ 2 => ->(x,y) { (y * width + x) * pixel_bytes }
126
+ }.freeze
127
+
128
+ OPT_GET_INDEX = {
129
+ 8 => ->(index) { Color.new(*@buffer.get_values(@io_buffer_types, index)) }
130
+ }.freeze
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ class Image
5
+ autoload :Buffer, "image_util/image/buffer"
6
+ autoload :PixelView, "image_util/image/pixel_view"
7
+
8
+ Util.irb_fixup
9
+
10
+ ALL = nil..nil
11
+
12
+ def initialize(*dimensions, color_bits: 8, color_length: 4, &block)
13
+ @buf = Buffer.new(dimensions, color_bits, color_length)
14
+
15
+ set_each_pixel_by_location(&block) if block_given?
16
+ end
17
+
18
+ def initialize_from_buffer(buffer)
19
+ @buf = buffer
20
+ end
21
+
22
+ def initialize_copy(_other)
23
+ @buf = @buf.dup
24
+ end
25
+
26
+ def self.from_buffer(...)
27
+ allocate.tap { |i| i.initialize_from_buffer(...) }
28
+ end
29
+
30
+ def self.from_string(data, format = nil, codec: nil, **kwargs)
31
+ format ||= Codec.detect(data)
32
+ raise ArgumentError, "could not detect format" unless format
33
+
34
+ Codec.decode(format, data, codec: codec, **kwargs)
35
+ end
36
+
37
+ def self.from_file(path_or_io, format = nil, codec: nil, **kwargs)
38
+ if format
39
+ if path_or_io.respond_to?(:read)
40
+ path_or_io.binmode if path_or_io.respond_to?(:binmode)
41
+ Codec.decode_io(format, path_or_io, codec: codec, **kwargs)
42
+ else
43
+ File.open(path_or_io, "rb") do |io|
44
+ Codec.decode_io(format, io, codec: codec, **kwargs)
45
+ end
46
+ end
47
+ elsif path_or_io.respond_to?(:read)
48
+ path_or_io.binmode if path_or_io.respond_to?(:binmode)
49
+ fmt, io = Magic.detect_io(path_or_io)
50
+ raise ArgumentError, "could not detect format" unless fmt
51
+
52
+ Codec.decode_io(fmt, io, codec: codec, **kwargs)
53
+ else
54
+ File.open(path_or_io, "rb") do |io|
55
+ fmt, io = Magic.detect_io(io)
56
+ raise ArgumentError, "could not detect format" unless fmt
57
+
58
+ Codec.decode_io(fmt, io, codec: codec, **kwargs)
59
+ end
60
+ end
61
+ end
62
+
63
+ def buffer = @buf
64
+ def dimensions = @buf.dimensions
65
+ def width = dimensions[0]
66
+ def height = dimensions[1]
67
+ def length = dimensions[2]
68
+ def color_bits = @buf.color_bits
69
+ def color_length = @buf.color_length
70
+ def pixel_bytes = @buf.pixel_bytes
71
+
72
+ def location_expand(location)
73
+ counts = []
74
+
75
+ location = location.reverse.map.with_index do |i,idx|
76
+ if i.is_a?(Range) && i.begin == nil
77
+ i = Range.new(0, i.end, i.exclude_end?)
78
+ end
79
+ if i.is_a?(Range) && i.end == nil
80
+ i = Range.new(i.begin, dimensions[-idx-1]-1, false)
81
+ end
82
+
83
+ i = i.to_i if i.is_a? Float
84
+
85
+ i = Array(i)
86
+
87
+ counts << i.count
88
+
89
+ i
90
+ end
91
+
92
+ [counts.reverse, location.shift.product(*location).map(&:reverse)]
93
+ end
94
+
95
+ def check_bounds!(location)
96
+ location.each_with_index do |i,idx|
97
+ if i < 0 || i >= dimensions[idx]
98
+ raise IndexError, "out of bounds image access (#{location.inspect} exceeds #{dimensions.inspect})"
99
+ end
100
+ end
101
+ end
102
+
103
+ def [](*location)
104
+ if location.all?(Numeric)
105
+ location = location.map(&:to_i)
106
+ check_bounds!(location)
107
+ @buf.get(location)
108
+ else
109
+ new_dimensions, locations = location_expand(location)
110
+ new_image = Image.new(*new_dimensions,
111
+ color_bits: color_bits,
112
+ color_length: color_length)
113
+
114
+ locations.each_with_index do |i, idx|
115
+ new_image.buffer.set_index(idx * @buf.pixel_bytes, @buf.get(i))
116
+ end
117
+
118
+ new_image
119
+ end
120
+ end
121
+
122
+ def full_image_location = [ALL]*dimensions.length
123
+
124
+ def all=(value)
125
+ self[*full_image_location] = value
126
+ end
127
+
128
+ def []=(*location, value)
129
+ if location.all?(Numeric)
130
+ case value
131
+ when Image
132
+ paste!(value, *location)
133
+ else
134
+ location = location.map(&:to_i)
135
+ check_bounds!(location)
136
+ @buf.set(location, value)
137
+ end
138
+ else
139
+ _, locations = location_expand(location)
140
+ locations.each do |loc|
141
+ self[*loc] = value
142
+ end
143
+ end
144
+ end
145
+
146
+ def to_a
147
+ if dimensions.length == 1
148
+ dimensions.first.times.map { |i| self[i] }
149
+ else
150
+ @buf.last_dimension_split.map { |i| Image.from_buffer(i) }
151
+ end
152
+ end
153
+
154
+ def deep_to_a
155
+ to_a.map do |i|
156
+ case i
157
+ when Image
158
+ i.deep_to_a
159
+ else
160
+ i
161
+ end
162
+ end
163
+ end
164
+
165
+ def each(...)
166
+ to_a.each(...)
167
+ end
168
+
169
+ include Enumerable
170
+ include Filter::Dither
171
+ include Filter::Background
172
+ include Filter::Paste
173
+ include Filter::Draw
174
+ include Filter::Resize
175
+ include Statistic::Color
176
+
177
+ def length = dimensions.last
178
+
179
+ def to_pam(fill_to: nil)
180
+ Codec.encode(:pam, self, fill_to: fill_to)
181
+ end
182
+
183
+ def to_string(format, codec: nil, **kwargs)
184
+ Codec.encode(format, self, codec: codec, **kwargs)
185
+ end
186
+
187
+ def to_file(path_or_io, format, codec: nil, **kwargs)
188
+ if path_or_io.respond_to?(:write)
189
+ path_or_io.binmode if path_or_io.respond_to?(:binmode)
190
+ Codec.encode_io(format, self, path_or_io, codec: codec, **kwargs)
191
+ else
192
+ File.open(path_or_io, "wb") do |io|
193
+ Codec.encode_io(format, self, io, codec: codec, **kwargs)
194
+ end
195
+ end
196
+ end
197
+
198
+ def to_sixel
199
+ Codec.encode(:sixel, self)
200
+ end
201
+
202
+ alias inspect to_sixel
203
+
204
+ def pretty_print(p)
205
+ p.flush
206
+ p.output << to_sixel
207
+ p.text("", 0)
208
+ end
209
+
210
+ def pixel_count(locations) = location_expand(locations).first.reduce(:*)
211
+
212
+ def each_pixel_location(locations = full_image_location, ...)
213
+ location_expand(locations).last.each(...)
214
+ end
215
+
216
+ def each_pixel(locations = full_image_location)
217
+ return enum_for(:each_pixel) { pixel_count(locations) } unless block_given?
218
+
219
+ each_pixel_location(locations) do |location|
220
+ yield self[*location]
221
+ end
222
+ end
223
+
224
+ def set_each_pixel_by_location(locations = full_image_location)
225
+ return enum_for(:set_each_pixel_by_location) { pixel_count(locations) } unless block_given?
226
+
227
+ each_pixel_location(locations) do |location|
228
+ value = yield location
229
+ self[*location] = value if value
230
+ end
231
+ end
232
+
233
+ def view(obj)
234
+ if block_given?
235
+ yield obj.new(self)
236
+ self
237
+ else
238
+ obj.new(self)
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module ImageUtil
6
+ module Magic
7
+ MAGIC_NUMBERS = {
8
+ pam: "P7\n".b,
9
+ png: "\x89PNG\r\n\x1a\n".b,
10
+ jpeg: "\xFF\xD8".b
11
+ }.freeze
12
+
13
+ BYTES_NEEDED = MAGIC_NUMBERS.values.map(&:bytesize).max
14
+
15
+ module_function
16
+
17
+ def bytes_needed = BYTES_NEEDED
18
+
19
+ def detect(data)
20
+ return nil unless data
21
+
22
+ MAGIC_NUMBERS.each do |fmt, magic|
23
+ return fmt if data.start_with?(magic)
24
+ crlf_magic = magic.gsub("\n", "\r\n")
25
+ return fmt if crlf_magic != magic && data.start_with?(crlf_magic)
26
+ end
27
+ nil
28
+ end
29
+
30
+ def detect_io(io)
31
+
32
+ pos = io.pos
33
+ data = io.read(BYTES_NEEDED)
34
+ io.seek(pos)
35
+ [detect(data), io]
36
+ rescue Errno::ESPIPE, IOError
37
+ data = io.read(BYTES_NEEDED)
38
+ fmt = detect(data)
39
+ rest = io.read
40
+ new_io = StringIO.new((data || "") + (rest || ""))
41
+ [fmt, new_io]
42
+
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,9 @@
1
+ module ImageUtil
2
+ module Statistic
3
+ module Color
4
+ def histogram = each_pixel.to_a.tally
5
+ def unique_colors = histogram.keys
6
+ def unique_color_count = unique_colors.length
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Statistic
5
+ autoload :Color, "image_util/statistic/color"
6
+ end
7
+ end