xsponenta Claude Opus 4.7 commited on
Commit
0c54114
·
1 Parent(s): 2a5c52a

Add DINOv2 edge classifier (v4) for post-processing edge filtering

Browse files

Trained head: 128K params, frozen DINOv2-small (22M params) backbone.
Operates as a final post-processing step: for each predicted edge, sample
DINOv2 patch features at projected edge midpoint per view, mean+max pool
across views (768-dim), concat with 40-dim geometric+mask features (808-dim),
pass through 4-layer MLP head -> P(keep).

Best val acc 82.1% (vs 70-75% for hand-engineered features alone).
Operating point: thresh=0.15, min_keep=0.85 - drop only the bottom ~15% of
edges where the classifier is most confident they are wrong. Re-runs orphan
drop after to clean up any stranded vertices.

Local A/B vs production:
100 samples: +0.0032 hss_mean (t=+1.07, 46 wins / 44 losses)
200 samples: +0.0030 hss_mean (t=+1.26, 100 wins / 77 losses)

DINOv2 weights download automatically on first run via torch.hub.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Files changed (4) hide show
  1. edge_classifier_v2.py +339 -0
  2. edge_classifier_v4.py +232 -0
  3. local_eval.py +261 -3
  4. script.py +53 -0
edge_classifier_v2.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """V2 edge classifier with image-feature inputs.
2
+
3
+ Richer features than v1: per-edge-class support along projection, endpoint
4
+ distance to gestalt corner pixels, view-consistency stats. The signal is what
5
+ the gestalt segmentation actually shows along the projected edge — not just
6
+ "are there any edge pixels", but WHICH class and HOW consistently.
7
+
8
+ Feature layout (40 total):
9
+ 0-11 geometric features (same as v1)
10
+ 12-18 per-edge-class mean fraction along projected segment (7 classes)
11
+ 19-25 per-edge-class MAX fraction (best single view)
12
+ 26 endpoint A: mean px distance to nearest gestalt corner (clipped 30)
13
+ 27 endpoint B: same
14
+ 28 endpoint A: count of views where it's <10px from a corner
15
+ 29 endpoint B: same
16
+ 30 edge midpoint: mean px dist to nearest gestalt edge pixel
17
+ 31 num views with any-edge support > 0.3
18
+ 32 num views with any-edge support > 0.6
19
+ 33 mean projected-length in pixels (across views)
20
+ 34 std of any-edge support across views (consistency)
21
+ 35 mean depth z (camera frame, across views) — flat-roof prior
22
+ 36 colmap support: min endpoint dist to colmap point
23
+ 37 colmap support: midpoint dist to colmap point
24
+ 38 edge length / scene median length (raw ratio)
25
+ 39 vertex_count / scene_median_count (graph context)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import numpy as np
31
+ import torch
32
+ import torch.nn as nn
33
+
34
+ EDGE_CLASSES = (
35
+ "eave", "ridge", "rake", "valley", "hip", "flashing", "step_flashing",
36
+ )
37
+ POINT_CLASSES = ("apex", "eave_end_point", "flashing_end_point")
38
+ NUM_FEATURES = 40
39
+
40
+
41
+ class EdgeClassifierV2(nn.Module):
42
+ def __init__(self, in_dim: int = NUM_FEATURES, hidden: int = 64):
43
+ super().__init__()
44
+ self.net = nn.Sequential(
45
+ nn.Linear(in_dim, hidden),
46
+ nn.GELU(),
47
+ nn.Linear(hidden, hidden),
48
+ nn.GELU(),
49
+ nn.Linear(hidden, hidden // 2),
50
+ nn.GELU(),
51
+ nn.Linear(hidden // 2, 1),
52
+ )
53
+
54
+ def forward(self, x):
55
+ return self.net(x).squeeze(-1)
56
+
57
+
58
+ def _build_class_masks_and_corner_dt(good, views, dilate_px: int = 3):
59
+ """Per-view: dict img_id -> {edge_masks[7], corner_dt, H, W}."""
60
+ import cv2
61
+ from hoho2025.color_mappings import gestalt_color_mapping
62
+ out = {}
63
+ for gest_pil, depth_pil, img_id in zip(
64
+ good["gestalt"], good["depth"], good["image_ids"]
65
+ ):
66
+ if img_id not in views:
67
+ continue
68
+ depth_np = np.array(depth_pil)
69
+ H, W = depth_np.shape[:2]
70
+ gest_np = np.array(gest_pil.resize((W, H))).astype(np.uint8)
71
+
72
+ # Edge class masks: each cls -> bool mask
73
+ edge_masks = []
74
+ for cls in EDGE_CLASSES:
75
+ color = np.array(gestalt_color_mapping[cls])
76
+ m = cv2.inRange(gest_np, color - 0.5, color + 0.5)
77
+ if dilate_px > 0:
78
+ k = 2 * dilate_px + 1
79
+ m = cv2.dilate(m, np.ones((k, k), np.uint8), iterations=1)
80
+ edge_masks.append(m > 0)
81
+
82
+ # Corner DT: distance to nearest pixel of ANY point class
83
+ corner_mask = np.zeros((H, W), dtype=np.uint8)
84
+ for cls in POINT_CLASSES:
85
+ color = np.array(gestalt_color_mapping[cls])
86
+ corner_mask |= cv2.inRange(gest_np, color - 0.5, color + 0.5)
87
+ if corner_mask.sum() > 0:
88
+ corner_dt = cv2.distanceTransform(255 - corner_mask, cv2.DIST_L2, 5)
89
+ corner_dt = np.minimum(corner_dt, 30.0).astype(np.float32)
90
+ else:
91
+ corner_dt = np.full((H, W), 30.0, dtype=np.float32)
92
+
93
+ # Edge-union DT (for midpoint feature)
94
+ edge_union = np.zeros((H, W), dtype=np.uint8)
95
+ for cls in EDGE_CLASSES:
96
+ color = np.array(gestalt_color_mapping[cls])
97
+ edge_union |= cv2.inRange(gest_np, color - 0.5, color + 0.5)
98
+ if edge_union.sum() > 0:
99
+ edge_dt = cv2.distanceTransform(255 - edge_union, cv2.DIST_L2, 5)
100
+ edge_dt = np.minimum(edge_dt, 30.0).astype(np.float32)
101
+ else:
102
+ edge_dt = np.full((H, W), 30.0, dtype=np.float32)
103
+
104
+ out[img_id] = {
105
+ "edge_masks": edge_masks, # list of 7 bool (H,W)
106
+ "corner_dt": corner_dt,
107
+ "edge_dt": edge_dt,
108
+ "H": H, "W": W,
109
+ }
110
+ return out
111
+
112
+
113
+ def _edge_features_v2(pv, edges, sample, sample_steps: int = 24):
114
+ """Per-edge feature vectors (E, 40). All zeros on failure."""
115
+ from hoho2025.example_solutions import convert_entry_to_human_readable
116
+ from mvs_utils import collect_views, project_world_to_image
117
+ from scipy.spatial import cKDTree
118
+
119
+ E = len(edges)
120
+ feats = np.zeros((E, NUM_FEATURES), dtype=np.float32)
121
+ if E == 0:
122
+ return feats
123
+
124
+ pv_arr = np.asarray(pv, dtype=np.float64)
125
+
126
+ # Vertex degrees
127
+ deg = np.zeros(len(pv_arr), dtype=np.int32)
128
+ for a, b in edges:
129
+ if 0 <= a < len(pv_arr): deg[a] += 1
130
+ if 0 <= b < len(pv_arr): deg[b] += 1
131
+
132
+ # Edge geometry
133
+ lens = np.zeros(E, dtype=np.float32)
134
+ cos_z = np.zeros(E, dtype=np.float32)
135
+ for i, (a, b) in enumerate(edges):
136
+ if a >= len(pv_arr) or b >= len(pv_arr):
137
+ continue
138
+ d = pv_arr[b] - pv_arr[a]
139
+ n = float(np.linalg.norm(d))
140
+ lens[i] = n
141
+ if n > 1e-6:
142
+ cos_z[i] = float(d[2] / n)
143
+ median_len = float(np.median(lens[lens > 0])) if (lens > 0).any() else 1.0
144
+
145
+ # Geometric core (12 dims)
146
+ for i, (a, b) in enumerate(edges):
147
+ if a >= len(pv_arr) or b >= len(pv_arr):
148
+ continue
149
+ L = lens[i]
150
+ feats[i, 0] = float(np.log(L + 1e-3))
151
+ feats[i, 1] = cos_z[i]
152
+ feats[i, 2] = float(abs(cos_z[i]))
153
+ feats[i, 3] = float(deg[a])
154
+ feats[i, 4] = float(deg[b])
155
+ # 5-9 filled later (image features)
156
+ # 10-11 colmap (filled later)
157
+
158
+ try:
159
+ good = convert_entry_to_human_readable(sample)
160
+ colmap_rec = good.get("colmap") or good.get("colmap_binary")
161
+ if colmap_rec is None:
162
+ return feats
163
+ views = collect_views(colmap_rec, good["image_ids"])
164
+ if not views:
165
+ return feats
166
+ per_view = _build_class_masks_and_corner_dt(good, views)
167
+ if not per_view:
168
+ return feats
169
+
170
+ t_vec = np.linspace(0.0, 1.0, sample_steps)
171
+
172
+ # Colmap tree (for cdist)
173
+ pts = []
174
+ if hasattr(colmap_rec, 'points3D'):
175
+ for p in colmap_rec.points3D.values():
176
+ pts.append(p.xyz)
177
+ c_tree = cKDTree(np.asarray(pts, dtype=np.float32)) if pts else None
178
+
179
+ for i, (u, vv) in enumerate(edges):
180
+ u, vv = int(u), int(vv)
181
+ if u >= len(pv_arr) or vv >= len(pv_arr) or u == vv:
182
+ continue
183
+ endpoints = np.stack([pv_arr[u], pv_arr[vv]])
184
+
185
+ per_class_supp = [] # (n_views, 7)
186
+ any_edge_supp = [] # (n_views,)
187
+ proj_lens = []
188
+ corner_dist_a = []
189
+ corner_dist_b = []
190
+ close_a = 0
191
+ close_b = 0
192
+ mid_edge_dist = []
193
+ cam_z = []
194
+
195
+ for img_id, view in views.items():
196
+ if img_id not in per_view:
197
+ continue
198
+ pv_view = per_view[img_id]
199
+ H, W = pv_view["H"], pv_view["W"]
200
+ uv, z = project_world_to_image(view["P"], endpoints)
201
+ if z[0] <= 0 or z[1] <= 0:
202
+ continue
203
+ if not (0 <= uv[0,0] < W and 0 <= uv[0,1] < H
204
+ and 0 <= uv[1,0] < W and 0 <= uv[1,1] < H):
205
+ continue
206
+ cam_z.append(0.5 * (z[0] + z[1]))
207
+ proj_lens.append(float(np.linalg.norm(uv[1] - uv[0])))
208
+
209
+ xs = uv[0,0] + t_vec * (uv[1,0] - uv[0,0])
210
+ ys = uv[0,1] + t_vec * (uv[1,1] - uv[0,1])
211
+ xs_i = np.clip(xs.astype(np.int32), 0, W - 1)
212
+ ys_i = np.clip(ys.astype(np.int32), 0, H - 1)
213
+
214
+ # Per-class fractions
215
+ this_view_class = np.zeros(7, dtype=np.float32)
216
+ for ci in range(7):
217
+ this_view_class[ci] = float(pv_view["edge_masks"][ci][ys_i, xs_i].sum()) / sample_steps
218
+ per_class_supp.append(this_view_class)
219
+ any_edge_supp.append(float(this_view_class.max())) # max-class frac as "any edge"
220
+
221
+ # Endpoint corner distances
222
+ cd_a = float(pv_view["corner_dt"][int(uv[0,1]), int(uv[0,0])])
223
+ cd_b = float(pv_view["corner_dt"][int(uv[1,1]), int(uv[1,0])])
224
+ corner_dist_a.append(cd_a)
225
+ corner_dist_b.append(cd_b)
226
+ if cd_a < 10.0: close_a += 1
227
+ if cd_b < 10.0: close_b += 1
228
+
229
+ # Midpoint edge distance
230
+ mx, my = int(0.5 * (uv[0,0] + uv[1,0])), int(0.5 * (uv[0,1] + uv[1,1]))
231
+ mx = max(0, min(mx, W - 1))
232
+ my = max(0, min(my, H - 1))
233
+ mid_edge_dist.append(float(pv_view["edge_dt"][my, mx]))
234
+
235
+ if per_class_supp:
236
+ arr = np.asarray(per_class_supp) # (n_views, 7)
237
+ feats[i, 12:19] = arr.mean(axis=0)
238
+ feats[i, 19:26] = arr.max(axis=0)
239
+ feats[i, 26] = float(np.mean(corner_dist_a))
240
+ feats[i, 27] = float(np.mean(corner_dist_b))
241
+ feats[i, 28] = float(close_a)
242
+ feats[i, 29] = float(close_b)
243
+ feats[i, 30] = float(np.mean(mid_edge_dist))
244
+ any_arr = np.asarray(any_edge_supp)
245
+ feats[i, 31] = float((any_arr > 0.3).sum())
246
+ feats[i, 32] = float((any_arr > 0.6).sum())
247
+ feats[i, 33] = float(np.log(max(np.mean(proj_lens), 1.0)))
248
+ feats[i, 34] = float(any_arr.std())
249
+ feats[i, 35] = float(np.mean(cam_z))
250
+
251
+ # Colmap distance
252
+ if c_tree is not None:
253
+ a3d, b3d = pv_arr[u], pv_arr[vv]
254
+ m3d = (a3d + b3d) * 0.5
255
+ d_a, _ = c_tree.query(a3d)
256
+ d_b, _ = c_tree.query(b3d)
257
+ d_m, _ = c_tree.query(m3d)
258
+ feats[i, 36] = float(min(d_a, d_b))
259
+ feats[i, 37] = float(d_m)
260
+
261
+ feats[i, 38] = float(lens[i] / max(median_len, 1e-3))
262
+ feats[i, 39] = float(len(pv_arr) / max(median_len, 1.0)) # graph density hint
263
+
264
+ except Exception:
265
+ pass
266
+
267
+ return feats
268
+
269
+
270
+ def extract_features_v2(pv, pe, sample):
271
+ edges = [(int(a), int(b)) for a, b in pe]
272
+ return _edge_features_v2(pv, edges, sample)
273
+
274
+
275
+ def label_edges_vs_gt(pv, pe, gt_v, gt_e, match_radius: float = 0.5):
276
+ pv_arr = np.asarray(pv, dtype=np.float32)
277
+ gt_v_arr = np.asarray(gt_v, dtype=np.float32)
278
+ labels = np.zeros(len(pe), dtype=np.float32)
279
+ if pv_arr.shape[0] < 2 or len(pe) == 0 or gt_v_arr.shape[0] < 2 or len(gt_e) == 0:
280
+ return labels
281
+ gt_pairs = []
282
+ for a, b in gt_e:
283
+ a, b = int(a), int(b)
284
+ if 0 <= a < len(gt_v_arr) and 0 <= b < len(gt_v_arr):
285
+ gt_pairs.append((gt_v_arr[a], gt_v_arr[b]))
286
+ if not gt_pairs:
287
+ return labels
288
+ r2 = match_radius * match_radius
289
+ for i, (u, vv) in enumerate(pe):
290
+ u, vv = int(u), int(vv)
291
+ if u >= len(pv_arr) or vv >= len(pv_arr):
292
+ continue
293
+ pa, pb = pv_arr[u], pv_arr[vv]
294
+ for ga, gb in gt_pairs:
295
+ d1 = ((pa - ga) ** 2).sum() + ((pb - gb) ** 2).sum()
296
+ d2 = ((pa - gb) ** 2).sum() + ((pb - ga) ** 2).sum()
297
+ if min(d1, d2) <= 2.0 * r2:
298
+ labels[i] = 1.0
299
+ break
300
+ return labels
301
+
302
+
303
+ def classify_edges_v2(pv, pe, sample, classifier, threshold: float = 0.5,
304
+ feature_mean=None, feature_std=None,
305
+ min_keep_frac: float = 0.7):
306
+ try:
307
+ if len(pe) == 0:
308
+ return pv, pe
309
+ feats = extract_features_v2(pv, pe, sample)
310
+ if feature_mean is not None and feature_std is not None:
311
+ feats = (feats - feature_mean) / (feature_std + 1e-6)
312
+ with torch.no_grad():
313
+ x = torch.tensor(feats, dtype=torch.float32)
314
+ scores = torch.sigmoid(classifier(x)).numpy()
315
+
316
+ keep_mask = scores >= threshold
317
+ min_keep = max(1, int(np.ceil(min_keep_frac * len(pe))))
318
+ if keep_mask.sum() < min_keep:
319
+ top_idx = np.argsort(-scores)[:min_keep]
320
+ keep_mask = np.zeros_like(keep_mask)
321
+ keep_mask[top_idx] = True
322
+
323
+ keep_edges = [pe[i] for i in range(len(pe)) if keep_mask[i]]
324
+ if len(keep_edges) < 1:
325
+ return pv, pe
326
+
327
+ from edge_2d_filter import drop_orphan_vertices
328
+ return drop_orphan_vertices(np.asarray(pv), keep_edges)
329
+ except Exception:
330
+ return pv, pe
331
+
332
+
333
+ def load_classifier_v2(path: str, device: str = "cpu"):
334
+ blob = torch.load(path, map_location=device, weights_only=False)
335
+ m = EdgeClassifierV2(in_dim=blob.get("in_dim", NUM_FEATURES),
336
+ hidden=blob.get("hidden", 64))
337
+ m.load_state_dict(blob["model"])
338
+ m.to(device).eval()
339
+ return m, blob.get("feature_mean"), blob.get("feature_std")
edge_classifier_v4.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """V4 edge classifier with DINOv2-pretrained patch features.
2
+
3
+ DINOv2-small (22M params, frozen) encodes each gestalt view to a 16x16 grid
4
+ of 384-dim semantic features. For each predicted edge, we bilinearly sample
5
+ features at the projected edge midpoint in each view, then mean+max pool
6
+ across views to get a 768-dim feature per edge. Concatenated with v2's
7
+ 40-dim geometric+mask features = 808-dim input to MLP.
8
+
9
+ DINOv2 patch size is 14, input 224x224 → 16x16 patches. We feed gestalt RGB
10
+ resized to 224x224.
11
+
12
+ Falls back to no-op on any error.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.nn.functional as F
21
+
22
+ from edge_classifier_v2 import (
23
+ NUM_FEATURES as V2_NUM, extract_features_v2,
24
+ )
25
+
26
+ DINO_FEAT_DIM = 384
27
+ DINO_PATCH_SIZE = 14
28
+ DINO_INPUT = 224
29
+ DINO_GRID = DINO_INPUT // DINO_PATCH_SIZE # 16
30
+ EDGE_FEAT_DIM = DINO_FEAT_DIM * 2 # mean + max pool across views
31
+
32
+
33
+ def get_dino_model(device="cpu"):
34
+ """Load DINOv2-small (frozen). Cached after first call."""
35
+ if not hasattr(get_dino_model, "_cache"):
36
+ get_dino_model._cache = {}
37
+ cache_key = str(device)
38
+ if cache_key not in get_dino_model._cache:
39
+ model = torch.hub.load('facebookresearch/dinov2', 'dinov2_vits14', verbose=False)
40
+ model = model.to(device).eval()
41
+ for p in model.parameters():
42
+ p.requires_grad = False
43
+ get_dino_model._cache[cache_key] = model
44
+ return get_dino_model._cache[cache_key]
45
+
46
+
47
+ class EdgeClassifierV4(nn.Module):
48
+ """Head: takes pre-pooled DINOv2 edge features + v2 geom features."""
49
+ def __init__(self, geom_dim: int = V2_NUM, edge_feat_dim: int = EDGE_FEAT_DIM,
50
+ hidden: int = 128):
51
+ super().__init__()
52
+ in_dim = geom_dim + edge_feat_dim
53
+ self.net = nn.Sequential(
54
+ nn.Linear(in_dim, hidden),
55
+ nn.GELU(),
56
+ nn.Dropout(0.2),
57
+ nn.Linear(hidden, hidden),
58
+ nn.GELU(),
59
+ nn.Dropout(0.2),
60
+ nn.Linear(hidden, hidden // 2),
61
+ nn.GELU(),
62
+ nn.Linear(hidden // 2, 1),
63
+ )
64
+
65
+ def forward(self, geom_feats, edge_feats):
66
+ x = torch.cat([geom_feats, edge_feats], dim=1)
67
+ return self.net(x).squeeze(-1)
68
+
69
+
70
+ @torch.no_grad()
71
+ def _encode_views_with_dino(good, views, dino, device):
72
+ """Encode each view's gestalt image with DINOv2. Returns dict img_id -> (16,16,384)."""
73
+ out = {}
74
+ imgs = []
75
+ img_ids = []
76
+ Hs, Ws = {}, {}
77
+ for gest_pil, depth_pil, img_id in zip(
78
+ good["gestalt"], good["depth"], good["image_ids"]
79
+ ):
80
+ if img_id not in views:
81
+ continue
82
+ depth_np = np.array(depth_pil)
83
+ H, W = depth_np.shape[:2]
84
+ # Resize gestalt to 224x224 (DINOv2 input)
85
+ gest_resized = np.array(gest_pil.resize((DINO_INPUT, DINO_INPUT))).astype(np.float32) / 255.0
86
+ if gest_resized.ndim == 2:
87
+ gest_resized = np.stack([gest_resized]*3, axis=-1)
88
+ else:
89
+ gest_resized = gest_resized[..., :3]
90
+ # ImageNet normalization
91
+ gest_resized = (gest_resized - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225])
92
+ imgs.append(gest_resized.transpose(2, 0, 1)) # (3, 224, 224)
93
+ img_ids.append(img_id)
94
+ Hs[img_id] = H
95
+ Ws[img_id] = W
96
+ # Inject H,W for downstream code
97
+ views[img_id]["H"] = H
98
+ views[img_id]["W"] = W
99
+
100
+ if not imgs:
101
+ return out, Hs, Ws
102
+
103
+ batch = torch.tensor(np.stack(imgs), dtype=torch.float32, device=device)
104
+ # Forward (batch)
105
+ feats = dino.forward_features(batch)
106
+ patches = feats["x_norm_patchtokens"] # (B, 256, 384)
107
+ patches = patches.reshape(len(imgs), DINO_GRID, DINO_GRID, DINO_FEAT_DIM).cpu().numpy()
108
+ for i, img_id in enumerate(img_ids):
109
+ out[img_id] = patches[i] # (16, 16, 384)
110
+ return out, Hs, Ws
111
+
112
+
113
+ def _bilinear_sample_grid(grid, u_norm, v_norm):
114
+ """Bilinearly sample (G, G, D) grid at (u, v) in [0, 1] coords. Returns (D,)."""
115
+ G = grid.shape[0]
116
+ u = u_norm * (G - 1)
117
+ v = v_norm * (G - 1)
118
+ x0 = int(np.floor(u)); x1 = min(x0 + 1, G - 1)
119
+ y0 = int(np.floor(v)); y1 = min(y0 + 1, G - 1)
120
+ x0 = max(0, x0); y0 = max(0, y0)
121
+ dx = u - x0; dy = v - y0
122
+ f00 = grid[y0, x0]
123
+ f01 = grid[y0, x1]
124
+ f10 = grid[y1, x0]
125
+ f11 = grid[y1, x1]
126
+ f0 = f00 * (1 - dx) + f01 * dx
127
+ f1 = f10 * (1 - dx) + f11 * dx
128
+ return f0 * (1 - dy) + f1 * dy
129
+
130
+
131
+ def extract_features_v4(pv, pe, sample, dino, device="cpu"):
132
+ """Return (E, geom_dim), (E, edge_feat_dim) tensors."""
133
+ pv_arr = np.asarray(pv)
134
+ E = len(pe)
135
+ geom = extract_features_v2(pv, pe, sample)
136
+ edge_feats = np.zeros((E, EDGE_FEAT_DIM), dtype=np.float32)
137
+ if E == 0:
138
+ return geom, edge_feats
139
+
140
+ try:
141
+ from hoho2025.example_solutions import convert_entry_to_human_readable
142
+ from mvs_utils import collect_views, project_world_to_image
143
+ good = convert_entry_to_human_readable(sample)
144
+ colmap_rec = good.get("colmap") or good.get("colmap_binary")
145
+ if colmap_rec is None:
146
+ return geom, edge_feats
147
+ views = collect_views(colmap_rec, good["image_ids"])
148
+ if not views:
149
+ return geom, edge_feats
150
+ per_view, Hs, Ws = _encode_views_with_dino(good, views, dino, device)
151
+ if not per_view:
152
+ return geom, edge_feats
153
+
154
+ for i, (u, vv) in enumerate(pe):
155
+ u, vv = int(u), int(vv)
156
+ if u >= len(pv_arr) or vv >= len(pv_arr) or u == vv:
157
+ continue
158
+ endpoints = np.stack([pv_arr[u], pv_arr[vv]])
159
+ per_view_feats = []
160
+ for img_id, view in views.items():
161
+ if img_id not in per_view:
162
+ continue
163
+ H, W = Hs[img_id], Ws[img_id]
164
+ uv, z = project_world_to_image(view["P"], endpoints)
165
+ if z[0] <= 0 or z[1] <= 0:
166
+ continue
167
+ # Midpoint
168
+ mx, my = 0.5 * (uv[0, 0] + uv[1, 0]), 0.5 * (uv[0, 1] + uv[1, 1])
169
+ if not (0 <= mx < W and 0 <= my < H):
170
+ continue
171
+ # Sample DINOv2 grid at (mx/W, my/H)
172
+ feat = _bilinear_sample_grid(per_view[img_id], mx / max(W - 1, 1), my / max(H - 1, 1))
173
+ per_view_feats.append(feat)
174
+ if per_view_feats:
175
+ arr = np.asarray(per_view_feats) # (V, 384)
176
+ mean_f = arr.mean(axis=0)
177
+ max_f = arr.max(axis=0)
178
+ edge_feats[i] = np.concatenate([mean_f, max_f])
179
+ except Exception:
180
+ pass
181
+
182
+ return geom, edge_feats
183
+
184
+
185
+ def label_edges_vs_gt(pv, pe, gt_v, gt_e, match_radius: float = 0.4):
186
+ from edge_classifier_v2 import label_edges_vs_gt as _f
187
+ return _f(pv, pe, gt_v, gt_e, match_radius)
188
+
189
+
190
+ def classify_edges_v4(pv, pe, sample, classifier, dino, device="cpu",
191
+ threshold: float = 0.5,
192
+ feature_mean=None, feature_std=None,
193
+ edge_feat_mean=None, edge_feat_std=None,
194
+ min_keep_frac: float = 0.7):
195
+ try:
196
+ if len(pe) == 0:
197
+ return pv, pe
198
+ geom, edge_feats = extract_features_v4(pv, pe, sample, dino, device=device)
199
+ if feature_mean is not None and feature_std is not None:
200
+ geom = (geom - feature_mean) / (feature_std + 1e-6)
201
+ if edge_feat_mean is not None and edge_feat_std is not None:
202
+ edge_feats = (edge_feats - edge_feat_mean) / (edge_feat_std + 1e-6)
203
+ with torch.no_grad():
204
+ g = torch.tensor(geom, dtype=torch.float32, device=device)
205
+ e = torch.tensor(edge_feats, dtype=torch.float32, device=device)
206
+ scores = torch.sigmoid(classifier(g, e)).cpu().numpy()
207
+
208
+ keep_mask = scores >= threshold
209
+ min_keep = max(1, int(np.ceil(min_keep_frac * len(pe))))
210
+ if keep_mask.sum() < min_keep:
211
+ top_idx = np.argsort(-scores)[:min_keep]
212
+ keep_mask = np.zeros_like(keep_mask)
213
+ keep_mask[top_idx] = True
214
+
215
+ keep_edges = [pe[i] for i in range(len(pe)) if keep_mask[i]]
216
+ if len(keep_edges) < 1:
217
+ return pv, pe
218
+
219
+ from edge_2d_filter import drop_orphan_vertices
220
+ return drop_orphan_vertices(np.asarray(pv), keep_edges)
221
+ except Exception:
222
+ return pv, pe
223
+
224
+
225
+ def load_classifier_v4(path: str, device: str = "cpu"):
226
+ blob = torch.load(path, map_location=device, weights_only=False)
227
+ m = EdgeClassifierV4(geom_dim=V2_NUM, edge_feat_dim=EDGE_FEAT_DIM,
228
+ hidden=blob.get("hidden", 128))
229
+ m.load_state_dict(blob["model"])
230
+ m.to(device).eval()
231
+ return (m, blob.get("feature_mean"), blob.get("feature_std"),
232
+ blob.get("edge_feat_mean"), blob.get("edge_feat_std"))
local_eval.py CHANGED
@@ -66,6 +66,54 @@ def parse_args():
66
  help="hungarian TTA: drop anchor segments without this many supporting passes")
67
  p.add_argument("--tta-seeds", type=str, default="2718,31415,42",
68
  help="comma-separated priority-sample seeds for TTA")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  p.add_argument("--edge-fill", action="store_true",
70
  help="enable edge filling from 2D mask evidence")
71
  p.add_argument("--fill-min-views", type=int, default=2,
@@ -81,7 +129,8 @@ def predict_one(sample, model, device, cfg, rng,
81
  use_tracks=True, use_2d_filter=True, orphan_only=False,
82
  strict_no_support=False, vertex_refine=False,
83
  refine_kwargs=None, edge_fill=False, fill_kwargs=None,
84
- tta=False, tta_seeds=None):
 
85
  """Run the full inference pipeline on one sample. Returns (pv, pe, diag)."""
86
  diag = {"colmap": -1, "fused": 0, "track_v": 0, "track_e": 0,
87
  "pred_v": 0, "pred_e": 0, "2dfilt_in": 0, "2dfilt_out": 0,
@@ -96,7 +145,24 @@ def predict_one(sample, model, device, cfg, rng,
96
  except Exception:
97
  pass
98
 
99
- if tta:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  try:
101
  seeds = tta_seeds or (2718, 31415, 42)
102
  tta_method = (
@@ -142,6 +208,22 @@ def predict_one(sample, model, device, cfg, rng,
142
  except Exception as e:
143
  diag["status"] = f"track_failed:{type(e).__name__}"
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  diag["2dfilt_in"] = len(pred_e) if hasattr(pred_e, '__len__') else 0
146
  # Vertex refinement runs FIRST (refines vertex positions while orphan is still present;
147
  # orphan/2d-filter then cleans up afterwards).
@@ -184,6 +266,40 @@ def predict_one(sample, model, device, cfg, rng,
184
  diag["status"] = f"2dfilt_failed:{type(e).__name__}"
185
  diag["2dfilt_out"] = len(pred_e) if hasattr(pred_e, '__len__') else 0
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  if edge_fill:
188
  e_before = len(pred_e) if hasattr(pred_e, '__len__') else 0
189
  try:
@@ -195,6 +311,57 @@ def predict_one(sample, model, device, cfg, rng,
195
  except Exception as e:
196
  diag["status"] = f"fill_failed:{type(e).__name__}"
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  diag["pred_v"] = len(pred_v) if hasattr(pred_v, '__len__') else 0
199
  diag["pred_e"] = len(pred_e) if hasattr(pred_e, '__len__') else 0
200
  return pred_v, pred_e, diag
@@ -222,6 +389,72 @@ def main():
222
  model = script.load_model(ckpt_path, device)
223
  print(f"Model: {sum(p.numel() for p in model.parameters()):,} params")
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  if args.conf_thresh is not None:
226
  print(f"Overriding script.CONF_THRESH: {script.CONF_THRESH} -> {args.conf_thresh}")
227
  script.CONF_THRESH = args.conf_thresh
@@ -271,6 +504,29 @@ def main():
271
  tta_seeds_tuple = tuple(int(s) for s in args.tta_seeds.split(","))
272
  predict_one._tta_hungarian = args.tta_hungarian
273
  predict_one._tta_min_passes = args.tta_min_passes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  pred_v, pred_e, diag = predict_one(
275
  sample, model, device, cfg, rng,
276
  use_tracks=not args.no_tracks,
@@ -282,7 +538,9 @@ def main():
282
  edge_fill=args.edge_fill,
283
  fill_kwargs=fill_kwargs,
284
  tta=args.tta,
285
- tta_seeds=tta_seeds_tuple)
 
 
286
  if torch.backends.mps.is_available():
287
  torch.mps.empty_cache()
288
 
 
66
  help="hungarian TTA: drop anchor segments without this many supporting passes")
67
  p.add_argument("--tta-seeds", type=str, default="2718,31415,42",
68
  help="comma-separated priority-sample seeds for TTA")
69
+ p.add_argument("--tracks-only", action="store_true",
70
+ help="output ONLY the triangulation tracks (debug: baseline of tracks alone)")
71
+ p.add_argument("--fallback-to-tracks-when", type=str, default="",
72
+ help="fallback to tracks-only when pred_v > X and track_v < Y, e.g. 20,8")
73
+ p.add_argument("--hallu-filter", action="store_true",
74
+ help="filter vertices lacking BOTH COLMAP and gestalt-corner support")
75
+ p.add_argument("--hallu-colmap-radius", type=float, default=0.8,
76
+ help="hallucination filter: COLMAP support radius (meters)")
77
+ p.add_argument("--hallu-gestalt-px", type=float, default=20.0,
78
+ help="hallucination filter: gestalt corner pixel radius")
79
+ p.add_argument("--hallu-min-views", type=int, default=1,
80
+ help="hallucination filter: min views with gestalt support")
81
+ p.add_argument("--ensemble", type=str, default="",
82
+ help="comma-separated paths to ensemble checkpoints (additional to default checkpoint.pt)")
83
+ p.add_argument("--ensemble-min-passes", type=int, default=1,
84
+ help="ensemble: min cross-pass agreement to keep anchor segments")
85
+ p.add_argument("--bundle-adjust", action="store_true",
86
+ help="apply joint multi-view wireframe bundle adjustment")
87
+ p.add_argument("--ba-iter", type=int, default=50,
88
+ help="bundle-adjust Adam iterations")
89
+ p.add_argument("--ba-lr", type=float, default=0.003,
90
+ help="bundle-adjust learning rate")
91
+ p.add_argument("--ba-vertex-weight", type=float, default=1.0,
92
+ help="bundle-adjust vertex (corner pixel) loss weight")
93
+ p.add_argument("--ba-edge-weight", type=float, default=0.5,
94
+ help="bundle-adjust edge (edge pixel) loss weight")
95
+ p.add_argument("--ba-anchor-weight", type=float, default=200.0,
96
+ help="bundle-adjust anchor regularization weight")
97
+ p.add_argument("--ba-max-move", type=float, default=0.4,
98
+ help="bundle-adjust hard cap on vertex displacement (meters)")
99
+ p.add_argument("--tri-supplement", action="store_true",
100
+ help="supplement sparse predictions with loose (min_views=2) tracks")
101
+ p.add_argument("--tri-sparse-threshold", type=int, default=5,
102
+ help="tri-supplement: only activate when pred has < N vertices")
103
+ p.add_argument("--tri-merge-radius", type=float, default=0.7,
104
+ help="tri-supplement: absorb loose vertex into pred if within radius")
105
+ p.add_argument("--edge-classifier", type=str, default="",
106
+ help="path to edge_classifier.pt; if set, filter edges via learned model")
107
+ p.add_argument("--edge-classifier-v2", type=str, default="",
108
+ help="path to edge_classifier_v2.pt; if set, use v2 with image-mask features")
109
+ p.add_argument("--edge-classifier-v3", type=str, default="",
110
+ help="path to edge_classifier_v3.pt; v3 = CNN patches + v2 features")
111
+ p.add_argument("--edge-classifier-v4", type=str, default="",
112
+ help="path to edge_classifier_v4.pt; v4 = DINOv2 features + v2 features")
113
+ p.add_argument("--edge-class-thresh", type=float, default=0.5,
114
+ help="edge classifier: keep edges with P(keep) >= threshold")
115
+ p.add_argument("--edge-class-min-keep", type=float, default=0.5,
116
+ help="edge classifier: never drop more than (1 - this) of edges")
117
  p.add_argument("--edge-fill", action="store_true",
118
  help="enable edge filling from 2D mask evidence")
119
  p.add_argument("--fill-min-views", type=int, default=2,
 
129
  use_tracks=True, use_2d_filter=True, orphan_only=False,
130
  strict_no_support=False, vertex_refine=False,
131
  refine_kwargs=None, edge_fill=False, fill_kwargs=None,
132
+ tta=False, tta_seeds=None,
133
+ tracks_only=False, fallback_tracks=None):
134
  """Run the full inference pipeline on one sample. Returns (pv, pe, diag)."""
135
  diag = {"colmap": -1, "fused": 0, "track_v": 0, "track_e": 0,
136
  "pred_v": 0, "pred_e": 0, "2dfilt_in": 0, "2dfilt_out": 0,
 
145
  except Exception:
146
  pass
147
 
148
+ if getattr(predict_one, "_ensemble_models", None):
149
+ try:
150
+ from ensemble import predict_sample_ensemble
151
+ # If --tta and --ensemble both set, use TTA seeds; else single seed.
152
+ if tta:
153
+ seeds = tta_seeds or (2718, 31415, 42)
154
+ else:
155
+ seeds = (2718,)
156
+ pred_v, pred_e = predict_sample_ensemble(
157
+ sample, cfg, predict_one._ensemble_models, device,
158
+ seeds=tuple(seeds),
159
+ min_passes_for_keep=getattr(predict_one, "_ensemble_min_passes", 1),
160
+ )
161
+ diag["fused"] = -1
162
+ except Exception as e:
163
+ diag["status"] = f"ensemble_failed:{type(e).__name__}"
164
+ return *script.empty_solution(), diag
165
+ elif tta:
166
  try:
167
  seeds = tta_seeds or (2718, 31415, 42)
168
  tta_method = (
 
208
  except Exception as e:
209
  diag["status"] = f"track_failed:{type(e).__name__}"
210
 
211
+ # Sparse-scene supplement: when pred remains tiny after model + min_views=3
212
+ # tracks, fall back to min_views=2 loose tracks.
213
+ if getattr(predict_one, "_tri_supplement", False):
214
+ try:
215
+ from triangulate_supplement import supplement_sparse_with_loose_tracks
216
+ v_before = len(pred_v) if hasattr(pred_v, '__len__') else 0
217
+ pred_v, pred_e = supplement_sparse_with_loose_tracks(
218
+ pred_v, pred_e, sample,
219
+ sparse_threshold=getattr(predict_one, "_tri_sparse_threshold", 5),
220
+ merge_radius=getattr(predict_one, "_tri_merge_radius", 0.7),
221
+ )
222
+ v_after = len(pred_v) if hasattr(pred_v, '__len__') else 0
223
+ diag["tri_added"] = v_after - v_before
224
+ except Exception as e:
225
+ diag["status"] = f"tri_failed:{type(e).__name__}"
226
+
227
  diag["2dfilt_in"] = len(pred_e) if hasattr(pred_e, '__len__') else 0
228
  # Vertex refinement runs FIRST (refines vertex positions while orphan is still present;
229
  # orphan/2d-filter then cleans up afterwards).
 
266
  diag["status"] = f"2dfilt_failed:{type(e).__name__}"
267
  diag["2dfilt_out"] = len(pred_e) if hasattr(pred_e, '__len__') else 0
268
 
269
+ # Edge classifier: learned keep/drop on top of post-filter edges.
270
+ ec = getattr(predict_one, "_edge_classifier", None)
271
+ if ec is not None:
272
+ try:
273
+ e_before = len(pred_e) if hasattr(pred_e, '__len__') else 0
274
+ ver = ec.get("version", 1)
275
+ if ver == 4:
276
+ from edge_classifier_v4 import classify_edges_v4
277
+ pred_v, pred_e = classify_edges_v4(
278
+ pred_v, pred_e, sample,
279
+ ec["model"], ec["dino"], device=ec["dino_device"],
280
+ threshold=ec["threshold"],
281
+ feature_mean=ec["mean"], feature_std=ec["std"],
282
+ edge_feat_mean=ec["edge_feat_mean"], edge_feat_std=ec["edge_feat_std"],
283
+ min_keep_frac=ec["min_keep_frac"],
284
+ )
285
+ else:
286
+ if ver == 3:
287
+ from edge_classifier_v3 import classify_edges_v3 as _cls_fn
288
+ elif ver == 2:
289
+ from edge_classifier_v2 import classify_edges_v2 as _cls_fn
290
+ else:
291
+ from edge_classifier import classify_edges as _cls_fn
292
+ pred_v, pred_e = _cls_fn(
293
+ pred_v, pred_e, sample,
294
+ ec["model"], threshold=ec["threshold"],
295
+ feature_mean=ec["mean"], feature_std=ec["std"],
296
+ min_keep_frac=ec["min_keep_frac"],
297
+ )
298
+ diag["ec_kept"] = (len(pred_e) if hasattr(pred_e, '__len__') else 0)
299
+ diag["ec_dropped"] = e_before - diag["ec_kept"]
300
+ except Exception as e:
301
+ diag["status"] = f"ec_failed:{type(e).__name__}"
302
+
303
  if edge_fill:
304
  e_before = len(pred_e) if hasattr(pred_e, '__len__') else 0
305
  try:
 
311
  except Exception as e:
312
  diag["status"] = f"fill_failed:{type(e).__name__}"
313
 
314
+ if getattr(predict_one, "_bundle_adjust", False):
315
+ try:
316
+ from bundle_wireframe import bundle_adjust_wireframe
317
+ ba_kwargs = getattr(predict_one, "_ba_kwargs", {})
318
+ pred_v, pred_e = bundle_adjust_wireframe(
319
+ pred_v, pred_e, sample, **ba_kwargs)
320
+ except Exception as e:
321
+ diag["status"] = f"ba_failed:{type(e).__name__}"
322
+
323
+ # Optional: hallucination filter (drop vertices lacking both COLMAP and gestalt support)
324
+ if getattr(predict_one, "_hallu_filter", False):
325
+ try:
326
+ from hallucination_filter import filter_hallucinated_vertices
327
+ n_before = len(pred_v) if hasattr(pred_v, '__len__') else 0
328
+ pred_v, pred_e = filter_hallucinated_vertices(
329
+ pred_v, pred_e, sample,
330
+ colmap_radius=getattr(predict_one, "_hallu_colmap_radius", 0.8),
331
+ gestalt_radius_px=getattr(predict_one, "_hallu_gestalt_px", 20.0),
332
+ min_views_with_gestalt=getattr(predict_one, "_hallu_min_views", 1),
333
+ )
334
+ n_after = len(pred_v) if hasattr(pred_v, '__len__') else 0
335
+ diag["hallu_dropped"] = n_before - n_after
336
+ except Exception as e:
337
+ diag["status"] = f"hallu_failed:{type(e).__name__}"
338
+
339
+ # Optional: replace prediction with tracks-only on hard scenes
340
+ if tracks_only:
341
+ # Use the tracks computed earlier (regardless of pred quality)
342
+ try:
343
+ from triangulation import predict_wireframe_tracks
344
+ track_v, track_e = predict_wireframe_tracks(sample, min_views=3)
345
+ if track_v is not None and track_e is not None and len(track_v) >= 2 and len(track_e) >= 1:
346
+ pred_v = np.asarray(track_v, dtype=np.float32)
347
+ pred_e = list(track_e)
348
+ diag["status"] = "tracks_only_forced"
349
+ except Exception:
350
+ pass
351
+ elif fallback_tracks is not None:
352
+ pred_v_thresh, track_v_thresh = fallback_tracks # tuple
353
+ n_pv = len(pred_v) if hasattr(pred_v, '__len__') else 0
354
+ if n_pv > pred_v_thresh and diag.get("track_v", 0) < track_v_thresh:
355
+ try:
356
+ from triangulation import predict_wireframe_tracks
357
+ track_v, track_e = predict_wireframe_tracks(sample, min_views=3)
358
+ if track_v is not None and track_e is not None and len(track_v) >= 2 and len(track_e) >= 1:
359
+ pred_v = np.asarray(track_v, dtype=np.float32)
360
+ pred_e = list(track_e)
361
+ diag["status"] = "fallback_to_tracks"
362
+ except Exception:
363
+ pass
364
+
365
  diag["pred_v"] = len(pred_v) if hasattr(pred_v, '__len__') else 0
366
  diag["pred_e"] = len(pred_e) if hasattr(pred_e, '__len__') else 0
367
  return pred_v, pred_e, diag
 
389
  model = script.load_model(ckpt_path, device)
390
  print(f"Model: {sum(p.numel() for p in model.parameters()):,} params")
391
 
392
+ ensemble_models = None
393
+ if args.ensemble:
394
+ from ensemble import load_two_checkpoints
395
+ extra_paths = [p.strip() for p in args.ensemble.split(",") if p.strip()]
396
+ ensemble_models = [model] + load_two_checkpoints(extra_paths, device)
397
+ print(f"Ensemble: {len(ensemble_models)} models")
398
+
399
+ if args.edge_classifier:
400
+ from edge_classifier import load_classifier
401
+ ec_model, ec_mean, ec_std = load_classifier(args.edge_classifier, device="cpu")
402
+ print(f"Edge classifier loaded from {args.edge_classifier} "
403
+ f"(thresh={args.edge_class_thresh}, min_keep={args.edge_class_min_keep})")
404
+ predict_one._edge_classifier_loaded = {
405
+ "model": ec_model,
406
+ "mean": ec_mean.cpu().numpy() if hasattr(ec_mean, 'cpu') else ec_mean,
407
+ "std": ec_std.cpu().numpy() if hasattr(ec_std, 'cpu') else ec_std,
408
+ "threshold": args.edge_class_thresh,
409
+ "min_keep_frac": args.edge_class_min_keep,
410
+ "version": 1,
411
+ }
412
+
413
+ if args.edge_classifier_v2:
414
+ from edge_classifier_v2 import load_classifier_v2
415
+ ec_model, ec_mean, ec_std = load_classifier_v2(args.edge_classifier_v2, device="cpu")
416
+ print(f"Edge classifier V2 loaded from {args.edge_classifier_v2}")
417
+ predict_one._edge_classifier_loaded = {
418
+ "model": ec_model,
419
+ "mean": ec_mean.cpu().numpy() if hasattr(ec_mean, 'cpu') else ec_mean,
420
+ "std": ec_std.cpu().numpy() if hasattr(ec_std, 'cpu') else ec_std,
421
+ "threshold": args.edge_class_thresh,
422
+ "min_keep_frac": args.edge_class_min_keep,
423
+ "version": 2,
424
+ }
425
+
426
+ if args.edge_classifier_v3:
427
+ from edge_classifier_v3 import load_classifier_v3
428
+ ec_model, ec_mean, ec_std = load_classifier_v3(args.edge_classifier_v3, device="cpu")
429
+ print(f"Edge classifier V3 (CNN) loaded from {args.edge_classifier_v3}")
430
+ predict_one._edge_classifier_loaded = {
431
+ "model": ec_model,
432
+ "mean": ec_mean.cpu().numpy() if hasattr(ec_mean, 'cpu') else ec_mean,
433
+ "std": ec_std.cpu().numpy() if hasattr(ec_std, 'cpu') else ec_std,
434
+ "threshold": args.edge_class_thresh,
435
+ "min_keep_frac": args.edge_class_min_keep,
436
+ "version": 3,
437
+ }
438
+
439
+ if args.edge_classifier_v4:
440
+ from edge_classifier_v4 import load_classifier_v4, get_dino_model
441
+ ec_model, g_mean, g_std, e_mean, e_std = load_classifier_v4(args.edge_classifier_v4, device="cpu")
442
+ # DINO runs on the inference device for speed
443
+ dino = get_dino_model(device=device)
444
+ print(f"Edge classifier V4 (DINOv2) loaded from {args.edge_classifier_v4}")
445
+ predict_one._edge_classifier_loaded = {
446
+ "model": ec_model,
447
+ "dino": dino,
448
+ "dino_device": device,
449
+ "mean": g_mean.cpu().numpy() if hasattr(g_mean, 'cpu') else g_mean,
450
+ "std": g_std.cpu().numpy() if hasattr(g_std, 'cpu') else g_std,
451
+ "edge_feat_mean": e_mean.cpu().numpy() if hasattr(e_mean, 'cpu') else e_mean,
452
+ "edge_feat_std": e_std.cpu().numpy() if hasattr(e_std, 'cpu') else e_std,
453
+ "threshold": args.edge_class_thresh,
454
+ "min_keep_frac": args.edge_class_min_keep,
455
+ "version": 4,
456
+ }
457
+
458
  if args.conf_thresh is not None:
459
  print(f"Overriding script.CONF_THRESH: {script.CONF_THRESH} -> {args.conf_thresh}")
460
  script.CONF_THRESH = args.conf_thresh
 
504
  tta_seeds_tuple = tuple(int(s) for s in args.tta_seeds.split(","))
505
  predict_one._tta_hungarian = args.tta_hungarian
506
  predict_one._tta_min_passes = args.tta_min_passes
507
+ predict_one._hallu_filter = args.hallu_filter
508
+ predict_one._hallu_colmap_radius = args.hallu_colmap_radius
509
+ predict_one._hallu_gestalt_px = args.hallu_gestalt_px
510
+ predict_one._hallu_min_views = args.hallu_min_views
511
+ predict_one._ensemble_models = ensemble_models
512
+ predict_one._ensemble_min_passes = args.ensemble_min_passes
513
+ predict_one._bundle_adjust = args.bundle_adjust
514
+ predict_one._ba_kwargs = {
515
+ "n_iter": args.ba_iter,
516
+ "lr": args.ba_lr,
517
+ "vertex_weight": args.ba_vertex_weight,
518
+ "edge_weight": args.ba_edge_weight,
519
+ "anchor_weight": args.ba_anchor_weight,
520
+ "max_move_meters": args.ba_max_move,
521
+ }
522
+ predict_one._tri_supplement = args.tri_supplement
523
+ predict_one._tri_sparse_threshold = args.tri_sparse_threshold
524
+ predict_one._tri_merge_radius = args.tri_merge_radius
525
+ predict_one._edge_classifier = getattr(predict_one, "_edge_classifier_loaded", None)
526
+ fallback_tracks = None
527
+ if args.fallback_to_tracks_when:
528
+ pv_thr, tv_thr = args.fallback_to_tracks_when.split(",")
529
+ fallback_tracks = (int(pv_thr), int(tv_thr))
530
  pred_v, pred_e, diag = predict_one(
531
  sample, model, device, cfg, rng,
532
  use_tracks=not args.no_tracks,
 
538
  edge_fill=args.edge_fill,
539
  fill_kwargs=fill_kwargs,
540
  tta=args.tta,
541
+ tta_seeds=tta_seeds_tuple,
542
+ tracks_only=args.tracks_only,
543
+ fallback_tracks=fallback_tracks)
544
  if torch.backends.mps.is_available():
545
  torch.mps.empty_cache()
546
 
script.py CHANGED
@@ -391,6 +391,37 @@ if __name__ == "__main__":
391
  model = load_model(checkpoint_path, device)
392
  print(f"Model loaded: {sum(p.numel() for p in model.parameters()):,} params")
393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  # Optional: load 2nd checkpoint for ensemble inference
395
  ensemble_models = None
396
  if USE_ENSEMBLE:
@@ -550,6 +581,28 @@ if __name__ == "__main__":
550
  print(f" orphan drop failed for {order_id}: {filt_err}")
551
  edges_after_2d = len(pred_e) if hasattr(pred_e, '__len__') else 0
552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  except Exception as e:
554
  import traceback
555
  print(f" Predict failed for {order_id}:\n{traceback.format_exc()}")
 
391
  model = load_model(checkpoint_path, device)
392
  print(f"Model loaded: {sum(p.numel() for p in model.parameters()):,} params")
393
 
394
+ # Edge classifier (DINOv2 patch features + geometric features → P(keep edge)).
395
+ # Trained on 400 samples with match_radius=0.4. Best val acc 82.1%.
396
+ # Operating point: thresh=0.15, min_keep=0.85 — drop only the bottom ~15%
397
+ # of edges that the classifier is most confident are wrong. 200-sample local
398
+ # A/B: +0.0030 hss_mean (t=+1.26, 100 wins / 77 losses).
399
+ edge_classifier_bundle = None
400
+ edge_classifier_path = SCRIPT_DIR / "edge_classifier_v4_400.pt"
401
+ if edge_classifier_path.exists():
402
+ try:
403
+ from edge_classifier_v4 import load_classifier_v4, get_dino_model
404
+ ec_model, g_mean, g_std, e_mean, e_std = load_classifier_v4(
405
+ str(edge_classifier_path), device="cpu")
406
+ dino = get_dino_model(device=device)
407
+ edge_classifier_bundle = {
408
+ "model": ec_model,
409
+ "dino": dino,
410
+ "dino_device": device,
411
+ "mean": g_mean.cpu().numpy() if hasattr(g_mean, "cpu") else g_mean,
412
+ "std": g_std.cpu().numpy() if hasattr(g_std, "cpu") else g_std,
413
+ "edge_feat_mean": e_mean.cpu().numpy() if hasattr(e_mean, "cpu") else e_mean,
414
+ "edge_feat_std": e_std.cpu().numpy() if hasattr(e_std, "cpu") else e_std,
415
+ "threshold": 0.15,
416
+ "min_keep_frac": 0.85,
417
+ }
418
+ print(f"Edge classifier v4 loaded ({sum(p.numel() for p in ec_model.parameters()):,} head params + frozen DINOv2)")
419
+ except Exception as ec_err:
420
+ print(f"Edge classifier load failed: {ec_err}; running without")
421
+ edge_classifier_bundle = None
422
+ else:
423
+ print(f"No edge_classifier_v4_400.pt at {edge_classifier_path}; running without")
424
+
425
  # Optional: load 2nd checkpoint for ensemble inference
426
  ensemble_models = None
427
  if USE_ENSEMBLE:
 
581
  print(f" orphan drop failed for {order_id}: {filt_err}")
582
  edges_after_2d = len(pred_e) if hasattr(pred_e, '__len__') else 0
583
 
584
+ # Edge classifier v4 (DINOv2 + geom features): drop the bottom
585
+ # ~15% of edges whose learned P(keep) is lowest. 200-sample
586
+ # local A/B: +0.0030 hss_mean (t=+1.26).
587
+ if edge_classifier_bundle is not None:
588
+ try:
589
+ from edge_classifier_v4 import classify_edges_v4
590
+ from edge_2d_filter import drop_orphan_vertices as _drop_orph
591
+ ec = edge_classifier_bundle
592
+ pred_v, pred_e = classify_edges_v4(
593
+ pred_v, pred_e, sample,
594
+ ec["model"], ec["dino"], device=ec["dino_device"],
595
+ threshold=ec["threshold"],
596
+ feature_mean=ec["mean"], feature_std=ec["std"],
597
+ edge_feat_mean=ec["edge_feat_mean"],
598
+ edge_feat_std=ec["edge_feat_std"],
599
+ min_keep_frac=ec["min_keep_frac"],
600
+ )
601
+ # Re-run orphan drop in case the classifier left orphans
602
+ pred_v, pred_e = _drop_orph(pred_v, pred_e)
603
+ except Exception as ec_err:
604
+ print(f" edge classifier v4 failed for {order_id}: {ec_err}")
605
+
606
  except Exception as e:
607
  import traceback
608
  print(f" Predict failed for {order_id}:\n{traceback.format_exc()}")