Merge branch 'measure-framerate' into 'v3'

Measure framerate

See merge request openflexure/openflexure-microscope-server!583
This commit is contained in:
Joe Knapper 2026-05-14 10:57:17 +00:00
commit 182c67e105
6 changed files with 182 additions and 7 deletions

Binary file not shown.

View file

@ -198,6 +198,8 @@ class BaseCamera(OFMThing):
def __enter__(self) -> Self: def __enter__(self) -> Self:
"""Open hardware connection when the Thing context manager is opened.""" """Open hardware connection when the Thing context manager is opened."""
super().__enter__()
self._background_detector_name = coerce_thing_selector( self._background_detector_name = coerce_thing_selector(
thing_mapping=self._all_background_detectors, thing_mapping=self._all_background_detectors,
selected=self._background_detector_name, selected=self._background_detector_name,
@ -264,6 +266,113 @@ class BaseCamera(OFMThing):
self.mjpeg_stream._streaming = False self.mjpeg_stream._streaming = False
self.lores_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 @lt.property
def stream_active(self) -> bool: def stream_active(self) -> bool:
"""Whether the MJPEG stream is active.""" """Whether the MJPEG stream is active."""

View file

@ -1,5 +1,6 @@
"""Utilities to help with testing the camera.""" """Utilities to help with testing the camera."""
import tempfile
from contextlib import contextmanager from contextlib import contextmanager
from typing import Optional from typing import Optional
@ -23,7 +24,14 @@ def camera_test_env(settings_folder: Optional[str] = None):
"camera": StreamingPiCamera2, "camera": StreamingPiCamera2,
"bg_channel_deviations_luv": ChannelDeviationLUV, "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 yield env

View file

@ -1,5 +1,8 @@
"""Test data collection from the Raspberry Picamera.""" """Test data collection from the Raspberry Picamera."""
import json
from pathlib import Path
import numpy as np import numpy as np
from PIL import Image from PIL import Image
@ -29,3 +32,47 @@ def test_jpeg_and_array(picamera_client):
# Verify image sizes are the same # Verify image sizes are the same
assert mjpeg_frame.size == jpeg_capture.size assert mjpeg_frame.size == jpeg_capture.size
assert array_main.shape[1::-1] == 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

View file

@ -42,20 +42,28 @@ class LabThingsTestEnv:
as a context manager: as a context manager:
:param things: The thing configuration dictionary used to initialise the server. :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._server: Optional[lt.ThingServer]
self._test_client: Optional[TestClient] self._test_client: Optional[TestClient]
self._things_config = things self._things_config = things
self._settings_folder = settings_folder self._settings_folder = settings_folder
self._application_config = application_config 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: def __enter__(self) -> Self:
"""Create server and run it in the TestClient.""" """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: if self._settings_folder is None:
self._tmp_dir_obj = tempfile.TemporaryDirectory() self._settings_tmp_dir = tempfile.TemporaryDirectory()
self._settings_folder = self._tmp_dir_obj.name self._settings_folder = self._settings_tmp_dir.name
self._server = lt.ThingServer( self._server = lt.ThingServer(
things=self._things_config, things=self._things_config,
settings_folder=self._settings_folder, settings_folder=self._settings_folder,
@ -73,8 +81,10 @@ class LabThingsTestEnv:
) -> None: ) -> None:
"""Close the TestClient and cleanup.""" """Close the TestClient and cleanup."""
self._test_client.__exit__(exc_type, exc_value, traceback) self._test_client.__exit__(exc_type, exc_value, traceback)
if self._tmp_dir_obj is not None: if self._settings_tmp_dir is not None:
self._tmp_dir_obj.cleanup() self._settings_tmp_dir.cleanup()
if self._data_tmp_dir is not None:
self._data_tmp_dir.cleanup()
@property @property
def server(self) -> lt.ThingServer: def server(self) -> lt.ThingServer:

View file

@ -27,6 +27,7 @@ def test_env() -> LabThingsTestEnv:
"stage": DummyStage, "stage": DummyStage,
"bg_channel_deviations_luv": ChannelDeviationLUV, "bg_channel_deviations_luv": ChannelDeviationLUV,
} }
with LabThingsTestEnv(things=thing_conf) as env: with LabThingsTestEnv(things=thing_conf) as env:
yield env yield env