From 6eefa4f25b4575a5b4dfc0553ba25abb179321e2 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 2 Nov 2023 10:05:54 +0000 Subject: [PATCH 1/4] Add image resolution to CSM metadata The camera stage mapper should now note the resolution used when it was last calibrated - this is helpful for tiling, because we may need to scale it if the images recorded are a different resolution from the images used for calibration. This addresses https://gitlab.com/openflexure/microscope-extensions/camera-stage-mapping/-/issues/11 --- .../things/camera_stage_mapping.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index 8aae1d3a..ad44a7c5 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -132,6 +132,7 @@ class CameraStageMapper(Thing): result: dict = calibrate_backlash_1d(tracker, move, direction_array) result["move_history"] = move.history + result["image_resolution"] = hw.grab_image().shape[:2] return result @thing_action @@ -145,11 +146,13 @@ class CameraStageMapper(Thing): # Combine X and Y calibrations to make a 2D calibration cal_xy: dict = denumpify(image_to_stage_displacement_from_1d([cal_x, cal_y])) self.thing_settings.update(cal_xy) + self.thing_settings["image_resolution"] = cal_x["image_resolution"] data: Dict[str, dict] = { "camera_stage_mapping_calibration": cal_xy, "linear_calibration_x": cal_x, "linear_calibration_y": cal_y, + "image_resolution": cal_x["image_resolution"] } self.thing_settings["last_calibration"] = DenumpifyingDict(data).model_dump() @@ -195,4 +198,7 @@ class CameraStageMapper(Thing): "image_to_stage_displacement_matrix": ( self.image_to_stage_displacement_matrix ), + "image_resolution": ( + self.thing_settings.get("image_to_stage_displacement", None) + ) } \ No newline at end of file From 92ba79ef7aa760592bd6cc5e7c3ede6fa0ee08d6 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 2 Nov 2023 10:27:27 +0000 Subject: [PATCH 2/4] Improved docstrings --- .../things/camera_stage_mapping.py | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index ad44a7c5..bbc45db6 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -137,7 +137,10 @@ class CameraStageMapper(Thing): @thing_action def calibrate_xy(self, hw: HardwareInterfaceDep) -> DenumpifyingDict: - """Move the microscope's stage in X and Y, to calibrate its relationship to the camera""" + """Move the microscope's stage in X and Y, to calibrate its relationship to the camera + + This performs two 1d calibrations in x and y, then combines their results. + """ logging.info("Calibrating X axis:") cal_x: dict = self.calibrate_1d(hw, (1, 0, 0)) logging.info("Calibrating Y axis:") @@ -161,7 +164,23 @@ class CameraStageMapper(Thing): @thing_property def image_to_stage_displacement_matrix(self) -> List[List[float]]: # 2x2 integer array - """A 2x2 matrix that converts displacement in image coordinates to stage coordinates.""" + """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 + may be (y,x). This is an artifact of the way numpy, opencv, etc. define images. If + you are making use of this matrix in your own code, you will need to take care of + that conversion. + + It is often helpful to give a concrete example: to make a move in image coordinates + (`dy`, `dx`), where `dx` is horizontal, i.e. the longer dimension of the image, you + should move the stage by: + ``` + stage_disp = np.dot( + np.array(image_to_stage_displacement_matrix), + np.array([dy,dx]), + ) + ``` + """ displacement_matrix = self.thing_settings.get("image_to_stage_displacement") if not displacement_matrix: raise ValueError("The microscope has not yet been calibrated.") From 7bbc105fc600150e11a18bad1a327306f171cc76 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 2 Nov 2023 20:29:22 +0000 Subject: [PATCH 3/4] Expose last calibration and thing_state --- .../things/camera_stage_mapping.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index bbc45db6..0e303db3 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -185,6 +185,12 @@ class CameraStageMapper(Thing): if not displacement_matrix: raise ValueError("The microscope has not yet been calibrated.") return np.array(displacement_matrix).tolist() + + @thing_property + def last_calibration(self) -> Optional[Dict]: # 2x2 integer array + """The results of the last calibration that was run + """ + return self.thing_settings.get("last_calibration", None) @thing_action def move_in_image_coordinates( @@ -210,7 +216,7 @@ class CameraStageMapper(Thing): ) stage.move_relative(x=relative_move[0], y=relative_move[1]) - @property + @thing_property def thing_state(self) -> dict: """Summary metadata describing the current state of the Thing""" return { From 27c74bff6e90ce91d26ad7570153332de2b7852d Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 2 Nov 2023 21:18:59 +0000 Subject: [PATCH 4/4] Add shutdown/restart actions --- src/openflexure_microscope_server/server.py | 2 + .../things/system_control.py | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/openflexure_microscope_server/things/system_control.py diff --git a/src/openflexure_microscope_server/server.py b/src/openflexure_microscope_server/server.py index 699e7c28..ab84d4cf 100644 --- a/src/openflexure_microscope_server/server.py +++ b/src/openflexure_microscope_server/server.py @@ -10,6 +10,7 @@ from labthings_picamera2.thing import StreamingPiCamera2 from .things.autofocus import AutofocusThing from .things.camera_stage_mapping import CameraStageMapper +from .things.system_control import SystemControlThing from .serve_static_files import add_static_files import openflexure_microscope_server @@ -20,6 +21,7 @@ thing_server.add_thing(StreamingPiCamera2(), "/camera/") thing_server.add_thing(SangaboardThing(), "/stage/") thing_server.add_thing(AutofocusThing(), "/autofocus/") thing_server.add_thing(CameraStageMapper(), "/camera_stage_mapping/") +thing_server.add_thing(SystemControlThing(), "/system_control/") try: add_static_files(thing_server.app) except RuntimeError: diff --git a/src/openflexure_microscope_server/things/system_control.py b/src/openflexure_microscope_server/things/system_control.py new file mode 100644 index 00000000..74a0be44 --- /dev/null +++ b/src/openflexure_microscope_server/things/system_control.py @@ -0,0 +1,59 @@ +""" +OpenFlexure Microscope API extension for stage calibration + +This file contains the HTTP API for camera/stage calibration. It +includes calibration functions that measure the relationship between +stage coordinates and camera coordinates, as well as functions that +move by a specified displacement in pixels, perform closed-loop moves, +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 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 + + +class SystemControlThing(Thing): + """ + Attempt to shutdown the device + """ + + @thing_action + def shutdown(self) -> CommandOutput: + """ + Attempt to shutdown the device + """ + p = subprocess.Popen( + ["sudo", "shutdown", "-h", "now"], + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + + out, err = p.communicate() + return CommandOutput(output=out, error=err) + + @thing_property + def is_raspberrypi() -> bool: + """ + Checks if we are running on a Raspberry Pi. + """ + return os.path.exists("/usr/bin/raspi-config") + + @thing_action + def reboot(self) -> CommandOutput: + """Attempt to reboot the device""" + p = subprocess.Popen( + ["sudo", "shutdown", "-r", "now"], + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + out, err = p.communicate() + return CommandOutput(output=out, error=err)