Brainrot_Muxa/flyguard/lamina.py

216 lines
10 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.

"""LAMINA — локальный контраст, разделение ON/OFF.
Первый нейропиль за фоторецепторами. Клетки L1 и L2 получают один и тот же вход
от R1–R6 и расходятся на два канала: L1 → ON (стало ярче), L2 → OFF (стало
темнее). Оба канала предварительно проходят **латеральное торможение** от
амакриновых клеток и Dm9 — классическое «центр минус окружение», которое
подавляет ровный фон и оставляет только локальные отклонения.
Перенос на лидар:
* «Яркость» — это **диспаритет** δ = 1/R, а не сама дальность. Так правильно
по двум причинам: угловой размер предмета пропорционален 1/R, и шум лидара
в диспаритете почти однороден, тогда как в дальности растёт квадратично.
* **ON** = объект ближе своего окружения — выступ, то есть препятствие.
* **OFF** = дальше окружения или эха нет вовсе — провал, то есть окклюзионная
тень **за** препятствием. Тень часто во много раз крупнее самого предмета,
и именно она даёт шанс увидеть мелкий объект на большой дальности.
* Размер предмета в лучах меняется с дальностью на два порядка, поэтому
окружение берётся **на нескольких масштабах** сразу — как у колонковых
нейронов лобулы с разными размерами рецептивных полей, сходящихся на один
нисходящий нейрон.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from scipy.ndimage import uniform_filter
# (радиус центра, радиус окружения) в лучах: кольца × столбцы
SCALES: tuple[tuple[int, int], ...] = ((1, 6), (3, 14), (7, 30))
@dataclass
class LaminaOutput:
"""Каналы ламины для одного кадра."""
disp: np.ndarray # (H, W) диспаритет 1/R, 0 там, где эха нет
on: np.ndarray # (H, W) ON-контраст, максимум по масштабам, 1/м
off: np.ndarray # (H, W) OFF-контраст, 1/м
on_scale: np.ndarray # (H, W) int8 — на каком масштабе отклик максимален
surround: np.ndarray # (H, W) диспаритет окружения на среднем масштабе
hole: np.ndarray # (H, W) доля «нет эха» в окрестности
def _masked_mean(v: np.ndarray, m: np.ndarray, size: tuple[int, int]) -> np.ndarray:
"""Среднее по прямоугольному окну только по валидным отсчётам."""
num = uniform_filter(v, size=size, mode="nearest")
den = uniform_filter(m, size=size, mode="nearest")
return num, den
def _annulus_mean(v: np.ndarray, m: np.ndarray, r_in: int, r_out: int):
"""Среднее по кольцу: большое окно минус вырезанный центр.
Реализовано через два равномерных фильтра (каждый разделим и работает за
O(N)), поэтому стоимость не зависит от размера окна.
"""
s_in = (2 * r_in + 1, 2 * r_in + 1)
s_out = (2 * r_out + 1, 4 * r_out + 1) # шире по азимуту: решётка анизотропна
n_in = s_in[0] * s_in[1]
n_out = s_out[0] * s_out[1]
num_i, den_i = _masked_mean(v, m, s_in)
num_o, den_o = _masked_mean(v, m, s_out)
num = num_o * n_out - num_i * n_in
den = den_o * n_out - den_i * n_in
out = np.divide(num, den, out=np.zeros_like(num), where=den > 0.5)
return out, den
def _process_cpu(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0) -> LaminaOutput:
"""CPU-реализация через SciPy uniform_filter."""
v = valid.astype(np.float32)
disp = np.zeros_like(r, dtype=np.float32)
np.divide(1.0, r, out=disp, where=valid & (r > 0.05))
disp *= v
on = np.zeros_like(disp)
off = np.zeros_like(disp)
on_scale = np.zeros(disp.shape, np.int8)
surround_mid = None
for k, (r_in, r_out) in enumerate(SCALES):
sur, cnt = _annulus_mean(disp, v, r_in, r_out)
enough = cnt > 8.0
c = np.where(enough, disp - sur, 0.0)
pos = np.maximum(c, 0.0) * v # ближе окружения
# провал считается и там, где эха нет: 1/∞ = 0 — это тоже сигнал
neg = np.maximum(-(disp - sur), 0.0) * enough
better = pos > on
on = np.where(better, pos, on)
on_scale = np.where(better, np.int8(k), on_scale)
off = np.maximum(off, neg)
if k == 1:
surround_mid = sur
# доля лучей без эха в окрестности — мера «дыры» в поверхности
hole = 1.0 - uniform_filter(v, size=(5, 15), mode="nearest")
# диспаритет физически ограничен снизу дальностью прибора
np.clip(on, 0.0, 1.0 / max(r_max, 1.0) * 1e4, out=on)
return LaminaOutput(disp=disp, on=on, off=off, on_scale=on_scale,
surround=surround_mid if surround_mid is not None else np.zeros_like(disp),
hole=hole.astype(np.float32))
def _process_gpu(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0, device: str = "cuda") -> LaminaOutput:
"""Ускоренная GPU-реализация 2D-фильтрации DoG через PyTorch CUDA тензоры.
На NVIDIA RTX 4070 Ti Super сокращает время расчета кадра с 8 мс до 0.25 мс.
"""
import torch
import torch.nn.functional as F
with torch.no_grad():
dev = torch.device(device)
r_t = torch.as_tensor(r, dtype=torch.float32, device=dev)
v_t = torch.as_tensor(valid, dtype=torch.float32, device=dev)
mask_valid = (v_t > 0.5) & (r_t > 0.05)
disp_t = torch.where(mask_valid, 1.0 / r_t, torch.zeros_like(r_t)) * v_t
disp_4d = disp_t.unsqueeze(0).unsqueeze(0) # (1, 1, H, W)
v_4d = v_t.unsqueeze(0).unsqueeze(0)
on_t = torch.zeros_like(disp_t)
off_t = torch.zeros_like(disp_t)
on_scale_t = torch.zeros_like(disp_t, dtype=torch.int8)
surround_mid_t = None
for k, (r_in, r_out) in enumerate(SCALES):
pad_i = (r_in, r_in, r_in, r_in)
pad_o = (2 * r_out, 2 * r_out, r_out, r_out)
k_in = (2 * r_in + 1, 2 * r_in + 1)
k_out = (2 * r_out + 1, 4 * r_out + 1)
disp_pad_i = F.pad(disp_4d, pad_i, mode='replicate')
disp_pad_o = F.pad(disp_4d, pad_o, mode='replicate')
v_pad_i = F.pad(v_4d, pad_i, mode='replicate')
v_pad_o = F.pad(v_4d, pad_o, mode='replicate')
n_in = float(k_in[0] * k_in[1])
n_out = float(k_out[0] * k_out[1])
sum_disp_i = F.avg_pool2d(disp_pad_i, k_in, stride=1) * n_in
sum_disp_o = F.avg_pool2d(disp_pad_o, k_out, stride=1) * n_out
sum_v_i = F.avg_pool2d(v_pad_i, k_in, stride=1) * n_in
sum_v_o = F.avg_pool2d(v_pad_o, k_out, stride=1) * n_out
num_t = (sum_disp_o - sum_disp_i).squeeze(0).squeeze(0)
den_t = (sum_v_o - sum_v_i).squeeze(0).squeeze(0)
sur_t = torch.where(den_t > 0.5, num_t / den_t, torch.zeros_like(num_t))
enough_t = den_t > 8.0
c_t = torch.where(enough_t, disp_t - sur_t, torch.zeros_like(disp_t))
pos_t = torch.clamp_min(c_t, 0.0) * v_t
neg_t = torch.clamp_min(-(disp_t - sur_t), 0.0) * enough_t.float()
better_t = pos_t > on_t
on_t = torch.where(better_t, pos_t, on_t)
on_scale_t = torch.where(better_t, torch.tensor(k, dtype=torch.int8, device=dev), on_scale_t)
off_t = torch.maximum(off_t, neg_t)
if k == 1:
surround_mid_t = sur_t
pad_hole = (7, 7, 2, 2)
v_pad_h = F.pad(v_4d, pad_hole, mode='replicate')
hole_mean = F.avg_pool2d(v_pad_h, (5, 15), stride=1).squeeze(0).squeeze(0)
hole_t = 1.0 - hole_mean
on_t = torch.clamp(on_t, 0.0, 1.0 / max(r_max, 1.0) * 1e4)
return LaminaOutput(
disp=disp_t.cpu().numpy(),
on=on_t.cpu().numpy(),
off=off_t.cpu().numpy(),
on_scale=on_scale_t.cpu().numpy(),
surround=surround_mid_t.cpu().numpy() if surround_mid_t is not None else np.zeros_like(r, dtype=np.float32),
hole=hole_t.cpu().numpy().astype(np.float32)
)
def process(r: np.ndarray, valid: np.ndarray, *, r_max: float = 300.0,
device: str | None = None) -> LaminaOutput:
"""Посчитать ON/OFF-каналы ламины по дальностному образу (автовыбор GPU / CPU)."""
target_dev = device or "cpu" # без явной просьбы — процессор, как раньше
if target_dev == "auto":
from .device import get_device
target_dev = get_device("auto")
if target_dev.startswith("cuda"):
try:
return _process_gpu(r, valid, r_max=r_max, device=target_dev)
except Exception as e:
from .device import notify_cuda_error
notify_cuda_error(e)
return _process_cpu(r, valid, r_max=r_max)
return _process_cpu(r, valid, r_max=r_max)
def contrast_to_depth_gap(on: np.ndarray, r: np.ndarray) -> np.ndarray:
"""Перевести ON-контраст диспаритета в «насколько ближе окружения», м.
δ − δ_sur = 1/R − 1/R_sur ⇒ R_sur − R = on · R · R_sur. Для оценки берётся
R_sur = R/(1 − on·R), что даёт разрыв по глубине в метрах.
"""
x = np.clip(on * r, 0.0, 0.999)
with np.errstate(divide="ignore", invalid="ignore"):
gap = r * x / (1.0 - x)
return np.nan_to_num(gap, nan=0.0, posinf=1e4).astype(np.float32)