""" HY3D pipeline: sq_fit_v20 + mesh_mapper_v8 combined batch runner. Input layout: ///full.ply Output layout: // adaptive_map_v4/_adaptive_map_v4.npz sdf_vol/_sdf_vol.npz sq_fit_v20/{post_sq_*.ply, final_sq_*.ply, final_sq_model.ply, ...} curv_seg_v4/face_labels.npy # segmentation labels (prerequisite for stage B) mesh_mapping_v8/{face_labels_v8.npy, mesh_mapped_v8.ply, report.json} full.ply # copy of the input mesh summary.json Resume semantics: Stage A sq_fit_v20 done marker: sq_fit_v20/final_sq_model.ply Stage B mesh_mapping_v8 done marker: mesh_mapping_v8/face_labels_v8.npy - both done -> skip the uid entirely - only A done -> run stage B only (plus the curv_seg_v4 prerequisite if needed) - none done -> run A, then B - summary.json is rewritten at the end of each uid Usage: python scripts/run_batch_sqfit.py --mesh_root --output_root python scripts/run_batch_sqfit.py --mesh_root --output_root --start_index 20 --end_index 50 python scripts/run_batch_sqfit.py --mesh_root --output_root --uids_file list.json python scripts/run_batch_sqfit.py --mesh_root --output_root --no_isolate # same process (debug) """ from __future__ import annotations import argparse import glob import json import os import shutil import sys import time import traceback from pathlib import Path import numpy as np DEFAULT_SHARD = "00" V20_PARAMS = dict( complex_levels=(16, 32), max_batches=50, max_cands=5, target_coverage=0.9, max_iter=200, cost_threshold=0.05, max_total_sqs=100, ) V4_PARAMS = dict( cut_percentile=90.0, cut_abs_min_deg=6.0, cut_abs_max_deg=12.0, curv_weight=0.0, min_faces=60, smooth_iters=1, explode_scale=0.3, ) V8_PARAMS = dict( vote_mode="count", vote_tau=0.025, orphan_face_dist=0.04, orphan_atom_frac=0.6, min_atom_size=3, ) # ============================================================ # UID discovery # ============================================================ def discover_uids(mesh_root: str, shard: str, uids_file: str | None = None) -> list[str]: if uids_file and os.path.exists(uids_file): if uids_file.endswith(".json"): data = json.load(open(uids_file)) if isinstance(data, dict) and "uids" in data: return list(data["uids"]) return list(data) return [ln.strip() for ln in open(uids_file) if ln.strip() and not ln.startswith("#")] shard_dir = os.path.join(mesh_root, shard) if not os.path.isdir(shard_dir): raise FileNotFoundError(shard_dir) uids = [] for d in sorted(os.listdir(shard_dir)): full = os.path.join(shard_dir, d, "full.ply") if os.path.isfile(full): uids.append(d) return uids # ============================================================ # Stage completion checks # ============================================================ def stage_sq_done(uid_out: str) -> bool: return os.path.isfile(os.path.join(uid_out, "sq_fit_v20", "final_sq_model.ply")) def stage_v8_done(uid_out: str) -> bool: return os.path.isfile(os.path.join(uid_out, "mesh_mapping_v8", "face_labels_v8.npy")) def stage_v4_done(uid_out: str) -> bool: return os.path.isfile(os.path.join(uid_out, "curv_seg_v4", "face_labels.npy")) # ============================================================ # Stage A: sq_fit_v20 (adaptive_map_v4 + sdf_vol + sq_fit_v20) # ============================================================ def _normalize_mesh_o3d(mesh, half=0.5, margin=1e-6): import open3d as o3d v = np.asarray(mesh.vertices, dtype=np.float64).copy() f = np.asarray(mesh.triangles, dtype=np.int32).copy() if v.size == 0 or f.size == 0: raise ValueError("Empty mesh.") center = (v.min(0) + v.max(0)) * 0.5 ext = float((v.max(0) - v.min(0)).max()) if ext <= 0: raise ValueError(f"Invalid extent: {ext}") v = (v - center) * ((half - margin) * 2.0 / ext) return o3d.geometry.TriangleMesh( o3d.utility.Vector3dVector(v), o3d.utility.Vector3iVector(f), ) def run_stage_sqfit(uid: str, mesh_path: str, uid_out: str) -> dict: import open3d as o3d from hitops.adaptive.adaptive_block import build_block_maps_o3d from hitops.adaptive.adaptive_res_block import ( build_adaptive_block_map, save_adaptive_map, ) from hitops.sdf.build_adaptive_sdf import build_sdf_volume, save_sdf_volume, sdf_volume_stats from hitops.sqfit.sq_fit import SDFAdaptiveFitter map_dir = os.path.join(uid_out, "adaptive_map_v4") sdf_dir = os.path.join(uid_out, "sdf_vol") sq_dir = os.path.join(uid_out, "sq_fit_v20") for d in [map_dir, sdf_dir, sq_dir]: os.makedirs(d, exist_ok=True) stats = {"status": "ok"} t_start = time.time() mesh = o3d.io.read_triangle_mesh(mesh_path) if len(mesh.vertices) == 0: raise ValueError("empty mesh") stats["n_vertices"] = int(len(mesh.vertices)) stats["n_triangles"] = int(len(mesh.triangles)) mesh = _normalize_mesh_o3d(mesh) # Build the adaptive block map. t0 = time.time() prebuilt = {} for b in [16, 32, 64]: for attempt in range(3): try: r = build_block_maps_o3d( mesh, B=b, R_max=32, level_scheme="quantile", clip_percentile=99.5, ) assert r["map"].shape == (b, b, b) prebuilt[b] = r["map"] break except (ValueError, AssertionError) as e: print(f" [retry {attempt+1}/3] B={b}: {e}", flush=True) else: raise RuntimeError(f"build_block_maps_o3d B={b} failed") adaptive = build_adaptive_block_map( mesh=mesh, B_list=[16, 32, 64], R_max=32, prebuilt_maps=prebuilt, ) stats["time_adaptive"] = round(time.time() - t0, 2) save_adaptive_map(adaptive, os.path.join(map_dir, f"{uid}_adaptive_map_v4.npz")) # Build the SDF volume. t0 = time.time() R = adaptive["level_map"].shape[0] sdf_vol, truncation = build_sdf_volume(mesh, resolution=R) save_sdf_volume(sdf_vol, truncation, os.path.join(sdf_dir, f"{uid}_sdf_vol.npz")) stats["time_sdf"] = round(time.time() - t0, 2) sdf_volume_stats(sdf_vol, truncation) # Fit superquadrics. t0 = time.time() fitter = SDFAdaptiveFitter( adaptive_map=adaptive, sdf_vol=sdf_vol, truncation=truncation, complex_levels=tuple(V20_PARAMS["complex_levels"]), ) fitter.run( max_batches=V20_PARAMS["max_batches"], max_cands=V20_PARAMS["max_cands"], target_coverage=V20_PARAMS["target_coverage"], max_iter=V20_PARAMS["max_iter"], cost_threshold=V20_PARAMS["cost_threshold"], max_total_sqs=V20_PARAMS["max_total_sqs"], ) fitter.save_results(sq_dir) stats["time_sqfit"] = round(time.time() - t0, 2) stats["n_sq"] = len(glob.glob(os.path.join(sq_dir, "post_sq_*.ply"))) stats["time_total"] = round(time.time() - t_start, 2) return stats # ============================================================ # Stage B prereq: curv_seg_v4 (produces face_labels.npy) # ============================================================ def run_stage_v4(mesh_path: str, uid_out: str) -> dict: from hitops.segment.curvature_seg import segment_pipeline out = os.path.join(uid_out, "curv_seg_v4") os.makedirs(out, exist_ok=True) t0 = time.time() segment_pipeline( mesh_path=mesh_path, out_dir=out, **V4_PARAMS, ) return {"status": "ok", "time_total": round(time.time() - t0, 2)} # ============================================================ # Stage B: mesh_mapper_v8 # ============================================================ def run_stage_v8(mesh_path: str, uid_out: str) -> dict: import trimesh from hitops.mapping.mesh_mapper import _load_sq_voxel_groups, map_with_atoms, save_results fl_path = os.path.join(uid_out, "curv_seg_v4", "face_labels.npy") sq_dir = os.path.join(uid_out, "sq_fit_v20") out_dir = os.path.join(uid_out, "mesh_mapping_v8") os.makedirs(out_dir, exist_ok=True) t0 = time.time() mesh = trimesh.load(mesh_path, force="mesh", process=False) face_labels_v4 = np.load(fl_path).astype(np.int32) sq_names, sq_pts = _load_sq_voxel_groups(sq_dir) if not sq_names: return {"status": "skipped", "reason": "empty sq_dir"} res = map_with_atoms( mesh, face_labels_v4, sq_pts, vote_mode=V8_PARAMS["vote_mode"], vote_tau=V8_PARAMS["vote_tau"], orphan_face_dist=V8_PARAMS["orphan_face_dist"], orphan_atom_frac=V8_PARAMS["orphan_atom_frac"], min_atom_size=V8_PARAMS["min_atom_size"], ) save_results(mesh, res, sq_names, out_dir, save_per_sq=False) s = {"status": "ok", "t_total": round(time.time() - t0, 2), **res["stats"]} return s # ============================================================ # Per-UID driver: runs only missing stages # ============================================================ def process_one(uid: str, mesh_root: str, shard: str, output_root: str) -> dict: mesh_src = os.path.join(mesh_root, shard, uid, "full.ply") if not os.path.isfile(mesh_src): return {"uid": uid, "status": "failed", "error": f"mesh not found: {mesh_src}"} uid_out = os.path.join(output_root, uid) os.makedirs(uid_out, exist_ok=True) # Copy full.ply into the output directory to mirror the expected layout, # only when it is missing. mesh_dst = os.path.join(uid_out, "full.ply") if not os.path.isfile(mesh_dst): shutil.copy2(mesh_src, mesh_dst) result: dict = {"uid": uid, "mesh_path": mesh_src} # Run the stages against the input mesh rather than the copy; the bytes are # identical, and the pipeline code expects the input path convention. mesh_for_run = mesh_src # ---- Stage A: sq_fit_v20 ---- if stage_sq_done(uid_out): n_sq = len(glob.glob(os.path.join(uid_out, "sq_fit_v20", "post_sq_*.ply"))) result["sq_fit_v20"] = {"status": "skipped", "n_sq": n_sq} else: print(f" [sq_fit_v20] running...", flush=True) try: result["sq_fit_v20"] = run_stage_sqfit(uid, mesh_for_run, uid_out) except Exception as e: result["sq_fit_v20"] = {"status": "failed", "error": str(e)} result["status"] = "failed" result["error"] = f"sq_fit_v20: {e}" return result # ---- Stage B: mesh_mapper_v8 ---- if stage_v8_done(uid_out): result["mesh_mapping_v8"] = {"status": "skipped"} else: # Prerequisite: the curv_seg_v4 face labels. if not stage_v4_done(uid_out): print(f" [curv_seg_v4] running (prereq for v8)...", flush=True) try: result["curv_seg_v4"] = run_stage_v4(mesh_for_run, uid_out) except Exception as e: result["curv_seg_v4"] = {"status": "failed", "error": str(e)} result["mesh_mapping_v8"] = {"status": "failed", "error": f"v4 prereq: {e}"} result["status"] = "failed" result["error"] = f"curv_seg_v4: {e}" return result else: result["curv_seg_v4"] = {"status": "skipped"} print(f" [mesh_mapping_v8] running...", flush=True) try: result["mesh_mapping_v8"] = run_stage_v8(mesh_for_run, uid_out) except Exception as e: result["mesh_mapping_v8"] = {"status": "failed", "error": str(e)} result["status"] = "failed" result["error"] = f"mesh_mapping_v8: {e}" return result result["status"] = "ok" # Write the per-uid summary.json. _to_py = lambda o: (float(o) if isinstance(o, np.floating) else int(o) if isinstance(o, np.integer) else o.tolist() if isinstance(o, np.ndarray) else o) with open(os.path.join(uid_out, "summary.json"), "w") as f: json.dump(result, f, indent=2, default=_to_py) return result # ============================================================ # Subprocess isolation # ============================================================ def _worker(uid, mesh_root, shard, output_root, q): try: r = process_one(uid, mesh_root, shard, output_root) q.put(r) except Exception as e: q.put({"uid": uid, "status": "failed", "error": str(e), "traceback": traceback.format_exc()}) def process_one_isolated(uid, mesh_root, shard, output_root, timeout_sec=1800): import multiprocessing as mp ctx = mp.get_context("spawn") q = ctx.Queue() p = ctx.Process(target=_worker, args=(uid, mesh_root, shard, output_root, q)) p.start() p.join(timeout=timeout_sec) if p.is_alive(): print(f" [isolate] TIMEOUT {timeout_sec}s -> terminate", flush=True) p.terminate(); p.join(5) if p.is_alive(): p.kill(); p.join() return {"uid": uid, "status": "failed", "error": f"timeout {timeout_sec}s"} res = None try: if not q.empty(): res = q.get(timeout=2) except Exception: res = None if p.exitcode != 0: sig = f"signal {-p.exitcode}" if p.exitcode < 0 else f"exit {p.exitcode}" if res is not None: res.setdefault("error", f"worker died ({sig})") return res return {"uid": uid, "status": "failed", "error": f"worker died ({sig})"} if res is None: return {"uid": uid, "status": "failed", "error": "no result"} return res # ============================================================ # Main # ============================================================ def main(): ap = argparse.ArgumentParser(description="HY3D sq_fit_v20 + mesh_mapper_v8 batch runner") ap.add_argument("--mesh_root", required=True, help="mesh root; meshes at ///full.ply") ap.add_argument("--shard", default=DEFAULT_SHARD) ap.add_argument("--output_root", required=True) ap.add_argument("--uids_file", default=None, help="uid list .txt/.json; omit to scan the shard dir") ap.add_argument("--start_index", type=int, default=0, help="index into uid list to start from (inclusive, default 0)") ap.add_argument("--end_index", type=int, default=200, help="index into uid list to stop at (exclusive, default 200)") ap.add_argument("--no_isolate", action="store_true", help="run in same process (debug)") ap.add_argument("--timeout_sec", type=int, default=1800) args = ap.parse_args() uids_file = args.uids_file if args.uids_file else None all_uids = discover_uids(args.mesh_root, args.shard, uids_file) total = len(all_uids) s = max(0, args.start_index) e = min(total, args.end_index) if args.end_index > 0 else total if s >= e: raise SystemExit(f"[batch] empty slice: start_index={s} end_index={e} total={total}") uids = all_uids[s:e] print(f"[batch] {len(uids)} uid(s) to process (slice [{s}:{e}) of {total})") print(f"[batch] uids_file = {uids_file or '(scan shard dir)'}") print(f"[batch] mesh_root = {args.mesh_root}") print(f"[batch] shard = {args.shard}") print(f"[batch] output_root = {args.output_root}") os.makedirs(args.output_root, exist_ok=True) summary_path = os.path.join(args.output_root, "batch_summary.json") all_stats: list[dict] = [] if os.path.exists(summary_path): try: all_stats = json.load(open(summary_path)) done = {s["uid"] for s in all_stats if s.get("status") in ("ok", "skipped")} print(f"[batch] resume: {len(done)} uids already in batch_summary.json", flush=True) except Exception: all_stats = [] for i, uid in enumerate(uids): print("\n" + "=" * 72, flush=True) print(f"[{i+1:3d}/{len(uids)}] {uid}", flush=True) print("=" * 72, flush=True) # Fast path: both stages are already complete. uid_out = os.path.join(args.output_root, uid) if stage_sq_done(uid_out) and stage_v8_done(uid_out): print(" -> both stages done, skip", flush=True) s = {"uid": uid, "status": "skipped", "reason": "both_done"} else: try: if args.no_isolate: s = process_one(uid, args.mesh_root, args.shard, args.output_root) else: s = process_one_isolated( uid, args.mesh_root, args.shard, args.output_root, timeout_sec=args.timeout_sec, ) except Exception as e: traceback.print_exc() s = {"uid": uid, "status": "failed", "error": str(e)} all_stats = [x for x in all_stats if x.get("uid") != uid] all_stats.append(s) _to_py = lambda o: (float(o) if isinstance(o, np.floating) else int(o) if isinstance(o, np.integer) else o.tolist() if isinstance(o, np.ndarray) else o) with open(summary_path, "w") as f: json.dump(all_stats, f, indent=2, default=_to_py) st = s.get("status") if st == "ok": sq = s.get("sq_fit_v20", {}) v8 = s.get("mesh_mapping_v8", {}) print(f" -> OK sq:{sq.get('status')} v8:{v8.get('status')}", flush=True) elif st == "skipped": print(f" -> SKIPPED ({s.get('reason','?')})", flush=True) else: print(f" -> FAILED: {s.get('error','?')}", flush=True) ok = [s for s in all_stats if s.get("status") == "ok"] skipped = [s for s in all_stats if s.get("status") == "skipped"] failed = [s for s in all_stats if s.get("status") == "failed"] print(f"\n[done] ok={len(ok)} skipped={len(skipped)} failed={len(failed)} " f"| summary={summary_path}") if __name__ == "__main__": main()