diff --git a/examples/tutorials/003_OpenVINO_Optimization.ipynb b/examples/tutorials/003_OpenVINO_Optimization.ipynb index 443fcd94..7749f3f6 100644 --- a/examples/tutorials/003_OpenVINO_Optimization.ipynb +++ b/examples/tutorials/003_OpenVINO_Optimization.ipynb @@ -30,10 +30,10 @@ "id": "39464140-04c5-4f8a-b386-6a7bb735836b", "metadata": {}, "source": [ - "## 1) Train a model using the Physical AI Studio or LeRobot\n", - "* Use Pi0.5 policy supported in Physical AI Studio or LeRobot to train a model.
\n", - "* The output of this stage should be a trained Pi0.5 model
\n", - "[Open Notebook](002_Using_Physical_AI_Studio.ipynb)" + "## 1) Train a model using Physical AI Studio or LeRobot\n", + "* Use the Pi0.5 policy supported in Physical AI Studio or LeRobot to train a model.
\n", + "* The output of this stage should be a trained Pi0.5 model.
\n", + "* For the Physical AI Studio workflow, follow the official [Physical AI Studio documentation](https://github.com/open-edge-platform/physical-ai-studio/blob/main/application/README.md)." ] }, { @@ -49,7 +49,9 @@ "id": "3c28a826-a284-4623-8a7b-74765529bdd6", "metadata": {}, "source": [ - "Clone the OpenVINO Physical AI repo and install." + "Install OpenVINO Physical AI from PyPI with a pinned released version. If this notebook is run outside a full repository checkout, the install cell also clones the tutorial sources into `_physicalai_tutorial_repo` so it can find `requirements.txt`.\n", + "\n", + "> **Why pin the version?** The OpenVINO Physical AI APIs are evolving quickly. This notebook depends on a tested `physicalai` release, so the install cell below pins the package version. Update `PHYSICALAI_VERSION` only after re-validating the rest of this notebook.\n" ] }, { @@ -60,16 +62,40 @@ "outputs": [], "source": [ "from pathlib import Path\n", + "import subprocess\n", + "import sys\n", + "\n", + "WORKSPACE = Path.cwd().resolve()\n", + "TUTORIALS_REF = \"docs/tutorials\"\n", + "TUTORIALS_REPO = \"https://github.com/openvinotoolkit/physicalai.git\"\n", + "TUTORIAL_REPO_DIR = WORKSPACE / \"_physicalai_tutorial_repo\"\n", + "\n", + "local_requirements = WORKSPACE / \"requirements.txt\"\n", + "fallback_requirements = WORKSPACE / \"notebooks\" / \"requirements.txt\"\n", "\n", - "requirements_file = Path(\"requirements.txt\")\n", - "if not requirements_file.exists():\n", - " requirements_file = Path(\"notebooks/requirements.txt\")\n", + "if local_requirements.exists():\n", + " requirements_file = local_requirements\n", + "elif fallback_requirements.exists():\n", + " requirements_file = fallback_requirements\n", + "else:\n", + " if not TUTORIAL_REPO_DIR.exists():\n", + " subprocess.check_call([\n", + " \"git\",\n", + " \"clone\",\n", + " \"--depth\",\n", + " \"1\",\n", + " \"--branch\",\n", + " TUTORIALS_REF,\n", + " TUTORIALS_REPO,\n", + " str(TUTORIAL_REPO_DIR),\n", + " ])\n", + " requirements_file = TUTORIAL_REPO_DIR / \"examples\" / \"tutorials\" / \"requirements.txt\"\n", "\n", - "if not Path(\"physicalai\").exists():\n", - " !git clone https://github.com/openvinotoolkit/physicalai.git\n", + "# Tested with this notebook. Pinning avoids breakage from fast-moving API changes.\n", + "PHYSICALAI_VERSION = \"0.1.1\"\n", "\n", "%pip install -q --extra-index-url https://download.pytorch.org/whl/cpu -r {requirements_file}\n", - "%pip install -q -e physicalai" + "%pip install -q physicalai=={PHYSICALAI_VERSION}\n" ] }, { @@ -88,19 +114,9 @@ "outputs": [], "source": [ "from pathlib import Path\n", - "import json\n", - "import os\n", - "import time\n", - "\n", - "import numpy as np\n", - "import openvino as ov\n", - "from huggingface_hub import hf_hub_download, snapshot_download\n", - "from physicalai.inference import InferenceModel\n", "\n", "WORKSPACE = Path.cwd().resolve()\n", - "RUNTIME_ROOT = WORKSPACE / \"physicalai\"\n", - "ROOT = RUNTIME_ROOT\n", - "os.chdir(ROOT)" + "print(\"Workspace:\", WORKSPACE)" ] }, { @@ -118,19 +134,19 @@ "metadata": {}, "outputs": [], "source": [ - "INSTALL_HARDWARE_DEPS = \"True\"\n", - "USE_SHARED_CAMERA = \"True\"\n", + "INSTALL_HARDWARE_DEPS = True\n", + "USE_SHARED_CAMERA = True\n", "\n", "if INSTALL_HARDWARE_DEPS:\n", " import subprocess\n", " import sys\n", "\n", " hardware_extras = \"transport,so101\" if USE_SHARED_CAMERA else \"so101\"\n", - " runtime_with_hardware_extras = str(WORKSPACE / \"physicalai\") + f\"[{hardware_extras}]\"\n", - " subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"-e\", runtime_with_hardware_extras])\n", - " print(f\"[DONE] Installed PhysicalAI hardware extras: {hardware_extras}\")\n", + " package_spec = f\"physicalai[{hardware_extras}]=={PHYSICALAI_VERSION}\"\n", + " subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", package_spec])\n", + " print(f\"[DONE] Installed PhysicalAI hardware extras: {package_spec}\")\n", "else:\n", - " print(\"[SKIP] Hardware extras are not installed. Set INSTALL_HARDWARE_DEPS = True if this environment needs them.\")\n" + " print(\"[SKIP] Hardware extras are not installed. Set INSTALL_HARDWARE_DEPS = True if this environment needs them.\")" ] }, { @@ -138,7 +154,56 @@ "id": "2694f43e-7753-4ce5-9124-7faf9bc32490", "metadata": {}, "source": [ - "## 3) Discover and connect to Robot and Cameras" + "## 3) Discover and connect to Robot and Cameras\n", + "\n", + "> **Before continuing on Linux:** make sure the user running Jupyter has permission to access USB cameras and the SO101 serial device. If the robot appears as `/dev/ttyACM0`, the user usually needs to be in the `dialout` group. Camera access may require membership in the `video` group." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "linux-permission-check", + "metadata": {}, + "outputs": [], + "source": [ + "# Optional Linux permission check for cameras and the SO101 serial device.\n", + "# If the current user is missing the required groups, run the printed commands\n", + "# in a terminal, then log out/in or restart the machine before continuing.\n", + "import getpass\n", + "import os\n", + "from pathlib import Path\n", + "\n", + "try:\n", + " import grp\n", + "except ImportError:\n", + " grp = None\n", + "\n", + "user = getpass.getuser()\n", + "print(\"Current user:\", user)\n", + "\n", + "if grp is None:\n", + " print(\"Group membership checks are only available on Unix-like systems.\")\n", + "else:\n", + " groups = {g.gr_name for g in grp.getgrall() if user in g.gr_mem}\n", + " try:\n", + " groups.add(grp.getgrgid(os.getgid()).gr_name)\n", + " except KeyError:\n", + " pass\n", + " print(\"Groups:\", \" \".join(sorted(groups)))\n", + "\n", + " for group in [\"video\", \"dialout\"]:\n", + " if group not in groups:\n", + " print(f\"[ACTION REQUIRED] Add {user!r} to the {group!r} group:\")\n", + " print(f\" sudo usermod -aG {group} {user}\")\n", + "\n", + "serial_port = Path(\"/dev/ttyACM0\")\n", + "if serial_port.exists():\n", + " print(\"/dev/ttyACM0 mode/owner:\", oct(serial_port.stat().st_mode), serial_port.stat().st_uid, serial_port.stat().st_gid)\n", + " if not os.access(serial_port, os.R_OK | os.W_OK):\n", + " print(\"[ACTION REQUIRED] Current process cannot read/write /dev/ttyACM0.\")\n", + " print(\"After adding the user to dialout, restart the login session/Jupyter server.\")\n", + "else:\n", + " print(\"/dev/ttyACM0 was not found. Update SO101_PORT later if your robot uses a different serial device.\")\n" ] }, { @@ -146,7 +211,9 @@ "id": "7c427976-07a9-49ac-86da-8b68a2a6dbdd", "metadata": {}, "source": [ - "Discover cameras." + "
\n", + "Discover cameras.\n", + "
\n" ] }, { @@ -173,10 +240,22 @@ "id": "85241b2b-6119-4bba-8986-aa2fa0f57123", "metadata": {}, "source": [ - "To set up cameras
\n", - "* Use the output of previous cell in the next cell
\n", - "* Use the same names of camera as set in the dataset
\n", - "\"Description\"\n" + "
\n", + "ACTION REQUIRED: Configure your cameras before running the next code cell.\n", + "
\n", + "\n", + "
\n", + "Use the device IDs printed by the previous cell. Keep the camera names exactly the same as the names used during dataset collection and model export.\n", + "
\n", + "\n", + "For the released Pi0.5 pick-and-place model used in this tutorial, the model expects **two** cameras. Do not add a third camera such as `table-cam` unless your exported model was trained with that camera.\n", + "\n", + "Expected names for the example model:\n", + "\n", + "- `top-cam`\n", + "- `gripper-cam`\n", + "\n", + "\"Camera\n" ] }, { @@ -186,13 +265,20 @@ "metadata": {}, "outputs": [], "source": [ - "# Cameras — use the same names and setup as used for the dataset collection\n", + "# Cameras - use the same names and setup as used for dataset collection and model export.\n", "# ============================================================\n", - "# 🔴 USER PARAMETERS — Edit these before running 🔧\n", + "# ACTION REQUIRED - Edit these before running.\n", "# ============================================================\n", + "# Use the LEFT column from the camera discovery output as device_id.\n", + "# For UVC cameras, prefer stable /dev/v4l/by-id/... paths instead of numeric IDs.\n", + "#\n", + "# The example Pi0.5 model expects exactly two cameras:\n", + "# - \"top-cam\"\n", + "# - \"gripper-cam\"\n", + "# Remove or rename cameras only if your exported model was trained with different names.\n", "CAMERAS = [\n", - " (, \"uvc\", ),\n", - " (, \"uvc\", ),\n", + " (\"top-cam\", \"uvc\", \"/dev/v4l/by-id/\"),\n", + " (\"gripper-cam\", \"uvc\", \"/dev/v4l/by-id/\"),\n", "]\n", "\n", "CAMERA_WIDTH = 640\n", @@ -201,7 +287,7 @@ "\n", "# Runtime\n", "FPS = 30\n", - "DURATION_S = 60.0" + "DURATION_S = 60.0\n" ] }, { @@ -210,33 +296,59 @@ "id": "19ae4018-7f3d-4c98-917c-843c58074b8a", "metadata": {}, "source": [ - "Define robot and get the robot ID from Physical AI Studio

\n", - "\"Description\"\n", - "\n" + "Define robot and get the robot ID from Physical AI Studio.\n", + "\n", + "\"Robot\n", + "\n", + "
\n", + "ACTION REQUIRED: Configure the SO101 serial port and calibration file before connecting the robot.\n", + "
\n", + "\n", + "The calibration file path depends on how you collected data:\n", + "\n", + "- Physical AI Studio: `~/.cache/physicalai/robots//calibrations/.json`\n", + "- LeRobot: `~/.cache/calibration/so101_follower.json`\n", + "\n", + "Make sure the Jupyter user can read the calibration JSON. If you see `PermissionError`, fix file ownership/permissions or copy the calibration file to a readable path.\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "35aa390b-c7e4-4bc2-ab08-13aecff652d5", + "id": "so101-port-config", "metadata": {}, "outputs": [], "source": [ "# ============================================================\n", - "# 🔴 USER PARAMETERS — Edit these before running 🔧\n", + "# ACTION REQUIRED - Edit these before running.\n", "# ============================================================\n", - "# Robot\n", - "SO101_PORT = \"/dev/ttyACM0\"\n", - "\n", + "# Robot serial port. Common Linux values are /dev/ttyACM0 or /dev/ttyUSB0.\n", + "SO101_PORT = \"/dev/ttyACM0\"\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "so101-calibration-config", + "metadata": {}, + "outputs": [], + "source": [ "# ============================================================\n", - "# 🔴 USER PARAMETERS — Edit these before running 🔧\n", + "# ACTION REQUIRED - Edit this calibration path before running the connect cell.\n", "# ============================================================\n", - "# Calibration file location depends on how you collected data:\n", - "# Physical AI Studio: ~/.cache/physicalai/robots//calibrations/.json\n", - "# LeRobot: ~/.cache/calibration/so101_follower.json\n", + "# Physical AI Studio example:\n", + "# SO101_CALIBRATION = \"~/.cache/physicalai/robots//calibrations/.json\"\n", + "#\n", + "# LeRobot example:\n", + "# SO101_CALIBRATION = \"~/.cache/calibration/so101_follower.json\"\n", + "SO101_CALIBRATION = \"~/.cache/physicalai/robots//calibrations/.json\"\n", "\n", - "#SO101_CALIBRATION = \"~/.cache/physicalai/robots//calibrations/.json\"\n", - "SO101_CALIBRATION = \"/home/intel/.cache/physicalai/robots/7549dd0c-a292-41c4-a8c0-712aae14c55f/calibrations/b28a2ed2-66db-4f61-848e-b65c88827d48.json\"" + "SO101_CALIBRATION = str(Path(SO101_CALIBRATION).expanduser())\n", + "if \"<\" in SO101_CALIBRATION or \">\" in SO101_CALIBRATION:\n", + " raise ValueError(\"Update SO101_CALIBRATION to your actual calibration JSON path before continuing.\")\n", + "if not Path(SO101_CALIBRATION).is_file():\n", + " raise FileNotFoundError(f\"Calibration file not found: {SO101_CALIBRATION}\")\n", + "print(\"Using SO101 calibration:\", SO101_CALIBRATION)\n" ] }, { @@ -244,8 +356,18 @@ "id": "6c86a75f-9cd6-46ba-99fb-880521a689c8", "metadata": {}, "source": [ - "Connect cameras and robot together. This is an important step as it sets up an environment for robot and camera together.\n", - "* Make sure Physical AI Studio application is closed and no other application is using the cameras or robot" + "Connect cameras and robot together. This sets up the synchronized robot/camera runtime environment.\n", + "\n", + "
\n", + "ACTION REQUIRED: Confirm cameras, robot connection, and permissions before running the next code cell.\n", + "
\n", + "\n", + "Before running this cell:\n", + "\n", + "- Make sure Physical AI Studio and other camera applications are closed.\n", + "- Confirm the current user can access the SO101 serial port, for example `/dev/ttyACM0`.\n", + "- Confirm `CAMERAS` contains exactly the camera names expected by your exported model. The example model expects `top-cam` and `gripper-cam` only.\n", + "- If a previous connection attempt failed, disconnect cameras or restart the kernel before trying again.\n" ] }, { @@ -260,17 +382,25 @@ "\n", "# Cameras\n", "cameras = {}\n", - "for name, driver, device_id in CAMERAS:\n", - " kwargs = {\"serial_number\": device_id} if driver == \"realsense\" else {\"device\": device_id}\n", - " cam = SharedCamera(driver, **kwargs, width=CAMERA_WIDTH, height=CAMERA_HEIGHT, fps=CAMERA_FPS)\n", - " cam.connect()\n", - " cameras[name] = cam\n", - " print(f\"Camera '{name}' connected: {cam.actual_width}x{cam.actual_height} @ {cam.actual_fps}fps\")\n", - "\n", - "# Robot\n", - "robot = SO101(port=SO101_PORT, calibration=SO101_CALIBRATION, role=\"follower\")\n", - "robot.connect()\n", - "print(f\"Robot connected on {SO101_PORT}\")" + "try:\n", + " for name, driver, device_id in CAMERAS:\n", + " kwargs = {\"serial_number\": device_id} if driver == \"realsense\" else {\"device\": device_id}\n", + " cam = SharedCamera(driver, **kwargs, width=CAMERA_WIDTH, height=CAMERA_HEIGHT, fps=CAMERA_FPS)\n", + " cam.connect()\n", + " cameras[name] = cam\n", + " print(f\"Camera '{name}' connected: {cam.actual_width}x{cam.actual_height} @ {cam.actual_fps}fps\")\n", + "\n", + " # Robot\n", + " robot = SO101(port=SO101_PORT, calibration=SO101_CALIBRATION, role=\"follower\")\n", + " robot.connect()\n", + " print(f\"Robot connected on {SO101_PORT}\")\n", + "except Exception:\n", + " for cam in cameras.values():\n", + " try:\n", + " cam.disconnect()\n", + " except Exception:\n", + " pass\n", + " raise\n" ] }, { @@ -278,7 +408,14 @@ "id": "66334671-4691-44d7-a37b-59bc92693798", "metadata": {}, "source": [ - "## 4) Load the model" + "## 4) Load the model\n", + "\n", + "This notebook supports two model sources:\n", + "\n", + "1. **Your own exported model** from Physical AI Studio or LeRobot. Set `MODEL_SOURCE = \"local\"` and point `EXPORT_DIR` to the exported OpenVINO policy package. Use this path for real SO101 robot deployment.\n", + "2. **The official OpenVINO example model** from Hugging Face. Set `MODEL_SOURCE = \"hub\"`; the notebook downloads a released OpenVINO policy package from the official [OpenVINO Physical AI collection](https://huggingface.co/collections/OpenVINO/physical-ai). This is useful for validating model package download and loading before using your own exported model.\n", + "\n", + "The exported model must match your runtime setup, especially the robot action space and the number and names of cameras." ] }, { @@ -289,10 +426,24 @@ "outputs": [], "source": [ "# ============================================================\n", - "# 🔴 USER PARAMETERS — Edit these before running 🔧\n", + "# ACTION REQUIRED - Choose model source and deployment device.\n", "# ============================================================\n", + "# \"local\": use your own exported model from Physical AI Studio or LeRobot.\n", + "# \"hub\": download an official OpenVINO example Pi0.5 policy package.\n", + "MODEL_SOURCE = \"local\" # \"local\" or \"hub\"\n", + "\n", + "# Used when MODEL_SOURCE == \"local\".\n", "EXPORT_DIR = \"exports/pi05-pick-place-purple-cube\"\n", - "DEVICE = \"GPU\" # OpenVINO can run on \"GPU\", \"CPU\", \"NPU\"\n", + "\n", + "# Used when MODEL_SOURCE == \"hub\".\n", + "# This official example is from https://huggingface.co/collections/OpenVINO/physical-ai.\n", + "# It is a LIBERO benchmark model, so do not deploy it on a real SO101 robot unless\n", + "# you have verified that the robot, cameras, and action schema match your setup.\n", + "MODEL_REPO_ID = \"OpenVINO/pi05-libero-fp16-ov\"\n", + "HUB_MODEL_IS_ROBOT_COMPATIBLE = False\n", + "ASSETS_DIR = Path(\"physicalai_assets\").resolve()\n", + "\n", + "DEVICE = \"GPU\" # OpenVINO can run on \"GPU\", \"CPU\", \"NPU\".\n", "TASK = \"pick up the box\"" ] }, @@ -301,10 +452,11 @@ "id": "d4ed8ff1-4c78-4060-b9d2-6e8fb9e9c891", "metadata": {}, "source": [ - "
Make sure to copy all the model files from the studio to the $EXPORT_DIR\n", - "
Then, load the model to the target device\n", - "

\n", - "\"Description\"" + "If `MODEL_SOURCE = \"local\"`, make sure `EXPORT_DIR` contains the full exported policy package, including `manifest.json`, model IR files, tokenizer files, and metadata.\n", + "\n", + "If `MODEL_SOURCE = \"hub\"`, the next cell downloads the official example model package from the OpenVINO Hugging Face collection. Use this path to validate model loading; for real robot deployment, use a model exported from a dataset collected with the same robot, camera names, and action schema.\n", + "\n", + "\"Model" ] }, { @@ -314,11 +466,39 @@ "metadata": {}, "outputs": [], "source": [ - "import openvino_tokenizers \n", + "import openvino_tokenizers # noqa: F401 - registers OpenVINO tokenizer custom ops\n", + "from huggingface_hub import snapshot_download\n", "from physicalai.inference import InferenceModel\n", "\n", - "policy = InferenceModel.load(EXPORT_DIR, device=DEVICE)\n", - "print(f\"Model loaded on {DEVICE}\")" + "if MODEL_SOURCE == \"hub\":\n", + " EXPORT_DIR = Path(\n", + " snapshot_download(\n", + " repo_id=MODEL_REPO_ID,\n", + " local_dir=ASSETS_DIR / \"models\" / MODEL_REPO_ID.replace(\"/\", \"__\"),\n", + " allow_patterns=[\n", + " \"manifest.json\",\n", + " \"pi05.xml\",\n", + " \"pi05.bin\",\n", + " \"tokenizer.xml\",\n", + " \"tokenizer.bin\",\n", + " \"metadata.yaml\",\n", + " \"README.md\",\n", + " ],\n", + " local_dir_use_symlinks=False,\n", + " )\n", + " ).resolve()\n", + "elif MODEL_SOURCE == \"local\":\n", + " EXPORT_DIR = Path(EXPORT_DIR).expanduser().resolve()\n", + "else:\n", + " raise ValueError(\"MODEL_SOURCE must be 'local' or 'hub'.\")\n", + "\n", + "required_model_files = [\"manifest.json\", \"pi05.xml\", \"pi05.bin\", \"tokenizer.xml\", \"tokenizer.bin\"]\n", + "missing = [name for name in required_model_files if not (EXPORT_DIR / name).exists()]\n", + "if missing:\n", + " raise FileNotFoundError(f\"Missing required model package files in {EXPORT_DIR}: {missing}\")\n", + "\n", + "policy = InferenceModel.load(EXPORT_DIR, backend=\"openvino\", device=DEVICE)\n", + "print(f\"Model loaded from {EXPORT_DIR} on {DEVICE}\")" ] }, { @@ -344,23 +524,39 @@ "metadata": {}, "outputs": [], "source": [ - "from physicalai.runtime import ActionQueue, PolicyRuntime, SyncExecution\n", + "if MODEL_SOURCE == \"hub\" and not HUB_MODEL_IS_ROBOT_COMPATIBLE:\n", + " raise RuntimeError(\n", + " \"The official Hugging Face example model is intended for model-loading validation. \"\n", + " \"Before running it on a real SO101 robot, verify that its robot action space, \"\n", + " \"camera count, camera names, and preprocessing metadata match your setup, then set \"\n", + " \"HUB_MODEL_IS_ROBOT_COMPATIBLE = True.\"\n", + " )\n", + "\n", + "from physicalai.runtime import PolicyRuntime, SyncExecution\n", "\n", "runtime = PolicyRuntime(\n", " robot=robot,\n", " model=policy,\n", - " execution=SyncExecution(fps=FPS, request_threshold=0.5),\n", + " execution=SyncExecution(request_threshold=0.5),\n", " fps=FPS,\n", " cameras=cameras,\n", - " action_queue=ActionQueue(),\n", " task=TASK,\n", ")\n", "\n", + "# Optional sanity check: the example Pi0.5 model expects two camera inputs.\n", + "sample_obs = runtime._build_model_input()\n", + "print(\"Runtime model inputs:\")\n", + "for key, value in sample_obs.items():\n", + " print(\" \", key, getattr(value, \"shape\", None))\n", + "image_keys = [key for key in sample_obs if key.startswith(\"images.\")]\n", + "if len(image_keys) != len(CAMERAS):\n", + " raise RuntimeError(f\"Camera input mismatch: CAMERAS has {len(CAMERAS)} entries but runtime built {len(image_keys)} image inputs.\")\n", + "\n", "with runtime:\n", - " print(f\"Running policy at {FPS} fps for {DURATION_S}s — task: {TASK!r}\")\n", + " print(f\"Running policy at {FPS} fps for {DURATION_S}s - task: {TASK!r}\")\n", " stats = runtime.run(duration_s=DURATION_S)\n", "\n", - "print(f\"\\nDone — {stats.steps} steps, {stats.inference_count} inferences, {stats.total_holds} holds\")" + "print(f\"\\nDone - {stats.steps} steps, {stats.inference_count} inferences, {stats.total_holds} holds\")\n" ] }, { @@ -379,11 +575,19 @@ "outputs": [], "source": [ "for name, cam in cameras.items():\n", - " cam.disconnect()\n", - " print(f\"Camera '{name}' disconnected\")\n", + " try:\n", + " cam.disconnect()\n", + " print(f\"Camera '{name}' disconnected\")\n", + " except Exception as exc:\n", + " print(f\"Camera '{name}' disconnect warning: {exc}\")\n", "\n", - "robot.disconnect()\n", - "print(\"Robot disconnected\")" + "try:\n", + " robot.disconnect()\n", + " print(\"Robot disconnected\")\n", + "except NameError:\n", + " print(\"Robot was not created\")\n", + "except Exception as exc:\n", + " print(f\"Robot disconnect warning: {exc}\")\n" ] } ],