Adaptive voxelization
riemann::adaptive stores a scene as non-overlapping leaves at different refinement levels.
Detail goes only where it is needed: surfaces, dense regions or high-variance features.
- Headers
- The model
- Configuration
- Building a tensor
- Refinement and coarsening
- Inspecting a tensor
- Operations
- Devices
- Interop with uniform sparse tensors
- Limitations
Headers
#include <octree/adaptive.hpp> // canonical
// #include <dynamic/dynamic.hpp> // forwarding alias; also pulled in by <riemann.hpp>
using namespace riemann::adaptive;
The model
- Level 0 cells measure
base_voxel_sizeper axis. Each level halves the cell extent, so a level-Lcell isbase / 2^L. max_depthcaps refinement. Dimensions 1 to 4 are supported, with depth up to 30.- A leaf is identified by an
AdaptiveVoxelKey<D>{coordinates, level}. Its integercoordinatesare in level-Lcell units, relative toorigin. - Cells are half-open boxes
[lo, hi). The active leaves never overlap. - Refining a leaf replaces it with
2^Dchildren.RefinePolicy::Replicatecopies its features into the children, andSplitdivides them by2^Dso sums are conserved. - Coarsening merges descendants back into one leaf with
Reduction::Mean,SumorMax.
level 0 ┌───────────────┬───────────────┐
│ │ ┌───┬───┐ │
│ L0 │ │L2 │L2 │L1 │ <- refined only near detail
│ │ ├───┼───┤ │
│ │ │L2 │L2 │ │
└───────────────┴───┴───┴───┴───┘
Configuration
struct AdaptiveVoxelConfig {
std::vector<double> base_voxel_size; // required, one entry per axis
std::vector<double> min_voxel_size; // optional; if set, must equal base / 2^max_depth
std::vector<double> origin; // optional; empty = zero vector
uint32_t max_depth = 0;
void validate() const; // throws std::invalid_argument
};
AdaptiveVoxelConfig cfg;
cfg.base_voxel_size = {8.0, 8.0, 8.0};
cfg.min_voxel_size = {1.0, 1.0, 1.0};
cfg.max_depth = 3; // 8 -> 4 -> 2 -> 1
Building a tensor
// From points: quantize to level-0 cells, averaging features per cell
auto t = AdaptiveSparseTensor::from_points(points /*[N,D] float64*/,
features /*[N,C]*/, cfg);
// From explicit leaves
auto u = AdaptiveSparseTensor::from_voxels(coords /*[M,D] int64*/, levels /*[M]*/,
feats /*[M,C]*/, cfg);
from_points keeps the source points. refine_by_density, refine_by_variance and
revoxelize_from_points() re-bin them as the topology changes.
Refinement and coarsening
// Region-based: refine everything inside a box to level 2
t.refine(AxisAlignedRegion{{0, 0, 0}, {8, 8, 8}}, /*target_level=*/2);
t.refine(AxisAlignedRegion::everything(3), 1, RefinePolicy::Split);
// Data-driven (needs source points)
t.refine_by_density(/*points_per_leaf_threshold=*/16);
t.refine_by_variance(/*feature_variance_threshold=*/0.05);
// Arbitrary predicate on each leaf's bounds and level
t.refine_if([](int64_t leaf, const std::array<double, 4>& lo,
const std::array<double, 4>& hi, uint32_t level) {
return lo[2] < 1.0 && level < 3; // refine near the floor
});
// Merge back
t.coarsen(AxisAlignedRegion::everything(3), /*target_level=*/0, Reduction::Max);
Inspecting a tensor
| Method | Returns |
|---|---|
size(), dimension(), channels() |
Leaf count, D, feature width |
coordinates(), levels() |
[N, D] int64 cell coordinates, [N] levels |
centers(), bounds(), voxel_sizes(), volumes() |
World-space geometry per leaf |
features(), set_features(f), with_features(f) |
Feature access (with_features returns a copy) |
counts_by_level() |
Leaves per level |
index_of({x,y,z}, level), index_of_key(coords, level) |
Row of a leaf, or -1 |
has_overlapping_leaves() |
Sanity check, should be false |
topology_version() |
Increments on every topology change. Used by caches |
Operations
Adaptive convolution
AdaptiveKernelConfig kc;
kc.kernel_size = 3; // positive, odd
kc.mode = AdaptiveConvMode::VolumeWeighted; // CenterSampled | VolumeWeighted | OverlapWeighted
kc.output_level_shift = 0; // 0 = same leaves; -n = coarser; +n = subdivide
auto weight = torch::randn({27, t.channels(), 16}, torch::dtype(torch::kFloat64).requires_grad(true));
AdaptiveSparseTensor y = adaptive_convolution(t, weight, kc); // weight [K^D, Cin, Cout]
// Transposed: maps back onto a reference topology (the forward conv's input)
AdaptiveSparseTensor back = adaptive_convolution_transpose(y, weight_t, kc, /*reference=*/t);
Each kernel tap sits at output centre + offset × output cell size.
| Mode | How neighbours of different sizes contribute |
|---|---|
CenterSampled (default) |
The leaf that contains the tap point, weight 1. On a uniform grid this matches an ordinary sparse convolution |
VolumeWeighted |
Same neighbour, weighted V_in / V_out |
OverlapWeighted |
Every leaf overlapping the tap box, weighted overlap / V_out. Most accurate, and cost grows with the level gap |
output_level_shift = -n lifts each output to its ancestor n levels up (never below level 0).
+n subdivides every leaf n times, which multiplies the output count by 2^(D·n) and must fit
max_depth.
Kernel maps are cached in KernelMapCache::instance(), keyed by (topology id, kernel size, mode,
shift). Topology changes mint new ids, so stale maps are never served. The default capacity is
64 entries. Inspect the cache with .stats(), and manage it with .set_capacity(n) and .clear().
build_adaptive_kernel_map(t, kc) exposes the raw (in, out, kernel, weight) lists.
Pooling, resampling and interpolation
at::Tensor g = adaptive_global_avg_pool(t, /*volume_weighted=*/true); // [C]
auto c = adaptive_pool(t, /*target_level=*/1, Reduction::Max); // coarsen finer leaves
auto u = t.resample_uniform(/*level=*/2); // all leaves at one level
auto r = a.resample_to(b); // onto b's topology
auto s = r + b; // same topology -> elementwise
auto cat = concatenate(r, b); // channel concat
at::Tensor v = interpolate(t, query_points /*[M,D]*/, InterpMode::Multilinear); // [M, C]; default Nearest
adaptive_pool coarsens every leaf finer than the target level. resample_to reduces finer
sources, replicates coarser ones and fills uncovered reference leaves with zeros. interpolate
returns zeros for points outside every leaf.
resample_uniformat a fine level can produce a very large number of leaves. Computepow(2, D*level) * size()first to bound it.
Sphere voxelization
double achieved = 0.0;
auto sphere = voxelize_sphere(/*radius=*/1.0, /*target_accuracy_percent=*/99.5,
/*center=*/{0, 0, 0}, /*max_voxels=*/10'000'000,
/*max_depth=*/10, &achieved);
double vol = sphere_volume_estimate(sphere); // compare with 4/3 π r³
auto gpu_sphere = voxelize_sphere_cuda(1.0, 99.5); // CUDA builds only
The voxelizer starts from one cube of side 2r and splits the largest boundary cells first, until
accuracy = 100 × (1 − |estimate − exact| / exact) reaches the target. The target must be in
(0, 100). A leaf counts as occupied when its centre is inside the sphere. The result has one
float channel: 1 for occupied leaves and 0 otherwise. It throws std::runtime_error when the
target cannot be reached within max_voxels or max_depth.
Devices
t.to(torch::kCUDA)moves features to the GPU. The topology and kernel maps stay on the host and are built withstd::threadworkers.adaptive_convolutionruns its gather/GEMM/scatter on the features’ device.- Convolution, pooling and interpolation are differentiable with respect to features and weights.
Interop with uniform sparse tensors
There is no direct conversion function yet. To feed a uniform SparseTensor network, resample to
one level and wrap the coordinates:
auto u = t.resample_uniform(2);
auto coords = torch::cat({torch::zeros({u.size(), 1}, torch::kInt64), u.coordinates()}, 1)
.to(torch::kInt32);
riemann::SparseTensor x(u.features().to(torch::kFloat32), coords);
Limitations
| Area | Status |
|---|---|
| Hierarchy and kernel maps | Host-side (multi-threaded), not GPU |
| Uniform fast path | Not implemented. Uniform inputs still use the general adaptive path |
output_level_shift > 0 |
Output count grows by 2^(D·n). A std::length_error is thrown if it would overflow |
OverlapWeighted cost |
Grows with the level gap between neighbours |