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,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module Math
5
+ # OpenCV pinhole distortion and equidistant fisheye projection.
6
+ module CameraDistortion
7
+ module_function
8
+
9
+ # rubocop:disable Metrics/ParameterLists
10
+ def project_camera(means, intrinsics, camera_model, radial: nil, tangential: nil, thin_prism: nil)
11
+ # rubocop:enable Metrics/ParameterLists
12
+ projected = means.class.zeros(means.shape[0], 2)
13
+ jacobians = means.class.zeros(means.shape[0], 2, 3)
14
+ means.shape[0].times do |index|
15
+ point = means[index, true].to_a.map(&:to_f)
16
+ projected[index, true] = project_point(
17
+ point, intrinsics, camera_model, radial, tangential, thin_prism
18
+ )
19
+ jacobians[index, true, true] = numerical_jacobian(
20
+ point, intrinsics, camera_model, radial, tangential, thin_prism, means.class
21
+ )
22
+ end
23
+ [projected, jacobians]
24
+ end
25
+
26
+ # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
27
+ def project_point(point, intrinsics, camera_model, radial, tangential, thin_prism)
28
+ # rubocop:enable Metrics/AbcSize, Metrics/ParameterLists
29
+ x_coord = point[0] / point[2]
30
+ y_coord = point[1] / point[2]
31
+ normalized = if camera_model.to_s == "fisheye"
32
+ fisheye_normalized = fisheye(x_coord, y_coord, radial)
33
+ tangential_prism(
34
+ *fisheye_normalized, *fisheye_normalized, tangential, thin_prism
35
+ )
36
+ else
37
+ pinhole_normalized = pinhole(x_coord, y_coord, radial)
38
+ tangential_prism(
39
+ *pinhole_normalized, x_coord, y_coord, tangential, thin_prism
40
+ )
41
+ end
42
+ [
43
+ (intrinsics[0, 0].to_f * normalized[0]) +
44
+ (intrinsics[0, 1].to_f * normalized[1]) + intrinsics[0, 2].to_f,
45
+ (intrinsics[1, 0].to_f * normalized[0]) +
46
+ (intrinsics[1, 1].to_f * normalized[1]) + intrinsics[1, 2].to_f
47
+ ]
48
+ end
49
+
50
+ def pinhole(x_coord, y_coord, radial)
51
+ coefficients = Array.new(6, 0.0)
52
+ radial&.each_with_index { |value, index| coefficients[index] = value.to_f }
53
+ radius2 = (x_coord * x_coord) + (y_coord * y_coord)
54
+ radius4 = radius2 * radius2
55
+ radius6 = radius4 * radius2
56
+ numerator = 1 + (coefficients[0] * radius2) +
57
+ (coefficients[1] * radius4) + (coefficients[2] * radius6)
58
+ denominator = 1 + (coefficients[3] * radius2) +
59
+ (coefficients[4] * radius4) + (coefficients[5] * radius6)
60
+ scale = numerator / denominator
61
+ [x_coord * scale, y_coord * scale]
62
+ end
63
+ private_class_method :pinhole
64
+
65
+ def fisheye(x_coord, y_coord, radial)
66
+ radius = ::Math.sqrt((x_coord * x_coord) + (y_coord * y_coord))
67
+ return [x_coord, y_coord] if radius < 1e-12
68
+
69
+ theta = ::Math.atan(radius)
70
+ theta2 = theta * theta
71
+ coefficients = Array.new(4, 0.0)
72
+ radial&.each_with_index { |value, index| coefficients[index] = value.to_f }
73
+ polynomial = 1.0
74
+ power = theta2
75
+ coefficients.each do |coefficient|
76
+ polynomial += coefficient * power
77
+ power *= theta2
78
+ end
79
+ scale = theta * polynomial / radius
80
+ [x_coord * scale, y_coord * scale]
81
+ end
82
+ private_class_method :fisheye
83
+
84
+ # rubocop:disable Metrics/ParameterLists
85
+ def tangential_prism(base_x, base_y, x_coord, y_coord, tangential, thin_prism)
86
+ # rubocop:enable Metrics/ParameterLists
87
+ p1, p2 = tangential ? tangential.map(&:to_f) : [0.0, 0.0]
88
+ s1, s2, s3, s4 = thin_prism ? thin_prism.map(&:to_f) : [0.0, 0.0, 0.0, 0.0]
89
+ radius2 = (x_coord * x_coord) + (y_coord * y_coord)
90
+ radius4 = radius2 * radius2
91
+ [
92
+ base_x + (2 * p1 * x_coord * y_coord) + (p2 * (radius2 + (2 * x_coord * x_coord))) +
93
+ (s1 * radius2) + (s2 * radius4),
94
+ base_y + (p1 * (radius2 + (2 * y_coord * y_coord))) + (2 * p2 * x_coord * y_coord) +
95
+ (s3 * radius2) + (s4 * radius4)
96
+ ]
97
+ end
98
+ private_class_method :tangential_prism
99
+
100
+ # rubocop:disable Metrics/ParameterLists
101
+ def numerical_jacobian(point, intrinsics, camera_model, radial, tangential, thin_prism, type)
102
+ # rubocop:enable Metrics/ParameterLists
103
+ epsilon = type == Numo::DFloat ? 1e-6 : 1e-3
104
+ 2.times.map do |output_axis|
105
+ 3.times.map do |input_axis|
106
+ positive = point.dup
107
+ negative = point.dup
108
+ positive[input_axis] += epsilon
109
+ negative[input_axis] -= epsilon
110
+ projected_positive = project_point(
111
+ positive, intrinsics, camera_model, radial, tangential, thin_prism
112
+ )
113
+ projected_negative = project_point(
114
+ negative, intrinsics, camera_model, radial, tangential, thin_prism
115
+ )
116
+ (projected_positive[output_axis] - projected_negative[output_axis]) / (2 * epsilon)
117
+ end
118
+ end
119
+ end
120
+ private_class_method :numerical_jacobian
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module Math
5
+ # Camera-space transformation and pinhole Gaussian projection primitives.
6
+ module CameraProjection
7
+ module_function
8
+
9
+ def world_to_cam(means, covars, viewmats)
10
+ means, covars, viewmats = validate_world_inputs(means, covars, viewmats)
11
+ camera_count = viewmats.shape[0]
12
+ gaussian_count = means.shape[0]
13
+ camera_means = means.class.zeros(camera_count, gaussian_count, 3)
14
+ camera_covars = means.class.zeros(camera_count, gaussian_count, 3, 3)
15
+ camera_count.times do |camera_index|
16
+ rotation = viewmats[camera_index, 0...3, 0...3]
17
+ translation = viewmats[camera_index, 0...3, 3]
18
+ rotation_batch = means.class.zeros(gaussian_count, 3, 3)
19
+ rotation_batch[true, true, true] = rotation
20
+ camera_means[camera_index, true, true] = means.dot(rotation.transpose) + translation
21
+ rotated = Mat.matmul_batch(rotation_batch, covars)
22
+ camera_covars[camera_index, true, true, true] = Mat.matmul_batch(
23
+ rotated,
24
+ rotation_batch.transpose(0, 2, 1)
25
+ )
26
+ end
27
+ [camera_means, camera_covars]
28
+ end
29
+
30
+ def persp_proj(means, covars, intrinsics, width, height)
31
+ means, covars, intrinsics = validate_projection_inputs(means, covars, intrinsics, width, height)
32
+ camera_count = means.shape[0]
33
+ gaussian_count = means.shape[1]
34
+ means2d = means.class.zeros(camera_count, gaussian_count, 2)
35
+ covars2d = means.class.zeros(camera_count, gaussian_count, 2, 2)
36
+ camera_count.times do |camera_index|
37
+ projected_means, jacobians = pinhole_camera(
38
+ means[camera_index, true, true],
39
+ intrinsics[camera_index, true, true],
40
+ width,
41
+ height
42
+ )
43
+ means2d[camera_index, true, true] = projected_means
44
+ covariance = covars[camera_index, true, true, true]
45
+ transformed = Mat.matmul_batch(jacobians, covariance)
46
+ covars2d[camera_index, true, true, true] = Mat.matmul_batch(
47
+ transformed,
48
+ jacobians.transpose(0, 2, 1)
49
+ )
50
+ end
51
+ [means2d, covars2d]
52
+ end
53
+
54
+ def ortho_proj(means, covars, intrinsics, width, height)
55
+ means, covars, intrinsics = validate_projection_inputs(means, covars, intrinsics, width, height)
56
+ camera_count = means.shape[0]
57
+ gaussian_count = means.shape[1]
58
+ means2d = means.class.zeros(camera_count, gaussian_count, 2)
59
+ covars2d = means.class.zeros(camera_count, gaussian_count, 2, 2)
60
+ camera_count.times do |camera_index|
61
+ projected_means, jacobians = ortho_camera(
62
+ means[camera_index, true, true],
63
+ intrinsics[camera_index, true, true]
64
+ )
65
+ means2d[camera_index, true, true] = projected_means
66
+ transformed = Mat.matmul_batch(jacobians, covars[camera_index, true, true, true])
67
+ covars2d[camera_index, true, true, true] = Mat.matmul_batch(
68
+ transformed,
69
+ jacobians.transpose(0, 2, 1)
70
+ )
71
+ end
72
+ [means2d, covars2d]
73
+ end
74
+
75
+ # rubocop:disable Metrics/ParameterLists
76
+ def distorted_proj(means, covars, intrinsics, camera_model, radial_coeffs: nil,
77
+ tangential_coeffs: nil, thin_prism_coeffs: nil)
78
+ # rubocop:enable Metrics/ParameterLists
79
+ camera_count = means.shape[0]
80
+ gaussian_count = means.shape[1]
81
+ means2d = means.class.zeros(camera_count, gaussian_count, 2)
82
+ covars2d = means.class.zeros(camera_count, gaussian_count, 2, 2)
83
+ camera_count.times do |camera_index|
84
+ projected, jacobians = CameraDistortion.project_camera(
85
+ means[camera_index, true, true],
86
+ intrinsics[camera_index, true, true],
87
+ camera_model,
88
+ radial: radial_coeffs && radial_coeffs[camera_index, true].to_a,
89
+ tangential: tangential_coeffs && tangential_coeffs[camera_index, true].to_a,
90
+ thin_prism: thin_prism_coeffs && thin_prism_coeffs[camera_index, true].to_a
91
+ )
92
+ means2d[camera_index, true, true] = projected
93
+ transformed = Mat.matmul_batch(jacobians, covars[camera_index, true, true, true])
94
+ covars2d[camera_index, true, true, true] = Mat.matmul_batch(
95
+ transformed,
96
+ jacobians.transpose(0, 2, 1)
97
+ )
98
+ end
99
+ [means2d, covars2d]
100
+ end
101
+
102
+ def validate_world_inputs(means, covars, viewmats)
103
+ validate_float_array!(means, "means")
104
+ covars = means.class.cast(covars)
105
+ viewmats = means.class.cast(viewmats)
106
+ unless means.ndim == 2 && means.shape[-1] == 3 && covars.shape == [means.shape[0], 3, 3]
107
+ raise ShapeError,
108
+ "expected means [N,3] and covars [N,3,3], " \
109
+ "got #{means.shape.inspect} and #{covars.shape.inspect}"
110
+ end
111
+ unless viewmats.ndim == 3 && viewmats.shape[1..] == [4, 4]
112
+ raise ShapeError, "expected viewmats [C,4,4], got #{viewmats.shape.inspect}"
113
+ end
114
+
115
+ [means, covars, viewmats]
116
+ end
117
+ private_class_method :validate_world_inputs
118
+
119
+ def validate_projection_inputs(means, covars, intrinsics, width, height)
120
+ validate_float_array!(means, "means")
121
+ covars = means.class.cast(covars)
122
+ intrinsics = means.class.cast(intrinsics)
123
+ valid = means.ndim == 3 && means.shape[-1] == 3 &&
124
+ covars.shape == means.shape[0...-1] + [3, 3] &&
125
+ intrinsics.shape == [means.shape[0], 3, 3]
126
+ unless valid
127
+ raise ShapeError,
128
+ "expected means [C,N,3], covars [C,N,3,3], and intrinsics [C,3,3], " \
129
+ "got #{means.shape.inspect}, #{covars.shape.inspect}, and #{intrinsics.shape.inspect}"
130
+ end
131
+ unless width.is_a?(Integer) && width.positive? && height.is_a?(Integer) && height.positive?
132
+ raise ArgumentError, "width and height must be positive integers"
133
+ end
134
+
135
+ [means, covars, intrinsics]
136
+ end
137
+ private_class_method :validate_projection_inputs
138
+
139
+ def validate_float_array!(array, name)
140
+ return if array.is_a?(Numo::NArray) && [Numo::SFloat, Numo::DFloat].include?(array.class)
141
+
142
+ raise ArgumentError, "#{name} must be Numo::SFloat or Numo::DFloat"
143
+ end
144
+ private_class_method :validate_float_array!
145
+
146
+ # rubocop:disable Metrics/AbcSize
147
+ def pinhole_camera(means, intrinsics, width, height)
148
+ x_coord = means[true, 0]
149
+ y_coord = means[true, 1]
150
+ z_coord = means[true, 2]
151
+ focal_x = intrinsics[0, 0]
152
+ focal_y = intrinsics[1, 1]
153
+ center_x = intrinsics[0, 2]
154
+ center_y = intrinsics[1, 2]
155
+ clipped_x = clip_ratio(
156
+ x_coord / z_coord,
157
+ -((center_x / focal_x) + (0.15 * width / focal_x)),
158
+ ((width - center_x) / focal_x) + (0.15 * width / focal_x)
159
+ ) * z_coord
160
+ clipped_y = clip_ratio(
161
+ y_coord / z_coord,
162
+ -((center_y / focal_y) + (0.15 * height / focal_y)),
163
+ ((height - center_y) / focal_y) + (0.15 * height / focal_y)
164
+ ) * z_coord
165
+ jacobians = means.class.zeros(means.shape[0], 2, 3)
166
+ jacobians[true, 0, 0] = focal_x / z_coord
167
+ jacobians[true, 0, 2] = -focal_x * clipped_x / (z_coord**2)
168
+ jacobians[true, 1, 1] = focal_y / z_coord
169
+ jacobians[true, 1, 2] = -focal_y * clipped_y / (z_coord**2)
170
+ homogeneous_x = (intrinsics[0, 0] * x_coord) + (intrinsics[0, 1] * y_coord) +
171
+ (intrinsics[0, 2] * z_coord)
172
+ homogeneous_y = (intrinsics[1, 0] * x_coord) + (intrinsics[1, 1] * y_coord) +
173
+ (intrinsics[1, 2] * z_coord)
174
+ means2d = means.class.zeros(means.shape[0], 2)
175
+ means2d[true, 0] = homogeneous_x / z_coord
176
+ means2d[true, 1] = homogeneous_y / z_coord
177
+ [means2d, jacobians]
178
+ end
179
+ # rubocop:enable Metrics/AbcSize
180
+
181
+ def clip_ratio(values, minimum, maximum)
182
+ output = values.dup
183
+ below = output.lt(minimum)
184
+ above = output.gt(maximum)
185
+ output[below] = minimum if below.any?
186
+ output[above] = maximum if above.any?
187
+ output
188
+ end
189
+ private_class_method :clip_ratio
190
+
191
+ def ortho_camera(means, intrinsics)
192
+ jacobians = means.class.zeros(means.shape[0], 2, 3)
193
+ jacobians[true, 0, 0] = intrinsics[0, 0]
194
+ jacobians[true, 1, 1] = intrinsics[1, 1]
195
+ means2d = means.class.zeros(means.shape[0], 2)
196
+ means2d[true, 0] = (means[true, 0] * intrinsics[0, 0]) + intrinsics[0, 2]
197
+ means2d[true, 1] = (means[true, 1] * intrinsics[1, 1]) + intrinsics[1, 2]
198
+ [means2d, jacobians]
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "small_matrix_primitives"
4
+
5
+ module Gsplat
6
+ # Quaternion, projection, image metric, and small-matrix primitives.
7
+ module Math
8
+ # Closed-form batched operations for small matrices.
9
+ module Mat
10
+ extend SmallMatrixPrimitives
11
+
12
+ module_function
13
+
14
+ # Determinant of batched 2x2 matrices.
15
+ #
16
+ # @param matrices [Numo::NArray] [...,2,2]
17
+ # @param dtype [Class, Symbol, nil] optional float32/float64 calculation type
18
+ # @return [Numo::NArray] [...]
19
+ def det2x2(matrices, dtype: nil)
20
+ _, leading_shape, flat = prepare_matrix(matrices, 2, dtype)
21
+ determinant = (flat[true, 0, 0] * flat[true, 1, 1]) - (flat[true, 0, 1] * flat[true, 1, 0])
22
+ reshape_vector(determinant, leading_shape)
23
+ end
24
+
25
+ # Inverse of batched 2x2 matrices.
26
+ #
27
+ # @param matrices [Numo::NArray] [...,2,2]
28
+ # @param dtype [Class, Symbol, nil] optional float32/float64 calculation type
29
+ # @param epsilon [Numeric, nil] singularity threshold
30
+ # @return [Numo::NArray] [...,2,2]
31
+ def inv2x2(matrices, dtype: nil, epsilon: nil)
32
+ typed, leading_shape, flat = prepare_matrix(matrices, 2, dtype)
33
+ determinant = (flat[true, 0, 0] * flat[true, 1, 1]) - (flat[true, 0, 1] * flat[true, 1, 0])
34
+ ensure_invertible!(determinant, epsilon || default_epsilon(typed.class))
35
+ output = typed.class.zeros(flat.shape[0], 2, 2)
36
+ output[true, 0, 0] = flat[true, 1, 1] / determinant
37
+ output[true, 0, 1] = -flat[true, 0, 1] / determinant
38
+ output[true, 1, 0] = -flat[true, 1, 0] / determinant
39
+ output[true, 1, 1] = flat[true, 0, 0] / determinant
40
+ reshape_matrix(output, leading_shape, 2, 2)
41
+ end
42
+
43
+ # Ordered real eigenvalues of batched 2x2 matrices.
44
+ #
45
+ # @param matrices [Numo::NArray] [...,2,2]
46
+ # @param dtype [Class, Symbol, nil] optional float32/float64 calculation type
47
+ # @return [Numo::NArray] [...,2] in ascending order
48
+ def eigvals2x2(matrices, dtype: nil)
49
+ typed, leading_shape, flat = prepare_matrix(matrices, 2, dtype)
50
+ half_trace = (flat[true, 0, 0] + flat[true, 1, 1]) * 0.5
51
+ half_difference = (flat[true, 0, 0] - flat[true, 1, 1]) * 0.5
52
+ discriminant = (half_difference**2) + (flat[true, 0, 1] * flat[true, 1, 0])
53
+ discriminant[discriminant.lt(0)] = 0
54
+ root = discriminant**0.5
55
+ output = typed.class.zeros(flat.shape[0], 2)
56
+ output[true, 0] = half_trace - root
57
+ output[true, 1] = half_trace + root
58
+ reshape_matrix(output, leading_shape, 2)
59
+ end
60
+
61
+ # Determinant of batched 3x3 matrices.
62
+ #
63
+ # @param matrices [Numo::NArray] [...,3,3]
64
+ # @param dtype [Class, Symbol, nil] optional float32/float64 calculation type
65
+ # @return [Numo::NArray] [...]
66
+ def det3x3(matrices, dtype: nil)
67
+ _, leading_shape, flat = prepare_matrix(matrices, 3, dtype)
68
+ determinant = determinant3(flat)
69
+ reshape_vector(determinant, leading_shape)
70
+ end
71
+
72
+ # Inverse of batched 3x3 matrices.
73
+ #
74
+ # @param matrices [Numo::NArray] [...,3,3]
75
+ # @param dtype [Class, Symbol, nil] optional float32/float64 calculation type
76
+ # @param epsilon [Numeric, nil] singularity threshold
77
+ # @return [Numo::NArray] [...,3,3]
78
+ def inv3x3(matrices, dtype: nil, epsilon: nil)
79
+ typed, leading_shape, flat = prepare_matrix(matrices, 3, dtype)
80
+ determinant = determinant3(flat)
81
+ ensure_invertible!(determinant, epsilon || default_epsilon(typed.class))
82
+ output = typed.class.zeros(flat.shape[0], 3, 3)
83
+ fill_inverse3!(output, flat, determinant)
84
+ reshape_matrix(output, leading_shape, 3, 3)
85
+ end
86
+
87
+ # Batched matrix multiplication for small matrices.
88
+ #
89
+ # @param left [Numo::NArray] [...,M,K]
90
+ # @param right [Numo::NArray] [...,K,N]
91
+ # @param dtype [Class, Symbol, nil] optional float32/float64 calculation type
92
+ # @return [Numo::NArray] [...,M,N]
93
+ def matmul_batch(left, right, dtype: nil)
94
+ left = cast_float(left, dtype)
95
+ right = cast_float(right, left.class)
96
+ validate_rank!(left, 2)
97
+ validate_rank!(right, 2)
98
+ leading_shape = left.shape[0...-2]
99
+ unless right.shape[0...-2] == leading_shape && left.shape[-1] == right.shape[-2]
100
+ raise ShapeError, "matmul shape mismatch: left #{left.shape.inspect}, right #{right.shape.inspect}"
101
+ end
102
+
103
+ rows = left.shape[-2]
104
+ shared = left.shape[-1]
105
+ columns = right.shape[-1]
106
+ batch_size = leading_shape.empty? ? 1 : leading_shape.inject(:*)
107
+ left_flat = left.reshape(batch_size, rows, shared)
108
+ right_flat = right.reshape(batch_size, shared, columns)
109
+ output = multiply_flat(left_flat, right_flat, rows, shared, columns)
110
+ reshape_matrix(output, leading_shape, rows, columns)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module Math
5
+ # Batched wxyz quaternion normalization, rotation conversion, and VJPs.
6
+ module Quaternion
7
+ module_function
8
+
9
+ # Normalizes wxyz quaternions along the last dimension.
10
+ #
11
+ # @param quaternions [Numo::NArray] [...,4]
12
+ # @param dtype [Class, Symbol, nil] optional float32/float64 calculation type
13
+ # @param epsilon [Numeric, nil] minimum norm
14
+ # @return [Numo::NArray] [...,4]
15
+ def normalize(quaternions, dtype: nil, epsilon: nil)
16
+ typed, leading_shape, flat = prepare(quaternions, 4, dtype)
17
+ denominator = norm_denominator(flat, epsilon || default_epsilon(typed.class))
18
+ (flat / denominator.reshape(flat.shape[0], 1)).reshape(*(leading_shape + [4]))
19
+ end
20
+
21
+ # VJP of quaternion normalization.
22
+ #
23
+ # @param quaternions [Numo::NArray] [...,4]
24
+ # @param grad_output [Numo::NArray] [...,4]
25
+ # @param dtype [Class, Symbol, nil] optional float32/float64 calculation type
26
+ # @param epsilon [Numeric, nil] minimum norm
27
+ # @return [Numo::NArray] [...,4]
28
+ def normalize_vjp(quaternions, grad_output, dtype: nil, epsilon: nil)
29
+ typed, leading_shape, flat = prepare(quaternions, 4, dtype)
30
+ gradient, = prepare_matching(grad_output, leading_shape, 4, typed.class)
31
+ denominator = norm_denominator(flat, epsilon || default_epsilon(typed.class))
32
+ normalized = flat / denominator.reshape(flat.shape[0], 1)
33
+ dot = (normalized * gradient).sum(axis: 1)
34
+ result = (gradient - (normalized * dot.reshape(flat.shape[0], 1))) / denominator.reshape(flat.shape[0], 1)
35
+ result.reshape(*(leading_shape + [4]))
36
+ end
37
+
38
+ # Converts wxyz quaternions to rotation matrices.
39
+ #
40
+ # @param quaternions [Numo::NArray] [...,4]
41
+ # @param dtype [Class, Symbol, nil] optional float32/float64 calculation type
42
+ # @return [Numo::NArray] [...,3,3]
43
+ def to_rotmat(quaternions, dtype: nil)
44
+ typed, leading_shape, = prepare(quaternions, 4, dtype)
45
+ flat = normalize(typed).reshape(typed.size / 4, 4)
46
+ quat_w = flat[true, 0]
47
+ quat_x = flat[true, 1]
48
+ quat_y = flat[true, 2]
49
+ quat_z = flat[true, 3]
50
+ output = typed.class.zeros(flat.shape[0], 3, 3)
51
+ fill_rotation!(output, quat_w, quat_x, quat_y, quat_z)
52
+ output.reshape(*(leading_shape + [3, 3]))
53
+ end
54
+
55
+ # VJP of quaternion-to-rotation-matrix conversion.
56
+ #
57
+ # @param quaternions [Numo::NArray] [...,4]
58
+ # @param grad_output [Numo::NArray] [...,3,3]
59
+ # @param dtype [Class, Symbol, nil] optional float32/float64 calculation type
60
+ # @return [Numo::NArray] [...,4]
61
+ def to_rotmat_vjp(quaternions, grad_output, dtype: nil)
62
+ typed, leading_shape, = prepare(quaternions, 4, dtype)
63
+ gradient = cast_float(grad_output, typed.class)
64
+ unless gradient.shape == leading_shape + [3, 3]
65
+ raise ShapeError, "expected gradient #{(leading_shape + [3, 3]).inspect}, got #{gradient.shape.inspect}"
66
+ end
67
+
68
+ normalized = normalize(typed).reshape(typed.size / 4, 4)
69
+ grad_matrix = gradient.reshape(normalized.shape[0], 3, 3)
70
+ grad_normalized = rotation_vjp(normalized, grad_matrix)
71
+ normalize_vjp(typed, grad_normalized.reshape(*(leading_shape + [4])))
72
+ end
73
+
74
+ def prepare(quaternions, width, dtype)
75
+ typed = cast_float(quaternions, dtype)
76
+ unless typed.ndim >= 1 && typed.shape[-1] == width
77
+ raise ShapeError, "expected [...,#{width}], got #{typed.shape.inspect}"
78
+ end
79
+
80
+ leading_shape = typed.shape[0...-1]
81
+ [typed, leading_shape, typed.reshape(typed.size / width, width)]
82
+ end
83
+ private_class_method :prepare
84
+
85
+ def prepare_matching(value, leading_shape, width, dtype)
86
+ typed = cast_float(value, dtype)
87
+ expected = leading_shape + [width]
88
+ raise ShapeError, "expected #{expected.inspect}, got #{typed.shape.inspect}" unless typed.shape == expected
89
+
90
+ [typed.reshape(typed.size / width, width), leading_shape]
91
+ end
92
+ private_class_method :prepare_matching
93
+
94
+ def cast_float(array, dtype)
95
+ raise ArgumentError, "expected a Numo::NArray" unless array.is_a?(Numo::NArray)
96
+
97
+ type = case dtype
98
+ when nil then array.class
99
+ when :float32 then Numo::SFloat
100
+ when :float64 then Numo::DFloat
101
+ else dtype
102
+ end
103
+ unless [Numo::SFloat, Numo::DFloat].include?(type)
104
+ raise ArgumentError, "dtype must be Numo::SFloat or Numo::DFloat"
105
+ end
106
+
107
+ type.cast(array)
108
+ end
109
+ private_class_method :cast_float
110
+
111
+ def norm_denominator(flat, epsilon)
112
+ norms = (flat**2).sum(axis: 1)**0.5
113
+ norms[norms.lt(epsilon)] = epsilon
114
+ norms
115
+ end
116
+ private_class_method :norm_denominator
117
+
118
+ def default_epsilon(type)
119
+ type == Numo::DFloat ? 1e-12 : 1e-6
120
+ end
121
+ private_class_method :default_epsilon
122
+
123
+ # rubocop:disable Metrics/AbcSize
124
+ def fill_rotation!(output, quat_w, quat_x, quat_y, quat_z)
125
+ output[true, 0, 0] = 1 - (2 * ((quat_y**2) + (quat_z**2)))
126
+ output[true, 0, 1] = 2 * ((quat_x * quat_y) - (quat_w * quat_z))
127
+ output[true, 0, 2] = 2 * ((quat_x * quat_z) + (quat_w * quat_y))
128
+ output[true, 1, 0] = 2 * ((quat_x * quat_y) + (quat_w * quat_z))
129
+ output[true, 1, 1] = 1 - (2 * ((quat_x**2) + (quat_z**2)))
130
+ output[true, 1, 2] = 2 * ((quat_y * quat_z) - (quat_w * quat_x))
131
+ output[true, 2, 0] = 2 * ((quat_x * quat_z) - (quat_w * quat_y))
132
+ output[true, 2, 1] = 2 * ((quat_y * quat_z) + (quat_w * quat_x))
133
+ output[true, 2, 2] = 1 - (2 * ((quat_x**2) + (quat_y**2)))
134
+ end
135
+ # rubocop:enable Metrics/AbcSize
136
+ private_class_method :fill_rotation!
137
+
138
+ # rubocop:disable Metrics/AbcSize
139
+ def rotation_vjp(quaternion, gradient)
140
+ quat_w = quaternion[true, 0]
141
+ quat_x = quaternion[true, 1]
142
+ quat_y = quaternion[true, 2]
143
+ quat_z = quaternion[true, 3]
144
+ grad_at = ->(row, column) { gradient[true, row, column] }
145
+ output = quaternion.class.zeros(*quaternion.shape)
146
+ output[true, 0] = (
147
+ (-2 * quat_z * grad_at.call(0, 1)) + (2 * quat_y * grad_at.call(0, 2)) +
148
+ (2 * quat_z * grad_at.call(1, 0)) - (2 * quat_x * grad_at.call(1, 2)) -
149
+ (2 * quat_y * grad_at.call(2, 0)) + (2 * quat_x * grad_at.call(2, 1))
150
+ )
151
+ output[true, 1] = (
152
+ (2 * quat_y * grad_at.call(0, 1)) + (2 * quat_z * grad_at.call(0, 2)) +
153
+ (2 * quat_y * grad_at.call(1, 0)) - (4 * quat_x * grad_at.call(1, 1)) -
154
+ (2 * quat_w * grad_at.call(1, 2)) + (2 * quat_z * grad_at.call(2, 0)) +
155
+ (2 * quat_w * grad_at.call(2, 1)) - (4 * quat_x * grad_at.call(2, 2))
156
+ )
157
+ output[true, 2] = (
158
+ (-4 * quat_y * grad_at.call(0, 0)) + (2 * quat_x * grad_at.call(0, 1)) +
159
+ (2 * quat_w * grad_at.call(0, 2)) + (2 * quat_x * grad_at.call(1, 0)) +
160
+ (2 * quat_z * grad_at.call(1, 2)) - (2 * quat_w * grad_at.call(2, 0)) +
161
+ (2 * quat_z * grad_at.call(2, 1)) - (4 * quat_y * grad_at.call(2, 2))
162
+ )
163
+ output[true, 3] = (
164
+ (-4 * quat_z * grad_at.call(0, 0)) - (2 * quat_w * grad_at.call(0, 1)) +
165
+ (2 * quat_x * grad_at.call(0, 2)) + (2 * quat_w * grad_at.call(1, 0)) -
166
+ (4 * quat_z * grad_at.call(1, 1)) + (2 * quat_y * grad_at.call(1, 2)) +
167
+ (2 * quat_x * grad_at.call(2, 0)) + (2 * quat_y * grad_at.call(2, 1))
168
+ )
169
+ output
170
+ end
171
+ # rubocop:enable Metrics/AbcSize
172
+ private_class_method :rotation_vjp
173
+ end
174
+ end
175
+ end