diff --git a/.gitignore b/.gitignore index 543d4ba..3403fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,6 @@ cython_debug/ # WEI .wei + +.pdm-python +.vscode 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..836471b 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_id: 01K9T47YGD515GTRJJ0Y2HNR6F +node_description: null node_type: device -module_name: camera_module -module_version: 0.0.1 +module_name: camera_node +module_version: 3.0.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,105 @@ 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 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 + 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://parker.cels.anl.gov: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 +156,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..0a487f4 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_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: 3.0.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..a1b5e85 --- /dev/null +++ b/src/camera_interface.py @@ -0,0 +1,217 @@ +""" +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 Exception: + import traceback + + decode = None + print( # noqa: T201 + f"pyzbar not found, barcode reading functionality will be disabled. Error: {traceback.format_exc()}" + ) + + +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_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 _open_camera(self) -> cv2.VideoCapture: + """ + Open a connection to the camera. This is an internal method. + + Returns: + An opened VideoCapture object. + + Raises: + Exception: If 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 _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 test_connection(self) -> bool: + """ + Test if the camera can be connected to. + + Returns: + True if camera can be connected, False otherwise. + """ + with self.camera_lock: + 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 + ) -> 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: + 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, + 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_unlocked( + self, + camera: cv2.VideoCapture, + 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: + 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. + + Raises: + Exception: If camera is not connected. + ValueError: If the focus value is out of range. + """ + if camera is None or not camera.isOpened(): + raise Exception("Camera is not connected") + + focus_changed = False + + if autofocus is not None: + 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: + 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: + # Discard 30 frames to allow focus to stabilize + for _ in range(30): + camera.read() + else: + # Discard 5 frames in case the camera needs a moment for startup + for _ in range(5): + camera.read() diff --git a/src/camera_rest_node.py b/src/camera_rest_node.py index b2cd0c6..49d8cf4 100644 --- a/src/camera_rest_node.py +++ b/src/camera_rest_node.py @@ -2,17 +2,18 @@ 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, Optional, Union +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 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 + +from camera_interface import CameraInterface class CameraNodeConfig(RestNodeConfig): @@ -21,13 +22,23 @@ 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""" 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.""" @@ -69,19 +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(): + # Initialize camera interface + self.camera_interface = CameraInterface(self.config.camera_address) + # 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") - self.logger.log("Camera node initialized!") 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.") + camera_status = "disconnected" + try: + 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("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 + self.node_status.errored = False + self.node_state = {"camera_status": camera_status} @action def take_picture( @@ -89,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( @@ -116,7 +143,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"], ]: @@ -124,80 +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 = None - - 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 + if self.camera_interface is None: + raise Exception("Camera interface is not initialized") - return barcode, image_path - - 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__": 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" },