diff --git a/src/urh/controller/MainController.py b/src/urh/controller/MainController.py index 64b22e9e..62f6c492 100644 --- a/src/urh/controller/MainController.py +++ b/src/urh/controller/MainController.py @@ -65,6 +65,13 @@ class MainController(QMainWindow): self.undo_group.addStack(self.generator_tab_controller.generator_undo_stack) self.undo_group.setActiveStack(self.signal_tab_controller.signal_undo_stack) + self.cancel_action = QAction(self.tr("Cancel"), self) + self.cancel_action.setShortcut(QKeySequence.Cancel if hasattr(QKeySequence, "Cancel") else "Esc") + self.cancel_action.triggered.connect(self.on_cancel_triggered) + self.cancel_action.setShortcutContext(Qt.WidgetWithChildrenShortcut) + self.cancel_action.setIcon(QIcon.fromTheme("dialog-cancel")) + self.addAction(self.cancel_action) + self.participant_legend_model = ParticipantLegendListModel(self.project_manager.participants) self.ui.listViewParticipants.setModel(self.participant_legend_model) @@ -812,3 +819,8 @@ class MainController(QMainWindow): @pyqtSlot(int, Signal) def on_signal_created(self, index: int, signal: Signal): self.add_signal(signal, index=index) + + @pyqtSlot() + def on_cancel_triggered(self): + for signal_frame in self.signal_tab_controller.signal_frames: + signal_frame.cancel_filtering() diff --git a/src/urh/controller/SignalFrameController.py b/src/urh/controller/SignalFrameController.py index 4e28314c..475d80b6 100644 --- a/src/urh/controller/SignalFrameController.py +++ b/src/urh/controller/SignalFrameController.py @@ -1,11 +1,12 @@ import math import numpy as np +import time from PyQt5.QtCore import pyqtSignal, QPoint, Qt, QMimeData, pyqtSlot, QTimer from PyQt5.QtGui import QFontDatabase, QIcon, QDrag, QPixmap, QRegion, QDropEvent, QTextCursor, QContextMenuEvent, \ QResizeEvent -from PyQt5.QtWidgets import QFrame, QMessageBox, QMenu, QWidget, QUndoStack, \ - QCheckBox, QApplication +from PyQt5.QtWidgets import QFrame, QMessageBox, QMenu, QWidget, QUndoStack, QCheckBox, QApplication +from multiprocessing import Process, Array from urh import constants from urh.controller.FilterDialogController import FilterDialogController @@ -26,6 +27,11 @@ from urh.util.Formatter import Formatter from urh.util.Logger import logger +def perform_filter(result_array: Array, data, f_low, f_high, filter_bw): + result_array = np.frombuffer(result_array.get_obj(), dtype=np.complex64) + result_array[:] = Filter.apply_bandpass_filter(data, f_low, f_high, filter_bw=filter_bw) + + class SignalFrameController(QFrame): closed = pyqtSignal(QWidget) signal_created = pyqtSignal(Signal) @@ -61,6 +67,8 @@ class SignalFrameController(QFrame): self.ui.gvSignal.participants = project_manager.participants + self.filter_abort_wanted = False + self.setAttribute(Qt.WA_DeleteOnClose) self.project_manager = project_manager @@ -269,6 +277,9 @@ class SignalFrameController(QFrame): self.ui.cbModulationType.hide() self.ui.btnSaveSignal.hide() + def cancel_filtering(self): + self.filter_abort_wanted = True + def update_number_selected_samples(self): if self.spectrogram_is_active: self.ui.lNumSelectedSamples.setText(str(abs(int(self.ui.gvSpectrogram.selection_area.length)))) @@ -1160,14 +1171,32 @@ class SignalFrameController(QFrame): @pyqtSlot(float, float) def on_bandpass_filter_triggered(self, f_low: float, f_high: float): - self.setCursor(Qt.WaitCursor) + self.filter_abort_wanted = False + + QApplication.instance().setOverrideCursor(Qt.WaitCursor) filter_bw = Filter.read_configured_filter_bw() - filtered = Filter.apply_bandpass_filter(self.signal.data, f_low, f_high, filter_bw=filter_bw) + filtered = Array("f", 2 * self.signal.num_samples) + p = Process(target=perform_filter, args=(filtered, self.signal.data, f_low, f_high, filter_bw)) + p.daemon = True + p.start() + + while p.is_alive(): + QApplication.instance().processEvents() + + if self.filter_abort_wanted: + p.terminate() + p.join() + QApplication.instance().restoreOverrideCursor() + return + + time.sleep(0.1) + + filtered = np.frombuffer(filtered.get_obj(), dtype=np.complex64) signal = self.signal.create_new(new_data=filtered.astype(np.complex64)) signal.name = self.signal.name + " filtered with f_low={0:.4n} f_high={1:.4n} bw={2:.4n}".format(f_low, f_high, - filter_bw) + filter_bw) self.signal_created.emit(signal) - self.unsetCursor() + QApplication.instance().restoreOverrideCursor() def on_signal_data_edited(self): self.refresh_signal() diff --git a/src/urh/signalprocessing/Filter.py b/src/urh/signalprocessing/Filter.py index d0a33dd9..be470227 100644 --- a/src/urh/signalprocessing/Filter.py +++ b/src/urh/signalprocessing/Filter.py @@ -33,29 +33,29 @@ class Filter(object): return signalFunctions.fir_filter(input_signal, np.array(self.taps, dtype=np.complex64)) - @classmethod - def read_configured_filter_bw(cls) -> float: + @staticmethod + def read_configured_filter_bw() -> float: bw_type = constants.SETTINGS.value("bandpass_filter_bw_type", "Medium", str) - if bw_type in cls.BANDWIDTHS: - return cls.BANDWIDTHS[bw_type] + if bw_type in Filter.BANDWIDTHS: + return Filter.BANDWIDTHS[bw_type] if bw_type.lower() == "custom": return constants.SETTINGS.value("bandpass_filter_custom_bw", 0.1, float) return 0.08 - @classmethod - def get_bandwidth_from_filter_length(cls, N): + @staticmethod + def get_bandwidth_from_filter_length(N): return 4 / N - @classmethod - def get_filter_length_from_bandwidth(cls, bw): + @staticmethod + def get_filter_length_from_bandwidth(bw): N = int(math.ceil((4 / bw))) return N + 1 if N % 2 == 0 else N # Ensure N is odd. - @classmethod - def fft_convolve_1d(cls, x: np.ndarray, h: np.ndarray): + @staticmethod + def fft_convolve_1d(x: np.ndarray, h: np.ndarray): n = len(x) + len(h) - 1 n_opt = 1 << (n - 1).bit_length() # Get next power of 2 if np.issubdtype(x.dtype, np.complexfloating) or np.issubdtype(h.dtype, np.complexfloating): @@ -67,19 +67,15 @@ class Filter(object): too_much = (len(result) - len(x)) // 2 # Center result return result[too_much: -too_much] - @classmethod - def apply_bandpass_filter(cls, data, f_low, f_high, sample_rate: float = None, filter_bw=0.08): - if sample_rate is not None: - f_low /= sample_rate - f_high /= sample_rate - + @staticmethod + def apply_bandpass_filter(data, f_low, f_high, filter_bw=0.08): if f_low > f_high: f_low, f_high = f_high, f_low f_low = util.clip(f_low, -0.5, 0.5) f_high = util.clip(f_high, -0.5, 0.5) - h = cls.design_windowed_sinc_bandpass(f_low, f_high, filter_bw) + h = Filter.design_windowed_sinc_bandpass(f_low, f_high, filter_bw) # Choose normal or FFT convolution based on heuristic described in # https://softwareengineering.stackexchange.com/questions/171757/computational-complexity-of-correlation-in-time-vs-multiplication-in-frequency-s/ @@ -88,11 +84,11 @@ class Filter(object): return np.convolve(data, h, 'same') else: logger.debug("Use FFT convolve") - return cls.fft_convolve_1d(data, h) + return Filter.fft_convolve_1d(data, h) - @classmethod - def design_windowed_sinc_lpf(cls, fc, bw): - N = cls.get_filter_length_from_bandwidth(bw) + @staticmethod + def design_windowed_sinc_lpf(fc, bw): + N = Filter.get_filter_length_from_bandwidth(bw) # Compute sinc filter impulse response h = np.sinc(2 * fc * (np.arange(N) - (N - 1) / 2.)) @@ -108,8 +104,8 @@ class Filter(object): return h_unity - @classmethod - def design_windowed_sinc_bandpass(cls, f_low, f_high, bw): + @staticmethod + def design_windowed_sinc_bandpass(f_low, f_high, bw): f_shift = (f_low + f_high) / 2 f_c = (f_high - f_low) / 2 diff --git a/src/urh/ui/views/SpectrogramGraphicView.py b/src/urh/ui/views/SpectrogramGraphicView.py index 3c8bf22f..cd1a0949 100644 --- a/src/urh/ui/views/SpectrogramGraphicView.py +++ b/src/urh/ui/views/SpectrogramGraphicView.py @@ -1,6 +1,6 @@ import numpy as np from PyQt5.QtCore import pyqtSlot, pyqtSignal -from PyQt5.QtGui import QIcon +from PyQt5.QtGui import QIcon, QKeySequence from PyQt5.QtWidgets import QMenu from urh.controller.FilterBandwidthDialogController import FilterBandwidthDialogController @@ -8,6 +8,8 @@ from urh.signalprocessing.Filter import Filter from urh.ui.painting.SpectrogramScene import SpectrogramScene from urh.ui.painting.SpectrogramSceneManager import SpectrogramSceneManager from urh.ui.views.ZoomableGraphicView import ZoomableGraphicView +from urh.util.Logger import logger + class SpectrogramGraphicView(ZoomableGraphicView): MINIMUM_VIEW_WIDTH = 10 @@ -44,6 +46,7 @@ class SpectrogramGraphicView(ZoomableGraphicView): def create_context_menu(self): menu = QMenu() + menu.setToolTipsVisible(True) self._add_zoom_actions_to_menu(menu) if self.something_is_selected: @@ -53,6 +56,14 @@ class SpectrogramGraphicView(ZoomableGraphicView): create_from_frequency_selection.triggered.connect(self.on_create_from_frequency_selection_triggered) create_from_frequency_selection.setIcon(QIcon.fromTheme("view-filter")) + try: + cancel_button = " or ".join(k.toString() for k in QKeySequence.keyBindings(QKeySequence.Cancel)) + except Exception as e: + logger.debug("Error reading cancel button:", str(e)) + cancel_button = "Esc" + + create_from_frequency_selection.setToolTip("You can abort filtering with {}.".format(cancel_button)) + configure_filter_bw = menu.addAction(self.tr("Configure filter bandwidth...")) configure_filter_bw.triggered.connect(self.on_configure_filter_bw_triggered) configure_filter_bw.setIcon(QIcon.fromTheme("configure")) diff --git a/tests/SpectrogramTest.py b/tests/SpectrogramTest.py index f4ff48cb..043b97be 100644 --- a/tests/SpectrogramTest.py +++ b/tests/SpectrogramTest.py @@ -95,7 +95,7 @@ class SpectrogramTest(unittest.TestCase): b = 0.05 data = x - y = Filter.apply_bandpass_filter(data, lowcut, highcut, fs, filter_bw=b) + y = Filter.apply_bandpass_filter(data, lowcut / fs, highcut / fs, filter_bw=b) plt.plot(y, label='Filtered signal (%g Hz)' % f0) plt.plot(data, label='Noisy signal') @@ -175,12 +175,12 @@ class SpectrogramTest(unittest.TestCase): plt.imshow(np.transpose(spectrogram.data), aspect="auto", cmap="magma") plt.ylim(0, spectrogram.freq_bins) - chann1_filtered = Filter.apply_bandpass_filter(mixed_signal, filter_freq1_low, filter_freq1_high, sample_rate, filter_bw) + chann1_filtered = Filter.apply_bandpass_filter(mixed_signal, filter_freq1_low / sample_rate, filter_freq1_high / sample_rate, filter_bw) plt.subplot("223") plt.title("Channel 1 Filtered ({})".format("".join(map(str, channel1_data)))) plt.plot(chann1_filtered) - chann2_filtered = Filter.apply_bandpass_filter(mixed_signal, filter_freq2_low, filter_freq2_high, sample_rate, filter_bw) + chann2_filtered = Filter.apply_bandpass_filter(mixed_signal, filter_freq2_low / sample_rate, filter_freq2_high / sample_rate, filter_bw) plt.subplot("224") plt.title("Channel 2 Filtered ({})".format("".join(map(str, channel2_data)))) plt.plot(chann2_filtered) diff --git a/tests/test_filter.py b/tests/test_filter.py index 2d80089f..56ade27a 100644 --- a/tests/test_filter.py +++ b/tests/test_filter.py @@ -108,5 +108,15 @@ class TestFilter(QtTestCase): np.testing.assert_array_almost_equal(result_np, result_fft) print("fft convolve time", t_fft, "np convolve time", t_np) + def test_bandpass_filter(self): + # GUI tests for bandpass filter are in test_spectrogram.py + sig1 = np.sin(2 * np.pi * 0.2 * np.arange(0, 100)) + sig2 = np.sin(2 * np.pi * 0.3 * np.arange(0, 100)) + sig = sig1 + sig2 + + filtered1 = Filter.apply_bandpass_filter(sig, 0.1, 0.2) + filtered2 = Filter.apply_bandpass_filter(sig, 0.2, 0.1) + self.assertTrue(np.array_equal(filtered1, filtered2)) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_spectrogram.py b/tests/test_spectrogram.py index c4b31d5a..865e1c4f 100644 --- a/tests/test_spectrogram.py +++ b/tests/test_spectrogram.py @@ -1,3 +1,5 @@ +from PyQt5.QtCore import QTimer + from tests.QtTestCase import QtTestCase from urh import colormaps from urh.signalprocessing.Signal import Signal @@ -44,6 +46,26 @@ class TestSpectrogram(QtTestCase): self.__test_extract_channel(signal_frame, freq1=217, freq2=324, bandwidth="104,492kHz", target_bits="10010111", center=0.4) + def test_cancel_filtering(self): + super().setUp() + self.add_signal_to_form("two_participants.complex") + signal_frame = self.form.signal_tab_controller.signal_frames[0] + signal_frame.ui.cbSignalView.setCurrentIndex(2) + signal_frame.ui.spinBoxSelectionStart.setValue(100) + signal_frame.ui.spinBoxSelectionEnd.setValue(200) + menu = signal_frame.ui.gvSpectrogram.create_context_menu() + create_action = next(action for action in menu.actions() if "bandpass filter" in action.text()) + timer = QTimer() + timer.setSingleShot(True) + timer.timeout.connect(self.form.cancel_action.trigger) + timer.setInterval(5) + timer.start() + + create_action.trigger() + + self.assertTrue(signal_frame.filter_abort_wanted) + self.assertEqual(self.form.signal_tab_controller.num_frames, 1) + def __prepare_channel_separation(self, signal_frame): self.assertEqual(self.form.signal_tab_controller.num_frames, 1) signal_frame = self.form.signal_tab_controller.signal_frames[0]