From 881970ce348a5b5fa9b21fdebcbea9e04c16750d Mon Sep 17 00:00:00 2001 From: jaknapper Date: Thu, 7 May 2026 16:34:45 +0100 Subject: [PATCH 01/13] Record fom as other sharpness metric --- .../things/autofocus.py | 45 ++++++++++++++++--- .../things/camera/picamera.py | 10 +++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 695c349c..98726c86 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -38,6 +38,7 @@ class SharpnessMethod(enum.Enum): """The possible SharpnessMethods for autofocus.""" JPEG = enum.auto() + FOCUS_FOM = enum.auto() class AutofocusParams(BaseModel): @@ -235,6 +236,7 @@ class SharpnessDataArrays(BaseModel): jpeg_times: NDArray jpeg_sizes: NDArray + focus_foms: NDArray stage_times: NDArray stage_positions: list[dict[str, int]] @@ -253,7 +255,12 @@ class JPEGSharpnessMonitor: """ - def __init__(self, stage: BaseStage, camera: BaseCamera) -> None: + def __init__( + self, + stage: BaseStage, + camera: BaseCamera, + method: SharpnessMethod = SharpnessMethod.JPEG, + ) -> None: """Initialise a new JPEGSharpnessMonitor. The args are injected automatically. :param stage: A direct_thing_client dependency for the the microscope stage. @@ -262,11 +269,13 @@ class JPEGSharpnessMonitor: """ self.camera = camera self.stage = stage + self.method = method LOGGER.debug(f"Created sharpness monitor with {stage}, {camera}") self._stage_positions: list[Mapping[str, int]] = [] self._stage_times: list[float] = [] self._jpeg_times: list[float] = [] self._jpeg_sizes: list[int] = [] + self._focus_foms: list[float] = [] @property def stage_positions(self) -> Sequence[Mapping[str, int]]: @@ -288,14 +297,28 @@ class JPEGSharpnessMonitor: """The recorded JPEG frame sizes used as a sharpness metric.""" return self._jpeg_sizes + @property + def focus_foms(self) -> Sequence[float]: + """The recorded FocusFoM values.""" + return self._focus_foms + running = False async def monitor_sharpness(self) -> None: - """Start monitoring the frame sizes.""" + """Start monitoring sharpness metrics.""" self.running = True async for frame in self.camera.lores_mjpeg_stream.frame_async_generator(): self._jpeg_times.append(time.time()) + + # JPEG sharpness metric self._jpeg_sizes.append(len(frame)) + + # FocusFoM metric + fom = getattr(self.camera, "_focus_fom", None) + if fom is not None: + self._focus_foms.append(float(fom)) + else: + self._focus_foms.append(np.nan) if not self.running: break @@ -352,7 +375,13 @@ class JPEGSharpnessMonitor: if istop is None: istop = istart + 2 jpeg_times: np.ndarray = np.array(self.jpeg_times) - jpeg_sizes: np.ndarray = np.array(self.jpeg_sizes) + # Two sharpness metrics are measured - this chooses which to use to focus + if self.method == SharpnessMethod.JPEG: + sharpnesses = np.array(self.jpeg_sizes) + elif self.method == SharpnessMethod.FOCUS_FOM: + sharpnesses = np.array(self.focus_foms) + else: + raise ValueError(f"Unknown sharpness method: {self.method}") stage_times: np.ndarray = np.array(self.stage_times)[istart:istop] stage_heights: np.ndarray = np.array( [p["z"] for p in self.stage_positions[istart:istop]] @@ -373,7 +402,7 @@ class JPEGSharpnessMonitor: LOGGER.debug("changing stop to %s", (stop)) jpeg_times = jpeg_times[start:stop] jpeg_heights: np.ndarray = np.interp(jpeg_times, stage_times, stage_heights) - return jpeg_times, jpeg_heights, jpeg_sizes[start:stop] + return jpeg_times, jpeg_heights, sharpnesses[start:stop] def sharpest_z_on_move(self, data_index: int) -> int: """Return the z position of the sharpest image on a given move.""" @@ -388,7 +417,13 @@ class JPEGSharpnessMonitor: def data_to_array(self) -> SharpnessDataArrays: """Return the gathered data as SharpnessDataArrays.""" data = {} - for k in ["jpeg_times", "jpeg_sizes", "stage_times", "stage_positions"]: + for k in [ + "jpeg_times", + "jpeg_sizes", + "stage_times", + "focus_foms", + "stage_positions", + ]: data[k] = getattr(self, k) return SharpnessDataArrays(**data) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 29afc35b..41c45313 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -28,6 +28,7 @@ from types import TracebackType from typing import Annotated, Any, Iterator, Literal, Mapping, Optional, Self import numpy as np +from libcamera import Request from picamera2 import Picamera2 from picamera2.encoders import MJPEGEncoder from picamera2.outputs import Output @@ -366,6 +367,9 @@ class StreamingPiCamera2(BaseCamera): if self._picamera is None: # Type narrow (error if failure) raise RuntimeError("Failed to start Picamera") + + self._picamera.pre_callback = self._on_frame_complete + if check_sensor_model: hw_sensor_model = self._picamera.camera_properties["Model"] if hw_sensor_model != self._sensor_info.sensor_model: @@ -374,6 +378,12 @@ class StreamingPiCamera2(BaseCamera): f"but found {hw_sensor_model}." ) + def _on_frame_complete(self, request: Request) -> None: + md = request.get_metadata() + fom = md.get("FocusFoM") + if fom is not None: + self._focus_fom = fom + def __enter__(self) -> Self: """Start streaming when the Thing context manager is opened. From 664bb00c128f6e8c562d5cd98e7a9fe2b745b349 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Thu, 7 May 2026 19:45:24 +0100 Subject: [PATCH 02/13] Allow users to specify method for autofocus --- .../things/autofocus.py | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 98726c86..b7c92b5e 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -380,6 +380,14 @@ class JPEGSharpnessMonitor: sharpnesses = np.array(self.jpeg_sizes) elif self.method == SharpnessMethod.FOCUS_FOM: sharpnesses = np.array(self.focus_foms) + + # If no valid FocusFoM values were recorded, fall back to JPEG size + if np.all(np.isnan(self.focus_foms)): + LOGGER.warning( + "Sharpness method FOCUS_FOM selected, but no FocusFoM values " + "were recorded. Falling back to JPEG sharpness." + ) + sharpnesses = np.array(self.jpeg_sizes) else: raise ValueError(f"Unknown sharpness method: {self.method}") stage_times: np.ndarray = np.array(self.stage_times)[istart:istop] @@ -449,14 +457,19 @@ class AutofocusThing(lt.Thing): self, dz: int = 2000, start: Literal["centre", "base"] = "centre", + sharpness_metric: SharpnessMethod = SharpnessMethod.JPEG, ) -> SharpnessDataArrays: """Sweep the stage up and down, then move to the sharpest point. This method will will move down by dz/2, sweep up by dz, and then evaluate the position where the image was sharpest. We'll then move back down, and finally up to the sharpest point. + + sharpness_metric is either JPEG file size (JPEG) or inbuilt figure of metric (FOCUS_FOM). """ - with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor: + with JPEGSharpnessMonitor( + self._stage, self._cam, sharpness_metric + ) as sharpness_monitor: # Move to (-dz / 2) if start == "centre": sharpness_monitor.focus_rel(-dz // 2) @@ -483,7 +496,7 @@ class AutofocusThing(lt.Thing): """Make a move (or a series of moves) and monitor sharpness. This method will will make a series of relative moves in z, and - return the sharpness (JPEG size) vs time, along with timestamps + return the sharpness (JPEG size and FOM) vs time, along with timestamps for the moves. This can be used to calibrate autofocus. Each move is relative to the last one, i.e. we will finish at @@ -504,6 +517,7 @@ class AutofocusThing(lt.Thing): self, dz: int = 2000, start: Literal["centre", "base"] = "centre", + sharpness_metric: SharpnessMethod = SharpnessMethod.JPEG, ) -> tuple[list[float], list[float]]: """Repeatedly autofocus the stage until it looks focused. @@ -511,10 +525,14 @@ class AutofocusThing(lt.Thing): in the middle 3/5 of its range. Such logic can be helpful if the microscope is close to focus, but not quite within ``dz/2``. It will attempt to autofocus up to 10 times. + + sharpness_metric is either JPEG file size (JPEG) or inbuilt figure of metric (FOCUS_FOM). """ attempt = 0 - with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor: + with JPEGSharpnessMonitor( + self._stage, self._cam, sharpness_metric + ) as sharpness_monitor: while attempt < 10: attempt += 1 if start == "centre": @@ -531,9 +549,11 @@ class AutofocusThing(lt.Thing): focus_data_index, _ = sharpness_monitor.focus_rel( dz, block_cancellation=True ) - _times, heights, sizes = sharpness_monitor.move_data(focus_data_index) + _times, heights, sharpnesses = sharpness_monitor.move_data( + focus_data_index + ) - peak_height = heights[np.argmax(sizes)] + peak_height = heights[np.argmax(sharpnesses)] target_min = np.min(heights) + dz / 5 target_max = np.max(heights) - dz / 5 @@ -545,7 +565,7 @@ class AutofocusThing(lt.Thing): if target_min < peak_height < target_max: # If it is within the target range then return - return heights.tolist(), sizes.tolist() + return heights.tolist(), sharpnesses.tolist() raise NoFocusFoundError( "Looping autofocus couldn't converge on a focus location." ) From cab960fe2ce52f610d5e9a8ca670c3e3e5331ef2 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Fri, 8 May 2026 15:45:49 +0000 Subject: [PATCH 03/13] Apply suggestions from code review of branch fom-af Co-authored-by: Julian Stirling --- .../things/autofocus.py | 41 +++++++++++-------- .../things/camera/picamera.py | 3 +- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index b7c92b5e..33207b41 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -37,8 +37,16 @@ class NotStreamingError(RuntimeError): class SharpnessMethod(enum.Enum): """The possible SharpnessMethods for autofocus.""" - JPEG = enum.auto() - FOCUS_FOM = enum.auto() +class SharpnessMethod(enum.Enum): + """The possible SharpnessMethods for autofocus. + + Use powers of two for the methods so they can be selected bitwise. This allows choosing what to record + as the sum of methods. + """ + + JPEG = 1 + FOCUS_FOM = 2 + # Next method should be 4 not 3 for bitwise selection. class AutofocusParams(BaseModel): @@ -260,6 +268,7 @@ class JPEGSharpnessMonitor: stage: BaseStage, camera: BaseCamera, method: SharpnessMethod = SharpnessMethod.JPEG, + record: Optional[int] = None, ) -> None: """Initialise a new JPEGSharpnessMonitor. The args are injected automatically. @@ -270,6 +279,13 @@ class JPEGSharpnessMonitor: self.camera = camera self.stage = stage self.method = method + self.record = method if record is None else record + + if not self.method & self.record: + raise ValueError(f"The sharpness metric `self.record` is not being recorded.") + + if self.record & SharpnessMethod.FOCUS_FOM and not camera.supports_focus_fom: + raise ValueError("Cannot record focus FOM as this camera doesn't support it.") LOGGER.debug(f"Created sharpness monitor with {stage}, {camera}") self._stage_positions: list[Mapping[str, int]] = [] self._stage_times: list[float] = [] @@ -311,14 +327,13 @@ class JPEGSharpnessMonitor: self._jpeg_times.append(time.time()) # JPEG sharpness metric - self._jpeg_sizes.append(len(frame)) + if self.record & SharpnessMethod.JPEG: + self._jpeg_sizes.append(len(frame)) # FocusFoM metric - fom = getattr(self.camera, "_focus_fom", None) - if fom is not None: - self._focus_foms.append(float(fom)) - else: - self._focus_foms.append(np.nan) + if self.record & SharpnessMethod.FOCUS_FOM: + self._focus_foms.append(self.camera._focus_fom) + if not self.running: break @@ -381,15 +396,6 @@ class JPEGSharpnessMonitor: elif self.method == SharpnessMethod.FOCUS_FOM: sharpnesses = np.array(self.focus_foms) - # If no valid FocusFoM values were recorded, fall back to JPEG size - if np.all(np.isnan(self.focus_foms)): - LOGGER.warning( - "Sharpness method FOCUS_FOM selected, but no FocusFoM values " - "were recorded. Falling back to JPEG sharpness." - ) - sharpnesses = np.array(self.jpeg_sizes) - else: - raise ValueError(f"Unknown sharpness method: {self.method}") stage_times: np.ndarray = np.array(self.stage_times)[istart:istop] stage_heights: np.ndarray = np.array( [p["z"] for p in self.stage_positions[istart:istop]] @@ -458,6 +464,7 @@ class AutofocusThing(lt.Thing): dz: int = 2000, start: Literal["centre", "base"] = "centre", sharpness_metric: SharpnessMethod = SharpnessMethod.JPEG, + record: Optional[Int] ) -> SharpnessDataArrays: """Sweep the stage up and down, then move to the sharpest point. diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 41c45313..d0b02f5f 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -123,7 +123,8 @@ class StreamingPiCamera2(BaseCamera): Currently the Thing only supports the PiCamera v2 board. This needs generalisation. """ - + _focus_fom: int + supports_focus_fom: bool = True tuning: dict = lt.setting(default_factory=dict, readonly=True) """The Raspberry PiCamera Tuning File JSON.""" From bea7447f263b88157199ab9c0859be98793e0ea3 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Fri, 8 May 2026 17:39:49 +0100 Subject: [PATCH 04/13] Clean up from suggestions --- .../things/autofocus.py | 74 ++++++++++++++----- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 33207b41..b11c404f 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -34,10 +34,7 @@ class NotStreamingError(RuntimeError): """No images captured from stream. The camera is almost certainly not streaming.""" -class SharpnessMethod(enum.Enum): - """The possible SharpnessMethods for autofocus.""" - -class SharpnessMethod(enum.Enum): +class SharpnessMethod(enum.IntFlag): """The possible SharpnessMethods for autofocus. Use powers of two for the methods so they can be selected bitwise. This allows choosing what to record @@ -268,13 +265,20 @@ class JPEGSharpnessMonitor: stage: BaseStage, camera: BaseCamera, method: SharpnessMethod = SharpnessMethod.JPEG, - record: Optional[int] = None, + record: Optional[SharpnessMethod] = None, ) -> None: """Initialise a new JPEGSharpnessMonitor. The args are injected automatically. :param stage: A direct_thing_client dependency for the the microscope stage. :param camera: A raw_thing_client depeendency for the camera. This is a raw - dependency as the underlying class needs to be + dependency as required by the underlying class. + :param method: The sharpness metric used when evaluating autofocus. + :param record: Bitmask of sharpness metrics to record while monitoring. + If ``None``, only the metric specified by ``method`` is recorded. + + :raises ValueError: If ``method`` is not included in ``record``. + :raises ValueError: If ``SharpnessMethod.FOCUS_FOM`` is requested but + the camera does not support FocusFoM measurements. """ self.camera = camera self.stage = stage @@ -295,7 +299,12 @@ class JPEGSharpnessMonitor: @property def stage_positions(self) -> Sequence[Mapping[str, int]]: - """The positions recorded for the stage.""" + """The positions recorded for the stage. + + :raises ValueError: If stage position recording is not enabled. + """ + if not self._stage_positions: + raise ValueError("Stage positions have not been recorded yet.") return self._stage_positions @property @@ -310,12 +319,22 @@ class JPEGSharpnessMonitor: @property def jpeg_sizes(self) -> Sequence[int]: - """The recorded JPEG frame sizes used as a sharpness metric.""" + """The recorded JPEG frame sizes used as a sharpness metric. + + :raises ValueError: If JPEG sharpness recording is not enabled. + """ + if not self.record & SharpnessMethod.JPEG: + raise ValueError("JPEG sizes are not being recorded.") return self._jpeg_sizes @property def focus_foms(self) -> Sequence[float]: - """The recorded FocusFoM values.""" + """The recorded FocusFoM values. + + :raises ValueError: If FocusFoM recording is not enabled. + """ + if not self.record & SharpnessMethod.FOCUS_FOM: + raise ValueError("FocusFoM values are not being recorded.") return self._focus_foms running = False @@ -464,18 +483,28 @@ class AutofocusThing(lt.Thing): dz: int = 2000, start: Literal["centre", "base"] = "centre", sharpness_metric: SharpnessMethod = SharpnessMethod.JPEG, - record: Optional[Int] + record: Optional[SharpnessMethod] = None ) -> SharpnessDataArrays: """Sweep the stage up and down, then move to the sharpest point. - This method will will move down by dz/2, sweep up by dz, and then evaluate - the position where the image was sharpest. We'll then move back down, and - finally up to the sharpest point. + This method will move down by dz/2, sweep up by dz, and then evaluate + the position where the image was sharpest. We'll then move back down, + and finally up to the sharpest point. - sharpness_metric is either JPEG file size (JPEG) or inbuilt figure of metric (FOCUS_FOM). + :param dz: Total z-range (in steps) used for the autofocus sweep. + :param start: Starting position of the sweep. "centre" begins at -dz/2, + while "base" begins from the current position. + :param sharpness_metric: Sharpness metric used for evaluation. Either + JPEG file size (JPEG) or inbuilt figure of metric (FOCUS_FOM). + :param record: Optional bitmask of sharpness metrics to record during + acquisition. If None, defaults to recording only the selected + sharpness_metric. + + :returns: SharpnessDataArrays containing all recorded sharpness and + stage position data for the sweep. """ with JPEGSharpnessMonitor( - self._stage, self._cam, sharpness_metric + self._stage, self._cam, sharpness_metric, record=record, ) as sharpness_monitor: # Move to (-dz / 2) if start == "centre": @@ -525,6 +554,7 @@ class AutofocusThing(lt.Thing): dz: int = 2000, start: Literal["centre", "base"] = "centre", sharpness_metric: SharpnessMethod = SharpnessMethod.JPEG, + record: Optional[SharpnessMethod] = None, ) -> tuple[list[float], list[float]]: """Repeatedly autofocus the stage until it looks focused. @@ -533,12 +563,22 @@ class AutofocusThing(lt.Thing): is close to focus, but not quite within ``dz/2``. It will attempt to autofocus up to 10 times. - sharpness_metric is either JPEG file size (JPEG) or inbuilt figure of metric (FOCUS_FOM). + :param dz: Total z-range (in steps) used for the autofocus sweep. + :param start: Starting position of the sweep. "centre" begins at -dz/2, + while "base" begins from the current position. + :param sharpness_metric: Sharpness metric used for evaluation. Either + JPEG file size (JPEG) or inbuilt figure of metric (FOCUS_FOM). + :param record: Optional bitmask of sharpness metrics to record during + acquisition. If None, defaults to recording only the selected + sharpness_metric. + + :returns: SharpnessDataArrays containing all recorded sharpness and + stage position data for the sweep. """ attempt = 0 with JPEGSharpnessMonitor( - self._stage, self._cam, sharpness_metric + self._stage, self._cam, sharpness_metric, record=record, ) as sharpness_monitor: while attempt < 10: attempt += 1 From 767e3992ff3773c3b6d5d3108851b47958d9f73a Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Mon, 11 May 2026 13:50:16 +0100 Subject: [PATCH 05/13] Add FoM to codespell ignore --- .codespellrc | 2 ++ .codespellrc2 | 1 + 2 files changed, 3 insertions(+) diff --git a/.codespellrc b/.codespellrc index 5832630e..1e682e69 100644 --- a/.codespellrc +++ b/.codespellrc @@ -19,3 +19,5 @@ skip = node_modules, report.xml regex = [a-zA-Z0-9\-'’]+ +ignore-words-list = fom + diff --git a/.codespellrc2 b/.codespellrc2 index b3f96563..ab1d49ce 100644 --- a/.codespellrc2 +++ b/.codespellrc2 @@ -27,3 +27,4 @@ skip = node_modules, ignore-regex = #[0-9A-Fa-f]{3,8}\b|\bFoV\b regex = [A-Z]?[a-z\']+ +ignore-words-list = fom From 1f5adbb1a5e076cf08a4568a281b0106076b0522 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Mon, 11 May 2026 13:50:38 +0100 Subject: [PATCH 06/13] Ruff --- .../things/autofocus.py | 22 ++++++++++++++----- .../things/camera/picamera.py | 1 + 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index b11c404f..2b573997 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -286,10 +286,14 @@ class JPEGSharpnessMonitor: self.record = method if record is None else record if not self.method & self.record: - raise ValueError(f"The sharpness metric `self.record` is not being recorded.") + raise ValueError( + f"The sharpness metric {self.record} is not being recorded." + ) if self.record & SharpnessMethod.FOCUS_FOM and not camera.supports_focus_fom: - raise ValueError("Cannot record focus FOM as this camera doesn't support it.") + raise ValueError( + "Cannot record focus FOM as this camera doesn't support it." + ) LOGGER.debug(f"Created sharpness monitor with {stage}, {camera}") self._stage_positions: list[Mapping[str, int]] = [] self._stage_times: list[float] = [] @@ -320,7 +324,7 @@ class JPEGSharpnessMonitor: @property def jpeg_sizes(self) -> Sequence[int]: """The recorded JPEG frame sizes used as a sharpness metric. - + :raises ValueError: If JPEG sharpness recording is not enabled. """ if not self.record & SharpnessMethod.JPEG: @@ -483,7 +487,7 @@ class AutofocusThing(lt.Thing): dz: int = 2000, start: Literal["centre", "base"] = "centre", sharpness_metric: SharpnessMethod = SharpnessMethod.JPEG, - record: Optional[SharpnessMethod] = None + record: Optional[SharpnessMethod] = None, ) -> SharpnessDataArrays: """Sweep the stage up and down, then move to the sharpest point. @@ -504,7 +508,10 @@ class AutofocusThing(lt.Thing): stage position data for the sweep. """ with JPEGSharpnessMonitor( - self._stage, self._cam, sharpness_metric, record=record, + self._stage, + self._cam, + sharpness_metric, + record=record, ) as sharpness_monitor: # Move to (-dz / 2) if start == "centre": @@ -578,7 +585,10 @@ class AutofocusThing(lt.Thing): attempt = 0 with JPEGSharpnessMonitor( - self._stage, self._cam, sharpness_metric, record=record, + self._stage, + self._cam, + sharpness_metric, + record=record, ) as sharpness_monitor: while attempt < 10: attempt += 1 diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index d0b02f5f..c8b2ec29 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -123,6 +123,7 @@ class StreamingPiCamera2(BaseCamera): Currently the Thing only supports the PiCamera v2 board. This needs generalisation. """ + _focus_fom: int supports_focus_fom: bool = True tuning: dict = lt.setting(default_factory=dict, readonly=True) From 4d9669f8ae7a8ea81da5936405522338596bb5a7 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Mon, 11 May 2026 15:14:25 +0100 Subject: [PATCH 07/13] Add FoM to codespell regex ignore --- .codespellrc2 | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.codespellrc2 b/.codespellrc2 index ab1d49ce..2a690037 100644 --- a/.codespellrc2 +++ b/.codespellrc2 @@ -23,8 +23,7 @@ skip = node_modules, # Ignores split by | # Ignore hexadecimal numbers preceded by # -# Ignore FoV -ignore-regex = #[0-9A-Fa-f]{3,8}\b|\bFoV\b +# Ignore FoV and FoM +ignore-regex = #[0-9A-Fa-f]{3,8}\b|FoV|FoM regex = [A-Z]?[a-z\']+ -ignore-words-list = fom From 0e1d9cca18ff718cfb8c607dcddecc6dcf1caa4a Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 12 May 2026 13:15:23 +0100 Subject: [PATCH 08/13] Improvements to properties and docstrings --- .../things/autofocus.py | 22 ++++++++++--------- .../things/camera/__init__.py | 12 ++++++++++ .../things/camera/picamera.py | 5 +++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 2b573997..84d1acad 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -265,7 +265,7 @@ class JPEGSharpnessMonitor: stage: BaseStage, camera: BaseCamera, method: SharpnessMethod = SharpnessMethod.JPEG, - record: Optional[SharpnessMethod] = None, + record: Optional[int] = None, ) -> None: """Initialise a new JPEGSharpnessMonitor. The args are injected automatically. @@ -355,7 +355,7 @@ class JPEGSharpnessMonitor: # FocusFoM metric if self.record & SharpnessMethod.FOCUS_FOM: - self._focus_foms.append(self.camera._focus_fom) + self._focus_foms.append(self.camera.focus_fom) if not self.running: break @@ -487,7 +487,7 @@ class AutofocusThing(lt.Thing): dz: int = 2000, start: Literal["centre", "base"] = "centre", sharpness_metric: SharpnessMethod = SharpnessMethod.JPEG, - record: Optional[SharpnessMethod] = None, + record: Optional[int] = None, ) -> SharpnessDataArrays: """Sweep the stage up and down, then move to the sharpest point. @@ -499,10 +499,11 @@ class AutofocusThing(lt.Thing): :param start: Starting position of the sweep. "centre" begins at -dz/2, while "base" begins from the current position. :param sharpness_metric: Sharpness metric used for evaluation. Either - JPEG file size (JPEG) or inbuilt figure of metric (FOCUS_FOM). + JPEG file size (1) or inbuilt figure of metric (2). :param record: Optional bitmask of sharpness metrics to record during - acquisition. If None, defaults to recording only the selected - sharpness_metric. + acquisition. Sharpness metrics can be selected as the sum of their int + value (see sharpness_metric). + If None, defaults to recording only the selected sharpness_metric. :returns: SharpnessDataArrays containing all recorded sharpness and stage position data for the sweep. @@ -561,7 +562,7 @@ class AutofocusThing(lt.Thing): dz: int = 2000, start: Literal["centre", "base"] = "centre", sharpness_metric: SharpnessMethod = SharpnessMethod.JPEG, - record: Optional[SharpnessMethod] = None, + record: Optional[int] = None, ) -> tuple[list[float], list[float]]: """Repeatedly autofocus the stage until it looks focused. @@ -574,10 +575,11 @@ class AutofocusThing(lt.Thing): :param start: Starting position of the sweep. "centre" begins at -dz/2, while "base" begins from the current position. :param sharpness_metric: Sharpness metric used for evaluation. Either - JPEG file size (JPEG) or inbuilt figure of metric (FOCUS_FOM). + JPEG file size (1) or inbuilt figure of metric (2). :param record: Optional bitmask of sharpness metrics to record during - acquisition. If None, defaults to recording only the selected - sharpness_metric. + acquisition. Sharpness metrics can be selected as the sum of their int + value (see sharpness_metric). + If None, defaults to recording only the selected sharpness_metric. :returns: SharpnessDataArrays containing all recorded sharpness and stage position data for the sweep. diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index b9f0f2a7..5842a2c0 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -179,6 +179,7 @@ class BaseCamera(OFMThing): mjpeg_stream = lt.outputs.MJPEGStreamDescriptor() lores_mjpeg_stream = lt.outputs.MJPEGStreamDescriptor() _memory_buffer = CameraMemoryBuffer() + supports_focus_fom: bool = False def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None: """Initialise the base camera, this creates the background detectors. @@ -213,6 +214,17 @@ class BaseCamera(OFMThing): """Close hardware connection when the Thing context manager is closed.""" pass + @property + def focus_fom(self) -> int: + """Return the focus figure of merit. + + This returns a NotImplementedError if not supported by the camera. + To use, requires self.supports_focus_fom to be set to True. + """ + raise NotImplementedError( + "This camera does not support Figure of Merit focusing." + ) + @lt.property def calibration_required(self) -> bool: """Whether the camera needs calibrating. diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index c8b2ec29..4267bc14 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -380,6 +380,11 @@ class StreamingPiCamera2(BaseCamera): f"but found {hw_sensor_model}." ) + @property + def focus_fom(self) -> int: + """Return the focus figure of merit.""" + return self._focus_fom + def _on_frame_complete(self, request: Request) -> None: md = request.get_metadata() fom = md.get("FocusFoM") From e93d9777a2a8dc9a7d8beb58655cb09a14789946 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 12 May 2026 14:52:16 +0100 Subject: [PATCH 09/13] Type checking help for libcamera requests --- .../things/camera/picamera.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 4267bc14..b76d4ed5 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -25,10 +25,18 @@ import time from contextlib import contextmanager from threading import RLock from types import TracebackType -from typing import Annotated, Any, Iterator, Literal, Mapping, Optional, Self +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Iterator, + Literal, + Mapping, + Optional, + Self, +) import numpy as np -from libcamera import Request from picamera2 import Picamera2 from picamera2.encoders import MJPEGEncoder from picamera2.outputs import Output @@ -51,6 +59,9 @@ from . import BaseCamera from . import picamera_recalibrate_utils as recalibrate_utils from . import picamera_tuning_file_utils as tf_utils +if TYPE_CHECKING: + from libcamera import Request + LOGGER = logging.getLogger(__name__) SUPPORTED_CAMS_SENSOR_INFO = { From 563bfcf7bef8793b2c0e24660afbbcc1ee2a34b8 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 13 May 2026 12:18:36 +0100 Subject: [PATCH 10/13] Update move_and_measure, add unit tests --- .../things/autofocus.py | 18 +- tests/unit_tests/test_autofocus.py | 156 ++++++++++++++++++ 2 files changed, 170 insertions(+), 4 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 84d1acad..a2b5f0c7 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -536,20 +536,30 @@ class AutofocusThing(lt.Thing): self, dz: Sequence[int], wait: float = 0, + sharpness_metric: SharpnessMethod = SharpnessMethod.JPEG, + record: Optional[int] = None, ) -> SharpnessDataArrays: """Make a move (or a series of moves) and monitor sharpness. This method will will make a series of relative moves in z, and - return the sharpness (JPEG size and FOM) vs time, along with timestamps + return the sharpness (JPEG size and/or FOM) vs time, along with timestamps for the moves. This can be used to calibrate autofocus. Each move is relative to the last one, i.e. we will finish at ``sum(dz)`` relative to the starting position. - If ``wait`` is specified, we will wait for that many seconds - between moves. + :param dz: A list of moves to make in z (in steps). + :param wait: The time to pause between movements, in seconds. + :param sharpness_metric: Sharpness metric used for evaluation. Either + JPEG file size (1) or inbuilt figure of metric (2). + :param record: Optional bitmask of sharpness metrics to record during + acquisition. Sharpness metrics can be selected as the sum of their int + value (see sharpness_metric). + If None, defaults to recording only the selected sharpness_metric. """ - with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor: + with JPEGSharpnessMonitor( + self._stage, self._cam, sharpness_metric, record + ) as sharpness_monitor: for move_index, current_dz in enumerate(dz): if move_index > 0 and wait > 0: time.sleep(wait) diff --git a/tests/unit_tests/test_autofocus.py b/tests/unit_tests/test_autofocus.py index 3661cbc6..3401f2d4 100644 --- a/tests/unit_tests/test_autofocus.py +++ b/tests/unit_tests/test_autofocus.py @@ -10,7 +10,9 @@ from labthings_fastapi.testing import create_thing_without_server from openflexure_microscope_server.things.autofocus import ( AutofocusThing, + JPEGSharpnessMonitor, NoFocusFoundError, + SharpnessMethod, ) @@ -98,3 +100,157 @@ def test_looping_autofocus(start_z, max_loc, centre, attempts_expected, passes, ) assert sharpness_monitor.focus_rel.call_count == attempts_expected assert abs(max_loc - autofocus_thing._stage.position["z"]) < dz / 40 + + +@pytest.fixture +def mock_camera(mocker): + """Mock a camera to return fom properties.""" + camera = mocker.MagicMock() + camera.supports_focus_fom = True + camera.focus_fom = 123 + return camera + + +@pytest.fixture +def mock_stage(mocker): + """Mock a stage to return position.""" + stage = mocker.MagicMock() + stage.position = {"x": 0, "y": 0, "z": 0} + return stage + + +def test_record_defaults_to_method(mock_stage, mock_camera): + """If record=None, only the selected method is recorded.""" + monitor = JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.FOCUS_FOM, + ) + + assert monitor.record == SharpnessMethod.FOCUS_FOM + + +def test_record_must_include_selected_method(mock_stage, mock_camera): + """The selected autofocus metric must also be recorded.""" + with pytest.raises(ValueError, match="not being recorded"): + JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.FOCUS_FOM, + record=SharpnessMethod.JPEG, + ) + + +def test_focus_fom_requires_camera_support(mock_stage, mock_camera): + """FocusFoM recording requires camera support.""" + mock_camera.supports_focus_fom = False + + with pytest.raises(ValueError, match="doesn't support"): + JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.FOCUS_FOM, + ) + + +def test_jpeg_sizes_property_requires_recording(mock_stage, mock_camera): + """JPEG sizes should error if JPEG recording disabled.""" + monitor = JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.FOCUS_FOM, + ) + + with pytest.raises(ValueError, match="JPEG sizes are not being recorded"): + _ = monitor.jpeg_sizes + + +def test_focus_fom_property_requires_recording(mock_stage, mock_camera): + """FocusFoM values should error if FOM recording disabled.""" + monitor = JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.JPEG, + ) + + with pytest.raises(ValueError, match="FocusFoM values are not being recorded"): + _ = monitor.focus_foms + + +@pytest.mark.parametrize( + ("method", "expected"), + [ + (SharpnessMethod.JPEG, [100, 200, 300]), + (SharpnessMethod.FOCUS_FOM, [1, 5, 2]), + ], +) +def test_move_data_uses_selected_method( + method, + expected, + mock_stage, + mock_camera, +): + """move_data should select the correct sharpness metric.""" + monitor = JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=method, + record=SharpnessMethod.JPEG + SharpnessMethod.FOCUS_FOM, + ) + + monitor._jpeg_times = [1.0, 2.0, 3.0] + monitor._jpeg_sizes = [100, 200, 300] + monitor._focus_foms = [1, 5, 2] + + monitor._stage_times = [0.5, 3.5] + monitor._stage_positions = [ + {"z": 0}, + {"z": 100}, + ] + + _, _, sharpnesses = monitor.move_data(0) + + assert sharpnesses.tolist() == expected + + +def test_monitor_records_both_metrics(mock_stage, mock_camera): + """Both JPEG and FocusFoM metrics can be recorded together.""" + monitor = JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.JPEG, + record=SharpnessMethod.JPEG + SharpnessMethod.FOCUS_FOM, + ) + + monitor._jpeg_sizes.append(999) + monitor._focus_foms.append(321) + + assert monitor.jpeg_sizes == [999] + assert monitor.focus_foms == [321] + + +def test_move_data_uses_focus_fom(mock_stage, mock_camera): + """Test that move_data returns the chosen metric.""" + monitor = JPEGSharpnessMonitor( + mock_stage, + mock_camera, + method=SharpnessMethod.FOCUS_FOM, + record=SharpnessMethod.JPEG + SharpnessMethod.FOCUS_FOM, + ) + + # Record both sharpness metrics + monitor._jpeg_times = [1.0, 2.0, 3.0] + monitor._jpeg_sizes = [100, 200, 300] + monitor._focus_foms = [10, 99, 20] + + monitor._stage_times = [0.5, 3.5] + monitor._stage_positions = [ + {"z": 0}, + {"z": 100}, + ] + + # sharpnesses chosen by the chosen method + _, _, sharpnesses = monitor.move_data(0) + + # ensure sharpnesses matches focus_foms + assert sharpnesses.tolist() == [10, 99, 20] From b1791833e2aeaa4e09ccd957a67400a4c53ba354 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Wed, 13 May 2026 13:03:40 +0100 Subject: [PATCH 11/13] Clean up data_to_array to not use get attr --- .../things/autofocus.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index a2b5f0c7..d5851e80 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -454,14 +454,18 @@ class JPEGSharpnessMonitor: def data_to_array(self) -> SharpnessDataArrays: """Return the gathered data as SharpnessDataArrays.""" data = {} - for k in [ - "jpeg_times", - "jpeg_sizes", - "stage_times", - "focus_foms", - "stage_positions", - ]: - data[k] = getattr(self, k) + + data["jpeg_times"] = self._jpeg_times + data["stage_times"] = self._stage_times + data["stage_positions"] = self._stage_positions + + if self.record & SharpnessMethod.JPEG: + data["jpeg_sizes"] = self._jpeg_sizes + + if self.record & SharpnessMethod.FOCUS_FOM: + data["focus_foms"] = self._focus_foms + else: + data["focus_foms"] = [] return SharpnessDataArrays(**data) def data_dict(self) -> dict: From 07caa1f05f8648869043e7aa73c9a9dd70940591 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Wed, 13 May 2026 14:06:27 +0100 Subject: [PATCH 12/13] Looser typing of stage positions in autofocus --- .../things/autofocus.py | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index d5851e80..bd2d807f 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -243,7 +243,7 @@ class SharpnessDataArrays(BaseModel): jpeg_sizes: NDArray focus_foms: NDArray stage_times: NDArray - stage_positions: list[dict[str, int]] + stage_positions: list[Mapping[str, int]] class JPEGSharpnessMonitor: @@ -453,20 +453,21 @@ class JPEGSharpnessMonitor: def data_to_array(self) -> SharpnessDataArrays: """Return the gathered data as SharpnessDataArrays.""" - data = {} - - data["jpeg_times"] = self._jpeg_times - data["stage_times"] = self._stage_times - data["stage_positions"] = self._stage_positions - - if self.record & SharpnessMethod.JPEG: - data["jpeg_sizes"] = self._jpeg_sizes - - if self.record & SharpnessMethod.FOCUS_FOM: - data["focus_foms"] = self._focus_foms - else: - data["focus_foms"] = [] - return SharpnessDataArrays(**data) + return SharpnessDataArrays( + jpeg_times=np.array(self._jpeg_times), + jpeg_sizes=( + np.array(self._jpeg_sizes) + if self.record & SharpnessMethod.JPEG + else np.array([]) + ), + focus_foms=( + np.array(self._focus_foms) + if self.record & SharpnessMethod.FOCUS_FOM + else np.array([]) + ), + stage_times=np.array(self._stage_times), + stage_positions=self._stage_positions, + ) def data_dict(self) -> dict: """Return the gathered data as dict.""" From d12ca97faca9be95b5f2abf04170a69d54721515 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 13 May 2026 17:24:12 +0100 Subject: [PATCH 13/13] We are now a picamera testing organisation --- picamera_coverage.zip | Bin 54038 -> 54038 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/picamera_coverage.zip b/picamera_coverage.zip index 121e8c33f5b1271b9fbaf3f925059ecd133bca41..a29f518f023a9183533f48a1cac72e3307fe8dd0 100644 GIT binary patch delta 1620 zcmbQXjCtBJW}yIYW)=|!5Gd$c8#60L$Zn!ggHkdhFMk*V4_hw-e;ltrzb?-lUN!EE zJh_}*-1FEIIg{CXH#-V&vTg2QJH*D-9L>ZoE-K2{F1a~`_cG(=82(!VY!4au-}67* z>?m-Rf3ko9pCCUYv$S|wYEf}!exA9Wg`Ux70|RLR%~`z3yaqhqcy{vC@i_2sa$n@` z<__c*B;?`Hf)YkEDVk8lNtRCCR=;*u}ZQqG;)J^b)Ni8b`p~feDo%7@Mf7@ z>&3(7AkM`D8OlKte2Jwc z`DyvdrNw#$l`g`QZM|Js&4id4ib0Y*>4`ZxsYR7~1(l|PlMi~EGT8`B?)TK0yxv=Y z$&!C^fsg9s0#A1S;@rfdlKA4}#5}!%N^8DJ-qx%ZybueNCj0sdvl{X+Gh~9j_}-Ui z@kgy_pI$sE%1n;xWjsj$z+le6*1Y~NYY^LHxxS#dC{|WR&PEefreEL9HtQJA zmh2K?WXNY=U~pjJV`#2o+!)u$X7GSPfkA@DYy;yBh8SC2h8toHwGj?X!VJtCBpDc# z8>C;%XPEo^*jW)KjRy_94TfylnWhE~(kg~5lZ*NcC%@_s<$lJ0pZ^B`MgCL#hc`P4 ztmM~ZV`1cEEsI+Y$o^kiB9ghcsMxO)YvlBGR@2~ z*(}*0InmHK#W*#^EZNw?%*@Cn)i}+_(8$!>Ak|o@Ho%*aNrV}-Gc|LN_ z;j!Qh<<4Z+;nZV`*m&>?>*l>|huFAU1en;xMMW7qm^XLwUS>2_P*70N)m2bX&qyrJ zP**5PttbHr!LhEcf=6O;hC+UtLPDtYX<|8XDV z?&l8V=H@!bHJK}tOOo?C=WNbcP6>{yn;iuvaxgi{P5$fY#3(!2!ONM+MrQJUPa9S% zX=aA%$&6l_BE=<%>8biDrMbD4dIgmZQY;LOtRRWWpS*-w?Ic+k8Ydr&)DnXzi;vID z%PfhH*DI*BkYHvg2P<6f&BJ6VK6!(W6^j@%LoHZL+?SupMRf9BZx<#Tk;(f#btcdA z5nweHW@ab^$+Hz#7MG;vPX6a@&1xqIwPSLDw;+?Hz~l`+s*_iGiZD6xPj>KhVm0Su zW+VOD(}W`Z8W}-6 zQHUy}FqX7rE(gg>UhmDrC^30`j1`Nx4RbA6NZgm7Rm_#8k$3XJNQ22AyagFWCl|)p zvWS>57lIVPgE-oT1!e;%gc$`Vua8j!1u&z)Wcx^G7Jdun(#Z!Sl|jMF!UwTYd9rV; z2(tlmDo6qvwy}1T6Jw1SxhDI^*=@Fp`=coGhJpVl|6BfD{PBDb`Ihn(^EvRo+3YBA ziI*veb24Xt5R)OtypzF{~G;~oN=&YPT*g{`*(zPwlV!nd=Wq-4I~7 zR~^gObXSU@?sY9cg9GFL-`^j+`H(4HC(FR_pWIqEl1A_zuTLH7dk6ZDPQ7jk2c&dj*JW^7?>IkCa_C4$R4~tYbBgrz&Ak8SzJjpcCGRefuBE={v*)%!P z#K1Vo(!|irz{Dag$v83DX!3;%&KgFRiD}8krb#KO#s-GTMrp>WiD`)`CPs#-DMpqC ziDpJ77Kz45CYF;gT(Ftk<0m?~=i=dD3)56%bIUX{qoia56H`Mo3q!M1(=?;RBqP)0 z6w}m1V*_&oW5ZN)rP=^*MkWzv)MAz40s{jG3xR_jLTs75=#sJmsNe;8Lx_oi0fZ$X Tl3;R6qrrvAJ{P1XT^0iXa=p6)