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,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../backend/ruby/spherical_harmonics"
|
|
4
|
+
|
|
5
|
+
# Differentiable real spherical harmonics operation.
|
|
6
|
+
module Gsplat
|
|
7
|
+
module Ops
|
|
8
|
+
# Evaluates real SH bases through degree four.
|
|
9
|
+
class SphericalHarmonics < Autograd::Function
|
|
10
|
+
class << self
|
|
11
|
+
# Evaluates SH and stores inputs for its analytic VJP.
|
|
12
|
+
# @api private
|
|
13
|
+
def forward(context, degree, directions, coefficients, masks: nil)
|
|
14
|
+
context.save(degree, directions, coefficients, masks)
|
|
15
|
+
Backend.dispatch(
|
|
16
|
+
:spherical_harmonics_forward,
|
|
17
|
+
degree,
|
|
18
|
+
directions,
|
|
19
|
+
coefficients,
|
|
20
|
+
masks: masks
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Propagates an SH value gradient to directions and coefficients.
|
|
25
|
+
# @api private
|
|
26
|
+
def backward(context, grad_output)
|
|
27
|
+
degree, directions, coefficients, masks = context.saved_values
|
|
28
|
+
grad_directions, grad_coefficients = Backend.dispatch(
|
|
29
|
+
:spherical_harmonics_backward,
|
|
30
|
+
degree,
|
|
31
|
+
directions,
|
|
32
|
+
coefficients,
|
|
33
|
+
grad_output,
|
|
34
|
+
masks: masks,
|
|
35
|
+
grad_dirs: context.needs_input_grad.fetch(1),
|
|
36
|
+
grad_coeffs: context.needs_input_grad.fetch(2)
|
|
37
|
+
)
|
|
38
|
+
[nil, grad_directions, grad_coefficients]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Evaluates real spherical harmonics.
|
|
46
|
+
#
|
|
47
|
+
# @param degree [Integer] degree 0..4
|
|
48
|
+
# @param directions [Autograd::Variable, Numo::NArray] [...,3]
|
|
49
|
+
# @param coefficients [Autograd::Variable, Numo::NArray] [...,K,D], K >= (degree+1)^2
|
|
50
|
+
# @param masks [Numo::Bit, nil] [...]
|
|
51
|
+
# @return [Autograd::Variable, Numo::NArray] [...,D]
|
|
52
|
+
def spherical_harmonics(degree, directions, coefficients, masks: nil)
|
|
53
|
+
if [directions, coefficients].any?(Autograd::Variable)
|
|
54
|
+
return Ops::SphericalHarmonics.apply(degree, directions, coefficients, masks: masks)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Backend.dispatch(:spherical_harmonics_forward, degree, directions, coefficients, masks: masks)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Backend.register(
|
|
62
|
+
:spherical_harmonics_forward,
|
|
63
|
+
:ruby,
|
|
64
|
+
Backend::RubySphericalHarmonics.method(:forward)
|
|
65
|
+
)
|
|
66
|
+
Backend.register(
|
|
67
|
+
:spherical_harmonics_backward,
|
|
68
|
+
:ruby,
|
|
69
|
+
Backend::RubySphericalHarmonics.method(:backward)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
module Ops
|
|
5
|
+
# Internal helpers for composing coarse differentiable operations.
|
|
6
|
+
module TensorOps
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def apply(function, *inputs, **)
|
|
10
|
+
return function.apply(*inputs, **) if inputs.any?(Autograd::Variable)
|
|
11
|
+
|
|
12
|
+
needs_grad = Array.new(inputs.length, false)
|
|
13
|
+
function.forward(Autograd::Context.new(needs_grad), *inputs, **)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def data(value)
|
|
17
|
+
value.is_a?(Autograd::Variable) ? value.data : value
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Adds a leading camera dimension to a tensor.
|
|
22
|
+
class CameraBroadcast < Autograd::Function
|
|
23
|
+
class << self
|
|
24
|
+
def forward(context, value, camera_count)
|
|
25
|
+
valid_count = camera_count.is_a?(Integer) && camera_count.positive?
|
|
26
|
+
raise ArgumentError, "camera_count must be positive" unless valid_count
|
|
27
|
+
|
|
28
|
+
context.save(value.shape)
|
|
29
|
+
output = value.class.zeros(*([camera_count] + value.shape))
|
|
30
|
+
camera_count.times do |camera_index|
|
|
31
|
+
output[*([camera_index] + Array.new(value.ndim, true))] = value
|
|
32
|
+
end
|
|
33
|
+
output
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def backward(_context, gradient)
|
|
37
|
+
[gradient.sum(axis: 0), nil]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Computes camera-to-Gaussian viewing directions from world-to-camera matrices.
|
|
43
|
+
class CameraDirections < Autograd::Function
|
|
44
|
+
class << self
|
|
45
|
+
def forward(context, means, viewmats)
|
|
46
|
+
unless means.ndim == 2 && means.shape[-1] == 3 &&
|
|
47
|
+
viewmats.ndim == 3 && viewmats.shape[1..] == [4, 4]
|
|
48
|
+
raise ShapeError,
|
|
49
|
+
"expected means [N,3] and viewmats [C,4,4], " \
|
|
50
|
+
"got #{means.shape.inspect} and #{viewmats.shape.inspect}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
output = means.class.zeros(viewmats.shape[0], means.shape[0], 3)
|
|
54
|
+
viewmats.shape[0].times do |camera_index|
|
|
55
|
+
rotation = means.class.cast(viewmats[camera_index, 0...3, 0...3])
|
|
56
|
+
translation = means.class.cast(viewmats[camera_index, 0...3, 3])
|
|
57
|
+
camera_center = -translation.dot(rotation)
|
|
58
|
+
output[camera_index, true, true] = means - camera_center
|
|
59
|
+
end
|
|
60
|
+
context.save(means.shape)
|
|
61
|
+
output
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def backward(_context, gradient)
|
|
65
|
+
[gradient.sum(axis: 0), nil]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Adds a singleton feature axis to depths.
|
|
71
|
+
class DepthFeatures < Autograd::Function
|
|
72
|
+
class << self
|
|
73
|
+
def forward(_context, depths)
|
|
74
|
+
depths.reshape(*(depths.shape + [1]))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def backward(_context, gradient)
|
|
78
|
+
[gradient.reshape(*gradient.shape[0...-1])]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Concatenates per-Gaussian features and depth.
|
|
84
|
+
class ConcatDepth < Autograd::Function
|
|
85
|
+
class << self
|
|
86
|
+
def forward(context, features, depth_features)
|
|
87
|
+
unless features.shape[0...-1] == depth_features.shape[0...-1] && depth_features.shape[-1] == 1
|
|
88
|
+
raise ShapeError,
|
|
89
|
+
"expected features [...,D] and depths [...,1], " \
|
|
90
|
+
"got #{features.shape.inspect} and #{depth_features.shape.inspect}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
feature_count = features.shape[-1]
|
|
94
|
+
output = features.class.zeros(*(features.shape[0...-1] + [feature_count + 1]))
|
|
95
|
+
output[*Array.new(features.ndim - 1, true), 0...feature_count] = features
|
|
96
|
+
output[*Array.new(features.ndim - 1, true), feature_count] = depth_features[
|
|
97
|
+
*Array.new(depth_features.ndim - 1, true), 0
|
|
98
|
+
]
|
|
99
|
+
context.save(feature_count)
|
|
100
|
+
output
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def backward(context, gradient)
|
|
104
|
+
feature_count = context.saved_values.fetch(0)
|
|
105
|
+
leading = Array.new(gradient.ndim - 1, true)
|
|
106
|
+
[
|
|
107
|
+
gradient[*leading, 0...feature_count].dup,
|
|
108
|
+
gradient[*leading, feature_count].reshape(*(gradient.shape[0...-1] + [1]))
|
|
109
|
+
]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Extracts a contiguous interval from the last tensor axis.
|
|
115
|
+
class FeatureSlice < Autograd::Function
|
|
116
|
+
class << self
|
|
117
|
+
def forward(context, value, range)
|
|
118
|
+
unless range.is_a?(Range) && range.begin.is_a?(Integer) && range.end.is_a?(Integer)
|
|
119
|
+
raise ArgumentError, "feature range must have integer bounds"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
stop = range.exclude_end? ? range.end : range.end + 1
|
|
123
|
+
unless range.begin.between?(0, value.shape[-1]) && stop.between?(range.begin, value.shape[-1])
|
|
124
|
+
raise ShapeError, "feature range #{range.inspect} is outside #{value.shape.inspect}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
context.save(value.shape, range.begin, stop)
|
|
128
|
+
value[*Array.new(value.ndim - 1, true), range.begin...stop].dup
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def backward(context, gradient)
|
|
132
|
+
shape, start, stop = context.saved_values
|
|
133
|
+
output = gradient.class.zeros(*shape)
|
|
134
|
+
output[*Array.new(output.ndim - 1, true), start...stop] = gradient
|
|
135
|
+
[output, nil]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Concatenates any number of tensors along their final feature axis.
|
|
141
|
+
class ConcatFeatures < Autograd::Function
|
|
142
|
+
class << self
|
|
143
|
+
def forward(context, *values)
|
|
144
|
+
raise ArgumentError, "at least one feature tensor is required" if values.empty?
|
|
145
|
+
|
|
146
|
+
leading_shape = values.first.shape[0...-1]
|
|
147
|
+
unless values.all? { |value| value.shape[0...-1] == leading_shape }
|
|
148
|
+
raise ShapeError, "feature tensors must share leading dimensions"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
sizes = values.map { |value| value.shape[-1] }
|
|
152
|
+
output = values.first.class.zeros(*(leading_shape + [sizes.sum]))
|
|
153
|
+
cursor = 0
|
|
154
|
+
values.each_with_index do |value, index|
|
|
155
|
+
output[*Array.new(output.ndim - 1, true), cursor...(cursor + sizes[index])] = value
|
|
156
|
+
cursor += sizes[index]
|
|
157
|
+
end
|
|
158
|
+
context.save(sizes)
|
|
159
|
+
output
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def backward(context, gradient)
|
|
163
|
+
cursor = 0
|
|
164
|
+
context.saved_values.first.map do |size|
|
|
165
|
+
value = gradient[*Array.new(gradient.ndim - 1, true), cursor...(cursor + size)].dup
|
|
166
|
+
cursor += size
|
|
167
|
+
value
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
module Ops
|
|
5
|
+
# Adds an offset and applies a lower clamp.
|
|
6
|
+
class AddClampMin < Autograd::Function
|
|
7
|
+
class << self
|
|
8
|
+
def forward(context, value, offset:, minimum:)
|
|
9
|
+
shifted = value + offset
|
|
10
|
+
context.save(shifted.gt(minimum))
|
|
11
|
+
below = shifted.lt(minimum)
|
|
12
|
+
shifted[below] = minimum if below.any?
|
|
13
|
+
shifted
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# rubocop:disable Metrics/AbcSize
|
|
17
|
+
def backward(context, gradient)
|
|
18
|
+
mask = gradient.class.cast(context.saved_values.fetch(0))
|
|
19
|
+
[gradient * mask]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Elementwise multiplication with VJPs for both inputs.
|
|
25
|
+
class Multiply < Autograd::Function
|
|
26
|
+
class << self
|
|
27
|
+
def forward(context, left, right)
|
|
28
|
+
unless left.shape == right.shape
|
|
29
|
+
raise ShapeError, "multiply shape mismatch: #{left.shape.inspect} and #{right.shape.inspect}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
context.save(left, right)
|
|
33
|
+
left * right
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def backward(context, gradient)
|
|
37
|
+
left, right = context.saved_values
|
|
38
|
+
[gradient * right, gradient * left]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Elementwise exponential activation.
|
|
44
|
+
class Exp < Autograd::Function
|
|
45
|
+
class << self
|
|
46
|
+
def forward(context, value)
|
|
47
|
+
output = Numo::NMath.exp(value)
|
|
48
|
+
context.save(output)
|
|
49
|
+
output
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def backward(context, gradient)
|
|
53
|
+
[gradient * context.saved_values.first]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Elementwise logistic activation.
|
|
59
|
+
class Sigmoid < Autograd::Function
|
|
60
|
+
class << self
|
|
61
|
+
def forward(context, value)
|
|
62
|
+
output = 1.0 / (1.0 + Numo::NMath.exp(-value))
|
|
63
|
+
context.save(output)
|
|
64
|
+
output
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def backward(context, gradient)
|
|
68
|
+
output = context.saved_values.first
|
|
69
|
+
[gradient * output * (1.0 - output)]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Last-axis quaternion normalization.
|
|
75
|
+
class NormalizeQuaternion < Autograd::Function
|
|
76
|
+
class << self
|
|
77
|
+
def forward(context, value)
|
|
78
|
+
context.save(value)
|
|
79
|
+
Math::Quaternion.normalize(value)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def backward(context, gradient)
|
|
83
|
+
[Math::Quaternion.normalize_vjp(context.saved_values.first, gradient)]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Joins the direct-current and remaining SH coefficient blocks.
|
|
89
|
+
class ConcatCoefficients < Autograd::Function
|
|
90
|
+
class << self
|
|
91
|
+
def forward(context, direct, rest)
|
|
92
|
+
unless direct.ndim == 3 && rest.ndim == 3 &&
|
|
93
|
+
direct.shape[0] == rest.shape[0] && direct.shape[2] == rest.shape[2]
|
|
94
|
+
raise ShapeError, "cannot concatenate SH shapes #{direct.shape.inspect} and #{rest.shape.inspect}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
direct_count = direct.shape[1]
|
|
98
|
+
output = direct.class.zeros(direct.shape[0], direct_count + rest.shape[1], direct.shape[2])
|
|
99
|
+
output[true, 0...direct_count, true] = direct
|
|
100
|
+
output[true, direct_count...output.shape[1], true] = rest
|
|
101
|
+
context.save(direct_count)
|
|
102
|
+
output
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def backward(context, gradient)
|
|
106
|
+
direct_count = context.saved_values.first
|
|
107
|
+
[
|
|
108
|
+
gradient[true, 0...direct_count, true].dup,
|
|
109
|
+
gradient[true, direct_count...gradient.shape[1], true].dup
|
|
110
|
+
]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Divides one selected rendered depth channel by accumulated alpha.
|
|
116
|
+
class NormalizeDepth < Autograd::Function
|
|
117
|
+
class << self
|
|
118
|
+
def forward(context, rendered, alphas, depth_index:)
|
|
119
|
+
expected = rendered.shape[0...-1] + [1]
|
|
120
|
+
unless alphas.shape == expected
|
|
121
|
+
raise ShapeError, "expected alphas #{expected.inspect}, got #{alphas.shape.inspect}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
alpha = alphas[*Array.new(alphas.ndim - 1, true).push(0)]
|
|
125
|
+
depth = rendered[*Array.new(rendered.ndim - 1, true).push(depth_index)]
|
|
126
|
+
valid = alpha.gt(0)
|
|
127
|
+
output = rendered.dup
|
|
128
|
+
normalized = rendered.class.zeros(*alpha.shape)
|
|
129
|
+
normalized[valid] = depth[valid] / alpha[valid] if valid.any?
|
|
130
|
+
output[*Array.new(output.ndim - 1, true), depth_index] = normalized
|
|
131
|
+
context.save(alpha, depth, valid, depth_index)
|
|
132
|
+
output
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def backward(context, gradient)
|
|
136
|
+
alpha, depth, valid, depth_index = context.saved_values
|
|
137
|
+
grad_rendered = gradient.dup
|
|
138
|
+
grad_depth = gradient[*Array.new(gradient.ndim - 1, true).push(depth_index)]
|
|
139
|
+
normalized_grad = gradient.class.zeros(*alpha.shape)
|
|
140
|
+
normalized_grad[valid] = grad_depth[valid] / alpha[valid] if valid.any?
|
|
141
|
+
grad_rendered[*Array.new(gradient.ndim - 1, true), depth_index] = normalized_grad
|
|
142
|
+
grad_alpha = gradient.class.zeros(*alpha.shape)
|
|
143
|
+
grad_alpha[valid] = -grad_depth[valid] * depth[valid] / (alpha[valid]**2) if valid.any?
|
|
144
|
+
[grad_rendered, grad_alpha.reshape(*(alpha.shape + [1]))]
|
|
145
|
+
end
|
|
146
|
+
# rubocop:enable Metrics/AbcSize
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
# Optimizers and learning-rate schedules for autograd Variables.
|
|
5
|
+
module Optim
|
|
6
|
+
# Dense Adam optimizer with editable first-axis state for densification.
|
|
7
|
+
class Adam
|
|
8
|
+
# Immutable parameter-group configuration.
|
|
9
|
+
Group = Data.define(:name, :variable, :lr, :eps)
|
|
10
|
+
# Adam step count and first/second moments.
|
|
11
|
+
State = Data.define(:step, :exp_avg, :exp_avg_sq)
|
|
12
|
+
|
|
13
|
+
attr_reader :beta1, :beta2, :groups
|
|
14
|
+
|
|
15
|
+
# rubocop:disable Naming/MethodParameterName
|
|
16
|
+
def initialize(groups = nil, lr: 1e-3, betas: [0.9, 0.999], eps: 1e-15, **named_groups)
|
|
17
|
+
# rubocop:enable Naming/MethodParameterName
|
|
18
|
+
raise ArgumentError, "provide positional or named groups, not both" if groups && !named_groups.empty?
|
|
19
|
+
|
|
20
|
+
groups ||= named_groups
|
|
21
|
+
@beta1, @beta2 = betas
|
|
22
|
+
validate_hyperparameters!(lr, eps)
|
|
23
|
+
@groups = normalize_groups(groups, lr, eps)
|
|
24
|
+
@states = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# rubocop:disable Metrics/AbcSize
|
|
28
|
+
def step
|
|
29
|
+
groups.each_value do |group|
|
|
30
|
+
gradient = group.variable.grad
|
|
31
|
+
next unless gradient
|
|
32
|
+
|
|
33
|
+
state = state_for(group.name)
|
|
34
|
+
next_step = state.step + 1
|
|
35
|
+
exp_avg = (beta1 * state.exp_avg) + ((1 - beta1) * gradient)
|
|
36
|
+
exp_avg_sq = (beta2 * state.exp_avg_sq) + ((1 - beta2) * (gradient**2))
|
|
37
|
+
corrected_mean = exp_avg / (1 - (beta1**next_step))
|
|
38
|
+
corrected_variance = exp_avg_sq / (1 - (beta2**next_step))
|
|
39
|
+
update = group.lr * corrected_mean / ((corrected_variance**0.5) + group.eps)
|
|
40
|
+
group.variable.data[] = group.variable.data - update
|
|
41
|
+
@states[group.name] = State.new(
|
|
42
|
+
step: next_step,
|
|
43
|
+
exp_avg: exp_avg,
|
|
44
|
+
exp_avg_sq: exp_avg_sq
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
# rubocop:enable Metrics/AbcSize
|
|
50
|
+
|
|
51
|
+
def zero_grad!
|
|
52
|
+
groups.each_value { |group| group.variable.zero_grad! }
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns lazily initialized moment state for one or all groups.
|
|
57
|
+
#
|
|
58
|
+
# @return [State, Hash{Symbol=>State}]
|
|
59
|
+
def state(name = nil)
|
|
60
|
+
return state_for(name.to_sym) if name
|
|
61
|
+
return state_for(groups.keys.first) if groups.length == 1
|
|
62
|
+
|
|
63
|
+
groups.keys.to_h { |key| [key, state_for(key)] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the current learning rate for one or all groups.
|
|
67
|
+
#
|
|
68
|
+
# @return [Numeric, Hash{Symbol=>Numeric}]
|
|
69
|
+
def learning_rate(name = nil)
|
|
70
|
+
return groups.fetch(name.to_sym).lr if name
|
|
71
|
+
return groups.values.first.lr if groups.length == 1
|
|
72
|
+
|
|
73
|
+
groups.transform_values(&:lr)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def learning_rate=(value)
|
|
77
|
+
raise ArgumentError, "learning rate must be positive" unless value.positive?
|
|
78
|
+
|
|
79
|
+
@groups = groups.transform_values do |group|
|
|
80
|
+
Group.new(name: group.name, variable: group.variable, lr: value, eps: group.eps)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def set_learning_rate(name, value)
|
|
85
|
+
raise ArgumentError, "learning rate must be positive" unless value.positive?
|
|
86
|
+
|
|
87
|
+
key = name.to_sym
|
|
88
|
+
group = groups.fetch(key)
|
|
89
|
+
@groups[key] = Group.new(name: key, variable: group.variable, lr: value, eps: group.eps)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Selects first-axis optimizer rows after pruning parameters.
|
|
93
|
+
#
|
|
94
|
+
# @param selection [Numo::Bit, Array<Integer>, Range]
|
|
95
|
+
# @return [self]
|
|
96
|
+
def select!(selection)
|
|
97
|
+
groups.each_key do |name|
|
|
98
|
+
current = state_for(name)
|
|
99
|
+
@states[name] = State.new(
|
|
100
|
+
step: current.step,
|
|
101
|
+
exp_avg: select_first_axis(current.exp_avg, selection),
|
|
102
|
+
exp_avg_sq: select_first_axis(current.exp_avg_sq, selection)
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
self
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def append!(count)
|
|
109
|
+
raise ArgumentError, "append count must be non-negative" unless count.is_a?(Integer) && !count.negative?
|
|
110
|
+
return self if count.zero?
|
|
111
|
+
|
|
112
|
+
groups.each_key do |name|
|
|
113
|
+
current = state_for(name)
|
|
114
|
+
@states[name] = State.new(
|
|
115
|
+
step: current.step,
|
|
116
|
+
exp_avg: append_zeros(current.exp_avg, count),
|
|
117
|
+
exp_avg_sq: append_zeros(current.exp_avg_sq, count)
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
self
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Clears moments at selected first-axis rows.
|
|
124
|
+
#
|
|
125
|
+
# @param indices [Integer, Array<Integer>, Range]
|
|
126
|
+
# @return [self]
|
|
127
|
+
def zero_state_at!(indices)
|
|
128
|
+
groups.each_key do |name|
|
|
129
|
+
current = state_for(name)
|
|
130
|
+
index = [indices] + Array.new(current.exp_avg.ndim - 1, true)
|
|
131
|
+
current.exp_avg[*index] = 0
|
|
132
|
+
current.exp_avg_sq[*index] = 0
|
|
133
|
+
end
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Restores one parameter group's moments from a checkpoint.
|
|
138
|
+
def load_state!(name = nil, step:, exp_avg:, exp_avg_sq:)
|
|
139
|
+
key = name ? name.to_sym : single_group_name
|
|
140
|
+
group = groups.fetch(key)
|
|
141
|
+
expected = group.variable.data.shape
|
|
142
|
+
unless exp_avg.shape == expected && exp_avg_sq.shape == expected
|
|
143
|
+
raise ShapeError,
|
|
144
|
+
"Adam state for #{key} expected #{expected.inspect}, " \
|
|
145
|
+
"got #{exp_avg.shape.inspect} and #{exp_avg_sq.shape.inspect}"
|
|
146
|
+
end
|
|
147
|
+
raise ArgumentError, "Adam step must be a non-negative integer" unless step.is_a?(Integer) && !step.negative?
|
|
148
|
+
|
|
149
|
+
type = group.variable.data.class
|
|
150
|
+
@states[key] = State.new(
|
|
151
|
+
step: step,
|
|
152
|
+
exp_avg: type.cast(exp_avg).dup,
|
|
153
|
+
exp_avg_sq: type.cast(exp_avg_sq).dup
|
|
154
|
+
)
|
|
155
|
+
self
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def single_group_name
|
|
161
|
+
return groups.keys.first if groups.length == 1
|
|
162
|
+
|
|
163
|
+
raise ArgumentError, "group name is required for a multi-group Adam optimizer"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def validate_hyperparameters!(learning_rate, epsilon)
|
|
167
|
+
valid_betas = [beta1, beta2].all? { |value| value >= 0 && value < 1 }
|
|
168
|
+
raise ArgumentError, "betas must be in [0,1)" unless valid_betas
|
|
169
|
+
raise ArgumentError, "learning rate must be positive" unless learning_rate.positive?
|
|
170
|
+
raise ArgumentError, "eps must be positive" unless epsilon.positive?
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def normalize_groups(input, default_lr, default_eps)
|
|
174
|
+
raw_groups = input.is_a?(Autograd::Variable) ? { default: { variable: input } } : input
|
|
175
|
+
unless raw_groups.is_a?(Hash) && !raw_groups.empty?
|
|
176
|
+
raise ArgumentError, "groups must be a Variable or non-empty Hash"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
raw_groups.to_h do |name, specification|
|
|
180
|
+
specification = { variable: specification } if specification.is_a?(Autograd::Variable)
|
|
181
|
+
variable = specification.fetch(:variable)
|
|
182
|
+
raise ArgumentError, "group variable must be an Autograd::Variable" unless variable.is_a?(Autograd::Variable)
|
|
183
|
+
|
|
184
|
+
group_lr = specification.fetch(:lr, default_lr)
|
|
185
|
+
group_eps = specification.fetch(:eps, default_eps)
|
|
186
|
+
validate_hyperparameters!(group_lr, group_eps)
|
|
187
|
+
key = name.to_sym
|
|
188
|
+
[key, Group.new(name: key, variable: variable, lr: group_lr, eps: group_eps)]
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def state_for(name)
|
|
193
|
+
group = groups.fetch(name)
|
|
194
|
+
@states[name] ||= State.new(
|
|
195
|
+
step: 0,
|
|
196
|
+
exp_avg: group.variable.data.class.zeros(*group.variable.data.shape),
|
|
197
|
+
exp_avg_sq: group.variable.data.class.zeros(*group.variable.data.shape)
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def select_first_axis(array, selection)
|
|
202
|
+
array[*([selection] + Array.new(array.ndim - 1, true))].dup
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def append_zeros(array, count)
|
|
206
|
+
output = array.class.zeros(*([array.shape[0] + count] + array.shape[1..]))
|
|
207
|
+
output[*([0...array.shape[0]] + Array.new(array.ndim - 1, true))] = array
|
|
208
|
+
output
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
module Optim
|
|
5
|
+
# Closed-form exponential learning-rate schedule.
|
|
6
|
+
class ExponentialLR
|
|
7
|
+
attr_reader :max_steps, :step_count
|
|
8
|
+
|
|
9
|
+
def initialize(optimizer, lr_final:, max_steps:)
|
|
10
|
+
raise ArgumentError, "lr_final must be positive" unless lr_final.positive?
|
|
11
|
+
raise ArgumentError, "max_steps must be positive" unless max_steps.is_a?(Integer) && max_steps.positive?
|
|
12
|
+
|
|
13
|
+
@optimizer = optimizer
|
|
14
|
+
@lr_final = lr_final
|
|
15
|
+
@max_steps = max_steps
|
|
16
|
+
@step_count = 0
|
|
17
|
+
@initial_rates = optimizer.groups.transform_values(&:lr)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Advances or sets the schedule and updates every optimizer group.
|
|
21
|
+
#
|
|
22
|
+
# @param step [Integer, nil] absolute step, or nil to advance once
|
|
23
|
+
# @return [Numeric, Hash{Symbol=>Numeric}] updated learning rates
|
|
24
|
+
def step(step = nil)
|
|
25
|
+
@step_count = step || (step_count + 1)
|
|
26
|
+
progress = [step_count.to_f / max_steps, 1.0].min
|
|
27
|
+
@optimizer.groups.each_key do |name|
|
|
28
|
+
initial = @initial_rates.fetch(name)
|
|
29
|
+
rate = initial * ((@lr_final / initial)**progress)
|
|
30
|
+
@optimizer.set_learning_rate(name, rate)
|
|
31
|
+
end
|
|
32
|
+
@optimizer.learning_rate
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|