Metadata-Version: 2.4
Name: stampe1993-gaze-mapping
Version: 1.0.0
Summary: Stampe (1993) gaze-mapping polynomial: per-eye 5-term biquadratic + per-quadrant corner correction, staged-fit variant matching EyeLink's stored coefficients.
Project-URL: Homepage, https://github.com/mh-salari/stampe1993-gaze-mapping
Project-URL: Repository, https://github.com/mh-salari/stampe1993-gaze-mapping
Project-URL: Issues, https://github.com/mh-salari/stampe1993-gaze-mapping/issues
Author-email: Mohammadhossein Salari <mohammadhossein.salari@gmail.com>
License: MIT
License-File: LICENSE
Keywords: calibration,eyelink,eyetracking,gaze-mapping,neuroscience,polynomial,stampe1993
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.10
Requires-Dist: numpy
Provides-Extra: dev
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: ruff>=0.13.0; extra == 'dev'
Description-Content-Type: text/markdown

# Stampe (1993) gaze-mapping polynomial

[![PyPI version](https://img.shields.io/pypi/v/stampe1993-gaze-mapping)](https://pypi.org/project/stampe1993-gaze-mapping/)
[![Downloads](https://static.pepy.tech/badge/stampe1993-gaze-mapping)](https://pepy.tech/project/stampe1993-gaze-mapping)
[![License](https://img.shields.io/pypi/l/stampe1993-gaze-mapping)](https://github.com/mh-salari/stampe1993-gaze-mapping/blob/main/LICENSE)

Per-eye Stampe (1993) gaze-mapping polynomial in Python, generalised to arbitrary degree.

This package fits a polynomial **without cross terms** (`1, x, y, x², y², ..., xᵈ, yᵈ`) plus an optional **per-quadrant corner correction** that supplies the missing `xy` cross-term coefficient on outer points.

| degree | polynomial terms | min inner points | maps to |
|---|---|---|---|
| `d = 1` | `1, x, y` | 3 | HV3 (bilinear) |
| `d = 2` | `1, x, y, x², y²` | 5 | HV5, HV9 — **Stampe 1993 canonical** |
| `d = 3` | `1, x, y, x², y², x³, y³` | 7 | HV13-style extension |

Corner correction is independent of `d`: pass exactly 4 outer points (one per quadrant relative to the centroid) and the model adds one `xy` coefficient per quadrant. With no outer points, the polynomial alone is used.

The staged inner/outer fit matches what the EyeLink 1000 Plus does at calibration and has been verified against EyeLink HREF P-CR data.

**Reference:** Stampe, D. M. (1993). "Heuristic filtering and reliable calibration methods for video-based pupil-tracking systems." *Behavior Research Methods*, 25(2), 137-142.

Stampe's published mapping function (paper eq., p. 141, HV9 case):

```
x₁ = a + b·x + c·y + d·x² + e·y²
y₁ = f + g·x + h·y + i·x² + j·y²
X  = x₁ + m[q]·x₁·y₁
Y  = y₁ + n[q]·x₁·y₁
```

where `(x, y)` is the tracker feature, `(X, Y)` is the screen coordinate, and `q` is the quadrant into which `(x₁, y₁)` falls.

---

## Installation

From PyPI:

```bash
pip install stampe1993-gaze-mapping
```

With uv:

```bash
uv add stampe1993-gaze-mapping
```

For a local checkout, install editable:

```bash
pip install -e .
# or
uv add --editable .
```

---

## Quick start

```python
import numpy as np
from stampe1993_gaze_mapping import StampeModel, angular_error

# HV9: 5 inner + 4 outer corner targets
inner_pcr = np.array([...])  # (5, 2) P-CR features at centre + 4 edges
inner_targets = np.array([...])  # (5, 2) screen-pixel targets
outer_pcr = np.array([...])  # (4, 2) P-CR features at the 4 corners
outer_targets = np.array([...])  # (4, 2) screen targets at the 4 corners

model = StampeModel(degree=2)
model.fit(inner_pcr, inner_targets, outer_pcr, outer_targets)

# Predict screen coordinates from a new (or training) P-CR vector
predicted_xy = model.predict(inner_pcr)

# Pixel error → degrees of visual angle (per-axis signed + magnitude)
err_px = predicted_xy - inner_targets
deg_x, deg_y, mag_deg = angular_error(
    err_px,
    pitch_x_mm=screen_width_mm / screen_res_x,
    pitch_y_mm=screen_height_mm / screen_res_y,
    viewing_dist_mm=eye_to_screen_mm,
)
print(f"calibration residual: mean {mag_deg.mean():.3f}° max {mag_deg.max():.3f}°")
```

For **HV5** (no corners) skip the outer arguments — `outer_*` defaults to `None`:

```python
model = StampeModel(degree=2)
model.fit(inner5_pcr, inner5_targets)
```

For **HV3** (bilinear):

```python
model = StampeModel(degree=1)
model.fit(inner3_pcr, inner3_targets)
```

For **HV13** (bicubic), either use the model as a pure polynomial fitter on all 13 points, or split 9 inner + 4 outer for Stampe-style correction. The caller decides the split:

```python
# pure polynomial: all 13 as inner, no corners
model = StampeModel(degree=3)
model.fit(all13_pcr, all13_targets)

# Stampe-style: caller picks which 9 are inner and which 4 are outer
model.fit(inner9_pcr, inner9_targets, corners4_pcr, corners4_targets)
```

Cross terms above `xy` (`x²y`, `xy²`, etc.) are not modelled in any configuration. The Stampe-style polynomial drops them and corner correction only puts back `xy` per quadrant. If you need a full bicubic with all cross terms, use a different fitter.

The fitted model is fully described by these attributes after `fit`:

- `model.degree` — polynomial degree.
- `model.coef_x` — shape `(1 + 2*degree,)`, polynomial coefficients for the X axis.
- `model.coef_y` — shape `(1 + 2*degree,)`, same for Y.
- `model.centroid` — shape `(2,)`, the screen-coord centroid of the calibration targets (used as the quadrant origin).
- `model.corner` — shape `(4, 2)`, the per-quadrant `(cx, cy)` corner-correction coefficients. Quadrants are indexed `(top-left, top-right, bottom-left, bottom-right)` in image-y-down coordinates. All zeros if no outer points were supplied.

---

## API

| call | what it does |
|---|---|
| `StampeModel(degree=2)` | Unfitted model. `degree=1` bilinear, `degree=2` biquadratic (Stampe default), `degree=3` bicubic. Raises `ValueError` if `degree < 1`. |
| `model.fit(inner_pcr, inner_targets, outer_pcr=None, outer_targets=None)` | Fits polynomial on inner; per-quadrant corner correction if `outer_*` given. All arrays `(N, 2)`. |
| `model.predict(pcr_xy)` | Evaluate on `(2,)` or `(N, 2)` P-CR features; same shape out. Raises `RuntimeError` if called before `fit`. |
| `angular_error(err_px, pitch_x_mm, pitch_y_mm, viewing_dist_mm)` | Convert `(N, 2)` pixel-error to `(deg_x, deg_y, mag_deg)`. Lives in `stampe1993_gaze_mapping.angular`, re-exported from package root. |

`fit` raises `ValueError` on: shape mismatch, fewer than `1 + 2*degree` inner points, asymmetric outer (one `None`, one not), `outer_*` length ≠ 4, outer points not spanning all 4 quadrants around the centroid, or an outer-predicted point on a cardinal axis through the centroid.

**Internals (also exported):** `design_row(pcr, degree=2)` returns the `[1, x, y, x², y², ..., xᵈ, yᵈ]` feature row for one P-CR vector. `quadrant_code(dx, dy)` returns `0..3` from the sign of `(dx, dy)`, image-y-down.

---

## Acknowledgments

This project has received funding from the European Union's Horizon Europe research and innovation funding program under grant agreement No 101072410, Eyes4ICU project.

<p align="center">
<img src="https://raw.githubusercontent.com/mh-salari/stampe1993-gaze-mapping/main/resources/Funded_by_EU_Eyes4ICU.png" alt="Funded by EU Eyes4ICU" width="500">
</p>