This commit is contained in:
awe
2026-03-12 15:12:20 +03:00
parent 3cc423031c
commit c2a892f397
27 changed files with 3200 additions and 0 deletions

View File

@ -0,0 +1,5 @@
"""GUI backends."""
from rfg_adc_plotter.gui.pyqtgraph_backend import run_pyqtgraph
__all__ = ["run_pyqtgraph"]

View File

@ -0,0 +1,913 @@
"""PyQtGraph realtime backend for the ADC sweep plotter."""
from __future__ import annotations
import signal
import threading
import time
from queue import Empty, Queue
from typing import Dict, List, Optional, Tuple
import numpy as np
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.processing.calibration import (
calibrate_freqs,
get_calibration_base,
get_calibration_coeffs,
set_calibration_base_value,
)
from rfg_adc_plotter.processing.fft import compute_fft_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_calib
from rfg_adc_plotter.processing.peaks import (
find_peak_width_markers,
find_top_peaks_over_ref,
rolling_median_ref,
)
from rfg_adc_plotter.state import RingBuffer, RuntimeState
from rfg_adc_plotter.types import SweepAuxCurves, SweepInfo, SweepPacket
def _visible_levels_pyqtgraph(data: np.ndarray, plot_item) -> Optional[Tuple[float, float]]:
"""Compute vmin/vmax from the currently visible part of an ImageItem."""
if data.size == 0:
return None
ny, nx = data.shape[0], data.shape[1]
try:
(x0, x1), (y0, y1) = plot_item.viewRange()
except Exception:
x0, x1 = 0.0, float(nx - 1)
y0, y1 = 0.0, float(ny - 1)
xmin, xmax = sorted((float(x0), float(x1)))
ymin, ymax = sorted((float(y0), float(y1)))
ix0 = max(0, min(nx - 1, int(np.floor(xmin))))
ix1 = max(0, min(nx - 1, int(np.ceil(xmax))))
iy0 = max(0, min(ny - 1, int(np.floor(ymin))))
iy1 = max(0, min(ny - 1, int(np.ceil(ymax))))
if ix1 < ix0:
ix1 = ix0
if iy1 < iy0:
iy1 = iy0
sub = data[iy0 : iy1 + 1, ix0 : ix1 + 1]
finite = np.isfinite(sub)
if not finite.any():
return None
vals = sub[finite]
vmin = float(np.nanpercentile(vals, 5))
vmax = float(np.nanpercentile(vals, 95))
if not (np.isfinite(vmin) and np.isfinite(vmax)) or vmin == vmax:
return None
return (vmin, vmax)
def run_pyqtgraph(args) -> None:
"""Start the PyQtGraph GUI."""
peak_calibrate_mode = bool(getattr(args, "calibrate", False))
peak_search_enabled = bool(getattr(args, "peak_search", False))
try:
import pyqtgraph as pg
from pyqtgraph.Qt import QtCore, QtWidgets # type: ignore
except Exception as exc:
raise RuntimeError(
"pyqtgraph и совместимый Qt backend не найдены. Установите: pip install pyqtgraph PyQt5"
) from exc
queue: Queue[SweepPacket] = Queue(maxsize=1000)
stop_event = threading.Event()
reader = SweepReader(
args.port,
args.baud,
queue,
stop_event,
fancy=bool(args.fancy),
bin_mode=bool(args.bin_mode),
logscale=bool(args.logscale),
parser_16_bit_x2=bool(args.parser_16_bit_x2),
parser_test=bool(args.parser_test),
)
reader.start()
max_sweeps = int(max(10, args.max_sweeps))
max_fps = max(1.0, float(args.max_fps))
interval_ms = int(1000.0 / max_fps)
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))
norm_type = str(getattr(args, "norm_type", "projector")).strip().lower()
runtime = RuntimeState(ring=RingBuffer(max_sweeps))
pg.setConfigOptions(
useOpenGL=not peak_calibrate_mode,
antialias=False,
imageAxisOrder="row-major",
)
app = QtWidgets.QApplication.instance()
if app is None:
app = QtWidgets.QApplication([])
try:
app.setApplicationName(str(args.title))
app.setQuitOnLastWindowClosed(True)
except Exception:
pass
main_window = QtWidgets.QWidget()
try:
main_window.setWindowTitle(str(args.title))
except Exception:
pass
main_layout = QtWidgets.QHBoxLayout(main_window)
main_layout.setContentsMargins(6, 6, 6, 6)
main_layout.setSpacing(6)
win = pg.GraphicsLayoutWidget(show=False, title=args.title)
main_layout.addWidget(win)
settings_widget = QtWidgets.QWidget()
settings_layout = QtWidgets.QVBoxLayout(settings_widget)
settings_layout.setContentsMargins(6, 6, 6, 6)
settings_layout.setSpacing(8)
try:
settings_widget.setMinimumWidth(130)
settings_widget.setMaximumWidth(150)
except Exception:
pass
main_layout.addWidget(settings_widget)
p_line = win.addPlot(row=0, col=0, title="Сырые данные")
p_line.showGrid(x=True, y=True, alpha=0.3)
curve_avg1 = p_line.plot(pen=pg.mkPen((170, 170, 170), width=1))
curve_avg2 = p_line.plot(pen=pg.mkPen((110, 110, 110), width=1))
curve = p_line.plot(pen=pg.mkPen((80, 120, 255), width=1))
curve_calib = p_line.plot(pen=pg.mkPen((220, 60, 60), width=1))
curve_norm = p_line.plot(pen=pg.mkPen((60, 180, 90), width=1))
p_line.setLabel("bottom", "ГГц")
p_line.setLabel("left", "Y")
ch_text = pg.TextItem("", anchor=(1, 1))
ch_text.setZValue(10)
p_line.addItem(ch_text)
p_img = win.addPlot(row=0, col=1, title="Сырые данные водопад")
p_img.invertY(False)
p_img.showGrid(x=False, y=False)
p_img.setLabel("bottom", "Время, с (новое справа)")
try:
p_img.getAxis("bottom").setStyle(showValues=False)
except Exception:
pass
p_img.setLabel("left", "ГГц")
img = pg.ImageItem()
p_img.addItem(img)
p_fft = win.addPlot(row=1, col=0, title="FFT")
p_fft.showGrid(x=True, y=True, alpha=0.3)
curve_fft = p_fft.plot(pen=pg.mkPen((255, 120, 80), width=1))
curve_fft_ref = p_fft.plot(pen=pg.mkPen((255, 0, 0), width=1))
peak_pen = pg.mkPen((255, 0, 0), width=1)
peak_box_pen = pg.mkPen((0, 170, 0), width=3)
fft_peak_boxes = [p_fft.plot(pen=peak_box_pen) for _ in range(3)]
fft_bg_line = pg.InfiniteLine(angle=0, movable=False, pen=peak_pen)
fft_left_line = pg.InfiniteLine(angle=90, movable=False, pen=peak_pen)
fft_right_line = pg.InfiniteLine(angle=90, movable=False, pen=peak_pen)
curve_fft_ref.setVisible(False)
for box in fft_peak_boxes:
box.setVisible(False)
p_fft.addItem(fft_bg_line)
p_fft.addItem(fft_left_line)
p_fft.addItem(fft_right_line)
fft_bg_line.setVisible(False)
fft_left_line.setVisible(False)
fft_right_line.setVisible(False)
p_fft.setLabel("bottom", "Расстояние, м")
p_fft.setLabel("left", "дБ")
p_spec = win.addPlot(row=1, col=1, title="B-scan (дБ)")
p_spec.invertY(False)
p_spec.showGrid(x=False, y=False)
p_spec.setLabel("bottom", "Время, с (новое справа)")
try:
p_spec.getAxis("bottom").setStyle(showValues=False)
except Exception:
pass
p_spec.setLabel("left", "Расстояние, м")
img_fft = pg.ImageItem()
p_spec.addItem(img_fft)
spec_left_line = pg.InfiniteLine(angle=0, movable=False, pen=peak_pen)
spec_right_line = pg.InfiniteLine(angle=0, movable=False, pen=peak_pen)
p_spec.addItem(spec_left_line)
p_spec.addItem(spec_right_line)
spec_left_line.setVisible(False)
spec_right_line.setVisible(False)
calib_cb = QtWidgets.QCheckBox("нормировка")
bg_compute_cb = QtWidgets.QCheckBox("расчет фона")
bg_subtract_cb = QtWidgets.QCheckBox("вычет фона")
fft_bg_subtract_cb = QtWidgets.QCheckBox("FFT вычет фона")
peak_search_cb = QtWidgets.QCheckBox("поиск пиков")
try:
settings_layout.addWidget(QtWidgets.QLabel("Настройки"))
except Exception:
pass
settings_layout.addWidget(calib_cb)
settings_layout.addWidget(bg_compute_cb)
settings_layout.addWidget(bg_subtract_cb)
settings_layout.addWidget(fft_bg_subtract_cb)
settings_layout.addWidget(peak_search_cb)
status = pg.LabelItem(justify="left")
win.addItem(status, row=3, col=0, colspan=2)
calib_enabled = False
bg_compute_enabled = True
bg_subtract_enabled = False
fft_bg_subtract_enabled = False
fixed_ylim: Optional[Tuple[float, float]] = None
if args.ylim:
try:
y0, y1 = args.ylim.split(",")
fixed_ylim = (float(y0), float(y1))
except Exception:
fixed_ylim = None
if fixed_ylim is not None:
p_line.setYRange(fixed_ylim[0], fixed_ylim[1], padding=0)
def ensure_buffer(sweep_width: int) -> None:
changed = runtime.ring.ensure_init(sweep_width)
if not changed:
return
img.setImage(runtime.ring.get_display_raw(), autoLevels=False)
img.setRect(0, SWEEP_FREQ_MIN_GHZ, max_sweeps, SWEEP_FREQ_MAX_GHZ - SWEEP_FREQ_MIN_GHZ)
p_img.setRange(
xRange=(0, max_sweeps - 1),
yRange=(SWEEP_FREQ_MIN_GHZ, SWEEP_FREQ_MAX_GHZ),
padding=0,
)
p_line.setXRange(SWEEP_FREQ_MIN_GHZ, SWEEP_FREQ_MAX_GHZ, padding=0)
img_fft.setImage(runtime.ring.get_display_fft_linear(), autoLevels=False)
img_fft.setRect(0, 0.0, max_sweeps, 1.0)
p_spec.setRange(xRange=(0, max_sweeps - 1), yRange=(0.0, 1.0), padding=0)
p_fft.setXRange(0.0, 1.0, padding=0)
def update_physical_axes() -> None:
if runtime.current_freqs is not None and runtime.current_freqs.size > 0:
finite_f = runtime.current_freqs[np.isfinite(runtime.current_freqs)]
if finite_f.size > 0:
f_min = float(np.min(finite_f))
f_max = float(np.max(finite_f))
if f_max <= f_min:
f_max = f_min + 1.0
img.setRect(0, f_min, max_sweeps, f_max - f_min)
p_img.setRange(xRange=(0, max_sweeps - 1), yRange=(f_min, f_max), padding=0)
distance_axis = runtime.ring.distance_axis
if distance_axis is not None and distance_axis.size > 0:
d_min = float(distance_axis[0])
d_max = float(distance_axis[-1]) if distance_axis.size > 1 else float(distance_axis[0] + 1.0)
if d_max <= d_min:
d_max = d_min + 1.0
img_fft.setRect(0, d_min, max_sweeps, d_max - d_min)
p_spec.setRange(xRange=(0, max_sweeps - 1), yRange=(d_min, d_max), padding=0)
def normalize_sweep(raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
return normalize_by_calib(raw, calib, norm_type=norm_type)
def set_calib_enabled() -> None:
nonlocal calib_enabled
try:
calib_enabled = bool(calib_cb.isChecked())
except Exception:
calib_enabled = False
if calib_enabled and runtime.current_sweep_raw is not None and runtime.last_calib_sweep is not None:
runtime.current_sweep_norm = normalize_sweep(runtime.current_sweep_raw, runtime.last_calib_sweep)
else:
runtime.current_sweep_norm = None
runtime.mark_dirty()
def set_bg_compute_enabled() -> None:
nonlocal bg_compute_enabled
try:
bg_compute_enabled = bool(bg_compute_cb.isChecked())
except Exception:
bg_compute_enabled = False
runtime.mark_dirty()
def set_bg_subtract_enabled() -> None:
nonlocal bg_subtract_enabled
try:
bg_subtract_enabled = bool(bg_subtract_cb.isChecked())
except Exception:
bg_subtract_enabled = False
runtime.mark_dirty()
def set_fft_bg_subtract_enabled() -> None:
nonlocal fft_bg_subtract_enabled
try:
fft_bg_subtract_enabled = bool(fft_bg_subtract_cb.isChecked())
except Exception:
fft_bg_subtract_enabled = False
runtime.mark_dirty()
try:
bg_compute_cb.setChecked(True)
except Exception:
pass
set_bg_compute_enabled()
try:
calib_cb.stateChanged.connect(lambda _v: set_calib_enabled())
bg_compute_cb.stateChanged.connect(lambda _v: set_bg_compute_enabled())
bg_subtract_cb.stateChanged.connect(lambda _v: set_bg_subtract_enabled())
fft_bg_subtract_cb.stateChanged.connect(lambda _v: set_fft_bg_subtract_enabled())
except Exception:
pass
peak_group = None
peak_window_edit = None
peak_params_label = None
peak_ref_window = float(getattr(args, "peak_ref_window", 1.0))
if (not np.isfinite(peak_ref_window)) or peak_ref_window <= 0.0:
peak_ref_window = 1.0
def refresh_peak_params_label(peaks: List[Dict[str, float]]) -> None:
if peak_params_label is None:
return
lines = []
for idx in range(3):
if idx < len(peaks):
peak = peaks[idx]
lines.append(f"peak {idx + 1}:")
lines.append(f" X: {peak['x']:.4g} m")
lines.append(f" H: {peak['height']:.4g} dB")
lines.append(f" W: {peak['width']:.4g} m")
else:
lines.append(f"peak {idx + 1}:")
lines.append(" X: - m")
lines.append(" H: - dB")
lines.append(" W: - m")
if idx != 2:
lines.append("")
peak_params_label.setText("\n".join(lines))
try:
peak_group = QtWidgets.QGroupBox("Поиск пиков")
peak_layout = QtWidgets.QFormLayout(peak_group)
peak_layout.setContentsMargins(6, 6, 6, 6)
peak_layout.setSpacing(6)
peak_window_edit = QtWidgets.QLineEdit(f"{peak_ref_window:.6g}")
peak_layout.addRow("Окно медианы, ГГц", peak_window_edit)
peak_params_label = QtWidgets.QLabel("")
try:
peak_params_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
except Exception:
pass
peak_layout.addRow("Параметры", peak_params_label)
settings_layout.addWidget(peak_group)
def apply_peak_window() -> None:
nonlocal peak_ref_window
if peak_window_edit is None:
return
try:
value = float(peak_window_edit.text().strip())
if np.isfinite(value) and value > 0.0:
peak_ref_window = value
runtime.mark_dirty()
except Exception:
pass
try:
peak_window_edit.setText(f"{peak_ref_window:.6g}")
except Exception:
pass
peak_window_edit.editingFinished.connect(apply_peak_window)
refresh_peak_params_label([])
except Exception:
peak_group = None
peak_window_edit = None
peak_params_label = None
def set_peak_search_enabled() -> None:
nonlocal peak_search_enabled
try:
peak_search_enabled = bool(peak_search_cb.isChecked())
except Exception:
peak_search_enabled = False
try:
if peak_group is not None:
peak_group.setEnabled(peak_search_enabled)
except Exception:
pass
if not peak_search_enabled:
runtime.peak_candidates = []
refresh_peak_params_label([])
runtime.mark_dirty()
try:
peak_search_cb.setChecked(peak_search_enabled)
peak_search_cb.stateChanged.connect(lambda _v: set_peak_search_enabled())
except Exception:
pass
set_peak_search_enabled()
calib_window = None
c_edits = []
c_value_labels = []
if peak_calibrate_mode:
try:
calib_window = QtWidgets.QWidget()
try:
calib_window.setWindowTitle(f"{args.title} freq calibration")
except Exception:
pass
calib_layout = QtWidgets.QFormLayout(calib_window)
calib_layout.setContentsMargins(8, 8, 8, 8)
def refresh_c_value_labels() -> None:
coeffs = get_calibration_coeffs()
for idx, label in enumerate(c_value_labels):
try:
label.setText(f"{float(coeffs[idx]):.6g}")
except Exception:
pass
def apply_c_value(idx: int, edit) -> None:
try:
set_calibration_base_value(idx, float(edit.text().strip()))
runtime.mark_dirty()
except Exception:
try:
edit.setText(f"{float(get_calibration_base()[idx]):.6g}")
except Exception:
pass
refresh_c_value_labels()
def apply_all_c_values() -> None:
for idx, edit in enumerate(c_edits):
apply_c_value(idx, edit)
for idx in range(3):
edit = QtWidgets.QLineEdit(f"{float(get_calibration_base()[idx]):.6g}")
try:
edit.setMaximumWidth(120)
edit.editingFinished.connect(lambda i=idx, e=edit: apply_c_value(i, e))
except Exception:
pass
calib_layout.addRow(f"C{idx}", edit)
c_edits.append(edit)
try:
update_btn = QtWidgets.QPushButton("Update")
update_btn.clicked.connect(lambda _checked=False: apply_all_c_values())
calib_layout.addRow(update_btn)
calib_layout.addRow(QtWidgets.QLabel("Working C"), QtWidgets.QLabel(""))
except Exception:
pass
for idx in range(3):
label = QtWidgets.QLabel(f"{float(get_calibration_coeffs()[idx]):.6g}")
calib_layout.addRow(f"C*{idx}", label)
c_value_labels.append(label)
refresh_c_value_labels()
try:
calib_window.show()
except Exception:
pass
except Exception:
calib_window = None
try:
settings_layout.addStretch(1)
except Exception:
pass
def visible_bg_fft(disp_fft: np.ndarray, force: bool = False) -> Optional[np.ndarray]:
nonlocal bg_compute_enabled, bg_subtract_enabled
need_bg = bool(bg_subtract_enabled or force)
if (not need_bg) or disp_fft.size == 0:
return None
ny, nx = disp_fft.shape
if ny <= 0 or nx <= 0:
return runtime.bg_spec_cache
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:
drained = 0
while True:
try:
sweep, info, aux_curves = queue.get_nowait()
except Empty:
break
drained += 1
calibrated = calibrate_freqs(
{
"F": np.linspace(SWEEP_FREQ_MIN_GHZ, SWEEP_FREQ_MAX_GHZ, sweep.size, dtype=np.float64),
"I": sweep,
}
)
runtime.current_freqs = calibrated["F"]
runtime.current_sweep_raw = calibrated["I"]
runtime.current_aux_curves = aux_curves
runtime.current_info = info
channel = 0
try:
channel = int(info.get("ch", 0)) if isinstance(info, dict) else 0
except Exception:
channel = 0
if channel == 0:
runtime.last_calib_sweep = runtime.current_sweep_raw
runtime.current_sweep_norm = None
sweep_for_processing = runtime.current_sweep_raw
else:
if calib_enabled and runtime.last_calib_sweep is not None:
runtime.current_sweep_norm = normalize_sweep(runtime.current_sweep_raw, runtime.last_calib_sweep)
sweep_for_processing = runtime.current_sweep_norm
else:
runtime.current_sweep_norm = None
sweep_for_processing = runtime.current_sweep_raw
ensure_buffer(runtime.current_sweep_raw.size)
runtime.ring.push(sweep_for_processing, runtime.current_freqs)
runtime.current_distances = runtime.ring.distance_axis
runtime.current_fft_db = runtime.ring.last_fft_db
if drained > 0:
update_physical_axes()
return drained
try:
cm_mod = getattr(pg, "colormap", None)
if cm_mod is not None:
colormap = cm_mod.get(args.cmap)
lut = colormap.getLookupTable(0.0, 1.0, 256)
img.setLookupTable(lut)
img_fft.setLookupTable(lut)
except Exception:
pass
def update() -> None:
nonlocal peak_ref_window
if peak_calibrate_mode and any(edit.hasFocus() for edit in c_edits):
return
if peak_search_enabled and peak_window_edit is not None and peak_window_edit.hasFocus():
return
changed = drain_queue() > 0
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 and runtime.current_sweep_raw is not None:
xs = None
if runtime.current_freqs is not None and runtime.current_freqs.size == runtime.current_sweep_raw.size:
xs = runtime.current_freqs
elif runtime.ring.x_shared is not None and runtime.current_sweep_raw.size <= runtime.ring.x_shared.size:
xs = runtime.ring.x_shared[: runtime.current_sweep_raw.size]
else:
xs = np.arange(runtime.current_sweep_raw.size)
curve.setData(xs, runtime.current_sweep_raw, autoDownsample=True)
if runtime.current_aux_curves is not None:
avg_1_curve, avg_2_curve = runtime.current_aux_curves
curve_avg1.setData(xs[: avg_1_curve.size], avg_1_curve, autoDownsample=True)
curve_avg2.setData(xs[: avg_2_curve.size], avg_2_curve, autoDownsample=True)
else:
curve_avg1.setData([], [])
curve_avg2.setData([], [])
if runtime.last_calib_sweep is not None:
curve_calib.setData(xs[: runtime.last_calib_sweep.size], runtime.last_calib_sweep, autoDownsample=True)
else:
curve_calib.setData([], [])
if runtime.current_sweep_norm is not None:
curve_norm.setData(xs[: runtime.current_sweep_norm.size], runtime.current_sweep_norm, autoDownsample=True)
else:
curve_norm.setData([], [])
if fixed_ylim is None:
y_series = [runtime.current_sweep_raw, runtime.last_calib_sweep, runtime.current_sweep_norm]
if runtime.current_aux_curves is not None:
y_series.extend(runtime.current_aux_curves)
y_limits = compute_auto_ylim(*y_series)
if y_limits is not None:
p_line.setYRange(y_limits[0], y_limits[1], padding=0)
if isinstance(xs, np.ndarray) and xs.size > 0:
finite_x = xs[np.isfinite(xs)]
if finite_x.size > 0:
p_line.setXRange(float(np.min(finite_x)), float(np.max(finite_x)), padding=0)
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
if 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:
runtime.current_fft_db = compute_fft_row(sweep_for_fft, runtime.current_freqs, distance_axis.size)
fft_vals = runtime.current_fft_db
xs_fft = distance_axis[: fft_vals.size]
if fft_bg_subtract_enabled and bg_fft_for_line is not None:
n_bg = int(min(fft_vals.size, bg_fft_for_line.size))
if n_bg > 0:
num = np.maximum(
np.power(10.0, np.asarray(fft_vals[:n_bg], dtype=np.float64) / 20.0),
0.0,
)
den = np.maximum(np.asarray(bg_fft_for_line[:n_bg], dtype=np.float64), 0.0)
fft_vals = (20.0 * np.log10((num + 1e-9) / (den + 1e-9))).astype(np.float32, copy=False)
xs_fft = xs_fft[:n_bg]
curve_fft.setData(xs_fft, fft_vals)
finite_x = xs_fft[np.isfinite(xs_fft)]
if finite_x.size > 0:
p_fft.setXRange(float(np.min(finite_x)), float(np.max(finite_x)), padding=0)
finite_fft = np.isfinite(xs_fft) & np.isfinite(fft_vals)
y_for_range = fft_vals[finite_fft]
if peak_search_enabled:
fft_ref = rolling_median_ref(xs_fft, fft_vals, peak_ref_window)
finite_ref = np.isfinite(xs_fft) & np.isfinite(fft_ref)
if np.any(finite_ref):
curve_fft_ref.setData(xs_fft[finite_ref], fft_ref[finite_ref])
curve_fft_ref.setVisible(True)
y_for_range = np.concatenate((y_for_range, fft_ref[finite_ref]))
else:
curve_fft_ref.setVisible(False)
runtime.peak_candidates = find_top_peaks_over_ref(xs_fft, fft_vals, fft_ref, top_n=3)
refresh_peak_params_label(runtime.peak_candidates)
for idx, box in enumerate(fft_peak_boxes):
if idx < len(runtime.peak_candidates):
peak = runtime.peak_candidates[idx]
box.setData(
[peak["left"], peak["left"], peak["right"], peak["right"], peak["left"]],
[peak["ref"], peak["peak_y"], peak["peak_y"], peak["ref"], peak["ref"]],
)
box.setVisible(True)
else:
box.setVisible(False)
else:
runtime.peak_candidates = []
refresh_peak_params_label([])
curve_fft_ref.setVisible(False)
for box in fft_peak_boxes:
box.setVisible(False)
if fft_bg_subtract_enabled and bg_fft_for_line is not None:
p_fft.setYRange(-10.0, 30.0, padding=0)
else:
finite_y = y_for_range[np.isfinite(y_for_range)]
if finite_y.size > 0:
y0 = float(np.min(finite_y))
y1 = float(np.max(finite_y))
if y1 <= y0:
y1 = y0 + 1e-3
p_fft.setYRange(y0, y1, padding=0)
if peak_calibrate_mode:
markers = find_peak_width_markers(xs_fft, fft_vals)
if markers is not None:
fft_bg_line.setValue(markers["background"])
fft_left_line.setValue(markers["left"])
fft_right_line.setValue(markers["right"])
spec_left_line.setValue(markers["left"])
spec_right_line.setValue(markers["right"])
fft_bg_line.setVisible(True)
fft_left_line.setVisible(True)
fft_right_line.setVisible(True)
spec_left_line.setVisible(True)
spec_right_line.setVisible(True)
runtime.current_peak_width = markers["width"]
runtime.current_peak_amplitude = markers["amplitude"]
else:
fft_bg_line.setVisible(False)
fft_left_line.setVisible(False)
fft_right_line.setVisible(False)
spec_left_line.setVisible(False)
spec_right_line.setVisible(False)
runtime.current_peak_width = None
runtime.current_peak_amplitude = None
else:
fft_bg_line.setVisible(False)
fft_left_line.setVisible(False)
fft_right_line.setVisible(False)
spec_left_line.setVisible(False)
spec_right_line.setVisible(False)
runtime.current_peak_width = None
runtime.current_peak_amplitude = None
else:
curve_fft_ref.setVisible(False)
for box in fft_peak_boxes:
box.setVisible(False)
runtime.peak_candidates = []
refresh_peak_params_label([])
runtime.plot_dirty = False
if changed and runtime.ring.ring is not None:
disp = runtime.ring.get_display_raw()
levels = _visible_levels_pyqtgraph(disp, p_img)
if levels is not None:
img.setImage(disp, autoLevels=False, levels=levels)
else:
img.setImage(disp, autoLevels=False)
if changed and runtime.current_info:
try:
status_payload = dict(runtime.current_info)
if peak_calibrate_mode and runtime.current_peak_width is not None:
status_payload["peak_w"] = runtime.current_peak_width
if peak_calibrate_mode and runtime.current_peak_amplitude is not None:
status_payload["peak_a"] = runtime.current_peak_amplitude
status.setText(format_status_kv(status_payload))
except Exception:
pass
try:
chs = runtime.current_info.get("chs") if isinstance(runtime.current_info, dict) else None
if chs is None:
chs = runtime.current_info.get("ch") if isinstance(runtime.current_info, dict) else None
if chs is None:
ch_text.setText("")
else:
if isinstance(chs, (list, tuple, set)):
ch_list = sorted(int(v) for v in chs)
ch_text_val = ", ".join(str(v) for v in ch_list)
else:
ch_text_val = str(int(chs))
ch_text.setText(f"chs {ch_text_val}")
(x0, x1), (y0, y1) = p_line.viewRange()
dx = 0.01 * max(1.0, float(x1 - x0))
dy = 0.01 * max(1.0, float(y1 - y0))
ch_text.setPos(float(x1 - dx), float(y1 - dy))
except Exception:
pass
if changed and runtime.ring.ring_fft is not None:
disp_fft_lin = runtime.ring.get_display_fft_linear()
if spec_mean_sec > 0.0:
disp_times = runtime.ring.get_display_times()
if disp_times is not None:
now_t = time.time()
mask = np.isfinite(disp_times) & (disp_times >= (now_t - spec_mean_sec))
if np.any(mask):
try:
mean_spec = np.nanmean(disp_fft_lin[:, mask], axis=1)
mean_spec = np.nan_to_num(mean_spec, nan=0.0)
disp_fft_lin = disp_fft_lin - mean_spec[:, None]
except Exception:
pass
bg_spec = visible_bg_fft(disp_fft_lin)
if bg_spec is not None:
num = np.maximum(disp_fft_lin, 0.0).astype(np.float32, copy=False) + 1e-9
den = bg_spec[:, None] + 1e-9
disp_fft = (20.0 * np.log10(num / den)).astype(np.float32, copy=False)
else:
disp_fft = fft_mag_to_db(disp_fft_lin)
levels = None
if bg_spec is not None:
try:
p5 = float(np.nanpercentile(disp_fft, 5))
p95 = float(np.nanpercentile(disp_fft, 95))
span = max(abs(p5), abs(p95))
if np.isfinite(span) and span > 0.0:
levels = (-span, span)
except Exception:
levels = None
else:
try:
mean_spec = np.nanmean(disp_fft, axis=1)
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)
if levels is not None:
img_fft.setImage(disp_fft, autoLevels=False, levels=levels)
else:
img_fft.setImage(disp_fft, autoLevels=False)
timer = pg.QtCore.QTimer()
timer.timeout.connect(update)
timer.start(interval_ms)
sigint_requested = threading.Event()
sigint_timer = pg.QtCore.QTimer()
sigint_timer.setInterval(50)
sigint_timer.timeout.connect(lambda: app.quit() if sigint_requested.is_set() else None)
sigint_timer.start()
cleanup_done = False
def on_quit() -> None:
nonlocal cleanup_done
if cleanup_done:
return
cleanup_done = True
try:
timer.stop()
sigint_timer.stop()
except Exception:
pass
stop_event.set()
reader.join(timeout=1.0)
try:
main_window.close()
except Exception:
pass
if calib_window is not None:
try:
calib_window.close()
except Exception:
pass
def handle_sigint(_signum, _frame) -> None:
sigint_requested.set()
prev_sigint = signal.getsignal(signal.SIGINT)
try:
signal.signal(signal.SIGINT, handle_sigint)
except Exception:
prev_sigint = None
orig_close_event = getattr(main_window, "closeEvent", None)
def close_event(event) -> None:
try:
if callable(orig_close_event):
orig_close_event(event)
else:
event.accept()
except Exception:
try:
event.accept()
except Exception:
pass
try:
app.quit()
except Exception:
pass
try:
main_window.closeEvent = close_event # type: ignore[method-assign]
except Exception:
pass
app.aboutToQuit.connect(on_quit)
try:
main_window.resize(1200, 680)
except Exception:
pass
main_window.show()
exec_fn = getattr(app, "exec_", None) or getattr(app, "exec", None)
try:
exec_fn()
finally:
on_quit()
if prev_sigint is not None:
try:
signal.signal(signal.SIGINT, prev_sigint)
except Exception:
pass