From 44e919db521faf8622d1d2888ee58cecbd255d3f Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Fri, 19 Dec 2025 15:06:19 -0500 Subject: [PATCH 01/25] display error dialog when saving a datacube fails --- src/py4D_browser/menu_actions.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py index 79a436d..380a19e 100644 --- a/src/py4D_browser/menu_actions.py +++ b/src/py4D_browser/menu_actions.py @@ -197,17 +197,26 @@ def export_datacube(self, save_format: str): self.statusBar().showMessage("Cancelling due to user guilt", 5_000) return - filename = self.get_savefile_name(save_format) + try: + filename = self.get_savefile_name(save_format) - if save_format == "Raw float32": - self.datacube.data.astype(np.float32).tofile(filename) + if save_format == "Raw float32": + self.datacube.data.astype(np.float32).tofile(filename) + + elif save_format == "py4DSTEM HDF5": + py4DSTEM.save(filename, self.datacube, mode="o") - elif save_format == "py4DSTEM HDF5": - py4DSTEM.save(filename, self.datacube, mode="o") + elif save_format == "Plain HDF5": + with h5py.File(filename, "w") as f: + f["array"] = self.datacube.data + except Exception as exc: + QMessageBox.critical( + self, + "Uh-oh!", + repr(exc), + ) - elif save_format == "Plain HDF5": - with h5py.File(filename, "w") as f: - f["array"] = self.datacube.data + raise exc def export_virtual_image(self, im_format: str, im_type: str): From d08656ca5bfd2a1d3b3aa535b349965a5edaa02e Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Fri, 19 Dec 2025 15:07:14 -0500 Subject: [PATCH 02/25] add plugin to set logging levels --- src/py4D_browser/main_window.py | 2 +- .../logging_config_plugin/__init__.py | 1 + .../logging_config_plugin/logger_plugin.py | 82 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/py4d_browser_plugin/logging_config_plugin/__init__.py create mode 100644 src/py4d_browser_plugin/logging_config_plugin/logger_plugin.py diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index d1b0ee2..19a39a2 100644 --- a/src/py4D_browser/main_window.py +++ b/src/py4D_browser/main_window.py @@ -134,7 +134,7 @@ def __init__(self, argv): # launch pyqtgraph's debug console if environment variable exists if os.environ.get("PY4DGUI_DEBUG"): - pg.dbg() + pg.dbg(namespace={"main_window": self}) def setup_menus(self): self.menu_bar = self.menuBar() diff --git a/src/py4d_browser_plugin/logging_config_plugin/__init__.py b/src/py4d_browser_plugin/logging_config_plugin/__init__.py new file mode 100644 index 0000000..df97af5 --- /dev/null +++ b/src/py4d_browser_plugin/logging_config_plugin/__init__.py @@ -0,0 +1 @@ +from .logger_plugin import LoggingConfigurationPlugin diff --git a/src/py4d_browser_plugin/logging_config_plugin/logger_plugin.py b/src/py4d_browser_plugin/logging_config_plugin/logger_plugin.py new file mode 100644 index 0000000..d358bb5 --- /dev/null +++ b/src/py4d_browser_plugin/logging_config_plugin/logger_plugin.py @@ -0,0 +1,82 @@ +from PyQt5.QtWidgets import QPushButton +from PyQt5.QtWidgets import ( + QDialog, + QHBoxLayout, + QVBoxLayout, + QComboBox, + QGroupBox, + QWidget, + QFormLayout, + QScrollArea, +) +import logging + + +class LoggingConfigurationPlugin(QWidget): + + # required for py4DGUI to recognize this as a plugin. + plugin_id = "py4DGUI.internal.logging" + + uses_single_action = True + display_name = "Logger Settings..." + + def __init__(self, parent, plugin_action, **kwargs): + super().__init__() + + self.parent = parent + + plugin_action.triggered.connect(self.launch_dialog) + + def close(self): + pass + + def launch_dialog(self): + dialog = LoggerDialog(parent=self.parent) + dialog.open() + + +class LoggerDialog(QDialog): + def __init__(self, parent): + super().__init__(parent=parent) + + self.parent = parent + + layout = QVBoxLayout(self) + + ####### LAYOUT ######## + + main_box = QGroupBox("Registered Loggers") + # layout.addWidget(main_box) + + scroll = QScrollArea() + scroll.setWidget(main_box) + scroll.setWidgetResizable(True) + layout.addWidget(scroll) + # scroll.setFixedHeight(200) + + form = QFormLayout() + main_box.setLayout(form) + + # get all loggers + loggers = [logging.getLogger(name) for name in logging.root.manager.loggerDict] + + log_levels = ["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + + for lg in loggers: + selector = QComboBox() + selector.addItems(log_levels) + selector.setCurrentText(logging.getLevelName(lg.level)) + selector.currentTextChanged.connect(lg.setLevel) + + form.addRow( + lg.name, + selector, + ) + + button_layout = QHBoxLayout() + button_layout.addStretch() + cancel_button = QPushButton("Done") + cancel_button.pressed.connect(self.close) + button_layout.addWidget(cancel_button) + + layout.addLayout(button_layout) From 3ce14ac1dd066ade278a2635f0d7b5864cbe9b2b Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Fri, 19 Dec 2025 15:40:00 -0500 Subject: [PATCH 03/25] delete extraneous line --- src/py4d_browser_plugin/logging_config_plugin/logger_plugin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/py4d_browser_plugin/logging_config_plugin/logger_plugin.py b/src/py4d_browser_plugin/logging_config_plugin/logger_plugin.py index d358bb5..e511746 100644 --- a/src/py4d_browser_plugin/logging_config_plugin/logger_plugin.py +++ b/src/py4d_browser_plugin/logging_config_plugin/logger_plugin.py @@ -52,7 +52,6 @@ def __init__(self, parent): scroll.setWidget(main_box) scroll.setWidgetResizable(True) layout.addWidget(scroll) - # scroll.setFixedHeight(200) form = QFormLayout() main_box.setLayout(form) From df96d3aadde61289aebf6b933499690a4bc44e4c Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Fri, 19 Dec 2025 16:55:24 -0500 Subject: [PATCH 04/25] add metadata viewer, fix metadata reading for emdfile data --- src/py4D_browser/menu_actions.py | 22 ++++-- .../metadata_plugin/__init__.py | 1 + .../metadata_plugin/metadata_viewer.py | 74 +++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 src/py4d_browser_plugin/metadata_plugin/__init__.py create mode 100644 src/py4d_browser_plugin/metadata_plugin/metadata_viewer.py diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py index 380a19e..eb897fa 100644 --- a/src/py4D_browser/menu_actions.py +++ b/src/py4D_browser/menu_actions.py @@ -72,16 +72,22 @@ def load_file(self, filepath, mmap=False, binning=1): if len(datacubes) >= 1: # Read the first datacube in the HDF5 file into RAM print(f"Reading dataset at location {datacubes[0].name}") - self.datacube = py4DSTEM.DataCube( - datacubes[0] if mmap else datacubes[0][()] - ) - R_size, R_units, Q_size, Q_units = find_calibrations(datacubes[0]) + parent = "/".join(datacubes[0].name.split("/")[:-1]) + if parent != "/" and "emd_group_type" in file[parent].attrs: + print("This appears to be an emdfile... reading natively") + self.datacube = py4DSTEM.DataCube.from_h5(datacubes[0].file[parent]) + else: + self.datacube = py4DSTEM.DataCube( + datacubes[0] if mmap else datacubes[0][()] + ) + + R_size, R_units, Q_size, Q_units = find_calibrations(datacubes[0]) - self.datacube.calibration.set_R_pixel_size(R_size) - self.datacube.calibration.set_R_pixel_units(R_units) - self.datacube.calibration.set_Q_pixel_size(Q_size) - self.datacube.calibration.set_Q_pixel_units(Q_units) + self.datacube.calibration.set_R_pixel_size(R_size) + self.datacube.calibration.set_R_pixel_units(R_units) + self.datacube.calibration.set_Q_pixel_size(Q_size) + self.datacube.calibration.set_Q_pixel_units(Q_units) else: # if no 4D data was found, look for 3D data diff --git a/src/py4d_browser_plugin/metadata_plugin/__init__.py b/src/py4d_browser_plugin/metadata_plugin/__init__.py new file mode 100644 index 0000000..c90c120 --- /dev/null +++ b/src/py4d_browser_plugin/metadata_plugin/__init__.py @@ -0,0 +1 @@ +from .metadata_viewer import MetadataViewer diff --git a/src/py4d_browser_plugin/metadata_plugin/metadata_viewer.py b/src/py4d_browser_plugin/metadata_plugin/metadata_viewer.py new file mode 100644 index 0000000..af37c8b --- /dev/null +++ b/src/py4d_browser_plugin/metadata_plugin/metadata_viewer.py @@ -0,0 +1,74 @@ +from PyQt5.QtWidgets import QPushButton +from PyQt5.QtWidgets import ( + QDialog, + QHBoxLayout, + QVBoxLayout, + QComboBox, + QGroupBox, + QWidget, + QFormLayout, + QTreeWidget, + QTreeWidgetItem, +) +import logging +from emdfile import Metadata + + +class MetadataViewer(QWidget): + + # required for py4DGUI to recognize this as a plugin. + plugin_id = "py4DGUI.internal.metadata" + + uses_single_action = True + display_name = "Show Metadata..." + + def __init__(self, parent, plugin_action, **kwargs): + super().__init__() + + self.parent = parent + + plugin_action.triggered.connect(self.launch_dialog) + + def close(self): + pass + + def launch_dialog(self): + dialog = MetadataDialog(parent=self.parent) + dialog.open() + + +class MetadataDialog(QDialog): + def __init__(self, parent): + super().__init__(parent=parent) + + self.parent = parent + + layout = QVBoxLayout(self) + + print(self.parent.datacube.metadata) + + mdata = self.parent.datacube.metadata | { + "calibration": self.parent.datacube.calibration + } + + tree = QTreeWidget() + tree.setColumnCount(2) + layout.addWidget(tree) + + for name, md in mdata.items(): + top = QTreeWidgetItem(tree) + top.setText(0, name) + + # TODO: make this recursive to handle nested dicts + for k in md.keys: + entry = QTreeWidgetItem(top) + entry.setText(0, k) + entry.setText(1, str(md[k])) + + button_layout = QHBoxLayout() + button_layout.addStretch() + cancel_button = QPushButton("Done") + cancel_button.pressed.connect(self.close) + button_layout.addWidget(cancel_button) + + layout.addLayout(button_layout) From 5c4091aa5857d23391fa82eeadd8ae8465bee2a3 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Fri, 19 Dec 2025 16:55:57 -0500 Subject: [PATCH 05/25] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 83f3d6c..d76a19e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "py4D_browser" -version = "1.3.1" +version = "1.4.0" authors = [ { name="Steven Zeltmann", email="steven.zeltmann@lbl.gov" }, ] From d2348ce18c725292e5ee4cd22c40d687a9dd1068 Mon Sep 17 00:00:00 2001 From: EMPAD-EELS Date: Wed, 14 Jan 2026 15:44:51 -0500 Subject: [PATCH 06/25] better error formatting --- src/py4D_browser/menu_actions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py index eb897fa..d973508 100644 --- a/src/py4D_browser/menu_actions.py +++ b/src/py4D_browser/menu_actions.py @@ -74,7 +74,7 @@ def load_file(self, filepath, mmap=False, binning=1): print(f"Reading dataset at location {datacubes[0].name}") parent = "/".join(datacubes[0].name.split("/")[:-1]) - if parent != "/" and "emd_group_type" in file[parent].attrs: + if len(parent) > 1 and "emd_group_type" in file[parent].attrs: print("This appears to be an emdfile... reading natively") self.datacube = py4DSTEM.DataCube.from_h5(datacubes[0].file[parent]) else: @@ -216,10 +216,11 @@ def export_datacube(self, save_format: str): with h5py.File(filename, "w") as f: f["array"] = self.datacube.data except Exception as exc: + import traceback QMessageBox.critical( self, "Uh-oh!", - repr(exc), + traceback.format_exc(), ) raise exc From da76ad2207ca17d6bc01bf5a402908e281872bf4 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Mon, 2 Feb 2026 12:41:08 -0500 Subject: [PATCH 07/25] copy to clipboard --- src/py4D_browser/main_window.py | 8 ++++++++ src/py4D_browser/menu_actions.py | 22 ++++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index 19a39a2..4196d93 100644 --- a/src/py4D_browser/main_window.py +++ b/src/py4D_browser/main_window.py @@ -51,6 +51,8 @@ class DataViewer(QMainWindow): reshape_data, set_datacube, update_scalebars, + copy_vimg_to_clipboard, + copy_diff_to_clipboard, ) from py4D_browser.update_views import ( @@ -186,6 +188,9 @@ def setup_menus(self): # Submenu to export virtual image vimg_export_menu = QMenu("Export Virtual Image", self) self.file_menu.addMenu(vimg_export_menu) + menu_item = vimg_export_menu.addAction("To clipboard") + menu_item.triggered.connect(self.copy_vimg_to_clipboard) + menu_item.setShortcut(QtGui.QKeySequence("Ctrl+C")) for method in ["PNG (display)", "TIFF (display)", "TIFF (raw)"]: menu_item = vimg_export_menu.addAction(method) menu_item.triggered.connect( @@ -195,6 +200,9 @@ def setup_menus(self): # Submenu to export diffraction vdiff_export_menu = QMenu("Export Diffraction Pattern", self) self.file_menu.addMenu(vdiff_export_menu) + menu_item = vdiff_export_menu.addAction("To clipboard") + menu_item.triggered.connect(self.copy_diff_to_clipboard) + menu_item.setShortcut(QtGui.QKeySequence("Ctrl+Alt+C")) for method in ["PNG (display)", "TIFF (display)", "TIFF (raw)"]: menu_item = vdiff_export_menu.addAction(method) menu_item.triggered.connect( diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py index d973508..e4d8847 100644 --- a/src/py4D_browser/menu_actions.py +++ b/src/py4D_browser/menu_actions.py @@ -1,6 +1,5 @@ -from numbers import Real import py4DSTEM -from PyQt5.QtWidgets import QFileDialog, QMessageBox +from PyQt5.QtWidgets import QFileDialog, QMessageBox, QApplication import h5py import os import numpy as np @@ -217,6 +216,7 @@ def export_datacube(self, save_format: str): f["array"] = self.datacube.data except Exception as exc: import traceback + QMessageBox.critical( self, "Uh-oh!", @@ -260,6 +260,24 @@ def export_virtual_image(self, im_format: str, im_type: str): raise RuntimeError("Nothing saved! Format not recognized") +def copy_vimg_to_clipboard(self): + img = self.real_space_widget.getImageItem() + + if img._renderRequired: + img.render() + + QApplication.clipboard().setImage(img.qimage) + + +def copy_diff_to_clipboard(self): + img = self.diffraction_space_widget.getImageItem() + + if img._renderRequired: + img.render() + + QApplication.clipboard().setImage(img.qimage) + + def show_keyboard_map(self): keymap = KeyboardMapMenu(parent=self) keymap.open() From 536aaa79bd5cc7802ee480dccecee641f9a62410 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Wed, 1 Apr 2026 10:30:30 -0400 Subject: [PATCH 08/25] show message that file is saved --- src/py4D_browser/menu_actions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py index e4d8847..7007f3e 100644 --- a/src/py4D_browser/menu_actions.py +++ b/src/py4D_browser/menu_actions.py @@ -214,6 +214,9 @@ def export_datacube(self, save_format: str): elif save_format == "Plain HDF5": with h5py.File(filename, "w") as f: f["array"] = self.datacube.data + + self.setWindowTitle(filename) + self.statusBar().showMessage(f"File saved to {filename}") except Exception as exc: import traceback From 6f1a55213ca9c16f9879a1970f67704afa90502e Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Fri, 17 Apr 2026 13:04:35 -0400 Subject: [PATCH 09/25] show config file from Help --- pyproject.toml | 3 ++- src/py4D_browser/main_window.py | 43 ++++++++++++++++++++++----------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d76a19e..365ce82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "py4D_browser" -version = "1.4.0" +version = "1.5.0" authors = [ { name="Steven Zeltmann", email="steven.zeltmann@lbl.gov" }, ] @@ -25,6 +25,7 @@ dependencies = [ "PyQt5 >= 5.10", "pyqtgraph >= 0.11", "sigfig", + "show-in-file-manager", ] [project.scripts] diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index 4196d93..8d2564b 100644 --- a/src/py4D_browser/main_window.py +++ b/src/py4D_browser/main_window.py @@ -23,6 +23,7 @@ import importlib import os, sys import platformdirs +from showinfm import show_in_file_manager from py4D_browser.utils import pg_point_roi, VLine, LatchingButton from py4D_browser.scalebar import ScaleBar @@ -75,8 +76,14 @@ class DataViewer(QMainWindow): update_tooltip, ) + from py4D_browser.signals import register_result_callback + from py4D_browser.plugins import load_plugins + signal_diffraction_data_changed = QtCore.pyqtSignal() + signal_virtual_image_data_changed = QtCore.pyqtSignal() + signal_datacube_changed = QtCore.pyqtSignal() + def __init__(self, argv): super().__init__() # Define this as the QApplication object @@ -95,15 +102,17 @@ def __init__(self, argv): self.datacube = None - # Load settings from cofig file - config_path = os.path.join( + # Load settings from config file + self.config_path = os.path.join( platformdirs.user_config_dir("py4DGUI", "py4DSTEM"), "GUI_config.ini" ) - print(f"Loading configuration from {config_path}") + print(f"Loading configuration from {self.config_path}") QtCore.QCoreApplication.setOrganizationName("py4DSTEM") QtCore.QCoreApplication.setOrganizationDomain("py4DSTEM.com") QtCore.QCoreApplication.setApplicationName("py4DGUI") - self.settings = QtCore.QSettings(config_path, QtCore.QSettings.Format.IniFormat) + self.settings = QtCore.QSettings( + self.config_path, QtCore.QSettings.Format.IniFormat + ) # Reset stored state if so asked: if os.environ.get("PY4DGUI_RESET"): @@ -482,30 +491,30 @@ def setup_menus(self): rs_detector_shape_group.addAction(detector_rectangle_action) self.detector_shape_menu.addAction(detector_rectangle_action) - self.fft_menu = QMenu("FF&T View", self) - self.menu_bar.addMenu(self.fft_menu) + self.result_menu = QMenu("Resul&t View", self) + self.menu_bar.addMenu(self.result_menu) - self.fft_source_action_group = QActionGroup(self) - self.fft_source_action_group.setExclusive(True) + self.result_source_action_group = QActionGroup(self) + self.result_source_action_group.setExclusive(True) img_fft_action = QAction("Virtual Image FFT", self) img_fft_action.setCheckable(True) img_fft_action.setChecked(True) img_fft_action.triggered.connect(partial(self.update_real_space_view, False)) - self.fft_menu.addAction(img_fft_action) - self.fft_source_action_group.addAction(img_fft_action) + self.result_menu.addAction(img_fft_action) + self.result_source_action_group.addAction(img_fft_action) img_complex_fft_action = QAction("Virtual Image FFT (complex)", self) img_complex_fft_action.setCheckable(True) - self.fft_menu.addAction(img_complex_fft_action) - self.fft_source_action_group.addAction(img_complex_fft_action) + self.result_menu.addAction(img_complex_fft_action) + self.result_source_action_group.addAction(img_complex_fft_action) img_complex_fft_action.triggered.connect( partial(self.update_real_space_view, False) ) img_ewpc_action = QAction("EWPC", self) img_ewpc_action.setCheckable(True) - self.fft_menu.addAction(img_ewpc_action) - self.fft_source_action_group.addAction(img_ewpc_action) + self.result_menu.addAction(img_ewpc_action) + self.result_source_action_group.addAction(img_ewpc_action) img_ewpc_action.triggered.connect( partial(self.update_diffraction_space_view, False) ) @@ -522,6 +531,12 @@ def setup_menus(self): self.keyboard_map_action.triggered.connect(self.show_keyboard_map) self.help_menu.addAction(self.keyboard_map_action) + self.show_config_file_action = QAction("Show Configuration File", self) + self.show_config_file_action.triggered.connect( + partial(show_in_file_manager, self.config_path) + ) + self.help_menu.addAction(self.show_config_file_action) + def setup_views(self): # Set up the diffraction space window. self.diffraction_space_widget = pg.ImageView() From 61941d06a93ad2824317d2d99aa542dd70b27e4d Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Fri, 17 Apr 2026 15:51:48 -0400 Subject: [PATCH 10/25] mostly migrate FFT window to generic result window --- src/py4D_browser/main_window.py | 30 ++++++-- src/py4D_browser/signals.py | 96 ++++++++++++++++++++++++ src/py4D_browser/update_views.py | 125 +++++++++++++++++++------------ 3 files changed, 195 insertions(+), 56 deletions(-) create mode 100644 src/py4D_browser/signals.py diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index 8d2564b..4a77861 100644 --- a/src/py4D_browser/main_window.py +++ b/src/py4D_browser/main_window.py @@ -65,6 +65,7 @@ class DataViewer(QMainWindow): _render_diffraction_image, update_diffraction_space_view, update_real_space_view, + update_fft_view, update_realspace_detector, update_diffraction_detector, set_diffraction_autoscale_range, @@ -76,7 +77,10 @@ class DataViewer(QMainWindow): update_tooltip, ) - from py4D_browser.signals import register_result_callback + from py4D_browser.signals import ( + register_result_callback, + set_internal_result_callback, + ) from py4D_browser.plugins import load_plugins @@ -102,6 +106,10 @@ def __init__(self, argv): self.datacube = None + self.unscaled_diffraction_image = None + self.unscaled_realspace_image = None + self.unscaled_fft_image = None + # Load settings from config file self.config_path = os.path.join( platformdirs.user_config_dir("py4DGUI", "py4DSTEM"), "GUI_config.ini" @@ -498,8 +506,8 @@ def setup_menus(self): self.result_source_action_group.setExclusive(True) img_fft_action = QAction("Virtual Image FFT", self) img_fft_action.setCheckable(True) + img_fft_action.triggered.connect(self.set_internal_result_callback) img_fft_action.setChecked(True) - img_fft_action.triggered.connect(partial(self.update_real_space_view, False)) self.result_menu.addAction(img_fft_action) self.result_source_action_group.addAction(img_fft_action) @@ -507,17 +515,23 @@ def setup_menus(self): img_complex_fft_action.setCheckable(True) self.result_menu.addAction(img_complex_fft_action) self.result_source_action_group.addAction(img_complex_fft_action) - img_complex_fft_action.triggered.connect( - partial(self.update_real_space_view, False) - ) + img_complex_fft_action.triggered.connect(self.set_internal_result_callback) img_ewpc_action = QAction("EWPC", self) img_ewpc_action.setCheckable(True) self.result_menu.addAction(img_ewpc_action) self.result_source_action_group.addAction(img_ewpc_action) - img_ewpc_action.triggered.connect( - partial(self.update_diffraction_space_view, False) - ) + img_ewpc_action.triggered.connect(self.set_internal_result_callback) + + # action only for information purposes, to show when a plugin has taken over + # the result window + self.result_other_action = QAction("Plugin", self) + self.result_other_action.setCheckable(True) + self.result_other_action.setEnabled(False) + self.result_menu.addAction(self.result_other_action) + self.result_source_action_group.addAction(self.result_other_action) + + self.set_internal_result_callback() # Plugins menu self.processing_menu = QMenu("&Plugins", self) diff --git a/src/py4D_browser/signals.py b/src/py4D_browser/signals.py new file mode 100644 index 0000000..bb56c09 --- /dev/null +++ b/src/py4D_browser/signals.py @@ -0,0 +1,96 @@ +from typing import Callable, Optional + +""" +Registration and marshalling for callbacks/signals +""" + +__all__ = ["register_result_callback", "set_internal_result_callback"] + +_registered_result_callbacks = { + "diffraction": None, + "virtual_image": None, + "datacube": None, +} + + +def register_result_callback( + self, + title: str, + callback_diffraction_pattern_changed: Optional[Callable] = None, + callback_virtual_image_changed: Optional[Callable] = None, + callback_datacube_changed: Optional[Callable] = None, +): + # Only one plugin should use these callbacks at a time, and the menu + # must be updated to indicate which plugin is actively recieving + # the signals. + self.result_other_action.setText(title) + self.fft_widget_text.setText(title) + self.result_other_action.setChecked(True) + + _replace_result_callbacks( + self, + callback_diffraction_pattern_changed=callback_diffraction_pattern_changed, + callback_virtual_image_changed=callback_virtual_image_changed, + callback_datacube_changed=callback_datacube_changed, + ) + + +def set_internal_result_callback(self): + # Set the callbacks back to the internal ones for FFT/EWPC + # and use the default renderer + + # TODO: In a future release, these actions will also + # be handed as a plugin, but new plumbing is required + # for plugins to add to this menu directly... + + self.result_other_action.setText("Plugin") + + _replace_result_callbacks( + self, + callback_diffraction_pattern_changed=self.update_fft_view, + callback_virtual_image_changed=self.update_fft_view, + callback_datacube_changed=None, + ) + + +def _replace_result_callbacks( + self, + callback_diffraction_pattern_changed: Optional[Callable] = None, + callback_virtual_image_changed: Optional[Callable] = None, + callback_datacube_changed: Optional[Callable] = None, +): + # unregister any previously set callbacks + if _registered_result_callbacks["diffraction"] is not None: + self.signal_diffraction_data_changed.disconnect( + _registered_result_callbacks["diffraction"] + ) + + if _registered_result_callbacks["virtual_image"] is not None: + self.signal_virtual_image_data_changed.disconnect( + _registered_result_callbacks["virtual_image"] + ) + + if _registered_result_callbacks["datacube"] is not None: + self.signal_datacube_changed.disconnect( + _registered_result_callbacks["datacube"] + ) + + # register any supplied callbacks + if callback_diffraction_pattern_changed is not None: + _registered_result_callbacks["diffraction"] = ( + self.signal_diffraction_data_changed.connect( + callback_diffraction_pattern_changed + ) + ) + + if callback_virtual_image_changed is not None: + _registered_result_callbacks["virtual_image"] = ( + self.signal_virtual_image_data_changed.connect( + callback_virtual_image_changed + ) + ) + + if callback_datacube_changed is not None: + _registered_result_callbacks["datacube"] = self.signal_datacube_changed.connect( + callback_datacube_changed + ) diff --git a/src/py4D_browser/update_views.py b/src/py4D_browser/update_views.py index 93f1b44..a2a36d4 100644 --- a/src/py4D_browser/update_views.py +++ b/src/py4D_browser/update_views.py @@ -1,3 +1,4 @@ +from typing import Optional import pyqtgraph as pg import numpy as np import py4DSTEM @@ -309,6 +310,7 @@ def update_real_space_view(self, reset=False): def set_virtual_image(self, vimg, reset=False): self.unscaled_realspace_image = vimg self._render_virtual_image(reset=reset) + self.signal_virtual_image_data_changed.emit() def _render_virtual_image(self, reset=False): @@ -364,53 +366,6 @@ def _render_virtual_image(self, reset=False): for t, m in zip(stats_text, self.realspace_statistics_actions): m.setText(t) - # Update FFT view - self.unscaled_fft_image = None - vimg_2D = vimg if np.isrealobj(vimg) else np.abs(vimg) - fft_window = ( - np.hanning(vimg_2D.shape[0])[:, None] * np.hanning(vimg_2D.shape[1])[None, :] - ) - if self.fft_source_action_group.checkedAction().text() == "Virtual Image FFT": - fft = np.abs(np.fft.fftshift(np.fft.fft2(vimg_2D * fft_window))) ** 0.5 - levels = (np.min(fft), np.percentile(fft, 99.9)) - mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" - self.fft_widget_text.setText("Virtual Image FFT") - self.fft_widget.setImage( - fft.T, autoLevels=False, levels=levels, autoRange=mode_switch - ) - self.fft_widget.getImageItem().setRect(0, 0, fft.shape[1], fft.shape[1]) - if mode_switch: - # Need to autorange after setRect - self.fft_widget.autoRange() - self.unscaled_fft_image = fft - elif ( - self.fft_source_action_group.checkedAction().text() - == "Virtual Image FFT (complex)" - ): - fft = np.fft.fftshift(np.fft.fft2(vimg_2D * fft_window)) - levels = (np.min(np.abs(fft)), np.percentile(np.abs(fft), 99.9)) - mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" - self.fft_widget_text.setText("Virtual Image FFT") - fft_img = complex_to_Lab( - fft.T, - amin=levels[0], - amax=levels[1], - ab_scale=128, - gamma=0.5, - ) - self.fft_widget.setImage( - fft_img, - autoLevels=False, - autoRange=mode_switch, - levels=(0, 1), - ) - - self.fft_widget.getImageItem().setRect(0, 0, fft.shape[1], fft.shape[1]) - if mode_switch: - # Need to autorange after setRect - self.fft_widget.autoRange() - self.unscaled_fft_image = fft - def update_diffraction_space_view(self, reset=False): if self.datacube is None: @@ -450,6 +405,7 @@ def update_diffraction_space_view(self, reset=False): def set_diffraction_image(self, DP, reset=False): self.unscaled_diffraction_image = DP self._render_diffraction_image(reset=reset) + self.signal_diffraction_data_changed.emit() def _render_diffraction_image(self, reset=False): @@ -494,7 +450,74 @@ def _render_diffraction_image(self, reset=False): autoRange=reset, ) - if self.fft_source_action_group.checkedAction().text() == "EWPC": + +def update_fft_view(self, mode: Optional[str] = None): + # called via signals when the Result menu has an internal option chosen + # TODO: architect this as a plugin as well! + + # This gets called when *any* internal option is picked and + # the mode is read from which menu item is selected + + mode = mode or self.result_source_action_group.checkedAction().text() + + # Update FFT view + self.unscaled_fft_image = None + + vimg = self.unscaled_realspace_image + DP = self.unscaled_diffraction_image + + if vimg is None or DP is None: + return + + if mode == "Virtual Image FFT": + vimg_2D = vimg if np.isrealobj(vimg) else np.abs(vimg) + fft_window = ( + np.hanning(vimg_2D.shape[0])[:, None] + * np.hanning(vimg_2D.shape[1])[None, :] + ) + + fft = np.abs(np.fft.fftshift(np.fft.fft2(vimg_2D * fft_window))) ** 0.5 + levels = (np.min(fft), np.percentile(fft, 99.9)) + mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" + self.fft_widget_text.setText("Virtual Image FFT") + self.fft_widget.setImage( + fft.T, autoLevels=False, levels=levels, autoRange=mode_switch + ) + self.fft_widget.getImageItem().setRect(0, 0, fft.shape[1], fft.shape[1]) + if mode_switch: + # Need to autorange after setRect + self.fft_widget.autoRange() + elif mode == "Virtual Image FFT (complex)": + vimg_2D = vimg if np.isrealobj(vimg) else np.abs(vimg) + fft_window = ( + np.hanning(vimg_2D.shape[0])[:, None] + * np.hanning(vimg_2D.shape[1])[None, :] + ) + + fft = np.fft.fftshift(np.fft.fft2(vimg_2D * fft_window)) + levels = (np.min(np.abs(fft)), np.percentile(np.abs(fft), 99.9)) + mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" + self.fft_widget_text.setText("Virtual Image FFT") + fft_img = complex_to_Lab( + fft.T, + amin=levels[0], + amax=levels[1], + ab_scale=128, + gamma=0.5, + ) + self.fft_widget.setImage( + fft_img, + autoLevels=False, + autoRange=mode_switch, + levels=(0, 1), + ) + + self.fft_widget.getImageItem().setRect(0, 0, fft.shape[1], fft.shape[1]) + if mode_switch: + # Need to autorange after setRect + self.fft_widget.autoRange() + + elif mode == "EWPC": log_clip = np.maximum(1e-10, np.percentile(np.maximum(DP, 0.0), 0.1)) fft = np.abs(np.fft.fftshift(np.fft.fft2(np.log(np.maximum(DP, log_clip))))) levels = (np.min(fft), np.percentile(fft, 99.9)) @@ -503,6 +526,12 @@ def _render_diffraction_image(self, reset=False): self.fft_widget.setImage( fft.T, autoLevels=False, levels=levels, autoRange=mode_switch ) + else: + raise RuntimeError( + f"The internal FFT view callback was triggered but {mode} is checked!" + ) + + self.unscaled_fft_image = fft def update_realspace_detector(self): From a55c7d6792005199d7b41f376aa8470993533196 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Mon, 20 Apr 2026 10:00:06 -0400 Subject: [PATCH 11/25] add result handler cleanup function --- src/py4D_browser/signals.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/py4D_browser/signals.py b/src/py4D_browser/signals.py index bb56c09..4e63b82 100644 --- a/src/py4D_browser/signals.py +++ b/src/py4D_browser/signals.py @@ -6,16 +6,18 @@ __all__ = ["register_result_callback", "set_internal_result_callback"] -_registered_result_callbacks = { +_registered_result_callbacks: dict[str, Optional[Callable]] = { "diffraction": None, "virtual_image": None, "datacube": None, + "cleanup": None, } def register_result_callback( self, title: str, + cleanup: Optional[Callable], callback_diffraction_pattern_changed: Optional[Callable] = None, callback_virtual_image_changed: Optional[Callable] = None, callback_datacube_changed: Optional[Callable] = None, @@ -23,12 +25,17 @@ def register_result_callback( # Only one plugin should use these callbacks at a time, and the menu # must be updated to indicate which plugin is actively recieving # the signals. + + # The cleanup function is called when the plugin/handler is removed, + # and should be used to restore the GUI to its original state (i.e. + # by removing any additional ROIs or windows). self.result_other_action.setText(title) self.fft_widget_text.setText(title) self.result_other_action.setChecked(True) _replace_result_callbacks( self, + cleanup=cleanup, callback_diffraction_pattern_changed=callback_diffraction_pattern_changed, callback_virtual_image_changed=callback_virtual_image_changed, callback_datacube_changed=callback_datacube_changed, @@ -55,6 +62,7 @@ def set_internal_result_callback(self): def _replace_result_callbacks( self, + cleanup: Optional[Callable] = None, callback_diffraction_pattern_changed: Optional[Callable] = None, callback_virtual_image_changed: Optional[Callable] = None, callback_datacube_changed: Optional[Callable] = None, @@ -64,16 +72,24 @@ def _replace_result_callbacks( self.signal_diffraction_data_changed.disconnect( _registered_result_callbacks["diffraction"] ) + _registered_result_callbacks["diffraction"] = None if _registered_result_callbacks["virtual_image"] is not None: self.signal_virtual_image_data_changed.disconnect( _registered_result_callbacks["virtual_image"] ) + _registered_result_callbacks["virtual_image"] = None if _registered_result_callbacks["datacube"] is not None: self.signal_datacube_changed.disconnect( _registered_result_callbacks["datacube"] ) + _registered_result_callbacks["datacube"] + + # call the cleanup function for the old plugin/handler + if _registered_result_callbacks["cleanup"] is not None: + _registered_result_callbacks["cleanup"]() + _registered_result_callbacks["cleanup"] = cleanup # register any supplied callbacks if callback_diffraction_pattern_changed is not None: From f4064240322b8c7ef8ebe64b1d1a18b61dca73c6 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Mon, 20 Apr 2026 16:11:58 -0400 Subject: [PATCH 12/25] add same scaling options for result window as main views --- src/py4D_browser/main_window.py | 80 +++++++++++++++++++++++ src/py4D_browser/menu_actions.py | 6 -- src/py4D_browser/signals.py | 6 +- src/py4D_browser/update_views.py | 109 +++++++++++++++++++++++++------ 4 files changed, 174 insertions(+), 27 deletions(-) diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index 4a77861..d58f990 100644 --- a/src/py4D_browser/main_window.py +++ b/src/py4D_browser/main_window.py @@ -59,10 +59,12 @@ class DataViewer(QMainWindow): from py4D_browser.update_views import ( set_virtual_image, set_diffraction_image, + set_result_image, get_diffraction_detector, get_virtual_image_detector, _render_virtual_image, _render_diffraction_image, + _render_result_image, update_diffraction_space_view, update_real_space_view, update_fft_view, @@ -70,6 +72,7 @@ class DataViewer(QMainWindow): update_diffraction_detector, set_diffraction_autoscale_range, set_real_space_autoscale_range, + set_result_autoscale_range, nudge_real_space_selector, nudge_diffraction_selector, update_annulus_pos, @@ -300,6 +303,43 @@ def setup_menus(self): vimg_scaling_group.addAction(vimg_scale_sqrt_action) self.scaling_menu.addAction(vimg_scale_sqrt_action) + self.scaling_menu.addSeparator() + + # Real space scaling + result_scaling_group = QActionGroup(self) + result_scaling_group.setExclusive(True) + self.result_scaling_group = result_scaling_group + + result_menu_separator = QAction("Result", self) + result_menu_separator.setDisabled(True) + self.scaling_menu.addAction(result_menu_separator) + + result_scale_linear_action = QAction("Linear", self) + self.result_scale_linear_action = result_scale_linear_action # Save this one! + result_scale_linear_action.setCheckable(True) + result_scale_linear_action.triggered.connect( + partial(self._render_result_image, True) + ) + result_scaling_group.addAction(result_scale_linear_action) + self.scaling_menu.addAction(result_scale_linear_action) + + result_scale_log_action = QAction("Log", self) + result_scale_log_action.setCheckable(True) + result_scale_log_action.triggered.connect( + partial(self._render_result_image, True) + ) + result_scaling_group.addAction(result_scale_log_action) + self.scaling_menu.addAction(result_scale_log_action) + + result_scale_sqrt_action = QAction("Square Root", self) + result_scale_sqrt_action.setCheckable(True) + result_scale_sqrt_action.setChecked(True) + result_scale_sqrt_action.triggered.connect( + partial(self._render_result_image, True) + ) + result_scaling_group.addAction(result_scale_sqrt_action) + self.scaling_menu.addAction(result_scale_sqrt_action) + # Autorange menu self.autorange_menu = QMenu("&Autorange", self) self.menu_bar.addMenu(self.autorange_menu) @@ -358,6 +398,36 @@ def setup_menus(self): action.setChecked(True) self.set_real_space_autoscale_range(scale_range, redraw=False) + ## + self.autorange_menu.addSeparator() + + result_autoscale_separator = QAction("Result", self) + result_autoscale_separator.setDisabled(True) + self.autorange_menu.addAction(result_autoscale_separator) + + result_range_group = QActionGroup(self) + result_range_group.setExclusive(True) + + scale_range_default = self.settings.value( + "last_state/result_autorange", [0.1, 99.9], type=float + ) + for scale_range in [(0, 100), (0.1, 99.9), (1, 99), (2, 98), (5, 95)]: + action = QAction(f"{scale_range[0]}% – {scale_range[1]}%", self) + result_range_group.addAction(action) + self.autorange_menu.addAction(action) + action.setCheckable(True) + action.triggered.connect( + partial(self.set_result_autoscale_range, scale_range) + ) + # set default + if ( + scale_range[0] == scale_range_default[0] + and scale_range[1] == scale_range_default[1] + ): + action.setChecked(True) + self.set_result_autoscale_range(scale_range, redraw=False) + ## + # Detector Response Menu self.detector_menu = QMenu("&Detector Response", self) self.menu_bar.addMenu(self.detector_menu) @@ -670,6 +740,7 @@ def setup_views(self): self.statusBar().addPermanentWidget(VLine()) self.statusBar().addPermanentWidget(self.real_space_view_text) self.statusBar().addPermanentWidget(VLine()) + self.diffraction_rescale_button = LatchingButton( "Autorange Diffraction", status_bar=self.statusBar(), @@ -679,6 +750,7 @@ def setup_views(self): self.diffraction_space_widget.autoLevels ) self.statusBar().addPermanentWidget(self.diffraction_rescale_button) + self.realspace_rescale_button = LatchingButton( "Autorange Virtual Image", status_bar=self.statusBar(), @@ -689,6 +761,14 @@ def setup_views(self): ) self.statusBar().addPermanentWidget(self.realspace_rescale_button) + self.result_rescale_button = LatchingButton( + "Autorange Result", + status_bar=self.statusBar(), + latched=True, + ) + self.result_rescale_button.activated.connect(self.fft_widget.autoLevels) + self.statusBar().addPermanentWidget(self.result_rescale_button) + def resizeEvent(self, event): # Store window size for next run self.settings.setValue("last_state/window_size", event.size()) diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py index 7007f3e..907bfa1 100644 --- a/src/py4D_browser/menu_actions.py +++ b/src/py4D_browser/menu_actions.py @@ -153,14 +153,8 @@ def update_scalebars(self): else r_units ) - self.fft_scale_bar.pixel_size = ( - 1.0 / self.datacube.calibration.get_R_pixel_size() / self.datacube.R_Ny - ) - self.fft_scale_bar.units = f"{self.datacube.calibration.get_R_pixel_units()}⁻¹" - self.diffraction_scale_bar.updateBar() self.real_space_scale_bar.updateBar() - self.fft_scale_bar.updateBar() def reshape_data(self): diff --git a/src/py4D_browser/signals.py b/src/py4D_browser/signals.py index 4e63b82..c564924 100644 --- a/src/py4D_browser/signals.py +++ b/src/py4D_browser/signals.py @@ -30,7 +30,7 @@ def register_result_callback( # and should be used to restore the GUI to its original state (i.e. # by removing any additional ROIs or windows). self.result_other_action.setText(title) - self.fft_widget_text.setText(title) + # self.fft_widget_text.setText(title) # this is handled in set_result_image now self.result_other_action.setChecked(True) _replace_result_callbacks( @@ -59,6 +59,8 @@ def set_internal_result_callback(self): callback_datacube_changed=None, ) + self.update_fft_view() + def _replace_result_callbacks( self, @@ -84,7 +86,7 @@ def _replace_result_callbacks( self.signal_datacube_changed.disconnect( _registered_result_callbacks["datacube"] ) - _registered_result_callbacks["datacube"] + _registered_result_callbacks["datacube"] = None # call the cleanup function for the old plugin/handler if _registered_result_callbacks["cleanup"] is not None: diff --git a/src/py4D_browser/update_views.py b/src/py4D_browser/update_views.py index a2a36d4..b47071d 100644 --- a/src/py4D_browser/update_views.py +++ b/src/py4D_browser/update_views.py @@ -479,9 +479,14 @@ def update_fft_view(self, mode: Optional[str] = None): fft = np.abs(np.fft.fftshift(np.fft.fft2(vimg_2D * fft_window))) ** 0.5 levels = (np.min(fft), np.percentile(fft, 99.9)) mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" - self.fft_widget_text.setText("Virtual Image FFT") - self.fft_widget.setImage( - fft.T, autoLevels=False, levels=levels, autoRange=mode_switch + self.set_result_image( + fft.T, + reset=mode_switch, + title="Virtual Image FFT", + pixel_size=( + 1.0 / self.datacube.calibration.get_R_pixel_size() / self.datacube.R_Ny + ), + pixel_units=f"{self.datacube.calibration.get_R_pixel_units()}⁻¹", ) self.fft_widget.getImageItem().setRect(0, 0, fft.shape[1], fft.shape[1]) if mode_switch: @@ -497,21 +502,15 @@ def update_fft_view(self, mode: Optional[str] = None): fft = np.fft.fftshift(np.fft.fft2(vimg_2D * fft_window)) levels = (np.min(np.abs(fft)), np.percentile(np.abs(fft), 99.9)) mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" - self.fft_widget_text.setText("Virtual Image FFT") - fft_img = complex_to_Lab( + self.set_result_image( fft.T, - amin=levels[0], - amax=levels[1], - ab_scale=128, - gamma=0.5, - ) - self.fft_widget.setImage( - fft_img, - autoLevels=False, - autoRange=mode_switch, - levels=(0, 1), + reset=mode_switch, + title="Virtual Image FFT", + pixel_size=( + 1.0 / self.datacube.calibration.get_R_pixel_size() / self.datacube.R_Ny + ), + pixel_units=f"{self.datacube.calibration.get_R_pixel_units()}⁻¹", ) - self.fft_widget.getImageItem().setRect(0, 0, fft.shape[1], fft.shape[1]) if mode_switch: # Need to autorange after setRect @@ -522,9 +521,14 @@ def update_fft_view(self, mode: Optional[str] = None): fft = np.abs(np.fft.fftshift(np.fft.fft2(np.log(np.maximum(DP, log_clip))))) levels = (np.min(fft), np.percentile(fft, 99.9)) mode_switch = self.fft_widget_text.textItem.toPlainText() != "EWPC" - self.fft_widget_text.setText("EWPC") - self.fft_widget.setImage( - fft.T, autoLevels=False, levels=levels, autoRange=mode_switch + self.set_result_image( + fft.T, + reset=mode_switch, + title="EWPC", + pixel_size=( + 1.0 / self.datacube.calibration.get_Q_pixel_size() / self.datacube.Q_Ny + ), + pixel_units=f"{self.datacube.calibration.get_Q_pixel_units()}⁻¹", ) else: raise RuntimeError( @@ -534,6 +538,65 @@ def update_fft_view(self, mode: Optional[str] = None): self.unscaled_fft_image = fft +def set_result_image(self, vimg, reset=False, pixel_size=1.0, pixel_units="", title=""): + self.unscaled_fft_image = vimg + self.fft_widget_text.setText(title) + self._render_result_image(reset=reset) + + self.fft_scale_bar.pixel_size = pixel_size + self.fft_scale_bar.units = pixel_units + self.fft_scale_bar.updateBar() + + +def _render_result_image(self, reset=False): + vimg = self.unscaled_fft_image + + # for 2D images, use the scaling set by the user + # for RGB (3D) images, always scale linear + if np.isrealobj(vimg): + scaling_mode = self.result_scaling_group.checkedAction().text().replace("&", "") + assert scaling_mode in ["Linear", "Log", "Square Root"], scaling_mode + + if scaling_mode == "Linear": + new_view = vimg.copy() + elif scaling_mode == "Log": + new_view = np.log2(np.maximum(vimg, self.LOG_SCALE_MIN_VALUE)) + elif scaling_mode == "Square Root": + new_view = np.sqrt(np.maximum(vimg, 0)) + else: + raise ValueError("Mode not recognized") + + auto_level = reset or self.result_rescale_button.latched + + self.fft_widget.setImage( + new_view.T, + autoLevels=False, + levels=( + ( + np.percentile(new_view, self.result_autoscale_percentiles[0]), + np.percentile(new_view, self.result_autoscale_percentiles[1]), + ) + if auto_level + else None + ), + autoRange=reset, + ) + else: + new_view = complex_to_Lab( + vimg, + amin=np.percentile(np.abs(vimg), self.result_autoscale_percentiles[0]), + amax=np.percentile(np.abs(vimg), self.result_autoscale_percentiles[1]), + ab_scale=128, + gamma=0.5, + ) + self.fft_widget.setImage( + np.transpose(new_view, (1, 0, 2)), # flip x/y but keep RGB ordering + autoLevels=False, + levels=(0, 1), + autoRange=reset, + ) + + def update_realspace_detector(self): # change the shape of the detector, then update the view @@ -743,6 +806,14 @@ def set_real_space_autoscale_range(self, percentiles, redraw=True): self._render_virtual_image(reset=False) +def set_result_autoscale_range(self, percentiles, redraw=True): + self.result_autoscale_percentiles = percentiles + self.settings.setValue("last_state/result_autorange", list(percentiles)) + + if redraw: + self._render_result_image(reset=False) + + def nudge_real_space_selector(self, dx, dy): if ( hasattr(self, "real_space_point_selector") From 446fbe3c7fe901a55b3482f3f610f493dff46576 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Mon, 20 Apr 2026 16:14:36 -0400 Subject: [PATCH 13/25] postinit mechanics for plugins --- src/py4D_browser/plugins.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/py4D_browser/plugins.py b/src/py4D_browser/plugins.py index 5c41886..297bbf4 100644 --- a/src/py4D_browser/plugins.py +++ b/src/py4D_browser/plugins.py @@ -71,12 +71,19 @@ def load_plugins(self): ), "menu": plugin_menu, "action": plugin_action, + "id": plugin_id, } ) except Exception as exc: print(f"Failed to load plugin.\n{exc}") print(traceback.print_exc()) + # run post-initialization so that plugins can see all other loaded plugins + for plugin_dict in self.loaded_plugins: + plugin = plugin_dict["plugin"] + if hasattr(plugin, "post_init"): + plugin.post_init(parent=self) + def unload_plugins(self): # NOTE: This is currently not actually called! @@ -84,7 +91,7 @@ def unload_plugins(self): plugin["plugin"].close() -class ExamplePlugin: +class py4DBrowserPlugin: # required for py4DGUI to recognize this as a plugin. plugin_id = "my.plugin.identifier" @@ -105,5 +112,11 @@ class ExamplePlugin: def __init__(self, parent, **kwargs): self.parent = parent + def post_init(self, parent, **kwargs): + # This is called after *all* plugins are loaded and __init__'ed + # to enabled to plugins to discover and hook into one another. + # ADDED IN v1.5.0 (currently called only with parent argument) + pass + def close(self): pass # perform any shutdown activities From 5759e4baf4ad8189a062bed5707d7450b3d5dae2 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Mon, 20 Apr 2026 16:42:17 -0400 Subject: [PATCH 14/25] fft view bugfixes --- src/py4D_browser/update_views.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/py4D_browser/update_views.py b/src/py4D_browser/update_views.py index b47071d..e1a4ed5 100644 --- a/src/py4D_browser/update_views.py +++ b/src/py4D_browser/update_views.py @@ -460,9 +460,6 @@ def update_fft_view(self, mode: Optional[str] = None): mode = mode or self.result_source_action_group.checkedAction().text() - # Update FFT view - self.unscaled_fft_image = None - vimg = self.unscaled_realspace_image DP = self.unscaled_diffraction_image @@ -480,7 +477,7 @@ def update_fft_view(self, mode: Optional[str] = None): levels = (np.min(fft), np.percentile(fft, 99.9)) mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" self.set_result_image( - fft.T, + fft, reset=mode_switch, title="Virtual Image FFT", pixel_size=( @@ -503,7 +500,7 @@ def update_fft_view(self, mode: Optional[str] = None): levels = (np.min(np.abs(fft)), np.percentile(np.abs(fft), 99.9)) mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" self.set_result_image( - fft.T, + fft, reset=mode_switch, title="Virtual Image FFT", pixel_size=( @@ -522,7 +519,7 @@ def update_fft_view(self, mode: Optional[str] = None): levels = (np.min(fft), np.percentile(fft, 99.9)) mode_switch = self.fft_widget_text.textItem.toPlainText() != "EWPC" self.set_result_image( - fft.T, + fft, reset=mode_switch, title="EWPC", pixel_size=( @@ -535,8 +532,6 @@ def update_fft_view(self, mode: Optional[str] = None): f"The internal FFT view callback was triggered but {mode} is checked!" ) - self.unscaled_fft_image = fft - def set_result_image(self, vimg, reset=False, pixel_size=1.0, pixel_units="", title=""): self.unscaled_fft_image = vimg From b53a306e9c13847f455ef6cb122d433fa73c7a08 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Mon, 20 Apr 2026 17:25:14 -0400 Subject: [PATCH 15/25] fftshift for complex FFT view --- src/py4D_browser/update_views.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/py4D_browser/update_views.py b/src/py4D_browser/update_views.py index e1a4ed5..052d162 100644 --- a/src/py4D_browser/update_views.py +++ b/src/py4D_browser/update_views.py @@ -474,7 +474,6 @@ def update_fft_view(self, mode: Optional[str] = None): ) fft = np.abs(np.fft.fftshift(np.fft.fft2(vimg_2D * fft_window))) ** 0.5 - levels = (np.min(fft), np.percentile(fft, 99.9)) mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" self.set_result_image( fft, @@ -496,8 +495,7 @@ def update_fft_view(self, mode: Optional[str] = None): * np.hanning(vimg_2D.shape[1])[None, :] ) - fft = np.fft.fftshift(np.fft.fft2(vimg_2D * fft_window)) - levels = (np.min(np.abs(fft)), np.percentile(np.abs(fft), 99.9)) + fft = np.fft.fftshift(np.fft.fft2(np.fft.fftshift(vimg_2D * fft_window))) mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" self.set_result_image( fft, @@ -516,7 +514,6 @@ def update_fft_view(self, mode: Optional[str] = None): elif mode == "EWPC": log_clip = np.maximum(1e-10, np.percentile(np.maximum(DP, 0.0), 0.1)) fft = np.abs(np.fft.fftshift(np.fft.fft2(np.log(np.maximum(DP, log_clip))))) - levels = (np.min(fft), np.percentile(fft, 99.9)) mode_switch = self.fft_widget_text.textItem.toPlainText() != "EWPC" self.set_result_image( fft, From 875513fd5235929a0b6793e32c8b2ea29614751c Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Tue, 21 Apr 2026 10:31:19 -0400 Subject: [PATCH 16/25] actually emit datacube changed signal --- src/py4D_browser/menu_actions.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py index 907bfa1..3eab32a 100644 --- a/src/py4D_browser/menu_actions.py +++ b/src/py4D_browser/menu_actions.py @@ -49,15 +49,11 @@ def load_data_arina(self): self.real_space_scale_bar.pixel_size = self.datacube.calibration.get_R_pixel_size() self.real_space_scale_bar.units = self.datacube.calibration.get_R_pixel_units() - self.fft_scale_bar.pixel_size = ( - 1.0 / self.datacube.calibration.get_R_pixel_size() / self.datacube.R_Ny - ) - self.fft_scale_bar.units = f"1/{self.datacube.calibration.get_R_pixel_units()}" - self.update_diffraction_space_view(reset=True) self.update_real_space_view(reset=True) self.setWindowTitle(filename) + self.signal_datacube_changed.emit() def load_file(self, filepath, mmap=False, binning=1): @@ -115,6 +111,7 @@ def load_file(self, filepath, mmap=False, binning=1): self.update_real_space_view(reset=True) self.setWindowTitle(filepath) + self.signal_datacube_changed.emit() def set_datacube(self, datacube, window_title): @@ -126,6 +123,7 @@ def set_datacube(self, datacube, window_title): self.update_real_space_view(reset=True) self.setWindowTitle(window_title) + self.signal_datacube_changed.emit() def update_scalebars(self): From a1f422bce646e9ae0f4a861c5006b5f2cb5c74a1 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Thu, 23 Apr 2026 12:00:05 -0400 Subject: [PATCH 17/25] various static analysis changes --- src/py4D_browser/dialogs.py | 15 +--------- src/py4D_browser/help_menu.py | 2 +- src/py4D_browser/main_window.py | 16 +++++------ src/py4D_browser/menu_actions.py | 35 +++++++++++++---------- src/py4D_browser/plugins.py | 9 ++++-- src/py4D_browser/signals.py | 11 ++++--- src/py4D_browser/update_views.py | 49 ++++++++++++++++++-------------- 7 files changed, 72 insertions(+), 65 deletions(-) diff --git a/src/py4D_browser/dialogs.py b/src/py4D_browser/dialogs.py index 399528f..1573896 100644 --- a/src/py4D_browser/dialogs.py +++ b/src/py4D_browser/dialogs.py @@ -1,23 +1,10 @@ -from py4DSTEM import DataCube, data -import pyqtgraph as pg -import numpy as np -from tqdm import tqdm -from PyQt5.QtWidgets import QFrame, QPushButton, QApplication, QLabel -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtCore import Qt, QObject -from PyQt5.QtGui import QDoubleValidator +from PyQt5.QtWidgets import QPushButton, QLabel from PyQt5.QtWidgets import ( QDialog, QHBoxLayout, QVBoxLayout, QSpinBox, - QLineEdit, - QComboBox, - QGroupBox, - QGridLayout, - QCheckBox, ) -from py4D_browser.utils import make_detector, StatusBarWriter class ResizeDialog(QDialog): diff --git a/src/py4D_browser/help_menu.py b/src/py4D_browser/help_menu.py index ad3abeb..9e5c7dd 100644 --- a/src/py4D_browser/help_menu.py +++ b/src/py4D_browser/help_menu.py @@ -1,4 +1,4 @@ -from PyQt5 import QtGui, QtCore +from PyQt5 import QtGui from PyQt5.QtWidgets import ( QWidget, QDialog, diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index d58f990..9535ba2 100644 --- a/src/py4D_browser/main_window.py +++ b/src/py4D_browser/main_window.py @@ -1,3 +1,4 @@ +from typing import Optional from PyQt5 import QtCore, QtGui from PyQt5.QtWidgets import ( QApplication, @@ -14,18 +15,17 @@ QShortcut, ) -from matplotlib.backend_bases import tools +from py4DSTEM import DataCube import pyqtgraph as pg import numpy as np from functools import partial from pathlib import Path -import importlib -import os, sys +import os import platformdirs from showinfm import show_in_file_manager -from py4D_browser.utils import pg_point_roi, VLine, LatchingButton +from py4D_browser.utils import VLine, LatchingButton from py4D_browser.scalebar import ScaleBar @@ -107,11 +107,11 @@ def __init__(self, argv): self.setWindowTitle("py4DSTEM") self.setAcceptDrops(True) - self.datacube = None + self.datacube: Optional[DataCube] = None - self.unscaled_diffraction_image = None - self.unscaled_realspace_image = None - self.unscaled_fft_image = None + self.unscaled_diffraction_image: Optional[np.ndarray] = None + self.unscaled_realspace_image: Optional[np.ndarray] = None + self.unscaled_fft_image: Optional[np.ndarray] = None # Load settings from config file self.config_path = os.path.join( diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py index 3eab32a..ae68692 100644 --- a/src/py4D_browser/menu_actions.py +++ b/src/py4D_browser/menu_actions.py @@ -8,24 +8,29 @@ from py4D_browser.dialogs import ResizeDialog from py4DSTEM.io.filereaders import read_arina +from typing import TYPE_CHECKING -def load_data_auto(self): +if TYPE_CHECKING: + from py4D_browser import DataViewer + + +def load_data_auto(self: DataViewer): filename = self.show_file_dialog() self.load_file(filename) -def load_data_mmap(self): +def load_data_mmap(self: DataViewer): filename = self.show_file_dialog() self.load_file(filename, mmap=True) -def load_data_bin(self): +def load_data_bin(self: DataViewer): # TODO: Ask user for binning level filename = self.show_file_dialog() self.load_file(filename, mmap=False, binning=4) -def load_data_arina(self): +def load_data_arina(self: DataViewer): filename = self.show_file_dialog() dataset = read_arina(filename) @@ -56,7 +61,7 @@ def load_data_arina(self): self.signal_datacube_changed.emit() -def load_file(self, filepath, mmap=False, binning=1): +def load_file(self: DataViewer, filepath, mmap=False, binning=1): print(f"Loading file {filepath}") extension = os.path.splitext(filepath)[-1].lower() print(f"Type: {extension}") @@ -114,7 +119,7 @@ def load_file(self, filepath, mmap=False, binning=1): self.signal_datacube_changed.emit() -def set_datacube(self, datacube, window_title): +def set_datacube(self: DataViewer, datacube, window_title): self.datacube = datacube self.update_scalebars() @@ -126,7 +131,7 @@ def set_datacube(self, datacube, window_title): self.signal_datacube_changed.emit() -def update_scalebars(self): +def update_scalebars(self: DataViewer): realspace_translation = { "A": "Å", @@ -155,7 +160,7 @@ def update_scalebars(self): self.real_space_scale_bar.updateBar() -def reshape_data(self): +def reshape_data(self: DataViewer): new_shape = ResizeDialog.get_new_size(self.datacube.shape[:2], parent=self) self.datacube.data = self.datacube.data.reshape( *new_shape, *self.datacube.data.shape[2:] @@ -167,7 +172,7 @@ def reshape_data(self): self.update_real_space_view(reset=True) -def export_datacube(self, save_format: str): +def export_datacube(self: DataViewer, save_format: str): assert save_format in [ "Raw float32", "py4DSTEM HDF5", @@ -221,7 +226,7 @@ def export_datacube(self, save_format: str): raise exc -def export_virtual_image(self, im_format: str, im_type: str): +def export_virtual_image(self: DataViewer, im_format: str, im_type: str): assert im_type in ["image", "diffraction"], f"bad image type: {im_type}" filename = self.get_savefile_name(im_format) @@ -255,7 +260,7 @@ def export_virtual_image(self, im_format: str, im_type: str): raise RuntimeError("Nothing saved! Format not recognized") -def copy_vimg_to_clipboard(self): +def copy_vimg_to_clipboard(self: DataViewer): img = self.real_space_widget.getImageItem() if img._renderRequired: @@ -264,7 +269,7 @@ def copy_vimg_to_clipboard(self): QApplication.clipboard().setImage(img.qimage) -def copy_diff_to_clipboard(self): +def copy_diff_to_clipboard(self: DataViewer): img = self.diffraction_space_widget.getImageItem() if img._renderRequired: @@ -273,12 +278,12 @@ def copy_diff_to_clipboard(self): QApplication.clipboard().setImage(img.qimage) -def show_keyboard_map(self): +def show_keyboard_map(self: DataViewer): keymap = KeyboardMapMenu(parent=self) keymap.open() -def show_file_dialog(self) -> str: +def show_file_dialog(self: DataViewer) -> str: filename = QFileDialog.getOpenFileName( self, "Open 4D-STEM Data", @@ -292,7 +297,7 @@ def show_file_dialog(self) -> str: raise ValueError("Could not read file") -def get_savefile_name(self, file_format) -> str: +def get_savefile_name(self: DataViewer, file_format) -> str: filters = { "Raw float32": "RAW File (*.raw *.f32);;Any file (*)", "py4DSTEM HDF5": "HDF5 File (*.hdf5 *.h5 *.emd *.py4dstem);;Any file (*)", diff --git a/src/py4D_browser/plugins.py b/src/py4D_browser/plugins.py index 297bbf4..36eec42 100644 --- a/src/py4D_browser/plugins.py +++ b/src/py4D_browser/plugins.py @@ -5,10 +5,15 @@ from PyQt5.QtWidgets import QMenu, QAction +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from py4D_browser import DataViewer + __all__ = ["load_plugins", "unload_plugins"] -def load_plugins(self): +def load_plugins(self: DataViewer): """ The py4D_browser plugin mechanics are inspired by Nion Swift: https://nionswift.readthedocs.io/en/stable/api/plugins.html @@ -85,7 +90,7 @@ def load_plugins(self): plugin.post_init(parent=self) -def unload_plugins(self): +def unload_plugins(self: DataViewer): # NOTE: This is currently not actually called! for plugin in self.loaded_plugins: plugin["plugin"].close() diff --git a/src/py4D_browser/signals.py b/src/py4D_browser/signals.py index c564924..1f01bc5 100644 --- a/src/py4D_browser/signals.py +++ b/src/py4D_browser/signals.py @@ -1,4 +1,7 @@ -from typing import Callable, Optional +from typing import Callable, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from py4D_browser import DataViewer """ Registration and marshalling for callbacks/signals @@ -15,7 +18,7 @@ def register_result_callback( - self, + self: DataViewer, title: str, cleanup: Optional[Callable], callback_diffraction_pattern_changed: Optional[Callable] = None, @@ -42,7 +45,7 @@ def register_result_callback( ) -def set_internal_result_callback(self): +def set_internal_result_callback(self: DataViewer): # Set the callbacks back to the internal ones for FFT/EWPC # and use the default renderer @@ -63,7 +66,7 @@ def set_internal_result_callback(self): def _replace_result_callbacks( - self, + self: DataViewer, cleanup: Optional[Callable] = None, callback_diffraction_pattern_changed: Optional[Callable] = None, callback_virtual_image_changed: Optional[Callable] = None, diff --git a/src/py4D_browser/update_views.py b/src/py4D_browser/update_views.py index 052d162..790c6a6 100644 --- a/src/py4D_browser/update_views.py +++ b/src/py4D_browser/update_views.py @@ -23,8 +23,13 @@ PointGeometry, ) +from typing import TYPE_CHECKING -def get_diffraction_detector(self) -> DetectorInfo: +if TYPE_CHECKING: + from py4D_browser import DataViewer + + +def get_diffraction_detector(self: DataViewer) -> DetectorInfo: """ Get the current detector and its position on the diffraction view. Returns a DetectorInfo dictionary, which contains the shape and @@ -122,7 +127,7 @@ def get_diffraction_detector(self) -> DetectorInfo: raise ValueError("Detector could not be determined") -def get_virtual_image_detector(self) -> DetectorInfo: +def get_virtual_image_detector(self: DataViewer) -> DetectorInfo: """ Get the current detector and its position on the diffraction view. Returns a DetectorInfo dictionary, which contains the shape and @@ -178,7 +183,7 @@ def get_virtual_image_detector(self) -> DetectorInfo: raise ValueError("Detector could not be determined") -def update_real_space_view(self, reset=False): +def update_real_space_view(self: DataViewer, reset=False): if self.datacube is None: return @@ -307,13 +312,13 @@ def update_real_space_view(self, reset=False): self.set_virtual_image(vimg, reset=reset) -def set_virtual_image(self, vimg, reset=False): +def set_virtual_image(self: DataViewer, vimg, reset=False): self.unscaled_realspace_image = vimg self._render_virtual_image(reset=reset) self.signal_virtual_image_data_changed.emit() -def _render_virtual_image(self, reset=False): +def _render_virtual_image(self: DataViewer, reset=False): vimg = self.unscaled_realspace_image # for 2D images, use the scaling set by the user @@ -367,7 +372,7 @@ def _render_virtual_image(self, reset=False): m.setText(t) -def update_diffraction_space_view(self, reset=False): +def update_diffraction_space_view(self: DataViewer, reset=False): if self.datacube is None: return @@ -402,13 +407,13 @@ def update_diffraction_space_view(self, reset=False): self.set_diffraction_image(DP, reset=reset) -def set_diffraction_image(self, DP, reset=False): +def set_diffraction_image(self: DataViewer, DP, reset=False): self.unscaled_diffraction_image = DP self._render_diffraction_image(reset=reset) self.signal_diffraction_data_changed.emit() -def _render_diffraction_image(self, reset=False): +def _render_diffraction_image(self: DataViewer, reset=False): DP = self.unscaled_diffraction_image scaling_mode = self.diff_scaling_group.checkedAction().text().replace("&", "") @@ -451,7 +456,7 @@ def _render_diffraction_image(self, reset=False): ) -def update_fft_view(self, mode: Optional[str] = None): +def update_fft_view(self: DataViewer, mode: Optional[str] = None): # called via signals when the Result menu has an internal option chosen # TODO: architect this as a plugin as well! @@ -530,7 +535,9 @@ def update_fft_view(self, mode: Optional[str] = None): ) -def set_result_image(self, vimg, reset=False, pixel_size=1.0, pixel_units="", title=""): +def set_result_image( + self: DataViewer, vimg, reset=False, pixel_size=1.0, pixel_units="", title="" +): self.unscaled_fft_image = vimg self.fft_widget_text.setText(title) self._render_result_image(reset=reset) @@ -540,7 +547,7 @@ def set_result_image(self, vimg, reset=False, pixel_size=1.0, pixel_units="", ti self.fft_scale_bar.updateBar() -def _render_result_image(self, reset=False): +def _render_result_image(self: DataViewer, reset=False): vimg = self.unscaled_fft_image # for 2D images, use the scaling set by the user @@ -589,7 +596,7 @@ def _render_result_image(self, reset=False): ) -def update_realspace_detector(self): +def update_realspace_detector(self: DataViewer): # change the shape of the detector, then update the view detector_shape = ( @@ -651,7 +658,7 @@ def update_realspace_detector(self): self.update_diffraction_space_view(reset=True) -def update_diffraction_detector(self): +def update_diffraction_detector(self: DataViewer): # change the shape of the detector, then update the view detector_shape = self.detector_shape_group.checkedAction().text().strip("&") @@ -782,7 +789,7 @@ def update_diffraction_detector(self): self.update_real_space_view(reset=True) -def set_diffraction_autoscale_range(self, percentiles, redraw=True): +def set_diffraction_autoscale_range(self: DataViewer, percentiles, redraw=True): self.diffraction_autoscale_percentiles = percentiles self.settings.setValue("last_state/diffraction_autorange", list(percentiles)) @@ -790,7 +797,7 @@ def set_diffraction_autoscale_range(self, percentiles, redraw=True): self._render_diffraction_image(reset=False) -def set_real_space_autoscale_range(self, percentiles, redraw=True): +def set_real_space_autoscale_range(self: DataViewer, percentiles, redraw=True): self.real_space_autoscale_percentiles = percentiles self.settings.setValue("last_state/realspace_autorange", list(percentiles)) @@ -798,7 +805,7 @@ def set_real_space_autoscale_range(self, percentiles, redraw=True): self._render_virtual_image(reset=False) -def set_result_autoscale_range(self, percentiles, redraw=True): +def set_result_autoscale_range(self: DataViewer, percentiles, redraw=True): self.result_autoscale_percentiles = percentiles self.settings.setValue("last_state/result_autorange", list(percentiles)) @@ -806,7 +813,7 @@ def set_result_autoscale_range(self, percentiles, redraw=True): self._render_result_image(reset=False) -def nudge_real_space_selector(self, dx, dy): +def nudge_real_space_selector(self: DataViewer, dx, dy): if ( hasattr(self, "real_space_point_selector") and self.real_space_point_selector is not None @@ -827,7 +834,7 @@ def nudge_real_space_selector(self, dx, dy): selector.setPos(position) -def nudge_diffraction_selector(self, dx, dy): +def nudge_diffraction_selector(self: DataViewer, dx, dy): if ( hasattr(self, "virtual_detector_point") and self.virtual_detector_point is not None @@ -852,7 +859,7 @@ def nudge_diffraction_selector(self, dx, dy): selector.setPos(position) -def update_tooltip(self): +def update_tooltip(self: DataViewer): modifier_keys = QApplication.queryKeyboardModifiers() if self.datacube is not None and self.isActiveWindow(): @@ -878,7 +885,7 @@ def update_tooltip(self): self.cursor_value_text.setText(display_text) -def update_annulus_pos(self): +def update_annulus_pos(self: DataViewer): """ Function to keep inner and outer rings of annulus aligned. """ @@ -890,7 +897,7 @@ def update_annulus_pos(self): self.virtual_detector_roi_inner.setPos(x0 - R_inner, y0 - R_inner, update=False) -def update_annulus_radii(self): +def update_annulus_radii(self: DataViewer): R_outer = self.virtual_detector_roi_outer.size().x() / 2 R_inner = self.virtual_detector_roi_inner.size().x() / 2 if R_outer < R_inner: From 8e6dea06499a36abdbf6b0d7c864f0f2d50f2849 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Thu, 23 Apr 2026 12:10:40 -0400 Subject: [PATCH 18/25] quote the static-only imports --- src/py4D_browser/menu_actions.py | 30 +++++++++++------------ src/py4D_browser/plugins.py | 4 +-- src/py4D_browser/signals.py | 6 ++--- src/py4D_browser/update_views.py | 42 ++++++++++++++++---------------- 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py index ae68692..ea35f14 100644 --- a/src/py4D_browser/menu_actions.py +++ b/src/py4D_browser/menu_actions.py @@ -14,23 +14,23 @@ from py4D_browser import DataViewer -def load_data_auto(self: DataViewer): +def load_data_auto(self: "DataViewer"): filename = self.show_file_dialog() self.load_file(filename) -def load_data_mmap(self: DataViewer): +def load_data_mmap(self: "DataViewer"): filename = self.show_file_dialog() self.load_file(filename, mmap=True) -def load_data_bin(self: DataViewer): +def load_data_bin(self: "DataViewer"): # TODO: Ask user for binning level filename = self.show_file_dialog() self.load_file(filename, mmap=False, binning=4) -def load_data_arina(self: DataViewer): +def load_data_arina(self: "DataViewer"): filename = self.show_file_dialog() dataset = read_arina(filename) @@ -61,7 +61,7 @@ def load_data_arina(self: DataViewer): self.signal_datacube_changed.emit() -def load_file(self: DataViewer, filepath, mmap=False, binning=1): +def load_file(self: "DataViewer", filepath, mmap=False, binning=1): print(f"Loading file {filepath}") extension = os.path.splitext(filepath)[-1].lower() print(f"Type: {extension}") @@ -119,7 +119,7 @@ def load_file(self: DataViewer, filepath, mmap=False, binning=1): self.signal_datacube_changed.emit() -def set_datacube(self: DataViewer, datacube, window_title): +def set_datacube(self: "DataViewer", datacube, window_title): self.datacube = datacube self.update_scalebars() @@ -131,7 +131,7 @@ def set_datacube(self: DataViewer, datacube, window_title): self.signal_datacube_changed.emit() -def update_scalebars(self: DataViewer): +def update_scalebars(self: "DataViewer"): realspace_translation = { "A": "Å", @@ -160,7 +160,7 @@ def update_scalebars(self: DataViewer): self.real_space_scale_bar.updateBar() -def reshape_data(self: DataViewer): +def reshape_data(self: "DataViewer"): new_shape = ResizeDialog.get_new_size(self.datacube.shape[:2], parent=self) self.datacube.data = self.datacube.data.reshape( *new_shape, *self.datacube.data.shape[2:] @@ -172,7 +172,7 @@ def reshape_data(self: DataViewer): self.update_real_space_view(reset=True) -def export_datacube(self: DataViewer, save_format: str): +def export_datacube(self: "DataViewer", save_format: str): assert save_format in [ "Raw float32", "py4DSTEM HDF5", @@ -226,7 +226,7 @@ def export_datacube(self: DataViewer, save_format: str): raise exc -def export_virtual_image(self: DataViewer, im_format: str, im_type: str): +def export_virtual_image(self: "DataViewer", im_format: str, im_type: str): assert im_type in ["image", "diffraction"], f"bad image type: {im_type}" filename = self.get_savefile_name(im_format) @@ -260,7 +260,7 @@ def export_virtual_image(self: DataViewer, im_format: str, im_type: str): raise RuntimeError("Nothing saved! Format not recognized") -def copy_vimg_to_clipboard(self: DataViewer): +def copy_vimg_to_clipboard(self: "DataViewer"): img = self.real_space_widget.getImageItem() if img._renderRequired: @@ -269,7 +269,7 @@ def copy_vimg_to_clipboard(self: DataViewer): QApplication.clipboard().setImage(img.qimage) -def copy_diff_to_clipboard(self: DataViewer): +def copy_diff_to_clipboard(self: "DataViewer"): img = self.diffraction_space_widget.getImageItem() if img._renderRequired: @@ -278,12 +278,12 @@ def copy_diff_to_clipboard(self: DataViewer): QApplication.clipboard().setImage(img.qimage) -def show_keyboard_map(self: DataViewer): +def show_keyboard_map(self: "DataViewer"): keymap = KeyboardMapMenu(parent=self) keymap.open() -def show_file_dialog(self: DataViewer) -> str: +def show_file_dialog(self: "DataViewer") -> str: filename = QFileDialog.getOpenFileName( self, "Open 4D-STEM Data", @@ -297,7 +297,7 @@ def show_file_dialog(self: DataViewer) -> str: raise ValueError("Could not read file") -def get_savefile_name(self: DataViewer, file_format) -> str: +def get_savefile_name(self: "DataViewer", file_format) -> str: filters = { "Raw float32": "RAW File (*.raw *.f32);;Any file (*)", "py4DSTEM HDF5": "HDF5 File (*.hdf5 *.h5 *.emd *.py4dstem);;Any file (*)", diff --git a/src/py4D_browser/plugins.py b/src/py4D_browser/plugins.py index 36eec42..8b7bea9 100644 --- a/src/py4D_browser/plugins.py +++ b/src/py4D_browser/plugins.py @@ -13,7 +13,7 @@ __all__ = ["load_plugins", "unload_plugins"] -def load_plugins(self: DataViewer): +def load_plugins(self: "DataViewer"): """ The py4D_browser plugin mechanics are inspired by Nion Swift: https://nionswift.readthedocs.io/en/stable/api/plugins.html @@ -90,7 +90,7 @@ def load_plugins(self: DataViewer): plugin.post_init(parent=self) -def unload_plugins(self: DataViewer): +def unload_plugins(self: "DataViewer"): # NOTE: This is currently not actually called! for plugin in self.loaded_plugins: plugin["plugin"].close() diff --git a/src/py4D_browser/signals.py b/src/py4D_browser/signals.py index 1f01bc5..9796d67 100644 --- a/src/py4D_browser/signals.py +++ b/src/py4D_browser/signals.py @@ -18,7 +18,7 @@ def register_result_callback( - self: DataViewer, + self: "DataViewer", title: str, cleanup: Optional[Callable], callback_diffraction_pattern_changed: Optional[Callable] = None, @@ -45,7 +45,7 @@ def register_result_callback( ) -def set_internal_result_callback(self: DataViewer): +def set_internal_result_callback(self: "DataViewer"): # Set the callbacks back to the internal ones for FFT/EWPC # and use the default renderer @@ -66,7 +66,7 @@ def set_internal_result_callback(self: DataViewer): def _replace_result_callbacks( - self: DataViewer, + self: "DataViewer", cleanup: Optional[Callable] = None, callback_diffraction_pattern_changed: Optional[Callable] = None, callback_virtual_image_changed: Optional[Callable] = None, diff --git a/src/py4D_browser/update_views.py b/src/py4D_browser/update_views.py index 790c6a6..c3abd3c 100644 --- a/src/py4D_browser/update_views.py +++ b/src/py4D_browser/update_views.py @@ -29,7 +29,7 @@ from py4D_browser import DataViewer -def get_diffraction_detector(self: DataViewer) -> DetectorInfo: +def get_diffraction_detector(self: "DataViewer") -> DetectorInfo: """ Get the current detector and its position on the diffraction view. Returns a DetectorInfo dictionary, which contains the shape and @@ -127,7 +127,7 @@ def get_diffraction_detector(self: DataViewer) -> DetectorInfo: raise ValueError("Detector could not be determined") -def get_virtual_image_detector(self: DataViewer) -> DetectorInfo: +def get_virtual_image_detector(self: "DataViewer") -> DetectorInfo: """ Get the current detector and its position on the diffraction view. Returns a DetectorInfo dictionary, which contains the shape and @@ -183,7 +183,7 @@ def get_virtual_image_detector(self: DataViewer) -> DetectorInfo: raise ValueError("Detector could not be determined") -def update_real_space_view(self: DataViewer, reset=False): +def update_real_space_view(self: "DataViewer", reset=False): if self.datacube is None: return @@ -312,13 +312,13 @@ def update_real_space_view(self: DataViewer, reset=False): self.set_virtual_image(vimg, reset=reset) -def set_virtual_image(self: DataViewer, vimg, reset=False): +def set_virtual_image(self: "DataViewer", vimg, reset=False): self.unscaled_realspace_image = vimg self._render_virtual_image(reset=reset) self.signal_virtual_image_data_changed.emit() -def _render_virtual_image(self: DataViewer, reset=False): +def _render_virtual_image(self: "DataViewer", reset=False): vimg = self.unscaled_realspace_image # for 2D images, use the scaling set by the user @@ -372,7 +372,7 @@ def _render_virtual_image(self: DataViewer, reset=False): m.setText(t) -def update_diffraction_space_view(self: DataViewer, reset=False): +def update_diffraction_space_view(self: "DataViewer", reset=False): if self.datacube is None: return @@ -407,13 +407,13 @@ def update_diffraction_space_view(self: DataViewer, reset=False): self.set_diffraction_image(DP, reset=reset) -def set_diffraction_image(self: DataViewer, DP, reset=False): +def set_diffraction_image(self: "DataViewer", DP, reset=False): self.unscaled_diffraction_image = DP self._render_diffraction_image(reset=reset) self.signal_diffraction_data_changed.emit() -def _render_diffraction_image(self: DataViewer, reset=False): +def _render_diffraction_image(self: "DataViewer", reset=False): DP = self.unscaled_diffraction_image scaling_mode = self.diff_scaling_group.checkedAction().text().replace("&", "") @@ -456,7 +456,7 @@ def _render_diffraction_image(self: DataViewer, reset=False): ) -def update_fft_view(self: DataViewer, mode: Optional[str] = None): +def update_fft_view(self: "DataViewer", mode: Optional[str] = None): # called via signals when the Result menu has an internal option chosen # TODO: architect this as a plugin as well! @@ -536,7 +536,7 @@ def update_fft_view(self: DataViewer, mode: Optional[str] = None): def set_result_image( - self: DataViewer, vimg, reset=False, pixel_size=1.0, pixel_units="", title="" + self: "DataViewer", vimg, reset=False, pixel_size=1.0, pixel_units="", title="" ): self.unscaled_fft_image = vimg self.fft_widget_text.setText(title) @@ -547,7 +547,7 @@ def set_result_image( self.fft_scale_bar.updateBar() -def _render_result_image(self: DataViewer, reset=False): +def _render_result_image(self: "DataViewer", reset=False): vimg = self.unscaled_fft_image # for 2D images, use the scaling set by the user @@ -596,7 +596,7 @@ def _render_result_image(self: DataViewer, reset=False): ) -def update_realspace_detector(self: DataViewer): +def update_realspace_detector(self: "DataViewer"): # change the shape of the detector, then update the view detector_shape = ( @@ -658,7 +658,7 @@ def update_realspace_detector(self: DataViewer): self.update_diffraction_space_view(reset=True) -def update_diffraction_detector(self: DataViewer): +def update_diffraction_detector(self: "DataViewer"): # change the shape of the detector, then update the view detector_shape = self.detector_shape_group.checkedAction().text().strip("&") @@ -789,7 +789,7 @@ def update_diffraction_detector(self: DataViewer): self.update_real_space_view(reset=True) -def set_diffraction_autoscale_range(self: DataViewer, percentiles, redraw=True): +def set_diffraction_autoscale_range(self: "DataViewer", percentiles, redraw=True): self.diffraction_autoscale_percentiles = percentiles self.settings.setValue("last_state/diffraction_autorange", list(percentiles)) @@ -797,7 +797,7 @@ def set_diffraction_autoscale_range(self: DataViewer, percentiles, redraw=True): self._render_diffraction_image(reset=False) -def set_real_space_autoscale_range(self: DataViewer, percentiles, redraw=True): +def set_real_space_autoscale_range(self: "DataViewer", percentiles, redraw=True): self.real_space_autoscale_percentiles = percentiles self.settings.setValue("last_state/realspace_autorange", list(percentiles)) @@ -805,7 +805,7 @@ def set_real_space_autoscale_range(self: DataViewer, percentiles, redraw=True): self._render_virtual_image(reset=False) -def set_result_autoscale_range(self: DataViewer, percentiles, redraw=True): +def set_result_autoscale_range(self: "DataViewer", percentiles, redraw=True): self.result_autoscale_percentiles = percentiles self.settings.setValue("last_state/result_autorange", list(percentiles)) @@ -813,7 +813,7 @@ def set_result_autoscale_range(self: DataViewer, percentiles, redraw=True): self._render_result_image(reset=False) -def nudge_real_space_selector(self: DataViewer, dx, dy): +def nudge_real_space_selector(self: "DataViewer", dx, dy): if ( hasattr(self, "real_space_point_selector") and self.real_space_point_selector is not None @@ -834,7 +834,7 @@ def nudge_real_space_selector(self: DataViewer, dx, dy): selector.setPos(position) -def nudge_diffraction_selector(self: DataViewer, dx, dy): +def nudge_diffraction_selector(self: "DataViewer", dx, dy): if ( hasattr(self, "virtual_detector_point") and self.virtual_detector_point is not None @@ -859,7 +859,7 @@ def nudge_diffraction_selector(self: DataViewer, dx, dy): selector.setPos(position) -def update_tooltip(self: DataViewer): +def update_tooltip(self: "DataViewer"): modifier_keys = QApplication.queryKeyboardModifiers() if self.datacube is not None and self.isActiveWindow(): @@ -885,7 +885,7 @@ def update_tooltip(self: DataViewer): self.cursor_value_text.setText(display_text) -def update_annulus_pos(self: DataViewer): +def update_annulus_pos(self: "DataViewer"): """ Function to keep inner and outer rings of annulus aligned. """ @@ -897,7 +897,7 @@ def update_annulus_pos(self: DataViewer): self.virtual_detector_roi_inner.setPos(x0 - R_inner, y0 - R_inner, update=False) -def update_annulus_radii(self: DataViewer): +def update_annulus_radii(self: "DataViewer"): R_outer = self.virtual_detector_roi_outer.size().x() / 2 R_inner = self.virtual_detector_roi_inner.size().x() / 2 if R_outer < R_inner: From 171d11167b25d39c9f3b4b5f20cbfb623d177f50 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Thu, 23 Apr 2026 12:11:21 -0400 Subject: [PATCH 19/25] calibrate accelerating voltage --- .../calibration_plugin/calibration_plugin.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/py4d_browser_plugin/calibration_plugin/calibration_plugin.py b/src/py4d_browser_plugin/calibration_plugin/calibration_plugin.py index 9471489..e35d7ff 100644 --- a/src/py4d_browser_plugin/calibration_plugin/calibration_plugin.py +++ b/src/py4d_browser_plugin/calibration_plugin/calibration_plugin.py @@ -136,6 +136,18 @@ def __init__(self, datacube, parent, diffraction_selector_size=None): ) diff_right_layout.addWidget(self.diff_unit_box) + kV_box = QGroupBox("Energy") + layout.addWidget(kV_box) + kV_layout = QHBoxLayout() + kV_box.setLayout(kV_layout) + kV_left_layout = QGridLayout() + kV_layout.addLayout(kV_left_layout) + kV_left_layout.addWidget( + QLabel("Accelerating Voltage [kV]"), 0, 0, Qt.AlignRight + ) + self.kV_input = QLineEdit() + kV_left_layout.addWidget(self.kV_input, 0, 1) + button_layout = QHBoxLayout() button_layout.addStretch() cancel_button = QPushButton("Cancel") @@ -225,6 +237,13 @@ def set_and_close(self): translation[self.diff_unit_box.currentText()] ) + kV_text = self.kV_input.text() + if kV_text != "": + kV = float(kV_text) + self.datacube.calibration["voltage"] = kV + # note there is no canonical tag for voltage, so we are + # going to make our own key for it + self.parent.update_scalebars() print("New calibration") From 2ec677527b7cdc489b72d1cbf60b9bdc74d153b8 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Thu, 23 Apr 2026 12:19:16 -0400 Subject: [PATCH 20/25] more type hints --- .../calibration_plugin/calibration_plugin.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/py4d_browser_plugin/calibration_plugin/calibration_plugin.py b/src/py4d_browser_plugin/calibration_plugin/calibration_plugin.py index e35d7ff..a34e7a1 100644 --- a/src/py4d_browser_plugin/calibration_plugin/calibration_plugin.py +++ b/src/py4d_browser_plugin/calibration_plugin/calibration_plugin.py @@ -25,6 +25,11 @@ CircleGeometry, ) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from py4D_browser import DataViewer + class CalibrationPlugin(QWidget): @@ -34,7 +39,7 @@ class CalibrationPlugin(QWidget): uses_single_action = True display_name = "Calibrate..." - def __init__(self, parent, plugin_action, **kwargs): + def __init__(self, parent: "DataViewer", plugin_action, **kwargs): super().__init__() self.parent = parent From 14f693adbd07033a41cdd43b544a996967725936 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Tue, 28 Apr 2026 14:13:11 -0400 Subject: [PATCH 21/25] optional alternate logo --- src/py4D_browser/logo_alternate.png | Bin 0 -> 36438 bytes src/py4D_browser/main_window.py | 34 ++++++++++++++++------------ src/py4D_browser/utils.py | 15 ++++++++++++ 3 files changed, 35 insertions(+), 14 deletions(-) create mode 100644 src/py4D_browser/logo_alternate.png diff --git a/src/py4D_browser/logo_alternate.png b/src/py4D_browser/logo_alternate.png new file mode 100644 index 0000000000000000000000000000000000000000..fd89ad304a400c62d03753e99b76456f4c0b62cb GIT binary patch literal 36438 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

l8 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 U=-hpOZvX%Q07*qoM6N<$f=)%&tpET3 literal 0 HcmV?d00001 diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index 9535ba2..d06a3e2 100644 --- a/src/py4D_browser/main_window.py +++ b/src/py4D_browser/main_window.py @@ -25,7 +25,7 @@ import platformdirs from showinfm import show_in_file_manager -from py4D_browser.utils import VLine, LatchingButton +from py4D_browser.utils import VLine, LatchingButton, strtobool from py4D_browser.scalebar import ScaleBar @@ -98,9 +98,27 @@ def __init__(self, argv): if not self.qtapp: self.qtapp = QApplication(argv) + # Load settings from config file + self.config_path = os.path.join( + platformdirs.user_config_dir("py4DGUI", "py4DSTEM"), "GUI_config.ini" + ) + print(f"Loading configuration from {self.config_path}") + QtCore.QCoreApplication.setOrganizationName("py4DSTEM") + QtCore.QCoreApplication.setOrganizationDomain("py4DSTEM.com") + QtCore.QCoreApplication.setApplicationName("py4DGUI") + self.settings = QtCore.QSettings( + self.config_path, QtCore.QSettings.Format.IniFormat + ) + self.setWindowTitle("py4DSTEM") - icon = QtGui.QIcon(str(Path(__file__).parent.absolute() / "logo.png")) + alternate_logo = strtobool(self.settings.value("gui/quack", 0)) + icon = QtGui.QIcon( + str( + Path(__file__).parent.absolute() + / ("logo.png" if not alternate_logo else "logo_alternate.png") + ) + ) self.setWindowIcon(icon) self.qtapp.setWindowIcon(icon) @@ -113,18 +131,6 @@ def __init__(self, argv): self.unscaled_realspace_image: Optional[np.ndarray] = None self.unscaled_fft_image: Optional[np.ndarray] = None - # Load settings from config file - self.config_path = os.path.join( - platformdirs.user_config_dir("py4DGUI", "py4DSTEM"), "GUI_config.ini" - ) - print(f"Loading configuration from {self.config_path}") - QtCore.QCoreApplication.setOrganizationName("py4DSTEM") - QtCore.QCoreApplication.setOrganizationDomain("py4DSTEM.com") - QtCore.QCoreApplication.setApplicationName("py4DGUI") - self.settings = QtCore.QSettings( - self.config_path, QtCore.QSettings.Format.IniFormat - ) - # Reset stored state if so asked: if os.environ.get("PY4DGUI_RESET"): self.settings.remove("last_state") diff --git a/src/py4D_browser/utils.py b/src/py4D_browser/utils.py index 0fabc26..3993310 100644 --- a/src/py4D_browser/utils.py +++ b/src/py4D_browser/utils.py @@ -241,3 +241,18 @@ def complex_to_Lab( rgb = lab2rgb(Lab) return rgb + + +def strtobool(val): + """Convert a string representation of truth to true (1) or false (0). + True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values + are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if + 'val' is anything else. + """ + val = val.lower() + if val in ("y", "yes", "t", "true", "on", "1"): + return 1 + elif val in ("n", "no", "f", "false", "off", "0"): + return 0 + else: + raise ValueError("invalid truth value %r" % (val,)) From 3b4db70540433382e9317893731c93cf366b2b9b Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Tue, 28 Apr 2026 14:39:36 -0400 Subject: [PATCH 22/25] add export for result view --- src/py4D_browser/main_window.py | 12 ++++++++++++ src/py4D_browser/menu_actions.py | 32 ++++++++++++++++++++++---------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index d06a3e2..fd45bcc 100644 --- a/src/py4D_browser/main_window.py +++ b/src/py4D_browser/main_window.py @@ -54,6 +54,7 @@ class DataViewer(QMainWindow): update_scalebars, copy_vimg_to_clipboard, copy_diff_to_clipboard, + copy_result_to_clipboard, ) from py4D_browser.update_views import ( @@ -235,6 +236,17 @@ def setup_menus(self): partial(self.export_virtual_image, method, "diffraction") ) + result_export_menu = QMenu("Export Result", self) + self.file_menu.addMenu(result_export_menu) + menu_item = result_export_menu.addAction("To clipboard") + menu_item.triggered.connect(self.copy_result_to_clipboard) + menu_item.setShortcut(QtGui.QKeySequence("Ctrl+Shift+C")) + for method in ["PNG (display)", "TIFF (display)", "TIFF (raw)"]: + menu_item = result_export_menu.addAction(method) + menu_item.triggered.connect( + partial(self.export_virtual_image, method, "result") + ) + # Scaling Menu self.scaling_menu = QMenu("&Scaling", self) self.menu_bar.addMenu(self.scaling_menu) diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py index e1751e9..2745bfa 100644 --- a/src/py4D_browser/menu_actions.py +++ b/src/py4D_browser/menu_actions.py @@ -218,13 +218,21 @@ def export_datacube(self: "DataViewer", save_format: str): def export_virtual_image(self: "DataViewer", im_format: str, im_type: str): - assert im_type in ["image", "diffraction"], f"bad image type: {im_type}" + assert im_type in ["image", "diffraction", "result"], f"bad image type: {im_type}" filename = self.get_savefile_name(im_format) - view = ( - self.real_space_widget if im_type == "image" else self.diffraction_space_widget - ) + if im_type == "image": + view = self.real_space_widget + rawimg = self.unscaled_realspace_image + elif im_type == "diffraction": + view = self.diffraction_space_widget + rawimg = self.unscaled_diffraction_image + elif im_type == "result": + view = self.fft_widget + rawimg = self.unscaled_fft_image + else: + raise RuntimeError("Unrecognized export image source...") vimg = view.image.T vmin, vmax = view.getLevels() @@ -240,13 +248,8 @@ def export_virtual_image(self: "DataViewer", im_format: str, im_type: str): elif im_format == "TIFF (raw)": from tifffile import TiffWriter - vimg = ( - self.unscaled_realspace_image - if im_type == "image" - else self.unscaled_diffraction_image - ) with TiffWriter(filename) as tw: - tw.write(vimg) + tw.write(rawimg) else: raise RuntimeError("Nothing saved! Format not recognized") @@ -269,6 +272,15 @@ def copy_diff_to_clipboard(self: "DataViewer"): QApplication.clipboard().setImage(img.qimage) +def copy_result_to_clipboard(self: "DataViewer"): + img = self.fft_widget.getImageItem() + + if img._renderRequired: + img.render() + + QApplication.clipboard().setImage(img.qimage) + + def show_keyboard_map(self: "DataViewer"): keymap = KeyboardMapMenu(parent=self) keymap.open() From c51ee0907bcc3179a3118526917aabaa70816355 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Tue, 28 Apr 2026 16:46:14 -0400 Subject: [PATCH 23/25] raw data tooltip --- src/py4D_browser/main_window.py | 2 +- src/py4D_browser/update_views.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index fd45bcc..adb0764 100644 --- a/src/py4D_browser/main_window.py +++ b/src/py4D_browser/main_window.py @@ -113,7 +113,7 @@ def __init__(self, argv): self.setWindowTitle("py4DSTEM") - alternate_logo = strtobool(self.settings.value("gui/quack", 0)) + alternate_logo = strtobool(self.settings.value("gui/quack", "0")) icon = QtGui.QIcon( str( Path(__file__).parent.absolute() diff --git a/src/py4D_browser/update_views.py b/src/py4D_browser/update_views.py index c3abd3c..937bfc2 100644 --- a/src/py4D_browser/update_views.py +++ b/src/py4D_browser/update_views.py @@ -862,7 +862,7 @@ def nudge_diffraction_selector(self: "DataViewer", dx, dy): def update_tooltip(self: "DataViewer"): modifier_keys = QApplication.queryKeyboardModifiers() - if self.datacube is not None and self.isActiveWindow(): + if self.isActiveWindow(): global_pos = QCursor.pos() for scene, data in [ @@ -870,6 +870,8 @@ def update_tooltip(self: "DataViewer"): (self.real_space_widget, self.unscaled_realspace_image), (self.fft_widget, self.unscaled_fft_image), ]: + if data is None: + return pos_in_scene = scene.mapFromGlobal(QCursor.pos()) if scene.getView().rect().contains(pos_in_scene): pos_in_data = scene.view.mapSceneToView(pos_in_scene) @@ -878,7 +880,10 @@ def update_tooltip(self: "DataViewer"): x = int(np.clip(np.floor(pos_in_data.y()), 0, data.shape[0] - 1)) if np.isrealobj(data): - display_text = f"[{x},{y}]: {data[x,y]:.5g}" + if QtCore.Qt.ControlModifier == modifier_keys and data.dtype in (np.uint32, np.float32): + display_text = f"[{x},{y}]: {data.view(np.uint32)[x,y]:#08X}" + else: + display_text = f"[{x},{y}]: {data[x,y]:.5g}" else: display_text = f"[{x},{y}]: |z|={np.abs(data[x,y]):.5g}, ϕ={np.degrees(np.angle(data[x,y])):.5g}°" From fa6881cc5fc8db4fb009751ea689773b2d926af7 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Wed, 29 Apr 2026 15:56:35 -0400 Subject: [PATCH 24/25] format with black --- src/py4D_browser/update_views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/py4D_browser/update_views.py b/src/py4D_browser/update_views.py index 937bfc2..4bbbec2 100644 --- a/src/py4D_browser/update_views.py +++ b/src/py4D_browser/update_views.py @@ -880,7 +880,10 @@ def update_tooltip(self: "DataViewer"): x = int(np.clip(np.floor(pos_in_data.y()), 0, data.shape[0] - 1)) if np.isrealobj(data): - if QtCore.Qt.ControlModifier == modifier_keys and data.dtype in (np.uint32, np.float32): + if QtCore.Qt.ControlModifier == modifier_keys and data.dtype in ( + np.uint32, + np.float32, + ): display_text = f"[{x},{y}]: {data.view(np.uint32)[x,y]:#08X}" else: display_text = f"[{x},{y}]: {data[x,y]:.5g}" From 28417f84ae75dcb700aeff919aa2eec93195d1f7 Mon Sep 17 00:00:00 2001 From: Steven Zeltmann Date: Wed, 29 Apr 2026 16:01:47 -0400 Subject: [PATCH 25/25] better sizing of metadata plugin --- src/py4d_browser_plugin/metadata_plugin/metadata_viewer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/py4d_browser_plugin/metadata_plugin/metadata_viewer.py b/src/py4d_browser_plugin/metadata_plugin/metadata_viewer.py index af37c8b..20e50bf 100644 --- a/src/py4d_browser_plugin/metadata_plugin/metadata_viewer.py +++ b/src/py4d_browser_plugin/metadata_plugin/metadata_viewer.py @@ -3,12 +3,10 @@ QDialog, QHBoxLayout, QVBoxLayout, - QComboBox, - QGroupBox, QWidget, - QFormLayout, QTreeWidget, QTreeWidgetItem, + QHeaderView, ) import logging from emdfile import Metadata @@ -53,6 +51,7 @@ def __init__(self, parent): tree = QTreeWidget() tree.setColumnCount(2) + tree.header().setSectionResizeMode(QHeaderView.ResizeToContents) layout.addWidget(tree) for name, md in mdata.items(): @@ -72,3 +71,5 @@ def __init__(self, parent): button_layout.addWidget(cancel_button) layout.addLayout(button_layout) + + self.setMinimumSize(400, 600)