diff --git a/picamera_coverage.zip b/picamera_coverage.zip index a29f518f..e1c41f1e 100644 Binary files a/picamera_coverage.zip and b/picamera_coverage.zip differ diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 5842a2c0..049f2a29 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -198,6 +198,8 @@ class BaseCamera(OFMThing): def __enter__(self) -> Self: """Open hardware connection when the Thing context manager is opened.""" + super().__enter__() + self._background_detector_name = coerce_thing_selector( thing_mapping=self._all_background_detectors, selected=self._background_detector_name, @@ -264,6 +266,113 @@ class BaseCamera(OFMThing): self.mjpeg_stream._streaming = False self.lores_mjpeg_stream._streaming = False + async def _monitor_framerate( + self, + duration: float, + sample_interval: float = 0.1, + ) -> tuple[float, int, list[dict[str, float | int]]]: + """Asynchronously monitor the timing on incoming frames.""" + start_time = time.time() + last_sample_time = start_time + last_sample_frames = 0 + + frames = 0 + samples = [] + + async for frame in self.mjpeg_stream.frame_async_generator(): + if not self._framerate_monitor_running: + break + + now = time.time() + frames += 1 + + if now - last_sample_time >= sample_interval: + interval = now - last_sample_time + fps = (frames - last_sample_frames) / interval + + samples.append( + { + "timestamp": now, + "frame_count": frames, + "frame_size_bytes": len(frame), + "instant_fps": fps, + } + ) + + last_sample_time = now + last_sample_frames = frames + + if now - start_time >= duration: + break + + total_time = time.time() - start_time + + return total_time, frames, samples + + @lt.action + def record_framerate( + self, + duration: float = 5.0, + ) -> str: + """Record MJPEG stream framerate statistics.""" + output_dir = os.path.join( + self.data_dir, + "characterisation", + "framerate", + ) + os.makedirs(output_dir, exist_ok=True) + + timestamp = time.strftime("%Y%m%d_%H%M%S") + datafile_path = os.path.join( + output_dir, + f"framerate_{timestamp}.json", + ) + + self.logger.info( + "Framerate monitor started -> %s", + datafile_path, + ) + + self._framerate_monitor_running = True + + # This runs as an async task, which we wait to complete + try: + total_time, frames, samples = self._thing_server_interface.call_async_task( + self._monitor_framerate, + duration, + ) + + finally: + self._framerate_monitor_running = False + + avg_fps = frames / total_time if total_time > 0 else 0 + + data = { + "summary": { + "total_duration": total_time, + "total_frames": frames, + "avg_fps": avg_fps, + }, + "samples": samples, + } + + self.logger.info( + ("Framerate monitor results: duration=%.2fs, frames=%d, avg_fps=%.2f"), + total_time, + frames, + avg_fps, + ) + + with open(datafile_path, "w") as f: + json.dump(data, f, indent=2) + + self.logger.info( + "Framerate monitor complete -> %s", + datafile_path, + ) + + return datafile_path + @lt.property def stream_active(self) -> bool: """Whether the MJPEG stream is active.""" diff --git a/tests/hardware_specific_tests/picamera2/cam_test_utils.py b/tests/hardware_specific_tests/picamera2/cam_test_utils.py index 144bc54b..8a96362b 100644 --- a/tests/hardware_specific_tests/picamera2/cam_test_utils.py +++ b/tests/hardware_specific_tests/picamera2/cam_test_utils.py @@ -1,5 +1,6 @@ """Utilities to help with testing the camera.""" +import tempfile from contextlib import contextmanager from typing import Optional @@ -23,7 +24,14 @@ def camera_test_env(settings_folder: Optional[str] = None): "camera": StreamingPiCamera2, "bg_channel_deviations_luv": ChannelDeviationLUV, } - with LabThingsTestEnv(things=thing_conf, settings_folder=settings_folder) as env: + app_config = { + "data_folder": tempfile.mkdtemp(), + } + with LabThingsTestEnv( + things=thing_conf, + settings_folder=settings_folder, + application_config=app_config, + ) as env: yield env diff --git a/tests/hardware_specific_tests/picamera2/test_acquisition.py b/tests/hardware_specific_tests/picamera2/test_acquisition.py index 1d8ba1a9..7ac49e3a 100644 --- a/tests/hardware_specific_tests/picamera2/test_acquisition.py +++ b/tests/hardware_specific_tests/picamera2/test_acquisition.py @@ -1,5 +1,8 @@ """Test data collection from the Raspberry Picamera.""" +import json +from pathlib import Path + import numpy as np from PIL import Image @@ -29,3 +32,47 @@ def test_jpeg_and_array(picamera_client): # Verify image sizes are the same assert mjpeg_frame.size == jpeg_capture.size assert array_main.shape[1::-1] == jpeg_capture.size + + +def test_record_framerate(picamera_client): + """Check that framerate monitoring creates a valid JSON log with good data.""" + log_file = Path(picamera_client.record_framerate(duration=1.0)) + + assert log_file.exists() + assert log_file.is_file() + + # Ensure file is not empty + assert log_file.stat().st_size > 0 + + # Load JSON + with open(log_file, "r") as f: + data = json.load(f) + + assert "summary" in data + assert "samples" in data + + summary = data["summary"] + samples = data["samples"] + + assert "total_duration" in summary + assert "total_frames" in summary + assert "avg_fps" in summary + + assert summary["total_duration"] > 0 + assert summary["total_frames"] > 0 + assert summary["avg_fps"] > 0 + + assert len(samples) > 0 + + sample = samples[0] + + assert "timestamp" in sample + assert "frame_count" in sample + assert "frame_size_bytes" in sample + assert "instant_fps" in sample + + assert sample["timestamp"] > 0 + assert sample["frame_count"] > 0 + assert sample["frame_size_bytes"] > 0 + + assert samples[-1]["frame_count"] > 0 diff --git a/tests/shared_utils/lt_test_utils.py b/tests/shared_utils/lt_test_utils.py index 19b61723..5ceabe4c 100644 --- a/tests/shared_utils/lt_test_utils.py +++ b/tests/shared_utils/lt_test_utils.py @@ -42,20 +42,28 @@ class LabThingsTestEnv: as a context manager: :param things: The thing configuration dictionary used to initialise the server. - :param settings_folder: The settings folder to use. + :param settings_folder: The settings folder to use. If None a tempdir is created + for this. + :param application_config: The application configuration dictionary. If None is + supplied then "data_folder" is set to a temporary directory. """ self._server: Optional[lt.ThingServer] self._test_client: Optional[TestClient] self._things_config = things self._settings_folder = settings_folder self._application_config = application_config - self._tmp_dir_obj: Optional[tempfile.TemporaryDirectory] = None + self._settings_tmp_dir: Optional[tempfile.TemporaryDirectory] = None + self._data_tmp_dir: Optional[tempfile.TemporaryDirectory] = None def __enter__(self) -> Self: """Create server and run it in the TestClient.""" + # Any OFMThings require an application config for the data_folder + if self._application_config is None: + self._data_tmp_dir = tempfile.TemporaryDirectory() + self._application_config = {"data_folder": self._data_tmp_dir} if self._settings_folder is None: - self._tmp_dir_obj = tempfile.TemporaryDirectory() - self._settings_folder = self._tmp_dir_obj.name + self._settings_tmp_dir = tempfile.TemporaryDirectory() + self._settings_folder = self._settings_tmp_dir.name self._server = lt.ThingServer( things=self._things_config, settings_folder=self._settings_folder, @@ -73,8 +81,10 @@ class LabThingsTestEnv: ) -> None: """Close the TestClient and cleanup.""" self._test_client.__exit__(exc_type, exc_value, traceback) - if self._tmp_dir_obj is not None: - self._tmp_dir_obj.cleanup() + if self._settings_tmp_dir is not None: + self._settings_tmp_dir.cleanup() + if self._data_tmp_dir is not None: + self._data_tmp_dir.cleanup() @property def server(self) -> lt.ThingServer: diff --git a/tests/unit_tests/test_simulated_camera.py b/tests/unit_tests/test_simulated_camera.py index dd499fc9..99f486be 100644 --- a/tests/unit_tests/test_simulated_camera.py +++ b/tests/unit_tests/test_simulated_camera.py @@ -27,6 +27,7 @@ def test_env() -> LabThingsTestEnv: "stage": DummyStage, "bg_channel_deviations_luv": ChannelDeviationLUV, } + with LabThingsTestEnv(things=thing_conf) as env: yield env