heatmatch 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- heatmatch-1.0.0/LICENSE +21 -0
- heatmatch-1.0.0/PKG-INFO +159 -0
- heatmatch-1.0.0/README.md +136 -0
- heatmatch-1.0.0/heatmatch/__init__.py +3 -0
- heatmatch-1.0.0/heatmatch/fields.py +197 -0
- heatmatch-1.0.0/heatmatch/heatmapping.py +157 -0
- heatmatch-1.0.0/heatmatch/matching.py +99 -0
- heatmatch-1.0.0/heatmatch.egg-info/PKG-INFO +159 -0
- heatmatch-1.0.0/heatmatch.egg-info/SOURCES.txt +12 -0
- heatmatch-1.0.0/heatmatch.egg-info/dependency_links.txt +1 -0
- heatmatch-1.0.0/heatmatch.egg-info/requires.txt +3 -0
- heatmatch-1.0.0/heatmatch.egg-info/top_level.txt +1 -0
- heatmatch-1.0.0/pyproject.toml +37 -0
- heatmatch-1.0.0/setup.cfg +4 -0
heatmatch-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xingyu Long
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
heatmatch-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: heatmatch
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Orientation-aware visualization and comparison of very dense saccade patterns (scanpath comparison for long free viewing)
|
|
5
|
+
Author: Xingyu Long
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/xysLong/HeatMatch
|
|
8
|
+
Project-URL: Paper, https://doi.org/10.1145/3803539
|
|
9
|
+
Project-URL: Data, https://osf.io/f2xhj/
|
|
10
|
+
Keywords: eye-tracking,saccades,scanpath,scanpath-comparison,gaze,heatmap,visualization,orientation-field,empirical-aesthetics,art-perception
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: numpy
|
|
20
|
+
Requires-Dist: matplotlib
|
|
21
|
+
Requires-Dist: Pillow
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# HeatMatch
|
|
25
|
+
|
|
26
|
+
**Visualize and compare very dense saccade patterns from long free-viewing eye-tracking data.**
|
|
27
|
+
|
|
28
|
+
HeatMatch turns hundreds or thousands of saccades into continuous *orientation fields*: at every location it estimates saccade density, mean orientation, and how consistently the saccades agree in orientation. From these fields it draws orientation-coded heatmaps that show *where* gaze moves and *in which direction*. It also computes a similarity score for comparing saccade patterns across participants, stimuli, or conditions, without AOIs, scanpath alignment, or manual segmentation.
|
|
29
|
+
|
|
30
|
+
It was developed for 60-second viewing of paintings (empirical aesthetics, art history) and works for any static stimulus. Python implementation accompanying the paper:
|
|
31
|
+
|
|
32
|
+
<table border="0" cellspacing="0" cellpadding="0"><tr>
|
|
33
|
+
<td width="72%" valign="top">
|
|
34
|
+
|
|
35
|
+
<blockquote>
|
|
36
|
+
Xingyu Long, Jozsef Arato, Sophia Kury, Anna Miscena, and Raphael Rosenberg. 2026.<br/>
|
|
37
|
+
<strong>HeatMatch: Orientation-Aware Visualization and Comparison of Very Dense Saccade Patterns.</strong><br/>
|
|
38
|
+
<em>Proceedings of the ACM on Computer Graphics and Interactive Techniques</em> (PACMCGIT), 9(2), Article 18.<br/>
|
|
39
|
+
<a href="https://doi.org/10.1145/3803539">https://doi.org/10.1145/3803539</a>
|
|
40
|
+
</blockquote>
|
|
41
|
+
|
|
42
|
+
</td>
|
|
43
|
+
<td width="28%" align="center" valign="middle">
|
|
44
|
+
<img src="https://raw.githubusercontent.com/xysLong/HeatMatch/main/assets/logos.png" width="200" alt="CReA Lab and Vienna Cognitive Science Hub"/>
|
|
45
|
+
</td>
|
|
46
|
+
</tr></table>
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Overview
|
|
51
|
+
|
|
52
|
+
1. **OOI-coded heatmaps** — orientation fields visualized with researcher-defined color anchors and confidence-weighted opacity.
|
|
53
|
+
2. **HeatMatch similarity** — comparison based on saccade density and orientation.
|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
<sub>Per-participant (columns 1–5) and aggregated (column 6) heatmaps for three paintings. Hue = mean saccade orientation relative to OOI; opacity = local confidence. Stimulus images: public domain via [Wikimedia Commons](https://commons.wikimedia.org) — see paper for details.</sub>
|
|
57
|
+
|
|
58
|
+
See the [paper](https://doi.org/10.1145/3803539) for methodology.
|
|
59
|
+
|
|
60
|
+
### When to use it
|
|
61
|
+
|
|
62
|
+
- **Long, dense viewing.** Scanpath comparison methods such as MultiMatch or ScanMatch align scanpaths sequence by sequence. With hundreds of saccades per trial and strongly diverging paths, they become slow and hard to interpret. HeatMatch aggregates instead of aligning, so it scales to arbitrarily dense data.
|
|
63
|
+
- **Orientation matters.** Fixation heatmaps and density-based metrics ignore saccade direction. HeatMatch keeps orientation, so you can ask whether gaze follows horizontal, vertical, diagonal, or symmetric structure in an image, e.g. composition lines in a painting.
|
|
64
|
+
- **Group, stimulus, and individual comparisons.** In the paper, the similarity score recovers the abstract vs. still-life distinction and differences in within-painting agreement. It also reveals stable individual "saccadic signatures" that hold across paintings.
|
|
65
|
+
|
|
66
|
+
It is **not** a replacement for sequence-based methods. HeatMatch is permutation-invariant (it ignores saccade order) and treats orientations as unsigned (left-to-right = right-to-left). Use it alongside MultiMatch, ScanMatch, or RQA when order or direction matters.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install heatmatch
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Dependencies: `numpy`, `matplotlib`, `Pillow`. For development, clone the repository and run `pip install -e .`.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Quick Start
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import matplotlib.pyplot as plt
|
|
84
|
+
from heatmatch import make_reference_grid, make_orientation_field, Heatmap, compute_similarity
|
|
85
|
+
|
|
86
|
+
W, H = 2880, 2160
|
|
87
|
+
onset = ... # (J, 2) saccade start coordinates
|
|
88
|
+
offset = ... # (J, 2) saccade end coordinates
|
|
89
|
+
|
|
90
|
+
pts, xx, yy = make_reference_grid(W, H, grid_resolution=200)
|
|
91
|
+
field = make_orientation_field(pts, onset, offset, sigma=50.0, grid_shape=yy.shape)
|
|
92
|
+
|
|
93
|
+
fig, ax = plt.subplots()
|
|
94
|
+
Heatmap(field, W, H).draw(ax, ooi=0.0) # ooi=0 → horizontal, ooi=90 → vertical
|
|
95
|
+
plt.show()
|
|
96
|
+
|
|
97
|
+
# Compare two patterns (fields built on the same grid, e.g. two participants)
|
|
98
|
+
result = compute_similarity(field_a, field_b)
|
|
99
|
+
s = (result.s_loc + result.s_dir) / 2 # composite HeatMatch score S in [0, 1]
|
|
100
|
+
print(result.s_loc, result.s_dir, s)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A full worked example is in [`demo.ipynb`](https://github.com/xysLong/HeatMatch/blob/main/demo.ipynb). The dataset is not included — download `data_anonymized.csv` from **[osf.io/f2xhj](https://osf.io/f2xhj/)** and place it in `tests/`.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## API
|
|
108
|
+
|
|
109
|
+
### `heatmatch.fields`
|
|
110
|
+
|
|
111
|
+
| | |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `make_reference_grid(w, h, grid_resolution)` | Returns `(pts, xx, yy)`; int → square grid, `(ny, nx)` tuple → rectangular |
|
|
114
|
+
| `make_orientation_field(pts, onset, offset, sigma, grid_shape, ...)` | Returns `OrientationField` with `omega_mean`, `R`, `rho` |
|
|
115
|
+
|
|
116
|
+
### `heatmatch.heatmapping`
|
|
117
|
+
|
|
118
|
+
| | |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `Heatmap(field, w, h, image=None)` | Caches colormap, opacity, and (optionally) grayscale background image |
|
|
121
|
+
| `Heatmap.draw(ax, ooi, opacity_density_weight, base_opacity, cmap, ...)` | Renders onto `ax` |
|
|
122
|
+
|
|
123
|
+
OOI in degrees: 0 = east, 90 = north, 180 = west, 270 = south. Unsigned, so `ooi` and `ooi ± 180` are equivalent.
|
|
124
|
+
|
|
125
|
+
### `heatmatch.matching`
|
|
126
|
+
|
|
127
|
+
| | |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `compute_similarity(field_a, field_b, density_coherence_tradeoff)` | Returns `SimilarityResult(s_loc, s_dir)` |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Key Parameters
|
|
134
|
+
|
|
135
|
+
| Parameter | Default | Notes |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| `sigma` | `50.0` | Gaussian bandwidth in px. Paper evaluates {50, 100, 150}. |
|
|
138
|
+
| `grid_resolution` | `200` | Int or `(ny, nx)`. Paper uses 200. |
|
|
139
|
+
| `ooi` | `0.0` | Orientation of Interest in degrees. |
|
|
140
|
+
| `opacity_density_weight` | `1.0` | Density vs. coherence for opacity. Recommended 0.5–1.0. |
|
|
141
|
+
| `base_opacity` | `0.85` | Global opacity ceiling; useful with a background image. |
|
|
142
|
+
| `density_coherence_tradeoff` | `0.5` | Density vs. coherence for *S*_dir. At 1.0, angular information is discarded. |
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Citation
|
|
147
|
+
|
|
148
|
+
```bibtex
|
|
149
|
+
@article{long2026heatmatch,
|
|
150
|
+
author = {Long, Xingyu and Arato, Jozsef and Kury, Sophia and Miscena, Anna and Rosenberg, Raphael},
|
|
151
|
+
title = {HeatMatch: Orientation-Aware Visualization and Comparison of Very Dense Saccade Patterns},
|
|
152
|
+
journal = {Proc. ACM Comput. Graph. Interact. Tech.},
|
|
153
|
+
year = {2026},
|
|
154
|
+
volume = {9},
|
|
155
|
+
number = {2},
|
|
156
|
+
articleno = {18},
|
|
157
|
+
doi = {10.1145/3803539},
|
|
158
|
+
}
|
|
159
|
+
```
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# HeatMatch
|
|
2
|
+
|
|
3
|
+
**Visualize and compare very dense saccade patterns from long free-viewing eye-tracking data.**
|
|
4
|
+
|
|
5
|
+
HeatMatch turns hundreds or thousands of saccades into continuous *orientation fields*: at every location it estimates saccade density, mean orientation, and how consistently the saccades agree in orientation. From these fields it draws orientation-coded heatmaps that show *where* gaze moves and *in which direction*. It also computes a similarity score for comparing saccade patterns across participants, stimuli, or conditions, without AOIs, scanpath alignment, or manual segmentation.
|
|
6
|
+
|
|
7
|
+
It was developed for 60-second viewing of paintings (empirical aesthetics, art history) and works for any static stimulus. Python implementation accompanying the paper:
|
|
8
|
+
|
|
9
|
+
<table border="0" cellspacing="0" cellpadding="0"><tr>
|
|
10
|
+
<td width="72%" valign="top">
|
|
11
|
+
|
|
12
|
+
<blockquote>
|
|
13
|
+
Xingyu Long, Jozsef Arato, Sophia Kury, Anna Miscena, and Raphael Rosenberg. 2026.<br/>
|
|
14
|
+
<strong>HeatMatch: Orientation-Aware Visualization and Comparison of Very Dense Saccade Patterns.</strong><br/>
|
|
15
|
+
<em>Proceedings of the ACM on Computer Graphics and Interactive Techniques</em> (PACMCGIT), 9(2), Article 18.<br/>
|
|
16
|
+
<a href="https://doi.org/10.1145/3803539">https://doi.org/10.1145/3803539</a>
|
|
17
|
+
</blockquote>
|
|
18
|
+
|
|
19
|
+
</td>
|
|
20
|
+
<td width="28%" align="center" valign="middle">
|
|
21
|
+
<img src="https://raw.githubusercontent.com/xysLong/HeatMatch/main/assets/logos.png" width="200" alt="CReA Lab and Vienna Cognitive Science Hub"/>
|
|
22
|
+
</td>
|
|
23
|
+
</tr></table>
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Overview
|
|
28
|
+
|
|
29
|
+
1. **OOI-coded heatmaps** — orientation fields visualized with researcher-defined color anchors and confidence-weighted opacity.
|
|
30
|
+
2. **HeatMatch similarity** — comparison based on saccade density and orientation.
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
<sub>Per-participant (columns 1–5) and aggregated (column 6) heatmaps for three paintings. Hue = mean saccade orientation relative to OOI; opacity = local confidence. Stimulus images: public domain via [Wikimedia Commons](https://commons.wikimedia.org) — see paper for details.</sub>
|
|
34
|
+
|
|
35
|
+
See the [paper](https://doi.org/10.1145/3803539) for methodology.
|
|
36
|
+
|
|
37
|
+
### When to use it
|
|
38
|
+
|
|
39
|
+
- **Long, dense viewing.** Scanpath comparison methods such as MultiMatch or ScanMatch align scanpaths sequence by sequence. With hundreds of saccades per trial and strongly diverging paths, they become slow and hard to interpret. HeatMatch aggregates instead of aligning, so it scales to arbitrarily dense data.
|
|
40
|
+
- **Orientation matters.** Fixation heatmaps and density-based metrics ignore saccade direction. HeatMatch keeps orientation, so you can ask whether gaze follows horizontal, vertical, diagonal, or symmetric structure in an image, e.g. composition lines in a painting.
|
|
41
|
+
- **Group, stimulus, and individual comparisons.** In the paper, the similarity score recovers the abstract vs. still-life distinction and differences in within-painting agreement. It also reveals stable individual "saccadic signatures" that hold across paintings.
|
|
42
|
+
|
|
43
|
+
It is **not** a replacement for sequence-based methods. HeatMatch is permutation-invariant (it ignores saccade order) and treats orientations as unsigned (left-to-right = right-to-left). Use it alongside MultiMatch, ScanMatch, or RQA when order or direction matters.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install heatmatch
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Dependencies: `numpy`, `matplotlib`, `Pillow`. For development, clone the repository and run `pip install -e .`.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import matplotlib.pyplot as plt
|
|
61
|
+
from heatmatch import make_reference_grid, make_orientation_field, Heatmap, compute_similarity
|
|
62
|
+
|
|
63
|
+
W, H = 2880, 2160
|
|
64
|
+
onset = ... # (J, 2) saccade start coordinates
|
|
65
|
+
offset = ... # (J, 2) saccade end coordinates
|
|
66
|
+
|
|
67
|
+
pts, xx, yy = make_reference_grid(W, H, grid_resolution=200)
|
|
68
|
+
field = make_orientation_field(pts, onset, offset, sigma=50.0, grid_shape=yy.shape)
|
|
69
|
+
|
|
70
|
+
fig, ax = plt.subplots()
|
|
71
|
+
Heatmap(field, W, H).draw(ax, ooi=0.0) # ooi=0 → horizontal, ooi=90 → vertical
|
|
72
|
+
plt.show()
|
|
73
|
+
|
|
74
|
+
# Compare two patterns (fields built on the same grid, e.g. two participants)
|
|
75
|
+
result = compute_similarity(field_a, field_b)
|
|
76
|
+
s = (result.s_loc + result.s_dir) / 2 # composite HeatMatch score S in [0, 1]
|
|
77
|
+
print(result.s_loc, result.s_dir, s)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A full worked example is in [`demo.ipynb`](https://github.com/xysLong/HeatMatch/blob/main/demo.ipynb). The dataset is not included — download `data_anonymized.csv` from **[osf.io/f2xhj](https://osf.io/f2xhj/)** and place it in `tests/`.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## API
|
|
85
|
+
|
|
86
|
+
### `heatmatch.fields`
|
|
87
|
+
|
|
88
|
+
| | |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `make_reference_grid(w, h, grid_resolution)` | Returns `(pts, xx, yy)`; int → square grid, `(ny, nx)` tuple → rectangular |
|
|
91
|
+
| `make_orientation_field(pts, onset, offset, sigma, grid_shape, ...)` | Returns `OrientationField` with `omega_mean`, `R`, `rho` |
|
|
92
|
+
|
|
93
|
+
### `heatmatch.heatmapping`
|
|
94
|
+
|
|
95
|
+
| | |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `Heatmap(field, w, h, image=None)` | Caches colormap, opacity, and (optionally) grayscale background image |
|
|
98
|
+
| `Heatmap.draw(ax, ooi, opacity_density_weight, base_opacity, cmap, ...)` | Renders onto `ax` |
|
|
99
|
+
|
|
100
|
+
OOI in degrees: 0 = east, 90 = north, 180 = west, 270 = south. Unsigned, so `ooi` and `ooi ± 180` are equivalent.
|
|
101
|
+
|
|
102
|
+
### `heatmatch.matching`
|
|
103
|
+
|
|
104
|
+
| | |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `compute_similarity(field_a, field_b, density_coherence_tradeoff)` | Returns `SimilarityResult(s_loc, s_dir)` |
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Key Parameters
|
|
111
|
+
|
|
112
|
+
| Parameter | Default | Notes |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `sigma` | `50.0` | Gaussian bandwidth in px. Paper evaluates {50, 100, 150}. |
|
|
115
|
+
| `grid_resolution` | `200` | Int or `(ny, nx)`. Paper uses 200. |
|
|
116
|
+
| `ooi` | `0.0` | Orientation of Interest in degrees. |
|
|
117
|
+
| `opacity_density_weight` | `1.0` | Density vs. coherence for opacity. Recommended 0.5–1.0. |
|
|
118
|
+
| `base_opacity` | `0.85` | Global opacity ceiling; useful with a background image. |
|
|
119
|
+
| `density_coherence_tradeoff` | `0.5` | Density vs. coherence for *S*_dir. At 1.0, angular information is discarded. |
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Citation
|
|
124
|
+
|
|
125
|
+
```bibtex
|
|
126
|
+
@article{long2026heatmatch,
|
|
127
|
+
author = {Long, Xingyu and Arato, Jozsef and Kury, Sophia and Miscena, Anna and Rosenberg, Raphael},
|
|
128
|
+
title = {HeatMatch: Orientation-Aware Visualization and Comparison of Very Dense Saccade Patterns},
|
|
129
|
+
journal = {Proc. ACM Comput. Graph. Interact. Tech.},
|
|
130
|
+
year = {2026},
|
|
131
|
+
volume = {9},
|
|
132
|
+
number = {2},
|
|
133
|
+
articleno = {18},
|
|
134
|
+
doi = {10.1145/3803539},
|
|
135
|
+
}
|
|
136
|
+
```
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Field Computation
|
|
6
|
+
|
|
7
|
+
Computes orientation fields over a reference grid from saccade data.
|
|
8
|
+
|
|
9
|
+
- Orientation — unsigned saccade orientation ω_j ∈ [0, π).
|
|
10
|
+
- Point-Saccade Distance — minimal Euclidean distance from a reference point p_i
|
|
11
|
+
to a saccade segment s_j.
|
|
12
|
+
- Gaussian Kernel — kernel K(p_i, s_j) and kernel mass m_i.
|
|
13
|
+
- Mean Orientation and Coherence — weighted mean orientation ω̄_i and mean
|
|
14
|
+
resultant length R_i via double-angle embedding.
|
|
15
|
+
- Density — normalized kernel mass ρ_i.
|
|
16
|
+
|
|
17
|
+
Public API : make_reference_grid, make_orientation_field, OrientationField
|
|
18
|
+
Internal : _compute_orientations, _point_to_segment_distances, _gaussian_kernel
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class OrientationField:
|
|
24
|
+
"""
|
|
25
|
+
Precomputed orientation field over a reference grid.
|
|
26
|
+
|
|
27
|
+
Holds the three per-point statistics returned by make_orientation_field
|
|
28
|
+
together with the grid shape so that callers need not carry it separately.
|
|
29
|
+
|
|
30
|
+
Attributes
|
|
31
|
+
----------
|
|
32
|
+
omega_mean : (P,) array in [0, π), NaN where m_i = 0
|
|
33
|
+
R : (P,) array in [0, 1] — mean resultant length (coherence)
|
|
34
|
+
rho : (P,) array in [0, 1] — normalized kernel mass (density), sums to 1
|
|
35
|
+
grid_shape : (ny, nx) tuple — reshape arrays with .reshape(grid_shape)
|
|
36
|
+
"""
|
|
37
|
+
omega_mean: np.ndarray
|
|
38
|
+
R: np.ndarray
|
|
39
|
+
rho: np.ndarray
|
|
40
|
+
grid_shape: tuple
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _compute_orientations(onset, offset):
|
|
44
|
+
"""
|
|
45
|
+
Unsigned saccade orientations ω_j ∈ [0, π).
|
|
46
|
+
|
|
47
|
+
ω_j = atan2(y_{j1} − y_{j0}; x_{j1} − x_{j0}) mod π
|
|
48
|
+
|
|
49
|
+
Orientations are unsigned: ω and ω+π are treated as equivalent, so that
|
|
50
|
+
back-and-forth saccades along the same axis reinforce rather than cancel.
|
|
51
|
+
|
|
52
|
+
Used internally by make_orientation_field; not part of the public API.
|
|
53
|
+
"""
|
|
54
|
+
d = offset - onset
|
|
55
|
+
return np.mod(np.arctan2(d[:, 1], d[:, 0]), np.pi)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _point_to_segment_distances(points, onset, offset):
|
|
59
|
+
"""
|
|
60
|
+
Minimal Euclidean distance d(p_i, s_j) from each reference point p_i to each
|
|
61
|
+
saccade segment s_j = [s_{j0}, s_{j1}].
|
|
62
|
+
|
|
63
|
+
Used internally by make_orientation_field; not part of the public API.
|
|
64
|
+
"""
|
|
65
|
+
p = points[:, None, :] # (P, 1, 2)
|
|
66
|
+
a = onset[None, :, :] # (1, J, 2)
|
|
67
|
+
b = offset[None, :, :] # (1, J, 2)
|
|
68
|
+
|
|
69
|
+
ab = b - a # (1, J, 2)
|
|
70
|
+
ap = p - a # (P, J, 2)
|
|
71
|
+
ab_len2 = np.sum(ab * ab, axis=-1, keepdims=True) # (1, J, 1)
|
|
72
|
+
ab_len2 = np.where(ab_len2 == 0.0, 1.0, ab_len2) # guard zero-length
|
|
73
|
+
|
|
74
|
+
t = np.clip(np.sum(ap * ab, axis=-1, keepdims=True) / ab_len2, 0.0, 1.0) # (P, J, 1)
|
|
75
|
+
closest = a + t * ab # (P, J, 2)
|
|
76
|
+
return np.linalg.norm(p - closest, axis=-1) # (P, J)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _gaussian_kernel(d, sigma):
|
|
80
|
+
"""
|
|
81
|
+
Gaussian kernel K(p_i, s_j) = exp(−½ (d / σ)²).
|
|
82
|
+
|
|
83
|
+
Used internally by make_orientation_field; not part of the public API.
|
|
84
|
+
"""
|
|
85
|
+
return np.exp(-0.5 * (d / sigma) ** 2)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def make_reference_grid(w, h, grid_resolution=200):
|
|
90
|
+
"""
|
|
91
|
+
Uniform reference grid of positions {p_i} ⊂ [1..W] × [1..H].
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
w, h : stimulus width and height in pixels
|
|
96
|
+
grid_resolution : int or (ny, nx) tuple
|
|
97
|
+
Number of grid points along each axis. A single integer
|
|
98
|
+
produces a square grid; a tuple sets rows and columns
|
|
99
|
+
independently. Follows numpy's (rows, cols) = (ny, nx)
|
|
100
|
+
convention, matching the shape of the returned YY array.
|
|
101
|
+
Default: 200 (i.e. 200×200 = 40 000 points).
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
pts : (ny*nx, 2) array — flattened (x, y) positions, input to make_orientation_field
|
|
106
|
+
XX : (ny, nx) meshgrid of x-coordinates (use for reshaping field outputs)
|
|
107
|
+
YY : (ny, nx) meshgrid of y-coordinates (use for reshaping field outputs)
|
|
108
|
+
"""
|
|
109
|
+
if isinstance(grid_resolution, int):
|
|
110
|
+
ny = nx = grid_resolution
|
|
111
|
+
else:
|
|
112
|
+
ny, nx = grid_resolution
|
|
113
|
+
xs = np.linspace(1, w, nx, dtype=np.float64)
|
|
114
|
+
ys = np.linspace(1, h, ny, dtype=np.float64)
|
|
115
|
+
xx, yy = np.meshgrid(xs, ys)
|
|
116
|
+
pts = np.column_stack([xx.ravel(), yy.ravel()])
|
|
117
|
+
return pts, xx, yy
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def make_orientation_field(points, onset, offset, sigma=50.0,
|
|
121
|
+
grid_shape=None,
|
|
122
|
+
points_per_chunk=4096, segs_per_chunk=512):
|
|
123
|
+
"""
|
|
124
|
+
Compute the orientation field (ω̄, R, ρ) over all reference points.
|
|
125
|
+
|
|
126
|
+
For each reference point p_i, accumulates:
|
|
127
|
+
sin_i = Σ_j K(p_i,s_j) · sin(2ω_j) (unnormalized; proportional to paper's s̄_i · m_i)
|
|
128
|
+
cos_i = Σ_j K(p_i,s_j) · cos(2ω_j) (unnormalized; proportional to paper's c̄_i · m_i)
|
|
129
|
+
m_i = Σ_j K(p_i,s_j) (kernel mass)
|
|
130
|
+
|
|
131
|
+
Then derives:
|
|
132
|
+
ω̄_i = ½ · (atan2(sin_i, cos_i) mod 2π) ∈ [0, π) mean orientation
|
|
133
|
+
R_i = √(sin_i² + cos_i²) / m_i ∈ [0, 1] coherence (MRL)
|
|
134
|
+
ρ_i = m_i / Σ_k m_k ∈ [0, 1] density
|
|
135
|
+
|
|
136
|
+
Points with no kernel support (m_i = 0) get ω̄_i = NaN, R_i = 0, ρ_i = 0.
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
points : (P, 2) reference grid positions {p_i}
|
|
141
|
+
onset : (J, 2) saccade starting points {s_{j0}}
|
|
142
|
+
offset : (J, 2) saccade end points {s_{j1}}
|
|
143
|
+
sigma : float Gaussian bandwidth σ > 0, in pixels
|
|
144
|
+
grid_shape : (ny, nx) tuple — stored on the returned OrientationField for
|
|
145
|
+
convenient reshaping; pass yy.shape from make_reference_grid
|
|
146
|
+
points_per_chunk : int chunk size over grid points (memory/speed trade-off)
|
|
147
|
+
segs_per_chunk : int chunk size over saccades (memory/speed trade-off)
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
OrientationField with omega_mean, R, rho (each (P,)) and grid_shape
|
|
152
|
+
"""
|
|
153
|
+
n_pts = points.shape[0]
|
|
154
|
+
n_sac = onset.shape[0]
|
|
155
|
+
|
|
156
|
+
omega = _compute_orientations(onset, offset)
|
|
157
|
+
|
|
158
|
+
sin2 = np.sin(2.0 * omega) # (J,)
|
|
159
|
+
cos2 = np.cos(2.0 * omega) # (J,)
|
|
160
|
+
|
|
161
|
+
sin_acc = np.zeros(n_pts, dtype=np.float64)
|
|
162
|
+
cos_acc = np.zeros(n_pts, dtype=np.float64)
|
|
163
|
+
mass_acc = np.zeros(n_pts, dtype=np.float64)
|
|
164
|
+
|
|
165
|
+
for p0 in range(0, n_pts, points_per_chunk):
|
|
166
|
+
p1 = min(n_pts, p0 + points_per_chunk)
|
|
167
|
+
p_blk = points[p0:p1]
|
|
168
|
+
|
|
169
|
+
sin_blk = np.zeros(p1 - p0, dtype=np.float64)
|
|
170
|
+
cos_blk = np.zeros(p1 - p0, dtype=np.float64)
|
|
171
|
+
mass_blk = np.zeros(p1 - p0, dtype=np.float64)
|
|
172
|
+
|
|
173
|
+
for s0 in range(0, n_sac, segs_per_chunk):
|
|
174
|
+
s1 = min(n_sac, s0 + segs_per_chunk)
|
|
175
|
+
d_blk = _point_to_segment_distances(p_blk, onset[s0:s1], offset[s0:s1])
|
|
176
|
+
k_blk = _gaussian_kernel(d_blk, sigma)
|
|
177
|
+
|
|
178
|
+
mass_blk += k_blk.sum(axis=1)
|
|
179
|
+
sin_blk += k_blk @ sin2[s0:s1]
|
|
180
|
+
cos_blk += k_blk @ cos2[s0:s1]
|
|
181
|
+
|
|
182
|
+
sin_acc[p0:p1] = sin_blk
|
|
183
|
+
cos_acc[p0:p1] = cos_blk
|
|
184
|
+
mass_acc[p0:p1] = mass_blk
|
|
185
|
+
|
|
186
|
+
# ω̄_i = ½ · (atan2(sin_i, cos_i) mod 2π) — scale-invariant, so unnormalized sums work
|
|
187
|
+
omega_mean = 0.5 * np.mod(np.arctan2(sin_acc, cos_acc), 2.0 * np.pi)
|
|
188
|
+
omega_mean = np.where(mass_acc > 0.0, omega_mean, np.nan)
|
|
189
|
+
|
|
190
|
+
# R_i = √(s̄_i² + c̄_i²) where s̄_i = sin_i/m_i, c̄_i = cos_i/m_i
|
|
191
|
+
R = np.where(mass_acc > 0.0, np.sqrt(sin_acc**2 + cos_acc**2) / mass_acc, 0.0)
|
|
192
|
+
|
|
193
|
+
# ρ_i = m_i / Σ_k m_k
|
|
194
|
+
total_mass = mass_acc.sum()
|
|
195
|
+
rho = mass_acc / total_mass if total_mass > 0 else np.zeros_like(mass_acc)
|
|
196
|
+
|
|
197
|
+
return OrientationField(omega_mean=omega_mean, R=R, rho=rho, grid_shape=grid_shape)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
import matplotlib.cm as cm
|
|
4
|
+
from matplotlib.colors import ListedColormap, Normalize
|
|
5
|
+
from PIL import Image
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
Visualization
|
|
10
|
+
|
|
11
|
+
Implements the OOI-coded heatmap rendering pipeline.
|
|
12
|
+
|
|
13
|
+
Public API : Heatmap
|
|
14
|
+
Internal : _make_cyclic_colormap, _opacity_from_confidence, _load_grayscale
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _make_cyclic_colormap(base_cmap):
|
|
19
|
+
"""
|
|
20
|
+
Cyclic diverging colormap for unsigned orientation display.
|
|
21
|
+
|
|
22
|
+
Concatenates the forward and reverse passes of a diverging colormap so that
|
|
23
|
+
ω = 0 and ω = π map to the same color, creating a seamless cycle over [0, π).
|
|
24
|
+
"""
|
|
25
|
+
base = plt.get_cmap(base_cmap)
|
|
26
|
+
n = 256
|
|
27
|
+
forward = base(np.linspace(0, 1, n))
|
|
28
|
+
backward = base(np.linspace(1, 0, n))
|
|
29
|
+
colors = np.vstack([forward[:-1], backward[:-1]]) # avoid seam duplicate
|
|
30
|
+
return ListedColormap(colors, name=f"{base_cmap}_cyclic")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _opacity_from_confidence(confidence, degree=2.0, quantile=0.8, eps=1e-12):
|
|
34
|
+
"""
|
|
35
|
+
"Gentle nonlinearity" mapping a confidence signal to opacity ∈ [0, 1].
|
|
36
|
+
|
|
37
|
+
Uses a logistic function centered at the quantile-th percentile so that
|
|
38
|
+
low-confidence grid points fade toward transparency.
|
|
39
|
+
"""
|
|
40
|
+
cmin, cmax = float(np.min(confidence)), float(np.max(confidence))
|
|
41
|
+
if cmax <= cmin + eps:
|
|
42
|
+
return np.zeros_like(confidence)
|
|
43
|
+
c = (confidence - cmin) / (cmax - cmin)
|
|
44
|
+
center = np.quantile(c, quantile)
|
|
45
|
+
c = np.clip(c, eps, 1.0 - eps)
|
|
46
|
+
return 1.0 / (1.0 + (center * (1.0 - c) / ((1.0 - center) * c)) ** degree)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_grayscale(path):
|
|
50
|
+
"""
|
|
51
|
+
Load an image from path and return a float32 grayscale array in [0, 1].
|
|
52
|
+
|
|
53
|
+
Uses PIL for format detection, so the actual file format takes precedence
|
|
54
|
+
over the file extension (handles e.g. JPEG files named .png).
|
|
55
|
+
"""
|
|
56
|
+
img = Image.open(str(path)).convert('L') # 'L' = 8-bit grayscale
|
|
57
|
+
return np.asarray(img, dtype=np.float32) / 255.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Heatmap:
|
|
61
|
+
"""
|
|
62
|
+
OOI-coded heatmap renderer with two-level caching.
|
|
63
|
+
|
|
64
|
+
Wraps an OrientationField and caches intermediate results to avoid redundant
|
|
65
|
+
computation when only display parameters change:
|
|
66
|
+
|
|
67
|
+
- Background image → loaded and converted to grayscale once at construction.
|
|
68
|
+
- Cyclic colormap → rebuilt only when `cmap` changes.
|
|
69
|
+
- Opacity array → rebuilt only when `opacity_density_weight`, `degree`, or `quantile` changes.
|
|
70
|
+
- OOI rotation → always cheap (one np.mod call), never cached.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
field : OrientationField
|
|
75
|
+
Precomputed orientation field from make_orientation_field.
|
|
76
|
+
w, h : stimulus width and height in pixels
|
|
77
|
+
image : str, path-like, or None
|
|
78
|
+
Optional path to a stimulus image. When provided it is converted to
|
|
79
|
+
grayscale once and rendered as a background; the heatmap is composited
|
|
80
|
+
on top with opacity scaled by `base_opacity`.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, field, w, h, image=None):
|
|
84
|
+
self._field = field
|
|
85
|
+
self._w = w
|
|
86
|
+
self._h = h
|
|
87
|
+
|
|
88
|
+
self._image_gray = _load_grayscale(image) if image is not None else None
|
|
89
|
+
|
|
90
|
+
self._cmap_name = None
|
|
91
|
+
self._cyclic_cmap = None
|
|
92
|
+
|
|
93
|
+
self._opacity_key = None # (opacity_density_weight, degree, quantile)
|
|
94
|
+
self._opacity = None
|
|
95
|
+
|
|
96
|
+
def draw(self, ax, ooi=0.0, opacity_density_weight=1.0, base_opacity=0.85,
|
|
97
|
+
cmap='coolwarm', degree=2.0, quantile=0.8):
|
|
98
|
+
"""
|
|
99
|
+
Render the OOI-coded saccade heatmap onto a matplotlib Axes.
|
|
100
|
+
|
|
101
|
+
Hue encodes mean orientation ω̄_i relative to the chosen OOI.
|
|
102
|
+
Opacity encodes local confidence ρ^opacity_density_weight · R^(1−opacity_density_weight),
|
|
103
|
+
passed through a gentle nonlinearity and then scaled by base_opacity.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
ax : matplotlib Axes
|
|
108
|
+
ooi : float — Orientation of Interest in degrees, compass convention:
|
|
109
|
+
0=east, 90=north, 180=west, 270=south (default 0.0 = horizontal).
|
|
110
|
+
Orientations are unsigned so ooi and ooi±180 are equivalent.
|
|
111
|
+
opacity_density_weight : float in [0, 1] — blend between density and coherence for opacity:
|
|
112
|
+
1.0 → density only, 0.0 → coherence only. Recommended 0.5–1.0.
|
|
113
|
+
base_opacity : float in [0, 1] — global opacity ceiling applied on top of the
|
|
114
|
+
data-derived opacity; useful when a background image is shown.
|
|
115
|
+
The final per-point alpha = data_opacity × base_opacity.
|
|
116
|
+
cmap : str — base diverging colormap name (default 'coolwarm')
|
|
117
|
+
degree : float — sharpness of the opacity nonlinearity
|
|
118
|
+
quantile : float — center of the opacity nonlinearity
|
|
119
|
+
"""
|
|
120
|
+
# -- colormap cache (invalidated on cmap change) -----------------------
|
|
121
|
+
if cmap != self._cmap_name:
|
|
122
|
+
self._cyclic_cmap = _make_cyclic_colormap(cmap)
|
|
123
|
+
self._cmap_name = cmap
|
|
124
|
+
|
|
125
|
+
# -- opacity cache (invalidated on opacity_density_weight / degree / quantile change) --
|
|
126
|
+
opacity_key = (opacity_density_weight, degree, quantile)
|
|
127
|
+
if opacity_key != self._opacity_key:
|
|
128
|
+
confidence = self._field.rho ** opacity_density_weight * self._field.R ** (1.0 - opacity_density_weight)
|
|
129
|
+
self._opacity = _opacity_from_confidence(confidence, degree=degree, quantile=quantile)
|
|
130
|
+
self._opacity_key = opacity_key
|
|
131
|
+
|
|
132
|
+
# -- background image (grayscale) --------------------------------------
|
|
133
|
+
if self._image_gray is not None:
|
|
134
|
+
ax.imshow(self._image_gray, extent=(1, self._w, self._h, 1),
|
|
135
|
+
origin='upper', cmap='gray', vmin=0.0, vmax=1.0,
|
|
136
|
+
interpolation='bilinear')
|
|
137
|
+
|
|
138
|
+
# -- OOI rotation (always cheap) ---------------------------------------
|
|
139
|
+
omega_display = np.mod(self._field.omega_mean - np.deg2rad(ooi), math.pi)
|
|
140
|
+
|
|
141
|
+
# -- compose RGBA and render -------------------------------------------
|
|
142
|
+
norm = Normalize(vmin=0.0, vmax=math.pi)
|
|
143
|
+
sm = cm.ScalarMappable(norm=norm, cmap=self._cyclic_cmap)
|
|
144
|
+
|
|
145
|
+
shape = self._field.grid_shape
|
|
146
|
+
rgba = sm.to_rgba(omega_display.reshape(shape))
|
|
147
|
+
alpha = np.where(np.isnan(self._field.omega_mean.reshape(shape)),
|
|
148
|
+
0.0,
|
|
149
|
+
self._opacity.reshape(shape) * base_opacity)
|
|
150
|
+
rgba[..., -1] = alpha
|
|
151
|
+
|
|
152
|
+
ax.imshow(rgba, extent=(1, self._w, self._h, 1),
|
|
153
|
+
origin='upper', interpolation='gaussian')
|
|
154
|
+
ax.set_xlim(1, self._w)
|
|
155
|
+
ax.set_ylim(self._h, 1)
|
|
156
|
+
ax.set_xticks([])
|
|
157
|
+
ax.set_yticks([])
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
HeatMatch Similarity
|
|
6
|
+
|
|
7
|
+
Computes pairwise similarity scores between two saccade orientation fields.
|
|
8
|
+
|
|
9
|
+
- Locational similarity S_loc: Pearson correlation of density maps ρ, rescaled to [0, 1].
|
|
10
|
+
- Directional similarity S_dir: density- and coherence-weighted sum of per-point axial
|
|
11
|
+
angular similarities, using geometric-mean overlap weights, with a density-coherence
|
|
12
|
+
trade-off parameter ∈ [0, 1].
|
|
13
|
+
|
|
14
|
+
Public API : compute_similarity, SimilarityResult
|
|
15
|
+
Internal : _locational_similarity, _directional_similarity
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SimilarityResult:
|
|
21
|
+
"""
|
|
22
|
+
HeatMatch similarity scores between two saccade patterns.
|
|
23
|
+
|
|
24
|
+
Attributes
|
|
25
|
+
----------
|
|
26
|
+
s_loc : float in [0, 1] — locational similarity S_loc
|
|
27
|
+
s_dir : float in [0, 1] — directional similarity S_dir
|
|
28
|
+
"""
|
|
29
|
+
s_loc: float
|
|
30
|
+
s_dir: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _locational_similarity(rho_a, rho_b):
|
|
34
|
+
"""
|
|
35
|
+
S_loc(A, B) = (corr(ρ^A, ρ^B) + 1) / 2 ∈ [0, 1].
|
|
36
|
+
|
|
37
|
+
Pearson correlation of the two density maps, shifted to [0, 1].
|
|
38
|
+
S_loc = 1: identical spatial layout; S_loc = 0: maximally anti-correlated.
|
|
39
|
+
"""
|
|
40
|
+
corr = np.corrcoef(rho_a.ravel(), rho_b.ravel())[0, 1]
|
|
41
|
+
return float((corr + 1.0) / 2.0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _directional_similarity(omega_a, omega_b, R_a, R_b, rho_a, rho_b,
|
|
45
|
+
density_coherence_tradeoff=0.5):
|
|
46
|
+
"""
|
|
47
|
+
S_dir(A, B) = Σ_i w_i · axial-sim(ω̄_i^A, ω̄_i^B) / Σ_i w_i ∈ [0, 1].
|
|
48
|
+
|
|
49
|
+
where w_i = ρ̄_i^dct · R̄_i^(1−dct), dct = density_coherence_tradeoff.
|
|
50
|
+
|
|
51
|
+
ρ̄_i = √(ρ_i^A · ρ_i^B) and R̄_i = √(R_i^A · R_i^B) are the geometric-mean
|
|
52
|
+
overlap weights for density and coherence.
|
|
53
|
+
|
|
54
|
+
density_coherence_tradeoff=1 weights by density only — at this extreme S_dir
|
|
55
|
+
becomes statistically redundant with S_loc since angular coherence has no
|
|
56
|
+
influence on the score. Recommended value ~0.5.
|
|
57
|
+
|
|
58
|
+
axial-sim(ω^A, ω^B) = 1 − (2/π) · δ where δ = min(Δ, π−Δ), Δ = |ω^A − ω^B|.
|
|
59
|
+
|
|
60
|
+
Grid points with no kernel support have ω̄ = NaN and ρ = 0; their contribution
|
|
61
|
+
is zero via the ρ̄ weight. axial-sim is set to 0 for NaN inputs as a safety guard.
|
|
62
|
+
"""
|
|
63
|
+
dct = density_coherence_tradeoff
|
|
64
|
+
|
|
65
|
+
Delta = np.abs(omega_a - omega_b)
|
|
66
|
+
delta = np.minimum(Delta, np.pi - Delta)
|
|
67
|
+
axial_sim = 1.0 - (2.0 / np.pi) * delta
|
|
68
|
+
axial_sim = np.where(np.isnan(axial_sim), 0.0, axial_sim)
|
|
69
|
+
|
|
70
|
+
rho_bar = np.sqrt(rho_a * rho_b)
|
|
71
|
+
r_bar = np.sqrt(R_a * R_b)
|
|
72
|
+
w = rho_bar ** dct * r_bar ** (1.0 - dct)
|
|
73
|
+
W = float(w.sum())
|
|
74
|
+
return float(np.sum(w * axial_sim) / W) if W > 0.0 else 0.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compute_similarity(field_a, field_b, density_coherence_tradeoff=0.5):
|
|
78
|
+
"""
|
|
79
|
+
Compute HeatMatch similarity between two saccade patterns.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
field_a, field_b : OrientationField
|
|
84
|
+
Precomputed orientation fields from make_orientation_field.
|
|
85
|
+
density_coherence_tradeoff : float in [0, 1]
|
|
86
|
+
Controls how much S_dir weights grid points by density vs. coherence.
|
|
87
|
+
At 1.0 the weight is purely density-based and angular coherence has no influence,
|
|
88
|
+
making S_dir statistically redundant with S_loc. Recommended ~0.5.
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
SimilarityResult with s_loc and s_dir
|
|
93
|
+
"""
|
|
94
|
+
s_loc = _locational_similarity(field_a.rho, field_b.rho)
|
|
95
|
+
s_dir = _directional_similarity(field_a.omega_mean, field_b.omega_mean,
|
|
96
|
+
field_a.R, field_b.R,
|
|
97
|
+
field_a.rho, field_b.rho,
|
|
98
|
+
density_coherence_tradeoff=density_coherence_tradeoff)
|
|
99
|
+
return SimilarityResult(s_loc=s_loc, s_dir=s_dir)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: heatmatch
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Orientation-aware visualization and comparison of very dense saccade patterns (scanpath comparison for long free viewing)
|
|
5
|
+
Author: Xingyu Long
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/xysLong/HeatMatch
|
|
8
|
+
Project-URL: Paper, https://doi.org/10.1145/3803539
|
|
9
|
+
Project-URL: Data, https://osf.io/f2xhj/
|
|
10
|
+
Keywords: eye-tracking,saccades,scanpath,scanpath-comparison,gaze,heatmap,visualization,orientation-field,empirical-aesthetics,art-perception
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: numpy
|
|
20
|
+
Requires-Dist: matplotlib
|
|
21
|
+
Requires-Dist: Pillow
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# HeatMatch
|
|
25
|
+
|
|
26
|
+
**Visualize and compare very dense saccade patterns from long free-viewing eye-tracking data.**
|
|
27
|
+
|
|
28
|
+
HeatMatch turns hundreds or thousands of saccades into continuous *orientation fields*: at every location it estimates saccade density, mean orientation, and how consistently the saccades agree in orientation. From these fields it draws orientation-coded heatmaps that show *where* gaze moves and *in which direction*. It also computes a similarity score for comparing saccade patterns across participants, stimuli, or conditions, without AOIs, scanpath alignment, or manual segmentation.
|
|
29
|
+
|
|
30
|
+
It was developed for 60-second viewing of paintings (empirical aesthetics, art history) and works for any static stimulus. Python implementation accompanying the paper:
|
|
31
|
+
|
|
32
|
+
<table border="0" cellspacing="0" cellpadding="0"><tr>
|
|
33
|
+
<td width="72%" valign="top">
|
|
34
|
+
|
|
35
|
+
<blockquote>
|
|
36
|
+
Xingyu Long, Jozsef Arato, Sophia Kury, Anna Miscena, and Raphael Rosenberg. 2026.<br/>
|
|
37
|
+
<strong>HeatMatch: Orientation-Aware Visualization and Comparison of Very Dense Saccade Patterns.</strong><br/>
|
|
38
|
+
<em>Proceedings of the ACM on Computer Graphics and Interactive Techniques</em> (PACMCGIT), 9(2), Article 18.<br/>
|
|
39
|
+
<a href="https://doi.org/10.1145/3803539">https://doi.org/10.1145/3803539</a>
|
|
40
|
+
</blockquote>
|
|
41
|
+
|
|
42
|
+
</td>
|
|
43
|
+
<td width="28%" align="center" valign="middle">
|
|
44
|
+
<img src="https://raw.githubusercontent.com/xysLong/HeatMatch/main/assets/logos.png" width="200" alt="CReA Lab and Vienna Cognitive Science Hub"/>
|
|
45
|
+
</td>
|
|
46
|
+
</tr></table>
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Overview
|
|
51
|
+
|
|
52
|
+
1. **OOI-coded heatmaps** — orientation fields visualized with researcher-defined color anchors and confidence-weighted opacity.
|
|
53
|
+
2. **HeatMatch similarity** — comparison based on saccade density and orientation.
|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
<sub>Per-participant (columns 1–5) and aggregated (column 6) heatmaps for three paintings. Hue = mean saccade orientation relative to OOI; opacity = local confidence. Stimulus images: public domain via [Wikimedia Commons](https://commons.wikimedia.org) — see paper for details.</sub>
|
|
57
|
+
|
|
58
|
+
See the [paper](https://doi.org/10.1145/3803539) for methodology.
|
|
59
|
+
|
|
60
|
+
### When to use it
|
|
61
|
+
|
|
62
|
+
- **Long, dense viewing.** Scanpath comparison methods such as MultiMatch or ScanMatch align scanpaths sequence by sequence. With hundreds of saccades per trial and strongly diverging paths, they become slow and hard to interpret. HeatMatch aggregates instead of aligning, so it scales to arbitrarily dense data.
|
|
63
|
+
- **Orientation matters.** Fixation heatmaps and density-based metrics ignore saccade direction. HeatMatch keeps orientation, so you can ask whether gaze follows horizontal, vertical, diagonal, or symmetric structure in an image, e.g. composition lines in a painting.
|
|
64
|
+
- **Group, stimulus, and individual comparisons.** In the paper, the similarity score recovers the abstract vs. still-life distinction and differences in within-painting agreement. It also reveals stable individual "saccadic signatures" that hold across paintings.
|
|
65
|
+
|
|
66
|
+
It is **not** a replacement for sequence-based methods. HeatMatch is permutation-invariant (it ignores saccade order) and treats orientations as unsigned (left-to-right = right-to-left). Use it alongside MultiMatch, ScanMatch, or RQA when order or direction matters.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install heatmatch
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Dependencies: `numpy`, `matplotlib`, `Pillow`. For development, clone the repository and run `pip install -e .`.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Quick Start
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import matplotlib.pyplot as plt
|
|
84
|
+
from heatmatch import make_reference_grid, make_orientation_field, Heatmap, compute_similarity
|
|
85
|
+
|
|
86
|
+
W, H = 2880, 2160
|
|
87
|
+
onset = ... # (J, 2) saccade start coordinates
|
|
88
|
+
offset = ... # (J, 2) saccade end coordinates
|
|
89
|
+
|
|
90
|
+
pts, xx, yy = make_reference_grid(W, H, grid_resolution=200)
|
|
91
|
+
field = make_orientation_field(pts, onset, offset, sigma=50.0, grid_shape=yy.shape)
|
|
92
|
+
|
|
93
|
+
fig, ax = plt.subplots()
|
|
94
|
+
Heatmap(field, W, H).draw(ax, ooi=0.0) # ooi=0 → horizontal, ooi=90 → vertical
|
|
95
|
+
plt.show()
|
|
96
|
+
|
|
97
|
+
# Compare two patterns (fields built on the same grid, e.g. two participants)
|
|
98
|
+
result = compute_similarity(field_a, field_b)
|
|
99
|
+
s = (result.s_loc + result.s_dir) / 2 # composite HeatMatch score S in [0, 1]
|
|
100
|
+
print(result.s_loc, result.s_dir, s)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A full worked example is in [`demo.ipynb`](https://github.com/xysLong/HeatMatch/blob/main/demo.ipynb). The dataset is not included — download `data_anonymized.csv` from **[osf.io/f2xhj](https://osf.io/f2xhj/)** and place it in `tests/`.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## API
|
|
108
|
+
|
|
109
|
+
### `heatmatch.fields`
|
|
110
|
+
|
|
111
|
+
| | |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `make_reference_grid(w, h, grid_resolution)` | Returns `(pts, xx, yy)`; int → square grid, `(ny, nx)` tuple → rectangular |
|
|
114
|
+
| `make_orientation_field(pts, onset, offset, sigma, grid_shape, ...)` | Returns `OrientationField` with `omega_mean`, `R`, `rho` |
|
|
115
|
+
|
|
116
|
+
### `heatmatch.heatmapping`
|
|
117
|
+
|
|
118
|
+
| | |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `Heatmap(field, w, h, image=None)` | Caches colormap, opacity, and (optionally) grayscale background image |
|
|
121
|
+
| `Heatmap.draw(ax, ooi, opacity_density_weight, base_opacity, cmap, ...)` | Renders onto `ax` |
|
|
122
|
+
|
|
123
|
+
OOI in degrees: 0 = east, 90 = north, 180 = west, 270 = south. Unsigned, so `ooi` and `ooi ± 180` are equivalent.
|
|
124
|
+
|
|
125
|
+
### `heatmatch.matching`
|
|
126
|
+
|
|
127
|
+
| | |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `compute_similarity(field_a, field_b, density_coherence_tradeoff)` | Returns `SimilarityResult(s_loc, s_dir)` |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Key Parameters
|
|
134
|
+
|
|
135
|
+
| Parameter | Default | Notes |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| `sigma` | `50.0` | Gaussian bandwidth in px. Paper evaluates {50, 100, 150}. |
|
|
138
|
+
| `grid_resolution` | `200` | Int or `(ny, nx)`. Paper uses 200. |
|
|
139
|
+
| `ooi` | `0.0` | Orientation of Interest in degrees. |
|
|
140
|
+
| `opacity_density_weight` | `1.0` | Density vs. coherence for opacity. Recommended 0.5–1.0. |
|
|
141
|
+
| `base_opacity` | `0.85` | Global opacity ceiling; useful with a background image. |
|
|
142
|
+
| `density_coherence_tradeoff` | `0.5` | Density vs. coherence for *S*_dir. At 1.0, angular information is discarded. |
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Citation
|
|
147
|
+
|
|
148
|
+
```bibtex
|
|
149
|
+
@article{long2026heatmatch,
|
|
150
|
+
author = {Long, Xingyu and Arato, Jozsef and Kury, Sophia and Miscena, Anna and Rosenberg, Raphael},
|
|
151
|
+
title = {HeatMatch: Orientation-Aware Visualization and Comparison of Very Dense Saccade Patterns},
|
|
152
|
+
journal = {Proc. ACM Comput. Graph. Interact. Tech.},
|
|
153
|
+
year = {2026},
|
|
154
|
+
volume = {9},
|
|
155
|
+
number = {2},
|
|
156
|
+
articleno = {18},
|
|
157
|
+
doi = {10.1145/3803539},
|
|
158
|
+
}
|
|
159
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
heatmatch/__init__.py
|
|
5
|
+
heatmatch/fields.py
|
|
6
|
+
heatmatch/heatmapping.py
|
|
7
|
+
heatmatch/matching.py
|
|
8
|
+
heatmatch.egg-info/PKG-INFO
|
|
9
|
+
heatmatch.egg-info/SOURCES.txt
|
|
10
|
+
heatmatch.egg-info/dependency_links.txt
|
|
11
|
+
heatmatch.egg-info/requires.txt
|
|
12
|
+
heatmatch.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
heatmatch
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[tool.setuptools]
|
|
6
|
+
packages = ["heatmatch"]
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "heatmatch"
|
|
10
|
+
version = "1.0.0"
|
|
11
|
+
description = "Orientation-aware visualization and comparison of very dense saccade patterns (scanpath comparison for long free viewing)"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = "MIT"
|
|
14
|
+
authors = [{name = "Xingyu Long"}]
|
|
15
|
+
requires-python = ">=3.9"
|
|
16
|
+
dependencies = [
|
|
17
|
+
"numpy",
|
|
18
|
+
"matplotlib",
|
|
19
|
+
"Pillow",
|
|
20
|
+
]
|
|
21
|
+
keywords = [
|
|
22
|
+
"eye-tracking", "saccades", "scanpath", "scanpath-comparison",
|
|
23
|
+
"gaze", "heatmap", "visualization", "orientation-field",
|
|
24
|
+
"empirical-aesthetics", "art-perception",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Intended Audience :: Science/Research",
|
|
30
|
+
"Topic :: Scientific/Engineering :: Visualization",
|
|
31
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/xysLong/HeatMatch"
|
|
36
|
+
Paper = "https://doi.org/10.1145/3803539"
|
|
37
|
+
Data = "https://osf.io/f2xhj/"
|