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.
@@ -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
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23537087.svg)](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
+ ![Moving-window demo: text outside seven characters around the gaze (red dot) is masked](https://raw.githubusercontent.com/laubrock-lab/eyelogic-recorder/main/docs/images/moving_window.png)
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
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23537087.svg)](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
+ ![Moving-window demo: text outside seven characters around the gaze (red dot) is masked](https://raw.githubusercontent.com/laubrock-lab/eyelogic-recorder/main/docs/images/moving_window.png)
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )