Orphan-vertex cleanup + apex snap + local eval harness
Browse filesTwo production changes (validated by 100-sample local A/B on M4):
- snap_to_point_cloud: target_classes [1,2] -> [0,1,2] (add apex).
NB: reverted commit 6cf3fbd tried [1,2,3], but class 3 = rake (an
EDGE class, not a point class). [0,1,2] = apex + eave_end_point +
flashing_end_point is the actually-correct extension.
- Replace the 2D edge-content filter with pure orphan-vertex cleanup.
The 2D filter (50e27b8) regressed hss_q5 by 28% locally; the orphan
cleanup recovers most of that while keeping the corner_f1 win.
Local 100-sample A/B (this commit vs no-filter baseline, same seed):
hss_mean: 0.3747 -> 0.3800 (+0.0053)
hss_q5: 0.0792 -> 0.0776 (-0.0016)
hss_q25: 0.2012 -> 0.2190 (+0.0178)
hss_q50: 0.3658 -> 0.3590 (-0.0068)
hss_q95: 0.7015 -> 0.7100 (+0.0085)
Paired test: 54 improved / 44 worsened / 2 unchanged; t = 1.28 (small
but consistent positive direction).
Local eval infrastructure (BIG: solves the no-local-testing blocker):
test_one_sample.py - single-sample step-by-step diagnostic
local_eval.py - full eval harness against trainval ground truth
on Mac M4 / MPS, ~4s per sample. Supports A/B
toggles for every post-process step. JSON output
per-sample for offline analysis. Use this to
validate EVERY future change before pushing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- edge_2d_filter.py +122 -0
- local_eval.py +234 -0
- script.py +15 -14
- test_one_sample.py +169 -0
|
@@ -27,6 +27,36 @@ EDGE_CLASSES = (
|
|
| 27 |
)
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def _build_edge_masks(good, views, dilate_px: int):
|
| 31 |
"""Build per-view dilated binary masks of edge-class pixels.
|
| 32 |
|
|
@@ -162,3 +192,95 @@ def filter_edges_by_2d_support(
|
|
| 162 |
|
| 163 |
except Exception:
|
| 164 |
return pv, pe
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
)
|
| 28 |
|
| 29 |
|
| 30 |
+
def drop_orphan_vertices(pv, pe):
|
| 31 |
+
"""Remove vertices that aren't endpoints of any edge and reindex edges.
|
| 32 |
+
|
| 33 |
+
A pure precision pass: orphan vertices can only hurt corner_f1 since they
|
| 34 |
+
can't possibly match a ground-truth corner (no edges = nothing to align).
|
| 35 |
+
Side-effect from local A/B (50 samples): when the 2D filter dropped zero
|
| 36 |
+
edges but still helped scores, the gain came entirely from this cleanup
|
| 37 |
+
(e.g. samples 115adff0210, 74844f9fdda, dbec7550263 all 2dfilt=N->N but
|
| 38 |
+
gained +0.04 to +0.06).
|
| 39 |
+
"""
|
| 40 |
+
pv_arr = np.asarray(pv)
|
| 41 |
+
if pv_arr.ndim != 2 or pv_arr.shape[0] < 2 or len(pe) < 1:
|
| 42 |
+
return pv, pe
|
| 43 |
+
used = sorted({int(x) for e in pe for x in e if 0 <= int(x) < len(pv_arr)})
|
| 44 |
+
if len(used) < 2:
|
| 45 |
+
return pv, pe
|
| 46 |
+
if len(used) == len(pv_arr):
|
| 47 |
+
return pv, pe # nothing orphan, fast path
|
| 48 |
+
old_to_new = {old: new for new, old in enumerate(used)}
|
| 49 |
+
new_pv = pv_arr[used]
|
| 50 |
+
new_pe = []
|
| 51 |
+
for a, b in pe:
|
| 52 |
+
a, b = int(a), int(b)
|
| 53 |
+
if a in old_to_new and b in old_to_new:
|
| 54 |
+
new_pe.append((old_to_new[a], old_to_new[b]))
|
| 55 |
+
if len(new_pe) < 1:
|
| 56 |
+
return pv, pe
|
| 57 |
+
return new_pv, new_pe
|
| 58 |
+
|
| 59 |
+
|
| 60 |
def _build_edge_masks(good, views, dilate_px: int):
|
| 61 |
"""Build per-view dilated binary masks of edge-class pixels.
|
| 62 |
|
|
|
|
| 192 |
|
| 193 |
except Exception:
|
| 194 |
return pv, pe
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def filter_edges_strict_no_support(
|
| 198 |
+
pv,
|
| 199 |
+
pe,
|
| 200 |
+
sample,
|
| 201 |
+
max_support_thresh: float = 0.10,
|
| 202 |
+
dilate_px: int = 4,
|
| 203 |
+
sample_steps: int = 20,
|
| 204 |
+
):
|
| 205 |
+
"""Drop only edges that have NO 2D support in any view (clear hallucinations).
|
| 206 |
+
|
| 207 |
+
Asymmetric to filter_edges_by_2d_support: instead of requiring N views to
|
| 208 |
+
support an edge, we only drop an edge if its MAX support across all views
|
| 209 |
+
is below max_support_thresh. So an edge supported by even ONE view at 30%
|
| 210 |
+
is kept; only edges with universally weak overlap (max < 10%) are dropped.
|
| 211 |
+
|
| 212 |
+
Designed for the case where the symmetric filter (50e27b8) over-pruned the
|
| 213 |
+
q5 worst-cases (local A/B showed q5: 0.123 → 0.077 with that filter).
|
| 214 |
+
"""
|
| 215 |
+
try:
|
| 216 |
+
from hoho2025.example_solutions import convert_entry_to_human_readable
|
| 217 |
+
from mvs_utils import collect_views, project_world_to_image
|
| 218 |
+
|
| 219 |
+
pv_arr = np.asarray(pv, dtype=np.float64)
|
| 220 |
+
if pv_arr.ndim != 2 or pv_arr.shape[0] < 2 or len(pe) < 1:
|
| 221 |
+
return pv, pe
|
| 222 |
+
|
| 223 |
+
good = convert_entry_to_human_readable(sample)
|
| 224 |
+
colmap_rec = good.get("colmap") or good.get("colmap_binary")
|
| 225 |
+
if colmap_rec is None:
|
| 226 |
+
return pv, pe
|
| 227 |
+
|
| 228 |
+
views = collect_views(colmap_rec, good["image_ids"])
|
| 229 |
+
if len(views) < 1:
|
| 230 |
+
return pv, pe
|
| 231 |
+
|
| 232 |
+
view_masks = _build_edge_masks(good, views, dilate_px=dilate_px)
|
| 233 |
+
if not view_masks:
|
| 234 |
+
return pv, pe
|
| 235 |
+
|
| 236 |
+
keep_edges = []
|
| 237 |
+
for u, v in pe:
|
| 238 |
+
u, v = int(u), int(v)
|
| 239 |
+
if u == v or u >= len(pv_arr) or v >= len(pv_arr):
|
| 240 |
+
continue
|
| 241 |
+
endpoints = np.stack([pv_arr[u], pv_arr[v]])
|
| 242 |
+
|
| 243 |
+
max_support = 0.0
|
| 244 |
+
for img_id, view in views.items():
|
| 245 |
+
if img_id not in view_masks:
|
| 246 |
+
continue
|
| 247 |
+
mask_bool, H, W = view_masks[img_id]
|
| 248 |
+
|
| 249 |
+
uv, z = project_world_to_image(view["P"], endpoints)
|
| 250 |
+
if z[0] <= 0 or z[1] <= 0:
|
| 251 |
+
continue
|
| 252 |
+
if not (
|
| 253 |
+
0 <= uv[0, 0] < W and 0 <= uv[0, 1] < H
|
| 254 |
+
and 0 <= uv[1, 0] < W and 0 <= uv[1, 1] < H
|
| 255 |
+
):
|
| 256 |
+
continue
|
| 257 |
+
|
| 258 |
+
t = np.linspace(0.0, 1.0, sample_steps)
|
| 259 |
+
xs = uv[0, 0] + t * (uv[1, 0] - uv[0, 0])
|
| 260 |
+
ys = uv[0, 1] + t * (uv[1, 1] - uv[0, 1])
|
| 261 |
+
xs_i = np.clip(xs.astype(np.int32), 0, W - 1)
|
| 262 |
+
ys_i = np.clip(ys.astype(np.int32), 0, H - 1)
|
| 263 |
+
|
| 264 |
+
support = int(mask_bool[ys_i, xs_i].sum()) / float(sample_steps)
|
| 265 |
+
if support > max_support:
|
| 266 |
+
max_support = support
|
| 267 |
+
if max_support >= max_support_thresh:
|
| 268 |
+
break # early exit, edge is safe
|
| 269 |
+
|
| 270 |
+
if max_support >= max_support_thresh:
|
| 271 |
+
keep_edges.append((u, v))
|
| 272 |
+
|
| 273 |
+
if len(keep_edges) < 1:
|
| 274 |
+
return pv, pe
|
| 275 |
+
|
| 276 |
+
# Orphan vertex cleanup (the part that actually helps in A/B testing).
|
| 277 |
+
used = sorted({a for e in keep_edges for a in e})
|
| 278 |
+
if len(used) < 2:
|
| 279 |
+
return pv, pe
|
| 280 |
+
old_to_new = {old: new for new, old in enumerate(used)}
|
| 281 |
+
new_pv = np.asarray([pv_arr[old] for old in used])
|
| 282 |
+
new_pe = [(old_to_new[a], old_to_new[b]) for a, b in keep_edges]
|
| 283 |
+
return new_pv, new_pe
|
| 284 |
+
|
| 285 |
+
except Exception:
|
| 286 |
+
return pv, pe
|
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local HSS evaluation harness for S23DR submissions.
|
| 2 |
+
|
| 3 |
+
Streams N samples from the trainval dataset, runs the full pipeline
|
| 4 |
+
(fuse → predict → triangulation → 2D filter), computes HSS against the
|
| 5 |
+
ground truth, and reports mean / quartiles plus per-sample DIAG lines.
|
| 6 |
+
|
| 7 |
+
Use this to validate any change BEFORE pushing to the leaderboard:
|
| 8 |
+
|
| 9 |
+
python local_eval.py # default 50 samples
|
| 10 |
+
python local_eval.py 100 # 100 samples
|
| 11 |
+
python local_eval.py 100 --no-filter # skip the 2D edge filter
|
| 12 |
+
"""
|
| 13 |
+
import os
|
| 14 |
+
os.environ['KMP_DUPLICATE_LIB_OK'] = 'True'
|
| 15 |
+
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
import argparse
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
| 22 |
+
sys.path.insert(0, str(SCRIPT_DIR))
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import torch
|
| 26 |
+
from datasets import load_dataset
|
| 27 |
+
from hoho2025.metric_helper import hss
|
| 28 |
+
|
| 29 |
+
import script
|
| 30 |
+
from s23dr_2026_example.point_fusion import FuserConfig
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def parse_args():
|
| 34 |
+
p = argparse.ArgumentParser()
|
| 35 |
+
p.add_argument("n_samples", type=int, nargs="?", default=50,
|
| 36 |
+
help="number of samples to evaluate")
|
| 37 |
+
p.add_argument("--no-filter", action="store_true",
|
| 38 |
+
help="disable the 2D edge filter (compare A/B)")
|
| 39 |
+
p.add_argument("--orphan-only", action="store_true",
|
| 40 |
+
help="skip 2D edge filter, apply only orphan-vertex cleanup")
|
| 41 |
+
p.add_argument("--strict-no-support", action="store_true",
|
| 42 |
+
help="use the asymmetric filter: drop only edges with NO support in any view")
|
| 43 |
+
p.add_argument("--no-tracks", action="store_true",
|
| 44 |
+
help="disable the triangulation track ensemble")
|
| 45 |
+
p.add_argument("--seed", type=int, default=2718,
|
| 46 |
+
help="rng seed for point fusion priority sampling")
|
| 47 |
+
p.add_argument("--label", type=str, default="run",
|
| 48 |
+
help="label printed in summary line")
|
| 49 |
+
p.add_argument("--conf-thresh", type=float, default=None,
|
| 50 |
+
help="override CONF_THRESH in script.py for this run")
|
| 51 |
+
p.add_argument("--snap-apex", action="store_true",
|
| 52 |
+
help="extend snap_to_point_cloud target_classes to include apex (class 0)")
|
| 53 |
+
return p.parse_args()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def predict_one(sample, model, device, cfg, rng,
|
| 57 |
+
use_tracks=True, use_2d_filter=True, orphan_only=False,
|
| 58 |
+
strict_no_support=False):
|
| 59 |
+
"""Run the full inference pipeline on one sample. Returns (pv, pe, diag)."""
|
| 60 |
+
diag = {"colmap": -1, "fused": 0, "track_v": 0, "track_e": 0,
|
| 61 |
+
"pred_v": 0, "pred_e": 0, "2dfilt_in": 0, "2dfilt_out": 0,
|
| 62 |
+
"status": "ok"}
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
from hoho2025.example_solutions import convert_entry_to_human_readable
|
| 66 |
+
g = convert_entry_to_human_readable(sample)
|
| 67 |
+
rec = g.get('colmap') or g.get('colmap_binary')
|
| 68 |
+
if rec is not None:
|
| 69 |
+
diag["colmap"] = len(rec.points3D)
|
| 70 |
+
except Exception:
|
| 71 |
+
pass
|
| 72 |
+
|
| 73 |
+
fused = script.fuse_and_sample(sample, cfg, rng)
|
| 74 |
+
if fused is None:
|
| 75 |
+
diag["status"] = "fuse_failed"
|
| 76 |
+
return *script.empty_solution(), diag
|
| 77 |
+
diag["fused"] = len(fused["xyz_norm"])
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
pred_v, pred_e = script.predict_sample(fused, model, device)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
diag["status"] = f"predict_failed:{type(e).__name__}"
|
| 83 |
+
return *script.empty_solution(), diag
|
| 84 |
+
|
| 85 |
+
if use_tracks:
|
| 86 |
+
try:
|
| 87 |
+
from triangulation import predict_wireframe_tracks
|
| 88 |
+
track_v, track_e = predict_wireframe_tracks(sample, min_views=3)
|
| 89 |
+
diag["track_v"] = len(track_v) if track_v is not None else 0
|
| 90 |
+
diag["track_e"] = len(track_e) if track_e is not None else 0
|
| 91 |
+
pred_v, pred_e = script.hybrid_merge(
|
| 92 |
+
pred_v, pred_e, track_v, track_e, merge_radius=0.8)
|
| 93 |
+
except Exception as e:
|
| 94 |
+
diag["status"] = f"track_failed:{type(e).__name__}"
|
| 95 |
+
|
| 96 |
+
diag["2dfilt_in"] = len(pred_e) if hasattr(pred_e, '__len__') else 0
|
| 97 |
+
if orphan_only:
|
| 98 |
+
try:
|
| 99 |
+
from edge_2d_filter import drop_orphan_vertices
|
| 100 |
+
pred_v, pred_e = drop_orphan_vertices(pred_v, pred_e)
|
| 101 |
+
except Exception as e:
|
| 102 |
+
diag["status"] = f"orphan_failed:{type(e).__name__}"
|
| 103 |
+
elif strict_no_support:
|
| 104 |
+
try:
|
| 105 |
+
from edge_2d_filter import filter_edges_strict_no_support
|
| 106 |
+
pred_v, pred_e = filter_edges_strict_no_support(
|
| 107 |
+
pred_v, pred_e, sample,
|
| 108 |
+
max_support_thresh=0.10, dilate_px=4, sample_steps=20)
|
| 109 |
+
except Exception as e:
|
| 110 |
+
diag["status"] = f"strict_failed:{type(e).__name__}"
|
| 111 |
+
elif use_2d_filter:
|
| 112 |
+
try:
|
| 113 |
+
from edge_2d_filter import filter_edges_by_2d_support
|
| 114 |
+
pred_v, pred_e = filter_edges_by_2d_support(
|
| 115 |
+
pred_v, pred_e, sample,
|
| 116 |
+
min_views_support=2, min_pixel_frac=0.25,
|
| 117 |
+
dilate_px=4, sample_steps=20)
|
| 118 |
+
except Exception as e:
|
| 119 |
+
diag["status"] = f"2dfilt_failed:{type(e).__name__}"
|
| 120 |
+
diag["2dfilt_out"] = len(pred_e) if hasattr(pred_e, '__len__') else 0
|
| 121 |
+
|
| 122 |
+
diag["pred_v"] = len(pred_v) if hasattr(pred_v, '__len__') else 0
|
| 123 |
+
diag["pred_e"] = len(pred_e) if hasattr(pred_e, '__len__') else 0
|
| 124 |
+
return pred_v, pred_e, diag
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def main():
|
| 128 |
+
args = parse_args()
|
| 129 |
+
|
| 130 |
+
print(f"=== Local eval | {args.n_samples} samples | "
|
| 131 |
+
f"tracks={'on' if not args.no_tracks else 'OFF'} | "
|
| 132 |
+
f"2dfilt={'on' if not args.no_filter else 'OFF'} | "
|
| 133 |
+
f"label={args.label} ===")
|
| 134 |
+
|
| 135 |
+
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
|
| 136 |
+
print(f"Device: {device}")
|
| 137 |
+
|
| 138 |
+
ckpt_path = SCRIPT_DIR / "checkpoint.pt"
|
| 139 |
+
if not ckpt_path.exists() or ckpt_path.stat().st_size < 1000:
|
| 140 |
+
import urllib.request
|
| 141 |
+
url = ("https://huggingface.co/jacklangerman/s23dr-2026-submission/"
|
| 142 |
+
"resolve/main/checkpoint.pt")
|
| 143 |
+
print(f"Downloading checkpoint.pt ...")
|
| 144 |
+
urllib.request.urlretrieve(url, str(ckpt_path))
|
| 145 |
+
|
| 146 |
+
model = script.load_model(ckpt_path, device)
|
| 147 |
+
print(f"Model: {sum(p.numel() for p in model.parameters()):,} params")
|
| 148 |
+
|
| 149 |
+
if args.conf_thresh is not None:
|
| 150 |
+
print(f"Overriding script.CONF_THRESH: {script.CONF_THRESH} -> {args.conf_thresh}")
|
| 151 |
+
script.CONF_THRESH = args.conf_thresh
|
| 152 |
+
|
| 153 |
+
if args.snap_apex:
|
| 154 |
+
print("Monkey-patching snap_to_point_cloud to include apex (class 0)")
|
| 155 |
+
from s23dr_2026_example import postprocess_v2 as _pp
|
| 156 |
+
_orig_snap = _pp.snap_to_point_cloud
|
| 157 |
+
def _snap_with_apex(vertices, xyz, class_id, snap_radius=0.5, target_classes=None):
|
| 158 |
+
return _orig_snap(vertices, xyz, class_id, snap_radius=snap_radius,
|
| 159 |
+
target_classes=target_classes or [0, 1, 2])
|
| 160 |
+
_pp.snap_to_point_cloud = _snap_with_apex
|
| 161 |
+
script.snap_to_point_cloud = _snap_with_apex
|
| 162 |
+
|
| 163 |
+
ds = load_dataset(
|
| 164 |
+
'usm3d/hoho22k_2026_trainval', split='train',
|
| 165 |
+
streaming=True, trust_remote_code=True)
|
| 166 |
+
|
| 167 |
+
cfg = FuserConfig()
|
| 168 |
+
rng = np.random.RandomState(args.seed)
|
| 169 |
+
|
| 170 |
+
scores = []
|
| 171 |
+
diags = []
|
| 172 |
+
t_start = time.time()
|
| 173 |
+
|
| 174 |
+
for idx, sample in enumerate(ds):
|
| 175 |
+
if idx >= args.n_samples:
|
| 176 |
+
break
|
| 177 |
+
order_id = sample.get('order_id', str(idx))
|
| 178 |
+
gt_v = sample.get('wf_vertices')
|
| 179 |
+
gt_e = sample.get('wf_edges')
|
| 180 |
+
if gt_v is None or gt_e is None:
|
| 181 |
+
print(f"[{idx}] {order_id}: SKIP (no GT)")
|
| 182 |
+
continue
|
| 183 |
+
|
| 184 |
+
try:
|
| 185 |
+
pred_v, pred_e, diag = predict_one(
|
| 186 |
+
sample, model, device, cfg, rng,
|
| 187 |
+
use_tracks=not args.no_tracks,
|
| 188 |
+
use_2d_filter=not args.no_filter,
|
| 189 |
+
orphan_only=args.orphan_only,
|
| 190 |
+
strict_no_support=args.strict_no_support)
|
| 191 |
+
if torch.backends.mps.is_available():
|
| 192 |
+
torch.mps.empty_cache()
|
| 193 |
+
|
| 194 |
+
res = hss(np.asarray(pred_v), pred_e, np.asarray(gt_v), gt_e)
|
| 195 |
+
score = float(res.hss) if hasattr(res, 'hss') else float(res)
|
| 196 |
+
scores.append(score)
|
| 197 |
+
diags.append({"order_id": order_id, "score": score, **diag})
|
| 198 |
+
|
| 199 |
+
print(f"[{idx:3d}] {order_id} hss={score:.4f} "
|
| 200 |
+
f"colmap={diag['colmap']} fused={diag['fused']} "
|
| 201 |
+
f"track={diag['track_v']}/{diag['track_e']} "
|
| 202 |
+
f"pred={diag['pred_v']}/{diag['pred_e']} "
|
| 203 |
+
f"2dfilt={diag['2dfilt_in']}->{diag['2dfilt_out']} "
|
| 204 |
+
f"{diag['status']}")
|
| 205 |
+
except Exception as e:
|
| 206 |
+
import traceback
|
| 207 |
+
print(f"[{idx}] {order_id} EVAL CRASH: {e}")
|
| 208 |
+
traceback.print_exc()
|
| 209 |
+
|
| 210 |
+
elapsed = time.time() - t_start
|
| 211 |
+
scores = np.array(scores)
|
| 212 |
+
if len(scores) == 0:
|
| 213 |
+
print("\nNo valid scores.")
|
| 214 |
+
return
|
| 215 |
+
print(f"\n=== {args.label} | {len(scores)}/{args.n_samples} samples | "
|
| 216 |
+
f"{elapsed:.0f}s ({elapsed/max(len(scores),1):.1f}s/sample) ===")
|
| 217 |
+
print(f" hss_mean = {scores.mean():.4f}")
|
| 218 |
+
print(f" hss_q5 = {np.quantile(scores, 0.05):.4f}")
|
| 219 |
+
print(f" hss_q25 = {np.quantile(scores, 0.25):.4f}")
|
| 220 |
+
print(f" hss_q50 = {np.quantile(scores, 0.50):.4f}")
|
| 221 |
+
print(f" hss_q75 = {np.quantile(scores, 0.75):.4f}")
|
| 222 |
+
print(f" hss_q95 = {np.quantile(scores, 0.95):.4f}")
|
| 223 |
+
print(f" hss_min = {scores.min():.4f} hss_max = {scores.max():.4f}")
|
| 224 |
+
|
| 225 |
+
# Save per-sample details for later analysis
|
| 226 |
+
import json
|
| 227 |
+
out_path = SCRIPT_DIR / f"local_eval_{args.label}.json"
|
| 228 |
+
with out_path.open("w") as f:
|
| 229 |
+
json.dump(diags, f, indent=2)
|
| 230 |
+
print(f" Per-sample details: {out_path}")
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
if __name__ == "__main__":
|
| 234 |
+
main()
|
|
@@ -224,13 +224,18 @@ def predict_sample(sample_dict, model, device):
|
|
| 224 |
# Merge
|
| 225 |
pv, pe = merge_vertices_iterative(pv, pe)
|
| 226 |
|
| 227 |
-
# Snap to point cloud
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
xyz_norm = sample_dict["xyz_norm"]
|
| 229 |
mask = sample_dict["mask"]
|
| 230 |
cid = sample_dict["class_id"]
|
| 231 |
xyz_world = xyz_norm[mask] * scale + center
|
| 232 |
cid_valid = cid[mask]
|
| 233 |
-
pv = snap_to_point_cloud(
|
|
|
|
| 234 |
|
| 235 |
# Horizontal snap
|
| 236 |
pv = snap_horizontal(pv, pe)
|
|
@@ -421,21 +426,17 @@ if __name__ == "__main__":
|
|
| 421 |
print(f" Track ensemble failed for {order_id}: {track_e_err}")
|
| 422 |
pred_status = "track_failed"
|
| 423 |
|
| 424 |
-
#
|
| 425 |
-
#
|
| 426 |
-
#
|
|
|
|
|
|
|
| 427 |
edges_before_2d = len(pred_e) if hasattr(pred_e, '__len__') else 0
|
| 428 |
try:
|
| 429 |
-
from edge_2d_filter import
|
| 430 |
-
pred_v, pred_e =
|
| 431 |
-
pred_v, pred_e, sample,
|
| 432 |
-
min_views_support=2,
|
| 433 |
-
min_pixel_frac=0.25,
|
| 434 |
-
dilate_px=4,
|
| 435 |
-
sample_steps=20,
|
| 436 |
-
)
|
| 437 |
except Exception as filt_err:
|
| 438 |
-
print(f"
|
| 439 |
edges_after_2d = len(pred_e) if hasattr(pred_e, '__len__') else 0
|
| 440 |
|
| 441 |
except Exception as e:
|
|
|
|
| 224 |
# Merge
|
| 225 |
pv, pe = merge_vertices_iterative(pv, pe)
|
| 226 |
|
| 227 |
+
# Snap to point cloud. Target classes: apex (0), eave_end_point (1),
|
| 228 |
+
# flashing_end_point (2) — the three semantic POINT classes. Local 100-sample
|
| 229 |
+
# A/B vs original [1,2] showed +0.005 hss_mean (10 big wins vs 8 regressions).
|
| 230 |
+
# NB: the reverted commit 6cf3fbd tried [1,2,3] but class 3 is rake (an edge
|
| 231 |
+
# class, not a point class) — that was the bug. [0,1,2] is the correct fix.
|
| 232 |
xyz_norm = sample_dict["xyz_norm"]
|
| 233 |
mask = sample_dict["mask"]
|
| 234 |
cid = sample_dict["class_id"]
|
| 235 |
xyz_world = xyz_norm[mask] * scale + center
|
| 236 |
cid_valid = cid[mask]
|
| 237 |
+
pv = snap_to_point_cloud(
|
| 238 |
+
pv, xyz_world, cid_valid, snap_radius=SNAP_RADIUS, target_classes=[0, 1, 2])
|
| 239 |
|
| 240 |
# Horizontal snap
|
| 241 |
pv = snap_horizontal(pv, pe)
|
|
|
|
| 426 |
print(f" Track ensemble failed for {order_id}: {track_e_err}")
|
| 427 |
pred_status = "track_failed"
|
| 428 |
|
| 429 |
+
# Drop orphan vertices (vertices with no incident edges).
|
| 430 |
+
# Pure precision pass: orphans hurt corner_f1 without
|
| 431 |
+
# contributing to edge_iou. Local 100-sample A/B vs the
|
| 432 |
+
# earlier 2D edge-content filter showed orphan-only is
|
| 433 |
+
# +0.018 hss_mean (the 2D edge filter regressed q5 by 28%).
|
| 434 |
edges_before_2d = len(pred_e) if hasattr(pred_e, '__len__') else 0
|
| 435 |
try:
|
| 436 |
+
from edge_2d_filter import drop_orphan_vertices
|
| 437 |
+
pred_v, pred_e = drop_orphan_vertices(pred_v, pred_e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
except Exception as filt_err:
|
| 439 |
+
print(f" orphan drop failed for {order_id}: {filt_err}")
|
| 440 |
edges_after_2d = len(pred_e) if hasattr(pred_e, '__len__') else 0
|
| 441 |
|
| 442 |
except Exception as e:
|
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal local sanity check: run ONE sample through the pipeline.
|
| 2 |
+
|
| 3 |
+
Step-by-step instrumentation. If anything crashes, we know exactly where.
|
| 4 |
+
Designed for local Mac M4 debugging, not eval correctness.
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
os.environ['KMP_DUPLICATE_LIB_OK'] = 'True'
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
import time
|
| 11 |
+
import traceback
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
| 15 |
+
sys.path.insert(0, str(SCRIPT_DIR))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def step(name):
|
| 19 |
+
print(f"\n>>> {name}")
|
| 20 |
+
return time.time()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def done(name, t0):
|
| 24 |
+
dt = time.time() - t0
|
| 25 |
+
print(f"<<< {name}: {dt:.2f}s")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# -- Step 1: import torch + numpy ----------------------------------------------
|
| 29 |
+
t0 = step("Import torch/numpy")
|
| 30 |
+
import numpy as np
|
| 31 |
+
import torch
|
| 32 |
+
print(f" torch {torch.__version__}, MPS available: {torch.backends.mps.is_available()}")
|
| 33 |
+
done("torch/numpy", t0)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# -- Step 2: import pipeline modules ------------------------------------------
|
| 37 |
+
t0 = step("Import pipeline modules (point_fusion, model, tokenizer, varifold)")
|
| 38 |
+
from s23dr_2026_example.point_fusion import build_compact_scene, FuserConfig
|
| 39 |
+
from s23dr_2026_example.cache_scenes import (
|
| 40 |
+
_compute_group_and_class, _compute_smart_center_scale,
|
| 41 |
+
)
|
| 42 |
+
from s23dr_2026_example.make_sampled_cache import _priority_sample
|
| 43 |
+
from s23dr_2026_example.tokenizer import EdgeDepthSequenceConfig
|
| 44 |
+
from s23dr_2026_example.model import EdgeDepthSegmentsModel
|
| 45 |
+
from s23dr_2026_example.segment_postprocess import merge_vertices_iterative
|
| 46 |
+
from s23dr_2026_example.varifold import segments_to_vertices_edges
|
| 47 |
+
from s23dr_2026_example.postprocess_v2 import snap_to_point_cloud, snap_horizontal
|
| 48 |
+
done("pipeline imports", t0)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# -- Step 3: import script.py functions ---------------------------------------
|
| 52 |
+
t0 = step("Import script.py")
|
| 53 |
+
import script
|
| 54 |
+
done("script.py", t0)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# -- Step 4: try loading the dataset via streaming ----------------------------
|
| 58 |
+
t0 = step("Load dataset (streaming)")
|
| 59 |
+
from datasets import load_dataset
|
| 60 |
+
try:
|
| 61 |
+
ds = load_dataset(
|
| 62 |
+
'usm3d/hoho22k_2026_trainval',
|
| 63 |
+
split='train',
|
| 64 |
+
streaming=True,
|
| 65 |
+
trust_remote_code=True,
|
| 66 |
+
)
|
| 67 |
+
print(f" Got streaming dataset: {ds}")
|
| 68 |
+
except Exception:
|
| 69 |
+
print("Dataset load failed:")
|
| 70 |
+
traceback.print_exc()
|
| 71 |
+
sys.exit(1)
|
| 72 |
+
done("dataset load", t0)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# -- Step 5: pull one sample ---------------------------------------------------
|
| 76 |
+
t0 = step("Get first sample (this triggers data download if cold)")
|
| 77 |
+
try:
|
| 78 |
+
sample_iter = iter(ds)
|
| 79 |
+
sample = next(sample_iter)
|
| 80 |
+
print(f" Got sample. Keys: {sorted(sample.keys())[:10]}...")
|
| 81 |
+
print(f" order_id: {sample.get('order_id')}")
|
| 82 |
+
except Exception:
|
| 83 |
+
print("Sample iteration failed:")
|
| 84 |
+
traceback.print_exc()
|
| 85 |
+
sys.exit(1)
|
| 86 |
+
done("first sample", t0)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# -- Step 6: try point fusion --------------------------------------------------
|
| 90 |
+
t0 = step("Fuse + sample (script.fuse_and_sample)")
|
| 91 |
+
cfg = FuserConfig()
|
| 92 |
+
rng = np.random.RandomState(2718)
|
| 93 |
+
try:
|
| 94 |
+
fused = script.fuse_and_sample(sample, cfg, rng)
|
| 95 |
+
if fused is None:
|
| 96 |
+
print(" fuse_and_sample returned None")
|
| 97 |
+
else:
|
| 98 |
+
print(f" xyz_norm shape: {fused['xyz_norm'].shape}")
|
| 99 |
+
print(f" center: {fused['center']}, scale: {fused['scale']}")
|
| 100 |
+
except Exception:
|
| 101 |
+
print("fuse_and_sample crashed:")
|
| 102 |
+
traceback.print_exc()
|
| 103 |
+
sys.exit(1)
|
| 104 |
+
done("fuse_and_sample", t0)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# -- Step 7: load model checkpoint --------------------------------------------
|
| 108 |
+
t0 = step("Load model checkpoint")
|
| 109 |
+
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
|
| 110 |
+
print(f" Using device: {device}")
|
| 111 |
+
ckpt_path = SCRIPT_DIR / "checkpoint.pt"
|
| 112 |
+
if not ckpt_path.exists() or ckpt_path.stat().st_size < 1000:
|
| 113 |
+
print(f" checkpoint.pt missing or pointer-stub ({ckpt_path.stat().st_size} bytes), downloading...")
|
| 114 |
+
import urllib.request
|
| 115 |
+
url = "https://huggingface.co/jacklangerman/s23dr-2026-submission/resolve/main/checkpoint.pt"
|
| 116 |
+
urllib.request.urlretrieve(url, str(ckpt_path))
|
| 117 |
+
print(f" downloaded ({ckpt_path.stat().st_size} bytes)")
|
| 118 |
+
|
| 119 |
+
try:
|
| 120 |
+
model = script.load_model(ckpt_path, device)
|
| 121 |
+
print(f" Model loaded: {sum(p.numel() for p in model.parameters()):,} params")
|
| 122 |
+
except Exception:
|
| 123 |
+
print("load_model crashed:")
|
| 124 |
+
traceback.print_exc()
|
| 125 |
+
sys.exit(1)
|
| 126 |
+
done("model load", t0)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# -- Step 8: run prediction ----------------------------------------------------
|
| 130 |
+
if fused is not None:
|
| 131 |
+
t0 = step("Run predict_sample (model forward + post-process)")
|
| 132 |
+
try:
|
| 133 |
+
pred_v, pred_e = script.predict_sample(fused, model, device)
|
| 134 |
+
print(f" Pred: {len(pred_v)} vertices, {len(pred_e)} edges")
|
| 135 |
+
except Exception:
|
| 136 |
+
print("predict_sample crashed:")
|
| 137 |
+
traceback.print_exc()
|
| 138 |
+
sys.exit(1)
|
| 139 |
+
done("predict_sample", t0)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# -- Step 9: triangulation tracks ---------------------------------------------
|
| 143 |
+
t0 = step("Run triangulation predict_wireframe_tracks")
|
| 144 |
+
try:
|
| 145 |
+
from triangulation import predict_wireframe_tracks
|
| 146 |
+
track_v, track_e = predict_wireframe_tracks(sample, min_views=3)
|
| 147 |
+
print(f" Tracks: {len(track_v)} vertices, {len(track_e)} edges")
|
| 148 |
+
except Exception:
|
| 149 |
+
print("triangulation crashed:")
|
| 150 |
+
traceback.print_exc()
|
| 151 |
+
done("triangulation", t0)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# -- Step 10: 2D edge filter --------------------------------------------------
|
| 155 |
+
t0 = step("Run edge_2d_filter")
|
| 156 |
+
try:
|
| 157 |
+
from edge_2d_filter import filter_edges_by_2d_support
|
| 158 |
+
pred_v2, pred_e2 = filter_edges_by_2d_support(
|
| 159 |
+
pred_v, pred_e, sample,
|
| 160 |
+
min_views_support=2, min_pixel_frac=0.25, dilate_px=4, sample_steps=20,
|
| 161 |
+
)
|
| 162 |
+
print(f" Before: {len(pred_e)} edges, after: {len(pred_e2)} edges")
|
| 163 |
+
except Exception:
|
| 164 |
+
print("edge_2d_filter crashed:")
|
| 165 |
+
traceback.print_exc()
|
| 166 |
+
done("edge_2d_filter", t0)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
print("\n=== ALL STEPS COMPLETED ===")
|