diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 09b2a0f..f7afd36 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -11,4 +11,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: psf/black@stable \ No newline at end of file + - uses: psf/black@stable diff --git a/.gitignore b/.gitignore index 8817b70..c69b3d5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .pydevproject *.swp *.ipynb_checkpoints* +pyrightconfig.json # Folders # .idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..196c01b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + # - id: trailing-whitespace + # - id: end-of-file-fixer + # - id: check-yaml + - id: check-added-large-files +- repo: https://github.com/psf/black + rev: 24.2.0 + hooks: + - id: black diff --git a/CITATION.cff b/CITATION.cff index 89d614c..1892afa 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,7 +1,7 @@ cff-version: 1.1.0 message: "If you use this software, please cite the accompanying paper." abstract: "Scanning transmission electron microscopy (STEM) allows for imaging, diffraction, and spectroscopy of materials on length scales ranging from microns to atoms. By using a high-speed, direct electron detector, it is now possible to record a full two-dimensional (2D) image of the diffracted electron beam at each probe position, typically a 2D grid of probe positions. These 4D-STEM datasets are rich in information, including signatures of the local structure, orientation, deformation, electromagnetic fields, and other sample-dependent properties. However, extracting this information requires complex analysis pipelines that include data wrangling, calibration, analysis, and visualization, all while maintaining robustness against imaging distortions and artifacts. In this paper, we present py4DSTEM, an analysis toolkit for measuring material properties from 4D-STEM datasets, written in the Python language and released with an open-source license. We describe the algorithmic steps for dataset calibration and various 4D-STEM property measurements in detail and present results from several experimental datasets. We also implement a simple and universal file format appropriate for electron microscopy data in py4DSTEM, which uses the open-source HDF5 standard. We hope this tool will benefit the research community and help improve the standards for data and computational methods in electron microscopy, and we invite the community to contribute to this ongoing project." -authors: +authors: - affiliation: "National Center for Electron Microscopy, Molecular Foundry, Lawrence Berkeley National Laboratory, 1 Cyclotron Road, Berkeley, CA 94720, USA" family-names: Savitzky diff --git a/LICENSE.txt b/LICENSE.txt index 72f3b9d..77f1435 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -672,4 +672,3 @@ may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . - diff --git a/README.md b/README.md index e68e9d3..c7132b6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # The `py4DSTEM` GUI -This repository hosts the `pyqt` based graphical 4D--STEM data browser that was originally part of **py4DSTEM** until version 0.13.11. +This repository hosts the `pyqt` based graphical 4D--STEM data browser that was originally part of **py4DSTEM** until version 0.13.11. -## Installation -The GUI is available on PyPI and conda-forge: +## Installation +The GUI is available on PyPI and conda-forge: `pip install py4D-browser` @@ -15,19 +15,19 @@ The GUI is available on PyPI and conda-forge: Run `py4DGUI` in your terminal to open the GUI. Then just drag and drop a 4D-STEM dataset into the window! ### Controls -* Move the virtual detector and the real-space selector using the mouse or using the keyboard shortcuts: WASD moves the detector and IJKL moves the selector, and holding down shift moves 5 pixels at a time. -* Auto scaling of both views is on by default. Press the "Autoscale" buttons in the bottom right to disable. Press either button to apply automatic scaling once, or Shift + click to lock autoscaling back on. +* Move the virtual detector and the real-space selector using the mouse or using the keyboard shortcuts: WASD moves the detector and IJKL moves the selector, and holding down shift moves 5 pixels at a time. +* Auto scaling of both views is on by default. Press the "Autoscale" buttons in the bottom right to disable. Press either button to apply automatic scaling once, or Shift + click to lock autoscaling back on. * Different shapes of virtual detector are available in the "Detector Shape" menu, and different detector responses are available in the "Detector Response" menu. -* The information in the bottom bar contains the details of the virtual detector used to generate the images, and can be entered into py4DSTEM to generate the same image. +* The information in the bottom bar contains the details of the virtual detector used to generate the images, and can be entered into py4DSTEM to generate the same image. * The FFT pane can be switched between displaying the FFT of the virtual image and displaying the [exit wave power cepstrum](https://doi.org/10.1016/j.ultramic.2020.112994). * Virtual images can be exported either as the scaled and clipped displays shown in the GUI or as raw data. The exact datatype stored in the raw TIFF image depends on both the datatype of the dataset and the type of virtual image being displayed (in particular, integer datatypes are converted internally to floating point to prevent overflows when generating any synthesized virtual images). -* If the [EMPAD-G2 Raw Reader](https://github.com/sezelt/empad2) is installed in the same environment, an extra menu will appear that allows the concatenated binary format data to be background subtracted and calibrated in the GUI. You can also save the calibrated data as an HDF5 file for later analysis. +* If the [EMPAD-G2 Raw Reader](https://github.com/sezelt/empad2) is installed in the same environment, an extra menu will appear that allows the concatenated binary format data to be background subtracted and calibrated in the GUI. You can also save the calibrated data as an HDF5 file for later analysis. ![Demonstration](/images/demo.gif) The keyboard map in the Help menu was made using [this tool](https://archie-adams.github.io/keyboard-shortcut-map-maker/) and the map file is in the top level of this repo. -## About +## About ![py4DSTEM logo](/images/py4DSTEM_logo.png) diff --git a/py4DGUI-keymap.html b/py4DGUI-keymap.html index 95996c2..b701bf8 100644 --- a/py4DGUI-keymap.html +++ b/py4DGUI-keymap.html @@ -21,8 +21,8 @@ .ulButtons ul { margin: 0; - list-style-type: none; - text-align: center; + list-style-type: none; + text-align: center; background-color: #b4b3bd; } .ulButtons ul li { @@ -51,7 +51,7 @@ } .bodyStyle { - width:1109px; + width:1109px; margin:0 auto; } @@ -68,8 +68,8 @@ /* Footer styling. */ footer { - background-color: #b4b3bd; - margin-top: 20px; + background-color: #b4b3bd; + margin-top: 20px; margin-bottom: 0; } @@ -79,12 +79,12 @@ .footer-div { width:960px; - margin:0 auto; + margin:0 auto; background-color: #b4b3bd; } #to-top-button{ - margin-left:426px; + margin-left:426px; padding-top: 14px; } #to-top-input { @@ -689,4 +689,4 @@

Keyboard Shortcuts

- \ No newline at end of file + diff --git a/pyproject.toml b/pyproject.toml index 3426704..b1b145f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "py4D_browser" -version = "0.999999" +version = "1.1.0" authors = [ { name="Steven Zeltmann", email="steven.zeltmann@lbl.gov" }, ] @@ -43,4 +43,4 @@ include-package-data = true where = ["src"] [tool.setuptools.package-data] -py4D_browser = ["*.png"] \ No newline at end of file +py4D_browser = ["*.png"] diff --git a/src/py4D_browser/dialogs.py b/src/py4D_browser/dialogs.py new file mode 100644 index 0000000..d7c86c0 --- /dev/null +++ b/src/py4D_browser/dialogs.py @@ -0,0 +1,403 @@ +from py4DSTEM import DataCube, data +import pyqtgraph as pg +import numpy as np +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 ( + QDialog, + QHBoxLayout, + QVBoxLayout, + QSpinBox, + QLineEdit, + QComboBox, + QGroupBox, + QGridLayout, + QCheckBox, +) +from py4D_browser.utils import make_detector + + +class ResizeDialog(QDialog): + def __init__(self, size, parent=None): + super().__init__(parent=parent) + + self.new_size = size + Nmax = size[0] * size[1] + + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Dataset size unknown. Please enter the shape:")) + + box_layout = QHBoxLayout() + box_layout.addWidget(QLabel("X:")) + + xbox = QSpinBox() + xbox.setRange(1, Nmax) + xbox.setSingleStep(1) + xbox.setKeyboardTracking(False) + xbox.valueChanged.connect(self.x_box_changed) + box_layout.addWidget(xbox) + + box_layout.addStretch() + box_layout.addWidget(QLabel("Y:")) + + ybox = QSpinBox() + ybox.setRange(1, Nmax) + ybox.setSingleStep(1) + ybox.setValue(Nmax) + ybox.setKeyboardTracking(False) + ybox.valueChanged.connect(self.y_box_changed) + box_layout.addWidget(ybox) + + layout.addLayout(box_layout) + + button_layout = QHBoxLayout() + button_layout.addStretch() + done_button = QPushButton("Done") + done_button.pressed.connect(self.close) + button_layout.addWidget(done_button) + layout.addLayout(button_layout) + + self.x_box = xbox + self.y_box = ybox + self.x_box_last = xbox.value() + self.y_box_last = ybox.value() + self.N = Nmax + + self.resize(600, 400) + + @classmethod + def get_new_size(cls, size, parent=None): + dialog = cls(size=size, parent=parent) + dialog.exec_() + return dialog.new_size + + def x_box_changed(self, new_value): + if new_value == self.x_box_last: + return + x_new, y_new = self.get_next_rect( + new_value, "down" if new_value < self.x_box_last else "up" + ) + + self.x_box_last = x_new + self.y_box_last = y_new + + self.x_box.setValue(x_new) + self.y_box.setValue(y_new) + + self.new_size = [x_new, y_new] + + def y_box_changed(self, new_value): + if new_value == self.y_box_last: + return + y_new, x_new = self.get_next_rect( + new_value, "down" if new_value < self.y_box_last else "up" + ) + + self.x_box_last = x_new + self.y_box_last = y_new + + self.x_box.setValue(x_new) + self.y_box.setValue(y_new) + + self.new_size = [x_new, y_new] + + def get_next_rect(self, current, direction): + # get the next perfect rectangle + iterator = ( + range(current, 0, -1) if direction == "down" else range(current, self.N + 1) + ) + + for i in iterator: + if self.N % i == 0: + return i, self.N // i + + raise ValueError("Factor finding failed, frustratingly.") + + +class CalibrateDialog(QDialog): + def __init__(self, datacube, parent, diffraction_selector_size=None): + super().__init__(parent=parent) + + self.datacube = datacube + self.parent = parent + self.diffraction_selector_size = diffraction_selector_size + + layout = QVBoxLayout(self) + + ####### LAYOUT ######## + + realspace_box = QGroupBox("Real Space") + layout.addWidget(realspace_box) + realspace_layout = QHBoxLayout() + realspace_box.setLayout(realspace_layout) + + realspace_left_layout = QGridLayout() + realspace_layout.addLayout(realspace_left_layout) + + realspace_left_layout.addWidget(QLabel("Pixel Size"), 0, 0, Qt.AlignRight) + self.realspace_pix_box = QLineEdit() + self.realspace_pix_box.setValidator(QDoubleValidator()) + realspace_left_layout.addWidget(self.realspace_pix_box, 0, 1) + + realspace_left_layout.addWidget(QLabel("Full Width"), 1, 0, Qt.AlignRight) + self.realspace_fov_box = QLineEdit() + realspace_left_layout.addWidget(self.realspace_fov_box, 1, 1) + + realspace_right_layout = QHBoxLayout() + realspace_layout.addLayout(realspace_right_layout) + self.realspace_unit_box = QComboBox() + self.realspace_unit_box.addItems(["Å", "nm"]) + self.realspace_unit_box.setMinimumContentsLength(5) + realspace_right_layout.addWidget(self.realspace_unit_box) + + diff_box = QGroupBox("Diffraction") + layout.addWidget(diff_box) + diff_layout = QHBoxLayout() + diff_box.setLayout(diff_layout) + + diff_left_layout = QGridLayout() + diff_layout.addLayout(diff_left_layout) + + diff_left_layout.addWidget(QLabel("Pixel Size"), 0, 0, Qt.AlignRight) + self.diff_pix_box = QLineEdit() + diff_left_layout.addWidget(self.diff_pix_box, 0, 1) + + diff_left_layout.addWidget(QLabel("Full Width"), 1, 0, Qt.AlignRight) + self.diff_fov_box = QLineEdit() + diff_left_layout.addWidget(self.diff_fov_box, 1, 1) + + diff_left_layout.addWidget(QLabel("Selection Radius"), 2, 0, Qt.AlignRight) + self.diff_selection_box = QLineEdit() + diff_left_layout.addWidget(self.diff_selection_box, 2, 1) + self.diff_selection_box.setEnabled(self.diffraction_selector_size is not None) + + diff_right_layout = QHBoxLayout() + diff_layout.addLayout(diff_right_layout) + self.diff_unit_box = QComboBox() + self.diff_unit_box.setMinimumContentsLength(5) + self.diff_unit_box.addItems( + [ + "mrad", + "Å⁻¹", + # "nm⁻¹", + ] + ) + diff_right_layout.addWidget(self.diff_unit_box) + + button_layout = QHBoxLayout() + button_layout.addStretch() + cancel_button = QPushButton("Cancel") + cancel_button.pressed.connect(self.close) + button_layout.addWidget(cancel_button) + done_button = QPushButton("Done") + done_button.pressed.connect(self.set_and_close) + button_layout.addWidget(done_button) + layout.addLayout(button_layout) + + ######### CALLBACKS ######## + self.realspace_pix_box.textEdited.connect(self.realspace_pix_box_changed) + self.realspace_fov_box.textEdited.connect(self.realspace_fov_box_changed) + self.diff_pix_box.textEdited.connect(self.diffraction_pix_box_changed) + self.diff_fov_box.textEdited.connect(self.diffraction_fov_box_changed) + self.diff_selection_box.textEdited.connect( + self.diffraction_selection_box_changed + ) + + def realspace_pix_box_changed(self, new_text): + pix_size = float(new_text) + + fov = pix_size * self.datacube.R_Ny + self.realspace_fov_box.setText(f"{fov:g}") + + def realspace_fov_box_changed(self, new_text): + fov = float(new_text) + + pix_size = fov / self.datacube.R_Ny + self.realspace_pix_box.setText(f"{pix_size:g}") + + def diffraction_pix_box_changed(self, new_text): + pix_size = float(new_text) + + fov = pix_size * self.datacube.Q_Ny + self.diff_fov_box.setText(f"{fov:g}") + + if self.diffraction_selector_size: + sel_size = pix_size * self.diffraction_selector_size + self.diff_selection_box.setText(f"{sel_size:g}") + + def diffraction_fov_box_changed(self, new_text): + fov = float(new_text) + + pix_size = fov / self.datacube.Q_Ny + self.diff_pix_box.setText(f"{pix_size:g}") + + if self.diffraction_selector_size: + sel_size = pix_size * self.diffraction_selector_size + self.diff_selection_box.setText(f"{sel_size:g}") + + def diffraction_selection_box_changed(self, new_text): + if self.diffraction_selector_size: + sel_size = float(new_text) + + pix_size = sel_size / self.diffraction_selector_size + fov = pix_size * self.datacube.Q_Nx + self.diff_pix_box.setText(f"{pix_size:g}") + self.diff_fov_box.setText(f"{fov:g}") + + sel_size = pix_size * self.diffraction_selector_size + self.diff_selection_box.setText(f"{sel_size:g}") + + def set_and_close(self): + + print("Old calibration") + print(self.datacube.calibration) + + realspace_text = self.realspace_pix_box.text() + if realspace_text != "": + realspace_pix = float(realspace_text) + self.datacube.calibration.set_R_pixel_size(realspace_pix) + self.datacube.calibration.set_R_pixel_units( + self.realspace_unit_box.currentText().replace("Å", "A") + ) + + diff_text = self.diff_pix_box.text() + if diff_text != "": + diff_pix = float(diff_text) + self.datacube.calibration.set_Q_pixel_size(diff_pix) + translation = { + "mrad": "mrad", + "Å⁻¹": "A^-1", + "nm⁻¹": "1/nm", + } + self.datacube.calibration.set_Q_pixel_units( + translation[self.diff_unit_box.currentText()] + ) + + self.parent.update_scalebars() + + print("New calibration") + print(self.datacube.calibration) + + self.close() + + +class ManualTCBFDialog(QDialog): + def __init__(self, parent): + super().__init__(parent=parent) + + self.parent = parent + + layout = QVBoxLayout(self) + + ####### LAYOUT ######## + + params_box = QGroupBox("Parameters") + layout.addWidget(params_box) + + params_layout = QGridLayout() + params_box.setLayout(params_layout) + + params_layout.addWidget(QLabel("Rotation [deg]"), 0, 0, Qt.AlignRight) + rotation_box = QLineEdit() + rotation_box.setValidator(QDoubleValidator()) + params_layout.addWidget(rotation_box, 0, 1) + + params_layout.addWidget(QLabel("Transpose x/y"), 1, 0, Qt.AlignRight) + transpose_box = QCheckBox() + params_layout.addWidget(transpose_box, 1, 1) + + params_layout.addWidget(QLabel("Max Shift [px]"), 2, 0, Qt.AlignRight) + self.max_shift_box = QLineEdit() + self.max_shift_box.setValidator(QDoubleValidator()) + params_layout.addWidget(self.max_shift_box, 2, 1) + + button_layout = QHBoxLayout() + button_layout.addStretch() + cancel_button = QPushButton("Cancel") + cancel_button.pressed.connect(self.close) + button_layout.addWidget(cancel_button) + done_button = QPushButton("Reconstruct") + done_button.pressed.connect(self.reconstruct) + button_layout.addWidget(done_button) + layout.addLayout(button_layout) + + def reconstruct(self): + datacube = self.parent.datacube + + # tcBF requires an area detector for generating the mask + detector_shape = ( + self.parent.detector_shape_group.checkedAction().text().replace("&", "") + ) + if detector_shape not in [ + "Rectangular", + "Circle", + ]: + self.parent.statusBar().showMessage( + "tcBF requires a selection of the BF disk" + ) + return + + if detector_shape == "Rectangular": + # Get slices corresponding to ROI + slices, _ = self.parent.virtual_detector_roi.getArraySlice( + self.parent.datacube.data[0, 0, :, :], + self.parent.diffraction_space_widget.getImageItem(), + ) + slice_y, slice_x = slices + + mask = np.zeros( + (self.parent.datacube.Q_Nx, self.parent.datacube.Q_Ny), dtype=np.bool_ + ) + mask[slice_x, slice_y] = True + + elif detector_shape == "Circle": + R = self.parent.virtual_detector_roi.size()[0] / 2.0 + + x0 = self.parent.virtual_detector_roi.pos()[0] + R + y0 = self.parent.virtual_detector_roi.pos()[1] + R + + mask = make_detector( + (self.parent.datacube.Q_Nx, self.parent.datacube.Q_Ny), + "circle", + ((x0, y0), R), + ) + else: + raise ValueError("idk how we got here...") + + if self.max_shift_box.text() == "": + self.parent.statusBar().showMessage("Max Shift must be specified") + return + + rotation = float(self.rotation_box.text() or 0.0) + transpose = self.transpose_box.checkState() + max_shift = float(self.max_shift_box.text()) + + x, y = np.meshgrid( + np.arange(datacube.Q_Nx), np.arange(datacube.Q_Ny), indexing="ij" + ) + + mask_comx = np.sum(mask * x) / np.sum(mask) + mask_comy = np.sum(mask * y) / np.sum(mask) + + pix_coord_x = x - mask_comx + pix_coord_y = y - mask_comy + + q_pix = np.hypot(pix_coord_x, pix_coord_y) + # unrotated shifts in scan pixels + shifts_pix_x = pix_coord_x / np.max(q_pix * mask) * max_shift + shifts_pix_y = pix_coord_y / np.max(q_pix * mask) * max_shift + # shifts_pix = np. + + R = np.array( + [ + [np.cos(rotation), -np.sin(rotation)], + [np.sin(rotation), np.cos(rotation)], + ] + ) + T = np.array([[0.0, 1.0], [1.0, 0.0]]) + + if transpose: + R = T @ R diff --git a/src/py4D_browser/empad2_reader.py b/src/py4D_browser/empad2_reader.py index 3fedfe8..8d8a9ec 100644 --- a/src/py4D_browser/empad2_reader.py +++ b/src/py4D_browser/empad2_reader.py @@ -1,6 +1,7 @@ import empad2 -from PyQt5.QtWidgets import QFileDialog, QMessageBox +from PyQt5.QtWidgets import QFileDialog, QMessageBox, QApplication import numpy as np +from py4D_browser.utils import StatusBarWriter def set_empad2_sensor(self, sensor_name): @@ -41,7 +42,14 @@ def load_empad2_dataset(self): filename = raw_file_dialog(self) self.datacube = empad2.load_dataset( - filename, self.empad2_background, self.empad2_calibrations + filename, + self.empad2_background, + self.empad2_calibrations, + _tqdm_args={ + "desc": "Loading", + "file": StatusBarWriter(self.statusBar()), + "mininterval": 1.0, + }, ) if dummy_data: diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index 0b4ef66..2967774 100644 --- a/src/py4D_browser/main_window.py +++ b/src/py4D_browser/main_window.py @@ -9,15 +9,17 @@ QSplitter, QActionGroup, QLabel, - QPushButton, + QToolTip, ) +from matplotlib.backend_bases import tools import pyqtgraph as pg import numpy as np from functools import partial from pathlib import Path import importlib +import os from py4D_browser.utils import pg_point_roi, VLine, LatchingButton from py4D_browser.scalebar import ScaleBar @@ -34,6 +36,7 @@ class DataViewer(QMainWindow): from py4D_browser.menu_actions import ( load_file, + load_data_arina, load_data_auto, load_data_bin, load_data_mmap, @@ -42,17 +45,29 @@ class DataViewer(QMainWindow): export_datacube, export_virtual_image, show_keyboard_map, + show_calibration_dialog, + reshape_data, + update_scalebars, + reconstruct_tcBF_auto, + reconstruct_tcBF_manual, ) from py4D_browser.update_views import ( + set_virtual_image, + set_diffraction_image, + _render_virtual_image, + _render_diffraction_image, update_diffraction_space_view, update_real_space_view, update_realspace_detector, update_diffraction_detector, + set_diffraction_autoscale_range, + set_real_space_autoscale_range, nudge_real_space_selector, nudge_diffraction_selector, update_annulus_pos, update_annulus_radii, + update_tooltip, ) HAS_EMPAD2 = importlib.util.find_spec("empad2") is not None @@ -84,6 +99,14 @@ def __init__(self, argv): self.setup_menus() self.setup_views() + # setup listener for tooltip + self.tooltip_timer = pg.ThreadsafeTimer() + self.tooltip_timer.timeout.connect(self.update_tooltip) + self.tooltip_timer.start(1000 // 30) # run tooltip at 30 Hz + font = QtGui.QFont(self.font()) + font.setPointSize(10) + QToolTip.setFont(font) + self.resize(1000, 800) self.show() @@ -92,6 +115,10 @@ def __init__(self, argv): if len(argv) > 1: self.load_file(argv[1]) + # launch pyqtgraph's debug console if environment variable exists + if os.environ.get("PY4DGUI_DEBUG"): + pg.dbg() + def setup_menus(self): self.menu_bar = self.menuBar() @@ -115,6 +142,14 @@ def setup_menus(self): self.load_binned_action.triggered.connect(self.load_data_bin) self.file_menu.addAction(self.load_binned_action) + self.load_arina_action = QAction("Load &Arina Data...", self) + self.load_arina_action.triggered.connect(self.load_data_arina) + self.file_menu.addAction(self.load_arina_action) + + self.reshape_data_action = QAction("&Reshape Data...", self) + self.reshape_data_action.triggered.connect(self.reshape_data) + self.file_menu.addAction(self.reshape_data_action) + self.file_menu.addSeparator() export_label = QAction("Export", self) @@ -187,7 +222,7 @@ def setup_menus(self): diff_scale_linear_action = QAction("Linear", self) diff_scale_linear_action.setCheckable(True) diff_scale_linear_action.triggered.connect( - partial(self.update_diffraction_space_view, True) + partial(self._render_diffraction_image, True) ) diff_scaling_group.addAction(diff_scale_linear_action) self.scaling_menu.addAction(diff_scale_linear_action) @@ -195,7 +230,7 @@ def setup_menus(self): diff_scale_log_action = QAction("Log", self) diff_scale_log_action.setCheckable(True) diff_scale_log_action.triggered.connect( - partial(self.update_diffraction_space_view, True) + partial(self._render_diffraction_image, True) ) diff_scaling_group.addAction(diff_scale_log_action) self.scaling_menu.addAction(diff_scale_log_action) @@ -203,7 +238,7 @@ def setup_menus(self): diff_scale_sqrt_action = QAction("Square Root", self) diff_scale_sqrt_action.setCheckable(True) diff_scale_sqrt_action.triggered.connect( - partial(self.update_diffraction_space_view, True) + partial(self._render_diffraction_image, True) ) diff_scaling_group.addAction(diff_scale_sqrt_action) diff_scale_sqrt_action.setChecked(True) @@ -225,7 +260,7 @@ def setup_menus(self): vimg_scale_linear_action.setCheckable(True) vimg_scale_linear_action.setChecked(True) vimg_scale_linear_action.triggered.connect( - partial(self.update_real_space_view, True) + partial(self._render_virtual_image, True) ) vimg_scaling_group.addAction(vimg_scale_linear_action) self.scaling_menu.addAction(vimg_scale_linear_action) @@ -233,7 +268,7 @@ def setup_menus(self): vimg_scale_log_action = QAction("Log", self) vimg_scale_log_action.setCheckable(True) vimg_scale_log_action.triggered.connect( - partial(self.update_real_space_view, True) + partial(self._render_virtual_image, True) ) vimg_scaling_group.addAction(vimg_scale_log_action) self.scaling_menu.addAction(vimg_scale_log_action) @@ -241,11 +276,57 @@ def setup_menus(self): vimg_scale_sqrt_action = QAction("Square Root", self) vimg_scale_sqrt_action.setCheckable(True) vimg_scale_sqrt_action.triggered.connect( - partial(self.update_real_space_view, True) + partial(self._render_virtual_image, True) ) vimg_scaling_group.addAction(vimg_scale_sqrt_action) self.scaling_menu.addAction(vimg_scale_sqrt_action) + # Autorange menu + self.autorange_menu = QMenu("&Autorange", self) + self.menu_bar.addMenu(self.autorange_menu) + + diff_autoscale_separator = QAction("Diffraction", self) + diff_autoscale_separator.setDisabled(True) + self.autorange_menu.addAction(diff_autoscale_separator) + + diff_range_group = QActionGroup(self) + diff_range_group.setExclusive(True) + + 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) + self.autorange_menu.addAction(action) + action.setCheckable(True) + action.triggered.connect( + partial(self.set_diffraction_autoscale_range, scale_range) + ) + # set default + if scale_range[0] == 2 and scale_range[1] == 98: + action.setChecked(True) + self.set_diffraction_autoscale_range(scale_range, redraw=False) + + self.autorange_menu.addSeparator() + + vimg_autoscale_separator = QAction("Virtual Image", self) + vimg_autoscale_separator.setDisabled(True) + self.autorange_menu.addAction(vimg_autoscale_separator) + + vimg_range_group = QActionGroup(self) + vimg_range_group.setExclusive(True) + + 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) + self.autorange_menu.addAction(action) + action.setCheckable(True) + action.triggered.connect( + partial(self.set_real_space_autoscale_range, scale_range) + ) + # set default + if scale_range[0] == 2 and scale_range[1] == 98: + action.setChecked(True) + self.set_real_space_autoscale_range(scale_range, redraw=False) + # Detector Response Menu self.detector_menu = QMenu("&Detector Response", self) self.menu_bar.addMenu(self.detector_menu) @@ -331,7 +412,7 @@ def setup_menus(self): self.detector_shape_menu.addSeparator() - diffraction_detector_separator = QAction("Real Space", self) + diffraction_detector_separator = QAction("Virtual Image", self) diffraction_detector_separator.setDisabled(True) self.detector_shape_menu.addAction(diffraction_detector_separator) @@ -360,17 +441,44 @@ def setup_menus(self): 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) + + 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) + ) + 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_fft_action.triggered.connect(partial(self.update_real_space_view, False)) img_ewpc_action.triggered.connect( partial(self.update_diffraction_space_view, False) ) + # Processing menu + self.processing_menu = QMenu("&Processing", self) + self.menu_bar.addMenu(self.processing_menu) + + calibrate_action = QAction("&Calibrate...", self) + calibrate_action.triggered.connect(self.show_calibration_dialog) + self.processing_menu.addAction(calibrate_action) + + tcBF_action_manual = QAction("tcBF (Manual)...", self) + tcBF_action_manual.triggered.connect(self.reconstruct_tcBF_manual) + self.processing_menu.addAction(tcBF_action_manual) + tcBF_action_manual.setEnabled(False) + + tcBF_action_auto = QAction("tcBF (Automatic)", self) + tcBF_action_auto.triggered.connect(self.reconstruct_tcBF_auto) + self.processing_menu.addAction(tcBF_action_auto) + + # Help menu self.help_menu = QMenu("&Help", self) self.menu_bar.addMenu(self.help_menu) @@ -384,6 +492,8 @@ def setup_views(self): self.diffraction_space_widget.setImage(np.zeros((512, 512))) self.diffraction_space_view_text = QLabel("Slice") + self.diffraction_space_widget.setMouseTracking(True) + # Create virtual detector ROI selector self.virtual_detector_point = pg_point_roi( self.diffraction_space_widget.getView() @@ -419,7 +529,7 @@ def setup_views(self): self.real_space_scale_bar.anchor((1, 1), (1, 1), offset=(-40, -40)) # Name and return - self.real_space_widget.setWindowTitle("Real Space") + self.real_space_widget.setWindowTitle("Virtual Image") self.diffraction_space_widget.setAcceptDrops(True) self.real_space_widget.setAcceptDrops(True) @@ -466,13 +576,19 @@ def setup_views(self): self.fft_widget.getView().setMenuEnabled(False) # Setup Status Bar + self.realspace_statistics_text = QLabel("Image Stats") + self.diffraction_statistics_text = QLabel("Diffraction Stats") + self.statusBar().addPermanentWidget(VLine()) + self.statusBar().addPermanentWidget(self.realspace_statistics_text) + self.statusBar().addPermanentWidget(VLine()) + self.statusBar().addPermanentWidget(self.diffraction_statistics_text) self.statusBar().addPermanentWidget(VLine()) self.statusBar().addPermanentWidget(self.diffraction_space_view_text) self.statusBar().addPermanentWidget(VLine()) self.statusBar().addPermanentWidget(self.real_space_view_text) self.statusBar().addPermanentWidget(VLine()) self.diffraction_rescale_button = LatchingButton( - "Autoscale Diffraction", + "Autorange Diffraction", status_bar=self.statusBar(), latched=True, ) @@ -481,7 +597,7 @@ def setup_views(self): ) self.statusBar().addPermanentWidget(self.diffraction_rescale_button) self.realspace_rescale_button = LatchingButton( - "Autoscale Real Space", + "Autorange Virtual Image", status_bar=self.statusBar(), latched=True, ) diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py index 94ff7c1..ce76600 100644 --- a/src/py4D_browser/menu_actions.py +++ b/src/py4D_browser/menu_actions.py @@ -1,3 +1,4 @@ +from numbers import Real import py4DSTEM from PyQt5.QtWidgets import QFileDialog, QMessageBox import h5py @@ -5,6 +6,9 @@ import numpy as np import matplotlib.pyplot as plt from py4D_browser.help_menu import KeyboardMapMenu +from py4D_browser.dialogs import CalibrateDialog, ResizeDialog, ManualTCBFDialog +from py4D_browser.utils import make_detector +from py4DSTEM.io.filereaders import read_arina def load_data_auto(self): @@ -23,12 +27,48 @@ def load_data_bin(self): self.load_file(filename, mmap=False, binning=4) +def load_data_arina(self): + filename = self.show_file_dialog() + dataset = read_arina(filename) + + # Try to reshape the data to be square + N_patterns = dataset.data.shape[1] + Nxy = np.sqrt(N_patterns) + if np.abs(Nxy - np.round(Nxy)) <= 1e-10: + Nxy = int(Nxy) + dataset.data = dataset.data.reshape( + Nxy, Nxy, dataset.data.shape[2], dataset.data.shape[3] + ) + else: + self.statusBar().showMessage( + f"The scan appears to not be square! Found {N_patterns} patterns", 5_000 + ) + + self.datacube = dataset + self.diffraction_scale_bar.pixel_size = self.datacube.calibration.get_Q_pixel_size() + self.diffraction_scale_bar.units = self.datacube.calibration.get_Q_pixel_units() + + 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) + + def load_file(self, filepath, mmap=False, binning=1): print(f"Loading file {filepath}") extension = os.path.splitext(filepath)[-1].lower() print(f"Type: {extension}") - if extension in (".h5", ".hdf5", ".py4dstem", ".emd"): - datacubes = get_4D(h5py.File(filepath, "r")) + if extension in (".h5", ".hdf5", ".py4dstem", ".emd", ".mat"): + file = h5py.File(filepath, "r") + datacubes = get_ND(file) print(f"Found {len(datacubes)} 4D datasets inside the HDF5 file...") if len(datacubes) >= 1: # Read the first datacube in the HDF5 file into RAM @@ -45,7 +85,17 @@ def load_file(self, filepath, mmap=False, binning=1): self.datacube.calibration.set_Q_pixel_units(Q_units) else: - raise ValueError("No 4D data detected in the H5 file!") + # if no 4D data was found, look for 3D data + datacubes = get_ND(file, N=3) + print(f"Found {len(datacubes)} 3D datasets inside the HDF5 file...") + if len(datacubes) >= 1: + array = datacubes[0] if mmap else datacubes[0][()] + new_shape = ResizeDialog.get_new_size([1, array.shape[0]], parent=self) + self.datacube = py4DSTEM.DataCube( + array.reshape(*new_shape, *array.shape[1:]) + ) + else: + raise ValueError("No 4D (or even 3D) data detected in the H5 file!") elif extension in [".npy"]: self.datacube = py4DSTEM.DataCube(np.load(filepath)) else: @@ -55,22 +105,60 @@ def load_file(self, filepath, mmap=False, binning=1): binfactor=binning, ) + self.update_scalebars() + + self.update_diffraction_space_view(reset=True) + self.update_real_space_view(reset=True) + + self.setWindowTitle(filepath) + + +def update_scalebars(self): + + realspace_translation = { + "A": "Å", + } + reciprocal_translation = { + "A^-1": "Å⁻¹", + } + self.diffraction_scale_bar.pixel_size = self.datacube.calibration.get_Q_pixel_size() - self.diffraction_scale_bar.units = self.datacube.calibration.get_Q_pixel_units() + q_units = self.datacube.calibration.get_Q_pixel_units() + self.diffraction_scale_bar.units = ( + reciprocal_translation[q_units] + if q_units in reciprocal_translation.keys() + else q_units + ) 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() + r_units = self.datacube.calibration.get_R_pixel_units() + self.real_space_scale_bar.units = ( + realspace_translation[r_units] + if r_units in realspace_translation.keys() + 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"1/{self.datacube.calibration.get_R_pixel_units()}" + 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): + 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:] + ) + + print(f"Reshaping data to {new_shape}") self.update_diffraction_space_view(reset=True) self.update_real_space_view(reset=True) - self.setWindowTitle(filepath) - def export_datacube(self, save_format: str): assert save_format in [ @@ -151,12 +239,90 @@ def show_keyboard_map(self): keymap.open() +def reconstruct_tcBF_auto(self): + # tcBF requires an area detector for generating the mask + detector_shape = self.detector_shape_group.checkedAction().text().replace("&", "") + if detector_shape not in [ + "Rectangular", + "Circle", + ]: + self.statusBar().showMessage("tcBF requires a selection of the BF disk", 5_000) + return + + if ( + self.datacube.calibration.get_R_pixel_units == "pixels" + or self.datacube.calibration.get_Q_pixel_units == "pixels" + ): + self.statusBar().showMessage("tcBF requires caibrated data", 5_000) + return + + if detector_shape == "Rectangular": + # Get slices corresponding to ROI + slices, _ = self.virtual_detector_roi.getArraySlice( + self.datacube.data[0, 0, :, :], self.diffraction_space_widget.getImageItem() + ) + slice_y, slice_x = slices + + mask = np.zeros((self.datacube.Q_Nx, self.datacube.Q_Ny), dtype=np.bool_) + mask[slice_x, slice_y] = True + + elif detector_shape == "Circle": + R = self.virtual_detector_roi.size()[0] / 2.0 + + x0 = self.virtual_detector_roi.pos()[0] + R + y0 = self.virtual_detector_roi.pos()[1] + R + + mask = make_detector( + (self.datacube.Q_Nx, self.datacube.Q_Ny), "circle", ((x0, y0), R) + ) + else: + raise ValueError("idk how we got here...") + + # do tcBF! + self.statusBar().showMessage("Reconstructing... (This may take a while)") + + tcBF = py4DSTEM.process.phase.Parallax( + energy=300e3, + datacube=self.datacube, + ) + tcBF.preprocess( + dp_mask=mask, + plot_average_bf=False, + vectorized_com_calculation=False, + store_initial_arrays=False, + ) + tcBF.reconstruct( + plot_aligned_bf=False, + plot_convergence=False, + ) + + self.set_virtual_image(tcBF.recon_BF, reset=True) + + +def reconstruct_tcBF_manual(self): + dialog = ManualTCBFDialog(parent=self) + dialog.show() + + +def show_calibration_dialog(self): + # If the selector has a size, figure that out + if hasattr(self, "virtual_detector_roi") and self.virtual_detector_roi is not None: + selector_size = self.virtual_detector_roi.size()[0] / 2.0 + else: + selector_size = None + + dialog = CalibrateDialog( + self.datacube, parent=self, diffraction_selector_size=selector_size + ) + dialog.open() + + def show_file_dialog(self) -> str: filename = QFileDialog.getOpenFileName( self, "Open 4D-STEM Data", "", - "4D-STEM Data (*.dm3 *.dm4 *.raw *.mib *.gtg *.h5 *.hdf5 *.emd *.py4dstem *.npy *.npz);;Any file (*)", + "4D-STEM Data (*.dm3 *.dm4 *.raw *.mib *.gtg *.h5 *.hdf5 *.emd *.py4dstem *.npy *.npz *.mat);;Any file (*)", ) if filename is not None and len(filename[0]) > 0: return filename[0] @@ -207,16 +373,17 @@ def get_savefile_name(self, file_format) -> str: raise ValueError("Could get save file") -def get_4D(f, datacubes=None): +def get_ND(f, datacubes=None, N=4): + # Traverse an h5py.File and look for Datasets with N dimensions if datacubes is None: datacubes = [] for k in f.keys(): if isinstance(f[k], h5py.Dataset): # we found data - if len(f[k].shape) == 4: + if len(f[k].shape) == N: datacubes.append(f[k]) elif isinstance(f[k], h5py.Group): - get_4D(f[k], datacubes) + get_ND(f[k], datacubes) return datacubes @@ -258,7 +425,7 @@ def find_calibrations(dset: h5py.Dataset): try: if "sampling" in dset.parent and "units" in dset.parent: R_size = dset.parent["sampling"][0] - R_units = dset.parent["units"][0].decode() + R_units = dset.parent["units"][0].decode().replace("Å", "A") Q_size = dset.parent["sampling"][3] Q_units = dset.parent["units"][3].decode() diff --git a/src/py4D_browser/update_views.py b/src/py4D_browser/update_views.py index 7b7c332..8125baa 100644 --- a/src/py4D_browser/update_views.py +++ b/src/py4D_browser/update_views.py @@ -2,14 +2,14 @@ import numpy as np import py4DSTEM from functools import partial +from PyQt5.QtWidgets import QApplication, QToolTip +from PyQt5 import QtCore +from PyQt5.QtGui import QCursor -from py4D_browser.utils import pg_point_roi, make_detector +from py4D_browser.utils import pg_point_roi, make_detector, complex_to_Lab def update_real_space_view(self, reset=False): - scaling_mode = self.vimg_scaling_group.checkedAction().text().replace("&", "") - assert scaling_mode in ["Linear", "Log", "Square Root"], scaling_mode - detector_shape = self.detector_shape_group.checkedAction().text().replace("&", "") assert detector_shape in [ "Point", @@ -163,8 +163,22 @@ def update_real_space_view(self, reset=False): else: raise ValueError("Oopsie") + self.set_virtual_image(vimg, reset=reset) + + +def set_virtual_image(self, vimg, reset=False): + self.unscaled_realspace_image = vimg + self._render_virtual_image(reset=reset) + + +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 + if scaling_mode == "Linear": - new_view = vimg + 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": @@ -172,17 +186,31 @@ def update_real_space_view(self, reset=False): else: raise ValueError("Mode not recognized") - self.unscaled_realspace_image = vimg + self.realspace_statistics_text.setToolTip( + f"min\t{vimg.min():.5g}\nmax\t{vimg.max():.5g}\nmean\t{vimg.mean():.5g}\nsum\t{vimg.sum():.5g}\nstd\t{np.std(vimg):.5g}" + ) + + auto_level = reset or self.realspace_rescale_button.latched self.real_space_widget.setImage( new_view.T, - autoLevels=reset or self.realspace_rescale_button.latched, + 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, :] if self.fft_source_action_group.checkedAction().text() == "Virtual Image FFT": - fft = np.abs(np.fft.fftshift(np.fft.fft2(new_view))) ** 0.5 + fft = np.abs(np.fft.fftshift(np.fft.fft2(vimg * 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") @@ -193,12 +221,37 @@ def update_real_space_view(self, reset=False): 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 * 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): - scaling_mode = self.diff_scaling_group.checkedAction().text().replace("&", "") - assert scaling_mode in ["Linear", "Log", "Square Root"] - if self.datacube is None: return @@ -240,10 +293,22 @@ def update_diffraction_space_view(self, reset=False): else: raise ValueError("Detector shape not recognized") + self.set_diffraction_image(DP, reset=reset) + + +def set_diffraction_image(self, DP, reset=False): self.unscaled_diffraction_image = DP + self._render_diffraction_image(reset=reset) + + +def _render_diffraction_image(self, reset=False): + DP = self.unscaled_diffraction_image + + scaling_mode = self.diff_scaling_group.checkedAction().text().replace("&", "") + assert scaling_mode in ["Linear", "Log", "Square Root"] if scaling_mode == "Linear": - new_view = DP + new_view = DP.copy() elif scaling_mode == "Log": new_view = np.log2(np.maximum(DP, self.LOG_SCALE_MIN_VALUE)) elif scaling_mode == "Square Root": @@ -251,9 +316,23 @@ def update_diffraction_space_view(self, reset=False): else: raise ValueError("Mode not recognized") + self.diffraction_statistics_text.setToolTip( + f"min\t{DP.min():.5g}\nmax\t{DP.max():.5g}\nmean\t{DP.mean():.5g}\nsum\t{DP.sum():.5g}\nstd\t{np.std(DP):.5g}" + ) + + auto_level = reset or self.diffraction_rescale_button.latched + self.diffraction_space_widget.setImage( new_view.T, - autoLevels=reset or self.diffraction_rescale_button.latched, + autoLevels=False, + levels=( + ( + np.percentile(new_view, self.diffraction_autoscale_percentiles[0]), + np.percentile(new_view, self.diffraction_autoscale_percentiles[1]), + ) + if auto_level + else None + ), autoRange=reset, ) @@ -279,7 +358,7 @@ def update_realspace_detector(self): if self.datacube is None: return - x, y = self.datacube.shape[2:] + x, y = self.datacube.data.shape[:2] x0, y0 = x / 2, y / 2 xr, yr = x / 10, y / 10 @@ -322,7 +401,7 @@ def update_diffraction_detector(self): if self.datacube is None: return - x, y = self.datacube.shape[2:] + x, y = self.datacube.data.shape[2:] x0, y0 = x / 2, y / 2 xr, yr = x / 10, y / 10 @@ -416,6 +495,20 @@ def update_diffraction_detector(self): self.update_real_space_view(reset=True) +def set_diffraction_autoscale_range(self, percentiles, redraw=True): + self.diffraction_autoscale_percentiles = percentiles + + if redraw: + self._render_diffraction_image(reset=False) + + +def set_real_space_autoscale_range(self, percentiles, redraw=True): + self.real_space_autoscale_percentiles = percentiles + + if redraw: + self._render_virtual_image(reset=False) + + def nudge_real_space_selector(self, dx, dy): if ( hasattr(self, "real_space_point_selector") @@ -462,6 +555,31 @@ def nudge_diffraction_selector(self, dx, dy): selector.setPos(position) +def update_tooltip(self): + modifier_keys = QApplication.queryKeyboardModifiers() + # print(self.isHidden()) + + if QtCore.Qt.ControlModifier == modifier_keys and self.datacube is not None: + global_pos = QCursor.pos() + + for scene, data in [ + (self.diffraction_space_widget, self.unscaled_diffraction_image), + (self.real_space_widget, self.unscaled_realspace_image), + (self.fft_widget, self.unscaled_fft_image), + ]: + pos_in_scene = scene.mapFromGlobal(QCursor.pos()) + if scene.getView().rect().contains(pos_in_scene): + pos_in_data = scene.view.mapSceneToView(pos_in_scene) + + 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}" + + # Clearing the tooltip forces it to move every tick, but it flickers + # QToolTip.showText(global_pos, "") + QToolTip.showText(global_pos, display_text) + + def update_annulus_pos(self): """ Function to keep inner and outer rings of annulus aligned. diff --git a/src/py4D_browser/utils.py b/src/py4D_browser/utils.py index fb7be24..03e1d21 100644 --- a/src/py4D_browser/utils.py +++ b/src/py4D_browser/utils.py @@ -1,8 +1,22 @@ import pyqtgraph as pg import numpy as np -from PyQt5.QtWidgets import QFrame, QPushButton, QApplication +from PyQt5.QtWidgets import QFrame, QPushButton, QApplication, QLabel from PyQt5.QtCore import pyqtSignal -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QObject +from PyQt5.QtWidgets import QDialog, QHBoxLayout, QVBoxLayout, QSpinBox + + +class StatusBarWriter: + def __init__(self, statusBar): + self.statusBar = statusBar + self.app = app = QApplication.instance() + + def write(self, message): + self.statusBar.showMessage(message, 1_000) + self.app.processEvents() + + def flush(self): + pass class VLine(QFrame): @@ -108,3 +122,31 @@ def make_detector(shape: tuple, mode: str, geometry) -> np.ndarray: raise ValueError(f"mode and geometry not understood: {unknown}") return mask + + +def complex_to_Lab( + im, amin=None, amax=None, gamma=1.0, L_scale=100, ab_scale=64, uniform_L=None +): + from skimage.color import lab2rgb + from matplotlib.colors import Normalize + import warnings + + Lab = np.zeros(im.shape + (3,), dtype=np.float64) + angle = np.angle(im) + + L = Normalize(vmin=amin, vmax=amax, clip=True)(np.abs(im)) ** gamma + L = Normalize()(L) + + # attempt at polynomial saturation + # ab_prescale = 4*L - 4*L*L + ab_prescale = 0.5 + + Lab[..., 0] = uniform_L or L * L_scale + Lab[..., 1] = np.cos(angle) * ab_scale * ab_prescale + Lab[..., 2] = np.sin(angle) * ab_scale * ab_prescale + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + rgb = lab2rgb(Lab) + + return rgb