File size: 8,108 Bytes
59a5482
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Build the object catalog from NVIDIA SimReady Central assets on the Hugging Face Hub.

For every asset in the dataset that a robot arm could handle (mass <= MAX_KG), writes
  objects/<id>.glb      the decimated visual geometry, metres, z up
and objects/catalog.js, the list app.js searches: name, class, description, mass,
bounding-box size and a link to the asset's thumbnail and page on the Hub.

Mass, semantic class and description come from each asset's payloads/base.usda; the size
and the geometry come from composing the asset's root USD with usd-core.

  uv pip install usd-core huggingface_hub trimesh fast-simplification numpy
  python tools/build_objects.py
  for f in objects/*.glb; do npx @gltf-transform/cli optimize $f $f --compress meshopt --simplify false \\
      --texture-compress false; done
"""
import json
import re
import sys
from collections import Counter, defaultdict
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import fast_simplification
import numpy as np
import trimesh
from huggingface_hub import HfApi, hf_hub_download
from pxr import Usd, UsdGeom

REPO = "nvidia/simready-assets"
MAX_KG = 35.0                 # heaviest object the heaviest catalog robot can lift
MAX_GEOMETRY = 40e6           # skip huge scans
FACES = 2500
OUT = Path(__file__).resolve().parent.parent / "objects"
HUB = f"https://huggingface.co/datasets/{REPO}"


def assets():
    by = defaultdict(dict)
    for f in HfApi().list_repo_tree(REPO, repo_type="dataset", recursive=True):
        if hasattr(f, "size"):
            by[f.path.split("/")[0]][f.path] = f.size
    out = []
    for name, files in sorted(by.items()):
        roots = [p for p in files if p.count("/") == 2 and p.split("/")[1] == "simready_usd" and p.endswith(".usd")]
        base = [p for p in files if p.endswith("payloads/base.usda")]
        if not roots or not base:
            continue
        root = roots[0]
        thumb = root.replace("simready_usd/", "simready_usd/.thumbs/256x256/") + "_transparent.png"
        geo = sum(s for p, s in files.items() if p.endswith(".usd") and "/payloads/" in p)
        out.append({"id": name, "root": root, "base": base[0], "thumb": thumb if thumb in files else None, "geo": geo,
                    "files": [p for p in files if "/simready_usd/" in p and "/Textures/" not in p and "/source_assets/" not in p
                              and "/.thumbs/" not in p and not p.endswith((".png", ".jpg", ".mdl"))]})
    return out


def metadata(a):
    text = Path(hf_hub_download(REPO, a["base"], repo_type="dataset")).read_text(errors="ignore")
    mass = sum(float(m) for m in re.findall(r"pxr:usd:physics_mass = ([0-9.eE+-]+)", text))
    if not mass:
        m = re.search(r"physics:mass = ([0-9.eE+-]+)", text)
        mass = float(m.group(1)) if m else None
    cls = re.search(r'semantics:labels:wikidata_class = \["([^"]+)"', text)
    doc = re.search(r'omni:simready:documentation = "([^"]*)"', text)
    kind = re.search(r'string type = "([^"]+)"', text)
    return {"mass": mass, "cls": cls.group(1) if cls else None, "doc": doc.group(1) if doc else "", "type": kind.group(1) if kind else None}


def pretty(name):
    words = re.sub(r"_[a-z]\d\d$", "", name).replace("_", " ")
    words = re.sub(r"([a-z])([A-Z])", r"\1 \2", words).lower()
    return words[:1].upper() + words[1:]


def geometry(a):
    for p in a["files"]:
        hf_hub_download(REPO, p, repo_type="dataset")
    root = hf_hub_download(REPO, a["root"], repo_type="dataset")
    stage = Usd.Stage.Open(root)
    up = UsdGeom.GetStageUpAxis(stage)
    scale = UsdGeom.GetStageMetersPerUnit(stage) or 1.0
    cache = UsdGeom.XformCache()
    parts = []
    for prim in Usd.PrimRange.Stage(stage, Usd.TraverseInstanceProxies()):
        if not prim.IsA(UsdGeom.Mesh) or UsdGeom.Imageable(prim).ComputePurpose() not in (UsdGeom.Tokens.default_, UsdGeom.Tokens.render):
            continue
        mesh = UsdGeom.Mesh(prim)
        pts, counts, idx = mesh.GetPointsAttr().Get(), mesh.GetFaceVertexCountsAttr().Get(), mesh.GetFaceVertexIndicesAttr().Get()
        if not pts or not counts:
            continue
        pts = np.asarray(pts, float)
        M = np.asarray(cache.GetLocalToWorldTransform(prim), float)        # row-vector convention
        pts = (np.c_[pts, np.ones(len(pts))] @ M)[:, :3]
        faces, k = [], 0
        idx = np.asarray(idx)
        for c in counts:
            faces += [[idx[k], idx[k + i], idx[k + i + 1]] for i in range(1, c - 1)]
            k += c
        color = mesh.GetDisplayColorAttr().Get()
        parts.append((pts, np.asarray(faces, int), tuple(color[0]) if color else None))
    if not parts:
        return None
    meshes = []
    total = sum(len(f) for _, f, _ in parts)
    for pts, faces, color in parts:
        target = max(60, int(FACES * len(faces) / total))
        if len(faces) > target:
            pts, faces = fast_simplification.simplify(pts.astype(np.float32), faces.astype(np.int32), 1 - target / len(faces))
        m = trimesh.Trimesh(pts * scale, faces, process=True)
        rgba = (np.r_[color, 1] if color else np.array([0.78, 0.74, 0.68, 1])) * 255
        m.visual = trimesh.visual.TextureVisuals(material=trimesh.visual.material.PBRMaterial(baseColorFactor=rgba.astype(np.uint8), roughnessFactor=0.7))
        meshes.append(m)
    scene = trimesh.Scene(meshes)
    if up == "Y":
        scene.apply_transform(trimesh.transformations.rotation_matrix(np.pi / 2, [1, 0, 0]))
    lo, hi = scene.bounds
    scene.apply_translation([-(lo[0] + hi[0]) / 2, -(lo[1] + hi[1]) / 2, -lo[2]])    # centred, resting on z = 0
    return scene


def build(a):
    try:
        meta = metadata(a)
    except Exception as e:
        return a["id"], None, f"metadata: {e!r}"
    if meta["mass"] is None or meta["mass"] > MAX_KG or a["geo"] > MAX_GEOMETRY:
        return a["id"], None, "skipped"
    try:
        scene = geometry(a)
    except Exception as e:
        return a["id"], None, f"geometry: {e!r}"
    if scene is None:
        return a["id"], None, "no mesh"
    dims = scene.extents
    if max(dims) > 3:                      # mis-scaled asset
        return a["id"], None, f"size {dims}"
    (OUT / f"{a['id']}.glb").write_bytes(trimesh.exchange.gltf.export_glb(scene))
    variant = re.search(r"_([a-z]\d\d)$", a["id"])
    name = (meta["cls"][:1].upper() + meta["cls"][1:]) if meta["cls"] else pretty(a["id"])
    item = {"id": a["id"], "name": name + (f" ({variant.group(1)})" if variant else ""), "cls": meta["cls"], "doc": meta["doc"][:160], "kg": round(meta["mass"], 4),
            "size": [round(float(x), 4) for x in dims], "thumb": a["thumb"]}
    return a["id"], item, "ok"


def dedupe(items):
    """Several folders share a class (three "Bottle (a01)"): name those after their folder instead."""
    count = Counter(i["name"] for i in items)
    for i in items:
        if count[i["name"]] > 1:
            variant = re.search(r"_([a-z]\d\d)$", i["id"])
            i["name"] = pretty(i["id"]) + (f" ({variant.group(1)})" if variant else "")


def main():
    OUT.mkdir(exist_ok=True)
    todo = assets()
    if len(sys.argv) > 1:
        todo = [a for a in todo if a["id"] in sys.argv[1:]]
    print(f"{len(todo)} assets", flush=True)
    items, stats = [], defaultdict(int)
    with ThreadPoolExecutor(12) as pool:
        for i, (aid, item, status) in enumerate(pool.map(build, todo)):
            stats[status.split(":")[0]] += 1
            if item:
                items.append(item)
            elif status != "skipped":
                print(aid, status, flush=True)
            if i % 100 == 0:
                print(i, dict(stats), flush=True)
    dedupe(items)
    items.sort(key=lambda x: x["name"])
    (OUT / "catalog.js").write_text(f"// Generated by tools/build_objects.py from {HUB} (CC-BY-4.0).\n"
                                    f"const OBJECTS_SOURCE = {json.dumps(HUB)};\nconst OBJECTS = "
                                    + json.dumps(items, separators=(",", ":")) + ";\n")
    print(len(items), "objects", dict(stats))


if __name__ == "__main__":
    main()