Update cameras inline with base camera rebase

This commit is contained in:
Julian Stirling 2026-05-27 23:26:56 +01:00
parent feea7135b1
commit e3361ebec8
3 changed files with 180 additions and 118 deletions

View file

@ -11,7 +11,7 @@ from __future__ import annotations
import logging import logging
from threading import Thread from threading import Thread
from types import TracebackType from types import TracebackType
from typing import Literal, Optional, Self from typing import Optional, Self
import cv2 import cv2
from PIL import Image from PIL import Image
@ -136,41 +136,33 @@ class OpenCVCamera(BaseCamera):
@lt.action @lt.action
def discard_frames(self) -> None: def discard_frames(self) -> None:
"""Discard frames so that the next frame captured is fresh.""" """Discard frames so that the next frame captured is fresh."""
self.capture_array() self.capture_as_array()
@lt.action @lt.action
def capture_array( def capture_as_array(
self, self,
stream_name: Literal["main", "lores", "raw", "full"] = "full", capture_mode: str = "standard",
wait: Optional[float] = None, raw: bool = False,
) -> NDArray: ) -> NDArray:
"""Acquire one image from the camera and return as an array. """Acquire one image from the camera and return as an array."""
if raw is True:
This function will produce a nested list containing an uncompressed RGB image. raise NotImplementedError(
It's likely to be highly inefficient - raw and/or uncompressed captures using "OpenCV camera camera doesn't support raw capture."
binary image formats will be added in due course. )
""" # Warn if the capture mode is incorrect, but don't read the cooerced value as
if wait is not None: # this camera only supports one mode.
LOGGER.warning("OpenCV camera has no wait option. Use None.") self._validate_capture_mode(capture_mode)
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("Failed to capture frame from camera.") raise RuntimeError("Failed to capture frame from camera.")
return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
def capture_image( def _capture_image(self, capture_mode: str = "standard") -> Image.Image:
self, """Acquire one image from the camera and return as a PIL image."""
stream_name: Literal["main", "lores", "full"] = "main", # Warn if the capture mode is incorrect, but don't read the cooerced value as
wait: Optional[float] = None, # this camera only supports one mode.
) -> Image.Image: self._validate_capture_mode(capture_mode)
"""Acquire one image from the camera and return as a PIL image. return Image.fromarray(self.capture_as_array())
This function will produce a JPEG image.
"""
if wait is not None:
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 @lt.property
def manual_camera_settings(self) -> list[PropertyControl]: def manual_camera_settings(self) -> list[PropertyControl]:

View file

@ -54,7 +54,7 @@ from openflexure_microscope_server.ui import (
property_control_for, property_control_for,
) )
from . import BaseCamera, StreamingMode from . import BaseCamera, CaptureMode, StreamingMode
from . import picamera_recalibrate_utils as recalibrate_utils from . import picamera_recalibrate_utils as recalibrate_utils
from . import picamera_tuning_file_utils as tf_utils from . import picamera_tuning_file_utils as tf_utils
@ -112,6 +112,20 @@ class PiCamera2StreamingMode(StreamingMode):
return {"output_size": self.sensor_mode_resolution, "bit_depth": self.bit_depth} return {"output_size": self.sensor_mode_resolution, "bit_depth": self.bit_depth}
class PiCamera2CaptureMode(CaptureMode):
"""Capture mode configuration for the PiCamera2."""
streaming_mode: Optional[str]
"""The streaming mode the camera should be in for capture.
Use None to use the active mode.
"""
stream_name: Literal["lores", "main"]
"""The Picamera stream name to capture."""
timeout: float = 5.0
"""The timeout. A number above 10 risks hardlocking the Pi."""
class StreamingPiCamera2(BaseCamera, ABC): class StreamingPiCamera2(BaseCamera, ABC):
"""A Thing that provides and interface to the Raspberry Pi Camera. """A Thing that provides and interface to the Raspberry Pi Camera.
@ -382,6 +396,7 @@ class StreamingPiCamera2(BaseCamera, ABC):
* On closing of the context manager the stream will restart. * On closing of the context manager the stream will restart.
""" """
already_streaming = self.stream_active already_streaming = self.stream_active
streaming_mode = self.streaming_mode
with self._picamera_lock: with self._picamera_lock:
if pause_stream and already_streaming: if pause_stream and already_streaming:
self._stop_streaming(stop_web_stream=False) self._stop_streaming(stop_web_stream=False)
@ -389,7 +404,7 @@ class StreamingPiCamera2(BaseCamera, ABC):
yield self._picamera yield self._picamera
finally: finally:
if pause_stream and already_streaming: if pause_stream and already_streaming:
self._start_streaming() self._start_streaming(streaming_mode)
def __exit__( def __exit__(
self, self,
@ -409,6 +424,31 @@ class StreamingPiCamera2(BaseCamera, ABC):
def streaming_modes(self) -> Mapping[str, PiCamera2StreamingMode]: def streaming_modes(self) -> Mapping[str, PiCamera2StreamingMode]:
"""Modes the camera can stream in.""" """Modes the camera can stream in."""
@abstractmethod
@lt.property
def capture_modes(self) -> Mapping[str, PiCamera2CaptureMode]:
"""Modes the camera can use for capturing."""
def _create_picam_config_from_mode_info(
self,
picam: Picamera2,
mode_info: PiCamera2StreamingMode,
) -> dict[str, Any]:
"""Create the dictionary to pass to ``Picamera2.configure``."""
controls = self._get_persistent_controls()
if mode_info.scaler_crop is not None:
controls["ScalerCrop"] = mode_info.scaler_crop
stream_config = picam.create_video_configuration(
main={"size": mode_info.main_resolution},
lores={"size": mode_info.lores_resolution, "format": "YUV420"},
sensor=mode_info.sensor_mode_dict,
controls=controls,
)
stream_config["buffer_count"] = mode_info.buffer_count
return stream_config
def _start_streaming(self, mode: str = "default") -> None: def _start_streaming(self, mode: str = "default") -> None:
"""Start the MJPEG stream. This is where persistent controls are sent to camera. """Start the MJPEG stream. This is where persistent controls are sent to camera.
@ -426,23 +466,16 @@ class StreamingPiCamera2(BaseCamera, ABC):
self.streaming_mode = mode self.streaming_mode = mode
mode_info = self.streaming_modes[mode] mode_info = self.streaming_modes[mode]
controls = self._get_persistent_controls()
if mode_info.scaler_crop is not None:
controls["ScalerCrop"] = mode_info.scaler_crop
with self._streaming_picamera() as picam: with self._streaming_picamera() as picam:
try: try:
if picam.started: if picam.started:
picam.stop() picam.stop()
picam.stop_encoder() # make sure there are no other encoders going picam.stop_encoder() # make sure there are no other encoders going
stream_config = picam.create_video_configuration( stream_config = self._create_picam_config_from_mode_info(
main={"size": mode_info.main_resolution}, picam=picam,
lores={"size": mode_info.lores_resolution, "format": "YUV420"}, mode_info=mode_info,
sensor=mode_info.sensor_mode_dict,
controls=controls,
) )
stream_config["buffer_count"] = mode_info.buffer_count
picam.configure(stream_config) picam.configure(stream_config)
LOGGER.info("Starting picamera MJPEG stream...") LOGGER.info("Starting picamera MJPEG stream...")
stream_name = "lores" if mode_info.use_lores_as_preview else "main" stream_name = "lores" if mode_info.use_lores_as_preview else "main"
@ -487,65 +520,58 @@ class StreamingPiCamera2(BaseCamera, ABC):
cam.capture_metadata() cam.capture_metadata()
@contextmanager @contextmanager
def _switch_to_single_capture_mode(self) -> Iterator[Picamera2]: def _ensure_mode_for_capture(
"""Get the picamera lock, pause stream and switch into still capture config. self, capture_mode_info: PiCamera2CaptureMode
) -> Iterator[Picamera2]:
"""Ensure in correct mode for capture.
Restarts stream when complete. If the camera is already in the correct mode, the stream isn't paused and
this is the same as using ``self._streaming_picamera().
Otherwise, pause stream, and switch switch mode. Mode is reset and stream
restarts stream after the context manager closes.
""" """
mode_info = self.streaming_modes["full_resolution"] required_streaming_mode = capture_mode_info.streaming_mode
with self._streaming_picamera(pause_stream=True) as cam: if (
LOGGER.debug("Reconfiguring camera for full resolution capture") required_streaming_mode is None
cam.configure( or required_streaming_mode == self.streaming_mode
cam.create_still_configuration(sensor=mode_info.sensor_mode_dict) ):
) with self._streaming_picamera() as cam:
cam.start() yield cam
time.sleep(self._sensor_info.short_pause) else:
yield cam streaming_mode_info = self.streaming_modes[required_streaming_mode]
with self._streaming_picamera(pause_stream=True) as cam:
LOGGER.debug("Reconfiguring camera for full resolution capture")
stream_config = self._create_picam_config_from_mode_info(
picam=cam,
mode_info=streaming_mode_info,
)
cam.configure(stream_config)
cam.start()
time.sleep(self._sensor_info.short_pause)
yield cam
def capture_image( def _capture_image(self, capture_mode: str = "standard") -> Image.Image:
self,
stream_name: Literal["main", "lores", "full"] = "main",
wait: Optional[float] = 0.9,
) -> Image.Image:
"""Acquire one image from the camera and return it as a PIL Image. """Acquire one image from the camera and return it as a PIL Image.
If the ``stream_name`` parameter is ``main`` or ``lores``, it will be captured :param capture_mode: The capture mode to use. See the description field of each
from the main preview stream, or the low-res preview stream, respectively. This mode for more detail in ``capture_modes`` for more detail.
means the camera won't be reconfigured, and the stream will not pause (though
it may miss one frame).
If ``full`` resolution is requested, we will briefly pause the MJPEG stream and
reconfigure the camera to capture a full resolution image. This will capture an
image at the full resolution of the current sensor mode. If the current sensor
mode bins or crops the image, this may not be the native resolution of the
camera sensor.
:param stream_name: (Optional) The PiCamera2 stream to use, should be one of
["main", "lores", "full"]. Default = "main". Note that "raw" images cannot
be captured as PIL images. Use capture_array
:param wait: (Optional, float) Set a timeout in seconds. Default = 0.9s,
lower than the 1s timeout for the camera. This ensures that our code times
out and returns before the camera times out. If None is set the default
value of 0.9 will be used to prevent the possibility of the camera locking.
:raises TimeoutError: if this time is exceeded during capture. :raises TimeoutError: if this time is exceeded during capture.
""" """
if wait is None: capture_mode = self._validate_capture_mode(capture_mode)
wait = 0.9 capture_mode_info = self.capture_modes[capture_mode]
if stream_name in ["main", "lores", "raw"]:
with self._streaming_picamera() as cam: with self._ensure_mode_for_capture(capture_mode_info) as cam:
return cam.capture_image(stream_name, wait=wait) return cam.capture_image(
elif stream_name == "full": capture_mode_info.stream_name, wait=capture_mode_info.timeout
with self._switch_to_single_capture_mode() as cam: )
return cam.capture_image(name="main", wait=wait)
else:
raise ValueError(f'Unknown stream name "{stream_name}"')
@lt.action @lt.action
def capture_array( def capture_as_array(
self, self,
stream_name: Literal["main", "lores", "raw", "full"] = "main", capture_mode: str = "standard",
wait: Optional[float] = 0.9, raw: bool = False,
) -> NDArray: ) -> NDArray:
"""Acquire one image from the camera and return as an array. """Acquire one image from the camera and return as an array.
@ -555,24 +581,22 @@ class StreamingPiCamera2(BaseCamera, ABC):
:param stream_name: (Optional) The PiCamera2 stream to use, should be one of :param stream_name: (Optional) The PiCamera2 stream to use, should be one of
["main", "lores", "raw", "full"]. Default = "main" ["main", "lores", "raw", "full"]. Default = "main"
:param wait: (Optional, float) Set a timeout in seconds. Default = 0.9s,
lower than the 1s timeout for the camera. This ensures that our code times
out and returns before the camera times out. If None is set the default
value of 0.9 will be used to prevent the possibility of the camera locking.
:raises TimeoutError: if this time is exceeded during capture. :raises TimeoutError: if this time is exceeded during capture.
""" """
if stream_name == "raw": if raw:
# Raw cannot used capture_image. # Raw cannot used _capture_image.
if wait is None: capture_mode = self._validate_capture_mode(capture_mode)
wait = 0.9 capture_mode_info = self.capture_modes[capture_mode]
with self._switch_to_single_capture_mode() as cam:
return cam.capture_array(name="raw", wait=wait) with self._ensure_mode_for_capture(capture_mode_info) as cam:
return cam.capture_array(name="raw", wait=capture_mode_info.timeout)
# Note that internally the PiCamera creates a PIL image and then converts to # Note that internally the PiCamera creates a PIL image and then converts to
# numpy with ``np.array(Image.open(io.BytesIO(self.make_buffer(name))))``. # numpy with ``np.array(Image.open(io.BytesIO(self.make_buffer(name))))``.
# As such we use capture_image to get an Image from the picamera and return # As such we use _capture_image to get an Image from the picamera and return
# as array # as array
return np.array(self.capture_image(stream_name, wait)) return np.array(self._capture_image(capture_mode))
@lt.property @lt.property
def camera_configuration(self) -> Mapping: def camera_configuration(self) -> Mapping:
@ -946,6 +970,31 @@ class PiCameraV2(StreamingPiCamera2):
), ),
} }
@lt.property
def capture_modes(self) -> Mapping[str, PiCamera2CaptureMode]:
"""Modes the camera can use for capturing."""
return {
"standard": PiCamera2CaptureMode(
description=(
"The standard mode, 2MP image created from downsampling an 8MP "
"capture."
),
save_resolution=(1640, 1232),
streaming_mode="full_resolution",
stream_name="main",
),
"full": PiCamera2CaptureMode(
description="A full resolution 8MP capture.",
streaming_mode="full_resolution",
stream_name="main",
),
"quick": PiCamera2CaptureMode(
description="Capture directly from the current stream.",
streaming_mode=None,
stream_name="main",
),
}
class PiCameraHQ(StreamingPiCamera2): class PiCameraHQ(StreamingPiCamera2):
"""A Thing that provides and interface to the Raspberry Pi Camera HQ.""" """A Thing that provides and interface to the Raspberry Pi Camera HQ."""
@ -983,3 +1032,28 @@ class PiCameraHQ(StreamingPiCamera2):
scaler_crop=(628, 120, 2800, 2800), scaler_crop=(628, 120, 2800, 2800),
), ),
} }
@lt.property
def capture_modes(self) -> Mapping[str, PiCamera2CaptureMode]:
"""Modes the camera can use for capturing."""
return {
"standard": PiCamera2CaptureMode(
description=(
"The standard mode, 3MP image created from downsampling an 12MP "
"capture."
),
save_resolution=(1640, 1232),
streaming_mode="full_resolution",
stream_name="main",
),
"full": PiCamera2CaptureMode(
description="A full resolution 12MP capture.",
streaming_mode="full_resolution",
stream_name="main",
),
"quick": PiCamera2CaptureMode(
description="Capture directly from the current stream.",
streaming_mode=None,
stream_name="main",
),
}

View file

@ -14,7 +14,7 @@ import re
import time import time
from threading import Thread from threading import Thread
from types import TracebackType from types import TracebackType
from typing import Literal, Optional, Self, overload from typing import Optional, Self, overload
import numpy as np import numpy as np
from PIL import Image, ImageFilter from PIL import Image, ImageFilter
@ -479,10 +479,10 @@ class SimulatedCamera(BaseCamera):
""" """
@lt.action @lt.action
def capture_array( def capture_as_array(
self, self,
stream_name: Literal["main", "lores", "raw", "full"] = "full", capture_mode: str = "standard",
wait: Optional[float] = None, raw: bool = False,
) -> NDArray: ) -> NDArray:
"""Acquire one image from the camera and return as an array. """Acquire one image from the camera and return as an array.
@ -491,28 +491,24 @@ class SimulatedCamera(BaseCamera):
binary image formats will be added in due course. binary image formats will be added in due course.
:param stream_name: Currently ignored, this argument exists to ensure consistent API across camera Things. :param stream_name: Currently ignored, this argument exists to ensure consistent API across camera Things.
:param wait: Currently ignored, this argument exists to ensure consistent API across camera Things.
""" """
if wait is not None: if raw is True:
LOGGER.warning("Simulation camera has no wait option. Use None.") raise NotImplementedError(
LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}") "Simulation camera camera doesn't support raw capture."
)
# Warn if the capture mode is incorrect, but don't read the cooerced value as
# this camera only supports one mode.
self._validate_capture_mode(capture_mode)
return np.array(self.generate_frame()) return np.array(self.generate_frame())
def capture_image( def _capture_image(self, capture_mode: str = "standard") -> Image.Image:
self,
stream_name: Literal["main", "lores", "full"],
wait: Optional[float] = None,
) -> Image.Image:
"""Capture to a PIL image. This is not exposed as a ThingAction. """Capture to a PIL image. This is not exposed as a ThingAction.
It is used for capture to memory. It is used for capture to memory.
:param stream_name: Currently ignored, this argument exists to ensure consistent API across camera Things.
:param wait: Currently ignored, this argument exists to ensure consistent API across camera Things.
""" """
if wait is not None: # Warn if the capture mode is incorrect, but don't read the cooerced value as
LOGGER.warning("Simulation camera has no wait option. Use None.") # this camera only supports one mode.
LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}") self._validate_capture_mode(capture_mode)
return self.generate_frame() return self.generate_frame()
@lt.action @lt.action