Lidar_Muxa/flyguard/brain_hybrid.py

145 lines
7.3 KiB
Python
Raw Permalink 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.

"""Гибридный вид: что видит глаз — и что от этого происходит в мозге.
Сверху четыре панели ретинотопической решётки, как их видит муха: дальностный
образ, 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)