necksight 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- necksight/__init__.py +24 -0
- necksight/__main__.py +6 -0
- necksight/cli.py +348 -0
- necksight/emd.py +283 -0
- necksight/looming.py +513 -0
- necksight/retina.py +225 -0
- necksight-1.0.0.dist-info/METADATA +246 -0
- necksight-1.0.0.dist-info/RECORD +12 -0
- necksight-1.0.0.dist-info/WHEEL +5 -0
- necksight-1.0.0.dist-info/entry_points.txt +2 -0
- necksight-1.0.0.dist-info/licenses/LICENSE +21 -0
- necksight-1.0.0.dist-info/top_level.txt +1 -0
necksight/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""NeckSight: Drosophila Optic Lobe T4/T5 Motion & LPLC2 Looming Reflex Engine."""
|
|
2
|
+
|
|
3
|
+
from .emd import MotionOutput, T4T5MotionEngine
|
|
4
|
+
from .looming import (
|
|
5
|
+
GiantFiberTrigger,
|
|
6
|
+
LPLC2LoomingReflex,
|
|
7
|
+
NeckSightPipeline,
|
|
8
|
+
attach_nltp_reflex,
|
|
9
|
+
)
|
|
10
|
+
from .retina import LaminaOutput, RetinaLaminaPreproc
|
|
11
|
+
|
|
12
|
+
__version__ = "1.0.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"LaminaOutput",
|
|
17
|
+
"RetinaLaminaPreproc",
|
|
18
|
+
"MotionOutput",
|
|
19
|
+
"T4T5MotionEngine",
|
|
20
|
+
"GiantFiberTrigger",
|
|
21
|
+
"LPLC2LoomingReflex",
|
|
22
|
+
"NeckSightPipeline",
|
|
23
|
+
"attach_nltp_reflex",
|
|
24
|
+
]
|
necksight/__main__.py
ADDED
necksight/cli.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Command-line interface for NeckSight (demo & benchmark-quick)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .looming import NeckSightPipeline
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _make_clutter(
|
|
17
|
+
h: int, w: int, rng: np.random.Generator, base_scale: float = 64.0
|
|
18
|
+
) -> np.ndarray:
|
|
19
|
+
"""Synthesize a multi-scale natural-like textured clutter array in [40, 210]."""
|
|
20
|
+
yy, xx = np.meshgrid(
|
|
21
|
+
np.arange(h, dtype=np.float32),
|
|
22
|
+
np.arange(w, dtype=np.float32),
|
|
23
|
+
indexing="ij",
|
|
24
|
+
)
|
|
25
|
+
bg = np.zeros((h, w), dtype=np.float32)
|
|
26
|
+
for cycles in (1.0, 2.0, 4.0, 7.0):
|
|
27
|
+
wl = base_scale / cycles
|
|
28
|
+
phx = float(rng.uniform(0.0, 2.0 * np.pi))
|
|
29
|
+
phy = float(rng.uniform(0.0, 2.0 * np.pi))
|
|
30
|
+
amp = 1.0 / cycles
|
|
31
|
+
bg += amp * np.sin(2.0 * np.pi * xx / wl + phx) * np.cos(
|
|
32
|
+
2.0 * np.pi * yy / wl + phy
|
|
33
|
+
)
|
|
34
|
+
bg = (bg - float(np.min(bg))) / (float(np.ptp(bg)) + 1e-6)
|
|
35
|
+
return (45.0 + 160.0 * bg).astype(np.float32)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run_demo(grid: int = 64, frames: int = 36, as_json: bool = False) -> Dict[str, Any]:
|
|
39
|
+
dt = 1.0 / 60.0
|
|
40
|
+
rng = np.random.default_rng(42)
|
|
41
|
+
yy, xx = np.meshgrid(
|
|
42
|
+
np.arange(grid, dtype=np.float32),
|
|
43
|
+
np.arange(grid, dtype=np.float32),
|
|
44
|
+
indexing="ij",
|
|
45
|
+
)
|
|
46
|
+
pano_w = grid + 4 * frames + 16
|
|
47
|
+
pano = _make_clutter(grid * 2, pano_w, rng, base_scale=float(grid))
|
|
48
|
+
|
|
49
|
+
scenarios: List[Dict[str, Any]] = []
|
|
50
|
+
|
|
51
|
+
# 1. Rightward translating sinusoidal grating
|
|
52
|
+
pipe = NeckSightPipeline(grid_size=grid)
|
|
53
|
+
vx_vals: List[float] = []
|
|
54
|
+
max_loom = 0.0
|
|
55
|
+
trig_any = False
|
|
56
|
+
for t in range(frames):
|
|
57
|
+
frame = 128.0 + 90.0 * np.sin(2.0 * np.pi * (xx - 1.2 * t) / 12.0)
|
|
58
|
+
_, mot, trig = pipe.step_full(frame, dt=dt)
|
|
59
|
+
if t >= 3:
|
|
60
|
+
vx_vals.append(mot.vx_flow)
|
|
61
|
+
max_loom = max(max_loom, trig.looming_score)
|
|
62
|
+
trig_any = trig_any or trig.triggered
|
|
63
|
+
scenarios.append(
|
|
64
|
+
{
|
|
65
|
+
"scenario": "rightward_grating",
|
|
66
|
+
"mean_vx_flow": round(float(np.mean(vx_vals)), 5),
|
|
67
|
+
"peak_looming_score": round(max_loom, 5),
|
|
68
|
+
"triggered": trig_any,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# 2. Fast camera yaw pan across cluttered scene (continuous 3.0 px/frame)
|
|
73
|
+
pipe.reset()
|
|
74
|
+
vx_vals = []
|
|
75
|
+
max_loom = 0.0
|
|
76
|
+
trig_any = False
|
|
77
|
+
for t in range(frames):
|
|
78
|
+
ox = 3 * t
|
|
79
|
+
frame = pano[grid // 2 : grid // 2 + grid, ox : ox + grid]
|
|
80
|
+
_, mot, trig = pipe.step_full(frame, dt=dt)
|
|
81
|
+
if t >= 3:
|
|
82
|
+
vx_vals.append(mot.vx_flow)
|
|
83
|
+
max_loom = max(max_loom, trig.looming_score)
|
|
84
|
+
trig_any = trig_any or trig.triggered
|
|
85
|
+
scenarios.append(
|
|
86
|
+
{
|
|
87
|
+
"scenario": "fast_camera_yaw_pan",
|
|
88
|
+
"mean_vx_flow": round(float(np.mean(vx_vals)), 5),
|
|
89
|
+
"peak_looming_score": round(max_loom, 5),
|
|
90
|
+
"triggered": trig_any,
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# 3. 100x global sunlight glare step on cluttered scene
|
|
95
|
+
pipe.reset()
|
|
96
|
+
max_loom = 0.0
|
|
97
|
+
trig_any = False
|
|
98
|
+
crop = pano[:grid, :grid]
|
|
99
|
+
for t in range(frames):
|
|
100
|
+
mult = 100.0 if t >= frames // 2 else 1.0
|
|
101
|
+
frame = crop * mult
|
|
102
|
+
_, _, trig = pipe.step_full(frame, dt=dt)
|
|
103
|
+
max_loom = max(max_loom, trig.looming_score)
|
|
104
|
+
trig_any = trig_any or trig.triggered
|
|
105
|
+
scenarios.append(
|
|
106
|
+
{
|
|
107
|
+
"scenario": "glare_flash_100x",
|
|
108
|
+
"mean_vx_flow": 0.0,
|
|
109
|
+
"peak_looming_score": round(max_loom, 5),
|
|
110
|
+
"triggered": trig_any,
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# 4. Head-on approaching dark obstacle on cluttered background
|
|
115
|
+
pipe.reset()
|
|
116
|
+
max_loom = 0.0
|
|
117
|
+
trig_any = False
|
|
118
|
+
first_trig_frame: Optional[int] = None
|
|
119
|
+
trig_ttc: float = float("inf")
|
|
120
|
+
cy = 0.5 * (grid - 1)
|
|
121
|
+
cx = 0.5 * (grid - 1)
|
|
122
|
+
dist = np.hypot(xx - cx, yy - cy)
|
|
123
|
+
for t in range(frames):
|
|
124
|
+
ttc_true = max((frames - t) * dt, 0.025)
|
|
125
|
+
radius = 0.09 * grid / ttc_true
|
|
126
|
+
edge = np.clip((radius - dist) / 1.2 + 0.5, 0.0, 1.0)
|
|
127
|
+
frame = crop * (1.0 - edge) + 12.0 * edge
|
|
128
|
+
_, _, trig = pipe.step_full(frame, dt=dt)
|
|
129
|
+
max_loom = max(max_loom, trig.looming_score)
|
|
130
|
+
if trig.triggered and not trig_any:
|
|
131
|
+
trig_any = True
|
|
132
|
+
first_trig_frame = t
|
|
133
|
+
trig_ttc = trig.est_ttc_s
|
|
134
|
+
lead_ms = (
|
|
135
|
+
round((frames - first_trig_frame) * dt * 1000.0, 1)
|
|
136
|
+
if first_trig_frame is not None
|
|
137
|
+
else 0.0
|
|
138
|
+
)
|
|
139
|
+
scenarios.append(
|
|
140
|
+
{
|
|
141
|
+
"scenario": "head_on_looming_obstacle",
|
|
142
|
+
"peak_looming_score": round(max_loom, 5),
|
|
143
|
+
"triggered": trig_any,
|
|
144
|
+
"lead_time_ms": lead_ms,
|
|
145
|
+
"est_ttc_at_trigger_s": round(trig_ttc, 3) if np.isfinite(trig_ttc) else None,
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
summary = {"version": __version__, "grid": grid, "scenarios": scenarios}
|
|
150
|
+
if as_json:
|
|
151
|
+
print(json.dumps(summary, indent=2))
|
|
152
|
+
else:
|
|
153
|
+
print(f"NeckSight v{__version__} -- Optic Lobe T4/T5 + LPLC2 Demo ({grid}x{grid})")
|
|
154
|
+
print("-" * 74)
|
|
155
|
+
for item in scenarios:
|
|
156
|
+
name = item["scenario"]
|
|
157
|
+
score = item["peak_looming_score"]
|
|
158
|
+
trig_flag = "TRIGGERED" if item["triggered"] else "quiet (0 false alarm)"
|
|
159
|
+
extra = ""
|
|
160
|
+
if "lead_time_ms" in item:
|
|
161
|
+
extra = (
|
|
162
|
+
f" | lead={item['lead_time_ms']} ms"
|
|
163
|
+
f" | est_ttc={item['est_ttc_at_trigger_s']} s"
|
|
164
|
+
)
|
|
165
|
+
elif "mean_vx_flow" in item:
|
|
166
|
+
extra = f" | vx_flow={item['mean_vx_flow']:+.4f}"
|
|
167
|
+
print(f" {name:<26s} | score={score:.4f} | {trig_flag}{extra}")
|
|
168
|
+
return summary
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _compute_auroc(pos_scores: Sequence[float], neg_scores: Sequence[float]) -> float:
|
|
172
|
+
pos = np.asarray(pos_scores, dtype=np.float64)
|
|
173
|
+
neg = np.asarray(neg_scores, dtype=np.float64)
|
|
174
|
+
diffs = pos[:, None] - neg[None, :]
|
|
175
|
+
wins = float(np.sum(diffs > 0.0) + 0.5 * np.sum(diffs == 0.0))
|
|
176
|
+
return wins / max(pos.size * neg.size, 1)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _run_benchmark_quick(
|
|
180
|
+
grid: int = 64, sequences: int = 24, as_json: bool = False
|
|
181
|
+
) -> Dict[str, Any]:
|
|
182
|
+
rng = np.random.default_rng(2026)
|
|
183
|
+
dt = 1.0 / 60.0
|
|
184
|
+
frames = 32
|
|
185
|
+
n_pos = max(4, sequences // 2)
|
|
186
|
+
n_neg = max(4, sequences - n_pos)
|
|
187
|
+
|
|
188
|
+
pipe = NeckSightPipeline(grid_size=grid)
|
|
189
|
+
yy, xx = np.meshgrid(
|
|
190
|
+
np.arange(grid, dtype=np.float32),
|
|
191
|
+
np.arange(grid, dtype=np.float32),
|
|
192
|
+
indexing="ij",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
pos_scores: List[float] = []
|
|
196
|
+
lead_times_ms: List[float] = []
|
|
197
|
+
for i in range(n_pos):
|
|
198
|
+
pipe.reset()
|
|
199
|
+
bg = _make_clutter(grid, grid, rng, base_scale=float(grid))
|
|
200
|
+
cx = 0.5 * (grid - 1) + float(rng.uniform(-6.0, 6.0))
|
|
201
|
+
cy = 0.5 * (grid - 1) + float(rng.uniform(-6.0, 6.0))
|
|
202
|
+
dist = np.hypot(xx - cx, yy - cy)
|
|
203
|
+
dark_obj = (i % 2) == 0
|
|
204
|
+
obj_val = 14.0 if dark_obj else 240.0
|
|
205
|
+
rate = float(rng.uniform(0.075, 0.115)) * grid
|
|
206
|
+
peak = 0.0
|
|
207
|
+
first_t: Optional[int] = None
|
|
208
|
+
for t in range(frames):
|
|
209
|
+
ttc = max((frames - t) * dt, 0.025)
|
|
210
|
+
rad = rate / ttc
|
|
211
|
+
edge = np.clip((rad - dist) / 1.2 + 0.5, 0.0, 1.0)
|
|
212
|
+
frame = bg * (1.0 - edge) + obj_val * edge
|
|
213
|
+
trig = pipe.step(frame, dt=dt)
|
|
214
|
+
peak = max(peak, trig.looming_score)
|
|
215
|
+
if trig.triggered and first_t is None:
|
|
216
|
+
first_t = t
|
|
217
|
+
pos_scores.append(peak)
|
|
218
|
+
if first_t is not None:
|
|
219
|
+
lead_times_ms.append((frames - first_t) * dt * 1000.0)
|
|
220
|
+
|
|
221
|
+
neg_scores: List[float] = []
|
|
222
|
+
false_alarms = 0
|
|
223
|
+
pano_w = grid + 4 * frames + 32
|
|
224
|
+
pano_h = grid + 2 * frames + 32
|
|
225
|
+
for i in range(n_neg):
|
|
226
|
+
pipe.reset()
|
|
227
|
+
pano = _make_clutter(pano_h, pano_w, rng, base_scale=float(grid))
|
|
228
|
+
peak = 0.0
|
|
229
|
+
fired = False
|
|
230
|
+
mode = i % 3
|
|
231
|
+
speed = int(rng.choice([-3, -2, 2, 3]))
|
|
232
|
+
start_x = 0 if speed > 0 else (-speed * frames + 4)
|
|
233
|
+
for t in range(frames):
|
|
234
|
+
if mode == 0:
|
|
235
|
+
ox = start_x + speed * t
|
|
236
|
+
oy = 4 + (t // 2)
|
|
237
|
+
frame = pano[oy : oy + grid, ox : ox + grid]
|
|
238
|
+
elif mode == 1:
|
|
239
|
+
mult = 80.0 if t >= frames // 2 else 1.0
|
|
240
|
+
frame = pano[:grid, :grid] * mult
|
|
241
|
+
else:
|
|
242
|
+
dist = np.hypot(xx - 0.5 * grid, yy - 0.5 * grid)
|
|
243
|
+
rad = max(2.0, 22.0 - 0.65 * t)
|
|
244
|
+
edge = np.clip((rad - dist) / 1.2 + 0.5, 0.0, 1.0)
|
|
245
|
+
frame = pano[:grid, :grid] * (1.0 - edge) + 18.0 * edge
|
|
246
|
+
trig = pipe.step(frame, dt=dt)
|
|
247
|
+
peak = max(peak, trig.looming_score)
|
|
248
|
+
fired = fired or trig.triggered
|
|
249
|
+
neg_scores.append(peak)
|
|
250
|
+
if fired:
|
|
251
|
+
false_alarms += 1
|
|
252
|
+
|
|
253
|
+
# Throughput timing on 600 frames
|
|
254
|
+
pipe.reset()
|
|
255
|
+
test_frames = [
|
|
256
|
+
_make_clutter(grid, grid, rng, base_scale=float(grid)) for _ in range(16)
|
|
257
|
+
]
|
|
258
|
+
n_bench = 600
|
|
259
|
+
t0 = time.perf_counter()
|
|
260
|
+
for k in range(n_bench):
|
|
261
|
+
pipe.step(test_frames[k & 15], dt=dt)
|
|
262
|
+
elapsed = max(time.perf_counter() - t0, 1e-6)
|
|
263
|
+
fps = n_bench / elapsed
|
|
264
|
+
|
|
265
|
+
state_bytes = sum(
|
|
266
|
+
arr.nbytes
|
|
267
|
+
for arr in (
|
|
268
|
+
pipe.retina._baseline_log,
|
|
269
|
+
pipe.emd._d_on,
|
|
270
|
+
pipe.emd._d_off,
|
|
271
|
+
)
|
|
272
|
+
if arr is not None
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
auroc = _compute_auroc(pos_scores, neg_scores)
|
|
276
|
+
far = false_alarms / max(n_neg, 1)
|
|
277
|
+
mean_lead = float(np.mean(lead_times_ms)) if lead_times_ms else 0.0
|
|
278
|
+
|
|
279
|
+
result = {
|
|
280
|
+
"version": __version__,
|
|
281
|
+
"grid": f"{grid}x{grid}",
|
|
282
|
+
"sequences": n_pos + n_neg,
|
|
283
|
+
"auroc": round(auroc, 4),
|
|
284
|
+
"false_alarm_rate": round(far, 4),
|
|
285
|
+
"mean_lead_time_ms": round(mean_lead, 1),
|
|
286
|
+
"throughput_fps": round(fps, 1),
|
|
287
|
+
"persistent_state_kb": round(state_bytes / 1024.0, 2),
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if as_json:
|
|
291
|
+
print(json.dumps(result, indent=2))
|
|
292
|
+
else:
|
|
293
|
+
print(
|
|
294
|
+
f"NeckSight v{__version__} Quick Benchmark ({grid}x{grid}, N={n_pos + n_neg}):\n"
|
|
295
|
+
f" Looming vs Yaw/Glare AUROC : {result['auroc']:.4f}\n"
|
|
296
|
+
f" False-Alarm Rate (negatives): {result['false_alarm_rate'] * 100.0:.1f}%\n"
|
|
297
|
+
f" Mean Warning Lead Time : {result['mean_lead_time_ms']:.1f} ms\n"
|
|
298
|
+
f" CPU Throughput (Python) : {result['throughput_fps']:.1f} FPS\n"
|
|
299
|
+
f" Persistent State Memory : {result['persistent_state_kb']:.2f} KB"
|
|
300
|
+
)
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
305
|
+
parser = argparse.ArgumentParser(
|
|
306
|
+
prog="necksight",
|
|
307
|
+
description="NeckSight: Drosophila Optic Lobe T4/T5 Motion & LPLC2 Looming Reflex.",
|
|
308
|
+
)
|
|
309
|
+
parser.add_argument(
|
|
310
|
+
"--version", action="version", version=f"necksight {__version__}"
|
|
311
|
+
)
|
|
312
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
313
|
+
|
|
314
|
+
p_demo = sub.add_parser(
|
|
315
|
+
"demo", help="Run 4 synthetic optic-lobe scenarios (grating, yaw, glare, looming)."
|
|
316
|
+
)
|
|
317
|
+
p_demo.add_argument("--grid", type=int, default=64, help="Retinal grid size (default: 64)")
|
|
318
|
+
p_demo.add_argument("--frames", type=int, default=36, help="Frames per scenario (default: 36)")
|
|
319
|
+
p_demo.add_argument("--json", action="store_true", help="Emit JSON output")
|
|
320
|
+
|
|
321
|
+
p_bench = sub.add_parser(
|
|
322
|
+
"benchmark-quick", help="Run a fast accuracy & throughput benchmark."
|
|
323
|
+
)
|
|
324
|
+
p_bench.add_argument("--grid", type=int, default=64, help="Retinal grid size (default: 64)")
|
|
325
|
+
p_bench.add_argument(
|
|
326
|
+
"--sequences", type=int, default=24, help="Number of sequences (default: 24)"
|
|
327
|
+
)
|
|
328
|
+
p_bench.add_argument("--json", action="store_true", help="Emit JSON output")
|
|
329
|
+
|
|
330
|
+
return parser
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
334
|
+
parser = build_parser()
|
|
335
|
+
args = parser.parse_args(argv)
|
|
336
|
+
if args.command == "demo":
|
|
337
|
+
_run_demo(grid=args.grid, frames=args.frames, as_json=args.json)
|
|
338
|
+
return 0
|
|
339
|
+
if args.command == "benchmark-quick":
|
|
340
|
+
_run_benchmark_quick(
|
|
341
|
+
grid=args.grid, sequences=args.sequences, as_json=args.json
|
|
342
|
+
)
|
|
343
|
+
return 0
|
|
344
|
+
return 1
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
if __name__ == "__main__":
|
|
348
|
+
raise SystemExit(main())
|
necksight/emd.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Medulla/Lobula Plate T4 (ON) and T5 (OFF) 3-arm Elementary Motion Detectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Dict, Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .retina import LaminaOutput
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class MotionOutput:
|
|
15
|
+
"""Output of the T4/T5 Elementary Motion Detector and LPTC wide-field stage.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
t4: 4-cardinal ON motion maps (keys ``"+X"``, ``"-X"``, ``"+Y"``, ``"-Y"``).
|
|
19
|
+
t5: 4-cardinal OFF motion maps (keys ``"+X"``, ``"-X"``, ``"+Y"``, ``"-Y"``).
|
|
20
|
+
pos_x: Combined (T4 + T5) rightward (+X) motion map.
|
|
21
|
+
neg_x: Combined (T4 + T5) leftward (-X) motion map.
|
|
22
|
+
pos_y: Combined (T4 + T5) downward (+Y) motion map.
|
|
23
|
+
neg_y: Combined (T4 + T5) upward (-Y) motion map.
|
|
24
|
+
flow_x: Opponent horizontal motion field ``pos_x - neg_x``.
|
|
25
|
+
flow_y: Opponent vertical motion field ``pos_y - neg_y``.
|
|
26
|
+
vx_flow: Wide-field horizontal LPTC (HS-like) optic flow.
|
|
27
|
+
vy_flow: Wide-field vertical LPTC (VS-like) optic flow.
|
|
28
|
+
yaw_pan: Wide-field equatorial horizontal tangential pan signal.
|
|
29
|
+
radial_divergence: Wide-field radial expansion (+) vs contraction (-) flow.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
t4: Dict[str, np.ndarray]
|
|
33
|
+
t5: Dict[str, np.ndarray]
|
|
34
|
+
pos_x: np.ndarray
|
|
35
|
+
neg_x: np.ndarray
|
|
36
|
+
pos_y: np.ndarray
|
|
37
|
+
neg_y: np.ndarray
|
|
38
|
+
flow_x: np.ndarray
|
|
39
|
+
flow_y: np.ndarray
|
|
40
|
+
vx_flow: float
|
|
41
|
+
vy_flow: float
|
|
42
|
+
yaw_pan: float
|
|
43
|
+
radial_divergence: float
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def lptc(self) -> Tuple[float, float, float, float]:
|
|
47
|
+
"""Return wide-field LPTC summary tuple ``(vx_flow, vy_flow, yaw_pan, radial_divergence)``."""
|
|
48
|
+
return (self.vx_flow, self.vy_flow, self.yaw_pan, self.radial_divergence)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class T4T5MotionEngine:
|
|
52
|
+
"""Biophysical 4-cardinal 3-arm T4 (ON) and T5 (OFF) motion engine.
|
|
53
|
+
|
|
54
|
+
Inspired by *Drosophila* T4 and T5 Lobula Plate direction-selective neurons
|
|
55
|
+
(Maisak et al., *Nature* 2013; Haag et al., *eLife* 2017):
|
|
56
|
+
- **Preferred-arm Hassenstein-Reichardt correlation**: multiplies delayed
|
|
57
|
+
trailing-column activity by prompt center-column activity while subtracting
|
|
58
|
+
synchronous self-correlation (``delayed_back * center - delayed_center * back``),
|
|
59
|
+
mathematically guaranteeing zero response to stationary contrast flicker.
|
|
60
|
+
- **Null-arm Barlow-Levick divisive shunting veto**: divides excitation by
|
|
61
|
+
``1 + veto_gain * delayed_front`` so waves arriving from the null direction
|
|
62
|
+
actively suppress output.
|
|
63
|
+
- **Multi-stride receptive field** (1-ommatidium + weighted 2-ommatidia arms)
|
|
64
|
+
maintaining clean direction selectivity across both slow drift and rapid
|
|
65
|
+
pre-collision expansion speeds.
|
|
66
|
+
- **LPTC wide-field tangential integration** computing ``vx_flow``, ``vy_flow``,
|
|
67
|
+
``yaw_pan``, and ``radial_divergence``.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
grid_size: Union[int, Tuple[int, int]] = 64,
|
|
73
|
+
*,
|
|
74
|
+
delay_alpha: float = 0.55,
|
|
75
|
+
veto_gain: float = 12.0,
|
|
76
|
+
stride2_weight: float = 0.5,
|
|
77
|
+
output_gain: float = 10.0,
|
|
78
|
+
) -> None:
|
|
79
|
+
if isinstance(grid_size, int):
|
|
80
|
+
self.grid_shape: Tuple[int, int] = (grid_size, grid_size)
|
|
81
|
+
else:
|
|
82
|
+
self.grid_shape = (int(grid_size[0]), int(grid_size[1]))
|
|
83
|
+
|
|
84
|
+
gh, gw = self.grid_shape
|
|
85
|
+
if gh < 8 or gw < 8:
|
|
86
|
+
raise ValueError("grid_size dimensions must be >= 8")
|
|
87
|
+
if not (0.0 < delay_alpha <= 1.0):
|
|
88
|
+
raise ValueError("delay_alpha must be in (0.0, 1.0]")
|
|
89
|
+
if veto_gain < 0.0:
|
|
90
|
+
raise ValueError("veto_gain must be >= 0.0")
|
|
91
|
+
|
|
92
|
+
self.delay_alpha = float(delay_alpha)
|
|
93
|
+
self.veto_gain = float(veto_gain)
|
|
94
|
+
self.stride2_weight = float(stride2_weight)
|
|
95
|
+
self.output_gain = float(output_gain)
|
|
96
|
+
|
|
97
|
+
self._d_on: Optional[np.ndarray] = None
|
|
98
|
+
self._d_off: Optional[np.ndarray] = None
|
|
99
|
+
|
|
100
|
+
# Precompute wide-field LPTC radial unit vectors & equatorial HS weights
|
|
101
|
+
yy, xx = np.meshgrid(
|
|
102
|
+
np.arange(gh, dtype=np.float32),
|
|
103
|
+
np.arange(gw, dtype=np.float32),
|
|
104
|
+
indexing="ij",
|
|
105
|
+
)
|
|
106
|
+
cy = 0.5 * (gh - 1)
|
|
107
|
+
cx = 0.5 * (gw - 1)
|
|
108
|
+
dy = yy - cy
|
|
109
|
+
dx = xx - cx
|
|
110
|
+
rad = np.hypot(dx, dy)
|
|
111
|
+
inv_rad = np.where(rad > 1e-3, 1.0 / np.maximum(rad, 1e-3), 0.0)
|
|
112
|
+
self._ux = (dx * inv_rad).astype(np.float32)
|
|
113
|
+
self._uy = (dy * inv_rad).astype(np.float32)
|
|
114
|
+
|
|
115
|
+
# Equatorial Gaussian belt (models HS horizontal system cells)
|
|
116
|
+
norm_y = dy / max(0.5 * gh, 1.0)
|
|
117
|
+
hs_w = np.exp(-1.5 * (norm_y ** 2)).astype(np.float32)
|
|
118
|
+
self._hs_weights = hs_w / float(np.mean(hs_w))
|
|
119
|
+
|
|
120
|
+
def reset(self) -> None:
|
|
121
|
+
"""Clear delayed T4/T5 temporal filter states."""
|
|
122
|
+
self._d_on = None
|
|
123
|
+
self._d_off = None
|
|
124
|
+
|
|
125
|
+
def _compute_cardinal_stride(
|
|
126
|
+
self,
|
|
127
|
+
prompt: np.ndarray,
|
|
128
|
+
delayed: np.ndarray,
|
|
129
|
+
stride: int,
|
|
130
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
131
|
+
"""Evaluate 3-arm HR + BL motion across all 4 cardinal directions for one stride."""
|
|
132
|
+
h, w = prompt.shape
|
|
133
|
+
g = self.veto_gain
|
|
134
|
+
s = stride
|
|
135
|
+
|
|
136
|
+
pos_x = np.zeros((h, w), dtype=np.float32)
|
|
137
|
+
neg_x = np.zeros((h, w), dtype=np.float32)
|
|
138
|
+
pos_y = np.zeros((h, w), dtype=np.float32)
|
|
139
|
+
neg_y = np.zeros((h, w), dtype=np.float32)
|
|
140
|
+
|
|
141
|
+
if w > 2 * s:
|
|
142
|
+
f_c = prompt[:, s : w - s]
|
|
143
|
+
d_c = delayed[:, s : w - s]
|
|
144
|
+
f_l = prompt[:, : w - 2 * s]
|
|
145
|
+
d_l = delayed[:, : w - 2 * s]
|
|
146
|
+
f_r = prompt[:, 2 * s :]
|
|
147
|
+
d_r = delayed[:, 2 * s :]
|
|
148
|
+
|
|
149
|
+
# +X (rightward: left -> center, null veto from right + Mi4 center adaptation)
|
|
150
|
+
hr_px = np.maximum(0.0, d_l * f_c - 1.35 * d_c * f_l)
|
|
151
|
+
pos_x[:, s : w - s] = hr_px / (1.0 + g * d_r)
|
|
152
|
+
|
|
153
|
+
# -X (leftward: right -> center, null veto from left + Mi4 center adaptation)
|
|
154
|
+
hr_nx = np.maximum(0.0, d_r * f_c - 1.35 * d_c * f_r)
|
|
155
|
+
neg_x[:, s : w - s] = hr_nx / (1.0 + g * d_l)
|
|
156
|
+
|
|
157
|
+
if h > 2 * s:
|
|
158
|
+
f_c = prompt[s : h - s, :]
|
|
159
|
+
d_c = delayed[s : h - s, :]
|
|
160
|
+
f_u = prompt[: h - 2 * s, :]
|
|
161
|
+
d_u = delayed[: h - 2 * s, :]
|
|
162
|
+
f_d = prompt[2 * s :, :]
|
|
163
|
+
d_d = delayed[2 * s :, :]
|
|
164
|
+
|
|
165
|
+
# +Y (downward: up -> center, null veto from down + Mi4 center adaptation)
|
|
166
|
+
hr_py = np.maximum(0.0, d_u * f_c - 1.35 * d_c * f_u)
|
|
167
|
+
pos_y[s : h - s, :] = hr_py / (1.0 + g * d_d)
|
|
168
|
+
|
|
169
|
+
# -Y (upward: down -> center, null veto from up + Mi4 center adaptation)
|
|
170
|
+
hr_ny = np.maximum(0.0, d_d * f_c - 1.35 * d_c * f_d)
|
|
171
|
+
neg_y[s : h - s, :] = hr_ny / (1.0 + g * d_u)
|
|
172
|
+
|
|
173
|
+
return pos_x, neg_x, pos_y, neg_y
|
|
174
|
+
|
|
175
|
+
def _compute_pathway(
|
|
176
|
+
self, prompt: np.ndarray, delayed: np.ndarray
|
|
177
|
+
) -> Dict[str, np.ndarray]:
|
|
178
|
+
px1, nx1, py1, ny1 = self._compute_cardinal_stride(prompt, delayed, stride=1)
|
|
179
|
+
if self.stride2_weight > 0.0 and min(prompt.shape) > 6:
|
|
180
|
+
w2 = self.stride2_weight
|
|
181
|
+
px2, nx2, py2, ny2 = self._compute_cardinal_stride(prompt, delayed, stride=2)
|
|
182
|
+
scale = self.output_gain / (1.0 + w2)
|
|
183
|
+
px = (px1 + w2 * px2) * scale
|
|
184
|
+
nx = (nx1 + w2 * nx2) * scale
|
|
185
|
+
py = (py1 + w2 * py2) * scale
|
|
186
|
+
ny = (ny1 + w2 * ny2) * scale
|
|
187
|
+
else:
|
|
188
|
+
scale = self.output_gain
|
|
189
|
+
px = px1 * scale
|
|
190
|
+
nx = nx1 * scale
|
|
191
|
+
py = py1 * scale
|
|
192
|
+
ny = ny1 * scale
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
"+X": px.astype(np.float32, copy=False),
|
|
196
|
+
"-X": nx.astype(np.float32, copy=False),
|
|
197
|
+
"+Y": py.astype(np.float32, copy=False),
|
|
198
|
+
"-Y": ny.astype(np.float32, copy=False),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
def step(
|
|
202
|
+
self,
|
|
203
|
+
lamina_or_l1: Union[LaminaOutput, np.ndarray],
|
|
204
|
+
l2_off: Optional[np.ndarray] = None,
|
|
205
|
+
) -> MotionOutput:
|
|
206
|
+
"""Advance T4 (ON) and T5 (OFF) motion detectors by one frame."""
|
|
207
|
+
if isinstance(lamina_or_l1, LaminaOutput):
|
|
208
|
+
l1 = lamina_or_l1.l1_on
|
|
209
|
+
l2 = lamina_or_l1.l2_off
|
|
210
|
+
else:
|
|
211
|
+
if l2_off is None:
|
|
212
|
+
raise ValueError("l2_off must be provided when passing raw l1_on array")
|
|
213
|
+
l1 = np.asarray(lamina_or_l1, dtype=np.float32)
|
|
214
|
+
l2 = np.asarray(l2_off, dtype=np.float32)
|
|
215
|
+
|
|
216
|
+
if l1.shape != self.grid_shape or l2.shape != self.grid_shape:
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"Expected L1/L2 shape {self.grid_shape}, got {l1.shape} and {l2.shape}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if self._d_on is None or self._d_off is None:
|
|
222
|
+
self._d_on = (self.delay_alpha * l1).astype(np.float32)
|
|
223
|
+
self._d_off = (self.delay_alpha * l2).astype(np.float32)
|
|
224
|
+
zeros = np.zeros(self.grid_shape, dtype=np.float32)
|
|
225
|
+
empty_dirs = {"+X": zeros, "-X": zeros.copy(), "+Y": zeros.copy(), "-Y": zeros.copy()}
|
|
226
|
+
empty_dirs_off = {"+X": zeros.copy(), "-X": zeros.copy(), "+Y": zeros.copy(), "-Y": zeros.copy()}
|
|
227
|
+
return MotionOutput(
|
|
228
|
+
t4=empty_dirs,
|
|
229
|
+
t5=empty_dirs_off,
|
|
230
|
+
pos_x=zeros.copy(),
|
|
231
|
+
neg_x=zeros.copy(),
|
|
232
|
+
pos_y=zeros.copy(),
|
|
233
|
+
neg_y=zeros.copy(),
|
|
234
|
+
flow_x=zeros.copy(),
|
|
235
|
+
flow_y=zeros.copy(),
|
|
236
|
+
vx_flow=0.0,
|
|
237
|
+
vy_flow=0.0,
|
|
238
|
+
yaw_pan=0.0,
|
|
239
|
+
radial_divergence=0.0,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
t4 = self._compute_pathway(l1, self._d_on)
|
|
243
|
+
t5 = self._compute_pathway(l2, self._d_off)
|
|
244
|
+
|
|
245
|
+
# Update delayed low-pass states for next step
|
|
246
|
+
a = self.delay_alpha
|
|
247
|
+
self._d_on = ((1.0 - a) * self._d_on + a * l1).astype(np.float32, copy=False)
|
|
248
|
+
self._d_off = ((1.0 - a) * self._d_off + a * l2).astype(np.float32, copy=False)
|
|
249
|
+
|
|
250
|
+
pos_x = t4["+X"] + t5["+X"]
|
|
251
|
+
neg_x = t4["-X"] + t5["-X"]
|
|
252
|
+
pos_y = t4["+Y"] + t5["+Y"]
|
|
253
|
+
neg_y = t4["-Y"] + t5["-Y"]
|
|
254
|
+
|
|
255
|
+
flow_x = pos_x - neg_x
|
|
256
|
+
flow_y = pos_y - neg_y
|
|
257
|
+
|
|
258
|
+
vx_flow = float(np.mean(flow_x))
|
|
259
|
+
vy_flow = float(np.mean(flow_y))
|
|
260
|
+
yaw_pan = float(np.mean(flow_x * self._hs_weights))
|
|
261
|
+
radial_div = float(np.mean(flow_x * self._ux + flow_y * self._uy))
|
|
262
|
+
|
|
263
|
+
return MotionOutput(
|
|
264
|
+
t4=t4,
|
|
265
|
+
t5=t5,
|
|
266
|
+
pos_x=pos_x,
|
|
267
|
+
neg_x=neg_x,
|
|
268
|
+
pos_y=pos_y,
|
|
269
|
+
neg_y=neg_y,
|
|
270
|
+
flow_x=flow_x,
|
|
271
|
+
flow_y=flow_y,
|
|
272
|
+
vx_flow=vx_flow,
|
|
273
|
+
vy_flow=vy_flow,
|
|
274
|
+
yaw_pan=yaw_pan,
|
|
275
|
+
radial_divergence=radial_div,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def __call__(
|
|
279
|
+
self,
|
|
280
|
+
lamina_or_l1: Union[LaminaOutput, np.ndarray],
|
|
281
|
+
l2_off: Optional[np.ndarray] = None,
|
|
282
|
+
) -> MotionOutput:
|
|
283
|
+
return self.step(lamina_or_l1, l2_off)
|