Retinal curl as a functional signal for heading estimation beyond the focus of expansion.
The 7 matches
- [1] § Results › Simulation results ↔ Python tree/curl-neural_model/ring_attractor_heading_v1.py, lines 144 to 266 · score 0.94 · Phase Portrait, drift dynamics, decoded heading, straight ahead prior, neural activity, ring attractor · Report a mismatch
- [2] § Results › Simulation results ↔ Python tree/curl-neural_model/ring_attractor_heading_v1.py, lines 144 to 266 · score 0.93 · neuron preferred headings, Phase portrait, sensory prior competition, normalized amplitude, straight ahead prior, ring position · Report a mismatch
- [3] § Appendix 2 › Neural network model of gaze-contingent heading bias ↔ Python tree/curl-neural_model/ring_attractor_heading_v1.py, lines 1 to 89 · score 0.92 · weak straight ahead, gaze modulated, bias emerges, neural model, heading representation, cortical · Report a mismatch
- [4] § Appendix 2 › Bias generation as sensory–prior competition ↔ Python tree/curl-neural_model/ring_attractor_heading_v1.py, lines 1 to 89 · score 0.83 · allows sensory evidence, weak straight ahead, weak prior, interaction, attractor, opposite · Report a mismatch
- [5] § Appendix 2 › Heading estimation and 3D path reconstruction ↔ Python tree/curl-neural_model/ring_attractor_heading_v1.py, lines 713 to 761 · score 0.71 · focal length, ring attractor, smoothed, horizontal, pixels, field · Report a mismatch
- [6] § Methods › Displays and conditions ↔ eLife-VOR-RA-2026-110770/3D_stimulus_generator.py, lines 102 to 163 · score 0.58 · simplex noise, stimulus generator, shaders, motion · Report a mismatch
- [7] § Appendix 2 › Local motion encoding ↔ Python tree/curl-neural_model/ring_attractor_heading_v1.py, lines 713 to 761 · score 0.51 · optical flow, encoding, V1, MT, motion, model · Report a mismatch
Paper
Loaded from Europe PMC by your browser; OSCR does not store it: 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
- #!/usr/bin/env python3
- """
- Neural Heading Estimator with Inhibitory Recurrent Push-Pull Mechanism
- This script adds plotting and saving features to curl_recurrent_heading_network.py
- -----------------------------------------------------------
- Eliminates the explicit Kaway term by using asymmetric recurrent connectivity
- to naturally create push-pull dynamics in the ring attractor.
- Key changes:
- 1. Removed Kaway parameter entirely
- 2. Added asymmetric recurrent weights that create natural opposition
- 3. Simplified parameter space
- 4. More biologically plausible connectivity
- Neurophysiological basis:
- Biologically plausible: Uses asymmetric recurrent connectivity instead of artificial "anti-gaze" lobe
- - Cortical circuits often have asymmetric lateral connections
- - Inhibition can spread preferentially in specific directions
- - Natural push-pull emerges from network dynamics rather than explicit coding
- ┌─────────────────────────────────────────────────────────────┐
- │ VISUAL PROCESSING │
- │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
- │ │ V1 │ -> │ MT │ -> │ MSTd │ │
- │ │ Orientation │ │ Direction │ │ Flow Pattern │ │
- │ │ Selectivity│ │ Selectivity│ │ Analysis │ │
- │ └─────────────┘ └─────────────┘ └─────────────────┘ │
- └─────────────────────────────┬───────────────────────────────┘
- │
- Curl Signal (ω̄)
- │
- ┌─────────────────────────────────────────────────────────────┐
- │ PARIETAL CORTEX (PPC) - Ring Attractor │
- │ │
- │ ┌─────────────────────────────────────────────────────┐ │
- │ │ Heading Representation │ │
- │ │ │ │
- │ │ [← Left] ○────○────○────○────○────○────○ [Right →] │
- │ │ -θmax │ │ │ │ │ +θmax │ │
- │ │ △ △ △ △ △ │ │
- │ │ │ │ │ │ │ │ │
- │ │ Gaze-Inhibited Input │ Prior Input │ │
- │ │ (Kp × ω̄) │ (Straight-ahead) │ │
- │ │ │ │ │
- │ │ Asymmetric Recurrent Connections │ │
- │ │ (Stronger inhibition away from center) │ │
- │ └─────────────────────────────────────────────────────┘ │
- │ │
- │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
- │ │ Eye Position│ │ Motor Plan │ │ Vestibular │ │
- │ │ Signals │ │ Integration │ │ Integration│ │
- │ └─────────────┘ └─────────────┘ └─────────────┘ │
- └─────────────────────────────────────────────────────────────┘
- Parameter Space Revelation
- The key parameters are actually:
- I0 (prior strength) - determines flexibility
- K_p (inhibition gain) - determines bias magnitude
- sigma_prior - determines spatial scale of prior
- Bias emerges from gaze-modulated inhibition interacting with a weak straight-ahead prior and slightly asymmetric recurrent connectivity in PPC.
- Revised Neurophysiological Story
- The bias emerges from:
- Gaze-modulated inhibition from MSTd to PPC
- Weak straight-ahead prior in PPC
- Standard center-surround recurrence in cortical circuits
- the essential mechanism is sensory-prior competition, plus asymmetric recurrence (not determinant).
- This is actually a more elegant and biologically plausible explanation!
- The system needs to be flexible enough (weak prior) to allow sensory evidence to shift the heading representation away from gaze.
- """
- import argparse
- import csv
- import math
- import os
- from dataclasses import dataclass
- from typing import Optional, Tuple
- import cv2
- import numpy as np
- import matplotlib.pyplot as plt # Added for plotting
- # ---------------------------- I/O helpers ---------------------------- #
- def load_gaze_csv(gaze_csv_path: str):
- xs, ys, vis = [], [], []
- has_visible = False
- with open(gaze_csv_path, "r", newline="") as f:
- reader = csv.DictReader(f)
- fields = [fn.strip() for fn in (reader.fieldnames or [])]
- required = {"x_px_ui", "y_px_ui"}
- if not required.issubset(set(fields)):
- raise ValueError(f"gaze_csv must contain columns {required}, found: {fields}")
- has_visible = ("visible" in fields)
- for row in reader:
- try:
- x = float(row["x_px_ui"]) ; y = float(row["y_px_ui"])
- except Exception:
- x, y = float("nan"), float("nan")
- xs.append(x) ; ys.append(y)
- if has_visible:
- try:
- v = int(float(row["visible"]))
- except Exception:
- v = 1
- vis.append(v)
- return {
- "x": np.array(xs, dtype=np.float32),
- "y": np.array(ys, dtype=np.float32),
- "visible": (np.array(vis, dtype=np.int32) if has_visible else None),
- }
- def get_gaze_for_abs_frame(gaze_data, abs_idx: int, w: int, h: int,
- last_valid: Optional[Tuple[float, float]]) -> Tuple[float, float, Optional[Tuple[float, float]]]:
- if gaze_data is None:
- return (w/2.0), (h/2.0), ((w/2.0), (h/2.0))
- n = gaze_data["x"].shape[0]
- idx = min(abs_idx, n - 1) if n > 0 else 0
- gx = float(gaze_data["x"][idx]) if n > 0 else float("nan")
- gy = float(gaze_data["y"][idx]) if n > 0 else float("nan")
- visible = True
- if gaze_data.get("visible") is not None and n > 0:
- visible = bool(int(gaze_data["visible"][idx]))
- def valid(x, y):
- return (not (math.isnan(x) or math.isnan(y))) and (0 <= x < w) and (0 <= y < h)
- if visible and valid(gx, gy):
- return gx, gy, (gx, gy)
- if last_valid is not None and valid(*last_valid):
- return last_valid[0], last_valid[1], last_valid
- return (w / 2.0), (h / 2.0), (w / 2.0, h / 2.0)
- # --- NEW CLASS FOR DATA COLLECTION ---
- class DataCollector:
- def __init__(self, phi):
- # self.n_units = n_units
- self.phi = phi
- self.history_x = [] # Neural activity over time
- self.history_I_total = [] # total activity
- self.history_I_gaze = [] # gaze inhibition
- self.history_I_prior = [] # prior
- self.history_theta = [] # Decoded heading over time
- # self.history_omega = [] # Input curl over time
- def collect(self, x, I_total, I_gaze, I_prior, theta):
- self.history_x.append(x.copy())
- self.history_I_total.append(I_total.copy())
- self.history_I_gaze.append(I_gaze.copy())
- self.history_I_prior.append(I_prior.copy())
- self.history_theta.append(theta)
- # self.history_omega.append(omega)
- def export_to_csv(self, prefix="heading_model"):
- # 1. Save Activity Matrix (Rows=Frames, Cols=Neurons)
- # Header: time, unit_1_pxl, unit_2_pxl, ...
- header = ["frame"] + [f"unit_{p:.2f}" for p in self.phi]
- with open(f"{prefix}_activity.csv", "w", newline="") as f:
- writer = csv.writer(f)
- writer.writerow(header)
- for i, row in enumerate(self.history_x):
- writer.writerow([i] + list(row))
- # New: Detailed Input CSV
- header = ["frame", "type"] + [f"unit_{p:.2f}" for p in self.phi]
- with open(f"{prefix}_inputs.csv", "w", newline="") as f:
- writer = csv.writer(f)
- writer.writerow(header)
- for i in range(len(self.history_I_total)):
- # Write three rows per frame so she can filter by 'type'
- writer.writerow([i, "total"] + list(self.history_I_total[i]))
- writer.writerow([i, "gaze_inhibition"] + list(self.history_I_gaze[i]))
- writer.writerow([i, "prior"] + list(self.history_I_prior[i]))
- # 3. Save Summary Dynamics (For Phase Portrait)
- with open(f"{prefix}_dynamics.csv", "w", newline="") as f:
- writer = csv.writer(f)
- writer.writerow(["frame", "theta_hat", "d_theta_dt"])
- thetas = self.history_theta
- for i in range(len(thetas)):
- d_dt = (thetas[i] - thetas[i-1]) if i > 0 else 0
- writer.writerow([i, thetas[i], d_dt])
- print(f"Data exported to {prefix}_activity.csv, _inputs.csv, and _dynamics.csv")
- def save_plots(self, filename="diagnostic_plots.png",px2deg=1.0):
- if not self.history_x: return
- X = np.array(self.history_x)
- I_total = np.array(self.history_I_total)
- I_gaze = np.array(self.history_I_gaze)
- I_prior = np.array(self.history_I_prior)
- thetas = np.array(self.history_theta)
- fig, axes = plt.subplots(2, 2, figsize=(12, 10))
- # 1. Kymograph (Space-Time Heatmap)
- ax = axes[0, 0]
- y_min = self.phi[0] * px2deg
- y_max = self.phi[-1] * px2deg
- im = ax.imshow(X.T, aspect='auto', extent=[0, len(X), y_min, y_max], origin='lower', cmap='viridis')
- ax.plot(thetas*px2deg, color='white', linestyle='--', alpha=0.5, label='Decoded Heading')
- ax.set_title("Kymograph (Neural Activity over Time)")
- ax.set_ylabel("Neuron Preferred Heading (pixels)")
- ax.set_xlabel("Frame Index")
- # --- TO RESTRICT THE VIEW ---
- #ax.set_ylim(-90, 90)
- plt.colorbar(im, ax=ax)
- # 2. Phase Portrait (Delta Theta vs Theta)
- ax = axes[0, 1]
- d_theta = np.diff(thetas)
- ax.scatter(thetas[:-1], d_theta, c=np.arange(len(d_theta)), cmap='plasma', s=5, alpha=0.6)
- ax.axhline(0, color='black', lw=1)
- ax.set_title("Phase Portrait (Drift Dynamics)")
- ax.set_xlabel("Heading Estimate (theta -pixels-)")
- ax.set_ylabel("Change in Heading (d_theta/dt)")
- # 3. Snapshot of Input vs Activity (Final Frame)
- # This is the "Mechanistic Proof" showing the balance of forces
- ax = axes[1, 0]
- phi_deg = self.phi * px2deg
- # Plot Neural Activity (The Result)
- #ax.plot(phi_deg, X[-1] / (np.max(X[-1]) + 1e-9), label='Neural Activity (Bump)', color='black', lw=3)
- ax.plot(phi_deg, I_total[-1] / (np.max(I_total[-1]) + 1e-9), label='Neural Activity (Bump)', color='black', lw=3)
- # Plot Gaze Inhibition (The Pull/Sensory) - Normalize to show shape
- ax.plot(phi_deg, I_gaze[-1] / (np.max(np.abs(I_gaze[-1])) + 1e-9),
- label='Gaze Inhibition (Pull)', color='red', linestyle='--', alpha=0.8)
- # Plot Prior (The Push/Internal) - Normalize to show shape
- ax.plot(phi_deg, I_prior[-1] / (np.max(np.abs(I_prior[-1])) + 1e-9),
- label='Straight-Ahead Prior (Push)', color='blue', linestyle=':', alpha=0.8)
- ax.axvline(0, color='gray', lw=0.5, alpha=0.5) # Center line
- ax.set_title("Mechanism: Sensory-Prior Competition")
- ax.set_xlabel("Ring Position (deg)")
- ax.set_ylabel("Normalized Amplitude")
- ax.legend(fontsize='small', loc='upper right')
- # 4. Circular Variance (Coherence of the bump)
- ax = axes[1, 1]
- # Calculate coherence: R = |sum(x * exp(i*phi))| / sum(x)
- ang = 2 * np.pi * (self.phi - self.phi[0]) / (self.phi[-1] - self.phi[0])
- z = np.array([np.sum(xt * np.exp(1j * ang)) / np.sum(xt) for xt in X])
- coherence = np.abs(z)
- ax.plot(coherence, color='blue')
- ax.set_ylim([0, 1.1])
- ax.set_title("Bump Coherence (1 - Circular Variance)")
- ax.set_ylabel("Coherence (0 to 1)")
- ax.set_xlabel("Frame Index")
- plt.tight_layout()
- plt.savefig(filename)
- print(f"Analysis plots saved to {filename}")
- # ---------------------------- Sampling ------------------------------- #
- def sample_points_relative_cam(h: int, w: int, N: int, R_pixels: float, seed: int = 42) -> np.ndarray:
- rng = np.random.default_rng(seed)
- pts = []
- Rint = max(1, int(round(R_pixels)))
- tries = 0
- while len(pts) < N and tries < 300 * N:
- dx = rng.integers(-Rint, Rint + 1)
- dy = rng.integers(-Rint, Rint + 1)
- if dx*dx + dy*dy <= R_pixels*R_pixels:
- pts.append((int(dx), int(dy)))
- tries += 1
- while len(pts) < N:
- pts.append((0, 0))
- return np.array(pts, dtype=np.int32)
- # --------------------------- MT-like encode -------------------------- #
- angles = np.deg2rad(np.arange(0, 360, 45, dtype=np.float32))
- E_DIRS = np.stack([np.cos(angles), np.sin(angles)], axis=1).astype(np.float32) # (8,2)
- def encode_direction_pools(flow_xy: np.ndarray, points_abs: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
- """Rectified cosine energy in 8 sectors at the absolute points (MT-like pools)."""
- N = points_abs.shape[0]
- H, W = flow_xy.shape[:2]
- xs = np.clip(points_abs[:, 0], 0, W - 1)
- ys = np.clip(points_abs[:, 1], 0, H - 1)
- uvs = flow_xy[ys, xs, :].astype(np.float32) # (N,2)
- mk = np.maximum(0.0, uvs @ E_DIRS.T) # (N,8) mk: 8 numbers per sample, showing how much motion aligns with each direction
- spd = np.sqrt(np.sum(uvs**2, axis=1) + 1e-8) # overall speed at each sample
- return mk, spd
- def vector_from_sectors(m: np.ndarray, eps: float = 1e-6) -> np.ndarray:
- """Estimate a 2D velocity vector from 8 sector energies for each sample."""
- total = np.sum(m, axis=1, keepdims=True) + eps
- v = (m @ E_DIRS) / total # (N,2)
- return v
- # ------------------------ MSTd-like curl code ------------------------ #
- @dataclass
- class CurlCode:
- omega_bar: float # signed mean tangential component around gaze
- mean_speed: float # mean |v| from sector reconstruction
- n_used: int # number of samples used (excludes near-center)
- def curl_from_tangential(mk: np.ndarray, points_abs: np.ndarray, gaze_xy: Tuple[float, float],
- r_min_px: float = 4.0, trim_q: float = 0.1, eps: float = 1e-6) -> CurlCode:
- """Compute a gaze-centered curl proxy via tangential projection."""
- v_est = vector_from_sectors(mk) # (N,2)
- gx, gy = gaze_xy
- r = points_abs.astype(np.float32) - np.array([[gx, gy]], dtype=np.float32) # (N,2)
- r2 = np.sum(r*r, axis=1)
- keep = r2 > (r_min_px * r_min_px)
- r = r[keep]
- v = v_est[keep]
- if r.shape[0] == 0:
- return CurlCode(omega_bar=0.0, mean_speed=0.0, n_used=0)
- r_norm = np.sqrt(np.sum(r*r, axis=1, keepdims=True)) + eps
- t_hat = np.concatenate([-r[:, 1:2], r[:, 0:1]], axis=1) / r_norm
- omega_samples = np.sum(v * t_hat, axis=1)
- # to correct discrepancy for distances
- #mean_eccentricity = np.mean(r_norm)
- #omega_samples = omega_samples / (mean_eccentricity + eps)
- # DEBUG: Check if sign matches expected biological convention
- #gaze_az_px = gx - (400/2) # Approximate gaze azimuth
- #print(f"DEBUG: Gaze az: {gaze_az_px:+.1f}, mean omega: {np.mean(omega_samples):+.4f}")
- ql, qh = np.quantile(omega_samples, [trim_q, 1 - trim_q])
- mask = (omega_samples >= ql) & (omega_samples <= qh)
- if np.any(mask):
- omega_bar = float(np.mean(omega_samples[mask]))
- else:
- omega_bar = float(np.mean(omega_samples))
- mean_speed = float(np.mean(np.sqrt(np.sum(v*v, axis=1))))
- return CurlCode(omega_bar=omega_bar, mean_speed=mean_speed, n_used=int(r.shape[0]))
- # ------------------------ Ring-attractor dynamics -------------------- #
- @dataclass
- class RingParams:
- n_units: int
- az_range_px: float # ring spans [-az_range_px, +az_range_px]
- tau: float # time constant (seconds)
- recurrent_gain: float
- sigma_exc: float # px
- sigma_inh: float # px
- sigma_kappa: float # width of gaze input lobe (px)
- asymmetry_strength: float # New: controls push-pull asymmetry
- class RingAttractor:
- def __init__(self, params: RingParams, seed: int = 0):
- self.p = params
- self.phi = np.linspace(-self.p.az_range_px, self.p.az_range_px, self.p.n_units, endpoint=False).astype(np.float32)
- # Create asymmetric Mexican hat connectivity for natural push-pull
- W = self._create_asymmetric_connectivity()
- self.W = W
- # Store connectivity patterns for visualization
- self.connection_profiles = self._compute_connection_profiles()
- # Seed a centered bump
- d0 = np.array([self._circ_dist(phi_i, 0.0) for phi_i in self.phi], dtype=np.float32)
- self.x = np.exp(-(d0**2) / (2 * (self.p.sigma_exc**2) + 1e-6)).astype(np.float32)
- self.x /= (np.max(self.x) + 1e-6)
- def _create_asymmetric_connectivity(self) -> np.ndarray:
- d = self._circ_dist_matrix(self.phi)
- # Base Mexican hat
- Ae = 1.9 # Excitatory amplitude
- Ai = 1.0 # Inhibitory amplitude
- Wexc = Ae * np.exp(-(d**2) / (2 * (self.p.sigma_exc**2) + 1e-6))
- Winh = Ai * np.exp(-(d**2) / (2 * (self.p.sigma_inh**2) + 1e-6))
- W_base = Wexc - Winh
- # Wexc = np.exp(-(d**2) / (2 * (self.p.sigma_exc**2) + 1e-6))
- # Winh = np.exp(-(d**2) / (2 * (self.p.sigma_inh**2) + 1e-6))
- # W_base = Wexc - Winh
- # More pronounced asymmetry: directional bias
- asymmetry_mod = np.ones_like(W_base)
- center_idx = self.p.n_units // 2
- for i in range(self.p.n_units):
- for j in range(self.p.n_units):
- # Create left-right asymmetry based on position relative to center
- if self.phi[j] > self.phi[i]: # j is right of i
- if W_base[i, j] < 0: # inhibitory connection
- asymmetry_mod[i, j] += self.p.asymmetry_strength#*sign_lr
- elif self.phi[j] < self.phi[i]: # j is left of i
- if W_base[i, j] < 0: # inhibitory connection
- asymmetry_mod[i, j] += self.p.asymmetry_strength*.5 #sign_lr # *.5 weaker
- W_asym = W_base * asymmetry_mod
- # Normalize to control stability
- v = np.random.default_rng(seed=42).standard_normal(size=(self.p.n_units, 1)).astype(np.float32)
- for _ in range(25):
- v = W_asym @ v
- v_norm = np.linalg.norm(v) + 1e-6
- v /= v_norm
- #lam = float((v.T @ (W_asym @ v)) / (v.T @ v)) # gives a warning
- lam = ((v.T @ (W_asym @ v)) / (v.T @ v)).item()
- W_normalized = (self.p.recurrent_gain / (abs(lam) + 1e-6)) * W_asym
- return W_normalized.astype(np.float32)
- def _compute_connection_profiles(self) -> dict:
- """Compute connection patterns for visualization."""
- profiles = {}
- # Profile from center unit
- center_idx = self.p.n_units // 2
- profiles['from_center'] = self.W[center_idx, :]
- # Profile from left unit (negative azimuth)
- left_idx = int(self.p.n_units * 0.25)
- profiles['from_left'] = self.W[left_idx, :]
- # Profile from right unit (positive azimuth)
- right_idx = int(self.p.n_units * 0.75)
- profiles['from_right'] = self.W[right_idx, :]
- return profiles
- @staticmethod
- def _circ_dist_matrix(phi: np.ndarray) -> np.ndarray:
- R = float(np.max(phi) - np.min(phi)) / 2.0
- ang = 2 * np.pi * (phi - (-R)) / (2 * R + 1e-9)
- diff = ang[:, None] - ang[None, :]
- diff = (diff + np.pi) % (2 * np.pi) - np.pi
- d = np.abs(diff) * (R / np.pi)
- return d.astype(np.float32)
- def _circ_dist(self, a: float, b: float) -> float:
- R = float(self.p.az_range_px)
- ang_a = 2 * np.pi * (a - (-R)) / (2 * R + 1e-9)
- ang_b = 2 * np.pi * (b - (-R)) / (2 * R + 1e-9)
- d = (ang_a - ang_b + np.pi) % (2 * np.pi) - np.pi
- return float(abs(d) * (R / np.pi))
- def step(self, dt: float, omega_bar: float, gaze_az_px: float, Kp: float,
- noise_std: float = 0.0, *,
- I0: float = 0.0, sigma_prior: Optional[float] = None, theta_prior_px: float = 0.0):
- """Step the ring dynamics """
- # Only gaze-centered inhibitory input
- d_g = np.array([self._circ_dist(phi_i, gaze_az_px) for phi_i in self.phi], dtype=np.float32)
- kappa_gaze = np.exp(-(d_g**2) / (2 * (self.p.sigma_kappa**2) + 1e-6))
- #I = (-Kp * float(omega_bar)) * kappa_gaze
- # Always inhibit at gaze position, use |ω̄| for strength
- I_gaze = (-Kp * abs(float(omega_bar))) * kappa_gaze # I_gaze,j in equation
- # Baseline prior (straight-ahead expectation)
- #if I0 > 0.0:
- if sigma_prior is None:
- sigma_prior = self.p.sigma_kappa
- d_p = np.array([self._circ_dist(phi_i, theta_prior_px) for phi_i in self.phi], dtype=np.float32)
- I_prior = I0 * np.exp(-(d_p**2) / (2 * (sigma_prior**2) + 1e-6))
- I_total = I_gaze + I_prior
- if noise_std > 0:
- I_total += np.random.normal(0.0, noise_std, size=I_total.shape).astype(np.float32)
- # Euler update - push-pull emerges from recurrent weights
- xdot = -self.x + (self.W @ self.x) + I_total
- self.x = self.x + (dt / self.p.tau) * xdot
- #print(f"I0={I0}, max_activity={np.max(self.x)}, bump_pos={self.decode_theta_px()}")
- # Rectify & mild normalization
- # TEMPORARILY REMOVE:
- self.x = np.maximum(self.x, 0.0)
- total = np.sum(self.x)
- if total > 1e-6:
- self.x /= (1.0 + 0.0 * total)
- return I_total, I_gaze, I_prior
- def decode_theta_px(self) -> float:
- R = float(self.p.az_range_px)
- ang = 2 * np.pi * (self.phi - (-R)) / (2 * R + 1e-9)
- z = np.sum(self.x * np.exp(1j * ang))
- if np.abs(z) < 1e-9:
- return 0.0
- ang_mean = float(np.angle(z))
- theta_px = -R + (ang_mean % (2 * np.pi)) * (2 * R) / (2 * np.pi)
- if theta_px > R:
- theta_px -= 2 * R
- return float(theta_px)
- # --------------------------- Visualization ---------------------------- #
- def _render_connectivity_panel(width: int, height: int, ring: RingAttractor,
- gaze_az_px: float, omega_bar: float, Kp: float,
- I0: float, sigma_prior: float) -> np.ndarray:
- """Simplified visualization showing only essential inputs and activity."""
- panel = np.full((height, width, 3), 40, dtype=np.uint8)
- n_units = ring.p.n_units
- phi = ring.phi
- # Normalize for display
- def normalize_profile(prof):
- prof = prof.copy()
- max_val = np.max(np.abs(prof)) + 1e-6
- return np.clip(prof / max_val, 0, 1)
- xs = np.linspace(0, width-1, n_units).astype(np.int32)
- # 1. Current neural activity (most important)
- activity_norm = normalize_profile(ring.x)
- # 2. Input profiles (simplified)
- d_g = np.array([ring._circ_dist(phi_i, gaze_az_px) for phi_i in phi], dtype=np.float32)
- kappa_gaze = np.exp(-(d_g**2) / (2 * (ring.p.sigma_kappa**2) + 1e-6))
- gaze_input = (-Kp * float(omega_bar)) * kappa_gaze
- gaze_norm = normalize_profile(gaze_input)
- # Prior input (only show if significant)
- if I0 > 0.1: # Only show prior if it's substantial
- d_p = np.array([ring._circ_dist(phi_i, 0.0) for phi_i in phi], dtype=np.float32)
- prior_input = I0 * np.exp(-(d_p**2) / (2 * (sigma_prior**2) + 1e-6))
- prior_norm = normalize_profile(prior_input)
- else:
- prior_norm = np.zeros_like(phi)
- # Plot in single row: activity + inputs
- row_height = height - 20
- y_offset = 10
- # Neural activity (white - most important)
- for i in range(n_units-1):
- y1 = int((1.0 - activity_norm[i]) * row_height + y_offset)
- y2 = int((1.0 - activity_norm[i+1]) * row_height + y_offset)
- cv2.line(panel, (xs[i], y1), (xs[i+1], y2), (255, 255, 255), 2, cv2.LINE_AA)
- # Gaze input (red)
- for i in range(n_units-1):
- y1 = int((1.0 - gaze_norm[i]) * row_height + y_offset)
- y2 = int((1.0 - gaze_norm[i+1]) * row_height + y_offset)
- cv2.line(panel, (xs[i], y1), (xs[i+1], y2), (0, 0, 200), 1, cv2.LINE_AA)
- # Prior input (gray, only if significant)
- if I0 > 0.1:
- for i in range(n_units-1):
- y1 = int((1.0 - prior_norm[i]) * row_height + y_offset)
- y2 = int((1.0 - prior_norm[i+1]) * row_height + y_offset)
- cv2.line(panel, (xs[i], y1), (xs[i+1], y2), (100, 100, 100), 1, cv2.LINE_AA)
- # Add vertical line at gaze position
- gaze_idx = np.argmin(np.abs(phi - gaze_az_px))
- gaze_x = xs[gaze_idx]
- cv2.line(panel, (gaze_x, y_offset), (gaze_x, y_offset + row_height), (0, 255, 255), 1)
- # Add vertical line at current heading
- theta_px = ring.decode_theta_px()
- theta_idx = np.argmin(np.abs(phi - theta_px))
- theta_x = xs[theta_idx]
- cv2.line(panel, (theta_x, y_offset), (theta_x, y_offset + row_height), (0, 0, 255), 1)
- # Simple labels
- cv2.putText(panel, "Activity(white) GazeIn(red) Gaze|->(yellow) Heading|->(red)",
- (5, height-5), cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255,255,255), 1)
- # Border
- cv2.rectangle(panel, (0,0), (width-1, height-1), (80,80,80), 1)
- overlay = cv2.cvtColor(panel, cv2.COLOR_BGR2BGRA)
- overlay[:, :, 3] = int(0.8 * 255)
- return overlay
- # --------------------------- Main routine ---------------------------- #
- def run(
- video_path: str,
- gaze_csv: Optional[str],
- output_csv: str,
- display: bool,
- step: int,
- resize_width: int,
- sample_count: int,
- sample_radius_frac: float,
- pyr_scale: float,
- levels: int,
- winsize: int,
- iterations: int,
- poly_n: int,
- poly_sigma: float,
- seed: int,
- Kp: float,
- smooth_alpha: float,
- omega_sign: float,
- # Ring params
- n_units: int,
- az_range_frac: float,
- tau_ms: float,
- recurrent_gain: float,
- sigma_exc_frac: float,
- sigma_inh_frac: float,
- sigma_kappa_frac: float,
- asymmetry_strength: float,
- I0: float,
- sigma_prior_frac: float,
- noise_std: float,
- show_kappa: bool,
- ):
- if not os.path.isfile(video_path):
- raise FileNotFoundError(f"Cannot find video: {video_path}")
- # 1. Extract the name (e.g., 'trial_0' from 'path/to/trial_0.mp4')
- video_base_name = os.path.splitext(os.path.basename(video_path))[0]
- gaze_data = load_gaze_csv(gaze_csv) if gaze_csv else None
- cap = cv2.VideoCapture(video_path)
- if not cap.isOpened():
- raise FileNotFoundError(f"Cannot open video: {video_path}")
- ok, frame0 = cap.read()
- if not ok:
- raise RuntimeError("Empty video or cannot read first frame.")
- fps = cap.get(cv2.CAP_PROP_FPS) # usually 30 fps
- if fps <= 1e-3 or math.isnan(fps):
- fps = 60.0
- dt_frame = float(step) / float(fps)
- if resize_width > 0 and frame0.shape[1] != resize_width:
- scale = resize_width / frame0.shape[1]
- frame0 = cv2.resize(frame0, (int(frame0.shape[1] * scale), int(frame0.shape[0] * scale)), interpolation=cv2.INTER_AREA)
- gray0 = cv2.cvtColor(frame0, cv2.COLOR_BGR2GRAY)
- h, w = gray0.shape[:2]
- cx_cam = w / 2.0
- cy_cam = h / 2.0
- R_samples = sample_radius_frac * min(h, w) * 0.5
- points_rel_cam = sample_points_relative_cam(h, w, sample_count, R_pixels=R_samples, seed=seed)
- # Define your camera's horizontal FOV (e.g., 90 degrees)
- H_FOV = 100.0
- # Redefine the ring to span -90 to 90 degrees instead of pixels
- az_range_px = az_range_frac * (w * 0.5) # 0.9 -> 360 default
- az_range_px2deg = ((H_FOV/2)/(w/2))
- sigma_exc = sigma_exc_frac * az_range_px # sigma 0.10 default
- sigma_inh = sigma_inh_frac * az_range_px # sigma 0.3 default
- sigma_kappa = sigma_kappa_frac * az_range_px # sigma_kappa controls the SPATIAL SPREAD of the gaze-centered inhibition.
- #Small sigma_kappa: Narrow Gaussian → Localized inhibition only at exact gaze position
- I0 = max(I0, 1e-6) # Ensure some minimal prior
- asymmetry_strength = min(asymmetry_strength,1.0) # max value of 1
- sigma_prior = sigma_prior_frac * az_range_px #az_range_px
- ring = RingAttractor(
- RingParams(
- n_units=n_units, # default 180
- az_range_px=az_range_px, #az_range_px,
- tau=float(tau_ms) / 1000.0, # time constant
- recurrent_gain=recurrent_gain,
- sigma_exc=sigma_exc,
- sigma_inh=sigma_inh,
- sigma_kappa=sigma_kappa,
- asymmetry_strength=asymmetry_strength,
- ),
- seed=seed,
- )
- #print(ring.phi)
- collector = DataCollector(ring.phi)
- os.makedirs(os.path.dirname(output_csv) or ".", exist_ok=True)
- writer = csv.writer(open(output_csv, "w", newline=""))
- writer.writerow([
- "frame_proc", "frame_abs",
- "theta_px", "pred_px",
- "omega_bar", "mean_speed", "n_used", "asymmetry_strength","I0",
- "cx_gaze", "cy_gaze"
- ])
- prev_gray = gray0
- abs_frame_idx = 0
- proc_frame_idx = 0
- last_valid_gaze: Optional[Tuple[float, float]] = None
- omega_prev: Optional[float] = None
- color_pts = (200, 200, 200)
- color_pred = (0, 0, 255)
- color_gaze = (0, 255, 255)
- while True:
- video_ended = False
- for _ in range(step):
- ok, frame = cap.read()
- abs_frame_idx += 1
- if not ok:
- video_ended = True
- break # break for
- #cap.release()
- #if display:
- # cv2.destroyAllWindows()
- #return
- if video_ended:
- print("End of video reached.")
- break # break while
- if resize_width > 0 and frame.shape[1] != resize_width:
- scale = resize_width / frame.shape[1]
- frame = cv2.resize(frame, (int(frame.shape[1] * scale), int(frame.shape[0] * scale)), interpolation=cv2.INTER_AREA)
- gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
- cx_gaze, cy_gaze, last_valid_gaze = get_gaze_for_abs_frame(
- gaze_data, abs_frame_idx, w, h, last_valid_gaze
- )
- flow = cv2.calcOpticalFlowFarneback( # compute flow field
- prev_gray, gray, None,
- pyr_scale=pyr_scale, levels=levels, winsize=winsize,
- iterations=iterations, poly_n=poly_n, poly_sigma=poly_sigma, flags=0
- )
- points_abs = np.empty_like(points_rel_cam)
- points_abs[:, 0] = np.round(points_rel_cam[:, 0] + cx_gaze).astype(np.int32)
- points_abs[:, 1] = np.round(points_rel_cam[:, 1] + cy_gaze).astype(np.int32)
- points_abs[:, 0] = np.clip(points_abs[:, 0], 0, w - 1)
- points_abs[:, 1] = np.clip(points_abs[:, 1], 0, h - 1)
- mk, spd = encode_direction_pools(flow, points_abs) # mt-like motion computation
- curl = curl_from_tangential(mk, points_abs, (cx_gaze, cy_gaze)) # compute curl
- omega = curl.omega_bar
- omega *= float(omega_sign) # mean curl
- if smooth_alpha > 0.0 and omega_prev is not None: # default 0.15
- omega = (1.0 - smooth_alpha) * omega_prev + smooth_alpha * omega
- omega_prev = omega
- gaze_az_px = float(cx_gaze - cx_cam) # Gaze horizontal position relative to image center in pixel coordinates of the ring attractor space
- #gaze_az_deg = math.degrees(math.atan(gaze_az_px / focal_length_px))
- #print(gaze_az_deg)
- # we pass now gaze_az_deg
- I_total, I_gaze, I_prior = ring.step(
- dt=dt_frame, omega_bar=omega, gaze_az_px=gaze_az_px, Kp=Kp, noise_std=noise_std,
- I0=I0, sigma_prior=sigma_prior, theta_prior_px=0.0,
- )
- theta_px = ring.decode_theta_px()
- pred_px = cx_cam + theta_px
- # Collect data for plotting
- collector.collect(x=ring.x, I_total=I_total, I_gaze=I_gaze, I_prior=I_prior, theta=theta_px)
- # writer.writerow([
- # proc_frame_idx, abs_frame_idx,
- # theta_px, pred_px,
- # omega, curl.mean_speed, curl.n_used, asymmetry_strength,
- # cx_gaze, cy_gaze
- # ])
- writer.writerow([
- proc_frame_idx,
- abs_frame_idx,
- f"{theta_px:.3f}",
- f"{pred_px:.3f}",
- f"{omega:.3f}",
- f"{curl.mean_speed:.3f}",
- curl.n_used,
- f"{asymmetry_strength:.1f}",
- f"{I0:.3f}",
- f"{cx_gaze:.3f}",
- f"{cy_gaze:.3f}",
- ])
- if display:
- vis = frame.copy()
- # Draw sample points
- for (x, y) in points_abs:
- cv2.circle(vis, (int(x), int(y)), 1, color_pts, -1)
- # Draw gaze point
- cv2.circle(vis, (int(round(cx_gaze)), int(round(cy_gaze))), 4, color_gaze, -1)
- # Draw heading prediction (red circle on the horizontal midline)
- cy_mid = int(round(cy_cam))
- cv2.circle(vis, (int(round(pred_px)), cy_mid), 6, color_pred, 2)
- # Add HUD text
- cv2.putText(vis, f"Proc {proc_frame_idx} | Abs {abs_frame_idx}", (10, 20),
- cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,0,0), 2)
- cv2.putText(vis, f"omega={omega:+.4f} theta_px={theta_px:+.1f}", (10, 40),
- cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,0,0), 2)
- cv2.putText(vis, f"Kp={Kp:.3f} asym={asymmetry_strength:.2f} n={ring.p.n_units}", (10, 60),
- cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,0,0), 2)
- if show_kappa:
- # Use the new connectivity visualization
- panel = _render_connectivity_panel(
- width=400, height=200, ring=ring,
- gaze_az_px=gaze_az_px, omega_bar=omega, Kp=Kp,
- I0=I0, sigma_prior=sigma_prior
- )
- ph, pw = panel.shape[:2]
- y0 = max(0, vis.shape[0]-ph-8)
- x0 = 8
- roi = vis[y0:y0+ph, x0:x0+pw]
- if panel.shape[2] == 4:
- alpha = panel[:, :, 3:4] / 255.0
- roi[:] = (alpha * panel[:, :, :3] + (1 - alpha) * roi).astype(np.uint8)
- else:
- roi[:] = panel
- # Show the main visualization window
- cv2.imshow("Neural ring - recurrent push-pull", vis)
- key = cv2.waitKey(1) & 0xFF
- if key == 27 or key == ord('q'):
- break
- prev_gray = gray
- proc_frame_idx += 1
- print(f"Finalizing... saving plots to heading_analysis_results.png")
- #collector.save_plots("heading_analysis_results.png",px2deg=az_range_px2deg)
- collector.save_plots(f"{video_base_name}_plots.png")
- collector.export_to_csv(video_base_name)
- # Also save the connectivity for the weight profile
- np.savetxt(f"{video_base_name}_connectivity.csv",
- ring.W[ring.p.n_units // 2, :], delimiter=",")
- cap.release()
- if display:
- cv2.destroyAllWindows()
- # --------------------------- CLI entrypoint -------------------------- #
- def parse_args():
- p = argparse.ArgumentParser(
- description="Neural heading estimator with recurrent push-pull (no Kaway)"
- )
- p.add_argument("--video", required=True, help="Path to input video file")
- p.add_argument("--gaze_csv", default=None, help="Optional CSV with x_px_ui,y_px_ui,(optional)visible")
- p.add_argument("--output_csv", default="heading_recurrent_pp.csv", help="Output CSV path")
- p.add_argument("--display", action="store_true", help="Show visualization window")
- # Frame processing
- p.add_argument("--step", type=int, default=1, help="Process every Nth frame")
- p.add_argument("--resize_width", type=int, default=0, help="Resize frames to this width (0=off)")
- # Sampling near gaze
- p.add_argument("--sample_count", type=int, default=400, help="Number of gaze-centered sample points")
- p.add_argument("--sample_radius_frac", type=float, default=0.85, help="Sampling radius as fraction of min(h,w)/2")
- # Farnebäck params
- p.add_argument("--pyr_scale", type=float, default=0.5)
- p.add_argument("--levels", type=int, default=3)
- p.add_argument("--winsize", type=int, default=15)
- p.add_argument("--iterations", type=int, default=3)
- p.add_argument("--poly_n", type=int, default=5)
- p.add_argument("--poly_sigma", type=float, default=1.2)
- # Controller gain & sign
- p.add_argument("--Kp", type=float, default=0.4, help="Coupling from ω̄ to gaze-centered inhibitory lobe")
- p.add_argument("--omega_sign", type=float, default=1.0, help="Multiply ω̄ by this sign (+1 or -1)")
- # Smoothing
- p.add_argument("--smooth_alpha", type=float, default=0.15, help="EMA for ω̄ (0 disables)")
- # Ring params
- p.add_argument("--n_units", type=int, default=181, help="Number of ring units")
- p.add_argument("--az_range_frac", type=float, default=0.9, help="Heading range as fraction of half image width")
- p.add_argument("--az_range_deg", type=float, default=90.0, help="Heading range as deg (close to screen degs)")
- p.add_argument("--tau_ms", type=float, default=60.0, help="Ring time constant (ms)")
- p.add_argument("--recurrent_gain", type=float, default=0.95, help="Recurrent gain (stability <~1)")
- p.add_argument("--sigma_exc_frac", type=float, default=0.10, help="Excitatory kernel width (fraction of az_range_px)")
- p.add_argument("--sigma_inh_frac", type=float, default=0.35, help="Inhibitory kernel width (fraction of az_range_px)")
- p.add_argument("--sigma_kappa_frac", type=float, default=0.12, help="Input lobe width (fraction of az_range_px)")
- # New parameter for push-pull asymmetry
- p.add_argument("--asymmetry_strength", type=float, default=0.0,
- help="Strength of asymmetric connectivity for push-pull (0=symmetric, 0.3=moderate asymmetry)")
- # Baseline prior
- p.add_argument("--I0", type=float, default=0.12, help="Baseline prior amplitude (Gaussian at straight-ahead)")
- p.add_argument("--sigma_prior_frac", type=float, default=0.18, help="Baseline prior width (fraction of az_range_px)")
- p.add_argument("--noise_std", type=float, default=0.0, help="Additive input noise std")
- p.add_argument("--show_kappa", action="store_true", help="Show input lobe profiles")
- p.add_argument("--seed", type=int, default=42, help="RNG seed")
- return p.parse_args()
- if __name__ == "__main__":
- args = parse_args()
- run(
- video_path=args.video,
- gaze_csv=args.gaze_csv,
- output_csv=args.output_csv,
- display=args.display,
- step=args.step,
- resize_width=args.resize_width,
- sample_count=args.sample_count,
- sample_radius_frac=args.sample_radius_frac,
- pyr_scale=args.pyr_scale,
- levels=args.levels,
- winsize=args.winsize,
- iterations=args.iterations,
- poly_n=args.poly_n,
- poly_sigma=args.poly_sigma,
- seed=args.seed,
- Kp=args.Kp,
- smooth_alpha=args.smooth_alpha,
- omega_sign=args.omega_sign,
- n_units=args.n_units,
- az_range_frac=args.az_range_frac,
- tau_ms=args.tau_ms,
- recurrent_gain=args.recurrent_gain,
- sigma_exc_frac=args.sigma_exc_frac,
- sigma_inh_frac=args.sigma_inh_frac,
- sigma_kappa_frac=args.sigma_kappa_frac,
- asymmetry_strength=args.asymmetry_strength, # New argument
- I0=args.I0,
- sigma_prior_frac=args.sigma_prior_frac,
- noise_std=args.noise_std,
- show_kappa=args.show_kappa,
- )
ring_attractor_heading_v1.py, no license · at the source
Overview
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
Availability: 1 check, the latest on 28 September 2026: the link answers (HTTP 200)
- 28 September 2026: the link answers (HTTP 200)
15 files
- Python tree/
curl-neural_model/ , Python, 947 lines, 6 matchesring_attractor_heading_v 1.py - Python tree/
curl-neural_model/ , Shell, 82 linesrunCurlmodel_v2.sh - R/
loaddata.R , R, 238 lines - R/
modelling.R , R, 1,091 lines - R/
packages.R , R, 34 lines - R/
plotting.R , R, 179 lines - R/
prepdata.R , R, 169 lines - R/
steering.R , R, 114 lines - R/
utils.R , R, 24 lines - docs/
controller_model.qmd , Quarto, 1,253 lines - docs/
figures.qmd , Quarto, 954 lines - docs/
index.qmd , Quarto, 449 lines - docs/
neural_model_simulations , Quarto, 220 lines.qmd - docs/
steering_simulation.qmd , Quarto, 163 lines - readme.txt, Text, 74 lines
supp:PMC13614786/elife-110770-code1.zip
Availability: 1 check, the latest on 28 September 2026: the link answers (HTTP 200)
- 28 September 2026: the link answers (HTTP 200)
1 file
- eLife-VOR-RA-2026-110770
/ , Python, 837 lines, 1 match3D_stimulus_generator.py
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://
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://
BibTeX
@article{zorpala2026reti
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/
url = {https://
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/
VL - 15
SP - RP110770
SN - 2050-084X
PB - eLife Sciences Publications, Ltd
DO - 10.7554/
UR - https://
LA - en
ER -
CSL-JSON
{
"id": "10.7554/
"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":
"volume": "15",
"page": "RP110770",
"DOI": "10.7554/
"PMID": "42788718",
"PMCID": "PMC13614786",
"ISSN": "2050-084X",
"publisher": "eLife Sciences Publications, Ltd",
"URL": "https://
"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-022-16371-4 [code]
- Distributed encoding of curvilinear self-motion across spiral optic flow patterns.Journal: Scientific reportsIn common: behavior only, 13 references
- [2] doi:10.1038/s41467-022-33245-5 [code]
- Causal contribution of optic flow signal in Macaque extrastriate visual cortex for roll perception.Journal: Nature communicationsIn common: cognitive, 13 references
- [3] doi:10.1038/s41598-026-35805-x
- The effect of unilateral cortical blindness on lane position and gaze behavior in a virtual reality steering task.Journal: Scientific reportsIn common: cognitive, 9 references
- [4] doi:10.1167/jov.23.2.8 [code]
- Temporal stability of human heading perception.Journal: Journal of visionIn common: cognitive, 9 references
- [5] doi:10.7554/elife.82410 [code]
- Retinal motion statistics during natural locomotion.Journal: eLifeIn common: behavior only, cognitive, 7 references
- [6] doi:10.1038/s41598-026-56170-9 [code]
- Path integration from optic flow and the role of eye movements.Journal: Scientific reportsIn common: cognitive, 5 references
- [7] doi:10.3389/fnins.2024.1441285 [code]
- Accuracy optimized neural networks do not effectively model optic flow tuning in brain area MSTd.Journal: Frontiers in neuroscienceIn common: NumPy, 5 references
- [8] doi:10.1167/jov.25.13.10 [code]
- Distinct temporal dynamics of judging scene-relative object motion and estimating heading from optic flow.Journal: Journal of visionIn common: cognitive, 4 references
- [9] doi:10.1016/j.isci.2023.108307
- Psychophysical measurement of perceived motion flow of naturalistic scenes.Journal: iScienceIn common: behavior only, 4 references
- [10] doi:10.1073/pnas.2412954122 [code]
- Active vision in freely moving marmosets using head-mounted eye tracking.Journal: Proceedings of the National Academy of Sciences of the United States of AmericaIn common: OpenCV, Matplotlib, NumPy, behavior only, cognitive, 2 references
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.
Claim this paper
Correct its record
Say what each link of this record is, remove the ones that are not the paper's, add the ones that are missing. The correction becomes a new version of the record, in its Versions section.
Validate its tracing map
You validate the map as this page shows it: 2 repositories of the authors' code, each at its verified commit and with its license, 15 scripts, and 7 matches between paragraphs and code (see the Code and Map sections). It then receives a DOI on Zenodo, with you (your ORCID iD) and OSCR as its creators; the code itself is not deposited.
The map's fingerprint: sha256:af4d51a7b3ffe808…
Add the badge to the code's README
If you are an author or a maintainer of this paper's code, you can add this badge. It says what OSCR knows of the paper (its code, its validated tracing map) and links the code to this page. Paste one of these snippets under the README's title. Keep the two marks around it, so that the badge can be replaced in place and is never added twice. OSCR changes nothing in your repository.
Markdown
<!-- oscr-badge:start -->
[](https://openscicode.org/badge/doi/10.7554/elife.110770)
<!-- oscr-badge:end -->reStructuredText
.. oscr-badge:start
.. image:: https://openscicode.org/badge/doi/10.7554/elife.110770.svg
:target: https://openscicode.org/badge/doi/10.7554/elife.110770
:alt: OSCR
.. oscr-badge:endHTML
<!-- oscr-badge:start -->
<a href="https://openscicode.org/badge/doi/10.7554/elife.110770"><img src="https://openscicode.org/badge/doi/10.7554/elife.110770.svg" alt="OSCR"></a>
<!-- oscr-badge:end -->If you choose to, you can have the badge kept up to date for you, with oscr badge --write from the command line or with the GitHub Action in your repository's workflows. Every other form and option (a card, AsciiDoc, LaTeX, shields.io) is on the badge 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: code errors and mismatches between the code and the paper about this paper and its code, from readers and its authors, beside the paper and its tracing map.
Report a mismatch between the code and the paper · Report a code error (signed in)
Reproductions: reports from readers who ran the authors' code and did not get the paper's result, what they ran, with which environment, commit and data.
Report a reproduction failure (signed in)
Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.
