From 804438516f6ae837bdf6e2b035cfc96dda4d01e8 Mon Sep 17 00:00:00 2001 From: tginsbu1 Date: Wed, 19 Nov 2025 10:34:37 -0600 Subject: [PATCH 1/5] WIP: better robustness and error handling --- .gitignore | 2 + compose.yaml | 2 +- .../camera_node_template.node.info.yaml | 267 ++++++++---------- definitions/camera_node_template.node.yaml | 32 +-- pdm.lock | 50 ++-- pyproject.toml | 4 +- src/camera_interface.py | 2 + src/camera_rest_node.py | 26 +- tests/test_node.ipynb | 20 +- 9 files changed, 191 insertions(+), 214 deletions(-) create mode 100644 src/camera_interface.py diff --git a/.gitignore b/.gitignore index 543d4ba..5f6b490 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,5 @@ cython_debug/ # WEI .wei + +.pdm-python diff --git a/compose.yaml b/compose.yaml index c7d8dc8..40c58da 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,7 +12,7 @@ services: tags: - ghcr.io/ad-sdl/camera_module:latest - ghcr.io/ad-sdl/camera_module:dev - command: python camera_module/src/camera_rest_node.py --definition definitions/camera_node_template.node.yaml + command: python camera_module/src/camera_rest_node.py --node_definition definitions/camera_node_template.node.yaml volumes: - ./definitions:/home/madsci/definitions privileged: true diff --git a/definitions/camera_node_template.node.info.yaml b/definitions/camera_node_template.node.info.yaml index df3e316..99ad014 100644 --- a/definitions/camera_node_template.node.info.yaml +++ b/definitions/camera_node_template.node.info.yaml @@ -1,15 +1,15 @@ -node_name: camera_node_template -node_id: 01JPN93A0G54674CMHJ0N04PB2 -node_url: null -node_description: A template for defining a camera node +node_name: camera_node_template.node +node_id: 01K9T47YGD515GTRJJ0Y2HNR6F +node_description: null node_type: device -module_name: camera_module -module_version: 0.0.1 +module_name: camera_node +module_version: 2.1.0 capabilities: get_info: true get_state: true get_status: true send_action: true + get_action_status: true get_action_result: true get_action_history: true action_files: true @@ -18,13 +18,11 @@ capabilities: get_resources: false get_log: true admin_commands: - - reset - lock - - unlock + - reset - shutdown -commands: {} -is_template: false -config_defaults: {} + - unlock +node_url: null actions: take_picture: name: take_picture @@ -45,126 +43,107 @@ actions: default: null locations: {} files: {} - results: {} + results: + file: + result_label: file + description: null + result_type: file blocking: false asynchronous: true + accepts_var_args: false + accepts_var_kwargs: false + var_args_schema: null + var_kwargs_schema: null + read_barcode: + name: read_barcode + description: "\n Takes an image and returns the values of any barcodes\ + \ present in the image. Camera focus can be adjusted using the provided parameters\ + \ if necessary.\n\n Args:\n camera (cv2.VideoCapture): The\ + \ camera object to adjust focus for.\n focus (Optional[int]): The\ + \ desired focus value (used if autofocus is disabled).\n autofocus\ + \ (Optional[bool]): Whether to enable or disable autofocus.\n\n Returns:\n\ + \ ActionSucceded regardless of if barcode is collected or not.\n\ + \ Barcode field in ActionResult data dictionary will contain 'None'\ + \ if no barcode was collected\n\n " + args: + focus: + name: focus + description: '' + argument_type: int + required: false + default: null + autofocus: + name: autofocus + description: '' + argument_type: bool + required: false + default: null + locations: {} + files: {} + results: + json_result: + result_label: json_result + description: null + result_type: json + json_schema: + properties: + data: + title: Data + type: string + required: + - data + title: strModel + type: object + file: + result_label: file + description: null + result_type: file + blocking: false + asynchronous: true + accepts_var_args: false + accepts_var_kwargs: false + var_args_schema: null + var_kwargs_schema: null config: + node_definition: definitions/camera_node_template.node.yaml + node_info_path: null + update_node_files: true status_update_interval: 2.0 state_update_interval: 2.0 - event_client_config: null - resource_server_url: null - host: 127.0.0.1 - port: 8000 - protocol: http + node_url: http://127.0.0.1:2000/ + uvicorn_kwargs: {} camera_address: 0 config_schema: - $defs: - EventClientConfig: - description: Configuration for an Event Client. - properties: - name: - anyOf: - - type: string - - type: 'null' - default: null - description: The name of the event client. - title: Event Client Name - event_server_url: - anyOf: - - type: string - - type: 'null' - default: null - description: The URL of the event server. - title: Event Server URL - log_level: - default: 20 - description: The log level of the event client. - title: Event Client Log Level - type: integer - source: - $ref: '#/$defs/OwnershipInfo' - description: Information about the source of the event client. - title: Source - log_dir: - anyOf: - - type: string - - format: path - type: string - description: The directory to store logs in. - title: Log Directory - title: EventClientConfig - type: object - OwnershipInfo: - description: Information about the ownership of a MADSci object. - properties: - user_id: - anyOf: - - type: string - - type: 'null' - default: null - description: The ID of the user who owns the object. - title: User ID - experiment_id: - anyOf: - - type: string - - type: 'null' - default: null - description: The ID of the experiment that owns the object. - title: Experiment ID - campaign_id: - anyOf: - - type: string - - type: 'null' - default: null - description: The ID of the campaign that owns the object. - title: Campaign ID - project_id: - anyOf: - - type: string - - type: 'null' - default: null - description: The ID of the project that owns the object. - title: Project ID - node_id: - anyOf: - - type: string - - type: 'null' - default: null - description: The ID of the node that owns the object. - title: Node ID - workcell_id: - anyOf: - - type: string - - type: 'null' - default: null - description: The ID of the workcell that owns the object. - title: Workcell ID - lab_id: - anyOf: - - type: string - - type: 'null' - default: null - description: The ID of the lab that owns the object. - title: Lab ID - step_id: - anyOf: - - type: string - - type: 'null' - default: null - description: The ID of the step that owns the object. - title: Step ID - workflow_id: - anyOf: - - type: string - - type: 'null' - default: null - description: The ID of the workflow that owns the object. - title: Workflow ID - title: OwnershipInfo - type: object - additionalProperties: true description: Configuration for the camera node module. properties: + node_definition: + anyOf: + - type: string + - format: path + type: string + - type: 'null' + default: default.node.yaml + description: Path to the node definition file to use. If set, the node will + load the definition from this file on startup. Otherwise, a default configuration + will be created. + title: Node Definition File + node_info_path: + anyOf: + - type: string + - format: path + type: string + - type: 'null' + default: null + description: Path to export the generated node info file. If not set, will use + the node name and the node_definition's path. + title: Node Info Path + update_node_files: + default: true + description: Whether to update the node definition and info files on startup. + If set to False, the node will not update the files even if they are out of + date. + title: Update Node Files + type: boolean status_update_interval: anyOf: - type: number @@ -179,42 +158,24 @@ config_schema: default: 2.0 description: The interval in seconds at which the node should update its state. title: State Update Interval - event_client_config: - anyOf: - - $ref: '#/$defs/EventClientConfig' - - type: 'null' - default: null - description: The configuration for a MADSci event client. - title: Event Client Configuration - resource_server_url: - anyOf: - - format: uri - minLength: 1 - type: string - - type: 'null' - default: null - description: The URL of the resource server for this node to use. - title: Resource Client URL - host: - default: 127.0.0.1 - description: The host of the REST API. - title: Host - type: string - port: - default: 8000 - description: The port of the REST API. - title: Port - type: integer - protocol: - default: http - description: The protocol of the REST API, either 'http' or 'https'. - title: Protocol + node_url: + default: http://127.0.0.1:2000/ + description: The URL used to communicate with the node. This is the base URL + for the REST API. + format: uri + minLength: 1 + title: Node URL type: string + uvicorn_kwargs: + additionalProperties: true + description: Configuration for the Uvicorn server that runs the REST API. + title: Uvicorn Configuration + type: object camera_address: anyOf: - type: integer - type: string default: 0 title: Camera Address - title: CameraConfig + title: CameraNodeConfig type: object diff --git a/definitions/camera_node_template.node.yaml b/definitions/camera_node_template.node.yaml index 0ca376a..1b21def 100644 --- a/definitions/camera_node_template.node.yaml +++ b/definitions/camera_node_template.node.yaml @@ -1,27 +1,7 @@ -node_name: camera_node_template -node_id: 01JPN93A0G54674CMHJ0N04PB2 -node_url: null -node_description: A template for defining a camera node +node_name: camera_node_template.node +node_id: 01K9T47YGD515GTRJJ0Y2HNR6F +node_description: null node_type: device -module_name: camera_module -module_version: 0.0.1 -capabilities: - get_info: true - get_state: true - get_status: true - send_action: true - get_action_result: true - get_action_history: true - action_files: true - send_admin_commands: true - set_config: true - get_resources: false - get_log: true - admin_commands: - - reset - - lock - - unlock - - shutdown -commands: {} -is_template: false -config_defaults: {} +module_name: camera_node +module_version: 2.1.0 +capabilities: null diff --git a/pdm.lock b/pdm.lock index 340bcb2..cabcc54 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:86d127ef9cb78a052852b7040f5e3c21493c1911d7a1546d0f0c8e8bc68ea52d" +content_hash = "sha256:8e407cc7db0bea001ded10fd3d7cd3f8da3e65f9f731fe1cd38601667defcfe9" [[metadata.targets]] requires_python = ">=3.9.1" @@ -664,28 +664,28 @@ files = [ [[package]] name = "ipykernel" -version = "6.29.5" -requires_python = ">=3.8" +version = "6.31.0" +requires_python = ">=3.9" summary = "IPython Kernel for Jupyter" groups = ["dev"] dependencies = [ - "appnope; platform_system == \"Darwin\"", + "appnope>=0.1.2; platform_system == \"Darwin\"", "comm>=0.1.1", "debugpy>=1.6.5", "ipython>=7.23.1", - "jupyter-client>=6.1.12", + "jupyter-client>=8.0.0", "jupyter-core!=5.0.*,>=4.12", "matplotlib-inline>=0.1", - "nest-asyncio", - "packaging", - "psutil", - "pyzmq>=24", - "tornado>=6.1", + "nest-asyncio>=1.4", + "packaging>=22", + "psutil>=5.7", + "pyzmq>=25", + "tornado>=6.2", "traitlets>=5.4.0", ] files = [ - {file = "ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5"}, - {file = "ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215"}, + {file = "ipykernel-6.31.0-py3-none-any.whl", hash = "sha256:abe5386f6ced727a70e0eb0cf1da801fa7c5fa6ff82147747d5a0406cd8c94af"}, + {file = "ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6"}, ] [[package]] @@ -777,9 +777,9 @@ files = [ [[package]] name = "madsci-client" -version = "0.5.0" +version = "0.5.3" requires_python = ">=3.9.1" -summary = "The Modular Autonomous Discovery for Science (MADSci) Python Client and CLI." +summary = "The Modular Autonomous Discovery for Science (MADSci) Python Clients." groups = ["default"] dependencies = [ "click>=8.1.7", @@ -788,13 +788,13 @@ dependencies = [ "trogon>=0.6.0", ] files = [ - {file = "madsci_client-0.5.0-py3-none-any.whl", hash = "sha256:350f63132a8a64a0c238e0b9d946b4a65cf03e744c2971d4fb3ff4e58d9f36fb"}, - {file = "madsci_client-0.5.0.tar.gz", hash = "sha256:1f7eb89ff83d20042f0d6b5d22a0a893cc535a3948c2e7bf487bbea259d3b946"}, + {file = "madsci_client-0.5.3-py3-none-any.whl", hash = "sha256:1b611f53c0adb1a0e4b14b820e1159978f91ecd7437fab10001f52ab0e663894"}, + {file = "madsci_client-0.5.3.tar.gz", hash = "sha256:d7335229f2d7ad7a32baffcd26bb8753098a00af59b1b0ebb38d5c3da2faf5dc"}, ] [[package]] name = "madsci-common" -version = "0.5.0" +version = "0.5.3" requires_python = ">=3.9.1" summary = "The Modular Autonomous Discovery for Science (MADSci) Common Definitions and Utilities." groups = ["default"] @@ -819,13 +819,13 @@ dependencies = [ "uvicorn[standard]>=0.32.0", ] files = [ - {file = "madsci_common-0.5.0-py3-none-any.whl", hash = "sha256:69f123576a414c046cff5b238ac9b2ab5f3ae895bde69688f1bb3b56bcada3d5"}, - {file = "madsci_common-0.5.0.tar.gz", hash = "sha256:68c7a2fbd27262ea0d228c1a20c18173d68e653908ebb672bbcdd9fe019f2835"}, + {file = "madsci_common-0.5.3-py3-none-any.whl", hash = "sha256:69cbcdad127e2c63ef8f086885844a1b1a0231833282fafa71bddb452e5681f7"}, + {file = "madsci_common-0.5.3.tar.gz", hash = "sha256:ad0c04d0f7363404278c6ec26b78743b22e34e0c2fe01e5bfd0a12be60d76ce5"}, ] [[package]] name = "madsci-node-module" -version = "0.5.0" +version = "0.5.3" requires_python = ">=3.9.1" summary = "The Modular Autonomous Discovery for Science (MADSci) Node Module Helper Classes." groups = ["default"] @@ -835,8 +835,8 @@ dependencies = [ "regex", ] files = [ - {file = "madsci_node_module-0.5.0-py3-none-any.whl", hash = "sha256:b7353d8633740bf62cfad05ad1a3bd2e9c190f997fac7fed93919361c503c36e"}, - {file = "madsci_node_module-0.5.0.tar.gz", hash = "sha256:77bdea14bf1ce01bc46fe99865cb3294f947e81e613d7b79c4097c6ed8462087"}, + {file = "madsci_node_module-0.5.3-py3-none-any.whl", hash = "sha256:32616ae0cfaaaecbba2641b40f1e8a7a59de4acd9fd96b5ae4c015cf00191ed2"}, + {file = "madsci_node_module-0.5.3.tar.gz", hash = "sha256:287f7e50cc2fe8ba87784b946f34e881d2a6e7f3b4fb10623b313a5aacffaa10"}, ] [[package]] @@ -1095,7 +1095,7 @@ files = [ [[package]] name = "pre-commit" -version = "4.2.0" +version = "4.3.0" requires_python = ">=3.9" summary = "A framework for managing and maintaining multi-language pre-commit hooks." groups = ["dev"] @@ -1107,8 +1107,8 @@ dependencies = [ "virtualenv>=20.10.0", ] files = [ - {file = "pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd"}, - {file = "pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146"}, + {file = "pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8"}, + {file = "pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index aa08092..052a225 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "camera_module" -version = "2.0.0" +version = "2.1.0" description = "A simple MADSci node module that supports taking snapshots with a camera or other video device." authors = [ {name = "Ryan D. Lewis", email="ryan.lewis@anl.gov"}, @@ -24,5 +24,5 @@ homepage = "https://github.com/AD-SDL/camera_module" [dependency-groups] dev = [ "pre-commit>=4.2.0", - "ipykernel>=6.29.5", + "ipykernel>=6.31.0", ] diff --git a/src/camera_interface.py b/src/camera_interface.py new file mode 100644 index 0000000..50cadad --- /dev/null +++ b/src/camera_interface.py @@ -0,0 +1,2 @@ +class CameraInterface: + """Interface for camera operations.""" diff --git a/src/camera_rest_node.py b/src/camera_rest_node.py index b2cd0c6..e233d6e 100644 --- a/src/camera_rest_node.py +++ b/src/camera_rest_node.py @@ -4,7 +4,7 @@ import tempfile from pathlib import Path -from typing import Annotated, Optional, Union +from typing import Annotated, Any, Optional, Union import cv2 from madsci.common.ownership import get_current_ownership_info @@ -12,7 +12,12 @@ from madsci.common.types.resource_types import Slot from madsci.node_module.helpers import action from madsci.node_module.rest_node_module import RestNode -from pyzbar.pyzbar import decode +from pydantic import field_validator + +try: + from pyzbar.pyzbar import decode +except ImportError: + print("pyzbar not found, barcode reading functionality will be disabled.") # noqa: T201 class CameraNodeConfig(RestNodeConfig): @@ -21,6 +26,15 @@ class CameraNodeConfig(RestNodeConfig): camera_address: Union[int, str] = 0 """The camera address, either a number for windows or a device path in Linux/Mac.""" + @field_validator("camera_address", mode="after") + @classmethod + def ensure_int_camera_address(cls, v: Any) -> Union[int, str]: + """Validates that, if the camera address is a string that can be converted to an integer, it does so.""" + try: + return int(v) + except (ValueError, TypeError): + return v + class CameraNode(RestNode): """Node that interfaces with MADSci and provides a USB camera interface""" @@ -78,7 +92,6 @@ def state_handler(self) -> None: """Periodically called to update the current state of the node.""" if self.camera is not None: self.node_state = {"camera_status": "connected"} - self.logger.log("Camera is operational.") else: self.node_state = {"camera_status": "disconnected"} self.logger.log_warning("Camera is not connected.") @@ -116,7 +129,8 @@ def read_barcode( autofocus: Optional[bool] = None, ) -> tuple[ Annotated[ - str, "The barcode read from the image, or None if no barcode was found" + str, + "The barcode read from the image, or an empty string if no barcode was found", ], Annotated[Path, "The picture taken by the camera"], ]: @@ -139,8 +153,10 @@ def read_barcode( # try to collect the barcode from the image image = cv2.imread(image_path) - barcode = None + barcode = "" + if "decode" not in globals(): + raise ImportError("pyzbar is not installed, cannot read barcodes.") all_detected_barcodes = decode(image) if all_detected_barcodes: # Note: only collects the first in a potential list of barcodes diff --git a/tests/test_node.ipynb b/tests/test_node.ipynb index dbb539d..1eb1249 100644 --- a/tests/test_node.ipynb +++ b/tests/test_node.ipynb @@ -16,7 +16,7 @@ "metadata": {}, "outputs": [], "source": [ - "client = RestNodeClient(\"http://localhost:8000\")\n", + "client = RestNodeClient(\"http://localhost:2000\")\n", "request = ActionRequest(\n", " action_name=\"take_picture\",\n", " args={\n", @@ -33,11 +33,27 @@ "source": [ "result = client.send_action(request)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "result" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": ".venv", + "display_name": "camera_module-3.12", "language": "python", "name": "python3" }, From d442b933b99f74a580dae3c75943d100aa360f86 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Wed, 19 Nov 2025 10:50:20 -0600 Subject: [PATCH 2/5] Adding camera interface, better error handling --- .gitignore | 1 + src/camera_interface.py | 210 ++++++++++++++++++++++++++++++++++++++++ src/camera_rest_node.py | 164 ++++++++++++------------------- 3 files changed, 274 insertions(+), 101 deletions(-) diff --git a/.gitignore b/.gitignore index 5f6b490..3403fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,4 @@ cython_debug/ .wei .pdm-python +.vscode diff --git a/src/camera_interface.py b/src/camera_interface.py index 50cadad..daff5d2 100644 --- a/src/camera_interface.py +++ b/src/camera_interface.py @@ -1,2 +1,212 @@ +""" +Camera interface for handling camera operations including image capture and barcode reading. +""" + +import tempfile +import threading +from pathlib import Path +from typing import Optional, Union + +import cv2 + +try: + from pyzbar.pyzbar import decode +except ImportError: + decode = None + print("pyzbar not found, barcode reading functionality will be disabled.") # noqa: T201 + + class CameraInterface: """Interface for camera operations.""" + + def __init__(self, camera_address: Union[int, str] = 0) -> None: + """ + Initialize the camera interface. + + Args: + camera_address: The camera address, either a number for windows or a device path in Linux/Mac. + """ + self.camera_address = self._validate_camera_address(camera_address) + self.camera: Optional[cv2.VideoCapture] = None + self.camera_lock = threading.Lock() + + @staticmethod + def _validate_camera_address(camera_address: Union[int, str]) -> Union[int, str]: + """ + Validates that, if the camera address is a string that can be converted to an integer, it does so. + + Args: + camera_address: The camera address to validate. + + Returns: + The validated camera address as int or str. + """ + try: + return int(camera_address) + except (ValueError, TypeError): + return camera_address + + def connect(self) -> None: + """ + Connect to the camera. + + Raises: + Exception: If unable to connect to camera. + """ + with self.camera_lock: + self.camera = cv2.VideoCapture(self.camera_address) + if not self.camera.isOpened(): + raise Exception("Unable to connect to camera") + + def disconnect(self) -> None: + """Disconnect from the camera if connected.""" + with self.camera_lock: + if self.camera is not None and self.camera.isOpened(): + self.camera.release() + self.camera = None + + def is_connected(self) -> bool: + """ + Check if the camera is connected. + + Returns: + True if camera is connected, False otherwise. + """ + with self.camera_lock: + return self.camera is not None and self.camera.isOpened() + + def take_picture( + self, focus: Optional[int] = None, autofocus: Optional[bool] = None + ) -> Path: + """ + Take a picture using the configured camera. + + Args: + focus: The desired focus value (used if autofocus is disabled). + autofocus: Whether to enable or disable autofocus. + + Returns: + Path to the captured image file. + + Raises: + Exception: If unable to read from camera or camera is not connected. + """ + with self.camera_lock: + if self.camera is None or not self.camera.isOpened(): + raise Exception("Camera is not connected") + + # Handle autofocus/refocusing + if focus is not None or autofocus is not None: + self._adjust_focus_settings_unlocked(focus, autofocus) + + success, frame = self.camera.read() + if not success: + raise Exception("Unable to read from camera") + + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as temp_file: + temp_file_path = Path(temp_file.name) + cv2.imwrite(str(temp_file_path), frame) + + return temp_file_path + + def read_barcode( + self, + focus: Optional[int] = None, + autofocus: Optional[bool] = None, + ) -> tuple[str, Path]: + """ + Take an image and return the values of any barcodes present in the image. + + Args: + focus: The desired focus value (used if autofocus is disabled). + autofocus: Whether to enable or disable autofocus. + + Returns: + A tuple containing: + - The barcode string (empty if no barcode found) + - Path to the captured image + + Raises: + ImportError: If pyzbar is not installed. + Exception: If unable to capture image or read from camera. + """ + # Take an image and collect the image path + image_path = self.take_picture(focus=focus, autofocus=autofocus) + + # Try to collect the barcode from the image + image = cv2.imread(str(image_path)) + barcode = "" + + if decode is None: + raise ImportError("pyzbar is not installed, cannot read barcodes.") + + all_detected_barcodes = decode(image) + if all_detected_barcodes: + # Note: only collects the first in a potential list of barcodes + barcode = all_detected_barcodes[0].data.decode("utf-8") + + return barcode, image_path + + def adjust_focus_settings( + self, + focus: Optional[int] = None, + autofocus: Optional[bool] = None, + ) -> None: + """ + Adjust the camera's focus, if necessary/possible, based on the provided parameters. + + Args: + focus: The desired focus value (used if autofocus is disabled). + autofocus: Whether to enable or disable autofocus. + + Raises: + Exception: If camera is not connected. + ValueError: If the focus value is out of range. + """ + with self.camera_lock: + self._adjust_focus_settings_unlocked(focus, autofocus) + + def _adjust_focus_settings_unlocked( + self, + focus: Optional[int] = None, + autofocus: Optional[bool] = None, + ) -> None: + """ + Internal method to adjust camera focus without acquiring the lock. + This should only be called when the camera_lock is already held. + + Args: + focus: The desired focus value (used if autofocus is disabled). + autofocus: Whether to enable or disable autofocus. + + Raises: + Exception: If camera is not connected. + ValueError: If the focus value is out of range. + """ + if self.camera is None or not self.camera.isOpened(): + raise Exception("Camera is not connected") + + focus_changed = False + + if autofocus is not None: + current_autofocus = self.camera.get(cv2.CAP_PROP_AUTOFOCUS) + if current_autofocus != (1 if autofocus else 0): + self.camera.set(cv2.CAP_PROP_AUTOFOCUS, 1 if autofocus else 0) + focus_changed = True + + if not autofocus and focus is not None: + if focus < 0 or focus > 255: + raise ValueError("Focus value must be between 0 and 255.") + current_focus = self.camera.get(cv2.CAP_PROP_FOCUS) + if current_focus != focus: + self.camera.set(cv2.CAP_PROP_FOCUS, focus) + focus_changed = True + + if focus_changed: + # Discard 30 frames to allow focus to stabilize + for _ in range(30): + self.camera.read() + else: + # Discard 5 frames in case the camera needs a moment for startup + for _ in range(5): + self.camera.read() diff --git a/src/camera_rest_node.py b/src/camera_rest_node.py index e233d6e..3487cd4 100644 --- a/src/camera_rest_node.py +++ b/src/camera_rest_node.py @@ -2,11 +2,10 @@ REST-based node that interfaces with MADSci and provides a USB camera interface """ -import tempfile +import traceback from pathlib import Path from typing import Annotated, Any, Optional, Union -import cv2 from madsci.common.ownership import get_current_ownership_info from madsci.common.types.node_types import RestNodeConfig from madsci.common.types.resource_types import Slot @@ -14,10 +13,7 @@ from madsci.node_module.rest_node_module import RestNode from pydantic import field_validator -try: - from pyzbar.pyzbar import decode -except ImportError: - print("pyzbar not found, barcode reading functionality will be disabled.") # noqa: T201 +from camera_interface import CameraInterface class CameraNodeConfig(RestNodeConfig): @@ -41,7 +37,8 @@ class CameraNode(RestNode): config: CameraNodeConfig = CameraNodeConfig() config_model = CameraNodeConfig - camera: cv2.VideoCapture = None + camera_interface: Optional[CameraInterface] = None + state_error_latch: bool = False def startup_handler(self) -> None: """Called to (re)initialize the node. Should be used to open connections to devices or initialize any other resources.""" @@ -83,18 +80,42 @@ def startup_handler(self) -> None: f"Initialized capture deck resource from template: {self.capture_deck.resource_id}" ) - self.camera = cv2.VideoCapture(self.config.camera_address) - if not self.camera.isOpened(): - raise Exception("Unable to connect to camera") - self.logger.log("Camera node initialized!") + # Initialize camera interface + self.camera_interface = CameraInterface(self.config.camera_address) + try: + self.camera_interface.connect() + if self.camera_interface.is_connected(): + self.logger.log("Camera node initialized!") + except Exception as e: + self.logger.log_error(f"Failed to connect to camera: {e}") + raise def state_handler(self) -> None: """Periodically called to update the current state of the node.""" - if self.camera is not None: - self.node_state = {"camera_status": "connected"} - else: - self.node_state = {"camera_status": "disconnected"} - self.logger.log_warning("Camera is not connected.") + camera_status = "disconnected" + try: + if ( + self.camera_interface is not None + and self.camera_interface.is_connected() + ): + camera_status = "connected" + else: + try: + self.camera_interface.connect() + if self.camera_interface.is_connected(): + camera_status = "connected" + except Exception: + camera_status = "disconnected" + if not self.state_error_latch: + self.logger.log_error(traceback.format_exc()) + self.node_status.errors.append(traceback.format_exc()) + self.node_status.errored = True + self.state_error_latch = True + finally: + if camera_status == "connected": + self.state_error_latch = False + self.node_status.errored = False + self.node_state = {"camera_status": camera_status} @action def take_picture( @@ -102,25 +123,18 @@ def take_picture( ) -> Annotated[Path, "The picture taken by the camera"]: """Action that takes a picture using the configured camera. The focus used can be set using the focus parameter.""" - # * Handle autofocus/refocusing + if self.camera_interface is None: + raise Exception("Camera interface is not initialized") + try: - if focus is not None or autofocus is not None: - self.logger.log_info("Adjusting focus settings") - self.adjust_focus_settings(self.camera, focus, autofocus) + image_path = self.camera_interface.take_picture( + focus=focus, autofocus=autofocus + ) + self.logger.log_info(f"Picture taken and saved to {image_path}") + return image_path except Exception as e: - self.logger.log_error(f"Failed to adjust focus settings: {e}") - - success, frame = self.camera.read() - if not success: - if self.camera.isOpened(): - self.camera.release() - raise Exception("Unable to read from camera") - with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as temp_file: - temp_file_path = Path(temp_file.name) - cv2.imwrite(str(temp_file_path), frame) - self.camera.release() - - return temp_file_path + self.logger.log_error(f"Failed to take picture: {e}") + raise @action def read_barcode( @@ -138,82 +152,30 @@ def read_barcode( Takes an image and returns the values of any barcodes present in the image. Camera focus can be adjusted using the provided parameters if necessary. Args: - camera (cv2.VideoCapture): The camera object to adjust focus for. focus (Optional[int]): The desired focus value (used if autofocus is disabled). autofocus (Optional[bool]): Whether to enable or disable autofocus. Returns: - ActionSucceded regardless of if barcode is collected or not. - Barcode field in ActionResult data dictionary will contain 'None' if no barcode was collected + A tuple containing: + - The barcode string (empty if no barcode found) + - Path to the captured image """ - try: - # take an image and collect the image path - image_path = self.take_picture(focus=focus, autofocus=autofocus) - - # try to collect the barcode from the image - image = cv2.imread(image_path) - barcode = "" - - if "decode" not in globals(): - raise ImportError("pyzbar is not installed, cannot read barcodes.") - all_detected_barcodes = decode(image) - if all_detected_barcodes: - # Note: only collects the first in a potential list of barcodes - barcode = all_detected_barcodes[0].data.decode("utf-8") - - except Exception as e: - raise e - - return barcode, image_path + if self.camera_interface is None: + raise Exception("Camera interface is not initialized") - def adjust_focus_settings( - self, - camera: cv2.VideoCapture, - focus: Optional[int] = None, - autofocus: Optional[bool] = None, - ) -> None: - """ - Adjusts the camera's focus, if necessary/possible, based on the provided parameters. - - Args: - camera (cv2.VideoCapture): The camera object to adjust focus for. - focus (Optional[int]): The desired focus value (used if autofocus is disabled). - autofocus (Optional[bool]): Whether to enable or disable autofocus. - - Raises: - Exception: If the camera does not support autofocus or manual focus. - ValueError: If the focus value is out of range. - """ - focus_changed = False - - if autofocus is not None: - self.logger.log_info(f"Setting autofocus to {autofocus}") - current_autofocus = camera.get(cv2.CAP_PROP_AUTOFOCUS) - if current_autofocus != (1 if autofocus else 0): - camera.set(cv2.CAP_PROP_AUTOFOCUS, 1 if autofocus else 0) - focus_changed = True - - if not autofocus and focus is not None: - self.logger.log_info(f"Setting focus to {focus}") - if focus < 0 or focus > 255: - raise ValueError("Focus value must be between 0 and 255.") - current_focus = camera.get(cv2.CAP_PROP_FOCUS) - if current_focus != focus: - camera.set(cv2.CAP_PROP_FOCUS, focus) - focus_changed = True - - if focus_changed: - self.logger.log_info( - "Focus settings changed. Waiting for focus to stabilize." + try: + barcode, image_path = self.camera_interface.read_barcode( + focus=focus, autofocus=autofocus ) - for _ in range(30): # Discard 30 frames to allow focus to stabilize - camera.read() - else: - for _ in range( - 5 - ): # Discard 5 frames in case the camera needs a moment for startup - camera.read() + if barcode: + self.logger.log_info(f"Barcode read: {barcode}") + else: + self.logger.log_info("No barcode detected in image") + return barcode, image_path + except Exception as e: + self.logger.log_error(f"Failed to read barcode: {e}") + raise if __name__ == "__main__": From a4e4584b1578e5995fc31790e858f0a5c8d2fb1c Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Wed, 19 Nov 2025 11:12:23 -0600 Subject: [PATCH 3/5] Move to a JIT camera access model --- src/camera_interface.py | 119 ++++++++++++++++++++-------------------- src/camera_rest_node.py | 40 +++++++------- 2 files changed, 80 insertions(+), 79 deletions(-) diff --git a/src/camera_interface.py b/src/camera_interface.py index daff5d2..9e709a2 100644 --- a/src/camera_interface.py +++ b/src/camera_interface.py @@ -27,7 +27,6 @@ def __init__(self, camera_address: Union[int, str] = 0) -> None: camera_address: The camera address, either a number for windows or a device path in Linux/Mac. """ self.camera_address = self._validate_camera_address(camera_address) - self.camera: Optional[cv2.VideoCapture] = None self.camera_lock = threading.Lock() @staticmethod @@ -46,34 +45,45 @@ def _validate_camera_address(camera_address: Union[int, str]) -> Union[int, str] except (ValueError, TypeError): return camera_address - def connect(self) -> None: + def _open_camera(self) -> cv2.VideoCapture: """ - Connect to the camera. + Open a connection to the camera. This is an internal method. + + Returns: + An opened VideoCapture object. Raises: Exception: If unable to connect to camera. """ - with self.camera_lock: - self.camera = cv2.VideoCapture(self.camera_address) - if not self.camera.isOpened(): - raise Exception("Unable to connect to camera") + camera = cv2.VideoCapture(self.camera_address) + if not camera.isOpened(): + raise Exception("Unable to connect to camera") + return camera - def disconnect(self) -> None: - """Disconnect from the camera if connected.""" - with self.camera_lock: - if self.camera is not None and self.camera.isOpened(): - self.camera.release() - self.camera = None + def _close_camera(self, camera: cv2.VideoCapture) -> None: + """ + Close the camera connection. This is an internal method. + + Args: + camera: The VideoCapture object to close. + """ + if camera is not None and camera.isOpened(): + camera.release() - def is_connected(self) -> bool: + def test_connection(self) -> bool: """ - Check if the camera is connected. + Test if the camera can be connected to. Returns: - True if camera is connected, False otherwise. + True if camera can be connected, False otherwise. """ with self.camera_lock: - return self.camera is not None and self.camera.isOpened() + try: + camera = self._open_camera() + self._close_camera(camera) + return True + except Exception: + return False def take_picture( self, focus: Optional[int] = None, autofocus: Optional[bool] = None @@ -92,22 +102,30 @@ def take_picture( Exception: If unable to read from camera or camera is not connected. """ with self.camera_lock: - if self.camera is None or not self.camera.isOpened(): - raise Exception("Camera is not connected") - - # Handle autofocus/refocusing - if focus is not None or autofocus is not None: - self._adjust_focus_settings_unlocked(focus, autofocus) - - success, frame = self.camera.read() - if not success: - raise Exception("Unable to read from camera") - - with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as temp_file: - temp_file_path = Path(temp_file.name) - cv2.imwrite(str(temp_file_path), frame) - - return temp_file_path + camera = None + try: + # Open camera connection + camera = self._open_camera() + + # Handle autofocus/refocusing + if focus is not None or autofocus is not None: + self._adjust_focus_settings_unlocked(camera, focus, autofocus) + + success, frame = camera.read() + if not success: + raise Exception("Unable to read from camera") + + with tempfile.NamedTemporaryFile( + suffix=".jpg", delete=False + ) as temp_file: + temp_file_path = Path(temp_file.name) + cv2.imwrite(str(temp_file_path), frame) + + return temp_file_path + finally: + # Always close the camera connection + if camera is not None: + self._close_camera(camera) def read_barcode( self, @@ -147,27 +165,9 @@ def read_barcode( return barcode, image_path - def adjust_focus_settings( - self, - focus: Optional[int] = None, - autofocus: Optional[bool] = None, - ) -> None: - """ - Adjust the camera's focus, if necessary/possible, based on the provided parameters. - - Args: - focus: The desired focus value (used if autofocus is disabled). - autofocus: Whether to enable or disable autofocus. - - Raises: - Exception: If camera is not connected. - ValueError: If the focus value is out of range. - """ - with self.camera_lock: - self._adjust_focus_settings_unlocked(focus, autofocus) - def _adjust_focus_settings_unlocked( self, + camera: cv2.VideoCapture, focus: Optional[int] = None, autofocus: Optional[bool] = None, ) -> None: @@ -176,6 +176,7 @@ def _adjust_focus_settings_unlocked( This should only be called when the camera_lock is already held. Args: + camera: The VideoCapture object to adjust focus for. focus: The desired focus value (used if autofocus is disabled). autofocus: Whether to enable or disable autofocus. @@ -183,30 +184,30 @@ def _adjust_focus_settings_unlocked( Exception: If camera is not connected. ValueError: If the focus value is out of range. """ - if self.camera is None or not self.camera.isOpened(): + if camera is None or not camera.isOpened(): raise Exception("Camera is not connected") focus_changed = False if autofocus is not None: - current_autofocus = self.camera.get(cv2.CAP_PROP_AUTOFOCUS) + current_autofocus = camera.get(cv2.CAP_PROP_AUTOFOCUS) if current_autofocus != (1 if autofocus else 0): - self.camera.set(cv2.CAP_PROP_AUTOFOCUS, 1 if autofocus else 0) + camera.set(cv2.CAP_PROP_AUTOFOCUS, 1 if autofocus else 0) focus_changed = True if not autofocus and focus is not None: if focus < 0 or focus > 255: raise ValueError("Focus value must be between 0 and 255.") - current_focus = self.camera.get(cv2.CAP_PROP_FOCUS) + current_focus = camera.get(cv2.CAP_PROP_FOCUS) if current_focus != focus: - self.camera.set(cv2.CAP_PROP_FOCUS, focus) + camera.set(cv2.CAP_PROP_FOCUS, focus) focus_changed = True if focus_changed: # Discard 30 frames to allow focus to stabilize for _ in range(30): - self.camera.read() + camera.read() else: # Discard 5 frames in case the camera needs a moment for startup for _ in range(5): - self.camera.read() + camera.read() diff --git a/src/camera_rest_node.py b/src/camera_rest_node.py index 3487cd4..49d8cf4 100644 --- a/src/camera_rest_node.py +++ b/src/camera_rest_node.py @@ -82,35 +82,35 @@ def startup_handler(self) -> None: # Initialize camera interface self.camera_interface = CameraInterface(self.config.camera_address) - try: - self.camera_interface.connect() - if self.camera_interface.is_connected(): - self.logger.log("Camera node initialized!") - except Exception as e: - self.logger.log_error(f"Failed to connect to camera: {e}") - raise + # Test camera connection + if self.camera_interface.test_connection(): + self.logger.log("Camera node initialized!") + else: + self.logger.log_error("Failed to connect to camera during initialization") + raise Exception("Unable to connect to camera") def state_handler(self) -> None: """Periodically called to update the current state of the node.""" camera_status = "disconnected" try: - if ( - self.camera_interface is not None - and self.camera_interface.is_connected() - ): - camera_status = "connected" - else: - try: - self.camera_interface.connect() - if self.camera_interface.is_connected(): - camera_status = "connected" - except Exception: + if self.camera_interface is not None: + # Test if camera can be connected + if self.camera_interface.test_connection(): + camera_status = "connected" + else: camera_status = "disconnected" if not self.state_error_latch: - self.logger.log_error(traceback.format_exc()) - self.node_status.errors.append(traceback.format_exc()) + self.logger.log_error("Camera connection test failed") + self.node_status.errors.append("Camera connection test failed") self.node_status.errored = True self.state_error_latch = True + except Exception: + camera_status = "disconnected" + if not self.state_error_latch: + self.logger.log_error(traceback.format_exc()) + self.node_status.errors.append(traceback.format_exc()) + self.node_status.errored = True + self.state_error_latch = True finally: if camera_status == "connected": self.state_error_latch = False From 5b07a36d139082c8c1634b8cf3597638125fcddf Mon Sep 17 00:00:00 2001 From: tginsbu1 Date: Wed, 19 Nov 2025 11:15:17 -0600 Subject: [PATCH 4/5] Updated node info --- definitions/camera_node_template.node.info.yaml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/definitions/camera_node_template.node.info.yaml b/definitions/camera_node_template.node.info.yaml index 99ad014..b93046a 100644 --- a/definitions/camera_node_template.node.info.yaml +++ b/definitions/camera_node_template.node.info.yaml @@ -58,13 +58,11 @@ actions: name: read_barcode description: "\n Takes an image and returns the values of any barcodes\ \ present in the image. Camera focus can be adjusted using the provided parameters\ - \ if necessary.\n\n Args:\n camera (cv2.VideoCapture): The\ - \ camera object to adjust focus for.\n focus (Optional[int]): The\ - \ desired focus value (used if autofocus is disabled).\n autofocus\ - \ (Optional[bool]): Whether to enable or disable autofocus.\n\n Returns:\n\ - \ ActionSucceded regardless of if barcode is collected or not.\n\ - \ Barcode field in ActionResult data dictionary will contain 'None'\ - \ if no barcode was collected\n\n " + \ if necessary.\n\n Args:\n focus (Optional[int]): The desired\ + \ focus value (used if autofocus is disabled).\n autofocus (Optional[bool]):\ + \ Whether to enable or disable autofocus.\n\n Returns:\n A\ + \ tuple containing:\n - The barcode string (empty if no barcode\ + \ found)\n - Path to the captured image\n\n " args: focus: name: focus @@ -110,7 +108,7 @@ config: update_node_files: true status_update_interval: 2.0 state_update_interval: 2.0 - node_url: http://127.0.0.1:2000/ + node_url: http://parker.cels.anl.gov:2000/ uvicorn_kwargs: {} camera_address: 0 config_schema: From 2b67a96f9e1c036937f5f9f06e869e9946fa1714 Mon Sep 17 00:00:00 2001 From: tginsbu1 Date: Mon, 24 Nov 2025 13:29:44 -0600 Subject: [PATCH 5/5] Update version, minor tweaks to error handling --- definitions/camera_node_template.node.info.yaml | 4 ++-- definitions/camera_node_template.node.yaml | 4 ++-- src/camera_interface.py | 8 ++++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/definitions/camera_node_template.node.info.yaml b/definitions/camera_node_template.node.info.yaml index b93046a..836471b 100644 --- a/definitions/camera_node_template.node.info.yaml +++ b/definitions/camera_node_template.node.info.yaml @@ -1,9 +1,9 @@ -node_name: camera_node_template.node +node_name: camera_node_template node_id: 01K9T47YGD515GTRJJ0Y2HNR6F node_description: null node_type: device module_name: camera_node -module_version: 2.1.0 +module_version: 3.0.0 capabilities: get_info: true get_state: true diff --git a/definitions/camera_node_template.node.yaml b/definitions/camera_node_template.node.yaml index 1b21def..0a487f4 100644 --- a/definitions/camera_node_template.node.yaml +++ b/definitions/camera_node_template.node.yaml @@ -1,7 +1,7 @@ -node_name: camera_node_template.node +node_name: camera_node_template node_id: 01K9T47YGD515GTRJJ0Y2HNR6F node_description: null node_type: device module_name: camera_node -module_version: 2.1.0 +module_version: 3.0.0 capabilities: null diff --git a/src/camera_interface.py b/src/camera_interface.py index 9e709a2..a1b5e85 100644 --- a/src/camera_interface.py +++ b/src/camera_interface.py @@ -11,9 +11,13 @@ try: from pyzbar.pyzbar import decode -except ImportError: +except Exception: + import traceback + decode = None - print("pyzbar not found, barcode reading functionality will be disabled.") # noqa: T201 + print( # noqa: T201 + f"pyzbar not found, barcode reading functionality will be disabled. Error: {traceback.format_exc()}" + ) class CameraInterface: