Add functionality to switch OpenCV camera without restarting the server.
This commit is contained in:
parent
9a198cc5a3
commit
4dfb252439
3 changed files with 107 additions and 18 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/",
|
||||||
|
|
|
||||||
|
|
@ -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,30 @@ 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 camera_index: The index of the camera to use for the microscope.
|
||||||
"""
|
"""
|
||||||
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("{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 +65,40 @@ 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 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:
|
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 +111,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 +142,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 +158,14 @@ 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."""
|
||||||
|
return [
|
||||||
|
property_control_for(
|
||||||
|
self,
|
||||||
|
"camera_name",
|
||||||
|
label="Camera name",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue