Ronaldo-GOAT's picture
FORGE3DBench: final eval protocol + eval_final.py, batched Ours inference, held-out view tars, missing-object lists, README
cca6827 verified
Raw History Blame Contribute Delete
5.13 kB
"""Geometry metrics vs a GT mesh (LOCKED spec).
Both meshes are already canonical in [-0.5,0.5]^3; NO ICP / NO alignment.
100k surface points sampled per mesh (same count both sides) via
pytorch3d.ops.sample_points_from_meshes. NN distances via knn_points.
Reported:
* Chamfer L1 (headline): mean over both directions of the sqrt Euclidean
nearest-neighbour distance. Units = fraction of the unit-cube edge.
* Chamfer L2 : pytorch3d.loss.chamfer_distance default (mean squared).
* F-score@{0.01,0.02,0.05}: P = frac pred within tau of GT,
R = frac GT within tau of pred, F = 2PR/(P+R). Headline F@0.02.
* Normal Consistency: mean abs cosine of normals at mutual NN (both dirs).
* Volume IoU@128^3: solid voxel occupancy on a shared 128^3 grid, I/U.
"""
from __future__ import annotations
# FINAL: = metrics/appeval/geometry.py + extent-relative CD/F keys, optional vol IoU. Original keys unchanged.
import numpy as np
import torch
import trimesh
from pytorch3d.structures import Meshes
from pytorch3d.ops import sample_points_from_meshes, knn_points
from pytorch3d.loss import chamfer_distance
N_SAMPLES = 100_000
TAUS = (0.01, 0.02, 0.05)
VOX_RES = 128
def _to_p3d(mesh: trimesh.Trimesh, device):
v = torch.as_tensor(np.asarray(mesh.vertices), dtype=torch.float32, device=device)
f = torch.as_tensor(np.asarray(mesh.faces), dtype=torch.int64, device=device)
return Meshes(verts=[v], faces=[f])
def _sample(mesh, device, n=N_SAMPLES, seed=0):
torch.manual_seed(seed)
m = _to_p3d(mesh, device)
pts, nrm = sample_points_from_meshes(m, n, return_normals=True)
return pts, nrm # (1,n,3), (1,n,3)
def _voxelize_solid(mesh: trimesh.Trimesh, res=VOX_RES):
"""Solid occupancy on a fixed res^3 grid over [-0.5,0.5]^3 (bool array)."""
pitch = 1.0 / res
grid = np.zeros((res, res, res), dtype=bool)
try:
vox = mesh.voxelized(pitch=pitch)
try:
vox = vox.fill()
except Exception:
pass # non-watertight -> hollow occupancy, still comparable
pts = np.asarray(vox.points) # world-space centres of occupied cells
except Exception:
# last-resort: dense surface samples
pts, _ = trimesh.sample.sample_surface(mesh, 2_000_000, seed=0)
pts = np.asarray(pts)
idx = np.floor((pts + 0.5) * res).astype(np.int64)
idx = np.clip(idx, 0, res - 1)
grid[idx[:, 0], idx[:, 1], idx[:, 2]] = True
return grid
def geometry_metrics(pred_mesh: trimesh.Trimesh, gt_mesh: trimesh.Trimesh,
device="cuda", seed=0, with_vol=True):
"""Return a dict of all geometry metrics. NO alignment applied."""
pp, pn = _sample(pred_mesh, device, seed=seed)
gp, gn = _sample(gt_mesh, device, seed=seed)
# NN both directions (knn returns squared dists)
p2g = knn_points(pp, gp, K=1)
g2p = knn_points(gp, pp, K=1)
d_p2g = p2g.dists[..., 0].clamp_min(0).sqrt()[0] # pred->gt (n,)
d_g2p = g2p.dists[..., 0].clamp_min(0).sqrt()[0] # gt->pred (n,)
cd_l1 = 0.5 * (d_p2g.mean() + d_g2p.mean())
cd_l2, _ = chamfer_distance(pp, gp) # default mean squared
fscore = {}
for tau in TAUS:
precision = (d_p2g < tau).float().mean()
recall = (d_g2p < tau).float().mean()
f = (2 * precision * recall / (precision + recall + 1e-12))
fscore[tau] = {"precision": float(precision), "recall": float(recall),
"f": float(f)}
# normal consistency at mutual NN, both directions
idx_p2g = p2g.idx[..., 0][0] # for each pred pt, nearest gt idx
idx_g2p = g2p.idx[..., 0][0]
gn0, pn0 = gn[0], pn[0]
cos_p = (pn0 * gn0[idx_p2g]).sum(-1).abs()
cos_g = (gn0 * pn0[idx_g2p]).sum(-1).abs()
nc = 0.5 * (cos_p.mean() + cos_g.mean())
# volume IoU on shared grid
iou = float("nan")
if with_vol: # optional (slow; meaningless for non-watertight GT, see memory uv-flip note)
gp_vox = _voxelize_solid(pred_mesh)
gg_vox = _voxelize_solid(gt_mesh)
inter = np.logical_and(gp_vox, gg_vox).sum()
union = np.logical_or(gp_vox, gg_vox).sum()
iou = float(inter) / float(union) if union > 0 else 0.0
# ADDED: extent-relative metrics so FB150 (GT max extent 0.9) is comparable with
# Toys/Omni (1.0): distances divided by the GT max bbox extent (tau scaled by it).
ext = float((np.asarray(gt_mesh.vertices).max(0) - np.asarray(gt_mesh.vertices).min(0)).max())
rel = {}
for tau in TAUS:
t = tau * ext
pr_, rc_ = (d_p2g < t).float().mean(), (d_g2p < t).float().mean()
rel[tau] = float(2 * pr_ * rc_ / (pr_ + rc_ + 1e-12))
return {
"gt_extent": ext,
"cd_l1_rel": float(cd_l1) / ext,
"f01_rel": rel[0.01], "f02_rel": rel[0.02], "f05_rel": rel[0.05],
"cd_l1": float(cd_l1),
"cd_l2": float(cd_l2),
"fscore": {str(t): fscore[t] for t in TAUS},
"f01": fscore[0.01]["f"],
"f02": fscore[0.02]["f"], # headline
"f05": fscore[0.05]["f"],
"normal_consistency": float(nc),
"vol_iou": iou,
}