scanpath-studio 0.14.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.
- scanpath_studio/__init__.py +13 -0
- scanpath_studio/__main__.py +18 -0
- scanpath_studio/annotations.py +320 -0
- scanpath_studio/app.py +752 -0
- scanpath_studio/constants.py +83 -0
- scanpath_studio/controls.py +467 -0
- scanpath_studio/data.py +961 -0
- scanpath_studio/export.py +510 -0
- scanpath_studio/measures.py +505 -0
- scanpath_studio/onestop_shard.py +139 -0
- scanpath_studio/plots.py +2186 -0
- scanpath_studio/sample_data/fixations.csv +3210 -0
- scanpath_studio/sample_data/fixations.parquet +0 -0
- scanpath_studio/sample_data/ia.csv +3923 -0
- scanpath_studio/sample_data/ia.parquet +0 -0
- scanpath_studio/sample_data/raw_gaze.csv +2234 -0
- scanpath_studio/sample_data/raw_gaze.parquet +0 -0
- scanpath_studio/styles.py +72 -0
- scanpath_studio/synthetic.py +133 -0
- scanpath_studio/tabs.py +1916 -0
- scanpath_studio/update_sample_data.py +500 -0
- scanpath_studio/utils.py +556 -0
- scanpath_studio-0.14.0.dist-info/METADATA +229 -0
- scanpath_studio-0.14.0.dist-info/RECORD +28 -0
- scanpath_studio-0.14.0.dist-info/WHEEL +5 -0
- scanpath_studio-0.14.0.dist-info/entry_points.txt +2 -0
- scanpath_studio-0.14.0.dist-info/licenses/LICENSE +21 -0
- scanpath_studio-0.14.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Streamlit workbench for scanpath visualization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["__version__", "main"]
|
|
6
|
+
__version__ = "0.14.0"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
"""Programmatic entry point — `from scanpath_studio import main`."""
|
|
11
|
+
from .app import main as _main
|
|
12
|
+
|
|
13
|
+
_main()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import importlib.resources as resources
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from streamlit.web import cli as stcli
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv: Optional[list[str]] = None) -> None:
|
|
9
|
+
"""Entrypoint that launches the Streamlit app via ``streamlit run``."""
|
|
10
|
+
extra_args = list(argv) if argv is not None else sys.argv[1:]
|
|
11
|
+
app_resource = resources.files(__package__).joinpath("app.py")
|
|
12
|
+
with resources.as_file(app_resource) as app_path:
|
|
13
|
+
sys.argv = ["streamlit", "run", str(app_path), *extra_args]
|
|
14
|
+
sys.exit(stcli.main())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Per-trial researcher annotations: favorites (stars), tags, and free notes.
|
|
2
|
+
|
|
3
|
+
Annotations are keyed by ``(participant_id, trial_id)`` and live in Streamlit
|
|
4
|
+
session state, so they persist across reruns within a session. There is no
|
|
5
|
+
backend; to keep annotations across sessions or share them, the sidebar offers
|
|
6
|
+
a JSON **download** (a portable sidecar) and **restore** (re-upload).
|
|
7
|
+
|
|
8
|
+
The module is split into a *pure* core (``records_to_store`` /
|
|
9
|
+
``store_to_records`` / ``serialize`` / ``deserialize`` — no Streamlit, unit
|
|
10
|
+
tested) and a thin session-backed layer plus the small render helpers used by
|
|
11
|
+
``tabs.py`` and ``app.py``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
18
|
+
|
|
19
|
+
import streamlit as st
|
|
20
|
+
|
|
21
|
+
ANNOTATIONS_STATE_KEY = "trial_annotations"
|
|
22
|
+
SCHEMA_VERSION = 1
|
|
23
|
+
|
|
24
|
+
# Per-trial annotation widgets use this prefix so they can be cleared on import
|
|
25
|
+
# (forcing a re-seed from the freshly loaded store). Kept distinct from the
|
|
26
|
+
# sidebar control keys ("anno_download" / "anno_upload").
|
|
27
|
+
_WIDGET_PREFIX = "annotrial_"
|
|
28
|
+
_LAST_IMPORT_KEY = "_anno_last_import"
|
|
29
|
+
|
|
30
|
+
# Always-available tag suggestions (users can add their own on top).
|
|
31
|
+
PRESET_TAGS = ["To exclude", "Review", "Good example", "Check alignment"]
|
|
32
|
+
|
|
33
|
+
# (participant_id, trial_id) -> {"star": bool, "tags": list[str], "note": str}
|
|
34
|
+
Key = Tuple[str, str]
|
|
35
|
+
Entry = Dict[str, object]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Pure core (no Streamlit) — unit tested in tests/test_annotations.py
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def default_entry() -> Entry:
|
|
44
|
+
return {"star": False, "tags": [], "note": ""}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _normalize_entry(star: object, tags: object, note: object) -> Entry:
|
|
48
|
+
clean_tags = sorted({str(t).strip() for t in (tags or []) if str(t).strip()})
|
|
49
|
+
return {"star": bool(star), "tags": clean_tags, "note": str(note or "").strip()}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_empty_entry(entry: Entry) -> bool:
|
|
53
|
+
"""True when an entry carries no information (and can be dropped)."""
|
|
54
|
+
return (
|
|
55
|
+
not entry.get("star")
|
|
56
|
+
and not entry.get("tags")
|
|
57
|
+
and not str(entry.get("note") or "").strip()
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def records_to_store(records: List[dict]) -> Dict[Key, Entry]:
|
|
62
|
+
"""Build a ``{(pid, tid): entry}`` store from a list of flat records."""
|
|
63
|
+
store: Dict[Key, Entry] = {}
|
|
64
|
+
for rec in records or []:
|
|
65
|
+
pid = rec.get("participant_id")
|
|
66
|
+
tid = rec.get("trial_id")
|
|
67
|
+
if pid is None or tid is None:
|
|
68
|
+
continue
|
|
69
|
+
entry = _normalize_entry(
|
|
70
|
+
rec.get("star", False), rec.get("tags", []), rec.get("note", "")
|
|
71
|
+
)
|
|
72
|
+
if not is_empty_entry(entry):
|
|
73
|
+
store[(str(pid), str(tid))] = entry
|
|
74
|
+
return store
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def store_to_records(store: Dict[Key, Entry]) -> List[dict]:
|
|
78
|
+
"""Flatten a store into a sorted list of records for JSON export."""
|
|
79
|
+
records = []
|
|
80
|
+
for (pid, tid), entry in sorted(store.items()):
|
|
81
|
+
records.append(
|
|
82
|
+
{
|
|
83
|
+
"participant_id": pid,
|
|
84
|
+
"trial_id": tid,
|
|
85
|
+
"star": bool(entry.get("star", False)),
|
|
86
|
+
"tags": list(entry.get("tags", [])),
|
|
87
|
+
"note": str(entry.get("note", "")),
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
return records
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def serialize(store: Dict[Key, Entry]) -> str:
|
|
94
|
+
"""Serialize a store to a JSON document string."""
|
|
95
|
+
return json.dumps(
|
|
96
|
+
{"schema": SCHEMA_VERSION, "annotations": store_to_records(store)}, indent=2
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def deserialize(text: str) -> Dict[Key, Entry]:
|
|
101
|
+
"""Parse a JSON document (object with ``annotations`` or a bare list)."""
|
|
102
|
+
data = json.loads(text)
|
|
103
|
+
if isinstance(data, dict):
|
|
104
|
+
records = data.get("annotations", [])
|
|
105
|
+
elif isinstance(data, list):
|
|
106
|
+
records = data
|
|
107
|
+
else:
|
|
108
|
+
records = []
|
|
109
|
+
return records_to_store(records)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Session-backed layer
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _store() -> Dict[Key, Entry]:
|
|
118
|
+
return st.session_state.setdefault(ANNOTATIONS_STATE_KEY, {})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_entry(participant_id: str, trial_id: str) -> Entry:
|
|
122
|
+
return _store().get((str(participant_id), str(trial_id)), default_entry())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def set_entry(
|
|
126
|
+
participant_id: str, trial_id: str, *, star: bool, tags: List[str], note: str
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Upsert an annotation; empty entries are pruned to keep the store small."""
|
|
129
|
+
key = (str(participant_id), str(trial_id))
|
|
130
|
+
entry = _normalize_entry(star, tags, note)
|
|
131
|
+
store = _store()
|
|
132
|
+
if is_empty_entry(entry):
|
|
133
|
+
store.pop(key, None)
|
|
134
|
+
else:
|
|
135
|
+
store[key] = entry
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def known_tags() -> List[str]:
|
|
139
|
+
"""Preset tags plus any tag used anywhere in the store, sorted."""
|
|
140
|
+
tags: Set[str] = set(PRESET_TAGS)
|
|
141
|
+
for entry in _store().values():
|
|
142
|
+
tags.update(entry.get("tags", []))
|
|
143
|
+
return sorted(tags)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def starred_keys() -> Set[Key]:
|
|
147
|
+
return {k for k, v in _store().items() if v.get("star")}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def keys_with_tag(tag: str) -> Set[Key]:
|
|
151
|
+
return {k for k, v in _store().items() if tag in v.get("tags", [])}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def annotated_count() -> int:
|
|
155
|
+
return len(_store())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# UI render helpers
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _add_tag_callback(tags_key: str, newtag_key: str) -> None:
|
|
164
|
+
"""on_change for the 'add tag' input: append to the multiselect's state.
|
|
165
|
+
|
|
166
|
+
Runs before the next rerun, so writing the multiselect's session_state here
|
|
167
|
+
is allowed (the widget hasn't been instantiated yet that run)."""
|
|
168
|
+
new_tag = str(st.session_state.get(newtag_key, "")).strip()
|
|
169
|
+
if not new_tag:
|
|
170
|
+
return
|
|
171
|
+
current = list(st.session_state.get(tags_key, []))
|
|
172
|
+
if new_tag not in current:
|
|
173
|
+
st.session_state[tags_key] = current + [new_tag]
|
|
174
|
+
st.session_state[newtag_key] = ""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def render_trial_annotations(participant_id: str, trial_id: str) -> None:
|
|
178
|
+
"""Render the per-trial annotations expander (star / tags / notes)."""
|
|
179
|
+
entry = get_entry(participant_id, trial_id)
|
|
180
|
+
slug = f"{participant_id}__{trial_id}"
|
|
181
|
+
star_key = f"{_WIDGET_PREFIX}star_{slug}"
|
|
182
|
+
tags_key = f"{_WIDGET_PREFIX}tags_{slug}"
|
|
183
|
+
note_key = f"{_WIDGET_PREFIX}note_{slug}"
|
|
184
|
+
newtag_key = f"{_WIDGET_PREFIX}newtag_{slug}"
|
|
185
|
+
|
|
186
|
+
# Seed widget state once from the store (re-seeds after a JSON import, which
|
|
187
|
+
# clears these keys).
|
|
188
|
+
st.session_state.setdefault(star_key, entry["star"])
|
|
189
|
+
st.session_state.setdefault(tags_key, list(entry["tags"]))
|
|
190
|
+
st.session_state.setdefault(note_key, entry["note"])
|
|
191
|
+
|
|
192
|
+
label = "📝 Annotations" + (" ⭐" if entry["star"] else "")
|
|
193
|
+
if entry["tags"]:
|
|
194
|
+
label += f" · {', '.join(entry['tags'])}"
|
|
195
|
+
with st.expander(label, expanded=False):
|
|
196
|
+
star = st.checkbox("⭐ Favorite (star this trial)", key=star_key)
|
|
197
|
+
# Options must include every currently-selected tag (incl. ones added
|
|
198
|
+
# via the input) or st.multiselect raises.
|
|
199
|
+
options = sorted(
|
|
200
|
+
set(known_tags())
|
|
201
|
+
| set(entry["tags"])
|
|
202
|
+
| set(st.session_state.get(tags_key, []))
|
|
203
|
+
)
|
|
204
|
+
tags = st.multiselect(
|
|
205
|
+
"Tags",
|
|
206
|
+
options=options,
|
|
207
|
+
key=tags_key,
|
|
208
|
+
help="Pick presets (e.g. 'To exclude') or add your own below.",
|
|
209
|
+
)
|
|
210
|
+
st.text_input(
|
|
211
|
+
"Add a tag",
|
|
212
|
+
key=newtag_key,
|
|
213
|
+
placeholder="type a tag, press Enter",
|
|
214
|
+
on_change=_add_tag_callback,
|
|
215
|
+
args=(tags_key, newtag_key),
|
|
216
|
+
)
|
|
217
|
+
note = st.text_area(
|
|
218
|
+
"Notes",
|
|
219
|
+
key=note_key,
|
|
220
|
+
placeholder="Researcher notes for this trial…",
|
|
221
|
+
height=100,
|
|
222
|
+
)
|
|
223
|
+
set_entry(participant_id, trial_id, star=star, tags=tags, note=note)
|
|
224
|
+
st.caption(
|
|
225
|
+
"Saved for this session. Use the sidebar **Annotations** panel to "
|
|
226
|
+
"download a JSON copy or restore one."
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def render_annotations_sidebar() -> None:
|
|
231
|
+
"""Render the sidebar Annotations panel: count + JSON download/restore."""
|
|
232
|
+
panel = st.sidebar.expander("Save & restore (JSON)", expanded=False)
|
|
233
|
+
count = annotated_count()
|
|
234
|
+
panel.caption(
|
|
235
|
+
f"{count} trial(s) annotated this session."
|
|
236
|
+
if count
|
|
237
|
+
else "No annotations yet — star, tag, or note trials in the Interactive Plot tab."
|
|
238
|
+
)
|
|
239
|
+
panel.download_button(
|
|
240
|
+
"⬇ Download annotations (JSON)",
|
|
241
|
+
data=serialize(_store()),
|
|
242
|
+
file_name="scanpath_annotations.json",
|
|
243
|
+
mime="application/json",
|
|
244
|
+
disabled=(count == 0),
|
|
245
|
+
key="anno_download",
|
|
246
|
+
help="A portable sidecar of all stars / tags / notes from this session.",
|
|
247
|
+
)
|
|
248
|
+
uploaded = panel.file_uploader(
|
|
249
|
+
"Restore annotations (JSON)",
|
|
250
|
+
type=["json"],
|
|
251
|
+
key="anno_upload",
|
|
252
|
+
help="Re-load a previously downloaded annotations file. Replaces the current set.",
|
|
253
|
+
)
|
|
254
|
+
if uploaded is not None:
|
|
255
|
+
signature = (uploaded.name, uploaded.size)
|
|
256
|
+
if st.session_state.get(_LAST_IMPORT_KEY) != signature:
|
|
257
|
+
try:
|
|
258
|
+
store = deserialize(uploaded.getvalue().decode("utf-8"))
|
|
259
|
+
except Exception as exc: # malformed file
|
|
260
|
+
panel.error(f"Could not load annotations: {exc}")
|
|
261
|
+
else:
|
|
262
|
+
st.session_state[ANNOTATIONS_STATE_KEY] = store
|
|
263
|
+
st.session_state[_LAST_IMPORT_KEY] = signature
|
|
264
|
+
# Drop per-trial widget state so it re-seeds from the new store.
|
|
265
|
+
for key in [
|
|
266
|
+
k
|
|
267
|
+
for k in list(st.session_state.keys())
|
|
268
|
+
if k.startswith(_WIDGET_PREFIX)
|
|
269
|
+
]:
|
|
270
|
+
del st.session_state[key]
|
|
271
|
+
panel.success(f"Loaded {len(store)} annotation(s).")
|
|
272
|
+
st.rerun()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def select_keys(
|
|
276
|
+
store: Dict[Key, Entry],
|
|
277
|
+
keys: List[Key],
|
|
278
|
+
*,
|
|
279
|
+
favorites_only: bool = False,
|
|
280
|
+
required_tags: Optional[List[str]] = None,
|
|
281
|
+
excluded_tags: Optional[List[str]] = None,
|
|
282
|
+
) -> List[Key]:
|
|
283
|
+
"""Pure core of :func:`filter_keys` — filter ``keys`` against ``store``.
|
|
284
|
+
|
|
285
|
+
- ``favorites_only``: keep only starred trials.
|
|
286
|
+
- ``required_tags``: keep trials carrying *any* of these tags.
|
|
287
|
+
- ``excluded_tags``: drop trials carrying *any* of these tags.
|
|
288
|
+
"""
|
|
289
|
+
required = set(required_tags or [])
|
|
290
|
+
excluded = set(excluded_tags or [])
|
|
291
|
+
out: List[Key] = []
|
|
292
|
+
for key in keys:
|
|
293
|
+
entry = store.get(key)
|
|
294
|
+
tags = set(entry.get("tags", [])) if entry else set()
|
|
295
|
+
starred = bool(entry.get("star")) if entry else False
|
|
296
|
+
if favorites_only and not starred:
|
|
297
|
+
continue
|
|
298
|
+
if required and not (tags & required):
|
|
299
|
+
continue
|
|
300
|
+
if excluded and (tags & excluded):
|
|
301
|
+
continue
|
|
302
|
+
out.append(key)
|
|
303
|
+
return out
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def filter_keys(
|
|
307
|
+
keys: List[Key],
|
|
308
|
+
*,
|
|
309
|
+
favorites_only: bool = False,
|
|
310
|
+
required_tags: Optional[List[str]] = None,
|
|
311
|
+
excluded_tags: Optional[List[str]] = None,
|
|
312
|
+
) -> List[Key]:
|
|
313
|
+
"""Session-backed wrapper around :func:`select_keys`."""
|
|
314
|
+
return select_keys(
|
|
315
|
+
_store(),
|
|
316
|
+
keys,
|
|
317
|
+
favorites_only=favorites_only,
|
|
318
|
+
required_tags=required_tags,
|
|
319
|
+
excluded_tags=excluded_tags,
|
|
320
|
+
)
|