Simulation mode
I successfully calibrated and used the camera-stage mapping.
This commit is contained in:
parent
2019234ad1
commit
868aa0598c
5 changed files with 215 additions and 11 deletions
|
|
@ -67,7 +67,7 @@ To set up a development version of the software (most likely using emulated came
|
||||||
* `python -m venv .venv`
|
* `python -m venv .venv`
|
||||||
* `source .venv/bin/activate` (on Linux) or `.venv/Scripts/activate` (on Windows)
|
* `source .venv/bin/activate` (on Linux) or `.venv/Scripts/activate` (on Windows)
|
||||||
* `pip install -e .[dev]` (This will install development dependencies. If you don't need these, there is probably a simpler way to run the server than cloning this repo.)
|
* `pip install -e .[dev]` (This will install development dependencies. If you don't need these, there is probably a simpler way to run the server than cloning this repo.)
|
||||||
* Finally, run the server: currently you can do this with `sudo systemctl start openflexure-microscope-server` if it's pre-installed on a Raspberry Pi, or `uvicorn --port 5000 openflexure_microscope_server.server:app` to run locally.
|
* Finally, run the server: currently you can do this with `sudo systemctl start openflexure-microscope-server` if it's pre-installed on a Raspberry Pi, or `openflexure-microscope-server -c ofm_config_stub.json` to run locally.
|
||||||
|
|
||||||
### Run the server manually on a Raspberry Pi
|
### Run the server manually on a Raspberry Pi
|
||||||
```
|
```
|
||||||
|
|
|
||||||
20
ofm_config_simulation.json
Normal file
20
ofm_config_simulation.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"things": {
|
||||||
|
"/camera/": "openflexure_microscope_server.things.camera.simulation:SimulatedCamera",
|
||||||
|
"/stage/": "openflexure_microscope_server.things.stage.dummy:DummyStage",
|
||||||
|
"/auto_recentre_stage/": "openflexure_microscope_server.things.auto_recentre_stage:RecentringThing",
|
||||||
|
"/autofocus/": "openflexure_microscope_server.things.autofocus:AutofocusThing",
|
||||||
|
"/camera_stage_mapping/": "openflexure_microscope_server.things.camera_stage_mapping:CameraStageMapper",
|
||||||
|
"/system_control/": "openflexure_microscope_server.things.system_control:SystemControlThing",
|
||||||
|
"/settings/": "openflexure_microscope_server.things.settings_manager:SettingsManager",
|
||||||
|
"/smart_scan/": {
|
||||||
|
"class": "openflexure_microscope_server.things.smart_scan:SmartScanThing",
|
||||||
|
"kwargs": {
|
||||||
|
"path_to_openflexure_stitch": "application/openflexure-stitching/.venv/bin/openflexure-stitch"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/background_detect/": "openflexure_microscope_server.things.smart_scan:BackgroundDetectThing",
|
||||||
|
"/api_test/": "openflexure_microscope_server.things.test:APITestThing"
|
||||||
|
},
|
||||||
|
"settings_folder": "./openflexure_settings/"
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""OpenFlexure Microscope OpenCV Camera
|
"""OpenFlexure Microscope OpenCV Camera
|
||||||
|
|
||||||
This module defines a Thing that is responsible for using the stage and
|
This module defines a camera Thing that uses OpenCV's
|
||||||
camera together to perform an autofocus routine.
|
`VideoCapture`.
|
||||||
|
|
||||||
See repository root for licensing information.
|
See repository root for licensing information.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
176
src/openflexure_microscope_server/things/camera/simulation.py
Normal file
176
src/openflexure_microscope_server/things/camera/simulation.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""OpenFlexure Microscope OpenCV Camera
|
||||||
|
|
||||||
|
This module defines a Thing that is responsible for using the stage and
|
||||||
|
camera together to perform an autofocus routine.
|
||||||
|
|
||||||
|
See repository root for licensing information.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Literal, Optional
|
||||||
|
from threading import Thread
|
||||||
|
import time
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import piexif
|
||||||
|
|
||||||
|
from labthings_fastapi.utilities import get_blocking_portal
|
||||||
|
from labthings_fastapi.decorators import thing_action, thing_property
|
||||||
|
from labthings_fastapi.dependencies.metadata import GetThingStates
|
||||||
|
from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor
|
||||||
|
from labthings_fastapi.types.numpy import NDArray
|
||||||
|
from labthings_fastapi.server import ThingServer
|
||||||
|
|
||||||
|
from . import Camera, JPEGBlob
|
||||||
|
from ..stage import Stage
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatedCamera(Camera):
|
||||||
|
"""A Thing representing an OpenCV camera"""
|
||||||
|
shape = (600, 800, 3)
|
||||||
|
glyph_shape = (51, 51, 3)
|
||||||
|
canvas_shape = (3000, 4000, 3)
|
||||||
|
frame_interval = 0.1
|
||||||
|
_stage: Optional[Stage] = None
|
||||||
|
_server: Optional[ThingServer] = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._capture_thread: Optional[Thread] = None
|
||||||
|
self._capture_enabled = False
|
||||||
|
self.generate_sprites()
|
||||||
|
self.generate_blobs()
|
||||||
|
self.generate_canvas()
|
||||||
|
|
||||||
|
def generate_sprites(self):
|
||||||
|
"""Generate sprites to populate the image"""
|
||||||
|
self.sprites = []
|
||||||
|
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)
|
||||||
|
for i in [5, 7, 9, 11, 13, 15]:
|
||||||
|
sprite = black.copy()
|
||||||
|
sprite[rr < i] = 255
|
||||||
|
self.sprites.append(sprite)
|
||||||
|
|
||||||
|
def generate_blobs(self, N: int = 1000):
|
||||||
|
"""Generate coordinates of blobs
|
||||||
|
|
||||||
|
Blobs are characterised by X, Y, sprite
|
||||||
|
We also generate a KD tree to rapidly find blobs in an image
|
||||||
|
"""
|
||||||
|
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[:, 2] = rng.choice(len(self.sprites), N)
|
||||||
|
|
||||||
|
def generate_canvas(self):
|
||||||
|
"""Generate a blank canvas"""
|
||||||
|
self.canvas = np.zeros(self.canvas_shape, dtype=np.uint8)
|
||||||
|
self.canvas[...] = 255
|
||||||
|
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,
|
||||||
|
] -= 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)
|
||||||
|
return self.canvas[
|
||||||
|
tuple(slice(tl[i],tl[i] + self.shape[i]) for i in range(2)) + (slice(None),)
|
||||||
|
]
|
||||||
|
|
||||||
|
def attach_to_server(self, server: ThingServer, path: str):
|
||||||
|
self._server = server
|
||||||
|
return super().attach_to_server(server, path)
|
||||||
|
|
||||||
|
def get_stage_position(self):
|
||||||
|
if not self._stage and self._server:
|
||||||
|
self._stage = self._server.things["/stage/"]
|
||||||
|
return self._stage.instantaneous_position
|
||||||
|
|
||||||
|
def generate_frame(self):
|
||||||
|
"""Generate a frame with blobs based on the stage coordinates"""
|
||||||
|
try:
|
||||||
|
pos = self.get_stage_position()
|
||||||
|
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))
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self._capture_enabled = True
|
||||||
|
self._capture_thread = Thread(target=self._capture_frames)
|
||||||
|
self._capture_thread.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||||
|
if self.stream_active:
|
||||||
|
self._capture_enabled = False
|
||||||
|
self._capture_thread.join()
|
||||||
|
|
||||||
|
@thing_property
|
||||||
|
def stream_active(self) -> bool:
|
||||||
|
"Whether the MJPEG stream is active"
|
||||||
|
if self._capture_enabled and self._capture_thread:
|
||||||
|
return self._capture_thread.is_alive()
|
||||||
|
return False
|
||||||
|
mjpeg_stream = MJPEGStreamDescriptor()
|
||||||
|
lores_mjpeg_stream = MJPEGStreamDescriptor()
|
||||||
|
|
||||||
|
def _capture_frames(self):
|
||||||
|
portal = get_blocking_portal(self)
|
||||||
|
while self._capture_enabled:
|
||||||
|
time.sleep(self.frame_interval)
|
||||||
|
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()
|
||||||
|
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
|
||||||
|
|
||||||
|
@thing_action
|
||||||
|
def capture_array(
|
||||||
|
self,
|
||||||
|
resolution: Literal["main", "full"] = "full",
|
||||||
|
) -> NDArray:
|
||||||
|
"""Acquire one image from the camera and return as an array
|
||||||
|
|
||||||
|
This function will produce a nested list containing an uncompressed RGB image.
|
||||||
|
It's likely to be highly inefficient - raw and/or uncompressed captures using
|
||||||
|
binary image formats will be added in due course.
|
||||||
|
"""
|
||||||
|
return self.generate_frame()
|
||||||
|
|
||||||
|
@thing_action
|
||||||
|
def capture_jpeg(
|
||||||
|
self,
|
||||||
|
metadata_getter: GetThingStates,
|
||||||
|
resolution: Literal["main", "full"] = "main",
|
||||||
|
) -> JPEGBlob:
|
||||||
|
"""Acquire one image from the camera and return as a JPEG blob
|
||||||
|
|
||||||
|
This function will produce a JPEG image.
|
||||||
|
"""
|
||||||
|
frame = self.capture_array()
|
||||||
|
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||||
|
exif_dict = {
|
||||||
|
"Exif": {
|
||||||
|
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8")
|
||||||
|
},
|
||||||
|
"GPS": {},
|
||||||
|
"Interop": {},
|
||||||
|
"1st": {},
|
||||||
|
"thumbnail": None,
|
||||||
|
}
|
||||||
|
output = io.BytesIO()
|
||||||
|
piexif.insert(piexif.dump(exif_dict), jpeg, output)
|
||||||
|
return JPEGBlob.from_bytes(output.getvalue())
|
||||||
|
|
@ -13,8 +13,9 @@ class DummyStage(Stage):
|
||||||
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 __enter__(self):
|
def __enter__(self):
|
||||||
pass
|
self.instantaneous_position = self.position
|
||||||
|
|
||||||
def __exit__(self, _exc_type, _exc_value, _traceback):
|
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||||
pass
|
pass
|
||||||
|
|
@ -26,19 +27,24 @@ class DummyStage(Stage):
|
||||||
self.moving = True
|
self.moving = True
|
||||||
try:
|
try:
|
||||||
fraction_complete = 0.0
|
fraction_complete = 0.0
|
||||||
|
dt = 0.001
|
||||||
max_displacement = max(abs(v) for v in displacement)
|
max_displacement = max(abs(v) for v in displacement)
|
||||||
if block_cancellation:
|
start_time = time.time()
|
||||||
time.sleep(0.001 * max_displacement)
|
while time.time() - start_time < dt * max_displacement:
|
||||||
else:
|
if block_cancellation:
|
||||||
start_time = time.time()
|
time.sleep(dt)
|
||||||
while time.time() - start_time < 0.001 * max_displacement:
|
else:
|
||||||
cancel.sleep(0.1)
|
cancel.sleep(dt)
|
||||||
|
fraction_complete = (time.time() - start_time) / (dt * max_displacement)
|
||||||
|
self.instantaneous_position = {
|
||||||
|
k: self.position[k] + int(fraction_complete * v)
|
||||||
|
for k, v in zip(self.axis_names, displacement)
|
||||||
|
}
|
||||||
fraction_complete = 1.0
|
fraction_complete = 1.0
|
||||||
except InvocationCancelledError as e:
|
except InvocationCancelledError as e:
|
||||||
# If the move has been cancelled, stop it but don't handle the exception.
|
# If the move has been cancelled, stop it but don't handle the exception.
|
||||||
# We need the exception to propagate in order to stop any calling tasks,
|
# We need the exception to propagate in order to stop any calling tasks,
|
||||||
# and to mark the invocation as "cancelled" rather than stopped.
|
# and to mark the invocation as "cancelled" rather than stopped.
|
||||||
fraction_complete = (time.time() - start_time) / (0.001 * max_displacement)
|
|
||||||
raise e
|
raise e
|
||||||
finally:
|
finally:
|
||||||
self.moving=False
|
self.moving=False
|
||||||
|
|
@ -46,6 +52,7 @@ class DummyStage(Stage):
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
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]):
|
||||||
|
|
@ -66,3 +73,4 @@ class DummyStage(Stage):
|
||||||
stage.
|
stage.
|
||||||
"""
|
"""
|
||||||
self.position = {k: 0 for k in self.axis_names}
|
self.position = {k: 0 for k in self.axis_names}
|
||||||
|
self.instantaneous_position = self.position
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue