gsplat 1.0.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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +202 -0
  3. data/README.md +236 -0
  4. data/docs/ACCEPTANCE.md +60 -0
  5. data/docs/BENCHMARKS.md +79 -0
  6. data/docs/DECISIONS.md +52 -0
  7. data/docs/MIGRATION.md +117 -0
  8. data/docs/PROFILE.md +75 -0
  9. data/docs/PROGRESS.md +199 -0
  10. data/docs/decisions/0000-template.md +17 -0
  11. data/docs/decisions/0001-pin-golden-reference-and-cpu-validation.md +27 -0
  12. data/docs/decisions/0002-retain-ruby-fallbacks-for-native-backend.md +26 -0
  13. data/docs/decisions/0003-share-projection-for-distorted-cameras.md +26 -0
  14. data/docs/decisions/0004-use-portable-world-space-reference-paths.md +26 -0
  15. data/docs/decisions/0005-reuse-ewa-core-for-2dgs.md +24 -0
  16. data/docs/decisions/0006-keep-eval3d-as-portable-reference.md +25 -0
  17. data/docs/decisions/0007-share-compositor-semantics-for-contribution-indices.md +25 -0
  18. data/examples/data/README.md +14 -0
  19. data/examples/data/colmap/images/view_000.png +0 -0
  20. data/examples/data/colmap/images/view_001.png +0 -0
  21. data/examples/data/colmap/images/view_002.png +0 -0
  22. data/examples/data/colmap/sparse/0/cameras.txt +2 -0
  23. data/examples/data/colmap/sparse/0/images.txt +6 -0
  24. data/examples/data/colmap/sparse/0/points3D.txt +17 -0
  25. data/examples/data/splats.ply +0 -0
  26. data/examples/fit_image.rb +35 -0
  27. data/examples/generate_sample_data.rb +147 -0
  28. data/examples/render_path.rb +117 -0
  29. data/examples/simple_trainer.rb +76 -0
  30. data/ext/gsplat_native/common.h +54 -0
  31. data/ext/gsplat_native/extconf.rb +39 -0
  32. data/ext/gsplat_native/gsplat_native.c +60 -0
  33. data/ext/gsplat_native/intersections.c +211 -0
  34. data/ext/gsplat_native/projection.c +199 -0
  35. data/ext/gsplat_native/raster_backward.c +129 -0
  36. data/ext/gsplat_native/raster_backward_bridge.c +69 -0
  37. data/ext/gsplat_native/raster_forward.c +150 -0
  38. data/ext/gsplat_native/rasterization.h +51 -0
  39. data/ext/gsplat_native/spherical_harmonics.c +129 -0
  40. data/gsplat.gemspec +36 -0
  41. data/lib/gsplat/autograd/context.rb +60 -0
  42. data/lib/gsplat/autograd/function.rb +68 -0
  43. data/lib/gsplat/autograd/variable.rb +159 -0
  44. data/lib/gsplat/backend/ruby/accumulate.rb +139 -0
  45. data/lib/gsplat/backend/ruby/accumulate_backward.rb +40 -0
  46. data/lib/gsplat/backend/ruby/eval3d_rasterizer.rb +175 -0
  47. data/lib/gsplat/backend/ruby/isect_tiles.rb +198 -0
  48. data/lib/gsplat/backend/ruby/projection.rb +251 -0
  49. data/lib/gsplat/backend/ruby/projection_backward.rb +190 -0
  50. data/lib/gsplat/backend/ruby/projection_covariance_vjp.rb +72 -0
  51. data/lib/gsplat/backend/ruby/projection_input_vjp.rb +252 -0
  52. data/lib/gsplat/backend/ruby/quat_scale_to_covar_preci.rb +139 -0
  53. data/lib/gsplat/backend/ruby/rasterize_to_indices_in_range.rb +121 -0
  54. data/lib/gsplat/backend/ruby/rasterize_to_pixels.rb +199 -0
  55. data/lib/gsplat/backend/ruby/rasterize_to_pixels_backward.rb +121 -0
  56. data/lib/gsplat/backend/ruby/spherical_harmonics.rb +135 -0
  57. data/lib/gsplat/backend/ruby/tile_compositor.rb +50 -0
  58. data/lib/gsplat/backend/ruby/tile_compositor_backward.rb +84 -0
  59. data/lib/gsplat/backend.rb +79 -0
  60. data/lib/gsplat/compression/grid_sort.rb +79 -0
  61. data/lib/gsplat/compression/kmeans.rb +121 -0
  62. data/lib/gsplat/compression/png.rb +147 -0
  63. data/lib/gsplat/compression/png_codec.rb +133 -0
  64. data/lib/gsplat/compression/quantizer.rb +79 -0
  65. data/lib/gsplat/io/checkpoint.rb +145 -0
  66. data/lib/gsplat/io/colmap.rb +175 -0
  67. data/lib/gsplat/io/colmap_binary.rb +98 -0
  68. data/lib/gsplat/io/colmap_text.rb +84 -0
  69. data/lib/gsplat/io/image.rb +63 -0
  70. data/lib/gsplat/io/image_backends.rb +81 -0
  71. data/lib/gsplat/io/npy.rb +189 -0
  72. data/lib/gsplat/io/ply.rb +185 -0
  73. data/lib/gsplat/io/ply_reader.rb +142 -0
  74. data/lib/gsplat/io/zip_archive.rb +183 -0
  75. data/lib/gsplat/math/camera_distortion.rb +123 -0
  76. data/lib/gsplat/math/camera_projection.rb +202 -0
  77. data/lib/gsplat/math/mat.rb +114 -0
  78. data/lib/gsplat/math/quaternion.rb +175 -0
  79. data/lib/gsplat/math/small_matrix_primitives.rb +112 -0
  80. data/lib/gsplat/math/spherical_harmonic_basis.rb +148 -0
  81. data/lib/gsplat/math/ssim.rb +153 -0
  82. data/lib/gsplat/native.rb +30 -0
  83. data/lib/gsplat/native_ops.rb +148 -0
  84. data/lib/gsplat/native_raster_ops.rb +76 -0
  85. data/lib/gsplat/ops/accumulate.rb +68 -0
  86. data/lib/gsplat/ops/eval3d_rasterize.rb +62 -0
  87. data/lib/gsplat/ops/isect_tiles.rb +38 -0
  88. data/lib/gsplat/ops/projection.rb +154 -0
  89. data/lib/gsplat/ops/quat_scale_to_covar_preci.rb +118 -0
  90. data/lib/gsplat/ops/rasterize_to_indices_in_range.rb +38 -0
  91. data/lib/gsplat/ops/rasterize_to_pixels.rb +105 -0
  92. data/lib/gsplat/ops/relocation.rb +94 -0
  93. data/lib/gsplat/ops/spherical_harmonics.rb +71 -0
  94. data/lib/gsplat/ops/tensor_shape_ops.rb +173 -0
  95. data/lib/gsplat/ops/tensor_value_ops.rb +150 -0
  96. data/lib/gsplat/optim/adam.rb +212 -0
  97. data/lib/gsplat/optim/lr_scheduler.rb +36 -0
  98. data/lib/gsplat/optim/selective_adam.rb +68 -0
  99. data/lib/gsplat/rasterization.rb +130 -0
  100. data/lib/gsplat/rasterization_2dgs.rb +140 -0
  101. data/lib/gsplat/rasterization_helpers.rb +162 -0
  102. data/lib/gsplat/rasterization_validation.rb +99 -0
  103. data/lib/gsplat/strategy/base.rb +49 -0
  104. data/lib/gsplat/strategy/default.rb +188 -0
  105. data/lib/gsplat/strategy/mcmc.rb +103 -0
  106. data/lib/gsplat/strategy/mcmc_ops.rb +143 -0
  107. data/lib/gsplat/strategy/ops.rb +165 -0
  108. data/lib/gsplat/training/config.rb +91 -0
  109. data/lib/gsplat/training/image_fitter.rb +158 -0
  110. data/lib/gsplat/training/losses.rb +191 -0
  111. data/lib/gsplat/training/scene.rb +116 -0
  112. data/lib/gsplat/training/trainer.rb +236 -0
  113. data/lib/gsplat/utils.rb +110 -0
  114. data/lib/gsplat/version.rb +6 -0
  115. data/lib/gsplat.rb +98 -0
  116. metadata +181 -0
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ require_relative "zip_archive"
6
+
7
+ module Gsplat
8
+ # File formats and dataset readers used by training and interchange.
9
+ module IO
10
+ # NumPy v1.0 NPY and NPZ interoperability.
11
+ module Npy
12
+ # Binary prefix defined by the NumPy NPY format.
13
+ MAGIC = "\x93NUMPY".b
14
+ # NPY format version emitted and accepted by this codec.
15
+ VERSION = [1, 0].freeze
16
+ # Header byte alignment used by NPY v1.0.
17
+ HEADER_ALIGNMENT = 64
18
+ # Mapping from supported Numo types to NumPy descriptors and pack formats.
19
+ DESCRIPTORS = {
20
+ Numo::SFloat => ["<f4", "e*", 4],
21
+ Numo::DFloat => ["<f8", "E*", 8],
22
+ Numo::UInt8 => ["|u1", "C*", 1],
23
+ Numo::UInt16 => ["<u2", "S<*", 2],
24
+ Numo::Int32 => ["<i4", "l<*", 4],
25
+ Numo::Int64 => ["<i8", "q<*", 8],
26
+ Numo::Bit => ["|b1", "C*", 1]
27
+ }.freeze
28
+ # Reverse descriptor-to-Numo type mapping.
29
+ TYPES = DESCRIPTORS.to_h { |type, metadata| [metadata.first, type] }.freeze
30
+
31
+ module_function
32
+
33
+ # Reads an NPY v1.0 array.
34
+ #
35
+ # @param source [String, #read] path or binary IO
36
+ # @return [Numo::NArray]
37
+ def read(source)
38
+ decode(read_bytes(source))
39
+ end
40
+
41
+ # Writes an NPY v1.0 array in C order.
42
+ #
43
+ # @param target [String, #write] path or binary IO
44
+ # @param array [Numo::NArray]
45
+ # @return [Integer] bytes written
46
+ def write(target, array)
47
+ write_bytes(target, encode(array))
48
+ end
49
+
50
+ # Reads all arrays from an NPZ archive.
51
+ #
52
+ # @param source [String, #read] path or binary IO
53
+ # @return [Hash{String=>Numo::NArray}]
54
+ def read_npz(source)
55
+ ZipArchive.decode(read_bytes(source)).each_with_object({}) do |(name, data), arrays|
56
+ next unless name.end_with?(".npy")
57
+
58
+ arrays[name.delete_suffix(".npy")] = decode(data)
59
+ end
60
+ end
61
+
62
+ # Writes arrays to an NPZ archive.
63
+ #
64
+ # @param target [String, #write] path or binary IO
65
+ # @param arrays [Hash{String, Symbol=>Numo::NArray}]
66
+ # @param compression [Symbol] :deflate or :stored
67
+ # @return [Integer] bytes written
68
+ def write_npz(target, arrays, compression: :deflate)
69
+ entries = arrays.to_h do |name, array|
70
+ normalized = normalize_entry_name(name)
71
+ ["#{normalized}.npy", encode(array)]
72
+ end
73
+ write_bytes(target, ZipArchive.encode(entries, compression: compression))
74
+ end
75
+
76
+ # Returns the NumPy dtype descriptor for a Numo type.
77
+ #
78
+ # @param type [Class]
79
+ # @return [String]
80
+ def descriptor_for(type)
81
+ DESCRIPTORS.fetch(type) do
82
+ raise NotSupportedError, "unsupported Numo dtype #{type}"
83
+ end.first
84
+ end
85
+
86
+ def encode(array)
87
+ descriptor, pack_format, = DESCRIPTORS.fetch(array.class) do
88
+ raise NotSupportedError, "unsupported Numo dtype #{array.class}"
89
+ end
90
+ header = build_header(descriptor, array.shape)
91
+ values = array.to_a
92
+ values = values.is_a?(Array) ? values.flatten : [values]
93
+
94
+ MAGIC + VERSION.pack("C2") + [header.bytesize].pack("v") + header + values.pack(pack_format)
95
+ end
96
+ private_class_method :encode
97
+
98
+ def decode(data)
99
+ data = data.b
100
+ raise Gsplat::Error, "invalid NPY magic" unless data.start_with?(MAGIC)
101
+
102
+ version = byteslice!(data, 6, 2).unpack("C2")
103
+ raise NotSupportedError, "unsupported NPY version #{version.join('.')}" unless version == VERSION
104
+
105
+ header_length = byteslice!(data, 8, 2).unpack1("v")
106
+ header = byteslice!(data, 10, header_length)
107
+ descriptor, shape = parse_header(header)
108
+ type, pack_format, byte_width = dtype_metadata(descriptor)
109
+ count = shape.empty? ? 1 : shape.inject(:*)
110
+ payload = byteslice!(data, 10 + header_length, count * byte_width)
111
+ values = payload.unpack(pack_format)
112
+
113
+ return type.cast(values.first) if shape.empty?
114
+
115
+ type.cast(values).reshape(*shape)
116
+ end
117
+ private_class_method :decode
118
+
119
+ def build_header(descriptor, shape)
120
+ shape_text = if shape.empty?
121
+ ""
122
+ elsif shape.length == 1
123
+ "#{shape.first},"
124
+ else
125
+ shape.join(", ")
126
+ end
127
+ dictionary = "{'descr': '#{descriptor}', 'fortran_order': False, 'shape': (#{shape_text}), }"
128
+ padding = (HEADER_ALIGNMENT - ((10 + dictionary.bytesize + 1) % HEADER_ALIGNMENT)) % HEADER_ALIGNMENT
129
+ header = "#{dictionary}#{' ' * padding}\n"
130
+ raise NotSupportedError, "NPY v1.0 header exceeds 65535 bytes" if header.bytesize > 65_535
131
+
132
+ header.b
133
+ end
134
+ private_class_method :build_header
135
+
136
+ def parse_header(header)
137
+ descriptor = header[/(?:'|")descr(?:'|"):\s*(?:'|")([^'"]+)(?:'|")/, 1]
138
+ order = header[/(?:'|")fortran_order(?:'|"):\s*(True|False)/, 1]
139
+ shape_text = header[/(?:'|")shape(?:'|"):\s*\(([^)]*)\)/, 1]
140
+ raise Gsplat::Error, "invalid NPY header" unless descriptor && order && shape_text
141
+ raise NotSupportedError, "Fortran order NPY arrays are unsupported" if order == "True"
142
+
143
+ shape = shape_text.scan(/\d+/).map!(&:to_i)
144
+ [descriptor, shape]
145
+ end
146
+ private_class_method :parse_header
147
+
148
+ def dtype_metadata(descriptor)
149
+ type = TYPES.fetch(descriptor) do
150
+ raise NotSupportedError, "unsupported NumPy dtype #{descriptor.inspect}"
151
+ end
152
+ [type, *DESCRIPTORS.fetch(type).drop(1)]
153
+ end
154
+ private_class_method :dtype_metadata
155
+
156
+ def normalize_entry_name(name)
157
+ normalized = name.to_s.delete_suffix(".npy")
158
+ if normalized.empty? || normalized.match?(%r{[/\\]}) || normalized.include?("\0")
159
+ raise ArgumentError, "invalid NPZ entry name #{name.inspect}"
160
+ end
161
+
162
+ normalized
163
+ end
164
+ private_class_method :normalize_entry_name
165
+
166
+ def read_bytes(source)
167
+ return source.read.b if source.respond_to?(:read)
168
+
169
+ File.binread(source)
170
+ end
171
+ private_class_method :read_bytes
172
+
173
+ def write_bytes(target, data)
174
+ return target.write(data) if target.respond_to?(:write)
175
+
176
+ File.binwrite(target, data)
177
+ end
178
+ private_class_method :write_bytes
179
+
180
+ def byteslice!(data, offset, length)
181
+ slice = data.byteslice(offset, length)
182
+ return slice if slice&.bytesize == length
183
+
184
+ raise Gsplat::Error, "truncated NPY data"
185
+ end
186
+ private_class_method :byteslice!
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ply_reader"
4
+
5
+ module Gsplat
6
+ module IO
7
+ # Inria-compatible Gaussian-splat PLY input and output.
8
+ module Ply
9
+ # Leading vertex properties in the Inria layout.
10
+ BASE_PROPERTIES = %w[x y z nx ny nz].freeze
11
+ # Properties following spherical-harmonic coefficients.
12
+ TRAILING_PROPERTIES = %w[opacity scale_0 scale_1 scale_2 rot_0 rot_1 rot_2 rot_3].freeze
13
+ # Minimum property set required for a readable Gaussian model.
14
+ REQUIRED_PROPERTIES = (%w[x y z] + %w[f_dc_0 f_dc_1 f_dc_2] + TRAILING_PROPERTIES).freeze
15
+
16
+ module_function
17
+
18
+ # Writes raw Gaussian parameters with the Inria property layout.
19
+ #
20
+ # @param target [String, #write] output path or binary IO
21
+ # @param params [Hash] means, scales, quats, opacities, sh0 and shN
22
+ # @param format [Symbol] :binary_little_endian or :ascii
23
+ # @return [Integer] bytes written
24
+ def write(target, params, format: :binary_little_endian)
25
+ arrays = normalize_params(params)
26
+ rows = parameter_rows(arrays).select { |row| row.all?(&:finite?) }
27
+ properties = property_names(arrays.fetch(:shN).shape[1])
28
+ header = build_header(format, rows.length, properties)
29
+ payload = encode_rows(rows, format)
30
+ write_bytes(target, header + payload)
31
+ end
32
+
33
+ # Reads Inria-layout Gaussian parameters from ASCII or little-endian PLY.
34
+ #
35
+ # @param source [String, #read] input path or IO
36
+ # @return [Hash{Symbol=>Numo::SFloat}]
37
+ def read(source)
38
+ columns = PlyReader.decode(read_bytes(source))
39
+ missing = REQUIRED_PROPERTIES - columns.keys
40
+ raise Gsplat::Error, "missing PLY properties: #{missing.join(', ')}" unless missing.empty?
41
+
42
+ build_params(columns)
43
+ end
44
+
45
+ def normalize_params(params)
46
+ required = %i[means scales quats opacities sh0 shN]
47
+ missing = required - params.keys
48
+ raise ArgumentError, "missing PLY params: #{missing.join(', ')}" unless missing.empty?
49
+
50
+ arrays = required.to_h { |key| [key, Ops::TensorOps.data(params.fetch(key))] }
51
+ count = arrays.fetch(:means).shape[0]
52
+ expected = {
53
+ means: [count, 3],
54
+ scales: [count, 3],
55
+ quats: [count, 4],
56
+ opacities: [count],
57
+ sh0: [count, 1, 3]
58
+ }
59
+ invalid = expected.find { |key, shape| arrays.fetch(key).shape != shape }
60
+ if invalid
61
+ raise ShapeError,
62
+ "expected #{invalid[0]} #{invalid[1].inspect}, got #{arrays.fetch(invalid[0]).shape.inspect}"
63
+ end
64
+
65
+ shn = arrays.fetch(:shN)
66
+ unless shn.ndim == 3 && shn.shape[0] == count && shn.shape[2] == 3
67
+ raise ShapeError, "expected shN [#{count},K,3], got #{shn.shape.inspect}"
68
+ end
69
+
70
+ arrays
71
+ end
72
+ private_class_method :normalize_params
73
+
74
+ def parameter_rows(arrays)
75
+ count = arrays.fetch(:means).shape[0]
76
+ count.times.map do |index|
77
+ [
78
+ *arrays.fetch(:means)[index, true].to_a.map(&:to_f),
79
+ 0.0, 0.0, 0.0,
80
+ *channel_major_sh(arrays.fetch(:sh0), index),
81
+ *channel_major_sh(arrays.fetch(:shN), index),
82
+ arrays.fetch(:opacities)[index].to_f,
83
+ *arrays.fetch(:scales)[index, true].to_a.map(&:to_f),
84
+ *arrays.fetch(:quats)[index, true].to_a.map(&:to_f)
85
+ ]
86
+ end
87
+ end
88
+ private_class_method :parameter_rows
89
+
90
+ def channel_major_sh(array, index)
91
+ 3.times.flat_map do |channel|
92
+ array.shape[1].times.map { |coefficient| array[index, coefficient, channel].to_f }
93
+ end
94
+ end
95
+ private_class_method :channel_major_sh
96
+
97
+ def property_names(rest_count)
98
+ direct = 3.times.map { |index| "f_dc_#{index}" }
99
+ rest = (rest_count * 3).times.map { |index| "f_rest_#{index}" }
100
+ BASE_PROPERTIES + direct + rest + TRAILING_PROPERTIES
101
+ end
102
+ private_class_method :property_names
103
+
104
+ def build_header(format, count, properties)
105
+ unless %i[binary_little_endian ascii].include?(format)
106
+ raise NotSupportedError, "unsupported PLY output format #{format.inspect}"
107
+ end
108
+
109
+ lines = ["ply", "format #{format} 1.0", "element vertex #{count}"]
110
+ lines.concat(properties.map { |name| "property float #{name}" })
111
+ lines << "end_header"
112
+ "#{lines.join("\n")}\n".b
113
+ end
114
+ private_class_method :build_header
115
+
116
+ def encode_rows(rows, format)
117
+ return rows.flatten.pack("e*") if format == :binary_little_endian
118
+
119
+ rows.map { |row| row.map { |value| format("%.9g", value) }.join(" ") }.join("\n").concat("\n").b
120
+ end
121
+ private_class_method :encode_rows
122
+
123
+ def build_params(columns)
124
+ count = columns.fetch("x").length
125
+ validate_column_lengths!(columns, count)
126
+ rest_names = indexed_names(columns, "f_rest")
127
+ raise Gsplat::Error, "f_rest property count must be divisible by 3" unless (rest_names.length % 3).zero?
128
+
129
+ rest_count = rest_names.length / 3
130
+ {
131
+ means: matrix(columns, %w[x y z]),
132
+ scales: matrix(columns, %w[scale_0 scale_1 scale_2]),
133
+ quats: matrix(columns, %w[rot_0 rot_1 rot_2 rot_3]),
134
+ opacities: Numo::SFloat.cast(columns.fetch("opacity")),
135
+ sh0: sh_array(columns, 1, %w[f_dc_0 f_dc_1 f_dc_2]),
136
+ shN: sh_array(columns, rest_count, rest_names)
137
+ }
138
+ end
139
+ private_class_method :build_params
140
+
141
+ def matrix(columns, names)
142
+ values = columns.fetch(names.first).length.times.flat_map do |index|
143
+ names.map { |name| columns.fetch(name)[index] }
144
+ end
145
+ Numo::SFloat.cast(values).reshape(columns.fetch(names.first).length, names.length)
146
+ end
147
+ private_class_method :matrix
148
+
149
+ def sh_array(columns, coefficient_count, names)
150
+ count = columns.fetch("x").length
151
+ output = Numo::SFloat.zeros(count, coefficient_count, 3)
152
+ 3.times do |channel|
153
+ coefficient_count.times do |coefficient|
154
+ name = names.fetch((channel * coefficient_count) + coefficient)
155
+ output[true, coefficient, channel] = Numo::SFloat.cast(columns.fetch(name))
156
+ end
157
+ end
158
+ output
159
+ end
160
+ private_class_method :sh_array
161
+
162
+ def indexed_names(columns, prefix)
163
+ names = columns.keys.grep(/\A#{Regexp.escape(prefix)}_\d+\z/)
164
+ names.sort_by { |name| Integer(name.split("_").last, 10) }
165
+ end
166
+ private_class_method :indexed_names
167
+
168
+ def validate_column_lengths!(columns, count)
169
+ invalid = columns.find { |_name, values| values.length != count }
170
+ raise Gsplat::Error, "inconsistent PLY column #{invalid[0]}" if invalid
171
+ end
172
+ private_class_method :validate_column_lengths!
173
+
174
+ def read_bytes(source)
175
+ source.respond_to?(:read) ? source.read.b : File.binread(source)
176
+ end
177
+ private_class_method :read_bytes
178
+
179
+ def write_bytes(target, data)
180
+ target.respond_to?(:write) ? target.write(data) : File.binwrite(target, data)
181
+ end
182
+ private_class_method :write_bytes
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Gsplat
6
+ module IO
7
+ # Header and scalar payload decoder for PLY files.
8
+ module PlyReader
9
+ Element = Data.define(:name, :count, :properties)
10
+ Property = Data.define(:name, :type, :count_type)
11
+ TYPE_FORMATS = {
12
+ "char" => ["c", 1], "int8" => ["c", 1],
13
+ "uchar" => ["C", 1], "uint8" => ["C", 1],
14
+ "short" => ["s<", 2], "int16" => ["s<", 2],
15
+ "ushort" => ["S<", 2], "uint16" => ["S<", 2],
16
+ "int" => ["l<", 4], "int32" => ["l<", 4],
17
+ "uint" => ["L<", 4], "uint32" => ["L<", 4],
18
+ "float" => ["e", 4], "float32" => ["e", 4],
19
+ "double" => ["E", 8], "float64" => ["E", 8]
20
+ }.freeze
21
+
22
+ module_function
23
+
24
+ def decode(data)
25
+ header, payload = split_header(data.b)
26
+ format, elements = parse_header(header)
27
+ columns = vertex_columns(elements)
28
+ reader = payload_reader(payload, format)
29
+ decode_elements(reader, elements, columns)
30
+ columns
31
+ end
32
+
33
+ def split_header(data)
34
+ match = /\A.*?end_header\r?\n/m.match(data)
35
+ raise Gsplat::Error, "PLY end_header is missing" unless match
36
+
37
+ [match[0], data.byteslice(match[0].bytesize..)]
38
+ end
39
+ private_class_method :split_header
40
+
41
+ def parse_header(header)
42
+ lines = header.lines(chomp: true).map { |line| line.delete_suffix("\r") }
43
+ raise Gsplat::Error, "invalid PLY magic" unless lines.shift == "ply"
44
+
45
+ format = nil
46
+ elements = []
47
+ lines.each do |line|
48
+ parts = line.split
49
+ case parts.first
50
+ when "format" then format = parse_format(parts)
51
+ when "element" then elements << parse_element(parts)
52
+ when "property" then elements.last.properties << parse_property(parts)
53
+ end
54
+ end
55
+ raise Gsplat::Error, "PLY format declaration is missing" unless format
56
+ raise Gsplat::Error, "PLY vertex element is missing" unless elements.any? { |item| item.name == "vertex" }
57
+
58
+ [format, elements]
59
+ end
60
+ private_class_method :parse_header
61
+
62
+ def parse_format(parts)
63
+ raise NotSupportedError, "unsupported PLY version #{parts[2]}" unless parts[2] == "1.0"
64
+ return parts[1].to_sym if %w[ascii binary_little_endian].include?(parts[1])
65
+
66
+ raise NotSupportedError, "unsupported PLY format #{parts[1].inspect}"
67
+ end
68
+ private_class_method :parse_format
69
+
70
+ def parse_element(parts)
71
+ raise Gsplat::Error, "invalid PLY element declaration" unless parts.length == 3
72
+
73
+ Element.new(name: parts[1], count: Integer(parts[2], 10), properties: [])
74
+ end
75
+ private_class_method :parse_element
76
+
77
+ def parse_property(parts)
78
+ if parts[1] == "list"
79
+ validate_type!(parts[2])
80
+ validate_type!(parts[3])
81
+ return Property.new(name: parts[4], type: parts[3], count_type: parts[2])
82
+ end
83
+ validate_type!(parts[1])
84
+ Property.new(name: parts[2], type: parts[1], count_type: nil)
85
+ end
86
+ private_class_method :parse_property
87
+
88
+ def validate_type!(type)
89
+ return if TYPE_FORMATS.key?(type)
90
+
91
+ raise NotSupportedError, "unsupported PLY property type #{type.inspect}"
92
+ end
93
+ private_class_method :validate_type!
94
+
95
+ def vertex_columns(elements)
96
+ vertex = elements.find { |element| element.name == "vertex" }
97
+ vertex.properties.reject(&:count_type).to_h { |property| [property.name, []] }
98
+ end
99
+ private_class_method :vertex_columns
100
+
101
+ def payload_reader(payload, format)
102
+ return payload.split.each if format == :ascii
103
+
104
+ StringIO.new(payload)
105
+ end
106
+ private_class_method :payload_reader
107
+
108
+ def decode_elements(reader, elements, columns)
109
+ elements.each do |element|
110
+ element.count.times do
111
+ element.properties.each do |property|
112
+ value = read_property(reader, property)
113
+ columns[property.name] << value if element.name == "vertex" && !property.count_type
114
+ end
115
+ end
116
+ end
117
+ rescue EOFError, StopIteration
118
+ raise Gsplat::Error, "truncated PLY payload"
119
+ end
120
+ private_class_method :decode_elements
121
+
122
+ def read_property(reader, property)
123
+ return read_scalar(reader, property.type) unless property.count_type
124
+
125
+ count = read_scalar(reader, property.count_type).to_i
126
+ Array.new(count) { read_scalar(reader, property.type) }
127
+ end
128
+ private_class_method :read_property
129
+
130
+ def read_scalar(reader, type)
131
+ return Float(reader.next) unless reader.is_a?(StringIO)
132
+
133
+ format, width = TYPE_FORMATS.fetch(type)
134
+ bytes = reader.read(width)
135
+ raise EOFError unless bytes&.bytesize == width
136
+
137
+ bytes.unpack1(format)
138
+ end
139
+ private_class_method :read_scalar
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Gsplat
6
+ module IO
7
+ # Minimal single-disk ZIP reader/writer for NPZ archives.
8
+ module ZipArchive
9
+ LOCAL_SIGNATURE = 0x04034B50
10
+ CENTRAL_SIGNATURE = 0x02014B50
11
+ END_SIGNATURE = 0x06054B50
12
+ UTF8_FLAG = 0x0800
13
+ METHODS = { stored: 0, deflate: 8 }.freeze
14
+ LOCAL_TEMPLATE = "VvvvvvVVVvv"
15
+ CENTRAL_TEMPLATE = "VvvvvvvVVVvvvvvVV"
16
+ END_TEMPLATE = "VvvvvVVv"
17
+
18
+ module_function
19
+
20
+ # Encodes named binary entries as a ZIP archive.
21
+ #
22
+ # @param entries [Hash{String=>String}]
23
+ # @param compression [Symbol] :stored or :deflate
24
+ # @return [String] ZIP bytes
25
+ def encode(entries, compression: :deflate)
26
+ method = METHODS.fetch(compression) do
27
+ raise ArgumentError, "unknown compression #{compression.inspect}; expected stored or deflate"
28
+ end
29
+ body = +"".b
30
+ central = +"".b
31
+
32
+ entries.each do |name, data|
33
+ name = name.to_s.b
34
+ data = data.b
35
+ compressed = method.zero? ? data : deflate(data)
36
+ metadata = entry_metadata(name, data, compressed, method, body.bytesize)
37
+ body << local_header(metadata) << name << compressed
38
+ central << central_header(metadata) << name
39
+ end
40
+
41
+ central_offset = body.bytesize
42
+ body << central
43
+ body << end_record(entries.length, central.bytesize, central_offset)
44
+ end
45
+
46
+ # Decodes named binary entries from a ZIP archive.
47
+ #
48
+ # @param archive [String] ZIP bytes
49
+ # @return [Hash{String=>String}]
50
+ def decode(archive)
51
+ archive = archive.b
52
+ entry_count, central_offset = end_metadata(archive)
53
+ cursor = central_offset
54
+
55
+ entry_count.times.each_with_object({}) do |_, entries|
56
+ metadata, cursor = parse_central_entry(archive, cursor)
57
+ entries[metadata.fetch(:name)] = extract_entry(archive, metadata)
58
+ end
59
+ end
60
+
61
+ def entry_metadata(name, data, compressed, method, offset)
62
+ {
63
+ name: name,
64
+ method: method,
65
+ crc: Zlib.crc32(data),
66
+ compressed_size: compressed.bytesize,
67
+ size: data.bytesize,
68
+ offset: offset
69
+ }
70
+ end
71
+ private_class_method :entry_metadata
72
+
73
+ def local_header(metadata)
74
+ [
75
+ LOCAL_SIGNATURE, 20, UTF8_FLAG, metadata.fetch(:method), 0, 0,
76
+ metadata.fetch(:crc), metadata.fetch(:compressed_size), metadata.fetch(:size),
77
+ metadata.fetch(:name).bytesize, 0
78
+ ].pack(LOCAL_TEMPLATE)
79
+ end
80
+ private_class_method :local_header
81
+
82
+ def central_header(metadata)
83
+ [
84
+ CENTRAL_SIGNATURE, 20, 20, UTF8_FLAG, metadata.fetch(:method), 0, 0,
85
+ metadata.fetch(:crc), metadata.fetch(:compressed_size), metadata.fetch(:size),
86
+ metadata.fetch(:name).bytesize, 0, 0, 0, 0, 0, metadata.fetch(:offset)
87
+ ].pack(CENTRAL_TEMPLATE)
88
+ end
89
+ private_class_method :central_header
90
+
91
+ def end_record(entry_count, central_size, central_offset)
92
+ [
93
+ END_SIGNATURE, 0, 0, entry_count, entry_count, central_size, central_offset, 0
94
+ ].pack(END_TEMPLATE)
95
+ end
96
+ private_class_method :end_record
97
+
98
+ def end_metadata(archive)
99
+ offset = archive.rindex([END_SIGNATURE].pack("V"))
100
+ raise Gsplat::Error, "invalid ZIP archive: end record not found" unless offset
101
+
102
+ fields = byteslice!(archive, offset, 22).unpack(END_TEMPLATE)
103
+ raise Gsplat::Error, "multi-disk ZIP archives are unsupported" unless fields[1].zero? && fields[2].zero?
104
+
105
+ [fields[4], fields[6]]
106
+ end
107
+ private_class_method :end_metadata
108
+
109
+ def parse_central_entry(archive, cursor)
110
+ fields = byteslice!(archive, cursor, 46).unpack(CENTRAL_TEMPLATE)
111
+ raise Gsplat::Error, "invalid ZIP central directory" unless fields[0] == CENTRAL_SIGNATURE
112
+
113
+ name_length, extra_length, comment_length = fields.values_at(10, 11, 12)
114
+ name = byteslice!(archive, cursor + 46, name_length).force_encoding(Encoding::UTF_8)
115
+ metadata = {
116
+ name: name,
117
+ flags: fields[3],
118
+ method: fields[4],
119
+ crc: fields[7],
120
+ compressed_size: fields[8],
121
+ size: fields[9],
122
+ offset: fields[16]
123
+ }
124
+ [metadata, cursor + 46 + name_length + extra_length + comment_length]
125
+ end
126
+ private_class_method :parse_central_entry
127
+
128
+ def extract_entry(archive, metadata)
129
+ raise NotSupportedError, "encrypted ZIP entries are unsupported" unless metadata.fetch(:flags).nobits?(1)
130
+
131
+ offset = metadata.fetch(:offset)
132
+ fields = byteslice!(archive, offset, 30).unpack(LOCAL_TEMPLATE)
133
+ raise Gsplat::Error, "invalid ZIP local header" unless fields[0] == LOCAL_SIGNATURE
134
+
135
+ data_offset = offset + 30 + fields[9] + fields[10]
136
+ compressed = byteslice!(archive, data_offset, metadata.fetch(:compressed_size))
137
+ data = inflate_entry(compressed, metadata.fetch(:method))
138
+ validate_entry!(data, metadata)
139
+ data
140
+ end
141
+ private_class_method :extract_entry
142
+
143
+ def inflate_entry(data, method)
144
+ return data if method.zero?
145
+ return inflate(data) if method == METHODS.fetch(:deflate)
146
+
147
+ raise NotSupportedError, "ZIP compression method #{method} is unsupported"
148
+ end
149
+ private_class_method :inflate_entry
150
+
151
+ def validate_entry!(data, metadata)
152
+ return if data.bytesize == metadata.fetch(:size) && Zlib.crc32(data) == metadata.fetch(:crc)
153
+
154
+ raise Gsplat::Error, "corrupt ZIP entry #{metadata.fetch(:name).inspect}"
155
+ end
156
+ private_class_method :validate_entry!
157
+
158
+ def deflate(data)
159
+ stream = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS)
160
+ stream.deflate(data, Zlib::FINISH)
161
+ ensure
162
+ stream&.close
163
+ end
164
+ private_class_method :deflate
165
+
166
+ def inflate(data)
167
+ stream = Zlib::Inflate.new(-Zlib::MAX_WBITS)
168
+ stream.inflate(data)
169
+ ensure
170
+ stream&.close
171
+ end
172
+ private_class_method :inflate
173
+
174
+ def byteslice!(data, offset, length)
175
+ slice = data.byteslice(offset, length)
176
+ return slice if slice&.bytesize == length
177
+
178
+ raise Gsplat::Error, "truncated ZIP archive"
179
+ end
180
+ private_class_method :byteslice!
181
+ end
182
+ end
183
+ end