Files
radar_system/GPR_clean_v1.py
T
2026-03-19 19:29:08 +03:00

653 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()