Skip to content

Commit 6025c81

Browse files
harrismclaude
andauthored
Fix crash when accumulated_gradient_step_counts is None during refinement (openvdb#281)
## Summary - Add defensive None guard in `_compute_insertion_masks` to handle the case where gradient accumulation tensors are `None` (e.g., when using the Unscented Transform projection path for OpenCV camera models) - Add regression test that reproduces the exact `AttributeError` from issue openvdb#279 ## Root Cause When COLMAP datasets use non-pinhole camera models (SIMPLE_RADIAL, RADIAL, OPENCV), fvdb maps them to `CameraModel.OPENCV_RADTAN_5`, which forces the UT projection path in fvdb-core. The UT path does not initialize `accumulated_gradient_step_counts` or `accumulated_mean_2d_gradient_norms`, leaving them as `None`. At the first refinement step, `_compute_insertion_masks()` crashes calling `.clamp_min(1)` on `None`. The companion fvdb-core fix is at harrism/fvdb-core#1 — these two fixes are independent and can be merged in any order. ## Behavioral impact When the UT projection path is used, no Gaussians will be duplicated or split during refinement (since no gradient data is available to make that decision). Deletion still works. Training converges, just without adaptive densification — a reasonable degradation until fvdb-core adds gradient accumulation to the UT kernel. Closes openvdb#279 ## Test plan - [x] New test `test_refinement_with_none_gradient_accumulation` reproduces the crash (red before fix, green after) - [x] All 9 optimizer tests pass with zero regressions 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Mark Harris <mharris@nvidia.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c6d56fc commit 6025c81

2 files changed

Lines changed: 50 additions & 0 deletions

File tree

fvdb_reality_capture/radiance_fields/gaussian_splat_optimizer.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,27 @@ def _compute_insertion_masks(
835835
# We use the average norm of the gradients of the projected Gaussians with respect to the
836836
# loss (accumulated since the last refinement step) to decide which Gaussians to duplicate or split.
837837

838+
# Guard against None gradient accumulation tensors. This can happen when a projection method
839+
# (e.g., Unscented Transform for OpenCV camera models) does not accumulate 2D mean gradients.
840+
if (
841+
self._model.accumulated_gradient_step_counts is None
842+
or self._model.accumulated_mean_2d_gradient_norms is None
843+
):
844+
if not getattr(self, "_warned_missing_gradient_accumulation", False):
845+
self._logger.warning(
846+
"Gradient accumulation data is unavailable (accumulated_gradient_step_counts or "
847+
"accumulated_mean_2d_gradient_norms is None). This is expected when using a projection "
848+
"method that does not support gradient accumulation (e.g., Unscented Transform for "
849+
"OpenCV camera models). Skipping Gaussian insertion for this refinement step."
850+
)
851+
self._warned_missing_gradient_accumulation = True
852+
device = self._model.means.device
853+
N = self._model.num_gaussians
854+
return (
855+
torch.zeros(N, dtype=torch.bool, device=device),
856+
torch.zeros(N, dtype=torch.bool, device=device),
857+
)
858+
838859
# model.accumulated_gradient_step_counts is the number of times a Gaussian has been projected
839860
# to an image (i.e. included in the loss gradient computation)
840861
# model.accumulated_mean_2d_gradient_norms is the sum of norms of the gradients of the

tests/unit/test_gaussian_splat_optimizer.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,35 @@ def setUp(self):
116116
loss.backward()
117117
self.optimizer.step()
118118

119+
def test_refinement_with_none_gradient_accumulation(self):
120+
"""Regression test for issue #279: refine() must not crash when gradient accumulation state is None.
121+
122+
When the Unscented Transform projection path is used (e.g., for OpenCV camera models),
123+
fvdb-core does not initialize accumulated_gradient_step_counts or
124+
accumulated_mean_2d_gradient_norms. Calling set_state() also resets them to None.
125+
The optimizer must handle this gracefully by skipping insertion (duplication/splitting).
126+
"""
127+
model = self.model
128+
optimizer = self.optimizer
129+
130+
# Simulate the UT projection path: set_state() resets gradient tensors to None
131+
with torch.no_grad():
132+
model.set_state(
133+
means=model.means,
134+
quats=model.quats,
135+
log_scales=model.log_scales,
136+
logit_opacities=model.logit_opacities,
137+
sh0=model.sh0,
138+
shN=model.shN,
139+
)
140+
self.assertIsNone(model.accumulated_gradient_step_counts)
141+
self.assertIsNone(model.accumulated_mean_2d_gradient_norms)
142+
143+
# refine() should not crash — it should skip insertion and return zero counts
144+
refine_stats = optimizer.refine(zero_gradients=True)
145+
self.assertEqual(refine_stats["num_duplicated"], 0)
146+
self.assertEqual(refine_stats["num_split"], 0)
147+
119148
def test_refinement_no_op(self):
120149
model = self.model
121150
optimizer = self.optimizer

0 commit comments

Comments
 (0)