From 9eff514156e102bf3d0b25e61ff33975a1c7af32 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Tue, 1 Sep 2026 13:53:02 +0100 Subject: [PATCH 01/31] add timer, progress bar and cancel mechanism --- .../nion_experimental_tools/__init__.py | 1 + .../overview_scan_panel.py | 479 ++++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 nionswift_plugin/nion_experimental_tools/overview_scan_panel.py diff --git a/nionswift_plugin/nion_experimental_tools/__init__.py b/nionswift_plugin/nion_experimental_tools/__init__.py index 1208047..0f436a8 100755 --- a/nionswift_plugin/nion_experimental_tools/__init__.py +++ b/nionswift_plugin/nion_experimental_tools/__init__.py @@ -13,6 +13,7 @@ from . import MultiDimensionalProcessing from . import IESquarePlot from . import FindLocalMaxima +from . import overview_scan_panel _computation_classes = [ diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py new file mode 100644 index 0000000..1014d9c --- /dev/null +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -0,0 +1,479 @@ +# standard libraries +import gettext +import typing + +# nionswift libraries +from nion.swift import Panel +from nion.swift import Workspace +from nion.swift import DocumentController +from nion.swift.model import PlugInManager +from nion.ui import Declarative +from nion.utils import Registry +from nion.typeshed import API_1_0 + + +import time +import math +import numpy + +from nion.instrumentation import camera_base +from nion.instrumentation import stem_controller as stem_controller_module + +import asyncio + +_ = gettext.gettext + + +class SamplePanelUI: + panel_type = "overview-scan-panel" + + def get_ui_handler( + self, + api_broker: PlugInManager.APIBroker, + event_loop: typing.Optional[asyncio.AbstractEventLoop] = None, + **kwargs: typing.Any, + ) -> Declarative.HandlerLike: + api = api_broker.get_api("~1.0") + document_controller = kwargs.get("document_controller") + return SamplePanelHandler(api, event_loop, document_controller) + +class SamplePanelHandler(Declarative.Handler): + """Declarative handler for the Sample docked panel.""" + + def __init__( + self, + + api: "API_1_0.API", + event_loop: typing.Optional[asyncio.AbstractEventLoop], + document_controller: typing.Any, + ) -> None: + super().__init__() + self._api = api + self._event_loop = event_loop or asyncio.get_event_loop() + self._document_controller = document_controller + self.width_value: str = "" + self.height_value: str = "" + self.defocus: str = "" + self.instrument = typing.cast(stem_controller_module.STEMController, Registry.get_component('stem_controller')) + self.camera = typing.cast(camera_base.CameraHardwareSource, self.instrument.ronchigram_camera) + self._document_controller = document_controller + self.output_text: str = "" + self.progress_value: int = 0 + self.progress_max: int = 100 + self.progress_min: int = 0 + self.progress_text: str = "Progress:\nIdle" + self._acq_task: typing.Optional[asyncio.Task[None]] = None + self.ui_view = self._build_ui() + + def _set_progress(self, value: int, maximum: int, text: str) -> None: + self.progress_value = value + self.progress_max = max(1, int(maximum)) + self.progress_min = 0 + self.progress_text = text + self.property_changed_event.fire("progress_value") + self.property_changed_event.fire("progress_text") + + def _set_progress_threadsafe(self, value: int, maximum: int, text: str) -> None: + self._event_loop.call_soon_threadsafe(self._set_progress, value, maximum, text) + + def _build_ui(self) -> typing.Mapping[str, typing.Any]: + u = Declarative.DeclarativeUI() + title = u.create_label(text="Overview Scan", font="bold") + time_button = u.create_push_button( + text="Estimate time to acquire survey image", + on_clicked="on_estimate_time_clicked" + ) + acq_button = u.create_push_button( + text="Perform wide-field acquisition", + on_clicked="on_perform_acquisition_clicked" + ) + width_label = u.create_label(text="Desired width of image (um):") + width_field = u.create_line_edit(text="@binding(width_value)", editable=True) + + height_label = u.create_label(text="Desired height of image (um):") + height_field = u.create_line_edit(text="@binding(height_value)", editable=True) + + defocus_label = u.create_label(text="Desired defocus (nm):") + defocus = u.create_line_edit(text="@binding(defocus)", editable=True) + + output_label = u.create_label(text="Output:") + output_box = u.create_text_edit( + text="@binding(output_text)", + editable=False, + height=200 + ) + progress_label = u.create_label(text="@binding(progress_text)") + progress_bar = u.create_progress_bar( + value="@binding(progress_value)", + minimum=0, + maximum=100, + width=500 + ) + + + return u.create_column( + title, + u.create_row(width_label, u.create_spacing(4), width_field), + u.create_spacing(4), + u.create_row(height_label, u.create_spacing(4), height_field), + u.create_spacing(4), + u.create_row(defocus_label, u.create_spacing(4), defocus), + u.create_spacing(8), + time_button, + u.create_spacing(8), + acq_button, + u.create_spacing(8), + output_label, + output_box, + u.create_spacing(50), + progress_label, + progress_bar, + u.create_stretch(), + width=500, + height=500 + ) + + def _append_output(self, message: str) -> None: + self.output_text += f"{message}\n" + self.property_changed_event.fire("output_text") + + def _append_output_threadsafe(self, message: str) -> None: + self._event_loop.call_soon_threadsafe(self._append_output, message) + + def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: + instrument = self.instrument + sx0 = instrument.get_control_output("SShft.sx") + sy0 = instrument.get_control_output("SShft.sy") + x0 = instrument.get_control_output("SShft.x") + y0 = instrument.get_control_output("SShft.y") + + instrument.set_control_output("SShft.sx", sx0 + ds) + x1 = instrument.get_control_output("SShft.x") + y1 = instrument.get_control_output("SShft.y") + + dx_from_sx = x1 - x0 + dy_from_sx = y1 - y0 + + instrument.set_control_output("SShft.sx", sx0) + instrument.set_control_output("SShft.sy", sy0) + instrument.set_control_output("SShft.x", x0) + instrument.set_control_output("SShft.y", y0) + + instrument.set_control_output("SShft.sy", sy0 + ds) + x2 = instrument.get_control_output("SShft.x") + y2 = instrument.get_control_output("SShft.y") + instrument.set_control_output("SShft.sy", sy0) + + dx_from_sy = x2 - x0 + dy_from_sy = y2 - y0 + + mat = numpy.array([ + [dx_from_sx / ds, dx_from_sy / ds], + [dy_from_sx / ds, dy_from_sy / ds], + ]) + + return mat + + + def acquisition(self, + instrument, + camera, + defocus, + target_width_m: tuple[float | int, float | int], timer = False, + reduce: float = 1.0): + + counter = 0 + + try: + tv_pixel_angle_rad = instrument.get_control_output("TVPixelAngle") + except Exception: + tv_pixel_angle_rad = None + + if tv_pixel_angle_rad is not None: + shift_x_control_name = "SShft.sx" + shift_y_control_name = "SShft.sy" + + else: + shift_x_control_name = "stage_position_m.x" + shift_y_control_name = "stage_position_m.y" + + frame = camera.grab_next_to_start()[0] + assert frame is not None + tv_pixel_angle_rad = frame.dimensional_calibrations[0].scale + + # grab stage original location + sx_m = instrument.get_control_output(shift_x_control_name) + sy_m = instrument.get_control_output(shift_y_control_name) + df_original = instrument.get_control_output("C10") + + instrument.set_control_output("C10", defocus) + pixel_size_m = abs(defocus) * math.tan(tv_pixel_angle_rad) + + image_size = camera.get_expected_dimensions(camera.get_current_frame_parameters()) + image_dtype = numpy.float32 + + image_width_m = abs(defocus) * math.sin(tv_pixel_angle_rad * image_size[0]) + + master_sub_area_size = image_size[0] // 2, image_size[1] // 2 + master_sub_area = (image_size[0] // 2 - master_sub_area_size[0] // 2, image_size[1] // 2 - master_sub_area_size[1] // 2), master_sub_area_size + + reduce = max(1, int(reduce)) + + sub_area_shift_m = image_width_m * (master_sub_area[1][0] / image_size[0]) + + sub_area = (master_sub_area[0][0] // reduce, master_sub_area[0][1] // reduce), (master_sub_area[1][0] // reduce, master_sub_area[1][1] // reduce) + + frames_needed_width = math.ceil(target_width_m[0] * 1e-6 / sub_area_shift_m) + frames_needed_height = math.ceil(target_width_m[1] * 1e-6 / sub_area_shift_m) + size = (frames_needed_width, frames_needed_height) + + total_images = frames_needed_width* frames_needed_height + + master_data = numpy.empty((sub_area[1][0] * size[0], sub_area[1][1] * size[1]), image_dtype) + + if not timer: + self._append_output_threadsafe(f"Stage starting position: {sx_m * 1e6, sy_m * 1e6} um") + self._append_output_threadsafe(f"Pixel size: {(pixel_size_m * 1e9):.3f} nm") + self._append_output_threadsafe(f"Defocus: {(defocus * 1e9):.0f} nm") + + self._append_output_threadsafe(f"Image width: {image_width_m * 1e6} um") + self._append_output_threadsafe(f"Master size: {master_data.shape}\n") + + self._set_progress_threadsafe(0, total_images, "Progress:\nStarting acquisition...") + + else: + self._append_output_threadsafe(f"Need to acquire {frames_needed_width} x {frames_needed_height} frames for a {target_width_m[0]} um x {target_width_m[1]} um image \n") + + + t1 = time.time() + + if timer: + size = (1,1) + else: + size = size + try: + for row in range(size[0]): + col_iter = range(size[1]) if (row % 2 == 0) else range(size[1] - 1, -1, -1) + for column in col_iter: + + if shift_x_control_name == "stage_position_m.x": + delta_x_m = - sub_area_shift_m * (column - size[1] // 2) + delta_y_m = - sub_area_shift_m * (row - size[0] // 2) + else: + matrix = self.find_matrix(instrument) + delta_x_m = - sub_area_shift_m * (column - size[1] // 2) + delta_y_m = - sub_area_shift_m * (row - size[0] // 2) + delta_camera = numpy.array([delta_x_m, delta_y_m], dtype=numpy.float64) + delta_fast = numpy.linalg.solve(matrix, delta_camera) + + delta_x_m = float(delta_fast[0]) + delta_y_m = float(delta_fast[1]) + + counter += 1 + + attempts = 0 + while attempts < 4: + attempts += 1 + try: + tolerance_factor = 0.0001 + instrument.set_control_output(shift_x_control_name, sx_m - delta_x_m, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + instrument.set_control_output(shift_y_control_name, sy_m - delta_y_m, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + except TimeoutError: + self._append_output_threadsafe(f"Timeout row= {row} column= {column}") + continue + break + + supradata = camera.grab_next_to_start()[0] + assert supradata is not None + # set both values + attempts = 0 + while attempts < 4: + attempts += 1 + try: + tolerance_factor = 0.0001 + instrument.set_control_output(shift_x_control_name, sx_m, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + instrument.set_control_output(shift_y_control_name, sy_m, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + except TimeoutError: + self._append_output_threadsafe(f"Timeout row= {row} column= {column}") + continue + break + data = supradata.data[master_sub_area[0][0]:master_sub_area[0][0] + master_sub_area[1][0]:reduce, master_sub_area[0][1]:master_sub_area[0][1] + master_sub_area[1][1]:reduce] + slice_row = row + slice_column = column + slice0 = slice(slice_row * sub_area[1][0], (slice_row + 1) * sub_area[1][0]) + slice1 = slice(slice_column * sub_area[1][1], (slice_column + 1) * sub_area[1][1]) + master_data[slice0, slice1] = data + + # inside loop after counter increment or frame write + if not timer: + pct = (100 * counter / total_images) + self._set_progress_threadsafe(pct, total_images, f"Progress:\nAcquiring {counter}/{total_images}") + t2 = time.time() + time_total = t2-t1 + finally: + # restore stage to original location + instrument.set_control_output(shift_x_control_name, sx_m) + instrument.set_control_output(shift_y_control_name, sy_m) + instrument.set_control_output("C10", df_original) + + if timer: + return total_images, time_total + else: + return master_data + + + def on_estimate_time_clicked(self, widget: typing.Any) -> None: + try: + width_um = int(self.width_value) + height_um = int(self.height_value) + defocus_nm = int(self.defocus) * 1e-9 + except ValueError: + self._append_output("Please enter width, height, and defocus as integers.") + return + + if width_um < 1 or height_um < 1: + self._append_output("Please ensure width and height are positive.") + return + if width_um >= 1000 or height_um >= 1000: + self._append_output("Warning: Requested scan size is outside of sensible limit") + return + if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 500000: + self._append_output("Warning: Requested defocus is outside of sensible limit") + return + + instrument = self.instrument + camera = self.camera + reduce = 1.0 + target_width_m = (width_um, height_um) + total_images, t_total = self.acquisition(instrument, camera, defocus_nm, target_width_m, timer=True, reduce=reduce) + + time_taken = t_total * total_images + self._append_output( + f"This acquisition will take approximately {(time_taken // 3600):.0f}h {((time_taken % 3600) / 60):.0f}min {(time_taken % 60):.0f}s" + ) + + async def _run_acquisition_async( + self, + instrument, + camera, + defocus_nm: float, + target_width_um: tuple[int, int], + ) -> None: + loop = self._event_loop + + try: + master_data = await loop.run_in_executor( + None, + lambda: self.acquisition(instrument, camera, defocus_nm, target_width_um, False, 1.0), + ) + except Exception as e: + self._append_output(f"Acquisition failed: {e!r}") + return + + try: + library = self._api.library + library.create_data_item_from_data(master_data, "Composite Survey") + self._append_output("Acquisition complete.\n") + except Exception as e: + self._append_output(f"Failed to publish result: {e!r}") + + def on_perform_acquisition_clicked(self, widget: typing.Any) -> None: + try: + width_um = int(self.width_value) + height_um = int(self.height_value) + defocus_nm = int(self.defocus) * 1e-9 + except ValueError: + self._append_output("Please enter width, height, and defocus as integers.") + return + + if width_um <= 0 or height_um <= 0: + self._append_output("Please ensure width and height are positive.") + return + + if self._acq_task and not self._acq_task.done(): + self._append_output("Acquisition already running.") + return + + instrument = self.instrument + camera = self.camera + target_width_um = (width_um, height_um) + + self._acq_task = self._event_loop.create_task( + self._run_acquisition_async(instrument, camera, defocus_nm, target_width_um) + ) +# --------------------------------------------------------------------------- +# Swift Panel wrapper +# --------------------------------------------------------------------------- + +class SamplePanel(Panel.Panel): + """Swift panel class instantiated by the Workspace panel manager.""" + + def __init__( + self, + document_controller: "DocumentController.DocumentController", + panel_id: str, + properties: typing.Dict[str, typing.Any], + ) -> None: + super().__init__(document_controller, panel_id, "overview-scan-panel") + for component in Registry.get_components_by_type("overview-scan-panel"): + if getattr(component, "panel_type", None) == "overview-scan-panel": + ui_handler = component.get_ui_handler( + api_broker=PlugInManager.APIBroker(), + event_loop=document_controller.event_loop, + document_controller=document_controller, + ) + self.widget = Declarative.DeclarativeWidget( + document_controller.ui, + document_controller.event_loop, + ui_handler, + ) + break + + +class PanelSampleExtension: + + # required for Swift to recognize this as an extension class. + extension_id = "sample.panel" + + def __init__(self, api_broker): + self.__component = Registry.register_component(SamplePanelUI(), {"overview-scan-panel"}) + self.__panel = Workspace.WorkspaceManager().register_panel( + SamplePanel, + "sample-main-panel", + _("Overview Scan"), + ["left", "right"], + "right", + {"panel_type": "overview-scan-panel"}, + ) + + def close(self): + pass + + +# class SampleMenuItemDelegate: +# +# def __init__(self, api): +# self.__api = api +# self.menu_id = "example_menu" # required, specify menu_id where this item will go +# self.menu_name = _("Examples") # optional, specify default name if not a standard menu +# self.menu_before_id = "window_menu" # optional, specify before menu_id if not a standard menu +# self.menu_item_name = _("Run Sample") # menu item name +# +# def menu_item_execute(self, window): +# sampler.sample_function() +# +# +# class MenuSampleExtension: +# +# # required for Swift to recognize this as an extension class. +# extension_id = "sample.menu_item_call_sample" +# +# def __init__(self, api_broker): +# # grab the api object. +# api = api_broker.get_api(version="~1.0") +# # be sure to keep a reference or it will be closed immediately. +# self.__menu_item_ref = api.create_menu_item(SampleMenuItemDelegate(api)) +# +# def close(self): +# self.__menu_item_ref.close() +# self.__menu_item_ref = None From 0ac96a9073e0998c5f69d387bd63aae81a14aa5c Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Tue, 1 Sep 2026 15:51:39 +0100 Subject: [PATCH 02/31] fix time estimate and add cancel mechanism --- .../overview_scan_panel.py | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index 1014d9c..e358178 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -63,6 +63,8 @@ def __init__( self.progress_min: int = 0 self.progress_text: str = "Progress:\nIdle" self._acq_task: typing.Optional[asyncio.Task[None]] = None + self._cancel_requested: bool = False + self._is_running: bool = False self.ui_view = self._build_ui() def _set_progress(self, value: int, maximum: int, text: str) -> None: @@ -109,6 +111,9 @@ def _build_ui(self) -> typing.Mapping[str, typing.Any]: maximum=100, width=500 ) + cancel_button = u.create_push_button( + text="Cancel acquisition", + on_clicked="on_cancel_acquisition_clicked") return u.create_column( @@ -128,6 +133,8 @@ def _build_ui(self) -> typing.Mapping[str, typing.Any]: u.create_spacing(50), progress_label, progress_bar, + u.create_spacing(8), + cancel_button, u.create_stretch(), width=500, height=500 @@ -140,6 +147,12 @@ def _append_output(self, message: str) -> None: def _append_output_threadsafe(self, message: str) -> None: self._event_loop.call_soon_threadsafe(self._append_output, message) + def on_cancel_acquisition_clicked(self, widget: typing.Any) -> None: + if self._is_running: + self._cancel_requested = True + self._set_progress_threadsafe(self.progress_value, 100, "Cancel requested...") + + def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: instrument = self.instrument sx0 = instrument.get_control_output("SShft.sx") @@ -183,6 +196,8 @@ def acquisition(self, reduce: float = 1.0): counter = 0 + self._cancel_requested = False + self._is_running = True try: tv_pixel_angle_rad = instrument.get_control_output("TVPixelAngle") @@ -248,19 +263,26 @@ def acquisition(self, t1 = time.time() if timer: - size = (1,1) + size = (2,1) else: size = size try: for row in range(size[0]): + if self._cancel_requested: + self._append_output_threadsafe("Acquisition Cancelled.") + return None if not timer else (0, 0.0) + col_iter = range(size[1]) if (row % 2 == 0) else range(size[1] - 1, -1, -1) for column in col_iter: + if self._cancel_requested: + self._append_output_threadsafe("Acquisition Cancelled.") + return None if not timer else (0, 0.0) if shift_x_control_name == "stage_position_m.x": delta_x_m = - sub_area_shift_m * (column - size[1] // 2) delta_y_m = - sub_area_shift_m * (row - size[0] // 2) else: - matrix = self.find_matrix(instrument) + matrix = self.find_matrix() delta_x_m = - sub_area_shift_m * (column - size[1] // 2) delta_y_m = - sub_area_shift_m * (row - size[0] // 2) delta_camera = numpy.array([delta_x_m, delta_y_m], dtype=numpy.float64) @@ -273,6 +295,9 @@ def acquisition(self, attempts = 0 while attempts < 4: + if self._cancel_requested: + self._append_output_threadsafe("Acquisition Cancelled.") + return None if not timer else (0, 0.0) attempts += 1 try: tolerance_factor = 0.0001 @@ -288,6 +313,9 @@ def acquisition(self, # set both values attempts = 0 while attempts < 4: + if self._cancel_requested: + self._append_output_threadsafe("Acquisition Cancelled.") + return None if not timer else (0, 0.0) attempts += 1 try: tolerance_factor = 0.0001 @@ -315,6 +343,7 @@ def acquisition(self, instrument.set_control_output(shift_x_control_name, sx_m) instrument.set_control_output(shift_y_control_name, sy_m) instrument.set_control_output("C10", df_original) + self._set_progress_threadsafe(0, 100, "Progress:\n Idle") if timer: return total_images, time_total @@ -347,7 +376,7 @@ def on_estimate_time_clicked(self, widget: typing.Any) -> None: target_width_m = (width_um, height_um) total_images, t_total = self.acquisition(instrument, camera, defocus_nm, target_width_m, timer=True, reduce=reduce) - time_taken = t_total * total_images + time_taken = t_total * total_images / 2 #average time to move the stage self._append_output( f"This acquisition will take approximately {(time_taken // 3600):.0f}h {((time_taken % 3600) / 60):.0f}min {(time_taken % 60):.0f}s" ) @@ -366,6 +395,9 @@ async def _run_acquisition_async( None, lambda: self.acquisition(instrument, camera, defocus_nm, target_width_um, False, 1.0), ) + if master_data is None: + self._set_progress(0, 100, "Progress:\nIdle") + return except Exception as e: self._append_output(f"Acquisition failed: {e!r}") return From 4f71e888238bd01c20adfbe2d27531edb316084a Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Wed, 2 Sep 2026 10:13:11 +0100 Subject: [PATCH 03/31] grey out cancel button until running --- .../overview_scan_panel.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index e358178..2696c27 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -8,7 +8,7 @@ from nion.swift import DocumentController from nion.swift.model import PlugInManager from nion.ui import Declarative -from nion.utils import Registry +from nion.utils import Registry, Model from nion.typeshed import API_1_0 @@ -63,8 +63,12 @@ def __init__( self.progress_min: int = 0 self.progress_text: str = "Progress:\nIdle" self._acq_task: typing.Optional[asyncio.Task[None]] = None + self.width_value: str = "30" + self.height_value: str = "30" + self.defocus: str = "-50000" self._cancel_requested: bool = False self._is_running: bool = False + self.cancel_enabled = Model.PropertyModel(False) self.ui_view = self._build_ui() def _set_progress(self, value: int, maximum: int, text: str) -> None: @@ -113,7 +117,10 @@ def _build_ui(self) -> typing.Mapping[str, typing.Any]: ) cancel_button = u.create_push_button( text="Cancel acquisition", - on_clicked="on_cancel_acquisition_clicked") + on_clicked="on_cancel_acquisition_clicked", + enabled="@binding(cancel_enabled.value)" + + ) return u.create_column( @@ -136,8 +143,8 @@ def _build_ui(self) -> typing.Mapping[str, typing.Any]: u.create_spacing(8), cancel_button, u.create_stretch(), - width=500, - height=500 + margin=6, + spacing=4 ) def _append_output(self, message: str) -> None: @@ -198,6 +205,7 @@ def acquisition(self, counter = 0 self._cancel_requested = False self._is_running = True + self.cancel_enabled.value = True try: tv_pixel_angle_rad = instrument.get_control_output("TVPixelAngle") @@ -270,12 +278,14 @@ def acquisition(self, for row in range(size[0]): if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") + self.cancel_enabled.value = False return None if not timer else (0, 0.0) col_iter = range(size[1]) if (row % 2 == 0) else range(size[1] - 1, -1, -1) for column in col_iter: if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") + self.cancel_enabled.value = False return None if not timer else (0, 0.0) if shift_x_control_name == "stage_position_m.x": @@ -297,6 +307,7 @@ def acquisition(self, while attempts < 4: if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") + self.cancel_enabled.value = False return None if not timer else (0, 0.0) attempts += 1 try: @@ -315,6 +326,7 @@ def acquisition(self, while attempts < 4: if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") + self.cancel_enabled.value = False return None if not timer else (0, 0.0) attempts += 1 try: @@ -400,14 +412,17 @@ async def _run_acquisition_async( return except Exception as e: self._append_output(f"Acquisition failed: {e!r}") + self.cancel_enabled.value = False return try: library = self._api.library library.create_data_item_from_data(master_data, "Composite Survey") self._append_output("Acquisition complete.\n") + self.cancel_enabled.value = False except Exception as e: self._append_output(f"Failed to publish result: {e!r}") + self.cancel_enabled.value = False def on_perform_acquisition_clicked(self, widget: typing.Any) -> None: try: @@ -433,6 +448,7 @@ def on_perform_acquisition_clicked(self, widget: typing.Any) -> None: self._acq_task = self._event_loop.create_task( self._run_acquisition_async(instrument, camera, defocus_nm, target_width_um) ) + self.cancel_enabled.value = False # --------------------------------------------------------------------------- # Swift Panel wrapper # --------------------------------------------------------------------------- From bd35fe74b04fb3bb522c8c35eda8f179abb3617c Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Wed, 2 Sep 2026 14:05:48 +0100 Subject: [PATCH 04/31] adding dimension calibrated units --- .../overview_scan_panel.py | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index 2696c27..af174ec 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -274,6 +274,7 @@ def acquisition(self, size = (2,1) else: size = size + try: for row in range(size[0]): if self._cancel_requested: @@ -358,9 +359,11 @@ def acquisition(self, self._set_progress_threadsafe(0, 100, "Progress:\n Idle") if timer: + self.cancel_enabled.value = False return total_images, time_total else: - return master_data + self.cancel_enabled.value = False + return master_data, sub_area, sub_area_shift_m, pixel_size_m def on_estimate_time_clicked(self, widget: typing.Any) -> None: @@ -402,8 +405,9 @@ async def _run_acquisition_async( ) -> None: loop = self._event_loop + self._append_output_threadsafe("Starting acquisition...\n") try: - master_data = await loop.run_in_executor( + master_data, sub_area, sub_area_shift_m, pixel_size_m = await loop.run_in_executor( None, lambda: self.acquisition(instrument, camera, defocus_nm, target_width_um, False, 1.0), ) @@ -417,9 +421,21 @@ async def _run_acquisition_async( try: library = self._api.library - library.create_data_item_from_data(master_data, "Composite Survey") - self._append_output("Acquisition complete.\n") - self.cancel_enabled.value = False + y_scale_um = (sub_area_shift_m / sub_area[1][0]) * 1e6 + x_scale_um = (sub_area_shift_m / sub_area[1][1]) * 1e6 + dimensional_calibrations = [ + self._api.create_calibration(0.0, y_scale_um, "um"), + self._api.create_calibration(0.0, x_scale_um, "um"), + ] + + xdata = self._api.create_data_and_metadata( + master_data, + dimensional_calibrations=dimensional_calibrations, + ) + + library.create_data_item_from_data_and_metadata(xdata, "Composite Survey") + # library.create_data_item_from_data(master_data, "Composite Survey") + # self._append_output("Acquisition complete.\n") except Exception as e: self._append_output(f"Failed to publish result: {e!r}") self.cancel_enabled.value = False From 894ef4df1df2828cf098c284a0600938730bc294 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Fri, 4 Sep 2026 12:13:43 +0100 Subject: [PATCH 05/31] fix typing issues and adding size warning for data item re: sample navigation window --- .../overview_scan_panel.py | 200 ++++++++---------- 1 file changed, 89 insertions(+), 111 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index af174ec..15d2a20 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -8,36 +8,38 @@ from nion.swift import DocumentController from nion.swift.model import PlugInManager from nion.ui import Declarative -from nion.utils import Registry, Model +from nion.utils import Registry +from nion.utils import Model from nion.typeshed import API_1_0 - import time import math import numpy from nion.instrumentation import camera_base from nion.instrumentation import stem_controller as stem_controller_module +import numpy.typing as npt import asyncio _ = gettext.gettext -class SamplePanelUI: +class OverviewScanPanelUI: panel_type = "overview-scan-panel" + @staticmethod def get_ui_handler( - self, api_broker: PlugInManager.APIBroker, event_loop: typing.Optional[asyncio.AbstractEventLoop] = None, **kwargs: typing.Any, ) -> Declarative.HandlerLike: api = api_broker.get_api("~1.0") document_controller = kwargs.get("document_controller") - return SamplePanelHandler(api, event_loop, document_controller) + return OverviewSamplePanelHandler(api, event_loop, document_controller) + -class SamplePanelHandler(Declarative.Handler): +class OverviewSamplePanelHandler(Declarative.Handler): # type: ignore[misc] """Declarative handler for the Sample docked panel.""" def __init__( @@ -50,10 +52,6 @@ def __init__( super().__init__() self._api = api self._event_loop = event_loop or asyncio.get_event_loop() - self._document_controller = document_controller - self.width_value: str = "" - self.height_value: str = "" - self.defocus: str = "" self.instrument = typing.cast(stem_controller_module.STEMController, Registry.get_component('stem_controller')) self.camera = typing.cast(camera_base.CameraHardwareSource, self.instrument.ronchigram_camera) self._document_controller = document_controller @@ -66,6 +64,7 @@ def __init__( self.width_value: str = "30" self.height_value: str = "30" self.defocus: str = "-50000" + self.binning: str = "1" self._cancel_requested: bool = False self._is_running: bool = False self.cancel_enabled = Model.PropertyModel(False) @@ -82,26 +81,31 @@ def _set_progress(self, value: int, maximum: int, text: str) -> None: def _set_progress_threadsafe(self, value: int, maximum: int, text: str) -> None: self._event_loop.call_soon_threadsafe(self._set_progress, value, maximum, text) - def _build_ui(self) -> typing.Mapping[str, typing.Any]: + @staticmethod + def _build_ui() -> typing.Mapping[str, typing.Any]: u = Declarative.DeclarativeUI() title = u.create_label(text="Overview Scan", font="bold") time_button = u.create_push_button( - text="Estimate time to acquire survey image", + text="Estimate scan size and duration", on_clicked="on_estimate_time_clicked" ) acq_button = u.create_push_button( - text="Perform wide-field acquisition", + text="Scan", on_clicked="on_perform_acquisition_clicked" ) - width_label = u.create_label(text="Desired width of image (um):") + properties_label = u.create_label(text="Desired properties of image:") + width_label = u.create_label(text="Width (um):") width_field = u.create_line_edit(text="@binding(width_value)", editable=True) - height_label = u.create_label(text="Desired height of image (um):") + height_label = u.create_label(text="Height (um):") height_field = u.create_line_edit(text="@binding(height_value)", editable=True) - defocus_label = u.create_label(text="Desired defocus (nm):") + defocus_label = u.create_label(text="Defocus (nm):") defocus = u.create_line_edit(text="@binding(defocus)", editable=True) + reduce_label = u.create_label(text="Binning:") + reduce_val = u.create_line_edit(text="@binding(binning)", editable=True) + output_label = u.create_label(text="Output:") output_box = u.create_text_edit( text="@binding(output_text)", @@ -116,36 +120,32 @@ def _build_ui(self) -> typing.Mapping[str, typing.Any]: width=500 ) cancel_button = u.create_push_button( - text="Cancel acquisition", + text="Cancel", on_clicked="on_cancel_acquisition_clicked", enabled="@binding(cancel_enabled.value)" ) - - return u.create_column( + return typing.cast(typing.Mapping[str, typing.Any], u.create_column( title, - u.create_row(width_label, u.create_spacing(4), width_field), + properties_label, + u.create_row(width_label, u.create_spacing(4), width_field, u.create_spacing(20), height_label, u.create_spacing(4), height_field), u.create_spacing(4), - u.create_row(height_label, u.create_spacing(4), height_field), - u.create_spacing(4), - u.create_row(defocus_label, u.create_spacing(4), defocus), - u.create_spacing(8), - time_button, + u.create_row(defocus_label, u.create_spacing(4), defocus, u.create_spacing(20), reduce_label, u.create_spacing(4), reduce_val), u.create_spacing(8), - acq_button, + u.create_row(time_button, u.create_spacing(20), acq_button), u.create_spacing(8), - output_label, - output_box, - u.create_spacing(50), progress_label, progress_bar, u.create_spacing(8), cancel_button, + u.create_spacing(8), + output_label, + output_box, u.create_stretch(), margin=6, spacing=4 - ) + )) def _append_output(self, message: str) -> None: self.output_text += f"{message}\n" @@ -159,7 +159,6 @@ def on_cancel_acquisition_clicked(self, widget: typing.Any) -> None: self._cancel_requested = True self._set_progress_threadsafe(self.progress_value, 100, "Cancel requested...") - def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: instrument = self.instrument sx0 = instrument.get_control_output("SShft.sx") @@ -194,13 +193,12 @@ def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: return mat - def acquisition(self, - instrument, - camera, - defocus, - target_width_m: tuple[float | int, float | int], timer = False, - reduce: float = 1.0): + instrument: stem_controller_module.STEMController, + camera: camera_base.CameraHardwareSource, + defocus: float, + target_width_m: tuple[float | int, float | int], timer: bool = False, + reduce: float = 1.0) -> tuple[npt.NDArray[numpy.float32], int, float] | tuple[npt.NDArray[numpy.float32], tuple[tuple[int, int], tuple[int, int]], float, float] | tuple[int, float] | None: counter = 0 self._cancel_requested = False @@ -208,7 +206,7 @@ def acquisition(self, self.cancel_enabled.value = True try: - tv_pixel_angle_rad = instrument.get_control_output("TVPixelAngle") + tv_pixel_angle_rad = float(instrument.get_control_output("TVPixelAngle")) except Exception: tv_pixel_angle_rad = None @@ -222,7 +220,7 @@ def acquisition(self, frame = camera.grab_next_to_start()[0] assert frame is not None - tv_pixel_angle_rad = frame.dimensional_calibrations[0].scale + tv_pixel_angle_rad = float(frame.dimensional_calibrations[0].scale) # grab stage original location sx_m = instrument.get_control_output(shift_x_control_name) @@ -233,8 +231,6 @@ def acquisition(self, pixel_size_m = abs(defocus) * math.tan(tv_pixel_angle_rad) image_size = camera.get_expected_dimensions(camera.get_current_frame_parameters()) - image_dtype = numpy.float32 - image_width_m = abs(defocus) * math.sin(tv_pixel_angle_rad * image_size[0]) master_sub_area_size = image_size[0] // 2, image_size[1] // 2 @@ -250,10 +246,9 @@ def acquisition(self, frames_needed_height = math.ceil(target_width_m[1] * 1e-6 / sub_area_shift_m) size = (frames_needed_width, frames_needed_height) - total_images = frames_needed_width* frames_needed_height - - master_data = numpy.empty((sub_area[1][0] * size[0], sub_area[1][1] * size[1]), image_dtype) + total_images = frames_needed_width * frames_needed_height + master_data = typing.cast(npt.NDArray[numpy.float32], numpy.empty((sub_area[1][0] * size[0], sub_area[1][1] * size[1]), dtype=numpy.float32)) if not timer: self._append_output_threadsafe(f"Stage starting position: {sx_m * 1e6, sy_m * 1e6} um") self._append_output_threadsafe(f"Pixel size: {(pixel_size_m * 1e9):.3f} nm") @@ -264,14 +259,10 @@ def acquisition(self, self._set_progress_threadsafe(0, total_images, "Progress:\nStarting acquisition...") - else: - self._append_output_threadsafe(f"Need to acquire {frames_needed_width} x {frames_needed_height} frames for a {target_width_m[0]} um x {target_width_m[1]} um image \n") - - t1 = time.time() if timer: - size = (2,1) + size = (2, 1) else: size = size @@ -338,7 +329,7 @@ def acquisition(self, self._append_output_threadsafe(f"Timeout row= {row} column= {column}") continue break - data = supradata.data[master_sub_area[0][0]:master_sub_area[0][0] + master_sub_area[1][0]:reduce, master_sub_area[0][1]:master_sub_area[0][1] + master_sub_area[1][1]:reduce] + data = numpy.empty(supradata.data[master_sub_area[0][0]:master_sub_area[0][0] + master_sub_area[1][0]:reduce, master_sub_area[0][1]:master_sub_area[0][1] + master_sub_area[1][1]:reduce], dtype=numpy.float32) slice_row = row slice_column = column slice0 = slice(slice_row * sub_area[1][0], (slice_row + 1) * sub_area[1][0]) @@ -347,10 +338,10 @@ def acquisition(self, # inside loop after counter increment or frame write if not timer: - pct = (100 * counter / total_images) - self._set_progress_threadsafe(pct, total_images, f"Progress:\nAcquiring {counter}/{total_images}") + pct = int(100 * counter / total_images) + self._set_progress_threadsafe(pct, total_images, f"Progress:\nAcquiring {counter}/{total_images} frames") t2 = time.time() - time_total = t2-t1 + time_total = t2 - t1 finally: # restore stage to original location instrument.set_control_output(shift_x_control_name, sx_m) @@ -360,60 +351,70 @@ def acquisition(self, if timer: self.cancel_enabled.value = False - return total_images, time_total + return master_data, total_images, time_total else: self.cancel_enabled.value = False return master_data, sub_area, sub_area_shift_m, pixel_size_m - def on_estimate_time_clicked(self, widget: typing.Any) -> None: try: width_um = int(self.width_value) height_um = int(self.height_value) defocus_nm = int(self.defocus) * 1e-9 + reduce = int(self.binning) except ValueError: - self._append_output("Please enter width, height, and defocus as integers.") + self._append_output("Please enter width, height, binning and defocus as integers.") return - - if width_um < 1 or height_um < 1: + if width_um < 1 or height_um < 1 or reduce < 1: self._append_output("Please ensure width and height are positive.") return if width_um >= 1000 or height_um >= 1000: self._append_output("Warning: Requested scan size is outside of sensible limit") return + if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 500000: self._append_output("Warning: Requested defocus is outside of sensible limit") return - instrument = self.instrument camera = self.camera - reduce = 1.0 - target_width_m = (width_um, height_um) - total_images, t_total = self.acquisition(instrument, camera, defocus_nm, target_width_m, timer=True, reduce=reduce) - time_taken = t_total * total_images / 2 #average time to move the stage + target_width_m = (width_um, height_um) + result = self.acquisition(instrument, camera, defocus_nm, target_width_m, timer=True, reduce=reduce) + if result is None or len(result) != 3: + return + master_data, total_images, t_total = result + image_size = master_data.shape + time_taken = t_total * total_images / 2 # average time to move the stage self._append_output( f"This acquisition will take approximately {(time_taken // 3600):.0f}h {((time_taken % 3600) / 60):.0f}min {(time_taken % 60):.0f}s" ) + self._append_output(f"The size of the final data item will be {image_size}.\n") + if any(dim > 32768 for dim in image_size): + self._append_output("The final data item is too large to be used in the sample navigation window. Consider increasing the binning or reducing the size of the acquisition.\n") + return + else: + return async def _run_acquisition_async( self, - instrument, - camera, + instrument: stem_controller_module.STEMController, + camera: camera_base.CameraHardwareSource, defocus_nm: float, target_width_um: tuple[int, int], + reduce: int ) -> None: loop = self._event_loop self._append_output_threadsafe("Starting acquisition...\n") try: - master_data, sub_area, sub_area_shift_m, pixel_size_m = await loop.run_in_executor( - None, - lambda: self.acquisition(instrument, camera, defocus_nm, target_width_um, False, 1.0), + result = await loop.run_in_executor( + None, self.acquisition, instrument, camera, defocus_nm, target_width_um, False, reduce ) - if master_data is None: + if result is None or len(result) != 4: self._set_progress(0, 100, "Progress:\nIdle") return + + master_data, sub_area, sub_area_shift_m, pixel_size_m = result except Exception as e: self._append_output(f"Acquisition failed: {e!r}") self.cancel_enabled.value = False @@ -434,8 +435,7 @@ async def _run_acquisition_async( ) library.create_data_item_from_data_and_metadata(xdata, "Composite Survey") - # library.create_data_item_from_data(master_data, "Composite Survey") - # self._append_output("Acquisition complete.\n") + self._append_output("Acquisition complete.\n") except Exception as e: self._append_output(f"Failed to publish result: {e!r}") self.cancel_enabled.value = False @@ -445,13 +445,19 @@ def on_perform_acquisition_clicked(self, widget: typing.Any) -> None: width_um = int(self.width_value) height_um = int(self.height_value) defocus_nm = int(self.defocus) * 1e-9 + reduce = int(self.binning) except ValueError: - self._append_output("Please enter width, height, and defocus as integers.") + self._append_output("Please enter width, height, binning and defocus as integers.") return - - if width_um <= 0 or height_um <= 0: + if width_um < 1 or height_um < 1 or reduce < 1: self._append_output("Please ensure width and height are positive.") return + if width_um >= 1000 or height_um >= 1000: + self._append_output("Warning: Requested scan size is outside of sensible limit") + return + if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 500000: + self._append_output("Warning: Requested defocus is outside of sensible limit") + return if self._acq_task and not self._acq_task.done(): self._append_output("Acquisition already running.") @@ -462,14 +468,15 @@ def on_perform_acquisition_clicked(self, widget: typing.Any) -> None: target_width_um = (width_um, height_um) self._acq_task = self._event_loop.create_task( - self._run_acquisition_async(instrument, camera, defocus_nm, target_width_um) + self._run_acquisition_async(instrument, camera, defocus_nm, target_width_um, reduce) ) self.cancel_enabled.value = False # --------------------------------------------------------------------------- # Swift Panel wrapper # --------------------------------------------------------------------------- -class SamplePanel(Panel.Panel): + +class OverviewScanPanel(Panel.Panel): # type: ignore[misc] """Swift panel class instantiated by the Workspace panel manager.""" def __init__( @@ -494,50 +501,21 @@ def __init__( break -class PanelSampleExtension: +class OverviewScanPanelExtension: # required for Swift to recognize this as an extension class. extension_id = "sample.panel" - def __init__(self, api_broker): - self.__component = Registry.register_component(SamplePanelUI(), {"overview-scan-panel"}) + def __init__(self, api_broker: typing.Any) -> None: + self.__component = Registry.register_component(OverviewScanPanelUI(), {"overview-scan-panel"}) self.__panel = Workspace.WorkspaceManager().register_panel( - SamplePanel, - "sample-main-panel", + OverviewScanPanel, + "overview-scan-main-panel", _("Overview Scan"), ["left", "right"], "right", {"panel_type": "overview-scan-panel"}, ) - def close(self): + def close(self) -> None: pass - - -# class SampleMenuItemDelegate: -# -# def __init__(self, api): -# self.__api = api -# self.menu_id = "example_menu" # required, specify menu_id where this item will go -# self.menu_name = _("Examples") # optional, specify default name if not a standard menu -# self.menu_before_id = "window_menu" # optional, specify before menu_id if not a standard menu -# self.menu_item_name = _("Run Sample") # menu item name -# -# def menu_item_execute(self, window): -# sampler.sample_function() -# -# -# class MenuSampleExtension: -# -# # required for Swift to recognize this as an extension class. -# extension_id = "sample.menu_item_call_sample" -# -# def __init__(self, api_broker): -# # grab the api object. -# api = api_broker.get_api(version="~1.0") -# # be sure to keep a reference or it will be closed immediately. -# self.__menu_item_ref = api.create_menu_item(SampleMenuItemDelegate(api)) -# -# def close(self): -# self.__menu_item_ref.close() -# self.__menu_item_ref = None From 834d246d3ec39343499e14170fba334b73609498 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Fri, 4 Sep 2026 13:34:37 +0100 Subject: [PATCH 06/31] placating mypy --- .../nion_experimental_tools/overview_scan_panel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index 15d2a20..736a220 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -329,7 +329,7 @@ def acquisition(self, self._append_output_threadsafe(f"Timeout row= {row} column= {column}") continue break - data = numpy.empty(supradata.data[master_sub_area[0][0]:master_sub_area[0][0] + master_sub_area[1][0]:reduce, master_sub_area[0][1]:master_sub_area[0][1] + master_sub_area[1][1]:reduce], dtype=numpy.float32) + data = supradata.data[master_sub_area[0][0]:master_sub_area[0][0] + master_sub_area[1][0]:reduce, master_sub_area[0][1]:master_sub_area[0][1] + master_sub_area[1][1]:reduce] slice_row = row slice_column = column slice0 = slice(slice_row * sub_area[1][0], (slice_row + 1) * sub_area[1][0]) @@ -507,8 +507,8 @@ class OverviewScanPanelExtension: extension_id = "sample.panel" def __init__(self, api_broker: typing.Any) -> None: - self.__component = Registry.register_component(OverviewScanPanelUI(), {"overview-scan-panel"}) - self.__panel = Workspace.WorkspaceManager().register_panel( + Registry.register_component(OverviewScanPanelUI(), {"overview-scan-panel"}) + Workspace.WorkspaceManager().register_panel( OverviewScanPanel, "overview-scan-main-panel", _("Overview Scan"), From dda72ef83f62cf50ad5ddb86a1fc026dbbbe3dff Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Fri, 4 Sep 2026 13:36:40 +0100 Subject: [PATCH 07/31] placating mypy --- nionswift_plugin/nion_experimental_tools/overview_scan_panel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index 736a220..fddcf11 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -519,3 +519,4 @@ def __init__(self, api_broker: typing.Any) -> None: def close(self) -> None: pass + From 7556b90963e2053f7887e41b554779601b31056c Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Fri, 4 Sep 2026 13:47:05 +0100 Subject: [PATCH 08/31] placating mypy --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 5c035b6..ec35fab 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -8,5 +8,5 @@ git+https://github.com/nion-software/niondata.git#egg=niondata git+https://github.com/nion-software/nionui.git#egg=nionui git+https://github.com/nion-software/nionswift-io.git#egg=nionswift-io git+https://github.com/nion-software/nionswift.git#egg=nionswift - +git+https://github.com/nion-software/nionswift-instrumentation-kit.git#egg=nionswift-instrumentation numpy From a0da62e618c9286570c5ff8e791aa93bb089c674 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Fri, 4 Sep 2026 16:26:43 +0100 Subject: [PATCH 09/31] things --- .../overview_scan_panel.py | 76 ++++++++++--------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index fddcf11..375449d 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -1,8 +1,11 @@ -# standard libraries import gettext import typing -# nionswift libraries +import time +import math +import numpy +import asyncio + from nion.swift import Panel from nion.swift import Workspace from nion.swift import DocumentController @@ -12,16 +15,10 @@ from nion.utils import Model from nion.typeshed import API_1_0 -import time -import math -import numpy - from nion.instrumentation import camera_base from nion.instrumentation import stem_controller as stem_controller_module import numpy.typing as npt -import asyncio - _ = gettext.gettext @@ -223,38 +220,38 @@ def acquisition(self, tv_pixel_angle_rad = float(frame.dimensional_calibrations[0].scale) # grab stage original location - sx_m = instrument.get_control_output(shift_x_control_name) - sy_m = instrument.get_control_output(shift_y_control_name) + sx_um = instrument.get_control_output(shift_x_control_name) + sy_um = instrument.get_control_output(shift_y_control_name) df_original = instrument.get_control_output("C10") instrument.set_control_output("C10", defocus) - pixel_size_m = abs(defocus) * math.tan(tv_pixel_angle_rad) + pixel_size_nm = abs(defocus) * math.tan(tv_pixel_angle_rad) image_size = camera.get_expected_dimensions(camera.get_current_frame_parameters()) - image_width_m = abs(defocus) * math.sin(tv_pixel_angle_rad * image_size[0]) + image_width_um = abs(defocus) * math.sin(tv_pixel_angle_rad * image_size[0]) master_sub_area_size = image_size[0] // 2, image_size[1] // 2 master_sub_area = (image_size[0] // 2 - master_sub_area_size[0] // 2, image_size[1] // 2 - master_sub_area_size[1] // 2), master_sub_area_size reduce = max(1, int(reduce)) - sub_area_shift_m = image_width_m * (master_sub_area[1][0] / image_size[0]) + sub_area_shift_um = image_width_um * (master_sub_area[1][0] / image_size[0]) sub_area = (master_sub_area[0][0] // reduce, master_sub_area[0][1] // reduce), (master_sub_area[1][0] // reduce, master_sub_area[1][1] // reduce) - frames_needed_width = math.ceil(target_width_m[0] * 1e-6 / sub_area_shift_m) - frames_needed_height = math.ceil(target_width_m[1] * 1e-6 / sub_area_shift_m) + frames_needed_width = math.ceil(target_width_m[0] * 1e-6 / sub_area_shift_um) + frames_needed_height = math.ceil(target_width_m[1] * 1e-6 / sub_area_shift_um) size = (frames_needed_width, frames_needed_height) - + total_image_height = size[1] * image_width_um total_images = frames_needed_width * frames_needed_height master_data = typing.cast(npt.NDArray[numpy.float32], numpy.empty((sub_area[1][0] * size[0], sub_area[1][1] * size[1]), dtype=numpy.float32)) if not timer: - self._append_output_threadsafe(f"Stage starting position: {sx_m * 1e6, sy_m * 1e6} um") - self._append_output_threadsafe(f"Pixel size: {(pixel_size_m * 1e9):.3f} nm") + self._append_output_threadsafe(f"Stage starting position: {sx_um * 1e6, sy_um * 1e6} um") + self._append_output_threadsafe(f"Pixel size: {(pixel_size_nm * 1e9):.3f} nm") self._append_output_threadsafe(f"Defocus: {(defocus * 1e9):.0f} nm") - self._append_output_threadsafe(f"Image width: {image_width_m * 1e6} um") + self._append_output_threadsafe(f"Frame width: {image_width_um * 1e6} um") self._append_output_threadsafe(f"Master size: {master_data.shape}\n") self._set_progress_threadsafe(0, total_images, "Progress:\nStarting acquisition...") @@ -281,17 +278,17 @@ def acquisition(self, return None if not timer else (0, 0.0) if shift_x_control_name == "stage_position_m.x": - delta_x_m = - sub_area_shift_m * (column - size[1] // 2) - delta_y_m = - sub_area_shift_m * (row - size[0] // 2) + delta_x_um = - sub_area_shift_um * (column - size[1] // 2) + delta_y_um = - sub_area_shift_um * (row - size[0] // 2) else: matrix = self.find_matrix() - delta_x_m = - sub_area_shift_m * (column - size[1] // 2) - delta_y_m = - sub_area_shift_m * (row - size[0] // 2) - delta_camera = numpy.array([delta_x_m, delta_y_m], dtype=numpy.float64) + delta_x_um = - sub_area_shift_um * (column - size[1] // 2) + delta_y_um = - sub_area_shift_um * (row - size[0] // 2) + delta_camera = numpy.array([delta_x_um, delta_y_um], dtype=numpy.float64) delta_fast = numpy.linalg.solve(matrix, delta_camera) - delta_x_m = float(delta_fast[0]) - delta_y_m = float(delta_fast[1]) + delta_x_um = float(delta_fast[0]) + delta_y_um = float(delta_fast[1]) counter += 1 @@ -304,8 +301,8 @@ def acquisition(self, attempts += 1 try: tolerance_factor = 0.0001 - instrument.set_control_output(shift_x_control_name, sx_m - delta_x_m, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) - instrument.set_control_output(shift_y_control_name, sy_m - delta_y_m, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + instrument.set_control_output(shift_x_control_name, sx_um - delta_x_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + instrument.set_control_output(shift_y_control_name, sy_um - delta_y_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) except TimeoutError: self._append_output_threadsafe(f"Timeout row= {row} column= {column}") continue @@ -323,8 +320,8 @@ def acquisition(self, attempts += 1 try: tolerance_factor = 0.0001 - instrument.set_control_output(shift_x_control_name, sx_m, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) - instrument.set_control_output(shift_y_control_name, sy_m, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + instrument.set_control_output(shift_x_control_name, sx_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + instrument.set_control_output(shift_y_control_name, sy_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) except TimeoutError: self._append_output_threadsafe(f"Timeout row= {row} column= {column}") continue @@ -344,8 +341,8 @@ def acquisition(self, time_total = t2 - t1 finally: # restore stage to original location - instrument.set_control_output(shift_x_control_name, sx_m) - instrument.set_control_output(shift_y_control_name, sy_m) + instrument.set_control_output(shift_x_control_name, sx_um) + instrument.set_control_output(shift_y_control_name, sy_um) instrument.set_control_output("C10", df_original) self._set_progress_threadsafe(0, 100, "Progress:\n Idle") @@ -354,7 +351,7 @@ def acquisition(self, return master_data, total_images, time_total else: self.cancel_enabled.value = False - return master_data, sub_area, sub_area_shift_m, pixel_size_m + return master_data, sub_area, sub_area_shift_um, pixel_size_nm, total_image_height def on_estimate_time_clicked(self, widget: typing.Any) -> None: try: @@ -378,8 +375,8 @@ def on_estimate_time_clicked(self, widget: typing.Any) -> None: instrument = self.instrument camera = self.camera - target_width_m = (width_um, height_um) - result = self.acquisition(instrument, camera, defocus_nm, target_width_m, timer=True, reduce=reduce) + target_width_um = (width_um, height_um) + result = self.acquisition(instrument, camera, defocus_nm, target_width_um, timer=True, reduce=reduce) if result is None or len(result) != 3: return master_data, total_images, t_total = result @@ -410,11 +407,11 @@ async def _run_acquisition_async( result = await loop.run_in_executor( None, self.acquisition, instrument, camera, defocus_nm, target_width_um, False, reduce ) - if result is None or len(result) != 4: + if result is None or len(result) != 5: self._set_progress(0, 100, "Progress:\nIdle") return - master_data, sub_area, sub_area_shift_m, pixel_size_m = result + master_data, sub_area, sub_area_shift_m, pixel_size_m, total_image_height = result except Exception as e: self._append_output(f"Acquisition failed: {e!r}") self.cancel_enabled.value = False @@ -436,6 +433,11 @@ async def _run_acquisition_async( library.create_data_item_from_data_and_metadata(xdata, "Composite Survey") self._append_output("Acquisition complete.\n") + + self._append_output("Image properties:") + self._append_output_threadsafe(f"Total image height: {total_image_height * 1e3} mm") + self._append_output(f"x offset: {x_scale_um} um") + self._append_output(f"y offset: {y_scale_um} um") except Exception as e: self._append_output(f"Failed to publish result: {e!r}") self.cancel_enabled.value = False From 50c3955b4c2c21f8dc978f74d6497dc72d167251 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Mon, 7 Sep 2026 11:37:19 +0100 Subject: [PATCH 10/31] placating mypy (again) --- mypy.ini | 1 + .../overview_scan_panel.py | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/mypy.ini b/mypy.ini index 3a5f52c..c94785d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -6,5 +6,6 @@ follow_imports = silent strict = True warn_redundant_casts = False warn_unused_ignores = False +mypy_path = ../nionutils,../nionui,../nionswift,../niondata,../nion-instrumentation packages = nion.experimental, nionswift_plugin.drift_correction, nionswift_plugin.nion_experimental_4dtools, nionswift_plugin.nion_experimental_tools exclude = (/test|/docs) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index 375449d..a77d94a 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -36,7 +36,7 @@ def get_ui_handler( return OverviewSamplePanelHandler(api, event_loop, document_controller) -class OverviewSamplePanelHandler(Declarative.Handler): # type: ignore[misc] +class OverviewSamplePanelHandler(Declarative.Handler): """Declarative handler for the Sample docked panel.""" def __init__( @@ -195,8 +195,18 @@ def acquisition(self, camera: camera_base.CameraHardwareSource, defocus: float, target_width_m: tuple[float | int, float | int], timer: bool = False, - reduce: float = 1.0) -> tuple[npt.NDArray[numpy.float32], int, float] | tuple[npt.NDArray[numpy.float32], tuple[tuple[int, int], tuple[int, int]], float, float] | tuple[int, float] | None: - + reduce: float = 1.0) -> ( + tuple[npt.NDArray[numpy.float32], int, float] # timer=True + | tuple[ + npt.NDArray[numpy.float32], + tuple[tuple[int, int], tuple[int, int]], + float, + float, + float, # total_image_height + ] # timer=False + | tuple[int, float] # cancel path in timer mode + | None # cancel path in non-timer mode +): counter = 0 self._cancel_requested = False self._is_running = True @@ -478,7 +488,7 @@ def on_perform_acquisition_clicked(self, widget: typing.Any) -> None: # --------------------------------------------------------------------------- -class OverviewScanPanel(Panel.Panel): # type: ignore[misc] +class OverviewScanPanel(Panel.Panel): """Swift panel class instantiated by the Workspace panel manager.""" def __init__( From 2aedbe3c5b756aaa056a3ec3b00f547638cf92e6 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Tue, 8 Sep 2026 10:08:42 +0100 Subject: [PATCH 11/31] push image to as2 minimap --- nionswift_plugin/nion_experimental_tools/overview_scan_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index a77d94a..31eab0d 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -516,7 +516,7 @@ def __init__( class OverviewScanPanelExtension: # required for Swift to recognize this as an extension class. - extension_id = "sample.panel" + extension_id = "overview-scan.panel" def __init__(self, api_broker: typing.Any) -> None: Registry.register_component(OverviewScanPanelUI(), {"overview-scan-panel"}) From dc7861ee7e2fc927f84f7f1f16c6219bf6e80e6c Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Tue, 8 Sep 2026 10:18:20 +0100 Subject: [PATCH 12/31] push image to as2 minimap --- .../overview_scan_panel.py | 175 ++++++++++-------- 1 file changed, 99 insertions(+), 76 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index 31eab0d..b2e134b 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -1,23 +1,24 @@ +import asyncio import gettext +import math +import time import typing +from pathlib import Path -import time -import math import numpy -import asyncio +import numpy.typing as npt +from PIL import Image +from nion.instrumentation import camera_base +from nion.instrumentation import stem_controller as stem_controller_module +from nion.swift import DocumentController from nion.swift import Panel from nion.swift import Workspace -from nion.swift import DocumentController from nion.swift.model import PlugInManager +from nion.typeshed import API_1_0 from nion.ui import Declarative -from nion.utils import Registry from nion.utils import Model -from nion.typeshed import API_1_0 - -from nion.instrumentation import camera_base -from nion.instrumentation import stem_controller as stem_controller_module -import numpy.typing as npt +from nion.utils import Registry _ = gettext.gettext @@ -49,8 +50,8 @@ def __init__( super().__init__() self._api = api self._event_loop = event_loop or asyncio.get_event_loop() - self.instrument = typing.cast(stem_controller_module.STEMController, Registry.get_component('stem_controller')) - self.camera = typing.cast(camera_base.CameraHardwareSource, self.instrument.ronchigram_camera) + self.stem_controller = typing.cast(stem_controller_module.STEMController, Registry.get_component('stem_controller')) + self.camera = typing.cast(camera_base.CameraHardwareSource, self.stem_controller.ronchigram_camera) self._document_controller = document_controller self.output_text: str = "" self.progress_value: int = 0 @@ -157,28 +158,28 @@ def on_cancel_acquisition_clicked(self, widget: typing.Any) -> None: self._set_progress_threadsafe(self.progress_value, 100, "Cancel requested...") def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: - instrument = self.instrument - sx0 = instrument.get_control_output("SShft.sx") - sy0 = instrument.get_control_output("SShft.sy") - x0 = instrument.get_control_output("SShft.x") - y0 = instrument.get_control_output("SShft.y") + stem_controller = self.stem_controller + sx0 = stem_controller.get_control_output("SShft.sx") + sy0 = stem_controller.get_control_output("SShft.sy") + x0 = stem_controller.get_control_output("SShft.x") + y0 = stem_controller.get_control_output("SShft.y") - instrument.set_control_output("SShft.sx", sx0 + ds) - x1 = instrument.get_control_output("SShft.x") - y1 = instrument.get_control_output("SShft.y") + stem_controller.set_control_output("SShft.sx", sx0 + ds) + x1 = stem_controller.get_control_output("SShft.x") + y1 = stem_controller.get_control_output("SShft.y") dx_from_sx = x1 - x0 dy_from_sx = y1 - y0 - instrument.set_control_output("SShft.sx", sx0) - instrument.set_control_output("SShft.sy", sy0) - instrument.set_control_output("SShft.x", x0) - instrument.set_control_output("SShft.y", y0) + stem_controller.set_control_output("SShft.sx", sx0) + stem_controller.set_control_output("SShft.sy", sy0) + stem_controller.set_control_output("SShft.x", x0) + stem_controller.set_control_output("SShft.y", y0) - instrument.set_control_output("SShft.sy", sy0 + ds) - x2 = instrument.get_control_output("SShft.x") - y2 = instrument.get_control_output("SShft.y") - instrument.set_control_output("SShft.sy", sy0) + stem_controller.set_control_output("SShft.sy", sy0 + ds) + x2 = stem_controller.get_control_output("SShft.x") + y2 = stem_controller.get_control_output("SShft.y") + stem_controller.set_control_output("SShft.sy", sy0) dx_from_sy = x2 - x0 dy_from_sy = y2 - y0 @@ -191,50 +192,41 @@ def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: return mat def acquisition(self, - instrument: stem_controller_module.STEMController, + stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, defocus: float, - target_width_m: tuple[float | int, float | int], timer: bool = False, - reduce: float = 1.0) -> ( - tuple[npt.NDArray[numpy.float32], int, float] # timer=True - | tuple[ - npt.NDArray[numpy.float32], - tuple[tuple[int, int], tuple[int, int]], - float, - float, - float, # total_image_height - ] # timer=False - | tuple[int, float] # cancel path in timer mode - | None # cancel path in non-timer mode -): + target_width_um: tuple[float | int, float | int], timer: bool = False, + reduce: float = 1.0) -> (tuple[npt.NDArray[numpy.float64], int, float] | + tuple[npt.NDArray[numpy.float64], tuple[tuple[int, int], tuple[int, int]], float, float, float, float, float] | + tuple[int, float] |None): counter = 0 self._cancel_requested = False self._is_running = True self.cancel_enabled.value = True - try: - tv_pixel_angle_rad = float(instrument.get_control_output("TVPixelAngle")) - except Exception: - tv_pixel_angle_rad = None + success, tv_pixel_angle_rad = stem_controller.TryGetVal("TVPixelAngle") - if tv_pixel_angle_rad is not None: + if success: shift_x_control_name = "SShft.sx" shift_y_control_name = "SShft.sy" + matrix = self.find_matrix() else: shift_x_control_name = "stage_position_m.x" shift_y_control_name = "stage_position_m.y" + matrix = None frame = camera.grab_next_to_start()[0] assert frame is not None tv_pixel_angle_rad = float(frame.dimensional_calibrations[0].scale) - # grab stage original location - sx_um = instrument.get_control_output(shift_x_control_name) - sy_um = instrument.get_control_output(shift_y_control_name) - df_original = instrument.get_control_output("C10") + # grab stage original location and original defocus + sx_um = stem_controller.get_control_output(shift_x_control_name) + sy_um = stem_controller.get_control_output(shift_y_control_name) + df_original = stem_controller.get_control_output("C10") - instrument.set_control_output("C10", defocus) + assert tv_pixel_angle_rad is not None + stem_controller.set_control_output("C10", defocus) pixel_size_nm = abs(defocus) * math.tan(tv_pixel_angle_rad) image_size = camera.get_expected_dimensions(camera.get_current_frame_parameters()) @@ -249,13 +241,13 @@ def acquisition(self, sub_area = (master_sub_area[0][0] // reduce, master_sub_area[0][1] // reduce), (master_sub_area[1][0] // reduce, master_sub_area[1][1] // reduce) - frames_needed_width = math.ceil(target_width_m[0] * 1e-6 / sub_area_shift_um) - frames_needed_height = math.ceil(target_width_m[1] * 1e-6 / sub_area_shift_um) + frames_needed_width = math.ceil(target_width_um[0] * 1e-6 / sub_area_shift_um) + frames_needed_height = math.ceil(target_width_um[1] * 1e-6 / sub_area_shift_um) size = (frames_needed_width, frames_needed_height) total_image_height = size[1] * image_width_um total_images = frames_needed_width * frames_needed_height - master_data = typing.cast(npt.NDArray[numpy.float32], numpy.empty((sub_area[1][0] * size[0], sub_area[1][1] * size[1]), dtype=numpy.float32)) + master_data = numpy.empty((sub_area[1][0] * size[0], sub_area[1][1] * size[1])) if not timer: self._append_output_threadsafe(f"Stage starting position: {sx_um * 1e6, sy_um * 1e6} um") self._append_output_threadsafe(f"Pixel size: {(pixel_size_nm * 1e9):.3f} nm") @@ -287,7 +279,7 @@ def acquisition(self, self.cancel_enabled.value = False return None if not timer else (0, 0.0) - if shift_x_control_name == "stage_position_m.x": + if matrix is None or numpy.linalg.det(matrix) == 0 or len(matrix) == 0: delta_x_um = - sub_area_shift_um * (column - size[1] // 2) delta_y_um = - sub_area_shift_um * (row - size[0] // 2) else: @@ -311,8 +303,8 @@ def acquisition(self, attempts += 1 try: tolerance_factor = 0.0001 - instrument.set_control_output(shift_x_control_name, sx_um - delta_x_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) - instrument.set_control_output(shift_y_control_name, sy_um - delta_y_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + stem_controller.set_control_output(shift_x_control_name, sx_um - delta_x_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + stem_controller.set_control_output(shift_y_control_name, sy_um - delta_y_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) except TimeoutError: self._append_output_threadsafe(f"Timeout row= {row} column= {column}") continue @@ -330,8 +322,8 @@ def acquisition(self, attempts += 1 try: tolerance_factor = 0.0001 - instrument.set_control_output(shift_x_control_name, sx_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) - instrument.set_control_output(shift_y_control_name, sy_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + stem_controller.set_control_output(shift_x_control_name, sx_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + stem_controller.set_control_output(shift_y_control_name, sy_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) except TimeoutError: self._append_output_threadsafe(f"Timeout row= {row} column= {column}") continue @@ -351,9 +343,9 @@ def acquisition(self, time_total = t2 - t1 finally: # restore stage to original location - instrument.set_control_output(shift_x_control_name, sx_um) - instrument.set_control_output(shift_y_control_name, sy_um) - instrument.set_control_output("C10", df_original) + stem_controller.set_control_output(shift_x_control_name, sx_um) + stem_controller.set_control_output(shift_y_control_name, sy_um) + stem_controller.set_control_output("C10", df_original) self._set_progress_threadsafe(0, 100, "Progress:\n Idle") if timer: @@ -361,7 +353,7 @@ def acquisition(self, return master_data, total_images, time_total else: self.cancel_enabled.value = False - return master_data, sub_area, sub_area_shift_um, pixel_size_nm, total_image_height + return master_data, sub_area, sub_area_shift_um, pixel_size_nm, total_image_height, sx_um, sy_um def on_estimate_time_clicked(self, widget: typing.Any) -> None: try: @@ -382,11 +374,11 @@ def on_estimate_time_clicked(self, widget: typing.Any) -> None: if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 500000: self._append_output("Warning: Requested defocus is outside of sensible limit") return - instrument = self.instrument + stem_controller = self.stem_controller camera = self.camera target_width_um = (width_um, height_um) - result = self.acquisition(instrument, camera, defocus_nm, target_width_um, timer=True, reduce=reduce) + result = self.acquisition(stem_controller, camera, defocus_nm, target_width_um, timer=True, reduce=reduce) if result is None or len(result) != 3: return master_data, total_images, t_total = result @@ -404,7 +396,7 @@ def on_estimate_time_clicked(self, widget: typing.Any) -> None: async def _run_acquisition_async( self, - instrument: stem_controller_module.STEMController, + stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, defocus_nm: float, target_width_um: tuple[int, int], @@ -415,13 +407,13 @@ async def _run_acquisition_async( self._append_output_threadsafe("Starting acquisition...\n") try: result = await loop.run_in_executor( - None, self.acquisition, instrument, camera, defocus_nm, target_width_um, False, reduce + None, self.acquisition, stem_controller, camera, defocus_nm, target_width_um, False, reduce ) - if result is None or len(result) != 5: + if result is None or len(result) != 7: self._set_progress(0, 100, "Progress:\nIdle") return - master_data, sub_area, sub_area_shift_m, pixel_size_m, total_image_height = result + master_data, sub_area, sub_area_shift_m, pixel_size_m, total_image_height, sx_um, sy_um = result except Exception as e: self._append_output(f"Acquisition failed: {e!r}") self.cancel_enabled.value = False @@ -446,11 +438,43 @@ async def _run_acquisition_async( self._append_output("Image properties:") self._append_output_threadsafe(f"Total image height: {total_image_height * 1e3} mm") - self._append_output(f"x offset: {x_scale_um} um") - self._append_output(f"y offset: {y_scale_um} um") + self._append_output_threadsafe(f"Original stage coordinates: {sx_um * 1e6, sy_um * 1e6} um") + + data_array = numpy.array(xdata) + data_uint8 = ((data_array - data_array.min()) / (data_array.max() - data_array.min()) * 255).astype(numpy.uint8) + + img = Image.fromarray(data_uint8) + export_path = Path(r"C:\Users\Elizabeth.Wylie\Pictures\overview-scan.jpg") + if not export_path.parent.exists(): + export_path.parent.mkdir(parents=True, exist_ok=True) + + background = img.save(export_path) + + except Exception as e: self._append_output(f"Failed to publish result: {e!r}") self.cancel_enabled.value = False + return + + try: + result = stem_controller._get_rest_api("/exchange?property=CartridgeInStage") + if result.is_valid: + cartridge_string = result.value + self._append_output_threadsafe(f"Cartridge in stage: {cartridge_string}") + + # Set the values on the cartridge + stem_controller._put_rest_api("/exchange/cartridges/" + str(cartridge_string) + "/ImageScaleRad_m", content=total_image_height) + stem_controller._put_rest_api("/exchange/cartridges/" + str(cartridge_string) + "/ImageOffsetX_px", content=sx_um/pixel_size_m) + stem_controller._put_rest_api("/exchange/cartridges/" + str(cartridge_string) + "/ImageOffsetY_px", content=sy_um/pixel_size_m) + stem_controller._put_rest_api("/exchange/cartridges/" + str(cartridge_string) + "/ImageFile", content=background) + else: + self._append_output_threadsafe(f"Failed to get CartridgeInStage: {result.exception}") + return + + except Exception as e: + self._append_output(f"Failed to update cartridge data: {e!r}") + self.cancel_enabled.value = False + return def on_perform_acquisition_clicked(self, widget: typing.Any) -> None: try: @@ -467,7 +491,7 @@ def on_perform_acquisition_clicked(self, widget: typing.Any) -> None: if width_um >= 1000 or height_um >= 1000: self._append_output("Warning: Requested scan size is outside of sensible limit") return - if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 500000: + if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 200000: self._append_output("Warning: Requested defocus is outside of sensible limit") return @@ -475,12 +499,12 @@ def on_perform_acquisition_clicked(self, widget: typing.Any) -> None: self._append_output("Acquisition already running.") return - instrument = self.instrument + stem_controller = self.stem_controller camera = self.camera target_width_um = (width_um, height_um) self._acq_task = self._event_loop.create_task( - self._run_acquisition_async(instrument, camera, defocus_nm, target_width_um, reduce) + self._run_acquisition_async(stem_controller, camera, defocus_nm, target_width_um, reduce) ) self.cancel_enabled.value = False # --------------------------------------------------------------------------- @@ -531,4 +555,3 @@ def __init__(self, api_broker: typing.Any) -> None: def close(self) -> None: pass - From 4e6398362e386122d8039946a7308df6c251056a Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Tue, 8 Sep 2026 13:44:19 +0100 Subject: [PATCH 13/31] push image to as2 minimap --- .../nion_experimental_tools/overview_scan_panel.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index b2e134b..b95a750 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -198,7 +198,7 @@ def acquisition(self, target_width_um: tuple[float | int, float | int], timer: bool = False, reduce: float = 1.0) -> (tuple[npt.NDArray[numpy.float64], int, float] | tuple[npt.NDArray[numpy.float64], tuple[tuple[int, int], tuple[int, int]], float, float, float, float, float] | - tuple[int, float] |None): + tuple[int, float] | None): counter = 0 self._cancel_requested = False self._is_running = True @@ -448,8 +448,7 @@ async def _run_acquisition_async( if not export_path.parent.exists(): export_path.parent.mkdir(parents=True, exist_ok=True) - background = img.save(export_path) - + img.save(export_path) except Exception as e: self._append_output(f"Failed to publish result: {e!r}") @@ -462,11 +461,12 @@ async def _run_acquisition_async( cartridge_string = result.value self._append_output_threadsafe(f"Cartridge in stage: {cartridge_string}") + properties = {"ImageScaleRad_m": total_image_height, "ImageOffsetX_px": sx_um / pixel_size_m, "ImageOffsetY_px": sy_um / pixel_size_m, "ImageFile": str(export_path)} + # Set the values on the cartridge - stem_controller._put_rest_api("/exchange/cartridges/" + str(cartridge_string) + "/ImageScaleRad_m", content=total_image_height) - stem_controller._put_rest_api("/exchange/cartridges/" + str(cartridge_string) + "/ImageOffsetX_px", content=sx_um/pixel_size_m) - stem_controller._put_rest_api("/exchange/cartridges/" + str(cartridge_string) + "/ImageOffsetY_px", content=sy_um/pixel_size_m) - stem_controller._put_rest_api("/exchange/cartridges/" + str(cartridge_string) + "/ImageFile", content=background) + + stem_controller._put_rest_api(f"/exchange/cartridges/{cartridge_string}", content=properties) + else: self._append_output_threadsafe(f"Failed to get CartridgeInStage: {result.exception}") return From 71c3be185ec54d627498455f56b36a05e5cf8775 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Tue, 8 Sep 2026 14:04:49 +0100 Subject: [PATCH 14/31] placating mypy (again again) --- .../overview_scan_panel.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index b95a750..d116a78 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -21,7 +21,7 @@ from nion.utils import Registry _ = gettext.gettext - +JSONDict = dict[str, typing.Any] class OverviewScanPanelUI: panel_type = "overview-scan-panel" @@ -283,7 +283,6 @@ def acquisition(self, delta_x_um = - sub_area_shift_um * (column - size[1] // 2) delta_y_um = - sub_area_shift_um * (row - size[0] // 2) else: - matrix = self.find_matrix() delta_x_um = - sub_area_shift_um * (column - size[1] // 2) delta_y_um = - sub_area_shift_um * (row - size[0] // 2) delta_camera = numpy.array([delta_x_um, delta_y_um], dtype=numpy.float64) @@ -444,7 +443,7 @@ async def _run_acquisition_async( data_uint8 = ((data_array - data_array.min()) / (data_array.max() - data_array.min()) * 255).astype(numpy.uint8) img = Image.fromarray(data_uint8) - export_path = Path(r"C:\Users\Elizabeth.Wylie\Pictures\overview-scan.jpg") + export_path = Path(r"C:\AS2\AS2User\Pictures\overview-scan.jpg") if not export_path.parent.exists(): export_path.parent.mkdir(parents=True, exist_ok=True) @@ -456,19 +455,20 @@ async def _run_acquisition_async( return try: - result = stem_controller._get_rest_api("/exchange?property=CartridgeInStage") - if result.is_valid: - cartridge_string = result.value + cartridge_result = stem_controller._get_rest_api("/exchange?property=CartridgeInStage") + if cartridge_result.is_valid: + cartridge_string = cartridge_result.value self._append_output_threadsafe(f"Cartridge in stage: {cartridge_string}") - properties = {"ImageScaleRad_m": total_image_height, "ImageOffsetX_px": sx_um / pixel_size_m, "ImageOffsetY_px": sy_um / pixel_size_m, "ImageFile": str(export_path)} + properties: JSONDict = {"ImageScaleRad_m": total_image_height, "ImageOffsetX_px": sx_um / pixel_size_m, "ImageOffsetY_px": sy_um / pixel_size_m, "ImageFile": str(export_path)} # Set the values on the cartridge stem_controller._put_rest_api(f"/exchange/cartridges/{cartridge_string}", content=properties) - + if hasattr(cartridge_result, "is_valid") and not cartridge_result.is_valid: + self._append_output_threadsafe(f"PUT failed: {cartridge_result.exception}") else: - self._append_output_threadsafe(f"Failed to get CartridgeInStage: {result.exception}") + self._append_output_threadsafe(f"Failed to get CartridgeInStage: {cartridge_result.exception}") return except Exception as e: From b96f71bf76018d5cf4074c2b6d59ff349c22119d Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Tue, 8 Sep 2026 14:20:38 +0100 Subject: [PATCH 15/31] Update mypy.ini --- mypy.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/mypy.ini b/mypy.ini index c94785d..3a5f52c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -6,6 +6,5 @@ follow_imports = silent strict = True warn_redundant_casts = False warn_unused_ignores = False -mypy_path = ../nionutils,../nionui,../nionswift,../niondata,../nion-instrumentation packages = nion.experimental, nionswift_plugin.drift_correction, nionswift_plugin.nion_experimental_4dtools, nionswift_plugin.nion_experimental_tools exclude = (/test|/docs) From afc030e66f504f808615b268ddffdd1e14233b09 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Thu, 10 Sep 2026 11:20:04 +0100 Subject: [PATCH 16/31] adding "clear minimap" button --- .../overview_scan_panel.py | 83 ++++++++----------- 1 file changed, 36 insertions(+), 47 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index d116a78..d46bd18 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -1,13 +1,13 @@ +import typing + import asyncio import gettext import math -import time -import typing -from pathlib import Path - import numpy import numpy.typing as npt +from pathlib import Path from PIL import Image +import time from nion.instrumentation import camera_base from nion.instrumentation import stem_controller as stem_controller_module @@ -38,7 +38,6 @@ def get_ui_handler( class OverviewSamplePanelHandler(Declarative.Handler): - """Declarative handler for the Sample docked panel.""" def __init__( self, @@ -83,46 +82,23 @@ def _set_progress_threadsafe(self, value: int, maximum: int, text: str) -> None: def _build_ui() -> typing.Mapping[str, typing.Any]: u = Declarative.DeclarativeUI() title = u.create_label(text="Overview Scan", font="bold") - time_button = u.create_push_button( - text="Estimate scan size and duration", - on_clicked="on_estimate_time_clicked" - ) - acq_button = u.create_push_button( - text="Scan", - on_clicked="on_perform_acquisition_clicked" - ) + time_button = u.create_push_button(text="Estimate scan size and duration", on_clicked="handle_estimate_time_clicked") + acq_button = u.create_push_button(text="Scan", on_clicked="handle_perform_acquisition_clicked") properties_label = u.create_label(text="Desired properties of image:") width_label = u.create_label(text="Width (um):") width_field = u.create_line_edit(text="@binding(width_value)", editable=True) - height_label = u.create_label(text="Height (um):") height_field = u.create_line_edit(text="@binding(height_value)", editable=True) - defocus_label = u.create_label(text="Defocus (nm):") defocus = u.create_line_edit(text="@binding(defocus)", editable=True) - reduce_label = u.create_label(text="Binning:") reduce_val = u.create_line_edit(text="@binding(binning)", editable=True) - output_label = u.create_label(text="Output:") - output_box = u.create_text_edit( - text="@binding(output_text)", - editable=False, - height=200 - ) + output_box = u.create_text_edit(text="@binding(output_text)", editable=False, height=200) progress_label = u.create_label(text="@binding(progress_text)") - progress_bar = u.create_progress_bar( - value="@binding(progress_value)", - minimum=0, - maximum=100, - width=500 - ) - cancel_button = u.create_push_button( - text="Cancel", - on_clicked="on_cancel_acquisition_clicked", - enabled="@binding(cancel_enabled.value)" - - ) + progress_bar = u.create_progress_bar(value="@binding(progress_value)", minimum=0, maximum=100, width=500) + cancel_button = u.create_push_button(text="Cancel", on_clicked="handle_cancel_acquisition_clicked", enabled="@binding(cancel_enabled.value)") + clear_button = u.create_push_button(text="Clear minimap", on_clicked="handle_clear_minimap_clicked") return typing.cast(typing.Mapping[str, typing.Any], u.create_column( title, @@ -136,7 +112,7 @@ def _build_ui() -> typing.Mapping[str, typing.Any]: progress_label, progress_bar, u.create_spacing(8), - cancel_button, + u.create_row(cancel_button, u.create_spacing(4),clear_button), u.create_spacing(8), output_label, output_box, @@ -152,7 +128,7 @@ def _append_output(self, message: str) -> None: def _append_output_threadsafe(self, message: str) -> None: self._event_loop.call_soon_threadsafe(self._append_output, message) - def on_cancel_acquisition_clicked(self, widget: typing.Any) -> None: + def handle_cancel_acquisition_clicked(self, widget: typing.Any) -> None: if self._is_running: self._cancel_requested = True self._set_progress_threadsafe(self.progress_value, 100, "Cancel requested...") @@ -227,18 +203,16 @@ def acquisition(self, assert tv_pixel_angle_rad is not None stem_controller.set_control_output("C10", defocus) - pixel_size_nm = abs(defocus) * math.tan(tv_pixel_angle_rad) + pixel_size_nm = abs(defocus) * math.tan(tv_pixel_angle_rad) image_size = camera.get_expected_dimensions(camera.get_current_frame_parameters()) image_width_um = abs(defocus) * math.sin(tv_pixel_angle_rad * image_size[0]) master_sub_area_size = image_size[0] // 2, image_size[1] // 2 master_sub_area = (image_size[0] // 2 - master_sub_area_size[0] // 2, image_size[1] // 2 - master_sub_area_size[1] // 2), master_sub_area_size - reduce = max(1, int(reduce)) sub_area_shift_um = image_width_um * (master_sub_area[1][0] / image_size[0]) - sub_area = (master_sub_area[0][0] // reduce, master_sub_area[0][1] // reduce), (master_sub_area[1][0] // reduce, master_sub_area[1][1] // reduce) frames_needed_width = math.ceil(target_width_um[0] * 1e-6 / sub_area_shift_um) @@ -248,6 +222,7 @@ def acquisition(self, total_images = frames_needed_width * frames_needed_height master_data = numpy.empty((sub_area[1][0] * size[0], sub_area[1][1] * size[1])) + if not timer: self._append_output_threadsafe(f"Stage starting position: {sx_um * 1e6, sy_um * 1e6} um") self._append_output_threadsafe(f"Pixel size: {(pixel_size_nm * 1e9):.3f} nm") @@ -354,7 +329,7 @@ def acquisition(self, self.cancel_enabled.value = False return master_data, sub_area, sub_area_shift_um, pixel_size_nm, total_image_height, sx_um, sy_um - def on_estimate_time_clicked(self, widget: typing.Any) -> None: + def handle_estimate_time_clicked(self, widget: typing.Any) -> None: try: width_um = int(self.width_value) height_um = int(self.height_value) @@ -440,9 +415,14 @@ async def _run_acquisition_async( self._append_output_threadsafe(f"Original stage coordinates: {sx_um * 1e6, sy_um * 1e6} um") data_array = numpy.array(xdata) - data_uint8 = ((data_array - data_array.min()) / (data_array.max() - data_array.min()) * 255).astype(numpy.uint8) + data_min = float(numpy.min(data_array)) + data_max = float(numpy.max(data_array)) + data_range = data_max - data_min + + data_uint8 = ((data_array - data_min/ data_range * 255).astype(numpy.uint8)) img = Image.fromarray(data_uint8) + #export_path = Path(r"C:\Users\Elizabeth.Wylie\Pictures\overview-scan.jpg") export_path = Path(r"C:\AS2\AS2User\Pictures\overview-scan.jpg") if not export_path.parent.exists(): export_path.parent.mkdir(parents=True, exist_ok=True) @@ -476,7 +456,7 @@ async def _run_acquisition_async( self.cancel_enabled.value = False return - def on_perform_acquisition_clicked(self, widget: typing.Any) -> None: + def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: try: width_um = int(self.width_value) height_um = int(self.height_value) @@ -507,13 +487,23 @@ def on_perform_acquisition_clicked(self, widget: typing.Any) -> None: self._run_acquisition_async(stem_controller, camera, defocus_nm, target_width_um, reduce) ) self.cancel_enabled.value = False -# --------------------------------------------------------------------------- -# Swift Panel wrapper -# --------------------------------------------------------------------------- + + def handle_clear_minimap_clicked(self, widget: typing.Any) -> None: + stem_controller = self.stem_controller + try: + cartridge_result = stem_controller._get_rest_api("/exchange?property=CartridgeInStage") + if cartridge_result.is_valid: + cartridge_string = cartridge_result.value + properties: JSONDict = {"ImageScaleRad_m": 0.0, "ImageOffsetX_px": 0.0, "ImageOffsetY_px": 0.0, "ImageFile": ""} + stem_controller._put_rest_api(f"/exchange/cartridges/{cartridge_string}", content=properties) + self._append_output_threadsafe("Minimap cleared.") + else: + self._append_output_threadsafe(f"Failed to get CartridgeInStage: {cartridge_result.exception}") + except Exception as e: + self._append_output(f"Failed to clear minimap data: {e!r}") class OverviewScanPanel(Panel.Panel): - """Swift panel class instantiated by the Workspace panel manager.""" def __init__( self, @@ -539,7 +529,6 @@ def __init__( class OverviewScanPanelExtension: - # required for Swift to recognize this as an extension class. extension_id = "overview-scan.panel" def __init__(self, api_broker: typing.Any) -> None: From 70c7e3a205e0bfd0938a50700cc4982078098b86 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Fri, 11 Sep 2026 09:48:12 +0100 Subject: [PATCH 17/31] remove code that puts the camera back to the centre after each frame acquisition --- .../overview_scan_panel.py | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index d46bd18..8945497 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -208,7 +208,7 @@ def acquisition(self, image_size = camera.get_expected_dimensions(camera.get_current_frame_parameters()) image_width_um = abs(defocus) * math.sin(tv_pixel_angle_rad * image_size[0]) - master_sub_area_size = image_size[0] // 2, image_size[1] // 2 + master_sub_area_size = image_size[0], image_size[1] master_sub_area = (image_size[0] // 2 - master_sub_area_size[0] // 2, image_size[1] // 2 - master_sub_area_size[1] // 2), master_sub_area_size reduce = max(1, int(reduce)) @@ -286,22 +286,6 @@ def acquisition(self, supradata = camera.grab_next_to_start()[0] assert supradata is not None - # set both values - attempts = 0 - while attempts < 4: - if self._cancel_requested: - self._append_output_threadsafe("Acquisition Cancelled.") - self.cancel_enabled.value = False - return None if not timer else (0, 0.0) - attempts += 1 - try: - tolerance_factor = 0.0001 - stem_controller.set_control_output(shift_x_control_name, sx_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) - stem_controller.set_control_output(shift_y_control_name, sy_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) - except TimeoutError: - self._append_output_threadsafe(f"Timeout row= {row} column= {column}") - continue - break data = supradata.data[master_sub_area[0][0]:master_sub_area[0][0] + master_sub_area[1][0]:reduce, master_sub_area[0][1]:master_sub_area[0][1] + master_sub_area[1][1]:reduce] slice_row = row slice_column = column @@ -312,7 +296,7 @@ def acquisition(self, # inside loop after counter increment or frame write if not timer: pct = int(100 * counter / total_images) - self._set_progress_threadsafe(pct, total_images, f"Progress:\nAcquiring {counter}/{total_images} frames") + self._set_progress_threadsafe(pct, total_images, f"Progress:\nAcquiring frame {counter} of {total_images}") t2 = time.time() time_total = t2 - t1 finally: @@ -471,7 +455,7 @@ def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: if width_um >= 1000 or height_um >= 1000: self._append_output("Warning: Requested scan size is outside of sensible limit") return - if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 200000: + if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 500000: self._append_output("Warning: Requested defocus is outside of sensible limit") return From 93741e0f812519d88ff313deb2c1a264bab4d65f Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Fri, 11 Sep 2026 16:50:07 +0100 Subject: [PATCH 18/31] added maximum scan button and documentation --- .../overview_scan_panel.py | 291 +++++++++++++----- 1 file changed, 221 insertions(+), 70 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index 8945497..edb63cb 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -22,6 +22,8 @@ _ = gettext.gettext JSONDict = dict[str, typing.Any] +max_size = 20000 # this is the maximum size of the final image in pixels that can be pushed to the sample navigation window. Placeholder value at the moment because something weird is happening with AS2 where the max possible size is decreasing + class OverviewScanPanelUI: panel_type = "overview-scan-panel" @@ -52,22 +54,25 @@ def __init__( self.stem_controller = typing.cast(stem_controller_module.STEMController, Registry.get_component('stem_controller')) self.camera = typing.cast(camera_base.CameraHardwareSource, self.stem_controller.ronchigram_camera) self._document_controller = document_controller + self.width_value: str = "30" + self.height_value: str = "30" + self.defocus: str = "-50000" + self.binning: str = "1" self.output_text: str = "" self.progress_value: int = 0 self.progress_max: int = 100 self.progress_min: int = 0 self.progress_text: str = "Progress:\nIdle" self._acq_task: typing.Optional[asyncio.Task[None]] = None - self.width_value: str = "30" - self.height_value: str = "30" - self.defocus: str = "-50000" - self.binning: str = "1" self._cancel_requested: bool = False self._is_running: bool = False self.cancel_enabled = Model.PropertyModel(False) self.ui_view = self._build_ui() def _set_progress(self, value: int, maximum: int, text: str) -> None: + """ + Set the progress value, maximum, and text for the progress bar + """ self.progress_value = value self.progress_max = max(1, int(maximum)) self.progress_min = 0 @@ -76,43 +81,52 @@ def _set_progress(self, value: int, maximum: int, text: str) -> None: self.property_changed_event.fire("progress_text") def _set_progress_threadsafe(self, value: int, maximum: int, text: str) -> None: + """ + Thread-safe method to set the progress value, maximum, and text for the progress bar, so it can be updated during acquisition + """ self._event_loop.call_soon_threadsafe(self._set_progress, value, maximum, text) @staticmethod def _build_ui() -> typing.Mapping[str, typing.Any]: + """ + Construct the UI for the Overview Scan panel, including labels, buttons, input fields, and a progress bar. + """ u = Declarative.DeclarativeUI() title = u.create_label(text="Overview Scan", font="bold") time_button = u.create_push_button(text="Estimate scan size and duration", on_clicked="handle_estimate_time_clicked") acq_button = u.create_push_button(text="Scan", on_clicked="handle_perform_acquisition_clicked") + max_button = u.create_push_button(text="Maximum scan at defocus", on_clicked="handle_max_clicked") properties_label = u.create_label(text="Desired properties of image:") - width_label = u.create_label(text="Width (um):") - width_field = u.create_line_edit(text="@binding(width_value)", editable=True) - height_label = u.create_label(text="Height (um):") - height_field = u.create_line_edit(text="@binding(height_value)", editable=True) - defocus_label = u.create_label(text="Defocus (nm):") - defocus = u.create_line_edit(text="@binding(defocus)", editable=True) + width_label = u.create_label(text="Width (um):", width=80) + width_field = u.create_line_edit(text="@binding(width_value)", width=50, editable=True) + height_label = u.create_label(text="Height (um):", width=80) + height_field = u.create_line_edit(text="@binding(height_value)", width=50, editable=True) + defocus_label = u.create_label(text="Defocus (nm):", width=80) + defocus = u.create_line_edit(text="@binding(defocus)", width=50, editable=True) reduce_label = u.create_label(text="Binning:") - reduce_val = u.create_line_edit(text="@binding(binning)", editable=True) + reduce_val = u.create_line_edit(text="@binding(binning)", width=50, editable=True) output_label = u.create_label(text="Output:") output_box = u.create_text_edit(text="@binding(output_text)", editable=False, height=200) progress_label = u.create_label(text="@binding(progress_text)") - progress_bar = u.create_progress_bar(value="@binding(progress_value)", minimum=0, maximum=100, width=500) + progress_bar = u.create_progress_bar(value="@binding(progress_value)", minimum=0, maximum=100, width=600) cancel_button = u.create_push_button(text="Cancel", on_clicked="handle_cancel_acquisition_clicked", enabled="@binding(cancel_enabled.value)") clear_button = u.create_push_button(text="Clear minimap", on_clicked="handle_clear_minimap_clicked") return typing.cast(typing.Mapping[str, typing.Any], u.create_column( title, properties_label, - u.create_row(width_label, u.create_spacing(4), width_field, u.create_spacing(20), height_label, u.create_spacing(4), height_field), - u.create_spacing(4), - u.create_row(defocus_label, u.create_spacing(4), defocus, u.create_spacing(20), reduce_label, u.create_spacing(4), reduce_val), - u.create_spacing(8), - u.create_row(time_button, u.create_spacing(20), acq_button), + u.create_row( + u.create_row(u.create_column(width_label, spacing=0), u.create_column(width_field, spacing=0), spacing=2), + u.create_row(u.create_column(height_label, spacing=0), u.create_column(height_field, spacing=0), spacing=2), + u.create_row(u.create_column(defocus_label, spacing=0), u.create_column(defocus, spacing=0), spacing=2), + u.create_row(u.create_column(reduce_label, spacing=0), u.create_column(reduce_val, spacing=0), spacing=8), + ), + u.create_row(time_button, acq_button, max_button, spacing=4), u.create_spacing(8), progress_label, progress_bar, u.create_spacing(8), - u.create_row(cancel_button, u.create_spacing(4),clear_button), + u.create_row(cancel_button, u.create_spacing(4), clear_button), u.create_spacing(8), output_label, output_box, @@ -122,24 +136,32 @@ def _build_ui() -> typing.Mapping[str, typing.Any]: )) def _append_output(self, message: str) -> None: + """ + Add text to the output window + """ self.output_text += f"{message}\n" self.property_changed_event.fire("output_text") def _append_output_threadsafe(self, message: str) -> None: + """ + Update output window contemporaneously with acquisition + """ self._event_loop.call_soon_threadsafe(self._append_output, message) - def handle_cancel_acquisition_clicked(self, widget: typing.Any) -> None: - if self._is_running: - self._cancel_requested = True - self._set_progress_threadsafe(self.progress_value, 100, "Cancel requested...") - def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: + """ + Calculate the transformation matrix from stage coordinates to camera coordinates by moving the stage in small increments and measuring the resulting changes in camera coordinates. + This is done because moving along the stage axis is much faster than moving along the camera axis as it requires fewer moves + """ stem_controller = self.stem_controller + + # Get original stage position in both stage and camera coordinates sx0 = stem_controller.get_control_output("SShft.sx") sy0 = stem_controller.get_control_output("SShft.sy") x0 = stem_controller.get_control_output("SShft.x") y0 = stem_controller.get_control_output("SShft.y") + # Move a small amount in x direction in the stage axis and then measure the change in x and y in the camera axis stem_controller.set_control_output("SShft.sx", sx0 + ds) x1 = stem_controller.get_control_output("SShft.x") y1 = stem_controller.get_control_output("SShft.y") @@ -147,19 +169,27 @@ def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: dx_from_sx = x1 - x0 dy_from_sx = y1 - y0 + # Put the stage back to its original position stem_controller.set_control_output("SShft.sx", sx0) stem_controller.set_control_output("SShft.sy", sy0) stem_controller.set_control_output("SShft.x", x0) stem_controller.set_control_output("SShft.y", y0) + # Move a small amount in x direction in the stage axis and then measure the change in x and y in the camera axis stem_controller.set_control_output("SShft.sy", sy0 + ds) x2 = stem_controller.get_control_output("SShft.x") y2 = stem_controller.get_control_output("SShft.y") - stem_controller.set_control_output("SShft.sy", sy0) dx_from_sy = x2 - x0 dy_from_sy = y2 - y0 + # Put the stage back to its original position + stem_controller.set_control_output("SShft.sy", sy0) + stem_controller.set_control_output("SShft.sx", sx0) + stem_controller.set_control_output("SShft.x", x0) + stem_controller.set_control_output("SShft.y", y0) + + # Construct the transformation matrix from stage coordinates to camera coordinates mat = numpy.array([ [dx_from_sx / ds, dx_from_sy / ds], [dy_from_sx / ds, dy_from_sy / ds], @@ -167,6 +197,39 @@ def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: return mat + @staticmethod + def find_dimensions(stem_controller: stem_controller_module.STEMController, + camera: camera_base.CameraHardwareSource, + defocus: float, + tv_pixel_angle_rad: float, + reduce: float = 1.0) -> tuple[float, tuple[int, int], float, tuple[tuple[int, int], tuple[int, int]], tuple[int, int], float, tuple[tuple[int, int], tuple[int, int]]]: + """ + Calculate the pixel size, image size, image width, master sub-area, master sub-area size, sub-area shift, and sub-area based on the provided defocus and TV pixel angle. + """ + stem_controller.set_control_output("C10", defocus) # set the defocus to the desired value + + # Get pixel size, image size, and image width based on the defocus and TV pixel angle + pixel_size_nm = abs(defocus) * math.tan(tv_pixel_angle_rad) + image_size = camera.get_expected_dimensions(camera.get_current_frame_parameters()) + image_width_um = abs(defocus) * math.sin(tv_pixel_angle_rad * image_size[0]) + + # Calculate the area of the image and the master sub-area based on the image size and reduce factor + master_sub_area_size = image_size[0], image_size[1] + master_sub_area = (image_size[0] // 2 - master_sub_area_size[0] // 2, + image_size[1] // 2 - master_sub_area_size[1] // 2), master_sub_area_size + reduce = max(1, int(reduce)) + + sub_area_shift_um = image_width_um * (master_sub_area[1][0] / image_size[0]) + sub_area_height = len(range(master_sub_area[0][0], master_sub_area[0][0] + master_sub_area[1][0], reduce)) + sub_area_width = len(range(master_sub_area[0][1], master_sub_area[0][1] + master_sub_area[1][1], reduce)) + + sub_area = ( + (master_sub_area[0][0] // reduce, master_sub_area[0][1] // reduce), + (sub_area_height, sub_area_width), + ) + + return pixel_size_nm, image_size, image_width_um, master_sub_area, master_sub_area_size, sub_area_shift_um, sub_area + def acquisition(self, stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, @@ -175,19 +238,25 @@ def acquisition(self, reduce: float = 1.0) -> (tuple[npt.NDArray[numpy.float64], int, float] | tuple[npt.NDArray[numpy.float64], tuple[tuple[int, int], tuple[int, int]], float, float, float, float, float] | tuple[int, float] | None): + """ + Move across the sample in a snake pattern, acquiring images at each position, and return the resulting data and relevant parameters. + If timer is True, return an estimate of how long the full acquisition will take. + """ counter = 0 self._cancel_requested = False self._is_running = True self.cancel_enabled.value = True - success, tv_pixel_angle_rad = stem_controller.TryGetVal("TVPixelAngle") + success, tv_pixel_angle_rad = stem_controller.TryGetVal("TVPixelAngle") # if success is False, the plugin is likely being run on uSim if success: + # this branch will run where the plugin is used on an actual microscope OR when AS2 is running locally alongside uSim shift_x_control_name = "SShft.sx" shift_y_control_name = "SShft.sy" matrix = self.find_matrix() else: + # this allows the plugin to run on uSim without AS2 locally shift_x_control_name = "stage_position_m.x" shift_y_control_name = "stage_position_m.y" matrix = None @@ -204,26 +273,19 @@ def acquisition(self, assert tv_pixel_angle_rad is not None stem_controller.set_control_output("C10", defocus) - pixel_size_nm = abs(defocus) * math.tan(tv_pixel_angle_rad) - image_size = camera.get_expected_dimensions(camera.get_current_frame_parameters()) - image_width_um = abs(defocus) * math.sin(tv_pixel_angle_rad * image_size[0]) - - master_sub_area_size = image_size[0], image_size[1] - master_sub_area = (image_size[0] // 2 - master_sub_area_size[0] // 2, image_size[1] // 2 - master_sub_area_size[1] // 2), master_sub_area_size - reduce = max(1, int(reduce)) - - sub_area_shift_um = image_width_um * (master_sub_area[1][0] / image_size[0]) - sub_area = (master_sub_area[0][0] // reduce, master_sub_area[0][1] // reduce), (master_sub_area[1][0] // reduce, master_sub_area[1][1] // reduce) + pixel_size_nm, image_size, image_width_um, master_sub_area, master_sub_area_size, sub_area_shift_um, sub_area = self.find_dimensions(stem_controller, camera, defocus, tv_pixel_angle_rad, reduce) + # calculate the number of frames to cover the targe frames_needed_width = math.ceil(target_width_um[0] * 1e-6 / sub_area_shift_um) frames_needed_height = math.ceil(target_width_um[1] * 1e-6 / sub_area_shift_um) size = (frames_needed_width, frames_needed_height) - total_image_height = size[1] * image_width_um - total_images = frames_needed_width * frames_needed_height - master_data = numpy.empty((sub_area[1][0] * size[0], sub_area[1][1] * size[1])) + total_image_height = size[1] * image_width_um # calculate the height of the image in um + total_images = frames_needed_width * frames_needed_height # calculate the total number of frames required for the image - if not timer: + master_data = numpy.empty((sub_area[1][0] * size[0], sub_area[1][1] * size[1])) # create an empty array to hold the final image data + + if not timer: # if performing the full acquisition instead of just estimating the time, update the progress bar and output window self._append_output_threadsafe(f"Stage starting position: {sx_um * 1e6, sy_um * 1e6} um") self._append_output_threadsafe(f"Pixel size: {(pixel_size_nm * 1e9):.3f} nm") self._append_output_threadsafe(f"Defocus: {(defocus * 1e9):.0f} nm") @@ -236,17 +298,17 @@ def acquisition(self, t1 = time.time() if timer: - size = (2, 1) - else: - size = size + size = (2, 1) # for timing purposes, only need to acquire 2 frames and average the time to take them both try: for row in range(size[0]): + # cancel mechanism if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") self.cancel_enabled.value = False return None if not timer else (0, 0.0) + # acquisition algorithm in a snake pattern col_iter = range(size[1]) if (row % 2 == 0) else range(size[1] - 1, -1, -1) for column in col_iter: if self._cancel_requested: @@ -254,10 +316,10 @@ def acquisition(self, self.cancel_enabled.value = False return None if not timer else (0, 0.0) - if matrix is None or numpy.linalg.det(matrix) == 0 or len(matrix) == 0: + if matrix is None or numpy.linalg.det(matrix) == 0 or len(matrix) == 0: # if the plugin is being run on uSim then correction for stage axis is not needed as can move straight along the camera axis delta_x_um = - sub_area_shift_um * (column - size[1] // 2) delta_y_um = - sub_area_shift_um * (row - size[0] // 2) - else: + else: # if the plugin is being run on a microscope need to transform every movement from the stage axis to the camera axis delta_x_um = - sub_area_shift_um * (column - size[1] // 2) delta_y_um = - sub_area_shift_um * (row - size[0] // 2) delta_camera = numpy.array([delta_x_um, delta_y_um], dtype=numpy.float64) @@ -275,7 +337,7 @@ def acquisition(self, self.cancel_enabled.value = False return None if not timer else (0, 0.0) attempts += 1 - try: + try: # try to move the stage to the desired position, if it times out then try again up to 4 times tolerance_factor = 0.0001 stem_controller.set_control_output(shift_x_control_name, sx_um - delta_x_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) stem_controller.set_control_output(shift_y_control_name, sy_um - delta_y_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) @@ -284,8 +346,10 @@ def acquisition(self, continue break + # adding the new frame to the data item supradata = camera.grab_next_to_start()[0] assert supradata is not None + data = supradata.data[master_sub_area[0][0]:master_sub_area[0][0] + master_sub_area[1][0]:reduce, master_sub_area[0][1]:master_sub_area[0][1] + master_sub_area[1][1]:reduce] slice_row = row slice_column = column @@ -293,18 +357,19 @@ def acquisition(self, slice1 = slice(slice_column * sub_area[1][1], (slice_column + 1) * sub_area[1][1]) master_data[slice0, slice1] = data - # inside loop after counter increment or frame write - if not timer: + if not timer: # if performing the actual acquisition then update the progress bar and output window pct = int(100 * counter / total_images) self._set_progress_threadsafe(pct, total_images, f"Progress:\nAcquiring frame {counter} of {total_images}") t2 = time.time() time_total = t2 - t1 + finally: # restore stage to original location stem_controller.set_control_output(shift_x_control_name, sx_um) stem_controller.set_control_output(shift_y_control_name, sy_um) - stem_controller.set_control_output("C10", df_original) - self._set_progress_threadsafe(0, 100, "Progress:\n Idle") + + stem_controller.set_control_output("C10", df_original) # restore defocus to original value + self._set_progress_threadsafe(0, 100, "Progress:\n Idle") # reset progress bar to idle state if timer: self.cancel_enabled.value = False @@ -313,7 +378,19 @@ def acquisition(self, self.cancel_enabled.value = False return master_data, sub_area, sub_area_shift_um, pixel_size_nm, total_image_height, sx_um, sy_um + def handle_cancel_acquisition_clicked(self, widget: typing.Any) -> None: + """ + Cancel button: off when the acquisition is not running, on when it is + """ + if self._is_running: + self._cancel_requested = True + self._set_progress_threadsafe(self.progress_value, 100, "Cancel requested...") + def handle_estimate_time_clicked(self, widget: typing.Any) -> None: + """ + Estimates the time an acquisition will take by averaging the time it takes to capture two frames and multiplying by the total number of frames required for the acquisition. + """ + # guardrails to make sure width, height, defocus and binning are all integers and within sensible limits try: width_um = int(self.width_value) height_um = int(self.height_value) @@ -328,10 +405,10 @@ def handle_estimate_time_clicked(self, widget: typing.Any) -> None: if width_um >= 1000 or height_um >= 1000: self._append_output("Warning: Requested scan size is outside of sensible limit") return - if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 500000: - self._append_output("Warning: Requested defocus is outside of sensible limit") + self._append_output("Warning: Requested defocus is outside of safe limit") return + stem_controller = self.stem_controller camera = self.camera @@ -345,21 +422,21 @@ def handle_estimate_time_clicked(self, widget: typing.Any) -> None: self._append_output( f"This acquisition will take approximately {(time_taken // 3600):.0f}h {((time_taken % 3600) / 60):.0f}min {(time_taken % 60):.0f}s" ) - self._append_output(f"The size of the final data item will be {image_size}.\n") - if any(dim > 32768 for dim in image_size): + if any(dim > max_size for dim in image_size): self._append_output("The final data item is too large to be used in the sample navigation window. Consider increasing the binning or reducing the size of the acquisition.\n") return else: return - async def _run_acquisition_async( - self, - stem_controller: stem_controller_module.STEMController, - camera: camera_base.CameraHardwareSource, - defocus_nm: float, - target_width_um: tuple[int, int], - reduce: int - ) -> None: + async def _run_acquisition_async(self, + stem_controller: stem_controller_module.STEMController, + camera: camera_base.CameraHardwareSource, + defocus_nm: float, + target_width_um: tuple[int, int], + reduce: int) -> None: + """ + Performs acquisition asynchronously to avoid blocking the UI thread, then pushes results to the sample navigaiton map + """ loop = self._event_loop self._append_output_threadsafe("Starting acquisition...\n") @@ -378,6 +455,7 @@ async def _run_acquisition_async( return try: + # dimensional calibrations for the final data item library = self._api.library y_scale_um = (sub_area_shift_m / sub_area[1][0]) * 1e6 x_scale_um = (sub_area_shift_m / sub_area[1][1]) * 1e6 @@ -391,22 +469,24 @@ async def _run_acquisition_async( dimensional_calibrations=dimensional_calibrations, ) + # create final data item library.create_data_item_from_data_and_metadata(xdata, "Composite Survey") + self._append_output("Acquisition complete.\n") self._append_output("Image properties:") self._append_output_threadsafe(f"Total image height: {total_image_height * 1e3} mm") self._append_output_threadsafe(f"Original stage coordinates: {sx_um * 1e6, sy_um * 1e6} um") + # convert the data to uint8 and save as a jpg data_array = numpy.array(xdata) data_min = float(numpy.min(data_array)) data_max = float(numpy.max(data_array)) data_range = data_max - data_min - data_uint8 = ((data_array - data_min/ data_range * 255).astype(numpy.uint8)) + data_uint8 = ((data_array - data_min / data_range * 255).astype(numpy.uint8)) img = Image.fromarray(data_uint8) - #export_path = Path(r"C:\Users\Elizabeth.Wylie\Pictures\overview-scan.jpg") export_path = Path(r"C:\AS2\AS2User\Pictures\overview-scan.jpg") if not export_path.parent.exists(): export_path.parent.mkdir(parents=True, exist_ok=True) @@ -418,6 +498,7 @@ async def _run_acquisition_async( self.cancel_enabled.value = False return + # push the image, scale height and offsets to the sample navigation map try: cartridge_result = stem_controller._get_rest_api("/exchange?property=CartridgeInStage") if cartridge_result.is_valid: @@ -441,6 +522,11 @@ async def _run_acquisition_async( return def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: + """ + Start the acquisition process by validating input parameters and initiating the asynchronous acquisition task when the button is clicked + """ + + # guardrails to make sure width, height, defocus and binning are all integers and within sensible limits try: width_um = int(self.width_value) height_um = int(self.height_value) @@ -449,6 +535,7 @@ def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: except ValueError: self._append_output("Please enter width, height, binning and defocus as integers.") return + if width_um < 1 or height_um < 1 or reduce < 1: self._append_output("Please ensure width and height are positive.") return @@ -456,7 +543,7 @@ def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: self._append_output("Warning: Requested scan size is outside of sensible limit") return if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 500000: - self._append_output("Warning: Requested defocus is outside of sensible limit") + self._append_output("Warning: Requested defocus is outside of safe limit") return if self._acq_task and not self._acq_task.done(): @@ -472,7 +559,73 @@ def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: ) self.cancel_enabled.value = False + def handle_max_clicked(self, widget: typing.Any) -> None: + """ + Calculates the maximum scan size at the specified defocus/binning for the image to be pushed to the sample navigation map, estimates the time it will take and performs the acquisition when the button is clicked + """ + # guardrails to make sure defocus and binning are both integers and within sensible limits + try: + defocus_nm = int(self.defocus) * 1e-9 + reduce = int(self.binning) + except ValueError: + self._append_output("Please enter defocus and binning as integers.") + return + if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 500000: + self._append_output("Warning: Requested defocus is outside of sensible limit") + return + + if self._acq_task and not self._acq_task.done(): + self._append_output("Acquisition already running.") + return + + stem_controller = self.stem_controller + camera = self.camera + + self._cancel_requested = False + self._is_running = True + self.cancel_enabled.value = True + + # calculating the maximum scan size at the specified defocus/binning for the image to be pushed to the sample navigation map + success, tv_pixel_angle_rad = stem_controller.TryGetVal("TVPixelAngle") + + if not success: + frame = camera.grab_next_to_start()[0] + assert frame is not None + tv_pixel_angle_rad = float(frame.dimensional_calibrations[0].scale) + + assert tv_pixel_angle_rad is not None + + pixel_size_nm, image_size, image_width_um, master_sub_area, master_sub_area_size, sub_area_shift_um, sub_area = self.find_dimensions(stem_controller, camera, defocus_nm, tv_pixel_angle_rad, reduce) + + size1 = max_size // sub_area[1][0] + size2 = max_size // sub_area[1][1] + + # putting the calculated maximum scan size into the width and height fields in the UI + self.width_value = str(int(size1 * sub_area_shift_um * 1e6)) + self.height_value = str(int(size2 * sub_area_shift_um * 1e6)) + self.property_changed_event.fire("width_value") + self.property_changed_event.fire("height_value") + + target_width_um = (int(self.width_value), int(self.height_value)) + + result = self.acquisition(stem_controller, camera, defocus_nm, target_width_um, timer=True, reduce=reduce) + if result is None or len(result) != 3: + return + master_data, total_images, t_total = result + time_taken = t_total * total_images / 2 # average time to move the stage + self._append_output( + f"This acquisition will take approximately {(time_taken // 3600):.0f}h {((time_taken % 3600) / 60):.0f}min {(time_taken % 60):.0f}s\n" + ) + + self._acq_task = self._event_loop.create_task( + self._run_acquisition_async(stem_controller, camera, defocus_nm, target_width_um, reduce) + ) + self.cancel_enabled.value = False + def handle_clear_minimap_clicked(self, widget: typing.Any) -> None: + """ + Clears the image, scale height and offsets from the sample navigation map when the button is clicked + """ stem_controller = self.stem_controller try: cartridge_result = stem_controller._get_rest_api("/exchange?property=CartridgeInStage") @@ -489,12 +642,10 @@ def handle_clear_minimap_clicked(self, widget: typing.Any) -> None: class OverviewScanPanel(Panel.Panel): - def __init__( - self, - document_controller: "DocumentController.DocumentController", - panel_id: str, - properties: typing.Dict[str, typing.Any], - ) -> None: + def __init__(self, + document_controller: "DocumentController.DocumentController", + panel_id: str, + properties: typing.Dict[str, typing.Any]) -> None: super().__init__(document_controller, panel_id, "overview-scan-panel") for component in Registry.get_components_by_type("overview-scan-panel"): if getattr(component, "panel_type", None) == "overview-scan-panel": From d562fcec37779755f9fcf15e9f03de00415ffe49 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Mon, 14 Sep 2026 13:10:43 +0100 Subject: [PATCH 19/31] tidying code and adding documentation --- .../overview_scan_panel.py | 291 ++++++++++-------- 1 file changed, 169 insertions(+), 122 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index edb63cb..ca86346 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -41,13 +41,10 @@ def get_ui_handler( class OverviewSamplePanelHandler(Declarative.Handler): - def __init__( - self, - - api: "API_1_0.API", - event_loop: typing.Optional[asyncio.AbstractEventLoop], - document_controller: typing.Any, - ) -> None: + def __init__(self, + api: "API_1_0.API", + event_loop: typing.Optional[asyncio.AbstractEventLoop], + document_controller: typing.Any) -> None: super().__init__() self._api = api self._event_loop = event_loop or asyncio.get_event_loop() @@ -71,7 +68,7 @@ def __init__( def _set_progress(self, value: int, maximum: int, text: str) -> None: """ - Set the progress value, maximum, and text for the progress bar + Set the progress value, maximum, and text for the progress bar. """ self.progress_value = value self.progress_max = max(1, int(maximum)) @@ -82,7 +79,7 @@ def _set_progress(self, value: int, maximum: int, text: str) -> None: def _set_progress_threadsafe(self, value: int, maximum: int, text: str) -> None: """ - Thread-safe method to set the progress value, maximum, and text for the progress bar, so it can be updated during acquisition + Thread-safe method to set the progress value, maximum, and text for the progress bar, so it can be updated during acquisition. """ self._event_loop.call_soon_threadsafe(self._set_progress, value, maximum, text) @@ -102,9 +99,9 @@ def _build_ui() -> typing.Mapping[str, typing.Any]: height_label = u.create_label(text="Height (um):", width=80) height_field = u.create_line_edit(text="@binding(height_value)", width=50, editable=True) defocus_label = u.create_label(text="Defocus (nm):", width=80) - defocus = u.create_line_edit(text="@binding(defocus)", width=50, editable=True) - reduce_label = u.create_label(text="Binning:") - reduce_val = u.create_line_edit(text="@binding(binning)", width=50, editable=True) + defocus_field = u.create_line_edit(text="@binding(defocus)", width=50, editable=True) + binning_label = u.create_label(text="Binning:") + binning_field = u.create_line_edit(text="@binding(binning)", width=50, editable=True) output_label = u.create_label(text="Output:") output_box = u.create_text_edit(text="@binding(output_text)", editable=False, height=200) progress_label = u.create_label(text="@binding(progress_text)") @@ -112,14 +109,14 @@ def _build_ui() -> typing.Mapping[str, typing.Any]: cancel_button = u.create_push_button(text="Cancel", on_clicked="handle_cancel_acquisition_clicked", enabled="@binding(cancel_enabled.value)") clear_button = u.create_push_button(text="Clear minimap", on_clicked="handle_clear_minimap_clicked") - return typing.cast(typing.Mapping[str, typing.Any], u.create_column( + overview_scan_ui = u.create_column( title, properties_label, u.create_row( u.create_row(u.create_column(width_label, spacing=0), u.create_column(width_field, spacing=0), spacing=2), u.create_row(u.create_column(height_label, spacing=0), u.create_column(height_field, spacing=0), spacing=2), - u.create_row(u.create_column(defocus_label, spacing=0), u.create_column(defocus, spacing=0), spacing=2), - u.create_row(u.create_column(reduce_label, spacing=0), u.create_column(reduce_val, spacing=0), spacing=8), + u.create_row(u.create_column(defocus_label, spacing=0), u.create_column(defocus_field, spacing=0), spacing=2), + u.create_row(u.create_column(binning_label, spacing=0), u.create_column(binning_field, spacing=0), spacing=8), ), u.create_row(time_button, acq_button, max_button, spacing=4), u.create_spacing(8), @@ -133,25 +130,33 @@ def _build_ui() -> typing.Mapping[str, typing.Any]: u.create_stretch(), margin=6, spacing=4 - )) + ) + + return typing.cast(typing.Mapping[str, typing.Any], overview_scan_ui) def _append_output(self, message: str) -> None: """ - Add text to the output window + Add text to the output window. """ self.output_text += f"{message}\n" self.property_changed_event.fire("output_text") def _append_output_threadsafe(self, message: str) -> None: """ - Update output window contemporaneously with acquisition + Update output window contemporaneously with acquisition. """ self._event_loop.call_soon_threadsafe(self._append_output, message) def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: """ Calculate the transformation matrix from stage coordinates to camera coordinates by moving the stage in small increments and measuring the resulting changes in camera coordinates. - This is done because moving along the stage axis is much faster than moving along the camera axis as it requires fewer moves + This is done because moving along the stage axis is much faster than moving along the camera axis as it requires fewer moves. + + Args: + - ds: the step size by which the stage is moved in the x and y directions to measure the resulting changes in camera coordinates. + + Returns: + - matrix: a 2x2 numpy array representing the transformation matrix from stage coordinates to camera coordinates. """ stem_controller = self.stem_controller @@ -175,7 +180,7 @@ def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: stem_controller.set_control_output("SShft.x", x0) stem_controller.set_control_output("SShft.y", y0) - # Move a small amount in x direction in the stage axis and then measure the change in x and y in the camera axis + # Move a small amount in y direction in the stage axis and then measure the change in x and y in the camera axis stem_controller.set_control_output("SShft.sy", sy0 + ds) x2 = stem_controller.get_control_output("SShft.x") y2 = stem_controller.get_control_output("SShft.y") @@ -190,57 +195,95 @@ def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: stem_controller.set_control_output("SShft.y", y0) # Construct the transformation matrix from stage coordinates to camera coordinates - mat = numpy.array([ + matrix = numpy.array([ [dx_from_sx / ds, dx_from_sy / ds], [dy_from_sx / ds, dy_from_sy / ds], ]) - return mat + return matrix @staticmethod def find_dimensions(stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, defocus: float, tv_pixel_angle_rad: float, - reduce: float = 1.0) -> tuple[float, tuple[int, int], float, tuple[tuple[int, int], tuple[int, int]], tuple[int, int], float, tuple[tuple[int, int], tuple[int, int]]]: + binning: float = 1.0) -> tuple[float, tuple[int, int], float, tuple[tuple[int, int], tuple[int, int]], tuple[int, int], float, tuple[tuple[int, int], tuple[int, int]]]: """ - Calculate the pixel size, image size, image width, master sub-area, master sub-area size, sub-area shift, and sub-area based on the provided defocus and TV pixel angle. + Calculate the relevant properties of each frame based on the provided defocus and TV pixel angle. + + Args: + - stem_controller: the instrument used to control the STEM microscope. + - camera: the Ronchigram camera used to capture images. + - defocus: the desired defocus value in meters. + - tv_pixel_angle_rad: the TV pixel angle in radians. + - binning: the binning factor for the camera, which reduces the resolution of the captured images by combining adjacent pixels. + + Returns: + - pixel_size: real-world size of each pixel in the image in meters. + - frame_size: dimensions of each frame in pixels + - frame_width: real-world width of the image in meters. + - master_sub_area: the full-size crop taken from the frame + - master_sub_area_size: the size of that crop in pixels + - sub_area_shift: the real-world distance in meters that the stage needs to move to capture the next frame in the snake pattern. + - sub_area: the binned crop taken from the frame, which is used to construct the final image """ stem_controller.set_control_output("C10", defocus) # set the defocus to the desired value # Get pixel size, image size, and image width based on the defocus and TV pixel angle - pixel_size_nm = abs(defocus) * math.tan(tv_pixel_angle_rad) - image_size = camera.get_expected_dimensions(camera.get_current_frame_parameters()) - image_width_um = abs(defocus) * math.sin(tv_pixel_angle_rad * image_size[0]) + pixel_size = abs(defocus) * math.tan(tv_pixel_angle_rad) + frame_size = camera.get_expected_dimensions(camera.get_current_frame_parameters()) + frame_width = abs(defocus) * math.sin(tv_pixel_angle_rad * frame_size[0]) # Calculate the area of the image and the master sub-area based on the image size and reduce factor - master_sub_area_size = image_size[0], image_size[1] - master_sub_area = (image_size[0] // 2 - master_sub_area_size[0] // 2, - image_size[1] // 2 - master_sub_area_size[1] // 2), master_sub_area_size - reduce = max(1, int(reduce)) + master_sub_area_size = frame_size[0], frame_size[1] + master_sub_area = (frame_size[0] // 2 - master_sub_area_size[0] // 2, + frame_size[1] // 2 - master_sub_area_size[1] // 2), master_sub_area_size + binning = max(1, int(binning)) - sub_area_shift_um = image_width_um * (master_sub_area[1][0] / image_size[0]) - sub_area_height = len(range(master_sub_area[0][0], master_sub_area[0][0] + master_sub_area[1][0], reduce)) - sub_area_width = len(range(master_sub_area[0][1], master_sub_area[0][1] + master_sub_area[1][1], reduce)) + sub_area_shift = frame_width * (master_sub_area[1][0] / frame_size[0]) + sub_area_height = len(range(master_sub_area[0][0], master_sub_area[0][0] + master_sub_area[1][0], binning)) + sub_area_width = len(range(master_sub_area[0][1], master_sub_area[0][1] + master_sub_area[1][1], binning)) sub_area = ( - (master_sub_area[0][0] // reduce, master_sub_area[0][1] // reduce), + (master_sub_area[0][0] // binning, master_sub_area[0][1] // binning), (sub_area_height, sub_area_width), ) - return pixel_size_nm, image_size, image_width_um, master_sub_area, master_sub_area_size, sub_area_shift_um, sub_area + return pixel_size, frame_size, frame_width, master_sub_area, master_sub_area_size, sub_area_shift, sub_area def acquisition(self, stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, defocus: float, - target_width_um: tuple[float | int, float | int], timer: bool = False, - reduce: float = 1.0) -> (tuple[npt.NDArray[numpy.float64], int, float] | - tuple[npt.NDArray[numpy.float64], tuple[tuple[int, int], tuple[int, int]], float, float, float, float, float] | - tuple[int, float] | None): + target_width: tuple[float | int, float | int], timer: bool = False, + binning: float = 1.0) -> (tuple[npt.NDArray[numpy.float64], int, float] | + tuple[npt.NDArray[numpy.float64], tuple[tuple[int, int], tuple[int, int]], float, float, float, float, float] | + tuple[int, float] | None): """ Move across the sample in a snake pattern, acquiring images at each position, and return the resulting data and relevant parameters. If timer is True, return an estimate of how long the full acquisition will take. + + Args: + - stem_controller: the instrument used to control the STEM microscope. + - camera: the Ronchigram camera used to capture images. + - defocus: the desired defocus value in meters. + - target_width: the desired width and height of the final image in micrometers. + - binning: the binning factor for the camera, which reduces the resolution of the captured images by combining adjacent pixels. + - timer: if True, the function will only acquire two frames to estimate the time required for the full acquisition. + if False, the function will perform the full acquisition. + + Returns: + if timer is True: + - master_data: the acquired data + - total_images: the total number of images the acquisition needs + - time_total: the total time for the acquisition of two frames + if timer is False: + - master_data: the acquired data + - sub_area: the binned crop taken from the frame, which is used to construct the final image + - sub_area_shift: the real-world distance in meters that the stage needs to move to capture the next frame in the snake pattern. + - pixel_size: real-world size of each pixel in the image in meters. + - total_image_height: the real_world height of the final data item in metres + - sx, sy: the original stage coordinates. """ counter = 0 self._cancel_requested = False @@ -266,31 +309,31 @@ def acquisition(self, tv_pixel_angle_rad = float(frame.dimensional_calibrations[0].scale) # grab stage original location and original defocus - sx_um = stem_controller.get_control_output(shift_x_control_name) - sy_um = stem_controller.get_control_output(shift_y_control_name) + sx = stem_controller.get_control_output(shift_x_control_name) + sy = stem_controller.get_control_output(shift_y_control_name) df_original = stem_controller.get_control_output("C10") assert tv_pixel_angle_rad is not None stem_controller.set_control_output("C10", defocus) - pixel_size_nm, image_size, image_width_um, master_sub_area, master_sub_area_size, sub_area_shift_um, sub_area = self.find_dimensions(stem_controller, camera, defocus, tv_pixel_angle_rad, reduce) + pixel_size, frame_size, frame_width, master_sub_area, master_sub_area_size, sub_area_shift, sub_area = self.find_dimensions(stem_controller, camera, defocus, tv_pixel_angle_rad, binning) - # calculate the number of frames to cover the targe - frames_needed_width = math.ceil(target_width_um[0] * 1e-6 / sub_area_shift_um) - frames_needed_height = math.ceil(target_width_um[1] * 1e-6 / sub_area_shift_um) - size = (frames_needed_width, frames_needed_height) + # calculate the number of frames to cover the target area + frames_needed_width = math.ceil(target_width[0] * 1e-6 / sub_area_shift) + frames_needed_height = math.ceil(target_width[1] * 1e-6 / sub_area_shift) + dimensions = (frames_needed_width, frames_needed_height) - total_image_height = size[1] * image_width_um # calculate the height of the image in um + total_image_height = dimensions[1] * frame_width # calculate the height of the image in um total_images = frames_needed_width * frames_needed_height # calculate the total number of frames required for the image - master_data = numpy.empty((sub_area[1][0] * size[0], sub_area[1][1] * size[1])) # create an empty array to hold the final image data + master_data = numpy.empty((sub_area[1][0] * dimensions[0], sub_area[1][1] * dimensions[1])) # create an empty array to hold the final image data if not timer: # if performing the full acquisition instead of just estimating the time, update the progress bar and output window - self._append_output_threadsafe(f"Stage starting position: {sx_um * 1e6, sy_um * 1e6} um") - self._append_output_threadsafe(f"Pixel size: {(pixel_size_nm * 1e9):.3f} nm") + self._append_output_threadsafe(f"Stage starting position: {sx * 1e6, sy * 1e6} um") + self._append_output_threadsafe(f"Pixel size: {(pixel_size * 1e9):.3f} nm") self._append_output_threadsafe(f"Defocus: {(defocus * 1e9):.0f} nm") - self._append_output_threadsafe(f"Frame width: {image_width_um * 1e6} um") + self._append_output_threadsafe(f"Frame width: {frame_width * 1e6} um") self._append_output_threadsafe(f"Master size: {master_data.shape}\n") self._set_progress_threadsafe(0, total_images, "Progress:\nStarting acquisition...") @@ -298,10 +341,10 @@ def acquisition(self, t1 = time.time() if timer: - size = (2, 1) # for timing purposes, only need to acquire 2 frames and average the time to take them both + dimensions = (2, 1) # for timing purposes, only need to acquire 2 frames and average the time to take them both try: - for row in range(size[0]): + for row in range(dimensions[0]): # cancel mechanism if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") @@ -309,7 +352,7 @@ def acquisition(self, return None if not timer else (0, 0.0) # acquisition algorithm in a snake pattern - col_iter = range(size[1]) if (row % 2 == 0) else range(size[1] - 1, -1, -1) + col_iter = range(dimensions[1]) if (row % 2 == 0) else range(dimensions[1] - 1, -1, -1) for column in col_iter: if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") @@ -317,16 +360,16 @@ def acquisition(self, return None if not timer else (0, 0.0) if matrix is None or numpy.linalg.det(matrix) == 0 or len(matrix) == 0: # if the plugin is being run on uSim then correction for stage axis is not needed as can move straight along the camera axis - delta_x_um = - sub_area_shift_um * (column - size[1] // 2) - delta_y_um = - sub_area_shift_um * (row - size[0] // 2) + delta_x = - sub_area_shift * (column - dimensions[1] // 2) + delta_y = - sub_area_shift * (row - dimensions[0] // 2) else: # if the plugin is being run on a microscope need to transform every movement from the stage axis to the camera axis - delta_x_um = - sub_area_shift_um * (column - size[1] // 2) - delta_y_um = - sub_area_shift_um * (row - size[0] // 2) - delta_camera = numpy.array([delta_x_um, delta_y_um], dtype=numpy.float64) + delta_x = - sub_area_shift * (column - dimensions[1] // 2) + delta_y = - sub_area_shift * (row - dimensions[0] // 2) + delta_camera = numpy.array([delta_x, delta_y], dtype=numpy.float64) delta_fast = numpy.linalg.solve(matrix, delta_camera) - delta_x_um = float(delta_fast[0]) - delta_y_um = float(delta_fast[1]) + delta_x = float(delta_fast[0]) + delta_y = float(delta_fast[1]) counter += 1 @@ -339,8 +382,8 @@ def acquisition(self, attempts += 1 try: # try to move the stage to the desired position, if it times out then try again up to 4 times tolerance_factor = 0.0001 - stem_controller.set_control_output(shift_x_control_name, sx_um - delta_x_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) - stem_controller.set_control_output(shift_y_control_name, sy_um - delta_y_um, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + stem_controller.set_control_output(shift_x_control_name, sx - delta_x, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + stem_controller.set_control_output(shift_y_control_name, sy - delta_y, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) except TimeoutError: self._append_output_threadsafe(f"Timeout row= {row} column= {column}") continue @@ -350,7 +393,7 @@ def acquisition(self, supradata = camera.grab_next_to_start()[0] assert supradata is not None - data = supradata.data[master_sub_area[0][0]:master_sub_area[0][0] + master_sub_area[1][0]:reduce, master_sub_area[0][1]:master_sub_area[0][1] + master_sub_area[1][1]:reduce] + data = supradata.data[master_sub_area[0][0]:master_sub_area[0][0] + master_sub_area[1][0]:binning, master_sub_area[0][1]:master_sub_area[0][1] + master_sub_area[1][1]:binning] slice_row = row slice_column = column slice0 = slice(slice_row * sub_area[1][0], (slice_row + 1) * sub_area[1][0]) @@ -365,8 +408,8 @@ def acquisition(self, finally: # restore stage to original location - stem_controller.set_control_output(shift_x_control_name, sx_um) - stem_controller.set_control_output(shift_y_control_name, sy_um) + stem_controller.set_control_output(shift_x_control_name, sx) + stem_controller.set_control_output(shift_y_control_name, sy) stem_controller.set_control_output("C10", df_original) # restore defocus to original value self._set_progress_threadsafe(0, 100, "Progress:\n Idle") # reset progress bar to idle state @@ -376,11 +419,11 @@ def acquisition(self, return master_data, total_images, time_total else: self.cancel_enabled.value = False - return master_data, sub_area, sub_area_shift_um, pixel_size_nm, total_image_height, sx_um, sy_um + return master_data, sub_area, sub_area_shift, pixel_size, total_image_height, sx, sy def handle_cancel_acquisition_clicked(self, widget: typing.Any) -> None: """ - Cancel button: off when the acquisition is not running, on when it is + Cancel button: off when the acquisition is not running, on when it is. """ if self._is_running: self._cancel_requested = True @@ -392,28 +435,28 @@ def handle_estimate_time_clicked(self, widget: typing.Any) -> None: """ # guardrails to make sure width, height, defocus and binning are all integers and within sensible limits try: - width_um = int(self.width_value) - height_um = int(self.height_value) - defocus_nm = int(self.defocus) * 1e-9 - reduce = int(self.binning) + width = int(self.width_value) + height = int(self.height_value) + defocus = int(self.defocus) * 1e-9 + binning = int(self.binning) except ValueError: self._append_output("Please enter width, height, binning and defocus as integers.") return - if width_um < 1 or height_um < 1 or reduce < 1: + if width < 1 or height < 1 or binning < 1: self._append_output("Please ensure width and height are positive.") return - if width_um >= 1000 or height_um >= 1000: + if width >= 1000 or height >= 1000: self._append_output("Warning: Requested scan size is outside of sensible limit") return - if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 500000: + if abs(defocus * 1e9) < 1000 or abs(defocus * 1e9) > 500000: self._append_output("Warning: Requested defocus is outside of safe limit") return stem_controller = self.stem_controller camera = self.camera - target_width_um = (width_um, height_um) - result = self.acquisition(stem_controller, camera, defocus_nm, target_width_um, timer=True, reduce=reduce) + target_width = (width, height) + result = self.acquisition(stem_controller, camera, defocus, target_width, timer=True, binning=binning) if result is None or len(result) != 3: return master_data, total_images, t_total = result @@ -422,7 +465,7 @@ def handle_estimate_time_clicked(self, widget: typing.Any) -> None: self._append_output( f"This acquisition will take approximately {(time_taken // 3600):.0f}h {((time_taken % 3600) / 60):.0f}min {(time_taken % 60):.0f}s" ) - if any(dim > max_size for dim in image_size): + if any(dimension > max_size for dimension in image_size): self._append_output("The final data item is too large to be used in the sample navigation window. Consider increasing the binning or reducing the size of the acquisition.\n") return else: @@ -431,24 +474,30 @@ def handle_estimate_time_clicked(self, widget: typing.Any) -> None: async def _run_acquisition_async(self, stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, - defocus_nm: float, - target_width_um: tuple[int, int], - reduce: int) -> None: + defocus: float, + target_width: tuple[int, int], + binning: int) -> None: """ - Performs acquisition asynchronously to avoid blocking the UI thread, then pushes results to the sample navigaiton map + Performs acquisition asynchronously to avoid blocking the UI thread, then pushes results to the sample navigation map in AS2. + Calculates dimensional calibrations for the final data item and creates a new data item in the library. + Uses REST API calls to get and set the cartridge properties for the sample navigation map. + + Args: + - stem_controller: the instrument used to control the STEM microscope. + - camera: the Ronchigram camera used to capture images. + - defocus: the desired defocus value in meters. + - binning: the binning factor for the camera, which reduces the resolution of the captured images by combining adjacent pixels. """ loop = self._event_loop self._append_output_threadsafe("Starting acquisition...\n") try: - result = await loop.run_in_executor( - None, self.acquisition, stem_controller, camera, defocus_nm, target_width_um, False, reduce - ) + result = await loop.run_in_executor(None, self.acquisition, stem_controller, camera, defocus, target_width, False, binning) if result is None or len(result) != 7: self._set_progress(0, 100, "Progress:\nIdle") return - master_data, sub_area, sub_area_shift_m, pixel_size_m, total_image_height, sx_um, sy_um = result + master_data, sub_area, sub_area_shift, pixel_size, total_image_height, sx, sy = result except Exception as e: self._append_output(f"Acquisition failed: {e!r}") self.cancel_enabled.value = False @@ -457,17 +506,14 @@ async def _run_acquisition_async(self, try: # dimensional calibrations for the final data item library = self._api.library - y_scale_um = (sub_area_shift_m / sub_area[1][0]) * 1e6 - x_scale_um = (sub_area_shift_m / sub_area[1][1]) * 1e6 + y_scale = (sub_area_shift / sub_area[1][0]) * 1e6 + x_scale = (sub_area_shift / sub_area[1][1]) * 1e6 dimensional_calibrations = [ - self._api.create_calibration(0.0, y_scale_um, "um"), - self._api.create_calibration(0.0, x_scale_um, "um"), + self._api.create_calibration(0.0, y_scale, "um"), + self._api.create_calibration(0.0, x_scale, "um"), ] - xdata = self._api.create_data_and_metadata( - master_data, - dimensional_calibrations=dimensional_calibrations, - ) + xdata = self._api.create_data_and_metadata(master_data, dimensional_calibrations=dimensional_calibrations) # create final data item library.create_data_item_from_data_and_metadata(xdata, "Composite Survey") @@ -476,7 +522,7 @@ async def _run_acquisition_async(self, self._append_output("Image properties:") self._append_output_threadsafe(f"Total image height: {total_image_height * 1e3} mm") - self._append_output_threadsafe(f"Original stage coordinates: {sx_um * 1e6, sy_um * 1e6} um") + self._append_output_threadsafe(f"Original stage coordinates: {sx * 1e6, sy * 1e6} um") # convert the data to uint8 and save as a jpg data_array = numpy.array(xdata) @@ -505,7 +551,7 @@ async def _run_acquisition_async(self, cartridge_string = cartridge_result.value self._append_output_threadsafe(f"Cartridge in stage: {cartridge_string}") - properties: JSONDict = {"ImageScaleRad_m": total_image_height, "ImageOffsetX_px": sx_um / pixel_size_m, "ImageOffsetY_px": sy_um / pixel_size_m, "ImageFile": str(export_path)} + properties: JSONDict = {"ImageScaleRad_m": total_image_height, "ImageOffsetX_px": sx / pixel_size, "ImageOffsetY_px": sy / pixel_size, "ImageFile": str(export_path)} # Set the values on the cartridge @@ -523,26 +569,26 @@ async def _run_acquisition_async(self, def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: """ - Start the acquisition process by validating input parameters and initiating the asynchronous acquisition task when the button is clicked + Starts the acquisition process by validating input parameters. + Initiates the asynchronous acquisition task. """ - # guardrails to make sure width, height, defocus and binning are all integers and within sensible limits try: - width_um = int(self.width_value) - height_um = int(self.height_value) - defocus_nm = int(self.defocus) * 1e-9 - reduce = int(self.binning) + width = int(self.width_value) + height = int(self.height_value) + defocus = int(self.defocus) * 1e-9 + binning = int(self.binning) except ValueError: self._append_output("Please enter width, height, binning and defocus as integers.") return - if width_um < 1 or height_um < 1 or reduce < 1: + if width < 1 or height < 1 or binning < 1: self._append_output("Please ensure width and height are positive.") return - if width_um >= 1000 or height_um >= 1000: + if width >= 1000 or height >= 1000: self._append_output("Warning: Requested scan size is outside of sensible limit") return - if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 500000: + if abs(defocus * 1e9) < 1000 or abs(defocus * 1e9) > 500000: self._append_output("Warning: Requested defocus is outside of safe limit") return @@ -552,25 +598,26 @@ def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: stem_controller = self.stem_controller camera = self.camera - target_width_um = (width_um, height_um) + target_width = (width, height) self._acq_task = self._event_loop.create_task( - self._run_acquisition_async(stem_controller, camera, defocus_nm, target_width_um, reduce) + self._run_acquisition_async(stem_controller, camera, defocus, target_width, binning) ) self.cancel_enabled.value = False def handle_max_clicked(self, widget: typing.Any) -> None: """ - Calculates the maximum scan size at the specified defocus/binning for the image to be pushed to the sample navigation map, estimates the time it will take and performs the acquisition when the button is clicked + Calculates the maximum scan size at the specified defocus/binning for the image to be pushed to the sample navigation map. + Estimates the time it will take and performs the acquisition. """ # guardrails to make sure defocus and binning are both integers and within sensible limits try: - defocus_nm = int(self.defocus) * 1e-9 - reduce = int(self.binning) + defocus = int(self.defocus) * 1e-9 + binning = int(self.binning) except ValueError: self._append_output("Please enter defocus and binning as integers.") return - if abs(defocus_nm * 1e9) < 1000 or abs(defocus_nm * 1e9) > 500000: + if abs(defocus * 1e9) < 1000 or abs(defocus * 1e9) > 500000: self._append_output("Warning: Requested defocus is outside of sensible limit") return @@ -595,20 +642,20 @@ def handle_max_clicked(self, widget: typing.Any) -> None: assert tv_pixel_angle_rad is not None - pixel_size_nm, image_size, image_width_um, master_sub_area, master_sub_area_size, sub_area_shift_um, sub_area = self.find_dimensions(stem_controller, camera, defocus_nm, tv_pixel_angle_rad, reduce) + pixel_size, frame_size, frame_width, master_sub_area, master_sub_area_size, sub_area_shift, sub_area = self.find_dimensions(stem_controller, camera, defocus, tv_pixel_angle_rad, binning) - size1 = max_size // sub_area[1][0] - size2 = max_size // sub_area[1][1] + dimension_y = max_size // sub_area[1][0] + dimension_x = max_size // sub_area[1][1] # putting the calculated maximum scan size into the width and height fields in the UI - self.width_value = str(int(size1 * sub_area_shift_um * 1e6)) - self.height_value = str(int(size2 * sub_area_shift_um * 1e6)) + self.width_value = str(int(dimension_x * sub_area_shift * 1e6)) + self.height_value = str(int(dimension_y * sub_area_shift * 1e6)) self.property_changed_event.fire("width_value") self.property_changed_event.fire("height_value") - target_width_um = (int(self.width_value), int(self.height_value)) + target_width = (int(self.width_value), int(self.height_value)) - result = self.acquisition(stem_controller, camera, defocus_nm, target_width_um, timer=True, reduce=reduce) + result = self.acquisition(stem_controller, camera, defocus, target_width, timer=True, binning=binning) if result is None or len(result) != 3: return master_data, total_images, t_total = result @@ -618,13 +665,13 @@ def handle_max_clicked(self, widget: typing.Any) -> None: ) self._acq_task = self._event_loop.create_task( - self._run_acquisition_async(stem_controller, camera, defocus_nm, target_width_um, reduce) + self._run_acquisition_async(stem_controller, camera, defocus, target_width, binning) ) self.cancel_enabled.value = False def handle_clear_minimap_clicked(self, widget: typing.Any) -> None: """ - Clears the image, scale height and offsets from the sample navigation map when the button is clicked + Clears the image, scale height and offsets from the sample navigation map. """ stem_controller = self.stem_controller try: @@ -678,4 +725,4 @@ def __init__(self, api_broker: typing.Any) -> None: ) def close(self) -> None: - pass + pass \ No newline at end of file From cd98ea12768709d50882b0631def306d9c5a472c Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Mon, 14 Sep 2026 14:08:12 +0100 Subject: [PATCH 20/31] making it work on usim again --- .../overview_scan_panel.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index ca86346..cba9aa4 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -291,15 +291,15 @@ def acquisition(self, self.cancel_enabled.value = True success, tv_pixel_angle_rad = stem_controller.TryGetVal("TVPixelAngle") # if success is False, the plugin is likely being run on uSim + matrix = self.find_matrix() - if success: - # this branch will run where the plugin is used on an actual microscope OR when AS2 is running locally alongside uSim + if success and matrix is not None and numpy.linalg.det(matrix) != 0 and len(matrix) != 0: #even if success is True, it could still be on usim- this would give an empty or singular matrix so can guard against non-uSim controls being used + # this branch will run where the plugin is used on an actual microscope shift_x_control_name = "SShft.sx" shift_y_control_name = "SShft.sy" - matrix = self.find_matrix() else: - # this allows the plugin to run on uSim without AS2 locally + # this allows the plugin to run on uSim shift_x_control_name = "stage_position_m.x" shift_y_control_name = "stage_position_m.y" matrix = None @@ -359,7 +359,7 @@ def acquisition(self, self.cancel_enabled.value = False return None if not timer else (0, 0.0) - if matrix is None or numpy.linalg.det(matrix) == 0 or len(matrix) == 0: # if the plugin is being run on uSim then correction for stage axis is not needed as can move straight along the camera axis + if shift_x_control_name == "stage_position_m.x": # if the plugin is being run on uSim then correction for stage axis is not needed as can move straight along the camera axis delta_x = - sub_area_shift * (column - dimensions[1] // 2) delta_y = - sub_area_shift * (row - dimensions[0] // 2) else: # if the plugin is being run on a microscope need to transform every movement from the stage axis to the camera axis @@ -372,14 +372,16 @@ def acquisition(self, delta_y = float(delta_fast[1]) counter += 1 - attempts = 0 + while attempts < 4: if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") self.cancel_enabled.value = False return None if not timer else (0, 0.0) + attempts += 1 + try: # try to move the stage to the desired position, if it times out then try again up to 4 times tolerance_factor = 0.0001 stem_controller.set_control_output(shift_x_control_name, sx - delta_x, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) @@ -403,6 +405,7 @@ def acquisition(self, if not timer: # if performing the actual acquisition then update the progress bar and output window pct = int(100 * counter / total_images) self._set_progress_threadsafe(pct, total_images, f"Progress:\nAcquiring frame {counter} of {total_images}") + t2 = time.time() time_total = t2 - t1 @@ -454,17 +457,17 @@ def handle_estimate_time_clicked(self, widget: typing.Any) -> None: stem_controller = self.stem_controller camera = self.camera - target_width = (width, height) + result = self.acquisition(stem_controller, camera, defocus, target_width, timer=True, binning=binning) if result is None or len(result) != 3: return + master_data, total_images, t_total = result image_size = master_data.shape time_taken = t_total * total_images / 2 # average time to move the stage - self._append_output( - f"This acquisition will take approximately {(time_taken // 3600):.0f}h {((time_taken % 3600) / 60):.0f}min {(time_taken % 60):.0f}s" - ) + self._append_output(f"This acquisition will take approximately {(time_taken // 3600):.0f}h {((time_taken % 3600) / 60):.0f}min {(time_taken % 60):.0f}s") + if any(dimension > max_size for dimension in image_size): self._append_output("The final data item is too large to be used in the sample navigation window. Consider increasing the binning or reducing the size of the acquisition.\n") return From 475355b502021d8d3dd31f6ccd0480565da5ed58 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Tue, 15 Sep 2026 10:04:54 +0100 Subject: [PATCH 21/31] converting scale height from diameter to radius --- nionswift_plugin/nion_experimental_tools/overview_scan_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index cba9aa4..c201d0c 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -554,7 +554,7 @@ async def _run_acquisition_async(self, cartridge_string = cartridge_result.value self._append_output_threadsafe(f"Cartridge in stage: {cartridge_string}") - properties: JSONDict = {"ImageScaleRad_m": total_image_height, "ImageOffsetX_px": sx / pixel_size, "ImageOffsetY_px": sy / pixel_size, "ImageFile": str(export_path)} + properties: JSONDict = {"ImageScaleRad_m": total_image_height // 2, "ImageOffsetX_px": sx / pixel_size, "ImageOffsetY_px": sy / pixel_size, "ImageFile": str(export_path)} # Set the values on the cartridge From 09dd6d817db6a426e0b35cb219232de1467d2fcb Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Tue, 15 Sep 2026 10:18:07 +0100 Subject: [PATCH 22/31] placating mypy --- nionswift_plugin/nion_experimental_tools/overview_scan_panel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index c201d0c..c0ee01a 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -302,7 +302,6 @@ def acquisition(self, # this allows the plugin to run on uSim shift_x_control_name = "stage_position_m.x" shift_y_control_name = "stage_position_m.y" - matrix = None frame = camera.grab_next_to_start()[0] assert frame is not None From b856aa904bc15b92b2789c96636fee10b3aa1bfb Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Tue, 15 Sep 2026 13:43:24 +0100 Subject: [PATCH 23/31] Update overview_scan_panel.py --- .../overview_scan_panel.py | 69 ++++++++----------- 1 file changed, 27 insertions(+), 42 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index c0ee01a..c1ba720 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -22,7 +22,7 @@ _ = gettext.gettext JSONDict = dict[str, typing.Any] -max_size = 20000 # this is the maximum size of the final image in pixels that can be pushed to the sample navigation window. Placeholder value at the moment because something weird is happening with AS2 where the max possible size is decreasing +max_size = 32000 # this is the maximum size of the final image in pixels that can be pushed to the sample navigation window. Placeholder value at the moment because something weird is happening with AS2 where the max possible size is decreasing class OverviewScanPanelUI: @@ -64,6 +64,7 @@ def __init__(self, self._cancel_requested: bool = False self._is_running: bool = False self.cancel_enabled = Model.PropertyModel(False) + self.scan_buttons_enabled = Model.PropertyModel(True) self.ui_view = self._build_ui() def _set_progress(self, value: int, maximum: int, text: str) -> None: @@ -90,9 +91,9 @@ def _build_ui() -> typing.Mapping[str, typing.Any]: """ u = Declarative.DeclarativeUI() title = u.create_label(text="Overview Scan", font="bold") - time_button = u.create_push_button(text="Estimate scan size and duration", on_clicked="handle_estimate_time_clicked") - acq_button = u.create_push_button(text="Scan", on_clicked="handle_perform_acquisition_clicked") - max_button = u.create_push_button(text="Maximum scan at defocus", on_clicked="handle_max_clicked") + time_button = u.create_push_button(text="Estimate scan size and duration", on_clicked="handle_estimate_time_clicked", enabled="@binding(scan_buttons_enabled.value)") + acq_button = u.create_push_button(text="Scan", on_clicked="handle_perform_acquisition_clicked", enabled="@binding(scan_buttons_enabled.value)") + max_button = u.create_push_button(text="Calculate maximum scan", on_clicked="handle_max_clicked", enabled="@binding(scan_buttons_enabled.value)") properties_label = u.create_label(text="Desired properties of image:") width_label = u.create_label(text="Width (um):", width=80) width_field = u.create_line_edit(text="@binding(width_value)", width=50, editable=True) @@ -107,7 +108,7 @@ def _build_ui() -> typing.Mapping[str, typing.Any]: progress_label = u.create_label(text="@binding(progress_text)") progress_bar = u.create_progress_bar(value="@binding(progress_value)", minimum=0, maximum=100, width=600) cancel_button = u.create_push_button(text="Cancel", on_clicked="handle_cancel_acquisition_clicked", enabled="@binding(cancel_enabled.value)") - clear_button = u.create_push_button(text="Clear minimap", on_clicked="handle_clear_minimap_clicked") + clear_button = u.create_push_button(text="Clear minimap", on_clicked="handle_clear_minimap_clicked", enabled="@binding(scan_buttons_enabled.value)") overview_scan_ui = u.create_column( title, @@ -118,13 +119,14 @@ def _build_ui() -> typing.Mapping[str, typing.Any]: u.create_row(u.create_column(defocus_label, spacing=0), u.create_column(defocus_field, spacing=0), spacing=2), u.create_row(u.create_column(binning_label, spacing=0), u.create_column(binning_field, spacing=0), spacing=8), ), - u.create_row(time_button, acq_button, max_button, spacing=4), + u.create_row(max_button, time_button, acq_button, spacing=4), u.create_spacing(8), progress_label, progress_bar, u.create_spacing(8), - u.create_row(cancel_button, u.create_spacing(4), clear_button), - u.create_spacing(8), + u.create_row(cancel_button), + u.create_spacing(4), + u.create_row(clear_button), output_label, output_box, u.create_stretch(), @@ -289,6 +291,7 @@ def acquisition(self, self._cancel_requested = False self._is_running = True self.cancel_enabled.value = True + self.scan_buttons_enabled.value = False success, tv_pixel_angle_rad = stem_controller.TryGetVal("TVPixelAngle") # if success is False, the plugin is likely being run on uSim matrix = self.find_matrix() @@ -328,11 +331,9 @@ def acquisition(self, master_data = numpy.empty((sub_area[1][0] * dimensions[0], sub_area[1][1] * dimensions[1])) # create an empty array to hold the final image data if not timer: # if performing the full acquisition instead of just estimating the time, update the progress bar and output window - self._append_output_threadsafe(f"Stage starting position: {sx * 1e6, sy * 1e6} um") + self._append_output_threadsafe(f"Stage starting position: {(sx * 1e6):.3f}, {(sy * 1e6):.3f} um") self._append_output_threadsafe(f"Pixel size: {(pixel_size * 1e9):.3f} nm") - self._append_output_threadsafe(f"Defocus: {(defocus * 1e9):.0f} nm") - - self._append_output_threadsafe(f"Frame width: {frame_width * 1e6} um") + self._append_output_threadsafe(f"Frame width: {(frame_width * 1e6):.3f} um") self._append_output_threadsafe(f"Master size: {master_data.shape}\n") self._set_progress_threadsafe(0, total_images, "Progress:\nStarting acquisition...") @@ -348,6 +349,7 @@ def acquisition(self, if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True return None if not timer else (0, 0.0) # acquisition algorithm in a snake pattern @@ -356,6 +358,7 @@ def acquisition(self, if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True return None if not timer else (0, 0.0) if shift_x_control_name == "stage_position_m.x": # if the plugin is being run on uSim then correction for stage axis is not needed as can move straight along the camera axis @@ -377,6 +380,7 @@ def acquisition(self, if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True return None if not timer else (0, 0.0) attempts += 1 @@ -418,9 +422,11 @@ def acquisition(self, if timer: self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True return master_data, total_images, time_total else: self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True return master_data, sub_area, sub_area_shift, pixel_size, total_image_height, sx, sy def handle_cancel_acquisition_clicked(self, widget: typing.Any) -> None: @@ -431,6 +437,7 @@ def handle_cancel_acquisition_clicked(self, widget: typing.Any) -> None: self._cancel_requested = True self._set_progress_threadsafe(self.progress_value, 100, "Cancel requested...") + def handle_estimate_time_clicked(self, widget: typing.Any) -> None: """ Estimates the time an acquisition will take by averaging the time it takes to capture two frames and multiplying by the total number of frames required for the acquisition. @@ -447,9 +454,6 @@ def handle_estimate_time_clicked(self, widget: typing.Any) -> None: if width < 1 or height < 1 or binning < 1: self._append_output("Please ensure width and height are positive.") return - if width >= 1000 or height >= 1000: - self._append_output("Warning: Requested scan size is outside of sensible limit") - return if abs(defocus * 1e9) < 1000 or abs(defocus * 1e9) > 500000: self._append_output("Warning: Requested defocus is outside of safe limit") return @@ -471,6 +475,7 @@ def handle_estimate_time_clicked(self, widget: typing.Any) -> None: self._append_output("The final data item is too large to be used in the sample navigation window. Consider increasing the binning or reducing the size of the acquisition.\n") return else: + self._append_output(f"The final data item will have dimensions {image_size[0]} x {image_size[1]} pixels.\n") return async def _run_acquisition_async(self, @@ -503,6 +508,7 @@ async def _run_acquisition_async(self, except Exception as e: self._append_output(f"Acquisition failed: {e!r}") self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True return try: @@ -523,8 +529,8 @@ async def _run_acquisition_async(self, self._append_output("Acquisition complete.\n") self._append_output("Image properties:") - self._append_output_threadsafe(f"Total image height: {total_image_height * 1e3} mm") - self._append_output_threadsafe(f"Original stage coordinates: {sx * 1e6, sy * 1e6} um") + self._append_output_threadsafe(f"Total image height: {(total_image_height * 1e3):.3f} mm") + self._append_output_threadsafe(f"Original stage coordinates: {(sx * 1e6):.3f}, {(sy * 1e6):.3f} um") # convert the data to uint8 and save as a jpg data_array = numpy.array(xdata) @@ -544,6 +550,7 @@ async def _run_acquisition_async(self, except Exception as e: self._append_output(f"Failed to publish result: {e!r}") self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True return # push the image, scale height and offsets to the sample navigation map @@ -552,8 +559,7 @@ async def _run_acquisition_async(self, if cartridge_result.is_valid: cartridge_string = cartridge_result.value self._append_output_threadsafe(f"Cartridge in stage: {cartridge_string}") - - properties: JSONDict = {"ImageScaleRad_m": total_image_height // 2, "ImageOffsetX_px": sx / pixel_size, "ImageOffsetY_px": sy / pixel_size, "ImageFile": str(export_path)} + properties: JSONDict = {"ImageScaleRad_m": total_image_height / 2, "ImageOffsetX_px": sx / pixel_size, "ImageOffsetY_px": sy / pixel_size, "ImageFile": str(export_path)} # Set the values on the cartridge @@ -567,6 +573,7 @@ async def _run_acquisition_async(self, except Exception as e: self._append_output(f"Failed to update cartridge data: {e!r}") self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True return def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: @@ -587,9 +594,6 @@ def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: if width < 1 or height < 1 or binning < 1: self._append_output("Please ensure width and height are positive.") return - if width >= 1000 or height >= 1000: - self._append_output("Warning: Requested scan size is outside of sensible limit") - return if abs(defocus * 1e9) < 1000 or abs(defocus * 1e9) > 500000: self._append_output("Warning: Requested defocus is outside of safe limit") return @@ -606,6 +610,7 @@ def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: self._run_acquisition_async(stem_controller, camera, defocus, target_width, binning) ) self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True def handle_max_clicked(self, widget: typing.Any) -> None: """ @@ -630,10 +635,6 @@ def handle_max_clicked(self, widget: typing.Any) -> None: stem_controller = self.stem_controller camera = self.camera - self._cancel_requested = False - self._is_running = True - self.cancel_enabled.value = True - # calculating the maximum scan size at the specified defocus/binning for the image to be pushed to the sample navigation map success, tv_pixel_angle_rad = stem_controller.TryGetVal("TVPixelAngle") @@ -655,22 +656,6 @@ def handle_max_clicked(self, widget: typing.Any) -> None: self.property_changed_event.fire("width_value") self.property_changed_event.fire("height_value") - target_width = (int(self.width_value), int(self.height_value)) - - result = self.acquisition(stem_controller, camera, defocus, target_width, timer=True, binning=binning) - if result is None or len(result) != 3: - return - master_data, total_images, t_total = result - time_taken = t_total * total_images / 2 # average time to move the stage - self._append_output( - f"This acquisition will take approximately {(time_taken // 3600):.0f}h {((time_taken % 3600) / 60):.0f}min {(time_taken % 60):.0f}s\n" - ) - - self._acq_task = self._event_loop.create_task( - self._run_acquisition_async(stem_controller, camera, defocus, target_width, binning) - ) - self.cancel_enabled.value = False - def handle_clear_minimap_clicked(self, widget: typing.Any) -> None: """ Clears the image, scale height and offsets from the sample navigation map. From 3e229c2b212049aa5fc299d8eac3a81d3b65309d Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Wed, 16 Sep 2026 09:59:13 +0100 Subject: [PATCH 24/31] alternative scan patterns --- .../overview_scan_panel.py | 145 ++++++++++++++++-- 1 file changed, 131 insertions(+), 14 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index c1ba720..b08d500 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -253,6 +253,60 @@ def find_dimensions(stem_controller: stem_controller_module.STEMController, return pixel_size, frame_size, frame_width, master_sub_area, master_sub_area_size, sub_area_shift, sub_area + def spiral_indices(self, rows: int, cols: int) -> typing.Iterator[tuple[int, int]]: + # Start from center tile. + r = rows // 2 + c = cols // 2 + + yielded = 0 + total = rows * cols + + # Emit start if valid. + if 0 <= r < rows and 0 <= c < cols: + yield r, c + yielded += 1 + + # Spiral: right, down, left, up with step lengths 1,1,2,2,3,3,... + step_len = 1 + while yielded < total: + # right + for _ in range(step_len): + c += 1 + if 0 <= r < rows and 0 <= c < cols: + yield r, c + yielded += 1 + if yielded >= total: + return + # down + for _ in range(step_len): + r += 1 + if 0 <= r < rows and 0 <= c < cols: + yield r, c + yielded += 1 + if yielded >= total: + return + + step_len += 1 + + # left + for _ in range(step_len): + c -= 1 + if 0 <= r < rows and 0 <= c < cols: + yield r, c + yielded += 1 + if yielded >= total: + return + # up + for _ in range(step_len): + r -= 1 + if 0 <= r < rows and 0 <= c < cols: + yield r, c + yielded += 1 + if yielded >= total: + return + + step_len += 1 + def acquisition(self, stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, @@ -344,23 +398,84 @@ def acquisition(self, dimensions = (2, 1) # for timing purposes, only need to acquire 2 frames and average the time to take them both try: - for row in range(dimensions[0]): + #for row in range(dimensions[0]): # cancel mechanism - if self._cancel_requested: - self._append_output_threadsafe("Acquisition Cancelled.") - self.cancel_enabled.value = False - self.scan_buttons_enabled.value = True - return None if not timer else (0, 0.0) + # if self._cancel_requested: + # self._append_output_threadsafe("Acquisition Cancelled.") + # self.cancel_enabled.value = False + # self.scan_buttons_enabled.value = True + # return None if not timer else (0, 0.0) # acquisition algorithm in a snake pattern - col_iter = range(dimensions[1]) if (row % 2 == 0) else range(dimensions[1] - 1, -1, -1) - for column in col_iter: + + ###SNAKE + # col_iter = range(dimensions[1]) if (row % 2 == 0) else range(dimensions[1] - 1, -1, -1) + # for column in col_iter: + + ###LINE + #for column in range(dimensions[1]): + # if self._cancel_requested: + # self._append_output_threadsafe("Acquisition Cancelled.") + # self.cancel_enabled.value = False + # self.scan_buttons_enabled.value = True + # return None if not timer else (0, 0.0) + # + # if shift_x_control_name == "stage_position_m.x": # if the plugin is being run on uSim then correction for stage axis is not needed as can move straight along the camera axis + # delta_x = - sub_area_shift * (column - dimensions[1] // 2) + # delta_y = - sub_area_shift * (row - dimensions[0] // 2) + # else: # if the plugin is being run on a microscope need to transform every movement from the stage axis to the camera axis + # delta_x = - sub_area_shift * (column - dimensions[1] // 2) + # delta_y = - sub_area_shift * (row - dimensions[0] // 2) + # delta_camera = numpy.array([delta_x, delta_y], dtype=numpy.float64) + # delta_fast = numpy.linalg.solve(matrix, delta_camera) + # + # delta_x = float(delta_fast[0]) + # delta_y = float(delta_fast[1]) + # + # counter += 1 + # attempts = 0 + # + # while attempts < 4: + # if self._cancel_requested: + # self._append_output_threadsafe("Acquisition Cancelled.") + # self.cancel_enabled.value = False + # self.scan_buttons_enabled.value = True + # return None if not timer else (0, 0.0) + # + # attempts += 1 + # + # try: # try to move the stage to the desired position, if it times out then try again up to 4 times + # tolerance_factor = 0.0001 + # stem_controller.set_control_output(shift_x_control_name, sx - delta_x, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + # stem_controller.set_control_output(shift_y_control_name, sy - delta_y, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) + # except TimeoutError: + # self._append_output_threadsafe(f"Timeout row= {row} column= {column}") + # continue + # break + # + # # adding the new frame to the data item + # supradata = camera.grab_next_to_start()[0] + # assert supradata is not None + # + # data = supradata.data[master_sub_area[0][0]:master_sub_area[0][0] + master_sub_area[1][0]:binning, master_sub_area[0][1]:master_sub_area[0][1] + master_sub_area[1][1]:binning] + # slice_row = row + # slice_column = column + # slice0 = slice(slice_row * sub_area[1][0], (slice_row + 1) * sub_area[1][0]) + # slice1 = slice(slice_column * sub_area[1][1], (slice_column + 1) * sub_area[1][1]) + # master_data[slice0, slice1] = data + # + # if not timer: # if performing the actual acquisition then update the progress bar and output window + # pct = int(100 * counter / total_images) + # self._set_progress_threadsafe(pct, total_images, f"Progress:\nAcquiring frame {counter} of {total_images}") + + ###########SPIRAL + + for row, column in self.spiral_indices(dimensions[0], dimensions[1]): if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") self.cancel_enabled.value = False self.scan_buttons_enabled.value = True return None if not timer else (0, 0.0) - if shift_x_control_name == "stage_position_m.x": # if the plugin is being run on uSim then correction for stage axis is not needed as can move straight along the camera axis delta_x = - sub_area_shift * (column - dimensions[1] // 2) delta_y = - sub_area_shift * (row - dimensions[0] // 2) @@ -409,8 +524,10 @@ def acquisition(self, pct = int(100 * counter / total_images) self._set_progress_threadsafe(pct, total_images, f"Progress:\nAcquiring frame {counter} of {total_images}") - t2 = time.time() - time_total = t2 - t1 + t2 = time.time() + time_total = t2 - t1 + + finally: # restore stage to original location @@ -454,7 +571,7 @@ def handle_estimate_time_clicked(self, widget: typing.Any) -> None: if width < 1 or height < 1 or binning < 1: self._append_output("Please ensure width and height are positive.") return - if abs(defocus * 1e9) < 1000 or abs(defocus * 1e9) > 500000: + if abs(defocus * 1e9) > 500000: self._append_output("Warning: Requested defocus is outside of safe limit") return @@ -594,7 +711,7 @@ def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: if width < 1 or height < 1 or binning < 1: self._append_output("Please ensure width and height are positive.") return - if abs(defocus * 1e9) < 1000 or abs(defocus * 1e9) > 500000: + if abs(defocus * 1e9) > 500000: self._append_output("Warning: Requested defocus is outside of safe limit") return @@ -624,7 +741,7 @@ def handle_max_clicked(self, widget: typing.Any) -> None: except ValueError: self._append_output("Please enter defocus and binning as integers.") return - if abs(defocus * 1e9) < 1000 or abs(defocus * 1e9) > 500000: + if abs(defocus * 1e9) > 500000: self._append_output("Warning: Requested defocus is outside of sensible limit") return From 78f86021157f1d6adf07d841a2f83addebb837ed Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Mon, 21 Sep 2026 11:16:08 +0100 Subject: [PATCH 25/31] confirming snake scan pattern --- .../overview_scan_panel.py | 133 ++---------------- 1 file changed, 9 insertions(+), 124 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index b08d500..d06f9df 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -253,60 +253,6 @@ def find_dimensions(stem_controller: stem_controller_module.STEMController, return pixel_size, frame_size, frame_width, master_sub_area, master_sub_area_size, sub_area_shift, sub_area - def spiral_indices(self, rows: int, cols: int) -> typing.Iterator[tuple[int, int]]: - # Start from center tile. - r = rows // 2 - c = cols // 2 - - yielded = 0 - total = rows * cols - - # Emit start if valid. - if 0 <= r < rows and 0 <= c < cols: - yield r, c - yielded += 1 - - # Spiral: right, down, left, up with step lengths 1,1,2,2,3,3,... - step_len = 1 - while yielded < total: - # right - for _ in range(step_len): - c += 1 - if 0 <= r < rows and 0 <= c < cols: - yield r, c - yielded += 1 - if yielded >= total: - return - # down - for _ in range(step_len): - r += 1 - if 0 <= r < rows and 0 <= c < cols: - yield r, c - yielded += 1 - if yielded >= total: - return - - step_len += 1 - - # left - for _ in range(step_len): - c -= 1 - if 0 <= r < rows and 0 <= c < cols: - yield r, c - yielded += 1 - if yielded >= total: - return - # up - for _ in range(step_len): - r -= 1 - if 0 <= r < rows and 0 <= c < cols: - yield r, c - yielded += 1 - if yielded >= total: - return - - step_len += 1 - def acquisition(self, stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, @@ -398,84 +344,23 @@ def acquisition(self, dimensions = (2, 1) # for timing purposes, only need to acquire 2 frames and average the time to take them both try: - #for row in range(dimensions[0]): + for row in range(dimensions[0]): # cancel mechanism - # if self._cancel_requested: - # self._append_output_threadsafe("Acquisition Cancelled.") - # self.cancel_enabled.value = False - # self.scan_buttons_enabled.value = True - # return None if not timer else (0, 0.0) + if self._cancel_requested: + self._append_output_threadsafe("Acquisition Cancelled.") + self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True + return None if not timer else (0, 0.0) # acquisition algorithm in a snake pattern - - ###SNAKE - # col_iter = range(dimensions[1]) if (row % 2 == 0) else range(dimensions[1] - 1, -1, -1) - # for column in col_iter: - - ###LINE - #for column in range(dimensions[1]): - # if self._cancel_requested: - # self._append_output_threadsafe("Acquisition Cancelled.") - # self.cancel_enabled.value = False - # self.scan_buttons_enabled.value = True - # return None if not timer else (0, 0.0) - # - # if shift_x_control_name == "stage_position_m.x": # if the plugin is being run on uSim then correction for stage axis is not needed as can move straight along the camera axis - # delta_x = - sub_area_shift * (column - dimensions[1] // 2) - # delta_y = - sub_area_shift * (row - dimensions[0] // 2) - # else: # if the plugin is being run on a microscope need to transform every movement from the stage axis to the camera axis - # delta_x = - sub_area_shift * (column - dimensions[1] // 2) - # delta_y = - sub_area_shift * (row - dimensions[0] // 2) - # delta_camera = numpy.array([delta_x, delta_y], dtype=numpy.float64) - # delta_fast = numpy.linalg.solve(matrix, delta_camera) - # - # delta_x = float(delta_fast[0]) - # delta_y = float(delta_fast[1]) - # - # counter += 1 - # attempts = 0 - # - # while attempts < 4: - # if self._cancel_requested: - # self._append_output_threadsafe("Acquisition Cancelled.") - # self.cancel_enabled.value = False - # self.scan_buttons_enabled.value = True - # return None if not timer else (0, 0.0) - # - # attempts += 1 - # - # try: # try to move the stage to the desired position, if it times out then try again up to 4 times - # tolerance_factor = 0.0001 - # stem_controller.set_control_output(shift_x_control_name, sx - delta_x, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) - # stem_controller.set_control_output(shift_y_control_name, sy - delta_y, {"confirm": True, "confirm_tolerance_factor": tolerance_factor}) - # except TimeoutError: - # self._append_output_threadsafe(f"Timeout row= {row} column= {column}") - # continue - # break - # - # # adding the new frame to the data item - # supradata = camera.grab_next_to_start()[0] - # assert supradata is not None - # - # data = supradata.data[master_sub_area[0][0]:master_sub_area[0][0] + master_sub_area[1][0]:binning, master_sub_area[0][1]:master_sub_area[0][1] + master_sub_area[1][1]:binning] - # slice_row = row - # slice_column = column - # slice0 = slice(slice_row * sub_area[1][0], (slice_row + 1) * sub_area[1][0]) - # slice1 = slice(slice_column * sub_area[1][1], (slice_column + 1) * sub_area[1][1]) - # master_data[slice0, slice1] = data - # - # if not timer: # if performing the actual acquisition then update the progress bar and output window - # pct = int(100 * counter / total_images) - # self._set_progress_threadsafe(pct, total_images, f"Progress:\nAcquiring frame {counter} of {total_images}") - - ###########SPIRAL - - for row, column in self.spiral_indices(dimensions[0], dimensions[1]): + col_iter = range(dimensions[1]) if (row % 2 == 0) else range(dimensions[1] - 1, -1, -1) + for column in col_iter: if self._cancel_requested: self._append_output_threadsafe("Acquisition Cancelled.") self.cancel_enabled.value = False self.scan_buttons_enabled.value = True return None if not timer else (0, 0.0) + if shift_x_control_name == "stage_position_m.x": # if the plugin is being run on uSim then correction for stage axis is not needed as can move straight along the camera axis delta_x = - sub_area_shift * (column - dimensions[1] // 2) delta_y = - sub_area_shift * (row - dimensions[0] // 2) From 69f22f3cf85fc2092abeea87af425629a62027e0 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Mon, 21 Sep 2026 16:10:31 +0100 Subject: [PATCH 26/31] integrating stem_controller.axis_transform_point --- .../overview_scan_panel.py | 89 +++++-------------- 1 file changed, 20 insertions(+), 69 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index d06f9df..0a6cae5 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -17,14 +17,13 @@ from nion.swift.model import PlugInManager from nion.typeshed import API_1_0 from nion.ui import Declarative -from nion.utils import Model +from nion.utils import Model, Geometry from nion.utils import Registry _ = gettext.gettext JSONDict = dict[str, typing.Any] max_size = 32000 # this is the maximum size of the final image in pixels that can be pushed to the sample navigation window. Placeholder value at the moment because something weird is happening with AS2 where the max possible size is decreasing - class OverviewScanPanelUI: panel_type = "overview-scan-panel" @@ -149,60 +148,15 @@ def _append_output_threadsafe(self, message: str) -> None: """ self._event_loop.call_soon_threadsafe(self._append_output, message) - def find_matrix(self, ds: float = 16e-6) -> numpy.ndarray: - """ - Calculate the transformation matrix from stage coordinates to camera coordinates by moving the stage in small increments and measuring the resulting changes in camera coordinates. - This is done because moving along the stage axis is much faster than moving along the camera axis as it requires fewer moves. - - Args: - - ds: the step size by which the stage is moved in the x and y directions to measure the resulting changes in camera coordinates. - - Returns: - - matrix: a 2x2 numpy array representing the transformation matrix from stage coordinates to camera coordinates. - """ - stem_controller = self.stem_controller - - # Get original stage position in both stage and camera coordinates - sx0 = stem_controller.get_control_output("SShft.sx") - sy0 = stem_controller.get_control_output("SShft.sy") - x0 = stem_controller.get_control_output("SShft.x") - y0 = stem_controller.get_control_output("SShft.y") - - # Move a small amount in x direction in the stage axis and then measure the change in x and y in the camera axis - stem_controller.set_control_output("SShft.sx", sx0 + ds) - x1 = stem_controller.get_control_output("SShft.x") - y1 = stem_controller.get_control_output("SShft.y") - - dx_from_sx = x1 - x0 - dy_from_sx = y1 - y0 - - # Put the stage back to its original position - stem_controller.set_control_output("SShft.sx", sx0) - stem_controller.set_control_output("SShft.sy", sy0) - stem_controller.set_control_output("SShft.x", x0) - stem_controller.set_control_output("SShft.y", y0) - - # Move a small amount in y direction in the stage axis and then measure the change in x and y in the camera axis - stem_controller.set_control_output("SShft.sy", sy0 + ds) - x2 = stem_controller.get_control_output("SShft.x") - y2 = stem_controller.get_control_output("SShft.y") - - dx_from_sy = x2 - x0 - dy_from_sy = y2 - y0 - - # Put the stage back to its original position - stem_controller.set_control_output("SShft.sy", sy0) - stem_controller.set_control_output("SShft.sx", sx0) - stem_controller.set_control_output("SShft.x", x0) - stem_controller.set_control_output("SShft.y", y0) - - # Construct the transformation matrix from stage coordinates to camera coordinates - matrix = numpy.array([ - [dx_from_sx / ds, dx_from_sy / ds], - [dy_from_sx / ds, dy_from_sy / ds], - ]) - - return matrix + def _get_axis_description(self, axis_name: str) -> stem_controller_module.AxisDescription: + for axis_description in self.stem_controller.axis_descriptions: + if axis_description.axis_id == axis_name: + return axis_description + if axis_description.display_name == axis_name: + return axis_description + if getattr(axis_description, "searchable_name", None) == axis_name: + return axis_description + raise ValueError(f"Axis '{axis_name}' not found.") @staticmethod def find_dimensions(stem_controller: stem_controller_module.STEMController, @@ -294,10 +248,9 @@ def acquisition(self, self.scan_buttons_enabled.value = False success, tv_pixel_angle_rad = stem_controller.TryGetVal("TVPixelAngle") # if success is False, the plugin is likely being run on uSim - matrix = self.find_matrix() - if success and matrix is not None and numpy.linalg.det(matrix) != 0 and len(matrix) != 0: #even if success is True, it could still be on usim- this would give an empty or singular matrix so can guard against non-uSim controls being used - # this branch will run where the plugin is used on an actual microscope + if success: #even if success is True, it could still be on usim- this would give an empty or singular matrix so can guard against non-uSim controls being used + # this branch will run where the plugin is used on an actual microscope shift_x_control_name = "SShft.sx" shift_y_control_name = "SShft.sy" @@ -361,15 +314,15 @@ def acquisition(self, self.scan_buttons_enabled.value = True return None if not timer else (0, 0.0) - if shift_x_control_name == "stage_position_m.x": # if the plugin is being run on uSim then correction for stage axis is not needed as can move straight along the camera axis - delta_x = - sub_area_shift * (column - dimensions[1] // 2) - delta_y = - sub_area_shift * (row - dimensions[0] // 2) - else: # if the plugin is being run on a microscope need to transform every movement from the stage axis to the camera axis - delta_x = - sub_area_shift * (column - dimensions[1] // 2) - delta_y = - sub_area_shift * (row - dimensions[0] // 2) - delta_camera = numpy.array([delta_x, delta_y], dtype=numpy.float64) - delta_fast = numpy.linalg.solve(matrix, delta_camera) + delta_x = - sub_area_shift * (column - dimensions[1] // 2) + delta_y = - sub_area_shift * (row - dimensions[0] // 2) + + stage_axis = self._get_axis_description("StageAxis") + camera_axis = self._get_axis_description("TV") + + delta_fast = stem_controller.axis_transform_point(Geometry.FloatPoint(y=delta_y, x=delta_x),from_axis=stage_axis, to_axis=camera_axis) + if delta_fast: delta_x = float(delta_fast[0]) delta_y = float(delta_fast[1]) @@ -412,8 +365,6 @@ def acquisition(self, t2 = time.time() time_total = t2 - t1 - - finally: # restore stage to original location stem_controller.set_control_output(shift_x_control_name, sx) From eb4d30583b1cb90a5736959a3c31031a6b6c693c Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Fri, 25 Sep 2026 17:00:10 +0100 Subject: [PATCH 27/31] adding setters for line edit properties and tidying up --- .../overview_scan_panel.py | 191 ++++++++++-------- 1 file changed, 107 insertions(+), 84 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index 0a6cae5..6627e88 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -4,9 +4,8 @@ import gettext import math import numpy -import numpy.typing as npt -from pathlib import Path -from PIL import Image +import numpy.typing +import pathlib import time from nion.instrumentation import camera_base @@ -14,14 +13,17 @@ from nion.swift import DocumentController from nion.swift import Panel from nion.swift import Workspace +from nion.swift.model import ImportExportManager from nion.swift.model import PlugInManager from nion.typeshed import API_1_0 from nion.ui import Declarative -from nion.utils import Model, Geometry +from nion.utils import Converter +from nion.utils import Geometry +from nion.utils import Model from nion.utils import Registry _ = gettext.gettext -JSONDict = dict[str, typing.Any] +JSONType = stem_controller_module.JSONType max_size = 32000 # this is the maximum size of the final image in pixels that can be pushed to the sample navigation window. Placeholder value at the moment because something weird is happening with AS2 where the max possible size is decreasing class OverviewScanPanelUI: @@ -50,10 +52,12 @@ def __init__(self, self.stem_controller = typing.cast(stem_controller_module.STEMController, Registry.get_component('stem_controller')) self.camera = typing.cast(camera_base.CameraHardwareSource, self.stem_controller.ronchigram_camera) self._document_controller = document_controller - self.width_value: str = "30" - self.height_value: str = "30" - self.defocus: str = "-50000" - self.binning: str = "1" + self.integer_to_string_converter = Converter.IntegerToStringConverter() + self.float_to_string_converter = Converter.FloatToStringConverter(pass_none=True) + self._width_value: int = 30 + self._height_value: int = 30 + self._defocus: float = -5e-05 + self._binning: int = 1 self.output_text: str = "" self.progress_value: int = 0 self.progress_max: int = 100 @@ -66,6 +70,58 @@ def __init__(self, self.scan_buttons_enabled = Model.PropertyModel(True) self.ui_view = self._build_ui() + @property + def width_value(self) -> int: + return self._width_value + + @width_value.setter + def width_value(self, value: int | None) -> None: + if value is None or value < 1: + self._append_output_threadsafe("Width must be a positive integer.") + return + if value != self._width_value: + self._width_value = value + self.property_changed_event.fire("width_value") + + @property + def height_value(self) -> int: + return self._height_value + + @height_value.setter + def height_value(self, value: int | None) -> None: + if value is None or value < 1: + self._append_output_threadsafe("Width must be a positive integer.") + return + if value != self._height_value: + self._height_value = value + self.property_changed_event.fire("height_value") + + @property + def binning(self) -> int: + return self._binning + + @binning.setter + def binning(self, value: int | None) -> None: + if value is None or value < 1: + self._append_output_threadsafe("Binning must be a positive integer.") + return + if value != self._binning: + self._binning = value + self.property_changed_event.fire("binning") + + @property + def defocus(self) -> float: + return self._defocus * 1e9 + + @defocus.setter + def defocus(self, value: float | None) -> None: + if value is None or abs(value * 1e9) > 500000: + self._append_output_threadsafe("Defocus must be between -500000 and 500000 nm.") + return + if value != self._defocus: + self._defocus = value + self.property_changed_event.fire("defocus") + def _set_progress(self, value: int, maximum: int, text: str) -> None: """ Set the progress value, maximum, and text for the progress bar. @@ -95,13 +151,13 @@ def _build_ui() -> typing.Mapping[str, typing.Any]: max_button = u.create_push_button(text="Calculate maximum scan", on_clicked="handle_max_clicked", enabled="@binding(scan_buttons_enabled.value)") properties_label = u.create_label(text="Desired properties of image:") width_label = u.create_label(text="Width (um):", width=80) - width_field = u.create_line_edit(text="@binding(width_value)", width=50, editable=True) + width_field = u.create_line_edit(text="@binding(width_value, converter=integer_to_string_converter)", width=50, editable=True) height_label = u.create_label(text="Height (um):", width=80) - height_field = u.create_line_edit(text="@binding(height_value)", width=50, editable=True) + height_field = u.create_line_edit(text="@binding(height_value, converter=integer_to_string_converter)", width=50, editable=True) defocus_label = u.create_label(text="Defocus (nm):", width=80) - defocus_field = u.create_line_edit(text="@binding(defocus)", width=50, editable=True) + defocus_field = u.create_line_edit(text="@binding(defocus, converter=float_to_string_converter)", width=50, editable=True) binning_label = u.create_label(text="Binning:") - binning_field = u.create_line_edit(text="@binding(binning)", width=50, editable=True) + binning_field = u.create_line_edit(text="@binding(binning, converter=integer_to_string_converter)", width=50, editable=True) output_label = u.create_label(text="Output:") output_box = u.create_text_edit(text="@binding(output_text)", editable=False, height=200) progress_label = u.create_label(text="@binding(progress_text)") @@ -211,9 +267,9 @@ def acquisition(self, stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, defocus: float, - target_width: tuple[float | int, float | int], timer: bool = False, - binning: float = 1.0) -> (tuple[npt.NDArray[numpy.float64], int, float] | - tuple[npt.NDArray[numpy.float64], tuple[tuple[int, int], tuple[int, int]], float, float, float, float, float] | + target_width: int, target_height: int, timer: bool = False, + binning: float = 1.0) -> (tuple[numpy.typing.NDArray[numpy.float64], int, float] | + tuple[numpy.typing.NDArray[numpy.float64], tuple[tuple[int, int], tuple[int, int]], float, float, float, float, float] | tuple[int, float] | None): """ Move across the sample in a snake pattern, acquiring images at each position, and return the resulting data and relevant parameters. @@ -274,8 +330,8 @@ def acquisition(self, pixel_size, frame_size, frame_width, master_sub_area, master_sub_area_size, sub_area_shift, sub_area = self.find_dimensions(stem_controller, camera, defocus, tv_pixel_angle_rad, binning) # calculate the number of frames to cover the target area - frames_needed_width = math.ceil(target_width[0] * 1e-6 / sub_area_shift) - frames_needed_height = math.ceil(target_width[1] * 1e-6 / sub_area_shift) + frames_needed_width = math.ceil(target_width * 1e-6 / sub_area_shift) + frames_needed_height = math.ceil(target_height * 1e-6 / sub_area_shift) dimensions = (frames_needed_width, frames_needed_height) total_image_height = dimensions[1] * frame_width # calculate the height of the image in um @@ -292,6 +348,7 @@ def acquisition(self, self._set_progress_threadsafe(0, total_images, "Progress:\nStarting acquisition...") t1 = time.time() + time_total = 0.0 if timer: dimensions = (2, 1) # for timing purposes, only need to acquire 2 frames and average the time to take them both @@ -347,7 +404,7 @@ def acquisition(self, continue break - # adding the new frame to the data item + # adding the new frame to the data item supradata = camera.grab_next_to_start()[0] assert supradata is not None @@ -382,7 +439,7 @@ def acquisition(self, self.scan_buttons_enabled.value = True return master_data, sub_area, sub_area_shift, pixel_size, total_image_height, sx, sy - def handle_cancel_acquisition_clicked(self, widget: typing.Any) -> None: + def handle_cancel_acquisition_clicked(self, widget: Declarative.UIWidget) -> None: """ Cancel button: off when the acquisition is not running, on when it is. """ @@ -391,31 +448,25 @@ def handle_cancel_acquisition_clicked(self, widget: typing.Any) -> None: self._set_progress_threadsafe(self.progress_value, 100, "Cancel requested...") - def handle_estimate_time_clicked(self, widget: typing.Any) -> None: + def handle_estimate_time_clicked(self, widget: Declarative.UIWidget) -> None: """ Estimates the time an acquisition will take by averaging the time it takes to capture two frames and multiplying by the total number of frames required for the acquisition. """ # guardrails to make sure width, height, defocus and binning are all integers and within sensible limits try: - width = int(self.width_value) - height = int(self.height_value) - defocus = int(self.defocus) * 1e-9 - binning = int(self.binning) + width = self.width_value + height = self.height_value + defocus = self.defocus * 1e-9 + binning = self._binning except ValueError: self._append_output("Please enter width, height, binning and defocus as integers.") return - if width < 1 or height < 1 or binning < 1: - self._append_output("Please ensure width and height are positive.") - return - if abs(defocus * 1e9) > 500000: - self._append_output("Warning: Requested defocus is outside of safe limit") - return + stem_controller = self.stem_controller camera = self.camera - target_width = (width, height) - result = self.acquisition(stem_controller, camera, defocus, target_width, timer=True, binning=binning) + result = self.acquisition(stem_controller, camera, defocus, width, height, timer=True, binning=binning) if result is None or len(result) != 3: return @@ -435,7 +486,7 @@ async def _run_acquisition_async(self, stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, defocus: float, - target_width: tuple[int, int], + target_width: int, target_height: int, binning: int) -> None: """ Performs acquisition asynchronously to avoid blocking the UI thread, then pushes results to the sample navigation map in AS2. @@ -452,7 +503,7 @@ async def _run_acquisition_async(self, self._append_output_threadsafe("Starting acquisition...\n") try: - result = await loop.run_in_executor(None, self.acquisition, stem_controller, camera, defocus, target_width, False, binning) + result = await loop.run_in_executor(None, self.acquisition, stem_controller, camera, defocus, target_width, target_height, False, binning) if result is None or len(result) != 7: self._set_progress(0, 100, "Progress:\nIdle") return @@ -477,7 +528,10 @@ async def _run_acquisition_async(self, xdata = self._api.create_data_and_metadata(master_data, dimensional_calibrations=dimensional_calibrations) # create final data item - library.create_data_item_from_data_and_metadata(xdata, "Composite Survey") + data_item = library.create_data_item_from_data_and_metadata(xdata, "Composite Survey") + display_item = self._document_controller.document_model.get_display_item_for_data_item(data_item) + data_path = pathlib.Path(r"C:\AS2\AS2User\Pictures\overview-scan.jpg") + ImportExportManager.ImportExportManager().write_display_item(display_item, data_path) self._append_output("Acquisition complete.\n") @@ -485,21 +539,6 @@ async def _run_acquisition_async(self, self._append_output_threadsafe(f"Total image height: {(total_image_height * 1e3):.3f} mm") self._append_output_threadsafe(f"Original stage coordinates: {(sx * 1e6):.3f}, {(sy * 1e6):.3f} um") - # convert the data to uint8 and save as a jpg - data_array = numpy.array(xdata) - data_min = float(numpy.min(data_array)) - data_max = float(numpy.max(data_array)) - data_range = data_max - data_min - - data_uint8 = ((data_array - data_min / data_range * 255).astype(numpy.uint8)) - - img = Image.fromarray(data_uint8) - export_path = Path(r"C:\AS2\AS2User\Pictures\overview-scan.jpg") - if not export_path.parent.exists(): - export_path.parent.mkdir(parents=True, exist_ok=True) - - img.save(export_path) - except Exception as e: self._append_output(f"Failed to publish result: {e!r}") self.cancel_enabled.value = False @@ -512,13 +551,13 @@ async def _run_acquisition_async(self, if cartridge_result.is_valid: cartridge_string = cartridge_result.value self._append_output_threadsafe(f"Cartridge in stage: {cartridge_string}") - properties: JSONDict = {"ImageScaleRad_m": total_image_height / 2, "ImageOffsetX_px": sx / pixel_size, "ImageOffsetY_px": sy / pixel_size, "ImageFile": str(export_path)} + properties: JSONType = {"ImageScaleRad_m": total_image_height / 2, "ImageOffsetX_px": sx / pixel_size, "ImageOffsetY_px": sy / pixel_size, "ImageFile": str(data_path)} # Set the values on the cartridge - stem_controller._put_rest_api(f"/exchange/cartridges/{cartridge_string}", content=properties) - if hasattr(cartridge_result, "is_valid") and not cartridge_result.is_valid: - self._append_output_threadsafe(f"PUT failed: {cartridge_result.exception}") + property_result = stem_controller._put_rest_api(f"/exchange/cartridges/{cartridge_string}", content=properties) + if not property_result.is_valid: + self._append_output_threadsafe(f"PUT failed: {property_result.exception}") else: self._append_output_threadsafe(f"Failed to get CartridgeInStage: {cartridge_result.exception}") return @@ -529,57 +568,43 @@ async def _run_acquisition_async(self, self.scan_buttons_enabled.value = True return - def handle_perform_acquisition_clicked(self, widget: typing.Any) -> None: + def handle_perform_acquisition_clicked(self, widget: Declarative.UIWidget) -> None: """ Starts the acquisition process by validating input parameters. Initiates the asynchronous acquisition task. """ # guardrails to make sure width, height, defocus and binning are all integers and within sensible limits try: - width = int(self.width_value) - height = int(self.height_value) - defocus = int(self.defocus) * 1e-9 - binning = int(self.binning) + width = self.width_value + height = self.height_value + defocus = self.defocus * 1e-9 + binning = self._binning except ValueError: self._append_output("Please enter width, height, binning and defocus as integers.") return - if width < 1 or height < 1 or binning < 1: - self._append_output("Please ensure width and height are positive.") - return - if abs(defocus * 1e9) > 500000: - self._append_output("Warning: Requested defocus is outside of safe limit") - return - if self._acq_task and not self._acq_task.done(): self._append_output("Acquisition already running.") return - stem_controller = self.stem_controller - camera = self.camera - target_width = (width, height) - self._acq_task = self._event_loop.create_task( - self._run_acquisition_async(stem_controller, camera, defocus, target_width, binning) + self._run_acquisition_async(self.stem_controller, self.camera, defocus, width, height, binning) ) self.cancel_enabled.value = False self.scan_buttons_enabled.value = True - def handle_max_clicked(self, widget: typing.Any) -> None: + def handle_max_clicked(self, widget: Declarative.UIWidget) -> None: """ Calculates the maximum scan size at the specified defocus/binning for the image to be pushed to the sample navigation map. Estimates the time it will take and performs the acquisition. """ # guardrails to make sure defocus and binning are both integers and within sensible limits try: - defocus = int(self.defocus) * 1e-9 - binning = int(self.binning) + defocus = self.defocus * 1e-9 + binning = self._binning except ValueError: self._append_output("Please enter defocus and binning as integers.") return - if abs(defocus * 1e9) > 500000: - self._append_output("Warning: Requested defocus is outside of sensible limit") - return if self._acq_task and not self._acq_task.done(): self._append_output("Acquisition already running.") @@ -604,12 +629,10 @@ def handle_max_clicked(self, widget: typing.Any) -> None: dimension_x = max_size // sub_area[1][1] # putting the calculated maximum scan size into the width and height fields in the UI - self.width_value = str(int(dimension_x * sub_area_shift * 1e6)) - self.height_value = str(int(dimension_y * sub_area_shift * 1e6)) - self.property_changed_event.fire("width_value") - self.property_changed_event.fire("height_value") + self.width_value = int(dimension_x * sub_area_shift * 1e6) + self.height_value = int(dimension_y * sub_area_shift * 1e6) - def handle_clear_minimap_clicked(self, widget: typing.Any) -> None: + def handle_clear_minimap_clicked(self, widget: Declarative.UIWidget) -> None: """ Clears the image, scale height and offsets from the sample navigation map. """ @@ -618,7 +641,7 @@ def handle_clear_minimap_clicked(self, widget: typing.Any) -> None: cartridge_result = stem_controller._get_rest_api("/exchange?property=CartridgeInStage") if cartridge_result.is_valid: cartridge_string = cartridge_result.value - properties: JSONDict = {"ImageScaleRad_m": 0.0, "ImageOffsetX_px": 0.0, "ImageOffsetY_px": 0.0, "ImageFile": ""} + properties: JSONType = {"ImageScaleRad_m": 0.0, "ImageOffsetX_px": 0.0, "ImageOffsetY_px": 0.0, "ImageFile": ""} stem_controller._put_rest_api(f"/exchange/cartridges/{cartridge_string}", content=properties) self._append_output_threadsafe("Minimap cleared.") else: From 54707e89a8d9bf6645f84b08ccd815d7ce8befa3 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Tue, 29 Sep 2026 16:43:01 +0100 Subject: [PATCH 28/31] introduce ImportExportManager --- .../nion_experimental_tools/overview_scan_panel.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index 6627e88..0d761f9 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -524,13 +524,21 @@ async def _run_acquisition_async(self, self._api.create_calibration(0.0, y_scale, "um"), self._api.create_calibration(0.0, x_scale, "um"), ] + data_descriptor = self._api.create_data_descriptor(False, 0, 2) - xdata = self._api.create_data_and_metadata(master_data, dimensional_calibrations=dimensional_calibrations) - + xdata = self._api.create_data_and_metadata(master_data, dimensional_calibrations=dimensional_calibrations, data_descriptor=data_descriptor) # create final data item data_item = library.create_data_item_from_data_and_metadata(xdata, "Composite Survey") - display_item = self._document_controller.document_model.get_display_item_for_data_item(data_item) + document_controller = self._api.application.document_controllers[0] + document_controller.display_data_item(data_item) + + await asyncio.sleep(0.5) # allow time for the display to be created so the image exporter doesn't throw an assertion error + + display = data_item.display + display.display_type = "image" + display_item = display._display_item data_path = pathlib.Path(r"C:\AS2\AS2User\Pictures\overview-scan.jpg") + ImportExportManager.ImportExportManager().write_display_item(display_item, data_path) self._append_output("Acquisition complete.\n") From b1564aef48886be4aed980be460cd8295136bfe9 Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Tue, 29 Sep 2026 16:50:00 +0100 Subject: [PATCH 29/31] =?UTF-8?q?replacing=20um=20with=20=CE=BCm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nion_experimental_tools/overview_scan_panel.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index 0d761f9..1a56b62 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -150,9 +150,9 @@ def _build_ui() -> typing.Mapping[str, typing.Any]: acq_button = u.create_push_button(text="Scan", on_clicked="handle_perform_acquisition_clicked", enabled="@binding(scan_buttons_enabled.value)") max_button = u.create_push_button(text="Calculate maximum scan", on_clicked="handle_max_clicked", enabled="@binding(scan_buttons_enabled.value)") properties_label = u.create_label(text="Desired properties of image:") - width_label = u.create_label(text="Width (um):", width=80) + width_label = u.create_label(text="Width (μm):", width=80) width_field = u.create_line_edit(text="@binding(width_value, converter=integer_to_string_converter)", width=50, editable=True) - height_label = u.create_label(text="Height (um):", width=80) + height_label = u.create_label(text="Height (μm):", width=80) height_field = u.create_line_edit(text="@binding(height_value, converter=integer_to_string_converter)", width=50, editable=True) defocus_label = u.create_label(text="Defocus (nm):", width=80) defocus_field = u.create_line_edit(text="@binding(defocus, converter=float_to_string_converter)", width=50, editable=True) @@ -340,9 +340,9 @@ def acquisition(self, master_data = numpy.empty((sub_area[1][0] * dimensions[0], sub_area[1][1] * dimensions[1])) # create an empty array to hold the final image data if not timer: # if performing the full acquisition instead of just estimating the time, update the progress bar and output window - self._append_output_threadsafe(f"Stage starting position: {(sx * 1e6):.3f}, {(sy * 1e6):.3f} um") + self._append_output_threadsafe(f"Stage starting position: {(sx * 1e6):.3f}, {(sy * 1e6):.3f} μm") self._append_output_threadsafe(f"Pixel size: {(pixel_size * 1e9):.3f} nm") - self._append_output_threadsafe(f"Frame width: {(frame_width * 1e6):.3f} um") + self._append_output_threadsafe(f"Frame width: {(frame_width * 1e6):.3f} μm") self._append_output_threadsafe(f"Master size: {master_data.shape}\n") self._set_progress_threadsafe(0, total_images, "Progress:\nStarting acquisition...") @@ -545,7 +545,7 @@ async def _run_acquisition_async(self, self._append_output("Image properties:") self._append_output_threadsafe(f"Total image height: {(total_image_height * 1e3):.3f} mm") - self._append_output_threadsafe(f"Original stage coordinates: {(sx * 1e6):.3f}, {(sy * 1e6):.3f} um") + self._append_output_threadsafe(f"Original stage coordinates: {(sx * 1e6):.3f}, {(sy * 1e6):.3f} μm") except Exception as e: self._append_output(f"Failed to publish result: {e!r}") From bb73d5a2ccfccf55500b3c9cfc0e5aea0ec0e60c Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Fri, 2 Oct 2026 14:30:52 +0100 Subject: [PATCH 30/31] fixing defocus/width/height logic, improving typing and adding units to variable names --- .../overview_scan_panel.py | 428 ++++++++++-------- 1 file changed, 251 insertions(+), 177 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index 1a56b62..929997a 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -1,6 +1,7 @@ import typing import asyncio +from dataclasses import dataclass import gettext import math import numpy @@ -16,7 +17,7 @@ from nion.swift.model import ImportExportManager from nion.swift.model import PlugInManager from nion.typeshed import API_1_0 -from nion.ui import Declarative +from nion.ui import Declarative, UserInterface from nion.utils import Converter from nion.utils import Geometry from nion.utils import Model @@ -26,6 +27,37 @@ JSONType = stem_controller_module.JSONType max_size = 32000 # this is the maximum size of the final image in pixels that can be pushed to the sample navigation window. Placeholder value at the moment because something weird is happening with AS2 where the max possible size is decreasing + +@dataclass +class DimensionsResult: + pixel_size: float + frame_size: tuple[int, int] + frame_width: float + master_sub_area: tuple[tuple[int, int], tuple[int, int]] + master_sub_area_size: tuple[int, int] + sub_area_shift: float + sub_area: tuple[tuple[int, int], tuple[int, int]] + + +@dataclass +class AcquisitionTimingResult: + master_data: numpy.typing.NDArray[numpy.float64] + total_images: int + time_total: float + total_image_size: tuple[int, int] + + +@dataclass +class AcquisitionFullResult: + master_data: numpy.typing.NDArray[numpy.float64] + sub_area: tuple[tuple[int, int], tuple[int, int]] + sub_area_shift: float + pixel_size: float + total_image_height: float + sx: float + sy: float + + class OverviewScanPanelUI: panel_type = "overview-scan-panel" @@ -36,7 +68,7 @@ def get_ui_handler( **kwargs: typing.Any, ) -> Declarative.HandlerLike: api = api_broker.get_api("~1.0") - document_controller = kwargs.get("document_controller") + document_controller = typing.cast(DocumentController.DocumentController, kwargs.get("document_controller")) return OverviewSamplePanelHandler(api, event_loop, document_controller) @@ -45,25 +77,24 @@ class OverviewSamplePanelHandler(Declarative.Handler): def __init__(self, api: "API_1_0.API", event_loop: typing.Optional[asyncio.AbstractEventLoop], - document_controller: typing.Any) -> None: + document_controller: DocumentController.DocumentController) -> None: super().__init__() self._api = api self._event_loop = event_loop or asyncio.get_event_loop() self.stem_controller = typing.cast(stem_controller_module.STEMController, Registry.get_component('stem_controller')) self.camera = typing.cast(camera_base.CameraHardwareSource, self.stem_controller.ronchigram_camera) - self._document_controller = document_controller self.integer_to_string_converter = Converter.IntegerToStringConverter() self.float_to_string_converter = Converter.FloatToStringConverter(pass_none=True) - self._width_value: int = 30 - self._height_value: int = 30 - self._defocus: float = -5e-05 + self._width_value_m: float = 3e-5 + self._height_value_m: float = 3e-5 + self._defocus_m: float = -5e-5 # defocus is in metres here self._binning: int = 1 - self.output_text: str = "" + self.output_widget: UserInterface.TextEditWidget | None = None self.progress_value: int = 0 self.progress_max: int = 100 self.progress_min: int = 0 self.progress_text: str = "Progress:\nIdle" - self._acq_task: typing.Optional[asyncio.Task[None]] = None + self._acquisition_task: asyncio.Task[None] | None = None self._cancel_requested: bool = False self._is_running: bool = False self.cancel_enabled = Model.PropertyModel(False) @@ -71,56 +102,72 @@ def __init__(self, self.ui_view = self._build_ui() @property - def width_value(self) -> int: - return self._width_value + def width_value_m(self) -> float: + return self._width_value_m + + @property + def width_value_um(self) -> int: + return int(self._width_value_m * 1e6) - @width_value.setter - def width_value(self, value: int | None) -> None: + @width_value_um.setter + def width_value_um(self, value: int) -> None: if value is None or value < 1: - self._append_output_threadsafe("Width must be a positive integer.") + self._append_output_threadsafe("Width must be a positive integer. Returning to default value.\n") return - if value != self._width_value: - self._width_value = value - self.property_changed_event.fire("width_value") + width_um = value * 1e-6 + if width_um != self._width_value_m: + self._width_value_m = width_um + self.notify_property_changed("width_value_um") @property - def height_value(self) -> int: - return self._height_value + def height_value_m(self) -> float: + return self._height_value_m - @height_value.setter - def height_value(self, value: int | None) -> None: + @property + def height_value_um(self) -> int: + return int(self._height_value_m * 1e6) + + @height_value_um.setter + def height_value_um(self, value: int) -> None: if value is None or value < 1: - self._append_output_threadsafe("Width must be a positive integer.") + self._append_output_threadsafe("Height must be a positive integer. Returning to default value.\n") + return + height_um = value * 1e-6 + if height_um != self._height_value_m: + self._height_value_m = height_um + self.notify_property_changed("height_value_um") + + + @property + def defocus_m(self) -> float: # defocus is in nm here + return self._defocus_m + + @property + def defocus_nm(self) -> float: # defocus is in nm here + return int(self._defocus_m * 1e9) + + @defocus_nm.setter + def defocus_nm(self, value: float | None) -> None: + if value is None or abs(value) > 500000: + self._append_output_threadsafe(f"Defocus must be between -500000 and 500000 nm. Returning to default value.\n") return - if value != self._height_value: - self._height_value = value - self.property_changed_event.fire("height_value") + defocus_nm = value * 1e-9 + if defocus_nm != self._defocus_m: + self._defocus_m = defocus_nm + self.notify_property_changed("defocus_nm") @property def binning(self) -> int: return self._binning @binning.setter - def binning(self, value: int | None) -> None: + def binning(self, value: int) -> None: if value is None or value < 1: - self._append_output_threadsafe("Binning must be a positive integer.") + self._append_output_threadsafe("Binning must be a positive integer. Returning to default value.\n") return if value != self._binning: self._binning = value - self.property_changed_event.fire("binning") - - @property - def defocus(self) -> float: - return self._defocus * 1e9 - - @defocus.setter - def defocus(self, value: float | None) -> None: - if value is None or abs(value * 1e9) > 500000: - self._append_output_threadsafe("Defocus must be between -500000 and 500000 nm.") - return - if value != self._defocus: - self._defocus = value - self.property_changed_event.fire("defocus") + self.notify_property_changed("binning") def _set_progress(self, value: int, maximum: int, text: str) -> None: """ @@ -130,8 +177,8 @@ def _set_progress(self, value: int, maximum: int, text: str) -> None: self.progress_max = max(1, int(maximum)) self.progress_min = 0 self.progress_text = text - self.property_changed_event.fire("progress_value") - self.property_changed_event.fire("progress_text") + self.notify_property_changed("progress_value") + self.notify_property_changed("progress_text") def _set_progress_threadsafe(self, value: int, maximum: int, text: str) -> None: """ @@ -140,41 +187,42 @@ def _set_progress_threadsafe(self, value: int, maximum: int, text: str) -> None: self._event_loop.call_soon_threadsafe(self._set_progress, value, maximum, text) @staticmethod - def _build_ui() -> typing.Mapping[str, typing.Any]: + def _build_ui() -> Declarative.UIDescription: """ Construct the UI for the Overview Scan panel, including labels, buttons, input fields, and a progress bar. """ u = Declarative.DeclarativeUI() - title = u.create_label(text="Overview Scan", font="bold") time_button = u.create_push_button(text="Estimate scan size and duration", on_clicked="handle_estimate_time_clicked", enabled="@binding(scan_buttons_enabled.value)") - acq_button = u.create_push_button(text="Scan", on_clicked="handle_perform_acquisition_clicked", enabled="@binding(scan_buttons_enabled.value)") + acquisition_button = u.create_push_button(text="Scan", on_clicked="handle_perform_acquisition_clicked", enabled="@binding(scan_buttons_enabled.value)") max_button = u.create_push_button(text="Calculate maximum scan", on_clicked="handle_max_clicked", enabled="@binding(scan_buttons_enabled.value)") properties_label = u.create_label(text="Desired properties of image:") width_label = u.create_label(text="Width (μm):", width=80) - width_field = u.create_line_edit(text="@binding(width_value, converter=integer_to_string_converter)", width=50, editable=True) + width_field = u.create_line_edit(text="@binding(width_value_um, converter=integer_to_string_converter)", width=50) height_label = u.create_label(text="Height (μm):", width=80) - height_field = u.create_line_edit(text="@binding(height_value, converter=integer_to_string_converter)", width=50, editable=True) + height_field = u.create_line_edit(text="@binding(height_value_um, converter=integer_to_string_converter)", width=50) defocus_label = u.create_label(text="Defocus (nm):", width=80) - defocus_field = u.create_line_edit(text="@binding(defocus, converter=float_to_string_converter)", width=50, editable=True) + defocus_field = u.create_line_edit(text="@binding(defocus_nm, converter=float_to_string_converter)", width=50) binning_label = u.create_label(text="Binning:") - binning_field = u.create_line_edit(text="@binding(binning, converter=integer_to_string_converter)", width=50, editable=True) + binning_field = u.create_line_edit(text="@binding(binning, converter=integer_to_string_converter)", width=50) output_label = u.create_label(text="Output:") - output_box = u.create_text_edit(text="@binding(output_text)", editable=False, height=200) + output_box = u.create_text_edit(name="output_widget", editable=False, height=200) progress_label = u.create_label(text="@binding(progress_text)") - progress_bar = u.create_progress_bar(value="@binding(progress_value)", minimum=0, maximum=100, width=600) + progress_bar = u.create_progress_bar(value="@binding(progress_value)", minimum=0, maximum=100, width=300) cancel_button = u.create_push_button(text="Cancel", on_clicked="handle_cancel_acquisition_clicked", enabled="@binding(cancel_enabled.value)") clear_button = u.create_push_button(text="Clear minimap", on_clicked="handle_clear_minimap_clicked", enabled="@binding(scan_buttons_enabled.value)") overview_scan_ui = u.create_column( - title, properties_label, u.create_row( - u.create_row(u.create_column(width_label, spacing=0), u.create_column(width_field, spacing=0), spacing=2), - u.create_row(u.create_column(height_label, spacing=0), u.create_column(height_field, spacing=0), spacing=2), - u.create_row(u.create_column(defocus_label, spacing=0), u.create_column(defocus_field, spacing=0), spacing=2), - u.create_row(u.create_column(binning_label, spacing=0), u.create_column(binning_field, spacing=0), spacing=8), + u.create_row(width_label, width_field), + u.create_row(height_label, height_field), ), - u.create_row(max_button, time_button, acq_button, spacing=4), + u.create_row( + u.create_row(defocus_label, defocus_field), + u.create_row(binning_label, binning_field) + ), + u.create_row(max_button, time_button), + acquisition_button, u.create_spacing(8), progress_label, progress_bar, @@ -195,8 +243,9 @@ def _append_output(self, message: str) -> None: """ Add text to the output window. """ - self.output_text += f"{message}\n" - self.property_changed_event.fire("output_text") + if self.output_widget is not None: + self.output_widget.move_cursor_position("end") + self.output_widget.append_text(message) def _append_output_threadsafe(self, message: str) -> None: """ @@ -215,44 +264,44 @@ def _get_axis_description(self, axis_name: str) -> stem_controller_module.AxisDe raise ValueError(f"Axis '{axis_name}' not found.") @staticmethod - def find_dimensions(stem_controller: stem_controller_module.STEMController, + def find_properties(stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, - defocus: float, + defocus_m: float, tv_pixel_angle_rad: float, - binning: float = 1.0) -> tuple[float, tuple[int, int], float, tuple[tuple[int, int], tuple[int, int]], tuple[int, int], float, tuple[tuple[int, int], tuple[int, int]]]: + binning: float = 1.0) -> DimensionsResult: """ Calculate the relevant properties of each frame based on the provided defocus and TV pixel angle. Args: - stem_controller: the instrument used to control the STEM microscope. - camera: the Ronchigram camera used to capture images. - - defocus: the desired defocus value in meters. + - defocus_m: the desired defocus value in meters. - tv_pixel_angle_rad: the TV pixel angle in radians. - binning: the binning factor for the camera, which reduces the resolution of the captured images by combining adjacent pixels. Returns: - - pixel_size: real-world size of each pixel in the image in meters. - - frame_size: dimensions of each frame in pixels - - frame_width: real-world width of the image in meters. + - pixel_size_m: real-world size of each pixel in the image in meters. + - frame_size_px: dimensions of each frame in pixels + - frame_width_m: real-world width of the image in meters. - master_sub_area: the full-size crop taken from the frame - master_sub_area_size: the size of that crop in pixels - - sub_area_shift: the real-world distance in meters that the stage needs to move to capture the next frame in the snake pattern. + - sub_area_shift_m: the real-world distance in meters that the stage needs to move to capture the next frame in the snake pattern. - sub_area: the binned crop taken from the frame, which is used to construct the final image """ - stem_controller.set_control_output("C10", defocus) # set the defocus to the desired value + stem_controller.set_control_output("C10", defocus_m) # set the defocus to the desired value # Get pixel size, image size, and image width based on the defocus and TV pixel angle - pixel_size = abs(defocus) * math.tan(tv_pixel_angle_rad) - frame_size = camera.get_expected_dimensions(camera.get_current_frame_parameters()) - frame_width = abs(defocus) * math.sin(tv_pixel_angle_rad * frame_size[0]) + pixel_size_m = abs(defocus_m) * math.tan(tv_pixel_angle_rad) + frame_size_px = camera.get_expected_dimensions(camera.get_current_frame_parameters()) + frame_width_m = abs(defocus_m) * math.sin(tv_pixel_angle_rad * frame_size_px[0]) # Calculate the area of the image and the master sub-area based on the image size and reduce factor - master_sub_area_size = frame_size[0], frame_size[1] - master_sub_area = (frame_size[0] // 2 - master_sub_area_size[0] // 2, - frame_size[1] // 2 - master_sub_area_size[1] // 2), master_sub_area_size + master_sub_area_size = frame_size_px[0], frame_size_px[1] + master_sub_area = (frame_size_px[0] // 2 - master_sub_area_size[0] // 2, + frame_size_px[1] // 2 - master_sub_area_size[1] // 2), master_sub_area_size binning = max(1, int(binning)) - sub_area_shift = frame_width * (master_sub_area[1][0] / frame_size[0]) + sub_area_shift_m = frame_width_m * (master_sub_area[1][0] / frame_size_px[0]) sub_area_height = len(range(master_sub_area[0][0], master_sub_area[0][0] + master_sub_area[1][0], binning)) sub_area_width = len(range(master_sub_area[0][1], master_sub_area[0][1] + master_sub_area[1][1], binning)) @@ -261,16 +310,22 @@ def find_dimensions(stem_controller: stem_controller_module.STEMController, (sub_area_height, sub_area_width), ) - return pixel_size, frame_size, frame_width, master_sub_area, master_sub_area_size, sub_area_shift, sub_area + return DimensionsResult( + pixel_size=pixel_size_m, + frame_size=frame_size_px, + frame_width=frame_width_m, + master_sub_area=master_sub_area, + master_sub_area_size=master_sub_area_size, + sub_area_shift=sub_area_shift_m, + sub_area=sub_area, +) def acquisition(self, stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, - defocus: float, - target_width: int, target_height: int, timer: bool = False, - binning: float = 1.0) -> (tuple[numpy.typing.NDArray[numpy.float64], int, float] | - tuple[numpy.typing.NDArray[numpy.float64], tuple[tuple[int, int], tuple[int, int]], float, float, float, float, float] | - tuple[int, float] | None): + defocus_m: float, + target_width: float, target_height: float, timer: bool = False, + binning: float = 1.0) -> AcquisitionTimingResult | AcquisitionFullResult | tuple[int, float] | None: """ Move across the sample in a snake pattern, acquiring images at each position, and return the resulting data and relevant parameters. If timer is True, return an estimate of how long the full acquisition will take. @@ -278,8 +333,9 @@ def acquisition(self, Args: - stem_controller: the instrument used to control the STEM microscope. - camera: the Ronchigram camera used to capture images. - - defocus: the desired defocus value in meters. - - target_width: the desired width and height of the final image in micrometers. + - defocus_m: the desired defocus value in meters. + - target_width_m: the desired width of the final image in meters. + - target_height_m: the desired height of the final image in meters. - binning: the binning factor for the camera, which reduces the resolution of the captured images by combining adjacent pixels. - timer: if True, the function will only acquire two frames to estimate the time required for the full acquisition. if False, the function will perform the full acquisition. @@ -303,7 +359,7 @@ def acquisition(self, self.cancel_enabled.value = True self.scan_buttons_enabled.value = False - success, tv_pixel_angle_rad = stem_controller.TryGetVal("TVPixelAngle") # if success is False, the plugin is likely being run on uSim + success, pixel_angle_rad = stem_controller.TryGetVal("TVPixelAngle") # if success is False, the plugin is likely being run on uSim if success: #even if success is True, it could still be on usim- this would give an empty or singular matrix so can guard against non-uSim controls being used # this branch will run where the plugin is used on an actual microscope @@ -317,42 +373,47 @@ def acquisition(self, frame = camera.grab_next_to_start()[0] assert frame is not None - tv_pixel_angle_rad = float(frame.dimensional_calibrations[0].scale) + pixel_angle_rad = float(frame.dimensional_calibrations[0].scale) # grab stage original location and original defocus sx = stem_controller.get_control_output(shift_x_control_name) sy = stem_controller.get_control_output(shift_y_control_name) df_original = stem_controller.get_control_output("C10") - assert tv_pixel_angle_rad is not None - stem_controller.set_control_output("C10", defocus) + assert pixel_angle_rad is not None + stem_controller.set_control_output("C10", defocus_m) - pixel_size, frame_size, frame_width, master_sub_area, master_sub_area_size, sub_area_shift, sub_area = self.find_dimensions(stem_controller, camera, defocus, tv_pixel_angle_rad, binning) + properties = self.find_properties(stem_controller, camera, defocus_m, pixel_angle_rad, binning) + pixel_size_m = properties.pixel_size + frame_width_m = properties.frame_width + master_sub_area = properties.master_sub_area + sub_area_shift_m = properties.sub_area_shift + sub_area = properties.sub_area # calculate the number of frames to cover the target area - frames_needed_width = math.ceil(target_width * 1e-6 / sub_area_shift) - frames_needed_height = math.ceil(target_height * 1e-6 / sub_area_shift) - dimensions = (frames_needed_width, frames_needed_height) + frames_needed_width = math.ceil(target_width / sub_area_shift_m) + frames_needed_height = math.ceil(target_height / sub_area_shift_m) - total_image_height = dimensions[1] * frame_width # calculate the height of the image in um - total_images = frames_needed_width * frames_needed_height # calculate the total number of frames required for the image + t1 = time.time() + time_total = 0.0 + + if timer: + dimensions = (2, 1) # for timing purposes, only need to acquire 2 frames and average the time to take them both + else: + dimensions = (frames_needed_width, frames_needed_height) - master_data = numpy.empty((sub_area[1][0] * dimensions[0], sub_area[1][1] * dimensions[1])) # create an empty array to hold the final image data + master_data = numpy.empty((sub_area[1][0] * dimensions[0], sub_area[1][1] * dimensions[1])) + total_image_height_um = frames_needed_height * frame_width_m # calculate the height of the image in um + total_images = frames_needed_width * frames_needed_height # calculate the total number of frames required for the image + total_image_size_px = (sub_area[1][0] * frames_needed_width, sub_area[1][1] * frames_needed_height) # calculate the total size of the image in pixels if not timer: # if performing the full acquisition instead of just estimating the time, update the progress bar and output window self._append_output_threadsafe(f"Stage starting position: {(sx * 1e6):.3f}, {(sy * 1e6):.3f} μm") - self._append_output_threadsafe(f"Pixel size: {(pixel_size * 1e9):.3f} nm") - self._append_output_threadsafe(f"Frame width: {(frame_width * 1e6):.3f} μm") + self._append_output_threadsafe(f"Pixel size: {(pixel_size_m * 1e9):.3f} nm") + self._append_output_threadsafe(f"Frame width: {(frame_width_m * 1e6):.3f} μm") self._append_output_threadsafe(f"Master size: {master_data.shape}\n") self._set_progress_threadsafe(0, total_images, "Progress:\nStarting acquisition...") - - t1 = time.time() - time_total = 0.0 - - if timer: - dimensions = (2, 1) # for timing purposes, only need to acquire 2 frames and average the time to take them both - try: for row in range(dimensions[0]): # cancel mechanism @@ -371,8 +432,8 @@ def acquisition(self, self.scan_buttons_enabled.value = True return None if not timer else (0, 0.0) - delta_x = - sub_area_shift * (column - dimensions[1] // 2) - delta_y = - sub_area_shift * (row - dimensions[0] // 2) + delta_x = - sub_area_shift_m * (column - dimensions[1] // 2) + delta_y = - sub_area_shift_m * (row - dimensions[0] // 2) stage_axis = self._get_axis_description("StageAxis") camera_axis = self._get_axis_description("TV") @@ -428,16 +489,25 @@ def acquisition(self, stem_controller.set_control_output(shift_y_control_name, sy) stem_controller.set_control_output("C10", df_original) # restore defocus to original value - self._set_progress_threadsafe(0, 100, "Progress:\n Idle") # reset progress bar to idle state + self._set_progress_threadsafe(0, 100, "Progress:\nIdle") # reset progress bar to idle state if timer: - self.cancel_enabled.value = False - self.scan_buttons_enabled.value = True - return master_data, total_images, time_total + return AcquisitionTimingResult( + master_data=master_data, + total_images=total_images, + time_total=time_total, + total_image_size=total_image_size_px, + ) else: - self.cancel_enabled.value = False - self.scan_buttons_enabled.value = True - return master_data, sub_area, sub_area_shift, pixel_size, total_image_height, sx, sy + return AcquisitionFullResult( + master_data=master_data, + sub_area=sub_area, + sub_area_shift=sub_area_shift_m, + pixel_size=pixel_size_m, + total_image_height=total_image_height_um, + sx=sx, + sy=sy, + ) def handle_cancel_acquisition_clicked(self, widget: Declarative.UIWidget) -> None: """ @@ -452,41 +522,42 @@ def handle_estimate_time_clicked(self, widget: Declarative.UIWidget) -> None: """ Estimates the time an acquisition will take by averaging the time it takes to capture two frames and multiplying by the total number of frames required for the acquisition. """ - # guardrails to make sure width, height, defocus and binning are all integers and within sensible limits - try: - width = self.width_value - height = self.height_value - defocus = self.defocus * 1e-9 - binning = self._binning - except ValueError: - self._append_output("Please enter width, height, binning and defocus as integers.") - return - + width_m = self.width_value_m + height_m = self.height_value_m + defocus_m = self.defocus_m + binning = self._binning stem_controller = self.stem_controller camera = self.camera - result = self.acquisition(stem_controller, camera, defocus, width, height, timer=True, binning=binning) - if result is None or len(result) != 3: - return + result = self.acquisition(stem_controller, camera, defocus_m, width_m, height_m, timer=True, binning=binning) - master_data, total_images, t_total = result - image_size = master_data.shape + total_images = result.total_images + t_total = result.time_total + image_size_px = result.total_image_size time_taken = t_total * total_images / 2 # average time to move the stage self._append_output(f"This acquisition will take approximately {(time_taken // 3600):.0f}h {((time_taken % 3600) / 60):.0f}min {(time_taken % 60):.0f}s") - if any(dimension > max_size for dimension in image_size): + if any(dimension > max_size for dimension in image_size_px): self._append_output("The final data item is too large to be used in the sample navigation window. Consider increasing the binning or reducing the size of the acquisition.\n") + self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True + self._set_progress_threadsafe(0, 100, "Progress:\nIdle") + return else: - self._append_output(f"The final data item will have dimensions {image_size[0]} x {image_size[1]} pixels.\n") + self._append_output(f"The final data item will have dimensions {image_size_px[0]} x {image_size_px[1]} pixels.\n") + self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True + self._set_progress_threadsafe(0, 100, "Progress:\nIdle") + return async def _run_acquisition_async(self, stem_controller: stem_controller_module.STEMController, camera: camera_base.CameraHardwareSource, - defocus: float, - target_width: int, target_height: int, + defocus_m: float, + target_width: float, target_height: float, binning: int) -> None: """ Performs acquisition asynchronously to avoid blocking the UI thread, then pushes results to the sample navigation map in AS2. @@ -496,43 +567,49 @@ async def _run_acquisition_async(self, Args: - stem_controller: the instrument used to control the STEM microscope. - camera: the Ronchigram camera used to capture images. - - defocus: the desired defocus value in meters. + - defocus_m: the desired defocus value in meters. - binning: the binning factor for the camera, which reduces the resolution of the captured images by combining adjacent pixels. """ loop = self._event_loop self._append_output_threadsafe("Starting acquisition...\n") try: - result = await loop.run_in_executor(None, self.acquisition, stem_controller, camera, defocus, target_width, target_height, False, binning) - if result is None or len(result) != 7: - self._set_progress(0, 100, "Progress:\nIdle") - return - - master_data, sub_area, sub_area_shift, pixel_size, total_image_height, sx, sy = result + result = await loop.run_in_executor(None, self.acquisition, stem_controller, camera, defocus_m, target_width, target_height, False, binning) + self._set_progress(0, 100, "Progress:\nIdle") + + master_data = result.master_data + sub_area = result.sub_area + sub_area_shift_m = result.sub_area_shift + pixel_size_m = result.pixel_size + total_image_height_m = result.total_image_height + sx = result.sx + sy = result.sy except Exception as e: self._append_output(f"Acquisition failed: {e!r}") self.cancel_enabled.value = False self.scan_buttons_enabled.value = True + self._set_progress_threadsafe(0, 100, "Progress:\nIdle") return try: # dimensional calibrations for the final data item library = self._api.library - y_scale = (sub_area_shift / sub_area[1][0]) * 1e6 - x_scale = (sub_area_shift / sub_area[1][1]) * 1e6 + y_scale_um = (sub_area_shift_m / sub_area[1][0]) * 1e6 + x_scale_um = (sub_area_shift_m / sub_area[1][1]) * 1e6 dimensional_calibrations = [ - self._api.create_calibration(0.0, y_scale, "um"), - self._api.create_calibration(0.0, x_scale, "um"), + self._api.create_calibration(0.0, y_scale_um, "um"), + self._api.create_calibration(0.0, x_scale_um, "um"), ] data_descriptor = self._api.create_data_descriptor(False, 0, 2) xdata = self._api.create_data_and_metadata(master_data, dimensional_calibrations=dimensional_calibrations, data_descriptor=data_descriptor) # create final data item + self._append_output_threadsafe("Creating data item in library...\n") data_item = library.create_data_item_from_data_and_metadata(xdata, "Composite Survey") - document_controller = self._api.application.document_controllers[0] - document_controller.display_data_item(data_item) + document_window = self._api.application.document_controllers[0] + document_window.display_data_item(data_item) - await asyncio.sleep(0.5) # allow time for the display to be created so the image exporter doesn't throw an assertion error + await asyncio.sleep(5) # allow time for the display to be created so the image exporter doesn't throw an assertion error- needs more the bigger the data item display = data_item.display display.display_type = "image" @@ -541,11 +618,13 @@ async def _run_acquisition_async(self, ImportExportManager.ImportExportManager().write_display_item(display_item, data_path) - self._append_output("Acquisition complete.\n") + self._append_output_threadsafe("Acquisition complete.\n") - self._append_output("Image properties:") - self._append_output_threadsafe(f"Total image height: {(total_image_height * 1e3):.3f} mm") + self._append_output_threadsafe("Image properties:") + self._append_output_threadsafe(f"Total image height: {(total_image_height_m * 1e3):.3f} mm") self._append_output_threadsafe(f"Original stage coordinates: {(sx * 1e6):.3f}, {(sy * 1e6):.3f} μm") + self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True except Exception as e: self._append_output(f"Failed to publish result: {e!r}") @@ -555,19 +634,25 @@ async def _run_acquisition_async(self, # push the image, scale height and offsets to the sample navigation map try: + self._append_output_threadsafe("Pushing image to minimap...") cartridge_result = stem_controller._get_rest_api("/exchange?property=CartridgeInStage") if cartridge_result.is_valid: cartridge_string = cartridge_result.value self._append_output_threadsafe(f"Cartridge in stage: {cartridge_string}") - properties: JSONType = {"ImageScaleRad_m": total_image_height / 2, "ImageOffsetX_px": sx / pixel_size, "ImageOffsetY_px": sy / pixel_size, "ImageFile": str(data_path)} + properties: JSONType = {"ImageScaleRad_m": total_image_height_m / 2, "ImageOffsetX_px": sx / pixel_size_m, "ImageOffsetY_px": sy / pixel_size_m, "ImageFile": str(data_path)} # Set the values on the cartridge property_result = stem_controller._put_rest_api(f"/exchange/cartridges/{cartridge_string}", content=properties) if not property_result.is_valid: self._append_output_threadsafe(f"PUT failed: {property_result.exception}") + self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True + else: self._append_output_threadsafe(f"Failed to get CartridgeInStage: {cartridge_result.exception}") + self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True return except Exception as e: @@ -581,42 +666,29 @@ def handle_perform_acquisition_clicked(self, widget: Declarative.UIWidget) -> No Starts the acquisition process by validating input parameters. Initiates the asynchronous acquisition task. """ - # guardrails to make sure width, height, defocus and binning are all integers and within sensible limits - try: - width = self.width_value - height = self.height_value - defocus = self.defocus * 1e-9 - binning = self._binning - except ValueError: - self._append_output("Please enter width, height, binning and defocus as integers.") - return + width_m = self.width_value_m + height_m = self.height_value_m + defocus_m = self.defocus_m + binning = self._binning - if self._acq_task and not self._acq_task.done(): + if self._acquisition_task and not self._acquisition_task.done(): self._append_output("Acquisition already running.") return - self._acq_task = self._event_loop.create_task( - self._run_acquisition_async(self.stem_controller, self.camera, defocus, width, height, binning) + self._acquisition_task = self._event_loop.create_task( + self._run_acquisition_async(self.stem_controller, self.camera, defocus_m, width_m, height_m, binning) ) self.cancel_enabled.value = False self.scan_buttons_enabled.value = True + self._set_progress_threadsafe(0, 100, "Progress:\nIdle") def handle_max_clicked(self, widget: Declarative.UIWidget) -> None: """ Calculates the maximum scan size at the specified defocus/binning for the image to be pushed to the sample navigation map. - Estimates the time it will take and performs the acquisition. + Estimates the time it will take. """ - # guardrails to make sure defocus and binning are both integers and within sensible limits - try: - defocus = self.defocus * 1e-9 - binning = self._binning - except ValueError: - self._append_output("Please enter defocus and binning as integers.") - return - - if self._acq_task and not self._acq_task.done(): - self._append_output("Acquisition already running.") - return + defocus_m = self.defocus_m + binning = self._binning stem_controller = self.stem_controller camera = self.camera @@ -631,14 +703,16 @@ def handle_max_clicked(self, widget: Declarative.UIWidget) -> None: assert tv_pixel_angle_rad is not None - pixel_size, frame_size, frame_width, master_sub_area, master_sub_area_size, sub_area_shift, sub_area = self.find_dimensions(stem_controller, camera, defocus, tv_pixel_angle_rad, binning) + properties = self.find_properties(stem_controller, camera, defocus_m, tv_pixel_angle_rad, binning) + sub_area_shift_m = properties.sub_area_shift + sub_area = properties.sub_area dimension_y = max_size // sub_area[1][0] dimension_x = max_size // sub_area[1][1] # putting the calculated maximum scan size into the width and height fields in the UI - self.width_value = int(dimension_x * sub_area_shift * 1e6) - self.height_value = int(dimension_y * sub_area_shift * 1e6) + self.width_value_um = int(dimension_x * sub_area_shift_m * 1e6) # convert to micrometers + self.height_value_um = int(dimension_y * sub_area_shift_m * 1e6) def handle_clear_minimap_clicked(self, widget: Declarative.UIWidget) -> None: """ From 5f55a85adea9db49feb7b07ac0d352a2d4ca75ca Mon Sep 17 00:00:00 2001 From: lizziewylie Date: Fri, 2 Oct 2026 15:10:16 +0100 Subject: [PATCH 31/31] placating mypy --- .../overview_scan_panel.py | 75 +++++++++++-------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py index 929997a..05f1b97 100644 --- a/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py +++ b/nionswift_plugin/nion_experimental_tools/overview_scan_panel.py @@ -41,7 +41,6 @@ class DimensionsResult: @dataclass class AcquisitionTimingResult: - master_data: numpy.typing.NDArray[numpy.float64] total_images: int time_total: float total_image_size: tuple[int, int] @@ -87,7 +86,7 @@ def __init__(self, self.float_to_string_converter = Converter.FloatToStringConverter(pass_none=True) self._width_value_m: float = 3e-5 self._height_value_m: float = 3e-5 - self._defocus_m: float = -5e-5 # defocus is in metres here + self._defocus_m: float = -5e-5 # defocus is in metres here self._binning: int = 1 self.output_widget: UserInterface.TextEditWidget | None = None self.progress_value: int = 0 @@ -112,7 +111,7 @@ def width_value_um(self) -> int: @width_value_um.setter def width_value_um(self, value: int) -> None: if value is None or value < 1: - self._append_output_threadsafe("Width must be a positive integer. Returning to default value.\n") + self._append_output_threadsafe("Width must be a positive integer. Returning to previous value.\n") return width_um = value * 1e-6 if width_um != self._width_value_m: @@ -130,14 +129,13 @@ def height_value_um(self) -> int: @height_value_um.setter def height_value_um(self, value: int) -> None: if value is None or value < 1: - self._append_output_threadsafe("Height must be a positive integer. Returning to default value.\n") + self._append_output_threadsafe("Height must be a positive integer. Returning to previous value.\n") return height_um = value * 1e-6 if height_um != self._height_value_m: self._height_value_m = height_um self.notify_property_changed("height_value_um") - @property def defocus_m(self) -> float: # defocus is in nm here return self._defocus_m @@ -149,7 +147,7 @@ def defocus_nm(self) -> float: # defocus is in nm here @defocus_nm.setter def defocus_nm(self, value: float | None) -> None: if value is None or abs(value) > 500000: - self._append_output_threadsafe(f"Defocus must be between -500000 and 500000 nm. Returning to default value.\n") + self._append_output_threadsafe("Defocus must be between -500000 and 500000 nm. Returning to previous value.\n") return defocus_nm = value * 1e-9 if defocus_nm != self._defocus_m: @@ -311,14 +309,14 @@ def find_properties(stem_controller: stem_controller_module.STEMController, ) return DimensionsResult( - pixel_size=pixel_size_m, - frame_size=frame_size_px, - frame_width=frame_width_m, - master_sub_area=master_sub_area, - master_sub_area_size=master_sub_area_size, - sub_area_shift=sub_area_shift_m, - sub_area=sub_area, -) + pixel_size=pixel_size_m, + frame_size=frame_size_px, + frame_width=frame_width_m, + master_sub_area=master_sub_area, + master_sub_area_size=master_sub_area_size, + sub_area_shift=sub_area_shift_m, + sub_area=sub_area, + ) def acquisition(self, stem_controller: stem_controller_module.STEMController, @@ -361,7 +359,7 @@ def acquisition(self, success, pixel_angle_rad = stem_controller.TryGetVal("TVPixelAngle") # if success is False, the plugin is likely being run on uSim - if success: #even if success is True, it could still be on usim- this would give an empty or singular matrix so can guard against non-uSim controls being used + if success: # even if success is True, it could still be on usim- this would give an empty or singular matrix so can guard against non-uSim controls being used # this branch will run where the plugin is used on an actual microscope shift_x_control_name = "SShft.sx" shift_y_control_name = "SShft.sy" @@ -438,7 +436,7 @@ def acquisition(self, stage_axis = self._get_axis_description("StageAxis") camera_axis = self._get_axis_description("TV") - delta_fast = stem_controller.axis_transform_point(Geometry.FloatPoint(y=delta_y, x=delta_x),from_axis=stage_axis, to_axis=camera_axis) + delta_fast = stem_controller.axis_transform_point(Geometry.FloatPoint(y=delta_y, x=delta_x), from_axis=stage_axis, to_axis=camera_axis) if delta_fast: delta_x = float(delta_fast[0]) @@ -493,7 +491,6 @@ def acquisition(self, if timer: return AcquisitionTimingResult( - master_data=master_data, total_images=total_images, time_total=time_total, total_image_size=total_image_size_px, @@ -517,7 +514,6 @@ def handle_cancel_acquisition_clicked(self, widget: Declarative.UIWidget) -> Non self._cancel_requested = True self._set_progress_threadsafe(self.progress_value, 100, "Cancel requested...") - def handle_estimate_time_clicked(self, widget: Declarative.UIWidget) -> None: """ Estimates the time an acquisition will take by averaging the time it takes to capture two frames and multiplying by the total number of frames required for the acquisition. @@ -532,9 +528,17 @@ def handle_estimate_time_clicked(self, widget: Declarative.UIWidget) -> None: result = self.acquisition(stem_controller, camera, defocus_m, width_m, height_m, timer=True, binning=binning) - total_images = result.total_images - t_total = result.time_total - image_size_px = result.total_image_size + if type(result) == AcquisitionTimingResult: + result = typing.cast(AcquisitionTimingResult, result) + total_images = result.total_images + t_total = result.time_total + image_size_px = result.total_image_size + else: + self._append_output_threadsafe("Acquisition failed.") + self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True + self._set_progress_threadsafe(0, 100, "Progress:\nIdle") + return time_taken = t_total * total_images / 2 # average time to move the stage self._append_output(f"This acquisition will take approximately {(time_taken // 3600):.0f}h {((time_taken % 3600) / 60):.0f}min {(time_taken % 60):.0f}s") @@ -576,14 +580,14 @@ async def _run_acquisition_async(self, try: result = await loop.run_in_executor(None, self.acquisition, stem_controller, camera, defocus_m, target_width, target_height, False, binning) self._set_progress(0, 100, "Progress:\nIdle") - - master_data = result.master_data - sub_area = result.sub_area - sub_area_shift_m = result.sub_area_shift - pixel_size_m = result.pixel_size - total_image_height_m = result.total_image_height - sx = result.sx - sy = result.sy + if type(result) == AcquisitionFullResult: + result = typing.cast(AcquisitionFullResult, result) + else: + self._append_output(f"Acquisition failed: {result!r}") + self.cancel_enabled.value = False + self.scan_buttons_enabled.value = True + self._set_progress_threadsafe(0, 100, "Progress:\nIdle") + return except Exception as e: self._append_output(f"Acquisition failed: {e!r}") self.cancel_enabled.value = False @@ -593,6 +597,13 @@ async def _run_acquisition_async(self, try: # dimensional calibrations for the final data item + master_data = result.master_data + sub_area = result.sub_area + sub_area_shift_m = result.sub_area_shift + pixel_size_m = result.pixel_size + total_image_height_m = result.total_image_height + sx = result.sx + sy = result.sy library = self._api.library y_scale_um = (sub_area_shift_m / sub_area[1][0]) * 1e6 x_scale_um = (sub_area_shift_m / sub_area[1][1]) * 1e6 @@ -609,7 +620,7 @@ async def _run_acquisition_async(self, document_window = self._api.application.document_controllers[0] document_window.display_data_item(data_item) - await asyncio.sleep(5) # allow time for the display to be created so the image exporter doesn't throw an assertion error- needs more the bigger the data item + await asyncio.sleep(5) # allow time for the display to be created so the image exporter doesn't throw an assertion error display = data_item.display display.display_type = "image" @@ -711,7 +722,7 @@ def handle_max_clicked(self, widget: Declarative.UIWidget) -> None: dimension_x = max_size // sub_area[1][1] # putting the calculated maximum scan size into the width and height fields in the UI - self.width_value_um = int(dimension_x * sub_area_shift_m * 1e6) # convert to micrometers + self.width_value_um = int(dimension_x * sub_area_shift_m * 1e6) # convert to micrometers self.height_value_um = int(dimension_y * sub_area_shift_m * 1e6) def handle_clear_minimap_clicked(self, widget: Declarative.UIWidget) -> None: @@ -770,4 +781,4 @@ def __init__(self, api_broker: typing.Any) -> None: ) def close(self) -> None: - pass \ No newline at end of file + pass