diff --git a/README.md b/README.md
index 8210e73..e68e9d3 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,19 @@ The GUI is available on PyPI and conda-forge:
## Usage
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.
+* 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 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.
+

+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

diff --git a/py4DGUI-keymap.html b/py4DGUI-keymap.html
new file mode 100644
index 0000000..95996c2
--- /dev/null
+++ b/py4DGUI-keymap.html
@@ -0,0 +1,692 @@
+
py4DGUI-keymap.htmlKeyboard Shortcut Map Maker
-
+
+
+
Keyboard Shortcuts
+ 🚮
+
+
+
+
+
+
+
+
+
+
+
ESC
+
+
F1
+
F2
+
F3
+
F4
+
+
F5
+
F6
+
F7
+
F8
+
F9
+
F10
+
F11
+
F12
+
+
+
+
+
+
+
+
ESC
+
`
+
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
+
9
+
0
+
-
+
=
+
BACKSPACE
+
+
+
+
+
TAB
+
Q
+
WDetector Up
+
E
+
R
+
T
+
Y
+
U
+
ISelector Up
+
O
+
P
+
[ {
+
] }
+
\ |
+
+
+
+
CAPSLOCK
+
ADetector Left
+
SDetector Down
+
DDetector Right
+
F
+
G
+
H
+
JSelector Left
+
KSelector Down
+
LSelector Right
+
; :
+
' "
+
ENTER
+
+
+
+
SHIFTMove 5 px at once
+
Z
+
X
+
C
+
V
+
B
+
N
+
M
+
, <
+
. >
+
/ ?
+
SHIFT
+
+
+
+
CTRL
+
+
WIN
+
+
ALT
+
+
ALT
+
FN
+
MENU
+
+
CTRL
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PRTSC
+
INS
+
DEL
+
+
+
◁
+
+
+
+
+ SCRLK
+
HOME
+
END
+
+
△
+
▽
+
+
+
+
PAUSE
+
+
PGUP
+
PGDN
+
+
+
▷
+
+
+
+
+
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 64c32a3..3426704 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,25 +4,26 @@ build-backend = "setuptools.build_meta"
[project]
name = "py4D_browser"
-version = "0.99999"
+version = "0.999999"
authors = [
{ name="Steven Zeltmann", email="steven.zeltmann@lbl.gov" },
]
description = "A 4D-STEM data browser built on py4DSTEM."
readme = "README.md"
-requires-python = ">=3.9"
+requires-python = ">=3.10"
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
]
dependencies = [
- "py4dstem >= 0.14.3",
+ "py4dstem >= 0.14.9",
"emdfile >= 0.0.11",
"h5py",
"numpy >= 1.19",
"matplotlib >= 3.2.2",
"PyQt5 >= 5.10",
"pyqtgraph >= 0.11",
+ "sigfig",
]
[project.scripts]
@@ -33,4 +34,13 @@ py4DGUI = "py4D_browser.runGUI:launch"
"Bug Tracker" = "https://github.com/py4dstem/py4D-browser/issues"
[tool.pyright]
-venv = "py4dstem"
\ No newline at end of file
+venv = "py4dstem"
+
+[tool.setuptools]
+include-package-data = true
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.setuptools.package-data]
+py4D_browser = ["*.png"]
\ No newline at end of file
diff --git a/src/py4D_browser/empad2_reader.py b/src/py4D_browser/empad2_reader.py
index 5aba496..3fedfe8 100644
--- a/src/py4D_browser/empad2_reader.py
+++ b/src/py4D_browser/empad2_reader.py
@@ -5,6 +5,7 @@
def set_empad2_sensor(self, sensor_name):
self.empad2_calibrations = empad2.load_calibration_data(sensor=sensor_name)
+ self.statusBar().showMessage(f"{sensor_name} calibrations loaded", 5_000)
def load_empad2_background(self):
@@ -13,6 +14,7 @@ def load_empad2_background(self):
self.empad2_background = empad2.load_background(
filepath=filename, calibration_data=self.empad2_calibrations
)
+ self.statusBar().showMessage("Background data loaded", 5_000)
else:
QMessageBox.warning(
self, "No calibrations loaded!", "Please select a sensor first"
diff --git a/src/py4D_browser/help_menu.py b/src/py4D_browser/help_menu.py
new file mode 100644
index 0000000..ad3abeb
--- /dev/null
+++ b/src/py4D_browser/help_menu.py
@@ -0,0 +1,44 @@
+from PyQt5 import QtGui, QtCore
+from PyQt5.QtWidgets import (
+ QWidget,
+ QDialog,
+ QVBoxLayout,
+)
+from pathlib import Path
+
+
+class KeyboardMapMenu(QDialog):
+ def __init__(self, parent=None):
+ super().__init__(parent=parent)
+
+ self.keymap = QtGui.QPixmap(
+ str(Path(__file__).parent.absolute() / "py4DGUI-keymap.png")
+ ).scaledToWidth(1500)
+ label = Label()
+
+ label.setPixmap(self.keymap)
+
+ layout = QVBoxLayout()
+ layout.addWidget(label)
+ self.setLayout(layout)
+
+ self.resize(self.keymap.width(), self.keymap.height())
+
+
+# Widget that smoothly resizes Pixmap keeping aspect ratio
+class Label(QWidget):
+ def __init__(self, parent=None):
+ QWidget.__init__(self, parent=parent)
+ self.p = QtGui.QPixmap()
+
+ def setPixmap(self, p):
+ self.p = p
+ self.update()
+
+ def paintEvent(self, event):
+ if not self.p.isNull():
+ painter = QtGui.QPainter(self)
+ painter.setRenderHint(QtGui.QPainter.SmoothPixmapTransform)
+ rect = self.rect()
+ rect.setHeight(int(self.p.height() * rect.width() / self.p.width()))
+ painter.drawPixmap(rect, self.p)
diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py
index 4277c3a..0b4ef66 100644
--- a/src/py4D_browser/main_window.py
+++ b/src/py4D_browser/main_window.py
@@ -8,6 +8,8 @@
QHBoxLayout,
QSplitter,
QActionGroup,
+ QLabel,
+ QPushButton,
)
import pyqtgraph as pg
@@ -17,7 +19,8 @@
from pathlib import Path
import importlib
-from py4D_browser.utils import pg_point_roi
+from py4D_browser.utils import pg_point_roi, VLine, LatchingButton
+from py4D_browser.scalebar import ScaleBar
class DataViewer(QMainWindow):
@@ -38,6 +41,7 @@ class DataViewer(QMainWindow):
get_savefile_name,
export_datacube,
export_virtual_image,
+ show_keyboard_map,
)
from py4D_browser.update_views import (
@@ -45,6 +49,8 @@ class DataViewer(QMainWindow):
update_real_space_view,
update_realspace_detector,
update_diffraction_detector,
+ nudge_real_space_selector,
+ nudge_diffraction_selector,
update_annulus_pos,
update_annulus_radii,
)
@@ -78,7 +84,7 @@ def __init__(self, argv):
self.setup_menus()
self.setup_views()
- self.resize(800, 400)
+ self.resize(1000, 800)
self.show()
@@ -125,7 +131,7 @@ def setup_menus(self):
# Submenu to export virtual image
vimg_export_menu = QMenu("Export Virtual Image", self)
self.file_menu.addMenu(vimg_export_menu)
- for method in ["PNG", "TIFF", "TIFF (raw)"]:
+ for method in ["PNG (display)", "TIFF (display)", "TIFF (raw)"]:
menu_item = vimg_export_menu.addAction(method)
menu_item.triggered.connect(
partial(self.export_virtual_image, method, "image")
@@ -134,7 +140,7 @@ def setup_menus(self):
# Submenu to export diffraction
vdiff_export_menu = QMenu("Export Diffraction Pattern", self)
self.file_menu.addMenu(vdiff_export_menu)
- for method in ["PNG", "TIFF", "TIFF (raw)"]:
+ 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")
@@ -346,33 +352,60 @@ 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.fft_source_action_group = QActionGroup(self)
+ self.fft_source_action_group.setExclusive(True)
+ img_fft_action = QAction("Virtual Image FFT", self)
+ img_fft_action.setCheckable(True)
+ img_fft_action.setChecked(True)
+ self.fft_menu.addAction(img_fft_action)
+ self.fft_source_action_group.addAction(img_fft_action)
+ 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)
+ )
+
+ self.help_menu = QMenu("&Help", self)
+ self.menu_bar.addMenu(self.help_menu)
+
+ self.keyboard_map_action = QAction("Show &Keyboard Map", self)
+ self.keyboard_map_action.triggered.connect(self.show_keyboard_map)
+ self.help_menu.addAction(self.keyboard_map_action)
+
def setup_views(self):
# Set up the diffraction space window.
self.diffraction_space_widget = pg.ImageView()
self.diffraction_space_widget.setImage(np.zeros((512, 512)))
- self.diffraction_space_view_text = pg.TextItem(
- "Slice", (200, 200, 200), None, (0, 1)
- )
- self.diffraction_space_widget.addItem(self.diffraction_space_view_text)
+ self.diffraction_space_view_text = QLabel("Slice")
# Create virtual detector ROI selector
self.virtual_detector_point = pg_point_roi(
self.diffraction_space_widget.getView()
)
self.virtual_detector_point.sigRegionChanged.connect(
- self.update_real_space_view
+ partial(self.update_real_space_view, False)
)
+ # Scalebar
+ self.diffraction_scale_bar = ScaleBar(pixel_size=1, units="px", width=10)
+ self.diffraction_scale_bar.setParentItem(
+ self.diffraction_space_widget.getView()
+ )
+ self.diffraction_scale_bar.anchor((1, 1), (1, 1), offset=(-40, -40))
+
# Name and return
self.diffraction_space_widget.setWindowTitle("Diffraction Space")
# Set up the real space window.
self.real_space_widget = pg.ImageView()
self.real_space_widget.setImage(np.zeros((512, 512)))
- self.real_space_view_text = pg.TextItem(
- "Scan pos.", (200, 200, 200), None, (0, 1)
- )
- self.real_space_widget.addItem(self.real_space_view_text)
+ self.real_space_view_text = QLabel("Scan Position")
# Add point selector connected to displayed diffraction pattern
self.real_space_point_selector = pg_point_roi(self.real_space_widget.getView())
@@ -380,6 +413,11 @@ def setup_views(self):
partial(self.update_diffraction_space_view, False)
)
+ # Scalebar, None by default
+ self.real_space_scale_bar = ScaleBar(pixel_size=1, units="px", width=10)
+ self.real_space_scale_bar.setParentItem(self.real_space_widget.getView())
+ self.real_space_scale_bar.anchor((1, 1), (1, 1), offset=(-40, -40))
+
# Name and return
self.real_space_widget.setWindowTitle("Real Space")
@@ -394,9 +432,15 @@ def setup_views(self):
self.fft_widget = pg.ImageView()
self.fft_widget.setImage(np.zeros((512, 512)))
+ # FFT scale bar
+ self.fft_scale_bar = ScaleBar(pixel_size=1, units="1/px", width=10)
+ self.fft_scale_bar.setParentItem(self.fft_widget.getView())
+ self.fft_scale_bar.anchor((1, 1), (1, 1), offset=(-40, -40))
+
# Name and return
self.fft_widget.setWindowTitle("FFT of Virtual Image")
- self.fft_widget.addItem(pg.TextItem("FFT", (200, 200, 200), None, (0, 1)))
+ self.fft_widget_text = pg.TextItem("FFT", (200, 200, 200), None, (0, 1))
+ self.fft_widget.addItem(self.fft_widget_text)
self.fft_widget.setAcceptDrops(True)
self.fft_widget.dragEnterEvent = self.dragEnterEvent
@@ -417,6 +461,35 @@ def setup_views(self):
widget.setLayout(layout)
self.setCentralWidget(widget)
+ self.diffraction_space_widget.getView().setMenuEnabled(False)
+ self.real_space_widget.getView().setMenuEnabled(False)
+ self.fft_widget.getView().setMenuEnabled(False)
+
+ # Setup Status Bar
+ 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",
+ status_bar=self.statusBar(),
+ latched=True,
+ )
+ self.diffraction_rescale_button.activated.connect(
+ self.diffraction_space_widget.autoLevels
+ )
+ self.statusBar().addPermanentWidget(self.diffraction_rescale_button)
+ self.realspace_rescale_button = LatchingButton(
+ "Autoscale Real Space",
+ status_bar=self.statusBar(),
+ latched=True,
+ )
+ self.realspace_rescale_button.activated.connect(
+ self.real_space_widget.autoLevels
+ )
+ self.statusBar().addPermanentWidget(self.realspace_rescale_button)
+
# Handle dragging and dropping a file on the window
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
@@ -429,3 +502,37 @@ def dropEvent(self, event):
if len(files) == 1:
print(f"Reieving dropped file: {files[0]}")
self.load_file(files[0])
+
+ def keyPressEvent(self, event):
+ key = event.key()
+ modifier = event.modifiers()
+
+ speed = 5 if modifier == QtCore.Qt.ShiftModifier else 1
+
+ if key in [QtCore.Qt.Key_W, QtCore.Qt.Key_A, QtCore.Qt.Key_S, QtCore.Qt.Key_D]:
+ self.nudge_diffraction_selector(
+ dx=speed
+ * (
+ -1 if key == QtCore.Qt.Key_W else 1 if key == QtCore.Qt.Key_S else 0
+ ),
+ dy=speed
+ * (
+ -1 if key == QtCore.Qt.Key_A else 1 if key == QtCore.Qt.Key_D else 0
+ ),
+ )
+ elif key in [
+ QtCore.Qt.Key_I,
+ QtCore.Qt.Key_J,
+ QtCore.Qt.Key_K,
+ QtCore.Qt.Key_L,
+ ]:
+ self.nudge_real_space_selector(
+ dx=speed
+ * (
+ -1 if key == QtCore.Qt.Key_I else 1 if key == QtCore.Qt.Key_K else 0
+ ),
+ dy=speed
+ * (
+ -1 if key == QtCore.Qt.Key_J else 1 if key == QtCore.Qt.Key_L else 0
+ ),
+ )
diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py
index 2e61631..94ff7c1 100644
--- a/src/py4D_browser/menu_actions.py
+++ b/src/py4D_browser/menu_actions.py
@@ -4,6 +4,7 @@
import os
import numpy as np
import matplotlib.pyplot as plt
+from py4D_browser.help_menu import KeyboardMapMenu
def load_data_auto(self):
@@ -35,7 +36,17 @@ def load_file(self, filepath, mmap=False, binning=1):
self.datacube = py4DSTEM.DataCube(
datacubes[0] if mmap else datacubes[0][()]
)
- elif extension in [".npy", ".npz"]:
+
+ 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)
+
+ else:
+ raise ValueError("No 4D data detected in the H5 file!")
+ elif extension in [".npy"]:
self.datacube = py4DSTEM.DataCube(np.load(filepath))
else:
self.datacube = py4DSTEM.import_file(
@@ -44,6 +55,17 @@ def load_file(self, filepath, mmap=False, binning=1):
binfactor=binning,
)
+ 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)
@@ -74,7 +96,7 @@ def export_datacube(self, save_format: str):
)
if response == QMessageBox.Cancel:
- print("Cancelling due to user guilt")
+ self.statusBar().showMessage("Cancelling due to user guilt", 5_000)
return
filename = self.get_savefile_name(save_format)
@@ -99,22 +121,34 @@ def export_virtual_image(self, im_format: str, im_type: str):
self.real_space_widget if im_type == "image" else self.diffraction_space_widget
)
- vimg = view.image
+ vimg = view.image.T
vmin, vmax = view.getLevels()
- if im_format == "PNG":
+ if im_format == "PNG (display)":
plt.imsave(
fname=filename, arr=vimg, vmin=vmin, vmax=vmax, format="png", cmap="gray"
)
- elif im_format == "TIFF":
+ elif im_format == "TIFF (display)":
plt.imsave(
fname=filename, arr=vimg, vmin=vmin, vmax=vmax, format="tiff", cmap="gray"
)
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)
+ else:
+ raise RuntimeError("Nothing saved! Format not recognized")
+
+
+def show_keyboard_map(self):
+ keymap = KeyboardMapMenu(parent=self)
+ keymap.open()
def show_file_dialog(self) -> str:
@@ -136,8 +170,8 @@ def get_savefile_name(self, file_format) -> str:
"Raw float32": "RAW File (*.raw *.f32);;Any file (*)",
"py4DSTEM HDF5": "HDF5 File (*.hdf5 *.h5 *.emd *.py4dstem);;Any file (*)",
"Plain HDF5": "HDF5 File (*.hdf5 *.h5;;Any file (*)",
- "PNG": "PNG File (*.png);;Any file (*)",
- "TIFF": "TIFF File (*.tiff *.tif *.tff);;Any File (*)",
+ "PNG (display)": "PNG File (*.png);;Any file (*)",
+ "TIFF (display)": "TIFF File (*.tiff *.tif *.tff);;Any File (*)",
"TIFF (raw)": "TIFF File (*.tiff *.tif *.tff);;Any File (*)",
}
@@ -145,8 +179,8 @@ def get_savefile_name(self, file_format) -> str:
"Raw float32": ".raw",
"py4DSTEM HDF5": ".h5",
"Plain HDF5": ".h5",
- "PNG": ".png",
- "TIFF": ".tiff",
+ "PNG (display)": ".png",
+ "TIFF (display)": ".tiff",
"TIFF (raw)": ".tiff",
}
@@ -184,3 +218,53 @@ def get_4D(f, datacubes=None):
elif isinstance(f[k], h5py.Group):
get_4D(f[k], datacubes)
return datacubes
+
+
+def find_calibrations(dset: h5py.Dataset):
+ # Attempt to find calibrations from an H5 file
+ R_size, R_units, Q_size, Q_units = 1.0, "pixels", 1.0, "pixels"
+
+ # Does it look like a py4DSTEM file?
+ try:
+ if "emd_group_type" in dset.parent.attrs:
+ # EMD files theoretically store this in the Array,
+ # but in practice seem to only keep the calibrations
+ # in the Metadata object, which is separate
+
+ # R_size = dset.parent["dim0"][1] - dset.parent["dim0"][0]
+ # R_units = dset.parent["dim0"].attrs["units"]
+
+ # Q_size = dset.parent["dim3"][1] - dset.parent["dim3"][0]
+ # Q_units = dset.parent["dim3"].attrs["units"]
+ R_size = dset.parent.parent["metadatabundle"]["calibration"][
+ "R_pixel_size"
+ ][()]
+ R_units = dset.parent.parent["metadatabundle"]["calibration"][
+ "R_pixel_units"
+ ][()].decode()
+
+ Q_size = dset.parent.parent["metadatabundle"]["calibration"][
+ "Q_pixel_size"
+ ][()]
+ Q_units = dset.parent.parent["metadatabundle"]["calibration"][
+ "Q_pixel_units"
+ ][()].decode()
+ except:
+ print(
+ "This file looked like a py4DSTEM dataset but the dim vectors appear malformed..."
+ )
+
+ # Does it look like an abTEM file?
+ try:
+ if "sampling" in dset.parent and "units" in dset.parent:
+ R_size = dset.parent["sampling"][0]
+ R_units = dset.parent["units"][0].decode()
+
+ Q_size = dset.parent["sampling"][3]
+ Q_units = dset.parent["units"][3].decode()
+ except:
+ print(
+ "This file looked like an abTEM simulation but the calibrations aren't as expected..."
+ )
+
+ return R_size, R_units, Q_size, Q_units
diff --git a/src/py4D_browser/py4DGUI-keymap.png b/src/py4D_browser/py4DGUI-keymap.png
new file mode 100644
index 0000000..e2b86da
Binary files /dev/null and b/src/py4D_browser/py4DGUI-keymap.png differ
diff --git a/src/py4D_browser/scalebar.py b/src/py4D_browser/scalebar.py
new file mode 100644
index 0000000..6601448
--- /dev/null
+++ b/src/py4D_browser/scalebar.py
@@ -0,0 +1,103 @@
+from pyqtgraph import functions as fn
+from pyqtgraph import getConfigOption
+from pyqtgraph import Point
+from PyQt5 import QtCore, QtWidgets
+from pyqtgraph import GraphicsObject
+from pyqtgraph import GraphicsWidgetAnchor
+from pyqtgraph import TextItem
+import numpy as np
+from sigfig import round
+
+__all__ = ["ScaleBar"]
+
+
+class ScaleBar(GraphicsWidgetAnchor, GraphicsObject):
+ """
+ Displays a rectangular bar to indicate the relative scale of objects on the view.
+ """
+
+ def __init__(
+ self,
+ pixel_size: float,
+ units: str,
+ target_relaive_size=0.2,
+ width=5,
+ brush=None,
+ pen=None,
+ offset=None,
+ nice_numbers=[1, 2, 5, 10],
+ ):
+ GraphicsObject.__init__(self)
+ GraphicsWidgetAnchor.__init__(self)
+ self.setFlag(self.GraphicsItemFlag.ItemHasNoContents)
+ self.setAcceptedMouseButtons(QtCore.Qt.MouseButton.NoButton)
+
+ if brush is None:
+ brush = getConfigOption("foreground")
+ self.brush = fn.mkBrush(brush)
+ self.pen = fn.mkPen(pen)
+ self._width = width
+ self._target_relative_size = target_relaive_size
+ self._nice_numbers = np.array(nice_numbers)
+
+ self.pixel_size = pixel_size
+ self.units = units
+
+ if offset is None:
+ offset = (0, 0)
+ self.offset = offset
+
+ self.bar = QtWidgets.QGraphicsRectItem()
+ self.bar.setPen(self.pen)
+ self.bar.setBrush(self.brush)
+ self.bar.setParentItem(self)
+
+ self.text = TextItem(text="smol", anchor=(0.5, 1))
+ self.text.setParentItem(self)
+
+ def changeParent(self):
+ view = self.parentItem()
+ if view is None:
+ return
+ view.sigRangeChanged.connect(self.updateBar)
+ self.updateBar()
+
+ def updateBar(self):
+ view = self.parentItem()
+
+ if view is None:
+ return
+
+ view_width = view.viewRect().width() * self.pixel_size
+ target_size = view_width * self._target_relative_size
+
+ exponent = np.floor(np.log10(target_size))
+ mantissa = target_size / np.power(10, exponent)
+
+ # Get the "nice" size of the scalebar
+ nice_size = (
+ self._nice_numbers[np.argmin(np.abs(mantissa - self._nice_numbers))]
+ * 10**exponent
+ )
+
+ p1 = view.mapFromViewToItem(self, QtCore.QPointF(0, 0))
+ p2 = view.mapFromViewToItem(
+ self, QtCore.QPointF(nice_size / self.pixel_size, 0)
+ )
+ w = (p2 - p1).x()
+ self.bar.setRect(QtCore.QRectF(-w, 0, w, self._width))
+ self.text.setPos(-w / 2.0, 0)
+ self.text.setText(f"{round(nice_size,sigfigs=1,output_type=str)} {self.units}")
+
+ def boundingRect(self):
+ return QtCore.QRectF()
+
+ def setParentItem(self, p):
+ ret = GraphicsObject.setParentItem(self, p)
+ if self.offset is not None:
+ offset = Point(self.offset)
+ anchorx = 1 if offset[0] <= 0 else 0
+ anchory = 1 if offset[1] <= 0 else 0
+ anchor = (anchorx, anchory)
+ self.anchor(itemPos=anchor, parentPos=anchor, offset=offset)
+ return ret
diff --git a/src/py4D_browser/update_views.py b/src/py4D_browser/update_views.py
index 8b7fd52..7b7c332 100644
--- a/src/py4D_browser/update_views.py
+++ b/src/py4D_browser/update_views.py
@@ -1,6 +1,7 @@
import pyqtgraph as pg
import numpy as np
import py4DSTEM
+from functools import partial
from py4D_browser.utils import pg_point_roi, make_detector
@@ -49,7 +50,7 @@ def update_real_space_view(self, reset=False):
# update the label:
self.diffraction_space_view_text.setText(
- f"[{slice_x.start}:{slice_x.stop},{slice_y.start}:{slice_y.stop}]"
+ f"Diffraction Space Range: [{slice_x.start}:{slice_x.stop},{slice_y.start}:{slice_y.stop}]"
)
if detector_mode == "Integrating":
@@ -66,7 +67,9 @@ def update_real_space_view(self, reset=False):
x0 = self.virtual_detector_roi.pos()[0] + R
y0 = self.virtual_detector_roi.pos()[1] + R
- self.diffraction_space_view_text.setText(f"[({x0:.0f},{y0:.0f}),{R:.0f}]")
+ self.diffraction_space_view_text.setText(
+ f"Detector Center: ({x0:.0f},{y0:.0f}), Radius: {R:.0f}"
+ )
mask = make_detector(
(self.datacube.Q_Nx, self.datacube.Q_Ny), "circle", ((x0, y0), R)
@@ -85,7 +88,7 @@ def update_real_space_view(self, reset=False):
R_inner -= 1
self.diffraction_space_view_text.setText(
- f"[({x0:.0f},{y0:.0f}),({R_inner:.0f},{R_outer:.0f})]"
+ f"Detector Center: ({x0:.0f},{y0:.0f}), Radii: ({R_inner:.0f},{R_outer:.0f})"
)
mask = make_detector(
@@ -104,7 +107,7 @@ def update_real_space_view(self, reset=False):
yc = np.clip(yc, 0, self.datacube.Q_Ny - 1)
vimg = self.datacube.data[:, :, xc, yc]
- self.diffraction_space_view_text.setText(f"[{xc},{yc}]")
+ self.diffraction_space_view_text.setText(f"Diffraction Pixel: [{xc},{yc}]")
else:
raise ValueError("Detector shape not recognized")
@@ -146,7 +149,7 @@ def update_real_space_view(self, reset=False):
elif detector_mode == "CoM Angle":
vimg = np.arctan2(CoMy, CoMx)
elif detector_mode == "iCoM":
- dpc = py4DSTEM.process.phase.DPCReconstruction(verbose=False)
+ dpc = py4DSTEM.process.phase.DPC(verbose=False)
dpc.preprocess(
force_com_measured=[CoMx, CoMy],
plot_rotation=False,
@@ -158,7 +161,7 @@ def update_real_space_view(self, reset=False):
raise ValueError("Mode logic gone haywire!")
else:
- raise ValueError("Oppsie")
+ raise ValueError("Oopsie")
if scaling_mode == "Linear":
new_view = vimg
@@ -168,12 +171,28 @@ def update_real_space_view(self, reset=False):
new_view = np.sqrt(np.maximum(vimg, 0))
else:
raise ValueError("Mode not recognized")
- self.real_space_widget.setImage(new_view.T, autoLevels=True)
+
+ self.unscaled_realspace_image = vimg
+
+ self.real_space_widget.setImage(
+ new_view.T,
+ autoLevels=reset or self.realspace_rescale_button.latched,
+ autoRange=reset,
+ )
# Update FFT view
- fft = np.abs(np.fft.fftshift(np.fft.fft2(new_view))) ** 0.5
- levels = (np.min(fft), np.percentile(fft, 99.9))
- self.fft_widget.setImage(fft.T, autoLevels=False, levels=levels, autoRange=reset)
+ if self.fft_source_action_group.checkedAction().text() == "Virtual Image FFT":
+ fft = np.abs(np.fft.fftshift(np.fft.fft2(new_view))) ** 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()
def update_diffraction_space_view(self, reset=False):
@@ -201,7 +220,7 @@ def update_diffraction_space_view(self, reset=False):
xc = np.clip(xc, 0, self.datacube.R_Nx - 1)
yc = np.clip(yc, 0, self.datacube.R_Ny - 1)
- self.real_space_view_text.setText(f"[{xc},{yc}]")
+ self.real_space_view_text.setText(f"Real Space Pixel: [{xc},{yc}]")
DP = self.datacube.data[xc, yc]
elif detector_shape == "Rectangular":
@@ -213,21 +232,16 @@ def update_diffraction_space_view(self, reset=False):
# update the label:
self.real_space_view_text.setText(
- f"[{slice_x.start}:{slice_x.stop},{slice_y.start}:{slice_y.stop}]"
+ f"Real Space Range: [{slice_x.start}:{slice_x.stop},{slice_y.start}:{slice_y.stop}]"
)
DP = np.sum(self.datacube.data[slice_x, slice_y], axis=(0, 1))
- # if detector_mode == "Integrating":
- # vimg = np.sum(self.datacube.data[:, :, slice_x, slice_y], axis=(2, 3))
- # elif detector_mode == "Maximum":
- # vimg = np.max(self.datacube.data[:, :, slice_x, slice_y], axis=(2, 3))
- # else:
- # mask = np.zeros((self.datacube.Q_Nx, self.datacube.Q_Ny), dtype=np.bool_)
- # mask[slice_x, slice_y] = True
else:
raise ValueError("Detector shape not recognized")
+ self.unscaled_diffraction_image = DP
+
if scaling_mode == "Linear":
new_view = DP
elif scaling_mode == "Log":
@@ -238,9 +252,21 @@ def update_diffraction_space_view(self, reset=False):
raise ValueError("Mode not recognized")
self.diffraction_space_widget.setImage(
- new_view.T, autoLevels=reset, autoRange=reset
+ new_view.T,
+ autoLevels=reset or self.diffraction_rescale_button.latched,
+ autoRange=reset,
)
+ if self.fft_source_action_group.checkedAction().text() == "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.fft_widget.setImage(
+ fft.T, autoLevels=False, levels=levels, autoRange=mode_switch
+ )
+
def update_realspace_detector(self):
# change the shape of the detector, then update the view
@@ -260,14 +286,16 @@ def update_realspace_detector(self):
# Remove existing detector
if hasattr(self, "real_space_point_selector"):
self.real_space_widget.view.scene().removeItem(self.real_space_point_selector)
+ self.real_space_point_selector = None
if hasattr(self, "real_space_rect_selector"):
self.real_space_widget.view.scene().removeItem(self.real_space_rect_selector)
+ self.real_space_rect_selector = None
# Rectangular detector
if detector_shape == "Point":
self.real_space_point_selector = pg_point_roi(self.real_space_widget.getView())
self.real_space_point_selector.sigRegionChanged.connect(
- self.update_diffraction_space_view
+ partial(self.update_diffraction_space_view, False)
)
elif detector_shape == "Rectangular":
@@ -276,13 +304,13 @@ def update_realspace_detector(self):
)
self.real_space_widget.getView().addItem(self.real_space_rect_selector)
self.real_space_rect_selector.sigRegionChangeFinished.connect(
- self.update_diffraction_space_view
+ partial(self.update_diffraction_space_view, False)
)
else:
raise ValueError("Unknown detector shape! Got: {}".format(detector_shape))
- self.update_diffraction_space_view()
+ self.update_diffraction_space_view(reset=True)
def update_diffraction_detector(self):
@@ -303,16 +331,20 @@ def update_diffraction_detector(self):
self.diffraction_space_widget.view.scene().removeItem(
self.virtual_detector_point
)
+ self.virtual_detector_point = None
if hasattr(self, "virtual_detector_roi"):
self.diffraction_space_widget.view.scene().removeItem(self.virtual_detector_roi)
+ self.virtual_detector_roi = None
if hasattr(self, "virtual_detector_roi_inner"):
self.diffraction_space_widget.view.scene().removeItem(
self.virtual_detector_roi_inner
)
+ self.virtual_detector_roi_inner = None
if hasattr(self, "virtual_detector_roi_outer"):
self.diffraction_space_widget.view.scene().removeItem(
self.virtual_detector_roi_outer
)
+ self.virtual_detector_roi_outer = None
# Rectangular detector
if detector_shape == "Point":
@@ -320,7 +352,7 @@ def update_diffraction_detector(self):
self.diffraction_space_widget.getView()
)
self.virtual_detector_point.sigRegionChanged.connect(
- self.update_real_space_view
+ partial(self.update_real_space_view, False)
)
elif detector_shape == "Rectangular":
@@ -329,7 +361,7 @@ def update_diffraction_detector(self):
)
self.diffraction_space_widget.getView().addItem(self.virtual_detector_roi)
self.virtual_detector_roi.sigRegionChangeFinished.connect(
- self.update_real_space_view
+ partial(self.update_real_space_view, False)
)
# Circular detector
@@ -339,7 +371,7 @@ def update_diffraction_detector(self):
)
self.diffraction_space_widget.getView().addItem(self.virtual_detector_roi)
self.virtual_detector_roi.sigRegionChangeFinished.connect(
- self.update_real_space_view
+ partial(self.update_real_space_view, False)
)
# Annular dector
@@ -360,28 +392,74 @@ def update_diffraction_detector(self):
self.diffraction_space_widget.getView().addItem(self.virtual_detector_roi_inner)
# Connect size/position of inner and outer detectors
- self.virtual_detector_roi_outer.sigRegionChangeFinished.connect(
+ self.virtual_detector_roi_outer.sigRegionChanged.connect(
self.update_annulus_pos
)
- self.virtual_detector_roi_outer.sigRegionChangeFinished.connect(
+ self.virtual_detector_roi_outer.sigRegionChanged.connect(
self.update_annulus_radii
)
- self.virtual_detector_roi_inner.sigRegionChangeFinished.connect(
+ self.virtual_detector_roi_inner.sigRegionChanged.connect(
self.update_annulus_radii
)
# Connect to real space view update function
self.virtual_detector_roi_outer.sigRegionChangeFinished.connect(
- self.update_real_space_view
+ partial(self.update_real_space_view, False)
)
self.virtual_detector_roi_inner.sigRegionChangeFinished.connect(
- self.update_real_space_view
+ partial(self.update_real_space_view, False)
)
else:
raise ValueError("Unknown detector shape! Got: {}".format(detector_shape))
- self.update_real_space_view()
+ self.update_real_space_view(reset=True)
+
+
+def nudge_real_space_selector(self, dx, dy):
+ if (
+ hasattr(self, "real_space_point_selector")
+ and self.real_space_point_selector is not None
+ ):
+ selector = self.real_space_point_selector
+ elif (
+ hasattr(self, "real_space_rect_selector")
+ and self.real_space_rect_selector is not None
+ ):
+ selector = self.real_space_rect_selector
+ else:
+ raise RuntimeError("Can't find the real space selector!")
+
+ position = selector.pos()
+ position[0] += dy
+ position[1] += dx
+
+ selector.setPos(position)
+
+
+def nudge_diffraction_selector(self, dx, dy):
+ if (
+ hasattr(self, "virtual_detector_point")
+ and self.virtual_detector_point is not None
+ ):
+ selector = self.virtual_detector_point
+ elif (
+ hasattr(self, "virtual_detector_roi") and self.virtual_detector_roi is not None
+ ):
+ selector = self.virtual_detector_roi
+ elif (
+ hasattr(self, "virtual_detector_roi_outer")
+ and self.virtual_detector_roi_outer is not None
+ ):
+ selector = self.virtual_detector_roi_outer
+ else:
+ raise RuntimeError("Can't find the diffraction space selector!")
+
+ position = selector.pos()
+ position[0] += dy
+ position[1] += dx
+
+ selector.setPos(position)
def update_annulus_pos(self):
@@ -393,7 +471,7 @@ def update_annulus_pos(self):
# Only outer annulus is draggable; when it moves, update position of inner annulus
x0 = self.virtual_detector_roi_outer.pos().x() + R_outer
y0 = self.virtual_detector_roi_outer.pos().y() + R_outer
- self.virtual_detector_roi_inner.setPos(x0 - R_inner, y0 - R_inner)
+ self.virtual_detector_roi_inner.setPos(x0 - R_inner, y0 - R_inner, update=False)
def update_annulus_radii(self):
@@ -402,5 +480,7 @@ def update_annulus_radii(self):
if R_outer < R_inner:
x0 = self.virtual_detector_roi_outer.pos().x() + R_outer
y0 = self.virtual_detector_roi_outer.pos().y() + R_outer
- self.virtual_detector_roi_outer.setSize(2 * R_inner + 6)
- self.virtual_detector_roi_outer.setPos(x0 - R_inner - 3, y0 - R_inner - 3)
+ self.virtual_detector_roi_outer.setSize(2 * R_inner + 6, update=False)
+ self.virtual_detector_roi_outer.setPos(
+ x0 - R_inner - 3, y0 - R_inner - 3, update=False
+ )
diff --git a/src/py4D_browser/utils.py b/src/py4D_browser/utils.py
index ed95364..fb7be24 100644
--- a/src/py4D_browser/utils.py
+++ b/src/py4D_browser/utils.py
@@ -1,5 +1,55 @@
import pyqtgraph as pg
import numpy as np
+from PyQt5.QtWidgets import QFrame, QPushButton, QApplication
+from PyQt5.QtCore import pyqtSignal
+from PyQt5.QtCore import Qt
+
+
+class VLine(QFrame):
+ # a simple vertical divider line
+ def __init__(self):
+ super(VLine, self).__init__()
+ self.setFrameShape(self.VLine | self.Sunken)
+
+
+class LatchingButton(QPushButton):
+ """
+ Subclass of QPushButton that acts as a momentary button,
+ unless shift is held down during the click, in which case
+ it toggles on.
+ Emits the "activated" signal whenever triggered, and
+ maintains the "latched" state when latched down.
+ """
+
+ activated = pyqtSignal()
+
+ def __init__(self, *args, **kwargs):
+ self.status_bar = kwargs.pop("status_bar", None)
+ self.latched = kwargs.pop("latched", False)
+ super().__init__(*args, **kwargs)
+ self.setCheckable(True)
+ self.clicked.connect(self.on_click)
+ if self.latched:
+ self.setChecked(True)
+ self.activated.emit()
+
+ def on_click(self, *args):
+ modifiers = QApplication.keyboardModifiers()
+
+ if self.latched:
+ self.setChecked(False)
+ self.latched = False
+ else:
+ if modifiers == Qt.ShiftModifier:
+ self.setChecked(True)
+ self.latched = True
+ self.activated.emit()
+ else:
+ self.setChecked(False)
+ self.latched = False
+ self.activated.emit()
+ if self.status_bar is not None:
+ self.status_bar.showMessage("Shift+click to keep on", 5_000)
def pg_point_roi(view_box):