Skip to content

Commit

Permalink
feat(interactive.imagetool): add normalization option to View menu
Browse files Browse the repository at this point in the history
  • Loading branch information
kmnhan committed Oct 22, 2024
1 parent 1abf687 commit 53e2cf2
Show file tree
Hide file tree
Showing 4 changed files with 256 additions and 34 deletions.
160 changes: 142 additions & 18 deletions src/erlab/interactive/imagetool/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from erlab.interactive.utils import DictMenuBar, copy_to_clipboard, generate_code

if TYPE_CHECKING:
from collections.abc import Callable, Collection
from collections.abc import Callable, Collection, Hashable

from erlab.interactive.imagetool.core import ImageSlicerState
from erlab.interactive.imagetool.slicer import ArraySlicer
Expand Down Expand Up @@ -518,6 +518,11 @@ def _generate_menu_kwargs(self) -> dict:
"toggled": self._set_colormap_options,
"sep_after": True,
},
"Normalize": {"triggered": self._normalize},
"Reset": {
"triggered": self._reset_filters,
"sep_after": True,
},
},
},
"editMenu": {
Expand Down Expand Up @@ -671,6 +676,15 @@ def _rotate(self) -> None:
dialog = RotationDialog(self.slicer_area)
dialog.exec()

@QtCore.Slot()
def _normalize(self) -> None:
dialog = NormalizeDialog(self.slicer_area)
dialog.exec()

@QtCore.Slot()
def _reset_filters(self) -> None:
self.slicer_area.apply_func(lambda x: x)

Check warning on line 686 in src/erlab/interactive/imagetool/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/erlab/interactive/imagetool/__init__.py#L686

Added line #L686 was not covered by tests

def _set_colormap_options(self) -> None:
self.slicer_area.set_colormap(
reverse=self.colorAct[0].isChecked(),
Expand Down Expand Up @@ -784,10 +798,8 @@ def _to_hdf5(darr: xr.DataArray, file: str, **kwargs) -> None:
fn(self.slicer_area._data, files[0], **kargs)


class _DataEditDialog(QtWidgets.QDialog):
class _DataManipulationDialog(QtWidgets.QDialog):
title: str | None = None
prefix: str = ""
suffix: str = ""
show_copy_button: bool = False

def __init__(self, slicer_area: ImageSlicerArea) -> None:
Expand All @@ -802,10 +814,6 @@ def __init__(self, slicer_area: ImageSlicerArea) -> None:

self.setup_widgets()

self.new_window_check = QtWidgets.QCheckBox("Open in New Window")
self.new_window_check.setChecked(True)
self.layout_.addRow(self.new_window_check)

self.buttonBox = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.StandardButton.Ok
| QtWidgets.QDialogButtonBox.StandardButton.Cancel
Expand All @@ -831,6 +839,35 @@ def layout_(self) -> QtWidgets.QFormLayout:
def array_slicer(self) -> ArraySlicer:
return self.slicer_area.array_slicer

def setup_widgets(self) -> None:
# Overridden by subclasses
pass

Check warning on line 844 in src/erlab/interactive/imagetool/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/erlab/interactive/imagetool/__init__.py#L844

Added line #L844 was not covered by tests

def process_data(self) -> xr.DataArray:
# Overridden by subclasses
return self.slicer_area.data

Check warning on line 848 in src/erlab/interactive/imagetool/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/erlab/interactive/imagetool/__init__.py#L848

Added line #L848 was not covered by tests

def make_code(self) -> str:
# Overridden by subclasses
return ""

Check warning on line 852 in src/erlab/interactive/imagetool/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/erlab/interactive/imagetool/__init__.py#L852

Added line #L852 was not covered by tests


class _DataTransformDialog(_DataManipulationDialog):
"""
Parent class for implementing data changes that affect both shape and values.
These changes are destructive and cannot be undone.
"""

prefix: str = ""
suffix: str = ""

def __init__(self, slicer_area: ImageSlicerArea) -> None:
super().__init__(slicer_area)
self.new_window_check = QtWidgets.QCheckBox("Open in New Window")
self.new_window_check.setChecked(True)
self.layout_.insertRow(-1, self.new_window_check)

@QtCore.Slot()
def accept(self) -> None:
if self.slicer_area.data.name is not None:
Expand All @@ -844,20 +881,45 @@ def accept(self) -> None:
self.slicer_area.set_data(self.process_data().rename(new_name))
super().accept()

def setup_widgets(self) -> None:
# Overridden by subclasses
pass

def process_data(self) -> xr.DataArray:
# Overridden by subclasses
return self.slicer_area.data
class _DataFilterDialog(_DataManipulationDialog):
"""Parent class for implementing data changes that affect only the values."""

def make_code(self) -> str:
# Overridden by subclasses
return ""
enable_preview: bool = True

def __init__(self, slicer_area: ImageSlicerArea) -> None:
super().__init__(slicer_area)
self._previewed: bool = False

if self.enable_preview:
self.preview_button = QtWidgets.QPushButton("Preview")
self.preview_button.clicked.connect(self._preview)
self.buttonBox.addButton(
self.preview_button, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole
)

@QtCore.Slot()
def _preview(self):
self._previewed = True
self.slicer_area.apply_func(self.func)

@QtCore.Slot()
def reject(self) -> None:
if self._previewed:
self.slicer_area.apply_func(None)
super().reject()

@QtCore.Slot()
def accept(self) -> None:
self.slicer_area.apply_func(self.func)
super().accept()

def func(self, data: xr.DataArray) -> xr.DataArray:
# Implement this method in subclasses
raise NotImplementedError

Check warning on line 919 in src/erlab/interactive/imagetool/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/erlab/interactive/imagetool/__init__.py#L919

Added line #L919 was not covered by tests


class RotationDialog(_DataEditDialog):
class RotationDialog(_DataTransformDialog):
suffix = " Rotated"
show_copy_button = True

Expand Down Expand Up @@ -933,3 +995,65 @@ def make_code(self) -> str:
return generate_code(
rotate, [f"|{placeholder}|"], self._rotate_params, module="era.transform"
)


class NormalizeDialog(_DataFilterDialog):
title = "Normalize"
show_copy_button = False

def setup_widgets(self) -> None:
dim_group = QtWidgets.QGroupBox("Dimensions")
dim_layout = QtWidgets.QVBoxLayout()
dim_group.setLayout(dim_layout)

self.dim_checks: dict[Hashable, QtWidgets.QCheckBox] = {}

for d in self.slicer_area.data.dims:
self.dim_checks[d] = QtWidgets.QCheckBox(str(d))
dim_layout.addWidget(self.dim_checks[d])

option_group = QtWidgets.QGroupBox("Options")
option_layout = QtWidgets.QVBoxLayout()
option_group.setLayout(option_layout)

self.opts: list[QtWidgets.QRadioButton] = []
self.opts.append(QtWidgets.QRadioButton("Data/Area"))
self.opts.append(QtWidgets.QRadioButton("(Data−m)/(M−m)"))
self.opts.append(QtWidgets.QRadioButton("Data−m"))
self.opts.append(QtWidgets.QRadioButton("(Data−m)/Area"))

self.opts[0].setChecked(True)
for opt in self.opts:
option_layout.addWidget(opt)

self.layout_.addRow(dim_group)
self.layout_.addRow(option_group)

def func(self, data: xr.DataArray) -> xr.DataArray:
norm_dims = tuple(k for k, v in self.dim_checks.items() if v.isChecked())
if len(norm_dims) == 0:
return data

Check warning on line 1035 in src/erlab/interactive/imagetool/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/erlab/interactive/imagetool/__init__.py#L1035

Added line #L1035 was not covered by tests

calc_area: bool = self.opts[0].isChecked() or self.opts[3].isChecked()
calc_minimum: bool = not self.opts[0].isChecked()
calc_maximum: bool = self.opts[1].isChecked()

if calc_area:
area = data.mean(norm_dims)

if calc_minimum:
minimum = data.min(norm_dims)

if calc_maximum:
maximum = data.max(norm_dims)

if self.opts[0].isChecked():
return data / area

if self.opts[1].isChecked():
return (data - minimum) / (maximum - minimum)

if self.opts[2].isChecked():
return data - minimum

return (data - minimum) / area
60 changes: 47 additions & 13 deletions src/erlab/interactive/imagetool/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Sequence

from pyqtgraph.graphicsItems.ViewBox import ViewBoxMenu

from erlab.interactive.imagetool.slicer import ArraySlicerState


Expand Down Expand Up @@ -970,6 +968,35 @@ def update_values(
self.array_slicer.clear_val_cache(include_vals=True)
self.refresh_all(only_plots=True)

# This will update colorbar limits if visible
self.lock_levels(self.levels_locked)

def apply_func(self, func: Callable[[xr.DataArray], xr.DataArray] | None) -> None:
"""Apply a function to the data.
The function must accept the data as the first argument and return a new
DataArray. The returned DataArray must have the same dimensions and coordinates
as the original data.
This action is not recorded in the history, and the data is not affected. Only
one function can be applied at a time.
Parameters
----------
func
The function to apply to the data. if None, the data is restored.
"""
# self._data is original data passed to `set_data`
# self.data is the current data transformed by ArraySlicer
if self._data is None:
return

Check warning on line 993 in src/erlab/interactive/imagetool/core.py

View check run for this annotation

Codecov / codecov/patch

src/erlab/interactive/imagetool/core.py#L993

Added line #L993 was not covered by tests

if func is None:
self.update_values(self._data)
else:
self.update_values(func(self._data))

@QtCore.Slot(int, int)
@link_slicer
@record_history
Expand Down Expand Up @@ -1534,12 +1561,23 @@ def __init__(
for act in ["Transforms", "Downsample", "Average", "Alpha", "Points"]:
self.setContextMenuActionVisible(act, False)

self.vb.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.CrossCursor))

for i in (0, 1):
# Hide unnecessary menu items
self.vb.menu.ctrl[i].linkCombo.setVisible(False)
self.vb.menu.ctrl[i].label.setVisible(False)

self.vb.menu.addSeparator()

save_action = self.vb.menu.addAction("Save data as HDF5")
save_action.triggered.connect(self.save_current_data)

copy_code_action = self.vb.menu.addAction("Copy selection code")
copy_code_action.triggered.connect(self.copy_selection_code)

self.vb.menu.addSeparator()

if image:
goldtool_action = self.vb.menu.addAction("Open in goldtool")
self._goldtool: None | QtWidgets.QWidget = None
Expand All @@ -1549,10 +1587,7 @@ def __init__(
self._dtool: None | QtWidgets.QWidget = None
dtool_action.triggered.connect(self.open_in_dtool)

for i in (0, 1):
self.getViewBoxMenu().ctrl[i].linkCombo.setVisible(False)
self.getViewBoxMenu().ctrl[i].label.setVisible(False)
self.getViewBox().setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.CrossCursor))
self.vb.menu.addSeparator()

self.slicer_area = slicer_area
self.display_axis = display_axis
Expand Down Expand Up @@ -1713,9 +1748,6 @@ def getMenu(self) -> QtWidgets.QMenu:
def getViewBox(self) -> pg.ViewBox:
return self.vb

def getViewBoxMenu(self) -> ViewBoxMenu:
return self.getViewBox().menu

def mouseDragEvent(
self, ev: mouseEvents.MouseDragEvent | mouseEvents.MouseClickEvent
) -> None:
Expand Down Expand Up @@ -1953,9 +1985,11 @@ def _print_angle():
self._guideline_angle = line.angle_effective
self._guideline_offset = [line_pos.x(), line_pos.y()]
for i in range(2):
self._guideline_offset[i] = np.round(
self._guideline_offset[i],
self.array_slicer.get_significant(self.display_axis[i]),
self._guideline_offset[i] = float(
np.round(
self._guideline_offset[i],
self.array_slicer.get_significant(self.display_axis[i]),
)
)

self.setTitle(
Expand Down Expand Up @@ -2171,4 +2205,4 @@ def setVisible(self, visible: bool) -> None:
self.cb._span.blockSignals(not visible)

if visible:
self.cb._span.setRegion(self.cb.limits)
self.cb.setSpanRegion(self.cb.limits)
2 changes: 1 addition & 1 deletion src/erlab/interactive/imagetool/slicer.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ def validate_array(data: xr.DataArray) -> xr.DataArray:
The converted data.
"""
data = data.squeeze()
data = data.copy().squeeze()

if data.ndim < 2:
raise ValueError("Data must have at least two dimensions.")
Expand Down
Loading

0 comments on commit 53e2cf2

Please sign in to comment.