| """Edge classifier and classifier-gated edge augmentation. |
| |
| Contains: |
| - ClassificationPointNet: PointNet binary classifier on 6D cylindrical patches |
| - colmap_points_xyz_rgb / build_edge_patch_6d: patch builder |
| - score_edges_batched: per-sample batched inference |
| - augment_hybrid_with_filtered_hc: import high-confidence handcrafted edges |
| """ |
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
|
|
| |
| CYL_RADIUS = 0.5 |
| CYL_EXT = 0.25 |
| MAX_PATCH_POINTS = 1024 |
| AUGMENT_DEDUP_RADIUS = 0.3 |
| AUGMENT_THRESHOLD = 0.55 |
|
|
|
|
| class ClassificationPointNet(nn.Module): |
| """PointNet binary classifier on 6D point cloud patches (xyz + rgb).""" |
|
|
| def __init__(self, input_dim=6, max_points=1024): |
| super().__init__() |
| self.max_points = max_points |
| self.conv1 = nn.Conv1d(input_dim, 64, 1) |
| self.conv2 = nn.Conv1d(64, 128, 1) |
| self.conv3 = nn.Conv1d(128, 256, 1) |
| self.conv4 = nn.Conv1d(256, 512, 1) |
| self.conv5 = nn.Conv1d(512, 1024, 1) |
| self.conv6 = nn.Conv1d(1024, 2048, 1) |
| self.fc1 = nn.Linear(2048, 1024) |
| self.fc2 = nn.Linear(1024, 512) |
| self.fc3 = nn.Linear(512, 256) |
| self.fc4 = nn.Linear(256, 128) |
| self.fc5 = nn.Linear(128, 64) |
| self.fc6 = nn.Linear(64, 1) |
| self.bn1 = nn.BatchNorm1d(64) |
| self.bn2 = nn.BatchNorm1d(128) |
| self.bn3 = nn.BatchNorm1d(256) |
| self.bn4 = nn.BatchNorm1d(512) |
| self.bn5 = nn.BatchNorm1d(1024) |
| self.bn6 = nn.BatchNorm1d(2048) |
| self.dropout1 = nn.Dropout(0.3) |
| self.dropout2 = nn.Dropout(0.4) |
| self.dropout3 = nn.Dropout(0.5) |
| self.dropout4 = nn.Dropout(0.4) |
| self.dropout5 = nn.Dropout(0.3) |
|
|
| def forward(self, x): |
| |
| x1 = F.relu(self.bn1(self.conv1(x))) |
| x2 = F.relu(self.bn2(self.conv2(x1))) |
| x3 = F.relu(self.bn3(self.conv3(x2))) |
| x4 = F.relu(self.bn4(self.conv4(x3))) |
| x5 = F.relu(self.bn5(self.conv5(x4))) |
| x6 = F.relu(self.bn6(self.conv6(x5))) |
| g = torch.max(x6, 2)[0] |
| x = F.relu(self.fc1(g)); x = self.dropout1(x) |
| x = F.relu(self.fc2(x)); x = self.dropout2(x) |
| x = F.relu(self.fc3(x)); x = self.dropout3(x) |
| x = F.relu(self.fc4(x)); x = self.dropout4(x) |
| x = F.relu(self.fc5(x)); x = self.dropout5(x) |
| return self.fc6(x) |
|
|
|
|
| def load_pnet_class(model_path, device=None): |
| """Load a trained ClassificationPointNet checkpoint.""" |
| if device is None: |
| device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') |
| model = ClassificationPointNet(input_dim=6, max_points=MAX_PATCH_POINTS) |
| ckpt = torch.load(model_path, map_location=device, weights_only=False) |
| model.load_state_dict(ckpt['model_state_dict']) |
| model.to(device).eval() |
| return model |
|
|
|
|
| def colmap_points_xyz_rgb(colmap_rec): |
| """Return (xyz, rgb_normalized_0_1) for all COLMAP points.""" |
| xyz_list, rgb_list = [], [] |
| for _, p3D in colmap_rec.points3D.items(): |
| xyz_list.append(p3D.xyz) |
| rgb_list.append(p3D.color / 255.0) |
| if not xyz_list: |
| return np.empty((0, 3)), np.empty((0, 3)) |
| return np.array(xyz_list), np.array(rgb_list) |
|
|
|
|
| def build_edge_patch_6d(u_xyz, v_xyz, colmap_xyz, colmap_rgb): |
| """6D cylinder patch around edge (u, v). None if too sparse.""" |
| line = v_xyz - u_xyz |
| L = float(np.linalg.norm(line)) |
| if L < 1e-6: |
| return None |
| direction = line / L |
| ext_start = u_xyz - CYL_EXT * direction |
| ext_L = L + 2 * CYL_EXT |
| rel = colmap_xyz - ext_start[np.newaxis, :] |
| proj = rel @ direction |
| in_bounds = (proj >= 0) & (proj <= ext_L) |
| closest = ext_start[np.newaxis, :] + proj[:, np.newaxis] * direction[np.newaxis, :] |
| perp = np.linalg.norm(colmap_xyz - closest, axis=1) |
| in_cyl = in_bounds & (perp <= CYL_RADIUS) |
| if int(in_cyl.sum()) <= 10: |
| return None |
| midpoint = (u_xyz + v_xyz) / 2 |
| pts_centered = colmap_xyz[in_cyl] - midpoint |
| rgb_signed = colmap_rgb[in_cyl] * 2.0 - 1.0 |
| return np.hstack([pts_centered, rgb_signed]) |
|
|
|
|
| def _pad_or_sample_patch(patch_6d, max_pts=MAX_PATCH_POINTS, rng=None): |
| if rng is None: |
| rng = np.random |
| n = patch_6d.shape[0] |
| if n >= max_pts: |
| idx = rng.choice(n, max_pts, replace=False) |
| return patch_6d[idx] |
| out = np.zeros((max_pts, 6), dtype=np.float32) |
| out[:n] = patch_6d |
| return out |
|
|
|
|
| def score_edges_batched(model, device, vertices, edges, colmap_xyz, colmap_rgb, |
| rng=None, batch_size=32): |
| """Score every edge in `edges` (returns list of floats, or None per failed patch).""" |
| if rng is None: |
| rng = np.random.RandomState(0) |
| n = len(edges) |
| scores = [None] * n |
| if n == 0 or len(vertices) == 0 or len(colmap_xyz) == 0: |
| return scores |
|
|
| patches, indices = [], [] |
| for i, (u, v) in enumerate(edges): |
| u_xyz = vertices[int(u)] |
| v_xyz = vertices[int(v)] |
| raw = build_edge_patch_6d(u_xyz, v_xyz, colmap_xyz, colmap_rgb) |
| if raw is None: |
| continue |
| patches.append(_pad_or_sample_patch(raw, rng=rng).astype(np.float32)) |
| indices.append(i) |
|
|
| if not patches: |
| return scores |
|
|
| batch = np.stack(patches, axis=0).transpose(0, 2, 1) |
| with torch.no_grad(): |
| for start in range(0, len(batch), batch_size): |
| end = min(start + batch_size, len(batch)) |
| x = torch.from_numpy(batch[start:end]).to(device) |
| logits = model(x).squeeze(-1) |
| probs = torch.sigmoid(logits).cpu().numpy().reshape(-1) |
| for j, p in enumerate(probs): |
| scores[indices[start + j]] = float(p) |
| return scores |
|
|
|
|
| def augment_hybrid_with_filtered_hc(h_v, h_e, user_v, user_e, scores, |
| thresh=AUGMENT_THRESHOLD, |
| dedup_radius=AUGMENT_DEDUP_RADIUS): |
| """Add handcrafted edges scoring above thresh into the hybrid (v, e).""" |
| h_v = np.asarray(h_v, dtype=np.float32) |
| user_v = np.asarray(user_v, dtype=np.float32) |
| h_e_list = [(int(a), int(b)) for a, b in h_e] |
| if scores is None or len(user_e) == 0 or len(user_v) == 0: |
| return h_v, h_e_list |
|
|
| kept_pairs = [ |
| (int(u), int(v)) for (u, v), s in zip(user_e, scores) |
| if s is not None and s > thresh |
| ] |
| if not kept_pairs: |
| return h_v, h_e_list |
|
|
| needed_hc_idx = sorted({u for u, _ in kept_pairs} | {v for _, v in kept_pairs}) |
| new_v_list, user_to_combined = [], {} |
| for u_idx in needed_hc_idx: |
| pos = user_v[u_idx] |
| if len(h_v) > 0: |
| d = np.linalg.norm(h_v - pos, axis=1) |
| best = int(np.argmin(d)) |
| if d[best] <= dedup_radius: |
| user_to_combined[u_idx] = best |
| continue |
| user_to_combined[u_idx] = len(h_v) + len(new_v_list) |
| new_v_list.append(pos) |
|
|
| if new_v_list: |
| combined_v = np.concatenate([h_v, np.stack(new_v_list, axis=0)], axis=0) |
| else: |
| combined_v = h_v |
|
|
| existing = {(min(a, b), max(a, b)) for a, b in h_e_list} |
| new_edges = [] |
| for u, v in kept_pairs: |
| a, b = user_to_combined[u], user_to_combined[v] |
| if a == b: |
| continue |
| key = (min(a, b), max(a, b)) |
| if key in existing: |
| continue |
| existing.add(key) |
| new_edges.append((a, b)) |
| return combined_v, h_e_list + new_edges |
|
|