"""Volt-v1m2: Sonata-compatible Volt encoder + transposed-conv decoder.
Differs from v1m1 by upsampling the encoder's per-token features back to
per-input-point features through a SparseInverseConv3d decoder (matching
original Volt's design at libs/Volt/pointcept/models/volt/decoder.py).
The output is a Point at input resolution — no `pooling_parent`/
`pooling_inverse` chain, no `head_indices`-based label propagation.
Why: with v1m1 the SSL matching loss runs at token resolution, so KNN
across views works fine; but per-point downstream tasks see *patch-uniform*
features (every input point in a patch shares its parent token). v1m2
gives every input point a unique upsampled feature, which (a) avoids
the patch-uniform pathology entirely and (b) gives ~10x more samples
per batch to a SIGReg-style loss.
Pipeline:
stem -> mask injection -> strided tokenizer -> RoPE blocks -> final norm
-> SparseInverseConv3d decoder -> per-input-point Point
Pair with Sonata using `up_cast_level=0` (no pooling chain) and
`head_in_channels=decoder_dim` (typically 256, matching Volt's default).
"""
from __future__ import annotations
from typing import Optional
import spconv.pytorch as spconv
import torch
import torch.nn as nn
from timm.layers import DropPath, Mlp
from timm.models.vision_transformer import LayerScale
from torch.nn.init import trunc_normal_
from pimm.models.builder import MODELS
from pimm.models.modules import PointModel, PointModule, PointSequential
from pimm.models.utils.structure import Point
from pimm.utils.logger import get_logger
try:
from flash_attn import flash_attn_varlen_qkvpacked_func
except ImportError:
flash_attn_varlen_qkvpacked_func = None
logger = get_logger(__name__)
class RoPE(nn.Module):
def __init__(
self,
theta: float = 100.0,
freq_split: tuple = (12, 12, 8),
max_grid_size: tuple = (1024, 1024, 512),
) -> None:
super().__init__()
freqs_x = 1.0 / theta ** torch.linspace(0, 1, freq_split[0])
freqs_y = 1.0 / theta ** torch.linspace(0, 1, freq_split[1])
freqs_z = 1.0 / theta ** torch.linspace(0, 1, freq_split[2])
self.register_buffer(
"cis_cache_x", self._precompute(freqs_x, max_grid_size[0]), persistent=False
)
self.register_buffer(
"cis_cache_y", self._precompute(freqs_y, max_grid_size[1]), persistent=False
)
self.register_buffer(
"cis_cache_z", self._precompute(freqs_z, max_grid_size[2]), persistent=False
)
@staticmethod
def _precompute(freqs, max_pos):
freqs_pos = torch.outer(torch.arange(max_pos).float(), freqs)
return torch.polar(torch.ones_like(freqs_pos), freqs_pos)
def compute_axial_cis_efficient(self, indices):
cis_x = self.cis_cache_x[indices[:, 0]]
cis_y = self.cis_cache_y[indices[:, 1]]
cis_z = self.cis_cache_z[indices[:, 2]]
return torch.cat([cis_x, cis_y, cis_z], dim=-1).unsqueeze(0)
class RoPE_Attention(nn.Module):
def __init__(
self,
dim: int = 768,
num_heads: int = 12,
qk_norm: bool = False,
) -> None:
super().__init__()
assert dim % num_heads == 0, "dim should be divisible by num_heads"
self.num_heads = num_heads
self.h_dim = dim // num_heads
self.qkv = nn.Linear(dim, 3 * dim)
self.proj = nn.Linear(dim, dim)
self.q_norm = nn.LayerNorm(self.h_dim) if qk_norm else nn.Identity()
self.k_norm = nn.LayerNorm(self.h_dim) if qk_norm else nn.Identity()
@staticmethod
def apply_rotary_emb(
q: torch.Tensor,
k: torch.Tensor,
freqs_cis: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
q_ = torch.view_as_complex(q.float().reshape(*q.shape[:-1], -1, 2))
k_ = torch.view_as_complex(k.float().reshape(*k.shape[:-1], -1, 2))
q_out = torch.view_as_real(q_ * freqs_cis).flatten(2)
k_out = torch.view_as_real(k_ * freqs_cis).flatten(2)
return q_out.type_as(q), k_out.type_as(k)
def forward(
self,
x: torch.Tensor,
freqs_cis: torch.Tensor,
cu_seqlens: torch.Tensor,
max_seqlen: int,
):
N, C = x.shape
qkv = self.qkv(x).view(N, 3, self.num_heads, self.h_dim)
qkv = qkv.permute(1, 2, 0, 3)
q, k, v = qkv.unbind(dim=0)
q, k = self.q_norm(q).to(q.dtype), self.k_norm(k).to(k.dtype)
q, k = self.apply_rotary_emb(q, k, freqs_cis)
qkv = torch.stack([q, k, v], dim=0).permute(2, 0, 1, 3)
qkv_dtype = qkv.dtype
x = flash_attn_varlen_qkvpacked_func(
qkv.half(),
cu_seqlens,
max_seqlen=max_seqlen,
)
x = x.reshape(-1, C).to(qkv_dtype)
x = self.proj(x)
return x
class Block(PointModule):
"""RoPE+FlashAttn transformer block taking and returning a `Point`.
On the first call within a forward pass, computes the attention
bookkeeping (`cu_seqlens`, `max_seqlen`, `freqs_cis`) from the Point's
`sparse_conv_feat.indices` and caches it on the Point itself; subsequent
blocks reuse the cache. Mirrors PT-v3m2's pattern of caching attention
metadata on the Point (e.g. `point.serialized_order`) so the encoder
body doesn't have to plumb it through every call.
"""
def __init__(
self,
dim: int = 768,
num_heads: int = 12,
mlp_ratio: float = 4.0,
init_values: Optional[float] = None,
qk_norm: bool = False,
drop_path: float = 0.0,
act_layer: nn.Module = nn.GELU,
norm_layer: nn.Module = nn.LayerNorm,
mlp_layer: nn.Module = Mlp,
pos_enc: Optional[nn.Module] = None,
) -> None:
super().__init__()
# Shared RoPE module (registered on parent too); PyTorch handles
# the shared sub-module fine and RoPE's caches are non-persistent
# buffers so state_dict isn't polluted.
self.pos_enc = pos_enc
self.norm1 = norm_layer(dim)
self.attn = RoPE_Attention(
dim=dim,
num_heads=num_heads,
qk_norm=qk_norm,
)
self.ls1 = (
LayerScale(dim, init_values=init_values) if init_values else nn.Identity()
)
self.drop_path1 = DropPath(drop_path) if drop_path > 0.0 else nn.Identity()
self.norm2 = norm_layer(dim)
self.mlp = mlp_layer(
in_features=dim,
hidden_features=int(dim * mlp_ratio),
act_layer=act_layer,
)
self.ls2 = (
LayerScale(dim, init_values=init_values) if init_values else nn.Identity()
)
self.drop_path2 = DropPath(drop_path) if drop_path > 0.0 else nn.Identity()
def _compute_seqlens_and_freqs(self, point: Point):
"""Fetch (cu_seqlens, max_seqlen, freqs_cis) from the Point, computing
and caching them on the Point itself if absent."""
if "cu_seqlens" not in point.keys():
indices = point.sparse_conv_feat.indices
counts = torch.bincount(indices[:, 0])
cu = torch.zeros(
counts.numel() + 1, dtype=torch.int32, device=indices.device
)
cu[1:] = torch.cumsum(counts.to(torch.int32), dim=0)
point["cu_seqlens"] = cu
point["max_seqlen"] = int(counts.max().item()) if counts.numel() else 0
point["freqs_cis"] = self.pos_enc.compute_axial_cis_efficient(
indices[:, 1:].long()
)
return point.cu_seqlens, point.max_seqlen, point.freqs_cis
def forward(self, point: Point) -> Point:
cu_seqlens, max_seqlen, freqs_cis = self._compute_seqlens_and_freqs(point)
x = point.feat
x = x + self.drop_path1(
self.ls1(self.attn(self.norm1(x), freqs_cis, cu_seqlens, max_seqlen))
)
x = x + self.drop_path2(self.ls2(self.mlp(self.norm2(x))))
point.feat = x
if "sparse_conv_feat" in point.keys():
point.sparse_conv_feat = point.sparse_conv_feat.replace_feature(x)
return point
class Stem(PointModule):
"""Per-point input projection + optional learnable mask token.
Mirrors PT-v3m2's `Embedding` (point_transformer_v3m2_sonata.py:585-619):
a `PointSequential` of `Linear` (+ optional `norm_layer` / `act_layer`)
followed by a learnable mask-token override on points where `point.mask`
is True. `PointSequential` keeps `point.feat` and `point.sparse_conv_feat`
in sync through the linear projection; we sync once more after the mask
`where` so the downstream `Tokenizer` reads the mask-injected SCT.
"""
def __init__(
self,
in_channels: int,
stem_channels: int,
norm_layer=None,
act_layer=None,
mask_token: bool = True,
):
super().__init__()
self.in_channels = in_channels
self.stem_channels = stem_channels
self.stem = PointSequential(linear=nn.Linear(in_channels, stem_channels))
if norm_layer is not None:
self.stem.add(norm_layer(stem_channels), name="norm")
if act_layer is not None:
self.stem.add(act_layer(), name="act")
if mask_token:
self.mask_token = nn.Parameter(torch.zeros(1, stem_channels))
else:
self.mask_token = None
def forward(self, point: Point) -> Point:
point = self.stem(point)
if (
self.mask_token is not None
and "mask" in point.keys()
and point.mask is not None
):
point.feat = torch.where(
point.mask.unsqueeze(-1),
self.mask_token.to(point.feat.dtype),
point.feat,
)
if "sparse_conv_feat" in point.keys():
point.sparse_conv_feat = point.sparse_conv_feat.replace_feature(
point.feat
)
return point
class Tokenizer(PointModule):
"""Strided sparse-conv tokenizer: input voxels -> 5x5x5 patch tokens."""
def __init__(
self,
in_channels: int,
embed_dim: int,
kernel_size: int = 5,
stride: int = 5,
indice_key: str = "embedding",
):
super().__init__()
self.conv = spconv.SparseConv3d(
in_channels,
embed_dim,
kernel_size=kernel_size,
stride=stride,
bias=True,
indice_key=indice_key,
)
def forward(self, point: Point) -> Point:
x = self.conv(point.sparse_conv_feat)
point.sparse_conv_feat = x
point.feat = x.features
return point
class Decoder(PointModule):
"""Transposed-conv decoder upsampling 5x5x5 patches back to per-input-voxel features.
Structurally mirrors libs/Volt/pointcept/models/volt/decoder.py (Linear
projection `embed_dim -> out_channels` followed by SparseInverseConv3d
using the cached `indice_key` from the parent tokenizer to restore the
original input voxel positions). Differs from original Volt by using
`LayerNorm` instead of `BatchNorm1d` — BN was fine for supervised
fine-tuning but produces train/eval-mode-asymmetric features that
break SSL online linear probing. LN is batch-independent.
"""
def __init__(
self,
in_channels: int,
out_channels: int,
kernel_size: int,
indice_key: str = "embedding",
):
super().__init__()
act_layer = nn.GELU
self.up = spconv.SparseSequential(
nn.LayerNorm(in_channels),
act_layer(),
nn.Linear(in_channels, out_channels, bias=False),
nn.LayerNorm(out_channels),
act_layer(),
spconv.SparseInverseConv3d(
out_channels,
out_channels,
kernel_size=kernel_size,
indice_key=indice_key,
bias=False,
),
nn.LayerNorm(out_channels),
act_layer(),
)
def forward(self, point: Point) -> Point:
x = self.up(point.sparse_conv_feat)
point.sparse_conv_feat = x
point.feat = x.features
return point
[docs]
@MODELS.register_module("Volt-v1m2")
class VoltBackboneV2(PointModel):
"""Flat Volt encoder + transposed-conv decoder, returning per-input-point features."""
def __init__(
self,
in_channels: int = 4,
stem_channels: int = 32,
embed_dim: int = 384,
depth: int = 12,
num_heads: int = 6,
mlp_ratio: float = 4.0,
init_values: Optional[float] = None,
qk_norm: bool = True,
drop_path: float = 0.3,
increase_drop_path: bool = True,
stride: int = 5,
kernel_size: int = 5,
mask_token: bool = True,
decoder_dim: int = 256,
rope_max_grid_size: tuple = (1024, 1024, 1024),
rope_freq_split: tuple = (11, 11, 10),
):
super().__init__()
self.stride = stride
self.kernel_size = kernel_size
self.embed_dim = embed_dim
self.decoder_dim = decoder_dim
self.stem = Stem(in_channels, stem_channels, mask_token=mask_token)
self.tokenizer = Tokenizer(
in_channels=stem_channels,
embed_dim=embed_dim,
kernel_size=kernel_size,
stride=stride,
indice_key="embedding",
)
h_dim = embed_dim // num_heads
assert sum(rope_freq_split) * 2 == h_dim, (
f"rope_freq_split {tuple(rope_freq_split)} sums to {sum(rope_freq_split)}, "
f"but h_dim//2 = {h_dim // 2} for embed_dim={embed_dim}, num_heads={num_heads}. "
f"Adjust rope_freq_split so that sum(..)*2 == embed_dim // num_heads."
)
self.pos_enc = RoPE(
freq_split=tuple(rope_freq_split),
max_grid_size=tuple(rope_max_grid_size),
)
if increase_drop_path:
dp = torch.linspace(0, drop_path, depth).tolist()
else:
dp = [drop_path] * depth
self.blocks = PointSequential(
*[
Block(
dim=embed_dim,
num_heads=num_heads,
mlp_ratio=mlp_ratio,
init_values=init_values,
qk_norm=qk_norm,
drop_path=dp[i],
act_layer=nn.GELU,
norm_layer=nn.LayerNorm,
pos_enc=self.pos_enc,
)
for i in range(depth)
]
)
self.decoder = Decoder(
in_channels=embed_dim,
out_channels=decoder_dim,
kernel_size=kernel_size,
indice_key="embedding",
)
for name, module in self.named_modules():
self._init_weights(module, name=name)
logger.info(
f"Volt-v1m2: in_channels={in_channels}, stem_channels={stem_channels}, "
f"embed_dim={embed_dim}, depth={depth}, num_heads={num_heads}, "
f"stride={stride}, kernel_size={kernel_size}, mask_token={mask_token}, "
f"decoder_dim={decoder_dim}"
)
@staticmethod
def _init_weights(module, name: str = ""):
if isinstance(module, nn.Linear):
# keep kaiming for stem and decoder
if name.startswith("stem.") or name.startswith("decoder."):
return
trunc_normal_(module.weight, std=0.02)
if module.bias is not None:
nn.init.zeros_(module.bias)
elif hasattr(module, "init_weights"):
module.init_weights()
[docs]
def forward(self, point):
if not isinstance(point, Point):
point = Point(point)
point.sparsify(pad=96)
point = self.stem(point)
point = self.tokenizer(point)
point = self.blocks(point)
point = self.decoder(point)
return point