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 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
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m necksight``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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)