├── 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