From c171ae07e0732fc946e5079edd24c577a9b3ba54 Mon Sep 17 00:00:00 2001 From: Theodor Chikin Date: Wed, 4 Mar 2026 14:34:41 +0300 Subject: [PATCH] implemented --calibrate mode. In this mode frequency calibration coeffs can be entered via GUI. Also fixed some bugs in PyQT backend. Problem: matplotlib is so slow... --- RFG_ADC_dataplotter.py | 294 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 282 insertions(+), 12 deletions(-) diff --git a/RFG_ADC_dataplotter.py b/RFG_ADC_dataplotter.py index ea6715a..9a912da 100755 --- a/RFG_ADC_dataplotter.py +++ b/RFG_ADC_dataplotter.py @@ -47,6 +47,7 @@ SweepInfo = Dict[str, Any] SweepData = Dict[str, np.ndarray] SweepAuxCurves = Optional[Tuple[np.ndarray, np.ndarray]] SweepPacket = Tuple[np.ndarray, SweepInfo, SweepAuxCurves] +CALIBRATION_C = np.asarray([0.0, 1.0, 0.025], dtype=np.float64) def _format_status_kv(data: Mapping[str, Any]) -> str: @@ -136,8 +137,7 @@ def calibrate_freqs(sweep: Mapping[str, Any]) -> SweepData: F = np.asarray(sweep["F"], dtype=np.float64).copy() I = np.asarray(sweep["I"], dtype=np.float64).copy() tmp = [] - - C = [0,1,0] + C = np.asarray(CALIBRATION_C, dtype=np.float64) for f in F: val = C[0] + (f**1) * C[1] + (f**2) * C[2] @@ -244,6 +244,84 @@ def _compute_distance_axis(freqs: Optional[np.ndarray], bins: int) -> np.ndarray return np.arange(bins, dtype=np.float64) * step_m +def _find_peak_width_markers(xs: np.ndarray, ys: np.ndarray) -> Optional[Dict[str, float]]: + """Найти главный ненулевой пик и его ширину по уровню половины высоты над фоном.""" + x_arr = np.asarray(xs, dtype=np.float64) + y_arr = np.asarray(ys, dtype=np.float64) + valid = np.isfinite(x_arr) & np.isfinite(y_arr) & (x_arr > 0.0) + if int(np.count_nonzero(valid)) < 3: + return None + + x = x_arr[valid] + y = y_arr[valid] + x_min = float(x[0]) + x_max = float(x[-1]) + x_span = x_max - x_min + central_mask = (x >= (x_min + 0.25 * x_span)) & (x <= (x_min + 0.75 * x_span)) + if int(np.count_nonzero(central_mask)) > 0: + central_idx = np.flatnonzero(central_mask) + peak_idx = int(central_idx[int(np.argmax(y[central_mask]))]) + else: + peak_idx = int(np.argmax(y)) + peak_y = float(y[peak_idx]) + shoulder_gap = max(1, min(8, y.size // 64 if y.size > 0 else 1)) + shoulder_width = max(4, min(32, y.size // 16 if y.size > 0 else 4)) + left_lo = max(0, peak_idx - shoulder_gap - shoulder_width) + left_hi = max(0, peak_idx - shoulder_gap) + right_lo = min(y.size, peak_idx + shoulder_gap + 1) + right_hi = min(y.size, right_lo + shoulder_width) + background_parts = [] + if left_hi > left_lo: + background_parts.append(float(np.nanmedian(y[left_lo:left_hi]))) + if right_hi > right_lo: + background_parts.append(float(np.nanmedian(y[right_lo:right_hi]))) + if background_parts: + background = float(np.mean(background_parts)) + else: + background = float(np.nanpercentile(y, 10)) + if not np.isfinite(peak_y) or not np.isfinite(background) or peak_y <= background: + return None + + half_level = background + 0.5 * (peak_y - background) + + def _interp_cross(x0: float, y0: float, x1: float, y1: float) -> float: + if not (np.isfinite(x0) and np.isfinite(y0) and np.isfinite(x1) and np.isfinite(y1)): + return x1 + dy = y1 - y0 + if dy == 0.0: + return x1 + t = (half_level - y0) / dy + t = min(1.0, max(0.0, t)) + return x0 + t * (x1 - x0) + + left_x = float(x[0]) + for i in range(peak_idx, 0, -1): + if y[i - 1] <= half_level <= y[i]: + left_x = _interp_cross(float(x[i - 1]), float(y[i - 1]), float(x[i]), float(y[i])) + break + else: + left_x = float(x[0]) + + right_x = float(x[-1]) + for i in range(peak_idx, x.size - 1): + if y[i] >= half_level >= y[i + 1]: + right_x = _interp_cross(float(x[i]), float(y[i]), float(x[i + 1]), float(y[i + 1])) + break + else: + right_x = float(x[-1]) + + width = right_x - left_x + if not np.isfinite(width) or width <= 0.0: + return None + + return { + "background": background, + "left": left_x, + "right": right_x, + "width": width, + } + + def _normalize_sweep_simple(raw: np.ndarray, calib: np.ndarray) -> np.ndarray: """Простая нормировка: поэлементное деление raw/calib.""" w = min(raw.size, calib.size) @@ -1028,6 +1106,14 @@ def main(): "а свип считается как 10**(avg_1*0.001) - 10**(avg_2*0.001)" ), ) + parser.add_argument( + "--calibrate", + action="store_true", + help=( + "Режим измерения ширины главного пика FFT: рисует красные маркеры " + "границ и фона и выводит ширину пика в статус" + ), + ) args = parser.parse_args() @@ -1045,7 +1131,7 @@ def main(): import matplotlib import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation - from matplotlib.widgets import Slider, CheckButtons + from matplotlib.widgets import Slider, CheckButtons, TextBox except Exception as e: sys.stderr.write(f"[error] Нужны matplotlib и ее зависимости: {e}\n") sys.exit(1) @@ -1099,8 +1185,11 @@ def main(): ymax_slider = None contrast_slider = None calib_enabled = False + peak_calibrate_mode = bool(getattr(args, "calibrate", False)) + current_peak_width: Optional[float] = None norm_type = str(getattr(args, "norm_type", "projector")).strip().lower() cb = None + c_boxes = [] # Статусная строка (внизу окна) status_text = fig.text( @@ -1135,6 +1224,9 @@ def main(): # Линейный график спектра текущего свипа fft_line_obj, = ax_fft.plot([], [], lw=1) + fft_bg_obj = ax_fft.axhline(0.0, lw=1, color="red", visible=False) + fft_left_obj = ax_fft.axvline(0.0, lw=1, color="red", visible=False) + fft_right_obj = ax_fft.axvline(0.0, lw=1, color="red", visible=False) ax_fft.set_title("FFT", pad=1) ax_fft.set_xlabel("Расстояние, м") ax_fft.set_ylabel("дБ") @@ -1184,6 +1276,8 @@ def main(): ax_spec.tick_params(axis="x", labelbottom=False) except Exception: pass + spec_left_obj = ax_spec.axhline(0.0, lw=1, color="red", visible=False) + spec_right_obj = ax_spec.axhline(0.0, lw=1, color="red", visible=False) def _normalize_sweep(raw: np.ndarray, calib: np.ndarray) -> np.ndarray: return _normalize_by_calib(raw, calib, norm_type=norm_type) @@ -1227,6 +1321,23 @@ def main(): except Exception: pass + if peak_calibrate_mode: + try: + def _set_c_value(idx: int, text: str): + global CALIBRATION_C + try: + CALIBRATION_C[idx] = float(text.strip()) + except Exception: + pass + + for idx, ypos in enumerate((0.36, 0.30, 0.24)): + ax_c = fig.add_axes([0.92, ypos, 0.08, 0.045]) + tb = TextBox(ax_c, f"C{idx}", initial=f"{float(CALIBRATION_C[idx]):.6g}") + tb.on_submit(lambda text, i=idx: _set_c_value(i, text)) + c_boxes.append(tb) + except Exception: + pass + # Для контроля частоты обновления max_fps = max(1.0, float(args.max_fps)) interval_ms = int(1000.0 / max_fps) @@ -1431,7 +1542,25 @@ def main(): return base.T # (bins, time) def update(_frame): - nonlocal frames_since_ylim_update + nonlocal frames_since_ylim_update, current_peak_width + if peak_calibrate_mode and any(getattr(tb, "capturekeystrokes", False) for tb in c_boxes): + return ( + line_obj, + line_avg1_obj, + line_avg2_obj, + line_calib_obj, + line_norm_obj, + img_obj, + fft_line_obj, + fft_bg_obj, + fft_left_obj, + fft_right_obj, + img_fft_obj, + spec_left_obj, + spec_right_obj, + status_text, + channel_text, + ) changed = drain_queue() > 0 # Обновление линии последнего свипа @@ -1486,6 +1615,34 @@ def main(): if finite_x.size > 0: ax_fft.set_xlim(float(np.min(finite_x)), float(np.max(finite_x))) ax_fft.set_ylim(float(np.nanmin(fft_vals)), float(np.nanmax(fft_vals))) + if peak_calibrate_mode: + markers = _find_peak_width_markers(xs_fft[: fft_vals.size], fft_vals) + if markers is not None: + fft_bg_obj.set_ydata([markers["background"], markers["background"]]) + fft_left_obj.set_xdata([markers["left"], markers["left"]]) + fft_right_obj.set_xdata([markers["right"], markers["right"]]) + spec_left_obj.set_ydata([markers["left"], markers["left"]]) + spec_right_obj.set_ydata([markers["right"], markers["right"]]) + fft_bg_obj.set_visible(True) + fft_left_obj.set_visible(True) + fft_right_obj.set_visible(True) + spec_left_obj.set_visible(True) + spec_right_obj.set_visible(True) + current_peak_width = markers["width"] + else: + fft_bg_obj.set_visible(False) + fft_left_obj.set_visible(False) + fft_right_obj.set_visible(False) + spec_left_obj.set_visible(False) + spec_right_obj.set_visible(False) + current_peak_width = None + else: + fft_bg_obj.set_visible(False) + fft_left_obj.set_visible(False) + fft_right_obj.set_visible(False) + spec_left_obj.set_visible(False) + spec_right_obj.set_visible(False) + current_peak_width = None # Обновление водопада if changed and ring is not None: @@ -1535,7 +1692,10 @@ def main(): img_fft_obj.set_clim(vmin=vmin_v, vmax=vmax_eff) if changed and current_info: - status_text.set_text(_format_status_kv(current_info)) + status_payload = dict(current_info) + if peak_calibrate_mode and current_peak_width is not None: + status_payload["peak_w"] = current_peak_width + status_text.set_text(_format_status_kv(status_payload)) chs = current_info.get("chs") if isinstance(current_info, dict) else None if chs is None: chs = current_info.get("ch") if isinstance(current_info, dict) else None @@ -1561,7 +1721,12 @@ def main(): line_norm_obj, img_obj, fft_line_obj, + fft_bg_obj, + fft_left_obj, + fft_right_obj, img_fft_obj, + spec_left_obj, + spec_right_obj, status_text, channel_text, ) @@ -1576,6 +1741,7 @@ def main(): def run_pyqtgraph(args): """Быстрый GUI на PyQtGraph. Требует pyqtgraph и PyQt5/PySide6.""" + peak_calibrate_mode = bool(getattr(args, "calibrate", False)) try: import pyqtgraph as pg from PyQt5 import QtCore, QtWidgets # noqa: F401 @@ -1609,8 +1775,14 @@ def run_pyqtgraph(args): interval_ms = int(1000.0 / max_fps) # PyQtGraph настройки - pg.setConfigOptions(useOpenGL=True, antialias=False) - app = pg.mkQApp(args.title) + pg.setConfigOptions(useOpenGL=not peak_calibrate_mode, antialias=False) + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication([]) + try: + app.setApplicationName(str(args.title)) + except Exception: + pass win = pg.GraphicsLayoutWidget(show=True, title=args.title) win.resize(1200, 600) @@ -1645,6 +1817,16 @@ def run_pyqtgraph(args): 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)) + peak_pen = pg.mkPen((255, 0, 0), width=1) + 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) + 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", "дБ") @@ -1660,12 +1842,24 @@ def run_pyqtgraph(args): 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) - # Чекбокс калибровки + # Отдельное окно контролов: GraphicsLayoutWidget не принимает обычные QWidget через addItem. calib_cb = QtWidgets.QCheckBox("калибровка") - cb_proxy = QtWidgets.QGraphicsProxyWidget() - cb_proxy.setWidget(calib_cb) - win.addItem(cb_proxy, row=2, col=1) + control_window = QtWidgets.QWidget() + try: + control_window.setWindowTitle(f"{args.title} controls") + except Exception: + pass + control_layout = QtWidgets.QVBoxLayout(control_window) + control_layout.setContentsMargins(8, 8, 8, 8) + control_layout.setSpacing(6) + control_layout.addWidget(calib_cb) # Статусная строка (внизу окна) status = pg.LabelItem(justify="left") @@ -1694,7 +1888,9 @@ def run_pyqtgraph(args): spec_clip = _parse_spec_clip(getattr(args, "spec_clip", None)) spec_mean_sec = float(getattr(args, "spec_mean_sec", 0.0)) calib_enabled = False + current_peak_width: Optional[float] = None norm_type = str(getattr(args, "norm_type", "projector")).strip().lower() + c_edits = [] # Диапазон по Y: авто по умолчанию (поддерживает отрицательные значения) fixed_ylim: Optional[Tuple[float, float]] = None if args.ylim: @@ -1725,6 +1921,42 @@ def run_pyqtgraph(args): except Exception: pass + if peak_calibrate_mode: + try: + c_widget = QtWidgets.QWidget() + c_layout = QtWidgets.QFormLayout(c_widget) + c_layout.setContentsMargins(0, 0, 0, 0) + + def _apply_c_value(idx: int, edit): + global CALIBRATION_C + try: + CALIBRATION_C[idx] = float(edit.text().strip()) + except Exception: + try: + edit.setText(f"{float(CALIBRATION_C[idx]):.6g}") + except Exception: + pass + + for idx in range(3): + edit = QtWidgets.QLineEdit(f"{float(CALIBRATION_C[idx]):.6g}") + try: + edit.setMaximumWidth(120) + except Exception: + pass + try: + edit.editingFinished.connect(lambda i=idx, e=edit: _apply_c_value(i, e)) + except Exception: + pass + c_layout.addRow(f"C{idx}", edit) + c_edits.append(edit) + control_layout.addWidget(c_widget) + except Exception: + pass + try: + control_window.show() + except Exception: + pass + def ensure_buffer(_w: int): nonlocal ring, ring_time, head, width, x_shared, ring_fft, distance_shared if ring is not None: @@ -1875,6 +2107,9 @@ def run_pyqtgraph(args): pass def update(): + nonlocal current_peak_width + if peak_calibrate_mode and any(edit.hasFocus() for edit in c_edits): + return changed = drain_queue() > 0 if current_sweep_raw is not None and x_shared is not None: if current_freqs is not None and current_freqs.size == current_sweep_raw.size: @@ -1923,6 +2158,34 @@ def run_pyqtgraph(args): if finite_x.size > 0: p_fft.setXRange(float(np.min(finite_x)), float(np.max(finite_x)), padding=0) p_fft.setYRange(float(np.nanmin(fft_vals)), float(np.nanmax(fft_vals)), padding=0) + if peak_calibrate_mode: + markers = _find_peak_width_markers(xs_fft[: fft_vals.size], 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) + current_peak_width = markers["width"] + 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) + current_peak_width = 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) + current_peak_width = None if changed and ring is not None: disp = ring if head == 0 else np.roll(ring, -head, axis=0) @@ -1935,7 +2198,10 @@ def run_pyqtgraph(args): if changed and current_info: try: - status.setText(_format_status_kv(current_info)) + status_payload = dict(current_info) + if peak_calibrate_mode and current_peak_width is not None: + status_payload["peak_w"] = current_peak_width + status.setText(_format_status_kv(status_payload)) except Exception: pass try: @@ -2007,6 +2273,10 @@ def run_pyqtgraph(args): def on_quit(): stop_event.set() reader.join(timeout=1.0) + try: + control_window.close() + except Exception: + pass app.aboutToQuit.connect(on_quit) win.show()