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`
|
||||
* `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.)
|
||||
* 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
|
||||
```
|
||||
|
|
|
|||
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
|
||||
|
||||
This module defines a Thing that is responsible for using the stage and
|
||||
camera together to perform an autofocus routine.
|
||||
This module defines a camera Thing that uses OpenCV's
|
||||
`VideoCapture`.
|
||||
|
||||
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
|
||||
hardware attached.
|
||||
"""
|
||||
|
||||
def __enter__(self):
|
||||
pass
|
||||
self.instantaneous_position = self.position
|
||||
|
||||
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||
pass
|
||||
|
|
@ -26,19 +27,24 @@ class DummyStage(Stage):
|
|||
self.moving = True
|
||||
try:
|
||||
fraction_complete = 0.0
|
||||
dt = 0.001
|
||||
max_displacement = max(abs(v) for v in displacement)
|
||||
if block_cancellation:
|
||||
time.sleep(0.001 * max_displacement)
|
||||
else:
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < 0.001 * max_displacement:
|
||||
cancel.sleep(0.1)
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < dt * max_displacement:
|
||||
if block_cancellation:
|
||||
time.sleep(dt)
|
||||
else:
|
||||
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
|
||||
except InvocationCancelledError as e:
|
||||
# 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,
|
||||
# and to mark the invocation as "cancelled" rather than stopped.
|
||||
fraction_complete = (time.time() - start_time) / (0.001 * max_displacement)
|
||||
raise e
|
||||
finally:
|
||||
self.moving=False
|
||||
|
|
@ -46,6 +52,7 @@ class DummyStage(Stage):
|
|||
k: self.position[k] + int(fraction_complete * v)
|
||||
for k, v in zip(self.axis_names, displacement)
|
||||
}
|
||||
self.instantaneous_position = self.position
|
||||
|
||||
@thing_action
|
||||
def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]):
|
||||
|
|
@ -66,3 +73,4 @@ class DummyStage(Stage):
|
|||
stage.
|
||||
"""
|
||||
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