Spaces:
Running on Zero
Running on Zero
File size: 38,989 Bytes
632d805 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 | """
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
|