From 4dfb2524393dc4c1a00173bc628aacab2765e575 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 17 Feb 2026 17:08:19 +0000 Subject: [PATCH 01/12] Add functionality to switch OpenCV camera without restarting the server. --- ofm_config_manual.json | 5 +- .../things/camera/opencv.py | 72 +++++++++++++++---- .../things/camera/opencv_utils.py | 48 +++++++++++++ 3 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 src/openflexure_microscope_server/things/camera/opencv_utils.py diff --git a/ofm_config_manual.json b/ofm_config_manual.json index c8b878ec..cff014e4 100644 --- a/ofm_config_manual.json +++ b/ofm_config_manual.json @@ -1,9 +1,6 @@ { "things": { - "camera": { - "class": "openflexure_microscope_server.things.camera.opencv:OpenCVCamera", - "kwargs": {"camera_index": 0} - }, + "camera": "openflexure_microscope_server.things.camera.opencv:OpenCVCamera", "system": "openflexure_microscope_server.things.system:OpenFlexureSystem" }, "settings_folder": "./openflexure/settings/", diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index 8295b8f8..f427e837 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -19,7 +19,9 @@ from PIL import Image import labthings_fastapi as lt from labthings_fastapi.types.numpy import NDArray -from . import BaseCamera +from openflexure_microscope_server.ui import PropertyControl, property_control_for + +from . import BaseCamera, opencv_utils LOGGER = logging.getLogger(__name__) @@ -27,25 +29,30 @@ LOGGER = logging.getLogger(__name__) class OpenCVCamera(BaseCamera): """A Thing that provides and interface to an OpenCV Camera.""" - def __init__( - self, thing_server_interface: lt.ThingServerInterface, camera_index: int = 0 - ) -> None: + def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None: """Iniatilise the thing storing the index of the camera to use. :param camera_index: The index of the camera to use for the microscope. """ super().__init__(thing_server_interface) - self.camera_index = camera_index + + self.cameras: dict[str, int] = {} self._capture_thread: Optional[Thread] = None self._capture_enabled = False def __enter__(self) -> Self: """Start the capture thread when the Thing context manager is opened.""" super().__enter__() - self.cap = cv2.VideoCapture(self.camera_index) - self._capture_enabled = True - self._capture_thread = Thread(target=self._capture_frames) - self._capture_thread.start() + all_camera_ids = opencv_utils.find_all_cameras() + if not all_camera_ids: + raise RuntimeError("No cameras available.") + self.cameras = opencv_utils.identify_cameras(all_camera_ids) + if self.camera_name not in self.cameras: + if self.camera_name: + self.logger.warning("{self.camera_name} not found.") + self._camera_name = next(iter(self.cameras)) + + self._start_stream() return self def __exit__( @@ -58,12 +65,40 @@ class OpenCVCamera(BaseCamera): Before releasing the camera the capture thread is closed. """ + self._stop_stream() + super().__exit__(exc_type, exc_value, traceback) + + _camera_name = "" + + @lt.setting + def camera_name(self) -> str: + """The name of the camera.""" + return self._camera_name + + @camera_name.setter + def _set_camera_name(self, value: str) -> None: + """Set the name of the camera.""" + if value not in self.cameras: + raise ValueError("{value} is not a valid camera name.") + self._camera_name = value + self._start_stream() + + def _start_stream(self) -> None: + """Start the camera stream or restart if running.""" + if self.stream_active: + self._stop_stream() + self.cap = cv2.VideoCapture(self.cameras[self.camera_name]) + self._capture_enabled = True + self._capture_thread = Thread(target=self._capture_frames) + self._capture_thread.start() + + def _stop_stream(self) -> None: + """Stop the camera stream.""" if self.stream_active: self._capture_enabled = False if self._capture_thread is not None: self._capture_thread.join() self.cap.release() - super().__exit__(exc_type, exc_value, traceback) @lt.property def stream_active(self) -> bool: @@ -76,7 +111,7 @@ class OpenCVCamera(BaseCamera): while self._capture_enabled: ret, frame = self.cap.read() if not ret: - LOGGER.error(f"Failed to capture frame from camera {self.camera_index}") + LOGGER.error("Failed to capture frame from camera.") break jpeg = cv2.imencode(".jpg", frame)[1].tobytes() self.mjpeg_stream.add_frame(jpeg) @@ -107,9 +142,7 @@ class OpenCVCamera(BaseCamera): LOGGER.warning(f"OpenCV camera doesn't respect {stream_name=}") ret, frame = self.cap.read() if not ret: - raise RuntimeError( - f"Failed to capture frame from camera {self.camera_index}" - ) + raise RuntimeError("Failed to capture frame from camera.") return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) def capture_image( @@ -125,3 +158,14 @@ class OpenCVCamera(BaseCamera): LOGGER.warning("OpenCV camera has no wait option. Use None.") LOGGER.warning(f"OpenCV camera doesn't respect {stream_name=}") return Image.fromarray(self.capture_array()) + + @lt.property + def manual_camera_settings(self) -> list[PropertyControl]: + """The camera settings to expose as property controls in the settings panel.""" + return [ + property_control_for( + self, + "camera_name", + label="Camera name", + ), + ] diff --git a/src/openflexure_microscope_server/things/camera/opencv_utils.py b/src/openflexure_microscope_server/things/camera/opencv_utils.py new file mode 100644 index 00000000..160d50a3 --- /dev/null +++ b/src/openflexure_microscope_server/things/camera/opencv_utils.py @@ -0,0 +1,48 @@ +"""Utilities for managing CV2 cameras.""" + +import sys + +import cv2 + +if sys.platform.startswith("win"): + BACKEND = cv2.CAP_DSHOW +elif sys.platform.startswith("linux"): + BACKEND = cv2.CAP_V4L2 +elif sys.platform == "darwin": + BACKEND = cv2.CAP_AVFOUNDATION +else: + raise RuntimeError("Unsupported platform {sys.platform}") + +MAX_CAMERAS = 12 + + +def find_all_cameras() -> list[int]: + """Find all accessible USB cameras on the device.""" + available = [] + for i in range(MAX_CAMERAS): + cap = cv2.VideoCapture(i, BACKEND) + + if cap.isOpened(): + available.append(i) + cap.release() + return available + + +def identify_cameras(camera_ids: list[int]) -> dict[str, int]: + """For a list of camera IDs return a dictionary of name -> ID.""" + # Set default names mapping -d -> camera for easy replacement if names are found. + name_dict = {n: f"Unknown Camera {n}" for n in camera_ids} + # If linux try to read video4linux name + if BACKEND == cv2.CAP_V4L2: + for camera_id in camera_ids: + try: + if not isinstance(camera_id, int): + raise TypeError("Camera ID must be an integer.") + # Linux only so just use direct path. + path = f"/sys/class/video4linux/video{camera_id}/name" + with open(path, "r") as f_obj: + name_dict[camera_id] = f_obj.read().strip() + except IOError: + pass + # Swap order for return + return {name: cam_id for cam_id, name in name_dict.items()} From 8ab85e2d7fcc0166e9706278f34642cdf5c6c6f0 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 19 Feb 2026 16:48:35 +0000 Subject: [PATCH 02/12] Update docstring for OpenCV camera --- src/openflexure_microscope_server/things/camera/opencv.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index f427e837..d12d4f6b 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -32,7 +32,8 @@ class OpenCVCamera(BaseCamera): def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None: """Iniatilise the thing storing the index of the camera to use. - :param camera_index: The index of the camera to use for the microscope. + :param thing_server_interface: The thing server interface to be passed to to + the parent class. """ super().__init__(thing_server_interface) From a22bf2b7119e7281057a505b53f44de7d9847d93 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 19 Feb 2026 17:15:58 +0000 Subject: [PATCH 03/12] OpenCV camera: Fix startup behaviour loading camera from settings. --- .../things/camera/opencv.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index d12d4f6b..36d4df3d 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -79,9 +79,16 @@ class OpenCVCamera(BaseCamera): @camera_name.setter def _set_camera_name(self, value: str) -> None: """Set the name of the camera.""" + if not self.cameras: + # Don't try to validate if cameras dict is empty, just set the value. + # As this is the startup behaviour. On __enter__ we check if the + # initial camera is valid and that at least 1 camera exists. + self._camera_name = value + # Return so we don't try to start the stream + return + if value not in self.cameras: - raise ValueError("{value} is not a valid camera name.") - self._camera_name = value + raise ValueError(f"{value} is not a valid camera name.") self._start_stream() def _start_stream(self) -> None: From 095988838b40890f1e33b51a634264b9a3851680 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 19 Feb 2026 17:16:40 +0000 Subject: [PATCH 04/12] Use dropdown for selecting camera (OpenCV camera) --- src/openflexure_microscope_server/things/camera/opencv.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index 36d4df3d..359112de 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -174,6 +174,7 @@ class OpenCVCamera(BaseCamera): property_control_for( self, "camera_name", - label="Camera name", + label="Camera", + options={key: key for key in self.cameras}, ), ] From b1d84492cccb5688fdd47625faba6c7ea1dfa970 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 24 Feb 2026 18:21:43 +0000 Subject: [PATCH 05/12] Set OpenCV backend when switching cameras. --- src/openflexure_microscope_server/things/camera/opencv.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index 359112de..c01e1637 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -95,7 +95,9 @@ class OpenCVCamera(BaseCamera): """Start the camera stream or restart if running.""" if self.stream_active: self._stop_stream() - self.cap = cv2.VideoCapture(self.cameras[self.camera_name]) + self.cap = cv2.VideoCapture( + self.cameras[self.camera_name], opencv_utils.BACKEND + ) self._capture_enabled = True self._capture_thread = Thread(target=self._capture_frames) self._capture_thread.start() From c9117b62a00606423d94121f56c2b1db78e78054 Mon Sep 17 00:00:00 2001 From: williamwadsworth Date: Thu, 26 Feb 2026 12:48:55 +0000 Subject: [PATCH 06/12] camera switching for opencv camera From discussion with @julianstirling --- src/openflexure_microscope_server/things/camera/opencv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index c01e1637..99c8f408 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -86,7 +86,7 @@ class OpenCVCamera(BaseCamera): self._camera_name = value # Return so we don't try to start the stream return - + self._camera_name = value if value not in self.cameras: raise ValueError(f"{value} is not a valid camera name.") self._start_stream() From 40f970130fd3a91a521795ebdce5d5fdb315644d Mon Sep 17 00:00:00 2001 From: williamwadsworth Date: Thu, 26 Feb 2026 14:02:03 +0000 Subject: [PATCH 07/12] human readable opencv camera names in windows --- .../things/camera/opencv_utils.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/openflexure_microscope_server/things/camera/opencv_utils.py b/src/openflexure_microscope_server/things/camera/opencv_utils.py index 160d50a3..d93decd2 100644 --- a/src/openflexure_microscope_server/things/camera/opencv_utils.py +++ b/src/openflexure_microscope_server/things/camera/opencv_utils.py @@ -3,6 +3,7 @@ import sys import cv2 +from cv2_enumerate_cameras import enumerate_cameras if sys.platform.startswith("win"): BACKEND = cv2.CAP_DSHOW @@ -44,5 +45,13 @@ def identify_cameras(camera_ids: list[int]) -> dict[str, int]: name_dict[camera_id] = f_obj.read().strip() except IOError: pass + + if BACKEND == cv2.CAP_DSHOW: + try: + for camera_info in enumerate_cameras(cv2.CAP_DSHOW): + name_dict[camera_info.index] = camera_info.name + except IOError: + pass + # Swap order for return return {name: cam_id for cam_id, name in name_dict.items()} From f551fd5f28eb1458a6178a0ed6d4862fea326442 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 26 Feb 2026 14:53:20 +0000 Subject: [PATCH 08/12] Use cv2_enumerate_cameras by default if installed for naming openCV cameras --- pyproject.toml | 3 ++ .../things/camera/opencv_utils.py | 35 +++++++++++++------ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5f52963b..f6f7c439 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,9 @@ dev = [ pi = [ "picamera2~=0.3.27", ] +manual = [ + "cv2-enumerate-cameras~=1.3.3", +] [project.scripts] openflexure-microscope-server = "openflexure_microscope_server.server:serve_from_cli" diff --git a/src/openflexure_microscope_server/things/camera/opencv_utils.py b/src/openflexure_microscope_server/things/camera/opencv_utils.py index d93decd2..e18117ee 100644 --- a/src/openflexure_microscope_server/things/camera/opencv_utils.py +++ b/src/openflexure_microscope_server/things/camera/opencv_utils.py @@ -1,9 +1,20 @@ """Utilities for managing CV2 cameras.""" +import logging import sys +from typing import Callable, Optional import cv2 -from cv2_enumerate_cameras import enumerate_cameras + +try: + from cv2_enumerate_cameras.camera_info import CameraInfo + + enumerate_cameras: Optional[Callable[[int], list[CameraInfo]]] + from cv2_enumerate_cameras import enumerate_cameras +except ModuleNotFoundError: + enumerate_cameras = None + +LOGGER = logging.getLogger(__name__) if sys.platform.startswith("win"): BACKEND = cv2.CAP_DSHOW @@ -33,8 +44,14 @@ def identify_cameras(camera_ids: list[int]) -> dict[str, int]: """For a list of camera IDs return a dictionary of name -> ID.""" # Set default names mapping -d -> camera for easy replacement if names are found. name_dict = {n: f"Unknown Camera {n}" for n in camera_ids} - # If linux try to read video4linux name - if BACKEND == cv2.CAP_V4L2: + + # enumerate cameras works for all backends if it is installed: + if enumerate_cameras is not None: + for camera_info in enumerate_cameras(BACKEND): + if camera_info.index in name_dict: + name_dict[camera_info.index] = camera_info.name + elif BACKEND == cv2.CAP_V4L2: + # If linux try to read video4linux name for camera_id in camera_ids: try: if not isinstance(camera_id, int): @@ -45,13 +62,11 @@ def identify_cameras(camera_ids: list[int]) -> dict[str, int]: name_dict[camera_id] = f_obj.read().strip() except IOError: pass - - if BACKEND == cv2.CAP_DSHOW: - try: - for camera_info in enumerate_cameras(cv2.CAP_DSHOW): - name_dict[camera_info.index] = camera_info.name - except IOError: - pass + else: + LOGGER.warning( + "Cannot determine camera names. Please install the Optional `manual` " + "dependencies." + ) # Swap order for return return {name: cam_id for cam_id, name in name_dict.items()} From efad4631fb4fb11728118591c4fdf4093765a32f Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 26 Feb 2026 15:46:40 +0000 Subject: [PATCH 09/12] Tweak types --- .../things/camera/opencv_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/opencv_utils.py b/src/openflexure_microscope_server/things/camera/opencv_utils.py index e18117ee..dc9b7651 100644 --- a/src/openflexure_microscope_server/things/camera/opencv_utils.py +++ b/src/openflexure_microscope_server/things/camera/opencv_utils.py @@ -10,7 +10,9 @@ try: from cv2_enumerate_cameras.camera_info import CameraInfo enumerate_cameras: Optional[Callable[[int], list[CameraInfo]]] - from cv2_enumerate_cameras import enumerate_cameras + # MyPy thinks enumerate_cameras is being redefined. We just needed to set the type + # before defining. + from cv2_enumerate_cameras import enumerate_cameras # type: ignore[no-redef] except ModuleNotFoundError: enumerate_cameras = None From 9e48f13d6fa8e830321e180c3f052f064d3a2cd7 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 2 Mar 2026 11:02:35 +0000 Subject: [PATCH 10/12] Apply suggestions from code review of branch opencv-camera-switcher Co-authored-by: Joe Knapper --- .../things/camera/opencv.py | 10 +++++++--- .../things/camera/opencv_utils.py | 8 ++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index 99c8f408..efa7ed85 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -50,7 +50,7 @@ class OpenCVCamera(BaseCamera): self.cameras = opencv_utils.identify_cameras(all_camera_ids) if self.camera_name not in self.cameras: if self.camera_name: - self.logger.warning("{self.camera_name} not found.") + self.logger.warning(f"{self.camera_name} not found.") self._camera_name = next(iter(self.cameras)) self._start_stream() @@ -81,7 +81,7 @@ class OpenCVCamera(BaseCamera): """Set the name of the camera.""" if not self.cameras: # Don't try to validate if cameras dict is empty, just set the value. - # As this is the startup behaviour. On __enter__ we check if the + # As this is the startup behaviour, on __enter__ we check if the # initial camera is valid and that at least 1 camera exists. self._camera_name = value # Return so we don't try to start the stream @@ -171,7 +171,11 @@ class OpenCVCamera(BaseCamera): @lt.property def manual_camera_settings(self) -> list[PropertyControl]: - """The camera settings to expose as property controls in the settings panel.""" + """The camera settings to expose as property controls in the settings panel. + + The options for the camera selector are populated with camera names once the + server starts and available cameras are have been detected. + """ return [ property_control_for( self, diff --git a/src/openflexure_microscope_server/things/camera/opencv_utils.py b/src/openflexure_microscope_server/things/camera/opencv_utils.py index dc9b7651..e85ac7d7 100644 --- a/src/openflexure_microscope_server/things/camera/opencv_utils.py +++ b/src/openflexure_microscope_server/things/camera/opencv_utils.py @@ -25,7 +25,7 @@ elif sys.platform.startswith("linux"): elif sys.platform == "darwin": BACKEND = cv2.CAP_AVFOUNDATION else: - raise RuntimeError("Unsupported platform {sys.platform}") + raise RuntimeError(f"Unsupported platform {sys.platform}") MAX_CAMERAS = 12 @@ -44,7 +44,11 @@ def find_all_cameras() -> list[int]: def identify_cameras(camera_ids: list[int]) -> dict[str, int]: """For a list of camera IDs return a dictionary of name -> ID.""" - # Set default names mapping -d -> camera for easy replacement if names are found. + # When first creating the mapping the mapping with default names it goes from + # id -> camera name. This makes it easy to replace the name (based on a fixed camera + # id as the key) if the name is then found. + # Before returning this is swapped to be a mapping from camera name -> id. As this + # is what is needed to switch the cameras based on a name. name_dict = {n: f"Unknown Camera {n}" for n in camera_ids} # enumerate cameras works for all backends if it is installed: From 2fee313d42238ad5ace9c444acfd5611d6412ed1 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 2 Mar 2026 11:24:32 +0000 Subject: [PATCH 11/12] Apply suggestions from code review of branch opencv-camera-switcher --- .../things/camera/opencv_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/opencv_utils.py b/src/openflexure_microscope_server/things/camera/opencv_utils.py index e85ac7d7..472af386 100644 --- a/src/openflexure_microscope_server/things/camera/opencv_utils.py +++ b/src/openflexure_microscope_server/things/camera/opencv_utils.py @@ -27,6 +27,8 @@ elif sys.platform == "darwin": else: raise RuntimeError(f"Unsupported platform {sys.platform}") +# Max cameras is set to balance finding all cameras with not taking too long to start up. +# Note that due to extra camera modes in Linux 1 camera can take up multiple camera numbers. MAX_CAMERAS = 12 @@ -44,7 +46,7 @@ def find_all_cameras() -> list[int]: def identify_cameras(camera_ids: list[int]) -> dict[str, int]: """For a list of camera IDs return a dictionary of name -> ID.""" - # When first creating the mapping the mapping with default names it goes from + # When first creating the mapping with default names it goes from # id -> camera name. This makes it easy to replace the name (based on a fixed camera # id as the key) if the name is then found. # Before returning this is swapped to be a mapping from camera name -> id. As this From 61e4c8d8d7483ac814e91cad522a1c62ce754260 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Mon, 2 Mar 2026 12:34:06 +0000 Subject: [PATCH 12/12] Apply suggestions from code review of branch opencv-camera-switcher Co-authored-by: Julian Stirling --- src/openflexure_microscope_server/things/camera/opencv_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/opencv_utils.py b/src/openflexure_microscope_server/things/camera/opencv_utils.py index 472af386..ee331ced 100644 --- a/src/openflexure_microscope_server/things/camera/opencv_utils.py +++ b/src/openflexure_microscope_server/things/camera/opencv_utils.py @@ -59,7 +59,7 @@ def identify_cameras(camera_ids: list[int]) -> dict[str, int]: if camera_info.index in name_dict: name_dict[camera_info.index] = camera_info.name elif BACKEND == cv2.CAP_V4L2: - # If linux try to read video4linux name + # If Linux, try to read video4linux name as a fallback option. for camera_id in camera_ids: try: if not isinstance(camera_id, int):