Merge branch 'opencv-camera-switcher' into 'v3'
Add functionality to switch OpenCV camera without restarting the server. See merge request openflexure/openflexure-microscope-server!487
This commit is contained in:
commit
ae8285ed28
4 changed files with 158 additions and 19 deletions
|
|
@ -1,9 +1,6 @@
|
||||||
{
|
{
|
||||||
"things": {
|
"things": {
|
||||||
"camera": {
|
"camera": "openflexure_microscope_server.things.camera.opencv:OpenCVCamera",
|
||||||
"class": "openflexure_microscope_server.things.camera.opencv:OpenCVCamera",
|
|
||||||
"kwargs": {"camera_index": 0}
|
|
||||||
},
|
|
||||||
"system": "openflexure_microscope_server.things.system:OpenFlexureSystem"
|
"system": "openflexure_microscope_server.things.system:OpenFlexureSystem"
|
||||||
},
|
},
|
||||||
"settings_folder": "./openflexure/settings/",
|
"settings_folder": "./openflexure/settings/",
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ dev = [
|
||||||
pi = [
|
pi = [
|
||||||
"picamera2~=0.3.27",
|
"picamera2~=0.3.27",
|
||||||
]
|
]
|
||||||
|
manual = [
|
||||||
|
"cv2-enumerate-cameras~=1.3.3",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
openflexure-microscope-server = "openflexure_microscope_server.server:serve_from_cli"
|
openflexure-microscope-server = "openflexure_microscope_server.server:serve_from_cli"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ from PIL import Image
|
||||||
import labthings_fastapi as lt
|
import labthings_fastapi as lt
|
||||||
from labthings_fastapi.types.numpy import NDArray
|
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__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -27,25 +29,31 @@ LOGGER = logging.getLogger(__name__)
|
||||||
class OpenCVCamera(BaseCamera):
|
class OpenCVCamera(BaseCamera):
|
||||||
"""A Thing that provides and interface to an OpenCV Camera."""
|
"""A Thing that provides and interface to an OpenCV Camera."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None:
|
||||||
self, thing_server_interface: lt.ThingServerInterface, camera_index: int = 0
|
|
||||||
) -> None:
|
|
||||||
"""Iniatilise the thing storing the index of the camera to use.
|
"""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)
|
super().__init__(thing_server_interface)
|
||||||
self.camera_index = camera_index
|
|
||||||
|
self.cameras: dict[str, int] = {}
|
||||||
self._capture_thread: Optional[Thread] = None
|
self._capture_thread: Optional[Thread] = None
|
||||||
self._capture_enabled = False
|
self._capture_enabled = False
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
def __enter__(self) -> Self:
|
||||||
"""Start the capture thread when the Thing context manager is opened."""
|
"""Start the capture thread when the Thing context manager is opened."""
|
||||||
super().__enter__()
|
super().__enter__()
|
||||||
self.cap = cv2.VideoCapture(self.camera_index)
|
all_camera_ids = opencv_utils.find_all_cameras()
|
||||||
self._capture_enabled = True
|
if not all_camera_ids:
|
||||||
self._capture_thread = Thread(target=self._capture_frames)
|
raise RuntimeError("No cameras available.")
|
||||||
self._capture_thread.start()
|
self.cameras = opencv_utils.identify_cameras(all_camera_ids)
|
||||||
|
if self.camera_name not in self.cameras:
|
||||||
|
if self.camera_name:
|
||||||
|
self.logger.warning(f"{self.camera_name} not found.")
|
||||||
|
self._camera_name = next(iter(self.cameras))
|
||||||
|
|
||||||
|
self._start_stream()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(
|
def __exit__(
|
||||||
|
|
@ -58,12 +66,49 @@ class OpenCVCamera(BaseCamera):
|
||||||
|
|
||||||
Before releasing the camera the capture thread is closed.
|
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 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
|
||||||
|
self._camera_name = value
|
||||||
|
if value not in self.cameras:
|
||||||
|
raise ValueError(f"{value} is not a valid camera name.")
|
||||||
|
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], opencv_utils.BACKEND
|
||||||
|
)
|
||||||
|
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:
|
if self.stream_active:
|
||||||
self._capture_enabled = False
|
self._capture_enabled = False
|
||||||
if self._capture_thread is not None:
|
if self._capture_thread is not None:
|
||||||
self._capture_thread.join()
|
self._capture_thread.join()
|
||||||
self.cap.release()
|
self.cap.release()
|
||||||
super().__exit__(exc_type, exc_value, traceback)
|
|
||||||
|
|
||||||
@lt.property
|
@lt.property
|
||||||
def stream_active(self) -> bool:
|
def stream_active(self) -> bool:
|
||||||
|
|
@ -76,7 +121,7 @@ class OpenCVCamera(BaseCamera):
|
||||||
while self._capture_enabled:
|
while self._capture_enabled:
|
||||||
ret, frame = self.cap.read()
|
ret, frame = self.cap.read()
|
||||||
if not ret:
|
if not ret:
|
||||||
LOGGER.error(f"Failed to capture frame from camera {self.camera_index}")
|
LOGGER.error("Failed to capture frame from camera.")
|
||||||
break
|
break
|
||||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||||
self.mjpeg_stream.add_frame(jpeg)
|
self.mjpeg_stream.add_frame(jpeg)
|
||||||
|
|
@ -107,9 +152,7 @@ class OpenCVCamera(BaseCamera):
|
||||||
LOGGER.warning(f"OpenCV camera doesn't respect {stream_name=}")
|
LOGGER.warning(f"OpenCV camera doesn't respect {stream_name=}")
|
||||||
ret, frame = self.cap.read()
|
ret, frame = self.cap.read()
|
||||||
if not ret:
|
if not ret:
|
||||||
raise RuntimeError(
|
raise RuntimeError("Failed to capture frame from camera.")
|
||||||
f"Failed to capture frame from camera {self.camera_index}"
|
|
||||||
)
|
|
||||||
return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||||
|
|
||||||
def capture_image(
|
def capture_image(
|
||||||
|
|
@ -125,3 +168,19 @@ class OpenCVCamera(BaseCamera):
|
||||||
LOGGER.warning("OpenCV camera has no wait option. Use None.")
|
LOGGER.warning("OpenCV camera has no wait option. Use None.")
|
||||||
LOGGER.warning(f"OpenCV camera doesn't respect {stream_name=}")
|
LOGGER.warning(f"OpenCV camera doesn't respect {stream_name=}")
|
||||||
return Image.fromarray(self.capture_array())
|
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.
|
||||||
|
|
||||||
|
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,
|
||||||
|
"camera_name",
|
||||||
|
label="Camera",
|
||||||
|
options={key: key for key in self.cameras},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""Utilities for managing CV2 cameras."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cv2_enumerate_cameras.camera_info import CameraInfo
|
||||||
|
|
||||||
|
enumerate_cameras: Optional[Callable[[int], list[CameraInfo]]]
|
||||||
|
# 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
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
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(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
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
# 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
|
||||||
|
# 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:
|
||||||
|
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 as a fallback option.
|
||||||
|
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
|
||||||
|
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()}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue