xsponenta Claude Opus 4.7 commited on
Commit
2df06c6
·
1 Parent(s): b1c3ec5

Vertex view-projection refinement: snap 3D corners to 2D gestalt evidence

Browse files

For each predicted 3D vertex V:
1. Project V into every registered COLMAP view via its P matrix.
2. Find the nearest gestalt corner-class pixel (apex / eave_end_point /
flashing_end_point) within 15 px in each view.
3. If at least 2 views have a match, DLT-triangulate a new 3D position
from the matched 2D corner pixels.
4. Sanity-check: refined position must lie within 0.5 m of V and
reproject with mean error <= 10 px.
5. If both checks pass, replace V with the refined position.

This is precision refinement of vertex *positions* — topology (edges)
is unchanged, no new geometry is introduced. Directly targets corner_f1
(currently 0.517 vs leader ~0.65+) by snapping model approximations
to exact gestalt corner detections that already drove training.

Reuses validated machinery: mvs_utils.collect_views + project_world_to_image
+ triangulate_dlt + mean_reprojection_error. Falls back to the input on
any error so the pipeline cannot regress structurally.

Local 100-sample A/B (paired, fixed seed):
baseline: 0.3770
+ orphan only: 0.3800 (+0.003, t=0.98)
+ refine only: 0.3839 (+0.007, t=1.42, 22 big wins vs 13 big losses)
+ both: 0.3856 (+0.009, t=1.69) <-- this commit

Refine runs BEFORE orphan cleanup so vertex movements complete first,
then orphan pass removes any vertices whose edges were dropped earlier
by hybrid_merge. Local A/B showed combined effect is partially additive
(refine +0.007, orphan +0.003, combined +0.009).

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

Files changed (3) hide show
  1. local_eval.py +34 -2
  2. script.py +18 -4
  3. vertex_refine.py +172 -0
local_eval.py CHANGED
@@ -50,12 +50,21 @@ def parse_args():
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,
@@ -94,6 +103,22 @@ def predict_one(sample, model, device, cfg, rng,
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
@@ -182,12 +207,19 @@ def main():
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
 
 
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
+ p.add_argument("--vertex-refine", action="store_true",
54
+ help="apply vertex view-projection refinement after all post-process")
55
+ p.add_argument("--refine-max-pixel-dist", type=float, default=15.0,
56
+ help="vertex refine: max 2D pixel distance for corner matching")
57
+ p.add_argument("--refine-min-views", type=int, default=2,
58
+ help="vertex refine: min views with 2D match")
59
+ p.add_argument("--refine-max-move", type=float, default=0.5,
60
+ help="vertex refine: max 3D displacement in meters")
61
  return p.parse_args()
62
 
63
 
64
  def predict_one(sample, model, device, cfg, rng,
65
  use_tracks=True, use_2d_filter=True, orphan_only=False,
66
+ strict_no_support=False, vertex_refine=False,
67
+ refine_kwargs=None):
68
  """Run the full inference pipeline on one sample. Returns (pv, pe, diag)."""
69
  diag = {"colmap": -1, "fused": 0, "track_v": 0, "track_e": 0,
70
  "pred_v": 0, "pred_e": 0, "2dfilt_in": 0, "2dfilt_out": 0,
 
103
  diag["status"] = f"track_failed:{type(e).__name__}"
104
 
105
  diag["2dfilt_in"] = len(pred_e) if hasattr(pred_e, '__len__') else 0
106
+ # Vertex refinement runs FIRST (refines vertex positions while orphan is still present;
107
+ # orphan/2d-filter then cleans up afterwards).
108
+ if vertex_refine:
109
+ try:
110
+ from vertex_refine import refine_vertices_view_projection
111
+ pv_before = pred_v
112
+ pred_v, pred_e = refine_vertices_view_projection(
113
+ pred_v, pred_e, sample,
114
+ **(refine_kwargs or {}))
115
+ if hasattr(pred_v, '__len__') and len(pred_v) == len(pv_before):
116
+ moved = int(np.sum(np.linalg.norm(
117
+ np.asarray(pred_v) - np.asarray(pv_before), axis=1) > 1e-6))
118
+ diag["refined"] = moved
119
+ except Exception as e:
120
+ diag["status"] = f"refine_failed:{type(e).__name__}"
121
+
122
  if orphan_only:
123
  try:
124
  from edge_2d_filter import drop_orphan_vertices
 
207
  continue
208
 
209
  try:
210
+ refine_kwargs = {
211
+ "max_pixel_dist": args.refine_max_pixel_dist,
212
+ "min_views": args.refine_min_views,
213
+ "max_move_meters": args.refine_max_move,
214
+ }
215
  pred_v, pred_e, diag = predict_one(
216
  sample, model, device, cfg, rng,
217
  use_tracks=not args.no_tracks,
218
  use_2d_filter=not args.no_filter,
219
  orphan_only=args.orphan_only,
220
+ strict_no_support=args.strict_no_support,
221
+ vertex_refine=args.vertex_refine,
222
+ refine_kwargs=refine_kwargs)
223
  if torch.backends.mps.is_available():
224
  torch.mps.empty_cache()
225
 
script.py CHANGED
@@ -426,11 +426,25 @@ if __name__ == "__main__":
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
 
426
  print(f" Track ensemble failed for {order_id}: {track_e_err}")
427
  pred_status = "track_failed"
428
 
429
+ # Vertex view-projection refinement. For each predicted 3D
430
+ # vertex, find the nearest gestalt-corner pixel in each
431
+ # view, re-triangulate via DLT, and replace the vertex if
432
+ # the refined position is close (<=0.5m) and reprojects
433
+ # well (<=10px). Local 100-sample A/B: +0.007 hss_mean,
434
+ # 56 wins / 41 losses vs baseline.
435
+ try:
436
+ from vertex_refine import refine_vertices_view_projection
437
+ pred_v, pred_e = refine_vertices_view_projection(
438
+ pred_v, pred_e, sample,
439
+ max_pixel_dist=15.0, min_views=2,
440
+ max_move_meters=0.5, max_reproj_px=10.0,
441
+ )
442
+ except Exception as ref_err:
443
+ print(f" vertex refine failed for {order_id}: {ref_err}")
444
+
445
  # Drop orphan vertices (vertices with no incident edges).
446
+ # Local 100-sample A/B: combined refine + orphan = +0.009
447
+ # hss_mean over baseline (t=1.69).
 
 
448
  edges_before_2d = len(pred_e) if hasattr(pred_e, '__len__') else 0
449
  try:
450
  from edge_2d_filter import drop_orphan_vertices
vertex_refine.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vertex view-projection refinement.
2
+
3
+ For each predicted 3D vertex V:
4
+
5
+ 1. Project V to 2D in every registered COLMAP view.
6
+ 2. In each view, find the nearest gestalt corner-class pixel
7
+ (apex / eave_end_point / flashing_end_point) within ``max_pixel_dist``.
8
+ 3. If at least ``min_views`` views have a 2D match, DLT-triangulate a new
9
+ 3D position from those 2D corner detections.
10
+ 4. Sanity-check: the refined position must (a) lie within
11
+ ``max_move_meters`` of the original V, and (b) have mean reprojection
12
+ error below ``max_reproj_px`` across the supporting views.
13
+ 5. If both checks pass, replace V with the refined position.
14
+
15
+ This is pure precision refinement of corner positions. Topology (edges)
16
+ is preserved. Falls back to the input on any error — the function is
17
+ guaranteed to never return fewer vertices than it received.
18
+
19
+ Targets corner_f1: the learned model's 3D vertices are approximate; the
20
+ gestalt segmentation gives us *exact* pixel-level corner detections per
21
+ view; triangulating from those gives a much tighter 3D position whenever
22
+ multiple views agree.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import numpy as np
28
+ import cv2
29
+
30
+
31
+ POINT_CLASSES = ("apex", "eave_end_point", "flashing_end_point")
32
+
33
+
34
+ def _build_corner_pixels_per_view(good, views):
35
+ """Build per-view corner-pixel catalog.
36
+
37
+ Returns dict ``img_id -> {P, corners (N,2), tree, H, W}``.
38
+ Only includes views that have at least one corner pixel.
39
+ """
40
+ from hoho2025.color_mappings import gestalt_color_mapping
41
+ from scipy.spatial import cKDTree
42
+
43
+ out = {}
44
+ for gest_pil, depth_pil, img_id in zip(
45
+ good["gestalt"], good["depth"], good["image_ids"]
46
+ ):
47
+ if img_id not in views:
48
+ continue
49
+ depth_np = np.array(depth_pil)
50
+ H, W = depth_np.shape[:2]
51
+ gest_np = np.array(gest_pil.resize((W, H))).astype(np.uint8)
52
+
53
+ corners = []
54
+ for cls in POINT_CLASSES:
55
+ color = np.array(gestalt_color_mapping[cls])
56
+ mask = cv2.inRange(gest_np, color - 0.5, color + 0.5)
57
+ if mask.sum() == 0:
58
+ continue
59
+ n_cc, _, _, centroids = cv2.connectedComponentsWithStats(
60
+ mask, 8, cv2.CV_32S
61
+ )
62
+ if n_cc > 1:
63
+ # Skip the background centroid at index 0; rest are blob centers.
64
+ corners.extend(centroids[1:].tolist())
65
+
66
+ if not corners:
67
+ continue
68
+ corners_arr = np.asarray(corners, dtype=np.float64)
69
+ out[img_id] = {
70
+ "P": views[img_id]["P"],
71
+ "corners": corners_arr,
72
+ "tree": cKDTree(corners_arr),
73
+ "H": H,
74
+ "W": W,
75
+ }
76
+ return out
77
+
78
+
79
+ def refine_vertices_view_projection(
80
+ pv,
81
+ pe,
82
+ sample,
83
+ max_pixel_dist: float = 15.0,
84
+ min_views: int = 2,
85
+ max_move_meters: float = 0.5,
86
+ max_reproj_px: float = 10.0,
87
+ ):
88
+ """Refine predicted vertex positions by re-triangulating from gestalt corners.
89
+
90
+ Args:
91
+ pv: (N, 3) predicted vertices in world coordinates.
92
+ pe: edge list (unchanged on return).
93
+ sample: raw dataset entry.
94
+ max_pixel_dist: max 2D distance from projected vertex to a gestalt
95
+ corner pixel to count as a match (15px ~= 2% of 768px width).
96
+ min_views: minimum views with a 2D match to attempt re-triangulation.
97
+ max_move_meters: refined vertex must lie within this distance of
98
+ the original — guards against spurious cross-corner matches.
99
+ max_reproj_px: refined vertex must have mean reprojection error
100
+ below this across the supporting views.
101
+
102
+ Returns:
103
+ (pv_refined, pe). Vertex count unchanged; pe is the same list.
104
+ Falls back to (pv, pe) on any error.
105
+ """
106
+ try:
107
+ from hoho2025.example_solutions import convert_entry_to_human_readable
108
+ from mvs_utils import (
109
+ collect_views, project_world_to_image,
110
+ triangulate_dlt, mean_reprojection_error,
111
+ )
112
+
113
+ pv_arr = np.asarray(pv, dtype=np.float64)
114
+ if pv_arr.ndim != 2 or pv_arr.shape[0] < 1:
115
+ return pv, pe
116
+
117
+ good = convert_entry_to_human_readable(sample)
118
+ colmap_rec = good.get("colmap") or good.get("colmap_binary")
119
+ if colmap_rec is None:
120
+ return pv, pe
121
+
122
+ views = collect_views(colmap_rec, good["image_ids"])
123
+ if len(views) < min_views:
124
+ return pv, pe
125
+
126
+ view_data = _build_corner_pixels_per_view(good, views)
127
+ if len(view_data) < min_views:
128
+ return pv, pe
129
+
130
+ refined = pv_arr.copy()
131
+ n_refined = 0
132
+
133
+ for i, v in enumerate(pv_arr):
134
+ Ps_match = []
135
+ pts2d_match = []
136
+
137
+ for img_id, vd in view_data.items():
138
+ uv, z = project_world_to_image(vd["P"], v.reshape(1, 3))
139
+ if z[0] <= 0:
140
+ continue
141
+ u, vp = float(uv[0, 0]), float(uv[0, 1])
142
+ if not (0 <= u < vd["W"] and 0 <= vp < vd["H"]):
143
+ continue
144
+
145
+ dist, idx = vd["tree"].query([u, vp], k=1)
146
+ if dist <= max_pixel_dist:
147
+ Ps_match.append(vd["P"])
148
+ pts2d_match.append(vd["corners"][idx])
149
+
150
+ if len(Ps_match) < min_views:
151
+ continue # not enough 2D evidence; keep original V
152
+
153
+ v_new = triangulate_dlt(Ps_match, pts2d_match)
154
+ if not np.all(np.isfinite(v_new)):
155
+ continue
156
+
157
+ # Sanity 1: refined position shouldn't have moved too far.
158
+ if float(np.linalg.norm(v_new - v)) > max_move_meters:
159
+ continue
160
+
161
+ # Sanity 2: refined position must reproject well to its 2D matches.
162
+ err = mean_reprojection_error(v_new, Ps_match, pts2d_match)
163
+ if not np.isfinite(err) or err > max_reproj_px:
164
+ continue
165
+
166
+ refined[i] = v_new
167
+ n_refined += 1
168
+
169
+ return refined.astype(np.asarray(pv).dtype), pe
170
+
171
+ except Exception:
172
+ return pv, pe