rigidformer / src /models /encoder.py
multimodalart's picture
multimodalart HF Staff
RigidFormer demo
632d805 verified
Raw History Blame Contribute Delete
39 kB
"""
PointNet-based encoder for rigid body representation.
Contains the per-object point cloud encoder (PointNet backbone with spatial
and feature transforms), multi-strategy global pooling, and step-size FiLM
conditioning.
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
from typing import Tuple, Optional
class STN3d(nn.Module):
"""Spatial Transformer Network for 3D point alignment."""
def __init__(self, input_dim: int = 3):
super().__init__()
self.input_dim = input_dim
# Convolutional layers
self.conv1 = nn.Conv1d(input_dim, 64, 1, bias=False)
self.conv2 = nn.Conv1d(64, 128, 1, bias=False)
self.conv3 = nn.Conv1d(128, 1024, 1, bias=False)
# Batch normalization
self.bn1 = nn.BatchNorm1d(64)
self.bn2 = nn.BatchNorm1d(128)
self.bn3 = nn.BatchNorm1d(1024)
# Fully connected layers
self.fc1 = nn.Linear(1024, 512, bias=False)
self.fc2 = nn.Linear(512, 256, bias=False)
self.fc3 = nn.Linear(256, input_dim * input_dim, bias=False)
self.bn4 = nn.BatchNorm1d(512)
self.bn5 = nn.BatchNorm1d(256)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Args:
x: Input point cloud (B, input_dim, N)
Returns:
Transformation matrix (B, input_dim, input_dim)
"""
batch_size = x.size(0)
# Extract features (use SiLU for smoother nonlinearity)
x = F.silu(self.bn1(self.conv1(x)))
x = F.silu(self.bn2(self.conv2(x)))
x = F.silu(self.bn3(self.conv3(x)))
# Global feature via max pooling
x = torch.max(x, 2, keepdim=False)[0]
# Transform to matrix
x = F.silu(self.bn4(self.fc1(x)))
x = F.silu(self.bn5(self.fc2(x)))
x = self.fc3(x)
# Add identity matrix for stability
identity = torch.eye(self.input_dim, dtype=x.dtype, device=x.device)
identity = identity.view(1, -1).repeat(batch_size, 1)
x = x + identity
x = x.view(batch_size, self.input_dim, self.input_dim)
return x
def grouped_masked_batch_norm(bn: nn.Module, x: torch.Tensor, mask: torch.Tensor, groups: int) -> torch.Tensor:
"""Training-mode BatchNorm over x (G*M, C, N) with separate statistics for each of G groups.
Group g is the contiguous block of rows [g*M, (g+1)*M); its statistics use only the masked-valid
positions (mask (G*M, N), True = valid). This equals G separate calls of the packed masked BatchNorm
(RigidBodyEncoder._bn) on the G blocks, including the running-statistics updates (applied in the
closed form of G sequential momentum updates), without packing: fixed shapes and no host syncs. Under
torch.distributed the per-group sums are all-reduced with an autograd-aware collective, so both the
statistics and their gradients are those of SyncBatchNorm applied per group. Padded positions of the
output are zero, as in the packed path.
Sums run in fp32 with fp64 accumulation (no fp64 copy of x); results match the packed path up to
floating-point rounding (different reduction order)."""
GM, C, N = x.shape
if GM % groups != 0:
raise ValueError(f"grouped_masked_batch_norm: {GM} rows are not divisible into {groups} groups")
M = GM // groups
if mask.shape != (GM, N):
raise ValueError(f"grouped_masked_batch_norm: mask must be {(GM, N)}; got {tuple(mask.shape)}")
m = mask.view(groups, M, 1, N)
xf = x.float().view(groups, M, C, N)
xm = torch.where(m, xf, torch.zeros((), dtype=xf.dtype, device=x.device))
s1 = xm.sum(dim=(1, 3), dtype=torch.float64) # (G, C)
s2 = (xm * xm).sum(dim=(1, 3), dtype=torch.float64) # (G, C)
cnt = m.sum(dim=(1, 3), dtype=torch.float64) # (G, 1)
if torch.distributed.is_available() and torch.distributed.is_initialized():
from torch.distributed.nn.functional import all_reduce as _ar_autograd
packed = _ar_autograd(torch.cat([s1, s2, cnt], dim=1)) # (G, 2C+1), gradients all-reduced too
s1, s2, cnt = packed[:, :C], packed[:, C:2 * C], packed[:, 2 * C:]
cnt_c = cnt.clamp_min(1.0)
mean = s1 / cnt_c # (G, C)
var = (s2 / cnt_c - mean * mean).clamp_min(0.0) # biased (normalisation), (G, C)
if bn.track_running_stats and bn.running_mean is not None:
with torch.no_grad():
mom = bn.momentum if bn.momentum is not None else 0.1
unbiased = var * (cnt / (cnt - 1.0).clamp_min(1.0))
# G sequential updates r <- (1-mom) r + mom * stat_g, in closed form
w = mom * (1.0 - mom) ** torch.arange(groups - 1, -1, -1, dtype=torch.float64, device=x.device)
decay = (1.0 - mom) ** groups
rm = bn.running_mean.double() * decay + (w.view(groups, 1) * mean.detach()).sum(0)
rv = bn.running_var.double() * decay + (w.view(groups, 1) * unbiased.detach()).sum(0)
bn.running_mean.copy_(rm.to(bn.running_mean.dtype))
bn.running_var.copy_(rv.to(bn.running_var.dtype))
bn.num_batches_tracked.add_(groups)
inv = torch.rsqrt(var.float() + bn.eps).view(groups, 1, C, 1)
y = (xf - mean.float().view(groups, 1, C, 1)) * inv
if bn.affine:
y = y * bn.weight.float().view(1, 1, C, 1) + bn.bias.float().view(1, 1, C, 1)
y = torch.where(m, y, torch.zeros((), dtype=y.dtype, device=y.device))
return y.view(GM, C, N).to(x.dtype)
def grouped_rows_batch_norm(bn: nn.Module, x: torch.Tensor, group_sizes: list) -> torch.Tensor:
"""Training-mode BatchNorm over packed rows x (P, C) with separate statistics for G contiguous row blocks of the
given sizes: the packed counterpart of grouped_masked_batch_norm (same fp64 accumulation, autograd-aware
all-reduce of the per-group sums under torch.distributed, closed-form running-statistics updates of G
sequential momentum steps)."""
G = len(group_sizes)
P, C = x.shape
xf = x.float()
blocks = xf.split(group_sizes, dim=0)
s1 = torch.stack([b.sum(dim=0, dtype=torch.float64) for b in blocks]) # (G, C)
s2 = torch.stack([(b * b).sum(dim=0, dtype=torch.float64) for b in blocks]) # (G, C)
cnt = torch.tensor(group_sizes, dtype=torch.float64, device=x.device).view(G, 1)
if torch.distributed.is_available() and torch.distributed.is_initialized():
from torch.distributed.nn.functional import all_reduce as _ar_autograd
packed = _ar_autograd(torch.cat([s1, s2, cnt], dim=1))
s1, s2, cnt = packed[:, :C], packed[:, C:2 * C], packed[:, 2 * C:]
cnt_c = cnt.clamp_min(1.0)
mean = s1 / cnt_c
var = (s2 / cnt_c - mean * mean).clamp_min(0.0)
if bn.track_running_stats and bn.running_mean is not None:
with torch.no_grad():
mom = bn.momentum if bn.momentum is not None else 0.1
unbiased = var * (cnt / (cnt - 1.0).clamp_min(1.0))
w = mom * (1.0 - mom) ** torch.arange(G - 1, -1, -1, dtype=torch.float64, device=x.device)
decay = (1.0 - mom) ** G
rm = bn.running_mean.double() * decay + (w.view(G, 1) * mean.detach()).sum(0)
rv = bn.running_var.double() * decay + (w.view(G, 1) * unbiased.detach()).sum(0)
bn.running_mean.copy_(rm.to(bn.running_mean.dtype))
bn.running_var.copy_(rv.to(bn.running_var.dtype))
bn.num_batches_tracked.add_(G)
# Per-block broadcast of the group statistics (the same elementwise ops as indexing them per row, whose
# backward is a heavily duplicated index_put)
inv, mean_f = torch.rsqrt(var.float() + bn.eps), mean.float() # (G, C)
y = torch.cat([(b - mean_f[g]) * inv[g] for g, b in enumerate(blocks)], dim=0) # (P, C)
if bn.affine:
y = y * bn.weight.float() + bn.bias.float()
return y.to(x.dtype)
def hierarchical_subset_size(n_valid: torch.Tensor, level: int, small: str) -> torch.Tensor:
"""Vertices pooled by subsampled level `level` (0-based) of an object with n_valid valid vertices: the first
n_valid // 2^(level+1) of its FPS ordering (50/25/12.5% for levels 0/1/2). small = 'all_below_8': a level
with fewer than 8 vertices uses all of them; 'fraction': the stated fraction for every object, at least 1 vertex."""
k = n_valid // (2 ** (level + 1))
if small == 'all_below_8':
return torch.where(k < 8, n_valid, k)
if small == 'fraction':
return k.clamp_min(1)
raise ValueError(f"subset_small_objects must be 'all_below_8' or 'fraction', got {small!r}")
class EncoderPack:
"""Packed layout of the valid vertices of an encoder call (rows = objects, N = vertex slots): flat indices of the
valid vertices, of every hierarchical level's subset, and the per-group row counts for grouped BatchNorm.
The masks do not change over a batch or a rollout, so the pack is built once (a few host syncs) and reused."""
def __init__(self, valid_mask: torch.Tensor, vtx_rank: Optional[torch.Tensor], num_levels: int,
subsets: bool, groups: int = 1, small: str = 'all_below_8'):
R, N = valid_mask.shape
if R % groups != 0:
raise ValueError(f"EncoderPack: {R} rows are not divisible into {groups} groups")
self.R, self.N, self.groups = R, N, groups
mask = valid_mask.bool()
self.idx = mask.reshape(-1).nonzero(as_tuple=True)[0] # (P,) sorted
rows_per_group = R // groups
g_of = torch.div(torch.div(self.idx, N, rounding_mode='floor'), rows_per_group, rounding_mode='floor')
self.group_sizes = torch.bincount(g_of, minlength=groups).tolist() if groups > 1 else [int(self.idx.numel())]
self.level_idx, self.level_rows, self.level_masks, self.level_group_sizes = [], [], [], []
n_valid = mask.sum(dim=1)
# Segment layout for pooling over the packed rows (packed_pooling='segment'): row r's valid vertices are
# the contiguous packed rows of segment r (idx is sorted), lengths = their counts, seg = segment per row.
self.lengths, self.seg = n_valid, torch.div(self.idx, N, rounding_mode='floor')
self.level_lengths, self.level_seg = [], []
for level in range(num_levels):
if subsets:
if vtx_rank is None:
raise ValueError("EncoderPack: hierarchical subsets need vtx_rank")
k = hierarchical_subset_size(n_valid, level, small)
lmask = mask & (vtx_rank < k.unsqueeze(1))
else:
lmask = mask
li = lmask.reshape(-1).nonzero(as_tuple=True)[0] # sorted, subset of idx
# positions of the level's vertices inside the packed rows (idx is sorted, li a subset of it)
self.level_rows.append(torch.searchsorted(self.idx, li))
self.level_idx.append(li)
self.level_masks.append(lmask)
lg = torch.div(torch.div(li, N, rounding_mode='floor'), rows_per_group, rounding_mode='floor')
self.level_group_sizes.append(torch.bincount(lg, minlength=groups).tolist() if groups > 1
else [int(li.numel())])
self.level_lengths.append(lmask.sum(dim=1))
self.level_seg.append(torch.div(li, N, rounding_mode='floor'))
self.mask = mask
class RigidBodyEncoder(nn.Module):
"""
PointNet encoder for rigid body representation.
Follows the original PointNet architecture (STN, feature transform,
shared MLPs), with two pooling variants beyond plain max pooling:
- Enhanced pooling: max + mean + std for richer global features
- Hierarchical pooling: multi-scale aggregation (PointNet++ style)
"""
def __init__(
self,
input_dim: int = 9, # 3 for dist_vec + 3 for velocity + 3 for relative_pos
hidden_dims: list = None, # Will use original PointNet dims by default
output_dim: int = 1024, # Final fused output dim used by downstream modules (MultiBodyEncoder maps to this)
backbone_dim: int = None, # Internal PointNet feature width before pooling/ fusion; defaults to output_dim
use_stn: bool = True, # Enable STN like original
use_batch_norm: bool = True,
feature_transform: bool = True, # Enable feature transform like original
feature_transform_dim: int = 64, # K=64 in original paper
pooling_type: str = 'enhanced', # 'max' | 'enhanced' (max+mean+std) | 'hierarchical' (PointNet++)
num_hierarchical_levels: int = 2,
masked_pooling: bool = False, # Exact set-function pooling / BatchNorm over valid vertices only
freeze_bn_stats: bool = False, # Training uses the stored running statistics (fixed per-channel affine); no re-estimation
height_input: bool = False, # Extra input channel: vertex height above the ground plane (absolute z)
hierarchical_subsets: bool = False, # Levels pool the first 50/25/12.5/... percent of an order-independent FPS ordering
packed_pooling: str = 'scatter', # Packed path only: 'scatter' (pool the padded layout) | 'segment' (pool the packed rows)
subset_small_objects: str = 'all_below_8', # Subset levels below 8 vertices: 'all_below_8' (all vertices) | 'fraction'
):
super().__init__()
self.input_dim = input_dim
self.output_dim = output_dim
self.backbone_dim = backbone_dim if backbone_dim is not None else output_dim
self.use_stn = use_stn
self.feature_transform = feature_transform
self.feature_transform_dim = feature_transform_dim
self.pooling_type = pooling_type
self.num_hierarchical_levels = num_hierarchical_levels
# Set-function mode: pooling and BatchNorm statistics ignore padded vertices,
# and every hierarchical level pools over the full valid set.
self.masked_pooling = masked_pooling
self.freeze_bn_stats = bool(freeze_bn_stats)
self.height_input = bool(height_input)
self.hierarchical_subsets = bool(hierarchical_subsets)
if packed_pooling not in ('scatter', 'segment'):
raise ValueError(f"packed_pooling must be 'scatter' or 'segment', got {packed_pooling!r}")
self.packed_pooling = packed_pooling
if subset_small_objects not in ('all_below_8', 'fraction'):
raise ValueError(f"subset_small_objects must be 'all_below_8' or 'fraction', got {subset_small_objects!r}")
self.subset_small_objects = subset_small_objects
# Use original PointNet architecture dimensions if not specified:
# conv(64) -> conv(64) -> [feature transform] -> conv(64) -> conv(128) -> conv(1024)
if hidden_dims is None:
hidden_dims = [64, 128, 1024]
# Spatial Transformer Network (for input alignment)
if use_stn:
self.stn = STN3d(input_dim)
# First set of convolutions (before feature transform)
self.conv1 = nn.Conv1d(input_dim, 64, 1, bias=not use_batch_norm)
self.bn1 = nn.BatchNorm1d(64) if use_batch_norm else nn.Identity()
self.conv2 = nn.Conv1d(64, 64, 1, bias=not use_batch_norm)
self.bn2 = nn.BatchNorm1d(64) if use_batch_norm else nn.Identity()
# Feature transform network at 64-dimensional features
if feature_transform:
self.feature_stn = STN3d(feature_transform_dim)
# Second set of convolutions (after feature transform)
self.conv3 = nn.Conv1d(64, 64, 1, bias=not use_batch_norm)
self.bn3 = nn.BatchNorm1d(64) if use_batch_norm else nn.Identity()
self.conv4 = nn.Conv1d(64, 128, 1, bias=not use_batch_norm)
self.bn4 = nn.BatchNorm1d(128) if use_batch_norm else nn.Identity()
self.conv5 = nn.Conv1d(128, self.backbone_dim, 1, bias=not use_batch_norm)
self.bn5 = nn.BatchNorm1d(self.backbone_dim) if use_batch_norm else nn.Identity()
# Enhanced pooling: project (max+mean+std) back to backbone_dim
if pooling_type == 'enhanced':
self.pooling_proj = nn.Sequential(
nn.Linear(self.backbone_dim * 3, self.backbone_dim * 2),
nn.ReLU(),
nn.Linear(self.backbone_dim * 2, self.backbone_dim)
)
# Hierarchical pooling: multi-scale feature aggregation (PointNet++ inspired)
# Designed to handle variable vertex counts robustly
if pooling_type == 'hierarchical':
self.hierarchical_layers = nn.ModuleList()
for i in range(num_hierarchical_levels):
# Each level: Conv1d for local feature extraction
self.hierarchical_layers.append(nn.Sequential(
nn.Conv1d(self.backbone_dim, self.backbone_dim, 1),
nn.BatchNorm1d(self.backbone_dim) if use_batch_norm else nn.Identity(),
nn.ReLU()
))
# Project concatenated multi-scale features
# Input: global (backbone_dim*2) + level1 (backbone_dim*2) + ... + levelN (backbone_dim*2)
# Total: backbone_dim * 2 * (1 + num_hierarchical_levels)
proj_input_dim = self.backbone_dim * 2 * (1 + num_hierarchical_levels)
self.hierarchical_proj = nn.Sequential(
nn.Linear(proj_input_dim, self.backbone_dim * 2),
nn.ReLU(),
nn.Dropout(0.1), # Dropout for regularization
nn.Linear(self.backbone_dim * 2, self.backbone_dim)
)
def _bn(self, bn: nn.Module, x: torch.Tensor, mask: Optional[torch.Tensor],
mask_idx: Optional[Tuple[torch.Tensor, torch.Tensor]] = None, groups: int = 1) -> torch.Tensor:
"""BatchNorm over (B, C, N) whose training statistics come from valid vertices only.
The valid vertices are packed into one (1, C, P) tensor and normalised by the stock
(Sync)BatchNorm module, so batch statistics, running statistics and the gradient
through the statistics are exactly those of a BatchNorm applied to the un-padded
set. Padded rows of the output are zero; the masked pooling ignores them.
Evaluation uses the running statistics, a per-channel affine map that is already
independent of padding.
mask_idx: optional mask.nonzero(as_tuple=True), shared by calls that use the same mask. Indexing
with it is what boolean-mask indexing does internally, so results are identical; it only avoids
recomputing the nonzero (a host sync) for the pack and again for the scatter.
groups > 1: the rows are G contiguous blocks (e.g. G time steps batched together) whose statistics
must stay separate; grouped_masked_batch_norm computes them per block without packing."""
is_bn = isinstance(bn, torch.nn.modules.batchnorm._BatchNorm)
if self.freeze_bn_stats and self.training and is_bn:
# Fine-tuning a converged model: keep the stored statistics (the downstream weights
# were trained against them), so training and evaluation normalise identically and
# padded rows cannot influence anything. The affine parameters remain trainable.
if not getattr(self, '_frozen_bn_logged', False):
self._frozen_bn_logged = True
print(f"[Encoder] BatchNorm statistics FROZEN in training (module={type(bn).__name__}; stored running stats, affine trainable)", flush=True)
return torch.nn.functional.batch_norm(x, bn.running_mean, bn.running_var, bn.weight, bn.bias, False, 0.0, bn.eps)
if not (self.masked_pooling and mask is not None and self.training and is_bn):
return bn(x)
if not getattr(self, '_packed_bn_logged', False):
self._packed_bn_logged = True
print(f"[Encoder] packed masked BatchNorm ACTIVE: module={type(bn).__name__}, valid={int(mask.sum())}/{mask.numel()} vertices", flush=True)
if groups > 1:
return grouped_masked_batch_norm(bn, x, mask, groups)
if mask_idx is None:
mask_idx = mask.nonzero(as_tuple=True)
xt = x.transpose(1, 2) # (B, N, C)
packed = xt[mask_idx] # (P, C), same rows and order as xt[mask]
y = bn(packed.t().unsqueeze(0)).squeeze(0).t() # (P, C)
out = xt.new_zeros(xt.shape)
out[mask_idx] = y.to(out.dtype)
return out.transpose(1, 2)
@staticmethod
def _masked_max(f: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
neg = torch.finfo(f.dtype).min
out = f.masked_fill(~mask.unsqueeze(1), neg).max(dim=2)[0]
return torch.where(mask.any(dim=1, keepdim=True), out, torch.zeros_like(out))
@staticmethod
def _masked_mean(f: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
# Accumulate in fp32 so the mean does not depend on the padded length under autocast.
m = mask.unsqueeze(1).float()
return ((f.float() * m).sum(dim=2) / m.sum(dim=2).clamp_min(1.0)).to(f.dtype)
@staticmethod
def _segment_max_mean(h: torch.Tensor, lengths: torch.Tensor, seg: torch.Tensor):
"""_masked_max and _masked_mean of the packed rows h (P, C) without the padded layout: segment r is the
contiguous block of lengths[r] rows (seg = segment of every row). The max has the same value and passes its
gradient to the first maximal row of the segment, which is the index torch.max returns on the padded layout
(the packed order is the slot order); the mean accumulates in fp32. Empty segments give zeros."""
P = h.shape[0]
if P >= 2 ** 24:
raise ValueError(f"_segment_max_mean: {P} rows exceed the exact fp32 row positions")
with torch.no_grad():
mx = torch.segment_reduce(h, 'max', lengths=lengths, axis=0, unsafe=True) # (R, C)
pos = torch.arange(P, device=h.device, dtype=torch.float32).unsqueeze(1)
first = torch.segment_reduce(torch.where(h == mx.index_select(0, seg), pos, float(P)), 'min',
lengths=lengths, axis=0, unsafe=True)
first = first.clamp_max(P - 1).long() # (R, C)
mx = torch.where((lengths > 0).unsqueeze(1), h.gather(0, first), torch.zeros((), dtype=h.dtype, device=h.device))
mean = (torch.segment_reduce(h.float(), 'sum', lengths=lengths, axis=0, unsafe=True)
/ lengths.clamp_min(1).unsqueeze(1).float()).to(h.dtype)
return mx, mean
def _bn_rows(self, bn: nn.Module, x: torch.Tensor, group_sizes: list, groups: int) -> torch.Tensor:
"""BatchNorm over packed valid rows x (P, C): the statistics of the masked path without packing/scattering."""
if not isinstance(bn, torch.nn.modules.batchnorm._BatchNorm):
return bn(x)
if self.freeze_bn_stats and self.training:
return torch.nn.functional.batch_norm(x, bn.running_mean, bn.running_var, bn.weight, bn.bias, False, 0.0, bn.eps)
if self.training and groups > 1:
return grouped_rows_batch_norm(bn, x, group_sizes)
return bn(x)
@staticmethod
def _rows_linear(conv: nn.Conv1d, x: torch.Tensor) -> torch.Tensor:
return F.linear(x, conv.weight.squeeze(-1), conv.bias)
def _forward_packed(self, x: torch.Tensor, pack: 'EncoderPack', bn_groups: int):
"""Encoder over the valid vertices only (model.encoder.packed): every pointwise layer runs on the packed rows,
each hierarchical level only on its own vertex subset, and the features are scattered back (zeros at
padding) for the unchanged masked pooling and for anchor-vertex pooling. Equal to the padded path at every
valid vertex up to floating-point rounding (different GEMM shapes / reduction order)."""
if not (self.masked_pooling and self.pooling_type == 'hierarchical') or self.use_stn or self.feature_transform:
raise ValueError("the packed encoder path needs masked hierarchical pooling without STN / feature transform")
R, N, Cin = x.shape
if (R, N) != (pack.R, pack.N) or bn_groups != pack.groups:
raise ValueError(f"EncoderPack built for {(pack.R, pack.N, pack.groups)}, called with {(R, N, bn_groups)}")
if not getattr(self, '_packed_path_logged', False):
self._packed_path_logged = True
print(f"[Encoder] packed vertex path ACTIVE: {int(pack.idx.numel())}/{R * N} vertex slots valid, "
f"levels {[int(i.numel()) for i in pack.level_idx]}", flush=True)
gs = pack.group_sizes
h = x.reshape(R * N, Cin).index_select(0, pack.idx)
h = F.silu(self._bn_rows(self.bn1, self._rows_linear(self.conv1, h), gs, bn_groups))
h = F.silu(self._bn_rows(self.bn2, self._rows_linear(self.conv2, h), gs, bn_groups))
h = F.silu(self._bn_rows(self.bn3, self._rows_linear(self.conv3, h), gs, bn_groups))
h = F.silu(self._bn_rows(self.bn4, self._rows_linear(self.conv4, h), gs, bn_groups))
h = self._bn_rows(self.bn5, self._rows_linear(self.conv5, h), gs, bn_groups) # (P, C)
C = h.shape[1]
# Per-vertex features in the padded layout (zeros at padding) for anchor-vertex pooling
per_point_features = h.new_zeros(R * N, C).index_copy(0, pack.idx, h).view(R, N, C).transpose(1, 2)
segment = self.packed_pooling == 'segment'
if segment and not getattr(self, '_segment_pool_logged', False):
self._segment_pool_logged = True
print("[Encoder] packed pooling: segment max/mean over the packed rows (no padded level tensors)", flush=True)
if segment:
feats = [torch.cat(self._segment_max_mean(h, pack.lengths, pack.seg), dim=1)]
else:
mask = pack.mask
feats = [torch.cat([self._masked_max(per_point_features, mask),
self._masked_mean(per_point_features, mask)], dim=1)]
for level, layer in enumerate(self.hierarchical_layers):
hl = self._rows_linear(layer[0], h.index_select(0, pack.level_rows[level]))
hl = layer[2](self._bn_rows(layer[1], hl, pack.level_group_sizes[level], bn_groups))
if segment:
feats.append(torch.cat(self._segment_max_mean(hl, pack.level_lengths[level], pack.level_seg[level]),
dim=1))
continue
lf = hl.new_zeros(R * N, C).index_copy(0, pack.level_idx[level], hl).view(R, N, C).transpose(1, 2)
lmask = pack.level_masks[level]
feats.append(torch.cat([self._masked_max(lf, lmask), self._masked_mean(lf, lmask)], dim=1))
latent = self.hierarchical_proj(torch.cat(feats, dim=1))
return latent, per_point_features, None
def forward(
self,
dist_vec_norm: torch.Tensor,
velocity_norm: torch.Tensor,
relative_pos: torch.Tensor,
phys_params: Optional[torch.Tensor] = None,
time_step: Optional[torch.Tensor] = None,
valid_mask: Optional[torch.Tensor] = None,
height: Optional[torch.Tensor] = None,
vtx_rank: Optional[torch.Tensor] = None,
bn_groups: int = 1,
pack: Optional['EncoderPack'] = None
) -> Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]:
"""
Encode rigid body point cloud into latent representation.
Follows the original PointNet architecture.
Args:
dist_vec_norm: Normalized distance vectors (B, N, 3) where N is number of vertices
velocity_norm: Normalized velocities (B, N, 3)
relative_pos: Position relative to reference pose (B, N, 3)
phys_params: Optional physics parameters (B, N, 3) - [mass, friction, restitution] broadcasted to all vertices
time_step: Optional time step size (B, N, 1) - skip_step_size broadcasted to all vertices
valid_mask: Optional vertex validity mask (B, N) - True for valid vertices, False for padding
Returns:
latent: Global latent representation (B, output_dim)
per_point_features: Per-point features before pooling (B, output_dim, N)
trans_feat: Feature transformation matrix if feature_transform=True
"""
B, N, _ = dist_vec_norm.shape
# Concatenate all input features: (B, N, input_dim)
# Base: 9D (dist_vec + velocity + rel_pos)
# +phys_params: +3D = 12D
# +time_step: +2D (s and s^2) = 14D
features = [dist_vec_norm, velocity_norm, relative_pos]
if phys_params is not None:
features.append(phys_params)
if time_step is not None:
features.append(time_step)
if self.height_input:
if height is None:
raise ValueError("RigidBodyEncoder(height_input=True) requires the `height` tensor (B, N, 1)")
features.append(height) # last channel: height above the ground plane
if not getattr(self, '_height_logged', False):
self._height_logged = True
print(f"[Encoder] height-above-ground input channel ACTIVE (input_dim={self.input_dim}, "
f"height range [{height.min().item():.2f}, {height.max().item():.2f}] m)", flush=True)
x = torch.cat(features, dim=-1)
if pack is not None:
return self._forward_packed(x, pack, bn_groups)
# Transpose for Conv1d: (B, N, C) -> (B, C, N)
x = x.transpose(1, 2)
# Apply input spatial transformer if enabled
if self.use_stn:
trans = self.stn(x)
x = x.transpose(2, 1) # (B, C, N) -> (B, N, C)
x = torch.bmm(x, trans) # Apply transformation
x = x.transpose(2, 1) # (B, N, C) -> (B, C, N)
# The five backbone BatchNorms share one vertex mask: compute its index set once (training with
# masked statistics only; evaluation and frozen statistics never pack).
_bn_idx = (valid_mask.nonzero(as_tuple=True)
if (self.masked_pooling and valid_mask is not None and self.training
and not self.freeze_bn_stats and bn_groups == 1) else None)
# First block of convolutions
x = F.silu(self._bn(self.bn1, self.conv1(x), valid_mask, _bn_idx, bn_groups))
x = F.silu(self._bn(self.bn2, self.conv2(x), valid_mask, _bn_idx, bn_groups))
# Apply feature transform at 64d features
trans_feat = None
if self.feature_transform:
trans_feat = self.feature_stn(x)
x_transformed = x.transpose(2, 1) # (B, 64, N) -> (B, N, 64)
x_transformed = torch.bmm(x_transformed, trans_feat) # Apply transformation
x = x_transformed.transpose(2, 1) # (B, N, 64) -> (B, 64, N)
# Second block of convolutions (after feature transform)
x = F.silu(self._bn(self.bn3, self.conv3(x), valid_mask, _bn_idx, bn_groups))
x = F.silu(self._bn(self.bn4, self.conv4(x), valid_mask, _bn_idx, bn_groups))
x = self._bn(self.bn5, self.conv5(x), valid_mask, _bn_idx, bn_groups) # No ReLU on last layer before pooling
# Store per-point features
per_point_features = x # (B, backbone_dim, N)
# With zero-valued padding, standard pooling is safe: zeros rarely
# dominate the max and only mildly dilute the mean, so valid_mask is
# accepted for interface compatibility but not applied here.
if self.masked_pooling and valid_mask is not None:
_key = '_masked_pool_logged_' + ('train' if self.training else 'eval')
if not getattr(self, _key, False):
setattr(self, _key, True)
print(f"[Encoder] masked set-function pooling ACTIVE ({'train' if self.training else 'eval'} mode): "
f"pooling={self.pooling_type}, levels={getattr(self, 'num_hierarchical_levels', None)}, "
f"valid={int(valid_mask.sum())}/{valid_mask.numel()} vertices, mask dtype={valid_mask.dtype}", flush=True)
mask = valid_mask.bool()
if self.pooling_type == 'max':
latent = self._masked_max(per_point_features, mask)
elif self.pooling_type == 'enhanced':
mean_feat = self._masked_mean(per_point_features, mask)
m = mask.unsqueeze(1).to(per_point_features.dtype)
var = (((per_point_features - mean_feat.unsqueeze(-1)) ** 2) * m).sum(dim=2) / (m.sum(dim=2) - 1).clamp_min(1.0)
combined = torch.cat([self._masked_max(per_point_features, mask), mean_feat, var.clamp_min(1e-12).sqrt()], dim=1)
latent = self.pooling_proj(combined)
elif self.pooling_type == 'hierarchical':
# Every level pools over the full valid set: a set function of the
# vertices, independent of their order, count, and padding.
feats = [torch.cat([self._masked_max(per_point_features, mask),
self._masked_mean(per_point_features, mask)], dim=1)]
if self.hierarchical_subsets:
if vtx_rank is None:
raise ValueError("RigidBodyEncoder(hierarchical_subsets=True) requires `vtx_rank` (B, N)")
n_valid = mask.sum(dim=1) # (B,)
if not getattr(self, '_subsets_logged', False):
self._subsets_logged = True
print(f"[Encoder] hierarchical FPS subsets ACTIVE: levels={len(self.hierarchical_layers)}, "
f"fractions={[1.0 / 2 ** (l + 1) for l in range(len(self.hierarchical_layers))]}, "
f"small objects={self.subset_small_objects}", flush=True)
for level, layer in enumerate(self.hierarchical_layers):
if self.hierarchical_subsets:
# n // 2^(level+1) vertices; small objects per subset_small_objects
k = hierarchical_subset_size(n_valid, level, self.subset_small_objects)
level_mask = mask & (vtx_rank < k.unsqueeze(1))
else:
level_mask = mask
h = layer[0](per_point_features)
h = layer[2](self._bn(layer[1], h, level_mask, None, bn_groups))
feats.append(torch.cat([self._masked_max(h, level_mask), self._masked_mean(h, level_mask)], dim=1))
latent = self.hierarchical_proj(torch.cat(feats, dim=1))
else:
raise ValueError(f"Unknown pooling_type: {self.pooling_type}")
return latent, per_point_features, trans_feat
# Unmasked pooling over all slots (PADDING_VALUE=0).
if self.pooling_type == 'max':
latent = torch.max(per_point_features, dim=2)[0] # (B, backbone_dim)
elif self.pooling_type == 'enhanced':
max_feat = torch.max(per_point_features, dim=2)[0] # (B, backbone_dim)
mean_feat = torch.mean(per_point_features, dim=2) # (B, backbone_dim)
std_feat = torch.std(per_point_features, dim=2) # (B, backbone_dim)
combined = torch.cat([max_feat, mean_feat, std_feat], dim=1) # (B, backbone_dim*3)
latent = self.pooling_proj(combined) # (B, backbone_dim)
elif self.pooling_type == 'hierarchical':
# Hierarchical: multi-scale feature aggregation (PointNet++ inspired)
global_max = torch.max(per_point_features, dim=2)[0] # (B, backbone_dim)
global_mean = torch.mean(per_point_features, dim=2) # (B, backbone_dim)
global_feat = torch.cat([global_max, global_mean], dim=1) # (B, backbone_dim*2)
local_features = [global_feat]
for level, layer in enumerate(self.hierarchical_layers):
stride = 2 ** (level + 1)
sampled_feat = per_point_features[:, :, ::stride] # (B, backbone_dim, N//stride)
if sampled_feat.shape[2] < 8:
sampled_feat = per_point_features
local_feat = layer(sampled_feat) # (B, backbone_dim, num_sampled)
local_max = torch.max(local_feat, dim=2)[0] # (B, backbone_dim)
local_mean = torch.mean(local_feat, dim=2) # (B, backbone_dim)
local_combined = torch.cat([local_max, local_mean], dim=1) # (B, backbone_dim*2)
local_features.append(local_combined)
all_features = torch.cat(local_features, dim=1)
latent = self.hierarchical_proj(all_features) # (B, backbone_dim)
else:
raise ValueError(f"Unknown pooling_type: {self.pooling_type}")
return latent, per_point_features, trans_feat
class StepSizeFiLM(nn.Module):
"""
Feature-wise Linear Modulation (FiLM) conditioned on step-size.
Generates per-feature scale (γ) and shift (β) from [s, s²]:
output = features * (1 + γ) + β
This gives the step size a stronger influence than concatenating
[s, s²] as two extra dimensions of the encoder input.
Reference: Perez et al., "FiLM: Visual Reasoning with a General
Conditioning Layer", AAAI 2018.
"""
def __init__(self, time_dim: int = 2, feature_dim: int = 768):
super().__init__()
self.film_net = nn.Sequential(
nn.Linear(time_dim, 256),
nn.SiLU(),
nn.Linear(256, feature_dim * 2) # γ and β
)
# Zero-init the head so γ = β = 0 and FiLM starts as the identity
nn.init.zeros_(self.film_net[-1].weight)
nn.init.zeros_(self.film_net[-1].bias)
def forward(self, features: torch.Tensor, time_features: torch.Tensor) -> torch.Tensor:
"""
Args:
features: (B, Nobj, D) or (B*Nobj, Nq, D) — encoder/predictor features
time_features: (B, 2) — [s, s²] per sample
Returns:
Modulated features, same shape as input
"""
gamma_beta = self.film_net(time_features) # (B, 2*D)
D = features.shape[-1]
gamma = gamma_beta[..., :D] # (B, D)
beta = gamma_beta[..., D:] # (B, D)
# Broadcast over spatial dimensions (Nobj or Nq)
ndim_diff = features.dim() - gamma.dim()
for _ in range(ndim_diff):
gamma = gamma.unsqueeze(-2) # (B, 1, D) or (B, 1, 1, D)
beta = beta.unsqueeze(-2)
return features * (1.0 + gamma) + beta