phanerozoic commited on
Commit
836a0e2
·
verified ·
1 Parent(s): 6e2f404

Argus-3D: discovered class-agnostic 3D detection head on EUPE-ViT-B

Browse files
README.md CHANGED
@@ -17,25 +17,31 @@ tags:
17
 
18
  Class-agnostic 3D bounding box detection on a frozen [EUPE-ViT-B](https://huggingface.co/facebook/EUPE-ViT-B) backbone. Given a posed RGB image and camera intrinsics, returns 7-DoF boxes (cx, cy, cz, w, h, d, theta) for the objects in the scene.
19
 
20
- The detector pairs a non-linear (or linear) per-patch foreground head with feature-dim discovery for depth, and unsupervised k-means clustering for size priors. No 2D bounding boxes, no class labels, no segmentation map as final output. Camera-frame 3D boxes only.
21
 
22
  ## Architecture
23
 
24
  ```
25
- Image (768x768)
26
- -> EUPE-ViT-B (frozen, reused from phanerozoic/argus)
27
- -> patch tokens (2304, 768) on a 48x48 grid
28
- -> instance head: 2-layer MLP (default) or linear ridge -> per-patch foreground score
29
- depth head: ridge over 768 dims -> per-patch metric depth (m)
30
- k-means modes: 8 cluster centers -> per-patch object-type assignment
31
- -> threshold instance score
32
- -> upsample mask to 768x768, connected components
33
- -> for each component:
34
- unproject pixels to 3D using depth + K
35
- DBSCAN-split for instance separation
36
- PCA-on-xz for yaw, percentile extents for (w, h, d)
37
- blend extents toward the matched cluster's size prior
38
- -> camera-frame 7-DoF box list
 
 
 
 
 
 
39
  ```
40
 
41
  ## Components
@@ -43,14 +49,14 @@ Image (768x768)
43
  | Component | Parameters | Discovery / training |
44
  |---|---|---|
45
  | EUPE-ViT-B backbone (frozen, reused) | not part of this head | reused from phanerozoic/argus |
46
- | Instance head — MLP (default) | ~200 K | 2-layer MLP (768 → 256 → 1, ReLU + dropout 0.5), 30 epochs AdamW BCE-with-logits |
47
  | Instance head — linear ridge (fallback) | 769 floats + threshold | random K=20 subset search + hard-neg mining, AUC selection |
48
  | Depth head (ridge over 768 dims) | 769 floats | random K=20 subset search, RMSE selection |
49
- | K-means cluster centers | 8 × 768 floats | MiniBatchKMeans on foreground patches |
50
- | Per-cluster size priors (w, h, d) | 8 × 3 floats | median of observed extents per mode |
51
  | OBB fitter (PCA + percentile + Tikhonov) | 0 | closed-form |
 
52
  | **Total head footprint (MLP)** | **~200 K params / ~830 KB** | |
53
- | **Total head footprint (linear ridge)** | **~7 700 floats / 43 KB** | |
54
 
55
  ## File layout
56
 
@@ -59,8 +65,8 @@ instance_head_mlp.safetensors # MLP fc1/fc2 weights
59
  instance_head_mlp_meta.json # MLP config + threshold
60
  instance_head.safetensors # linear ridge: dims + coef + intercept + threshold
61
  depth_head.safetensors # depth ridge: dims + coef + intercept
62
- size_priors.safetensors # 8 cluster centers + 8 (w, h, d) priors
63
- config.json # input_res, patch_grid, prior_weight, etc.
64
  argus_3d.py # Argus3D class
65
  infer.py # CLI dispatcher
66
  ```
@@ -71,11 +77,7 @@ infer.py # CLI dispatcher
71
  from argus_3d import Argus3D
72
  import numpy as np
73
 
74
- # default (head='auto') uses MLP if shipped, falls back to linear ridge.
75
  model = Argus3D.from_pretrained("phanerozoic/argus-3d", device="cuda")
76
- # or force one explicitly:
77
- model = Argus3D.from_pretrained("phanerozoic/argus-3d", device="cuda", head="mlp")
78
- model = Argus3D.from_pretrained("phanerozoic/argus-3d", device="cuda", head="linear")
79
 
80
  K = np.array([[850, 0, 395], [0, 850, 510], [0, 0, 1]])
81
  boxes = model.detect("room.jpg", K) # list of Box3D
@@ -90,38 +92,48 @@ for b in boxes:
90
 
91
  CA-1M val sequence `ca1m-val-45662921`. Class-agnostic per-scene 3D IoU after multi-view fusion across 284 frames (stride-4 sampling of 1135 total). The head produces its own instance hypotheses; no ground-truth 2D bounding boxes are used. Sensor depth is supplied; the discovered depth head can be used in its place.
92
 
93
- End-to-end pipeline numbers, MLP head vs linear ridge head, all else identical:
94
 
95
- | Metric | Linear ridge (v0) | MLP (default) |
96
- |---|---|---|
97
- | Fused boxes per scene | 72 | 80 |
98
- | Mean 3D IoU | 0.063 | **0.071** |
99
- | Fraction > 0.1 IoU | 27.8 % | 23.7 % |
100
- | Fraction > 0.25 IoU | 6.9 % | **11.3 %** |
101
- | Fraction > 0.5 IoU | 0.0 % | **1.3 %** |
102
- | Recall (matched / GT) | 19.8 % | **24.4 %** |
 
 
 
 
 
103
 
104
- The MLP head moves the high-IoU tail and the recall up. The 0.1 IoU bucket drops slightly because the MLP's tighter mask produces fewer marginal-quality fused boxes; the boxes it does produce are of higher quality on average.
 
 
 
 
105
 
106
- Per-stage discovery metrics (in-distribution random patch split mixing all 4 cached scenes):
107
 
108
  | Discovery output | Metric | Linear ridge | MLP |
109
  |---|---|---|---|
110
  | Instance head, per-patch foreground | AUC | 0.860 | 0.980 |
111
  | Instance head, per-patch foreground | F1 (tuned threshold) | 0.569 | 0.815 |
112
- | Depth head, foreground patches in 0.1-3 m | RMSE | 0.190 m | (linear ridge unchanged) |
113
- | Depth head, foreground patches in 0.1-3 m | delta1 (1.25× ratio) | 0.919 | (linear ridge unchanged) |
114
 
115
  ### Cross-scene held-out
116
 
117
- The numbers above use a random patch split that mixes patches from all 4 cached scenes. That setup overstates generalization because adjacent patches in the same room share scene-specific cues (lighting, wall colour, carpet pattern). A leave-one-scene-out evaluation gives the honest cross-scene number:
118
 
119
  | Head | In-distribution AUC | Cross-scene AUC | Cross-scene F1 |
120
  |---|---|---|---|
121
  | Linear ridge (all-768) | 0.860 | 0.604 | 0.301 |
122
- | MLP (768 256 → 1) | 0.980 | **0.678** | **0.352** |
 
123
 
124
- The MLP narrows the in-distribution-to-cross-scene gap by lifting both numbers, but the gap (0.98 vs 0.68) signals the linear/non-linear head still leans on per-scene cues that don't transfer. The cleanest path to closing that gap is more scenes from CA-1M, not deeper heads.
125
 
126
  ## Backbone
127
 
 
17
 
18
  Class-agnostic 3D bounding box detection on a frozen [EUPE-ViT-B](https://huggingface.co/facebook/EUPE-ViT-B) backbone. Given a posed RGB image and camera intrinsics, returns 7-DoF boxes (cx, cy, cz, w, h, d, theta) for the objects in the scene.
19
 
20
+ The detector pairs a non-linear (or linear) per-patch foreground head with feature-dim discovery for depth, k-means clustering for size priors derived from 20 CA-1M val scenes, and 3D-IoU-based multi-view fusion. No 2D bounding boxes, no class labels, no segmentation map as final output. Camera-frame 3D boxes only.
21
 
22
  ## Architecture
23
 
24
  ```
25
+ Per-frame:
26
+ Image (768x768)
27
+ -> EUPE-ViT-B (frozen, reused from phanerozoic/argus)
28
+ -> patch tokens (2304, 768) on a 48x48 grid
29
+ -> instance head: 2-layer MLP (default) or linear ridge -> per-patch foreground score
30
+ depth head: ridge over 768 dims -> per-patch metric depth (m)
31
+ k-means modes: 8 cluster centers (20-scene) -> per-patch object-type assignment
32
+ -> threshold instance score, upsample mask to 768x768, connected components
33
+ -> for each component:
34
+ unproject to 3D using depth + K
35
+ DBSCAN-split for instance separation
36
+ PCA-on-xz for yaw, percentile extents for (w, h, d)
37
+ blend extents toward the matched cluster's size prior
38
+ -> camera-frame 7-DoF box list
39
+
40
+ Multi-view (per scene):
41
+ -> transform every per-frame box to world frame using camera RT
42
+ -> 3D-IoU-based clustering: union-find with edges where iou_3d_zup(box_i, box_j) > 0.2
43
+ -> filter clusters by min_obs and total inlier weight (per-cluster confidence)
44
+ -> per-cluster: weighted-median fuse to single 7-DoF box
45
  ```
46
 
47
  ## Components
 
49
  | Component | Parameters | Discovery / training |
50
  |---|---|---|
51
  | EUPE-ViT-B backbone (frozen, reused) | not part of this head | reused from phanerozoic/argus |
52
+ | Instance head — MLP (default) | ~200 K | 2-layer MLP (768 → 256 → 1, ReLU + dropout 0.5), 30 epochs AdamW BCE-with-logits on 4-scene mixed split |
53
  | Instance head — linear ridge (fallback) | 769 floats + threshold | random K=20 subset search + hard-neg mining, AUC selection |
54
  | Depth head (ridge over 768 dims) | 769 floats | random K=20 subset search, RMSE selection |
55
+ | K-means cluster centers | 8 × 768 floats | MiniBatchKMeans on foreground patches across 20 CA-1M val scenes |
56
+ | Per-cluster size priors (w, h, d) | 8 × 3 floats | median of observed extents per mode (8 800 instances aggregated) |
57
  | OBB fitter (PCA + percentile + Tikhonov) | 0 | closed-form |
58
+ | Multi-view fusion (3D-IoU union-find + weighted median) | 0 | closed-form |
59
  | **Total head footprint (MLP)** | **~200 K params / ~830 KB** | |
 
60
 
61
  ## File layout
62
 
 
65
  instance_head_mlp_meta.json # MLP config + threshold
66
  instance_head.safetensors # linear ridge: dims + coef + intercept + threshold
67
  depth_head.safetensors # depth ridge: dims + coef + intercept
68
+ size_priors.safetensors # 8 cluster centers + 8 (w, h, d) priors (20-scene)
69
+ config.json # input_res, patch_grid, prior_weight, fusion_iou, etc.
70
  argus_3d.py # Argus3D class
71
  infer.py # CLI dispatcher
72
  ```
 
77
  from argus_3d import Argus3D
78
  import numpy as np
79
 
 
80
  model = Argus3D.from_pretrained("phanerozoic/argus-3d", device="cuda")
 
 
 
81
 
82
  K = np.array([[850, 0, 395], [0, 850, 510], [0, 0, 1]])
83
  boxes = model.detect("room.jpg", K) # list of Box3D
 
92
 
93
  CA-1M val sequence `ca1m-val-45662921`. Class-agnostic per-scene 3D IoU after multi-view fusion across 284 frames (stride-4 sampling of 1135 total). The head produces its own instance hypotheses; no ground-truth 2D bounding boxes are used. Sensor depth is supplied; the discovered depth head can be used in its place.
94
 
95
+ ### Headline result
96
 
97
+ 3D-IoU multi-view fusion + 4-scene MLP head + 20-scene size priors:
98
+
99
+ | Metric | Linear ridge (v0) + position fusion | MLP + position fusion | MLP + IoU fusion (default) |
100
+ |---|---|---|---|
101
+ | Mean 3D IoU | 0.063 | 0.076 | **0.154** |
102
+ | Median 3D IoU | | 0.019 | **0.117** |
103
+ | Fraction > 0.1 IoU | 27.8 % | 25.6 % | **53.5 %** |
104
+ | Fraction > 0.25 IoU | 6.9 % | 11.5 % | **28.2 %** |
105
+ | Fraction > 0.5 IoU | 0.0 % | 1.3 % | **2.8 %** |
106
+ | Recall (matched / GT) | 19.8 % | 24.4 % | 28.1 % |
107
+ | Fused boxes per scene | 72 | 78 | 71 |
108
+
109
+ The IoU-based fusion at threshold 0.2, with min 4 observations per cluster and weight floor at the 80th percentile of nonzero cluster weights, gives the strictest filtering. Looser filters trade per-box quality for recall; useful operating points along the curve:
110
 
111
+ | Config | Mean IoU | > 0.25 IoU | > 0.5 IoU | Recall | Fused boxes |
112
+ |---|---|---|---|---|---|
113
+ | min_obs=3, weight_pct=75 (loose) | 0.120 | 21.2 % | 1.9 % | 35.0 % | 104 |
114
+ | min_obs=3, weight_pct=80 (balanced) | 0.137 | 25.3 % | 2.3 % | 30.9 % | 87 |
115
+ | min_obs=4, weight_pct=80 (strict, default) | **0.154** | 28.2 % | 2.8 % | 28.1 % | 71 |
116
 
117
+ ### Per-stage discovery metrics (in-distribution random patch split, 4 scenes mixed)
118
 
119
  | Discovery output | Metric | Linear ridge | MLP |
120
  |---|---|---|---|
121
  | Instance head, per-patch foreground | AUC | 0.860 | 0.980 |
122
  | Instance head, per-patch foreground | F1 (tuned threshold) | 0.569 | 0.815 |
123
+ | Depth head, foreground patches in 0.1-3 m | RMSE | 0.190 m | 0.133 m (MLP variant) |
124
+ | Depth head, foreground patches in 0.1-3 m | delta1 (1.25× ratio) | 0.919 | 0.974 (MLP variant) |
125
 
126
  ### Cross-scene held-out
127
 
128
+ The numbers above use a random patch split that mixes patches from 4 cached scenes. That setup overstates generalization because adjacent patches in the same room share scene-specific cues. A leave-one-scene-out eval gives the honest cross-scene head AUC:
129
 
130
  | Head | In-distribution AUC | Cross-scene AUC | Cross-scene F1 |
131
  |---|---|---|---|
132
  | Linear ridge (all-768) | 0.860 | 0.604 | 0.301 |
133
+ | MLP (3 train scenes) | 0.980 | 0.566 (fold 45662921) | 0.312 |
134
+ | MLP (19 train scenes) | 0.980 | **0.780** (45662921 held out) | **0.465** |
135
 
136
+ Scaling the train set from 3 to 19 scenes lifts cross-scene AUC by 0.21 (0.566 0.780). End-to-end IoU on the held-out scene with the 19-scene MLP head was 0.046 mean IoU under position-only fusion; the IoU-fusion improvement above is in-distribution. Re-running cross-scene with the IoU-fusion stage is open work.
137
 
138
  ## Backbone
139
 
__pycache__/argus_3d.cpython-313.pyc ADDED
Binary file (38.4 kB). View file
 
argus_3d.py CHANGED
@@ -59,6 +59,192 @@ class Box3D:
59
  n_inliers: int = 0
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  class _MLPInstanceHead(nn.Module):
63
  """2-layer MLP fg head: 768 -> hidden -> 1."""
64
 
 
59
  n_inliers: int = 0
60
 
61
 
62
+ def cam_to_world(box_cam: Tuple[float, ...], RT: np.ndarray) -> Tuple[float, ...]:
63
+ """Camera-frame 7-DoF box -> world-frame (X, Y, Z, sx, sy, sz, yaw_world).
64
+
65
+ Output convention: Z up. yaw_world is rotation about world +Z that takes
66
+ world +X to the box's local +x axis. sz is height along world Z.
67
+ """
68
+ cx, cy, cz, w, h, d, theta = box_cam
69
+ pos_cam = np.array([cx, cy, cz])
70
+ R_cw = RT[:3, :3]
71
+ t_cw = RT[:3, 3]
72
+ pos_world = R_cw @ pos_cam + t_cw
73
+ x_local_cam = np.array([math.cos(theta), 0.0, -math.sin(theta)])
74
+ x_local_world = R_cw @ x_local_cam
75
+ yaw_world = math.atan2(x_local_world[1], x_local_world[0])
76
+ return (
77
+ float(pos_world[0]), float(pos_world[1]), float(pos_world[2]),
78
+ float(w), float(d), float(h), yaw_world,
79
+ )
80
+
81
+
82
+ def _polygon_area(poly: np.ndarray) -> float:
83
+ if len(poly) < 3:
84
+ return 0.0
85
+ return float(0.5 * abs(sum(
86
+ (poly[i, 0] * poly[(i + 1) % len(poly), 1]
87
+ - poly[(i + 1) % len(poly), 0] * poly[i, 1])
88
+ for i in range(len(poly))
89
+ )))
90
+
91
+
92
+ def _polygon_clip(subject: np.ndarray, clip: np.ndarray) -> np.ndarray:
93
+ """Sutherland-Hodgman polygon clipping in 2D."""
94
+ out = subject.tolist()
95
+ cn = len(clip)
96
+ for i in range(cn):
97
+ if not out:
98
+ return np.zeros((0, 2))
99
+ a = clip[i]
100
+ b = clip[(i + 1) % cn]
101
+ edge = b - a
102
+ new_out = []
103
+ prev = out[-1]
104
+ prev_in = (b[0] - a[0]) * (prev[1] - a[1]) - (b[1] - a[1]) * (prev[0] - a[0]) >= 0
105
+ for cur in out:
106
+ cur_in = (b[0] - a[0]) * (cur[1] - a[1]) - (b[1] - a[1]) * (cur[0] - a[0]) >= 0
107
+ if cur_in:
108
+ if not prev_in:
109
+ dx = cur[0] - prev[0]
110
+ dy = cur[1] - prev[1]
111
+ denom = edge[0] * dy - edge[1] * dx
112
+ if abs(denom) > 1e-12:
113
+ t = (edge[0] * (prev[1] - a[1]) - edge[1] * (prev[0] - a[0])) / denom
114
+ new_out.append([prev[0] + t * dx, prev[1] + t * dy])
115
+ new_out.append(cur)
116
+ elif prev_in:
117
+ dx = cur[0] - prev[0]
118
+ dy = cur[1] - prev[1]
119
+ denom = edge[0] * dy - edge[1] * dx
120
+ if abs(denom) > 1e-12:
121
+ t = (edge[0] * (prev[1] - a[1]) - edge[1] * (prev[0] - a[0])) / denom
122
+ new_out.append([prev[0] + t * dx, prev[1] + t * dy])
123
+ prev = cur
124
+ prev_in = cur_in
125
+ out = new_out
126
+ return np.array(out) if out else np.zeros((0, 2))
127
+
128
+
129
+ def iou_3d_zup(a: Tuple[float, ...], b: Tuple[float, ...]) -> float:
130
+ """3D IoU for gravity-aligned (Z-up) world-frame boxes (X, Y, Z, sx, sy, sz, yaw)."""
131
+ Ax, Ay, Az, Asx, Asy, Asz, Ayaw = a
132
+ Bx, By, Bz, Bsx, Bsy, Bsz, Byaw = b
133
+
134
+ def xy_poly(x, y, sx, sy, yaw):
135
+ local = np.array([[-sx/2, -sy/2], [+sx/2, -sy/2], [+sx/2, +sy/2], [-sx/2, +sy/2]])
136
+ c, s = math.cos(yaw), math.sin(yaw)
137
+ R = np.array([[c, -s], [s, c]])
138
+ return local @ R.T + np.array([x, y])
139
+
140
+ pa = xy_poly(Ax, Ay, Asx, Asy, Ayaw)
141
+ pb = xy_poly(Bx, By, Bsx, Bsy, Byaw)
142
+ inter_poly = _polygon_clip(pa, pb)
143
+ inter_xy = _polygon_area(inter_poly)
144
+ if inter_xy == 0:
145
+ return 0.0
146
+ z_overlap = max(0.0, min(Az + Asz/2, Bz + Bsz/2) - max(Az - Asz/2, Bz - Bsz/2))
147
+ if z_overlap == 0:
148
+ return 0.0
149
+ inter_vol = inter_xy * z_overlap
150
+ vol_a = _polygon_area(pa) * Asz
151
+ vol_b = _polygon_area(pb) * Bsz
152
+ union_vol = vol_a + vol_b - inter_vol
153
+ if union_vol < 1e-9:
154
+ return 0.0
155
+ return float(inter_vol / union_vol)
156
+
157
+
158
+ def fuse_observations_iou(
159
+ observations: List[Tuple[Tuple[float, ...], float]],
160
+ iou_thresh: float = 0.2,
161
+ min_obs: int = 4,
162
+ weight_thresh_pct: float = 80.0,
163
+ ) -> List[Tuple[float, ...]]:
164
+ """Multi-view fusion via 3D-IoU clustering + weighted-median fuse.
165
+
166
+ observations: list of (world_frame_box, weight). world_frame_box is the
167
+ 7-DoF tuple (X, Y, Z, sx, sy, sz, yaw_world) returned by
168
+ cam_to_world(...). Weight is typically the inlier count of
169
+ the per-frame OBB fit.
170
+ iou_thresh: union-find edge if iou_3d_zup(box_i, box_j) > iou_thresh.
171
+ min_obs: drop clusters with fewer than this many observations.
172
+ weight_thresh_pct: drop clusters whose total inlier weight is below the
173
+ given percentile of nonzero cluster weights.
174
+
175
+ Returns a list of fused 7-DoF world-frame boxes.
176
+ """
177
+ if not observations:
178
+ return []
179
+ boxes = [o[0] for o in observations]
180
+ weights = [float(o[1]) for o in observations]
181
+ n = len(boxes)
182
+
183
+ # Pairwise IoU (with XY prefilter to bound the pair count).
184
+ from scipy.spatial import cKDTree
185
+ arr = np.array(boxes, dtype=np.float64)
186
+ tree = cKDTree(arr[:, 0:2])
187
+ candidate_pairs = tree.query_pairs(0.5)
188
+
189
+ parent = list(range(n))
190
+ def find(i):
191
+ while parent[i] != i:
192
+ parent[i] = parent[parent[i]]
193
+ i = parent[i]
194
+ return i
195
+ def union(i, j):
196
+ ri, rj = find(i), find(j)
197
+ if ri != rj:
198
+ parent[ri] = rj
199
+
200
+ for (i, j) in candidate_pairs:
201
+ if iou_3d_zup(boxes[i], boxes[j]) > iou_thresh:
202
+ union(i, j)
203
+
204
+ from collections import defaultdict
205
+ groups = defaultdict(list)
206
+ for i in range(n):
207
+ groups[find(i)].append(i)
208
+ clusters = list(groups.values())
209
+
210
+ cluster_weight = [(idx, sum(weights[i] for i in idx)) for idx in clusters]
211
+ cluster_weight.sort(key=lambda x: -x[1])
212
+ nonzero = [w for _, w in cluster_weight if w > 0]
213
+ weight_thresh = max(50.0, float(np.percentile(nonzero, weight_thresh_pct))) if nonzero else 50.0
214
+
215
+ fused: List[Tuple[float, ...]] = []
216
+ for cluster, total_w in cluster_weight:
217
+ if len(cluster) < min_obs or total_w < weight_thresh:
218
+ continue
219
+ cluster_boxes = [boxes[i] for i in cluster]
220
+ cluster_ws = np.array([weights[i] for i in cluster], dtype=np.float64)
221
+ # Weighted-median fuse on (X, Y, Z, sx, sy, sz); circular-mean on yaw.
222
+ arr = np.array(cluster_boxes, dtype=np.float64)
223
+ order = arr[:, 0].argsort() # any deterministic order for tie-breaking
224
+ sorted_w = cluster_ws[order]
225
+ sorted_a = arr[order]
226
+ cum = sorted_w.cumsum()
227
+ target = cum[-1] * 0.5
228
+ med_idx = int(np.searchsorted(cum, target))
229
+ med_idx = min(med_idx, len(sorted_a) - 1)
230
+ med = sorted_a[med_idx].copy() # 7-vector
231
+ for j in [1, 2, 3, 4, 5]: # Y, Z, sx, sy, sz
232
+ sw = cluster_ws[arr[:, j].argsort()]
233
+ sa = arr[arr[:, j].argsort()]
234
+ c = sw.cumsum()
235
+ t = c[-1] * 0.5
236
+ mi = min(int(np.searchsorted(c, t)), len(sa) - 1)
237
+ med[j] = sa[mi, j]
238
+ # Circular-mean yaw modulo pi (boxes are flip-symmetric on yaw + pi).
239
+ yaws = arr[:, 6]
240
+ yaws_mod = (yaws * 2.0) % (2 * math.pi)
241
+ sin_sum = float((cluster_ws * np.sin(yaws_mod)).sum())
242
+ cos_sum = float((cluster_ws * np.cos(yaws_mod)).sum())
243
+ med[6] = math.atan2(sin_sum, cos_sum) / 2.0
244
+ fused.append(tuple(float(x) for x in med))
245
+ return fused
246
+
247
+
248
  class _MLPInstanceHead(nn.Module):
249
  """2-layer MLP fg head: 768 -> hidden -> 1."""
250
 
size_priors.safetensors CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:901ae6958490daf1c8dd6541b6e877d83d9cdb822577993fcd5f17cf94b31394
3
  size 24832
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ba90168daf451ea21bf5009df47bd5353bb455b160f6c2b718ba33ce1f97fd61
3
  size 24832