xsponenta Claude Opus 4.7 commited on
Commit
72962c4
·
1 Parent(s): 6ecd5f8

Geometric regularization pass: vertical + Manhattan-direction snap

Browse files

Two 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>

Files changed (2) hide show
  1. s23dr_2026_example/postprocess_v2.py +108 -0
  2. script.py +7 -2
s23dr_2026_example/postprocess_v2.py CHANGED
@@ -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)
script.py CHANGED
@@ -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 snap_to_point_cloud, snap_horizontal
 
 
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
- # Horizontal snap
 
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()