#!/usr/bin/env python3 """ Compare geometric scale of two GLB exports (pred vs GT) before running full metrics. Uses trimesh (same stack as calculate_metric_3d.py). For each mesh we report vertex count, axis-aligned bbox min/max, extents, diagonal, center; then pred/GT ratios for diagonal and each axis extent, and center delta. Batch mode mirrors calculate_metric_3d.py path layout. """ from __future__ import annotations import argparse import json import math import sys from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import numpy as np import trimesh @dataclass class BBoxReport: n_vertices: int n_faces: int min_xyz: np.ndarray max_xyz: np.ndarray extents: np.ndarray diagonal: float center: np.ndarray def load_glb_as_mesh(path: Path) -> trimesh.Trimesh: if not path.is_file(): raise FileNotFoundError(str(path)) loaded = trimesh.load(str(path), force=None, ignore_broken=True) if isinstance(loaded, trimesh.Scene): meshes: List[trimesh.Trimesh] = [] for geom in loaded.geometry.values(): if isinstance(geom, trimesh.Trimesh): meshes.append(geom) if not meshes: raise ValueError(f"No Trimesh geometry in scene: {path}") return trimesh.util.concatenate(meshes) if isinstance(loaded, trimesh.Trimesh): return loaded raise TypeError(f"Unsupported trimesh type {type(loaded)} for {path}") def bbox_report(mesh: trimesh.Trimesh) -> BBoxReport: v = np.asarray(mesh.vertices, dtype=np.float64) if v.size == 0: raise ValueError("empty mesh vertices") vmin = v.min(axis=0) vmax = v.max(axis=0) extents = vmax - vmin diagonal = float(np.linalg.norm(extents)) center = 0.5 * (vmin + vmax) faces = int(len(mesh.faces)) if hasattr(mesh, "faces") else 0 return BBoxReport( n_vertices=int(v.shape[0]), n_faces=faces, min_xyz=vmin, max_xyz=vmax, extents=extents, diagonal=diagonal, center=center, ) def safe_ratio(a: float, b: float) -> float: if not math.isfinite(a) or not math.isfinite(b) or abs(b) < 1e-12: return float("nan") return float(a / b) def compare_reports(pred: BBoxReport, gt: BBoxReport) -> Dict[str, Any]: ratios_xyz = [safe_ratio(float(pe), float(ge)) for pe, ge in zip(pred.extents, gt.extents)] return { "diagonal_ratio_pred_over_gt": safe_ratio(pred.diagonal, gt.diagonal), "extent_ratio_xyz_pred_over_gt": ratios_xyz, "center_delta_pred_minus_gt": (pred.center - gt.center).tolist(), } def print_report(tag: str, rep: BBoxReport) -> None: print(f"\n[{tag}] {rep.n_vertices} verts, {rep.n_faces} faces") print(f" min xyz: {rep.min_xyz}") print(f" max xyz: {rep.max_xyz}") print(f" extents (Lx,Ly,Lz): {rep.extents}") print(f" diagonal: {rep.diagonal:.6f}") print(f" center: {rep.center}") def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="Compare pred vs GT GLB bounding-box scale.") g = p.add_mutually_exclusive_group(required=True) g.add_argument("--pred_glb", type=Path, help="Single predicted GLB path.") g.add_argument( "--eval_results_dir", type=Path, help="Eval output root (batch); pairs built like calculate_metric_3d.py.", ) p.add_argument("--gt_glb", type=Path, help="Single GT GLB path (use with --pred_glb).") p.add_argument("--gt_dir", type=Path, help="GT root for batch mode (use with --eval_results_dir).") p.add_argument( "--dataset_root_dir", type=Path, default=Path("/mnt/zsn/data/3DEditVerse"), help="Used to locate test_data_info.json in batch mode.", ) p.add_argument( "--test_data_info_path", type=Path, default=None, help="Override test_data_info.json path (default: /test_data_info.json).", ) p.add_argument("--wo_mixamo", action="store_true", help="Skip mixamo keys in batch mode.") p.add_argument( "--warn_ratio", type=float, default=1.05, help="Warn if diagonal or any extent ratio falls outside [1/r, r] (default 1.05).", ) p.add_argument("--max_samples", type=int, default=None, help="Optional cap for batch mode.") p.add_argument("--save_json", type=Path, default=None, help="Optional path to write batch summary JSON.") return p.parse_args() def _batch_pairs(args: argparse.Namespace) -> List[Tuple[str, Path, Path]]: if args.gt_dir is None: raise SystemExit("batch mode requires --gt_dir") info_path = args.test_data_info_path or (args.dataset_root_dir / "test_data_info.json") with open(info_path, "r", encoding="utf-8") as f: test_data_info = json.load(f) pairs: List[Tuple[str, Path, Path]] = [] for key in test_data_info.get("flux_edit", []): pred = args.eval_results_dir / "flux_edit" / key / "edit.glb" gt = args.gt_dir / "flux_edit" / key / "edit.glb" pairs.append((f"flux_edit/{key}", pred, gt)) for key in test_data_info.get("alpaca", []): pred = args.eval_results_dir / "alpaca" / key / "edit.glb" gt = args.gt_dir / "alpaca" / key / "edit.glb" pairs.append((f"alpaca/{key}", pred, gt)) if not args.wo_mixamo: with open(args.dataset_root_dir / "dataset_info.json", "r", encoding="utf-8") as f: all_data_info = json.load(f) for mixamo_key in test_data_info.get("mixamo", []): object_name, ori_idx, edit_idx = mixamo_key pred_key = f"{object_name}_{ori_idx}_{edit_idx}" gt_key = all_data_info["mixamo"][object_name][int(edit_idx)]["ss_latents_path"].split("/")[-1][:-4] pred = args.eval_results_dir / "mixamo" / pred_key / "edit.glb" gt = args.gt_dir / "mixamo" / gt_key / "ori.glb" pairs.append((f"mixamo/{pred_key}", pred, gt)) if args.max_samples is not None: pairs = pairs[: args.max_samples] return pairs def main() -> None: args = parse_args() warn_r = float(args.warn_ratio) if warn_r <= 1.0: raise SystemExit("--warn_ratio must be > 1") if args.pred_glb is not None: if args.gt_glb is None: raise SystemExit("--pred_glb requires --gt_glb") pred_mesh = load_glb_as_mesh(args.pred_glb) gt_mesh = load_glb_as_mesh(args.gt_glb) pr = bbox_report(pred_mesh) gr = bbox_report(gt_mesh) print_report("PRED", pr) print_report("GT", gr) cmp = compare_reports(pr, gr) print("\n[COMPARE]") print(json.dumps(cmp, indent=2)) diag_r = cmp["diagonal_ratio_pred_over_gt"] bad = False if math.isfinite(diag_r) and (diag_r > warn_r or diag_r < 1.0 / warn_r): print(f"WARNING: diagonal ratio {diag_r:.6f} outside [{1/warn_r:.4f}, {warn_r:.4f}]") bad = True for i, r in enumerate(cmp["extent_ratio_xyz_pred_over_gt"]): if math.isfinite(r) and (r > warn_r or r < 1.0 / warn_r): print(f"WARNING: extent ratio axis {i} pred/gt = {r:.6f} outside [{1/warn_r:.4f}, {warn_r:.4f}]") bad = True if bad: raise SystemExit(2) print("\nOK: pred and GT appear to share a similar axis-aligned scale (within warn_ratio).") return pairs = _batch_pairs(args) rows: List[Dict[str, Any]] = [] worst_dev = 1.0 worst_key = "" missing = 0 failed = 0 for key, pred_path, gt_path in pairs: if not pred_path.is_file() or not gt_path.is_file(): missing += 1 rows.append({"key": key, "status": "missing_file", "pred": str(pred_path), "gt": str(gt_path)}) continue try: pr = bbox_report(load_glb_as_mesh(pred_path)) gr = bbox_report(load_glb_as_mesh(gt_path)) cmp = compare_reports(pr, gr) diag_r = cmp["diagonal_ratio_pred_over_gt"] row: Dict[str, Any] = { "key": key, "status": "ok", "pred": str(pred_path), "gt": str(gt_path), "pred_diag": pr.diagonal, "gt_diag": gr.diagonal, **cmp, } extent_rs = cmp["extent_ratio_xyz_pred_over_gt"] bad = False if math.isfinite(diag_r) and (diag_r > warn_r or diag_r < 1.0 / warn_r): bad = True for r in extent_rs: if math.isfinite(r) and (r > warn_r or r < 1.0 / warn_r): bad = True if bad: row["status"] = "scale_mismatch" failed += 1 rows.append(row) if math.isfinite(diag_r) and diag_r > 0: dev = max(diag_r, 1.0 / diag_r) if dev > worst_dev: worst_dev = dev worst_key = key except Exception as exc: failed += 1 rows.append({"key": key, "status": "error", "error": repr(exc), "pred": str(pred_path), "gt": str(gt_path)}) finite_diags = [ r["diagonal_ratio_pred_over_gt"] for r in rows if r.get("status") == "ok" and math.isfinite(float(r.get("diagonal_ratio_pred_over_gt", float("nan")))) ] summary = { "n_pairs": len(pairs), "missing_files": missing, "scale_mismatch_or_error": failed, "warn_ratio": warn_r, "diagonal_ratio_median": float(np.median(finite_diags)) if finite_diags else None, "worst_symmetric_diag_deviation": worst_dev, "worst_key": worst_key, } print(json.dumps(summary, indent=2)) if args.save_json is not None: args.save_json.parent.mkdir(parents=True, exist_ok=True) with open(args.save_json, "w", encoding="utf-8") as f: json.dump({"summary": summary, "rows": rows}, f, indent=2) print(f"wrote {args.save_json}") if missing: print(f"NOTE: {missing} pairs missing pred or gt GLB on disk.", file=sys.stderr) if failed: raise SystemExit(2) if __name__ == "__main__": main()