diff --git a/rfg_adc_plotter/cli.py b/rfg_adc_plotter/cli.py index 075be83..ebf877c 100644 --- a/rfg_adc_plotter/cli.py +++ b/rfg_adc_plotter/cli.py @@ -40,10 +40,11 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--bscan-db-range", dest="bscan_db_range", - default="80,150", + default="auto", help=( "Фиксированный диапазон цветовой шкалы B-scan в дБ (min,max). " - "Напр. 80,150. Применяется к обычному виду (без вычитания фона)." + "Напр. 80,150. 'auto' — подобрать один раз по первому кадру. " + "Применяется к обычному виду (без вычитания фона)." ), ) parser.add_argument("--title", default="ADC Sweeps", help="Заголовок окна") diff --git a/rfg_adc_plotter/gui/pyqtgraph_backend.py b/rfg_adc_plotter/gui/pyqtgraph_backend.py index 4ded1d2..5d73cdb 100644 --- a/rfg_adc_plotter/gui/pyqtgraph_backend.py +++ b/rfg_adc_plotter/gui/pyqtgraph_backend.py @@ -827,17 +827,29 @@ def _db_to_linear_amplitude(values: np.ndarray) -> np.ndarray: return np.maximum(out, 0.0).astype(np.float32, copy=False) +def try_parse_bscan_db_range(spec: Optional[str]) -> Optional[Tuple[float, float]]: + """Parse an explicit B-scan dB window 'min,max'. Returns None if not explicit. + + 'auto' (or empty/invalid) returns None, meaning "seed once from the first frame". + """ + if not spec: + return None + if str(spec).strip().lower() == "auto": + return None + 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 None + + 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 + parsed = try_parse_bscan_db_range(spec) + return parsed if parsed is not None else BSCAN_DB_RANGE_DEFAULT def compute_background_subtracted_bscan_levels( @@ -922,7 +934,10 @@ 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)) + bscan_db_explicit_range = try_parse_bscan_db_range(getattr(args, "bscan_db_range", None)) + bscan_db_vmin, bscan_db_vmax = ( + bscan_db_explicit_range if bscan_db_explicit_range is not None else BSCAN_DB_RANGE_DEFAULT + ) runtime = RuntimeState( ring=RingBuffer(max_sweeps), range_min_ghz=float(SWEEP_FREQ_MIN_GHZ), @@ -1389,6 +1404,10 @@ def run_pyqtgraph(args) -> 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} + # Seed the manual window once from the first real frame (unless the user set an + # explicit --bscan-db-range) so the signal is visible out of the box; it stays + # fixed/manual afterwards. + bscan_levels_seeded: Dict[str, bool] = {"done": bscan_db_explicit_range is not None} # 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} @@ -2137,8 +2156,13 @@ def run_pyqtgraph(args) -> None: runtime.current_secondary_phase = None if runtime.current_sweep_raw.size == 0: - if push_to_ring: - reset_ring_buffers() + # A single empty/partial sweep (no points in the working range) must NOT + # wipe the whole waterfall — that made the B-scan flash to zero and back + # (the "мерцание"/jumping max). Skip this frame and keep the existing ring + # history intact. Deliberate resets come through reset_ring=True (handled + # at the top of this function), so nothing to clear here. + if push_to_ring and not reset_ring: + log_debug_event("empty_sweep_skipped", "ui empty sweep skipped (ring preserved)") runtime.current_freqs = None runtime.current_sweep_raw = None runtime.current_fft_source = None @@ -3853,7 +3877,27 @@ def run_pyqtgraph(args) -> None: if levels is not None: bscan_bg_levels_frozen["held"] = levels else: - # Normal view: constant manual dB window from the spin boxes. + # Normal view: seed the manual window once from the first real + # frame (unless the user gave an explicit --bscan-db-range), then + # hold it fixed so the image never flickers. + if not bscan_levels_seeded["done"]: + try: + lo = float(np.nanpercentile(disp_fft, 2.0)) + hi = float(np.nanpercentile(disp_fft, 98.0)) + if np.isfinite(lo) and np.isfinite(hi) and lo < hi: + bscan_manual_levels["vmin"] = lo + bscan_manual_levels["vmax"] = hi + for _spin, _val in ( + (bscan_db_min_spin, lo), + (bscan_db_max_spin, hi), + ): + _spin.blockSignals(True) + _spin.setValue(_val) + _spin.blockSignals(False) + bscan_levels_seeded["done"] = True + except Exception: + pass + # 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]: