2D gestalt-edge consistency filter: drop edges with no view support
Browse filesFor 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>
- edge_2d_filter.py +164 -0
- script.py +21 -1
|
@@ -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
|
|
@@ -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}
|
|
|
|
| 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({
|