Add DINOv2 edge classifier (v4) for post-processing edge filtering
Browse filesTrained 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>
- edge_classifier_v2.py +339 -0
- edge_classifier_v4.py +232 -0
- local_eval.py +261 -3
- script.py +53 -0
|
@@ -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")
|
|
@@ -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"))
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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()}")
|