| """Batched GPU furthest-point sampling for the stage-2 pipeline. |
| |
| Designed for the v2 (FPS) stage-2 path where the dataloader returns the full |
| hull-filtered point cloud padded to a fixed cap, and we subsample to the |
| training budget (e.g. 8k+8k) *after* collate — i.e. once per batch on the GPU |
| inside the training/inference loop. CPU dataloader workers cannot use CUDA, so |
| this is the canonical place to run FPS. |
| |
| Two entry points: |
| |
| * :func:`fps_batched_indices` — single-provenance FPS. Returns ``(B, k)`` long |
| indices into ``(B, N, 3)`` xyz. Honors a per-point ``valid_mask`` (padding |
| slots are never selected) and an optional per-sample RNG seed. |
| |
| * :func:`fps_subsample_stage2_batch` — convenience wrapper that operates on a |
| stage-2 batch dict produced by ``stage2_collate_fn`` with the FPS variant. |
| Runs FPS once per provenance (COLMAP, depth) and gathers all the per-point |
| fields (xyz, semantics, RGB, …) to the resampled order. Returns a NEW batch |
| dict with the same keys but with the per-point tensors shrunk from ``N`` to |
| ``n_col + n_dep`` and ``scene_xyz`` re-normalized into the post-subsample |
| bbox (matching the random-sampling path). |
| |
| The kernel is a vectorized version of the single-loop torch FPS in |
| ``viz_stage2_density._fps_indices``: one ``(B, N)`` distance buffer, ``k`` |
| iterations, each step gathers the just-picked point and updates the running |
| minimum-distance buffer in place. No host syncs inside the loop. |
| """ |
| from __future__ import annotations |
|
|
| from typing import Optional |
|
|
| import torch |
|
|
|
|
| |
| |
| |
| def _neg_inf(dtype: torch.dtype) -> float: |
| return torch.finfo(dtype).min |
|
|
|
|
| @torch.no_grad() |
| def fps_batched_indices( |
| xyz: torch.Tensor, |
| valid_mask: torch.Tensor, |
| k: int, |
| seed_per_sample: Optional[torch.Tensor] = None, |
| max_exact_iters: Optional[int] = None, |
| ) -> torch.Tensor: |
| """Batched furthest-point sampling on GPU (or CPU; CUDA strongly preferred). |
| |
| Args: |
| xyz: ``(B, N, 3)`` float tensor of point coordinates. |
| valid_mask: ``(B, N)`` bool tensor. ``False`` slots are padding / |
| wrong-provenance / otherwise ineligible. They are never selected. |
| k: number of points to pick per batch element. |
| seed_per_sample: optional ``(B,)`` long tensor. Each entry is taken |
| modulo the per-sample valid count to pick the seed within the |
| eligible set. ``None`` → seed at the first eligible index |
| (deterministic). |
| max_exact_iters: optional cap for exact FPS iterations. If set and |
| smaller than ``k``, exact FPS is used for the first anchors and the |
| remainder is filled with deterministic random eligible points. This |
| keeps broad spatial anchors while avoiding thousands of sequential |
| distance-buffer updates in the stage-2 training hot path. |
| |
| Returns: |
| ``(B, k)`` long tensor of indices into the ``N`` axis. For batch |
| elements with fewer than ``k`` eligible points, the deficit slots are |
| padded by repeating earlier picks (FPS naturally cycles when the |
| eligible set is exhausted and argmax falls back to index 0; the |
| downstream encoder doesn't care about duplicates). |
| """ |
| if xyz.dim() != 3 or xyz.shape[-1] != 3: |
| raise ValueError(f"xyz must be (B, N, 3), got {tuple(xyz.shape)}") |
| if valid_mask.shape != xyz.shape[:2]: |
| raise ValueError( |
| f"valid_mask shape {tuple(valid_mask.shape)} != xyz[:2] " |
| f"{tuple(xyz.shape[:2])}" |
| ) |
| B, N, _ = xyz.shape |
| device = xyz.device |
|
|
| if k <= 0: |
| return torch.empty((B, 0), device=device, dtype=torch.long) |
|
|
| k_exact = k |
| if max_exact_iters is not None: |
| k_exact = max(0, min(k, int(max_exact_iters))) |
| if k_exact == 0: |
| return _deterministic_random_fill_indices( |
| valid_mask, |
| k=k, |
| seed_per_sample=seed_per_sample, |
| exclude_idx=None, |
| ) |
|
|
| NEG = _neg_inf(xyz.dtype) |
| dist2 = torch.where( |
| valid_mask, |
| torch.full_like(xyz[..., 0], float("inf")), |
| torch.full_like(xyz[..., 0], NEG), |
| ) |
|
|
| |
| if seed_per_sample is None: |
| |
| seed_idx = valid_mask.to(torch.int8).argmax(dim=1) |
| else: |
| if seed_per_sample.shape != (B,): |
| raise ValueError( |
| f"seed_per_sample shape {tuple(seed_per_sample.shape)} != ({B},)" |
| ) |
| counts = valid_mask.sum(dim=1).clamp(min=1) |
| offset = (seed_per_sample.to(device=device, dtype=torch.long).abs() |
| % counts) |
| |
| cum = valid_mask.to(torch.long).cumsum(dim=1) |
| target = (offset + 1).unsqueeze(1) |
| seed_idx = (cum >= target).to(torch.int8).argmax(dim=1) |
| |
| |
| |
|
|
| arange_b = torch.arange(B, device=device) |
| out = torch.empty((B, k_exact), device=device, dtype=torch.long) |
| out[:, 0] = seed_idx |
| |
| dist2[arange_b, seed_idx] = NEG |
|
|
| last_idx = seed_idx |
| for i in range(1, k_exact): |
| last_pts = xyz[arange_b, last_idx] |
| diff = xyz - last_pts.unsqueeze(1) |
| new_d2 = (diff * diff).sum(dim=-1) |
| |
| |
| eligible = dist2 > NEG |
| dist2 = torch.where(eligible, torch.minimum(dist2, new_d2), dist2) |
| next_idx = dist2.argmax(dim=1) |
| out[:, i] = next_idx |
| dist2[arange_b, next_idx] = NEG |
| last_idx = next_idx |
|
|
| if k_exact < k: |
| fill = _deterministic_random_fill_indices( |
| valid_mask, |
| k=k - k_exact, |
| seed_per_sample=seed_per_sample, |
| exclude_idx=out, |
| ) |
| out = torch.cat([out, fill], dim=1) |
|
|
| return out |
|
|
|
|
| @torch.no_grad() |
| def _deterministic_random_fill_indices( |
| valid_mask: torch.Tensor, |
| k: int, |
| seed_per_sample: Optional[torch.Tensor], |
| exclude_idx: Optional[torch.Tensor], |
| ) -> torch.Tensor: |
| """Pick deterministic pseudo-random valid indices, excluding anchors. |
| |
| This is intentionally one vectorized top-k instead of a loop. It is used |
| only for the fast hybrid FPS mode, where the expensive exact FPS anchors |
| have already provided spatial coverage. |
| """ |
| B, N = valid_mask.shape |
| device = valid_mask.device |
| if k <= 0: |
| return torch.empty((B, 0), device=device, dtype=torch.long) |
|
|
| remaining = valid_mask.clone() |
| if exclude_idx is not None and exclude_idx.numel() > 0: |
| remaining.scatter_(1, exclude_idx, False) |
|
|
| n_top = min(k, N) |
| idx_f = torch.arange(N, device=device, dtype=torch.float32).unsqueeze(0) |
| batch_f = torch.arange(B, device=device, dtype=torch.float32).unsqueeze(1) |
| if seed_per_sample is None: |
| seed_f = torch.zeros((B, 1), device=device, dtype=torch.float32) |
| else: |
| seed_i = seed_per_sample.to(device=device, dtype=torch.long).remainder(1_000_003) |
| seed_f = seed_i.to(dtype=torch.float32).view(B, 1) |
|
|
| |
| |
| |
| scores = torch.sin((idx_f + 1.0) * 12.9898 + seed_f * 78.233 + batch_f * 37.719) |
| scores = scores * 43758.5453 |
| scores = scores - torch.floor(scores) |
| scores = torch.where(remaining, scores, torch.full_like(scores, -1.0)) |
|
|
| fill = scores.topk(n_top, dim=1).indices |
| fill_ok = remaining.gather(1, fill) |
| first_valid = valid_mask.to(torch.int8).argmax(dim=1) |
| if exclude_idx is not None and exclude_idx.numel() > 0: |
| fallback_src = exclude_idx |
| else: |
| fallback_src = first_valid.unsqueeze(1) |
| fallback = fallback_src[:, torch.arange(n_top, device=device) % fallback_src.shape[1]] |
| fill = torch.where(fill_ok, fill, fallback) |
|
|
| if n_top < k: |
| pad = fill[:, torch.arange(k - n_top, device=device) % n_top] |
| fill = torch.cat([fill, pad], dim=1) |
| return fill |
|
|
|
|
| @torch.no_grad() |
| def fps_per_provenance_indices( |
| xyz: torch.Tensor, |
| type_ids: torch.Tensor, |
| valid_mask: torch.Tensor, |
| n_col: int, |
| n_dep: int, |
| seed_per_sample: Optional[torch.Tensor] = None, |
| max_exact_iters_per_provenance: Optional[int] = None, |
| ) -> torch.Tensor: |
| """Per-provenance FPS: COLMAP (type_id==0) first, then depth (type_id==1). |
| |
| Returns ``(B, n_col + n_dep)`` indices into ``N``. The two halves are |
| concatenated so the encoder sees the same provenance ordering as the |
| random-sampling path. |
| """ |
| col_mask = valid_mask & (type_ids == 0) |
| dep_mask = valid_mask & (type_ids == 1) |
| |
| |
| seed_col = seed_per_sample |
| seed_dep = (None if seed_per_sample is None |
| else seed_per_sample ^ 0x9E3779B97F4A7C15) |
| idx_col = fps_batched_indices( |
| xyz, col_mask, n_col, seed_col, |
| max_exact_iters=max_exact_iters_per_provenance, |
| ) |
| idx_dep = fps_batched_indices( |
| xyz, dep_mask, n_dep, seed_dep, |
| max_exact_iters=max_exact_iters_per_provenance, |
| ) |
| return torch.cat([idx_col, idx_dep], dim=1) |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| _STAGE2_SCENE_PER_POINT_KEYS = ( |
| "scene_xyz", |
| "scene_type_ids", |
| "scene_gestalt_ids", |
| "scene_gestalt_id2", |
| "scene_gestalt_w1", |
| "scene_ade_ids", |
| "scene_geom_conf", |
| "scene_sem_conf", |
| "scene_rgb", |
| ) |
|
|
|
|
| def _robust_norm_params_batched( |
| xyz: torch.Tensor, |
| valid_mask: torch.Tensor, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| """Per-batch (median centre, P95 scale) computed only over valid points. |
| |
| Mirrors :func:`data.stage2_dataset._robust_norm_params` for a batched |
| tensor. Implements median + P95 via :func:`torch.quantile` on padded |
| rows; invalid slots are set to NaN so quantile ignores them. |
| """ |
| B = xyz.shape[0] |
| device = xyz.device |
| |
| |
| mask3 = valid_mask.unsqueeze(-1) |
| safe_xyz = torch.where(mask3, xyz, torch.full_like(xyz, float("nan"))) |
| center = torch.nanmedian(safe_xyz, dim=1).values |
| d = torch.linalg.norm(xyz - center.unsqueeze(1), dim=-1) |
| d = torch.where(valid_mask, d, torch.full_like(d, float("nan"))) |
| scale = torch.nanquantile(d, 0.95, dim=1) |
| scale = scale.clamp(min=1e-3) |
| return center.to(xyz.dtype), scale.to(xyz.dtype) |
|
|
|
|
| def _robust_norm_params_all_valid_batched( |
| xyz: torch.Tensor, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| """Fast median centre + P95 scale when every slot is valid.""" |
| center = xyz.median(dim=1).values |
| d = torch.linalg.norm(xyz - center.unsqueeze(1), dim=-1) |
| scale = torch.quantile(d, 0.95, dim=1).clamp(min=1e-3) |
| return center.to(xyz.dtype), scale.to(xyz.dtype) |
|
|
|
|
| @torch.no_grad() |
| def fps_subsample_stage2_batch( |
| batch: dict, |
| n_col: int, |
| n_dep: int, |
| seed_per_sample: Optional[torch.Tensor] = None, |
| renormalize_bbox: bool = True, |
| max_exact_iters_per_provenance: Optional[int] = None, |
| ) -> dict: |
| """Apply per-provenance FPS to a stage-2 batch, in place where possible. |
| |
| The batch is expected to come from ``stage2_collate_fn`` with the FPS |
| variant: per-point tensors have shape ``(B, N_max, …)`` and a |
| ``scene_valid_mask`` ``(B, N_max)`` marks real points vs. padding. |
| Coordinates are in WORLD space (no bbox normalisation applied) — i.e. |
| produced by ``stage2_row_to_sample(..., pre_subsample=False)``. |
| |
| Args: |
| batch: dict with at least ``scene_xyz``, ``scene_type_ids``, |
| ``scene_valid_mask``, ``bbox_center`` (filler placeholder, will be |
| overwritten), ``bbox_scale``, ``bbox_R``. Optional per-point keys |
| (gestalt/ADE/conf/RGB) are gathered if present. |
| n_col / n_dep: per-provenance budgets. |
| seed_per_sample: optional ``(B,)`` long for reproducible seeding. |
| renormalize_bbox: if True, recompute ``bbox_center`` / ``bbox_scale`` |
| from the FPS-subsampled cloud (matches the random path which also |
| recomputes per augmented sample). If False, leaves the existing |
| ``bbox_center`` / ``bbox_scale`` alone. |
| max_exact_iters_per_provenance: optional cap for exact FPS anchors in |
| each provenance. ``None`` preserves exact FPS. Smaller values enable |
| the fast hybrid sampler. |
| |
| Returns: |
| A new dict with the per-point tensors shrunk to ``(B, n_col + n_dep, |
| …)`` and ``scene_xyz`` normalized into the post-subsample bbox. |
| Non-point tensors (init_verts, verts_gt, etc.) are passed through |
| un-changed. ``scene_valid_mask`` is set to all-True at the new size. |
| """ |
| xyz_world = batch["scene_xyz"] |
| type_ids = batch["scene_type_ids"] |
| valid_mask = batch["scene_valid_mask"] |
| B, N, _ = xyz_world.shape |
| device = xyz_world.device |
|
|
| idx = fps_per_provenance_indices( |
| xyz_world, type_ids, valid_mask, |
| n_col=n_col, n_dep=n_dep, |
| seed_per_sample=seed_per_sample, |
| max_exact_iters_per_provenance=max_exact_iters_per_provenance, |
| ) |
| K = idx.shape[1] |
| arange_b = torch.arange(B, device=device).unsqueeze(1).expand(B, K) |
|
|
| out = dict(batch) |
|
|
| |
| for key in _STAGE2_SCENE_PER_POINT_KEYS: |
| t = batch.get(key) |
| if t is None: |
| continue |
| if t.dim() == 2: |
| out[key] = t[arange_b, idx] |
| else: |
| |
| out[key] = t[arange_b, idx] |
|
|
| |
| |
| |
| out["scene_valid_mask"] = torch.ones((B, K), dtype=torch.bool, device=device) |
|
|
| if renormalize_bbox: |
| sub_xyz_world = out["scene_xyz"] |
| center, scale = _robust_norm_params_all_valid_batched(sub_xyz_world) |
| out["scene_xyz"] = ((sub_xyz_world - center.unsqueeze(1)) |
| / scale.view(B, 1, 1)).to(sub_xyz_world.dtype) |
| out["bbox_center"] = center |
| out["bbox_scale"] = scale |
| |
| |
| out["bbox_R"] = torch.eye(3, device=device, dtype=sub_xyz_world.dtype) \ |
| .unsqueeze(0).expand(B, 3, 3).contiguous() |
|
|
| |
| |
| init_world = batch.get("init_verts_world") |
| if isinstance(init_world, torch.Tensor): |
| out["init_verts"] = ( |
| (init_world - center.unsqueeze(1)) / scale.view(B, 1, 1) |
| ).to(init_world.dtype) |
| verts_gt_world = batch.get("verts_gt_world") |
| if isinstance(verts_gt_world, torch.Tensor): |
| |
| xyz_g = verts_gt_world[..., :3] |
| flag = verts_gt_world[..., 3:] |
| xyz_g_n = (xyz_g - center.unsqueeze(1)) / scale.view(B, 1, 1) |
| out["verts_gt"] = torch.cat([xyz_g_n.to(verts_gt_world.dtype), flag], dim=-1) |
|
|
| return out |
|
|