Formatted with Ruff
This commit is contained in:
parent
27aec769a2
commit
9fd8fc37f1
19 changed files with 680 additions and 392 deletions
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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": {},
|
||||||
|
|
|
||||||
|
|
@ -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": {},
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue