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
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ class Zenodo:
|
|||
self.zenodo_url = "https://zenodo.org/api/deposit/depositions"
|
||||
|
||||
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"}
|
||||
r = requests.post(
|
||||
|
|
@ -35,7 +36,7 @@ class Zenodo:
|
|||
return r.json()
|
||||
|
||||
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"}
|
||||
r = requests.put(
|
||||
|
|
@ -48,7 +49,7 @@ class Zenodo:
|
|||
print(r.json())
|
||||
|
||||
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)
|
||||
data = {"filename": file_name}
|
||||
|
|
@ -63,7 +64,7 @@ class Zenodo:
|
|||
print(r.json())
|
||||
|
||||
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(
|
||||
self.zenodo_url + "/{}/actions/publish".format(deposition_id),
|
||||
|
|
@ -73,7 +74,7 @@ class Zenodo:
|
|||
print(r.json())
|
||||
|
||||
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(
|
||||
self.zenodo_url + "/{}/actions/newversion".format(deposition_id),
|
||||
|
|
@ -84,7 +85,7 @@ class Zenodo:
|
|||
return os.path.basename(r.json()["links"]["latest_draft"])
|
||||
|
||||
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(
|
||||
self.zenodo_url + "/{}/files".format(deposition_id),
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ import os
|
|||
|
||||
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")
|
||||
|
||||
|
||||
def configure_logging():
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.INFO)
|
||||
|
|
@ -14,15 +15,13 @@ def configure_logging():
|
|||
if not os.path.exists(OFM_LOG_FOLDER):
|
||||
os.makedirs(OFM_LOG_FOLDER)
|
||||
handler = RotatingFileHandler(
|
||||
filename = OFM_LOG_FILE,
|
||||
mode = "a",
|
||||
maxBytes = 1000000,
|
||||
backupCount = 10,
|
||||
filename=OFM_LOG_FILE,
|
||||
mode="a",
|
||||
maxBytes=1000000,
|
||||
backupCount=10,
|
||||
)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"[%(asctime)s] [%(levelname)s] %(message)s"
|
||||
)
|
||||
logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s")
|
||||
)
|
||||
root_logger.addHandler(handler)
|
||||
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)
|
||||
else:
|
||||
raise e
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from socket import gethostname
|
|||
|
||||
def add_v2_endpoints(thing_server: ThingServer):
|
||||
app = thing_server.app
|
||||
|
||||
# TODO: update openflexure connect to make this unnecessary!!
|
||||
# The endpoints below fool OpenFlexure Connect into thinking we are a
|
||||
# v2 microscope, so we show up correctly.
|
||||
|
|
@ -15,7 +16,7 @@ def add_v2_endpoints(thing_server: ThingServer):
|
|||
fake_routes = [
|
||||
"/api/v2/",
|
||||
"/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}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,22 +4,23 @@ from fastapi import FastAPI
|
|||
import os
|
||||
import pathlib
|
||||
|
||||
|
||||
def add_static_file(app: FastAPI, fname: str, folder: str):
|
||||
print(f"Adding route for /{fname}")
|
||||
p=os.path.join(folder, fname)
|
||||
app.get(
|
||||
f"/{fname}",
|
||||
response_class=FileResponse,
|
||||
include_in_schema=False
|
||||
)(lambda: FileResponse(p))
|
||||
p = os.path.join(folder, fname)
|
||||
app.get(f"/{fname}", response_class=FileResponse, include_in_schema=False)(
|
||||
lambda: FileResponse(p)
|
||||
)
|
||||
|
||||
|
||||
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/")
|
||||
#TODO: don't hard code this!
|
||||
# TODO: don't hard code this!
|
||||
search_paths = [
|
||||
"/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__:
|
||||
search_paths.append(pathlib.Path(__file__).parent.parent / "static")
|
||||
|
|
@ -34,6 +35,7 @@ def add_static_files(app: FastAPI):
|
|||
@app.get("/", response_class=RedirectResponse)
|
||||
async def redirect_fastapi():
|
||||
return "/index.html"
|
||||
|
||||
for fname in os.listdir(static_path):
|
||||
fpath = os.path.join(static_path, fname)
|
||||
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/")
|
||||
AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/")
|
||||
|
||||
|
||||
class RecentringThing(Thing):
|
||||
@thing_action
|
||||
def recentre(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ camera together to perform an autofocus routine.
|
|||
|
||||
See repository root for licensing information.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from contextlib import contextmanager
|
||||
import logging
|
||||
|
|
@ -26,8 +27,10 @@ from pydantic import BaseModel
|
|||
|
||||
### Autofocus utilities
|
||||
|
||||
|
||||
class JPEGSharpnessMonitor:
|
||||
__globals__ = globals() # Required for FastAPI dependency
|
||||
|
||||
def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal):
|
||||
self.camera = camera
|
||||
self.stage = stage
|
||||
|
|
@ -73,7 +76,7 @@ class JPEGSharpnessMonitor:
|
|||
# Index of the data for this movement
|
||||
data_index: int = len(self.stage_positions) - 2
|
||||
# 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
|
||||
|
||||
def move_data(
|
||||
|
|
@ -84,12 +87,10 @@ class JPEGSharpnessMonitor:
|
|||
istop = istart + 2
|
||||
jpeg_times: np.ndarray = np.array(self.jpeg_times)
|
||||
jpeg_sizes: np.ndarray = np.array(self.jpeg_sizes)
|
||||
stage_times: np.ndarray = np.array(self.stage_times)[
|
||||
istart:istop
|
||||
]
|
||||
stage_times: np.ndarray = np.array(self.stage_times)[istart:istop]
|
||||
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:
|
||||
start: int = int(np.argmax(jpeg_times > stage_times[0]))
|
||||
stop: int = int(np.argmax(jpeg_times > stage_times[1]))
|
||||
|
|
@ -128,6 +129,7 @@ class JPEGSharpnessMonitor:
|
|||
|
||||
SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()]
|
||||
|
||||
|
||||
class SharpnessDataArrays(BaseModel):
|
||||
jpeg_times: NDArray
|
||||
jpeg_sizes: NDArray
|
||||
|
|
@ -140,8 +142,8 @@ class AutofocusThing(Thing):
|
|||
def fast_autofocus(
|
||||
self,
|
||||
m: SharpnessMonitorDep,
|
||||
dz: int=2000,
|
||||
start: str='centre',
|
||||
dz: int = 2000,
|
||||
start: str = "centre",
|
||||
) -> SharpnessDataArrays:
|
||||
"""Sweep the stage up and down, then move to the sharpest point
|
||||
|
||||
|
|
@ -151,7 +153,7 @@ class AutofocusThing(Thing):
|
|||
"""
|
||||
with m.run():
|
||||
# Move to (-dz / 2)
|
||||
if start == 'centre':
|
||||
if start == "centre":
|
||||
m.focus_rel(-dz / 2)
|
||||
# Move to dz while monitoring sharpness
|
||||
# i: Sharpness monitor index for this move
|
||||
|
|
@ -171,7 +173,7 @@ class AutofocusThing(Thing):
|
|||
self,
|
||||
m: SharpnessMonitorDep,
|
||||
dz: Sequence[int],
|
||||
wait: float=0,
|
||||
wait: float = 0,
|
||||
) -> SharpnessDataArrays:
|
||||
"""Make a move (or a series of moves) and monitor sharpness
|
||||
|
||||
|
|
@ -193,7 +195,9 @@ class AutofocusThing(Thing):
|
|||
return m.data_dict()
|
||||
|
||||
@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.
|
||||
|
||||
This action will run the `fast_autofocus` action until it settles on a point
|
||||
|
|
@ -207,10 +211,9 @@ class AutofocusThing(Thing):
|
|||
|
||||
with m.run():
|
||||
while repeat and attempts < 10:
|
||||
|
||||
if start == 'centre':
|
||||
stage.move_relative(x = 0, y = 0, z = -(backlash + dz / 2))
|
||||
stage.move_relative(x = 0, y = 0, z = backlash)
|
||||
if start == "centre":
|
||||
stage.move_relative(x=0, y=0, z=-(backlash + dz / 2))
|
||||
stage.move_relative(x=0, y=0, z=backlash)
|
||||
|
||||
i, z = m.focus_rel(dz, block_cancellation=True)
|
||||
_, heights, sizes = m.move_data(i)
|
||||
|
|
@ -224,22 +227,24 @@ class AutofocusThing(Thing):
|
|||
or height_max - peak_height < dz / 5
|
||||
):
|
||||
attempts += 1
|
||||
start = 'centre'
|
||||
stage.move_absolute(z = peak_height-backlash)
|
||||
stage.move_absolute(z = peak_height)
|
||||
start = "centre"
|
||||
stage.move_absolute(z=peak_height - backlash)
|
||||
stage.move_absolute(z=peak_height)
|
||||
else:
|
||||
repeat = False
|
||||
stage.move_relative(x = 0, y = 0, z = -(dz+backlash))
|
||||
stage.move_absolute(z = peak_height)
|
||||
stage.move_relative(x=0, y=0, z=-(dz + backlash))
|
||||
stage.move_absolute(z=peak_height)
|
||||
return heights.tolist(), sizes.tolist()
|
||||
|
||||
@thing_action
|
||||
def verify_focus_sharpness(self, sweep_sizes: list, camera: WrappedCamera, threshold: float = 0.95):
|
||||
'''Take the sharpness curve of the autofocus, and the size of the current frame
|
||||
def verify_focus_sharpness(
|
||||
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
|
||||
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)
|
||||
base = np.min(sweep_sizes)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ should enabe the server to work.
|
|||
|
||||
See repository root for licensing information.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
|
@ -23,15 +24,14 @@ from labthings_fastapi.types.numpy import NDArray
|
|||
class JPEGBlob(Blob):
|
||||
media_type: str = "image/jpeg"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CameraProtocol(Protocol):
|
||||
"""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
|
||||
def stream_active(self) -> bool:
|
||||
|
|
@ -45,8 +45,7 @@ class CameraProtocol(Protocol):
|
|||
def capture_array(
|
||||
self,
|
||||
resolution: Literal["lores", "main", "full"] = "main",
|
||||
) -> NDArray:
|
||||
...
|
||||
) -> NDArray: ...
|
||||
|
||||
def capture_jpeg(
|
||||
self,
|
||||
|
|
@ -78,6 +77,7 @@ class CameraProtocol(Protocol):
|
|||
"""Acquire one image from the preview stream and return its size"""
|
||||
...
|
||||
|
||||
|
||||
class BaseCamera(Thing):
|
||||
"""A Thing representing a camera
|
||||
|
||||
|
|
@ -110,12 +110,16 @@ class BaseCamera(Thing):
|
|||
stream (either "main" for the preview stream, or "lores" for the low
|
||||
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 = (
|
||||
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
|
||||
)
|
||||
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)
|
||||
|
||||
@thing_action
|
||||
|
|
@ -142,6 +146,7 @@ class CameraStub(BaseCamera):
|
|||
|
||||
This stub class should be used for dependencies on the CameraProtocol.
|
||||
"""
|
||||
|
||||
def __enter__(self) -> None:
|
||||
raise NotImplementedError("Cameras must not inherit from CameraStub")
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ This module defines a camera Thing that uses OpenCV's
|
|||
|
||||
See repository root for licensing information.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import io
|
||||
import json
|
||||
|
|
@ -26,7 +27,8 @@ from . import BaseCamera, JPEGBlob
|
|||
|
||||
class OpenCVCamera(BaseCamera):
|
||||
"""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._capture_thread: Optional[Thread] = None
|
||||
self._capture_enabled = False
|
||||
|
|
@ -50,6 +52,7 @@ class OpenCVCamera(BaseCamera):
|
|||
if self._capture_enabled and self._capture_thread:
|
||||
return self._capture_thread.is_alive()
|
||||
return False
|
||||
|
||||
mjpeg_stream = MJPEGStreamDescriptor()
|
||||
lores_mjpeg_stream = MJPEGStreamDescriptor()
|
||||
|
||||
|
|
@ -58,11 +61,15 @@ class OpenCVCamera(BaseCamera):
|
|||
while self._capture_enabled:
|
||||
ret, frame = self.cap.read()
|
||||
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
|
||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||
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)
|
||||
|
||||
@thing_action
|
||||
|
|
@ -78,7 +85,9 @@ class OpenCVCamera(BaseCamera):
|
|||
"""
|
||||
ret, frame = self.cap.read()
|
||||
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
|
||||
|
||||
@thing_action
|
||||
|
|
@ -95,7 +104,9 @@ class OpenCVCamera(BaseCamera):
|
|||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||
exif_dict = {
|
||||
"Exif": {
|
||||
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8")
|
||||
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode(
|
||||
"utf-8"
|
||||
)
|
||||
},
|
||||
"GPS": {},
|
||||
"Interop": {},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ camera together to perform an autofocus routine.
|
|||
|
||||
See repository root for licensing information.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import io
|
||||
import json
|
||||
|
|
@ -28,22 +29,26 @@ from pydantic import RootModel
|
|||
from . import BaseCamera, JPEGBlob
|
||||
from ..stage import StageProtocol as Stage
|
||||
|
||||
|
||||
class ArrayModel(RootModel):
|
||||
"""A model for an array"""
|
||||
|
||||
root: NDArray
|
||||
|
||||
|
||||
class SimulatedCamera(BaseCamera):
|
||||
"""A Thing representing an OpenCV camera"""
|
||||
|
||||
_stage: Optional[Stage] = None
|
||||
_server: Optional[ThingServer] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
shape: tuple[int, int, int] = (600, 800, 3),
|
||||
glyph_shape: tuple[int, int, int] = (51, 51, 3),
|
||||
canvas_shape: tuple[int, int, int] = (3000, 4000, 3),
|
||||
frame_interval: float = 0.1,
|
||||
):
|
||||
self,
|
||||
shape: tuple[int, int, int] = (600, 800, 3),
|
||||
glyph_shape: tuple[int, int, int] = (51, 51, 3),
|
||||
canvas_shape: tuple[int, int, int] = (3000, 4000, 3),
|
||||
frame_interval: float = 0.1,
|
||||
):
|
||||
self.shape = shape
|
||||
self.glyph_shape = glyph_shape
|
||||
self.canvas_shape = canvas_shape
|
||||
|
|
@ -60,7 +65,7 @@ class SimulatedCamera(BaseCamera):
|
|||
black = np.zeros(self.glyph_shape, dtype=np.uint8)
|
||||
x = np.arange(black.shape[0])
|
||||
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]:
|
||||
sprite = black.copy()
|
||||
sprite[rr < i] = 255
|
||||
|
|
@ -75,8 +80,8 @@ class SimulatedCamera(BaseCamera):
|
|||
self.blobs = np.zeros((N, 3))
|
||||
rng = np.random.default_rng()
|
||||
w = np.max(self.glyph_shape)
|
||||
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[:, 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[:, 2] = rng.choice(len(self.sprites), N)
|
||||
|
||||
def generate_canvas(self):
|
||||
|
|
@ -86,20 +91,23 @@ class SimulatedCamera(BaseCamera):
|
|||
w, h, _ = self.glyph_shape
|
||||
for x, y, sprite in self.blobs:
|
||||
self.canvas[
|
||||
int(x) - w//2:int(x) - w//2 + w,
|
||||
int(y) - h//2:int(y) - h//2 + h,
|
||||
int(x) - w // 2 : int(x) - w // 2 + w,
|
||||
int(y) - h // 2 : int(y) - h // 2 + h,
|
||||
] -= self.sprites[int(sprite)]
|
||||
|
||||
def generate_image(self, pos: tuple[int, int]):
|
||||
"""Generate an image with blobs based on supplied coordinates"""
|
||||
cw, ch, _ = self.canvas_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[
|
||||
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:
|
||||
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
|
||||
|
||||
def attach_to_server(self, server: ThingServer, path: str):
|
||||
|
|
@ -118,7 +126,7 @@ class SimulatedCamera(BaseCamera):
|
|||
except Exception as e:
|
||||
print(f"Failed to get stage position: {e}")
|
||||
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):
|
||||
self._capture_enabled = True
|
||||
|
|
@ -137,6 +145,7 @@ class SimulatedCamera(BaseCamera):
|
|||
if self._capture_enabled and self._capture_thread:
|
||||
return self._capture_thread.is_alive()
|
||||
return False
|
||||
|
||||
mjpeg_stream = MJPEGStreamDescriptor()
|
||||
lores_mjpeg_stream = MJPEGStreamDescriptor()
|
||||
|
||||
|
|
@ -148,7 +157,9 @@ class SimulatedCamera(BaseCamera):
|
|||
frame = self.generate_frame()
|
||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||
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)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to capture frame: {e}, retrying...")
|
||||
|
|
@ -180,7 +191,9 @@ class SimulatedCamera(BaseCamera):
|
|||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||
exif_dict = {
|
||||
"Exif": {
|
||||
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8")
|
||||
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode(
|
||||
"utf-8"
|
||||
)
|
||||
},
|
||||
"GPS": {},
|
||||
"Interop": {},
|
||||
|
|
|
|||
|
|
@ -10,8 +10,20 @@ and return the calibration data.
|
|||
This module is only intended to be called from the OpenFlexure Microscope
|
||||
server, and depends on that server and its underlying LabThings library.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
import numpy as np
|
||||
|
|
@ -20,7 +32,10 @@ from camera_stage_mapping.camera_stage_calibration_1d import (
|
|||
calibrate_backlash_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.decorators import thing_action, thing_property
|
||||
from labthings_fastapi.thing import Thing
|
||||
|
|
@ -31,6 +46,7 @@ from .stage import StageDependency as Stage
|
|||
CoordinateType = Tuple[float, float, float]
|
||||
XYCoordinateType = Tuple[float, float]
|
||||
|
||||
|
||||
class HardwareInterfaceModel(BaseModel):
|
||||
move: Callable[[NDArray], None]
|
||||
get_position: Callable[[], NDArray]
|
||||
|
|
@ -53,46 +69,60 @@ def downsample(factor: int, image: np.ndarray) -> np.ndarray:
|
|||
new_size = [d // factor for d in image.shape[:2]]
|
||||
# First, we ensure we have something that's an integer multiple
|
||||
# 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(
|
||||
(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
|
||||
|
||||
|
||||
def make_hardware_interface(
|
||||
stage: Stage, camera: Camera, downsample_factor: int = 2
|
||||
) -> HardwareInterfaceModel:
|
||||
stage: Stage, camera: Camera, downsample_factor: int = 2
|
||||
) -> HardwareInterfaceModel:
|
||||
"""Construct the functions we need to interface with the hardware"""
|
||||
axes = stage.axis_names
|
||||
|
||||
def pos2dict(pos: Sequence[float]) -> Mapping[str, float]:
|
||||
return {k: p for k, p in zip(axes, pos)}
|
||||
|
||||
def dict2pos(posd: Mapping[str, float]) -> Sequence[float]:
|
||||
return tuple(posd[k] for k in axes if k in posd)
|
||||
|
||||
def move(pos: CoordinateType) -> None:
|
||||
current_pos = stage.position
|
||||
new_pos = pos2dict(pos)
|
||||
displacement = {k: new_pos[k] - current_pos[k] for k in new_pos.keys()}
|
||||
stage.move_relative(**displacement)
|
||||
|
||||
def get_position() -> CoordinateType:
|
||||
return dict2pos(stage.position)
|
||||
|
||||
def grab_image() -> np.ndarray:
|
||||
img = camera.capture_array()
|
||||
return downsample(downsample_factor, img)
|
||||
|
||||
def settle() -> None:
|
||||
time.sleep(DEFAULT_SETTLING_TIME)
|
||||
try:
|
||||
camera.capture_metadata # This discards frames on a picamera
|
||||
except AttributeError:
|
||||
pass # Don't raise an error for other cameras (may consider grabbing a frame)
|
||||
|
||||
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):
|
||||
|
|
@ -143,14 +173,16 @@ class CSMUncalibratedError(HTTPException):
|
|||
(
|
||||
"The camera_stage_mapping calibration is not yet available. "
|
||||
"This probably means you need to run the calibration routine."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CameraStageMapper(Thing):
|
||||
"""A Thing to manage mapping between image and stage coordinates"""
|
||||
|
||||
def __enter__(self):
|
||||
pass
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.thing_settings.write_to_file()
|
||||
|
||||
|
|
@ -163,13 +195,17 @@ class CameraStageMapper(Thing):
|
|||
direction: Tuple[float, float, float],
|
||||
) -> DenumpifyingDict:
|
||||
"""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)
|
||||
direction_array: np.ndarray = np.array(direction)
|
||||
|
||||
starting_position = stage.position
|
||||
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:
|
||||
logger.info("Returning to starting position")
|
||||
stage.move_absolute(**starting_position, block_cancellation=True)
|
||||
|
|
@ -204,7 +240,7 @@ class CameraStageMapper(Thing):
|
|||
self.thing_settings["image_resolution"] = corrected_resolution
|
||||
|
||||
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}.")
|
||||
|
||||
data: Dict[str, dict] = {
|
||||
|
|
@ -221,7 +257,9 @@ class CameraStageMapper(Thing):
|
|||
return data
|
||||
|
||||
@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.
|
||||
|
||||
Note that this matrix is defined using "matrix coordinates", i.e. image coordinates
|
||||
|
|
@ -256,8 +294,7 @@ class CameraStageMapper(Thing):
|
|||
|
||||
@thing_property
|
||||
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)
|
||||
|
||||
@thing_action
|
||||
|
|
@ -280,8 +317,7 @@ class CameraStageMapper(Thing):
|
|||
"""
|
||||
self.assert_calibrated()
|
||||
relative_move: np.ndarray = np.dot(
|
||||
np.array([y, x]),
|
||||
np.array(self.image_to_stage_displacement_matrix)
|
||||
np.array([y, x]), np.array(self.image_to_stage_displacement_matrix)
|
||||
)
|
||||
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
|
||||
the server.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from socket import gethostname
|
||||
from typing import Annotated, Any, MutableMapping, Optional, Sequence
|
||||
|
|
@ -29,10 +30,7 @@ def recursive_update(old_dict: MutableMapping, update: Mapping):
|
|||
"""Update a dictionary recursively"""
|
||||
for k, v in update.items():
|
||||
if isinstance(v, Mapping):
|
||||
if (
|
||||
k in old_dict
|
||||
and isinstance(old_dict[k], MutableMapping)
|
||||
):
|
||||
if k in old_dict and isinstance(old_dict[k], MutableMapping):
|
||||
recursive_update(old_dict[k], v)
|
||||
else:
|
||||
old_dict[k] = dict(**v)
|
||||
|
|
@ -57,7 +55,9 @@ class SettingsManager(Thing):
|
|||
return self.thing_settings.get("external_metadata", {})
|
||||
|
||||
@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.
|
||||
|
||||
The data supplied will be merged into the existing dictionary
|
||||
|
|
@ -91,8 +91,7 @@ class SettingsManager(Thing):
|
|||
del subdict[key.split("/")[-1]]
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="The specified key '{key}' was not found"
|
||||
status_code=404, detail="The specified key '{key}' was not found"
|
||||
)
|
||||
self.thing_settings["external_metadata"] = metadata
|
||||
|
||||
|
|
@ -117,6 +116,7 @@ class SettingsManager(Thing):
|
|||
def external_metadata_in_state(self) -> Sequence[str]:
|
||||
"""A list of strings that are included in the "state" metadata"""
|
||||
return self.thing_settings.get("external_metadata_in_state", [])
|
||||
|
||||
@external_metadata_in_state.setter
|
||||
def external_metadata_in_state(self, keys: Sequence[str]):
|
||||
"""Set the keys from external metadata that are returned in state"""
|
||||
|
|
@ -140,7 +140,9 @@ class SettingsManager(Thing):
|
|||
return state
|
||||
|
||||
@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.
|
||||
|
||||
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."
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.warning(
|
||||
f"Could not write {name} settings, folder not found"
|
||||
)
|
||||
|
||||
logger.warning(f"Could not write {name} settings, folder not found")
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ import piexif
|
|||
from labthings_fastapi.thing import Thing
|
||||
from labthings_fastapi.dependencies.metadata import GetThingStates
|
||||
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
|
||||
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger, InvocationCancelledError
|
||||
from labthings_fastapi.dependencies.invocation import (
|
||||
CancelHook,
|
||||
InvocationLogger,
|
||||
InvocationCancelledError,
|
||||
)
|
||||
from labthings_fastapi.decorators import thing_action, thing_property, fastapi_endpoint
|
||||
from labthings_fastapi.outputs.blob import blob_type
|
||||
from .camera import CameraDependency as CamDep
|
||||
|
|
@ -118,10 +122,12 @@ def limit_focus_change(prev_pos, prev_z, new_pos, new_z, limit):
|
|||
# (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2, dtype="float64"
|
||||
# )
|
||||
|
||||
|
||||
def steps_from_centre(current_loc, starting_loc, dx, dy):
|
||||
step_size = np.array([dx,dy])
|
||||
step_size = np.array([dx, dy])
|
||||
return np.max(np.abs(np.divide(np.subtract(current_loc, starting_loc), step_size)))
|
||||
|
||||
|
||||
# def set_template(microscope, pos):
|
||||
# microscope.move(pos)
|
||||
# background = microscope.grab_image_array()
|
||||
|
|
@ -145,41 +151,60 @@ def distance_to_site(current, next):
|
|||
current = np.array(current, dtype="float64")
|
||||
return np.sqrt((next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2)
|
||||
|
||||
|
||||
def scale_csm(csm_matrix, calibration_width, img_width):
|
||||
"Account for a calibration width that may differ from image width"
|
||||
scale = img_width / calibration_width # Usually >1, if we calibrated at low res
|
||||
csm = np.array(csm_matrix) / scale # Decrease the CSM if pixels are smaller]
|
||||
return csm
|
||||
|
||||
def generate_config(folder_path: str, positions: list, names: list, camera_to_sample_matrix, csm_calibration_width, img_width, logger):
|
||||
|
||||
def generate_config(
|
||||
folder_path: str,
|
||||
positions: list,
|
||||
names: list,
|
||||
camera_to_sample_matrix,
|
||||
csm_calibration_width,
|
||||
img_width,
|
||||
logger,
|
||||
):
|
||||
positions = np.array(positions)
|
||||
mean_loc = np.mean(positions, axis = 0)
|
||||
mean_loc = np.mean(positions, axis=0)
|
||||
|
||||
#TODO: positions from recent scans need to be 2x bigger - change to CSM res?
|
||||
# TODO: positions from recent scans need to be 2x bigger - change to CSM res?
|
||||
|
||||
camera_to_sample_matrix = scale_csm(camera_to_sample_matrix, csm_calibration_width, img_width)
|
||||
camera_to_sample_matrix = scale_csm(
|
||||
camera_to_sample_matrix, csm_calibration_width, img_width
|
||||
)
|
||||
|
||||
with open(os.path.join(folder_path, 'TileConfiguration.txt'), 'w') as fp:
|
||||
fp.write('# Define the number of dimensions we are working on\ndim = 2\n\n# Define the image coordinates\n')
|
||||
with open(os.path.join(folder_path, "TileConfiguration.txt"), "w") as fp:
|
||||
fp.write(
|
||||
"# Define the number of dimensions we are working on\ndim = 2\n\n# Define the image coordinates\n"
|
||||
)
|
||||
for i in range(len(names)):
|
||||
loc = np.dot((positions[i] - mean_loc), np.linalg.inv(camera_to_sample_matrix))
|
||||
fp.write(f'{names[i]}; ; {loc[1], loc[0]} \n')
|
||||
loc = np.dot(
|
||||
(positions[i] - mean_loc), np.linalg.inv(camera_to_sample_matrix)
|
||||
)
|
||||
fp.write(f"{names[i]}; ; {loc[1], loc[0]} \n")
|
||||
|
||||
|
||||
def raw2rggb(raw):
|
||||
"""Convert packed 10 bit raw to RGGB 8 bit"""
|
||||
raw = np.asarray(raw) # ensure it's an array
|
||||
rggb = np.empty((616, 820, 4), dtype=np.uint8)
|
||||
raw_w = rggb.shape[1]//2*5
|
||||
for plane, offset in enumerate([(1,1), (0,1), (1,0), (0,0)]):
|
||||
rggb[:, ::2, plane] = raw[offset[0]::2, offset[1]:raw_w+offset[1]:5]
|
||||
rggb[:, 1::2, plane] = raw[offset[0]::2, offset[1]+2:raw_w+offset[1]+2:5]
|
||||
raw_w = rggb.shape[1] // 2 * 5
|
||||
for plane, offset in enumerate([(1, 1), (0, 1), (1, 0), (0, 0)]):
|
||||
rggb[:, ::2, plane] = raw[offset[0] :: 2, offset[1] : raw_w + offset[1] : 5]
|
||||
rggb[:, 1::2, plane] = raw[
|
||||
offset[0] :: 2, offset[1] + 2 : raw_w + offset[1] + 2 : 5
|
||||
]
|
||||
return rggb
|
||||
|
||||
|
||||
def rggb2rgb(rggb):
|
||||
return np.stack([rggb[..., 0], rggb[..., 1]//2 + rggb[..., 2]//2, rggb[...,3]], axis=2)
|
||||
return np.stack(
|
||||
[rggb[..., 0], rggb[..., 1] // 2 + rggb[..., 2] // 2, rggb[..., 3]], axis=2
|
||||
)
|
||||
|
||||
|
||||
class ChannelDistributions(BaseModel):
|
||||
|
|
@ -187,6 +212,7 @@ class ChannelDistributions(BaseModel):
|
|||
standard_deviations: list[float]
|
||||
colorspace: str = "LUV"
|
||||
|
||||
|
||||
class BackgroundDetectThing(Thing):
|
||||
@thing_property
|
||||
def background_distributions(self) -> Optional[ChannelDistributions]:
|
||||
|
|
@ -230,10 +256,13 @@ class BackgroundDetectThing(Thing):
|
|||
"""
|
||||
d = self.background_distributions
|
||||
if not d:
|
||||
raise RuntimeError("Background is not set: you need to calibrate background detection.")
|
||||
raise RuntimeError(
|
||||
"Background is not set: you need to calibrate background detection."
|
||||
)
|
||||
return np.all(
|
||||
np.abs(image - np.array(d.means)[np.newaxis, np.newaxis, :])
|
||||
< np.array(d.standard_deviations)[np.newaxis, np.newaxis, :] * self.tolerance,
|
||||
< np.array(d.standard_deviations)[np.newaxis, np.newaxis, :]
|
||||
* self.tolerance,
|
||||
axis=2,
|
||||
)
|
||||
|
||||
|
|
@ -289,9 +318,9 @@ class BackgroundDetectThing(Thing):
|
|||
mu, std = np.apply_along_axis(norm.fit, 0, points)
|
||||
|
||||
self.background_distributions = ChannelDistributions(
|
||||
means = mu.tolist(),
|
||||
standard_deviations = std.tolist(),
|
||||
colorspace = "LUV",
|
||||
means=mu.tolist(),
|
||||
standard_deviations=std.tolist(),
|
||||
colorspace="LUV",
|
||||
)
|
||||
|
||||
@property
|
||||
|
|
@ -304,7 +333,9 @@ class BackgroundDetectThing(Thing):
|
|||
}
|
||||
|
||||
|
||||
BackgroundDep = direct_thing_client_dependency(BackgroundDetectThing, "/background_detect/")
|
||||
BackgroundDep = direct_thing_client_dependency(
|
||||
BackgroundDetectThing, "/background_detect/"
|
||||
)
|
||||
|
||||
|
||||
class NotEnoughFreeSpaceError(IOError):
|
||||
|
|
@ -322,20 +353,20 @@ def ensure_free_disk_space(path: str, min_space: int = 500000000) -> None:
|
|||
|
||||
|
||||
class ScanInfo(BaseModel):
|
||||
""""Summary information about a scan folder"""
|
||||
""" "Summary information about a scan folder"""
|
||||
|
||||
name: str
|
||||
created: datetime
|
||||
modified: datetime
|
||||
number_of_images: int
|
||||
|
||||
|
||||
DOWNLOADABLE_SCAN_FILES = (
|
||||
"images.zip",
|
||||
)
|
||||
DOWNLOADABLE_SCAN_FILES = ("images.zip",)
|
||||
|
||||
JPEGBlob = blob_type("image/jpeg")
|
||||
ZipBlob = blob_type("application/zip")
|
||||
|
||||
|
||||
class SmartScanThing(Thing):
|
||||
def __init__(self, path_to_openflexure_stitch: str):
|
||||
self._script = path_to_openflexure_stitch
|
||||
|
|
@ -352,12 +383,13 @@ class SmartScanThing(Thing):
|
|||
return "scans"
|
||||
|
||||
_latest_scan_name = None
|
||||
|
||||
@thing_property
|
||||
def latest_scan_name(self) -> Optional[str]:
|
||||
"""The name of the last scan to be started."""
|
||||
return self._latest_scan_name
|
||||
|
||||
def scan_folder_path(self, scan_name: Optional[str]=None):
|
||||
def scan_folder_path(self, scan_name: Optional[str] = None):
|
||||
"""The path to the scan folder with a given name"""
|
||||
if not scan_name:
|
||||
if not self.latest_scan_name:
|
||||
|
|
@ -365,7 +397,7 @@ class SmartScanThing(Thing):
|
|||
scan_name = self.latest_scan_name
|
||||
return os.path.join(self.scans_folder_path, scan_name)
|
||||
|
||||
def new_scan_folder(self, scan_name: str="scan") -> str:
|
||||
def new_scan_folder(self, scan_name: str = "scan") -> str:
|
||||
"""Create a new empty folder, into which we can save scan images
|
||||
|
||||
The folder will be named `{scan_name}_000001/` where the number is
|
||||
|
|
@ -389,14 +421,13 @@ class SmartScanThing(Thing):
|
|||
return folder_path
|
||||
raise FileExistsError("Could not create a new scan folder: all names in use!")
|
||||
|
||||
|
||||
def move_to_next_point(
|
||||
self,
|
||||
stage: StageDep,
|
||||
logger: InvocationLogger,
|
||||
path: list[list[int]],
|
||||
focused_path: list[list[int]],
|
||||
) -> list[int]:
|
||||
self,
|
||||
stage: StageDep,
|
||||
logger: InvocationLogger,
|
||||
path: list[list[int]],
|
||||
focused_path: list[list[int]],
|
||||
) -> list[int]:
|
||||
"""Remove the first point from the path, and move there.
|
||||
|
||||
This will move to the next XY position in `path`, taking the `z` value
|
||||
|
|
@ -412,9 +443,7 @@ class SmartScanThing(Thing):
|
|||
else:
|
||||
z = stage.position["z"]
|
||||
logger.info(f"Moving to {loc}")
|
||||
stage.move_absolute(
|
||||
x=int(loc[0]), y=int(loc[1]), z = z - self.autofocus_dz / 2
|
||||
)
|
||||
stage.move_absolute(x=int(loc[0]), y=int(loc[1]), z=z - self.autofocus_dz / 2)
|
||||
return loc + [z]
|
||||
|
||||
@thing_action
|
||||
|
|
@ -429,7 +458,7 @@ class SmartScanThing(Thing):
|
|||
csm: CSMDep,
|
||||
background_detect: BackgroundDep,
|
||||
recentre: RecentreStage,
|
||||
scan_name: str="",
|
||||
scan_name: str = "",
|
||||
):
|
||||
"""Move the stage to cover an area, taking images that can be tiled together.
|
||||
|
||||
|
|
@ -455,14 +484,18 @@ class SmartScanThing(Thing):
|
|||
max_dist = self.max_range
|
||||
|
||||
if self.autofocus_dz == 0:
|
||||
logger.info('Running scan without autofocus')
|
||||
logger.info("Running scan without autofocus")
|
||||
elif self.autofocus_dz <= 200:
|
||||
logger.warning(f'Your dz range is {self.autofocus_dz} steps, which is too short to attempt to focus. Running without autofocus')
|
||||
logger.warning(
|
||||
f"Your dz range is {self.autofocus_dz} steps, which is too short to attempt to focus. Running without autofocus"
|
||||
)
|
||||
|
||||
if self.skip_background:
|
||||
d = background_detect.background_distributions
|
||||
if not d:
|
||||
raise RuntimeError("Background is not set: you need to calibrate background detection.")
|
||||
raise RuntimeError(
|
||||
"Background is not set: you need to calibrate background detection."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"This scan will run in a spiral from the starting point "
|
||||
|
|
@ -470,7 +503,7 @@ class SmartScanThing(Thing):
|
|||
"in every direction. Make sure you watch it run to stop it leaving "
|
||||
"the area of interest, or (worse) leading the microscope's range "
|
||||
"of motion."
|
||||
)
|
||||
)
|
||||
names = []
|
||||
positions = []
|
||||
|
||||
|
|
@ -495,14 +528,20 @@ class SmartScanThing(Thing):
|
|||
# TODO: generalise to have 2D displacements for x and y (as the
|
||||
# camera and stage may not be aligned).
|
||||
CSM = csm.image_to_stage_displacement_matrix
|
||||
#csm_calibration_width = csm.last_calibration["image_resolution"][1]
|
||||
# csm_calibration_width = csm.last_calibration["image_resolution"][1]
|
||||
|
||||
overlap = self.overlap
|
||||
|
||||
dx = int(np.abs(np.dot(np.array([0, arr.shape[1] * (1 - overlap)]), CSM)[0]))
|
||||
dy = int(np.abs(np.dot(np.array([arr.shape[0] * (1 - overlap), 0]), CSM)[1]))
|
||||
dx = int(
|
||||
np.abs(np.dot(np.array([0, arr.shape[1] * (1 - overlap)]), CSM)[0])
|
||||
)
|
||||
dy = int(
|
||||
np.abs(np.dot(np.array([arr.shape[0] * (1 - overlap), 0]), CSM)[1])
|
||||
)
|
||||
|
||||
logger.info(f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}")
|
||||
logger.info(
|
||||
f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}"
|
||||
)
|
||||
|
||||
# construct a 2D scan path
|
||||
path = [[stage.position["x"], stage.position["y"]]]
|
||||
|
|
@ -520,16 +559,18 @@ class SmartScanThing(Thing):
|
|||
logger.info(f"Saving images to {images_folder}")
|
||||
|
||||
data = {
|
||||
'scan_name' : scan_name,
|
||||
'overlap' : overlap,
|
||||
'autofocus range' : self.autofocus_dz,
|
||||
'dx' : dx,
|
||||
'dy' : dy,
|
||||
'start time' : start_time,
|
||||
'skipping background' : self.skip_background
|
||||
"scan_name": scan_name,
|
||||
"overlap": overlap,
|
||||
"autofocus range": self.autofocus_dz,
|
||||
"dx": dx,
|
||||
"dy": dy,
|
||||
"start time": start_time,
|
||||
"skipping background": self.skip_background,
|
||||
}
|
||||
|
||||
with open(os.path.join(images_folder, 'scan_inputs.json'), 'w', encoding='utf-8') as f:
|
||||
with open(
|
||||
os.path.join(images_folder, "scan_inputs.json"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=4)
|
||||
|
||||
# We will capture images and process them with this function, defined once here.
|
||||
|
|
@ -537,29 +578,41 @@ class SmartScanThing(Thing):
|
|||
# that change each iteration.
|
||||
# We also pre-calculate a normalisation image based on the LST and white balance
|
||||
raw_image = cam.capture_array(stream_name="raw")
|
||||
#TODO: assert the image is 10-bit packed, or deal with other formats!
|
||||
# TODO: assert the image is 10-bit packed, or deal with other formats!
|
||||
rgb = rggb2rgb(raw2rggb(raw_image))
|
||||
lst = dict(cam.lens_shading_tables)
|
||||
lum = np.array(lst["luminance"])
|
||||
Cr = np.array(lst["Cr"])
|
||||
Cb = np.array(lst["Cb"])
|
||||
gr, gb = cam.colour_gains
|
||||
G = 1/lum
|
||||
R = G/Cr/gr*np.min(Cr) # The extra /np.max(Cr) emulates the quirky handling of Cr in
|
||||
B = G/Cb/gb*np.min(Cb) # the picamera2 pipeline
|
||||
G = 1 / lum
|
||||
R = (
|
||||
G / Cr / gr * np.min(Cr)
|
||||
) # The extra /np.max(Cr) emulates the quirky handling of Cr in
|
||||
B = G / Cb / gb * np.min(Cb) # the picamera2 pipeline
|
||||
white_norm_lores = np.stack([R, G, B], axis=2)
|
||||
zoom_factors = [i/n for i, n in zip(rgb[...,:3].shape, white_norm_lores.shape)]
|
||||
white_norm = zoom(white_norm_lores, zoom_factors, order=1)[:rgb.shape[0], :rgb.shape[1], :] # Could use some work
|
||||
colour_correction_matrix = np.array(cam.colour_correction_matrix).reshape((3,3))
|
||||
zoom_factors = [
|
||||
i / n for i, n in zip(rgb[..., :3].shape, white_norm_lores.shape)
|
||||
]
|
||||
white_norm = zoom(white_norm_lores, zoom_factors, order=1)[
|
||||
: rgb.shape[0], : rgb.shape[1], :
|
||||
] # Could use some work
|
||||
colour_correction_matrix = np.array(cam.colour_correction_matrix).reshape(
|
||||
(3, 3)
|
||||
)
|
||||
contrast_algorithm = cam.tuning["algorithms"][9]["rpi.contrast"]
|
||||
gamma = np.array(contrast_algorithm["gamma_curve"]).reshape((-1,2))
|
||||
gamma_8bit = interp1d(gamma[:, 0]/255, gamma[:, 1]/255)
|
||||
gamma = np.array(contrast_algorithm["gamma_curve"]).reshape((-1, 2))
|
||||
gamma_8bit = interp1d(gamma[:, 0] / 255, gamma[:, 1] / 255)
|
||||
|
||||
def process_raw_image(img):
|
||||
normed = img/white_norm
|
||||
corrected = np.dot(colour_correction_matrix, normed.reshape((-1, 3)).T).T.reshape(normed.shape)
|
||||
normed = img / white_norm
|
||||
corrected = np.dot(
|
||||
colour_correction_matrix, normed.reshape((-1, 3)).T
|
||||
).T.reshape(normed.shape)
|
||||
corrected[corrected < 0] = 0
|
||||
corrected[corrected > 255] = 255
|
||||
return gamma_8bit(corrected)
|
||||
|
||||
logger.info(
|
||||
f"Generated normalisation image with shape {white_norm.shape}, "
|
||||
f"max {white_norm.max(axis=(0,1))}, min {white_norm.min(axis=(0,1))}"
|
||||
|
|
@ -571,6 +624,7 @@ class SmartScanThing(Thing):
|
|||
"gain_red": gr,
|
||||
"gain_blue": gb,
|
||||
}
|
||||
|
||||
def capture_and_save(acquired: Event, name: str) -> None:
|
||||
"""Capture an image and save it to disk
|
||||
|
||||
|
|
@ -584,32 +638,42 @@ class SmartScanThing(Thing):
|
|||
acquired.set()
|
||||
acquisition_time = time.time()
|
||||
# Save the raw image
|
||||
np.savez(os.path.join(raw_images_folder, name + ".npz"), raw_image=raw_image, **norm_inputs)
|
||||
np.savez(
|
||||
os.path.join(raw_images_folder, name + ".npz"),
|
||||
raw_image=raw_image,
|
||||
**norm_inputs,
|
||||
)
|
||||
# Process it into 8 bit RGB
|
||||
processed = process_raw_image(rggb2rgb(raw2rggb(raw_image)))
|
||||
processed[processed > 255] = 255
|
||||
processed[processed < 0] = 0
|
||||
img = Image.fromarray(processed.astype(np.uint8), mode="RGB")
|
||||
img.save(
|
||||
os.path.join(images_folder, name),
|
||||
quality=95,
|
||||
subsampling=0
|
||||
os.path.join(images_folder, name), quality=95, subsampling=0
|
||||
)
|
||||
exif_dict = piexif.load(os.path.join(images_folder, name))
|
||||
exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps(
|
||||
metadata
|
||||
).encode("utf-8")
|
||||
piexif.insert(piexif.dump(exif_dict), os.path.join(images_folder, name))
|
||||
piexif.insert(
|
||||
piexif.dump(exif_dict), os.path.join(images_folder, name)
|
||||
)
|
||||
save_time = time.time()
|
||||
logger.info(f"Acquired {name} in {acquisition_time-capture_start:.1f}s then {save_time-acquisition_time:.1f}s saving to disk")
|
||||
logger.info(
|
||||
f"Acquired {name} in {acquisition_time-capture_start:.1f}s then {save_time-acquisition_time:.1f}s saving to disk"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred while saving {name}: {e}", exc_info=e)
|
||||
logger.error(
|
||||
f"An error occurred while saving {name}: {e}", exc_info=e
|
||||
)
|
||||
|
||||
# At the start of the loop, we simultaneously capture an image and move to the next scan point.
|
||||
# We skip capturing on the first run, because we've not focused yet - and also we skip capturing if
|
||||
# it looks like background.
|
||||
while len(path) > 0:
|
||||
loc = self.move_to_next_point(stage, logger, path=path, focused_path=focused_path)
|
||||
loc = self.move_to_next_point(
|
||||
stage, logger, path=path, focused_path=focused_path
|
||||
)
|
||||
if not self.preview_stitch_running():
|
||||
self.preview_stitch_start(images_folder)
|
||||
if self.stitch_automatically:
|
||||
|
|
@ -626,7 +690,9 @@ class SmartScanThing(Thing):
|
|||
|
||||
# if more than 92% of the image is background, treat it as background and continue
|
||||
if not image_is_sample:
|
||||
logger.info(f"Skipping {stage.position} as it is {round(background_detect.background_fraction(),0)}% background.")
|
||||
logger.info(
|
||||
f"Skipping {stage.position} as it is {round(background_detect.background_fraction(),0)}% background."
|
||||
)
|
||||
else:
|
||||
# if not, it's sample. run an autofocus and use the updated height
|
||||
new_pos = [
|
||||
|
|
@ -645,17 +711,25 @@ class SmartScanThing(Thing):
|
|||
attempts = 0
|
||||
if self.autofocus_dz > 200:
|
||||
while True:
|
||||
jpeg_zs, jpeg_sizes = autofocus.looping_autofocus(dz=self.autofocus_dz, start = 'base')
|
||||
jpeg_zs, jpeg_sizes = autofocus.looping_autofocus(
|
||||
dz=self.autofocus_dz, start="base"
|
||||
)
|
||||
current_height = stage.position["z"]
|
||||
time.sleep(0.2)
|
||||
autofocus_success = autofocus.verify_focus_sharpness(sweep_sizes = jpeg_sizes, camera = CamDep, threshold = 0.92)
|
||||
logger.info(f"We just tested the focus! Result was {autofocus_success}")
|
||||
autofocus_success = autofocus.verify_focus_sharpness(
|
||||
sweep_sizes=jpeg_sizes, camera=CamDep, threshold=0.92
|
||||
)
|
||||
logger.info(
|
||||
f"We just tested the focus! Result was {autofocus_success}"
|
||||
)
|
||||
|
||||
if autofocus_success:
|
||||
# if there have been successful autofocuses in this scan, find the closest one in x-y
|
||||
# test if the change in z between them exceeds a ratio (indicating a failed autofocus)
|
||||
if len(focused_path) > 0:
|
||||
nearest_focused_site = focused_path[closest(loc, focused_path)]
|
||||
nearest_focused_site = focused_path[
|
||||
closest(loc, focused_path)
|
||||
]
|
||||
result = limit_focus_change(
|
||||
nearest_focused_site[0:2],
|
||||
nearest_focused_site[-1],
|
||||
|
|
@ -691,31 +765,33 @@ class SmartScanThing(Thing):
|
|||
wait_start = time.time()
|
||||
capture_thread.join()
|
||||
wait_time = time.time() - wait_start
|
||||
logger.info(f"Waited {wait_time:.1f}s for the previous capture to finish saving.")
|
||||
logger.info(
|
||||
f"Waited {wait_time:.1f}s for the previous capture to finish saving."
|
||||
)
|
||||
acquired = Event()
|
||||
name = f"image_{loc[0]}_{loc[1]}.jpg"
|
||||
time.sleep(0.2)
|
||||
capture_thread = Thread(
|
||||
target=capture_and_save,
|
||||
kwargs={
|
||||
# "cam": cam,
|
||||
# "logger": logger,
|
||||
# "cam": cam,
|
||||
# "logger": logger,
|
||||
"acquired": acquired,
|
||||
"name": name,
|
||||
# "images_folder": images_folder,
|
||||
# "raw_images_folder": raw_images_folder,
|
||||
}
|
||||
# "images_folder": images_folder,
|
||||
# "raw_images_folder": raw_images_folder,
|
||||
},
|
||||
)
|
||||
capture_thread.start()
|
||||
acquired.wait() # wait until the image is acquired
|
||||
#time.sleep(0.5)
|
||||
# time.sleep(0.5)
|
||||
positions.append(loc[:2])
|
||||
names.append(name)
|
||||
|
||||
# add the current position to the list of all positions visited
|
||||
true_path.append(loc)
|
||||
|
||||
#if len(names) > 1:
|
||||
# if len(names) > 1:
|
||||
# generate_config(images_folder, positions, names, CSM, csm_calibration_width, img_width, logger)
|
||||
|
||||
temp_path = []
|
||||
|
|
@ -724,10 +800,20 @@ class SmartScanThing(Thing):
|
|||
if distance_to_site(i, true_path[0][:2]) < max_dist:
|
||||
temp_path.append(i)
|
||||
else:
|
||||
logger.info(f'Rejected moving to {i} as it is out of range')
|
||||
logger.info(f"Rejected moving to {i} as it is out of range")
|
||||
path = temp_path.copy()
|
||||
path = sorted(path, key=lambda x: (steps_from_centre(x, true_path[0][:2], dx, dy), distance_to_site(loc[:2], x)))
|
||||
self.create_zip_of_scan(logger = logger, scan_name = scan_folder.split('scans/')[1], download_zip = False)
|
||||
path = sorted(
|
||||
path,
|
||||
key=lambda x: (
|
||||
steps_from_centre(x, true_path[0][:2], dx, dy),
|
||||
distance_to_site(loc[:2], x),
|
||||
),
|
||||
)
|
||||
self.create_zip_of_scan(
|
||||
logger=logger,
|
||||
scan_name=scan_folder.split("scans/")[1],
|
||||
download_zip=False,
|
||||
)
|
||||
|
||||
except InvocationCancelledError:
|
||||
logger.error("Stopping scan because it was cancelled.")
|
||||
|
|
@ -740,8 +826,7 @@ class SmartScanThing(Thing):
|
|||
except Exception as e:
|
||||
logger.error(
|
||||
f"The scan stopped because of an error: {e}",
|
||||
"We will attempt to stitch and archive the images acquired "
|
||||
"so far.",
|
||||
"We will attempt to stitch and archive the images acquired " "so far.",
|
||||
exc_info=e,
|
||||
)
|
||||
raise e
|
||||
|
|
@ -754,14 +839,20 @@ class SmartScanThing(Thing):
|
|||
stage.move_absolute(**starting_position, block_cancellation=True)
|
||||
finally:
|
||||
self._scan_lock.release()
|
||||
self.create_zip_of_scan(logger = logger, scan_name = scan_folder.split('scans/')[1], download_zip = False)
|
||||
self.create_zip_of_scan(
|
||||
logger=logger,
|
||||
scan_name=scan_folder.split("scans/")[1],
|
||||
download_zip=False,
|
||||
)
|
||||
logger.info("Waiting for background processes to finish...")
|
||||
self.preview_stitch_wait()
|
||||
self.correlate_wait()
|
||||
try:
|
||||
if scan_folder and self.stitch_automatically:
|
||||
logger.info("Stitching final image (may take some time)...")
|
||||
self.stitch_scan(logger, os.path.basename(scan_folder), overlap=overlap)
|
||||
self.stitch_scan(
|
||||
logger, os.path.basename(scan_folder), overlap=overlap
|
||||
)
|
||||
except SubprocessError as e:
|
||||
logger.error(f"Stitching failed: {e}", exc_info=e)
|
||||
|
||||
|
|
@ -845,26 +936,26 @@ class SmartScanThing(Thing):
|
|||
number_of_images = 0
|
||||
scans.append(
|
||||
ScanInfo(
|
||||
name = f,
|
||||
created = os.path.getctime(path),
|
||||
modified = os.path.getmtime(path),
|
||||
number_of_images = number_of_images,
|
||||
name=f,
|
||||
created=os.path.getctime(path),
|
||||
modified=os.path.getmtime(path),
|
||||
number_of_images=number_of_images,
|
||||
)
|
||||
)
|
||||
return scans
|
||||
|
||||
@fastapi_endpoint(
|
||||
"get",
|
||||
"scans/{scan_name}/{file}",
|
||||
responses = {
|
||||
200: {
|
||||
"description": "Successfully downloading file",
|
||||
"content": {"*/*": {}}
|
||||
},
|
||||
403: {"description": "Filename not permitted"},
|
||||
404: {"description": "File not found"}
|
||||
"get",
|
||||
"scans/{scan_name}/{file}",
|
||||
responses={
|
||||
200: {
|
||||
"description": "Successfully downloading file",
|
||||
"content": {"*/*": {}},
|
||||
},
|
||||
)
|
||||
403: {"description": "Filename not permitted"},
|
||||
404: {"description": "File not found"},
|
||||
},
|
||||
)
|
||||
def get_scan_file(self, scan_name: str, file: str) -> FileResponse:
|
||||
"""Retrieve a file from a scan.
|
||||
|
||||
|
|
@ -884,7 +975,7 @@ class SmartScanThing(Thing):
|
|||
@fastapi_endpoint(
|
||||
"delete",
|
||||
"scans/{scan_name}",
|
||||
responses = {
|
||||
responses={
|
||||
200: {"description": "Successfully deleted scan"},
|
||||
404: {"description": "Scan not found"},
|
||||
},
|
||||
|
|
@ -914,7 +1005,7 @@ class SmartScanThing(Thing):
|
|||
for scan in self.scans:
|
||||
self.delete_scan(scan.name)
|
||||
|
||||
def images_folder(self, scan_name: Optional[str]=None) -> str:
|
||||
def images_folder(self, scan_name: Optional[str] = None) -> str:
|
||||
scan_folder = self.scan_folder_path(scan_name=scan_name)
|
||||
return os.path.join(scan_folder, "images")
|
||||
|
||||
|
|
@ -938,25 +1029,25 @@ class SmartScanThing(Thing):
|
|||
return None
|
||||
|
||||
@fastapi_endpoint(
|
||||
"get",
|
||||
"latest_preview_stitch.jpg",
|
||||
responses = {
|
||||
200: {
|
||||
"description": "A preview-quality stitched image",
|
||||
"content": {"image/jpeg": {}}
|
||||
},
|
||||
404: {"description": "File not found"}
|
||||
"get",
|
||||
"latest_preview_stitch.jpg",
|
||||
responses={
|
||||
200: {
|
||||
"description": "A preview-quality stitched image",
|
||||
"content": {"image/jpeg": {}},
|
||||
},
|
||||
)
|
||||
404: {"description": "File not found"},
|
||||
},
|
||||
)
|
||||
def get_latest_preview(self) -> FileResponse:
|
||||
"""Retrieve the latest preview image.
|
||||
"""
|
||||
"""Retrieve the latest preview image."""
|
||||
path = self.latest_preview_stitch_path
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(404, "File not found")
|
||||
return FileResponse(path)
|
||||
|
||||
_preview_stitch_popen = None
|
||||
|
||||
def preview_stitch_start(self, images_folder: str) -> None:
|
||||
"""Start stitching a preview of the scan in a subprocess"""
|
||||
if self.preview_stitch_running():
|
||||
|
|
@ -981,13 +1072,21 @@ class SmartScanThing(Thing):
|
|||
self._preview_stitch_popen.wait()
|
||||
|
||||
_correlate_popen = None
|
||||
|
||||
def correlate_start(self, images_folder: str, overlap: float = 0.1) -> None:
|
||||
"""Start stitching a preview of the scan in a subprocess"""
|
||||
if self.correlate_running():
|
||||
raise RuntimeError("Only one subprocess is allowed at a time")
|
||||
with self._correlate_popen_lock:
|
||||
self._correlate_popen = Popen(
|
||||
[self._script, "--stitching_mode", "only_correlate", "--minimum_overlap", f"{round(overlap*0.9, 2)}", images_folder]
|
||||
[
|
||||
self._script,
|
||||
"--stitching_mode",
|
||||
"only_correlate",
|
||||
"--minimum_overlap",
|
||||
f"{round(overlap*0.9, 2)}",
|
||||
images_folder,
|
||||
]
|
||||
)
|
||||
|
||||
def correlate_running(self) -> bool:
|
||||
|
|
@ -1005,15 +1104,16 @@ class SmartScanThing(Thing):
|
|||
self._correlate_popen.wait()
|
||||
|
||||
def run_subprocess(
|
||||
self, logger: InvocationLogger, cmd: list[str],
|
||||
) -> CompletedProcess:
|
||||
self,
|
||||
logger: InvocationLogger,
|
||||
cmd: list[str],
|
||||
) -> CompletedProcess:
|
||||
"""Run a subprocess and log any output"""
|
||||
logger.info(f"Running command in subprocess: `{' '.join(cmd)}`")
|
||||
|
||||
|
||||
p = Popen(cmd, stdout=PIPE, stderr = STDOUT, bufsize=1, universal_newlines=True)
|
||||
p = Popen(cmd, stdout=PIPE, stderr=STDOUT, bufsize=1, universal_newlines=True)
|
||||
os.set_blocking(p.stdout.fileno(), False)
|
||||
logger.info(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())))
|
||||
logger.info(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())))
|
||||
while p.poll() is None:
|
||||
try:
|
||||
output = p.stdout.readline()
|
||||
|
|
@ -1030,41 +1130,67 @@ class SmartScanThing(Thing):
|
|||
except:
|
||||
pass
|
||||
|
||||
logger.info('Stitching complete')
|
||||
logger.info("Stitching complete")
|
||||
return p
|
||||
|
||||
@thing_action
|
||||
def stitch_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None, overlap: float = 0.0) -> None:
|
||||
def stitch_scan(
|
||||
self,
|
||||
logger: InvocationLogger,
|
||||
scan_name: Optional[str] = None,
|
||||
overlap: float = 0.0,
|
||||
) -> None:
|
||||
"""Generate a stitched image based on stage position metadata"""
|
||||
images_folder = self.images_folder(scan_name=scan_name)
|
||||
|
||||
if self.stitch_tiff:
|
||||
tiff_arg = '--stitch_tiff'
|
||||
tiff_arg = "--stitch_tiff"
|
||||
else:
|
||||
tiff_arg = '--no-stitch_tiff'
|
||||
tiff_arg = "--no-stitch_tiff"
|
||||
|
||||
if overlap == 0.0:
|
||||
try:
|
||||
with open(os.path.join(images_folder, 'scan_inputs.json')) as data_file:
|
||||
with open(os.path.join(images_folder, "scan_inputs.json")) as data_file:
|
||||
data_loaded = json.load(data_file)
|
||||
logger.info(data_loaded)
|
||||
overlap = data_loaded['overlap']
|
||||
overlap = data_loaded["overlap"]
|
||||
except:
|
||||
overlap = 0.1
|
||||
self.run_subprocess(logger, [self._script, "--stitching_mode", "all", f"{tiff_arg}", "--minimum_overlap", f"{round(overlap*0.9,2)}", images_folder])
|
||||
self.run_subprocess(
|
||||
logger,
|
||||
[
|
||||
self._script,
|
||||
"--stitching_mode",
|
||||
"all",
|
||||
f"{tiff_arg}",
|
||||
"--minimum_overlap",
|
||||
f"{round(overlap*0.9,2)}",
|
||||
images_folder,
|
||||
],
|
||||
)
|
||||
|
||||
@thing_action
|
||||
def create_zip_of_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None, download_zip = True) -> ZipBlob:
|
||||
def create_zip_of_scan(
|
||||
self,
|
||||
logger: InvocationLogger,
|
||||
scan_name: Optional[str] = None,
|
||||
download_zip=True,
|
||||
) -> ZipBlob:
|
||||
"""Generate a zip file that can be downloaded, with all the scan files in it."""
|
||||
images_folder = self.images_folder(scan_name=scan_name)
|
||||
scan_folder = self.scan_folder_path(scan_name=scan_name)
|
||||
if scan_folder != os.path.dirname(images_folder) or os.path.basename(images_folder) != "images":
|
||||
if (
|
||||
scan_folder != os.path.dirname(images_folder)
|
||||
or os.path.basename(images_folder) != "images"
|
||||
):
|
||||
logger.error(
|
||||
"There is a problem with filenames, the archive may be incorrect."
|
||||
f"scan_folder: {scan_folder}, images_folder: {images_folder}."
|
||||
)
|
||||
if not os.path.isdir(images_folder):
|
||||
raise FileNotFoundError(f"Tried to make a zip archive of {images_folder} but it does not exist.")
|
||||
raise FileNotFoundError(
|
||||
f"Tried to make a zip archive of {images_folder} but it does not exist."
|
||||
)
|
||||
# logger.info("Creating zip archive of images (may take some time)...")
|
||||
|
||||
zip_fname = f'{os.path.join(scan_folder, "images")}.zip'
|
||||
|
|
@ -1081,20 +1207,26 @@ class SmartScanThing(Thing):
|
|||
|
||||
# get a list of files in the folder we're zipping
|
||||
folder_path = self.scan_folder_path(scan_name)
|
||||
files = glob.glob(folder_path + '/**/*', recursive=True)
|
||||
files = [i.split(f'{folder_path}/')[1] for i in files]
|
||||
files = glob.glob(folder_path + "/**/*", recursive=True)
|
||||
files = [i.split(f"{folder_path}/")[1] for i in files]
|
||||
|
||||
# This is a list of file names that are updated as the scan goes,
|
||||
# and should only be zipped at the end of the scan - otherwise they'll
|
||||
# be appended on every loop as we can't overwrite files in the zip
|
||||
files_to_delay = ['TileConfiguration', 'tiling_cache', 'stitched.jp', 'stitched_from', 'stitched.om']
|
||||
files_to_delay = [
|
||||
"TileConfiguration",
|
||||
"tiling_cache",
|
||||
"stitched.jp",
|
||||
"stitched_from",
|
||||
"stitched.om",
|
||||
]
|
||||
tiff_name = ""
|
||||
|
||||
with zipfile.ZipFile(zip_fname, mode="a") as zip:
|
||||
for file in files:
|
||||
if 'stitched.jp' in file:
|
||||
if "stitched.jp" in file:
|
||||
stitch_name = os.path.split(file)[1]
|
||||
if '.ome.tiff' in file:
|
||||
if ".ome.tiff" in file:
|
||||
tiff_name = os.path.split(file)[1]
|
||||
if any(banned_name in file for banned_name in files_to_delay):
|
||||
# logger.info(f'we only add {file} into zip at the end of the scan')
|
||||
|
|
@ -1102,15 +1234,14 @@ class SmartScanThing(Thing):
|
|||
elif file in current_zip:
|
||||
# logger.info(f'{file} is already in zip')
|
||||
pass
|
||||
elif ".zip" in file or 'raw' in file:
|
||||
elif ".zip" in file or "raw" in file:
|
||||
# logger.info('Not adding the .zip to itself')
|
||||
pass
|
||||
else:
|
||||
logger.info(f'appending {file} to zip')
|
||||
logger.info(f"appending {file} to zip")
|
||||
zip.write(os.path.join(folder_path, file), arcname=file)
|
||||
|
||||
|
||||
images_folder = os.path.join(folder_path, 'images')
|
||||
images_folder = os.path.join(folder_path, "images")
|
||||
# Promote key files to the top level of the zip only at the end of the scan (when downloading)
|
||||
# and finally zip some of the final files
|
||||
# TODO: if you download multiple times, you get duplicate files - is this a problem?
|
||||
|
|
@ -1119,13 +1250,13 @@ class SmartScanThing(Thing):
|
|||
for fname in ["stitched_from_stage.jpg", stitch_name, tiff_name]:
|
||||
fpath = os.path.join(images_folder, fname)
|
||||
if os.path.exists(fpath):
|
||||
logger.info(f'copying {fpath} to upper level')
|
||||
logger.info(f"copying {fpath} to upper level")
|
||||
zip.write(fpath, arcname=fname)
|
||||
for file in files:
|
||||
if any(banned_name in file for banned_name in files_to_delay):
|
||||
logger.info(f'we are finally adding {file} into zip')
|
||||
logger.info(f"we are finally adding {file} into zip")
|
||||
zip.write(os.path.join(folder_path, file), arcname=file)
|
||||
logger.info('about to download zip')
|
||||
logger.info("about to download zip")
|
||||
return ZipBlob.from_file(zip_fname)
|
||||
|
||||
@thing_action
|
||||
|
|
@ -1135,4 +1266,3 @@ class SmartScanThing(Thing):
|
|||
zip = [os.path.normpath(i) for i in zip.namelist()]
|
||||
|
||||
return zip
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ from labthings_fastapi.dependencies.invocation import CancelHook
|
|||
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
|
||||
from collections.abc import Sequence, Mapping
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StageProtocol(Protocol):
|
||||
"""A protocol for the OpenFlexure translation stage
|
||||
"""
|
||||
"""A protocol for the OpenFlexure translation stage"""
|
||||
|
||||
_axis_names: Sequence[str]
|
||||
|
||||
@property
|
||||
|
|
@ -33,11 +34,21 @@ class StageProtocol(Protocol):
|
|||
"""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."""
|
||||
...
|
||||
|
||||
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."""
|
||||
...
|
||||
|
||||
|
|
@ -59,6 +70,7 @@ class BaseStage(Thing):
|
|||
`move_relative` and `move_absolute` actions, which update the
|
||||
`position` property on completion, and provide `set_zero_position`.
|
||||
"""
|
||||
|
||||
_axis_names = ("x", "y", "z")
|
||||
|
||||
@thing_property
|
||||
|
|
@ -85,9 +97,7 @@ class BaseStage(Thing):
|
|||
@property
|
||||
def thing_state(self):
|
||||
"""Summary metadata describing the current state of the stage"""
|
||||
return {
|
||||
"position": self.position
|
||||
}
|
||||
return {"position": self.position}
|
||||
|
||||
|
||||
class StageStub(BaseStage):
|
||||
|
|
@ -98,13 +108,24 @@ class StageStub(BaseStage):
|
|||
methods/properties are not decorated as Affordances. This stub class
|
||||
is a workaround for that limitation, and should not be used directly.
|
||||
"""
|
||||
|
||||
@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."""
|
||||
raise NotImplementedError("StageStub should not be used directly")
|
||||
|
||||
@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."""
|
||||
raise NotImplementedError("StageStub should not be used directly")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from __future__ import annotations
|
||||
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
|
||||
import time
|
||||
|
||||
|
|
@ -13,7 +16,8 @@ class DummyStage(BaseStage):
|
|||
This stage should work similarly to a Sangaboard stage, but without any
|
||||
hardware attached.
|
||||
"""
|
||||
def __init__(self, step_time: float=0.001, **kwargs):
|
||||
|
||||
def __init__(self, step_time: float = 0.001, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.step_time = step_time
|
||||
|
||||
|
|
@ -24,7 +28,12 @@ class DummyStage(BaseStage):
|
|||
pass
|
||||
|
||||
@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."""
|
||||
displacement = [kwargs.get(k, 0) for k in self.axis_names]
|
||||
self.moving = True
|
||||
|
|
@ -50,7 +59,7 @@ class DummyStage(BaseStage):
|
|||
# and to mark the invocation as "cancelled" rather than stopped.
|
||||
raise e
|
||||
finally:
|
||||
self.moving=False
|
||||
self.moving = False
|
||||
self.position = {
|
||||
k: self.position[k] + int(fraction_complete * v)
|
||||
for k, v in zip(self.axis_names, displacement)
|
||||
|
|
@ -58,14 +67,21 @@ class DummyStage(BaseStage):
|
|||
self.instantaneous_position = self.position
|
||||
|
||||
@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."""
|
||||
displacement = {
|
||||
k: int(v) - self.position[k]
|
||||
for k, v in kwargs.items()
|
||||
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
|
||||
def set_zero_position(self):
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ class Stitcher(Thing):
|
|||
self._script = path_to_openflexure_stitch
|
||||
|
||||
def run_subprocess(
|
||||
self, logger: InvocationLogger, cmd: list[str],
|
||||
) -> CompletedProcess:
|
||||
self,
|
||||
logger: InvocationLogger,
|
||||
cmd: list[str],
|
||||
) -> CompletedProcess:
|
||||
"""Run a subprocess and log any output"""
|
||||
logger.info(f"Running command in subprocess: `{' '.join(cmd)}")
|
||||
output = run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True)
|
||||
|
|
@ -32,12 +34,19 @@ class Stitcher(Thing):
|
|||
output.check_returncode()
|
||||
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)
|
||||
return os.path.join(scan_folder, "images")
|
||||
|
||||
@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
|
||||
|
||||
If there are no images (files with the prefix and suffix) newer than the
|
||||
|
|
@ -61,24 +70,48 @@ class Stitcher(Thing):
|
|||
return True
|
||||
|
||||
@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"""
|
||||
output_fname = "stitched_from_stage.jpg"
|
||||
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
|
||||
if self.output_up_to_date(images_folder, output_fname):
|
||||
logger.info(f"No images are newer than {output_fname}, skipping.")
|
||||
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))
|
||||
|
||||
@thing_action
|
||||
def update_scan_correlations(self, logger: InvocationLogger, smart_scan: SmartScanDep, scan_name: Optional[str]=None) -> 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"""
|
||||
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])
|
||||
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:
|
||||
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])
|
||||
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.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import os
|
||||
from labthings_fastapi.thing import Thing
|
||||
from labthings_fastapi.decorators import thing_action, thing_property
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CommandOutput(BaseModel):
|
||||
output: str
|
||||
error: str
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ OpenFlexure Microscope API test Thing
|
|||
|
||||
This Thing is intended only for use testing out the API and client(s).
|
||||
"""
|
||||
|
||||
from labthings_fastapi.thing import Thing
|
||||
from labthings_fastapi.decorators import thing_action, thing_property
|
||||
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 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
|
||||
def thing_server():
|
||||
temp_folder = tempfile.TemporaryDirectory()
|
||||
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(AutofocusThing(), "/autofocus/")
|
||||
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
|
||||
yield server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(thing_server):
|
||||
with TestClient(thing_server.app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slower_client(thing_server):
|
||||
thing_server.things["/stage/"].step_time = 0.0001
|
||||
with TestClient(thing_server.app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def test_autofocus(slower_client):
|
||||
client = slower_client
|
||||
autofocus = ThingClient.from_url("/autofocus/", client)
|
||||
_ = autofocus.fast_autofocus()
|
||||
|
||||
|
||||
def test_grab_jpeg(client):
|
||||
camera = ThingClient.from_url("/camera/", client)
|
||||
blob = camera.grab_jpeg()
|
||||
_image = Image.open(blob.open())
|
||||
|
||||
|
||||
def test_capture_jpeg_metadata(client):
|
||||
camera = ThingClient.from_url("/camera/", client)
|
||||
blob = camera.capture_jpeg()
|
||||
|
|
@ -60,6 +71,7 @@ def test_capture_jpeg_metadata(client):
|
|||
metadata = json.loads(encoded_metadata)
|
||||
assert "position" in metadata["/stage/"]
|
||||
|
||||
|
||||
def test_stage(client):
|
||||
stage = ThingClient.from_url("/stage/", client)
|
||||
start = stage.position
|
||||
|
|
@ -73,11 +85,13 @@ def test_stage(client):
|
|||
for s, p in zip(start.values(), pos.values()):
|
||||
assert s == p
|
||||
|
||||
|
||||
def test_capture_array(client):
|
||||
camera = ThingClient.from_url("/camera/", client)
|
||||
array = np.asarray(camera.capture_array())
|
||||
assert array.shape == (240, 320, 3)
|
||||
|
||||
|
||||
# Currently this fails, not yet sure why.
|
||||
def test_camera_stage_mapping_calibration(client):
|
||||
camera_stage_mapping = ThingClient.from_url("/camera_stage_mapping/", client)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue