diff --git a/extra_foam/algorithms/__init__.py b/extra_foam/algorithms/__init__.py index 8fa477355..29cd71997 100644 --- a/extra_foam/algorithms/__init__.py +++ b/extra_foam/algorithms/__init__.py @@ -42,4 +42,8 @@ from .computer_vision import ( edge_detect, fourier_transform_2d -) \ No newline at end of file +) + +from .peak_finding import ( + find_peaks_1d +) diff --git a/extra_foam/algorithms/peak_finding.py b/extra_foam/algorithms/peak_finding.py new file mode 100644 index 000000000..9bcda9abc --- /dev/null +++ b/extra_foam/algorithms/peak_finding.py @@ -0,0 +1,14 @@ +""" +Distributed under the terms of the BSD 3-Clause License. + +The full license is in the file LICENSE, distributed with this software. + +Author: Jun Zhu +Copyright (C) European X-Ray Free-Electron Laser Facility GmbH. +All rights reserved. +""" +from scipy.signal import find_peaks + + +def find_peaks_1d(a, *args, **kwargs): + return find_peaks(a, *args, **kwargs) diff --git a/extra_foam/gui/ctrl_widgets/azimuthal_integ_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/azimuthal_integ_ctrl_widget.py index 64171b6d0..2393688f0 100644 --- a/extra_foam/gui/ctrl_widgets/azimuthal_integ_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/azimuthal_integ_ctrl_widget.py @@ -11,7 +11,9 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QDoubleValidator, QIntValidator -from PyQt5.QtWidgets import QComboBox, QGridLayout, QLabel +from PyQt5.QtWidgets import ( + QCheckBox, QComboBox, QFrame, QGridLayout, QHBoxLayout, QLabel +) from .base_ctrl_widgets import _AbstractCtrlWidget from .smart_widgets import SmartBoundaryLineEdit, SmartLineEdit @@ -90,6 +92,12 @@ def __init__(self, *args, **kwargs): self._auc_range_le = SmartBoundaryLineEdit("0, Inf") self._fom_integ_range_le = SmartBoundaryLineEdit("0, Inf") + self._peak_finding_cb = QCheckBox("Peak finding") + self._peak_finding_cb.setChecked(True) + + self._non_reconfigurable_widgets = [ + ] + self.initUI() self.initConnections() @@ -97,49 +105,65 @@ def __init__(self, *args, **kwargs): def initUI(self): """Override.""" - layout = QGridLayout() + layout = QHBoxLayout() AR = Qt.AlignRight + param_widget = QFrame() + param_layout = QGridLayout() row = 0 - layout.addWidget(QLabel("Cx (pixel): "), row, 0, AR) - layout.addWidget(self._cx_le, row, 1) - layout.addWidget(QLabel("Cy (pixel): "), row, 2, AR) - layout.addWidget(self._cy_le, row, 3) - layout.addWidget(QLabel("Pixel x (m): "), row, 4, AR) - layout.addWidget(self._px_le, row, 5) - layout.addWidget(QLabel("Pixel y (m): "), row, 6, AR) - layout.addWidget(self._py_le, row, 7) + param_layout.addWidget(QLabel("Cx (pixel): "), row, 0, AR) + param_layout.addWidget(self._cx_le, row, 1) + param_layout.addWidget(QLabel("Cy (pixel): "), row, 2, AR) + param_layout.addWidget(self._cy_le, row, 3) + param_layout.addWidget(QLabel("Pixel x (m): "), row, 4, AR) + param_layout.addWidget(self._px_le, row, 5) + param_layout.addWidget(QLabel("Pixel y (m): "), row, 6, AR) + param_layout.addWidget(self._py_le, row, 7) row += 1 - layout.addWidget(QLabel("Sample distance (m): "), row, 0, AR) - layout.addWidget(self._sample_dist_le, row, 1) - layout.addWidget(QLabel("Rotation x (rad): "), row, 2, AR) - layout.addWidget(self._rx_le, row, 3) - layout.addWidget(QLabel("Rotation y (rad): "), row, 4, AR) - layout.addWidget(self._ry_le, row, 5) - layout.addWidget(QLabel("Rotation z (rad): "), row, 6, AR) - layout.addWidget(self._rz_le, row, 7) + param_layout.addWidget(QLabel("Sample distance (m): "), row, 0, AR) + param_layout.addWidget(self._sample_dist_le, row, 1) + param_layout.addWidget(QLabel("Rotation x (rad): "), row, 2, AR) + param_layout.addWidget(self._rx_le, row, 3) + param_layout.addWidget(QLabel("Rotation y (rad): "), row, 4, AR) + param_layout.addWidget(self._ry_le, row, 5) + param_layout.addWidget(QLabel("Rotation z (rad): "), row, 6, AR) + param_layout.addWidget(self._rz_le, row, 7) row += 1 - layout.addWidget(QLabel("Photon energy (keV): "), row, 0, AR) - layout.addWidget(self._photon_energy_le, row, 1) - layout.addWidget(QLabel("Integ method: "), row, 2, AR) - layout.addWidget(self._integ_method_cb, row, 3) - layout.addWidget(QLabel("Integ points: "), row, 4, AR) - layout.addWidget(self._integ_pts_le, row, 5) - layout.addWidget(QLabel("Integ range (1/A): "), row, 6, AR) - layout.addWidget(self._integ_range_le, row, 7) + param_layout.addWidget(QLabel("Photon energy (keV): "), row, 0, AR) + param_layout.addWidget(self._photon_energy_le, row, 1) + param_layout.addWidget(QLabel("Integ method: "), row, 2, AR) + param_layout.addWidget(self._integ_method_cb, row, 3) + param_layout.addWidget(QLabel("Integ points: "), row, 4, AR) + param_layout.addWidget(self._integ_pts_le, row, 5) + param_layout.addWidget(QLabel("Integ range (1/A): "), row, 6, AR) + param_layout.addWidget(self._integ_range_le, row, 7) row += 1 - layout.addWidget(QLabel("Norm: "), row, 0, AR) - layout.addWidget(self._norm_cb, row, 1) - layout.addWidget(QLabel("AUC range (1/A): "), row, 2, AR) - layout.addWidget(self._auc_range_le, row, 3) - layout.addWidget(QLabel("FOM range (1/A): "), row, 4, AR) - layout.addWidget(self._fom_integ_range_le, row, 5) - + param_layout.addWidget(QLabel("Norm: "), row, 0, AR) + param_layout.addWidget(self._norm_cb, row, 1) + param_layout.addWidget(QLabel("AUC range (1/A): "), row, 2, AR) + param_layout.addWidget(self._auc_range_le, row, 3) + param_layout.addWidget(QLabel("FOM range (1/A): "), row, 4, AR) + param_layout.addWidget(self._fom_integ_range_le, row, 5) + + param_widget.setLayout(param_layout) + + algo_widget = QFrame() + algo_layout = QGridLayout() + algo_layout.addWidget(self._peak_finding_cb) + algo_widget.setLayout(algo_layout) + + layout.addWidget(param_widget) + layout.addWidget(algo_widget) + layout.setContentsMargins(1, 1, 1, 1) self.setLayout(layout) + self.setFrameStyle(QFrame.NoFrame) + param_widget.setFrameStyle(QFrame.StyledPanel) + algo_widget.setFrameStyle(QFrame.StyledPanel) + def initConnections(self): """Override.""" mediator = self._mediator @@ -178,6 +202,9 @@ def initConnections(self): self._fom_integ_range_le.value_changed_sgn.connect( mediator.onAiFomIntegRangeChange) + self._peak_finding_cb.toggled.connect( + mediator.onAiPeakFindingChange) + def updateMetaData(self): """Override.""" self._photon_energy_le.returnPressed.emit() @@ -203,6 +230,8 @@ def updateMetaData(self): self._fom_integ_range_le.returnPressed.emit() + self._peak_finding_cb.toggled.emit(self._peak_finding_cb.isChecked()) + return True def loadMetaData(self): @@ -223,3 +252,5 @@ def loadMetaData(self): self._available_norms_inv[int(cfg['normalizer'])]) self._auc_range_le.setText(cfg['auc_range'][1:-1]) self._fom_integ_range_le.setText(cfg['fom_integ_range'][1:-1]) + + self._updateWidgetValue(self._peak_finding_cb, cfg, "peak_finding") diff --git a/extra_foam/gui/ctrl_widgets/base_ctrl_widgets.py b/extra_foam/gui/ctrl_widgets/base_ctrl_widgets.py index ccc2df792..64967ac2e 100644 --- a/extra_foam/gui/ctrl_widgets/base_ctrl_widgets.py +++ b/extra_foam/gui/ctrl_widgets/base_ctrl_widgets.py @@ -10,10 +10,14 @@ import abc from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QFrame, QGroupBox +from PyQt5.QtWidgets import ( + QCheckBox, QComboBox, QFrame, QGroupBox, QLineEdit, QAbstractSpinBox +) +from .smart_widgets import SmartBoundaryLineEdit from ..mediator import Mediator from ...database import MetaProxy +from ...logger import logger class _AbstractCtrlWidgetMixin: @@ -87,6 +91,39 @@ def onStop(self): for widget in self._non_reconfigurable_widgets: widget.setEnabled(True) + def _updateWidgetValue(self, widget, config, key, *, cast=None): + """Update widget value from meta data.""" + value = self._getMetaData(config, key) + if value is None: + return + + if cast is not None: + value = cast(value) + + if isinstance(widget, QCheckBox): + widget.setChecked(value == 'True') + elif isinstance(widget, SmartBoundaryLineEdit): + widget.setText(value[1:-1]) + elif isinstance(widget, QLineEdit): + widget.setText(value) + elif isinstance(widget, QAbstractSpinBox): + widget.setValue(value) + else: + logger.error(f"Unknown widget type: {type(widget)}") + + def _getMetaData(self, config, key): + """Convienient function to get metadata and capture key error. + + :param dict config: config dictionary. + :param str key: meta data key. + """ + try: + return config[key] + except KeyError: + # This happens when loading metadata in a new version with + # a config file in the old version. + logger.warning(f"Meta data key not found: {key}") + class _AbstractGroupBoxCtrlWidget(QGroupBox, _AbstractCtrlWidgetMixin): GROUP_BOX_STYLE_SHEET = 'QGroupBox:title {'\ diff --git a/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py index c46f58c2b..583cc2257 100644 --- a/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py @@ -19,12 +19,13 @@ ) from .base_ctrl_widgets import _AbstractCtrlWidget -from .smart_widgets import SmartLineEdit, SmartStringLineEdit +from .smart_widgets import SmartLineEdit from ..gui_helpers import invert_dict +from ..items import GeometryItem from ...config import config, GeomAssembler from ...database import Metadata as mt from ...geometries import module_indices -from ..items import GeometryItem +from ...logger import logger def _parse_table_widget(widget): @@ -213,16 +214,21 @@ def loadMetaData(self): cfg = self._meta.hget_all(mt.GEOMETRY_PROC) - self._assembler_cb.setCurrentText( - self._assemblers_inv[int(cfg["assembler"])]) - self._stack_only_cb.setChecked(cfg["stack_only"] == 'True') - self._geom_file_le.setText(cfg["geometry_file"]) + assembler = self._getMetaData(cfg, "assembler") + if assembler is not None: + self._assembler_cb.setCurrentText( + self._assemblers_inv[int(cfg["assembler"])]) + + self._updateWidgetValue(self._stack_only_cb, cfg, "stack_only") + self._updateWidgetValue(self._geom_file_le, cfg, "geometry_file") # TODO: check number of modules for JungFrau - coordinates = json.loads(cfg["coordinates"], encoding='utf8') - table = self._coordinates_tb - n_rows = table.rowCount() - n_cols = table.columnCount() - for j in range(n_cols): - for i in range(n_rows): - table.cellWidget(i, j).setText(str(coordinates[j][i])) + coordinates = self._getMetaData(cfg, "coordinates") + if coordinates is not None: + coordinates = json.loads(coordinates, encoding='utf8') + table = self._coordinates_tb + n_rows = table.rowCount() + n_cols = table.columnCount() + for j in range(n_cols): + for i in range(n_rows): + table.cellWidget(i, j).setText(str(coordinates[j][i])) diff --git a/extra_foam/gui/ctrl_widgets/image_transform_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/image_transform_ctrl_widget.py index a6f46ee13..2e1cfc802 100644 --- a/extra_foam/gui/ctrl_widgets/image_transform_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/image_transform_ctrl_widget.py @@ -162,17 +162,18 @@ def loadMetaData(self): """Override.""" cfg = self._meta.hget_all(mt.IMAGE_TRANSFORM_PROC) - self._ma_window_le.setText(str(cfg["ma_window"])) + self._updateWidgetValue(self._ma_window_le, cfg, "ma_window") # do not load transform type since it is not an "input" fft = self._fourier_transform - fft.logrithmic_cb.setChecked(cfg["fft:logrithmic"] == 'True') + self._updateWidgetValue(fft.logrithmic_cb, cfg, "fft:logrithmic") ed = self._edge_detection - ed.kernel_size_sp.setValue(int(cfg["ed:kernel_size"])) - ed.sigma_sp.setValue(float(cfg["ed:sigma"])) - ed.threshold_le.setText(cfg['ed:threshold'][1:-1]) + self._updateWidgetValue( + ed.kernel_size_sp, cfg, "ed:kernel_size", cast=int) + self._updateWidgetValue(ed.sigma_sp, cfg, "ed:sigma", cast=float) + self._updateWidgetValue(ed.threshold_le, cfg, "ed:threshold") def registerTransformType(self): self._mediator.onItTransformTypeChange( diff --git a/extra_foam/gui/image_tool/azimuthal_integ_1d_view.py b/extra_foam/gui/image_tool/azimuthal_integ_1d_view.py index 04f67f3d8..5ec78e991 100644 --- a/extra_foam/gui/image_tool/azimuthal_integ_1d_view.py +++ b/extra_foam/gui/image_tool/azimuthal_integ_1d_view.py @@ -13,6 +13,7 @@ from ..ctrl_widgets import AzimuthalIntegCtrlWidget from ..misc_widgets import FColor from ..plot_widgets import ImageViewF, PlotWidgetF +from ...algorithms import find_peaks_1d from ...config import AnalysisType, plot_labels @@ -31,16 +32,25 @@ def __init__(self, *, parent=None): self.setTitle('Azimuthal integration') self._plot = self.plotCurve(pen=FColor.mkPen("p")) + self._peaks = self.plotScatter( + pen=FColor.mkPen("r"), symbol="+", size=12) def updateF(self, data): """Override.""" - momentum, intensity = data.ai.x, data.ai.y + ai = data.ai + momentum, intensity = ai.x, ai.y if intensity is None: return self._plot.setData(momentum, intensity) + peaks = ai.peaks + if peaks is None: + self._peaks.setData([], []) + else: + self._peaks.setData(momentum[peaks], intensity[peaks]) + @create_imagetool_view(AzimuthalIntegCtrlWidget) class AzimuthalInteg1dView(_AbstractImageToolView): @@ -63,6 +73,7 @@ def __init__(self, *args, **kwargs): self._azimuthal_integ_1d_curve = AzimuthalInteg1dPlot() self.initUI() + self.initConnections() def initUI(self): """Override.""" diff --git a/extra_foam/gui/image_tool/tests/test_image_tool.py b/extra_foam/gui/image_tool/tests/test_image_tool.py index 8356ff832..136fb080f 100644 --- a/extra_foam/gui/image_tool/tests/test_image_tool.py +++ b/extra_foam/gui/image_tool/tests/test_image_tool.py @@ -586,6 +586,7 @@ def testAzimuthalInteg1dCtrlWidget(self): self.assertEqual(default_pixel_size, proc._pixel2) self.assertEqual(0, proc._poni1) self.assertEqual(0, proc._poni2) + self.assertTrue(proc._find_peaks) # test setting new values widget._photon_energy_le.setText("12.4") @@ -600,6 +601,7 @@ def testAzimuthalInteg1dCtrlWidget(self): widget._py_le.setText("0.000002") widget._cx_le.setText("-1000") widget._cy_le.setText("1000") + widget._peak_finding_cb.setChecked(False) proc.update() self.assertAlmostEqual(1e-10, proc._wavelength) self.assertAlmostEqual(0.3, proc._sample_dist) @@ -613,6 +615,7 @@ def testAzimuthalInteg1dCtrlWidget(self): self.assertEqual(0.000002, proc._pixel1) self.assertEqual(-1000 * 0.000001, proc._poni2) self.assertEqual(1000 * 0.000002, proc._poni1) + self.assertFalse(proc._find_peaks) # test loading meta data mediator = widget._mediator @@ -628,6 +631,7 @@ def testAzimuthalInteg1dCtrlWidget(self): mediator.onAiPixelSizeYChange(0.002) mediator.onAiIntegCenterXChange(1) mediator.onAiIntegCenterYChange(2) + mediator.onAiPeakFindingChange(True) widget.loadMetaData() self.assertEqual("2.0", widget._photon_energy_le.text()) self.assertEqual("0.2", widget._sample_dist_le.text()) @@ -641,6 +645,7 @@ def testAzimuthalInteg1dCtrlWidget(self): self.assertEqual("0.002", widget._py_le.text()) self.assertEqual("1", widget._cx_le.text()) self.assertEqual("2", widget._cy_le.text()) + self.assertTrue(widget._peak_finding_cb.isChecked()) def testRoiFomCtrlWidget(self): widget = self.image_tool._corrected_view._roi_fom_ctrl_widget diff --git a/extra_foam/gui/mediator.py b/extra_foam/gui/mediator.py index 538cfb6bd..2a2f0d00c 100644 --- a/extra_foam/gui/mediator.py +++ b/extra_foam/gui/mediator.py @@ -173,6 +173,9 @@ def onAiAucRangeChange(self, value: tuple): def onAiFomIntegRangeChange(self, value: tuple): self._meta.hset(mt.AZIMUTHAL_INTEG_PROC, 'fom_integ_range', str(value)) + def onAiPeakFindingChange(self, value: bool): + self._meta.hset(mt.AZIMUTHAL_INTEG_PROC, "peak_finding", str(value)) + def onPpModeChange(self, value: IntEnum): self._meta.hset(mt.PUMP_PROBE_PROC, 'mode', int(value)) diff --git a/extra_foam/gui/plot_widgets/plot_items.py b/extra_foam/gui/plot_widgets/plot_items.py index c1078d1f1..53a624daf 100644 --- a/extra_foam/gui/plot_widgets/plot_items.py +++ b/extra_foam/gui/plot_widgets/plot_items.py @@ -42,6 +42,10 @@ def setData(self, x, y): self.updateGraph() + def data(self): + """Override.""" + return self._x, self._y + def _prepareGraph(self): """Override.""" p = QPainterPath() @@ -97,6 +101,10 @@ def setData(self, x, y): self.updateGraph() + def data(self): + """Override.""" + return self._x, self._y + def _prepareGraph(self): """Override.""" self._graph = QPicture() @@ -180,6 +188,10 @@ def setData(self, x, y, y_min=None, y_max=None, beam=None): self.updateGraph() + def data(self): + """Override.""" + return self._x, self._y, self._y_min, self._y_max + def setBeam(self, w): self._beam = w diff --git a/extra_foam/gui/pyqtgraph/graphicsItems/GraphicsObject.py b/extra_foam/gui/pyqtgraph/graphicsItems/GraphicsObject.py index 8f6b0a334..262d7b386 100644 --- a/extra_foam/gui/pyqtgraph/graphicsItems/GraphicsObject.py +++ b/extra_foam/gui/pyqtgraph/graphicsItems/GraphicsObject.py @@ -59,6 +59,10 @@ def __init__(self, name=None, *args, **kwargs): def setData(self, *args, **kwargs): raise NotImplementedError + @abc.abstractmethod + def data(self): + raise NotImplementedError + def updateGraph(self): self._graph = None self.prepareGeometryChange() diff --git a/extra_foam/pipeline/data_model.py b/extra_foam/pipeline/data_model.py index 4ff3a43e5..cdbb7d5ce 100644 --- a/extra_foam/pipeline/data_model.py +++ b/extra_foam/pipeline/data_model.py @@ -185,11 +185,12 @@ def __init__(self): class AzimuthalIntegrationData(DataItem): """Azimuthal integration data item.""" - __slots__ = ['x', 'y', 'fom', 'q_map'] + __slots__ = ['x', 'y', 'fom', 'q_map', 'peaks'] def __init__(self): super().__init__() self.q_map = None + self.peaks = None class _RoiGeomBase(ABC): diff --git a/extra_foam/pipeline/processors/azimuthal_integration.py b/extra_foam/pipeline/processors/azimuthal_integration.py index 93670a9ad..4af3058d7 100644 --- a/extra_foam/pipeline/processors/azimuthal_integration.py +++ b/extra_foam/pipeline/processors/azimuthal_integration.py @@ -21,7 +21,9 @@ from ...database import Metadata as mt from ...utils import profiler -from extra_foam.algorithms import energy2wavelength, mask_image_data +from extra_foam.algorithms import ( + energy2wavelength, find_peaks_1d, mask_image_data +) class _AzimuthalIntegProcessorBase(_BaseProcessor): @@ -52,8 +54,12 @@ class _AzimuthalIntegProcessorBase(_BaseProcessor): _q_map (numpy.ndarray): momentum transfer of map of the detector image. q = 4 * pi * sin(theta) / lambda _ma_window (int): moving average window size. + _find_peaks (bool): whether to apply peak finding. """ + # maximum number of peaks expected + _MAX_N_PEAKS = 20 + def __init__(self): super().__init__() @@ -77,6 +83,8 @@ def __init__(self): self._integrator = None self._q_map = None + self._find_peaks = True + self._reset_ma = False def update(self): @@ -99,6 +107,8 @@ def update(self): self._auc_range = self.str2tuple(cfg['auc_range']) self._fom_integ_range = self.str2tuple(cfg['fom_integ_range']) + self._find_peaks = cfg['peak_finding'] == 'True' + def _update_integrator(self): if self._integrator is None: self._integrator = AzimuthalIntegrator( @@ -264,6 +274,13 @@ def process(self, data): ai.fom = fom ai.q_map = self._q_map + if self._find_peaks: + peaks, _ = find_peaks_1d(self._intensity_ma) + + if len(peaks > self._MAX_N_PEAKS): + peaks = None + ai.peaks = peaks + # ------------------------------------ # pump-probe azimuthal integration # ------------------------------------