From ab25705edc7692c6a1321e2a5495ea8fd7aae7a0 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 12 May 2026 14:00:29 +0100 Subject: [PATCH] Move framerate to basecamera, using data_dir --- .../things/camera/__init__.py | 154 ++++++++++++++++++ .../things/camera/picamera.py | 154 ------------------ .../picamera2/test_acquisition.py | 2 +- tests/unit_tests/test_cameras.py | 1 - 4 files changed, 155 insertions(+), 156 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 5842a2c0..bc9d504d 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -8,6 +8,7 @@ See repository root for licensing information. from __future__ import annotations +import csv import io import json import os @@ -198,6 +199,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 +267,157 @@ class BaseCamera(OFMThing): self.mjpeg_stream._streaming = False self.lores_mjpeg_stream._streaming = False + async def _monitor_framerate( + self, + duration: float, + output_dir: str, + sample_interval: float = 0.1, + ) -> None: + """Monitor the delivered MJPEG frame rate asynchronously. + + This method listens to frames arriving on self.mjpeg_stream and records + timing information without directly interacting with the underlying + ``Picamera2`` instance. + + Writes a CSV log file with timestamp, frame count, and size of the most + recent MJPEG frame in bytes + + Monitoring stops automatically after ``duration`` seconds, or earlier if + ``self._framerate_monitor_running`` is set to ``False``. + + :param duration: Total monitoring duration in seconds. + :param output_dir: Directory in which the CSV log file will be written. + :param sample_interval: Time interval in seconds between FPS samples. + Default = 0.1s. + + :raises Exception: Any unexpected exception is logged before the monitor + exits. + """ + try: + os.makedirs(output_dir, exist_ok=True) + + timestamp = time.strftime("%Y%m%d_%H%M%S") + log_path = os.path.join( + output_dir, + f"framerate_{timestamp}.csv", + ) + + start_time = time.time() + last_sample_time = start_time + last_sample_frames = 0 + frames = 0 + + self._framerate_monitor_running = True + + # Store rows in memory and write once at the end + sample_rows = [] + + self.logger.info( + "Starting framerate monitor for %.2fs -> %s", + duration, + log_path, + ) + + async for frame in self.mjpeg_stream.frame_async_generator(): + if not self._framerate_monitor_running: + self.logger.info("Framerate monitor stopped externally") + break + + now = time.time() + + frames += 1 + frame_size = len(frame) + + if now - last_sample_time >= sample_interval: + interval = now - last_sample_time + + instant_fps = (frames - last_sample_frames) / interval + + sample_rows.append( + [ + now, + frames, + frame_size, + ] + ) + + self.logger.info( + "[Framerate test] FPS=%.2f, size=%d B", + instant_fps, + frame_size, + ) + + last_sample_time = now + last_sample_frames = frames + + if now - start_time >= duration: + self.logger.info("Framerate monitor duration reached") + break + + total_time = time.time() - start_time + avg_fps = frames / total_time if total_time > 0 else 0 + + # Write CSV once at completion + with open(log_path, "w", newline="") as csv_file: + writer = csv.writer(csv_file) + + writer.writerow( + [ + "timestamp", + "frame_count", + "frame_size_bytes", + ] + ) + + writer.writerows(sample_rows) + + writer.writerow([]) + writer.writerow(["TOTAL_DURATION", total_time]) + writer.writerow(["TOTAL_FRAMES", frames]) + writer.writerow(["AVG_FPS", avg_fps]) + + self.logger.info( + "[Framerate test complete] Frames=%d, Avg FPS=%.2f", + frames, + avg_fps, + ) + + except Exception: + self.logger.exception("Framerate monitor crashed") + + finally: + self._framerate_monitor_running = False + + @lt.action + def record_framerate_async( + self, + duration: float = 5.0, + ) -> str: + """Start asynchronous MJPEG framerate monitoring. + + The monitor observes frames delivered through ``self.mjpeg_stream`` rather + than directly querying the camera hardware. This makes it suitable for + measuring effective stream throughput under real operating conditions. + + :param duration: Monitoring duration in seconds. Default = 5.0s. + + :returns: The output directory containing generated log files. + """ + output_dir = os.path.join(self.data_dir, "characterisation", "framerate") + self._thing_server_interface.start_async_task_soon( + self._monitor_framerate, + duration, + output_dir, + ) + + self.logger.info( + "Started async framerate monitor for %.2fs -> %s", + duration, + output_dir, + ) + + return output_dir + @lt.property def stream_active(self) -> bool: """Whether the MJPEG stream is active.""" diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index f2735758..93f536fa 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -17,7 +17,6 @@ https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf from __future__ import annotations import copy -import csv import json import logging import os @@ -550,159 +549,6 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera() as cam: cam.capture_metadata() - async def _monitor_framerate( - self, - duration: float, - output_dir: str, - sample_interval: float = 0.1, - ) -> None: - """Monitor the delivered MJPEG frame rate asynchronously. - - This method listens to frames arriving on self.mjpeg_stream and records - timing information without directly interacting with the underlying - ``Picamera2`` instance. - - Writes a CSV log file with timestamp, frame count, and size of the most - recent MJPEG frame in bytes - - Monitoring stops automatically after ``duration`` seconds, or earlier if - ``self._framerate_monitor_running`` is set to ``False``. - - :param duration: Total monitoring duration in seconds. - :param output_dir: Directory in which the CSV log file will be written. - :param sample_interval: Time interval in seconds between FPS samples. - Default = 0.1s. - - :raises Exception: Any unexpected exception is logged before the monitor - exits. - """ - try: - os.makedirs(output_dir, exist_ok=True) - - timestamp = time.strftime("%Y%m%d_%H%M%S") - log_path = os.path.join( - output_dir, - f"framerate_log_{timestamp}.csv", - ) - - start_time = time.time() - last_sample_time = start_time - last_sample_frames = 0 - frames = 0 - - self._framerate_monitor_running = True - - LOGGER.info( - "Starting framerate monitor for %.2fs -> %s", - duration, - log_path, - ) - - with open(log_path, "w", newline="") as csv_file: - writer = csv.writer(csv_file) - - writer.writerow( - [ - "timestamp", - "frame_count", - "frame_size_bytes", - ] - ) - - async for frame in self.mjpeg_stream.frame_async_generator(): - if not self._framerate_monitor_running: - LOGGER.info("Framerate monitor stopped externally") - break - - now = time.time() - - frames += 1 - frame_size = len(frame) - - if now - last_sample_time >= sample_interval: - interval = now - last_sample_time - - instant_fps = (frames - last_sample_frames) / interval - - writer.writerow( - [ - now, - frames, - frame_size, - ] - ) - - csv_file.flush() - - LOGGER.info( - "[Framerate test] FPS=%.2f, size=%d B", - instant_fps, - frame_size, - ) - - last_sample_time = now - last_sample_frames = frames - - if now - start_time >= duration: - LOGGER.info("Framerate monitor duration reached") - break - - total_time = time.time() - start_time - avg_fps = frames / total_time if total_time > 0 else 0 - - writer.writerow([]) - writer.writerow(["TOTAL_DURATION", total_time]) - writer.writerow(["TOTAL_FRAMES", frames]) - writer.writerow(["AVG_FPS", avg_fps]) - - LOGGER.info( - "[Framerate test complete] Frames=%d, Avg FPS=%.2f", - frames, - avg_fps, - ) - - except Exception: - LOGGER.exception("Framerate monitor crashed") - - finally: - self._framerate_monitor_running = False - - @lt.action - def record_framerate_async( - self, - duration: float = 5.0, - output_dir: str = "framerate", - ) -> str: - """Start asynchronous MJPEG framerate monitoring. - - This action schedules ``_monitor_framerate`` on the Thing server event loop - and immediately returns. Monitoring occurs in the background and does not - block the calling thread. - - The monitor observes frames delivered through ``self.mjpeg_stream`` rather - than directly querying the camera hardware. This makes it suitable for - measuring effective stream throughput under real operating conditions. - - :param duration: Monitoring duration in seconds. Default = 5.0s. - :param output_dir: Directory where CSV log files will be written. - Default = ``"framerate"``. - - :returns: The output directory containing generated log files. - """ - self._thing_server_interface.start_async_task_soon( - self._monitor_framerate, - duration, - output_dir, - ) - - LOGGER.info( - "Started async framerate monitor for %.2fs -> %s", - duration, - output_dir, - ) - - return output_dir - @contextmanager def _switch_to_still_capture_mode(self) -> Iterator[Picamera2]: """Get the picamera lock, pause stream and switch into still capture config. diff --git a/tests/hardware_specific_tests/picamera2/test_acquisition.py b/tests/hardware_specific_tests/picamera2/test_acquisition.py index b017cf83..84259515 100644 --- a/tests/hardware_specific_tests/picamera2/test_acquisition.py +++ b/tests/hardware_specific_tests/picamera2/test_acquisition.py @@ -48,7 +48,7 @@ def test_record_framerate_async(picamera_client, tmp_path): # Wait slightly longer than recording duration for async task completion time.sleep(1.5) - log_files = list(output_dir.glob("framerate_log_*.csv")) + log_files = list(output_dir.glob("framerate_*.csv")) # Ensure exactly one log file was created assert len(log_files) == 1 diff --git a/tests/unit_tests/test_cameras.py b/tests/unit_tests/test_cameras.py index 52d16f31..0fcec5ca 100644 --- a/tests/unit_tests/test_cameras.py +++ b/tests/unit_tests/test_cameras.py @@ -93,7 +93,6 @@ def test_thing_description_equivalence(mock_picam_thing): "set_static_green_equalisation", "set_ce_enable_to_off", "stop_streaming", - "record_framerate_async", "reset_ccm", } picamera_extra_props = {