Commit ·
7df6a88
1
Parent(s): ff4370f
Deploy learned baseline + hybrid multi-view tracking ensemble
Browse files- __pycache__/bundle_adjust.cpython-313.pyc +0 -0
- __pycache__/depth_edges.cpython-313.pyc +0 -0
- __pycache__/dgcnn.cpython-313.pyc +0 -0
- __pycache__/junction.cpython-313.pyc +0 -0
- __pycache__/line_cloud.cpython-313.pyc +0 -0
- __pycache__/mvs_utils.cpython-313.pyc +0 -0
- __pycache__/plane_wireframe.cpython-313.pyc +0 -0
- __pycache__/sklearn_submission.cpython-313.pyc +0 -0
- __pycache__/triangulation.cpython-313.pyc +0 -0
- __pycache__/winner_candidates.cpython-313.pyc +0 -0
- __pycache__/winner_inference.cpython-313.pyc +0 -0
- base.json +39 -0
- best_dgcnn_params.json +8 -0
- evaluate_sklearn.py +42 -0
- s23dr_2026_example/__init__.py +0 -0
- s23dr_2026_example/__pycache__/__init__.cpython-313.pyc +0 -0
- s23dr_2026_example/__pycache__/attention.cpython-313.pyc +0 -0
- s23dr_2026_example/__pycache__/cache_scenes.cpython-313.pyc +0 -0
- s23dr_2026_example/__pycache__/color_mappings.cpython-313.pyc +0 -0
- s23dr_2026_example/__pycache__/make_sampled_cache.cpython-313.pyc +0 -0
- s23dr_2026_example/__pycache__/model.cpython-313.pyc +0 -0
- s23dr_2026_example/__pycache__/point_fusion.cpython-313.pyc +0 -0
- s23dr_2026_example/__pycache__/postprocess_v2.cpython-313.pyc +0 -0
- s23dr_2026_example/__pycache__/segment_postprocess.cpython-313.pyc +0 -0
- s23dr_2026_example/__pycache__/tokenizer.cpython-313.pyc +0 -0
- s23dr_2026_example/__pycache__/varifold.cpython-313.pyc +0 -0
- s23dr_2026_example/__pycache__/wire_varifold_kernels.cpython-313.pyc +0 -0
- s23dr_2026_example/attention.py +141 -0
- s23dr_2026_example/bad_samples.txt +156 -0
- s23dr_2026_example/cache_scenes.py +282 -0
- s23dr_2026_example/color_mappings.py +183 -0
- s23dr_2026_example/data.py +227 -0
- s23dr_2026_example/losses.py +215 -0
- s23dr_2026_example/make_sampled_cache.py +159 -0
- s23dr_2026_example/model.py +519 -0
- s23dr_2026_example/point_fusion.py +554 -0
- s23dr_2026_example/postprocess_v2.py +39 -0
- s23dr_2026_example/segment_postprocess.py +77 -0
- s23dr_2026_example/sinkhorn.py +126 -0
- s23dr_2026_example/tokenizer.py +88 -0
- s23dr_2026_example/train.py +530 -0
- s23dr_2026_example/varifold.py +53 -0
- s23dr_2026_example/wire_varifold_kernels.py +168 -0
- script.py +358 -100
- sklearn_submission_diff.txt +189 -0
- test_no_dgcnn.py +32 -0
- test_triangulation.py +20 -0
- time_test.py +29 -0
- tune_dgcnn.py +100 -0
__pycache__/bundle_adjust.cpython-313.pyc
ADDED
|
Binary file (9.4 kB). View file
|
|
|
__pycache__/depth_edges.cpython-313.pyc
ADDED
|
Binary file (9.93 kB). View file
|
|
|
__pycache__/dgcnn.cpython-313.pyc
ADDED
|
Binary file (9.54 kB). View file
|
|
|
__pycache__/junction.cpython-313.pyc
ADDED
|
Binary file (9.01 kB). View file
|
|
|
__pycache__/line_cloud.cpython-313.pyc
ADDED
|
Binary file (23.8 kB). View file
|
|
|
__pycache__/mvs_utils.cpython-313.pyc
ADDED
|
Binary file (9.72 kB). View file
|
|
|
__pycache__/plane_wireframe.cpython-313.pyc
ADDED
|
Binary file (21 kB). View file
|
|
|
__pycache__/sklearn_submission.cpython-313.pyc
ADDED
|
Binary file (50.7 kB). View file
|
|
|
__pycache__/triangulation.cpython-313.pyc
ADDED
|
Binary file (28.8 kB). View file
|
|
|
__pycache__/winner_candidates.cpython-313.pyc
ADDED
|
Binary file (12 kB). View file
|
|
|
__pycache__/winner_inference.cpython-313.pyc
ADDED
|
Binary file (11.2 kB). View file
|
|
|
base.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"arch": "perceiver",
|
| 3 |
+
"segments": 64,
|
| 4 |
+
"hidden": 256,
|
| 5 |
+
"ff": 1024,
|
| 6 |
+
"num_heads": 4,
|
| 7 |
+
"kv_heads_cross": 2,
|
| 8 |
+
"kv_heads_self": 2,
|
| 9 |
+
"latent_tokens": 256,
|
| 10 |
+
"latent_layers": 7,
|
| 11 |
+
"decoder_layers": 3,
|
| 12 |
+
"cross_attn_interval": 4,
|
| 13 |
+
"encoder_layers": 4,
|
| 14 |
+
"behind_emb_dim": 8,
|
| 15 |
+
"dropout": 0.1,
|
| 16 |
+
"activation": "gelu",
|
| 17 |
+
"rms_norm": true,
|
| 18 |
+
"qk_norm": true,
|
| 19 |
+
"qk_norm_type": "l2",
|
| 20 |
+
"segment_param": "midpoint_dir_len",
|
| 21 |
+
"segment_conf": true,
|
| 22 |
+
"vote_features": true,
|
| 23 |
+
|
| 24 |
+
"adam_betas": "0.9,0.95",
|
| 25 |
+
"weight_decay": 0.01,
|
| 26 |
+
"warmup": 10000,
|
| 27 |
+
"varifold_weight": 0.0,
|
| 28 |
+
"sinkhorn_weight": 1.0,
|
| 29 |
+
"sinkhorn_eps": 0.1,
|
| 30 |
+
"sinkhorn_iters": 20,
|
| 31 |
+
"sinkhorn_dustbin": 0.3,
|
| 32 |
+
"conf_weight": 0.1,
|
| 33 |
+
"conf_mode": "sinkhorn",
|
| 34 |
+
"conf_head_wd": 0.1,
|
| 35 |
+
|
| 36 |
+
"aug_rotate": true,
|
| 37 |
+
"aug_flip": true,
|
| 38 |
+
"seed": 353
|
| 39 |
+
}
|
best_dgcnn_params.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"threshold": 0.6,
|
| 3 |
+
"strong_threshold": 0.7,
|
| 4 |
+
"very_strong_threshold": 0.85,
|
| 5 |
+
"max_length": 6.0,
|
| 6 |
+
"max_per_vertex": 1,
|
| 7 |
+
"dilate_px": 6
|
| 8 |
+
}
|
evaluate_sklearn.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import json
|
| 3 |
+
import numpy as np
|
| 4 |
+
import sys
|
| 5 |
+
import os
|
| 6 |
+
from datasets import load_dataset
|
| 7 |
+
from hoho2025.metric_helper import hss
|
| 8 |
+
import sklearn_submission
|
| 9 |
+
|
| 10 |
+
print("Loading dataset...")
|
| 11 |
+
dataset = load_dataset('usm3d/hoho22k_2026_trainval', split='train', streaming=True, trust_remote_code=True)
|
| 12 |
+
|
| 13 |
+
samples = []
|
| 14 |
+
for idx, s in enumerate(dataset):
|
| 15 |
+
if idx >= 10:
|
| 16 |
+
break
|
| 17 |
+
samples.append(s)
|
| 18 |
+
|
| 19 |
+
scores = []
|
| 20 |
+
for idx, sample in enumerate(samples):
|
| 21 |
+
print(f"Testing sample {idx}")
|
| 22 |
+
try:
|
| 23 |
+
pred_v, pred_e = sklearn_submission.predict_wireframe_sklearn(sample)
|
| 24 |
+
except Exception as e:
|
| 25 |
+
print(f"Error on sample {idx}: {e}")
|
| 26 |
+
pred_v, pred_e = np.zeros((2, 3)), [(0, 1)]
|
| 27 |
+
|
| 28 |
+
gt_v = sample.get('wf_vertices')
|
| 29 |
+
gt_e = sample.get('wf_edges')
|
| 30 |
+
|
| 31 |
+
if gt_v is None or gt_e is None:
|
| 32 |
+
print(f"Skipping sample {idx} due to missing ground truth.")
|
| 33 |
+
continue
|
| 34 |
+
|
| 35 |
+
res = hss(pred_v, pred_e, gt_v, gt_e)
|
| 36 |
+
scores.append(res.hss)
|
| 37 |
+
print(f"Sample {idx} HSS: {res.hss:.4f}")
|
| 38 |
+
|
| 39 |
+
if scores:
|
| 40 |
+
print(f"Average HSS: {sum(scores) / len(scores):.4f}")
|
| 41 |
+
else:
|
| 42 |
+
print("No valid scores.")
|
s23dr_2026_example/__init__.py
ADDED
|
File without changes
|
s23dr_2026_example/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (186 Bytes). View file
|
|
|
s23dr_2026_example/__pycache__/attention.cpython-313.pyc
ADDED
|
Binary file (8.02 kB). View file
|
|
|
s23dr_2026_example/__pycache__/cache_scenes.cpython-313.pyc
ADDED
|
Binary file (14 kB). View file
|
|
|
s23dr_2026_example/__pycache__/color_mappings.cpython-313.pyc
ADDED
|
Binary file (9.98 kB). View file
|
|
|
s23dr_2026_example/__pycache__/make_sampled_cache.cpython-313.pyc
ADDED
|
Binary file (9.77 kB). View file
|
|
|
s23dr_2026_example/__pycache__/model.cpython-313.pyc
ADDED
|
Binary file (21.9 kB). View file
|
|
|
s23dr_2026_example/__pycache__/point_fusion.cpython-313.pyc
ADDED
|
Binary file (29 kB). View file
|
|
|
s23dr_2026_example/__pycache__/postprocess_v2.cpython-313.pyc
ADDED
|
Binary file (2.01 kB). View file
|
|
|
s23dr_2026_example/__pycache__/segment_postprocess.cpython-313.pyc
ADDED
|
Binary file (3.88 kB). View file
|
|
|
s23dr_2026_example/__pycache__/tokenizer.cpython-313.pyc
ADDED
|
Binary file (4.82 kB). View file
|
|
|
s23dr_2026_example/__pycache__/varifold.cpython-313.pyc
ADDED
|
Binary file (3.09 kB). View file
|
|
|
s23dr_2026_example/__pycache__/wire_varifold_kernels.cpython-313.pyc
ADDED
|
Binary file (9.29 kB). View file
|
|
|
s23dr_2026_example/attention.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# custom_transformer.py
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn as nn
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
|
| 6 |
+
# =============================================================================
|
| 7 |
+
# Core Efficient Multihead Attention using Scaled Dot Product Attention (SDPA)
|
| 8 |
+
# =============================================================================
|
| 9 |
+
|
| 10 |
+
class MultiHeadSDPA(nn.Module):
|
| 11 |
+
"""
|
| 12 |
+
Multi-head cross-attention using torch.nn.functional.scaled_dot_product_attention
|
| 13 |
+
without causal masking. Suitable for set inputs and cross-attention.
|
| 14 |
+
|
| 15 |
+
If qk_norm=True, L2-normalizes Q and K per-head before the dot product,
|
| 16 |
+
then scales by a learned per-head temperature (log_scale). This caps logit
|
| 17 |
+
magnitude to [-1, +1] * exp(log_scale), preventing attention entropy
|
| 18 |
+
collapse at large head_dim.
|
| 19 |
+
"""
|
| 20 |
+
def __init__(self, d_model: int, num_heads: int, kv_heads: int = None,
|
| 21 |
+
qk_norm: bool = False, qk_norm_type: str = "l2"):
|
| 22 |
+
super().__init__()
|
| 23 |
+
assert d_model % num_heads == 0, "d_model must be divisible by num_heads"
|
| 24 |
+
self.d_model = d_model
|
| 25 |
+
self.num_heads = num_heads
|
| 26 |
+
self.kv_heads = kv_heads or num_heads
|
| 27 |
+
assert self.num_heads % self.kv_heads == 0, "kv_heads must divide num_heads"
|
| 28 |
+
|
| 29 |
+
self.head_dim = d_model // num_heads
|
| 30 |
+
self.qk_norm = qk_norm
|
| 31 |
+
self.qk_norm_type = qk_norm_type
|
| 32 |
+
|
| 33 |
+
# Input projection layers
|
| 34 |
+
self.q_proj = nn.Linear(d_model, d_model, bias=False)
|
| 35 |
+
self.k_proj = nn.Linear(d_model, self.kv_heads * self.head_dim, bias=False)
|
| 36 |
+
self.v_proj = nn.Linear(d_model, self.kv_heads * self.head_dim, bias=False)
|
| 37 |
+
|
| 38 |
+
# Output projection
|
| 39 |
+
self.out_proj = nn.Linear(d_model, d_model, bias=False)
|
| 40 |
+
nn.init.zeros_(self.out_proj.weight)
|
| 41 |
+
|
| 42 |
+
if qk_norm:
|
| 43 |
+
import math
|
| 44 |
+
if qk_norm_type == "rms":
|
| 45 |
+
# Standard QK-norm (Qwen3/Gemma3 style): RMSNorm on Q and K,
|
| 46 |
+
# no learned temperature. SDPA's 1/sqrt(d) scaling is sufficient
|
| 47 |
+
# because RMSNorm preserves the expected logit variance.
|
| 48 |
+
pass # no extra parameters needed
|
| 49 |
+
else:
|
| 50 |
+
# L2 + learned temperature (nGPT/ViT-22B style):
|
| 51 |
+
# L2 projects to unit sphere, needs learned scale to compensate.
|
| 52 |
+
self.log_scale = nn.Parameter(
|
| 53 |
+
torch.full((num_heads,), math.log(math.sqrt(self.head_dim))))
|
| 54 |
+
|
| 55 |
+
def forward(
|
| 56 |
+
self,
|
| 57 |
+
query: torch.Tensor,
|
| 58 |
+
key: torch.Tensor,
|
| 59 |
+
key_padding_mask: torch.Tensor | None = None,
|
| 60 |
+
) -> torch.Tensor:
|
| 61 |
+
# Project
|
| 62 |
+
q = self.q_proj(query)
|
| 63 |
+
k = self.k_proj(key)
|
| 64 |
+
v = self.v_proj(key)
|
| 65 |
+
|
| 66 |
+
B, Tq, _ = q.shape
|
| 67 |
+
_, Tk, _ = k.shape
|
| 68 |
+
|
| 69 |
+
q = q.view(B, Tq, self.num_heads, self.head_dim).transpose(1, 2)
|
| 70 |
+
k = k.view(B, Tk, self.kv_heads, self.head_dim).transpose(1, 2)
|
| 71 |
+
v = v.view(B, Tk, self.kv_heads, self.head_dim).transpose(1, 2)
|
| 72 |
+
|
| 73 |
+
if self.kv_heads != self.num_heads:
|
| 74 |
+
repeat = self.num_heads // self.kv_heads
|
| 75 |
+
k = k.repeat_interleave(repeat, dim=1)
|
| 76 |
+
v = v.repeat_interleave(repeat, dim=1)
|
| 77 |
+
|
| 78 |
+
if self.qk_norm:
|
| 79 |
+
if self.qk_norm_type == "rms":
|
| 80 |
+
# RMSNorm (Qwen3/Gemma3 style): no learned temperature needed.
|
| 81 |
+
# After RMSNorm, logit variance matches standard SDPA naturally.
|
| 82 |
+
q = q * torch.rsqrt(q.square().mean(dim=-1, keepdim=True) + 1e-6)
|
| 83 |
+
k = k * torch.rsqrt(k.square().mean(dim=-1, keepdim=True) + 1e-6)
|
| 84 |
+
attn_mask = None
|
| 85 |
+
if key_padding_mask is not None:
|
| 86 |
+
attn_mask = ~key_padding_mask[:, None, None, :].to(dtype=torch.bool)
|
| 87 |
+
attn_out = F.scaled_dot_product_attention(
|
| 88 |
+
q, k, v, attn_mask=attn_mask, dropout_p=0.0, is_causal=False,
|
| 89 |
+
)
|
| 90 |
+
else:
|
| 91 |
+
# L2 + learned temperature (nGPT/ViT-22B style)
|
| 92 |
+
q = F.normalize(q, dim=-1)
|
| 93 |
+
k = F.normalize(k, dim=-1)
|
| 94 |
+
scale = self.log_scale.exp().view(1, -1, 1, 1)
|
| 95 |
+
q = q * scale
|
| 96 |
+
attn_mask = None
|
| 97 |
+
if key_padding_mask is not None:
|
| 98 |
+
attn_mask = ~key_padding_mask[:, None, None, :].to(dtype=torch.bool)
|
| 99 |
+
attn_out = F.scaled_dot_product_attention(
|
| 100 |
+
q, k, v, attn_mask=attn_mask, dropout_p=0.0, is_causal=False,
|
| 101 |
+
scale=1.0,
|
| 102 |
+
)
|
| 103 |
+
else:
|
| 104 |
+
attn_mask = None
|
| 105 |
+
if key_padding_mask is not None:
|
| 106 |
+
attn_mask = ~key_padding_mask[:, None, None, :].to(dtype=torch.bool)
|
| 107 |
+
attn_out = F.scaled_dot_product_attention(
|
| 108 |
+
q, k, v, attn_mask=attn_mask, dropout_p=0.0, is_causal=False
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
attn_out = attn_out.transpose(1, 2).reshape(B, Tq, self.d_model)
|
| 112 |
+
return self.out_proj(attn_out)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# =============================================================================
|
| 116 |
+
# Transformer Feed-Forward Block
|
| 117 |
+
# =============================================================================
|
| 118 |
+
|
| 119 |
+
def _get_activation(name: str):
|
| 120 |
+
"""Look up activation function by name. Supports 'relu_sq' for ReLU^2."""
|
| 121 |
+
if name == "relu_sq":
|
| 122 |
+
return lambda x: F.relu(x).square()
|
| 123 |
+
return getattr(F, name)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class FeedForward(nn.Module):
|
| 127 |
+
"""
|
| 128 |
+
Position-wise MLP block: linear -> activation -> linear.
|
| 129 |
+
Supports 'gelu', 'relu', 'relu_sq', etc.
|
| 130 |
+
"""
|
| 131 |
+
def __init__(self, d_model: int, dim_ff: int, activation: str = "gelu"):
|
| 132 |
+
super().__init__()
|
| 133 |
+
self.linear1 = nn.Linear(d_model, dim_ff)
|
| 134 |
+
self.linear2 = nn.Linear(dim_ff, d_model)
|
| 135 |
+
nn.init.zeros_(self.linear2.weight)
|
| 136 |
+
nn.init.zeros_(self.linear2.bias)
|
| 137 |
+
self.activation = _get_activation(activation)
|
| 138 |
+
|
| 139 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 140 |
+
x = self.linear1(x)
|
| 141 |
+
return self.linear2(self.activation(x))
|
s23dr_2026_example/bad_samples.txt
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
14b1872e960
|
| 2 |
+
1807ef90db4
|
| 3 |
+
180e6a67e87
|
| 4 |
+
1ad5c6bd31f
|
| 5 |
+
1c3f939ad93
|
| 6 |
+
1ede4c0d52f
|
| 7 |
+
214f17d9cc4
|
| 8 |
+
22256d88df9
|
| 9 |
+
24a92a8de6d
|
| 10 |
+
24b4e984bad
|
| 11 |
+
2565978cf53
|
| 12 |
+
2a71f1a2072
|
| 13 |
+
2d44c1fade6
|
| 14 |
+
2ebed43823a
|
| 15 |
+
33982551420
|
| 16 |
+
3b480496f82
|
| 17 |
+
412a2bdf7a4
|
| 18 |
+
44343bbabbb
|
| 19 |
+
4a0b3f04cbd
|
| 20 |
+
4a7fa170826
|
| 21 |
+
4b7dc027214
|
| 22 |
+
4e0dc2c9b18
|
| 23 |
+
5172a516c8b
|
| 24 |
+
529e8f15cd2
|
| 25 |
+
56fc6f6f163
|
| 26 |
+
575963ce814
|
| 27 |
+
578ec40a278
|
| 28 |
+
5a0c07c575a
|
| 29 |
+
5d521223c26
|
| 30 |
+
6148b5c9461
|
| 31 |
+
631eb6d7c03
|
| 32 |
+
655a14f8a75
|
| 33 |
+
66502d7ee6f
|
| 34 |
+
6da76fc6687
|
| 35 |
+
777eaaad0ca
|
| 36 |
+
7a4e2909d68
|
| 37 |
+
7c5c9baf483
|
| 38 |
+
80806dfd75e
|
| 39 |
+
81a4ead431d
|
| 40 |
+
833152dd554
|
| 41 |
+
85797868c0f
|
| 42 |
+
86460ad8181
|
| 43 |
+
86783a6bee4
|
| 44 |
+
95193322d7a
|
| 45 |
+
99a9d056200
|
| 46 |
+
9b1d4eeaab9
|
| 47 |
+
9ff759f2e4c
|
| 48 |
+
acbd243da16
|
| 49 |
+
b9b275710c0
|
| 50 |
+
beceaa9bb7c
|
| 51 |
+
c243d079286
|
| 52 |
+
c5c7337d2cb
|
| 53 |
+
cdf6f2d3b35
|
| 54 |
+
cfe370f1c87
|
| 55 |
+
d4a72aea80c
|
| 56 |
+
d655f066cd3
|
| 57 |
+
d79e8d9455c
|
| 58 |
+
d7d6c5be76e
|
| 59 |
+
dc30ae4b93b
|
| 60 |
+
de9495f7ca3
|
| 61 |
+
e1901819c72
|
| 62 |
+
e1d88c1a6b1
|
| 63 |
+
e5d3eb0a617
|
| 64 |
+
ec11d3cdcf6
|
| 65 |
+
ecb21fad0ad
|
| 66 |
+
ee55d8c6493
|
| 67 |
+
ee7e6d4dee1
|
| 68 |
+
008052054aa
|
| 69 |
+
03ecb7d3cf3
|
| 70 |
+
0555a655534
|
| 71 |
+
099cad230c6
|
| 72 |
+
0d061ae23f0
|
| 73 |
+
10741a421c0
|
| 74 |
+
110d5e407b9
|
| 75 |
+
128a7fb415a
|
| 76 |
+
13177736b26
|
| 77 |
+
1635d73bf7d
|
| 78 |
+
18a760de9ea
|
| 79 |
+
18d90d03e95
|
| 80 |
+
209627a5c1a
|
| 81 |
+
21e3cd4b7b8
|
| 82 |
+
22f5499200d
|
| 83 |
+
266eb64de68
|
| 84 |
+
269235f770b
|
| 85 |
+
2758490e558
|
| 86 |
+
2a203cf5d35
|
| 87 |
+
2a878ec47ab
|
| 88 |
+
2cb43eb2201
|
| 89 |
+
393298e282b
|
| 90 |
+
395abe6aac7
|
| 91 |
+
3d19c7a4ca3
|
| 92 |
+
44e2b719b1e
|
| 93 |
+
45039819fcc
|
| 94 |
+
4cb4ff01619
|
| 95 |
+
4e5eb5712fa
|
| 96 |
+
4e988765a6d
|
| 97 |
+
5077bf42714
|
| 98 |
+
55ed69b2622
|
| 99 |
+
5ae3b651a37
|
| 100 |
+
5ca1edeed4c
|
| 101 |
+
5daa76b1c7f
|
| 102 |
+
5fdd11dfae5
|
| 103 |
+
6078cf180c2
|
| 104 |
+
6682b309e9c
|
| 105 |
+
6c02d2038c0
|
| 106 |
+
71c595506c8
|
| 107 |
+
73c8f960c18
|
| 108 |
+
74ccc8fd057
|
| 109 |
+
7a34156a798
|
| 110 |
+
7ac7af9f59c
|
| 111 |
+
7f2ec0ea179
|
| 112 |
+
823b837b36c
|
| 113 |
+
82d7600f9a3
|
| 114 |
+
848161a2900
|
| 115 |
+
88cedf129eb
|
| 116 |
+
8dec106b6a6
|
| 117 |
+
8e335d08ca4
|
| 118 |
+
8ecf7c58193
|
| 119 |
+
8fa55008beb
|
| 120 |
+
90e09de2301
|
| 121 |
+
9197acc0b9d
|
| 122 |
+
954c25e876c
|
| 123 |
+
98517d5563d
|
| 124 |
+
99e717a0148
|
| 125 |
+
9a0c0635bd7
|
| 126 |
+
9ad436b7b3d
|
| 127 |
+
9be351cbf14
|
| 128 |
+
9e2a2e51798
|
| 129 |
+
a84a7ea9220
|
| 130 |
+
aa8cb84d3eb
|
| 131 |
+
b07977292da
|
| 132 |
+
b3e33456f0b
|
| 133 |
+
b7823de373e
|
| 134 |
+
bac379382d9
|
| 135 |
+
bd2d9bf67a3
|
| 136 |
+
c14584a84cd
|
| 137 |
+
c497170c970
|
| 138 |
+
cd8e767612b
|
| 139 |
+
d17917bb279
|
| 140 |
+
d42b9d432a9
|
| 141 |
+
d53d8857a85
|
| 142 |
+
d6808cf3d98
|
| 143 |
+
d6f509d1dd9
|
| 144 |
+
d7abd08e643
|
| 145 |
+
d83493bf974
|
| 146 |
+
d87293651ee
|
| 147 |
+
da9d4ac9e8e
|
| 148 |
+
daa1702791a
|
| 149 |
+
dcb12411c14
|
| 150 |
+
de9ab9cdd5b
|
| 151 |
+
df906c58a3c
|
| 152 |
+
e3870649eb5
|
| 153 |
+
ea90aed9b98
|
| 154 |
+
ecaa81b9711
|
| 155 |
+
efc1238665b
|
| 156 |
+
c5a65219daf
|
s23dr_2026_example/cache_scenes.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Cache compact scenes from HoHo22k shards to training-ready .pt files.
|
| 3 |
+
|
| 4 |
+
Streams samples from the public `usm3d/hoho22k_2026_trainval` dataset, runs
|
| 5 |
+
`build_compact_scene` (see point_fusion.py), precomputes priority group_id
|
| 6 |
+
and semantic class_id, and saves one .pt per scene.
|
| 7 |
+
|
| 8 |
+
Stage 1 of the dataset pipeline. See make_sampled_cache.py for stage 2.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python -m s23dr_2026_example.cache_scenes --out-dir cache/full --split train
|
| 12 |
+
python -m s23dr_2026_example.cache_scenes --out-dir cache/full_val --split validation
|
| 13 |
+
|
| 14 |
+
Cache format per .pt file:
|
| 15 |
+
xyz: float32 [P, 3] all points in world space
|
| 16 |
+
source: uint8 [P] 0=colmap, 1=depth
|
| 17 |
+
group_id: int8 [P] priority tier 0-4, -1=excluded
|
| 18 |
+
class_id: uint8 [P] one-hot class index (0-12)
|
| 19 |
+
behind_gest_id: int16 [P] behind-gestalt id (-1 if none)
|
| 20 |
+
visible_src: uint8 [P] 1=gestalt, 2=ade
|
| 21 |
+
visible_id: int16 [P] class id within space
|
| 22 |
+
n_views_voted: uint8 [P] number of views that voted
|
| 23 |
+
vote_frac: float32 [P] fraction of votes
|
| 24 |
+
center: float32 [3] smart normalization center
|
| 25 |
+
scale: float32 scalar smart normalization scale
|
| 26 |
+
gt_vertices: float32 [V, 3] ground truth wireframe vertices
|
| 27 |
+
gt_edges: int32 [E, 2] ground truth wireframe edge indices
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import argparse
|
| 32 |
+
import time
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
import numpy as np
|
| 36 |
+
import torch
|
| 37 |
+
|
| 38 |
+
from .point_fusion import (
|
| 39 |
+
FuserConfig, build_compact_scene,
|
| 40 |
+
GEST_ID_TO_NAME, ADE_ID_TO_NAME, NUM_GEST,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
# Semantic class encoding: 11 structural + 1 other_house + 1 non_house = 13
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
# Each structural gestalt class gets its own one-hot bit.
|
| 48 |
+
STRUCTURAL_CLASSES = (
|
| 49 |
+
"apex", "eave_end_point", "flashing_end_point", # point classes (tier 0)
|
| 50 |
+
"rake", "ridge", "eave", "hip", "valley", # roof edges (tier 1)
|
| 51 |
+
"flashing", "step_flashing",
|
| 52 |
+
"roof", # roof face (tier 2)
|
| 53 |
+
)
|
| 54 |
+
# Index 11 = other house part (door, window, siding, etc.)
|
| 55 |
+
# Index 12 = non-house / ADE / unlabeled
|
| 56 |
+
NUM_SEMANTIC_CLASSES = len(STRUCTURAL_CLASSES) + 2 # 13
|
| 57 |
+
|
| 58 |
+
# Priority tiers (same as tokenizer.py)
|
| 59 |
+
_GEST_NAME_TO_ID = {n: i for i, n in enumerate(GEST_ID_TO_NAME)}
|
| 60 |
+
_POINT_IDS = {_GEST_NAME_TO_ID[n] for n in ("apex", "eave_end_point", "flashing_end_point") if n in _GEST_NAME_TO_ID}
|
| 61 |
+
_EDGE_IDS = {_GEST_NAME_TO_ID[n] for n in ("rake", "ridge", "eave", "hip", "valley", "flashing", "step_flashing") if n in _GEST_NAME_TO_ID}
|
| 62 |
+
_FACE_IDS = {_GEST_NAME_TO_ID[n] for n in ("roof",) if n in _GEST_NAME_TO_ID}
|
| 63 |
+
_HOUSE_IDS = {_GEST_NAME_TO_ID[n] for n in (
|
| 64 |
+
"apex", "eave_end_point", "flashing_end_point",
|
| 65 |
+
"rake", "ridge", "eave", "hip", "valley", "flashing", "step_flashing",
|
| 66 |
+
"roof", "door", "garage", "window", "shutter", "fascia", "soffit",
|
| 67 |
+
"horizontal_siding", "vertical_siding", "brick", "concrete",
|
| 68 |
+
"other_wall", "trim", "post", "ground_line",
|
| 69 |
+
) if n in _GEST_NAME_TO_ID}
|
| 70 |
+
|
| 71 |
+
_ADE_NAME_TO_ID = {n.lower(): i for i, n in enumerate(ADE_ID_TO_NAME)}
|
| 72 |
+
_ADE_HOUSE_IDS = {_ADE_NAME_TO_ID[n] for n in ("building;edifice", "house", "wall", "windowpane;window", "door;double;door") if n in _ADE_NAME_TO_ID}
|
| 73 |
+
|
| 74 |
+
_UNCLS_ID = _GEST_NAME_TO_ID.get("unclassified", -1)
|
| 75 |
+
|
| 76 |
+
# Map structural gestalt names to one-hot index
|
| 77 |
+
_STRUCTURAL_ONEHOT = {}
|
| 78 |
+
for idx, name in enumerate(STRUCTURAL_CLASSES):
|
| 79 |
+
gid = _GEST_NAME_TO_ID.get(name)
|
| 80 |
+
if gid is not None:
|
| 81 |
+
_STRUCTURAL_ONEHOT[gid] = idx
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _compute_group_and_class(visible_src, visible_id, behind_id, source):
|
| 85 |
+
"""Compute priority group_id and semantic class_id per point (vectorized).
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
visible_src: uint8 [P] -- 0=unlabeled, 1=gestalt, 2=ade
|
| 89 |
+
visible_id: int16 [P] -- class id within gestalt or ade space
|
| 90 |
+
behind_id: int16 [P] -- behind-gestalt id (-1 if none)
|
| 91 |
+
source: uint8 [P] -- 0=colmap, 1=depth
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
group_id: int8 [P] -- priority tier 0-4, -1 for excluded (unclassified)
|
| 95 |
+
class_id: uint8 [P] -- one-hot class index 0-12
|
| 96 |
+
"""
|
| 97 |
+
P = len(visible_src)
|
| 98 |
+
vsrc = visible_src.astype(np.int32)
|
| 99 |
+
vid = visible_id.astype(np.int32)
|
| 100 |
+
bid = behind_id.astype(np.int32)
|
| 101 |
+
|
| 102 |
+
# Effective gestalt id: prefer visible gestalt, fall back to behind
|
| 103 |
+
gest_id = np.full(P, -1, dtype=np.int32)
|
| 104 |
+
has_vis_gest = (vsrc == 1) & (vid >= 0)
|
| 105 |
+
has_behind = (bid >= 0) & ~has_vis_gest
|
| 106 |
+
gest_id[has_vis_gest] = vid[has_vis_gest]
|
| 107 |
+
gest_id[has_behind] = bid[has_behind]
|
| 108 |
+
|
| 109 |
+
# Exclude unclassified points
|
| 110 |
+
if _UNCLS_ID >= 0:
|
| 111 |
+
is_uncls = ((vsrc == 1) & (vid == _UNCLS_ID)) | (bid == _UNCLS_ID)
|
| 112 |
+
gest_id[is_uncls] = -1 # force excluded
|
| 113 |
+
|
| 114 |
+
# Build lookup arrays for gestalt id -> group and gestalt id -> class
|
| 115 |
+
max_gid = NUM_GEST
|
| 116 |
+
gid_to_group = np.full(max_gid, 4, dtype=np.int8) # default: tier 4
|
| 117 |
+
gid_to_class = np.full(max_gid, NUM_SEMANTIC_CLASSES - 1, dtype=np.uint8) # default: non-house
|
| 118 |
+
|
| 119 |
+
for gid in _POINT_IDS:
|
| 120 |
+
gid_to_group[gid] = 0
|
| 121 |
+
for gid in _EDGE_IDS:
|
| 122 |
+
gid_to_group[gid] = 1
|
| 123 |
+
for gid in _FACE_IDS:
|
| 124 |
+
gid_to_group[gid] = 2
|
| 125 |
+
for gid in _HOUSE_IDS - _POINT_IDS - _EDGE_IDS - _FACE_IDS:
|
| 126 |
+
gid_to_group[gid] = 3
|
| 127 |
+
for gid, onehot_idx in _STRUCTURAL_ONEHOT.items():
|
| 128 |
+
gid_to_class[gid] = onehot_idx
|
| 129 |
+
for gid in _HOUSE_IDS - set(_STRUCTURAL_ONEHOT.keys()):
|
| 130 |
+
gid_to_class[gid] = len(STRUCTURAL_CLASSES) # other_house
|
| 131 |
+
|
| 132 |
+
# Apply lookup for points with valid gestalt ids
|
| 133 |
+
has_gest = gest_id >= 0
|
| 134 |
+
group_id = np.full(P, 4, dtype=np.int8) # default: tier 4
|
| 135 |
+
class_id = np.full(P, NUM_SEMANTIC_CLASSES - 1, dtype=np.uint8) # default: non-house
|
| 136 |
+
|
| 137 |
+
group_id[has_gest] = gid_to_group[gest_id[has_gest]]
|
| 138 |
+
class_id[has_gest] = gid_to_class[gest_id[has_gest]]
|
| 139 |
+
|
| 140 |
+
# ADE house points (no gestalt) get tier 3 + class_id = other_house
|
| 141 |
+
ade_house_arr = np.array(sorted(_ADE_HOUSE_IDS), dtype=np.int32)
|
| 142 |
+
is_ade_house = ~has_gest & (vsrc == 2) & (vid >= 0) & np.isin(vid, ade_house_arr)
|
| 143 |
+
group_id[is_ade_house] = 3
|
| 144 |
+
class_id[is_ade_house] = len(STRUCTURAL_CLASSES) # other_house (index 11)
|
| 145 |
+
|
| 146 |
+
# Mark excluded points (unclassified) as -1
|
| 147 |
+
if _UNCLS_ID >= 0:
|
| 148 |
+
group_id[is_uncls] = -1
|
| 149 |
+
class_id[is_uncls] = NUM_SEMANTIC_CLASSES - 1
|
| 150 |
+
|
| 151 |
+
return group_id, class_id
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _compute_smart_center_scale(xyz, source, mad_k=2.5, percentile=95.0,
|
| 155 |
+
max_points=8000):
|
| 156 |
+
"""Compute normalization center and scale from depth points with MAD filter."""
|
| 157 |
+
depth_mask = source == 1
|
| 158 |
+
ref = xyz[depth_mask] if depth_mask.any() else xyz
|
| 159 |
+
if ref.shape[0] == 0:
|
| 160 |
+
center = xyz.mean(axis=0)
|
| 161 |
+
scale = max(np.linalg.norm(xyz - center, axis=1).max(), 1e-6)
|
| 162 |
+
return center.astype(np.float32), np.float32(scale)
|
| 163 |
+
|
| 164 |
+
if ref.shape[0] > max_points:
|
| 165 |
+
idx = np.random.choice(ref.shape[0], max_points, replace=False)
|
| 166 |
+
ref = ref[idx]
|
| 167 |
+
|
| 168 |
+
center0 = np.median(ref, axis=0)
|
| 169 |
+
dist = np.linalg.norm(ref - center0, axis=1)
|
| 170 |
+
med = np.median(dist)
|
| 171 |
+
mad = max(np.median(np.abs(dist - med)), 1e-6)
|
| 172 |
+
inliers = dist <= (med + mad_k * mad)
|
| 173 |
+
if inliers.any():
|
| 174 |
+
ref = ref[inliers]
|
| 175 |
+
|
| 176 |
+
# Percentile bounding box
|
| 177 |
+
lo_f = (100.0 - percentile) * 0.5 / 100.0
|
| 178 |
+
sorted_v = np.sort(ref, axis=0)
|
| 179 |
+
n = sorted_v.shape[0]
|
| 180 |
+
lo_idx = max(0, min(n - 1, int(lo_f * (n - 1))))
|
| 181 |
+
hi_idx = max(0, min(n - 1, int((1.0 - lo_f) * (n - 1))))
|
| 182 |
+
low = sorted_v[lo_idx]
|
| 183 |
+
high = sorted_v[hi_idx]
|
| 184 |
+
|
| 185 |
+
center = 0.5 * (low + high)
|
| 186 |
+
scale = max(np.sqrt(((high - low) ** 2).sum()), 1e-6)
|
| 187 |
+
return center.astype(np.float32), np.float32(scale)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
# Dataset pipeline stage 1: raw HF sample -> cached .pt
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
|
| 194 |
+
def _process_one(sample, cfg):
|
| 195 |
+
"""Fuse a single HF sample into a cache dict. Returns (order_id, dict) or None."""
|
| 196 |
+
rng = np.random.RandomState()
|
| 197 |
+
|
| 198 |
+
n_edges = len(sample.get("wf_edges", []))
|
| 199 |
+
if n_edges == 0 or n_edges > 64:
|
| 200 |
+
return None
|
| 201 |
+
|
| 202 |
+
scene = build_compact_scene(sample, cfg, rng=rng)
|
| 203 |
+
if scene is None:
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
gt_v = scene.get("gt_vertices")
|
| 207 |
+
gt_e = scene.get("gt_edges")
|
| 208 |
+
if gt_v is None or gt_e is None or len(gt_e) == 0:
|
| 209 |
+
return None
|
| 210 |
+
|
| 211 |
+
xyz = scene["xyz"]
|
| 212 |
+
source = scene["source"]
|
| 213 |
+
group_id, class_id = _compute_group_and_class(
|
| 214 |
+
scene["visible_src"], scene["visible_id"], scene["behind_gest_id"], source)
|
| 215 |
+
center, scale = _compute_smart_center_scale(xyz, source)
|
| 216 |
+
|
| 217 |
+
gt_edge_classes = np.asarray(sample["wf_classifications"], dtype=np.int64)
|
| 218 |
+
return sample["order_id"], {
|
| 219 |
+
"xyz": xyz.astype(np.float32),
|
| 220 |
+
"source": source.astype(np.uint8),
|
| 221 |
+
"group_id": group_id,
|
| 222 |
+
"class_id": class_id,
|
| 223 |
+
"behind_gest_id": scene["behind_gest_id"].astype(np.int16),
|
| 224 |
+
"visible_src": scene["visible_src"].astype(np.uint8),
|
| 225 |
+
"visible_id": scene["visible_id"].astype(np.int16),
|
| 226 |
+
"n_views_voted": scene["n_views_voted"],
|
| 227 |
+
"vote_frac": scene["vote_frac"],
|
| 228 |
+
"center": center,
|
| 229 |
+
"scale": scale,
|
| 230 |
+
"gt_vertices": gt_v.astype(np.float32),
|
| 231 |
+
"gt_edges": gt_e.astype(np.int32),
|
| 232 |
+
"gt_edge_classes": gt_edge_classes,
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def main():
|
| 237 |
+
p = argparse.ArgumentParser(description="Stage 1: HoHo22k -> cached .pt files")
|
| 238 |
+
p.add_argument("--out-dir", required=True, help="Output directory for .pt files")
|
| 239 |
+
p.add_argument("--split", default="train", choices=["train", "validation"])
|
| 240 |
+
p.add_argument("--limit", type=int, default=0, help="Stop after N samples (0 = all)")
|
| 241 |
+
p.add_argument("--depth-per-view", type=int, default=8000)
|
| 242 |
+
p.add_argument("--skip-existing", action="store_true")
|
| 243 |
+
args = p.parse_args()
|
| 244 |
+
|
| 245 |
+
out_dir = Path(args.out_dir)
|
| 246 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 247 |
+
existing = {p.stem for p in out_dir.glob("*.pt")} if args.skip_existing else set()
|
| 248 |
+
|
| 249 |
+
from datasets import load_dataset
|
| 250 |
+
print(f"Streaming usm3d/hoho22k_2026_trainval split={args.split}...")
|
| 251 |
+
ds = load_dataset("usm3d/hoho22k_2026_trainval",
|
| 252 |
+
streaming=True, trust_remote_code=True, split=args.split)
|
| 253 |
+
|
| 254 |
+
cfg = FuserConfig(depth_points_per_view=args.depth_per_view)
|
| 255 |
+
saved, skipped = 0, 0
|
| 256 |
+
t0 = time.perf_counter()
|
| 257 |
+
for i, sample in enumerate(ds):
|
| 258 |
+
if args.limit > 0 and i >= args.limit:
|
| 259 |
+
break
|
| 260 |
+
oid = sample["order_id"]
|
| 261 |
+
if oid in existing:
|
| 262 |
+
skipped += 1
|
| 263 |
+
continue
|
| 264 |
+
result = _process_one(sample, cfg)
|
| 265 |
+
if result is None:
|
| 266 |
+
skipped += 1
|
| 267 |
+
continue
|
| 268 |
+
order_id, data = result
|
| 269 |
+
torch.save(data, out_dir / f"{order_id}.pt")
|
| 270 |
+
saved += 1
|
| 271 |
+
if saved % 100 == 0:
|
| 272 |
+
rate = saved / (time.perf_counter() - t0)
|
| 273 |
+
print(f" saved {saved} (skipped {skipped}) [{rate:.1f}/s]")
|
| 274 |
+
|
| 275 |
+
elapsed = time.perf_counter() - t0
|
| 276 |
+
print(f"Done. Saved {saved}, skipped {skipped} in {elapsed:.0f}s.")
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
if __name__ == "__main__":
|
| 280 |
+
main()
|
| 281 |
+
|
| 282 |
+
|
s23dr_2026_example/color_mappings.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gestalt_color_mapping = {
|
| 2 |
+
"unclassified": (215, 62, 138),
|
| 3 |
+
"apex": (235, 88, 48),
|
| 4 |
+
"eave_end_point": (248, 130, 228),
|
| 5 |
+
"flashing_end_point": (71, 11, 161),
|
| 6 |
+
"ridge": (214, 251, 248),
|
| 7 |
+
"rake": (13, 94, 47),
|
| 8 |
+
"eave": (54, 243, 63),
|
| 9 |
+
"post": (187, 123, 236),
|
| 10 |
+
"ground_line": (136, 206, 14),
|
| 11 |
+
"flashing": (162, 162, 32),
|
| 12 |
+
"step_flashing": (169, 255, 219),
|
| 13 |
+
"hip": (8, 89, 52),
|
| 14 |
+
"valley": (85, 27, 65),
|
| 15 |
+
"roof": (215, 232, 179),
|
| 16 |
+
"door": (110, 52, 23),
|
| 17 |
+
"garage": (50, 233, 171),
|
| 18 |
+
"window": (230, 249, 40),
|
| 19 |
+
"shutter": (122, 4, 233),
|
| 20 |
+
"fascia": (95, 230, 240),
|
| 21 |
+
"soffit": (2, 102, 197),
|
| 22 |
+
"horizontal_siding": (131, 88, 59),
|
| 23 |
+
"vertical_siding": (110, 187, 198),
|
| 24 |
+
"brick": (171, 252, 7),
|
| 25 |
+
"concrete": (32, 47, 246),
|
| 26 |
+
"other_wall": (112, 61, 240),
|
| 27 |
+
"trim": (151, 206, 58),
|
| 28 |
+
"unknown": (127, 127, 127),
|
| 29 |
+
"transition_line": (0,0,0),
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
ade20k_color_mapping = {
|
| 33 |
+
'wall': (120, 120, 120),
|
| 34 |
+
'building;edifice': (180, 120, 120),
|
| 35 |
+
'sky': (6, 230, 230),
|
| 36 |
+
'floor;flooring': (80, 50, 50),
|
| 37 |
+
'tree': (4, 200, 3),
|
| 38 |
+
'ceiling': (120, 120, 80),
|
| 39 |
+
'road;route': (140, 140, 140),
|
| 40 |
+
'bed': (204, 5, 255),
|
| 41 |
+
'windowpane;window': (230, 230, 230),
|
| 42 |
+
'grass': (4, 250, 7),
|
| 43 |
+
'cabinet': (224, 5, 255),
|
| 44 |
+
'sidewalk;pavement': (235, 255, 7),
|
| 45 |
+
'person;individual;someone;somebody;mortal;soul': (150, 5, 61),
|
| 46 |
+
'earth;ground': (120, 120, 70),
|
| 47 |
+
'door;double;door': (8, 255, 51),
|
| 48 |
+
'table': (255, 6, 82),
|
| 49 |
+
'mountain;mount': (143, 255, 140),
|
| 50 |
+
'plant;flora;plant;life': (204, 255, 4),
|
| 51 |
+
'curtain;drape;drapery;mantle;pall': (255, 51, 7),
|
| 52 |
+
'chair': (204, 70, 3),
|
| 53 |
+
'car;auto;automobile;machine;motorcar': (0, 102, 200),
|
| 54 |
+
'water': (61, 230, 250),
|
| 55 |
+
'painting;picture': (255, 6, 51),
|
| 56 |
+
'sofa;couch;lounge': (11, 102, 255),
|
| 57 |
+
'shelf': (255, 7, 71),
|
| 58 |
+
'house': (255, 9, 224),
|
| 59 |
+
'sea': (9, 7, 230),
|
| 60 |
+
'mirror': (220, 220, 220),
|
| 61 |
+
'rug;carpet;carpeting': (255, 9, 92),
|
| 62 |
+
'field': (112, 9, 255),
|
| 63 |
+
'armchair': (8, 255, 214),
|
| 64 |
+
'seat': (7, 255, 224),
|
| 65 |
+
'fence;fencing': (255, 184, 6),
|
| 66 |
+
'desk': (10, 255, 71),
|
| 67 |
+
'rock;stone': (255, 41, 10),
|
| 68 |
+
'wardrobe;closet;press': (7, 255, 255),
|
| 69 |
+
'lamp': (224, 255, 8),
|
| 70 |
+
'bathtub;bathing;tub;bath;tub': (102, 8, 255),
|
| 71 |
+
'railing;rail': (255, 61, 6),
|
| 72 |
+
'cushion': (255, 194, 7),
|
| 73 |
+
'base;pedestal;stand': (255, 122, 8),
|
| 74 |
+
'box': (0, 255, 20),
|
| 75 |
+
'column;pillar': (255, 8, 41),
|
| 76 |
+
'signboard;sign': (255, 5, 153),
|
| 77 |
+
'chest;of;drawers;chest;bureau;dresser': (6, 51, 255),
|
| 78 |
+
'counter': (235, 12, 255),
|
| 79 |
+
'sand': (160, 150, 20),
|
| 80 |
+
'sink': (0, 163, 255),
|
| 81 |
+
'skyscraper': (140, 140, 140),
|
| 82 |
+
'fireplace;hearth;open;fireplace': (250, 10, 15),
|
| 83 |
+
'refrigerator;icebox': (20, 255, 0),
|
| 84 |
+
'grandstand;covered;stand': (31, 255, 0),
|
| 85 |
+
'path': (255, 31, 0),
|
| 86 |
+
'stairs;steps': (255, 224, 0),
|
| 87 |
+
'runway': (153, 255, 0),
|
| 88 |
+
'case;display;case;showcase;vitrine': (0, 0, 255),
|
| 89 |
+
'pool;table;billiard;table;snooker;table': (255, 71, 0),
|
| 90 |
+
'pillow': (0, 235, 255),
|
| 91 |
+
'screen;door;screen': (0, 173, 255),
|
| 92 |
+
'stairway;staircase': (31, 0, 255),
|
| 93 |
+
'river': (11, 200, 200),
|
| 94 |
+
'bridge;span': (255 ,82, 0),
|
| 95 |
+
'bookcase': (0, 255, 245),
|
| 96 |
+
'blind;screen': (0, 61, 255),
|
| 97 |
+
'coffee;table;cocktail;table': (0, 255, 112),
|
| 98 |
+
'toilet;can;commode;crapper;pot;potty;stool;throne': (0, 255, 133),
|
| 99 |
+
'flower': (255, 0, 0),
|
| 100 |
+
'book': (255, 163, 0),
|
| 101 |
+
'hill': (255, 102, 0),
|
| 102 |
+
'bench': (194, 255, 0),
|
| 103 |
+
'countertop': (0, 143, 255),
|
| 104 |
+
'stove;kitchen;stove;range;kitchen;range;cooking;stove': (51, 255, 0),
|
| 105 |
+
'palm;palm;tree': (0, 82, 255),
|
| 106 |
+
'kitchen;island': (0, 255, 41),
|
| 107 |
+
'computer;computing;machine;computing;device;data;processor;electronic;computer;information;processing;system': (0, 255, 173),
|
| 108 |
+
'swivel;chair': (10, 0, 255),
|
| 109 |
+
'boat': (173, 255, 0),
|
| 110 |
+
'bar': (0, 255, 153),
|
| 111 |
+
'arcade;machine': (255, 92, 0),
|
| 112 |
+
'hovel;hut;hutch;shack;shanty': (255, 0, 255),
|
| 113 |
+
'bus;autobus;coach;charabanc;double-decker;jitney;motorbus;motorcoach;omnibus;passenger;vehicle': (255, 0, 245),
|
| 114 |
+
'towel': (255, 0, 102),
|
| 115 |
+
'light;light;source': (255, 173, 0),
|
| 116 |
+
'truck;motortruck': (255, 0, 20),
|
| 117 |
+
'tower': (255, 184, 184),
|
| 118 |
+
'chandelier;pendant;pendent': (0, 31, 255),
|
| 119 |
+
'awning;sunshade;sunblind': (0, 255, 61),
|
| 120 |
+
'streetlight;street;lamp': (0, 71, 255),
|
| 121 |
+
'booth;cubicle;stall;kiosk': (255, 0, 204),
|
| 122 |
+
'television;television;receiver;television;set;tv;tv;set;idiot;box;boob;tube;telly;goggle;box': (0, 255, 194),
|
| 123 |
+
'airplane;aeroplane;plane': (0, 255, 82),
|
| 124 |
+
'dirt;track': (0, 10, 255),
|
| 125 |
+
'apparel;wearing;apparel;dress;clothes': (0, 112, 255),
|
| 126 |
+
'pole': (51, 0, 255),
|
| 127 |
+
'land;ground;soil': (0, 194, 255),
|
| 128 |
+
'bannister;banister;balustrade;balusters;handrail': (0, 122, 255),
|
| 129 |
+
'escalator;moving;staircase;moving;stairway': (0, 255, 163),
|
| 130 |
+
'ottoman;pouf;pouffe;puff;hassock': (255, 153, 0),
|
| 131 |
+
'bottle': (0, 255, 10),
|
| 132 |
+
'buffet;counter;sideboard': (255, 112, 0),
|
| 133 |
+
'poster;posting;placard;notice;bill;card': (143, 255, 0),
|
| 134 |
+
'stage': (82, 0, 255),
|
| 135 |
+
'van': (163, 255, 0),
|
| 136 |
+
'ship': (255, 235, 0),
|
| 137 |
+
'fountain': (8, 184, 170),
|
| 138 |
+
'conveyer;belt;conveyor;belt;conveyer;conveyor;transporter': (133, 0, 255),
|
| 139 |
+
'canopy': (0, 255, 92),
|
| 140 |
+
'washer;automatic;washer;washing;machine': (184, 0, 255),
|
| 141 |
+
'plaything;toy': (255, 0, 31),
|
| 142 |
+
'swimming;pool;swimming;bath;natatorium': (0, 184, 255),
|
| 143 |
+
'stool': (0, 214, 255),
|
| 144 |
+
'barrel;cask': (255, 0, 112),
|
| 145 |
+
'basket;handbasket': (92, 255, 0),
|
| 146 |
+
'waterfall;falls': (0, 224, 255),
|
| 147 |
+
'tent;collapsible;shelter': (112, 224, 255),
|
| 148 |
+
'bag': (70, 184, 160),
|
| 149 |
+
'minibike;motorbike': (163, 0, 255),
|
| 150 |
+
'cradle': (153, 0, 255),
|
| 151 |
+
'oven': (71, 255, 0),
|
| 152 |
+
'ball': (255, 0, 163),
|
| 153 |
+
'food;solid;food': (255, 204, 0),
|
| 154 |
+
'step;stair': (255, 0, 143),
|
| 155 |
+
'tank;storage;tank': (0, 255, 235),
|
| 156 |
+
'trade;name;brand;name;brand;marque': (133, 255, 0),
|
| 157 |
+
'microwave;microwave;oven': (255, 0, 235),
|
| 158 |
+
'pot;flowerpot': (245, 0, 255),
|
| 159 |
+
'animal;animate;being;beast;brute;creature;fauna': (255, 0, 122),
|
| 160 |
+
'bicycle;bike;wheel;cycle': (255, 245, 0),
|
| 161 |
+
'lake': (10, 190, 212),
|
| 162 |
+
'dishwasher;dish;washer;dishwashing;machine': (214, 255, 0),
|
| 163 |
+
'screen;silver;screen;projection;screen': (0, 204, 255),
|
| 164 |
+
'blanket;cover': (20, 0, 255),
|
| 165 |
+
'sculpture': (255, 255, 0),
|
| 166 |
+
'hood;exhaust;hood': (0, 153, 255),
|
| 167 |
+
'sconce': (0, 41, 255),
|
| 168 |
+
'vase': (0, 255, 204),
|
| 169 |
+
'traffic;light;traffic;signal;stoplight': (41, 0, 255),
|
| 170 |
+
'tray': (41, 255, 0),
|
| 171 |
+
'ashcan;trash;can;garbage;can;wastebin;ash;bin;ash-bin;ashbin;dustbin;trash;barrel;trash;bin': (173, 0, 255),
|
| 172 |
+
'fan': (0, 245, 255),
|
| 173 |
+
'pier;wharf;wharfage;dock': (71, 0, 255),
|
| 174 |
+
'crt;screen': (122, 0, 255),
|
| 175 |
+
'plate': (0, 255, 184),
|
| 176 |
+
'monitor;monitoring;device': (0, 92, 255),
|
| 177 |
+
'bulletin;board;notice;board': (184, 255, 0),
|
| 178 |
+
'shower': (0, 133, 255),
|
| 179 |
+
'radiator': (255, 214, 0),
|
| 180 |
+
'glass;drinking;glass': (25, 194, 194),
|
| 181 |
+
'clock': (102, 255, 0),
|
| 182 |
+
'flag': (92, 0, 255),
|
| 183 |
+
}
|
s23dr_2026_example/data.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data loading for pre-sampled HF datasets.
|
| 2 |
+
|
| 3 |
+
Expects pre-sampled npz blobs with xyz_norm (not full PCD).
|
| 4 |
+
Supports both 2048-point and 4096-point datasets.
|
| 5 |
+
Use make_sampled_cache.py to produce these from full point clouds.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
|
| 14 |
+
from .tokenizer import EdgeDepthSequenceConfig
|
| 15 |
+
|
| 16 |
+
# Default token budget (for 2048-point datasets; 4096 uses 3072/1024)
|
| 17 |
+
SEQ_LEN = 2048
|
| 18 |
+
COLMAP_POINTS = 1536
|
| 19 |
+
DEPTH_POINTS = 512
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Datasets
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
def _load_bad_sample_ids():
|
| 27 |
+
"""Load the set of known-bad sample IDs (misaligned GT, extreme scale)."""
|
| 28 |
+
bad_file = Path(__file__).parent / "bad_samples.txt"
|
| 29 |
+
if not bad_file.exists():
|
| 30 |
+
return set()
|
| 31 |
+
return set(line.strip() for line in bad_file.read_text().splitlines() if line.strip())
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class HFCachedDataset(torch.utils.data.Dataset):
|
| 35 |
+
"""Load pre-sampled HuggingFace dataset into memory."""
|
| 36 |
+
|
| 37 |
+
def __init__(self, hf_dataset, aug_rotate=False, aug_jitter=0.0,
|
| 38 |
+
aug_drop=0.0, aug_flip=False):
|
| 39 |
+
import io as _io
|
| 40 |
+
bad_ids = _load_bad_sample_ids()
|
| 41 |
+
print(f"Pre-decoding {len(hf_dataset)} samples into memory...")
|
| 42 |
+
self.samples = []
|
| 43 |
+
self.order_ids = []
|
| 44 |
+
n_skipped = 0
|
| 45 |
+
for i, sample in enumerate(hf_dataset):
|
| 46 |
+
if sample["order_id"] in bad_ids:
|
| 47 |
+
n_skipped += 1
|
| 48 |
+
continue
|
| 49 |
+
d = dict(np.load(_io.BytesIO(sample["data"])))
|
| 50 |
+
if "xyz_norm" not in d:
|
| 51 |
+
raise ValueError(
|
| 52 |
+
f"Sample {sample['order_id']} missing 'xyz_norm' -- this looks like "
|
| 53 |
+
f"a full PCD dataset, not pre-sampled. Use make_sampled_cache.py first.")
|
| 54 |
+
self.samples.append(d)
|
| 55 |
+
self.order_ids.append(sample["order_id"])
|
| 56 |
+
if (i + 1) % 2000 == 0:
|
| 57 |
+
print(f" {i+1}/{len(hf_dataset)}...")
|
| 58 |
+
print(f" Done. {len(self.samples)} samples in memory"
|
| 59 |
+
f" ({n_skipped} bad samples filtered).")
|
| 60 |
+
self.aug_rotate = aug_rotate
|
| 61 |
+
self.aug_jitter = aug_jitter
|
| 62 |
+
self.aug_drop = aug_drop
|
| 63 |
+
self.aug_flip = aug_flip
|
| 64 |
+
|
| 65 |
+
def __len__(self):
|
| 66 |
+
return len(self.samples)
|
| 67 |
+
|
| 68 |
+
def __getitem__(self, idx):
|
| 69 |
+
out = _process_sample(self.samples[idx], self.aug_rotate,
|
| 70 |
+
self.aug_jitter, self.aug_drop, self.aug_flip)
|
| 71 |
+
out["sample_id"] = self.order_ids[idx]
|
| 72 |
+
return out
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _process_sample(d, aug_rotate, aug_jitter=0.0, aug_drop=0.0, aug_flip=False):
|
| 76 |
+
"""Process a pre-sampled npz dict into training tensors.
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
aug_rotate: random yaw rotation
|
| 80 |
+
aug_jitter: std of Gaussian noise added to point positions (0=disabled)
|
| 81 |
+
aug_drop: fraction of points to randomly drop (0=disabled)
|
| 82 |
+
aug_flip: random mirror along X axis (50% chance)
|
| 83 |
+
"""
|
| 84 |
+
xyz_norm = d["xyz_norm"].copy()
|
| 85 |
+
gt_seg = d["gt_segments"].copy()
|
| 86 |
+
mask = d["mask"].copy()
|
| 87 |
+
|
| 88 |
+
if aug_rotate:
|
| 89 |
+
theta = np.random.rand() * 2 * np.pi
|
| 90 |
+
cos_t, sin_t = np.cos(theta), np.sin(theta)
|
| 91 |
+
x, z = xyz_norm[:, 0].copy(), xyz_norm[:, 2].copy()
|
| 92 |
+
xyz_norm[:, 0] = x * cos_t - z * sin_t
|
| 93 |
+
xyz_norm[:, 2] = x * sin_t + z * cos_t
|
| 94 |
+
for ep in range(2):
|
| 95 |
+
sx, sz = gt_seg[:, ep, 0].copy(), gt_seg[:, ep, 2].copy()
|
| 96 |
+
gt_seg[:, ep, 0] = sx * cos_t - sz * sin_t
|
| 97 |
+
gt_seg[:, ep, 2] = sx * sin_t + sz * cos_t
|
| 98 |
+
|
| 99 |
+
if aug_flip and np.random.rand() < 0.5:
|
| 100 |
+
xyz_norm[:, 0] = -xyz_norm[:, 0]
|
| 101 |
+
gt_seg[:, :, 0] = -gt_seg[:, :, 0]
|
| 102 |
+
|
| 103 |
+
if aug_jitter > 0:
|
| 104 |
+
valid = mask.astype(bool)
|
| 105 |
+
xyz_norm[valid] += np.random.randn(valid.sum(), 3).astype(np.float32) * aug_jitter
|
| 106 |
+
|
| 107 |
+
if aug_drop > 0:
|
| 108 |
+
valid_idx = np.where(mask)[0]
|
| 109 |
+
n_drop = int(len(valid_idx) * aug_drop)
|
| 110 |
+
if n_drop > 0:
|
| 111 |
+
drop_idx = np.random.choice(valid_idx, n_drop, replace=False)
|
| 112 |
+
mask[drop_idx] = False
|
| 113 |
+
|
| 114 |
+
result = {
|
| 115 |
+
"xyz_norm": torch.as_tensor(xyz_norm, dtype=torch.float32),
|
| 116 |
+
"class_id": torch.as_tensor(d["class_id"], dtype=torch.long),
|
| 117 |
+
"source": torch.as_tensor(d["source"], dtype=torch.long),
|
| 118 |
+
"mask": torch.as_tensor(mask),
|
| 119 |
+
"gt_segments": torch.as_tensor(gt_seg, dtype=torch.float32),
|
| 120 |
+
"scale": torch.tensor(float(d["scale"]), dtype=torch.float32),
|
| 121 |
+
"center": torch.as_tensor(d["center"], dtype=torch.float32),
|
| 122 |
+
"gt_vertices": d["gt_vertices"],
|
| 123 |
+
"gt_edges": d["gt_edges"],
|
| 124 |
+
"visible_src": torch.as_tensor(d["visible_src"], dtype=torch.long),
|
| 125 |
+
"visible_id": torch.as_tensor(d["visible_id"], dtype=torch.long),
|
| 126 |
+
}
|
| 127 |
+
if "behind" in d:
|
| 128 |
+
result["behind"] = torch.as_tensor(
|
| 129 |
+
np.clip(np.asarray(d["behind"], dtype=np.int16), 0, None), dtype=torch.long)
|
| 130 |
+
if "n_views_voted" in d:
|
| 131 |
+
result["n_views_voted"] = torch.as_tensor(d["n_views_voted"], dtype=torch.float32)
|
| 132 |
+
if "vote_frac" in d:
|
| 133 |
+
result["vote_frac"] = torch.as_tensor(d["vote_frac"], dtype=torch.float32)
|
| 134 |
+
return result
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
# Collation + DataLoader
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
|
| 141 |
+
def collate(batch):
|
| 142 |
+
"""Stack samples into batched tensors."""
|
| 143 |
+
out = {
|
| 144 |
+
"xyz_norm": torch.stack([d["xyz_norm"] for d in batch]),
|
| 145 |
+
"class_id": torch.stack([d["class_id"] for d in batch]),
|
| 146 |
+
"source": torch.stack([d["source"] for d in batch]),
|
| 147 |
+
"mask": torch.stack([d["mask"] for d in batch]),
|
| 148 |
+
"gt_segments": [d["gt_segments"] for d in batch],
|
| 149 |
+
"scales": torch.stack([d["scale"] for d in batch]),
|
| 150 |
+
"meta": batch,
|
| 151 |
+
}
|
| 152 |
+
# Optional fields: check ALL samples, not just batch[0].
|
| 153 |
+
# If any sample has it, all must have it (no mixed data versions).
|
| 154 |
+
for field in ("behind", "n_views_voted", "vote_frac"):
|
| 155 |
+
if any(field in d for d in batch):
|
| 156 |
+
missing = [i for i, d in enumerate(batch) if field not in d]
|
| 157 |
+
if missing:
|
| 158 |
+
raise KeyError(
|
| 159 |
+
f"Field '{field}' present in some batch samples but missing in "
|
| 160 |
+
f"{len(missing)}/{len(batch)}. Mixed data versions in cache?")
|
| 161 |
+
out[field] = torch.stack([d[field] for d in batch])
|
| 162 |
+
return out
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def build_loader(cache_dir, batch_size, aug_rotate=False, aug_jitter=0.0,
|
| 166 |
+
aug_drop=0.0, aug_flip=False):
|
| 167 |
+
"""Create a DataLoader from HF dataset.
|
| 168 |
+
|
| 169 |
+
cache_dir should be 'hf://repo/name:split' format.
|
| 170 |
+
"""
|
| 171 |
+
if not cache_dir.startswith("hf://"):
|
| 172 |
+
raise ValueError(
|
| 173 |
+
f"cache_dir must be 'hf://repo:split' format, got: {cache_dir}. "
|
| 174 |
+
f"Local .pt caches are no longer supported in the training path.")
|
| 175 |
+
parts = cache_dir[5:].split(":")
|
| 176 |
+
repo = parts[0]
|
| 177 |
+
split = parts[1] if len(parts) > 1 else "train"
|
| 178 |
+
from datasets import load_dataset
|
| 179 |
+
hf_ds = load_dataset(repo, split=split)
|
| 180 |
+
ds = HFCachedDataset(hf_ds, aug_rotate=aug_rotate, aug_jitter=aug_jitter,
|
| 181 |
+
aug_drop=aug_drop, aug_flip=aug_flip)
|
| 182 |
+
loader = torch.utils.data.DataLoader(
|
| 183 |
+
ds, batch_size=batch_size, shuffle=True,
|
| 184 |
+
num_workers=0, collate_fn=collate,
|
| 185 |
+
)
|
| 186 |
+
print(f"Dataset: {len(ds)} scenes, batch_size={batch_size}")
|
| 187 |
+
return loader
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
# Token building (GPU)
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
|
| 194 |
+
def build_tokens(batch, model, device):
|
| 195 |
+
"""Apply Fourier features + learned embeddings on GPU."""
|
| 196 |
+
xyz = batch["xyz_norm"].to(device)
|
| 197 |
+
cid = batch["class_id"].to(device)
|
| 198 |
+
src = batch["source"].to(device)
|
| 199 |
+
masks = batch["mask"].to(device)
|
| 200 |
+
gt = [g.to(device) for g in batch["gt_segments"]]
|
| 201 |
+
scales = batch["scales"]
|
| 202 |
+
|
| 203 |
+
B, T, _ = xyz.shape
|
| 204 |
+
tok = model.tokenizer
|
| 205 |
+
fourier = tok.pos_enc(xyz.reshape(-1, 3)).reshape(B, T, -1) \
|
| 206 |
+
if tok.pos_enc is not None else xyz.new_zeros(B, T, 0)
|
| 207 |
+
parts = [xyz, fourier, tok.label_emb(cid), tok.src_emb(src.clamp(0, 1))]
|
| 208 |
+
if tok.behind_emb_dim > 0:
|
| 209 |
+
if "behind" in batch:
|
| 210 |
+
beh = batch["behind"].to(device)
|
| 211 |
+
else:
|
| 212 |
+
# Data doesn't have behind -- use zeros (embed index 0).
|
| 213 |
+
# This is intentional for eval on old data; for training,
|
| 214 |
+
# fail fast by requiring the field (checked in _process_sample).
|
| 215 |
+
beh = xyz.new_zeros(B, T, dtype=torch.long)
|
| 216 |
+
parts.append(tok.behind_emb(beh))
|
| 217 |
+
if tok.use_vote_features:
|
| 218 |
+
if "n_views_voted" not in batch or "vote_frac" not in batch:
|
| 219 |
+
raise KeyError(
|
| 220 |
+
"Model expects vote features (--vote-features) but data is missing "
|
| 221 |
+
"'n_views_voted'/'vote_frac'. Use v2 dataset or regenerate cache.")
|
| 222 |
+
# Normalize to ~zero mean, unit variance (dataset stats: nv~2.7+/-1.0, vf~0.5+/-0.25)
|
| 223 |
+
nv = ((batch["n_views_voted"].to(device).float() - 2.7) / 1.0).unsqueeze(-1)
|
| 224 |
+
vf = ((batch["vote_frac"].to(device).float() - 0.5) / 0.25).unsqueeze(-1)
|
| 225 |
+
parts.extend([nv, vf])
|
| 226 |
+
tokens = torch.cat(parts, dim=-1)
|
| 227 |
+
return tokens, masks, gt, scales, batch["meta"]
|
s23dr_2026_example/losses.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Loss computation for wireframe prediction."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
from .varifold import varifold_loss_batch
|
| 7 |
+
from .sinkhorn import batched_sinkhorn_loss
|
| 8 |
+
|
| 9 |
+
# Varifold config
|
| 10 |
+
VARIANT = "simpson3"
|
| 11 |
+
SIGMAS = [0.5, 1.0, 2.0] # meters (divided by per-scene scale at runtime)
|
| 12 |
+
ALPHAS = [0.2, 0.6, 0.2]
|
| 13 |
+
LEN_POW = 1.0
|
| 14 |
+
VARIFOLD_CROSS_ONLY = False # Set to True to drop self-energy (avoids O(S^2) blowup)
|
| 15 |
+
|
| 16 |
+
# Sinkhorn config (note: near-zero gradients at eps=0.05, effectively disabled)
|
| 17 |
+
SINKHORN_EPS = 0.05
|
| 18 |
+
SINKHORN_ITERS = 10
|
| 19 |
+
|
| 20 |
+
# Sinkhorn dustbin cost: controls the OT "not matching" penalty.
|
| 21 |
+
# Like tau, this is an OT behavior parameter, NOT a physical distance.
|
| 22 |
+
# Must be comparable to typical matching costs in normalized space (~0.1).
|
| 23 |
+
# Do NOT divide by scale.
|
| 24 |
+
SINKHORN_DUSTBIN = 0.1
|
| 25 |
+
|
| 26 |
+
MAX_GT = 64 # fixed pad size for compile-friendly shapes
|
| 27 |
+
|
| 28 |
+
# Precomputed constants (created once on first call)
|
| 29 |
+
_loss_constants = {}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _get_loss_constants(device, dtype):
|
| 33 |
+
key = (device, dtype)
|
| 34 |
+
if key not in _loss_constants:
|
| 35 |
+
_loss_constants[key] = {
|
| 36 |
+
"sigmas": torch.tensor(SIGMAS, device=device, dtype=dtype),
|
| 37 |
+
"alphas": torch.tensor(ALPHAS, device=device, dtype=dtype),
|
| 38 |
+
}
|
| 39 |
+
return _loss_constants[key]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def pad_gt_fixed(gt_list, device, dtype):
|
| 43 |
+
"""Pad GT segments to fixed MAX_GT for compile-friendly shapes."""
|
| 44 |
+
B = len(gt_list)
|
| 45 |
+
gt_pad = torch.zeros((B, MAX_GT, 2, 3), device=device, dtype=dtype)
|
| 46 |
+
gt_mask = torch.zeros((B, MAX_GT), device=device, dtype=torch.bool)
|
| 47 |
+
gt_lengths = torch.zeros(B, device=device, dtype=dtype)
|
| 48 |
+
for i, g in enumerate(gt_list):
|
| 49 |
+
n = g.shape[0]
|
| 50 |
+
if n > 0:
|
| 51 |
+
gt_pad[i, :n] = g
|
| 52 |
+
gt_mask[i, :n] = True
|
| 53 |
+
gt_lengths[i] = torch.linalg.norm(g[:, 1] - g[:, 0], dim=-1).sum()
|
| 54 |
+
return gt_pad, gt_mask, gt_lengths
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _loss_inner(pred_segments, gt_pad, gt_mask, gt_lengths, scales,
|
| 58 |
+
sigmas, alphas, varifold_w):
|
| 59 |
+
"""Pure tensor loss -- no Python control flow, no boolean indexing."""
|
| 60 |
+
has_gt = (gt_lengths > 0).float()
|
| 61 |
+
|
| 62 |
+
sigmas_eff = sigmas / scales[:, None]
|
| 63 |
+
loss_batch = varifold_loss_batch(
|
| 64 |
+
pred_segments, gt_pad, gt_mask=gt_mask,
|
| 65 |
+
variant=VARIANT, sigmas=sigmas_eff, alpha=alphas, len_pow=LEN_POW,
|
| 66 |
+
cross_only=VARIFOLD_CROSS_ONLY,
|
| 67 |
+
)
|
| 68 |
+
v = loss_batch / gt_lengths.clamp(min=1.0)
|
| 69 |
+
v = (v * has_gt).sum() / has_gt.sum().clamp(min=1.0)
|
| 70 |
+
|
| 71 |
+
total = varifold_w * v
|
| 72 |
+
return total, v
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# Will be replaced with compiled version on CUDA
|
| 76 |
+
_loss_fn = _loss_inner
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def compute_loss(pred_segments, gt_list, scales, device,
|
| 80 |
+
varifold_w, sinkhorn_w,
|
| 81 |
+
endpoint_w=0.0,
|
| 82 |
+
conf_logits=None, conf_weight=0.0, conf_mode="sinkhorn",
|
| 83 |
+
sinkhorn_eps=None, sinkhorn_iters=None,
|
| 84 |
+
sinkhorn_dustbin=None, conf_clamp_min=None):
|
| 85 |
+
"""Combined loss with fixed-size GT padding.
|
| 86 |
+
|
| 87 |
+
conf_mode: "sinkhorn" = conf-weighted sinkhorn, "sinkhorn_detach" = detached conf.
|
| 88 |
+
"""
|
| 89 |
+
if conf_logits is not None and conf_clamp_min is not None:
|
| 90 |
+
conf_logits = conf_logits.clamp(min=conf_clamp_min)
|
| 91 |
+
gt_pad, gt_mask, gt_lengths = pad_gt_fixed(gt_list, device, pred_segments.dtype)
|
| 92 |
+
c = _get_loss_constants(device, pred_segments.dtype)
|
| 93 |
+
|
| 94 |
+
total, v = _loss_fn(
|
| 95 |
+
pred_segments, gt_pad, gt_mask, gt_lengths, scales,
|
| 96 |
+
c["sigmas"], c["alphas"], varifold_w)
|
| 97 |
+
|
| 98 |
+
terms = {}
|
| 99 |
+
if varifold_w > 0:
|
| 100 |
+
terms["varifold"] = v.detach()
|
| 101 |
+
|
| 102 |
+
if sinkhorn_w > 0:
|
| 103 |
+
has_gt = (gt_lengths > 0).float()
|
| 104 |
+
if conf_logits is not None and conf_mode == "sinkhorn":
|
| 105 |
+
pred_mass = torch.sigmoid(conf_logits)
|
| 106 |
+
elif conf_logits is not None and conf_mode == "sinkhorn_detach":
|
| 107 |
+
pred_mass = torch.sigmoid(conf_logits.detach())
|
| 108 |
+
else:
|
| 109 |
+
pred_mass = None
|
| 110 |
+
eps = sinkhorn_eps if sinkhorn_eps is not None else SINKHORN_EPS
|
| 111 |
+
iters = sinkhorn_iters if sinkhorn_iters is not None else SINKHORN_ITERS
|
| 112 |
+
dustbin = sinkhorn_dustbin if sinkhorn_dustbin is not None else SINKHORN_DUSTBIN
|
| 113 |
+
S = pred_segments.shape[1]
|
| 114 |
+
sink_per = batched_sinkhorn_loss(
|
| 115 |
+
pred_segments, gt_pad, gt_mask,
|
| 116 |
+
eps, iters, dustbin,
|
| 117 |
+
pred_mass=pred_mass,
|
| 118 |
+
) / (gt_lengths.clamp(min=1.0) * S)
|
| 119 |
+
s = (sink_per * has_gt).sum() / has_gt.sum().clamp(min=1.0)
|
| 120 |
+
total = total + sinkhorn_w * s
|
| 121 |
+
terms["sinkhorn"] = s.detach()
|
| 122 |
+
|
| 123 |
+
if conf_logits is not None and conf_weight > 0:
|
| 124 |
+
if conf_mode in ("sinkhorn", "sinkhorn_detach"):
|
| 125 |
+
conf_w = torch.sigmoid(conf_logits)
|
| 126 |
+
S = conf_logits.shape[1]
|
| 127 |
+
gt_counts = gt_mask.sum(dim=1).float()
|
| 128 |
+
conf_sum = conf_w.sum(dim=1)
|
| 129 |
+
reg = (((conf_sum - gt_counts) / S) ** 2).mean()
|
| 130 |
+
total = total + conf_weight * reg
|
| 131 |
+
terms["conf_reg"] = reg.detach()
|
| 132 |
+
else:
|
| 133 |
+
raise ValueError(f"Unknown conf_mode: {conf_mode}")
|
| 134 |
+
|
| 135 |
+
if endpoint_w > 0:
|
| 136 |
+
has_gt = (gt_lengths > 0).float()
|
| 137 |
+
eps_ep = sinkhorn_eps if sinkhorn_eps is not None else SINKHORN_EPS
|
| 138 |
+
iters_ep = sinkhorn_iters if sinkhorn_iters is not None else SINKHORN_ITERS
|
| 139 |
+
dustbin_ep = sinkhorn_dustbin if sinkhorn_dustbin is not None else SINKHORN_DUSTBIN
|
| 140 |
+
B, S = pred_segments.shape[:2]
|
| 141 |
+
M = gt_pad.shape[1]
|
| 142 |
+
|
| 143 |
+
# Compute hard assignment via sinkhorn (detached -- matching is not trained)
|
| 144 |
+
with torch.no_grad():
|
| 145 |
+
pred_mass_ep = torch.sigmoid(conf_logits) if conf_logits is not None else None
|
| 146 |
+
sink_loss_for_assign = batched_sinkhorn_loss(
|
| 147 |
+
pred_segments, gt_pad, gt_mask, eps_ep, iters_ep, dustbin_ep,
|
| 148 |
+
pred_mass=pred_mass_ep)
|
| 149 |
+
p0, p1 = pred_segments[:, :, 0], pred_segments[:, :, 1]
|
| 150 |
+
g0, g1 = gt_pad[:, :, 0], gt_pad[:, :, 1]
|
| 151 |
+
mid_p, half_p = 0.5 * (p0 + p1), 0.5 * (p1 - p0)
|
| 152 |
+
mid_g, half_g = 0.5 * (g0 + g1), 0.5 * (g1 - g0)
|
| 153 |
+
d_mid = torch.linalg.norm(mid_p.unsqueeze(2) - mid_g.unsqueeze(1), dim=-1)
|
| 154 |
+
len_p = torch.linalg.norm(half_p, dim=-1, keepdim=True).clamp(min=1e-6)
|
| 155 |
+
len_g = torch.linalg.norm(half_g, dim=-1, keepdim=True).clamp(min=1e-6)
|
| 156 |
+
dir_p, dir_g = half_p / len_p, half_g / len_g
|
| 157 |
+
cos_a = (dir_p.unsqueeze(2) * dir_g.unsqueeze(1)).sum(dim=-1)
|
| 158 |
+
d_dir = 1.0 - cos_a.abs()
|
| 159 |
+
d_len = (len_p.unsqueeze(2) - len_g.unsqueeze(1)).squeeze(-1).abs()
|
| 160 |
+
cost = d_mid + d_dir + d_len
|
| 161 |
+
dc = torch.as_tensor(dustbin_ep, device=cost.device, dtype=cost.dtype)
|
| 162 |
+
cost = torch.where(gt_mask.unsqueeze(1), cost, dc * 10.0)
|
| 163 |
+
cost_pad = dc.expand(B, S + 1, M + 1).clone()
|
| 164 |
+
cost_pad[:, :S, :M] = cost
|
| 165 |
+
cost_pad[:, -1, -1] = 0.0
|
| 166 |
+
gt_counts = gt_mask.sum(dim=1).float()
|
| 167 |
+
if pred_mass_ep is not None:
|
| 168 |
+
pm = pred_mass_ep.clamp(min=0.0)
|
| 169 |
+
a = torch.cat([pm, (gt_counts - pm.sum(1)).clamp(min=0).unsqueeze(1)], dim=1)
|
| 170 |
+
b_val = torch.zeros(B, M + 1, device=cost.device, dtype=cost.dtype)
|
| 171 |
+
b_val[:, :M] = gt_mask.float()
|
| 172 |
+
b_val[:, -1] = (pm.sum(1) - gt_counts).clamp(min=0)
|
| 173 |
+
else:
|
| 174 |
+
n = float(S)
|
| 175 |
+
denom = n + gt_counts
|
| 176 |
+
a = (1.0 / denom).unsqueeze(1).expand(B, S + 1).clone()
|
| 177 |
+
a[:, -1] = gt_counts / denom
|
| 178 |
+
b_val = (1.0 / denom).unsqueeze(1).expand(B, M + 1).clone()
|
| 179 |
+
b_val[:, -1] = n / denom
|
| 180 |
+
b_val[:, :M] = b_val[:, :M] * gt_mask.float()
|
| 181 |
+
log_a = torch.log(a + 1e-9)
|
| 182 |
+
log_b = torch.log(b_val + 1e-9)
|
| 183 |
+
log_k = -cost_pad / eps_ep
|
| 184 |
+
log_u = torch.zeros_like(a)
|
| 185 |
+
log_v = torch.zeros_like(b_val)
|
| 186 |
+
for _ in range(iters_ep):
|
| 187 |
+
log_u = log_a - torch.logsumexp(log_k + log_v.unsqueeze(1), dim=2)
|
| 188 |
+
log_v = log_b - torch.logsumexp(log_k + log_u.unsqueeze(2), dim=1)
|
| 189 |
+
transport = torch.exp(log_u.unsqueeze(2) + log_v.unsqueeze(1) + log_k)
|
| 190 |
+
assignment = transport[:, :S, :M+1].argmax(dim=2)
|
| 191 |
+
assignment[assignment >= M] = -1
|
| 192 |
+
|
| 193 |
+
# Everything below is WITH gradients (assignment is detached but pred_segments is live)
|
| 194 |
+
matched = (assignment >= 0) # [B, S]
|
| 195 |
+
n_matched = matched.float().sum().clamp(min=1.0)
|
| 196 |
+
assign_safe = assignment.clamp(min=0)
|
| 197 |
+
gt_matched = gt_pad[
|
| 198 |
+
torch.arange(B, device=device)[:, None].expand(B, S),
|
| 199 |
+
assign_safe] # [B, S, 2, 3]
|
| 200 |
+
|
| 201 |
+
# Symmetric endpoint distance
|
| 202 |
+
ref_ep1 = pred_segments[:, :, 0]
|
| 203 |
+
ref_ep2 = pred_segments[:, :, 1]
|
| 204 |
+
gt_ep1 = gt_matched[:, :, 0]
|
| 205 |
+
gt_ep2 = gt_matched[:, :, 1]
|
| 206 |
+
dist_fwd = (ref_ep1 - gt_ep1).norm(dim=-1) + (ref_ep2 - gt_ep2).norm(dim=-1)
|
| 207 |
+
dist_rev = (ref_ep1 - gt_ep2).norm(dim=-1) + (ref_ep2 - gt_ep1).norm(dim=-1)
|
| 208 |
+
ep_dist = torch.min(dist_fwd, dist_rev)
|
| 209 |
+
|
| 210 |
+
# Normalize by GT total length * S (same scale as sinkhorn)
|
| 211 |
+
ep_loss = (ep_dist * matched.float()).sum() / n_matched
|
| 212 |
+
total = total + endpoint_w * ep_loss
|
| 213 |
+
terms["endpoint"] = ep_loss.detach()
|
| 214 |
+
|
| 215 |
+
return total, terms
|
s23dr_2026_example/make_sampled_cache.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Stage 2: priority-sample cached .pt scenes into fixed-size .npz files.
|
| 3 |
+
|
| 4 |
+
Reads the per-scene .pt files produced by cache_scenes.py, priority-samples
|
| 5 |
+
a fixed number of points (2048 or 4096), normalizes, and writes one .npz per
|
| 6 |
+
scene (~50KB at 2048, ~100KB at 4096).
|
| 7 |
+
|
| 8 |
+
A fixed seed is used so every scene gets one deterministic sample -- no
|
| 9 |
+
per-epoch sampling augmentation, every epoch sees the same points.
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
python -m s23dr_2026_example.make_sampled_cache \\
|
| 13 |
+
--in-dir cache/full --out-dir cache/sampled_2048 --seq-len 2048
|
| 14 |
+
python -m s23dr_2026_example.make_sampled_cache \\
|
| 15 |
+
--in-dir cache/full --out-dir cache/sampled_4096 --seq-len 4096
|
| 16 |
+
|
| 17 |
+
The 3:1 colmap:depth quota ratio is fixed: at seq_len=2048 that's
|
| 18 |
+
colmap=1536/depth=512; at seq_len=4096 that's colmap=3072/depth=1024.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import argparse
|
| 23 |
+
import time
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
import torch
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# Priority sampling (same logic as train.py)
|
| 31 |
+
def _priority_sample(source, group_id, seq_len, colmap_quota, depth_quota):
|
| 32 |
+
def pick(src_id, quota):
|
| 33 |
+
base = source == src_id
|
| 34 |
+
picked, remaining = [], quota
|
| 35 |
+
for tier in range(5):
|
| 36 |
+
if remaining <= 0:
|
| 37 |
+
break
|
| 38 |
+
pool = np.where(base & (group_id == tier))[0]
|
| 39 |
+
if len(pool) == 0:
|
| 40 |
+
continue
|
| 41 |
+
np.random.shuffle(pool)
|
| 42 |
+
take = min(remaining, len(pool))
|
| 43 |
+
picked.append(pool[:take])
|
| 44 |
+
remaining -= take
|
| 45 |
+
if remaining > 0:
|
| 46 |
+
pool = np.where(base & (group_id >= 0))[0]
|
| 47 |
+
if len(pool) > 0:
|
| 48 |
+
np.random.shuffle(pool)
|
| 49 |
+
picked.append(pool[:min(remaining, len(pool))])
|
| 50 |
+
remaining -= min(remaining, len(pool))
|
| 51 |
+
return np.concatenate(picked) if picked else np.array([], dtype=np.int64), remaining
|
| 52 |
+
|
| 53 |
+
idx_c, rem_c = pick(0, colmap_quota)
|
| 54 |
+
idx_d, rem_d = pick(1, depth_quota)
|
| 55 |
+
|
| 56 |
+
if rem_c > 0:
|
| 57 |
+
extra = np.setdiff1d(np.where((source == 1) & (group_id >= 0))[0], idx_d)
|
| 58 |
+
np.random.shuffle(extra)
|
| 59 |
+
idx_d = np.concatenate([idx_d, extra[:rem_c]])
|
| 60 |
+
if rem_d > 0:
|
| 61 |
+
extra = np.setdiff1d(np.where((source == 0) & (group_id >= 0))[0], idx_c)
|
| 62 |
+
np.random.shuffle(extra)
|
| 63 |
+
idx_c = np.concatenate([idx_c, extra[:rem_d]])
|
| 64 |
+
|
| 65 |
+
indices = np.concatenate([idx_c, idx_d])
|
| 66 |
+
num_valid = len(indices)
|
| 67 |
+
if num_valid < seq_len:
|
| 68 |
+
if num_valid == 0:
|
| 69 |
+
return np.zeros(seq_len, dtype=np.int64), np.zeros(seq_len, dtype=bool)
|
| 70 |
+
indices = np.concatenate([indices, np.full(seq_len - num_valid, indices[-1])])
|
| 71 |
+
mask = np.zeros(seq_len, dtype=bool)
|
| 72 |
+
mask[:num_valid] = True
|
| 73 |
+
return indices[:seq_len], mask
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _process_sample(d, seq_len, colmap_q, depth_q):
|
| 77 |
+
"""Sample and normalize one cached scene dict into a small npz-ready dict."""
|
| 78 |
+
xyz = np.asarray(d["xyz"], np.float32)
|
| 79 |
+
source = np.asarray(d["source"], np.uint8)
|
| 80 |
+
group_id = np.asarray(d["group_id"], np.int8)
|
| 81 |
+
class_id = np.asarray(d["class_id"], np.uint8)
|
| 82 |
+
vis_src = np.asarray(d["visible_src"], np.uint8)
|
| 83 |
+
vis_id = np.asarray(d["visible_id"], np.int16)
|
| 84 |
+
center = np.asarray(d["center"], np.float32)
|
| 85 |
+
scale = float(d["scale"])
|
| 86 |
+
gt_v = np.asarray(d["gt_vertices"], np.float32)
|
| 87 |
+
gt_e = np.asarray(d["gt_edges"], np.int32)
|
| 88 |
+
|
| 89 |
+
indices, mask = _priority_sample(source, group_id, seq_len, colmap_q, depth_q)
|
| 90 |
+
xyz_norm = ((xyz[indices] - center) / scale).astype(np.float32)
|
| 91 |
+
gt_seg = np.stack([gt_v[gt_e[:, 0]], gt_v[gt_e[:, 1]]], axis=1)
|
| 92 |
+
gt_seg_norm = ((gt_seg - center) / scale).astype(np.float32)
|
| 93 |
+
|
| 94 |
+
result = {
|
| 95 |
+
"xyz_norm": xyz_norm,
|
| 96 |
+
"class_id": class_id[indices].astype(np.uint8),
|
| 97 |
+
"source": source[indices].astype(np.uint8),
|
| 98 |
+
"mask": mask,
|
| 99 |
+
"gt_segments": gt_seg_norm,
|
| 100 |
+
"scale": np.float32(scale),
|
| 101 |
+
"center": center,
|
| 102 |
+
"gt_vertices": gt_v,
|
| 103 |
+
"gt_edges": gt_e,
|
| 104 |
+
"visible_src": vis_src[indices].astype(np.uint8),
|
| 105 |
+
"visible_id": vis_id[indices].astype(np.int16),
|
| 106 |
+
}
|
| 107 |
+
if "behind_gest_id" in d:
|
| 108 |
+
result["behind"] = np.asarray(d["behind_gest_id"], np.int16)[indices]
|
| 109 |
+
if "n_views_voted" in d:
|
| 110 |
+
result["n_views_voted"] = np.asarray(d["n_views_voted"], np.uint8)[indices]
|
| 111 |
+
if "vote_frac" in d:
|
| 112 |
+
result["vote_frac"] = np.asarray(d["vote_frac"], np.float32)[indices]
|
| 113 |
+
if "gt_edge_classes" in d:
|
| 114 |
+
result["gt_edge_classes"] = np.asarray(d["gt_edge_classes"], np.int64)
|
| 115 |
+
return result
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def main():
|
| 119 |
+
p = argparse.ArgumentParser(description="Stage 2: cached .pt -> sampled .npz")
|
| 120 |
+
p.add_argument("--in-dir", required=True, help="Directory of .pt files from cache_scenes.py")
|
| 121 |
+
p.add_argument("--out-dir", required=True, help="Output directory for .npz files")
|
| 122 |
+
p.add_argument("--seq-len", type=int, default=2048, help="Points per sample (2048 or 4096)")
|
| 123 |
+
p.add_argument("--seed", type=int, default=7)
|
| 124 |
+
args = p.parse_args()
|
| 125 |
+
|
| 126 |
+
colmap_q = args.seq_len * 3 // 4
|
| 127 |
+
depth_q = args.seq_len - colmap_q
|
| 128 |
+
print(f"seq_len={args.seq_len} colmap={colmap_q} depth={depth_q}")
|
| 129 |
+
|
| 130 |
+
out_dir = Path(args.out_dir)
|
| 131 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 132 |
+
np.random.seed(args.seed)
|
| 133 |
+
|
| 134 |
+
files = sorted(Path(args.in_dir).glob("*.pt"))
|
| 135 |
+
print(f"Found {len(files)} .pt files in {args.in_dir}")
|
| 136 |
+
|
| 137 |
+
done = 0
|
| 138 |
+
t0 = time.perf_counter()
|
| 139 |
+
for f in files:
|
| 140 |
+
out_f = out_dir / (f.stem + ".npz")
|
| 141 |
+
if out_f.exists():
|
| 142 |
+
done += 1
|
| 143 |
+
continue
|
| 144 |
+
d = torch.load(f, weights_only=False)
|
| 145 |
+
result = _process_sample(d, args.seq_len, colmap_q, depth_q)
|
| 146 |
+
np.savez(out_f, **result)
|
| 147 |
+
done += 1
|
| 148 |
+
if done % 2000 == 0:
|
| 149 |
+
rate = done / (time.perf_counter() - t0)
|
| 150 |
+
print(f" {done}/{len(files)} [{rate:.0f}/s]")
|
| 151 |
+
|
| 152 |
+
elapsed = time.perf_counter() - t0
|
| 153 |
+
print(f"Done. {done} files in {elapsed:.0f}s -> {out_dir}")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
if __name__ == "__main__":
|
| 157 |
+
main()
|
| 158 |
+
|
| 159 |
+
|
s23dr_2026_example/model.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Perceiver-based transformer for 3D roof wireframe prediction.
|
| 3 |
+
|
| 4 |
+
Architecture overview:
|
| 5 |
+
|
| 6 |
+
Input tokens [B, T, D]
|
| 7 |
+
|
|
| 8 |
+
v
|
| 9 |
+
input_proj: Linear -> GELU -> Linear -> LayerNorm => [B, T, hidden]
|
| 10 |
+
|
|
| 11 |
+
v
|
| 12 |
+
Perceiver latent bottleneck (N PerceiverLatentLayers):
|
| 13 |
+
Learnable latent embeddings [L, hidden] are broadcast to batch.
|
| 14 |
+
Each layer: cross-attn(latents <- tokens) -> self-attn(latents) -> FFN
|
| 15 |
+
Output: latents [B, L, hidden]
|
| 16 |
+
|
|
| 17 |
+
v
|
| 18 |
+
Segment decoder (M SegmentDecoderLayers):
|
| 19 |
+
Learnable query embeddings [S, hidden] are broadcast to batch.
|
| 20 |
+
Each layer: cross-attn(queries <- latents) -> self-attn(queries) -> FFN
|
| 21 |
+
Output: queries [B, S, hidden]
|
| 22 |
+
|
|
| 23 |
+
v
|
| 24 |
+
segment_head: Linear -> 6D -> (midpoint, half_vector)
|
| 25 |
+
+ query_offsets (learnable per-query bias)
|
| 26 |
+
endpoints = midpoint +/- half_vector -> [B, S, 2, 3]
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import torch
|
| 30 |
+
import torch.nn as nn
|
| 31 |
+
|
| 32 |
+
from .attention import MultiHeadSDPA, FeedForward
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Building blocks
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
class AttnResidual(nn.Module):
|
| 40 |
+
"""Pre-norm attention + residual + dropout."""
|
| 41 |
+
|
| 42 |
+
def __init__(
|
| 43 |
+
self,
|
| 44 |
+
d_model: int,
|
| 45 |
+
num_heads: int,
|
| 46 |
+
dropout: float = 0.0,
|
| 47 |
+
kv_heads: int | None = None,
|
| 48 |
+
norm_class=None,
|
| 49 |
+
qk_norm: bool = False,
|
| 50 |
+
qk_norm_type: str = "l2",
|
| 51 |
+
):
|
| 52 |
+
super().__init__()
|
| 53 |
+
norm_class = norm_class or nn.LayerNorm
|
| 54 |
+
self.norm = norm_class(d_model)
|
| 55 |
+
self.attn = MultiHeadSDPA(d_model, num_heads, kv_heads=kv_heads, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 56 |
+
self.drop = nn.Dropout(dropout)
|
| 57 |
+
|
| 58 |
+
def forward(
|
| 59 |
+
self,
|
| 60 |
+
x: torch.Tensor,
|
| 61 |
+
memory: torch.Tensor,
|
| 62 |
+
memory_key_padding_mask: torch.Tensor | None = None,
|
| 63 |
+
) -> torch.Tensor:
|
| 64 |
+
res = x
|
| 65 |
+
x = self.norm(x)
|
| 66 |
+
x = self.attn(x, memory, key_padding_mask=memory_key_padding_mask)
|
| 67 |
+
return res + self.drop(x)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class FFNResidual(nn.Module):
|
| 71 |
+
"""Pre-norm feed-forward + residual + dropout."""
|
| 72 |
+
|
| 73 |
+
def __init__(
|
| 74 |
+
self,
|
| 75 |
+
d_model: int,
|
| 76 |
+
dim_ff: int,
|
| 77 |
+
dropout: float = 0.0,
|
| 78 |
+
activation: str = "gelu",
|
| 79 |
+
norm_class=None,
|
| 80 |
+
):
|
| 81 |
+
super().__init__()
|
| 82 |
+
norm_class = norm_class or nn.LayerNorm
|
| 83 |
+
self.norm = norm_class(d_model)
|
| 84 |
+
self.ffn = FeedForward(d_model, dim_ff, activation=activation)
|
| 85 |
+
self.drop = nn.Dropout(dropout)
|
| 86 |
+
|
| 87 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 88 |
+
res = x
|
| 89 |
+
x = self.norm(x)
|
| 90 |
+
x = self.ffn(x)
|
| 91 |
+
return res + self.drop(x)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
# Perceiver encoder layer
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
class PerceiverLatentLayer(nn.Module):
|
| 99 |
+
"""Single Perceiver latent layer.
|
| 100 |
+
|
| 101 |
+
If use_cross=True: cross-attn(latents <- points) -> self-attn -> FFN
|
| 102 |
+
If use_cross=False: self-attn -> FFN (saves compute in deep stacks)
|
| 103 |
+
"""
|
| 104 |
+
|
| 105 |
+
def __init__(
|
| 106 |
+
self,
|
| 107 |
+
d_model: int,
|
| 108 |
+
num_heads: int,
|
| 109 |
+
dim_ff: int,
|
| 110 |
+
dropout: float = 0.0,
|
| 111 |
+
activation: str = "gelu",
|
| 112 |
+
kv_heads_cross: int | None = None,
|
| 113 |
+
kv_heads_self: int | None = None,
|
| 114 |
+
use_cross: bool = True,
|
| 115 |
+
norm_class=None,
|
| 116 |
+
qk_norm: bool = False,
|
| 117 |
+
qk_norm_type: str = "l2",
|
| 118 |
+
):
|
| 119 |
+
super().__init__()
|
| 120 |
+
self.use_cross = use_cross
|
| 121 |
+
if use_cross:
|
| 122 |
+
self.cross = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_cross, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 123 |
+
self.self_attn = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_self, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 124 |
+
self.ffn = FFNResidual(d_model, dim_ff, dropout, activation=activation, norm_class=norm_class)
|
| 125 |
+
|
| 126 |
+
def forward(
|
| 127 |
+
self,
|
| 128 |
+
latents: torch.Tensor,
|
| 129 |
+
points: torch.Tensor,
|
| 130 |
+
points_key_padding_mask: torch.Tensor | None = None,
|
| 131 |
+
) -> torch.Tensor:
|
| 132 |
+
if self.use_cross:
|
| 133 |
+
latents = self.cross(latents, points, memory_key_padding_mask=points_key_padding_mask)
|
| 134 |
+
latents = self.self_attn(latents, latents)
|
| 135 |
+
latents = self.ffn(latents)
|
| 136 |
+
return latents
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
# Segment decoder layer
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
|
| 143 |
+
class SegmentDecoderLayer(nn.Module):
|
| 144 |
+
"""Single segment decoder layer.
|
| 145 |
+
|
| 146 |
+
cross-attn(queries <- latents) -> [cross-attn(queries <- inputs)] -> self-attn(queries) -> FFN
|
| 147 |
+
|
| 148 |
+
If input_xattn=True, adds a second cross-attention that attends directly
|
| 149 |
+
to the projected input tokens (bypassing the latent bottleneck). This gives
|
| 150 |
+
queries access to fine-grained point-level detail for vertex precision.
|
| 151 |
+
"""
|
| 152 |
+
|
| 153 |
+
def __init__(
|
| 154 |
+
self,
|
| 155 |
+
d_model: int,
|
| 156 |
+
num_heads: int,
|
| 157 |
+
dim_ff: int,
|
| 158 |
+
dropout: float = 0.0,
|
| 159 |
+
activation: str = "gelu",
|
| 160 |
+
kv_heads_cross: int | None = None,
|
| 161 |
+
kv_heads_self: int | None = None,
|
| 162 |
+
norm_class=None,
|
| 163 |
+
input_xattn: bool = False,
|
| 164 |
+
qk_norm: bool = False,
|
| 165 |
+
qk_norm_type: str = "l2",
|
| 166 |
+
):
|
| 167 |
+
super().__init__()
|
| 168 |
+
self.cross = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_cross, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 169 |
+
self.input_xattn = input_xattn
|
| 170 |
+
if input_xattn:
|
| 171 |
+
self.cross_input = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_cross, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 172 |
+
self.self_attn = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_self, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 173 |
+
self.ffn = FFNResidual(d_model, dim_ff, dropout, activation=activation, norm_class=norm_class)
|
| 174 |
+
|
| 175 |
+
def forward(
|
| 176 |
+
self,
|
| 177 |
+
queries: torch.Tensor,
|
| 178 |
+
latents: torch.Tensor,
|
| 179 |
+
src: torch.Tensor | None = None,
|
| 180 |
+
src_key_padding_mask: torch.Tensor | None = None,
|
| 181 |
+
) -> torch.Tensor:
|
| 182 |
+
queries = self.cross(queries, latents)
|
| 183 |
+
if self.input_xattn and src is not None:
|
| 184 |
+
queries = self.cross_input(queries, src, memory_key_padding_mask=src_key_padding_mask)
|
| 185 |
+
queries = self.self_attn(queries, queries)
|
| 186 |
+
queries = self.ffn(queries)
|
| 187 |
+
return queries
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
# Full model
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
|
| 194 |
+
class TokenTransformerSegments(nn.Module):
|
| 195 |
+
"""Perceiver transformer that predicts 3D roof wireframe segments.
|
| 196 |
+
|
| 197 |
+
Takes point-cloud tokens and outputs segment endpoints as [B, S, 2, 3]
|
| 198 |
+
where S is the number of segments and each segment has two 3D endpoints.
|
| 199 |
+
|
| 200 |
+
Args:
|
| 201 |
+
segments: Number of predicted segments (S).
|
| 202 |
+
in_dim: Dimensionality of input tokens.
|
| 203 |
+
hidden: Internal hidden dimension throughout the model.
|
| 204 |
+
num_heads: Number of attention heads.
|
| 205 |
+
kv_heads_cross: Grouped-query heads for cross-attention (None = standard MHA).
|
| 206 |
+
kv_heads_self: Grouped-query heads for self-attention (None = standard MHA).
|
| 207 |
+
dim_feedforward: FFN intermediate dimension.
|
| 208 |
+
dropout: Dropout rate applied after attention and FFN.
|
| 209 |
+
latent_tokens: Number of learnable latent embeddings (L) in the bottleneck.
|
| 210 |
+
latent_layers: Number of PerceiverLatentLayers (N).
|
| 211 |
+
decoder_layers: Number of SegmentDecoderLayers (M).
|
| 212 |
+
"""
|
| 213 |
+
|
| 214 |
+
def __init__(
|
| 215 |
+
self,
|
| 216 |
+
segments: int = 32,
|
| 217 |
+
in_dim: int = 128,
|
| 218 |
+
hidden: int = 128,
|
| 219 |
+
num_heads: int = 4,
|
| 220 |
+
kv_heads_cross: int | None = 2,
|
| 221 |
+
kv_heads_self: int | None = 0,
|
| 222 |
+
dim_feedforward: int = 256,
|
| 223 |
+
dropout: float = 0.01,
|
| 224 |
+
latent_tokens: int = 64,
|
| 225 |
+
latent_layers: int = 2,
|
| 226 |
+
decoder_layers: int = 2,
|
| 227 |
+
cross_attn_interval: int = 1,
|
| 228 |
+
norm_class=None,
|
| 229 |
+
activation: str = "gelu",
|
| 230 |
+
segment_conf: bool = False,
|
| 231 |
+
pre_encoder_layers: int = 0,
|
| 232 |
+
segment_param: str = "midpoint_halfvec",
|
| 233 |
+
length_floor: float = 0.0,
|
| 234 |
+
decoder_input_xattn: bool = False,
|
| 235 |
+
qk_norm: bool = False,
|
| 236 |
+
qk_norm_type: str = "l2",
|
| 237 |
+
):
|
| 238 |
+
super().__init__()
|
| 239 |
+
self.segments = segments
|
| 240 |
+
self.out_vertices = segments * 2
|
| 241 |
+
self.segment_param = segment_param
|
| 242 |
+
self.decoder_input_xattn = decoder_input_xattn
|
| 243 |
+
norm_class = norm_class or nn.LayerNorm
|
| 244 |
+
|
| 245 |
+
# Treat 0 as "use standard MHA"
|
| 246 |
+
if kv_heads_cross is not None and kv_heads_cross <= 0:
|
| 247 |
+
kv_heads_cross = None
|
| 248 |
+
if kv_heads_self is not None and kv_heads_self <= 0:
|
| 249 |
+
kv_heads_self = None
|
| 250 |
+
|
| 251 |
+
# -- Input projection --
|
| 252 |
+
self.input_proj = nn.Sequential(
|
| 253 |
+
nn.Linear(in_dim, dim_feedforward),
|
| 254 |
+
nn.GELU(),
|
| 255 |
+
nn.Linear(dim_feedforward, hidden),
|
| 256 |
+
norm_class(hidden),
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
# -- Optional pre-encoder: self-attention on full token sequence --
|
| 260 |
+
if pre_encoder_layers > 0:
|
| 261 |
+
self.pre_encoder = nn.ModuleList([
|
| 262 |
+
SelfAttentionEncoderLayer(
|
| 263 |
+
d_model=hidden,
|
| 264 |
+
num_heads=num_heads,
|
| 265 |
+
dim_ff=dim_feedforward,
|
| 266 |
+
dropout=dropout,
|
| 267 |
+
activation=activation,
|
| 268 |
+
kv_heads=kv_heads_self,
|
| 269 |
+
norm_class=norm_class,
|
| 270 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 271 |
+
)
|
| 272 |
+
for _ in range(pre_encoder_layers)
|
| 273 |
+
])
|
| 274 |
+
else:
|
| 275 |
+
self.pre_encoder = None
|
| 276 |
+
|
| 277 |
+
# -- Perceiver latent bottleneck --
|
| 278 |
+
self.latent_embed = nn.Embedding(latent_tokens, hidden)
|
| 279 |
+
N = latent_layers
|
| 280 |
+
self.latent_layers = nn.ModuleList([
|
| 281 |
+
PerceiverLatentLayer(
|
| 282 |
+
d_model=hidden,
|
| 283 |
+
num_heads=num_heads,
|
| 284 |
+
dim_ff=dim_feedforward,
|
| 285 |
+
dropout=dropout,
|
| 286 |
+
activation=activation,
|
| 287 |
+
kv_heads_cross=kv_heads_cross,
|
| 288 |
+
kv_heads_self=kv_heads_self,
|
| 289 |
+
use_cross=(i == 0) or (i == N - 1) or (i % cross_attn_interval == 0),
|
| 290 |
+
norm_class=norm_class,
|
| 291 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 292 |
+
)
|
| 293 |
+
for i in range(N)
|
| 294 |
+
])
|
| 295 |
+
|
| 296 |
+
# -- Segment decoder --
|
| 297 |
+
self.query_embed = nn.Embedding(segments, hidden)
|
| 298 |
+
self.decoder_layers = nn.ModuleList([
|
| 299 |
+
SegmentDecoderLayer(
|
| 300 |
+
d_model=hidden,
|
| 301 |
+
num_heads=num_heads,
|
| 302 |
+
dim_ff=dim_feedforward,
|
| 303 |
+
dropout=dropout,
|
| 304 |
+
activation=activation,
|
| 305 |
+
kv_heads_cross=kv_heads_cross,
|
| 306 |
+
kv_heads_self=kv_heads_self,
|
| 307 |
+
norm_class=norm_class,
|
| 308 |
+
input_xattn=decoder_input_xattn,
|
| 309 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 310 |
+
)
|
| 311 |
+
for _ in range(decoder_layers)
|
| 312 |
+
])
|
| 313 |
+
|
| 314 |
+
# -- Output head --
|
| 315 |
+
if segment_param == "midpoint_dir_len":
|
| 316 |
+
self.segment_head = nn.Linear(hidden, 7) # mid(3) + dir(3) + len(1)
|
| 317 |
+
else:
|
| 318 |
+
self.segment_head = nn.Linear(hidden, 6) # mid(3) + half(3)
|
| 319 |
+
self.query_offsets = nn.Parameter(torch.zeros(segments, 2, 3))
|
| 320 |
+
|
| 321 |
+
nn.init.trunc_normal_(self.segment_head.weight, mean=0.0, std=1e-3)
|
| 322 |
+
if self.segment_head.bias is not None:
|
| 323 |
+
nn.init.zeros_(self.segment_head.bias)
|
| 324 |
+
if segment_param == "midpoint_dir_len":
|
| 325 |
+
# softplus(0.5) * 0.1 ≈ 0.097 default length in normalized space
|
| 326 |
+
self.segment_head.bias.data[6] = 0.5
|
| 327 |
+
nn.init.normal_(self.query_offsets, mean=0.0, std=0.05)
|
| 328 |
+
|
| 329 |
+
# -- Optional confidence head --
|
| 330 |
+
self.segment_conf = segment_conf
|
| 331 |
+
if segment_conf:
|
| 332 |
+
self.conf_head = nn.Linear(hidden, 1)
|
| 333 |
+
nn.init.zeros_(self.conf_head.bias)
|
| 334 |
+
|
| 335 |
+
def forward(
|
| 336 |
+
self,
|
| 337 |
+
tokens: torch.Tensor,
|
| 338 |
+
mask: torch.Tensor | None = None,
|
| 339 |
+
) -> dict[str, torch.Tensor | list]:
|
| 340 |
+
"""
|
| 341 |
+
Args:
|
| 342 |
+
tokens: Input point-cloud tokens [B, T, in_dim].
|
| 343 |
+
mask: Boolean validity mask [B, T]. True = valid token.
|
| 344 |
+
|
| 345 |
+
Returns:
|
| 346 |
+
Dict with keys:
|
| 347 |
+
"vertices": [B, S*2, 3] flattened endpoints.
|
| 348 |
+
"segments": [B, S, 2, 3] segment endpoints.
|
| 349 |
+
"edges": Per-batch list of (start, end) index pairs into vertices.
|
| 350 |
+
"conf": [B, S] logits (only if segment_conf=True).
|
| 351 |
+
"""
|
| 352 |
+
B = tokens.shape[0]
|
| 353 |
+
|
| 354 |
+
# Project input tokens
|
| 355 |
+
src = self.input_proj(tokens) # [B, T, hidden]
|
| 356 |
+
|
| 357 |
+
# Padding mask (True where padded) for cross-attention
|
| 358 |
+
pad_mask = ~mask.bool() if mask is not None else None
|
| 359 |
+
|
| 360 |
+
# Optional pre-encoder: self-attention on full token sequence
|
| 361 |
+
if self.pre_encoder is not None:
|
| 362 |
+
for layer in self.pre_encoder:
|
| 363 |
+
src = layer(src, key_padding_mask=pad_mask)
|
| 364 |
+
|
| 365 |
+
# Perceiver latent bottleneck
|
| 366 |
+
latents = self.latent_embed.weight.unsqueeze(0).expand(B, -1, -1)
|
| 367 |
+
for layer in self.latent_layers:
|
| 368 |
+
latents = layer(latents, src, points_key_padding_mask=pad_mask)
|
| 369 |
+
|
| 370 |
+
# Segment decoder
|
| 371 |
+
queries = self.query_embed.weight.unsqueeze(0).expand(B, -1, -1)
|
| 372 |
+
for layer in self.decoder_layers:
|
| 373 |
+
queries = layer(queries, latents,
|
| 374 |
+
src=src if self.decoder_input_xattn else None,
|
| 375 |
+
src_key_padding_mask=pad_mask if self.decoder_input_xattn else None)
|
| 376 |
+
|
| 377 |
+
# Predict segments -> endpoints
|
| 378 |
+
if self.segment_param == "midpoint_dir_len":
|
| 379 |
+
raw = self.segment_head(queries) # [B, S, 7]
|
| 380 |
+
mid = raw[:, :, :3] + self.query_offsets[:, 0, :].unsqueeze(0)
|
| 381 |
+
direction = torch.nn.functional.normalize(raw[:, :, 3:6], dim=-1)
|
| 382 |
+
length = torch.nn.functional.softplus(raw[:, :, 6:7]) * 0.1
|
| 383 |
+
half = direction * length * 0.5
|
| 384 |
+
else:
|
| 385 |
+
raw = self.segment_head(queries).view(B, self.segments, 2, 3)
|
| 386 |
+
raw = raw + self.query_offsets.unsqueeze(0)
|
| 387 |
+
mid, half = raw[:, :, 0], raw[:, :, 1]
|
| 388 |
+
seg_params = torch.stack([mid - half, mid + half], dim=2)
|
| 389 |
+
|
| 390 |
+
vertices = seg_params.reshape(B, self.out_vertices, 3)
|
| 391 |
+
edges = [[(2 * i, 2 * i + 1) for i in range(self.segments)] for _ in range(B)]
|
| 392 |
+
|
| 393 |
+
out = {"vertices": vertices, "segments": seg_params, "edges": edges,
|
| 394 |
+
"src": src, "pad_mask": pad_mask, "queries": queries}
|
| 395 |
+
if self.segment_conf:
|
| 396 |
+
out["conf"] = self.conf_head(queries).squeeze(-1) # [B, S]
|
| 397 |
+
return out
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
# ---------------------------------------------------------------------------
|
| 401 |
+
# Encoder-only layer (self-attention on full token sequence)
|
| 402 |
+
# ---------------------------------------------------------------------------
|
| 403 |
+
|
| 404 |
+
class SelfAttentionEncoderLayer(nn.Module):
|
| 405 |
+
"""Single self-attention layer: self-attn(tokens) -> FFN."""
|
| 406 |
+
|
| 407 |
+
def __init__(
|
| 408 |
+
self,
|
| 409 |
+
d_model: int,
|
| 410 |
+
num_heads: int,
|
| 411 |
+
dim_ff: int,
|
| 412 |
+
dropout: float = 0.0,
|
| 413 |
+
activation: str = "gelu",
|
| 414 |
+
kv_heads: int | None = None,
|
| 415 |
+
norm_class=None,
|
| 416 |
+
qk_norm: bool = False,
|
| 417 |
+
qk_norm_type: str = "l2",
|
| 418 |
+
):
|
| 419 |
+
super().__init__()
|
| 420 |
+
self.self_attn = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 421 |
+
self.ffn = FFNResidual(d_model, dim_ff, dropout, activation=activation, norm_class=norm_class)
|
| 422 |
+
|
| 423 |
+
def forward(self, x: torch.Tensor, key_padding_mask: torch.Tensor | None = None) -> torch.Tensor:
|
| 424 |
+
x = self.self_attn(x, x, memory_key_padding_mask=key_padding_mask)
|
| 425 |
+
x = self.ffn(x)
|
| 426 |
+
return x
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
# ---------------------------------------------------------------------------
|
| 430 |
+
# End-to-end model: tokenizer embeddings + perceiver
|
| 431 |
+
# ---------------------------------------------------------------------------
|
| 432 |
+
|
| 433 |
+
class EdgeDepthSegmentsModel(nn.Module):
|
| 434 |
+
"""Tokenizer embeddings + transformer for 3D roof wireframes.
|
| 435 |
+
|
| 436 |
+
Supports two architectures via the `arch` parameter:
|
| 437 |
+
- "perceiver": Perceiver latent bottleneck (default, O(L*T) attention)
|
| 438 |
+
- "transformer": Standard self-attention encoder (O(T^2) attention)
|
| 439 |
+
|
| 440 |
+
Both share the same decoder, output head, and tokenizer.
|
| 441 |
+
"""
|
| 442 |
+
|
| 443 |
+
def __init__(
|
| 444 |
+
self,
|
| 445 |
+
seq_cfg,
|
| 446 |
+
segments: int = 32,
|
| 447 |
+
hidden: int = 128,
|
| 448 |
+
num_heads: int = 4,
|
| 449 |
+
kv_heads_cross: int | None = 2,
|
| 450 |
+
kv_heads_self: int | None = 0,
|
| 451 |
+
dim_feedforward: int = 256,
|
| 452 |
+
dropout: float = 0.1,
|
| 453 |
+
latent_tokens: int = 64,
|
| 454 |
+
latent_layers: int = 1,
|
| 455 |
+
decoder_layers: int = 2,
|
| 456 |
+
label_emb_dim: int = 16,
|
| 457 |
+
src_emb_dim: int = 2,
|
| 458 |
+
behind_emb_dim: int = 8,
|
| 459 |
+
fourier_seed: int = 0,
|
| 460 |
+
cross_attn_interval: int = 1,
|
| 461 |
+
norm_class=None,
|
| 462 |
+
activation: str = "gelu",
|
| 463 |
+
segment_conf: bool = False,
|
| 464 |
+
use_vote_features: bool = False,
|
| 465 |
+
arch: str = "perceiver",
|
| 466 |
+
encoder_layers: int = 4,
|
| 467 |
+
pre_encoder_layers: int = 0,
|
| 468 |
+
segment_param: str = "midpoint_halfvec",
|
| 469 |
+
length_floor: float = 0.0,
|
| 470 |
+
decoder_input_xattn: bool = False,
|
| 471 |
+
qk_norm: bool = False,
|
| 472 |
+
qk_norm_type: str = "l2",
|
| 473 |
+
learnable_fourier: bool = False,
|
| 474 |
+
):
|
| 475 |
+
super().__init__()
|
| 476 |
+
self.seq_cfg = seq_cfg
|
| 477 |
+
|
| 478 |
+
from .tokenizer import EdgeDepthSequenceBuilder
|
| 479 |
+
self.tokenizer = EdgeDepthSequenceBuilder(
|
| 480 |
+
seq_cfg,
|
| 481 |
+
label_emb_dim=label_emb_dim,
|
| 482 |
+
src_emb_dim=src_emb_dim,
|
| 483 |
+
behind_emb_dim=behind_emb_dim,
|
| 484 |
+
fourier_seed=fourier_seed,
|
| 485 |
+
use_vote_features=use_vote_features,
|
| 486 |
+
learnable_fourier=learnable_fourier,
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
if arch == "transformer":
|
| 490 |
+
raise ValueError(
|
| 491 |
+
"arch='transformer' is no longer supported. "
|
| 492 |
+
"TransformerSegments has been removed; use arch='perceiver'.")
|
| 493 |
+
else:
|
| 494 |
+
self.segmenter = TokenTransformerSegments(
|
| 495 |
+
segments=segments,
|
| 496 |
+
in_dim=self.tokenizer.out_dim,
|
| 497 |
+
hidden=hidden,
|
| 498 |
+
num_heads=num_heads,
|
| 499 |
+
kv_heads_cross=kv_heads_cross,
|
| 500 |
+
kv_heads_self=kv_heads_self,
|
| 501 |
+
dim_feedforward=dim_feedforward,
|
| 502 |
+
dropout=dropout,
|
| 503 |
+
latent_tokens=latent_tokens,
|
| 504 |
+
latent_layers=latent_layers,
|
| 505 |
+
decoder_layers=decoder_layers,
|
| 506 |
+
cross_attn_interval=cross_attn_interval,
|
| 507 |
+
norm_class=norm_class,
|
| 508 |
+
activation=activation,
|
| 509 |
+
segment_conf=segment_conf,
|
| 510 |
+
pre_encoder_layers=pre_encoder_layers,
|
| 511 |
+
segment_param=segment_param,
|
| 512 |
+
length_floor=length_floor,
|
| 513 |
+
decoder_input_xattn=decoder_input_xattn,
|
| 514 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
def forward_tokens(self, tokens: torch.Tensor, mask: torch.Tensor):
|
| 518 |
+
"""Run the segmenter on pre-built token tensors."""
|
| 519 |
+
return self.segmenter(tokens, mask)
|
s23dr_2026_example/point_fusion.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
point_fusion.py
|
| 3 |
+
|
| 4 |
+
Simplified semantic point fusion for the 2026 dataset format.
|
| 5 |
+
|
| 6 |
+
Takes per-view (ADE segmap, Gestalt segmap, depth) + sparse COLMAP point cloud
|
| 7 |
+
from the usm3d/hoho22k_2026_trainval dataset and builds a compact, house-centric
|
| 8 |
+
semantic point representation suitable for downstream wireframe prediction.
|
| 9 |
+
|
| 10 |
+
Key differences from the 2025 pipeline:
|
| 11 |
+
- COLMAP is a ZIP of text files (cameras.txt, images.txt, points3D.txt)
|
| 12 |
+
- Depth is millimeter I;16 PNG (depth_scale=0.001 converts to meters)
|
| 13 |
+
- Views flagged with pose_only_in_colmap=True have zeroed K/R/t and must be
|
| 14 |
+
skipped for depth unprojection and projection
|
| 15 |
+
- Images arrive as PIL Images, not byte arrays
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import zipfile
|
| 21 |
+
from dataclasses import dataclass
|
| 22 |
+
from io import BytesIO
|
| 23 |
+
from typing import Dict, List, Optional, Tuple
|
| 24 |
+
|
| 25 |
+
import cv2
|
| 26 |
+
import numpy as np
|
| 27 |
+
from scipy.stats import mode as scipy_mode
|
| 28 |
+
|
| 29 |
+
from .color_mappings import ade20k_color_mapping, gestalt_color_mapping
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Color packing helpers
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
def _pack_rgb_u32(rgb: np.ndarray) -> np.ndarray:
|
| 36 |
+
"""Pack uint8 RGB (..., 3) into uint32 codes."""
|
| 37 |
+
rgb = rgb.astype(np.uint32, copy=False)
|
| 38 |
+
return (rgb[..., 0] << 16) | (rgb[..., 1] << 8) | rgb[..., 2]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _build_rgbcode_maps(color_mapping):
|
| 42 |
+
"""Return (rgbcode_to_id, id_to_name) for a color mapping dict."""
|
| 43 |
+
names = list(color_mapping.keys())
|
| 44 |
+
rgbs = np.array([color_mapping[n] for n in names], dtype=np.uint8)
|
| 45 |
+
codes = _pack_rgb_u32(rgbs.reshape(-1, 1, 3)).reshape(-1)
|
| 46 |
+
rgbcode_to_id = {int(c): i for i, c in enumerate(codes)}
|
| 47 |
+
return rgbcode_to_id, names
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _name_to_packed_rgb(name, mapping):
|
| 51 |
+
"""Case-insensitive lookup returning a packed RGB code, or None."""
|
| 52 |
+
for key in mapping:
|
| 53 |
+
if key.lower() == name.lower():
|
| 54 |
+
rgb = np.array(mapping[key], np.uint8).reshape(1, 1, 3)
|
| 55 |
+
return int(_pack_rgb_u32(rgb).reshape(()))
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# Label mapping constants
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
ADE_RGBCODE_TO_ID, ADE_ID_TO_NAME = _build_rgbcode_maps(ade20k_color_mapping)
|
| 63 |
+
GEST_RGBCODE_TO_ID, GEST_ID_TO_NAME = _build_rgbcode_maps(gestalt_color_mapping)
|
| 64 |
+
NUM_ADE = len(ADE_ID_TO_NAME)
|
| 65 |
+
NUM_GEST = len(GEST_ID_TO_NAME)
|
| 66 |
+
|
| 67 |
+
GEST_INVALID_NAMES = ("unclassified", "unknown", "transition_line")
|
| 68 |
+
GEST_INVALID_CODES = set(
|
| 69 |
+
int(_pack_rgb_u32(np.array(gestalt_color_mapping[n], np.uint8).reshape(1, 1, 3)).reshape(()))
|
| 70 |
+
for n in GEST_INVALID_NAMES if n in gestalt_color_mapping
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# ADE classes whose surfaces are "see-through" for label fusion: when a point
|
| 74 |
+
# projects onto one of these, we use the Gestalt label behind it instead.
|
| 75 |
+
ADE_TRANSPARENT_NAMES = (
|
| 76 |
+
"wall", "building;edifice", "floor;flooring", "ceiling",
|
| 77 |
+
"windowpane;window", "door;double;door", "house", "skyscraper",
|
| 78 |
+
"screen;door;screen", "blind;screen", "hovel;hut;hutch;shack;shanty",
|
| 79 |
+
"tower", "booth;cubicle;stall;kiosk",
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# ADE classes kept as "occluders/add-ons" when overlapping the house silhouette.
|
| 83 |
+
ADE_OCCLUDER_ALLOWLIST_NAMES = (
|
| 84 |
+
"tree", "person;individual;someone;somebody;mortal;soul",
|
| 85 |
+
"car;auto;automobile;machine;motorcar", "truck;motortruck", "van",
|
| 86 |
+
"fence;fencing", "railing;rail",
|
| 87 |
+
"bannister;banister;balustrade;balusters;handrail",
|
| 88 |
+
"stairs;steps", "stairway;staircase", "step;stair", "pole",
|
| 89 |
+
"streetlight;street;lamp", "signboard;sign", "awning;sunshade;sunblind",
|
| 90 |
+
"plant;flora;plant;life", "pot;flowerpot",
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# Precomputed arrays for the default name lists (avoids re-lookup every call).
|
| 94 |
+
_DEFAULT_ADE_TRANSPARENT_CODES = np.array(
|
| 95 |
+
[c for n in ADE_TRANSPARENT_NAMES
|
| 96 |
+
if (c := _name_to_packed_rgb(n, ade20k_color_mapping)) is not None],
|
| 97 |
+
dtype=np.uint32,
|
| 98 |
+
)
|
| 99 |
+
_DEFAULT_ADE_OCCLUDER_IDS = np.array(
|
| 100 |
+
sorted({ADE_RGBCODE_TO_ID[c]
|
| 101 |
+
for n in ADE_OCCLUDER_ALLOWLIST_NAMES
|
| 102 |
+
if (c := _name_to_packed_rgb(n, ade20k_color_mapping)) is not None
|
| 103 |
+
and c in ADE_RGBCODE_TO_ID}),
|
| 104 |
+
dtype=np.int32,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
+
# Config
|
| 109 |
+
# ---------------------------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
@dataclass(frozen=True)
|
| 112 |
+
class FuserConfig:
|
| 113 |
+
"""Simplified fusion configuration (no depth calibration fields)."""
|
| 114 |
+
depth_points_per_view: int = 20_000 # depth samples per view
|
| 115 |
+
depth_scale: float = 0.001 # mm -> meters
|
| 116 |
+
depth_clip_percentile: float = 99.5 # drop extreme outliers
|
| 117 |
+
house_mask_dilate_px: int = 5 # dilate gestalt mask
|
| 118 |
+
min_support_views: int = 1 # min views for a kept point
|
| 119 |
+
ade_transparent_classes: Tuple[str, ...] = ADE_TRANSPARENT_NAMES
|
| 120 |
+
ade_occluder_allowlist: Tuple[str, ...] = ADE_OCCLUDER_ALLOWLIST_NAMES
|
| 121 |
+
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
# Geometry: projection + depth unprojection
|
| 124 |
+
# ---------------------------------------------------------------------------
|
| 125 |
+
|
| 126 |
+
def project_world_points(points_world, K, R, t):
|
| 127 |
+
"""Project (N,3) world points to pixel (u,v) with validity mask."""
|
| 128 |
+
pts = points_world.astype(np.float32, copy=False)
|
| 129 |
+
cam = (R @ pts.T + t).T # (N, 3)
|
| 130 |
+
z = cam[:, 2]
|
| 131 |
+
valid = z > 1e-6
|
| 132 |
+
inv_z = np.zeros_like(z)
|
| 133 |
+
inv_z[valid] = 1.0 / z[valid]
|
| 134 |
+
x = cam[:, 0] * inv_z
|
| 135 |
+
y = cam[:, 1] * inv_z
|
| 136 |
+
u = K[0, 0] * x + K[0, 2]
|
| 137 |
+
v = K[1, 1] * y + K[1, 2]
|
| 138 |
+
return u, v, valid
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def unproject_depth_to_world(depth, K, R, t, num_points, sample_mask=None, rng=None):
|
| 142 |
+
"""Convert a depth map + camera params to (M, 3) world points, M <= num_points."""
|
| 143 |
+
if rng is None:
|
| 144 |
+
rng = np.random.default_rng()
|
| 145 |
+
d = np.asarray(depth, dtype=np.float32)
|
| 146 |
+
if d.ndim != 2:
|
| 147 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 148 |
+
|
| 149 |
+
valid = np.isfinite(d) & (d > 1e-6)
|
| 150 |
+
if sample_mask is not None:
|
| 151 |
+
mask = np.asarray(sample_mask, dtype=bool)
|
| 152 |
+
if mask.shape != d.shape:
|
| 153 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 154 |
+
valid &= mask
|
| 155 |
+
|
| 156 |
+
ys, xs = np.where(valid)
|
| 157 |
+
if ys.size == 0:
|
| 158 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 159 |
+
|
| 160 |
+
idx = rng.choice(ys.size, size=min(num_points, ys.size), replace=False)
|
| 161 |
+
y = ys[idx].astype(np.float32)
|
| 162 |
+
x = xs[idx].astype(np.float32)
|
| 163 |
+
z = d[ys[idx], xs[idx]].astype(np.float32)
|
| 164 |
+
|
| 165 |
+
fx, fy, cx, cy = K[0, 0], K[1, 1], K[0, 2], K[1, 2]
|
| 166 |
+
cam_pts = np.stack([(x - cx) * z / fx, (y - cy) * z / fy, z], axis=0)
|
| 167 |
+
# cam = R * world + t => world = R^T * (cam - t)
|
| 168 |
+
world = (R.T @ (cam_pts - t)).T
|
| 169 |
+
return world.astype(np.float32, copy=False)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def clean_depth(depth, clip_percentile):
|
| 173 |
+
"""Clip extreme depth values."""
|
| 174 |
+
d = np.asarray(depth, dtype=np.float32)
|
| 175 |
+
d = np.where(np.isfinite(d), d, 0.0)
|
| 176 |
+
d[d <= 0] = 0.0
|
| 177 |
+
if clip_percentile is not None and clip_percentile > 0 and np.any(d > 0):
|
| 178 |
+
hi = float(np.percentile(d[d > 0], clip_percentile))
|
| 179 |
+
d = np.clip(d, 0.0, hi)
|
| 180 |
+
return d
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def dilate_mask(mask, radius_px):
|
| 184 |
+
"""Binary dilation via cv2. mask: (H, W) bool."""
|
| 185 |
+
if radius_px <= 0:
|
| 186 |
+
return mask
|
| 187 |
+
k = 2 * radius_px + 1
|
| 188 |
+
kernel = np.ones((k, k), np.uint8)
|
| 189 |
+
return cv2.dilate(mask.astype(np.uint8), kernel) > 0
|
| 190 |
+
|
| 191 |
+
# ---------------------------------------------------------------------------
|
| 192 |
+
# COLMAP extraction (2026 format)
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
|
| 195 |
+
def extract_colmap_points_2026(sample):
|
| 196 |
+
"""Extract (N, 3) float32 COLMAP world points from a 2026-format sample.
|
| 197 |
+
|
| 198 |
+
sample['colmap'] must be a ZIP archive containing points3D.txt.
|
| 199 |
+
Fails fast if that file is missing (it is always present in the 2026 format).
|
| 200 |
+
"""
|
| 201 |
+
colmap_blob = sample.get("colmap")
|
| 202 |
+
if colmap_blob is None:
|
| 203 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 204 |
+
if not isinstance(colmap_blob, (bytes, bytearray, memoryview)):
|
| 205 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 206 |
+
|
| 207 |
+
try:
|
| 208 |
+
with zipfile.ZipFile(BytesIO(colmap_blob)) as zf:
|
| 209 |
+
if "points3D.txt" not in set(zf.namelist()):
|
| 210 |
+
raise FileNotFoundError(
|
| 211 |
+
"COLMAP ZIP is missing points3D.txt -- "
|
| 212 |
+
"this is required in the 2026 dataset format")
|
| 213 |
+
with zf.open("points3D.txt") as f:
|
| 214 |
+
text = f.read().decode("utf-8", errors="ignore")
|
| 215 |
+
# Format: POINT3D_ID X Y Z R G B ERROR TRACK[]
|
| 216 |
+
# Filter comment/blank lines, parse columns 1-3 (X,Y,Z)
|
| 217 |
+
from io import StringIO
|
| 218 |
+
clean = "\n".join(l for l in text.split("\n") if l and not l.startswith("#"))
|
| 219 |
+
if not clean:
|
| 220 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 221 |
+
return np.loadtxt(StringIO(clean), dtype=np.float32, usecols=(1, 2, 3))
|
| 222 |
+
except zipfile.BadZipFile:
|
| 223 |
+
pass
|
| 224 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 225 |
+
|
| 226 |
+
# ---------------------------------------------------------------------------
|
| 227 |
+
# Label helpers
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
|
| 230 |
+
def _codes_from_image(img):
|
| 231 |
+
"""Convert a PIL Image or numpy array to a (H, W) uint32 packed-RGB map."""
|
| 232 |
+
arr = np.asarray(img)
|
| 233 |
+
if arr.ndim == 2:
|
| 234 |
+
arr = np.stack([arr, arr, arr], axis=-1)
|
| 235 |
+
arr = arr[..., :3]
|
| 236 |
+
if arr.dtype != np.uint8:
|
| 237 |
+
arr = np.clip(arr, 0, 255).astype(np.uint8)
|
| 238 |
+
return _pack_rgb_u32(arr)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _row_majority(values):
|
| 242 |
+
"""Row-wise majority vote on (P, V) int array; -1 means "no vote".
|
| 243 |
+
Returns (P,) with the most frequent non-negative value per row, or -1.
|
| 244 |
+
|
| 245 |
+
Masks -1 entries before voting so that abstentions don't outvote
|
| 246 |
+
actual labels (which happens when a point is visible in only 1-2 views).
|
| 247 |
+
"""
|
| 248 |
+
P, V = values.shape
|
| 249 |
+
result = np.full(P, -1, dtype=values.dtype)
|
| 250 |
+
|
| 251 |
+
# For each row, find the most frequent non-negative value.
|
| 252 |
+
# Vectorized approach: flatten valid entries per row using argmax on counts.
|
| 253 |
+
# Since values are typically small non-negative ints (0-200), we can use
|
| 254 |
+
# a simple max-of-first-valid approach for speed when V is small.
|
| 255 |
+
for vi in range(V):
|
| 256 |
+
# For rows still unset, take the first valid vote
|
| 257 |
+
col = values[:, vi]
|
| 258 |
+
unset = result == -1
|
| 259 |
+
has_val = col >= 0
|
| 260 |
+
update = unset & has_val
|
| 261 |
+
result[update] = col[update]
|
| 262 |
+
|
| 263 |
+
# Now refine: if a row has multiple different valid votes, pick the mode.
|
| 264 |
+
# Check if any row has conflicting votes across views.
|
| 265 |
+
has_any = np.any(values >= 0, axis=1)
|
| 266 |
+
n_valid = np.sum(values >= 0, axis=1)
|
| 267 |
+
needs_vote = has_any & (n_valid > 1)
|
| 268 |
+
|
| 269 |
+
if np.any(needs_vote):
|
| 270 |
+
for i in np.where(needs_vote)[0]:
|
| 271 |
+
valid = values[i][values[i] >= 0]
|
| 272 |
+
# Use numpy bincount for speed (values are small non-neg ints)
|
| 273 |
+
counts = np.bincount(valid.astype(np.intp))
|
| 274 |
+
result[i] = counts.argmax()
|
| 275 |
+
|
| 276 |
+
return result
|
| 277 |
+
|
| 278 |
+
# ---------------------------------------------------------------------------
|
| 279 |
+
# Semantic fusion: house-centric, occluder-aware
|
| 280 |
+
# ---------------------------------------------------------------------------
|
| 281 |
+
|
| 282 |
+
def _fuse_labels_for_points(
|
| 283 |
+
points_world, Ks, Rs, ts, ade_images, gestalt_images,
|
| 284 |
+
ade_transparent_codes, ade_occluder_allowed_ids,
|
| 285 |
+
min_support_views, valid_view_mask=None,
|
| 286 |
+
):
|
| 287 |
+
"""Multi-view semantic label fusion with majority voting.
|
| 288 |
+
|
| 289 |
+
For each 3D point, project into every valid view:
|
| 290 |
+
- ADE "envelope" class -> use the Gestalt label behind it.
|
| 291 |
+
- ADE non-envelope -> keep if on the occluder allowlist.
|
| 292 |
+
Then majority-vote across views.
|
| 293 |
+
|
| 294 |
+
Returns dict: keep, visible_src, visible_id, behind_gest_id, support
|
| 295 |
+
"""
|
| 296 |
+
P = points_world.shape[0]
|
| 297 |
+
V = min(len(Ks), len(Rs), len(ts), len(ade_images), len(gestalt_images))
|
| 298 |
+
empty = {
|
| 299 |
+
"keep": np.zeros(P, dtype=bool),
|
| 300 |
+
"visible_src": np.zeros(P, np.uint8),
|
| 301 |
+
"visible_id": np.full(P, -1, np.int16),
|
| 302 |
+
"behind_gest_id": np.full(P, -1, np.int16),
|
| 303 |
+
"support": np.zeros(P, np.uint8),
|
| 304 |
+
}
|
| 305 |
+
if P == 0 or V == 0:
|
| 306 |
+
return empty
|
| 307 |
+
|
| 308 |
+
# Per-view labels. src: 1=gestalt, 2=ade; -1 = no contribution.
|
| 309 |
+
visible_src_pv = np.full((P, V), -1, dtype=np.int8)
|
| 310 |
+
visible_id_pv = np.full((P, V), -1, dtype=np.int32)
|
| 311 |
+
behind_id_pv = np.full((P, V), -1, dtype=np.int32)
|
| 312 |
+
support = np.zeros(P, dtype=np.int32)
|
| 313 |
+
|
| 314 |
+
ade_allowed_set = set(ade_occluder_allowed_ids.tolist())
|
| 315 |
+
ade_transparent_u32 = ade_transparent_codes.astype(np.uint32, copy=False)
|
| 316 |
+
gest_invalid_arr = np.array(list(GEST_INVALID_CODES), dtype=np.uint32)
|
| 317 |
+
|
| 318 |
+
for vi in range(V):
|
| 319 |
+
if valid_view_mask is not None and not valid_view_mask[vi]:
|
| 320 |
+
continue
|
| 321 |
+
|
| 322 |
+
K = np.asarray(Ks[vi], np.float32)
|
| 323 |
+
R = np.asarray(Rs[vi], np.float32)
|
| 324 |
+
t = np.asarray(ts[vi], np.float32).reshape(3, 1)
|
| 325 |
+
|
| 326 |
+
ade_codes_img = _codes_from_image(ade_images[vi])
|
| 327 |
+
gest_codes_img = _codes_from_image(gestalt_images[vi])
|
| 328 |
+
H, W = ade_codes_img.shape
|
| 329 |
+
|
| 330 |
+
u, v, valid = project_world_points(points_world, K, R, t)
|
| 331 |
+
in_img = valid & (u >= 0) & (u < W) & (v >= 0) & (v < H)
|
| 332 |
+
if not np.any(in_img):
|
| 333 |
+
continue
|
| 334 |
+
|
| 335 |
+
ui = np.clip(np.round(u[in_img]).astype(np.int32), 0, W - 1)
|
| 336 |
+
vi_pix = np.clip(np.round(v[in_img]).astype(np.int32), 0, H - 1)
|
| 337 |
+
ade_codes = ade_codes_img[vi_pix, ui]
|
| 338 |
+
gest_codes = gest_codes_img[vi_pix, ui]
|
| 339 |
+
|
| 340 |
+
in_house = ~np.isin(gest_codes, gest_invalid_arr)
|
| 341 |
+
if not np.any(in_house):
|
| 342 |
+
continue
|
| 343 |
+
|
| 344 |
+
idx = np.where(in_img)[0][in_house]
|
| 345 |
+
ade_codes_h = ade_codes[in_house]
|
| 346 |
+
gest_codes_h = gest_codes[in_house]
|
| 347 |
+
|
| 348 |
+
behind_local = np.array(
|
| 349 |
+
[GEST_RGBCODE_TO_ID.get(int(c), -1) for c in gest_codes_h],
|
| 350 |
+
dtype=np.int32)
|
| 351 |
+
behind_id_pv[idx, vi] = behind_local
|
| 352 |
+
|
| 353 |
+
ade_is_transparent = np.isin(ade_codes_h, ade_transparent_u32)
|
| 354 |
+
|
| 355 |
+
# Case A: ADE is envelope -- use Gestalt label.
|
| 356 |
+
mask_a = ade_is_transparent & (behind_local >= 0)
|
| 357 |
+
if np.any(mask_a):
|
| 358 |
+
visible_src_pv[idx[mask_a], vi] = 1
|
| 359 |
+
visible_id_pv[idx[mask_a], vi] = behind_local[mask_a]
|
| 360 |
+
|
| 361 |
+
# Case B: ADE is non-envelope -- use ADE label (allowlist-filtered).
|
| 362 |
+
mask_b = ~ade_is_transparent
|
| 363 |
+
if np.any(mask_b):
|
| 364 |
+
ade_local = np.array(
|
| 365 |
+
[ADE_RGBCODE_TO_ID.get(int(c), -1) for c in ade_codes_h[mask_b]],
|
| 366 |
+
dtype=np.int32)
|
| 367 |
+
on_allowlist = np.array(
|
| 368 |
+
[int(a) in ade_allowed_set for a in ade_local], dtype=bool
|
| 369 |
+
) & (ade_local >= 0)
|
| 370 |
+
if np.any(on_allowlist):
|
| 371 |
+
visible_src_pv[idx[mask_b][on_allowlist], vi] = 2
|
| 372 |
+
visible_id_pv[idx[mask_b][on_allowlist], vi] = ade_local[on_allowlist]
|
| 373 |
+
|
| 374 |
+
support[idx] += 1
|
| 375 |
+
|
| 376 |
+
# ---- Aggregate across views via majority vote ----
|
| 377 |
+
keep = (support >= min_support_views) & np.any(visible_src_pv >= 0, axis=1)
|
| 378 |
+
|
| 379 |
+
# Combine (src, id) into a single key for voting, then split back.
|
| 380 |
+
# src in {1,2} and id in [0, ~150], so stride=100k avoids collisions.
|
| 381 |
+
VIS_STRIDE = 100_000
|
| 382 |
+
vis_key = np.where(
|
| 383 |
+
visible_src_pv >= 0,
|
| 384 |
+
visible_src_pv.astype(np.int64) * VIS_STRIDE + visible_id_pv.astype(np.int64),
|
| 385 |
+
-1)
|
| 386 |
+
voted_key = _row_majority(vis_key)
|
| 387 |
+
voted_behind = _row_majority(behind_id_pv)
|
| 388 |
+
|
| 389 |
+
final_src = np.zeros(P, dtype=np.uint8)
|
| 390 |
+
final_id = np.full(P, -1, dtype=np.int16)
|
| 391 |
+
ok = voted_key >= 0
|
| 392 |
+
if np.any(ok):
|
| 393 |
+
final_src[ok] = (voted_key[ok] // VIS_STRIDE).astype(np.uint8)
|
| 394 |
+
final_id[ok] = (voted_key[ok] % VIS_STRIDE).astype(np.int16)
|
| 395 |
+
|
| 396 |
+
# ---- Vote confidence metadata ----
|
| 397 |
+
n_views_voted = np.sum(visible_src_pv >= 0, axis=1).astype(np.uint8)
|
| 398 |
+
|
| 399 |
+
# Fraction of voting views that agreed with the majority label
|
| 400 |
+
vote_frac = np.zeros(P, dtype=np.float32)
|
| 401 |
+
if np.any(ok):
|
| 402 |
+
for i in np.where(ok)[0]:
|
| 403 |
+
votes = vis_key[i][vis_key[i] >= 0]
|
| 404 |
+
if len(votes) > 0:
|
| 405 |
+
vote_frac[i] = (votes == voted_key[i]).sum() / len(votes)
|
| 406 |
+
|
| 407 |
+
return {
|
| 408 |
+
"keep": keep,
|
| 409 |
+
"visible_src": final_src,
|
| 410 |
+
"visible_id": final_id,
|
| 411 |
+
"behind_gest_id": voted_behind.astype(np.int16),
|
| 412 |
+
"support": support.astype(np.uint8),
|
| 413 |
+
"n_views_voted": n_views_voted,
|
| 414 |
+
"vote_frac": vote_frac,
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
# ---------------------------------------------------------------------------
|
| 418 |
+
# Compact scene builder (2026 dataset format)
|
| 419 |
+
# ---------------------------------------------------------------------------
|
| 420 |
+
|
| 421 |
+
def _resolve_ade_codes(cfg):
|
| 422 |
+
"""Return (transparent_codes, occluder_ids) for the given config.
|
| 423 |
+
Uses precomputed module-level arrays when the config has default names.
|
| 424 |
+
"""
|
| 425 |
+
if cfg.ade_transparent_classes == ADE_TRANSPARENT_NAMES:
|
| 426 |
+
transparent = _DEFAULT_ADE_TRANSPARENT_CODES
|
| 427 |
+
else:
|
| 428 |
+
transparent = np.array(
|
| 429 |
+
[c for n in cfg.ade_transparent_classes
|
| 430 |
+
if (c := _name_to_packed_rgb(n, ade20k_color_mapping)) is not None],
|
| 431 |
+
dtype=np.uint32)
|
| 432 |
+
|
| 433 |
+
if cfg.ade_occluder_allowlist == ADE_OCCLUDER_ALLOWLIST_NAMES:
|
| 434 |
+
occluder_ids = _DEFAULT_ADE_OCCLUDER_IDS
|
| 435 |
+
else:
|
| 436 |
+
occluder_ids = np.array(
|
| 437 |
+
sorted({ADE_RGBCODE_TO_ID[c]
|
| 438 |
+
for n in cfg.ade_occluder_allowlist
|
| 439 |
+
if (c := _name_to_packed_rgb(n, ade20k_color_mapping)) is not None
|
| 440 |
+
and c in ADE_RGBCODE_TO_ID}),
|
| 441 |
+
dtype=np.int32)
|
| 442 |
+
return transparent, occluder_ids
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def _parse_gt_array(sample, key, dtype, expected_cols):
|
| 446 |
+
"""Parse an optional ground-truth array from the sample dict."""
|
| 447 |
+
raw = sample.get(key)
|
| 448 |
+
if raw is None:
|
| 449 |
+
return None
|
| 450 |
+
arr = np.asarray(raw, dtype=dtype)
|
| 451 |
+
if arr.ndim == 2 and arr.shape[1] == expected_cols:
|
| 452 |
+
return arr
|
| 453 |
+
return None
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def build_compact_scene(sample, cfg, rng):
|
| 457 |
+
"""Build a compact semantic point representation from a HuggingFace sample.
|
| 458 |
+
|
| 459 |
+
Expected sample keys: K, R, t, ade, gestalt, depth, colmap,
|
| 460 |
+
pose_only_in_colmap, wf_vertices (opt), wf_edges (opt), __key__ (opt).
|
| 461 |
+
|
| 462 |
+
Returns dict (xyz, source, visible_src, visible_id, behind_gest_id,
|
| 463 |
+
gt_vertices, gt_edges, sample_id) or None if no points survive fusion.
|
| 464 |
+
"""
|
| 465 |
+
Ks = sample.get("K") or []
|
| 466 |
+
Rs = sample.get("R") or []
|
| 467 |
+
ts = sample.get("t") or []
|
| 468 |
+
ade_imgs = sample.get("ade") or []
|
| 469 |
+
gest_imgs = sample.get("gestalt") or []
|
| 470 |
+
depths = sample.get("depth") or []
|
| 471 |
+
pose_flags = sample.get("pose_only_in_colmap") or []
|
| 472 |
+
|
| 473 |
+
V = min(len(Ks), len(Rs), len(ts), len(ade_imgs), len(gest_imgs))
|
| 474 |
+
if V == 0:
|
| 475 |
+
return None
|
| 476 |
+
|
| 477 |
+
valid_view = [not (vi < len(pose_flags) and pose_flags[vi]) for vi in range(V)]
|
| 478 |
+
if not any(valid_view):
|
| 479 |
+
return None
|
| 480 |
+
|
| 481 |
+
# ---- COLMAP points ----
|
| 482 |
+
colmap_pts = extract_colmap_points_2026(sample)
|
| 483 |
+
|
| 484 |
+
# ---- Precompute house masks (from Gestalt), optionally dilated ----
|
| 485 |
+
gest_invalid_arr = np.array(list(GEST_INVALID_CODES), dtype=np.uint32)
|
| 486 |
+
house_masks = []
|
| 487 |
+
for vi in range(V):
|
| 488 |
+
if not valid_view[vi]:
|
| 489 |
+
house_masks.append(None)
|
| 490 |
+
continue
|
| 491 |
+
mask = ~np.isin(_codes_from_image(gest_imgs[vi]), gest_invalid_arr)
|
| 492 |
+
if cfg.house_mask_dilate_px > 0:
|
| 493 |
+
mask = dilate_mask(mask, cfg.house_mask_dilate_px)
|
| 494 |
+
house_masks.append(mask)
|
| 495 |
+
|
| 496 |
+
# ---- Sample depth points per view ----
|
| 497 |
+
depth_points_all = []
|
| 498 |
+
for vi in range(min(V, len(depths))):
|
| 499 |
+
if not valid_view[vi] or depths[vi] is None:
|
| 500 |
+
continue
|
| 501 |
+
d = clean_depth(
|
| 502 |
+
np.asarray(depths[vi], dtype=np.float32) * cfg.depth_scale,
|
| 503 |
+
cfg.depth_clip_percentile)
|
| 504 |
+
pts = unproject_depth_to_world(
|
| 505 |
+
depth=d,
|
| 506 |
+
K=np.asarray(Ks[vi], np.float32),
|
| 507 |
+
R=np.asarray(Rs[vi], np.float32),
|
| 508 |
+
t=np.asarray(ts[vi], np.float32).reshape(3, 1),
|
| 509 |
+
num_points=cfg.depth_points_per_view,
|
| 510 |
+
sample_mask=house_masks[vi], rng=rng)
|
| 511 |
+
if pts.shape[0]:
|
| 512 |
+
depth_points_all.append(pts)
|
| 513 |
+
|
| 514 |
+
# ---- Combine COLMAP + depth points ----
|
| 515 |
+
pts_list, src_list = [], []
|
| 516 |
+
if colmap_pts.shape[0]:
|
| 517 |
+
pts_list.append(colmap_pts)
|
| 518 |
+
src_list.append(np.zeros(colmap_pts.shape[0], dtype=np.uint8)) # 0=colmap
|
| 519 |
+
if depth_points_all:
|
| 520 |
+
all_depth = np.concatenate(depth_points_all, axis=0)
|
| 521 |
+
pts_list.append(all_depth)
|
| 522 |
+
src_list.append(np.ones(all_depth.shape[0], dtype=np.uint8)) # 1=depth
|
| 523 |
+
if not pts_list:
|
| 524 |
+
return None
|
| 525 |
+
|
| 526 |
+
points_world = np.concatenate(pts_list, axis=0).astype(np.float32, copy=False)
|
| 527 |
+
point_source = np.concatenate(src_list, axis=0).astype(np.uint8, copy=False)
|
| 528 |
+
|
| 529 |
+
# ---- Fuse semantic labels ----
|
| 530 |
+
ade_transparent_arr, ade_allow_ids = _resolve_ade_codes(cfg)
|
| 531 |
+
fused = _fuse_labels_for_points(
|
| 532 |
+
points_world=points_world, Ks=Ks, Rs=Rs, ts=ts,
|
| 533 |
+
ade_images=ade_imgs, gestalt_images=gest_imgs,
|
| 534 |
+
ade_transparent_codes=ade_transparent_arr,
|
| 535 |
+
ade_occluder_allowed_ids=ade_allow_ids,
|
| 536 |
+
min_support_views=cfg.min_support_views,
|
| 537 |
+
valid_view_mask=valid_view)
|
| 538 |
+
|
| 539 |
+
keep = fused["keep"]
|
| 540 |
+
if not np.any(keep):
|
| 541 |
+
return None
|
| 542 |
+
|
| 543 |
+
return {
|
| 544 |
+
"xyz": points_world[keep],
|
| 545 |
+
"source": point_source[keep], # 0=colmap, 1=monodepth
|
| 546 |
+
"visible_src": fused["visible_src"][keep], # 1=gestalt, 2=ade
|
| 547 |
+
"visible_id": fused["visible_id"][keep],
|
| 548 |
+
"behind_gest_id": fused["behind_gest_id"][keep],
|
| 549 |
+
"n_views_voted": fused["n_views_voted"][keep],
|
| 550 |
+
"vote_frac": fused["vote_frac"][keep],
|
| 551 |
+
"gt_vertices": _parse_gt_array(sample, "wf_vertices", np.float32, 3),
|
| 552 |
+
"gt_edges": _parse_gt_array(sample, "wf_edges", np.int64, 2),
|
| 553 |
+
"sample_id": sample.get("__key__", None),
|
| 554 |
+
}
|
s23dr_2026_example/postprocess_v2.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Post-processing functions for segment predictions."""
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def snap_to_point_cloud(vertices, xyz, class_id, snap_radius=0.5,
|
| 6 |
+
target_classes=None):
|
| 7 |
+
"""Snap vertices to nearby point cloud clusters of specific semantic classes."""
|
| 8 |
+
if target_classes is None:
|
| 9 |
+
target_classes = [1, 2] # apex, eave_end_point
|
| 10 |
+
|
| 11 |
+
snapped = vertices.copy()
|
| 12 |
+
mask = np.isin(class_id, target_classes)
|
| 13 |
+
|
| 14 |
+
if mask.sum() < 2:
|
| 15 |
+
return snapped
|
| 16 |
+
|
| 17 |
+
target_pts = xyz[mask]
|
| 18 |
+
|
| 19 |
+
for i, v in enumerate(vertices):
|
| 20 |
+
dists = np.linalg.norm(target_pts - v, axis=-1)
|
| 21 |
+
close = dists < snap_radius
|
| 22 |
+
if close.sum() >= 2:
|
| 23 |
+
snapped[i] = target_pts[close].mean(axis=0)
|
| 24 |
+
|
| 25 |
+
return snapped
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def snap_horizontal(vertices, edges, max_slope=0.05):
|
| 29 |
+
"""Snap near-horizontal edges to be exactly horizontal."""
|
| 30 |
+
verts = vertices.copy()
|
| 31 |
+
for a, b in edges:
|
| 32 |
+
a, b = int(a), int(b)
|
| 33 |
+
dy = abs(verts[a, 1] - verts[b, 1])
|
| 34 |
+
dxz = np.sqrt((verts[a, 0] - verts[b, 0])**2 + (verts[a, 2] - verts[b, 2])**2)
|
| 35 |
+
if dxz > 0.1 and dy / dxz < max_slope:
|
| 36 |
+
avg_y = 0.5 * (verts[a, 1] + verts[b, 1])
|
| 37 |
+
verts[a, 1] = avg_y
|
| 38 |
+
verts[b, 1] = avg_y
|
| 39 |
+
return verts
|
s23dr_2026_example/segment_postprocess.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def merge_vertices_iterative(vertices: np.ndarray, edges: np.ndarray,
|
| 7 |
+
start: float = 0.15, end: float = 0.6,
|
| 8 |
+
n_iters: int = 5):
|
| 9 |
+
"""Iterative merge: start with tight threshold, gradually widen.
|
| 10 |
+
|
| 11 |
+
Avoids the worst transitive chaining effects of a single wide threshold.
|
| 12 |
+
Each pass merges only the closest pairs first, establishing stable cluster
|
| 13 |
+
centers before wider merges pull in more distant endpoints.
|
| 14 |
+
|
| 15 |
+
+0.004 HSS / +0.007 F1 over single-pass merge(0.4) on 1024 val samples.
|
| 16 |
+
"""
|
| 17 |
+
pv, pe = vertices, edges
|
| 18 |
+
for t in np.linspace(start, end, n_iters):
|
| 19 |
+
pv, pe = merge_vertices(pv, pe, t)
|
| 20 |
+
return pv, pe
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def merge_vertices(vertices: np.ndarray, edges: np.ndarray, thresh: float):
|
| 24 |
+
verts = np.asarray(vertices, dtype=np.float32)
|
| 25 |
+
edges = np.asarray(edges, dtype=np.int64)
|
| 26 |
+
if verts.size == 0 or edges.size == 0:
|
| 27 |
+
return verts, edges
|
| 28 |
+
|
| 29 |
+
n = verts.shape[0]
|
| 30 |
+
parent = np.arange(n, dtype=np.int64)
|
| 31 |
+
|
| 32 |
+
def find(i):
|
| 33 |
+
while parent[i] != i:
|
| 34 |
+
parent[i] = parent[parent[i]]
|
| 35 |
+
i = parent[i]
|
| 36 |
+
return i
|
| 37 |
+
|
| 38 |
+
def union(i, j):
|
| 39 |
+
ri = find(i)
|
| 40 |
+
rj = find(j)
|
| 41 |
+
if ri != rj:
|
| 42 |
+
parent[rj] = ri
|
| 43 |
+
|
| 44 |
+
for i in range(n):
|
| 45 |
+
vi = verts[i]
|
| 46 |
+
for j in range(i + 1, n):
|
| 47 |
+
if np.linalg.norm(vi - verts[j]) <= thresh:
|
| 48 |
+
union(i, j)
|
| 49 |
+
|
| 50 |
+
clusters = {}
|
| 51 |
+
for i in range(n):
|
| 52 |
+
root = find(i)
|
| 53 |
+
clusters.setdefault(root, []).append(i)
|
| 54 |
+
|
| 55 |
+
new_vertices = []
|
| 56 |
+
mapping = {}
|
| 57 |
+
for new_idx, idxs in enumerate(clusters.values()):
|
| 58 |
+
pts = verts[idxs]
|
| 59 |
+
center = pts.mean(axis=0)
|
| 60 |
+
new_vertices.append(center)
|
| 61 |
+
for i in idxs:
|
| 62 |
+
mapping[i] = new_idx
|
| 63 |
+
|
| 64 |
+
new_edges = []
|
| 65 |
+
seen = set()
|
| 66 |
+
for a, b in edges:
|
| 67 |
+
na = mapping.get(int(a), int(a))
|
| 68 |
+
nb = mapping.get(int(b), int(b))
|
| 69 |
+
if na == nb:
|
| 70 |
+
continue
|
| 71 |
+
key = (na, nb) if na <= nb else (nb, na)
|
| 72 |
+
if key in seen:
|
| 73 |
+
continue
|
| 74 |
+
seen.add(key)
|
| 75 |
+
new_edges.append([na, nb])
|
| 76 |
+
|
| 77 |
+
return np.asarray(new_vertices, dtype=np.float32), np.asarray(new_edges, dtype=np.int64)
|
s23dr_2026_example/sinkhorn.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sinkhorn optimal transport loss for segment matching.
|
| 2 |
+
|
| 3 |
+
Note: at eps=0.05, sinkhorn gradients are near-zero (~1e-7 norm) for
|
| 4 |
+
typical matrix sizes. The loss value is tracked but does not meaningfully
|
| 5 |
+
train the model. Default sinkhorn_weight=0.0. See worklog.md for details.
|
| 6 |
+
|
| 7 |
+
Future: schedule eps from large (1.0) to small (0.05) during training
|
| 8 |
+
to get useful gradients early and precise matching late.
|
| 9 |
+
"""
|
| 10 |
+
import torch
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def batched_sinkhorn_loss(
|
| 14 |
+
pred_segments: torch.Tensor,
|
| 15 |
+
gt_pad: torch.Tensor,
|
| 16 |
+
gt_mask: torch.Tensor,
|
| 17 |
+
eps: float,
|
| 18 |
+
iters: int,
|
| 19 |
+
dustbin_cost: float | torch.Tensor,
|
| 20 |
+
pred_mass: torch.Tensor | None = None,
|
| 21 |
+
) -> torch.Tensor:
|
| 22 |
+
"""Batched sinkhorn segment matching loss.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
pred_segments: [B, S, 2, 3] predicted segments
|
| 26 |
+
gt_pad: [B, M, 2, 3] padded GT segments
|
| 27 |
+
gt_mask: [B, M] bool mask (True = valid GT segment)
|
| 28 |
+
eps: sinkhorn regularization
|
| 29 |
+
iters: sinkhorn iterations
|
| 30 |
+
dustbin_cost: cost for unmatched segments (scalar or [B])
|
| 31 |
+
pred_mass: [B, S] per-segment mass weights (e.g. sigmoid(conf)).
|
| 32 |
+
If None, uniform masses are used.
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
[B] per-sample sinkhorn transport cost
|
| 36 |
+
"""
|
| 37 |
+
B, S, _, _ = pred_segments.shape
|
| 38 |
+
M = gt_pad.shape[1]
|
| 39 |
+
|
| 40 |
+
# Allow per-sample dustbin cost
|
| 41 |
+
dc = torch.as_tensor(dustbin_cost, device=pred_segments.device, dtype=pred_segments.dtype)
|
| 42 |
+
if dc.dim() == 0:
|
| 43 |
+
dc = dc.expand(B)
|
| 44 |
+
|
| 45 |
+
# Compute cost matrices [B, S, M] in midpoint-halfvec space.
|
| 46 |
+
# Decouples position from direction: mid gradient is pure position,
|
| 47 |
+
# half gradient is pure direction/length. Sign-invariance on half
|
| 48 |
+
# handles segment direction ambiguity cleanly.
|
| 49 |
+
p0 = pred_segments[:, :, 0] # [B, S, 3]
|
| 50 |
+
p1 = pred_segments[:, :, 1] # [B, S, 3]
|
| 51 |
+
g0 = gt_pad[:, :, 0] # [B, M, 3]
|
| 52 |
+
g1 = gt_pad[:, :, 1] # [B, M, 3]
|
| 53 |
+
|
| 54 |
+
mid_pred = 0.5 * (p0 + p1) # [B, S, 3]
|
| 55 |
+
half_pred = 0.5 * (p1 - p0) # [B, S, 3]
|
| 56 |
+
mid_gt = 0.5 * (g0 + g1) # [B, M, 3]
|
| 57 |
+
half_gt = 0.5 * (g1 - g0) # [B, M, 3]
|
| 58 |
+
|
| 59 |
+
# Midpoint distance [B, S, M]
|
| 60 |
+
d_mid = torch.linalg.norm(
|
| 61 |
+
mid_pred.unsqueeze(2) - mid_gt.unsqueeze(1), dim=-1)
|
| 62 |
+
|
| 63 |
+
# Decoupled direction + length distance (sign-invariant for direction ambiguity)
|
| 64 |
+
len_pred = torch.linalg.norm(half_pred, dim=-1, keepdim=True).clamp(min=1e-6) # [B, S, 1]
|
| 65 |
+
len_gt = torch.linalg.norm(half_gt, dim=-1, keepdim=True).clamp(min=1e-6) # [B, M, 1]
|
| 66 |
+
dir_pred = half_pred / len_pred # [B, S, 3]
|
| 67 |
+
dir_gt = half_gt / len_gt # [B, M, 3]
|
| 68 |
+
|
| 69 |
+
# Direction distance: 1 - |cos(angle)|, sign-invariant [B, S, M]
|
| 70 |
+
cos_angle = (dir_pred.unsqueeze(2) * dir_gt.unsqueeze(1)).sum(dim=-1) # [B, S, M]
|
| 71 |
+
d_dir = 1.0 - cos_angle.abs()
|
| 72 |
+
|
| 73 |
+
# Length distance [B, S, M]
|
| 74 |
+
d_len = (len_pred.unsqueeze(2) - len_gt.unsqueeze(1)).squeeze(-1).abs()
|
| 75 |
+
|
| 76 |
+
cost = d_mid + d_dir + d_len # [B, S, M]
|
| 77 |
+
|
| 78 |
+
# Mask invalid GT segments with high cost so they go to dustbin
|
| 79 |
+
cost = torch.where(gt_mask.unsqueeze(1), cost, dc[:, None, None] * 10.0)
|
| 80 |
+
|
| 81 |
+
# Pad with dustbin row and column: [B, S+1, M+1]
|
| 82 |
+
cost_pad = dc[:, None, None].expand(B, S + 1, M + 1).clone()
|
| 83 |
+
cost_pad[:, :S, :M] = cost
|
| 84 |
+
cost_pad[:, -1, -1] = 0.0
|
| 85 |
+
|
| 86 |
+
# Masses
|
| 87 |
+
gt_counts = gt_mask.sum(dim=1).float() # [B]
|
| 88 |
+
|
| 89 |
+
if pred_mass is not None:
|
| 90 |
+
# Confidence-weighted masses (matches learned_v2 approach).
|
| 91 |
+
# sigmoid(conf) gives per-segment mass; dustbin masses balance the totals.
|
| 92 |
+
# No normalization -- sum(a) == sum(b) == max(sum_pred, sum_gt).
|
| 93 |
+
pm = pred_mass.clamp(min=0.0) # [B, S]
|
| 94 |
+
sum_pred = pm.sum(dim=1) # [B]
|
| 95 |
+
sum_gt = gt_counts # [B]
|
| 96 |
+
pred_dustbin = (sum_gt - sum_pred).clamp(min=0.0) # [B]
|
| 97 |
+
gt_dustbin = (sum_pred - sum_gt).clamp(min=0.0) # [B]
|
| 98 |
+
a = torch.cat([pm, pred_dustbin.unsqueeze(1)], dim=1) # [B, S+1]
|
| 99 |
+
b_val = torch.zeros(B, M + 1, device=cost.device, dtype=cost.dtype)
|
| 100 |
+
b_val[:, :M] = gt_mask.float() # 1.0 per valid GT segment
|
| 101 |
+
b_val[:, -1] = gt_dustbin
|
| 102 |
+
else:
|
| 103 |
+
# Uniform masses (normalized)
|
| 104 |
+
n = float(S)
|
| 105 |
+
denom = n + gt_counts # [B]
|
| 106 |
+
a = (1.0 / denom).unsqueeze(1).expand(B, S + 1).clone() # [B, S+1]
|
| 107 |
+
a[:, -1] = gt_counts / denom
|
| 108 |
+
b_val = (1.0 / denom).unsqueeze(1).expand(B, M + 1).clone() # [B, M+1]
|
| 109 |
+
b_val[:, -1] = n / denom
|
| 110 |
+
# Zero out mass for invalid GT
|
| 111 |
+
b_val[:, :M] = b_val[:, :M] * gt_mask.float()
|
| 112 |
+
|
| 113 |
+
# Log-domain sinkhorn
|
| 114 |
+
log_a = torch.log(a + 1e-9)
|
| 115 |
+
log_b = torch.log(b_val + 1e-9)
|
| 116 |
+
log_k = -cost_pad / eps
|
| 117 |
+
|
| 118 |
+
log_u = torch.zeros_like(a)
|
| 119 |
+
log_v = torch.zeros_like(b_val)
|
| 120 |
+
|
| 121 |
+
for _ in range(iters):
|
| 122 |
+
log_u = log_a - torch.logsumexp(log_k + log_v.unsqueeze(1), dim=2)
|
| 123 |
+
log_v = log_b - torch.logsumexp(log_k + log_u.unsqueeze(2), dim=1)
|
| 124 |
+
|
| 125 |
+
transport = torch.exp(log_u.unsqueeze(2) + log_v.unsqueeze(1) + log_k)
|
| 126 |
+
return (transport * cost_pad).sum(dim=(1, 2)) # [B]
|
s23dr_2026_example/tokenizer.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tokenizer: learned embeddings + Fourier features for the point cloud tokens.
|
| 2 |
+
|
| 3 |
+
The EdgeDepthSequenceBuilder holds the learned embedding tables (label, source,
|
| 4 |
+
behind) and the random Fourier positional encoding. At training time,
|
| 5 |
+
build_tokens() in data.py applies these to pre-sampled point indices on GPU.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Tuple
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn as nn
|
| 15 |
+
|
| 16 |
+
from .point_fusion import NUM_ADE, NUM_GEST
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# -- Config --
|
| 20 |
+
|
| 21 |
+
@dataclass(frozen=True)
|
| 22 |
+
class EdgeDepthSequenceConfig:
|
| 23 |
+
seq_len: int = 2048
|
| 24 |
+
colmap_points: int = 1280
|
| 25 |
+
depth_points: int = 768
|
| 26 |
+
use_fourier: bool = True
|
| 27 |
+
fourier_dim: int = 32
|
| 28 |
+
fourier_scale: float = 10.0
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# -- Fourier positional encoding --
|
| 32 |
+
|
| 33 |
+
class FourierFeatures(nn.Module):
|
| 34 |
+
def __init__(self, in_dim: int = 3, fourier_dim: int = 64,
|
| 35 |
+
scale: float = 10.0, seed: int = 0,
|
| 36 |
+
learnable: bool = False):
|
| 37 |
+
super().__init__()
|
| 38 |
+
gen = torch.Generator()
|
| 39 |
+
gen.manual_seed(seed)
|
| 40 |
+
B = torch.randn(fourier_dim, in_dim, generator=gen) * scale
|
| 41 |
+
if learnable:
|
| 42 |
+
self.B = nn.Parameter(B)
|
| 43 |
+
else:
|
| 44 |
+
self.register_buffer("B", B, persistent=True)
|
| 45 |
+
|
| 46 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 47 |
+
proj = (2.0 * np.pi) * (x @ self.B.t())
|
| 48 |
+
return torch.cat([torch.sin(proj), torch.cos(proj)], dim=-1)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# -- Sequence builder (holds embeddings) --
|
| 52 |
+
|
| 53 |
+
class EdgeDepthSequenceBuilder(nn.Module):
|
| 54 |
+
"""Holds learned embeddings for point cloud tokenization.
|
| 55 |
+
|
| 56 |
+
Used by the model at training time: build_tokens() calls
|
| 57 |
+
self.label_emb(class_id), self.src_emb(source), etc.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
def __init__(self, cfg: EdgeDepthSequenceConfig, label_emb_dim: int = 16,
|
| 61 |
+
src_emb_dim: int = 2, behind_emb_dim: int = 8,
|
| 62 |
+
fourier_seed: int = 0, use_vote_features: bool = False,
|
| 63 |
+
learnable_fourier: bool = False):
|
| 64 |
+
super().__init__()
|
| 65 |
+
self.cfg = cfg
|
| 66 |
+
|
| 67 |
+
self.num_labels = 13 # 11 structural + other_house + non_house
|
| 68 |
+
self.label_emb = nn.Embedding(self.num_labels, label_emb_dim)
|
| 69 |
+
self.src_emb = nn.Embedding(2, src_emb_dim)
|
| 70 |
+
self.behind_emb_dim = behind_emb_dim
|
| 71 |
+
if behind_emb_dim > 0:
|
| 72 |
+
self.behind_emb = nn.Embedding(NUM_GEST + 1, behind_emb_dim)
|
| 73 |
+
|
| 74 |
+
# Fourier positional encoding
|
| 75 |
+
if cfg.use_fourier:
|
| 76 |
+
self.pos_enc = FourierFeatures(
|
| 77 |
+
in_dim=3, fourier_dim=cfg.fourier_dim,
|
| 78 |
+
scale=cfg.fourier_scale, seed=fourier_seed,
|
| 79 |
+
learnable=learnable_fourier,
|
| 80 |
+
)
|
| 81 |
+
pos_dim = 3 + 2 * cfg.fourier_dim
|
| 82 |
+
else:
|
| 83 |
+
self.pos_enc = None
|
| 84 |
+
pos_dim = 3
|
| 85 |
+
|
| 86 |
+
vote_dim = 2 if use_vote_features else 0 # n_views_voted + vote_frac
|
| 87 |
+
self.use_vote_features = use_vote_features
|
| 88 |
+
self.out_dim = pos_dim + label_emb_dim + src_emb_dim + behind_emb_dim + vote_dim
|
s23dr_2026_example/train.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Training script for S23DR 2026.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python -m s23dr_2026_example.train --cache-dir hf://usm3d/s23dr-2026-sampled_2048_v2:train --steps 80000 --aug-rotate
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path as _Path
|
| 12 |
+
if __package__ is None or __package__ == "":
|
| 13 |
+
_here = _Path(__file__).resolve().parent
|
| 14 |
+
if str(_here.parent) not in sys.path:
|
| 15 |
+
sys.path.insert(0, str(_here.parent))
|
| 16 |
+
__package__ = _here.name
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import gc
|
| 20 |
+
import json
|
| 21 |
+
import math
|
| 22 |
+
import subprocess
|
| 23 |
+
import time
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
import torch
|
| 28 |
+
|
| 29 |
+
from .tokenizer import EdgeDepthSequenceConfig
|
| 30 |
+
from .model import EdgeDepthSegmentsModel
|
| 31 |
+
from .data import build_loader, build_tokens
|
| 32 |
+
from .losses import compute_loss, _loss_inner
|
| 33 |
+
|
| 34 |
+
# Re-export for eval scripts
|
| 35 |
+
from .data import HFCachedDataset, collate as _collate # noqa: F401
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Main
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
def main():
|
| 43 |
+
p = argparse.ArgumentParser(description="S23DR 2026 training")
|
| 44 |
+
p.add_argument("--cache-dir", default=None, help="HF dataset path (hf://repo:split)")
|
| 45 |
+
p.add_argument("--val-cache-dir", default="", help="Separate cache for validation")
|
| 46 |
+
p.add_argument("--seq-len", type=int, default=2048,
|
| 47 |
+
help="Input sequence length (2048 or 4096, must match dataset)")
|
| 48 |
+
p.add_argument("--arch", choices=["perceiver", "transformer"], default="perceiver",
|
| 49 |
+
help="perceiver=latent bottleneck, transformer=full self-attention encoder")
|
| 50 |
+
p.add_argument("--segments", type=int, default=32)
|
| 51 |
+
p.add_argument("--hidden", type=int, default=128)
|
| 52 |
+
p.add_argument("--ff", type=int, default=512)
|
| 53 |
+
p.add_argument("--latent-tokens", type=int, default=128)
|
| 54 |
+
p.add_argument("--latent-layers", type=int, default=7)
|
| 55 |
+
p.add_argument("--encoder-layers", type=int, default=4,
|
| 56 |
+
help="Encoder layers (transformer arch only)")
|
| 57 |
+
p.add_argument("--pre-encoder-layers", type=int, default=0,
|
| 58 |
+
help="Self-attn layers on full token sequence before perceiver bottleneck")
|
| 59 |
+
p.add_argument("--decoder-layers", type=int, default=3)
|
| 60 |
+
p.add_argument("--decoder-input-xattn", action="store_true",
|
| 61 |
+
help="Add cross-attention from segment queries to input tokens in each decoder layer")
|
| 62 |
+
p.add_argument("--qk-norm", action="store_true",
|
| 63 |
+
help="Normalize Q and K per-head with learned temperature (stabilizes wide models)")
|
| 64 |
+
p.add_argument("--qk-norm-type", choices=["l2", "rms"], default="l2",
|
| 65 |
+
help="QK-norm type: l2 (unit sphere) or rms (RMSNorm, preserves magnitudes)")
|
| 66 |
+
p.add_argument("--learnable-fourier", action="store_true",
|
| 67 |
+
help="Make Fourier positional encoding learnable (vs fixed random)")
|
| 68 |
+
p.add_argument("--num-heads", type=int, default=4, help="Attention heads")
|
| 69 |
+
p.add_argument("--kv-heads-cross", type=int, default=2,
|
| 70 |
+
help="KV heads for cross-attention (GQA; 0 = standard MHA)")
|
| 71 |
+
p.add_argument("--kv-heads-self", type=int, default=2,
|
| 72 |
+
help="KV heads for self-attention (GQA; 0 = standard MHA)")
|
| 73 |
+
p.add_argument("--cross-attn-interval", type=int, default=4,
|
| 74 |
+
help="Perceiver cross-attention frequency (every N latent layers)")
|
| 75 |
+
p.add_argument("--dropout", type=float, default=0.1)
|
| 76 |
+
p.add_argument("--weight-decay", type=float, default=0.01, help="AdamW weight decay")
|
| 77 |
+
p.add_argument("--steps", type=int, default=5000)
|
| 78 |
+
p.add_argument("--batch-size", type=int, default=32)
|
| 79 |
+
p.add_argument("--lr", type=float, default=3e-4)
|
| 80 |
+
p.add_argument("--adam-betas", default="0.9,0.95", help="AdamW beta1,beta2")
|
| 81 |
+
p.add_argument("--warmup", type=int, default=200, help="LR warmup steps")
|
| 82 |
+
p.add_argument("--cosine-decay", action="store_true",
|
| 83 |
+
help="Cosine decay LR after warmup (to lr*0.01 at end)")
|
| 84 |
+
p.add_argument("--cooldown-start", type=int, default=0,
|
| 85 |
+
help="Step to begin linear cooldown to lr*0.01 (0=disabled, constant LR after warmup)")
|
| 86 |
+
p.add_argument("--cooldown-steps", type=int, default=0,
|
| 87 |
+
help="Number of steps for linear cooldown (0=no cooldown)")
|
| 88 |
+
p.add_argument("--seed", type=int, default=7)
|
| 89 |
+
p.add_argument("--deterministic", action="store_true",
|
| 90 |
+
help="Force deterministic mode (disables torch.compile, slower but bit-reproducible)")
|
| 91 |
+
p.add_argument("--varifold-weight", type=float, default=0.0)
|
| 92 |
+
p.add_argument("--varifold-cross-only", action="store_true",
|
| 93 |
+
help="Drop varifold self-energy (avoids O(S^2) spike, sinkhorn handles repulsion)")
|
| 94 |
+
p.add_argument("--sinkhorn-weight", type=float, default=1.0)
|
| 95 |
+
p.add_argument("--sinkhorn-eps", type=float, default=0.1,
|
| 96 |
+
help="Sinkhorn regularization (larger = softer matching, stronger gradients)")
|
| 97 |
+
p.add_argument("--sinkhorn-eps-start", type=float, default=None,
|
| 98 |
+
help="Starting eps for epsilon annealing (anneals to --sinkhorn-eps). None=no annealing.")
|
| 99 |
+
p.add_argument("--sinkhorn-eps-schedule", choices=["linear", "sqrt", "none"], default="none",
|
| 100 |
+
help="Eps annealing schedule: linear, sqrt, or none (default: no annealing)")
|
| 101 |
+
p.add_argument("--sinkhorn-iters", type=int, default=20,
|
| 102 |
+
help="Sinkhorn iterations")
|
| 103 |
+
p.add_argument("--sinkhorn-dustbin", type=float, default=0.3,
|
| 104 |
+
help="Sinkhorn dustbin cost in normalized space")
|
| 105 |
+
p.add_argument("--endpoint-weight", type=float, default=0.0,
|
| 106 |
+
help="Weight for endpoint distance loss (sinkhorn-matched, symmetric)")
|
| 107 |
+
p.add_argument("--endpoint-warmup", type=int, default=0,
|
| 108 |
+
help="Steps to linearly warm up endpoint weight from 0 (0=instant)")
|
| 109 |
+
p.add_argument("--aug-rotate", action="store_true")
|
| 110 |
+
p.add_argument("--aug-jitter", type=float, default=0.0,
|
| 111 |
+
help="Point position jitter std in normalized space (0=disabled, try 0.005)")
|
| 112 |
+
p.add_argument("--aug-drop", type=float, default=0.0,
|
| 113 |
+
help="Fraction of points to randomly drop (0=disabled, try 0.1)")
|
| 114 |
+
p.add_argument("--aug-flip", action="store_true",
|
| 115 |
+
help="Random mirror along X axis (50%% chance)")
|
| 116 |
+
p.add_argument("--rms-norm", action="store_true", default=True,
|
| 117 |
+
help="Use RMSNorm (default). Use --no-rms-norm for LayerNorm")
|
| 118 |
+
p.add_argument("--no-rms-norm", dest="rms_norm", action="store_false")
|
| 119 |
+
p.add_argument("--activation", default="gelu", help="FFN activation: gelu, relu, relu_sq")
|
| 120 |
+
p.add_argument("--behind-emb-dim", type=int, default=8,
|
| 121 |
+
help="Behind-gestalt embedding dim (0 to disable)")
|
| 122 |
+
p.add_argument("--vote-features", action="store_true",
|
| 123 |
+
help="Add n_views_voted + vote_frac as raw token features (requires v2 data)")
|
| 124 |
+
p.add_argument("--segment-param", choices=["midpoint_halfvec", "midpoint_dir_len"],
|
| 125 |
+
default="midpoint_halfvec",
|
| 126 |
+
help="Output parameterization: halfvec (default) or decoupled direction+length")
|
| 127 |
+
p.add_argument("--length-floor", type=float, default=0.0,
|
| 128 |
+
help="Minimum segment length for midpoint_dir_len (0=no floor)")
|
| 129 |
+
p.add_argument("--segment-conf", action="store_true",
|
| 130 |
+
help="Add per-segment confidence head (use with --conf-thresh at eval)")
|
| 131 |
+
p.add_argument("--conf-weight", type=float, default=0.0,
|
| 132 |
+
help="Weight for confidence loss (requires --segment-conf)")
|
| 133 |
+
p.add_argument("--conf-mode", choices=["sinkhorn", "sinkhorn_detach"], default="sinkhorn",
|
| 134 |
+
help="Confidence training: 'match'=BCE, 'sinkhorn'=OT mass, 'sinkhorn_detach'=OT mass (detached)")
|
| 135 |
+
p.add_argument("--conf-clamp-min", type=float, default=None,
|
| 136 |
+
help="Clamp conf logits to this minimum before sigmoid (e.g., -5)")
|
| 137 |
+
p.add_argument("--conf-head-wd", type=float, default=None,
|
| 138 |
+
help="Separate weight decay for conf head (default: same as other params)")
|
| 139 |
+
p.add_argument("--ema-decay", type=float, default=0.0,
|
| 140 |
+
help="EMA decay rate (0=disabled, try 0.9999). Saves EMA weights in checkpoints.")
|
| 141 |
+
p.add_argument("--out-dir", default=str(_Path(__file__).resolve().parent / "runs"))
|
| 142 |
+
p.add_argument("--resume", default="")
|
| 143 |
+
p.add_argument("--cpu", action="store_true")
|
| 144 |
+
p.add_argument("--args-from", default=None,
|
| 145 |
+
help="Load defaults from a run's args.json (CLI flags override)")
|
| 146 |
+
|
| 147 |
+
# If --args-from is specified, load defaults from that JSON file first,
|
| 148 |
+
# then let CLI flags override.
|
| 149 |
+
raw_args = p.parse_args()
|
| 150 |
+
if raw_args.args_from is not None:
|
| 151 |
+
import json as _json
|
| 152 |
+
args_path = _Path(raw_args.args_from)
|
| 153 |
+
if not args_path.exists():
|
| 154 |
+
raise FileNotFoundError(f"--args-from file not found: {args_path}")
|
| 155 |
+
saved = _json.loads(args_path.read_text())
|
| 156 |
+
valid_dests = {a.dest for a in p._actions}
|
| 157 |
+
defaults = {}
|
| 158 |
+
for k, v in saved.items():
|
| 159 |
+
if k in valid_dests and k != "args_from":
|
| 160 |
+
defaults[k] = v
|
| 161 |
+
p.set_defaults(**defaults)
|
| 162 |
+
args = p.parse_args()
|
| 163 |
+
print(f"Loaded defaults from {args_path} (CLI flags override)")
|
| 164 |
+
else:
|
| 165 |
+
args = raw_args
|
| 166 |
+
|
| 167 |
+
# Validate required args
|
| 168 |
+
if not args.cache_dir:
|
| 169 |
+
p.error("--cache-dir is required (either directly or via --args-from)")
|
| 170 |
+
|
| 171 |
+
# Validate arg compatibility
|
| 172 |
+
if args.arch == "transformer":
|
| 173 |
+
perceiver_only = []
|
| 174 |
+
if args.latent_tokens != 128:
|
| 175 |
+
perceiver_only.append(f"--latent-tokens={args.latent_tokens}")
|
| 176 |
+
if args.latent_layers != 7:
|
| 177 |
+
perceiver_only.append(f"--latent-layers={args.latent_layers}")
|
| 178 |
+
if args.pre_encoder_layers != 0:
|
| 179 |
+
perceiver_only.append(f"--pre-encoder-layers={args.pre_encoder_layers}")
|
| 180 |
+
if args.cross_attn_interval != 4:
|
| 181 |
+
perceiver_only.append(f"--cross-attn-interval={args.cross_attn_interval}")
|
| 182 |
+
if perceiver_only:
|
| 183 |
+
raise ValueError(
|
| 184 |
+
f"Args {', '.join(perceiver_only)} have no effect with --arch transformer. "
|
| 185 |
+
f"Use --arch perceiver or remove them.")
|
| 186 |
+
if args.conf_weight > 0 and not args.segment_conf:
|
| 187 |
+
raise ValueError("--conf-weight requires --segment-conf")
|
| 188 |
+
if args.conf_mode in ("sinkhorn", "sinkhorn_detach") and args.sinkhorn_weight == 0:
|
| 189 |
+
raise ValueError("--conf-mode sinkhorn requires --sinkhorn-weight > 0")
|
| 190 |
+
if args.cosine_decay and args.cooldown_start > 0:
|
| 191 |
+
raise ValueError("--cosine-decay and --cooldown-start are mutually exclusive")
|
| 192 |
+
|
| 193 |
+
device = torch.device("cpu" if args.cpu else ("cuda" if torch.cuda.is_available() else "cpu"))
|
| 194 |
+
print(f"Device: {device}")
|
| 195 |
+
torch.manual_seed(args.seed)
|
| 196 |
+
np.random.seed(args.seed)
|
| 197 |
+
|
| 198 |
+
# Output
|
| 199 |
+
import hashlib, os
|
| 200 |
+
args_hash = hashlib.md5(json.dumps(vars(args), sort_keys=True).encode()).hexdigest()[:4]
|
| 201 |
+
run_tag = time.strftime("%Y%m%d_%H%M%S") + f"_{args_hash}_{os.getpid() % 10000:04d}"
|
| 202 |
+
out_dir = Path(args.out_dir) / run_tag
|
| 203 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 204 |
+
(out_dir / "checkpoints").mkdir(exist_ok=True)
|
| 205 |
+
|
| 206 |
+
# Tee stdout/stderr to run dir
|
| 207 |
+
import sys as _sys
|
| 208 |
+
_log_path = out_dir / "train.log"
|
| 209 |
+
class _Tee:
|
| 210 |
+
def __init__(self, path, stream):
|
| 211 |
+
self._file = open(path, "a")
|
| 212 |
+
self._stream = stream
|
| 213 |
+
def write(self, data):
|
| 214 |
+
self._stream.write(data)
|
| 215 |
+
self._file.write(data)
|
| 216 |
+
self._file.flush()
|
| 217 |
+
def flush(self):
|
| 218 |
+
self._stream.flush()
|
| 219 |
+
self._file.flush()
|
| 220 |
+
_sys.stdout = _Tee(_log_path, _sys.stdout)
|
| 221 |
+
_sys.stderr = _Tee(_log_path, _sys.stderr)
|
| 222 |
+
|
| 223 |
+
git_sha = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True,
|
| 224 |
+
cwd=str(_Path(__file__).parent)).stdout.strip()
|
| 225 |
+
git_dirty = subprocess.run(["git", "diff", "--quiet"], capture_output=True,
|
| 226 |
+
cwd=str(_Path(__file__).parent)).returncode != 0
|
| 227 |
+
run_info = {**vars(args), "git_sha": git_sha, "git_dirty": git_dirty}
|
| 228 |
+
(out_dir / "args.json").write_text(json.dumps(run_info, indent=2, sort_keys=True) + "\n")
|
| 229 |
+
|
| 230 |
+
# Set varifold cross-only mode before compile
|
| 231 |
+
if args.varifold_cross_only:
|
| 232 |
+
from . import losses as L
|
| 233 |
+
L.VARIFOLD_CROSS_ONLY = True
|
| 234 |
+
print("Varifold: cross-only mode (no self-energy)")
|
| 235 |
+
|
| 236 |
+
# Model
|
| 237 |
+
seq_len = args.seq_len
|
| 238 |
+
norm_class = torch.nn.RMSNorm if args.rms_norm else None
|
| 239 |
+
seq_cfg = EdgeDepthSequenceConfig(seq_len=seq_len)
|
| 240 |
+
model = EdgeDepthSegmentsModel(
|
| 241 |
+
seq_cfg=seq_cfg, segments=args.segments, hidden=args.hidden,
|
| 242 |
+
num_heads=args.num_heads, kv_heads_cross=args.kv_heads_cross,
|
| 243 |
+
kv_heads_self=args.kv_heads_self,
|
| 244 |
+
dim_feedforward=args.ff, dropout=args.dropout,
|
| 245 |
+
latent_tokens=args.latent_tokens, latent_layers=args.latent_layers,
|
| 246 |
+
decoder_layers=args.decoder_layers, cross_attn_interval=args.cross_attn_interval,
|
| 247 |
+
norm_class=norm_class, activation=args.activation,
|
| 248 |
+
segment_conf=args.segment_conf,
|
| 249 |
+
segment_param=args.segment_param,
|
| 250 |
+
length_floor=args.length_floor,
|
| 251 |
+
arch=args.arch, encoder_layers=args.encoder_layers,
|
| 252 |
+
pre_encoder_layers=args.pre_encoder_layers,
|
| 253 |
+
behind_emb_dim=args.behind_emb_dim,
|
| 254 |
+
use_vote_features=args.vote_features,
|
| 255 |
+
decoder_input_xattn=args.decoder_input_xattn,
|
| 256 |
+
qk_norm=args.qk_norm,
|
| 257 |
+
qk_norm_type=args.qk_norm_type,
|
| 258 |
+
learnable_fourier=args.learnable_fourier,
|
| 259 |
+
).to(device)
|
| 260 |
+
|
| 261 |
+
try:
|
| 262 |
+
from torchinfo import summary
|
| 263 |
+
summary(model.segmenter,
|
| 264 |
+
input_data=[torch.zeros(1, seq_len, model.tokenizer.out_dim, device=device),
|
| 265 |
+
torch.ones(1, seq_len, device=device, dtype=torch.bool)],
|
| 266 |
+
col_names=("input_size", "output_size", "num_params"), verbose=1)
|
| 267 |
+
except ImportError:
|
| 268 |
+
pass
|
| 269 |
+
print(f"Total params: {sum(p.numel() for p in model.parameters()):,}")
|
| 270 |
+
|
| 271 |
+
# Compile (skip in deterministic mode for bit-reproducibility)
|
| 272 |
+
torch.set_float32_matmul_precision("high")
|
| 273 |
+
if args.deterministic:
|
| 274 |
+
torch.use_deterministic_algorithms(True)
|
| 275 |
+
torch.backends.cudnn.deterministic = True
|
| 276 |
+
torch.backends.cudnn.benchmark = False
|
| 277 |
+
import os
|
| 278 |
+
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":16:8")
|
| 279 |
+
print("Deterministic mode: no torch.compile, bit-reproducible but ~3x slower")
|
| 280 |
+
elif device.type == "cuda":
|
| 281 |
+
model.segmenter = torch.compile(model.segmenter, mode="reduce-overhead", fullgraph=True)
|
| 282 |
+
from . import losses as L
|
| 283 |
+
L._loss_fn = torch.compile(_loss_inner, mode="reduce-overhead", fullgraph=True)
|
| 284 |
+
print("Compiled model + loss (reduce-overhead, fullgraph)")
|
| 285 |
+
|
| 286 |
+
# EMA
|
| 287 |
+
ema_model = None
|
| 288 |
+
if args.ema_decay > 0:
|
| 289 |
+
from copy import deepcopy
|
| 290 |
+
ema_model = deepcopy(model).eval()
|
| 291 |
+
for p_ema in ema_model.parameters():
|
| 292 |
+
p_ema.requires_grad_(False)
|
| 293 |
+
print(f"EMA enabled (decay={args.ema_decay})")
|
| 294 |
+
|
| 295 |
+
# Resume
|
| 296 |
+
start_step = 0
|
| 297 |
+
if args.resume:
|
| 298 |
+
ckpt = torch.load(args.resume, map_location=device, weights_only=False)
|
| 299 |
+
try:
|
| 300 |
+
model.load_state_dict(ckpt["model"])
|
| 301 |
+
except RuntimeError:
|
| 302 |
+
state = {k.replace("segmenter._orig_mod.", "segmenter."): v
|
| 303 |
+
for k, v in ckpt["model"].items()}
|
| 304 |
+
model.load_state_dict(state)
|
| 305 |
+
start_step = ckpt.get("step", 0)
|
| 306 |
+
print(f"Resumed from {args.resume} at step {start_step}")
|
| 307 |
+
|
| 308 |
+
betas = tuple(float(x) for x in args.adam_betas.split(","))
|
| 309 |
+
|
| 310 |
+
# Optimizer: AdamW with optional separate conf_head weight decay
|
| 311 |
+
conf_wd = args.conf_head_wd if args.conf_head_wd is not None else args.weight_decay
|
| 312 |
+
if args.conf_head_wd is not None:
|
| 313 |
+
conf_decay_params = []
|
| 314 |
+
other_params = []
|
| 315 |
+
for name, param in model.named_parameters():
|
| 316 |
+
if not param.requires_grad:
|
| 317 |
+
continue
|
| 318 |
+
if 'conf_head' in name:
|
| 319 |
+
conf_decay_params.append(param)
|
| 320 |
+
else:
|
| 321 |
+
other_params.append(param)
|
| 322 |
+
param_groups = [
|
| 323 |
+
{"params": other_params, "weight_decay": args.weight_decay},
|
| 324 |
+
{"params": conf_decay_params, "weight_decay": conf_wd},
|
| 325 |
+
]
|
| 326 |
+
print(f"Conf head WD: {conf_wd} ({len(conf_decay_params)} params)")
|
| 327 |
+
else:
|
| 328 |
+
param_groups = model.parameters()
|
| 329 |
+
|
| 330 |
+
opt = torch.optim.AdamW(param_groups, lr=args.lr, weight_decay=args.weight_decay,
|
| 331 |
+
betas=betas)
|
| 332 |
+
if args.resume and "optimizer" in ckpt:
|
| 333 |
+
opt.load_state_dict(ckpt["optimizer"])
|
| 334 |
+
|
| 335 |
+
# Data
|
| 336 |
+
torch.manual_seed(args.seed + 7919)
|
| 337 |
+
np.random.seed(args.seed + 7919)
|
| 338 |
+
train_loader = build_loader(args.cache_dir, args.batch_size, aug_rotate=args.aug_rotate,
|
| 339 |
+
aug_jitter=args.aug_jitter, aug_drop=args.aug_drop,
|
| 340 |
+
aug_flip=args.aug_flip)
|
| 341 |
+
val_loader = build_loader(args.val_cache_dir, args.batch_size) if args.val_cache_dir else None
|
| 342 |
+
data_iter = iter(train_loader)
|
| 343 |
+
|
| 344 |
+
# Intervals
|
| 345 |
+
log_int = max(1, min(50, args.steps // 20))
|
| 346 |
+
ckpt_int = 5000
|
| 347 |
+
val_int = ckpt_int if val_loader else 0
|
| 348 |
+
|
| 349 |
+
# Training loop
|
| 350 |
+
global_step = start_step
|
| 351 |
+
loss_ema, loss_sq_ema = 0.0, 0.0
|
| 352 |
+
t_start = time.perf_counter()
|
| 353 |
+
|
| 354 |
+
print(f"Training for {args.steps} steps | {args.segments}seg "
|
| 355 |
+
f"{args.hidden}h {args.latent_tokens}x{args.latent_layers}L "
|
| 356 |
+
f"{args.decoder_layers}D")
|
| 357 |
+
|
| 358 |
+
# Pre-fetch first batch
|
| 359 |
+
try:
|
| 360 |
+
next_batch = next(data_iter)
|
| 361 |
+
except StopIteration:
|
| 362 |
+
data_iter = iter(train_loader)
|
| 363 |
+
next_batch = next(data_iter)
|
| 364 |
+
|
| 365 |
+
# Freeze GC after setup to eliminate stalls during training
|
| 366 |
+
gc.collect()
|
| 367 |
+
gc.freeze()
|
| 368 |
+
gc.disable()
|
| 369 |
+
|
| 370 |
+
amp_ctx = torch.autocast(device_type='cuda', dtype=torch.bfloat16,
|
| 371 |
+
enabled=(device.type == 'cuda'))
|
| 372 |
+
|
| 373 |
+
while global_step < args.steps:
|
| 374 |
+
tokens, masks, gt_list, scales, meta = build_tokens(next_batch, model, device)
|
| 375 |
+
|
| 376 |
+
# Epsilon annealing
|
| 377 |
+
if args.sinkhorn_eps_start is not None and args.sinkhorn_eps_start != args.sinkhorn_eps:
|
| 378 |
+
if args.sinkhorn_eps_schedule == "sqrt":
|
| 379 |
+
ratio_sq = (args.sinkhorn_eps_start / args.sinkhorn_eps) ** 2
|
| 380 |
+
t0 = max(args.steps * 0.8 / max(ratio_sq - 1, 1e-6), 1.0)
|
| 381 |
+
current_eps = args.sinkhorn_eps_start / math.sqrt(1 + global_step / t0)
|
| 382 |
+
current_eps = max(current_eps, args.sinkhorn_eps)
|
| 383 |
+
else:
|
| 384 |
+
frac = min(global_step / max(args.steps * 0.8, 1), 1.0)
|
| 385 |
+
current_eps = args.sinkhorn_eps_start + frac * (args.sinkhorn_eps - args.sinkhorn_eps_start)
|
| 386 |
+
else:
|
| 387 |
+
current_eps = args.sinkhorn_eps
|
| 388 |
+
|
| 389 |
+
with amp_ctx:
|
| 390 |
+
out = model.forward_tokens(tokens, masks)
|
| 391 |
+
pred = out["segments"]
|
| 392 |
+
conf = out.get("conf")
|
| 393 |
+
|
| 394 |
+
# Endpoint weight warmup
|
| 395 |
+
if args.endpoint_warmup > 0 and global_step < args.endpoint_warmup:
|
| 396 |
+
current_ep_w = args.endpoint_weight * global_step / args.endpoint_warmup
|
| 397 |
+
else:
|
| 398 |
+
current_ep_w = args.endpoint_weight
|
| 399 |
+
|
| 400 |
+
loss, terms = compute_loss(pred, gt_list, scales.to(device), device,
|
| 401 |
+
args.varifold_weight, args.sinkhorn_weight,
|
| 402 |
+
endpoint_w=current_ep_w,
|
| 403 |
+
conf_logits=conf, conf_weight=args.conf_weight,
|
| 404 |
+
conf_mode=args.conf_mode,
|
| 405 |
+
sinkhorn_eps=current_eps,
|
| 406 |
+
sinkhorn_iters=args.sinkhorn_iters,
|
| 407 |
+
sinkhorn_dustbin=args.sinkhorn_dustbin,
|
| 408 |
+
conf_clamp_min=args.conf_clamp_min)
|
| 409 |
+
|
| 410 |
+
loss_val = loss.item()
|
| 411 |
+
# Adaptive loss spike detection
|
| 412 |
+
if global_step < 100:
|
| 413 |
+
loss_ema = loss_val if global_step == start_step else 0.9 * loss_ema + 0.1 * loss_val
|
| 414 |
+
loss_sq_ema = loss_val**2 if global_step == start_step else 0.9 * loss_sq_ema + 0.1 * loss_val**2
|
| 415 |
+
else:
|
| 416 |
+
loss_ema = 0.99 * loss_ema + 0.01 * loss_val
|
| 417 |
+
loss_sq_ema = 0.99 * loss_sq_ema + 0.01 * loss_val**2
|
| 418 |
+
loss_std = max(math.sqrt(max(loss_sq_ema - loss_ema**2, 0)), 1e-6)
|
| 419 |
+
spike_thresh = loss_ema + 5 * loss_std
|
| 420 |
+
|
| 421 |
+
# Skip on total loss spike or NaN
|
| 422 |
+
if not math.isfinite(loss_val) or loss_val > max(spike_thresh, 0.5):
|
| 423 |
+
sample_ids = [m.get("sample_id", "?") for m in meta]
|
| 424 |
+
skip_reason = f"loss={loss_val:.2f} > thresh={spike_thresh:.2f}"
|
| 425 |
+
print(f"Step {global_step}: {skip_reason}, skipping (samples: {sample_ids[:3]})")
|
| 426 |
+
with open(out_dir / "skipped_samples.jsonl", "a") as f:
|
| 427 |
+
f.write(json.dumps({"step": global_step, "reason": skip_reason,
|
| 428 |
+
"samples": sample_ids}) + "\n")
|
| 429 |
+
try:
|
| 430 |
+
next_batch = next(data_iter)
|
| 431 |
+
except StopIteration:
|
| 432 |
+
data_iter = iter(train_loader)
|
| 433 |
+
next_batch = next(data_iter)
|
| 434 |
+
continue
|
| 435 |
+
|
| 436 |
+
opt.zero_grad()
|
| 437 |
+
loss.backward()
|
| 438 |
+
|
| 439 |
+
# Fetch next batch while GPU finishes backward
|
| 440 |
+
try:
|
| 441 |
+
next_batch = next(data_iter)
|
| 442 |
+
except StopIteration:
|
| 443 |
+
data_iter = iter(train_loader)
|
| 444 |
+
next_batch = next(data_iter)
|
| 445 |
+
|
| 446 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
|
| 447 |
+
|
| 448 |
+
# LR schedule: warmup -> constant -> optional cooldown or cosine
|
| 449 |
+
if global_step < args.warmup:
|
| 450 |
+
lr = args.lr * (global_step + 1) / max(1, args.warmup)
|
| 451 |
+
elif args.cosine_decay:
|
| 452 |
+
progress = (global_step - args.warmup) / max(1, args.steps - args.warmup)
|
| 453 |
+
lr = args.lr * (0.01 + 0.99 * 0.5 * (1 + math.cos(math.pi * progress)))
|
| 454 |
+
elif args.cooldown_start > 0 and global_step >= args.cooldown_start:
|
| 455 |
+
progress = (global_step - args.cooldown_start) / max(1, args.cooldown_steps)
|
| 456 |
+
lr = args.lr * max(0.01, 1.0 - 0.99 * min(1.0, progress))
|
| 457 |
+
else:
|
| 458 |
+
lr = args.lr
|
| 459 |
+
for pg in opt.param_groups:
|
| 460 |
+
pg["lr"] = lr
|
| 461 |
+
opt.step()
|
| 462 |
+
global_step += 1
|
| 463 |
+
|
| 464 |
+
# EMA update
|
| 465 |
+
if ema_model is not None:
|
| 466 |
+
decay = args.ema_decay
|
| 467 |
+
with torch.no_grad():
|
| 468 |
+
for p_ema, p_model in zip(ema_model.parameters(), model.parameters()):
|
| 469 |
+
p_ema.lerp_(p_model, 1.0 - decay)
|
| 470 |
+
|
| 471 |
+
# Log
|
| 472 |
+
entry = {"step": global_step, "ts": time.time(), "loss": loss.item(), "lr": lr}
|
| 473 |
+
entry.update({k: v.item() for k, v in terms.items()})
|
| 474 |
+
if global_step % log_int == 0:
|
| 475 |
+
grad_norm = sum(p.grad.norm().item()**2 for p in model.parameters()
|
| 476 |
+
if p.grad is not None) ** 0.5
|
| 477 |
+
entry["grad_norm"] = grad_norm
|
| 478 |
+
|
| 479 |
+
if global_step % log_int == 0:
|
| 480 |
+
ms = (time.perf_counter() - t_start) / log_int * 1000
|
| 481 |
+
t_start = time.perf_counter()
|
| 482 |
+
t_str = " ".join(f"{k}={v:.4f}" for k, v in terms.items())
|
| 483 |
+
print(f"[{global_step}/{args.steps}] loss={loss.item():.4f} {t_str} "
|
| 484 |
+
f"lr={lr:.2e} gnorm={entry.get('grad_norm', 0):.3f} [{ms:.0f}ms/step]")
|
| 485 |
+
|
| 486 |
+
if val_int > 0 and global_step % val_int == 0:
|
| 487 |
+
try:
|
| 488 |
+
vl_list = []
|
| 489 |
+
with torch.no_grad(), amp_ctx:
|
| 490 |
+
for vb in val_loader:
|
| 491 |
+
vt, vm, vg, vs, _ = build_tokens(vb, model, device)
|
| 492 |
+
vo = model.forward_tokens(vt, vm)
|
| 493 |
+
vl, _ = compute_loss(vo["segments"], vg, vs.to(device), device,
|
| 494 |
+
args.varifold_weight, args.sinkhorn_weight)
|
| 495 |
+
if math.isfinite(vl.item()):
|
| 496 |
+
vl_list.append(vl.item())
|
| 497 |
+
if vl_list:
|
| 498 |
+
val_loss = float(np.mean(vl_list))
|
| 499 |
+
print(f" val_loss={val_loss:.4f}")
|
| 500 |
+
entry["val_loss"] = val_loss
|
| 501 |
+
except Exception as e:
|
| 502 |
+
print(f" val eval failed: {e}")
|
| 503 |
+
|
| 504 |
+
# Write log entry
|
| 505 |
+
with open(out_dir / "history.jsonl", "a") as f:
|
| 506 |
+
f.write(json.dumps(entry) + "\n")
|
| 507 |
+
|
| 508 |
+
if global_step % ckpt_int == 0:
|
| 509 |
+
try:
|
| 510 |
+
gc.enable(); gc.collect(); gc.freeze(); gc.disable()
|
| 511 |
+
torch.cuda.empty_cache()
|
| 512 |
+
save_dict = {"step": global_step, "model": model.state_dict(),
|
| 513 |
+
"optimizer": opt.state_dict(), "args": vars(args)}
|
| 514 |
+
if ema_model is not None:
|
| 515 |
+
save_dict["ema_model"] = ema_model.state_dict()
|
| 516 |
+
torch.save(save_dict, out_dir / "checkpoints" / f"step{global_step:06d}.pt")
|
| 517 |
+
except Exception as e:
|
| 518 |
+
print(f" checkpoint save failed: {e}")
|
| 519 |
+
|
| 520 |
+
# Final save
|
| 521 |
+
save_dict = {"step": global_step, "model": model.state_dict(),
|
| 522 |
+
"optimizer": opt.state_dict(), "args": vars(args)}
|
| 523 |
+
if ema_model is not None:
|
| 524 |
+
save_dict["ema_model"] = ema_model.state_dict()
|
| 525 |
+
torch.save(save_dict, out_dir / "checkpoints" / "final.pt")
|
| 526 |
+
print(f"Done. {global_step} steps. Output: {out_dir}")
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
if __name__ == "__main__":
|
| 530 |
+
main()
|
s23dr_2026_example/varifold.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
|
| 3 |
+
from .wire_varifold_kernels import (
|
| 4 |
+
loss_simpson3_batch,
|
| 5 |
+
loss_simpson3_mix_batch,
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def segments_to_vertices_edges(segments: torch.Tensor):
|
| 10 |
+
segs = torch.as_tensor(segments, dtype=torch.float32)
|
| 11 |
+
vertices = segs.reshape(-1, 3)
|
| 12 |
+
edges = [(2 * i, 2 * i + 1) for i in range(segs.shape[0])]
|
| 13 |
+
return vertices, edges
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def varifold_loss_batch(
|
| 17 |
+
pred_segments: torch.Tensor,
|
| 18 |
+
gt_segments: torch.Tensor,
|
| 19 |
+
*,
|
| 20 |
+
sigma: float = 0.1,
|
| 21 |
+
variant: str = "semi_lobatto3",
|
| 22 |
+
t_nodes01: torch.Tensor | None = None,
|
| 23 |
+
t_w: torch.Tensor | None = None,
|
| 24 |
+
sigmas: torch.Tensor | None = None,
|
| 25 |
+
alpha: torch.Tensor | None = None,
|
| 26 |
+
normalize_alpha: bool = True,
|
| 27 |
+
len_pow: float | None = None,
|
| 28 |
+
gt_mask: torch.Tensor | None = None,
|
| 29 |
+
pred_weights: torch.Tensor | None = None,
|
| 30 |
+
cross_only: bool = False,
|
| 31 |
+
) -> torch.Tensor:
|
| 32 |
+
if pred_segments.dim() != 4 or gt_segments.dim() != 4:
|
| 33 |
+
raise ValueError("pred_segments and gt_segments must be (B, N, 2, 3)")
|
| 34 |
+
p_pred, q_pred = pred_segments[:, :, 0], pred_segments[:, :, 1]
|
| 35 |
+
p_gt, q_gt = gt_segments[:, :, 0], gt_segments[:, :, 1]
|
| 36 |
+
|
| 37 |
+
w_gt = None
|
| 38 |
+
if gt_mask is not None:
|
| 39 |
+
w_gt = gt_mask.to(device=pred_segments.device, dtype=pred_segments.dtype)
|
| 40 |
+
|
| 41 |
+
w_pred = None
|
| 42 |
+
if pred_weights is not None:
|
| 43 |
+
w_pred = pred_weights.to(device=pred_segments.device, dtype=pred_segments.dtype)
|
| 44 |
+
|
| 45 |
+
if variant != "simpson3":
|
| 46 |
+
raise ValueError(
|
| 47 |
+
f"Unsupported varifold variant: {variant!r}. "
|
| 48 |
+
f"Only 'simpson3' is supported in batch mode.")
|
| 49 |
+
if sigmas is not None or alpha is not None:
|
| 50 |
+
if sigmas is None or alpha is None:
|
| 51 |
+
raise ValueError("sigmas and alpha are required for simpson3 mix")
|
| 52 |
+
return loss_simpson3_mix_batch(p_pred, q_pred, p_gt, q_gt, sigmas, alpha, w_gt=w_gt, w_pred=w_pred, normalize_alpha=normalize_alpha, cross_only=cross_only)
|
| 53 |
+
return loss_simpson3_batch(p_pred, q_pred, p_gt, q_gt, sigma, w_gt=w_gt, w_pred=w_pred)
|
s23dr_2026_example/wire_varifold_kernels.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
|
| 3 |
+
# -----------------------------
|
| 4 |
+
# Helpers
|
| 5 |
+
# -----------------------------
|
| 6 |
+
def segment_geom(p: torch.Tensor, q: torch.Tensor, eps: float = 1e-9):
|
| 7 |
+
"""
|
| 8 |
+
p,q: (...,3)
|
| 9 |
+
returns d, a, ell, u:
|
| 10 |
+
d = q - p
|
| 11 |
+
a = ||d||^2
|
| 12 |
+
ell = sqrt(a + eps^2)
|
| 13 |
+
u = d / ell
|
| 14 |
+
"""
|
| 15 |
+
d = q - p
|
| 16 |
+
a = (d * d).sum(dim=-1)
|
| 17 |
+
eps_val = eps
|
| 18 |
+
if p.dtype in (torch.float16, torch.bfloat16):
|
| 19 |
+
eps_val = max(eps, float(torch.finfo(p.dtype).eps))
|
| 20 |
+
ell = torch.sqrt(a + eps_val * eps_val)
|
| 21 |
+
u = d / ell.unsqueeze(-1)
|
| 22 |
+
return d, a, ell, u
|
| 23 |
+
|
| 24 |
+
def sample_points(p: torch.Tensor, q: torch.Tensor, nodes01: torch.Tensor):
|
| 25 |
+
# (...,3) + (K,) -> (...,K,3)
|
| 26 |
+
d = q - p
|
| 27 |
+
nodes = nodes01.to(device=p.device, dtype=p.dtype)
|
| 28 |
+
shape = [1] * (p.dim() - 1) + [nodes.shape[0], 1]
|
| 29 |
+
nodes = nodes.view(*shape)
|
| 30 |
+
return p.unsqueeze(-2) + nodes * d.unsqueeze(-2)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# Fixed Lobatto-3 / Simpson nodes+weights on [0,1]
|
| 34 |
+
LOBATTO3_NODES = torch.tensor([0.0, 0.5, 1.0])
|
| 35 |
+
# LOBATTO3_W = torch.tensor([1.0/6.0, 4.0/6.0, 1.0/6.0])
|
| 36 |
+
LOBATTO3_W = torch.tensor([1/3, 1/3, 1/3])
|
| 37 |
+
LOBATTO3_W2 = LOBATTO3_W[:, None] * LOBATTO3_W[None, :] # (3,3)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _prepare_mix_weights(sigmas, alpha, device, dtype, normalize_alpha: bool):
|
| 41 |
+
sigmas_t = torch.as_tensor(sigmas, device=device, dtype=dtype).clamp_min(1e-6)
|
| 42 |
+
alpha_t = torch.as_tensor(alpha, device=device, dtype=dtype)
|
| 43 |
+
if normalize_alpha:
|
| 44 |
+
alpha_t = alpha_t / alpha_t.sum().clamp_min(1e-12)
|
| 45 |
+
return sigmas_t, alpha_t
|
| 46 |
+
|
| 47 |
+
# -----------------------------
|
| 48 |
+
# Simpson-3 on both segments (3x3 product rule)
|
| 49 |
+
# -----------------------------
|
| 50 |
+
def _prep_weight(w, n: int, b: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor | None:
|
| 51 |
+
if w is None:
|
| 52 |
+
return None
|
| 53 |
+
w = torch.as_tensor(w, device=device, dtype=dtype)
|
| 54 |
+
if w.dim() == 1:
|
| 55 |
+
if w.shape[0] != n:
|
| 56 |
+
raise ValueError(f"weight length {w.shape[0]} != {n}")
|
| 57 |
+
w = w.unsqueeze(0).expand(b, -1)
|
| 58 |
+
elif w.dim() == 2:
|
| 59 |
+
if w.shape[0] != b or w.shape[1] != n:
|
| 60 |
+
raise ValueError(f"weight shape {tuple(w.shape)} != ({b}, {n})")
|
| 61 |
+
else:
|
| 62 |
+
raise ValueError("weights must be 1D or 2D")
|
| 63 |
+
return w
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def cross_simpson3(
|
| 67 |
+
pA,
|
| 68 |
+
qA,
|
| 69 |
+
pB,
|
| 70 |
+
qB,
|
| 71 |
+
sigma: float | torch.Tensor,
|
| 72 |
+
wA: torch.Tensor | None = None,
|
| 73 |
+
wB: torch.Tensor | None = None,
|
| 74 |
+
):
|
| 75 |
+
device, dtype = pA.device, pA.dtype
|
| 76 |
+
batched = pA.dim() == 3
|
| 77 |
+
if not batched:
|
| 78 |
+
pA = pA.unsqueeze(0)
|
| 79 |
+
qA = qA.unsqueeze(0)
|
| 80 |
+
pB = pB.unsqueeze(0)
|
| 81 |
+
qB = qB.unsqueeze(0)
|
| 82 |
+
nodes = LOBATTO3_NODES.to(device=device, dtype=dtype)
|
| 83 |
+
w2 = LOBATTO3_W2.to(device=device, dtype=dtype)
|
| 84 |
+
|
| 85 |
+
bsz, nA, _ = pA.shape
|
| 86 |
+
nB = pB.shape[1]
|
| 87 |
+
wA = _prep_weight(wA, nA, bsz, device, dtype)
|
| 88 |
+
wB = _prep_weight(wB, nB, bsz, device, dtype)
|
| 89 |
+
|
| 90 |
+
_, _, ellA, uA = segment_geom(pA, qA)
|
| 91 |
+
_, _, ellB, uB = segment_geom(pB, qB)
|
| 92 |
+
|
| 93 |
+
XA = sample_points(pA, qA, nodes) # (B,N,3,3)
|
| 94 |
+
YB = sample_points(pB, qB, nodes) # (B,M,3,3)
|
| 95 |
+
|
| 96 |
+
# angular + length factors: (N,M)
|
| 97 |
+
ang = torch.matmul(uA, uB.transpose(-1, -2)).pow(2)
|
| 98 |
+
lenfac = ellA[:, :, None] * ellB[:, None, :]
|
| 99 |
+
if wA is not None or wB is not None:
|
| 100 |
+
if wA is None:
|
| 101 |
+
wA = torch.ones((bsz, nA), device=device, dtype=dtype)
|
| 102 |
+
if wB is None:
|
| 103 |
+
wB = torch.ones((bsz, nB), device=device, dtype=dtype)
|
| 104 |
+
lenfac = lenfac * (wA[:, :, None] * wB[:, None, :])
|
| 105 |
+
|
| 106 |
+
# spatial: build (N,M,3,3) kernel via broadcasting
|
| 107 |
+
diff = XA[:, :, None, :, None, :] - YB[:, None, :, None, :, :] # (B,N,M,3,3,3)
|
| 108 |
+
r2 = (diff * diff).sum(dim=-1) # (B,N,M,3,3)
|
| 109 |
+
sigma_t = torch.as_tensor(sigma, device=device, dtype=dtype)
|
| 110 |
+
if sigma_t.ndim == 0:
|
| 111 |
+
inv2s2 = 1.0 / (2.0 * sigma_t * sigma_t)
|
| 112 |
+
else:
|
| 113 |
+
if sigma_t.shape[0] != bsz:
|
| 114 |
+
raise ValueError(f"sigma batch {sigma_t.shape[0]} != {bsz}")
|
| 115 |
+
inv2s2 = (1.0 / (2.0 * sigma_t * sigma_t)).view(bsz, 1, 1, 1, 1)
|
| 116 |
+
K = torch.exp(-r2 * inv2s2) # (B,N,M,3,3)
|
| 117 |
+
|
| 118 |
+
spatial = (K * w2).sum(dim=-1).sum(dim=-1) # (B,N,M)
|
| 119 |
+
out = (ang * lenfac * spatial).sum(dim=-1).sum(dim=-1) # (B,)
|
| 120 |
+
return out[0] if not batched else out
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# -----------------------------
|
| 124 |
+
# Batch losses
|
| 125 |
+
# -----------------------------
|
| 126 |
+
|
| 127 |
+
def loss_simpson3_batch(
|
| 128 |
+
p_pred: torch.Tensor,
|
| 129 |
+
q_pred: torch.Tensor,
|
| 130 |
+
p_gt: torch.Tensor,
|
| 131 |
+
q_gt: torch.Tensor,
|
| 132 |
+
sigma: float | torch.Tensor,
|
| 133 |
+
w_gt: torch.Tensor | None = None,
|
| 134 |
+
w_pred: torch.Tensor | None = None,
|
| 135 |
+
cross_only: bool = False,
|
| 136 |
+
) -> torch.Tensor:
|
| 137 |
+
cross = cross_simpson3(p_pred, q_pred, p_gt, q_gt, sigma, wA=w_pred, wB=w_gt)
|
| 138 |
+
if cross_only:
|
| 139 |
+
# No self-energy: avoids O(S^2) blowup, sinkhorn handles repulsion
|
| 140 |
+
return -2.0 * cross
|
| 141 |
+
s_pred = cross_simpson3(p_pred, q_pred, p_pred, q_pred, sigma, wA=w_pred, wB=w_pred)
|
| 142 |
+
return s_pred - 2.0 * cross
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def loss_simpson3_mix_batch(
|
| 146 |
+
p_pred: torch.Tensor,
|
| 147 |
+
q_pred: torch.Tensor,
|
| 148 |
+
p_gt: torch.Tensor,
|
| 149 |
+
q_gt: torch.Tensor,
|
| 150 |
+
sigmas,
|
| 151 |
+
alpha,
|
| 152 |
+
w_gt: torch.Tensor | None = None,
|
| 153 |
+
w_pred: torch.Tensor | None = None,
|
| 154 |
+
normalize_alpha: bool = True,
|
| 155 |
+
cross_only: bool = False,
|
| 156 |
+
) -> torch.Tensor:
|
| 157 |
+
device, dtype = p_pred.device, p_pred.dtype
|
| 158 |
+
sigmas_t = torch.as_tensor(sigmas, device=device, dtype=dtype).clamp_min(1e-6)
|
| 159 |
+
alpha_t = torch.as_tensor(alpha, device=device, dtype=dtype)
|
| 160 |
+
if normalize_alpha:
|
| 161 |
+
alpha_t = alpha_t / alpha_t.sum().clamp_min(1e-12)
|
| 162 |
+
if sigmas_t.ndim == 1:
|
| 163 |
+
losses = [loss_simpson3_batch(p_pred, q_pred, p_gt, q_gt, s, w_gt=w_gt, w_pred=w_pred, cross_only=cross_only) for s in sigmas_t]
|
| 164 |
+
return (torch.stack(losses, dim=0) * alpha_t[:, None]).sum(dim=0)
|
| 165 |
+
if sigmas_t.ndim == 2:
|
| 166 |
+
losses = [loss_simpson3_batch(p_pred, q_pred, p_gt, q_gt, sigmas_t[:, i], w_gt=w_gt, w_pred=w_pred, cross_only=cross_only) for i in range(sigmas_t.shape[1])]
|
| 167 |
+
return (torch.stack(losses, dim=0) * alpha_t[:, None]).sum(dim=0)
|
| 168 |
+
raise ValueError("sigmas must be 1D or 2D for batch loss")
|
script.py
CHANGED
|
@@ -1,131 +1,389 @@
|
|
| 1 |
-
"""S23DR 2026 submission
|
| 2 |
|
|
|
|
|
|
|
| 3 |
from pathlib import Path
|
| 4 |
from tqdm import tqdm
|
| 5 |
-
import numpy as np
|
| 6 |
-
from datasets import load_dataset
|
| 7 |
-
from typing import Dict
|
| 8 |
-
import os
|
| 9 |
import json
|
| 10 |
-
import
|
| 11 |
-
import subprocess
|
| 12 |
import sys
|
|
|
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
if current_dir not in sys.path:
|
| 17 |
-
sys.path.insert(0, current_dir)
|
| 18 |
|
| 19 |
-
def install_if_missing(package):
|
| 20 |
-
try:
|
| 21 |
-
__import__(package)
|
| 22 |
-
except ImportError:
|
| 23 |
-
subprocess.check_call([sys.executable, "-m", "pip", "install", package])
|
| 24 |
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
class Sample(Dict):
|
| 27 |
-
def __repr__(self):
|
| 28 |
-
return str({k: v.shape if hasattr(v, 'shape') else [type(v[0])] if isinstance(v, list) else type(v) for k, v in self.items()})
|
| 29 |
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
|
|
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
try:
|
| 37 |
-
|
| 38 |
-
pred_vertices, pred_edges = predict_wireframe_sklearn(sample, sklearn_model)
|
| 39 |
except Exception as e:
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
else:
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
snapshot_download(repo_id=params['dataset'], local_dir="/tmp/data", repo_type="dataset")
|
| 69 |
|
| 70 |
-
|
|
|
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
data_files["test"] = private_tars
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
dataset = load_dataset(
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
| 86 |
)
|
| 87 |
-
print(f"
|
| 88 |
|
| 89 |
-
#
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
| 106 |
|
| 107 |
-
|
| 108 |
solution = []
|
| 109 |
-
for
|
| 110 |
-
|
| 111 |
-
for i, sample in enumerate(tqdm(dataset[subset_name])):
|
| 112 |
-
res = process_sample(sample, i, sklearn_model)
|
| 113 |
-
solution.append(res)
|
| 114 |
-
if i % 50 == 0:
|
| 115 |
-
print(f" Processed {i} samples", flush=True)
|
| 116 |
-
|
| 117 |
-
print("------------ Saving results ---------------", flush=True)
|
| 118 |
-
output_path = Path(params.get('output_path', '.'))
|
| 119 |
-
|
| 120 |
-
with open(output_path / "submission.json", 'w') as f:
|
| 121 |
-
json.dump(solution, f)
|
| 122 |
-
print(f"Saved {len(solution)} predictions to submission.json", flush=True)
|
| 123 |
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
"""S23DR 2026 submission: learned wireframe prediction from fused point clouds.
|
| 2 |
|
| 3 |
+
Pipeline: raw sample -> point fusion -> priority sample 2048 -> model -> post-process -> wireframe
|
| 4 |
+
"""
|
| 5 |
from pathlib import Path
|
| 6 |
from tqdm import tqdm
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
import json
|
| 8 |
+
import os
|
|
|
|
| 9 |
import sys
|
| 10 |
+
import time
|
| 11 |
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
|
|
|
|
|
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
def empty_solution():
|
| 17 |
+
return np.zeros((2, 3)), [(0, 1)]
|
| 18 |
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# Point fusion + sampling (from cache_scenes.py / make_sampled_cache.py)
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
|
| 24 |
+
# Add our package to path
|
| 25 |
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
| 26 |
+
sys.path.insert(0, str(SCRIPT_DIR))
|
| 27 |
|
| 28 |
+
from s23dr_2026_example.point_fusion import build_compact_scene, FuserConfig
|
| 29 |
+
from s23dr_2026_example.cache_scenes import (
|
| 30 |
+
_compute_group_and_class, _compute_smart_center_scale,
|
| 31 |
+
)
|
| 32 |
+
from s23dr_2026_example.make_sampled_cache import _priority_sample
|
| 33 |
|
| 34 |
+
# Tokenizer / model imports
|
| 35 |
+
from s23dr_2026_example.tokenizer import EdgeDepthSequenceConfig
|
| 36 |
+
from s23dr_2026_example.model import EdgeDepthSegmentsModel
|
| 37 |
+
from s23dr_2026_example.segment_postprocess import merge_vertices_iterative
|
| 38 |
+
from s23dr_2026_example.varifold import segments_to_vertices_edges
|
| 39 |
+
from s23dr_2026_example.postprocess_v2 import snap_to_point_cloud, snap_horizontal
|
| 40 |
+
|
| 41 |
+
SEQ_LEN = 4096
|
| 42 |
+
COLMAP_QUOTA = 3072
|
| 43 |
+
DEPTH_QUOTA = 1024
|
| 44 |
+
CONF_THRESH = 0.5
|
| 45 |
+
MERGE_THRESH = 0.4
|
| 46 |
+
SNAP_RADIUS = 0.5
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def fuse_and_sample(sample, cfg, rng):
|
| 50 |
+
"""Run point fusion + priority sampling on a raw dataset sample.
|
| 51 |
+
|
| 52 |
+
Returns a dict with xyz_norm, class_id, source, mask, center, scale, etc.
|
| 53 |
+
ready for model inference. Returns None if fusion fails.
|
| 54 |
+
"""
|
| 55 |
try:
|
| 56 |
+
scene = build_compact_scene(sample, cfg, rng)
|
|
|
|
| 57 |
except Exception as e:
|
| 58 |
+
print(f" Fusion failed: {e}")
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
xyz = scene["xyz"]
|
| 62 |
+
source = scene["source"]
|
| 63 |
+
|
| 64 |
+
if len(xyz) < 10:
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
# Compute group_id and class_id (same as cache_scenes.py)
|
| 68 |
+
behind_id = scene.get("behind_gest_id", np.full(len(xyz), -1, dtype=np.int16))
|
| 69 |
+
group_id, class_id = _compute_group_and_class(
|
| 70 |
+
scene["visible_src"], scene["visible_id"], behind_id, source)
|
| 71 |
+
|
| 72 |
+
# Normalize
|
| 73 |
+
center, scale = _compute_smart_center_scale(xyz, source)
|
| 74 |
+
|
| 75 |
+
# Priority sample
|
| 76 |
+
indices, mask = _priority_sample(source, group_id, SEQ_LEN, COLMAP_QUOTA, DEPTH_QUOTA)
|
| 77 |
+
|
| 78 |
+
xyz_norm = (xyz[indices] - center) / scale
|
| 79 |
+
|
| 80 |
+
result = {
|
| 81 |
+
"xyz_norm": xyz_norm.astype(np.float32),
|
| 82 |
+
"class_id": class_id[indices].astype(np.int64),
|
| 83 |
+
"source": source[indices].astype(np.int64),
|
| 84 |
+
"mask": mask,
|
| 85 |
+
"center": center.astype(np.float32),
|
| 86 |
+
"scale": np.float32(scale),
|
| 87 |
}
|
| 88 |
|
| 89 |
+
# Optional fields
|
| 90 |
+
if "behind_gest_id" in scene:
|
| 91 |
+
behind = np.clip(scene["behind_gest_id"][indices].astype(np.int16), 0, None)
|
| 92 |
+
result["behind"] = behind.astype(np.int64)
|
| 93 |
+
if "n_views_voted" in scene:
|
| 94 |
+
result["n_views_voted"] = scene["n_views_voted"][indices].astype(np.float32)
|
| 95 |
+
if "vote_frac" in scene:
|
| 96 |
+
result["vote_frac"] = scene["vote_frac"][indices].astype(np.float32)
|
| 97 |
|
| 98 |
+
# Visible src/id for snap post-processing
|
| 99 |
+
result["visible_src"] = scene["visible_src"][indices].astype(np.int64)
|
| 100 |
+
result["visible_id"] = scene["visible_id"][indices].astype(np.int64)
|
| 101 |
+
|
| 102 |
+
return result
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def load_model(checkpoint_path, device):
|
| 106 |
+
"""Load model from checkpoint."""
|
| 107 |
+
ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 108 |
+
args = ckpt.get("args", {})
|
| 109 |
+
|
| 110 |
+
norm_class = torch.nn.RMSNorm if args.get("rms_norm") else None
|
| 111 |
+
seq_cfg = EdgeDepthSequenceConfig(
|
| 112 |
+
seq_len=SEQ_LEN, colmap_points=COLMAP_QUOTA, depth_points=DEPTH_QUOTA)
|
| 113 |
+
|
| 114 |
+
model = EdgeDepthSegmentsModel(
|
| 115 |
+
seq_cfg=seq_cfg,
|
| 116 |
+
segments=args.get("segments", 64),
|
| 117 |
+
hidden=args.get("hidden", 256),
|
| 118 |
+
num_heads=args.get("num_heads", 4),
|
| 119 |
+
kv_heads_cross=args.get("kv_heads_cross", 2),
|
| 120 |
+
kv_heads_self=args.get("kv_heads_self", 2),
|
| 121 |
+
dim_feedforward=args.get("ff", 1024),
|
| 122 |
+
dropout=args.get("dropout", 0.1),
|
| 123 |
+
latent_tokens=args.get("latent_tokens", 256),
|
| 124 |
+
latent_layers=args.get("latent_layers", 7),
|
| 125 |
+
decoder_layers=args.get("decoder_layers", 3),
|
| 126 |
+
cross_attn_interval=args.get("cross_attn_interval", 4),
|
| 127 |
+
norm_class=norm_class,
|
| 128 |
+
activation=args.get("activation", "gelu"),
|
| 129 |
+
segment_conf=args.get("segment_conf", True),
|
| 130 |
+
behind_emb_dim=args.get("behind_emb_dim", 8),
|
| 131 |
+
use_vote_features=args.get("vote_features", True),
|
| 132 |
+
arch=args.get("arch", "perceiver"),
|
| 133 |
+
encoder_layers=args.get("encoder_layers", 4),
|
| 134 |
+
pre_encoder_layers=args.get("pre_encoder_layers", 0),
|
| 135 |
+
segment_param=args.get("segment_param", "midpoint_dir_len"),
|
| 136 |
+
qk_norm=args.get("qk_norm", True),
|
| 137 |
+
).to(device)
|
| 138 |
+
|
| 139 |
+
# Handle torch.compile _orig_mod prefix
|
| 140 |
+
state = ckpt["model"]
|
| 141 |
+
fixed = {k.replace("segmenter._orig_mod.", "segmenter."): v
|
| 142 |
+
for k, v in state.items()}
|
| 143 |
+
model.load_state_dict(fixed, strict=True)
|
| 144 |
+
model.eval()
|
| 145 |
+
return model
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def build_tokens_single(sample_dict, model, device):
|
| 149 |
+
"""Build token tensor for a single sample (no DataLoader)."""
|
| 150 |
+
xyz = torch.as_tensor(sample_dict["xyz_norm"], dtype=torch.float32).unsqueeze(0).to(device)
|
| 151 |
+
cid = torch.as_tensor(sample_dict["class_id"], dtype=torch.long).unsqueeze(0).to(device)
|
| 152 |
+
src = torch.as_tensor(sample_dict["source"], dtype=torch.long).unsqueeze(0).to(device)
|
| 153 |
+
masks = torch.as_tensor(sample_dict["mask"], dtype=torch.bool).unsqueeze(0).to(device)
|
| 154 |
+
|
| 155 |
+
B, T, _ = xyz.shape
|
| 156 |
+
tok = model.tokenizer
|
| 157 |
+
fourier = tok.pos_enc(xyz.reshape(-1, 3)).reshape(B, T, -1) \
|
| 158 |
+
if tok.pos_enc is not None else xyz.new_zeros(B, T, 0)
|
| 159 |
+
parts = [xyz, fourier, tok.label_emb(cid), tok.src_emb(src.clamp(0, 1))]
|
| 160 |
+
|
| 161 |
+
if tok.behind_emb_dim > 0:
|
| 162 |
+
if "behind" in sample_dict:
|
| 163 |
+
beh = torch.as_tensor(sample_dict["behind"], dtype=torch.long).unsqueeze(0).to(device)
|
| 164 |
+
else:
|
| 165 |
+
beh = xyz.new_zeros(B, T, dtype=torch.long)
|
| 166 |
+
parts.append(tok.behind_emb(beh))
|
| 167 |
+
|
| 168 |
+
if tok.use_vote_features:
|
| 169 |
+
if "n_views_voted" in sample_dict and "vote_frac" in sample_dict:
|
| 170 |
+
nv = ((torch.as_tensor(sample_dict["n_views_voted"], dtype=torch.float32).unsqueeze(0).to(device) - 2.7) / 1.0).unsqueeze(-1)
|
| 171 |
+
vf = ((torch.as_tensor(sample_dict["vote_frac"], dtype=torch.float32).unsqueeze(0).to(device) - 0.5) / 0.25).unsqueeze(-1)
|
| 172 |
+
parts.extend([nv, vf])
|
| 173 |
+
else:
|
| 174 |
+
parts.extend([xyz.new_zeros(B, T, 1), xyz.new_zeros(B, T, 1)])
|
| 175 |
+
|
| 176 |
+
tokens = torch.cat(parts, dim=-1)
|
| 177 |
+
return tokens, masks
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def predict_sample(sample_dict, model, device):
|
| 181 |
+
"""Run model inference + post-processing on a fused sample.
|
| 182 |
+
|
| 183 |
+
Returns (vertices, edges) in world space.
|
| 184 |
+
"""
|
| 185 |
+
tokens, masks = build_tokens_single(sample_dict, model, device)
|
| 186 |
+
scale = float(sample_dict["scale"])
|
| 187 |
+
center = sample_dict["center"]
|
| 188 |
+
|
| 189 |
+
with torch.no_grad(), torch.autocast(device_type='cuda', dtype=torch.float16,
|
| 190 |
+
enabled=(device.type == 'cuda')):
|
| 191 |
+
out = model.forward_tokens(tokens, masks)
|
| 192 |
+
|
| 193 |
+
segs = out["segments"][0].float().cpu()
|
| 194 |
+
conf = torch.sigmoid(out["conf"][0].float()).cpu().numpy() if "conf" in out else None
|
| 195 |
+
|
| 196 |
+
# Confidence filter
|
| 197 |
+
if conf is not None:
|
| 198 |
+
keep = conf > CONF_THRESH
|
| 199 |
+
segs = segs[keep]
|
| 200 |
+
if len(segs) < 1:
|
| 201 |
+
return empty_solution()
|
| 202 |
+
|
| 203 |
+
# To world space
|
| 204 |
+
segs_world = segs.numpy() * scale + center
|
| 205 |
+
|
| 206 |
+
# Vertices + edges from segments
|
| 207 |
+
pv, pe = segments_to_vertices_edges(torch.tensor(segs_world))
|
| 208 |
+
pv, pe = pv.numpy(), np.array(pe, dtype=np.int32)
|
| 209 |
+
|
| 210 |
+
# Merge
|
| 211 |
+
pv, pe = merge_vertices_iterative(pv, pe)
|
| 212 |
+
|
| 213 |
+
# Snap to point cloud
|
| 214 |
+
xyz_norm = sample_dict["xyz_norm"]
|
| 215 |
+
mask = sample_dict["mask"]
|
| 216 |
+
cid = sample_dict["class_id"]
|
| 217 |
+
xyz_world = xyz_norm[mask] * scale + center
|
| 218 |
+
cid_valid = cid[mask]
|
| 219 |
+
pv = snap_to_point_cloud(pv, xyz_world, cid_valid, snap_radius=SNAP_RADIUS)
|
| 220 |
+
|
| 221 |
+
# Horizontal snap
|
| 222 |
+
pv = snap_horizontal(pv, pe)
|
| 223 |
+
|
| 224 |
+
if len(pv) < 2 or len(pe) < 1:
|
| 225 |
+
return empty_solution()
|
| 226 |
+
|
| 227 |
+
edges = [(int(a), int(b)) for a, b in pe]
|
| 228 |
+
return pv, edges
|
| 229 |
+
|
| 230 |
+
def hybrid_merge(pred_v, pred_e, track_v, track_e, merge_radius=0.8):
|
| 231 |
+
if len(track_v) == 0:
|
| 232 |
+
return pred_v, pred_e
|
| 233 |
+
|
| 234 |
+
pred_v = np.array(pred_v) if isinstance(pred_v, list) else pred_v
|
| 235 |
+
track_v = np.array(track_v)
|
| 236 |
+
|
| 237 |
+
# We will append track vertices that are NOT close to any pred_v
|
| 238 |
+
if len(pred_v) > 0:
|
| 239 |
+
from scipy.spatial import cKDTree
|
| 240 |
+
tree = cKDTree(pred_v)
|
| 241 |
+
dists, indices = tree.query(track_v, k=1)
|
| 242 |
else:
|
| 243 |
+
dists = np.full(len(track_v), np.inf)
|
| 244 |
+
indices = np.zeros(len(track_v), dtype=int)
|
| 245 |
+
|
| 246 |
+
# Map track vertex indices to final vertex indices
|
| 247 |
+
track_to_final = {}
|
| 248 |
+
new_vertices = []
|
| 249 |
+
|
| 250 |
+
for i, (d, idx) in enumerate(zip(dists, indices)):
|
| 251 |
+
if d <= merge_radius and len(pred_v) > 0:
|
| 252 |
+
# Map to existing pred_v
|
| 253 |
+
track_to_final[i] = int(idx)
|
| 254 |
+
else:
|
| 255 |
+
# Add as new vertex
|
| 256 |
+
track_to_final[i] = len(pred_v) + len(new_vertices)
|
| 257 |
+
new_vertices.append(track_v[i])
|
| 258 |
+
|
| 259 |
+
final_v = list(pred_v) + new_vertices
|
| 260 |
+
final_e = list(pred_e)
|
| 261 |
+
|
| 262 |
+
# Add track edges, mapping their indices
|
| 263 |
+
existing_edges = set()
|
| 264 |
+
for u, v in final_e:
|
| 265 |
+
existing_edges.add((min(u, v), max(u, v)))
|
| 266 |
+
|
| 267 |
+
for u_t, v_t in track_e:
|
| 268 |
+
u_f = track_to_final.get(u_t)
|
| 269 |
+
v_f = track_to_final.get(v_t)
|
| 270 |
+
if u_f is not None and v_f is not None and u_f != v_f:
|
| 271 |
+
e = (min(u_f, v_f), max(u_f, v_f))
|
| 272 |
+
if e not in existing_edges:
|
| 273 |
+
final_e.append(e)
|
| 274 |
+
existing_edges.add(e)
|
| 275 |
+
|
| 276 |
+
return np.array(final_v), final_e
|
| 277 |
|
| 278 |
+
# ---------------------------------------------------------------------------
|
| 279 |
+
# Main
|
| 280 |
+
# ---------------------------------------------------------------------------
|
|
|
|
| 281 |
|
| 282 |
+
if __name__ == "__main__":
|
| 283 |
+
t_start = time.time()
|
| 284 |
|
| 285 |
+
# Load params
|
| 286 |
+
param_path = Path("params.json")
|
| 287 |
+
with param_path.open() as f:
|
| 288 |
+
params = json.load(f)
|
| 289 |
+
print(f"Competition: {params.get('competition_id', '?')}")
|
| 290 |
+
print(f"Dataset: {params.get('dataset', '?')}")
|
|
|
|
| 291 |
|
| 292 |
+
# Load test data
|
| 293 |
+
data_path = Path("/tmp/data")
|
| 294 |
+
if not data_path.exists():
|
| 295 |
+
from huggingface_hub import snapshot_download
|
| 296 |
+
snapshot_download(
|
| 297 |
+
repo_id=params["dataset"],
|
| 298 |
+
local_dir="/tmp/data",
|
| 299 |
+
repo_type="dataset",
|
| 300 |
+
)
|
| 301 |
|
| 302 |
+
from datasets import load_dataset
|
| 303 |
+
data_files = {
|
| 304 |
+
"validation": [str(p) for p in data_path.rglob("*public*/**/*.tar")],
|
| 305 |
+
"test": [str(p) for p in data_path.rglob("*private*/**/*.tar")],
|
| 306 |
+
}
|
| 307 |
+
print(f"Data files: {data_files}")
|
| 308 |
dataset = load_dataset(
|
| 309 |
+
str(data_path / "hoho22k_2026_test_x_anon.py"),
|
| 310 |
+
data_files=data_files,
|
| 311 |
+
trust_remote_code=True,
|
| 312 |
+
writer_batch_size=100,
|
| 313 |
)
|
| 314 |
+
print(f"Loaded: {dataset}")
|
| 315 |
|
| 316 |
+
# Load model
|
| 317 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 318 |
+
print(f"Device: {device}")
|
| 319 |
+
checkpoint_path = SCRIPT_DIR / "checkpoint.pt"
|
| 320 |
+
|
| 321 |
+
# Auto-download checkpoint if missing or just an LFS pointer
|
| 322 |
+
if not checkpoint_path.exists() or checkpoint_path.stat().st_size < 1000:
|
| 323 |
+
print("Downloading checkpoint.pt from upstream learned baseline...")
|
| 324 |
+
import urllib.request
|
| 325 |
+
ckpt_url = "https://huggingface.co/jacklangerman/s23dr-2026-submission/resolve/main/checkpoint.pt"
|
| 326 |
+
urllib.request.urlretrieve(ckpt_url, str(checkpoint_path))
|
| 327 |
+
print("Downloaded checkpoint.pt")
|
| 328 |
+
|
| 329 |
+
model = load_model(checkpoint_path, device)
|
| 330 |
+
print(f"Model loaded: {sum(p.numel() for p in model.parameters()):,} params")
|
| 331 |
+
|
| 332 |
+
# Point fusion config
|
| 333 |
+
cfg = FuserConfig()
|
| 334 |
+
rng = np.random.RandomState(2718)
|
| 335 |
|
| 336 |
+
# Process all samples
|
| 337 |
solution = []
|
| 338 |
+
total_samples = sum(len(dataset[s]) for s in dataset)
|
| 339 |
+
processed = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
|
| 341 |
+
for subset_name in dataset:
|
| 342 |
+
print(f"\nProcessing {subset_name} ({len(dataset[subset_name])} samples)...")
|
| 343 |
+
|
| 344 |
+
for sample in tqdm(dataset[subset_name], desc=subset_name):
|
| 345 |
+
order_id = sample["order_id"]
|
| 346 |
+
|
| 347 |
+
# Fuse + sample
|
| 348 |
+
fused = fuse_and_sample(sample, cfg, rng)
|
| 349 |
+
if fused is None:
|
| 350 |
+
pred_v, pred_e = empty_solution()
|
| 351 |
+
else:
|
| 352 |
+
try:
|
| 353 |
+
pred_v, pred_e = predict_sample(fused, model, device)
|
| 354 |
+
|
| 355 |
+
# Apply handcrafted triangulation tracking to catch missing corners/edges
|
| 356 |
+
try:
|
| 357 |
+
from triangulation import predict_wireframe_tracks
|
| 358 |
+
# Force TRACK_MIN_VIEWS = 2 for aggressive recall
|
| 359 |
+
track_v, track_e = predict_wireframe_tracks(sample, min_views=2)
|
| 360 |
+
|
| 361 |
+
pred_v, pred_e = hybrid_merge(pred_v, pred_e, track_v, track_e, merge_radius=0.8)
|
| 362 |
+
except Exception as track_e_err:
|
| 363 |
+
print(f" Track ensemble failed for {order_id}: {track_e_err}")
|
| 364 |
+
|
| 365 |
+
except Exception as e:
|
| 366 |
+
print(f" Predict failed for {order_id}: {e}")
|
| 367 |
+
pred_v, pred_e = empty_solution()
|
| 368 |
+
|
| 369 |
+
solution.append({
|
| 370 |
+
"order_id": order_id,
|
| 371 |
+
"wf_vertices": pred_v.tolist() if isinstance(pred_v, np.ndarray) else pred_v,
|
| 372 |
+
"wf_edges": [(int(a), int(b)) for a, b in pred_e],
|
| 373 |
+
})
|
| 374 |
+
processed += 1
|
| 375 |
+
|
| 376 |
+
if processed % 50 == 0:
|
| 377 |
+
elapsed = time.time() - t_start
|
| 378 |
+
rate = elapsed / processed
|
| 379 |
+
remaining = (total_samples - processed) * rate
|
| 380 |
+
print(f" [{processed}/{total_samples}] "
|
| 381 |
+
f"{elapsed:.0f}s elapsed, ~{remaining:.0f}s remaining")
|
| 382 |
+
|
| 383 |
+
# Save
|
| 384 |
+
with open("submission.json", "w") as f:
|
| 385 |
+
json.dump(solution, f)
|
| 386 |
|
| 387 |
+
elapsed = time.time() - t_start
|
| 388 |
+
print(f"\nDone. {processed} samples in {elapsed:.0f}s ({elapsed/max(processed,1):.1f}s/sample)")
|
| 389 |
+
print(f"Saved submission.json ({len(solution)} entries)")
|
sklearn_submission_diff.txt
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
--- /Users/ihorivanyshyn/Documents/S23DR/s23dr-2026-submission/sklearn_submission.py 2026-04-26 12:52:14
|
| 2 |
+
+++ /Users/ihorivanyshyn/Documents/S23DR/handcrafted_submission_2026/sklearn_submission.py 2026-05-06 12:53:15
|
| 3 |
+
@@ -84,7 +84,8 @@
|
| 4 |
+
# Stage 1 alone regressed in v16, but with DGCNN refinement the surviving
|
| 5 |
+
# candidates have median distance ~0.3 m to GT (vs ~1 m raw).
|
| 6 |
+
# v17 DGCNN vertex refinement — marginal on 100-sample sweep
|
| 7 |
+
-# (ΔHSS +0.001 at best). Disabled by default.
|
| 8 |
+
+# (ΔHSS +0.001 at best). Disabled by default. Keep this conservative:
|
| 9 |
+
+# adding/removing vertices has a larger blast radius than adding edges.
|
| 10 |
+
USE_DGCNN_REFINEMENT = False
|
| 11 |
+
DGCNN_CLS_THRESHOLD = 0.5
|
| 12 |
+
DGCNN_DEDUP_RADIUS = 0.5
|
| 13 |
+
@@ -100,7 +101,15 @@
|
| 14 |
+
# t=0.7 +0.0039 (peak) t=0.8 +0.0031
|
| 15 |
+
# Clean signal: F1 stable (±0.0006), IoU +0.0065 at t=0.7.
|
| 16 |
+
USE_DGCNN_EDGES = True
|
| 17 |
+
-DGCNN_EDGE_THRESHOLD = 0.7
|
| 18 |
+
+# Ask the edge model for a wider candidate set, then apply our own
|
| 19 |
+
+# geometry gates below. This recovers medium-confidence true edges without
|
| 20 |
+
+# letting the classifier densify the graph unchecked.
|
| 21 |
+
+DGCNN_EDGE_THRESHOLD = 0.55
|
| 22 |
+
+DGCNN_EDGE_STRONG_THRESHOLD = 0.70
|
| 23 |
+
+DGCNN_EDGE_VERY_STRONG_THRESHOLD = 0.88
|
| 24 |
+
+DGCNN_EDGE_MAX_LENGTH = 8.0
|
| 25 |
+
+DGCNN_EDGE_MAX_PER_VERTEX = 2
|
| 26 |
+
+DGCNN_EDGE_REPROJ_DILATE_PX = 4
|
| 27 |
+
|
| 28 |
+
# v16: 3D vertex candidates from the S23DR 2025 winner Stage 1 — DISABLED.
|
| 29 |
+
# Raw cluster centroids without PointNet Stage 2 refinement have median
|
| 30 |
+
@@ -191,7 +200,7 @@
|
| 31 |
+
device = "cuda" if _torch.cuda.is_available() else "cpu"
|
| 32 |
+
except Exception:
|
| 33 |
+
device = "cpu"
|
| 34 |
+
- _DGCNN_EDGE_MODEL = load_edge_model("checkpoints/edge_model_dgcnn.pt", device=device)
|
| 35 |
+
+ _DGCNN_EDGE_MODEL = load_edge_model("edge_model_dgcnn.pt", device=device)
|
| 36 |
+
return _DGCNN_EDGE_MODEL
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@@ -214,7 +223,7 @@
|
| 40 |
+
device = "cuda" if _torch.cuda.is_available() else "cpu"
|
| 41 |
+
except Exception:
|
| 42 |
+
device = "cpu"
|
| 43 |
+
- _DGCNN_VERTEX_MODEL = load_vertex_model("checkpoints/vertex_model_dgcnn.pt", device=device)
|
| 44 |
+
+ _DGCNN_VERTEX_MODEL = load_vertex_model("vertex_model_dgcnn.pt", device=device)
|
| 45 |
+
return _DGCNN_VERTEX_MODEL
|
| 46 |
+
|
| 47 |
+
# v7: ensemble with the standalone tracks-based predictor.
|
| 48 |
+
@@ -500,8 +509,96 @@
|
| 49 |
+
if ok_views >= min_views:
|
| 50 |
+
return True
|
| 51 |
+
return ok_views >= min_views
|
| 52 |
+
+
|
| 53 |
+
+
|
| 54 |
+
+def _passes_dgcnn_edge_gates(
|
| 55 |
+
+ v1: np.ndarray,
|
| 56 |
+
+ v2: np.ndarray,
|
| 57 |
+
+ prob: float,
|
| 58 |
+
+ all_xyz: np.ndarray,
|
| 59 |
+
+ kd_tree=None,
|
| 60 |
+
+ masks: dict | None = None,
|
| 61 |
+
+ views: dict | None = None,
|
| 62 |
+
+) -> bool:
|
| 63 |
+
+ """Conservative accept rule for learned edge candidates.
|
| 64 |
+
+
|
| 65 |
+
+ The DGCNN classifier is useful for recall, but raw learned edges can hurt
|
| 66 |
+
+ IoU if accepted without geometry. Strong candidates need COLMAP support;
|
| 67 |
+
+ very strong candidates may pass with looser sparse support; medium
|
| 68 |
+
+ candidates must also reproject onto gestalt edge pixels.
|
| 69 |
+
+ """
|
| 70 |
+
+ length = float(np.linalg.norm(v2 - v1))
|
| 71 |
+
+ if length < 0.25 or length > DGCNN_EDGE_MAX_LENGTH:
|
| 72 |
+
+ return False
|
| 73 |
+
+
|
| 74 |
+
+ strong_support = validate_edge(
|
| 75 |
+
+ v1, v2, all_xyz, kd_tree,
|
| 76 |
+
+ n_samples=24, radius=0.45, min_ratio=0.55,
|
| 77 |
+
+ )
|
| 78 |
+
+ if prob >= DGCNN_EDGE_STRONG_THRESHOLD and strong_support:
|
| 79 |
+
+ return True
|
| 80 |
+
+
|
| 81 |
+
+ loose_support = validate_edge(
|
| 82 |
+
+ v1, v2, all_xyz, kd_tree,
|
| 83 |
+
+ n_samples=24, radius=0.60, min_ratio=0.35,
|
| 84 |
+
+ )
|
| 85 |
+
+ if prob >= DGCNN_EDGE_VERY_STRONG_THRESHOLD and loose_support:
|
| 86 |
+
+ return True
|
| 87 |
+
+
|
| 88 |
+
+ if prob >= DGCNN_EDGE_STRONG_THRESHOLD and loose_support and masks and views:
|
| 89 |
+
+ return validate_edge_reprojection(
|
| 90 |
+
+ v1, v2, masks, views,
|
| 91 |
+
+ n_samples=24, min_views=1, min_hit_frac=0.35,
|
| 92 |
+
+ )
|
| 93 |
+
+
|
| 94 |
+
+ return False
|
| 95 |
+
+
|
| 96 |
+
+
|
| 97 |
+
+def _select_dgcnn_edges(
|
| 98 |
+
+ final_v: np.ndarray,
|
| 99 |
+
+ final_e: list,
|
| 100 |
+
+ dgcnn_edges: list,
|
| 101 |
+
+ all_xyz: np.ndarray,
|
| 102 |
+
+ kd_tree=None,
|
| 103 |
+
+ masks: dict | None = None,
|
| 104 |
+
+ views: dict | None = None,
|
| 105 |
+
+) -> list[tuple[int, int]]:
|
| 106 |
+
+ """Filter and degree-cap DGCNN edge proposals.
|
| 107 |
+
|
| 108 |
+
+ Existing edges are never removed here. At most
|
| 109 |
+
+ ``DGCNN_EDGE_MAX_PER_VERTEX`` learned edges are added at each vertex,
|
| 110 |
+
+ prioritising higher classifier probabilities.
|
| 111 |
+
+ """
|
| 112 |
+
+ existing = {tuple(sorted(e)) for e in final_e}
|
| 113 |
+
+ candidates = []
|
| 114 |
+
+ for i, j, prob in dgcnn_edges:
|
| 115 |
+
+ lo, hi = (int(i), int(j)) if i < j else (int(j), int(i))
|
| 116 |
+
+ if lo == hi or (lo, hi) in existing:
|
| 117 |
+
+ continue
|
| 118 |
+
+ prob = float(prob)
|
| 119 |
+
+ if _passes_dgcnn_edge_gates(
|
| 120 |
+
+ final_v[lo], final_v[hi], prob,
|
| 121 |
+
+ all_xyz, kd_tree, masks=masks, views=views,
|
| 122 |
+
+ ):
|
| 123 |
+
+ candidates.append((prob, lo, hi))
|
| 124 |
+
|
| 125 |
+
+ candidates.sort(reverse=True)
|
| 126 |
+
+ added_per_vertex = np.zeros(len(final_v), dtype=np.int32)
|
| 127 |
+
+ accepted: list[tuple[int, int]] = []
|
| 128 |
+
+ accepted_set = set()
|
| 129 |
+
+ for prob, lo, hi in candidates:
|
| 130 |
+
+ if (lo, hi) in accepted_set:
|
| 131 |
+
+ continue
|
| 132 |
+
+ if (added_per_vertex[lo] >= DGCNN_EDGE_MAX_PER_VERTEX
|
| 133 |
+
+ or added_per_vertex[hi] >= DGCNN_EDGE_MAX_PER_VERTEX):
|
| 134 |
+
+ continue
|
| 135 |
+
+ accepted.append((lo, hi))
|
| 136 |
+
+ accepted_set.add((lo, hi))
|
| 137 |
+
+ added_per_vertex[lo] += 1
|
| 138 |
+
+ added_per_vertex[hi] += 1
|
| 139 |
+
+ return accepted
|
| 140 |
+
+
|
| 141 |
+
+
|
| 142 |
+
def validate_edge(v1, v2, all_xyz, kd_tree=None, n_samples=20, radius=0.35, min_ratio=0.70):
|
| 143 |
+
"""Check if edge v1→v2 is supported by COLMAP point cloud.
|
| 144 |
+
|
| 145 |
+
@@ -1051,9 +1148,11 @@
|
| 146 |
+
if len(final_v) < 2 or len(final_e) < 1:
|
| 147 |
+
return empty_solution()
|
| 148 |
+
|
| 149 |
+
- # v18: DGCNN edge classifier — placed AFTER prune_not_connected so
|
| 150 |
+
- # that the vertex set is already fixed (no ghost vertices rescued by
|
| 151 |
+
- # spurious DGCNN edges). Only adds edges between surviving vertices.
|
| 152 |
+
+ # v19: guarded DGCNN edge rescue. The learned model is queried at a
|
| 153 |
+
+ # recall-friendly threshold, but new edges are accepted only if they
|
| 154 |
+
+ # also have sparse-cloud or reprojection evidence, then degree-capped.
|
| 155 |
+
+ # This targets the main weakness of v18: useful classifier recall
|
| 156 |
+
+ # without raw learned edges turning roofs into dense graphs.
|
| 157 |
+
if USE_DGCNN_EDGES and len(final_v) >= 2:
|
| 158 |
+
edge_model = _get_dgcnn_edge_model()
|
| 159 |
+
if edge_model is not None:
|
| 160 |
+
@@ -1075,11 +1174,24 @@
|
| 161 |
+
threshold=DGCNN_EDGE_THRESHOLD,
|
| 162 |
+
)
|
| 163 |
+
if dgcnn_edges:
|
| 164 |
+
- existing = set(tuple(sorted(e)) for e in final_e)
|
| 165 |
+
- for i, j, prob in dgcnn_edges:
|
| 166 |
+
- lo, hi = (i, j) if i < j else (j, i)
|
| 167 |
+
- if (lo, hi) not in existing:
|
| 168 |
+
- final_e.append((lo, hi))
|
| 169 |
+
+ masks, mvs_views = {}, {}
|
| 170 |
+
+ try:
|
| 171 |
+
+ masks, mvs_views = _build_gestalt_edge_masks(
|
| 172 |
+
+ entry, dilate_px=DGCNN_EDGE_REPROJ_DILATE_PX,
|
| 173 |
+
+ )
|
| 174 |
+
+ except Exception:
|
| 175 |
+
+ pass
|
| 176 |
+
+ extra = _select_dgcnn_edges(
|
| 177 |
+
+ np.asarray(final_v, dtype=np.float64),
|
| 178 |
+
+ final_e,
|
| 179 |
+
+ dgcnn_edges,
|
| 180 |
+
+ all_xyz,
|
| 181 |
+
+ kd_tree,
|
| 182 |
+
+ masks=masks,
|
| 183 |
+
+ views=mvs_views,
|
| 184 |
+
+ )
|
| 185 |
+
+ if extra:
|
| 186 |
+
+ final_e.extend(extra)
|
| 187 |
+
except Exception:
|
| 188 |
+
pass
|
| 189 |
+
|
test_no_dgcnn.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import json
|
| 3 |
+
import numpy as np
|
| 4 |
+
from datasets import load_dataset
|
| 5 |
+
from hoho2025.metric_helper import hss
|
| 6 |
+
import sklearn_submission
|
| 7 |
+
sklearn_submission.USE_DGCNN_EDGES = False
|
| 8 |
+
sklearn_submission.USE_DGCNN_REFINEMENT = False
|
| 9 |
+
|
| 10 |
+
# Turn on track ensemble parameters
|
| 11 |
+
sklearn_submission.USE_TRACK_ENSEMBLE = True
|
| 12 |
+
sklearn_submission.ADD_ISOLATED_TRACK_VERTICES = True
|
| 13 |
+
|
| 14 |
+
# Turn on Tracks as Vertices
|
| 15 |
+
sklearn_submission.USE_TRACKS_AS_VERTICES = True
|
| 16 |
+
|
| 17 |
+
# Turn on winner candidates
|
| 18 |
+
sklearn_submission.USE_WINNER_CANDIDATES = True
|
| 19 |
+
|
| 20 |
+
print("Loading dataset...")
|
| 21 |
+
dataset = load_dataset('usm3d/hoho22k_2026_trainval', split='train', streaming=True, trust_remote_code=True)
|
| 22 |
+
samples = [s for idx, s in enumerate(dataset) if idx < 10]
|
| 23 |
+
|
| 24 |
+
scores = []
|
| 25 |
+
for idx, sample in enumerate(samples):
|
| 26 |
+
pred_v, pred_e = sklearn_submission.predict_wireframe_sklearn(sample)
|
| 27 |
+
gt_v = sample.get('wf_vertices')
|
| 28 |
+
gt_e = sample.get('wf_edges')
|
| 29 |
+
if gt_v is not None and gt_e is not None:
|
| 30 |
+
scores.append(hss(pred_v, pred_e, gt_v, gt_e).hss)
|
| 31 |
+
|
| 32 |
+
print(f"Avg HSS without DGCNN: {sum(scores)/len(scores):.4f}")
|
test_triangulation.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
os.environ['KMP_DUPLICATE_LIB_OK'] = 'TRUE'
|
| 3 |
+
import numpy as np
|
| 4 |
+
from datasets import load_dataset
|
| 5 |
+
import sklearn_submission
|
| 6 |
+
from triangulation import predict_wireframe_tracks
|
| 7 |
+
|
| 8 |
+
print("Loading dataset...")
|
| 9 |
+
dataset = load_dataset('usm3d/hoho22k_2026_trainval', split='train', streaming=True, trust_remote_code=True)
|
| 10 |
+
samples = [s for idx, s in enumerate(dataset) if idx < 3]
|
| 11 |
+
|
| 12 |
+
for idx, sample in enumerate(samples):
|
| 13 |
+
print(f"Sample {idx}")
|
| 14 |
+
try:
|
| 15 |
+
tv, te = predict_wireframe_tracks(sample)
|
| 16 |
+
print(f"Tracks: {len(tv)} vertices, {len(te)} edges")
|
| 17 |
+
except Exception as e:
|
| 18 |
+
import traceback
|
| 19 |
+
traceback.print_exc()
|
| 20 |
+
print(f"CRASH: {e}")
|
time_test.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import os
|
| 3 |
+
os.environ['KMP_DUPLICATE_LIB_OK'] = 'TRUE'
|
| 4 |
+
import numpy as np
|
| 5 |
+
from datasets import load_dataset
|
| 6 |
+
import sklearn_submission
|
| 7 |
+
from sklearn_submission import predict_wireframe_sklearn
|
| 8 |
+
|
| 9 |
+
sklearn_submission.USE_BUNDLE_ADJUST = True
|
| 10 |
+
sklearn_submission.ADD_ISOLATED_TRACK_VERTICES = True
|
| 11 |
+
|
| 12 |
+
print("Loading dataset...")
|
| 13 |
+
dataset = load_dataset('usm3d/hoho22k_2026_trainval', split='train', streaming=True, trust_remote_code=True)
|
| 14 |
+
|
| 15 |
+
# Process 5 samples and time them
|
| 16 |
+
times = []
|
| 17 |
+
for idx, sample in enumerate(dataset):
|
| 18 |
+
if idx >= 5:
|
| 19 |
+
break
|
| 20 |
+
start = time.time()
|
| 21 |
+
try:
|
| 22 |
+
predict_wireframe_sklearn(sample)
|
| 23 |
+
except Exception as e:
|
| 24 |
+
print(f"Error on sample {idx}: {e}")
|
| 25 |
+
elapsed = time.time() - start
|
| 26 |
+
times.append(elapsed)
|
| 27 |
+
print(f"Sample {idx}: {elapsed:.2f} seconds")
|
| 28 |
+
|
| 29 |
+
print(f"Average time per sample: {np.mean(times):.2f} seconds")
|
tune_dgcnn.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import json
|
| 3 |
+
import numpy as np
|
| 4 |
+
import sys
|
| 5 |
+
import os
|
| 6 |
+
import random
|
| 7 |
+
import importlib
|
| 8 |
+
from datasets import load_dataset
|
| 9 |
+
from hoho2025.metric_helper import hss
|
| 10 |
+
|
| 11 |
+
import sklearn_submission
|
| 12 |
+
|
| 13 |
+
print("Loading dataset...")
|
| 14 |
+
dataset = load_dataset('usm3d/hoho22k_2026_trainval', split='train', streaming=True, trust_remote_code=True)
|
| 15 |
+
|
| 16 |
+
num_samples = 10
|
| 17 |
+
samples = []
|
| 18 |
+
for idx, s in enumerate(dataset):
|
| 19 |
+
if idx >= num_samples:
|
| 20 |
+
break
|
| 21 |
+
samples.append(s)
|
| 22 |
+
|
| 23 |
+
def set_hyperparameters(p):
|
| 24 |
+
sklearn_submission.DGCNN_EDGE_THRESHOLD = p['threshold']
|
| 25 |
+
sklearn_submission.DGCNN_EDGE_STRONG_THRESHOLD = p['strong_threshold']
|
| 26 |
+
sklearn_submission.DGCNN_EDGE_VERY_STRONG_THRESHOLD = p['very_strong_threshold']
|
| 27 |
+
sklearn_submission.DGCNN_EDGE_MAX_LENGTH = p['max_length']
|
| 28 |
+
sklearn_submission.DGCNN_EDGE_MAX_PER_VERTEX = p['max_per_vertex']
|
| 29 |
+
sklearn_submission.DGCNN_EDGE_REPROJ_DILATE_PX = p['dilate_px']
|
| 30 |
+
|
| 31 |
+
def evaluate():
|
| 32 |
+
scores = []
|
| 33 |
+
for sample in samples:
|
| 34 |
+
try:
|
| 35 |
+
pred_v, pred_e = sklearn_submission.predict_wireframe_sklearn(sample)
|
| 36 |
+
except Exception as e:
|
| 37 |
+
pred_v, pred_e = np.zeros((2, 3)), [(0, 1)]
|
| 38 |
+
|
| 39 |
+
gt_v = sample.get('wf_vertices')
|
| 40 |
+
gt_e = sample.get('wf_edges')
|
| 41 |
+
|
| 42 |
+
if gt_v is None or gt_e is None:
|
| 43 |
+
continue
|
| 44 |
+
|
| 45 |
+
res = hss(pred_v, pred_e, gt_v, gt_e)
|
| 46 |
+
scores.append(res.hss)
|
| 47 |
+
|
| 48 |
+
import gc
|
| 49 |
+
gc.collect()
|
| 50 |
+
|
| 51 |
+
return sum(scores) / len(scores) if scores else 0
|
| 52 |
+
|
| 53 |
+
space = {
|
| 54 |
+
'threshold': [0.45, 0.50, 0.55, 0.60],
|
| 55 |
+
'strong_threshold': [0.65, 0.70, 0.75],
|
| 56 |
+
'very_strong_threshold': [0.85, 0.88, 0.90, 0.95],
|
| 57 |
+
'max_length': [6.0, 8.0, 10.0],
|
| 58 |
+
'max_per_vertex': [1, 2, 3],
|
| 59 |
+
'dilate_px': [2, 4, 6]
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
trials = 8
|
| 63 |
+
print(f"Running Random Search for {trials} trials over {num_samples} samples...")
|
| 64 |
+
|
| 65 |
+
best_score = -1.0
|
| 66 |
+
best_params = None
|
| 67 |
+
|
| 68 |
+
# Base parameters evaluation
|
| 69 |
+
base_p = {
|
| 70 |
+
'threshold': 0.55,
|
| 71 |
+
'strong_threshold': 0.70,
|
| 72 |
+
'very_strong_threshold': 0.88,
|
| 73 |
+
'max_length': 8.0,
|
| 74 |
+
'max_per_vertex': 2,
|
| 75 |
+
'dilate_px': 4
|
| 76 |
+
}
|
| 77 |
+
set_hyperparameters(base_p)
|
| 78 |
+
base_score = evaluate()
|
| 79 |
+
print(f"Baseline Score: {base_score:.4f} | Params: {base_p}")
|
| 80 |
+
best_score = base_score
|
| 81 |
+
best_params = base_p
|
| 82 |
+
|
| 83 |
+
for i in range(trials):
|
| 84 |
+
p = {k: random.choice(v) for k, v in space.items()}
|
| 85 |
+
set_hyperparameters(p)
|
| 86 |
+
score = evaluate()
|
| 87 |
+
print(f"Trial {i+1} Score: {score:.4f} | Params: {p}")
|
| 88 |
+
if score > best_score:
|
| 89 |
+
best_score = score
|
| 90 |
+
best_params = p
|
| 91 |
+
|
| 92 |
+
print(f"\n--- BEST RESULT ---")
|
| 93 |
+
print(f"Best HSS Score: {best_score:.4f}")
|
| 94 |
+
print("Best Parameters:")
|
| 95 |
+
for k, v in best_params.items():
|
| 96 |
+
print(f" {k}: {v}")
|
| 97 |
+
|
| 98 |
+
# Write the best params to a JSON for record
|
| 99 |
+
with open("best_dgcnn_params.json", "w") as f:
|
| 100 |
+
json.dump(best_params, f, indent=4)
|