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.
@@ -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
+ )