Update code: learned-baseline + graph-opt postprocess
Browse files- requirements.txt +1 -6
- script.py +91 -16
- src/learned_baseline/configs/base.json +39 -0
- src/learned_baseline/plane_snap.py +135 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 96 |
-
COLMAP_QUOTA
|
| 97 |
-
DEPTH_QUOTA
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 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
|
| 242 |
-
mask
|
| 243 |
-
cid
|
|
|
|
| 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
|