eyelogic-recorder 0.2.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.
- eyelogic_recorder-0.2.0/LICENSE +21 -0
- eyelogic_recorder-0.2.0/PKG-INFO +277 -0
- eyelogic_recorder-0.2.0/README.md +252 -0
- eyelogic_recorder-0.2.0/pyproject.toml +40 -0
- eyelogic_recorder-0.2.0/setup.cfg +4 -0
- eyelogic_recorder-0.2.0/src/eyelogic_recorder/__init__.py +44 -0
- eyelogic_recorder-0.2.0/src/eyelogic_recorder/_sdk.py +37 -0
- eyelogic_recorder-0.2.0/src/eyelogic_recorder/client.py +673 -0
- eyelogic_recorder-0.2.0/src/eyelogic_recorder/exceptions.py +66 -0
- eyelogic_recorder-0.2.0/src/eyelogic_recorder/io.py +118 -0
- eyelogic_recorder-0.2.0/src/eyelogic_recorder/simulator.py +319 -0
- eyelogic_recorder-0.2.0/src/eyelogic_recorder.egg-info/PKG-INFO +277 -0
- eyelogic_recorder-0.2.0/src/eyelogic_recorder.egg-info/SOURCES.txt +19 -0
- eyelogic_recorder-0.2.0/src/eyelogic_recorder.egg-info/dependency_links.txt +1 -0
- eyelogic_recorder-0.2.0/src/eyelogic_recorder.egg-info/requires.txt +10 -0
- eyelogic_recorder-0.2.0/src/eyelogic_recorder.egg-info/top_level.txt +1 -0
- eyelogic_recorder-0.2.0/tests/test_calibration.py +74 -0
- eyelogic_recorder-0.2.0/tests/test_client.py +260 -0
- eyelogic_recorder-0.2.0/tests/test_eye_checks.py +89 -0
- eyelogic_recorder-0.2.0/tests/test_recording_core.py +185 -0
- eyelogic_recorder-0.2.0/tests/test_simulator.py +45 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 Jochen Laubrock
|
|
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.
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eyelogic-recorder
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Lightweight EyeLogic eye-tracker client with asynchronous recording to CSV that never blocks the SDK callback
|
|
5
|
+
Author: Jochen Laubrock
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/laubrock-lab/eyelogic-recorder
|
|
8
|
+
Project-URL: Issues, https://github.com/laubrock-lab/eyelogic-recorder/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/laubrock-lab/eyelogic-recorder/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: eye tracking,eye-tracking,EyeLogic,PsychoPy,reading,gaze-contingent,psychology
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: numpy
|
|
19
|
+
Requires-Dist: eyelogic-sdk==1.2.4; sys_platform == "win32"
|
|
20
|
+
Provides-Extra: psychopy
|
|
21
|
+
Requires-Dist: psychopy; extra == "psychopy"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# eyelogic-recorder
|
|
27
|
+
|
|
28
|
+
[](https://doi.org/10.5281/zenodo.23537087)
|
|
29
|
+
|
|
30
|
+
A lightweight Python client for [EyeLogic](https://www.eyelogicsolutions.com/)
|
|
31
|
+
eye trackers, built on top of the official EyeLogic SDK. It adds what the SDK does not provide: asynchronous
|
|
32
|
+
recording of every gaze sample to plain CSV, timestamped messages (for example
|
|
33
|
+
`TRIALID 3` or `TRIAL_END`) in a separate TSV file that shares the clock of the
|
|
34
|
+
sample file, typed exceptions, and context-manager cleanup. It works well with
|
|
35
|
+
PsychoPy but does not require it. The only Python dependency is NumPy (plus the
|
|
36
|
+
Windows-only EyeLogic SDK).
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
*`examples/gaze_contingent.py` in reading mode: outside seven characters to
|
|
41
|
+
either side of the fixated letter (red dot), letters are replaced by `x`.*
|
|
42
|
+
|
|
43
|
+
## Status
|
|
44
|
+
|
|
45
|
+
First public release (0.2.0). The recording code was reviewed by EyeLogic and
|
|
46
|
+
tested on their hardware. Feedback and issues welcome.
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
pip install eyelogic-recorder
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
On Windows this also installs the EyeLogic SDK (`eyelogic-sdk`), pinned to the
|
|
55
|
+
version eyelogic-recorder is tested with (1.2.4). The installed EyeLogic server must be at
|
|
56
|
+
least as new as the SDK (1.2.4 or later); EyeLogic keeps newer servers
|
|
57
|
+
compatible with older SDKs. SDK 1.2.x no longer bundles the MSVC runtime, so
|
|
58
|
+
you may need the Microsoft Visual C++ Redistributable.
|
|
59
|
+
|
|
60
|
+
Reading recorded files (`load_samples`, `load_messages`) works on any
|
|
61
|
+
platform; recording requires Windows and the SDK.
|
|
62
|
+
|
|
63
|
+
The development version installs from GitHub:
|
|
64
|
+
`pip install git+https://github.com/laubrock-lab/eyelogic-recorder`.
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from eyelogic_recorder import EyeLogicClient, EyeLogicError
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
with EyeLogicClient("my_experiment", data_folder="data") as et:
|
|
73
|
+
et.connect()
|
|
74
|
+
et.find_tracker()
|
|
75
|
+
et.start_tracking()
|
|
76
|
+
et.calibrate()
|
|
77
|
+
et.validate()
|
|
78
|
+
for trial in trials:
|
|
79
|
+
et.start_recording()
|
|
80
|
+
et.send_message(f"TRIALID {trial.id}")
|
|
81
|
+
... # present the stimulus
|
|
82
|
+
et.send_message("TRIAL_END")
|
|
83
|
+
et.stop_recording()
|
|
84
|
+
et.check_health()
|
|
85
|
+
except EyeLogicError as err:
|
|
86
|
+
print("Eye tracker problem:", err)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Leaving the `with` block (or calling `close()`) stops recording and tracking,
|
|
90
|
+
disconnects, and closes the files.
|
|
91
|
+
|
|
92
|
+
Right after `calibrate()` the EyeLogic server is briefly busy (a known server
|
|
93
|
+
bug, confirmed by EyeLogic), so a `validate()` started immediately would fail
|
|
94
|
+
with `ALREADY_BUSY`. `calibrate()` and `validate()` therefore retry for up to
|
|
95
|
+
`busy_timeout` seconds (default 5); no wait is needed in your own code.
|
|
96
|
+
`calibrate()` also waits up to `eye_timeout` seconds (default 5) for a detected
|
|
97
|
+
eye and raises `NoEyeDetectedError` if there is none.
|
|
98
|
+
|
|
99
|
+
A runnable version without PsychoPy is in
|
|
100
|
+
[`examples/minimal.py`](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/examples/minimal.py) (`python examples/minimal.py --help`).
|
|
101
|
+
|
|
102
|
+
Examples (all accept `--help`; the PsychoPy ones also `--simulate --windowed`
|
|
103
|
+
to run without a tracker, with the mouse as the eye):
|
|
104
|
+
|
|
105
|
+
- [`examples/minimal.py`](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/examples/minimal.py): recording without PsychoPy.
|
|
106
|
+
- [`examples/psychopy_free_viewing.py`](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/examples/psychopy_free_viewing.py): free viewing of images.
|
|
107
|
+
- [`examples/paragraph_reading.py`](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/examples/paragraph_reading.py): paragraph reading with a
|
|
108
|
+
comprehension question; word AOIs derived from TextBox2 glyph vertices and logged as
|
|
109
|
+
messages; own texts via `--texts` CSV. The built-in texts are the openings of two
|
|
110
|
+
Grimm fairy tales (1857 edition, public domain).
|
|
111
|
+
- [`examples/gaze_contingent.py`](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/examples/gaze_contingent.py): gaze marker plus a moving window, for
|
|
112
|
+
reading (`--mode text`, ±N characters on the fixated line) or scene viewing (`--mode scene`,
|
|
113
|
+
circular window).
|
|
114
|
+
- [`examples/boundary.py`](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/examples/boundary.py): boundary paradigm for reading, with a fixation check,
|
|
115
|
+
the changed sentence pre-rendered so the display change is a pure buffer swap, and
|
|
116
|
+
word AOIs logged as messages (based on an implementation by Philipp Freiherr von Entreß-Fürsteneck).
|
|
117
|
+
|
|
118
|
+
## Running without a tracker
|
|
119
|
+
|
|
120
|
+
`eyelogic_recorder.simulator` replaces the EyeLogic SDK with a simulated tracker that
|
|
121
|
+
delivers samples at the device frame rate (synthetic fixations, saccades and
|
|
122
|
+
blinks, or any position source such as the mouse). It works on any OS:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from eyelogic_recorder import EyeLogicClient, simulator
|
|
126
|
+
|
|
127
|
+
simulator.install() # before creating the client
|
|
128
|
+
with EyeLogicClient("dry_run") as et:
|
|
129
|
+
...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The same simulator drives the test suite and `benchmarks/synthetic_throughput.py`.
|
|
133
|
+
|
|
134
|
+
## Output files
|
|
135
|
+
|
|
136
|
+
Each client creates two files in `data_folder`, named
|
|
137
|
+
`<experiment>_<YYYYmmdd_HHMMSS>.csv` and `.tsv`. Existing files are never
|
|
138
|
+
overwritten (a numeric suffix is added if needed).
|
|
139
|
+
|
|
140
|
+
### Samples (`.csv`)
|
|
141
|
+
|
|
142
|
+
One row per gaze sample. Invalid samples (eye not tracked) are kept, with `NA`
|
|
143
|
+
in the gaze and eye columns.
|
|
144
|
+
|
|
145
|
+
| Column | Meaning |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `ETTime` | Timestamp from the SDK (`timestampMicroSec`), microseconds since the epoch on the host clock |
|
|
148
|
+
| `Index` | SDK sample index (gaps mean samples the SDK did not deliver) |
|
|
149
|
+
| `x`, `y` | Binocular raw point of regard, screen pixels |
|
|
150
|
+
| `x.filtered`, `y.filtered` | Binocular filtered point of regard, screen pixels |
|
|
151
|
+
| `x.left`, `y.left` | Left-eye point of regard, screen pixels |
|
|
152
|
+
| `eyePositionLeftX/Y/Z` | Left-eye position in mm, device coordinates |
|
|
153
|
+
| `pr.left` | Left pupil radius in mm |
|
|
154
|
+
| `x.right`, `y.right` | Right-eye point of regard, screen pixels |
|
|
155
|
+
| `eyePositionRightX/Y/Z` | Right-eye position in mm, device coordinates |
|
|
156
|
+
| `pr.right` | Right pupil radius in mm |
|
|
157
|
+
| `SysTime` | Host `time.time()` (seconds) when the sample arrived in Python; same clock as the message file |
|
|
158
|
+
|
|
159
|
+
Use `SysTime` to align samples with messages. `SysTime − ETTime` is not a
|
|
160
|
+
reliable per-sample latency; for measuring gaze-contingent latency see
|
|
161
|
+
[benchmarks/gaze_contingent_latency.md](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/benchmarks/gaze_contingent_latency.md).
|
|
162
|
+
|
|
163
|
+
Validity is per vector (EyeLogic): each eye's point of regard (`x.left`/`y.left`,
|
|
164
|
+
`x.right`/`y.right`), eye position and pupil can be `NA` independently. The
|
|
165
|
+
binocular point of regard (`x`/`y`, `x.filtered`/`y.filtered`) is valid when at
|
|
166
|
+
least one eye's is. Points of regard need a calibration; eye positions and
|
|
167
|
+
pupils only need a found eye, so before calibration they are recorded while the
|
|
168
|
+
gaze columns are `NA`.
|
|
169
|
+
|
|
170
|
+
### Messages (`.tsv`)
|
|
171
|
+
|
|
172
|
+
Tab-separated with columns `SysTime`, `Type`, `Text`. `Type` is `MSG` for your
|
|
173
|
+
own messages (`send_message`), `EVENT` for device events reported by the SDK,
|
|
174
|
+
`ERROR` for failures (with the reason, e.g. `Validation failed: ReturnValidate.ALREADY_BUSY`),
|
|
175
|
+
`WARNING` for data-quality problems (e.g. a recording without any detected eye),
|
|
176
|
+
and `INFO` for client status lines (connect, calibration, validation, recording
|
|
177
|
+
start/stop). `SysTime` is on the same clock as the `SysTime` column of the
|
|
178
|
+
sample file, so messages can be aligned with samples.
|
|
179
|
+
|
|
180
|
+
## Reading the data
|
|
181
|
+
|
|
182
|
+
Python:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
import eyelogic_recorder
|
|
186
|
+
|
|
187
|
+
samples = eyelogic_recorder.load_samples("data/my_experiment_20260101_120000.csv") # structured array, NA -> NaN
|
|
188
|
+
messages = eyelogic_recorder.load_messages("data/my_experiment_20260101_120000.tsv") # list of Message(sys_time, type, text)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
R:
|
|
192
|
+
|
|
193
|
+
```r
|
|
194
|
+
samples <- read.csv("data/my_experiment_20260101_120000.csv") # NA is read as missing by default
|
|
195
|
+
messages <- read.delim("data/my_experiment_20260101_120000.tsv")
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Gaze-contingent use
|
|
199
|
+
|
|
200
|
+
`get_position()` returns the latest filtered gaze position (or `None` if the
|
|
201
|
+
last sample was invalid) whenever tracking runs, also between recordings.
|
|
202
|
+
`get_position(as_array=True)` returns a NumPy array; `last_sample` gives the
|
|
203
|
+
full SDK sample (every sample, also when some of its fields are invalid).
|
|
204
|
+
|
|
205
|
+
## Error model
|
|
206
|
+
|
|
207
|
+
All errors derive from `EyeLogicError`:
|
|
208
|
+
|
|
209
|
+
- `ConnectError`, `DeviceNotFoundError`, `StartTrackingError`,
|
|
210
|
+
`CalibrationError`, `ValidationError`: raised by the corresponding call.
|
|
211
|
+
- `ConnectionClosedError`, `DeviceDisconnectedError`, `TrackingStoppedError`:
|
|
212
|
+
the server connection, device, or tracking was lost unexpectedly.
|
|
213
|
+
- `WriterError`: the background writer thread failed (for example disk full).
|
|
214
|
+
- `RecordingOverflowError`: samples were dropped because the writer fell behind
|
|
215
|
+
by more than the buffer pool (`samples_dropped`).
|
|
216
|
+
- `MessageWriteError`: a message (e.g. a trial marker) could not be written.
|
|
217
|
+
- `NoEyeDetectedError`: `calibrate()` saw no eye within `eye_timeout` seconds
|
|
218
|
+
(participant not in view, or a frame rate the device offers but does not
|
|
219
|
+
support: according to EyeLogic only the LogicOne supports 250 Hz, but an
|
|
220
|
+
EyeLogic Lite may still offer it).
|
|
221
|
+
|
|
222
|
+
A `ValidationError` is also raised when validation finds no eye at any target.
|
|
223
|
+
After each recording, the message file gets the share of samples with a
|
|
224
|
+
detected eye; if there were none (or no samples at all), a `WARNING` line is
|
|
225
|
+
written and an `EyeLogicWarning` is issued.
|
|
226
|
+
|
|
227
|
+
Failures that occur on SDK or writer threads cannot be raised where they
|
|
228
|
+
happen. They are stored and raised by `check_health()` and by the methods that
|
|
229
|
+
call it (`connect`, `start_recording`, `get_position`, ...). Call
|
|
230
|
+
`check_health()` regularly, for example once per trial.
|
|
231
|
+
|
|
232
|
+
## Running tests
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
pip install -e .[dev]
|
|
236
|
+
pytest
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The tests use a fake SDK and run on any OS.
|
|
240
|
+
|
|
241
|
+
## Design
|
|
242
|
+
|
|
243
|
+
Gaze samples are copied by the SDK callback into preallocated NumPy chunks;
|
|
244
|
+
full chunks go to a writer thread, so disk I/O never delays the SDK callback
|
|
245
|
+
(the EyeLogic server drops samples when the callback is slow). The chunk pool
|
|
246
|
+
buffers 40 s at 250 Hz by default; if the disk stalls for longer, samples are
|
|
247
|
+
dropped, counted (`samples_dropped`) and reported by `check_health()` as
|
|
248
|
+
`RecordingOverflowError` instead of being lost silently. See
|
|
249
|
+
[docs/recording_pipeline.md](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/docs/recording_pipeline.md) for details.
|
|
250
|
+
|
|
251
|
+
## Contributors
|
|
252
|
+
|
|
253
|
+
- Philipp Freiherr von Entreß-Fürsteneck: the boundary-paradigm example
|
|
254
|
+
(`examples/boundary.py`), adapted from Philipp's original implementation, and
|
|
255
|
+
the high-DPI coordinate conversion used in all PsychoPy examples.
|
|
256
|
+
- Aynur Pelin Çelikkol: the paragraph-reading example
|
|
257
|
+
(`examples/paragraph_reading.py`), which follows the design of Pelin's
|
|
258
|
+
reading-comprehension task (trial flow, text display with TextBox2).
|
|
259
|
+
- Niklas Rosin and Joline Flatau: testing on EyeLogic hardware.
|
|
260
|
+
|
|
261
|
+
## Acknowledgements
|
|
262
|
+
|
|
263
|
+
We thank Matthias Nieser and Thomas Jablonski of
|
|
264
|
+
[EyeLogic](https://www.eyelogicsolutions.com/) for reviewing the recording code
|
|
265
|
+
and testing it on EyeLogic hardware. eyelogic-recorder is developed
|
|
266
|
+
independently and is not an official EyeLogic product.
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
MIT, see [LICENSE](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/LICENSE).
|
|
271
|
+
|
|
272
|
+
## Citation
|
|
273
|
+
|
|
274
|
+
Please cite the software via its DOI,
|
|
275
|
+
https://doi.org/10.5281/zenodo.23537087, which resolves to the latest version
|
|
276
|
+
(each version also has its own DOI on Zenodo). Details are in
|
|
277
|
+
[CITATION.cff](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/CITATION.cff).
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# eyelogic-recorder
|
|
2
|
+
|
|
3
|
+
[](https://doi.org/10.5281/zenodo.23537087)
|
|
4
|
+
|
|
5
|
+
A lightweight Python client for [EyeLogic](https://www.eyelogicsolutions.com/)
|
|
6
|
+
eye trackers, built on top of the official EyeLogic SDK. It adds what the SDK does not provide: asynchronous
|
|
7
|
+
recording of every gaze sample to plain CSV, timestamped messages (for example
|
|
8
|
+
`TRIALID 3` or `TRIAL_END`) in a separate TSV file that shares the clock of the
|
|
9
|
+
sample file, typed exceptions, and context-manager cleanup. It works well with
|
|
10
|
+
PsychoPy but does not require it. The only Python dependency is NumPy (plus the
|
|
11
|
+
Windows-only EyeLogic SDK).
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
*`examples/gaze_contingent.py` in reading mode: outside seven characters to
|
|
16
|
+
either side of the fixated letter (red dot), letters are replaced by `x`.*
|
|
17
|
+
|
|
18
|
+
## Status
|
|
19
|
+
|
|
20
|
+
First public release (0.2.0). The recording code was reviewed by EyeLogic and
|
|
21
|
+
tested on their hardware. Feedback and issues welcome.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
pip install eyelogic-recorder
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
On Windows this also installs the EyeLogic SDK (`eyelogic-sdk`), pinned to the
|
|
30
|
+
version eyelogic-recorder is tested with (1.2.4). The installed EyeLogic server must be at
|
|
31
|
+
least as new as the SDK (1.2.4 or later); EyeLogic keeps newer servers
|
|
32
|
+
compatible with older SDKs. SDK 1.2.x no longer bundles the MSVC runtime, so
|
|
33
|
+
you may need the Microsoft Visual C++ Redistributable.
|
|
34
|
+
|
|
35
|
+
Reading recorded files (`load_samples`, `load_messages`) works on any
|
|
36
|
+
platform; recording requires Windows and the SDK.
|
|
37
|
+
|
|
38
|
+
The development version installs from GitHub:
|
|
39
|
+
`pip install git+https://github.com/laubrock-lab/eyelogic-recorder`.
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from eyelogic_recorder import EyeLogicClient, EyeLogicError
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
with EyeLogicClient("my_experiment", data_folder="data") as et:
|
|
48
|
+
et.connect()
|
|
49
|
+
et.find_tracker()
|
|
50
|
+
et.start_tracking()
|
|
51
|
+
et.calibrate()
|
|
52
|
+
et.validate()
|
|
53
|
+
for trial in trials:
|
|
54
|
+
et.start_recording()
|
|
55
|
+
et.send_message(f"TRIALID {trial.id}")
|
|
56
|
+
... # present the stimulus
|
|
57
|
+
et.send_message("TRIAL_END")
|
|
58
|
+
et.stop_recording()
|
|
59
|
+
et.check_health()
|
|
60
|
+
except EyeLogicError as err:
|
|
61
|
+
print("Eye tracker problem:", err)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Leaving the `with` block (or calling `close()`) stops recording and tracking,
|
|
65
|
+
disconnects, and closes the files.
|
|
66
|
+
|
|
67
|
+
Right after `calibrate()` the EyeLogic server is briefly busy (a known server
|
|
68
|
+
bug, confirmed by EyeLogic), so a `validate()` started immediately would fail
|
|
69
|
+
with `ALREADY_BUSY`. `calibrate()` and `validate()` therefore retry for up to
|
|
70
|
+
`busy_timeout` seconds (default 5); no wait is needed in your own code.
|
|
71
|
+
`calibrate()` also waits up to `eye_timeout` seconds (default 5) for a detected
|
|
72
|
+
eye and raises `NoEyeDetectedError` if there is none.
|
|
73
|
+
|
|
74
|
+
A runnable version without PsychoPy is in
|
|
75
|
+
[`examples/minimal.py`](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/examples/minimal.py) (`python examples/minimal.py --help`).
|
|
76
|
+
|
|
77
|
+
Examples (all accept `--help`; the PsychoPy ones also `--simulate --windowed`
|
|
78
|
+
to run without a tracker, with the mouse as the eye):
|
|
79
|
+
|
|
80
|
+
- [`examples/minimal.py`](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/examples/minimal.py): recording without PsychoPy.
|
|
81
|
+
- [`examples/psychopy_free_viewing.py`](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/examples/psychopy_free_viewing.py): free viewing of images.
|
|
82
|
+
- [`examples/paragraph_reading.py`](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/examples/paragraph_reading.py): paragraph reading with a
|
|
83
|
+
comprehension question; word AOIs derived from TextBox2 glyph vertices and logged as
|
|
84
|
+
messages; own texts via `--texts` CSV. The built-in texts are the openings of two
|
|
85
|
+
Grimm fairy tales (1857 edition, public domain).
|
|
86
|
+
- [`examples/gaze_contingent.py`](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/examples/gaze_contingent.py): gaze marker plus a moving window, for
|
|
87
|
+
reading (`--mode text`, ±N characters on the fixated line) or scene viewing (`--mode scene`,
|
|
88
|
+
circular window).
|
|
89
|
+
- [`examples/boundary.py`](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/examples/boundary.py): boundary paradigm for reading, with a fixation check,
|
|
90
|
+
the changed sentence pre-rendered so the display change is a pure buffer swap, and
|
|
91
|
+
word AOIs logged as messages (based on an implementation by Philipp Freiherr von Entreß-Fürsteneck).
|
|
92
|
+
|
|
93
|
+
## Running without a tracker
|
|
94
|
+
|
|
95
|
+
`eyelogic_recorder.simulator` replaces the EyeLogic SDK with a simulated tracker that
|
|
96
|
+
delivers samples at the device frame rate (synthetic fixations, saccades and
|
|
97
|
+
blinks, or any position source such as the mouse). It works on any OS:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from eyelogic_recorder import EyeLogicClient, simulator
|
|
101
|
+
|
|
102
|
+
simulator.install() # before creating the client
|
|
103
|
+
with EyeLogicClient("dry_run") as et:
|
|
104
|
+
...
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The same simulator drives the test suite and `benchmarks/synthetic_throughput.py`.
|
|
108
|
+
|
|
109
|
+
## Output files
|
|
110
|
+
|
|
111
|
+
Each client creates two files in `data_folder`, named
|
|
112
|
+
`<experiment>_<YYYYmmdd_HHMMSS>.csv` and `.tsv`. Existing files are never
|
|
113
|
+
overwritten (a numeric suffix is added if needed).
|
|
114
|
+
|
|
115
|
+
### Samples (`.csv`)
|
|
116
|
+
|
|
117
|
+
One row per gaze sample. Invalid samples (eye not tracked) are kept, with `NA`
|
|
118
|
+
in the gaze and eye columns.
|
|
119
|
+
|
|
120
|
+
| Column | Meaning |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `ETTime` | Timestamp from the SDK (`timestampMicroSec`), microseconds since the epoch on the host clock |
|
|
123
|
+
| `Index` | SDK sample index (gaps mean samples the SDK did not deliver) |
|
|
124
|
+
| `x`, `y` | Binocular raw point of regard, screen pixels |
|
|
125
|
+
| `x.filtered`, `y.filtered` | Binocular filtered point of regard, screen pixels |
|
|
126
|
+
| `x.left`, `y.left` | Left-eye point of regard, screen pixels |
|
|
127
|
+
| `eyePositionLeftX/Y/Z` | Left-eye position in mm, device coordinates |
|
|
128
|
+
| `pr.left` | Left pupil radius in mm |
|
|
129
|
+
| `x.right`, `y.right` | Right-eye point of regard, screen pixels |
|
|
130
|
+
| `eyePositionRightX/Y/Z` | Right-eye position in mm, device coordinates |
|
|
131
|
+
| `pr.right` | Right pupil radius in mm |
|
|
132
|
+
| `SysTime` | Host `time.time()` (seconds) when the sample arrived in Python; same clock as the message file |
|
|
133
|
+
|
|
134
|
+
Use `SysTime` to align samples with messages. `SysTime − ETTime` is not a
|
|
135
|
+
reliable per-sample latency; for measuring gaze-contingent latency see
|
|
136
|
+
[benchmarks/gaze_contingent_latency.md](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/benchmarks/gaze_contingent_latency.md).
|
|
137
|
+
|
|
138
|
+
Validity is per vector (EyeLogic): each eye's point of regard (`x.left`/`y.left`,
|
|
139
|
+
`x.right`/`y.right`), eye position and pupil can be `NA` independently. The
|
|
140
|
+
binocular point of regard (`x`/`y`, `x.filtered`/`y.filtered`) is valid when at
|
|
141
|
+
least one eye's is. Points of regard need a calibration; eye positions and
|
|
142
|
+
pupils only need a found eye, so before calibration they are recorded while the
|
|
143
|
+
gaze columns are `NA`.
|
|
144
|
+
|
|
145
|
+
### Messages (`.tsv`)
|
|
146
|
+
|
|
147
|
+
Tab-separated with columns `SysTime`, `Type`, `Text`. `Type` is `MSG` for your
|
|
148
|
+
own messages (`send_message`), `EVENT` for device events reported by the SDK,
|
|
149
|
+
`ERROR` for failures (with the reason, e.g. `Validation failed: ReturnValidate.ALREADY_BUSY`),
|
|
150
|
+
`WARNING` for data-quality problems (e.g. a recording without any detected eye),
|
|
151
|
+
and `INFO` for client status lines (connect, calibration, validation, recording
|
|
152
|
+
start/stop). `SysTime` is on the same clock as the `SysTime` column of the
|
|
153
|
+
sample file, so messages can be aligned with samples.
|
|
154
|
+
|
|
155
|
+
## Reading the data
|
|
156
|
+
|
|
157
|
+
Python:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
import eyelogic_recorder
|
|
161
|
+
|
|
162
|
+
samples = eyelogic_recorder.load_samples("data/my_experiment_20260101_120000.csv") # structured array, NA -> NaN
|
|
163
|
+
messages = eyelogic_recorder.load_messages("data/my_experiment_20260101_120000.tsv") # list of Message(sys_time, type, text)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
R:
|
|
167
|
+
|
|
168
|
+
```r
|
|
169
|
+
samples <- read.csv("data/my_experiment_20260101_120000.csv") # NA is read as missing by default
|
|
170
|
+
messages <- read.delim("data/my_experiment_20260101_120000.tsv")
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Gaze-contingent use
|
|
174
|
+
|
|
175
|
+
`get_position()` returns the latest filtered gaze position (or `None` if the
|
|
176
|
+
last sample was invalid) whenever tracking runs, also between recordings.
|
|
177
|
+
`get_position(as_array=True)` returns a NumPy array; `last_sample` gives the
|
|
178
|
+
full SDK sample (every sample, also when some of its fields are invalid).
|
|
179
|
+
|
|
180
|
+
## Error model
|
|
181
|
+
|
|
182
|
+
All errors derive from `EyeLogicError`:
|
|
183
|
+
|
|
184
|
+
- `ConnectError`, `DeviceNotFoundError`, `StartTrackingError`,
|
|
185
|
+
`CalibrationError`, `ValidationError`: raised by the corresponding call.
|
|
186
|
+
- `ConnectionClosedError`, `DeviceDisconnectedError`, `TrackingStoppedError`:
|
|
187
|
+
the server connection, device, or tracking was lost unexpectedly.
|
|
188
|
+
- `WriterError`: the background writer thread failed (for example disk full).
|
|
189
|
+
- `RecordingOverflowError`: samples were dropped because the writer fell behind
|
|
190
|
+
by more than the buffer pool (`samples_dropped`).
|
|
191
|
+
- `MessageWriteError`: a message (e.g. a trial marker) could not be written.
|
|
192
|
+
- `NoEyeDetectedError`: `calibrate()` saw no eye within `eye_timeout` seconds
|
|
193
|
+
(participant not in view, or a frame rate the device offers but does not
|
|
194
|
+
support: according to EyeLogic only the LogicOne supports 250 Hz, but an
|
|
195
|
+
EyeLogic Lite may still offer it).
|
|
196
|
+
|
|
197
|
+
A `ValidationError` is also raised when validation finds no eye at any target.
|
|
198
|
+
After each recording, the message file gets the share of samples with a
|
|
199
|
+
detected eye; if there were none (or no samples at all), a `WARNING` line is
|
|
200
|
+
written and an `EyeLogicWarning` is issued.
|
|
201
|
+
|
|
202
|
+
Failures that occur on SDK or writer threads cannot be raised where they
|
|
203
|
+
happen. They are stored and raised by `check_health()` and by the methods that
|
|
204
|
+
call it (`connect`, `start_recording`, `get_position`, ...). Call
|
|
205
|
+
`check_health()` regularly, for example once per trial.
|
|
206
|
+
|
|
207
|
+
## Running tests
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
pip install -e .[dev]
|
|
211
|
+
pytest
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The tests use a fake SDK and run on any OS.
|
|
215
|
+
|
|
216
|
+
## Design
|
|
217
|
+
|
|
218
|
+
Gaze samples are copied by the SDK callback into preallocated NumPy chunks;
|
|
219
|
+
full chunks go to a writer thread, so disk I/O never delays the SDK callback
|
|
220
|
+
(the EyeLogic server drops samples when the callback is slow). The chunk pool
|
|
221
|
+
buffers 40 s at 250 Hz by default; if the disk stalls for longer, samples are
|
|
222
|
+
dropped, counted (`samples_dropped`) and reported by `check_health()` as
|
|
223
|
+
`RecordingOverflowError` instead of being lost silently. See
|
|
224
|
+
[docs/recording_pipeline.md](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/docs/recording_pipeline.md) for details.
|
|
225
|
+
|
|
226
|
+
## Contributors
|
|
227
|
+
|
|
228
|
+
- Philipp Freiherr von Entreß-Fürsteneck: the boundary-paradigm example
|
|
229
|
+
(`examples/boundary.py`), adapted from Philipp's original implementation, and
|
|
230
|
+
the high-DPI coordinate conversion used in all PsychoPy examples.
|
|
231
|
+
- Aynur Pelin Çelikkol: the paragraph-reading example
|
|
232
|
+
(`examples/paragraph_reading.py`), which follows the design of Pelin's
|
|
233
|
+
reading-comprehension task (trial flow, text display with TextBox2).
|
|
234
|
+
- Niklas Rosin and Joline Flatau: testing on EyeLogic hardware.
|
|
235
|
+
|
|
236
|
+
## Acknowledgements
|
|
237
|
+
|
|
238
|
+
We thank Matthias Nieser and Thomas Jablonski of
|
|
239
|
+
[EyeLogic](https://www.eyelogicsolutions.com/) for reviewing the recording code
|
|
240
|
+
and testing it on EyeLogic hardware. eyelogic-recorder is developed
|
|
241
|
+
independently and is not an official EyeLogic product.
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
MIT, see [LICENSE](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/LICENSE).
|
|
246
|
+
|
|
247
|
+
## Citation
|
|
248
|
+
|
|
249
|
+
Please cite the software via its DOI,
|
|
250
|
+
https://doi.org/10.5281/zenodo.23537087, which resolves to the latest version
|
|
251
|
+
(each version also has its own DOI on Zenodo). Details are in
|
|
252
|
+
[CITATION.cff](https://github.com/laubrock-lab/eyelogic-recorder/blob/main/CITATION.cff).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"] # SPDX license string; only needed to build, wheels run on 3.8
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "eyelogic-recorder"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Lightweight EyeLogic eye-tracker client with asynchronous recording to CSV that never blocks the SDK callback"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
keywords = ["eye tracking", "eye-tracking", "EyeLogic", "PsychoPy", "reading", "gaze-contingent", "psychology"]
|
|
13
|
+
authors = [{ name = "Jochen Laubrock" }]
|
|
14
|
+
requires-python = ">=3.8"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"numpy",
|
|
17
|
+
# One tested SDK version; the EyeLogic server must be at least as new.
|
|
18
|
+
"eyelogic-sdk==1.2.4; sys_platform == 'win32'",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Operating System :: Microsoft :: Windows",
|
|
23
|
+
"Intended Audience :: Science/Research",
|
|
24
|
+
"Topic :: Scientific/Engineering",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/laubrock-lab/eyelogic-recorder"
|
|
29
|
+
Issues = "https://github.com/laubrock-lab/eyelogic-recorder/issues"
|
|
30
|
+
Changelog = "https://github.com/laubrock-lab/eyelogic-recorder/blob/main/CHANGELOG.md"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
psychopy = ["psychopy"]
|
|
34
|
+
dev = ["pytest"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Lightweight EyeLogic client with asynchronous recording to CSV that never blocks the SDK callback."""
|
|
2
|
+
|
|
3
|
+
from .client import EyeLogicClient, ValidationPoint
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
CalibrationError,
|
|
6
|
+
ConnectError,
|
|
7
|
+
ConnectionClosedError,
|
|
8
|
+
DeviceDisconnectedError,
|
|
9
|
+
DeviceNotFoundError,
|
|
10
|
+
EyeLogicError,
|
|
11
|
+
EyeLogicWarning,
|
|
12
|
+
MessageWriteError,
|
|
13
|
+
NoEyeDetectedError,
|
|
14
|
+
RecordingOverflowError,
|
|
15
|
+
StartTrackingError,
|
|
16
|
+
TrackingStoppedError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
WriterError,
|
|
19
|
+
)
|
|
20
|
+
from .io import load_messages, load_samples
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CalibrationError",
|
|
24
|
+
"ConnectError",
|
|
25
|
+
"ConnectionClosedError",
|
|
26
|
+
"DeviceDisconnectedError",
|
|
27
|
+
"DeviceNotFoundError",
|
|
28
|
+
"EyeLogicClient",
|
|
29
|
+
"EyeLogicError",
|
|
30
|
+
"EyeLogicWarning",
|
|
31
|
+
"MessageWriteError",
|
|
32
|
+
"NoEyeDetectedError",
|
|
33
|
+
"RecordingOverflowError",
|
|
34
|
+
"StartTrackingError",
|
|
35
|
+
"TrackingStoppedError",
|
|
36
|
+
"ValidationError",
|
|
37
|
+
"ValidationPoint",
|
|
38
|
+
"WriterError",
|
|
39
|
+
"load_messages",
|
|
40
|
+
"load_samples",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
__author__ = "Jochen Laubrock"
|
|
44
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Import of the EyeLogic SDK (``eyelogic-sdk``, pinned in pyproject.toml).
|
|
2
|
+
|
|
3
|
+
eyelogic-recorder is built against one SDK version and uses its device-event API
|
|
4
|
+
(``ELDeviceEvent``, ``DeviceEventCallback``, ``registerDeviceEventCallback``,
|
|
5
|
+
SDK >= 1.2). The installed EyeLogic server must be at least as new as the SDK;
|
|
6
|
+
newer servers stay compatible with older SDKs.
|
|
7
|
+
|
|
8
|
+
The SDK is imported lazily so that ``eyelogic_recorder.io`` (reading recorded files) works
|
|
9
|
+
on any platform, without the Windows-only SDK installed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
from types import SimpleNamespace
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load() -> SimpleNamespace:
|
|
19
|
+
"""Import ``eyelogic.ELApi`` and return the names ``eyelogic_recorder`` uses."""
|
|
20
|
+
try:
|
|
21
|
+
sdk = importlib.import_module("eyelogic.ELApi")
|
|
22
|
+
except ImportError as err:
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"The EyeLogic SDK is not installed (`pip install eyelogic-sdk`; "
|
|
25
|
+
"installing eyelogic-recorder on Windows installs the supported version)."
|
|
26
|
+
) from err
|
|
27
|
+
if not hasattr(sdk, "ELDeviceEvent"):
|
|
28
|
+
raise ImportError("eyelogic-recorder needs eyelogic-sdk >= 1.2 (device-event API); found an older SDK.")
|
|
29
|
+
return SimpleNamespace(
|
|
30
|
+
ELApi=sdk.ELApi,
|
|
31
|
+
ELGazeSample=sdk.ELGazeSample,
|
|
32
|
+
ELInvalidValue=sdk.ELInvalidValue,
|
|
33
|
+
GazeSampleCallback=sdk.GazeSampleCallback,
|
|
34
|
+
DeviceEvent=sdk.ELDeviceEvent,
|
|
35
|
+
DeviceEventCallback=sdk.DeviceEventCallback,
|
|
36
|
+
register_device_event_callback=lambda api, callback: api.registerDeviceEventCallback(callback),
|
|
37
|
+
)
|