diff --git a/rfg_adc_plotter/gui/pyqtgraph_backend.py b/rfg_adc_plotter/gui/pyqtgraph_backend.py index 10b3aa9..57f96a2 100644 --- a/rfg_adc_plotter/gui/pyqtgraph_backend.py +++ b/rfg_adc_plotter/gui/pyqtgraph_backend.py @@ -37,6 +37,8 @@ from rfg_adc_plotter.processing.peaks import ( from rfg_adc_plotter.state import RingBuffer, RuntimeState from rfg_adc_plotter.types import SweepAuxCurves, SweepInfo, SweepPacket +RAW_PLOT_MAX_POINTS = 4096 + def _visible_levels_pyqtgraph(data: np.ndarray, plot_item) -> Optional[Tuple[float, float]]: """Compute vmin/vmax from the currently visible part of an ImageItem.""" @@ -205,6 +207,38 @@ def resolve_visible_aux_curves(aux_curves: SweepAuxCurves, enabled: bool) -> Swe return aux_1_arr, aux_2_arr +def decimate_curve_for_display( + xs: Optional[np.ndarray], + ys: Optional[np.ndarray], + *, + max_points: int = RAW_PLOT_MAX_POINTS, +) -> Tuple[np.ndarray, np.ndarray]: + """Reduce a dense line series before sending it to pyqtgraph.""" + if xs is None or ys is None: + return ( + np.zeros((0,), dtype=np.float64), + np.zeros((0,), dtype=np.float32), + ) + + x_arr = np.asarray(xs).reshape(-1) + y_arr = np.asarray(ys).reshape(-1) + width = min(x_arr.size, y_arr.size) + if width <= 0: + return ( + np.zeros((0,), dtype=np.float64), + np.zeros((0,), dtype=np.float32), + ) + + x_arr = x_arr[:width] + y_arr = y_arr[:width] + if max_points <= 1 or width <= int(max_points): + return x_arr, y_arr + + display_idx = np.linspace(0, width - 1, int(max_points), dtype=np.int64) + display_idx = np.unique(display_idx) + return x_arr[display_idx], y_arr[display_idx] + + def resolve_visible_fft_curves( fft_complex: Optional[np.ndarray], fft_mag: Optional[np.ndarray], @@ -1302,15 +1336,18 @@ def run_pyqtgraph(args) -> None: displayed_aux = resolve_visible_aux_curves(runtime.current_aux_curves, parsed_data_enabled) if runtime.current_sweep_raw is not None: - curve.setData(xs[: runtime.current_sweep_raw.size], runtime.current_sweep_raw, autoDownsample=True) + raw_x, raw_y = decimate_curve_for_display(xs, runtime.current_sweep_raw) + curve.setData(raw_x, raw_y, autoDownsample=False) else: curve.setData([], []) if displayed_aux is not None: aux_1, aux_2 = displayed_aux aux_width = min(xs.size, aux_1.size, aux_2.size) - curve_aux_1.setData(xs[:aux_width], aux_1[:aux_width], autoDownsample=True) - curve_aux_2.setData(xs[:aux_width], aux_2[:aux_width], autoDownsample=True) + aux_x_1, aux_y_1 = decimate_curve_for_display(xs[:aux_width], aux_1[:aux_width]) + aux_x_2, aux_y_2 = decimate_curve_for_display(xs[:aux_width], aux_2[:aux_width]) + curve_aux_1.setData(aux_x_1, aux_y_1, autoDownsample=False) + curve_aux_2.setData(aux_x_2, aux_y_2, autoDownsample=False) else: curve_aux_1.setData([], []) curve_aux_2.setData([], []) @@ -1322,13 +1359,15 @@ def run_pyqtgraph(args) -> None: else: displayed_calib = runtime.calib_envelope xs_calib = resolve_curve_xs(displayed_calib.size) - curve_calib.setData(xs_calib, displayed_calib, autoDownsample=True) + calib_x, calib_y = decimate_curve_for_display(xs_calib, displayed_calib) + curve_calib.setData(calib_x, calib_y, autoDownsample=False) else: curve_calib.setData([], []) if runtime.current_sweep_norm is not None: norm_display = runtime.current_sweep_norm * norm_display_scale - curve_norm.setData(xs[: norm_display.size], norm_display, autoDownsample=True) + norm_x, norm_y = decimate_curve_for_display(xs[: norm_display.size], norm_display) + curve_norm.setData(norm_x, norm_y, autoDownsample=False) else: curve_norm.setData([], []) diff --git a/tests/test_processing.py b/tests/test_processing.py index b4bd978..31fdc8d 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -10,6 +10,7 @@ from rfg_adc_plotter.gui.pyqtgraph_backend import ( apply_working_range, apply_working_range_to_aux_curves, compute_background_subtracted_bscan_levels, + decimate_curve_for_display, resolve_visible_fft_curves, resolve_visible_aux_curves, ) @@ -205,6 +206,28 @@ class ProcessingTests(unittest.TestCase): self.assertTrue(np.allclose(visible[0], aux[0])) self.assertTrue(np.allclose(visible[1], aux[1])) + def test_decimate_curve_for_display_preserves_small_series(self): + xs = np.linspace(3.3, 14.3, 64, dtype=np.float64) + ys = np.linspace(-1.0, 1.0, 64, dtype=np.float32) + + decimated_x, decimated_y = decimate_curve_for_display(xs, ys, max_points=128) + + self.assertTrue(np.allclose(decimated_x, xs)) + self.assertTrue(np.allclose(decimated_y, ys)) + + def test_decimate_curve_for_display_limits_points_and_keeps_endpoints(self): + xs = np.linspace(3.3, 14.3, 10000, dtype=np.float64) + ys = np.sin(np.linspace(0.0, 12.0 * np.pi, 10000)).astype(np.float32) + + decimated_x, decimated_y = decimate_curve_for_display(xs, ys, max_points=512) + + self.assertLessEqual(decimated_x.size, 512) + self.assertEqual(decimated_x.shape, decimated_y.shape) + self.assertAlmostEqual(float(decimated_x[0]), float(xs[0]), places=12) + self.assertAlmostEqual(float(decimated_x[-1]), float(xs[-1]), places=12) + self.assertAlmostEqual(float(decimated_y[0]), float(ys[0]), places=6) + self.assertAlmostEqual(float(decimated_y[-1]), float(ys[-1]), places=6) + def test_background_subtracted_bscan_levels_ignore_zero_floor(self): disp_fft_lin = np.zeros((4, 8), dtype=np.float32) disp_fft_lin[1, 2:6] = np.asarray([0.05, 0.1, 0.5, 2.0], dtype=np.float32)