diff --git a/.gitignore b/.gitignore index 69061763a..e61027a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -208,3 +208,9 @@ scripts/benchmark/rl/reports/* .worktrees/ # Local gym project workspace /gym_project/ +.debug_engine/ + +# Local Gradio UI dependencies, generated Articraft records, and bytecode +/embodichain/gen_sim/gradio_ui/.articraft/ +/embodichain/gen_sim/gradio_ui/.debug_engine/ +/embodichain/gen_sim/gradio_ui/__pycache__/ diff --git a/embodichain/__main__.py b/embodichain/__main__.py index 975e7649d..0e73bd076 100644 --- a/embodichain/__main__.py +++ b/embodichain/__main__.py @@ -96,11 +96,6 @@ class Command: target="embodichain.workspace_cache_cli:main", help="Inspect and clean workspace analyzer caches.", ), - Command( - name="analyze-workspace", - target="embodichain.lab.scripts.analyze_workspace:cli", - help="Analyze a robot's reachable workspace from a URDF/USD asset.", - ), ) diff --git a/embodichain/gen_sim/.env.example b/embodichain/gen_sim/.env.example new file mode 100644 index 000000000..6544d0710 --- /dev/null +++ b/embodichain/gen_sim/.env.example @@ -0,0 +1,39 @@ +# Shared GenSim configuration +# Copy this file to .env and set deployment-specific values. Values exported +# by the shell, container, or CI environment take precedence over this file. + +# Common OpenAI-compatible LLM endpoint used by Scene Engine. +OPENAI_API_KEY="" +OPENAI_MODEL="" +OPENAI_BASE_URL="" +SCENE_ENGINE_OPENAI_DEFAULT_QUERY="{}" +OPENAI_MAX_ATTEMPTS=3 + +# Scene Engine services. +SCENE_ENGINE_IMAGE_SEGMENTATION_BASE_URL="" +SCENE_ENGINE_IMAGE_SEGMENTATION_TIMEOUT_S=30 +SCENE_ENGINE_IMAGE_SEGMENTATION_MAX_ATTEMPTS=3 +SCENE_ENGINE_IMAGE_SEGMENTATION_HEALTH_PATH="/health" +SCENE_ENGINE_IMAGE_SEGMENTATION_SINGLE_OBJECT_PATH="/predict" +SCENE_ENGINE_GEOMETRY_GENERATION_BASE_URL="" +SCENE_ENGINE_GEOMETRY_GENERATION_TIMEOUT_S=600 +SCENE_ENGINE_GEOMETRY_GENERATION_MAX_ATTEMPTS=3 +SCENE_ENGINE_GEOMETRY_GENERATION_HEALTH_PATH="/health" +SCENE_ENGINE_GEOMETRY_GENERATION_OBJECTS_PATH="/generate_multiple_objects" + +# Gradio application and local workbench settings. The EmbodiChain repository +# root is derived automatically from the installed source tree. +GRADIO_SERVER_NAME="0.0.0.0" +GRADIO_SERVER_PORT=7860 +SCENE_ENGINE_VISER_PORT=8080 +ARTICRAFT_VISER_PORT=8081 +ARTICRAFT_ROOT="" +ARTICRAFT_REPOSITORY_URL="https://github.com/mattzh72/articraft.git" +ARTICRAFT_CONDA_ENV="articraft" +ARTICRAFT_OUTPUT_ROOT="" + +# Optional SimReady endpoint. These values are mapped to OPENAI_* only for +# SimReady subprocesses, leaving Scene Engine settings unchanged. +SIMREADY_OPENAI_API_KEY="" +SIMREADY_OPENAI_MODEL="" +SIMREADY_OPENAI_BASE_URL="" diff --git a/embodichain/gen_sim/env.py b/embodichain/gen_sim/env.py new file mode 100644 index 000000000..44b772568 --- /dev/null +++ b/embodichain/gen_sim/env.py @@ -0,0 +1,105 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Load the shared GenSim environment configuration.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import MutableMapping + +__all__ = ["find_gen_sim_env_file", "get_embodichain_root", "load_gen_sim_env"] + + +def get_embodichain_root() -> Path: + """Return the repository containing the installed GenSim source tree. + + The Gradio app always launches its local tools from this directory. It is + derived from this module instead of a machine-specific dotenv value, so a + checkout continues to work after it is moved or cloned elsewhere. + """ + return Path(__file__).resolve().parents[2] + + +def find_gen_sim_env_file() -> Path: + """Return the configured shared ``.env`` file path. + + ``EMBODICHAIN_ENV_FILE`` is useful for deployments that keep secrets outside + the source tree. Otherwise GenSim uses ``embodichain/gen_sim/.env``. The + repository-root ``.env`` remains a backward-compatible fallback. + """ + configured_path = os.environ.get("EMBODICHAIN_ENV_FILE") + if configured_path: + return Path(configured_path).expanduser().resolve() + default_path = Path(__file__).resolve().parent / ".env" + if default_path.is_file(): + return default_path + + +def load_gen_sim_env(env: MutableMapping[str, str] | None = None) -> Path | None: + """Load missing variables from the shared GenSim ``.env`` file. + + Existing process environment variables are never overwritten so container, + CI, and shell-provided settings retain precedence over the local file. + + Args: + env: Environment mapping to populate. Defaults to :data:`os.environ`. + + Returns: + The loaded path, or ``None`` when no local ``.env`` file exists. + + Raises: + ValueError: If the file contains an invalid ``KEY=VALUE`` entry. + """ + target_env = os.environ if env is None else env + env_path = find_gen_sim_env_file() + if not env_path.is_file(): + return None + + for line_number, raw_line in enumerate( + env_path.read_text(encoding="utf-8").splitlines(), start=1 + ): + parsed = _parse_env_line(raw_line) + if parsed is None: + continue + key, value = parsed + if not key.isidentifier(): + raise ValueError( + f"Invalid environment variable name at {env_path}:{line_number}: {key!r}" + ) + target_env.setdefault(key, value) + return env_path + + +def _parse_env_line(line: str) -> tuple[str, str] | None: + """Parse one conventional dotenv line without requiring a third-party package.""" + stripped = line.strip() + if not stripped or stripped.startswith("#"): + return None + if stripped.startswith("export "): + stripped = stripped.removeprefix("export ").lstrip() + if "=" not in stripped: + raise ValueError(f"Expected KEY=VALUE entry, got: {line!r}") + + key, value = stripped.split("=", maxsplit=1) + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + elif " #" in value: + value = value.split(" #", maxsplit=1)[0].rstrip() + return key, value diff --git a/embodichain/gen_sim/gradio_ui/app_articraft.py b/embodichain/gen_sim/gradio_ui/app_articraft.py new file mode 100644 index 000000000..a7fd81f9e --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_articraft.py @@ -0,0 +1,1138 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Codex-backed Articraft generation for the Asset engine. + +The integration uses Articraft's external-agent workflow: Articraft owns +record creation and validation while Codex authors the generated model. All +mutable run data is kept under ``ARTICRAFT_OUTPUT_ROOT``. +""" + +from __future__ import annotations + +import atexit +import os +import queue +import json +import shutil +import html +import signal +import socket +import subprocess +import sys +import threading +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import gradio as gr + +from app_env import ( + ARTICRAFT_CONDA_ENV, + ARTICRAFT_OUTPUT_ROOT, + ARTICRAFT_REPOSITORY_URL, + ARTICRAFT_ROOT, + ARTICRAFT_VISER_PORT, +) +from app_processes import ( + read_process_output, + register_managed_process, + start_pipeline, + terminate_process_group, +) + +__all__ = [ + "build_articraft_panel", + "configure_articraft_environment", + "generate_articraft_asset", + "reset_articraft_asset", + "stop_articraft_viser_preview", +] + +_VISER_START_TIMEOUT_SECONDS = 15.0 +_VISER_STOP_TIMEOUT_SECONDS = 5.0 +_ARTICRAFT_PYTHON_VERSION = "3.12" +_CONDA_ENVIRONMENT_SETUP_TIMEOUT_SECONDS = 1_200 +_articraft_environment_lock = threading.Lock() +_articraft_generation_lock = threading.Lock() +_articraft_generation_process: subprocess.Popen[str] | None = None +_articraft_generation_token: str | None = None +_ARTICRAFT_IDLE_PREVIEW = ( + "
" + "The interactive Viser articulation preview will appear here after generation." + "
" +) + + +def _begin_articraft_generation() -> str: + """Invalidate the previous generation and return a new ownership token.""" + global _articraft_generation_process, _articraft_generation_token + with _articraft_generation_lock: + previous_process = _articraft_generation_process + _articraft_generation_process = None + token = uuid.uuid4().hex + _articraft_generation_token = token + if previous_process is not None: + terminate_process_group(previous_process) + return token + + +def _articraft_generation_is_active( + token: str, process: subprocess.Popen[str] | None = None +) -> bool: + with _articraft_generation_lock: + return _articraft_generation_token == token and ( + process is None or _articraft_generation_process is process + ) + + +def _set_articraft_generation_process( + token: str, process: subprocess.Popen[str] +) -> bool: + global _articraft_generation_process + with _articraft_generation_lock: + if _articraft_generation_token != token: + return False + _articraft_generation_process = process + return True + + +def _finish_articraft_generation_process( + token: str, process: subprocess.Popen[str] +) -> None: + global _articraft_generation_process + with _articraft_generation_lock: + if ( + _articraft_generation_token == token + and _articraft_generation_process is process + ): + _articraft_generation_process = None + + +def _run_articraft_generation_check( + command: list[str], *, token: str, timeout: int +) -> subprocess.CompletedProcess[str] | None: + """Run one Articraft CLI gate so Reset can stop its whole process group.""" + process = register_managed_process( + subprocess.Popen( + command, + cwd=ARTICRAFT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + env=os.environ.copy(), + ) + ) + if not _set_articraft_generation_process(token, process): + terminate_process_group(process) + return None + try: + stdout, _ = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + terminate_process_group(process) + raise + finally: + _finish_articraft_generation_process(token, process) + if not _articraft_generation_is_active(token): + return None + return subprocess.CompletedProcess(command, process.returncode, stdout) + + +def reset_articraft_asset(): + """Clear Articraft inputs/results and stop its command and Viser processes.""" + global _articraft_generation_process, _articraft_generation_token + with _articraft_generation_lock: + process = _articraft_generation_process + _articraft_generation_process = None + _articraft_generation_token = None + if process is not None: + terminate_process_group(process) + stop_articraft_viser_preview() + return ( + "**Environment:** not checked.", + "", + None, + None, + "", + "**Status:** waiting for a description.", + "", + _ARTICRAFT_IDLE_PREVIEW, + ) + + +def _command_path(name: str) -> str | None: + """Resolve commands even when Gradio did not inherit an interactive PATH.""" + configured = os.environ.get(f"{name.upper()}_EXE") + return configured or shutil.which(name) + + +def _conda_path() -> str | None: + configured = os.environ.get("CONDA_EXE") + if configured and Path(configured).is_file(): + return configured + return _command_path("conda") + + +def _conda_command(*args: str) -> list[str]: + conda = _conda_path() + if not conda: + raise RuntimeError("Conda was not found. Set CONDA_EXE before starting Gradio.") + return [conda, "run", "--no-capture-output", "-n", ARTICRAFT_CONDA_ENV, *args] + + +def _articraft_cli_command(*args: str) -> list[str]: + """Run the CLI from the checked-out source without installing it with pip.""" + return _conda_command("python", "-m", "cli.main", *args) + + +def _articraft_conda_environment_exists() -> bool: + """Check only for the named Conda environment, not package installation.""" + conda = _conda_path() + if not conda: + return False + try: + result = subprocess.run( + [conda, "env", "list", "--json"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=30, + check=False, + ) + if result.returncode: + return False + environments = json.loads(result.stdout or "{}").get("envs", []) + return any(Path(path).name == ARTICRAFT_CONDA_ENV for path in environments) + except (OSError, json.JSONDecodeError, TypeError): + return False + + +def _ensure_articraft_conda_environment() -> tuple[bool, str]: + """Create and populate the Articraft Conda environment when it is absent. + + Articraft currently supports Python 3.11 and 3.12, while the Gradio process + can use a different interpreter. The setup therefore creates an isolated + Python 3.12 environment and installs the checked-out project's runtime + dependencies into it. + + Returns: + Whether the environment is ready and a status message suitable for the + Gradio configuration panel. + """ + conda = _conda_path() + if not conda: + return False, "Conda is not on PATH. Set CONDA_EXE to the conda executable." + + with _articraft_environment_lock: + if _articraft_conda_environment_exists(): + return True, f"Conda environment: {ARTICRAFT_CONDA_ENV} (already exists)" + + create_command = [ + conda, + "create", + "--yes", + "--name", + ARTICRAFT_CONDA_ENV, + f"python={_ARTICRAFT_PYTHON_VERSION}", + "pip", + ] + try: + created = subprocess.run( + create_command, + cwd=ARTICRAFT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=_CONDA_ENVIRONMENT_SETUP_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, f"Unable to create Conda environment: {exc}" + if created.returncode and not _articraft_conda_environment_exists(): + return False, ( + "Conda environment creation failed: " + f"{_short_output(created, limit=3000)}" + ) + + for install_args, description in ( + ( + ["python", "-m", "pip", "install", "--upgrade", "pip"], + "upgrade pip", + ), + (["python", "-m", "pip", "install", "."], "install Articraft dependencies"), + ): + install_command = [ + conda, + "run", + "--no-capture-output", + "--name", + ARTICRAFT_CONDA_ENV, + *install_args, + ] + try: + installed = subprocess.run( + install_command, + cwd=ARTICRAFT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=_CONDA_ENVIRONMENT_SETUP_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, f"Unable to {description}: {exc}" + if installed.returncode: + return ( + False, + f"Unable to {description}: {_short_output(installed, limit=3000)}", + ) + + return True, ( + f"Created Conda environment: {ARTICRAFT_CONDA_ENV} " + f"(Python {_ARTICRAFT_PYTHON_VERSION})" + ) + + +def _run_check( + command: list[str], *, timeout: int = 45 +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=ARTICRAFT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=timeout, + check=False, + ) + + +def _short_output( + result: subprocess.CompletedProcess[str], *, limit: int = 1800 +) -> str: + output = (result.stdout or "").strip() + return output[-limit:] if len(output) > limit else (output or "(no output)") + + +def _check_requirements() -> tuple[list[str], list[str], str | None]: + """Return diagnostics and the Codex executable, without creating an asset.""" + errors: list[str] = [] + details: list[str] = [] + if not ( + ARTICRAFT_ROOT.is_dir() + and (ARTICRAFT_ROOT / ".git").exists() + and (ARTICRAFT_ROOT / "pyproject.toml").is_file() + ): + errors.append(f".articraft checkout is not ready: {ARTICRAFT_ROOT}") + if not _conda_path(): + errors.append("Conda is not on PATH. Set CONDA_EXE to the conda executable.") + elif not _articraft_conda_environment_exists(): + errors.append(f"Conda environment not found: {ARTICRAFT_CONDA_ENV}") + else: + details.append(f"Conda environment: {ARTICRAFT_CONDA_ENV}") + + codex = _command_path("codex") + if not codex: + errors.append("Codex CLI is not on PATH. Install it or set CODEX_EXE.") + elif not errors: + try: + result = _run_check([codex, "--version"]) + if result.returncode: + errors.append(f"Codex CLI check failed: {_short_output(result)}") + else: + details.append(f"Codex: {_short_output(result, limit=120)}") + except Exception as exc: + errors.append(f"Codex CLI check failed: {exc}") + + if not errors: + details.append(f".articraft checkout: {ARTICRAFT_ROOT}") + return details, errors, codex + + +def _prepare_articraft_checkout() -> tuple[bool, str]: + """Clone the configured checkout when absent, without overwriting a directory.""" + if ARTICRAFT_ROOT.exists(): + if (ARTICRAFT_ROOT / ".git").exists() and ( + ARTICRAFT_ROOT / "pyproject.toml" + ).is_file(): + return True, f".articraft checkout: {ARTICRAFT_ROOT}" + return ( + False, + f"{ARTICRAFT_ROOT} exists but is not an Articraft Git checkout; it was left untouched.", + ) + + git = _command_path("git") + if not git: + return False, "Git is not on PATH, so .articraft cannot be cloned." + try: + ARTICRAFT_ROOT.parent.mkdir(parents=True, exist_ok=True) + clone = subprocess.run( + [git, "clone", ARTICRAFT_REPOSITORY_URL, str(ARTICRAFT_ROOT)], + cwd=ARTICRAFT_ROOT.parent, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=300, + check=False, + ) + except Exception as exc: + return False, f"Unable to clone Articraft: {exc}" + if clone.returncode: + return False, f"Articraft clone failed: {_short_output(clone, limit=3000)}" + return True, f"Cloned .articraft from {ARTICRAFT_REPOSITORY_URL}" + + +def configure_articraft_environment() -> str: + """Clone the checkout, prepare its Conda environment, and verify Codex.""" + checkout_ready, checkout_message = _prepare_articraft_checkout() + if not checkout_ready: + return "**Articulation is not ready.**\n\n- " + checkout_message + environment_ready, environment_message = _ensure_articraft_conda_environment() + if not environment_ready: + return "**Articulation is not ready.**\n\n- " + environment_message + try: + for directory in ( + ARTICRAFT_OUTPUT_ROOT, + ARTICRAFT_OUTPUT_ROOT / "runs", + ARTICRAFT_OUTPUT_ROOT / "exports", + ): + directory.mkdir(parents=True, exist_ok=True) + except Exception as exc: + return f"**Unable to prepare the shared Articulation output folder:** `{exc}`" + details, errors, _ = _check_requirements() + if errors: + return "**Articulation is not ready.**\n\n" + "\n".join( + f"- {error}" for error in errors + ) + details.insert(0, checkout_message) + details.insert(1, environment_message) + details.extend( + ( + f"Shared output: `{ARTICRAFT_OUTPUT_ROOT}`", + "Generation runs the `.articraft` checkout directly with `conda run`; no `pip install -e .` is required.", + ) + ) + return "**Articulation is ready.**\n\n" + "\n".join( + f"- {detail}" for detail in details + ) + + +def _record_id() -> str: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + # Articraft validates external IDs against the required ``rec_`` prefix. + return f"rec_ui_articraft_{timestamp}_{uuid.uuid4().hex[:8]}" + + +def _copy_reference_image(value: Any, run_root: Path) -> Path | None: + if not value: + return None + source = Path(str(value)) + if not source.is_file(): + raise ValueError( + "The reference image is no longer available; please upload it again." + ) + suffix = source.suffix.lower() or ".png" + if suffix not in {".png", ".jpg", ".jpeg", ".webp"}: + raise ValueError("Reference image must be PNG, JPG, JPEG, or WEBP.") + target = run_root / f"reference{suffix}" + shutil.copy2(source, target) + return target + + +def _active_model_path(record_dir: Path) -> Path: + candidates = sorted(record_dir.glob("revisions/*/model.py")) + if len(candidates) != 1: + raise FileNotFoundError( + f"Expected one active model.py in {record_dir}, found {len(candidates)}." + ) + return candidates[0] + + +def _make_result_bundle(record_id: str) -> tuple[Path, Path]: + materialized = ( + ARTICRAFT_OUTPUT_ROOT / "data" / "cache" / "record_materialization" / record_id + ) + if not (materialized / "model.urdf").is_file(): + raise FileNotFoundError( + "Articraft completed without a compiled model.urdf output." + ) + exports_root = ARTICRAFT_OUTPUT_ROOT / "exports" + exports_root.mkdir(parents=True, exist_ok=True) + archive = Path( + shutil.make_archive( + (exports_root / record_id).as_posix(), + "zip", + root_dir=materialized, + ) + ) + return materialized, archive + + +def _articraft_viser_iframe(record_id: str) -> str: + """Embed the Articulation Viser service through the Gradio page hostname.""" + srcdoc = ( + "" + ) + escaped_record_id = html.escape(record_id) + return ( + "
Viser articulation preview: " + f"{escaped_record_id}" + f"" + "
" + ) + + +class _ArticraftViserPreview: + """Own the single Articraft Viser process and its dedicated TCP port.""" + + def __init__(self, port: int) -> None: + self._port = port + self._lock = threading.Lock() + self._process: subprocess.Popen[str] | None = None + + def start(self, urdf_path: Path, record_id: str) -> str: + """Replace the active preview with a verified preview of one URDF.""" + if not urdf_path.is_file(): + raise FileNotFoundError(f"Compiled URDF is missing: {urdf_path}") + + with self._lock: + self._stop_managed_process() + self._clear_stale_listener() + process = start_pipeline(self._command(urdf_path)) + if not self._wait_until_owned(process): + terminate_process_group(process) + raise RuntimeError("New Articraft Viser preview did not bind its port.") + self._process = process + return _articraft_viser_iframe(record_id) + + def stop(self) -> None: + """Stop the preview process, if this panel started one.""" + with self._lock: + self._stop_managed_process() + + def _command(self, urdf_path: Path) -> list[str]: + return [ + sys.executable, + str(Path(__file__).with_name("app_media.py")), + "--asset_path", + str(urdf_path), + "--asset_type", + "articulation", + "--headless", + "--viser", + "--viser-host", + "0.0.0.0", + "--viser-port", + str(self._port), + ] + + def _stop_managed_process(self) -> None: + if self._process is not None: + terminate_process_group(self._process) + self._process = None + + def _clear_stale_listener(self) -> None: + if self._port_is_available(): + return + listener_pids = self._listener_pids() + if listener_pids is None: + raise RuntimeError( + "Cannot identify the process using the Articraft Viser port." + ) + if not listener_pids: + raise RuntimeError( + f"Port {self._port} is unavailable without a visible listener." + ) + + self._signal_listeners(listener_pids, signal.SIGTERM, "stop") + if self._wait_for_port_release(): + return + + remaining_pids = self._listener_pids() + if remaining_pids is None: + raise RuntimeError( + f"Cannot identify the stale Viser service on port {self._port}." + ) + self._signal_listeners(remaining_pids, signal.SIGKILL, "force-stop") + if not self._wait_for_port_release(): + raise RuntimeError( + f"The stale Viser service is still listening on port {self._port}." + ) + + def _signal_listeners( + self, listener_pids: set[int], signal_value: int, action: str + ) -> None: + for pid in listener_pids: + try: + os.kill(pid, signal_value) + except ProcessLookupError: + continue + except PermissionError as exc: + raise RuntimeError( + f"Cannot {action} Viser process {pid} using port {self._port}." + ) from exc + + def _wait_for_port_release(self) -> bool: + deadline = time.monotonic() + _VISER_STOP_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if self._port_is_available(): + return True + time.sleep(0.1) + return self._port_is_available() + + def _wait_until_owned(self, process: subprocess.Popen[str]) -> bool: + deadline = time.monotonic() + _VISER_START_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if process.poll() is not None: + return False + try: + with socket.create_connection(("127.0.0.1", self._port), timeout=0.2): + listener_pids = self._listener_pids() + if listener_pids is not None and process.pid in listener_pids: + return True + except OSError: + pass + time.sleep(0.25) + return False + + def _port_is_available(self) -> bool: + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + probe.bind(("0.0.0.0", self._port)) + except OSError: + return False + finally: + probe.close() + return True + + def _listener_pids(self) -> set[int] | None: + for command in self._listener_commands(): + try: + result = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=5, + check=False, + ) + except OSError: + continue + if result.returncode in (0, 1): + return {int(pid) for pid in result.stdout.split() if pid.isdecimal()} + return None + + def _listener_commands(self) -> list[list[str]]: + commands: list[list[str]] = [] + if lsof := _command_path("lsof"): + commands.append([lsof, "-nP", f"-iTCP:{self._port}", "-sTCP:LISTEN", "-t"]) + if fuser := _command_path("fuser"): + commands.append([fuser, "-n", "tcp", str(self._port)]) + return commands + + +_articraft_viser_preview = _ArticraftViserPreview(ARTICRAFT_VISER_PORT) + + +def stop_articraft_viser_preview() -> None: + """Stop the Viser subprocess currently owned by the Articraft panel. + + The preview runs independently from Gradio so it can be embedded through an + iframe. Expose its cleanup explicitly so application shutdown can release + the dedicated port instead of leaving an orphaned Viser server behind. + """ + _articraft_viser_preview.stop() + + +atexit.register(stop_articraft_viser_preview) + + +def _start_articraft_viser_preview(materialized: Path, record_id: str) -> str: + """Load the compiled URDF as an articulation and expose it through Viser.""" + return _articraft_viser_preview.start(materialized / "model.urdf", record_id) + + +def _external_check_is_unsupported(result: subprocess.CompletedProcess[str]) -> bool: + """Recognize the older Articraft CLI, which has no ``external check``.""" + output = (result.stdout or "").lower() + return "invalid choice: 'check'" in output and "external" in output + + +def _compile_report_failures(record_id: str) -> list[str]: + """Read blocking QC/test signals from the older CLI's compile report.""" + report_path = ( + ARTICRAFT_OUTPUT_ROOT + / "data" + / "cache" + / "record_materialization" + / record_id + / "compile_report.json" + ) + try: + report = json.loads(report_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [f"Compile report is unavailable: {report_path}"] + bundle = report.get("signal_bundle") if isinstance(report, dict) else None + signals = bundle.get("signals") if isinstance(bundle, dict) else None + if not isinstance(signals, list): + return ["Compile report contains no validation signals."] + failures: list[str] = [] + for signal in signals: + if not isinstance(signal, dict): + continue + if signal.get("severity") == "failure" or signal.get("blocking") is True: + failures.append( + str( + signal.get("summary") + or signal.get("code") + or "Unnamed validation failure" + ) + ) + return failures + + +def _build_codex_prompt( + *, + prompt: str, + record_id: str, + record_dir: Path, + model_path: Path, + reference_image: Path | None, +) -> str: + image_note = ( + f"A reference image is attached and also copied at {reference_image}. Use it as visual reference." + if reference_image + else "No reference image was supplied." + ) + return f"""You are the Codex external author for one Articraft articulated 3D asset. + +User request: +{prompt} + +{image_note} + +The Articraft source repository is {ARTICRAFT_ROOT}. The shared UI output/storage root is +{ARTICRAFT_OUTPUT_ROOT}. Articraft has already created this external workbench record: +record_id={record_id} +record_dir={record_dir} +active_model={model_path} + +Codex itself is launched from the Gradio environment, not the Articraft Conda environment. +For every Articraft CLI invocation, use this command prefix: + +{_conda_path()} run --no-capture-output -n {ARTICRAFT_CONDA_ENV} python -m cli.main + +Follow EXTERNAL_AGENT_DATA.md exactly. Read the design and link-naming guidance it references, +then use relevant SDK docs/examples. Edit only the active model.py for this record. Do not create +record folders or metadata manually, edit unrelated records, commit/push, or promote this +workbench record to the dataset. + +Create a realistic mechanically meaningful articulated object matching the request. Use semantic +parts, visible plausible joints, appropriate materials, and prompt-specific run_tests(). Iterate +until this succeeds: + +{_conda_path()} run --no-capture-output -n {ARTICRAFT_CONDA_ENV} python -m cli.main external --repo-root {ARTICRAFT_OUTPUT_ROOT} check {record_id} + +Then run: + +{_conda_path()} run --no-capture-output -n {ARTICRAFT_CONDA_ENV} python -m cli.main external --repo-root {ARTICRAFT_OUTPUT_ROOT} finalize {record_id} + +The Gradio app packages the compiled URDF and meshes after you finish. In your final response, +briefly state the articulation mechanisms and validation result.""" + + +def generate_articraft_asset(prompt_value: str, image_value: Any): + """Initialize a record, let Codex author it, and expose one result bundle.""" + token = _begin_articraft_generation() + prompt = (prompt_value or "").strip() + if not prompt: + if _articraft_generation_is_active(token): + yield None, "", "**Input error:** enter a description of the articulated object.", "", "" + return + + details, errors, codex = _check_requirements() + if errors or not codex: + message = ( + "\n".join(f"- {error}" for error in errors) or "Codex CLI is unavailable." + ) + if _articraft_generation_is_active(token): + yield None, "", f"**Articulation is not ready.**\n\n{message}", "", "" + return + + record_id = _record_id() + run_root = ARTICRAFT_OUTPUT_ROOT / "runs" / record_id + record_dir = ARTICRAFT_OUTPUT_ROOT / "data" / "records" / record_id + log_lines = [*details, f"Shared output: {ARTICRAFT_OUTPUT_ROOT}"] + try: + run_root.mkdir(parents=True, exist_ok=False) + reference_image = _copy_reference_image(image_value, run_root) + init_command = _articraft_cli_command( + "external", + "--repo-root", + str(ARTICRAFT_OUTPUT_ROOT), + "init", + "--agent", + "codex", + "--record-id", + record_id, + prompt, + ) + log_lines.append("$ " + " ".join(init_command[:-1]) + " ") + initialized = _run_articraft_generation_check( + init_command, token=token, timeout=90 + ) + if initialized is None: + return + log_lines.append(_short_output(initialized, limit=4000)) + if initialized.returncode: + yield None, "", "**Articraft record initialization failed.**", "\n".join( + log_lines + ), "" + return + model_path = _active_model_path(record_dir) + except Exception as exc: + if _articraft_generation_is_active(token): + yield None, "", f"**Setup failed:** {exc}", "\n".join(log_lines), "" + return + + if not _articraft_generation_is_active(token): + return + + final_message = run_root / "codex_final_message.txt" + codex_command = [ + codex, + "exec", + "--sandbox", + "workspace-write", + "--color", + "never", + "-C", + str(ARTICRAFT_ROOT), + "--add-dir", + str(ARTICRAFT_OUTPUT_ROOT), + "--output-last-message", + str(final_message), + ] + if reference_image: + codex_command.extend(["--image", str(reference_image)]) + codex_command.append( + _build_codex_prompt( + prompt=prompt, + record_id=record_id, + record_dir=record_dir, + model_path=model_path, + reference_image=reference_image, + ) + ) + log_lines.append("$ codex exec --sandbox workspace-write …") + yield None, record_dir.as_posix(), "**Codex is generating and validating the Articraft model…**", "\n".join( + log_lines + ), "" + + try: + process = register_managed_process( + subprocess.Popen( + codex_command, + cwd=ARTICRAFT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=True, + env=os.environ.copy(), + ) + ) + if not _set_articraft_generation_process(token, process): + terminate_process_group(process) + return + except Exception as exc: + if _articraft_generation_is_active(token): + yield None, record_dir.as_posix(), f"**Codex could not start:** {exc}", "\n".join( + log_lines + ), "" + return + + output_queue: queue.Queue[str] = queue.Queue() + reader = threading.Thread( + target=read_process_output, args=(process, output_queue), daemon=True + ) + reader.start() + while process.poll() is None: + if not _articraft_generation_is_active(token, process): + return + try: + while True: + log_lines.append(output_queue.get_nowait()) + except queue.Empty: + pass + yield None, record_dir.as_posix(), "**Codex is generating and validating the Articraft model…**", "\n".join( + log_lines[-240:] + ), "" + time.sleep(0.75) + try: + reader.join(timeout=2) + try: + while True: + log_lines.append(output_queue.get_nowait()) + except queue.Empty: + pass + finally: + _finish_articraft_generation_process(token, process) + + if not _articraft_generation_is_active(token): + return + + if final_message.is_file(): + final_text = final_message.read_text(encoding="utf-8", errors="replace").strip() + if final_text: + log_lines.append("\nCodex final response:\n" + final_text) + if process.returncode: + yield None, record_dir.as_posix(), f"**Codex generation failed** (exit code {process.returncode}).", "\n".join( + log_lines[-300:] + ), "" + return + + # Do not rely solely on Codex's final message: independently run the + # external validation and finalize gates before exposing an output bundle. + check_command = _articraft_cli_command( + "external", + "--repo-root", + str(ARTICRAFT_OUTPUT_ROOT), + "check", + record_id, + ) + log_lines.append("$ " + " ".join(check_command)) + yield ( + None, + record_dir.as_posix(), + "**Codex finished. Articraft is running the final validation gate…**", + "\n".join(log_lines[-300:]), + "", + ) + try: + checked = _run_articraft_generation_check( + check_command, token=token, timeout=300 + ) + if checked is None: + return + log_lines.append(_short_output(checked, limit=5000)) + except Exception as exc: + yield ( + None, + record_dir.as_posix(), + f"**Final Articraft validation could not run:** {exc}", + "\n".join(log_lines[-300:]), + "", + ) + return + if checked.returncode: + if not _external_check_is_unsupported(checked): + yield ( + None, + record_dir.as_posix(), + "**Articraft validation failed; no output bundle was published.**", + "\n".join(log_lines[-300:]), + "", + ) + return + # The older CLI reports external init/finalize/categories only. Its + # equivalent strict model validation is the top-level compile command. + compile_command = _articraft_cli_command( + "compile", + "--repo-root", + str(ARTICRAFT_OUTPUT_ROOT), + "--target", + "full", + "--validate", + "--strict-geom-qc", + record_id, + ) + log_lines.append( + "external check is unavailable; falling back to compile --validate." + ) + log_lines.append("$ " + " ".join(compile_command)) + yield ( + None, + record_dir.as_posix(), + "**Using this Articraft version's compile validation gate…**", + "\n".join(log_lines[-300:]), + "", + ) + try: + compiled = _run_articraft_generation_check( + compile_command, token=token, timeout=300 + ) + if compiled is None: + return + log_lines.append(_short_output(compiled, limit=5000)) + except Exception as exc: + yield ( + None, + record_dir.as_posix(), + f"**Fallback Articraft validation could not run:** {exc}", + "\n".join(log_lines[-300:]), + "", + ) + return + if compiled.returncode: + yield ( + None, + record_dir.as_posix(), + "**Articraft validation failed; no output bundle was published.**", + "\n".join(log_lines[-300:]), + "", + ) + return + failures = _compile_report_failures(record_id) + if failures: + log_lines.append("Blocking compile-report failures: " + "; ".join(failures)) + yield ( + None, + record_dir.as_posix(), + "**Articraft validation found blocking model defects; no output bundle was published.**", + "\n".join(log_lines[-300:]), + "", + ) + return + + finalize_command = _articraft_cli_command( + "external", + "--repo-root", + str(ARTICRAFT_OUTPUT_ROOT), + "finalize", + record_id, + ) + log_lines.append("$ " + " ".join(finalize_command)) + try: + finalized = _run_articraft_generation_check( + finalize_command, token=token, timeout=300 + ) + if finalized is None: + return + log_lines.append(_short_output(finalized, limit=5000)) + except Exception as exc: + yield ( + None, + record_dir.as_posix(), + f"**Articraft finalization could not run:** {exc}", + "\n".join(log_lines[-300:]), + "", + ) + return + if finalized.returncode: + yield ( + None, + record_dir.as_posix(), + "**Articraft finalization failed; no output bundle was published.**", + "\n".join(log_lines[-300:]), + "", + ) + return + + if not _articraft_generation_is_active(token): + return + try: + materialized, archive = _make_result_bundle(record_id) + status = ( + "**Articraft generation completed and passed the Codex validation workflow.**\n\n" + f"- Record: `{record_dir}`\n- Compiled output: `{materialized}`\n- Downloadable bundle: `{archive}`" + ) + try: + preview_html = _start_articraft_viser_preview(materialized, record_id) + status += "\n- Interactive Viser preview: ready" + except Exception as exc: + preview_html = "" + status += f"\n- Interactive Viser preview could not start: `{exc}`" + log_lines.append(f"Viser preview failed: {exc}") + yield archive.as_posix(), record_dir.as_posix(), status, "\n".join( + log_lines[-300:] + ), preview_html + except Exception as exc: + yield None, record_dir.as_posix(), f"**Codex finished, but result packaging failed:** {exc}", "\n".join( + log_lines[-300:] + ), "" + + +def build_articraft_panel() -> None: + """Render the Articraft tab inside the Asset engine.""" + gr.Markdown( + "### Articulation\n" + "Generate an articulated object from text and an optional reference image. Codex writes and validates the Articraft model; only submit trusted requests." + ) + with gr.Row(): + configure_button = gr.Button("Configure Articulation & check Codex") + generate_button = gr.Button("Generate articulation", variant="primary") + reset_button = gr.Button("Reset Articulation", variant="stop") + environment_status = gr.Markdown("**Environment:** not checked.") + with gr.Row(): + prompt = gr.Textbox( + label="Articulated object description", + lines=5, + placeholder="e.g. A countertop toaster oven with a hinged door and rotating temperature knob.", + ) + image = gr.Image( + label="Optional reference image", + type="filepath", + image_mode="RGB", + sources=["upload"], + ) + with gr.Row(): + output_file = gr.File( + label="Compiled Articulation result bundle (.zip)", interactive=False + ) + record_folder = gr.Textbox( + label="Articulation record folder", interactive=False + ) + articulation_preview = gr.HTML(_ARTICRAFT_IDLE_PREVIEW) + generation_status = gr.Markdown("**Status:** waiting for a description.") + generation_log = gr.Textbox( + label="Codex / Articraft log", lines=14, interactive=False + ) + + configure_button.click( + configure_articraft_environment, outputs=[environment_status], queue=False + ) + generate_button.click( + generate_articraft_asset, + inputs=[prompt, image], + outputs=[ + output_file, + record_folder, + generation_status, + generation_log, + articulation_preview, + ], + ) + reset_button.click( + reset_articraft_asset, + outputs=[ + environment_status, + prompt, + image, + output_file, + record_folder, + generation_status, + generation_log, + articulation_preview, + ], + queue=False, + ) diff --git a/embodichain/gen_sim/gradio_ui/app_asset_engine.py b/embodichain/gen_sim/gradio_ui/app_asset_engine.py new file mode 100644 index 000000000..5acfbfb76 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_asset_engine.py @@ -0,0 +1,369 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Standalone SimReady asset-engine workflow used by Debug mode. + +The upstream SimReady CLI works on a directory, while Gradio uploads files. +This adapter creates an isolated directory for every run, keeps material +sidecars together with the mesh, and exposes GLB previews before and after +processing. It deliberately has no DexSim dependency. +""" + +from __future__ import annotations + +import queue +import shutil +import subprocess +import sys +import threading +import time +import uuid +from pathlib import Path +from typing import Any, Iterable + +import gradio as gr +import trimesh + +from app_articraft import build_articraft_panel +from app_config import DEBUG_ASSET_ENGINE_ROOT, SIMREADY_MESH_SUFFIXES +from app_env import EMBODICHAIN_ROOT +from app_processes import read_process_output, start_pipeline, terminate_process_group + +_simready_run_lock = threading.Lock() +_simready_process: subprocess.Popen[str] | None = None +_simready_run_token: str | None = None +_SIMREADY_IDLE_STATUS = "**Status:** waiting for an asset." + + +def _begin_simready_run() -> str: + """Invalidate any previous SimReady run and return a new ownership token.""" + global _simready_process, _simready_run_token + with _simready_run_lock: + previous_process = _simready_process + _simready_process = None + token = uuid.uuid4().hex + _simready_run_token = token + if previous_process is not None: + terminate_process_group(previous_process) + return token + + +def _simready_run_is_active( + token: str, process: subprocess.Popen[str] | None = None +) -> bool: + with _simready_run_lock: + return _simready_run_token == token and ( + process is None or _simready_process is process + ) + + +def _finish_simready_run( + token: str, process: subprocess.Popen[str] | None = None +) -> None: + global _simready_process + with _simready_run_lock: + if _simready_run_token == token and ( + process is None or _simready_process is process + ): + _simready_process = None + + +def reset_simready_asset(): + """Clear SimReady widgets and terminate the process group for its active run.""" + global _simready_process, _simready_run_token + with _simready_run_lock: + process = _simready_process + _simready_process = None + _simready_run_token = None + if process is not None: + terminate_process_group(process) + return None, "rigid_object", None, None, None, _SIMREADY_IDLE_STATUS, "" + + +def _as_paths(value: Any) -> list[Path]: + if value is None: + return [] + values: Iterable[Any] = value if isinstance(value, (list, tuple)) else [value] + paths: list[Path] = [] + for item in values: + if isinstance(item, str): + paths.append(Path(item)) + elif isinstance(item, dict) and item.get("path"): + paths.append(Path(item["path"])) + return [path for path in paths if path.is_file()] + + +def _mesh_path(paths: Iterable[Path]) -> Path: + meshes = [path for path in paths if path.suffix.lower() in SIMREADY_MESH_SUFFIXES] + if not meshes: + supported = ", ".join(sorted(SIMREADY_MESH_SUFFIXES)) + raise ValueError( + f"Upload one mesh file ({supported}) and optional material files." + ) + return meshes[0] + + +def _safe_copy_uploads(upload_paths: list[Path], destination: Path) -> Path: + destination.mkdir(parents=True, exist_ok=False) + copied: list[Path] = [] + for index, source in enumerate(upload_paths): + # Upload file names are untrusted. Keep only their basename and avoid + # collisions without ever interpreting a supplied relative path. + name = source.name or f"upload_{index}" + target = destination / name + if target.exists(): + target = destination / f"{target.stem}_{index}{target.suffix}" + shutil.copy2(source, target) + copied.append(target) + return _mesh_path(copied) + + +def _export_preview(mesh_path: Path, destination: Path) -> Path: + """Convert every supported mesh type to GLB for one consistent viewer.""" + loaded = trimesh.load(mesh_path, force="scene", process=False) + if isinstance(loaded, trimesh.Trimesh): + scene = trimesh.Scene(loaded) + elif isinstance(loaded, trimesh.Scene): + scene = loaded + else: + raise ValueError(f"Unsupported mesh payload: {type(loaded)!r}") + if not scene.geometry: + raise ValueError("The uploaded asset contains no renderable geometry.") + destination.parent.mkdir(parents=True, exist_ok=True) + scene.export(destination) + return destination + + +def prepare_asset_input_preview(upload_value: Any): + """Validate an upload and return a normalized GLB preview without running SimReady.""" + try: + source = _mesh_path(_as_paths(upload_value)) + preview = DEBUG_ASSET_ENGINE_ROOT / "previews" / f"{uuid.uuid4().hex}.glb" + _export_preview(source, preview) + return ( + preview.as_posix(), + "**Asset input ready.** Review the model, then run SimReady.", + ) + except Exception as exc: + return None, f"**Input error:** {exc}" + + +def _find_simready_output(output_root: Path) -> Path: + candidates = sorted( + output_root.rglob("asset_simready.glb"), + key=lambda path: path.stat().st_mtime_ns, + reverse=True, + ) + if not candidates: + candidates = sorted( + output_root.rglob("asset_simready.obj"), + key=lambda path: path.stat().st_mtime_ns, + reverse=True, + ) + if not candidates: + raise FileNotFoundError( + "SimReady completed without asset_simready.glb or asset_simready.obj." + ) + return candidates[0] + + +def run_simready_asset(upload_value: Any, category: str): + """Run one upstream SimReady job and stream concise subprocess progress.""" + global _simready_process + token = _begin_simready_run() + category = (category or "").strip() + if not category: + if _simready_run_is_active(token): + yield None, None, None, "**Input error:** enter an asset category.", "" + return + try: + uploads = _as_paths(upload_value) + _mesh_path(uploads) + run_root = DEBUG_ASSET_ENGINE_ROOT / "runs" / uuid.uuid4().hex + input_dir = run_root / "input" + output_root = run_root / "output" + source_mesh = _safe_copy_uploads(uploads, input_dir) + input_preview = _export_preview(source_mesh, run_root / "input_preview.glb") + except Exception as exc: + if _simready_run_is_active(token): + yield None, None, None, f"**Input error:** {exc}", "" + return + + command = [ + sys.executable, + "-m", + "embodichain.gen_sim.simready_pipeline.cli.start", + "--input_dir", + str(input_dir), + "--output_root", + str(output_root), + "--category", + category, + ] + log_lines = ["$ " + " ".join(command)] + if not _simready_run_is_active(token): + return + yield input_preview.as_posix(), None, None, "**SimReady is running…**", "\n".join( + log_lines + ) + + try: + process = start_pipeline(command) + except Exception as exc: + if _simready_run_is_active(token): + yield input_preview.as_posix(), None, None, f"**Pipeline start failed:** {exc}", "\n".join( + log_lines + ) + return + + with _simready_run_lock: + owns_process = _simready_run_token == token + if owns_process: + _simready_process = process + if not owns_process: + terminate_process_group(process) + return + + try: + output_queue: queue.Queue[str] = queue.Queue() + reader = threading.Thread( + target=read_process_output, args=(process, output_queue), daemon=True + ) + reader.start() + while process.poll() is None: + if not _simready_run_is_active(token, process): + return + try: + while True: + log_lines.append(output_queue.get_nowait()) + except queue.Empty: + pass + # Keep the browser responsive while the Blender/LLM stages run. + yield input_preview.as_posix(), None, None, "**SimReady is running…**", "\n".join( + log_lines[-160:] + ) + time.sleep(0.5) + reader.join(timeout=1) + try: + while True: + log_lines.append(output_queue.get_nowait()) + except queue.Empty: + pass + if not _simready_run_is_active(token, process): + return + + if process.returncode != 0: + yield input_preview.as_posix(), None, None, f"**SimReady failed** (exit code {process.returncode}).", "\n".join( + log_lines[-220:] + ) + return + try: + result = _find_simready_output(output_root) + preview = ( + result + if result.suffix.lower() == ".glb" + else _export_preview(result, run_root / "output_preview.glb") + ) + yield input_preview.as_posix(), preview.as_posix(), result.as_posix(), "**SimReady completed.**", "\n".join( + log_lines[-220:] + ) + except Exception as exc: + yield input_preview.as_posix(), None, None, f"**Output error:** {exc}", "\n".join( + log_lines[-220:] + ) + finally: + _finish_simready_run(token, process) + + +def build_asset_engine_panel() -> dict[str, Any]: + """Create the Debug Asset-engine panel and return its event endpoints.""" + with gr.Column(visible=True) as panel: + gr.Markdown( + "## Asset engine\nConvert an existing mesh with SimReady, or generate a new articulated asset through Articraft and Codex. DexSim is not started in this engine." + ) + with gr.Tabs(): + with gr.Tab("SimReady"): + with gr.Row(): + uploads = gr.File( + label="3D asset and optional material files", + file_count="multiple", + type="filepath", + file_types=[ + ".glb", + ".gltf", + ".obj", + ".ply", + ".stl", + ".mtl", + ".png", + ".jpg", + ".jpeg", + ".webp", + ".bin", + ], + ) + category = gr.Textbox( + label="Asset category", + value="rigid_object", + placeholder="e.g. cup, chair, bottle", + ) + with gr.Row(): + input_model = gr.Model3D( + label="Input asset preview", + height=440, + clear_color=(0.94, 0.94, 0.94, 1.0), + ) + output_model = gr.Model3D( + label="SimReady asset preview", + height=440, + clear_color=(0.94, 0.94, 0.94, 1.0), + ) + with gr.Row(): + run_button = gr.Button("Run SimReady", variant="primary") + reset_button = gr.Button("Reset SimReady", variant="stop") + output_file = gr.File( + label="SimReady asset output", interactive=False + ) + status = gr.Markdown(_SIMREADY_IDLE_STATUS) + log = gr.Textbox(label="Pipeline log", lines=10, interactive=False) + with gr.Tab("Articulation"): + build_articraft_panel() + + uploads.change( + prepare_asset_input_preview, + inputs=[uploads], + outputs=[input_model, status], + queue=False, + ) + run_button.click( + run_simready_asset, + inputs=[uploads, category], + outputs=[input_model, output_model, output_file, status, log], + ) + reset_button.click( + reset_simready_asset, + outputs=[ + uploads, + category, + input_model, + output_model, + output_file, + status, + log, + ], + queue=False, + ) + return {"panel": panel} diff --git a/embodichain/gen_sim/gradio_ui/app_commands.py b/embodichain/gen_sim/gradio_ui/app_commands.py new file mode 100644 index 000000000..7dc1f4a1c --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_commands.py @@ -0,0 +1,173 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""CLI command builders for EmbodiChain pipelines.""" + +from __future__ import annotations + +import sys +from typing import Protocol + +from app_config import ( + COMMANDS, + ROBOT_PROFILE_FRANKA, + ROBOT_PROFILE_UR5, + ROBOT_PROFILE_UR10, + SCENE_ID, +) + + +class ScenePathsLike(Protocol): + scene_id: str + image_path: object + fast_gym_config: object + agent_config: object + + +def robot_profile_cli_value(robot_profile: str | None) -> str | None: + return { + ROBOT_PROFILE_FRANKA: "franka", + ROBOT_PROFILE_UR5: "dual_ur5", + ROBOT_PROFILE_UR10: "dual_ur10", + }.get(robot_profile) + + +def _pipeline_paths(paths: ScenePathsLike) -> tuple[str, str]: + return ( + f"gym_project/{paths.scene_id}", + f"gym_project/action_agent_pipeline/configs/{paths.scene_id}", + ) + + +def build_initial_pipeline_command( + task_text: str, + paths: ScenePathsLike, + prompt2scene_prompt: str = "", + robot_profile: str | None = None, + load_template_material: bool = False, +) -> list[str]: + prompt_root, config_dir = _pipeline_paths(paths) + command = [ + sys.executable, + "-m", + COMMANDS["pipeline"]["module"], + "--image", + str(paths.image_path.resolve()), + "--prompt2scene-output-root", + prompt_root, + "--config-output-dir", + config_dir, + "--task_name", + SCENE_ID, + "--task_description", + task_text, + *COMMANDS["pipeline"]["base_args"], + ] + if profile := robot_profile_cli_value(robot_profile): + command.extend(["--robot-profile", profile]) + if prompt2scene_prompt.strip(): + command.extend(["--prompt2scene-prompt", prompt2scene_prompt.strip()]) + if load_template_material: + command.append("--load-template-material") + return command + + +def build_scene_edit_pipeline_command( + task_text: str, + env_text: str, + paths: ScenePathsLike, + robot_profile: str | None = None, + load_template_material: bool = False, +) -> list[str]: + prompt_root, config_dir = _pipeline_paths(paths) + command = [ + sys.executable, + "-m", + COMMANDS["pipeline"]["module"], + "--prompt2scene-output-root", + prompt_root, + "--prompt2scene-prompt", + env_text, + "--config-output-dir", + config_dir, + "--task_name", + SCENE_ID, + "--task_description", + task_text, + *COMMANDS["pipeline"]["base_args"], + ] + if profile := robot_profile_cli_value(robot_profile): + command.extend(["--robot-profile", profile]) + if load_template_material: + command.append("--load-template-material") + return command + + +def build_config_command_for_paths( + task_text: str, + paths: ScenePathsLike, + robot_profile: str | None = None, + load_template_material: bool = False, +) -> list[str]: + _, config_dir = _pipeline_paths(paths) + command = [ + sys.executable, + "-m", + COMMANDS["config"]["module"], + "--gym_project", + f"gym_project/{paths.scene_id}/gym_export", + "--output_dir", + config_dir, + "--task_name", + SCENE_ID, + "--task_description", + task_text, + *COMMANDS["config"]["base_args"], + ] + if profile := robot_profile_cli_value(robot_profile): + command.extend(["--robot-profile", profile]) + if load_template_material: + command.append("--load-template-material") + return command + + +def build_run_agent_command( + paths: ScenePathsLike, + *, + parallel_env: bool = False, + robot_profile: str | None = None, + supports_robot_profile: bool = False, +) -> list[str]: + agent = COMMANDS["agent"] + command = [ + sys.executable, + "-m", + agent["module"], + "--task_name", + SCENE_ID, + "--gym_config", + str(paths.fast_gym_config), + "--agent_config", + str(paths.agent_config), + *agent["base_args"], + "--num_envs", + agent["parallel_num_envs"] if parallel_env else agent["single_num_envs"], + ] + if parallel_env: + command.extend(agent["parallel_args"]) + if supports_robot_profile and (profile := robot_profile_cli_value(robot_profile)): + command.extend(["--robot-profile", profile]) + return command diff --git a/embodichain/gen_sim/gradio_ui/app_config.py b/embodichain/gen_sim/gradio_ui/app_config.py new file mode 100644 index 000000000..21085ea53 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_config.py @@ -0,0 +1,233 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Static settings and helpers for the Gradio application. + +Deployment-specific settings live in :mod:`app_env`, which reads the shared +``embodichain/gen_sim/.env`` file. This module keeps UI constants, path +derivation, and CLI command definitions close to the application code. +""" + +from __future__ import annotations + +from pathlib import Path + +import app_env + +APP_ROOT = Path(__file__).resolve().parent +ASSETS_DIR = APP_ROOT / "assets" +DEXFORCE_LOGO = ASSETS_DIR / "dexforce.png" +INTERACT_RANDOM_PREVIEW_DIR = APP_ROOT / ".gradio_previews" +DEBUG_ENGINE_ROOT = APP_ROOT / ".debug_engine" +DEBUG_ASSET_ENGINE_ROOT = DEBUG_ENGINE_ROOT / "assets" +DEBUG_SCENE_ENGINE_ROOT = DEBUG_ENGINE_ROOT / "scenes" +SCENE_ID = "current" + +GYM_PROJECT_ROOT = app_env.EMBODICHAIN_ROOT / "gym_project" +ACTION_AGENT_ROOT = GYM_PROJECT_ROOT / "action_agent_pipeline" +IMAGE_DIR = ACTION_AGENT_ROOT / "images" +AUTO_LOG_DIR = ACTION_AGENT_ROOT / "auto_logs" +IMAGE_PATH = IMAGE_DIR / f"{SCENE_ID}.png" +PROMPT2SCENE_ROOT = GYM_PROJECT_ROOT / SCENE_ID +CONFIG_DIR = ACTION_AGENT_ROOT / "configs" / SCENE_ID +FAST_GYM_CONFIG = CONFIG_DIR / "fast_gym_config.json" +OUTPUTS_DIR = app_env.EMBODICHAIN_ROOT / "outputs" +CURRENT_GYM_EXPORT_DIR = PROMPT2SCENE_ROOT / "gym_export" +CURRENT_GYM_EXPORT_CONFIG = CURRENT_GYM_EXPORT_DIR / "gym_config.json" +GRADIO_SCENE_DIR = CONFIG_DIR / "gradio_scene" +GRADIO_SCENE_GLB = GRADIO_SCENE_DIR / "scene_current.glb" +GRADIO_INITIAL_SCENE_GLB = GRADIO_SCENE_DIR / "initial_scene.glb" +GRADIO_OBJECT_PREVIEW_GLB = GRADIO_SCENE_DIR / "object_preview.glb" +SCENE_MANIFEST = GRADIO_SCENE_DIR / "scene_manifest.json" +PENDING_PREFIX = "_gradio_pending_" +REPLACED_PREFIX = "_gradio_replaced_" +GRADIO_SCENE_TRANSFORM_POLICY = "dexsim_gltf_y_up_to_sim_z_up_v1" + +PROCESS_STOP_TIMEOUT_S = 8.0 +TEXT_REWRITE_SUFFIXES = {".json", ".jsonl", ".txt", ".yaml", ".yml", ".md", ".csv"} +VIDEO_SUFFIXES = {".mp4", ".avi", ".mov", ".mkv", ".webm"} +LEROBOT_PREVIEW_DIR = OUTPUTS_DIR / "lerobot_previews" +COMBINED_PREVIEW_DIR = OUTPUTS_DIR / "combined_previews" +LEROBOT_PREVIEW_MAX_FRAMES = 360 +COMBINED_VIDEO_FPS = 25 + +TOP_MODE_AUTO = "auto" +TOP_MODE_INTERACT = "interact" +TOP_MODE_PARALLEL_ENV = "parallel_env" +APP_MODE_DEMO = "demo" +APP_MODE_DEBUG = "debug" +DEBUG_ENGINE_ASSET = "asset_engine" +DEBUG_ENGINE_SCENE = "scene_engine" +DEBUG_ENGINE_ACTION = "action_engine" +DEBUG_ENGINES = ( + (DEBUG_ENGINE_ASSET, "Asset_engine"), + (DEBUG_ENGINE_SCENE, "Scene_engine"), + (DEBUG_ENGINE_ACTION, "Action_engine"), +) + +# SimReady accepts one mesh plus optional material/texture sidecar files. The +# File component deliberately permits the sidecars so OBJ/GLTF uploads retain +# their appearance during both preview and processing. +SIMREADY_MESH_SUFFIXES = {".glb", ".gltf", ".obj", ".ply", ".stl"} + +LANGUAGE_EN = "en" +LANGUAGE_ZH = "zh" +BUTTON_LABELS = { + LANGUAGE_EN: { + "auto": "Auto", + "interact": "Interact", + "parallel_env": "Parallel Simulation", + "rerun_simulation": "Run Task", + "generate": "Generate", + "start": "Start", + "random_input": "Random Task", + "random_scene_input": "Random Scene", + "reset": "Reset", + "stop": "Stop", + "language": "中文", + }, + LANGUAGE_ZH: { + "auto": "自动", + "interact": "交互", + "parallel_env": "并行仿真", + "rerun_simulation": "运行任务", + "generate": "生成", + "start": "开始", + "random_input": "随机任务", + "random_scene_input": "随机场景", + "reset": "重置", + "stop": "停止", + "language": "English", + }, +} +UI_TEXT = { + LANGUAGE_EN: { + "heading": "# Generative Simulation User Interface", + "instruction": "Upload one image, enter one task, then EmbodiChain will generate simulation data what you want.", + "robot": "Robot", + "input_image": "Input image", + "task_description": "Task description", + "task_placeholder": "Put the middle bottle on the book", + "scene_description": "Scene description", + "scene_placeholder": "Optional: describe how to edit the current scene", + "scene_mode": "Generation mode", + "scene_mode_initial": "Initial generation", + "scene_mode_edit": "Edit current scene", + "scene_mode_task_only": "Change task only", + "single_video_preview": "LeRobot Data Preview", + "parallel_video_preview": "Parallel Env Data Preview", + "current_task": "Current task", + "progress": "Progress", + "initial_preview": "Initial scene preview", + "edited_preview": "Edited scene preview", + "object_preview": "Generated object GLBs preview", + }, + LANGUAGE_ZH: { + "heading": "# 生成式仿真用户界面", + "instruction": "上传一张图片,输入一个任务,EmbodiChain 将生成所需的仿真数据。", + "robot": "机器人", + "input_image": "输入图像", + "task_description": "任务描述", + "task_placeholder": "把中间的水瓶放到书上", + "scene_description": "场景描述", + "scene_placeholder": "可选:描述如何编辑当前场景", + "scene_mode": "生成模式", + "scene_mode_initial": "初始生成", + "scene_mode_edit": "编辑当前场景", + "scene_mode_task_only": "仅修改任务", + "single_video_preview": "LeRobot 数据预览", + "parallel_video_preview": "并行环境数据预览", + "current_task": "当前任务", + "progress": "进度", + "initial_preview": "初始场景预览", + "edited_preview": "编辑后场景预览", + "object_preview": "生成对象 GLB 预览", + }, +} + +PIPELINE_MODE_INITIAL = "initial" +PIPELINE_MODE_EDIT = "edit" +PIPELINE_MODE_TASK_ONLY = "task_only" +SCENE_MODE_INITIAL = "initial" +SCENE_MODE_EDIT = "edit" +SCENE_MODE_TASK_ONLY = "task_only" +ROBOT_PROFILE_FRANKA = "Franka" +ROBOT_PROFILE_UR5 = "UR5" +ROBOT_PROFILE_UR10 = "UR10" +ROBOT_PROFILES = [ROBOT_PROFILE_FRANKA, ROBOT_PROFILE_UR5, ROBOT_PROFILE_UR10] +DEFAULT_ROBOT_PROFILE = ROBOT_PROFILE_UR5 +RUN_LOG_MODE_AUTO = "auto" +RUN_LOG_MODE_INTERACT = "interact" + +# Command modules and immutable argument defaults. Dynamic values are added by +# command builders in app_commands.py. +COMMANDS = { + "pipeline": { + "module": "embodichain.gen_sim.action_agent_pipeline.cli.run_agent_pipeline", + "base_args": ( + "--use-prompt2scene", + "--overwrite-config", + "--regenerate", + "--skip-run-agent", + ), + }, + "config": { + "module": "embodichain.gen_sim.action_agent_pipeline.cli.generate_action_agent_config", + "base_args": ("--overwrite",), + }, + "agent": { + "module": "embodichain.gen_sim.action_agent_pipeline.cli.run_agent", + "help_args": ("--help",), + "base_args": ("--regenerate", "--renderer", "fast-rt"), + "parallel_args": ("--arena_space", "2.2", "--filter_dataset_saving"), + "parallel_num_envs": "9", + "single_num_envs": "1", + }, + # Scene Engine is dispatched by EmbodiChain's registered top-level CLI. + "scene_engine": { + "module": "embodichain", + "base_args": ("scene-engine",), + "preview_script": "embodichain/gen_sim/scene_engine/cli/preview.py", + }, +} + +PHASE_DEFINITIONS = { + "idle": (0, "Idle"), + "received": (5, "Input received"), + "started": (10, "Local pipeline started"), + "scene_intake": (20, "Scene understanding"), + "relations": (35, "Segmentation and spatial relations"), + "asset_generation": (55, "3D asset generation"), + "gym_export": (70, "Scene export"), + "config": (82, "Action config generated"), + "preview": (90, "3D preview loaded"), + "complete": (100, "Complete"), + "failed": (100, "Failed"), +} +TIMING_PHASE_LABELS = { + "relations": "Segmentation / spatial relations", + "asset_generation": "Object generation", + "gym_export": "Scene generation / export", + "action_graph_execution": "Action graph execution", +} +TIMING_PHASE_ORDER = ( + "relations", + "asset_generation", + "gym_export", + "action_graph_execution", +) + +DEFAULT_CONCURRENCY_LIMIT = 1 diff --git a/embodichain/gen_sim/gradio_ui/app_env.py b/embodichain/gen_sim/gradio_ui/app_env.py new file mode 100644 index 000000000..f9227e25e --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_env.py @@ -0,0 +1,113 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Environment-backed deployment settings for the Gradio application.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from embodichain.gen_sim.env import get_embodichain_root, load_gen_sim_env + +__all__ = [ + "ARTICRAFT_CONDA_ENV", + "ARTICRAFT_OUTPUT_ROOT", + "ARTICRAFT_REPOSITORY_URL", + "ARTICRAFT_ROOT", + "ARTICRAFT_VISER_PORT", + "DIRECT_NO_PROXY_VALUE", + "EMBODICHAIN_ROOT", + "PROXY_ENV_KEYS", + "SCENE_ENGINE_VISER_PORT", + "SERVER_NAME", + "SERVER_PORT", + "SIMREADY_OPENAI_API_KEY", + "SIMREADY_OPENAI_BASE_URL", + "SIMREADY_OPENAI_MODEL", + "configure_direct_network_env", + "configure_simready_llm_env", +] + +load_gen_sim_env() + +APP_ROOT = Path(__file__).resolve().parent +DEBUG_ENGINE_ROOT = APP_ROOT / ".debug_engine" +PROXY_ENV_KEYS = ( + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "FTP_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "ftp_proxy", +) +DIRECT_NO_PROXY_VALUE = "*" + + +def _getenv(name: str, default: str) -> str: + """Read a non-empty shared ``.env`` value, falling back to ``default``.""" + return os.environ.get(name) or default + + +# The repository root must follow this checkout, not a machine-specific .env +# value. Its path is shared with child processes through their working +# directory, so deriving it once here keeps every Debug workflow relocatable. +EMBODICHAIN_ROOT = get_embodichain_root() +ARTICRAFT_ROOT = Path( + _getenv("ARTICRAFT_ROOT", str(APP_ROOT / ".articraft")) +).expanduser() +ARTICRAFT_REPOSITORY_URL = _getenv( + "ARTICRAFT_REPOSITORY_URL", "https://github.com/mattzh72/articraft.git" +) +ARTICRAFT_CONDA_ENV = _getenv("ARTICRAFT_CONDA_ENV", "articraft") +ARTICRAFT_OUTPUT_ROOT = Path( + _getenv("ARTICRAFT_OUTPUT_ROOT", str(DEBUG_ENGINE_ROOT / "articraft")) +).expanduser() +SCENE_ENGINE_VISER_PORT = int(_getenv("SCENE_ENGINE_VISER_PORT", "8080")) +ARTICRAFT_VISER_PORT = int(_getenv("ARTICRAFT_VISER_PORT", "8081")) +SERVER_NAME = _getenv("GRADIO_SERVER_NAME", "0.0.0.0") +SERVER_PORT = int(_getenv("GRADIO_SERVER_PORT", "7860")) +SIMREADY_OPENAI_API_KEY = _getenv("SIMREADY_OPENAI_API_KEY", "") +SIMREADY_OPENAI_MODEL = _getenv("SIMREADY_OPENAI_MODEL", "") +SIMREADY_OPENAI_BASE_URL = _getenv("SIMREADY_OPENAI_BASE_URL", "") + + +def configure_direct_network_env(env: Any = None) -> None: + """Disable proxy inheritance for local pipeline and Gradio processes.""" + if env is None: + env = os.environ + for key in PROXY_ENV_KEYS: + env.pop(key, None) + env["NO_PROXY"] = DIRECT_NO_PROXY_VALUE + env["no_proxy"] = DIRECT_NO_PROXY_VALUE + env.setdefault("GRADIO_ANALYTICS_ENABLED", "False") + + +def configure_simready_llm_env(env: Any = None) -> None: + """Map app-level SimReady settings to the upstream CLI's environment.""" + if env is None: + env = os.environ + configured_values = { + "OPENAI_API_KEY": SIMREADY_OPENAI_API_KEY, + "OPENAI_MODEL": SIMREADY_OPENAI_MODEL, + "OPENAI_BASE_URL": SIMREADY_OPENAI_BASE_URL, + } + for key, value in configured_values.items(): + if value: + env[key] = value diff --git a/embodichain/gen_sim/gradio_ui/app_media.py b/embodichain/gen_sim/gradio_ui/app_media.py new file mode 100644 index 000000000..5c4813c49 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_media.py @@ -0,0 +1,725 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Run-log archival and video/dataset preview generation.""" + +from __future__ import annotations + +import argparse +import json +import math +import shutil +import subprocess +from collections.abc import Sequence +from datetime import datetime +from pathlib import Path +from typing import Any + +import numpy as np +from PIL import Image, ImageDraw + +from app_config import * # noqa: F403 - media paths and limits are configuration. +from app_env import EMBODICHAIN_ROOT +from app_state import format_timing_lines, runtime, runtime_lock, snapshot_timing_locked + + +def archive_run_log( + *, + mode: str, + task_description: str = "", + scene_description: str = "", + outcome: str, + audience_video: Path | None = None, +) -> Path | None: + with runtime_lock: + run_logs = list(runtime.log_lines) + status_text = runtime.status + last_error = runtime.last_error + runtime_video = runtime.video_path + timing_durations, simulation_duration = snapshot_timing_locked() + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + run_dir = make_next_log_archive_dir() + video_paths, video_errors = archive_audience_video( + run_dir, + audience_video or runtime_video, + ) + log_path = run_dir / "log.md" + content = [ + f"mode: {mode}", + "", + f"Timestamp: {timestamp}", + f"Outcome: {outcome}", + "", + "## Task description", + "", + task_description or "", + "", + "## Scene description", + "", + scene_description or "", + "", + "## Status", + "", + status_text or "", + ] + if last_error: + content.extend(["", "## Last error", "", last_error]) + if video_paths: + content.extend( + [ + "", + "## Archived audience video", + "", + *[path.as_posix() for path in video_paths], + ] + ) + if video_errors: + content.extend(["", "## Video archive errors", "", *video_errors]) + content.extend( + [ + "", + "## Logs", + "", + "```text", + "\n".join(run_logs) if run_logs else "(no logs)", + "```", + "", + "## Timing", + "", + *format_timing_lines(timing_durations, simulation_duration), + "", + ] + ) + + try: + run_dir.mkdir(parents=True, exist_ok=True) + log_path.write_text("\n".join(content), encoding="utf-8") + except Exception as exc: + with runtime_lock: + runtime.log_lines.append(f"Failed to archive run log: {exc}") + return None + return log_path + + +def make_next_log_archive_dir() -> Path: + AUTO_LOG_DIR.mkdir(parents=True, exist_ok=True) + existing_indices = [ + int(path.name) + for path in AUTO_LOG_DIR.iterdir() + if path.is_dir() and path.name.isdigit() + ] + next_index = (max(existing_indices) + 1) if existing_indices else 1 + while True: + candidate = AUTO_LOG_DIR / f"{next_index:04d}" + if not candidate.exists(): + try: + candidate.mkdir(parents=True, exist_ok=False) + return candidate + except FileExistsError: + pass + next_index += 1 + + +def archive_audience_video( + run_dir: Path, + video_path: Path | None, +) -> tuple[list[Path], list[str]]: + copied_paths: list[Path] = [] + errors: list[str] = [] + if video_path is None: + return copied_paths, errors + if not video_path.is_file(): + return copied_paths, [f"Audience video not found: {video_path}"] + destination = run_dir / "audience_video" / video_path.name + try: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(video_path, destination) + except Exception as exc: + errors.append(f"Failed to archive audience video {video_path}: {exc}") + return copied_paths, errors + copied_paths.append(destination.relative_to(run_dir)) + return copied_paths, errors + + +def collect_output_videos() -> list[Path]: + if not OUTPUTS_DIR.is_dir(): + return [] + return sorted( + path + for path in OUTPUTS_DIR.rglob("*") + if path.is_file() and path.suffix.lower() in VIDEO_SUFFIXES + ) + + +def collect_audience_output_videos() -> list[Path]: + videos = collect_output_videos() + audience_videos = [ + path + for path in videos + if "audience" in path.relative_to(OUTPUTS_DIR).as_posix().lower() + ] + if audience_videos: + return audience_videos + return [ + path + for path in videos + if "audience" in path.relative_to(OUTPUTS_DIR).as_posix().lower() + ] + + +def latest_audience_output_video(min_mtime_ns: int | None = None) -> Path | None: + latest_path: Path | None = None + latest_mtime = -1 + for path in collect_audience_output_videos(): + try: + mtime = path.stat().st_mtime_ns + except OSError: + continue + if min_mtime_ns is not None and mtime < min_mtime_ns: + continue + if mtime > latest_mtime: + latest_path = path + latest_mtime = mtime + return latest_path + + +def configured_lerobot_roots() -> list[Path]: + roots: list[Path] = [] + env_root = os.environ.get("EMBODICHAIN_DATASET_ROOT") + if env_root: + roots.append(Path(env_root).expanduser()) + roots.append(Path("~/.cache/embodichain_datasets").expanduser()) + + config_roots = read_lerobot_save_paths(CURRENT_PATHS.fast_gym_config) + roots.extend(config_roots) + + normalized: list[Path] = [] + seen: set[Path] = set() + for root in roots: + root = root.expanduser() + if not root.is_absolute(): + root = EMBODICHAIN_ROOT / root + try: + resolved = root.resolve() + except OSError: + resolved = root + if resolved in seen: + continue + seen.add(resolved) + normalized.append(root) + return normalized + + +def read_lerobot_save_paths(config_path: Path) -> list[Path]: + if not config_path.is_file(): + return [] + try: + config = json.loads(config_path.read_text(encoding="utf-8")) + except Exception: + return [] + + paths: list[Path] = [] + + def visit(value: Any, key_path: tuple[str, ...] = ()) -> None: + if isinstance(value, dict): + if key_path[-2:] == ("lerobot", "params") and isinstance( + value.get("save_path"), str + ): + paths.append(Path(value["save_path"])) + for key, child in value.items(): + visit(child, (*key_path, str(key))) + elif isinstance(value, list): + for item in value: + visit(item, key_path) + + visit(config) + return paths + + +def collect_lerobot_datasets() -> list[Path]: + datasets: list[Path] = [] + for root in configured_lerobot_roots(): + if not root.is_dir(): + continue + try: + candidates = list(root.iterdir()) + except OSError: + continue + for candidate in candidates: + if not candidate.is_dir(): + continue + if (candidate / "meta" / "info.json").is_file() or ( + candidate / "data" + ).is_dir(): + datasets.append(candidate) + return datasets + + +def latest_lerobot_dataset(min_mtime_ns: int | None = None) -> Path | None: + latest_path: Path | None = None + latest_mtime = -1 + for dataset_path in collect_lerobot_datasets(): + if not lerobot_dataset_has_frames(dataset_path): + continue + mtime = latest_lerobot_dataset_mtime_ns(dataset_path) + if min_mtime_ns is not None and mtime < min_mtime_ns: + continue + if mtime > latest_mtime: + latest_path = dataset_path + latest_mtime = mtime + return latest_path + + +def lerobot_dataset_has_frames(dataset_path: Path) -> bool: + data_dir = dataset_path / "data" + return data_dir.is_dir() and any(data_dir.rglob("*.parquet")) + + +def latest_lerobot_dataset_mtime_ns(dataset_path: Path) -> int: + latest_mtime = -1 + for child in dataset_path.rglob("*"): + if not child.is_file(): + continue + try: + latest_mtime = max(latest_mtime, child.stat().st_mtime_ns) + except OSError: + continue + if latest_mtime >= 0: + return latest_mtime + try: + return dataset_path.stat().st_mtime_ns + except OSError: + return -1 + + +def build_lerobot_preview_video(dataset_path: Path) -> Path | None: + parquet_paths = sorted((dataset_path / "data").rglob("*.parquet")) + if not parquet_paths: + return None + + latest_source_mtime = max( + latest_lerobot_dataset_mtime_ns(dataset_path), + *(path.stat().st_mtime_ns for path in parquet_paths), + ) + output_path = LEROBOT_PREVIEW_DIR / f"{dataset_path.name}_data_preview.mp4" + if output_path.is_file() and output_path.stat().st_mtime_ns >= latest_source_mtime: + return output_path + + try: + import imageio.v2 as imageio + import pandas as pd + except Exception as exc: + with runtime_lock: + runtime.log_lines.append( + f"LeRobot preview skipped; missing dependency: {exc}" + ) + return None + + try: + data_frame = pd.concat( + [pd.read_parquet(path) for path in parquet_paths], + ignore_index=True, + ) + except Exception as exc: + with runtime_lock: + runtime.log_lines.append(f"LeRobot preview skipped; read failed: {exc}") + return None + + if data_frame.empty: + return None + + try: + fps = read_lerobot_fps(dataset_path) or 25 + fps = max(1, min(int(round(fps)), 30)) + frames = render_lerobot_data_frames(data_frame, dataset_path.name) + if not frames: + return None + output_path.parent.mkdir(parents=True, exist_ok=True) + with imageio.get_writer(output_path, fps=fps, codec="libx264") as writer: + for frame in frames: + writer.append_data(frame) + except Exception as exc: + with runtime_lock: + runtime.log_lines.append(f"LeRobot preview skipped; render failed: {exc}") + return None + + return output_path + + +def video_duration_seconds(video_path: Path) -> float | None: + command = [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(video_path), + ] + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + timeout=15, + ) + duration = float(result.stdout.strip()) + except (OSError, ValueError, subprocess.TimeoutExpired): + return None + return duration if duration > 0 else None + + +def build_single_env_combined_video( + audience_video: Path | None, + lerobot_video: Path | None, +) -> Path | None: + """Create a synchronized side-by-side simulation and LeRobot video.""" + if ( + audience_video is None + or lerobot_video is None + or not audience_video.is_file() + or not lerobot_video.is_file() + ): + return None + + audience_duration = video_duration_seconds(audience_video) + lerobot_duration = video_duration_seconds(lerobot_video) + if audience_duration is None or lerobot_duration is None: + return None + + latest_source_mtime = max( + audience_video.stat().st_mtime_ns, + lerobot_video.stat().st_mtime_ns, + ) + output_path = ( + COMBINED_PREVIEW_DIR + / f"{safe_filename_part(audience_video.stem)}_with_lerobot.mp4" + ) + if output_path.is_file() and output_path.stat().st_mtime_ns >= latest_source_mtime: + return output_path + + lerobot_time_scale = audience_duration / lerobot_duration + filter_graph = ( + f"[0:v]fps={COMBINED_VIDEO_FPS},scale=960:540:force_original_aspect_ratio=decrease," + "pad=960:540:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1," + "setpts=PTS-STARTPTS[sim];" + f"[1:v]fps={COMBINED_VIDEO_FPS},scale=960:540:force_original_aspect_ratio=decrease," + "pad=960:540:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1," + f"setpts=(PTS-STARTPTS)*{lerobot_time_scale:.9f}[data];" + "[sim][data]hstack=inputs=2:shortest=1,format=yuv420p[video]" + ) + command = [ + "ffmpeg", + "-y", + "-i", + str(audience_video), + "-i", + str(lerobot_video), + "-filter_complex", + filter_graph, + "-map", + "[video]", + "-an", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "23", + "-movflags", + "+faststart", + str(output_path), + ] + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + timeout=180, + ) + if result.returncode == 0 and output_path.is_file(): + return output_path + with runtime_lock: + runtime.log_lines.append( + "Combined video skipped: " + + ( + result.stderr.strip().splitlines()[-1] + if result.stderr + else "ffmpeg failed" + ) + ) + except (OSError, subprocess.TimeoutExpired) as exc: + with runtime_lock: + runtime.log_lines.append(f"Combined video skipped: {exc}") + return None + + +def read_lerobot_fps(dataset_path: Path) -> int | None: + info_path = dataset_path / "meta" / "info.json" + if not info_path.is_file(): + return None + try: + info = json.loads(info_path.read_text(encoding="utf-8")) + except Exception: + return None + fps = info.get("fps") + if isinstance(fps, (int, float)): + return int(fps) + return None + + +def render_lerobot_data_frames(data_frame: Any, dataset_name: str) -> list[np.ndarray]: + total_rows = len(data_frame) + frame_indices = np.linspace( + 0, + total_rows - 1, + num=min(total_rows, LEROBOT_PREVIEW_MAX_FRAMES), + dtype=int, + ) + state = series_to_matrix(data_frame.get("observation.state")) + action = series_to_matrix(data_frame.get("action")) + qvel = series_to_matrix(data_frame.get("observation.qvel")) + timestamps = numeric_column(data_frame, "timestamp", total_rows) + + frames: list[np.ndarray] = [] + for row_index in frame_indices: + image = Image.new("RGB", (960, 544), (247, 248, 250)) + draw = ImageDraw.Draw(image) + draw_lerobot_header( + draw, + dataset_name=dataset_name, + row_index=int(row_index), + total_rows=total_rows, + timestamp=float(timestamps[row_index]) if len(timestamps) else None, + ) + draw_signal_panel( + draw, (40, 96, 920, 220), state, row_index, "observation.state" + ) + draw_signal_panel(draw, (40, 244, 920, 368), action, row_index, "action") + draw_bar_panel(draw, (40, 392, 920, 506), qvel, row_index, "observation.qvel") + frames.append(np.asarray(image)) + return frames + + +def series_to_matrix(series: Any, max_dims: int = 12) -> np.ndarray: + if series is None: + return np.empty((0, 0), dtype=float) + rows: list[np.ndarray] = [] + for value in series: + array = np.asarray(value, dtype=float).reshape(-1) + if array.size: + rows.append(array[:max_dims]) + if not rows: + return np.empty((0, 0), dtype=float) + width = max(row.size for row in rows) + matrix = np.full((len(rows), width), np.nan, dtype=float) + for index, row in enumerate(rows): + matrix[index, : row.size] = row + return matrix + + +def numeric_column(data_frame: Any, column: str, fallback_length: int) -> np.ndarray: + if column not in data_frame: + return np.arange(fallback_length, dtype=float) + try: + values = np.asarray(data_frame[column], dtype=float) + except Exception: + values = np.arange(fallback_length, dtype=float) + return values + + +def draw_lerobot_header( + draw: ImageDraw.ImageDraw, + *, + dataset_name: str, + row_index: int, + total_rows: int, + timestamp: float | None, +) -> None: + draw.text((40, 28), "LeRobot dataset preview", fill=(17, 24, 39)) + short_name = dataset_name if len(dataset_name) <= 78 else f"{dataset_name[:75]}..." + draw.text((40, 54), short_name, fill=(75, 85, 99)) + progress = 0 if total_rows <= 1 else row_index / (total_rows - 1) + draw.text((750, 28), f"frame {row_index + 1}/{total_rows}", fill=(17, 24, 39)) + if timestamp is not None: + draw.text((750, 54), f"t = {timestamp:.2f}s", fill=(75, 85, 99)) + draw.rectangle((40, 78, 920, 82), fill=(224, 231, 239)) + draw.rectangle((40, 78, int(40 + 880 * progress), 82), fill=(37, 99, 235)) + + +def draw_signal_panel( + draw: ImageDraw.ImageDraw, + box: tuple[int, int, int, int], + matrix: np.ndarray, + row_index: int, + title: str, +) -> None: + x0, y0, x1, y1 = box + draw.rounded_rectangle(box, radius=8, fill=(255, 255, 255), outline=(209, 213, 219)) + draw.text((x0 + 14, y0 + 10), title, fill=(17, 24, 39)) + if matrix.size == 0: + draw.text((x0 + 14, y0 + 48), "No numeric data", fill=(107, 114, 128)) + return + plot_box = (x0 + 14, y0 + 36, x1 - 14, y1 - 16) + draw_timeseries(draw, plot_box, matrix, row_index) + + +def draw_bar_panel( + draw: ImageDraw.ImageDraw, + box: tuple[int, int, int, int], + matrix: np.ndarray, + row_index: int, + title: str, +) -> None: + x0, y0, x1, y1 = box + draw.rounded_rectangle(box, radius=8, fill=(255, 255, 255), outline=(209, 213, 219)) + draw.text((x0 + 14, y0 + 10), title, fill=(17, 24, 39)) + if matrix.size == 0 or row_index >= len(matrix): + draw.text((x0 + 14, y0 + 48), "No numeric data", fill=(107, 114, 128)) + return + values = matrix[row_index] + finite = values[np.isfinite(values)] + if finite.size == 0: + return + max_abs = max(float(np.nanmax(np.abs(finite))), 1e-6) + base_y = y1 - 30 + left = x0 + 18 + available_width = x1 - x0 - 36 + bar_count = min(len(values), 12) + bar_gap = 8 + bar_width = max(8, (available_width - bar_gap * (bar_count - 1)) // bar_count) + for index in range(bar_count): + value = values[index] + if not np.isfinite(value): + continue + x = left + index * (bar_width + bar_gap) + height = int((abs(float(value)) / max_abs) * 58) + color = (22, 163, 74) if value >= 0 else (220, 38, 38) + y_top = base_y - height + draw.rectangle((x, y_top, x + bar_width, base_y), fill=color) + draw.text((x, base_y + 5), str(index), fill=(107, 114, 128)) + + +def draw_timeseries( + draw: ImageDraw.ImageDraw, + box: tuple[int, int, int, int], + matrix: np.ndarray, + row_index: int, +) -> None: + x0, y0, x1, y1 = box + draw.rectangle(box, outline=(229, 231, 235)) + sample_count = min(len(matrix), LEROBOT_PREVIEW_MAX_FRAMES) + if sample_count <= 1: + return + sampled = matrix[ + np.linspace(0, len(matrix) - 1, num=sample_count, dtype=int), + : min(matrix.shape[1], 8), + ] + finite = sampled[np.isfinite(sampled)] + if finite.size == 0: + return + minimum = float(np.nanmin(finite)) + maximum = float(np.nanmax(finite)) + if math.isclose(minimum, maximum): + minimum -= 1.0 + maximum += 1.0 + palette = [ + (37, 99, 235), + (5, 150, 105), + (217, 119, 6), + (220, 38, 38), + (124, 58, 237), + (8, 145, 178), + (79, 70, 229), + (202, 138, 4), + ] + + def point(sample_index: int, value: float) -> tuple[int, int]: + x = int(x0 + (x1 - x0) * sample_index / (sample_count - 1)) + y = int(y1 - (y1 - y0) * (value - minimum) / (maximum - minimum)) + return x, y + + for dim in range(sampled.shape[1]): + points = [ + point(index, float(value)) + for index, value in enumerate(sampled[:, dim]) + if np.isfinite(value) + ] + if len(points) >= 2: + draw.line(points, fill=palette[dim % len(palette)], width=2) + + cursor_x = int(x0 + (x1 - x0) * row_index / max(len(matrix) - 1, 1)) + draw.line((cursor_x, y0, cursor_x, y1), fill=(17, 24, 39), width=2) + + +def safe_filename_part(value: str) -> str: + safe = "".join( + char if char.isalnum() or char in {"-", "_"} else "_" for char in value.strip() + ) + return safe.strip("_")[:80] + + +def run_articraft_viser_preview(args: argparse.Namespace) -> None: + """Load an Articraft URDF and publish its initial scene topology to Viser. + + The generic asset-preview command starts Viser lazily. For an asset loaded + before that first capture, explicitly marking the topology dirty and + capturing once more ensures the initial scene is sent to the browser. + + Args: + args: Parsed preview-asset command-line arguments. + """ + from embodichain.lab.scripts import preview_asset + from embodichain.lab.sim.sim_manager import SimulationManager + from embodichain.utils.logger import log_info + + sim = SimulationManager(preview_asset.build_sim_cfg(args)) + try: + if args.env_map: + log_info(f"Setting environment map: {args.env_map} ...", color="green") + sim.set_indirect_lighting(args.env_map) + + assets = preview_asset.load_assets(sim, args) + log_info(f"Loaded {len(assets)} asset(s) successfully.", color="green") + if args.viser: + sim.start_visualization() + sim.notify_visualization_topology_changed() + sim.capture_visualization_safely(force=True) + preview_asset._run_preview_mode(sim, assets, args) + finally: + log_info("Destroying simulation ...", color="green") + sim.destroy() + + +def articraft_viser_preview_cli(argv: Sequence[str] | None = None) -> None: + """Run the Articraft-aware variant of the generic preview-asset CLI. + + Args: + argv: Arguments excluding the program name, or ``None`` for ``sys.argv``. + """ + from embodichain.lab.scripts import preview_asset + + parser = preview_asset._create_parser() + run_articraft_viser_preview(parser.parse_args(argv)) + + +if __name__ == "__main__": + articraft_viser_preview_cli() diff --git a/embodichain/gen_sim/gradio_ui/app_processes.py b/embodichain/gen_sim/gradio_ui/app_processes.py new file mode 100644 index 000000000..a0f414f3a --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_processes.py @@ -0,0 +1,348 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Pipeline subprocess execution and progress detection.""" + +from __future__ import annotations + +import os +import queue +import signal +import subprocess +import sys +import threading +import time +from pathlib import Path + +from app_config import COMMANDS, PROCESS_STOP_TIMEOUT_S +from app_env import ( + EMBODICHAIN_ROOT, + configure_direct_network_env, + configure_simready_llm_env, +) +from app_state import PHASES + +__all__ = [ + "build_pipeline_env", + "build_run_agent_command", + "detect_phase_from_files", + "force_stop_all_child_processes", + "read_process_output", + "register_managed_process", + "run_agent_cli_supports_robot_profile", + "start_pipeline", + "terminate_process_group", + "update_phase_from_log", +] + +_RUN_AGENT_SUPPORTS_ROBOT_PROFILE: bool | None = None +_managed_processes: dict[int, subprocess.Popen[str]] = {} +_managed_processes_lock = threading.Lock() +_shutdown_requested = False + + +def run_agent_cli_supports_robot_profile() -> bool: + global _RUN_AGENT_SUPPORTS_ROBOT_PROFILE + if _RUN_AGENT_SUPPORTS_ROBOT_PROFILE is not None: + return _RUN_AGENT_SUPPORTS_ROBOT_PROFILE + try: + result = subprocess.run( + [ + sys.executable, + "-m", + COMMANDS["agent"]["module"], + *COMMANDS["agent"]["help_args"], + ], + cwd=EMBODICHAIN_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=build_pipeline_env(), + timeout=20, + ) + help_text = (result.stdout or "").lower() + _RUN_AGENT_SUPPORTS_ROBOT_PROFILE = "--robot-profile" in help_text + except Exception: + _RUN_AGENT_SUPPORTS_ROBOT_PROFILE = False + return _RUN_AGENT_SUPPORTS_ROBOT_PROFILE + + +def build_run_agent_command( + paths: ScenePaths, *, parallel_env: bool = False, robot_profile: str | None = None +) -> list[str]: + from app_commands import build_run_agent_command as build_command + + return build_command( + paths, + parallel_env=parallel_env, + robot_profile=robot_profile, + supports_robot_profile=run_agent_cli_supports_robot_profile(), + ) + + +def start_pipeline(command: list[str]) -> subprocess.Popen[str]: + env = build_pipeline_env() + env["PYTHONUNBUFFERED"] = "1" + return register_managed_process( + subprocess.Popen( + command, + cwd=EMBODICHAIN_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=True, + env=env, + ) + ) + + +def build_pipeline_env() -> dict[str, str]: + env = os.environ.copy() + configure_direct_network_env(env) + configure_simready_llm_env(env) + return env + + +def register_managed_process( + process: subprocess.Popen[str], +) -> subprocess.Popen[str]: + """Register a UI-owned subprocess for application-shutdown cleanup. + + Processes must be registered immediately after they are created. If Gradio + shutdown has already begun, the new process is stopped before this function + returns so a callback cannot leave an orphan behind. + """ + with _managed_processes_lock: + if not _shutdown_requested: + _managed_processes[process.pid] = process + return process + + terminate_process_group(process) + return process + + +def _unregister_managed_process(process: subprocess.Popen[str]) -> None: + with _managed_processes_lock: + _managed_processes.pop(process.pid, None) + + +def _child_process_ids(parent_pid: int) -> set[int]: + """Return a snapshot of every descendant of ``parent_pid`` on POSIX.""" + try: + result = subprocess.run( + ["ps", "-eo", "pid=,ppid="], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=2, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return set() + + children_by_parent: dict[int, set[int]] = {} + for line in result.stdout.splitlines(): + fields = line.split() + if len(fields) != 2 or not all(field.isdecimal() for field in fields): + continue + pid, ppid = (int(field) for field in fields) + children_by_parent.setdefault(ppid, set()).add(pid) + + descendants: set[int] = set() + pending = list(children_by_parent.get(parent_pid, set())) + while pending: + pid = pending.pop() + if pid in descendants: + continue + descendants.add(pid) + pending.extend(children_by_parent.get(pid, set())) + return descendants + + +def _force_stop_process_ids(process_ids: set[int]) -> None: + """Stop unregistered child PIDs, escalating from SIGTERM to SIGKILL.""" + process_ids.discard(os.getpid()) + for pid in process_ids: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + continue + except PermissionError: + continue + + deadline = time.monotonic() + PROCESS_STOP_TIMEOUT_S + remaining = set(process_ids) + while remaining and time.monotonic() < deadline: + remaining = {pid for pid in remaining if _process_is_running(pid)} + if remaining: + time.sleep(0.1) + + for pid in remaining: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + continue + except PermissionError: + continue + + +def _process_is_running(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + try: + status = Path(f"/proc/{pid}/stat").read_text().rsplit(")", maxsplit=1)[1] + except (FileNotFoundError, IndexError, PermissionError): + return True + return not status.lstrip().startswith("Z") + + +def force_stop_all_child_processes() -> None: + """Force-stop every subprocess owned by the Gradio application. + + Registered processes are stopped by their isolated process groups, which + also stops their descendants. A second descendant scan catches short-lived + or legacy subprocesses that were not registered explicitly. + """ + global _shutdown_requested + with _managed_processes_lock: + _shutdown_requested = True + managed_processes = tuple(_managed_processes.values()) + child_process_ids = _child_process_ids(os.getpid()) + + for process in managed_processes: + terminate_process_group(process) + + _force_stop_process_ids(child_process_ids) + + +def terminate_process_group(process: subprocess.Popen[str]) -> None: + try: + if process.poll() is not None: + return + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + except Exception: + process.terminate() + + deadline = time.monotonic() + PROCESS_STOP_TIMEOUT_S + while time.monotonic() < deadline: + if process.poll() is not None: + return + time.sleep(0.2) + + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + return + except Exception: + process.kill() + finally: + _unregister_managed_process(process) + + +def detect_phase_from_files(current_key: str, paths: ScenePaths) -> str: + candidates = [ + ("scene_intake", paths.prompt_root / "scene_intake" / "result.json"), + ("relations", paths.prompt_root / "image_segments" / "result.json"), + ( + "relations", + paths.prompt_root / "image_spatial_relations" / "result.json", + ), + ("gym_export", paths.prompt_root / "gym_export" / "gym_config.json"), + ("config", paths.fast_gym_config), + ("preview", paths.gradio_scene_glb), + ] + best_key = current_key + best_progress = PHASES.get(best_key, PHASES["idle"]).progress + + if any(paths.prompt_root.glob("unified_scene_gen/**/*.glb")): + best_key, best_progress = _choose_later_phase( + best_key, + best_progress, + "asset_generation", + ) + for phase_key, marker in candidates: + if marker.exists(): + best_key, best_progress = _choose_later_phase( + best_key, + best_progress, + phase_key, + ) + return best_key + + +def _choose_later_phase( + current_key: str, + current_progress: int, + candidate_key: str, +) -> tuple[str, int]: + candidate_progress = PHASES[candidate_key].progress + if candidate_progress > current_progress: + return candidate_key, candidate_progress + return current_key, current_progress + + +def update_phase_from_log(line: str, current_key: str) -> str: + text = line.lower() + mapping = [ + ("scene_intake", "scene_intake"), + ("image_segments", "relations"), + ("image_spatial_relations", "relations"), + ("unified_scene_gen", "asset_generation"), + ("glb", "asset_generation"), + ("gym_export", "gym_export"), + ("generated gym config", "config"), + ("fast_gym_config", "config"), + ] + best_key = current_key + best_progress = PHASES.get(best_key, PHASES["idle"]).progress + for needle, phase_key in mapping: + if needle in text: + best_key, best_progress = _choose_later_phase( + best_key, + best_progress, + phase_key, + ) + return best_key + + +def read_process_output( + process: subprocess.Popen[str], + output_queue: queue.Queue[str], + log_path: Path | None = None, +) -> None: + """Forward merged subprocess output to the UI queue and an optional log.""" + if process.stdout is None: + return + log_file = log_path.open("a", encoding="utf-8") if log_path is not None else None + try: + for line in process.stdout: + output_queue.put(line.rstrip()) + if log_file is not None: + log_file.write(line) + if not line.endswith("\n"): + log_file.write("\n") + log_file.flush() + finally: + if log_file is not None: + log_file.close() diff --git a/embodichain/gen_sim/gradio_ui/app_services.py b/embodichain/gen_sim/gradio_ui/app_services.py new file mode 100644 index 000000000..36b7fccdf --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_services.py @@ -0,0 +1,28 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Compatibility facade for application services. + +The executable workflow lives in :mod:`app_workflows`; the Gradio view lives +in :mod:`app_ui`. Keep this module small so existing imports remain valid +while callers move to the focused modules. +""" + +from __future__ import annotations + +from app_ui import build_demo + +__all__ = ["build_demo"] diff --git a/embodichain/gen_sim/gradio_ui/app_state.py b/embodichain/gen_sim/gradio_ui/app_state.py new file mode 100644 index 000000000..9ad6b9dac --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_state.py @@ -0,0 +1,189 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Shared, thread-safe runtime state and timing helpers.""" + +from __future__ import annotations + +import subprocess +import threading +import time +import uuid +from collections import deque +from dataclasses import dataclass, field +from pathlib import Path + +from app_config import ( + DEFAULT_ROBOT_PROFILE, + LANGUAGE_EN, + PHASE_DEFINITIONS, + SCENE_MODE_INITIAL, + TIMING_PHASE_LABELS, + TIMING_PHASE_ORDER, +) + + +@dataclass(frozen=True) +class Phase: + progress: int + label: str + + +PHASES = {key: Phase(*value) for key, value in PHASE_DEFINITIONS.items()} + + +@dataclass +class RuntimeState: + is_busy: bool = False + run_token: str = field(default_factory=lambda: uuid.uuid4().hex) + auto_loop_active: bool = False + auto_loop_token: str | None = None + auto_round: int = 0 + auto_scene_mode: str = SCENE_MODE_INITIAL + auto_parallel_env: bool = False + auto_robot_profile: str = DEFAULT_ROBOT_PROFILE + language: str = LANGUAGE_EN + process: subprocess.Popen[str] | None = None + sim_process: subprocess.Popen[str] | None = None + scene_engine_process: subprocess.Popen[str] | None = None + scene_preview_process: subprocess.Popen[str] | None = None + scene_engine_is_running: bool = False + sim_started: bool = False + sim_finished: bool = False + sim_returncode: int | None = None + phase_key: str = "idle" + status: str = "Idle." + task_text: str = "" + input_task_text: str = "" + input_scene_text: str = "" + image_path: Path | None = None + video_path: Path | None = None + last_sent_video_signature: tuple[str, int] | None = None + lerobot_video_path: Path | None = None + lerobot_dataset_path: Path | None = None + submitted_input_revision: int = 0 + object_model_path: Path | None = None + scene_model_path: Path | None = None + edited_scene_model_path: Path | None = None + last_error: str | None = None + log_lines: deque[str] = field(default_factory=deque) + timing_started_ns: int | None = None + current_timing_phase_key: str | None = None + current_timing_phase_started_ns: int | None = None + phase_durations_ns: dict[str, int] = field(default_factory=dict) + simulation_started_monotonic_ns: int | None = None + simulation_duration_ns: int | None = None + + +runtime = RuntimeState() +runtime_lock = threading.Lock() + + +def clear_run_timing_locked() -> None: + runtime.timing_started_ns = None + runtime.current_timing_phase_key = None + runtime.current_timing_phase_started_ns = None + runtime.phase_durations_ns.clear() + runtime.simulation_started_monotonic_ns = None + runtime.simulation_duration_ns = None + + +def start_run_timing_locked(phase_key: str) -> None: + now_ns = time.monotonic_ns() + runtime.timing_started_ns = now_ns + runtime.current_timing_phase_key = phase_key + runtime.current_timing_phase_started_ns = now_ns + runtime.phase_durations_ns.clear() + runtime.simulation_started_monotonic_ns = None + runtime.simulation_duration_ns = None + + +def record_phase_transition_locked(new_phase_key: str) -> None: + current_key = runtime.current_timing_phase_key + current_started_ns = runtime.current_timing_phase_started_ns + now_ns = time.monotonic_ns() + if current_key is None or current_started_ns is None: + runtime.timing_started_ns = runtime.timing_started_ns or now_ns + runtime.current_timing_phase_key = new_phase_key + runtime.current_timing_phase_started_ns = now_ns + return + if new_phase_key == current_key: + return + runtime.phase_durations_ns[current_key] = runtime.phase_durations_ns.get( + current_key, 0 + ) + max(0, now_ns - current_started_ns) + runtime.current_timing_phase_key = new_phase_key + runtime.current_timing_phase_started_ns = now_ns + + +def set_runtime_phase_locked(new_phase_key: str) -> None: + record_phase_transition_locked(new_phase_key) + runtime.phase_key = new_phase_key + + +def record_simulation_started_locked() -> None: + runtime.simulation_started_monotonic_ns = time.monotonic_ns() + runtime.simulation_duration_ns = None + + +def record_simulation_finished_locked() -> None: + started_ns = runtime.simulation_started_monotonic_ns + if started_ns is not None: + runtime.simulation_duration_ns = max(0, time.monotonic_ns() - started_ns) + runtime.simulation_started_monotonic_ns = None + + +def snapshot_timing_locked() -> tuple[dict[str, int], int | None]: + durations = dict(runtime.phase_durations_ns) + current_key = runtime.current_timing_phase_key + current_started_ns = runtime.current_timing_phase_started_ns + if ( + current_key + and current_started_ns is not None + and current_key not in {"complete", "failed", "idle"} + ): + durations[current_key] = durations.get(current_key, 0) + max( + 0, time.monotonic_ns() - current_started_ns + ) + simulation_duration_ns = runtime.simulation_duration_ns + if ( + simulation_duration_ns is None + and runtime.simulation_started_monotonic_ns is not None + ): + simulation_duration_ns = max( + 0, time.monotonic_ns() - runtime.simulation_started_monotonic_ns + ) + return durations, simulation_duration_ns + + +def format_duration_ns(duration_ns: int) -> str: + seconds = duration_ns / 1_000_000_000 + if seconds < 60: + return f"{seconds:.2f}s" + minutes = int(seconds // 60) + return f"{minutes}m {seconds - minutes * 60:05.2f}s" + + +def format_timing_lines( + phase_durations_ns: dict[str, int], simulation_duration_ns: int | None +) -> list[str]: + timing_values = dict(phase_durations_ns) + if simulation_duration_ns is not None: + timing_values["action_graph_execution"] = simulation_duration_ns + return [ + f"- {TIMING_PHASE_LABELS[key]}: {format_duration_ns(value) if (value := timing_values.get(key)) is not None else 'skipped'}" + for key in TIMING_PHASE_ORDER + ] diff --git a/embodichain/gen_sim/gradio_ui/app_ui.py b/embodichain/gen_sim/gradio_ui/app_ui.py new file mode 100644 index 000000000..92e30eeed --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_ui.py @@ -0,0 +1,586 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Gradio layout and event bindings. + +The workflow layer supplies all callbacks; this module only owns presentation +and wires components to those callbacks. +""" + +from __future__ import annotations + +from app_workflows import * # noqa: F401,F403 - callbacks/constants form the UI contract. +from app_asset_engine import build_asset_engine_panel + + +def select_application_mode(selected_mode: str | None): + """Switch between the full product UI and the focused engine UI.""" + is_debug = selected_mode == APP_MODE_DEBUG + debug_css = ( + "" if is_debug else "" + ) + return ( + gr.update(variant="secondary" if is_debug else "primary"), + gr.update(variant="primary" if is_debug else "secondary"), + gr.update(visible=is_debug), + gr.update(value=debug_css), + APP_MODE_DEBUG if is_debug else APP_MODE_DEMO, + gr.update(visible=is_debug), + ) + + +def select_debug_engine(selected_engine: str): + """Expose an explicit active state without starting any pipeline.""" + button_updates = tuple( + gr.update(variant="primary" if engine == selected_engine else "secondary") + for engine, _ in DEBUG_ENGINES + ) + return ( + *button_updates, + gr.update(visible=selected_engine == DEBUG_ENGINE_ASSET), + gr.update(visible=selected_engine == DEBUG_ENGINE_SCENE), + gr.update(visible=selected_engine == DEBUG_ENGINE_ACTION), + ) + + +def action_engine_snapshot(): + """Adapt the shared runtime snapshot to the five Action-engine widgets.""" + video, task, progress, status, initial, edited, _objects = ui_snapshot() + return video, task, progress, status, initial or edited + + +def run_action_engine_panel(task_text: str, robot_profile: str | None): + run_action_engine_from_current(task_text, robot_profile) + return action_engine_snapshot() + + +def build_demo() -> gr.Blocks: + with gr.Blocks(title="EmbodiChain Gradio") as demo: + app_mode = gr.State(APP_MODE_DEMO) + run_mode = gr.State(TOP_MODE_INTERACT) + action_mode = gr.State(None) + language = gr.State(LANGUAGE_EN) + last_seen_input_revision = gr.State(0) + interact_prebuilt_scene_dir = gr.State(None) + mode_style = gr.HTML(value="", visible=True) + with gr.Row(): + demo_mode_button = gr.Button("Demo", variant="primary") + debug_mode_button = gr.Button("Debug", variant="secondary") + with gr.Row(visible=False) as debug_controls: + asset_engine_button = gr.Button("Asset_engine", variant="primary") + scene_engine_button = gr.Button("Scene_engine", variant="secondary") + action_engine_button = gr.Button("Action_engine", variant="secondary") + with gr.Column(visible=False) as debug_engine_area: + asset_engine = build_asset_engine_panel() + with gr.Column(visible=False) as scene_engine_panel: + gr.Markdown( + "## Scene engine\n" + "Upload one image to generate a Scene Engine export. " + "The resulting Viser page is shown below." + ) + with gr.Row(): + with gr.Column(scale=1): + debug_scene_image = gr.Image( + label=UI_TEXT[LANGUAGE_EN]["input_image"], + sources=["upload", "webcam"], + type="filepath", + format="png", + height=300, + ) + with gr.Row(): + debug_scene_run = gr.Button( + "Generate scene", variant="primary" + ) + debug_scene_reset = gr.Button( + "Reset Scene Engine", variant="stop" + ) + with gr.Column(scale=2): + debug_scene_progress = gr.Slider( + 0, + 100, + value=0, + step=1, + label=UI_TEXT[LANGUAGE_EN]["progress"], + interactive=False, + ) + debug_scene_status = gr.Markdown(format_status("Idle.")) + debug_scene_output = gr.Textbox( + label="Scene output directory (hash-named)", + interactive=False, + ) + debug_scene_preview = gr.HTML( + "
" + "The Viser preview will appear here after generation." + "
" + ) + with gr.Column(visible=False) as action_engine_panel: + gr.Markdown( + "## Action engine\nUses the Gym scene produced by Scene engine (not merely a rendered GLB), then generates the action config and launches DexSim. This retains collisions, poses and physics metadata required by simulation." + ) + with gr.Row(): + with gr.Column(scale=1): + debug_action_task = gr.Textbox( + label="Task description", + placeholder="e.g. Put the bottle on the table", + ) + debug_action_robot = gr.Radio( + choices=ROBOT_PROFILES, + value=DEFAULT_ROBOT_PROFILE, + label=UI_TEXT[LANGUAGE_EN]["robot"], + ) + debug_action_load = gr.Button("Load current scene") + debug_action_run = gr.Button("Run DexSim", variant="primary") + with gr.Column(scale=2): + debug_action_scene = gr.Model3D( + label="Input Gym scene preview", + height=420, + clear_color=(0.94, 0.94, 0.94, 1.0), + ) + debug_action_video = gr.Video( + label=UI_TEXT[LANGUAGE_EN]["single_video_preview"], + height=320, + autoplay=True, + loop=True, + ) + debug_action_current_task = gr.Textbox( + label=UI_TEXT[LANGUAGE_EN]["current_task"], + interactive=False, + ) + debug_action_progress = gr.Slider( + 0, + 100, + value=0, + step=1, + label=UI_TEXT[LANGUAGE_EN]["progress"], + interactive=False, + ) + debug_action_status = gr.Markdown( + format_status("Load or generate a scene first.") + ) + debug_action_refresh_timer = gr.Timer(2.0) + with gr.Row(equal_height=True, elem_classes="demo-only"): + if DEXFORCE_LOGO.is_file(): + gr.Image( + value=str(DEXFORCE_LOGO), + show_label=False, + container=False, + height=58, + width=183, + ) + heading = gr.Markdown(UI_TEXT[LANGUAGE_EN]["heading"]) + with gr.Row(elem_classes="demo-only"): + auto_button = gr.Button("Auto", variant="secondary") + interact_button = gr.Button("Interact", variant="primary") + parallel_env_button = gr.Button("Parallel Simulation", variant="secondary") + language_button = gr.Button("中文", variant="secondary") + with gr.Row(elem_classes="demo-only"): + with gr.Column(scale=4): + instruction = gr.HTML( + "
" + "Upload one image, enter one task, then EmbodiChain " + " will generate what you want." + "
" + ) + with gr.Column(scale=1): + robot_profile = gr.Radio( + choices=ROBOT_PROFILES, + value=DEFAULT_ROBOT_PROFILE, + label=UI_TEXT[LANGUAGE_EN]["robot"], + ) + + with gr.Row(elem_classes="demo-only"): + with gr.Column(scale=1): + image_input = gr.Image( + label=UI_TEXT[LANGUAGE_EN]["input_image"], + sources=["upload", "webcam"], + type="filepath", + format="png", + height=320, + ) + with gr.Row(): + with gr.Column(): + task_input = gr.Textbox( + label=UI_TEXT[LANGUAGE_EN]["task_description"], + placeholder=UI_TEXT[LANGUAGE_EN]["task_placeholder"], + lines=1, + ) + random_task_input_button = gr.Button("Random Task") + with gr.Column(): + env_input = gr.Textbox( + label=UI_TEXT[LANGUAGE_EN]["scene_description"], + placeholder=UI_TEXT[LANGUAGE_EN]["scene_placeholder"], + lines=1, + ) + random_scene_input_button = gr.Button("Random Scene") + scene_mode = gr.Radio( + choices=scene_mode_choices(LANGUAGE_EN), + value=SCENE_MODE_INITIAL, + label=UI_TEXT[LANGUAGE_EN]["scene_mode"], + ) + with gr.Row(): + generate_button = gr.Button("Generate", variant="primary") + rerun_simulation_button = gr.Button("Run Task", variant="secondary") + reset_button = gr.Button("Reset", variant="stop") + with gr.Column(scale=2): + current_image = gr.Video( + label=UI_TEXT[LANGUAGE_EN]["single_video_preview"], + height=420, + elem_id="embodichain-video-preview", + autoplay=True, + loop=True, + ) + current_task = gr.Textbox( + label=UI_TEXT[LANGUAGE_EN]["current_task"], + interactive=False, + lines=2, + ) + + progress = gr.Slider( + minimum=0, + maximum=100, + value=0, + step=1, + label=UI_TEXT[LANGUAGE_EN]["progress"], + interactive=False, + elem_classes="demo-only", + ) + status = gr.Markdown(format_status("Idle."), elem_classes="demo-only") + with gr.Row(elem_classes="demo-only"): + model = gr.Model3D( + label=UI_TEXT[LANGUAGE_EN]["initial_preview"], + height=520, + clear_color=(0.94, 0.94, 0.94, 1.0), + ) + edited_model = gr.Model3D( + label=UI_TEXT[LANGUAGE_EN]["edited_preview"], + height=520, + clear_color=(0.94, 0.94, 0.94, 1.0), + ) + object_model = gr.Model3D( + label=UI_TEXT[LANGUAGE_EN]["object_preview"], + height=360, + clear_color=(0.94, 0.94, 0.94, 1.0), + elem_classes="demo-only", + ) + + refresh_timer = gr.Timer(2.0) + top_mode_outputs = [ + auto_button, + interact_button, + parallel_env_button, + generate_button, + rerun_simulation_button, + random_task_input_button, + random_scene_input_button, + reset_button, + current_image, + run_mode, + action_mode, + ] + demo_mode_button.click( + select_application_mode, + inputs=[gr.State(APP_MODE_DEMO)], + outputs=[ + demo_mode_button, + debug_mode_button, + debug_controls, + mode_style, + app_mode, + debug_engine_area, + ], + queue=False, + ) + debug_mode_button.click( + select_application_mode, + inputs=[gr.State(APP_MODE_DEBUG)], + outputs=[ + demo_mode_button, + debug_mode_button, + debug_controls, + mode_style, + app_mode, + debug_engine_area, + ], + queue=False, + ) + for engine, button in zip( + (engine for engine, _ in DEBUG_ENGINES), + ( + asset_engine_button, + scene_engine_button, + action_engine_button, + ), + ): + button.click( + select_debug_engine, + inputs=[gr.State(engine)], + outputs=[ + asset_engine_button, + scene_engine_button, + action_engine_button, + asset_engine["panel"], + scene_engine_panel, + action_engine_panel, + ], + queue=False, + ) + debug_scene_run.click( + run_scene_engine, + inputs=[debug_scene_image], + outputs=[ + debug_scene_progress, + debug_scene_status, + debug_scene_output, + debug_scene_preview, + ], + ) + debug_scene_reset.click( + reset_scene_engine, + outputs=[ + debug_scene_image, + debug_scene_progress, + debug_scene_status, + debug_scene_output, + debug_scene_preview, + ], + queue=False, + ) + debug_action_load.click( + action_engine_snapshot, + outputs=[ + debug_action_video, + debug_action_current_task, + debug_action_progress, + debug_action_status, + debug_action_scene, + ], + queue=False, + ) + debug_action_run.click( + run_action_engine_panel, + inputs=[debug_action_task, debug_action_robot], + outputs=[ + debug_action_video, + debug_action_current_task, + debug_action_progress, + debug_action_status, + debug_action_scene, + ], + ) + debug_action_refresh_timer.tick( + action_engine_snapshot, + outputs=[ + debug_action_video, + debug_action_current_task, + debug_action_progress, + debug_action_status, + debug_action_scene, + ], + queue=False, + ) + auto_button.click( + select_top_mode, + inputs=[ + gr.State(TOP_MODE_AUTO), + gr.State(None), + run_mode, + action_mode, + language, + ], + outputs=top_mode_outputs, + queue=False, + ) + interact_button.click( + select_top_mode, + inputs=[ + gr.State(TOP_MODE_INTERACT), + gr.State(None), + run_mode, + action_mode, + language, + ], + outputs=top_mode_outputs, + queue=False, + ) + parallel_env_button.click( + select_top_mode, + inputs=[ + gr.State(None), + gr.State(TOP_MODE_PARALLEL_ENV), + run_mode, + action_mode, + language, + ], + outputs=top_mode_outputs, + queue=False, + ) + language_button.click( + toggle_language, + inputs=[language, run_mode, action_mode], + outputs=[ + auto_button, + interact_button, + parallel_env_button, + generate_button, + rerun_simulation_button, + random_task_input_button, + random_scene_input_button, + reset_button, + language_button, + heading, + instruction, + robot_profile, + image_input, + task_input, + env_input, + scene_mode, + current_image, + current_task, + progress, + model, + edited_model, + object_model, + language, + ], + queue=False, + ) + generate_button.click( + run_generate_for_top_mode, + inputs=[ + run_mode, + action_mode, + scene_mode, + robot_profile, + image_input, + task_input, + env_input, + interact_prebuilt_scene_dir, + language, + ], + outputs=[ + image_input, + task_input, + env_input, + current_image, + current_task, + progress, + status, + model, + edited_model, + object_model, + ], + ) + random_task_input_button.click( + randomize_interact_task_input, + inputs=[run_mode, language], + outputs=[ + image_input, + task_input, + env_input, + scene_mode, + interact_prebuilt_scene_dir, + model, + edited_model, + object_model, + ], + queue=False, + ) + random_scene_input_button.click( + randomize_interact_scene_input, + inputs=[run_mode, language], + outputs=[env_input], + queue=False, + ) + rerun_simulation_button.click( + rerun_current_simulation, + inputs=[ + run_mode, + action_mode, + robot_profile, + ], + outputs=[ + image_input, + task_input, + env_input, + current_image, + current_task, + progress, + status, + model, + edited_model, + object_model, + ], + queue=False, + ) + image_input.upload( + clear_interact_prebuilt_scene, + outputs=[interact_prebuilt_scene_dir], + queue=False, + ) + scene_mode.change( + scene_mode_input_updates, + inputs=[scene_mode], + outputs=[task_input, env_input], + queue=False, + ) + reset_button.click( + clear_interact_prebuilt_scene, + outputs=[interact_prebuilt_scene_dir], + queue=False, + ) + reset_button.click( + run_reset_or_stop, + inputs=[run_mode], + outputs=[ + image_input, + task_input, + env_input, + current_image, + current_task, + progress, + status, + model, + edited_model, + object_model, + ], + queue=False, + ) + refresh_timer.tick( + synced_ui_snapshot, + inputs=[run_mode, action_mode, last_seen_input_revision], + outputs=[ + image_input, + task_input, + env_input, + current_image, + current_task, + progress, + status, + model, + edited_model, + object_model, + rerun_simulation_button, + last_seen_input_revision, + scene_mode, + robot_profile, + parallel_env_button, + action_mode, + ], + queue=False, + ) + return demo diff --git a/embodichain/gen_sim/gradio_ui/app_workflows.py b/embodichain/gen_sim/gradio_ui/app_workflows.py new file mode 100644 index 000000000..4895454eb --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/app_workflows.py @@ -0,0 +1,3427 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import hashlib +import html +import io +import json +import importlib.util +import math +import os +import queue +import shutil +import signal +import socket +import subprocess +import sys +import threading +import time +import uuid +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Iterable + +from random_input import IMAGE_DIR as AUTO_BASE_IMAGE_DIR +from random_input import ( + auto_image_directories, + available_auto_task_indices, + generate_auto_scene_description, + generate_auto_text_input, + get_prebuilt_scene_dir, + parse_task_id, +) +from app_config import * # noqa: F403 - services intentionally consume central config. +from app_env import SCENE_ENGINE_VISER_PORT, configure_direct_network_env +from app_processes import ( + build_pipeline_env, + build_run_agent_command, + detect_phase_from_files, + read_process_output, + run_agent_cli_supports_robot_profile, + start_pipeline, + terminate_process_group, + update_phase_from_log, +) +from app_media import * # noqa: F403 - workflow consumes media service helpers. + +configure_direct_network_env() + +import gradio as gr +import numpy as np +import trimesh +from PIL import Image, ImageDraw, ImageOps + +_RUN_AGENT_SUPPORTS_ROBOT_PROFILE: bool | None = None +VIDEO_SYNC_JS = r""" +() => { + const audienceRootId = "embodichain-audience-video"; + const lerobotRootId = "embodichain-lerobot-video"; + let syncing = false; + + function findVideo(rootId) { + const root = document.getElementById(rootId); + return root ? root.querySelector("video") : null; + } + + function sourceLoaded(video) { + return Boolean(video && (video.currentSrc || video.src)); + } + + function copyTime(source, target) { + if (!sourceLoaded(source) || !sourceLoaded(target)) { + return; + } + const sourceTime = source.currentTime || 0; + if (!Number.isFinite(sourceTime)) { + return; + } + const duration = Number.isFinite(target.duration) ? target.duration : sourceTime; + const targetTime = Math.min(sourceTime, duration); + if (Math.abs((target.currentTime || 0) - targetTime) > 0.35) { + try { + target.currentTime = targetTime; + } catch (_) { + // Some browsers reject seeking before metadata is fully available. + } + } + } + + function syncPlayback(sourceRootId, targetRootId, shouldPlay) { + if (syncing) { + return; + } + const source = findVideo(sourceRootId); + const target = findVideo(targetRootId); + if (!sourceLoaded(source) || !sourceLoaded(target)) { + return; + } + + syncing = true; + copyTime(source, target); + + const release = () => { + window.setTimeout(() => { + syncing = false; + }, 0); + }; + + if (shouldPlay) { + const result = target.play(); + if (result && typeof result.finally === "function") { + result.catch(() => {}).finally(release); + } else { + release(); + } + } else { + target.pause(); + release(); + } + } + + function bindOne(rootId, peerRootId) { + const video = findVideo(rootId); + if (!sourceLoaded(video) || video.dataset.embodichainSyncBound === "true") { + return; + } + video.dataset.embodichainSyncBound = "true"; + video.addEventListener("play", () => syncPlayback(rootId, peerRootId, true)); + video.addEventListener("pause", () => syncPlayback(rootId, peerRootId, false)); + } + + function bindVideos() { + bindOne(audienceRootId, lerobotRootId); + bindOne(lerobotRootId, audienceRootId); + } + + bindVideos(); + window.setInterval(bindVideos, 1000); + const observer = new MutationObserver(bindVideos); + observer.observe(document.body, { childList: true, subtree: true }); +} +""" + + +# Runtime ownership lives in app_state; this module only orchestrates it. +from app_state import ( + PHASES, + Phase, + RuntimeState, + clear_run_timing_locked, + format_duration_ns, + format_timing_lines, + record_phase_transition_locked, + record_simulation_finished_locked, + record_simulation_started_locked, + runtime, + runtime_lock, + set_runtime_phase_locked, + snapshot_timing_locked, + start_run_timing_locked, +) + + +@dataclass(frozen=True) +class ScenePaths: + scene_id: str + image_path: Path + prompt_root: Path + config_dir: Path + + @property + def fast_gym_config(self) -> Path: + return self.config_dir / "fast_gym_config.json" + + @property + def agent_config(self) -> Path: + return self.config_dir / "agent_config.json" + + @property + def gradio_scene_dir(self) -> Path: + return self.config_dir / "gradio_scene" + + @property + def gradio_scene_glb(self) -> Path: + return self.gradio_scene_dir / "scene_current.glb" + + @property + def gradio_object_preview_glb(self) -> Path: + return self.gradio_scene_dir / "object_preview.glb" + + @property + def scene_manifest(self) -> Path: + return self.gradio_scene_dir / "scene_manifest.json" + + @property + def object_preview_manifest(self) -> Path: + return self.gradio_scene_dir / "object_preview_manifest.json" + + +CURRENT_PATHS = ScenePaths( + scene_id=SCENE_ID, + image_path=IMAGE_PATH, + prompt_root=PROMPT2SCENE_ROOT, + config_dir=CONFIG_DIR, +) + + +def make_stage_paths(run_token: str) -> ScenePaths: + scene_id = f"{PENDING_PREFIX}{run_token[:12]}" + return ScenePaths( + scene_id=scene_id, + image_path=IMAGE_DIR / f"{scene_id}.png", + prompt_root=GYM_PROJECT_ROOT / scene_id, + config_dir=ACTION_AGENT_ROOT / "configs" / scene_id, + ) + + +def make_replaced_paths(run_token: str) -> ScenePaths: + scene_id = f"{REPLACED_PREFIX}{run_token[:12]}" + return ScenePaths( + scene_id=scene_id, + image_path=IMAGE_DIR / f"{scene_id}.png", + prompt_root=GYM_PROJECT_ROOT / scene_id, + config_dir=ACTION_AGENT_ROOT / "configs" / scene_id, + ) + + +def save_input( + image_value: str | np.ndarray | Image.Image, + task_text: str, + image_path: Path, +) -> Path: + if image_value is None: + raise ValueError("Please upload an image first.") + if not task_text.strip(): + raise ValueError("Please enter a task description.") + + image_path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(image_value, str): + image = Image.open(image_value) + elif isinstance(image_value, np.ndarray): + image = Image.fromarray(image_value) + elif isinstance(image_value, Image.Image): + image = image_value + else: + raise TypeError(f"Unsupported image input type: {type(image_value)!r}") + + image = ImageOps.exif_transpose(image).convert("RGB") + image.save(image_path, format="PNG") + return image_path + + +def reset_current_scene() -> list[str]: + process: subprocess.Popen[str] | None = None + sim_process: subprocess.Popen[str] | None = None + with runtime_lock: + runtime.run_token = uuid.uuid4().hex + runtime.auto_loop_active = False + runtime.auto_loop_token = None + runtime.auto_round = 0 + process = runtime.process + sim_process = runtime.sim_process + runtime.process = None + runtime.sim_process = None + runtime.sim_started = False + runtime.sim_finished = False + runtime.sim_returncode = None + runtime.is_busy = False + runtime.phase_key = "idle" + runtime.status = "Idle." + runtime.task_text = "" + runtime.input_task_text = "" + runtime.input_scene_text = "" + runtime.image_path = None + runtime.video_path = None + runtime.lerobot_video_path = None + runtime.lerobot_dataset_path = None + runtime.object_model_path = None + runtime.scene_model_path = None + runtime.edited_scene_model_path = None + runtime.last_error = None + runtime.log_lines.clear() + clear_run_timing_locked() + + if process is not None: + terminate_process_group(process) + if sim_process is not None: + terminate_process_group(sim_process) + + return cleanup_current_and_staging() + + +def cleanup_current_and_staging() -> list[str]: + errors: list[str] = [] + paths: list[Path] = [ + PROMPT2SCENE_ROOT, + CONFIG_DIR, + IMAGE_PATH, + *pending_artifact_paths(), + ] + for path in paths: + errors.extend(remove_path(path)) + errors.extend(cleanup_outputs_preserving_videos()) + return errors + + +def cleanup_auto_generated_artifacts(extra_image_path: Path | None = None) -> list[str]: + errors: list[str] = [] + paths: list[Path] = [ + PROMPT2SCENE_ROOT, + CONFIG_DIR, + IMAGE_PATH, + *pending_artifact_paths(), + ] + if extra_image_path is not None: + paths.append(extra_image_path) + + for path in paths: + if is_protected_auto_base_image(path): + continue + errors.extend(remove_path(path)) + + with runtime_lock: + runtime.image_path = None + runtime.input_task_text = "" + runtime.input_scene_text = "" + runtime.lerobot_video_path = None + runtime.lerobot_dataset_path = None + runtime.object_model_path = None + runtime.scene_model_path = None + runtime.edited_scene_model_path = None + errors.extend(cleanup_outputs_preserving_videos()) + return errors + + +def is_protected_auto_base_image(path: Path) -> bool: + try: + path.resolve().relative_to(AUTO_BASE_IMAGE_DIR.resolve()) + except ValueError: + return False + except FileNotFoundError: + return False + return True + + +def pending_artifact_paths() -> list[Path]: + paths: list[Path] = [] + for root in (GYM_PROJECT_ROOT, ACTION_AGENT_ROOT / "configs"): + if root.is_dir(): + paths.extend(root.glob(f"{PENDING_PREFIX}*")) + paths.extend(root.glob(f"{REPLACED_PREFIX}*")) + if IMAGE_DIR.is_dir(): + paths.extend(IMAGE_DIR.glob(f"{PENDING_PREFIX}*.png")) + paths.extend(IMAGE_DIR.glob(f"{REPLACED_PREFIX}*.png")) + return paths + + +def remove_path(path: Path) -> list[str]: + try: + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink(missing_ok=True) + except Exception as exc: + return [f"Failed to remove {path}: {exc}"] + return [] + + +def cleanup_outputs_preserving_videos() -> list[str]: + if not OUTPUTS_DIR.exists(): + return [] + if OUTPUTS_DIR.is_file(): + if OUTPUTS_DIR.suffix.lower() in VIDEO_SUFFIXES: + return [] + return remove_path(OUTPUTS_DIR) + + errors: list[str] = [] + for path in sorted( + OUTPUTS_DIR.rglob("*"), + key=lambda item: len(item.parts), + reverse=True, + ): + if path.is_file() and path.suffix.lower() not in VIDEO_SUFFIXES: + errors.extend(remove_path(path)) + + for path in sorted( + OUTPUTS_DIR.rglob("*"), + key=lambda item: len(item.parts), + reverse=True, + ): + if not path.is_dir(): + continue + try: + path.rmdir() + except OSError: + pass + except Exception as exc: + errors.append(f"Failed to remove empty output directory {path}: {exc}") + return errors + + +from app_commands import ( + build_config_command_for_paths, + build_initial_pipeline_command, + build_scene_edit_pipeline_command, + robot_profile_cli_value, +) + + +def build_edit_pipeline_command( + task_text: str, + env_text: str, + robot_profile: str | None = None, + load_template_material: bool = False, +) -> list[str]: + return build_scene_edit_pipeline_command( + task_text, env_text, CURRENT_PATHS, robot_profile, load_template_material + ) + + +def build_task_only_config_command( + task_text: str, + robot_profile: str | None = None, + load_template_material: bool = False, +) -> list[str]: + return build_config_command_for_paths( + task_text, CURRENT_PATHS, robot_profile, load_template_material + ) + + +def format_current_task(task_text: str, env_text: str = "") -> str: + return "\n".join( + part for part in ((task_text or "").strip(), (env_text or "").strip()) if part + ) + + +def build_gradio_scene_from_fast_config( + config_path: Path, + scene_dir: Path | None = None, +) -> Path: + config_dir = config_path.parent + if scene_dir is None: + scene_dir = config_dir / "gradio_scene" + scene_glb = scene_dir / "scene_current.glb" + scene_manifest = scene_dir / "scene_manifest.json" + with config_path.open("r", encoding="utf-8") as file: + config = json.load(file) + config_stat = config_path.stat() + + scene = trimesh.Scene() + manifest: dict[str, Any] = { + "source_config": os.path.relpath(config_path, scene_dir), + "source_config_size": config_stat.st_size, + "source_config_mtime_ns": config_stat.st_mtime_ns, + "transform_policy": GRADIO_SCENE_TRANSFORM_POLICY, + "objects": [], + } + + object_count = 0 + for role, obj in iter_scene_objects(config): + shape = obj.get("shape") if isinstance(obj, dict) else None + if not isinstance(shape, dict) or shape.get("shape_type") != "Mesh": + continue + raw_fpath = shape.get("fpath") + if not raw_fpath: + continue + mesh_path = resolve_mesh_path(config_dir, str(raw_fpath)) + if not mesh_path.is_file(): + raise FileNotFoundError( + f"Mesh file not found for {obj.get('uid')}: {mesh_path}" + ) + + transform = object_transform(obj) + frame_transform = gltf_to_sim_frame_transform(mesh_path) + if frame_transform is not None: + transform = transform @ frame_transform + add_mesh_to_scene(scene, mesh_path, transform, str(obj.get("uid", "object"))) + manifest["objects"].append( + { + "uid": obj.get("uid"), + "role": role, + "source_mesh": os.path.relpath(mesh_path, scene_dir), + "source_mesh_size": mesh_path.stat().st_size, + "source_mesh_mtime_ns": mesh_path.stat().st_mtime_ns, + "gltf_to_sim_frame": frame_transform is not None, + } + ) + object_count += 1 + + if object_count == 0: + raise ValueError(f"No mesh objects found in {config_path}") + + scene_dir.mkdir(parents=True, exist_ok=True) + scene.export(scene_glb) + scene_manifest.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return scene_glb + + +def gradio_scene_is_current( + scene_glb: Path, + manifest_path: Path, + config_path: Path, +) -> bool: + if ( + not scene_glb.is_file() + or not manifest_path.is_file() + or not config_path.is_file() + ): + return False + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + config = json.loads(config_path.read_text(encoding="utf-8")) + config_stat = config_path.stat() + except Exception: + return False + + if manifest.get("source_config") != os.path.relpath( + config_path, manifest_path.parent + ): + return False + if manifest.get("source_config_size") != config_stat.st_size: + return False + if manifest.get("source_config_mtime_ns") != config_stat.st_mtime_ns: + return False + if manifest.get("transform_policy") != GRADIO_SCENE_TRANSFORM_POLICY: + return False + + expected_objects = [] + try: + for role, obj in iter_scene_objects(config): + shape = obj.get("shape") if isinstance(obj, dict) else None + if not isinstance(shape, dict) or shape.get("shape_type") != "Mesh": + continue + raw_fpath = shape.get("fpath") + if not raw_fpath: + continue + mesh_path = resolve_mesh_path(config_path.parent, str(raw_fpath)) + mesh_stat = mesh_path.stat() + frame_transform = gltf_to_sim_frame_transform(mesh_path) + expected_objects.append( + { + "uid": obj.get("uid"), + "role": role, + "source_mesh": os.path.relpath(mesh_path, manifest_path.parent), + "source_mesh_size": mesh_stat.st_size, + "source_mesh_mtime_ns": mesh_stat.st_mtime_ns, + "gltf_to_sim_frame": frame_transform is not None, + } + ) + except OSError: + return False + return manifest.get("objects") == expected_objects + + +def collect_generated_object_glbs(paths: ScenePaths) -> list[Path]: + if not paths.prompt_root.is_dir(): + return [] + + glb_paths: list[Path] = [] + seen: set[Path] = set() + for glb_dir in sorted(paths.prompt_root.rglob("glb_gen")): + if not glb_dir.is_dir(): + continue + candidates = [ + path for path in glb_dir.rglob("*_simready.glb") if is_previewable_glb(path) + ] + if not candidates: + candidates = [ + path for path in glb_dir.rglob("*.glb") if is_previewable_glb(path) + ] + for path in sorted(candidates): + resolved = path.resolve() + if resolved in seen: + continue + seen.add(resolved) + glb_paths.append(path) + return glb_paths + + +def is_previewable_glb(path: Path) -> bool: + if not path.is_file() or path.name.startswith("."): + return False + return not any(part.startswith(".") for part in path.relative_to(path.anchor).parts) + + +def build_object_preview_scene( + glb_paths: list[Path], + scene_dir: Path, +) -> Path: + if not glb_paths: + raise ValueError("No generated object GLBs found") + + scene_dir.mkdir(parents=True, exist_ok=True) + preview_glb = scene_dir / "object_preview.glb" + preview_manifest = scene_dir / "object_preview_manifest.json" + scene = trimesh.Scene() + manifest: dict[str, Any] = {"objects": []} + + cursor = 0.0 + spacing = 0.35 + added_count = 0 + for object_index, mesh_path in enumerate(glb_paths): + meshes = load_mesh_geometries(mesh_path) + if not meshes: + continue + + bounds = combined_bounds(meshes) + extents = bounds[1] - bounds[0] + max_extent = float(max(extents.max(), 1e-6)) + scale = 1.0 / max_extent + scaled_width = max(float(extents[0]) * scale, 0.2) + placement_x = cursor + scaled_width / 2.0 + cursor += scaled_width + spacing + + transform = ( + trimesh.transformations.translation_matrix( + [ + placement_x, + 0.0, + 0.0, + ] + ) + @ trimesh.transformations.scale_matrix(scale) + @ trimesh.transformations.translation_matrix( + [ + -float((bounds[0][0] + bounds[1][0]) / 2.0), + -float((bounds[0][1] + bounds[1][1]) / 2.0), + -float(bounds[0][2]), + ] + ) + ) + + for mesh_index, mesh in enumerate(meshes): + mesh.apply_transform(transform) + name = f"object_{object_index}_{mesh_index}" + scene.add_geometry(mesh, node_name=name, geom_name=name) + added_count += 1 + + manifest["objects"].append( + { + "source_mesh": os.path.relpath(mesh_path, scene_dir), + "size": mesh_path.stat().st_size, + "mtime_ns": mesh_path.stat().st_mtime_ns, + } + ) + + if added_count == 0: + raise ValueError("No renderable meshes found in generated object GLBs") + + if cursor > spacing: + scene.apply_transform( + trimesh.transformations.translation_matrix( + [-(cursor - spacing) / 2.0, 0.0, 0.0] + ) + ) + scene.export(preview_glb) + preview_manifest.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return preview_glb + + +def object_preview_is_current( + manifest_path: Path, + glb_paths: list[Path], +) -> bool: + if not manifest_path.is_file(): + return False + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception: + return False + expected = [] + for path in glb_paths: + try: + stat = path.stat() + except OSError: + return False + expected.append( + { + "source_mesh": os.path.relpath(path, manifest_path.parent), + "size": stat.st_size, + "mtime_ns": stat.st_mtime_ns, + } + ) + return manifest.get("objects") == expected + + +def load_mesh_geometries(mesh_path: Path) -> list[trimesh.Trimesh]: + loaded = trimesh.load(mesh_path, force="scene", process=False) + gltf_to_sim_transform = gltf_to_sim_frame_transform(mesh_path) + if isinstance(loaded, trimesh.Trimesh): + mesh = loaded.copy() + if gltf_to_sim_transform is not None: + mesh.apply_transform(gltf_to_sim_transform) + return [mesh] + if isinstance(loaded, trimesh.Scene): + meshes: list[trimesh.Trimesh] = [] + for geometry in loaded.dump(concatenate=False): + if isinstance(geometry, trimesh.Trimesh): + mesh = geometry.copy() + if gltf_to_sim_transform is not None: + mesh.apply_transform(gltf_to_sim_transform) + meshes.append(mesh) + return meshes + raise TypeError(f"Unsupported mesh type for {mesh_path}: {type(loaded)!r}") + + +def gltf_to_sim_frame_transform(mesh_path: Path) -> np.ndarray | None: + if mesh_path.suffix.lower() not in {".glb", ".gltf"}: + return None + # Match DexSim's native GLTF Y-up to simulation Z-up conversion. + transform = np.eye(4) + transform[:3, :3] = np.array( + [ + [1.0, 0.0, 0.0], + [0.0, 0.0, -1.0], + [0.0, 1.0, 0.0], + ], + dtype=float, + ) + return transform + + +def combined_bounds(meshes: list[trimesh.Trimesh]) -> np.ndarray: + valid_bounds = [ + mesh.bounds + for mesh in meshes + if mesh.vertices is not None and len(mesh.vertices) > 0 + ] + if not valid_bounds: + raise ValueError("Mesh has no vertices") + bounds = np.asarray(valid_bounds, dtype=float) + return np.stack([bounds[:, 0, :].min(axis=0), bounds[:, 1, :].max(axis=0)]) + + +def iter_scene_objects(config: dict[str, Any]) -> Iterable[tuple[str, dict[str, Any]]]: + for role in ("background", "rigid_object"): + value = config.get(role, []) + if isinstance(value, dict): + value = [value] + if not isinstance(value, list): + continue + for obj in value: + if isinstance(obj, dict): + yield role, obj + + +def resolve_mesh_path(config_dir: Path, raw_fpath: str) -> Path: + mesh_path = Path(raw_fpath).expanduser() + if not mesh_path.is_absolute(): + mesh_path = config_dir / mesh_path + return mesh_path.resolve() + + +def object_transform(obj: dict[str, Any]) -> np.ndarray: + scale = vector3(obj.get("body_scale"), [1.0, 1.0, 1.0]) + + scale_matrix = np.eye(4) + scale_matrix[0, 0] = scale[0] + scale_matrix[1, 1] = scale[1] + scale_matrix[2, 2] = scale[2] + + init_local_pose = matrix4(obj.get("init_local_pose")) + if init_local_pose is not None: + return init_local_pose @ scale_matrix + + position = vector3(obj.get("init_pos"), [0.0, 0.0, 0.0]) + rotation_degrees = vector3(obj.get("init_rot"), [0.0, 0.0, 0.0]) + root_matrix = euler_xyz_degrees_matrix(rotation_degrees, position) + return root_matrix @ scale_matrix + + +def euler_xyz_degrees_matrix( + rotation_degrees: list[float], + position: list[float], +) -> np.ndarray: + rx, ry, rz = (math.radians(value) for value in rotation_degrees) + cx, sx = math.cos(rx), math.sin(rx) + cy, sy = math.cos(ry), math.sin(ry) + cz, sz = math.cos(rz), math.sin(rz) + + rot_x = np.array( + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, cx, -sx, 0.0], + [0.0, sx, cx, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + dtype=float, + ) + rot_y = np.array( + [ + [cy, 0.0, sy, 0.0], + [0.0, 1.0, 0.0, 0.0], + [-sy, 0.0, cy, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + dtype=float, + ) + rot_z = np.array( + [ + [cz, -sz, 0.0, 0.0], + [sz, cz, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + dtype=float, + ) + matrix = rot_x @ rot_y @ rot_z + matrix[:3, 3] = position + return matrix + + +def matrix4(value: Any) -> np.ndarray | None: + if not isinstance(value, (list, tuple)) or len(value) != 4: + return None + try: + matrix = np.asarray(value, dtype=float) + except (TypeError, ValueError): + return None + if matrix.shape != (4, 4) or not np.all(np.isfinite(matrix)): + return None + return matrix + + +def vector3(value: Any, default: list[float]) -> list[float]: + if not isinstance(value, (list, tuple)) or len(value) != 3: + return list(default) + return [float(value[0]), float(value[1]), float(value[2])] + + +def add_mesh_to_scene( + scene: trimesh.Scene, + mesh_path: Path, + transform: np.ndarray, + uid: str, +) -> None: + loaded = trimesh.load(mesh_path, force="scene", process=False) + if isinstance(loaded, trimesh.Trimesh): + loaded.apply_transform(transform) + scene.add_geometry(loaded, node_name=uid, geom_name=uid) + return + + if isinstance(loaded, trimesh.Scene): + loaded.apply_transform(transform) + for index, geometry in enumerate(loaded.dump(concatenate=False)): + if isinstance(geometry, trimesh.Trimesh): + scene.add_geometry( + geometry, + node_name=f"{uid}_{index}", + geom_name=f"{uid}_{index}", + ) + return + + raise TypeError(f"Unsupported mesh type for {mesh_path}: {type(loaded)!r}") + + +def promote_stage_to_current(stage: ScenePaths, run_token: str) -> list[str]: + backup = make_replaced_paths(run_token) + promotion_errors: list[str] = [] + cleanup_errors: list[str] = [] + + for required_path in (stage.prompt_root, stage.config_dir, stage.image_path): + if not required_path.exists(): + raise FileNotFoundError(f"Generated artifact missing: {required_path}") + + cleanup_errors.extend(remove_path(backup.prompt_root)) + cleanup_errors.extend(remove_path(backup.config_dir)) + cleanup_errors.extend(remove_path(backup.image_path)) + + moved_to_backup: list[tuple[Path, Path]] = [] + moved_to_current: list[tuple[Path, Path]] = [] + try: + move_if_exists(PROMPT2SCENE_ROOT, backup.prompt_root, moved_to_backup) + move_if_exists(CONFIG_DIR, backup.config_dir, moved_to_backup) + move_if_exists(IMAGE_PATH, backup.image_path, moved_to_backup) + + move_required(stage.prompt_root, PROMPT2SCENE_ROOT, moved_to_current) + move_required(stage.config_dir, CONFIG_DIR, moved_to_current) + move_required(stage.image_path, IMAGE_PATH, moved_to_current) + rewrite_promoted_paths(stage) + except Exception as exc: + promotion_errors.append(f"Failed to promote generated scene: {exc}") + restore_promoted_paths(moved_to_current, moved_to_backup, promotion_errors) + raise RuntimeError("\n".join(promotion_errors)) from exc + + cleanup_errors.extend(remove_path(backup.prompt_root)) + cleanup_errors.extend(remove_path(backup.config_dir)) + cleanup_errors.extend(remove_path(backup.image_path)) + return cleanup_errors + + +def move_if_exists(src: Path, dst: Path, moved: list[tuple[Path, Path]]) -> None: + if not src.exists(): + return + dst.parent.mkdir(parents=True, exist_ok=True) + src.rename(dst) + moved.append((src, dst)) + + +def move_required(src: Path, dst: Path, moved: list[tuple[Path, Path]]) -> None: + if not src.exists(): + raise FileNotFoundError(src) + dst.parent.mkdir(parents=True, exist_ok=True) + src.rename(dst) + moved.append((dst, src)) + + +def restore_promoted_paths( + moved_to_current: list[tuple[Path, Path]], + moved_to_backup: list[tuple[Path, Path]], + errors: list[str], +) -> None: + for current_path, original_stage_path in reversed(moved_to_current): + try: + if current_path.exists(): + original_stage_path.parent.mkdir(parents=True, exist_ok=True) + current_path.rename(original_stage_path) + except Exception as exc: + errors.append( + f"Failed to restore staging artifact {original_stage_path}: {exc}" + ) + + for original_current_path, backup_path in reversed(moved_to_backup): + try: + if backup_path.exists() and not original_current_path.exists(): + original_current_path.parent.mkdir(parents=True, exist_ok=True) + backup_path.rename(original_current_path) + except Exception as exc: + errors.append( + f"Failed to restore previous scene {original_current_path}: {exc}" + ) + + +def rewrite_promoted_paths(stage: ScenePaths) -> None: + replacements = [ + (str(stage.config_dir), str(CONFIG_DIR)), + (str(stage.prompt_root), str(PROMPT2SCENE_ROOT)), + (str(stage.image_path), str(IMAGE_PATH)), + (stage.scene_id, SCENE_ID), + ] + for root in (PROMPT2SCENE_ROOT, CONFIG_DIR): + if not root.is_dir(): + continue + for path in root.rglob("*"): + if not path.is_file() or path.suffix.lower() not in TEXT_REWRITE_SUFFIXES: + continue + text = path.read_text(encoding="utf-8") + new_text = text + for old, new in replacements: + new_text = new_text.replace(old, new) + if new_text != text: + path.write_text(new_text, encoding="utf-8") + + +def ensure_initial_scene_snapshot(*, overwrite: bool = False) -> Path: + if not GRADIO_SCENE_GLB.is_file(): + build_gradio_scene_from_fast_config(FAST_GYM_CONFIG, GRADIO_SCENE_DIR) + if overwrite or not GRADIO_INITIAL_SCENE_GLB.is_file(): + GRADIO_SCENE_DIR.mkdir(parents=True, exist_ok=True) + shutil.copy2(GRADIO_SCENE_GLB, GRADIO_INITIAL_SCENE_GLB) + return GRADIO_INITIAL_SCENE_GLB + + +def prepare_current_scene_for_edit() -> Path: + scene_state = PROMPT2SCENE_ROOT / "gym_export" / "scene_state" / "result.json" + if not scene_state.is_file(): + raise FileNotFoundError( + f"Current prompt2scene scene state not found: {scene_state}" + ) + if not FAST_GYM_CONFIG.is_file(): + raise FileNotFoundError(f"Current gym config not found: {FAST_GYM_CONFIG}") + + initial_scene_path = ensure_initial_scene_snapshot() + errors = remove_path(GRADIO_SCENE_GLB) + errors.extend(remove_path(SCENE_MANIFEST)) + if errors: + raise RuntimeError("\n".join(errors)) + return initial_scene_path + + +def prebuilt_scene_dir_for_image_value( + image_value: str | np.ndarray | Image.Image, +) -> Path | None: + if not isinstance(image_value, str): + return None + image_path = Path(image_value).expanduser() + task_index = parse_task_id(image_path.name) + if task_index is None: + return None + + try: + resolved_image = image_path.resolve() + except FileNotFoundError: + return None + filename = image_path.name + matches_auto_image = False + for image_dir in auto_image_directories(): + candidate = image_dir / filename + if not candidate.is_file(): + continue + try: + if candidate.resolve() == resolved_image: + matches_auto_image = True + break + except FileNotFoundError: + continue + if not matches_auto_image: + return None + + scene_dir = get_prebuilt_scene_dir(task_index) + return scene_dir if scene_dir.is_dir() else None + + +def copy_prebuilt_scene_to_stage(prebuilt_scene_dir: Path, stage: ScenePaths) -> None: + required_paths = [ + prebuilt_scene_dir / "gym_export" / "gym_config.json", + prebuilt_scene_dir / "gym_export" / "scene_state" / "result.json", + prebuilt_scene_dir / "gym_export" / "scene_state" / "unified_scene.json", + prebuilt_scene_dir / "gym_export" / "scene_state" / "unified_scene_gen.json", + ] + missing = [path for path in required_paths if not path.is_file()] + if missing: + missing_text = ", ".join(str(path) for path in missing) + raise FileNotFoundError(f"Prebuilt scene is incomplete: {missing_text}") + + cleanup_errors = [] + cleanup_errors.extend(remove_path(stage.prompt_root)) + cleanup_errors.extend(remove_path(stage.config_dir)) + if cleanup_errors: + raise RuntimeError("\n".join(cleanup_errors)) + stage.prompt_root.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(prebuilt_scene_dir, stage.prompt_root) + + +def build_interact_random_initial_preview(prebuilt_scene_dir: Path) -> Path: + scene_id = prebuilt_scene_dir.name + preview_dir = INTERACT_RANDOM_PREVIEW_DIR / scene_id + config_path = prebuilt_scene_dir / "gym_export" / "gym_config.json" + return build_gradio_scene_from_fast_config(config_path, preview_dir) + + +def current_scene_available_for_task_only() -> bool: + return CURRENT_GYM_EXPORT_CONFIG.is_file() + + +def rerun_simulation_is_available() -> bool: + return ( + CURRENT_PATHS.fast_gym_config.is_file() and CURRENT_PATHS.agent_config.is_file() + ) + + +def run_generate( + image_value: str | np.ndarray | Image.Image, + task_text: str, + env_text: str, + *, + force_initial: bool = False, + scene_mode: str = SCENE_MODE_INITIAL, + parallel_env: bool = False, + robot_profile: str | None = None, + load_template_material: bool = False, + run_log_mode: str = RUN_LOG_MODE_INTERACT, + prebuilt_scene_dir: Path | None = None, + launch_simulation: bool = True, +): + task_text = (task_text or "").strip() + env_text = (env_text or "").strip() + if force_initial or scene_mode == SCENE_MODE_INITIAL: + mode = PIPELINE_MODE_INITIAL + elif scene_mode == SCENE_MODE_EDIT: + mode = PIPELINE_MODE_EDIT + elif scene_mode == SCENE_MODE_TASK_ONLY: + mode = PIPELINE_MODE_TASK_ONLY + else: + raise ValueError(f"Unsupported scene mode: {scene_mode}") + requested_mode = mode + if requested_mode == PIPELINE_MODE_TASK_ONLY: + env_text = "" + resolved_prebuilt_scene_dir = ( + prebuilt_scene_dir or prebuilt_scene_dir_for_image_value(image_value) + ) + use_prebuilt_scene = resolved_prebuilt_scene_dir is not None + supervisor_mode = PIPELINE_MODE_INITIAL if use_prebuilt_scene else mode + old_sim_process: subprocess.Popen[str] | None = None + with runtime_lock: + if runtime.is_busy: + yield ui_snapshot(extra_status="A pipeline run is already in progress.") + return + old_sim_process = runtime.sim_process + runtime.sim_process = None + runtime.sim_started = False + runtime.sim_finished = False + runtime.sim_returncode = None + + if old_sim_process is not None: + terminate_process_group(old_sim_process) + + token = uuid.uuid4().hex + stage = ( + CURRENT_PATHS + if supervisor_mode in {PIPELINE_MODE_EDIT, PIPELINE_MODE_TASK_ONLY} + else make_stage_paths(token) + ) + initial_scene_path: Path | None = None + existing_object_preview_path = ( + GRADIO_OBJECT_PREVIEW_GLB + if supervisor_mode in {PIPELINE_MODE_EDIT, PIPELINE_MODE_TASK_ONLY} + and GRADIO_OBJECT_PREVIEW_GLB.is_file() + else None + ) + prebuilt_initial_scene_dir: Path | None = None + try: + if use_prebuilt_scene: + if not task_text: + raise ValueError("Please enter a task description.") + if requested_mode == PIPELINE_MODE_EDIT and not env_text: + raise ValueError("Please enter a scene description to edit.") + image_path = save_input(image_value, task_text, stage.image_path) + prebuilt_initial_scene_dir = resolved_prebuilt_scene_dir + copy_prebuilt_scene_to_stage(prebuilt_initial_scene_dir, stage) + initial_scene_path = build_interact_random_initial_preview( + prebuilt_initial_scene_dir + ) + elif mode == PIPELINE_MODE_EDIT: + if not task_text: + raise ValueError("Please enter a task description.") + if not env_text: + raise ValueError("Please enter a scene description to edit.") + initial_scene_path = prepare_current_scene_for_edit() + image_path = IMAGE_PATH if IMAGE_PATH.is_file() else None + elif mode == PIPELINE_MODE_TASK_ONLY: + if not task_text: + raise ValueError("Please enter a task description.") + if not CURRENT_GYM_EXPORT_CONFIG.is_file(): + raise FileNotFoundError( + f"Current gym export not found: {CURRENT_GYM_EXPORT_CONFIG}" + ) + if GRADIO_INITIAL_SCENE_GLB.is_file(): + initial_scene_path = GRADIO_INITIAL_SCENE_GLB + elif GRADIO_SCENE_GLB.is_file(): + initial_scene_path = GRADIO_SCENE_GLB + image_path = IMAGE_PATH if IMAGE_PATH.is_file() else None + else: + image_path = save_input(image_value, task_text, stage.image_path) + except Exception as exc: + with runtime_lock: + runtime.phase_key = "failed" + runtime.status = f"Input error: {exc}" + runtime.last_error = str(exc) + runtime.log_lines.clear() + clear_run_timing_locked() + runtime.log_lines.append(runtime.status) + if run_log_mode == RUN_LOG_MODE_INTERACT: + archive_run_log( + mode=RUN_LOG_MODE_INTERACT, + task_description=task_text, + scene_description=env_text, + outcome="input_error", + ) + yield ui_snapshot() + return + + should_edit_prebuilt_scene = ( + prebuilt_initial_scene_dir is not None + and requested_mode != PIPELINE_MODE_TASK_ONLY + and bool(env_text) + ) + if mode == PIPELINE_MODE_EDIT and prebuilt_initial_scene_dir is None: + command = build_edit_pipeline_command( + task_text, + env_text, + robot_profile, + load_template_material, + ) + elif mode == PIPELINE_MODE_TASK_ONLY and prebuilt_initial_scene_dir is None: + command = build_task_only_config_command( + task_text, + robot_profile, + load_template_material, + ) + elif should_edit_prebuilt_scene: + command = build_scene_edit_pipeline_command( + task_text, + env_text, + stage, + robot_profile, + load_template_material, + ) + elif prebuilt_initial_scene_dir is not None: + command = build_config_command_for_paths( + task_text, + stage, + robot_profile, + load_template_material, + ) + else: + command = build_initial_pipeline_command( + task_text, + stage, + env_text, + robot_profile, + load_template_material, + ) + display_task_text = format_current_task(task_text, env_text) + with runtime_lock: + runtime.run_token = token + runtime.is_busy = True + runtime.phase_key = "received" + if mode == PIPELINE_MODE_EDIT: + runtime.status = "Starting scene edit..." + elif should_edit_prebuilt_scene: + runtime.status = "Prebuilt scene loaded. Starting scene edit..." + elif mode == PIPELINE_MODE_TASK_ONLY: + runtime.status = "Current scene found. Regenerating action config only..." + else: + runtime.status = "Input saved. Starting local pipeline..." + runtime.task_text = display_task_text + runtime.input_task_text = task_text + runtime.input_scene_text = env_text + runtime.image_path = image_path + runtime.submitted_input_revision += 1 + runtime.lerobot_video_path = None + runtime.lerobot_dataset_path = None + runtime.object_model_path = existing_object_preview_path + runtime.scene_model_path = initial_scene_path + runtime.edited_scene_model_path = None + runtime.last_error = None + runtime.sim_started = False + runtime.sim_finished = False + runtime.sim_returncode = None + runtime.log_lines.clear() + clear_run_timing_locked() + runtime.log_lines.append("$ " + " ".join(command)) + yield ui_snapshot() + + try: + process = start_pipeline(command) + except Exception as exc: + with runtime_lock: + if runtime.run_token != token: + return + runtime.is_busy = False + runtime.process = None + runtime.phase_key = "failed" + runtime.status = f"Pipeline start failed: {exc}" + runtime.last_error = str(exc) + if run_log_mode == RUN_LOG_MODE_INTERACT: + archive_run_log( + mode=RUN_LOG_MODE_INTERACT, + task_description=task_text, + scene_description=env_text, + outcome="pipeline_start_failed", + ) + yield ui_snapshot() + return + + output_queue: queue.Queue[str] = queue.Queue() + reader = threading.Thread( + target=read_process_output, + args=(process, output_queue), + daemon=True, + ) + supervisor = threading.Thread( + target=supervise_pipeline, + args=( + token, + stage, + supervisor_mode, + process, + display_task_text, + task_text, + env_text, + output_queue, + reader, + parallel_env, + robot_profile, + run_log_mode, + initial_scene_path, + should_edit_prebuilt_scene, + launch_simulation, + ), + daemon=True, + ) + + with runtime_lock: + if runtime.run_token != token: + terminate_process_group(process) + return + runtime.process = process + start_run_timing_locked("started") + runtime.phase_key = "started" + runtime.status = "Local pipeline started." + reader.start() + supervisor.start() + yield ui_snapshot() + + while True: + with runtime_lock: + still_current = runtime.run_token == token + busy = runtime.is_busy + if not still_current or not busy: + break + time.sleep(1.0) + yield ui_snapshot() + yield ui_snapshot() + + +def start_auto_loop_state() -> str | None: + process: subprocess.Popen[str] | None = None + sim_process: subprocess.Popen[str] | None = None + with runtime_lock: + if runtime.auto_loop_active or runtime.is_busy: + runtime.status = "A pipeline run is already in progress." + return None + + available_tasks = available_auto_task_indices() + if not available_tasks: + image_dirs = ", ".join(str(path) for path in auto_image_directories()) + message = ( + "Auto cannot start: no task input images were found. " + "Add task1_0.png through task5_3.png to one of: " + f"{image_dirs}" + ) + runtime.phase_key = "failed" + runtime.status = message + runtime.last_error = message + runtime.log_lines.clear() + clear_run_timing_locked() + runtime.log_lines.append(message) + return None + + token = uuid.uuid4().hex + process = runtime.process + sim_process = runtime.sim_process + runtime.process = None + runtime.sim_process = None + runtime.sim_started = False + runtime.sim_finished = False + runtime.sim_returncode = None + runtime.auto_loop_active = True + runtime.auto_loop_token = token + runtime.auto_round = 0 + runtime.auto_scene_mode = SCENE_MODE_INITIAL + runtime.auto_parallel_env = False + runtime.auto_robot_profile = DEFAULT_ROBOT_PROFILE + runtime.phase_key = "received" + runtime.status = "Auto loop starting." + runtime.video_path = None + runtime.lerobot_video_path = None + runtime.lerobot_dataset_path = None + runtime.last_error = None + runtime.log_lines.clear() + clear_run_timing_locked() + + if process is not None: + terminate_process_group(process) + if sim_process is not None: + terminate_process_group(sim_process) + return token + + +def auto_loop_is_active(loop_token: str) -> bool: + with runtime_lock: + return runtime.auto_loop_active and runtime.auto_loop_token == loop_token + + +def finish_auto_loop(loop_token: str, status_text: str | None = None) -> None: + with runtime_lock: + if runtime.auto_loop_token != loop_token: + return + runtime.auto_loop_active = False + runtime.auto_loop_token = None + runtime.auto_round = 0 + runtime.auto_scene_mode = SCENE_MODE_INITIAL + runtime.auto_parallel_env = False + runtime.auto_robot_profile = DEFAULT_ROBOT_PROFILE + if status_text is not None: + runtime.status = status_text + + +def stop_auto_loop_if_running() -> bool: + process: subprocess.Popen[str] | None = None + sim_process: subprocess.Popen[str] | None = None + with runtime_lock: + if not runtime.auto_loop_active: + return False + runtime.run_token = uuid.uuid4().hex + runtime.auto_loop_active = False + runtime.auto_loop_token = None + runtime.auto_round = 0 + runtime.auto_scene_mode = SCENE_MODE_INITIAL + runtime.auto_parallel_env = False + runtime.auto_robot_profile = DEFAULT_ROBOT_PROFILE + process = runtime.process + sim_process = runtime.sim_process + runtime.process = None + runtime.sim_process = None + runtime.sim_started = False + runtime.sim_finished = False + runtime.sim_returncode = None + runtime.is_busy = False + runtime.phase_key = "idle" + runtime.status = "Stopped." + runtime.task_text = "" + runtime.input_task_text = "" + runtime.input_scene_text = "" + runtime.image_path = None + runtime.video_path = None + runtime.lerobot_video_path = None + runtime.lerobot_dataset_path = None + runtime.object_model_path = None + runtime.scene_model_path = None + runtime.edited_scene_model_path = None + runtime.last_error = None + runtime.log_lines.clear() + clear_run_timing_locked() + + if process is not None: + terminate_process_group(process) + if sim_process is not None: + terminate_process_group(sim_process) + return True + + +def wait_for_current_simulation_to_exit( + loop_token: str, + base_image: str, + auto_task: str, + auto_scene: str, +): + while auto_loop_is_active(loop_token): + with runtime_lock: + sim_running = runtime.sim_process is not None + if not sim_running: + break + time.sleep(1.0) + yield ( + base_image, + auto_task, + auto_scene, + *ui_snapshot(extra_status="Auto waiting for Dexsim to exit."), + ) + + +def run_generate_for_top_mode( + run_mode: str, + action_mode: str | None, + scene_mode: str, + robot_profile: str | None, + image_value: str | np.ndarray | Image.Image, + task_text: str, + env_text: str, + interact_prebuilt_scene_dir: str | None, + language: str | None, +): + parallel_env = action_mode == TOP_MODE_PARALLEL_ENV + if run_mode != TOP_MODE_AUTO: + selected_prebuilt_scene_dir = ( + Path(interact_prebuilt_scene_dir) if interact_prebuilt_scene_dir else None + ) + for snapshot in run_generate( + image_value, + task_text, + env_text, + force_initial=False, + scene_mode=scene_mode, + parallel_env=parallel_env, + robot_profile=robot_profile, + load_template_material=False, + run_log_mode=RUN_LOG_MODE_INTERACT, + prebuilt_scene_dir=selected_prebuilt_scene_dir, + ): + yield ( + gr.update(), + gr.update(), + gr.update(), + *snapshot, + ) + return + + loop_token = start_auto_loop_state() + if loop_token is None: + yield ( + gr.update(), + gr.update(), + gr.update(), + *ui_snapshot(), + ) + return + + with runtime_lock: + runtime.language = language or LANGUAGE_EN + + def set_auto_control_state( + scene_mode: str, + parallel_env: bool, + robot_profile: str | None, + ) -> None: + with runtime_lock: + runtime.auto_scene_mode = scene_mode + runtime.auto_parallel_env = parallel_env + runtime.auto_robot_profile = robot_profile or DEFAULT_ROBOT_PROFILE + + def run_auto_phase( + phase_name: str, + base_image: str, + task_text: str, + scene_text: str, + *, + scene_mode: str, + parallel_env: bool, + robot_profile: str | None, + force_initial: bool = False, + prebuilt_scene_dir: Path | None = None, + ): + for snapshot in run_generate( + base_image, + task_text, + scene_text, + force_initial=force_initial, + scene_mode=scene_mode, + parallel_env=parallel_env, + robot_profile=robot_profile, + load_template_material=False, + run_log_mode=RUN_LOG_MODE_AUTO, + prebuilt_scene_dir=prebuilt_scene_dir, + ): + yield ( + base_image, + task_text, + scene_text, + *snapshot, + ) + if not auto_loop_is_active(loop_token): + break + + if not auto_loop_is_active(loop_token): + archive_run_log( + mode=RUN_LOG_MODE_AUTO, + task_description=task_text, + scene_description=scene_text, + outcome="stopped", + ) + return "stopped" + + with runtime_lock: + pipeline_failed = runtime.phase_key == "failed" + pipeline_error = runtime.last_error + if pipeline_failed: + cleanup_auto_generated_artifacts() + if pipeline_error: + with runtime_lock: + runtime.last_error = pipeline_error + runtime.log_lines.append( + f"{phase_name} generation failed: {pipeline_error}" + ) + archive_run_log( + mode=RUN_LOG_MODE_AUTO, + task_description=task_text, + scene_description=scene_text, + outcome="pipeline_failed", + ) + return "pipeline_failed" + + for snapshot in wait_for_current_simulation_to_exit( + loop_token, + base_image, + task_text, + scene_text, + ): + yield snapshot + + if not auto_loop_is_active(loop_token): + archive_run_log( + mode=RUN_LOG_MODE_AUTO, + task_description=task_text, + scene_description=scene_text, + outcome="stopped", + ) + return "stopped" + + with runtime_lock: + simulation_completed = ( + runtime.sim_started + and runtime.sim_finished + and runtime.sim_process is None + ) + round_outcome = "completed" if simulation_completed else "simulation_failed" + archive_run_log( + mode=RUN_LOG_MODE_AUTO, + task_description=task_text, + scene_description=scene_text, + outcome=round_outcome, + ) + yield ( + base_image, + task_text, + scene_text, + *ui_snapshot(extra_status=f"{phase_name}: {round_outcome}."), + ) + return round_outcome + + def run_auto_parallel_simulation( + base_image: str, + task_text: str, + scene_text: str, + *, + robot_profile: str | None, + ): + with runtime_lock: + simulation_token = runtime.run_token + runtime.sim_started = False + runtime.sim_finished = False + runtime.sim_returncode = None + runtime.last_error = None + runtime.status = "Starting parallel simulation..." + clear_run_timing_locked() + runtime.log_lines.append("Auto phase: starting parallel simulation.") + + simulation_error = launch_current_simulation( + simulation_token, + parallel_env=True, + robot_profile=robot_profile, + run_log_mode=RUN_LOG_MODE_AUTO, + task_description=task_text, + scene_description=scene_text, + ) + if simulation_error is not None: + with runtime_lock: + runtime.phase_key = "failed" + runtime.status = ( + f"Parallel simulation launch failed: {simulation_error}" + ) + runtime.last_error = simulation_error + runtime.log_lines.append(runtime.status) + archive_run_log( + mode=RUN_LOG_MODE_AUTO, + task_description=task_text, + scene_description=scene_text, + outcome="simulation_launch_failed", + ) + yield ( + base_image, + task_text, + scene_text, + *ui_snapshot(), + ) + return "simulation_failed" + + yield ( + base_image, + task_text, + scene_text, + *ui_snapshot(extra_status="Parallel simulation started."), + ) + for snapshot in wait_for_current_simulation_to_exit( + loop_token, + base_image, + task_text, + scene_text, + ): + yield snapshot + + if not auto_loop_is_active(loop_token): + archive_run_log( + mode=RUN_LOG_MODE_AUTO, + task_description=task_text, + scene_description=scene_text, + outcome="stopped", + ) + return "stopped" + + with runtime_lock: + simulation_completed = ( + runtime.sim_started + and runtime.sim_finished + and runtime.sim_process is None + ) + round_outcome = "completed" if simulation_completed else "simulation_failed" + archive_run_log( + mode=RUN_LOG_MODE_AUTO, + task_description=task_text, + scene_description=scene_text, + outcome=round_outcome, + ) + yield ( + base_image, + task_text, + scene_text, + *ui_snapshot(extra_status=f"Parallel simulation: {round_outcome}."), + ) + return round_outcome + + while auto_loop_is_active(loop_token): + auto_task = "" + auto_scene = "" + task_label = "unknown" + with runtime_lock: + runtime.auto_round += 1 + auto_round = runtime.auto_round + runtime.status = f"Auto round {auto_round}: cleaning previous artifacts." + runtime.last_error = None + runtime.sim_started = False + runtime.sim_finished = False + runtime.sim_returncode = None + runtime.log_lines.clear() + clear_run_timing_locked() + runtime.log_lines.append(f"Auto round {auto_round} started.") + + cleanup_errors = cleanup_auto_generated_artifacts() + if cleanup_errors: + with runtime_lock: + runtime.log_lines.extend(cleanup_errors) + + if not auto_loop_is_active(loop_token): + break + + try: + with runtime_lock: + selected_language = runtime.language + auto_input = generate_auto_text_input( + language=selected_language, + include_scene=False, + ) + except Exception as exc: + if not auto_loop_is_active(loop_token): + break + with runtime_lock: + runtime.phase_key = "failed" + runtime.status = f"Auto text generation failed: {exc}" + runtime.last_error = str(exc) + clear_run_timing_locked() + runtime.log_lines.append(runtime.status) + yield ( + gr.update(), + gr.update(), + gr.update(), + *ui_snapshot(), + ) + archive_run_log( + mode=RUN_LOG_MODE_AUTO, + task_description=auto_task, + scene_description=auto_scene, + outcome="text_generation_failed", + ) + continue + + base_image = auto_input.base_image_path.as_posix() + auto_task = auto_input.task_description + task_label = f"task{auto_input.task_index[0]}_{auto_input.task_index[1]}" + with runtime_lock: + runtime.task_text = format_current_task(auto_task, auto_scene) + runtime.input_task_text = auto_task + runtime.input_scene_text = auto_scene + runtime.image_path = auto_input.base_image_path + runtime.video_path = None + runtime.lerobot_video_path = None + runtime.lerobot_dataset_path = None + runtime.phase_key = "received" + runtime.status = ( + f"Auto round {auto_round}: selected {task_label}. " + "Starting prompt2scene pipeline." + ) + runtime.last_error = None + runtime.log_lines.append( + f"Auto selected {task_label}: task={auto_task!r}, scene={auto_scene!r}" + ) + if auto_input.prebuilt_scene_dir is not None: + runtime.log_lines.append( + f"Auto prebuilt scene: {auto_input.prebuilt_scene_dir}" + ) + yield ( + base_image, + auto_task, + auto_scene, + *ui_snapshot(extra_status=f"Auto text generated: {task_label}."), + ) + + if not auto_loop_is_active(loop_token): + archive_run_log( + mode=RUN_LOG_MODE_AUTO, + task_description=auto_task, + scene_description=auto_scene, + outcome="stopped", + ) + break + + with runtime_lock: + selected_language = runtime.language + phase_results: list[str] = [] + + phase_results.append("stopped") + set_auto_control_state(SCENE_MODE_INITIAL, False, robot_profile) + phase_generator = run_auto_phase( + "Initial generation", + base_image, + auto_task, + auto_scene, + scene_mode=SCENE_MODE_INITIAL, + parallel_env=False, + robot_profile=robot_profile, + force_initial=True, + prebuilt_scene_dir=auto_input.prebuilt_scene_dir, + ) + try: + while True: + yield next(phase_generator) + except StopIteration as exc: + phase_results[0] = str(exc.value) + + if phase_results[0] == "stopped": + break + if phase_results[0] == "pipeline_failed": + continue + + try: + auto_edit_scene = generate_auto_scene_description( + task_index=auto_input.task_index, + language=selected_language, + ensure_scene=True, + ) + except Exception as exc: + with runtime_lock: + runtime.phase_key = "failed" + runtime.status = f"Auto scene description generation failed: {exc}" + runtime.last_error = str(exc) + clear_run_timing_locked() + runtime.log_lines.append(runtime.status) + yield ( + gr.update(), + gr.update(), + gr.update(), + *ui_snapshot(), + ) + archive_run_log( + mode=RUN_LOG_MODE_AUTO, + task_description=auto_task, + scene_description=auto_scene, + outcome="text_generation_failed", + ) + continue + + phase_results.append("stopped") + set_auto_control_state(SCENE_MODE_EDIT, False, robot_profile) + phase_generator = run_auto_phase( + "Scene edit", + base_image, + auto_task, + auto_edit_scene, + scene_mode=SCENE_MODE_EDIT, + parallel_env=False, + robot_profile=robot_profile, + force_initial=False, + ) + try: + while True: + yield next(phase_generator) + except StopIteration as exc: + phase_results[1] = str(exc.value) + + if phase_results[1] == "stopped": + break + if phase_results[1] == "pipeline_failed": + continue + + phase_results.append("stopped") + set_auto_control_state(SCENE_MODE_EDIT, True, robot_profile) + phase_generator = run_auto_parallel_simulation( + base_image, + auto_task, + auto_edit_scene, + robot_profile=robot_profile, + ) + try: + while True: + yield next(phase_generator) + except StopIteration as exc: + phase_results[2] = str(exc.value) + + if phase_results[2] == "stopped": + break + + phase_results.append("stopped") + set_auto_control_state(SCENE_MODE_TASK_ONLY, False, ROBOT_PROFILE_FRANKA) + phase_generator = run_auto_phase( + "Task-only Franka", + base_image, + auto_task, + "", + scene_mode=SCENE_MODE_TASK_ONLY, + parallel_env=False, + robot_profile=ROBOT_PROFILE_FRANKA, + force_initial=False, + ) + try: + while True: + yield next(phase_generator) + except StopIteration as exc: + phase_results[3] = str(exc.value) + + if phase_results[3] == "stopped": + break + if phase_results[3] == "pipeline_failed": + continue + + phase_results.append("stopped") + set_auto_control_state(SCENE_MODE_TASK_ONLY, True, ROBOT_PROFILE_FRANKA) + phase_generator = run_auto_parallel_simulation( + base_image, + auto_task, + "", + robot_profile=ROBOT_PROFILE_FRANKA, + ) + try: + while True: + yield next(phase_generator) + except StopIteration as exc: + phase_results[4] = str(exc.value) + + if phase_results[4] == "stopped": + break + + cleanup_errors = cleanup_auto_generated_artifacts() + if cleanup_errors: + with runtime_lock: + runtime.log_lines.extend(cleanup_errors) + + finish_auto_loop(loop_token) + + +def _scene_engine_phase_from_log(line: str, current_key: str) -> str: + """Map the standalone Scene Engine's stage names to the shared progress UI.""" + text = line.lower() + mapping = ( + ("scene understanding", "scene_intake"), + ("scene segmentation", "relations"), + ("coarse layout", "asset_generation"), + ("scene export", "gym_export"), + ) + current_progress = PHASES.get(current_key, PHASES["idle"]).progress + for needle, phase_key in mapping: + if needle in text and PHASES[phase_key].progress > current_progress: + return phase_key + return current_key + + +def _scene_engine_updates( + output_root: Path | None = None, + preview_html: str | None = None, +) -> tuple[int, str, str | None, str]: + with runtime_lock: + phase = PHASES.get(runtime.phase_key, PHASES["idle"]) + status = format_status( + runtime.status, + phase=phase, + busy=runtime.is_busy, + last_error=runtime.last_error, + ) + return ( + phase.progress, + status, + output_root.as_posix() if output_root is not None else None, + preview_html or "", + ) + + +def _prepare_scene_engine_input( + image_value: str | np.ndarray | Image.Image, +) -> tuple[str, Path, Path]: + """Normalize an uploaded image and store it under a stable content hash.""" + if image_value is None: + raise ValueError("Please upload an image first.") + if isinstance(image_value, str): + image = Image.open(image_value) + elif isinstance(image_value, np.ndarray): + image = Image.fromarray(image_value) + elif isinstance(image_value, Image.Image): + image = image_value + else: + raise TypeError(f"Unsupported image input type: {type(image_value)!r}") + + normalized = ImageOps.exif_transpose(image).convert("RGB") + image_bytes = io.BytesIO() + normalized.save(image_bytes, format="PNG") + scene_hash = hashlib.sha256(image_bytes.getvalue()).hexdigest()[:16] + output_root = DEBUG_SCENE_ENGINE_ROOT / scene_hash + output_root.mkdir(parents=True, exist_ok=True) + image_path = output_root / "input.png" + image_path.write_bytes(image_bytes.getvalue()) + return scene_hash, output_root, image_path + + +def _wait_for_viser(port: int, process: subprocess.Popen[str]) -> bool: + """Wait briefly for Viser's HTTP listener, without treating Ctrl-C as success.""" + deadline = time.monotonic() + 15.0 + while time.monotonic() < deadline: + if process.poll() is not None: + return False + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + time.sleep(0.25) + return False + + +def _viser_iframe(port: int, scene_hash: str) -> str: + """Embed the Viser service using the same hostname as the Gradio page.""" + srcdoc = ( + "" + ) + return ( + f"
Viser preview: {html.escape(scene_hash)}" + f"" + "
" + ) + + +def reset_scene_engine(): + """Clear Scene Engine widgets and stop its generator and Viser process groups.""" + with runtime_lock: + generator_process = runtime.scene_engine_process + preview_process = runtime.scene_preview_process + owns_runtime = runtime.scene_engine_is_running + other_workflow_running = not owns_runtime and runtime.is_busy + if owns_runtime or not runtime.is_busy: + if owns_runtime: + runtime.run_token = uuid.uuid4().hex + if runtime.process is generator_process: + runtime.process = None + runtime.is_busy = False + set_runtime_phase_locked("idle") + runtime.status = "Scene Engine reset." + runtime.image_path = None + runtime.last_error = None + runtime.log_lines.clear() + clear_run_timing_locked() + runtime.scene_engine_process = None + runtime.scene_preview_process = None + runtime.scene_engine_is_running = False + + for process in {generator_process, preview_process}: + if process is not None: + terminate_process_group(process) + + return ( + None, + PHASES["idle"].progress, + format_status( + "Scene Engine reset." + if not other_workflow_running + else "Scene Engine preview reset; another workflow is still running." + ), + "", + "
" + "The Viser preview will appear here after generation." + "
", + ) + + +def run_scene_engine(image_value: str | np.ndarray | Image.Image): + """Generate one image-conditioned scene and expose its Viser preview.""" + output_root: Path | None = None + preview_html = "" + token = uuid.uuid4().hex + with runtime_lock: + if runtime.is_busy: + runtime.status = "Another pipeline is already running." + runtime.last_error = runtime.status + busy_message = runtime.status + else: + runtime.run_token = token + runtime.is_busy = True + runtime.scene_engine_is_running = True + set_runtime_phase_locked("received") + runtime.status = "Preparing Scene Engine input." + runtime.last_error = None + runtime.log_lines.clear() + clear_run_timing_locked() + busy_message = None + + if busy_message is not None: + yield _scene_engine_updates(output_root, preview_html) + return + + try: + scene_hash, output_root, image_path = _prepare_scene_engine_input(image_value) + except Exception as exc: + with runtime_lock: + if runtime.run_token != token: + return + runtime.is_busy = False + runtime.scene_engine_is_running = False + set_runtime_phase_locked("failed") + runtime.status = f"Input error: {exc}" + runtime.last_error = str(exc) + yield _scene_engine_updates(output_root, preview_html) + return + + old_preview: subprocess.Popen[str] | None = None + old_generator: subprocess.Popen[str] | None = None + with runtime_lock: + if runtime.run_token != token: + return + old_preview = runtime.scene_preview_process + old_generator = runtime.scene_engine_process + runtime.scene_engine_process = None + runtime.scene_preview_process = None + runtime.status = f"Image saved. Generating Scene Engine output {scene_hash}." + runtime.image_path = image_path + + if old_preview is not None: + terminate_process_group(old_preview) + if old_generator is not None: + terminate_process_group(old_generator) + + command = [ + sys.executable, + "-m", + COMMANDS["scene_engine"]["module"], + *COMMANDS["scene_engine"]["base_args"], + "--image", + str(image_path), + "--output_root", + str(output_root), + ] + scene_engine_log = output_root / "scene_engine.log" + scene_engine_log.write_text( + "$ " + " ".join(command) + "\n", + encoding="utf-8", + ) + with runtime_lock: + runtime.log_lines.append("$ " + " ".join(command)) + yield _scene_engine_updates(output_root, preview_html) + + try: + process = start_pipeline(command) + except Exception as exc: + with runtime_lock: + runtime.is_busy = False + runtime.scene_engine_is_running = False + set_runtime_phase_locked("failed") + runtime.status = f"Scene Engine start failed: {exc}" + runtime.last_error = str(exc) + yield _scene_engine_updates(output_root, preview_html) + return + + output_queue: queue.Queue[str] = queue.Queue() + reader = threading.Thread( + target=read_process_output, + args=(process, output_queue, scene_engine_log), + daemon=True, + ) + with runtime_lock: + if runtime.run_token != token: + terminate_process_group(process) + return + runtime.process = process + runtime.scene_engine_process = process + start_run_timing_locked("started") + set_runtime_phase_locked("started") + runtime.status = "Scene Engine generation started." + reader.start() + + while process.poll() is None: + drained = drain_output_queue(output_queue) + with runtime_lock: + if ( + runtime.run_token != token + or runtime.scene_engine_process is not process + ): + return + for line in drained: + runtime.log_lines.append(line) + set_runtime_phase_locked( + _scene_engine_phase_from_log(line, runtime.phase_key) + ) + if (output_root / "scene_export" / "scene_config.json").is_file(): + set_runtime_phase_locked("gym_export") + runtime.status = PHASES[runtime.phase_key].label + "." + yield _scene_engine_updates(output_root, preview_html) + time.sleep(0.5) + + reader.join(timeout=1.0) + with runtime_lock: + if runtime.run_token != token or runtime.scene_engine_process is not process: + return + for line in drain_output_queue(output_queue): + runtime.log_lines.append(line) + set_runtime_phase_locked( + _scene_engine_phase_from_log(line, runtime.phase_key) + ) + runtime.process = None + runtime.scene_engine_process = None + + scene_export = output_root / "scene_export" / "scene_config.json" + if process.returncode != 0 or not scene_export.is_file(): + detail = ( + f"Scene Engine exited with code {process.returncode}." + if process.returncode != 0 + else f"Scene Engine did not create {scene_export}." + ) + with runtime_lock: + if runtime.run_token != token: + return + runtime.is_busy = False + runtime.scene_engine_is_running = False + set_runtime_phase_locked("failed") + runtime.status = detail + runtime.last_error = detail + yield _scene_engine_updates(output_root, preview_html) + return + + port = SCENE_ENGINE_VISER_PORT + preview_command = [ + sys.executable, + COMMANDS["scene_engine"]["preview_script"], + "--output_root", + str(output_root), + "--viser", + "--viser-host", + "0.0.0.0", + "--viser-port", + str(port), + ] + try: + preview_process = start_pipeline(preview_command) + except Exception as exc: + with runtime_lock: + if runtime.run_token != token: + return + runtime.is_busy = False + runtime.scene_engine_is_running = False + set_runtime_phase_locked("failed") + runtime.status = f"Viser preview start failed: {exc}" + runtime.last_error = str(exc) + yield _scene_engine_updates(output_root, preview_html) + return + + with runtime_lock: + if runtime.run_token != token: + terminate_process_group(preview_process) + return + runtime.scene_preview_process = preview_process + runtime.log_lines.append("$ " + " ".join(preview_command)) + set_runtime_phase_locked("preview") + runtime.status = "Starting Viser preview..." + yield _scene_engine_updates(output_root, preview_html) + + if not _wait_for_viser(port, preview_process): + terminate_process_group(preview_process) + with runtime_lock: + if runtime.run_token != token: + return + runtime.scene_preview_process = None + runtime.is_busy = False + runtime.scene_engine_is_running = False + set_runtime_phase_locked("failed") + runtime.status = "Viser preview did not start." + runtime.last_error = runtime.status + yield _scene_engine_updates(output_root, preview_html) + return + + preview_html = _viser_iframe(port, scene_hash) + with runtime_lock: + if ( + runtime.run_token != token + or runtime.scene_preview_process is not preview_process + ): + terminate_process_group(preview_process) + return + runtime.scene_preview_process = preview_process + runtime.is_busy = False + runtime.scene_engine_is_running = False + set_runtime_phase_locked("complete") + runtime.status = "Scene generated successfully. Viser preview is ready." + runtime.last_error = None + yield _scene_engine_updates(output_root, preview_html) + + +def run_action_engine_from_current(task_text: str, robot_profile: str | None): + """Launch DexSim for the Gym scene most recently generated by Scene engine.""" + task_text = (task_text or "").strip() + failure: str | None = None + with runtime_lock: + if not task_text: + runtime.status = "Enter a task description first." + runtime.last_error = "Task description is required." + failure = runtime.status + elif not rerun_simulation_is_available(): + runtime.status = "Generate a scene first." + runtime.last_error = "Current Gym scene/config is unavailable." + failure = runtime.status + elif ( + runtime.process is not None + or runtime.sim_process is not None + or runtime.is_busy + ): + runtime.status = "Another pipeline or simulation is already running." + runtime.last_error = "Busy." + failure = runtime.status + elif not action_agent_cli_is_available(): + runtime.status = ( + "Action-agent CLI is unavailable in this EmbodiChain environment." + ) + runtime.last_error = ( + "Missing embodichain.gen_sim.action_agent_pipeline.cli.run_agent" + ) + failure = runtime.status + else: + token = uuid.uuid4().hex + runtime.run_token = token + runtime.task_text = task_text + runtime.input_task_text = task_text + runtime.input_scene_text = "" + runtime.status = "Starting DexSim action simulation..." + runtime.last_error = None + runtime.log_lines.append(runtime.status) + + if failure: + return ui_snapshot() + + error = launch_current_simulation( + token, + robot_profile=robot_profile, + run_log_mode=RUN_LOG_MODE_INTERACT, + task_description=task_text, + ) + if error: + with runtime_lock: + runtime.status = error + runtime.last_error = error + return ui_snapshot() + + +def action_agent_cli_is_available() -> bool: + """Avoid spawning a subprocess when the optional action-agent package is absent.""" + try: + return importlib.util.find_spec(COMMANDS["agent"]["module"]) is not None + except (ImportError, ModuleNotFoundError): + return False + + +def supervise_pipeline( + token: str, + stage: ScenePaths, + mode: str, + process: subprocess.Popen[str], + display_task_text: str, + task_description: str, + scene_description: str, + output_queue: queue.Queue[str], + reader: threading.Thread, + parallel_env: bool, + robot_profile: str | None, + run_log_mode: str, + initial_scene_path: Path | None, + show_generated_scene_as_edit: bool, + launch_simulation: bool = True, +) -> None: + is_edit = mode == PIPELINE_MODE_EDIT + is_task_only = mode == PIPELINE_MODE_TASK_ONLY + scene_build_error: str | None = None + simulation_error: str | None = None + simulation_started = False + try: + while True: + with runtime_lock: + still_current = runtime.run_token == token + if not still_current: + terminate_process_group(process) + return + + drained = drain_output_queue(output_queue) + if drained: + with runtime_lock: + for line in drained: + runtime.log_lines.append(line) + set_runtime_phase_locked( + update_phase_from_log(line, runtime.phase_key) + ) + + with runtime_lock: + detected_key = detect_phase_from_files(runtime.phase_key, stage) + set_runtime_phase_locked(detected_key) + if detected_key in PHASES and runtime.phase_key != "failed": + runtime.status = PHASES[detected_key].label + "." + + glb_paths = collect_generated_object_glbs(stage) + if glb_paths and ( + not stage.gradio_object_preview_glb.is_file() + or not object_preview_is_current( + stage.object_preview_manifest, + glb_paths, + ) + ): + try: + object_preview_path = build_object_preview_scene( + glb_paths, + stage.gradio_scene_dir, + ) + with runtime_lock: + runtime.object_model_path = object_preview_path + set_runtime_phase_locked( + _choose_later_phase( + runtime.phase_key, + PHASES.get(runtime.phase_key, PHASES["idle"]).progress, + "asset_generation", + )[0] + ) + runtime.status = ( + f"Generated object GLB preview loaded " + f"({len(glb_paths)} files)." + ) + except Exception as exc: + with runtime_lock: + runtime.log_lines.append(f"Object preview pending: {exc}") + + if ( + not is_edit + and not is_task_only + and stage.fast_gym_config.is_file() + and not gradio_scene_is_current( + stage.gradio_scene_glb, + stage.scene_manifest, + stage.fast_gym_config, + ) + ): + try: + scene_path = build_gradio_scene_from_fast_config( + stage.fast_gym_config, + stage.gradio_scene_dir, + ) + scene_build_error = None + with runtime_lock: + if show_generated_scene_as_edit: + if initial_scene_path is not None: + runtime.scene_model_path = initial_scene_path + runtime.edited_scene_model_path = scene_path + else: + runtime.scene_model_path = scene_path + set_runtime_phase_locked("preview") + runtime.status = "3D preview loaded." + runtime.last_error = None + except Exception as exc: + scene_build_error = str(exc) + with runtime_lock: + runtime.log_lines.append( + f"3D preview error: {scene_build_error}" + ) + runtime.last_error = scene_build_error + + if process.poll() is not None: + break + time.sleep(0.5) + + reader.join(timeout=1.0) + drained = drain_output_queue(output_queue) + with runtime_lock: + for line in drained: + runtime.log_lines.append(line) + set_runtime_phase_locked(update_phase_from_log(line, runtime.phase_key)) + + glb_paths = collect_generated_object_glbs(stage) + if glb_paths and ( + not stage.gradio_object_preview_glb.is_file() + or not object_preview_is_current(stage.object_preview_manifest, glb_paths) + ): + try: + object_preview_path = build_object_preview_scene( + glb_paths, + stage.gradio_scene_dir, + ) + with runtime_lock: + runtime.object_model_path = object_preview_path + except Exception as exc: + with runtime_lock: + runtime.log_lines.append(f"Object preview skipped: {exc}") + + if is_task_only and process.returncode == 0 and stage.fast_gym_config.is_file(): + try: + scene_path = build_gradio_scene_from_fast_config( + stage.fast_gym_config, + stage.gradio_scene_dir, + ) + scene_build_error = None + if GRADIO_SCENE_GLB.is_file(): + GRADIO_SCENE_DIR.mkdir(parents=True, exist_ok=True) + shutil.copy2(GRADIO_SCENE_GLB, GRADIO_INITIAL_SCENE_GLB) + with runtime_lock: + runtime.scene_model_path = scene_path + runtime.edited_scene_model_path = None + set_runtime_phase_locked("preview") + runtime.status = "3D preview loaded." + runtime.last_error = None + except Exception as exc: + scene_build_error = str(exc) + elif ( + stage.fast_gym_config.is_file() + and (not is_edit or process.returncode == 0) + and not gradio_scene_is_current( + stage.gradio_scene_glb, + stage.scene_manifest, + stage.fast_gym_config, + ) + ): + try: + scene_path = build_gradio_scene_from_fast_config( + stage.fast_gym_config, + stage.gradio_scene_dir, + ) + scene_build_error = None + with runtime_lock: + if is_edit or show_generated_scene_as_edit: + runtime.edited_scene_model_path = scene_path + else: + runtime.scene_model_path = scene_path + set_runtime_phase_locked("preview") + runtime.status = "3D preview loaded." + runtime.last_error = None + except Exception as exc: + scene_build_error = str(exc) + + cleanup_errors: list[str] = [] + promotion_error: str | None = None + pipeline_output_ready = ( + stage.fast_gym_config.is_file() and stage.agent_config.is_file() + if is_task_only + else stage.fast_gym_config.is_file() + ) + missing_output_name = ( + f"{stage.fast_gym_config.name} and/or {stage.agent_config.name}" + if is_task_only + else FAST_GYM_CONFIG.name + ) + pipeline_succeeded = ( + process.returncode == 0 and pipeline_output_ready and not scene_build_error + ) + if pipeline_succeeded: + if is_edit: + with runtime_lock: + if runtime.run_token == token: + runtime.image_path = ( + IMAGE_PATH if IMAGE_PATH.is_file() else None + ) + if GRADIO_OBJECT_PREVIEW_GLB.is_file(): + runtime.object_model_path = GRADIO_OBJECT_PREVIEW_GLB + if GRADIO_INITIAL_SCENE_GLB.is_file(): + runtime.scene_model_path = GRADIO_INITIAL_SCENE_GLB + if GRADIO_SCENE_GLB.is_file(): + runtime.edited_scene_model_path = GRADIO_SCENE_GLB + if launch_simulation: + simulation_error = launch_current_simulation( + token, + parallel_env=parallel_env, + robot_profile=robot_profile, + run_log_mode=run_log_mode, + task_description=task_description, + scene_description=scene_description, + ) + simulation_started = simulation_error is None + elif is_task_only: + with runtime_lock: + if runtime.run_token == token: + runtime.image_path = ( + IMAGE_PATH if IMAGE_PATH.is_file() else None + ) + if GRADIO_OBJECT_PREVIEW_GLB.is_file(): + runtime.object_model_path = GRADIO_OBJECT_PREVIEW_GLB + if GRADIO_INITIAL_SCENE_GLB.is_file(): + runtime.scene_model_path = GRADIO_INITIAL_SCENE_GLB + elif GRADIO_SCENE_GLB.is_file(): + runtime.scene_model_path = GRADIO_SCENE_GLB + runtime.edited_scene_model_path = None + if launch_simulation: + simulation_error = launch_current_simulation( + token, + parallel_env=parallel_env, + robot_profile=robot_profile, + run_log_mode=run_log_mode, + task_description=task_description, + scene_description=scene_description, + ) + simulation_started = simulation_error is None + else: + try: + cleanup_errors = promote_stage_to_current(stage, token) + except Exception as exc: + promotion_error = str(exc) + else: + initial_scene_error: str | None = None + try: + if show_generated_scene_as_edit and initial_scene_path: + GRADIO_SCENE_DIR.mkdir(parents=True, exist_ok=True) + shutil.copy2(initial_scene_path, GRADIO_INITIAL_SCENE_GLB) + else: + ensure_initial_scene_snapshot(overwrite=True) + except Exception as exc: + initial_scene_error = str(exc) + with runtime_lock: + if runtime.run_token == token: + runtime.image_path = IMAGE_PATH + if GRADIO_OBJECT_PREVIEW_GLB.is_file(): + runtime.object_model_path = GRADIO_OBJECT_PREVIEW_GLB + if show_generated_scene_as_edit: + if GRADIO_INITIAL_SCENE_GLB.is_file(): + runtime.scene_model_path = GRADIO_INITIAL_SCENE_GLB + elif initial_scene_path is not None: + runtime.scene_model_path = initial_scene_path + if GRADIO_SCENE_GLB.is_file(): + runtime.edited_scene_model_path = GRADIO_SCENE_GLB + elif GRADIO_INITIAL_SCENE_GLB.is_file(): + runtime.scene_model_path = GRADIO_INITIAL_SCENE_GLB + elif GRADIO_SCENE_GLB.is_file(): + runtime.scene_model_path = GRADIO_SCENE_GLB + if not show_generated_scene_as_edit: + runtime.edited_scene_model_path = None + if initial_scene_error: + runtime.log_lines.append( + f"Initial scene snapshot skipped: {initial_scene_error}" + ) + if launch_simulation: + simulation_error = launch_current_simulation( + token, + parallel_env=parallel_env, + robot_profile=robot_profile, + run_log_mode=run_log_mode, + task_description=task_description, + scene_description=scene_description, + ) + simulation_started = simulation_error is None + + archive_after_status = False + archive_outcome = "completed" + with runtime_lock: + if runtime.run_token != token: + return + runtime.is_busy = False + runtime.process = None + if pipeline_succeeded and not promotion_error: + set_runtime_phase_locked("complete") + runtime.status = "Pipeline completed successfully." + runtime.task_text = display_task_text + runtime.image_path = IMAGE_PATH if IMAGE_PATH.is_file() else None + if GRADIO_OBJECT_PREVIEW_GLB.is_file(): + runtime.object_model_path = GRADIO_OBJECT_PREVIEW_GLB + if is_edit or show_generated_scene_as_edit: + if GRADIO_INITIAL_SCENE_GLB.is_file(): + runtime.scene_model_path = GRADIO_INITIAL_SCENE_GLB + elif initial_scene_path is not None: + runtime.scene_model_path = initial_scene_path + if GRADIO_SCENE_GLB.is_file(): + runtime.edited_scene_model_path = GRADIO_SCENE_GLB + elif GRADIO_INITIAL_SCENE_GLB.is_file(): + runtime.scene_model_path = GRADIO_INITIAL_SCENE_GLB + runtime.edited_scene_model_path = None + elif GRADIO_SCENE_GLB.is_file(): + runtime.scene_model_path = GRADIO_SCENE_GLB + runtime.edited_scene_model_path = None + if simulation_started: + runtime.status += "\nDexsim simulation launched." + if cleanup_errors: + runtime.status += "\nCleanup completed with errors; see Last error." + runtime.last_error = "\n".join(cleanup_errors) + if simulation_error: + runtime.status += ( + "\nDexsim launch failed; Gradio preview is still available." + ) + runtime.last_error = simulation_error + elif process.returncode == 0 and not pipeline_output_ready: + set_runtime_phase_locked("failed") + runtime.status = f"Pipeline ended without {missing_output_name}." + runtime.last_error = runtime.status + archive_outcome = "pipeline_output_missing" + elif scene_build_error: + set_runtime_phase_locked("failed") + runtime.status = f"3D preview failed: {scene_build_error}" + runtime.last_error = scene_build_error + archive_outcome = "preview_failed" + elif promotion_error: + set_runtime_phase_locked("failed") + runtime.status = f"Scene promotion failed: {promotion_error}" + runtime.last_error = promotion_error + archive_outcome = "promotion_failed" + else: + set_runtime_phase_locked("failed") + runtime.status = ( + f"Pipeline failed with return code {process.returncode}." + ) + runtime.last_error = runtime.status + archive_outcome = "pipeline_failed" + if pipeline_succeeded and not promotion_error: + archive_outcome = ( + "dexsim_launch_failed" if simulation_error else "completed" + ) + archive_after_status = ( + run_log_mode == RUN_LOG_MODE_INTERACT and not simulation_started + ) + if archive_after_status: + archive_run_log( + mode=RUN_LOG_MODE_INTERACT, + task_description=task_description or display_task_text, + scene_description=scene_description, + outcome=archive_outcome, + ) + except Exception as exc: + should_archive_exception = False + with runtime_lock: + if runtime.run_token == token: + runtime.is_busy = False + runtime.process = None + set_runtime_phase_locked("failed") + runtime.status = f"Pipeline supervision failed: {exc}" + runtime.last_error = str(exc) + runtime.log_lines.append(runtime.status) + should_archive_exception = run_log_mode == RUN_LOG_MODE_INTERACT + if should_archive_exception: + archive_run_log( + mode=RUN_LOG_MODE_INTERACT, + task_description=task_description or display_task_text, + scene_description=scene_description, + outcome="pipeline_supervision_failed", + ) + + +def launch_current_simulation( + token: str, + *, + parallel_env: bool = False, + robot_profile: str | None = None, + run_log_mode: str = RUN_LOG_MODE_INTERACT, + task_description: str = "", + scene_description: str = "", +) -> str | None: + if not CURRENT_PATHS.fast_gym_config.is_file(): + return f"Dexsim launch skipped; missing {CURRENT_PATHS.fast_gym_config}" + if not CURRENT_PATHS.agent_config.is_file(): + return f"Dexsim launch skipped; missing {CURRENT_PATHS.agent_config}" + + command = build_run_agent_command( + CURRENT_PATHS, + parallel_env=parallel_env, + robot_profile=robot_profile, + ) + started_at_ns = time.time_ns() + try: + process = start_pipeline(command) + except Exception as exc: + return f"Dexsim launch failed: {exc}" + + output_queue: queue.Queue[str] = queue.Queue() + reader = threading.Thread( + target=read_process_output, + args=(process, output_queue), + daemon=True, + ) + monitor = threading.Thread( + target=monitor_simulation, + args=( + token, + process, + output_queue, + reader, + started_at_ns, + run_log_mode, + task_description, + scene_description, + parallel_env, + ), + daemon=True, + ) + + with runtime_lock: + if runtime.run_token != token: + stale = True + else: + stale = False + runtime.sim_process = process + runtime.sim_started = True + runtime.sim_finished = False + runtime.sim_returncode = None + record_simulation_started_locked() + runtime.log_lines.append("$ " + " ".join(command)) + + if stale: + terminate_process_group(process) + return None + + reader.start() + monitor.start() + return None + + +def monitor_simulation( + token: str, + process: subprocess.Popen[str], + output_queue: queue.Queue[str], + reader: threading.Thread, + started_at_ns: int, + run_log_mode: str, + task_description: str, + scene_description: str, + parallel_env: bool, +) -> None: + while process.poll() is None: + append_simulation_logs(token, process, drain_output_queue(output_queue)) + time.sleep(0.5) + + reader.join(timeout=1.0) + append_simulation_logs(token, process, drain_output_queue(output_queue)) + latest_video = latest_audience_output_video(min_mtime_ns=started_at_ns) + latest_dataset = latest_lerobot_dataset(min_mtime_ns=started_at_ns) + lerobot_video = ( + build_lerobot_preview_video(latest_dataset) + if latest_dataset is not None + else None + ) + combined_video = ( + build_single_env_combined_video(latest_video, lerobot_video) + if not parallel_env + else None + ) + display_video = combined_video or latest_video + + should_archive = False + archive_outcome = "completed" + with runtime_lock: + if runtime.run_token != token or runtime.sim_process is not process: + return + record_simulation_finished_locked() + runtime.sim_process = None + runtime.sim_finished = True + runtime.sim_returncode = process.returncode + runtime.video_path = display_video + runtime.lerobot_dataset_path = latest_dataset + runtime.lerobot_video_path = ( + None if combined_video is not None else lerobot_video + ) + if process.returncode == 0: + runtime.status = ( + "Pipeline completed successfully.\nDexsim simulation finished." + ) + if latest_video is None: + runtime.log_lines.append("Audience video not found in outputs.") + if latest_dataset is None: + runtime.log_lines.append( + "LeRobot dataset with recorded frames not found." + ) + elif lerobot_video is None: + runtime.log_lines.append( + f"LeRobot dataset found, but preview was not generated: {latest_dataset}" + ) + elif combined_video is not None: + runtime.log_lines.append( + f"Single-env combined video created: {combined_video}" + ) + else: + runtime.status = ( + "Pipeline completed successfully.\n" + f"Dexsim simulation exited with return code {process.returncode}." + ) + runtime.log_lines.append( + f"Dexsim simulation exited with return code {process.returncode}." + ) + archive_outcome = "simulation_failed" + should_archive = run_log_mode == RUN_LOG_MODE_INTERACT + if should_archive: + archive_run_log( + mode=RUN_LOG_MODE_INTERACT, + task_description=task_description, + scene_description=scene_description, + outcome=archive_outcome, + audience_video=display_video, + ) + + +def append_simulation_logs( + token: str, + process: subprocess.Popen[str], + lines: list[str], +) -> None: + if not lines: + return + with runtime_lock: + if runtime.run_token != token or runtime.sim_process is not process: + return + for line in lines: + runtime.log_lines.append(line) + + +def drain_output_queue(output_queue: queue.Queue[str]) -> list[str]: + lines: list[str] = [] + while True: + try: + lines.append(output_queue.get_nowait()) + except queue.Empty: + return lines + + +def run_reset(): + cleanup_errors = reset_current_scene() + last_error = "\n".join(cleanup_errors) if cleanup_errors else None + status_text = ( + "Reset complete." + if not cleanup_errors + else "Reset completed, but some cleanup failed." + ) + return ( + None, + "", + "", + None, + "", + PHASES["idle"].progress, + format_status(status_text, last_error=last_error), + None, + None, + None, + ) + + +def stop_current_run_without_cleanup(): + process: subprocess.Popen[str] | None = None + sim_process: subprocess.Popen[str] | None = None + with runtime_lock: + runtime.run_token = uuid.uuid4().hex + runtime.auto_loop_active = False + runtime.auto_loop_token = None + runtime.auto_round = 0 + process = runtime.process + sim_process = runtime.sim_process + runtime.process = None + runtime.sim_process = None + runtime.sim_started = False + runtime.sim_finished = False + runtime.sim_returncode = None + runtime.is_busy = False + runtime.phase_key = "idle" + runtime.status = "Stopped." + runtime.task_text = "" + runtime.input_task_text = "" + runtime.input_scene_text = "" + runtime.image_path = None + runtime.video_path = None + runtime.lerobot_video_path = None + runtime.lerobot_dataset_path = None + runtime.object_model_path = None + runtime.scene_model_path = None + runtime.edited_scene_model_path = None + runtime.last_error = None + runtime.log_lines.clear() + + if process is not None: + terminate_process_group(process) + if sim_process is not None: + terminate_process_group(sim_process) + + return ( + None, + "", + "", + None, + "", + PHASES["idle"].progress, + format_status("Stopped."), + None, + None, + None, + ) + + +def run_reset_or_stop(run_mode: str): + if run_mode == TOP_MODE_AUTO: + return stop_current_run_without_cleanup() + return run_reset() + + +def rerun_current_simulation( + run_mode: str | None, + action_mode: str | None, + robot_profile: str | None, +): + def _rerun_outputs(): + return ( + gr.update(), + gr.update(), + gr.update(), + *ui_snapshot(), + ) + + if run_mode != TOP_MODE_INTERACT: + with runtime_lock: + runtime.status = "Rerun 3D is only available in Interact mode." + runtime.last_error = runtime.status + runtime.log_lines.append(runtime.status) + return _rerun_outputs() + + if not rerun_simulation_is_available(): + with runtime_lock: + runtime.status = ( + "Current simulation files are not available. Generate once first." + ) + runtime.last_error = runtime.status + runtime.log_lines.append(runtime.status) + return _rerun_outputs() + + token = uuid.uuid4().hex + with runtime_lock: + if runtime.process is not None: + runtime.status = "Another pipeline run is in progress. Stop it first." + runtime.last_error = runtime.status + runtime.log_lines.append(runtime.status) + return _rerun_outputs() + if runtime.sim_process is not None: + runtime.status = "Another Dexsim process is running. Stop it first." + runtime.last_error = runtime.status + runtime.log_lines.append(runtime.status) + return _rerun_outputs() + if runtime.is_busy: + runtime.status = "Another run is in progress. Stop it first." + runtime.last_error = runtime.status + runtime.log_lines.append(runtime.status) + return _rerun_outputs() + + runtime.run_token = token + runtime.sim_started = False + runtime.sim_finished = False + runtime.sim_returncode = None + runtime.last_error = None + clear_run_timing_locked() + runtime.log_lines.append("Starting Dexsim rerun (run_agent only).") + + simulation_error = launch_current_simulation( + runtime.run_token, + parallel_env=action_mode == TOP_MODE_PARALLEL_ENV, + robot_profile=robot_profile, + run_log_mode=RUN_LOG_MODE_INTERACT, + task_description=runtime.task_text, + scene_description=runtime.input_scene_text, + ) + if simulation_error is not None: + with runtime_lock: + runtime.status = f"Dexsim rerun launch failed: {simulation_error}" + runtime.last_error = simulation_error + runtime.log_lines.append(runtime.status) + + return _rerun_outputs() + + +def randomize_interact_task_input(run_mode: str | None, language: str | None): + """Fill the Interact form with one available template task.""" + if run_mode != TOP_MODE_INTERACT: + return ( + gr.update(), + gr.update(), + gr.update(), + gr.update(), + None, + gr.update(), + None, + None, + ) + auto_input = generate_auto_text_input( + language=language or LANGUAGE_EN, + include_scene=False, + ) + initial_preview = None + if auto_input.prebuilt_scene_dir is not None: + initial_preview = build_interact_random_initial_preview( + auto_input.prebuilt_scene_dir + ).as_posix() + return ( + auto_input.base_image_path.as_posix(), + gr.update(value=auto_input.task_description, interactive=True), + gr.update(), + SCENE_MODE_INITIAL, + ( + auto_input.prebuilt_scene_dir.as_posix() + if auto_input.prebuilt_scene_dir + else None + ), + initial_preview, + None, + None, + ) + + +def randomize_interact_scene_input(run_mode: str | None, language: str | None): + """Fill only the scene text in the Interact form.""" + if run_mode != TOP_MODE_INTERACT: + return gr.update() + scene_description = generate_auto_scene_description( + language=language or LANGUAGE_EN, + ensure_scene=True, + ) + return gr.update(value=scene_description, interactive=True) + + +def clear_interact_prebuilt_scene() -> None: + return None + + +def button_updates( + language: str | None, + run_mode: str | None, + action_mode: str | None, +) -> tuple[Any, Any, Any, Any, Any, Any, Any, Any]: + """Build localized labels while preserving the selected button variants.""" + labels = BUTTON_LABELS.get(language or LANGUAGE_EN, BUTTON_LABELS[LANGUAGE_EN]) + is_auto = run_mode == TOP_MODE_AUTO + is_interact = run_mode != TOP_MODE_AUTO + is_parallel_env = action_mode == TOP_MODE_PARALLEL_ENV + can_rerun = ( + run_mode == TOP_MODE_INTERACT + and rerun_simulation_is_available() + and not runtime.is_busy + and runtime.process is None + and runtime.sim_process is None + ) + return ( + gr.update( + value=labels["auto"], + variant="primary" if is_auto else "secondary", + ), + gr.update( + value=labels["interact"], + variant="primary" if is_interact else "secondary", + ), + gr.update( + value=labels["parallel_env"], + variant="primary" if is_parallel_env else "secondary", + interactive=not is_auto, + ), + gr.update(value=labels["start"] if is_auto else labels["generate"]), + gr.update( + value=labels["rerun_simulation"], + visible=is_interact, + interactive=can_rerun, + ), + gr.update(value=labels["random_input"], visible=is_interact), + gr.update(value=labels["random_scene_input"], visible=is_interact), + gr.update(value=labels["stop"] if is_auto else labels["reset"]), + ) + + +def auto_control_updates( + run_mode: str | None, + action_mode: str | None, +) -> tuple[Any, Any, Any, str | None]: + if run_mode != TOP_MODE_AUTO: + return ( + gr.update(interactive=True), + gr.update(interactive=True), + gr.update(), + action_mode, + ) + + with runtime_lock: + scene_mode = runtime.auto_scene_mode + parallel_env = runtime.auto_parallel_env + robot_profile = runtime.auto_robot_profile + labels = BUTTON_LABELS.get(runtime.language, BUTTON_LABELS[LANGUAGE_EN]) + return ( + gr.update(value=scene_mode, interactive=False), + gr.update(value=robot_profile, interactive=False), + gr.update( + value=labels["parallel_env"], + variant="primary" if parallel_env else "secondary", + interactive=False, + ), + TOP_MODE_PARALLEL_ENV if parallel_env else None, + ) + + +def video_preview_label(language: str | None, action_mode: str | None) -> str: + text = UI_TEXT.get(language or LANGUAGE_EN, UI_TEXT[LANGUAGE_EN]) + key = ( + "parallel_video_preview" + if action_mode == TOP_MODE_PARALLEL_ENV + else "single_video_preview" + ) + return text[key] + + +def scene_mode_choices(language: str | None) -> list[tuple[str, str]]: + text = UI_TEXT.get(language or LANGUAGE_EN, UI_TEXT[LANGUAGE_EN]) + return [ + (text["scene_mode_initial"], SCENE_MODE_INITIAL), + (text["scene_mode_edit"], SCENE_MODE_EDIT), + (text["scene_mode_task_only"], SCENE_MODE_TASK_ONLY), + ] + + +def scene_mode_input_updates(scene_mode: str | None) -> tuple[Any, Any]: + """Set field availability for the selected scene operation.""" + is_task_only = scene_mode == SCENE_MODE_TASK_ONLY + return ( + gr.update(interactive=True), + gr.update(interactive=not is_task_only), + ) + + +def localized_ui_updates( + language: str | None, + action_mode: str | None, +) -> tuple[Any, ...]: + """Return updates for every non-button, user-facing static UI string.""" + text = UI_TEXT.get(language or LANGUAGE_EN, UI_TEXT[LANGUAGE_EN]) + instruction_html = ( + "
{text['instruction']}
" + ) + return ( + gr.update(value=text["heading"]), + gr.update(value=instruction_html), + gr.update(label=text["robot"]), + gr.update(label=text["input_image"]), + gr.update( + label=text["task_description"], + placeholder=text["task_placeholder"], + ), + gr.update( + label=text["scene_description"], + placeholder=text["scene_placeholder"], + ), + gr.update( + label=text["scene_mode"], + choices=scene_mode_choices(language), + ), + gr.update(label=video_preview_label(language, action_mode)), + gr.update(label=text["current_task"]), + gr.update(label=text["progress"]), + gr.update(label=text["initial_preview"]), + gr.update(label=text["edited_preview"]), + gr.update(label=text["object_preview"]), + ) + + +def toggle_language( + language: str | None, + run_mode: str | None, + action_mode: str | None, +): + next_language = LANGUAGE_ZH if language != LANGUAGE_ZH else LANGUAGE_EN + with runtime_lock: + runtime.language = next_language + labels = BUTTON_LABELS[next_language] + return ( + *button_updates(next_language, run_mode, action_mode), + gr.update(value=labels["language"]), + *localized_ui_updates(next_language, action_mode), + next_language, + ) + + +def select_top_mode( + selected_run_mode: str | None, + selected_action_mode: str | None, + current_run_mode: str, + current_action_mode: str | None, + language: str | None, +): + run_mode = selected_run_mode or current_run_mode or TOP_MODE_INTERACT + action_mode = current_action_mode + if selected_action_mode == TOP_MODE_PARALLEL_ENV: + action_mode = ( + None if action_mode == TOP_MODE_PARALLEL_ENV else TOP_MODE_PARALLEL_ENV + ) + elif selected_action_mode: + action_mode = selected_action_mode + if ( + run_mode != current_run_mode + or action_mode != current_action_mode + or run_mode != TOP_MODE_AUTO + ): + stop_auto_loop_if_running() + return ( + *button_updates(language, run_mode, action_mode), + gr.update(label=video_preview_label(language, action_mode)), + run_mode, + action_mode, + ) + + +def ui_snapshot(extra_status: str | None = None): + with runtime_lock: + phase = PHASES.get(runtime.phase_key, PHASES["idle"]) + video_value = None + video_signature = None + if runtime.video_path and runtime.video_path.is_file(): + video_value = runtime.video_path.as_posix() + video_signature = (video_value, runtime.video_path.stat().st_mtime_ns) + if runtime.auto_loop_active: + video_update = video_value + elif video_signature != runtime.last_sent_video_signature: + runtime.last_sent_video_signature = video_signature + video_update = video_value + else: + video_update = gr.update() + object_model_value = ( + runtime.object_model_path.as_posix() + if runtime.object_model_path and runtime.object_model_path.is_file() + else None + ) + model_value = ( + runtime.scene_model_path.as_posix() + if runtime.scene_model_path and runtime.scene_model_path.is_file() + else None + ) + edited_model_value = ( + runtime.edited_scene_model_path.as_posix() + if runtime.edited_scene_model_path + and runtime.edited_scene_model_path.is_file() + else None + ) + task_text = runtime.task_text + status_text = runtime.status + if extra_status: + status_text = f"{status_text}\n{extra_status}" + busy = runtime.is_busy + last_error = runtime.last_error + return ( + video_update, + task_text, + phase.progress, + format_status( + status_text, + phase=phase, + busy=busy, + last_error=last_error, + ), + model_value, + edited_model_value, + object_model_value, + ) + + +def synced_ui_snapshot( + run_mode: str | None = None, + action_mode: str | None = None, + last_seen_input_revision: int | None = None, +): + sync_inputs = False + with runtime_lock: + submitted_input_revision = runtime.submitted_input_revision + sync_inputs = ( + runtime.auto_loop_active + or run_mode == TOP_MODE_AUTO + or submitted_input_revision != (last_seen_input_revision or 0) + ) + image_value = ( + runtime.image_path.as_posix() + if runtime.image_path and runtime.image_path.is_file() + else None + ) + input_task_text = runtime.input_task_text + input_scene_text = runtime.input_scene_text + can_rerun = ( + runtime.process is None + and runtime.sim_process is None + and not runtime.is_busy + and rerun_simulation_is_available() + ) + + if sync_inputs: + input_values = (image_value, input_task_text, input_scene_text) + else: + input_values = (gr.update(), gr.update(), gr.update()) + return ( + *input_values, + *ui_snapshot(), + gr.update( + visible=run_mode == TOP_MODE_INTERACT, + interactive=run_mode == TOP_MODE_INTERACT and can_rerun, + ), + submitted_input_revision, + *auto_control_updates(run_mode, action_mode), + ) + + +def format_status( + status_text: str, + *, + phase: Phase | None = None, + busy: bool = False, + last_error: str | None = None, +) -> str: + if phase is None: + phase = PHASES["idle"] + state = "running" if busy else "ready" + parts = [ + f"**State:** {state}", + f"**Phase:** {phase.progress}% - {phase.label}", + f"**Status:** {status_text}", + ] + if last_error: + escaped_error = last_error.replace("`", "'") + if "\n" in escaped_error: + parts.append(f"**Last error:**\n```text\n{escaped_error}\n```") + else: + parts.append(f"**Last error:** `{escaped_error}`") + return "\n\n".join(parts) diff --git a/embodichain/gen_sim/gradio_ui/assets/dexforce.png b/embodichain/gen_sim/gradio_ui/assets/dexforce.png new file mode 100644 index 000000000..6a8e11b00 Binary files /dev/null and b/embodichain/gen_sim/gradio_ui/assets/dexforce.png differ diff --git a/embodichain/gen_sim/gradio_ui/gradio_app.py b/embodichain/gen_sim/gradio_ui/gradio_app.py new file mode 100644 index 000000000..7dce2e1a8 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/gradio_app.py @@ -0,0 +1,83 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Application entry point. + +The UI callbacks and pipeline services are intentionally kept out of this +module; this file only validates configuration and launches the application. +""" + +from __future__ import annotations + +import signal + +from app_config import ( + ASSETS_DIR, + DEBUG_ENGINE_ROOT, + DEFAULT_CONCURRENCY_LIMIT, +) +from app_env import EMBODICHAIN_ROOT, SERVER_NAME, SERVER_PORT +from app_processes import force_stop_all_child_processes +from app_services import build_demo + +__all__ = ["main"] + + +def _stop_child_processes() -> None: + """Force-stop UI-owned subprocesses without masking app shutdown.""" + try: + force_stop_all_child_processes() + except Exception: + # Shutdown must not be blocked by an already-exited preview process. + pass + + +def _handle_shutdown_signal(signum: int, _frame: object) -> None: + """Terminate UI subprocesses before leaving the Gradio process.""" + _stop_child_processes() + if signum == signal.SIGINT: + raise KeyboardInterrupt + raise SystemExit(128 + signum) + + +def _install_shutdown_handlers() -> None: + """Install cleanup-aware handlers for the normal Gradio stop signals.""" + signal.signal(signal.SIGINT, _handle_shutdown_signal) + signal.signal(signal.SIGTERM, _handle_shutdown_signal) + + +def main() -> None: + if not EMBODICHAIN_ROOT.is_dir(): + raise FileNotFoundError(f"EmbodiChain root not found: {EMBODICHAIN_ROOT}") + demo = build_demo() + demo.queue(default_concurrency_limit=DEFAULT_CONCURRENCY_LIMIT) + _install_shutdown_handlers() + try: + demo.launch( + server_name=SERVER_NAME, + server_port=SERVER_PORT, + allowed_paths=[ + str(EMBODICHAIN_ROOT), + str(ASSETS_DIR), + str(DEBUG_ENGINE_ROOT), + ], + ) + finally: + _stop_child_processes() + + +if __name__ == "__main__": + main() diff --git a/embodichain/gen_sim/gradio_ui/gradio_visualization_architecture.md b/embodichain/gen_sim/gradio_ui/gradio_visualization_architecture.md new file mode 100644 index 000000000..f8cc2fe80 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/gradio_visualization_architecture.md @@ -0,0 +1,285 @@ +# Gradio 可视化系统架构 + +本文档以当前代码为准,描述 Gradio Demo、Debug 下的三个引擎,以及它们与 EmbodiChain、SimReady、Articraft 和 DexSim 的边界。`gradio_app.py` 只负责启动;界面、资产工作流、场景工作流和进程管理分散在专用模块中。 + +## 架构总览 + +```text +gradio_app.py + │ 启动、队列、allowed_paths + ▼ +app_services.py(兼容门面) + ▼ +app_ui.py ───────────► app_asset_engine.py ───► SimReady CLI + │ 布局、模式和事件绑定 │ │ + │ │ └──────────► app_articraft.py ───► Articraft CLI + Codex CLI + ▼ +app_workflows.py ────► EmbodiChain Scene Engine CLI + Viser + │ └► prompt2scene / action-agent pipeline + DexSim + ├──────────────► app_commands.py 命令构造 + ├──────────────► app_processes.py 子进程、环境、日志和阶段检测 + ├──────────────► app_state.py 共享 RuntimeState、锁和计时 + ├──────────────► app_media.py 视频、数据集预览和日志归档 + └──────────────► app_config.py UI 常量、路径推导和命令定义 + └──────────────► app_env.py 部署配置读取 + └──────────► ../.env 部署路径、端口和服务凭据 +``` + +| 模块 | 职责 | +| --- | --- | +| `gradio_app.py` | 唯一启动入口;校验 `EMBODICHAIN_ROOT`,创建 Blocks,设置队列和本地文件访问路径。 | +| `app_ui.py` | Demo/Debug 布局、引擎面板切换和回调绑定;不实现 pipeline。 | +| `app_asset_engine.py` | SimReady 上传适配、输入/输出 GLB 预览、处理日志,以及 Asset engine 的 Articraft 标签页。 | +| `app_articraft.py` | Articraft checkout/环境检查、外部记录创建、Codex 生成与校验、URDF bundle 和 Viser 关节预览。 | +| `app_workflows.py` | Demo 的 prompt2scene/action-agent 工作流、独立 Scene Engine 工作流、GLB 预览、场景提升和 DexSim。 | +| `app_processes.py` | 子进程环境、进程组终止、stdout 读取、Demo pipeline 阶段检测。 | +| `app_state.py` | `RuntimeState`、互斥锁、进度阶段、运行 token 和耗时统计。 | +| `app_commands.py` | prompt2scene、动作配置和 `run_agent` 的参数构造。 | +| `app_media.py` | 观众视频、LeRobot 数据预览、组合视频和运行日志归档。 | +| `app_config.py` | UI 文案、引擎模式、路径推导和 CLI 固定参数。 | +| `app_env.py` | 从 `.env` 读取 Gradio、Articraft 和 SimReady 的部署值,并保留未配置时的默认值。 | +| `../.env` | Gradio 与 Scene Engine 共用的路径、端口、LLM 和服务端点配置;不提交凭据。 | + +## 启动、路径和网络环境 + +从本项目目录启动: + +```bash +conda run -n embodichain python gradio_app.py +``` + +| 变量 | 默认值 | 用途 | +| --- | --- | --- | +| EmbodiChain root | 自动从 `embodichain/gen_sim/env.py` 的源码位置推导 | EmbodiChain 根目录;不再从 `.env` 配置。 | +| `GRADIO_SERVER_NAME` | `0.0.0.0` | Gradio 监听地址。 | +| `GRADIO_SERVER_PORT` | `7860` | Gradio 监听端口。 | +| `SCENE_ENGINE_VISER_PORT` | `8080` | 独立 Scene Engine 的 Viser 端口。 | +| `ARTICRAFT_VISER_PORT` | `8081` | Articraft 关节预览的 Viser 端口。 | +| `ARTICRAFT_ROOT` | `<项目>/.articraft` | Articraft checkout。 | +| `ARTICRAFT_CONDA_ENV` | `articraft` | 运行 Articraft CLI 的 Conda 环境。 | +| `ARTICRAFT_OUTPUT_ROOT` | `<项目>/.debug_engine/articraft` | Articraft 记录、运行日志和导出 bundle。 | + +`demo.launch()` 仅开放 EmbodiChain 根目录、`assets/` 和 `.debug_engine/` 给浏览器读取。pipeline 子进程由 `build_pipeline_env()` 创建环境:它清除代理变量、设置 `NO_PROXY=no_proxy=*`、关闭 Gradio analytics,并把非空的 SimReady 配置映射为 `OPENAI_*`。这不会改写启动 Gradio 的父进程环境。 + +## 页面与引擎 + +顶部的 `Demo` / `Debug` 只切换可见面板,不会启动任务;切换后共享运行状态保留。Debug 有三个按钮:`Asset_engine`、`Scene_engine`、`Action_engine`。它们的实际输入和产物并不完全相同: + +| Engine | 输入 | 预览/下载 | 实际产物 | 是否启动 DexSim | +| --- | --- | --- | --- | --- | +| Asset engine / SimReady | 一个网格、可选材质附件、类别 | 输入 GLB、SimReady GLB、原始输出下载 | `.debug_engine/assets/runs//` | 否 | +| Asset engine / Articulation | 文字、可选参考图 | URDF articulation 的 Viser、zip 下载 | `.debug_engine/articraft/` | 否 | +| Scene engine | 一张图片 | Scene Engine 的 Viser | `.debug_engine/scenes//` | 否 | +| Action engine | `current` Gym 场景、任务、机器人 | `current` 的 GLB 和 DexSim 视频 | EmbodiChain `gym_project/current` 与 `outputs/` | 是 | + +因此,Debug 的 Scene engine 是独立的图像条件场景生成器;它不会提升、复制或转换输出到 `gym_project/current`。Action engine 只消费 Demo/prompt2scene 工作流已经生成的 `current` Gym 场景。界面中的 “Scene engine” 文案表达的是所需场景类型,并不意味着独立 Scene Engine 输出已自动连到 Action engine。 + +## Demo:端到端 Gym 场景和 DexSim + +Demo 提供 `Auto`、`Interact`、`Parallel Simulation` 三种运行状态,以及图像、任务、场景描述、生成模式、机器人、随机输入、视频和 GLB 预览。它们与顶部的 Demo/Debug 模式无关。 + +`run_generate()` 是 Demo 的主入口。初始生成会在 staging 场景中运行 prompt2scene/action-agent pipeline,成功后才 promote 为固定的 `current`;随后默认启动 DexSim。编辑和仅改任务复用已有 `current`: + +```text +Initial generation + image + task + → _gradio_pending_ + → run_agent_pipeline --skip-run-agent + → fast_gym_config / agent_config / GLB previews + → promote 到 current + → run_agent(DexSim) + +Edit current scene + current + task + scene description + → 编辑 pipeline + → current + → run_agent(DexSim) + +Change task only + current + task + → generate_action_agent_config + → current + → run_agent(DexSim) +``` + +场景生成期间,工作流会从 `fast_gym_config.json` 构建场景 GLB,并将生成的对象 GLB 合并为对象预览。`launch_simulation=False` 是可用的工作流参数,但当前 Debug Scene panel 不调用这条 Demo 工作流;它调用独立的 `run_scene_engine()`。 + +正式场景固定在: + +```text +gym_project/current/ +gym_project/current/gym_export/ +gym_project/action_agent_pipeline/images/current.png +gym_project/action_agent_pipeline/configs/current/ + fast_gym_config.json + agent_config.json + gradio_scene/ + scene_current.glb + initial_scene.glb + object_preview.glb +``` + +初始生成使用 `_gradio_pending_` 路径。提升失败或 pipeline 失败时,已有 `current` 保持不变;成功提升后会重写 staging 中的路径引用。`Reset` 会清理当前场景和 staging 产物;`Stop` 通过进程组终止正在运行的 pipeline 或 DexSim。 + +## Asset engine + +### SimReady:单资产目录适配 + +SimReady CLI 接收目录,而 Gradio 接收上传文件。上传文件会复制到隔离目录,文件名只保留 basename,重名追加序号,避免上传路径或重名影响处理: + +```text +mesh + sidecar files + → .debug_engine/assets/runs//input/ + → trimesh 导出 input_preview.glb + → SimReady CLI + → output/**/asset_simready.glb(优先)或 asset_simready.obj + → GLB 预览 + 原始文件下载 +``` + +主网格支持 `.glb`、`.gltf`、`.obj`、`.ply`、`.stl`;可一并上传 `.mtl`、纹理和 `.bin` 等附件。执行命令为: + +```bash +python -m embodichain.gen_sim.simready_pipeline.cli.start \ + --input_dir \ + --output_root \ + --category +``` + +处理函数以 generator 持续返回最近的 stdout;完成时优先预览 `asset_simready.glb`,只有 OBJ 时再转为 GLB。此路径不依赖 DexSim。 + +`Reset SimReady` 会清空上传、类别、预览、下载项和日志,并按进程组终止正在运行的 SimReady CLI 及其子进程。 + +### Articulation:Articraft + Codex + +Articulation 标签页根据文本和可选参考图生成一个可下载的 articulated asset。先点击环境检查:若 `ARTICRAFT_ROOT` 不存在,应用会 clone `ARTICRAFT_REPOSITORY_URL`;随后检查 Conda、指定的 Articraft 环境和 Codex CLI。该操作会创建 checkout 和 `.debug_engine/articraft/` 中的输出目录,现有的非 Articraft 目录不会被覆盖。 + +生成流程: + +```text +description + optional image + → Articraft external init(创建 rec_ui_articraft_* 记录) + → 启动 Codex CLI,仅授权编辑该记录的 active model.py + → Articraft external check + └─ 旧版 CLI 无 check 时:compile --validate --strict-geom-qc + compile_report + → Articraft external finalize + → materialized model.urdf + meshes + → exports/.zip + Viser articulation preview +``` + +产物、记录和参考图均在 `ARTICRAFT_OUTPUT_ROOT` 下,不能直接当作 Demo 的 Gym 场景或 SimReady 资产;若要进入后续仿真,需要另行定义并实现转换/导入流程。Articraft Viser 每次成功预览会终止旧的 Articraft 预览进程,再以 `0.0.0.0:` 启动新进程。 + +`Reset Articulation` 会清空描述、参考图、记录与下载结果,终止当前 Articraft/Codex 命令进程组,并关闭该面板启动的 Viser。 + +## 独立 Scene engine 和 Viser + +Scene engine 只接收图像。上传图像会先进行 EXIF 归正并转为 RGB PNG,以 PNG 字节的 SHA-256 前 16 位作为目录名;相同图像会复用同一目录: + +```text +image + → .debug_engine/scenes//input.png + → python -m embodichain scene-engine + --image + --output_root + → /scene_export/scene_config.json + → preview.py --viser --viser-host 0.0.0.0 --viser-port 8080 + → Gradio iframe +``` + +当 `scene_export/scene_config.json` 存在且生成进程返回成功时,应用才启动 Viser。iframe 使用 Gradio 页面当前的协议和主机名转向 Viser 端口,因此从其他设备访问时,浏览器必须能访问该端口。每次新 Scene Engine 任务开始前会终止旧的 Scene Viser 进程。输出目录会显示在 UI 中,便于检查 hash 命名的场景导出。 + +`Reset Scene Engine` 会清空图像、进度、输出目录和 iframe,并终止当前生成命令与 Scene Viser 的进程组;运行 token 会使已经失效的生成器停止回写界面。 + +## Action engine:Gym 场景契约 + +Action engine 不接收裸 GLB。普通 GLB 只有渲染数据,而 DexSim 还需要碰撞、物理参数、初始位姿、资源相对路径和 action 配置。当前实现的前置条件是: + +```text +gym_project/current/gym_export/ +gym_project/action_agent_pipeline/configs/current/fast_gym_config.json +gym_project/action_agent_pipeline/configs/current/agent_config.json +``` + +点击 `Load current scene` 只读取共享状态快照。点击 `Run DexSim` 会先检查任务、`current` 的 Gym/action 配置、运行占用和可导入的 `embodichain.gen_sim.action_agent_pipeline.cli.run_agent`,再以当前配置调用 `run_agent`。它不会因为新的任务文本重建动作图;任务改变时应在 Demo 里使用 `Change task only`,或者实现显式的配置再生成步骤。 + +运行命令的核心参数为: + +```bash +python -m embodichain.gen_sim.action_agent_pipeline.cli.run_agent \ + --task_name current \ + --gym_config <.../fast_gym_config.json> \ + --agent_config <.../agent_config.json> \ + --regenerate --renderer fast-rt --num_envs <1|9> +``` + +并行模式额外传入 arena 和数据保存过滤参数。`--robot-profile` 仅在通过 `run_agent --help` 探测到该参数时加入。DexSim 完成后会寻找 audience 视频和 LeRobot 数据集;单环境可组合两种预览视频。 + +## 共享状态、并发和进度 + +Demo、独立 Scene engine 和 Action engine 共享 `RuntimeState` 与 `runtime_lock`,其中包含运行 token、pipeline/DexSim/Scene Viser 进程、输入、预览、日志、阶段和计时。运行 token 用于丢弃过期线程的更新。Articraft Viser 使用单独的锁和进程引用;SimReady 使用自己的同步 generator。 + +`demo.queue(default_concurrency_limit=1)` 将队列中的高成本回调串行化。Demo 的 `Timer(2.0)` 与 Action engine 的独立 `Timer(2.0)` 都读取同一共享状态。Scene Engine 和 Demo pipeline 因共享 `is_busy` 互斥;Asset/Articraft 面板不写入这一状态,但仍会受 Gradio 队列限制。 + +共享阶段如下;独立 Scene Engine 将其日志映射到相同的进度条: + +```text +idle → received → started → scene_intake → relations +→ asset_generation → gym_export → config → preview → complete + └──────────────→ failed +``` + +## 环境前置条件与验证 + +SimReady 需要 Blender、trimesh、LLM 配置以及可导入的: + +```text +embodichain.gen_sim.simready_pipeline.cli.start +``` + +SimReady 的 OpenAI-compatible 设置来自环境变量,且不应写入 Git: + +```bash +export SIMREADY_OPENAI_API_KEY='' +export SIMREADY_OPENAI_MODEL='' +export SIMREADY_OPENAI_BASE_URL='' +``` + +Demo/Action 需要 action-agent 模块,特别是: + +```text +embodichain.gen_sim.action_agent_pipeline.cli.run_agent_pipeline +embodichain.gen_sim.action_agent_pipeline.cli.generate_action_agent_config +embodichain.gen_sim.action_agent_pipeline.cli.run_agent +``` + +独立 Scene engine 还需要: + +```text +python -m embodichain scene-engine +embodichain/gen_sim/scene_engine/cli/preview.py +.env +``` + +Articulation 还需要 Git(首次 clone)、Conda、`ARTICRAFT_CONDA_ENV` 和 Codex CLI。生成请求会交给本机 Codex CLI 执行,因此只应提交可信请求。 + +每次修改后至少执行: + +```bash +python -m py_compile \ + gradio_app.py app_config.py app_env.py app_state.py app_commands.py \ + app_processes.py app_media.py app_workflows.py app_ui.py \ + app_asset_engine.py app_articraft.py app_services.py + +env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \ + -u http_proxy -u https_proxy -u all_proxy \ + conda run -n embodichain python -c \ + "from app_ui import build_demo; assert build_demo() is not None" +``` + +手动检查: + +1. SimReady 上传简单网格后能显示输入预览;执行后显示 SimReady 输出或明确错误。 +2. Articulation 环境检查能报告 checkout、Conda 和 Codex 状态;成功生成后有 zip、记录目录和 Viser 或明确的预览错误。 +3. Scene engine 从图像生成 `scene_export/scene_config.json`,并在 `8080` 显示 Viser;它不应改写 `gym_project/current`。 +4. Demo 初始生成成功后才替换 `current`;失败时旧场景仍可用。 +5. Action engine 在没有 `current` Gym/action 配置或缺少 CLI 时给出预检错误;任务更新后通过 Demo 的 `Change task only` 重建配置。 +6. Demo 的 Auto/Interact/Parallel Simulation 行为不因 Debug 面板切换而改变;Reset/Stop 能终止其对应的进程组。 diff --git a/embodichain/gen_sim/gradio_ui/random_input.py b/embodichain/gen_sim/gradio_ui/random_input.py new file mode 100644 index 000000000..9ce553992 --- /dev/null +++ b/embodichain/gen_sim/gradio_ui/random_input.py @@ -0,0 +1,542 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import base64 +import json +import os +import uuid +from dataclasses import asdict, dataclass, replace +from pathlib import Path + +import numpy as np + +from embodichain.gen_sim.env import get_embodichain_root, load_gen_sim_env + +load_gen_sim_env() + +EMBODICHAIN_ROOT = get_embodichain_root() +APP_ROOT = Path(__file__).resolve().parent +IMAGE_DIR = Path( + os.environ.get( + "AUTO_IMAGE_DIR", + str(EMBODICHAIN_ROOT / "gym_project/action_agent_pipeline/auto_images"), + ) +).expanduser() +AUTO_IMAGE_DIR_IS_CONFIGURED = "AUTO_IMAGE_DIR" in os.environ +FALLBACK_IMAGE_DIR = ( + EMBODICHAIN_ROOT / "gym_project/action_agent_pipeline/baseline_image_input" +) +PREBUILT_SCENE_DIR = Path( + os.environ.get("AUTO_PREBUILT_SCENE_DIR", str(APP_ROOT / "scenes")) +).expanduser() +GENERATED_IMAGE_DIR = Path( + os.environ.get("AUTO_GENERATED_IMAGE_DIR", "./tmp_img/auto") +).expanduser() +IMAGE_API_KEY = os.environ.get("AUTO_IMAGE_API_KEY", os.environ.get("ARK_API_KEY", "")) +IMAGE_API_URL = os.environ.get( + "AUTO_IMAGE_API_URL", + "https://ark.cn-beijing.volces.com/api/v3", +) +IMAGE_MODEL = os.environ.get("AUTO_IMAGE_MODEL", "doubao-seedream-4-5-251128") +IMAGE_SIZE = os.environ.get("AUTO_IMAGE_SIZE", "2848x1600") +IMAGE_PROMPT = ( + "以原图为基础参考,严格保持原图的相机视角、拍摄距离、透视关系、画面构图、背景环境、桌面材质、桌面纹理、光影方向、阴影位置和整体明暗关系。" + "原图中已有物体的类别、大小、形状、轮廓、空间位置、朝向、部件数量和结构比例必须保持不变。" + "如果提供了 Scene description,只根据该描述在桌面上新增对应背景物体;新增物体必须放在描述指定的位置,尺寸、透视、遮挡和阴影要与原图自然一致。" + "不要删除原有物体,不要移动原有物体,不要改变主任务物体的结构。高清细节,真实自然。" +) + +TASK_DESCRIPTIONS: dict[tuple[int, int], str] = { + (0, 0): "用双臂把两侧的罐头和瓶子放到篮子里", + (0, 1): "用双臂把两侧的方块放到篮子里", + (0, 2): "用双臂把两侧的方块和纸杯放到篮子里", + (0, 3): "用双臂把两侧的方块和苹果放到篮子里", + (1, 0): "用双臂把塑料水盆往前移动", + (1, 1): "用双臂把木棍往前移动", + (1, 2): "用双臂往把苹果和魔方放入盘子,然后用双臂端起盘子", + (1, 3): "用双臂把托盘往前移动", + (2, 0): "用双臂把两侧的香蕉放到盘子里,然后用双臂端起盘子", + (2, 1): "用双臂把两侧的罐头扶正", + (2, 2): "用双臂把两侧的瓶子和罐头扶正", + (2, 3): "用双臂把两侧的罐头扶正", + (3, 0): "把桌面上的物体按照方块按照从右往左的顺序叠起来", + (3, 1): "把桌面上的物体按照右边的方块,左边的方块,纸杯的顺序叠起来", + (3, 2): "把纸杯叠放到爆米花桶上,把蓝色耳机叠放到爆米花桶上", + (3, 3): "把纸杯叠放到爆米花桶上,把固体胶叠放到爆米花桶上", + (4, 0): "把桌面上的方块摆成一排", + (4, 1): "把桌面上的物体按照瓶子,方块排成一排", + (4, 2): "把桌面上的罐头摆成一排", + (4, 3): "把桌面上的物体按照瓶子,罐头,方块的顺序摆成一排", +} + +TASK_DESCRIPTIONS_EN: dict[tuple[int, int], str] = { + ( + 0, + 0, + ): "Use both arms to place the cans and bottles on both sides into the basket.", + (0, 1): "Use both arms to place the blocks on both sides into the basket.", + ( + 0, + 2, + ): "Use both arms to place the blocks and paper cups on both sides into the basket.", + ( + 0, + 3, + ): "Use both arms to place the blocks and apples on both sides into the basket.", + (1, 0): "Use both arms to move the plastic basin forward.", + (1, 1): "Use both arms to move the wooden stick forward.", + ( + 1, + 2, + ): "Use both arms to place the apple and Rubik's Cube onto the plate, then use both arms to lift the plate.", + (1, 3): "Use both arms to move the tray forward.", + ( + 2, + 0, + ): "Use both arms to place the bananas on both sides onto the tray, then use both arms to lift the tray.", + (2, 1): "Use both arms to set the cans on both sides upright.", + (2, 2): "Use both arms to set the bottles and cans on both sides upright.", + (2, 3): "Use both arms to set the cans on both sides upright.", + (3, 0): "Stack the blocks on the table in order from right to left.", + ( + 3, + 1, + ): "Stack the objects on the table in this order: right block, left block, paper cup.", + ( + 3, + 2, + ): "Stack the paper cup on the popcorn bucket, then stack the blue headphones on the popcorn bucket.", + ( + 3, + 3, + ): "Stack the paper cup on the popcorn bucket, then stack the glue stick on the popcorn bucket.", + (4, 0): "Arrange the blocks on the table in a row.", + (4, 1): "Arrange the objects on the table in a row in this order: bottle, block.", + (4, 2): "Arrange the cans on the table in a row.", + ( + 4, + 3, + ): "Arrange the objects on the table in a row in this order: bottle, can, block.", +} + +RELATION_PATTERN = { + (0, 0): ["at the left side of the can", "at the right side of the bottle"], + (0, 1): [ + "at the left side of the left cheese cube", + "at the right side of the right cheese cube", + ], + (0, 2): ["at the left side of the cube", "at the right side of the cup"], + (0, 3): ["at the left side of the cube", "at the right side of the apple"], + (1, 0): [], + (1, 1): [], + (1, 2): [], + (1, 3): [], + (2, 0): [ + "at the left side of the left bottle", + "at the right side of the right bottle", + ], + (2, 1): [ + "at the left side of the left soda can", + "at the right side of the right soda can", + ], + (2, 2): ["at the left side of the bottle", "at the right side of the can"], + (2, 3): ["at the left side of the paper cup", "at the right side of the soda can"], + (3, 0): [], + (3, 1): [], + (3, 2): [], + (3, 3): [], + (4, 0): [], + (4, 1): [], + (4, 2): [], + (4, 3): [], +} + +AREA_PATTERN = [ + "at the left side of the table", + "at the right side of the table", + "at the front of the table", + "at the front right corner of the table", + "at the front left corner of the table", +] + +OBJECT_LIST = [ + "cup", + "potted plant", + "clock", + "book", + "pen", + "bottle", + "soda can", + "photo frame", + "apple", + "peach", + "bread", + "chocolate bar", + "cookie", + "penholder", + "desk lamp", + "stapler", + "headphones", + "desk calendar", + "eyeglasses", + "fan", + "bluetooth speaker", + "table mirror", + "computer mouse", + "keyboard", +] + +CHINESE_OBJECT_NAMES = { + "cup": "杯子", + "potted plant": "盆栽", + "clock": "时钟", + "book": "书", + "bottle": "瓶子", + "soda can": "易拉罐", + "photo frame": "相框", + "apple": "苹果", + "peach": "桃子", + "bread": "小面包", + "chocolate bar": "巧克力棒", + "cookie": "饼干", + "penholder": "笔筒", + "desk lamp": "小台灯", + "stapler": "订书机", + "headphones": "耳机", + "small desk calendar": "小台历", + "eyeglasses": "眼镜", + "fan": "小风扇", + "bluetooth speaker": "蓝牙音箱", + "computer mouse": "鼠标", +} + + +CHINESE_SPATIAL_RELATIONS = { + "at the left side of the can": "罐头左侧", + "at the right side of the bottle": "瓶子右侧", + "at the left side of the left cheese cube": "左侧奶酪方块左侧", + "at the right side of the right cheese cube": "右侧奶酪方块右侧", + "at the left side of the cube": "方块左侧", + "at the right side of the cup": "杯子右侧", + "at the right side of the apple": "苹果右侧", + "at the left side of the left bottle": "左侧瓶子左侧", + "at the right side of the right bottle": "右侧瓶子右侧", + "at the left side of the left soda can": "左侧易拉罐左侧", + "at the right side of the right soda can": "右侧易拉罐右侧", + "at the left side of the bottle": "瓶子左侧", + "at the right side of the can": "罐头右侧", + "at the left side of the paper cup": "纸杯左侧", + "at the right side of the soda can": "易拉罐右侧", + "at the left side of the table": "桌子左侧", + "at the right side of the table": "桌子右侧", + "at the front of the table": "桌子前侧", + "at the front right corner of the table": "桌子右前角", + "at the front left corner of the table": "桌子左前角", + "on the table": "桌面上", +} + + +@dataclass(frozen=True) +class AutoInput: + task_index: tuple[int, int] + base_image_path: Path + prebuilt_scene_dir: Path | None + image_path: Path | None + task_description: str + scene_description: str + + def to_json_dict(self) -> dict[str, object]: + value = asdict(self) + value["task_index"] = list(self.task_index) + value["base_image_path"] = self.base_image_path.as_posix() + value["prebuilt_scene_dir"] = ( + self.prebuilt_scene_dir.as_posix() if self.prebuilt_scene_dir else None + ) + value["image_path"] = self.image_path.as_posix() if self.image_path else None + return value + + +def task_id(task_index: tuple[int, int]) -> str: + return f"task{task_index[0]}_{task_index[1]}" + + +def parse_task_id(value: str) -> tuple[int, int] | None: + stem = Path(value).stem + if not stem.startswith("task"): + return None + parts = stem[4:].split("_", maxsplit=1) + if len(parts) != 2: + return None + try: + return int(parts[0]), int(parts[1]) + except ValueError: + return None + + +def auto_image_directories() -> tuple[Path, ...]: + """Return image sources in precedence order for the Auto loop. + + A user-supplied ``AUTO_IMAGE_DIR`` is authoritative. With the default + directory, retain compatibility with deployments that have the checked-in + ``baseline_image_input`` set but have not created ``auto_images`` yet. + """ + directories = [IMAGE_DIR] + if not AUTO_IMAGE_DIR_IS_CONFIGURED and FALLBACK_IMAGE_DIR != IMAGE_DIR: + directories.append(FALLBACK_IMAGE_DIR) + return tuple(directories) + + +def available_auto_task_indices() -> tuple[tuple[int, int], ...]: + """Return only task variants whose input image and clean scene can be resolved.""" + return tuple( + task_index + for task_index in TASK_DESCRIPTIONS + if any( + (image_dir / f"{task_id(task_index)}.png").is_file() + for image_dir in auto_image_directories() + ) + and get_prebuilt_scene_dir(task_index).is_dir() + ) + + +def random_task(rng: np.random.Generator) -> tuple[int, int]: + available_tasks = available_auto_task_indices() + if not available_tasks: + expected = ", ".join(str(path) for path in auto_image_directories()) + raise FileNotFoundError( + "No Auto input images were found. Add task_.png " + f"files to: {expected}" + ) + return available_tasks[int(rng.integers(0, len(available_tasks)))] + + +def get_base_image_path(task_index: tuple[int, int]) -> Path: + filename = f"{task_id(task_index)}.png" + for image_dir in auto_image_directories(): + candidate = image_dir / filename + if candidate.is_file(): + return candidate + return IMAGE_DIR / filename + + +def get_prebuilt_scene_dir(task_index: tuple[int, int]) -> Path: + return PREBUILT_SCENE_DIR / task_id(task_index) + + +def get_task_description(task_index: tuple[int, int], *, language: str = "zh") -> str: + descriptions = TASK_DESCRIPTIONS_EN if language == "en" else TASK_DESCRIPTIONS + try: + return descriptions[task_index] + except KeyError as exc: + raise KeyError(f"No task description configured for task{task_index}") from exc + + +def image_to_base64(path: Path) -> str: + ext = path.suffix.lower() + if ext in (".jpg", ".jpeg"): + mime = "image/jpeg" + elif ext == ".png": + mime = "image/png" + else: + raise ValueError(f"Not supported: {ext}, only jpg/jpeg/png are supported") + with path.open("rb") as file: + b64_str = base64.b64encode(file.read()).decode("utf-8") + return f"data:{mime};base64,{b64_str}" + + +def build_image_prompt(scene_description: str = "") -> str: + scene_description = (scene_description or "").strip() + if not scene_description: + return IMAGE_PROMPT + return ( + f"{IMAGE_PROMPT}\n\n" + "Scene description:\n" + f"{scene_description}\n\n" + "严格执行 Scene description 中的新增物体和空间位置要求。" + ) + + +def create_image_input( + task_index: tuple[int, int], + *, + scene_description: str = "", + output_dir: Path = GENERATED_IMAGE_DIR, +) -> Path: + base_image_path = get_base_image_path(task_index) + if not base_image_path.is_file(): + raise FileNotFoundError(f"Base auto image not found: {base_image_path}") + + from volcenginesdkarkruntime import Ark + import requests + + image_base64 = image_to_base64(base_image_path) + client = Ark(api_key=IMAGE_API_KEY, base_url=IMAGE_API_URL) + response = client.images.generate( + model=IMAGE_MODEL, + prompt=build_image_prompt(scene_description), + image=image_base64, + size=IMAGE_SIZE, + response_format="url", + watermark=False, + ) + image_url = response.data[0].url + resp = requests.get(image_url, timeout=60) + resp.raise_for_status() + output_dir.mkdir(parents=True, exist_ok=True) + output_path = ( + output_dir + / f"auto_task{task_index[0]}_{task_index[1]}_{uuid.uuid4().hex[:12]}.png" + ) + output_path.write_bytes(resp.content) + return output_path + + +def create_text_input( + task_index: tuple[int, int], + rng: np.random.Generator, + *, + language: str = "en", + min_background_objects: int = 0, +) -> str: + text_parts: list[str] = [] + if task_index[0] == 5 and min_background_objects == 0: + return "" + + mu, sigma = 1.0, 1.0 + raw = rng.normal(loc=mu, scale=sigma) + num_background_objects = int(np.clip(np.round(raw), 0, 3)) + if min_background_objects > 0: + num_background_objects = max(num_background_objects, min_background_objects) + + if num_background_objects == 0: + return "" + + selected_objects = rng.choice( + OBJECT_LIST, + size=num_background_objects, + replace=False, + ).tolist() + + spatial_candidates = [] + spatial_candidates.extend(RELATION_PATTERN.get(task_index, [])) + spatial_candidates.extend(AREA_PATTERN) + spatial_candidates.append("on the table") + + for obj in selected_objects: + selected_spatial = rng.choice(spatial_candidates) + if language == "zh": + chinese_object = CHINESE_OBJECT_NAMES.get(obj, obj) + chinese_relation = CHINESE_SPATIAL_RELATIONS.get( + selected_spatial, + selected_spatial, + ) + text_parts.append(f"将一个{chinese_object}放在{chinese_relation}。") + else: + article = "an" if obj[0].lower() in {"a", "e", "i", "o", "u"} else "a" + text_parts.append(f"Place {article} {obj} {selected_spatial}.") + + return " ".join(text_parts) + + +def generate_auto_scene_description( + *, + rng: np.random.Generator | None = None, + task_index: tuple[int, int] | None = None, + language: str = "en", + ensure_scene: bool = False, +) -> str: + rng = rng or np.random.default_rng() + task_index = task_index or random_task(rng) + return create_text_input( + task_index, + rng, + language=language, + min_background_objects=1 if ensure_scene else 0, + ) + + +def generate_auto_text_input( + *, + rng: np.random.Generator | None = None, + task_index: tuple[int, int] | None = None, + language: str = "en", + ensure_scene: bool = False, + include_scene: bool = True, +) -> AutoInput: + rng = rng or np.random.default_rng() + task_index = task_index or random_task(rng) + base_image_path = get_base_image_path(task_index) + prebuilt_scene_dir = get_prebuilt_scene_dir(task_index) + if not base_image_path.is_file(): + raise FileNotFoundError(f"Base auto image not found: {base_image_path}") + if not prebuilt_scene_dir.is_dir(): + raise FileNotFoundError(f"Prebuilt scene not found: {prebuilt_scene_dir}") + return AutoInput( + task_index=task_index, + base_image_path=base_image_path, + prebuilt_scene_dir=prebuilt_scene_dir, + image_path=None, + task_description=get_task_description(task_index, language=language), + scene_description=( + generate_auto_scene_description( + rng=rng, + task_index=task_index, + language=language, + ensure_scene=ensure_scene, + ) + if include_scene + else "" + ), + ) + + +def generate_auto_image( + auto_input: AutoInput, + *, + output_dir: Path = GENERATED_IMAGE_DIR, +) -> AutoInput: + image_path = create_image_input( + auto_input.task_index, + scene_description=auto_input.scene_description, + output_dir=output_dir, + ) + return replace(auto_input, image_path=image_path) + + +def generate_auto_input( + *, + rng: np.random.Generator | None = None, + task_index: tuple[int, int] | None = None, + output_dir: Path = GENERATED_IMAGE_DIR, + language: str = "en", +) -> AutoInput: + auto_input = generate_auto_text_input( + rng=rng, + task_index=task_index, + language=language, + ) + return generate_auto_image(auto_input, output_dir=output_dir) + + +def main() -> None: + auto_input = generate_auto_input() + print(json.dumps(auto_input.to_json_dict(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/gen_sim/scene_engine/test_config.py b/tests/gen_sim/scene_engine/test_config.py new file mode 100644 index 000000000..01914e144 --- /dev/null +++ b/tests/gen_sim/scene_engine/test_config.py @@ -0,0 +1,100 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from embodichain.gen_sim.scene_engine.cli import start +from embodichain.gen_sim.scene_engine.configs import environment + + +def test_read_scene_engine_env_values_reads_requested_keys( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + env_path = tmp_path / ".env" + env_path.write_text('OPENAI_MODEL="test-model"\nUNRELATED_VALUE=ignored\n') + monkeypatch.setattr(environment, "_SCENE_ENGINE_ENV_PATH", env_path) + + assert environment.read_scene_engine_env_values("OPENAI_MODEL") == { + "OPENAI_MODEL": "test-model" + } + + +def test_read_scene_engine_env_values_reports_missing_keys( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + env_path = tmp_path / ".env" + env_path.write_text("OPENAI_MODEL=test-model\n") + monkeypatch.setattr(environment, "_SCENE_ENGINE_ENV_PATH", env_path) + + with pytest.raises(ValueError, match="OPENAI_API_KEY"): + environment.read_scene_engine_env_values("OPENAI_MODEL", "OPENAI_API_KEY") + + +def test_scene_engine_help_exposes_only_runtime_arguments( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as exc_info: + start.main(["--help"]) + + assert exc_info.value.code == 0 + output = capsys.readouterr().out + assert "--image" in output + assert "--output_root" in output + assert "gen_sim/.env" in output + assert "--config" not in output + + +def test_scene_engine_cli_forwards_validated_paths( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + image_path = tmp_path / "scene.png" + image_path.write_bytes(b"png") + captured: dict[str, Path] = {} + + def generate_scene(*, image_path: Path, output_root: Path) -> None: + captured["image_path"] = image_path + captured["output_root"] = output_root + + monkeypatch.setattr(start, "generate_scene_from_image", generate_scene) + output_root = tmp_path / "output" + + start.cli_scene_engine(image_path, output_root) + + assert captured == { + "image_path": image_path.resolve(), + "output_root": output_root.resolve(), + } + + +@pytest.mark.parametrize("image_name", ["missing.png", "scene.gif"]) +def test_scene_engine_cli_rejects_invalid_image_inputs( + tmp_path: Path, + image_name: str, +) -> None: + image_path = tmp_path / image_name + if image_path.suffix == ".gif": + image_path.write_bytes(b"gif") + + with pytest.raises((FileNotFoundError, ValueError)): + start.cli_scene_engine(image_path, tmp_path / "output")