diff --git a/README.md b/README.md index 15734630..a6004496 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/ofm_config_simulation.json b/ofm_config_simulation.json new file mode 100644 index 00000000..62f427fb --- /dev/null +++ b/ofm_config_simulation.json @@ -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/" +} \ No newline at end of file diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index 0b37582f..bc7641cd 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -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. """ diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py new file mode 100644 index 00000000..a431c867 --- /dev/null +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -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()) diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index ca1f7f63..6e984470 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -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