653 lines
28 KiB
Python
653 lines
28 KiB
Python
"""
|
||
MIMO GPR — локализация через пересечение эллипсов
|
||
==================================================
|
||
|
||
Физика в двух словах:
|
||
Пик A-скана пары (Tx_i, Rx_j) на задержке τ означает:
|
||
|Tx → объект| + |объект → Rx| = v · τ
|
||
Это уравнение эллипса. Истинный отражатель лежит на
|
||
пересечении всех 16 эллипсов (по одному на пару).
|
||
|
||
Алгоритм:
|
||
1. S(f) → IFFT → 16 A-сканов
|
||
2. Поиск пиков: SNR = пик / медиана > порог
|
||
3. Для каждого пика → мягкий эллипс в аккумуляторе
|
||
(с компенсацией геометрического и углового затухания)
|
||
4. CLEAN: найти максимум → убрать его эллипсы → повторить
|
||
|
||
О параметре SHELL_SIGMA:
|
||
Аккумулятор — это «мягкое голосование». Каждый эллипс добавляет
|
||
не единицу, а гауссово-взвешенный вклад:
|
||
w = exp(−δ²/2σ²), где δ = |R_Tx + R_Rx − v·τ|
|
||
SHELL_SIGMA — ширина этой гауссовой оболочки.
|
||
Слишком широко → ghost-цели не подавляются.
|
||
Слишком узко → вклад падает до нуля из-за дискретности сетки.
|
||
Оптимум: ~ 0.4 × δZ, где δZ = v/(2B) — разрешение по глубине.
|
||
|
||
О score:
|
||
score = количество пар (из 16), чей эллипс проходит
|
||
через данную точку с невязкой δ < 3σ.
|
||
Принимает целые значения от 0 до 16.
|
||
Максимальный score у истинного объекта = 16 (все пары согласны).
|
||
Ghost-цели имеют меньший score, т.к. согласуются только
|
||
с частью пар.
|
||
|
||
О компенсации затухания:
|
||
При генерации S(f) сигнал ослаблен:
|
||
geo(i,j) = 1/(R_Tx · R_Rx) — геометрическое ослабление
|
||
pat(i,j) = cos²(θ_Tx)·cos²(θ_Rx) — диаграмма направленности
|
||
Без компенсации глубокий/угловой отражатель будет недооценён.
|
||
Компенсация: делим вес каждого пика на ожидаемое затухание
|
||
в точке z_apparent, вычисленное для данной пары антенн.
|
||
|
||
Геометрия: плоскость XZ (X — вдоль антенн, Z — глубина).
|
||
"""
|
||
|
||
"""
|
||
MIMO GPR — локализация через пересечение эллипсов
|
||
==================================================
|
||
Версия для реальных данных
|
||
"""
|
||
|
||
import numpy as np
|
||
import matplotlib.pyplot as plt
|
||
from scipy.signal import find_peaks
|
||
from scipy.ndimage import gaussian_filter, label
|
||
from pathlib import Path
|
||
|
||
### Изменяемые параметры
|
||
INPUT_IDX = [0,1,2,3]
|
||
OUTPUT_IDX = [0,3]
|
||
|
||
MIN_DEPTH = 2.0 # [м] пропустить прямую волну
|
||
MAX_DEPTH = 14.0
|
||
COMP_POWER = 0.2 # степень компенсации затухания
|
||
|
||
F_START = 30*1e8 # Нижняя частота
|
||
F_STOP = 6*1e9 # Верхняя частота
|
||
|
||
# Вычитание среднего фона (background removal)
|
||
|
||
BG_SUBTRACT = True #True/False
|
||
BG_PATH = Path('/Users/ivan_root/Downloads/Telegram_dwnld/03-13-measure/2_cylinder_dif_side_and_mushrooms/preprocessed')
|
||
|
||
DATA_PATH = Path('/Users/ivan_root/Downloads/Telegram_dwnld/03-13-measure/2_cylinder_dif_side_and_mushrooms/preprocessed/0006_id1_ns1875848749103')
|
||
|
||
|
||
###Конфиги
|
||
|
||
MODE = 'point' # Один из 2х режимов `point` or `extended`
|
||
|
||
eps_r = 1.0 # <-- Диэлектрическая проницаемость среды
|
||
v = 3e8 / np.sqrt(eps_r) # скорость света в среде
|
||
|
||
|
||
# КООРДИНАТЫ АНТЕНН
|
||
# Координаты вдоль оси X на поверхности (z = 0), в метрах
|
||
|
||
# Физические координаты антенн по их реальным индексам
|
||
TX_POSITIONS = {0: 90.5*0.01, # Tx с индексом 0 → x = +0.9 м
|
||
3: -90.5*0.01} # Tx с индексом 3 → x = -0.9 м
|
||
|
||
RX_POSITIONS = {0: -18*0.01,
|
||
1: 48.5*0.01,
|
||
2: -49.0*0.01,
|
||
3: 18.5*0.01}
|
||
|
||
# Массивы позиций в порядке возрастания физических индексов
|
||
x_tx = np.array([TX_POSITIONS[i] for i in sorted(TX_POSITIONS)])
|
||
x_rx = np.array([RX_POSITIONS[j] for j in sorted(RX_POSITIONS)])
|
||
|
||
# Параметры алгоритма
|
||
SNR_THRESH = 3.0 # минимальный SNR пика
|
||
SNR_COMP_MAX = 20.0 # Верхний порог для компенсированного значения SNR
|
||
|
||
# Параметр: максимальное число объектов для поиска
|
||
MAX_OBJECTS = 15 # <-- настройте под вашу задачу
|
||
|
||
# Границы сетки аккумулятора
|
||
x_min, x_max = x_tx.min() - 2.0, x_tx.max() + 2.0 # [м]
|
||
z_min, z_max = 0.10, MAX_DEPTH # <-- глубина [м]
|
||
|
||
|
||
|
||
# ══════════════════════════════════════════════════════
|
||
# 1. ЗАГРУЗКА РЕАЛЬНЫХ ДАННЫХ
|
||
# ══════════════════════════════════════════════════════
|
||
|
||
def load_mimo_data(data_path, input_idx, output_idx):
|
||
data_path = Path(data_path)
|
||
s21_data = {}
|
||
freq_data = {}
|
||
|
||
for f in data_path.glob("i*_o*_s21.npy"):
|
||
name = f.stem
|
||
parts = name.split('_')
|
||
i_tx_phys = int(parts[1][1:])
|
||
i_rx_phys = int(parts[0][1:])
|
||
|
||
if i_tx_phys not in output_idx or i_rx_phys not in input_idx:
|
||
continue
|
||
|
||
# Переводим физический индекс → порядковый (0,1,2,...)
|
||
i_tx = sorted(output_idx).index(i_tx_phys)
|
||
i_rx = sorted(input_idx).index(i_rx_phys)
|
||
|
||
s21_data[(i_tx, i_rx)] = np.load(f)
|
||
freq_file = data_path / f"i{i_rx_phys}_o{i_tx_phys}_freq.npy"
|
||
freq_data[(i_tx, i_rx)] = np.load(freq_file)
|
||
|
||
tx_indices = sorted(set(k[0] for k in s21_data))
|
||
rx_indices = sorted(set(k[1] for k in s21_data))
|
||
n_tx = len(tx_indices)
|
||
n_rx = len(rx_indices)
|
||
|
||
return s21_data, freq_data, n_tx, n_rx
|
||
|
||
|
||
# Загрузка данных
|
||
s21_data, freq_data, N_tx, N_rx = load_mimo_data(DATA_PATH, INPUT_IDX, OUTPUT_IDX)
|
||
N_pairs = len(s21_data)
|
||
|
||
# ══════════════════════════════════════════════════════
|
||
# 1б. ВЫЧИСЛЕНИЕ СРЕДНЕГО ФОНА ПО ВСЕМ СНИМКАМ
|
||
# ══════════════════════════════════════════════════════
|
||
|
||
def compute_background(bg_path, input_idx, output_idx):
|
||
"""
|
||
Для каждой пары (i_tx, i_rx) усредняем S21 по всем снимкам в папке.
|
||
|
||
Возвращает:
|
||
bg : dict[(i_tx, i_rx)] → np.array (complex), усреднённый S21
|
||
"""
|
||
bg_path = Path(bg_path)
|
||
snapshots = sorted(bg_path.glob("*/")) # каждый подкаталог — один снимок
|
||
snapshots = [s for s in snapshots if s.is_dir()]
|
||
|
||
if len(snapshots) == 0:
|
||
print("⚠️ Снимков для фона не найдено, BG_SUBTRACT отключён.")
|
||
return None
|
||
|
||
|
||
# Накопитель: для каждой пары суммируем S21
|
||
bg_sum = {}
|
||
bg_count = {}
|
||
|
||
for snap_dir in snapshots:
|
||
for f in snap_dir.glob("i*_o*_s21.npy"):
|
||
name = f.stem
|
||
parts = name.split('_')
|
||
i_tx_phys = int(parts[1][1:])
|
||
i_rx_phys = int(parts[0][1:])
|
||
|
||
if i_tx_phys not in output_idx or i_rx_phys not in input_idx:
|
||
continue
|
||
|
||
i_tx = sorted(output_idx).index(i_tx_phys)
|
||
i_rx = sorted(input_idx).index(i_rx_phys)
|
||
key = (i_tx, i_rx)
|
||
|
||
s21 = np.load(f)
|
||
if key not in bg_sum:
|
||
bg_sum[key] = np.zeros_like(s21, dtype=complex)
|
||
bg_count[key] = 0
|
||
bg_sum[key] += s21
|
||
bg_count[key] += 1
|
||
|
||
bg = {key: bg_sum[key] / bg_count[key] for key in bg_sum}
|
||
# print(f"готово. Пар: {len(bg)}, снимков на пару: "
|
||
# f"{list(bg_count.values())[0] if bg_count else 0}")
|
||
return bg
|
||
|
||
|
||
if BG_SUBTRACT:
|
||
background = compute_background(BG_PATH, INPUT_IDX, OUTPUT_IDX)
|
||
if background is None:
|
||
BG_SUBTRACT = False # автоматически выключаем если нет данных
|
||
else:
|
||
background = None
|
||
# print("BG_SUBTRACT = False, вычитание фона отключено.")
|
||
|
||
|
||
# Проверка частот (берём из первой пары как референс)
|
||
first_key = list(freq_data.keys())[0]
|
||
freqs = freq_data[first_key]
|
||
|
||
|
||
|
||
# Проверим что частоты одинаковые для всех пар
|
||
for key, freq in freq_data.items():
|
||
if not np.allclose(freq, freqs):
|
||
print(f"⚠️ Частоты для пары {key} отличаются!")
|
||
|
||
|
||
|
||
mask_freq = (freqs >= F_START) & (freqs <= F_STOP)
|
||
|
||
freqs = freqs[mask_freq]
|
||
|
||
f_min, f_max = freqs[0], freqs[-1]
|
||
BW = f_max - f_min
|
||
N_f = len(freqs)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════
|
||
# 2. ПАРАМЕТРЫ СИСТЕМЫ
|
||
# ══════════════════════════════════════════════════════
|
||
|
||
# Проверка соответствия координатов антенн
|
||
assert len(x_tx) == N_tx, f"x_tx должен содержать {N_tx} элементов"
|
||
assert len(x_rx) == N_rx, f"x_rx должен содержать {N_rx} элементов"
|
||
|
||
|
||
|
||
# SHELL_SIGMA — ширина гауссовой оболочки
|
||
SHELL_SIGMA = v / BW * 0.5 # [м]
|
||
|
||
|
||
# Сетка аккумулятора
|
||
|
||
x_grid = np.linspace(x_min, x_max, 300)
|
||
z_grid = np.linspace(z_min, z_max, 300)
|
||
XX, ZZ = np.meshgrid(x_grid, z_grid)
|
||
|
||
# Расстояния от сетки до каждой антенны
|
||
R_tx_grid = {i: np.sqrt((XX - x_tx[i])**2 + ZZ**2) for i in range(N_tx)}
|
||
R_rx_grid = {j: np.sqrt((XX - x_rx[j])**2 + ZZ**2) for j in range(N_rx)}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════
|
||
# 3. ВЫЧИСЛЕНИЕ A-СКАНОВ ИЗ РЕАЛЬНЫХ S21
|
||
# ══════════════════════════════════════════════════════
|
||
|
||
def compute_ascan(s21, freq, f_start, f_stop, window=True):
|
||
"""
|
||
S21(f) → IFFT → A-скан с правильным частотным сдвигом.
|
||
|
||
Проблема наивного подхода (buf[:n] = s21):
|
||
IFFT считает, что спектр начинается с 0 Гц.
|
||
Реальные данные начинаются с f[0] > 0, поэтому
|
||
нулевая задержка смещается и в A-скане появляются биения.
|
||
|
||
Правильный подход — сдвиг спектра:
|
||
Шаг частотной сетки df вычисляется из данных.
|
||
Индекс первой частоты: k0 = round(f[0] / df).
|
||
Данные кладутся в H[k0 : k0+n], а не в H[0 : n].
|
||
Тогда IFFT корректно восстанавливает временной сигнал
|
||
с нулевой задержкой в t=0.
|
||
|
||
Размер FFT:
|
||
Минимум для покрытия всего диапазона [0, f[-1]]:
|
||
min_len = 2 * (k0 + n - 1)
|
||
Округляем вверх до степени двойки для скорости FFT.
|
||
"""
|
||
mask_freq_ = (freq >= f_start) & (freq <= f_stop)
|
||
freq = freq[mask_freq_]
|
||
s21 = s21[mask_freq_]
|
||
|
||
n = len(freq)
|
||
if n < 2:
|
||
raise ValueError("Слишком мало частотных точек")
|
||
|
||
# Шаг частотной сетки
|
||
df = (freq[-1] - freq[0]) / (n - 1)
|
||
if df <= 0:
|
||
raise ValueError("Частоты не возрастают")
|
||
|
||
# Индекс первой частоты в полной сетке от 0 до f[-1]
|
||
k0 = int(np.round(freq[0] / df))
|
||
|
||
# Минимальный размер FFT, округлённый до степени двойки
|
||
min_len = 2 * (k0 + n - 1)
|
||
n_fft = 1 << int(np.ceil(np.log2(min_len)))
|
||
|
||
|
||
# Временна́я ось — пересчитываем из нового n_fft
|
||
dt = 1.0 / (n_fft * df)
|
||
t_sec = np.arange(n_fft, dtype=float) * dt
|
||
|
||
# Оконная функция (подавление боковых лепестков IFFT)
|
||
s = s21 * np.hanning(n) if window else s21.copy()
|
||
|
||
# Спектр со сдвигом: данные на своём месте в частотной сетке
|
||
H = np.zeros(n_fft, dtype=np.complex128)
|
||
H[k0 : k0 + n] = s
|
||
|
||
y = np.abs(np.fft.ifft(H))
|
||
|
||
|
||
return t_sec[:y.size], y[:y.size]
|
||
|
||
|
||
|
||
A = {} # A[(i,j)] — амплитудный A-скан
|
||
T_h = {} # T_h[(i,j)] — временна́я ось для этой пары [с]
|
||
Z_h = {} # Z_h[(i,j)] — ось глубины [м]
|
||
|
||
for (i, j), s21 in s21_data.items():
|
||
s21_proc = s21.copy()
|
||
|
||
# Вычитание фона в частотной области
|
||
if BG_SUBTRACT and background is not None and (i, j) in background:
|
||
s21_proc = s21_proc - background[(i, j)]
|
||
# Примечание: вычитаем до обрезки по частоте и до окна —
|
||
# фон вычисляется из полных (необрезанных) данных,
|
||
# поэтому вычитание корректно в полном частотном диапазоне.
|
||
|
||
t_pair, a_pair = compute_ascan(s21_proc, freq_data[(i, j)],
|
||
f_start=F_START, f_stop=F_STOP)
|
||
T_h[(i, j)] = t_pair
|
||
Z_h[(i, j)] = t_pair * v / 2
|
||
A[(i, j)] = a_pair
|
||
|
||
bg_label = "с вычитанием фона" if BG_SUBTRACT else "без вычитания фона"
|
||
|
||
|
||
|
||
# Общая ось z для визуализации и поиска пиков
|
||
# (берём максимальный диапазон по всем парам)
|
||
z_h = Z_h[list(Z_h.keys())[0]] # все пары дают одинаковую ось, если freq совпадают
|
||
t_h = T_h[list(T_h.keys())[0]]
|
||
|
||
|
||
# 4. ВИЗУАЛИЗАЦИЯ A-СКАНОВ
|
||
|
||
|
||
def plot_ascans(A, z_h, n_tx, n_rx):
|
||
"""Отображение всех A-сканов"""
|
||
fig, axes = plt.subplots(n_tx, n_rx, figsize=(3*n_rx, 3*n_tx),
|
||
sharex=True, sharey=True)
|
||
|
||
if n_tx == 1:
|
||
axes = axes.reshape(1, -1)
|
||
if n_rx == 1:
|
||
axes = axes.reshape(-1, 1)
|
||
|
||
for i in range(n_tx):
|
||
for j in range(n_rx):
|
||
ax = axes[i, j]
|
||
if (i, j) in A:
|
||
ax.plot(z_h, A[(i, j)], 'b-', lw=0.8)
|
||
ax.set_title(f'Tx{i} → Rx{j}', fontsize=10)
|
||
ax.grid(True, alpha=0.3)
|
||
else:
|
||
ax.set_visible(False)
|
||
|
||
axes[-1, 0].set_xlabel('Глубина z [м]')
|
||
axes[0, 0].set_ylabel('Амплитуда')
|
||
fig.suptitle('A-сканы всех пар Tx-Rx', fontsize=12)
|
||
plt.tight_layout()
|
||
plt.show()
|
||
|
||
|
||
def plot_bscan(A, z_h, x_tx, x_rx):
|
||
"""B-скан: все A-сканы рядом, отсортированные по виртуальной позиции"""
|
||
pairs = sorted(A.keys(), key=lambda p: (x_tx[p[0]] + x_rx[p[1]]) / 2)
|
||
|
||
bscan = np.array([A[p] for p in pairs]).T
|
||
x_virt = [(x_tx[p[0]] + x_rx[p[1]]) / 2 for p in pairs]
|
||
|
||
plt.figure(figsize=(10, 6))
|
||
plt.imshow(bscan, aspect='auto', origin='lower',
|
||
extent=[min(x_virt), max(x_virt), z_h[0], z_h[-1]],
|
||
cmap='jet')
|
||
plt.colorbar(label='Амплитуда')
|
||
plt.xlabel('Виртуальная позиция X [м]')
|
||
plt.ylabel('Глубина Z [м]')
|
||
plt.title('B-скан (все пары)')
|
||
plt.show()
|
||
|
||
|
||
|
||
# ══════════════════════════════════════════════════════
|
||
# 5. ДЕТЕКТИРОВАНИЕ ПИКОВ
|
||
# ══════════════════════════════════════════════════════
|
||
|
||
def attenuation_at_depth(i_tx, i_rx, z_app):
|
||
"""
|
||
Ожидаемое ослабление geo·pattern для точки прямо под виртуальным
|
||
центром пары на глубине z_app.
|
||
|
||
Используется для компенсации: реальный SNR пика делится на это
|
||
значение, чтобы вес глубокого/углового объекта не занижался.
|
||
"""
|
||
xc = (x_tx[i_tx] + x_rx[i_rx]) / 2.0 # виртуальный центр
|
||
Rtx = np.sqrt((xc - x_tx[i_tx])**2 + z_app**2)
|
||
Rrx = np.sqrt((xc - x_rx[i_rx])**2 + z_app**2)
|
||
geo = 1.0 / (Rtx * Rrx + 1e-12)
|
||
pat = (z_app / (Rtx + 1e-12))**2 * (z_app / (Rrx + 1e-12))**2
|
||
return geo * pat + 1e-30 # +ε чтобы не делить на ноль
|
||
|
||
|
||
def find_peaks_snr(i_tx, i_rx, SNR_COMP_MAX = SNR_COMP_MAX):
|
||
"""
|
||
Поиск пиков A-скана.
|
||
Возвращает список dict:
|
||
z_app — кажущаяся глубина [м]
|
||
tau — задержка [с]
|
||
snr_raw — SNR без компенсации = пик / медиана
|
||
snr_comp— SNR с компенсацией ослабления (используется в аккумуляторе)
|
||
"""
|
||
ascan = A[(i_tx, i_rx)]
|
||
z_h_ij = Z_h[(i_tx, i_rx)]
|
||
t_h_ij = T_h[(i_tx, i_rx)]
|
||
noise = np.median(ascan)
|
||
i_min = np.searchsorted(z_h_ij, MIN_DEPTH)
|
||
i_max = np.searchsorted(z_h_ij, MAX_DEPTH)
|
||
min_dist = max(4, int(v / (2*BW) / (z_h_ij[1] - z_h_ij[0]) * 0.7))
|
||
idx, _ = find_peaks(ascan[i_min:i_max],
|
||
height=noise * SNR_THRESH,
|
||
distance=min_dist)
|
||
idx += i_min
|
||
result = []
|
||
for p in idx:
|
||
z_app = float(z_h_ij[p])
|
||
snr_raw = float(ascan[p] / noise)
|
||
atten = attenuation_at_depth(i_tx, i_rx, z_app)
|
||
atten_norm = atten / attenuation_at_depth(i_tx, i_rx, 2.0) # Референсная глубина - 2м
|
||
snr_comp = snr_raw / (atten_norm ** COMP_POWER + 1e-12)
|
||
snr_comp = min(snr_comp, SNR_COMP_MAX) # ← clipping
|
||
result.append({'z_app': z_app,
|
||
'tau': float(t_h_ij[p]),
|
||
'snr_raw': snr_raw,
|
||
'snr_comp': snr_comp})
|
||
return result
|
||
|
||
|
||
peaks = {(i, j): find_peaks_snr(i, j)
|
||
for i in range(N_tx) for j in range(N_rx)}
|
||
n_total = sum(len(v2) for v2 in peaks.values())
|
||
|
||
# ══════════════════════════════════════════════════════
|
||
# 5. АККУМУЛЯТОР
|
||
# ══════════════════════════════════════════════════════
|
||
|
||
def build_accumulator(exclude_z_ranges):
|
||
"""
|
||
Для каждого пика строим гауссову оболочку вокруг эллипса.
|
||
"""
|
||
acc = np.zeros_like(XX)
|
||
for i in range(N_tx):
|
||
for j in range(N_rx):
|
||
for pk in peaks[(i, j)]:
|
||
if any(lo <= pk['z_app'] <= hi for lo, hi in exclude_z_ranges):
|
||
continue
|
||
R_total = v * pk['tau']
|
||
residual = R_tx_grid[i] + R_rx_grid[j] - R_total
|
||
shell = np.exp(-0.5 * (residual / SHELL_SIGMA)**2)
|
||
acc += shell * pk['snr_comp'] # Это и есть метрика на карте накопления
|
||
return acc
|
||
|
||
|
||
def count_agreeing_ellipses(x_est, z_est, exclude_z_ranges):
|
||
"""
|
||
Score = количество пар, чей эллипс проходит
|
||
через точку (x_est, z_est) с невязкой δ < 3σ.
|
||
"""
|
||
count = 0
|
||
for i in range(N_tx):
|
||
for j in range(N_rx):
|
||
for pk in peaks[(i, j)]:
|
||
if any(lo <= pk['z_app'] <= hi for lo, hi in exclude_z_ranges):
|
||
continue
|
||
Rt = np.sqrt((x_est - x_tx[i])**2 + z_est**2)
|
||
Rr = np.sqrt((x_est - x_rx[j])**2 + z_est**2)
|
||
if abs(Rt + Rr - v*pk['tau']) < SHELL_SIGMA * 6:
|
||
count += 1
|
||
break # одна пара — один голос
|
||
return count
|
||
|
||
# ══════════════════════════════════════════════════════
|
||
# 6. ПОИСК ОБЪЕКТОВ
|
||
# ══════════════════════════════════════════════════════
|
||
|
||
def find_centroid(acc_s, iz, ix, rpz, rpx):
|
||
"""
|
||
Взвешенный центроид аккумулятора в окрестности (iz, ix).
|
||
"""
|
||
NZ, NX = acc_s.shape
|
||
iz0 = max(0, iz - rpz); iz1 = min(NZ, iz + rpz)
|
||
ix0 = max(0, ix - rpx); ix1 = min(NX, ix + rpx)
|
||
patch = acc_s[iz0:iz1, ix0:ix1].copy()
|
||
W = patch.sum()
|
||
if W <= 0:
|
||
return x_grid[ix], z_grid[iz]
|
||
rows = np.arange(iz0, iz1)[:, None] * np.ones(patch.shape)
|
||
cols = np.ones(patch.shape) * np.arange(ix0, ix1)[None, :]
|
||
iz_c = int(round(np.clip((rows * patch).sum() / W, 0, NZ-1)))
|
||
ix_c = int(round(np.clip((cols * patch).sum() / W, 0, NX-1)))
|
||
return x_grid[ix_c], z_grid[iz_c]
|
||
|
||
|
||
def clean_find(n_search=10, suppress_r_cm=7, thresh_frac=0.05):
|
||
"""
|
||
CLEAN-итерация для точечных объектов.
|
||
"""
|
||
dx = x_grid[1] - x_grid[0]
|
||
dz = z_grid[1] - z_grid[0]
|
||
rpx = int(suppress_r_cm / 100 / dx)
|
||
rpz = int(suppress_r_cm / 100 / dz)
|
||
|
||
excl_z = []
|
||
found = []
|
||
acc_initial = build_accumulator([])
|
||
|
||
for step in range(n_search):
|
||
acc = build_accumulator(excl_z)
|
||
acc_s = gaussian_filter(acc, sigma=3)
|
||
|
||
if acc_s.max() < thresh_frac * acc_initial.max():
|
||
break
|
||
|
||
iz, ix = np.unravel_index(acc_s.argmax(), acc_s.shape)
|
||
x_est, z_est = find_centroid(acc_s, iz, ix, rpz, rpx)
|
||
|
||
score = count_agreeing_ellipses(x_est, z_est, excl_z)
|
||
found.append({'x': x_est, 'z': z_est, 'score': score})
|
||
|
||
# Пики, соответствующие этому объекту
|
||
matched = [pk['z_app']
|
||
for i in range(N_tx) for j in range(N_rx)
|
||
for pk in peaks[(i, j)]
|
||
if not any(lo<=pk['z_app']<=hi for lo,hi in excl_z)
|
||
and abs(np.sqrt((x_est-x_tx[i])**2+z_est**2) +
|
||
np.sqrt((x_est-x_rx[j])**2+z_est**2) -
|
||
v*pk['tau']) < SHELL_SIGMA * 3] #Возможны изменения
|
||
|
||
if matched:
|
||
margin = SHELL_SIGMA * 1.0
|
||
excl_z.append((min(matched) - margin, max(matched) + margin))
|
||
|
||
return found, acc_initial
|
||
|
||
|
||
def extended_find(thresh_frac=0.75, min_area_cm2=2.0):
|
||
"""
|
||
Режим для протяжённых объектов (труба, плита и т.п.).
|
||
"""
|
||
acc = build_accumulator([])
|
||
acc_s = gaussian_filter(acc, sigma=3)
|
||
binary = acc_s > thresh_frac * acc_s.max()
|
||
|
||
dx = (x_grid[1]-x_grid[0])*100
|
||
dz = (z_grid[1]-z_grid[0])*100
|
||
min_pix = int(min_area_cm2 / (dx * dz))
|
||
|
||
labeled, n = label(binary)
|
||
regions = []
|
||
for k in range(1, n+1):
|
||
mask = labeled == k
|
||
if mask.sum() < min_pix:
|
||
continue
|
||
w = acc_s[mask]
|
||
xs = XX[mask]; zs = ZZ[mask]
|
||
xc = (xs * w).sum() / w.sum()
|
||
zc = (zs * w).sum() / w.sum()
|
||
score = count_agreeing_ellipses(xc, zc, [])
|
||
regions.append({'x': xc, 'z': zc, 'score': score,
|
||
'mask': mask, 'n_pix': mask.sum()})
|
||
return regions, acc
|
||
|
||
|
||
|
||
if MODE == 'point':
|
||
found, accum = clean_find(n_search=MAX_OBJECTS)
|
||
# print(f"найдено {len(found)} объектов.")
|
||
# for k, obj in enumerate(found):
|
||
# print(f" [{k+1}] x={obj['x']*100:+.1f} см, "
|
||
# f"z={obj['z']*100:.1f} см, "
|
||
# f"score={obj['score']}/{N_pairs}")
|
||
else:
|
||
regions, accum = extended_find()
|
||
found = regions
|
||
# print(f"найдено {len(regions)} регионов.")
|
||
# for k, r in enumerate(regions):
|
||
# print(f" [{k+1}] центр x={r['x']*100:+.1f} см, "
|
||
# f"z={r['z']*100:.1f} см, score={r['score']}/{N_pairs}")
|
||
|
||
# ══════════════════════════════════════════════════════
|
||
# 7. ГРАФИКИ
|
||
# ══════════════════════════════════════════════════════
|
||
|
||
|
||
|
||
# ─── График 2: карта накопления ────────────────────
|
||
fig, ax = plt.subplots(figsize=(12, 7))
|
||
acc_s = gaussian_filter(accum, sigma=3)
|
||
im = ax.imshow(acc_s,
|
||
extent=[x_grid[0]*100, x_grid[-1]*100,
|
||
z_grid[-1]*100, z_grid[0]*100],
|
||
aspect='auto', origin='upper', cmap='hot',
|
||
vmin=acc_s.max()*0.35, vmax=acc_s.max()*0.95)
|
||
plt.colorbar(im, ax=ax, label='Накопленный вес (SNR_comp × гауссова оболочка)')
|
||
|
||
# Позиции антенн
|
||
ax.plot(x_tx*100, np.zeros(N_tx), 'r^', ms=10, label='Tx', zorder=5)
|
||
ax.plot(x_rx*100, np.zeros(N_rx), 'bv', ms=10, label='Rx', zorder=5)
|
||
|
||
# Найденные объекты
|
||
for obj in found:
|
||
lbl = f"score={obj['score']}/{N_pairs}"
|
||
ax.plot(obj['x']*100, obj['z']*100, 'wD', ms=9, zorder=11,
|
||
markeredgecolor='black', mew=1.2)
|
||
ax.annotate(lbl, (obj['x']*100, obj['z']*100),
|
||
textcoords='offset points', xytext=(6, 4),
|
||
fontsize=8, color='white',
|
||
bbox=dict(boxstyle='round,pad=0.2', fc='black', alpha=0.5))
|
||
|
||
if MODE == 'extended':
|
||
for r in regions:
|
||
ax.contour(x_grid*100, z_grid*100, r['mask'].astype(float),
|
||
levels=[0.5], colors=['cyan'], linewidths=[1.2])
|
||
|
||
ax.plot([], [], 'wD', ms=9, markeredgecolor='k', mew=1.2, label='Найденные объекты')
|
||
ax.set_xlabel("X [см]"); ax.set_ylabel("Глубина Z [см]")
|
||
ax.set_title("Карта накопления эллипсов\n"
|
||
f"score = число пар из {N_pairs}, чей эллипс проходит через точку")
|
||
ax.set_xlim(x_grid[0]*100, x_grid[-1]*100)
|
||
ax.set_ylim(z_grid[-1]*100, z_grid[0]*100)
|
||
ax.legend(loc='lower right', fontsize=9)
|
||
ax.grid(alpha=0.25)
|
||
ax.invert_yaxis()
|
||
plt.tight_layout()
|
||
plt.show() |