Restructured text fixes so that pydoctor would return without an error

This commit is contained in:
Julian Stirling 2025-07-09 23:55:46 +01:00
parent 58b056988a
commit a84a916719
31 changed files with 269 additions and 256 deletions

View file

@ -43,7 +43,6 @@ class RecentringThing(lt.Thing):
much between these sites, making the procedure more sensitive
to noise or a failed autofocus.
"""
max_steps = 20
dx = lateral_distance

View file

@ -33,10 +33,10 @@ class StackParams:
:param stack_dz: The number of motor steps between images
:param images_to_save: The number of images to save to disk
:param min_images_to_test: The minimum number of images in the stack before, the
stack is evaluated for focus. As more images are captured evaluation of the focus
is always evaluated with the same number of images. i.e. if min_images_to_test=9,
then 9 images are captured, if the stack is not well focused, a 10th image is
captured and images 2 to 10 are evaluated for focus
stack is evaluated for focus. As more images are captured evaluation of the
focus is always evaluated with the same number of images. i.e. if
``min_images_to_test=9``, then 9 images are captured, if the stack is not well
focused, a 10th image is captured and images 2 to 10 are evaluated for focus
:param autofocus_dz: The number of steps in a full autofocus (when required)
:param images_dir: The directory to save images to disk
:param save_resolution: The resolution to save the captures to disk with
@ -101,7 +101,8 @@ class StackParams:
Note that this is the range of the minimum number of images captured,
which is also the range of the images stored in memory that can be
saved."""
saved.
"""
return self.stack_dz * (self.min_images_to_test - 1)
@property
@ -109,7 +110,6 @@ class StackParams:
"""
The distance to deliberately undershoot the estimated optimal starting point
"""
# Starting too low by "steps_undershoot" makes smart stacking faster.
# Starting a stack too high requires it to move to the start,
# autofocus and then re-stack. Starting slightly too low only
@ -157,7 +157,7 @@ def _get_capture_by_id(captures: list[CaptureInfo], buffer_id: int) -> CaptureIn
:returns: the CaptureInfo object of the capture with matching id
:raises: ValueError if buffer_id does not match the buffer_id of any captures
:raises ValueError: if buffer_id does not match the buffer_id of any captures
"""
return captures[_get_capture_index_by_id(captures, buffer_id)]
@ -171,7 +171,7 @@ def _get_capture_index_by_id(captures: list[CaptureInfo], buffer_id: int) -> int
:returns: the list index of the capture with matching id
:raises: ValueError if buffer_id does not match the buffer_id of any captures
:raises ValueError: if buffer_id does not match the buffer_id of any captures
"""
ids = [capture.buffer_id for capture in captures]
if buffer_id not in ids:
@ -290,7 +290,8 @@ class AutofocusThing(lt.Thing):
Actions here involve moving a stage in z, and using the camera to either
capture images (generally, z-stacking) and measuring the sharpness of the
field of view to assess focus (autofocus and testing the success of a z-stack)"""
field of view to assess focus (autofocus and testing the success of a z-stack)
"""
@lt.thing_action
def fast_autofocus(
@ -336,9 +337,9 @@ class AutofocusThing(lt.Thing):
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.
``sum(dz)`` relative to the starting position.
If `wait` is specified, we will wait for that many seconds
If ``wait`` is specified, we will wait for that many seconds
between moves.
"""
with sharpness_monitor.run():
@ -358,9 +359,9 @@ class AutofocusThing(lt.Thing):
):
"""Repeatedly autofocus the stage until it looks focused.
This action will run the `fast_autofocus` action until it settles on a point
This action will run the ``fast_autofocus`` action until it settles on a point
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
is close to focus, but not quite within ``dz/2``. It will attempt to autofocus
up to 10 times.
"""
repeat = True
@ -444,17 +445,18 @@ class AutofocusThing(lt.Thing):
:param cam: Camera Dependency supplied by LabThings dependency injection
:param stage: Stage Dependency supplied by LabThings dependency injection
:param sharpness_monitor: Sharpness Monitor Dependency (for focus detection)
supplied by LabThings dependency injection
supplied by LabThings dependency injection
:param images_dir: the folder to save all images
:param autofocus_dz: the range to autofocus over if a stack fails
:param save_resolution: The resolution the images should be saved at, the
images will be resampled if this doesn't match the camera's capture resolution
images will be resampled if this doesn't match the camera's capture
resolution
:returns: A tuple containing:
- A boolean, True if stack was successfully
- The z position of the sharpest image
"""
* A boolean, True if stack was successfully
* The z position of the sharpest image
"""
# Set the variables to prevent changes from the GUI or other windows
stack_parameters = StackParams(
stack_dz=self.stack_dz,
@ -511,11 +513,12 @@ class AutofocusThing(lt.Thing):
"""Return to the initial height of the current stack, and run
a looping autofocus.
Arguments:
initial_z_pos: The initial z positions of previous captures
autofocus_dz: the range in steps to autofocus
variables stage and sharpness_monitor are Thing dependencies passed through from
the calling action
:param initial_z_pos: The initial z positions of previous captures
:param autofocus_dz: the range in steps to autofocus
``stage`` and ``sharpness_monitor`` are Thing dependencies passed through
from the calling action
"""
stage.move_absolute(z=initial_z_pos)
self.looping_autofocus(
@ -534,14 +537,13 @@ class AutofocusThing(lt.Thing):
"""Save the required captures to disk. Will save the sharpest image,
and any images either side of focus.
Arguments:
sharpest_id: the buffer id index of the sharpest image
captures: a list of captures, including file name, image data and metadata
stack_parameters: a StackParams object holding stack parameters
variables logger and capture are Thing dependencies passed through from the
calling action
"""
:param sharpest_id: the buffer id index of the sharpest image
:param captures: a list of captures, including file name, image data and
metadata
:param stack_parameters: a StackParams object holding stack parameters
:param cam: is a Thing dependency passed through from the calling action
"""
sharpest_index = _get_capture_index_by_id(captures, sharpest_id)
slice_to_save = stack_parameters.slice_to_save(sharpest_index)
@ -569,9 +571,10 @@ class AutofocusThing(lt.Thing):
:param stage: Stage Dependency to be passed through from the calling action
:returns: A tuple of
- the stack result (True for successful stack, False for failed stack),
- a list of CaptureInfo objects,
- the buffer_id of the shapest image (or None if the stack failed)
* the stack result (True for successful stack, False for failed stack),
* a list of CaptureInfo objects,
* the buffer_id of the shapest image (or None if the stack failed)
"""
# Move down by the height of the z stack, plus an overshoot
# Better to start too low and take too many images than too high and need to refocus
@ -628,11 +631,11 @@ class AutofocusThing(lt.Thing):
:param cam: Camera Dependency to be passed through from the calling action
:param stage: Stage Dependency to be passed through from the calling action
:buffer_max: The maximum number of images to tell the camera to keep in memory
for saving once the stack is complete
:param buffer_max: The maximum number of images to tell the camera to keep in memory
for saving once the stack is complete
:return: A CaptureInfo object containing the capture information including its
camera buffer_id needed for saving.
:returns: A CaptureInfo object containing the capture information including its
camera buffer_id needed for saving.
"""
stage_location = stage.position
buffer_id = cam.capture_to_memory(buffer_max=buffer_max)
@ -649,14 +652,19 @@ class AutofocusThing(lt.Thing):
stack is centrally enough in the stack
:param captures: a list of the capture objects to for testing if the
sharpeness has converged in the centre
sharpness has converged in the centre
:return: A tuple with two values:
- result - which is one of three literal values:
'success' if the sharpest image is towards the centre
'continue' if the sharpest image is in the final two images of the list
'restart' if the sharpest image is in the first two images of the list
- capture_id - the buffer id of the sharpest image
:returns: A tuple with two values:
* result - which is one of three literal values:
* ``success`` if the sharpest image is towards the centre
* ``continue`` if the sharpest image is in the final two images of the
list
* ``restart`` if the sharpest image is in the first two images of the
list
* capture_id - the buffer id of the sharpest image
"""
sharpest_index = np.argmax([capture.sharpness for capture in captures])
# The buffer id of the sharpest image

View file

@ -70,12 +70,12 @@ class CameraMemoryBuffer:
every time an image is added.
:param image: The image to add. A PIL image is recommended, but cameras
can choose to use other formats
can choose to use other formats
:param metadata: Optional, a dictionary of the image metadata.
:param buffer_max: The maximum number of images that should be in the buffer
once this images is added. Default is 1.
once this images is added. Default is 1.
:return buffer_id: The id in the buffer for this image
:returns: The id in the buffer for this image
"""
self._latest_id += 1
self._create_space(buffer_max)
@ -94,9 +94,8 @@ class CameraMemoryBuffer:
:param buffer_id: The buffer id of the image to retrieve
:param remove: True (default) to remove this image from the buffer, False
to leave the image in the buffer.
to leave the image in the buffer.
"""
# No id given
if buffer_id is None:
# Get the latest image and metadata tuple from storage
@ -128,7 +127,7 @@ class CameraMemoryBuffer:
Create space to add an image.
:param buffer_max: The maximum number of images that should be in the buffer
once another images is added.
once another images is added.
"""
# If only one image to be stored just clear the storage and return
if buffer_max <= 1:
@ -164,7 +163,8 @@ class BaseCamera(lt.Thing):
self, main_resolution: tuple[int, int], buffer_count: int
) -> None:
"""Start (or stop and restart) the camera with the given resolution
for the main stream, and buffer_count number of images in the buffer"""
for the main stream, and buffer_count number of images in the buffer
"""
raise NotImplementedError(
"CameraThings must define their own start_streaming method"
)
@ -175,9 +175,9 @@ class BaseCamera(lt.Thing):
This is called when uvicorn gets the a shutdown signal. As this is called from
the event loop it cannot interact with the our ThingProperties or run
`self.mjpeg_stream.stop()` as the portal cannot be called from this loop.
``self.mjpeg_stream.stop()`` as the portal cannot be called from this loop.
Instead we just set the `_streaming` value to False. This stops the async frame
Instead we just set the ``_streaming`` value to False. This stops the async frame
generator when the next frame notifies.
"""
if self.stream_active:
@ -186,7 +186,7 @@ class BaseCamera(lt.Thing):
@lt.thing_property
def stream_active(self) -> bool:
"Whether the MJPEG stream is active"
"""Whether the MJPEG stream is active"""
raise NotImplementedError(
"CameraThings must define their own stream_active method"
)
@ -221,7 +221,7 @@ class BaseCamera(lt.Thing):
) -> JPEGBlob:
"""Acquire one image from the preview stream and return as an array
This differs from `capture_jpeg` in that it does not pause the MJPEG
This differs from ``capture_jpeg`` in that it does not pause the MJPEG
preview stream. Instead, we simply return the next frame from that
stream (either "main" for the preview stream, or "lores" for the low
resolution preview). No metadata is returned.
@ -267,11 +267,11 @@ class BaseCamera(lt.Thing):
:param jpeg_path: The path to save the file to
:param logger: This should be injected automatically by Labthings FastAPI
when calling the action
when calling the action
:param metadata_getter: This should be injected automatically by Labthings
FastAPI when calling the action
FastAPI when calling the action
:param save_resolution: can be set to resize the image before saving. By
default this is None meaning that the image is saved at original resolution.
default this is None meaning that the image is saved at original resolution.
"""
image, metadata = self._robust_image_capture(
metadata_getter,
@ -294,19 +294,19 @@ class BaseCamera(lt.Thing):
buffer_max: int = 1,
) -> None:
"""
Capture an image to memory. This can be saved later with `save_from_memory`
Capture an image to memory. This can be saved later with ``save_from_memory``
Note that only one image is held in memory so this will overwrite any image
in memory.
:param logger: This should be injected automatically by Labthings FastAPI
when calling the action
when calling the action
:param metadata_getter: This should be injected automatically by Labthings
FastAPI when calling the action
FastAPI when calling the action
:param buffer_max: The maximum number of images that should be in the buffer
once this images is added. Default is 1.
once this images is added. Default is 1.
:return: the buffer id of the image captured
:returns: the buffer id of the image captured
"""
image, metadata = self._robust_image_capture(metadata_getter, logger)
return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max)
@ -324,11 +324,12 @@ class BaseCamera(lt.Thing):
:param jpeg_path: The path to save the file to
:param logger: This should be injected automatically by Labthings FastAPI
when calling the action
when calling the action
:param save_resolution: can be set to resize the image before saving. By
default this is None meaning that the image is saved at original resolution.
default this is None meaning that the image is saved at original
resolution.
:param buffer_id: The buffer id of the image to save, this was returned by
`capture_to_memory`
``capture_to_memory``
"""
image, metadata = self._memory_buffer.get_image(buffer_id)
@ -379,7 +380,8 @@ class BaseCamera(lt.Thing):
"""Saving the captured image and metadata to disk
logger warning (via InvocationLogger) is raised if metadata is failed to be added
IOError is raised if the file cannot be saved
nothing is returned on success"""
nothing is returned on success
"""
if save_resolution is not None and image.size != save_resolution:
image = image.resize(save_resolution, Image.BOX)
try:

View file

@ -1,7 +1,7 @@
"""OpenFlexure Microscope OpenCV Camera
This module defines a camera Thing that uses OpenCV's
`VideoCapture`.
``VideoCapture``.
See repository root for licensing information.
"""
@ -45,7 +45,7 @@ class OpenCVCamera(BaseCamera):
@lt.thing_property
def stream_active(self) -> bool:
"Whether the MJPEG stream is active"
"""Whether the MJPEG stream is active"""
if self._capture_enabled and self._capture_thread:
return self._capture_thread.is_alive()
return False

View file

@ -264,7 +264,6 @@ class StreamingPiCamera2(BaseCamera):
@sensor_mode.setter
def sensor_mode(self, new_mode: Optional[SensorModeSelector | dict]):
"""Change the sensor mode used"""
if new_mode is None:
self._sensor_mode = None
elif isinstance(new_mode, SensorModeSelector):
@ -287,9 +286,9 @@ class StreamingPiCamera2(BaseCamera):
tuning = lt.ThingSetting(Optional[dict], None, readonly=True)
def _initialise_picamera(self):
"""Acquire the picamera device and store it as `self._picamera`.
"""Acquire the picamera device and store it as ``self._picamera``.
This duplicates logic in `Picamera2.__init__` to provide a tuning file that
This duplicates logic in ``Picamera2.__init__`` to provide a tuning file that
will be read when the camera system initialises.
"""
if self._picamera_lock is not None:
@ -332,16 +331,17 @@ class StreamingPiCamera2(BaseCamera):
@contextmanager
def _streaming_picamera(self, pause_stream=False) -> Iterator[Picamera2]:
"""Lock access to picamera and return the underlying `Picamera2` instance.
"""Lock access to picamera and return the underlying ``Picamera2`` instance.
Optionally the stream can be paused to allow updating the camera settings.
:param pause_stream: If False the `Picamera2` instance is simply yielded.
If True:
* Stop the MJPEG Stream
* Yield the `Picamera2` instance for function calling the context manager to
make changes.
* On closing of the context manager the stream will restart.
:param pause_stream: If False the ``Picamera2`` instance is simply yielded.
If True:
* Stop the MJPEG Stream
* Yield the ``Picamera2`` instance for function calling the context manager to
make changes.
* On closing of the context manager the stream will restart.
"""
already_streaming = self.stream_active
with self._picamera_lock:
@ -372,8 +372,9 @@ class StreamingPiCamera2(BaseCamera):
manual.
Create two streams:
- `lores_mjpeg_stream` for autofocus at low-res resolution
- `mjpeg_stream` for preview. This is the `main_resolution` if this is less
* ``lores_mjpeg_stream`` for autofocus at low-res resolution
* ``mjpeg_stream`` for preview. This is the ``main_resolution`` if this is less
than (1280, 960), or the low-res resolution if above. This allows for
high resolution capture without streaming high resolution video.
@ -489,7 +490,6 @@ class StreamingPiCamera2(BaseCamera):
A TimeoutError is raised if this time is exceeded during capture.
Default = 0.9s, lower than the 1s timeout default in picamera yaml settings
"""
# This was slower than capture_image for our use case, but directly returning
# an image as an array is still a useful feature
if stream_name == "full":
@ -523,13 +523,13 @@ class StreamingPiCamera2(BaseCamera):
) -> JPEGBlob:
"""Acquire one image from the camera as a JPEG
The JPEG will be acquired using `Picamera2.capture_file`. If the
`resolution` parameter is `main` or `lores`, it will be captured
The JPEG will be acquired using ``Picamera2.capture_file``. If the
``resolution`` parameter is ``main`` or ``lores``, it will be captured
from the main preview stream, or the low-res preview stream,
respectively. This means the camera won't be reconfigured, and
the stream will not pause (though it may miss one frame).
If `full` resolution is requested, we will briefly pause the
If ``full`` resolution is requested, we will briefly pause the
MJPEG stream and reconfigure the camera to capture a full
resolution image.
@ -588,11 +588,12 @@ class StreamingPiCamera2(BaseCamera):
the image reaches the specified white level.
:param target_white_level: The target 10bit white level. 10-bit data has a
theoretical maximum of 1023, but with black level correction the true maxiumum
is about 950. Default is 700 as this is approximately 70% saturated.
theoretical maximum of 1023, but with black level correction the true
maximum is about 950. Default is 700 as this is approximately 70%
saturated.
:param percentile: The percentile to use instead of maximum. Default 99.9. When
calculating the brightest pixel, a percentile is used rather than the
maximum in order to be robust to a small number of noisy/bright pixels.
calculating the brightest pixel, a percentile is used rather than the
maximum in order to be robust to a small number of noisy/bright pixels.
"""
with self._streaming_picamera(pause_stream=True) as cam:
recalibrate_utils.adjust_shutter_and_gain_from_raw(
@ -615,7 +616,7 @@ class StreamingPiCamera2(BaseCamera):
image with the lens shading correction applied, which should mean
that the image is uniform, rather than weighted towards the centre.
If `method` is `"centre"`, we will correct the mean of the central 10%
If ``method`` is ``"centre"``, we will correct the mean of the central 10%
of the image.
"""
with self._streaming_picamera(pause_stream=True) as cam:
@ -657,7 +658,7 @@ class StreamingPiCamera2(BaseCamera):
def colour_correction_matrix(
self,
) -> tuple[float, float, float, float, float, float, float, float, float]:
"""The `colour_correction_matrix` from the tuning file.
"""The ``colour_correction_matrix`` from the tuning file.
This is broken out into its own property for convenience and compatibility with
the micromanager API
@ -720,11 +721,11 @@ class StreamingPiCamera2(BaseCamera):
This function will call the other calibration actions in sequence:
* `flat_lens_shading` to disable flat-field
* `auto_expose_from_minimum`
* `set_static_green_equalisation` to set geq offset to max
* `calibrate_lens_shading`
* `calibrate_white_balance`
* ``flat_lens_shading`` to disable flat-field
* ``auto_expose_from_minimum``
* ``set_static_green_equalisation`` to set geq offset to max
* ``calibrate_lens_shading``
* ``calibrate_white_balance``
"""
self.flat_lens_shading()
self.auto_expose_from_minimum()
@ -804,15 +805,15 @@ class StreamingPiCamera2(BaseCamera):
The white balance algorithm we use assumes the brightest pixels
should be white, and that the only thing affecting the colour of
said pixels is the `colour_gains`.
said pixels is the ``colour_gains``.
The lens shading correction is normalised such that the *minimum*
gain in the `Cr` and `Cb` channels is 1. The white balance
gain in the ``Cr`` and ``Cb`` channels is 1. The white balance
assumption above requires that the gain for the brightest pixels
is 1. The solution might be that, when calibrating, we note which
pixels are brightest (usually the centre) and explicitly use
the LST values for there. However, for now I will assume that we
need to normalise by the **maximum** of the `Cr` and `Cb`
need to normalise by the **maximum** of the ``Cr`` and ``Cb``
channels, which is correct the majority of the time.
"""
if not self.lens_shading_is_static:

View file

@ -3,7 +3,7 @@ Functions to set up a Raspberry Pi Camera v2 for scientific use
This module provides slower, simpler functions to set the
gain, exposure, and white balance of a Raspberry Pi camera, using
the `picamera2` Python library. It's mostly used by the OpenFlexure
the ``picamera2`` Python library. It's mostly used by the OpenFlexure
Microscope, though it deliberately has no hard dependencies on
said software, so that it's useful on its own.
@ -20,14 +20,15 @@ to "memory" or nonlinearities in the camera's image processing
pipeline, is to use raw images. This is quite slow, but very
reliable. The three steps above can be accomplished by:
```
picamera = picamera2.Picamera2()
.. code-block:: python
picamera = picamera2.Picamera2()
adjust_shutter_and_gain_from_raw(picamera)
adjust_white_balance_from_raw(picamera)
lst = lst_from_camera(picamera)
picamera.lens_shading_table = lst
adjust_shutter_and_gain_from_raw(picamera)
adjust_white_balance_from_raw(picamera)
lst = lst_from_camera(picamera)
picamera.lens_shading_table = lst
```
"""
# Disable N806 & 803, which checks that all variables and args are lowercase.
@ -55,7 +56,7 @@ def load_default_tuning(cam: Picamera2) -> dict:
"""Load the default tuning file for the camera
This will open and close the camera to determine its model. If you are
using a model that's supported by `picamera2` it should have a tuning
using a model that's supported by ``picamera2`` it should have a tuning
file built in. If not, this will probably crash with an error.
Error handling for unsupported cameras is not something we are likely
@ -152,25 +153,19 @@ def adjust_shutter_and_gain_from_raw(
This routine is slow but effective. It uses raw images, so we
are not affected by white balance or digital gain.
:param camera: A Picamera2 object.
:param target_white_level: The raw, 10-bit value we aim for. The brightest pixels
should be approximately this bright. Maximum possible is about 900, 700 is
reasonable.
:param max_iterations: We will terminate once we perform this many iterations,
whether or not we converge. More than 10 shouldn't happen.
:param tolerance: How close to the target value we consider "done". Expressed as a
fraction of the ``target_white_level`` so 0.05 means +/- 5%
:param percentile: Rather then use the maximum value for each channel, we calculate
a percentile. This makes us robust to single pixels that are bright/noisy.
99.9% still picks the top of the brightness range, but seems much more reliable
than just ``np.max()``.
Arguments:
target_white_level:
The raw, 10-bit value we aim for. The brightest pixels
should be approximately this bright. Maximum possible
is about 900, 700 is reasonable.
max_iterations:
We will terminate once we perform this many iterations,
whether or not we converge. More than 10 shouldn't happen.
tolerance:
How close to the target value we consider "done". Expressed
as a fraction of the ``target_white_level`` so 0.05 means
+/- 5%
percentile:
Rather then use the maximum value for each channel, we
calculate a percentile. This makes us robust to single
pixels that are bright/noisy. 99.9% still picks the top
of the brightness range, but seems much more reliable
than just ``np.max()``.
"""
# TODO: read black level and bit depth from camera?
if target_white_level * (tolerance + 1) >= 959:
@ -351,7 +346,7 @@ def get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray:
def upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray:
"""Zoom an image in the last two dimensions
This is effectively the inverse operation of `get_16x12_grid`
This is effectively the inverse operation of ``get_16x12_grid``
"""
zoom_factors = [
1,
@ -381,7 +376,7 @@ def downsampled_channels(channels: np.ndarray, blacklevel=64) -> list[np.ndarray
def lst_from_channels(channels: np.ndarray) -> LensShadingTables:
"""Given the 4 Bayer colour channels from a white image, generate a LST.
Internally, is just calls `downsampled_channels` and `lst_from_grids`.
Internally, is just calls ``downsampled_channels`` and ``lst_from_grids``.
"""
grids = downsampled_channels(channels)
return lst_from_grids(grids)
@ -392,11 +387,10 @@ def lst_from_grids(grids: np.ndarray) -> LensShadingTables:
The grids are the 4 BAYER channels RGGB
The LST format has changed with `picamera2` and now uses a fixed resolution,
The LST format has changed with ``picamera2`` and now uses a fixed resolution,
and is in luminance, Cr, Cb format. This function returns three ndarrays of
luminance, Cr, Cb, each with shape (12, 16).
"""
# Calculated red, green, and blue channels from Bayer data
r: np.ndarray = grids[3, ...]
g: np.ndarray = np.mean(grids[1:3, ...], axis=0)
@ -419,7 +413,7 @@ def grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarra
Note that these will be normalised - the maximum green value is always 1.
Also, note that the channels are BGGR, to be consistent with the
`channels_from_raw_image` function. This should probably change in the
``channels_from_raw_image`` function. This should probably change in the
future.
"""
G = 1 / np.array(lum)
@ -434,9 +428,9 @@ def set_static_lst(
cr: np.ndarray,
cb: np.ndarray,
) -> None:
"""Update the `rpi.alsc` section of a camera tuning dict to use a static correcton.
"""Update the ``rpi.alsc`` section of a camera tuning dict to use a static correcton.
`tuning` will be updated in-place to set its shading to static, and disable any
``tuning`` will be updated in-place to set its shading to static, and disable any
adaptive tweaking by the algorithm.
"""
for table in luminance, cr, cb:
@ -459,9 +453,9 @@ def set_static_ccm(
float, float, float, float, float, float, float, float, float
],
) -> None:
"""Update the `rpi.alsc` section of a camera tuning dict to use a static correcton.
"""Update the ``rpi.alsc`` section of a camera tuning dict to use a static correcton.
`tuning` will be updated in-place to set its shading to static, and disable any
``tuning`` will be updated in-place to set its shading to static, and disable any
adaptive tweaking by the algorithm.
"""
ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm")
@ -469,7 +463,7 @@ def set_static_ccm(
def get_static_ccm(tuning: dict) -> None:
"""Get the `rpi.ccm` section of a camera tuning dict"""
"""Get the ``rpi.ccm`` section of a camera tuning dict"""
ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm")
return ccm["ccms"]
@ -484,14 +478,13 @@ def set_static_geq(
tuning: dict,
offset: int = 65535,
) -> None:
"""Update the `rpi.geq` section of a camera tuning dict to always use green
"""Update the ``rpi.geq`` section of a camera tuning dict to always use green
equalisation that averages the green pixels in the red and blue rows.
`tuning` will be updated in-place to set the geq offest to the given value.
``tuning`` will be updated in-place to set the geq offest to the given value.
The default 65535 is the maximum allowed value. This means
the brightness will always be below the threshold where averaging is used.
"""
geq = Picamera2.find_tuning_algo(tuning, "rpi.geq")
geq["offset"] = offset # max out offset to disable the adaptive green equalisation
@ -511,7 +504,7 @@ def index_of_algorithm(algorithms: list[dict], algorithm: str) -> int:
def copy_alsc_section(from_tuning: dict, to_tuning: dict) -> None:
"""Copy the `rpi.alsc` algorithm from one tuning to another.
"""Copy the ``rpi.alsc`` algorithm from one tuning to another.
This is done in-place, i.e. modifying to_tuning.
"""

View file

@ -145,7 +145,7 @@ class SimulatedCamera(BaseCamera):
@lt.thing_property
def stream_active(self) -> bool:
"Whether the MJPEG stream is active"
"""Whether the MJPEG stream is active"""
if self._capture_enabled and self._capture_thread:
return self._capture_thread.is_alive()
return False

View file

@ -57,7 +57,7 @@ def downsample(factor: int, image: np.ndarray) -> np.ndarray:
"""Downsample an image by taking the mean of each nxn region
This should be very efficient: we calculate the mean of each
`factor * factor` square, no interpolation. If the image is
``factor * factor`` square, no interpolation. If the image is
not an integer multiple of the resampling factor, we discard
the left-over pixels. This avoids odd edge effects and keeps
performance quick.
@ -265,14 +265,16 @@ class CameraStageMapper(lt.Thing):
that conversion.
It is often helpful to give a concrete example: to make a move in image coordinates
(`dy`, `dx`), where `dx` is horizontal, i.e. the longer dimension of the image, you
(``dy``, ``dx``), where ``dx`` is horizontal, i.e. the longer dimension of the image, you
should move the stage by:
```
stage_disp = np.dot(
np.array(image_to_stage_displacement_matrix),
np.array([dy,dx]),
)
```
.. code-block:: python
stage_disp = np.dot(
np.array(image_to_stage_displacement_matrix),
np.array([dy,dx]),
)
"""
if self.last_calibration is None:
return None
@ -316,8 +318,8 @@ class CameraStageMapper(lt.Thing):
swap the order of these coordinates. This includes opencv and PIL. So, don't be
surprised if you find it necessary to swap x and y around.
As a general rule, `x` usually corresponds to the longer dimension of the image,
and `y` to the shorter one. Checking what shape your chosen toolkit reports for
As a general rule, ``x`` usually corresponds to the longer dimension of the image,
and ``y`` to the shorter one. Checking what shape your chosen toolkit reports for
an image usually helps resolve any ambiguity.
"""
self.assert_calibrated()

View file

@ -66,9 +66,9 @@ class SettingsManager(lt.Thing):
recursively, i.e. if a key exists, it will be added to rather than
replaced.
If a key is supplied, we will treat `data` as being relative to that
key, i.e. calling this action with `data={"a":1 }, key="foo/bar"` is
equivalent to calling it with `data={"foo": {"bar": {"a": 1}}}`.
If a key is supplied, we will treat ``data`` as being relative to that
key, i.e. calling this action with ``data={"a":1 }, key="foo/bar"`` is
equivalent to calling it with ``data={"foo": {"bar": {"a": 1}}}``.
"""
metadata = self.external_metadata
subdict = metadata
@ -84,8 +84,8 @@ class SettingsManager(lt.Thing):
"""Delete a key from the stored metadata.
The key may contain forward slashes, which are understood to separate
levels of the dictionary - i.e. `'a/c'` will remove the `c` key from a
dictionary that looks like: `{'a': {'c': 1}, 'b': {'d': 2}}`
levels of the dictionary - i.e. ``'a/c'`` will remove the ``c`` key from a
dictionary that looks like: ``{'a': {'c': 1}, 'b': {'d': 2}}``
"""
metadata = self.external_metadata
try:

View file

@ -114,7 +114,6 @@ class SmartScanThing(lt.Thing):
stopping once it is surrounded by "background" (as detected by the
background_detect Thing) or reaches the "max_range" measured in steps.
"""
got_lock = self._scan_lock.acquire(timeout=0.1)
if not got_lock:
raise RuntimeError("Trying to run scan while scan is already running!")
@ -214,7 +213,6 @@ class SmartScanThing(lt.Thing):
Returns the (x,y,z) with the chosen z_estimate
"""
if z_estimate is None:
z_estimate = self._stage.position["z"]
@ -233,9 +231,9 @@ class SmartScanThing(lt.Thing):
Take a test image and use camera stage mapping to calculate x and y displacement
:param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means
that each image should overlap its nearest neighbour by 50%.
that each image should overlap its nearest neighbour by 50%.
Return (dx, dy) - the x and y displacments in steps
:returns: (dx, dy) - the x and y displacments in steps
"""
test_jpg = self._cam.grab_jpeg()
test_image = np.array(Image.open(test_jpg.open()))
@ -375,7 +373,6 @@ class SmartScanThing(lt.Thing):
"""
Manage the stitching threads, starting them if needed and not already running.
"""
# Assume 4 images means at least one offset in x and y, making the stitching
# well constrained.
if self._scan_images_taken > 3:
@ -390,7 +387,6 @@ class SmartScanThing(lt.Thing):
determine whether the scan should be stitched and the microscope
should return to the starting x,y,z position
"""
# Used to check if finally was reached via exception (except
# cancel by user)
scan_successful = True
@ -462,7 +458,6 @@ class SmartScanThing(lt.Thing):
The loop to run through during a scan, until no more scan x,y positions
are remaining.
"""
# The initial plan for the scan should be a single x,y position. All future
# moves will be planned around this point. In future, route planner could
# have multiple starting positions, each of which will be visited before the
@ -537,7 +532,6 @@ class SmartScanThing(lt.Thing):
@_scan_running
def _perform_final_stitch(self):
"""Update the scan zip and perform final stitch of the data"""
if self._scan_images_taken <= 3:
self._scan_logger.info("Not performing a stitch as 3 or fewer images taken")
return
@ -608,7 +602,7 @@ class SmartScanThing(lt.Thing):
model=bool,
description="""Whether to detect and skip empty fields of view
This uses the settings from the `background_detect` Thing.""",
This uses the settings from the ``BackgroundDetectThing``.""",
)
autofocus_dz = lt.ThingSetting(
@ -638,7 +632,7 @@ class SmartScanThing(lt.Thing):
Each scan has a name (which can be used to access it), along with
its modified and created times (according to the filesystem) and
the number of items in the `images` folder. Note that image count
the number of items in the ``images`` folder. Note that image count
uses a regular expression, and changes to the naming scheme will
break it.
"""
@ -660,8 +654,8 @@ class SmartScanThing(lt.Thing):
"""Return the stitched image corresponding to a given scan name, if it exists.
Will only return a file ending in suffix STITCH_SUFFIX
Note: when downloading this, the default filename will be `scan_name`.jpeg"""
Note: when downloading this, the default filename will be ``scan_name``.jpeg
"""
stitch_path = self._scan_dir_manager.get_final_stitch_path(scan_name)
if stitch_path is None:
@ -709,7 +703,6 @@ class SmartScanThing(lt.Thing):
"""
Delete all scan folders containing no images at the top level
"""
# JSON is ignored as it's created before any images are captured
for scan_info in self._scan_dir_manager.all_scans_info():
if scan_info.number_of_images == 0:
@ -732,7 +725,6 @@ class SmartScanThing(lt.Thing):
@property
def latest_preview_stitch_path(self) -> Optional[str]:
"""The path of the latest preview stitched image, or None if not available"""
if not self.latest_scan_name:
return None
@ -744,9 +736,10 @@ class SmartScanThing(lt.Thing):
def latest_preview_stitch_time(self) -> Optional[float]:
"""The modification time of the latest preview image, to allow live updating
This will return None (`null` to JS) if there is no preview image to return.
This will return None (``null`` to JS) if there is no preview image to return.
This is used for two reasons:
1. If all caching was turned off this stitch would be sent over the network
repeatedly
2. If caching was is on, then the stitch will not update when needed.
@ -834,12 +827,14 @@ class SmartScanThing(lt.Thing):
Raises:
ChildProcessError if exit code is not zero
InvocationCancelledError if the action is cancelled.
"""
logger.info(f"Running command in subprocess: `{' '.join(cmd)}`")
def log_buffer(buffer):
"""A short internal function to read everything in the buffer to
the log"""
the log
"""
while line := buffer.readline():
logger.info(line)
@ -968,6 +963,7 @@ class SmartScanThing(lt.Thing):
scan_name: str,
):
"""Update the zip to include the files left until the end, then return the
zip file as a Blob"""
zip file as a Blob
"""
zip_fname = self._scan_dir_manager.zip_scan(scan_name, final_version=True)
return ZipBlob.from_file(zip_fname)

View file

@ -1,5 +1,4 @@
from __future__ import annotations
from typing import TypeAlias
from collections.abc import Sequence, Mapping
import labthings_fastapi as lt
@ -10,8 +9,8 @@ class BaseStage(lt.Thing):
This can't be used directly but should reduce boilerplate code when
implementing new stages. A minimal working stage must implement
`move_relative` and `move_absolute` actions, which update the
`position` property on completion, and provide `set_zero_position`.
``move_relative`` and ``move_absolute`` actions, which update the
``position`` property on completion, and provide ``set_zero_position``.
"""
_axis_names = ("x", "y", "z")
@ -79,6 +78,4 @@ class BaseStage(lt.Thing):
)
StageDependency: TypeAlias = lt.deps.direct_thing_client_dependency(
BaseStage, "/stage/"
)
StageDependency = lt.deps.direct_thing_client_dependency(BaseStage, "/stage/")

View file

@ -13,13 +13,26 @@ from . import BaseStage
class SangaboardThing(BaseStage):
def __init__(self, port: str = None, **kwargs):
"""A Thing to manage a Sangaboard motor controller
"""A Thing to manage a Sangaboard motor controller
Internally, this uses the ``pysangaboard`` package from PyPi. This imports
as ``sangaboard``. As ``pysangaboard`` does not support some features added
to the Sangaboard firmware v1 (LED flashing, aborting moves, etc) this
functionality is accessed by directly querying the serial interface.
"""
def __init__(self, port: str = None, **kwargs):
"""Initialise SangaboardThing.
Initialise the "Thing", but do not initialise an underlying
``Sangaboard`` object from ``pysangaboard`` until the Thing context
manager is started.
:param port: The serial port for the Sangaboard. Optional, this is used
to stop the Sangaboard object querying available devices.
:param ``**kwargs``: Any other keyword arguments to be passed to the
Sangaboard class
Internally, this uses the `pysangaboard` package from PyPi. This imports
as `sangaboard`. As `pysangaboard` does not support some features added
to the Sangaboard firmware v1 (LED flashing, aborting moves, etc) this
functionality is accessed by directly querying the serial interface.
"""
self.sangaboard_kwargs = kwargs
self.sangaboard_kwargs["port"] = port
@ -41,9 +54,9 @@ class SangaboardThing(BaseStage):
@contextmanager
def sangaboard(self) -> Iterator[sangaboard.Sangaboard]:
"""Return the wrapped `sangaboard.Sangaboard` instance.
"""Return the wrapped ``sangaboard.Sangaboard`` instance.
This is protected by a `threading.RLock`, which may change in future.
This is protected by a ``threading.RLock``, which may change in future.
"""
with self._sangaboard_lock:
yield self._sangaboard

View file

@ -36,9 +36,7 @@ class SystemControlThing(lt.Thing):
@lt.thing_property
def is_raspberrypi() -> bool:
"""
Checks if we are running on a Raspberry Pi.
"""
"""Return True if running on a Raspberry Pi."""
return os.path.exists("/usr/bin/raspi-config")
@lt.thing_action