├── tests
├── __init__.py
├── test_tools.py
├── test_guide.py
├── test_vis.py
├── test_io.py
├── test_text.py
├── test_fixation.py
└── test_measure.py
├── docs
├── images
│ ├── logo.png
│ └── screenshot_eyekit.png
├── search.html
├── tools.html
├── io.html
└── measure.html
├── example
├── example_script.py
└── example_texts.json
├── eyekit
├── __init__.py
├── _font.py
├── _color.py
├── tools.py
├── io.py
├── measure.py
├── fixation.py
└── _snap.py
├── pyproject.toml
├── README.md
├── CONTRIBUTING.md
└── CHANGELOG.md
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jwcarr/eyekit/HEAD/docs/images/logo.png
--------------------------------------------------------------------------------
/docs/images/screenshot_eyekit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jwcarr/eyekit/HEAD/docs/images/screenshot_eyekit.png
--------------------------------------------------------------------------------
/tests/test_tools.py:
--------------------------------------------------------------------------------
1 | import eyekit
2 |
3 |
4 | def test_font_size_at_72dpi():
5 | assert eyekit.tools.font_size_at_72dpi(15, 96) == 20
6 |
--------------------------------------------------------------------------------
/example/example_script.py:
--------------------------------------------------------------------------------
1 | """
2 |
3 | Eyekit example script. Load in some fixation data and the associated texts.
4 | For each trial, produce a pdf showing the relevant text overlaid with the
5 | fixation sequence.
6 |
7 | """
8 |
9 | import eyekit
10 |
11 | # Read in fixation data
12 | data = eyekit.io.load("example_data.json")
13 |
14 | # Read in texts
15 | texts = eyekit.io.load("example_texts.json")
16 |
17 | # For each trial in the dataset
18 | for trial_id, trial in data.items():
19 |
20 | # Get the fixation sequence for that trial
21 | seq = trial["fixations"]
22 |
23 | # Get the passage ID of that trial
24 | passage_id = trial["passage_id"]
25 |
26 | # Get the relevant text with that passage ID
27 | txt = texts[passage_id]["text"]
28 |
29 | # Create an image with a screen size of 1920x1080
30 | img = eyekit.vis.Image(1920, 1080)
31 |
32 | # Draw the text
33 | img.draw_text_block(txt)
34 |
35 | # Draw the fixation sequence
36 | img.draw_fixation_sequence(seq)
37 |
38 | # Save the image as a PDF
39 | img.save(f"{trial_id}.pdf")
40 |
--------------------------------------------------------------------------------
/eyekit/__init__.py:
--------------------------------------------------------------------------------
1 | # Eyekit https://jwcarr.github.io/eyekit/
2 | # Copyright (C) 2019-2022 Jon Carr
3 |
4 | # This program is free software: you can redistribute it and/or modify it
5 | # under the terms of the GNU General Public License as published by the Free
6 | # Software Foundation, either version 3 of the License, or(at your option)
7 | # any later version.
8 |
9 | # This program is distributed in the hope that it will be useful, but WITHOUT
10 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
12 | # more details.
13 |
14 | # You should have received a copy of the GNU General Public License along with
15 | # this program. If not, see .
16 |
17 | """
18 | .. include:: ../.GUIDE.md
19 | :start-line: 2
20 | """
21 |
22 | from .fixation import FixationSequence
23 | from .text import TextBlock
24 | from . import measure
25 | from . import io
26 | from . import tools
27 | from . import vis
28 |
29 | del fixation, text
30 |
31 | __version__ = vis.__version__
32 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=69", "setuptools_scm>=8"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [project]
6 | dynamic = ["version"]
7 | name = "eyekit"
8 | authors = [
9 | { name="Jon Carr", email="jon.carr@rhul.ac.uk" },
10 | ]
11 | description = "A Python package for analyzing reading behavior using eyetracking data"
12 | readme = "README.md"
13 | license = "GPL-3.0-only"
14 | license-files = ["LICENSE"]
15 | classifiers = [
16 | "Programming Language :: Python :: 3",
17 | "Operating System :: OS Independent",
18 | "Development Status :: 5 - Production/Stable",
19 | "Intended Audience :: Science/Research",
20 | "Topic :: Scientific/Engineering :: Human Machine Interfaces",
21 | "Topic :: Scientific/Engineering :: Visualization",
22 | "Topic :: Text Processing :: Fonts",
23 | "Topic :: Text Processing :: Linguistic",
24 | ]
25 | requires-python = ">=3.8"
26 | dependencies = [
27 | "cairocffi>=1.1",
28 | ]
29 |
30 | [project.urls]
31 | Homepage = "https://github.com/jwcarr/eyekit"
32 | Documentation = "https://jwcarr.github.io/eyekit/"
33 |
34 | [tool.setuptools]
35 | packages = ["eyekit"]
36 |
37 | [tool.setuptools_scm]
38 | version_file = "eyekit/_version.py"
39 | version_file_template = '__version__ = "{version}"'
40 |
--------------------------------------------------------------------------------
/tests/test_guide.py:
--------------------------------------------------------------------------------
1 | import io
2 | from contextlib import redirect_stdout
3 | from sys import version_info
4 |
5 |
6 | def extract_codeblocks(file_path, skip_marker, replacements):
7 | codeblocks = []
8 | in_codeblock = False
9 | with open(file_path) as file:
10 | for line in file:
11 | if in_codeblock:
12 | if line.startswith("```"):
13 | in_codeblock = False
14 | elif skip_marker not in line:
15 | for search_string, replace_string in replacements.items():
16 | line = line.replace(search_string, replace_string)
17 | codeblocks[-1] += line
18 | elif line.startswith("```python"):
19 | in_codeblock = True
20 | codeblocks.append("")
21 | return codeblocks
22 |
23 |
24 | def test_guide():
25 | codeblocks = extract_codeblocks(
26 | "GUIDE.md", "#skiptest", {".save('": ".save('docs/images/"}
27 | )
28 | for block in codeblocks:
29 | captured_from_stdout = io.StringIO()
30 | with redirect_stdout(captured_from_stdout):
31 | if version_info.minor >= 13:
32 | # In Python 3.13, global scope needs to be passed in explicitly
33 | exec(block, globals=globals())
34 | else:
35 | exec(block)
36 | actual_output = captured_from_stdout.getvalue().strip().split("\n")
37 | expected_output = [l[2:] for l in block.split("\n") if l.startswith("# ")]
38 | for actual, expected in zip(actual_output, expected_output):
39 | assert actual == expected
40 |
--------------------------------------------------------------------------------
/eyekit/_font.py:
--------------------------------------------------------------------------------
1 | """
2 | Class for representing a font face in a particular size and style, and for
3 | providing a convenient interface to Cairo's font selection mechanism.
4 |
5 | Create font:
6 | my_font = _font.Font('Helvetica bold', 12)
7 |
8 | Query some metrics:
9 | x_height = my_font.calculate_height('x')
10 | text_width = my_font.calculate_width('example')
11 |
12 | Use the face in a Cairo context:
13 | context.set_font_face(my_font.face)
14 | """
15 |
16 | import re
17 | import cairocffi as cairo
18 |
19 |
20 | class Font:
21 | """
22 | Wrapper around Cairo's font selection mechanism.
23 | """
24 |
25 | regex_italic = re.compile(" italic", re.IGNORECASE)
26 | regex_bold = re.compile(" bold", re.IGNORECASE)
27 |
28 | def __init__(self, face, size):
29 | if self.regex_italic.search(face):
30 | self.slant = "italic"
31 | slant = cairo.FONT_SLANT_ITALIC
32 | face = self.regex_italic.sub("", face)
33 | else:
34 | self.slant = "normal"
35 | slant = cairo.FONT_SLANT_NORMAL
36 | if self.regex_bold.search(face):
37 | self.weight = "bold"
38 | weight = cairo.FONT_WEIGHT_BOLD
39 | face = self.regex_bold.sub("", face)
40 | else:
41 | self.weight = "normal"
42 | weight = cairo.FONT_WEIGHT_NORMAL
43 | self.family = face.strip()
44 | self.size = float(size)
45 | self.face = cairo.ToyFontFace(self.family, slant, weight)
46 | self.scaled_font = cairo.ScaledFont(
47 | self.face, cairo.Matrix(xx=self.size, yy=self.size)
48 | )
49 |
50 | def calculate_width(self, text):
51 | """
52 | Return pixel width of a piece of text rendered in the font.
53 | """
54 | return self.scaled_font.text_extents(text)[4]
55 |
56 | def calculate_height(self, text):
57 | """
58 | Return pixel height of a piece of text rendered in the font.
59 | """
60 | return self.scaled_font.text_extents(text)[3]
61 |
62 | def get_descent(self):
63 | """
64 | Return the font's descent in pixels.
65 | """
66 | return self.scaled_font.extents()[1]
67 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Eyekit is a Python package for analyzing reading behavior using eyetracking data. Eyekit aims to be entirely independent of any particular eyetracker hardware, experiment software, or data formats. It has an object-oriented style that defines three core objects – the TextBlock, InterestArea, and FixationSequence – that you bring into contact with a bit of coding.
4 |
5 |
6 | Is Eyekit the Right Tool for Me?
7 | --------------------------------
8 |
9 | - You want to analyze which parts of a text someone is looking at and for how long.
10 |
11 | - You need convenient tools for extracting areas of interest from texts, such as specific words, phrases, or letter combinations.
12 |
13 | - You want to calculate common reading measures, such as gaze duration or initial landing position.
14 |
15 | - You need support for arbitrary fonts, multiline passages, right-to-left text, or non-alphabetical scripts.
16 |
17 | - You want the flexibility to define custom reading measures and to build your own reproducible processing pipeline.
18 |
19 | - You would like tools for dealing with noise and calibration issues, and for discarding fixations according to your own criteria.
20 |
21 | - You want to share your data in an open format and produce publication-ready vector graphics.
22 |
23 |
24 | Installation
25 | ------------
26 |
27 | Eyekit may be installed using `pip`:
28 |
29 | ```shell
30 | $ pip install eyekit
31 | ```
32 |
33 | Eyekit is compatible with Python 3.8+. Its main dependency is the graphics library [Cairo](https://cairographics.org), which you might also need to install if it's not already on your system. Many Linux distributions have Cairo built in. On a Mac, it can be installed using [Homebrew](https://brew.sh): `brew install cairo`. On Windows, it can be installed using [Anaconda](https://anaconda.org/anaconda/cairo): `conda install -c anaconda cairo`.
34 |
35 |
36 | **[Full documentation and a usage guide is available here](https://jwcarr.github.io/eyekit/)**
37 |
38 |
39 | Contributing
40 | ------------
41 |
42 | This project is in the stable/maintenance phase and is not under active development. I am only aiming to fix bugs and keep it compatible with current versions of Python so that it remains useful to the community. With that in mind, contributions are very welcome, but please read the `CONTRIBUTING` file before submitting a pull request.
43 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Thank you for your interest in contributing to Eyekit! This project is in the stable/maintenance phase and is not under active development. I am only aiming to fix bugs and keep it compatible with current versions of Python so that it remains useful to the community. With that in mind, contributions are very welcome, so long as they align with the guidelines below.
4 |
5 |
6 | ## Scope and Expectations
7 |
8 | * **Maintenance-mode:** The package is not being actively expanded. I am happy to consider bug fixes, small improvements, and updates for compatibility with new Python releases.
9 | * **No major redesigns:** Please avoid proposing large structural refactors or redesigns based on personal preference.
10 | * **Avoid breaking changes:** The package is used in research contexts, so please do not submit changes that break the existing public API or alter core behavior in ways that may affect other researchers.
11 |
12 | If you're unsure whether a change is appropriate, opening an issue first is strongly encouraged. This helps avoid lost effort on pull requests that cannot be merged due to scope or compatibility requirements.
13 |
14 |
15 | ## Bug Reports & Issues
16 |
17 | If you encounter a bug, please open an issue with:
18 |
19 | * A short, clear description of the problem.
20 | * A minimal working example that shows the expected vs. actual behavior.
21 | * If possible, a proposal about how to fix the problem.
22 |
23 | If the bug is particularly straight-forward and uncontroversial, you can skip directly to the pull request.
24 |
25 |
26 | ## Pull Requests
27 |
28 | Before starting a PR, consider whether it might be more appropriate to open an issue first to confirm that the contribution fits within the project's scope.
29 |
30 | When submitting a PR, please include:
31 |
32 | * A clear description of **what the PR does** and **why**, linking to the relevant issue (if applicable).
33 | * If fixing a bug, provide a minimal example so that I can quickly understand and verify the solution.
34 | * Ideally, please add new test code to cover new behaviors and avoid future regressions.
35 |
36 | As always, please try to follow good "git hygiene":
37 |
38 | * One logical change per commit.
39 | * Don't bundle unrelated changes into a single commit or PR.
40 | * Use concise, descriptive commit messages.
41 | * Each commit should leave the package in a working state that passes the unit tests.
42 |
43 | Finally, before submitting a PR, please ensure that:
44 |
45 | 1. The unit tests still pass by running `pytest tests`.
46 | 2. The code is formatted in [Black](https://black.readthedocs.io) style: `black eyekit`.
47 | 3. New code follows existing conventions in the codebase.
48 |
49 |
50 | ## Thank You
51 |
52 | Your contributions – whether through bug reports, documentation improvements, or pull requests – help keep this software useful to the research community.
53 |
--------------------------------------------------------------------------------
/tests/test_vis.py:
--------------------------------------------------------------------------------
1 | from tempfile import TemporaryDirectory
2 | from pathlib import Path
3 | import eyekit
4 |
5 | sentence = "The quick brown fox [jump]{stem_1}[ed]{suffix_1} over the lazy dog."
6 | txt = eyekit.TextBlock(
7 | sentence, position=(100, 500), font_face="Times New Roman", font_size=36
8 | )
9 | seq = eyekit.FixationSequence(
10 | [
11 | [106, 490, 0, 100],
12 | [190, 486, 100, 200],
13 | [230, 505, 200, 300],
14 | [298, 490, 300, 400],
15 | [361, 497, 400, 500],
16 | [430, 489, 500, 600],
17 | [450, 505, 600, 700],
18 | [492, 491, 700, 800],
19 | [562, 505, 800, 900],
20 | [637, 493, 900, 1000],
21 | [712, 497, 1000, 1100],
22 | [763, 487, 1100, 1200],
23 | ]
24 | )
25 |
26 |
27 | def test_Image():
28 | img = eyekit.vis.Image(1920, 1080)
29 | img.set_caption("Quick Brown Fox", font_face="Helvetica Neue italic", font_size=8)
30 | img.set_background_color("snow")
31 | img.draw_text_block(txt)
32 | for word in txt.words():
33 | img.draw_rectangle(word.box, color="crimson")
34 | img.draw_fixation_sequence(seq)
35 | img.draw_line((0, 0), (1920, 1080), color="coral", stroke_width=2, dashed=True)
36 | img.draw_circle((200, 200), 200)
37 | img.draw_annotation(
38 | (1000, 500),
39 | "Hello world!",
40 | font_face="Courier New",
41 | font_size=26,
42 | color="yellowgreen",
43 | )
44 | with TemporaryDirectory() as temp_dir:
45 | img.save(Path(temp_dir) / "test_image.pdf")
46 | img.save(Path(temp_dir) / "test_image.eps")
47 | img.save(Path(temp_dir) / "test_image.svg")
48 | img.save(Path(temp_dir) / "test_image.png")
49 | fig = eyekit.vis.Figure(1, 2)
50 | fig.set_padding(vertical=10, horizontal=5, edge=2)
51 | fig.add_image(img)
52 | fig.add_image(img)
53 | with TemporaryDirectory() as temp_dir:
54 | fig.save(Path(temp_dir) / "test_figure.pdf")
55 | fig.save(Path(temp_dir) / "test_figure.eps")
56 | fig.save(Path(temp_dir) / "test_figure.svg")
57 |
58 |
59 | def test_mm_to_pts():
60 | assert str(eyekit.vis._mm_to_pts(1))[:5] == "2.834"
61 | assert str(eyekit.vis._mm_to_pts(10))[:5] == "28.34"
62 |
63 |
64 | def test_color_to_rgb():
65 | assert eyekit.vis._color_to_rgb("#FFFFFF", (0, 0, 0)) == (1.0, 1.0, 1.0)
66 | assert eyekit.vis._color_to_rgb("#ffffff", (0, 0, 0)) == (1.0, 1.0, 1.0)
67 | assert eyekit.vis._color_to_rgb("#000000", (0, 0, 0)) == (0.0, 0.0, 0.0)
68 | assert eyekit.vis._color_to_rgb("#01010", (0, 0, 0)) == (0, 0, 0)
69 | assert eyekit.vis._color_to_rgb("#red", (0, 0, 0)) == (0, 0, 0)
70 | assert eyekit.vis._color_to_rgb("blue", (0, 0, 0)) == (0.0, 0.0, 1.0)
71 | assert eyekit.vis._color_to_rgb("REd", (0, 0, 0)) == (1.0, 0.0, 0.0)
72 | assert eyekit.vis._color_to_rgb((0, 0, 255), (0, 0, 0)) == (0.0, 0.0, 1.0)
73 | assert eyekit.vis._color_to_rgb([0, 255, 0], (0, 0, 0)) == (0.0, 1.0, 0.0)
74 | assert eyekit.vis._color_to_rgb(0, (1, 0, 0)) == (1, 0, 0)
75 | assert eyekit.vis._color_to_rgb(1, (0, 1, 0)) == (0, 1, 0)
76 | assert eyekit.vis._color_to_rgb(255, (0, 0, 1)) == (0, 0, 1)
77 |
--------------------------------------------------------------------------------
/tests/test_io.py:
--------------------------------------------------------------------------------
1 | from tempfile import TemporaryDirectory
2 | from pathlib import Path
3 | import eyekit
4 |
5 | EXAMPLE_DATA = Path("example") / "example_data.json"
6 | EXAMPLE_TEXTS = Path("example") / "example_texts.json"
7 | EXAMPLE_ASC = Path("example") / "example_data.asc"
8 | EXAMPLE_CSV = Path("example") / "example_data.csv"
9 |
10 |
11 | def test_load_data():
12 | data = eyekit.io.load(EXAMPLE_DATA)
13 | assert isinstance(data["trial_0"]["fixations"], eyekit.FixationSequence)
14 | assert isinstance(data["trial_1"]["fixations"], eyekit.FixationSequence)
15 | assert isinstance(data["trial_2"]["fixations"], eyekit.FixationSequence)
16 | assert data["trial_0"]["fixations"][0].x == 412
17 | assert data["trial_0"]["fixations"][1].y == 163
18 | assert data["trial_0"]["fixations"][2].duration == 333
19 |
20 |
21 | def test_load_texts(texts_path=EXAMPLE_TEXTS):
22 | texts = eyekit.io.load(texts_path)
23 | assert isinstance(texts["passage_a"]["text"], eyekit.TextBlock)
24 | assert isinstance(texts["passage_b"]["text"], eyekit.TextBlock)
25 | assert isinstance(texts["passage_c"]["text"], eyekit.TextBlock)
26 | assert texts["passage_a"]["text"].position == (360, 161)
27 | assert texts["passage_b"]["text"].font_face == "Courier New"
28 | assert texts["passage_c"]["text"].align == "left"
29 | assert texts["passage_c"]["text"].anchor == "left"
30 |
31 |
32 | def test_save(compress=False):
33 | data = eyekit.io.load(EXAMPLE_DATA)
34 | with TemporaryDirectory() as temp_dir:
35 | output_file = Path(temp_dir) / "output.json"
36 | eyekit.io.save(data, output_file, compress=compress)
37 | written_data = eyekit.io.load(output_file)
38 | original_seq = data["trial_0"]["fixations"]
39 | written_seq = written_data["trial_0"]["fixations"]
40 | for fxn1, fxn2 in zip(original_seq, written_seq):
41 | assert fxn1.serialize() == fxn2.serialize()
42 |
43 |
44 | def test_compressed_save():
45 | test_save(compress=True)
46 |
47 |
48 | def test_save_text_block():
49 | texts = eyekit.io.load(EXAMPLE_TEXTS)
50 | with TemporaryDirectory() as temp_dir:
51 | output_file = Path(temp_dir) / "output.json"
52 | eyekit.io.save(texts, output_file)
53 | written_texts = eyekit.io.load(output_file)
54 | test_load_texts(texts_path=output_file)
55 |
56 |
57 | def test_save_interest_area():
58 | texts = eyekit.io.load(EXAMPLE_TEXTS)
59 | original_words = list(texts["passage_a"]["text"].words())
60 | with TemporaryDirectory() as temp_dir:
61 | output_file = Path(temp_dir) / "output.json"
62 | eyekit.io.save(original_words, output_file)
63 | loaded_words = eyekit.io.load(output_file)
64 | for original_word, loaded_word in zip(original_words, loaded_words):
65 | assert original_word.id == loaded_word.id
66 |
67 |
68 | def test_import_asc():
69 | try:
70 | data = eyekit.io.import_asc(EXAMPLE_ASC, variables=["trial_type"])
71 | except FileNotFoundError:
72 | return
73 | assert data[0]["trial_type"] == "Practice"
74 | assert data[1]["fixations"].duration == 72279
75 | assert data[2]["fixations"][0].x == 1236
76 | with TemporaryDirectory() as temp_dir:
77 | output_file = Path(temp_dir) / "output.json"
78 | eyekit.io.save(data, output_file)
79 |
80 |
81 | def test_import_csv():
82 | try:
83 | data = eyekit.io.import_csv(EXAMPLE_CSV, trial_header="trial")
84 | except FileNotFoundError:
85 | return
86 | assert data[0]["fixations"].duration == 78505
87 | assert data[1]["fixations"].duration == 60855
88 | assert data[2]["fixations"].duration == 57468
89 |
--------------------------------------------------------------------------------
/example/example_texts.json:
--------------------------------------------------------------------------------
1 | {
2 | "passage_a" : {
3 | "title" : "La storia dei tre orsi / Goldilocks and the Three Bears",
4 | "author" : "Robert Southey",
5 | "text" : {
6 | "__TextBlock__" : {
7 | "position" : [360, 161],
8 | "font_face" : "Courier New",
9 | "font_size" : 26.667,
10 | "line_height" : 64,
11 | "text": [
12 | "C’erano una volta tre Orsi, che vivevano in una casina nel bosco. C’era",
13 | "Babbo Orso grosso grosso, con una voce grossa grossa; c’era Mamma Orsa",
14 | "grossa la metà, con una voce grossa la metà; e c’era un Orsetto piccolo",
15 | "piccolo con una voce piccola piccola. Una mattina i tre Orsi facevano",
16 | "colazione e Mamma Orsa disse: “La pappa è troppo calda, ora. Andiamo a",
17 | "fare una passeggiata nel bosco, mentre la pappa diventa fredda”. Così i",
18 | "tre Orsi andarono a fare una passeggiata nel bosco. Mentre erano via,",
19 | "arrivò una piccola bimba chiamata Riccidoro. Quando vide la casetta nel",
20 | "bosco, si domandò chi mai potesse vivere là dentro, e picchiò alla porta.",
21 | "Nessuno rispose, e la bimba picchiò ancora. Nessuno rispose: Riccidoro",
22 | "allora aprì la porta ed entrò. E là, nella piccola stanza, vide una tavola",
23 | "apparecchiata per tre."
24 | ]
25 | }
26 | }
27 | },
28 | "passage_b" : {
29 | "title" : "I musicanti di Brema / Town Musicians of Bremen",
30 | "author" : "Brothers Grimm",
31 | "text" : {
32 | "__TextBlock__" : {
33 | "position" : [360, 161],
34 | "font_face" : "Courier New",
35 | "font_size" : 26.667,
36 | "line_height" : 64,
37 | "text": [
38 | "C’era una volta un vecchio asino che aveva lavorato sodo per tutta la",
39 | "vita. Ormai non era più capace di portare pesi e si stancava facilmente,",
40 | "per questo il suo padrone aveva deciso di relegarlo in un angolo della",
41 | "stalla ad aspettare la morte. L’asino però non voleva trascorrere così gli",
42 | "ultimi anni della sua vita. Decise di andarsene a Brema, dove sperava di",
43 | "poter vivere facendo il musicista. Si era incamminato da poco quando",
44 | "incontrò un cane, magro e ansimante. “Come mai hai il fiatone?” gli",
45 | "chiese. “Sono dovuto scappare in tutta fretta per salvare la pelle” gli",
46 | "rispose il cane. “Il mio padrone voleva uccidermi, perché ora che sono",
47 | "vecchio non gli servo più”."
48 | ]
49 | }
50 | }
51 | },
52 | "passage_c" : {
53 | "title" : "L'acciarino magico / The Tinderbox",
54 | "author" : "Hans Christian Andersen",
55 | "text" : {
56 | "__TextBlock__" : {
57 | "position" : [360, 161],
58 | "font_face" : "Courier New",
59 | "font_size" : 26.667,
60 | "line_height" : 64,
61 | "text": [
62 | "Così il soldato viveva allegramente, andava a teatro, passeggiava nel",
63 | "giardino reale di Parigi e dava ai poveri tanto denaro, e questo era ben",
64 | "fatto. Lo sapeva bene dai tempi passati, quanto fosse brutto non avere",
65 | "neppure un soldo. Ora era ricco e aveva abiti eleganti e si trovò",
66 | "tantissimi amici, tutti a ripetergli quanto era simpatico, un vero",
67 | "cavaliere, e questo al soldato faceva molto piacere. Ma spendendo ogni",
68 | "giorno dei soldi e non guadagnandone mai, alla fine rimase con i soli",
69 | "spiccioli e fu costretto a trasferirsi, dalle splendide stanze in cui",
70 | "aveva abitato, in una piccolissima cameretta, proprio sotto il tetto, e",
71 | "dovette pulirsi da sé gli stivali e cucirli con un ago, e nessuno dei suoi",
72 | "amici andò a trovarlo, perché vi erano troppe scale da fare."
73 | ]
74 | }
75 | }
76 | }
77 | }
--------------------------------------------------------------------------------
/eyekit/_color.py:
--------------------------------------------------------------------------------
1 | """
2 | Standard HTML color names with RGB values
3 | https://www.w3schools.com/colors/colors_names.asp
4 | """
5 |
6 | colors = {
7 | "aliceblue": (240, 248, 255),
8 | "antiquewhite": (250, 235, 215),
9 | "aqua": (0, 255, 255),
10 | "aquamarine": (127, 255, 212),
11 | "azure": (240, 255, 255),
12 | "beige": (245, 245, 220),
13 | "bisque": (255, 228, 196),
14 | "black": (0, 0, 0),
15 | "blanchedalmond": (255, 235, 205),
16 | "blue": (0, 0, 255),
17 | "blueviolet": (138, 43, 226),
18 | "brown": (165, 42, 42),
19 | "burlywood": (222, 184, 135),
20 | "cadetblue": (95, 158, 160),
21 | "chartreuse": (127, 255, 0),
22 | "chocolate": (210, 105, 30),
23 | "coral": (255, 127, 80),
24 | "cornflowerblue": (100, 149, 237),
25 | "cornsilk": (255, 248, 220),
26 | "crimson": (220, 20, 60),
27 | "cyan": (0, 255, 255),
28 | "darkblue": (0, 0, 139),
29 | "darkcyan": (0, 139, 139),
30 | "darkgoldenrod": (184, 134, 11),
31 | "darkgray": (169, 169, 169),
32 | "darkgreen": (0, 100, 0),
33 | "darkgrey": (169, 169, 169),
34 | "darkkhaki": (189, 183, 107),
35 | "darkmagenta": (139, 0, 139),
36 | "darkolivegreen": (85, 107, 47),
37 | "darkorange": (255, 140, 0),
38 | "darkorchid": (153, 50, 204),
39 | "darkred": (139, 0, 0),
40 | "darksalmon": (233, 150, 122),
41 | "darkseagreen": (143, 188, 143),
42 | "darkslateblue": (72, 61, 139),
43 | "darkslategray": (47, 79, 79),
44 | "darkslategrey": (47, 79, 79),
45 | "darkturquoise": (0, 206, 209),
46 | "darkviolet": (148, 0, 211),
47 | "deeppink": (255, 20, 147),
48 | "deepskyblue": (0, 191, 255),
49 | "dimgray": (105, 105, 105),
50 | "dimgrey": (105, 105, 105),
51 | "dodgerblue": (30, 144, 255),
52 | "firebrick": (178, 34, 34),
53 | "floralwhite": (255, 250, 240),
54 | "forestgreen": (34, 139, 34),
55 | "fuchsia": (255, 0, 255),
56 | "gainsboro": (220, 220, 220),
57 | "ghostwhite": (248, 248, 255),
58 | "gold": (255, 215, 0),
59 | "goldenrod": (218, 165, 32),
60 | "gray": (128, 128, 128),
61 | "grey": (128, 128, 128),
62 | "green": (0, 128, 0),
63 | "greenyellow": (173, 255, 47),
64 | "honeydew": (240, 255, 240),
65 | "hotpink": (255, 105, 180),
66 | "indianred": (205, 92, 92),
67 | "indigo": (75, 0, 130),
68 | "ivory": (255, 255, 240),
69 | "khaki": (240, 230, 140),
70 | "lavender": (230, 230, 250),
71 | "lavenderblush": (255, 240, 245),
72 | "lawngreen": (124, 252, 0),
73 | "lemonchiffon": (255, 250, 205),
74 | "lightblue": (173, 216, 230),
75 | "lightcoral": (240, 128, 128),
76 | "lightcyan": (224, 255, 255),
77 | "lightgoldenrodyellow": (250, 250, 210),
78 | "lightgray": (211, 211, 211),
79 | "lightgreen": (144, 238, 144),
80 | "lightgrey": (211, 211, 211),
81 | "lightpink": (255, 182, 193),
82 | "lightsalmon": (255, 160, 122),
83 | "lightseagreen": (32, 178, 170),
84 | "lightskyblue": (135, 206, 250),
85 | "lightslategray": (119, 136, 153),
86 | "lightslategrey": (119, 136, 153),
87 | "lightsteelblue": (176, 196, 222),
88 | "lightyellow": (255, 255, 224),
89 | "lime": (0, 255, 0),
90 | "limegreen": (50, 205, 50),
91 | "linen": (250, 240, 230),
92 | "magenta": (255, 0, 255),
93 | "maroon": (128, 0, 0),
94 | "mediumaquamarine": (102, 205, 170),
95 | "mediumblue": (0, 0, 205),
96 | "mediumorchid": (186, 85, 211),
97 | "mediumpurple": (147, 112, 219),
98 | "mediumseagreen": (60, 179, 113),
99 | "mediumslateblue": (123, 104, 238),
100 | "mediumspringgreen": (0, 250, 154),
101 | "mediumturquoise": (72, 209, 204),
102 | "mediumvioletred": (199, 21, 133),
103 | "midnightblue": (25, 25, 112),
104 | "mintcream": (245, 255, 250),
105 | "mistyrose": (255, 228, 225),
106 | "moccasin": (255, 228, 181),
107 | "navajowhite": (255, 222, 173),
108 | "navy": (0, 0, 128),
109 | "oldlace": (253, 245, 230),
110 | "olive": (128, 128, 0),
111 | "olivedrab": (107, 142, 35),
112 | "orange": (255, 165, 0),
113 | "orangered": (255, 69, 0),
114 | "orchid": (218, 112, 214),
115 | "palegoldenrod": (238, 232, 170),
116 | "palegreen": (152, 251, 152),
117 | "paleturquoise": (175, 238, 238),
118 | "palevioletred": (219, 112, 147),
119 | "papayawhip": (255, 239, 213),
120 | "peachpuff": (255, 218, 185),
121 | "peru": (205, 133, 63),
122 | "pink": (255, 192, 203),
123 | "plum": (221, 160, 221),
124 | "powderblue": (176, 224, 230),
125 | "purple": (128, 0, 128),
126 | "red": (255, 0, 0),
127 | "rosybrown": (188, 143, 143),
128 | "royalblue": (65, 105, 225),
129 | "saddlebrown": (139, 69, 19),
130 | "salmon": (250, 128, 114),
131 | "sandybrown": (244, 164, 96),
132 | "seagreen": (46, 139, 87),
133 | "seashell": (255, 245, 238),
134 | "sienna": (160, 82, 45),
135 | "silver": (192, 192, 192),
136 | "skyblue": (135, 206, 235),
137 | "slateblue": (106, 90, 205),
138 | "slategray": (112, 128, 144),
139 | "slategrey": (112, 128, 144),
140 | "snow": (255, 250, 250),
141 | "springgreen": (0, 255, 127),
142 | "steelblue": (70, 130, 180),
143 | "tan": (210, 180, 140),
144 | "teal": (0, 128, 128),
145 | "thistle": (216, 191, 216),
146 | "tomato": (255, 99, 71),
147 | "turquoise": (64, 224, 208),
148 | "violet": (238, 130, 238),
149 | "wheat": (245, 222, 179),
150 | "white": (255, 255, 255),
151 | "whitesmoke": (245, 245, 245),
152 | "yellow": (255, 255, 0),
153 | "yellowgreen": (154, 205, 50),
154 | }
155 |
--------------------------------------------------------------------------------
/docs/search.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Search
7 |
8 |
9 |
28 |
29 |
30 |
31 |
34 |
35 |
36 |
37 |
38 |
41 |
42 |
43 |
44 |
141 |
--------------------------------------------------------------------------------
/tests/test_text.py:
--------------------------------------------------------------------------------
1 | import eyekit
2 |
3 | sentence = "The quick brown fox [jump]{stem_1}[ed]{suffix_1} over the lazy dog."
4 | txt = eyekit.TextBlock(
5 | sentence, position=(100, 500), font_face="Times New Roman", font_size=36
6 | )
7 | seq = eyekit.FixationSequence(
8 | [
9 | [106, 490, 0, 100],
10 | [190, 486, 100, 200],
11 | [230, 505, 200, 300],
12 | [298, 490, 300, 400],
13 | [361, 497, 400, 500],
14 | [430, 489, 500, 600],
15 | [450, 505, 600, 700],
16 | [492, 491, 700, 800],
17 | [562, 505, 800, 900],
18 | [637, 493, 900, 1000],
19 | [712, 497, 1000, 1100],
20 | [763, 487, 1100, 1200],
21 | ]
22 | )
23 |
24 |
25 | def test_initialization():
26 | assert txt.position == (100, 500)
27 | assert txt.font_face == "Times New Roman"
28 | assert txt.font_size == 36
29 | assert txt.line_height == 36
30 | assert txt.align == "left"
31 | assert txt.anchor == "left"
32 | assert txt.alphabet is None
33 | assert txt.autopad == True
34 | assert txt.n_rows == 1
35 | assert txt.n_lines == 1
36 | assert txt.n_cols == 45
37 | assert len(txt) == 45
38 | assert txt.baselines[0] == 500
39 |
40 |
41 | def test_IA_extraction():
42 | assert txt["stem_1"].id == "stem_1"
43 | assert txt["stem_1"].text == "jump"
44 | assert txt["stem_1"].baseline == 500
45 | assert int(txt["stem_1"].midline) == 491
46 | assert txt["stem_1"].height == 36
47 | assert txt["suffix_1"].id == "suffix_1"
48 | assert txt["suffix_1"].text == "ed"
49 | assert txt["suffix_1"].baseline == 500
50 | assert int(txt["suffix_1"].midline) == 491
51 | assert txt["suffix_1"].height == 36
52 | txt[0:4:19].id = "test_id"
53 | assert txt["test_id"].text == "quick brown fox"
54 | assert txt[0:4:19].id == "test_id"
55 |
56 |
57 | def test_manual_ia_extraction():
58 | assert len(list(txt.interest_areas())) == 2
59 | for word in txt.interest_areas():
60 | assert word.text in ["jump", "ed"]
61 | assert word.baseline == 500
62 | assert word.height == 36
63 |
64 |
65 | def test_word_extraction():
66 | assert len(list(txt.words())) == 9
67 | for word in txt.words():
68 | assert word.text in [
69 | "The",
70 | "quick",
71 | "brown",
72 | "fox",
73 | "jumped",
74 | "over",
75 | "the",
76 | "lazy",
77 | "dog",
78 | ]
79 | assert word.baseline == 500
80 | assert word.height == 36
81 |
82 |
83 | def test_arbitrary_extraction():
84 | assert txt[0:0:3].text == "The"
85 | assert txt["0:0:3"].text == "The"
86 | assert txt[(0, 0, 3)].text == "The"
87 | assert txt[0:41:45].text == "dog."
88 | assert txt["0:41:45"].text == "dog."
89 | assert txt[(0, 41, 45)].text == "dog."
90 | assert txt[0:4:19].text == "quick brown fox"
91 | assert txt["0:4:19"].text == "quick brown fox"
92 | assert txt[(0, 4, 19)].text == "quick brown fox"
93 | assert txt[0::3].text == "The"
94 | assert txt[0:36:].text == "lazy dog."
95 |
96 |
97 | def test_IA_location():
98 | assert txt[0:0:3].location == (0, 0, 3)
99 | assert txt[0:5:40].location == (0, 5, 40)
100 |
101 |
102 | def test_IA_relative_positions():
103 | assert txt["stem_1"].is_right_of(seq[0]) == True
104 | assert txt["stem_1"].is_right_of(seq[-1]) == False
105 | assert txt["stem_1"].is_left_of(seq[-1]) == True
106 | assert txt["stem_1"].is_after(seq[0]) == True
107 | assert txt["stem_1"].is_before(seq[-1]) == True
108 |
109 |
110 | def test_iter_pairs():
111 | interest_area = txt["stem_1"]
112 | for curr_fixation, next_fixation in seq.iter_pairs():
113 | if curr_fixation in interest_area and next_fixation not in interest_area:
114 | assert next_fixation.x == 492
115 |
116 |
117 | def test_serialize():
118 | data = txt.serialize()
119 | assert data["text"] == [sentence]
120 | assert data["position"] == (100, 500)
121 | assert data["font_face"] == "Times New Roman"
122 | assert data["font_size"] == 36
123 | assert data["line_height"] == 36
124 | assert data["align"] == "left"
125 | assert data["anchor"] == "left"
126 | assert data["alphabet"] is None
127 | assert data["autopad"] == True
128 |
129 |
130 | def test_complex_font_selection():
131 | txt = eyekit.TextBlock(
132 | sentence,
133 | position=(100, 500),
134 | font_face="Times New Roman bold italic",
135 | font_size=36,
136 | )
137 | assert txt._font.family == "Times New Roman"
138 | assert txt._font.slant == "italic"
139 | assert txt._font.weight == "bold"
140 | assert txt._font.size == 36
141 |
142 |
143 | def test_align_and_anchor():
144 | positions = [
145 | ("left", "left", 1049),
146 | ("left", "center", 939),
147 | ("left", "right", 829),
148 | ("center", "left", 1061),
149 | ("center", "center", 951),
150 | ("center", "right", 841),
151 | ("right", "left", 1073),
152 | ("right", "center", 963),
153 | ("right", "right", 853),
154 | ]
155 | for align, anchor, target_x in positions:
156 | txt = eyekit.TextBlock(
157 | text=["The quick brown", "fox [jumps]{target} over", "the lazy dog"],
158 | position=(960, 540),
159 | font_face="Arial",
160 | font_size=30,
161 | align=align,
162 | anchor=anchor,
163 | )
164 | assert txt.align == align
165 | assert txt.anchor == anchor
166 | assert int(txt["target"].x) == target_x
167 |
168 |
169 | def test_right_to_left():
170 | txt = eyekit.TextBlock(
171 | text=["דג סקרן שט לו בים זך,", "אך לפתע פגש חבורה", "נחמדה שצצה כך."],
172 | position=(960, 540),
173 | font_face="Raanana bold",
174 | font_size=100,
175 | right_to_left=True,
176 | anchor="center",
177 | )
178 | for word, (logical_word, display_word, x, y) in zip(
179 | txt.words(),
180 | [
181 | ("דג", "גד", 1334, 502),
182 | ("סקרן", "ןרקס", 1176, 502),
183 | ("שט", "טש", 997, 502),
184 | ("לו", "ול", 875, 502),
185 | ("בים", "םיב", 745, 502),
186 | ("זך", "ךז", 611, 502),
187 | ("אך", "ךא", 1321, 602),
188 | ("לפתע", "עתפל", 1143, 602),
189 | ("פגש", "שגפ", 945, 602),
190 | ("חבורה", "הרובח", 732, 602),
191 | ("נחמדה", "הדמחנ", 1254, 702),
192 | ("שצצה", "הצצש", 1002, 702),
193 | ("כך", "ךכ", 824, 702),
194 | ],
195 | ):
196 | assert word.text == logical_word
197 | assert word.display_text == display_word
198 | assert int(word.x) == x
199 | assert int(word.y) == y
200 |
201 |
202 | def test_custom_padding():
203 | txt = eyekit.TextBlock(
204 | text=["The quick brown", "fox [jumps]{target} over", "the lazy dog"],
205 | position=(960, 540),
206 | font_face="Arial",
207 | font_size=30,
208 | autopad=False,
209 | )
210 | assert txt["target"].padding == [0, 0, 0, 0]
211 | txt["target"].set_padding(top=10, bottom=10, left=10, right=10)
212 | assert txt["target"].padding == [10, 10, 10, 10]
213 | txt["target"].adjust_padding(top=2, bottom=2, left=-2, right=-2)
214 | assert txt["target"].padding == [12, 12, 8, 8]
215 |
--------------------------------------------------------------------------------
/eyekit/tools.py:
--------------------------------------------------------------------------------
1 | """
2 | Miscellaneous utility functions.
3 | """
4 |
5 | import pathlib as _pathlib
6 | import cairocffi as _cairo
7 | from .text import _is_TextBlock, TextBlock as _TextBlock
8 | from .vis import Image as _Image
9 | from .io import save as _save
10 |
11 |
12 | def create_stimuli(
13 | input_texts,
14 | output_stimuli,
15 | *,
16 | screen_width: int,
17 | screen_height: int,
18 | color: str = "black",
19 | background_color: str = "white",
20 | **kwargs,
21 | ):
22 | """
23 | Create PNG stimuli for a set of texts. This may be useful if you want to
24 | use Eyekit to create your experimental stimuli. If `input_texts` is a
25 | string, it will be treated as a path to a directory of .txt files. If
26 | `input_texts` is a list, it is assumed to be a list of texts (strings or
27 | lists of strings). If `input_texts` is a dictionary, it should be of the
28 | form `{'stim_id': TextBlock, ...}`. `output_stimuli` must be a path to a
29 | directory. The `screen_width` and `screen_height` must be specified and
30 | should match the final display size of the experimental stimuli
31 | (typically, the experimental computer's screen resolution). A `color` and
32 | `background_color` can optionally be specified (defaulting to black on
33 | white). Additional arguments are passed to `TextBlock`, and may include
34 | `position`, `font_face`, `font_size`, and `line_height`
35 | (see `eyekit.text.TextBlock` for more info). The function will also store
36 | the texts as `TextBlock` objects in `stimuli.json` for use at the
37 | analysis stage.
38 |
39 | """
40 | stimuli = {}
41 | if isinstance(input_texts, dict):
42 | stimuli = input_texts
43 | elif isinstance(input_texts, str):
44 | input_texts = _pathlib.Path(input_texts)
45 | if not input_texts.exists() or not input_texts.is_dir():
46 | raise ValueError(
47 | f"Specified input_texts {input_texts} is not an existing directory"
48 | )
49 | for file_path in input_texts.iterdir():
50 | if file_path.suffix != ".txt":
51 | continue
52 | with open(file_path) as file:
53 | text = [line.strip() for line in file]
54 | stimuli[file_path.stem] = _TextBlock(text, **kwargs)
55 | elif isinstance(input_texts, list):
56 | for i, text in enumerate(input_texts):
57 | if isinstance(text, str):
58 | text = text.split("\n")
59 | stimuli[f"text{i}"] = _TextBlock(text, **kwargs)
60 | else:
61 | raise ValueError(
62 | "Cannot interpret input_texts. Should be path, list of texts, or dictionary of TextBlocks."
63 | )
64 | output_stimuli = _pathlib.Path(output_stimuli)
65 | if output_stimuli.exists() and not output_stimuli.is_dir():
66 | raise ValueError(
67 | f"Specified output_stimuli {output_stimuli} is not a directory"
68 | )
69 | if not output_stimuli.exists():
70 | output_stimuli.mkdir()
71 | for stim_id, text_block in stimuli.items():
72 | img = _Image(screen_width, screen_height)
73 | img.set_background_color(background_color)
74 | img.draw_text_block(text_block, color=color)
75 | img.save(output_stimuli / f"{stim_id}.png")
76 | _save(stimuli, output_stimuli / "stimuli.json")
77 |
78 |
79 | def align_to_screenshot(
80 | text_block,
81 | screenshot_path,
82 | *,
83 | output_path=None,
84 | show_text: bool = True,
85 | show_guide_lines: bool = True,
86 | show_bounding_boxes: bool = False,
87 | ):
88 | """
89 | Given a `eyekit.text.TextBlock` and the path to a PNG screenshot file,
90 | produce an image showing the original screenshot overlaid with the text
91 | block (shown in green). If no output path is provided, the output image is
92 | written to the same directory as the screenshot file. This is useful for
93 | establishing `eyekit.text.TextBlock` parameters (position, font size,
94 | etc.) that match what participants actually saw in your experiment.
95 | """
96 | _is_TextBlock(text_block)
97 | screenshot_path = _pathlib.Path(screenshot_path)
98 | if not screenshot_path.exists():
99 | raise ValueError(f"Screenshot file does not exist: {screenshot_path}")
100 | if screenshot_path.suffix[1:].upper() != "PNG":
101 | raise ValueError("Screenshot must be PNG file")
102 | surface = _cairo.ImageSurface(_cairo.FORMAT_ARGB32, 1, 1).create_from_png(
103 | str(screenshot_path)
104 | )
105 | context = _cairo.Context(surface)
106 | screen_width = surface.get_width()
107 | screen_height = surface.get_height()
108 | context.set_source_rgb(0.60392, 0.80392, 0.19607)
109 | context.set_font_face(text_block._font.face)
110 | context.set_font_size(text_block._font.size)
111 | if show_guide_lines:
112 | context.set_line_width(2)
113 | context.move_to(text_block.position[0], 0)
114 | context.line_to(text_block.position[0], screen_height)
115 | context.stroke()
116 | for line in text_block.lines():
117 | if show_guide_lines:
118 | context.move_to(0, line.baseline)
119 | context.line_to(screen_width, line.baseline)
120 | context.stroke()
121 | context.set_dash([8, 4])
122 | if show_text:
123 | context.move_to(line._x_tl, line.baseline) # _x_tl is unpadded x_tl
124 | context.show_text(line.text)
125 | if show_bounding_boxes:
126 | context.set_dash([])
127 | for word in text_block.words():
128 | context.rectangle(*word.box)
129 | context.stroke()
130 | if output_path is None:
131 | output_path = screenshot_path.parent / f"{screenshot_path.stem}_eyekit.png"
132 | else:
133 | output_path = _pathlib.Path(output_path)
134 | if not output_path.parent.exists():
135 | raise ValueError(f"Output path does not exist: {output_path.parent}")
136 | if output_path.suffix[1:].upper() != "PNG":
137 | raise ValueError("Output must be PNG file")
138 | surface.write_to_png(str(output_path))
139 |
140 |
141 | def font_size_at_72dpi(font_size, at_dpi: int = 96) -> float:
142 | """
143 | Convert a font size at some dpi to the equivalent font size at 72dpi.
144 | Typically, this can be used to convert a Windows-style 96dpi font size to
145 | the equivalent size at 72dpi.
146 | """
147 | return font_size * at_dpi / 72
148 |
149 |
150 | # DEPRECATED FUNCTIONS TO BE REMOVED IN THE FUTURE
151 |
152 |
153 | def discard_short_fixations(fixation_sequence, threshold=50): # pragma: no cover
154 | """
155 | **Deprecated in 0.4 and removed in 0.6.** Use
156 | `eyekit.fixation.FixationSequence.discard_short_fixations()`.
157 | """
158 | raise NotImplementedError(
159 | "eyekit.tools.discard_short_fixations() has been removed, use FixationSequence.discard_short_fixations() instead"
160 | )
161 |
162 |
163 | def discard_out_of_bounds_fixations(
164 | fixation_sequence, text_block, threshold=100
165 | ): # pragma: no cover
166 | """
167 | **Deprecated in 0.4 and removed in 0.6.** Use
168 | `eyekit.fixation.FixationSequence.discard_out_of_bounds_fixations()`.
169 | """
170 | raise NotImplementedError(
171 | "eyekit.tools.discard_out_of_bounds_fixations() has been removed, use FixationSequence.discard_out_of_bounds_fixations() instead"
172 | )
173 |
174 |
175 | def snap_to_lines(
176 | fixation_sequence, text_block, method="warp", **kwargs
177 | ): # pragma: no cover
178 | """
179 | **Deprecated in 0.4 and removed in 0.6.** Use
180 | `eyekit.fixation.FixationSequence.snap_to_lines()`.
181 | """
182 | raise NotImplementedError(
183 | "eyekit.tools.snap_to_lines() has been removed, use FixationSequence.snap_to_lines() instead"
184 | )
185 |
--------------------------------------------------------------------------------
/tests/test_fixation.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | import eyekit
3 | from eyekit._snap import methods
4 |
5 |
6 | seq = eyekit.FixationSequence(
7 | [
8 | [106, 490, 0, 100],
9 | [190, 486, 100, 200],
10 | [230, 505, 200, 300],
11 | [298, 490, 300, 400],
12 | [361, 497, 400, 500],
13 | [430, 489, 500, 600],
14 | {"x": 450, "y": 505, "start": 600, "end": 700, "discarded": True},
15 | [492, 491, 700, 800],
16 | [562, 505, 800, 900],
17 | [637, 493, 900, 1000],
18 | [712, 497, 1000, 1100],
19 | [763, 487, 1100, 1200],
20 | ]
21 | )
22 |
23 | EXAMPLE_DATA = Path("example") / "example_data.json"
24 | EXAMPLE_TEXTS = Path("example") / "example_texts.json"
25 |
26 | sentence = "The quick brown fox [jump]{stem_1}[ed]{suffix_1} over the lazy dog."
27 | txt = eyekit.TextBlock(
28 | sentence, position=(100, 500), font_face="Times New Roman", font_size=36
29 | )
30 |
31 |
32 | def test_initialization():
33 | assert seq[0].x == 106
34 | assert seq[0].y == 490
35 | assert seq[0].start == 0
36 | assert seq[0].end == 100
37 | assert seq[0].pupil_size == None
38 | assert seq[0].duration == 100
39 | assert seq[6].discarded == True
40 | assert seq.start == 0
41 | assert seq.end == 1200
42 | assert seq.duration == 1200
43 |
44 |
45 | def test_serialize():
46 | data = seq.serialize()
47 | assert len(data) == len(seq)
48 | for fxn_a, fxn_b in zip(data, seq):
49 | assert fxn_a["x"] == fxn_b.x
50 | assert fxn_a["y"] == fxn_b.y
51 | assert fxn_a["start"] == fxn_b.start
52 | assert fxn_a["end"] == fxn_b.end
53 | if "discarded" in fxn_a:
54 | assert fxn_a["discarded"] == fxn_b.discarded
55 |
56 |
57 | def test_iter_pairs():
58 | answers = [
59 | (106, 190),
60 | (190, 230),
61 | (230, 298),
62 | (298, 361),
63 | (361, 430),
64 | (430, 450),
65 | (450, 492),
66 | (492, 562),
67 | (562, 637),
68 | (637, 712),
69 | (712, 763),
70 | ]
71 | for (fix1, fix2), answer in zip(seq.iter_pairs(), answers):
72 | assert (fix1.x, fix2.x) == answer
73 | answers = [
74 | (106, 190),
75 | (190, 230),
76 | (230, 298),
77 | (298, 361),
78 | (361, 430),
79 | (430, 492),
80 | (492, 562),
81 | (562, 637),
82 | (637, 712),
83 | (712, 763),
84 | ]
85 | for (fix1, fix2), answer in zip(seq.iter_pairs(False), answers):
86 | assert (fix1.x, fix2.x) == answer
87 |
88 |
89 | def test_discard_short_fixations():
90 | seq = eyekit.FixationSequence(
91 | [
92 | [106, 490, 0, 100],
93 | [190, 486, 100, 200],
94 | [230, 505, 200, 240],
95 | [298, 490, 300, 400],
96 | [361, 497, 400, 500],
97 | [430, 489, 500, 600],
98 | [450, 505, 600, 700],
99 | [492, 491, 700, 800],
100 | [562, 505, 800, 820],
101 | [637, 493, 900, 1000],
102 | [712, 497, 1000, 1100],
103 | [763, 487, 1100, 1200],
104 | ]
105 | )
106 | seq.discard_short_fixations(50)
107 | assert seq[2].discarded == True
108 | assert seq[8].discarded == True
109 | seq.purge()
110 | assert len(seq) == 10
111 |
112 |
113 | def test_discard_long_fixations():
114 | seq = eyekit.FixationSequence(
115 | [
116 | [106, 490, 0, 100],
117 | [190, 486, 100, 200],
118 | [230, 505, 200, 300],
119 | [298, 490, 300, 900],
120 | [361, 497, 900, 1000],
121 | [430, 489, 1000, 1100],
122 | [450, 505, 1100, 1200],
123 | [492, 491, 1200, 1300],
124 | [562, 505, 1300, 1400],
125 | [637, 493, 1400, 2000],
126 | [712, 497, 2000, 2100],
127 | [763, 487, 2100, 2200],
128 | ]
129 | )
130 | seq.discard_long_fixations(500)
131 | assert seq[3].discarded == True
132 | assert seq[9].discarded == True
133 | seq.purge()
134 | assert len(seq) == 10
135 |
136 |
137 | def test_discard_out_of_bounds_fixations():
138 | seq = eyekit.FixationSequence(
139 | [
140 | [106, 490, 0, 100],
141 | [190, 486, 100, 200],
142 | [230, 505, 200, 300],
143 | [298, 490, 300, 400],
144 | [1, 1, 400, 500],
145 | [430, 489, 500, 600],
146 | [450, 505, 600, 700],
147 | [492, 491, 700, 800],
148 | [562, 505, 800, 900],
149 | [1000, 1000, 900, 1000],
150 | [712, 497, 1000, 1100],
151 | [763, 487, 1100, 1200],
152 | ]
153 | )
154 | seq.discard_out_of_bounds_fixations(txt, 100)
155 | assert seq[4].discarded == True
156 | assert seq[9].discarded == True
157 | seq.purge()
158 | assert len(seq) == 10
159 |
160 |
161 | def test_segment():
162 | seq = eyekit.FixationSequence(
163 | [
164 | [106, 490, 0, 100],
165 | [190, 486, 100, 200],
166 | [230, 505, 200, 300],
167 | [298, 490, 300, 400],
168 | [1, 1, 400, 500],
169 | [430, 489, 500, 600],
170 | [450, 505, 600, 700],
171 | [492, 491, 700, 800],
172 | [562, 505, 800, 900],
173 | [1000, 1000, 900, 1000],
174 | [712, 497, 1000, 1100],
175 | [763, 487, 1100, 1200],
176 | ]
177 | )
178 | subseqs = seq.segment([(0, 600), (600, 1200)])
179 | assert len(subseqs) == 2
180 | assert subseqs[0][0].x == 106
181 | assert subseqs[0][-1].x == 430
182 | assert subseqs[1][0].x == 450
183 | assert subseqs[1][-1].x == 763
184 |
185 |
186 | def test_snap_to_lines_single():
187 | seq = eyekit.FixationSequence(
188 | [
189 | [106, 490, 0, 100],
190 | [190, 486, 100, 200],
191 | [230, 505, 200, 300],
192 | [298, 490, 300, 400],
193 | [361, 497, 400, 500],
194 | [430, 489, 500, 600],
195 | [450, 505, 600, 700],
196 | [492, 491, 700, 800],
197 | [562, 505, 800, 900],
198 | [637, 493, 900, 1000],
199 | [712, 497, 1000, 1100],
200 | [763, 487, 1100, 1200],
201 | ]
202 | )
203 | seq.snap_to_lines(txt)
204 | midline = int(txt.midlines[0])
205 | for fixation in seq:
206 | assert fixation.y == midline
207 |
208 |
209 | def test_snap_to_lines_multi():
210 | example_data = eyekit.io.load(EXAMPLE_DATA)
211 | example_texts = eyekit.io.load(EXAMPLE_TEXTS)
212 | seq = example_data["trial_0"]["fixations"]
213 | txt = example_texts[example_data["trial_0"]["passage_id"]]["text"]
214 | midlines = [int(midline) for midline in txt.midlines]
215 | for method in methods:
216 | seq_copy = seq.copy()
217 | seq_copy.snap_to_lines(txt, method)
218 | for fixation in seq_copy:
219 | assert fixation.y in midlines
220 | delta, kappa = seq.snap_to_lines(txt, method=["chain", "cluster", "warp"])
221 | for fixation in seq:
222 | assert fixation.y in midlines
223 | assert str(delta)[:4] == "19.6"
224 | assert str(kappa)[:4] == "0.96"
225 |
226 |
227 | def test_snap_to_lines_RtL():
228 | txt = eyekit.TextBlock(
229 | text=["דג סקרן שט לו בים זך,", "אך לפתע פגש חבורה", "נחמדה שצצה כך."],
230 | font_face="Arial",
231 | line_height=180,
232 | font_size=150,
233 | position=(1627, 440),
234 | right_to_left=True,
235 | )
236 | seq = eyekit.FixationSequence(
237 | [
238 | (1609, 387, 0, 100),
239 | (1329, 397, 100, 200),
240 | (1483, 358, 200, 300),
241 | (1010, 401, 300, 400),
242 | (903, 393, 400, 500),
243 | (701, 340, 500, 600),
244 | (495, 406, 600, 700),
245 | (1612, 595, 700, 800),
246 | (1245, 575, 800, 900),
247 | (1410, 592, 900, 1000),
248 | (1001, 600, 1000, 1100),
249 | (701, 573, 1100, 1200),
250 | (784, 544, 1200, 1300),
251 | (1469, 828, 1300, 1400),
252 | (1521, 775, 1400, 1500),
253 | (1043, 755, 1500, 1600),
254 | (1202, 742, 1600, 1700),
255 | (777, 714, 1700, 1800),
256 | ]
257 | )
258 | correct_Ys = [
259 | 401,
260 | 401,
261 | 401,
262 | 401,
263 | 401,
264 | 401,
265 | 401,
266 | 581,
267 | 581,
268 | 581,
269 | 581,
270 | 581,
271 | 581,
272 | 761,
273 | 761,
274 | 761,
275 | 761,
276 | 761,
277 | ]
278 | for method in methods:
279 | seq_copy = seq.copy()
280 | seq_copy.snap_to_lines(txt, method)
281 | for fixation, correct_y in zip(seq_copy, correct_Ys):
282 | assert fixation.y == correct_y
283 |
284 |
285 | def test_sequence_modifications():
286 | seq = eyekit.FixationSequence(
287 | [
288 | [106, 490, 0, 100],
289 | [190, 486, 100, 200],
290 | ]
291 | )
292 | seq[0].x = 107
293 | assert seq[0].x == 107
294 | seq[0].y = 491
295 | assert seq[0].y == 491
296 | seq[0].start = 1
297 | assert seq[0].start == 1
298 | seq[0].end = 99
299 | assert seq[0].end == 99
300 | seq[0].shift_x(10)
301 | assert seq[0].x == 117
302 | seq[0].shift_x(-20)
303 | assert seq[0].x == 97
304 | seq[0].shift_y(10)
305 | assert seq[0].y == 501
306 | seq[0].shift_y(-20)
307 | assert seq[0].y == 481
308 | seq[0].shift_time(+1)
309 | assert seq[0].start == 2
310 | assert seq[0].end == 100
311 | seq.shift_x(10)
312 | assert seq[0].x == 107
313 | assert seq[1].x == 200
314 | seq.shift_y(10)
315 | assert seq[0].y == 491
316 | assert seq[1].y == 496
317 | seq.shift_time(10)
318 | assert seq[0].start == 12
319 | assert seq[0].end == 110
320 | assert seq[1].start == 110
321 | assert seq[1].end == 210
322 | seq.shift_start_time_to_zero()
323 | assert seq[0].start == 0
324 | assert seq[0].end == 98
325 | assert seq.start == 0
326 | assert seq.end == 198
327 |
328 |
329 | def test_empty_sequence():
330 | seq = eyekit.FixationSequence([])
331 | assert seq.start == 0
332 | assert seq.end == 0
333 | assert seq.duration == 0
334 |
335 |
336 | def test_fixation_sequence_concatenation():
337 | seq1 = eyekit.FixationSequence(
338 | [
339 | [106, 490, 0, 100],
340 | [190, 486, 100, 200],
341 | ]
342 | )
343 | seq2 = eyekit.FixationSequence(
344 | [
345 | [230, 505, 200, 300],
346 | [298, 490, 300, 400],
347 | ]
348 | )
349 | seq = seq1 + seq2
350 | assert seq.start == 0
351 | assert seq.end == 400
352 | for fixation, correct_x in zip(seq, [106, 190, 230, 298]):
353 | assert fixation.x == correct_x
354 |
355 |
356 | def test_tagging():
357 | seq = eyekit.FixationSequence(
358 | [
359 | [106, 490, 0, 100],
360 | [190, 486, 100, 200],
361 | ]
362 | )
363 | seq[0].add_tag("first fixation")
364 | seq[-1].add_tag("last fixation")
365 | assert seq[0].has_tag("first fixation") == True
366 | assert seq[-1].has_tag("last fixation") == True
367 | assert seq[0]["first fixation"] == True
368 | assert seq[-1]["last fixation"] == True
369 | del seq[0]["first fixation"]
370 | assert seq[0].has_tag("first fixation") == False
371 | seq[0]["fixation_number"] = 1
372 | seq[1]["fixation_number"] = 2
373 | assert seq[0].has_tag("fixation_number") == True
374 | assert seq[0].has_tag("fixation_number") == True
375 | assert seq[0]["fixation_number"] == 1
376 | assert seq[1]["fixation_number"] == 2
377 |
--------------------------------------------------------------------------------
/eyekit/io.py:
--------------------------------------------------------------------------------
1 | """
2 | Functions for reading and writing data.
3 | """
4 |
5 | import re as _re
6 | import json as _json
7 | from types import GeneratorType as _GeneratorType
8 | from .fixation import FixationSequence as _FixationSequence
9 | from .text import TextBlock as _TextBlock, InterestArea as _InterestArea
10 |
11 |
12 | def load(file_path):
13 | """
14 | Read in a JSON file. `eyekit.fixation.FixationSequence`,
15 | `eyekit.text.TextBlock`, and `eyekit.text.InterestArea` objects are
16 | automatically decoded and instantiated.
17 | """
18 | with open(str(file_path), encoding="utf-8") as file:
19 | data = _json.load(file, object_hook=_eyekit_decoder)
20 | return data
21 |
22 |
23 | def save(data, file_path, *, compress: bool = False):
24 | """
25 | Write arbitrary data to a JSON file. If `compress` is `True`, the file is
26 | written in the most compact way; if `False`, the file will be more human
27 | readable. `eyekit.fixation.FixationSequence`, `eyekit.text.TextBlock`,
28 | and `eyekit.text.InterestArea` objects are automatically encoded.
29 | """
30 | if compress:
31 | indent = None
32 | separators = (",", ":")
33 | else:
34 | indent = "\t"
35 | separators = (",", ": ")
36 | with open(str(file_path), "w", encoding="utf-8") as file:
37 | _json.dump(
38 | data,
39 | file,
40 | default=_eyekit_encoder,
41 | ensure_ascii=False,
42 | indent=indent,
43 | separators=separators,
44 | )
45 |
46 |
47 | def import_asc(
48 | file_path,
49 | *,
50 | variables: list = [],
51 | placement_of_variables: str = "after_end",
52 | import_samples: bool = False,
53 | encoding: str = "utf-8",
54 | ) -> list:
55 | """
56 | Import data from an ASC file produced from an SR Research EyeLink device
57 | (you will first need to use SR Research's Edf2asc tool to convert your
58 | original EDF files to ASC). The importer will extract all trials from the
59 | ASC file, where a trial is defined as a sequence of fixations (EFIX lines)
60 | that occur inside a START–END block. Optionally, the importer can extract
61 | user-defined variables and ms-by-ms samples. For example, if your ASC
62 | file contains messages like this:
63 |
64 | ```
65 | MSG 4244101 trial_type practice
66 | MSG 4244101 passage_id 1
67 | MSG 4244592 stim_onset
68 | ```
69 |
70 | then you could extract the variables `"trial_type"` and `"passage_id"`. A
71 | variable is some string that is followed by a space; anything that
72 | follows this space is the variable's value. If the variable is not
73 | followed by a value (e.g., `"stim_onset"` above), then the time of the
74 | message is recorded as its value; this can be useful for recording the
75 | precise timing of an event. By default, the importer looks for variables
76 | that follow the END tag. However, if your variables are placed before the
77 | START tag, then set the `placement_of_variables` argument to
78 | `"before_start"`. If unsure, you should first inspect your ASC file to
79 | see what messages you wrote to the data stream and where they are placed.
80 | The importer will return a list of dictionaries, where each dictionary
81 | represents a single trial and contains the fixations along with any other
82 | extracted variables. For example:
83 |
84 | ```
85 | [
86 | {
87 | "trial_type": "practice",
88 | "passage_id": "1",
89 | "stim_onset": 4244592,
90 | "fixations": FixationSequence[...]
91 | },
92 | {
93 | "trial_type": "test",
94 | "passage_id": "2",
95 | "stim_onset": 4256311,
96 | "fixations": FixationSequence[...]
97 | }
98 | ]
99 | ```
100 | """
101 | msg_regex = _re.compile( # regex for parsing variables from MSG lines
102 | r"^MSG\s+(?P