gazeMapper 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Diederick Niehorster
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ include requirements.txt
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.1
2
+ Name: gazeMapper
3
+ Version: 0.5.0
4
+ Summary: Automated world-based analysis of wearable eye tracker data.
5
+ Home-page: https://github.com/dcnieho/gazeMapper
6
+ Author: Diederick Niehorster
7
+ Author-email: diederick_c.niehorster@humlab.lu.se
8
+ License: MIT License
9
+
10
+ Copyright (c) 2024 Diederick Niehorster
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+
30
+ Project-URL: Source Code, https://github.com/dcnieho/gazeMapper
31
+ Classifier: Programming Language :: Python :: 3.10
32
+ Classifier: Programming Language :: Python :: 3.11
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Requires-Python: >=3.10
36
+ Description-Content-Type: text/markdown
37
+ License-File: LICENSE
38
+ Requires-Dist: glassesTools[GUI]==1.4.0
39
+ Requires-Dist: glassesValidator==2.0.0
40
+
41
+ [![Downloads](https://static.pepy.tech/badge/gazeMapper)](https://pepy.tech/project/gazeMapper)
42
+ [![Citation Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.juleskreuer.eu%2Fcitation-badge.php%3Fshield%26doi%3D10.3758%2Fs13428-023-02105-5&color=blue)](https://scholar.google.com/citations?view_op=view_citation&citation_for_view=uRUYoVgAAAAJ:uWQEDVKXjbEC)
43
+ [![PyPI Latest Release](https://img.shields.io/pypi/v/gazeMapper.svg)](https://pypi.org/project/gazeMapper/)
44
+ [![image](https://img.shields.io/pypi/pyversions/gazeMapper.svg)](https://pypi.org/project/gazeMapper/)
45
+
46
+ # gazeMapper v0.5.0
47
+ Tool for automated world-based analysis of wearable eye tracker data.
48
+
49
+ If you use this tool or any of the code in this repository, please cite:<br>
50
+ Niehorster, D.C., Hessels, R.S., Nyström, M., Benjamins, J.S. and Hooge, I.T.C. (in prep). gazeMapper: A tool for automated world-based analysis of wearable eye tracker data
51
+
52
+ # How to acquire
53
+ GlassesValidator is available from `https://github.com/dcnieho/gazeMapper`, and supports Python 3.10 and 3.11 on Windows, MacOS and Linux.
54
+
55
+ For Windows users who wish to use the gazeMapper GUI, the easiest way to acquire gazeMapper is to [download
56
+ a standalone executable](https://github.com/dcnieho/gazeMapper/releases/latest). The standalone executable is not
57
+ available for MacOS or Linux.
58
+
59
+ For users on Windows, Mac or Linux who wish to use gazeMapper in their Python code, the easiest way to acquire
60
+ gazeMapper is to install it directly into your Python distribution using the command
61
+ `python -m pip install gazeMapper`. Should that fail, this repository is pip-installable as well:
62
+ `python -m pip install git+https://github.com/dcnieho/gazeMapper.git#egg=gazeMapper`. NB: on some platforms you may have
63
+ to replace `python` with `python3` in the above command lines.
64
+
65
+ Once pip-installed in your Python distribution, there are three ways to run the GUI on any of the supported operating systems:
66
+ 1. Directly in the terminal of your operating system, type `gazeMapper` and run it.
67
+ 2. Open a Python console. From such a console, running the GUI requires only the following two lines of code:
68
+ ```python
69
+ import gazeMapper
70
+ gazeMapper.GUI.run()
71
+ ```
72
+ 3. If you run the gazeMapper's GUI from a script, make sure to wrap your script in `if __name__=="__main__"`. This is required for correct operation from a script because the GUI uses multiprocessing functionality. Do as follows:
73
+ ```python
74
+ if __name__=="__main__":
75
+ import gazeMapper
76
+ gazeMapper.GUI.run()
77
+ ```
@@ -0,0 +1,37 @@
1
+ [![Downloads](https://static.pepy.tech/badge/gazeMapper)](https://pepy.tech/project/gazeMapper)
2
+ [![Citation Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.juleskreuer.eu%2Fcitation-badge.php%3Fshield%26doi%3D10.3758%2Fs13428-023-02105-5&color=blue)](https://scholar.google.com/citations?view_op=view_citation&citation_for_view=uRUYoVgAAAAJ:uWQEDVKXjbEC)
3
+ [![PyPI Latest Release](https://img.shields.io/pypi/v/gazeMapper.svg)](https://pypi.org/project/gazeMapper/)
4
+ [![image](https://img.shields.io/pypi/pyversions/gazeMapper.svg)](https://pypi.org/project/gazeMapper/)
5
+
6
+ # gazeMapper v0.5.0
7
+ Tool for automated world-based analysis of wearable eye tracker data.
8
+
9
+ If you use this tool or any of the code in this repository, please cite:<br>
10
+ Niehorster, D.C., Hessels, R.S., Nyström, M., Benjamins, J.S. and Hooge, I.T.C. (in prep). gazeMapper: A tool for automated world-based analysis of wearable eye tracker data
11
+
12
+ # How to acquire
13
+ GlassesValidator is available from `https://github.com/dcnieho/gazeMapper`, and supports Python 3.10 and 3.11 on Windows, MacOS and Linux.
14
+
15
+ For Windows users who wish to use the gazeMapper GUI, the easiest way to acquire gazeMapper is to [download
16
+ a standalone executable](https://github.com/dcnieho/gazeMapper/releases/latest). The standalone executable is not
17
+ available for MacOS or Linux.
18
+
19
+ For users on Windows, Mac or Linux who wish to use gazeMapper in their Python code, the easiest way to acquire
20
+ gazeMapper is to install it directly into your Python distribution using the command
21
+ `python -m pip install gazeMapper`. Should that fail, this repository is pip-installable as well:
22
+ `python -m pip install git+https://github.com/dcnieho/gazeMapper.git#egg=gazeMapper`. NB: on some platforms you may have
23
+ to replace `python` with `python3` in the above command lines.
24
+
25
+ Once pip-installed in your Python distribution, there are three ways to run the GUI on any of the supported operating systems:
26
+ 1. Directly in the terminal of your operating system, type `gazeMapper` and run it.
27
+ 2. Open a Python console. From such a console, running the GUI requires only the following two lines of code:
28
+ ```python
29
+ import gazeMapper
30
+ gazeMapper.GUI.run()
31
+ ```
32
+ 3. If you run the gazeMapper's GUI from a script, make sure to wrap your script in `if __name__=="__main__"`. This is required for correct operation from a script because the GUI uses multiprocessing functionality. Do as follows:
33
+ ```python
34
+ if __name__=="__main__":
35
+ import gazeMapper
36
+ gazeMapper.GUI.run()
37
+ ```
@@ -0,0 +1,6 @@
1
+ [build-system]
2
+ requires = [
3
+ "setuptools>=42",
4
+ "wheel"
5
+ ]
6
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,2 @@
1
+ glassesTools[GUI]==1.4.0 # pulls in various other dependencies such as opencv, imgui_bundle, numpy, pandas
2
+ glassesValidator==2.0.0
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,50 @@
1
+ import setuptools
2
+ import runpy
3
+
4
+ info = runpy.run_path("src/gazeMapper/version.py")
5
+
6
+ with open('README.md') as f:
7
+ readme = f.read()
8
+
9
+ with open('LICENSE') as f:
10
+ license = f.read()
11
+
12
+ with open('requirements.txt') as f:
13
+ requirements = f.read().splitlines()
14
+
15
+ required = []
16
+
17
+ # get required packages
18
+ for line in requirements:
19
+ required.append(line)
20
+
21
+ setuptools.setup(
22
+ name=info['__title__'],
23
+ version=info['__version__'],
24
+ author=info['__author__'],
25
+ author_email=info['__email__'],
26
+ description=info['__description__'],
27
+ long_description=readme,
28
+ long_description_content_type="text/markdown",
29
+ url=info['__url__'],
30
+ project_urls={
31
+ "Source Code": info['__url__'],
32
+ },
33
+ classifiers=[
34
+ "Programming Language :: Python :: 3.10",
35
+ "Programming Language :: Python :: 3.11",
36
+ "License :: OSI Approved :: MIT License",
37
+ "Operating System :: OS Independent",
38
+ ],
39
+ license=license,
40
+ package_dir={"": "src"},
41
+ packages=setuptools.find_packages(where="src"),
42
+ include_package_data=True,
43
+ python_requires=">=3.10",
44
+ install_requires=required,
45
+ # entry_points={
46
+ # "gui_scripts": [
47
+ # "gazeMapper = gazeMapper.GUI:run",
48
+ # ],
49
+ # },
50
+ )
File without changes
@@ -0,0 +1,79 @@
1
+ import dataclasses
2
+ import pathlib
3
+ import typing
4
+ import shutil
5
+ import json
6
+
7
+ from glassesTools import utils, video_utils
8
+
9
+
10
+ @dataclasses.dataclass
11
+ class Recording:
12
+ default_json_file_name : typing.ClassVar[str] = 'recording_info.json'
13
+
14
+ name : str
15
+ video_file : str
16
+ source_directory : pathlib.Path
17
+ working_directory : pathlib.Path = ""
18
+ duration : int = None
19
+
20
+ def store_as_json(self, path: str | pathlib.Path):
21
+ path = pathlib.Path(path)
22
+ if path.is_dir():
23
+ path /= self.default_json_file_name
24
+ with open(path, 'w') as f:
25
+ # remove any crap potentially added by subclasses
26
+ to_dump = dataclasses.asdict(self)
27
+ to_dump = {k:to_dump[k] for k in to_dump if k in Recording.__annotations__}
28
+ # dump to file
29
+ json.dump(to_dump, f, cls=utils.CustomTypeEncoder, indent=2)
30
+
31
+ @staticmethod
32
+ def load_from_json(path: str | pathlib.Path):
33
+ path = pathlib.Path(path)
34
+ if path.is_dir():
35
+ path /= Recording.default_json_file_name
36
+ with open(path, 'r') as f:
37
+ return Recording(**json.load(f, object_hook=utils.json_reconstitute))
38
+
39
+
40
+ def get_video_path(self):
41
+ vid = self.working_directory / self.video_file
42
+ if not vid.is_file():
43
+ vid = self.source_directory / self.video_file
44
+ return vid
45
+
46
+
47
+
48
+ def do_import(rec_info: Recording, cam_cal_file: str|pathlib.Path=None, copy_video=True):
49
+ assert rec_info.working_directory
50
+ rec_info.working_directory = pathlib.Path(rec_info.working_directory)
51
+ ifile = (rec_info.source_directory / rec_info.video_file)
52
+ assert ifile.is_file()
53
+ print(f'processing: {rec_info.video_file} -> {rec_info.working_directory}')
54
+
55
+ if not rec_info.working_directory.is_dir():
56
+ rec_info.working_directory.mkdir()
57
+
58
+ if copy_video:
59
+ ofile = rec_info.working_directory / rec_info.video_file
60
+ print(' Copy video file...')
61
+ shutil.copy2(ifile, ofile)
62
+
63
+ # also get its calibration
64
+ print(' Getting camera calibration...')
65
+ if cam_cal_file is not None:
66
+ shutil.copy2(str(cam_cal_file), str(rec_info.working_directory / 'calibration.xml'))
67
+ else:
68
+ print(' !! No camera calibration provided! Defaulting to hardcoded')
69
+
70
+ # and frame timestamps
71
+ print(' Getting frame timestamps...')
72
+ ts = video_utils.getFrameTimestampsFromVideo(rec_info.get_video_path())
73
+ ts.to_csv(str(rec_info.working_directory / 'frameTimestamps.tsv'), sep='\t')
74
+ rec_info.duration = (ts.timestamp.iat[-1]-ts.timestamp.iat[0])/1000. # ms -> s
75
+
76
+ # store recording info to folder
77
+ rec_info.store_as_json(rec_info.working_directory)
78
+
79
+ return rec_info
@@ -0,0 +1,82 @@
1
+ import pathlib
2
+ import json
3
+ from typing import Any
4
+
5
+ from glassesTools import utils
6
+
7
+ from . import episode, plane, session
8
+
9
+
10
+ class Marker:
11
+ def __init__(self, id:int, size:float):
12
+ self.id = id
13
+ self.size = size
14
+ utils.register_type(utils.CustomTypeEntry(Marker,'__config.Marker__',lambda x: {'id': x.id, 'size': x.size}, lambda x: Marker(**x)))
15
+
16
+ def get_marker_dict_from_list(markers: list[Marker]) -> dict[int,dict[str,Any]]:
17
+ out = {}
18
+ for m in markers:
19
+ out[m.id] = {'marker_size': m.size}
20
+ return out
21
+
22
+ class Study:
23
+ default_json_file_name = 'study_def.json'
24
+
25
+ def __init__(self, session_def: session.SessionDefinition, planes: list[plane.Definition], planes_per_interval: dict[episode.Event,list[str]], individual_markers: list[Marker], working_directory: str|pathlib.Path):
26
+ self.session_def = session_def
27
+ self.planes = planes
28
+ self.planes_per_interval= planes_per_interval
29
+ self.working_directory = working_directory
30
+ self.individual_markers = individual_markers
31
+
32
+ self._check_planes_per_interval()
33
+
34
+ def _check_planes_per_interval(self):
35
+ for e in self.planes_per_interval:
36
+ for p in self.planes_per_interval[e]:
37
+ if not any([p==pl.name for pl in self.planes]):
38
+ raise ValueError(f'plane {p} not known')
39
+
40
+ def store_as_json(self, path: str | pathlib.Path):
41
+ path = pathlib.Path(path)
42
+ # this stores only the planes_per_interval variable to json, rest is read from other files
43
+ # instead to remain flexible and make it easy for users to rename, etc
44
+ d_path = path / self.default_json_file_name
45
+ with open(d_path, 'w') as f:
46
+ to_dump = {k:getattr(self,k) for k in ['planes_per_interval','individual_markers']} # only these fields. Name will be populated from name of session/provided folder, recordings from each subfolder in the session/provided folder, and working_directory as the provided path
47
+ to_dump['planes_per_interval'] = [(k, to_dump['planes_per_interval'][k]) for k in to_dump['planes_per_interval']] # pack as list of tuples for storage
48
+ # dump to file
49
+ json.dump(to_dump, f, cls=utils.CustomTypeEncoder, indent=2)
50
+ # this doesn't story any files itself, but triggers the contained info to be stored
51
+ self.session_def.store_as_json(self.working_directory / 'session_def.json')
52
+ for p in self.planes:
53
+ p_dir = self.working_directory / p.name
54
+ if not p_dir.is_dir():
55
+ p_dir.mkdir()
56
+ p.store_as_json(p_dir)
57
+
58
+ @staticmethod
59
+ def load_from_json(path: str | pathlib.Path) -> 'Study':
60
+ path = pathlib.Path(path)
61
+ # get kwds
62
+ d_path = path / Study.default_json_file_name
63
+ with open(d_path, 'r') as f:
64
+ kwds = json.load(f, object_hook=utils.json_reconstitute)
65
+ kwds['planes_per_interval'] = {k:v for k,v in kwds['planes_per_interval']} # stored as list of tuples, unpack
66
+ # get session def
67
+ s_path = path / 'session_def.json'
68
+ if not s_path.is_file():
69
+ return None
70
+ sess_def = session.SessionDefinition.load_from_json(s_path)
71
+
72
+ # get planes
73
+ planes: list[plane.Definition] = []
74
+ for p_dir in path.iterdir():
75
+ if not p_dir.is_dir():
76
+ continue
77
+ p_file = p_dir / plane.Definition.default_json_file_name
78
+ if not p_file.is_file():
79
+ continue
80
+ planes.append(plane.Definition.load_from_json(p_file))
81
+
82
+ return Study(sess_def, planes, working_directory=path, **kwds)
@@ -0,0 +1,106 @@
1
+ from enum import Enum, auto
2
+ import pathlib
3
+ import pandas as pd
4
+ from collections import defaultdict
5
+
6
+ from glassesTools import utils
7
+
8
+
9
+
10
+ class Type(Enum):
11
+ Point = auto()
12
+ Interval = auto()
13
+
14
+
15
+ class Event(utils.AutoName):
16
+ Validate = auto() # interval to be used for running glassesValidator
17
+ Sync_Camera = auto() # point to be used for synchronizing different cameras
18
+ Sync_VOR = auto() # episode to be used for VOR synchronization
19
+ Map = auto() # episode for which to map gaze to plane(s): output for files to be provided to user
20
+ events = [x.value for x in Event]
21
+ utils.register_type(utils.CustomTypeEntry(Event,'__enum.Event__',str, lambda x: getattr(Event, x.split('.')[1])))
22
+
23
+ type_map = {
24
+ Event.Validate : Type.Interval,
25
+ Event.Sync_Camera : Type.Point,
26
+ Event.Sync_VOR : Type.Interval,
27
+ Event.Map : Type.Interval,
28
+ }
29
+
30
+
31
+ class Episode:
32
+ def __init__(self, event:Event, start_frame:int, end_frame:int=None):
33
+ self.event = event
34
+ self.start_frame = start_frame
35
+
36
+ if type_map[event]==Type.Interval:
37
+ assert end_frame is not None, f"end frame expected for an interval-type episode ({event.value}), but not provided"
38
+ if type_map[event]==Type.Point:
39
+ assert end_frame is None, f"end frame provided but not expected for a point-type episode ({event.value})"
40
+ self.end_frame = end_frame
41
+
42
+
43
+ # for dealing with lists of events
44
+ def read_list_from_file(fileName: str|pathlib.Path) -> list[Episode]:
45
+ df = pd.read_csv(str(fileName), delimiter='\t', index_col=False, dtype=defaultdict(lambda: int, event=str, end_frame=pd.Int64Dtype()))
46
+ df['event'] = [Event(x) for x in df['event'].values]
47
+ df['end_frame'] = df['end_frame'].astype('object')
48
+ df.loc[pd.isnull(df['end_frame']),'end_frame'] = None # set missing to None
49
+ return [Episode(**kwargs) for kwargs in df.to_dict(orient='records')]
50
+
51
+ def write_list_to_file(episodes: list[Episode],
52
+ fileName: str|pathlib.Path):
53
+ if not episodes:
54
+ return
55
+
56
+ records = [{k:getattr(p,k) for k in vars(p) if not k.startswith('_')} for p in episodes]
57
+ df = pd.DataFrame.from_records(records)
58
+ df['event'] = [x.value for x in df['event'].values]
59
+
60
+ # keep only columns to be written out and order them correctly
61
+ df = df[['event','start_frame','end_frame']]
62
+ df.to_csv(str(fileName), index=False, sep='\t', na_rep='nan')
63
+
64
+
65
+ def get_empty_marker_dict() -> dict[Event,list[int]]:
66
+ return {e:[] for e in Event}
67
+
68
+ def list_to_marker_dict(episodes: list[Episode]) -> dict[Event,list[list[int]]]:
69
+ e_dict = get_empty_marker_dict()
70
+ for e in episodes:
71
+ if e.end_frame is not None:
72
+ e_dict[e.event].append([e.start_frame, e.end_frame])
73
+ else:
74
+ e_dict[e.event].append([e.start_frame])
75
+ return e_dict
76
+
77
+ def marker_dict_to_list(episodes: dict[Event,list[int]]|dict[Event,list[list[int]]]) -> list[Episode]:
78
+ e_list: list[Episode] = []
79
+ for e in episodes:
80
+ if not episodes[e]:
81
+ continue
82
+ if isinstance(episodes[e][0],list):
83
+ e_list.extend([Episode(e, *v) for v in episodes[e]])
84
+ else:
85
+ if type_map[e]==Type.Interval:
86
+ for m in range(0,len(episodes[e])-1,2): # read in batches of two, and run until -1 to make sure we don't pick up incomplete intervals
87
+ e_list.append(Episode(e, *episodes[e][m:m+2]))
88
+ else:
89
+ e_list.extend([Episode(e,m) for m in episodes[e]])
90
+
91
+ return sorted(e_list, key=lambda x: x.start_frame)
92
+
93
+
94
+ def is_in_interval(episodes: dict[Event,list[int]]|list[Episode], idx: int) -> dict[Event, bool]:
95
+ if isinstance(episodes,dict):
96
+ episodes = marker_dict_to_list(episodes)
97
+
98
+ e_dict: dict[Event,bool] = {e:False for e in Event}
99
+ for e in episodes:
100
+ if type_map[e.event]==Type.Interval:
101
+ if idx>=e.start_frame and idx<=e.end_frame:
102
+ e_dict[e.event] = True
103
+ else:
104
+ if idx==e.start_frame:
105
+ e_dict[e.event] = True
106
+ return e_dict
@@ -0,0 +1,109 @@
1
+ from enum import auto
2
+ import pathlib
3
+ import cv2
4
+ import json
5
+ import numpy as np
6
+
7
+ from glassesTools import plane, utils
8
+ from glassesValidator.config import get_validation_setup
9
+ from glassesValidator.config.poster import Poster
10
+
11
+
12
+ class Type(utils.AutoName):
13
+ GlassesValidator= auto()
14
+ Plane_2D = auto()
15
+ utils.register_type(utils.CustomTypeEntry(Type,'__enum.plane.Type__',str, lambda x: getattr(Type, x.split('.')[1])))
16
+
17
+
18
+ class Definition:
19
+ default_json_file_name = 'plane_def.json'
20
+
21
+ def __init__(self, type:Type, name:str=None, use_default:bool=False, marker_file: str|pathlib.Path=None, marker_size: float=None, marker_border_bits: int=1, min_num_markers: int=3, center: np.ndarray=None, unit: str=None, aruco_dict: int=cv2.aruco.DICT_4X4_250, ref_image_width: int=1920):
22
+ self.type = type
23
+ self.name = name
24
+ self.use_default : bool = use_default # applies only to glassesValidator planes. If False, denotes this is the default/built-in glassesValidator plane, if True, denotes custom settings are expected
25
+ # the below are only for non-glassesValidator planes
26
+ self.marker_file : str|pathlib.Path = marker_file # if str or Path: file from which to read markers. Else direction N_markerx4 array. Should contain centers of markers
27
+ self.marker_size : float = marker_size # in "unit" units
28
+ self.marker_border_bits : int = marker_border_bits
29
+ self.min_num_markers : int = min_num_markers # minimum number of markers that should be to run pose estimation w.r.t. the plane
30
+ self.center : np.ndarray = center # center of plane, in coordinates of the input file
31
+ self.unit : str = unit
32
+ self.aruco_dict : int = aruco_dict
33
+ self.ref_image_width : int = ref_image_width
34
+
35
+ if isinstance(self.center,list):
36
+ self.center = np.array(self.center)
37
+
38
+ # check provided info
39
+ if self.type==Type.GlassesValidator:
40
+ # prevent bugs
41
+ assert self.marker_file is None, "The marker_file input argument should not be set when the plane is a GlassesValidator plane (would be ignored)"
42
+ assert self.marker_size is None, "The marker_size input argument should not be set when the plane is a GlassesValidator plane (would be ignored)"
43
+ # NB: all the other parameters are also ignored, but have meaningful defaults, so can't be checked
44
+ else:
45
+ assert self.marker_file is not None, "The marker_file input argument should be provided"
46
+ assert self.marker_size is not None, "The marker_size input argument should be provided"
47
+ # prevent bugs
48
+ assert self.use_default is False, "The use_default input argument is for GlassesValidator planes. It should be set to False (default) when the plane is not a GlassesValidator plane (would be ignored)"
49
+
50
+ def store_as_json(self, path: str | pathlib.Path):
51
+ path = pathlib.Path(path)
52
+ if path.is_dir():
53
+ path /= self.default_json_file_name
54
+ with open(path, 'w') as f:
55
+ json.dump(self, f, cls=utils.CustomTypeEncoder, indent=2)
56
+
57
+ @staticmethod
58
+ def load_from_json(path: str | pathlib.Path) -> 'Definition':
59
+ path = pathlib.Path(path)
60
+ if path.is_dir():
61
+ path /= Definition.default_json_file_name
62
+ with open(path, 'r') as f:
63
+ dfntn = json.load(f, object_hook=utils.json_reconstitute)
64
+ dfntn.name=path.parent.name
65
+ return dfntn
66
+
67
+ def _to_dict(self):
68
+ return {
69
+ 'type': self.type,
70
+ 'use_default': self.use_default,
71
+ 'marker_file': self.marker_file,
72
+ 'marker_size': self.marker_size,
73
+ 'marker_border_bits': self.marker_border_bits,
74
+ 'min_num_markers': self.min_num_markers,
75
+ 'center': self.center,
76
+ 'unit': self.unit,
77
+ 'aruco_dict': self.aruco_dict,
78
+ 'ref_image_width': self.ref_image_width
79
+ }
80
+ utils.register_type(utils.CustomTypeEntry(Definition,'__plane.Definition',lambda x: x._to_dict(), lambda x: Definition(**x)))
81
+
82
+
83
+ def get_plane_from_path(path: str|pathlib.Path) -> plane.Plane:
84
+ path = pathlib.Path(path)
85
+ plane_def = Definition.load_from_json(path)
86
+ return get_plane_from_definition(plane_def, path)
87
+
88
+ def get_plane_from_definition(plane_def: Definition, path: str | pathlib.Path) -> plane.Plane:
89
+ # for loading a plane from a directory that doesn't contain a plane definition json file
90
+ # use the provided definition instead
91
+ if plane_def.type==Type.GlassesValidator:
92
+ validator_config_dir = None # use glassesValidator built-in/default
93
+ if not plane_def.use_default:
94
+ validator_config_dir = path
95
+ validation_setup = get_validation_setup(validator_config_dir)
96
+ return Poster(validator_config_dir, validation_setup, ref_image_store_path=path / plane.Plane.default_ref_image_name)
97
+ else:
98
+ pl = plane.Plane(
99
+ markers = path / plane_def.marker_file,
100
+ marker_size = plane_def.marker_size,
101
+ aruco_dict = plane_def.aruco_dict,
102
+ marker_border_bits = plane_def.marker_border_bits,
103
+ unit = plane_def.unit,
104
+ ref_image_store_path= path / plane.Plane.default_ref_image_name,
105
+ ref_image_width = plane_def.ref_image_width
106
+ )
107
+ if plane_def.center is not None:
108
+ pl.set_center(plane_def.center)
109
+ return pl
@@ -0,0 +1,193 @@
1
+ from enum import Enum, auto
2
+ import pathlib
3
+ import json
4
+
5
+ from glassesTools import importing, utils
6
+ from glassesTools.recording import Recording as EyeTrackerRecording
7
+
8
+ from . import camera_recording
9
+
10
+
11
+ class RecordingType(Enum):
12
+ EyeTracker = auto()
13
+ Camera = auto()
14
+ utils.register_type(utils.CustomTypeEntry(RecordingType,'__enum.session.RecordingType__',str, lambda x: getattr(RecordingType, x.split('.')[1])))
15
+
16
+
17
+
18
+ class RecordingDefinition:
19
+ def __init__(self, name:str, type:RecordingType):
20
+ self.name = name
21
+ self.type = type
22
+ utils.register_type(utils.CustomTypeEntry(RecordingDefinition,'__session.RecordingDefinition__',lambda x: {'name': x.name, 'type': x.type}, lambda x: RecordingDefinition(**x)))
23
+
24
+
25
+ class Recording:
26
+ def __init__(self, defition: RecordingDefinition, info:EyeTrackerRecording|camera_recording.Recording=None):
27
+ self.defition = defition
28
+ self.info = info
29
+ utils.register_type(utils.CustomTypeEntry(Recording,'__session.Recording__',lambda x: {'defition': x.defition, 'info': x.info}, lambda x: Recording(**x)))
30
+
31
+ def read_recording_info(working_dir: pathlib.Path, rec_type: RecordingType) -> tuple[EyeTrackerRecording|camera_recording.Recording, pathlib.Path]:
32
+ if rec_type==RecordingType.Camera:
33
+ rec_info = camera_recording.Recording.load_from_json(working_dir)
34
+ in_video = rec_info.get_video_path()
35
+ elif rec_type==RecordingType.EyeTracker:
36
+ rec_info = EyeTrackerRecording.load_from_json(working_dir)
37
+ in_video = rec_info.get_scene_video_path()
38
+ return rec_info, in_video
39
+
40
+ class SessionDefinition:
41
+ def __init__(self, recordings: list[RecordingDefinition]=None, sync_ref: str=None):
42
+ if recordings is None:
43
+ recordings = []
44
+ self.recordings = recordings
45
+
46
+ self.sync_ref: str = None
47
+ self.set_sync_ref(sync_ref)
48
+
49
+ def add_recording(self, recording: RecordingDefinition):
50
+ self.recordings.append(recording)
51
+
52
+ def set_sync_ref(self, which: str):
53
+ if not any([r.name==which for r in self.recordings]):
54
+ raise ValueError(f'recording "{which}" not known')
55
+ self.sync_ref = which
56
+
57
+ def get_recording(self, which: str) -> RecordingDefinition:
58
+ for r in self.recordings:
59
+ if r.name==which:
60
+ return r
61
+ raise ValueError(f'recording "{which}" not found')
62
+
63
+ def store_as_json(self, path: str | pathlib.Path):
64
+ path = pathlib.Path(path)
65
+ with open(path, 'w') as f:
66
+ json.dump(self, f, cls=utils.CustomTypeEncoder, indent=2)
67
+
68
+ @staticmethod
69
+ def load_from_json(path: str | pathlib.Path) -> 'Session':
70
+ path = pathlib.Path(path)
71
+ with open(path, 'r') as f:
72
+ return json.load(f, object_hook=utils.json_reconstitute)
73
+ utils.register_type(utils.CustomTypeEntry(SessionDefinition,'__session.SessionDefinition__',lambda x: {'recordings': x.recordings, 'sync_ref': x.sync_ref}, lambda x: SessionDefinition(**x)))
74
+
75
+
76
+ class Session:
77
+ default_json_file_name = 'session_info.json'
78
+
79
+ def __init__(self, definition: SessionDefinition, name: str, working_directory: str|pathlib.Path = None, recordings: dict[str,Recording] = None):
80
+ self.definition = definition
81
+ self.name = name
82
+ self.working_directory: pathlib.Path = pathlib.Path(working_directory) if working_directory else None
83
+ if not recordings:
84
+ recordings = {}
85
+ self.recordings = recordings
86
+
87
+ def create_working_directory(self, parent_directory: str|pathlib.Path):
88
+ self.working_directory = pathlib.Path(parent_directory) / self.name
89
+ if not self.working_directory.is_dir():
90
+ self.working_directory.mkdir()
91
+
92
+ def import_and_add_recording(self, which: str, rec_info: EyeTrackerRecording|camera_recording.Recording, copy_video = True) -> Recording:
93
+ rec_def = self.definition.get_recording(which)
94
+ self.check_recording_info(which, rec_info)
95
+
96
+ # do import
97
+ rec_info.working_directory = self.working_directory / rec_def.name
98
+ if rec_def.type==RecordingType.EyeTracker:
99
+ rec_info = importing.do_import(rec_info=rec_info, copy_scene_video=copy_video)
100
+ else:
101
+ rec_info = camera_recording.do_import(rec_info=rec_info, copy_video=copy_video)
102
+
103
+ # add recording
104
+ self.add_recording_from_info(which, rec_info)
105
+ return self.recordings[which]
106
+
107
+ def load_existing_recordings(self):
108
+ # load recordings that are present
109
+ for r in self.definition.recordings:
110
+ if (self.working_directory / r.name).is_dir():
111
+ self.add_existing_recording(r.name)
112
+
113
+ def add_existing_recording(self, which: str) -> Recording:
114
+ r_fold = self.working_directory / which
115
+ if not r_fold.is_dir():
116
+ return
117
+
118
+ # get info about recording
119
+ rec_def = self.definition.get_recording(which)
120
+ if rec_def.type==RecordingType.EyeTracker:
121
+ rec_info = EyeTrackerRecording.load_from_json(r_fold)
122
+ else:
123
+ rec_info = camera_recording.Recording.load_from_json(r_fold)
124
+
125
+ # add recording
126
+ self.add_recording_from_info(which, rec_info)
127
+ return self.recordings[which]
128
+
129
+ def check_recording_info(self, which: str, rec_info: EyeTrackerRecording|camera_recording.Recording):
130
+ rec_def = self.definition.get_recording(which)
131
+ if rec_def.type==RecordingType.EyeTracker:
132
+ assert isinstance(rec_info,EyeTrackerRecording), f"The provided rec_info is not for an eye tracker recording, but {which} is an eye tracker recording"
133
+ elif rec_def.type==RecordingType.Camera:
134
+ assert isinstance(rec_info,camera_recording.Recording), f"The provided rec_info is not for a camera recording, but {which} is a camera recording"
135
+
136
+ def add_recording_from_info(self, which: str, rec_info: EyeTrackerRecording|camera_recording.Recording):
137
+ rec_def = self.definition.get_recording(which)
138
+ self.check_recording_info(which, rec_info)
139
+ self.recordings[which] = Recording(rec_def, rec_info)
140
+
141
+ def has_all_recordings(self) -> bool:
142
+ return all([r.name in self.recordings for r in self.definition.recordings])
143
+
144
+ def store_as_json(self, path: str | pathlib.Path = None):
145
+ if path is None:
146
+ path = self.working_directory
147
+ path = pathlib.Path(path)
148
+ if path.is_dir():
149
+ path /= self.default_json_file_name
150
+ with open(path, 'w') as f:
151
+ to_dump = {k:getattr(self,k) for k in ['definition']} # only this field. Name will be populated from name of session/provided folder, recordings from each subfolder in the session/provided folder, and working_directory as the provided path
152
+ # dump to file
153
+ json.dump(to_dump, f, cls=utils.CustomTypeEncoder, indent=2)
154
+
155
+ @staticmethod
156
+ def load_from_json(path: str | pathlib.Path) -> 'Session':
157
+ path = pathlib.Path(path)
158
+ if path.is_dir():
159
+ path /= Session.default_json_file_name
160
+ # load session setup
161
+ with open(path, 'r') as f:
162
+ sess = Session(**json.load(f, object_hook=utils.json_reconstitute), name=path.parent.name, working_directory=path.parent)
163
+ # load recordings that are present
164
+ sess.load_existing_recordings()
165
+
166
+ return sess
167
+
168
+ @staticmethod
169
+ def from_definition(definition: SessionDefinition, path: str | pathlib.Path) -> 'Session':
170
+ # for loading a recording directory that doesn't contain a session json file
171
+ # use the provided definition instead
172
+ sess = Session(definition, name=path.name, working_directory=path)
173
+ sess.load_existing_recordings()
174
+ return sess
175
+
176
+
177
+ def get_sessions_from_directory(path: str|pathlib.Path, session_json_name=Session.default_json_file_name) -> list[Session]:
178
+ path = pathlib.Path(path)
179
+
180
+ # iterate through all folders in the provided path and check if the folder contains a file with the session_json_name
181
+ # then its potentially a session
182
+ sessions: list[Session] = []
183
+ for d in path.iterdir():
184
+ if not d.is_dir():
185
+ continue
186
+
187
+ f = d / session_json_name
188
+ if not f.is_file():
189
+ continue
190
+
191
+ sessions.append(Session.load_from_json(f))
192
+
193
+ return sessions
@@ -0,0 +1,13 @@
1
+ __version__ = "0.5.0"
2
+
3
+ __title__ = "gazeMapper"
4
+ __description__ = "Automated world-based analysis of wearable eye tracker data."
5
+ __url__ = "https://github.com/dcnieho/gazeMapper"
6
+ __uri__ = __url__
7
+ __doc__ = __description__ + " <" + __url__ + ">"
8
+
9
+ __author__ = "Diederick Niehorster"
10
+ __email__ = "diederick_c.niehorster@humlab.lu.se"
11
+
12
+ __license__ = "MIT"
13
+ __copyright__ = "Copyright (c) 2024 " + __author__
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.1
2
+ Name: gazeMapper
3
+ Version: 0.5.0
4
+ Summary: Automated world-based analysis of wearable eye tracker data.
5
+ Home-page: https://github.com/dcnieho/gazeMapper
6
+ Author: Diederick Niehorster
7
+ Author-email: diederick_c.niehorster@humlab.lu.se
8
+ License: MIT License
9
+
10
+ Copyright (c) 2024 Diederick Niehorster
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+
30
+ Project-URL: Source Code, https://github.com/dcnieho/gazeMapper
31
+ Classifier: Programming Language :: Python :: 3.10
32
+ Classifier: Programming Language :: Python :: 3.11
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Requires-Python: >=3.10
36
+ Description-Content-Type: text/markdown
37
+ License-File: LICENSE
38
+ Requires-Dist: glassesTools[GUI]==1.4.0
39
+ Requires-Dist: glassesValidator==2.0.0
40
+
41
+ [![Downloads](https://static.pepy.tech/badge/gazeMapper)](https://pepy.tech/project/gazeMapper)
42
+ [![Citation Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.juleskreuer.eu%2Fcitation-badge.php%3Fshield%26doi%3D10.3758%2Fs13428-023-02105-5&color=blue)](https://scholar.google.com/citations?view_op=view_citation&citation_for_view=uRUYoVgAAAAJ:uWQEDVKXjbEC)
43
+ [![PyPI Latest Release](https://img.shields.io/pypi/v/gazeMapper.svg)](https://pypi.org/project/gazeMapper/)
44
+ [![image](https://img.shields.io/pypi/pyversions/gazeMapper.svg)](https://pypi.org/project/gazeMapper/)
45
+
46
+ # gazeMapper v0.5.0
47
+ Tool for automated world-based analysis of wearable eye tracker data.
48
+
49
+ If you use this tool or any of the code in this repository, please cite:<br>
50
+ Niehorster, D.C., Hessels, R.S., Nyström, M., Benjamins, J.S. and Hooge, I.T.C. (in prep). gazeMapper: A tool for automated world-based analysis of wearable eye tracker data
51
+
52
+ # How to acquire
53
+ GlassesValidator is available from `https://github.com/dcnieho/gazeMapper`, and supports Python 3.10 and 3.11 on Windows, MacOS and Linux.
54
+
55
+ For Windows users who wish to use the gazeMapper GUI, the easiest way to acquire gazeMapper is to [download
56
+ a standalone executable](https://github.com/dcnieho/gazeMapper/releases/latest). The standalone executable is not
57
+ available for MacOS or Linux.
58
+
59
+ For users on Windows, Mac or Linux who wish to use gazeMapper in their Python code, the easiest way to acquire
60
+ gazeMapper is to install it directly into your Python distribution using the command
61
+ `python -m pip install gazeMapper`. Should that fail, this repository is pip-installable as well:
62
+ `python -m pip install git+https://github.com/dcnieho/gazeMapper.git#egg=gazeMapper`. NB: on some platforms you may have
63
+ to replace `python` with `python3` in the above command lines.
64
+
65
+ Once pip-installed in your Python distribution, there are three ways to run the GUI on any of the supported operating systems:
66
+ 1. Directly in the terminal of your operating system, type `gazeMapper` and run it.
67
+ 2. Open a Python console. From such a console, running the GUI requires only the following two lines of code:
68
+ ```python
69
+ import gazeMapper
70
+ gazeMapper.GUI.run()
71
+ ```
72
+ 3. If you run the gazeMapper's GUI from a script, make sure to wrap your script in `if __name__=="__main__"`. This is required for correct operation from a script because the GUI uses multiprocessing functionality. Do as follows:
73
+ ```python
74
+ if __name__=="__main__":
75
+ import gazeMapper
76
+ gazeMapper.GUI.run()
77
+ ```
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ requirements.txt
6
+ setup.py
7
+ src/gazeMapper/__init__.py
8
+ src/gazeMapper/camera_recording.py
9
+ src/gazeMapper/config.py
10
+ src/gazeMapper/episode.py
11
+ src/gazeMapper/plane.py
12
+ src/gazeMapper/session.py
13
+ src/gazeMapper/version.py
14
+ src/gazeMapper.egg-info/PKG-INFO
15
+ src/gazeMapper.egg-info/SOURCES.txt
16
+ src/gazeMapper.egg-info/dependency_links.txt
17
+ src/gazeMapper.egg-info/requires.txt
18
+ src/gazeMapper.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ glassesTools[GUI]==1.4.0
2
+ glassesValidator==2.0.0
@@ -0,0 +1 @@
1
+ gazeMapper