File size: 12,640 Bytes
47e6e42 | 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 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 | """Point-map faithfulness metrics for 3D completion (buffer-F1 suite).
Measures how faithfully a generated shape follows the *observed* evidence
(a partial point cloud / point map), NOT overall completion quality.
Everything operates on a shared canonical voxel grid (default 64^3 over
[-0.5, 0.5]^3, the TRELLIS convention). No registration is performed:
conditioned methods are born aligned; align unconditioned baselines before
calling.
Core metrics
buffer-F1@r voxel F1 with tolerance radius r (r=0,1,2 ~ user's 1/3/5 cubes)
distances mean / P95 / max (visible Hausdorff) from EDT, both directions
FSV free-space violation: predicted voxels in ray-carved empty space
rim bands F1@1 split by distance to the visibility border (near/mid/deep)
Usage:
from metrics.faithfulness import evaluate_faithfulness, voxelize_points
rep = evaluate_faithfulness(gt_vis_occ, pred_occ, free_occ) # 64^3 bool grids
Self-test (no data needed): python metrics/faithfulness.py
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field, asdict
import numpy as np
from scipy import ndimage
# ---------------------------------------------------------------- grids
def canonicalize(points: np.ndarray, center: np.ndarray | None = None,
scale: float | None = None):
"""Map points (M,3) into [-0.5, 0.5]^3.
If center/scale are None they are derived from the points' bbox
("realistic" mode). Pass the GT mesh's bbox center/extent for
"oracle" mode so partial and GT share one frame.
Returns (normalized points, center, scale).
"""
pts = np.asarray(points, dtype=np.float64)
if center is None:
center = (pts.min(0) + pts.max(0)) / 2.0
if scale is None:
scale = (pts.max(0) - pts.min(0)).max()
return (pts - center) / scale, center, scale
def voxelize_points(points: np.ndarray, n: int = 64) -> np.ndarray:
"""Points in [-0.5, 0.5]^3 -> (n,n,n) bool occupancy.
Both ±0.5 boundaries are INCLUSIVE (a bbox-tight mesh has whole faces
exactly at ±0.5; flooring them out would silently delete them).
"""
pts = np.asarray(points, dtype=np.float64)
atol = 1e-6
inside = ((pts >= -0.5 - atol) & (pts <= 0.5 + atol)).all(axis=1)
pts = np.clip(pts[inside], -0.5, np.nextafter(0.5, 0.0))
idx = np.floor((pts + 0.5) * n).astype(np.int64)
idx = np.minimum(idx, n - 1)
occ = np.zeros((n, n, n), dtype=bool)
occ[idx[:, 0], idx[:, 1], idx[:, 2]] = True
return occ
def voxelize_mesh_surface(mesh, n: int = 64, samples: int = 500_000,
center=None, scale=None) -> np.ndarray:
"""Sample a trimesh surface and voxelize (surface shell, no solid fill —
matches TRELLIS's triangle-only voxelization)."""
pts = mesh.sample(samples)
pts, _, _ = canonicalize(pts, center, scale)
return voxelize_points(pts, n)
def carve_free_space(vis_occ: np.ndarray, cam_pos: np.ndarray,
margin: int = 2, step: float = 0.5) -> np.ndarray:
"""Mark voxels proven empty by the scan: cells traversed by camera rays
*before* reaching an observed surface voxel (with a safety margin).
vis_occ : (n,n,n) bool, observed surface voxels (canonical grid)
cam_pos : (3,) camera position in canonical coords
margin : stop this many voxels before the surface hit
Returns (n,n,n) bool free-space mask (never overlaps vis_occ).
"""
n = vis_occ.shape[0]
targets = np.argwhere(vis_occ) + 0.5 # voxel centers
cam = (np.asarray(cam_pos, np.float64) + 0.5) * n # to grid coords
d = targets - cam
dist = np.linalg.norm(d, axis=1)
free = np.zeros_like(vis_occ)
n_steps = int(np.ceil(dist.max() / step)) if len(dist) else 0
for i in range(n_steps):
t = i * step
live = dist - margin > t # not yet near hit
if not live.any():
break
p = cam + d[live] / dist[live, None] * t
ijk = np.floor(p).astype(np.int64)
ok = ((ijk >= 0) & (ijk < n)).all(axis=1)
ijk = ijk[ok]
free[ijk[:, 0], ijk[:, 1], ijk[:, 2]] = True
# grazing rays can carve a 1-voxel skin hugging the surface (and even
# just behind it); a plausible reconstruction sitting 1 voxel inside GT
# would then trip FSV. Keep free space Chebyshev->=1 away from surface.
free &= ~ndimage.maximum_filter(vis_occ, size=3)
return free
# ---------------------------------------------------------------- metrics
@dataclass
class FaithfulnessReport:
f1: dict = field(default_factory=dict) # r -> {precision, recall, f1}
dist_pred_to_gt: dict = field(default_factory=dict) # mean/p95/max (voxels)
dist_gt_to_pred: dict = field(default_factory=dict) # mean/p95/max
fsv_rate: float = float("nan") # fraction of pred in free space
fsv_max_depth: float = float("nan") # deepest violation (voxels)
rim_bands: dict = field(default_factory=dict) # band -> {recall@1, n_voxels}
n_gt_vis: int = 0
n_pred: int = 0
n_pred_observed: int = 0 # pred voxels inside observed region
def to_json(self) -> str:
return json.dumps(asdict(self), indent=2, default=float)
def _dist_stats(d: np.ndarray) -> dict:
if d.size == 0:
return {"mean": float("nan"), "p95": float("nan"), "max": float("nan")}
return {"mean": float(d.mean()), "p95": float(np.percentile(d, 95)),
"max": float(d.max())}
def evaluate_faithfulness(gt_vis: np.ndarray, pred: np.ndarray,
free: np.ndarray | None = None,
gt_full: np.ndarray | None = None,
radii=(0, 1, 2),
rim_band_edges=(2, 4)) -> FaithfulnessReport:
"""All faithfulness metrics between observed voxels and a prediction.
gt_vis : (n,n,n) bool — observed (visible) surface voxels, the reference
pred : (n,n,n) bool — generated occupancy in the SAME canonical grid
free : optional (n,n,n) bool — ray-carved proven-empty voxels.
Enables FSV and restricts precision to the observed region
(vis ∪ free); without it precision falls back to all pred voxels
(pessimistic: the invented back half counts against precision).
gt_full: optional (n,n,n) bool — FULL GT surface voxels. Enables rim
bands: the border is the seam where the visible patch meets the
hidden part of the surface (gt_full & ~gt_vis). Without it the
border falls back to adjacency-to-unknown, which is only sound
when `free` covers the whole camera frustum.
radii : tolerance radii in voxels (0,1,2 == 1^3, 3^3, 5^3 buffers)
rim_band_edges : distances (voxels) splitting near/mid/deep from the
visibility border.
"""
rep = FaithfulnessReport()
rep.n_gt_vis = int(gt_vis.sum())
rep.n_pred = int(pred.sum())
if rep.n_gt_vis == 0 or rep.n_pred == 0:
return rep
# EDT: distance of every voxel to nearest occupied voxel of the other set
d_to_gt = ndimage.distance_transform_edt(~gt_vis)
d_to_pred = ndimage.distance_transform_edt(~pred)
# precision only over pred voxels where the scan asserts anything
if free is not None:
observed = gt_vis | free
pred_obs = pred & observed
else:
pred_obs = pred
rep.n_pred_observed = int(pred_obs.sum())
d_of_preds = d_to_gt[pred_obs] # per observed-region pred voxel
d_of_gts = d_to_pred[gt_vis] # per GT visible voxel
rep.dist_pred_to_gt = _dist_stats(d_of_preds)
rep.dist_gt_to_pred = _dist_stats(d_of_gts) # max == visible Hausdorff
for r in radii:
# +1e-6: EDT of a diagonal neighbor is sqrt(2) etc.; buffer n^3 means
# Chebyshev, so use the box distance via maximum filter instead of EDT
# for exact n^3 semantics at small r.
if r == 0:
hit_p = gt_vis[pred_obs] if pred_obs.any() else np.array([])
prec = float(hit_p.mean()) if hit_p.size else 0.0
rec = float(pred[gt_vis].mean())
else:
gt_dil = ndimage.maximum_filter(gt_vis, size=2 * r + 1)
pr_dil = ndimage.maximum_filter(pred, size=2 * r + 1)
prec = float(gt_dil[pred_obs].mean()) if pred_obs.any() else 0.0
rec = float(pr_dil[gt_vis].mean())
f1 = 2 * prec * rec / (prec + rec) if prec + rec > 0 else 0.0
rep.f1[int(r)] = {"precision": prec, "recall": rec, "f1": f1}
# free-space violations
if free is not None:
viol = pred & free
denom = int((pred & (gt_vis | free)).sum())
rep.fsv_rate = float(viol.sum() / denom) if denom else 0.0
if viol.any():
# depth = how far inside proven-empty space, in voxels
d_out = ndimage.distance_transform_edt(free) # dist to non-free
rep.fsv_max_depth = float(d_out[viol].max())
else:
rep.fsv_max_depth = 0.0
# rim bands: border = the seam between the visible patch and the rest
# of the surface. Preferred: gt_full gives the hidden surface directly.
# Fallback (no gt_full): visible voxels adjacent to un-carved space —
# only meaningful if `free` covers the whole frustum.
rim = None
if gt_full is not None:
hidden = gt_full & ~gt_vis
rim = gt_vis & ndimage.maximum_filter(hidden, size=3)
elif free is not None:
unknown = ~(gt_vis | free)
rim = gt_vis & ndimage.maximum_filter(unknown, size=3)
if rim is not None and rim.any():
d_rim = ndimage.distance_transform_edt(~rim)
dvals = d_rim[gt_vis]
pr_dil1 = ndimage.maximum_filter(pred, size=3)
hit1 = pr_dil1[gt_vis]
lo, hi = rim_band_edges
for name, m in (("near", dvals <= lo),
("mid", (dvals > lo) & (dvals <= hi)),
("deep", dvals > hi)):
rep.rim_bands[name] = {
"recall@1": float(hit1[m].mean()) if m.any() else float("nan"),
"n_voxels": int(m.sum()),
}
return rep
# ---------------------------------------------------------------- self-test
def _sphere_shell(n=64, r=20, center=None, thickness=1.2):
g = np.stack(np.meshgrid(*[np.arange(n)] * 3, indexing="ij"), -1) + 0.5
c = np.array(center if center is not None else [n / 2] * 3)
d = np.linalg.norm(g - c, axis=-1)
return np.abs(d - r) < thickness
def _self_test():
n = 64
full = _sphere_shell(n)
# front half visible (camera at -x)
x = np.arange(n)[:, None, None] + 0.5
vis = full & np.broadcast_to(x < n / 2, full.shape)
cam = np.array([-1.5, 0.0, 0.0]) # canonical coords
free = carve_free_space(vis, cam)
print(f"vis={vis.sum()} free={free.sum()}")
assert not (free & vis).any()
# 1) perfect prediction -> F1@0 == 1 in visible region, FSV == 0
rep = evaluate_faithfulness(vis, full, free)
print("perfect: F1@0=%.3f F1@1=%.3f FSV=%.4f Hausdorff=%.1f" % (
rep.f1[0]["f1"], rep.f1[1]["f1"], rep.fsv_rate,
rep.dist_gt_to_pred["max"]))
assert rep.f1[0]["f1"] > 0.999 and rep.fsv_rate == 0.0
# 2) shifted prediction -> F1@0 drops, F1@2 recovers, monotone in r
shifted = np.roll(full, 2, axis=1)
rep2 = evaluate_faithfulness(vis, shifted, free)
print("shift+2: F1@0=%.3f F1@1=%.3f F1@2=%.3f" % tuple(
rep2.f1[r]["f1"] for r in (0, 1, 2)))
assert rep2.f1[0]["f1"] < rep2.f1[1]["f1"] <= rep2.f1[2]["f1"]
assert rep2.f1[0]["f1"] < rep.f1[0]["f1"]
# 3) bulge toward camera -> free-space violation fires
bulge = full.copy()
bulge[8:12, 28:36, 28:36] = True
rep3 = evaluate_faithfulness(vis, bulge, free)
print("bulge: FSV=%.4f max_depth=%.1f" % (rep3.fsv_rate, rep3.fsv_max_depth))
assert rep3.fsv_rate > 0
# 4) rim-broken prediction -> near band suffers, deep stays
rimless = full.copy()
rim_zone = np.abs(x - n / 2) < 4
rimless &= ~np.broadcast_to(rim_zone, full.shape)
rep4 = evaluate_faithfulness(vis, rimless, free, gt_full=full)
b = rep4.rim_bands
for name in ("near", "mid", "deep"):
assert b[name]["n_voxels"] > 0, f"empty {name} band"
print("rimcut: near=%.3f mid=%.3f deep=%.3f" % (
b["near"]["recall@1"], b["mid"]["recall@1"], b["deep"]["recall@1"]))
assert b["near"]["recall@1"] < b["deep"]["recall@1"]
print("ALL SELF-TESTS PASSED")
if __name__ == "__main__":
_self_test()
|