Skip to content

Commit

Permalink
Merge pull request #1791 from magnunor/NEW_calibrate_signal2d
Browse files Browse the repository at this point in the history
Interactive calibration for Signal2D
  • Loading branch information
ericpre committed Apr 19, 2022
2 parents f79ad12 + 916b23b commit 842d6d9
Show file tree
Hide file tree
Showing 6 changed files with 459 additions and 2 deletions.
20 changes: 20 additions & 0 deletions doc/user_guide/signal2d.rst
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,26 @@ It is possible to crop interactively using :ref:`roi-label`. For example:
Interactive image cropping using a ROI.


Interactive calibration
-----------------------

The scale can be calibrated interactively by using
:py:meth:`~._signals.signal2d.Signal2D.calibrate`, which is used to
set the scale by dragging a line across some feature of known size.

.. code-block:: python
>>> s = hs.signals.Signal2D(np.random.random((200, 200)))
>>> s.calibrate()
The same function can also be used non-interactively.

.. code-block:: python
>>> s = hs.signals.Signal2D(np.random.random((200, 200)))
>>> s.calibrate(x0=1, y0=1, x1=5, y1=5, new_length=3.4, units="nm", interactive=False)
Add a linear ramp
-----------------
Expand Down
91 changes: 90 additions & 1 deletion hyperspy/_signals/signal2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from hyperspy._signals.signal1d import Signal1D
from hyperspy._signals.lazy import LazySignal
from hyperspy._signals.common_signal2d import CommonSignal2D
from hyperspy.signal_tools import PeaksFinder2D
from hyperspy.signal_tools import PeaksFinder2D, Signal2DCalibration
from hyperspy.docstrings.plot import (
BASE_PLOT_DOCSTRING, BASE_PLOT_DOCSTRING_PARAMETERS, PLOT2D_DOCSTRING,
PLOT2D_KWARGS_DOCSTRING)
Expand Down Expand Up @@ -761,6 +761,95 @@ def align2D(

align2D.__doc__ %= (SHOW_PROGRESSBAR_ARG, PARALLEL_ARG, MAX_WORKERS_ARG)

def calibrate(
self,
x0=None,
y0=None,
x1=None,
y1=None,
new_length=None,
units=None,
interactive=True,
display=True,
toolkit=None,
):
"""Calibrate the x and y signal dimensions.
Can be used either interactively, or by passing values as parameters.
Parameters
----------
x0, y0, x1, y1 : scalars, optional
If interactive is False, these must be set. If given in floats
the input will be in scaled axis values. If given in integers,
the input will be in non-scaled pixel values. Similar to how
integer and float input works when slicing using isig and inav.
new_length : scalar, optional
If interactive is False, this must be set.
units : string, optional
If interactive is False, this is used to set the axes units.
interactive : bool, default True
If True, will use a plot with an interactive line for calibration.
If False, x0, y0, x1, y1 and new_length must be set.
display : bool, default True
toolkit : string, optional
Examples
--------
>>> s = hs.signals.Signal2D(np.random.random((100, 100)))
>>> s.calibrate()
Running non-interactively
>>> s = hs.signals.Signal2D(np.random.random((100, 100)))
>>> s.calibrate(x0=10, y0=10, x1=60, y1=10, new_length=100,
... interactive=False, units="nm")
"""
self._check_signal_dimension_equals_two()
if interactive:
calibration = Signal2DCalibration(self)
calibration.gui(display=display, toolkit=toolkit)
else:
if None in (x0, y0, x1, y1, new_length):
raise ValueError(
"With interactive=False x0, y0, x1, y1 and new_length "
"must be set."
)
self._calibrate(x0, y0, x1, y1, new_length, units=units)

def _calibrate(self, x0, y0, x1, y1, new_length, units=None):
scale = self._get_signal2d_scale(x0, y0, x1, y1, new_length)
sa = self.axes_manager.signal_axes
sa[0].scale = scale
sa[1].scale = scale
if units is not None:
sa[0].units = units
sa[1].units = units

def _get_signal2d_scale(self, x0, y0, x1, y1, length):
sa = self.axes_manager.signal_axes
units = set([a.units for a in sa])
if len(units) != 1:
_logger.warning(
"The signal axes does not have the same units, this might lead to "
"strange values after this calibration"
)
scales = set([a.scale for a in sa])
if len(scales) != 1:
_logger.warning(
"The previous scaling is not the same for both axes, this might lead to "
"strange values after this calibration"
)
x0 = sa[0]._get_index(x0)
y0 = sa[1]._get_index(y0)
x1 = sa[0]._get_index(x1)
y1 = sa[1]._get_index(y1)
pos = ((x0, y0), (x1, y1))
old_length = np.linalg.norm(np.diff(pos, axis=0), axis=1)[0]
scale = length / old_length
return scale

def crop_image(self, top=None, bottom=None,
left=None, right=None, convert_units=False):
"""Crops an image in place.
Expand Down
1 change: 1 addition & 0 deletions hyperspy/hyperspy_extension.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ GUI:
- hyperspy.SimpleMessage
- hyperspy.Signal1D.spikes_removal_tool
- hyperspy.Signal2D.find_peaks
- hyperspy.Signal2D.calibrate
- hyperspy.Point1DROI
- hyperspy.Point2DROI
- hyperspy.SpanROI
Expand Down
150 changes: 149 additions & 1 deletion hyperspy/signal_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from hyperspy.docstrings.signal import HISTOGRAM_MAX_BIN_ARGS
from hyperspy.exceptions import SignalDimensionError
from hyperspy.axes import AxesManager, UniformDataAxis
from hyperspy.drawing.widgets import VerticalLineWidget
from hyperspy.drawing.widgets import Line2DWidget, VerticalLineWidget
from hyperspy.drawing._widgets.range import SpanSelector
from hyperspy import components1d
from hyperspy.component import Component
Expand All @@ -48,6 +48,154 @@
_logger = logging.getLogger(__name__)


class LineInSignal2D(t.HasTraits):
"""Adds a vertical draggable line to a spectrum that reports its
position to the position attribute of the class.
Attributes:
-----------
x0, y0, x1, y1 : floats
Position of the line in scaled units.
length : float
Length of the line in scaled units.
on : bool
Turns on and off the line
color : wx.Colour
The color of the line. It automatically redraws the line.
"""

x0, y0, x1, y1 = t.Float(0.0), t.Float(0.0), t.Float(1.0), t.Float(1.0)
length = t.Float(1.0)
is_ok = t.Bool(False)
on = t.Bool(False)
# The following is disabled because as of traits 4.6 the Color trait
# imports traitsui (!)
# try:
# color = t.Color("black")
# except ModuleNotFoundError: # traitsui is not installed
# pass
color_str = t.Str("black")

def __init__(self, signal):
if signal.axes_manager.signal_dimension != 2:
raise SignalDimensionError(signal.axes_manager.signal_dimension, 2)

self.signal = signal
if (self.signal._plot is None) or (not self.signal._plot.is_active):
self.signal.plot()
axis_dict0 = signal.axes_manager.signal_axes[0].get_axis_dictionary()
axis_dict1 = signal.axes_manager.signal_axes[1].get_axis_dictionary()
am = AxesManager([axis_dict1, axis_dict0])
am._axes[0].navigate = True
am._axes[1].navigate = True
self.axes_manager = am
self.on_trait_change(self.switch_on_off, "on")

def draw(self):
self.signal._plot.signal_plot.figure.canvas.draw_idle()

def _get_initial_position(self):
am = self.axes_manager
d0 = (am[0].high_value - am[0].low_value) / 10
d1 = (am[1].high_value - am[1].low_value) / 10
position = (
(am[0].low_value + d0, am[1].low_value + d1),
(am[0].high_value - d0, am[1].high_value - d1),
)
return position

def switch_on_off(self, obj, trait_name, old, new):
if not self.signal._plot.is_active:
return

if new is True and old is False:
self._line = Line2DWidget(self.axes_manager)
self._line.position = self._get_initial_position()
self._line.set_mpl_ax(self.signal._plot.signal_plot.ax)
self._line.linewidth = 1
self._color_changed("black", "black")
self.update_position()
self._line.events.changed.connect(self.update_position)
# There is not need to call draw because setting the
# color calls it.

elif new is False and old is True:
self._line.close()
self._line = None
self.draw()

def update_position(self, *args, **kwargs):
if not self.signal._plot.is_active:
return
pos = self._line.position
(self.x0, self.y0), (self.x1, self.y1) = pos
self.length = np.linalg.norm(np.diff(pos, axis=0), axis=1)[0]

def _color_changed(self, old, new):
if self.on is False:
return
self.draw()


@add_gui_method(toolkey="hyperspy.Signal2D.calibrate")
class Signal2DCalibration(LineInSignal2D):
new_length = t.Float(t.Undefined, label="New length")
scale = t.Float()
units = t.Unicode()

def __init__(self, signal):
super(Signal2DCalibration, self).__init__(signal)
if signal.axes_manager.signal_dimension != 2:
raise SignalDimensionError(signal.axes_manager.signal_dimension, 2)
self.units = self.signal.axes_manager.signal_axes[0].units
self.scale = self.signal.axes_manager.signal_axes[0].scale
self.on = True

def _new_length_changed(self, old, new):
# If the line position is invalid or the new length is not defined do
# nothing
if (
np.isnan(self.x0)
or np.isnan(self.y0)
or np.isnan(self.x1)
or np.isnan(self.y1)
or self.new_length is t.Undefined
):
return
self.scale = self.signal._get_signal2d_scale(
self.x0, self.y0, self.x1, self.y1, self.new_length
)

def _length_changed(self, old, new):
# If the line position is invalid or the new length is not defined do
# nothing
if (
np.isnan(self.x0)
or np.isnan(self.y0)
or np.isnan(self.x1)
or np.isnan(self.y1)
or self.new_length is t.Undefined
):
return
self.scale = self.signal._get_signal2d_scale(
self.x0, self.y0, self.x1, self.y1, self.new_length
)

def apply(self):
if self.new_length is t.Undefined:
_logger.warn("Input a new length before pressing apply.")
return
x0, y0, x1, y1 = self.x0, self.y0, self.x1, self.y1
if np.isnan(x0) or np.isnan(y0) or np.isnan(x1) or np.isnan(y1):
_logger.warn("Line position is not valid")
return
self.signal._calibrate(
x0=x0, y0=y0, x1=x1, y1=y1, new_length=self.new_length, units=self.units
)
self.signal._replot()


class SpanSelectorInSignal1D(t.HasTraits):
ss_left_value = t.Float(np.nan)
ss_right_value = t.Float(np.nan)
Expand Down

0 comments on commit 842d6d9

Please sign in to comment.