CoolFace
Apppublic

HorizonRobotics/EmbodiedGen-Gallery-Explorer

sourceHugging Faceapache-2.0updated 21d agoView on Hugging Face
1likes
app.py1148 linesDownload Raw Back to root
1# Project EmbodiedGen2#3# Copyright (c) 2025 Horizon Robotics. All Rights Reserved.4#5# Licensed under the Apache License, Version 2.0 (the "License");6# you may not use this file except in compliance with the License.7# You may obtain a copy of the License at8#9#       http://www.apache.org/licenses/LICENSE-2.010#11# Unless required by applicable law or agreed to in writing, software12# distributed under the License is distributed on an "AS IS" BASIS,13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or14# implied. See the License for the specific language governing15# permissions and limitations under the License.16 17 18import os19 20gradio_tmp_dir = os.path.join(21    os.path.dirname(os.path.abspath(__file__)), "gradio_cache"22)23os.makedirs(gradio_tmp_dir, exist_ok=True)24os.environ["GRADIO_TEMP_DIR"] = gradio_tmp_dir25 26import colorsys27import shutil28import uuid29import xml.etree.ElementTree as ET30import zipfile31from pathlib import Path32 33import gradio as gr34import numpy as np35import pandas as pd36import trimesh37from app_style import custom_theme38from embodied_gen.utils.tags import VERSION39 40try:41    from embodied_gen.utils.gpt_clients import GPT_CLIENT as gpt_client42 43    gpt_client.check_connection()44    GPT_AVAILABLE = True45except Exception as e:46    gpt_client = None47    GPT_AVAILABLE = False48    print(49        f"Warning: GPT client could not be initialized. Search will be disabled. Error: {e}"50    )51 52 53# --- Configuration & Data Loading ---54RUNNING_MODE = "hf_remote"  # local or hf_remote55CSV_FILE = "dataset_index.csv"56HF_REPO_ID = "HorizonRobotics/EmbodiedGenData"57HF_LOCAL_DIR = "EmbodiedGenData"58CAMERA_ZOOM = 3.259# Gradio's default per-event concurrency limit is 1, which would make every60# user's HF snapshot_download (potentially several seconds) serialize behind61# whichever user clicked first. Give the HF-fetching events their own shared62# pool so several users can download/preview assets in parallel, bounded to63# avoid hammering the HF API with too many simultaneous requests.64HF_IO_CONCURRENCY_ID = "hf_asset_io"65HF_IO_CONCURRENCY_LIMIT = 666 67# Compatible with huggingface space zero GPU68import spaces69from huggingface_hub import snapshot_download70 71 72@spaces.GPU73def fake_gpu_init():74    pass75 76 77fake_gpu_init()78 79if RUNNING_MODE == "local":80    DATA_ROOT = "/horizon-bucket/robot_lab/datasets/embodiedgen/assets_v2"81elif RUNNING_MODE == "hf_remote":82    # Only fetch the index and preview videos up front; per-asset mesh/urdf/83    # usd/mjcf files are pulled lazily on demand (see `ensure_hf_files`).84    snapshot_download(85        repo_id=HF_REPO_ID,86        repo_type="dataset",87        allow_patterns=[88            f"dataset/{CSV_FILE}",89            "dataset/**/*.mp4",90        ],91        local_dir=HF_LOCAL_DIR,92    )93    DATA_ROOT = os.path.join(HF_LOCAL_DIR, "dataset")94else:95    raise ValueError(96        f"Unknown RUNNING_MODE: {RUNNING_MODE}, must be 'local' or 'hf_remote'."97    )98 99 100def ensure_hf_files(rel_patterns: list[str]) -> None:101    """Lazily download files for one asset in hf_remote mode.102 103    Args:104        rel_patterns: Glob patterns relative to ``DATA_ROOT`` (the repo's105            ``dataset/`` folder), e.g. ``"cat/sub/uid/mesh/**"``.106 107    No-op when running locally, where all files already exist on disk.108    Progress toasts are emitted by the calling action handlers.109    """110    if RUNNING_MODE != "hf_remote":111        return112    snapshot_download(113        repo_id=HF_REPO_ID,114        repo_type="dataset",115        allow_patterns=[f"dataset/{p}" for p in rel_patterns],116        local_dir=HF_LOCAL_DIR,117    )118 119 120csv_path = os.path.join(DATA_ROOT, CSV_FILE)121df = pd.read_csv(csv_path)122TMP_DIR = os.path.join(123    os.path.dirname(os.path.abspath(__file__)), "sessions/asset_viewer"124)125os.makedirs(TMP_DIR, exist_ok=True)126 127 128# --- Custom CSS for Styling ---129css = """130.gradio-container .gradio-group { box-shadow: 0 2px 4px rgba(0,0,0,0.05) !important; }131#asset-gallery { border: 1px solid #E5E7EB; border-radius: 8px; padding: 8px; background-color: #F9FAFB; }132#download-row button { white-space: nowrap; }133"""134 135lighting_css = """136<style>137#visual_mesh canvas { filter: brightness(2.2) !important; }138#collision_mesh_a canvas, #collision_mesh_b canvas { filter: brightness(1.0) !important; }139</style>140"""141 142_prev_temp = {}143 144 145def _unique_path(146    src_path: str | None, session_hash: str, kind: str147) -> str | None:148    """Link/copy src to GRADIO_TEMP_DIR/session_hash with random filename. Always return a fresh URL."""149    if not src_path:150        return None151    tmp_root = (152        Path(os.environ.get("GRADIO_TEMP_DIR", "/tmp"))153        / "model3d-cache"154        / session_hash155    )156    tmp_root.mkdir(parents=True, exist_ok=True)157 158    # rolling cleanup for same kind159    prev = _prev_temp.get(session_hash, {})160    old = prev.get(kind)161    if old and old.exists():162        old.unlink()163 164    ext = Path(src_path).suffix or ".glb"165    dst = tmp_root / f"{kind}-{uuid.uuid4().hex}{ext}"166    shutil.copy2(src_path, dst)167 168    prev[kind] = dst169    _prev_temp[session_hash] = prev170    return str(dst)171 172 173def _bounding_radius(mesh: trimesh.Trimesh | trimesh.Scene) -> float:174    """Radius of the mesh/scene bounding sphere (half the diagonal)."""175    lo, hi = mesh.bounds176    return float(np.linalg.norm(np.asarray(hi) - np.asarray(lo)) / 2)177 178 179def _camera_position(radius: float | None) -> tuple:180    """Initial camera `(alpha, beta, radius)`; only push the distance."""181    if not radius or radius <= 0:182        return (None, None, None)183    return (None, None, CAMERA_ZOOM * radius)184 185 186def _visual_radius(visual_path: str | None) -> float | None:187    if not visual_path:188        return None189    return _bounding_radius(trimesh.load(visual_path, process=False))190 191 192def _pastel_color(i: int) -> list[int]:193    """Distinct pastel RGBA for the i-th convex piece (golden-ratio hues)."""194    h = (i * 0.6180339887498949) % 1.0195    r, g, b = colorsys.hsv_to_rgb(h, 0.45, 0.98)196    return [int(r * 255), int(g * 255), int(b * 255), 255]197 198 199def _colored_collision_path(200    collision_path: str | None, session_hash: str201) -> tuple[str | None, float | None]:202    """Export the collision mesh as a GLB with one color per convex piece.203 204    The convex-decomposition pieces are stored as separate objects inside the205    single collision ``.obj``; they are recovered via connected-component split206    and each is tinted a distinct pastel color for visualization.207 208    Returns ``(glb_path, bounding_radius)`` for camera framing.209    """210    if not collision_path:211        return None, None212 213    tmp_root = (214        Path(os.environ.get("GRADIO_TEMP_DIR", "/tmp"))215        / "model3d-cache"216        / session_hash217    )218    tmp_root.mkdir(parents=True, exist_ok=True)219 220    # rolling cleanup for same kind221    prev = _prev_temp.get(session_hash, {})222    old = prev.get("collision")223    if old and old.exists():224        old.unlink()225 226    loaded = trimesh.load(collision_path, process=False)227    if isinstance(loaded, trimesh.Scene):228        parts = list(loaded.geometry.values())229    else:230        parts = loaded.split(only_watertight=False)231        if len(parts) == 0:232            parts = [loaded]233 234    scene = trimesh.Scene()235    for i, part in enumerate(parts):236        part.visual = trimesh.visual.ColorVisuals(237            mesh=part, vertex_colors=_pastel_color(i)238        )239        scene.add_geometry(part)240 241    dst = tmp_root / f"collision-{uuid.uuid4().hex}.glb"242    scene.export(str(dst))243 244    prev["collision"] = dst245    _prev_temp[session_hash] = prev246    return str(dst), _bounding_radius(scene)247 248 249# --- Helper Functions (data filtering) ---250def get_primary_categories():251    return sorted(df["primary_category"].dropna().unique())252 253 254def get_secondary_categories(primary):255    if not primary:256        return []257    return sorted(258        df[df["primary_category"] == primary]["secondary_category"]259        .dropna()260        .unique()261    )262 263 264def get_categories(primary, secondary):265    if not primary or not secondary:266        return []267    return sorted(268        df[269            (df["primary_category"] == primary)270            & (df["secondary_category"] == secondary)271        ]["category"]272        .dropna()273        .unique()274    )275 276 277def get_assets(primary, secondary, category):278    if not primary or not secondary:279        return [], gr.update(interactive=False), pd.DataFrame()280 281    subset = df[282        (df["primary_category"] == primary)283        & (df["secondary_category"] == secondary)284    ]285    if category:286        subset = subset[subset["category"] == category]287 288    items = []289    for row in subset.itertuples():290        asset_dir = os.path.join(DATA_ROOT, row.asset_dir)291        video_path = None292        if pd.notna(asset_dir) and os.path.exists(asset_dir):293            for f in os.listdir(asset_dir):294                if f.lower().endswith(".mp4"):295                    video_path = os.path.join(asset_dir, f)296                    break297        items.append(298            video_path299            if video_path300            else "https://dummyimage.com/512x512/cccccc/000000&text=No+Preview"301        )302 303    return items, gr.update(interactive=True), subset304 305 306def search_assets(query: str, top_k: int):307    if not GPT_AVAILABLE or not query:308        gr.Warning(309            "GPT client is not available or query is empty. Cannot perform search."310        )311        return [], gr.update(interactive=False), pd.DataFrame()312 313    gr.Info(f"Searching for assets matching: '{query}'...")314 315    keywords = query.split()316    keyword_filter = pd.Series([False] * len(df), index=df.index)317    for keyword in keywords:318        keyword_filter |= df['description'].str.contains(319            keyword, case=False, na=False320        )321 322    candidates = df[keyword_filter]323 324    if len(candidates) > 100:325        candidates = candidates.head(100)326 327    if candidates.empty:328        gr.Warning("No assets found matching the keywords.")329        return [], gr.update(interactive=True), pd.DataFrame()330 331    try:332        descriptions = [333            f"{idx}: {desc}" for idx, desc in candidates['description'].items()334        ]335        descriptions_text = "\n".join(descriptions)336 337        prompt = f"""338        A user is searching for 3D assets with the query: "{query}".339        Below is a list of available assets, each with an ID and a description.340        Please evaluate how well each asset description matches the user's query and rate them on a scale from 0 to 10, where 10 is a perfect match.341 342        Your task is to return a list of the top {top_k} asset IDs, sorted from the most relevant to the least relevant.343        The output format must be a simple comma-separated list of IDs, for example: "123,45,678". Do not add any other text.344 345        Asset Descriptions:346        {descriptions_text}347 348        User Query: "{query}"349 350        Top {top_k} sorted asset IDs:351        """352        response = gpt_client.query(prompt)353        sorted_ids_str = response.strip().split(',')354        sorted_ids = [355            int(id_str.strip())356            for id_str in sorted_ids_str357            if id_str.strip().isdigit()358        ]359        top_assets = df.loc[sorted_ids].head(top_k)360    except Exception as e:361        gr.Error(f"An error occurred while using GPT for ranking: {e}")362        top_assets = candidates.head(top_k)363 364    items = []365    for row in top_assets.itertuples():366        asset_dir = os.path.join(DATA_ROOT, row.asset_dir)367        video_path = None368        if pd.notna(row.asset_dir) and os.path.exists(asset_dir):369            for f in os.listdir(asset_dir):370                if f.lower().endswith(".mp4"):371                    video_path = os.path.join(asset_dir, f)372                    break373        items.append(374            video_path375            if video_path376            else "https://dummyimage.com/512x512/cccccc/000000&text=No+Preview"377        )378 379    gr.Info(f"Found {len(items)} assets.")380    return items, gr.update(interactive=True), top_assets381 382 383def _extract_mesh_paths(row) -> tuple[str | None, str | None, str]:384    desc = row["description"]385    urdf_path = os.path.join(DATA_ROOT, row["urdf_path"])386    asset_dir = os.path.join(DATA_ROOT, row["asset_dir"])387    visual_mesh_path = None388    collision_mesh_path = None389 390    if pd.notna(urdf_path) and os.path.exists(urdf_path):391        try:392            tree = ET.parse(urdf_path)393            root = tree.getroot()394 395            visual_mesh_element = root.find('.//visual/geometry/mesh')396            if visual_mesh_element is not None:397                visual_mesh_filename = visual_mesh_element.get('filename')398                if visual_mesh_filename:399                    glb_filename = (400                        os.path.splitext(visual_mesh_filename)[0] + ".glb"401                    )402                    potential_path = os.path.join(asset_dir, glb_filename)403                    if os.path.exists(potential_path):404                        visual_mesh_path = potential_path405 406            collision_mesh_element = root.find('.//collision/geometry/mesh')407            if collision_mesh_element is not None:408                collision_mesh_filename = collision_mesh_element.get(409                    'filename'410                )411                if collision_mesh_filename:412                    potential_collision_path = os.path.join(413                        asset_dir, collision_mesh_filename414                    )415                    if os.path.exists(potential_collision_path):416                        collision_mesh_path = potential_collision_path417 418        except ET.ParseError:419            desc = f"Error: Failed to parse URDF at {urdf_path}. {desc}"420        except Exception as e:421            desc = f"An error occurred while processing URDF: {str(e)}. {desc}"422 423    return visual_mesh_path, collision_mesh_path, desc424 425 426def show_asset_from_gallery(427    evt: gr.SelectData,428    primary: str,429    secondary: str,430    category: str,431    search_query: str,432    gallery_df: pd.DataFrame,433):434    """Parse the selected asset and return raw paths + metadata."""435    index = evt.index436 437    if search_query and gallery_df is not None and not gallery_df.empty:438        subset = gallery_df439    else:440        if not primary or not secondary:441            return (442                None,  # visual_path443                None,  # collision_path444                "Error: Primary or secondary category not selected.",445                None,  # asset_dir446                None,  # urdf_path447                "N/A",448                "N/A",449                "N/A",450                "N/A",451            )452 453        subset = df[454            (df["primary_category"] == primary)455            & (df["secondary_category"] == secondary)456        ]457        if category:458            subset = subset[subset["category"] == category]459 460    if subset.empty or index >= len(subset):461        return (462            None,463            None,464            "Error: Selection index is out of bounds or data is missing.",465            None,466            None,467            "N/A",468            "N/A",469            "N/A",470            "N/A",471        )472 473    row = subset.iloc[index]474 475    # In hf_remote mode, pull only what the two mesh viewers need: the visual476    # GLB + collision OBJ (mesh/), plus the tiny URDF used to locate them and477    # show metadata. USD/MJCF/USD-textures are fetched only on button click.478    gr.Info("⏳ Loading 3D model, please wait...")479    rel_dir = row["asset_dir"]480    urdf_name = os.path.basename(row["urdf_path"])481    ensure_hf_files(482        [483            f"{rel_dir}/{urdf_name}",484            f"{rel_dir}/mesh/**",485        ]486    )487 488    visual_path, collision_path, desc = _extract_mesh_paths(row)489 490    urdf_path = os.path.join(DATA_ROOT, row["urdf_path"])491    asset_dir = os.path.join(DATA_ROOT, row["asset_dir"])492 493    # read extra info494    est_type_text = "N/A"495    est_height_text = "N/A"496    est_mass_text = "N/A"497    est_mu_text = "N/A"498 499    if pd.notna(urdf_path) and os.path.exists(urdf_path):500        try:501            tree = ET.parse(urdf_path)502            root = tree.getroot()503            category_elem = root.find('.//extra_info/category')504            if category_elem is not None and category_elem.text:505                est_type_text = category_elem.text.strip()506            height_elem = root.find('.//extra_info/real_height')507            if height_elem is not None and height_elem.text:508                est_height_text = height_elem.text.strip()509            mass_elem = root.find('.//extra_info/min_mass')510            if mass_elem is not None and mass_elem.text:511                est_mass_text = mass_elem.text.strip()512            mu_elem = root.find('.//collision/gazebo/mu2')513            if mu_elem is not None and mu_elem.text:514                est_mu_text = mu_elem.text.strip()515        except Exception:516            pass517 518    return (519        visual_path,520        collision_path,521        desc,522        asset_dir,523        urdf_path,524        est_type_text,525        est_height_text,526        est_mass_text,527        est_mu_text,528    )529 530 531def render_meshes(532    visual_path: str | None,533    collision_path: str | None,534    switch_viewer: bool,535    req: gr.Request,536):537    session_hash = getattr(req, "session_hash", "default")538 539    if switch_viewer:540        yield (541            gr.update(value=None),542            gr.update(value=None, visible=False),543            gr.update(value=None, visible=True),544            True,545        )546    else:547        yield (548            gr.update(value=None),549            gr.update(value=None, visible=True),550            gr.update(value=None, visible=False),551            True,552        )553 554    visual_unique = (555        _unique_path(visual_path, session_hash, "visual")556        if visual_path557        else None558    )559    visual_cam = _camera_position(_visual_radius(visual_path))560 561    if collision_path:562        collision_unique, collision_r = _colored_collision_path(563            collision_path, session_hash564        )565    else:566        collision_unique, collision_r = None, None567    collision_cam = _camera_position(collision_r)568 569    if visual_unique or collision_unique:570        gr.Info("✅ 3D model loaded.")571 572    if switch_viewer:573        yield (574            gr.update(value=visual_unique, camera_position=visual_cam),575            gr.update(value=None, visible=False),576            gr.update(577                value=collision_unique,578                visible=True,579                camera_position=collision_cam,580            ),581            False,582        )583    else:584        yield (585            gr.update(value=visual_unique, camera_position=visual_cam),586            gr.update(587                value=collision_unique,588                visible=True,589                camera_position=collision_cam,590            ),591            gr.update(value=None, visible=False),592            True,593        )594 595 596def _rel_dir(asset_dir: str) -> str:597    """Repo-relative asset dir (path under DATA_ROOT), for HF glob patterns."""598    return os.path.relpath(asset_dir, DATA_ROOT)599 600 601def _find_urdf_stem(asset_dir: str) -> str | None:602    for f in os.listdir(asset_dir):603        if f.lower().endswith(".urdf"):604            return os.path.splitext(f)[0]605    return None606 607 608def _zip_items(609    zip_path: str,610    items: list[tuple[str, str]],611    exclude_suffixes: tuple[str, ...] = (),612) -> str:613    """Write files/dirs into a zip.614 615    Args:616        zip_path: Output archive path.617        items: ``(src_abspath, arcname)`` pairs. Directories are walked and618            stored under ``arcname``.619        exclude_suffixes: Lowercase filename suffixes to skip when walking620            directories, e.g. ``(".glb",)``.621    """622    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:623        for src, arcname in items:624            if os.path.isdir(src):625                for root_d, _, files in os.walk(src):626                    for fn in files:627                        if exclude_suffixes and fn.lower().endswith(628                            exclude_suffixes629                        ):630                            continue631                        fp = os.path.join(root_d, fn)632                        rel = os.path.relpath(fp, src)633                        zf.write(fp, os.path.join(arcname, rel))634            elif os.path.isfile(src):635                zf.write(src, arcname)636    return zip_path637 638 639def _fmt_zip_path(640    asset_dir: str, fmt: str, req: gr.Request641) -> tuple[str, str, str]:642    """Build the per-format output zip path named ``<uid>_<fmt>.zip``.643 644    Returns ``(zip_path, uid, zip_name)``.645    """646    uid = os.path.basename(os.path.normpath(asset_dir))647    user_dir = os.path.join(TMP_DIR, str(req.session_hash))648    os.makedirs(user_dir, exist_ok=True)649    zip_name = f"{uid}_{fmt}.zip"650    return os.path.join(user_dir, zip_name), uid, zip_name651 652 653def download_urdf(asset_dir: str, req: gr.Request) -> str | None:654    """Package the ``.urdf`` file(s) and the ``mesh/`` folder (minus ``.glb``).655 656    The visual/collision meshes referenced by the URDF live in ``mesh/`` as657    ``.obj`` + material files; the ``.glb`` (a separate viewer asset) and all658    other formats (USD/MJCF/video/...) are intentionally excluded.659    """660    if not asset_dir or not os.path.isdir(asset_dir):661        gr.Warning("Please select an asset first.")662        return None663    gr.Info("⏳ Preparing URDF asset for download, please wait...")664    rel = _rel_dir(asset_dir)665    ensure_hf_files([f"{rel}/*.urdf", f"{rel}/mesh/**"])666 667    items: list[tuple[str, str]] = [668        (os.path.join(asset_dir, f), f)669        for f in os.listdir(asset_dir)670        if f.lower().endswith(".urdf")671    ]672    mesh_dir = os.path.join(asset_dir, "mesh")673    if os.path.isdir(mesh_dir):674        items.append((mesh_dir, "mesh"))675 676    zip_path, uid, zip_name = _fmt_zip_path(asset_dir, "urdf", req)677    _zip_items(zip_path, items, exclude_suffixes=(".glb",))678    gr.Info(f"✅ {zip_name} is ready.")679    return zip_path680 681 682def download_usd(asset_dir: str, req: gr.Request) -> str | None:683    """Package the ``.usd`` file together with its ``textures/`` folder."""684    if not asset_dir or not os.path.isdir(asset_dir):685        gr.Warning("Please select an asset first.")686        return None687 688    gr.Info("⏳ Preparing USD asset for download, please wait...")689    rel = _rel_dir(asset_dir)690    stem = _find_urdf_stem(asset_dir)691    if stem is not None:692        ensure_hf_files([f"{rel}/{stem}.usd", f"{rel}/textures/**"])693    else:694        ensure_hf_files([f"{rel}/*.usd", f"{rel}/textures/**"])695 696    items: list[tuple[str, str]] = []697    for f in os.listdir(asset_dir):698        if f.lower().endswith(".usd"):699            items.append((os.path.join(asset_dir, f), f))700    tex_dir = os.path.join(asset_dir, "textures")701    if os.path.isdir(tex_dir):702        items.append((tex_dir, "textures"))703 704    if not any(s.lower().endswith(".usd") for s, _ in items):705        gr.Warning("No USD file available for this asset.")706        return None707 708    zip_path, uid, zip_name = _fmt_zip_path(asset_dir, "usd", req)709    _zip_items(zip_path, items)710    gr.Info(f"✅ {zip_name} is ready.")711    return zip_path712 713 714def download_mjcf(asset_dir: str, req: gr.Request) -> str | None:715    """Package the ``mjcf/`` folder."""716    if not asset_dir or not os.path.isdir(asset_dir):717        gr.Warning("Please select an asset first.")718        return None719 720    gr.Info("⏳ Preparing MJCF asset for download, please wait...")721    ensure_hf_files([f"{_rel_dir(asset_dir)}/mjcf/**"])722    mjcf_dir = os.path.join(asset_dir, "mjcf")723    if not os.path.isdir(mjcf_dir):724        gr.Warning("No MJCF folder available for this asset.")725        return None726 727    zip_path, uid, zip_name = _fmt_zip_path(asset_dir, "mjcf", req)728    _zip_items(zip_path, [(mjcf_dir, "mjcf")])729    gr.Info(f"✅ {zip_name} is ready.")730    return zip_path731 732 733def download_affordance(asset_dir: str, req: gr.Request) -> str | None:734    """Package the ``affordance/`` folder."""735    if not asset_dir or not os.path.isdir(asset_dir):736        gr.Warning("Please select an asset first.")737        return None738 739    gr.Info("⏳ Preparing affordance asset for download, please wait...")740    ensure_hf_files([f"{_rel_dir(asset_dir)}/affordance/**"])741    affordance_dir = os.path.join(asset_dir, "affordance")742    if not os.path.isdir(affordance_dir):743        gr.Warning("No affordance folder available for this asset.")744        return None745 746    zip_path, uid, zip_name = _fmt_zip_path(asset_dir, "affordance", req)747    _zip_items(zip_path, [(affordance_dir, "affordance")])748    gr.Info(f"✅ {zip_name} is ready.")749    return zip_path750 751 752# Download buttons keyed by format; order matches the outputs list used when753# locking/unlocking them together.754_DL_KEYS = ("urdf", "usd", "mjcf", "affordance")755_DL_LABELS = {756    "urdf": "⬇️ Download URDF",757    "usd": "⬇️ Download USD",758    "mjcf": "⬇️ Download MJCF",759    "affordance": "⬇️ Affordance",760}761 762 763def _lock_downloads(active: str) -> tuple:764    """Disable all download buttons; show a persistent hint on the clicked one.765 766    Keeps the user from spamming clicks while the (possibly slow) zip is being767    prepared, since a toast alone neither blocks clicks nor stays put.768    """769    return tuple(770        gr.update(interactive=False, label=f"⏳ Preparing {k.upper()}...")771        if k == active772        else gr.update(interactive=False)773        for k in _DL_KEYS774    )775 776 777def _unlock_downloads() -> tuple:778    """Re-enable buttons and restore labels; keep the freshly built file value."""779    return tuple(780        gr.update(interactive=True, label=_DL_LABELS[k]) for k in _DL_KEYS781    )782 783 784def _reset_downloads() -> tuple:785    """Enable + restore labels + clear stale file value (used on asset switch)."""786    return tuple(787        gr.update(interactive=True, label=_DL_LABELS[k], value=None)788        for k in _DL_KEYS789    )790 791 792def start_session(req: gr.Request) -> None:793    user_dir = os.path.join(TMP_DIR, str(req.session_hash))794    os.makedirs(user_dir, exist_ok=True)795 796 797def end_session(req: gr.Request) -> None:798    user_dir = os.path.join(TMP_DIR, str(req.session_hash))799    if os.path.exists(user_dir):800        shutil.rmtree(user_dir)801 802 803# --- UI ---804with gr.Blocks(805    theme=custom_theme,806    css=css,807    title="3D Asset Library",808) as demo:809    # gr.HTML(lighting_css, visible=False)810    gr.Markdown(811        """812        ## 🏛️ ***EmbodiedGen***: 3D Asset Gallery Explorer813 814        **🔖 Version**: {VERSION}815        <p style="display: flex; gap: 10px; flex-wrap: nowrap;">816            <a href="https://horizonrobotics.github.io/EmbodiedGen">817                <img alt="📖 Documentation" src="https://img.shields.io/badge/📖-Documentation-blue">818            </a>819            <a href="https://arxiv.org/abs/2607.07459">820                <img alt="📄 arXiv" src="https://img.shields.io/badge/📄-arXiv-b31b1b">821            </a>822            <a href="https://github.com/HorizonRobotics/EmbodiedGen">823                <img alt="💻 GitHub" src="https://img.shields.io/badge/GitHub-000000?logo=github">824            </a>825            <a href="https://youtu.be/MIkJJSVM8L4">826                <img alt="🎥 Video" src="https://img.shields.io/badge/🎥-Video-red">827            </a>828        </p>829 830        Browse and visualize the EmbodiedGen 3D asset database. Select categories to filter and click on a preview to load the model.831 832        """.format(VERSION=VERSION),833        elem_classes=["header"],834    )835 836    primary_list = get_primary_categories()837    primary_val = primary_list[0] if primary_list else None838    secondary_list = get_secondary_categories(primary_val)839    secondary_val = secondary_list[0] if secondary_list else None840    category_list = get_categories(primary_val, secondary_val)841    category_val = category_list[0] if category_list else None842    asset_folder = gr.State(value=None)843    gallery_df_state = gr.State()844 845    switch_viewer_state = gr.State(value=False)846 847    with gr.Row(equal_height=False):848        with gr.Column(scale=1, min_width=350):849            with gr.Group():850                gr.Markdown("### Search Asset with Descriptions")851                search_box = gr.Textbox(852                    label="🔎 Enter your search query",853                    placeholder="e.g., 'a red chair with four legs'",854                    interactive=GPT_AVAILABLE,855                )856                top_k_slider = gr.Slider(857                    minimum=1,858                    maximum=50,859                    value=10,860                    step=1,861                    label="Number of results",862                    interactive=GPT_AVAILABLE,863                )864                search_button = gr.Button(865                    "Search", variant="primary", interactive=GPT_AVAILABLE866                )867                if not GPT_AVAILABLE:868                    gr.Markdown(869                        "<p style='color: #ff4b4b;'>⚠️ GPT client not available. Search is disabled.</p>"870                    )871 872            with gr.Group():873                gr.Markdown("### Select Asset Category")874                primary = gr.Dropdown(875                    choices=primary_list,876                    value=primary_val,877                    label="🗂️ Primary Category",878                )879                secondary = gr.Dropdown(880                    choices=secondary_list,881                    value=secondary_val,882                    label="📂 Secondary Category",883                )884                category = gr.Dropdown(885                    choices=category_list,886                    value=category_val,887                    label="🏷️ Asset Category",888                )889 890            with gr.Group():891                initial_assets, _, initial_df = get_assets(892                    primary_val, secondary_val, category_val893                )894                gallery = gr.Gallery(895                    value=initial_assets,896                    label="🖼️ Asset Previews",897                    columns=3,898                    height="auto",899                    allow_preview=True,900                    elem_id="asset-gallery",901                    interactive=bool(category_val),902                )903 904        with gr.Column(scale=2, min_width=500):905            with gr.Group():906                with gr.Tabs():907                    with gr.TabItem("Visual Mesh") as t1:908                        viewer = gr.Model3D(909                            label="🧊 3D Model Viewer",910                            height=380,911                            clear_color=[0.95, 0.95, 0.95],912                            elem_id="visual_mesh",913                        )914                    with gr.TabItem("Collision Mesh") as t2:915                        collision_viewer_a = gr.Model3D(916                            label="🧊 Collision Mesh",917                            height=380,918                            clear_color=[0.95, 0.95, 0.95],919                            elem_id="collision_mesh_a",920                            visible=True,921                        )922                        collision_viewer_b = gr.Model3D(923                            label="🧊 Collision Mesh",924                            height=380,925                            clear_color=[0.95, 0.95, 0.95],926                            elem_id="collision_mesh_b",927                            visible=False,928                        )929 930                t1.select(931                    fn=lambda: None,932                    js="() => { window.dispatchEvent(new Event('resize')); }",933                )934                t2.select(935                    fn=lambda: None,936                    js="() => { window.dispatchEvent(new Event('resize')); }",937                )938 939                with gr.Row():940                    est_type_text = gr.Textbox(941                        label="Asset category", interactive=False942                    )943                    est_height_text = gr.Textbox(944                        label="Real height(.m)", interactive=False945                    )946                    est_mass_text = gr.Textbox(947                        label="Mass(.kg)", interactive=False948                    )949                    est_mu_text = gr.Textbox(950                        label="Friction coefficient", interactive=False951                    )952                with gr.Row():953                    desc_box = gr.Textbox(954                        label="📝 Asset Description", interactive=False955                    )956                with gr.Accordion(label="Asset Details", open=False):957                    urdf_file = gr.Textbox(958                        label="URDF File Path", interactive=False, lines=2959                    )960                with gr.Row(elem_id="download-row"):961                    # DownloadButtons that build their zip into their own value962                    # on click; a chained JS handler then triggers the browser963                    # download from that value (one-click). If the JS ever964                    # fails, the value is still populated, so a second manual965                    # click downloads it via the button's native behavior.966                    urdf_dl_btn = gr.DownloadButton(967                        label="⬇️ Download URDF",968                        variant="primary",969                        interactive=False,970                    )971                    usd_dl_btn = gr.DownloadButton(972                        label="⬇️ Download USD",973                        variant="primary",974                        interactive=False,975                    )976                    mjcf_dl_btn = gr.DownloadButton(977                        label="⬇️ Download MJCF",978                        variant="primary",979                        interactive=False,980                    )981                    affordance_dl_btn = gr.DownloadButton(982                        label="⬇️ Affordance",983                        variant="primary",984                        interactive=False,985                    )986 987    search_button.click(988        fn=search_assets,989        inputs=[search_box, top_k_slider],990        outputs=[gallery, gallery, gallery_df_state],991    )992    search_box.submit(993        fn=search_assets,994        inputs=[search_box, top_k_slider],995        outputs=[gallery, gallery, gallery_df_state],996    )997 998    def update_on_primary_change(p):999        s_choices = get_secondary_categories(p)1000        initial_assets, gallery_update, initial_df = get_assets(p, None, None)1001        return (1002            gr.update(choices=s_choices, value=None),1003            gr.update(choices=[], value=None),1004            initial_assets,1005            gallery_update,1006            initial_df,1007        )1008 1009    def update_on_secondary_change(p, s):1010        c_choices = get_categories(p, s)1011        asset_previews, gallery_update, gallery_df = get_assets(p, s, None)1012        return (1013            gr.update(choices=c_choices, value=None),1014            asset_previews,1015            gallery_update,1016            gallery_df,1017        )1018 1019    def update_assets(p, s, c):1020        asset_previews, gallery_update, gallery_df = get_assets(p, s, c)1021        return asset_previews, gallery_update, gallery_df1022 1023    primary.change(1024        fn=update_on_primary_change,1025        inputs=[primary],1026        outputs=[secondary, category, gallery, gallery, gallery_df_state],1027    )1028    secondary.change(1029        fn=update_on_secondary_change,1030        inputs=[primary, secondary],1031        outputs=[category, gallery, gallery, gallery_df_state],1032    )1033    category.change(1034        fn=update_assets,1035        inputs=[primary, secondary, category],1036        outputs=[gallery, gallery, gallery_df_state],1037    )1038 1039    gallery.select(1040        fn=show_asset_from_gallery,1041        inputs=[primary, secondary, category, search_box, gallery_df_state],1042        concurrency_id=HF_IO_CONCURRENCY_ID,1043        concurrency_limit=HF_IO_CONCURRENCY_LIMIT,1044        outputs=[1045            (visual_path_state := gr.State()),1046            (collision_path_state := gr.State()),1047            desc_box,1048            asset_folder,1049            urdf_file,1050            est_type_text,1051            est_height_text,1052            est_mass_text,1053            est_mu_text,1054        ],1055    ).then(1056        fn=render_meshes,1057        inputs=[visual_path_state, collision_path_state, switch_viewer_state],1058        outputs=[1059            viewer,1060            collision_viewer_a,1061            collision_viewer_b,1062            switch_viewer_state,1063        ],1064    ).success(1065        fn=_reset_downloads,1066        outputs=[urdf_dl_btn, usd_dl_btn, mjcf_dl_btn, affordance_dl_btn],1067    )1068 1069    # After the zip is built into the button's own value, pass that file value1070    # straight into the JS handler and trigger the browser download from its1071    # URL. Reading the value via `inputs` avoids DOM-timing/selector issues.1072    download_js = """1073    (f) => {1074        if (f && f.url) {1075            const a = document.createElement('a');1076            a.href = f.url;1077            a.download = (f.orig_name || 'asset.zip').split('/').pop();1078            document.body.appendChild(a);1079            a.click();1080            document.body.removeChild(a);1081        }1082        return [];1083    }1084    """1085 1086    dl_btns = [urdf_dl_btn, usd_dl_btn, mjcf_dl_btn, affordance_dl_btn]1087 1088    # Each flow: lock all buttons (prevent spam clicks) -> build the zip ->1089    # JS-trigger the browser download -> unlock. Selecting another asset1090    # resets the buttons independently (see `.success` above).1091    urdf_dl_btn.click(1092        fn=lambda: _lock_downloads("urdf"), outputs=dl_btns, queue=False1093    ).then(1094        fn=download_urdf,1095        inputs=[asset_folder],1096        outputs=[urdf_dl_btn],1097        concurrency_id=HF_IO_CONCURRENCY_ID,1098        concurrency_limit=HF_IO_CONCURRENCY_LIMIT,1099    ).then(fn=lambda *a: None, inputs=[urdf_dl_btn], js=download_js).then(1100        fn=_unlock_downloads, outputs=dl_btns1101    )1102 1103    usd_dl_btn.click(1104        fn=lambda: _lock_downloads("usd"), outputs=dl_btns, queue=False1105    ).then(1106        fn=download_usd,1107        inputs=[asset_folder],1108        outputs=[usd_dl_btn],1109        concurrency_id=HF_IO_CONCURRENCY_ID,1110        concurrency_limit=HF_IO_CONCURRENCY_LIMIT,1111    ).then(fn=lambda *a: None, inputs=[usd_dl_btn], js=download_js).then(1112        fn=_unlock_downloads, outputs=dl_btns1113    )1114 1115    mjcf_dl_btn.click(1116        fn=lambda: _lock_downloads("mjcf"), outputs=dl_btns, queue=False1117    ).then(1118        fn=download_mjcf,1119        inputs=[asset_folder],1120        outputs=[mjcf_dl_btn],1121        concurrency_id=HF_IO_CONCURRENCY_ID,1122        concurrency_limit=HF_IO_CONCURRENCY_LIMIT,1123    ).then(fn=lambda *a: None, inputs=[mjcf_dl_btn], js=download_js).then(1124        fn=_unlock_downloads, outputs=dl_btns1125    )1126 1127    affordance_dl_btn.click(1128        fn=lambda: _lock_downloads("affordance"),1129        outputs=dl_btns,1130        queue=False,1131    ).then(1132        fn=download_affordance,1133        inputs=[asset_folder],1134        outputs=[affordance_dl_btn],1135        concurrency_id=HF_IO_CONCURRENCY_ID,1136        concurrency_limit=HF_IO_CONCURRENCY_LIMIT,1137    ).then(1138        fn=lambda *a: None, inputs=[affordance_dl_btn], js=download_js1139    ).then(fn=_unlock_downloads, outputs=dl_btns)1140 1141    demo.load(start_session)1142    demo.unload(end_session)1143 1144 1145if __name__ == "__main__":1146    # Serve gallery videos / meshes that live under DATA_ROOT (outside cwd).1147    demo.launch()1148