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.
Files changed (81) hide show
  1. creapy/__init__.py +67 -0
  2. creapy/aoi/__init__.py +0 -0
  3. creapy/aoi/aoi.py +142 -0
  4. creapy/aoi/sequence.py +174 -0
  5. creapy/examples/README.md +7 -0
  6. creapy/examples/__init__.py +127 -0
  7. creapy/examples/demo_fixations.csv +2151 -0
  8. creapy/examples/demo_picture_A.png +0 -0
  9. creapy/examples/demo_picture_B.png +0 -0
  10. creapy/formats/__init__.py +5 -0
  11. creapy/formats/detect.py +127 -0
  12. creapy/formats/table.py +84 -0
  13. creapy/formats/tobii.py +339 -0
  14. creapy/gaze/__init__.py +0 -0
  15. creapy/gaze/base.py +160 -0
  16. creapy/gaze/convert.py +82 -0
  17. creapy/gaze/fixation.py +289 -0
  18. creapy/gaze/saccade.py +311 -0
  19. creapy/io/__init__.py +0 -0
  20. creapy/io/__main__.py +4 -0
  21. creapy/io/aoi_metrics_window.py +155 -0
  22. creapy/io/glossary.py +419 -0
  23. creapy/io/help.py +201 -0
  24. creapy/io/main.py +1865 -0
  25. creapy/io/project.py +48 -0
  26. creapy/io/session_log.py +73 -0
  27. creapy/io/style/crea_logo.png +0 -0
  28. creapy/io/style/fix_thumbnail.png +0 -0
  29. creapy/io/style/main_dark.qss +336 -0
  30. creapy/io/style/main_light.qss +207 -0
  31. creapy/io/style/sac_thumbnail.png +0 -0
  32. creapy/io/style/start_dark.qss +139 -0
  33. creapy/io/style/start_light.qss +93 -0
  34. creapy/io/views.py +418 -0
  35. creapy/io/widgets/__init__.py +0 -0
  36. creapy/io/widgets/column_mapping.py +113 -0
  37. creapy/io/widgets/compare_dialog.py +81 -0
  38. creapy/io/widgets/filter_bar.py +157 -0
  39. creapy/io/widgets/grouping_dialog.py +88 -0
  40. creapy/io/widgets/half_dial.py +220 -0
  41. creapy/io/widgets/painter.py +58 -0
  42. creapy/io/widgets/stimulus_strip.py +82 -0
  43. creapy/io/widgets/stimulus_thumbnail.py +76 -0
  44. creapy/io/widgets/table_view.py +47 -0
  45. creapy/io/widgets/toolbars/__init__.py +0 -0
  46. creapy/io/widgets/toolbars/aoi.py +170 -0
  47. creapy/io/widgets/toolbars/fix_heatmap.py +227 -0
  48. creapy/io/widgets/toolbars/fix_scanpath.py +114 -0
  49. creapy/io/widgets/toolbars/recurrence.py +83 -0
  50. creapy/io/widgets/toolbars/rose.py +100 -0
  51. creapy/io/widgets/toolbars/sac_heatmap.py +183 -0
  52. creapy/io/widgets/toolbars/scanpath.py +153 -0
  53. creapy/io/widgets/toolbars/scanpath_compare.py +162 -0
  54. creapy/io/widgets/toolbars/similarity.py +175 -0
  55. creapy/io/widgets/toolbars/stats.py +90 -0
  56. creapy/io/widgets/toolbars/time_series.py +117 -0
  57. creapy/io/widgets/utils.py +103 -0
  58. creapy/io/worker.py +96 -0
  59. creapy/misc/__init__.py +0 -0
  60. creapy/misc/colormapping.py +13 -0
  61. creapy/misc/contours.py +41 -0
  62. creapy/misc/plot_manager.py +73 -0
  63. creapy/misc/repulsion.py +106 -0
  64. creapy/misc/utils.py +66 -0
  65. creapy/similarity/__init__.py +0 -0
  66. creapy/similarity/grid.py +35 -0
  67. creapy/similarity/grid_fixation.py +66 -0
  68. creapy/similarity/grid_result.py +227 -0
  69. creapy/similarity/heatmatch_result.py +269 -0
  70. creapy/similarity/scanpath.py +189 -0
  71. creapy/stats/__init__.py +8 -0
  72. creapy/stats/descriptives.py +107 -0
  73. creapy/stats/recurrence.py +111 -0
  74. creapy/stats/spectrum.py +164 -0
  75. creapy/stats/timeseries.py +104 -0
  76. creapy-0.1.0.dist-info/METADATA +199 -0
  77. creapy-0.1.0.dist-info/RECORD +81 -0
  78. creapy-0.1.0.dist-info/WHEEL +5 -0
  79. creapy-0.1.0.dist-info/entry_points.txt +2 -0
  80. creapy-0.1.0.dist-info/licenses/LICENSE +21 -0
  81. 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())