IhorIvanyshyn01 commited on
Commit
7df6a88
·
1 Parent(s): ff4370f

Deploy learned baseline + hybrid multi-view tracking ensemble

Browse files
Files changed (49) hide show
  1. __pycache__/bundle_adjust.cpython-313.pyc +0 -0
  2. __pycache__/depth_edges.cpython-313.pyc +0 -0
  3. __pycache__/dgcnn.cpython-313.pyc +0 -0
  4. __pycache__/junction.cpython-313.pyc +0 -0
  5. __pycache__/line_cloud.cpython-313.pyc +0 -0
  6. __pycache__/mvs_utils.cpython-313.pyc +0 -0
  7. __pycache__/plane_wireframe.cpython-313.pyc +0 -0
  8. __pycache__/sklearn_submission.cpython-313.pyc +0 -0
  9. __pycache__/triangulation.cpython-313.pyc +0 -0
  10. __pycache__/winner_candidates.cpython-313.pyc +0 -0
  11. __pycache__/winner_inference.cpython-313.pyc +0 -0
  12. base.json +39 -0
  13. best_dgcnn_params.json +8 -0
  14. evaluate_sklearn.py +42 -0
  15. s23dr_2026_example/__init__.py +0 -0
  16. s23dr_2026_example/__pycache__/__init__.cpython-313.pyc +0 -0
  17. s23dr_2026_example/__pycache__/attention.cpython-313.pyc +0 -0
  18. s23dr_2026_example/__pycache__/cache_scenes.cpython-313.pyc +0 -0
  19. s23dr_2026_example/__pycache__/color_mappings.cpython-313.pyc +0 -0
  20. s23dr_2026_example/__pycache__/make_sampled_cache.cpython-313.pyc +0 -0
  21. s23dr_2026_example/__pycache__/model.cpython-313.pyc +0 -0
  22. s23dr_2026_example/__pycache__/point_fusion.cpython-313.pyc +0 -0
  23. s23dr_2026_example/__pycache__/postprocess_v2.cpython-313.pyc +0 -0
  24. s23dr_2026_example/__pycache__/segment_postprocess.cpython-313.pyc +0 -0
  25. s23dr_2026_example/__pycache__/tokenizer.cpython-313.pyc +0 -0
  26. s23dr_2026_example/__pycache__/varifold.cpython-313.pyc +0 -0
  27. s23dr_2026_example/__pycache__/wire_varifold_kernels.cpython-313.pyc +0 -0
  28. s23dr_2026_example/attention.py +141 -0
  29. s23dr_2026_example/bad_samples.txt +156 -0
  30. s23dr_2026_example/cache_scenes.py +282 -0
  31. s23dr_2026_example/color_mappings.py +183 -0
  32. s23dr_2026_example/data.py +227 -0
  33. s23dr_2026_example/losses.py +215 -0
  34. s23dr_2026_example/make_sampled_cache.py +159 -0
  35. s23dr_2026_example/model.py +519 -0
  36. s23dr_2026_example/point_fusion.py +554 -0
  37. s23dr_2026_example/postprocess_v2.py +39 -0
  38. s23dr_2026_example/segment_postprocess.py +77 -0
  39. s23dr_2026_example/sinkhorn.py +126 -0
  40. s23dr_2026_example/tokenizer.py +88 -0
  41. s23dr_2026_example/train.py +530 -0
  42. s23dr_2026_example/varifold.py +53 -0
  43. s23dr_2026_example/wire_varifold_kernels.py +168 -0
  44. script.py +358 -100
  45. sklearn_submission_diff.txt +189 -0
  46. test_no_dgcnn.py +32 -0
  47. test_triangulation.py +20 -0
  48. time_test.py +29 -0
  49. 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 sklearn edges + edge validation + improved heuristic."""
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 gc
11
- import subprocess
12
  import sys
 
13
 
14
- # Ensure local imports work regardless of how script.py is invoked
15
- current_dir = str(Path(__file__).parent.absolute())
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
- def empty_solution():
32
- return np.zeros((2, 3)), [(0, 1)]
 
33
 
 
 
 
 
 
34
 
35
- def process_sample(sample, i, sklearn_model=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  try:
37
- from sklearn_submission import predict_wireframe_sklearn
38
- pred_vertices, pred_edges = predict_wireframe_sklearn(sample, sklearn_model)
39
  except Exception as e:
40
- if i < 5:
41
- print(f" Sample {i} sklearn failed: {e}", flush=True)
42
- try:
43
- from hoho2025.example_solutions import predict_wireframe
44
- pred_vertices, pred_edges = predict_wireframe(sample)
45
- except Exception:
46
- pred_vertices, pred_edges = empty_solution()
47
- if i % 10 == 0:
48
- gc.collect()
49
- return {
50
- 'order_id': sample['order_id'],
51
- 'wf_vertices': np.array(pred_vertices).tolist(),
52
- 'wf_edges': [list(e) for e in pred_edges],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  }
54
 
 
 
 
 
 
 
 
 
55
 
56
- if __name__ == "__main__":
57
- print("------------ Loading dataset ------------", flush=True)
58
- param_path = Path('params.json')
59
- if param_path.exists():
60
- with param_path.open() as f:
61
- params = json.load(f)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  else:
63
- params = {"dataset": "usm3d/hoho22k_2026_trainval", "output_path": "."}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
- data_path = Path('/tmp/data')
66
- if not data_path.exists():
67
- from huggingface_hub import snapshot_download
68
- snapshot_download(repo_id=params['dataset'], local_dir="/tmp/data", repo_type="dataset")
69
 
70
- os.system(f'ls -lahtrR {data_path}')
 
71
 
72
- data_files = {}
73
- public_tars = sorted([str(p) for p in data_path.rglob('*public*/**/*.tar')])
74
- private_tars = sorted([str(p) for p in data_path.rglob('*private*/**/*.tar')])
75
- if public_tars:
76
- data_files["validation"] = public_tars
77
- if private_tars:
78
- data_files["test"] = private_tars
79
 
80
- loading_scripts = sorted(data_path.rglob('*.py'))
81
- loading_script = str(loading_scripts[0]) if loading_scripts else str(data_path)
 
 
 
 
 
 
 
82
 
 
 
 
 
 
 
83
  dataset = load_dataset(
84
- loading_script, data_files=data_files,
85
- trust_remote_code=True, writer_batch_size=100,
 
 
86
  )
87
- print(f"Dataset: {dataset}", flush=True)
88
 
89
- # Try to load sklearn model
90
- sklearn_model = None
91
- try:
92
- install_if_missing('scikit-learn')
93
- install_if_missing('networkx')
94
- install_if_missing('scipy')
95
- import pickle
96
- model_path = Path(__file__).parent / 'sklearn_edge.pkl'
97
- print(f"Looking for sklearn model at: {model_path} (exists={model_path.exists()})", flush=True)
98
- if model_path.exists():
99
- with open(model_path, 'rb') as f:
100
- sklearn_model = pickle.load(f)
101
- print("Loaded sklearn edge model OK", flush=True)
102
- else:
103
- print("sklearn model not found using heuristic + edge validation only", flush=True)
104
- except Exception as e:
105
- print(f"sklearn failed: {e} — using heuristic + edge validation only", flush=True)
 
 
106
 
107
- print("------------ Running predictions ---------------", flush=True)
108
  solution = []
109
- for subset_name in dataset.keys():
110
- print(f"Predicting {subset_name}", flush=True)
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
- try:
125
- import pandas as pd
126
- sub = pd.DataFrame(solution, columns=["order_id", "wf_vertices", "wf_edges"])
127
- sub.to_parquet(output_path / "submission.parquet")
128
- except Exception:
129
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
- print("------------ Done ------------", flush=True)
 
 
 
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)