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,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module Backend
5
+ # Tile-accelerated Numo rasterization forward pass.
6
+ module RubyRasterizeToPixels
7
+ module_function
8
+
9
+ # rubocop:disable Metrics/ParameterLists
10
+ def forward(means2d, conics, colors, opacities, backgrounds, masks, width, height,
11
+ tile_size, isect_offsets, flatten_ids)
12
+ # rubocop:enable Metrics/ParameterLists
13
+ inputs = validate_inputs(
14
+ means2d, conics, colors, opacities, backgrounds, masks, width, height,
15
+ tile_size, isect_offsets, flatten_ids
16
+ )
17
+ means2d, conics, colors, opacities, backgrounds, masks, isect_offsets, flatten_ids = inputs
18
+ camera_count, gaussian_count = means2d.shape[0...2]
19
+ channel_count = colors.shape[-1]
20
+ tile_height, tile_width = isect_offsets.shape[-2..]
21
+ render_colors = means2d.class.zeros(camera_count, height, width, channel_count)
22
+ render_alphas = means2d.class.zeros(camera_count, height, width, 1)
23
+ last_ids = Numo::Int32.zeros(camera_count, height, width)
24
+ flattened_means = means2d.reshape(camera_count * gaussian_count, 2)
25
+ flattened_conics = conics.reshape(camera_count * gaussian_count, 3)
26
+ flattened_colors = colors.reshape(camera_count * gaussian_count, channel_count)
27
+ flattened_opacities = opacities.reshape(camera_count * gaussian_count)
28
+ camera_count.times do |camera_index|
29
+ tile_height.times do |tile_y|
30
+ tile_width.times do |tile_x|
31
+ render_tile!(
32
+ render_colors,
33
+ render_alphas,
34
+ last_ids,
35
+ flattened_means,
36
+ flattened_conics,
37
+ flattened_colors,
38
+ flattened_opacities,
39
+ backgrounds,
40
+ masks,
41
+ isect_offsets,
42
+ flatten_ids,
43
+ camera_index,
44
+ tile_x,
45
+ tile_y,
46
+ width,
47
+ height,
48
+ tile_size
49
+ )
50
+ end
51
+ end
52
+ end
53
+ [render_colors, render_alphas, last_ids]
54
+ end
55
+
56
+ # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
57
+ def validate_inputs(means2d, conics, colors, opacities, backgrounds, masks, width, height,
58
+ tile_size, isect_offsets, flatten_ids)
59
+ # rubocop:enable Metrics/AbcSize, Metrics/ParameterLists
60
+ unless means2d.is_a?(Numo::NArray) && [Numo::SFloat, Numo::DFloat].include?(means2d.class)
61
+ raise ArgumentError, "means2d must be Numo::SFloat or Numo::DFloat"
62
+ end
63
+
64
+ valid_means = means2d.ndim == 3 && means2d.shape[-1] == 2
65
+ raise ShapeError, "expected means2d [C,N,2], got #{means2d.shape.inspect}" unless valid_means
66
+
67
+ leading_shape = means2d.shape[0...2]
68
+ conics = means2d.class.cast(conics)
69
+ colors = means2d.class.cast(colors)
70
+ opacities = means2d.class.cast(opacities)
71
+ valid = conics.shape == leading_shape + [3] &&
72
+ colors.ndim == 3 && colors.shape[0...2] == leading_shape &&
73
+ opacities.shape == leading_shape
74
+ unless valid
75
+ raise ShapeError,
76
+ "expected conics [C,N,3], colors [C,N,D], and opacities [C,N], " \
77
+ "got #{conics.shape.inspect}, #{colors.shape.inspect}, and #{opacities.shape.inspect}"
78
+ end
79
+ backgrounds = validate_backgrounds(means2d.class, backgrounds, means2d.shape[0], colors.shape[-1])
80
+ isect_offsets = Numo::Int32.cast(isect_offsets)
81
+ valid_offsets = isect_offsets.ndim == 3 && isect_offsets.shape[0] == means2d.shape[0]
82
+ raise ShapeError, "expected isect_offsets [C,TH,TW]" unless valid_offsets
83
+
84
+ masks = validate_masks(masks, isect_offsets.shape)
85
+ flatten_ids = Numo::Int32.cast(flatten_ids)
86
+ validate_indices!(flatten_ids, means2d.shape[0] * means2d.shape[1], isect_offsets)
87
+ validate_dimensions!(width, height, tile_size, isect_offsets.shape[-1], isect_offsets.shape[-2])
88
+ [means2d, conics, colors, opacities, backgrounds, masks, isect_offsets, flatten_ids]
89
+ end
90
+ private_class_method :validate_inputs
91
+
92
+ def validate_backgrounds(type, backgrounds, camera_count, channel_count)
93
+ return nil unless backgrounds
94
+
95
+ value = type.cast(backgrounds)
96
+ expected = [camera_count, channel_count]
97
+ unless value.shape == expected
98
+ raise ShapeError, "expected backgrounds #{expected.inspect}, got #{value.shape.inspect}"
99
+ end
100
+
101
+ value
102
+ end
103
+ private_class_method :validate_backgrounds
104
+
105
+ def validate_masks(masks, expected_shape)
106
+ return nil unless masks
107
+
108
+ value = Numo::Bit.cast(masks)
109
+ unless value.shape == expected_shape
110
+ raise ShapeError, "expected masks #{expected_shape.inspect}, got #{value.shape.inspect}"
111
+ end
112
+
113
+ value
114
+ end
115
+ private_class_method :validate_masks
116
+
117
+ def validate_indices!(flatten_ids, gaussian_count, offsets)
118
+ raise ShapeError, "expected flatten_ids [M]" unless flatten_ids.ndim == 1
119
+ if flatten_ids.size.positive? && (flatten_ids.min.negative? || flatten_ids.max >= gaussian_count)
120
+ raise ShapeError, "flatten_ids contains an out-of-range Gaussian index"
121
+ end
122
+
123
+ values = offsets.to_a.flatten
124
+ valid = values.each_cons(2).all? { |left, right| left <= right } &&
125
+ values.all? { |value| value.between?(0, flatten_ids.size) }
126
+ raise Gsplat::Error, "intersection offsets must be sorted indices into flatten_ids" unless valid
127
+ end
128
+ private_class_method :validate_indices!
129
+
130
+ def validate_dimensions!(width, height, tile_size, tile_width, tile_height)
131
+ valid = [width, height, tile_size].all? { |value| value.is_a?(Integer) && value.positive? }
132
+ raise ArgumentError, "image dimensions and tile_size must be positive integers" unless valid
133
+ return if (tile_width * tile_size) >= width && (tile_height * tile_size) >= height
134
+
135
+ raise ShapeError, "intersection offset grid does not cover the image"
136
+ end
137
+ private_class_method :validate_dimensions!
138
+
139
+ # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
140
+ def render_tile!(render_colors, render_alphas, last_ids, means2d, conics, colors, opacities,
141
+ backgrounds, masks, offsets, flatten_ids, camera_index, tile_x, tile_y,
142
+ width, height, tile_size)
143
+ # rubocop:enable Metrics/ParameterLists
144
+ x_start = tile_x * tile_size
145
+ y_start = tile_y * tile_size
146
+ x_end = [x_start + tile_size, width].min
147
+ y_end = [y_start + tile_size, height].min
148
+ return if x_start >= width || y_start >= height
149
+
150
+ if masks && masks[camera_index, tile_y, tile_x].zero?
151
+ fill_background!(render_colors, backgrounds, camera_index, x_start, x_end, y_start, y_end)
152
+ return
153
+ end
154
+ tile_width = offsets.shape[-1]
155
+ tile_height = offsets.shape[-2]
156
+ flat_tile = (((camera_index * tile_height) + tile_y) * tile_width) + tile_x
157
+ range_start = offsets[flat_tile]
158
+ range_end = flat_tile + 1 < offsets.size ? offsets[flat_tile + 1] : flatten_ids.size
159
+ pixels_x, pixels_y = tile_coordinates(means2d.class, x_start, x_end, y_start, y_end)
160
+ composited, transmittance, tile_last_ids = RubyTileCompositor.composite(
161
+ means2d, conics, colors, opacities, flatten_ids, range_start, range_end,
162
+ pixels_x, pixels_y
163
+ )
164
+ pixel_height = y_end - y_start
165
+ pixel_width = x_end - x_start
166
+ if backgrounds
167
+ composited += transmittance.reshape(transmittance.size, 1) *
168
+ backgrounds[camera_index, true].reshape(1, colors.shape[-1])
169
+ end
170
+ render_colors[camera_index, y_start...y_end, x_start...x_end, true] =
171
+ composited.reshape(pixel_height, pixel_width, colors.shape[-1])
172
+ render_alphas[camera_index, y_start...y_end, x_start...x_end, 0] =
173
+ (1 - transmittance).reshape(pixel_height, pixel_width)
174
+ last_ids[camera_index, y_start...y_end, x_start...x_end] =
175
+ tile_last_ids.reshape(pixel_height, pixel_width)
176
+ end
177
+ # rubocop:enable Metrics/AbcSize
178
+ private_class_method :render_tile!
179
+
180
+ # rubocop:disable Metrics/ParameterLists
181
+ def fill_background!(render_colors, backgrounds, camera_index, x_start, x_end, y_start, y_end)
182
+ # rubocop:enable Metrics/ParameterLists
183
+ return unless backgrounds
184
+
185
+ render_colors[camera_index, y_start...y_end, x_start...x_end, true] =
186
+ backgrounds[camera_index, true]
187
+ end
188
+ private_class_method :fill_background!
189
+
190
+ def tile_coordinates(type, x_start, x_end, y_start, y_end)
191
+ width = x_end - x_start
192
+ x_values = Array.new(y_end - y_start) { (x_start...x_end).map { |column| column + 0.5 } }.flatten
193
+ y_values = (y_start...y_end).flat_map { |row| Array.new(width, row + 0.5) }
194
+ [type.cast(x_values), type.cast(y_values)]
195
+ end
196
+ private_class_method :tile_coordinates
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module Backend
5
+ # Tile orchestration for rasterization VJPs.
6
+ module RubyRasterizeToPixelsBackward
7
+ module_function
8
+
9
+ # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
10
+ def backward(means2d, conics, colors, opacities, backgrounds, masks, width, height,
11
+ tile_size, isect_offsets, flatten_ids, render_alphas, last_ids,
12
+ grad_render_colors, grad_render_alphas, absgrad:)
13
+ # rubocop:enable Metrics/ParameterLists
14
+ type = means2d.class
15
+ color_gradient_shape = [means2d.shape[0], height, width, colors.shape[-1]]
16
+ grad_render_colors = validate_gradient(type, grad_render_colors, color_gradient_shape)
17
+ grad_render_alphas = validate_gradient(type, grad_render_alphas, [means2d.shape[0], height, width, 1])
18
+ camera_count, gaussian_count = means2d.shape[0...2]
19
+ total_gaussians = camera_count * gaussian_count
20
+ grad_means = type.zeros(total_gaussians, 2)
21
+ grad_conics = type.zeros(total_gaussians, 3)
22
+ grad_colors = type.zeros(total_gaussians, colors.shape[-1])
23
+ grad_opacities = type.zeros(total_gaussians)
24
+ grad_backgrounds = backgrounds && type.zeros(*backgrounds.shape)
25
+ grad_means_abs = absgrad ? type.zeros(total_gaussians, 2) : nil
26
+ flattened = flatten_inputs(means2d, conics, colors, opacities)
27
+ tile_height, tile_width = isect_offsets.shape[-2..]
28
+ camera_count.times do |camera_index|
29
+ tile_height.times do |tile_y|
30
+ tile_width.times do |tile_x|
31
+ backward_tile!(
32
+ grad_means, grad_conics, grad_colors, grad_opacities, grad_backgrounds,
33
+ *flattened, backgrounds, masks, isect_offsets, flatten_ids, render_alphas,
34
+ last_ids, grad_render_colors, grad_render_alphas, camera_index, tile_x, tile_y,
35
+ width, height, tile_size, grad_means_abs
36
+ )
37
+ end
38
+ end
39
+ end
40
+ gradients = [
41
+ grad_means.reshape(*means2d.shape),
42
+ grad_conics.reshape(*conics.shape),
43
+ grad_colors.reshape(*colors.shape),
44
+ grad_opacities.reshape(*opacities.shape),
45
+ grad_backgrounds
46
+ ]
47
+ [gradients, grad_means_abs&.reshape(*means2d.shape)]
48
+ end
49
+ # rubocop:enable Metrics/AbcSize
50
+
51
+ def validate_gradient(type, gradient, expected_shape)
52
+ value = type.cast(gradient)
53
+ unless value.shape == expected_shape
54
+ raise ShapeError, "expected output gradient #{expected_shape.inspect}, got #{value.shape.inspect}"
55
+ end
56
+
57
+ value
58
+ end
59
+ private_class_method :validate_gradient
60
+
61
+ def flatten_inputs(means2d, conics, colors, opacities)
62
+ total = means2d.shape[0] * means2d.shape[1]
63
+ [
64
+ means2d.reshape(total, 2),
65
+ conics.reshape(total, 3),
66
+ colors.reshape(total, colors.shape[-1]),
67
+ opacities.reshape(total)
68
+ ]
69
+ end
70
+ private_class_method :flatten_inputs
71
+
72
+ # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
73
+ def backward_tile!(grad_means, grad_conics, grad_colors, grad_opacities, grad_backgrounds,
74
+ means2d, conics, colors, opacities, backgrounds, masks, offsets,
75
+ flatten_ids, render_alphas, last_ids, grad_render_colors, grad_render_alphas,
76
+ camera_index, tile_x, tile_y, width, height, tile_size, grad_means_abs)
77
+ # rubocop:enable Metrics/ParameterLists
78
+ x_start = tile_x * tile_size
79
+ y_start = tile_y * tile_size
80
+ x_end = [x_start + tile_size, width].min
81
+ y_end = [y_start + tile_size, height].min
82
+ return if x_start >= width || y_start >= height
83
+
84
+ alpha_tile = render_alphas[camera_index, y_start...y_end, x_start...x_end, 0].flatten
85
+ color_gradient = grad_render_colors[camera_index, y_start...y_end, x_start...x_end, true]
86
+ color_gradient = color_gradient.reshape(alpha_tile.size, colors.shape[-1])
87
+ alpha_gradient = grad_render_alphas[camera_index, y_start...y_end, x_start...x_end, 0].flatten
88
+ last_id_tile = last_ids[camera_index, y_start...y_end, x_start...x_end].flatten
89
+ if grad_backgrounds
90
+ grad_backgrounds[camera_index, true] += (
91
+ color_gradient * (1 - alpha_tile).reshape(alpha_tile.size, 1)
92
+ ).sum(axis: 0)
93
+ end
94
+ return if masks && masks[camera_index, tile_y, tile_x].zero?
95
+
96
+ tile_width = offsets.shape[-1]
97
+ tile_height = offsets.shape[-2]
98
+ flat_tile = (((camera_index * tile_height) + tile_y) * tile_width) + tile_x
99
+ range_start = offsets[flat_tile]
100
+ range_end = flat_tile + 1 < offsets.size ? offsets[flat_tile + 1] : flatten_ids.size
101
+ pixels_x, pixels_y = RubyRasterizeToPixels.send(
102
+ :tile_coordinates,
103
+ means2d.class,
104
+ x_start,
105
+ x_end,
106
+ y_start,
107
+ y_end
108
+ )
109
+ RubyTileCompositorBackward.backward!(
110
+ grad_means, grad_conics, grad_colors, grad_opacities, means2d, conics,
111
+ colors, opacities, flatten_ids, range_start, range_end, pixels_x, pixels_y,
112
+ alpha_tile, last_id_tile, color_gradient, alpha_gradient,
113
+ backgrounds && backgrounds[camera_index, true],
114
+ grad_means_abs: grad_means_abs
115
+ )
116
+ end
117
+ # rubocop:enable Metrics/AbcSize
118
+ private_class_method :backward_tile!
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module Backend
5
+ # Numo forward/backward implementation of real spherical harmonics.
6
+ module RubySphericalHarmonics
7
+ module_function
8
+
9
+ def forward(degree, directions, coefficients, masks: nil)
10
+ directions, coefficients, masks, leading_shape = validate_inputs(degree, directions, coefficients, masks)
11
+ count = directions.size / 3
12
+ basis_count = coefficients.shape[-2]
13
+ channel_count = coefficients.shape[-1]
14
+ normalized = normalize_directions(directions).reshape(count, 3)
15
+ bases, = Math::SphericalHarmonicBasis.evaluate(normalized, degree, basis_count)
16
+ apply_masks!(bases, nil, masks)
17
+ colors = (
18
+ bases.reshape(count, basis_count, 1) *
19
+ coefficients.reshape(count, basis_count, channel_count)
20
+ ).sum(axis: 1)
21
+ colors.reshape(*(leading_shape + [channel_count]))
22
+ end
23
+
24
+ # rubocop:disable Metrics/ParameterLists
25
+ def backward(degree, directions, coefficients, grad_output, masks: nil, grad_dirs: true, grad_coeffs: true)
26
+ # rubocop:enable Metrics/ParameterLists
27
+ directions, coefficients, masks, leading_shape = validate_inputs(degree, directions, coefficients, masks)
28
+ count = directions.size / 3
29
+ basis_count = coefficients.shape[-2]
30
+ channel_count = coefficients.shape[-1]
31
+ gradient = directions.class.cast(grad_output)
32
+ expected = leading_shape + [channel_count]
33
+ unless gradient.shape == expected
34
+ raise ShapeError, "expected output gradient #{expected.inspect}, got #{gradient.shape.inspect}"
35
+ end
36
+
37
+ normalized = normalize_directions(directions).reshape(count, 3)
38
+ bases, derivatives = Math::SphericalHarmonicBasis.evaluate(normalized, degree, basis_count)
39
+ apply_masks!(bases, derivatives, masks)
40
+ gradient = gradient.reshape(count, channel_count)
41
+ coefficient_values = coefficients.reshape(count, basis_count, channel_count)
42
+ coefficient_gradient = if grad_coeffs
43
+ bases.reshape(count, basis_count, 1) * gradient.reshape(count, 1, channel_count)
44
+ end
45
+ if grad_dirs
46
+ direction_gradient = direction_gradient(
47
+ directions,
48
+ coefficient_values,
49
+ gradient,
50
+ derivatives,
51
+ leading_shape
52
+ )
53
+ end
54
+ [
55
+ direction_gradient,
56
+ coefficient_gradient&.reshape(*(leading_shape + [basis_count, channel_count]))
57
+ ]
58
+ end
59
+
60
+ def validate_inputs(degree, directions, coefficients, masks)
61
+ raise ArgumentError, "degree must be an integer from 0 through 4" unless (0..4).cover?(degree)
62
+
63
+ validate_array_types!(directions, coefficients, masks)
64
+ leading_shape = directions.shape[0...-1]
65
+ valid_shapes = directions.ndim >= 1 && directions.shape[-1] == 3 &&
66
+ coefficients.ndim >= 2 &&
67
+ coefficients.shape[0...-2] == leading_shape &&
68
+ coefficients.shape[-2] >= ((degree + 1)**2)
69
+ unless valid_shapes
70
+ raise ShapeError,
71
+ "expected directions [...,3] and coefficients [...,K,D] with K >= #{(degree + 1)**2}, " \
72
+ "got #{directions.shape.inspect} and #{coefficients.shape.inspect}"
73
+ end
74
+ if masks && masks.shape != leading_shape
75
+ raise ShapeError, "expected masks #{leading_shape.inspect}, got #{masks.shape.inspect}"
76
+ end
77
+
78
+ [directions, directions.class.cast(coefficients), masks && Numo::Bit.cast(masks), leading_shape]
79
+ end
80
+ private_class_method :validate_inputs
81
+
82
+ def validate_array_types!(directions, coefficients, masks)
83
+ unless directions.is_a?(Numo::NArray) && [Numo::SFloat, Numo::DFloat].include?(directions.class)
84
+ raise ArgumentError, "directions must be Numo::SFloat or Numo::DFloat"
85
+ end
86
+ raise ArgumentError, "coefficients must be a Numo::NArray" unless coefficients.is_a?(Numo::NArray)
87
+ raise ArgumentError, "masks must be a Numo::NArray" if masks && !masks.is_a?(Numo::NArray)
88
+ end
89
+ private_class_method :validate_array_types!
90
+
91
+ def normalize_directions(directions)
92
+ flat = directions.reshape(directions.size / 3, 3)
93
+ norms = (flat**2).sum(axis: 1)**0.5
94
+ norms[norms.lt(1e-12)] = 1e-12
95
+ (flat / norms.reshape(flat.shape[0], 1)).reshape(*directions.shape)
96
+ end
97
+ private_class_method :normalize_directions
98
+
99
+ def apply_masks!(bases, derivatives, masks)
100
+ return unless masks
101
+
102
+ hidden = masks.reshape(masks.size).eq(0)
103
+ bases[hidden, true] = 0
104
+ derivatives[hidden, true, true] = 0 if derivatives
105
+ end
106
+ private_class_method :apply_masks!
107
+
108
+ def direction_gradient(directions, coefficients, gradient, derivatives, leading_shape)
109
+ count = directions.size / 3
110
+ basis_count = coefficients.shape[1]
111
+ grad_bases = (coefficients * gradient.reshape(count, 1, gradient.shape[1])).sum(axis: 2)
112
+ grad_normalized = (derivatives * grad_bases.reshape(count, basis_count, 1)).sum(axis: 1)
113
+ normalized_direction_vjp(
114
+ directions,
115
+ grad_normalized.reshape(*(leading_shape + [3]))
116
+ )
117
+ end
118
+ private_class_method :direction_gradient
119
+
120
+ def normalized_direction_vjp(directions, gradient)
121
+ flat = directions.reshape(directions.size / 3, 3)
122
+ grad_flat = gradient.reshape(flat.shape[0], 3)
123
+ norms = (flat**2).sum(axis: 1)**0.5
124
+ clamped = norms.lt(1e-12)
125
+ norms[clamped] = 1e-12
126
+ normalized = flat / norms.reshape(flat.shape[0], 1)
127
+ dot = (normalized * grad_flat).sum(axis: 1)
128
+ result = (grad_flat - (normalized * dot.reshape(flat.shape[0], 1))) / norms.reshape(flat.shape[0], 1)
129
+ result[clamped, true] = grad_flat[clamped, true] / 1e-12 if clamped.any?
130
+ result.reshape(*directions.shape)
131
+ end
132
+ private_class_method :normalized_direction_vjp
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module Backend
5
+ # Vectorized per-tile alpha compositing kernel.
6
+ module RubyTileCompositor
7
+ module_function
8
+
9
+ # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
10
+ def composite(means2d, conics, colors, opacities, flatten_ids, range_start, range_end,
11
+ pixels_x, pixels_y)
12
+ # rubocop:enable Metrics/ParameterLists
13
+ pixel_count = pixels_x.size
14
+ channel_count = colors.shape[-1]
15
+ transmittance = means2d.class.ones(pixel_count)
16
+ composited = means2d.class.zeros(pixel_count, channel_count)
17
+ last_ids = Numo::Int32.zeros(pixel_count)
18
+ active = Numo::Bit.ones(pixel_count)
19
+ (range_start...range_end).each do |intersection_index|
20
+ gaussian_index = flatten_ids[intersection_index]
21
+ delta_x = means2d[gaussian_index, 0] - pixels_x
22
+ delta_y = means2d[gaussian_index, 1] - pixels_y
23
+ sigma = (0.5 * (
24
+ (conics[gaussian_index, 0] * (delta_x**2)) +
25
+ (conics[gaussian_index, 2] * (delta_y**2))
26
+ )) + (conics[gaussian_index, 1] * delta_x * delta_y)
27
+ alpha = opacities[gaussian_index] * Numo::NMath.exp(-sigma)
28
+ above_clamp = alpha.gt(RubyAccumulate::ALPHA_CLAMP)
29
+ alpha[above_clamp] = RubyAccumulate::ALPHA_CLAMP if above_clamp.any?
30
+ candidate = active & sigma.ge(0) & alpha.ge(RubyAccumulate::ALPHA_SKIP)
31
+ next_transmittance = transmittance * (1 - alpha)
32
+ accepted = candidate & next_transmittance.gt(RubyAccumulate::TRANSMITTANCE_STOP)
33
+ if accepted.any?
34
+ contribution = means2d.class.zeros(pixel_count)
35
+ contribution[accepted] = (alpha * transmittance)[accepted]
36
+ composited += contribution.reshape(pixel_count, 1) *
37
+ colors[gaussian_index, true].reshape(1, channel_count)
38
+ transmittance[accepted] = next_transmittance[accepted]
39
+ last_ids[accepted] = intersection_index
40
+ end
41
+ terminating = candidate & next_transmittance.le(RubyAccumulate::TRANSMITTANCE_STOP)
42
+ active[terminating] = 0 if terminating.any?
43
+ break unless active.any?
44
+ end
45
+ [composited, transmittance, last_ids]
46
+ end
47
+ # rubocop:enable Metrics/AbcSize
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module Backend
5
+ # Reverse per-tile compositor with scatter-add into Gaussian gradients.
6
+ module RubyTileCompositorBackward
7
+ module_function
8
+
9
+ # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/MethodLength, Metrics/ParameterLists
10
+ def backward!(grad_means, grad_conics, grad_colors, grad_opacities, means2d, conics,
11
+ colors, opacities, flatten_ids, range_start, range_end, pixels_x, pixels_y,
12
+ render_alphas, last_ids, grad_render_colors, grad_render_alphas, background,
13
+ grad_means_abs: nil)
14
+ # rubocop:enable Metrics/ParameterLists
15
+ pixel_count = pixels_x.size
16
+ current_transmittance = 1 - render_alphas
17
+ after_transmittance = means2d.class.ones(pixel_count)
18
+ remaining_color = means2d.class.zeros(pixel_count, colors.shape[-1])
19
+ remaining_color[true, true] = background if background
20
+ (range_start...range_end).reverse_each do |intersection_index|
21
+ gaussian_index = flatten_ids[intersection_index]
22
+ delta_x = means2d[gaussian_index, 0] - pixels_x
23
+ delta_y = means2d[gaussian_index, 1] - pixels_y
24
+ sigma = (0.5 * (
25
+ (conics[gaussian_index, 0] * (delta_x**2)) +
26
+ (conics[gaussian_index, 2] * (delta_y**2))
27
+ )) + (conics[gaussian_index, 1] * delta_x * delta_y)
28
+ gaussian_response = Numo::NMath.exp(-sigma)
29
+ raw_alpha = opacities[gaussian_index] * gaussian_response
30
+ alpha = raw_alpha.dup
31
+ clamped = alpha.gt(RubyAccumulate::ALPHA_CLAMP)
32
+ alpha[clamped] = RubyAccumulate::ALPHA_CLAMP if clamped.any?
33
+ valid = render_alphas.gt(0) & sigma.ge(0) & alpha.ge(RubyAccumulate::ALPHA_SKIP)
34
+ valid &= last_ids.ge(intersection_index)
35
+ valid &= current_transmittance.lt(1)
36
+ valid &= alpha.lt(1)
37
+ next unless valid.any?
38
+
39
+ transmittance_before = current_transmittance.dup
40
+ transmittance_before[valid] = current_transmittance[valid] / (1 - alpha[valid])
41
+ visibility = means2d.class.zeros(pixel_count)
42
+ visibility[valid] = (transmittance_before * alpha)[valid]
43
+ color = colors[gaussian_index, true]
44
+ grad_colors[gaussian_index, true] += (
45
+ grad_render_colors * visibility.reshape(pixel_count, 1)
46
+ ).sum(axis: 0)
47
+ color_difference = color.reshape(1, colors.shape[-1]) - remaining_color
48
+ grad_alpha = transmittance_before * (
49
+ (grad_render_colors * color_difference).sum(axis: 1) +
50
+ (grad_render_alphas * after_transmittance)
51
+ )
52
+ unclamped = valid & raw_alpha.lt(RubyAccumulate::ALPHA_CLAMP)
53
+ grad_raw_alpha = means2d.class.zeros(pixel_count)
54
+ grad_raw_alpha[unclamped] = grad_alpha[unclamped] if unclamped.any?
55
+ grad_opacities[gaussian_index] += (grad_raw_alpha * gaussian_response).sum
56
+ grad_sigma = -grad_raw_alpha * raw_alpha
57
+ mean_x = grad_sigma * (
58
+ (conics[gaussian_index, 0] * delta_x) +
59
+ (conics[gaussian_index, 1] * delta_y)
60
+ )
61
+ mean_y = grad_sigma * (
62
+ (conics[gaussian_index, 2] * delta_y) +
63
+ (conics[gaussian_index, 1] * delta_x)
64
+ )
65
+ grad_means[gaussian_index, 0] += mean_x.sum
66
+ grad_means[gaussian_index, 1] += mean_y.sum
67
+ if grad_means_abs
68
+ grad_means_abs[gaussian_index, 0] += mean_x.abs.sum
69
+ grad_means_abs[gaussian_index, 1] += mean_y.abs.sum
70
+ end
71
+ grad_conics[gaussian_index, 0] += (grad_sigma * 0.5 * (delta_x**2)).sum
72
+ grad_conics[gaussian_index, 1] += (grad_sigma * delta_x * delta_y).sum
73
+ grad_conics[gaussian_index, 2] += (grad_sigma * 0.5 * (delta_y**2)).sum
74
+ updated_color = (alpha.reshape(pixel_count, 1) * color.reshape(1, colors.shape[-1])) +
75
+ ((1 - alpha).reshape(pixel_count, 1) * remaining_color)
76
+ remaining_color[valid, true] = updated_color[valid, true]
77
+ after_transmittance[valid] *= 1 - alpha[valid]
78
+ current_transmittance[valid] = transmittance_before[valid]
79
+ end
80
+ end
81
+ # rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/MethodLength
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ # Operation registry and runtime backend selector.
5
+ module Backend
6
+ # Backend names accepted by {Gsplat.backend=}.
7
+ VALID_BACKENDS = %i[auto ruby native].freeze
8
+
9
+ @registry = Hash.new { |operations, name| operations[name] = {} }
10
+ @mutex = Mutex.new
11
+ @fallback_warned = false
12
+
13
+ class << self
14
+ # Registers an operation implementation.
15
+ #
16
+ # @param op_name [Symbol, String] operation identifier
17
+ # @param backend [Symbol, String] implementation backend (:ruby or :native)
18
+ # @param callable [#call, nil] implementation callable
19
+ # @yield implementation body when callable is omitted
20
+ # @return [#call] the registered implementation
21
+ def register(op_name, backend, callable = nil, &block)
22
+ implementation = callable || block
23
+ raise ArgumentError, "backend implementation must respond to #call" unless implementation.respond_to?(:call)
24
+
25
+ backend = normalize_backend(backend, allow_auto: false)
26
+ @mutex.synchronize { @registry[op_name.to_sym][backend] = implementation }
27
+ implementation
28
+ end
29
+
30
+ # Dispatches an operation to the selected implementation.
31
+ #
32
+ # @param op_name [Symbol, String] operation identifier
33
+ # @return [Object] operation result
34
+ def dispatch(op_name, ...)
35
+ operation = op_name.to_sym
36
+ implementation = implementation_for(operation)
37
+ implementation.call(...)
38
+ end
39
+
40
+ # Validates and normalizes a backend name.
41
+ #
42
+ # @param backend [Symbol, String]
43
+ # @param allow_auto [Boolean]
44
+ # @return [Symbol]
45
+ def normalize_backend(backend, allow_auto: true)
46
+ normalized = backend.to_s.downcase.to_sym
47
+ valid = allow_auto ? VALID_BACKENDS : VALID_BACKENDS.drop(1)
48
+ return normalized if valid.include?(normalized)
49
+
50
+ raise ArgumentError, "unknown backend #{backend.inspect}; expected one of #{valid.join(', ')}"
51
+ end
52
+
53
+ private
54
+
55
+ def implementation_for(operation)
56
+ implementations = @mutex.synchronize { @registry.fetch(operation, {}).dup }
57
+ selected = Gsplat.backend
58
+ return implementations.fetch(selected) { missing_backend!(operation, selected) } unless selected == :auto
59
+ return implementations[:native] if implementations.key?(:native)
60
+
61
+ warn_fallback_once
62
+ implementations.fetch(:ruby) { missing_backend!(operation, :ruby) }
63
+ end
64
+
65
+ def missing_backend!(operation, backend)
66
+ raise NotSupportedError, "#{backend} backend does not implement #{operation}"
67
+ end
68
+
69
+ def warn_fallback_once
70
+ should_warn = @mutex.synchronize do
71
+ next false if @fallback_warned
72
+
73
+ @fallback_warned = true
74
+ end
75
+ Gsplat.logger.warn("native backend is unavailable; falling back to ruby") if should_warn
76
+ end
77
+ end
78
+ end
79
+ end