Skip to content
Merged
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,6 @@ py4DSTEM/test/unit_test_data/
ehthumbs.db
Thumbs.db
Untitled.ipynb

# Claude Code #
.claude/
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "py4D_browser"
version = "1.5.1"
dynamic = ["version"]
authors = [
{ name="Steven Zeltmann", email="steven.zeltmann@berkeley.edu" },
]
Expand All @@ -26,6 +26,7 @@ dependencies = [
"pyqtgraph >= 0.11",
"sigfig",
"show-in-file-manager",
"click",
]

[project.scripts]
Expand All @@ -43,3 +44,6 @@ where = ["src"]

[tool.setuptools.package-data]
py4D_browser = ["*.png"]

[tool.setuptools.dynamic]
version = {attr = "py4D_browser.version.__version__"}
1 change: 1 addition & 0 deletions src/py4D_browser/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from py4D_browser.main_window import DataViewer
from py4D_browser.version import __version__
196 changes: 195 additions & 1 deletion src/py4D_browser/dialogs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from PyQt5.QtWidgets import QPushButton, QLabel
import math

from PyQt5.QtWidgets import QPushButton, QLabel, QDialogButtonBox
from PyQt5.QtWidgets import (
QDialog,
QHBoxLayout,
Expand Down Expand Up @@ -103,3 +105,195 @@ def get_next_rect(self, current, direction):
return i, self.N // i

raise ValueError("Factor finding failed, frustratingly.")


class BinningDialog(QDialog):
"""Dialog to select a binning factor for loading binned data.

Displays file size, original dimensions, and estimated RAM usage
both before and after binning.
"""

def __init__(
self,
filepath: str,
file_size: int,
shape: tuple | None,
dtype: str | None,
parent=None,
):
super().__init__(parent=parent)
self.setWindowTitle("Select Binning Factor")

self.filepath = filepath
self.file_size = file_size
self.shape = shape
self.dtype = dtype

layout = QVBoxLayout(self)

# File path display
layout.addWidget(QLabel(f"File: {filepath}"))
layout.addSpacing(8)

# File info section
info_layout = QVBoxLayout()
info_layout.addWidget(QLabel("File Information:"))
info_layout.addWidget(QLabel(f" File size: {self._format_size(file_size)}"))

if shape is not None:
info_layout.addWidget(
QLabel(f" Dimensions: {' x '.join(map(str, shape))}")
)
if dtype:
info_layout.addWidget(QLabel(f" Data type: {dtype}"))
ram = self._calc_unbinned_ram()
info_layout.addWidget(QLabel(f" Un-binned RAM: {self._format_size(ram)}"))
else:
info_layout.addWidget(
QLabel(" Dimensions: unavailable for this file type")
)

layout.addLayout(info_layout)
layout.addSpacing(8)

# Binning control section
binning_layout = QVBoxLayout()
binning_layout.addWidget(QLabel("Binning Control:"))
binning_layout.addWidget(
QLabel(" Apply binning factor to detector (last 2) dimensions:")
)

spin_layout = QHBoxLayout()
spin_layout.addWidget(QLabel(" Bin factor:"), 0)

self.bin_spin = QSpinBox()
if self.shape is not None:
max_bin = min(self.shape[-2:])
self.bin_spin.setRange(1, max(max_bin, 1))
else:
self.bin_spin.setRange(1, 100)
self.bin_spin.setValue(4)
self.bin_spin.setSingleStep(1)
self.bin_spin.setAccelerated(True)
self.bin_spin.setKeyboardTracking(False)
self.bin_spin.valueChanged.connect(self._update_estimates)
spin_layout.addWidget(self.bin_spin)
spin_layout.addStretch()

binning_layout.addLayout(spin_layout)
layout.addLayout(binning_layout)
layout.addSpacing(8)

# Estimate section
estimate_layout = QVBoxLayout()
estimate_layout.addWidget(QLabel("Estimated Result:"))

self.binned_dims_label = QLabel()
estimate_layout.addWidget(self.binned_dims_label)

self.binned_ram_label = QLabel()
estimate_layout.addWidget(self.binned_ram_label)

layout.addLayout(estimate_layout)
layout.addStretch()

# Crop notice (shown when detector dims aren't even multiples of bin factor)
self.crop_label = QLabel()
self.crop_label.setStyleSheet("color: orange;")
self.crop_label.hide()
layout.addWidget(self.crop_label)

# Buttons
btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
btns.accepted.connect(self.accept)
btns.rejected.connect(self.reject)
self.ok_button = btns.button(QDialogButtonBox.Ok)
layout.addWidget(btns)

self._update_estimates()

# ---- public classmethod entry-point ----

@classmethod
def get_bin_value(cls, filepath, file_size, shape, dtype, parent=None):
"""Show the dialog and return (accepted, bin_value)."""
dlg = cls(
filepath=filepath,
file_size=file_size,
shape=shape,
dtype=dtype,
parent=parent,
)
ok = dlg.exec_() == QDialog.Accepted
return ok, dlg.bin_spin.value()

# ---- helpers ----

def _calc_unbinned_ram(self) -> int:
if self.shape is None:
return 0
itemsize = 1
if self.dtype:
itemsize = int(
self.dtype.replace("uint", "").replace("int", "").replace("float", "")
)
return int(math.prod(self.shape)) * itemsize

def _format_size(self, nbytes: int) -> str:
if nbytes <= 0:
return "N/A"
for unit in ("B", "KB", "MB", "GB", "TB"):
if nbytes < 1024 or unit == "TB":
f = nbytes / 1
if unit == "B" and nbytes == int(nbytes):
return f"{int(nbytes)} B"
return f"{f:.1f} {unit}"
nbytes /= 1024
return f"{nbytes:.1f} PB"

def _update_estimates(self):
bin_val = self.bin_spin.value()

if self.shape is not None:
dx, dy = self.shape[-2:]
crop_dx = (dx // bin_val) * bin_val
crop_dy = (dy // bin_val) * bin_val
binned = (crop_dx // bin_val, crop_dy // bin_val)
self.binned_dims_label.setText(
f" Binned detector dimensions: {' x '.join(map(str, binned))}"
)

# Compute RAM based on binned size. Integer sources always
# produce float32 output (averaging produces non-integer values),
# so use max(itemsize, 4) for integer dtypes.
itemsize = 1
if self.dtype:
itemsize = int(
self.dtype.replace("uint", "")
.replace("int", "")
.replace("float", "")
)
if self.dtype.startswith("uint") or self.dtype.startswith("int"):
itemsize = max(itemsize, 4) # integer → float32 output
binned_ram = int(math.prod(list(self.shape[:-2]) + list(binned))) * itemsize
self.binned_ram_label.setText(
f" Estimated RAM: {self._format_size(binned_ram)}"
)

# Show crop notice if dimensions need cropping
if dx % bin_val != 0 or dy % bin_val != 0:
self.crop_label.setText(
f" Note: Detector {dx} x {dy} will be cropped to {crop_dx} x {crop_dy} (dropping edge pixels)."
)
self.crop_label.show()
else:
self.crop_label.hide()
self.ok_button.setEnabled(True)
else:
self.binned_dims_label.setText(" Binned dimensions: N/A")
self.binned_ram_label.setText(
f" Estimated RAM: N/A (reduces by ~{bin_val**2}x vs un-binned)"
)
self.crop_label.hide()
self.ok_button.setEnabled(True)
69 changes: 56 additions & 13 deletions src/py4D_browser/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@
import platformdirs
from showinfm import show_in_file_manager

from py4D_browser.utils import VLine, LatchingButton, strtobool
from py4D_browser.utils import VLine, LatchingButton, strtobool, try_get_cmap
from py4D_browser.version import __version__
from py4D_browser.scalebar import ScaleBar


class DataViewer(QMainWindow):
"""
The class is used by instantiating and then entering the main Qt loop with, e.g.:
app = DataViewer(sys.argv)
win = DataViewer()
win.show()
app.exec_()
"""

Expand Down Expand Up @@ -78,25 +80,33 @@ class DataViewer(QMainWindow):
update_annulus_pos,
update_annulus_radii,
update_tooltip,
update_scalebars,
)

from py4D_browser.signals import (
register_result_callback,
set_internal_result_callback,
)

from py4D_browser.plugins import load_plugins
from py4D_browser.plugins import load_plugins, unload_plugins

signal_diffraction_data_changed = QtCore.pyqtSignal()
signal_virtual_image_data_changed = QtCore.pyqtSignal()
signal_datacube_changed = QtCore.pyqtSignal()

def __init__(self, argv):
def __init__(
self,
filepath: Optional[str] = None,
reset_state: bool = False,
debug_console: bool = False,
):
super().__init__()
# Define this as the QApplication object
self.qtapp = QApplication.instance()
if not self.qtapp:
self.qtapp = QApplication(argv)
import sys

self.qtapp = QApplication(sys.argv)

# Load settings from config file
self.config_path = os.path.join(
Expand Down Expand Up @@ -132,7 +142,7 @@ def __init__(self, argv):
self.unscaled_fft_image: Optional[np.ndarray] = None

# Reset stored state if so asked:
if os.environ.get("PY4DGUI_RESET"):
if reset_state:
self.settings.remove("last_state")
print("Cleared saved state, using defaults...")

Expand All @@ -157,11 +167,11 @@ def __init__(self, argv):
self.show()

# If a file was passed on the command line, open it
if len(argv) > 1:
self.load_file(argv[1])
if filepath is not None:
self.load_file(filepath)

# launch pyqtgraph's debug console if environment variable exists
if os.environ.get("PY4DGUI_DEBUG"):
# launch pyqtgraph's debug console if requested or environment variable exists
if debug_console or os.environ.get("PY4DGUI_DEBUG"):
pg.dbg(namespace={"main_window": self})

def setup_menus(self):
Expand Down Expand Up @@ -638,10 +648,26 @@ def setup_menus(self):
)
self.help_menu.addAction(self.show_config_file_action)

self.debug_console_action = QAction("&Debug Console", self)
self.debug_console_action.setShortcut(QtGui.QKeySequence("Ctrl+Shift+D"))
self.debug_console_action.triggered.connect(self._launch_debug_console)
self.help_menu.addAction(self.debug_console_action)

self.help_menu.addSeparator()

self.version_action = QAction(f"py4DGUI v{__version__}", self)
self.version_action.setEnabled(False)
self.help_menu.addAction(self.version_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_widget.setImage(np.zeros((128, 128)))

cmap_name = self.settings.value("gui/diffraction_colormap", "inferno")
cmap = try_get_cmap(cmap_name)
if cmap is not None:
self.diffraction_space_widget.setColorMap(cmap)

self.diffraction_space_widget.setMouseTracking(True)

Expand All @@ -660,7 +686,12 @@ def setup_views(self):

# Set up the real space window.
self.real_space_widget = pg.ImageView()
self.real_space_widget.setImage(np.zeros((512, 512)))
self.real_space_widget.setImage(np.zeros((256, 256)))

cmap_name = self.settings.value("gui/realspace_colormap", "thermal")
cmap = try_get_cmap(cmap_name)
if cmap is not None:
self.real_space_widget.setColorMap(cmap)

# Add point selector connected to displayed diffraction pattern
self.update_realspace_detector()
Expand All @@ -682,7 +713,12 @@ def setup_views(self):

# Set up the FFT window.
self.fft_widget = pg.ImageView()
self.fft_widget.setImage(np.zeros((512, 512)))
self.fft_widget.setImage(np.zeros((256, 256)))

cmap_name = self.settings.value("gui/fft_colormap", "yellowy")
cmap = try_get_cmap(cmap_name)
if cmap is not None:
self.fft_widget.setColorMap(cmap)

# FFT scale bar
self.fft_scale_bar = ScaleBar(pixel_size=1, units="1/px", width=10)
Expand Down Expand Up @@ -790,6 +826,13 @@ def resizeEvent(self, event):
# Store window size for next run
self.settings.setValue("last_state/window_size", event.size())

def _launch_debug_console(self):
pg.dbg(namespace={"main_window": self})

def closeEvent(self, event):
self.unload_plugins()
event.accept()

# Handle dragging and dropping a file on the window
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
Expand Down
Loading
Loading