texel 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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ class Codec
5
+ class << self
6
+ def can_decode?(_format)
7
+ false
8
+ end
9
+
10
+ def can_encode?(_format)
11
+ false
12
+ end
13
+
14
+ def decode(_bytes, **)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def info(_bytes, **)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def encode(_image, _format, **)
23
+ raise NotImplementedError
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ module Codecs
5
+ class Qoi < Codec
6
+ MAGIC = "qoif".b
7
+ END_MARKER = "\0\0\0\0\0\0\0\1".b
8
+ HEADER_SIZE = 14
9
+ INDEX_SIZE = 64
10
+ MAX_PIXELS = 1 << 28
11
+
12
+ class << self
13
+ def can_decode?(format)
14
+ format == :qoi
15
+ end
16
+
17
+ def can_encode?(format)
18
+ format == :qoi
19
+ end
20
+
21
+ def info(bytes, format: :qoi, **)
22
+ width, height, channels, = parse_header(bytes)
23
+ Info.new(width: width, height: height, channels: channels, format: format)
24
+ end
25
+
26
+ def decode(bytes, channels: nil, dtype: nil, **)
27
+ width, height, source_channels, color_space = parse_header(bytes)
28
+ rgba = decode_pixels(bytes, width * height)
29
+ image = Image.new(
30
+ width: width,
31
+ height: height,
32
+ channels: 4,
33
+ dtype: :u8,
34
+ color_space: color_space,
35
+ data: rgba
36
+ )
37
+ image.convert(channels: channels || source_channels, dtype: dtype || :u8)
38
+ rescue DecodeError
39
+ raise
40
+ rescue StandardError => e
41
+ raise DecodeError, "invalid QOI image: #{e.message}"
42
+ end
43
+
44
+ def encode(image, _format = :qoi, **)
45
+ prepared = prepare_image(image)
46
+ output = MAGIC + [prepared.width, prepared.height].pack("N2") +
47
+ [prepared.channels, prepared.color_space == :linear ? 1 : 0].pack("C2")
48
+ encode_pixels(prepared, output)
49
+ output << END_MARKER
50
+ rescue EncodeError
51
+ raise
52
+ rescue StandardError => e
53
+ raise EncodeError, "could not encode QOI image: #{e.message}"
54
+ end
55
+
56
+ private
57
+
58
+ def parse_header(bytes)
59
+ unless bytes.bytesize >= HEADER_SIZE + END_MARKER.bytesize && bytes.start_with?(MAGIC)
60
+ raise DecodeError, "invalid QOI header"
61
+ end
62
+
63
+ width, height = bytes.byteslice(4, 8).unpack("N2")
64
+ channels, color_space = bytes.byteslice(12, 2).unpack("C2")
65
+ raise DecodeError, "QOI dimensions must be positive" unless width.positive? && height.positive?
66
+ raise DecodeError, "QOI dimensions are too large" if width * height > MAX_PIXELS
67
+ raise DecodeError, "QOI channels must be 3 or 4" unless [3, 4].include?(channels)
68
+ raise DecodeError, "invalid QOI color space tag" unless [0, 1].include?(color_space)
69
+
70
+ [width, height, channels, color_space.zero? ? :srgb : :linear]
71
+ end
72
+
73
+ def decode_pixels(bytes, pixel_count)
74
+ index = Array.new(INDEX_SIZE) { [0, 0, 0, 0] }
75
+ pixel = [0, 0, 0, 255]
76
+ output = String.new(capacity: [pixel_count * 4, 1 << 20].min, encoding: Encoding::BINARY)
77
+ offset = HEADER_SIZE
78
+ run = 0
79
+
80
+ pixel_count.times do
81
+ if run.positive?
82
+ run -= 1
83
+ else
84
+ pixel, offset, run = decode_opcode(bytes, offset, pixel, index)
85
+ end
86
+ index[pixel_hash(pixel)] = pixel.dup
87
+ pixel.each { |component| output << component }
88
+ end
89
+ raise DecodeError, "QOI run exceeds the declared pixel count" if run.positive?
90
+
91
+ validate_trailer!(bytes, offset)
92
+ output.freeze
93
+ end
94
+
95
+ def decode_opcode(bytes, offset, previous, index)
96
+ byte = byte_at!(bytes, offset)
97
+ offset += 1
98
+ return decode_rgb(bytes, offset, previous) if byte == 0xfe
99
+ return decode_rgba(bytes, offset) if byte == 0xff
100
+
101
+ case byte & 0xc0
102
+ when 0x00 then [index.fetch(byte & 0x3f).dup, offset, 0]
103
+ when 0x40 then [decode_diff(byte, previous), offset, 0]
104
+ when 0x80 then decode_luma(bytes, offset, byte, previous)
105
+ when 0xc0 then [previous, offset, byte & 0x3f]
106
+ end
107
+ end
108
+
109
+ def decode_rgb(bytes, offset, previous)
110
+ rgb = bytes.byteslice(offset, 3)&.bytes
111
+ raise DecodeError, "truncated QOI RGB opcode" unless rgb&.length == 3
112
+
113
+ [[*rgb, previous[3]], offset + 3, 0]
114
+ end
115
+
116
+ def decode_rgba(bytes, offset)
117
+ rgba = bytes.byteslice(offset, 4)&.bytes
118
+ raise DecodeError, "truncated QOI RGBA opcode" unless rgba&.length == 4
119
+
120
+ [rgba, offset + 4, 0]
121
+ end
122
+
123
+ def decode_diff(byte, previous)
124
+ [
125
+ (previous[0] + (((byte >> 4) & 0x03) - 2)) & 0xff,
126
+ (previous[1] + (((byte >> 2) & 0x03) - 2)) & 0xff,
127
+ (previous[2] + ((byte & 0x03) - 2)) & 0xff,
128
+ previous[3]
129
+ ]
130
+ end
131
+
132
+ def decode_luma(bytes, offset, byte, previous)
133
+ second = byte_at!(bytes, offset)
134
+ green_difference = (byte & 0x3f) - 32
135
+ red_difference = green_difference + ((second >> 4) - 8)
136
+ blue_difference = green_difference + ((second & 0x0f) - 8)
137
+ pixel = [
138
+ (previous[0] + red_difference) & 0xff,
139
+ (previous[1] + green_difference) & 0xff,
140
+ (previous[2] + blue_difference) & 0xff,
141
+ previous[3]
142
+ ]
143
+ [pixel, offset + 1, 0]
144
+ end
145
+
146
+ def validate_trailer!(bytes, offset)
147
+ trailer = bytes.byteslice(offset, END_MARKER.bytesize)
148
+ raise DecodeError, "invalid or missing QOI end marker" unless trailer == END_MARKER
149
+ return if offset + END_MARKER.bytesize == bytes.bytesize
150
+
151
+ raise DecodeError, "unexpected data after QOI end marker"
152
+ end
153
+
154
+ def byte_at!(bytes, offset)
155
+ bytes.getbyte(offset) || raise(DecodeError, "truncated QOI pixel data")
156
+ end
157
+
158
+ def prepare_image(image)
159
+ channels = [3, 4].include?(image.channels) ? image.channels : 4
160
+ image.convert(channels: channels, dtype: :u8)
161
+ end
162
+
163
+ def encode_pixels(image, output)
164
+ index = Array.new(INDEX_SIZE) { [0, 0, 0, 0] }
165
+ previous = [0, 0, 0, 255]
166
+ run = 0
167
+ pixel_count = image.width * image.height
168
+
169
+ pixel_count.times do |pixel_index|
170
+ offset = pixel_index * image.channels
171
+ pixel = image.channels.times.map { |channel| image.data.getbyte(offset + channel) }
172
+ pixel << 255 if image.channels == 3
173
+ if pixel == previous
174
+ run += 1
175
+ if run == 62 || pixel_index == pixel_count - 1
176
+ output << (0xc0 | (run - 1))
177
+ run = 0
178
+ end
179
+ next
180
+ end
181
+
182
+ if run.positive?
183
+ output << (0xc0 | (run - 1))
184
+ run = 0
185
+ end
186
+ encode_pixel(output, pixel, previous, index)
187
+ index[pixel_hash(pixel)] = pixel
188
+ previous = pixel
189
+ end
190
+ end
191
+
192
+ def encode_pixel(output, pixel, previous, index)
193
+ index_position = pixel_hash(pixel)
194
+ if index[index_position] == pixel
195
+ output << index_position
196
+ elsif pixel[3] != previous[3]
197
+ output << 0xff << pixel.pack("C4")
198
+ else
199
+ encode_rgb_difference(output, pixel, previous)
200
+ end
201
+ end
202
+
203
+ def encode_rgb_difference(output, pixel, previous)
204
+ red = pixel[0] - previous[0]
205
+ green = pixel[1] - previous[1]
206
+ blue = pixel[2] - previous[2]
207
+ if [red, green, blue].all? { |difference| difference.between?(-2, 1) }
208
+ output << (0x40 | ((red + 2) << 4) | ((green + 2) << 2) | (blue + 2))
209
+ return
210
+ end
211
+
212
+ red_green = red - green
213
+ blue_green = blue - green
214
+ if green.between?(-32, 31) && red_green.between?(-8, 7) && blue_green.between?(-8, 7)
215
+ output << (0x80 | (green + 32)) << (((red_green + 8) << 4) | (blue_green + 8))
216
+ return
217
+ end
218
+
219
+ output << 0xfe << pixel.first(3).pack("C3")
220
+ end
221
+
222
+ def pixel_hash(pixel)
223
+ ((pixel[0] * 3) + (pixel[1] * 5) + (pixel[2] * 7) + (pixel[3] * 11)) % INDEX_SIZE
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ module Codecs
5
+ class Stb < Codec
6
+ DECODE_FORMATS = %i[png jpg jpeg bmp tga gif psd hdr].freeze
7
+ ENCODE_FORMATS = %i[png jpg jpeg bmp tga hdr].freeze
8
+
9
+ class << self
10
+ def can_decode?(format)
11
+ DECODE_FORMATS.include?(format) && Native.available?
12
+ end
13
+
14
+ def can_encode?(format)
15
+ ENCODE_FORMATS.include?(format) && Native.available?
16
+ end
17
+
18
+ def info(bytes, format:, **)
19
+ width, height, channels, = metadata(bytes)
20
+ Info.new(width: width, height: height, channels: channels, format: format)
21
+ end
22
+
23
+ def decode(bytes, channels: nil, dtype: nil, format:, **)
24
+ width, height, source_channels, sixteen_bit, hdr = metadata(bytes)
25
+ target_channels = channels || source_channels
26
+ target_dtype = dtype || default_dtype(sixteen_bit, hdr)
27
+ storage_dtype = if hdr
28
+ :f32
29
+ elsif target_dtype == :f32
30
+ sixteen_bit ? :u16 : :u8
31
+ else
32
+ target_dtype
33
+ end
34
+ data = Native.decode(bytes, target_channels, storage_dtype)
35
+ image = Image.new(
36
+ width: width,
37
+ height: height,
38
+ channels: target_channels,
39
+ dtype: storage_dtype,
40
+ color_space: hdr || format == :hdr ? :linear : :srgb,
41
+ data: data
42
+ )
43
+ image.convert(dtype: target_dtype)
44
+ rescue Native::OperationError => e
45
+ raise DecodeError, "could not decode #{format.to_s.upcase}: #{e.message}"
46
+ end
47
+
48
+ def encode(image, format, quality: 90, **)
49
+ format = :jpg if format == :jpeg
50
+ validate_quality!(quality) if format == :jpg
51
+ prepared = prepare_for_encoding(image, format)
52
+ Native.encode(
53
+ prepared.data, format, prepared.width, prepared.height, prepared.channels,
54
+ prepared.dtype, format == :png ? prepared.row_bytes : quality
55
+ )
56
+ rescue Native::OperationError => e
57
+ raise EncodeError, "could not encode #{format.to_s.upcase}: #{e.message}"
58
+ end
59
+
60
+ private
61
+
62
+ def metadata(bytes)
63
+ width, height, channels, sixteen_bit, hdr = Native.metadata(bytes)
64
+ raise Native::OperationError, "unsupported or corrupt image" unless width.positive? && height.positive?
65
+
66
+ [width, height, channels, sixteen_bit, hdr]
67
+ end
68
+
69
+ def default_dtype(sixteen_bit, hdr)
70
+ return :f32 if hdr
71
+ return :u16 if sixteen_bit
72
+
73
+ :u8
74
+ end
75
+
76
+ def prepare_for_encoding(image, format)
77
+ if format == :hdr
78
+ prepared = image.convert(dtype: :f32)
79
+ return prepared unless [2, 4].include?(prepared.channels)
80
+
81
+ return prepared.convert(channels: prepared.channels - 1)
82
+ end
83
+ if image.dtype == :u16
84
+ raise FormatError, "stb_image_write cannot encode 16-bit #{format.to_s.upcase}; convert to :u8 first"
85
+ end
86
+
87
+ prepared = image.convert(dtype: :u8)
88
+ if format == :bmp && prepared.channels == 2
89
+ return prepared.convert(channels: 4)
90
+ end
91
+ return prepared unless format == :jpg && [2, 4].include?(prepared.channels)
92
+
93
+ prepared.convert(channels: prepared.channels == 2 ? 1 : 3)
94
+ end
95
+
96
+ def validate_quality!(quality)
97
+ return if quality.is_a?(Integer) && quality.between?(1, 100)
98
+
99
+ raise FormatError, "JPEG quality must be an integer between 1 and 100"
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ module ColorTransform
5
+ SRGB_TO_LINEAR_LUT = Array.new(256) do |index|
6
+ channel = index / 255.0
7
+ channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055)**2.4
8
+ end.freeze
9
+ LINEAR_TO_SRGB_LUT = Array.new(4096) do |index|
10
+ channel = index / 4095.0
11
+ channel <= 0.0031308 ? 12.92 * channel : (1.055 * (channel**(1.0 / 2.4))) - 0.055
12
+ end.freeze
13
+
14
+ module_function
15
+
16
+ def linearize(image)
17
+ validate_color_space!(image, :srgb, :linear)
18
+ return image.convert(dtype: :f32).dup_with(color_space: :linear) if image.color_space == :linear
19
+
20
+ source = PixelData.unpack(image.data, image.dtype)
21
+ result = source.each_with_index.map do |value, index|
22
+ normalized = PixelData.normalize(value, image.dtype)
23
+ alpha_channel?(index, image.channels) ? normalized : srgb_to_linear(normalized, image.dtype, value)
24
+ end
25
+ image.dup_with(data: PixelData.pack(result, :f32), dtype: :f32, color_space: :linear)
26
+ end
27
+
28
+ def delinearize(image, dtype: :u8)
29
+ validate_color_space!(image, :linear, :srgb)
30
+ return image.convert(dtype: dtype).dup_with(color_space: :srgb) if image.color_space == :srgb
31
+
32
+ source = PixelData.unpack(image.data, image.dtype)
33
+ result = source.each_with_index.map do |value, index|
34
+ normalized = PixelData.normalize(value, image.dtype)
35
+ converted = alpha_channel?(index, image.channels) ? normalized : linear_to_srgb(normalized)
36
+ PixelData.denormalize(converted, dtype)
37
+ end
38
+ image.dup_with(data: PixelData.pack(result, dtype), dtype: dtype, color_space: :srgb)
39
+ end
40
+
41
+ def srgb_to_linear(channel, dtype = :f32, raw_value = nil)
42
+ return SRGB_TO_LINEAR_LUT.fetch(raw_value) if dtype == :u8 && raw_value
43
+
44
+ channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055)**2.4
45
+ end
46
+
47
+ def linear_to_srgb(channel)
48
+ LINEAR_TO_SRGB_LUT.fetch((channel.clamp(0.0, 1.0) * 4095).round)
49
+ end
50
+
51
+ def alpha_channel?(flat_index, channels)
52
+ (channels == 2 || channels == 4) && (flat_index % channels == channels - 1)
53
+ end
54
+ private_class_method :alpha_channel?
55
+
56
+ def validate_color_space!(image, source, destination)
57
+ return if [source, destination].include?(image.color_space)
58
+
59
+ raise FormatError, "cannot convert #{image.color_space.inspect} pixels from #{source} to #{destination}"
60
+ end
61
+ private_class_method :validate_color_space!
62
+ end
63
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ class Error < StandardError; end
5
+ class DecodeError < Error; end
6
+ class EncodeError < Error; end
7
+ class FormatError < Error; end
8
+ class MissingCodecError < Error; end
9
+ end
data/lib/texel/gl.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../texel"
4
+
5
+ module Texel
6
+ module GL
7
+ Format = Data.define(:internal_format, :format, :type)
8
+ CHANNEL_FORMATS = {
9
+ 1 => :red,
10
+ 2 => :rg,
11
+ 3 => :rgb,
12
+ 4 => :rgba
13
+ }.freeze
14
+ INTERNAL_FORMATS = {
15
+ u8: { 1 => :r8, 2 => :rg8, 3 => :rgb8, 4 => :rgba8 },
16
+ u16: { 1 => :r16, 2 => :rg16, 3 => :rgb16, 4 => :rgba16 },
17
+ f32: { 1 => :r32f, 2 => :rg32f, 3 => :rgb32f, 4 => :rgba32f }
18
+ }.freeze
19
+ TYPES = { u8: :unsigned_byte, u16: :unsigned_short, f32: :float }.freeze
20
+
21
+ module_function
22
+
23
+ def format(image)
24
+ raise FormatError, "expected a Texel::Image" unless image.is_a?(Image)
25
+
26
+ internal = INTERNAL_FORMATS.fetch(image.dtype).fetch(image.channels)
27
+ internal = :srgb8 if image.dtype == :u8 && image.color_space == :srgb && image.channels == 3
28
+ internal = :srgb8_alpha8 if image.dtype == :u8 && image.color_space == :srgb && image.channels == 4
29
+ Format.new(
30
+ internal_format: internal,
31
+ format: CHANNEL_FORMATS.fetch(image.channels),
32
+ type: TYPES.fetch(image.dtype)
33
+ ).freeze
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ module HalfFloat
5
+ module_function
6
+
7
+ def pack(f32_data)
8
+ return Native.pack_f16(f32_data) if Native.available?
9
+
10
+ f32_data.unpack("e*").map { |value| bits(value) }.pack("S<*").b
11
+ rescue Native::OperationError => e
12
+ raise FormatError, "f16 conversion failed: #{e.message}"
13
+ end
14
+
15
+ def bits(value)
16
+ input = [value].pack("e").unpack1("L<")
17
+ sign = (input >> 16) & 0x8000
18
+ exponent = (input >> 23) & 0xff
19
+ mantissa = input & 0x7fffff
20
+ return sign | (mantissa.zero? ? 0x7c00 : 0x7e00) if exponent == 0xff
21
+
22
+ half_exponent = exponent - 127 + 15
23
+ return sign | 0x7c00 if half_exponent >= 31
24
+ return subnormal_bits(sign, mantissa, half_exponent) if half_exponent <= 0
25
+
26
+ normal_bits(sign, mantissa, half_exponent)
27
+ end
28
+
29
+ def subnormal_bits(sign, mantissa, exponent)
30
+ return sign if exponent < -10
31
+
32
+ mantissa |= 0x800000
33
+ shift = 14 - exponent
34
+ half_mantissa = mantissa >> shift
35
+ remainder = mantissa & ((1 << shift) - 1)
36
+ halfway = 1 << (shift - 1)
37
+ half_mantissa += 1 if remainder > halfway || (remainder == halfway && half_mantissa.odd?)
38
+ sign | half_mantissa
39
+ end
40
+ private_class_method :subnormal_bits
41
+
42
+ def normal_bits(sign, mantissa, exponent)
43
+ half_mantissa = mantissa >> 13
44
+ remainder = mantissa & 0x1fff
45
+ if remainder > 0x1000 || (remainder == 0x1000 && half_mantissa.odd?)
46
+ half_mantissa += 1
47
+ if half_mantissa == 0x400
48
+ half_mantissa = 0
49
+ exponent += 1
50
+ return sign | 0x7c00 if exponent >= 31
51
+ end
52
+ end
53
+ sign | (exponent << 10) | half_mantissa
54
+ end
55
+ private_class_method :normal_bits
56
+ end
57
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ class Image
5
+ DTYPE_SIZES = { u8: 1, u16: 2, f32: 4 }.freeze
6
+ COLOR_SPACES = %i[srgb linear unknown].freeze
7
+ DUPLICABLE_TAGS = %i[width height channels dtype color_space premultiplied].freeze
8
+
9
+ attr_reader :width, :height, :channels, :dtype, :color_space, :data, :premultiplied
10
+
11
+ def initialize(width:, height:, channels:, dtype:, color_space:, data:, premultiplied: false)
12
+ validate_dimensions!(width, height)
13
+ validate_channels!(channels)
14
+ validate_dtype!(dtype)
15
+ validate_color_space!(color_space)
16
+
17
+ @width = width
18
+ @height = height
19
+ @channels = channels
20
+ @dtype = dtype
21
+ @color_space = color_space
22
+ source = String(data)
23
+ @data = if source.encoding == Encoding::BINARY && source.frozen?
24
+ source
25
+ else
26
+ source.dup.force_encoding(Encoding::BINARY).freeze
27
+ end
28
+ @premultiplied = !!premultiplied
29
+ validate_data_size!
30
+ end
31
+
32
+ def bytesize_per_pixel
33
+ channels * DTYPE_SIZES.fetch(dtype)
34
+ end
35
+
36
+ def row_bytes
37
+ width * bytesize_per_pixel
38
+ end
39
+
40
+ def pixel(x, y)
41
+ unless x.is_a?(Integer) && y.is_a?(Integer) && x.between?(0, width - 1) && y.between?(0, height - 1)
42
+ raise IndexError, "pixel coordinates (#{x}, #{y}) are outside #{width}x#{height} image"
43
+ end
44
+
45
+ byte_offset = ((y * width) + x) * bytesize_per_pixel
46
+ bytes = data.byteslice(byte_offset, bytesize_per_pixel)
47
+ PixelData.unpack(bytes, dtype)
48
+ end
49
+
50
+ def dup_with(data: nil, **tags)
51
+ unknown_tags = tags.keys - DUPLICABLE_TAGS
52
+ raise ArgumentError, "unknown image tags: #{unknown_tags.join(", ")}" unless unknown_tags.empty?
53
+
54
+ self.class.new(
55
+ width: tags.fetch(:width, width),
56
+ height: tags.fetch(:height, height),
57
+ channels: tags.fetch(:channels, channels),
58
+ dtype: tags.fetch(:dtype, dtype),
59
+ color_space: tags.fetch(:color_space, color_space),
60
+ data: data || self.data,
61
+ premultiplied: tags.fetch(:premultiplied, premultiplied)
62
+ )
63
+ end
64
+
65
+ def premultiplied?
66
+ premultiplied
67
+ end
68
+
69
+ def premultiplied=(value)
70
+ @premultiplied = !!value
71
+ end
72
+
73
+ private
74
+
75
+ def validate_dimensions!(image_width, image_height)
76
+ return if image_width.is_a?(Integer) && image_width.positive? && image_height.is_a?(Integer) && image_height.positive?
77
+
78
+ raise FormatError, "image dimensions must be positive integers"
79
+ end
80
+
81
+ def validate_channels!(image_channels)
82
+ return if image_channels.is_a?(Integer) && image_channels.between?(1, 4)
83
+
84
+ raise FormatError, "channels must be between 1 and 4"
85
+ end
86
+
87
+ def validate_dtype!(image_dtype)
88
+ return if DTYPE_SIZES.key?(image_dtype)
89
+
90
+ raise FormatError, "unsupported dtype #{image_dtype.inspect}; expected :u8, :u16, or :f32"
91
+ end
92
+
93
+ def validate_color_space!(space)
94
+ return if COLOR_SPACES.include?(space)
95
+
96
+ raise FormatError, "unsupported color space #{space.inspect}"
97
+ end
98
+
99
+ def validate_data_size!
100
+ expected = width * height * bytesize_per_pixel
101
+ return if data.bytesize == expected
102
+
103
+ raise FormatError, "pixel data is #{data.bytesize} bytes; expected #{expected} bytes"
104
+ end
105
+ end
106
+ end