From bacca8b9d5acd55ce6f03f59498625c6ec60ec01 Mon Sep 17 00:00:00 2001 From: awe Date: Mon, 16 Mar 2026 12:48:58 +0300 Subject: [PATCH] new background remove algoritm --- rfg_adc_plotter/constants.py | 1 + rfg_adc_plotter/gui/pyqtgraph_backend.py | 279 ++++++++++++++------- rfg_adc_plotter/processing/__init__.py | 10 + rfg_adc_plotter/processing/background.py | 66 +++++ rfg_adc_plotter/state/__init__.py | 3 +- rfg_adc_plotter/state/background_buffer.py | 49 ++++ rfg_adc_plotter/state/ring_buffer.py | 9 + rfg_adc_plotter/state/runtime_state.py | 9 +- tests/test_background_buffer.py | 44 ++++ tests/test_processing.py | 27 ++ tests/test_ring_buffer.py | 2 + 11 files changed, 402 insertions(+), 97 deletions(-) create mode 100644 rfg_adc_plotter/processing/background.py create mode 100644 rfg_adc_plotter/state/background_buffer.py create mode 100644 tests/test_background_buffer.py diff --git a/rfg_adc_plotter/constants.py b/rfg_adc_plotter/constants.py index 5ac0f42..cf4daa1 100644 --- a/rfg_adc_plotter/constants.py +++ b/rfg_adc_plotter/constants.py @@ -2,6 +2,7 @@ WF_WIDTH = 1000 FFT_LEN = 1024 +BACKGROUND_MEDIAN_SWEEPS = 64 SWEEP_FREQ_MIN_GHZ = 3.3 SWEEP_FREQ_MAX_GHZ = 14.3 diff --git a/rfg_adc_plotter/gui/pyqtgraph_backend.py b/rfg_adc_plotter/gui/pyqtgraph_backend.py index 45d03a5..fc6f35c 100644 --- a/rfg_adc_plotter/gui/pyqtgraph_backend.py +++ b/rfg_adc_plotter/gui/pyqtgraph_backend.py @@ -12,6 +12,11 @@ import numpy as np from rfg_adc_plotter.constants import FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ from rfg_adc_plotter.io.sweep_reader import SweepReader +from rfg_adc_plotter.processing.background import ( + load_fft_background, + save_fft_background, + subtract_fft_background, +) from rfg_adc_plotter.processing.calibration import ( build_calib_envelope, calibrate_freqs, @@ -21,7 +26,7 @@ from rfg_adc_plotter.processing.calibration import ( save_calib_envelope, set_calibration_base_value, ) -from rfg_adc_plotter.processing.fft import compute_fft_row, fft_mag_to_db +from rfg_adc_plotter.processing.fft import compute_fft_mag_row, fft_mag_to_db from rfg_adc_plotter.processing.formatting import compute_auto_ylim, format_status_kv, parse_spec_clip from rfg_adc_plotter.processing.normalization import normalize_by_envelope, resample_envelope from rfg_adc_plotter.processing.peaks import ( @@ -248,9 +253,6 @@ def run_pyqtgraph(args) -> None: spec_right_line.setVisible(False) calib_cb = QtWidgets.QCheckBox("калибровка по огибающей") - bg_compute_cb = QtWidgets.QCheckBox("расчет фона") - bg_subtract_cb = QtWidgets.QCheckBox("вычет фона") - fft_bg_subtract_cb = QtWidgets.QCheckBox("FFT вычет фона") range_group = QtWidgets.QGroupBox("Рабочий диапазон") range_group_layout = QtWidgets.QFormLayout(range_group) range_group_layout.setContentsMargins(6, 6, 6, 6) @@ -300,15 +302,39 @@ def run_pyqtgraph(args) -> None: calib_buttons_row.addWidget(calib_save_btn) calib_buttons_row.addWidget(calib_load_btn) calib_group_layout.addLayout(calib_buttons_row) + background_group = QtWidgets.QGroupBox("Фон") + background_group_layout = QtWidgets.QVBoxLayout(background_group) + background_group_layout.setContentsMargins(6, 6, 6, 6) + background_group_layout.setSpacing(6) + background_cb = QtWidgets.QCheckBox("вычитание фона") + background_group_layout.addWidget(background_cb) + background_path_edit = QtWidgets.QLineEdit("fft_background.npy") + try: + background_path_edit.setPlaceholderText("fft_background.npy") + except Exception: + pass + background_path_row = QtWidgets.QHBoxLayout() + background_path_row.setContentsMargins(0, 0, 0, 0) + background_path_row.setSpacing(4) + background_path_row.addWidget(background_path_edit) + background_pick_btn = QtWidgets.QPushButton("Файл...") + background_path_row.addWidget(background_pick_btn) + background_group_layout.addLayout(background_path_row) + background_buttons_row = QtWidgets.QHBoxLayout() + background_buttons_row.setContentsMargins(0, 0, 0, 0) + background_buttons_row.setSpacing(4) + background_save_btn = QtWidgets.QPushButton("Сохранить медиану") + background_load_btn = QtWidgets.QPushButton("Загрузить") + background_buttons_row.addWidget(background_save_btn) + background_buttons_row.addWidget(background_load_btn) + background_group_layout.addLayout(background_buttons_row) try: settings_layout.addWidget(QtWidgets.QLabel("Настройки")) except Exception: pass settings_layout.addWidget(range_group) settings_layout.addWidget(calib_group) - settings_layout.addWidget(bg_compute_cb) - settings_layout.addWidget(bg_subtract_cb) - settings_layout.addWidget(fft_bg_subtract_cb) + settings_layout.addWidget(background_group) settings_layout.addWidget(fft_mode_label) settings_layout.addWidget(fft_mode_combo) settings_layout.addWidget(peak_search_cb) @@ -317,9 +343,7 @@ def run_pyqtgraph(args) -> None: win.addItem(status, row=3, col=0, colspan=2) calib_enabled = False - bg_compute_enabled = True - bg_subtract_enabled = False - fft_bg_subtract_enabled = False + background_enabled = False fft_mode = "symmetric" status_note = "" status_dirty = True @@ -422,11 +446,35 @@ def run_pyqtgraph(args) -> None: path = "" return path or "calibration_envelope.npy" + def get_background_file_path() -> str: + try: + path = background_path_edit.text().strip() + except Exception: + path = "" + return path or "fft_background.npy" + + def reset_background_state(*, clear_profile: bool = True) -> None: + runtime.background_buffer.reset() + if clear_profile: + runtime.background_profile = None + runtime.current_fft_mag = None + runtime.current_fft_db = None + + def resolve_active_background(expected_size: int) -> Optional[np.ndarray]: + if not background_enabled: + return None + if runtime.background_profile is None: + return None + if runtime.background_profile.size != int(expected_size): + set_status_note("фон: размер профиля не совпадает с FFT") + return None + return runtime.background_profile + def reset_ring_buffers() -> None: runtime.ring.reset() runtime.current_distances = None + runtime.current_fft_mag = None runtime.current_fft_db = None - runtime.bg_spec_cache = None runtime.current_peak_width = None runtime.current_peak_amplitude = None runtime.peak_candidates = [] @@ -442,6 +490,7 @@ def run_pyqtgraph(args) -> None: runtime.current_freqs = None runtime.current_sweep_raw = None runtime.current_sweep_norm = None + runtime.current_fft_mag = None runtime.current_fft_db = None runtime.current_distances = runtime.ring.distance_axis return @@ -461,6 +510,7 @@ def run_pyqtgraph(args) -> None: runtime.current_freqs = None runtime.current_sweep_raw = None runtime.current_sweep_norm = None + runtime.current_fft_mag = None runtime.current_fft_db = None runtime.current_distances = None set_status_note("диапазон: нет точек в выбранном окне") @@ -479,6 +529,7 @@ def run_pyqtgraph(args) -> None: else: runtime.current_sweep_norm = None + runtime.current_fft_mag = None runtime.current_fft_db = None if ( not push_to_ring @@ -491,7 +542,10 @@ def run_pyqtgraph(args) -> None: ensure_buffer(runtime.current_sweep_raw.size) runtime.ring.push(sweep_for_processing, runtime.current_freqs) runtime.current_distances = runtime.ring.distance_axis + runtime.current_fft_mag = runtime.ring.get_last_fft_linear() runtime.current_fft_db = runtime.ring.last_fft_db + if runtime.current_fft_mag is not None: + runtime.background_buffer.push(runtime.current_fft_mag) def set_calib_enabled() -> None: nonlocal calib_enabled @@ -501,6 +555,7 @@ def run_pyqtgraph(args) -> None: calib_enabled = False if calib_enabled and runtime.calib_envelope is None: set_status_note("калибровка: огибающая не загружена") + reset_background_state(clear_profile=True) recompute_current_processed_sweep(push_to_ring=False) runtime.mark_dirty() @@ -530,6 +585,7 @@ def run_pyqtgraph(args) -> None: return runtime.range_min_ghz = new_min runtime.range_max_ghz = new_max + reset_background_state(clear_profile=True) refresh_current_window(push_to_ring=True, reset_ring=True) set_status_note(f"диапазон: {new_min:.6g}..{new_max:.6g} GHz") runtime.mark_dirty() @@ -554,6 +610,26 @@ def run_pyqtgraph(args) -> None: except Exception: pass + def pick_background_file() -> None: + start_path = get_background_file_path() + try: + selected, _ = QtWidgets.QFileDialog.getSaveFileName( + main_window, + "Файл фона FFT", + start_path, + "NumPy (*.npy);;All files (*)", + ) + except Exception as exc: + set_status_note(f"фон: выбор файла недоступен ({exc})") + runtime.mark_dirty() + return + if not selected: + return + try: + background_path_edit.setText(selected) + except Exception: + pass + def save_current_calibration() -> None: if runtime.current_sweep_raw is None or runtime.current_sweep_raw.size == 0: set_status_note("калибровка: нет текущего raw-свипа") @@ -574,6 +650,7 @@ def run_pyqtgraph(args) -> None: calib_path_edit.setText(saved_path) except Exception: pass + reset_background_state(clear_profile=True) recompute_current_processed_sweep(push_to_ring=False) set_status_note(f"калибровка сохранена: {saved_path}") runtime.mark_dirty() @@ -595,32 +672,67 @@ def run_pyqtgraph(args) -> None: calib_path_edit.setText(normalized_path) except Exception: pass + reset_background_state(clear_profile=True) recompute_current_processed_sweep(push_to_ring=False) set_status_note(f"калибровка загружена: {normalized_path}") runtime.mark_dirty() - def set_bg_compute_enabled() -> None: - nonlocal bg_compute_enabled + def save_current_background() -> None: + background = runtime.background_buffer.median() + if background is None or background.size == 0: + set_status_note("фон: буфер пуст, нечего сохранять") + runtime.mark_dirty() + return try: - bg_compute_enabled = bool(bg_compute_cb.isChecked()) + saved_path = save_fft_background(get_background_file_path(), background) + except Exception as exc: + set_status_note(f"фон: не удалось сохранить ({exc})") + runtime.mark_dirty() + return + + runtime.background_profile = np.asarray(background, dtype=np.float32).copy() + runtime.background_file_path = saved_path + try: + background_path_edit.setText(saved_path) except Exception: - bg_compute_enabled = False + pass + runtime.current_fft_db = None + set_status_note(f"фон сохранен: {saved_path}") runtime.mark_dirty() - def set_bg_subtract_enabled() -> None: - nonlocal bg_subtract_enabled + def load_background_file() -> None: + path = get_background_file_path() try: - bg_subtract_enabled = bool(bg_subtract_cb.isChecked()) + background = load_fft_background(path) + except Exception as exc: + set_status_note(f"фон: не удалось загрузить ({exc})") + runtime.mark_dirty() + return + + if background.size != fft_bins: + set_status_note(f"фон: ожидалось {fft_bins} FFT-бинов, получено {background.size}") + runtime.mark_dirty() + return + + normalized_path = path if path.lower().endswith(".npy") else f"{path}.npy" + runtime.background_profile = np.asarray(background, dtype=np.float32).copy() + runtime.background_file_path = normalized_path + try: + background_path_edit.setText(normalized_path) except Exception: - bg_subtract_enabled = False + pass + runtime.current_fft_db = None + set_status_note(f"фон загружен: {normalized_path}") runtime.mark_dirty() - def set_fft_bg_subtract_enabled() -> None: - nonlocal fft_bg_subtract_enabled + def set_background_enabled() -> None: + nonlocal background_enabled try: - fft_bg_subtract_enabled = bool(fft_bg_subtract_cb.isChecked()) + background_enabled = bool(background_cb.isChecked()) except Exception: - fft_bg_subtract_enabled = False + background_enabled = False + if background_enabled and runtime.background_profile is None: + set_status_note("фон: профиль не загружен") runtime.mark_dirty() def set_fft_mode() -> None: @@ -630,7 +742,9 @@ def run_pyqtgraph(args) -> None: except Exception: fft_mode = "symmetric" runtime.ring.set_fft_mode(fft_mode) + reset_background_state(clear_profile=True) runtime.current_distances = runtime.ring.distance_axis + runtime.current_fft_mag = None runtime.current_fft_db = None mode_label = { "direct": "IFFT: обычный", @@ -642,12 +756,11 @@ def run_pyqtgraph(args) -> None: runtime.mark_dirty() try: - bg_compute_cb.setChecked(True) fft_mode_combo.setCurrentIndex(1) except Exception: pass restore_range_controls() - set_bg_compute_enabled() + set_background_enabled() set_fft_mode() try: @@ -657,9 +770,10 @@ def run_pyqtgraph(args) -> None: calib_pick_btn.clicked.connect(lambda _checked=False: pick_calib_file()) calib_save_btn.clicked.connect(lambda _checked=False: save_current_calibration()) calib_load_btn.clicked.connect(lambda _checked=False: load_calibration_file()) - bg_compute_cb.stateChanged.connect(lambda _v: set_bg_compute_enabled()) - bg_subtract_cb.stateChanged.connect(lambda _v: set_bg_subtract_enabled()) - fft_bg_subtract_cb.stateChanged.connect(lambda _v: set_fft_bg_subtract_enabled()) + background_cb.stateChanged.connect(lambda _v: set_background_enabled()) + background_pick_btn.clicked.connect(lambda _checked=False: pick_background_file()) + background_save_btn.clicked.connect(lambda _checked=False: save_current_background()) + background_load_btn.clicked.connect(lambda _checked=False: load_background_file()) fft_mode_combo.currentIndexChanged.connect(lambda _v: set_fft_mode()) except Exception: pass @@ -776,6 +890,7 @@ def run_pyqtgraph(args) -> None: def apply_c_value(idx: int, edit) -> None: try: set_calibration_base_value(idx, float(edit.text().strip())) + reset_background_state(clear_profile=True) runtime.mark_dirty() except Exception: try: @@ -821,38 +936,14 @@ def run_pyqtgraph(args) -> None: except Exception: pass - def visible_bg_fft(disp_fft: np.ndarray, force: bool = False) -> Optional[np.ndarray]: - nonlocal bg_compute_enabled, bg_subtract_enabled - need_bg = bool(bg_subtract_enabled or force) - if (not need_bg) or disp_fft.size == 0: - return None - ny, nx = disp_fft.shape - if ny <= 0 or nx <= 0: - return runtime.bg_spec_cache - if runtime.bg_spec_cache is not None and runtime.bg_spec_cache.size != ny: - runtime.bg_spec_cache = None - if not bg_compute_enabled: - return runtime.bg_spec_cache - try: - (x0, x1), _ = p_spec.viewRange() - except Exception: - x0, x1 = 0.0, float(nx - 1) - xmin, xmax = sorted((float(x0), float(x1))) - ix0 = max(0, min(nx - 1, int(np.floor(xmin)))) - ix1 = max(0, min(nx - 1, int(np.ceil(xmax)))) - if ix1 < ix0: - ix1 = ix0 - window = disp_fft[:, ix0 : ix1 + 1] - if window.size == 0: - return runtime.bg_spec_cache - try: - bg_spec = np.nanmedian(window, axis=1) - except Exception: - return runtime.bg_spec_cache - if not np.any(np.isfinite(bg_spec)): - return runtime.bg_spec_cache - runtime.bg_spec_cache = np.nan_to_num(bg_spec, nan=0.0).astype(np.float32, copy=False) - return runtime.bg_spec_cache + def refresh_current_fft_cache(sweep_for_fft: np.ndarray, bins: int) -> None: + runtime.current_fft_mag = compute_fft_mag_row( + sweep_for_fft, + runtime.current_freqs, + bins, + mode=fft_mode, + ) + runtime.current_fft_db = fft_mag_to_db(runtime.current_fft_mag) def drain_queue() -> int: drained = 0 @@ -897,12 +988,6 @@ def run_pyqtgraph(args) -> None: changed = drain_queue() > 0 redraw_needed = changed or runtime.plot_dirty - bg_fft_for_line = None - if redraw_needed and fft_bg_subtract_enabled and runtime.ring.ring_fft is not None: - try: - bg_fft_for_line = visible_bg_fft(runtime.ring.get_display_fft_linear(), force=True) - except Exception: - bg_fft_for_line = None if redraw_needed: xs = resolve_curve_xs( @@ -952,25 +1037,24 @@ def run_pyqtgraph(args) -> None: sweep_for_fft = runtime.current_sweep_norm if runtime.current_sweep_norm is not None else runtime.current_sweep_raw distance_axis = runtime.current_distances if runtime.current_distances is not None else runtime.ring.distance_axis if sweep_for_fft is not None and sweep_for_fft.size > 0 and distance_axis is not None: - if runtime.current_fft_db is None or runtime.current_fft_db.size != distance_axis.size or runtime.plot_dirty: - runtime.current_fft_db = compute_fft_row( - sweep_for_fft, - runtime.current_freqs, - distance_axis.size, - mode=fft_mode, - ) - fft_vals = runtime.current_fft_db - xs_fft = distance_axis[: fft_vals.size] - if fft_bg_subtract_enabled and bg_fft_for_line is not None: - n_bg = int(min(fft_vals.size, bg_fft_for_line.size)) - if n_bg > 0: - num = np.maximum( - np.power(10.0, np.asarray(fft_vals[:n_bg], dtype=np.float64) / 20.0), - 0.0, - ) - den = np.maximum(np.asarray(bg_fft_for_line[:n_bg], dtype=np.float64), 0.0) - fft_vals = (20.0 * np.log10((num + 1e-9) / (den + 1e-9))).astype(np.float32, copy=False) - xs_fft = xs_fft[:n_bg] + if runtime.current_fft_mag is None or runtime.current_fft_mag.size != distance_axis.size or runtime.plot_dirty: + refresh_current_fft_cache(sweep_for_fft, distance_axis.size) + fft_mag = runtime.current_fft_mag + xs_fft = distance_axis[: fft_mag.size] + active_background = None + try: + active_background = resolve_active_background(fft_mag.size) + except Exception: + active_background = None + display_fft_mag = fft_mag + if active_background is not None: + try: + display_fft_mag = subtract_fft_background(fft_mag, active_background) + except Exception as exc: + set_status_note(f"фон: не удалось применить ({exc})") + active_background = None + display_fft_mag = fft_mag + fft_vals = fft_mag_to_db(display_fft_mag) curve_fft.setData(xs_fft, fft_vals) finite_x = xs_fft[np.isfinite(xs_fft)] if finite_x.size > 0: @@ -1006,7 +1090,7 @@ def run_pyqtgraph(args) -> None: for box in fft_peak_boxes: box.setVisible(False) - if fft_bg_subtract_enabled and bg_fft_for_line is not None: + if active_background is not None: p_fft.setYRange(-10.0, 30.0, padding=0) else: finite_y = y_for_range[np.isfinite(y_for_range)] @@ -1109,7 +1193,7 @@ def run_pyqtgraph(args) -> None: except Exception: pass - if changed and runtime.ring.ring_fft is not None: + if redraw_needed and runtime.ring.ring_fft is not None: disp_fft_lin = runtime.ring.get_display_fft_linear() if spec_mean_sec > 0.0: disp_times = runtime.ring.get_display_times() @@ -1124,16 +1208,21 @@ def run_pyqtgraph(args) -> None: except Exception: pass - bg_spec = visible_bg_fft(disp_fft_lin) - if bg_spec is not None: - num = np.maximum(disp_fft_lin, 0.0).astype(np.float32, copy=False) + 1e-9 - den = bg_spec[:, None] + 1e-9 - disp_fft = (20.0 * np.log10(num / den)).astype(np.float32, copy=False) - else: - disp_fft = fft_mag_to_db(disp_fft_lin) + active_background = None + try: + active_background = resolve_active_background(disp_fft_lin.shape[0]) + except Exception: + active_background = None + if active_background is not None: + try: + disp_fft_lin = subtract_fft_background(disp_fft_lin, active_background) + except Exception as exc: + set_status_note(f"фон: не удалось применить ({exc})") + active_background = None + disp_fft = fft_mag_to_db(disp_fft_lin) levels = None - if bg_spec is not None: + if active_background is not None: try: p5 = float(np.nanpercentile(disp_fft, 5)) p95 = float(np.nanpercentile(disp_fft, 95)) diff --git a/rfg_adc_plotter/processing/__init__.py b/rfg_adc_plotter/processing/__init__.py index 2d3e98a..ca489a0 100644 --- a/rfg_adc_plotter/processing/__init__.py +++ b/rfg_adc_plotter/processing/__init__.py @@ -1,5 +1,11 @@ """Pure sweep-processing helpers.""" +from rfg_adc_plotter.processing.background import ( + load_fft_background, + save_fft_background, + subtract_fft_background, + validate_fft_background, +) from rfg_adc_plotter.processing.calibration import ( build_calib_envelope, calibrate_freqs, @@ -47,11 +53,15 @@ __all__ = [ "get_calibration_base", "get_calibration_coeffs", "load_calib_envelope", + "load_fft_background", "normalize_by_envelope", "normalize_by_calib", "parse_spec_clip", "recalculate_calibration_c", "rolling_median_ref", "save_calib_envelope", + "save_fft_background", "set_calibration_base_value", + "subtract_fft_background", + "validate_fft_background", ] diff --git a/rfg_adc_plotter/processing/background.py b/rfg_adc_plotter/processing/background.py new file mode 100644 index 0000000..6f9e435 --- /dev/null +++ b/rfg_adc_plotter/processing/background.py @@ -0,0 +1,66 @@ +"""Helpers for persisted FFT background profiles.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np + + +def validate_fft_background(background: np.ndarray) -> np.ndarray: + """Validate a saved FFT background payload.""" + values = np.asarray(background) + if values.ndim != 1: + raise ValueError("FFT background must be a 1D array") + if not np.issubdtype(values.dtype, np.number): + raise ValueError("FFT background must be numeric") + values = np.asarray(values, dtype=np.float32).reshape(-1) + if values.size == 0: + raise ValueError("FFT background is empty") + return values + + +def _normalize_background_path(path: str | Path) -> Path: + out = Path(path).expanduser() + if out.suffix.lower() != ".npy": + out = out.with_suffix(".npy") + return out + + +def save_fft_background(path: str | Path, background: np.ndarray) -> str: + """Persist an FFT background profile as a .npy file.""" + normalized_path = _normalize_background_path(path) + values = validate_fft_background(background) + np.save(normalized_path, values.astype(np.float32, copy=False)) + return str(normalized_path) + + +def load_fft_background(path: str | Path) -> np.ndarray: + """Load and validate an FFT background profile from a .npy file.""" + normalized_path = _normalize_background_path(path) + loaded = np.load(normalized_path, allow_pickle=False) + return validate_fft_background(loaded) + + +def subtract_fft_background(signal_mag: np.ndarray, background_mag: np.ndarray) -> np.ndarray: + """Subtract a background profile from FFT magnitudes in linear amplitude.""" + signal = np.asarray(signal_mag, dtype=np.float32) + background = validate_fft_background(background_mag) + if signal.ndim == 1: + if signal.size != background.size: + raise ValueError("FFT background size does not match signal size") + valid = np.isfinite(signal) & np.isfinite(background) + out = np.full_like(signal, np.nan, dtype=np.float32) + if np.any(valid): + out[valid] = np.maximum(signal[valid] - background[valid], 0.0) + return out + + if signal.ndim == 2: + if signal.shape[0] != background.size: + raise ValueError("FFT background size does not match signal rows") + background_2d = background[:, None] + valid = np.isfinite(signal) & np.isfinite(background_2d) + diff = signal - background_2d + return np.where(valid, np.maximum(diff, 0.0), np.nan).astype(np.float32, copy=False) + + raise ValueError("FFT background subtraction supports only 1D or 2D signals") diff --git a/rfg_adc_plotter/state/__init__.py b/rfg_adc_plotter/state/__init__.py index 500f041..f9d9bbf 100644 --- a/rfg_adc_plotter/state/__init__.py +++ b/rfg_adc_plotter/state/__init__.py @@ -1,6 +1,7 @@ """Runtime state helpers.""" +from rfg_adc_plotter.state.background_buffer import BackgroundMedianBuffer from rfg_adc_plotter.state.ring_buffer import RingBuffer from rfg_adc_plotter.state.runtime_state import RuntimeState -__all__ = ["RingBuffer", "RuntimeState"] +__all__ = ["BackgroundMedianBuffer", "RingBuffer", "RuntimeState"] diff --git a/rfg_adc_plotter/state/background_buffer.py b/rfg_adc_plotter/state/background_buffer.py new file mode 100644 index 0000000..dccc432 --- /dev/null +++ b/rfg_adc_plotter/state/background_buffer.py @@ -0,0 +1,49 @@ +"""Rolling median buffer for persisted FFT background capture.""" + +from __future__ import annotations + +from typing import Optional + +import numpy as np + + +class BackgroundMedianBuffer: + """Store recent FFT rows and expose their median profile.""" + + def __init__(self, max_rows: int): + self.max_rows = max(1, int(max_rows)) + self.width = 0 + self.head = 0 + self.count = 0 + self.rows: Optional[np.ndarray] = None + + def reset(self) -> None: + self.width = 0 + self.head = 0 + self.count = 0 + self.rows = None + + def push(self, fft_mag: np.ndarray) -> None: + values = np.asarray(fft_mag, dtype=np.float32).reshape(-1) + if values.size == 0: + return + if self.rows is None or self.width != values.size: + self.width = values.size + self.rows = np.full((self.max_rows, self.width), np.nan, dtype=np.float32) + self.head = 0 + self.count = 0 + self.rows[self.head, :] = values + self.head = (self.head + 1) % self.max_rows + self.count = min(self.count + 1, self.max_rows) + + def median(self) -> Optional[np.ndarray]: + if self.rows is None or self.count <= 0: + return None + rows = self.rows[: self.count] if self.count < self.max_rows else self.rows + valid_rows = np.any(np.isfinite(rows), axis=1) + if not np.any(valid_rows): + return None + median = np.nanmedian(rows[valid_rows], axis=0).astype(np.float32, copy=False) + if not np.any(np.isfinite(median)): + return None + return np.nan_to_num(median, nan=0.0).astype(np.float32, copy=False) diff --git a/rfg_adc_plotter/state/ring_buffer.py b/rfg_adc_plotter/state/ring_buffer.py index 59ee18d..124c451 100644 --- a/rfg_adc_plotter/state/ring_buffer.py +++ b/rfg_adc_plotter/state/ring_buffer.py @@ -25,6 +25,7 @@ class RingBuffer: self.ring_fft: Optional[np.ndarray] = None self.x_shared: Optional[np.ndarray] = None self.distance_axis: Optional[np.ndarray] = None + self.last_fft_mag: Optional[np.ndarray] = None self.last_fft_db: Optional[np.ndarray] = None self.last_freqs: Optional[np.ndarray] = None self.y_min_fft: Optional[float] = None @@ -47,6 +48,7 @@ class RingBuffer: self.ring_fft = None self.x_shared = None self.distance_axis = None + self.last_fft_mag = None self.last_fft_db = None self.last_freqs = None self.y_min_fft = None @@ -125,6 +127,7 @@ class RingBuffer: last_idx = (self.head - 1) % self.max_sweeps if self.ring_fft.shape[0] > 0: last_fft = self.ring_fft[last_idx] + self.last_fft_mag = np.asarray(last_fft, dtype=np.float32).copy() self.last_fft_db = fft_mag_to_db(last_fft) finite = self.ring_fft[np.isfinite(self.ring_fft)] if finite.size > 0: @@ -155,6 +158,7 @@ class RingBuffer: fft_mag = compute_fft_mag_row(sweep, freqs, self.fft_bins, mode=self.fft_mode) self.ring_fft[self.head, :] = fft_mag + self.last_fft_mag = np.asarray(fft_mag, dtype=np.float32).copy() self.last_fft_db = fft_mag_to_db(fft_mag) if self.last_fft_db.size > 0: @@ -178,6 +182,11 @@ class RingBuffer: base = self.ring_fft if self.head == 0 else np.roll(self.ring_fft, -self.head, axis=0) return base.T + def get_last_fft_linear(self) -> Optional[np.ndarray]: + if self.last_fft_mag is None: + return None + return np.asarray(self.last_fft_mag, dtype=np.float32).copy() + def get_display_times(self) -> Optional[np.ndarray]: if self.ring_time is None: return None diff --git a/rfg_adc_plotter/state/runtime_state.py b/rfg_adc_plotter/state/runtime_state.py index 787071f..3b99a29 100644 --- a/rfg_adc_plotter/state/runtime_state.py +++ b/rfg_adc_plotter/state/runtime_state.py @@ -7,6 +7,8 @@ from typing import Dict, List, Optional import numpy as np +from rfg_adc_plotter.constants import BACKGROUND_MEDIAN_SWEEPS +from rfg_adc_plotter.state.background_buffer import BackgroundMedianBuffer from rfg_adc_plotter.state.ring_buffer import RingBuffer from rfg_adc_plotter.types import SweepAuxCurves, SweepInfo @@ -23,12 +25,17 @@ class RuntimeState: current_sweep_raw: Optional[np.ndarray] = None current_aux_curves: SweepAuxCurves = None current_sweep_norm: Optional[np.ndarray] = None + current_fft_mag: Optional[np.ndarray] = None current_fft_db: Optional[np.ndarray] = None last_calib_sweep: Optional[np.ndarray] = None calib_envelope: Optional[np.ndarray] = None calib_file_path: Optional[str] = None + background_buffer: BackgroundMedianBuffer = field( + default_factory=lambda: BackgroundMedianBuffer(BACKGROUND_MEDIAN_SWEEPS) + ) + background_profile: Optional[np.ndarray] = None + background_file_path: Optional[str] = None current_info: Optional[SweepInfo] = None - bg_spec_cache: Optional[np.ndarray] = None current_peak_width: Optional[float] = None current_peak_amplitude: Optional[float] = None peak_candidates: List[Dict[str, float]] = field(default_factory=list) diff --git a/tests/test_background_buffer.py b/tests/test_background_buffer.py new file mode 100644 index 0000000..e601bb5 --- /dev/null +++ b/tests/test_background_buffer.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import numpy as np +import unittest + +from rfg_adc_plotter.state.background_buffer import BackgroundMedianBuffer + + +class BackgroundMedianBufferTests(unittest.TestCase): + def test_buffer_returns_median_for_partial_fill(self): + buffer = BackgroundMedianBuffer(max_rows=4) + buffer.push(np.asarray([1.0, 5.0, 9.0], dtype=np.float32)) + buffer.push(np.asarray([3.0, 7.0, 11.0], dtype=np.float32)) + + median = buffer.median() + + self.assertIsNotNone(median) + self.assertTrue(np.allclose(median, np.asarray([2.0, 6.0, 10.0], dtype=np.float32))) + + def test_buffer_wraparound_keeps_latest_rows(self): + buffer = BackgroundMedianBuffer(max_rows=2) + buffer.push(np.asarray([1.0, 5.0], dtype=np.float32)) + buffer.push(np.asarray([3.0, 7.0], dtype=np.float32)) + buffer.push(np.asarray([9.0, 11.0], dtype=np.float32)) + + median = buffer.median() + + self.assertIsNotNone(median) + self.assertTrue(np.allclose(median, np.asarray([6.0, 9.0], dtype=np.float32))) + + def test_buffer_reset_clears_state(self): + buffer = BackgroundMedianBuffer(max_rows=2) + buffer.push(np.asarray([1.0, 2.0], dtype=np.float32)) + + buffer.reset() + + self.assertIsNone(buffer.rows) + self.assertIsNone(buffer.median()) + self.assertEqual(buffer.count, 0) + self.assertEqual(buffer.head, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_processing.py b/tests/test_processing.py index 19afa11..9d8eb28 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -14,6 +14,11 @@ from rfg_adc_plotter.processing.calibration import ( recalculate_calibration_c, save_calib_envelope, ) +from rfg_adc_plotter.processing.background import ( + load_fft_background, + save_fft_background, + subtract_fft_background, +) from rfg_adc_plotter.processing.fft import ( build_positive_only_centered_ifft_spectrum, build_symmetric_ifft_spectrum, @@ -101,6 +106,28 @@ class ProcessingTests(unittest.TestCase): with self.assertRaises(ValueError): load_calib_envelope(path) + def test_fft_background_roundtrip_and_rejects_non_1d_payload(self): + background = np.asarray([0.5, 1.5, 2.5], dtype=np.float32) + with tempfile.TemporaryDirectory() as tmp_dir: + path = os.path.join(tmp_dir, "fft_background") + saved_path = save_fft_background(path, background) + loaded = load_fft_background(saved_path) + self.assertTrue(saved_path.endswith(".npy")) + self.assertTrue(np.allclose(loaded, background)) + + invalid_path = os.path.join(tmp_dir, "fft_background_invalid.npy") + np.save(invalid_path, np.zeros((2, 2), dtype=np.float32)) + with self.assertRaises(ValueError): + load_fft_background(invalid_path) + + def test_subtract_fft_background_clamps_negative_residuals_to_zero(self): + signal = np.asarray([1.0, 2.0, 3.0], dtype=np.float32) + background = np.asarray([1.0, 1.5, 5.0], dtype=np.float32) + subtracted = subtract_fft_background(signal, background) + + self.assertTrue(np.allclose(subtracted, np.asarray([0.0, 0.5, 0.0], dtype=np.float32))) + self.assertTrue(np.allclose(subtract_fft_background(signal, signal), 0.0)) + def test_apply_working_range_crops_sweep_to_selected_band(self): freqs = np.linspace(3.3, 14.3, 12, dtype=np.float64) sweep = np.arange(12, dtype=np.float32) diff --git a/tests/test_ring_buffer.py b/tests/test_ring_buffer.py index e1e0405..7cf2c4f 100644 --- a/tests/test_ring_buffer.py +++ b/tests/test_ring_buffer.py @@ -15,6 +15,7 @@ class RingBufferTests(unittest.TestCase): self.assertIsNotNone(ring.ring_fft) self.assertIsNotNone(ring.ring_time) self.assertIsNotNone(ring.distance_axis) + self.assertIsNotNone(ring.get_last_fft_linear()) self.assertIsNotNone(ring.last_fft_db) self.assertEqual(ring.ring.shape[0], 4) self.assertEqual(ring.ring_fft.shape, (4, ring.fft_bins)) @@ -52,6 +53,7 @@ class RingBufferTests(unittest.TestCase): self.assertTrue(changed) self.assertFalse(ring.fft_symmetric) self.assertEqual(ring.get_display_raw().shape[1], 2) + self.assertIsNotNone(ring.get_last_fft_linear()) self.assertEqual(ring.last_fft_db.shape, fft_before.shape) self.assertFalse(np.allclose(ring.last_fft_db, fft_before)) self.assertFalse(np.allclose(ring.distance_axis, axis_before))