diff --git a/pyproject.toml b/pyproject.toml index 83f3d6c..365ce82 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.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/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/logo_alternate.png b/src/py4D_browser/logo_alternate.png new file mode 100644 index 0000000..fd89ad3 Binary files /dev/null and b/src/py4D_browser/logo_alternate.png differ diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index d1b0ee2..adb0764 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,17 +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, strtobool from py4D_browser.scalebar import ScaleBar @@ -51,21 +52,28 @@ class DataViewer(QMainWindow): reshape_data, set_datacube, update_scalebars, + copy_vimg_to_clipboard, + copy_diff_to_clipboard, + copy_result_to_clipboard, ) 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, update_realspace_detector, 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, @@ -73,8 +81,17 @@ class DataViewer(QMainWindow): update_tooltip, ) + from py4D_browser.signals import ( + register_result_callback, + set_internal_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 @@ -82,26 +99,38 @@ 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) self.setWindowTitle("py4DSTEM") self.setAcceptDrops(True) - self.datacube = None + self.datacube: Optional[DataCube] = None - # Load settings from cofig file - config_path = os.path.join( - platformdirs.user_config_dir("py4DGUI", "py4DSTEM"), "GUI_config.ini" - ) - print(f"Loading configuration from {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.unscaled_diffraction_image: Optional[np.ndarray] = None + self.unscaled_realspace_image: Optional[np.ndarray] = None + self.unscaled_fft_image: Optional[np.ndarray] = None # Reset stored state if so asked: if os.environ.get("PY4DGUI_RESET"): @@ -134,7 +163,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() @@ -186,6 +215,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,12 +227,26 @@ 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( 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) @@ -275,6 +321,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) @@ -333,6 +416,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) @@ -474,33 +587,39 @@ 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.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.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) - img_complex_fft_action.triggered.connect( - partial(self.update_real_space_view, False) - ) + self.result_menu.addAction(img_complex_fft_action) + self.result_source_action_group.addAction(img_complex_fft_action) + img_complex_fft_action.triggered.connect(self.set_internal_result_callback) 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) - img_ewpc_action.triggered.connect( - partial(self.update_diffraction_space_view, False) - ) + self.result_menu.addAction(img_ewpc_action) + self.result_source_action_group.addAction(img_ewpc_action) + 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) @@ -514,6 +633,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() @@ -633,6 +758,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(), @@ -642,6 +768,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(), @@ -652,6 +779,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 2f67234..2745bfa 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 @@ -9,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) @@ -41,18 +45,14 @@ 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): +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}") @@ -63,16 +63,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 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: + 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 @@ -101,9 +107,10 @@ 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): +def set_datacube(self: "DataViewer", datacube, window_title): self.datacube = datacube self.update_scalebars() @@ -112,9 +119,10 @@ 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): +def update_scalebars(self: "DataViewer"): realspace_translation = { "A": "Å", @@ -139,17 +147,11 @@ 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): +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:] @@ -161,7 +163,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", @@ -188,27 +190,49 @@ 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 + + self.setWindowTitle(filename) + self.statusBar().showMessage(f"File saved to {filename}") + except Exception as exc: + import traceback + + QMessageBox.critical( + self, + "Uh-oh!", + traceback.format_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): - assert im_type in ["image", "diffraction"], f"bad image type: {im_type}" +def export_virtual_image(self: "DataViewer", im_format: str, im_type: str): + 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() @@ -224,23 +248,45 @@ def export_virtual_image(self, 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") -def show_keyboard_map(self): +def copy_vimg_to_clipboard(self: "DataViewer"): + img = self.real_space_widget.getImageItem() + + if img._renderRequired: + img.render() + + QApplication.clipboard().setImage(img.qimage) + + +def copy_diff_to_clipboard(self: "DataViewer"): + img = self.diffraction_space_widget.getImageItem() + + if img._renderRequired: + img.render() + + 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() -def show_file_dialog(self) -> str: +def show_file_dialog(self: "DataViewer") -> str: filename = QFileDialog.getOpenFileName( self, "Open 4D-STEM Data", @@ -254,7 +300,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 5c41886..8b7bea9 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 @@ -71,20 +76,27 @@ 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): + +def unload_plugins(self: "DataViewer"): # NOTE: This is currently not actually called! for plugin in self.loaded_plugins: plugin["plugin"].close() -class ExamplePlugin: +class py4DBrowserPlugin: # required for py4DGUI to recognize this as a plugin. plugin_id = "my.plugin.identifier" @@ -105,5 +117,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 diff --git a/src/py4D_browser/signals.py b/src/py4D_browser/signals.py new file mode 100644 index 0000000..9796d67 --- /dev/null +++ b/src/py4D_browser/signals.py @@ -0,0 +1,117 @@ +from typing import Callable, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from py4D_browser import DataViewer + +""" +Registration and marshalling for callbacks/signals +""" + +__all__ = ["register_result_callback", "set_internal_result_callback"] + +_registered_result_callbacks: dict[str, Optional[Callable]] = { + "diffraction": None, + "virtual_image": None, + "datacube": None, + "cleanup": None, +} + + +def register_result_callback( + self: "DataViewer", + 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, +): + # 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) # this is handled in set_result_image now + 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, + ) + + +def set_internal_result_callback(self: "DataViewer"): + # 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, + ) + + self.update_fft_view() + + +def _replace_result_callbacks( + self: "DataViewer", + cleanup: Optional[Callable] = None, + 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"] + ) + _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"] = None + + # 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: + _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..4bbbec2 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 @@ -22,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 @@ -121,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 @@ -177,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 @@ -306,12 +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 @@ -364,55 +371,8 @@ 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): +def update_diffraction_space_view(self: "DataViewer", reset=False): if self.datacube is None: return @@ -447,12 +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("&", "") @@ -494,18 +455,148 @@ def _render_diffraction_image(self, reset=False): autoRange=reset, ) - if self.fft_source_action_group.checkedAction().text() == "EWPC": + +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! + + # 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() + + 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 + mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" + self.set_result_image( + fft, + 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 + 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(np.fft.fftshift(vimg_2D * fft_window))) + mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" + self.set_result_image( + fft, + 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 + 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)) mode_switch = self.fft_widget_text.textItem.toPlainText() != "EWPC" - self.fft_widget_text.setText("EWPC") + self.set_result_image( + fft, + 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( + f"The internal FFT view callback was triggered but {mode} is checked!" + ) + + +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) + + 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: "DataViewer", 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( - fft.T, autoLevels=False, levels=levels, autoRange=mode_switch + 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): +def update_realspace_detector(self: "DataViewer"): # change the shape of the detector, then update the view detector_shape = ( @@ -567,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("&") @@ -698,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)) @@ -706,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)) @@ -714,7 +805,15 @@ def set_real_space_autoscale_range(self, percentiles, redraw=True): self._render_virtual_image(reset=False) -def nudge_real_space_selector(self, dx, dy): +def set_result_autoscale_range(self: "DataViewer", 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: "DataViewer", dx, dy): if ( hasattr(self, "real_space_point_selector") and self.real_space_point_selector is not None @@ -735,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 @@ -760,10 +859,10 @@ 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(): + if self.isActiveWindow(): global_pos = QCursor.pos() for scene, data in [ @@ -771,6 +870,8 @@ def update_tooltip(self): (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) @@ -779,14 +880,20 @@ def update_tooltip(self): 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}°" 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. """ @@ -798,7 +905,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: 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,)) diff --git a/src/py4d_browser_plugin/calibration_plugin/calibration_plugin.py b/src/py4d_browser_plugin/calibration_plugin/calibration_plugin.py index 9471489..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 @@ -136,6 +141,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 +242,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") 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..e511746 --- /dev/null +++ b/src/py4d_browser_plugin/logging_config_plugin/logger_plugin.py @@ -0,0 +1,81 @@ +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) + + 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) 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..20e50bf --- /dev/null +++ b/src/py4d_browser_plugin/metadata_plugin/metadata_viewer.py @@ -0,0 +1,75 @@ +from PyQt5.QtWidgets import QPushButton +from PyQt5.QtWidgets import ( + QDialog, + QHBoxLayout, + QVBoxLayout, + QWidget, + QTreeWidget, + QTreeWidgetItem, + QHeaderView, +) +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) + tree.header().setSectionResizeMode(QHeaderView.ResizeToContents) + 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) + + self.setMinimumSize(400, 600)