gsplat 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +202 -0
  3. data/README.md +236 -0
  4. data/docs/ACCEPTANCE.md +60 -0
  5. data/docs/BENCHMARKS.md +79 -0
  6. data/docs/DECISIONS.md +52 -0
  7. data/docs/MIGRATION.md +117 -0
  8. data/docs/PROFILE.md +75 -0
  9. data/docs/PROGRESS.md +199 -0
  10. data/docs/decisions/0000-template.md +17 -0
  11. data/docs/decisions/0001-pin-golden-reference-and-cpu-validation.md +27 -0
  12. data/docs/decisions/0002-retain-ruby-fallbacks-for-native-backend.md +26 -0
  13. data/docs/decisions/0003-share-projection-for-distorted-cameras.md +26 -0
  14. data/docs/decisions/0004-use-portable-world-space-reference-paths.md +26 -0
  15. data/docs/decisions/0005-reuse-ewa-core-for-2dgs.md +24 -0
  16. data/docs/decisions/0006-keep-eval3d-as-portable-reference.md +25 -0
  17. data/docs/decisions/0007-share-compositor-semantics-for-contribution-indices.md +25 -0
  18. data/examples/data/README.md +14 -0
  19. data/examples/data/colmap/images/view_000.png +0 -0
  20. data/examples/data/colmap/images/view_001.png +0 -0
  21. data/examples/data/colmap/images/view_002.png +0 -0
  22. data/examples/data/colmap/sparse/0/cameras.txt +2 -0
  23. data/examples/data/colmap/sparse/0/images.txt +6 -0
  24. data/examples/data/colmap/sparse/0/points3D.txt +17 -0
  25. data/examples/data/splats.ply +0 -0
  26. data/examples/fit_image.rb +35 -0
  27. data/examples/generate_sample_data.rb +147 -0
  28. data/examples/render_path.rb +117 -0
  29. data/examples/simple_trainer.rb +76 -0
  30. data/ext/gsplat_native/common.h +54 -0
  31. data/ext/gsplat_native/extconf.rb +39 -0
  32. data/ext/gsplat_native/gsplat_native.c +60 -0
  33. data/ext/gsplat_native/intersections.c +211 -0
  34. data/ext/gsplat_native/projection.c +199 -0
  35. data/ext/gsplat_native/raster_backward.c +129 -0
  36. data/ext/gsplat_native/raster_backward_bridge.c +69 -0
  37. data/ext/gsplat_native/raster_forward.c +150 -0
  38. data/ext/gsplat_native/rasterization.h +51 -0
  39. data/ext/gsplat_native/spherical_harmonics.c +129 -0
  40. data/gsplat.gemspec +36 -0
  41. data/lib/gsplat/autograd/context.rb +60 -0
  42. data/lib/gsplat/autograd/function.rb +68 -0
  43. data/lib/gsplat/autograd/variable.rb +159 -0
  44. data/lib/gsplat/backend/ruby/accumulate.rb +139 -0
  45. data/lib/gsplat/backend/ruby/accumulate_backward.rb +40 -0
  46. data/lib/gsplat/backend/ruby/eval3d_rasterizer.rb +175 -0
  47. data/lib/gsplat/backend/ruby/isect_tiles.rb +198 -0
  48. data/lib/gsplat/backend/ruby/projection.rb +251 -0
  49. data/lib/gsplat/backend/ruby/projection_backward.rb +190 -0
  50. data/lib/gsplat/backend/ruby/projection_covariance_vjp.rb +72 -0
  51. data/lib/gsplat/backend/ruby/projection_input_vjp.rb +252 -0
  52. data/lib/gsplat/backend/ruby/quat_scale_to_covar_preci.rb +139 -0
  53. data/lib/gsplat/backend/ruby/rasterize_to_indices_in_range.rb +121 -0
  54. data/lib/gsplat/backend/ruby/rasterize_to_pixels.rb +199 -0
  55. data/lib/gsplat/backend/ruby/rasterize_to_pixels_backward.rb +121 -0
  56. data/lib/gsplat/backend/ruby/spherical_harmonics.rb +135 -0
  57. data/lib/gsplat/backend/ruby/tile_compositor.rb +50 -0
  58. data/lib/gsplat/backend/ruby/tile_compositor_backward.rb +84 -0
  59. data/lib/gsplat/backend.rb +79 -0
  60. data/lib/gsplat/compression/grid_sort.rb +79 -0
  61. data/lib/gsplat/compression/kmeans.rb +121 -0
  62. data/lib/gsplat/compression/png.rb +147 -0
  63. data/lib/gsplat/compression/png_codec.rb +133 -0
  64. data/lib/gsplat/compression/quantizer.rb +79 -0
  65. data/lib/gsplat/io/checkpoint.rb +145 -0
  66. data/lib/gsplat/io/colmap.rb +175 -0
  67. data/lib/gsplat/io/colmap_binary.rb +98 -0
  68. data/lib/gsplat/io/colmap_text.rb +84 -0
  69. data/lib/gsplat/io/image.rb +63 -0
  70. data/lib/gsplat/io/image_backends.rb +81 -0
  71. data/lib/gsplat/io/npy.rb +189 -0
  72. data/lib/gsplat/io/ply.rb +185 -0
  73. data/lib/gsplat/io/ply_reader.rb +142 -0
  74. data/lib/gsplat/io/zip_archive.rb +183 -0
  75. data/lib/gsplat/math/camera_distortion.rb +123 -0
  76. data/lib/gsplat/math/camera_projection.rb +202 -0
  77. data/lib/gsplat/math/mat.rb +114 -0
  78. data/lib/gsplat/math/quaternion.rb +175 -0
  79. data/lib/gsplat/math/small_matrix_primitives.rb +112 -0
  80. data/lib/gsplat/math/spherical_harmonic_basis.rb +148 -0
  81. data/lib/gsplat/math/ssim.rb +153 -0
  82. data/lib/gsplat/native.rb +30 -0
  83. data/lib/gsplat/native_ops.rb +148 -0
  84. data/lib/gsplat/native_raster_ops.rb +76 -0
  85. data/lib/gsplat/ops/accumulate.rb +68 -0
  86. data/lib/gsplat/ops/eval3d_rasterize.rb +62 -0
  87. data/lib/gsplat/ops/isect_tiles.rb +38 -0
  88. data/lib/gsplat/ops/projection.rb +154 -0
  89. data/lib/gsplat/ops/quat_scale_to_covar_preci.rb +118 -0
  90. data/lib/gsplat/ops/rasterize_to_indices_in_range.rb +38 -0
  91. data/lib/gsplat/ops/rasterize_to_pixels.rb +105 -0
  92. data/lib/gsplat/ops/relocation.rb +94 -0
  93. data/lib/gsplat/ops/spherical_harmonics.rb +71 -0
  94. data/lib/gsplat/ops/tensor_shape_ops.rb +173 -0
  95. data/lib/gsplat/ops/tensor_value_ops.rb +150 -0
  96. data/lib/gsplat/optim/adam.rb +212 -0
  97. data/lib/gsplat/optim/lr_scheduler.rb +36 -0
  98. data/lib/gsplat/optim/selective_adam.rb +68 -0
  99. data/lib/gsplat/rasterization.rb +130 -0
  100. data/lib/gsplat/rasterization_2dgs.rb +140 -0
  101. data/lib/gsplat/rasterization_helpers.rb +162 -0
  102. data/lib/gsplat/rasterization_validation.rb +99 -0
  103. data/lib/gsplat/strategy/base.rb +49 -0
  104. data/lib/gsplat/strategy/default.rb +188 -0
  105. data/lib/gsplat/strategy/mcmc.rb +103 -0
  106. data/lib/gsplat/strategy/mcmc_ops.rb +143 -0
  107. data/lib/gsplat/strategy/ops.rb +165 -0
  108. data/lib/gsplat/training/config.rb +91 -0
  109. data/lib/gsplat/training/image_fitter.rb +158 -0
  110. data/lib/gsplat/training/losses.rb +191 -0
  111. data/lib/gsplat/training/scene.rb +116 -0
  112. data/lib/gsplat/training/trainer.rb +236 -0
  113. data/lib/gsplat/utils.rb +110 -0
  114. data/lib/gsplat/version.rb +6 -0
  115. data/lib/gsplat.rb +98 -0
  116. metadata +181 -0
@@ -0,0 +1,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