Formatted with Ruff

This commit is contained in:
Richard Bowman 2024-12-03 06:46:08 +00:00
parent 27aec769a2
commit 9fd8fc37f1
19 changed files with 680 additions and 392 deletions

View file

@ -5,6 +5,7 @@ Copyright (c) 2018 German Aerospace Center (DLR). All rights reserved.
SPDX-License-Identifier: MIT-DLR SPDX-License-Identifier: MIT-DLR
""" """
import json import json
import os import os
@ -21,7 +22,7 @@ class Zenodo:
self.zenodo_url = "https://zenodo.org/api/deposit/depositions" self.zenodo_url = "https://zenodo.org/api/deposit/depositions"
def create_new_deposit(self): def create_new_deposit(self):
""" Creates a new (unpublished) Zenodo deposit and return its deposition ID. """ """Creates a new (unpublished) Zenodo deposit and return its deposition ID."""
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
r = requests.post( r = requests.post(
@ -35,7 +36,7 @@ class Zenodo:
return r.json() return r.json()
def set_metadata(self, deposition_id, metadata): def set_metadata(self, deposition_id, metadata):
""" Sets the given metadata for the specified deposit. """ """Sets the given metadata for the specified deposit."""
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
r = requests.put( r = requests.put(
@ -48,7 +49,7 @@ class Zenodo:
print(r.json()) print(r.json())
def upload_file(self, deposition_id, file_path): def upload_file(self, deposition_id, file_path):
""" Uploads a new file for the given deposit. """ """Uploads a new file for the given deposit."""
file_name = os.path.basename(file_path) file_name = os.path.basename(file_path)
data = {"filename": file_name} data = {"filename": file_name}
@ -63,7 +64,7 @@ class Zenodo:
print(r.json()) print(r.json())
def publish_deposit(self, deposition_id): def publish_deposit(self, deposition_id):
""" Publishes the given deposit. BEWARE: It is now visible to all!!! """ """Publishes the given deposit. BEWARE: It is now visible to all!!!"""
r = requests.post( r = requests.post(
self.zenodo_url + "/{}/actions/publish".format(deposition_id), self.zenodo_url + "/{}/actions/publish".format(deposition_id),
@ -73,7 +74,7 @@ class Zenodo:
print(r.json()) print(r.json())
def create_new_version(self, deposition_id): def create_new_version(self, deposition_id):
""" Creates a new version of an already published deposit. """ """Creates a new version of an already published deposit."""
r = requests.post( r = requests.post(
self.zenodo_url + "/{}/actions/newversion".format(deposition_id), self.zenodo_url + "/{}/actions/newversion".format(deposition_id),
@ -84,7 +85,7 @@ class Zenodo:
return os.path.basename(r.json()["links"]["latest_draft"]) return os.path.basename(r.json()["links"]["latest_draft"])
def remove_all_files(self, deposition_id): def remove_all_files(self, deposition_id):
""" Removes all uploaded files of a unpublished deposit. """ """Removes all uploaded files of a unpublished deposit."""
r = requests.get( r = requests.get(
self.zenodo_url + "/{}/files".format(deposition_id), self.zenodo_url + "/{}/files".format(deposition_id),

View file

@ -4,9 +4,10 @@ import os
from fastapi.responses import FileResponse, PlainTextResponse from fastapi.responses import FileResponse, PlainTextResponse
OFM_LOG_FOLDER = "/var/openflexure/logs/" OFM_LOG_FOLDER = "/var/openflexure/logs/"
OFM_LOG_FILE = os.path.join(OFM_LOG_FOLDER, "openflexure_microscope.log") OFM_LOG_FILE = os.path.join(OFM_LOG_FOLDER, "openflexure_microscope.log")
def configure_logging(): def configure_logging():
root_logger = logging.getLogger() root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO) root_logger.setLevel(logging.INFO)
@ -14,15 +15,13 @@ def configure_logging():
if not os.path.exists(OFM_LOG_FOLDER): if not os.path.exists(OFM_LOG_FOLDER):
os.makedirs(OFM_LOG_FOLDER) os.makedirs(OFM_LOG_FOLDER)
handler = RotatingFileHandler( handler = RotatingFileHandler(
filename = OFM_LOG_FILE, filename=OFM_LOG_FILE,
mode = "a", mode="a",
maxBytes = 1000000, maxBytes=1000000,
backupCount = 10, backupCount=10,
) )
handler.setFormatter( handler.setFormatter(
logging.Formatter( logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s")
"[%(asctime)s] [%(levelname)s] %(message)s"
)
) )
root_logger.addHandler(handler) root_logger.addHandler(handler)
except PermissionError as e: except PermissionError as e:

View file

@ -43,5 +43,3 @@ def serve_from_cli(argv: Optional[list[str]] = None):
uvicorn.run(app, host=args.host, port=args.port) uvicorn.run(app, host=args.host, port=args.port)
else: else:
raise e raise e

View file

@ -5,6 +5,7 @@ from socket import gethostname
def add_v2_endpoints(thing_server: ThingServer): def add_v2_endpoints(thing_server: ThingServer):
app = thing_server.app app = thing_server.app
# TODO: update openflexure connect to make this unnecessary!! # TODO: update openflexure connect to make this unnecessary!!
# The endpoints below fool OpenFlexure Connect into thinking we are a # The endpoints below fool OpenFlexure Connect into thinking we are a
# v2 microscope, so we show up correctly. # v2 microscope, so we show up correctly.
@ -15,7 +16,7 @@ def add_v2_endpoints(thing_server: ThingServer):
fake_routes = [ fake_routes = [
"/api/v2/", "/api/v2/",
"/api/v2/streams/snapshot", "/api/v2/streams/snapshot",
"/api/v2/instrument/settings/name" "/api/v2/instrument/settings/name",
] ]
return {url: {"url": url, "methods": ["GET"]} for url in fake_routes} return {url: {"url": url, "methods": ["GET"]} for url in fake_routes}

View file

@ -4,22 +4,23 @@ from fastapi import FastAPI
import os import os
import pathlib import pathlib
def add_static_file(app: FastAPI, fname: str, folder: str): def add_static_file(app: FastAPI, fname: str, folder: str):
print(f"Adding route for /{fname}") print(f"Adding route for /{fname}")
p=os.path.join(folder, fname) p = os.path.join(folder, fname)
app.get( app.get(f"/{fname}", response_class=FileResponse, include_in_schema=False)(
f"/{fname}", lambda: FileResponse(p)
response_class=FileResponse, )
include_in_schema=False
)(lambda: FileResponse(p))
def add_static_files(app: FastAPI): def add_static_files(app: FastAPI):
#with importlib.resources.as_file(openflexure_microscope_server) as p: # with importlib.resources.as_file(openflexure_microscope_server) as p:
# static_path = p.join("/static/") # static_path = p.join("/static/")
#TODO: don't hard code this! # TODO: don't hard code this!
search_paths = [ search_paths = [
"/var/openflexure/application/openflexure-microscope-server/src/openflexure_microscope_server/static", "/var/openflexure/application/openflexure-microscope-server/src/openflexure_microscope_server/static",
pathlib.Path().absolute() / "application/openflexure-microscope-server/src/openflexure_microscope_server/static", pathlib.Path().absolute()
/ "application/openflexure-microscope-server/src/openflexure_microscope_server/static",
] ]
if __file__: if __file__:
search_paths.append(pathlib.Path(__file__).parent.parent / "static") search_paths.append(pathlib.Path(__file__).parent.parent / "static")
@ -34,6 +35,7 @@ def add_static_files(app: FastAPI):
@app.get("/", response_class=RedirectResponse) @app.get("/", response_class=RedirectResponse)
async def redirect_fastapi(): async def redirect_fastapi():
return "/index.html" return "/index.html"
for fname in os.listdir(static_path): for fname in os.listdir(static_path):
fpath = os.path.join(static_path, fname) fpath = os.path.join(static_path, fname)
if os.path.isfile(fpath): if os.path.isfile(fpath):

View file

@ -11,6 +11,7 @@ from openflexure_microscope_server.things.camera_stage_mapping import CameraStag
CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/")
AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/")
class RecentringThing(Thing): class RecentringThing(Thing):
@thing_action @thing_action
def recentre( def recentre(
@ -27,7 +28,7 @@ class RecentringThing(Thing):
corresponds to the centre of the stage. This exploits the corresponds to the centre of the stage. This exploits the
fact that the OpenFlexure stage moves in an arc, i.e. its fact that the OpenFlexure stage moves in an arc, i.e. its
height will vary with X and Y. The point where the variation height will vary with X and Y. The point where the variation
of Z with X and Y motion is smallest is the centre of its of Z with X and Y motion is smallest is the centre of its
XY travel. This routine moves in X and Y, monitoring the XY travel. This routine moves in X and Y, monitoring the
Z value of the focal plane, and attempts to find the point Z value of the focal plane, and attempts to find the point
where Z does not vary with X and Y, which is where it stops. where Z does not vary with X and Y, which is where it stops.

View file

@ -5,6 +5,7 @@ camera together to perform an autofocus routine.
See repository root for licensing information. See repository root for licensing information.
""" """
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager from contextlib import contextmanager
import logging import logging
@ -26,8 +27,10 @@ from pydantic import BaseModel
### Autofocus utilities ### Autofocus utilities
class JPEGSharpnessMonitor: class JPEGSharpnessMonitor:
__globals__ = globals() # Required for FastAPI dependency __globals__ = globals() # Required for FastAPI dependency
def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal): def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal):
self.camera = camera self.camera = camera
self.stage = stage self.stage = stage
@ -48,7 +51,7 @@ class JPEGSharpnessMonitor:
self.jpeg_sizes.append(len(frame)) self.jpeg_sizes.append(len(frame))
if not self.running: if not self.running:
break break
@contextmanager @contextmanager
def run(self): def run(self):
"""Context manager, during which we will monitor sharpness from the camera""" """Context manager, during which we will monitor sharpness from the camera"""
@ -73,7 +76,7 @@ class JPEGSharpnessMonitor:
# Index of the data for this movement # Index of the data for this movement
data_index: int = len(self.stage_positions) - 2 data_index: int = len(self.stage_positions) - 2
# Final z position after move # Final z position after move
final_z_position: int = self.stage_positions[-1]['z'] final_z_position: int = self.stage_positions[-1]["z"]
return data_index, final_z_position return data_index, final_z_position
def move_data( def move_data(
@ -84,12 +87,10 @@ class JPEGSharpnessMonitor:
istop = istart + 2 istop = istart + 2
jpeg_times: np.ndarray = np.array(self.jpeg_times) jpeg_times: np.ndarray = np.array(self.jpeg_times)
jpeg_sizes: np.ndarray = np.array(self.jpeg_sizes) jpeg_sizes: np.ndarray = np.array(self.jpeg_sizes)
stage_times: np.ndarray = np.array(self.stage_times)[ stage_times: np.ndarray = np.array(self.stage_times)[istart:istop]
istart:istop
]
stage_zs: np.ndarray = np.array( stage_zs: np.ndarray = np.array(
[p['z'] for p in self.stage_positions[istart:istop]] [p["z"] for p in self.stage_positions[istart:istop]]
) )
try: try:
start: int = int(np.argmax(jpeg_times > stage_times[0])) start: int = int(np.argmax(jpeg_times > stage_times[0]))
stop: int = int(np.argmax(jpeg_times > stage_times[1])) stop: int = int(np.argmax(jpeg_times > stage_times[1]))
@ -128,6 +129,7 @@ class JPEGSharpnessMonitor:
SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()] SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()]
class SharpnessDataArrays(BaseModel): class SharpnessDataArrays(BaseModel):
jpeg_times: NDArray jpeg_times: NDArray
jpeg_sizes: NDArray jpeg_sizes: NDArray
@ -140,18 +142,18 @@ class AutofocusThing(Thing):
def fast_autofocus( def fast_autofocus(
self, self,
m: SharpnessMonitorDep, m: SharpnessMonitorDep,
dz: int=2000, dz: int = 2000,
start: str='centre', start: str = "centre",
) -> SharpnessDataArrays: ) -> SharpnessDataArrays:
"""Sweep the stage up and down, then move to the sharpest point """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 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 the position where the image was sharpest. We'll then move back down, and
finally up to the sharpest point. finally up to the sharpest point.
""" """
with m.run(): with m.run():
# Move to (-dz / 2) # Move to (-dz / 2)
if start == 'centre': if start == "centre":
m.focus_rel(-dz / 2) m.focus_rel(-dz / 2)
# Move to dz while monitoring sharpness # Move to dz while monitoring sharpness
# i: Sharpness monitor index for this move # i: Sharpness monitor index for this move
@ -165,16 +167,16 @@ class AutofocusThing(Thing):
m.focus_rel(fz - z) m.focus_rel(fz - z)
# Return all focus data # Return all focus data
return m.data_dict() return m.data_dict()
@thing_action @thing_action
def move_and_measure( def move_and_measure(
self, self,
m: SharpnessMonitorDep, m: SharpnessMonitorDep,
dz: Sequence[int], dz: Sequence[int],
wait: float=0, wait: float = 0,
) -> SharpnessDataArrays: ) -> SharpnessDataArrays:
"""Make a move (or a series of moves) and monitor sharpness """Make a move (or a series of moves) and monitor sharpness
This method will will make a series of relative moves in z, and 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) vs time, along with timestamps
for the moves. This can be used to calibrate autofocus. for the moves. This can be used to calibrate autofocus.
@ -193,9 +195,11 @@ class AutofocusThing(Thing):
return m.data_dict() return m.data_dict()
@thing_action @thing_action
def looping_autofocus(self, stage: Stage, m: SharpnessMonitorDep, dz=2000, start='centre'): def looping_autofocus(
self, stage: Stage, m: SharpnessMonitorDep, dz=2000, start="centre"
):
"""Repeatedly autofocus the stage until it looks focused. """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 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
@ -207,14 +211,13 @@ class AutofocusThing(Thing):
with m.run(): with m.run():
while repeat and attempts < 10: while repeat and attempts < 10:
if start == "centre":
if start == 'centre': stage.move_relative(x=0, y=0, z=-(backlash + dz / 2))
stage.move_relative(x = 0, y = 0, z = -(backlash + dz / 2)) stage.move_relative(x=0, y=0, z=backlash)
stage.move_relative(x = 0, y = 0, z = backlash)
i, z = m.focus_rel(dz, block_cancellation=True) i, z = m.focus_rel(dz, block_cancellation=True)
_, heights, sizes = m.move_data(i) _, heights, sizes = m.move_data(i)
peak_height = heights[np.argmax(sizes)] peak_height = heights[np.argmax(sizes)]
height_min = np.min(heights) height_min = np.min(heights)
height_max = np.max(heights) height_max = np.max(heights)
@ -224,25 +227,27 @@ class AutofocusThing(Thing):
or height_max - peak_height < dz / 5 or height_max - peak_height < dz / 5
): ):
attempts += 1 attempts += 1
start = 'centre' start = "centre"
stage.move_absolute(z = peak_height-backlash) stage.move_absolute(z=peak_height - backlash)
stage.move_absolute(z = peak_height) stage.move_absolute(z=peak_height)
else: else:
repeat = False repeat = False
stage.move_relative(x = 0, y = 0, z = -(dz+backlash)) stage.move_relative(x=0, y=0, z=-(dz + backlash))
stage.move_absolute(z = peak_height) stage.move_absolute(z=peak_height)
return heights.tolist(), sizes.tolist() return heights.tolist(), sizes.tolist()
@thing_action @thing_action
def verify_focus_sharpness(self, sweep_sizes: list, camera: WrappedCamera, threshold: float = 0.95): def verify_focus_sharpness(
'''Take the sharpness curve of the autofocus, and the size of the current frame self, sweep_sizes: list, camera: WrappedCamera, threshold: float = 0.95
):
"""Take the sharpness curve of the autofocus, and the size of the current frame
to see if the autofocus completed successfully. Returns True if current sharpness to see if the autofocus completed successfully. Returns True if current sharpness
is within "leniency" number of frames from the peak of the autofocus''' is within "leniency" number of frames from the peak of the autofocus"""
current_sharpness = camera.grab_jpeg_size(stream_name='lores') current_sharpness = camera.grab_jpeg_size(stream_name="lores")
peak = np.max(sweep_sizes) peak = np.max(sweep_sizes)
base = np.min(sweep_sizes) base = np.min(sweep_sizes)
cutoff = threshold * (peak - base) cutoff = threshold * (peak - base)
return current_sharpness >= base + cutoff return current_sharpness >= base + cutoff

View file

@ -5,6 +5,7 @@ should enabe the server to work.
See repository root for licensing information. See repository root for licensing information.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Literal, Protocol, runtime_checkable from typing import Literal, Protocol, runtime_checkable
@ -23,15 +24,14 @@ from labthings_fastapi.types.numpy import NDArray
class JPEGBlob(Blob): class JPEGBlob(Blob):
media_type: str = "image/jpeg" media_type: str = "image/jpeg"
@runtime_checkable @runtime_checkable
class CameraProtocol(Protocol): class CameraProtocol(Protocol):
"""A Thing representing a camera""" """A Thing representing a camera"""
def __enter__(self) -> None: def __enter__(self) -> None: ...
...
def __exit__(self, _exc_type, _exc_value, _traceback) -> None: ...
def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
...
@property @property
def stream_active(self) -> bool: def stream_active(self) -> bool:
@ -45,9 +45,8 @@ class CameraProtocol(Protocol):
def capture_array( def capture_array(
self, self,
resolution: Literal["lores", "main", "full"] = "main", resolution: Literal["lores", "main", "full"] = "main",
) -> NDArray: ) -> NDArray: ...
...
def capture_jpeg( def capture_jpeg(
self, self,
metadata_getter: GetThingStates, metadata_getter: GetThingStates,
@ -78,9 +77,10 @@ class CameraProtocol(Protocol):
"""Acquire one image from the preview stream and return its size""" """Acquire one image from the preview stream and return its size"""
... ...
class BaseCamera(Thing): class BaseCamera(Thing):
"""A Thing representing a camera """A Thing representing a camera
This is a concrete base class for `Thing`s implementing the `CameraProtocol`. This is a concrete base class for `Thing`s implementing the `CameraProtocol`.
It provides the stream descriptors and actions to grab from the stream. It provides the stream descriptors and actions to grab from the stream.
""" """
@ -110,12 +110,16 @@ class BaseCamera(Thing):
stream (either "main" for the preview stream, or "lores" for the low stream (either "main" for the preview stream, or "lores" for the low
resolution preview). No metadata is returned. resolution preview). No metadata is returned.
""" """
logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) starting") logging.info(
f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) starting"
)
stream = ( stream = (
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
) )
frame = portal.call(stream.grab_frame) frame = portal.call(stream.grab_frame)
logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) got frame") logging.info(
f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) got frame"
)
return JPEGBlob.from_bytes(frame) return JPEGBlob.from_bytes(frame)
@thing_action @thing_action
@ -129,12 +133,12 @@ class BaseCamera(Thing):
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
) )
return portal.call(stream.next_frame_size) return portal.call(stream.next_frame_size)
class CameraStub(BaseCamera): class CameraStub(BaseCamera):
"""A stub for a camera, to allow dependencies """A stub for a camera, to allow dependencies
As of LabThings-FastAPI 0.0.7, we can't make a thing client dependency As of LabThings-FastAPI 0.0.7, we can't make a thing client dependency
based on a protocol, because the protocol is not a Thing, and its based on a protocol, because the protocol is not a Thing, and its
methods/properties are not decorated as Affordances. This stub class methods/properties are not decorated as Affordances. This stub class
@ -142,9 +146,10 @@ class CameraStub(BaseCamera):
This stub class should be used for dependencies on the CameraProtocol. This stub class should be used for dependencies on the CameraProtocol.
""" """
def __enter__(self) -> None: def __enter__(self) -> None:
raise NotImplementedError("Cameras must not inherit from CameraStub") raise NotImplementedError("Cameras must not inherit from CameraStub")
def __exit__(self, _exc_type, _exc_value, _traceback) -> None: def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
raise NotImplementedError("Cameras must not inherit from CameraStub") raise NotImplementedError("Cameras must not inherit from CameraStub")
@ -164,7 +169,7 @@ class CameraStub(BaseCamera):
resolution: Literal["lores", "main", "full"] = "main", resolution: Literal["lores", "main", "full"] = "main",
) -> NDArray: ) -> NDArray:
raise NotImplementedError("Cameras must not inherit from CameraStub") raise NotImplementedError("Cameras must not inherit from CameraStub")
@thing_action @thing_action
def capture_jpeg( def capture_jpeg(
self, self,

View file

@ -1,10 +1,11 @@
"""OpenFlexure Microscope OpenCV Camera """OpenFlexure Microscope OpenCV Camera
This module defines a camera Thing that uses OpenCV's This module defines a camera Thing that uses OpenCV's
`VideoCapture`. `VideoCapture`.
See repository root for licensing information. See repository root for licensing information.
""" """
from __future__ import annotations from __future__ import annotations
import io import io
import json import json
@ -26,7 +27,8 @@ from . import BaseCamera, JPEGBlob
class OpenCVCamera(BaseCamera): class OpenCVCamera(BaseCamera):
"""A Thing representing an OpenCV camera""" """A Thing representing an OpenCV camera"""
def __init__(self, camera_index: int=0):
def __init__(self, camera_index: int = 0):
self.camera_index = camera_index self.camera_index = camera_index
self._capture_thread: Optional[Thread] = None self._capture_thread: Optional[Thread] = None
self._capture_enabled = False self._capture_enabled = False
@ -37,7 +39,7 @@ class OpenCVCamera(BaseCamera):
self._capture_thread = Thread(target=self._capture_frames) self._capture_thread = Thread(target=self._capture_frames)
self._capture_thread.start() self._capture_thread.start()
return self return self
def __exit__(self, _exc_type, _exc_value, _traceback): def __exit__(self, _exc_type, _exc_value, _traceback):
if self.stream_active: if self.stream_active:
self._capture_enabled = False self._capture_enabled = False
@ -50,6 +52,7 @@ class OpenCVCamera(BaseCamera):
if self._capture_enabled and self._capture_thread: if self._capture_enabled and self._capture_thread:
return self._capture_thread.is_alive() return self._capture_thread.is_alive()
return False return False
mjpeg_stream = MJPEGStreamDescriptor() mjpeg_stream = MJPEGStreamDescriptor()
lores_mjpeg_stream = MJPEGStreamDescriptor() lores_mjpeg_stream = MJPEGStreamDescriptor()
@ -58,11 +61,15 @@ class OpenCVCamera(BaseCamera):
while self._capture_enabled: while self._capture_enabled:
ret, frame = self.cap.read() ret, frame = self.cap.read()
if not ret: if not ret:
logging.error(f"Failed to capture frame from camera {self.camera_index}") logging.error(
f"Failed to capture frame from camera {self.camera_index}"
)
break break
jpeg = cv2.imencode(".jpg", frame)[1].tobytes() jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
self.mjpeg_stream.add_frame(jpeg, portal) self.mjpeg_stream.add_frame(jpeg, portal)
jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[1].tobytes() jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[
1
].tobytes()
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
@thing_action @thing_action
@ -78,9 +85,11 @@ class OpenCVCamera(BaseCamera):
""" """
ret, frame = self.cap.read() ret, frame = self.cap.read()
if not ret: if not ret:
raise RuntimeError(f"Failed to capture frame from camera {self.camera_index}") raise RuntimeError(
f"Failed to capture frame from camera {self.camera_index}"
)
return frame return frame
@thing_action @thing_action
def capture_jpeg( def capture_jpeg(
self, self,
@ -95,7 +104,9 @@ class OpenCVCamera(BaseCamera):
jpeg = cv2.imencode(".jpg", frame)[1].tobytes() jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
exif_dict = { exif_dict = {
"Exif": { "Exif": {
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8") piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode(
"utf-8"
)
}, },
"GPS": {}, "GPS": {},
"Interop": {}, "Interop": {},

View file

@ -5,6 +5,7 @@ camera together to perform an autofocus routine.
See repository root for licensing information. See repository root for licensing information.
""" """
from __future__ import annotations from __future__ import annotations
import io import io
import json import json
@ -28,22 +29,26 @@ from pydantic import RootModel
from . import BaseCamera, JPEGBlob from . import BaseCamera, JPEGBlob
from ..stage import StageProtocol as Stage from ..stage import StageProtocol as Stage
class ArrayModel(RootModel): class ArrayModel(RootModel):
"""A model for an array""" """A model for an array"""
root: NDArray root: NDArray
class SimulatedCamera(BaseCamera): class SimulatedCamera(BaseCamera):
"""A Thing representing an OpenCV camera""" """A Thing representing an OpenCV camera"""
_stage: Optional[Stage] = None _stage: Optional[Stage] = None
_server: Optional[ThingServer] = None _server: Optional[ThingServer] = None
def __init__( def __init__(
self, self,
shape: tuple[int, int, int] = (600, 800, 3), shape: tuple[int, int, int] = (600, 800, 3),
glyph_shape: tuple[int, int, int] = (51, 51, 3), glyph_shape: tuple[int, int, int] = (51, 51, 3),
canvas_shape: tuple[int, int, int] = (3000, 4000, 3), canvas_shape: tuple[int, int, int] = (3000, 4000, 3),
frame_interval: float = 0.1, frame_interval: float = 0.1,
): ):
self.shape = shape self.shape = shape
self.glyph_shape = glyph_shape self.glyph_shape = glyph_shape
self.canvas_shape = canvas_shape self.canvas_shape = canvas_shape
@ -53,14 +58,14 @@ class SimulatedCamera(BaseCamera):
self.generate_sprites() self.generate_sprites()
self.generate_blobs() self.generate_blobs()
self.generate_canvas() self.generate_canvas()
def generate_sprites(self): def generate_sprites(self):
"""Generate sprites to populate the image""" """Generate sprites to populate the image"""
self.sprites = [] self.sprites = []
black = np.zeros(self.glyph_shape, dtype=np.uint8) black = np.zeros(self.glyph_shape, dtype=np.uint8)
x = np.arange(black.shape[0]) x = np.arange(black.shape[0])
y = np.arange(black.shape[1]) y = np.arange(black.shape[1])
rr = np.sqrt((x[:, None] - np.mean(x))**2 + (y[None, :] - np.mean(y))**2) rr = np.sqrt((x[:, None] - np.mean(x)) ** 2 + (y[None, :] - np.mean(y)) ** 2)
for i in [5, 7, 9, 11, 13, 15]: for i in [5, 7, 9, 11, 13, 15]:
sprite = black.copy() sprite = black.copy()
sprite[rr < i] = 255 sprite[rr < i] = 255
@ -68,15 +73,15 @@ class SimulatedCamera(BaseCamera):
def generate_blobs(self, N: int = 1000): def generate_blobs(self, N: int = 1000):
"""Generate coordinates of blobs """Generate coordinates of blobs
Blobs are characterised by X, Y, sprite Blobs are characterised by X, Y, sprite
We also generate a KD tree to rapidly find blobs in an image We also generate a KD tree to rapidly find blobs in an image
""" """
self.blobs = np.zeros((N, 3)) self.blobs = np.zeros((N, 3))
rng = np.random.default_rng() rng = np.random.default_rng()
w = np.max(self.glyph_shape) w = np.max(self.glyph_shape)
self.blobs[:, 0] = rng.uniform(w/2, self.canvas_shape[0]-w/2, N) self.blobs[:, 0] = rng.uniform(w / 2, self.canvas_shape[0] - w / 2, N)
self.blobs[:, 1] = rng.uniform(w/2, self.canvas_shape[1]-w/2, N) self.blobs[:, 1] = rng.uniform(w / 2, self.canvas_shape[1] - w / 2, N)
self.blobs[:, 2] = rng.choice(len(self.sprites), N) self.blobs[:, 2] = rng.choice(len(self.sprites), N)
def generate_canvas(self): def generate_canvas(self):
@ -86,31 +91,34 @@ class SimulatedCamera(BaseCamera):
w, h, _ = self.glyph_shape w, h, _ = self.glyph_shape
for x, y, sprite in self.blobs: for x, y, sprite in self.blobs:
self.canvas[ self.canvas[
int(x) - w//2:int(x) - w//2 + w, int(x) - w // 2 : int(x) - w // 2 + w,
int(y) - h//2:int(y) - h//2 + h, int(y) - h // 2 : int(y) - h // 2 + h,
] -= self.sprites[int(sprite)] ] -= self.sprites[int(sprite)]
def generate_image(self, pos: tuple[int, int]): def generate_image(self, pos: tuple[int, int]):
"""Generate an image with blobs based on supplied coordinates""" """Generate an image with blobs based on supplied coordinates"""
cw, ch, _ = self.canvas_shape cw, ch, _ = self.canvas_shape
w, h, _ = self.shape w, h, _ = self.shape
tl = (int(pos[0]) - w//2 - cw//2, int(pos[1]) - h//2 - ch//2) tl = (int(pos[0]) - w // 2 - cw // 2, int(pos[1]) - h // 2 - ch // 2)
image = self.canvas[ image = self.canvas[
tuple(slice(tl[i],tl[i] + self.shape[i]) for i in range(2)) + (slice(None),) tuple(slice(tl[i], tl[i] + self.shape[i]) for i in range(2))
+ (slice(None),)
] ]
if image.shape != self.shape: if image.shape != self.shape:
raise ValueError(f"Image shape {image.shape} does not match intended shape {self.shape}") raise ValueError(
f"Image shape {image.shape} does not match intended shape {self.shape}"
)
return image return image
def attach_to_server(self, server: ThingServer, path: str): def attach_to_server(self, server: ThingServer, path: str):
self._server = server self._server = server
return super().attach_to_server(server, path) return super().attach_to_server(server, path)
def get_stage_position(self): def get_stage_position(self):
if not self._stage and self._server: if not self._stage and self._server:
self._stage = self._server.things["/stage/"] self._stage = self._server.things["/stage/"]
return self._stage.instantaneous_position return self._stage.instantaneous_position
def generate_frame(self): def generate_frame(self):
"""Generate a frame with blobs based on the stage coordinates""" """Generate a frame with blobs based on the stage coordinates"""
try: try:
@ -118,14 +126,14 @@ class SimulatedCamera(BaseCamera):
except Exception as e: except Exception as e:
print(f"Failed to get stage position: {e}") print(f"Failed to get stage position: {e}")
pos = {"x": 0, "y": 0} pos = {"x": 0, "y": 0}
return self.generate_image((pos["x"]/10, pos["y"]/10)) return self.generate_image((pos["x"] / 10, pos["y"] / 10))
def __enter__(self): def __enter__(self):
self._capture_enabled = True self._capture_enabled = True
self._capture_thread = Thread(target=self._capture_frames) self._capture_thread = Thread(target=self._capture_frames)
self._capture_thread.start() self._capture_thread.start()
return self return self
def __exit__(self, _exc_type, _exc_value, _traceback): def __exit__(self, _exc_type, _exc_value, _traceback):
if self.stream_active: if self.stream_active:
self._capture_enabled = False self._capture_enabled = False
@ -137,6 +145,7 @@ class SimulatedCamera(BaseCamera):
if self._capture_enabled and self._capture_thread: if self._capture_enabled and self._capture_thread:
return self._capture_thread.is_alive() return self._capture_thread.is_alive()
return False return False
mjpeg_stream = MJPEGStreamDescriptor() mjpeg_stream = MJPEGStreamDescriptor()
lores_mjpeg_stream = MJPEGStreamDescriptor() lores_mjpeg_stream = MJPEGStreamDescriptor()
@ -148,7 +157,9 @@ class SimulatedCamera(BaseCamera):
frame = self.generate_frame() frame = self.generate_frame()
jpeg = cv2.imencode(".jpg", frame)[1].tobytes() jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
self.mjpeg_stream.add_frame(jpeg, portal) self.mjpeg_stream.add_frame(jpeg, portal)
jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[1].tobytes() jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[
1
].tobytes()
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
except Exception as e: except Exception as e:
logging.error(f"Failed to capture frame: {e}, retrying...") logging.error(f"Failed to capture frame: {e}, retrying...")
@ -165,7 +176,7 @@ class SimulatedCamera(BaseCamera):
binary image formats will be added in due course. binary image formats will be added in due course.
""" """
return self.generate_frame() return self.generate_frame()
@thing_action @thing_action
def capture_jpeg( def capture_jpeg(
self, self,
@ -180,7 +191,9 @@ class SimulatedCamera(BaseCamera):
jpeg = cv2.imencode(".jpg", frame)[1].tobytes() jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
exif_dict = { exif_dict = {
"Exif": { "Exif": {
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8") piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode(
"utf-8"
)
}, },
"GPS": {}, "GPS": {},
"Interop": {}, "Interop": {},

View file

@ -10,8 +10,20 @@ and return the calibration data.
This module is only intended to be called from the OpenFlexure Microscope This module is only intended to be called from the OpenFlexure Microscope
server, and depends on that server and its underlying LabThings library. server, and depends on that server and its underlying LabThings library.
""" """
import time import time
from typing import Annotated, Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Sequence, Tuple from typing import (
Annotated,
Any,
Callable,
Dict,
List,
Mapping,
NamedTuple,
Optional,
Sequence,
Tuple,
)
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
import numpy as np import numpy as np
@ -20,7 +32,10 @@ from camera_stage_mapping.camera_stage_calibration_1d import (
calibrate_backlash_1d, calibrate_backlash_1d,
image_to_stage_displacement_from_1d, image_to_stage_displacement_from_1d,
) )
from labthings_fastapi.dependencies.invocation import InvocationCancelledError, InvocationLogger from labthings_fastapi.dependencies.invocation import (
InvocationCancelledError,
InvocationLogger,
)
from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict
from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.thing import Thing from labthings_fastapi.thing import Thing
@ -31,6 +46,7 @@ from .stage import StageDependency as Stage
CoordinateType = Tuple[float, float, float] CoordinateType = Tuple[float, float, float]
XYCoordinateType = Tuple[float, float] XYCoordinateType = Tuple[float, float]
class HardwareInterfaceModel(BaseModel): class HardwareInterfaceModel(BaseModel):
move: Callable[[NDArray], None] move: Callable[[NDArray], None]
get_position: Callable[[], NDArray] get_position: Callable[[], NDArray]
@ -41,7 +57,7 @@ class HardwareInterfaceModel(BaseModel):
def downsample(factor: int, image: np.ndarray) -> np.ndarray: def downsample(factor: int, image: np.ndarray) -> np.ndarray:
"""Downsample an image by taking the mean of each nxn region """Downsample an image by taking the mean of each nxn region
This should be very efficient: we calculate the mean of each 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 not an integer multiple of the resampling factor, we discard
@ -53,46 +69,60 @@ def downsample(factor: int, image: np.ndarray) -> np.ndarray:
new_size = [d // factor for d in image.shape[:2]] new_size = [d // factor for d in image.shape[:2]]
# First, we ensure we have something that's an integer multiple # First, we ensure we have something that's an integer multiple
# of `factor` # of `factor`
cropped = image[:new_size[0] * factor, :new_size[1] * factor, ...] cropped = image[: new_size[0] * factor, : new_size[1] * factor, ...]
reshaped = cropped.reshape( reshaped = cropped.reshape(
(new_size[0], factor, new_size[1], factor) + image.shape[2:] (new_size[0], factor, new_size[1], factor) + image.shape[2:]
) )
return reshaped.mean(axis=(1,3)) return reshaped.mean(axis=(1, 3))
DEFAULT_SETTLING_TIME = 0.2 DEFAULT_SETTLING_TIME = 0.2
def make_hardware_interface( def make_hardware_interface(
stage: Stage, camera: Camera, downsample_factor: int = 2 stage: Stage, camera: Camera, downsample_factor: int = 2
) -> HardwareInterfaceModel: ) -> HardwareInterfaceModel:
"""Construct the functions we need to interface with the hardware""" """Construct the functions we need to interface with the hardware"""
axes = stage.axis_names axes = stage.axis_names
def pos2dict(pos: Sequence[float]) -> Mapping[str, float]: def pos2dict(pos: Sequence[float]) -> Mapping[str, float]:
return {k: p for k, p in zip(axes, pos)} return {k: p for k, p in zip(axes, pos)}
def dict2pos(posd: Mapping[str, float]) -> Sequence[float]: def dict2pos(posd: Mapping[str, float]) -> Sequence[float]:
return tuple(posd[k] for k in axes if k in posd) return tuple(posd[k] for k in axes if k in posd)
def move(pos: CoordinateType) -> None: def move(pos: CoordinateType) -> None:
current_pos = stage.position current_pos = stage.position
new_pos = pos2dict(pos) new_pos = pos2dict(pos)
displacement = {k: new_pos[k] - current_pos[k] for k in new_pos.keys()} displacement = {k: new_pos[k] - current_pos[k] for k in new_pos.keys()}
stage.move_relative(**displacement) stage.move_relative(**displacement)
def get_position() -> CoordinateType: def get_position() -> CoordinateType:
return dict2pos(stage.position) return dict2pos(stage.position)
def grab_image() -> np.ndarray: def grab_image() -> np.ndarray:
img = camera.capture_array() img = camera.capture_array()
return downsample(downsample_factor, img) return downsample(downsample_factor, img)
def settle() -> None: def settle() -> None:
time.sleep(DEFAULT_SETTLING_TIME) time.sleep(DEFAULT_SETTLING_TIME)
try: try:
camera.capture_metadata # This discards frames on a picamera camera.capture_metadata # This discards frames on a picamera
except AttributeError: except AttributeError:
pass # Don't raise an error for other cameras (may consider grabbing a frame) pass # Don't raise an error for other cameras (may consider grabbing a frame)
return HardwareInterfaceModel( return HardwareInterfaceModel(
move=move, get_position=get_position, grab_image=grab_image, settle=settle, grab_image_downsampling=downsample_factor move=move,
get_position=get_position,
grab_image=grab_image,
settle=settle,
grab_image_downsampling=downsample_factor,
) )
HardwareInterfaceDep = Annotated[HardwareInterfaceModel, Depends(make_hardware_interface)]
HardwareInterfaceDep = Annotated[
HardwareInterfaceModel, Depends(make_hardware_interface)
]
class MoveHistory(NamedTuple): class MoveHistory(NamedTuple):
@ -102,11 +132,11 @@ class MoveHistory(NamedTuple):
class LoggingMoveWrapper: class LoggingMoveWrapper:
"""Wrap a move function, and maintain a log position/time. """Wrap a move function, and maintain a log position/time.
This class is callable, so it doesn't change the signature This class is callable, so it doesn't change the signature
of the function it wraps - it just makes it possible to get of the function it wraps - it just makes it possible to get
a list of all the moves we've made, and how long they took. a list of all the moves we've made, and how long they took.
Said list is intended to be useful for calibrating the stage Said list is intended to be useful for calibrating the stage
so we can estimate how long moves will take. so we can estimate how long moves will take.
""" """
@ -143,33 +173,39 @@ class CSMUncalibratedError(HTTPException):
( (
"The camera_stage_mapping calibration is not yet available. " "The camera_stage_mapping calibration is not yet available. "
"This probably means you need to run the calibration routine." "This probably means you need to run the calibration routine."
) ),
) )
class CameraStageMapper(Thing): class CameraStageMapper(Thing):
"""A Thing to manage mapping between image and stage coordinates""" """A Thing to manage mapping between image and stage coordinates"""
def __enter__(self): def __enter__(self):
pass pass
def __exit__(self, exc_type, exc_value, traceback): def __exit__(self, exc_type, exc_value, traceback):
self.thing_settings.write_to_file() self.thing_settings.write_to_file()
@thing_action @thing_action
def calibrate_1d( def calibrate_1d(
self, self,
hw: HardwareInterfaceDep, hw: HardwareInterfaceDep,
stage: Stage, stage: Stage,
logger: InvocationLogger, logger: InvocationLogger,
direction: Tuple[float, float, float], direction: Tuple[float, float, float],
) -> DenumpifyingDict: ) -> DenumpifyingDict:
"""Move a microscope's stage in 1D, and figure out the relationship with the camera""" """Move a microscope's stage in 1D, and figure out the relationship with the camera"""
move = LoggingMoveWrapper(hw.move) # log positions and times for stage calibration move = LoggingMoveWrapper(
hw.move
) # log positions and times for stage calibration
tracker = Tracker(hw.grab_image, hw.get_position, settle=hw.settle) tracker = Tracker(hw.grab_image, hw.get_position, settle=hw.settle)
direction_array: np.ndarray = np.array(direction) direction_array: np.ndarray = np.array(direction)
starting_position = stage.position starting_position = stage.position
try: try:
result: dict = calibrate_backlash_1d(tracker, move, direction_array, logger=logger) result: dict = calibrate_backlash_1d(
tracker, move, direction_array, logger=logger
)
except InvocationCancelledError as e: except InvocationCancelledError as e:
logger.info("Returning to starting position") logger.info("Returning to starting position")
stage.move_absolute(**starting_position, block_cancellation=True) stage.move_absolute(**starting_position, block_cancellation=True)
@ -183,7 +219,7 @@ class CameraStageMapper(Thing):
self, hw: HardwareInterfaceDep, stage: Stage, logger: InvocationLogger self, hw: HardwareInterfaceDep, stage: Stage, logger: InvocationLogger
) -> DenumpifyingDict: ) -> DenumpifyingDict:
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera """Move the microscope's stage in X and Y, to calibrate its relationship to the camera
This performs two 1d calibrations in x and y, then combines their results. This performs two 1d calibrations in x and y, then combines their results.
""" """
logger.info("Calibrating X axis:") logger.info("Calibrating X axis:")
@ -204,7 +240,7 @@ class CameraStageMapper(Thing):
self.thing_settings["image_resolution"] = corrected_resolution self.thing_settings["image_resolution"] = corrected_resolution
csm_matrix = cal_xy["image_to_stage_displacement"] csm_matrix = cal_xy["image_to_stage_displacement"]
csm_as_string = f'[{round(csm_matrix[0][0], 2)}, {round(csm_matrix[0][1], 2)},],[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][1], 2)}]' csm_as_string = f"[{round(csm_matrix[0][0], 2)}, {round(csm_matrix[0][1], 2)},],[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][1], 2)}]"
logger.info(f"CSM matrix is {csm_as_string}.") logger.info(f"CSM matrix is {csm_as_string}.")
data: Dict[str, dict] = { data: Dict[str, dict] = {
@ -221,14 +257,16 @@ class CameraStageMapper(Thing):
return data return data
@thing_property @thing_property
def image_to_stage_displacement_matrix(self) -> Optional[List[List[float]]]: # 2x2 integer array def image_to_stage_displacement_matrix(
self,
) -> Optional[List[List[float]]]: # 2x2 integer array
"""A 2x2 matrix that converts displacement in image coordinates to stage coordinates. """A 2x2 matrix that converts displacement in image coordinates to stage coordinates.
Note that this matrix is defined using "matrix coordinates", i.e. image coordinates Note that this matrix is defined using "matrix coordinates", i.e. image coordinates
may be (y,x). This is an artifact of the way numpy, opencv, etc. define images. If may be (y,x). This is an artifact of the way numpy, opencv, etc. define images. If
you are making use of this matrix in your own code, you will need to take care of you are making use of this matrix in your own code, you will need to take care of
that conversion. that conversion.
It is often helpful to give a concrete example: to make a move in image coordinates 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: should move the stage by:
@ -243,12 +281,12 @@ class CameraStageMapper(Thing):
if not displacement_matrix: if not displacement_matrix:
return None return None
return np.array(displacement_matrix).tolist() return np.array(displacement_matrix).tolist()
@thing_property @thing_property
def image_resolution(self) -> Optional[Tuple[float, float]]: def image_resolution(self) -> Optional[Tuple[float, float]]:
"""The image size used to calibrate the image_to_stage_displacement_matrix""" """The image size used to calibrate the image_to_stage_displacement_matrix"""
return self.thing_settings.get("image_resolution", None) return self.thing_settings.get("image_resolution", None)
def assert_calibrated(self): def assert_calibrated(self):
"""Raise an exception if the image_to_stage_displacement matrix is not set""" """Raise an exception if the image_to_stage_displacement matrix is not set"""
if self.image_to_stage_displacement_matrix is None: if self.image_to_stage_displacement_matrix is None:
@ -256,10 +294,9 @@ class CameraStageMapper(Thing):
@thing_property @thing_property
def last_calibration(self) -> Optional[Dict]: def last_calibration(self) -> Optional[Dict]:
"""The results of the last calibration that was run """The results of the last calibration that was run"""
"""
return self.thing_settings.get("last_calibration", None) return self.thing_settings.get("last_calibration", None)
@thing_action @thing_action
def move_in_image_coordinates( def move_in_image_coordinates(
self, self,
@ -268,7 +305,7 @@ class CameraStageMapper(Thing):
y: float, y: float,
): ):
"""Move by a given number of pixels on the camera """Move by a given number of pixels on the camera
NB x and y here refer to what is usually understood to be the horizontal and NB x and y here refer to what is usually understood to be the horizontal and
vertical axes of the image. In many toolkits, "matrix indices" are used, which vertical axes of the image. In many toolkits, "matrix indices" are used, which
swap the order of these coordinates. This includes opencv and PIL. So, don't be swap the order of these coordinates. This includes opencv and PIL. So, don't be
@ -280,8 +317,7 @@ class CameraStageMapper(Thing):
""" """
self.assert_calibrated() self.assert_calibrated()
relative_move: np.ndarray = np.dot( relative_move: np.ndarray = np.dot(
np.array([y, x]), np.array([y, x]), np.array(self.image_to_stage_displacement_matrix)
np.array(self.image_to_stage_displacement_matrix)
) )
stage.move_relative(x=relative_move[0], y=relative_move[1]) stage.move_relative(x=relative_move[0], y=relative_move[1])

View file

@ -5,6 +5,7 @@ This module provides some settings management across the other Things, and
for code that currently lives in clients but needs to persist settings on for code that currently lives in clients but needs to persist settings on
the server. the server.
""" """
from collections.abc import Mapping from collections.abc import Mapping
from socket import gethostname from socket import gethostname
from typing import Annotated, Any, MutableMapping, Optional, Sequence from typing import Annotated, Any, MutableMapping, Optional, Sequence
@ -29,10 +30,7 @@ def recursive_update(old_dict: MutableMapping, update: Mapping):
"""Update a dictionary recursively""" """Update a dictionary recursively"""
for k, v in update.items(): for k, v in update.items():
if isinstance(v, Mapping): if isinstance(v, Mapping):
if ( if k in old_dict and isinstance(old_dict[k], MutableMapping):
k in old_dict
and isinstance(old_dict[k], MutableMapping)
):
recursive_update(old_dict[k], v) recursive_update(old_dict[k], v)
else: else:
old_dict[k] = dict(**v) old_dict[k] = dict(**v)
@ -55,11 +53,13 @@ class SettingsManager(Thing):
def external_metadata(self) -> Mapping: def external_metadata(self) -> Mapping:
"""External metadata stored in the server's settings""" """External metadata stored in the server's settings"""
return self.thing_settings.get("external_metadata", {}) return self.thing_settings.get("external_metadata", {})
@thing_action @thing_action
def update_external_metadata(self, data: Mapping, key: Optional[str] = None) -> None: def update_external_metadata(
self, data: Mapping, key: Optional[str] = None
) -> None:
"""Add or replace keys in the external metadata. """Add or replace keys in the external metadata.
The data supplied will be merged into the existing dictionary The data supplied will be merged into the existing dictionary
recursively, i.e. if a key exists, it will be added to rather than recursively, i.e. if a key exists, it will be added to rather than
replaced. replaced.
@ -80,7 +80,7 @@ class SettingsManager(Thing):
@thing_action @thing_action
def delete_external_metadata(self, key: str) -> None: def delete_external_metadata(self, key: str) -> None:
"""Delete a key from the stored metadata. """Delete a key from the stored metadata.
The key may contain forward slashes, which are understood to separate 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 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}}` dictionary that looks like: `{'a': {'c': 1}, 'b': {'d': 2}}`
@ -91,11 +91,10 @@ class SettingsManager(Thing):
del subdict[key.split("/")[-1]] del subdict[key.split("/")[-1]]
except KeyError: except KeyError:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404, detail="The specified key '{key}' was not found"
detail="The specified key '{key}' was not found"
) )
self.thing_settings["external_metadata"] = metadata self.thing_settings["external_metadata"] = metadata
@thing_property @thing_property
def microscope_id(self) -> UUID: def microscope_id(self) -> UUID:
"""A unique identifier for this microscope""" """A unique identifier for this microscope"""
@ -117,6 +116,7 @@ class SettingsManager(Thing):
def external_metadata_in_state(self) -> Sequence[str]: def external_metadata_in_state(self) -> Sequence[str]:
"""A list of strings that are included in the "state" metadata""" """A list of strings that are included in the "state" metadata"""
return self.thing_settings.get("external_metadata_in_state", []) return self.thing_settings.get("external_metadata_in_state", [])
@external_metadata_in_state.setter @external_metadata_in_state.setter
def external_metadata_in_state(self, keys: Sequence[str]): def external_metadata_in_state(self, keys: Sequence[str]):
"""Set the keys from external metadata that are returned in state""" """Set the keys from external metadata that are returned in state"""
@ -140,7 +140,9 @@ class SettingsManager(Thing):
return state return state
@thing_action @thing_action
def save_all_thing_settings(self, thing_server: ThingServerDep, logger: InvocationLogger) -> None: def save_all_thing_settings(
self, thing_server: ThingServerDep, logger: InvocationLogger
) -> None:
"""Ensure all the Things sync their settings to disk. """Ensure all the Things sync their settings to disk.
Normally, each Thing has a `thing_settings` attribute that can be Normally, each Thing has a `thing_settings` attribute that can be
@ -161,7 +163,4 @@ class SettingsManager(Thing):
f"Could not write {name} settings to disk: permission error." f"Could not write {name} settings to disk: permission error."
) )
except FileNotFoundError: except FileNotFoundError:
logger.warning( logger.warning(f"Could not write {name} settings, folder not found")
f"Could not write {name} settings, folder not found"
)

File diff suppressed because it is too large Load diff

View file

@ -7,10 +7,11 @@ from labthings_fastapi.dependencies.invocation import CancelHook
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
from collections.abc import Sequence, Mapping from collections.abc import Sequence, Mapping
@runtime_checkable @runtime_checkable
class StageProtocol(Protocol): class StageProtocol(Protocol):
"""A protocol for the OpenFlexure translation stage """A protocol for the OpenFlexure translation stage"""
"""
_axis_names: Sequence[str] _axis_names: Sequence[str]
@property @property
@ -32,18 +33,28 @@ class StageProtocol(Protocol):
def thing_state(self) -> Mapping[str, Any]: def thing_state(self) -> Mapping[str, Any]:
"""Summary metadata describing the current state of the stage""" """Summary metadata describing the current state of the stage"""
... ...
def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): def move_relative(
self,
cancel: CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
"""Make a relative move. Keyword arguments should be axis names.""" """Make a relative move. Keyword arguments should be axis names."""
... ...
def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): def move_absolute(
self,
cancel: CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
"""Make an absolute move. Keyword arguments should be axis names.""" """Make an absolute move. Keyword arguments should be axis names."""
... ...
def set_zero_position(self): def set_zero_position(self):
"""Make the current position zero in all axes """Make the current position zero in all axes
This action does not move the stage, but resets the position to zero. This action does not move the stage, but resets the position to zero.
It is intended for use after manually or automatically recentring the It is intended for use after manually or automatically recentring the
stage. stage.
@ -53,12 +64,13 @@ class StageProtocol(Protocol):
class BaseStage(Thing): class BaseStage(Thing):
"""A base stage class for OpenFlexure translation stages """A base stage class for OpenFlexure translation stages
This can't be used directly but should reduce boilerplate code when This can't be used directly but should reduce boilerplate code when
implementing new stages. A minimal working stage must implement implementing new stages. A minimal working stage must implement
`move_relative` and `move_absolute` actions, which update the `move_relative` and `move_absolute` actions, which update the
`position` property on completion, and provide `set_zero_position`. `position` property on completion, and provide `set_zero_position`.
""" """
_axis_names = ("x", "y", "z") _axis_names = ("x", "y", "z")
@thing_property @thing_property
@ -85,9 +97,7 @@ class BaseStage(Thing):
@property @property
def thing_state(self): def thing_state(self):
"""Summary metadata describing the current state of the stage""" """Summary metadata describing the current state of the stage"""
return { return {"position": self.position}
"position": self.position
}
class StageStub(BaseStage): class StageStub(BaseStage):
@ -98,20 +108,31 @@ class StageStub(BaseStage):
methods/properties are not decorated as Affordances. This stub class methods/properties are not decorated as Affordances. This stub class
is a workaround for that limitation, and should not be used directly. is a workaround for that limitation, and should not be used directly.
""" """
@thing_action @thing_action
def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): def move_relative(
self,
cancel: CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
"""Make a relative move. Keyword arguments should be axis names.""" """Make a relative move. Keyword arguments should be axis names."""
raise NotImplementedError("StageStub should not be used directly") raise NotImplementedError("StageStub should not be used directly")
@thing_action @thing_action
def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): def move_absolute(
self,
cancel: CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
"""Make an absolute move. Keyword arguments should be axis names.""" """Make an absolute move. Keyword arguments should be axis names."""
raise NotImplementedError("StageStub should not be used directly") raise NotImplementedError("StageStub should not be used directly")
@thing_action @thing_action
def set_zero_position(self): def set_zero_position(self):
"""Make the current position zero in all axes """Make the current position zero in all axes
This action does not move the stage, but resets the position to zero. This action does not move the stage, but resets the position to zero.
It is intended for use after manually or automatically recentring the It is intended for use after manually or automatically recentring the
stage. stage.

View file

@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from labthings_fastapi.decorators import thing_action from labthings_fastapi.decorators import thing_action
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationCancelledError from labthings_fastapi.dependencies.invocation import (
CancelHook,
InvocationCancelledError,
)
from collections.abc import Mapping from collections.abc import Mapping
import time import time
@ -9,11 +12,12 @@ from . import BaseStage
class DummyStage(BaseStage): class DummyStage(BaseStage):
"""A dummy stage for testing purposes """A dummy stage for testing purposes
This stage should work similarly to a Sangaboard stage, but without any This stage should work similarly to a Sangaboard stage, but without any
hardware attached. hardware attached.
""" """
def __init__(self, step_time: float=0.001, **kwargs):
def __init__(self, step_time: float = 0.001, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.step_time = step_time self.step_time = step_time
@ -24,7 +28,12 @@ class DummyStage(BaseStage):
pass pass
@thing_action @thing_action
def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): def move_relative(
self,
cancel: CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
"""Make a relative move. Keyword arguments should be axis names.""" """Make a relative move. Keyword arguments should be axis names."""
displacement = [kwargs.get(k, 0) for k in self.axis_names] displacement = [kwargs.get(k, 0) for k in self.axis_names]
self.moving = True self.moving = True
@ -50,7 +59,7 @@ class DummyStage(BaseStage):
# and to mark the invocation as "cancelled" rather than stopped. # and to mark the invocation as "cancelled" rather than stopped.
raise e raise e
finally: finally:
self.moving=False self.moving = False
self.position = { self.position = {
k: self.position[k] + int(fraction_complete * v) k: self.position[k] + int(fraction_complete * v)
for k, v in zip(self.axis_names, displacement) for k, v in zip(self.axis_names, displacement)
@ -58,19 +67,26 @@ class DummyStage(BaseStage):
self.instantaneous_position = self.position self.instantaneous_position = self.position
@thing_action @thing_action
def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): def move_absolute(
self,
cancel: CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
"""Make an absolute move. Keyword arguments should be axis names.""" """Make an absolute move. Keyword arguments should be axis names."""
displacement = { displacement = {
k: int(v) - self.position[k] k: int(v) - self.position[k]
for k, v in kwargs.items() for k, v in kwargs.items()
if k in self.axis_names if k in self.axis_names
} }
self.move_relative(cancel, block_cancellation=block_cancellation, **displacement) self.move_relative(
cancel, block_cancellation=block_cancellation, **displacement
)
@thing_action @thing_action
def set_zero_position(self): def set_zero_position(self):
"""Make the current position zero in all axes """Make the current position zero in all axes
This action does not move the stage, but resets the position to zero. This action does not move the stage, but resets the position to zero.
It is intended for use after manually or automatically recentring the It is intended for use after manually or automatically recentring the
stage. stage.

View file

@ -21,8 +21,10 @@ class Stitcher(Thing):
self._script = path_to_openflexure_stitch self._script = path_to_openflexure_stitch
def run_subprocess( def run_subprocess(
self, logger: InvocationLogger, cmd: list[str], self,
) -> CompletedProcess: logger: InvocationLogger,
cmd: list[str],
) -> CompletedProcess:
"""Run a subprocess and log any output""" """Run a subprocess and log any output"""
logger.info(f"Running command in subprocess: `{' '.join(cmd)}") logger.info(f"Running command in subprocess: `{' '.join(cmd)}")
output = run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True) output = run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True)
@ -31,19 +33,26 @@ class Stitcher(Thing):
logger.info(pipe) logger.info(pipe)
output.check_returncode() output.check_returncode()
return output return output
def images_folder(self, smart_scan: SmartScanDep, scan_name: Optional[str]=None) -> str: def images_folder(
self, smart_scan: SmartScanDep, scan_name: Optional[str] = None
) -> str:
scan_folder = smart_scan.scan_folder_path(scan_name=scan_name) scan_folder = smart_scan.scan_folder_path(scan_name=scan_name)
return os.path.join(scan_folder, "images") return os.path.join(scan_folder, "images")
@staticmethod @staticmethod
def output_up_to_date(folder: str, output_filename: str, image_prefix: str="image", image_suffix: str=".jpg") -> bool: def output_up_to_date(
folder: str,
output_filename: str,
image_prefix: str = "image",
image_suffix: str = ".jpg",
) -> bool:
"""Check if any of the images in a folder are newer than a file """Check if any of the images in a folder are newer than a file
If there are no images (files with the prefix and suffix) newer than the If there are no images (files with the prefix and suffix) newer than the
`output_filename`, we return `True`, i.e. the output is up to date. If `output_filename`, we return `True`, i.e. the output is up to date. If
any image in the folder is newer, we return `False`. any image in the folder is newer, we return `False`.
This is not flawless logic - if an update process is slow, images might be This is not flawless logic - if an update process is slow, images might be
saved between starting that process and saving the output. Consequently, saved between starting that process and saving the output. Consequently,
a `True` from this function does not guarantee the output is up to date. a `True` from this function does not guarantee the output is up to date.
@ -61,24 +70,48 @@ class Stitcher(Thing):
return True return True
@thing_action @thing_action
def stitch_scan_from_stage(self, logger: InvocationLogger, smart_scan: SmartScanDep, scan_name: Optional[str]=None, downsample: float=1.0) -> JPEGBlob: def stitch_scan_from_stage(
self,
logger: InvocationLogger,
smart_scan: SmartScanDep,
scan_name: Optional[str] = None,
downsample: float = 1.0,
) -> JPEGBlob:
"""Generate a stitched image based on stage position metadata""" """Generate a stitched image based on stage position metadata"""
output_fname = "stitched_from_stage.jpg" output_fname = "stitched_from_stage.jpg"
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name) images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
if self.output_up_to_date(images_folder, output_fname): if self.output_up_to_date(images_folder, output_fname):
logger.info(f"No images are newer than {output_fname}, skipping.") logger.info(f"No images are newer than {output_fname}, skipping.")
else: else:
self.run_subprocess(logger, [self._script, "--stitching_mode", "only_stage_stitch", images_folder]) self.run_subprocess(
logger,
[self._script, "--stitching_mode", "only_stage_stitch", images_folder],
)
return JPEGBlob.from_file(os.path.join(images_folder, output_fname)) return JPEGBlob.from_file(os.path.join(images_folder, output_fname))
@thing_action
def update_scan_correlations(self, logger: InvocationLogger, smart_scan: SmartScanDep, scan_name: Optional[str]=None) -> None:
"""Generate a stitched image based on stage position metadata"""
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
self.run_subprocess(logger, [self._script, "--stitching_mode", "only_correlate", images_folder])
@thing_action @thing_action
def stitch_scan(self, logger: InvocationLogger, smart_scan: SmartScanDep, scan_name: Optional[str]=None, downsample: float=1.0) -> None: def update_scan_correlations(
self,
logger: InvocationLogger,
smart_scan: SmartScanDep,
scan_name: Optional[str] = None,
) -> None:
"""Generate a stitched image based on stage position metadata""" """Generate a stitched image based on stage position metadata"""
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name) images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
self.run_subprocess(logger, [self._script, "--stitching_mode", "all", images_folder]) self.run_subprocess(
logger, [self._script, "--stitching_mode", "only_correlate", images_folder]
)
@thing_action
def stitch_scan(
self,
logger: InvocationLogger,
smart_scan: SmartScanDep,
scan_name: Optional[str] = None,
downsample: float = 1.0,
) -> None:
"""Generate a stitched image based on stage position metadata"""
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
self.run_subprocess(
logger, [self._script, "--stitching_mode", "all", images_folder]
)

View file

@ -3,12 +3,14 @@ OpenFlexure Microscope system control Thing
This module defines a Thing that can shut down or restart the host computer. This module defines a Thing that can shut down or restart the host computer.
""" """
import subprocess import subprocess
import os import os
from labthings_fastapi.thing import Thing from labthings_fastapi.thing import Thing
from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.decorators import thing_action, thing_property
from pydantic import BaseModel from pydantic import BaseModel
class CommandOutput(BaseModel): class CommandOutput(BaseModel):
output: str output: str
error: str error: str
@ -16,7 +18,7 @@ class CommandOutput(BaseModel):
class SystemControlThing(Thing): class SystemControlThing(Thing):
""" """
Attempt to shutdown the device Attempt to shutdown the device
""" """
@thing_action @thing_action
@ -39,7 +41,7 @@ class SystemControlThing(Thing):
Checks if we are running on a Raspberry Pi. Checks if we are running on a Raspberry Pi.
""" """
return os.path.exists("/usr/bin/raspi-config") return os.path.exists("/usr/bin/raspi-config")
@thing_action @thing_action
def reboot(self) -> CommandOutput: def reboot(self) -> CommandOutput:
"""Attempt to reboot the device""" """Attempt to reboot the device"""

View file

@ -3,6 +3,7 @@ OpenFlexure Microscope API test Thing
This Thing is intended only for use testing out the API and client(s). This Thing is intended only for use testing out the API and client(s).
""" """
from labthings_fastapi.thing import Thing from labthings_fastapi.thing import Thing
from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger

View file

@ -16,13 +16,19 @@ from openflexure_microscope_server.things.autofocus import AutofocusThing
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
from openflexure_microscope_server.things import camera_stage_mapping from openflexure_microscope_server.things import camera_stage_mapping
camera_stage_mapping.DEFAULT_SETTLING_TIME = 0 # skip the settling time for tests camera_stage_mapping.DEFAULT_SETTLING_TIME = 0 # skip the settling time for tests
@pytest.fixture @pytest.fixture
def thing_server(): def thing_server():
temp_folder = tempfile.TemporaryDirectory() temp_folder = tempfile.TemporaryDirectory()
server = ThingServer(settings_folder=temp_folder.name) server = ThingServer(settings_folder=temp_folder.name)
server.add_thing(SimulatedCamera(shape=(240, 320, 3), canvas_shape=(960, 1240, 3), frame_interval=0.01), "/camera/") server.add_thing(
SimulatedCamera(
shape=(240, 320, 3), canvas_shape=(960, 1240, 3), frame_interval=0.01
),
"/camera/",
)
server.add_thing(DummyStage(step_time=0.000001), "/stage/") server.add_thing(DummyStage(step_time=0.000001), "/stage/")
server.add_thing(AutofocusThing(), "/autofocus/") server.add_thing(AutofocusThing(), "/autofocus/")
server.add_thing(CameraStageMapper(), "/camera_stage_mapping/") server.add_thing(CameraStageMapper(), "/camera_stage_mapping/")
@ -30,27 +36,32 @@ def thing_server():
# NB yield is important: otherwise, the temp folder gets deleted before the test runs # NB yield is important: otherwise, the temp folder gets deleted before the test runs
yield server yield server
@pytest.fixture @pytest.fixture
def client(thing_server): def client(thing_server):
with TestClient(thing_server.app) as client: with TestClient(thing_server.app) as client:
yield client yield client
@pytest.fixture @pytest.fixture
def slower_client(thing_server): def slower_client(thing_server):
thing_server.things["/stage/"].step_time = 0.0001 thing_server.things["/stage/"].step_time = 0.0001
with TestClient(thing_server.app) as client: with TestClient(thing_server.app) as client:
yield client yield client
def test_autofocus(slower_client): def test_autofocus(slower_client):
client = slower_client client = slower_client
autofocus = ThingClient.from_url("/autofocus/", client) autofocus = ThingClient.from_url("/autofocus/", client)
_ = autofocus.fast_autofocus() _ = autofocus.fast_autofocus()
def test_grab_jpeg(client): def test_grab_jpeg(client):
camera = ThingClient.from_url("/camera/", client) camera = ThingClient.from_url("/camera/", client)
blob = camera.grab_jpeg() blob = camera.grab_jpeg()
_image = Image.open(blob.open()) _image = Image.open(blob.open())
def test_capture_jpeg_metadata(client): def test_capture_jpeg_metadata(client):
camera = ThingClient.from_url("/camera/", client) camera = ThingClient.from_url("/camera/", client)
blob = camera.capture_jpeg() blob = camera.capture_jpeg()
@ -60,6 +71,7 @@ def test_capture_jpeg_metadata(client):
metadata = json.loads(encoded_metadata) metadata = json.loads(encoded_metadata)
assert "position" in metadata["/stage/"] assert "position" in metadata["/stage/"]
def test_stage(client): def test_stage(client):
stage = ThingClient.from_url("/stage/", client) stage = ThingClient.from_url("/stage/", client)
start = stage.position start = stage.position
@ -73,11 +85,13 @@ def test_stage(client):
for s, p in zip(start.values(), pos.values()): for s, p in zip(start.values(), pos.values()):
assert s == p assert s == p
def test_capture_array(client): def test_capture_array(client):
camera = ThingClient.from_url("/camera/", client) camera = ThingClient.from_url("/camera/", client)
array = np.asarray(camera.capture_array()) array = np.asarray(camera.capture_array())
assert array.shape == (240, 320, 3) assert array.shape == (240, 320, 3)
# Currently this fails, not yet sure why. # Currently this fails, not yet sure why.
def test_camera_stage_mapping_calibration(client): def test_camera_stage_mapping_calibration(client):
camera_stage_mapping = ThingClient.from_url("/camera_stage_mapping/", client) camera_stage_mapping = ThingClient.from_url("/camera_stage_mapping/", client)