diff --git a/rfg_adc_plotter/cli.py b/rfg_adc_plotter/cli.py index e4d179c..075be83 100644 --- a/rfg_adc_plotter/cli.py +++ b/rfg_adc_plotter/cli.py @@ -37,6 +37,15 @@ def build_parser() -> argparse.ArgumentParser: "в водопаде спектров (0 — отключить)" ), ) + parser.add_argument( + "--bscan-db-range", + dest="bscan_db_range", + default="80,150", + help=( + "Фиксированный диапазон цветовой шкалы B-scan в дБ (min,max). " + "Напр. 80,150. Применяется к обычному виду (без вычитания фона)." + ), + ) parser.add_argument("--title", default="ADC Sweeps", help="Заголовок окна") parser.add_argument( "--fancy", diff --git a/rfg_adc_plotter/gui/pyqtgraph_backend.py b/rfg_adc_plotter/gui/pyqtgraph_backend.py index 8516dc8..e56b621 100644 --- a/rfg_adc_plotter/gui/pyqtgraph_backend.py +++ b/rfg_adc_plotter/gui/pyqtgraph_backend.py @@ -2,7 +2,6 @@ from __future__ import annotations -import math import signal import os import sys @@ -55,7 +54,7 @@ from rfg_adc_plotter.types import SweepAuxCurves, SweepInfo, SweepPacket RAW_PLOT_MAX_POINTS = 2048 RAW_WATERFALL_MAX_POINTS = 2048 BSCAN_MAX_POINTS = 256 -BSCAN_LEVEL_TAU_S = 2.0 +BSCAN_DB_RANGE_DEFAULT: Tuple[float, float] = (80.0, 150.0) UI_QUEUE_MAXSIZE = 128 UI_MAX_PACKETS_PER_TICK = 8 DEBUG_FRAME_LOG_EVERY = 10 @@ -828,6 +827,19 @@ def _db_to_linear_amplitude(values: np.ndarray) -> np.ndarray: return np.maximum(out, 0.0).astype(np.float32, copy=False) +def parse_bscan_db_range(spec: Optional[str]) -> Tuple[float, float]: + """Parse a fixed B-scan dB color window 'min,max'. Falls back to the default.""" + if spec: + try: + p0, p1 = str(spec).strip().replace(";", ",").split(",") + lo, hi = float(p0), float(p1) + if np.isfinite(lo) and np.isfinite(hi) and lo < hi: + return (lo, hi) + except Exception: + pass + return BSCAN_DB_RANGE_DEFAULT + + def compute_background_subtracted_bscan_levels( disp_fft_lin: np.ndarray, disp_fft: np.ndarray, @@ -910,6 +922,7 @@ def run_pyqtgraph(args) -> None: fft_bins = FFT_LEN // 2 + 1 spec_clip = parse_spec_clip(getattr(args, "spec_clip", None)) spec_mean_sec = float(getattr(args, "spec_mean_sec", 0.0)) + bscan_db_vmin, bscan_db_vmax = parse_bscan_db_range(getattr(args, "bscan_db_range", None)) runtime = RuntimeState( ring=RingBuffer(max_sweeps), range_min_ghz=float(SWEEP_FREQ_MIN_GHZ), @@ -1163,6 +1176,24 @@ def run_pyqtgraph(args) -> None: range_max_spin.setValue(runtime.range_max_ghz) range_group_layout.addRow("f min", range_min_spin) range_group_layout.addRow("f max", range_max_spin) + bscan_db_group = QtWidgets.QGroupBox("B-scan дБ шкала") + bscan_db_group_layout = QtWidgets.QFormLayout(bscan_db_group) + bscan_db_group_layout.setContentsMargins(6, 6, 6, 6) + bscan_db_group_layout.setSpacing(6) + bscan_db_min_spin = QtWidgets.QDoubleSpinBox() + bscan_db_max_spin = QtWidgets.QDoubleSpinBox() + for spin in (bscan_db_min_spin, bscan_db_max_spin): + spin.setDecimals(1) + spin.setRange(-100.0, 400.0) + spin.setSingleStep(1.0) + try: + spin.setSuffix(" dB") + except Exception: + pass + bscan_db_min_spin.setValue(bscan_db_vmin) + bscan_db_max_spin.setValue(bscan_db_vmax) + bscan_db_group_layout.addRow("дБ мин", bscan_db_min_spin) + bscan_db_group_layout.addRow("дБ макс", bscan_db_max_spin) fft_mode_label = QtWidgets.QLabel("IFFT режим") fft_mode_combo = QtWidgets.QComboBox() fft_mode_combo.addItem("Обычный", "direct") @@ -1312,6 +1343,7 @@ def run_pyqtgraph(args) -> None: except Exception: pass settings_layout.addWidget(range_group) + settings_layout.addWidget(bscan_db_group) settings_layout.addWidget(calib_group) settings_layout.addWidget(complex_calib_group) settings_layout.addWidget(tty_range_group) @@ -1354,7 +1386,12 @@ def run_pyqtgraph(args) -> None: axis_range_cache: Dict[str, Tuple[float, ...]] = {} image_rect_cache: Dict[str, Tuple[float, ...]] = {} bscan_axis_cache: Dict[Tuple, np.ndarray] = {} - bscan_levels_state: Dict[str, Optional[Tuple[float, float]]] = {"held": None, "held_bg": None} + # Fixed manual dB color window for the normal (no-background) B-scan view. + # Never recomputed per frame; only changed by the user via the spin boxes. + bscan_manual_levels: Dict[str, float] = {"vmin": bscan_db_vmin, "vmax": bscan_db_vmax} + # Frozen levels for the background-subtracted residual view (different scale): + # computed once when background is active, held until a reset event clears it. + bscan_bg_levels_frozen: Dict[str, Optional[Tuple[float, float]]] = {"held": None} curve_has_data_cache: Dict[str, bool] = {} item_visibility_cache: Dict[str, bool] = {} text_value_cache: Dict[str, str] = {} @@ -1933,10 +1970,9 @@ def run_pyqtgraph(args) -> None: runtime.background_buffer.reset() if clear_profile: runtime.background_profile = None - # Re-seed the held B-scan color levels so contrast doesn't drag an old - # scale across the background on/off transition. - bscan_levels_state["held"] = None - bscan_levels_state["held_bg"] = None + # Re-freeze the background-subtracted B-scan levels so contrast doesn't + # drag an old residual scale across the background on/off transition. + bscan_bg_levels_frozen["held"] = None runtime.current_fft_complex = None runtime.current_fft_mag = None runtime.current_fft_db = None @@ -1956,8 +1992,7 @@ def run_pyqtgraph(args) -> None: axis_range_cache.clear() image_rect_cache.clear() bscan_axis_cache.clear() - bscan_levels_state["held"] = None - bscan_levels_state["held_bg"] = None + bscan_bg_levels_frozen["held"] = None runtime.current_distances = None runtime.current_fft_complex = None runtime.current_fft_mag = None @@ -2291,6 +2326,20 @@ def run_pyqtgraph(args) -> None: finally: range_change_in_progress = False + def set_bscan_db_range() -> None: + try: + new_min = float(bscan_db_min_spin.value()) + new_max = float(bscan_db_max_spin.value()) + except Exception: + return + if (not np.isfinite(new_min)) or (not np.isfinite(new_max)) or new_min >= new_max: + set_status_note("B-scan дБ: мин должен быть меньше макс") + return + bscan_manual_levels["vmin"] = new_min + bscan_manual_levels["vmax"] = new_max + set_status_note(f"B-scan дБ: {new_min:.1f}..{new_max:.1f}") + runtime.mark_dirty() + def set_working_range() -> None: nonlocal range_change_in_progress if range_change_in_progress: @@ -2559,6 +2608,8 @@ def run_pyqtgraph(args) -> None: background_enabled = False if background_enabled and runtime.background_profile is None: set_status_note("фон: профиль не загружен") + # Re-freeze residual levels so toggling background recomputes them once. + bscan_bg_levels_frozen["held"] = None runtime.mark_dirty() def set_fft_low_cut_percent() -> None: @@ -2613,6 +2664,8 @@ def run_pyqtgraph(args) -> None: try: range_min_spin.valueChanged.connect(lambda _v: set_working_range()) range_max_spin.valueChanged.connect(lambda _v: set_working_range()) + bscan_db_min_spin.valueChanged.connect(lambda _v: set_bscan_db_range()) + bscan_db_max_spin.valueChanged.connect(lambda _v: set_bscan_db_range()) tty_range_spin.valueChanged.connect(lambda _v: set_tty_range()) calib_cb.stateChanged.connect(lambda _v: set_calib_enabled()) complex_calib_cb.stateChanged.connect(lambda _v: set_complex_calib_enabled()) @@ -3783,38 +3836,19 @@ def run_pyqtgraph(args) -> None: log_debug_event("invalid_fft_image", "ui invalid FFT waterfall suppressed") return - levels = None + # Fixed color scale — never recomputed per frame, so the image no + # longer "changes"/flickers as new sweeps roll in. if active_background is not None: - levels = compute_background_subtracted_bscan_levels(disp_fft_lin, disp_fft) - if levels is None: - try: - finite_rows = np.any(np.isfinite(disp_fft), axis=1) - if np.any(finite_rows): - mean_spec = np.nanmean(disp_fft[finite_rows], axis=1) - if mean_spec.size > 0: - vmin_v = float(np.nanmin(mean_spec)) - vmax_v = float(np.nanmax(mean_spec)) - if np.isfinite(vmin_v) and np.isfinite(vmax_v) and vmin_v != vmax_v: - levels = (vmin_v, vmax_v) - except Exception: - levels = None - if levels is None and spec_clip is not None: - try: - vmin_v = float(np.nanpercentile(disp_fft, spec_clip[0])) - vmax_v = float(np.nanpercentile(disp_fft, spec_clip[1])) - if np.isfinite(vmin_v) and np.isfinite(vmax_v) and vmin_v != vmax_v: - levels = (vmin_v, vmax_v) - except Exception: - levels = None - if ( - levels is None - and runtime.ring.y_min_fft is not None - and runtime.ring.y_max_fft is not None - and np.isfinite(runtime.ring.y_min_fft) - and np.isfinite(runtime.ring.y_max_fft) - and runtime.ring.y_min_fft != runtime.ring.y_max_fft - ): - levels = (runtime.ring.y_min_fft, runtime.ring.y_max_fft) + # Residuals are on a different scale than absolute dB; use the + # dedicated bg-subtracted levels, computed ONCE and frozen. + levels = bscan_bg_levels_frozen["held"] + if levels is None: + levels = compute_background_subtracted_bscan_levels(disp_fft_lin, disp_fft) + if levels is not None: + bscan_bg_levels_frozen["held"] = levels + else: + # Normal view: constant manual dB window from the spin boxes. + levels = (bscan_manual_levels["vmin"], bscan_manual_levels["vmax"]) disp_fft_display_axis = display_distance_axis_for_mode(disp_fft_axis, fft_mode) if display_distance_transform_enabled(fft_mode) and disp_fft_display_axis.size == disp_fft.shape[0]: disp_fft = disp_fft[::-1, :] @@ -3839,28 +3873,6 @@ def run_pyqtgraph(args) -> None: y_bounds=(d_min, d_max), padding=0, ) - # Smooth the color levels with a slow EMA so brightness stops - # flickering while still tracking genuine signal-strength changes. - # Background-subtracted levels use a separate slot (different scale). - level_slot = "held_bg" if active_background is not None else "held" - if levels is not None: - prev_levels = bscan_levels_state[level_slot] - if prev_levels is None: - smoothed_levels = (float(levels[0]), float(levels[1])) - else: - alpha = 1.0 - math.exp( - -(interval_ms / 1000.0) / max(1e-6, BSCAN_LEVEL_TAU_S) - ) - smoothed_levels = ( - prev_levels[0] + alpha * (float(levels[0]) - prev_levels[0]), - prev_levels[1] + alpha * (float(levels[1]) - prev_levels[1]), - ) - bscan_levels_state[level_slot] = smoothed_levels - levels = smoothed_levels - else: - # No fresh measurement this frame: reuse the held levels so the - # image never flashes to autoLevels. - levels = bscan_levels_state[level_slot] if levels is not None: img_fft.setImage(disp_fft, autoLevels=False, levels=levels) else: