From 5986b8da72538d1a554d2da94a1325d3d8870c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 26 Sep 2025 13:19:22 +0200 Subject: [PATCH 01/49] Allow server cameras to be streamable by removing precaution checks --- src/instamatic/camera/camera.py | 1 - src/instamatic/camera/camera_client.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/instamatic/camera/camera.py b/src/instamatic/camera/camera.py index fa806b07..d7085628 100644 --- a/src/instamatic/camera/camera.py +++ b/src/instamatic/camera/camera.py @@ -56,7 +56,6 @@ def Camera(name: str = None, as_stream: bool = False, use_server: bool = False): from instamatic.camera.camera_client import CamClient cam = CamClient(name=name, interface=interface) - as_stream = False # precaution else: cam_cls = get_cam(interface) diff --git a/src/instamatic/camera/camera_client.py b/src/instamatic/camera/camera_client.py index dcb63442..c184ae9c 100644 --- a/src/instamatic/camera/camera_client.py +++ b/src/instamatic/camera/camera_client.py @@ -55,7 +55,6 @@ def __init__( self.name = name self.interface = interface self._bufsize = BUFSIZE - self.streamable = False # overrides cam settings self.verbose = False try: From 1267ac43a385d27b5e6582db881d6a9f98ae2456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 26 Sep 2025 14:14:48 +0200 Subject: [PATCH 02/49] Unify interface, naming between get_microscope/camera(_class) --- src/instamatic/camera/__init__.py | 2 +- src/instamatic/camera/camera.py | 30 +++++++++++++++++++------ src/instamatic/camera/camera_client.py | 4 ++-- src/instamatic/camera/videostream.py | 6 ++--- src/instamatic/config/autoconfig.py | 4 ++-- src/instamatic/controller.py | 4 ++-- src/instamatic/microscope/microscope.py | 4 ++-- src/instamatic/server/cam_server.py | 4 ++-- 8 files changed, 37 insertions(+), 21 deletions(-) diff --git a/src/instamatic/camera/__init__.py b/src/instamatic/camera/__init__.py index 950ec6da..d4db84a7 100644 --- a/src/instamatic/camera/__init__.py +++ b/src/instamatic/camera/__init__.py @@ -1,4 +1,4 @@ from __future__ import annotations -from .camera import Camera +from .camera import get_camera, get_camera_class from .videostream import VideoStream diff --git a/src/instamatic/camera/camera.py b/src/instamatic/camera/camera.py index d7085628..4deba61f 100644 --- a/src/instamatic/camera/camera.py +++ b/src/instamatic/camera/camera.py @@ -2,18 +2,20 @@ import logging from pathlib import Path +from typing import Optional from instamatic import config +from instamatic.camera.camera_base import CameraBase logger = logging.getLogger(__name__) -__all__ = ['Camera'] +__all__ = ['get_camera', 'get_camera_class'] default_cam_interface = config.camera.interface -def get_cam(interface: str = None): - """Grabs the camera object defined by `interface`""" +def get_camera_class(interface: str) -> type[CameraBase]: + """Grabs the camera class with the specific `interface`""" simulate = config.settings.simulate @@ -39,10 +41,24 @@ def get_cam(interface: str = None): return cam -def Camera(name: str = None, as_stream: bool = False, use_server: bool = False): +def get_camera( + name: Optional[str] = None, + as_stream: bool = False, + use_server: bool = False, +) -> CameraBase: """Initialize the camera identified by the 'name' parameter if `as_stream` is True, it will return a VideoStream object if `as_stream` is False, it - will return the raw Camera object.""" + will return the raw Camera object. + + name: Optional[str] + Specify which camera to use, must be implemented in `instamatic.camera` + as_stream: bool + If True (default False), allow streaming this camera image live. + use_server: bool + Connect to camera server running on the host/port defined in the config + + returns: Camera interface class instance + """ if name is None: name = config.camera.name @@ -57,7 +73,7 @@ def Camera(name: str = None, as_stream: bool = False, use_server: bool = False): cam = CamClient(name=name, interface=interface) else: - cam_cls = get_cam(interface) + cam_cls = get_camera_class(interface) if interface in ('timepix', 'pytimepix'): tpx_config = ( @@ -220,7 +236,7 @@ def main_entry(): if __name__ == '__main__': # main_entry() - cam = Camera(use_server=True) + cam = get_camera(use_server=True) arr = cam.get_image(exposure=0.1) print(arr) print(arr.shape) diff --git a/src/instamatic/camera/camera_client.py b/src/instamatic/camera/camera_client.py index c184ae9c..622f4cc6 100644 --- a/src/instamatic/camera/camera_client.py +++ b/src/instamatic/camera/camera_client.py @@ -155,9 +155,9 @@ def _eval_dct(self, dct): def _init_dict(self): """Get list of functions and their doc strings from the uninitialized class.""" - from instamatic.camera.camera import get_cam + from instamatic.camera.camera import get_camera_class - cam = get_cam(self.interface) + cam = get_camera_class(self.interface) self._dct = { key: value for key, value in cam.__dict__.items() if not key.startswith('_') diff --git a/src/instamatic/camera/videostream.py b/src/instamatic/camera/videostream.py index 84e4ed3b..13a4d7dd 100644 --- a/src/instamatic/camera/videostream.py +++ b/src/instamatic/camera/videostream.py @@ -10,7 +10,7 @@ import numpy as np -from instamatic.camera import Camera +from instamatic.camera import get_camera from instamatic.camera.camera_base import CameraBase from instamatic.image_utils import autoscale @@ -115,7 +115,7 @@ def from_any( cls: Type[VideoStream_T], cam: Union[CameraBase, str] = 'simulate' ) -> VideoStream_T: """Create a subclass based on passed cam or cam-str stream-ability.""" - cam: CameraBase = Camera(name=cam) if isinstance(cam, str) else cam + cam: CameraBase = get_camera(name=cam) if isinstance(cam, str) else cam if cls is VideoStream: return (LiveVideoStream if cam.streamable else FakeVideoStream)(cam) return cls(cam) @@ -123,7 +123,7 @@ def from_any( def __init__(self, cam: Union[CameraBase, str] = 'simulate') -> None: threading.Thread.__init__(self) - self.cam: CameraBase = Camera(name=cam) if isinstance(cam, str) else cam + self.cam: CameraBase = get_camera(name=cam) if isinstance(cam, str) else cam self.lock = threading.Lock() self.default_exposure = self.cam.default_exposure diff --git a/src/instamatic/config/autoconfig.py b/src/instamatic/config/autoconfig.py index b21c5930..96ef2fe2 100644 --- a/src/instamatic/config/autoconfig.py +++ b/src/instamatic/config/autoconfig.py @@ -125,12 +125,12 @@ def main(): cam_connect = False cam_name = None - from instamatic.camera.camera import get_cam + from instamatic.camera.camera import get_camera_class from instamatic.controller import TEMController from instamatic.microscope import get_microscope_class if cam_connect: - cam = get_cam(cam_name)() if cam_name else None + cam = get_camera_class(cam_name)() if cam_name else None else: cam = None diff --git a/src/instamatic/controller.py b/src/instamatic/controller.py index 2aa69fba..dea63ff2 100644 --- a/src/instamatic/controller.py +++ b/src/instamatic/controller.py @@ -9,7 +9,7 @@ import yaml from instamatic import config -from instamatic.camera import Camera +from instamatic.camera import get_camera from instamatic.camera.camera_base import CameraBase from instamatic.exceptions import TEMControllerError from instamatic.formats import write_tiff @@ -61,7 +61,7 @@ def initialize( print(f'Camera : {cam_name}{cam_tag}') - cam = Camera(cam_name, as_stream=stream, use_server=use_cam_server) + cam = get_camera(cam_name, as_stream=stream, use_server=use_cam_server) else: cam = None diff --git a/src/instamatic/microscope/microscope.py b/src/instamatic/microscope/microscope.py index 6f44791e..ac83d1c1 100644 --- a/src/instamatic/microscope/microscope.py +++ b/src/instamatic/microscope/microscope.py @@ -10,7 +10,7 @@ __all__ = ['get_microscope', 'get_microscope_class'] -def get_microscope_class(interface: str) -> 'type[MicroscopeBase]': +def get_microscope_class(interface: str) -> type[MicroscopeBase]: """Grab tem class with the specific 'interface'.""" simulate = config.settings.simulate @@ -42,7 +42,7 @@ def get_microscope(name: Optional[str] = None, use_server: bool = False) -> Micr use_server: bool Connect to microscope server running on the host/port defined in the config file - returns: TEM interface class + returns: TEM interface class instance """ if name is None: interface = default_tem_interface diff --git a/src/instamatic/server/cam_server.py b/src/instamatic/server/cam_server.py index bba3dc8d..2d3c926f 100644 --- a/src/instamatic/server/cam_server.py +++ b/src/instamatic/server/cam_server.py @@ -11,7 +11,7 @@ import numpy as np from instamatic import config -from instamatic.camera import Camera +from instamatic.camera import get_camera from instamatic.utils import high_precision_timers from .serializer import dumper, loader @@ -81,7 +81,7 @@ def copy_data_to_shared_buffer(self, arr): def run(self): """Start server thread.""" - self.cam = Camera(name=self._name, use_server=False) + self.cam = get_camera(name=self._name, use_server=False) self.cam.get_attrs = self.get_attrs print(f'Initialized camera: {self.cam.interface}') From 65a2707b0a39d62e79c509589a19e1b239ca485f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 26 Sep 2025 14:19:16 +0200 Subject: [PATCH 03/49] Remove unused _init_attr_dict/get_attrs from microscope client/server --- src/instamatic/microscope/client.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/instamatic/microscope/client.py b/src/instamatic/microscope/client.py index 1d34d27c..52150412 100644 --- a/src/instamatic/microscope/client.py +++ b/src/instamatic/microscope/client.py @@ -125,10 +125,6 @@ def _init_dict(self) -> None: } self._dct['get_attrs'] = None - def _init_attr_dict(self): - """Get list of attrs and their types.""" - self._attr_dct = self.get_attrs() - def __dir__(self) -> list: return list(self._dct.keys()) From e64c51fa1f880a1e8744bfa6be9c6fb14409a266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 26 Sep 2025 18:08:42 +0200 Subject: [PATCH 04/49] Do not force server-side attributes to be callable, use them first: these 3 lines took ~3h --- src/instamatic/camera/camera_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/instamatic/camera/camera_client.py b/src/instamatic/camera/camera_client.py index 622f4cc6..ebdf5125 100644 --- a/src/instamatic/camera/camera_client.py +++ b/src/instamatic/camera/camera_client.py @@ -82,6 +82,7 @@ def __init__( self.buffers: Dict[str, np.ndarray] = {} self.shms = {} + self._attr_dct: dict = {} self._init_dict() self._init_attr_dict() @@ -103,6 +104,8 @@ def connect(self): def __getattr__(self, attr_name): if attr_name in self._dct: + if attr_name in object.__getattribute__(self, '_attr_dct'): + return self._eval_dct({'attr_name': attr_name}) wrapped = self._dct[attr_name] elif attr_name in self._attr_dct: dct = {'attr_name': attr_name} From 81b099c36ae254fe10ab938401912282c25b11d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 26 Sep 2025 18:20:42 +0200 Subject: [PATCH 05/49] EAFP: Allow camera to call functions whether they are registered or not --- src/instamatic/camera/camera_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/instamatic/camera/camera_client.py b/src/instamatic/camera/camera_client.py index ebdf5125..6b320657 100644 --- a/src/instamatic/camera/camera_client.py +++ b/src/instamatic/camera/camera_client.py @@ -111,9 +111,7 @@ def __getattr__(self, attr_name): dct = {'attr_name': attr_name} return self._eval_dct(dct) else: - raise AttributeError( - f'`{self.__class__.__name__}` object has no attribute `{attr_name}`' - ) + wrapped = None # AFAIK can't wrap with None, can cause odd errors @wraps(wrapped) def wrapper(*args, **kwargs): From 69210a15322063cd056577f6137812b116d27260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 26 Sep 2025 18:23:54 +0200 Subject: [PATCH 06/49] EAFP: Allow camera to call unregistered functions - fixes server cameras --- src/instamatic/camera/camera_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/instamatic/camera/camera_client.py b/src/instamatic/camera/camera_client.py index 6b320657..952d26c4 100644 --- a/src/instamatic/camera/camera_client.py +++ b/src/instamatic/camera/camera_client.py @@ -103,6 +103,7 @@ def connect(self): print(f'Connected to CAM server ({HOST}:{PORT})') def __getattr__(self, attr_name): + print(self._dct) if attr_name in self._dct: if attr_name in object.__getattribute__(self, '_attr_dct'): return self._eval_dct({'attr_name': attr_name}) From 2322ece5f6daf4ba01592c63c3da32418b4f79ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 26 Sep 2025 19:33:18 +0200 Subject: [PATCH 07/49] Remove unnecessary print debug statement --- src/instamatic/camera/camera_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/instamatic/camera/camera_client.py b/src/instamatic/camera/camera_client.py index 952d26c4..6b320657 100644 --- a/src/instamatic/camera/camera_client.py +++ b/src/instamatic/camera/camera_client.py @@ -103,7 +103,6 @@ def connect(self): print(f'Connected to CAM server ({HOST}:{PORT})') def __getattr__(self, attr_name): - print(self._dct) if attr_name in self._dct: if attr_name in object.__getattribute__(self, '_attr_dct'): return self._eval_dct({'attr_name': attr_name}) From 1f5ecd21195ab6a4a523e943225d6c523ad95b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 26 Sep 2025 20:04:09 +0200 Subject: [PATCH 08/49] Add **streamable** description to `config.md` documentation --- docs/config.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/config.md b/docs/config.md index 14984e40..276022a3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -230,6 +230,9 @@ This file holds the specifications of the camera. This file is must be located t **camera_rotation_vs_stage_xy** : In radians, give here the rotation of the position of the rotation axis with respect to the horizontal. Used for diffraction only. Corresponds to the rotation axis in RED and PETS, for example: `-2.24`. You can find the rotation axis for your setup using the script `edtools.find_rotation_axis` available from [here](https://github.com/instamatic-dev/edtools#find_rotation_axispy). +**streamable** +: Boolean value. If present, overwrites the default behavior as implemented in each camera interface class to force the camera to stream (if `True`) or prevent it from streaming (if `False`) all collected data live directly to the GUI. + **stretch_amplitude** : Use `instamatic.stretch_correction` to characterize the lens distortion. The numbers here are used to calculate the XCORR/YCORR maps. The amplitude is the percentage difference between the maximum and minimum eigenvectors of the ellipsoid, i.e. if the amplitude is `2.43`, eig(max)/eig(min) = 1.0243. You can use the program `instamatic.stretch_correction` available [here](https://github.com/instamatic-dev/instamatic/blob/main/docs/programs.md#instamaticstretch_correction) on some powder patterns to define these numbers. From 463b79877f04c0909d529b9eb8336a40b0f1e04a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 30 Sep 2025 15:46:09 +0200 Subject: [PATCH 09/49] Encapsulate FastADT paths in separate prop/method --- .../experiments/fast_adt/experiment.py | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index 6cd4b5c6..49a79777 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -191,14 +191,7 @@ def __init__( super().__init__() self.ctrl = ctrl self.path = Path(path) - - self.mrc_path = self.path / 'mrc' - self.tiff_path = self.path / 'tiff' - self.tiff_image_path = self.path / 'tiff_image' - self.mrc_path.mkdir(exist_ok=True, parents=True) - self.tiff_path.mkdir(exist_ok=True, parents=True) - self.tiff_image_path.mkdir(exist_ok=True, parents=True) - + self.make_subdirectories() self.log = log or NullLogger() self.flatfield = flatfield self.fast_adt_frame = experiment_frame @@ -218,6 +211,23 @@ def __init__( self.steps_queue: Queue[Union[Step, None]] = Queue() self.run: Optional[Run] = None + @property + def mrc_path(self) -> Path: + return self.path / 'mrc' + + @property + def tiff_path(self) -> Path: + return self.path / 'tiff' + + @property + def tiff_image_path(self) -> Path: + return self.path / 'tiff_image' + + def make_subdirectories(self) -> None: + self.mrc_path.mkdir(exist_ok=True, parents=True) + self.tiff_path.mkdir(exist_ok=True, parents=True) + self.tiff_image_path.mkdir(exist_ok=True, parents=True) + def restore_fast_adt_diff_for_image(self): """Restore 'FastADT_diff' config with 'FastADT_track' magnification.""" self.ctrl.restore('FastADT_track') From 115dedac8c72b4732b9d219d6a38de7da32cf2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 30 Sep 2025 18:00:16 +0200 Subject: [PATCH 10/49] Add new TrackingArtist to be used with plotting multi-runs --- .../experiments/fast_adt/experiment.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index 49a79777..a864c5c7 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -10,6 +10,7 @@ import numpy as np import pandas as pd from matplotlib import pyplot as plt +from matplotlib.lines import Line2D from typing_extensions import Self from instamatic import config @@ -158,6 +159,42 @@ def from_params( return run +RGB = tuple[float, float, float] + + +class TrackingArtist: + """Responsible for plotting tracking summary for multiple FastADT runs.""" + + def __init__(self, beam_center_x: int, beam_center_y: int) -> None: + self.fig, self.ax = plt.subplots() + self.beam_center_x: int = beam_center_x + self.beam_center_y: int = beam_center_y + self.colors: list[RGB] = [] + + self.ax.set_axis_off() + self.fig.tight_layout(pad=0) + + def add_background(self, tracking_run: TrackingRun): + self.ax.imshow(np.mean(np.array(list(tracking_run.table['image'])), axis=0)) + + def add_tracking_run(self, tracking_run: TrackingRun) -> None: + i = len(self.colors) % 10 + color: RGB = tuple(plt.get_cmap('tab10')(i)[:3]) + self.colors.append(color) + x = self.beam_center_x + tracking_run.table['delta_x'] + y = self.beam_center_y + tracking_run.table['delta_y'] + u = np.diff(x) + v = np.diff(y) + a = (a := (u**2 + v**2 + 1) ** (-1 / 2)) / max(a) + self.ax.quiver(x[:-1], y[:-1], u, v, alpha=a, color=color) + self.regenerate_legend() + + def regenerate_legend(self): + ic = enumerate(self.colors) + handles = [Line2D([], [], color=c, marker='>', label=str(i)) for i, c in ic] + self.ax.legend(handles=handles) + + class Experiment(ExperimentBase): """Initialize a FastADT-style rotation electron diffraction experiment. From 5cafbc9d2c6f35b55c5f16338bcc1da0aea3a5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 1 Oct 2025 21:08:35 +0200 Subject: [PATCH 11/49] Working multi-tracking FastADT frame! Still needs polish --- src/instamatic/camera/videostream.py | 15 + .../experiments/fast_adt/experiment.py | 294 +++++++++--------- src/instamatic/gui/fast_adt_frame.py | 1 - src/instamatic/gui/videostream_processor.py | 2 + 4 files changed, 166 insertions(+), 146 deletions(-) diff --git a/src/instamatic/camera/videostream.py b/src/instamatic/camera/videostream.py index 13a4d7dd..858cde89 100644 --- a/src/instamatic/camera/videostream.py +++ b/src/instamatic/camera/videostream.py @@ -160,6 +160,10 @@ def unblock(self): def blocked(self): yield + @contextmanager + def unblocked(self): + yield + class LiveVideoStream(VideoStream): """Handle the continuous stream of incoming data from the ImageGrabber.""" @@ -237,6 +241,17 @@ def blocked(self): if not was_set_before: self.grabber.continuousCollectionEvent.clear() + @contextmanager + def unblocked(self): + """Clear `continuousCollectionEvent` in the statement scope only.""" + was_set_before = self.grabber.continuousCollectionEvent.is_set() + try: + self.grabber.continuousCollectionEvent.clear() + yield + finally: + if was_set_before: + self.grabber.continuousCollectionEvent.set() + def show_stream(self): from instamatic.gui import videostream_frame diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index a864c5c7..40b4ebd1 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -1,16 +1,18 @@ from __future__ import annotations +import contextlib +import itertools import logging +from copy import deepcopy from dataclasses import dataclass, field from pathlib import Path from queue import Queue from threading import Thread -from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, Union +from typing import Any, Iterable, Iterator, Optional, Sequence, TypeVar, Union import numpy as np import pandas as pd from matplotlib import pyplot as plt -from matplotlib.lines import Line2D from typing_extensions import Self from instamatic import config @@ -21,6 +23,13 @@ from instamatic.experiments.experiment_base import ExperimentBase from instamatic.processing.ImgConversionTPX import ImgConversionTPX as ImgConversion +T = TypeVar('T') + + +def get_color(i: int) -> tuple[int, int, int]: + """Return i-th color from matplotlib colormap tab10 as accepted by PIL.""" + return tuple([int(rgb * 255) for rgb in plt.get_cmap('tab10')(i)][:3]) # type: ignore + def safe_range(*, start: float, stop: float, step: float) -> np.ndarray: """Find 2+ floats between `start` and `stop` (inclusive) ~`step` apart.""" @@ -28,6 +37,11 @@ def safe_range(*, start: float, stop: float, step: float) -> np.ndarray: return np.linspace(start, stop, step_count, endpoint=True, dtype=float) +def sawtooth(iterator: Iterable[T]) -> Iterator[T]: + """Iterate elements of input sequence back and forth, repeating edges.""" + yield from itertools.cycle((seq := list(iterator)) + list(reversed(seq))) + + class FastADTEarlyTermination(RuntimeError): """Raised if FastADT experiment terminates early due to known reasons.""" @@ -72,7 +86,7 @@ def __init__(self, exposure=1.0, continuous=False, **columns: Sequence) -> None: self.table: pd.DataFrame = pd.DataFrame.from_dict(columns) @property - def scope(self) -> Tuple[float, float]: + def scope(self) -> tuple[float, float]: """The range of alpha values scanned during the entire run.""" a = self.table['alpha'] if not self.continuous: @@ -92,7 +106,7 @@ def interpolate(self, at: np.array, key: str) -> np.ndarray: return np.interp(at, alpha, values) @property - def buffer(self) -> List[Tuple[int, np.ndarray, dict]]: + def buffer(self) -> list[tuple[int, np.ndarray, dict]]: """Standardized list of (number, image, meta) used when saving.""" return [(i, s.image, s.meta) for i, s in enumerate(self.steps)] @@ -106,13 +120,13 @@ def osc_angle(self) -> float: a = list(self.table['alpha']) return (a[-1] - a[0]) / (len(a) - 1) if len(a) > 1 else -1 - def to_continuous(self) -> Self: - """Construct a new run from N-1 first rows for continuous method.""" - new_alphas = self.table['alpha'].rolling(2).mean().drop(0) - new_cols = self.table.iloc[:-1, :].to_dict(orient='list') - del new_cols['alpha'] - c = self.__class__ - return c(exposure=self.exposure, continuous=True, alpha=new_alphas, **new_cols) + def make_continuous(self) -> None: + """Make self a new run from N-1 first rows for the continuous + method.""" + new_al = self.table['alpha'].rolling(2).mean().drop(0) + new_kw = self.table.iloc[:-1, :].to_dict(orient='list') + del new_kw['alpha'] + self.__init__(exposure=self.exposure, continuous=True, alpha=new_al, **new_kw) def calculate_beamshifts(self, ctrl, beamshift) -> None: """Note CalibBeamShift uses swapped axes: X points down, Y right.""" @@ -129,7 +143,7 @@ class TrackingRun(Run): """Designed to estimate delta_x/y a priori based on manual used input.""" @classmethod - def from_params(cls, params: Dict[str, Any]) -> Self: + def from_params(cls, params: dict[str, Any]) -> Self: alpha_range = safe_range( start=params['diffraction_start'], stop=params['diffraction_stop'], @@ -144,8 +158,8 @@ class DiffractionRun(Run): @classmethod def from_params( cls, - params: Dict[str, Any], - tracking_run: Optional['TrackingRun'] = None, + params: dict[str, Any], + pathing_run: Optional['TrackingRun'] = None, ) -> Self: alpha_range = safe_range( start=params['diffraction_start'], @@ -153,46 +167,20 @@ def from_params( step=params['diffraction_step'], ) run = cls(exposure=params['diffraction_time'], alpha=alpha_range) - if tracking_run is not None: - run.table['delta_x'] = tracking_run.interpolate(alpha_range, 'delta_x') - run.table['delta_y'] = tracking_run.interpolate(alpha_range, 'delta_y') + if pathing_run is not None: + run.table['delta_x'] = pathing_run.interpolate(alpha_range, 'delta_x') + run.table['delta_y'] = pathing_run.interpolate(alpha_range, 'delta_y') return run -RGB = tuple[float, float, float] - - -class TrackingArtist: - """Responsible for plotting tracking summary for multiple FastADT runs.""" - - def __init__(self, beam_center_x: int, beam_center_y: int) -> None: - self.fig, self.ax = plt.subplots() - self.beam_center_x: int = beam_center_x - self.beam_center_y: int = beam_center_y - self.colors: list[RGB] = [] - - self.ax.set_axis_off() - self.fig.tight_layout(pad=0) - - def add_background(self, tracking_run: TrackingRun): - self.ax.imshow(np.mean(np.array(list(tracking_run.table['image'])), axis=0)) - - def add_tracking_run(self, tracking_run: TrackingRun) -> None: - i = len(self.colors) % 10 - color: RGB = tuple(plt.get_cmap('tab10')(i)[:3]) - self.colors.append(color) - x = self.beam_center_x + tracking_run.table['delta_x'] - y = self.beam_center_y + tracking_run.table['delta_y'] - u = np.diff(x) - v = np.diff(y) - a = (a := (u**2 + v**2 + 1) ** (-1 / 2)) / max(a) - self.ax.quiver(x[:-1], y[:-1], u, v, alpha=a, color=color) - self.regenerate_legend() +@dataclass +class Runs: + """Collection of runs: beam alignment, xtal tracking, beam pathing, diff""" - def regenerate_legend(self): - ic = enumerate(self.colors) - handles = [Line2D([], [], color=c, marker='>', label=str(i)) for i, c in ic] - self.ax.legend(handles=handles) + alignment: Optional[Run] = None + tracking: Optional[Run] = None + pathing: list[TrackingRun] = field(default_factory=list) + diffraction: list[DiffractionRun] = field(default_factory=list) class Experiment(ExperimentBase): @@ -228,7 +216,6 @@ def __init__( super().__init__() self.ctrl = ctrl self.path = Path(path) - self.make_subdirectories() self.log = log or NullLogger() self.flatfield = flatfield self.fast_adt_frame = experiment_frame @@ -245,25 +232,9 @@ def __init__( self.click_listener = None self.videostream_processor = None + self.beam_center: tuple[float, float] = (-1, -1) self.steps_queue: Queue[Union[Step, None]] = Queue() - self.run: Optional[Run] = None - - @property - def mrc_path(self) -> Path: - return self.path / 'mrc' - - @property - def tiff_path(self) -> Path: - return self.path / 'tiff' - - @property - def tiff_image_path(self) -> Path: - return self.path / 'tiff_image' - - def make_subdirectories(self) -> None: - self.mrc_path.mkdir(exist_ok=True, parents=True) - self.tiff_path.mkdir(exist_ok=True, parents=True) - self.tiff_image_path.mkdir(exist_ok=True, parents=True) + self.runs: Runs = Runs() def restore_fast_adt_diff_for_image(self): """Restore 'FastADT_diff' config with 'FastADT_track' magnification.""" @@ -346,63 +317,107 @@ def start_collection(self, **params) -> None: Finally, the collected run will be logged and the stage - reset. """ self.msg('FastADT experiment started') - with self.ctrl.beam.blanked(): - image_path = self.tiff_image_path / 'image.tiff' - if not image_path.exists(): - self.ctrl.restore('FastADT_image') - with self.ctrl.beam.unblanked(delay=0.2): - self.ctrl.get_image(params['tracking_time'], out=image_path) + image_path = self.path / 'image.tiff' + if not image_path.exists(): + self.ctrl.restore('FastADT_image') + with self.ctrl.beam.unblanked(delay=0.2): + self.ctrl.get_image(params['tracking_time'], out=image_path) + + with self.ctrl.beam.blanked(), self.ctrl.cam.blocked(): if params['tracking_mode'] == 'manual': - tracking_run = TrackingRun.from_params(params) - self.collect_manual_tracking(tracking_run) - else: - tracking_run = None + self.runs.tracking = TrackingRun.from_params(params) + self.determine_pathing_manually() + + for pathing_run in self.runs.pathing: + new_run = DiffractionRun.from_params(params, pathing_run) + self.runs.diffraction.append(new_run) + if not self.runs.pathing: + self.runs.diffraction = [DiffractionRun.from_params(params)] - self.run = DiffractionRun.from_params(params, tracking_run) self.ctrl.restore('FastADT_diff') self.camera_length = int(self.ctrl.magnification.get()) self.diffraction_mode = params['diffraction_mode'] - if self.diffraction_mode == 'stills': - self.collect_stills(self.run) - elif self.diffraction_mode == 'continuous': - self.collect_continuous(self.run) - print(self.run.table) + for run in self.runs.diffraction: + if self.diffraction_mode == 'stills': + self.collect_stills(run) + elif self.diffraction_mode == 'continuous': + self.collect_continuous(run) + self.finalize(run) + self.ctrl.restore('FastADT_image') - self.log.info('Collected the following run:') - self.log.info(str(self.run)) self.ctrl.stage.a = 0.0 - def collect_manual_tracking(self, run: TrackingRun) -> None: + @contextlib.contextmanager + def displayed_step(self, step: Step) -> None: + """Display step image with dots representing existing pathing.""" + draw = self.videostream_processor.draw + instructions: list[draw.Instruction] = [] + for run_i, p in enumerate(self.runs.pathing): + x = self.beam_center[0] + p.table.at[step.Index, 'delta_x'] + y = self.beam_center[1] + p.table.at[step.Index, 'delta_y'] + instructions.append(draw.circle((x, y), fill='white', radius=3)) + instructions.append(draw.circle((x, y), fill=get_color(run_i), radius=2)) + with self.videostream_processor.temporary(frame=step.image): + yield + for instruction in instructions: + draw.instructions.remove(instruction) + + def determine_pathing_manually(self) -> None: """Determine the target beam shifts `delta_x` and `delta_y` manually, based on the beam center found life (to find clicking offset) and `TrackingRun` to be used for crystal tracking in later experiment.""" + run: TrackingRun = self.runs.tracking self.restore_fast_adt_diff_for_image() self.beamshift = self.get_beamshift() self.ctrl.stage.a = run.table.loc[len(run.table) // 2, 'alpha'] - with self.ctrl.beam.unblanked(): + with self.ctrl.beam.unblanked(), self.ctrl.cam.unblocked(): self.msg('Collecting tracking. Click on the center of the beam.') with self.click_listener as cl: click = cl.get_click() - beam_center_x, beam_center_y = click.x, click.y + self.beam_center = (click.x, click.y) self.ctrl.restore('FastADT_track') - delta_xs, delta_ys = [], [] - Thread(target=self.enqueue_still_steps, args=(run,), daemon=True).start() - while (step := self.steps_queue.get()) is not None: - with self.videostream_processor.temporary(frame=step.image): + Thread(target=self.collect_tracking_stills, args=(run,), daemon=True).start() + + tracking_images = [] + tracking_in_progress = True + while tracking_in_progress: + print('Starting tracking again?') + while (step := self.steps_queue.get()) is not None: m = f'Click on the crystal (image={step.Index}, alpha={step.alpha} deg).' self.msg(m) - with self.click_listener as cl: + with self.displayed_step(step=step), self.click_listener as _, cl: click = cl.get_click() - delta_xs.append(click.x - beam_center_x) - delta_ys.append(click.y - beam_center_y) - self.msg('') - run.table['delta_x'] = delta_xs - run.table['delta_y'] = delta_ys - self.plot_tracking(tracking_run=run) + run.table.loc[step.Index, 'delta_x'] = click.x - self.beam_center[0] + run.table.loc[step.Index, 'delta_y'] = click.y - self.beam_center[1] + tracking_images.append(step.image) + self.msg('') + if 'image' not in run.table: + run.table['image'] = tracking_images + self.runs.pathing.append(deepcopy(run)) + + self.click_listener.queue.queue.clear() + self.msg('Tracking results: click LMB to accept, MMB to add new, RMB to reject.') + for step in sawtooth(self.runs.tracking.steps): + with self.displayed_step(step=step), self.click_listener as _, cl: + click = cl.get_click(timeout=0.5) + if click is None: + continue + if click.button == 1: + tracking_in_progress = False + break + elif click.button == 2: + for new_step in [*self.runs.tracking.steps, None]: + self.steps_queue.put(new_step) + print('Registered middle-click') + break + if click.button == 3: + msg = 'Experiment abandoned after tracking.' + self.msg(msg) + raise FastADTEarlyTermination(msg) def collect_stills(self, run: Run) -> None: """Collect a series of stills at angles/exposure specified in `run`""" @@ -423,7 +438,7 @@ def collect_stills(self, run: Run) -> None: run.table['meta'] = metas self.msg('Collected stills from {} to {} degree'.format(*run.scope)) - def enqueue_still_steps(self, run: Run) -> None: + def collect_tracking_stills(self, run: Run) -> None: """Get & put stills to `self.tracking_queue` to eval asynchronously.""" with self.ctrl.beam.unblanked(delay=0.2), self.ctrl.cam.blocked(): for step in run.steps: @@ -446,15 +461,15 @@ def collect_continuous(self, run: Run) -> None: movie = self.ctrl.get_movie(n_frames=len(run.table) - 1, exposure=run.exposure) a = float(run.table.iloc[-1].loc['alpha']) self.ctrl.stage.set_with_speed(a=a, speed=rot_speed, wait=False) - for step, (image, header) in zip(run.steps, movie): + for step, (image, meta) in zip(run.steps, movie): if run.has_beam_delta_information: self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) images.append(image) - metas.append(header) - self.run = run.to_continuous() - self.run.table['image'] = images - self.run.table['meta'] = metas - self.msg('Collected scans from {} to {} degree'.format(*run.scope)) + metas.append(meta) + run.make_continuous() + run.table['image'] = images + run.table['meta'] = metas + self.msg(str(run)) def determine_rotation_speed_and_exposure(self, run: Run) -> tuple[float, float]: """Closest possible speed setting & exposure considering dead time.""" @@ -465,8 +480,22 @@ def determine_rotation_speed_and_exposure(self, run: Run) -> tuple[float, float] exposure = abs(rot_plan.pace * run.osc_angle) - detector_dead_time return rot_plan.speed, exposure - def finalize(self) -> None: - self.msg(f'Saving experiment in: {self.path}') + def get_run_output_path(self, run: DiffractionRun) -> Path: + """Returns self.path if only 1 run done, self.path/sub## if + multiple.""" + if len(self.runs.pathing) <= 1: + return self.path + return self.path / f'sub{self.runs.diffraction.index(run):02d}' + + def finalize(self, run: DiffractionRun) -> None: + """Create output directories and save provided run there.""" + out_path = self.get_run_output_path(run) + mrc_path = out_path / 'mrc' + tiff_path = out_path / 'tiff' + mrc_path.mkdir(exist_ok=True, parents=True) + tiff_path.mkdir(exist_ok=True, parents=True) + + self.msg(f'Saving experiment in: {out_path}') rotation_axis = config.camera.camera_rotation_vs_stage_xy pixel_size = config.calibration['diff']['pixelsize'].get(self.camera_length, -1) physical_pixel_size = config.camera.physical_pixelsize # mm @@ -480,12 +509,12 @@ def finalize(self) -> None: method = 'Rotation Electron Diffraction' img_conv = ImgConversion( - buffer=self.run.buffer, - osc_angle=self.run.osc_angle, - start_angle=self.run.table['alpha'].iloc[0], - end_angle=self.run.table['alpha'].iloc[-1], + buffer=run.buffer, + osc_angle=run.osc_angle, + start_angle=run.table['alpha'].iloc[0], + end_angle=run.table['alpha'].iloc[-1], rotation_axis=rotation_axis, - acquisition_time=self.run.exposure, + acquisition_time=run.exposure, flatfield=self.flatfield, pixelsize=pixel_size, physical_pixelsize=physical_pixel_size, @@ -494,34 +523,9 @@ def finalize(self) -> None: stretch_azimuth=stretch_azimuth, method=method, ) - img_conv.threadpoolwriter(tiff_path=self.tiff_path, mrc_path=self.mrc_path, workers=8) - img_conv.write_ed3d(self.mrc_path) - img_conv.write_pets_inp(self.path) - img_conv.write_beam_centers(self.path) - self.msg('Data collection and conversion done. FastADT experiment finalized.') - def plot_tracking(self, tracking_run: Run) -> None: - """Plot tracking results in `VideoStreamFrame` and let user reject.""" - fig, ax1 = plt.subplots() - ax2 = ax1.twinx() - ax1.set_xlabel('alpha [degrees]') - ax1.set_ylabel('ΔX [pixels]') - ax2.set_ylabel('ΔY [pixels]') - ax1.yaxis.label.set_color('red') - ax2.yaxis.label.set_color('blue') - ax2.spines['left'].set_color('red') - ax2.spines['right'].set_color('blue') - ax1.tick_params(axis='y', colors='red') - ax2.tick_params(axis='y', colors='blue') - ax1.plot('alpha', 'delta_x', data=tracking_run.table, color='red', label='X') - ax2.plot('alpha', 'delta_y', data=tracking_run.table, color='blue', label='Y') - fig.tight_layout() - self.msg('Tracking results: left-click to accept, right-click to reject.') - with self.videostream_processor.temporary(figure=fig): - with self.click_listener as cl: - if cl.get_click().button != 1: - self.msg('Experiment abandoned after tracking.') - raise FastADTEarlyTermination('Experiment abandoned after tracking.') - - def teardown(self) -> None: - self.finalize() + img_conv.threadpoolwriter(tiff_path=tiff_path, mrc_path=mrc_path, workers=8) + img_conv.write_ed3d(mrc_path) + img_conv.write_pets_inp(out_path) + img_conv.write_beam_centers(out_path) + self.msg('Data collection and conversion done. FastADT experiment finalized.') diff --git a/src/instamatic/gui/fast_adt_frame.py b/src/instamatic/gui/fast_adt_frame.py index 3552d97c..8c889288 100644 --- a/src/instamatic/gui/fast_adt_frame.py +++ b/src/instamatic/gui/fast_adt_frame.py @@ -240,7 +240,6 @@ def fast_adt_interface_command(controller, **params: Any) -> None: try: fast_adt_frame.update_widget_state(busy=True) controller.fast_adt.start_collection(**params) - controller.fast_adt.finalize() except RuntimeError: pass # RuntimeError is raised if experiment is terminated early finally: diff --git a/src/instamatic/gui/videostream_processor.py b/src/instamatic/gui/videostream_processor.py index f10cb7ab..1f7299ef 100644 --- a/src/instamatic/gui/videostream_processor.py +++ b/src/instamatic/gui/videostream_processor.py @@ -167,6 +167,7 @@ def temporary( """ pre_context_values = self.temporary_frame, self.temporary_image try: + self.vsf.stream.block() if frame is not None: self.temporary_frame = frame if image is not None: @@ -175,6 +176,7 @@ def temporary( self.temporary_image = self.render_figure(figure) yield finally: + self.vsf.stream.unblock() self.temporary_frame, self.temporary_image = pre_context_values @property From eeb9e364c361c164df2b9d10880debc951f1c7f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 3 Oct 2025 15:37:22 +0200 Subject: [PATCH 12/49] Change `ClickEvent` to dataclass, implement `ClickEvent.xy` --- src/instamatic/gui/click_dispatcher.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/instamatic/gui/click_dispatcher.py b/src/instamatic/gui/click_dispatcher.py index 65c49b23..08fb2309 100644 --- a/src/instamatic/gui/click_dispatcher.py +++ b/src/instamatic/gui/click_dispatcher.py @@ -2,6 +2,7 @@ import enum import queue +from dataclasses import dataclass from typing import Callable, Optional, Union from typing_extensions import Self @@ -10,26 +11,25 @@ class MouseButton(enum.IntEnum): + """Mirrors tkinter.Event event values.""" + LEFT = 1 MIDDLE = 2 RIGHT = 3 + SCROLL_UP = 4 + SCROLL_DOWN = 5 +@dataclass class ClickEvent: """Individual click event expected and handled by `ClickListener`s.""" - def __init__( - self, - x: Optional[int] = None, - y: Optional[int] = None, - button: Optional[int] = None, - ) -> None: - self.x = x if x else 0 - self.y = y if y else 0 - self.button = MouseButton(button) if button else MouseButton.LEFT + x: Optional[int] = None + y: Optional[int] = None + button: MouseButton = MouseButton.LEFT - def __repr__(self) -> str: - return f'{self.__class__.__name__}(x={self.x}, y={self.y}, button={self.button})' + def xy(self) -> tuple[int, int]: + return self.x, self.y class ClickListener: From cbf757ecdf2d277ad792888a024dc046974ca400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 3 Oct 2025 15:47:50 +0200 Subject: [PATCH 13/49] Optimize clicking logic in FastADT experiment --- .../experiments/fast_adt/experiment.py | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index 40b4ebd1..866bdcec 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -21,6 +21,7 @@ from instamatic.calibrate import CalibBeamShift, CalibMovieDelays, CalibStageRotation from instamatic.calibrate.filenames import CALIB_BEAMSHIFT from instamatic.experiments.experiment_base import ExperimentBase +from instamatic.gui.click_dispatcher import MouseButton from instamatic.processing.ImgConversionTPX import ImgConversionTPX as ImgConversion T = TypeVar('T') @@ -232,7 +233,7 @@ def __init__( self.click_listener = None self.videostream_processor = None - self.beam_center: tuple[float, float] = (-1, -1) + self.beam_center: tuple[float, float] = (float('nan'), float('nan')) self.steps_queue: Queue[Union[Step, None]] = Queue() self.runs: Runs = Runs() @@ -376,8 +377,7 @@ def determine_pathing_manually(self) -> None: with self.ctrl.beam.unblanked(), self.ctrl.cam.unblocked(): self.msg('Collecting tracking. Click on the center of the beam.') with self.click_listener as cl: - click = cl.get_click() - self.beam_center = (click.x, click.y) + self.beam_center = cl.get_click().xy self.ctrl.restore('FastADT_track') Thread(target=self.collect_tracking_stills, args=(run,), daemon=True).start() @@ -389,8 +389,8 @@ def determine_pathing_manually(self) -> None: while (step := self.steps_queue.get()) is not None: m = f'Click on the crystal (image={step.Index}, alpha={step.alpha} deg).' self.msg(m) - with self.displayed_step(step=step), self.click_listener as _, cl: - click = cl.get_click() + with self.displayed_step(step=step), self.click_listener: + click = self.click_listener.get_click() run.table.loc[step.Index, 'delta_x'] = click.x - self.beam_center[0] run.table.loc[step.Index, 'delta_y'] = click.y - self.beam_center[1] tracking_images.append(step.image) @@ -399,25 +399,22 @@ def determine_pathing_manually(self) -> None: run.table['image'] = tracking_images self.runs.pathing.append(deepcopy(run)) - self.click_listener.queue.queue.clear() self.msg('Tracking results: click LMB to accept, MMB to add new, RMB to reject.') for step in sawtooth(self.runs.tracking.steps): - with self.displayed_step(step=step), self.click_listener as _, cl: - click = cl.get_click(timeout=0.5) + with self.displayed_step(step=step), self.click_listener: + click = self.click_listener.get_click(timeout=0.5) if click is None: continue - if click.button == 1: - tracking_in_progress = False - break - elif click.button == 2: - for new_step in [*self.runs.tracking.steps, None]: - self.steps_queue.put(new_step) - print('Registered middle-click') - break - if click.button == 3: + if click.button == MouseButton.RIGHT: msg = 'Experiment abandoned after tracking.' self.msg(msg) raise FastADTEarlyTermination(msg) + if click.button == MouseButton.LEFT: + tracking_in_progress = False + else: # any other mouse button was clicked + for new_step in [*self.runs.tracking.steps, None]: + self.steps_queue.put(new_step) + break def collect_stills(self, run: Run) -> None: """Collect a series of stills at angles/exposure specified in `run`""" From 5e379b50088aa1b56d154477d4c6db55ae64cc73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 3 Oct 2025 18:14:43 +0200 Subject: [PATCH 14/49] Streamline the `calibrate_beamshift_live` function --- .../calibrate/calibrate_beamshift.py | 81 +++++++------------ 1 file changed, 28 insertions(+), 53 deletions(-) diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index 60b6dcb5..3273ae4a 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -4,14 +4,17 @@ import os import pickle import sys +from pathlib import Path from typing import Optional import matplotlib.pyplot as plt import numpy as np from skimage.registration import phase_cross_correlation +from tqdm import tqdm from typing_extensions import Self from instamatic import config +from instamatic._typing import AnyPath from instamatic.calibrate.filenames import * from instamatic.calibrate.fit import fit_affine_transformation from instamatic.image_utils import autoscale, imgscale @@ -119,7 +122,12 @@ def center(self, ctrl) -> Optional[np.ndarray]: def calibrate_beamshift_live( - ctrl, gridsize=None, stepsize=None, save_images=False, outdir='.', **kwargs + ctrl, + gridsize: Optional[int] = None, + stepsize: Optional[float] = None, + save_images: bool = False, + outdir: AnyPath = '.', + **kwargs, ): """Calibrate pixel->beamshift coordinates live on the microscope. @@ -141,26 +149,20 @@ def calibrate_beamshift_live( """ exposure = kwargs.get('exposure', ctrl.cam.default_exposure) binsize = kwargs.get('binsize', ctrl.cam.default_binsize) + gridsize = gridsize or config.camera.calib_beamshift.get('gridsize', 5) + stepsize = stepsize or config.camera.calib_beamshift.get('stepsize', 250) + outfile = Path(outdir) / 'calib_beamshift_center' if save_images else None + kwargs = {'exposure': exposure, 'binsize': binsize, 'out': outfile} - if not gridsize: - gridsize = config.camera.calib_beamshift.get('gridsize', 5) - if not stepsize: - stepsize = config.camera.calib_beamshift.get('stepsize', 250) - - img_cent, h_cent = ctrl.get_image( - exposure=exposure, binsize=binsize, comment='Beam in center of image' - ) + comment = 'Beam in the center of the image' + img_cent, h_cent = ctrl.get_image(comment=comment, **kwargs) x_cent, y_cent = beamshift_cent = np.array(h_cent['BeamShift']) - magnification = h_cent['Magnification'] - stepsize = 2500.0 / magnification * stepsize + stepsize = 2500.0 / h_cent['Magnification'] * stepsize print(f'Gridsize: {gridsize} | Stepsize: {stepsize:.2f}') img_cent, scale = autoscale(img_cent) - - outfile = os.path.join(outdir, 'calib_beamcenter') if save_images else None - pixel_cent = find_beam_center(img_cent) * binsize / scale print('Beamshift: x={} | y={}'.format(*beamshift_cent)) @@ -168,62 +170,35 @@ def calibrate_beamshift_live( shifts = [] beampos = [] + dx_dy = ((np.indices((gridsize, gridsize)) - gridsize // 2) * stepsize).reshape(2, -1).T - n = int((gridsize - 1) / 2) # number of points = n*(n+1) - x_grid, y_grid = np.meshgrid( - np.arange(-n, n + 1) * stepsize, np.arange(-n, n + 1) * stepsize - ) - tot = gridsize * gridsize - - i = 0 - for dx, dy in np.stack([x_grid, y_grid]).reshape(2, -1).T: + progress_bar = tqdm(dx_dy, total=len(dx_dy), desc='Beamshift calibration') + for i, (dx, dy) in enumerate(progress_bar): ctrl.beamshift.set(x=float(x_cent + dx), y=float(y_cent + dy)) + progress_bar.set_postfix_str(ctrl.beamshift) - printer(f'Position: {i + 1}/{tot}: {ctrl.beamshift}') - - outfile = os.path.join(outdir, f'calib_beamshift_{i:04d}') if save_images else None - + kwargs['out'] = Path(outdir) / f'calib_beamshift_{i:04d}' if save_images else None comment = f'Calib image {i}: dx={dx} - dy={dy}' - img, h = ctrl.get_image( - exposure=exposure, - binsize=binsize, - out=outfile, - comment=comment, - header_keys=('BeamShift',), - ) + img, h = ctrl.get_image(comment=comment, header_keys=('BeamShift',), **kwargs) img = imgscale(img, scale) - shift, error, phasediff = phase_cross_correlation(img_cent, img, upsample_factor=10) - - beamshift = np.array(h['BeamShift']) - beampos.append(beamshift) - shifts.append(shift) - - i += 1 + shifts.append(phase_cross_correlation(img_cent, img, upsample_factor=10)[0]) + beampos.append(np.array(h['BeamShift'])) print('') # print "\nReset to center" ctrl.beamshift.set(*(float(_) for _ in beamshift_cent)) - # correct for binsize, store in binsize=1 - shifts = np.array(shifts) * binsize / scale - beampos = np.array(beampos) - np.array(beamshift_cent) - - c = CalibBeamShift.from_data( - shifts, - beampos, + # normalize to binsize = 1 and 512-pixel image scale before initializing + return CalibBeamShift.from_data( + np.array(shifts) * binsize / scale, + np.array(beampos) - beamshift_cent, reference_shift=beamshift_cent, reference_pixel=pixel_cent, header=h_cent, ) - # Calling c.plot with videostream crashes program - # if not hasattr(ctrl.cam, "VideoLoop"): - # c.plot() - - return c - def calibrate_beamshift_from_image_fn(center_fn, other_fn): """Calibrate pixel->beamshift coordinates from a set of images. From 70eddda86b2580360f7184b462a912cced31f2ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 3 Oct 2025 18:32:46 +0200 Subject: [PATCH 15/49] Streamline `CalibBeamShift.plot` --- src/instamatic/calibrate/calibrate_beamshift.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index 3273ae4a..f651249c 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -54,7 +54,6 @@ def pixelcoord_to_beamshift(self, pixelcoord) -> np.ndarray: def from_data(cls, shifts, beampos, reference_shift, reference_pixel, header=None) -> Self: fit_result = fit_affine_transformation(shifts, beampos) r = fit_result.r - t = fit_result.t c = cls(transform=r, reference_shift=reference_shift, reference_pixel=reference_pixel) c.data_shifts = shifts @@ -87,25 +86,20 @@ def to_file(self, fn=CALIB_BEAMSHIFT, outdir='.'): fout = os.path.join(outdir, fn) pickle.dump(self, open(fout, 'wb')) - def plot(self, to_file=None, outdir=''): + def plot(self, to_file: Optional[AnyPath] = None): if not self.has_data: return - if to_file: - to_file = f'calib_{beamshift}.png' - - beampos = self.data_beampos shifts = self.data_shifts - r_i = np.linalg.inv(self.transform) - beampos_ = np.dot(beampos, r_i) + beampos_ = np.dot(self.data_beampos, r_i) plt.scatter(*shifts.T, marker='>', label='Observed pixel shifts') plt.scatter(*beampos_.T, marker='<', label='Positions in pixel coords') plt.legend() plt.title('BeamShift vs. Direct beam position (Imaging)') if to_file: - plt.savefig(os.path.join(outdir, to_file)) + plt.savefig(Path(to_file) / 'calib_beamshift.png') plt.close() else: plt.show() @@ -191,13 +185,14 @@ def calibrate_beamshift_live( ctrl.beamshift.set(*(float(_) for _ in beamshift_cent)) # normalize to binsize = 1 and 512-pixel image scale before initializing - return CalibBeamShift.from_data( + c = CalibBeamShift.from_data( np.array(shifts) * binsize / scale, np.array(beampos) - beamshift_cent, reference_shift=beamshift_cent, reference_pixel=pixel_cent, header=h_cent, ) + return c def calibrate_beamshift_from_image_fn(center_fn, other_fn): From 49f1cf58e172d8e3a5eb98d4f70f0d0e7f13c833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 3 Oct 2025 20:28:49 +0200 Subject: [PATCH 16/49] Improvements to `CalibBeamShift` readability (WIP) --- .../calibrate/calibrate_beamshift.py | 98 ++++++++++--------- 1 file changed, 53 insertions(+), 45 deletions(-) diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index f651249c..fd134bec 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -4,9 +4,11 @@ import os import pickle import sys +from copy import deepcopy from pathlib import Path -from typing import Optional +from typing import Any, Optional, Sequence +import matplotlib.figure import matplotlib.pyplot as plt import numpy as np from skimage.registration import phase_cross_correlation @@ -19,55 +21,62 @@ from instamatic.calibrate.fit import fit_affine_transformation from instamatic.image_utils import autoscale, imgscale from instamatic.processing.find_holes import find_holes -from instamatic.tools import find_beam_center, printer +from instamatic.tools import find_beam_center logger = logging.getLogger(__name__) +Vector2 = np.ndarray # numpy array with two float (or int) elements +VectorNx2 = np.ndarray # numpy array with N Vector2-s +Matrix2x2 = np.ndarray # numpy array of shape (2, 2) with float elements + + class CalibBeamShift: """Simple class to hold the methods to perform transformations from one - setting to another based on calibration results.""" + setting to another based on calibration results. + + Throughout this class, the following two terms are used consistently: + - pixel: the (x, y) beam position in pixels determined from camera image + - shift: the unitless (x, y) value pair reported by the BeamShift deflector + """ - def __init__(self, transform, reference_shift, reference_pixel): + def __init__(self, transform, reference_pixel, reference_shift) -> None: super().__init__() - self.transform = transform - self.reference_shift = reference_shift - self.reference_pixel = reference_pixel - self.has_data = False + self.transform: Matrix2x2 = transform + self.reference_pixel: Vector2 = reference_pixel + self.reference_shift: Vector2 = reference_shift + self.pixels: Optional[VectorNx2] = None + self.shifts: Optional[VectorNx2] = None + self.images: Optional[list[np.ndarray]] = None def __repr__(self): return f'CalibBeamShift(transform=\n{self.transform},\n reference_shift=\n{self.reference_shift},\n reference_pixel=\n{self.reference_pixel})' - def beamshift_to_pixelcoord(self, beamshift): + def beamshift_to_pixelcoord(self, beamshift: Sequence[float, float]) -> Vector2: """Converts from beamshift x,y to pixel coordinates.""" + bs = np.array(beamshift) r_i = np.linalg.inv(self.transform) - pixelcoord = np.dot(self.reference_shift - beamshift, r_i) + self.reference_pixel - return pixelcoord + return np.dot(self.reference_shift - bs, r_i) + self.reference_pixel - def pixelcoord_to_beamshift(self, pixelcoord) -> np.ndarray: + def pixelcoord_to_beamshift(self, pixelcoord: Sequence[float, float]) -> Vector2: """Converts from pixel coordinates to beamshift x,y.""" r = self.transform - beamshift = self.reference_shift - np.dot(pixelcoord - self.reference_pixel, r) - return beamshift + pc = np.array(pixelcoord) + return self.reference_shift - np.dot(pc - self.reference_pixel, r) @classmethod - def from_data(cls, shifts, beampos, reference_shift, reference_pixel, header=None) -> Self: - fit_result = fit_affine_transformation(shifts, beampos) + def from_data(cls, pixels, shifts, reference_pixel, reference_shift, images=None) -> Self: + fit_result = fit_affine_transformation(pixels, shifts) r = fit_result.r - - c = cls(transform=r, reference_shift=reference_shift, reference_pixel=reference_pixel) - c.data_shifts = shifts - c.data_beampos = beampos - c.has_data = True - c.header = header - + c = cls(transform=r, reference_pixel=reference_pixel, reference_shift=reference_shift) + c.pixels = pixels + c.shifts = shifts + c.images = images return c @classmethod def from_file(cls, fn=CALIB_BEAMSHIFT) -> Self: """Read calibration from file.""" - import pickle - try: return pickle.load(open(fn, 'rb')) except OSError as e: @@ -83,19 +92,16 @@ def live(cls, ctrl, outdir='.') -> Self: def to_file(self, fn=CALIB_BEAMSHIFT, outdir='.'): """Save calibration to file.""" + self_without_images = deepcopy(self) + self_without_images.images = None fout = os.path.join(outdir, fn) - pickle.dump(self, open(fout, 'wb')) + pickle.dump(self_without_images, open(fout, 'wb')) def plot(self, to_file: Optional[AnyPath] = None): - if not self.has_data: - return - - shifts = self.data_shifts - r_i = np.linalg.inv(self.transform) - beampos_ = np.dot(self.data_beampos, r_i) - - plt.scatter(*shifts.T, marker='>', label='Observed pixel shifts') - plt.scatter(*beampos_.T, marker='<', label='Positions in pixel coords') + """Assuming the data is present, plot the data.""" + shifts = np.dot(self.shifts, np.linalg.inv(self.transform)) + plt.scatter(*self.pixels.T, marker='>', label='Observed pixel shifts') + plt.scatter(*shifts.T, marker='<', label='Reconstructed pixel shifts') plt.legend() plt.title('BeamShift vs. Direct beam position (Imaging)') if to_file: @@ -162,8 +168,7 @@ def calibrate_beamshift_live( print('Beamshift: x={} | y={}'.format(*beamshift_cent)) print('Pixel: x={} | y={}'.format(*pixel_cent)) - shifts = [] - beampos = [] + images, pixels, shifts = [], [], [] dx_dy = ((np.indices((gridsize, gridsize)) - gridsize // 2) * stepsize).reshape(2, -1).T progress_bar = tqdm(dx_dy, total=len(dx_dy), desc='Beamshift calibration') @@ -176,8 +181,9 @@ def calibrate_beamshift_live( img, h = ctrl.get_image(comment=comment, header_keys=('BeamShift',), **kwargs) img = imgscale(img, scale) - shifts.append(phase_cross_correlation(img_cent, img, upsample_factor=10)[0]) - beampos.append(np.array(h['BeamShift'])) + images.append(img) + pixels.append(phase_cross_correlation(img_cent, img, upsample_factor=10)[0]) + shifts.append(np.array(h['BeamShift'])) print('') # print "\nReset to center" @@ -186,11 +192,11 @@ def calibrate_beamshift_live( # normalize to binsize = 1 and 512-pixel image scale before initializing c = CalibBeamShift.from_data( - np.array(shifts) * binsize / scale, - np.array(beampos) - beamshift_cent, - reference_shift=beamshift_cent, + np.array(pixels) * binsize / scale, + np.array(shifts) - beamshift_cent, reference_pixel=pixel_cent, - header=h_cent, + reference_shift=beamshift_cent, + headers=h_cent, ) return c @@ -222,6 +228,7 @@ def calibrate_beamshift_from_image_fn(center_fn, other_fn): print('Beamshift: x={} | y={}'.format(*beamshift_cent)) print('Pixel: x={:.2f} | y={:.2f}'.format(*pixel_cent)) + images = [] shifts = [] beampos = [] @@ -236,6 +243,7 @@ def calibrate_beamshift_from_image_fn(center_fn, other_fn): shift, error, phasediff = phase_cross_correlation(img_cent, img, upsample_factor=10) + images.append(img) beampos.append(beamshift) shifts.append(shift) @@ -246,9 +254,9 @@ def calibrate_beamshift_from_image_fn(center_fn, other_fn): c = CalibBeamShift.from_data( shifts, beampos, - reference_shift=beamshift_cent, reference_pixel=pixel_cent, - header=h_cent, + reference_shift=beamshift_cent, + images=h_cent, ) c.plot() From 1d6f91d06ad72a61e6348eca2c5ad3f51a546365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 6 Oct 2025 14:13:41 +0200 Subject: [PATCH 17/49] Add option to calibrate beamshift with vsp --- .../calibrate/calibrate_beamshift.py | 36 +++++++++++++++---- .../experiments/fast_adt/experiment.py | 4 ++- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index fd134bec..e8e3f2e4 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -4,9 +4,10 @@ import os import pickle import sys +from contextlib import contextmanager, nullcontext from copy import deepcopy from pathlib import Path -from typing import Any, Optional, Sequence +from typing import TYPE_CHECKING, Any, Optional, Sequence, Union import matplotlib.figure import matplotlib.pyplot as plt @@ -23,6 +24,10 @@ from instamatic.processing.find_holes import find_holes from instamatic.tools import find_beam_center +if TYPE_CHECKING: + from instamatic.gui.videostream_processor import DeferredImageDraw, VideoStreamProcessor + + logger = logging.getLogger(__name__) @@ -66,8 +71,7 @@ def pixelcoord_to_beamshift(self, pixelcoord: Sequence[float, float]) -> Vector2 @classmethod def from_data(cls, pixels, shifts, reference_pixel, reference_shift, images=None) -> Self: - fit_result = fit_affine_transformation(pixels, shifts) - r = fit_result.r + r = fit_affine_transformation(pixels, shifts).r c = cls(transform=r, reference_pixel=reference_pixel, reference_shift=reference_shift) c.pixels = pixels c.shifts = shifts @@ -84,11 +88,12 @@ def from_file(cls, fn=CALIB_BEAMSHIFT) -> Self: raise OSError(f'{e.strerror}: {fn}. Please run {prog} first.') @classmethod - def live(cls, ctrl, outdir='.') -> Self: + def live(cls, ctrl, outdir='.', vsp: Optional[VideoStreamProcessor] = None) -> Self: while True: c = calibrate_beamshift(ctrl=ctrl, save_images=True, outdir=outdir) - if input(' >> Accept? [y/n] ') == 'y': - return c + with c.annotate_videostream(vsp) if vsp else nullcontext(): + if input(' >> Accept? [y/n] ') == 'y': + return c def to_file(self, fn=CALIB_BEAMSHIFT, outdir='.'): """Save calibration to file.""" @@ -110,6 +115,23 @@ def plot(self, to_file: Optional[AnyPath] = None): else: plt.show() + @contextmanager + def annotate_videostream(self, vsp: Optional[VideoStreamProcessor] = None) -> None: + shifts = np.dot(self.shifts, np.linalg.inv(self.transform)) + ins: list[DeferredImageDraw.Instruction] = [] + + vsp.temporary_frame = np.max(self.images, axis=0) + print('Determined (blue) vs calibrated (orange) beam positions:') + print(self.reference_pixel) + for p, s in zip(self.pixels + self.reference_pixel, shifts + self.reference_pixel): + print(f'{p!s:30} {s!s:30}') + ins.append(vsp.draw.circle(p, radius=3, fill='blue')) + ins.append(vsp.draw.circle(s, radius=3, fill='orange')) + yield + vsp.temporary_frame = None + for i in ins: + vsp.draw.instructions.remove(i) + def center(self, ctrl) -> Optional[np.ndarray]: """Return beamshift values to center the beam in the frame.""" pixel_center = [val / 2.0 for val in ctrl.cam.get_image_dimensions()] @@ -196,7 +218,7 @@ def calibrate_beamshift_live( np.array(shifts) - beamshift_cent, reference_pixel=pixel_cent, reference_shift=beamshift_cent, - headers=h_cent, + images=images, ) return c diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index 6cd4b5c6..5fb0b47a 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -233,7 +233,9 @@ def get_beamshift(self) -> CalibBeamShift: try: return CalibBeamShift.from_file(calib_dir / CALIB_BEAMSHIFT) except OSError: - return CalibBeamShift.live(self.ctrl, outdir=calib_dir) + return CalibBeamShift.live( + self.ctrl, outdir=calib_dir, vsp=self.videostream_processor + ) def get_dead_time( self, From 4c16ca98f4256b723ac5fb94533853d554660cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 6 Oct 2025 16:25:34 +0200 Subject: [PATCH 18/49] Fix errors, add beam center --- src/instamatic/calibrate/calibrate_beamshift.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index e8e3f2e4..bddbbd94 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -7,9 +7,8 @@ from contextlib import contextmanager, nullcontext from copy import deepcopy from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional, Sequence, Union +from typing import TYPE_CHECKING, Optional, Sequence -import matplotlib.figure import matplotlib.pyplot as plt import numpy as np from skimage.registration import phase_cross_correlation @@ -20,6 +19,7 @@ from instamatic._typing import AnyPath from instamatic.calibrate.filenames import * from instamatic.calibrate.fit import fit_affine_transformation +from instamatic.formats import read_tiff from instamatic.image_utils import autoscale, imgscale from instamatic.processing.find_holes import find_holes from instamatic.tools import find_beam_center @@ -124,9 +124,9 @@ def annotate_videostream(self, vsp: Optional[VideoStreamProcessor] = None) -> No print('Determined (blue) vs calibrated (orange) beam positions:') print(self.reference_pixel) for p, s in zip(self.pixels + self.reference_pixel, shifts + self.reference_pixel): - print(f'{p!s:30} {s!s:30}') ins.append(vsp.draw.circle(p, radius=3, fill='blue')) ins.append(vsp.draw.circle(s, radius=3, fill='orange')) + ins.append(vsp.draw.circle(self.reference_pixel, radius=3, fill='black')) yield vsp.temporary_frame = None for i in ins: @@ -237,7 +237,7 @@ def calibrate_beamshift_from_image_fn(center_fn, other_fn): print() print('Center:', center_fn) - img_cent, h_cent = load_img(center_fn) + img_cent, h_cent = read_tiff(center_fn) beamshift_cent = np.array(h_cent['BeamShift']) img_cent, scale = autoscale(img_cent, maxdim=512) @@ -255,7 +255,7 @@ def calibrate_beamshift_from_image_fn(center_fn, other_fn): beampos = [] for fn in other_fn: - img, h = load_img(fn) + img, h = read_tiff(fn) img = imgscale(img, scale) beamshift = np.array(h['BeamShift']) @@ -278,7 +278,7 @@ def calibrate_beamshift_from_image_fn(center_fn, other_fn): beampos, reference_pixel=pixel_cent, reference_shift=beamshift_cent, - images=h_cent, + images=images, ) c.plot() From ea64c4a474057071d255bf18e96edec3d322f352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 6 Oct 2025 17:08:38 +0200 Subject: [PATCH 19/49] Switch calibration output format from pickle to yaml --- .../calibrate/calibrate_beamshift.py | 34 +++++++++---------- src/instamatic/calibrate/filenames.py | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index bddbbd94..824a1e4a 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -1,16 +1,15 @@ from __future__ import annotations import logging -import os -import pickle import sys from contextlib import contextmanager, nullcontext -from copy import deepcopy +from dataclasses import asdict, dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Optional, Sequence import matplotlib.pyplot as plt import numpy as np +import yaml from skimage.registration import phase_cross_correlation from tqdm import tqdm from typing_extensions import Self @@ -36,6 +35,7 @@ Matrix2x2 = np.ndarray # numpy array of shape (2, 2) with float elements +@dataclass class CalibBeamShift: """Simple class to hold the methods to perform transformations from one setting to another based on calibration results. @@ -45,14 +45,12 @@ class CalibBeamShift: - shift: the unitless (x, y) value pair reported by the BeamShift deflector """ - def __init__(self, transform, reference_pixel, reference_shift) -> None: - super().__init__() - self.transform: Matrix2x2 = transform - self.reference_pixel: Vector2 = reference_pixel - self.reference_shift: Vector2 = reference_shift - self.pixels: Optional[VectorNx2] = None - self.shifts: Optional[VectorNx2] = None - self.images: Optional[list[np.ndarray]] = None + transform: Matrix2x2 + reference_pixel: Vector2 + reference_shift: Vector2 + pixels: Optional[VectorNx2] = field(default=None, repr=False) + shifts: Optional[VectorNx2] = field(default=None, repr=False) + images: Optional[list[np.ndarray]] = field(default=None, repr=False) def __repr__(self): return f'CalibBeamShift(transform=\n{self.transform},\n reference_shift=\n{self.reference_shift},\n reference_pixel=\n{self.reference_pixel})' @@ -82,7 +80,8 @@ def from_data(cls, pixels, shifts, reference_pixel, reference_shift, images=None def from_file(cls, fn=CALIB_BEAMSHIFT) -> Self: """Read calibration from file.""" try: - return pickle.load(open(fn, 'rb')) + with open(Path(fn), 'r') as yaml_file: + return cls(**yaml.safe_load(yaml_file)) except OSError as e: prog = 'instamatic.calibrate_beamshift' raise OSError(f'{e.strerror}: {fn}. Please run {prog} first.') @@ -95,12 +94,13 @@ def live(cls, ctrl, outdir='.', vsp: Optional[VideoStreamProcessor] = None) -> S if input(' >> Accept? [y/n] ') == 'y': return c - def to_file(self, fn=CALIB_BEAMSHIFT, outdir='.'): + def to_file(self, fn=CALIB_BEAMSHIFT, outdir: AnyPath = '.') -> None: """Save calibration to file.""" - self_without_images = deepcopy(self) - self_without_images.images = None - fout = os.path.join(outdir, fn) - pickle.dump(self_without_images, open(fout, 'wb')) + yaml_path = Path(outdir) / fn + yaml_dict = asdict(self) # type: ignore[arg-type] + yaml_dict = {k: v.tolist() for k, v in yaml_dict.items() if k != 'images'} + with open(yaml_path, 'w') as yaml_file: + yaml.safe_dump(yaml_dict, yaml_file) def plot(self, to_file: Optional[AnyPath] = None): """Assuming the data is present, plot the data.""" diff --git a/src/instamatic/calibrate/filenames.py b/src/instamatic/calibrate/filenames.py index d540c741..a3e1d9ab 100644 --- a/src/instamatic/calibrate/filenames.py +++ b/src/instamatic/calibrate/filenames.py @@ -1,7 +1,7 @@ from __future__ import annotations CALIB_STAGE_LOWMAG = 'calib_stage_lowmag.pickle' -CALIB_BEAMSHIFT = 'calib_beamshift.pickle' +CALIB_BEAMSHIFT = 'calib_beamshift.yaml' CALIB_BRIGHTNESS = 'calib_brightness.pickle' CALIB_DIFFSHIFT = 'calib_diffshift.pickle' CALIB_DIRECTBEAM = 'calib_directbeam.pickle' From 1fe61dd6432fdf5d4b45926ac2702f264576252a Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Mon, 6 Oct 2025 17:55:29 +0200 Subject: [PATCH 20/49] Allow delay during calibrate beamshift --- docs/config.md | 4 ++-- src/instamatic/calibrate/calibrate_beamshift.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/config.md b/docs/config.md index 276022a3..7de6f575 100644 --- a/docs/config.md +++ b/docs/config.md @@ -243,9 +243,9 @@ This file holds the specifications of the camera. This file is must be located t : Set the correction ratio for the cross pixels in the Timepix detector, default: 3. **calib_beamshift** -: Set up the grid and stepsize for the calibration of the beam shift in SerialED. The calibration will run a grid of `stepsize` by `stepsize` points, with steps of `stepsize`. The stepsize must be given corresponding to 2500x, and instamatic will then adjust the stepsize depending on the actual magnification, if needed. For example: +: Set up the grid and stepsize for the calibration of the beam shift in SerialED. The calibration will run a grid of `stepsize` by `stepsize` points, with steps of `stepsize`. The stepsize must be given corresponding to 2500x, and instamatic will then adjust the stepsize depending on the actual magnification, if needed. If the beam moves too slow, a `delay` between setting beam shift and getting image can be introduced. For example: ```yaml -{gridsize: 5, stepsize: 500} +{gridsize: 5, stepsize: 500, delay: 0.5} ``` **calib_directbeam** diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index 824a1e4a..25275bfd 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -2,6 +2,7 @@ import logging import sys +import time from contextlib import contextmanager, nullcontext from dataclasses import asdict, dataclass, field from pathlib import Path @@ -197,6 +198,7 @@ def calibrate_beamshift_live( for i, (dx, dy) in enumerate(progress_bar): ctrl.beamshift.set(x=float(x_cent + dx), y=float(y_cent + dy)) progress_bar.set_postfix_str(ctrl.beamshift) + time.sleep(config.camera.calib_beamshift.get('delay', 0.0)) kwargs['out'] = Path(outdir) / f'calib_beamshift_{i:04d}' if save_images else None comment = f'Calib image {i}: dx={dx} - dy={dy}' From 416c07073e15ddc4b3f2b4a6c71c0eee41d226ba Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Mon, 6 Oct 2025 18:38:03 +0200 Subject: [PATCH 21/49] Add necessary reflections to fix plotting --- src/instamatic/calibrate/calibrate_beamshift.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index 25275bfd..5b285acb 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -123,11 +123,12 @@ def annotate_videostream(self, vsp: Optional[VideoStreamProcessor] = None) -> No vsp.temporary_frame = np.max(self.images, axis=0) print('Determined (blue) vs calibrated (orange) beam positions:') - print(self.reference_pixel) - for p, s in zip(self.pixels + self.reference_pixel, shifts + self.reference_pixel): + for p, s in zip(self.pixels, shifts): + p = (p + self.reference_pixel)[::-1] # xy coords inverted for plot + s = (s + self.reference_pixel)[::-1] # xy coords inverted for plot ins.append(vsp.draw.circle(p, radius=3, fill='blue')) ins.append(vsp.draw.circle(s, radius=3, fill='orange')) - ins.append(vsp.draw.circle(self.reference_pixel, radius=3, fill='black')) + ins.append(vsp.draw.circle(self.reference_pixel[::-1], radius=3, fill='black')) yield vsp.temporary_frame = None for i in ins: From 9eb51fe1dfa4dd25a9213e33d43d5eb276204308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 6 Oct 2025 19:01:42 +0200 Subject: [PATCH 22/49] Final tweaks --- src/instamatic/calibrate/calibrate_beamshift.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index 5b285acb..8ccd5767 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -58,15 +58,13 @@ def __repr__(self): def beamshift_to_pixelcoord(self, beamshift: Sequence[float, float]) -> Vector2: """Converts from beamshift x,y to pixel coordinates.""" - bs = np.array(beamshift) r_i = np.linalg.inv(self.transform) - return np.dot(self.reference_shift - bs, r_i) + self.reference_pixel + return np.dot(self.reference_shift - np.array(beamshift), r_i) + self.reference_pixel def pixelcoord_to_beamshift(self, pixelcoord: Sequence[float, float]) -> Vector2: """Converts from pixel coordinates to beamshift x,y.""" - r = self.transform pc = np.array(pixelcoord) - return self.reference_shift - np.dot(pc - self.reference_pixel, r) + return self.reference_shift - np.dot(pc - self.reference_pixel, self.transform) @classmethod def from_data(cls, pixels, shifts, reference_pixel, reference_shift, images=None) -> Self: @@ -152,7 +150,7 @@ def calibrate_beamshift_live( save_images: bool = False, outdir: AnyPath = '.', **kwargs, -): +) -> CalibBeamShift: """Calibrate pixel->beamshift coordinates live on the microscope. ctrl: instance of `TEMController` @@ -211,8 +209,6 @@ def calibrate_beamshift_live( shifts.append(np.array(h['BeamShift'])) print('') - # print "\nReset to center" - ctrl.beamshift.set(*(float(_) for _ in beamshift_cent)) # normalize to binsize = 1 and 512-pixel image scale before initializing From 97274cfbca364c22d61cc7dadb5ab82456b37415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 6 Oct 2025 19:36:59 +0200 Subject: [PATCH 23/49] Make the yaml produced by CalibBeamShift human-readable --- .../calibrate/calibrate_beamshift.py | 5 ++-- src/instamatic/utils/yaml.py | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 src/instamatic/utils/yaml.py diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index 8ccd5767..2c26ecba 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -23,6 +23,7 @@ from instamatic.image_utils import autoscale, imgscale from instamatic.processing.find_holes import find_holes from instamatic.tools import find_beam_center +from instamatic.utils.yaml import Numpy2DDumper if TYPE_CHECKING: from instamatic.gui.videostream_processor import DeferredImageDraw, VideoStreamProcessor @@ -80,7 +81,7 @@ def from_file(cls, fn=CALIB_BEAMSHIFT) -> Self: """Read calibration from file.""" try: with open(Path(fn), 'r') as yaml_file: - return cls(**yaml.safe_load(yaml_file)) + return cls(**{k: np.array(v) for k, v in yaml.safe_load(yaml_file).items()}) except OSError as e: prog = 'instamatic.calibrate_beamshift' raise OSError(f'{e.strerror}: {fn}. Please run {prog} first.') @@ -99,7 +100,7 @@ def to_file(self, fn=CALIB_BEAMSHIFT, outdir: AnyPath = '.') -> None: yaml_dict = asdict(self) # type: ignore[arg-type] yaml_dict = {k: v.tolist() for k, v in yaml_dict.items() if k != 'images'} with open(yaml_path, 'w') as yaml_file: - yaml.safe_dump(yaml_dict, yaml_file) + yaml.dump(yaml_dict, yaml_file, Dumper=Numpy2DDumper, default_flow_style=None) def plot(self, to_file: Optional[AnyPath] = None): """Assuming the data is present, plot the data.""" diff --git a/src/instamatic/utils/yaml.py b/src/instamatic/utils/yaml.py new file mode 100644 index 00000000..ffedd18f --- /dev/null +++ b/src/instamatic/utils/yaml.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import numpy as np +import yaml + + +def _numpy_2d_representer(dumper: yaml.Dumper, array: np.ndarray) -> yaml.nodes.SequenceNode: + """Limit the number of newlines when writing numpy arrays where ndim>1.""" + data = array.tolist() + + if array.ndim == 1: + node = dumper.represent_list(data) + node.flow_style = True + elif array.ndim >= 2: + outer = [] + for row in data: + inner = dumper.represent_list(row) + inner.flow_style = True + outer.append(inner) + node = yaml.SequenceNode(tag='tag:yaml.org,2002:seq', value=outer, flow_style=False) + else: + node = dumper.represent_list(data) + return node + + +class Numpy2DDumper(yaml.SafeDumper): + """A yaml Dumper class that does not expand numpy arrays beyond 1st dim.""" + + +Numpy2DDumper.add_representer(np.ndarray, _numpy_2d_representer) From 9e0a017a1a24c47aea105ee06050fd7fc577af11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 7 Oct 2025 13:20:30 +0200 Subject: [PATCH 24/49] Update src/instamatic/calibrate/calibrate_beamshift.py Co-authored-by: Stef Smeets --- src/instamatic/calibrate/calibrate_beamshift.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index 2c26ecba..5f8c74c1 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -68,7 +68,7 @@ def pixelcoord_to_beamshift(self, pixelcoord: Sequence[float, float]) -> Vector2 return self.reference_shift - np.dot(pc - self.reference_pixel, self.transform) @classmethod - def from_data(cls, pixels, shifts, reference_pixel, reference_shift, images=None) -> Self: + def from_data(cls, pixels: VectorNx2, shifts: VectorNx2, reference_pixel: Vector2, reference_shift: Vector2, images: Optional[list[np.ndarray]] = None) -> Self: r = fit_affine_transformation(pixels, shifts).r c = cls(transform=r, reference_pixel=reference_pixel, reference_shift=reference_shift) c.pixels = pixels From 8e3ddd006bcaeefbb3e232eabd519ba46daf08bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 7 Oct 2025 13:22:51 +0200 Subject: [PATCH 25/49] Update src/instamatic/calibrate/calibrate_beamshift.py Co-authored-by: Stef Smeets --- src/instamatic/calibrate/calibrate_beamshift.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index 5f8c74c1..0cfecb3e 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -87,7 +87,7 @@ def from_file(cls, fn=CALIB_BEAMSHIFT) -> Self: raise OSError(f'{e.strerror}: {fn}. Please run {prog} first.') @classmethod - def live(cls, ctrl, outdir='.', vsp: Optional[VideoStreamProcessor] = None) -> Self: + def live(cls, ctrl, outdir: AnyPath='.', vsp: Optional[VideoStreamProcessor] = None) -> Self: while True: c = calibrate_beamshift(ctrl=ctrl, save_images=True, outdir=outdir) with c.annotate_videostream(vsp) if vsp else nullcontext(): From ea4ec427b4b5652f9fda97d593f14e95ee601d92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 7 Oct 2025 14:02:41 +0200 Subject: [PATCH 26/49] Minor post-review type-hint improvements + ruff --- .../calibrate/calibrate_beamshift.py | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index 0cfecb3e..06bcdf11 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -43,7 +43,7 @@ class CalibBeamShift: setting to another based on calibration results. Throughout this class, the following two terms are used consistently: - - pixel: the (x, y) beam position in pixels determined from camera image + - pixel: the (x, y) beam position in pixels as determined from camera image - shift: the unitless (x, y) value pair reported by the BeamShift deflector """ @@ -68,16 +68,25 @@ def pixelcoord_to_beamshift(self, pixelcoord: Sequence[float, float]) -> Vector2 return self.reference_shift - np.dot(pc - self.reference_pixel, self.transform) @classmethod - def from_data(cls, pixels: VectorNx2, shifts: VectorNx2, reference_pixel: Vector2, reference_shift: Vector2, images: Optional[list[np.ndarray]] = None) -> Self: - r = fit_affine_transformation(pixels, shifts).r - c = cls(transform=r, reference_pixel=reference_pixel, reference_shift=reference_shift) - c.pixels = pixels - c.shifts = shifts - c.images = images - return c + def from_data( + cls, + pixels: VectorNx2, + shifts: VectorNx2, + reference_pixel: Vector2, + reference_shift: Vector2, + images: Optional[list[np.ndarray]] = None, + ) -> Self: + return cls( + transform=fit_affine_transformation(pixels, shifts).r, + reference_pixel=reference_pixel, + reference_shift=reference_shift, + pixels=pixels, + shifts=shifts, + images=images, + ) @classmethod - def from_file(cls, fn=CALIB_BEAMSHIFT) -> Self: + def from_file(cls, fn: AnyPath = CALIB_BEAMSHIFT) -> Self: """Read calibration from file.""" try: with open(Path(fn), 'r') as yaml_file: @@ -87,14 +96,16 @@ def from_file(cls, fn=CALIB_BEAMSHIFT) -> Self: raise OSError(f'{e.strerror}: {fn}. Please run {prog} first.') @classmethod - def live(cls, ctrl, outdir: AnyPath='.', vsp: Optional[VideoStreamProcessor] = None) -> Self: + def live( + cls, ctrl, outdir: AnyPath = '.', vsp: Optional[VideoStreamProcessor] = None + ) -> Self: while True: c = calibrate_beamshift(ctrl=ctrl, save_images=True, outdir=outdir) with c.annotate_videostream(vsp) if vsp else nullcontext(): if input(' >> Accept? [y/n] ') == 'y': return c - def to_file(self, fn=CALIB_BEAMSHIFT, outdir: AnyPath = '.') -> None: + def to_file(self, fn: AnyPath = CALIB_BEAMSHIFT, outdir: AnyPath = '.') -> None: """Save calibration to file.""" yaml_path = Path(outdir) / fn yaml_dict = asdict(self) # type: ignore[arg-type] @@ -194,7 +205,7 @@ def calibrate_beamshift_live( images, pixels, shifts = [], [], [] dx_dy = ((np.indices((gridsize, gridsize)) - gridsize // 2) * stepsize).reshape(2, -1).T - progress_bar = tqdm(dx_dy, total=len(dx_dy), desc='Beamshift calibration') + progress_bar = tqdm(dx_dy, desc='Beamshift calibration') for i, (dx, dy) in enumerate(progress_bar): ctrl.beamshift.set(x=float(x_cent + dx), y=float(y_cent + dy)) progress_bar.set_postfix_str(ctrl.beamshift) From ce1d93451ac6c116226f9a511d4292b0d30da165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 14 Oct 2025 17:47:41 +0200 Subject: [PATCH 27/49] Add `instamatic.utils.iterating` with `sawtooth` iterating function --- src/instamatic/utils/iterating.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/instamatic/utils/iterating.py diff --git a/src/instamatic/utils/iterating.py b/src/instamatic/utils/iterating.py new file mode 100644 index 00000000..10152f36 --- /dev/null +++ b/src/instamatic/utils/iterating.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import itertools +from typing import Iterable, Iterator, TypeVar + +T = TypeVar('T') + + +def sawtooth(iterator: Iterable[T]) -> Iterator[T]: + """Iterate elements of input sequence back and forth, repeating edges.""" + yield from itertools.cycle((seq := list(iterator)) + list(reversed(seq))) From 23ae996e580675c03cf7f0fda45d5979fcb9344f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 14 Oct 2025 17:50:36 +0200 Subject: [PATCH 28/49] Make the `click_dispatcher:ClickEvent.xy` a property --- src/instamatic/gui/click_dispatcher.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/instamatic/gui/click_dispatcher.py b/src/instamatic/gui/click_dispatcher.py index 08fb2309..7b430cb2 100644 --- a/src/instamatic/gui/click_dispatcher.py +++ b/src/instamatic/gui/click_dispatcher.py @@ -28,6 +28,7 @@ class ClickEvent: y: Optional[int] = None button: MouseButton = MouseButton.LEFT + @property def xy(self) -> tuple[int, int]: return self.x, self.y From d2bfb1c682501a29ab5ff1ab8830a1109bf2c9f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 14 Oct 2025 17:51:56 +0200 Subject: [PATCH 29/49] Rephrase `VideoStreamProcessor.temporary` using `blocked` context --- src/instamatic/gui/videostream_processor.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/instamatic/gui/videostream_processor.py b/src/instamatic/gui/videostream_processor.py index 1f7299ef..bba18118 100644 --- a/src/instamatic/gui/videostream_processor.py +++ b/src/instamatic/gui/videostream_processor.py @@ -167,16 +167,15 @@ def temporary( """ pre_context_values = self.temporary_frame, self.temporary_image try: - self.vsf.stream.block() if frame is not None: self.temporary_frame = frame if image is not None: self.temporary_image = image elif figure is not None: self.temporary_image = self.render_figure(figure) - yield + with self.vsf.stream.blocked(): + yield finally: - self.vsf.stream.unblock() self.temporary_frame, self.temporary_image = pre_context_values @property From bf73eb42de89473fa87f351a93bad4e30b317e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 14 Oct 2025 17:55:06 +0200 Subject: [PATCH 30/49] Fix the bug where canceling FastADT did not remove its elements --- .../experiments/fast_adt/experiment.py | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index ac53ebb6..837fb5a7 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -1,14 +1,13 @@ from __future__ import annotations import contextlib -import itertools import logging from copy import deepcopy from dataclasses import dataclass, field from pathlib import Path from queue import Queue from threading import Thread -from typing import Any, Iterable, Iterator, Optional, Sequence, TypeVar, Union +from typing import Any, Iterator, Optional, Sequence, Union import numpy as np import pandas as pd @@ -23,8 +22,7 @@ from instamatic.experiments.experiment_base import ExperimentBase from instamatic.gui.click_dispatcher import MouseButton from instamatic.processing.ImgConversionTPX import ImgConversionTPX as ImgConversion - -T = TypeVar('T') +from instamatic.utils.iterating import sawtooth def get_color(i: int) -> tuple[int, int, int]: @@ -38,11 +36,6 @@ def safe_range(*, start: float, stop: float, step: float) -> np.ndarray: return np.linspace(start, stop, step_count, endpoint=True, dtype=float) -def sawtooth(iterator: Iterable[T]) -> Iterator[T]: - """Iterate elements of input sequence back and forth, repeating edges.""" - yield from itertools.cycle((seq := list(iterator)) + list(reversed(seq))) - - class FastADTEarlyTermination(RuntimeError): """Raised if FastADT experiment terminates early due to known reasons.""" @@ -362,10 +355,12 @@ def displayed_step(self, step: Step) -> None: y = self.beam_center[1] + p.table.at[step.Index, 'delta_y'] instructions.append(draw.circle((x, y), fill='white', radius=3)) instructions.append(draw.circle((x, y), fill=get_color(run_i), radius=2)) - with self.videostream_processor.temporary(frame=step.image): - yield - for instruction in instructions: - draw.instructions.remove(instruction) + try: + with self.videostream_processor.temporary(frame=step.image): + yield + finally: + for instruction in instructions: + draw.instructions.remove(instruction) def determine_pathing_manually(self) -> None: """Determine the target beam shifts `delta_x` and `delta_y` manually, @@ -374,9 +369,9 @@ def determine_pathing_manually(self) -> None: run: TrackingRun = self.runs.tracking self.restore_fast_adt_diff_for_image() - self.beamshift = self.get_beamshift() self.ctrl.stage.a = run.table.loc[len(run.table) // 2, 'alpha'] with self.ctrl.beam.unblanked(), self.ctrl.cam.unblocked(): + self.beamshift = self.get_beamshift() self.msg('Collecting tracking. Click on the center of the beam.') with self.click_listener as cl: self.beam_center = cl.get_click().xy @@ -403,8 +398,9 @@ def determine_pathing_manually(self) -> None: self.msg('Tracking results: click LMB to accept, MMB to add new, RMB to reject.') for step in sawtooth(self.runs.tracking.steps): - with self.displayed_step(step=step), self.click_listener: - click = self.click_listener.get_click(timeout=0.5) + with self.displayed_step(step=step): + with self.click_listener: + click = self.click_listener.get_click(timeout=0.5) if click is None: continue if click.button == MouseButton.RIGHT: From b220e7403a2eeeadb1c98b36c4befb5b1140b957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 14 Oct 2025 18:03:49 +0200 Subject: [PATCH 31/49] Fix the bug where colors of crystal tracking repeated after 10 --- src/instamatic/experiments/fast_adt/experiment.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index 837fb5a7..bcd77c7d 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -27,7 +27,7 @@ def get_color(i: int) -> tuple[int, int, int]: """Return i-th color from matplotlib colormap tab10 as accepted by PIL.""" - return tuple([int(rgb * 255) for rgb in plt.get_cmap('tab10')(i)][:3]) # type: ignore + return tuple([int(rgb * 255) for rgb in plt.get_cmap('tab10')(i % 10)][:3]) # type: ignore def safe_range(*, start: float, stop: float, step: float) -> np.ndarray: @@ -353,8 +353,8 @@ def displayed_step(self, step: Step) -> None: for run_i, p in enumerate(self.runs.pathing): x = self.beam_center[0] + p.table.at[step.Index, 'delta_x'] y = self.beam_center[1] + p.table.at[step.Index, 'delta_y'] - instructions.append(draw.circle((x, y), fill='white', radius=3)) - instructions.append(draw.circle((x, y), fill=get_color(run_i), radius=2)) + instructions.append(draw.circle((x, y), fill='white', radius=5)) + instructions.append(draw.circle((x, y), fill=get_color(run_i), radius=3)) try: with self.videostream_processor.temporary(frame=step.image): yield From 6af6c1cc642723d7038c282df199a42b71203196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 15 Oct 2025 17:36:57 +0200 Subject: [PATCH 32/49] Remove debug message --- src/instamatic/experiments/fast_adt/experiment.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index bcd77c7d..c71941ea 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -1,7 +1,7 @@ from __future__ import annotations -import contextlib import logging +from contextlib import contextmanager from copy import deepcopy from dataclasses import dataclass, field from pathlib import Path @@ -345,8 +345,8 @@ def start_collection(self, **params) -> None: self.ctrl.restore('FastADT_image') self.ctrl.stage.a = 0.0 - @contextlib.contextmanager - def displayed_step(self, step: Step) -> None: + @contextmanager + def displayed_pathing(self, step: Step) -> None: """Display step image with dots representing existing pathing.""" draw = self.videostream_processor.draw instructions: list[draw.Instruction] = [] @@ -382,11 +382,10 @@ def determine_pathing_manually(self) -> None: tracking_images = [] tracking_in_progress = True while tracking_in_progress: - print('Starting tracking again?') while (step := self.steps_queue.get()) is not None: m = f'Click on the crystal (image={step.Index}, alpha={step.alpha} deg).' self.msg(m) - with self.displayed_step(step=step), self.click_listener: + with self.displayed_pathing(step=step), self.click_listener: click = self.click_listener.get_click() run.table.loc[step.Index, 'delta_x'] = click.x - self.beam_center[0] run.table.loc[step.Index, 'delta_y'] = click.y - self.beam_center[1] @@ -398,7 +397,7 @@ def determine_pathing_manually(self) -> None: self.msg('Tracking results: click LMB to accept, MMB to add new, RMB to reject.') for step in sawtooth(self.runs.tracking.steps): - with self.displayed_step(step=step): + with self.displayed_pathing(step=step): with self.click_listener: click = self.click_listener.get_click(timeout=0.5) if click is None: @@ -476,8 +475,7 @@ def determine_rotation_speed_and_exposure(self, run: Run) -> tuple[float, float] return rot_plan.speed, exposure def get_run_output_path(self, run: DiffractionRun) -> Path: - """Returns self.path if only 1 run done, self.path/sub## if - multiple.""" + """Return self.path if only 1 run done, self.path/sub## if multiple.""" if len(self.runs.pathing) <= 1: return self.path return self.path / f'sub{self.runs.diffraction.index(run):02d}' From 4c4f44972dfbf5b6dce91d090e3314c697c6d59f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 15 Oct 2025 19:30:29 +0200 Subject: [PATCH 33/49] Attempt to generalize collecting, revert as needed --- .../experiments/fast_adt/experiment.py | 88 +++++++++++-------- 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index c71941ea..ceea15aa 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -132,6 +132,17 @@ def calculate_beamshifts(self, ctrl, beamshift) -> None: beamshifts = beamshift.pixelcoord_to_beamshift(crystal_yxs) self.table[['beamshift_x', 'beamshift_y']] = beamshifts + def update(self, steps_queue: Queue[Union[Step, None]]) -> None: + """Consume Steps from queue until None, update self.images & .meta.""" + step_list: list[Step] = [] + while True: + step = steps_queue.get() + if step is None: + break + step_list.append(step) + self.table['image'] = [s.image for s in step_list] + self.table['meta'] = [s.meta for s in step_list] + class TrackingRun(Run): """Designed to estimate delta_x/y a priori based on manual used input.""" @@ -320,6 +331,7 @@ def start_collection(self, **params) -> None: with self.ctrl.beam.unblanked(delay=0.2): self.ctrl.get_image(params['tracking_time'], out=image_path) + self.diffraction_mode = params['diffraction_mode'] with self.ctrl.beam.blanked(), self.ctrl.cam.blocked(): if params['tracking_mode'] == 'manual': self.runs.tracking = TrackingRun.from_params(params) @@ -333,13 +345,12 @@ def start_collection(self, **params) -> None: self.ctrl.restore('FastADT_diff') self.camera_length = int(self.ctrl.magnification.get()) - self.diffraction_mode = params['diffraction_mode'] for run in self.runs.diffraction: if self.diffraction_mode == 'stills': self.collect_stills(run) elif self.diffraction_mode == 'continuous': - self.collect_continuous(run) + self.collect_scans(run) self.finalize(run) self.ctrl.restore('FastADT_image') @@ -377,7 +388,7 @@ def determine_pathing_manually(self) -> None: self.beam_center = cl.get_click().xy self.ctrl.restore('FastADT_track') - Thread(target=self.collect_tracking_stills, args=(run,), daemon=True).start() + Thread(target=self.collect_tracking, args=(run,), daemon=True).start() tracking_images = [] tracking_in_progress = True @@ -413,57 +424,60 @@ def determine_pathing_manually(self) -> None: self.steps_queue.put(new_step) break + def _collect_stills(self, run: Run, enqueue: bool = True) -> None: + """Collect `run.steps` stills and place them in `self.steps_queue`.""" + for step in run.steps: + if run.has_beam_delta_information: + self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) + self.ctrl.stage.a = step.alpha + step.image, step.meta = self.ctrl.get_image(exposure=run.exposure) + if enqueue: + self.steps_queue.put(step) + if enqueue: + self.steps_queue.put(None) + def collect_stills(self, run: Run) -> None: """Collect a series of stills at angles/exposure specified in `run`""" self.msg('Collecting stills from {} to {} degree'.format(*run.scope)) - images, metas = [], [] if run.has_beam_delta_information: run.calculate_beamshifts(self.ctrl, self.beamshift) - with self.ctrl.beam.unblanked(delay=0.2), self.ctrl.cam.blocked(): - for step in run.steps: - if run.has_beam_delta_information: - self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) - self.ctrl.stage.a = step.alpha - image, meta = self.ctrl.get_image(exposure=run.exposure) - images.append(image) - metas.append(meta) - run.table['image'] = images - run.table['meta'] = metas + self._collect_stills(run=run, enqueue=True) + run.update(self.steps_queue) self.msg('Collected stills from {} to {} degree'.format(*run.scope)) - def collect_tracking_stills(self, run: Run) -> None: + def collect_tracking(self, run: Run) -> None: """Get & put stills to `self.tracking_queue` to eval asynchronously.""" with self.ctrl.beam.unblanked(delay=0.2), self.ctrl.cam.blocked(): - for step in run.steps: - self.ctrl.stage.a = step.alpha - step.image = self.ctrl.get_image(exposure=run.exposure)[0] - self.steps_queue.put(step) + self._collect_stills(run=run, enqueue=True) + + def _collect_scans(self, run: Run, enqueue: bool = True) -> None: + rot_speed, run.exposure = self.determine_rotation_speed_and_exposure(run) + with self.ctrl.stage.rotation_speed(speed=rot_speed): + self.ctrl.stage.a = float(run.table.loc[0, 'alpha']) + movie = self.ctrl.get_movie(n_frames=len(run.table) - 1, exposure=run.exposure) + a = float(run.table.iloc[-1].loc['alpha']) + self.ctrl.stage.set_with_speed(a=a, speed=rot_speed, wait=False) + for step, (image, meta) in zip(run.steps, movie): + print('got image') + if run.has_beam_delta_information: + self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) + if enqueue: + step.image = image + step.meta = meta + self.steps_queue.put(step) self.steps_queue.put(None) - def collect_continuous(self, run: Run) -> None: + def collect_scans(self, run: Run) -> None: """Collect a series of scans at angles/exposure specified in `run`""" self.msg('Collecting scans from {} to {} degree'.format(*run.scope)) - images, metas = [], [] if run.has_beam_delta_information: run.calculate_beamshifts(self.ctrl, self.beamshift) - rot_speed, run.exposure = self.determine_rotation_speed_and_exposure(run) - - self.ctrl.stage.a = float(run.table.loc[0, 'alpha']) - with self.ctrl.stage.rotation_speed(speed=rot_speed): - with self.ctrl.beam.unblanked(delay=0.2): - movie = self.ctrl.get_movie(n_frames=len(run.table) - 1, exposure=run.exposure) - a = float(run.table.iloc[-1].loc['alpha']) - self.ctrl.stage.set_with_speed(a=a, speed=rot_speed, wait=False) - for step, (image, meta) in zip(run.steps, movie): - if run.has_beam_delta_information: - self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) - images.append(image) - metas.append(meta) + with self.ctrl.beam.unblanked(delay=0.2), self.ctrl.cam.blocked(): + self._collect_scans(run=run, enqueue=True) run.make_continuous() - run.table['image'] = images - run.table['meta'] = metas - self.msg(str(run)) + run.update(self.steps_queue) + self.msg('Collected scans from {} to {} degree'.format(*run.scope)) def determine_rotation_speed_and_exposure(self, run: Run) -> tuple[float, float]: """Closest possible speed setting & exposure considering dead time.""" From 7611b6cd28b9620a5f475a4e72f8e22269ee8321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 17 Oct 2025 10:34:56 +0200 Subject: [PATCH 34/49] Fix: rotation speed for negative target pace is negative, rounds to 0 --- src/instamatic/calibrate/calibrate_stage_rotation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/calibrate/calibrate_stage_rotation.py b/src/instamatic/calibrate/calibrate_stage_rotation.py index 75e53d4f..546f3aa0 100644 --- a/src/instamatic/calibrate/calibrate_stage_rotation.py +++ b/src/instamatic/calibrate/calibrate_stage_rotation.py @@ -137,7 +137,7 @@ def speed_time_to_span(self, speed: float, time: float) -> float: def plan_rotation(self, target_pace: float) -> RotationPlan: """Given target pace in sec / deg, find nearest pace, speed, delay.""" - target_speed = self.alpha_pace / target_pace # exact speed setting needed + target_speed = abs(self.alpha_pace / target_pace) # exact speed setting needed nearest_speed = self.speed_options.nearest(target_speed) # nearest setting nearest_pace = self.alpha_pace / nearest_speed # nearest in sec/deg total_delay = self.alpha_windup / nearest_speed + self.delay From ca85cf0121acdef486b7c6daa918bfc17952fd7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 17 Oct 2025 12:24:07 +0200 Subject: [PATCH 35/49] Rename tracking "mode" to "algo"; if continuous, track w/ movie --- docs/gui.md | 4 +- .../experiments/fast_adt/experiment.py | 74 +++++++++---------- src/instamatic/gui/fast_adt_frame.py | 25 +++---- 3 files changed, 49 insertions(+), 54 deletions(-) diff --git a/docs/gui.md b/docs/gui.md index 87cba280..f8815d17 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -181,8 +181,8 @@ For optimum performance, the FastADT frame uses three separate TEM setting which **Diffraction exposure (s)** : The time taken to collect each diffraction image in seconds. In the `continuous` mode it will additionally dictate the rotation speed. -**Tracking mode** -: Dictates whether `none` or `manual` tracking is to be performed at the start of the experiment. +**Tracking algorithm** +: Dictates whether `none` or `manual` tracking algorithm is to be performed at the start of the experiment to determine pathing. **Tracking step (deg)** : The target spacing between angles at which subsequent tracking images are collected within the tracking series in degrees. diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index ceea15aa..ac19f126 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -114,13 +114,11 @@ def osc_angle(self) -> float: a = list(self.table['alpha']) return (a[-1] - a[0]) / (len(a) - 1) if len(a) > 1 else -1 - def make_continuous(self) -> None: - """Make self a new run from N-1 first rows for the continuous - method.""" - new_al = self.table['alpha'].rolling(2).mean().drop(0) - new_kw = self.table.iloc[:-1, :].to_dict(orient='list') - del new_kw['alpha'] - self.__init__(exposure=self.exposure, continuous=True, alpha=new_al, **new_kw) + def collapse_to_alpha_midpoints(self) -> None: + """Set current alpha midpoints as new alpha, dropping the first row.""" + alpha_midpoints = self.table['alpha'].rolling(2).mean().drop(0) + self.table = self.table.iloc[1:] + self.table['alpha'] = alpha_midpoints def calculate_beamshifts(self, ctrl, beamshift) -> None: """Note CalibBeamShift uses swapped axes: X points down, Y right.""" @@ -132,7 +130,7 @@ def calculate_beamshifts(self, ctrl, beamshift) -> None: beamshifts = beamshift.pixelcoord_to_beamshift(crystal_yxs) self.table[['beamshift_x', 'beamshift_y']] = beamshifts - def update(self, steps_queue: Queue[Union[Step, None]]) -> None: + def update_images_metas(self, steps_queue: Queue[Union[Step, None]]) -> None: """Consume Steps from queue until None, update self.images & .meta.""" step_list: list[Step] = [] while True: @@ -149,12 +147,14 @@ class TrackingRun(Run): @classmethod def from_params(cls, params: dict[str, Any]) -> Self: - alpha_range = safe_range( - start=params['diffraction_start'], - stop=params['diffraction_stop'], - step=params['tracking_step'], - ) - return cls(exposure=params['tracking_time'], alpha=alpha_range) + a0 = params['diffraction_start'] + a1 = params['diffraction_stop'] + alpha_range = safe_range(start=a0, stop=a1, step=params['tracking_step']) + if c := (params['diffraction_mode'] == 'continuous'): + step = float(np.mean(np.diff(alpha_range))) + offset = (a1 - a0) / abs(a1 - a0) * step / 2 + alpha_range = safe_range(start=a0 - offset, stop=a1 + offset, step=step) + return cls(exposure=params['tracking_time'], continuous=c, alpha=alpha_range) class DiffractionRun(Run): @@ -333,7 +333,7 @@ def start_collection(self, **params) -> None: self.diffraction_mode = params['diffraction_mode'] with self.ctrl.beam.blanked(), self.ctrl.cam.blocked(): - if params['tracking_mode'] == 'manual': + if params['tracking_algo'] == 'manual': self.runs.tracking = TrackingRun.from_params(params) self.determine_pathing_manually() @@ -347,6 +347,8 @@ def start_collection(self, **params) -> None: self.camera_length = int(self.ctrl.magnification.get()) for run in self.runs.diffraction: + if run.has_beam_delta_information: + run.calculate_beamshifts(self.ctrl, self.beamshift) if self.diffraction_mode == 'stills': self.collect_stills(run) elif self.diffraction_mode == 'continuous': @@ -426,57 +428,55 @@ def determine_pathing_manually(self) -> None: def _collect_stills(self, run: Run, enqueue: bool = True) -> None: """Collect `run.steps` stills and place them in `self.steps_queue`.""" - for step in run.steps: - if run.has_beam_delta_information: - self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) - self.ctrl.stage.a = step.alpha - step.image, step.meta = self.ctrl.get_image(exposure=run.exposure) - if enqueue: - self.steps_queue.put(step) + with self.ctrl.cam.blocked(): + for step in run.steps: + if run.has_beam_delta_information: + self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) + self.ctrl.stage.a = step.alpha + step.image, step.meta = self.ctrl.get_image(exposure=run.exposure) + if enqueue: + self.steps_queue.put(step) if enqueue: self.steps_queue.put(None) def collect_stills(self, run: Run) -> None: """Collect a series of stills at angles/exposure specified in `run`""" self.msg('Collecting stills from {} to {} degree'.format(*run.scope)) - if run.has_beam_delta_information: - run.calculate_beamshifts(self.ctrl, self.beamshift) - with self.ctrl.beam.unblanked(delay=0.2), self.ctrl.cam.blocked(): + with self.ctrl.beam.unblanked(delay=0.2): self._collect_stills(run=run, enqueue=True) - run.update(self.steps_queue) + run.update_images_metas(self.steps_queue) self.msg('Collected stills from {} to {} degree'.format(*run.scope)) def collect_tracking(self, run: Run) -> None: """Get & put stills to `self.tracking_queue` to eval asynchronously.""" - with self.ctrl.beam.unblanked(delay=0.2), self.ctrl.cam.blocked(): - self._collect_stills(run=run, enqueue=True) + collector = self._collect_scans if run.continuous else self._collect_stills + with self.ctrl.beam.unblanked(delay=0.2): + collector(run=run, enqueue=True) def _collect_scans(self, run: Run, enqueue: bool = True) -> None: rot_speed, run.exposure = self.determine_rotation_speed_and_exposure(run) - with self.ctrl.stage.rotation_speed(speed=rot_speed): - self.ctrl.stage.a = float(run.table.loc[0, 'alpha']) + with self.ctrl.stage.rotation_speed(speed=rot_speed), self.ctrl.cam.blocked(): + self.ctrl.stage.a = float(run.table.at[0, 'alpha']) movie = self.ctrl.get_movie(n_frames=len(run.table) - 1, exposure=run.exposure) a = float(run.table.iloc[-1].loc['alpha']) + run.collapse_to_alpha_midpoints() self.ctrl.stage.set_with_speed(a=a, speed=rot_speed, wait=False) for step, (image, meta) in zip(run.steps, movie): - print('got image') if run.has_beam_delta_information: self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) if enqueue: step.image = image step.meta = meta self.steps_queue.put(step) - self.steps_queue.put(None) + if enqueue: + self.steps_queue.put(None) def collect_scans(self, run: Run) -> None: """Collect a series of scans at angles/exposure specified in `run`""" self.msg('Collecting scans from {} to {} degree'.format(*run.scope)) - if run.has_beam_delta_information: - run.calculate_beamshifts(self.ctrl, self.beamshift) - with self.ctrl.beam.unblanked(delay=0.2), self.ctrl.cam.blocked(): + with self.ctrl.beam.unblanked(delay=0.2): self._collect_scans(run=run, enqueue=True) - run.make_continuous() - run.update(self.steps_queue) + run.update_images_metas(self.steps_queue) self.msg('Collected scans from {} to {} degree'.format(*run.scope)) def determine_rotation_speed_and_exposure(self, run: Run) -> tuple[float, float]: diff --git a/src/instamatic/gui/fast_adt_frame.py b/src/instamatic/gui/fast_adt_frame.py index 8c889288..ae584826 100644 --- a/src/instamatic/gui/fast_adt_frame.py +++ b/src/instamatic/gui/fast_adt_frame.py @@ -55,7 +55,7 @@ def __init__(self): self.diffraction_stop = DoubleVar(value=30) self.diffraction_step = DoubleVar(value=0.5) self.diffraction_time = DoubleVar(value=0.5) - self.tracking_mode = StringVar() + self.tracking_algo = StringVar() self.tracking_time = DoubleVar(value=0.5) self.tracking_step = DoubleVar(value=5.0) @@ -83,11 +83,9 @@ def __init__(self, parent): f = Frame(self) Label(f, text='Diffraction mode:').grid(row=3, column=0, **pad10) - self.diffraction_mode = Combobox(f, textvariable=self.var.diffraction_mode, **width) - self.diffraction_mode['values'] = ['stills', 'continuous'] - self.diffraction_mode['state'] = 'readonly' + m = ['stills', 'continuous'] + self.diffraction_mode = OptionMenu(f, self.var.diffraction_mode, m[0], *m) self.diffraction_mode.grid(row=3, column=1, **pad10) - self.diffraction_mode.current(0) Label(f, text='Diffraction start (deg):').grid(row=4, column=0, **pad10) var = self.var.diffraction_start @@ -109,14 +107,11 @@ def __init__(self, parent): self.diffraction_time = Spinbox(f, textvariable=var, **duration) self.diffraction_time.grid(row=7, column=1, **pad10) - Label(f, text='Tracking mode:').grid(row=3, column=2, **pad10) - var = self.var.tracking_mode - self.tracking_mode = Combobox(f, textvariable=var, **width) - self.tracking_mode['values'] = ['none', 'manual'] - self.tracking_mode['state'] = 'readonly' - self.tracking_mode.grid(row=3, column=3, **pad10) - self.tracking_mode.bind('<>', self.update_widget_state) - self.tracking_mode.current(0) + Label(f, text='Tracking algorithm:').grid(row=3, column=2, **pad10) + var = self.var.tracking_algo + m = ['none', 'manual'] + self.tracking_algo = OptionMenu(f, var, m[0], *m, command=self.update_widget_state) + self.tracking_algo.grid(row=3, column=3, **pad10) Label(f, text='Tracking step (deg):').grid(row=6, column=2, **pad10) var = self.var.tracking_step @@ -195,7 +190,7 @@ def toggle_beam_blank(self) -> None: def update_widget_state(self, *_, busy: Optional[bool] = None, **__) -> None: self.busy = busy if busy is not None else self.busy - no_tracking = self.var.tracking_mode.get() == 'none' + no_tracking = self.var.tracking_algo.get() == 'none' widget_state = 'disabled' if self.busy else 'enabled' tracking_state = 'disabled' if self.busy or no_tracking else 'enabled' @@ -205,7 +200,7 @@ def update_widget_state(self, *_, busy: Optional[bool] = None, **__) -> None: self.diffraction_stop.config(state=widget_state) self.diffraction_step.config(state=widget_state) self.diffraction_time.config(state=widget_state) - self.tracking_mode.config(state=widget_state) + self.tracking_algo.config(state=widget_state) self.tracking_step.config(state=tracking_state) self.tracking_time.config(state=tracking_state) From f056fbfc264621545ebe1e0b4143aa8bf61a18be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 17 Oct 2025 13:55:30 +0200 Subject: [PATCH 36/49] Generalize FastADT run collection (+fix resulting bugs) --- .../experiments/fast_adt/experiment.py | 85 +++++++------------ 1 file changed, 33 insertions(+), 52 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index ac19f126..02b331e0 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -3,7 +3,7 @@ import logging from contextlib import contextmanager from copy import deepcopy -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from queue import Queue from threading import Thread @@ -32,7 +32,7 @@ def get_color(i: int) -> tuple[int, int, int]: def safe_range(*, start: float, stop: float, step: float) -> np.ndarray: """Find 2+ floats between `start` and `stop` (inclusive) ~`step` apart.""" - step_count = max(round(abs(stop - start) / step) + 1, 2) + step_count = max(round(abs(stop - start / step)) + 1, 2) return np.linspace(start, stop, step_count, endpoint=True, dtype=float) @@ -57,6 +57,10 @@ class Step: image: Optional[np.ndarray] = None meta: dict = field(default_factory=dict) + @property + def summary(self) -> str: + return f'Step(Index={self.Index}, alpha={self.alpha})' + class Run: """Collection of details of a generalized single FastADT run. @@ -79,13 +83,11 @@ def __init__(self, exposure=1.0, continuous=False, **columns: Sequence) -> None: self.continuous: bool = continuous self.table: pd.DataFrame = pd.DataFrame.from_dict(columns) - @property - def scope(self) -> tuple[float, float]: - """The range of alpha values scanned during the entire run.""" - a = self.table['alpha'] - if not self.continuous: - return a.iloc[0], a.iloc[-1] - return a.iloc[0] - self.osc_angle / 2, a.iloc[-1] + self.osc_angle / 2 + def __str__(self) -> str: + c = self.__class__.__name__ + a = self.table['alpha'].values + ar = f'range({a[0]}, {a[-1]}, {float(np.mean(np.diff(a)))}))' + return f'{c}(exposure={self.exposure}, continuous={self.continuous}, alpha={ar}))' @property def steps(self) -> Iterator[Step]: @@ -152,8 +154,7 @@ def from_params(cls, params: dict[str, Any]) -> Self: alpha_range = safe_range(start=a0, stop=a1, step=params['tracking_step']) if c := (params['diffraction_mode'] == 'continuous'): step = float(np.mean(np.diff(alpha_range))) - offset = (a1 - a0) / abs(a1 - a0) * step / 2 - alpha_range = safe_range(start=a0 - offset, stop=a1 + offset, step=step) + alpha_range = safe_range(start=a0 - step / 2, stop=a1 + step / 2, step=step) return cls(exposure=params['tracking_time'], continuous=c, alpha=alpha_range) @@ -349,10 +350,8 @@ def start_collection(self, **params) -> None: for run in self.runs.diffraction: if run.has_beam_delta_information: run.calculate_beamshifts(self.ctrl, self.beamshift) - if self.diffraction_mode == 'stills': - self.collect_stills(run) - elif self.diffraction_mode == 'continuous': - self.collect_scans(run) + self.collect_run(run) + run.update_images_metas(self.steps_queue) self.finalize(run) self.ctrl.restore('FastADT_image') @@ -390,13 +389,12 @@ def determine_pathing_manually(self) -> None: self.beam_center = cl.get_click().xy self.ctrl.restore('FastADT_track') - Thread(target=self.collect_tracking, args=(run,), daemon=True).start() - + Thread(target=self.collect_run, args=(run,), daemon=True).start() tracking_images = [] tracking_in_progress = True while tracking_in_progress: while (step := self.steps_queue.get()) is not None: - m = f'Click on the crystal (image={step.Index}, alpha={step.alpha} deg).' + m = f'Click on tracked point: {step.summary}.' self.msg(m) with self.displayed_pathing(step=step), self.click_listener: click = self.click_listener.get_click() @@ -426,58 +424,41 @@ def determine_pathing_manually(self) -> None: self.steps_queue.put(new_step) break - def _collect_stills(self, run: Run, enqueue: bool = True) -> None: + def collect_run(self, run: Run) -> None: + """Collect `run.steps` and place them in `self.steps_queue`.""" + with self.ctrl.beam.unblanked(delay=0.2): + if run.continuous: + self._collect_scans(run=run) + else: + self._collect_stills(run=run) + + def _collect_stills(self, run: Run) -> None: """Collect `run.steps` stills and place them in `self.steps_queue`.""" + self.msg(f'Collecting {run!s}') with self.ctrl.cam.blocked(): for step in run.steps: if run.has_beam_delta_information: self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) self.ctrl.stage.a = step.alpha step.image, step.meta = self.ctrl.get_image(exposure=run.exposure) - if enqueue: - self.steps_queue.put(step) - if enqueue: - self.steps_queue.put(None) - - def collect_stills(self, run: Run) -> None: - """Collect a series of stills at angles/exposure specified in `run`""" - self.msg('Collecting stills from {} to {} degree'.format(*run.scope)) - with self.ctrl.beam.unblanked(delay=0.2): - self._collect_stills(run=run, enqueue=True) - run.update_images_metas(self.steps_queue) - self.msg('Collected stills from {} to {} degree'.format(*run.scope)) + self.steps_queue.put(step) + self.steps_queue.put(None) - def collect_tracking(self, run: Run) -> None: - """Get & put stills to `self.tracking_queue` to eval asynchronously.""" - collector = self._collect_scans if run.continuous else self._collect_stills - with self.ctrl.beam.unblanked(delay=0.2): - collector(run=run, enqueue=True) - - def _collect_scans(self, run: Run, enqueue: bool = True) -> None: + def _collect_scans(self, run: Run) -> None: + """Collect `run.steps` scans and place them in `self.steps_queue`.""" rot_speed, run.exposure = self.determine_rotation_speed_and_exposure(run) with self.ctrl.stage.rotation_speed(speed=rot_speed), self.ctrl.cam.blocked(): self.ctrl.stage.a = float(run.table.at[0, 'alpha']) movie = self.ctrl.get_movie(n_frames=len(run.table) - 1, exposure=run.exposure) a = float(run.table.iloc[-1].loc['alpha']) run.collapse_to_alpha_midpoints() + self.msg(f'Collecting {run!s}') self.ctrl.stage.set_with_speed(a=a, speed=rot_speed, wait=False) for step, (image, meta) in zip(run.steps, movie): if run.has_beam_delta_information: self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) - if enqueue: - step.image = image - step.meta = meta - self.steps_queue.put(step) - if enqueue: - self.steps_queue.put(None) - - def collect_scans(self, run: Run) -> None: - """Collect a series of scans at angles/exposure specified in `run`""" - self.msg('Collecting scans from {} to {} degree'.format(*run.scope)) - with self.ctrl.beam.unblanked(delay=0.2): - self._collect_scans(run=run, enqueue=True) - run.update_images_metas(self.steps_queue) - self.msg('Collected scans from {} to {} degree'.format(*run.scope)) + self.steps_queue.put(replace(step, image=image, meta=meta)) + self.steps_queue.put(None) def determine_rotation_speed_and_exposure(self, run: Run) -> tuple[float, float]: """Closest possible speed setting & exposure considering dead time.""" From 61ff039035f90e623b612d7ea4537153755b6bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 17 Oct 2025 15:37:57 +0200 Subject: [PATCH 37/49] Revert change: use stills for continuous tracking --- .../experiments/fast_adt/experiment.py | 43 ++++++++----------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index 02b331e0..942157ea 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -32,7 +32,7 @@ def get_color(i: int) -> tuple[int, int, int]: def safe_range(*, start: float, stop: float, step: float) -> np.ndarray: """Find 2+ floats between `start` and `stop` (inclusive) ~`step` apart.""" - step_count = max(round(abs(stop - start / step)) + 1, 2) + step_count = max(round(abs((stop - start) / step)) + 1, 2) return np.linspace(start, stop, step_count, endpoint=True, dtype=float) @@ -113,7 +113,7 @@ def has_beam_delta_information(self) -> bool: @property def osc_angle(self) -> float: """Difference of alpha angle between two consecutive frames.""" - a = list(self.table['alpha']) + a = self.table['alpha'].values return (a[-1] - a[0]) / (len(a) - 1) if len(a) > 1 else -1 def collapse_to_alpha_midpoints(self) -> None: @@ -152,10 +152,7 @@ def from_params(cls, params: dict[str, Any]) -> Self: a0 = params['diffraction_start'] a1 = params['diffraction_stop'] alpha_range = safe_range(start=a0, stop=a1, step=params['tracking_step']) - if c := (params['diffraction_mode'] == 'continuous'): - step = float(np.mean(np.diff(alpha_range))) - alpha_range = safe_range(start=a0 - step / 2, stop=a1 + step / 2, step=step) - return cls(exposure=params['tracking_time'], continuous=c, alpha=alpha_range) + return cls(exposure=params['tracking_time'], alpha=alpha_range) class DiffractionRun(Run): @@ -167,12 +164,11 @@ def from_params( params: dict[str, Any], pathing_run: Optional['TrackingRun'] = None, ) -> Self: - alpha_range = safe_range( - start=params['diffraction_start'], - stop=params['diffraction_stop'], - step=params['diffraction_step'], - ) - run = cls(exposure=params['diffraction_time'], alpha=alpha_range) + a0 = params['diffraction_start'] + a1 = params['diffraction_stop'] + alpha_range = safe_range(start=a0, stop=a1, step=params['diffraction_step']) + c = params['diffraction_mode'] == 'continuous' + run = cls(exposure=params['diffraction_time'], continuous=c, alpha=alpha_range) if pathing_run is not None: run.table['delta_x'] = pathing_run.interpolate(alpha_range, 'delta_x') run.table['delta_y'] = pathing_run.interpolate(alpha_range, 'delta_y') @@ -181,9 +177,8 @@ def from_params( @dataclass class Runs: - """Collection of runs: beam alignment, xtal tracking, beam pathing, diff""" + """Collection of runs for xtal tracking, beam pathing, diff collection.""" - alignment: Optional[Run] = None tracking: Optional[Run] = None pathing: list[TrackingRun] = field(default_factory=list) diffraction: list[DiffractionRun] = field(default_factory=list) @@ -325,7 +320,6 @@ def start_collection(self, **params) -> None: Finally, the collected run will be logged and the stage - reset. """ self.msg('FastADT experiment started') - image_path = self.path / 'image.tiff' if not image_path.exists(): self.ctrl.restore('FastADT_image') @@ -337,7 +331,6 @@ def start_collection(self, **params) -> None: if params['tracking_algo'] == 'manual': self.runs.tracking = TrackingRun.from_params(params) self.determine_pathing_manually() - for pathing_run in self.runs.pathing: new_run = DiffractionRun.from_params(params, pathing_run) self.runs.diffraction.append(new_run) @@ -346,7 +339,6 @@ def start_collection(self, **params) -> None: self.ctrl.restore('FastADT_diff') self.camera_length = int(self.ctrl.magnification.get()) - for run in self.runs.diffraction: if run.has_beam_delta_information: run.calculate_beamshifts(self.ctrl, self.beamshift) @@ -435,24 +427,23 @@ def collect_run(self, run: Run) -> None: def _collect_stills(self, run: Run) -> None: """Collect `run.steps` stills and place them in `self.steps_queue`.""" self.msg(f'Collecting {run!s}') - with self.ctrl.cam.blocked(): - for step in run.steps: - if run.has_beam_delta_information: - self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) - self.ctrl.stage.a = step.alpha - step.image, step.meta = self.ctrl.get_image(exposure=run.exposure) - self.steps_queue.put(step) + for step in run.steps: + if run.has_beam_delta_information: + self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) + self.ctrl.stage.a = step.alpha + step.image, step.meta = self.ctrl.get_image(exposure=run.exposure) + self.steps_queue.put(step) self.steps_queue.put(None) def _collect_scans(self, run: Run) -> None: """Collect `run.steps` scans and place them in `self.steps_queue`.""" rot_speed, run.exposure = self.determine_rotation_speed_and_exposure(run) - with self.ctrl.stage.rotation_speed(speed=rot_speed), self.ctrl.cam.blocked(): + with self.ctrl.stage.rotation_speed(speed=rot_speed): self.ctrl.stage.a = float(run.table.at[0, 'alpha']) movie = self.ctrl.get_movie(n_frames=len(run.table) - 1, exposure=run.exposure) a = float(run.table.iloc[-1].loc['alpha']) - run.collapse_to_alpha_midpoints() self.msg(f'Collecting {run!s}') + run.collapse_to_alpha_midpoints() self.ctrl.stage.set_with_speed(a=a, speed=rot_speed, wait=False) for step, (image, meta) in zip(run.steps, movie): if run.has_beam_delta_information: From 9fe149f2f35866057e4205b5d2f7e54be7b152fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 17 Oct 2025 15:51:55 +0200 Subject: [PATCH 38/49] Minor fixes and code quality improvements --- .../experiments/fast_adt/experiment.py | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index 942157ea..3ed541b0 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -7,7 +7,7 @@ from pathlib import Path from queue import Queue from threading import Thread -from typing import Any, Iterator, Optional, Sequence, Union +from typing import Any, Iterator, Optional, Sequence, Union, cast import numpy as np import pandas as pd @@ -30,7 +30,7 @@ def get_color(i: int) -> tuple[int, int, int]: return tuple([int(rgb * 255) for rgb in plt.get_cmap('tab10')(i % 10)][:3]) # type: ignore -def safe_range(*, start: float, stop: float, step: float) -> np.ndarray: +def safe_range(start: float, stop: float, step: float) -> np.ndarray: """Find 2+ floats between `start` and `stop` (inclusive) ~`step` apart.""" step_count = max(round(abs((stop - start) / step)) + 1, 2) return np.linspace(start, stop, step_count, endpoint=True, dtype=float) @@ -148,31 +148,25 @@ class TrackingRun(Run): """Designed to estimate delta_x/y a priori based on manual used input.""" @classmethod - def from_params(cls, params: dict[str, Any]) -> Self: - a0 = params['diffraction_start'] - a1 = params['diffraction_stop'] - alpha_range = safe_range(start=a0, stop=a1, step=params['tracking_step']) - return cls(exposure=params['tracking_time'], alpha=alpha_range) + def from_params(cls, p: dict[str, Any]) -> Self: + a = safe_range(p['diffraction_start'], p['diffraction_stop'], p['tracking_step']) + return cls(exposure=p['tracking_time'], alpha=a) class DiffractionRun(Run): """The implementation for the actual diffraction experiment itself.""" @classmethod - def from_params( - cls, - params: dict[str, Any], - pathing_run: Optional['TrackingRun'] = None, - ) -> Self: - a0 = params['diffraction_start'] - a1 = params['diffraction_stop'] - alpha_range = safe_range(start=a0, stop=a1, step=params['diffraction_step']) - c = params['diffraction_mode'] == 'continuous' - run = cls(exposure=params['diffraction_time'], continuous=c, alpha=alpha_range) - if pathing_run is not None: - run.table['delta_x'] = pathing_run.interpolate(alpha_range, 'delta_x') - run.table['delta_y'] = pathing_run.interpolate(alpha_range, 'delta_y') - return run + def from_params(cls, p: dict[str, Any]) -> Self: + c = p['diffraction_mode'] == 'continuous' + a = safe_range(p['diffraction_start'], p['diffraction_stop'], p['diffraction_step']) + return cls(exposure=p['diffraction_time'], continuous=c, alpha=a) + + def add_pathing(self, pathing_run: TrackingRun) -> None: + """Add and interpolate delta x/y info from another run instance.""" + a = self.table['alpha'].values + self.table['delta_x'] = pathing_run.interpolate(a, 'delta_x') + self.table['delta_y'] = pathing_run.interpolate(a, 'delta_y') @dataclass @@ -293,8 +287,8 @@ def msg(self, text: str) -> None: self.fast_adt_frame.message.set(text) except AttributeError: pass - print(text) if text: + print(text) self.log.info(text) def start_collection(self, **params) -> None: @@ -332,7 +326,8 @@ def start_collection(self, **params) -> None: self.runs.tracking = TrackingRun.from_params(params) self.determine_pathing_manually() for pathing_run in self.runs.pathing: - new_run = DiffractionRun.from_params(params, pathing_run) + new_run = DiffractionRun.from_params(params) + new_run.add_pathing(pathing_run) self.runs.diffraction.append(new_run) if not self.runs.pathing: self.runs.diffraction = [DiffractionRun.from_params(params)] @@ -371,7 +366,7 @@ def determine_pathing_manually(self) -> None: based on the beam center found life (to find clicking offset) and `TrackingRun` to be used for crystal tracking in later experiment.""" - run: TrackingRun = self.runs.tracking + run: TrackingRun = cast(TrackingRun, self.runs.tracking) self.restore_fast_adt_diff_for_image() self.ctrl.stage.a = run.table.loc[len(run.table) // 2, 'alpha'] with self.ctrl.beam.unblanked(), self.ctrl.cam.unblocked(): From e4e5dff64d5869ec72a66324ec38edc56dc6541b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 17 Oct 2025 15:59:31 +0200 Subject: [PATCH 39/49] Clean, remove unused code --- src/instamatic/experiments/fast_adt/experiment.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index 3ed541b0..c5521a9f 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -216,7 +216,6 @@ def __init__( self.fast_adt_frame = experiment_frame self.beamshift: Optional[CalibBeamShift] = None self.camera_length: int = 0 - self.diffraction_mode: str = '' if videostream_frame is not None: d = videostream_frame.click_dispatcher @@ -320,7 +319,6 @@ def start_collection(self, **params) -> None: with self.ctrl.beam.unblanked(delay=0.2): self.ctrl.get_image(params['tracking_time'], out=image_path) - self.diffraction_mode = params['diffraction_mode'] with self.ctrl.beam.blanked(), self.ctrl.cam.blocked(): if params['tracking_algo'] == 'manual': self.runs.tracking = TrackingRun.from_params(params) @@ -401,8 +399,7 @@ def determine_pathing_manually(self) -> None: if click is None: continue if click.button == MouseButton.RIGHT: - msg = 'Experiment abandoned after tracking.' - self.msg(msg) + self.msg(msg := 'Experiment abandoned after tracking.') raise FastADTEarlyTermination(msg) if click.button == MouseButton.LEFT: tracking_in_progress = False @@ -476,11 +473,7 @@ def finalize(self, run: DiffractionRun) -> None: wavelength = config.microscope.wavelength # angstrom stretch_azimuth = config.camera.stretch_azimuth stretch_amplitude = config.camera.stretch_amplitude - - if self.diffraction_mode == 'continuous': - method = 'Continuous-Rotation 3D ED' - else: - method = 'Rotation Electron Diffraction' + m = 'Continuous-Rotation 3D ED' if run.continuous else 'Rotation Electron Diffraction' img_conv = ImgConversion( buffer=run.buffer, @@ -495,7 +488,7 @@ def finalize(self, run: DiffractionRun) -> None: wavelength=wavelength, stretch_amplitude=stretch_amplitude, stretch_azimuth=stretch_azimuth, - method=method, + method=m, ) img_conv.threadpoolwriter(tiff_path=tiff_path, mrc_path=mrc_path, workers=8) From 869adbafffbe093ed5c9c427ad4d6de4331600e5 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Fri, 17 Oct 2025 17:57:44 +0200 Subject: [PATCH 40/49] Fix tracking failing for beam not in the center at alignment --- .../experiments/fast_adt/experiment.py | 63 +++++++++---------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index c5521a9f..d91c94fe 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -4,6 +4,7 @@ from contextlib import contextmanager from copy import deepcopy from dataclasses import dataclass, field, replace +from math import ceil from pathlib import Path from queue import Queue from threading import Thread @@ -32,7 +33,7 @@ def get_color(i: int) -> tuple[int, int, int]: def safe_range(start: float, stop: float, step: float) -> np.ndarray: """Find 2+ floats between `start` and `stop` (inclusive) ~`step` apart.""" - step_count = max(round(abs((stop - start) / step)) + 1, 2) + step_count = max(ceil(abs((stop - start) / step)) + 1, 2) return np.linspace(start, stop, step_count, endpoint=True, dtype=float) @@ -50,10 +51,10 @@ class Step: Index: int alpha: float + beampixel_x: Optional[float] = None + beampixel_y: Optional[float] = None beamshift_x: Optional[float] = None beamshift_y: Optional[float] = None - delta_x: Optional[float] = None - delta_y: Optional[float] = None image: Optional[np.ndarray] = None meta: dict = field(default_factory=dict) @@ -74,8 +75,8 @@ class Run: table: pd.DataFrame Describes details of individual steps (to be) measured: - alpha - average value of the rotation axes for given frame - - delta_x - x beam shift relative from center needed to track the crystal - - delta_y - y beam shift relative from center needed to track the crystal + - beampixel_x/y - beam x/y position in pixel used for tracking + - beamshift_x/y - beam deflector x/y value used for tracking """ def __init__(self, exposure=1.0, continuous=False, **columns: Sequence) -> None: @@ -86,8 +87,8 @@ def __init__(self, exposure=1.0, continuous=False, **columns: Sequence) -> None: def __str__(self) -> str: c = self.__class__.__name__ a = self.table['alpha'].values - ar = f'range({a[0]}, {a[-1]}, {float(np.mean(np.diff(a)))}))' - return f'{c}(exposure={self.exposure}, continuous={self.continuous}, alpha={ar}))' + ar = f'range({a[0]}, {a[-1]}, {float(np.mean(np.diff(a)))})' + return f'{c}(exposure={self.exposure}, continuous={self.continuous}, alpha={ar})' @property def steps(self) -> Iterator[Step]: @@ -107,8 +108,8 @@ def buffer(self) -> list[tuple[int, np.ndarray, dict]]: return [(i, s.image, s.meta) for i, s in enumerate(self.steps)] @property - def has_beam_delta_information(self) -> bool: - return {'delta_x', 'delta_y'}.issubset(self.table.columns) + def has_beamshifts(self) -> bool: + return {'beamshift_x', 'beamshift_y'}.issubset(self.table.columns) @property def osc_angle(self) -> float: @@ -122,16 +123,6 @@ def collapse_to_alpha_midpoints(self) -> None: self.table = self.table.iloc[1:] self.table['alpha'] = alpha_midpoints - def calculate_beamshifts(self, ctrl, beamshift) -> None: - """Note CalibBeamShift uses swapped axes: X points down, Y right.""" - beamshift_xy = ctrl.beamshift.get() - pixelcoord_xy = beamshift.beamshift_to_pixelcoord(beamshift_xy) - delta_xys = self.table[['delta_x', 'delta_y']].to_numpy() - crystal_xys = pixelcoord_xy + delta_xys - crystal_yxs = np.fliplr(crystal_xys) - beamshifts = beamshift.pixelcoord_to_beamshift(crystal_yxs) - self.table[['beamshift_x', 'beamshift_y']] = beamshifts - def update_images_metas(self, steps_queue: Queue[Union[Step, None]]) -> None: """Consume Steps from queue until None, update self.images & .meta.""" step_list: list[Step] = [] @@ -162,11 +153,11 @@ def from_params(cls, p: dict[str, Any]) -> Self: a = safe_range(p['diffraction_start'], p['diffraction_stop'], p['diffraction_step']) return cls(exposure=p['diffraction_time'], continuous=c, alpha=a) - def add_pathing(self, pathing_run: TrackingRun) -> None: + def add_beamshifts(self, pathing_run: TrackingRun) -> None: """Add and interpolate delta x/y info from another run instance.""" a = self.table['alpha'].values - self.table['delta_x'] = pathing_run.interpolate(a, 'delta_x') - self.table['delta_y'] = pathing_run.interpolate(a, 'delta_y') + self.table['beamshift_x'] = pathing_run.interpolate(a, 'beamshift_x') + self.table['beamshift_y'] = pathing_run.interpolate(a, 'beamshift_y') @dataclass @@ -226,7 +217,6 @@ def __init__( self.click_listener = None self.videostream_processor = None - self.beam_center: tuple[float, float] = (float('nan'), float('nan')) self.steps_queue: Queue[Union[Step, None]] = Queue() self.runs: Runs = Runs() @@ -325,7 +315,7 @@ def start_collection(self, **params) -> None: self.determine_pathing_manually() for pathing_run in self.runs.pathing: new_run = DiffractionRun.from_params(params) - new_run.add_pathing(pathing_run) + new_run.add_beamshifts(pathing_run) self.runs.diffraction.append(new_run) if not self.runs.pathing: self.runs.diffraction = [DiffractionRun.from_params(params)] @@ -333,8 +323,8 @@ def start_collection(self, **params) -> None: self.ctrl.restore('FastADT_diff') self.camera_length = int(self.ctrl.magnification.get()) for run in self.runs.diffraction: - if run.has_beam_delta_information: - run.calculate_beamshifts(self.ctrl, self.beamshift) + #if run.has_beam_delta_information: + # run.calculate_beamshifts(self.ctrl, self.beamshift) self.collect_run(run) run.update_images_metas(self.steps_queue) self.finalize(run) @@ -348,8 +338,8 @@ def displayed_pathing(self, step: Step) -> None: draw = self.videostream_processor.draw instructions: list[draw.Instruction] = [] for run_i, p in enumerate(self.runs.pathing): - x = self.beam_center[0] + p.table.at[step.Index, 'delta_x'] - y = self.beam_center[1] + p.table.at[step.Index, 'delta_y'] + x = p.table.at[step.Index, 'beampixel_x'] + y = p.table.at[step.Index, 'beampixel_y'] instructions.append(draw.circle((x, y), fill='white', radius=5)) instructions.append(draw.circle((x, y), fill=get_color(run_i), radius=3)) try: @@ -371,7 +361,8 @@ def determine_pathing_manually(self) -> None: self.beamshift = self.get_beamshift() self.msg('Collecting tracking. Click on the center of the beam.') with self.click_listener as cl: - self.beam_center = cl.get_click().xy + obs_beam_pixel_xy = np.array(cl.get_click().xy) + cal_beam_pixel_yx = self.beamshift.beamshift_to_pixelcoord(self.ctrl.beamshift.get()) self.ctrl.restore('FastADT_track') Thread(target=self.collect_run, args=(run,), daemon=True).start() @@ -383,8 +374,13 @@ def determine_pathing_manually(self) -> None: self.msg(m) with self.displayed_pathing(step=step), self.click_listener: click = self.click_listener.get_click() - run.table.loc[step.Index, 'delta_x'] = click.x - self.beam_center[0] - run.table.loc[step.Index, 'delta_y'] = click.y - self.beam_center[1] + run.table.loc[step.Index, 'beampixel_x'] = click.x + run.table.loc[step.Index, 'beampixel_y'] = click.y + delta_yx = (np.array(click.xy) - obs_beam_pixel_xy)[::-1] + target_beam_pos_yx = cal_beam_pixel_yx + delta_yx + target_beam_shift = self.beamshift.pixelcoord_to_beamshift(target_beam_pos_yx) + run.table.loc[step.Index, 'beamshift_x'] = target_beam_shift[0] + run.table.loc[step.Index, 'beamshift_y'] = target_beam_shift[1] tracking_images.append(step.image) self.msg('') if 'image' not in run.table: @@ -410,6 +406,7 @@ def determine_pathing_manually(self) -> None: def collect_run(self, run: Run) -> None: """Collect `run.steps` and place them in `self.steps_queue`.""" + print(run.table) with self.ctrl.beam.unblanked(delay=0.2): if run.continuous: self._collect_scans(run=run) @@ -420,7 +417,7 @@ def _collect_stills(self, run: Run) -> None: """Collect `run.steps` stills and place them in `self.steps_queue`.""" self.msg(f'Collecting {run!s}') for step in run.steps: - if run.has_beam_delta_information: + if run.has_beamshifts: self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) self.ctrl.stage.a = step.alpha step.image, step.meta = self.ctrl.get_image(exposure=run.exposure) @@ -438,7 +435,7 @@ def _collect_scans(self, run: Run) -> None: run.collapse_to_alpha_midpoints() self.ctrl.stage.set_with_speed(a=a, speed=rot_speed, wait=False) for step, (image, meta) in zip(run.steps, movie): - if run.has_beam_delta_information: + if run.has_beamshifts: self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) self.steps_queue.put(replace(step, image=image, meta=meta)) self.steps_queue.put(None) From b8f86444d4fad6ac524467027f4f2b4718a6ca0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 17 Oct 2025 19:14:13 +0200 Subject: [PATCH 41/49] Fix Run.__str__, clean up code, method, call order --- .../calibrate/calibrate_beamshift.py | 4 +- .../experiments/fast_adt/experiment.py | 122 +++++++++--------- 2 files changed, 62 insertions(+), 64 deletions(-) diff --git a/src/instamatic/calibrate/calibrate_beamshift.py b/src/instamatic/calibrate/calibrate_beamshift.py index 06bcdf11..de2f9066 100644 --- a/src/instamatic/calibrate/calibrate_beamshift.py +++ b/src/instamatic/calibrate/calibrate_beamshift.py @@ -57,12 +57,12 @@ class CalibBeamShift: def __repr__(self): return f'CalibBeamShift(transform=\n{self.transform},\n reference_shift=\n{self.reference_shift},\n reference_pixel=\n{self.reference_pixel})' - def beamshift_to_pixelcoord(self, beamshift: Sequence[float, float]) -> Vector2: + def beamshift_to_pixelcoord(self, beamshift: Sequence[float]) -> Vector2: """Converts from beamshift x,y to pixel coordinates.""" r_i = np.linalg.inv(self.transform) return np.dot(self.reference_shift - np.array(beamshift), r_i) + self.reference_pixel - def pixelcoord_to_beamshift(self, pixelcoord: Sequence[float, float]) -> Vector2: + def pixelcoord_to_beamshift(self, pixelcoord: Sequence[float]) -> Vector2: """Converts from pixel coordinates to beamshift x,y.""" pc = np.array(pixelcoord) return self.reference_shift - np.dot(pc - self.reference_pixel, self.transform) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index d91c94fe..7d8bfc43 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from collections import deque from contextlib import contextmanager from copy import deepcopy from dataclasses import dataclass, field, replace @@ -77,6 +78,7 @@ class Run: - alpha - average value of the rotation axes for given frame - beampixel_x/y - beam x/y position in pixel used for tracking - beamshift_x/y - beam deflector x/y value used for tracking + - image, meta - tracking or diffraction image and its header data """ def __init__(self, exposure=1.0, continuous=False, **columns: Sequence) -> None: @@ -87,15 +89,18 @@ def __init__(self, exposure=1.0, continuous=False, **columns: Sequence) -> None: def __str__(self) -> str: c = self.__class__.__name__ a = self.table['alpha'].values - ar = f'range({a[0]}, {a[-1]}, {float(np.mean(np.diff(a)))})' - return f'{c}(exposure={self.exposure}, continuous={self.continuous}, alpha={ar})' + ar = f'range({a[0]:.3g}, {a[-1]:.3g}, {float(np.mean(np.diff(a))):.3g})' + return f'{c}(exposure={self.exposure:.3g}, continuous={self.continuous}, alpha={ar})' + + def __len__(self) -> int: + return len(self.table) @property def steps(self) -> Iterator[Step]: """Iterate over individual run `Step`s holding rows of `self.table`.""" return (Step(**t._asdict()) for t in self.table.itertuples()) # noqa - def interpolate(self, at: np.array, key: str) -> np.ndarray: + def interpolate(self, key: str, at: np.array) -> np.ndarray: """Interpolate values of `table[key]` at some denser grid of points.""" alpha, values = self.table['alpha'], self.table[key] if at[0] > at[-1]: # decreasing order is not handled by numpy.interp @@ -115,7 +120,7 @@ def has_beamshifts(self) -> bool: def osc_angle(self) -> float: """Difference of alpha angle between two consecutive frames.""" a = self.table['alpha'].values - return (a[-1] - a[0]) / (len(a) - 1) if len(a) > 1 else -1 + return (a[-1] - a[0]) / (len(a) - 1) if len(a) > 1 else 0 def collapse_to_alpha_midpoints(self) -> None: """Set current alpha midpoints as new alpha, dropping the first row.""" @@ -123,11 +128,11 @@ def collapse_to_alpha_midpoints(self) -> None: self.table = self.table.iloc[1:] self.table['alpha'] = alpha_midpoints - def update_images_metas(self, steps_queue: Queue[Union[Step, None]]) -> None: + def update_images_metas(self, steps: Queue[Union[Step, None]]) -> None: """Consume Steps from queue until None, update self.images & .meta.""" step_list: list[Step] = [] while True: - step = steps_queue.get() + step = steps.get() if step is None: break step_list.append(step) @@ -136,12 +141,12 @@ def update_images_metas(self, steps_queue: Queue[Union[Step, None]]) -> None: class TrackingRun(Run): - """Designed to estimate delta_x/y a priori based on manual used input.""" + """Designed to estimate beampixel_x/y a priori based on manual input.""" @classmethod def from_params(cls, p: dict[str, Any]) -> Self: a = safe_range(p['diffraction_start'], p['diffraction_stop'], p['tracking_step']) - return cls(exposure=p['tracking_time'], alpha=a) + return cls(exposure=p['tracking_time'], continuous=False, alpha=a) class DiffractionRun(Run): @@ -156,8 +161,8 @@ def from_params(cls, p: dict[str, Any]) -> Self: def add_beamshifts(self, pathing_run: TrackingRun) -> None: """Add and interpolate delta x/y info from another run instance.""" a = self.table['alpha'].values - self.table['beamshift_x'] = pathing_run.interpolate(a, 'beamshift_x') - self.table['beamshift_y'] = pathing_run.interpolate(a, 'beamshift_y') + self.table['beamshift_x'] = pathing_run.interpolate('beamshift_x', at=a) + self.table['beamshift_y'] = pathing_run.interpolate('beamshift_y', at=a) @dataclass @@ -185,7 +190,7 @@ class Experiment(ExperimentBase): experiment_frame: Optional instance of `ExperimentalFastADT` used to display messages videostream_frame: - Optional instance of `VideoStreamFrame` used to display messages + Optional instance of `VideoStreamFrame` to display tracking and images """ name = 'FastADT' @@ -217,7 +222,7 @@ def __init__( self.click_listener = None self.videostream_processor = None - self.steps_queue: Queue[Union[Step, None]] = Queue() + self.steps: Queue[Union[Step, None]] = Queue() self.runs: Runs = Runs() def restore_fast_adt_diff_for_image(self): @@ -235,9 +240,8 @@ def get_beamshift(self) -> CalibBeamShift: try: return CalibBeamShift.from_file(calib_dir / CALIB_BEAMSHIFT) except OSError: - return CalibBeamShift.live( - self.ctrl, outdir=calib_dir, vsp=self.videostream_processor - ) + vsp = self.videostream_processor + return CalibBeamShift.live(self.ctrl, outdir=calib_dir, vsp=vsp) def get_dead_time( self, @@ -270,6 +274,15 @@ def get_stage_rotation(self) -> CalibStageRotation: self.msg(msg) raise FastADTMissingCalibError(msg) + def determine_rotation_speed_and_exposure(self, run: Run) -> tuple[float, float]: + """Closest possible speed setting & exposure considering dead time.""" + detector_dead_time = self.get_dead_time(run.exposure) + time_for_one_frame = run.exposure + detector_dead_time + rot_calib = self.get_stage_rotation() + rot_plan = rot_calib.plan_rotation(time_for_one_frame / run.osc_angle) + exposure = abs(rot_plan.pace * run.osc_angle) - detector_dead_time + return rot_plan.speed, exposure + def msg(self, text: str) -> None: """Display a message in log.info, consoles & FastADT frame at once.""" try: @@ -323,10 +336,8 @@ def start_collection(self, **params) -> None: self.ctrl.restore('FastADT_diff') self.camera_length = int(self.ctrl.magnification.get()) for run in self.runs.diffraction: - #if run.has_beam_delta_information: - # run.calculate_beamshifts(self.ctrl, self.beamshift) self.collect_run(run) - run.update_images_metas(self.steps_queue) + run.update_images_metas(self.steps) self.finalize(run) self.ctrl.restore('FastADT_image') @@ -353,34 +364,31 @@ def determine_pathing_manually(self) -> None: """Determine the target beam shifts `delta_x` and `delta_y` manually, based on the beam center found life (to find clicking offset) and `TrackingRun` to be used for crystal tracking in later experiment.""" - run: TrackingRun = cast(TrackingRun, self.runs.tracking) self.restore_fast_adt_diff_for_image() - self.ctrl.stage.a = run.table.loc[len(run.table) // 2, 'alpha'] + self.ctrl.stage.a = run.table.loc[len(run) // 2, 'alpha'] with self.ctrl.beam.unblanked(), self.ctrl.cam.unblocked(): self.beamshift = self.get_beamshift() self.msg('Collecting tracking. Click on the center of the beam.') with self.click_listener as cl: - obs_beam_pixel_xy = np.array(cl.get_click().xy) - cal_beam_pixel_yx = self.beamshift.beamshift_to_pixelcoord(self.ctrl.beamshift.get()) + obs_beampixel_xy = np.array(cl.get_click().xy) + cal_beampixel_yx = self.beamshift.beamshift_to_pixelcoord(self.ctrl.beamshift.get()) self.ctrl.restore('FastADT_track') Thread(target=self.collect_run, args=(run,), daemon=True).start() - tracking_images = [] + tracking_images = deque(maxlen=len(run)) tracking_in_progress = True while tracking_in_progress: - while (step := self.steps_queue.get()) is not None: + while (step := self.steps.get()) is not None: m = f'Click on tracked point: {step.summary}.' self.msg(m) with self.displayed_pathing(step=step), self.click_listener: click = self.click_listener.get_click() - run.table.loc[step.Index, 'beampixel_x'] = click.x - run.table.loc[step.Index, 'beampixel_y'] = click.y - delta_yx = (np.array(click.xy) - obs_beam_pixel_xy)[::-1] - target_beam_pos_yx = cal_beam_pixel_yx + delta_yx - target_beam_shift = self.beamshift.pixelcoord_to_beamshift(target_beam_pos_yx) - run.table.loc[step.Index, 'beamshift_x'] = target_beam_shift[0] - run.table.loc[step.Index, 'beamshift_y'] = target_beam_shift[1] + delta_yx = (np.array(click.xy) - obs_beampixel_xy)[::-1] + click_beampixel_yx = cast(Sequence[float], cal_beampixel_yx + delta_yx) + click_beamshift_xy = self.beamshift.pixelcoord_to_beamshift(click_beampixel_yx) + cols = ['beampixel_x', 'beampixel_y', 'beamshift_x', 'beamshift_y'] + run.table.loc[step.Index, cols] = *click.xy, *click_beamshift_xy tracking_images.append(step.image) self.msg('') if 'image' not in run.table: @@ -401,53 +409,43 @@ def determine_pathing_manually(self) -> None: tracking_in_progress = False else: # any other mouse button was clicked for new_step in [*self.runs.tracking.steps, None]: - self.steps_queue.put(new_step) + self.steps.put(new_step) break def collect_run(self, run: Run) -> None: - """Collect `run.steps` and place them in `self.steps_queue`.""" - print(run.table) + """Collect `run.steps` and place them in `self.steps` Queue.""" with self.ctrl.beam.unblanked(delay=0.2): if run.continuous: self._collect_scans(run=run) else: self._collect_stills(run=run) - def _collect_stills(self, run: Run) -> None: - """Collect `run.steps` stills and place them in `self.steps_queue`.""" - self.msg(f'Collecting {run!s}') - for step in run.steps: - if run.has_beamshifts: - self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) - self.ctrl.stage.a = step.alpha - step.image, step.meta = self.ctrl.get_image(exposure=run.exposure) - self.steps_queue.put(step) - self.steps_queue.put(None) - def _collect_scans(self, run: Run) -> None: - """Collect `run.steps` scans and place them in `self.steps_queue`.""" + """Collect `run.steps` scans and place them in `self.steps` Queue.""" rot_speed, run.exposure = self.determine_rotation_speed_and_exposure(run) + self.msg(f'Collecting {run!s}') + self.ctrl.stage.a = float(run.table.at[0, 'alpha']) + movie = self.ctrl.get_movie(n_frames=len(run) - 1, exposure=run.exposure) + target_alpha = float(run.table.iloc[-1].loc['alpha']) + run.collapse_to_alpha_midpoints() with self.ctrl.stage.rotation_speed(speed=rot_speed): - self.ctrl.stage.a = float(run.table.at[0, 'alpha']) - movie = self.ctrl.get_movie(n_frames=len(run.table) - 1, exposure=run.exposure) - a = float(run.table.iloc[-1].loc['alpha']) - self.msg(f'Collecting {run!s}') - run.collapse_to_alpha_midpoints() - self.ctrl.stage.set_with_speed(a=a, speed=rot_speed, wait=False) + self.ctrl.stage.set(a=target_alpha, wait=False) for step, (image, meta) in zip(run.steps, movie): if run.has_beamshifts: self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) - self.steps_queue.put(replace(step, image=image, meta=meta)) - self.steps_queue.put(None) + self.steps.put(replace(step, image=image, meta=meta)) + self.steps.put(None) - def determine_rotation_speed_and_exposure(self, run: Run) -> tuple[float, float]: - """Closest possible speed setting & exposure considering dead time.""" - detector_dead_time = self.get_dead_time(run.exposure) - time_for_one_frame = run.exposure + detector_dead_time - rot_calib = self.get_stage_rotation() - rot_plan = rot_calib.plan_rotation(time_for_one_frame / run.osc_angle) - exposure = abs(rot_plan.pace * run.osc_angle) - detector_dead_time - return rot_plan.speed, exposure + def _collect_stills(self, run: Run) -> None: + """Collect `run.steps` stills and place them in `self.steps` Queue.""" + self.msg(f'Collecting {run!s}') + for step in run.steps: + if run.has_beamshifts: + self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) + self.ctrl.stage.a = step.alpha + image, meta = self.ctrl.get_image(exposure=run.exposure) + self.steps.put(replace(step, image=image, meta=meta)) + self.steps.put(None) def get_run_output_path(self, run: DiffractionRun) -> Path: """Return self.path if only 1 run done, self.path/sub## if multiple.""" From 15ccdfec8e5a6ea01bae128da234c441aaa0e141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 17 Oct 2025 20:27:14 +0200 Subject: [PATCH 42/49] Log all behavior in two separate message windows. --- .../experiments/fast_adt/experiment.py | 56 ++++++++++++------- src/instamatic/gui/fast_adt_frame.py | 12 ++-- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index 7d8bfc43..0f982a60 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -9,6 +9,7 @@ from pathlib import Path from queue import Queue from threading import Thread +from tkinter import StringVar from typing import Any, Iterator, Optional, Sequence, Union, cast import numpy as np @@ -240,6 +241,7 @@ def get_beamshift(self) -> CalibBeamShift: try: return CalibBeamShift.from_file(calib_dir / CALIB_BEAMSHIFT) except OSError: + self.msg1('Focus and center the beam, and check terminal for instructions.') vsp = self.videostream_processor return CalibBeamShift.live(self.ctrl, outdir=calib_dir, vsp=vsp) @@ -254,25 +256,24 @@ def get_dead_time( return self.ctrl.cam.dead_time except AttributeError: pass - self.msg('`cam.dead_time` not found. Looking for calibrated estimate...') + self.msg2('`cam.dead_time` not found. Looking for calibrated estimate...') try: c = CalibMovieDelays.from_file(exposure, header_keys_variable, header_keys_common) except RuntimeWarning: return 0.0 else: return c.dead_time + finally: + self.msg2('') def get_stage_rotation(self) -> CalibStageRotation: """Get rotation calibration if present; otherwise warn & terminate.""" try: return CalibStageRotation.from_file() except OSError: - msg = ( - 'Collecting cRED with this script requires calibrated stage rotation. ' - 'Please run `instamatic.calibrate_stage_rotation` first.' - ) - self.msg(msg) - raise FastADTMissingCalibError(msg) + self.msg1(m1 := 'This script requires stage rotation to be calibrated.') + self.msg2(m2 := 'Please run `instamatic.calibrate_stage_rotation` first.') + raise FastADTMissingCalibError(m1 + ' ' + m2) def determine_rotation_speed_and_exposure(self, run: Run) -> tuple[float, float]: """Closest possible speed setting & exposure considering dead time.""" @@ -283,16 +284,24 @@ def determine_rotation_speed_and_exposure(self, run: Run) -> tuple[float, float] exposure = abs(rot_plan.pace * run.osc_angle) - detector_dead_time return rot_plan.speed, exposure - def msg(self, text: str) -> None: - """Display a message in log.info, consoles & FastADT frame at once.""" + def _message(self, text: str, var: StringVar) -> None: + """Display text in log.info, consoles, FastADT frame msg area 1/2.""" try: - self.fast_adt_frame.message.set(text) + var.set(text) except AttributeError: pass if text: print(text) self.log.info(text) + def msg1(self, text) -> None: + """Display in message area 1 with persistent status & instructions.""" + return self._message(text, var=self.fast_adt_frame.message1) + + def msg2(self, text) -> None: + """Display in message area 2 with the most recent tem/cam updates.""" + return self._message(text, var=self.fast_adt_frame.message2) + def start_collection(self, **params) -> None: """Collect FastADT experiment according to provided **params. @@ -315,7 +324,8 @@ def start_collection(self, **params) -> None: Finally, the collected run will be logged and the stage - reset. """ - self.msg('FastADT experiment started') + self.msg1('Collecting crystal image.') + self.msg2('') image_path = self.path / 'image.tiff' if not image_path.exists(): self.ctrl.restore('FastADT_image') @@ -336,7 +346,9 @@ def start_collection(self, **params) -> None: self.ctrl.restore('FastADT_diff') self.camera_length = int(self.ctrl.magnification.get()) for run in self.runs.diffraction: + self.msg1(f'Collecting {run!s}.') self.collect_run(run) + self.msg1(f'Finalizing {run!s}.') run.update_images_metas(self.steps) self.finalize(run) @@ -369,7 +381,7 @@ def determine_pathing_manually(self) -> None: self.ctrl.stage.a = run.table.loc[len(run) // 2, 'alpha'] with self.ctrl.beam.unblanked(), self.ctrl.cam.unblocked(): self.beamshift = self.get_beamshift() - self.msg('Collecting tracking. Click on the center of the beam.') + self.msg1('Locate the beam (move it if needed) and click on its center.') with self.click_listener as cl: obs_beampixel_xy = np.array(cl.get_click().xy) cal_beampixel_yx = self.beamshift.beamshift_to_pixelcoord(self.ctrl.beamshift.get()) @@ -380,8 +392,7 @@ def determine_pathing_manually(self) -> None: tracking_in_progress = True while tracking_in_progress: while (step := self.steps.get()) is not None: - m = f'Click on tracked point: {step.summary}.' - self.msg(m) + self.msg1(f'Click on tracked point: {step.summary}.') with self.displayed_pathing(step=step), self.click_listener: click = self.click_listener.get_click() delta_yx = (np.array(click.xy) - obs_beampixel_xy)[::-1] @@ -390,20 +401,21 @@ def determine_pathing_manually(self) -> None: cols = ['beampixel_x', 'beampixel_y', 'beamshift_x', 'beamshift_y'] run.table.loc[step.Index, cols] = *click.xy, *click_beamshift_xy tracking_images.append(step.image) - self.msg('') if 'image' not in run.table: run.table['image'] = tracking_images self.runs.pathing.append(deepcopy(run)) - self.msg('Tracking results: click LMB to accept, MMB to add new, RMB to reject.') + self.msg1('Displaying tracking. Click LEFT mouse button to start the experiment,') + self.msg2('MIDDLE to track another point, or RIGHT to cancel the experiment.') for step in sawtooth(self.runs.tracking.steps): with self.displayed_pathing(step=step): with self.click_listener: click = self.click_listener.get_click(timeout=0.5) if click is None: continue + self.msg2('') if click.button == MouseButton.RIGHT: - self.msg(msg := 'Experiment abandoned after tracking.') + self.msg1(msg := 'Experiment abandoned after tracking.') raise FastADTEarlyTermination(msg) if click.button == MouseButton.LEFT: tracking_in_progress = False @@ -423,7 +435,6 @@ def collect_run(self, run: Run) -> None: def _collect_scans(self, run: Run) -> None: """Collect `run.steps` scans and place them in `self.steps` Queue.""" rot_speed, run.exposure = self.determine_rotation_speed_and_exposure(run) - self.msg(f'Collecting {run!s}') self.ctrl.stage.a = float(run.table.at[0, 'alpha']) movie = self.ctrl.get_movie(n_frames=len(run) - 1, exposure=run.exposure) target_alpha = float(run.table.iloc[-1].loc['alpha']) @@ -431,21 +442,24 @@ def _collect_scans(self, run: Run) -> None: with self.ctrl.stage.rotation_speed(speed=rot_speed): self.ctrl.stage.set(a=target_alpha, wait=False) for step, (image, meta) in zip(run.steps, movie): + self.msg2(f'Collecting {step.summary}.') if run.has_beamshifts: self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) self.steps.put(replace(step, image=image, meta=meta)) self.steps.put(None) + self.msg2('') def _collect_stills(self, run: Run) -> None: """Collect `run.steps` stills and place them in `self.steps` Queue.""" - self.msg(f'Collecting {run!s}') for step in run.steps: + self.msg2(f'Collecting {step.summary}.') if run.has_beamshifts: self.ctrl.beamshift.set(step.beamshift_x, step.beamshift_y) self.ctrl.stage.a = step.alpha image, meta = self.ctrl.get_image(exposure=run.exposure) self.steps.put(replace(step, image=image, meta=meta)) self.steps.put(None) + self.msg2('') def get_run_output_path(self, run: DiffractionRun) -> Path: """Return self.path if only 1 run done, self.path/sub## if multiple.""" @@ -461,7 +475,7 @@ def finalize(self, run: DiffractionRun) -> None: mrc_path.mkdir(exist_ok=True, parents=True) tiff_path.mkdir(exist_ok=True, parents=True) - self.msg(f'Saving experiment in: {out_path}') + self.msg1(f'Saving experiment in "{out_path}"...') rotation_axis = config.camera.camera_rotation_vs_stage_xy pixel_size = config.calibration['diff']['pixelsize'].get(self.camera_length, -1) physical_pixel_size = config.camera.physical_pixelsize # mm @@ -490,4 +504,4 @@ def finalize(self, run: DiffractionRun) -> None: img_conv.write_ed3d(mrc_path) img_conv.write_pets_inp(out_path) img_conv.write_beam_centers(out_path) - self.msg('Data collection and conversion done. FastADT experiment finalized.') + self.msg1(f'Experiment saved in "{out_path}".') diff --git a/src/instamatic/gui/fast_adt_frame.py b/src/instamatic/gui/fast_adt_frame.py index ae584826..6010d7fa 100644 --- a/src/instamatic/gui/fast_adt_frame.py +++ b/src/instamatic/gui/fast_adt_frame.py @@ -172,12 +172,16 @@ def __init__(self, parent): Separator(f, orient=HORIZONTAL).grid(row=11, columnspan=4, sticky=EW, padx=10, pady=10) - # Center-aligned sticky message area and bottom start button + # Center-aligned sticky message areas 1, 2, and the bottom start button f = Frame(self) - self.message = StringVar(value='Further information will appear here.') - self.message_area = Label(f, textvariable=self.message, anchor=NW) - self.message_area.pack(fill='both', expand=True) + self.message1 = StringVar(value='Further information will appear here.') + self.message1_area = Label(f, textvariable=self.message1, anchor=NW) + self.message1_area.pack(fill='x') + + self.message2 = StringVar(value='') + self.message2_area = Label(f, textvariable=self.message2, anchor=NW) + self.message2_area.pack(fill='both', expand=True) f.pack(side='top', fill='both', expand=True, padx=10) self.start_button = Button(self, text='Start', width=1, command=self.start_collection) From 4d9d0e8c9c8e64f3be13fc35b517c1368987df34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 17 Oct 2025 20:35:45 +0200 Subject: [PATCH 43/49] Fix ignore msg1,2 variables in headless FastADT experiment --- src/instamatic/experiments/fast_adt/experiment.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index 0f982a60..5f93f016 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -284,7 +284,7 @@ def determine_rotation_speed_and_exposure(self, run: Run) -> tuple[float, float] exposure = abs(rot_plan.pace * run.osc_angle) - detector_dead_time return rot_plan.speed, exposure - def _message(self, text: str, var: StringVar) -> None: + def _message(self, text: str, var: Optional[StringVar]) -> None: """Display text in log.info, consoles, FastADT frame msg area 1/2.""" try: var.set(text) @@ -294,13 +294,15 @@ def _message(self, text: str, var: StringVar) -> None: print(text) self.log.info(text) - def msg1(self, text) -> None: + def msg1(self, text: str) -> None: """Display in message area 1 with persistent status & instructions.""" - return self._message(text, var=self.fast_adt_frame.message1) + var = self.fast_adt_frame.message1 if self.fast_adt_frame else None + return self._message(text, var=var) - def msg2(self, text) -> None: + def msg2(self, text: str) -> None: """Display in message area 2 with the most recent tem/cam updates.""" - return self._message(text, var=self.fast_adt_frame.message2) + var = self.fast_adt_frame.message2 if self.fast_adt_frame else None + return self._message(text, var=var) def start_collection(self, **params) -> None: """Collect FastADT experiment according to provided **params. From 8ac955a3d1814864e682169e60c3a3bbca9b18d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 17 Oct 2025 20:50:02 +0200 Subject: [PATCH 44/49] Update in tests `tracking_mode` -> `tracking_algo` --- tests/test_experiments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_experiments.py b/tests/test_experiments.py index 521dc259..4240d125 100644 --- a/tests/test_experiments.py +++ b/tests/test_experiments.py @@ -61,7 +61,7 @@ class ExperimentTestCase(InstanceAutoTracker): fast_adt_common_collect_kwargs = { 'diffraction_step': 0.5, 'diffraction_time': 0.01, - 'tracking_mode': 'none', + 'tracking_algo': 'none', 'tracking_time': 0.01, } From 8dfbf9ef420de7082ee1b5140ea396d9a2c72882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 20 Oct 2025 14:37:39 +0200 Subject: [PATCH 45/49] Add estimated time required dialog in FastADT message 2 --- src/instamatic/gui/fast_adt_frame.py | 44 ++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/instamatic/gui/fast_adt_frame.py b/src/instamatic/gui/fast_adt_frame.py index 6010d7fa..e550ff9d 100644 --- a/src/instamatic/gui/fast_adt_frame.py +++ b/src/instamatic/gui/fast_adt_frame.py @@ -89,37 +89,44 @@ def __init__(self, parent): Label(f, text='Diffraction start (deg):').grid(row=4, column=0, **pad10) var = self.var.diffraction_start + var.trace('w', self.update_widget) self.diffraction_start = Spinbox(f, textvariable=var, **angle_lim) self.diffraction_start.grid(row=4, column=1, **pad10) Label(f, text='Diffraction stop (deg):').grid(row=5, column=0, **pad10) var = self.var.diffraction_stop + var.trace('w', self.update_widget) self.diffraction_stop = Spinbox(f, textvariable=var, **angle_lim) self.diffraction_stop.grid(row=5, column=1, **pad10) Label(f, text='Diffraction step (deg):').grid(row=6, column=0, **pad10) var = self.var.diffraction_step + var.trace('w', self.update_widget) self.diffraction_step = Spinbox(f, textvariable=var, **angle_delta) self.diffraction_step.grid(row=6, column=1, **pad10) Label(f, text='Diffraction exposure (s):').grid(row=7, column=0, **pad10) var = self.var.diffraction_time + var.trace('w', self.update_widget) self.diffraction_time = Spinbox(f, textvariable=var, **duration) self.diffraction_time.grid(row=7, column=1, **pad10) Label(f, text='Tracking algorithm:').grid(row=3, column=2, **pad10) var = self.var.tracking_algo + var.trace('w', self.update_widget) m = ['none', 'manual'] - self.tracking_algo = OptionMenu(f, var, m[0], *m, command=self.update_widget_state) + self.tracking_algo = OptionMenu(f, var, m[0], *m) self.tracking_algo.grid(row=3, column=3, **pad10) Label(f, text='Tracking step (deg):').grid(row=6, column=2, **pad10) var = self.var.tracking_step + var.trace('w', self.update_widget) self.tracking_step = Spinbox(f, textvariable=var, **angle_delta) self.tracking_step.grid(row=6, column=3, **pad10) Label(f, text='Tracking exposure (s):').grid(row=7, column=2, **pad10) var = self.var.tracking_time + var.trace('w', self.update_widget) self.tracking_time = Spinbox(f, textvariable=var, **duration) self.tracking_time.grid(row=7, column=3, **pad10) @@ -187,12 +194,29 @@ def __init__(self, parent): self.start_button = Button(self, text='Start', width=1, command=self.start_collection) self.start_button.pack(side='bottom', fill='x', padx=10, pady=10) - self.update_widget_state() + self.update_widget() + + def estimate_times(self) -> tuple[float, float]: + """Estimate time needed for tracking + each diffraction in seconds.""" + a_span = abs(self.var.diffraction_start.get() - self.var.diffraction_stop.get()) + try: + track_step = self.var.tracking_step.get() + except TclError: + track_step = 0.001 + try: + diff_step = self.var.diffraction_step.get() + except TclError: + diff_step = 0.001 + track_time = 0 + if self.var.tracking_algo.get() != 'none': + track_time = self.var.tracking_time.get() * a_span / track_step + diff_time = self.var.diffraction_time.get() * a_span / diff_step + return track_time, diff_time def toggle_beam_blank(self) -> None: (self.ctrl.beam.unblank if self.ctrl.beam.is_blanked else self.ctrl.beam.blank)() - def update_widget_state(self, *_, busy: Optional[bool] = None, **__) -> None: + def update_widget(self, *_, busy: Optional[bool] = None, **__) -> None: self.busy = busy if busy is not None else self.busy no_tracking = self.var.tracking_algo.get() == 'none' widget_state = 'disabled' if self.busy else 'enabled' @@ -205,10 +229,18 @@ def update_widget_state(self, *_, busy: Optional[bool] = None, **__) -> None: self.diffraction_step.config(state=widget_state) self.diffraction_time.config(state=widget_state) self.tracking_algo.config(state=widget_state) - self.tracking_step.config(state=tracking_state) self.tracking_time.config(state=tracking_state) + tracking_time, diffraction_time = self.estimate_times() + tt = '{:.0f}:{:02.0f}'.format(*divmod(tracking_time, 60)) + dt = '{:.0f}:{:02.0f}'.format(*divmod(diffraction_time, 60)) + if tracking_time: # don't display tracking time or per-attempts if zero + msg = f'Estimated time required: {tt} + {dt} / tracking.' + else: + msg = f'Estimated time required: {dt}.' + self.message2.set(msg) + def start_collection(self) -> None: self.q.put(('fast_adt', {'frame': self, **self.var.as_dict()})) self.triggerEvent.set() @@ -237,13 +269,13 @@ def fast_adt_interface_command(controller, **params: Any) -> None: videostream_frame=videostream_frame, ) try: - fast_adt_frame.update_widget_state(busy=True) + fast_adt_frame.update_widget(busy=True) controller.fast_adt.start_collection(**params) except RuntimeError: pass # RuntimeError is raised if experiment is terminated early finally: del controller.fast_adt - fast_adt_frame.update_widget_state(busy=False) + fast_adt_frame.update_widget(busy=False) module = BaseModule( From b280c3507f3e470bee551906f35a5b8629701c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 20 Oct 2025 14:38:42 +0200 Subject: [PATCH 46/49] Display which experiment is being collected in multi-expt --- src/instamatic/experiments/fast_adt/experiment.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index 5f93f016..d87d585c 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -347,10 +347,11 @@ def start_collection(self, **params) -> None: self.ctrl.restore('FastADT_diff') self.camera_length = int(self.ctrl.magnification.get()) - for run in self.runs.diffraction: - self.msg1(f'Collecting {run!s}.') + for ir, run in enumerate(self.runs.diffraction): + c = f'{ir}/{t} ' if (t := len(self.runs.diffraction)) > 1 else '' + self.msg1(f'Collecting {c}{run!s}.') self.collect_run(run) - self.msg1(f'Finalizing {run!s}.') + self.msg1(f'Finalizing {c}{run!s}.') run.update_images_metas(self.steps) self.finalize(run) From 9422a93f980ab72769c925c65dac40a8c1643a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 20 Oct 2025 14:43:33 +0200 Subject: [PATCH 47/49] Display which experiment is being collected in multi-expt --- src/instamatic/experiments/fast_adt/experiment.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/instamatic/experiments/fast_adt/experiment.py b/src/instamatic/experiments/fast_adt/experiment.py index d87d585c..f9115291 100644 --- a/src/instamatic/experiments/fast_adt/experiment.py +++ b/src/instamatic/experiments/fast_adt/experiment.py @@ -347,11 +347,12 @@ def start_collection(self, **params) -> None: self.ctrl.restore('FastADT_diff') self.camera_length = int(self.ctrl.magnification.get()) + n_runs = len(self.runs.diffraction) for ir, run in enumerate(self.runs.diffraction): - c = f'{ir}/{t} ' if (t := len(self.runs.diffraction)) > 1 else '' - self.msg1(f'Collecting {c}{run!s}.') + suffix = f' ({ir + 1}/{n_runs})' if n_runs > 1 else '' + self.msg1(f'Collecting {run!s}.{suffix}') self.collect_run(run) - self.msg1(f'Finalizing {c}{run!s}.') + self.msg1(f'Finalizing {run!s}.{suffix}') run.update_images_metas(self.steps) self.finalize(run) From fa8fb2d1c2c4d2060ad49d2adaea2dd5e176a01f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 21 Oct 2025 20:48:40 +0200 Subject: [PATCH 48/49] Trace variables only after everything was defined to avoid Exceptions --- src/instamatic/gui/fast_adt_frame.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/instamatic/gui/fast_adt_frame.py b/src/instamatic/gui/fast_adt_frame.py index e550ff9d..b047407a 100644 --- a/src/instamatic/gui/fast_adt_frame.py +++ b/src/instamatic/gui/fast_adt_frame.py @@ -89,44 +89,37 @@ def __init__(self, parent): Label(f, text='Diffraction start (deg):').grid(row=4, column=0, **pad10) var = self.var.diffraction_start - var.trace('w', self.update_widget) self.diffraction_start = Spinbox(f, textvariable=var, **angle_lim) self.diffraction_start.grid(row=4, column=1, **pad10) Label(f, text='Diffraction stop (deg):').grid(row=5, column=0, **pad10) var = self.var.diffraction_stop - var.trace('w', self.update_widget) self.diffraction_stop = Spinbox(f, textvariable=var, **angle_lim) self.diffraction_stop.grid(row=5, column=1, **pad10) Label(f, text='Diffraction step (deg):').grid(row=6, column=0, **pad10) var = self.var.diffraction_step - var.trace('w', self.update_widget) self.diffraction_step = Spinbox(f, textvariable=var, **angle_delta) self.diffraction_step.grid(row=6, column=1, **pad10) Label(f, text='Diffraction exposure (s):').grid(row=7, column=0, **pad10) var = self.var.diffraction_time - var.trace('w', self.update_widget) self.diffraction_time = Spinbox(f, textvariable=var, **duration) self.diffraction_time.grid(row=7, column=1, **pad10) Label(f, text='Tracking algorithm:').grid(row=3, column=2, **pad10) var = self.var.tracking_algo - var.trace('w', self.update_widget) m = ['none', 'manual'] self.tracking_algo = OptionMenu(f, var, m[0], *m) self.tracking_algo.grid(row=3, column=3, **pad10) Label(f, text='Tracking step (deg):').grid(row=6, column=2, **pad10) var = self.var.tracking_step - var.trace('w', self.update_widget) self.tracking_step = Spinbox(f, textvariable=var, **angle_delta) self.tracking_step.grid(row=6, column=3, **pad10) Label(f, text='Tracking exposure (s):').grid(row=7, column=2, **pad10) var = self.var.tracking_time - var.trace('w', self.update_widget) self.tracking_time = Spinbox(f, textvariable=var, **duration) self.tracking_time.grid(row=7, column=3, **pad10) @@ -194,6 +187,14 @@ def __init__(self, parent): self.start_button = Button(self, text='Start', width=1, command=self.start_collection) self.start_button.pack(side='bottom', fill='x', padx=10, pady=10) + # update the state of the widget if any of these variavles changes + self.var.diffraction_start.trace('w', self.update_widget) + self.var.diffraction_stop.trace('w', self.update_widget) + self.var.diffraction_step.trace('w', self.update_widget) + self.var.diffraction_time.trace('w', self.update_widget) + self.var.tracking_algo.trace('w', self.update_widget) + self.var.tracking_step.trace('w', self.update_widget) + self.var.tracking_time.trace('w', self.update_widget) self.update_widget() def estimate_times(self) -> tuple[float, float]: From a4636472e3471632946e20da2c6a7f7cf0dbb9de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 22 Oct 2025 17:35:07 +0200 Subject: [PATCH 49/49] Fix: leaving input empty even temporarily caused exception --- src/instamatic/gui/fast_adt_frame.py | 43 +++++++++++++++++----------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/instamatic/gui/fast_adt_frame.py b/src/instamatic/gui/fast_adt_frame.py index b047407a..bd145f79 100644 --- a/src/instamatic/gui/fast_adt_frame.py +++ b/src/instamatic/gui/fast_adt_frame.py @@ -1,10 +1,11 @@ from __future__ import annotations import threading +from functools import wraps from queue import Queue from tkinter import * from tkinter.ttk import * -from typing import Any, Optional +from typing import Any, Callable, Optional from instamatic import controller from instamatic.utils.spinbox import Spinbox @@ -13,7 +14,6 @@ pad0 = {'sticky': 'EW', 'padx': 0, 'pady': 1} pad10 = {'sticky': 'EW', 'padx': 10, 'pady': 1} -width = {'width': 19} angle_lim = {'from_': -90, 'to': 90, 'increment': 1, 'width': 20} angle_delta = {'from_': 0, 'to': 180, 'increment': 0.1, 'width': 20} duration = {'from_': 0, 'to': 60, 'increment': 0.1} @@ -49,7 +49,7 @@ def restore(self) -> None: class ExperimentalFastADTVariables: """A collection of tkinter Variable instances passed to the experiment.""" - def __init__(self): + def __init__(self, on_change: Optional[Callable[[], None]] = None) -> None: self.diffraction_mode = StringVar() self.diffraction_start = DoubleVar(value=-30) self.diffraction_stop = DoubleVar(value=30) @@ -59,12 +59,29 @@ def __init__(self): self.tracking_time = DoubleVar(value=0.5) self.tracking_step = DoubleVar(value=5.0) + if on_change: + self._add_callback(on_change) + + def _add_callback(self, callback: Callable[[], None]) -> None: + """Add a safe trace callback to all `Variable` instances in self.""" + + @wraps(callback) + def safe_callback(*_): + try: + callback() + except TclError as e: # Ignore invalid/incomplete GUI edits + if 'expected floating-point number' not in str(e): + raise + except AttributeError as e: # Ignore incomplete initialization + if 'object has no attribute' not in str(e): + raise + + for name, var in vars(self).items(): + if isinstance(var, Variable): + var.trace_add('write', safe_callback) + def as_dict(self): - return { - v: getattr(self, v).get() - for v in dir(self) - if isinstance(getattr(self, v), Variable) - } + return {n: v.get() for n, v in vars(self).items() if isinstance(v, Variable)} class ExperimentalFastADT(LabelFrame): @@ -73,7 +90,7 @@ class ExperimentalFastADT(LabelFrame): def __init__(self, parent): super().__init__(parent, text='Experiment with a priori tracking options') self.parent = parent - self.var = ExperimentalFastADTVariables() + self.var = ExperimentalFastADTVariables(on_change=self.update_widget) self.q: Optional[Queue] = None self.triggerEvent: Optional[threading.Event] = None self.busy: bool = False @@ -187,14 +204,6 @@ def __init__(self, parent): self.start_button = Button(self, text='Start', width=1, command=self.start_collection) self.start_button.pack(side='bottom', fill='x', padx=10, pady=10) - # update the state of the widget if any of these variavles changes - self.var.diffraction_start.trace('w', self.update_widget) - self.var.diffraction_stop.trace('w', self.update_widget) - self.var.diffraction_step.trace('w', self.update_widget) - self.var.diffraction_time.trace('w', self.update_widget) - self.var.tracking_algo.trace('w', self.update_widget) - self.var.tracking_step.trace('w', self.update_widget) - self.var.tracking_time.trace('w', self.update_widget) self.update_widget() def estimate_times(self) -> tuple[float, float]: