Source code for pyOMA.GUI.PreProcessSignalsGUI

# SPDX-License-Identifier: GPL-3.0-or-later
"""Interactive PyQt6 GUI for pyOMA.core.PreProcessingTools.

Wraps :class:`~pyOMA.core.PreProcessingTools.PreProcessSignals` and
:class:`~pyOMA.core.PreProcessingTools.SignalPlot`: every pre-processing
action mutates the ``PreProcessSignals`` instance in place, and the two
plot widgets re-render (time domain on top, frequency domain at the
bottom) against its current state.

Widget layout lives in ``ui/preprocess_signals.ui`` (compiled to
``generated/ui_preprocess_signals.py`` by ``scripts/build_ui.py``); this
module only wires signals/slots and holds the plotting/pre-processing logic.
"""
import os
import sys
import logging

import numpy as np

from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QMessageBox, QComboBox, QCheckBox, QPushButton,
    QTableWidgetItem, QFileDialog, QInputDialog,
)
from PyQt6.QtCore import Qt, QEventLoop, QTimer, QPoint

from .generated.ui_preprocess_signals import Ui_PreProcessSignalsGUI
from .ChanDofEditorGUI import ChanDofEditorGUI
from .HelpersGUI import UnsavedChangesMixin
from ..core.PreProcessingTools import PreProcessSignals, GeometryProcessor, SignalPlot, SDOF_ambient

logger = logging.getLogger(__name__)

app = None

_CHANNEL_TYPES = ('Acceleration', 'Velocity', 'Displacement')
_CHANNEL_TYPE_ATTR = {
    'Acceleration': 'accel_channels',
    'Velocity': 'velo_channels',
    'Displacement': 'disp_channels',
}

_CONFIG_FILE_FILTER = "Text files (*.txt);;All files (*)"
_MEAS_FILE_FILTER = "NumPy archive (*.npy *.npz);;All files (*)"


[docs] class PreProcessSignalsGUI(UnsavedChangesMixin, QMainWindow, Ui_PreProcessSignalsGUI): """Interactive GUI for signal pre-processing and diagnostic plotting. Parameters ---------- prep_signals : PreProcessSignals, optional The signal object to inspect and process. Pre-processing actions performed through this GUI mutate it in place. When omitted, the window opens empty with processing controls disabled until :meth:`import_signals` or :meth:`load_state` supplies one. geometry_data : GeometryProcessor, optional Supplies the nodes offered by the per-channel "Add DOF" editor. When omitted, "Add DOF" is disabled (there would be nothing to assign to). parent : QWidget, optional """
[docs] def __init__(self, prep_signals=None, geometry_data=None, parent=None): super().__init__(parent) if prep_signals is not None and not isinstance(prep_signals, PreProcessSignals): raise TypeError( f"prep_signals must be a PreProcessSignals instance, " f"got {type(prep_signals).__name__}") if geometry_data is not None and not isinstance(geometry_data, GeometryProcessor): raise TypeError( f"geometry_data must be a GeometryProcessor instance, " f"got {type(geometry_data).__name__}") self.prep_signals = prep_signals self.geometry_data = geometry_data self.signal_plot = SignalPlot(prep_signals) if prep_signals is not None else None self._dirty = False self.setupUi(self) self._wire_channel_box() self._wire_preprocessing_box() self._wire_diagram_box() self._wire_panel_toggles() self._wire_buttons() self._refresh_channel_table() self._refresh_status() if prep_signals is not None: self._update_both_plots() self._set_controls_enabled(prep_signals is not None) self.show()
# ------------------------------------------------------------------ # Empty-start support # ------------------------------------------------------------------ def _set_controls_enabled(self, enabled): """Grey out everything but the File menu while no data is loaded.""" self.processing_panel.setEnabled(enabled) self.control_panel.setEnabled(enabled) self.save_figure_button.setEnabled(enabled) self.ok_close_button.setEnabled(enabled) # ------------------------------------------------------------------ # Wiring # ------------------------------------------------------------------ def _wire_channel_box(self): self.channel_table.itemSelectionChanged.connect(self._update_both_plots) self.channel_table.itemChanged.connect(self._on_channel_name_changed) self.btn_select_all.clicked.connect(self.channel_table.selectAll) self.btn_select_none.clicked.connect(self.channel_table.clearSelection) self.btn_delete_channels.clicked.connect(self._on_delete_channels) self.chk_auto_ref.stateChanged.connect(self._update_both_plots) def _wire_preprocessing_box(self): self.btn_undo.setEnabled( self.prep_signals is not None and self.prep_signals.undo_available) self.btn_undo.clicked.connect(self._on_undo) self.btn_compute_clarity_score.clicked.connect(self._update_clarity_score) self.btn_correct_offset.clicked.connect(self._on_correct_offset) self.btn_precondition.clicked.connect(self._on_precondition) self.btn_add_noise.clicked.connect(self._on_add_noise) self.chk_lowpass.toggled.connect(self.spin_lowpass.setEnabled) self.chk_highpass.toggled.connect(self.spin_highpass.setEnabled) self.combo_ftype.currentTextChanged.connect(self._on_ftype_changed) self.chk_auto_order.toggled.connect(lambda checked: self.spin_order.setEnabled(not checked)) self.btn_apply_filter.clicked.connect(self._on_filter) self.chk_decimate_highpass.toggled.connect(self.spin_decimate_highpass.setEnabled) self.btn_decimate.clicked.connect(self._on_decimate) def _wire_diagram_box(self): self.combo_time_diagram.currentIndexChanged.connect(self._on_time_diagram_changed) self.combo_ts_scale.currentIndexChanged.connect(self._update_time_plot) self.chk_corr_auto_mlags.toggled.connect(lambda c: self.spin_corr_mlags.setEnabled(not c)) self.combo_corr_scale.currentIndexChanged.connect(self._update_time_plot) self.combo_corr_method.currentIndexChanged.connect(self._update_time_plot) self.chk_corr_auto_mlags.toggled.connect(self._update_time_plot) self.spin_corr_mlags.valueChanged.connect(self._update_time_plot) self.chk_psd_auto_nlines.toggled.connect(lambda c: self.spin_psd_nlines.setEnabled(not c)) self.combo_psd_scale.currentTextChanged.connect(self._on_psd_scale_changed) self.combo_psd_method.currentIndexChanged.connect(self._update_freq_plot) self.chk_psd_auto_nlines.toggled.connect(self._update_freq_plot) self.spin_psd_nlines.valueChanged.connect(self._update_freq_plot) self.btn_refresh_plots.clicked.connect(self._update_both_plots) self.btn_refresh_plots_processing.clicked.connect(self._update_both_plots) def _wire_panel_toggles(self): self._collapsed_pane_sizes = {} self.processing_toggle_btn.toggled.connect( self._make_toggle_handler(self.processing_toggle_btn, self.processing_scroll)) self.control_toggle_btn.toggled.connect( self._make_toggle_handler(self.control_toggle_btn, self.control_scroll)) def _make_toggle_handler(self, button, scroll_area): def handler(checked): scroll_area.setVisible(checked) button.setArrowType(Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow) pane = scroll_area.parentWidget() index = self.splitter.indexOf(pane) sizes = self.splitter.sizes() if checked: sizes[index] = self._collapsed_pane_sizes.pop(pane, scroll_area.minimumWidth()) else: self._collapsed_pane_sizes[pane] = sizes[index] sizes[index] = button.sizeHint().width() self.splitter.setSizes(sizes) return handler def _wire_buttons(self): self.save_figure_button.clicked.connect(self.save_figure) self.ok_close_button.clicked.connect(self.close) self.actionLoad_Config.triggered.connect(self.load_config) self.actionSave_Config.triggered.connect(self.save_config) self.actionImport_Signals.triggered.connect(self.import_signals) self.actionSave_State.triggered.connect(self.save_state) self.actionLoad_State.triggered.connect(self.load_state) self.actionLoad_Chan_Dofs.triggered.connect(self.load_chan_dofs) self.actionSave_Chan_Dofs.triggered.connect(self.save_chan_dofs) self.actionQuit.triggered.connect(self.close) # ------------------------------------------------------------------ # State / figure saving # ------------------------------------------------------------------ def save_state(self): fname, _ext = QFileDialog.getSaveFileName( self, caption="Choose a filename to save to", directory=os.getcwd(), filter='Numpy Archive File (*.npz)') if not fname: return base, ext = os.path.splitext(fname) if ext != '.npz': fname = base + '.npz' try: self.prep_signals.save_state(fname) except Exception as exc: logger.exception("save_state failed") QMessageBox.warning(self, "Save failed", str(exc)) return self._dirty = False def _do_save(self): self.save_state() def load_state(self): fname, _ext = QFileDialog.getOpenFileName( self, caption="Choose a state file to load", directory=os.getcwd(), filter='Numpy Archive File (*.npz)') if not fname: return try: loaded = PreProcessSignals.load_state(fname) except Exception as exc: logger.exception("load_state failed") QMessageBox.warning(self, "Load failed", str(exc)) return self._apply_prep_signals(loaded) def load_config(self): conf_file, _ext = QFileDialog.getOpenFileName( self, caption="Choose a config file", directory=os.getcwd(), filter=_CONFIG_FILE_FILTER) if not conf_file: return meas_file, _ext = QFileDialog.getOpenFileName( self, caption="Choose a measurement file", directory=os.getcwd(), filter=_MEAS_FILE_FILTER) if not meas_file: return try: prep_signals = PreProcessSignals.init_from_config(conf_file, meas_file) except Exception as exc: logger.exception("load_config failed") QMessageBox.warning(self, "Load failed", str(exc)) return self._apply_prep_signals(prep_signals) def save_config(self): fname, _ext = QFileDialog.getSaveFileName( self, caption="Choose a filename to save the config to", directory=os.getcwd(), filter=_CONFIG_FILE_FILTER) if not fname: return base, ext = os.path.splitext(fname) if ext != '.txt': fname = base + '.txt' try: self.prep_signals.save_config(fname) except Exception as exc: logger.exception("save_config failed") QMessageBox.warning(self, "Save failed", str(exc)) def load_chan_dofs(self): fname, _ext = QFileDialog.getOpenFileName( self, caption="Choose a channel-DOFs file to load", directory=os.getcwd(), filter=_CONFIG_FILE_FILTER) if not fname: return try: chan_dofs = PreProcessSignals.load_chan_dofs(fname) self.prep_signals.add_chan_dofs(chan_dofs) except Exception as exc: logger.exception("load_chan_dofs failed") QMessageBox.warning(self, "Load failed", str(exc)) return self._dirty = True self._refresh_status() def save_chan_dofs(self): fname, _ext = QFileDialog.getSaveFileName( self, caption="Choose a filename to save the channel DOFs to", directory=os.getcwd(), filter=_CONFIG_FILE_FILTER) if not fname: return base, ext = os.path.splitext(fname) if ext != '.txt': fname = base + '.txt' try: self.prep_signals.save_chan_dofs(fname) except Exception as exc: logger.exception("save_chan_dofs failed") QMessageBox.warning(self, "Save failed", str(exc)) def import_signals(self): fname, _ext = QFileDialog.getOpenFileName( self, caption="Choose a signal file to import", directory=os.getcwd(), filter='Signal files (*.npz *.npy);;Numpy Archive (*.npz);;Numpy Array (*.npy)') if not fname: return ext = os.path.splitext(fname)[1].lower() try: if ext == '.npz': prep_signals = PreProcessSignals.load_state(fname) elif ext == '.npy': sampling_rate, ok = QInputDialog.getDouble( self, "Sampling rate", "Sampling rate [Hz]:", value=1.0, min=1e-6, decimals=6) if not ok: return prep_signals = PreProcessSignals(fname, sampling_rate) else: QMessageBox.warning( self, "Unsupported file", f"Unrecognized extension: {ext}") return except Exception as exc: logger.exception("import_signals failed") QMessageBox.warning(self, "Import failed", str(exc)) return self._apply_prep_signals(prep_signals) def _apply_prep_signals(self, prep_signals): """Swap in a freshly loaded/imported PreProcessSignals and refresh every view that depends on it.""" self.prep_signals = prep_signals self.signal_plot = SignalPlot(prep_signals) self._dirty = False # freshly loaded - nothing unsaved yet self._refresh_channel_table() self._refresh_status() self._update_both_plots() self._set_controls_enabled(True) def save_figure(self): filetypes = self.canvas_time.get_supported_filetypes_grouped() sorted_filetypes = sorted(filetypes.items()) filters = ';;'.join( '%s (%s)' % (name, " ".join('*.%s' % ext for ext in exts)) for name, exts in sorted_filetypes) fname, _ext = QFileDialog.getSaveFileName( self, caption="Choose a base filename to save the time/frequency plots to", filter=filters) if not fname: return base, ext = os.path.splitext(fname) ext = ext or '.png' self.canvas_time.figure.savefig(f'{base}_time{ext}') self.canvas_freq.figure.savefig(f'{base}_freq{ext}') # ------------------------------------------------------------------ # Status # ------------------------------------------------------------------ def _refresh_status(self): if self.prep_signals is None: return self.lbl_num_channels.setText(str(self.prep_signals.num_analised_channels)) self.lbl_sampling_rate.setText(f"{self.prep_signals.sampling_rate:.4g}") self.lbl_duration.setText(f"{self.prep_signals.duration:.4g}") self._update_clarity_score() def _update_clarity_score(self): try: score = self.prep_signals.signal_clarity_score() except Exception as exc: logger.exception("Signal clarity score computation failed") self.lbl_signal_clarity_score.setText("n/a") self.lbl_signal_clarity_score.setToolTip(str(exc)) return self.lbl_signal_clarity_score.setText(f"{score:.4g}") self.lbl_signal_clarity_score.setToolTip("") # ------------------------------------------------------------------ # Channel table: selection, type (accel/velo/disp), reference, delete # ------------------------------------------------------------------ def _channel_type_text(self, channel): if channel in self.prep_signals.accel_channels: return 'Acceleration' if channel in self.prep_signals.velo_channels: return 'Velocity' if channel in self.prep_signals.disp_channels: return 'Displacement' return 'Acceleration' def _refresh_channel_table(self): table = self.channel_table table.blockSignals(True) table.setRowCount(0) if self.prep_signals is None: table.blockSignals(False) return for channel in range(self.prep_signals.num_analised_channels): row = table.rowCount() table.insertRow(row) name_item = QTableWidgetItem(str(self.prep_signals.channel_headers[channel])) table.setItem(row, 0, name_item) combo = QComboBox() combo.addItems(_CHANNEL_TYPES) combo.setCurrentText(self._channel_type_text(channel)) combo.currentTextChanged.connect( lambda text, ch=channel: self._on_channel_type_changed(ch, text)) table.setCellWidget(row, 1, combo) checkbox = QCheckBox() checkbox.setChecked(channel in self.prep_signals.ref_channels) checkbox.toggled.connect( lambda checked, ch=channel: self._on_channel_ref_toggled(ch, checked)) table.setCellWidget(row, 2, checkbox) btn_dof = QPushButton("Add DOF") btn_dof.setEnabled(self.geometry_data is not None) btn_dof.clicked.connect( lambda _checked, ch=channel, btn=btn_dof: self._on_add_dof(ch, btn)) table.setCellWidget(row, 3, btn_dof) table.selectAll() table.blockSignals(False) def _selected_channels(self): rows = [index.row() for index in self.channel_table.selectionModel().selectedRows()] if not rows: return None # PreProcessSignals/SignalPlot interpret None as "all channels" return sorted(rows) def _selected_refs(self): return 'auto' if self.chk_auto_ref.isChecked() else None def _on_channel_type_changed(self, channel, type_text): attr = _CHANNEL_TYPE_ATTR[type_text] current = list(getattr(self.prep_signals, attr)) if channel not in current: current.append(channel) setattr(self.prep_signals, attr, current) self._dirty = True # Rebuilding the table now would destroy the combo box still # emitting this very signal - defer to the next event loop turn. QTimer.singleShot(0, self._refresh_channel_table) def _on_channel_name_changed(self, item): if item.column() != 0: return channel = item.row() new_name = item.text().strip() if not new_name or new_name == str(self.prep_signals.channel_headers[channel]): self._refresh_channel_table() return try: self.prep_signals.rename_channel(channel, new_name) except ValueError as exc: QMessageBox.warning(self, "Rename channel", str(exc)) self._refresh_channel_table() return self._dirty = True self.channel_table.blockSignals(True) item.setText(new_name) self.channel_table.blockSignals(False) self.btn_undo.setEnabled(self.prep_signals.undo_available) def _on_channel_ref_toggled(self, channel, checked): current = list(self.prep_signals.ref_channels) if checked and channel not in current: current.append(channel) elif not checked and channel in current: current.remove(channel) self.prep_signals.ref_channels = sorted(current) self._dirty = True self._update_both_plots() def _on_delete_channels(self): rows = sorted(index.row() for index in self.channel_table.selectionModel().selectedRows()) if not rows: QMessageBox.warning(self, "Delete channels", "Select at least one channel first.") return if len(rows) >= self.prep_signals.num_analised_channels: QMessageBox.warning(self, "Delete channels", "Cannot delete all channels.") return self.prep_signals.delete_channels(rows) self._dirty = True self._refresh_channel_table() self._refresh_status() self._update_both_plots() self.btn_undo.setEnabled(self.prep_signals.undo_available) def _on_add_dof(self, channel, button): if self.geometry_data is None: return dialog = ChanDofEditorGUI(self.prep_signals, self.geometry_data, channel, parent=self) dialog.move(button.mapToGlobal(QPoint(0, button.height()))) dialog.exec() # ------------------------------------------------------------------ # Pre-processing actions # ------------------------------------------------------------------ def _on_correct_offset(self): self.prep_signals.correct_offset() self._after_signal_mutation() def _on_precondition(self): self.prep_signals.precondition_signals( method=self.combo_precondition_method.currentText()) self._after_signal_mutation() def _on_add_noise(self): amplitude = self.spin_noise_amplitude.value() snr = self.spin_noise_snr.value() if amplitude == 0 and snr == 0: QMessageBox.warning(self, "Add noise", "Set a non-zero amplitude or SNR first.") return self.prep_signals.add_noise(amplitude=amplitude, snr=snr) self._after_signal_mutation() def _on_ftype_changed(self, ftype): needs_rprs = ftype in ('cheby1', 'cheby2', 'ellip') self.spin_rp.setEnabled(needs_rprs) self.spin_rs.setEnabled(needs_rprs) self.lbl_rp.setEnabled(needs_rprs) self.lbl_rs.setEnabled(needs_rprs) def _on_filter(self): lowpass = self.spin_lowpass.value() if self.chk_lowpass.isChecked() else None highpass = self.spin_highpass.value() if self.chk_highpass.isChecked() else None if lowpass is None and highpass is None: QMessageBox.warning(self, "Filter", "Enable at least one of lowpass/highpass.") return order = None if self.chk_auto_order.isChecked() else self.spin_order.value() ftype = self.combo_ftype.currentText() RpRs = [self.spin_rp.value(), self.spin_rs.value()] if ftype in ('cheby1', 'cheby2', 'ellip') else None overwrite = self.chk_overwrite.isChecked() plot_ax = None show_response = self.chk_show_response.isChecked() if show_response: self.canvas_time.figure.clear() self.canvas_freq.figure.clear() plot_ax = [self.canvas_time.figure.subplots(), self.canvas_freq.figure.subplots()] self.prep_signals.filter_signals( lowpass=lowpass, highpass=highpass, overwrite=overwrite, order=order, ftype=ftype, RpRs=RpRs, plot_ax=plot_ax) self._dirty = True self._refresh_status() if show_response: self.canvas_time.draw_idle() # filter response is shown instead of the signal plots this time self.canvas_freq.draw_idle() else: self._update_both_plots() def _on_decimate(self): decimate_factor = self.spin_decimate_factor.value() nyq_rat = self.spin_nyq_rat.value() highpass = self.spin_decimate_highpass.value() if self.chk_decimate_highpass.isChecked() else None filter_type = self.combo_decimate_ftype.currentText() self.prep_signals.decimate_signals( decimate_factor=decimate_factor, nyq_rat=nyq_rat, highpass=highpass, filter_type=filter_type) self._after_signal_mutation() def _after_signal_mutation(self): # sampling_rate/duration change (decimation) and cached spectra are # cleared by every mutating call above -> status and both plots need # a refresh. self._dirty = True self._refresh_status() self._update_both_plots() self.btn_undo.setEnabled(self.prep_signals.undo_available) def _on_undo(self): self.prep_signals.undo() self._dirty = True self._refresh_channel_table() self._refresh_status() self._update_both_plots() self.btn_undo.setEnabled(self.prep_signals.undo_available) # ------------------------------------------------------------------ # Diagram controls # ------------------------------------------------------------------ def _on_time_diagram_changed(self, index): self.stack_time_params.setCurrentIndex(index) self._update_time_plot() def _on_psd_scale_changed(self, scale): # SignalPlot.plot_psd(scale='svd') ignores channel/reference # selection (and warns if refs are given), so grey those controls # out rather than let the user set values that get silently ignored. is_svd = (scale == 'svd') self.channel_table.setEnabled(not is_svd) self.chk_auto_ref.setEnabled(not is_svd) self._update_freq_plot() # ------------------------------------------------------------------ # Plotting # ------------------------------------------------------------------ def _update_both_plots(self): self._update_time_plot() self._update_freq_plot() def _update_time_plot(self): diagram = self.combo_time_diagram.currentText() self.canvas_time.figure.clear() try: if diagram == 'Time series': self._plot_timeseries() elif diagram == 'Correlation': self._plot_correlation() except Exception as exc: logger.exception("Time-domain plotting failed") self.canvas_time.figure.clear() ax = self.canvas_time.figure.subplots() ax.text(0.5, 0.5, f"Could not plot:\n{exc}", ha='center', va='center', wrap=True, color='crimson', transform=ax.transAxes) self.canvas_time.draw_idle() def _update_freq_plot(self): self.canvas_freq.figure.clear() try: self._plot_psd() except Exception as exc: logger.exception("Frequency-domain plotting failed") self.canvas_freq.figure.clear() ax = self.canvas_freq.figure.subplots() ax.text(0.5, 0.5, f"Could not plot:\n{exc}", ha='center', va='center', wrap=True, color='crimson', transform=ax.transAxes) self.canvas_freq.draw_idle() def _plot_timeseries(self): ax = self.canvas_time.figure.subplots() scale = self.combo_ts_scale.currentText() self.signal_plot.plot_timeseries(channels=self._selected_channels(), ax=ax, scale=scale) ax.legend(fontsize='small') def _plot_correlation(self): ax = self.canvas_time.figure.subplots() m_lags = None if self.chk_corr_auto_mlags.isChecked() else self.spin_corr_mlags.value() scale = self.combo_corr_scale.currentText() method = self.combo_corr_method.currentText() method = None if method == 'auto' else method self.signal_plot.plot_correlation( m_lags=m_lags, channels=self._selected_channels(), ax=ax, scale=scale, refs=self._selected_refs(), method=method) ax.legend(fontsize='small') def _plot_psd(self): ax = self.canvas_freq.figure.subplots() n_lines = None if self.chk_psd_auto_nlines.isChecked() else self.spin_psd_nlines.value() scale = self.combo_psd_scale.currentText() method = self.combo_psd_method.currentText() method = None if method == 'auto' else method is_svd = (scale == 'svd') channels = None if is_svd else self._selected_channels() refs = None if is_svd else self._selected_refs() try: self.signal_plot.plot_psd( n_lines=n_lines, channels=channels, ax=ax, scale=scale, refs=refs, method=method) except RuntimeError: # "Auto n_lines" has nothing cached yet for this method (e.g. # the very first PSD plot, before any n_lines was ever given) - # fall back to the spin box's default instead of surfacing a # bare core error. if n_lines is not None: raise n_lines = self.spin_psd_nlines.value() self.signal_plot.plot_psd( n_lines=n_lines, channels=channels, ax=ax, scale=scale, refs=refs, method=method) if not is_svd: ax.legend(fontsize='small')
[docs] def closeEvent(self, event): if not self._prompt_save_on_close(event): return self._prep_signals_at_close = self.prep_signals self.deleteLater() return QMainWindow.closeEvent(self, event)
[docs] def build_demo_prep_signals(): """Small synthetic PreProcessSignals for manual GUI testing (no real measurement data required).""" _t, y, _omegas, _psd, _corr = SDOF_ambient() ch0 = y[:, np.newaxis] ch1 = ch0 * 0.6 + 0.05 # second channel so channel selection has something to show signals = np.hstack([ch0, ch1]) return PreProcessSignals(signals, sampling_rate=128, channel_headers=['ch0', 'ch1'])
[docs] def start_preprocess_gui(prep_signals=None, geometry_data=None): global app app = QApplication.instance() or QApplication(sys.argv) form = PreProcessSignalsGUI(prep_signals, geometry_data) form.resize(1250, 820) loop = QEventLoop() form.destroyed.connect(loop.quit) loop.exec() return form._prep_signals_at_close
[docs] def main(): prep_signals = build_demo_prep_signals() start_preprocess_gui(prep_signals)
if __name__ == '__main__': main()