OSCR

Retinal curl as a functional signal for heading estimation beyond the focus of expansion.

Code ↔ Paper

7 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 7 matches
  1. [1] § Results › Simulation results ↔ Python tree/curl-neural_model/ring_attractor_heading_v1.py, lines 144–266 · score 0.94 · Phase Portrait, drift dynamics, decoded heading, straight ahead prior, neural activity, ring attractor
  2. [2] § Results › Simulation results ↔ Python tree/curl-neural_model/ring_attractor_heading_v1.py, lines 144–266 · score 0.93 · neuron preferred headings, Phase portrait, sensory prior competition, normalized amplitude, straight ahead prior, ring position
  3. [3] § Appendix 2 › Neural network model of gaze-contingent heading bias ↔ Python tree/curl-neural_model/ring_attractor_heading_v1.py, lines 1–89 · score 0.92 · weak straight ahead, gaze modulated, bias emerges, neural model, heading representation, cortical
  4. [4] § Appendix 2 › Bias generation as sensory–prior competition ↔ Python tree/curl-neural_model/ring_attractor_heading_v1.py, lines 1–89 · score 0.83 · allows sensory evidence, weak straight ahead, weak prior, interaction, attractor, opposite
  5. [5] § Appendix 2 › Heading estimation and 3D path reconstruction ↔ Python tree/curl-neural_model/ring_attractor_heading_v1.py, lines 713–761 · score 0.71 · focal length, ring attractor, smoothed, horizontal, pixels, field
  6. [6] § Methods › Displays and conditions ↔ eLife-VOR-RA-2026-110770/3D_stimulus_generator.py, lines 102–163 · score 0.58 · simplex noise, stimulus generator, shaders, motion
  7. [7] § Appendix 2 › Local motion encoding ↔ Python tree/curl-neural_model/ring_attractor_heading_v1.py, lines 713–761 · score 0.51 · optical flow, encoding, V1, MT, motion, model

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 947 lines · 40 KB · no license · 6 matches

  1. #!/usr/bin/env python3
  2. """
  3. Neural Heading Estimator with Inhibitory Recurrent Push-Pull Mechanism
  4. This script adds plotting and saving features to curl_recurrent_heading_network.py
  5. -----------------------------------------------------------
  6. Eliminates the explicit Kaway term by using asymmetric recurrent connectivity
  7. to naturally create push-pull dynamics in the ring attractor.
  8. Key changes:
  9. 1. Removed Kaway parameter entirely
  10. 2. Added asymmetric recurrent weights that create natural opposition
  11. 3. Simplified parameter space
  12. 4. More biologically plausible connectivity
  13. Neurophysiological basis:
  14. Biologically plausible: Uses asymmetric recurrent connectivity instead of artificial "anti-gaze" lobe
  15. - Cortical circuits often have asymmetric lateral connections
  16. - Inhibition can spread preferentially in specific directions
  17. - Natural push-pull emerges from network dynamics rather than explicit coding
  18. ┌─────────────────────────────────────────────────────────────┐
  19. │ VISUAL PROCESSING │
  20. │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
  21. │ │ V1 │ -> │ MT │ -> │ MSTd │ │
  22. │ │ Orientation │ │ Direction │ │ Flow Pattern │ │
  23. │ │ Selectivity│ │ Selectivity│ │ Analysis │ │
  24. │ └─────────────┘ └─────────────┘ └─────────────────┘ │
  25. └─────────────────────────────┬───────────────────────────────┘
  26. │
  27. Curl Signal (ω̄)
  28. │
  29. ┌─────────────────────────────────────────────────────────────┐
  30. │ PARIETAL CORTEX (PPC) - Ring Attractor │
  31. │ │
  32. │ ┌─────────────────────────────────────────────────────┐ │
  33. │ │ Heading Representation │ │
  34. │ │ │ │
  35. │ │ [← Left] ○────○────○────○────○────○────○ [Right →] │
  36. │ │ -θmax │ │ │ │ │ +θmax │ │
  37. │ │ △ △ △ △ △ │ │
  38. │ │ │ │ │ │ │ │ │
  39. │ │ Gaze-Inhibited Input │ Prior Input │ │
  40. │ │ (Kp × ω̄) │ (Straight-ahead) │ │
  41. │ │ │ │ │
  42. │ │ Asymmetric Recurrent Connections │ │
  43. │ │ (Stronger inhibition away from center) │ │
  44. │ └─────────────────────────────────────────────────────┘ │
  45. │ │
  46. │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
  47. │ │ Eye Position│ │ Motor Plan │ │ Vestibular │ │
  48. │ │ Signals │ │ Integration │ │ Integration│ │
  49. │ └─────────────┘ └─────────────┘ └─────────────┘ │
  50. └─────────────────────────────────────────────────────────────┘
  51. Parameter Space Revelation
  52. The key parameters are actually:
  53. I0 (prior strength) - determines flexibility
  54. K_p (inhibition gain) - determines bias magnitude
  55. sigma_prior - determines spatial scale of prior
  56. Bias emerges from gaze-modulated inhibition interacting with a weak straight-ahead prior and slightly asymmetric recurrent connectivity in PPC.
  57. Revised Neurophysiological Story
  58. The bias emerges from:
  59. Gaze-modulated inhibition from MSTd to PPC
  60. Weak straight-ahead prior in PPC
  61. Standard center-surround recurrence in cortical circuits
  62. the essential mechanism is sensory-prior competition, plus asymmetric recurrence (not determinant).
  63. This is actually a more elegant and biologically plausible explanation!
  64. The system needs to be flexible enough (weak prior) to allow sensory evidence to shift the heading representation away from gaze.
  65. """
  66. import argparse
  67. import csv
  68. import math
  69. import os
  70. from dataclasses import dataclass
  71. from typing import Optional, Tuple
  72. import cv2
  73. import numpy as np
  74. import matplotlib.pyplot as plt # Added for plotting
  75. # ---------------------------- I/O helpers ---------------------------- #
  76. def load_gaze_csv(gaze_csv_path: str):
  77. xs, ys, vis = [], [], []
  78. has_visible = False
  79. with open(gaze_csv_path, "r", newline="") as f:
  80. reader = csv.DictReader(f)
  81. fields = [fn.strip() for fn in (reader.fieldnames or [])]
  82. required = {"x_px_ui", "y_px_ui"}
  83. if not required.issubset(set(fields)):
  84. raise ValueError(f"gaze_csv must contain columns {required}, found: {fields}")
  85. has_visible = ("visible" in fields)
  86. for row in reader:
  87. try:
  88. x = float(row["x_px_ui"]) ; y = float(row["y_px_ui"])
  89. except Exception:
  90. x, y = float("nan"), float("nan")
  91. xs.append(x) ; ys.append(y)
  92. if has_visible:
  93. try:
  94. v = int(float(row["visible"]))
  95. except Exception:
  96. v = 1
  97. vis.append(v)
  98. return {
  99. "x": np.array(xs, dtype=np.float32),
  100. "y": np.array(ys, dtype=np.float32),
  101. "visible": (np.array(vis, dtype=np.int32) if has_visible else None),
  102. }
  103. def get_gaze_for_abs_frame(gaze_data, abs_idx: int, w: int, h: int,
  104. last_valid: Optional[Tuple[float, float]]) -> Tuple[float, float, Optional[Tuple[float, float]]]:
  105. if gaze_data is None:
  106. return (w/2.0), (h/2.0), ((w/2.0), (h/2.0))
  107. n = gaze_data["x"].shape[0]
  108. idx = min(abs_idx, n - 1) if n > 0 else 0
  109. gx = float(gaze_data["x"][idx]) if n > 0 else float("nan")
  110. gy = float(gaze_data["y"][idx]) if n > 0 else float("nan")
  111. visible = True
  112. if gaze_data.get("visible") is not None and n > 0:
  113. visible = bool(int(gaze_data["visible"][idx]))
  114. def valid(x, y):
  115. return (not (math.isnan(x) or math.isnan(y))) and (0 <= x < w) and (0 <= y < h)
  116. if visible and valid(gx, gy):
  117. return gx, gy, (gx, gy)
  118. if last_valid is not None and valid(*last_valid):
  119. return last_valid[0], last_valid[1], last_valid
  120. return (w / 2.0), (h / 2.0), (w / 2.0, h / 2.0)
  121. # --- NEW CLASS FOR DATA COLLECTION ---
  122. class DataCollector:
  123. def __init__(self, phi):
  124. # self.n_units = n_units
  125. self.phi = phi
  126. self.history_x = [] # Neural activity over time
  127. self.history_I_total = [] # total activity
  128. self.history_I_gaze = [] # gaze inhibition
  129. self.history_I_prior = [] # prior
  130. self.history_theta = [] # Decoded heading over time
  131. # self.history_omega = [] # Input curl over time
  132. def collect(self, x, I_total, I_gaze, I_prior, theta):
  133. self.history_x.append(x.copy())
  134. self.history_I_total.append(I_total.copy())
  135. self.history_I_gaze.append(I_gaze.copy())
  136. self.history_I_prior.append(I_prior.copy())
  137. self.history_theta.append(theta)
  138. # self.history_omega.append(omega)
  139. def export_to_csv(self, prefix="heading_model"):
  140. # 1. Save Activity Matrix (Rows=Frames, Cols=Neurons)
  141. # Header: time, unit_1_pxl, unit_2_pxl, ...
  142. header = ["frame"] + [f"unit_{p:.2f}" for p in self.phi]
  143. with open(f"{prefix}_activity.csv", "w", newline="") as f:
  144. writer = csv.writer(f)
  145. writer.writerow(header)
  146. for i, row in enumerate(self.history_x):
  147. writer.writerow([i] + list(row))
  148. # New: Detailed Input CSV
  149. header = ["frame", "type"] + [f"unit_{p:.2f}" for p in self.phi]
  150. with open(f"{prefix}_inputs.csv", "w", newline="") as f:
  151. writer = csv.writer(f)
  152. writer.writerow(header)
  153. for i in range(len(self.history_I_total)):
  154. # Write three rows per frame so she can filter by 'type'
  155. writer.writerow([i, "total"] + list(self.history_I_total[i]))
  156. writer.writerow([i, "gaze_inhibition"] + list(self.history_I_gaze[i]))
  157. writer.writerow([i, "prior"] + list(self.history_I_prior[i]))
  158. # 3. Save Summary Dynamics (For Phase Portrait)
  159. with open(f"{prefix}_dynamics.csv", "w", newline="") as f:
  160. writer = csv.writer(f)
  161. writer.writerow(["frame", "theta_hat", "d_theta_dt"])
  162. thetas = self.history_theta
  163. for i in range(len(thetas)):
  164. d_dt = (thetas[i] - thetas[i-1]) if i > 0 else 0
  165. writer.writerow([i, thetas[i], d_dt])
  166. print(f"Data exported to {prefix}_activity.csv, _inputs.csv, and _dynamics.csv")
  167. def save_plots(self, filename="diagnostic_plots.png",px2deg=1.0):
  168. if not self.history_x: return
  169. X = np.array(self.history_x)
  170. I_total = np.array(self.history_I_total)
  171. I_gaze = np.array(self.history_I_gaze)
  172. I_prior = np.array(self.history_I_prior)
  173. thetas = np.array(self.history_theta)
  174. fig, axes = plt.subplots(2, 2, figsize=(12, 10))
  175. # 1. Kymograph (Space-Time Heatmap)
  176. ax = axes[0, 0]
  177. y_min = self.phi[0] * px2deg
  178. y_max = self.phi[-1] * px2deg
  179. im = ax.imshow(X.T, aspect='auto', extent=[0, len(X), y_min, y_max], origin='lower', cmap='viridis')
  180. ax.plot(thetas*px2deg, color='white', linestyle='--', alpha=0.5, label='Decoded Heading')
  181. ax.set_title("Kymograph (Neural Activity over Time)")
  182. ax.set_ylabel("Neuron Preferred Heading (pixels)")
  183. ax.set_xlabel("Frame Index")
  184. # --- TO RESTRICT THE VIEW ---
  185. #ax.set_ylim(-90, 90)
  186. plt.colorbar(im, ax=ax)
  187. # 2. Phase Portrait (Delta Theta vs Theta)
  188. ax = axes[0, 1]
  189. d_theta = np.diff(thetas)
  190. ax.scatter(thetas[:-1], d_theta, c=np.arange(len(d_theta)), cmap='plasma', s=5, alpha=0.6)
  191. ax.axhline(0, color='black', lw=1)
  192. ax.set_title("Phase Portrait (Drift Dynamics)")
  193. ax.set_xlabel("Heading Estimate (theta -pixels-)")
  194. ax.set_ylabel("Change in Heading (d_theta/dt)")
  195. # 3. Snapshot of Input vs Activity (Final Frame)
  196. # This is the "Mechanistic Proof" showing the balance of forces
  197. ax = axes[1, 0]
  198. phi_deg = self.phi * px2deg
  199. # Plot Neural Activity (The Result)
  200. #ax.plot(phi_deg, X[-1] / (np.max(X[-1]) + 1e-9), label='Neural Activity (Bump)', color='black', lw=3)
  201. ax.plot(phi_deg, I_total[-1] / (np.max(I_total[-1]) + 1e-9), label='Neural Activity (Bump)', color='black', lw=3)
  202. # Plot Gaze Inhibition (The Pull/Sensory) - Normalize to show shape
  203. ax.plot(phi_deg, I_gaze[-1] / (np.max(np.abs(I_gaze[-1])) + 1e-9),
  204. label='Gaze Inhibition (Pull)', color='red', linestyle='--', alpha=0.8)
  205. # Plot Prior (The Push/Internal) - Normalize to show shape
  206. ax.plot(phi_deg, I_prior[-1] / (np.max(np.abs(I_prior[-1])) + 1e-9),
  207. label='Straight-Ahead Prior (Push)', color='blue', linestyle=':', alpha=0.8)
  208. ax.axvline(0, color='gray', lw=0.5, alpha=0.5) # Center line
  209. ax.set_title("Mechanism: Sensory-Prior Competition")
  210. ax.set_xlabel("Ring Position (deg)")
  211. ax.set_ylabel("Normalized Amplitude")
  212. ax.legend(fontsize='small', loc='upper right')
  213. # 4. Circular Variance (Coherence of the bump)
  214. ax = axes[1, 1]
  215. # Calculate coherence: R = |sum(x * exp(i*phi))| / sum(x)
  216. ang = 2 * np.pi * (self.phi - self.phi[0]) / (self.phi[-1] - self.phi[0])
  217. z = np.array([np.sum(xt * np.exp(1j * ang)) / np.sum(xt) for xt in X])
  218. coherence = np.abs(z)
  219. ax.plot(coherence, color='blue')
  220. ax.set_ylim([0, 1.1])
  221. ax.set_title("Bump Coherence (1 - Circular Variance)")
  222. ax.set_ylabel("Coherence (0 to 1)")
  223. ax.set_xlabel("Frame Index")
  224. plt.tight_layout()
  225. plt.savefig(filename)
  226. print(f"Analysis plots saved to {filename}")
  227. # ---------------------------- Sampling ------------------------------- #
  228. def sample_points_relative_cam(h: int, w: int, N: int, R_pixels: float, seed: int = 42) -> np.ndarray:
  229. rng = np.random.default_rng(seed)
  230. pts = []
  231. Rint = max(1, int(round(R_pixels)))
  232. tries = 0
  233. while len(pts) < N and tries < 300 * N:
  234. dx = rng.integers(-Rint, Rint + 1)
  235. dy = rng.integers(-Rint, Rint + 1)
  236. if dx*dx + dy*dy <= R_pixels*R_pixels:
  237. pts.append((int(dx), int(dy)))
  238. tries += 1
  239. while len(pts) < N:
  240. pts.append((0, 0))
  241. return np.array(pts, dtype=np.int32)
  242. # --------------------------- MT-like encode -------------------------- #
  243. angles = np.deg2rad(np.arange(0, 360, 45, dtype=np.float32))
  244. E_DIRS = np.stack([np.cos(angles), np.sin(angles)], axis=1).astype(np.float32) # (8,2)
  245. def encode_direction_pools(flow_xy: np.ndarray, points_abs: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
  246. """Rectified cosine energy in 8 sectors at the absolute points (MT-like pools)."""
  247. N = points_abs.shape[0]
  248. H, W = flow_xy.shape[:2]
  249. xs = np.clip(points_abs[:, 0], 0, W - 1)
  250. ys = np.clip(points_abs[:, 1], 0, H - 1)
  251. uvs = flow_xy[ys, xs, :].astype(np.float32) # (N,2)
  252. mk = np.maximum(0.0, uvs @ E_DIRS.T) # (N,8) mk: 8 numbers per sample, showing how much motion aligns with each direction
  253. spd = np.sqrt(np.sum(uvs**2, axis=1) + 1e-8) # overall speed at each sample
  254. return mk, spd
  255. def vector_from_sectors(m: np.ndarray, eps: float = 1e-6) -> np.ndarray:
  256. """Estimate a 2D velocity vector from 8 sector energies for each sample."""
  257. total = np.sum(m, axis=1, keepdims=True) + eps
  258. v = (m @ E_DIRS) / total # (N,2)
  259. return v
  260. # ------------------------ MSTd-like curl code ------------------------ #
  261. @dataclass
  262. class CurlCode:
  263. omega_bar: float # signed mean tangential component around gaze
  264. mean_speed: float # mean |v| from sector reconstruction
  265. n_used: int # number of samples used (excludes near-center)
  266. def curl_from_tangential(mk: np.ndarray, points_abs: np.ndarray, gaze_xy: Tuple[float, float],
  267. r_min_px: float = 4.0, trim_q: float = 0.1, eps: float = 1e-6) -> CurlCode:
  268. """Compute a gaze-centered curl proxy via tangential projection."""
  269. v_est = vector_from_sectors(mk) # (N,2)
  270. gx, gy = gaze_xy
  271. r = points_abs.astype(np.float32) - np.array([[gx, gy]], dtype=np.float32) # (N,2)
  272. r2 = np.sum(r*r, axis=1)
  273. keep = r2 > (r_min_px * r_min_px)
  274. r = r[keep]
  275. v = v_est[keep]
  276. if r.shape[0] == 0:
  277. return CurlCode(omega_bar=0.0, mean_speed=0.0, n_used=0)
  278. r_norm = np.sqrt(np.sum(r*r, axis=1, keepdims=True)) + eps
  279. t_hat = np.concatenate([-r[:, 1:2], r[:, 0:1]], axis=1) / r_norm
  280. omega_samples = np.sum(v * t_hat, axis=1)
  281. # to correct discrepancy for distances
  282. #mean_eccentricity = np.mean(r_norm)
  283. #omega_samples = omega_samples / (mean_eccentricity + eps)
  284. # DEBUG: Check if sign matches expected biological convention
  285. #gaze_az_px = gx - (400/2) # Approximate gaze azimuth
  286. #print(f"DEBUG: Gaze az: {gaze_az_px:+.1f}, mean omega: {np.mean(omega_samples):+.4f}")
  287. ql, qh = np.quantile(omega_samples, [trim_q, 1 - trim_q])
  288. mask = (omega_samples >= ql) & (omega_samples <= qh)
  289. if np.any(mask):
  290. omega_bar = float(np.mean(omega_samples[mask]))
  291. else:
  292. omega_bar = float(np.mean(omega_samples))
  293. mean_speed = float(np.mean(np.sqrt(np.sum(v*v, axis=1))))
  294. return CurlCode(omega_bar=omega_bar, mean_speed=mean_speed, n_used=int(r.shape[0]))
  295. # ------------------------ Ring-attractor dynamics -------------------- #
  296. @dataclass
  297. class RingParams:
  298. n_units: int
  299. az_range_px: float # ring spans [-az_range_px, +az_range_px]
  300. tau: float # time constant (seconds)
  301. recurrent_gain: float
  302. sigma_exc: float # px
  303. sigma_inh: float # px
  304. sigma_kappa: float # width of gaze input lobe (px)
  305. asymmetry_strength: float # New: controls push-pull asymmetry
  306. class RingAttractor:
  307. def __init__(self, params: RingParams, seed: int = 0):
  308. self.p = params
  309. self.phi = np.linspace(-self.p.az_range_px, self.p.az_range_px, self.p.n_units, endpoint=False).astype(np.float32)
  310. # Create asymmetric Mexican hat connectivity for natural push-pull
  311. W = self._create_asymmetric_connectivity()
  312. self.W = W
  313. # Store connectivity patterns for visualization
  314. self.connection_profiles = self._compute_connection_profiles()
  315. # Seed a centered bump
  316. d0 = np.array([self._circ_dist(phi_i, 0.0) for phi_i in self.phi], dtype=np.float32)
  317. self.x = np.exp(-(d0**2) / (2 * (self.p.sigma_exc**2) + 1e-6)).astype(np.float32)
  318. self.x /= (np.max(self.x) + 1e-6)
  319. def _create_asymmetric_connectivity(self) -> np.ndarray:
  320. d = self._circ_dist_matrix(self.phi)
  321. # Base Mexican hat
  322. Ae = 1.9 # Excitatory amplitude
  323. Ai = 1.0 # Inhibitory amplitude
  324. Wexc = Ae * np.exp(-(d**2) / (2 * (self.p.sigma_exc**2) + 1e-6))
  325. Winh = Ai * np.exp(-(d**2) / (2 * (self.p.sigma_inh**2) + 1e-6))
  326. W_base = Wexc - Winh
  327. # Wexc = np.exp(-(d**2) / (2 * (self.p.sigma_exc**2) + 1e-6))
  328. # Winh = np.exp(-(d**2) / (2 * (self.p.sigma_inh**2) + 1e-6))
  329. # W_base = Wexc - Winh
  330. # More pronounced asymmetry: directional bias
  331. asymmetry_mod = np.ones_like(W_base)
  332. center_idx = self.p.n_units // 2
  333. for i in range(self.p.n_units):
  334. for j in range(self.p.n_units):
  335. # Create left-right asymmetry based on position relative to center
  336. if self.phi[j] > self.phi[i]: # j is right of i
  337. if W_base[i, j] < 0: # inhibitory connection
  338. asymmetry_mod[i, j] += self.p.asymmetry_strength#*sign_lr
  339. elif self.phi[j] < self.phi[i]: # j is left of i
  340. if W_base[i, j] < 0: # inhibitory connection
  341. asymmetry_mod[i, j] += self.p.asymmetry_strength*.5 #sign_lr # *.5 weaker
  342. W_asym = W_base * asymmetry_mod
  343. # Normalize to control stability
  344. v = np.random.default_rng(seed=42).standard_normal(size=(self.p.n_units, 1)).astype(np.float32)
  345. for _ in range(25):
  346. v = W_asym @ v
  347. v_norm = np.linalg.norm(v) + 1e-6
  348. v /= v_norm
  349. #lam = float((v.T @ (W_asym @ v)) / (v.T @ v)) # gives a warning
  350. lam = ((v.T @ (W_asym @ v)) / (v.T @ v)).item()
  351. W_normalized = (self.p.recurrent_gain / (abs(lam) + 1e-6)) * W_asym
  352. return W_normalized.astype(np.float32)
  353. def _compute_connection_profiles(self) -> dict:
  354. """Compute connection patterns for visualization."""
  355. profiles = {}
  356. # Profile from center unit
  357. center_idx = self.p.n_units // 2
  358. profiles['from_center'] = self.W[center_idx, :]
  359. # Profile from left unit (negative azimuth)
  360. left_idx = int(self.p.n_units * 0.25)
  361. profiles['from_left'] = self.W[left_idx, :]
  362. # Profile from right unit (positive azimuth)
  363. right_idx = int(self.p.n_units * 0.75)
  364. profiles['from_right'] = self.W[right_idx, :]
  365. return profiles
  366. @staticmethod
  367. def _circ_dist_matrix(phi: np.ndarray) -> np.ndarray:
  368. R = float(np.max(phi) - np.min(phi)) / 2.0
  369. ang = 2 * np.pi * (phi - (-R)) / (2 * R + 1e-9)
  370. diff = ang[:, None] - ang[None, :]
  371. diff = (diff + np.pi) % (2 * np.pi) - np.pi
  372. d = np.abs(diff) * (R / np.pi)
  373. return d.astype(np.float32)
  374. def _circ_dist(self, a: float, b: float) -> float:
  375. R = float(self.p.az_range_px)
  376. ang_a = 2 * np.pi * (a - (-R)) / (2 * R + 1e-9)
  377. ang_b = 2 * np.pi * (b - (-R)) / (2 * R + 1e-9)
  378. d = (ang_a - ang_b + np.pi) % (2 * np.pi) - np.pi
  379. return float(abs(d) * (R / np.pi))
  380. def step(self, dt: float, omega_bar: float, gaze_az_px: float, Kp: float,
  381. noise_std: float = 0.0, *,
  382. I0: float = 0.0, sigma_prior: Optional[float] = None, theta_prior_px: float = 0.0):
  383. """Step the ring dynamics """
  384. # Only gaze-centered inhibitory input
  385. d_g = np.array([self._circ_dist(phi_i, gaze_az_px) for phi_i in self.phi], dtype=np.float32)
  386. kappa_gaze = np.exp(-(d_g**2) / (2 * (self.p.sigma_kappa**2) + 1e-6))
  387. #I = (-Kp * float(omega_bar)) * kappa_gaze
  388. # Always inhibit at gaze position, use |ω̄| for strength
  389. I_gaze = (-Kp * abs(float(omega_bar))) * kappa_gaze # I_gaze,j in equation
  390. # Baseline prior (straight-ahead expectation)
  391. #if I0 > 0.0:
  392. if sigma_prior is None:
  393. sigma_prior = self.p.sigma_kappa
  394. d_p = np.array([self._circ_dist(phi_i, theta_prior_px) for phi_i in self.phi], dtype=np.float32)
  395. I_prior = I0 * np.exp(-(d_p**2) / (2 * (sigma_prior**2) + 1e-6))
  396. I_total = I_gaze + I_prior
  397. if noise_std > 0:
  398. I_total += np.random.normal(0.0, noise_std, size=I_total.shape).astype(np.float32)
  399. # Euler update - push-pull emerges from recurrent weights
  400. xdot = -self.x + (self.W @ self.x) + I_total
  401. self.x = self.x + (dt / self.p.tau) * xdot
  402. #print(f"I0={I0}, max_activity={np.max(self.x)}, bump_pos={self.decode_theta_px()}")
  403. # Rectify & mild normalization
  404. # TEMPORARILY REMOVE:
  405. self.x = np.maximum(self.x, 0.0)
  406. total = np.sum(self.x)
  407. if total > 1e-6:
  408. self.x /= (1.0 + 0.0 * total)
  409. return I_total, I_gaze, I_prior
  410. def decode_theta_px(self) -> float:
  411. R = float(self.p.az_range_px)
  412. ang = 2 * np.pi * (self.phi - (-R)) / (2 * R + 1e-9)
  413. z = np.sum(self.x * np.exp(1j * ang))
  414. if np.abs(z) < 1e-9:
  415. return 0.0
  416. ang_mean = float(np.angle(z))
  417. theta_px = -R + (ang_mean % (2 * np.pi)) * (2 * R) / (2 * np.pi)
  418. if theta_px > R:
  419. theta_px -= 2 * R
  420. return float(theta_px)
  421. # --------------------------- Visualization ---------------------------- #
  422. def _render_connectivity_panel(width: int, height: int, ring: RingAttractor,
  423. gaze_az_px: float, omega_bar: float, Kp: float,
  424. I0: float, sigma_prior: float) -> np.ndarray:
  425. """Simplified visualization showing only essential inputs and activity."""
  426. panel = np.full((height, width, 3), 40, dtype=np.uint8)
  427. n_units = ring.p.n_units
  428. phi = ring.phi
  429. # Normalize for display
  430. def normalize_profile(prof):
  431. prof = prof.copy()
  432. max_val = np.max(np.abs(prof)) + 1e-6
  433. return np.clip(prof / max_val, 0, 1)
  434. xs = np.linspace(0, width-1, n_units).astype(np.int32)
  435. # 1. Current neural activity (most important)
  436. activity_norm = normalize_profile(ring.x)
  437. # 2. Input profiles (simplified)
  438. d_g = np.array([ring._circ_dist(phi_i, gaze_az_px) for phi_i in phi], dtype=np.float32)
  439. kappa_gaze = np.exp(-(d_g**2) / (2 * (ring.p.sigma_kappa**2) + 1e-6))
  440. gaze_input = (-Kp * float(omega_bar)) * kappa_gaze
  441. gaze_norm = normalize_profile(gaze_input)
  442. # Prior input (only show if significant)
  443. if I0 > 0.1: # Only show prior if it's substantial
  444. d_p = np.array([ring._circ_dist(phi_i, 0.0) for phi_i in phi], dtype=np.float32)
  445. prior_input = I0 * np.exp(-(d_p**2) / (2 * (sigma_prior**2) + 1e-6))
  446. prior_norm = normalize_profile(prior_input)
  447. else:
  448. prior_norm = np.zeros_like(phi)
  449. # Plot in single row: activity + inputs
  450. row_height = height - 20
  451. y_offset = 10
  452. # Neural activity (white - most important)
  453. for i in range(n_units-1):
  454. y1 = int((1.0 - activity_norm[i]) * row_height + y_offset)
  455. y2 = int((1.0 - activity_norm[i+1]) * row_height + y_offset)
  456. cv2.line(panel, (xs[i], y1), (xs[i+1], y2), (255, 255, 255), 2, cv2.LINE_AA)
  457. # Gaze input (red)
  458. for i in range(n_units-1):
  459. y1 = int((1.0 - gaze_norm[i]) * row_height + y_offset)
  460. y2 = int((1.0 - gaze_norm[i+1]) * row_height + y_offset)
  461. cv2.line(panel, (xs[i], y1), (xs[i+1], y2), (0, 0, 200), 1, cv2.LINE_AA)
  462. # Prior input (gray, only if significant)
  463. if I0 > 0.1:
  464. for i in range(n_units-1):
  465. y1 = int((1.0 - prior_norm[i]) * row_height + y_offset)
  466. y2 = int((1.0 - prior_norm[i+1]) * row_height + y_offset)
  467. cv2.line(panel, (xs[i], y1), (xs[i+1], y2), (100, 100, 100), 1, cv2.LINE_AA)
  468. # Add vertical line at gaze position
  469. gaze_idx = np.argmin(np.abs(phi - gaze_az_px))
  470. gaze_x = xs[gaze_idx]
  471. cv2.line(panel, (gaze_x, y_offset), (gaze_x, y_offset + row_height), (0, 255, 255), 1)
  472. # Add vertical line at current heading
  473. theta_px = ring.decode_theta_px()
  474. theta_idx = np.argmin(np.abs(phi - theta_px))
  475. theta_x = xs[theta_idx]
  476. cv2.line(panel, (theta_x, y_offset), (theta_x, y_offset + row_height), (0, 0, 255), 1)
  477. # Simple labels
  478. cv2.putText(panel, "Activity(white) GazeIn(red) Gaze|->(yellow) Heading|->(red)",
  479. (5, height-5), cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255,255,255), 1)
  480. # Border
  481. cv2.rectangle(panel, (0,0), (width-1, height-1), (80,80,80), 1)
  482. overlay = cv2.cvtColor(panel, cv2.COLOR_BGR2BGRA)
  483. overlay[:, :, 3] = int(0.8 * 255)
  484. return overlay
  485. # --------------------------- Main routine ---------------------------- #
  486. def run(
  487. video_path: str,
  488. gaze_csv: Optional[str],
  489. output_csv: str,
  490. display: bool,
  491. step: int,
  492. resize_width: int,
  493. sample_count: int,
  494. sample_radius_frac: float,
  495. pyr_scale: float,
  496. levels: int,
  497. winsize: int,
  498. iterations: int,
  499. poly_n: int,
  500. poly_sigma: float,
  501. seed: int,
  502. Kp: float,
  503. smooth_alpha: float,
  504. omega_sign: float,
  505. # Ring params
  506. n_units: int,
  507. az_range_frac: float,
  508. tau_ms: float,
  509. recurrent_gain: float,
  510. sigma_exc_frac: float,
  511. sigma_inh_frac: float,
  512. sigma_kappa_frac: float,
  513. asymmetry_strength: float,
  514. I0: float,
  515. sigma_prior_frac: float,
  516. noise_std: float,
  517. show_kappa: bool,
  518. ):
  519. if not os.path.isfile(video_path):
  520. raise FileNotFoundError(f"Cannot find video: {video_path}")
  521. # 1. Extract the name (e.g., 'trial_0' from 'path/to/trial_0.mp4')
  522. video_base_name = os.path.splitext(os.path.basename(video_path))[0]
  523. gaze_data = load_gaze_csv(gaze_csv) if gaze_csv else None
  524. cap = cv2.VideoCapture(video_path)
  525. if not cap.isOpened():
  526. raise FileNotFoundError(f"Cannot open video: {video_path}")
  527. ok, frame0 = cap.read()
  528. if not ok:
  529. raise RuntimeError("Empty video or cannot read first frame.")
  530. fps = cap.get(cv2.CAP_PROP_FPS) # usually 30 fps
  531. if fps <= 1e-3 or math.isnan(fps):
  532. fps = 60.0
  533. dt_frame = float(step) / float(fps)
  534. if resize_width > 0 and frame0.shape[1] != resize_width:
  535. scale = resize_width / frame0.shape[1]
  536. frame0 = cv2.resize(frame0, (int(frame0.shape[1] * scale), int(frame0.shape[0] * scale)), interpolation=cv2.INTER_AREA)
  537. gray0 = cv2.cvtColor(frame0, cv2.COLOR_BGR2GRAY)
  538. h, w = gray0.shape[:2]
  539. cx_cam = w / 2.0
  540. cy_cam = h / 2.0
  541. R_samples = sample_radius_frac * min(h, w) * 0.5
  542. points_rel_cam = sample_points_relative_cam(h, w, sample_count, R_pixels=R_samples, seed=seed)
  543. # Define your camera's horizontal FOV (e.g., 90 degrees)
  544. H_FOV = 100.0
  545. # Redefine the ring to span -90 to 90 degrees instead of pixels
  546. az_range_px = az_range_frac * (w * 0.5) # 0.9 -> 360 default
  547. az_range_px2deg = ((H_FOV/2)/(w/2))
  548. sigma_exc = sigma_exc_frac * az_range_px # sigma 0.10 default
  549. sigma_inh = sigma_inh_frac * az_range_px # sigma 0.3 default
  550. sigma_kappa = sigma_kappa_frac * az_range_px # sigma_kappa controls the SPATIAL SPREAD of the gaze-centered inhibition.
  551. #Small sigma_kappa: Narrow Gaussian → Localized inhibition only at exact gaze position
  552. I0 = max(I0, 1e-6) # Ensure some minimal prior
  553. asymmetry_strength = min(asymmetry_strength,1.0) # max value of 1
  554. sigma_prior = sigma_prior_frac * az_range_px #az_range_px
  555. ring = RingAttractor(
  556. RingParams(
  557. n_units=n_units, # default 180
  558. az_range_px=az_range_px, #az_range_px,
  559. tau=float(tau_ms) / 1000.0, # time constant
  560. recurrent_gain=recurrent_gain,
  561. sigma_exc=sigma_exc,
  562. sigma_inh=sigma_inh,
  563. sigma_kappa=sigma_kappa,
  564. asymmetry_strength=asymmetry_strength,
  565. ),
  566. seed=seed,
  567. )
  568. #print(ring.phi)
  569. collector = DataCollector(ring.phi)
  570. os.makedirs(os.path.dirname(output_csv) or ".", exist_ok=True)
  571. writer = csv.writer(open(output_csv, "w", newline=""))
  572. writer.writerow([
  573. "frame_proc", "frame_abs",
  574. "theta_px", "pred_px",
  575. "omega_bar", "mean_speed", "n_used", "asymmetry_strength","I0",
  576. "cx_gaze", "cy_gaze"
  577. ])
  578. prev_gray = gray0
  579. abs_frame_idx = 0
  580. proc_frame_idx = 0
  581. last_valid_gaze: Optional[Tuple[float, float]] = None
  582. omega_prev: Optional[float] = None
  583. color_pts = (200, 200, 200)
  584. color_pred = (0, 0, 255)
  585. color_gaze = (0, 255, 255)
  586. while True:
  587. video_ended = False
  588. for _ in range(step):
  589. ok, frame = cap.read()
  590. abs_frame_idx += 1
  591. if not ok:
  592. video_ended = True
  593. break # break for
  594. #cap.release()
  595. #if display:
  596. # cv2.destroyAllWindows()
  597. #return
  598. if video_ended:
  599. print("End of video reached.")
  600. break # break while
  601. if resize_width > 0 and frame.shape[1] != resize_width:
  602. scale = resize_width / frame.shape[1]
  603. frame = cv2.resize(frame, (int(frame.shape[1] * scale), int(frame.shape[0] * scale)), interpolation=cv2.INTER_AREA)
  604. gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
  605. cx_gaze, cy_gaze, last_valid_gaze = get_gaze_for_abs_frame(
  606. gaze_data, abs_frame_idx, w, h, last_valid_gaze
  607. )
  608. flow = cv2.calcOpticalFlowFarneback( # compute flow field
  609. prev_gray, gray, None,
  610. pyr_scale=pyr_scale, levels=levels, winsize=winsize,
  611. iterations=iterations, poly_n=poly_n, poly_sigma=poly_sigma, flags=0
  612. )
  613. points_abs = np.empty_like(points_rel_cam)
  614. points_abs[:, 0] = np.round(points_rel_cam[:, 0] + cx_gaze).astype(np.int32)
  615. points_abs[:, 1] = np.round(points_rel_cam[:, 1] + cy_gaze).astype(np.int32)
  616. points_abs[:, 0] = np.clip(points_abs[:, 0], 0, w - 1)
  617. points_abs[:, 1] = np.clip(points_abs[:, 1], 0, h - 1)
  618. mk, spd = encode_direction_pools(flow, points_abs) # mt-like motion computation
  619. curl = curl_from_tangential(mk, points_abs, (cx_gaze, cy_gaze)) # compute curl
  620. omega = curl.omega_bar
  621. omega *= float(omega_sign) # mean curl
  622. if smooth_alpha > 0.0 and omega_prev is not None: # default 0.15
  623. omega = (1.0 - smooth_alpha) * omega_prev + smooth_alpha * omega
  624. omega_prev = omega
  625. gaze_az_px = float(cx_gaze - cx_cam) # Gaze horizontal position relative to image center in pixel coordinates of the ring attractor space
  626. #gaze_az_deg = math.degrees(math.atan(gaze_az_px / focal_length_px))
  627. #print(gaze_az_deg)
  628. # we pass now gaze_az_deg
  629. I_total, I_gaze, I_prior = ring.step(
  630. dt=dt_frame, omega_bar=omega, gaze_az_px=gaze_az_px, Kp=Kp, noise_std=noise_std,
  631. I0=I0, sigma_prior=sigma_prior, theta_prior_px=0.0,
  632. )
  633. theta_px = ring.decode_theta_px()
  634. pred_px = cx_cam + theta_px
  635. # Collect data for plotting
  636. collector.collect(x=ring.x, I_total=I_total, I_gaze=I_gaze, I_prior=I_prior, theta=theta_px)
  637. # writer.writerow([
  638. # proc_frame_idx, abs_frame_idx,
  639. # theta_px, pred_px,
  640. # omega, curl.mean_speed, curl.n_used, asymmetry_strength,
  641. # cx_gaze, cy_gaze
  642. # ])
  643. writer.writerow([
  644. proc_frame_idx,
  645. abs_frame_idx,
  646. f"{theta_px:.3f}",
  647. f"{pred_px:.3f}",
  648. f"{omega:.3f}",
  649. f"{curl.mean_speed:.3f}",
  650. curl.n_used,
  651. f"{asymmetry_strength:.1f}",
  652. f"{I0:.3f}",
  653. f"{cx_gaze:.3f}",
  654. f"{cy_gaze:.3f}",
  655. ])
  656. if display:
  657. vis = frame.copy()
  658. # Draw sample points
  659. for (x, y) in points_abs:
  660. cv2.circle(vis, (int(x), int(y)), 1, color_pts, -1)
  661. # Draw gaze point
  662. cv2.circle(vis, (int(round(cx_gaze)), int(round(cy_gaze))), 4, color_gaze, -1)
  663. # Draw heading prediction (red circle on the horizontal midline)
  664. cy_mid = int(round(cy_cam))
  665. cv2.circle(vis, (int(round(pred_px)), cy_mid), 6, color_pred, 2)
  666. # Add HUD text
  667. cv2.putText(vis, f"Proc {proc_frame_idx} | Abs {abs_frame_idx}", (10, 20),
  668. cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,0,0), 2)
  669. cv2.putText(vis, f"omega={omega:+.4f} theta_px={theta_px:+.1f}", (10, 40),
  670. cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,0,0), 2)
  671. cv2.putText(vis, f"Kp={Kp:.3f} asym={asymmetry_strength:.2f} n={ring.p.n_units}", (10, 60),
  672. cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,0,0), 2)
  673. if show_kappa:
  674. # Use the new connectivity visualization
  675. panel = _render_connectivity_panel(
  676. width=400, height=200, ring=ring,
  677. gaze_az_px=gaze_az_px, omega_bar=omega, Kp=Kp,
  678. I0=I0, sigma_prior=sigma_prior
  679. )
  680. ph, pw = panel.shape[:2]
  681. y0 = max(0, vis.shape[0]-ph-8)
  682. x0 = 8
  683. roi = vis[y0:y0+ph, x0:x0+pw]
  684. if panel.shape[2] == 4:
  685. alpha = panel[:, :, 3:4] / 255.0
  686. roi[:] = (alpha * panel[:, :, :3] + (1 - alpha) * roi).astype(np.uint8)
  687. else:
  688. roi[:] = panel
  689. # Show the main visualization window
  690. cv2.imshow("Neural ring - recurrent push-pull", vis)
  691. key = cv2.waitKey(1) & 0xFF
  692. if key == 27 or key == ord('q'):
  693. break
  694. prev_gray = gray
  695. proc_frame_idx += 1
  696. print(f"Finalizing... saving plots to heading_analysis_results.png")
  697. #collector.save_plots("heading_analysis_results.png",px2deg=az_range_px2deg)
  698. collector.save_plots(f"{video_base_name}_plots.png")
  699. collector.export_to_csv(video_base_name)
  700. # Also save the connectivity for the weight profile
  701. np.savetxt(f"{video_base_name}_connectivity.csv",
  702. ring.W[ring.p.n_units // 2, :], delimiter=",")
  703. cap.release()
  704. if display:
  705. cv2.destroyAllWindows()
  706. # --------------------------- CLI entrypoint -------------------------- #
  707. def parse_args():
  708. p = argparse.ArgumentParser(
  709. description="Neural heading estimator with recurrent push-pull (no Kaway)"
  710. )
  711. p.add_argument("--video", required=True, help="Path to input video file")
  712. p.add_argument("--gaze_csv", default=None, help="Optional CSV with x_px_ui,y_px_ui,(optional)visible")
  713. p.add_argument("--output_csv", default="heading_recurrent_pp.csv", help="Output CSV path")
  714. p.add_argument("--display", action="store_true", help="Show visualization window")
  715. # Frame processing
  716. p.add_argument("--step", type=int, default=1, help="Process every Nth frame")
  717. p.add_argument("--resize_width", type=int, default=0, help="Resize frames to this width (0=off)")
  718. # Sampling near gaze
  719. p.add_argument("--sample_count", type=int, default=400, help="Number of gaze-centered sample points")
  720. p.add_argument("--sample_radius_frac", type=float, default=0.85, help="Sampling radius as fraction of min(h,w)/2")
  721. # Farnebäck params
  722. p.add_argument("--pyr_scale", type=float, default=0.5)
  723. p.add_argument("--levels", type=int, default=3)
  724. p.add_argument("--winsize", type=int, default=15)
  725. p.add_argument("--iterations", type=int, default=3)
  726. p.add_argument("--poly_n", type=int, default=5)
  727. p.add_argument("--poly_sigma", type=float, default=1.2)
  728. # Controller gain & sign
  729. p.add_argument("--Kp", type=float, default=0.4, help="Coupling from ω̄ to gaze-centered inhibitory lobe")
  730. p.add_argument("--omega_sign", type=float, default=1.0, help="Multiply ω̄ by this sign (+1 or -1)")
  731. # Smoothing
  732. p.add_argument("--smooth_alpha", type=float, default=0.15, help="EMA for ω̄ (0 disables)")
  733. # Ring params
  734. p.add_argument("--n_units", type=int, default=181, help="Number of ring units")
  735. p.add_argument("--az_range_frac", type=float, default=0.9, help="Heading range as fraction of half image width")
  736. p.add_argument("--az_range_deg", type=float, default=90.0, help="Heading range as deg (close to screen degs)")
  737. p.add_argument("--tau_ms", type=float, default=60.0, help="Ring time constant (ms)")
  738. p.add_argument("--recurrent_gain", type=float, default=0.95, help="Recurrent gain (stability <~1)")
  739. p.add_argument("--sigma_exc_frac", type=float, default=0.10, help="Excitatory kernel width (fraction of az_range_px)")
  740. p.add_argument("--sigma_inh_frac", type=float, default=0.35, help="Inhibitory kernel width (fraction of az_range_px)")
  741. p.add_argument("--sigma_kappa_frac", type=float, default=0.12, help="Input lobe width (fraction of az_range_px)")
  742. # New parameter for push-pull asymmetry
  743. p.add_argument("--asymmetry_strength", type=float, default=0.0,
  744. help="Strength of asymmetric connectivity for push-pull (0=symmetric, 0.3=moderate asymmetry)")
  745. # Baseline prior
  746. p.add_argument("--I0", type=float, default=0.12, help="Baseline prior amplitude (Gaussian at straight-ahead)")
  747. p.add_argument("--sigma_prior_frac", type=float, default=0.18, help="Baseline prior width (fraction of az_range_px)")
  748. p.add_argument("--noise_std", type=float, default=0.0, help="Additive input noise std")
  749. p.add_argument("--show_kappa", action="store_true", help="Show input lobe profiles")
  750. p.add_argument("--seed", type=int, default=42, help="RNG seed")
  751. return p.parse_args()
  752. if __name__ == "__main__":
  753. args = parse_args()
  754. run(
  755. video_path=args.video,
  756. gaze_csv=args.gaze_csv,
  757. output_csv=args.output_csv,
  758. display=args.display,
  759. step=args.step,
  760. resize_width=args.resize_width,
  761. sample_count=args.sample_count,
  762. sample_radius_frac=args.sample_radius_frac,
  763. pyr_scale=args.pyr_scale,
  764. levels=args.levels,
  765. winsize=args.winsize,
  766. iterations=args.iterations,
  767. poly_n=args.poly_n,
  768. poly_sigma=args.poly_sigma,
  769. seed=args.seed,
  770. Kp=args.Kp,
  771. smooth_alpha=args.smooth_alpha,
  772. omega_sign=args.omega_sign,
  773. n_units=args.n_units,
  774. az_range_frac=args.az_range_frac,
  775. tau_ms=args.tau_ms,
  776. recurrent_gain=args.recurrent_gain,
  777. sigma_exc_frac=args.sigma_exc_frac,
  778. sigma_inh_frac=args.sigma_inh_frac,
  779. sigma_kappa_frac=args.sigma_kappa_frac,
  780. asymmetry_strength=args.asymmetry_strength, # New argument
  781. I0=args.I0,
  782. sigma_prior_frac=args.sigma_prior_frac,
  783. noise_std=args.noise_std,
  784. show_kappa=args.show_kappa,
  785. )

ring_attractor_heading_v1.py, no license · at the source

Overview

Authors: Kontessa I Zorpala1, Joan López-Moliner1
  1. Vision and Control of Action Group, Department of Cognition, Development and Psychology of Education and Institute of Neurosciences, Universitat de Barcelona, Barcelona, Spain
Institutions: Universitat de Barcelona (Spain)
Journal: eLife, volume 15, article RP110770
Dates: published online 25 September 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.7554/elife.110770 · PMID 42788718 · PMCID PMC13614786 · OpenAlex W7154654837
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: behavior only (modality), human (organism), cognitive (subfield)
Methods: Connectivity, Spectral & time-frequency, Smoothing, state filtering, decompositions, Physiology & signal measures
Keywords: Human
MeSH: Eye Movements*, Fixation, Ocular*, Motion Perception*, Retina*, Adult, Female, Humans (* major topic)
Topic: Visual perception and processing mechanisms (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Funding: MICIU (PREP2023-001890); Agencia Estatal de Investigación (MICIU/AEI/10.13039/501100011, PID2023-150081NB-I00)
Citations: not cited yet (Europe PMC); 73 references in the paper

Abstract

Prevailing models aiming at explaining heading assume that humans need to recover the Focus of Expansion (FoE) while accounting for eye-movement-induced rotation. We propose an alternative: the visual system utilizes mean retinal curl from fixations as a surrogate signal for heading, rendering the explicit recovery of the FoE unnecessary. Stationary participants viewed simulated walking paths on a large screen while fixating on points on the projected ground texture at varying eccentricities – a natural behavior inducing sustained retinal curl. Participants continuously reported perceived heading in 3D scene coordinates. To isolate the role of retinal curl, we employed a real-time manipulation that kept translational flow constant while the foveal curl component was either unaltered, canceled, or over-canceled. Under natural conditions (unaltered), participants exhibited systematic heading biases opposite the direction of gaze. Crucially, these biases vanished when we canceled the expected curl and flipped when we over-canceled it, identifying retinal curl as the specific driver of perceptual bias. We modeled these results using a simple feedback controller and a ring-attractor neural network featuring gaze-contingent inhibition and a ‘straight-ahead’ prior. These findings suggest that the brain exploits the geometry of gaze stabilization to simplify navigation, treating retinal curl as a functional signal rather than noise to be filtered.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repositories

Its files are read in the Code ↔ Paper reader above, with 7 matches between paragraphs and lines of code.

OSF b37rg

License: none: the authors keep all their rights
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Languages: R (7), Quarto (5), Shell (1), Python (1)
Size: 38 files, 14 scripts
Software Heritage: not checked
Found in: “Data availability”
Holds: README, documentation, 5 notebooks
Not found: license file, CITATION.cff, environment file, tests, continuous integration
Tools: data.table (3 files), ggplot2 (2 files), lmerTest (2 files), Matplotlib (1 file), NumPy (1 file), OpenCV (1 file), patchwork (1 file), tidyverse (1 file)
Availability: 1 check, the latest on 28 September 2026: the link answers (HTTP 200)
  • 28 September 2026: the link answers (HTTP 200)
15 files

supp:PMC13614786/elife-110770-code1.zip

License: none: the authors keep all their rights
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Languages: Python (1)
Size: 1 file, 1 script
Software Heritage: not checked
Found in: the supplementary material
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (1 file)
Availability: 1 check, the latest on 28 September 2026: the link answers (HTTP 200)
  • 28 September 2026: the link answers (HTTP 200)
1 file

The paper's code and data availability statement is in the Data section.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 15 scripts, each with its path and the digest of its content;
  • 7 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Data availability

All the data and code for the analysis are available through this link: https://doi.org/10.17605/OSF.IO/B37RG.

The following dataset was generated:

López-Moliner J, Zorpala KI. 2026. Beyond the Focus of Expansion: Retinal curl as a functional signal for heading estimation. Open Science Framework.

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 28 September 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 2 authors, 1 keyword, 7 MeSH terms, 2 funders, 71 references.

Cite

This paper

Zorpala, K. I., & López-Moliner, J. (2026). Retinal curl as a functional signal for heading estimation beyond the focus of expansion. eLife, 15, RP110770. https://doi.org/10.7554/elife.110770

BibTeX

@article{zorpala2026retinal,
author = {Zorpala, Kontessa I and López-Moliner, Joan},
title = {{Retinal curl as a functional signal for heading estimation beyond the focus of expansion}},
journal = {eLife},
year = {2026},
month = sep,
volume = {15},
pages = {RP110770},
publisher = {eLife Sciences Publications, Ltd},
issn = {2050-084X},
doi = {10.7554/elife.110770},
url = {https://doi.org/10.7554/elife.110770},
pmid = {42788718},
pmcid = {PMC13614786}
}

RIS

TY - JOUR
AU - Zorpala, Kontessa I
AU - López-Moliner, Joan
TI - Retinal curl as a functional signal for heading estimation beyond the focus of expansion
T2 - eLife
J2 - eLife
PY - 2026
DA - 2026/09/25
VL - 15
SP - RP110770
SN - 2050-084X
PB - eLife Sciences Publications, Ltd
DO - 10.7554/elife.110770
UR - https://doi.org/10.7554/elife.110770
LA - en
ER -

CSL-JSON

{
"id": "10.7554/elife.110770",
"type": "article-journal",
"title": "Retinal curl as a functional signal for heading estimation beyond the focus of expansion",
"container-title": "eLife",
"author": [
{
"family": "Zorpala",
"given": "Kontessa I"
},
{
"family": "López-Moliner",
"given": "Joan"
}
],
"container-title-short": "eLife",
"volume": "15",
"page": "RP110770",
"DOI": "10.7554/elife.110770",
"PMID": "42788718",
"PMCID": "PMC13614786",
"ISSN": "2050-084X",
"publisher": "eLife Sciences Publications, Ltd",
"URL": "https://doi.org/10.7554/elife.110770",
"language": "en",
"issued": {
"date-parts": [
[
2026,
9,
25
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1038/s41598-026-56170-9 [code]
Path integration from optic flow and the role of eye movements.
Journal: Scientific reports
In common: cognitive, 5 references
[2] doi:10.1016/j.xcrm.2026.102766 [code]
A longitudinal single-cell and spatial multiomic atlas of pediatric high-grade glioma.
Journal: Cell reports. Medicine
In common: lmerTest, OpenCV, data.table, 5 other tools
[3] doi:10.3390/ijms27135713 [code]
Chronic Administration of Marinobufagenin in Mice Causes Hyperlocomotion and Decrease in Anxiety by Altering Monoamine Turnover Unaccompanied by Motor Deficits or Oxidative Stress.
Journal: International journal of molecular sciences
In common: lmerTest, OpenCV, data.table, 4 other tools
[4] doi:10.1038/s42003-026-10957-8 [code]
Brain defence by the extracellular matrix protein Cochlin.
Journal: Communications biology
In common: OpenCV, data.table, patchwork, 4 other tools
[5] doi:10.1261/rna.080954.126 [code]
Neuronal subtype-specific ribosomal protein mRNA expression.
Journal: RNA (New York, N.Y.)
In common: lmerTest, data.table, patchwork, 4 other tools
[6] doi:10.1002/imt2.70163 [code]
Spatial multi-omics unveils sphingolipid metabolic reprogramming within the retinal pathological niche.
Journal: iMeta
In common: OpenCV, data.table, patchwork, 4 other tools
[7] doi:10.1038/s42003-026-10259-z [code]
Spatial transcriptomic profiling of developing mouse hearts reveals a spatially patterned signaling environment.
Journal: Communications biology
In common: OpenCV, data.table, patchwork, 4 other tools
[8] doi:10.1038/s41467-026-73865-9 [code]
Histamine shapes the neurocomputational dynamics of human learning.
Journal: Nature communications
In common: lmerTest, data.table, ggplot2, 3 other tools, cognitive
[9] doi:10.1371/journal.pbio.3003666 [code]
Emotion regulation success involves systematic gradient-based reconfigurations of large-scale activation patterns in the human brain.
Journal: PLoS biology
In common: lmerTest, data.table, ggplot2, 3 other tools, cognitive
[10] doi:10.1093/braincomms/fcag328 [code]
Subthalamic stimulation modulates working memory-related cortical dynamics in Parkinson's disease.
Journal: Brain communications
In common: lmerTest, patchwork, ggplot2, 3 other tools, cognitive

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.