File size: 8,862 Bytes
e4e030b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | """Build GT voxel grids for one (object, cam, frame) selection.
Pipeline (all in the OBJECT's canonical frame — free alignment from GT pose):
depth px (visible-mask bit) --K,c2w--> world (m)
--T_world_obj^-1--> object/model frame
GLB verts --glb_to_model--> object/model frame
canonicalize with the GLB's model-frame bbox (oracle center/scale)
voxelize 64^3: gt_vis (depth points), gt_full (GLB surface samples)
free space: ray-carve from the camera position (same canonical coords)
Usage:
from metrics.gt_loader import load_gt
g = load_gt(clip, sel_entry) # sel_entry = one dict from selection.json
g.gt_vis, g.gt_full, g.free, g.center, g.scale, g.cam_pos_canon
Self-test against real data:
python metrics/gt_loader.py (uses selection.json from .debug/exp_faithfulness)
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
import imageio.v3 as iio
import numpy as np
import trimesh
from scipy import ndimage
from faithfulness import canonicalize, voxelize_points, evaluate_faithfulness
DEPTH_UNIT = 1e-3 # uint16 png is millimeters (verified vs GT poses)
def _load_json(p: Path):
return json.loads(Path(p).read_text())
def _camera(clip: Path, name: str) -> dict:
cams = _load_json(clip / "cameras.json")["cameras"]
return next(c for c in cams if c["name"] == name)
def _pose(clip: Path, frame: int, obj: str) -> np.ndarray:
poses = _load_json(clip / "poses.json")
fr = poses["frames"][frame]
assert fr["f"] == frame
T = next(p["T_world_obj"] for p in fr["poses"] if p["name"] == obj)
return np.array(T, np.float64), np.array(poses["glb_to_model"], np.float64)
def _glb_model_frame(glb_path: str, glb_to_model: np.ndarray) -> trimesh.Trimesh:
"""Load GLB, merge geometry, map into the model (object) frame."""
scene = trimesh.load(glb_path, force="scene", process=False)
mesh = scene.to_mesh() if hasattr(scene, "to_mesh") else scene
mesh = trimesh.Trimesh(vertices=np.asarray(mesh.vertices),
faces=np.asarray(mesh.faces), process=False)
mesh.apply_transform(glb_to_model)
return mesh
def carve_free_space_depth(depth: np.ndarray, K: dict, c2w: np.ndarray,
T_world_obj: np.ndarray, center: np.ndarray,
scale: float, gt_vis: np.ndarray, n: int,
margin_vox: float = 3.0,
min_filter_px: int = 9) -> np.ndarray:
"""Exact free-space carving from the full scene depth map.
A voxel is proven empty iff its center projects inside the image and its
camera-space depth is at least `margin_vox` voxels IN FRONT of the
observed scene depth at that pixel. Unlike per-ray marching this cannot
leak behind foreground silhouette edges (a hidden voxel there projects
onto the foreground pixel, whose depth is closer -> not free), and it
covers the whole frustum, not just corridors to observed voxels.
"""
ii = (np.arange(n) + 0.5) / n - 0.5
gx, gy, gz = np.meshgrid(ii, ii, ii, indexing="ij")
pc_model = np.stack([gx, gy, gz], -1).reshape(-1, 3) * scale + center
T = np.linalg.inv(c2w) @ T_world_obj # model -> camera
p_cam = pc_model @ T[:3, :3].T + T[:3, 3]
z = p_cam[:, 2]
h, w = depth.shape
with np.errstate(divide="ignore", invalid="ignore"):
u = np.round(p_cam[:, 0] / z * K["fx"] + K["cx"]).astype(np.int64)
v = np.round(p_cam[:, 1] / z * K["fy"] + K["cy"]).astype(np.int64)
ok = (z > 0) & (u >= 0) & (u < w) & (v >= 0) & (v < h)
# conservative depth: minimum filter, so voxels projecting right next
# to a silhouette edge (where the pixel sees the far background) are not
# carved — hidden surface continues just behind the contour. 9px + 3 vox
# margin leaves 3 / 738k false-free voxels across the 6 test objects.
depth_min = ndimage.minimum_filter(depth, size=min_filter_px)
dmap = np.zeros(len(z))
dmap[ok] = depth_min[v[ok], u[ok]] * DEPTH_UNIT
margin_m = margin_vox * scale / n
free = ok & (dmap > 0) & (z < dmap - margin_m)
free = free.reshape(n, n, n)
# never mark observed surface (or its 1-voxel shell) as free
free &= ~ndimage.maximum_filter(gt_vis, size=3)
return free
@dataclass
class GTGrids:
gt_vis: np.ndarray # (n,n,n) bool, from depth px
gt_full: np.ndarray # (n,n,n) bool, from GLB surface
free: np.ndarray # (n,n,n) bool, ray-carved
center: np.ndarray # canonicalization center (model frame, m)
scale: float # canonicalization scale (m)
cam_pos_canon: np.ndarray # camera position in canonical coords
points_canon: np.ndarray # depth points, canonical coords (for debug)
kept_frac: float # fraction of depth points inside box (+tol)
mesh_canon: trimesh.Trimesh # GLB mesh in canonical coords (for renders)
def load_gt(clip: Path, sel: dict, n: int = 64,
mesh_samples: int = 1_000_000, mask_erode: int = 2) -> GTGrids:
clip = Path(clip)
cam = _camera(clip, sel["cam"])
K = cam["intrinsics"]
c2w = np.array(cam["c2w"], np.float64)
T_world_obj, glb_to_model = _pose(clip, sel["frame"], sel["object"])
# depth px of the object -> world -> object/model frame
d = iio.imread(clip / "depth" / sel["cam"] / f"{sel['frame']:04d}.png")
m = iio.imread(clip / "mask" / sel["cam"] / f"{sel['frame']:04d}.png")
obj_px = ((m >> sel["bit"]) & 1).astype(bool) & (d > 0)
if mask_erode:
# silhouette-edge px mix object and background depth; drop them
from scipy import ndimage
obj_px &= ndimage.binary_erosion(obj_px, iterations=mask_erode)
ys, xs = np.nonzero(obj_px)
z = d[ys, xs].astype(np.float64) * DEPTH_UNIT
pc = np.stack([(xs - K["cx"]) / K["fx"] * z,
(ys - K["cy"]) / K["fy"] * z,
z, np.ones_like(z)], 1)
p_world = (c2w @ pc.T).T
T_obj_world = np.linalg.inv(T_world_obj)
p_model = (T_obj_world @ p_world.T).T[:, :3]
# GLB in model frame; oracle canonicalization from its bbox
mesh = _glb_model_frame(sel["glb"], glb_to_model)
_, center, scale = canonicalize(mesh.vertices)
# seeded so every run scores against the identical GT grid
surf, _ = trimesh.sample.sample_surface(mesh, mesh_samples, seed=0)
surf_c, _, _ = canonicalize(surf, center, scale)
pts_c, _, _ = canonicalize(p_model, center, scale)
# depth is quantized to 1 mm: points on a bbox face can fall epsilon
# outside [-0.5, 0.5]. Clamp near-misses onto the box, drop true outliers.
# 0.012 ~ 0.77 voxel at n=64: sub-voxel, so a clamped point stays in the
# correct boundary voxel. Measured worst real overshoot 0.0105 (glazed
# squirrel, z-only mm-scale render/depth bias); everything else < 0.005.
tol = 0.012
near = (np.abs(pts_c) <= 0.5 + tol).all(1)
pts_c = np.clip(pts_c[near], -0.5, 0.5 - 1e-9)
gt_full = voxelize_points(surf_c, n)
gt_vis = voxelize_points(pts_c, n)
assert gt_vis.sum() > 0, (
f"{sel['object']}: empty gt_vis (mask erosion wiped a tiny object?)")
cam_world = c2w[:3, 3]
cam_model = (T_obj_world @ np.append(cam_world, 1.0))[:3]
cam_canon = (cam_model - center) / scale
free = carve_free_space_depth(d, K, c2w, T_world_obj, center, scale,
gt_vis, n)
mesh_c = mesh.copy()
mesh_c.apply_translation(-center)
mesh_c.apply_scale(1.0 / scale)
return GTGrids(gt_vis, gt_full, free, center, float(scale),
cam_canon, pts_c, float(near.mean()), mesh_c)
# ---------------------------------------------------------------- self-test
def _self_test():
root = Path(__file__).resolve().parent.parent / ".debug" / "exp_faithfulness"
sel = _load_json(root / "selection.json")
clip = Path(sel["clip"])
ok = True
for s in sel["selections"]:
g = load_gt(clip, s)
nv, nf = int(g.gt_vis.sum()), int(g.gt_full.sum())
# depth points must lie ON the GLB surface: gt_vis vs gt_full recall@1
rep = evaluate_faithfulness(g.gt_full, g.gt_vis, radii=(0, 1, 2))
# fraction of gt_vis voxels within 1 voxel of gt_full
r1 = rep.f1[1]["precision"] # pred=gt_vis voxels near gt_full
inb = g.kept_frac
vis_frac = nv / nf
line = (f"{s['object']:32s} vis={nv:5d} full={nf:6d} "
f"vis/full={vis_frac:.2f} onSurf@1={r1:.3f} inbox={inb:.3f} "
f"free={int(g.free.sum())}")
good = r1 > 0.95 and inb > 0.99 and 0.05 < vis_frac < 1.0
print(("PASS " if good else "FAIL ") + line)
ok &= good
assert ok, "gt_loader self-test failed"
print("ALL GT-LOADER SELF-TESTS PASSED")
if __name__ == "__main__":
_self_test()
|