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.
- checksums.yaml +7 -0
- data/LICENSE.txt +202 -0
- data/README.md +236 -0
- data/docs/ACCEPTANCE.md +60 -0
- data/docs/BENCHMARKS.md +79 -0
- data/docs/DECISIONS.md +52 -0
- data/docs/MIGRATION.md +117 -0
- data/docs/PROFILE.md +75 -0
- data/docs/PROGRESS.md +199 -0
- data/docs/decisions/0000-template.md +17 -0
- data/docs/decisions/0001-pin-golden-reference-and-cpu-validation.md +27 -0
- data/docs/decisions/0002-retain-ruby-fallbacks-for-native-backend.md +26 -0
- data/docs/decisions/0003-share-projection-for-distorted-cameras.md +26 -0
- data/docs/decisions/0004-use-portable-world-space-reference-paths.md +26 -0
- data/docs/decisions/0005-reuse-ewa-core-for-2dgs.md +24 -0
- data/docs/decisions/0006-keep-eval3d-as-portable-reference.md +25 -0
- data/docs/decisions/0007-share-compositor-semantics-for-contribution-indices.md +25 -0
- data/examples/data/README.md +14 -0
- data/examples/data/colmap/images/view_000.png +0 -0
- data/examples/data/colmap/images/view_001.png +0 -0
- data/examples/data/colmap/images/view_002.png +0 -0
- data/examples/data/colmap/sparse/0/cameras.txt +2 -0
- data/examples/data/colmap/sparse/0/images.txt +6 -0
- data/examples/data/colmap/sparse/0/points3D.txt +17 -0
- data/examples/data/splats.ply +0 -0
- data/examples/fit_image.rb +35 -0
- data/examples/generate_sample_data.rb +147 -0
- data/examples/render_path.rb +117 -0
- data/examples/simple_trainer.rb +76 -0
- data/ext/gsplat_native/common.h +54 -0
- data/ext/gsplat_native/extconf.rb +39 -0
- data/ext/gsplat_native/gsplat_native.c +60 -0
- data/ext/gsplat_native/intersections.c +211 -0
- data/ext/gsplat_native/projection.c +199 -0
- data/ext/gsplat_native/raster_backward.c +129 -0
- data/ext/gsplat_native/raster_backward_bridge.c +69 -0
- data/ext/gsplat_native/raster_forward.c +150 -0
- data/ext/gsplat_native/rasterization.h +51 -0
- data/ext/gsplat_native/spherical_harmonics.c +129 -0
- data/gsplat.gemspec +36 -0
- data/lib/gsplat/autograd/context.rb +60 -0
- data/lib/gsplat/autograd/function.rb +68 -0
- data/lib/gsplat/autograd/variable.rb +159 -0
- data/lib/gsplat/backend/ruby/accumulate.rb +139 -0
- data/lib/gsplat/backend/ruby/accumulate_backward.rb +40 -0
- data/lib/gsplat/backend/ruby/eval3d_rasterizer.rb +175 -0
- data/lib/gsplat/backend/ruby/isect_tiles.rb +198 -0
- data/lib/gsplat/backend/ruby/projection.rb +251 -0
- data/lib/gsplat/backend/ruby/projection_backward.rb +190 -0
- data/lib/gsplat/backend/ruby/projection_covariance_vjp.rb +72 -0
- data/lib/gsplat/backend/ruby/projection_input_vjp.rb +252 -0
- data/lib/gsplat/backend/ruby/quat_scale_to_covar_preci.rb +139 -0
- data/lib/gsplat/backend/ruby/rasterize_to_indices_in_range.rb +121 -0
- data/lib/gsplat/backend/ruby/rasterize_to_pixels.rb +199 -0
- data/lib/gsplat/backend/ruby/rasterize_to_pixels_backward.rb +121 -0
- data/lib/gsplat/backend/ruby/spherical_harmonics.rb +135 -0
- data/lib/gsplat/backend/ruby/tile_compositor.rb +50 -0
- data/lib/gsplat/backend/ruby/tile_compositor_backward.rb +84 -0
- data/lib/gsplat/backend.rb +79 -0
- data/lib/gsplat/compression/grid_sort.rb +79 -0
- data/lib/gsplat/compression/kmeans.rb +121 -0
- data/lib/gsplat/compression/png.rb +147 -0
- data/lib/gsplat/compression/png_codec.rb +133 -0
- data/lib/gsplat/compression/quantizer.rb +79 -0
- data/lib/gsplat/io/checkpoint.rb +145 -0
- data/lib/gsplat/io/colmap.rb +175 -0
- data/lib/gsplat/io/colmap_binary.rb +98 -0
- data/lib/gsplat/io/colmap_text.rb +84 -0
- data/lib/gsplat/io/image.rb +63 -0
- data/lib/gsplat/io/image_backends.rb +81 -0
- data/lib/gsplat/io/npy.rb +189 -0
- data/lib/gsplat/io/ply.rb +185 -0
- data/lib/gsplat/io/ply_reader.rb +142 -0
- data/lib/gsplat/io/zip_archive.rb +183 -0
- data/lib/gsplat/math/camera_distortion.rb +123 -0
- data/lib/gsplat/math/camera_projection.rb +202 -0
- data/lib/gsplat/math/mat.rb +114 -0
- data/lib/gsplat/math/quaternion.rb +175 -0
- data/lib/gsplat/math/small_matrix_primitives.rb +112 -0
- data/lib/gsplat/math/spherical_harmonic_basis.rb +148 -0
- data/lib/gsplat/math/ssim.rb +153 -0
- data/lib/gsplat/native.rb +30 -0
- data/lib/gsplat/native_ops.rb +148 -0
- data/lib/gsplat/native_raster_ops.rb +76 -0
- data/lib/gsplat/ops/accumulate.rb +68 -0
- data/lib/gsplat/ops/eval3d_rasterize.rb +62 -0
- data/lib/gsplat/ops/isect_tiles.rb +38 -0
- data/lib/gsplat/ops/projection.rb +154 -0
- data/lib/gsplat/ops/quat_scale_to_covar_preci.rb +118 -0
- data/lib/gsplat/ops/rasterize_to_indices_in_range.rb +38 -0
- data/lib/gsplat/ops/rasterize_to_pixels.rb +105 -0
- data/lib/gsplat/ops/relocation.rb +94 -0
- data/lib/gsplat/ops/spherical_harmonics.rb +71 -0
- data/lib/gsplat/ops/tensor_shape_ops.rb +173 -0
- data/lib/gsplat/ops/tensor_value_ops.rb +150 -0
- data/lib/gsplat/optim/adam.rb +212 -0
- data/lib/gsplat/optim/lr_scheduler.rb +36 -0
- data/lib/gsplat/optim/selective_adam.rb +68 -0
- data/lib/gsplat/rasterization.rb +130 -0
- data/lib/gsplat/rasterization_2dgs.rb +140 -0
- data/lib/gsplat/rasterization_helpers.rb +162 -0
- data/lib/gsplat/rasterization_validation.rb +99 -0
- data/lib/gsplat/strategy/base.rb +49 -0
- data/lib/gsplat/strategy/default.rb +188 -0
- data/lib/gsplat/strategy/mcmc.rb +103 -0
- data/lib/gsplat/strategy/mcmc_ops.rb +143 -0
- data/lib/gsplat/strategy/ops.rb +165 -0
- data/lib/gsplat/training/config.rb +91 -0
- data/lib/gsplat/training/image_fitter.rb +158 -0
- data/lib/gsplat/training/losses.rb +191 -0
- data/lib/gsplat/training/scene.rb +116 -0
- data/lib/gsplat/training/trainer.rb +236 -0
- data/lib/gsplat/utils.rb +110 -0
- data/lib/gsplat/version.rb +6 -0
- data/lib/gsplat.rb +98 -0
- 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
|