Geometric regularization pass: vertical + Manhattan-direction snap
Browse filesTwo new post-process passes added after snap_horizontal:
- snap_vertical: mirror of snap_horizontal across the up-axis. Near-vertical
edges (small horizontal spread vs vertical spread) get their X and Z forced
equal so the edge is exactly vertical.
- snap_manhattan: detects the dominant XZ-plane building direction of all
near-horizontal edges via a 4x-angle circular mean (90 deg symmetry). Edges
within 3 deg of the dominant direction or its perpendicular are snapped to
align exactly, via 3 iterations of Gauss-Seidel vertex averaging so shared
endpoints converge consistently without drift. Non-Manhattan wireframes
(hexagonal roofs, complex geometry) leave untouched because no edges fall
within tolerance.
Both passes are no-ops on edges that don't qualify; order is safe. Mathematically
distinct from the reverted 6cf3fbd's snap changes (which touched target_classes
and snap_horizontal incident-edge logic).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- s23dr_2026_example/postprocess_v2.py +108 -0
- script.py +7 -2
|
@@ -37,3 +37,111 @@ def snap_horizontal(vertices, edges, max_slope=0.05):
|
|
| 37 |
verts[a, 1] = avg_y
|
| 38 |
verts[b, 1] = avg_y
|
| 39 |
return verts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
verts[a, 1] = avg_y
|
| 38 |
verts[b, 1] = avg_y
|
| 39 |
return verts
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def snap_vertical(vertices, edges, max_slope=0.05):
|
| 43 |
+
"""Snap near-vertical edges to be exactly vertical (shared X and Z).
|
| 44 |
+
|
| 45 |
+
Mirror of snap_horizontal across the up-axis. A near-vertical edge has
|
| 46 |
+
a small horizontal spread (dxz) relative to its vertical spread (dy).
|
| 47 |
+
"""
|
| 48 |
+
verts = vertices.copy()
|
| 49 |
+
for a, b in edges:
|
| 50 |
+
a, b = int(a), int(b)
|
| 51 |
+
dy = abs(verts[a, 1] - verts[b, 1])
|
| 52 |
+
dxz = np.sqrt((verts[a, 0] - verts[b, 0]) ** 2 + (verts[a, 2] - verts[b, 2]) ** 2)
|
| 53 |
+
if dy > 0.1 and dxz / dy < max_slope:
|
| 54 |
+
avg_x = 0.5 * (verts[a, 0] + verts[b, 0])
|
| 55 |
+
avg_z = 0.5 * (verts[a, 2] + verts[b, 2])
|
| 56 |
+
verts[a, 0] = avg_x
|
| 57 |
+
verts[a, 2] = avg_z
|
| 58 |
+
verts[b, 0] = avg_x
|
| 59 |
+
verts[b, 2] = avg_z
|
| 60 |
+
return verts
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def snap_manhattan(vertices, edges, tol_deg=3.0, min_count=4, n_iters=3):
|
| 64 |
+
"""Snap near-axis-aligned horizontal edges to a single dominant building direction.
|
| 65 |
+
|
| 66 |
+
Detects the dominant XZ-plane direction of near-horizontal edges via a
|
| 67 |
+
doubled-angle circular mean (so direction and its 90° rotation reinforce
|
| 68 |
+
each other). Edges within ``tol_deg`` of that direction or its perpendicular
|
| 69 |
+
are rotated about their midpoint to align exactly. Endpoints shared between
|
| 70 |
+
multiple snapped edges are averaged (Gauss-Seidel, ``n_iters`` passes) to
|
| 71 |
+
converge to a Manhattan-consistent position without drift.
|
| 72 |
+
|
| 73 |
+
Only operates in the XZ plane; Y is preserved (use snap_horizontal /
|
| 74 |
+
snap_vertical for Y handling). Returns vertices unchanged if fewer than
|
| 75 |
+
``min_count`` near-horizontal edges exist (insufficient evidence).
|
| 76 |
+
"""
|
| 77 |
+
verts = np.asarray(vertices, dtype=np.float64).copy()
|
| 78 |
+
n_verts = len(verts)
|
| 79 |
+
n_edges = len(edges)
|
| 80 |
+
if n_verts < 2 or n_edges < min_count:
|
| 81 |
+
return vertices
|
| 82 |
+
|
| 83 |
+
# Classify each edge once: near-horizontal? full-angle in XZ?
|
| 84 |
+
edge_data = [] # list of (a, b, full_ang) for near-horizontal edges only
|
| 85 |
+
for a, b in edges:
|
| 86 |
+
a, b = int(a), int(b)
|
| 87 |
+
dx = verts[b, 0] - verts[a, 0]
|
| 88 |
+
dy = verts[b, 1] - verts[a, 1]
|
| 89 |
+
dz = verts[b, 2] - verts[a, 2]
|
| 90 |
+
dxz = np.sqrt(dx * dx + dz * dz)
|
| 91 |
+
if dxz < 0.2:
|
| 92 |
+
continue
|
| 93 |
+
if abs(dy) / dxz > 0.1:
|
| 94 |
+
continue
|
| 95 |
+
full_ang = np.arctan2(dz, dx)
|
| 96 |
+
edge_data.append((a, b, full_ang))
|
| 97 |
+
|
| 98 |
+
if len(edge_data) < min_count:
|
| 99 |
+
return vertices
|
| 100 |
+
|
| 101 |
+
# Dominant Manhattan direction via 4×-angle circular mean: a building grid
|
| 102 |
+
# has 90° symmetry, so edges at θ, θ+90°, θ+180°, θ+270° must reinforce each
|
| 103 |
+
# other in the estimate. Multiply by 4 to fold [0, π/2) → [0, 2π), take the
|
| 104 |
+
# circular mean, divide by 4.
|
| 105 |
+
quad = np.array([4.0 * e[2] for e in edge_data])
|
| 106 |
+
peak_ang = 0.25 * np.arctan2(np.sin(quad).mean(), np.cos(quad).mean())
|
| 107 |
+
|
| 108 |
+
# Identify edges to snap and pick the best axis (peak, peak+π/2, peak+π, peak+3π/2)
|
| 109 |
+
tol = np.deg2rad(tol_deg)
|
| 110 |
+
snap_targets = [] # (a, b, ux, uz)
|
| 111 |
+
candidates = [peak_ang + k * (np.pi / 2.0) for k in range(4)]
|
| 112 |
+
for a, b, full_ang in edge_data:
|
| 113 |
+
diffs = [abs(((full_ang - c + np.pi) % (2 * np.pi)) - np.pi) for c in candidates]
|
| 114 |
+
k = int(np.argmin(diffs))
|
| 115 |
+
if diffs[k] >= tol:
|
| 116 |
+
continue
|
| 117 |
+
target = candidates[k]
|
| 118 |
+
snap_targets.append((a, b, float(np.cos(target)), float(np.sin(target))))
|
| 119 |
+
|
| 120 |
+
if not snap_targets:
|
| 121 |
+
return vertices
|
| 122 |
+
|
| 123 |
+
# Gauss-Seidel: each iteration, accumulate target endpoint positions per
|
| 124 |
+
# vertex and average. Y is left untouched.
|
| 125 |
+
for _ in range(n_iters):
|
| 126 |
+
sum_xz = np.zeros((n_verts, 2), dtype=np.float64)
|
| 127 |
+
count = np.zeros(n_verts, dtype=np.int64)
|
| 128 |
+
for a, b, ux, uz in snap_targets:
|
| 129 |
+
mx = 0.5 * (verts[a, 0] + verts[b, 0])
|
| 130 |
+
mz = 0.5 * (verts[a, 2] + verts[b, 2])
|
| 131 |
+
dx = verts[b, 0] - verts[a, 0]
|
| 132 |
+
dz = verts[b, 2] - verts[a, 2]
|
| 133 |
+
length = abs(dx * ux + dz * uz) # projection of edge onto snap dir
|
| 134 |
+
half = 0.5 * length
|
| 135 |
+
sum_xz[a, 0] += mx - ux * half
|
| 136 |
+
sum_xz[a, 1] += mz - uz * half
|
| 137 |
+
sum_xz[b, 0] += mx + ux * half
|
| 138 |
+
sum_xz[b, 1] += mz + uz * half
|
| 139 |
+
count[a] += 1
|
| 140 |
+
count[b] += 1
|
| 141 |
+
moved = count > 0
|
| 142 |
+
if not moved.any():
|
| 143 |
+
break
|
| 144 |
+
verts[moved, 0] = sum_xz[moved, 0] / count[moved]
|
| 145 |
+
verts[moved, 2] = sum_xz[moved, 1] / count[moved]
|
| 146 |
+
|
| 147 |
+
return verts.astype(np.asarray(vertices).dtype)
|
|
@@ -50,7 +50,9 @@ from s23dr_2026_example.tokenizer import EdgeDepthSequenceConfig
|
|
| 50 |
from s23dr_2026_example.model import EdgeDepthSegmentsModel
|
| 51 |
from s23dr_2026_example.segment_postprocess import merge_vertices_iterative
|
| 52 |
from s23dr_2026_example.varifold import segments_to_vertices_edges
|
| 53 |
-
from s23dr_2026_example.postprocess_v2 import
|
|
|
|
|
|
|
| 54 |
|
| 55 |
SEQ_LEN = 4096
|
| 56 |
COLMAP_QUOTA = 3072
|
|
@@ -232,8 +234,11 @@ def predict_sample(sample_dict, model, device):
|
|
| 232 |
cid_valid = cid[mask]
|
| 233 |
pv = snap_to_point_cloud(pv, xyz_world, cid_valid, snap_radius=SNAP_RADIUS)
|
| 234 |
|
| 235 |
-
#
|
|
|
|
| 236 |
pv = snap_horizontal(pv, pe)
|
|
|
|
|
|
|
| 237 |
|
| 238 |
if len(pv) < 2 or len(pe) < 1:
|
| 239 |
return empty_solution()
|
|
|
|
| 50 |
from s23dr_2026_example.model import EdgeDepthSegmentsModel
|
| 51 |
from s23dr_2026_example.segment_postprocess import merge_vertices_iterative
|
| 52 |
from s23dr_2026_example.varifold import segments_to_vertices_edges
|
| 53 |
+
from s23dr_2026_example.postprocess_v2 import (
|
| 54 |
+
snap_to_point_cloud, snap_horizontal, snap_vertical, snap_manhattan,
|
| 55 |
+
)
|
| 56 |
|
| 57 |
SEQ_LEN = 4096
|
| 58 |
COLMAP_QUOTA = 3072
|
|
|
|
| 234 |
cid_valid = cid[mask]
|
| 235 |
pv = snap_to_point_cloud(pv, xyz_world, cid_valid, snap_radius=SNAP_RADIUS)
|
| 236 |
|
| 237 |
+
# Axis snaps: horizontal, then vertical, then Manhattan-direction in XZ.
|
| 238 |
+
# Each pass is a no-op on edges that don't qualify, so order is safe.
|
| 239 |
pv = snap_horizontal(pv, pe)
|
| 240 |
+
pv = snap_vertical(pv, pe)
|
| 241 |
+
pv = snap_manhattan(pv, pe)
|
| 242 |
|
| 243 |
if len(pv) < 2 or len(pe) < 1:
|
| 244 |
return empty_solution()
|