IhorIvanyshyn01 commited on
Commit
6cc08ce
·
1 Parent(s): 8f748c3

Optimize DGCNN edge hyper-parameters to beat baseline

Browse files
Files changed (1) hide show
  1. sklearn_submission.py +1210 -0
sklearn_submission.py ADDED
@@ -0,0 +1,1210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sklearn edge classifier + edge validation for submission — self-contained."""
2
+
3
+ import numpy as np
4
+ import cv2
5
+ from typing import Tuple, List
6
+
7
+ from hoho2025.example_solutions import (
8
+ convert_entry_to_human_readable, empty_solution,
9
+ filter_vertices_by_background,
10
+ get_sparse_depth, get_house_mask, get_uv_depth,
11
+ project_vertices_to_3d, merge_vertices_3d,
12
+ prune_not_connected, prune_too_far, point_to_segment_dist,
13
+ )
14
+ from hoho2025.color_mappings import gestalt_color_mapping
15
+
16
+ try:
17
+ from junction import apply_junction_constraints
18
+ except ImportError: # allow running from repo root
19
+ from submission.junction import apply_junction_constraints
20
+
21
+ try:
22
+ from triangulation import predict_wireframe_tracks, get_high_confidence_tracks
23
+ _TRIANGULATION_OK = True
24
+ except Exception:
25
+ try:
26
+ from submission.triangulation import predict_wireframe_tracks, get_high_confidence_tracks
27
+ _TRIANGULATION_OK = True
28
+ except Exception:
29
+ _TRIANGULATION_OK = False
30
+
31
+ try:
32
+ from bundle_adjust import refine_vertices_ba
33
+ _BA_OK = True
34
+ except Exception:
35
+ try:
36
+ from submission.bundle_adjust import refine_vertices_ba
37
+ _BA_OK = True
38
+ except Exception:
39
+ _BA_OK = False
40
+
41
+ try:
42
+ from line_cloud import line_based_vertices
43
+ _LINECLOUD_OK = True
44
+ except Exception:
45
+ try:
46
+ from submission.line_cloud import line_based_vertices
47
+ _LINECLOUD_OK = True
48
+ except Exception:
49
+ _LINECLOUD_OK = False
50
+
51
+ # v11: post-hoc bundle adjustment — DISABLED (see killed.md).
52
+ USE_BUNDLE_ADJUST = False
53
+
54
+ # v11: LC2WF-inspired line-based edges.
55
+ # Fits 3D lines from depth samples along gestalt edge segments, then
56
+ # maps each line's endpoints to the nearest merged_v vertices → edge
57
+ # candidates. Same edges-only-lift strategy that worked for tracks
58
+ # ensemble in v7, but from a different source (depth-sampled lines
59
+ # rather than epipolar-triangulated corners).
60
+ USE_LINE_EDGES = True
61
+ # Sweep history:
62
+ # r=0.5 HSS 0.3381 r=0.8 HSS 0.3428 (v11) r=1.0 HSS 0.3431
63
+ # r=1.2 HSS 0.3441 r=1.5 HSS 0.3436 r=2.0 HSS 0.3408
64
+ # v11 r=0.8 public 0.4157, v12 r=1.0 public 0.4153 (parity).
65
+ # v11 stays the best — keep r=0.8.
66
+ LINE_EDGE_MATCH_RADIUS = 0.8
67
+
68
+ # v15 bypass validate_edge — DISABLED.
69
+ # Hypothesis was that validate_edge dropped geometrically-correct
70
+ # tracks/line edges in sparse COLMAP regions. 100-sample ablation:
71
+ # B bypass tracks −0.0012 HSS
72
+ # C bypass lines −0.0003 HSS
73
+ # D bypass both −0.0004 HSS
74
+ # All three regressed. The truth: validate_edge was NOT the IoU bottleneck;
75
+ # the dropped edges were mostly ghosts, not legitimate ones. The +0.4
76
+ # edges/sample that bypass adds are net-negative on the metric.
77
+ # Code path kept behind the flag for completeness.
78
+ BYPASS_VALIDATE_FOR_TRACKS = False
79
+ BYPASS_VALIDATE_FOR_LINES = False
80
+
81
+ # v17: full winner Stage 1 + Stage 2 (DGCNN vertex refinement).
82
+ # Stage 1: generate_vertex_candidates — gestalt blob → COLMAP centroid.
83
+ # Stage 2: DGCNN vertex classifier — accept/reject + position offset.
84
+ # Stage 1 alone regressed in v16, but with DGCNN refinement the surviving
85
+ # candidates have median distance ~0.3 m to GT (vs ~1 m raw).
86
+ # v17 DGCNN vertex refinement — marginal on 100-sample sweep
87
+ # (ΔHSS +0.001 at best). Disabled by default. Keep this conservative:
88
+ # adding/removing vertices has a larger blast radius than adding edges.
89
+ USE_DGCNN_REFINEMENT = False
90
+ DGCNN_CLS_THRESHOLD = 0.5
91
+ DGCNN_DEDUP_RADIUS = 0.5
92
+ DGCNN_REPLACE_RADIUS = 0.0
93
+ DGCNN_MAX_DIST_TO_CLOUD = 5.0
94
+
95
+ # v18: DGCNN edge classifier — replaces or augments sklearn edge
96
+ # predictions with a PointNet-style model that scores cylindrical 3D
97
+ # patches between vertex pairs. Winner paper: edge classifier gave the
98
+ # biggest single-stage improvement (+0.026 IoU).
99
+ # Sweep on 100 samples (post-prune placement):
100
+ # t=0.3 ΔHSS=−0.0018 t=0.5 +0.0021 t=0.6 +0.0030
101
+ # t=0.7 +0.0039 (peak) t=0.8 +0.0031
102
+ # Clean signal: F1 stable (±0.0006), IoU +0.0065 at t=0.7.
103
+ USE_DGCNN_EDGES = True
104
+ # Ask the edge model for a wider candidate set, then apply our own
105
+ # geometry gates below. This recovers medium-confidence true edges without
106
+ # letting the classifier densify the graph unchecked.
107
+ DGCNN_EDGE_THRESHOLD = 0.60
108
+ DGCNN_EDGE_STRONG_THRESHOLD = 0.70
109
+ DGCNN_EDGE_VERY_STRONG_THRESHOLD = 0.85
110
+ DGCNN_EDGE_MAX_LENGTH = 6.0
111
+ DGCNN_EDGE_MAX_PER_VERTEX = 1
112
+ DGCNN_EDGE_REPROJ_DILATE_PX = 6
113
+
114
+ # v16: 3D vertex candidates from the S23DR 2025 winner Stage 1 — DISABLED.
115
+ # Raw cluster centroids without PointNet Stage 2 refinement have median
116
+ # distance ~0.5–1 m to GT corners (centroid is biased toward COLMAP point
117
+ # mass on roof faces, not the actual corner). 100-sample ablation:
118
+ # v11 baseline HSS=0.3421 F1=0.4093 IoU=0.3067
119
+ # v16 + winner cands HSS=0.3364 F1=0.3961 IoU=0.3059
120
+ # Regressed: +2 vertices and +2 edges per sample but the new vertices are
121
+ # mostly ghosts. Need PointNet Stage 2 (vertex refinement model) to make
122
+ # this useful — that requires training on ~600k samples from the dataset.
123
+ USE_WINNER_CANDIDATES = False
124
+ WINNER_DEDUP_RADIUS = 0.5
125
+ WINNER_MAX_DIST_TO_CLOUD = 8.0
126
+
127
+ # v14 depth-discontinuity edges — DISABLED.
128
+ # 100-sample ablation: HSS Δ = 0.0000 (parity), F1 −0.0002, IoU 0.0000.
129
+ # +0.4 edges/sample added but no metric movement: the new edges either
130
+ # duplicate existing ones or get filtered by validate_edge's tight COLMAP
131
+ # support check (the real bottleneck for IoU growth). Code path kept
132
+ # behind the flag.
133
+ USE_DEPTH_EDGES = False
134
+ DEPTH_EDGE_MATCH_RADIUS = 0.8
135
+
136
+ # v14 post-hoc reranking — DISABLED.
137
+ # 100-sample ablation: A v1 baseline 0.3426, B v1+rerank 0.3426 (parity),
138
+ # C v2-RF 0.3409, D v2-RF+rerank 0.3407. Both line_support and
139
+ # track_support are highly correlated with the existing gestalt_support
140
+ # feature (all three are derived from the same gestalt edge masks),
141
+ # so they add no complementary information. Code path kept behind
142
+ # the flag for completeness.
143
+ USE_RERANK = False
144
+ RERANK_BOOST_LINE = 0.20
145
+ RERANK_BOOST_TRACK = 0.10
146
+
147
+ try:
148
+ from plane_wireframe import predict_plane_edges
149
+ _PLANES_OK = True
150
+ except Exception:
151
+ try:
152
+ from submission.plane_wireframe import predict_plane_edges
153
+ _PLANES_OK = True
154
+ except Exception:
155
+ _PLANES_OK = False
156
+
157
+ try:
158
+ from depth_edges import extract_and_merge_depth_lines
159
+ _DEPTH_EDGES_OK = True
160
+ except Exception:
161
+ try:
162
+ from submission.depth_edges import extract_and_merge_depth_lines
163
+ _DEPTH_EDGES_OK = True
164
+ except Exception:
165
+ _DEPTH_EDGES_OK = False
166
+
167
+ try:
168
+ from winner_candidates import generate_winner_candidates
169
+ _WINNER_OK = True
170
+ except Exception:
171
+ try:
172
+ from submission.winner_candidates import generate_winner_candidates
173
+ _WINNER_OK = True
174
+ except Exception:
175
+ _WINNER_OK = False
176
+
177
+ # v17: load DGCNN refiner once at module import (process-wide singleton).
178
+ _DGCNN_VERTEX_MODEL = None
179
+ _DGCNN_VERTEX_TRIED = False
180
+
181
+
182
+ _DGCNN_EDGE_MODEL = None
183
+ _DGCNN_EDGE_TRIED = False
184
+
185
+
186
+ def _get_dgcnn_edge_model():
187
+ global _DGCNN_EDGE_MODEL, _DGCNN_EDGE_TRIED
188
+ if _DGCNN_EDGE_TRIED:
189
+ return _DGCNN_EDGE_MODEL
190
+ _DGCNN_EDGE_TRIED = True
191
+ try:
192
+ from winner_inference import load_edge_model
193
+ except Exception:
194
+ try:
195
+ from submission.winner_inference import load_edge_model
196
+ except Exception:
197
+ return None
198
+ try:
199
+ import torch as _torch
200
+ device = "cuda" if _torch.cuda.is_available() else "cpu"
201
+ except Exception:
202
+ device = "cpu"
203
+ _DGCNN_EDGE_MODEL = load_edge_model("edge_model_dgcnn.pt", device=device)
204
+ return _DGCNN_EDGE_MODEL
205
+
206
+
207
+ def _get_dgcnn_vertex_model():
208
+ global _DGCNN_VERTEX_MODEL, _DGCNN_VERTEX_TRIED
209
+ if _DGCNN_VERTEX_TRIED:
210
+ return _DGCNN_VERTEX_MODEL
211
+ _DGCNN_VERTEX_TRIED = True
212
+ try:
213
+ from winner_inference import load_vertex_model
214
+ except Exception:
215
+ try:
216
+ from submission.winner_inference import load_vertex_model
217
+ except Exception:
218
+ return None
219
+ import os as _os
220
+ device = "cuda" if _os.environ.get("CUDA_VISIBLE_DEVICES") != "" else "cuda"
221
+ try:
222
+ import torch as _torch
223
+ device = "cuda" if _torch.cuda.is_available() else "cpu"
224
+ except Exception:
225
+ device = "cpu"
226
+ _DGCNN_VERTEX_MODEL = load_vertex_model("vertex_model_dgcnn.pt", device=device)
227
+ return _DGCNN_VERTEX_MODEL
228
+
229
+ # v7: ensemble with the standalone tracks-based predictor.
230
+ # Confirmed on public leaderboard: v7 = 0.4095 (v4 = 0.3815, v6 = 0.3559).
231
+ # Harris sub-pixel + multi-view triangulation edges-only lift is the
232
+ # biggest single gain we have. Keep ON.
233
+ USE_TRACK_ENSEMBLE = True
234
+ ENSEMBLE_MATCH_RADIUS = 0.5
235
+
236
+ # v8 option 1 (isolated track vertices as new vertices) — REJECTED in
237
+ # ablation (100-sample val dropped HSS by −0.005 standalone). Kept code
238
+ # path behind this flag for future tuning, default OFF.
239
+ ADD_ISOLATED_TRACK_VERTICES = False
240
+ ISOLATED_TRACK_MIN_DIST = 0.8
241
+ ISOLATED_TRACK_MAX_DIST = 3.5
242
+
243
+ # v13 high-confidence tracks-as-vertices — DISABLED.
244
+ # 100-sample ablation showed +0.0002 HSS / +0.0027 F1 / +0.0013 IoU.
245
+ # F1 + IoU both signed positive (rare among our killed experiments) but
246
+ # HSS delta is in noise range. Code path kept behind the flag for future
247
+ # tuning or for combination with other refinements.
248
+ USE_TRACKS_AS_VERTICES = False
249
+ TRACK_MIN_VIEWS = 3
250
+ TRACK_MAX_REPROJ_PX = 2.0
251
+ TRACK_REPLACE_RADIUS = 0.6
252
+ TRACK_ADD_MAX_RADIUS = 2.0
253
+ TRACK_ADD_MIN_RADIUS = 0.6
254
+
255
+ # v8 reprojection-based edge validation — REVERTED (public regression).
256
+ # Local 100-sample tuning picked (mv=2, hit=0.5, dil=3) for +0.0095 HSS
257
+ # locally. Public leaderboard v8: 0.3998 vs v7 0.4095 → −0.0097.
258
+ # F1 went up (orphan vertex pruning works) but IoU dropped by ~0.02
259
+ # because the filter removes real edges where gestalt segmentation has
260
+ # gaps in the public test set. The 100-sample local validation set is
261
+ # systematically denser in gestalt coverage than the public test, so
262
+ # the local sweep was anti-predictive. Code path kept behind the flag
263
+ # for future tuning with a much larger validation set.
264
+ USE_REPROJECTION_EDGE_VAL = False
265
+ REPROJ_MIN_VIEWS = 2
266
+ REPROJ_MIN_HIT_FRAC = 0.5
267
+ REPROJ_MASK_DILATE_PX = 3
268
+
269
+ # v8: plane-intersection edges augmentation.
270
+ # Default OFF — 100-sample eval showed ΔHSS < 0.001.
271
+ # See reports/killed.md for details.
272
+ USE_PLANE_EDGES = False
273
+ PLANE_PERP_TOL = 0.8
274
+
275
+
276
+ def _refine_centroids_subpix(gest_seg_np, centroids, max_shift=4.0, win=5):
277
+ """Run cv2.cornerSubPix on the grayscale gestalt image, seeded at centroids.
278
+
279
+ Apex blobs sit at junctions where multiple coloured edge classes meet; in
280
+ the grayscale view that shows up as a real corner pattern. We feed the
281
+ centroid as a starting point, refine, and reject any refinement whose
282
+ displacement from the centroid exceeds ``max_shift`` pixels (likely
283
+ divergence to an unrelated texture).
284
+ """
285
+ if len(centroids) == 0:
286
+ return centroids
287
+ gray = cv2.cvtColor(gest_seg_np, cv2.COLOR_RGB2GRAY)
288
+ gray = cv2.GaussianBlur(gray, (3, 3), 0)
289
+ pts = np.asarray(centroids, dtype=np.float32).reshape(-1, 1, 2).copy()
290
+ criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.01)
291
+ try:
292
+ refined = cv2.cornerSubPix(gray, pts, (win, win), (-1, -1), criteria)
293
+ except cv2.error:
294
+ return centroids
295
+ refined = refined.reshape(-1, 2)
296
+ orig = np.asarray(centroids, dtype=np.float32)
297
+ shifts = np.linalg.norm(refined - orig, axis=1)
298
+ mask = shifts <= max_shift
299
+ out = orig.copy()
300
+ out[mask] = refined[mask]
301
+ return out
302
+
303
+
304
+ def get_vertices_and_edges_improved(gest_seg_np, edge_th=15.0, refine_subpix=True):
305
+ vertices = []
306
+ for v_class in ['apex', 'eave_end_point', 'flashing_end_point']:
307
+ color = np.array(gestalt_color_mapping[v_class])
308
+ mask = cv2.inRange(gest_seg_np, color - 0.5, color + 0.5)
309
+ if mask.sum() == 0:
310
+ continue
311
+ _, _, _, centroids = cv2.connectedComponentsWithStats(mask, 8, cv2.CV_32S)
312
+ blob_centroids = centroids[1:]
313
+ if refine_subpix and len(blob_centroids) > 0:
314
+ blob_centroids = _refine_centroids_subpix(gest_seg_np, blob_centroids)
315
+ for centroid in blob_centroids:
316
+ vertices.append({"xy": np.asarray(centroid, dtype=np.float32), "type": v_class})
317
+ apex_pts = np.array([v['xy'] for v in vertices]) if vertices else np.empty((0, 2))
318
+ connections = []
319
+ for edge_class in ['eave', 'ridge', 'rake', 'valley', 'hip']:
320
+ edge_color = np.array(gestalt_color_mapping[edge_class])
321
+ mask_raw = cv2.inRange(gest_seg_np, edge_color - 0.5, edge_color + 0.5)
322
+ mask = cv2.morphologyEx(mask_raw, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
323
+ if mask.sum() == 0:
324
+ continue
325
+ _, labels, _, _ = cv2.connectedComponentsWithStats(mask, 8, cv2.CV_32S)
326
+ for lbl in range(1, labels.max() + 1):
327
+ ys, xs = np.where(labels == lbl)
328
+ if len(xs) < 2:
329
+ continue
330
+ pts = np.column_stack([xs, ys]).astype(np.float32)
331
+ line_params = cv2.fitLine(pts, cv2.DIST_L2, 0, 0.01, 0.01)
332
+ vx, vy, x0, y0 = line_params.ravel()
333
+ proj = (xs - x0) * vx + (ys - y0) * vy
334
+ p1 = np.array([x0 + proj.min() * vx, y0 + proj.min() * vy])
335
+ p2 = np.array([x0 + proj.max() * vx, y0 + proj.max() * vy])
336
+ if len(apex_pts) < 2:
337
+ continue
338
+ dists = np.array([point_to_segment_dist(apex_pts[i], p1, p2) for i in range(len(apex_pts))])
339
+ near = np.where(dists <= edge_th)[0]
340
+ if len(near) < 2:
341
+ continue
342
+ near_pts = apex_pts[near]
343
+ a = near[np.argmin(np.linalg.norm(near_pts - p1, axis=1))]
344
+ b = near[np.argmin(np.linalg.norm(near_pts - p2, axis=1))]
345
+ if a != b:
346
+ connections.append(tuple(sorted((a, b))))
347
+ return vertices, connections
348
+
349
+
350
+ def fit_affine_ransac(depth, sparse_depth, validity_mask=None, n_iter=200, inlier_th=0.3):
351
+ """Fit affine depth correction: depth_corrected = alpha * depth + beta.
352
+
353
+ Scale+shift (2 DOF) is more accurate than scale-only when MoGe has systematic offset.
354
+ Falls back to scale-only if not enough sparse points for 2-parameter fit.
355
+ """
356
+ mask = (sparse_depth > 0) if validity_mask is None else (sparse_depth > 0) & validity_mask
357
+ mask = mask & (depth < 50) & (sparse_depth < 50) & (depth > 0)
358
+ X, Y = depth[mask], sparse_depth[mask]
359
+ if len(X) < 5:
360
+ if len(X) == 0 or np.all(X == 0):
361
+ return 1.0, 0.0, depth
362
+ alpha = float(np.median(Y / X))
363
+ return alpha, 0.0, alpha * depth
364
+ if len(X) < 10:
365
+ # Not enough points for affine — use scale only
366
+ alpha = float(np.median(Y / X))
367
+ return alpha, 0.0, alpha * depth
368
+
369
+ # RANSAC affine fit: sample 2 points, solve linear system
370
+ best_alpha, best_beta, best_n = float(np.median(Y / X)), 0.0, 0
371
+
372
+ for _ in range(n_iter):
373
+ idx = np.random.choice(len(X), 2, replace=False)
374
+ x1, x2 = X[idx[0]], X[idx[1]]
375
+ y1, y2 = Y[idx[0]], Y[idx[1]]
376
+ if abs(x1 - x2) < 1e-6:
377
+ continue
378
+ alpha = (y1 - y2) / (x1 - x2)
379
+ beta = y1 - alpha * x1
380
+ if alpha <= 0.05 or alpha > 20.0: # sanity check
381
+ continue
382
+ residuals = np.abs(alpha * X + beta - Y)
383
+ n_inliers = (residuals < inlier_th).sum()
384
+ if n_inliers > best_n:
385
+ best_n = n_inliers
386
+ inlier_mask = residuals < inlier_th
387
+ # Refit on all inliers via least squares
388
+ Xi, Yi = X[inlier_mask], Y[inlier_mask]
389
+ A = np.column_stack([Xi, np.ones_like(Xi)])
390
+ try:
391
+ result = np.linalg.lstsq(A, Yi, rcond=None)[0]
392
+ if result[0] > 0.05:
393
+ best_alpha, best_beta = float(result[0]), float(result[1])
394
+ except Exception:
395
+ best_alpha, best_beta = alpha, beta
396
+
397
+ corrected = np.clip(best_alpha * depth + best_beta, 0.1, 100.0)
398
+ return best_alpha, best_beta, corrected
399
+
400
+
401
+ def fit_scale_ransac(depth, sparse_depth, validity_mask=None, n_iter=100, inlier_th=0.3):
402
+ """Legacy scale-only fitting. Use fit_affine_ransac for better accuracy."""
403
+ _, _, corrected = fit_affine_ransac(depth, sparse_depth, validity_mask, n_iter, inlier_th)
404
+ return None, corrected
405
+
406
+
407
+ EDGE_CLASSES_FOR_VAL = ['eave', 'ridge', 'rake', 'valley', 'hip']
408
+
409
+
410
+ def _build_gestalt_edge_masks(entry, dilate_px: int = 3):
411
+ """Build a ``dict[image_id → (H, W) uint8]`` of gestalt edge masks.
412
+
413
+ Each mask is the union of all configured edge classes' pixels, dilated
414
+ by ``dilate_px`` so that a sub-pixel reprojection line can still land
415
+ on an edge pixel despite rendering / quantisation noise.
416
+
417
+ Returns ``(masks, views)``:
418
+ masks : dict[image_id → (H, W) bool]
419
+ views : dict[image_id → mvs_utils.ViewInfo] for projection.
420
+ """
421
+ try:
422
+ from hoho2025.example_solutions import convert_entry_to_human_readable as _conv
423
+ from hoho2025.color_mappings import gestalt_color_mapping as _gcm
424
+ except Exception:
425
+ return {}, {}
426
+
427
+ try:
428
+ from mvs_utils import collect_views as _cv
429
+ except Exception:
430
+ try:
431
+ from submission.mvs_utils import collect_views as _cv
432
+ except Exception:
433
+ return {}, {}
434
+
435
+ good = _conv(entry)
436
+ colmap_rec = good.get('colmap') or good.get('colmap_binary')
437
+ if colmap_rec is None:
438
+ return {}, {}
439
+
440
+ views = _cv(colmap_rec, good['image_ids'])
441
+ masks: dict[str, np.ndarray] = {}
442
+
443
+ kernel = None
444
+ if dilate_px > 0:
445
+ k = 2 * dilate_px + 1
446
+ kernel = np.ones((k, k), np.uint8)
447
+
448
+ for gest, img_id in zip(good['gestalt'], good['image_ids']):
449
+ if img_id not in views:
450
+ continue
451
+ info = views[img_id]
452
+ W, H = info['width'], info['height']
453
+ gest_np = np.array(gest.resize((W, H))).astype(np.uint8)
454
+ union_mask = np.zeros((H, W), dtype=np.uint8)
455
+ for ecls in EDGE_CLASSES_FOR_VAL:
456
+ color = np.array(_gcm[ecls])
457
+ m = cv2.inRange(gest_np, color - 0.5, color + 0.5)
458
+ if m.sum():
459
+ union_mask = np.maximum(union_mask, m)
460
+ if kernel is not None and union_mask.sum():
461
+ union_mask = cv2.dilate(union_mask, kernel, iterations=1)
462
+ masks[img_id] = union_mask > 0
463
+
464
+ return masks, views
465
+
466
+
467
+ def validate_edge_reprojection(
468
+ v1: np.ndarray, v2: np.ndarray,
469
+ masks: dict, views: dict,
470
+ n_samples: int = 20,
471
+ min_views: int = 2,
472
+ min_hit_frac: float = 0.4,
473
+ ) -> bool:
474
+ """Check that the edge's projection lies on gestalt edge pixels in at
475
+ least ``min_views`` views, with ≥ ``min_hit_frac`` of sampled points
476
+ landing on an edge pixel.
477
+
478
+ If no masks at all are available (e.g. entry lacks gestalt images),
479
+ the check returns True so it never blocks the pipeline.
480
+ """
481
+ if not masks or not views:
482
+ return True
483
+ t = np.linspace(0.0, 1.0, n_samples)
484
+ samples = v1 + t[:, None] * (v2 - v1)
485
+ ok_views = 0
486
+ for img_id, mask in masks.items():
487
+ info = views.get(img_id)
488
+ if info is None:
489
+ continue
490
+ P = info['P']
491
+ H, W = mask.shape
492
+ homog = np.hstack([samples, np.ones((len(samples), 1))])
493
+ proj = homog @ P.T
494
+ z = proj[:, 2]
495
+ if np.any(z <= 1e-6):
496
+ continue
497
+ uv = proj[:, :2] / z[:, None]
498
+ u = np.round(uv[:, 0]).astype(np.int64)
499
+ vv = np.round(uv[:, 1]).astype(np.int64)
500
+ in_bounds = (u >= 0) & (u < W) & (vv >= 0) & (vv < H)
501
+ if not np.any(in_bounds):
502
+ continue
503
+ u_in = u[in_bounds]
504
+ v_in = vv[in_bounds]
505
+ hits = mask[v_in, u_in]
506
+ hit_frac = float(hits.sum()) / max(1, int(in_bounds.sum()))
507
+ if hit_frac >= min_hit_frac:
508
+ ok_views += 1
509
+ if ok_views >= min_views:
510
+ return True
511
+ return ok_views >= min_views
512
+
513
+
514
+ def _passes_dgcnn_edge_gates(
515
+ v1: np.ndarray,
516
+ v2: np.ndarray,
517
+ prob: float,
518
+ all_xyz: np.ndarray,
519
+ kd_tree=None,
520
+ masks: dict | None = None,
521
+ views: dict | None = None,
522
+ ) -> bool:
523
+ """Conservative accept rule for learned edge candidates.
524
+
525
+ The DGCNN classifier is useful for recall, but raw learned edges can hurt
526
+ IoU if accepted without geometry. Strong candidates need COLMAP support;
527
+ very strong candidates may pass with looser sparse support; medium
528
+ candidates must also reproject onto gestalt edge pixels.
529
+ """
530
+ length = float(np.linalg.norm(v2 - v1))
531
+ if length < 0.25 or length > DGCNN_EDGE_MAX_LENGTH:
532
+ return False
533
+
534
+ strong_support = validate_edge(
535
+ v1, v2, all_xyz, kd_tree,
536
+ n_samples=24, radius=0.45, min_ratio=0.55,
537
+ )
538
+ if prob >= DGCNN_EDGE_STRONG_THRESHOLD and strong_support:
539
+ return True
540
+
541
+ loose_support = validate_edge(
542
+ v1, v2, all_xyz, kd_tree,
543
+ n_samples=24, radius=0.60, min_ratio=0.35,
544
+ )
545
+ if prob >= DGCNN_EDGE_VERY_STRONG_THRESHOLD and loose_support:
546
+ return True
547
+
548
+ if prob >= DGCNN_EDGE_STRONG_THRESHOLD and loose_support and masks and views:
549
+ return validate_edge_reprojection(
550
+ v1, v2, masks, views,
551
+ n_samples=24, min_views=1, min_hit_frac=0.35,
552
+ )
553
+
554
+ return False
555
+
556
+
557
+ def _select_dgcnn_edges(
558
+ final_v: np.ndarray,
559
+ final_e: list,
560
+ dgcnn_edges: list,
561
+ all_xyz: np.ndarray,
562
+ kd_tree=None,
563
+ masks: dict | None = None,
564
+ views: dict | None = None,
565
+ ) -> list[tuple[int, int]]:
566
+ """Filter and degree-cap DGCNN edge proposals.
567
+
568
+ Existing edges are never removed here. At most
569
+ ``DGCNN_EDGE_MAX_PER_VERTEX`` learned edges are added at each vertex,
570
+ prioritising higher classifier probabilities.
571
+ """
572
+ existing = {tuple(sorted(e)) for e in final_e}
573
+ candidates = []
574
+ for i, j, prob in dgcnn_edges:
575
+ lo, hi = (int(i), int(j)) if i < j else (int(j), int(i))
576
+ if lo == hi or (lo, hi) in existing:
577
+ continue
578
+ prob = float(prob)
579
+ if _passes_dgcnn_edge_gates(
580
+ final_v[lo], final_v[hi], prob,
581
+ all_xyz, kd_tree, masks=masks, views=views,
582
+ ):
583
+ candidates.append((prob, lo, hi))
584
+
585
+ candidates.sort(reverse=True)
586
+ added_per_vertex = np.zeros(len(final_v), dtype=np.int32)
587
+ accepted: list[tuple[int, int]] = []
588
+ accepted_set = set()
589
+ for prob, lo, hi in candidates:
590
+ if (lo, hi) in accepted_set:
591
+ continue
592
+ if (added_per_vertex[lo] >= DGCNN_EDGE_MAX_PER_VERTEX
593
+ or added_per_vertex[hi] >= DGCNN_EDGE_MAX_PER_VERTEX):
594
+ continue
595
+ accepted.append((lo, hi))
596
+ accepted_set.add((lo, hi))
597
+ added_per_vertex[lo] += 1
598
+ added_per_vertex[hi] += 1
599
+ return accepted
600
+
601
+
602
+ def validate_edge(v1, v2, all_xyz, kd_tree=None, n_samples=20, radius=0.35, min_ratio=0.70):
603
+ """Check if edge v1→v2 is supported by COLMAP point cloud.
604
+
605
+ Uses KD-tree for O(N log N) queries instead of O(N*n_samples).
606
+
607
+ History of this parameter:
608
+ v4: loose (n=10, r=0.5, mr=0.4) public 0.3815
609
+ v6: tight (n=20, r=0.35, mr=0.7) public 0.3559 → regression!
610
+ v7: tight (same) + tracks ensemble public 0.4095 → big win
611
+ v9: loose (reverted, by mistake) + tracks public 0.3832 → regression
612
+ v10 (current): tight restored → target paritet with v7 at 0.4095
613
+
614
+ The tight validate_edge is ONLY good in combination with the multi-view
615
+ tracks ensemble. Alone (v6) it removes too many real edges and loses
616
+ IoU. With tracks ensemble adding complementary edges, the tight filter
617
+ becomes a net win. Do not revert without also removing the tracks
618
+ ensemble.
619
+ """
620
+ if len(all_xyz) == 0:
621
+ return True
622
+ t = np.linspace(0, 1, n_samples)
623
+ samples = v1 + t[:, None] * (v2 - v1)
624
+ if kd_tree is not None:
625
+ dists, _ = kd_tree.query(samples, k=1)
626
+ supported = (dists <= radius).sum()
627
+ else:
628
+ supported = sum(1 for s in samples if np.linalg.norm(all_xyz - s, axis=1).min() <= radius)
629
+ return supported / n_samples >= min_ratio
630
+
631
+
632
+ def extract_edge_features(v1, v2, all_xyz, gestalt_support=0, n_views=0,
633
+ line_support=None, track_support=None):
634
+ """Build the per-pair edge feature vector.
635
+
636
+ By default returns the original 15-D vector (v1 sklearn model).
637
+ If either ``line_support`` or ``track_support`` is supplied, returns
638
+ a 17-D vector compatible with the v2 sklearn model.
639
+ """
640
+ diff = v2 - v1
641
+ dist = np.linalg.norm(diff)
642
+ mid = (v1 + v2) / 2.0
643
+ h_diff = abs(diff[2])
644
+ h_dist = np.linalg.norm(diff[:2])
645
+ slope = np.arctan2(h_diff, h_dist + 1e-6)
646
+ if len(all_xyz) > 0 and dist > 0.01:
647
+ edge_dir = diff / dist
648
+ rel = all_xyz - v1
649
+ proj = rel @ edge_dir
650
+ perp = np.linalg.norm(rel - proj[:, None] * edge_dir, axis=1)
651
+ in_cyl = (proj >= -0.5) & (proj <= dist + 0.5) & (perp <= 0.5)
652
+ n_along = in_cyl.sum()
653
+ n_mid = (np.linalg.norm(all_xyz - mid, axis=1) <= 1.0).sum()
654
+ density = n_along / max(dist, 0.01)
655
+ else:
656
+ n_along, n_mid, density = 0, 0, 0
657
+ base = [dist, h_diff, h_dist, slope, n_along, n_mid, density,
658
+ gestalt_support, n_views, 0, 0, 0, 0, v1[2], v2[2]]
659
+ if line_support is not None or track_support is not None:
660
+ base.append(int(line_support or 0))
661
+ base.append(int(track_support or 0))
662
+ return np.array(base, dtype=np.float32)
663
+
664
+
665
+ def _line_support_for_edge(v1, v2, lines, perp_tol=0.5, min_overlap=0.5):
666
+ """1 if any 3D line in ``lines`` runs alongside the (v1, v2) edge.
667
+
668
+ Both line endpoints must lie within ``perp_tol`` perpendicular distance
669
+ of the edge's infinite line, AND the projection overlap must be at
670
+ least ``min_overlap`` × edge length.
671
+ """
672
+ if not lines:
673
+ return 0
674
+ edge_dir = v2 - v1
675
+ edge_len = float(np.linalg.norm(edge_dir))
676
+ if edge_len < 0.05:
677
+ return 0
678
+ edge_dir = edge_dir / edge_len
679
+ for ln in lines:
680
+ s1 = float(np.dot(ln.p1 - v1, edge_dir))
681
+ s2 = float(np.dot(ln.p2 - v1, edge_dir))
682
+ perp1 = ln.p1 - v1 - s1 * edge_dir
683
+ perp2 = ln.p2 - v1 - s2 * edge_dir
684
+ if np.linalg.norm(perp1) > perp_tol or np.linalg.norm(perp2) > perp_tol:
685
+ continue
686
+ lo = max(0.0, min(s1, s2))
687
+ hi = min(edge_len, max(s1, s2))
688
+ if hi - lo >= min_overlap * edge_len:
689
+ return 1
690
+ return 0
691
+
692
+
693
+ def _lift_track_edges_to_merged_v(tracks, t_edges, merged_v, match_radius=0.5):
694
+ """Map per-track edge votes onto pairs of merged_v indices."""
695
+ if not tracks or not t_edges or len(merged_v) == 0:
696
+ return set()
697
+ track_xyz = np.array([t.xyz for t in tracks], dtype=np.float64)
698
+ from scipy.spatial import cKDTree
699
+ tree = cKDTree(merged_v)
700
+ track_to_merged = {}
701
+ for ti in range(len(tracks)):
702
+ d, j = tree.query(track_xyz[ti])
703
+ if d <= match_radius:
704
+ track_to_merged[ti] = int(j)
705
+ out = set()
706
+ for ti, tj, _votes in t_edges:
707
+ a = track_to_merged.get(ti)
708
+ b = track_to_merged.get(tj)
709
+ if a is None or b is None or a == b:
710
+ continue
711
+ out.add((a, b) if a < b else (b, a))
712
+ return out
713
+
714
+
715
+ def predict_wireframe_sklearn(entry, sklearn_model=None, edge_threshold=0.5):
716
+ good = convert_entry_to_human_readable(entry)
717
+ colmap_rec = good.get('colmap', good.get('colmap_binary'))
718
+
719
+ vert_edge_per_image = {}
720
+ for i, (gest, depth, img_id, ade_seg) in enumerate(zip(
721
+ good['gestalt'], good['depth'], good['image_ids'], good['ade']
722
+ )):
723
+ depth_size = (np.array(depth).shape[1], np.array(depth).shape[0])
724
+ gest_np = np.array(gest.resize(depth_size)).astype(np.uint8)
725
+ verts, conns = get_vertices_and_edges_improved(gest_np, edge_th=15.0)
726
+ ade_np = np.array(ade_seg.resize(depth_size)).astype(np.uint8)
727
+ verts, conns = filter_vertices_by_background(verts, conns, ade_np)
728
+ if len(verts) < 2 or len(conns) < 1:
729
+ vert_edge_per_image[i] = [], [], np.empty((0, 3))
730
+ continue
731
+ depth_np = np.array(depth) / 1000.0
732
+ depth_sparse, found, col_img, proj_pts = get_sparse_depth(colmap_rec, img_id, depth_np)
733
+ if found:
734
+ _, _, depth_fitted = fit_affine_ransac(depth_np, depth_sparse, get_house_mask(ade_seg))
735
+ else:
736
+ depth_fitted = depth_np
737
+ uv, dv = get_uv_depth(verts, depth_fitted,
738
+ depth_sparse if found else np.zeros_like(depth_np),
739
+ search_radius=10, proj_pts=proj_pts)
740
+ v3d = project_vertices_to_3d(uv, dv, col_img, colmap_rec=colmap_rec)
741
+ vert_edge_per_image[i] = verts, conns, v3d
742
+
743
+ if not any(len(v[0]) > 0 for v in vert_edge_per_image.values()):
744
+ return empty_solution()
745
+
746
+ merged_v, heur_edges, vertex_views, _ = merge_vertices_3d(vert_edge_per_image, 0.8)
747
+ merged_v, heur_edges = prune_too_far(merged_v, heur_edges, colmap_rec, th=5.0)
748
+ if len(merged_v) < 2:
749
+ return empty_solution()
750
+
751
+ # v13: replace/add vertices from high-confidence triangulation tracks.
752
+ # Tracks with ≥3 views and ≤2 px reproj have 5–10cm 3D accuracy, much
753
+ # better than depth-based unprojection (30–100cm). The pairing rule:
754
+ # * track within REPLACE_RADIUS of any merged_v → replace that vertex;
755
+ # * track between ADD_MIN_RADIUS and ADD_MAX_RADIUS from any merged_v
756
+ # → add as new vertex (sparse coverage region);
757
+ # * else ignore.
758
+ # Edges already in heur_edges are remapped to use new indices when an
759
+ # add happens. Replaces preserve indices.
760
+ if USE_TRACKS_AS_VERTICES and _TRIANGULATION_OK and len(merged_v) >= 1:
761
+ try:
762
+ hc_tracks = get_high_confidence_tracks(
763
+ entry,
764
+ min_views=TRACK_MIN_VIEWS,
765
+ max_reproj_px=TRACK_MAX_REPROJ_PX,
766
+ )
767
+ if hc_tracks:
768
+ from scipy.spatial import cKDTree as _cKD13
769
+ tree13 = _cKD13(merged_v)
770
+ added = []
771
+ replaced_set = set()
772
+ for t in hc_tracks:
773
+ d, j = tree13.query(t.xyz, k=1)
774
+ if d <= TRACK_REPLACE_RADIUS:
775
+ if j in replaced_set:
776
+ continue # do not double-replace one merged vertex
777
+ merged_v[j] = t.xyz
778
+ replaced_set.add(int(j))
779
+ elif TRACK_ADD_MIN_RADIUS < d <= TRACK_ADD_MAX_RADIUS:
780
+ added.append(t.xyz)
781
+ if added:
782
+ merged_v = np.vstack([merged_v, np.asarray(added, dtype=np.float64)])
783
+ # vertex_views needs to track new entries (use 0 = unknown)
784
+ vertex_views = list(vertex_views) + [0] * len(added)
785
+ except Exception:
786
+ pass
787
+
788
+ # v17: winner Stage 1 + Stage 2 (DGCNN refinement).
789
+ # Generate Stage 1 candidates, run DGCNN vertex classifier on them,
790
+ # and use the refined output to either replace or augment merged_v.
791
+ if USE_DGCNN_REFINEMENT:
792
+ try:
793
+ from s23dr.data_prep.vertex_candidates import generate_vertex_candidates
794
+ from winner_inference import refine_winner_candidates
795
+ except Exception:
796
+ try:
797
+ from submission.winner_inference import refine_winner_candidates
798
+ from s23dr.data_prep.vertex_candidates import generate_vertex_candidates
799
+ except Exception:
800
+ generate_vertex_candidates = None
801
+ refine_winner_candidates = None
802
+ model = _get_dgcnn_vertex_model()
803
+ if model is not None and generate_vertex_candidates is not None:
804
+ try:
805
+ cands = generate_vertex_candidates(entry, colmap_rec)
806
+ if cands:
807
+ refined = refine_winner_candidates(
808
+ cands, entry, model,
809
+ device=("cuda" if __import__('torch').cuda.is_available() else "cpu"),
810
+ cls_threshold=DGCNN_CLS_THRESHOLD,
811
+ )
812
+ if refined:
813
+ from scipy.spatial import cKDTree as _cKD17
814
+ tree17 = _cKD17(merged_v) if len(merged_v) >= 1 else None
815
+ new_pts = []
816
+ replaced = set()
817
+ for xyz, _score in refined:
818
+ xyz_arr = np.asarray(xyz, dtype=np.float64)
819
+ if tree17 is None:
820
+ new_pts.append(xyz_arr)
821
+ continue
822
+ d, j = tree17.query(xyz_arr, k=1)
823
+ if d <= DGCNN_REPLACE_RADIUS:
824
+ # Replace the existing vertex with the refined one
825
+ if int(j) not in replaced:
826
+ merged_v[int(j)] = xyz_arr
827
+ replaced.add(int(j))
828
+ elif DGCNN_DEDUP_RADIUS < d <= DGCNN_MAX_DIST_TO_CLOUD:
829
+ new_pts.append(xyz_arr)
830
+ if new_pts:
831
+ merged_v = np.vstack([merged_v, np.array(new_pts, dtype=np.float64)])
832
+ vertex_views = list(vertex_views) + [0] * len(new_pts)
833
+ except Exception:
834
+ pass
835
+
836
+ # v16: augment merged_v with winner-style 3D vertex candidates.
837
+ # Each candidate is the centroid of ≥5 COLMAP points whose projection
838
+ # falls inside a dilated gestalt corner blob — fully 3D, no depth lift.
839
+ # We add only candidates that are not duplicates of existing merged_v
840
+ # (within WINNER_DEDUP_RADIUS) and not absurdly far from any other
841
+ # vertex (which would be COLMAP outliers).
842
+ if USE_WINNER_CANDIDATES and _WINNER_OK and len(merged_v) >= 1:
843
+ try:
844
+ cands, _ = generate_winner_candidates(entry)
845
+ if cands:
846
+ cand_xyz = np.array([c.centroid for c in cands], dtype=np.float64)
847
+ from scipy.spatial import cKDTree as _cKD16
848
+ tree16 = _cKD16(merged_v)
849
+ d, _j = tree16.query(cand_xyz, k=1)
850
+ # Sanity: candidate must be within reasonable distance to
851
+ # the existing wireframe but not duplicate.
852
+ keep_mask = (d > WINNER_DEDUP_RADIUS) & (d <= WINNER_MAX_DIST_TO_CLOUD)
853
+ new = cand_xyz[keep_mask]
854
+ if len(new) > 0:
855
+ merged_v = np.vstack([merged_v, new])
856
+ vertex_views = list(vertex_views) + [0] * len(new)
857
+ except Exception:
858
+ pass
859
+
860
+ all_xyz = np.array([p.xyz for p in colmap_rec.points3D.values()])
861
+ heur_set = set(tuple(sorted(e)) for e in heur_edges)
862
+
863
+ # Build KD-tree once for fast edge validation
864
+ kd_tree = None
865
+ if len(all_xyz) > 0:
866
+ try:
867
+ from scipy.spatial import KDTree
868
+ kd_tree = KDTree(all_xyz)
869
+ except Exception:
870
+ pass
871
+
872
+ # If sklearn model available, add ML edges.
873
+ # The model is auto-detected as v2 (17 features) or v1 (15 features) by
874
+ # `n_features_in_`. We precompute 3D lines + triangulation tracks once
875
+ # whenever we need them for either v2 features OR v1+rerank.
876
+ _v2_model = (
877
+ sklearn_model is not None
878
+ and getattr(sklearn_model, 'n_features_in_', 15) == 17
879
+ )
880
+ _need_line_track = (_v2_model or USE_RERANK) and _TRIANGULATION_OK
881
+ _precomputed_lines = None
882
+ _precomputed_tracks_lifted = None
883
+ if _need_line_track:
884
+ try:
885
+ from triangulation import triangulate_wireframe as _triwf
886
+ except ImportError:
887
+ try:
888
+ from submission.triangulation import triangulate_wireframe as _triwf
889
+ except ImportError:
890
+ _triwf = None
891
+ try:
892
+ from line_cloud import extract_3d_lines as _e3l, merge_3d_lines as _m3l
893
+ except ImportError:
894
+ try:
895
+ from submission.line_cloud import extract_3d_lines as _e3l, merge_3d_lines as _m3l
896
+ except ImportError:
897
+ _e3l = _m3l = None
898
+ if _triwf is not None:
899
+ try:
900
+ _t, _v, _g, _te = _triwf(entry, want_edges=True)
901
+ _precomputed_tracks_lifted = _lift_track_edges_to_merged_v(
902
+ _t, _te, merged_v, match_radius=ENSEMBLE_MATCH_RADIUS,
903
+ )
904
+ except Exception:
905
+ pass
906
+ if _e3l is not None:
907
+ try:
908
+ _raw_lines, _ = _e3l(entry)
909
+ _precomputed_lines = _m3l(_raw_lines)
910
+ except Exception:
911
+ _precomputed_lines = None
912
+
913
+ if sklearn_model is not None:
914
+ features_list, pairs, supports = [], [], []
915
+ n = len(merged_v)
916
+ for i in range(n):
917
+ for j in range(i + 1, n):
918
+ if np.linalg.norm(merged_v[i] - merged_v[j]) > 8.0:
919
+ continue
920
+ gs = 1 if (i, j) in heur_set else 0
921
+ nv = min(vertex_views[i], vertex_views[j]) if len(vertex_views) > max(i, j) else 0
922
+
923
+ # Compute line/track support if either path needs it.
924
+ ls = ts = 0
925
+ if _need_line_track:
926
+ ls = _line_support_for_edge(
927
+ merged_v[i], merged_v[j], _precomputed_lines or [],
928
+ )
929
+ key = (i, j) if i < j else (j, i)
930
+ ts = 1 if (_precomputed_tracks_lifted and key in _precomputed_tracks_lifted) else 0
931
+
932
+ if _v2_model:
933
+ feat = extract_edge_features(
934
+ merged_v[i], merged_v[j], all_xyz, gs, nv,
935
+ line_support=ls, track_support=ts,
936
+ )
937
+ else:
938
+ feat = extract_edge_features(merged_v[i], merged_v[j], all_xyz, gs, nv)
939
+ features_list.append(feat)
940
+ pairs.append((i, j))
941
+ supports.append((ls, ts))
942
+
943
+ if features_list:
944
+ X = np.array(features_list)
945
+ probs = sklearn_model.predict_proba(X)[:, 1]
946
+ # v14 post-hoc reranking — boost probs for pairs that have
947
+ # complementary 3D evidence the classifier may have missed.
948
+ if USE_RERANK:
949
+ for k in range(len(pairs)):
950
+ ls, ts = supports[k]
951
+ if ls:
952
+ probs[k] = min(1.0, probs[k] + RERANK_BOOST_LINE)
953
+ if ts:
954
+ probs[k] = min(1.0, probs[k] + RERANK_BOOST_TRACK)
955
+ for k in range(len(pairs)):
956
+ if probs[k] >= edge_threshold:
957
+ heur_set.add(tuple(sorted(pairs[k])))
958
+
959
+ edges = list(heur_set)
960
+
961
+ # 3D edge validation
962
+ validated = [e for e in edges if validate_edge(merged_v[e[0]], merged_v[e[1]], all_xyz, kd_tree)]
963
+ if not validated:
964
+ validated = edges
965
+
966
+ # T2: plane-intersection edge augmentation.
967
+ # Fits planes via RANSAC on COLMAP sparse points, computes plane-pair
968
+ # intersection lines, and votes an edge between any pair of merged_v
969
+ # vertices that both lie within PLANE_PERP_TOL of the same line. Edges
970
+ # are validated against the same COLMAP support check as sklearn edges.
971
+ if USE_PLANE_EDGES and _PLANES_OK and len(merged_v) >= 2:
972
+ try:
973
+ extra = predict_plane_edges(entry, merged_v, perp_tol=PLANE_PERP_TOL)
974
+ if extra:
975
+ validated_set = set(tuple(sorted(e)) for e in validated)
976
+ new_edges = [
977
+ (a, b) for (a, b) in extra
978
+ if (min(a, b), max(a, b)) not in validated_set
979
+ ]
980
+ new_valid = [
981
+ e for e in new_edges
982
+ if validate_edge(merged_v[e[0]], merged_v[e[1]], all_xyz, kd_tree)
983
+ ]
984
+ validated = list(validated_set | set(tuple(sorted(e)) for e in new_valid))
985
+ except Exception:
986
+ pass # best-effort
987
+
988
+ # T1 ensemble: merge the sklearn-based (merged_v, validated) graph with
989
+ # the standalone triangulation-based predictor. Tracks often recover
990
+ # edges that the 2D-merged heur_set misses (esp. ridge/hip between views
991
+ # where blob merging fails). Strategy:
992
+ # - tracks vertices further than ENSEMBLE_MATCH_RADIUS from any
993
+ # existing merged_v are appended as new vertices.
994
+ # - tracks edges are remapped onto the closest merged_v within the
995
+ # same radius, then unioned with ``validated``.
996
+ if USE_TRACK_ENSEMBLE and _TRIANGULATION_OK:
997
+ try:
998
+ tv, te = predict_wireframe_tracks(entry)
999
+ tv = np.asarray(tv, dtype=np.float64)
1000
+ if len(tv) >= 2 and len(te) >= 1 and len(merged_v) >= 2:
1001
+ # Two-step mapping for each track vertex:
1002
+ # - if a sklearn vertex exists within ENSEMBLE_MATCH_RADIUS,
1003
+ # merge into it (v7 behaviour);
1004
+ # - otherwise, if enabled AND the distance is within
1005
+ # ISOLATED_TRACK_MIN_DIST..ISOLATED_TRACK_MAX_DIST, append
1006
+ # the track as a brand-new vertex.
1007
+ t_idx_map: list[int | None] = [None] * len(tv)
1008
+ added_vertices: list[np.ndarray] = []
1009
+ for i in range(len(tv)):
1010
+ d = np.linalg.norm(merged_v - tv[i], axis=1)
1011
+ j = int(np.argmin(d))
1012
+ if d[j] <= ENSEMBLE_MATCH_RADIUS:
1013
+ t_idx_map[i] = j
1014
+ elif (ADD_ISOLATED_TRACK_VERTICES
1015
+ and ISOLATED_TRACK_MIN_DIST <= d[j] <= ISOLATED_TRACK_MAX_DIST):
1016
+ added_vertices.append(tv[i])
1017
+ t_idx_map[i] = len(merged_v) + len(added_vertices) - 1
1018
+
1019
+ if added_vertices:
1020
+ merged_v = np.vstack([merged_v, np.asarray(added_vertices, dtype=np.float64)])
1021
+
1022
+ extra_edges: set[tuple[int, int]] = set()
1023
+ for (a, b) in te:
1024
+ ia = t_idx_map[a]
1025
+ ib = t_idx_map[b]
1026
+ if ia is None or ib is None or ia == ib:
1027
+ continue
1028
+ lo, hi = (ia, ib) if ia < ib else (ib, ia)
1029
+ extra_edges.add((lo, hi))
1030
+
1031
+ # v15: tracks edges already carry a multi-view triangulation
1032
+ # consistency proof (≥2 views, low reprojection error). When
1033
+ # BYPASS_VALIDATE_FOR_TRACKS is True we trust them directly
1034
+ # and skip the COLMAP-density check that drops valid edges
1035
+ # in sparse-cloud regions.
1036
+ if BYPASS_VALIDATE_FOR_TRACKS:
1037
+ extra_valid = list(extra_edges)
1038
+ else:
1039
+ extra_valid = [
1040
+ e for e in extra_edges
1041
+ if validate_edge(merged_v[e[0]], merged_v[e[1]], all_xyz, kd_tree)
1042
+ ]
1043
+ validated = list(set(tuple(sorted(e)) for e in validated) | set(extra_valid))
1044
+ except Exception:
1045
+ pass # best-effort ensemble
1046
+
1047
+ # v11: line-cloud edge lift. Each merged 3D line's endpoints are snapped
1048
+ # to the nearest merged_v vertices → edge candidate. Same edges-only-lift
1049
+ # strategy as tracks ensemble but from depth-sampled gestalt lines.
1050
+ if USE_LINE_EDGES and _LINECLOUD_OK and len(merged_v) >= 2:
1051
+ try:
1052
+ from line_cloud import extract_3d_lines, merge_3d_lines
1053
+ except ImportError:
1054
+ from submission.line_cloud import extract_3d_lines, merge_3d_lines
1055
+ try:
1056
+ lines_3d, _ = extract_3d_lines(entry)
1057
+ if lines_3d:
1058
+ merged_lines = merge_3d_lines(lines_3d)
1059
+ from scipy.spatial import cKDTree as _cKDTree2
1060
+ vtree = _cKDTree2(merged_v)
1061
+ validated_set = set(tuple(sorted(e)) for e in validated)
1062
+ line_edges: set[tuple[int, int]] = set()
1063
+ for line in merged_lines:
1064
+ # Snap p1, p2 to nearest merged_v
1065
+ d1, i1 = vtree.query(line.p1)
1066
+ d2, i2 = vtree.query(line.p2)
1067
+ if d1 > LINE_EDGE_MATCH_RADIUS or d2 > LINE_EDGE_MATCH_RADIUS:
1068
+ continue
1069
+ if i1 == i2:
1070
+ continue
1071
+ lo, hi = (int(i1), int(i2)) if i1 < i2 else (int(i2), int(i1))
1072
+ if (lo, hi) not in validated_set:
1073
+ line_edges.add((lo, hi))
1074
+ # v15: line edges already have RANSAC consistency proof on
1075
+ # ≥5 unprojected depth samples. Bypass COLMAP-density check.
1076
+ if BYPASS_VALIDATE_FOR_LINES:
1077
+ new_valid = list(line_edges)
1078
+ else:
1079
+ new_valid = [
1080
+ e for e in line_edges
1081
+ if validate_edge(merged_v[e[0]], merged_v[e[1]], all_xyz, kd_tree)
1082
+ ]
1083
+ validated = list(validated_set | set(new_valid))
1084
+ except Exception:
1085
+ pass
1086
+
1087
+ # v14: depth-discontinuity edge lift. Same shape as v11 line lift but
1088
+ # the source is Canny edges on the affine-fitted depth map (independent
1089
+ # of gestalt segmentation). Endpoint snap to merged_v + COLMAP-validate.
1090
+ if USE_DEPTH_EDGES and _DEPTH_EDGES_OK and len(merged_v) >= 2:
1091
+ try:
1092
+ d_lines = extract_and_merge_depth_lines(entry)
1093
+ if d_lines:
1094
+ from scipy.spatial import cKDTree as _cKDTree3
1095
+ vtree = _cKDTree3(merged_v)
1096
+ validated_set = set(tuple(sorted(e)) for e in validated)
1097
+ depth_edges: set[tuple[int, int]] = set()
1098
+ for line in d_lines:
1099
+ d1, i1 = vtree.query(line.p1)
1100
+ d2, i2 = vtree.query(line.p2)
1101
+ if d1 > DEPTH_EDGE_MATCH_RADIUS or d2 > DEPTH_EDGE_MATCH_RADIUS:
1102
+ continue
1103
+ if i1 == i2:
1104
+ continue
1105
+ lo, hi = (int(i1), int(i2)) if i1 < i2 else (int(i2), int(i1))
1106
+ if (lo, hi) not in validated_set:
1107
+ depth_edges.add((lo, hi))
1108
+ new_valid = [
1109
+ e for e in depth_edges
1110
+ if validate_edge(merged_v[e[0]], merged_v[e[1]], all_xyz, kd_tree)
1111
+ ]
1112
+ validated = list(validated_set | set(new_valid))
1113
+ except Exception:
1114
+ pass
1115
+
1116
+ # v8: reprojection-based edge validation. For each candidate edge we
1117
+ # project its 3D segment into each gestalt view and check what fraction
1118
+ # of sampled pixels lands on a gestalt edge mask (union of eave/ridge/
1119
+ # rake/valley/hip, dilated by REPROJ_MASK_DILATE_PX). An edge survives
1120
+ # if at least REPROJ_MIN_VIEWS agree. Acts as a strong ghost-edge filter.
1121
+ if USE_REPROJECTION_EDGE_VAL and validated:
1122
+ try:
1123
+ masks, mvs_views = _build_gestalt_edge_masks(
1124
+ entry, dilate_px=REPROJ_MASK_DILATE_PX
1125
+ )
1126
+ if masks and mvs_views:
1127
+ kept = [
1128
+ e for e in validated
1129
+ if validate_edge_reprojection(
1130
+ merged_v[e[0]], merged_v[e[1]],
1131
+ masks, mvs_views,
1132
+ min_views=REPROJ_MIN_VIEWS,
1133
+ min_hit_frac=REPROJ_MIN_HIT_FRAC,
1134
+ )
1135
+ ]
1136
+ # Only apply the filter if we did not collapse everything.
1137
+ if len(kept) >= max(1, len(validated) // 3):
1138
+ validated = kept
1139
+ except Exception:
1140
+ pass # best-effort
1141
+
1142
+ # Junction-type constraints available via submission/junction.py but not wired
1143
+ # in — on the 20-sample validation split they were neutral-to-slightly-negative.
1144
+ # Keeping module for use in the triangulation pipeline (T1) where the graph
1145
+ # is cleaner and junction priors pay off.
1146
+
1147
+ final_v, final_e = prune_not_connected(merged_v, validated, keep_largest=False)
1148
+ if len(final_v) < 2 or len(final_e) < 1:
1149
+ return empty_solution()
1150
+
1151
+ # v19: guarded DGCNN edge rescue. The learned model is queried at a
1152
+ # recall-friendly threshold, but new edges are accepted only if they
1153
+ # also have sparse-cloud or reprojection evidence, then degree-capped.
1154
+ # This targets the main weakness of v18: useful classifier recall
1155
+ # without raw learned edges turning roofs into dense graphs.
1156
+ if USE_DGCNN_EDGES and len(final_v) >= 2:
1157
+ edge_model = _get_dgcnn_edge_model()
1158
+ if edge_model is not None:
1159
+ try:
1160
+ from winner_inference import score_edges
1161
+ except ImportError:
1162
+ try:
1163
+ from submission.winner_inference import score_edges
1164
+ except ImportError:
1165
+ score_edges = None
1166
+ if score_edges is not None:
1167
+ try:
1168
+ import torch as _torch
1169
+ device = "cuda" if _torch.cuda.is_available() else "cpu"
1170
+ dgcnn_edges = score_edges(
1171
+ np.asarray(final_v, dtype=np.float64),
1172
+ entry, edge_model,
1173
+ device=device,
1174
+ threshold=DGCNN_EDGE_THRESHOLD,
1175
+ )
1176
+ if dgcnn_edges:
1177
+ masks, mvs_views = {}, {}
1178
+ try:
1179
+ masks, mvs_views = _build_gestalt_edge_masks(
1180
+ entry, dilate_px=DGCNN_EDGE_REPROJ_DILATE_PX,
1181
+ )
1182
+ except Exception:
1183
+ pass
1184
+ extra = _select_dgcnn_edges(
1185
+ np.asarray(final_v, dtype=np.float64),
1186
+ final_e,
1187
+ dgcnn_edges,
1188
+ all_xyz,
1189
+ kd_tree,
1190
+ masks=masks,
1191
+ views=mvs_views,
1192
+ )
1193
+ if extra:
1194
+ final_e.extend(extra)
1195
+ except Exception:
1196
+ pass
1197
+
1198
+ # v11: post-hoc BA on final vertex positions. Placed AFTER edge
1199
+ # detection so that edges are built from original (stable) positions,
1200
+ # and only the final output coordinates are refined for F1 + IoU.
1201
+ if USE_BUNDLE_ADJUST and _BA_OK and len(final_v) >= 2:
1202
+ try:
1203
+ final_v = refine_vertices_ba(
1204
+ np.asarray(final_v, dtype=np.float64), entry,
1205
+ min_initial_err_px=3.0,
1206
+ )
1207
+ except Exception:
1208
+ pass # best-effort
1209
+
1210
+ return final_v, [(int(a), int(b)) for a, b in final_e]