forked from Dan4ick/Lidar_Muxa
145 lines
7.3 KiB
Python
145 lines
7.3 KiB
Python
"""Гибридный вид: что видит глаз — и что от этого происходит в мозге.
|
||
|
||
Сверху четыре панели ретинотопической решётки, как их видит муха: дальностный
|
||
образ, ON-канал ламины (что ближе окружения), OFF-канал (что дальше) и
|
||
выделенные кандидаты. Снизу — те же 139 255 нейронов коннектома, подсвеченные
|
||
активностью соответствующих стадий.
|
||
|
||
Смысл именно в паре: видно не только «мозг светится», но и от чего. Контраст
|
||
в ламине появляется на панели — и в ту же секунду загорается полоса ламины в
|
||
облаке; предмет попадает в габарит — вспыхивает лобула; трек набирает улику —
|
||
разгорается центральный комплекс и уходит вниз к гигантскому волокну.
|
||
|
||
Стоит около 12 мс и включается флагом, чтобы не попадать в замеры задержки.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import numpy as np
|
||
|
||
try:
|
||
import cv2
|
||
except ImportError:
|
||
cv2 = None
|
||
|
||
from .brain_atlas import NeuronCloud
|
||
from .brain_view import BG, put_text
|
||
|
||
PANEL_H = 132 # высота строки панелей, px
|
||
PAD = 10
|
||
|
||
|
||
def _norm(a: np.ndarray, lo: float, hi: float) -> np.ndarray:
|
||
return np.clip((a - lo) / max(hi - lo, 1e-6), 0.0, 1.0)
|
||
|
||
|
||
class BrainHybrid:
|
||
"""Панели решётки + облако нейронов в одном кадре."""
|
||
|
||
def __init__(self, labels: bool = True, scale: int = 1):
|
||
self.cloud = NeuronCloud(labels=labels, scale=scale)
|
||
self.s = self.cloud.s
|
||
self.enabled = cv2 is not None and self.cloud.enabled
|
||
if not self.enabled:
|
||
return
|
||
self.w = self.cloud.w
|
||
self.strip_h = (PANEL_H + 34) * self.s
|
||
self.h = self.cloud.h + self.strip_h
|
||
|
||
# ------------------------------------------------------------------ панели
|
||
|
||
def _panel(self, img: np.ndarray, title: str, hint: str, slot: int,
|
||
out: np.ndarray) -> None:
|
||
n, s = 4, self.s
|
||
pad, ph, top = PAD * s, PANEL_H * s, 28 * s
|
||
pw = (self.w - pad * (n + 1)) // n
|
||
x = pad + slot * (pw + pad)
|
||
# INTER_AREA, а не NEAREST: столбцы чередуются по эхам, и при
|
||
# прореживании соседями решётка превращается в шахматку.
|
||
small = cv2.resize(img, (pw, ph), interpolation=cv2.INTER_AREA)
|
||
out[top:top + ph, x:x + pw] = small
|
||
cv2.rectangle(out, (x, top), (x + pw, top + ph), (58, 56, 68), s)
|
||
from .brain_view import text_size
|
||
put_text(out, title, (x + 2 * s, 22 * s), 0.4 * s, (188, 188, 200))
|
||
if hint:
|
||
tw = text_size(hint, 0.36 * s)[0]
|
||
# подпись справа рисуется только если не налезает на заголовок
|
||
if x + pw - tw - 8 * s > x + 2 * s + text_size(title, 0.4 * s)[0]:
|
||
put_text(out, hint, (x + pw - tw - 2 * s, 22 * s), 0.36 * s, (112, 110, 124))
|
||
|
||
def _strip(self, res) -> np.ndarray:
|
||
out = np.full((self.strip_h, self.w, 3), BG, np.uint8)
|
||
tf, lam = res.tf, res.lam
|
||
if tf is None or lam is None:
|
||
put_text(out, "панели решётки доступны только с keep_debug=True",
|
||
(PAD * self.s, 24 * self.s), 0.44 * self.s, (140, 138, 152))
|
||
return out
|
||
|
||
# 1. Дальностный образ: ближнее ярче, «нет эха» — ровный тёмный фон,
|
||
# иначе пустые лучи красятся палитрой и спорят с данными.
|
||
r = np.where(tf.valid, tf.r, np.nan)
|
||
depth = 1.0 - _norm(np.nan_to_num(r, nan=220.0), 4.0, 140.0)
|
||
rng = cv2.applyColorMap((depth * 255).astype(np.uint8), cv2.COLORMAP_TURBO)
|
||
rng[~tf.valid] = (34, 32, 38)
|
||
self._panel(rng, "дальностный образ", "R1–R8", 0, out)
|
||
|
||
# 2. ON: ближе окружения — то, что торчит навстречу
|
||
on = _norm(lam.on, 0.0, 0.02)
|
||
self._panel(cv2.applyColorMap((on * 255).astype(np.uint8),
|
||
cv2.COLORMAP_INFERNO),
|
||
"ламина L1 · ON-контраст", "ближе фона", 1, out)
|
||
|
||
# 3. OFF: дальше окружения — провалы, ниши, окклюзионная тень.
|
||
# Шкала шире, чем у ON: провалы по дальности много глубже выступов.
|
||
off = _norm(lam.off, 0.0, 0.06)
|
||
off_img = cv2.applyColorMap((off * 255).astype(np.uint8), cv2.COLORMAP_BONE)
|
||
off_img[~tf.valid] = (34, 32, 38)
|
||
self._panel(off_img, "ламина L2 · OFF-контраст", "дальше фона", 2, out)
|
||
|
||
# 4. Кандидаты: лучи, попавшие в габарит, поверх приглушённой сцены.
|
||
# Цвет по новизне: знакомое тусклое, незнакомое горит.
|
||
base = np.where(tf.valid, 40 + depth * 95, 22).astype(np.uint8)
|
||
cand = cv2.cvtColor(base, cv2.COLOR_GRAY2BGR)
|
||
mark = np.zeros(cand.shape[:2], np.uint8)
|
||
hot_of = np.zeros(cand.shape[:2], np.float32)
|
||
for c in res.candidates:
|
||
rays = c.extra.get("rays") if isinstance(c.extra, dict) else None
|
||
if rays is None:
|
||
continue
|
||
ii, jj = rays
|
||
keep = (ii < cand.shape[0]) & (jj < cand.shape[1])
|
||
mark[ii[keep], jj[keep]] = 1
|
||
hot_of[ii[keep], jj[keep]] = float(np.clip(c.novelty, 0.0, 1.0))
|
||
if mark.any():
|
||
# утолщаем: одиночные лучи на уменьшенной панели не видно
|
||
k = np.ones((3, 5), np.uint8)
|
||
grown = cv2.dilate(mark, k)
|
||
hot_g = cv2.dilate(hot_of, k)
|
||
m = grown.astype(bool)
|
||
cand[m] = np.stack([np.full(m.sum(), 60),
|
||
(110 + 130 * hot_g[m]).astype(int),
|
||
(70 + 185 * hot_g[m]).astype(int)], axis=1)
|
||
self._panel(cand, "лобула LC11 · кандидаты",
|
||
f"{len(res.candidates)} шт.", 3, out)
|
||
|
||
s = self.s
|
||
cv2.line(out, (PAD * s, self.strip_h - 2 * s), (self.w - PAD * s, self.strip_h - 2 * s),
|
||
(52, 52, 62), s)
|
||
return out
|
||
|
||
# ------------------------------------------------------------------ кадр
|
||
|
||
def render(self, res) -> np.ndarray | None:
|
||
if not self.enabled:
|
||
return None
|
||
cloud = self.cloud.render(res)
|
||
if cloud is None:
|
||
return None
|
||
out = np.empty((self.h, self.w, 3), np.uint8)
|
||
out[:self.strip_h] = self._strip(res)
|
||
out[self.strip_h:] = cloud
|
||
return out
|
||
|
||
@staticmethod
|
||
def to_msg(img: np.ndarray, stamp, frame_id: str):
|
||
from .brain_view import BrainView
|
||
return BrainView.to_msg(img, stamp, frame_id)
|