diff --git a/RFG_ADC_dataplotter.py b/RFG_ADC_dataplotter.py index fa118b7..14c6612 100755 --- a/RFG_ADC_dataplotter.py +++ b/RFG_ADC_dataplotter.py @@ -4,7 +4,7 @@ Формат строк: - "Sweep_start" — начало нового свипа (предыдущий считается завершённым) - - "s X Y" — точка (индекс X, значение Y), все целые со знаком + - "s CH X Y" — точка (номер канала, индекс X, значение Y), все целые со знаком Отрисовываются два графика: - Левый: последний полученный свип (Y vs X) @@ -38,7 +38,7 @@ FFT_LEN = 1024 # длина БПФ для спектра/водопада сп DATA_INVERSION_THRASHOLD = 10.0 Number = Union[int, float] -SweepInfo = Dict[str, Number] +SweepInfo = Dict[str, Any] SweepPacket = Tuple[np.ndarray, SweepInfo] @@ -312,9 +312,11 @@ class SweepReader(threading.Thread): self._read_errors: int = 0 # Ошибок чтения из порта self._last_diag_time: float = 0.0 # Время последнего вывода диагностики - def _finalize_current(self, xs, ys): + def _finalize_current(self, xs, ys, channels: Optional[set[int]]): if not xs: return + ch_list = sorted(channels) if channels else [0] + ch_primary = ch_list[0] if ch_list else 0 max_x = max(xs) width = max_x + 1 self._max_width = max(self._max_width, width) @@ -361,10 +363,14 @@ class SweepReader(threading.Thread): sweep *= -1.0 except Exception: pass - sweep -= float(np.nanmean(sweep)) + #sweep -= float(np.nanmean(sweep)) # Метрики для статусной строки (вид словаря: переменная -> значение) self._sweep_idx += 1 + if len(ch_list) > 1: + sys.stderr.write( + f"[warn] Sweep {self._sweep_idx}: изменялся номер канала: {ch_list}\n" + ) now = time.time() if self._last_sweep_ts is None: dt_ms = float("nan") @@ -388,6 +394,8 @@ class SweepReader(threading.Thread): vmin = vmax = mean = std = float("nan") info: SweepInfo = { "sweep": self._sweep_idx, + "ch": ch_primary, + "chs": ch_list, "n_valid": n_valid, "min": vmin, "max": vmax, @@ -432,6 +440,8 @@ class SweepReader(threading.Thread): # Состояние текущего свипа xs: list[int] = [] ys: list[int] = [] + cur_channel: Optional[int] = None + cur_channels: set[int] = set() try: self._src = SerialLineSource(self._port_path, self._baud, timeout=1.0) @@ -474,21 +484,38 @@ class SweepReader(threading.Thread): continue if line.startswith(b"Sweep_start"): - self._finalize_current(xs, ys) + self._finalize_current(xs, ys, cur_channels) xs.clear() ys.clear() + cur_channel = None + cur_channels.clear() continue - # s X Y (оба целые со знаком). Разделяем по любым пробелам/табам. + # sCH X Y или s CH X Y (все целые со знаком). Разделяем по любым пробелам/табам. if len(line) >= 3: parts = line.split() - if len(parts) >= 3 and parts[0].lower() == b"s": + if len(parts) >= 3 and (parts[0].lower() == b"s" or parts[0].lower().startswith(b"s")): try: - x = int(parts[1], 10) - y = int(parts[2], 10) # поддержка знака: "+…" и "-…" + if parts[0].lower() == b"s": + if len(parts) >= 4: + ch = int(parts[1], 10) + x = int(parts[2], 10) + y = int(parts[3], 10) # поддержка знака: "+…" и "-…" + else: + ch = 0 + x = int(parts[1], 10) + y = int(parts[2], 10) # поддержка знака: "+…" и "-…" + else: + # формат вида "s0" + ch = int(parts[0][1:], 10) + x = int(parts[1], 10) + y = int(parts[2], 10) # поддержка знака: "+…" и "-…" except Exception: self._total_parse_errors += 1 continue + if cur_channel is None: + cur_channel = ch + cur_channels.add(ch) xs.append(x) ys.append(y) self._total_lines_received += 1 @@ -505,7 +532,7 @@ class SweepReader(threading.Thread): finally: try: # Завершаем оставшийся свип - self._finalize_current(xs, ys) + self._finalize_current(xs, ys, cur_channels) except Exception: pass try: @@ -638,6 +665,15 @@ def main(): "Напр. 2,98. 'off' — отключить" ), ) + parser.add_argument( + "--spec-mean-sec", + type=float, + default=0.0, + help=( + "Вычитание среднего по каждой частоте за последние N секунд " + "в водопаде спектров (0 — отключить)" + ), + ) parser.add_argument("--title", default="ADC Sweeps", help="Заголовок окна") parser.add_argument( "--fancy", @@ -673,7 +709,7 @@ def main(): import matplotlib import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation - from matplotlib.widgets import Slider + from matplotlib.widgets import Slider, CheckButtons except Exception as e: sys.stderr.write(f"[error] Нужны matplotlib и ее зависимости: {e}\n") sys.exit(1) @@ -692,7 +728,9 @@ def main(): fig.subplots_adjust(wspace=0.25, hspace=0.35, left=0.07, right=0.90, top=0.95, bottom=0.05) # Состояние для отображения - current_sweep: Optional[np.ndarray] = None + current_sweep_raw: Optional[np.ndarray] = None + current_sweep_norm: Optional[np.ndarray] = None + last_calib_sweep: Optional[np.ndarray] = None current_info: Optional[SweepInfo] = None x_shared: Optional[np.ndarray] = None width: Optional[int] = None @@ -713,10 +751,13 @@ def main(): y_min_phase, y_max_phase = None, None # Параметры контраста водопада спектров spec_clip = _parse_spec_clip(getattr(args, "spec_clip", None)) + spec_mean_sec = float(getattr(args, "spec_mean_sec", 0.0)) # Ползунки управления Y для B-scan и контрастом ymin_slider = None ymax_slider = None contrast_slider = None + calib_enabled = False + cb = None # Статусная строка (внизу окна) status_text = fig.text( @@ -730,10 +771,22 @@ def main(): ) # Линейный график последнего свипа - line_obj, = ax_line.plot([], [], lw=1) + line_obj, = ax_line.plot([], [], lw=1, color="tab:blue") + line_calib_obj, = ax_line.plot([], [], lw=1, color="tab:red") + line_norm_obj, = ax_line.plot([], [], lw=1, color="tab:green") ax_line.set_title("Сырые данные", pad=1) ax_line.set_xlabel("F") ax_line.set_ylabel("") + channel_text = ax_line.text( + 0.98, + 0.98, + "", + transform=ax_line.transAxes, + ha="right", + va="top", + fontsize=9, + family="monospace", + ) # Линейный график спектра текущего свипа fft_line_obj, = ax_fft.plot([], [], lw=1) @@ -786,6 +839,27 @@ def main(): ax_spec.tick_params(axis="x", labelbottom=False) except Exception: pass + def _normalize_sweep(raw: np.ndarray, calib: np.ndarray) -> np.ndarray: + w = min(raw.size, calib.size) + if w <= 0: + return raw + out = np.full_like(raw, np.nan, dtype=np.float32) + with np.errstate(divide="ignore", invalid="ignore"): + out[:w] = raw[:w] / calib[:w] + out = np.nan_to_num(out, nan=np.nan, posinf=np.nan, neginf=np.nan) + return out + + def _set_calib_enabled(): + nonlocal calib_enabled, current_sweep_norm + try: + calib_enabled = bool(cb.get_status()[0]) if cb is not None else False + except Exception: + calib_enabled = False + if calib_enabled and current_sweep_raw is not None and last_calib_sweep is not None: + current_sweep_norm = _normalize_sweep(current_sweep_raw, last_calib_sweep) + else: + current_sweep_norm = None + # График фазы текущего свипа phase_line_obj, = ax_phase.plot([], [], lw=1) @@ -818,9 +892,11 @@ def main(): ax_smin = fig.add_axes([0.92, 0.55, 0.02, 0.35]) ax_smax = fig.add_axes([0.95, 0.55, 0.02, 0.35]) ax_sctr = fig.add_axes([0.98, 0.55, 0.02, 0.35]) + ax_cb = fig.add_axes([0.92, 0.45, 0.08, 0.08]) ymin_slider = Slider(ax_smin, "Y min", 0, max(1, fft_bins - 1), valinit=0, valstep=1, orientation="vertical") ymax_slider = Slider(ax_smax, "Y max", 0, max(1, fft_bins - 1), valinit=max(1, fft_bins - 1), valstep=1, orientation="vertical") contrast_slider = Slider(ax_sctr, "Int max", 0, 100, valinit=100, valstep=1, orientation="vertical") + cb = CheckButtons(ax_cb, ["калибровка"], [False]) def _on_ylim_change(_val): try: @@ -835,6 +911,7 @@ def main(): ymax_slider.on_changed(_on_ylim_change) # Контраст влияет на верхнюю границу цветовой шкалы (процент от авто-диапазона) contrast_slider.on_changed(lambda _v: fig.canvas.draw_idle()) + cb.on_clicked(lambda _v: _set_calib_enabled()) except Exception: pass @@ -843,6 +920,7 @@ def main(): interval_ms = int(1000.0 / max_fps) frames_since_ylim_update = 0 + def ensure_buffer(_w: int): nonlocal ring, width, head, x_shared, ring_fft, freq_shared, ring_time nonlocal ring_phase, prev_phase_per_bin, phase_offset_per_bin @@ -980,7 +1058,7 @@ def main(): y_max_phase = float(ph_max) def drain_queue(): - nonlocal current_sweep, current_info + nonlocal current_sweep_raw, current_sweep_norm, current_info, last_calib_sweep drained = 0 while True: try: @@ -988,10 +1066,26 @@ def main(): except Empty: break drained += 1 - current_sweep = s + current_sweep_raw = s current_info = info + ch = 0 + try: + ch = int(info.get("ch", 0)) if isinstance(info, dict) else 0 + except Exception: + ch = 0 + if ch == 0: + last_calib_sweep = s + current_sweep_norm = None + sweep_for_proc = s + else: + if calib_enabled and last_calib_sweep is not None: + current_sweep_norm = _normalize_sweep(s, last_calib_sweep) + sweep_for_proc = current_sweep_norm + else: + current_sweep_norm = None + sweep_for_proc = s ensure_buffer(s.size) - push_sweep(s) + push_sweep(sweep_for_proc) return drained def make_display_ring(): @@ -1007,6 +1101,24 @@ def main(): base_t = ring_time if head == 0 else np.roll(ring_time, -head) return base_t + def _subtract_recent_mean_fft(disp_fft: np.ndarray) -> np.ndarray: + """Вычесть среднее по каждой частоте за последние spec_mean_sec секунд.""" + if spec_mean_sec <= 0.0: + return disp_fft + disp_times = make_display_times() + if disp_times is None: + return disp_fft + now_t = time.time() + mask = np.isfinite(disp_times) & (disp_times >= (now_t - spec_mean_sec)) + if not np.any(mask): + return disp_fft + try: + mean_spec = np.nanmean(disp_fft[:, mask], axis=1) + except Exception: + return disp_fft + mean_spec = np.nan_to_num(mean_spec, nan=0.0) + return disp_fft - mean_spec[:, None] + def make_display_ring_fft(): if ring_fft is None: return np.zeros((1, 1), dtype=np.float32) @@ -1024,18 +1136,26 @@ def main(): changed = drain_queue() > 0 # Обновление линии последнего свипа - if current_sweep is not None: - if x_shared is not None and current_sweep.size <= x_shared.size: - xs = x_shared[: current_sweep.size] + if current_sweep_raw is not None: + if x_shared is not None and current_sweep_raw.size <= x_shared.size: + xs = x_shared[: current_sweep_raw.size] else: - xs = np.arange(current_sweep.size, dtype=np.int32) - line_obj.set_data(xs, current_sweep) + xs = np.arange(current_sweep_raw.size, dtype=np.int32) + line_obj.set_data(xs, current_sweep_raw) + if last_calib_sweep is not None: + line_calib_obj.set_data(xs[: last_calib_sweep.size], last_calib_sweep) + else: + line_calib_obj.set_data([], []) + if current_sweep_norm is not None: + line_norm_obj.set_data(xs[: current_sweep_norm.size], current_sweep_norm) + else: + line_norm_obj.set_data([], []) # Лимиты по X постоянные под текущую ширину - ax_line.set_xlim(0, max(1, current_sweep.size - 1)) + ax_line.set_xlim(0, max(1, current_sweep_raw.size - 1)) # Адаптивные Y-лимиты (если не задан --ylim) if fixed_ylim is None: - y0 = float(np.nanmin(current_sweep)) - y1 = float(np.nanmax(current_sweep)) + y0 = float(np.nanmin(current_sweep_raw)) + y1 = float(np.nanmax(current_sweep_raw)) if np.isfinite(y0) and np.isfinite(y1): if y0 == y1: pad = max(1.0, abs(y0) * 0.05) @@ -1047,11 +1167,12 @@ def main(): y1 += pad ax_line.set_ylim(y0, y1) - # Обновление спектра и фазы текущего свипа - take_fft = min(int(current_sweep.size), FFT_LEN) + # Обновление спектра текущего свипа + sweep_for_fft = current_sweep_norm if current_sweep_norm is not None else current_sweep_raw + take_fft = min(int(sweep_for_fft.size), FFT_LEN) if take_fft > 0 and freq_shared is not None: fft_in = np.zeros((FFT_LEN,), dtype=np.float32) - seg = np.nan_to_num(current_sweep[:take_fft], nan=0.0).astype(np.float32, copy=False) + seg = np.nan_to_num(sweep_for_fft[:take_fft], nan=0.0).astype(np.float32, copy=False) win = np.hanning(take_fft).astype(np.float32) fft_in[:take_fft] = seg * win spec = np.fft.rfft(fft_in) @@ -1101,6 +1222,7 @@ def main(): # Обновление водопада спектров if changed and ring_fft is not None: disp_fft = make_display_ring_fft() + disp_fft = _subtract_recent_mean_fft(disp_fft) # Новые данные справа: без реверса img_fft_obj.set_data(disp_fft) # Подписи времени не обновляем динамически (оставляем авто-тики) @@ -1153,9 +1275,33 @@ def main(): if changed and current_info: status_text.set_text(_format_status_kv(current_info)) + 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 + if chs is None: + channel_text.set_text("") + else: + try: + 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)) + channel_text.set_text(f"chs {ch_text_val}") + except Exception: + channel_text.set_text(f"chs {chs}") # Возвращаем обновлённые артисты - return (line_obj, img_obj, fft_line_obj, img_fft_obj, phase_line_obj, img_phase_obj, status_text) + return ( + line_obj, + line_calib_obj, + line_norm_obj, + img_obj, + fft_line_obj, + img_fft_obj, + status_text, + channel_text, + ) ani = FuncAnimation(fig, update, interval=interval_ms, blit=False) @@ -1201,8 +1347,13 @@ def run_pyqtgraph(args): 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)) + 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", "X") 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="Сырые данные водопад") @@ -1237,6 +1388,12 @@ def run_pyqtgraph(args): img_fft = pg.ImageItem() p_spec.addItem(img_fft) + # Чекбокс калибровки + calib_cb = QtWidgets.QCheckBox("калибровка") + cb_proxy = QtWidgets.QGraphicsProxyWidget() + cb_proxy.setWidget(calib_cb) + win.addItem(cb_proxy, row=2, col=1) + # График фазы (слева-снизу) p_phase = win.addPlot(row=2, col=0, title="Фаза спектра (развернутая)") p_phase.showGrid(x=True, y=True, alpha=0.3) @@ -1280,10 +1437,13 @@ def run_pyqtgraph(args): # Состояние ring: Optional[np.ndarray] = None + ring_time: Optional[np.ndarray] = None head = 0 width: Optional[int] = None x_shared: Optional[np.ndarray] = None - current_sweep: Optional[np.ndarray] = None + current_sweep_raw: Optional[np.ndarray] = None + current_sweep_norm: Optional[np.ndarray] = None + last_calib_sweep: Optional[np.ndarray] = None current_info: Optional[SweepInfo] = None # Авто-уровни цветовой шкалы водопада сырых данных пересчитываются по видимой области. # Для спектров @@ -1298,6 +1458,8 @@ def run_pyqtgraph(args): y_min_phase, y_max_phase = None, None # Параметры контраста водопада спектров (процентильная обрезка) spec_clip = _parse_spec_clip(getattr(args, "spec_clip", None)) + spec_mean_sec = float(getattr(args, "spec_mean_sec", 0.0)) + calib_enabled = False # Диапазон по Y: авто по умолчанию (поддерживает отрицательные значения) fixed_ylim: Optional[Tuple[float, float]] = None if args.ylim: @@ -1309,14 +1471,41 @@ def run_pyqtgraph(args): if fixed_ylim is not None: p_line.setYRange(fixed_ylim[0], fixed_ylim[1], padding=0) + def _normalize_sweep(raw: np.ndarray, calib: np.ndarray) -> np.ndarray: + w = min(raw.size, calib.size) + if w <= 0: + return raw + out = np.full_like(raw, np.nan, dtype=np.float32) + with np.errstate(divide="ignore", invalid="ignore"): + out[:w] = raw[:w] / calib[:w] + out = np.nan_to_num(out, nan=np.nan, posinf=np.nan, neginf=np.nan) + return out + + def _set_calib_enabled(): + nonlocal calib_enabled, current_sweep_norm + try: + calib_enabled = bool(calib_cb.isChecked()) + except Exception: + calib_enabled = False + if calib_enabled and current_sweep_raw is not None and last_calib_sweep is not None: + current_sweep_norm = _normalize_sweep(current_sweep_raw, last_calib_sweep) + else: + current_sweep_norm = None + + try: + calib_cb.stateChanged.connect(lambda _v: _set_calib_enabled()) + except Exception: + pass + def ensure_buffer(_w: int): - nonlocal ring, head, width, x_shared, ring_fft, freq_shared + nonlocal ring, ring_time, head, width, x_shared, ring_fft, freq_shared nonlocal ring_phase, prev_phase_per_bin, phase_offset_per_bin if ring is not None: return width = WF_WIDTH x_shared = np.arange(width, dtype=np.int32) ring = np.full((max_sweeps, width), np.nan, dtype=np.float32) + ring_time = np.full((max_sweeps,), np.nan, dtype=np.float64) head = 0 # Водопад: время по оси X, X по оси Y img.setImage(ring.T, autoLevels=False) @@ -1368,7 +1557,7 @@ def run_pyqtgraph(args): return (vmin, vmax) def push_sweep(s: np.ndarray): - nonlocal ring, head, ring_fft, y_min_fft, y_max_fft + nonlocal ring, ring_time, head, ring_fft, y_min_fft, y_max_fft nonlocal ring_phase, prev_phase_per_bin, phase_offset_per_bin, y_min_phase, y_max_phase if s is None or s.size == 0 or ring is None: return @@ -1377,6 +1566,8 @@ def run_pyqtgraph(args): take = min(w, s.size) row[:take] = s[:take] ring[head, :] = row + if ring_time is not None: + ring_time[head] = time.time() head = (head + 1) % ring.shape[0] # FFT строка (дБ) и фаза if ring_fft is not None: @@ -1428,7 +1619,7 @@ def run_pyqtgraph(args): y_max_phase = float(ph_max) def drain_queue(): - nonlocal current_sweep, current_info + nonlocal current_sweep_raw, current_sweep_norm, current_info, last_calib_sweep drained = 0 while True: try: @@ -1436,10 +1627,26 @@ def run_pyqtgraph(args): except Empty: break drained += 1 - current_sweep = s + current_sweep_raw = s current_info = info + ch = 0 + try: + ch = int(info.get("ch", 0)) if isinstance(info, dict) else 0 + except Exception: + ch = 0 + if ch == 0: + last_calib_sweep = s + current_sweep_norm = None + sweep_for_proc = s + else: + if calib_enabled and last_calib_sweep is not None: + current_sweep_norm = _normalize_sweep(s, last_calib_sweep) + sweep_for_proc = current_sweep_norm + else: + current_sweep_norm = None + sweep_for_proc = s ensure_buffer(s.size) - push_sweep(s) + push_sweep(sweep_for_proc) return drained # Попытка применить LUT из колормэпа (если доступен) @@ -1453,24 +1660,33 @@ def run_pyqtgraph(args): def update(): changed = drain_queue() > 0 - if current_sweep is not None and x_shared is not None: - if current_sweep.size <= x_shared.size: - xs = x_shared[: current_sweep.size] + if current_sweep_raw is not None and x_shared is not None: + if current_sweep_raw.size <= x_shared.size: + xs = x_shared[: current_sweep_raw.size] else: - xs = np.arange(current_sweep.size) - curve.setData(xs, current_sweep, autoDownsample=True) + xs = np.arange(current_sweep_raw.size) + curve.setData(xs, current_sweep_raw, autoDownsample=True) + if last_calib_sweep is not None: + curve_calib.setData(xs[: last_calib_sweep.size], last_calib_sweep, autoDownsample=True) + else: + curve_calib.setData([], []) + if current_sweep_norm is not None: + curve_norm.setData(xs[: current_sweep_norm.size], current_sweep_norm, autoDownsample=True) + else: + curve_norm.setData([], []) if fixed_ylim is None: - y0 = float(np.nanmin(current_sweep)) - y1 = float(np.nanmax(current_sweep)) + y0 = float(np.nanmin(current_sweep_raw)) + y1 = float(np.nanmax(current_sweep_raw)) 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) - # Обновим спектр и фазу - take_fft = min(int(current_sweep.size), FFT_LEN) + # Обновим спектр + sweep_for_fft = current_sweep_norm if current_sweep_norm is not None else current_sweep_raw + take_fft = min(int(sweep_for_fft.size), FFT_LEN) if take_fft > 0 and freq_shared is not None: fft_in = np.zeros((FFT_LEN,), dtype=np.float32) - seg = np.nan_to_num(current_sweep[:take_fft], nan=0.0).astype(np.float32, copy=False) + seg = np.nan_to_num(sweep_for_fft[:take_fft], nan=0.0).astype(np.float32, copy=False) win = np.hanning(take_fft).astype(np.float32) fft_in[:take_fft] = seg * win spec = np.fft.rfft(fft_in) @@ -1514,10 +1730,41 @@ def run_pyqtgraph(args): status.setText(_format_status_kv(current_info)) except Exception: pass + try: + 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 + 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 ring_fft is not None: disp_fft = ring_fft if head == 0 else np.roll(ring_fft, -head, axis=0) disp_fft = disp_fft.T[:, ::-1] + if spec_mean_sec > 0.0 and ring_time is not None: + disp_times = ring_time if head == 0 else np.roll(ring_time, -head) + disp_times = disp_times[::-1] + 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[:, mask], axis=1) + mean_spec = np.nan_to_num(mean_spec, nan=0.0) + disp_fft = disp_fft - mean_spec[:, None] + except Exception: + pass # Автодиапазон по среднему спектру за видимый интервал (как в хорошей версии) levels = None try: