diff --git a/pyproject.toml b/pyproject.toml index bfd434e..2eb1b60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "py4D_browser" -version = "1.1.3" +version = "1.2.0" authors = [ { name="Steven Zeltmann", email="steven.zeltmann@lbl.gov" }, ] @@ -21,6 +21,7 @@ dependencies = [ "h5py", "numpy >= 1.19", "matplotlib >= 3.2.2", + "platformdirs", "PyQt5 >= 5.10", "pyqtgraph >= 0.11", "sigfig", diff --git a/src/py4D_browser/dialogs.py b/src/py4D_browser/dialogs.py index 58694a2..d86352e 100644 --- a/src/py4D_browser/dialogs.py +++ b/src/py4D_browser/dialogs.py @@ -1,6 +1,7 @@ -from py4DSTEM import DataCube, data, tqdmnd +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 @@ -428,8 +429,9 @@ def reconstruct(self): qy_operator = qy_operator * -2.0j * np.pi # loop over images and shift - for mx, my in tqdmnd( - *mask.shape, + img_indices = np.argwhere(mask) + for mx, my in tqdm( + img_indices, desc="Shifting images", file=StatusBarWriter(self.parent.statusBar()), mininterval=1.0, diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index 60f9216..2bbd098 100644 --- a/src/py4D_browser/main_window.py +++ b/src/py4D_browser/main_window.py @@ -11,6 +11,7 @@ QLabel, QToolTip, QPushButton, + QShortcut, ) from matplotlib.backend_bases import tools @@ -21,6 +22,7 @@ from pathlib import Path import importlib import os +import platformdirs from py4D_browser.utils import pg_point_roi, VLine, LatchingButton from py4D_browser.scalebar import ScaleBar @@ -97,6 +99,21 @@ def __init__(self, argv): self.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) + + # Reset stored state if so asked: + if os.environ.get("PY4DGUI_RESET"): + self.settings.remove("last_state") + print("Cleared saved state, using defaults...") + self.setup_menus() self.setup_views() @@ -108,7 +125,9 @@ def __init__(self, argv): font.setPointSize(10) QToolTip.setFont(font) - self.resize(1000, 800) + self.resize( + self.settings.value("last_state/window_size", QtCore.QSize(1000, 800)), + ) self.show() @@ -134,6 +153,7 @@ def setup_menus(self): self.load_auto_action = QAction("&Load Data...", self) self.load_auto_action.triggered.connect(self.load_data_auto) self.file_menu.addAction(self.load_auto_action) + self.load_auto_action.setShortcut(QtGui.QKeySequence("Ctrl+O")) self.load_mmap_action = QAction("Load &Memory Map...", self) self.load_mmap_action.triggered.connect(self.load_data_mmap) @@ -163,6 +183,8 @@ def setup_menus(self): for method in ["Raw float32", "py4DSTEM HDF5", "Plain HDF5"]: menu_item = datacube_export_menu.addAction(method) menu_item.triggered.connect(partial(self.export_datacube, method)) + if method == "py4DSTEM HDF5": + menu_item.setShortcut(QtGui.QKeySequence("Ctrl+S")) # Submenu to export virtual image vimg_export_menu = QMenu("Export Virtual Image", self) @@ -293,6 +315,9 @@ def setup_menus(self): diff_range_group = QActionGroup(self) diff_range_group.setExclusive(True) + scale_range_default = self.settings.value( + "last_state/diffraction_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) diff_range_group.addAction(action) @@ -302,7 +327,10 @@ def setup_menus(self): partial(self.set_diffraction_autoscale_range, scale_range) ) # set default - if scale_range[0] == 2 and scale_range[1] == 98: + if ( + scale_range[0] == scale_range_default[0] + and scale_range[1] == scale_range_default[1] + ): action.setChecked(True) self.set_diffraction_autoscale_range(scale_range, redraw=False) @@ -315,6 +343,9 @@ def setup_menus(self): vimg_range_group = QActionGroup(self) vimg_range_group.setExclusive(True) + scale_range_default = self.settings.value( + "last_state/realspace_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) vimg_range_group.addAction(action) @@ -324,7 +355,10 @@ def setup_menus(self): partial(self.set_real_space_autoscale_range, scale_range) ) # set default - if scale_range[0] == 2 and scale_range[1] == 98: + if ( + scale_range[0] == scale_range_default[0] + and scale_range[1] == scale_range_default[1] + ): action.setChecked(True) self.set_real_space_autoscale_range(scale_range, redraw=False) @@ -357,19 +391,11 @@ def setup_menus(self): detector_mode_group.addAction(detector_maximum_action) self.detector_menu.addAction(detector_maximum_action) - detector_CoM_magnitude = QAction("CoM Ma&gnitude", self) - detector_CoM_magnitude.setCheckable(True) - detector_CoM_magnitude.triggered.connect( - partial(self.update_real_space_view, True) - ) - detector_mode_group.addAction(detector_CoM_magnitude) - self.detector_menu.addAction(detector_CoM_magnitude) - - detector_CoM_angle = QAction("CoM &Angle", self) - detector_CoM_angle.setCheckable(True) - detector_CoM_angle.triggered.connect(partial(self.update_real_space_view, True)) - detector_mode_group.addAction(detector_CoM_angle) - self.detector_menu.addAction(detector_CoM_angle) + detector_CoM = QAction("C&oM", self) + detector_CoM.setCheckable(True) + detector_CoM.triggered.connect(partial(self.update_real_space_view, True)) + detector_mode_group.addAction(detector_CoM) + self.detector_menu.addAction(detector_CoM) detector_iCoM = QAction("i&CoM", self) detector_iCoM.setCheckable(True) @@ -659,6 +685,10 @@ def setup_views(self): ) self.statusBar().addPermanentWidget(self.realspace_rescale_button) + def resizeEvent(self, event): + # Store window size for next run + self.settings.setValue("last_state/window_size", event.size()) + # Handle dragging and dropping a file on the window def dragEnterEvent(self, event): if event.mimeData().hasUrls(): diff --git a/src/py4D_browser/update_views.py b/src/py4D_browser/update_views.py index 47ac0e1..b494434 100644 --- a/src/py4D_browser/update_views.py +++ b/src/py4D_browser/update_views.py @@ -7,7 +7,12 @@ from PyQt5.QtGui import QCursor import os -from py4D_browser.utils import pg_point_roi, make_detector, complex_to_Lab +from py4D_browser.utils import ( + pg_point_roi, + make_detector, + complex_to_Lab, + StatusBarWriter, +) def update_real_space_view(self, reset=False): @@ -23,14 +28,14 @@ def update_real_space_view(self, reset=False): assert detector_mode in [ "Integrating", "Maximum", - "CoM Magnitude", - "CoM Angle", + "CoM", "iCoM", ], detector_mode # If a CoM method is checked, ensure linear scaling - if detector_mode in ["CoM Magnitude", "CoM Angle"] and scaling_mode != "Linear": - print("Warning! Setting linear scaling for CoM image") + scaling_mode = self.vimg_scaling_group.checkedAction().text().replace("&", "") + if detector_mode == "CoM" and scaling_mode != "Linear": + self.statusBar().showMessage("Warning! Setting linear scaling for CoM image") self.vimg_scale_linear_action.setChecked(True) scaling_mode = "Linear" @@ -119,7 +124,12 @@ def update_real_space_view(self, reset=False): return mask = mask.astype(np.float32) vimg = np.zeros((self.datacube.R_Nx, self.datacube.R_Ny)) - iterator = py4DSTEM.tqdmnd(self.datacube.R_Nx, self.datacube.R_Ny, disable=True) + iterator = py4DSTEM.tqdmnd( + self.datacube.R_Nx, + self.datacube.R_Ny, + file=StatusBarWriter(self.statusBar()), + mininterval=0.1, + ) if detector_mode == "Integrating": for rx, ry in iterator: @@ -144,10 +154,8 @@ def update_real_space_view(self, reset=False): CoMx -= np.mean(CoMx) CoMy -= np.mean(CoMy) - if detector_mode == "CoM Magnitude": - vimg = np.hypot(CoMx, CoMy) - elif detector_mode == "CoM Angle": - vimg = np.arctan2(CoMy, CoMx) + if detector_mode == "CoM": + vimg = CoMx + 1.0j * CoMy elif detector_mode == "iCoM": dpc = py4DSTEM.process.phase.DPC(verbose=False) dpc.preprocess( @@ -174,17 +182,44 @@ def set_virtual_image(self, vimg, reset=False): def _render_virtual_image(self, reset=False): vimg = self.unscaled_realspace_image - scaling_mode = self.vimg_scaling_group.checkedAction().text().replace("&", "") - assert scaling_mode in ["Linear", "Log", "Square Root"], scaling_mode + # for 2D images, use the scaling set by the user + # for RGB (3D) images, always scale linear + if np.isrealobj(vimg): + scaling_mode = self.vimg_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") - 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)) + auto_level = reset or self.realspace_rescale_button.latched + + self.real_space_widget.setImage( + new_view.T, + autoLevels=False, + levels=( + ( + np.percentile(new_view, self.real_space_autoscale_percentiles[0]), + np.percentile(new_view, self.real_space_autoscale_percentiles[1]), + ) + if auto_level + else None + ), + autoRange=reset, + ) else: - raise ValueError("Mode not recognized") + new_view = complex_to_Lab(vimg) + self.real_space_widget.setImage( + np.transpose(new_view, (1, 0, 2)), # flip x/y but keep RGB ordering + autoLevels=False, + levels=(0, 1), + autoRange=reset, + ) stats_text = [ f"Min:\t{vimg.min():.5g}", @@ -197,27 +232,14 @@ def _render_virtual_image(self, reset=False): for t, m in zip(stats_text, self.realspace_statistics_actions): m.setText(t) - auto_level = reset or self.realspace_rescale_button.latched - - self.real_space_widget.setImage( - new_view.T, - autoLevels=False, - levels=( - ( - np.percentile(new_view, self.real_space_autoscale_percentiles[0]), - np.percentile(new_view, self.real_space_autoscale_percentiles[1]), - ) - if auto_level - else None - ), - autoRange=reset, - ) - # Update FFT view self.unscaled_fft_image = None - fft_window = np.hanning(vimg.shape[0])[:, None] * np.hanning(vimg.shape[1])[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 * fft_window))) ** 0.5 + 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") @@ -233,7 +255,7 @@ def _render_virtual_image(self, reset=False): self.fft_source_action_group.checkedAction().text() == "Virtual Image FFT (complex)" ): - fft = np.fft.fftshift(np.fft.fft2(vimg * fft_window)) + 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") @@ -383,7 +405,7 @@ def update_realspace_detector(self): return x, y = self.datacube.data.shape[:2] - x0, y0 = x / 2, y / 2 + x0, y0 = x // 2, y // 2 xr, yr = x / 10, y / 10 # Remove existing detector @@ -396,7 +418,10 @@ def update_realspace_detector(self): # Rectangular detector if detector_shape == "Point": - self.real_space_point_selector = pg_point_roi(self.real_space_widget.getView()) + self.real_space_point_selector = pg_point_roi( + self.real_space_widget.getView(), + center=(x0 - 0.5, y0 - 0.5), + ) self.real_space_point_selector.sigRegionChanged.connect( partial(self.update_diffraction_space_view, False) ) @@ -426,7 +451,7 @@ def update_diffraction_detector(self): return x, y = self.datacube.data.shape[2:] - x0, y0 = x / 2, y / 2 + x0, y0 = x // 2, y // 2 xr, yr = x / 10, y / 10 # Remove existing detector @@ -449,15 +474,17 @@ def update_diffraction_detector(self): ) self.virtual_detector_roi_outer = None - # Rectangular detector + # Point detector if detector_shape == "Point": self.virtual_detector_point = pg_point_roi( - self.diffraction_space_widget.getView() + self.diffraction_space_widget.getView(), + center=(x0 - 0.5, y0 - 0.5), ) self.virtual_detector_point.sigRegionChanged.connect( partial(self.update_real_space_view, False) ) + # Rectangular detector elif detector_shape == "Rectangular": self.virtual_detector_roi = pg.RectROI( [int(x0 - xr / 2), int(y0 - yr / 2)], [int(xr), int(yr)], pen=(3, 9) @@ -521,6 +548,7 @@ def update_diffraction_detector(self): def set_diffraction_autoscale_range(self, percentiles, redraw=True): self.diffraction_autoscale_percentiles = percentiles + self.settings.setValue("last_state/diffraction_autorange", list(percentiles)) if redraw: self._render_diffraction_image(reset=False) @@ -528,6 +556,7 @@ def set_diffraction_autoscale_range(self, percentiles, redraw=True): def set_real_space_autoscale_range(self, percentiles, redraw=True): self.real_space_autoscale_percentiles = percentiles + self.settings.setValue("last_state/realspace_autorange", list(percentiles)) if redraw: self._render_virtual_image(reset=False) @@ -596,7 +625,11 @@ def update_tooltip(self): y = int(np.clip(np.floor(pos_in_data.x()), 0, data.shape[0] - 1)) x = int(np.clip(np.floor(pos_in_data.y()), 0, data.shape[1] - 1)) - display_text = f"[{x},{y}]: {data[x,y]:.5g}" + + if np.isrealobj(data): + 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) diff --git a/src/py4D_browser/utils.py b/src/py4D_browser/utils.py index 03e1d21..5ac1058 100644 --- a/src/py4D_browser/utils.py +++ b/src/py4D_browser/utils.py @@ -66,12 +66,12 @@ def on_click(self, *args): self.status_bar.showMessage("Shift+click to keep on", 5_000) -def pg_point_roi(view_box): +def pg_point_roi(view_box, center=(-0.5, -0.5)): """ Point selection. Based in pyqtgraph, and returns a pyqtgraph CircleROI object. This object has a sigRegionChanged.connect() signal method to connect to other functions. """ - circ_roi = pg.CircleROI((-0.5, -0.5), (2, 2), movable=True, pen=(0, 9)) + circ_roi = pg.CircleROI(center, (2, 2), movable=True, pen=(0, 9)) h = circ_roi.addTranslateHandle((0.5, 0.5)) h.pen = pg.mkPen("r") h.update()