|
|
|
|
@ -31,7 +31,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 +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_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
|
|
|
|
|
@ -821,6 +827,31 @@ 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."""
|
|
|
|
|
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(
|
|
|
|
|
disp_fft_lin: np.ndarray,
|
|
|
|
|
disp_fft: np.ndarray,
|
|
|
|
|
@ -903,6 +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_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),
|
|
|
|
|
@ -1156,6 +1191,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")
|
|
|
|
|
@ -1305,6 +1358,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)
|
|
|
|
|
@ -1346,6 +1400,17 @@ 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] = {}
|
|
|
|
|
# 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}
|
|
|
|
|
curve_has_data_cache: Dict[str, bool] = {}
|
|
|
|
|
item_visibility_cache: Dict[str, bool] = {}
|
|
|
|
|
text_value_cache: Dict[str, str] = {}
|
|
|
|
|
@ -1441,6 +1506,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:
|
|
|
|
|
@ -1608,9 +1710,15 @@ def run_pyqtgraph(args) -> None:
|
|
|
|
|
)
|
|
|
|
|
set_x_range_if_changed("line_x", p_line, f_min, f_max, padding=0)
|
|
|
|
|
|
|
|
|
|
distance_bounds = resolve_axis_bounds(runtime.ring.distance_axis)
|
|
|
|
|
if distance_bounds is not None:
|
|
|
|
|
display_axis_full = display_distance_axis_for_mode(runtime.ring.distance_axis, fft_mode)
|
|
|
|
|
# Use the SAME stable full-scale axis as the B-scan update block so both
|
|
|
|
|
# write identical bounds to the shared fft_waterfall cache. Using the
|
|
|
|
|
# per-frame runtime.ring.distance_axis here made the two thrash the cache
|
|
|
|
|
# and the B-scan y-axis jumped every frame.
|
|
|
|
|
bscan_axis = stable_full_scale_distance_axis()
|
|
|
|
|
if bscan_axis is None:
|
|
|
|
|
bscan_axis = runtime.ring.distance_axis
|
|
|
|
|
if bscan_axis is not None:
|
|
|
|
|
display_axis_full = display_distance_axis_for_mode(bscan_axis, fft_mode)
|
|
|
|
|
display_bounds = resolve_axis_bounds(display_axis_full)
|
|
|
|
|
if display_bounds is not None:
|
|
|
|
|
d_min_display, d_max_display = display_bounds
|
|
|
|
|
@ -1887,6 +1995,9 @@ def run_pyqtgraph(args) -> None:
|
|
|
|
|
runtime.background_buffer.reset()
|
|
|
|
|
if clear_profile:
|
|
|
|
|
runtime.background_profile = 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
|
|
|
|
|
@ -1905,6 +2016,8 @@ def run_pyqtgraph(args) -> None:
|
|
|
|
|
runtime.ring.reset()
|
|
|
|
|
axis_range_cache.clear()
|
|
|
|
|
image_rect_cache.clear()
|
|
|
|
|
bscan_axis_cache.clear()
|
|
|
|
|
bscan_bg_levels_frozen["held"] = None
|
|
|
|
|
runtime.current_distances = None
|
|
|
|
|
runtime.current_fft_complex = None
|
|
|
|
|
runtime.current_fft_mag = None
|
|
|
|
|
@ -2043,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
|
|
|
|
|
@ -2238,6 +2356,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:
|
|
|
|
|
@ -2506,6 +2638,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:
|
|
|
|
|
@ -2526,6 +2660,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
|
|
|
|
|
@ -2559,6 +2694,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())
|
|
|
|
|
@ -3729,43 +3866,53 @@ 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:
|
|
|
|
|
# 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: 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:
|
|
|
|
|
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)
|
|
|
|
|
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:
|
|
|
|
|
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)
|
|
|
|
|
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]:
|
|
|
|
|
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))
|
|
|
|
|
|