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