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,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
module Training
|
|
5
|
+
# A small self-consistency image fitting loop used by examples and E2E tests.
|
|
6
|
+
class ImageFitter
|
|
7
|
+
# Image-fit metrics, optimized colors, and target image.
|
|
8
|
+
Result = Data.define(:initial_psnr, :final_psnr, :history, :colors, :target)
|
|
9
|
+
|
|
10
|
+
# Scalar image MSE used to drive the example's reverse pass.
|
|
11
|
+
class MeanSquaredError < Autograd::Function
|
|
12
|
+
class << self
|
|
13
|
+
# Evaluates scalar mean squared error.
|
|
14
|
+
# @api private
|
|
15
|
+
def forward(context, rendered, target)
|
|
16
|
+
difference = rendered - target
|
|
17
|
+
context.save(difference, rendered.size)
|
|
18
|
+
rendered.class.cast((difference**2).sum / rendered.size)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns the rendered-image VJP; targets are constants.
|
|
22
|
+
# @api private
|
|
23
|
+
def backward(context, grad_output)
|
|
24
|
+
difference, count = context.saved_values
|
|
25
|
+
[(2.0 * difference / count) * grad_output.to_f, nil]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :dtype, :height, :learning_rate, :n_gaussians, :width
|
|
31
|
+
|
|
32
|
+
# rubocop:disable Metrics/ParameterLists
|
|
33
|
+
def initialize(width: 64, height: 64, n_gaussians: 2_000, learning_rate: 20.0,
|
|
34
|
+
seed: 42, dtype: Numo::SFloat)
|
|
35
|
+
# rubocop:enable Metrics/ParameterLists
|
|
36
|
+
@width = width
|
|
37
|
+
@height = height
|
|
38
|
+
@n_gaussians = n_gaussians
|
|
39
|
+
@learning_rate = learning_rate
|
|
40
|
+
@dtype = dtype
|
|
41
|
+
@rng = Random.new(seed)
|
|
42
|
+
validate_options!
|
|
43
|
+
build_scene
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fit(steps: 300)
|
|
47
|
+
raise ArgumentError, "steps must be a positive integer" unless steps.is_a?(Integer) && steps.positive?
|
|
48
|
+
|
|
49
|
+
target = render(@teacher_colors)
|
|
50
|
+
colors = initial_colors
|
|
51
|
+
history = [psnr(render(colors), target)]
|
|
52
|
+
steps.times do
|
|
53
|
+
variable = Autograd::Variable.new(colors, requires_grad: true)
|
|
54
|
+
rendered = render(variable)
|
|
55
|
+
MeanSquaredError.apply(rendered, target).backward
|
|
56
|
+
colors = clipped(colors - (learning_rate * variable.grad), 0.0, 1.0)
|
|
57
|
+
history << psnr(render(colors), target)
|
|
58
|
+
end
|
|
59
|
+
Result.new(
|
|
60
|
+
initial_psnr: history.first,
|
|
61
|
+
final_psnr: history.last,
|
|
62
|
+
history: history.freeze,
|
|
63
|
+
colors: colors,
|
|
64
|
+
target: target
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def validate_options!
|
|
71
|
+
values = { width: width, height: height, n_gaussians: n_gaussians }
|
|
72
|
+
invalid = values.find { |_name, value| !value.is_a?(Integer) || !value.positive? }
|
|
73
|
+
raise ArgumentError, "#{invalid[0]} must be a positive integer" if invalid
|
|
74
|
+
raise ArgumentError, "learning_rate must be positive" unless learning_rate.positive?
|
|
75
|
+
return if [Numo::SFloat, Numo::DFloat].include?(dtype)
|
|
76
|
+
|
|
77
|
+
raise ArgumentError, "dtype must be Numo::SFloat or Numo::DFloat"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# rubocop:disable Metrics/AbcSize
|
|
81
|
+
def build_scene
|
|
82
|
+
@means = dtype.zeros(n_gaussians, 3)
|
|
83
|
+
@quaternions = dtype.zeros(n_gaussians, 4)
|
|
84
|
+
@quaternions[true, 0] = 1
|
|
85
|
+
@scales = dtype.zeros(n_gaussians, 3)
|
|
86
|
+
@opacities = dtype.ones(n_gaussians) * 0.85
|
|
87
|
+
@viewmats = dtype.zeros(1, 4, 4)
|
|
88
|
+
@viewmats[0, true, true] = dtype.eye(4)
|
|
89
|
+
focal = [width, height].max.to_f
|
|
90
|
+
@intrinsics = dtype.cast([[[focal, 0, width / 2.0], [0, focal, height / 2.0], [0, 0, 1]]])
|
|
91
|
+
side = ::Math.sqrt(n_gaussians).ceil
|
|
92
|
+
n_gaussians.times do |index|
|
|
93
|
+
grid_x = index % side
|
|
94
|
+
grid_y = index / side
|
|
95
|
+
pixel_x = ((grid_x + 0.5) * width / side) - 0.5
|
|
96
|
+
pixel_y = ((grid_y + 0.5) * height / side) - 0.5
|
|
97
|
+
depth = 2.0 + (0.2 * (index % 3))
|
|
98
|
+
@means[index, true] = [
|
|
99
|
+
(pixel_x - (width / 2.0)) * depth / focal,
|
|
100
|
+
(pixel_y - (height / 2.0)) * depth / focal,
|
|
101
|
+
depth
|
|
102
|
+
]
|
|
103
|
+
world_scale = 0.8 * depth / focal
|
|
104
|
+
@scales[index, true] = [world_scale, world_scale, world_scale]
|
|
105
|
+
end
|
|
106
|
+
@teacher_colors = teacher_colors(side)
|
|
107
|
+
end
|
|
108
|
+
# rubocop:enable Metrics/AbcSize
|
|
109
|
+
|
|
110
|
+
def teacher_colors(side)
|
|
111
|
+
output = dtype.zeros(n_gaussians, 3)
|
|
112
|
+
n_gaussians.times do |index|
|
|
113
|
+
x_coord = (index % side).to_f / [side - 1, 1].max
|
|
114
|
+
y_coord = (index / side).to_f / [side - 1, 1].max
|
|
115
|
+
output[index, true] = [x_coord, y_coord, 0.25 + (0.5 * x_coord * y_coord)]
|
|
116
|
+
end
|
|
117
|
+
output
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def initial_colors
|
|
121
|
+
values = Array.new(n_gaussians * 3) { 0.35 + (@rng.rand * 0.3) }
|
|
122
|
+
dtype.cast(values).reshape(n_gaussians, 3)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def render(colors)
|
|
126
|
+
rendered, = Gsplat.rasterization(
|
|
127
|
+
means: @means,
|
|
128
|
+
quats: @quaternions,
|
|
129
|
+
scales: @scales,
|
|
130
|
+
opacities: @opacities,
|
|
131
|
+
colors: colors,
|
|
132
|
+
viewmats: @viewmats,
|
|
133
|
+
ks: @intrinsics,
|
|
134
|
+
width: width,
|
|
135
|
+
height: height,
|
|
136
|
+
tile_size: 16
|
|
137
|
+
)
|
|
138
|
+
rendered
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def psnr(rendered, target)
|
|
142
|
+
mean_squared_error = (((Ops::TensorOps.data(rendered) - target)**2).sum / target.size).to_f
|
|
143
|
+
return Float::INFINITY if mean_squared_error.zero?
|
|
144
|
+
|
|
145
|
+
-10.0 * ::Math.log10(mean_squared_error)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def clipped(values, minimum, maximum)
|
|
149
|
+
output = values.dup
|
|
150
|
+
below = output.lt(minimum)
|
|
151
|
+
above = output.gt(maximum)
|
|
152
|
+
output[below] = minimum if below.any?
|
|
153
|
+
output[above] = maximum if above.any?
|
|
154
|
+
output
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
module Training
|
|
5
|
+
# Differentiable image losses and scalar image-quality metrics.
|
|
6
|
+
module Losses
|
|
7
|
+
# Mean absolute error with a zero subgradient at exact equality.
|
|
8
|
+
class L1 < Autograd::Function
|
|
9
|
+
class << self
|
|
10
|
+
# Evaluates mean absolute error.
|
|
11
|
+
# @api private
|
|
12
|
+
def forward(context, prediction, target)
|
|
13
|
+
unless prediction.shape == target.shape
|
|
14
|
+
raise ShapeError, "L1 shapes differ: #{prediction.shape.inspect} and #{target.shape.inspect}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
difference = prediction - target
|
|
18
|
+
context.save(difference, prediction.size)
|
|
19
|
+
prediction.class.cast(difference.abs.mean)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Propagates the L1 subgradient to prediction and target.
|
|
23
|
+
# @api private
|
|
24
|
+
def backward(context, grad_output)
|
|
25
|
+
difference, count = context.saved_values
|
|
26
|
+
signs = difference.class.zeros(*difference.shape)
|
|
27
|
+
signs[difference.gt(0)] = 1
|
|
28
|
+
signs[difference.lt(0)] = -1
|
|
29
|
+
gradient = signs * (grad_output.to_f / count)
|
|
30
|
+
[gradient, -gradient]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Structural similarity with the standard 11x11, sigma 1.5 window.
|
|
36
|
+
class StructuralSimilarity < Autograd::Function
|
|
37
|
+
class << self
|
|
38
|
+
# Evaluates mean SSIM and stores convolution intermediates.
|
|
39
|
+
# @api private
|
|
40
|
+
def forward(context, image_a, image_b, layout:)
|
|
41
|
+
score, cache = Math::Ssim.forward(image_a, image_b, layout: layout)
|
|
42
|
+
context.save(cache)
|
|
43
|
+
image_a.class.cast(score)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Propagates the SSIM score gradient.
|
|
47
|
+
# @api private
|
|
48
|
+
def backward(context, grad_output)
|
|
49
|
+
Math::Ssim.backward(context.saved_values.first, grad_output)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Weighted L1 plus (1 - SSIM) reconstruction objective.
|
|
55
|
+
class Reconstruction < Autograd::Function
|
|
56
|
+
class << self
|
|
57
|
+
# Evaluates weighted L1 and SSIM reconstruction loss.
|
|
58
|
+
# @api private
|
|
59
|
+
def forward(context, prediction, target, ssim_lambda:, layout:)
|
|
60
|
+
unless prediction.shape == target.shape
|
|
61
|
+
raise ShapeError, "reconstruction shapes differ: #{prediction.shape.inspect} and #{target.shape.inspect}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
difference = prediction - target
|
|
65
|
+
score, ssim_cache = Math::Ssim.forward(prediction, target, layout: layout)
|
|
66
|
+
context.save(difference, prediction.size, ssim_cache, ssim_lambda)
|
|
67
|
+
prediction.class.cast(((1 - ssim_lambda) * difference.abs.mean.to_f) + (ssim_lambda * (1 - score)))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Propagates the combined reconstruction gradient.
|
|
71
|
+
# @api private
|
|
72
|
+
def backward(context, grad_output)
|
|
73
|
+
difference, count, ssim_cache, ssim_lambda = context.saved_values
|
|
74
|
+
signs = difference.class.zeros(*difference.shape)
|
|
75
|
+
signs[difference.gt(0)] = 1
|
|
76
|
+
signs[difference.lt(0)] = -1
|
|
77
|
+
l1_gradient = signs * ((1 - ssim_lambda) * grad_output.to_f / count)
|
|
78
|
+
ssim_a, ssim_b = Math::Ssim.backward(ssim_cache, grad_output)
|
|
79
|
+
[l1_gradient - (ssim_lambda * ssim_a), -l1_gradient - (ssim_lambda * ssim_b)]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Reconstruction loss with activated opacity and scale mean penalties.
|
|
85
|
+
class RegularizedReconstruction < Autograd::Function
|
|
86
|
+
class << self
|
|
87
|
+
# rubocop:disable Metrics/ParameterLists
|
|
88
|
+
def forward(context, prediction, target, opacities, scales, ssim_lambda:, opacity_reg:, scale_reg:, layout:)
|
|
89
|
+
# rubocop:enable Metrics/ParameterLists
|
|
90
|
+
difference = prediction - target
|
|
91
|
+
score, ssim_cache = Math::Ssim.forward(prediction, target, layout: layout)
|
|
92
|
+
context.save(
|
|
93
|
+
difference, prediction.size, ssim_cache, ssim_lambda,
|
|
94
|
+
opacities.shape, scales.shape, opacity_reg, scale_reg
|
|
95
|
+
)
|
|
96
|
+
reconstruction = ((1 - ssim_lambda) * difference.abs.mean.to_f) + (ssim_lambda * (1 - score))
|
|
97
|
+
prediction.class.cast(
|
|
98
|
+
reconstruction + (opacity_reg * opacities.mean.to_f) + (scale_reg * scales.mean.to_f)
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# rubocop:disable Metrics/AbcSize
|
|
103
|
+
def backward(context, grad_output)
|
|
104
|
+
difference, count, cache, ssim_lambda,
|
|
105
|
+
opacity_shape, scale_shape, opacity_reg, scale_reg = context.saved_values
|
|
106
|
+
signs = difference.class.zeros(*difference.shape)
|
|
107
|
+
signs[difference.gt(0)] = 1
|
|
108
|
+
signs[difference.lt(0)] = -1
|
|
109
|
+
l1_gradient = signs * ((1 - ssim_lambda) * grad_output.to_f / count)
|
|
110
|
+
ssim_a, ssim_b = Math::Ssim.backward(cache, grad_output)
|
|
111
|
+
opacity_gradient = difference.class.ones(*opacity_shape) *
|
|
112
|
+
opacity_reg * grad_output.to_f / opacity_shape.inject(:*)
|
|
113
|
+
scale_gradient = difference.class.ones(*scale_shape) *
|
|
114
|
+
scale_reg * grad_output.to_f / scale_shape.inject(:*)
|
|
115
|
+
[
|
|
116
|
+
l1_gradient - (ssim_lambda * ssim_a),
|
|
117
|
+
-l1_gradient - (ssim_lambda * ssim_b),
|
|
118
|
+
opacity_gradient,
|
|
119
|
+
scale_gradient
|
|
120
|
+
]
|
|
121
|
+
end
|
|
122
|
+
# rubocop:enable Metrics/AbcSize
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
module_function
|
|
127
|
+
|
|
128
|
+
# Returns mean absolute error, retaining an autograd graph when needed.
|
|
129
|
+
def l1(prediction, target)
|
|
130
|
+
return L1.apply(prediction, target) if [prediction, target].any?(Autograd::Variable)
|
|
131
|
+
|
|
132
|
+
L1.forward(Autograd::Context.new([false, false], [prediction, target]), prediction, target)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Returns mean SSIM over batches, channels, and pixels.
|
|
136
|
+
def ssim(image_a, image_b, layout: :auto)
|
|
137
|
+
if [image_a, image_b].any?(Autograd::Variable)
|
|
138
|
+
return StructuralSimilarity.apply(image_a, image_b, layout: layout)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
Math::Ssim.forward(image_a, image_b, layout: layout).first
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Returns `(1-lambda)*L1 + lambda*(1-SSIM)`.
|
|
145
|
+
def reconstruction(prediction, target, ssim_lambda: 0.2, layout: :auto)
|
|
146
|
+
raise ArgumentError, "ssim_lambda must be between 0 and 1" unless ssim_lambda.between?(0.0, 1.0)
|
|
147
|
+
|
|
148
|
+
if [prediction, target].any?(Autograd::Variable)
|
|
149
|
+
return Reconstruction.apply(
|
|
150
|
+
prediction,
|
|
151
|
+
target,
|
|
152
|
+
ssim_lambda: ssim_lambda,
|
|
153
|
+
layout: layout
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
l1_value = (prediction - target).abs.mean.to_f
|
|
158
|
+
((1 - ssim_lambda) * l1_value) + (ssim_lambda * (1 - ssim(prediction, target, layout: layout)))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Reconstruction objective plus optional MCMC opacity and scale regularizers.
|
|
162
|
+
def regularized_reconstruction(prediction, target, opacities, scales, **options)
|
|
163
|
+
RegularizedReconstruction.apply(
|
|
164
|
+
prediction,
|
|
165
|
+
target,
|
|
166
|
+
opacities,
|
|
167
|
+
scales,
|
|
168
|
+
ssim_lambda: options.fetch(:ssim_lambda, 0.2),
|
|
169
|
+
opacity_reg: options.fetch(:opacity_reg, 0.0),
|
|
170
|
+
scale_reg: options.fetch(:scale_reg, 0.0),
|
|
171
|
+
layout: options.fetch(:layout, :auto)
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Returns peak signal-to-noise ratio in decibels.
|
|
176
|
+
def psnr(prediction, target, max_value: 1.0)
|
|
177
|
+
prediction = Ops::TensorOps.data(prediction)
|
|
178
|
+
target = Ops::TensorOps.data(target)
|
|
179
|
+
unless prediction.shape == target.shape
|
|
180
|
+
raise ShapeError, "PSNR shapes differ: #{prediction.shape.inspect} and #{target.shape.inspect}"
|
|
181
|
+
end
|
|
182
|
+
raise ArgumentError, "max_value must be positive" unless max_value.positive?
|
|
183
|
+
|
|
184
|
+
mean_squared_error = ((prediction - target)**2).mean.to_f
|
|
185
|
+
return Float::INFINITY if mean_squared_error.zero?
|
|
186
|
+
|
|
187
|
+
10 * ::Math.log10((max_value**2) / mean_squared_error)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
# Dataset containers, losses, fitting utilities, and training loops.
|
|
5
|
+
module Training
|
|
6
|
+
# In-memory multi-view training scene.
|
|
7
|
+
class Scene
|
|
8
|
+
attr_reader :colors, :height, :images, :intrinsics, :names, :points,
|
|
9
|
+
:scene_scale, :viewmats, :width
|
|
10
|
+
|
|
11
|
+
# rubocop:disable Metrics/ParameterLists
|
|
12
|
+
def initialize(viewmats:, intrinsics:, images:, points:, colors:, names: nil, scene_scale: nil)
|
|
13
|
+
# rubocop:enable Metrics/ParameterLists
|
|
14
|
+
@viewmats = viewmats
|
|
15
|
+
@intrinsics = intrinsics
|
|
16
|
+
@images = images
|
|
17
|
+
@points = points
|
|
18
|
+
@colors = colors
|
|
19
|
+
@names = names || Array.new(images.shape[0]) { |index| format("camera_%03d", index) }
|
|
20
|
+
@height = images.shape[1]
|
|
21
|
+
@width = images.shape[2]
|
|
22
|
+
validate!
|
|
23
|
+
@scene_scale = scene_scale || infer_scene_scale
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Loads a COLMAP sparse model and its corresponding RGB images.
|
|
27
|
+
#
|
|
28
|
+
# @param path [String] dataset root containing `sparse` and `images`
|
|
29
|
+
# @param data_factor [Numeric] image/intrinsics downsampling factor
|
|
30
|
+
# @return [Scene]
|
|
31
|
+
# rubocop:disable Metrics/AbcSize
|
|
32
|
+
def self.from_colmap(path, data_factor: 1)
|
|
33
|
+
dataset = IO::Colmap.read(path, data_factor: data_factor)
|
|
34
|
+
records = dataset.images.values.sort_by(&:name)
|
|
35
|
+
views = Numo::SFloat.cast(stack(records.map(&:world_to_camera)))
|
|
36
|
+
intrinsics = Numo::SFloat.cast(
|
|
37
|
+
stack(records.map { |record| dataset.cameras.fetch(record.camera_id).intrinsics })
|
|
38
|
+
)
|
|
39
|
+
image_directory = resolve_image_directory(path, data_factor)
|
|
40
|
+
images = Numo::SFloat.cast(
|
|
41
|
+
stack(records.map { |record| IO::Image.read(File.join(image_directory, record.name)) })
|
|
42
|
+
)
|
|
43
|
+
points = dataset.points3d.values.sort_by(&:id)
|
|
44
|
+
new(
|
|
45
|
+
viewmats: views,
|
|
46
|
+
intrinsics: intrinsics,
|
|
47
|
+
images: images,
|
|
48
|
+
points: Numo::SFloat.cast(stack_rows(points.map(&:xyz))),
|
|
49
|
+
colors: Numo::SFloat.cast(stack_rows(points.map { |point| Numo::DFloat.cast(point.rgb) / 255.0 })),
|
|
50
|
+
names: records.map(&:name)
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
# rubocop:enable Metrics/AbcSize
|
|
54
|
+
|
|
55
|
+
# Number of registered training views.
|
|
56
|
+
#
|
|
57
|
+
# @return [Integer]
|
|
58
|
+
def camera_count
|
|
59
|
+
images.shape[0]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def stack(arrays)
|
|
66
|
+
raise ArgumentError, "cannot stack an empty array list" if arrays.empty?
|
|
67
|
+
|
|
68
|
+
type = arrays.first.class
|
|
69
|
+
output = type.zeros(*([arrays.length] + arrays.first.shape))
|
|
70
|
+
arrays.each_with_index do |array, index|
|
|
71
|
+
output[*([index] + Array.new(array.ndim, true))] = array
|
|
72
|
+
end
|
|
73
|
+
output
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stack_rows(rows)
|
|
77
|
+
raise ArgumentError, "COLMAP point cloud is empty" if rows.empty?
|
|
78
|
+
|
|
79
|
+
rows.first.class.cast(rows.map(&:to_a))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_image_directory(path, factor)
|
|
83
|
+
scaled = File.join(path, "images_#{factor}")
|
|
84
|
+
return scaled if factor != 1 && Dir.exist?(scaled)
|
|
85
|
+
|
|
86
|
+
File.join(path, "images")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def validate!
|
|
93
|
+
cameras = images.shape[0]
|
|
94
|
+
valid = images.ndim == 4 && images.shape[-1] == 3 &&
|
|
95
|
+
viewmats.shape == [cameras, 4, 4] &&
|
|
96
|
+
intrinsics.shape == [cameras, 3, 3] &&
|
|
97
|
+
points.ndim == 2 && points.shape[1] == 3 &&
|
|
98
|
+
colors.shape == [points.shape[0], 3] &&
|
|
99
|
+
names.length == cameras
|
|
100
|
+
raise ShapeError, "inconsistent multi-view scene arrays" unless valid
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def infer_scene_scale
|
|
104
|
+
centers = viewmats.class.zeros(viewmats.shape[0], 3)
|
|
105
|
+
viewmats.shape[0].times do |index|
|
|
106
|
+
rotation = viewmats[index, 0...3, 0...3]
|
|
107
|
+
translation = viewmats[index, 0...3, 3]
|
|
108
|
+
centers[index, true] = -translation.dot(rotation)
|
|
109
|
+
end
|
|
110
|
+
center = centers.mean(axis: 0)
|
|
111
|
+
scale = Numo::NMath.sqrt(((centers - center)**2).sum(axis: 1)).max.to_f
|
|
112
|
+
scale.positive? ? scale : 1.0
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Gsplat
|
|
6
|
+
module Training
|
|
7
|
+
# End-to-end multi-view Gaussian training loop.
|
|
8
|
+
class Trainer
|
|
9
|
+
# Final step, before/after metrics, and periodic history.
|
|
10
|
+
Result = Data.define(:step, :initial_metrics, :final_metrics, :history)
|
|
11
|
+
# Mapping from parameter names to {Config} learning-rate attributes.
|
|
12
|
+
LEARNING_RATES = {
|
|
13
|
+
means: :means_lr,
|
|
14
|
+
scales: :scales_lr,
|
|
15
|
+
quats: :quats_lr,
|
|
16
|
+
opacities: :opacities_lr,
|
|
17
|
+
sh0: :sh0_lr,
|
|
18
|
+
shN: :shN_lr
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :config, :optimizers, :params, :scene, :step, :strategy, :strategy_state
|
|
22
|
+
|
|
23
|
+
def initialize(scene:, config: Config.new, params: nil, strategy: nil)
|
|
24
|
+
@scene = scene
|
|
25
|
+
@config = config
|
|
26
|
+
@rng = Random.new(config.seed)
|
|
27
|
+
@params = params || Utils.init_from_points(
|
|
28
|
+
scene.points,
|
|
29
|
+
scene.colors,
|
|
30
|
+
sh_degree: config.sh_degree,
|
|
31
|
+
init_opacity: config.init_opacity,
|
|
32
|
+
init_scale: config.init_scale,
|
|
33
|
+
rng: @rng
|
|
34
|
+
)
|
|
35
|
+
@optimizers = build_optimizers
|
|
36
|
+
@strategy = strategy || Strategy::Default.new
|
|
37
|
+
@strategy.check_sanity(@params, @optimizers)
|
|
38
|
+
@strategy_state = @strategy.initialize_state(scene_scale: scene.scene_scale)
|
|
39
|
+
@scheduler = Optim::ExponentialLR.new(
|
|
40
|
+
@optimizers.fetch(:means),
|
|
41
|
+
lr_final: config.means_lr_final * scene.scene_scale,
|
|
42
|
+
max_steps: config.max_steps
|
|
43
|
+
)
|
|
44
|
+
@step = 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Optimizes until the configured maximum or for an additional step count.
|
|
48
|
+
#
|
|
49
|
+
# @param steps [Integer, nil]
|
|
50
|
+
# @return [Result]
|
|
51
|
+
def train(steps: nil)
|
|
52
|
+
target_step = steps ? step + steps : config.max_steps
|
|
53
|
+
initial_metrics = evaluate
|
|
54
|
+
history = []
|
|
55
|
+
while step < target_step
|
|
56
|
+
@step += 1
|
|
57
|
+
loss = training_step
|
|
58
|
+
history << { step: step, loss: loss } if log_due?
|
|
59
|
+
evaluate_and_log!(history) if config.eval_steps.include?(step)
|
|
60
|
+
save_checkpoint! if config.save_steps.include?(step)
|
|
61
|
+
end
|
|
62
|
+
Result.new(
|
|
63
|
+
step: step,
|
|
64
|
+
initial_metrics: initial_metrics,
|
|
65
|
+
final_metrics: evaluate,
|
|
66
|
+
history: history.freeze
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Renders every view without graph recording and reports PSNR/SSIM.
|
|
71
|
+
#
|
|
72
|
+
# @return [Hash{Symbol=>Float}]
|
|
73
|
+
def evaluate
|
|
74
|
+
Autograd.no_grad do
|
|
75
|
+
rendered, = render((0...scene.camera_count).to_a, sh_degree_for(step))
|
|
76
|
+
values = Ops::TensorOps.data(rendered)
|
|
77
|
+
{
|
|
78
|
+
psnr: Losses.psnr(values, scene.images),
|
|
79
|
+
ssim: Losses.ssim(values, scene.images, layout: :nhwc)
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Saves current parameters, optimizer moments, step, and config.
|
|
85
|
+
#
|
|
86
|
+
# @param path [String, nil] generated from `config.output_dir` when nil
|
|
87
|
+
# @return [String] written path
|
|
88
|
+
def save_checkpoint!(path = nil)
|
|
89
|
+
path ||= File.join(config.output_dir, "checkpoints", format("step_%06d.npz", step))
|
|
90
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
91
|
+
IO::Checkpoint.save(
|
|
92
|
+
path,
|
|
93
|
+
params: params,
|
|
94
|
+
optimizers: optimizers,
|
|
95
|
+
step: step,
|
|
96
|
+
config: config.to_h
|
|
97
|
+
)
|
|
98
|
+
path
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Restores parameters, optimizer moments, step, and scheduler position.
|
|
102
|
+
#
|
|
103
|
+
# @param source [String, #read]
|
|
104
|
+
# @return [IO::Checkpoint::Snapshot]
|
|
105
|
+
def load_checkpoint!(source)
|
|
106
|
+
snapshot = IO::Checkpoint.restore!(source, params: params, optimizers: optimizers)
|
|
107
|
+
@step = snapshot.step
|
|
108
|
+
@scheduler.step(step)
|
|
109
|
+
snapshot
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# rubocop:disable Metrics/AbcSize
|
|
115
|
+
def training_step
|
|
116
|
+
indices = batch_indices
|
|
117
|
+
rendered, _alphas, info = render(indices, sh_degree_for(step))
|
|
118
|
+
target = scene.images[indices, true, true, true].dup
|
|
119
|
+
strategy.step_pre_backward(
|
|
120
|
+
params: params, optimizers: optimizers, state: strategy_state, step: step, info: info
|
|
121
|
+
)
|
|
122
|
+
activated = activated_params
|
|
123
|
+
loss = Losses.regularized_reconstruction(
|
|
124
|
+
rendered,
|
|
125
|
+
target,
|
|
126
|
+
activated.fetch(:opacities),
|
|
127
|
+
activated.fetch(:scales),
|
|
128
|
+
ssim_lambda: config.ssim_lambda,
|
|
129
|
+
opacity_reg: config.opacity_reg,
|
|
130
|
+
scale_reg: config.scale_reg,
|
|
131
|
+
layout: :nhwc
|
|
132
|
+
)
|
|
133
|
+
loss.backward
|
|
134
|
+
strategy.step_post_backward(
|
|
135
|
+
params: params,
|
|
136
|
+
optimizers: optimizers,
|
|
137
|
+
state: strategy_state,
|
|
138
|
+
step: step,
|
|
139
|
+
info: info,
|
|
140
|
+
lr: optimizers.fetch(:means).learning_rate
|
|
141
|
+
)
|
|
142
|
+
optimizers.each_value(&:step)
|
|
143
|
+
optimizers.each_value(&:zero_grad!)
|
|
144
|
+
@scheduler.step(step)
|
|
145
|
+
loss.data.to_f
|
|
146
|
+
end
|
|
147
|
+
# rubocop:enable Metrics/AbcSize
|
|
148
|
+
|
|
149
|
+
# rubocop:disable Metrics/AbcSize
|
|
150
|
+
def render(indices, degree)
|
|
151
|
+
activated = activated_params
|
|
152
|
+
backgrounds = random_background(indices.length, scene.images.class)
|
|
153
|
+
renderer = %i[two_d 2dgs].include?(config.model_type.to_sym) ? :rasterization_2dgs : :rasterization
|
|
154
|
+
outputs = Gsplat.public_send(
|
|
155
|
+
renderer,
|
|
156
|
+
means: params.fetch(:means),
|
|
157
|
+
quats: activated.fetch(:quats),
|
|
158
|
+
scales: activated.fetch(:scales),
|
|
159
|
+
opacities: activated.fetch(:opacities),
|
|
160
|
+
colors: activated.fetch(:colors),
|
|
161
|
+
viewmats: scene.viewmats[indices, true, true].dup,
|
|
162
|
+
ks: scene.intrinsics[indices, true, true].dup,
|
|
163
|
+
width: scene.width,
|
|
164
|
+
height: scene.height,
|
|
165
|
+
sh_degree: degree,
|
|
166
|
+
backgrounds: backgrounds,
|
|
167
|
+
near_plane: config.near_plane,
|
|
168
|
+
far_plane: config.far_plane,
|
|
169
|
+
tile_size: config.tile_size,
|
|
170
|
+
rasterize_mode: config.rasterize_mode,
|
|
171
|
+
absgrad: strategy.respond_to?(:absgrad) && strategy.absgrad
|
|
172
|
+
)
|
|
173
|
+
return outputs if renderer == :rasterization
|
|
174
|
+
|
|
175
|
+
[outputs[0], outputs[1], outputs[6]]
|
|
176
|
+
end
|
|
177
|
+
# rubocop:enable Metrics/AbcSize
|
|
178
|
+
|
|
179
|
+
def activated_params
|
|
180
|
+
sh0 = params.fetch(:sh0)
|
|
181
|
+
shn = params.fetch(:shN)
|
|
182
|
+
colors = if shn.data.shape[1].zero?
|
|
183
|
+
sh0
|
|
184
|
+
else
|
|
185
|
+
Ops::TensorOps.apply(Ops::ConcatCoefficients, sh0, shn)
|
|
186
|
+
end
|
|
187
|
+
{
|
|
188
|
+
quats: Ops::TensorOps.apply(Ops::NormalizeQuaternion, params.fetch(:quats)),
|
|
189
|
+
scales: Ops::TensorOps.apply(Ops::Exp, params.fetch(:scales)),
|
|
190
|
+
opacities: Ops::TensorOps.apply(Ops::Sigmoid, params.fetch(:opacities)),
|
|
191
|
+
colors: colors
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def build_optimizers
|
|
196
|
+
LEARNING_RATES.to_h do |name, config_name|
|
|
197
|
+
rate = config.public_send(config_name)
|
|
198
|
+
rate *= scene.scene_scale if name == :means
|
|
199
|
+
[name, Optim::Adam.new(params.fetch(name), lr: rate)]
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def batch_indices
|
|
204
|
+
Array.new(config.batch_size) { @rng.rand(scene.camera_count) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def sh_degree_for(current_step)
|
|
208
|
+
[config.sh_degree, current_step / config.sh_degree_interval].min
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def random_background(count, type)
|
|
212
|
+
return nil unless config.random_background
|
|
213
|
+
|
|
214
|
+
type.cast(Array.new(count * 3) { @rng.rand }).reshape(count, 3)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def log_due?
|
|
218
|
+
(step % config.log_every).zero?
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def evaluate_and_log!(history)
|
|
222
|
+
metrics = evaluate
|
|
223
|
+
history << { step: step, **metrics }
|
|
224
|
+
Gsplat.logger.info(
|
|
225
|
+
format(
|
|
226
|
+
"step=%<step>d psnr=%<psnr>.3f ssim=%<ssim>.5f gaussians=%<gaussians>d",
|
|
227
|
+
step: step,
|
|
228
|
+
psnr: metrics[:psnr],
|
|
229
|
+
ssim: metrics[:ssim],
|
|
230
|
+
gaussians: params.fetch(:means).data.shape[0]
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|