Brainrot_Muxa/flyguard/fan_body.py
Данил Омелечко b8e95eccbe ML-ядро детектора: конвейер на схемах мозга дрозофилы
Ретина, ламина, медулла, лобула, грибовидное тело, веерное тело,
центральный комплекс, нисходящие нейроны. Обучение памяти тоннеля и
считывания MBON, оценка leave-one-bag-out, полигон дальности, 24 теста.

Реальный объект на 55 м — 98.9 % кадров, ложных 7.5 трека на км,
кадр обрабатывается за 33 мс на CPU.
2026-09-21 17:24:25 +03:00

180 lines
11 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.

"""FAN-SHAPED BODY — накопление слабых улик в координатах пути.
Покадровое решение проваливается там, где предмет даёт единицы лучей. На 170 м
человек освещён **в каждом** кадре, но всего пятью лучами: формы из них не
построить, а локальный контраст на такой дальности обнуляется — кольцо
окружения ламины упирается в стену тоннеля, которая на той же дальности.
Зато предмет неподвижен в мире, а тоннель проплывает мимо: за проход одни и те
же пять лучей попадают в одну и ту же точку пространства семнадцать раз подряд.
Именно так устроено веерное тело центрального комплекса мухи: оно копит
вектор к цели в координатах, привязанных к миру, а не к текущему кадру, и
достаёт из слабого повторяющегося сигнала то, чего нет ни в одном отдельном
наблюдении.
Здесь то же самое буквально: сетка, привязанная к пройденному пути, в которую
кадр за кадром складываются лучи из габарита. Сетка сдвигается на пройденное
расстояние, поэтому неподвижный предмет всегда попадает в одну ячейку, а шум
и случайные отражения размазываются.
Своих кандидатов накопитель не порождает — это проверено и отвергнуто
измерением: по геометрии накопленное скопление предмета и накопленный кусок
конструкции тоннеля неразличимы (совпадают все десять признаков, от дальности
до протяжённости). Разделяет их только память тоннеля, а ей нужны признаки
кадра, которых у скопления нет.
Роль накопителя другая и точная: он отвечает про **уже найденного покадрового
кандидата**, возвращались ли лучи из этой точки мира кадр за кадром. На
больших дальностях это единственная доступная улика — локальный контраст там
структурно равен нулю, потому что кольцо окружения ламины упирается в стену
тоннеля, стоящую на той же дальности.
Стоимость — доли миллисекунды: пара тысяч лучей в `bincount` по сетке из
пятнадцати тысяч ячеек.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from scipy import ndimage
# Шаг сетки вдоль пути крупный намеренно: оценка собственного движения копит
# ошибку около 3 %, за семнадцать кадров это уже 0.7 м. Мелкая ячейка размазала
# бы предмет по соседям и убила весь смысл накопления. Для отчёта о дистанции
# 2 м всё равно на порядок точнее допуска (12 % дальности).
DS_BIN = 2.0 # м вдоль пути
DU_BIN = 0.20 # м поперёк
DH_BIN = 0.25 # м по высоте
DECAY = 0.97 # забывание: полураспад около 23 кадров
MIN_FRAMES = 6 # меньше — это вспышка, а не предмет
MAX_SPAN_S = 8.0 # м: длиннее — это стена или лоток, а не предмет
MAX_WIDTH = 1.5 # м: шире — край габарита, а не предмет
BG_WIN_M = 24.0 # м: окно оценки фона вдоль пути
@dataclass
class Accumulated:
"""Скопление попаданий в одной точке пути."""
d: float
u: float
h: float
h_min: float
width: float
height: float
span_s: float
hits: float
frames: float
support: float # попаданий относительно ожидаемого для этой дальности
class FanBody:
"""Сетка в координатах пути, копящая лучи из габарита."""
def __init__(self, *, d_max: float = 220.0, half_width: float = 1.6,
h_lo: float = 0.28, h_hi: float = 2.3,
d_near: float = 55.0, min_support: float = 0.8,
ref_rays: float = 1.4, ref_d: float = 175.0):
self.ds, self.du, self.dh = DS_BIN, DU_BIN, DH_BIN
self.h_lo, self.h_hi = h_lo, h_hi
self.half_width = half_width
self.d_near = d_near # ближе этого покадровый тракт и так справляется
self.min_support = min_support
# Сколько попаданий ждать от настоящего предмета на данной дальности.
# Калибровано по замеру: человек, вставленный в реальный проезд, даёт
# после вычитания фона и разброса по ячейкам эквивалент 1.4 луча в
# кадре на 175 м (пять лучей приходят, но часть уходит в фон и в
# соседние ячейки). Число лучей падает как 1/R², накопление держит
# около 1/(1−DECAY) кадров.
self.k_expect = ref_rays * ref_d ** 2 / (1.0 - DECAY)
self.n_s = int(np.ceil(d_max / self.ds)) + 1
self.n_u = int(np.ceil(2 * half_width / self.du)) + 1
self.n_h = int(np.ceil((h_hi - h_lo) / self.dh)) + 1
self.hits = np.zeros((self.n_s, self.n_u, self.n_h), np.float32)
self.seen = np.zeros((self.n_s, self.n_u), np.float32)
self._off = 0.0 # смещение начала сетки внутри ячейки, м
# ------------------------------------------------------------------ такт
def update(self, d: np.ndarray, u: np.ndarray, h: np.ndarray, ds: float) -> None:
"""Сдвинуть сетку на пройденное `ds` и досыпать лучи текущего кадра."""
self._advance(ds)
if d.size == 0:
return
si = ((self._off + d) / self.ds).astype(np.int32)
ui = ((u + self.half_width) / self.du).astype(np.int32)
hi = ((h - self.h_lo) / self.dh).astype(np.int32)
ok = ((si >= 0) & (si < self.n_s) & (ui >= 0) & (ui < self.n_u)
& (hi >= 0) & (hi < self.n_h))
if not ok.any():
return
flat = (si[ok] * self.n_u + ui[ok]) * self.n_h + hi[ok]
self.hits += np.bincount(flat, minlength=self.hits.size).reshape(self.hits.shape)
# «Кадров подряд» считается по столбцу, а не по ячейке высоты: предмет
# может качнуться на четверть метра, и это не повод обнулять счёт.
col = np.unique(si[ok] * self.n_u + ui[ok])
self.seen.ravel()[col] += 1.0
def _advance(self, ds: float) -> None:
self.hits *= DECAY
self.seen *= DECAY
if ds <= 0:
return
self._off += ds
k = int(self._off // self.ds)
if k <= 0:
return
self._off -= k * self.ds
if k >= self.n_s:
self.hits[:] = 0.0
self.seen[:] = 0.0
return
self.hits[:-k] = self.hits[k:]
self.hits[-k:] = 0.0
self.seen[:-k] = self.seen[k:]
self.seen[-k:] = 0.0
# ------------------------------------------------------------------ съём
def support_at(self, d: np.ndarray, u: np.ndarray) -> np.ndarray:
"""Опора накопителя в точках пути: во сколько раз попаданий больше фона.
Своих кандидатов накопитель не порождает намеренно. Замер показал, что
по геометрии накопленное скопление предмета и накопленный кусок
конструкции тоннеля неразличимы: совпадают дальность, смещение, высота,
ширина, протяжённость — всё. Разделяет их только память тоннеля, а она
работает с признаками кадра (контраст, интенсивность, тень), которых у
скопления нет.
Поэтому накопитель отвечает на один вопрос про уже найденного
покадрового кандидата: **возвращались ли лучи из этой точки мира кадр
за кадром**. На больших дальностях это единственная доступная улика:
локальный контраст там структурно равен нулю, потому что кольцо
окружения упирается в стену тоннеля на той же дальности.
"""
if d.size == 0:
return np.zeros(0, np.float32)
col = self.hits.sum(axis=2)
win = max(int(BG_WIN_M / self.ds) | 1, 3)
base = ndimage.median_filter(col, size=(win, 1), mode="nearest")
resid = np.maximum(col - base, 0.0)
si = np.clip(((self._off + d) / self.ds).astype(np.int32), 0, self.n_s - 1)
ui = np.clip(((u + self.half_width) / self.du).astype(np.int32), 0, self.n_u - 1)
# окно ±1 ячейка: предмет шире одной ячейки, а оценка пути слегка плывёт
tot = np.zeros(d.size, np.float32)
frames = np.zeros(d.size, np.float32)
for ds_ in (-1, 0, 1):
for du_ in (-1, 0, 1):
a = np.clip(si + ds_, 0, self.n_s - 1)
b = np.clip(ui + du_, 0, self.n_u - 1)
tot += resid[a, b]
frames = np.maximum(frames, self.seen[a, b])
expect = self.k_expect / np.maximum(d, 1.0) ** 2
sup = tot / np.maximum(expect, 1e-6)
# без нескольких кадров подряд это не улика, а вспышка
return np.where(frames > MIN_FRAMES, sup, 0.0).astype(np.float32)