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,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Gsplat
|
|
6
|
+
module IO
|
|
7
|
+
# Portable NPZ checkpoint for parameters, Adam moments, step, and config.
|
|
8
|
+
module Checkpoint
|
|
9
|
+
# Current checkpoint schema version.
|
|
10
|
+
VERSION = 1
|
|
11
|
+
# Separator reserved for structured NPZ entry names.
|
|
12
|
+
SEPARATOR = "___"
|
|
13
|
+
# Loaded checkpoint payload.
|
|
14
|
+
#
|
|
15
|
+
# @!attribute params
|
|
16
|
+
# @return [Hash{Symbol=>Numo::NArray}]
|
|
17
|
+
# @!attribute optimizer_states
|
|
18
|
+
# @return [Hash] optimizer moments grouped by optimizer and parameter
|
|
19
|
+
# @!attribute step
|
|
20
|
+
# @return [Integer]
|
|
21
|
+
# @!attribute config
|
|
22
|
+
# @return [Hash]
|
|
23
|
+
Snapshot = Data.define(:params, :optimizer_states, :step, :config)
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def save(target, params:, optimizers:, step:, config: {})
|
|
28
|
+
raise ArgumentError, "step must be a non-negative integer" unless step.is_a?(Integer) && !step.negative?
|
|
29
|
+
|
|
30
|
+
arrays = {
|
|
31
|
+
"checkpoint_version" => Numo::Int32[VERSION],
|
|
32
|
+
"checkpoint_step" => Numo::Int64[step],
|
|
33
|
+
"checkpoint_config_json" => encode_json(config)
|
|
34
|
+
}
|
|
35
|
+
params.each do |name, value|
|
|
36
|
+
arrays[key("param", name)] = Ops::TensorOps.data(value)
|
|
37
|
+
end
|
|
38
|
+
optimizers.each do |optimizer_name, optimizer|
|
|
39
|
+
optimizer.groups.each_key do |group_name|
|
|
40
|
+
state = optimizer.state(group_name)
|
|
41
|
+
prefix = key("optimizer", optimizer_name, group_name)
|
|
42
|
+
arrays["#{prefix}#{SEPARATOR}step"] = Numo::Int64[state.step]
|
|
43
|
+
arrays["#{prefix}#{SEPARATOR}exp_avg"] = state.exp_avg
|
|
44
|
+
arrays["#{prefix}#{SEPARATOR}exp_avg_sq"] = state.exp_avg_sq
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
Npy.write_npz(target, arrays)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Loads parameters and optimizer state without mutating live objects.
|
|
51
|
+
#
|
|
52
|
+
# @param source [String, #read] NPZ checkpoint path or IO
|
|
53
|
+
# @return [Snapshot]
|
|
54
|
+
def load(source)
|
|
55
|
+
arrays = Npy.read_npz(source)
|
|
56
|
+
version = arrays.fetch("checkpoint_version")[0].to_i
|
|
57
|
+
raise NotSupportedError, "unsupported checkpoint version #{version}" unless version == VERSION
|
|
58
|
+
|
|
59
|
+
Snapshot.new(
|
|
60
|
+
params: extract_params(arrays),
|
|
61
|
+
optimizer_states: extract_optimizer_states(arrays),
|
|
62
|
+
step: arrays.fetch("checkpoint_step")[0].to_i,
|
|
63
|
+
config: decode_json(arrays.fetch("checkpoint_config_json"))
|
|
64
|
+
)
|
|
65
|
+
rescue KeyError => e
|
|
66
|
+
raise Gsplat::Error, "invalid checkpoint: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Loads a checkpoint into existing Variables and optimizers.
|
|
70
|
+
#
|
|
71
|
+
# @param source [String, #read] NPZ checkpoint path or IO
|
|
72
|
+
# @param params [Hash{Symbol=>Autograd::Variable}]
|
|
73
|
+
# @param optimizers [Hash{Symbol=>Optim::Adam}]
|
|
74
|
+
# @return [Snapshot]
|
|
75
|
+
def restore!(source, params:, optimizers:)
|
|
76
|
+
snapshot = load(source)
|
|
77
|
+
snapshot.params.each do |name, value|
|
|
78
|
+
params.fetch(name).replace_data!(value.dup)
|
|
79
|
+
end
|
|
80
|
+
snapshot.optimizer_states.each do |optimizer_name, groups|
|
|
81
|
+
optimizer = optimizers.fetch(optimizer_name)
|
|
82
|
+
groups.each do |group_name, state|
|
|
83
|
+
optimizer.load_state!(
|
|
84
|
+
group_name,
|
|
85
|
+
step: state.fetch(:step),
|
|
86
|
+
exp_avg: state.fetch(:exp_avg),
|
|
87
|
+
exp_avg_sq: state.fetch(:exp_avg_sq)
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
snapshot
|
|
92
|
+
rescue KeyError => e
|
|
93
|
+
raise Gsplat::Error, "checkpoint target mismatch: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def key(*parts)
|
|
97
|
+
values = parts.map(&:to_s)
|
|
98
|
+
invalid = values.find { |value| value.empty? || value.include?(SEPARATOR) || !value.match?(/\A\w+\z/) }
|
|
99
|
+
raise ArgumentError, "invalid checkpoint name #{invalid.inspect}" if invalid
|
|
100
|
+
|
|
101
|
+
values.join(SEPARATOR)
|
|
102
|
+
end
|
|
103
|
+
private_class_method :key
|
|
104
|
+
|
|
105
|
+
def encode_json(config)
|
|
106
|
+
value = config.respond_to?(:to_h) ? config.to_h : config
|
|
107
|
+
Numo::UInt8.cast(JSON.generate(value).bytes)
|
|
108
|
+
rescue JSON::GeneratorError => e
|
|
109
|
+
raise ArgumentError, "config is not JSON serializable: #{e.message}"
|
|
110
|
+
end
|
|
111
|
+
private_class_method :encode_json
|
|
112
|
+
|
|
113
|
+
def decode_json(bytes)
|
|
114
|
+
JSON.parse(bytes.to_a.pack("C*"), symbolize_names: true)
|
|
115
|
+
rescue JSON::ParserError => e
|
|
116
|
+
raise Gsplat::Error, "invalid checkpoint config JSON: #{e.message}"
|
|
117
|
+
end
|
|
118
|
+
private_class_method :decode_json
|
|
119
|
+
|
|
120
|
+
def extract_params(arrays)
|
|
121
|
+
prefix = "param#{SEPARATOR}"
|
|
122
|
+
arrays.filter_map do |name, value|
|
|
123
|
+
next unless name.start_with?(prefix)
|
|
124
|
+
|
|
125
|
+
[name.delete_prefix(prefix).to_sym, value]
|
|
126
|
+
end.to_h
|
|
127
|
+
end
|
|
128
|
+
private_class_method :extract_params
|
|
129
|
+
|
|
130
|
+
def extract_optimizer_states(arrays)
|
|
131
|
+
output = Hash.new { |hash, name| hash[name] = Hash.new { |groups, group| groups[group] = {} } }
|
|
132
|
+
pattern = /\Aoptimizer#{SEPARATOR}(\w+)#{SEPARATOR}(\w+)#{SEPARATOR}(step|exp_avg|exp_avg_sq)\z/
|
|
133
|
+
arrays.each do |name, value|
|
|
134
|
+
match = pattern.match(name)
|
|
135
|
+
next unless match
|
|
136
|
+
|
|
137
|
+
state_value = match[3] == "step" ? value[0].to_i : value
|
|
138
|
+
output[match[1].to_sym][match[2].to_sym][match[3].to_sym] = state_value
|
|
139
|
+
end
|
|
140
|
+
output.to_h { |name, groups| [name, groups.to_h] }
|
|
141
|
+
end
|
|
142
|
+
private_class_method :extract_optimizer_states
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "colmap_binary"
|
|
4
|
+
require_relative "colmap_text"
|
|
5
|
+
|
|
6
|
+
module Gsplat
|
|
7
|
+
module IO
|
|
8
|
+
# COLMAP sparse reconstruction reader and camera conversion utilities.
|
|
9
|
+
module Colmap
|
|
10
|
+
# Parsed camera calibration record.
|
|
11
|
+
Camera = Data.define(:id, :model, :width, :height, :params, :intrinsics, :distortion)
|
|
12
|
+
# Parsed registered-image pose and feature observations.
|
|
13
|
+
ImageRecord = Data.define(
|
|
14
|
+
:id, :qvec, :tvec, :camera_id, :name, :points2d, :point3d_ids, :rotation, :world_to_camera
|
|
15
|
+
)
|
|
16
|
+
# Parsed sparse 3D point and visibility track.
|
|
17
|
+
Point3D = Data.define(:id, :xyz, :rgb, :error, :track)
|
|
18
|
+
# Complete sparse model returned by {.read}.
|
|
19
|
+
Dataset = Data.define(:cameras, :images, :points3d, :path)
|
|
20
|
+
|
|
21
|
+
# Supported COLMAP numeric camera model IDs.
|
|
22
|
+
MODELS = {
|
|
23
|
+
0 => "SIMPLE_PINHOLE",
|
|
24
|
+
1 => "PINHOLE",
|
|
25
|
+
2 => "SIMPLE_RADIAL",
|
|
26
|
+
4 => "OPENCV"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Reads cameras, registered images, and points from a sparse model directory.
|
|
32
|
+
def read(path, data_factor: 1)
|
|
33
|
+
directory = model_directory(path)
|
|
34
|
+
extension = model_extension(directory)
|
|
35
|
+
Dataset.new(
|
|
36
|
+
cameras: read_cameras(File.join(directory, "cameras.#{extension}"), data_factor: data_factor),
|
|
37
|
+
images: read_images(File.join(directory, "images.#{extension}")),
|
|
38
|
+
points3d: read_points3d(File.join(directory, "points3D.#{extension}")),
|
|
39
|
+
path: directory
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Reads and scales camera calibration records from a bin/txt file.
|
|
44
|
+
#
|
|
45
|
+
# @param path [String]
|
|
46
|
+
# @param data_factor [Numeric] image downsampling factor
|
|
47
|
+
# @return [Hash{Integer=>Camera}]
|
|
48
|
+
def read_cameras(path, data_factor: 1)
|
|
49
|
+
validate_factor!(data_factor)
|
|
50
|
+
raw_records(path, :cameras).transform_values { |record| camera_record(record, data_factor) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Reads registered image poses from a bin/txt file.
|
|
54
|
+
#
|
|
55
|
+
# @param path [String]
|
|
56
|
+
# @return [Hash{Integer=>ImageRecord}]
|
|
57
|
+
def read_images(path)
|
|
58
|
+
raw_records(path, :images).transform_values { |record| image_record(record) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Reads sparse points from a bin/txt file.
|
|
62
|
+
#
|
|
63
|
+
# @param path [String]
|
|
64
|
+
# @return [Hash{Integer=>Point3D}]
|
|
65
|
+
def read_points3d(path)
|
|
66
|
+
raw_records(path, :points3d).transform_values do |record|
|
|
67
|
+
Point3D.new(
|
|
68
|
+
id: record.fetch(:id),
|
|
69
|
+
xyz: Numo::DFloat.cast(record.fetch(:xyz)),
|
|
70
|
+
rgb: Numo::UInt8.cast(record.fetch(:rgb)),
|
|
71
|
+
error: record.fetch(:error),
|
|
72
|
+
track: record.fetch(:track).freeze
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Converts a COLMAP wxyz quaternion to a 3x3 world-to-camera rotation.
|
|
78
|
+
def qvec_to_rotmat(qvec)
|
|
79
|
+
quaternion = Numo::DFloat.cast(qvec).reshape(1, 4)
|
|
80
|
+
Math::Quaternion.to_rotmat(quaternion)[0, true, true].dup
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def camera_record(record, factor)
|
|
84
|
+
model = model_name(record.fetch(:model))
|
|
85
|
+
params = record.fetch(:params).map(&:to_f)
|
|
86
|
+
focal, distortion = intrinsics_for(model, params)
|
|
87
|
+
intrinsics = Numo::DFloat[
|
|
88
|
+
[focal[0] / factor, 0, focal[2] / factor],
|
|
89
|
+
[0, focal[1] / factor, focal[3] / factor],
|
|
90
|
+
[0, 0, 1]
|
|
91
|
+
]
|
|
92
|
+
Camera.new(
|
|
93
|
+
id: record.fetch(:id),
|
|
94
|
+
model: model,
|
|
95
|
+
width: record.fetch(:width).fdiv(factor).to_i,
|
|
96
|
+
height: record.fetch(:height).fdiv(factor).to_i,
|
|
97
|
+
params: Numo::DFloat.cast(params),
|
|
98
|
+
intrinsics: intrinsics,
|
|
99
|
+
distortion: Numo::DFloat.cast(distortion)
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
private_class_method :camera_record
|
|
103
|
+
|
|
104
|
+
def image_record(record)
|
|
105
|
+
qvec = Numo::DFloat.cast(record.fetch(:qvec))
|
|
106
|
+
tvec = Numo::DFloat.cast(record.fetch(:tvec))
|
|
107
|
+
rotation = qvec_to_rotmat(qvec)
|
|
108
|
+
view = Numo::DFloat.eye(4)
|
|
109
|
+
view[0...3, 0...3] = rotation
|
|
110
|
+
view[0...3, 3] = tvec
|
|
111
|
+
coordinates = record.fetch(:points2d)
|
|
112
|
+
points2d = coordinates.empty? ? Numo::DFloat.zeros(0, 2) : Numo::DFloat.cast(coordinates)
|
|
113
|
+
ImageRecord.new(
|
|
114
|
+
id: record.fetch(:id),
|
|
115
|
+
qvec: qvec,
|
|
116
|
+
tvec: tvec,
|
|
117
|
+
camera_id: record.fetch(:camera_id),
|
|
118
|
+
name: record.fetch(:name),
|
|
119
|
+
points2d: points2d,
|
|
120
|
+
point3d_ids: Numo::Int64.cast(record.fetch(:point3d_ids)),
|
|
121
|
+
rotation: rotation,
|
|
122
|
+
world_to_camera: view
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
private_class_method :image_record
|
|
126
|
+
|
|
127
|
+
def intrinsics_for(model, params)
|
|
128
|
+
case model
|
|
129
|
+
when "SIMPLE_PINHOLE" then [[params[0], params[0], params[1], params[2]], []]
|
|
130
|
+
when "PINHOLE" then [params.first(4), []]
|
|
131
|
+
when "SIMPLE_RADIAL" then [[params[0], params[0], params[1], params[2]], [params[3]]]
|
|
132
|
+
when "OPENCV" then [params.first(4), params.drop(4)]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
private_class_method :intrinsics_for
|
|
136
|
+
|
|
137
|
+
def model_name(value)
|
|
138
|
+
return value if MODELS.value?(value)
|
|
139
|
+
|
|
140
|
+
MODELS.fetch(value) { raise NotSupportedError, "unsupported COLMAP camera model #{value.inspect}" }
|
|
141
|
+
end
|
|
142
|
+
private_class_method :model_name
|
|
143
|
+
|
|
144
|
+
def raw_records(path, kind)
|
|
145
|
+
data = File.binread(path)
|
|
146
|
+
reader = File.extname(path) == ".bin" ? ColmapBinary : ColmapText
|
|
147
|
+
reader.public_send(kind, data)
|
|
148
|
+
end
|
|
149
|
+
private_class_method :raw_records
|
|
150
|
+
|
|
151
|
+
def model_directory(path)
|
|
152
|
+
candidates = [path, File.join(path, "sparse", "0"), File.join(path, "sparse")]
|
|
153
|
+
directory = candidates.find do |candidate|
|
|
154
|
+
File.file?(File.join(candidate, "cameras.bin")) ||
|
|
155
|
+
File.file?(File.join(candidate, "cameras.txt"))
|
|
156
|
+
end
|
|
157
|
+
directory || raise(Gsplat::Error, "COLMAP sparse model not found under #{path}")
|
|
158
|
+
end
|
|
159
|
+
private_class_method :model_directory
|
|
160
|
+
|
|
161
|
+
def model_extension(directory)
|
|
162
|
+
return "bin" if %w[cameras images points3D].all? { |name| File.file?(File.join(directory, "#{name}.bin")) }
|
|
163
|
+
return "txt" if %w[cameras images points3D].all? { |name| File.file?(File.join(directory, "#{name}.txt")) }
|
|
164
|
+
|
|
165
|
+
raise Gsplat::Error, "COLMAP sparse model files are incomplete in #{directory}"
|
|
166
|
+
end
|
|
167
|
+
private_class_method :model_extension
|
|
168
|
+
|
|
169
|
+
def validate_factor!(factor)
|
|
170
|
+
raise ArgumentError, "data_factor must be positive" unless factor.is_a?(Numeric) && factor.positive?
|
|
171
|
+
end
|
|
172
|
+
private_class_method :validate_factor!
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
module IO
|
|
5
|
+
# Decoder for COLMAP sparse-model binary files.
|
|
6
|
+
module ColmapBinary
|
|
7
|
+
CAMERA_PARAM_COUNTS = {
|
|
8
|
+
0 => 3, 1 => 4, 2 => 4, 3 => 5, 4 => 8, 5 => 8,
|
|
9
|
+
6 => 12, 7 => 5, 8 => 4, 9 => 5, 10 => 12
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
# Bounds-checked little-endian binary cursor.
|
|
13
|
+
class Cursor
|
|
14
|
+
def initialize(data)
|
|
15
|
+
@data = data
|
|
16
|
+
@offset = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def scalar(format, width)
|
|
20
|
+
bytes = @data.byteslice(@offset, width)
|
|
21
|
+
raise Gsplat::Error, "truncated COLMAP binary file" unless bytes&.bytesize == width
|
|
22
|
+
|
|
23
|
+
@offset += width
|
|
24
|
+
bytes.unpack1(format)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def array(format, width, count)
|
|
28
|
+
Array.new(count) { scalar(format, width) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def cstring
|
|
32
|
+
finish = @data.index("\0", @offset)
|
|
33
|
+
raise Gsplat::Error, "unterminated COLMAP image name" unless finish
|
|
34
|
+
|
|
35
|
+
value = @data.byteslice(@offset, finish - @offset)
|
|
36
|
+
@offset = finish + 1
|
|
37
|
+
value.force_encoding(Encoding::UTF_8)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
def cameras(data)
|
|
44
|
+
cursor = Cursor.new(data)
|
|
45
|
+
Array.new(cursor.scalar("Q<", 8)).to_h do
|
|
46
|
+
id = cursor.scalar("l<", 4)
|
|
47
|
+
model = cursor.scalar("l<", 4)
|
|
48
|
+
count = CAMERA_PARAM_COUNTS.fetch(model) do
|
|
49
|
+
raise NotSupportedError, "unknown COLMAP camera model id #{model}"
|
|
50
|
+
end
|
|
51
|
+
width = cursor.scalar("Q<", 8)
|
|
52
|
+
height = cursor.scalar("Q<", 8)
|
|
53
|
+
[id, { id: id, model: model, width: width, height: height, params: cursor.array("E", 8, count) }]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def images(data)
|
|
58
|
+
cursor = Cursor.new(data)
|
|
59
|
+
Array.new(cursor.scalar("Q<", 8)).to_h do
|
|
60
|
+
id = cursor.scalar("l<", 4)
|
|
61
|
+
qvec = cursor.array("E", 8, 4)
|
|
62
|
+
tvec = cursor.array("E", 8, 3)
|
|
63
|
+
camera_id = cursor.scalar("l<", 4)
|
|
64
|
+
name = cursor.cstring
|
|
65
|
+
count = cursor.scalar("Q<", 8)
|
|
66
|
+
points2d, point3d_ids = image_points(cursor, count)
|
|
67
|
+
[id, { id: id, qvec: qvec, tvec: tvec, camera_id: camera_id, name: name,
|
|
68
|
+
points2d: points2d, point3d_ids: point3d_ids }]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def points3d(data)
|
|
73
|
+
cursor = Cursor.new(data)
|
|
74
|
+
Array.new(cursor.scalar("Q<", 8)).to_h do
|
|
75
|
+
id = cursor.scalar("Q<", 8)
|
|
76
|
+
xyz = cursor.array("E", 8, 3)
|
|
77
|
+
rgb = cursor.array("C", 1, 3)
|
|
78
|
+
error = cursor.scalar("E", 8)
|
|
79
|
+
track_count = cursor.scalar("Q<", 8)
|
|
80
|
+
track = Array.new(track_count) { [cursor.scalar("l<", 4), cursor.scalar("l<", 4)] }
|
|
81
|
+
[id, { id: id, xyz: xyz, rgb: rgb, error: error, track: track }]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def image_points(cursor, count)
|
|
86
|
+
coordinates = []
|
|
87
|
+
ids = []
|
|
88
|
+
count.times do
|
|
89
|
+
coordinates << cursor.array("E", 8, 2)
|
|
90
|
+
raw_id = cursor.scalar("Q<", 8)
|
|
91
|
+
ids << (raw_id == ((1 << 64) - 1) ? -1 : raw_id)
|
|
92
|
+
end
|
|
93
|
+
[coordinates, ids]
|
|
94
|
+
end
|
|
95
|
+
private_class_method :image_points
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
module IO
|
|
5
|
+
# Decoder for COLMAP sparse-model text files.
|
|
6
|
+
module ColmapText
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def cameras(data)
|
|
10
|
+
content_lines(data).to_h do |line|
|
|
11
|
+
fields = line.split
|
|
12
|
+
id = Integer(fields.shift, 10)
|
|
13
|
+
model = fields.shift
|
|
14
|
+
width = Integer(fields.shift, 10)
|
|
15
|
+
height = Integer(fields.shift, 10)
|
|
16
|
+
[id, { id: id, model: model, width: width, height: height, params: fields.map { |item| Float(item) } }]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def images(data)
|
|
21
|
+
lines = data.lines(chomp: true)
|
|
22
|
+
output = {}
|
|
23
|
+
until lines.empty?
|
|
24
|
+
metadata = next_metadata_line(lines)
|
|
25
|
+
break unless metadata
|
|
26
|
+
|
|
27
|
+
fields = metadata.split
|
|
28
|
+
id = Integer(fields.shift, 10)
|
|
29
|
+
qvec = fields.shift(4).map { |item| Float(item) }
|
|
30
|
+
tvec = fields.shift(3).map { |item| Float(item) }
|
|
31
|
+
camera_id = Integer(fields.shift, 10)
|
|
32
|
+
name = fields.join(" ")
|
|
33
|
+
points_line = lines.shift.to_s.strip
|
|
34
|
+
points2d, point3d_ids = parse_image_points(points_line)
|
|
35
|
+
output[id] = {
|
|
36
|
+
id: id, qvec: qvec, tvec: tvec, camera_id: camera_id,
|
|
37
|
+
name: name, points2d: points2d, point3d_ids: point3d_ids
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
output
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def points3d(data)
|
|
44
|
+
content_lines(data).to_h do |line|
|
|
45
|
+
fields = line.split
|
|
46
|
+
id = Integer(fields.shift, 10)
|
|
47
|
+
xyz = fields.shift(3).map { |item| Float(item) }
|
|
48
|
+
rgb = fields.shift(3).map { |item| Integer(item, 10) }
|
|
49
|
+
error = Float(fields.shift)
|
|
50
|
+
track = fields.each_slice(2).map { |image_id, point_index| [Integer(image_id, 10), Integer(point_index, 10)] }
|
|
51
|
+
[id, { id: id, xyz: xyz, rgb: rgb, error: error, track: track }]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def content_lines(data)
|
|
56
|
+
data.lines(chomp: true).map(&:strip).reject { |line| line.empty? || line.start_with?("#") }
|
|
57
|
+
end
|
|
58
|
+
private_class_method :content_lines
|
|
59
|
+
|
|
60
|
+
def next_metadata_line(lines)
|
|
61
|
+
until lines.empty?
|
|
62
|
+
line = lines.shift.strip
|
|
63
|
+
return line unless line.empty? || line.start_with?("#")
|
|
64
|
+
end
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
private_class_method :next_metadata_line
|
|
68
|
+
|
|
69
|
+
def parse_image_points(line)
|
|
70
|
+
fields = line.split
|
|
71
|
+
coordinates = []
|
|
72
|
+
ids = []
|
|
73
|
+
fields.each_slice(3) do |x_coord, y_coord, point_id|
|
|
74
|
+
raise Gsplat::Error, "invalid COLMAP image point row" unless point_id
|
|
75
|
+
|
|
76
|
+
coordinates << [Float(x_coord), Float(y_coord)]
|
|
77
|
+
ids << Integer(point_id, 10)
|
|
78
|
+
end
|
|
79
|
+
[coordinates, ids]
|
|
80
|
+
end
|
|
81
|
+
private_class_method :parse_image_points
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "image_backends"
|
|
4
|
+
|
|
5
|
+
module Gsplat
|
|
6
|
+
module IO
|
|
7
|
+
# RGB image input/output with vips-first optional backends.
|
|
8
|
+
module Image
|
|
9
|
+
# Optional image backend implementations in preference order.
|
|
10
|
+
BACKENDS = {
|
|
11
|
+
vips: VipsImageBackend,
|
|
12
|
+
chunky_png: ChunkyPngImageBackend
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Reads an image as float32 [H,W,3] in the range 0..1.
|
|
18
|
+
def read(path, backend: :auto)
|
|
19
|
+
resolve_backend(backend).read(path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Writes a float [H,W,3] image, clamping values to 0..1.
|
|
23
|
+
def write(path, array, backend: :auto)
|
|
24
|
+
validate_array!(array)
|
|
25
|
+
resolve_backend(backend).write(path, array)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns image codecs currently loadable in this process.
|
|
29
|
+
#
|
|
30
|
+
# @return [Array<Symbol>] subset of `:vips` and `:chunky_png`
|
|
31
|
+
def available_backends
|
|
32
|
+
BACKENDS.filter_map { |name, implementation| name if implementation.available? }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolve_backend(name)
|
|
36
|
+
if name == :auto
|
|
37
|
+
selected = BACKENDS.values.find(&:available?)
|
|
38
|
+
return selected if selected
|
|
39
|
+
|
|
40
|
+
raise NotSupportedError, "image IO requires ruby-vips or chunky_png"
|
|
41
|
+
end
|
|
42
|
+
implementation = BACKENDS.fetch(name.to_sym) do
|
|
43
|
+
raise ArgumentError, "unknown image backend #{name.inspect}"
|
|
44
|
+
end
|
|
45
|
+
return implementation if implementation.available?
|
|
46
|
+
|
|
47
|
+
raise NotSupportedError, "image backend #{name} is unavailable"
|
|
48
|
+
end
|
|
49
|
+
private_class_method :resolve_backend
|
|
50
|
+
|
|
51
|
+
def validate_array!(array)
|
|
52
|
+
valid = array.is_a?(Numo::NArray) &&
|
|
53
|
+
[Numo::SFloat, Numo::DFloat].include?(array.class) &&
|
|
54
|
+
array.ndim == 3 && array.shape[2] == 3
|
|
55
|
+
return if valid
|
|
56
|
+
|
|
57
|
+
actual = array.respond_to?(:shape) ? array.shape.inspect : array.class
|
|
58
|
+
raise ShapeError, "expected image [H,W,3] floating array, got #{actual}"
|
|
59
|
+
end
|
|
60
|
+
private_class_method :validate_array!
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
module IO
|
|
5
|
+
# Optional ruby-vips implementation.
|
|
6
|
+
module VipsImageBackend
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def available?
|
|
10
|
+
require "vips"
|
|
11
|
+
true
|
|
12
|
+
rescue LoadError, StandardError
|
|
13
|
+
false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def read(path)
|
|
17
|
+
image = Vips::Image.new_from_file(path.to_s, access: :sequential)
|
|
18
|
+
scale = { uchar: 255.0, ushort: 65_535.0 }.fetch(image.format.to_sym, 1.0)
|
|
19
|
+
rgb = rgb_image(image).cast(:float)
|
|
20
|
+
Numo::SFloat.from_binary(rgb.write_to_memory).reshape(rgb.height, rgb.width, 3) / scale
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def write(path, array)
|
|
24
|
+
bytes = quantized_bytes(array)
|
|
25
|
+
image = Vips::Image.new_from_memory(bytes, array.shape[1], array.shape[0], 3, :uchar)
|
|
26
|
+
image.write_to_file(path.to_s)
|
|
27
|
+
path
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rgb_image(image)
|
|
31
|
+
return image.extract_band(0, n: 3) if image.bands >= 3
|
|
32
|
+
|
|
33
|
+
band = image.extract_band(0)
|
|
34
|
+
band.bandjoin([band, band])
|
|
35
|
+
end
|
|
36
|
+
private_class_method :rgb_image
|
|
37
|
+
|
|
38
|
+
def quantized_bytes(array)
|
|
39
|
+
array.to_a.flatten.map { |value| (value.to_f.clamp(0.0, 1.0) * 255).round }.pack("C*")
|
|
40
|
+
end
|
|
41
|
+
private_class_method :quantized_bytes
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Portable chunky_png implementation.
|
|
45
|
+
module ChunkyPngImageBackend
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
def available?
|
|
49
|
+
require "chunky_png"
|
|
50
|
+
true
|
|
51
|
+
rescue LoadError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def read(path)
|
|
56
|
+
image = ChunkyPNG::Image.from_file(path.to_s)
|
|
57
|
+
values = image.height.times.flat_map do |y_coord|
|
|
58
|
+
image.width.times.flat_map do |x_coord|
|
|
59
|
+
pixel = image[x_coord, y_coord]
|
|
60
|
+
[ChunkyPNG::Color.r(pixel), ChunkyPNG::Color.g(pixel), ChunkyPNG::Color.b(pixel)]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
Numo::SFloat.cast(values).reshape(image.height, image.width, 3) / 255.0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def write(path, array)
|
|
67
|
+
image = ChunkyPNG::Image.new(array.shape[1], array.shape[0])
|
|
68
|
+
array.shape[0].times do |y_coord|
|
|
69
|
+
array.shape[1].times do |x_coord|
|
|
70
|
+
rgb = array[y_coord, x_coord, true].to_a.map do |value|
|
|
71
|
+
(value.to_f.clamp(0.0, 1.0) * 255).round
|
|
72
|
+
end
|
|
73
|
+
image[x_coord, y_coord] = ChunkyPNG::Color.rgb(*rgb)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
image.save(path.to_s)
|
|
77
|
+
path
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|