| """Scene feature point cloud builder from raw HoHo dataset samples. |
| |
| V10 cache layout (additions over v7): |
| - per-point RGB feature `scene_rgb` (uint8, 0..255): |
| * COLMAP points use `point3D.color` from the COLMAP record |
| * depth-unprojected points inherit the colour of their nearest COLMAP |
| neighbour in raw-world space (no raw images are released) |
| * camera tokens use a sentinel (0, 0, 0) |
| - metric voxel downsampling across ALL candidate points (COLMAP + depth + |
| camera) before the Tier-1/Tier-2 budget cap. At most one random point is |
| kept per `voxel_size_m`-side voxel in raw world coordinates. Set |
| `voxel_size_m <= 0` to disable. |
| |
| Inherited from v7: |
| - robust normalisation (median centre + P95 scale on COLMAP+camera union) |
| - bbox_R kept identity (random yaw is applied as data augmentation in the loader) |
| - separate per-point geometric and semantic confidence channels |
| - soft top-2 gestalt labels (`scene_gestalt_ids`/`scene_gestalt_id2` + `scene_gestalt_w1`) |
| - 3x3 majority gestalt/ADE projection on COLMAP points |
| - fixed split: 4096 COLMAP/camera tokens + 4096 depth-unprojected tokens |
| - camera tokens live only in the COLMAP half (type=2, Tier 0) |
| - Tier 1 structural Gestalt classes are preferred before weaker Tier 2 classes |
| - Tier 2 points require an ADE house/foreground label |
| - hard skip on empty COLMAP / missing cam metadata / ambiguous image matches |
| """ |
| import io |
| from typing import Optional, Tuple |
|
|
| import numpy as np |
| from PIL import Image |
|
|
| from hoho2025.example_solutions import _cam_matrix_from_image, get_fitted_dense_depth |
| from color_mappings import ade20k_color_mapping, gestalt_color_mapping |
|
|
| |
| |
| |
| GESTALT_CLASSES = list(gestalt_color_mapping.keys()) |
| ADE20K_CLASSES = list(ade20k_color_mapping.keys()) |
|
|
|
|
| def _build_palette_lut(color_mapping, start_id: int = 0): |
| """Return (packed_sorted, ids_sorted) arrays for searchsorted lookup.""" |
| colors = np.array([rgb for _, rgb in color_mapping.items()], dtype=np.int32) |
| packed = (colors[:, 0] << 16) | (colors[:, 1] << 8) | colors[:, 2] |
| ids = np.arange(start_id, start_id + len(packed), dtype=np.int64) |
| order = np.argsort(packed) |
| return packed[order], ids[order] |
|
|
|
|
| _GESTALT_PACKED_S, _GESTALT_IDS_S = _build_palette_lut(gestalt_color_mapping, start_id=0) |
| _ADE_PACKED_S, _ADE_IDS_S = _build_palette_lut(ade20k_color_mapping, start_id=1) |
|
|
|
|
| def _sample_palette( |
| img_np: np.ndarray, |
| vi: np.ndarray, |
| ui: np.ndarray, |
| packed_sorted: np.ndarray, |
| ids_sorted: np.ndarray, |
| default: int = 0, |
| ) -> np.ndarray: |
| """Look up palette class IDs for pixels at (vi, ui) without converting the whole image.""" |
| rgb = img_np[vi, ui].astype(np.int32) |
| packed = (rgb[:, 0] << 16) | (rgb[:, 1] << 8) | rgb[:, 2] |
| pos = np.searchsorted(packed_sorted, packed) |
| in_range = pos < len(packed_sorted) |
| hit = in_range & (packed_sorted[np.minimum(pos, len(packed_sorted) - 1)] == packed) |
| return np.where(hit, ids_sorted[np.minimum(pos, len(ids_sorted) - 1)], default).astype(np.int64) |
|
|
|
|
| def gestalt_img_to_ids(gest_np: np.ndarray) -> np.ndarray: |
| H, W = gest_np.shape[:2] |
| vi, ui = np.mgrid[0:H, 0:W] |
| return _sample_palette( |
| gest_np, |
| vi.ravel(), |
| ui.ravel(), |
| _GESTALT_PACKED_S, |
| _GESTALT_IDS_S, |
| default=0, |
| ).reshape(H, W) |
|
|
|
|
| def ade20k_img_to_ids(ade_np: np.ndarray) -> np.ndarray: |
| H, W = ade_np.shape[:2] |
| vi, ui = np.mgrid[0:H, 0:W] |
| return _sample_palette( |
| ade_np, |
| vi.ravel(), |
| ui.ravel(), |
| _ADE_PACKED_S, |
| _ADE_IDS_S, |
| default=0, |
| ).reshape(H, W) |
|
|
|
|
| def _ids_from_names(names: tuple[str, ...], all_names: list[str], start_id: int = 0) -> frozenset: |
| return frozenset(start_id + all_names.index(name) for name in names if name in all_names) |
|
|
|
|
| |
| |
| |
| GESTALT_TIER1_NAMES = ( |
| "apex", |
| "eave_end_point", |
| "flashing_end_point", |
| "eave", |
| "ridge", |
| "rake", |
| "valley", |
| ) |
| GESTALT_TIER2_NAMES = ( |
| "ground_line", |
| "flashing", |
| "step_flashing", |
| "hip", |
| "fascia", |
| "transition_line", |
| ) |
|
|
| TIER1_GESTALT_IDS: frozenset = _ids_from_names(GESTALT_TIER1_NAMES, GESTALT_CLASSES) |
| TIER2_GESTALT_IDS: frozenset = _ids_from_names(GESTALT_TIER2_NAMES, GESTALT_CLASSES) |
| PRIORITY_GESTALT_IDS: frozenset = TIER1_GESTALT_IDS | TIER2_GESTALT_IDS |
|
|
| _TIER1_GESTALT_ARR = np.array(sorted(TIER1_GESTALT_IDS), dtype=np.int64) |
| _TIER2_GESTALT_ARR = np.array(sorted(TIER2_GESTALT_IDS), dtype=np.int64) |
| _PRIORITY_GESTALT_ARR = np.array(sorted(PRIORITY_GESTALT_IDS), dtype=np.int64) |
|
|
| ADE_HOUSE_FOREGROUND_NAMES = ( |
| "wall", |
| "building;edifice", |
| "windowpane;window", |
| "door;double;door", |
| "house", |
| "column;pillar", |
| "skyscraper", |
| "stairs;steps", |
| ) |
| ADE_HOUSE_FOREGROUND_IDS: frozenset = _ids_from_names( |
| ADE_HOUSE_FOREGROUND_NAMES, ADE20K_CLASSES, start_id=1, |
| ) |
| _ADE_HOUSE_FOREGROUND_ARR = np.array(sorted(ADE_HOUSE_FOREGROUND_IDS), dtype=np.int64) |
|
|
|
|
| def _ade_house_foreground_mask(ade_ids: np.ndarray) -> np.ndarray: |
| return np.isin(ade_ids, _ADE_HOUSE_FOREGROUND_ARR) |
|
|
|
|
| |
| |
| |
|
|
| def _image_to_rgb_array(img_like) -> np.ndarray: |
| if isinstance(img_like, np.ndarray): |
| arr = img_like |
| elif isinstance(img_like, dict) and img_like.get("bytes") is not None: |
| arr = np.array(Image.open(io.BytesIO(img_like["bytes"])).convert("RGB")) |
| elif hasattr(img_like, "convert"): |
| arr = np.array(img_like.convert("RGB")) |
| else: |
| arr = np.array(img_like) |
|
|
| if arr.ndim == 2: |
| arr = np.repeat(arr[..., None], 3, axis=-1) |
| if arr.shape[-1] > 3: |
| arr = arr[..., :3] |
| return arr.astype(np.uint8, copy=False) |
|
|
|
|
| def _image_to_pil(img_like): |
| """Decode image-like dataset values while preserving depth image modes.""" |
| if isinstance(img_like, Image.Image): |
| return img_like |
| if isinstance(img_like, dict) and img_like.get("bytes") is not None: |
| img = Image.open(io.BytesIO(img_like["bytes"])) |
| img.load() |
| return img |
| if isinstance(img_like, np.ndarray): |
| return Image.fromarray(img_like) |
| return img_like |
|
|
|
|
| def _camera_for_image(colmap_rec, col_img): |
| try: |
| return col_img.camera |
| except AttributeError: |
| return colmap_rec.cameras[col_img.camera_id] |
|
|
|
|
| def _observed_point_mask(col_img, point_ids: np.ndarray) -> np.ndarray: |
| ids_attr = getattr(col_img, "point3D_ids", None) |
| if ids_attr is not None: |
| ids = ids_attr() if callable(ids_attr) else ids_attr |
| ids = np.asarray(ids, dtype=np.int64) |
| ids = ids[ids >= 0] |
| if ids.size > 0: |
| return np.isin(point_ids, ids) |
|
|
| if hasattr(col_img, "has_point3D"): |
| observed = np.fromiter( |
| (bool(col_img.has_point3D(int(pid))) for pid in point_ids), |
| dtype=bool, |
| count=len(point_ids), |
| ) |
| if observed.any(): |
| return observed |
|
|
| return np.ones(len(point_ids), dtype=bool) |
|
|
|
|
| |
| |
| |
|
|
| def colmap_points_xyz(colmap_rec) -> np.ndarray: |
| """Extract (N, 3) float32 world-space COLMAP points (xyz only).""" |
| if not colmap_rec.points3D: |
| return np.zeros((0, 3), dtype=np.float32) |
| return np.array([p.xyz for p in colmap_rec.points3D.values()], dtype=np.float32) |
|
|
|
|
| def colmap_points_xyz_ids(colmap_rec): |
| if not colmap_rec.points3D: |
| return np.zeros(0, dtype=np.int64), np.zeros((0, 3), dtype=np.float32) |
| ids, xyz = [], [] |
| for pid, p in colmap_rec.points3D.items(): |
| ids.append(int(pid)) |
| xyz.append(p.xyz) |
| return np.asarray(ids, dtype=np.int64), np.asarray(xyz, dtype=np.float32) |
|
|
|
|
| def colmap_points_full(colmap_rec): |
| """Return (ids, xyz, track_len, reproj_err, rgb) for all COLMAP 3D points. |
| |
| `rgb` is uint8 (N, 3) sourced from each point's COLMAP `color` field. |
| """ |
| if not colmap_rec.points3D: |
| return ( |
| np.zeros(0, dtype=np.int64), |
| np.zeros((0, 3), dtype=np.float32), |
| np.zeros(0, dtype=np.float32), |
| np.zeros(0, dtype=np.float32), |
| np.zeros((0, 3), dtype=np.uint8), |
| ) |
| ids, xyz, tlen, err, rgb = [], [], [], [], [] |
| for pid, p in colmap_rec.points3D.items(): |
| ids.append(int(pid)) |
| xyz.append(p.xyz) |
| try: |
| tlen.append(int(p.track.length())) |
| except Exception: |
| try: |
| tlen.append(int(len(p.track.elements))) |
| except Exception: |
| tlen.append(2) |
| try: |
| err.append(float(p.error)) |
| except Exception: |
| err.append(1.0) |
| try: |
| rgb.append(np.asarray(p.color, dtype=np.uint8)) |
| except Exception: |
| rgb.append(np.zeros(3, dtype=np.uint8)) |
| return ( |
| np.asarray(ids, dtype=np.int64), |
| np.asarray(xyz, dtype=np.float32), |
| np.asarray(tlen, dtype=np.float32), |
| np.asarray(err, dtype=np.float32), |
| np.asarray(rgb, dtype=np.uint8), |
| ) |
|
|
|
|
| def voxel_downsample_indices( |
| xyz: np.ndarray, |
| voxel_size: float, |
| rng: np.random.Generator, |
| ) -> np.ndarray: |
| """Return indices into `xyz` keeping one random point per metric voxel. |
| |
| Voxel edges are aligned to a grid of side `voxel_size` in the same units |
| as `xyz` (raw COLMAP world space). When `voxel_size <= 0` the function |
| returns a permutation of all indices (no-op). |
| """ |
| n = len(xyz) |
| if n == 0 or voxel_size <= 0: |
| return np.arange(n, dtype=np.int64) |
|
|
| keys = np.floor(xyz / voxel_size).astype(np.int64) |
| |
| order = np.lexsort(keys.T[::-1]) |
| keys_sorted = keys[order] |
|
|
| diff = np.any(keys_sorted[1:] != keys_sorted[:-1], axis=1) |
| starts = np.concatenate([[0], np.where(diff)[0] + 1]) |
| ends = np.concatenate([starts[1:], [n]]) |
| sizes = ends - starts |
| offsets = rng.integers(0, sizes) |
| return order[starts + offsets].astype(np.int64, copy=False) |
|
|
|
|
| def nearest_colmap_rgb( |
| query_xyz: np.ndarray, |
| colmap_xyz: np.ndarray, |
| colmap_rgb: np.ndarray, |
| ) -> np.ndarray: |
| """For each query point, return the RGB of its nearest COLMAP neighbour. |
| |
| Falls back to all-zero RGB when COLMAP is empty. Uses scipy cKDTree when |
| available; otherwise a chunked brute-force search. |
| """ |
| n_q = len(query_xyz) |
| if n_q == 0: |
| return np.zeros((0, 3), dtype=np.uint8) |
| if len(colmap_xyz) == 0: |
| return np.zeros((n_q, 3), dtype=np.uint8) |
|
|
| try: |
| from scipy.spatial import cKDTree |
| tree = cKDTree(colmap_xyz.astype(np.float32, copy=False)) |
| _, idx = tree.query(query_xyz.astype(np.float32, copy=False), k=1) |
| idx = np.asarray(idx, dtype=np.int64).reshape(-1) |
| except Exception: |
| |
| idx = np.zeros(n_q, dtype=np.int64) |
| c = colmap_xyz.astype(np.float32, copy=False) |
| chunk = 4096 |
| for s in range(0, n_q, chunk): |
| q = query_xyz[s : s + chunk].astype(np.float32, copy=False) |
| d2 = ((q[:, None, :] - c[None, :, :]) ** 2).sum(axis=-1) |
| idx[s : s + chunk] = d2.argmin(axis=1) |
| return colmap_rgb[idx] |
|
|
|
|
| def colmap_camera_centers(colmap_rec) -> np.ndarray: |
| centers = [] |
| for img in colmap_rec.images.values(): |
| R, t = _cam_matrix_from_image(img) |
| centers.append(R.T @ (-t)) |
| if not centers: |
| return np.zeros((0, 3), dtype=np.float32) |
| return np.array(centers, dtype=np.float32) |
|
|
|
|
| |
| |
| |
|
|
| def _robust_norm_params(colmap_xyz: np.ndarray, cam_centers: np.ndarray): |
| """Return (center, scale) for the scene. Rotation is identity by design.""" |
| pts = [] |
| if colmap_xyz.size: |
| pts.append(colmap_xyz) |
| if cam_centers.size: |
| pts.append(cam_centers) |
| pts = np.concatenate(pts, axis=0).astype(np.float64) |
|
|
| center = np.median(pts, axis=0).astype(np.float32) |
| d = np.linalg.norm(pts - center, axis=1) |
| scale = float(max(np.percentile(d, 95.0), 1e-3)) |
| return center, scale |
|
|
|
|
| def _normalise(pts: np.ndarray, center: np.ndarray, scale: float) -> np.ndarray: |
| return ((pts - center) / scale).astype(np.float32) |
|
|
|
|
| |
| |
| |
|
|
| def _project_to_pixel(pts: np.ndarray, col_img, cam): |
| """Project (N, 3) world points to pixel space of a single camera.""" |
| cam_w = getattr(cam, "width", 0) or 0 |
| cam_h = getattr(cam, "height", 0) or 0 |
| if cam_w <= 0 or cam_h <= 0: |
| return None |
|
|
| R, t = _cam_matrix_from_image(col_img) |
| p_cam = pts.astype(np.float64) @ R.T + t[None] |
| z = p_cam[:, 2] |
| in_front = z > 1e-8 |
| if not in_front.any(): |
| return None |
|
|
| K = cam.calibration_matrix() |
| u_proj = p_cam[:, 0] / np.maximum(z, 1e-8) * K[0, 0] + K[0, 2] |
| v_proj = p_cam[:, 1] / np.maximum(z, 1e-8) * K[1, 1] + K[1, 2] |
| return u_proj, v_proj, in_front, float(cam_w), float(cam_h) |
|
|
|
|
| def _gather_3x3_votes( |
| img_np: np.ndarray, |
| vi: np.ndarray, |
| ui: np.ndarray, |
| packed_sorted: np.ndarray, |
| ids_sorted: np.ndarray, |
| n_classes: int, |
| default_id: int, |
| ) -> np.ndarray: |
| """Sample 3x3 neighbourhoods and return per-point class vote counts.""" |
| H, W = img_np.shape[:2] |
| N = len(vi) |
| counts = np.zeros((N, n_classes), dtype=np.uint16) |
| for dv in (-1, 0, 1): |
| for du in (-1, 0, 1): |
| v2 = vi + dv |
| u2 = ui + du |
| ok = (v2 >= 0) & (v2 < H) & (u2 >= 0) & (u2 < W) |
| if not ok.any(): |
| continue |
| idx = np.where(ok)[0] |
| cls = _sample_palette( |
| img_np, |
| v2[ok], |
| u2[ok], |
| packed_sorted, |
| ids_sorted, |
| default=default_id, |
| ) |
| np.add.at(counts, (idx, cls), 1) |
| return counts |
|
|
|
|
| def _resolve_colmap_image(colmap_by_name: dict, img_id: str): |
| """Return the COLMAP image whose name matches img_id.""" |
| exact = colmap_by_name.get(img_id) |
| if exact is not None: |
| return exact |
| matches = [v for k, v in colmap_by_name.items() if img_id in k] |
| if len(matches) == 1: |
| return matches[0] |
| return None |
|
|
|
|
| def project_semantics_to_colmap_points( |
| sample: dict, |
| colmap_rec, |
| point_ids: np.ndarray, |
| point_xyz: np.ndarray, |
| ) -> dict: |
| """Vote projected Gestalt/ADE labels onto selected COLMAP 3D points (3x3).""" |
| n_points = len(point_xyz) |
| n_g = len(GESTALT_CLASSES) |
| n_a = len(ADE20K_CLASSES) + 1 |
| gestalt_counts = np.zeros((n_points, n_g), dtype=np.uint32) |
| ade_counts = np.zeros((n_points, n_a), dtype=np.uint32) |
|
|
| image_ids = sample.get("image_ids", []) |
| gestalt_imgs = sample.get("gestalt", []) |
| ade_imgs = sample.get("ade", []) |
|
|
| colmap_by_name = {col_img.name: col_img for col_img in colmap_rec.images.values()} |
|
|
| for i, img_id in enumerate(image_ids): |
| col_img = _resolve_colmap_image(colmap_by_name, img_id) |
| if col_img is None: |
| continue |
|
|
| cam = _camera_for_image(colmap_rec, col_img) |
|
|
| observed_mask = _observed_point_mask(col_img, point_ids) |
| obs_idx = np.nonzero(observed_mask)[0] |
| if obs_idx.size == 0: |
| continue |
|
|
| proj = _project_to_pixel(point_xyz[obs_idx], col_img, cam) |
| if proj is None: |
| continue |
| u_proj, v_proj, in_front, cam_w, cam_h = proj |
|
|
| def _valid_pixels(H: int, W: int): |
| u = np.rint(u_proj * (W / cam_w)).astype(np.int64) |
| v = np.rint(v_proj * (H / cam_h)).astype(np.int64) |
| ok = in_front & (u >= 0) & (u < W) & (v >= 0) & (v < H) |
| return u, v, ok |
|
|
| if i < len(gestalt_imgs) and gestalt_imgs[i] is not None: |
| gest_np = _image_to_rgb_array(gestalt_imgs[i]) |
| ui, vi, ok = _valid_pixels(*gest_np.shape[:2]) |
| if ok.any(): |
| votes = _gather_3x3_votes( |
| gest_np, |
| vi[ok], |
| ui[ok], |
| _GESTALT_PACKED_S, |
| _GESTALT_IDS_S, |
| n_classes=n_g, |
| default_id=0, |
| ) |
| rows = obs_idx[ok] |
| gestalt_counts[rows] += votes.astype(np.uint32) |
|
|
| if i < len(ade_imgs) and ade_imgs[i] is not None: |
| ade_np = _image_to_rgb_array(ade_imgs[i]) |
| ui, vi, ok = _valid_pixels(*ade_np.shape[:2]) |
| if ok.any(): |
| votes = _gather_3x3_votes( |
| ade_np, |
| vi[ok], |
| ui[ok], |
| _ADE_PACKED_S, |
| _ADE_IDS_S, |
| n_classes=n_a, |
| default_id=0, |
| ) |
| rows = obs_idx[ok] |
| ade_counts[rows] += votes.astype(np.uint32) |
|
|
| gest_total = gestalt_counts.sum(axis=1) |
| has_vote = gest_total > 0 |
|
|
| top1 = np.argmax(gestalt_counts, axis=1) |
| counts2 = gestalt_counts.copy() |
| counts2[np.arange(n_points), top1] = 0 |
| top2 = np.argmax(counts2, axis=1) |
| has_second = counts2[np.arange(n_points), top2] > 0 |
|
|
| gest_id1 = np.where(has_vote, top1, -1).astype(np.int64) |
| gest_id2 = np.where(has_vote & has_second, top2, -1).astype(np.int64) |
|
|
| top1_count = gestalt_counts[np.arange(n_points), top1].astype(np.float32) |
| gest_w1 = np.where( |
| has_vote, |
| top1_count / np.maximum(gest_total.astype(np.float32), 1.0), |
| 1.0, |
| ).astype(np.float32) |
|
|
| ade_ids = ade_counts.argmax(axis=1).astype(np.int64) |
|
|
| return { |
| "gestalt_id1": gest_id1, |
| "gestalt_id2": gest_id2, |
| "gestalt_w1": gest_w1, |
| "ade_ids": ade_ids, |
| "gest_top1_count": top1_count, |
| } |
|
|
|
|
| |
| |
| |
|
|
| def fit_depth_label_cache( |
| sample: dict, |
| colmap_rec, |
| ) -> list[dict]: |
| """Per-image preprocessing cache shared by stage-1 and stage-2 builders. |
| |
| For each image with valid depth fitting, returns the fitted depth map plus |
| the gestalt + ADE label images resized to the depth resolution and the |
| K/R/t needed to unproject pixels into world space. |
| |
| Stage 1's ``unproject_depth_tiered`` and stage 2's hull-cropped depth |
| extractor both consume this cache, avoiding a second pass through |
| ``get_fitted_dense_depth`` (Metric3Dv2) and a second PIL decode per image. |
| """ |
| K_mats = np.array(sample["K"]) |
| R_mats = np.array(sample["R"]) |
| t_vecs = np.array(sample["t"]) |
| if t_vecs.ndim == 3: |
| t_vecs = t_vecs[:, :, 0] |
| pose_only = list(sample.get("pose_only_in_colmap", [False] * len(sample["image_ids"]))) |
|
|
| out: list[dict] = [] |
| for i, (depth_img, gest_img, ade_img, img_id) in enumerate( |
| zip(sample["depth"], sample["gestalt"], sample["ade"], sample["image_ids"]) |
| ): |
| if pose_only[i] or depth_img is None: |
| continue |
| Ki = K_mats[i] |
| if Ki.shape != (3, 3) or Ki[0, 0] == 0: |
| continue |
| Ri = R_mats[i] |
| ti = t_vecs[i] |
| if not (np.isfinite(Ki).all() and np.isfinite(Ri).all() and np.isfinite(ti).all()): |
| continue |
| fx, fy = float(Ki[0, 0]), float(Ki[1, 1]) |
| if abs(fx) < 1e-6 or abs(fy) < 1e-6: |
| continue |
|
|
| depth_pil = _image_to_pil(depth_img) |
| ade_pil = _image_to_pil(ade_img) if ade_img is not None else None |
| gest_pil = _image_to_pil(gest_img) if gest_img is not None else None |
| try: |
| depth_fitted, _, found, _, _ = get_fitted_dense_depth( |
| depth_pil, colmap_rec, img_id, ade_pil, verbose=False, |
| ) |
| except Exception: |
| continue |
| if not found: |
| continue |
|
|
| H, W = depth_fitted.shape |
| gest_np = (_image_to_rgb_array(gest_pil.resize((W, H), Image.NEAREST)) |
| if gest_pil is not None else None) |
| ade_np = (_image_to_rgb_array(ade_pil.resize((W, H), Image.NEAREST)) |
| if ade_pil is not None else None) |
| out.append({ |
| "i": int(i), |
| "img_id": img_id, |
| "depth_fitted": depth_fitted, |
| "gest_np": gest_np, |
| "ade_np": ade_np, |
| "Ki": Ki.astype(np.float32, copy=False), |
| "Ri": Ri.astype(np.float32, copy=False), |
| "ti": ti.astype(np.float32, copy=False), |
| }) |
| return out |
|
|
|
|
| def unproject_depth_tiered( |
| sample: dict, |
| colmap_rec, |
| n_per_image: int = 4096, |
| rng: Optional[np.random.Generator] = None, |
| depth_cache: Optional[list[dict]] = None, |
| ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: |
| """Unproject Tier 1/2 Gestalt pixels. Returns (xyz, gestalt_ids, ade_ids, conf). |
| |
| Uses a single-pixel sample (no 3x3 voting): the pixel's own Gestalt/ADE IDs |
| become the point labels. Tier 2 pixels are accepted only where ADE maps to |
| the house/foreground subset, mirroring COLMAP token selection. |
| |
| When ``depth_cache`` (built by :func:`fit_depth_label_cache`) is supplied, |
| the per-image PIL decode + Metric3Dv2 fitting is skipped — entries are |
| consumed directly. Otherwise we build the cache implicitly. |
| """ |
| if rng is None: |
| rng = np.random.default_rng() |
|
|
| if depth_cache is None: |
| depth_cache = fit_depth_label_cache(sample, colmap_rec) |
|
|
| all_xyz, all_gids, all_ade, all_conf = [], [], [], [] |
|
|
| for entry in depth_cache: |
| if entry.get("gest_np") is None or entry.get("ade_np") is None: |
| |
| continue |
| depth_fitted = entry["depth_fitted"] |
| gest_np = entry["gest_np"] |
| ade_np = entry["ade_np"] |
| Ki = entry["Ki"] |
| Ri = entry["Ri"] |
| ti = entry["ti"] |
|
|
| H, W = depth_fitted.shape |
| gid_map = gestalt_img_to_ids(gest_np) |
| ade_id_map = ade20k_img_to_ids(ade_np) |
|
|
| valid_depth = depth_fitted > 0.1 |
| tier1_mask = np.isin(gid_map, _TIER1_GESTALT_ARR) & valid_depth |
| tier2_mask = ( |
| np.isin(gid_map, _TIER2_GESTALT_ARR) |
| & valid_depth |
| & _ade_house_foreground_mask(ade_id_map) |
| ) |
|
|
| def _sample_flat(mask: np.ndarray, budget: int) -> np.ndarray: |
| if budget <= 0: |
| return np.zeros(0, dtype=np.int64) |
| flat = np.flatnonzero(mask.ravel()) |
| if flat.size > budget: |
| flat = rng.choice(flat, budget, replace=False) |
| return flat.astype(np.int64, copy=False) |
|
|
| tier1_flat = _sample_flat(tier1_mask, n_per_image) |
| tier2_flat = _sample_flat(tier2_mask, n_per_image - len(tier1_flat)) |
| if tier1_flat.size == 0 and tier2_flat.size == 0: |
| continue |
|
|
| sel = np.concatenate([tier1_flat, tier2_flat], axis=0) |
| ys = (sel // W).astype(np.int64) |
| xs = (sel % W).astype(np.int64) |
| depths = depth_fitted[ys, xs] |
|
|
| fx, fy = Ki[0, 0], Ki[1, 1] |
| cx, cy = Ki[0, 2], Ki[1, 2] |
| x_cam = ((xs - cx) / fx) * depths |
| y_cam = ((ys - cy) / fy) * depths |
| z_cam = depths |
| pts_cam = np.stack([x_cam, y_cam, z_cam], axis=1) |
| pts_world = (pts_cam - ti[None]) @ Ri |
|
|
| gids = gid_map[ys, xs].astype(np.int64) |
| ade_ids_arr = ade_id_map[ys, xs].astype(np.int64) |
|
|
| conf = np.clip(2.0 / np.maximum(depths, 0.5), 0.0, 1.0).astype(np.float32) |
|
|
| all_xyz.append(pts_world.astype(np.float32)) |
| all_gids.append(gids) |
| all_ade.append(ade_ids_arr.astype(np.int64)) |
| all_conf.append(conf) |
|
|
| if not all_xyz: |
| return ( |
| np.zeros((0, 3), dtype=np.float32), |
| np.zeros(0, dtype=np.int64), |
| np.zeros(0, dtype=np.int64), |
| np.zeros(0, dtype=np.float32), |
| ) |
|
|
| return ( |
| np.concatenate(all_xyz, axis=0), |
| np.concatenate(all_gids, axis=0), |
| np.concatenate(all_ade, axis=0), |
| np.concatenate(all_conf, axis=0), |
| ) |
|
|
|
|
| def unproject_depth_priority( |
| sample: dict, |
| colmap_rec, |
| n_per_image: int = 4096, |
| rng: Optional[np.random.Generator] = None, |
| ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: |
| """Backward-compatible alias for the tiered depth sampler.""" |
| return unproject_depth_tiered(sample, colmap_rec, n_per_image=n_per_image, rng=rng) |
|
|
|
|
| |
| |
| |
|
|
| def _colmap_geom_conf(track_len: np.ndarray, reproj_err: np.ndarray) -> np.ndarray: |
| """Geometric confidence of a COLMAP point in [0, 1].""" |
| tl = np.clip(track_len / 5.0, 0.0, 1.0) |
| er = np.exp(-np.maximum(reproj_err, 0.0) / 2.0) |
| return (tl * er).astype(np.float32) |
|
|
|
|
| def build_scene_input( |
| sample: dict, |
| n_pts: int = 8192, |
| use_depth: bool = True, |
| n_depth_per_image: int = 4096, |
| voxel_size_m: float = 0.0, |
| rng: Optional[np.random.Generator] = None, |
| colmap_sem: Optional[dict] = None, |
| depth_cache: Optional[list[dict]] = None, |
| return_cache: bool = False, |
| ) -> dict: |
| """Build a fixed-size scene feature point cloud from one dataset sample. |
| |
| `n_pts` is split in half by provenance (default 8192 = 4096 + 4096): |
| the first half is COLMAP/camera tokens, the second half is depth-unprojected |
| tokens. If a scene cannot supply enough points for either half, that half is |
| padded by resampling from the points already selected for the same half. |
| |
| `voxel_size_m` (metres in raw world space) thins the candidate pool before |
| the budget cap: COLMAP, depth and camera candidates are jointly voxelised |
| and at most one random point per voxel survives. Set to 0 to disable. |
| |
| Tier order: |
| COLMAP half: |
| T0 camera centres (type=2) |
| T1 apex/eave_end/flashing_end/eave/ridge/rake/valley |
| T2 ground_line/flashing/step_flashing/hip/fascia/transition_line |
| only when ADE is house/foreground |
| Depth half: |
| T1 same structural Gestalt classes (type=1) |
| T2 same weak Gestalt classes with house/foreground ADE |
| """ |
| if rng is None: |
| rng = np.random.default_rng() |
|
|
| colmap_rec = sample["colmap"] |
|
|
| colmap_pids, colmap_xyz, colmap_tlen, colmap_err, colmap_rgb = colmap_points_full(colmap_rec) |
| cam_centers = colmap_camera_centers(colmap_rec) |
|
|
| if len(colmap_xyz) == 0: |
| raise ValueError("scene has no COLMAP 3D points — cannot build scene input") |
|
|
| bbox_center, bbox_scale = _robust_norm_params(colmap_xyz, cam_centers) |
| bbox_R = np.eye(3, dtype=np.float32) |
|
|
| def norm(pts: np.ndarray) -> np.ndarray: |
| return _normalise(pts, bbox_center, bbox_scale) |
|
|
| |
| |
| |
| if colmap_sem is None: |
| sem = project_semantics_to_colmap_points(sample, colmap_rec, colmap_pids, colmap_xyz) |
| else: |
| sem = colmap_sem |
| all_g1 = sem["gestalt_id1"] |
| all_g2 = sem["gestalt_id2"] |
| all_w1 = sem["gestalt_w1"] |
| all_ade = sem["ade_ids"] |
| top1_count = sem["gest_top1_count"] |
|
|
| geom_conf_all = _colmap_geom_conf(colmap_tlen, colmap_err) |
| sem_conf_all = np.clip(top1_count / 5.0, 0.0, 1.0).astype(np.float32) |
|
|
| colmap_budget = n_pts // 2 |
| depth_budget = n_pts - colmap_budget |
|
|
| tier1_mask = np.isin(all_g1, _TIER1_GESTALT_ARR) |
| tier2_mask = np.isin(all_g1, _TIER2_GESTALT_ARR) & _ade_house_foreground_mask(all_ade) |
|
|
| |
| if use_depth: |
| |
| |
| |
| |
| if depth_cache is None: |
| depth_cache = fit_depth_label_cache(sample, colmap_rec) |
| d_xyz, d_gids, d_ade, d_conf = unproject_depth_tiered( |
| sample, colmap_rec, n_per_image=n_depth_per_image, rng=rng, |
| depth_cache=depth_cache, |
| ) |
| else: |
| d_xyz = np.zeros((0, 3), dtype=np.float32) |
| d_gids = np.zeros(0, dtype=np.int64) |
| d_ade = np.zeros(0, dtype=np.int64) |
| d_conf = np.zeros(0, dtype=np.float32) |
|
|
| |
| |
| |
| |
| _colmap_kd = None |
| if len(d_xyz) > 0 and len(colmap_xyz) > 0: |
| try: |
| from scipy.spatial import cKDTree |
| _colmap_kd = cKDTree(colmap_xyz.astype(np.float32, copy=False)) |
| except Exception: |
| _colmap_kd = None |
|
|
| def _depth_rgb_for(sel: np.ndarray) -> np.ndarray: |
| if len(sel) == 0: |
| return np.zeros((0, 3), dtype=np.uint8) |
| if _colmap_kd is None or len(colmap_rgb) == 0: |
| return np.zeros((len(sel), 3), dtype=np.uint8) |
| _, idx = _colmap_kd.query(d_xyz[sel].astype(np.float32, copy=False), k=1) |
| return colmap_rgb[np.asarray(idx, dtype=np.int64).reshape(-1)] |
|
|
| cam_rgb = np.zeros((len(cam_centers), 3), dtype=np.uint8) |
|
|
| |
| |
| |
| |
| if voxel_size_m and voxel_size_m > 0.0 and (len(colmap_xyz) + len(d_xyz) + len(cam_centers)) > 0: |
| union_xyz = np.concatenate( |
| [colmap_xyz, d_xyz, cam_centers.astype(np.float32, copy=False)], axis=0 |
| ) |
| keep_global = voxel_downsample_indices(union_xyz, float(voxel_size_m), rng) |
| kept_flags = np.zeros(len(union_xyz), dtype=bool) |
| kept_flags[keep_global] = True |
| n_c = len(colmap_xyz) |
| n_d = len(d_xyz) |
| colmap_voxel_keep = kept_flags[:n_c] |
| depth_voxel_keep = kept_flags[n_c : n_c + n_d] |
| cam_voxel_keep = kept_flags[n_c + n_d :] |
| else: |
| colmap_voxel_keep = np.ones(len(colmap_xyz), dtype=bool) |
| depth_voxel_keep = np.ones(len(d_xyz), dtype=bool) |
| cam_voxel_keep = np.ones(len(cam_centers), dtype=bool) |
|
|
| def _pick(mask: np.ndarray, budget: int) -> np.ndarray: |
| if budget <= 0: |
| return np.zeros(0, dtype=np.int64) |
| idx = np.flatnonzero(mask) |
| if len(idx) > budget: |
| idx = rng.choice(idx, budget, replace=False) |
| return idx.astype(np.int64, copy=False) |
|
|
| def _empty_arrays(n: int = 0) -> tuple: |
| return ( |
| np.zeros((n, 3), dtype=np.float32), |
| np.zeros(n, dtype=np.int64), |
| np.full(n, -1, dtype=np.int64), |
| np.full(n, -1, dtype=np.int64), |
| np.ones(n, dtype=np.float32), |
| np.zeros(n, dtype=np.int64), |
| np.zeros(n, dtype=np.float32), |
| np.zeros(n, dtype=np.float32), |
| np.zeros((n, 3), dtype=np.uint8), |
| ) |
|
|
| _N_FIELDS = 9 |
|
|
| def _concat(parts: list[tuple]) -> tuple: |
| if not parts: |
| return _empty_arrays(0) |
| return tuple(np.concatenate([p[i] for p in parts], axis=0) for i in range(_N_FIELDS)) |
|
|
| def _take(arrs: tuple, idx: np.ndarray) -> tuple: |
| return tuple(a[idx] for a in arrs) |
|
|
| def _pad_or_trim(arrs: tuple, budget: int, fallback: Optional[tuple] = None) -> tuple: |
| if budget <= 0: |
| return _empty_arrays(0) |
| n = len(arrs[0]) |
| if n == 0 and fallback is not None and len(fallback[0]) > 0: |
| n_fallback = len(fallback[0]) |
| idx = rng.choice(n_fallback, budget, replace=n_fallback < budget) |
| return _take(fallback, idx) |
| if n == 0: |
| return _empty_arrays(budget) |
| if n > budget: |
| idx = rng.choice(n, budget, replace=False) |
| return _take(arrs, idx) |
| if n < budget: |
| pad = rng.choice(n, budget - n, replace=True) |
| return tuple(np.concatenate([a, a[pad]], axis=0) for a in arrs) |
| return arrs |
|
|
| def _camera_arrays(sel: np.ndarray) -> tuple: |
| n = len(sel) |
| return ( |
| norm(cam_centers[sel]), |
| np.full(n, 2, dtype=np.int64), |
| np.full(n, -1, dtype=np.int64), |
| np.full(n, -1, dtype=np.int64), |
| np.ones(n, dtype=np.float32), |
| np.zeros(n, dtype=np.int64), |
| np.ones(n, dtype=np.float32), |
| np.ones(n, dtype=np.float32), |
| cam_rgb[sel], |
| ) |
|
|
| def _colmap_arrays(sel: np.ndarray) -> tuple: |
| n = len(sel) |
| if n == 0: |
| return _empty_arrays(0) |
| return ( |
| norm(colmap_xyz[sel]), |
| np.zeros(n, dtype=np.int64), |
| all_g1[sel], |
| all_g2[sel], |
| all_w1[sel], |
| all_ade[sel], |
| geom_conf_all[sel], |
| sem_conf_all[sel], |
| colmap_rgb[sel], |
| ) |
|
|
| def _depth_arrays(sel: np.ndarray) -> tuple: |
| n = len(sel) |
| if n == 0: |
| return _empty_arrays(0) |
| return ( |
| norm(d_xyz[sel]), |
| np.full(n, 1, dtype=np.int64), |
| d_gids[sel], |
| np.full(n, -1, dtype=np.int64), |
| np.ones(n, dtype=np.float32), |
| d_ade[sel], |
| d_conf[sel], |
| np.ones(n, dtype=np.float32), |
| _depth_rgb_for(sel), |
| ) |
|
|
| |
| col_parts: list[tuple] = [] |
| remaining_col = colmap_budget |
| picked_colmap = np.zeros(len(colmap_xyz), dtype=bool) |
| cam_eligible = np.flatnonzero(cam_voxel_keep) |
| if remaining_col > 0 and len(cam_eligible) > 0: |
| n_cam = min(len(cam_eligible), remaining_col) |
| sel = ( |
| rng.choice(cam_eligible, n_cam, replace=False) |
| if len(cam_eligible) > n_cam else cam_eligible |
| ) |
| col_parts.append(_camera_arrays(sel.astype(np.int64, copy=False))) |
| remaining_col -= n_cam |
|
|
| if remaining_col > 0: |
| sel = _pick(tier1_mask & colmap_voxel_keep & ~picked_colmap, remaining_col) |
| col_parts.append(_colmap_arrays(sel)) |
| picked_colmap[sel] = True |
| remaining_col -= len(sel) |
|
|
| if remaining_col > 0: |
| sel = _pick(tier2_mask & colmap_voxel_keep & ~picked_colmap, remaining_col) |
| col_parts.append(_colmap_arrays(sel)) |
| picked_colmap[sel] = True |
| remaining_col -= len(sel) |
|
|
| |
| if remaining_col > 0: |
| sel = _pick(colmap_voxel_keep & ~picked_colmap, remaining_col) |
| col_parts.append(_colmap_arrays(sel)) |
| picked_colmap[sel] = True |
| remaining_col -= len(sel) |
|
|
| col_arrays = _pad_or_trim(_concat(col_parts), colmap_budget) |
|
|
| |
| depth_parts: list[tuple] = [] |
| if depth_budget > 0 and use_depth and len(d_xyz) > 0: |
| d_tier1 = np.isin(d_gids, _TIER1_GESTALT_ARR) & depth_voxel_keep |
| d_tier2 = ( |
| np.isin(d_gids, _TIER2_GESTALT_ARR) |
| & _ade_house_foreground_mask(d_ade) |
| & depth_voxel_keep |
| ) |
|
|
| remaining_depth = depth_budget |
| sel = _pick(d_tier1, remaining_depth) |
| depth_parts.append(_depth_arrays(sel)) |
| remaining_depth -= len(sel) |
|
|
| if remaining_depth > 0: |
| sel = _pick(d_tier2, remaining_depth) |
| depth_parts.append(_depth_arrays(sel)) |
|
|
| depth_arrays = _pad_or_trim(_concat(depth_parts), depth_budget, fallback=col_arrays) |
|
|
| all_xyz = np.concatenate([col_arrays[0], depth_arrays[0]], axis=0) |
| all_type = np.concatenate([col_arrays[1], depth_arrays[1]], axis=0) |
| all_g1 = np.concatenate([col_arrays[2], depth_arrays[2]], axis=0) |
| all_g2 = np.concatenate([col_arrays[3], depth_arrays[3]], axis=0) |
| all_w1 = np.concatenate([col_arrays[4], depth_arrays[4]], axis=0) |
| all_ade = np.concatenate([col_arrays[5], depth_arrays[5]], axis=0) |
| all_geom_conf = np.concatenate([col_arrays[6], depth_arrays[6]], axis=0) |
| all_sem_conf = np.concatenate([col_arrays[7], depth_arrays[7]], axis=0) |
| all_rgb = np.concatenate([col_arrays[8], depth_arrays[8]], axis=0) |
|
|
| out = { |
| "scene_xyz": all_xyz, |
| "scene_type_ids": all_type, |
| "scene_gestalt_ids": all_g1, |
| "scene_gestalt_id2": all_g2, |
| "scene_gestalt_w1": all_w1, |
| "scene_ade_ids": all_ade, |
| "scene_geom_conf": all_geom_conf.astype(np.float32), |
| "scene_sem_conf": all_sem_conf.astype(np.float32), |
| "scene_rgb": all_rgb.astype(np.uint8), |
| "bbox_center": bbox_center.astype(np.float32), |
| "bbox_scale": float(bbox_scale), |
| "bbox_R": bbox_R, |
| } |
| if return_cache: |
| out["_cache"] = { |
| "colmap": { |
| "pids": colmap_pids, |
| "xyz": colmap_xyz, |
| "rgb": colmap_rgb, |
| "track_len": colmap_tlen, |
| "reproj_err": colmap_err, |
| "sem": sem, |
| }, |
| |
| |
| "depth": depth_cache, |
| } |
| return out |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
|
|
| def _polygon_xy_area(verts_xy: np.ndarray) -> float: |
| """Shoelace area of the convex polygon formed by 1..4 XY points. |
| |
| Vertices are first sorted by polar angle around their centroid so the |
| polygon is non-self-intersecting. For < 3 points the area is zero. |
| """ |
| n = len(verts_xy) |
| if n < 3: |
| return 0.0 |
| xy = verts_xy.astype(np.float64, copy=False) |
| c = xy.mean(axis=0) |
| ang = np.arctan2(xy[:, 1] - c[1], xy[:, 0] - c[0]) |
| order = np.argsort(ang) |
| p = xy[order] |
| x, y = p[:, 0], p[:, 1] |
| return 0.5 * float(abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))) |
|
|
|
|
| def filter_small_components( |
| wf_vertices: np.ndarray, |
| wf_edges: np.ndarray, |
| max_verts_per_cc: int = 4, |
| max_area_m2: float = 5.0, |
| ) -> Tuple[np.ndarray, np.ndarray]: |
| """Mark vertices/edges belonging to small connected components for removal. |
| |
| Operates on RAW (un-normalised, metric) coordinates. A connected component |
| is removed iff it has at most ``max_verts_per_cc`` vertices AND its XY |
| footprint area is strictly less than ``max_area_m2`` square metres. |
| |
| Args: |
| wf_vertices: (N, 3) world-space GT vertices. |
| wf_edges: (M, 2) edges as vertex-index pairs. |
| |
| Returns: |
| (keep_v, keep_e): boolean masks of length N and M respectively. True |
| means "retain" (not a chimney). |
| """ |
| n = int(len(wf_vertices)) |
| m = int(len(wf_edges)) |
| if n == 0: |
| return np.ones(0, dtype=bool), np.ones(m, dtype=bool) |
|
|
| parent = np.arange(n, dtype=np.int64) |
|
|
| def find(i: int) -> int: |
| while parent[i] != i: |
| parent[i] = parent[parent[i]] |
| i = parent[i] |
| return i |
|
|
| if m > 0: |
| edges_int = np.asarray(wf_edges, dtype=np.int64) |
| valid_e = ( |
| (edges_int[:, 0] >= 0) |
| & (edges_int[:, 1] >= 0) |
| & (edges_int[:, 0] < n) |
| & (edges_int[:, 1] < n) |
| ) |
| for a, b in edges_int[valid_e]: |
| ra, rb = find(int(a)), find(int(b)) |
| if ra != rb: |
| parent[ra] = rb |
| else: |
| edges_int = np.zeros((0, 2), dtype=np.int64) |
| valid_e = np.zeros(0, dtype=bool) |
|
|
| roots = np.array([find(i) for i in range(n)], dtype=np.int64) |
|
|
| keep_v = np.ones(n, dtype=bool) |
| pts = np.asarray(wf_vertices, dtype=np.float32) |
| for root in np.unique(roots): |
| idx = np.where(roots == root)[0] |
| if len(idx) <= max_verts_per_cc: |
| area = _polygon_xy_area(pts[idx, :2]) |
| if area < max_area_m2: |
| keep_v[idx] = False |
|
|
| if m == 0: |
| return keep_v, np.ones(0, dtype=bool) |
| a = edges_int[:, 0].clip(0, n - 1) |
| b = edges_int[:, 1].clip(0, n - 1) |
| keep_e = valid_e & keep_v[a] & keep_v[b] |
| return keep_v, keep_e |
|
|
|
|
| def build_gt_verts( |
| sample: dict, |
| bbox_center: np.ndarray, |
| bbox_scale: float, |
| k_verts: int = 64, |
| ) -> np.ndarray: |
| """Build padded GT vertex tensor of shape (K, 4) in normalised coords. |
| |
| Column 3 is the validity flag: +1 for real vertex, -1 for padding. |
| No yaw rotation is applied here — the dataloader applies a random rotation |
| to both `scene_xyz` and `verts_gt[:, :3]` per epoch. |
| """ |
| verts = np.array(sample["wf_vertices"], dtype=np.float32) |
| n_real = min(len(verts), k_verts) |
|
|
| assert n_real > 0, "scene has zero GT vertices" |
|
|
| out = np.zeros((k_verts, 4), dtype=np.float32) |
| verts_norm = (verts[:n_real] - bbox_center) / bbox_scale |
| out[:n_real, :3] = verts_norm.astype(np.float32) |
| out[:n_real, 3] = 1.0 |
| out[n_real:, 3] = -1.0 |
| return out |
|
|