Merge branch 'measure-framerate' into 'v3'
Measure framerate See merge request openflexure/openflexure-microscope-server!583
This commit is contained in:
commit
182c67e105
6 changed files with 182 additions and 7 deletions
Binary file not shown.
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ def test_env() -> LabThingsTestEnv:
|
|||
"stage": DummyStage,
|
||||
"bg_channel_deviations_luv": ChannelDeviationLUV,
|
||||
}
|
||||
|
||||
with LabThingsTestEnv(things=thing_conf) as env:
|
||||
yield env
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue