new background remove algoritm

This commit is contained in:
awe
2026-03-16 12:48:58 +03:00
parent b70df8c1bd
commit bacca8b9d5
11 changed files with 402 additions and 97 deletions

View File

@ -2,6 +2,7 @@
WF_WIDTH = 1000 WF_WIDTH = 1000
FFT_LEN = 1024 FFT_LEN = 1024
BACKGROUND_MEDIAN_SWEEPS = 64
SWEEP_FREQ_MIN_GHZ = 3.3 SWEEP_FREQ_MIN_GHZ = 3.3
SWEEP_FREQ_MAX_GHZ = 14.3 SWEEP_FREQ_MAX_GHZ = 14.3

View File

@ -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.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.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 ( from rfg_adc_plotter.processing.calibration import (
build_calib_envelope, build_calib_envelope,
calibrate_freqs, calibrate_freqs,
@ -21,7 +26,7 @@ from rfg_adc_plotter.processing.calibration import (
save_calib_envelope, save_calib_envelope,
set_calibration_base_value, 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.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.normalization import normalize_by_envelope, resample_envelope
from rfg_adc_plotter.processing.peaks import ( from rfg_adc_plotter.processing.peaks import (
@ -248,9 +253,6 @@ def run_pyqtgraph(args) -> None:
spec_right_line.setVisible(False) spec_right_line.setVisible(False)
calib_cb = QtWidgets.QCheckBox("калибровка по огибающей") 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 = QtWidgets.QGroupBox("Рабочий диапазон")
range_group_layout = QtWidgets.QFormLayout(range_group) range_group_layout = QtWidgets.QFormLayout(range_group)
range_group_layout.setContentsMargins(6, 6, 6, 6) 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_save_btn)
calib_buttons_row.addWidget(calib_load_btn) calib_buttons_row.addWidget(calib_load_btn)
calib_group_layout.addLayout(calib_buttons_row) 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: try:
settings_layout.addWidget(QtWidgets.QLabel("Настройки")) settings_layout.addWidget(QtWidgets.QLabel("Настройки"))
except Exception: except Exception:
pass pass
settings_layout.addWidget(range_group) settings_layout.addWidget(range_group)
settings_layout.addWidget(calib_group) settings_layout.addWidget(calib_group)
settings_layout.addWidget(bg_compute_cb) settings_layout.addWidget(background_group)
settings_layout.addWidget(bg_subtract_cb)
settings_layout.addWidget(fft_bg_subtract_cb)
settings_layout.addWidget(fft_mode_label) settings_layout.addWidget(fft_mode_label)
settings_layout.addWidget(fft_mode_combo) settings_layout.addWidget(fft_mode_combo)
settings_layout.addWidget(peak_search_cb) settings_layout.addWidget(peak_search_cb)
@ -317,9 +343,7 @@ def run_pyqtgraph(args) -> None:
win.addItem(status, row=3, col=0, colspan=2) win.addItem(status, row=3, col=0, colspan=2)
calib_enabled = False calib_enabled = False
bg_compute_enabled = True background_enabled = False
bg_subtract_enabled = False
fft_bg_subtract_enabled = False
fft_mode = "symmetric" fft_mode = "symmetric"
status_note = "" status_note = ""
status_dirty = True status_dirty = True
@ -422,11 +446,35 @@ def run_pyqtgraph(args) -> None:
path = "" path = ""
return path or "calibration_envelope.npy" 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: def reset_ring_buffers() -> None:
runtime.ring.reset() runtime.ring.reset()
runtime.current_distances = None runtime.current_distances = None
runtime.current_fft_mag = None
runtime.current_fft_db = None runtime.current_fft_db = None
runtime.bg_spec_cache = None
runtime.current_peak_width = None runtime.current_peak_width = None
runtime.current_peak_amplitude = None runtime.current_peak_amplitude = None
runtime.peak_candidates = [] runtime.peak_candidates = []
@ -442,6 +490,7 @@ def run_pyqtgraph(args) -> None:
runtime.current_freqs = None runtime.current_freqs = None
runtime.current_sweep_raw = None runtime.current_sweep_raw = None
runtime.current_sweep_norm = None runtime.current_sweep_norm = None
runtime.current_fft_mag = None
runtime.current_fft_db = None runtime.current_fft_db = None
runtime.current_distances = runtime.ring.distance_axis runtime.current_distances = runtime.ring.distance_axis
return return
@ -461,6 +510,7 @@ def run_pyqtgraph(args) -> None:
runtime.current_freqs = None runtime.current_freqs = None
runtime.current_sweep_raw = None runtime.current_sweep_raw = None
runtime.current_sweep_norm = None runtime.current_sweep_norm = None
runtime.current_fft_mag = None
runtime.current_fft_db = None runtime.current_fft_db = None
runtime.current_distances = None runtime.current_distances = None
set_status_note("диапазон: нет точек в выбранном окне") set_status_note("диапазон: нет точек в выбранном окне")
@ -479,6 +529,7 @@ def run_pyqtgraph(args) -> None:
else: else:
runtime.current_sweep_norm = None runtime.current_sweep_norm = None
runtime.current_fft_mag = None
runtime.current_fft_db = None runtime.current_fft_db = None
if ( if (
not push_to_ring not push_to_ring
@ -491,7 +542,10 @@ def run_pyqtgraph(args) -> None:
ensure_buffer(runtime.current_sweep_raw.size) ensure_buffer(runtime.current_sweep_raw.size)
runtime.ring.push(sweep_for_processing, runtime.current_freqs) runtime.ring.push(sweep_for_processing, runtime.current_freqs)
runtime.current_distances = runtime.ring.distance_axis 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 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: def set_calib_enabled() -> None:
nonlocal calib_enabled nonlocal calib_enabled
@ -501,6 +555,7 @@ def run_pyqtgraph(args) -> None:
calib_enabled = False calib_enabled = False
if calib_enabled and runtime.calib_envelope is None: if calib_enabled and runtime.calib_envelope is None:
set_status_note("калибровка: огибающая не загружена") set_status_note("калибровка: огибающая не загружена")
reset_background_state(clear_profile=True)
recompute_current_processed_sweep(push_to_ring=False) recompute_current_processed_sweep(push_to_ring=False)
runtime.mark_dirty() runtime.mark_dirty()
@ -530,6 +585,7 @@ def run_pyqtgraph(args) -> None:
return return
runtime.range_min_ghz = new_min runtime.range_min_ghz = new_min
runtime.range_max_ghz = new_max runtime.range_max_ghz = new_max
reset_background_state(clear_profile=True)
refresh_current_window(push_to_ring=True, reset_ring=True) refresh_current_window(push_to_ring=True, reset_ring=True)
set_status_note(f"диапазон: {new_min:.6g}..{new_max:.6g} GHz") set_status_note(f"диапазон: {new_min:.6g}..{new_max:.6g} GHz")
runtime.mark_dirty() runtime.mark_dirty()
@ -554,6 +610,26 @@ def run_pyqtgraph(args) -> None:
except Exception: except Exception:
pass 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: def save_current_calibration() -> None:
if runtime.current_sweep_raw is None or runtime.current_sweep_raw.size == 0: if runtime.current_sweep_raw is None or runtime.current_sweep_raw.size == 0:
set_status_note("калибровка: нет текущего raw-свипа") set_status_note("калибровка: нет текущего raw-свипа")
@ -574,6 +650,7 @@ def run_pyqtgraph(args) -> None:
calib_path_edit.setText(saved_path) calib_path_edit.setText(saved_path)
except Exception: except Exception:
pass pass
reset_background_state(clear_profile=True)
recompute_current_processed_sweep(push_to_ring=False) recompute_current_processed_sweep(push_to_ring=False)
set_status_note(f"калибровка сохранена: {saved_path}") set_status_note(f"калибровка сохранена: {saved_path}")
runtime.mark_dirty() runtime.mark_dirty()
@ -595,32 +672,67 @@ def run_pyqtgraph(args) -> None:
calib_path_edit.setText(normalized_path) calib_path_edit.setText(normalized_path)
except Exception: except Exception:
pass pass
reset_background_state(clear_profile=True)
recompute_current_processed_sweep(push_to_ring=False) recompute_current_processed_sweep(push_to_ring=False)
set_status_note(f"калибровка загружена: {normalized_path}") set_status_note(f"калибровка загружена: {normalized_path}")
runtime.mark_dirty() runtime.mark_dirty()
def set_bg_compute_enabled() -> None: def save_current_background() -> None:
nonlocal bg_compute_enabled background = runtime.background_buffer.median()
if background is None or background.size == 0:
set_status_note("фон: буфер пуст, нечего сохранять")
runtime.mark_dirty()
return
try: 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: except Exception:
bg_compute_enabled = False pass
runtime.current_fft_db = None
set_status_note(f"фон сохранен: {saved_path}")
runtime.mark_dirty() runtime.mark_dirty()
def set_bg_subtract_enabled() -> None: def load_background_file() -> None:
nonlocal bg_subtract_enabled path = get_background_file_path()
try: 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: except Exception:
bg_subtract_enabled = False pass
runtime.current_fft_db = None
set_status_note(f"фон загружен: {normalized_path}")
runtime.mark_dirty() runtime.mark_dirty()
def set_fft_bg_subtract_enabled() -> None: def set_background_enabled() -> None:
nonlocal fft_bg_subtract_enabled nonlocal background_enabled
try: try:
fft_bg_subtract_enabled = bool(fft_bg_subtract_cb.isChecked()) background_enabled = bool(background_cb.isChecked())
except Exception: 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() runtime.mark_dirty()
def set_fft_mode() -> None: def set_fft_mode() -> None:
@ -630,7 +742,9 @@ def run_pyqtgraph(args) -> None:
except Exception: except Exception:
fft_mode = "symmetric" fft_mode = "symmetric"
runtime.ring.set_fft_mode(fft_mode) runtime.ring.set_fft_mode(fft_mode)
reset_background_state(clear_profile=True)
runtime.current_distances = runtime.ring.distance_axis runtime.current_distances = runtime.ring.distance_axis
runtime.current_fft_mag = None
runtime.current_fft_db = None runtime.current_fft_db = None
mode_label = { mode_label = {
"direct": "IFFT: обычный", "direct": "IFFT: обычный",
@ -642,12 +756,11 @@ def run_pyqtgraph(args) -> None:
runtime.mark_dirty() runtime.mark_dirty()
try: try:
bg_compute_cb.setChecked(True)
fft_mode_combo.setCurrentIndex(1) fft_mode_combo.setCurrentIndex(1)
except Exception: except Exception:
pass pass
restore_range_controls() restore_range_controls()
set_bg_compute_enabled() set_background_enabled()
set_fft_mode() set_fft_mode()
try: try:
@ -657,9 +770,10 @@ def run_pyqtgraph(args) -> None:
calib_pick_btn.clicked.connect(lambda _checked=False: pick_calib_file()) calib_pick_btn.clicked.connect(lambda _checked=False: pick_calib_file())
calib_save_btn.clicked.connect(lambda _checked=False: save_current_calibration()) calib_save_btn.clicked.connect(lambda _checked=False: save_current_calibration())
calib_load_btn.clicked.connect(lambda _checked=False: load_calibration_file()) calib_load_btn.clicked.connect(lambda _checked=False: load_calibration_file())
bg_compute_cb.stateChanged.connect(lambda _v: set_bg_compute_enabled()) background_cb.stateChanged.connect(lambda _v: set_background_enabled())
bg_subtract_cb.stateChanged.connect(lambda _v: set_bg_subtract_enabled()) background_pick_btn.clicked.connect(lambda _checked=False: pick_background_file())
fft_bg_subtract_cb.stateChanged.connect(lambda _v: set_fft_bg_subtract_enabled()) 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()) fft_mode_combo.currentIndexChanged.connect(lambda _v: set_fft_mode())
except Exception: except Exception:
pass pass
@ -776,6 +890,7 @@ def run_pyqtgraph(args) -> None:
def apply_c_value(idx: int, edit) -> None: def apply_c_value(idx: int, edit) -> None:
try: try:
set_calibration_base_value(idx, float(edit.text().strip())) set_calibration_base_value(idx, float(edit.text().strip()))
reset_background_state(clear_profile=True)
runtime.mark_dirty() runtime.mark_dirty()
except Exception: except Exception:
try: try:
@ -821,38 +936,14 @@ def run_pyqtgraph(args) -> None:
except Exception: except Exception:
pass pass
def visible_bg_fft(disp_fft: np.ndarray, force: bool = False) -> Optional[np.ndarray]: def refresh_current_fft_cache(sweep_for_fft: np.ndarray, bins: int) -> None:
nonlocal bg_compute_enabled, bg_subtract_enabled runtime.current_fft_mag = compute_fft_mag_row(
need_bg = bool(bg_subtract_enabled or force) sweep_for_fft,
if (not need_bg) or disp_fft.size == 0: runtime.current_freqs,
return None bins,
ny, nx = disp_fft.shape mode=fft_mode,
if ny <= 0 or nx <= 0: )
return runtime.bg_spec_cache runtime.current_fft_db = fft_mag_to_db(runtime.current_fft_mag)
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 drain_queue() -> int: def drain_queue() -> int:
drained = 0 drained = 0
@ -897,12 +988,6 @@ def run_pyqtgraph(args) -> None:
changed = drain_queue() > 0 changed = drain_queue() > 0
redraw_needed = changed or runtime.plot_dirty 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: if redraw_needed:
xs = resolve_curve_xs( 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 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 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 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: if runtime.current_fft_mag is None or runtime.current_fft_mag.size != distance_axis.size or runtime.plot_dirty:
runtime.current_fft_db = compute_fft_row( refresh_current_fft_cache(sweep_for_fft, distance_axis.size)
sweep_for_fft, fft_mag = runtime.current_fft_mag
runtime.current_freqs, xs_fft = distance_axis[: fft_mag.size]
distance_axis.size, active_background = None
mode=fft_mode, try:
) active_background = resolve_active_background(fft_mag.size)
fft_vals = runtime.current_fft_db except Exception:
xs_fft = distance_axis[: fft_vals.size] active_background = None
if fft_bg_subtract_enabled and bg_fft_for_line is not None: display_fft_mag = fft_mag
n_bg = int(min(fft_vals.size, bg_fft_for_line.size)) if active_background is not None:
if n_bg > 0: try:
num = np.maximum( display_fft_mag = subtract_fft_background(fft_mag, active_background)
np.power(10.0, np.asarray(fft_vals[:n_bg], dtype=np.float64) / 20.0), except Exception as exc:
0.0, set_status_note(f"фон: не удалось применить ({exc})")
) active_background = None
den = np.maximum(np.asarray(bg_fft_for_line[:n_bg], dtype=np.float64), 0.0) display_fft_mag = fft_mag
fft_vals = (20.0 * np.log10((num + 1e-9) / (den + 1e-9))).astype(np.float32, copy=False) fft_vals = fft_mag_to_db(display_fft_mag)
xs_fft = xs_fft[:n_bg]
curve_fft.setData(xs_fft, fft_vals) curve_fft.setData(xs_fft, fft_vals)
finite_x = xs_fft[np.isfinite(xs_fft)] finite_x = xs_fft[np.isfinite(xs_fft)]
if finite_x.size > 0: if finite_x.size > 0:
@ -1006,7 +1090,7 @@ def run_pyqtgraph(args) -> None:
for box in fft_peak_boxes: for box in fft_peak_boxes:
box.setVisible(False) 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) p_fft.setYRange(-10.0, 30.0, padding=0)
else: else:
finite_y = y_for_range[np.isfinite(y_for_range)] finite_y = y_for_range[np.isfinite(y_for_range)]
@ -1109,7 +1193,7 @@ def run_pyqtgraph(args) -> None:
except Exception: except Exception:
pass 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() disp_fft_lin = runtime.ring.get_display_fft_linear()
if spec_mean_sec > 0.0: if spec_mean_sec > 0.0:
disp_times = runtime.ring.get_display_times() disp_times = runtime.ring.get_display_times()
@ -1124,16 +1208,21 @@ def run_pyqtgraph(args) -> None:
except Exception: except Exception:
pass pass
bg_spec = visible_bg_fft(disp_fft_lin) active_background = None
if bg_spec is not None: try:
num = np.maximum(disp_fft_lin, 0.0).astype(np.float32, copy=False) + 1e-9 active_background = resolve_active_background(disp_fft_lin.shape[0])
den = bg_spec[:, None] + 1e-9 except Exception:
disp_fft = (20.0 * np.log10(num / den)).astype(np.float32, copy=False) active_background = None
else: if active_background is not None:
disp_fft = fft_mag_to_db(disp_fft_lin) 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 levels = None
if bg_spec is not None: if active_background is not None:
try: try:
p5 = float(np.nanpercentile(disp_fft, 5)) p5 = float(np.nanpercentile(disp_fft, 5))
p95 = float(np.nanpercentile(disp_fft, 95)) p95 = float(np.nanpercentile(disp_fft, 95))

View File

@ -1,5 +1,11 @@
"""Pure sweep-processing helpers.""" """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 ( from rfg_adc_plotter.processing.calibration import (
build_calib_envelope, build_calib_envelope,
calibrate_freqs, calibrate_freqs,
@ -47,11 +53,15 @@ __all__ = [
"get_calibration_base", "get_calibration_base",
"get_calibration_coeffs", "get_calibration_coeffs",
"load_calib_envelope", "load_calib_envelope",
"load_fft_background",
"normalize_by_envelope", "normalize_by_envelope",
"normalize_by_calib", "normalize_by_calib",
"parse_spec_clip", "parse_spec_clip",
"recalculate_calibration_c", "recalculate_calibration_c",
"rolling_median_ref", "rolling_median_ref",
"save_calib_envelope", "save_calib_envelope",
"save_fft_background",
"set_calibration_base_value", "set_calibration_base_value",
"subtract_fft_background",
"validate_fft_background",
] ]

View File

@ -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")

View File

@ -1,6 +1,7 @@
"""Runtime state helpers.""" """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.ring_buffer import RingBuffer
from rfg_adc_plotter.state.runtime_state import RuntimeState from rfg_adc_plotter.state.runtime_state import RuntimeState
__all__ = ["RingBuffer", "RuntimeState"] __all__ = ["BackgroundMedianBuffer", "RingBuffer", "RuntimeState"]

View File

@ -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)

View File

@ -25,6 +25,7 @@ class RingBuffer:
self.ring_fft: Optional[np.ndarray] = None self.ring_fft: Optional[np.ndarray] = None
self.x_shared: Optional[np.ndarray] = None self.x_shared: Optional[np.ndarray] = None
self.distance_axis: 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_fft_db: Optional[np.ndarray] = None
self.last_freqs: Optional[np.ndarray] = None self.last_freqs: Optional[np.ndarray] = None
self.y_min_fft: Optional[float] = None self.y_min_fft: Optional[float] = None
@ -47,6 +48,7 @@ class RingBuffer:
self.ring_fft = None self.ring_fft = None
self.x_shared = None self.x_shared = None
self.distance_axis = None self.distance_axis = None
self.last_fft_mag = None
self.last_fft_db = None self.last_fft_db = None
self.last_freqs = None self.last_freqs = None
self.y_min_fft = None self.y_min_fft = None
@ -125,6 +127,7 @@ class RingBuffer:
last_idx = (self.head - 1) % self.max_sweeps last_idx = (self.head - 1) % self.max_sweeps
if self.ring_fft.shape[0] > 0: if self.ring_fft.shape[0] > 0:
last_fft = self.ring_fft[last_idx] 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) self.last_fft_db = fft_mag_to_db(last_fft)
finite = self.ring_fft[np.isfinite(self.ring_fft)] finite = self.ring_fft[np.isfinite(self.ring_fft)]
if finite.size > 0: 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) fft_mag = compute_fft_mag_row(sweep, freqs, self.fft_bins, mode=self.fft_mode)
self.ring_fft[self.head, :] = fft_mag 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) self.last_fft_db = fft_mag_to_db(fft_mag)
if self.last_fft_db.size > 0: 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) base = self.ring_fft if self.head == 0 else np.roll(self.ring_fft, -self.head, axis=0)
return base.T 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]: def get_display_times(self) -> Optional[np.ndarray]:
if self.ring_time is None: if self.ring_time is None:
return None return None

View File

@ -7,6 +7,8 @@ from typing import Dict, List, Optional
import numpy as np 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.state.ring_buffer import RingBuffer
from rfg_adc_plotter.types import SweepAuxCurves, SweepInfo from rfg_adc_plotter.types import SweepAuxCurves, SweepInfo
@ -23,12 +25,17 @@ class RuntimeState:
current_sweep_raw: Optional[np.ndarray] = None current_sweep_raw: Optional[np.ndarray] = None
current_aux_curves: SweepAuxCurves = None current_aux_curves: SweepAuxCurves = None
current_sweep_norm: Optional[np.ndarray] = None current_sweep_norm: Optional[np.ndarray] = None
current_fft_mag: Optional[np.ndarray] = None
current_fft_db: Optional[np.ndarray] = None current_fft_db: Optional[np.ndarray] = None
last_calib_sweep: Optional[np.ndarray] = None last_calib_sweep: Optional[np.ndarray] = None
calib_envelope: Optional[np.ndarray] = None calib_envelope: Optional[np.ndarray] = None
calib_file_path: Optional[str] = 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 current_info: Optional[SweepInfo] = None
bg_spec_cache: Optional[np.ndarray] = None
current_peak_width: Optional[float] = None current_peak_width: Optional[float] = None
current_peak_amplitude: Optional[float] = None current_peak_amplitude: Optional[float] = None
peak_candidates: List[Dict[str, float]] = field(default_factory=list) peak_candidates: List[Dict[str, float]] = field(default_factory=list)

View File

@ -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()

View File

@ -14,6 +14,11 @@ from rfg_adc_plotter.processing.calibration import (
recalculate_calibration_c, recalculate_calibration_c,
save_calib_envelope, 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 ( from rfg_adc_plotter.processing.fft import (
build_positive_only_centered_ifft_spectrum, build_positive_only_centered_ifft_spectrum,
build_symmetric_ifft_spectrum, build_symmetric_ifft_spectrum,
@ -101,6 +106,28 @@ class ProcessingTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
load_calib_envelope(path) 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): def test_apply_working_range_crops_sweep_to_selected_band(self):
freqs = np.linspace(3.3, 14.3, 12, dtype=np.float64) freqs = np.linspace(3.3, 14.3, 12, dtype=np.float64)
sweep = np.arange(12, dtype=np.float32) sweep = np.arange(12, dtype=np.float32)

View File

@ -15,6 +15,7 @@ class RingBufferTests(unittest.TestCase):
self.assertIsNotNone(ring.ring_fft) self.assertIsNotNone(ring.ring_fft)
self.assertIsNotNone(ring.ring_time) self.assertIsNotNone(ring.ring_time)
self.assertIsNotNone(ring.distance_axis) self.assertIsNotNone(ring.distance_axis)
self.assertIsNotNone(ring.get_last_fft_linear())
self.assertIsNotNone(ring.last_fft_db) self.assertIsNotNone(ring.last_fft_db)
self.assertEqual(ring.ring.shape[0], 4) self.assertEqual(ring.ring.shape[0], 4)
self.assertEqual(ring.ring_fft.shape, (4, ring.fft_bins)) self.assertEqual(ring.ring_fft.shape, (4, ring.fft_bins))
@ -52,6 +53,7 @@ class RingBufferTests(unittest.TestCase):
self.assertTrue(changed) self.assertTrue(changed)
self.assertFalse(ring.fft_symmetric) self.assertFalse(ring.fft_symmetric)
self.assertEqual(ring.get_display_raw().shape[1], 2) 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.assertEqual(ring.last_fft_db.shape, fft_before.shape)
self.assertFalse(np.allclose(ring.last_fft_db, fft_before)) self.assertFalse(np.allclose(ring.last_fft_db, fft_before))
self.assertFalse(np.allclose(ring.distance_axis, axis_before)) self.assertFalse(np.allclose(ring.distance_axis, axis_before))