Add camera-stage-mapping compliant functions to BaseStage and BaseCamera.

This commit is contained in:
Julian Stirling 2025-07-13 21:55:55 +01:00
parent 18e5f80105
commit b89449be77
6 changed files with 130 additions and 110 deletions

View file

@ -9,7 +9,9 @@ See repository root for licensing information.
from __future__ import annotations
from typing import Literal, Optional, Tuple, Any
import json
import time
import numpy as np
from pydantic import RootModel
from PIL import Image
import piexif
@ -195,6 +197,13 @@ class BaseCamera(lt.Thing):
"CameraThings must define their own stream_active method"
)
@lt.thing_action
def discard_frames(self) -> None:
"""Discard frames so that the next frame captured is fresh."""
raise NotImplementedError(
"CameraThings must define their own discard_frames method"
)
@lt.thing_action
def capture_array(
self,
@ -206,6 +215,21 @@ class BaseCamera(lt.Thing):
"CameraThings must define their own capture_array method"
)
downsampled_array_factor = lt.ThingProperty(int, 2)
"""The downsampling factor when calling capture_downsampled_array."""
@lt.thing_action
def capture_downsampled_array(self) -> NDArray:
"""Acquire one image from the camera, downsample, and return as an array.
* The array is downsamples by the thing property `downsampled_array_factor`.
* The default capture array arguments are used.
This method is provides the interface expected by the camera_stage_mapping.
"""
img = self.capture_array()
return downsample(self.downsampled_array_factor, img)
@lt.thing_action
def capture_jpeg(
self,
@ -415,6 +439,40 @@ class BaseCamera(lt.Thing):
except Exception as e:
raise IOError(f"An error occurred while saving {jpeg_path}") from e
settling_time = lt.ThingSetting(float, 0.2)
"""The the settling time when calling the ``settle()`` method."""
@lt.thing_action
def settle(self) -> None:
"""Sleep for the settling time, ready to provide a fresh frame.
This method is provides the interface expected by the camera_stage_mapping.
"""
time.sleep(self.settling_time)
self.discard_frames()
CameraDependency = lt.deps.direct_thing_client_dependency(BaseCamera, "/camera/")
RawCameraDependency = lt.deps.raw_thing_dependency(BaseCamera)
def downsample(factor: int, image: np.ndarray) -> np.ndarray:
"""Downsample an image by taking the mean of each nxn region.
This should be very efficient:
* calculate each pixel as the mean of each ``factor * factor`` square without
interpolation.
* If the image is not an integer multiple of the resampling factor, discard
the left-over pixels to avoids odd edge effects and keep performance quick.
"""
if factor == 1:
return image
new_size = [d // factor for d in image.shape[:2]]
# First, we ensure we have something that's an integer multiple
# of `factor`
cropped = image[: new_size[0] * factor, : new_size[1] * factor, ...]
reshaped = cropped.reshape(
(new_size[0], factor, new_size[1], factor) + image.shape[2:]
)
return reshaped.mean(axis=(1, 3))

View file

@ -75,6 +75,11 @@ class OpenCVCamera(BaseCamera):
].tobytes()
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
@lt.thing_action
def discard_frames(self) -> None:
"""Discard frames so that the next frame captured is fresh."""
self.capture_array()
@lt.thing_action
def capture_array(
self,

View file

@ -240,12 +240,8 @@ class StreamingPiCamera2(BaseCamera):
cam.set_controls({"ExposureTime": value + 1})
def _get_persistent_controls(self) -> dict:
discard_frames: int = 1
if self.streaming:
with self._streaming_picamera() as cam:
# Discard frames, so data is fresh
for i in range(discard_frames):
cam.capture_metadata()
self.discard_frames()
return {
"AeEnable": False,
"AnalogueGain": self.analogue_gain,
@ -474,6 +470,12 @@ class StreamingPiCamera2(BaseCamera):
# Adding a sleep to prevent camera getting confused by rapid commands
time.sleep(0.2)
@lt.thing_action
def discard_frames(self) -> None:
"""Discard frames so that the next frame captured is fresh."""
with self._streaming_picamera() as cam:
cam.capture_metadata()
@lt.thing_action
def capture_image(
self,

View file

@ -187,6 +187,13 @@ class SimulatedCamera(BaseCamera):
except Exception as e:
logging.error(f"Failed to capture frame: {e}, retrying...")
@lt.thing_action
def discard_frames(self) -> None:
"""Discard frames so that the next frame captured is fresh.
There is nothing to do as this is a simulation!
"""
@lt.thing_action
def capture_array(
self,