"""Prepare paired, canonical rendered images and coarse Gaussian targets. These are mesh-derived proxy targets, NOT pretrained-teacher outputs or photos. Only public, explicitly allowed assets are downloaded. Source meshes stay local. """ import argparse import gzip import hashlib import json import random import shutil import time import urllib.error import urllib.request from pathlib import Path, PurePosixPath import numpy as np import pyarrow.parquet as pq import trimesh from huggingface_hub import hf_hub_download from image3d_student.data import cache_sample, splat_targets from image3d_student.splats import Splats, preview from image3d_student.engine import split_rows SCENARIO = ("scenario-labs/3d-collection", "55b4c002b4c1529fc48da3dff98516c3069bfa3f") OBJAVERSE = ("allenai/objaverse", "556637099bf4fa79ea7b239d0d9c328b8a2e9ac8") def digest(path): h = hashlib.sha256() with Path(path).open("rb") as f: for block in iter(lambda: f.read(1024 * 1024), b""): h.update(block) return h.hexdigest() class Downloader: def __init__(self, root, max_file_mb, max_total_mb, reserve_gb): self.root = Path(root) self.root.mkdir(parents=True, exist_ok=True) self.max_file = int(max_file_mb * 1024**2) self.max_total = int(max_total_mb * 1024**2) self.reserve = int(reserve_gb * 1024**3) self.total = 0 def get(self, repo, revision, filename, expected=None): parts = PurePosixPath(filename) if parts.is_absolute() or ".." in parts.parts or not filename.endswith(".glb"): raise ValueError("Only relative GLB paths are allowed") out = self.root / repo.replace("/", "--") / revision / filename if out.exists(): if expected and digest(out) != expected: raise ValueError("Cached source checksum mismatch") if out.stat().st_size > self.max_file: raise ValueError("Cached asset exceeds per-file limit") return out if self.total >= self.max_total: raise RuntimeError("Download budget exhausted") out.parent.mkdir(parents=True, exist_ok=True) url = f"https://huggingface.co/datasets/{repo}/resolve/{revision}/{filename}" for attempt in range(4): tmp = out.with_suffix(".part") try: # Public URL only: credentials are never forwarded to asset hosts. with urllib.request.urlopen(url, timeout=60) as response, tmp.open("wb") as f: length = int(response.headers.get("Content-Length", 0)) if length > self.max_file: raise ValueError("Asset exceeds per-file limit") count = 0 while block := response.read(1024 * 1024): count += len(block) self.total += len(block) if count > self.max_file: raise ValueError("Asset exceeds per-file limit") if self.total > self.max_total: raise RuntimeError("Download budget exhausted") if shutil.disk_usage(self.root).free < self.reserve + len(block): raise RuntimeError("Free disk space below reserve") f.write(block) if expected and digest(tmp) != expected: raise ValueError("Source checksum mismatch") tmp.replace(out) return out except urllib.error.HTTPError as exc: if exc.code not in (429, 500, 502, 503, 504) or attempt == 3: raise time.sleep(min(2**attempt, 8)) finally: tmp.unlink(missing_ok=True) raise RuntimeError("Download retries exhausted") def scenario_candidates(cache, seed): repo, revision = SCENARIO path = hf_hub_download(repo, "data/train-00000-of-00001.parquet", repo_type="dataset", revision=revision, local_dir=Path(cache) / "metadata/scenario") rows = pq.read_table(path, columns=["id", "license", "model", "inputs", "outputs"]).to_pylist() # Connected components keep alternate meshes generated from shared inputs together. parent = {} def find(x): parent.setdefault(x, x) if parent[x] != x: parent[x] = find(parent[x]) return parent[x] candidates = [] for row in rows: if row["license"] != "cc-by-4.0" or "hunyuan" in (row["model"] or "").lower(): continue assets = (row.get("inputs") or {}).get("assets") or [] keys = ["input:" + (a.get("sha256") or a["path"]) for a in assets if a.get("sha256") or a.get("path")] keys += ["output:" + o["sha256"] for o in row["outputs"] if o.get("sha256")] keys += [row["id"]] for key in keys[1:]: parent[find(key)] = find(keys[0]) for output in row["outputs"]: if output.get("format") == "glb": candidates.append({"repo": repo, "revision": revision, "path": output["path"], "sha256": output.get("sha256"), "asset_id": row["id"], "license": "cc-by-4.0", "author": "Scenario", "title": row["id"], "source_model": row["model"], "source_url": f"https://huggingface.co/datasets/{repo}", "group_key": keys[0]}) break for row in candidates: row["object_id"] = "scenario-" + hashlib.sha256(find(row.pop("group_key")).encode()).hexdigest()[:20] candidates.sort(key=lambda r: r["asset_id"]) random.Random(seed).shuffle(candidates) return candidates def objaverse_candidates(cache, seed, shards): repo, revision = OBJAVERSE candidates = [] for shard in range(shards): part = f"000-{shard:03d}" path = hf_hub_download(repo, f"metadata/{part}.json.gz", repo_type="dataset", revision=revision, local_dir=Path(cache) / "metadata/objaverse") with gzip.open(path) as f: records = json.load(f) for uid, row in records.items(): # Start with the more conservative CC0 subset; no NC/SA/unknown assets. if row.get("license") != "cc0" or row.get("isAgeRestricted") or row.get("animationCount", 0): continue candidates.append({"repo": repo, "revision": revision, "path": f"glbs/{part}/{uid}.glb", "asset_id": uid, "object_id": "objaverse-" + uid, "license": "cc0-1.0", "author": row.get("user", {}).get("displayName", "Unknown"), "title": row.get("name", uid), "source_url": row.get("viewerUrl", f"https://sketchfab.com/3d-models/{uid}")}) candidates.sort(key=lambda r: r["asset_id"]) random.Random(seed).shuffle(candidates) return candidates def mesh_splats(path, count, grid, seed): scene = trimesh.load_scene(path, process=False) meshes = [] for node in scene.graph.nodes_geometry: matrix, name = scene.graph[node] mesh = scene.geometry[name].copy() if not isinstance(mesh, trimesh.Trimesh) or not len(mesh.faces): continue mesh.apply_transform(matrix) meshes.append(mesh) if not meshes or sum(len(m.faces) for m in meshes) > 500_000: raise ValueError("Missing mesh or more than 500,000 triangles") bounds = np.array([m.bounds for m in meshes]) low, high = bounds[:, 0].min(axis=0), bounds[:, 1].max(axis=0) extent = float((high - low).max()) if not np.isfinite(bounds).all() or extent < 1e-8: raise ValueError("Degenerate mesh") center = (low + high) / 2 factor = .9 / extent areas = np.array([m.area for m in meshes]) if not np.isfinite(areas).all() or areas.sum() <= 0: raise ValueError("Invalid surface area") counts = np.random.default_rng(seed).multinomial(count, areas / areas.sum()) points, colours = [], [] for mesh, n in zip(meshes, counts): if not n: continue xyz, faces = trimesh.sample.sample_surface(mesh, int(n), seed=seed) bary = trimesh.triangles.points_to_barycentric(mesh.triangles[faces], xyz) vis = mesh.visual if vis.kind == "texture" and vis.uv is not None: uv = (vis.uv[mesh.faces[faces]] * bary[:, :, None]).sum(axis=1) sampled = np.asarray(vis.material.to_color(uv)) if sampled.ndim == 0: raise ValueError("Material has no usable texture or base colour") # Untextured PBR materials can return one constant RGBA value. if sampled.ndim == 1: sampled = np.broadcast_to(sampled, (n, len(sampled))) colour = sampled[:, :3] / 255. elif vis.kind == "vertex": colour = (vis.vertex_colors[mesh.faces[faces], :3] * bary[:, :, None]).sum(axis=1) / 255. elif vis.kind == "face": colour = vis.face_colors[faces, :3] / 255. else: colour = np.full((n, 3), .65) points.append((xyz - center) * factor) colours.append(colour) xyz = np.concatenate(points).astype(np.float32) rgb = np.concatenate(colours).astype(np.float32) n = len(xyz) splats = Splats(xyz, rgb, np.full((n, 3), .6 / grid), np.tile([1, 0, 0, 0], (n, 1)), np.full((n, 1), .9)) transform = np.eye(4) transform[:3, :3] *= factor transform[:3, 3] = -center * factor return splats, transform.tolist() def save_progress(out, args, rows, provenance, failures): state = {"version": 1, "settings": vars(args), "rows": rows, "sources": provenance, "skipped": failures} temporary = out / "progress.json.tmp" temporary.write_text(json.dumps(state)) temporary.replace(out / "progress.json") def check_resume_settings(previous, args): for key in ("grid", "views", "surface_points", "seed", "scenario", "objaverse"): if previous[key] != getattr(args, key): raise ValueError(f"Cannot change {key} while resuming; use a new dataset directory") def recover_legacy(out, args): """Recover the old writer's completed files without rendering or downloading. Old runs saved sources and arrays, but lost manifest rows on insufficient candidates. Recreate those rows and verify targets against cached GLBs. """ previous = json.loads((out / "preparation.json").read_text())["settings"] check_resume_settings(previous, args) provenance = json.loads((out / "sources.json").read_text()) failures = json.loads((out / "skipped.json").read_text()) rows = [] for index, item in enumerate(provenance): path = Path(args.cache) / item["repo"].replace("/", "--") / item["revision"] / item["path"] if not path.is_file() or digest(path) != item["sha256"]: raise ValueError(f"Missing or corrupt cached source for {item['asset_id']}") splats, transform = mesh_splats(path, args.surface_points, args.grid, int(item["sha256"][:8], 16) ^ args.seed) stem = hashlib.sha256((item["repo"] + item["asset_id"]).encode()).hexdigest()[:20] for view in range(args.views): sample_id = f"{stem}-v{view}" image, target = f"images/{sample_id}.png", f"targets/{sample_id}.npz" yaw = 2 * np.pi * view / args.views c, s = np.cos(yaw), np.sin(yaw) rotation = np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]], np.float32) rotated = Splats(splats.xyz @ rotation.T, splats.rgb, splats.scale, splats.quat, splats.opacity) expected = splat_targets(rotated, args.grid) with np.load(out / target, allow_pickle=False) as cached: if set(cached.files) != set(expected): raise ValueError(f"Target fields differ for {sample_id}") for key, value in expected.items(): if not np.array_equal(cached[key], value): raise ValueError(f"Recovered target differs for {sample_id}: {key}") from PIL import Image with Image.open(out / image) as check: check.verify() rows.append({"id": sample_id, "image": image, "target": target, "source": "mesh-proxy:" + item["repo"], "grid": args.grid, "teachers": {"appearance": "mesh-proxy:" + item["repo"], "coarse": None, "fine": None}, "teacher_gaussians": len(splats.xyz), "retained_gaussians": int(expected["occ"].sum()), "geometry_transforms": {"coarse": None, "fine": None}, "image_sha256": digest(out / image), "target_sha256": digest(out / target), "metadata": {**item, "normalization_matrix": transform, "yaw_radians": float(yaw), "render": "orthographic Gaussian proxy, camera +z", "target": "isotropic surface-sampled mesh proxy; not a pretrained teacher"}}) if (index + 1) % 10 == 0 or index + 1 == len(provenance): print(json.dumps({"recovered_objects": index + 1, "existing_objects": len(provenance)}), flush=True) save_progress(out, args, rows, provenance, failures) return rows, provenance, failures def load_progress(out, args): if not (out / "progress.json").exists(): return recover_legacy(out, args) state = json.loads((out / "progress.json").read_text()) if state.get("version") != 1: raise ValueError("Unsupported preparation progress version") check_resume_settings(state["settings"], args) for row in state["rows"]: for key in ("image", "target"): path = (out / row[key]).resolve() if not path.is_relative_to(out.resolve()) or digest(path) != row[key + "_sha256"]: raise ValueError(f"Prepared file checksum mismatch: {row['id']} {key}") return state["rows"], state["sources"], state["skipped"] def prune_invisible(out, rows, provenance, failures): from PIL import Image bad = set() for row in rows: with Image.open(out / row["image"]) as image: if image.convert("RGB").getbbox() is None: bad.add((row["metadata"]["repo"], row["metadata"]["asset_id"])) for repo, asset in sorted(bad): failures.append({"repo": repo, "asset": asset, "error": "Rendered view has no visible pixels", "terminal": True}) if bad: print(json.dumps({"excluded_invisible_objects": len(bad), "existing_files_preserved": True}), flush=True) return ([r for r in rows if (r["metadata"]["repo"], r["metadata"]["asset_id"]) not in bad], [p for p in provenance if (p["repo"], p["asset_id"]) not in bad]) def group_duplicates(rows, provenance): """Link entire objects when any views or target files are byte-identical.""" parent = {} def find(x): parent.setdefault(x, x) if parent[x] != x: parent[x] = find(parent[x]) return parent[x] hashes = {} for row in rows: meta = row["metadata"] original = meta.setdefault("source_object_id", meta["object_id"]) find(original) for field in ("image_sha256", "target_sha256"): key = (field, row[field]) if key in hashes: a, b = find(original), find(hashes[key]) parent[max(a, b)] = min(a, b) else: hashes[key] = original for row in rows: row["metadata"]["object_id"] = find(row["metadata"]["source_object_id"]) for item in provenance: original = item.setdefault("source_object_id", item["object_id"]) item["object_id"] = find(original) def prepare(args): out = Path(args.out) occupied = out.exists() and any(out.iterdir()) if occupied and not getattr(args, "resume", False): raise ValueError("Output is not empty. Use --resume to retain completed objects.") if args.scenario + args.objaverse < 4: raise ValueError("Request at least four objects") out.mkdir(parents=True, exist_ok=True) if occupied: all_rows, provenance, failures = load_progress(out, args) else: all_rows, provenance, failures = [], [], [] save_progress(out, args, all_rows, provenance, failures) all_rows, provenance = prune_invisible(out, all_rows, provenance, failures) save_progress(out, args, all_rows, provenance, failures) seen = {item["sha256"] for item in provenance} completed = {(item["repo"], item["asset_id"]) for item in provenance} rejected = {(f["repo"], f["asset"]) for f in failures if f.get("terminal")} counts = {} fetch = Downloader(args.cache, args.max_file_mb, args.max_download_mb, args.reserve_gb) sources = [] if args.scenario: ready = sum(item["repo"] == SCENARIO[0] for item in provenance) candidates = scenario_candidates(args.cache, args.seed) if ready < args.scenario else [] sources.append((SCENARIO[0], candidates, args.scenario, ready)) if args.objaverse: ready = sum(item["repo"] == OBJAVERSE[0] for item in provenance) candidates = objaverse_candidates(args.cache, args.seed, args.objaverse_shards) if ready < args.objaverse else [] sources.append((OBJAVERSE[0], candidates, args.objaverse, ready)) for repo, candidates, wanted, accepted in sources: for item in candidates: if accepted >= wanted: break if (item["repo"], item["asset_id"]) in completed | rejected: continue staging = out / "_staging" try: path = fetch.get(item["repo"], item["revision"], item["path"], item.get("sha256")) checksum = digest(path) if checksum in seen: continue item["sha256"] = checksum item_seed = int(checksum[:8], 16) ^ args.seed splats, transform = mesh_splats(path, args.surface_points, args.grid, item_seed) stem = hashlib.sha256((item["repo"] + item["asset_id"]).encode()).hexdigest()[:20] staging.mkdir(parents=True, exist_ok=True) rows = [] for view in range(args.views): yaw = 2 * np.pi * view / args.views c, s = np.cos(yaw), np.sin(yaw) rotation = np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]], np.float32) rotated = Splats(splats.xyz @ rotation.T, splats.rgb, splats.scale, splats.quat, splats.opacity) image = staging / "input.png" rendered = preview(rotated, size=128) if rendered.getbbox() is None: raise ValueError("Rendered view has no visible pixels") rendered.save(image) metadata = {**item, "object_id": item["object_id"], "normalization_matrix": transform, "yaw_radians": float(yaw), "render": "orthographic Gaussian proxy, camera +z", "target": "isotropic surface-sampled mesh proxy; not a pretrained teacher"} rows.append(cache_sample(staging, f"{stem}-v{view}", image, rotated, args.grid, source="mesh-proxy:" + item["repo"], extra=metadata)) for folder in ("images", "targets"): (out / folder).mkdir(exist_ok=True) for file in (staging / folder).iterdir(): file.replace(out / folder / file.name) all_rows.extend(rows) seen.add(checksum) provenance.append(item) completed.add((item["repo"], item["asset_id"])) accepted += 1 save_progress(out, args, all_rows, provenance, failures) print(json.dumps({"repo": item["repo"], "accepted": accepted, "requested": wanted, "asset": item["asset_id"]}), flush=True) except RuntimeError: raise # resource limits are a hard stop except Exception as exc: failures.append({"repo": item["repo"], "asset": item["asset_id"], "error": type(exc).__name__ + ": " + str(exc)[:200], "terminal": "no visible pixels" in str(exc)}) print(json.dumps({"skipped": item["asset_id"], "reason": failures[-1]["error"]}), flush=True) finally: shutil.rmtree(staging, ignore_errors=True) counts[repo] = {"requested": wanted, "prepared": accepted} group_duplicates(all_rows, provenance) save_progress(out, args, all_rows, provenance, failures) (out / "sources.json").write_text(json.dumps(provenance, indent=2)) (out / "skipped.json").write_text(json.dumps(failures, indent=2)) report = {"counts": counts, "samples": len(all_rows), "objects": len(provenance), "distinct_groups": len({r["metadata"]["object_id"] for r in all_rows}), "settings": vars(args), "rendered_proxy_data": True, "excluded": ["scenario-labs/3d-collection-hunyuan", "3dlg-hcvc/MultiScan", "huanngzh/3D-Front", "facebook/show3d-dataset", "Voxel51/gaussian_splatting"]} (out / "preparation.json").write_text(json.dumps(report, indent=2)) if any(v["prepared"] < v["requested"] for v in counts.values()): raise ValueError("Not enough eligible assets. Completed objects are saved. Rerun with --resume and a larger --objaverse-shards value.") if report["distinct_groups"] < 4: raise ValueError("Not enough independent object groups") # The test partition never enters training or checkpoint selection. development, test = split_rows(all_rows, .15, args.seed + 1) for name, rows in [("manifest.jsonl", development), ("test-manifest.jsonl", test)]: (out / name).write_text("".join(json.dumps(r) + "\n" for r in rows)) print(json.dumps(report, indent=2)) def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument("--out", required=True) p.add_argument("--cache", default="data/downloads") p.add_argument("--scenario", type=int, default=64) p.add_argument("--objaverse", type=int, default=32) p.add_argument("--objaverse-shards", type=int, default=8) p.add_argument("--resume", action="store_true", help="Reuse and verify completed objects, including older failed runs") p.add_argument("--grid", type=int, choices=[8, 16, 32], default=16) p.add_argument("--views", type=int, default=4) p.add_argument("--surface-points", type=int, default=4096) p.add_argument("--seed", type=int, default=42) p.add_argument("--max-file-mb", type=int, default=16) p.add_argument("--max-download-mb", type=int, default=2048) p.add_argument("--reserve-gb", type=float, default=2) args = p.parse_args() if min(args.scenario, args.objaverse) < 0 or not 1 <= args.objaverse_shards <= 160: p.error("Invalid object counts or shard count") if min(args.views, args.surface_points, args.max_file_mb, args.max_download_mb, args.reserve_gb) <= 0: p.error("Counts and limits must be positive") prepare(args) if __name__ == "__main__": main()