creapy 0.1.0__py3-none-any.whl
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.
- creapy/__init__.py +67 -0
- creapy/aoi/__init__.py +0 -0
- creapy/aoi/aoi.py +142 -0
- creapy/aoi/sequence.py +174 -0
- creapy/examples/README.md +7 -0
- creapy/examples/__init__.py +127 -0
- creapy/examples/demo_fixations.csv +2151 -0
- creapy/examples/demo_picture_A.png +0 -0
- creapy/examples/demo_picture_B.png +0 -0
- creapy/formats/__init__.py +5 -0
- creapy/formats/detect.py +127 -0
- creapy/formats/table.py +84 -0
- creapy/formats/tobii.py +339 -0
- creapy/gaze/__init__.py +0 -0
- creapy/gaze/base.py +160 -0
- creapy/gaze/convert.py +82 -0
- creapy/gaze/fixation.py +289 -0
- creapy/gaze/saccade.py +311 -0
- creapy/io/__init__.py +0 -0
- creapy/io/__main__.py +4 -0
- creapy/io/aoi_metrics_window.py +155 -0
- creapy/io/glossary.py +419 -0
- creapy/io/help.py +201 -0
- creapy/io/main.py +1865 -0
- creapy/io/project.py +48 -0
- creapy/io/session_log.py +73 -0
- creapy/io/style/crea_logo.png +0 -0
- creapy/io/style/fix_thumbnail.png +0 -0
- creapy/io/style/main_dark.qss +336 -0
- creapy/io/style/main_light.qss +207 -0
- creapy/io/style/sac_thumbnail.png +0 -0
- creapy/io/style/start_dark.qss +139 -0
- creapy/io/style/start_light.qss +93 -0
- creapy/io/views.py +418 -0
- creapy/io/widgets/__init__.py +0 -0
- creapy/io/widgets/column_mapping.py +113 -0
- creapy/io/widgets/compare_dialog.py +81 -0
- creapy/io/widgets/filter_bar.py +157 -0
- creapy/io/widgets/grouping_dialog.py +88 -0
- creapy/io/widgets/half_dial.py +220 -0
- creapy/io/widgets/painter.py +58 -0
- creapy/io/widgets/stimulus_strip.py +82 -0
- creapy/io/widgets/stimulus_thumbnail.py +76 -0
- creapy/io/widgets/table_view.py +47 -0
- creapy/io/widgets/toolbars/__init__.py +0 -0
- creapy/io/widgets/toolbars/aoi.py +170 -0
- creapy/io/widgets/toolbars/fix_heatmap.py +227 -0
- creapy/io/widgets/toolbars/fix_scanpath.py +114 -0
- creapy/io/widgets/toolbars/recurrence.py +83 -0
- creapy/io/widgets/toolbars/rose.py +100 -0
- creapy/io/widgets/toolbars/sac_heatmap.py +183 -0
- creapy/io/widgets/toolbars/scanpath.py +153 -0
- creapy/io/widgets/toolbars/scanpath_compare.py +162 -0
- creapy/io/widgets/toolbars/similarity.py +175 -0
- creapy/io/widgets/toolbars/stats.py +90 -0
- creapy/io/widgets/toolbars/time_series.py +117 -0
- creapy/io/widgets/utils.py +103 -0
- creapy/io/worker.py +96 -0
- creapy/misc/__init__.py +0 -0
- creapy/misc/colormapping.py +13 -0
- creapy/misc/contours.py +41 -0
- creapy/misc/plot_manager.py +73 -0
- creapy/misc/repulsion.py +106 -0
- creapy/misc/utils.py +66 -0
- creapy/similarity/__init__.py +0 -0
- creapy/similarity/grid.py +35 -0
- creapy/similarity/grid_fixation.py +66 -0
- creapy/similarity/grid_result.py +227 -0
- creapy/similarity/heatmatch_result.py +269 -0
- creapy/similarity/scanpath.py +189 -0
- creapy/stats/__init__.py +8 -0
- creapy/stats/descriptives.py +107 -0
- creapy/stats/recurrence.py +111 -0
- creapy/stats/spectrum.py +164 -0
- creapy/stats/timeseries.py +104 -0
- creapy-0.1.0.dist-info/METADATA +199 -0
- creapy-0.1.0.dist-info/RECORD +81 -0
- creapy-0.1.0.dist-info/WHEEL +5 -0
- creapy-0.1.0.dist-info/entry_points.txt +2 -0
- creapy-0.1.0.dist-info/licenses/LICENSE +21 -0
- creapy-0.1.0.dist-info/top_level.txt +1 -0
creapy/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from creapy.formats import read_table, detect_format, guess_mapping, FIXATION_FIELDS, SACCADE_FIELDS
|
|
2
|
+
from creapy.formats import load_prolab_export, load_prolab_data_export, load_prolab_event_export
|
|
3
|
+
from creapy.formats import load_prolab_data_export_chunked, load_prolab_aoi_metrics_export, aoi_metrics_summary
|
|
4
|
+
from creapy.gaze.base import StimulusGaze
|
|
5
|
+
from creapy.gaze.fixation import FixationData
|
|
6
|
+
from creapy.gaze.saccade import SaccadeData
|
|
7
|
+
from creapy.gaze.convert import fixations_to_saccades, saccades_to_fixations
|
|
8
|
+
from creapy.aoi.aoi import AOI
|
|
9
|
+
from creapy.aoi.sequence import aoi_sequence, transition_matrix, show_scarf_plot, show_transition_matrix, first_fixation_order
|
|
10
|
+
from creapy.stats import coefficient_k, fixation_k, participant_table, group_comparison, show_group_boxplot
|
|
11
|
+
from creapy.stats import value_series, show_measure_curve, split_by_group
|
|
12
|
+
from creapy.stats import participant_rqa, cross_recurrence_table, show_recurrence_plot
|
|
13
|
+
from creapy.stats import participant_periodogram, dominant_period, show_periodogram, morlet_scalogram, show_scalogram
|
|
14
|
+
from creapy.similarity.heatmatch_result import PairwiseSimilarityResult, HeatMatchSimilarityResult
|
|
15
|
+
from creapy.misc.utils import natural_sorted, find_images
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_fixations(path, mapping=None, **mapping_kwargs):
|
|
22
|
+
"""Fixation table (CSV/TSV/Excel with one row per fixation, or a Tobii Pro Lab Data export) -> (df, mapping)."""
|
|
23
|
+
df = read_table(path)
|
|
24
|
+
if detect_format(df.columns) in ('tobii_data_export', 'tobii_event_export'):
|
|
25
|
+
df, guessed = load_prolab_export(df)
|
|
26
|
+
else:
|
|
27
|
+
guessed = guess_mapping(df, FIXATION_FIELDS)
|
|
28
|
+
guessed.update(mapping or {})
|
|
29
|
+
guessed.update(mapping_kwargs)
|
|
30
|
+
return df, guessed
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_saccades(path, mapping=None, **mapping_kwargs):
|
|
34
|
+
"""Saccade table (one row per saccade) -> (df, mapping) with the sac_* keys of SACCADE_FIELDS."""
|
|
35
|
+
df = read_table(path)
|
|
36
|
+
guessed = guess_mapping(df, SACCADE_FIELDS)
|
|
37
|
+
guessed.update(mapping or {})
|
|
38
|
+
guessed.update(mapping_kwargs)
|
|
39
|
+
return df, guessed
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def datasets_by_stimulus(df, mapping, stimulus_paths=None, is_fixation=True):
|
|
43
|
+
"""Split a table into per-stimulus (FixationData, SaccadeData) pairs: {stimulus -> (fix, sac)}."""
|
|
44
|
+
if isinstance(stimulus_paths, (str, os.PathLike)):
|
|
45
|
+
found = find_images(str(stimulus_paths))
|
|
46
|
+
stimulus_paths = {}
|
|
47
|
+
for stim in df[mapping['stim_col']].unique():
|
|
48
|
+
key = str(stim).lower()
|
|
49
|
+
stimulus_paths[stim] = found.get(key, found.get(os.path.splitext(key)[0], str(stim)))
|
|
50
|
+
datasets = {}
|
|
51
|
+
for stim, df_stim in df.groupby(mapping['stim_col']):
|
|
52
|
+
path = stimulus_paths[stim] if stimulus_paths is not None else str(stim)
|
|
53
|
+
datasets[stim] = build_pair(df_stim, path, mapping, is_fixation)
|
|
54
|
+
return datasets
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_pair(df_stim, path, mapping, is_fixation=True):
|
|
58
|
+
"""(FixationData, SaccadeData) of one stimulus from its rows and image path."""
|
|
59
|
+
if is_fixation:
|
|
60
|
+
fix = FixationData(df_stim, path, **mapping)
|
|
61
|
+
df_sac, sac_mapping = fixations_to_saccades(fix.df, fix.mapping)
|
|
62
|
+
sac = SaccadeData(df_sac, path, img=fix.img, scale=1., time_unit=fix.time_unit, **sac_mapping)
|
|
63
|
+
else:
|
|
64
|
+
sac = SaccadeData(df_stim, path, **mapping)
|
|
65
|
+
df_fix, fix_mapping = saccades_to_fixations(sac.df, sac.mapping)
|
|
66
|
+
fix = FixationData(df_fix, path, img=sac.img, scale=1., time_unit=sac.time_unit, **fix_mapping)
|
|
67
|
+
return fix, sac
|
creapy/aoi/__init__.py
ADDED
|
File without changes
|
creapy/aoi/aoi.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import functools
|
|
3
|
+
import matplotlib.patches as patches
|
|
4
|
+
import matplotlib.patheffects as pe
|
|
5
|
+
import matplotlib.path as mpath
|
|
6
|
+
from shapely import area, centroid, covers
|
|
7
|
+
from shapely.ops import unary_union
|
|
8
|
+
from shapely.geometry import Point, Polygon, LinearRing, box
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AOI:
|
|
12
|
+
"""One area of interest: a named shapely geometry in image pixels (x right, y down)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, name="Unnamed", geom=None, paths=None):
|
|
15
|
+
assert (geom is None) != (paths is None), "EITHER 'geom' OR 'paths' must be provided, but not both"
|
|
16
|
+
self.__name = name
|
|
17
|
+
self.__geom = geom if paths is None else AOI.__multiple_paths_to_single_geom(paths)
|
|
18
|
+
self.__path = AOI.__single_geom_to_single_path(self.__geom)
|
|
19
|
+
self.__disconnected_polygons = AOI.__extract_disconnected_polygons(self.__geom)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def name(self):
|
|
23
|
+
"""The AOI's name (read-only)."""
|
|
24
|
+
return self.__name
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def geom(self):
|
|
28
|
+
"""The shapely geometry (Polygon or MultiPolygon) in image pixels, read-only."""
|
|
29
|
+
return self.__geom
|
|
30
|
+
|
|
31
|
+
def area(self):
|
|
32
|
+
"""Area of the AOI in square pixels."""
|
|
33
|
+
return float(area(self.__geom))
|
|
34
|
+
|
|
35
|
+
def contains(self, points):
|
|
36
|
+
"""True for each point inside or on the boundary of the AOI."""
|
|
37
|
+
return covers(self.__geom, points)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def rectangle(name, x0, y0, x1, y1):
|
|
41
|
+
"""Rectangular AOI from two opposite corners (x0, y0), (x1, y1) in pixels."""
|
|
42
|
+
return AOI(name=name, geom=box(min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)))
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def polygon(name, vertices):
|
|
46
|
+
"""Polygon AOI from a list of (x, y) vertices in pixels, at least three."""
|
|
47
|
+
assert len(vertices) >= 3, "A polygon AOI needs at least three corners."
|
|
48
|
+
return AOI(name=name, geom=Polygon(vertices).buffer(0))
|
|
49
|
+
|
|
50
|
+
def to_record(self):
|
|
51
|
+
"""Dict {'name', 'wkt'} describing the AOI as text (WKT), for saving AOI sets to JSON; see from_record."""
|
|
52
|
+
return {'name': self.__name, 'wkt': self.__geom.wkt}
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def from_record(record):
|
|
56
|
+
"""Rebuild an AOI from a to_record() dict."""
|
|
57
|
+
from shapely import wkt
|
|
58
|
+
return AOI(name=record['name'], geom=wkt.loads(record['wkt']))
|
|
59
|
+
|
|
60
|
+
def show(self, ax, color='yellow', alpha=0.4, label=True):
|
|
61
|
+
"""Draw the AOI on a matplotlib axes as a translucent patch."""
|
|
62
|
+
ax.add_patch(patches.PathPatch(self.__path, facecolor=color, alpha=alpha, edgecolor=color, linewidth=1.5))
|
|
63
|
+
if not label:
|
|
64
|
+
return
|
|
65
|
+
polygons = sorted([(area(polygon), polygon) for polygon in self.__disconnected_polygons], reverse=True)
|
|
66
|
+
center = centroid(polygons[0][1])
|
|
67
|
+
ax.text(center.x, center.y, self.__name, ha='center', va='center', color="black",
|
|
68
|
+
path_effects=[
|
|
69
|
+
pe.Stroke(linewidth=4, foreground='white', alpha=0.7),
|
|
70
|
+
pe.Normal()
|
|
71
|
+
])
|
|
72
|
+
for _, polygon in polygons[1:]:
|
|
73
|
+
center_ = centroid(polygon)
|
|
74
|
+
ax.plot([center.x, center_.x], [center.y, center_.y], color='black', alpha=0.3, linestyle='--')
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def __multiple_paths_to_single_geom(paths):
|
|
78
|
+
polygons = [AOI.__single_path_to_multiple_geoms(path) for path in paths]
|
|
79
|
+
flattened_polygons = functools.reduce(lambda a, b: a + b, polygons)
|
|
80
|
+
return unary_union(flattened_polygons)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def __extract_disconnected_polygons(geom):
|
|
84
|
+
if geom.geom_type == 'Polygon':
|
|
85
|
+
return [geom]
|
|
86
|
+
elif geom.geom_type == 'MultiPolygon':
|
|
87
|
+
return list(geom.geoms)
|
|
88
|
+
elif geom.geom_type == 'GeometryCollection':
|
|
89
|
+
return [g for g in geom.geoms if isinstance(g, Polygon)]
|
|
90
|
+
else:
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def __single_path_to_multiple_geoms(path):
|
|
95
|
+
polys = []
|
|
96
|
+
vertices = path.vertices
|
|
97
|
+
codes = path.codes
|
|
98
|
+
if codes is None:
|
|
99
|
+
if np.allclose(vertices[0], vertices[-1]):
|
|
100
|
+
polys.append(Polygon(vertices))
|
|
101
|
+
else:
|
|
102
|
+
polys.append(LinearRing(vertices))
|
|
103
|
+
return polys
|
|
104
|
+
|
|
105
|
+
current = []
|
|
106
|
+
for vert, code in zip(vertices, codes):
|
|
107
|
+
if code == mpath.Path.MOVETO:
|
|
108
|
+
if current:
|
|
109
|
+
ring = np.array(current)
|
|
110
|
+
if np.allclose(ring[0], ring[-1]):
|
|
111
|
+
polys.append(Polygon(ring))
|
|
112
|
+
else:
|
|
113
|
+
polys.append(LinearRing(ring))
|
|
114
|
+
current = [vert]
|
|
115
|
+
else:
|
|
116
|
+
current.append(vert)
|
|
117
|
+
|
|
118
|
+
if current:
|
|
119
|
+
ring = np.array(current)
|
|
120
|
+
if np.allclose(ring[0], ring[-1]):
|
|
121
|
+
polys.append(Polygon(ring))
|
|
122
|
+
else:
|
|
123
|
+
polys.append(LinearRing(ring))
|
|
124
|
+
return polys
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def __single_geom_to_single_path(geom):
|
|
128
|
+
if geom.geom_type == 'Polygon':
|
|
129
|
+
verts = np.array(geom.exterior.coords)
|
|
130
|
+
codes = ([mpath.Path.MOVETO] + [mpath.Path.LINETO] * (len(verts) - 2) + [mpath.Path.CLOSEPOLY])
|
|
131
|
+
return mpath.Path(verts, codes)
|
|
132
|
+
elif geom.geom_type == 'MultiPolygon':
|
|
133
|
+
all_verts = []
|
|
134
|
+
all_codes = []
|
|
135
|
+
for polygon in geom.geoms:
|
|
136
|
+
verts = np.array(polygon.exterior.coords)
|
|
137
|
+
codes = ([mpath.Path.MOVETO] + [mpath.Path.LINETO] * (len(verts) - 2) + [mpath.Path.CLOSEPOLY])
|
|
138
|
+
all_verts.extend(verts)
|
|
139
|
+
all_codes.extend(codes)
|
|
140
|
+
return mpath.Path(np.array(all_verts), all_codes)
|
|
141
|
+
else:
|
|
142
|
+
raise ValueError(f"Unsupported geometry type: {geom.geom_type}")
|
creapy/aoi/sequence.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
from matplotlib.patches import Patch
|
|
5
|
+
from matplotlib.collections import PolyCollection
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
OUTSIDE = 'outside'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def aoi_sequence(fix, names=None, include_outside=True):
|
|
12
|
+
"""Which AOI each fixation fell into, in viewing order."""
|
|
13
|
+
if names is None:
|
|
14
|
+
names = list(fix.areas_of_interest)
|
|
15
|
+
label = np.full(len(fix.df), OUTSIDE, dtype=object)
|
|
16
|
+
assigned = np.zeros(len(fix.df), dtype=bool)
|
|
17
|
+
for name in names:
|
|
18
|
+
inside = np.asarray(fix.areas_of_interest[name].contains(fix.fix_points)) & ~assigned
|
|
19
|
+
label[inside] = name
|
|
20
|
+
assigned |= inside
|
|
21
|
+
df_seq = pd.DataFrame({'participant': fix.subj_index.to_numpy(),
|
|
22
|
+
'trial': fix.df[fix.trial_col].to_numpy() if fix.trial_col else 1,
|
|
23
|
+
't': fix.t.to_numpy(), 'dur': fix.fix_dur.to_numpy(), 'aoi': label})
|
|
24
|
+
df_seq['order'] = df_seq.groupby(['participant', 'trial']).cumcount() + 1
|
|
25
|
+
if not include_outside:
|
|
26
|
+
df_seq = df_seq[df_seq['aoi'] != OUTSIDE]
|
|
27
|
+
return df_seq.reset_index(drop=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def collapse_runs(df_seq):
|
|
31
|
+
"""Merge consecutive fixations in the same AOI into one visit."""
|
|
32
|
+
ls = []
|
|
33
|
+
for (subj, trial), df_ in df_seq.groupby(['participant', 'trial'], sort=False):
|
|
34
|
+
aoi = df_['aoi'].to_numpy()
|
|
35
|
+
new_run = np.r_[True, aoi[1:] != aoi[:-1]]
|
|
36
|
+
run_id = np.cumsum(new_run)
|
|
37
|
+
for _, df_run in df_.groupby(run_id, sort=False):
|
|
38
|
+
ls.append({'participant': subj, 'trial': trial, 'aoi': df_run['aoi'].iloc[0],
|
|
39
|
+
't_start': float(df_run['t'].iloc[0]), 't_end': float(df_run['t'].iloc[-1]),
|
|
40
|
+
'n_fixations': int(len(df_run)), 'dwell': float(df_run['dur'].sum())})
|
|
41
|
+
return pd.DataFrame.from_records(ls)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def transition_matrix(fix, names=None, include_outside=False, normalize='row', per_participant_weight=True):
|
|
45
|
+
"""AOI -> AOI transitions between consecutive visits, self-transitions excluded."""
|
|
46
|
+
df_seq = aoi_sequence(fix, names=names, include_outside=include_outside)
|
|
47
|
+
labels = (names if names is not None else list(fix.areas_of_interest)) + ([OUTSIDE] if include_outside else [])
|
|
48
|
+
counts = pd.DataFrame(0., index=labels, columns=labels)
|
|
49
|
+
if len(df_seq) == 0:
|
|
50
|
+
return counts
|
|
51
|
+
df_runs = collapse_runs(df_seq)
|
|
52
|
+
ls = []
|
|
53
|
+
for (subj, trial), df_ in df_runs.groupby(['participant', 'trial'], sort=False):
|
|
54
|
+
aoi = df_['aoi'].to_numpy()
|
|
55
|
+
if len(aoi) < 2:
|
|
56
|
+
continue
|
|
57
|
+
df_t = pd.DataFrame({'from': aoi[:-1], 'to': aoi[1:]})
|
|
58
|
+
df_t['participant'] = subj
|
|
59
|
+
ls.append(df_t)
|
|
60
|
+
if not ls:
|
|
61
|
+
return counts
|
|
62
|
+
df_trans = pd.concat(ls)
|
|
63
|
+
if per_participant_weight:
|
|
64
|
+
df_trans['w'] = 1. / df_trans.groupby('participant')['from'].transform('size')
|
|
65
|
+
else:
|
|
66
|
+
df_trans['w'] = 1.
|
|
67
|
+
pivot = df_trans.pivot_table(index='from', columns='to', values='w', aggfunc='sum', fill_value=0.)
|
|
68
|
+
counts = counts.add(pivot, fill_value=0.).loc[labels, labels]
|
|
69
|
+
if per_participant_weight:
|
|
70
|
+
counts = counts * df_trans['participant'].nunique()
|
|
71
|
+
if normalize == 'row':
|
|
72
|
+
row_sums = counts.sum(axis=1)
|
|
73
|
+
counts = counts.div(row_sums.where(row_sums > 0, np.nan), axis=0)
|
|
74
|
+
elif normalize == 'all':
|
|
75
|
+
total = counts.to_numpy().sum()
|
|
76
|
+
counts = counts / total if total > 0 else counts
|
|
77
|
+
else:
|
|
78
|
+
assert normalize is None, f"Unknown normalize: {normalize}"
|
|
79
|
+
return counts
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def show_transition_matrix(ax, matrix, cmap='Blues', fmt='{:.0%}', text_color_switch=0.6):
|
|
83
|
+
"""Draw a transition_matrix() as a heat-table on ax (rows = from, columns = to)."""
|
|
84
|
+
values = matrix.to_numpy(dtype=float)
|
|
85
|
+
im = ax.imshow(np.nan_to_num(values), cmap=cmap, vmin=0, vmax=np.nanmax(values) if np.isfinite(values).any() else 1)
|
|
86
|
+
ax.set_xticks(range(len(matrix.columns)))
|
|
87
|
+
ax.set_yticks(range(len(matrix.index)))
|
|
88
|
+
ax.set_xticklabels(matrix.columns, rotation=45, ha='right')
|
|
89
|
+
ax.set_yticklabels(matrix.index)
|
|
90
|
+
ax.set_xlabel("to")
|
|
91
|
+
ax.set_ylabel("from")
|
|
92
|
+
vmax = np.nanmax(values) if np.isfinite(values).any() else 1
|
|
93
|
+
for i in range(values.shape[0]):
|
|
94
|
+
for j in range(values.shape[1]):
|
|
95
|
+
v = values[i, j]
|
|
96
|
+
if np.isnan(v):
|
|
97
|
+
continue
|
|
98
|
+
ax.text(j, i, fmt.format(v) if fmt else f"{v:.2f}", ha='center', va='center', fontsize=8,
|
|
99
|
+
color='white' if v > text_color_switch * vmax else 'black')
|
|
100
|
+
return im
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def aoi_palette(names, include_outside=True):
|
|
104
|
+
"""Dict AOI name -> RGBA colour (tab10 cycle), plus a grey for 'outside' unless include_outside=False."""
|
|
105
|
+
palette = plt.get_cmap('tab10')
|
|
106
|
+
colors = {name: palette(i % 10) for i, name in enumerate(names)}
|
|
107
|
+
if include_outside:
|
|
108
|
+
colors[OUTSIDE] = (0.35, 0.35, 0.38, 1.)
|
|
109
|
+
return colors
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def show_scarf_plot(ax, fix, names=None, subjects=None, colors=None, relative_time=True, outside_alpha=0.35,
|
|
113
|
+
row_height=0.8):
|
|
114
|
+
"""Scarf plot: one row per participant, viewing time on x."""
|
|
115
|
+
df_seq = aoi_sequence(fix, names=names, include_outside=True)
|
|
116
|
+
if names is None:
|
|
117
|
+
names = list(fix.areas_of_interest)
|
|
118
|
+
if colors is None:
|
|
119
|
+
colors = aoi_palette(names)
|
|
120
|
+
if subjects is None:
|
|
121
|
+
subjects = list(fix.subjects)
|
|
122
|
+
dur_s = df_seq['dur'] / (1000. if fix.dur_unit == 'ms' else 1.)
|
|
123
|
+
df_seq = df_seq.assign(dur_s=dur_s.to_numpy())
|
|
124
|
+
row_of = {subj: row for row, subj in enumerate(subjects)}
|
|
125
|
+
df_plot = df_seq[df_seq['participant'].isin(row_of)]
|
|
126
|
+
if relative_time:
|
|
127
|
+
t_start = df_plot['t'] - df_plot.groupby('participant')['t'].transform('min')
|
|
128
|
+
else:
|
|
129
|
+
t_start = df_plot['t']
|
|
130
|
+
rows = df_plot['participant'].map(row_of).to_numpy(dtype=float)
|
|
131
|
+
x0 = t_start.to_numpy(dtype=float)
|
|
132
|
+
x1 = x0 + df_plot['dur_s'].to_numpy(dtype=float)
|
|
133
|
+
y0 = rows - row_height / 2
|
|
134
|
+
y1 = rows + row_height / 2
|
|
135
|
+
verts = np.stack([np.column_stack([x0, y0]), np.column_stack([x1, y0]),
|
|
136
|
+
np.column_stack([x1, y1]), np.column_stack([x0, y1])], axis=1)
|
|
137
|
+
rgba = np.array([colors.get(a, (0.5, 0.5, 0.5, 1.)) for a in df_plot['aoi']], dtype=float)
|
|
138
|
+
rgba[df_plot['aoi'].to_numpy() == OUTSIDE, 3] = outside_alpha
|
|
139
|
+
ax.add_collection(PolyCollection(verts, facecolors=rgba, edgecolors='none'))
|
|
140
|
+
ax.set_xlim(0, float(x1.max()) if len(x1) else 1.)
|
|
141
|
+
if len(subjects) <= 40:
|
|
142
|
+
ax.set_yticks(range(len(subjects)))
|
|
143
|
+
ax.set_yticklabels([str(s) for s in subjects], fontsize=7 if len(subjects) > 30 else 9)
|
|
144
|
+
else:
|
|
145
|
+
step = 10 if len(subjects) <= 200 else 25
|
|
146
|
+
ax.set_yticks(range(0, len(subjects), step))
|
|
147
|
+
ax.set_yticklabels([str(subjects[i]) for i in range(0, len(subjects), step)], fontsize=7)
|
|
148
|
+
ax.set_ylabel(f"participants ({len(subjects)})")
|
|
149
|
+
ax.set_ylim(len(subjects) - 0.4, -0.6)
|
|
150
|
+
ax.set_xlabel("Viewing time (s)")
|
|
151
|
+
handles = [Patch(color=colors[name], label=name) for name in names] + \
|
|
152
|
+
[Patch(color=colors[OUTSIDE], alpha=outside_alpha, label=OUTSIDE)]
|
|
153
|
+
ax.legend(handles=handles, loc='upper left', bbox_to_anchor=(1.01, 1.), fontsize=8, frameon=False)
|
|
154
|
+
return df_seq
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def first_fixation_order(fix, names=None):
|
|
158
|
+
"""In which order each participant first visited the AOIs."""
|
|
159
|
+
df_seq = aoi_sequence(fix, names=names, include_outside=False)
|
|
160
|
+
if names is None:
|
|
161
|
+
names = list(fix.areas_of_interest)
|
|
162
|
+
ls = []
|
|
163
|
+
for subj in fix.subjects:
|
|
164
|
+
df_ = df_seq[df_seq['participant'] == subj]
|
|
165
|
+
firsts = df_.groupby('aoi')['t'].min().sort_values()
|
|
166
|
+
rank = {name: i + 1 for i, name in enumerate(firsts.index)}
|
|
167
|
+
row = {'participant': subj}
|
|
168
|
+
row.update({name: rank.get(name, np.nan) for name in names})
|
|
169
|
+
ls.append(row)
|
|
170
|
+
return pd.DataFrame.from_records(ls)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == '__main__':
|
|
174
|
+
pass
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# creapy example dataset (synthetic)
|
|
2
|
+
|
|
3
|
+
Generated by `creapy.examples.make_example`: twelve invented viewers (six 'expert', six 'layperson'), two generated pictures (1600 x 1200 px), 30 s of simulated viewing each.
|
|
4
|
+
|
|
5
|
+
`demo_fixations.csv`: one row per fixation with participant, group, age, trial, image, t_start (ms), dur (ms), x, y (px), pupil (arbitrary).
|
|
6
|
+
|
|
7
|
+
No real eye-tracking data is involved.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from PIL import Image, ImageDraw
|
|
5
|
+
|
|
6
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
7
|
+
WIDTH, HEIGHT = 1600, 1200
|
|
8
|
+
SEED = 2026
|
|
9
|
+
SHIPPED = ("demo_fixations.csv", "demo_picture_A.png", "demo_picture_B.png")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def example_folder():
|
|
13
|
+
"""Folder holding the example files."""
|
|
14
|
+
if all(os.path.exists(os.path.join(HERE, name)) for name in SHIPPED):
|
|
15
|
+
return HERE
|
|
16
|
+
folder = os.path.join(os.path.expanduser("~"), ".creapy", "example")
|
|
17
|
+
os.makedirs(folder, exist_ok=True)
|
|
18
|
+
return folder
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def example_dataset():
|
|
22
|
+
"""Path of the example fixation CSV (demo_fixations.csv); pictures and table are generated on first call."""
|
|
23
|
+
folder = example_folder()
|
|
24
|
+
csv_path = os.path.join(folder, "demo_fixations.csv")
|
|
25
|
+
if not os.path.exists(csv_path):
|
|
26
|
+
make_example(folder)
|
|
27
|
+
return csv_path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def draw_picture_a(path):
|
|
31
|
+
"""Paint synthetic picture A."""
|
|
32
|
+
img = Image.new('RGB', (WIDTH, HEIGHT), (214, 203, 182))
|
|
33
|
+
d = ImageDraw.Draw(img)
|
|
34
|
+
d.rectangle([0, 760, WIDTH, HEIGHT], fill=(96, 112, 84))
|
|
35
|
+
d.rectangle([0, 0, WIDTH, 760], fill=(176, 196, 214))
|
|
36
|
+
d.ellipse([520, 260, 860, 700], fill=(150, 72, 60))
|
|
37
|
+
d.ellipse([610, 140, 770, 300], fill=(222, 186, 150))
|
|
38
|
+
d.ellipse([1180, 160, 1300, 280], fill=(250, 232, 120))
|
|
39
|
+
d.polygon([(1000, 760), (1250, 460), (1500, 760)], fill=(92, 84, 110))
|
|
40
|
+
d.rectangle([120, 820, 420, 1120], fill=(70, 60, 50))
|
|
41
|
+
img.save(path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def draw_picture_b(path):
|
|
45
|
+
"""Paint synthetic picture B."""
|
|
46
|
+
img = Image.new('RGB', (WIDTH, HEIGHT), (58, 52, 60))
|
|
47
|
+
d = ImageDraw.Draw(img)
|
|
48
|
+
d.polygon([(0, HEIGHT), (WIDTH, 0), (WIDTH, 220), (0, HEIGHT - 220)], fill=(110, 96, 88))
|
|
49
|
+
d.ellipse([260, 380, 520, 720], fill=(190, 150, 110))
|
|
50
|
+
d.ellipse([320, 250, 460, 390], fill=(230, 200, 170))
|
|
51
|
+
d.ellipse([1080, 400, 1340, 740], fill=(110, 130, 180))
|
|
52
|
+
d.ellipse([1140, 270, 1280, 410], fill=(230, 200, 170))
|
|
53
|
+
rng = np.random.default_rng(SEED)
|
|
54
|
+
for _ in range(40):
|
|
55
|
+
x, y = rng.uniform(600, 1000), rng.uniform(820, 1150)
|
|
56
|
+
d.ellipse([x, y, x + 24, y + 24], fill=(240, 220, 90))
|
|
57
|
+
d.rectangle([700, 60, 900, 200], fill=(200, 70, 60))
|
|
58
|
+
img.save(path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def simulate_viewer(rng, attractors, weights, n_fix, focal, t_total=30.):
|
|
62
|
+
"""Simulate one viewer on one picture: a list of dicts with t_start (ms), dur (ms), x, y (px) and pupil."""
|
|
63
|
+
ls = []
|
|
64
|
+
t = 0.05
|
|
65
|
+
xy = np.array([WIDTH / 2, HEIGHT / 2], dtype=float)
|
|
66
|
+
k = 0
|
|
67
|
+
while t < t_total and k < n_fix:
|
|
68
|
+
if k < 3:
|
|
69
|
+
target = np.array([WIDTH / 2, HEIGHT / 2]) + rng.normal(0, 90, 2)
|
|
70
|
+
else:
|
|
71
|
+
idx = rng.choice(len(attractors), p=weights)
|
|
72
|
+
ax, ay, spread = attractors[idx]
|
|
73
|
+
target = np.array([ax, ay]) + rng.normal(0, spread, 2)
|
|
74
|
+
xy = target if rng.random() > 0.3 else xy + rng.normal(0, 40, 2)
|
|
75
|
+
xy = np.clip(xy, [10, 10], [WIDTH - 10, HEIGHT - 10])
|
|
76
|
+
dur = float(np.exp(rng.normal(np.log(260 if focal else 190), 0.35)))
|
|
77
|
+
dur = dur * (0.8 if k < 3 else 1.0)
|
|
78
|
+
ls.append({'t_start': round(t * 1000, 1), 'dur': round(dur, 1), 'x': round(xy[0], 1), 'y': round(xy[1], 1),
|
|
79
|
+
'pupil': round(float(rng.normal(900 if focal else 850, 40)), 1)})
|
|
80
|
+
t += (dur + rng.uniform(25, 60)) / 1000.
|
|
81
|
+
k += 1
|
|
82
|
+
return ls
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def make_example(folder):
|
|
86
|
+
"""Write the whole example (two pictures, fixation table, README) into `folder`."""
|
|
87
|
+
img_a = os.path.join(folder, "demo_picture_A.png")
|
|
88
|
+
img_b = os.path.join(folder, "demo_picture_B.png")
|
|
89
|
+
draw_picture_a(img_a)
|
|
90
|
+
draw_picture_b(img_b)
|
|
91
|
+
rng = np.random.default_rng(SEED)
|
|
92
|
+
pictures = {
|
|
93
|
+
'demo_picture_A.png': [(690, 220, 60), (690, 480, 110), (1240, 220, 40), (270, 970, 90), (1250, 620, 120)],
|
|
94
|
+
'demo_picture_B.png': [(390, 320, 55), (1210, 340, 55), (390, 550, 90), (1210, 570, 90), (800, 980, 130),
|
|
95
|
+
(800, 130, 50)],
|
|
96
|
+
}
|
|
97
|
+
weights = {
|
|
98
|
+
('demo_picture_A.png', 'expert'): [0.32, 0.25, 0.08, 0.15, 0.20],
|
|
99
|
+
('demo_picture_A.png', 'layperson'): [0.25, 0.20, 0.30, 0.10, 0.15],
|
|
100
|
+
('demo_picture_B.png', 'expert'): [0.24, 0.24, 0.12, 0.12, 0.08, 0.20],
|
|
101
|
+
('demo_picture_B.png', 'layperson'): [0.18, 0.18, 0.12, 0.12, 0.30, 0.10],
|
|
102
|
+
}
|
|
103
|
+
ls = []
|
|
104
|
+
for i in range(12):
|
|
105
|
+
group = 'expert' if i < 6 else 'layperson'
|
|
106
|
+
participant = f"P{i + 1:02d}"
|
|
107
|
+
for trial, (picture, attractors) in enumerate(pictures.items(), start=1):
|
|
108
|
+
focal = group == 'expert'
|
|
109
|
+
n_fix = int(rng.integers(70, 110))
|
|
110
|
+
for row in simulate_viewer(rng, attractors, weights[(picture, group)], n_fix, focal):
|
|
111
|
+
row.update({'participant': participant, 'group': group, 'trial': trial, 'image': picture,
|
|
112
|
+
'age': int(25 + i * 2)})
|
|
113
|
+
ls.append(row)
|
|
114
|
+
df = pd.DataFrame.from_records(ls)[['participant', 'group', 'age', 'trial', 'image', 't_start', 'dur', 'x', 'y',
|
|
115
|
+
'pupil']]
|
|
116
|
+
df.to_csv(os.path.join(folder, "demo_fixations.csv"), index=False)
|
|
117
|
+
with open(os.path.join(folder, "README.md"), 'w', encoding='utf-8') as f:
|
|
118
|
+
f.write("# creapy example dataset (synthetic)\n\n"
|
|
119
|
+
"Generated by `creapy.examples.make_example`: twelve invented viewers (six 'expert', six "
|
|
120
|
+
"'layperson'), two generated pictures (1600 x 1200 px), 30 s of simulated viewing each.\n\n"
|
|
121
|
+
"`demo_fixations.csv`: one row per fixation with participant, group, age, trial, image, t_start (ms), "
|
|
122
|
+
"dur (ms), x, y (px), pupil (arbitrary).\n\nNo real eye-tracking data is involved.\n")
|
|
123
|
+
return df
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == '__main__':
|
|
127
|
+
print(example_dataset())
|