Simulation mode

I successfully calibrated and used the camera-stage mapping.
This commit is contained in:
Richard Bowman 2024-11-02 00:10:25 +00:00
parent 2019234ad1
commit 868aa0598c
5 changed files with 215 additions and 11 deletions

View file

@ -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
```

View 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/"
}

View file

@ -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.
"""

View 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())

View file

@ -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