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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +219 -0
- data/Rakefile +35 -0
- data/ext/texel/extconf.rb +28 -0
- data/ext/texel/shim.c +244 -0
- data/ext/texel/shim.h +35 -0
- data/ext/texel/texel_native.c +415 -0
- data/ext/texel/vendor/README.md +11 -0
- data/ext/texel/vendor/stb_image.h +7988 -0
- data/ext/texel/vendor/stb_image_resize2.h +10679 -0
- data/ext/texel/vendor/stb_image_write.h +1724 -0
- data/lib/texel/codec.rb +27 -0
- data/lib/texel/codecs/qoi.rb +228 -0
- data/lib/texel/codecs/stb.rb +104 -0
- data/lib/texel/color_transform.rb +63 -0
- data/lib/texel/errors.rb +9 -0
- data/lib/texel/gl.rb +36 -0
- data/lib/texel/half_float.rb +57 -0
- data/lib/texel/image.rb +106 -0
- data/lib/texel/image_transformations.rb +115 -0
- data/lib/texel/info.rb +10 -0
- data/lib/texel/native.rb +91 -0
- data/lib/texel/pixel_data.rb +79 -0
- data/lib/texel/registry.rb +66 -0
- data/lib/texel/resampler.rb +79 -0
- data/lib/texel/source_reader.rb +52 -0
- data/lib/texel/version.rb +5 -0
- data/lib/texel/wgpu.rb +138 -0
- data/lib/texel.rb +164 -0
- metadata +75 -0
data/lib/texel/codec.rb
ADDED
|
@@ -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
|
data/lib/texel/errors.rb
ADDED
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
|
data/lib/texel/image.rb
ADDED
|
@@ -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
|