xsponenta Claude Opus 4.7 commited on
Commit
50e27b8
·
1 Parent(s): dec2057

2D gestalt-edge consistency filter: drop edges with no view support

Browse files

For each predicted 3D edge, project both endpoints into every COLMAP view
and walk the 2D segment. Count how many sampled pixels fall on a gestalt
edge-class pixel (eave, ridge, rake, valley, hip, flashing, step_flashing)
after a 4px dilation of the mask. If at least 2 views show >=25% of the
projected segment overlapping the edge mask, keep the edge; otherwise drop
it as a hallucination with no 2D evidence.

Precision-only operation: can drop bad edges but cannot add new geometry,
so it can't repeat the plane_wireframe failure mode of polluting predictions.
Uses the same COLMAP cameras + gestalt masks that triangulation tracks
already rely on (validated machinery).

Conservative thresholds chosen for first-evaluation safety:
- min_views_support=2 (needs 2-view agreement to KEEP, not 1)
- min_pixel_frac=0.25 (a truly hallucinated edge projects to random pixels,
~5-10% mask overlap; a real edge after dilation should be >=80%, so 0.25
is a wide margin from both extremes)
- dilate_px=4 (9px-thick edge tolerance)
- sample_steps=20

Applied after hybrid_merge so both learned edges AND triangulation track
edges are filtered uniformly. Floor: never returns an empty graph.

DIAG output extended with `2dfilt=N->M` so we can see drop rates per
sample in the HF Space logs and tune thresholds based on real data.

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

Files changed (2) hide show
  1. edge_2d_filter.py +164 -0
  2. script.py +21 -1
edge_2d_filter.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """2D gestalt-edge consistency filter for predicted 3D wireframe edges.
2
+
3
+ For each predicted edge (v_a, v_b), project the endpoints into every COLMAP view
4
+ and walk the 2D segment between them. Count how many sampled pixels fall on a
5
+ gestalt "edge class" pixel (eave, ridge, rake, valley, hip, flashing,
6
+ step_flashing). If at least ``min_views_support`` views show strong overlap
7
+ (>= ``min_pixel_frac`` of samples), the edge is kept; otherwise it is dropped
8
+ as a hallucination unsupported by 2D evidence.
9
+
10
+ All COLMAP cameras + masks are already validated machinery (triangulation
11
+ tracks use them). The filter is precision-only — it can drop edges but cannot
12
+ introduce new geometry. Conservative defaults: an edge needs evidence in 2+
13
+ views, with at least 25% of the projected segment lying on edge-class pixels
14
+ (after dilation), to survive.
15
+
16
+ Falls back to the unfiltered (vertices, edges) on any failure so the pipeline
17
+ cannot collapse if a sample's COLMAP / gestalt data is malformed.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import numpy as np
23
+ import cv2
24
+
25
+ EDGE_CLASSES = (
26
+ "eave", "ridge", "rake", "valley", "hip", "flashing", "step_flashing",
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
+
33
+ Returns dict ``img_id -> (mask_bool, H, W)`` for every img_id that has both
34
+ a registered COLMAP view and a gestalt image.
35
+ """
36
+ from hoho2025.color_mappings import gestalt_color_mapping
37
+
38
+ out = {}
39
+ for gest_pil, depth_pil, img_id in zip(
40
+ good["gestalt"], good["depth"], good["image_ids"]
41
+ ):
42
+ if img_id not in views:
43
+ continue
44
+ depth_np = np.array(depth_pil)
45
+ H, W = depth_np.shape[:2]
46
+ gest_np = np.array(gest_pil.resize((W, H))).astype(np.uint8)
47
+
48
+ mask = np.zeros((H, W), dtype=np.uint8)
49
+ for cls in EDGE_CLASSES:
50
+ color = np.array(gestalt_color_mapping[cls])
51
+ m = cv2.inRange(gest_np, color - 0.5, color + 0.5)
52
+ mask |= m
53
+
54
+ if dilate_px > 0:
55
+ k = 2 * dilate_px + 1
56
+ mask = cv2.dilate(mask, np.ones((k, k), np.uint8), iterations=1)
57
+
58
+ out[img_id] = (mask > 0, H, W)
59
+ return out
60
+
61
+
62
+ def filter_edges_by_2d_support(
63
+ pv,
64
+ pe,
65
+ sample,
66
+ min_views_support: int = 2,
67
+ min_pixel_frac: float = 0.25,
68
+ dilate_px: int = 4,
69
+ sample_steps: int = 20,
70
+ ):
71
+ """Drop edges whose 2D projection lacks gestalt-edge mask support.
72
+
73
+ Args:
74
+ pv: (N, 3) vertices in world coordinates.
75
+ pe: list of (u, v) edge indices.
76
+ sample: raw dataset entry (used to access COLMAP + gestalt views).
77
+ min_views_support: edge must be supported by this many views to be kept.
78
+ min_pixel_frac: fraction of sampled pixels along the projected line
79
+ that must lie on an edge-class pixel for the view to count as
80
+ supporting.
81
+ dilate_px: pixel-radius dilation of the edge mask (gives tolerance
82
+ for slightly-off projections; 4 → 9px-thick edges).
83
+ sample_steps: number of points sampled along each 2D projected line.
84
+
85
+ Returns:
86
+ (pv_filtered, pe_filtered) with orphaned vertices removed. Falls back
87
+ to the input on any error or if the filter would leave fewer than one
88
+ edge or two vertices.
89
+ """
90
+ try:
91
+ from hoho2025.example_solutions import convert_entry_to_human_readable
92
+ from mvs_utils import collect_views, project_world_to_image
93
+
94
+ pv_arr = np.asarray(pv, dtype=np.float64)
95
+ if pv_arr.ndim != 2 or pv_arr.shape[0] < 2 or len(pe) < 1:
96
+ return pv, pe
97
+
98
+ good = convert_entry_to_human_readable(sample)
99
+ colmap_rec = good.get("colmap") or good.get("colmap_binary")
100
+ if colmap_rec is None:
101
+ return pv, pe
102
+
103
+ views = collect_views(colmap_rec, good["image_ids"])
104
+ if len(views) < min_views_support:
105
+ return pv, pe
106
+
107
+ view_masks = _build_edge_masks(good, views, dilate_px=dilate_px)
108
+ if not view_masks:
109
+ return pv, pe
110
+
111
+ keep_edges = []
112
+ for u, v in pe:
113
+ u, v = int(u), int(v)
114
+ if u == v or u >= len(pv_arr) or v >= len(pv_arr):
115
+ continue
116
+ endpoints = np.stack([pv_arr[u], pv_arr[v]])
117
+
118
+ supporting = 0
119
+ for img_id, view in views.items():
120
+ if img_id not in view_masks:
121
+ continue
122
+ mask_bool, H, W = view_masks[img_id]
123
+
124
+ uv, z = project_world_to_image(view["P"], endpoints)
125
+ # Require both endpoints in front of camera and inside the image.
126
+ if z[0] <= 0 or z[1] <= 0:
127
+ continue
128
+ if not (
129
+ 0 <= uv[0, 0] < W
130
+ and 0 <= uv[0, 1] < H
131
+ and 0 <= uv[1, 0] < W
132
+ and 0 <= uv[1, 1] < H
133
+ ):
134
+ continue
135
+
136
+ t = np.linspace(0.0, 1.0, sample_steps)
137
+ xs = uv[0, 0] + t * (uv[1, 0] - uv[0, 0])
138
+ ys = uv[0, 1] + t * (uv[1, 1] - uv[0, 1])
139
+ xs_i = np.clip(xs.astype(np.int32), 0, W - 1)
140
+ ys_i = np.clip(ys.astype(np.int32), 0, H - 1)
141
+
142
+ hits = int(mask_bool[ys_i, xs_i].sum())
143
+ if hits / float(sample_steps) >= min_pixel_frac:
144
+ supporting += 1
145
+ if supporting >= min_views_support:
146
+ break
147
+
148
+ if supporting >= min_views_support:
149
+ keep_edges.append((u, v))
150
+
151
+ # Safety: never return an empty graph.
152
+ if len(keep_edges) < 1:
153
+ return pv, pe
154
+
155
+ used = sorted({a for e in keep_edges for a in e})
156
+ if len(used) < 2:
157
+ return pv, pe
158
+ old_to_new = {old: new for new, old in enumerate(used)}
159
+ new_pv = np.asarray([pv_arr[old] for old in used])
160
+ new_pe = [(old_to_new[a], old_to_new[b]) for a, b in keep_edges]
161
+ return new_pv, new_pe
162
+
163
+ except Exception:
164
+ return pv, pe
script.py CHANGED
@@ -421,6 +421,23 @@ if __name__ == "__main__":
421
  print(f" Track ensemble failed for {order_id}: {track_e_err}")
422
  pred_status = "track_failed"
423
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  except Exception as e:
425
  import traceback
426
  print(f" Predict failed for {order_id}:\n{traceback.format_exc()}")
@@ -431,10 +448,13 @@ if __name__ == "__main__":
431
 
432
  n_pred_v = len(pred_v) if hasattr(pred_v, '__len__') else 0
433
  n_pred_e = len(pred_e) if hasattr(pred_e, '__len__') else 0
 
 
434
  print(
435
  f"[DIAG] order_id={order_id} colmap={n_colmap_pts} fused={n_fused_pts} "
436
  f"track_v={track_v_count} track_e={track_e_count} "
437
- f"pred_v={n_pred_v} pred_e={n_pred_e} status={pred_status}"
 
438
  )
439
 
440
  solution.append({
 
421
  print(f" Track ensemble failed for {order_id}: {track_e_err}")
422
  pred_status = "track_failed"
423
 
424
+ # 2D gestalt-edge consistency filter: drop predicted 3D edges
425
+ # whose projection has no support in any view's edge-class mask.
426
+ # Precision-only; can only drop edges, never add bad ones.
427
+ edges_before_2d = len(pred_e) if hasattr(pred_e, '__len__') else 0
428
+ try:
429
+ from edge_2d_filter import filter_edges_by_2d_support
430
+ pred_v, pred_e = filter_edges_by_2d_support(
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" 2D edge filter failed for {order_id}: {filt_err}")
439
+ edges_after_2d = len(pred_e) if hasattr(pred_e, '__len__') else 0
440
+
441
  except Exception as e:
442
  import traceback
443
  print(f" Predict failed for {order_id}:\n{traceback.format_exc()}")
 
448
 
449
  n_pred_v = len(pred_v) if hasattr(pred_v, '__len__') else 0
450
  n_pred_e = len(pred_e) if hasattr(pred_e, '__len__') else 0
451
+ edges_before = locals().get('edges_before_2d', n_pred_e)
452
+ edges_after = locals().get('edges_after_2d', n_pred_e)
453
  print(
454
  f"[DIAG] order_id={order_id} colmap={n_colmap_pts} fused={n_fused_pts} "
455
  f"track_v={track_v_count} track_e={track_e_count} "
456
+ f"pred_v={n_pred_v} pred_e={n_pred_e} "
457
+ f"2dfilt={edges_before}->{edges_after} status={pred_status}"
458
  )
459
 
460
  solution.append({