ruby-hdf5 0.0.1 → 0.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a5d336f6b45f913a829873d20aba6c80c5724db14038965e073235f36477733
4
- data.tar.gz: 1fe31c4c72258d31d57ca6ce043aee21f7cd9083700c77405a501a79f1ea5323
3
+ metadata.gz: e7932dfc6c7cc1ffcfb6dc30c8963785e1e1e2ba206add86e634878d999e166d
4
+ data.tar.gz: cd6e121152d557821c64deb08a08286528911db013a48a0d7d7a1bff8c6da7a0
5
5
  SHA512:
6
- metadata.gz: 786821e23be9bdc249352c34dc5e293be4a6f3e931c69cc89a1f51ee284d53e00d2ccdb130ee49a2e5bf467ff89cf60f184c455ffc3271309ea0c37a189b28eb
7
- data.tar.gz: 98150386a48675d3208416cca2c1d4f1668f9519c2acafbb2935ae0d8111789a1de890d1516640c2649bc463d8c07f1144acad050a018f209fe429cf5d394821
6
+ metadata.gz: f7270f19f0bd3732147faad5f94f0c429f02c93e81ad66985adefddff43ac41fbacb2834073d6b195c14f4ff905df4a60735ea143fee79fab1116a96f76c7e54
7
+ data.tar.gz: 060cb8b49f0373ca6ac788274c1bb5bc187fe92bab799b240f731034b59abebd789c956e23da6ccdbef4dd577e37be38e20121879ce98caea20c326829cb2e2e
data/README.md CHANGED
@@ -2,114 +2,122 @@
2
2
 
3
3
  [![test](https://github.com/red-data-tools/ruby-hdf5/actions/workflows/test.yml/badge.svg)](https://github.com/red-data-tools/ruby-hdf5/actions/workflows/test.yml)
4
4
 
5
- Ruby bindings for the HDF5 library.
5
+ Ruby bindings for HDF5 with Numo::NArray support.
6
6
 
7
- ## Scope
7
+ ## Requirements
8
8
 
9
- This gem currently provides practical high-level wrappers for:
10
-
11
- - opening and creating files
12
- - creating groups
13
- - creating, writing, and reading one-dimensional numeric datasets
14
- - reading attributes
15
-
16
- Unsupported at this stage:
17
-
18
- - string dataset read/write
19
- - attribute write
20
- - multidimensional array write
21
-
22
- ## Supported HDF5 Versions
23
-
24
- - HDF5 1.10
25
- - HDF5 1.14
26
- - HDF5 2.x
27
-
28
- HDF5 versions older than 1.10 are not supported.
9
+ - Ruby 3.4 or later
10
+ - HDF5 1.10 or later (`libhdf5` shared library)
29
11
 
30
12
  ## Install
31
13
 
32
- Add to your Gemfile:
14
+ Add the gem to your Gemfile:
33
15
 
34
16
  ```ruby
35
17
  gem 'ruby-hdf5'
36
18
  ```
37
19
 
38
- Install:
20
+ Then install dependencies:
39
21
 
40
22
  ```sh
41
23
  bundle install
42
24
  ```
43
25
 
44
- System library (`libhdf5`) is required.
45
-
46
- ## Runtime Notes
47
-
48
- - The gem loads `libhdf5` through FFI.
49
- - If the shared library cannot be found automatically, set `HDF5_LIB_PATH`.
50
-
51
- Examples:
26
+ Set `HDF5_LIB_PATH` only when `libhdf5` cannot be found automatically. It may be a library directory or a shared-library path.
52
27
 
53
28
  ```sh
54
- # Point to a directory containing libhdf5.so
55
- export HDF5_LIB_PATH=/usr/lib
56
-
57
- # Or point directly to the shared object
58
29
  export HDF5_LIB_PATH=/usr/lib/libhdf5.so
59
30
  ```
60
31
 
61
- ## Quick Start
32
+ ## Usage
62
33
 
63
- ### Read an existing file
34
+ Create a file and write a Numo array:
64
35
 
65
36
  ```ruby
66
37
  require 'hdf5'
67
38
 
68
- HDF5::File.open('example.h5') do |file|
69
- group = file['foo']
70
- dataset = group['bar_int']
39
+ matrix = Numo::SFloat.new(100, 64).seq
40
+
41
+ HDF5::File.open('numbers.h5', 'w') do |file|
42
+ dataset = file.require_group('measurements').create_dataset('signal', matrix)
43
+ dataset.attrs['unit'] = 'a.u.'
44
+ end
45
+ ```
46
+
47
+ Read data and inspect its type:
48
+
49
+ ```ruby
50
+ HDF5::File.open('numbers.h5') do |file|
51
+ dataset = file['measurements/signal']
71
52
  p dataset.shape
72
- p dataset.dtype
53
+ p dataset.dtype.to_sym
73
54
  p dataset.read
74
55
  end
75
56
  ```
76
57
 
77
- ### Create and write a file
58
+ Use a block with `HDF5::File.open` to close the file automatically. Supported modes are `r`, `r+`, `w`, `x`, and `a`.
78
59
 
79
- ```ruby
80
- require 'hdf5'
60
+ ## Common Tasks
81
61
 
82
- HDF5::File.create('numbers.h5') do |file|
83
- file.create_group('values') do |group|
84
- group.create_dataset('ints', [1, 2, 3, 4])
85
- end
86
- end
62
+ Read a row, a column, or a strided selection without reading the complete dataset:
87
63
 
88
- reopened = HDF5::File.open('numbers.h5')
89
- p reopened['values']['ints'].read
90
- reopened.close
64
+ ```ruby
65
+ HDF5::File.open('numbers.h5') do |file|
66
+ dataset = file['measurements/signal']
67
+ row = dataset[10, true]
68
+ column = dataset[true, 0]
69
+ every_tenth_row = dataset[HDF5.slice(0...100, step: 10), true]
70
+ end
91
71
  ```
92
72
 
93
- ## Error Handling
73
+ Append rows to an extendible dataset. Extendible datasets must use chunked storage:
94
74
 
95
- High-level API failures raise `HDF5::Error`.
75
+ ```ruby
76
+ HDF5::File.open('samples.h5', 'w') do |file|
77
+ samples = file.create_dataset(
78
+ 'samples',
79
+ shape: [0, 2],
80
+ dtype: :float32,
81
+ maxshape: [nil, 2],
82
+ chunks: [256, 2]
83
+ )
84
+ samples.append(Numo::SFloat[[1.0, 2.0], [3.0, 4.0]])
85
+ end
86
+ ```
87
+
88
+ Process a dataset in bounded-memory blocks:
96
89
 
97
90
  ```ruby
98
- begin
99
- HDF5::File.open('missing.h5')
100
- rescue HDF5::Error => e
101
- warn e.message
91
+ sum = 0.0
92
+
93
+ HDF5::File.open('numbers.h5') do |file|
94
+ file['measurements/signal'].each_block(max_bytes: 4 * 1024 * 1024) do |_selection, block|
95
+ sum += block.sum
96
+ end
102
97
  end
103
98
  ```
104
99
 
105
- ## Development
100
+ ## Main Operations
101
+
102
+ - Hierarchy: `[]`, `create_group`, `require_group`, `keys`, `delete`, `move`
103
+ - Datasets: `create_dataset`, `read`, `write`, `[]`, `[]=`, `read_into`
104
+ - Dataset metadata: `shape`, `ndim`, `size`, `dtype`, `chunks`, `maxshape`, `fillvalue`
105
+ - Storage: chunking, gzip, shuffle, Fletcher32, resize, and append
106
+ - Iteration: `each_block(max_bytes:)` and `each_chunk`
107
+ - Attributes: `attrs[]`, `attrs[]=`, `attrs.create`, `attrs.modify`, `attrs.delete`
108
+
109
+ Datasets support Numo numeric arrays, scalar values, variable-length UTF-8 strings, h5py-compatible bool values, and h5py-compatible complex values.
110
+
111
+ ## Limitations
106
112
 
107
- After more than a decade, it is clear that the Ruby community does not have enough resources to sustainably maintain an HDF5 library. For that reason, development of this library is intentionally AI-assisted. Something is better than nothing.
113
+ - Fixed-length strings, general compound / enum / reference types, and variable-length numeric types are unsupported.
114
+ - Fancy indexing, boolean masks, negative slice steps, and general broadcasting are unsupported.
115
+ - SWMR, MPI, and VDS creation are unsupported.
108
116
 
109
- ## Acknowledgement
117
+ ## Examples
110
118
 
111
- [https://github.com/edmundhighcock/hdf5](https://github.com/edmundhighcock/hdf5)
119
+ See [examples/README.md](examples/README.md) for standalone examples, ordered from basic file I/O through chunking, resizing, and links.
112
120
 
113
121
  ## License
114
122
 
115
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
123
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -1,60 +1,248 @@
1
1
  module HDF5
2
2
  class Attribute
3
- def initialize(dataset_id, attr_name)
3
+ def initialize(dataset_id, attr_name, context = nil)
4
4
  @dataset_id = dataset_id
5
5
  @attr_name = attr_name
6
- @attr_id = FFI.H5Aopen(@dataset_id, @attr_name, 0)
7
- raise 'Failed to open attribute' if @attr_id < 0
6
+ context&.ensure_open!(dataset_id)
7
+ @attr_id = HDF5::FFI.H5Aopen(@dataset_id, @attr_name, HDF5::DEFAULT_PROPERTY_LIST)
8
+ raise HDF5::Error, 'Failed to open attribute' if @attr_id < 0
8
9
  end
9
10
 
10
11
  def read
11
- type_id = FFI.H5Aget_type(@attr_id)
12
- space_id = FFI.H5Aget_space(@attr_id)
13
-
14
- size = FFI.H5Sget_simple_extent_npoints(space_id)
15
-
16
- buffer = \
17
- case FFI.H5Tget_class(type_id)
18
- when :H5T_INTEGER
19
- ::FFI::MemoryPointer.new(:int, size)
20
- when :H5T_FLOAT
21
- ::FFI::MemoryPointer.new(:double, size)
22
- when :H5T_STRING
23
- ::FFI::MemoryPointer.new(:pointer, size)
24
- else
25
- raise 'Unsupported data type'
26
- end
12
+ type_id = HDF5::FFI.H5Aget_type(@attr_id)
13
+ raise HDF5::Error, 'Failed to get attribute datatype' if type_id < 0
27
14
 
28
- status = FFI.H5Aread(@attr_id, type_id, buffer)
29
- raise 'Failed to read attribute' if status < 0
15
+ space_id = HDF5::FFI.H5Aget_space(@attr_id)
16
+ raise HDF5::Error, 'Failed to get attribute dataspace' if space_id < 0
17
+ return read_string(type_id, space_id) if HDF5::FFI.H5Tget_class(type_id) == :H5T_STRING
30
18
 
31
- case FFI.H5Tget_class(type_id)
32
- when :H5T_INTEGER
33
- buffer.read_array_of_int(size)
34
- when :H5T_FLOAT
35
- buffer.read_array_of_double(size)
36
- when :H5T_STRING
37
- buffer.read_pointer.read_string
38
- else
39
- raise 'Unsupported data type'
40
- end
19
+ dtype_object = DType.for_hdf5(type_id)
20
+ attribute_shape = shape(space_id)
21
+ size = attribute_shape.empty? ? 1 : attribute_shape.inject(:*)
22
+ buffer = ::FFI::MemoryPointer.new(:char, size * dtype_object.itemsize)
23
+ status = HDF5::FFI.H5Aread(@attr_id, dtype_object.memory_type_id, buffer)
24
+ raise HDF5::Error, 'Failed to read attribute' if status < 0
25
+
26
+ result = HDF5::DataHelpers.from_binary(dtype_object, buffer.read_bytes(size * dtype_object.itemsize),
27
+ attribute_shape)
28
+ return result unless attribute_shape.empty?
29
+
30
+ scalar = result.extract
31
+ dtype_object.kind == :bool ? !scalar.zero? : scalar
32
+ ensure
33
+ HDF5::FFI.H5Tclose(type_id) if type_id && type_id >= 0
34
+ HDF5::FFI.H5Sclose(space_id) if space_id && space_id >= 0
41
35
  end
42
36
 
43
37
  def close
44
- FFI.H5Aclose(@attr_id)
38
+ return if @attr_id.nil?
39
+
40
+ HDF5::FFI.H5Aclose(@attr_id)
41
+ @attr_id = nil
42
+ end
43
+
44
+ private
45
+
46
+ def shape(space_id)
47
+ rank = HDF5::FFI.H5Sget_simple_extent_ndims(space_id)
48
+ raise HDF5::Error, 'Failed to get attribute rank' if rank < 0
49
+ return [] if rank.zero?
50
+
51
+ dimensions = ::FFI::MemoryPointer.new(:ulong_long, rank)
52
+ status = HDF5::FFI.H5Sget_simple_extent_dims(space_id, dimensions, nil)
53
+ raise HDF5::Error, 'Failed to get attribute shape' if status < 0
54
+
55
+ dimensions.read_array_of_uint64(rank)
56
+ end
57
+
58
+ def read_string(type_id, space_id)
59
+ unless HDF5::StringCodec.variable?(type_id)
60
+ raise UnsupportedTypeError, 'Fixed-length string attributes are not yet supported'
61
+ end
62
+
63
+ attribute_shape = shape(space_id)
64
+ count = attribute_shape.empty? ? 1 : attribute_shape.inject(:*)
65
+ buffer = ::FFI::MemoryPointer.new(:pointer, count)
66
+ status = HDF5::FFI.H5Aread(@attr_id, type_id, buffer)
67
+ raise HDF5::Error, 'Failed to read string attribute' if status < 0
68
+
69
+ HDF5::StringCodec.read_values(buffer, count, attribute_shape)
70
+ ensure
71
+ if buffer
72
+ active_error = $ERROR_INFO
73
+ reclaim_status = HDF5::FFI.H5Dvlen_reclaim(type_id, space_id, HDF5::DEFAULT_PROPERTY_LIST, buffer)
74
+ if reclaim_status.negative? && active_error.nil?
75
+ raise HDF5::Error,
76
+ 'Failed to reclaim variable-length string attribute'
77
+ end
78
+ end
45
79
  end
46
80
  end
47
81
 
48
82
  class AttributeManager
49
- def initialize(dataset_id)
83
+ def initialize(dataset_id, context = nil)
50
84
  @dataset_id = dataset_id
85
+ @context = context
51
86
  end
52
87
 
53
88
  def [](attr_name)
54
- attr = Attribute.new(@dataset_id, attr_name)
89
+ @context&.ensure_open!(@dataset_id)
90
+ attr = Attribute.new(@dataset_id, attr_name, @context)
55
91
  attr.read
56
92
  ensure
57
93
  attr.close if attr
58
94
  end
95
+
96
+ def []=(attr_name, value)
97
+ write(attr_name, value)
98
+ end
99
+
100
+ def keys
101
+ @context&.ensure_open!(@dataset_id)
102
+ names = []
103
+ index = ::FFI::MemoryPointer.new(:ulong_long)
104
+ index.write_ulong_long(0)
105
+ callback = ::FFI::Function.new(:int, %i[int64_t string pointer pointer]) do |_, name, _, _|
106
+ names << name
107
+ 0
108
+ end
109
+ status = HDF5::FFI.H5Aiterate2(@dataset_id, :H5_INDEX_NAME, :H5_ITER_NATIVE, index, callback, nil)
110
+ raise HDF5::Error, 'Failed to iterate over attributes' if status < 0
111
+
112
+ names
113
+ end
114
+
115
+ def key?(attr_name)
116
+ @context&.ensure_open!(@dataset_id)
117
+ exists = HDF5::FFI.H5Aexists(@dataset_id, attr_name)
118
+ raise HDF5::Error, "Failed to check attribute existence: #{attr_name}" if exists.negative?
119
+
120
+ exists.positive?
121
+ end
122
+
123
+ def delete(attr_name)
124
+ @context&.ensure_open!(@dataset_id)
125
+ status = HDF5::FFI.H5Adelete(@dataset_id, attr_name)
126
+ raise HDF5::Error, "Failed to delete attribute: #{attr_name}" if status < 0
127
+
128
+ self
129
+ end
130
+
131
+ def create(attr_name, value)
132
+ raise HDF5::Error, "Attribute already exists: #{attr_name}" if key?(attr_name)
133
+
134
+ write(attr_name, value)
135
+ end
136
+
137
+ def modify(attr_name, value)
138
+ @context&.ensure_open!(@dataset_id)
139
+ raise HDF5::Error, "Attribute not found: #{attr_name}" unless key?(attr_name)
140
+
141
+ attr_id = HDF5::FFI.H5Aopen(@dataset_id, attr_name, HDF5::DEFAULT_PROPERTY_LIST)
142
+ raise HDF5::Error, "Failed to open attribute: #{attr_name}" if attr_id < 0
143
+
144
+ type_id = HDF5::FFI.H5Aget_type(attr_id)
145
+ space_id = HDF5::FFI.H5Aget_space(attr_id)
146
+ raise HDF5::Error, "Failed to inspect attribute: #{attr_name}" if type_id < 0 || space_id < 0
147
+
148
+ if HDF5::FFI.H5Tget_class(type_id) == :H5T_STRING
149
+ unless HDF5::StringCodec.variable?(type_id)
150
+ raise UnsupportedTypeError, 'Fixed-length string attributes are not yet supported'
151
+ end
152
+
153
+ string_values, string_shape = HDF5::StringCodec.normalize_data(value)
154
+ unless string_shape == attribute_shape(space_id)
155
+ raise HDF5::ShapeError,
156
+ 'Attribute shape must not change when modifying'
157
+ end
158
+
159
+ buffer, _string_pointers = HDF5::StringCodec.buffer_for_values(string_values)
160
+ status = HDF5::FFI.H5Awrite(attr_id, type_id, buffer)
161
+ else
162
+ dtype_object = DType.for_hdf5(type_id)
163
+ values = HDF5::DataHelpers.normalize_data(value, label: 'Attribute data')
164
+ expected_shape = attribute_shape(space_id)
165
+ raise HDF5::Error, 'Attribute shape must not change when modifying' unless values.shape == expected_shape
166
+
167
+ converted = dtype_object.numo_class.cast(values)
168
+ status = HDF5::FFI.H5Awrite(attr_id, dtype_object.memory_type_id, HDF5::DataHelpers.buffer_for(converted))
169
+ end
170
+ raise HDF5::Error, "Failed to modify attribute: #{attr_name}" if status < 0
171
+
172
+ value
173
+ ensure
174
+ HDF5::FFI.H5Sclose(space_id) if space_id && space_id >= 0
175
+ HDF5::FFI.H5Tclose(type_id) if type_id && type_id >= 0
176
+ HDF5::FFI.H5Aclose(attr_id) if attr_id && attr_id >= 0
177
+ end
178
+
179
+ def write(attr_name, value)
180
+ @context&.ensure_open!(@dataset_id)
181
+ string_data = HDF5::StringCodec.string_data?(value)
182
+ string_values, string_shape = HDF5::StringCodec.normalize_data(value) if string_data
183
+ values = HDF5::DataHelpers.normalize_data(value, label: 'Attribute data') unless string_data
184
+ dtype_object = DType.for_numo(values) unless string_data
185
+ type_id = string_data ? HDF5::StringCodec.datatype_id : dtype_object.storage_type_id
186
+
187
+ exists = HDF5::FFI.H5Aexists(@dataset_id, attr_name)
188
+ raise HDF5::Error, "Failed to check attribute existence: #{attr_name}" if exists.negative?
189
+
190
+ if exists.positive?
191
+ status = HDF5::FFI.H5Adelete(@dataset_id, attr_name)
192
+ raise HDF5::Error, "Failed to replace attribute: #{attr_name}" if status < 0
193
+ end
194
+
195
+ dataspace_id = create_dataspace(string_data ? string_shape : values.shape)
196
+ raise HDF5::Error, 'Failed to create attribute dataspace' if dataspace_id < 0
197
+
198
+ attr_id = HDF5::FFI.H5Acreate2(
199
+ @dataset_id,
200
+ attr_name,
201
+ type_id,
202
+ dataspace_id,
203
+ HDF5::DEFAULT_PROPERTY_LIST,
204
+ HDF5::DEFAULT_PROPERTY_LIST
205
+ )
206
+ raise HDF5::Error, "Failed to create attribute: #{attr_name}" if attr_id < 0
207
+
208
+ buffer, _string_pointers = if string_data
209
+ HDF5::StringCodec.buffer_for_values(string_values)
210
+ else
211
+ [HDF5::DataHelpers.buffer_for(values), nil]
212
+ end
213
+ memory_type_id = string_data ? type_id : dtype_object.memory_type_id
214
+ status = HDF5::FFI.H5Awrite(attr_id, memory_type_id, buffer)
215
+ raise HDF5::Error, "Failed to write attribute: #{attr_name}" if status < 0
216
+
217
+ value
218
+ ensure
219
+ HDF5::FFI.H5Aclose(attr_id) if attr_id && attr_id >= 0
220
+ HDF5::FFI.H5Sclose(dataspace_id) if dataspace_id && dataspace_id >= 0
221
+ HDF5::FFI.H5Tclose(type_id) if string_data && type_id && type_id >= 0
222
+ end
223
+
224
+ private
225
+
226
+ def attribute_shape(space_id)
227
+ rank = HDF5::FFI.H5Sget_simple_extent_ndims(space_id)
228
+ raise HDF5::Error, 'Failed to get attribute rank' if rank < 0
229
+ return [] if rank.zero?
230
+
231
+ dimensions = ::FFI::MemoryPointer.new(:ulong_long, rank)
232
+ status = HDF5::FFI.H5Sget_simple_extent_dims(space_id, dimensions, nil)
233
+ raise HDF5::Error, 'Failed to get attribute shape' if status < 0
234
+
235
+ dimensions.read_array_of_uint64(rank)
236
+ end
237
+
238
+ def create_dataspace(shape)
239
+ return HDF5::FFI.H5Screate(:H5S_SCALAR) if shape.empty?
240
+
241
+ dimensions = ::FFI::MemoryPointer.new(:ulong_long, shape.length)
242
+ dimensions.write_array_of_ulong_long(shape)
243
+ HDF5::FFI.H5Screate_simple(shape.length, dimensions, nil)
244
+ end
245
+
246
+ prepend FileContext.guard(:[], :[]=, :keys, :key?, :delete, :create, :modify, :write)
59
247
  end
60
248
  end
@@ -0,0 +1,54 @@
1
+ module HDF5
2
+ module DataHelpers
3
+ module_function
4
+
5
+ def normalize_data(data, label: 'Data')
6
+ return data if data.is_a?(Numo::NArray) && DType.for_numo(data)
7
+
8
+ values = data.is_a?(Array) ? data.flatten : [data]
9
+ raise HDF5::Error, "#{label} must not be empty" if values.empty?
10
+
11
+ dtype = if values.all? { |value| value.is_a?(Integer) }
12
+ DType.for_symbol(:int64)
13
+ elsif values.all? { |value| [true, false].include?(value) }
14
+ DType.for_symbol(:bool)
15
+ elsif values.all? { |value| value.is_a?(Numeric) } && values.any? { |value| value.is_a?(Complex) }
16
+ DType.for_symbol(:complex128)
17
+ elsif values.all? { |value| value.is_a?(Numeric) }
18
+ DType.for_symbol(:float64)
19
+ else
20
+ raise HDF5::Error, "Only numeric #{label.downcase} is supported"
21
+ end
22
+ normalized = dtype.kind == :bool ? normalize_booleans(data) : data
23
+ dtype.numo_class.cast(normalized)
24
+ end
25
+
26
+ def buffer_for(narray)
27
+ dtype = DType.for_numo(narray)
28
+ binary = if dtype.kind == :bool
29
+ narray.to_a.flatten.map { |value| value.zero? ? 0 : 1 }.pack('C*')
30
+ else
31
+ narray.to_binary
32
+ end
33
+ expected_bytes = narray.size * dtype.itemsize
34
+ raise HDF5::Error, 'Numo binary representation has an unexpected size' unless binary.bytesize == expected_bytes
35
+
36
+ ::FFI::MemoryPointer.new(:char, expected_bytes).tap { |buffer| buffer.put_bytes(0, binary) }
37
+ end
38
+
39
+ def from_binary(dtype, binary, shape)
40
+ return dtype.numo_class.from_binary(binary, shape) unless dtype.kind == :bool
41
+
42
+ bytes = binary.unpack('C*')
43
+ return Numo::Bit.new.store(bytes.first) if shape.empty?
44
+
45
+ Numo::UInt8.cast(bytes).reshape(*shape).ne(0)
46
+ end
47
+
48
+ def normalize_booleans(value)
49
+ return value.map { |item| normalize_booleans(item) } if value.is_a?(Array)
50
+
51
+ value ? 1 : 0
52
+ end
53
+ end
54
+ end