diff --git a/RFG_ADC_dataplotter.py b/RFG_ADC_dataplotter.py index 024fca9..3a6049d 100755 --- a/RFG_ADC_dataplotter.py +++ b/RFG_ADC_dataplotter.py @@ -264,9 +264,25 @@ def main(): default=None, help="Фиксированные Y-пределы для кривой формата min,max (например -1000,1000). По умолчанию авто", ) + parser.add_argument( + "--backend", + choices=["auto", "pg", "mpl"], + default="auto", + help="Графический бэкенд: pyqtgraph (pg) — быстрее; matplotlib (mpl) — совместимый. По умолчанию auto", + ) args = parser.parse_args() + # Попробуем быстрый бэкенд (pyqtgraph) при auto/pg + if args.backend in ("auto", "pg"): + try: + return run_pyqtgraph(args) + except Exception as e: + if args.backend == "pg": + sys.stderr.write(f"[error] PyQtGraph бэкенд недоступен: {e}\n") + sys.exit(1) + # При auto — тихо откатываемся на matplotlib + try: import matplotlib import matplotlib.pyplot as plt @@ -288,6 +304,7 @@ def main(): # Состояние для отображения current_sweep: Optional[np.ndarray] = None + x_shared: Optional[np.ndarray] = None width: Optional[int] = None max_sweeps = int(max(10, args.max_sweeps)) ring = None # type: Optional[np.ndarray] @@ -327,10 +344,11 @@ def main(): frames_since_ylim_update = 0 def ensure_buffer(w: int): - nonlocal ring, width, head + nonlocal ring, width, head, x_shared if ring is not None and width == w: return width = w + x_shared = np.arange(w, dtype=np.int32) ring = np.full((max_sweeps, w), np.nan, dtype=np.float32) head = 0 # Обновляем изображение под новые размеры @@ -386,8 +404,8 @@ def main(): # Обновление линии последнего свипа if current_sweep is not None: - x = np.arange(current_sweep.size, dtype=np.int32) - line_obj.set_data(x, current_sweep) + xs = x_shared if x_shared is not None and x_shared.size == current_sweep.size else np.arange(current_sweep.size, dtype=np.int32) + line_obj.set_data(xs, current_sweep) # Лимиты по X постоянные под текущую ширину ax_line.set_xlim(0, max(1, current_sweep.size - 1)) # Y-лимиты: фиксированные либо периодическая автоподстройка @@ -421,5 +439,156 @@ def main(): reader.join(timeout=1.0) +def run_pyqtgraph(args): + """Быстрый GUI на PyQtGraph. Требует pyqtgraph и PyQt5/PySide6.""" + try: + import pyqtgraph as pg + from PyQt5 import QtCore, QtWidgets # noqa: F401 + except Exception: + # Возможно установлена PySide6 + try: + import pyqtgraph as pg + from PySide6 import QtCore, QtWidgets # noqa: F401 + except Exception as e: + raise RuntimeError( + "pyqtgraph/PyQt5(Pyside6) не найдены. Установите: pip install pyqtgraph PyQt5" + ) from e + + # Очередь завершённых свипов и поток чтения + q: Queue[np.ndarray] = Queue(maxsize=1000) + stop_event = threading.Event() + reader = SweepReader(args.port, args.baud, q, stop_event) + 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) + + # PyQtGraph настройки + pg.setConfigOptions(useOpenGL=True, antialias=False) + app = pg.mkQApp(args.title) + win = pg.GraphicsLayoutWidget(show=True, title=args.title) + win.resize(1200, 600) + + # Плот последнего свипа (слева) + p_line = win.addPlot(row=0, col=0, title="Последний свип") + p_line.showGrid(x=True, y=True, alpha=0.3) + curve = p_line.plot(pen=pg.mkPen((80, 120, 255), width=1)) + p_line.setLabel("bottom", "X") + p_line.setLabel("left", "Y") + + # Водопад (справа) + p_img = win.addPlot(row=0, col=1, title="Водопад (последние свипы)") + p_img.invertY(True) # 0 сверху, новые снизу + p_img.showGrid(x=False, y=False) + p_img.setLabel("bottom", "X") + p_img.setLabel("left", "Номер свипа (время →)") + img = pg.ImageItem() + p_img.addItem(img) + + # Состояние + ring: Optional[np.ndarray] = None + head = 0 + width: Optional[int] = None + x_shared: Optional[np.ndarray] = None + current_sweep: Optional[np.ndarray] = None + y_min, y_max = None, None + fixed_ylim: Optional[Tuple[float, float]] = None + if args.ylim: + try: + y0, y1 = args.ylim.split(",") + fixed_ylim = (float(y0), float(y1)) + p_line.setYRange(fixed_ylim[0], fixed_ylim[1], padding=0) + except Exception: + pass + + def ensure_buffer(w: int): + nonlocal ring, head, width, x_shared + if ring is not None and width == w: + return + width = w + x_shared = np.arange(w, dtype=np.int32) + ring = np.full((max_sweeps, w), np.nan, dtype=np.float32) + head = 0 + img.setImage(ring, autoLevels=False) + p_img.setRange(xRange=(0, max(1, w - 1)), yRange=(0, max_sweeps - 1), padding=0) + p_line.setXRange(0, max(1, w - 1), padding=0) + + def push_sweep(s: np.ndarray): + nonlocal ring, head, y_min, y_max + if s is None or s.size == 0 or ring is None: + return + w = ring.shape[1] + row = np.full((w,), np.nan, dtype=np.float32) + take = min(w, s.size) + row[:take] = s[:take] + ring[head, :] = row + head = (head + 1) % ring.shape[0] + # Обновление экстремумов + sv_min = np.nanmin(row) + sv_max = np.nanmax(row) + if y_min is None or (not np.isnan(sv_min) and sv_min < y_min): + y_min = float(sv_min) + if y_max is None or (not np.isnan(sv_max) and sv_max > y_max): + y_max = float(sv_max) + + def drain_queue(): + nonlocal current_sweep + drained = 0 + while True: + try: + s = q.get_nowait() + except Empty: + break + drained += 1 + current_sweep = s + ensure_buffer(s.size) + push_sweep(s) + return drained + + # Попытка применить LUT из колормэпа (если доступен) + try: + cm_mod = getattr(pg, "colormap", None) + if cm_mod is not None: + cm = cm_mod.get(args.cmap) + img.setLookupTable(cm.getLookupTable(0.0, 1.0, 256)) + except Exception: + pass + + def update(): + changed = drain_queue() > 0 + if current_sweep is not None and x_shared is not None: + curve.setData(x_shared[: current_sweep.size], current_sweep, autoDownsample=True) + if fixed_ylim is None: + y0 = float(np.nanmin(current_sweep)) + y1 = float(np.nanmax(current_sweep)) + if np.isfinite(y0) and np.isfinite(y1): + margin = 0.05 * max(1.0, (y1 - y0)) + p_line.setYRange(y0 - margin, y1 + margin, padding=0) + + if changed and ring is not None: + disp = ring if head == 0 else np.roll(ring, -head, axis=0) + if y_min is not None and y_max is not None and y_min != y_max and np.isfinite(y_min) and np.isfinite(y_max): + img.setImage(disp, autoLevels=False, levels=(y_min, y_max)) + else: + img.setImage(disp, autoLevels=False) + + timer = pg.QtCore.QTimer() + timer.timeout.connect(update) + timer.start(interval_ms) + + def on_quit(): + stop_event.set() + reader.join(timeout=1.0) + + app.aboutToQuit.connect(on_quit) + win.show() + exec_fn = getattr(app, "exec_", None) or getattr(app, "exec", None) + exec_fn() + # На случай если aboutToQuit не сработал + on_quit() + + if __name__ == "__main__": main()