3Deditformer / scripts /compare_glb_scale.py
zhaxie's picture
Upload 3DEditFormer source code
2274e38 verified
Raw
History Blame Contribute Delete
10.3 kB
#!/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: <dataset_root_dir>/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()