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,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Codec
5
+ # rubocop:disable Metrics/ModuleLength
6
+ module Libturbojpeg
7
+ SUPPORTED_FORMATS = [:jpeg].freeze
8
+
9
+ extend Guard
10
+
11
+ begin
12
+ require "ffi"
13
+
14
+ extend FFI::Library
15
+ ffi_lib [
16
+ "libturbojpeg.so.0", # Linux
17
+ "libturbojpeg.so", "libturbojpeg", # generic
18
+ "turbojpeg.dll", "libturbojpeg.dll", # Windows
19
+ "libturbojpeg.dylib", "turbojpeg.dylib" # macOS
20
+ ]
21
+
22
+ AVAILABLE = true
23
+ rescue LoadError
24
+ AVAILABLE = false
25
+ end
26
+
27
+ TJPF_RGB = 0
28
+ TJPF_RGBA = 7
29
+
30
+ if AVAILABLE
31
+ attach_function :tjInitCompress, [], :pointer
32
+ attach_function :tjInitDecompress, [], :pointer
33
+ attach_function :tjCompress2,
34
+ %i[pointer pointer int int int int pointer pointer int int int],
35
+ :int
36
+
37
+ begin
38
+ attach_function :tjDecompressHeader3,
39
+ %i[pointer pointer ulong pointer pointer pointer pointer],
40
+ :int
41
+ DECOMPRESS_HEADER_FUNC = :tjDecompressHeader3
42
+ rescue FFI::NotFoundError
43
+ attach_function :tjDecompressHeader2,
44
+ %i[pointer pointer ulong pointer pointer pointer],
45
+ :int
46
+ DECOMPRESS_HEADER_FUNC = :tjDecompressHeader2
47
+ end
48
+
49
+ attach_function :tjDecompress2,
50
+ %i[pointer pointer ulong pointer int int int int int],
51
+ :int
52
+ attach_function :tjDestroy, [:pointer], :int
53
+ attach_function :tjFree, [:pointer], :void
54
+ end
55
+
56
+ module_function
57
+
58
+ def supported?(format = nil)
59
+ return false unless AVAILABLE
60
+
61
+ return true if format.nil?
62
+
63
+ SUPPORTED_FORMATS.include?(format.to_s.downcase.to_sym)
64
+ end
65
+
66
+ def encode(format, image, quality: 75)
67
+ guard_supported_format!(format, SUPPORTED_FORMATS)
68
+ raise UnsupportedFormatError, "libturbojpeg not available" unless AVAILABLE
69
+
70
+ guard_2d_image!(image)
71
+ guard_8bit_colors!(image)
72
+
73
+ fmt = image.color_length == 4 ? TJPF_RGBA : TJPF_RGB
74
+
75
+ handle = tjInitCompress
76
+ raise StandardError, "tjInitCompress failed" if handle.null?
77
+
78
+ src_ptr = FFI::MemoryPointer.from_string(image.buffer.get_string)
79
+ jpeg_ptr_ptr = FFI::MemoryPointer.new(:pointer)
80
+ jpeg_size_ptr = FFI::MemoryPointer.new(:ulong)
81
+
82
+ res = tjCompress2(handle, src_ptr, image.width, 0, image.height, fmt, jpeg_ptr_ptr, jpeg_size_ptr, 0, quality, 0)
83
+ raise StandardError, "compression failed" if res != 0
84
+
85
+ jpeg_ptr = jpeg_ptr_ptr.read_pointer
86
+ jpeg_size = jpeg_size_ptr.read_ulong
87
+ data = jpeg_ptr.read_string(jpeg_size)
88
+ tjFree(jpeg_ptr)
89
+ data
90
+ ensure
91
+ tjDestroy(handle) if handle && !handle.null?
92
+ end
93
+
94
+ def encode_io(format, image, io, **kwargs)
95
+ io << encode(format, image, **kwargs)
96
+ end
97
+
98
+ def decode(format, data)
99
+ guard_supported_format!(format, SUPPORTED_FORMATS)
100
+ raise UnsupportedFormatError, "libturbojpeg not available" unless AVAILABLE
101
+
102
+ handle = tjInitDecompress
103
+ raise StandardError, "tjInitDecompress failed" if handle.null?
104
+
105
+ jpeg_buf = FFI::MemoryPointer.from_string(data)
106
+ width_ptr = FFI::MemoryPointer.new(:int)
107
+ height_ptr = FFI::MemoryPointer.new(:int)
108
+ subsamp_ptr = FFI::MemoryPointer.new(:int)
109
+ cs_ptr = FFI::MemoryPointer.new(:int)
110
+
111
+ header_args = [handle, jpeg_buf, data.bytesize, width_ptr, height_ptr]
112
+ if DECOMPRESS_HEADER_FUNC == :tjDecompressHeader3
113
+ header_args += [subsamp_ptr, cs_ptr]
114
+ else
115
+ header_args << subsamp_ptr
116
+ end
117
+ res = public_send(DECOMPRESS_HEADER_FUNC, *header_args)
118
+ raise StandardError, "header decode failed" if res != 0
119
+
120
+ width = width_ptr.read_int
121
+ height = height_ptr.read_int
122
+
123
+ dst_ptr = FFI::MemoryPointer.new(:uchar, width * height * 4)
124
+ res = tjDecompress2(handle, jpeg_buf, data.bytesize, dst_ptr, width, 0, height, TJPF_RGBA, 0)
125
+ raise StandardError, "decompress failed" if res != 0
126
+
127
+ raw = dst_ptr.read_string(width * height * 4)
128
+ io_buf = IO::Buffer.for(raw)
129
+ buf = Image::Buffer.new([width, height], 8, 4, io_buf)
130
+ Image.from_buffer(buf)
131
+ ensure
132
+ tjDestroy(handle) if handle && !handle.null?
133
+ end
134
+
135
+ def decode_io(format, io)
136
+ decode(format, io.read)
137
+ end
138
+ end
139
+ # rubocop:enable Metrics/ModuleLength
140
+ end
141
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module ImageUtil
6
+ module Codec
7
+ module Pam
8
+ SUPPORTED_FORMATS = [:pam].freeze
9
+
10
+ extend Guard
11
+
12
+ module_function
13
+
14
+ def supported?(format = nil)
15
+ return true if format.nil?
16
+
17
+ SUPPORTED_FORMATS.include?(format.to_s.downcase.to_sym)
18
+ end
19
+
20
+ def encode(format, image, fill_to: nil)
21
+ guard_supported_format!(format, SUPPORTED_FORMATS)
22
+ unless image.dimensions.length <= 2
23
+ raise ArgumentError, "can't convert to PAM more than 2 dimensions"
24
+ end
25
+
26
+ unless [3, 4].include?(image.color_length)
27
+ raise ArgumentError, "can't convert to PAM if color length isn't 3 or 4"
28
+ end
29
+
30
+ fill_height = image.height || 1
31
+ fill_buffer = "".b
32
+ if fill_to
33
+ remaining = fill_height % fill_to
34
+ added = remaining.zero? ? 0 : fill_to - remaining
35
+ fill_height += added
36
+ fill_buffer = "\0".b * added * image.pixel_bytes * image.width
37
+ end
38
+
39
+ header = <<~PAM.b
40
+ P7
41
+ WIDTH #{image.width}
42
+ HEIGHT #{fill_height}
43
+ DEPTH #{image.color_length}
44
+ MAXVAL #{2**image.color_bits - 1}
45
+ TUPLTYPE #{image.color_length == 3 ? "RGB" : "RGB_ALPHA"}
46
+ ENDHDR
47
+ PAM
48
+
49
+ header + image.buffer.get_string + fill_buffer
50
+ end
51
+
52
+ def encode_io(format, image, io, **kwargs)
53
+ io << encode(format, image, **kwargs)
54
+ end
55
+
56
+ def decode(format, data)
57
+ guard_supported_format!(format, SUPPORTED_FORMATS)
58
+
59
+ decode_io(format, StringIO.new(data))
60
+ end
61
+
62
+ def decode_io(format, io)
63
+ guard_supported_format!(format, SUPPORTED_FORMATS)
64
+
65
+ header = {}
66
+ while (line = io.gets)
67
+ line = line.chomp
68
+ break if line == "ENDHDR"
69
+
70
+ key, val = line.split(" ", 2)
71
+ header[key] = val
72
+ end
73
+ width = header["WIDTH"].to_i
74
+ height = header["HEIGHT"].to_i
75
+ depth = header["DEPTH"].to_i
76
+ maxval = header["MAXVAL"].to_i
77
+ color_bits = Math.log2(maxval + 1).to_i
78
+ raw = io.read
79
+ io_buf = IO::Buffer.for(raw)
80
+ buf = Image::Buffer.new([width, height], color_bits, depth, io_buf)
81
+ Image.from_buffer(buf)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Codec
5
+ module RubySixel
6
+ SUPPORTED_FORMATS = [:sixel].freeze
7
+ CHAR_MAP = (0..63).map { |b| (63 + b).chr }.freeze
8
+
9
+ extend Guard
10
+
11
+ module_function
12
+
13
+ def supported?(format = nil)
14
+ return true if format.nil?
15
+
16
+ SUPPORTED_FORMATS.include?(format.to_s.downcase.to_sym)
17
+ end
18
+
19
+ def encode(format, image)
20
+ guard_supported_format!(format, SUPPORTED_FORMATS)
21
+ guard_2d_image!(image)
22
+ guard_8bit_colors!(image)
23
+
24
+ img = if image.unique_color_count <= 256
25
+ image
26
+ else
27
+ image.dither(256)
28
+ end
29
+
30
+ height = img.height || 1
31
+ width = img.width || 1
32
+
33
+ palette = []
34
+ palette_map = {}
35
+ idx_image = Array.new(height * width)
36
+ buf = img.buffer
37
+ idx = 0
38
+ step = buf.pixel_bytes
39
+
40
+ height.times do |y|
41
+ row = y * width
42
+ width.times do |x|
43
+ color = buf.get_index(idx)
44
+ key = (color[0] || 255) |
45
+ ((color[1] || 255) << 8) |
46
+ ((color[2] || 255) << 16) |
47
+ ((color[3] || 255) << 24)
48
+ pal_idx = palette_map[key]
49
+ unless pal_idx
50
+ pal_idx = palette.length
51
+ palette_map[key] = pal_idx
52
+ palette << color
53
+ end
54
+ idx_image[row + x] = pal_idx
55
+ idx += step
56
+ end
57
+ end
58
+
59
+ out = "\ePq".dup
60
+ palette.each_with_index do |c, idx|
61
+ out << format("#%d;2;%d;%d;%d", idx, c.r * 100 / 255, c.g * 100 / 255, c.b * 100 / 255)
62
+ end
63
+
64
+ (0...height).step(6) do |y|
65
+ palette.each_index do |idx|
66
+ out << "##{idx}"
67
+ run_char = nil
68
+ run_len = 0
69
+ x = 0
70
+ while x < width
71
+ bits = 0
72
+ yy = y
73
+ i = 0
74
+ while i < 6
75
+ if yy < height && idx_image[yy * width + x] == idx
76
+ bits |= 1 << i
77
+ end
78
+ yy += 1
79
+ i += 1
80
+ end
81
+ char = CHAR_MAP[bits]
82
+ if char == run_char
83
+ run_len += 1
84
+ else
85
+ out << "!#{run_len}" << run_char if run_len > 1
86
+ out << run_char if run_len == 1
87
+ run_char = char
88
+ run_len = 1
89
+ end
90
+ x += 1
91
+ end
92
+ out << "!#{run_len}" << run_char if run_len > 1
93
+ out << run_char if run_len == 1
94
+ out << "$"
95
+ end
96
+ out << "-"
97
+ end
98
+
99
+ out << "\e\\"
100
+ out
101
+ end
102
+
103
+ def encode_io(format, image, io)
104
+ io << encode(format, image)
105
+ end
106
+
107
+ def decode(*)
108
+ raise UnsupportedFormatError, "decode not supported for sixel"
109
+ end
110
+
111
+ def decode_io(*)
112
+ raise UnsupportedFormatError, "decode not supported for sixel"
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Codec
5
+ class UnsupportedFormatError < Error; end
6
+
7
+ @encoders = []
8
+ @decoders = []
9
+
10
+ class << self
11
+ attr_reader :encoders, :decoders
12
+
13
+ def register_encoder(codec_const, *formats)
14
+ encoders << { codec: codec_const, formats: formats.map { |f| f.to_s.downcase } }
15
+ end
16
+
17
+ def register_decoder(codec_const, *formats)
18
+ decoders << { codec: codec_const, formats: formats.map { |f| f.to_s.downcase } }
19
+ end
20
+
21
+ def register_codec(codec_const, *formats)
22
+ register_encoder(codec_const, *formats)
23
+ register_decoder(codec_const, *formats)
24
+ end
25
+
26
+ def supported?(format)
27
+ fmt = format.to_s.downcase
28
+ encoders.any? { |r| r[:formats].include?(fmt) && codec_supported?(r[:codec], fmt) } ||
29
+ decoders.any? { |r| r[:formats].include?(fmt) && codec_supported?(r[:codec], fmt) }
30
+ end
31
+
32
+ def encode(format, image, codec: nil, **kwargs)
33
+ codec = find_codec(encoders, format, codec)
34
+ codec.encode(format, image, **kwargs)
35
+ end
36
+
37
+ def decode(format, data, codec: nil, **kwargs)
38
+ codec = find_codec(decoders, format, codec)
39
+ codec.decode(format, data, **kwargs)
40
+ end
41
+
42
+ def encode_io(format, image, io, codec: nil, **kwargs)
43
+ codec = find_codec(encoders, format, codec)
44
+ if codec.respond_to?(:encode_io)
45
+ codec.encode_io(format, image, io, **kwargs)
46
+ else
47
+ io << codec.encode(format, image, **kwargs)
48
+ end
49
+ end
50
+
51
+ def decode_io(format, io, codec: nil, **kwargs)
52
+ codec = find_codec(decoders, format, codec)
53
+ if codec.respond_to?(:decode_io)
54
+ codec.decode_io(format, io, **kwargs)
55
+ else
56
+ codec.decode(format, io.read, **kwargs)
57
+ end
58
+ end
59
+
60
+ def detect(data)
61
+ Magic.detect(data)
62
+ end
63
+
64
+ def detect_io(io)
65
+ Magic.detect_io(io).first
66
+ end
67
+
68
+ private
69
+
70
+ def find_codec(list, format, preferred = nil)
71
+ fmt = format.to_s.downcase
72
+ if preferred
73
+ r = list.find { |e| e[:formats].include?(fmt) && e[:codec].to_s == preferred.to_s }
74
+ raise UnsupportedFormatError, "unsupported format #{format}" unless r
75
+
76
+ codec = const_get(r[:codec])
77
+ unless !codec.respond_to?(:supported?) || codec.supported?(fmt.to_sym)
78
+ raise UnsupportedFormatError, "unsupported format #{format}"
79
+ end
80
+
81
+ return codec
82
+ end
83
+
84
+ list.each do |r|
85
+ next unless r[:formats].include?(fmt)
86
+
87
+ codec = const_get(r[:codec])
88
+ next if codec.respond_to?(:supported?) && !codec.supported?(fmt.to_sym)
89
+
90
+ return codec
91
+ end
92
+ raise UnsupportedFormatError, "unsupported format #{format}"
93
+ end
94
+
95
+ def codec_supported?(codec_const, fmt)
96
+ codec = const_get(codec_const)
97
+ !codec.respond_to?(:supported?) || codec.supported?(fmt.to_sym)
98
+ end
99
+ end
100
+
101
+ autoload :Guard, "image_util/codec/_guard"
102
+
103
+ autoload :Libpng, "image_util/codec/libpng"
104
+ autoload :Libturbojpeg, "image_util/codec/libturbojpeg"
105
+ autoload :Pam, "image_util/codec/pam"
106
+ autoload :Libsixel, "image_util/codec/libsixel"
107
+ autoload :ImageMagick, "image_util/codec/image_magick"
108
+ autoload :RubySixel, "image_util/codec/ruby_sixel"
109
+
110
+ register_codec :Pam, :pam
111
+ register_codec :Libpng, :png
112
+ register_codec :Libturbojpeg, :jpeg
113
+ register_encoder :Libsixel, :sixel
114
+ register_encoder :ImageMagick, :sixel
115
+ register_encoder :RubySixel, :sixel
116
+ end
117
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ class Color < Array
5
+ def initialize(*args)
6
+ super(args)
7
+ end
8
+
9
+ def r = self[0]
10
+ def g = self[1]
11
+ def b = self[2]
12
+ def a = self[3] || 255
13
+
14
+ def r=(val); self[0] = val; end
15
+ def g=(val); self[1] = val; end
16
+ def b=(val); self[2] = val; end
17
+ def a=(val); self[3] = val; end
18
+
19
+ def self.component_from_number(number)
20
+ case number
21
+ when nil
22
+ number
23
+ when Integer
24
+ number.clamp(0, 255)
25
+ when Float
26
+ (number * 255).clamp(0, 255)
27
+ else
28
+ raise ArgumentError, "wrong type passed as component (passed: #{number})"
29
+ end
30
+ end
31
+
32
+ def self.from_buffer(buffer, color_bits)
33
+ buffer.map do |i|
34
+ case color_bits
35
+ when 8
36
+ i
37
+ else
38
+ i.to_f / 2**(color_bits - 8)
39
+ end
40
+ end.then { |val| new(*val) }
41
+ end
42
+
43
+ def to_buffer(color_bits, color_length)
44
+ map do |i|
45
+ case color_bits
46
+ when 8
47
+ i
48
+ else
49
+ (i.to_f * 2**(color_bits - 8)).to_i
50
+ end
51
+ end + [255] * (color_length - length)
52
+ end
53
+
54
+ def self.from(value)
55
+ case value
56
+ when Color
57
+ value
58
+ when Array
59
+ value.map do |i|
60
+ component_from_number(i)
61
+ rescue ArgumentError
62
+ raise ArgumentError, "wrong type passed as array index (passed: #{value.inspect})"
63
+ end.then { |val| new(*val) }
64
+ when String
65
+ case value
66
+ when /\A#(\h)(\h)(\h)\z/
67
+ new($1.to_i(16) * 0x11, $2.to_i(16) * 0x11, $3.to_i(16) * 0x11)
68
+ when /\A#(\h{2})(\h{2})(\h{2})\z/
69
+ new($1.to_i(16), $2.to_i(16), $3.to_i(16))
70
+ when /\A#(\h{2})(\h{2})(\h{2})(\h{2})\z/
71
+ new($1.to_i(16), $2.to_i(16), $3.to_i(16), $4.to_i(16))
72
+ when "black" then new(0, 0, 0)
73
+ when "white" then new(255, 255, 255)
74
+ when "red" then new(255, 0, 0)
75
+ when "lime" then new(0, 255, 0)
76
+ when "blue" then new(0, 0, 255)
77
+ else
78
+ raise ArgumentError, "wrong String passed as color (passed: #{value.inspect})"
79
+ end
80
+ when Symbol
81
+ from(value.to_s)
82
+ when Integer, Float, nil
83
+ new(*[component_from_number(value)] * 3)
84
+ else
85
+ raise ArgumentError, "wrong type passed as color (passed: #{value.inspect})"
86
+ end
87
+ end
88
+
89
+ def self.[](*value)
90
+ value = value.first if value.is_a?(Array) && value.length == 1
91
+ from(value)
92
+ end
93
+
94
+ def inspect
95
+ if a != 255
96
+ "#%02x%02x%02x%02x" % [r, g, b, a]
97
+ else
98
+ "#%02x%02x%02x" % [r, g, b]
99
+ end
100
+ end
101
+
102
+ def pretty_print(q)
103
+ q.text inspect
104
+ end
105
+
106
+ def ==(other)
107
+ other = begin
108
+ Color.from(other)
109
+ rescue StandardError
110
+ nil
111
+ end
112
+ return false unless other.is_a?(Color)
113
+
114
+ self_rgb = self[0, 3]
115
+ other_rgb = other[0, 3]
116
+ return false unless self_rgb == other_rgb
117
+
118
+ (self[3] || 255) == (other[3] || 255)
119
+ end
120
+
121
+ alias eql? ==
122
+
123
+ # Overlays another color on top of this one taking the alpha
124
+ # channel of both colors into account.
125
+ def +(other)
126
+ other = Color.from(other)
127
+
128
+ base_a = a.to_f / 255
129
+ over_a = other.a.to_f / 255
130
+
131
+ out_a = over_a + base_a * (1 - over_a)
132
+
133
+ return Color.new(0, 0, 0, 0) if out_a.zero?
134
+
135
+ out_r = (other.r * over_a + r * base_a * (1 - over_a)) / out_a
136
+ out_g = (other.g * over_a + g * base_a * (1 - over_a)) / out_a
137
+ out_b = (other.b * over_a + b * base_a * (1 - over_a)) / out_a
138
+
139
+ Color.new(out_r, out_g, out_b, out_a * 255)
140
+ end
141
+
142
+ # Multiplies the alpha channel by the given factor and returns a new color.
143
+ def *(other)
144
+ raise TypeError, "factor must be numeric" unless other.is_a?(Numeric)
145
+
146
+ Color.new(r, g, b, (a * other).clamp(0, 255))
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Filter
5
+ module Mixin
6
+ def define_immutable_version(*names)
7
+ names.each do |name|
8
+ define_method(name) do |*args, **kwargs, &block|
9
+ dup.tap { |obj| obj.public_send("#{name}!", *args, **kwargs, &block) }
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImageUtil
4
+ module Filter
5
+ module Background
6
+ def background(bgcolor)
7
+ return self if color_length == 3
8
+
9
+ unless color_length == 4
10
+ raise ArgumentError, "background only supported on RGB or RGBA images"
11
+ end
12
+
13
+ bg = Color.from(bgcolor)
14
+ img = Image.new(*dimensions, color_bits: color_bits, color_length: 3)
15
+ img.set_each_pixel_by_location do |loc|
16
+ over = bg + self[*loc]
17
+ Color.new(over.r, over.g, over.b)
18
+ end
19
+ img
20
+ end
21
+ end
22
+ end
23
+ end