From 7a3186e55fe0ede3c9200ef5e7d4e533ceeb1926 Mon Sep 17 00:00:00 2001 From: Charles Madjeri <80175305+charlesmadjeri@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:28:16 +0200 Subject: [PATCH] feat(lucy_ws): Jetson GPU detection with hardware-accelerated Docker GUI and Gazebo sim --- config/launcher_config.json | 2 +- docker/gpu_detect.sh | 156 ++++++++++++++++++++++++++++++++++++ docker/gui_desktop.sh | 11 +++ install.sh | 9 ++- launch_lucy.sh | 45 +++++++++-- launcher.py | 50 +++++++++++- 6 files changed, 262 insertions(+), 11 deletions(-) create mode 100755 docker/gpu_detect.sh diff --git a/config/launcher_config.json b/config/launcher_config.json index 7e584ef..e980aee 100644 --- a/config/launcher_config.json +++ b/config/launcher_config.json @@ -32,7 +32,7 @@ "conflicts": ["real"], "command": "gazebo:=true", "runs_on_vnc": true, - "readiness_check": "pgrep -f '[g]azebo' >/dev/null 2>&1", + "readiness_check": "pgrep -f '[g]z sim' >/dev/null 2>&1", "readiness_timeout": 120, "default_on": false }, diff --git a/docker/gpu_detect.sh b/docker/gpu_detect.sh new file mode 100755 index 0000000..a356c74 --- /dev/null +++ b/docker/gpu_detect.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Detect host GPU capabilities and populate Docker flags for Lucy launches. +# +# Source from launch_lucy.sh or install.sh (do not execute directly): +# source "$SCRIPT_DIR/docker/gpu_detect.sh" +# +# Sets: +# LUCY_GPU_MODE — jetson | nvidia | dri | software +# GPU_DOCKER_ARGS — bash array appended to docker run +# +# Override detection for testing: LUCY_GPU_MODE=software|jetson|nvidia|dri + +GPU_DOCKER_ARGS=() +LUCY_GPU_MODE=software + +_lucy_is_jetson() { + if [[ -f /etc/nv_tegra_release ]]; then + return 0 + fi + if [[ -r /proc/device-tree/model ]]; then + tr -d '\0' /dev/null | grep -qi jetson + return $? + fi + return 1 +} + +_lucy_docker_has_nvidia_runtime() { + docker info 2>/dev/null | grep -qiE 'nvidia|Runtimes.*nvidia' +} + +_lucy_append_dri_devices() { + local node + shopt -s nullglob + for node in /dev/dri/card* /dev/dri/renderD*; do + GPU_DOCKER_ARGS+=(--device "$node") + done + shopt -u nullglob +} + +_lucy_append_render_groups() { + # Docker resolves group names against the *image* /etc/group, not the host. + # Pass numeric GIDs from the host so render/video access works in the container. + local group gid + for group in render video; do + gid="$(getent group "$group" 2>/dev/null | awk -F: '{print $3}')" + [[ -n "$gid" ]] || continue + GPU_DOCKER_ARGS+=(--group-add "$gid") + done +} + +_lucy_apply_jetson_gpu() { + LUCY_GPU_MODE=jetson + if _lucy_docker_has_nvidia_runtime; then + GPU_DOCKER_ARGS+=(--runtime nvidia) + GPU_DOCKER_ARGS+=(-e "NVIDIA_VISIBLE_DEVICES=all") + GPU_DOCKER_ARGS+=(-e "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute,video") + GPU_DOCKER_ARGS+=(-e "__GLX_VENDOR_LIBRARY_NAME=nvidia") + GPU_DOCKER_ARGS+=(-e "__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json") + else + echo "GPU: jetson detected but Docker nvidia runtime missing; using software rendering." >&2 + echo " Install nvidia-container-toolkit for hardware GL in the container." >&2 + fi + if [[ -d /dev/dri ]]; then + _lucy_append_dri_devices + _lucy_append_render_groups + fi +} + +lucy_apply_gpu_detect() { + GPU_DOCKER_ARGS=() + LUCY_GPU_MODE=software + + case "$(echo "${LUCY_GPU_MODE_OVERRIDE:-}" | tr '[:upper:]' '[:lower:]')" in + jetson|nvidia|dri|software) + LUCY_GPU_MODE="${LUCY_GPU_MODE_OVERRIDE,,}" + case "$LUCY_GPU_MODE" in + jetson) _lucy_apply_jetson_gpu ;; + nvidia) + GPU_DOCKER_ARGS+=(--gpus all) + GPU_DOCKER_ARGS+=(-e "NVIDIA_VISIBLE_DEVICES=all") + GPU_DOCKER_ARGS+=(-e "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute,video") + GPU_DOCKER_ARGS+=(-e "__GLX_VENDOR_LIBRARY_NAME=nvidia") + GPU_DOCKER_ARGS+=(-e "__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json") + ;; + dri) + _lucy_append_dri_devices + _lucy_append_render_groups + ;; + esac + return 0 + ;; + esac + + if _lucy_is_jetson; then + _lucy_apply_jetson_gpu + return 0 + fi + + if command -v nvidia-smi >/dev/null 2>&1 && _lucy_docker_has_nvidia_runtime; then + if nvidia-smi >/dev/null 2>&1; then + LUCY_GPU_MODE=nvidia + GPU_DOCKER_ARGS+=(--gpus all) + GPU_DOCKER_ARGS+=(-e "NVIDIA_VISIBLE_DEVICES=all") + GPU_DOCKER_ARGS+=(-e "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute,video") + GPU_DOCKER_ARGS+=(-e "__GLX_VENDOR_LIBRARY_NAME=nvidia") + GPU_DOCKER_ARGS+=(-e "__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json") + return 0 + fi + fi + + if [[ -d /dev/dri ]] && compgen -G '/dev/dri/renderD*' >/dev/null 2>&1; then + LUCY_GPU_MODE=dri + _lucy_append_dri_devices + _lucy_append_render_groups + return 0 + fi + + LUCY_GPU_MODE=software +} + +lucy_gpu_launch_message() { + case "$LUCY_GPU_MODE" in + jetson) + if _lucy_docker_has_nvidia_runtime; then + echo "GPU: jetson (nvidia container runtime + /dev/dri when available)" + else + echo "GPU: jetson (software fallback — nvidia runtime not configured)" + fi + ;; + nvidia) echo "GPU: nvidia (hardware acceleration enabled)" ;; + dri) echo "GPU: dri (Mesa /dev/dri passthrough)" ;; + *) echo "GPU: software (VNC llvmpipe or headless rendering)" ;; + esac +} + +# Pick a host DISPLAY when unset so Jetson can use native X11 + GPU instead of VNC/llvmpipe. +lucy_resolve_host_display() { + [[ -n "${DISPLAY:-}" ]] && return 0 + + local sock n + shopt -s nullglob + for sock in /tmp/.X11-unix/X[0-9]*; do + n="${sock##*/X}" + DISPLAY=":${n}" + export DISPLAY + shopt -u nullglob + echo "GUI: auto-selected DISPLAY=$DISPLAY (local X11 socket)" >&2 + return 0 + done + shopt -u nullglob +} + +# When sourced, apply immediately unless caller sets LUCY_GPU_DETECT_DEFER=1. +if [[ "${BASH_SOURCE[0]}" != "${0}" ]] && [[ "${LUCY_GPU_DETECT_DEFER:-}" != 1 ]]; then + lucy_apply_gpu_detect +fi diff --git a/docker/gui_desktop.sh b/docker/gui_desktop.sh index 899146e..64c0903 100755 --- a/docker/gui_desktop.sh +++ b/docker/gui_desktop.sh @@ -30,6 +30,17 @@ VNC_PASSWORD="${LUCY_GUI_VNC_PASSWORD:-lucy}" VNC_PASSWD_FILE=/tmp/.lucy_vncpasswd export DISPLAY=":${DISPLAY_NUM}" +# When the container was started with Jetson/NVIDIA GPU passthrough, prefer the +# NVIDIA GLX vendor so Xvfb-backed RViz/Gazebo use the Tegra GPU instead of llvmpipe. +case "${LUCY_GPU_MODE:-}" in + jetson|nvidia) + export __GLX_VENDOR_LIBRARY_NAME=nvidia + export __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json + ;; +esac +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/runtime-root}" +mkdir -p "$XDG_RUNTIME_DIR" 2>/dev/null || true + log() { echo "[gui_desktop] $*"; } # Match by exact process name (comm), not full command line, so these checks can't # accidentally match the surrounding shell whose argv may mention these binaries. diff --git a/install.sh b/install.sh index 617026f..b615628 100755 --- a/install.sh +++ b/install.sh @@ -44,6 +44,8 @@ fi # shellcheck disable=SC1091 source "$SCRIPT_DIR/docker/ensure_image.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/docker/gpu_detect.sh" ensure_docker_image() { ensure_lucy_docker_image "$SCRIPT_DIR" "$IMAGE_NAME" "$DOCKERFILE_PATH" @@ -103,7 +105,8 @@ remove_workspace_src_repo() { local name="$1" rm -rf "src/${name}" 2>/dev/null || true docker_run_platform_flags "$SCRIPT_DIR" - docker run "${DOCKER_RUN_PLATFORM_ARGS[@]}" --rm \ + docker run "${DOCKER_RUN_PLATFORM_ARGS[@]}" "${GPU_DOCKER_ARGS[@]}" --rm \ + -e LUCY_GPU_MODE="$LUCY_GPU_MODE" \ -v "$SCRIPT_DIR:$WORKSPACE" \ "$IMAGE_NAME" -c "rm -rf ${WORKSPACE}/src/${name}" } @@ -150,6 +153,7 @@ docker_workspace_install() { ensure_docker_image docker_run_platform_flags "$SCRIPT_DIR" docker_run_it_flags + lucy_gpu_launch_message echo "Docker install: rosdep, colcon build, yarn install (lucy_control_panel) ..." local inner_cmd read -r -d '' inner_cmd <<'EOS' || true @@ -163,7 +167,8 @@ source /opt/ros/humble/setup.bash \ ( cd src/lucy_control_panel && yarn install ); \ fi EOS - docker run "${DOCKER_RUN_PLATFORM_ARGS[@]}" "${DOCKER_RUN_IT[@]}" --rm \ + docker run "${DOCKER_RUN_PLATFORM_ARGS[@]}" "${DOCKER_RUN_IT[@]}" "${GPU_DOCKER_ARGS[@]}" --rm \ + -e LUCY_GPU_MODE="$LUCY_GPU_MODE" \ -v "$SCRIPT_DIR:$WORKSPACE" \ "$IMAGE_NAME" -c "$inner_cmd" } diff --git a/launch_lucy.sh b/launch_lucy.sh index 2584acf..b962a64 100755 --- a/launch_lucy.sh +++ b/launch_lucy.sh @@ -15,6 +15,7 @@ # VITE_PORT from src/lucy_control_panel/.env, else 5000). Vite proxies /rosbridge to the bridge. # # Docker platform follows the last ./install.sh run (.lucy-docker-platform; override with LUCY_DOCKER_PLATFORM). +# GPU mode is auto-detected via docker/gpu_detect.sh (jetson / nvidia / dri / software). set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -61,6 +62,10 @@ WORKSPACE="/workspace" # shellcheck disable=SC1091 source "$SCRIPT_DIR/docker/ensure_image.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/docker/gpu_detect.sh" +lucy_resolve_host_display + ensure_docker_image() { ensure_lucy_docker_image "$SCRIPT_DIR" "$IMAGE_NAME" "$DOCKERFILE_PATH" } @@ -86,13 +91,22 @@ case "$(echo "${LUCY_FORCE_VNC:-}" | tr '[:upper:]' '[:lower:]')" in 1|true|yes) USE_VNC=1 ;; 0|false|no) USE_VNC=0 ;; esac + +# Prefer native X11 + GPU when hardware acceleration is available (Jetson / NVIDIA / DRI). +if [ "$USE_VNC" = 1 ] && [ "${LUCY_GPU_MODE:-software}" != "software" ] && [ -n "${DISPLAY:-}" ]; then + case "$(echo "${LUCY_FORCE_VNC:-}" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes) ;; + *) USE_VNC=0 ;; + esac +fi + if [ "${1:-}" = "--headless" ]; then shift echo "Headless: no X11 (Gazebo runs headless; RViz is disabled by the launch)." elif [ "$USE_VNC" = 1 ]; then - # VNC desktop: native X11/GLX is unavailable or unreliable for GL apps (RViz, - # Gazebo). Offer an opt-in virtual desktop via noVNC/VNC from the launcher. The - # display is not started automatically — enable it in the launcher. + # VNC desktop: software GL fallback when no hardware GPU path is available. + # With jetson/nvidia/dri, prefer native X11 above; if you still land here (no DISPLAY), + # RViz/Gazebo render via Xvfb + llvmpipe on CPU — see message below. GUI_VNC_PORT="$(resolve_host_port VNC "${LUCY_GUI_VNC_PORT:-5901}")" GUI_NOVNC_PORT="$(resolve_host_port noVNC "${LUCY_GUI_NOVNC_PORT:-6080}")" GUI_VNC_PASSWORD="${LUCY_GUI_VNC_PASSWORD:-lucy}" @@ -100,11 +114,18 @@ elif [ "$USE_VNC" = 1 ]; then -e LUCY_GUI_VNC_AVAILABLE=1 -e LUCY_GUI_VNC_PASSWORD="$GUI_VNC_PASSWORD" -e LUCY_ORIGINAL_DISPLAY="${DISPLAY:-}" - -e LIBGL_ALWAYS_SOFTWARE=1 - -e GALLIUM_DRIVER=llvmpipe -e LUCY_GUI_NOVNC_PUBLISHED_PORT="$GUI_NOVNC_PORT" -e LUCY_GUI_VNC_PUBLISHED_PORT="$GUI_VNC_PORT" + -e XDG_RUNTIME_DIR=/tmp/runtime-root ) + if [ "${LUCY_GPU_MODE:-software}" = "software" ]; then + X11_ARGS+=( + -e LIBGL_ALWAYS_SOFTWARE=1 + -e GALLIUM_DRIVER=llvmpipe + ) + else + echo "GUI: noVNC virtual desktop (Jetson/NVIDIA GL when __GLX_VENDOR_LIBRARY_NAME=nvidia is set)" >&2 + fi GUI_PORT_ARGS=( -p "${GUI_VNC_PORT}:5901" -p "${GUI_NOVNC_PORT}:6080" @@ -113,7 +134,7 @@ elif [ "$USE_VNC" = 1 ]; then echo " Browser (noVNC): http://localhost:${GUI_NOVNC_PORT}/vnc.html (no password)" echo " VNC Viewer: localhost:${GUI_VNC_PORT} (password: ${GUI_VNC_PASSWORD})" else - # AMD64: native X11 forwarding, no VNC. + # Native X11 forwarding (GPU path on Jetson / NVIDIA / DRI when DISPLAY is set). GUI_DISPLAY="${DOCKER_GUI_DISPLAY:-$DISPLAY}" if [ -n "$GUI_DISPLAY" ]; then if command -v xhost &>/dev/null; then @@ -124,7 +145,11 @@ else echo "GUI: DISPLAY=:0 (host network)." else X11_ARGS=(-e DISPLAY="$GUI_DISPLAY" -v /tmp/.X11-unix:/tmp/.X11-unix:rw) - echo "GUI: DISPLAY=$GUI_DISPLAY" + if [ "${LUCY_GPU_MODE:-software}" != "software" ]; then + echo "GUI: DISPLAY=$GUI_DISPLAY (hardware GPU — nvidia runtime / DRI)" + else + echo "GUI: DISPLAY=$GUI_DISPLAY" + fi fi else echo "GUI: DISPLAY not set; Gazebo will run headless (RViz disabled)." @@ -135,6 +160,8 @@ ensure_docker_image docker_run_platform_flags "$SCRIPT_DIR" docker_run_it_flags +lucy_gpu_launch_message + # ---------------------------------------------------------------------------- # Port mapping (control panel + rosbridge) # ---------------------------------------------------------------------------- @@ -251,8 +278,10 @@ if [ $# -eq 0 ]; then --name lucy_dev \ "${DOCKER_PORT_ARGS[@]}" \ "${GUI_PORT_ARGS[@]}" \ + "${GPU_DOCKER_ARGS[@]}" \ -v "$SCRIPT_DIR:$WORKSPACE" \ "${X11_ARGS[@]}" \ + -e LUCY_GPU_MODE="$LUCY_GPU_MODE" \ -e LUCY_LCP_PUBLISHED_HOST_PORT="$PORT_CONTROL_PANEL" \ -e LUCY_LCP_CONTAINER_PORT="$PORT_CONTROL_PANEL_CONTAINER" \ -e LUCY_LCP_SCHEME="$LCP_SCHEME" \ @@ -261,7 +290,9 @@ else docker run "${DOCKER_RUN_PLATFORM_ARGS[@]}" "${DOCKER_RUN_IT[@]}" --rm \ --name lucy_dev \ "${DOCKER_PORT_ARGS[@]}" \ + "${GPU_DOCKER_ARGS[@]}" \ -v "$SCRIPT_DIR:$WORKSPACE" \ "${X11_ARGS[@]}" \ + -e LUCY_GPU_MODE="$LUCY_GPU_MODE" \ "$IMAGE_NAME" -c "${SETUP} && ${SOURCE_WORKSPACE} && $*" fi diff --git a/launcher.py b/launcher.py index 442382d..5c97ec3 100644 --- a/launcher.py +++ b/launcher.py @@ -210,6 +210,50 @@ def _env_enabled(var_name): return True return os.environ.get(var_name, "").strip().lower() in ("1", "true", "yes") + +def _gui_desktop_available(): + return _env_enabled("LUCY_GUI_VNC_AVAILABLE") + + +def _selection_needs_gui_display(state): + """True when a ticked package renders to the in-container virtual desktop.""" + return any( + p.selected and getattr(p, "runs_on_vnc", False) + for p in state.packages + ) + + +def _ensure_gui_display(state): + """Start Xvfb (+ fluxbox) when RViz/Gazebo are selected on arm64/VNC hosts.""" + if not _gui_desktop_available() or not _selection_needs_gui_display(state): + return + run_shell_command("bash /workspace/docker/gui_desktop.sh display start") + + +def _core_launch_display_prefix(state): + """DISPLAY= prefix for core launch when GL apps use the virtual desktop.""" + if not _gui_desktop_available() or not _selection_needs_gui_display(state): + return "" + num = os.environ.get("LUCY_GUI_DISPLAY_NUM", "99") + return f"DISPLAY=:{num} " + + +def _sync_novnc_for_gui(state): + """Auto-tick noVNC when RViz/Gazebo need the in-container desktop (arm64/Jetson).""" + novnc = state.get_by_id("novnc") + if not novnc or not _gui_desktop_available(): + return + headless = state.get_by_id("headless") + if headless and headless.selected: + return + if any( + p.selected and p.display_switch and p.id != "novnc" + for p in state.packages + ): + return + if _selection_needs_gui_display(state): + novnc.selected = True + def _ros_pkg_installed(pkg_name): """True when a ROS package is built in the workspace overlay (install/). @@ -565,6 +609,8 @@ def apply_changes(state): # Second Pass: Start processes that should be turned on (never re-launch one that is still shutting down). # A crashed service (non-zero exit) is relaunched too, after reaping the dead window; a clean exit (STOPPED) is left alone. + _ensure_gui_display(state) + display_prefix = _core_launch_display_prefix(state) for pkg in state.packages: crashed = pkg.pane_dead and pkg.pane_exit_status != 0 if pkg.selected and pkg.id not in _pkg_stop_times and (not pkg.is_running or crashed): @@ -584,7 +630,7 @@ def apply_changes(state): selected_modifiers = [p for p in state.packages if p.type == 'modifier' and p.selected] modifier_args = [p.command for p in selected_modifiers] modifier_ids = [p.id for p in selected_modifiers] - full_cmd = f"{base_cmd} {' '.join(modifier_args)}" + full_cmd = f"{display_prefix}{base_cmd} {' '.join(modifier_args)}" # remain-on-exit keeps the window (and the crash output) open if # ros2 launch dies, so the failure is visible and detectable. run_shell_command(f"tmux new-window -d -t lucy_ws -n core '{full_cmd}'; tmux set-window-option -t lucy_ws:core remain-on-exit on") @@ -666,6 +712,7 @@ def main(stdscr): state = LauncherState(load_config()) restore_selection(state) default_robot_selection(state) + _sync_novnc_for_gui(state) current_idx = 0 error_msg = None status_msg = None @@ -681,6 +728,7 @@ def main(stdscr): if lcp_pkg: lcp_pkg.selected = True default_robot_selection(state) + _sync_novnc_for_gui(state) apply_changes(state) status_msg = "Starting default services for production mode..." state = LauncherState(load_config())