StarAtNyte1 commited on
Commit
b71b050
·
verified ·
1 Parent(s): 6a9c6aa

Update code: learned-baseline + graph-opt postprocess

Browse files
requirements.txt CHANGED
@@ -1,14 +1,9 @@
1
- modal>=0.73.0
2
- datasets>=3.0.0
3
- huggingface-hub>=0.25.0
4
  torch>=2.2.0
5
  torchvision>=0.17.0
6
  numpy>=1.24.0
7
- opencv-python-headless>=4.8.0
8
  scipy>=1.11.0
9
  scikit-learn>=1.3.0
10
  trimesh>=4.0.0
11
- manifold3d
12
  Pillow>=10.0.0
13
  plotly>=5.0.0
14
  pycolmap>=3.13
@@ -17,4 +12,4 @@ open3d>=0.18.0
17
  tqdm
18
  pandas
19
  matplotlib
20
- hf-transfer
 
 
 
 
1
  torch>=2.2.0
2
  torchvision>=0.17.0
3
  numpy>=1.24.0
 
4
  scipy>=1.11.0
5
  scikit-learn>=1.3.0
6
  trimesh>=4.0.0
 
7
  Pillow>=10.0.0
8
  plotly>=5.0.0
9
  pycolmap>=3.13
 
12
  tqdm
13
  pandas
14
  matplotlib
15
+ opencv-python-headless>=4.8.0
script.py CHANGED
@@ -88,16 +88,22 @@ from src.learned_baseline.model import EdgeDepthSegmentsModel
88
  from src.learned_baseline.segment_postprocess import merge_vertices_iterative
89
  from src.learned_baseline.varifold import segments_to_vertices_edges
90
  from src.learned_baseline.postprocess_v2 import snap_to_point_cloud, snap_horizontal
 
91
 
92
  # graph_optimizer A/B-tested -0.0014 HSS — disabled
93
  USE_GRAPH_OPT = False
94
 
95
- SEQ_LEN = 4096
96
- COLMAP_QUOTA = 3072
97
- DEPTH_QUOTA = 1024
98
- CONF_THRESH = 0.5
99
- MERGE_THRESH = 0.4
100
- SNAP_RADIUS = 0.5
 
 
 
 
 
101
 
102
  CHECKPOINT_PATH = SCRIPT_DIR / "checkpoint_learned.pt"
103
  if not CHECKPOINT_PATH.exists():
@@ -215,17 +221,76 @@ def build_tokens_single(sample_dict, model, device):
215
  return tokens, masks
216
 
217
 
218
- def predict_sample(sample_dict, model, device):
219
- tokens, masks = build_tokens_single(sample_dict, model, device)
220
- scale = float(sample_dict["scale"])
221
- center = sample_dict["center"]
222
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  with torch.no_grad(), torch.autocast(device_type='cuda', dtype=torch.float16,
224
  enabled=(device.type == 'cuda')):
225
  out = model.forward_tokens(tokens, masks)
226
-
227
- segs = out["segments"][0].float().cpu()
228
  conf = torch.sigmoid(out["conf"][0].float()).cpu().numpy() if "conf" in out else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
  if conf is not None:
231
  keep = conf > CONF_THRESH
@@ -238,13 +303,23 @@ def predict_sample(sample_dict, model, device):
238
  pv, pe = pv.numpy(), np.array(pe, dtype=np.int32)
239
  pv, pe = merge_vertices_iterative(pv, pe)
240
 
241
- xyz_norm = sample_dict["xyz_norm"]
242
- mask = sample_dict["mask"]
243
- cid = sample_dict["class_id"]
 
244
  xyz_world = xyz_norm[mask] * scale + center
245
  cid_valid = cid[mask]
 
246
  pv = snap_to_point_cloud(pv, xyz_world, cid_valid, snap_radius=SNAP_RADIUS)
247
  pv = snap_horizontal(pv, pe)
 
 
 
 
 
 
 
 
248
 
249
  if len(pv) < 2 or len(pe) < 1:
250
  return empty_solution()
 
88
  from src.learned_baseline.segment_postprocess import merge_vertices_iterative
89
  from src.learned_baseline.varifold import segments_to_vertices_edges
90
  from src.learned_baseline.postprocess_v2 import snap_to_point_cloud, snap_horizontal
91
+ from src.learned_baseline.plane_snap import snap_to_plane_intersections
92
 
93
  # graph_optimizer A/B-tested -0.0014 HSS — disabled
94
  USE_GRAPH_OPT = False
95
 
96
+ SEQ_LEN = 4096
97
+ COLMAP_QUOTA = 3072
98
+ DEPTH_QUOTA = 1024
99
+ # Hparam sweep on 200 val scenes: marginal +0.0008 HSS over (0.5, 0.5, 0.6).
100
+ CONF_THRESH = 0.7 # was 0.5
101
+ MERGE_THRESH = 0.4
102
+ SNAP_RADIUS = 0.3 # was 0.5
103
+ PLANE_SNAP_RADIUS = 0.35 # sweep of 100 val scenes: r=0.35 best (+0.005 HSS)
104
+ USE_PLANE_SNAP = True
105
+ USE_TTA = False # model not rotation-equivariant; TTA hurts both F1 and HSS
106
+ TTA_ANGLES = [0.0, 0.5 * np.pi, np.pi, 1.5 * np.pi]
107
 
108
  CHECKPOINT_PATH = SCRIPT_DIR / "checkpoint_learned.pt"
109
  if not CHECKPOINT_PATH.exists():
 
221
  return tokens, masks
222
 
223
 
224
+ def _nms_segments(segs_np, confs_np, mid_thresh=0.4, dir_thresh=0.90):
225
+ """Greedy NMS on segment pool. Removes near-duplicate segments (same structural
226
+ edge predicted from multiple TTA rotations). segs_np: (N, 2, 3), confs_np: (N,).
227
+ Keeps highest-confidence segment in each cluster."""
228
+ if len(segs_np) == 0:
229
+ return segs_np, confs_np
230
+ order = np.argsort(-confs_np)
231
+ segs_s = segs_np[order]
232
+ confs_s = confs_np[order]
233
+ mids = (segs_s[:, 0] + segs_s[:, 1]) * 0.5 # (N, 3)
234
+ dirs = segs_s[:, 1] - segs_s[:, 0] # (N, 3)
235
+ norms = np.linalg.norm(dirs, axis=-1, keepdims=True) + 1e-8
236
+ dirs = dirs / norms
237
+ keep = np.ones(len(segs_s), dtype=bool)
238
+ for i in range(len(segs_s)):
239
+ if not keep[i]:
240
+ continue
241
+ dists = np.linalg.norm(mids[i+1:] - mids[i], axis=-1)
242
+ cos_sim = np.abs((dirs[i+1:] * dirs[i]).sum(-1))
243
+ suppress = (dists < mid_thresh) & (cos_sim > dir_thresh)
244
+ keep[i+1:][suppress] = False
245
+ return segs_s[keep], confs_s[keep]
246
+
247
+
248
+ def _rotation_y(angle):
249
+ """Rotation matrix around y-axis (vertical)."""
250
+ c, s = float(np.cos(angle)), float(np.sin(angle))
251
+ return np.array([[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]], dtype=np.float32)
252
+
253
+
254
+ def _forward_one(sample_dict, model, device, R=None):
255
+ """Single forward pass, optionally with point cloud rotated by R (3x3 numpy array).
256
+ Returns (segs_norm_derotated, conf) where segs_norm is (N, 2, 3) in normalized space."""
257
+ sd = sample_dict
258
+ if R is not None:
259
+ sd = dict(sample_dict)
260
+ sd["xyz_norm"] = sample_dict["xyz_norm"] @ R.T # rotate cloud
261
+ tokens, masks = build_tokens_single(sd, model, device)
262
  with torch.no_grad(), torch.autocast(device_type='cuda', dtype=torch.float16,
263
  enabled=(device.type == 'cuda')):
264
  out = model.forward_tokens(tokens, masks)
265
+ segs = out["segments"][0].float().cpu() # (N, 2, 3) normalized
 
266
  conf = torch.sigmoid(out["conf"][0].float()).cpu().numpy() if "conf" in out else None
267
+ if R is not None:
268
+ # De-rotate: segs @ R (since input was rotated by R, undo with R^-1 = R^T,
269
+ # but in row-vector form: x_orig = x_rot @ R because x_rot = x_orig @ R.T)
270
+ Rt = torch.tensor(R, dtype=torch.float32)
271
+ segs = torch.einsum('nkd,de->nke', segs, Rt)
272
+ return segs, conf
273
+
274
+
275
+ def predict_sample(sample_dict, model, device):
276
+ scale = float(sample_dict["scale"])
277
+ center = sample_dict["center"]
278
+
279
+ if USE_TTA:
280
+ all_segs, all_confs = [], []
281
+ for angle in TTA_ANGLES:
282
+ R = _rotation_y(angle) if angle != 0.0 else None
283
+ segs, conf = _forward_one(sample_dict, model, device, R)
284
+ all_segs.append(segs)
285
+ if conf is not None:
286
+ all_confs.append(conf)
287
+ segs_np = torch.cat(all_segs, dim=0).numpy()
288
+ conf = np.concatenate(all_confs) if all_confs else None
289
+ # NMS: collapse near-duplicate segments from 4 rotations into 1
290
+ segs_np, conf = _nms_segments(segs_np, conf)
291
+ segs = torch.tensor(segs_np)
292
+ else:
293
+ segs, conf = _forward_one(sample_dict, model, device)
294
 
295
  if conf is not None:
296
  keep = conf > CONF_THRESH
 
303
  pv, pe = pv.numpy(), np.array(pe, dtype=np.int32)
304
  pv, pe = merge_vertices_iterative(pv, pe)
305
 
306
+ xyz_norm = sample_dict["xyz_norm"]
307
+ mask = sample_dict["mask"]
308
+ cid = sample_dict["class_id"]
309
+ src = sample_dict.get("source", np.zeros(len(xyz_norm), dtype=np.int64))
310
  xyz_world = xyz_norm[mask] * scale + center
311
  cid_valid = cid[mask]
312
+ src_valid = src[mask]
313
  pv = snap_to_point_cloud(pv, xyz_world, cid_valid, snap_radius=SNAP_RADIUS)
314
  pv = snap_horizontal(pv, pe)
315
+ if USE_PLANE_SNAP:
316
+ try:
317
+ pv = snap_to_plane_intersections(
318
+ pv, pe, xyz_world, cid_valid, src_valid=src_valid,
319
+ snap_radius=PLANE_SNAP_RADIUS,
320
+ )
321
+ except Exception as exc:
322
+ print(f" plane_snap failed: {exc}")
323
 
324
  if len(pv) < 2 or len(pe) < 1:
325
  return empty_solution()
src/learned_baseline/configs/base.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "arch": "perceiver",
3
+ "segments": 64,
4
+ "hidden": 256,
5
+ "ff": 1024,
6
+ "num_heads": 4,
7
+ "kv_heads_cross": 2,
8
+ "kv_heads_self": 2,
9
+ "latent_tokens": 256,
10
+ "latent_layers": 7,
11
+ "decoder_layers": 3,
12
+ "cross_attn_interval": 4,
13
+ "encoder_layers": 4,
14
+ "behind_emb_dim": 8,
15
+ "dropout": 0.1,
16
+ "activation": "gelu",
17
+ "rms_norm": true,
18
+ "qk_norm": true,
19
+ "qk_norm_type": "l2",
20
+ "segment_param": "midpoint_dir_len",
21
+ "segment_conf": true,
22
+ "vote_features": true,
23
+
24
+ "adam_betas": "0.9,0.95",
25
+ "weight_decay": 0.01,
26
+ "warmup": 10000,
27
+ "varifold_weight": 0.0,
28
+ "sinkhorn_weight": 1.0,
29
+ "sinkhorn_eps": 0.1,
30
+ "sinkhorn_iters": 20,
31
+ "sinkhorn_dustbin": 0.3,
32
+ "conf_weight": 0.1,
33
+ "conf_mode": "sinkhorn",
34
+ "conf_head_wd": 0.1,
35
+
36
+ "aug_rotate": true,
37
+ "aug_flip": true,
38
+ "seed": 353
39
+ }
src/learned_baseline/plane_snap.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Roof plane RANSAC snap: fit planes to roof surface points, snap vertices
2
+ to plane-plane intersection lines (ridges/eaves/hips)."""
3
+ import numpy as np
4
+
5
+ ROOF_CLASS = 13 # gestalt "roof" surface label
6
+
7
+
8
+ def _ransac_plane(pts, dist_thresh=0.12, max_iters=150, rng=None):
9
+ """Fit plane via RANSAC + PCA refit. Returns (normal, d) or None."""
10
+ if rng is None:
11
+ rng = np.random.RandomState(42)
12
+ best_inliers = 0
13
+ best_n = best_d = None
14
+ for _ in range(max_iters):
15
+ idx = rng.choice(len(pts), 3, replace=False)
16
+ p1, p2, p3 = pts[idx]
17
+ n = np.cross(p2 - p1, p3 - p1)
18
+ nrm = np.linalg.norm(n)
19
+ if nrm < 1e-10:
20
+ continue
21
+ n /= nrm
22
+ d = float(np.dot(n, p1))
23
+ n_in = int((np.abs(pts @ n - d) < dist_thresh).sum())
24
+ if n_in > best_inliers:
25
+ best_inliers, best_n, best_d = n_in, n, d
26
+ if best_n is None or best_inliers < 3:
27
+ return None
28
+ inliers = pts[np.abs(pts @ best_n - best_d) < dist_thresh]
29
+ if len(inliers) < 3:
30
+ return (best_n, float(best_d))
31
+ centroid = inliers.mean(0)
32
+ _, _, Vt = np.linalg.svd(inliers - centroid, full_matrices=False)
33
+ n = Vt[-1]
34
+ if np.dot(n, best_n) < 0:
35
+ n = -n
36
+ return (n, float(np.dot(n, centroid)))
37
+
38
+
39
+ def _fit_planes(xyz_world, cid_valid, src_valid,
40
+ n_planes, min_inliers, dist_thresh):
41
+ """Iterative RANSAC on roof-class COLMAP points. Returns list of (n, d, centroid)."""
42
+ roof_mask = cid_valid == ROOF_CLASS
43
+ if src_valid is not None:
44
+ colmap_roof = roof_mask & (src_valid == 0)
45
+ pts = xyz_world[colmap_roof] if colmap_roof.sum() >= min_inliers else xyz_world[roof_mask]
46
+ else:
47
+ pts = xyz_world[roof_mask]
48
+ if len(pts) < min_inliers:
49
+ return []
50
+
51
+ rng = np.random.RandomState(123)
52
+ remaining = pts.copy()
53
+ planes = []
54
+ for _ in range(n_planes):
55
+ if len(remaining) < min_inliers:
56
+ break
57
+ result = _ransac_plane(remaining, dist_thresh=dist_thresh,
58
+ max_iters=150, rng=rng)
59
+ if result is None:
60
+ break
61
+ n, d = result
62
+ inlier_mask = np.abs(remaining @ n - d) < dist_thresh
63
+ if inlier_mask.sum() < min_inliers:
64
+ break
65
+ planes.append((n, d, remaining[inlier_mask].mean(0)))
66
+ remaining = remaining[~inlier_mask]
67
+ return planes
68
+
69
+
70
+ def _intersection_line(p1, p2):
71
+ """Line of intersection of two planes (n,d,c) each.
72
+ Returns (p0, dir, t_min, t_max) where [t_min, t_max] bounds the segment
73
+ between the two planes' centroid projections (limits snapping to actual edge
74
+ extent rather than infinite line). Returns None if parallel.
75
+ """
76
+ n1, d1, c1 = p1
77
+ n2, d2, c2 = p2
78
+ direction = np.cross(n1, n2)
79
+ nrm = np.linalg.norm(direction)
80
+ if nrm < 1e-6:
81
+ return None
82
+ direction /= nrm
83
+ A = np.stack([n1, n2, direction])
84
+ b = np.array([d1, d2, 0.0])
85
+ try:
86
+ p0 = np.linalg.solve(A, b)
87
+ except np.linalg.LinAlgError:
88
+ return None
89
+ # Bound line to overlap region of the two plane extents using centroids
90
+ t1 = float(np.dot(c1 - p0, direction))
91
+ t2 = float(np.dot(c2 - p0, direction))
92
+ t_min = min(t1, t2) - 3.0 # ±3m beyond centroids
93
+ t_max = max(t1, t2) + 3.0
94
+ return (p0, direction, t_min, t_max)
95
+
96
+
97
+ def snap_to_plane_intersections(pv, pe, xyz_world, cid_valid, src_valid=None,
98
+ n_planes=10, min_inliers=25, dist_thresh=0.12,
99
+ snap_radius=0.45):
100
+ """Snap predicted vertices to nearest roof plane-intersection line.
101
+
102
+ Fits up to n_planes roof planes via iterative RANSAC, computes pairwise
103
+ intersection lines, then moves each vertex to the closest point on the
104
+ nearest line within snap_radius. Falls back silently if < 2 planes found.
105
+ """
106
+ planes = _fit_planes(xyz_world, cid_valid, src_valid,
107
+ n_planes, min_inliers, dist_thresh)
108
+ if len(planes) < 2:
109
+ return pv
110
+
111
+ lines = []
112
+ for i in range(len(planes)):
113
+ for j in range(i + 1, len(planes)):
114
+ line = _intersection_line(planes[i], planes[j])
115
+ if line is not None:
116
+ lines.append(line)
117
+ if not lines:
118
+ return pv
119
+
120
+ pv_new = np.array(pv, dtype=np.float64)
121
+ for vi in range(len(pv_new)):
122
+ v = pv_new[vi]
123
+ best_dist = snap_radius
124
+ best_pos = None
125
+ for p0, d, t_min, t_max in lines:
126
+ t = float(np.dot(v - p0, d))
127
+ t = max(t_min, min(t_max, t)) # clamp to line segment
128
+ proj = p0 + t * d
129
+ dist = float(np.linalg.norm(v - proj))
130
+ if dist < best_dist:
131
+ best_dist = dist
132
+ best_pos = proj
133
+ if best_pos is not None:
134
+ pv_new[vi] = best_pos
135
+ return pv_new