diff --git a/rfg_adc_plotter/gui/pyqtgraph_backend.py b/rfg_adc_plotter/gui/pyqtgraph_backend.py index de6350a..8516dc8 100644 --- a/rfg_adc_plotter/gui/pyqtgraph_backend.py +++ b/rfg_adc_plotter/gui/pyqtgraph_backend.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math import signal import os import sys @@ -31,7 +32,12 @@ from rfg_adc_plotter.processing.calibration import ( save_complex_calibration, set_calibration_base_value, ) -from rfg_adc_plotter.processing.fft import compute_fft_complex_row, compute_fft_mag_row, fft_mag_to_db +from rfg_adc_plotter.processing.fft import ( + compute_distance_axis, + compute_fft_complex_row, + 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_complex_calibration, @@ -49,6 +55,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 UI_QUEUE_MAXSIZE = 128 UI_MAX_PACKETS_PER_TICK = 8 DEBUG_FRAME_LOG_EVERY = 10 @@ -1346,6 +1353,8 @@ def run_pyqtgraph(args) -> None: last_packet_processed_at: Optional[float] = 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} curve_has_data_cache: Dict[str, bool] = {} item_visibility_cache: Dict[str, bool] = {} text_value_cache: Dict[str, str] = {} @@ -1441,6 +1450,43 @@ def run_pyqtgraph(args) -> None: image_rect_cache[key] = values return True + def stable_full_scale_distance_axis() -> Optional[np.ndarray]: + """Full-scale B-scan distance axis, computed once per (range, mode). + + The per-sweep ``runtime.ring.distance_axis`` jitters when the reported + ``f_max`` varies between sweeps, which rescales the B-scan y-axis every + frame. Deriving the axis from the configured range keeps it stable and + only recomputes when the operator changes ``f min``/``f max`` or the mode. + """ + key = ( + round(float(runtime.range_min_ghz), 9), + round(float(runtime.range_max_ghz), 9), + str(fft_mode), + ) + cached = bscan_axis_cache.get(key) + if cached is not None: + return cached + if display_distance_transform_enabled(fft_mode): + # positive_only_exact derives its step from real sweep geometry, so a + # synthetic 2-point freq array would be wrong. Hold the first valid + # real axis instead (still keyed by mode, so it re-seeds on switch). + real_axis = runtime.ring.distance_axis + if real_axis is None: + return None + axis = np.asarray(real_axis, dtype=np.float64).reshape(-1) + if axis.size <= 0 or not np.all(np.isfinite(axis)): + return None + else: + synth_freqs = np.asarray( + [runtime.range_min_ghz, runtime.range_max_ghz], dtype=np.float64 + ) + axis = compute_distance_axis(synth_freqs, fft_bins, mode=fft_mode) + if axis is None or axis.size <= 0 or not np.all(np.isfinite(axis)): + return None + bscan_axis_cache.clear() + bscan_axis_cache[key] = axis + return axis + def set_item_visible_if_changed(key: str, item, visible: bool) -> bool: visible_value = bool(visible) if item_visibility_cache.get(key) == visible_value: @@ -1887,6 +1933,10 @@ 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 runtime.current_fft_complex = None runtime.current_fft_mag = None runtime.current_fft_db = None @@ -1905,6 +1955,9 @@ def run_pyqtgraph(args) -> None: runtime.ring.reset() axis_range_cache.clear() image_rect_cache.clear() + bscan_axis_cache.clear() + bscan_levels_state["held"] = None + bscan_levels_state["held_bg"] = None runtime.current_distances = None runtime.current_fft_complex = None runtime.current_fft_mag = None @@ -2526,6 +2579,7 @@ def run_pyqtgraph(args) -> None: except Exception: fft_mode = "symmetric" runtime.ring.set_fft_mode(fft_mode) + bscan_axis_cache.clear() reset_background_state(clear_profile=True) runtime.current_distances = runtime.ring.distance_axis runtime.current_fft_mag = None @@ -3765,7 +3819,16 @@ def run_pyqtgraph(args) -> None: if display_distance_transform_enabled(fft_mode) and disp_fft_display_axis.size == disp_fft.shape[0]: disp_fft = disp_fft[::-1, :] disp_fft_display_axis = disp_fft_display_axis[::-1] - distance_bounds = resolve_axis_bounds(disp_fft_display_axis) + # Use a stable full-scale distance axis for the y-range/rect so the + # B-scan always spans ~0..12 m and never rescales per sweep. Fall + # back to the per-frame axis only until a stable axis is available. + stable_axis = stable_full_scale_distance_axis() + if stable_axis is not None: + distance_bounds = resolve_axis_bounds( + display_distance_axis_for_mode(stable_axis, fft_mode) + ) + else: + distance_bounds = resolve_axis_bounds(disp_fft_display_axis) if distance_bounds is not None: d_min, d_max = distance_bounds set_image_rect_if_changed("fft_waterfall_rect", img_fft, 0.0, d_min, float(max_sweeps), max(1e-9, d_max - d_min)) @@ -3776,6 +3839,28 @@ 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: