File size: 20,593 Bytes
cca6827 | 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 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 | #!/usr/bin/env python3
"""FINAL evaluator (FORGE3DBench-150 / OmniObject3D-300 / Toys4K-300).
Toys4K-300 (--dataset toys300) keeps the ORIGINAL protocol unchanged: this script
only calls appeval/evaluate_appforce.eval_object (IV = pred render vs input crop,
unmasked; NV = 24-view orbit, pred render vs GT-mesh render; geometry) and
writes the same summary.json/results.csv as below. Numbers are identical to
evaluate_appforce.py (Toys cameras are 2.6 units away, so the far-clip fix is a no-op).
FB150 + Omni300: ONE image-comparison path for input views (IV) and novel views (NV):
the PREDICTED mesh (already in the GT canonical frame) is rendered with the
unlit nvdiffrast renderer (appeval/render.py) at the dataset camera
(K + c2w_cv from renders/<obj>_<tag>.npz), cropped with the stored bbox
(y0,y1,x0,x1), and compared with the DATASET'S OWN GT IMAGE crop
(inputs/<obj>_<tag>.png) inside the GT mask M = png alpha > 0.5:
pred_alpha *= M ; ref_alpha := M ; both composited on white
-> LPIPS(alex) / SSIM / PSNR / CLIP (appeval/appearance.py, unchanged).
Pixels outside M are white in both (ignored); M-pixels not covered by the
prediction are scored as white-vs-GT (penalised).
M is the VISIBLE (modal) object mask on FORGE3DBench (occluders and occluded
object parts are never scored) and the object alpha on Omni/Toys.
This is eval_heldout.heldout_view() (.debug/eval_heldout_nv) verbatim,
applied to both the input cameras and the held-out cameras; unit test
`--selftest-equiv` checks bit-equality against it.
IV = the input cameras (4v: front,side,back,oside ; 1v: front).
NV = held-out cameras never fed to any model (same set for 1v and 4v):
fb150 : the other scene cameras (of 24) with visibility
(modal/amodal area, views_4v.json) >= --min-vis (0.20) and >=400
visible px; tags h<cam>, files from data/fb150_heldout.tar
omni300: saved held-out renders top,top2,bottom,bottom2 (--nv-set saved,
default) [+ optional official-render pool p<idx> (--nv-set all)]
Geometry: appeval/geometry.py geometry_metrics(pred, GT canon) (CD-L1, F@tau,
NC, vol-IoU; original keys unchanged, + extent-relative *_rel keys). No
alignment is done here: meshes must already be in the GT frame
(align_v2.py / cammap_*.py upstream, see README).
Per-object means over views, then mean over objects. summary.json is written
by THIS script (single run or --summarize over shard dirs), never by hand.
Diagnostics (not the protocol):
--pred-is-gt score the GT canonical mesh itself (= protocol ceiling;
appearance is NOT perfect: lit scene images vs unlit
render; geometry is exactly perfect).
--ref gtrender replace the GT image by the GT-mesh render (same camera,
same mask). With --pred-is-gt this must be exactly
perfect (pipeline identity test).
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
import time
from pathlib import Path
import numpy as np
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / "appeval"))
import render as R # noqa: E402 (far-clip fixed)
import evaluate_appforce as EA # noqa: E402 (helpers only)
from appearance import appearance_metrics, composite_white # noqa: E402
from geometry import geometry_metrics # noqa: E402
MKEYS = ("lpips", "ssim", "psnr", "clip")
GKEYS = ("cd_l1", "f01", "f02", "f05", "normal_consistency", "vol_iou", "cd_l1_rel", "f01_rel")
IV_TAGS = {"4v": ["front", "side", "back", "oside"], "1v": ["front"]}
LP = "/lp-dev/jonghoon/mv-mesh"
# defaults = layout on the original server; override with --exp / --heldout-root
DEFAULT_EXP = {
("fb150", "4v"): f"{LP}/.debug/forgebench_eval/fb150/exp_4v",
("fb150", "1v"): f"{LP}/.debug/forgebench_eval/fb150/exp_1v",
("omni300", "4v"): f"{LP}/exp_faithfulness/omni3d300_rand",
("omni300", "1v"): f"{LP}/exp_faithfulness/omni3d300_rand",
("toys300", "4v"): f"{LP}/exp_faithfulness/toys4k300_rand",
("toys300", "1v"): f"{LP}/exp_faithfulness/toys4k300_rand",
}
DEFAULT_HELDOUT_ROOT = {"fb150": f"{LP}/.debug/eval_heldout_nv/fb150_heldout",
"omni300": f"{LP}/.debug/eval_heldout_nv/omni300_heldout",
"toys300": None}
BAD_FRAME_FILE = HERE / "heldout_index" / "omni300_bad_frame_objects.txt"
# --------------------------------------------------------------------------- views
def masked_view(g, root: Path, obj: str, tag: str, ctx, gt_g=None):
"""== eval_heldout.heldout_view (verbatim logic). Returns (metrics, pred, ref)."""
z = np.load(root / "renders" / f"{obj}_{tag}.npz")
K = {k: float(z[k]) for k in ("fx", "fy", "cx", "cy")}
res = int(z["res"]); y0, y1, x0, x1 = [int(v) for v in z["bbox"].tolist()]
ref = EA.read_input_png(root / "inputs" / f"{obj}_{tag}.png")
pred = R.render_input_view(g, K, z["c2w_cv"], res, res, ctx=ctx).cpu().numpy()[y0:y1, x0:x1]
h, w = min(pred.shape[0], ref.shape[0]), min(pred.shape[1], ref.shape[1])
pred, ref = pred[:h, :w].copy(), ref[:h, :w]
M = (ref[..., 3] > 0.5).astype(np.float32)
if gt_g is not None: # diagnostic: GT-mesh render replaces the GT image (same mask)
ref = R.render_input_view(gt_g, K, z["c2w_cv"], res, res, ctx=ctx).cpu().numpy()[y0:y1, x0:x1][:h, :w]
pred[..., 3] *= M # restrict to GT (visible) mask
ref = ref.copy(); ref[..., 3] = M if gt_g is None else ref[..., 3] * M
m = appearance_metrics(pred, ref)
m["coverage"] = float((pred[..., 3] > 0.5).sum() / max(M.sum(), 1)) # frac of mask hit by pred
m.update(view=tag, mask_px=int(M.sum()), cam_dist=float(np.linalg.norm(z["c2w_cv"][:3, 3])))
return m, pred, ref
def load_index(path: Path):
return json.loads(Path(path).read_text())
def nv_views(a, recs):
"""Filter the held-out records of one object according to the protocol flags."""
out = []
for r in recs:
if a.dataset == "fb150" and r["vis"] < a.min_vis:
continue
if a.dataset == "omni300" and a.nv_set != "all" and r["kind"] != a.nv_set:
continue
out.append(r)
return out
def view_root(a, r):
return a.exp if r["src"] == "exp" else a.heldout_root
def mean_views(per):
d = {k: float(np.mean([v[k] for v in per])) for k in MKEYS + ("coverage",)}
d["n_views"] = len(per)
return d
# --------------------------------------------------------------------------- object
def run_object(a, obj, recs, ctx, save_debug):
gt_path = EA.resolve_gt(a.exp / "renders", obj)
if gt_path is None:
return {"object": obj, "error": "missing gt mesh"}
pred_path = gt_path if a.pred_is_gt else a.meshes / f"{obj}.glb"
if not pred_path.exists():
return {"object": obj, "error": "missing pred glb"}
if pred_path.stat().st_size < 1024:
return {"object": obj, "error": f"pred glb too small ({pred_path.stat().st_size} B)"}
pred_mesh = EA.load_mesh(pred_path)
if len(getattr(pred_mesh, "faces", [])) == 0:
return {"object": obj, "error": "pred mesh has no faces"}
gt_mesh = EA.load_mesh(gt_path)
g = R.prepare_mesh(pred_mesh)
gt_g = R.prepare_mesh(gt_mesh) if a.ref == "gtrender" else None
row = {"object": obj, "pred": str(pred_path), "bad_frame": obj in a.bad_frame}
sheet = []
for kind, tags in (("iv", [dict(tag=t, src="exp") for t in IV_TAGS[a.setting]]), ("nv", nv_views(a, recs))):
per = []
for r in tags:
root = view_root(a, r)
if not (root / "renders" / f"{obj}_{r['tag']}.npz").exists():
continue
m, p, rf = masked_view(g, root, obj, r["tag"], ctx, gt_g)
for k in ("cam", "vis", "kind"):
if k in r:
m[k] = r[k]
per.append(m)
if save_debug and len(sheet) < 10:
sheet += [composite_white(rf), composite_white(p)]
if per:
row[kind] = mean_views(per)
row[kind]["per_view"] = per
else:
row[f"{kind}_error"] = "no views"
if not a.no_geometry:
try:
row["geometry"] = geometry_metrics(pred_mesh, gt_mesh, with_vol=not a.no_vol_iou)
except Exception as e: # noqa: BLE001
row["geometry_error"] = f"{type(e).__name__}: {e}"
if sheet:
EA.save_sheet(sheet, a.out / "debug" / f"{obj}_iv_nv.png")
return row
def run_object_toys(a, obj, ctx, save_debug):
"""ORIGINAL Toys4K protocol: evaluate_appforce.eval_object, unchanged."""
meshes = (a.exp / "renders") if a.pred_is_gt else a.meshes
if a.pred_is_gt: # eval_object reads <meshes>/<obj>.glb -> link dir to the GT canon
link = a.out / "_gt_links"; link.mkdir(parents=True, exist_ok=True)
l = link / f"{obj}.glb"
if not l.exists():
l.symlink_to(EA.resolve_gt(a.exp / "renders", obj))
meshes = link
r = EA.eval_object(a.exp, meshes, a.exp / "renders", obj, IV_TAGS[a.setting], True, a.out, save_debug, ctx)
if "error" in r:
return r
r = _legacy(r)
if "nv" not in r and r.get("gt_textured") is False:
r["nv_error"] = "GT untextured (orbit NV skipped by the original protocol)"
return r
# --------------------------------------------------------------------------- summary
def complete(r, need_geom=True):
"""IV + geometry required; NV required unless the GT is untextured (Toys orbit NV skipped)."""
nv_ok = "nv" in r or r.get("gt_textured") is False
return ("error" not in r and "iv" in r and nv_ok and (not need_geom or "geometry" in r))
def _legacy(r):
"""Rows written by the ORIGINAL evaluate_appforce.py (Toys4K) -> iv/nv keys (values untouched)."""
if "iv" not in r and r.get("input_view"):
r = dict(r)
r["iv"] = {k: r["input_view"][k] for k in MKEYS}; r["iv"]["coverage"] = float("nan")
r["iv"]["n_views"] = len(r["input_view"].get("per_view", [])) or 1
if r.get("novel_view"):
r["nv"] = {k: r["novel_view"][k] for k in MKEYS}; r["nv"]["coverage"] = float("nan"); r["nv"]["n_views"] = 24
return r
def summarize(rows, meta, out: Path, expected=None, exclude=()):
"""Merge rows (dedup: the LAST complete row of an object wins, i.e. pass retry dirs after the
main dirs; conflicting complete duplicates are listed), write results.json / results.csv / summary.json."""
by, conflicts = {}, {}
for r in map(_legacy, rows):
o = r["object"]
c_new = complete(r, not meta.get("no_geometry"))
if o in by and c_new and complete(by[o], not meta.get("no_geometry")):
d = abs(by[o]["iv"]["lpips"] - r["iv"]["lpips"]) + abs(by[o].get("geometry", {}).get("cd_l1", 0) - r.get("geometry", {}).get("cd_l1", 0))
if d > 1e-6:
conflicts[o] = conflicts.get(o, 0) + 1
if o not in by or c_new:
by[o] = r
rows = [by[o] for o in sorted(by)]
need_g = not meta.get("no_geometry")
ok = [r for r in rows if complete(r, need_g)]
failed = {r["object"]: r.get("error") or r.get("iv_error") or r.get("nv_error") or r.get("geometry_error")
for r in rows if not complete(r, need_g)}
excl = sorted(o for o in exclude if o in by or (expected and o in expected))
inc = [r for r in ok if r["object"] not in set(exclude)]
def means(rs):
d = {"n_objects": len(rs)}
if not rs:
return d
for kind in ("iv", "nv"):
rk = [r for r in rs if kind in r]
if not rk:
continue
d[kind] = {k: float(np.mean([r[kind][k] for r in rk])) for k in MKEYS + ("coverage",)}
d[kind]["n_views_total"] = int(sum(r[kind]["n_views"] for r in rk))
d[kind]["n_objects"] = len(rk)
gs = [r for r in rs if "geometry" in r]
if gs:
d["geometry"] = {k: float(np.nanmean([r["geometry"][k] for r in gs])) for k in GKEYS if k in gs[0]["geometry"]}
return d
summ = dict(meta)
summ["means"] = means(inc) # HEADLINE (exclusions applied)
if excl:
summ["means_including_excluded"] = means(ok)
summ["excluded_objects"] = excl
summ["failed_objects"] = failed
summ["duplicate_conflicts_last_wins"] = conflicts
if expected is not None:
summ["n_expected"] = len(expected)
summ["missing_objects"] = sorted(set(expected) - set(by))
summ["n_scored"] = len(ok)
summ["generated_by"] = f"{Path(__file__).name} (sha256 {_sha(Path(__file__))[:12]})"
summ["time_utc"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
out.mkdir(parents=True, exist_ok=True)
(out / "results.json").write_text(json.dumps(rows, indent=1))
with open(out / "results.csv", "w", newline="") as f:
w = csv.writer(f)
hdr = (["object", "status", "excluded"] + [f"iv_{k}" for k in MKEYS] + ["iv_n"]
+ [f"nv_{k}" for k in MKEYS] + ["nv_n"] + list(GKEYS))
w.writerow(hdr)
for r in rows:
st = "ok" if complete(r, need_g) else "failed"
line = [r["object"], st, r["object"] in set(exclude)]
for kind in ("iv", "nv"):
d = r.get(kind, {})
line += [d.get(k, "") for k in MKEYS] + [d.get("n_views", "")]
gg = r.get("geometry", {})
line += [gg.get(k, "") for k in GKEYS]
w.writerow(line)
(out / "summary.json").write_text(json.dumps(summ, indent=1))
return summ
def _sha(p: Path):
import hashlib
return hashlib.sha256(p.read_bytes()).hexdigest()
# --------------------------------------------------------------------------- main
def parse():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--dataset", choices=["fb150", "omni300", "toys300"])
ap.add_argument("--setting", choices=["4v", "1v"], default="4v", help="IV cameras (NV set is the same for both)")
ap.add_argument("--meshes", type=Path, help="DIR with <object>.glb already in the GT canonical frame")
ap.add_argument("--method", default=None, help="label stored in summary.json (default: meshes dir name)")
ap.add_argument("--out", type=Path)
ap.add_argument("--exp", type=Path, help="dataset exp dir (inputs/, renders/, selection.json)")
ap.add_argument("--heldout-root", type=Path, help="dir with held-out inputs/ renders/ (fb150 h**, omni pool p***)")
ap.add_argument("--heldout-index", type=Path, help="default: heldout_index/<dataset>.json next to this script")
ap.add_argument("--min-vis", type=float, default=0.20, help="fb150 NV: min visible/full-area ratio (protocol 0.20)")
ap.add_argument("--nv-set", choices=["saved", "pool", "all"], default="saved", help="omni300 NV views")
ap.add_argument("--exclude-bad-frame", action="store_true",
help="omni300: drop the 14 objects whose GT canon is not in the render-camera frame from the "
"headline means (they are still scored and listed)")
ap.add_argument("--ref", choices=["image", "gtrender"], default="image",
help="image = PROTOCOL (dataset GT image); gtrender = diagnostic only")
ap.add_argument("--pred-is-gt", action="store_true", help="diagnostic: score the GT canonical mesh itself")
ap.add_argument("--no-geometry", action="store_true")
ap.add_argument("--no-vol-iou", action="store_true", help="skip the (slow) volume IoU; other geometry unchanged")
ap.add_argument("--objects", nargs="*"); ap.add_argument("--objects-file", type=Path)
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--shard", type=int, default=0); ap.add_argument("--nshards", type=int, default=1)
ap.add_argument("--debug-sheets", type=int, default=4, help="save IV/NV proof sheets for the first N objects")
ap.add_argument("--summarize", nargs="+", type=Path, metavar="SHARD_DIR",
help="merge shard dirs (their results.json) into --out and write the summary; no GPU")
return ap.parse_args()
def setup(a):
a.exp = Path(a.exp or DEFAULT_EXP[(a.dataset, a.setting)])
hr = a.heldout_root or DEFAULT_HELDOUT_ROOT[a.dataset]
a.heldout_root = Path(hr) if hr else a.exp
a.heldout_index = Path(a.heldout_index or HERE / "heldout_index" / f"{a.dataset}.json")
if a.dataset == "toys300" and (a.ref != "image" or a.nv_set != "saved" or a.no_geometry):
sys.exit("toys300 = original evaluate_appforce protocol; --ref/--nv-set/--no-geometry do not apply")
a.bad_frame = set()
if a.dataset == "omni300" and BAD_FRAME_FILE.exists():
a.bad_frame = {l.split()[0] for l in BAD_FRAME_FILE.read_text().splitlines() if l.strip() and not l.startswith("#")}
sel = json.loads((a.exp / "selection.json").read_text())["selections"]
a.expected = [s["object"] for s in sel]
if a.method is None:
a.method = "GT" if a.pred_is_gt else (a.meshes.name if a.meshes else "?")
proto = ("ORIGINAL evaluate_appforce: IV pred-render vs input crop (unmasked); NV 24-orbit pred vs GT-mesh render"
if a.dataset == "toys300" else
"masked GT-image: pred render vs dataset GT image inside the GT/visible mask, IV=input cams, NV=held-out cams")
return dict(method=a.method, dataset=a.dataset, setting=a.setting, protocol=proto, ref=a.ref, pred_is_gt=a.pred_is_gt,
min_vis=a.min_vis if a.dataset == "fb150" else None,
nv_set={"fb150": "heldout_cams_vis>=min_vis", "omni300": a.nv_set, "toys300": "orbit24"}[a.dataset],
exclude_bad_frame=a.exclude_bad_frame, no_geometry=a.no_geometry, no_vol_iou=a.no_vol_iou,
meshes=str(a.meshes) if a.meshes else None, exp=str(a.exp))
def main():
a = parse()
meta = setup(a)
excl = sorted(a.bad_frame) if a.exclude_bad_frame else []
if a.summarize:
rows = []
for d in a.summarize:
rj = d / "results.json"
if not rj.exists():
sys.exit(f"ABORT: {rj} missing (shard not finished?)")
rows += json.loads(rj.read_text())
s = summarize(rows, meta, a.out, expected=a.expected, exclude=excl)
print(json.dumps({k: s[k] for k in ("means", "n_scored", "n_expected", "excluded_objects")}, indent=1))
print(f"missing={len(s['missing_objects'])} failed={len(s['failed_objects'])}")
return
if not a.pred_is_gt and a.meshes is None:
sys.exit("--meshes required (or --pred-is-gt)")
idx = load_index(a.heldout_index) if a.dataset != "toys300" else {}
objs = list(a.expected)
want = set(a.objects or [])
if a.objects_file:
want |= {l.strip() for l in a.objects_file.read_text().split() if l.strip()}
if want:
objs = [o for o in objs if o in want]
if a.limit:
objs = objs[:a.limit]
objs = objs[a.shard::a.nshards]
a.out.mkdir(parents=True, exist_ok=True)
ctx = R.get_ctx()
rows, t0 = [], time.time()
for i, obj in enumerate(objs):
try:
if a.dataset == "toys300":
r = run_object_toys(a, obj, ctx, save_debug=i < a.debug_sheets)
else:
r = run_object(a, obj, idx.get(obj, []), ctx, save_debug=i < a.debug_sheets)
except Exception as e: # noqa: BLE001
import traceback; traceback.print_exc()
r = {"object": obj, "error": f"{type(e).__name__}: {e}"}
rows.append(r)
iv, nv, ge = r.get("iv", {}), r.get("nv", {}), r.get("geometry", {})
print(f"[{i+1}/{len(objs)} {time.time()-t0:.0f}s] {obj} {r.get('error','')} "
f"IV lpips={iv.get('lpips', np.nan):.4f} psnr={iv.get('psnr', np.nan):.2f} | "
f"NV n={nv.get('n_views')} lpips={nv.get('lpips', np.nan):.4f} psnr={nv.get('psnr', np.nan):.2f} | "
f"CD={ge.get('cd_l1', np.nan):.4f} F01={ge.get('f01', np.nan):.3f}", flush=True)
if (i + 1) % 10 == 0: # checkpoint partial results
(a.out / "results.json").write_text(json.dumps(rows, indent=1))
exp_list = a.expected if (a.nshards == 1 and not want and not a.limit) else objs
s = summarize(rows, meta, a.out, expected=exp_list, exclude=excl)
print(json.dumps(s["means"], indent=1))
if __name__ == "__main__":
main()
|