last implement diff

This commit is contained in:
awe
2026-02-27 17:43:32 +03:00
parent 33e1976233
commit c199ab7f28
8 changed files with 265 additions and 34 deletions

Binary file not shown.

View File

@ -112,6 +112,10 @@ def run_matplotlib(args):
state = AppState(norm_type=norm_type)
state.configure_capture_import(fancy=bool(args.fancy), logscale=bool(getattr(args, "logscale", False)))
ring = RingBuffer(max_sweeps)
try:
ring.set_fft_complex_mode(str(getattr(args, "ifft_complex_mode", "arccos")))
except Exception:
pass
# --- Создание фигуры ---
fig, axs = plt.subplots(2, 2, figsize=(12, 8))
@ -181,17 +185,28 @@ def run_matplotlib(args):
ax_cb = fig.add_axes([0.92, 0.45, 0.08, 0.08])
ax_cb_file = fig.add_axes([0.92, 0.36, 0.08, 0.08])
ax_line_mode = fig.add_axes([0.92, 0.10, 0.08, 0.08])
ax_ifft_mode = fig.add_axes([0.92, 0.01, 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")
calib_cb = CheckButtons(ax_cb, ["калибровка"], [False])
calib_file_cb = CheckButtons(ax_cb_file, ["из файла"], [False])
line_mode_rb = RadioButtons(ax_line_mode, ("raw", "processed"), active=0)
ifft_mode_rb = RadioButtons(
ax_ifft_mode,
("arccos", "diff"),
active=(1 if ring.fft_complex_mode == "diff" else 0),
)
try:
ax_line_mode.set_title("Линия", fontsize=8, pad=2)
except Exception:
pass
try:
ax_ifft_mode.set_title("IFFT", fontsize=8, pad=2)
except Exception:
pass
line_mode_state = {"value": "raw"}
ifft_mode_state = {"value": str(ring.fft_complex_mode)}
import os as _os
try:
@ -280,7 +295,7 @@ def run_matplotlib(args):
pass
def _refresh_status_texts():
pipeline_text.set_text(state.format_pipeline_status())
pipeline_text.set_text(f"{state.format_pipeline_status()} | cplx:{ring.fft_complex_mode}")
ref_text.set_text(state.format_reference_status())
try:
fig.canvas.draw_idle()
@ -414,6 +429,18 @@ def run_matplotlib(args):
except Exception:
pass
def _on_ifft_mode_clicked(label):
ifft_mode_state["value"] = str(label)
try:
ring.set_fft_complex_mode(str(label))
except Exception:
pass
_refresh_status_texts()
try:
fig.canvas.draw_idle()
except Exception:
pass
save_bg_btn.on_clicked(_on_save_bg)
bg_cb.on_clicked(_on_bg_clicked)
calib_load_btn2.on_clicked(_on_calib_load_btn)
@ -425,6 +452,7 @@ def run_matplotlib(args):
bg_sample_btn2.on_clicked(_on_bg_sample_btn)
bg_save_btn2.on_clicked(_on_bg_save_btn2)
line_mode_rb.on_clicked(_on_line_mode_clicked)
ifft_mode_rb.on_clicked(_on_ifft_mode_clicked)
ymin_slider.on_changed(_on_ylim_change)
ymax_slider.on_changed(_on_ylim_change)
@ -437,6 +465,7 @@ def run_matplotlib(args):
except Exception:
calib_cb = None
line_mode_state = {"value": "raw"}
ifft_mode_state = {"value": str(getattr(ring, "fft_complex_mode", "arccos"))}
FREQ_MIN = float(FREQ_MIN_GHZ)
FREQ_MAX = float(FREQ_MAX_GHZ)
@ -602,10 +631,10 @@ def run_matplotlib(args):
if changed and state.current_info:
status_text.set_text(format_status(state.current_info))
channel_text.set_text(state.format_channel_label())
pipeline_text.set_text(state.format_pipeline_status())
pipeline_text.set_text(f"{state.format_pipeline_status()} | cplx:{ring.fft_complex_mode}")
ref_text.set_text(state.format_reference_status())
elif changed:
pipeline_text.set_text(state.format_pipeline_status())
pipeline_text.set_text(f"{state.format_pipeline_status()} | cplx:{ring.fft_complex_mode}")
ref_text.set_text(state.format_reference_status())
return (

View File

@ -140,6 +140,10 @@ def run_pyqtgraph(args):
state = AppState(norm_type=norm_type)
state.configure_capture_import(fancy=bool(args.fancy), logscale=bool(getattr(args, "logscale", False)))
ring = RingBuffer(max_sweeps)
try:
ring.set_fft_complex_mode(str(getattr(args, "ifft_complex_mode", "arccos")))
except Exception:
pass
try:
_qt_text_selectable = QtCore.Qt.TextSelectableByMouse
@ -445,6 +449,25 @@ def run_pyqtgraph(args):
def _line_mode() -> str:
return "processed" if line_mode_proc_rb.isChecked() else "raw"
# Переключатель режима реконструкции комплексного спектра перед IFFT
ifft_mode_widget = QtWidgets.QWidget()
ifft_mode_layout = QtWidgets.QHBoxLayout(ifft_mode_widget)
ifft_mode_layout.setContentsMargins(2, 2, 2, 2)
ifft_mode_layout.setSpacing(8)
ifft_mode_layout.addWidget(QtWidgets.QLabel("IFFT mode:"))
ifft_mode_arccos_rb = QtWidgets.QRadioButton("arccos")
ifft_mode_diff_rb = QtWidgets.QRadioButton("diff")
if ring.fft_complex_mode == "diff":
ifft_mode_diff_rb.setChecked(True)
else:
ifft_mode_arccos_rb.setChecked(True)
ifft_mode_layout.addWidget(ifft_mode_arccos_rb)
ifft_mode_layout.addWidget(ifft_mode_diff_rb)
ifft_mode_layout.addStretch(1)
ifft_mode_proxy = QtWidgets.QGraphicsProxyWidget()
ifft_mode_proxy.setWidget(ifft_mode_widget)
win.addItem(ifft_mode_proxy, row=7, col=0, colspan=2)
# Статусная строка
status = pg.LabelItem(justify="left")
win.addItem(status, row=3, col=0, colspan=2)
@ -455,12 +478,32 @@ def run_pyqtgraph(args):
def _refresh_pipeline_label():
txt = state.format_pipeline_status()
txt = f"{txt} | cplx:{ring.fft_complex_mode}"
trace = state.format_stage_trace()
if trace:
txt = f"{txt} | trace: {trace}"
pipeline_status.setText(txt)
ref_status.setText(state.format_reference_status())
def _apply_ifft_complex_mode(mode: str):
try:
changed = ring.set_fft_complex_mode(mode)
except Exception:
changed = False
if changed:
try:
curve_fft.setData([], [])
except Exception:
pass
_refresh_pipeline_label()
ifft_mode_arccos_rb.toggled.connect(
lambda checked: _apply_ifft_complex_mode("arccos") if checked else None
)
ifft_mode_diff_rb.toggled.connect(
lambda checked: _apply_ifft_complex_mode("diff") if checked else None
)
_refresh_calib_controls()
_refresh_bg_controls()
_refresh_pipeline_label()

View File

@ -77,6 +77,15 @@ def build_parser() -> argparse.ArgumentParser:
default="projector",
help="Тип нормировки: projector (по огибающим в [-1000,+1000]) или simple (raw/calib)",
)
parser.add_argument(
"--ifft-complex-mode",
choices=["arccos", "diff"],
default="arccos",
help=(
"Режим реконструкции комплексного спектра перед IFFT: "
"arccos (phi=arccos(x), unwrap) или diff (sin(phi) через численную производную)"
),
)
parser.add_argument(
"--bin",
dest="bin_mode",

View File

@ -1,12 +1,8 @@
"""Преобразование свипа в IFFT-профиль по глубине (м).
Новый pipeline перед IFFT:
1) нормировка по max(abs(sweep))
2) clip в [-1, 1]
3) phi = arccos(x)
4) непрерывная развёртка фазы (nearest-continuous)
5) s_complex = exp(1j * phi)
6) IFFT с учётом смещения частотной сетки
Поддерживает несколько режимов восстановления комплексного спектра перед IFFT:
- ``arccos``: phi = arccos(x), continuous unwrap, z = exp(1j*phi)
- ``diff``: x ~= cos(phi), diff(x) -> sin(phi), z = cos + 1j*sin (с проекцией на единичную окружность)
"""
from __future__ import annotations
@ -29,6 +25,7 @@ logger = logging.getLogger(__name__)
_EPS = 1e-12
_TWO_PI = float(2.0 * np.pi)
_VALID_COMPLEX_MODES = {"arccos", "diff"}
def _fallback_depth_response(
@ -58,6 +55,13 @@ def _fallback_depth_response(
return depth, out
def _normalize_complex_mode(mode: str) -> str:
m = str(mode).strip().lower()
if m not in _VALID_COMPLEX_MODES:
raise ValueError(f"Invalid complex reconstruction mode: {mode!r}")
return m
def build_ifft_time_axis_ns() -> np.ndarray:
"""Legacy helper: старая временная ось IFFT в наносекундах (фиксированная длина)."""
return (
@ -69,22 +73,27 @@ def build_frequency_axis_hz(sweep_width: int) -> np.ndarray:
"""Построить частотную сетку (Гц) для текущей длины свипа."""
n = int(sweep_width)
if n <= 0:
return np.zeros((0,), dtype=np.float128)
return np.zeros((0,), dtype=np.float64)
if n == 1:
return np.array([FREQ_MIN_GHZ * 1e9], dtype=np.float64)
return np.linspace(FREQ_MIN_GHZ * 1e9, FREQ_MAX_GHZ * 1e9, n, dtype=np.float64)
def normalize_sweep_for_phase(sweep: np.ndarray) -> np.ndarray:
"""Нормировать свип на max(abs(.)) и вернуть float64."""
x = np.asarray(sweep, dtype=np.float64).ravel()
if x.size == 0:
return x
x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
amax = float(np.max(np.abs(x)))
def normalize_trace_unit_range(x: np.ndarray) -> np.ndarray:
"""Signed-нормировка массива по max(abs(.)) в диапазон около [-1, 1]."""
arr = np.asarray(x, dtype=np.float64).ravel()
if arr.size == 0:
return arr
arr = np.nan_to_num(arr, nan=0.0, posinf=0.0, neginf=0.0)
amax = float(np.max(np.abs(arr)))
if (not np.isfinite(amax)) or amax <= _EPS:
return np.zeros_like(x, dtype=np.float64)
return x / amax
return np.zeros_like(arr, dtype=np.float64)
return arr / amax
def normalize_sweep_for_phase(sweep: np.ndarray) -> np.ndarray:
"""Совместимый alias: нормировка свипа перед восстановлением фазы."""
return normalize_trace_unit_range(sweep)
def unwrap_arccos_phase_continuous(x_norm: np.ndarray) -> np.ndarray:
@ -101,6 +110,7 @@ def unwrap_arccos_phase_continuous(x_norm: np.ndarray) -> np.ndarray:
phi0 = np.arccos(x)
out = np.empty_like(phi0, dtype=np.float64)
out[0] = float(phi0[0])
for i in range(1, phi0.size):
base_phi = float(phi0[i])
prev = float(out[i - 1])
@ -110,7 +120,6 @@ def unwrap_arccos_phase_continuous(x_norm: np.ndarray) -> np.ndarray:
for sign in (1.0, -1.0):
base = sign * base_phi
# Ищем ближайший сдвиг 2πk относительно prev именно для этой ветви.
k_center = int(np.round((prev - base) / _TWO_PI))
for k in (k_center - 1, k_center, k_center + 1):
cand = base + _TWO_PI * float(k)
@ -122,20 +131,55 @@ def unwrap_arccos_phase_continuous(x_norm: np.ndarray) -> np.ndarray:
best_cand = cand
out[i] = prev if best_cand is None else float(best_cand)
return out
return phi0
def reconstruct_complex_spectrum_from_real_trace(sweep: np.ndarray) -> np.ndarray:
"""Восстановить комплексный спектр из вещественного следа через arccos+Euler."""
x_norm = normalize_sweep_for_phase(sweep)
def reconstruct_complex_spectrum_arccos(sweep: np.ndarray) -> np.ndarray:
"""Режим arccos: cos(phi) -> phi -> exp(i*phi)."""
x_norm = normalize_trace_unit_range(sweep)
if x_norm.size == 0:
return np.zeros((0,), dtype=np.complex128)
x_norm = np.clip(x_norm, -1.0, 1.0)
phi = unwrap_arccos_phase_continuous(x_norm)
phi = unwrap_arccos_phase_continuous(np.clip(x_norm, -1.0, 1.0))
return np.exp(1j * phi).astype(np.complex128, copy=False)
def reconstruct_complex_spectrum_diff(sweep: np.ndarray) -> np.ndarray:
"""Режим diff: x~=cos(phi), diff(x)->sin(phi), z=cos+i*sin с проекцией на единичную окружность."""
cos_phi = normalize_trace_unit_range(sweep)
if cos_phi.size == 0:
return np.zeros((0,), dtype=np.complex128)
cos_phi = np.clip(cos_phi, -1.0, 1.0)
if cos_phi.size < 2:
sin_est = np.zeros_like(cos_phi, dtype=np.float64)
else:
d = np.gradient(cos_phi)
sin_est = normalize_trace_unit_range(d)
sin_est = np.clip(sin_est, -1.0, 1.0)
z = cos_phi.astype(np.complex128, copy=False) + 1j * sin_est.astype(np.complex128, copy=False)
mag = np.abs(z)
z_unit = np.ones_like(z, dtype=np.complex128)
mask = mag > _EPS
if np.any(mask):
z_unit[mask] = z[mask] / mag[mask]
return mag
def reconstruct_complex_spectrum_from_real_trace(
sweep: np.ndarray,
*,
complex_mode: str = "arccos",
) -> np.ndarray:
"""Восстановить комплексный спектр из вещественного свипа в выбранном режиме."""
mode = _normalize_complex_mode(complex_mode)
if mode == "arccos":
return reconstruct_complex_spectrum_arccos(sweep)
if mode == "diff":
return reconstruct_complex_spectrum_diff(sweep)
raise ValueError(f"Unsupported complex reconstruction mode: {complex_mode!r}")
def perform_ifft_depth_response(
s_array: np.ndarray,
frequencies_hz: np.ndarray,
@ -173,7 +217,6 @@ def perform_ifft_depth_response(
n = int(f.size)
if n < 2:
raise ValueError("Not enough frequency points after filtering")
if np.any(np.diff(f) <= 0.0):
raise ValueError("Non-increasing frequency grid")
@ -221,7 +264,11 @@ def perform_ifft_depth_response(
return _fallback_depth_response(np.asarray(s_array).size, np.asarray(s_array))
def compute_ifft_profile_from_sweep(sweep: Optional[np.ndarray]) -> tuple[np.ndarray, np.ndarray]:
def compute_ifft_profile_from_sweep(
sweep: Optional[np.ndarray],
*,
complex_mode: str = "arccos",
) -> tuple[np.ndarray, np.ndarray]:
"""Высокоуровневый pipeline: sweep -> complex spectrum -> IFFT(abs) depth profile."""
if sweep is None:
return _fallback_depth_response(1, None)
@ -232,12 +279,12 @@ def compute_ifft_profile_from_sweep(sweep: Optional[np.ndarray]) -> tuple[np.nda
return _fallback_depth_response(1, None)
freqs_hz = build_frequency_axis_hz(s.size)
s_complex = reconstruct_complex_spectrum_from_real_trace(s)
s_complex = reconstruct_complex_spectrum_from_real_trace(s, complex_mode=complex_mode)
depth_m, y = perform_ifft_depth_response(s_complex, freqs_hz, axis="abs")
n = min(depth_m.size, y.size)
if n <= 0:
return _fallback_depth_response(s.size, s)
return depth_m[:n].astype(np.float128, copy=False), y[:n].astype(np.float128, copy=False) *20
return depth_m[:n].astype(np.float32, copy=False), y[:n].astype(np.float32, copy=False) # log10 для лучшей визуализации в водопаде
except Exception as exc: # noqa: BLE001
logger.error("compute_ifft_profile_from_sweep failed: %r", exc)
return _fallback_depth_response(np.asarray(sweep).size if sweep is not None else 1, sweep)
@ -245,6 +292,6 @@ def compute_ifft_profile_from_sweep(sweep: Optional[np.ndarray]) -> tuple[np.nda
def compute_ifft_db_profile(sweep: Optional[np.ndarray]) -> np.ndarray:
"""Legacy wrapper (deprecated name): возвращает линейный |IFFT| профиль."""
_depth_m, y = compute_ifft_profile_from_sweep(sweep)
_depth_m, y = compute_ifft_profile_from_sweep(sweep, complex_mode="arccos")
return y

View File

@ -24,6 +24,7 @@ class RingBuffer:
self.max_sweeps = max_sweeps
# Размер IFFT-профиля теперь динамический и определяется по первому успешному свипу.
self.fft_bins = 0
self.fft_complex_mode: str = "arccos"
# Инициализируются при первом свипе (ensure_init)
self.ring: Optional[np.ndarray] = None # (max_sweeps, WF_WIDTH)
@ -47,6 +48,26 @@ class RingBuffer:
"""Legacy alias: старое имя поля (раньше было время в нс, теперь глубина в м)."""
return self.fft_depth_axis_m
def set_fft_complex_mode(self, mode: str) -> bool:
"""Выбрать режим реконструкции комплексного спектра для IFFT.
Возвращает True, если режим изменился (и FFT-буфер был сброшен).
"""
m = str(mode).strip().lower()
if m not in ("arccos", "diff"):
raise ValueError(f"Unsupported IFFT complex mode: {mode!r}")
if m == self.fft_complex_mode:
return False
self.fft_complex_mode = m
# Сбрасываем только FFT-зависимые структуры. Сырые ряды сохраняем.
self.ring_fft = None
self.fft_depth_axis_m = None
self.fft_bins = 0
self.last_fft_vals = None
self.y_min_fft = None
self.y_max_fft = None
return True
def ensure_init(self, sweep_width: int):
"""Инициализировать буферы при первом свипе. Повторные вызовы — no-op (кроме x_shared)."""
if self.ring is None:
@ -73,7 +94,10 @@ class RingBuffer:
self._push_fft(s)
def _push_fft(self, s: np.ndarray):
depth_axis_m, fft_row = compute_ifft_profile_from_sweep(s)
depth_axis_m, fft_row = compute_ifft_profile_from_sweep(
s,
complex_mode=self.fft_complex_mode,
)
fft_row = np.asarray(fft_row, dtype=np.float32).ravel()
depth_axis_m = np.asarray(depth_axis_m, dtype=np.float32).ravel()

View File

@ -0,0 +1,54 @@
import numpy as np
from rfg_adc_plotter.processing.fourier import (
compute_ifft_profile_from_sweep,
reconstruct_complex_spectrum_from_real_trace,
)
def test_reconstruct_complex_spectrum_arccos_mode_returns_complex128():
sweep = np.linspace(-3.0, 7.0, 128, dtype=np.float32)
z = reconstruct_complex_spectrum_from_real_trace(sweep, complex_mode="arccos")
assert z.dtype == np.complex128
assert z.shape == sweep.shape
assert np.all(np.isfinite(np.real(z)))
assert np.all(np.isfinite(np.imag(z)))
def test_reconstruct_complex_spectrum_diff_mode_returns_complex128():
sweep = np.linspace(-1.0, 1.0, 128, dtype=np.float32)
z = reconstruct_complex_spectrum_from_real_trace(sweep, complex_mode="diff")
assert z.dtype == np.complex128
assert z.shape == sweep.shape
assert np.all(np.isfinite(np.real(z)))
assert np.all(np.isfinite(np.imag(z)))
def test_reconstruct_complex_spectrum_diff_mode_projects_to_unit_circle():
sweep = np.sin(np.linspace(0.0, 6.0 * np.pi, 256)).astype(np.float32)
z = reconstruct_complex_spectrum_from_real_trace(sweep, complex_mode="diff")
mag = np.abs(z)
assert np.all(np.isfinite(mag))
assert np.allclose(mag, np.ones_like(mag), atol=1e-5, rtol=0.0)
def test_compute_ifft_profile_from_sweep_accepts_both_modes():
sweep = np.linspace(-5.0, 5.0, 257, dtype=np.float32)
d1, y1 = compute_ifft_profile_from_sweep(sweep, complex_mode="arccos")
d2, y2 = compute_ifft_profile_from_sweep(sweep, complex_mode="diff")
assert d1.dtype == np.float32 and y1.dtype == np.float32
assert d2.dtype == np.float32 and y2.dtype == np.float32
assert d1.size == y1.size and d2.size == y2.size
assert d1.size > 0 and d2.size > 0
assert np.all(np.diff(d1) >= 0.0)
assert np.all(np.diff(d2) >= 0.0)
def test_invalid_complex_mode_falls_back_deterministically_in_outer_wrapper():
sweep = np.linspace(-1.0, 1.0, 64, dtype=np.float32)
depth, y = compute_ifft_profile_from_sweep(sweep, complex_mode="unknown")
assert depth.dtype == np.float32
assert y.dtype == np.float32
assert depth.size == y.size
assert depth.size > 0

View File

@ -38,3 +38,28 @@ def test_ring_buffer_reallocates_fft_buffers_when_ifft_length_changes():
assert second_shape == (ring.max_sweeps, second_bins)
assert ring.fft_depth_axis_m is not None
assert ring.fft_depth_axis_m.size == second_bins
def test_ring_buffer_mode_switch_resets_fft_buffers_only():
ring = RingBuffer(max_sweeps=4)
ring.ensure_init(128)
ring.push(np.linspace(-1.0, 1.0, 128, dtype=np.float32))
assert ring.ring is not None
assert ring.ring_fft is not None
raw_before = ring.ring.copy()
changed = ring.set_fft_complex_mode("diff")
assert changed is True
assert ring.fft_complex_mode == "diff"
assert ring.ring is not None
assert np.array_equal(ring.ring, raw_before, equal_nan=True)
assert ring.ring_fft is None
assert ring.fft_depth_axis_m is None
assert ring.last_fft_vals is None
assert ring.fft_bins == 0
ring.push(np.linspace(-1.0, 1.0, 128, dtype=np.float32))
assert ring.ring_fft is not None
assert ring.fft_depth_axis_m is not None
assert ring.last_fft_vals is not None