Coverage for scanpath_studio/cli.py: 91%
1394 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
1"""Command-line interface for scanpath-studio.
3Subcommands:
4 scanpath-studio # launch the Streamlit app (default)
5 scanpath-studio run [args…] # same, forwarding extra args to streamlit
6 scanpath-studio render … # headless: render one trial to a file
8Anything that isn't a known subcommand is forwarded to ``streamlit run`` so
9pre-existing invocations like ``scanpath-studio --server.port 8502`` keep
10working.
11"""
13from __future__ import annotations
15import argparse
16import json
17import os
18import re
19import shlex
20import sys
21from dataclasses import replace
22from importlib import resources
23from pathlib import Path
25import pandas as pd
27from . import __version__
28from . import column_names as _cn
29from .code_snippet import (
30 SOURCE_AUTHOR,
31 SOURCE_DEMO,
32 SOURCE_MULTIPLEYE,
33 SOURCE_ONESTOP,
34 SOURCE_POTEC,
35 SnippetSource,
36 source_canvas,
37)
38from .constants import (
39 DEFAULT_FIXATION_COLOR,
40 DEFAULT_FIXATION_COLORSCALE,
41 DEFAULT_FIXATION_SYMBOL,
42 DEFAULT_HEATMAP_COLORSCALE,
43 DEFAULT_SACCADE_WIDTH,
44 FIXATION_SYMBOLS,
45 FONT_FAMILY,
46 SACCADE_CLASS_COLORS,
47 SACCADE_CLASS_EDITABLE,
48 SACCADE_CLASS_ORDER,
49 SACCADE_COLOR,
50 SACCADE_DASH_OPTIONS,
51 SACCADE_WIDTH_BOUNDS,
52 UNIFORM_COLOR_FIELD,
53 benchmark_corpora_enabled,
54 drift_correction_enabled,
55 multipleye_enabled,
56 palette_settings,
57)
60def _spell_color(argv: list[str]) -> list[str]:
61 """Every ``--…colour…`` flag as its ``--…color…`` name, so the British
62 spelling works too."""
63 out = []
64 for token in argv:
65 if isinstance(token, str) and token.startswith("--") and "colour" in token:
66 flag, sep, value = token.partition("=")
67 token = flag.replace("colour", "color") + sep + value
68 out.append(token)
69 return out
72class _ShortErrorParser(argparse.ArgumentParser):
73 """#374 F21: a parser whose error is three lines at most — the error, a
74 "did you mean" for a misspelled flag, and where every option is listed —
75 instead of the usage block (``render``'s alone runs to ~125 lines). The
76 "did you mean" is our own difflib pass, so it works on 3.11–3.13 too."""
78 def parse_known_args(self, args=None, namespace=None):
79 args = sys.argv[1:] if args is None else list(args)
80 return super().parse_known_args(_spell_color(args), namespace)
82 def _suggestions(self, message: str) -> str:
83 import difflib
85 match = re.match(r"unrecognized arguments: (.*)", message)
86 if not match:
87 return ""
88 known = [
89 flag
90 for action in self._actions
91 for flag in action.option_strings
92 if flag.startswith("--")
93 ]
94 hints = []
95 for token in match.group(1).split():
96 if not token.startswith("--"):
97 continue
98 close = difflib.get_close_matches(token.split("=")[0], known, n=1)
99 if close:
100 hints.append(close[0])
101 return f"Did you mean {', '.join(hints)}?" if hints else ""
103 def error(self, message: str):
104 lines = [f"{self.prog}: error: {message}"]
105 hint = self._suggestions(message)
106 if hint:
107 lines.append(hint)
108 lines.append(f"`{self.prog} --help` lists every option.")
109 self.exit(2, "\n".join(lines) + "\n")
112def _drift_algorithm(value: str) -> str:
113 """Validate ``--drift-correction`` against :data:`alignment.ALGORITHMS`.
115 Used as an argparse ``type=`` callable so the alignment import (which pulls
116 in scipy, ~0.7 s) is paid only when the flag is actually passed — plain
117 ``render`` and ``render --help`` stay instant. An unknown name raises
118 ``ArgumentTypeError``, so argparse exits non-zero listing the valid ones
119 instead of forwarding the string to the API.
120 """
121 from .alignment import ALGORITHMS
123 name = str(value).strip().lower()
124 if name not in ALGORITHMS:
125 raise argparse.ArgumentTypeError(
126 f"unknown algorithm {value!r}; choose one of {', '.join(ALGORITHMS)}."
127 )
128 return name
131def _palette_name(value: str) -> str:
132 """``--palette``: a short name (``print``) or the app's (#374)."""
133 from .api import resolve_palette
135 try:
136 return resolve_palette(value)
137 except ValueError as exc:
138 raise argparse.ArgumentTypeError(str(exc)) from None
141def _colorscale_name(value: str) -> str:
142 """Validate ``--heatmap-colorscale`` / ``--fixation-colorscale`` (EXP-13).
144 An argparse ``type=`` like :func:`_drift_algorithm`: a name Plotly doesn't
145 know otherwise surfaced as a ``PlotlyError`` traceback from deep inside the
146 builder, after the data had already loaded."""
147 import difflib
149 from plotly.colors import get_colorscale, named_colorscales
150 from plotly.exceptions import PlotlyError
152 try:
153 get_colorscale(str(value))
154 except PlotlyError:
155 names = named_colorscales()
156 close = difflib.get_close_matches(str(value).lower(), names, n=3, cutoff=0.6)
157 hint = f" Closest: {', '.join(close)}." if close else ""
158 raise argparse.ArgumentTypeError(
159 f"unknown colorscale {value!r}.{hint} Any Plotly named colorscale "
160 "works, e.g. Viridis, Greens, Blues, Cividis; append _r to reverse one."
161 )
162 return str(value)
165def _fill_color(value: str) -> str:
166 """Validate ``--word-box-fill-color`` — an argparse ``type=`` like
167 :func:`_colorscale_name`. The fill is drawn at its own opacity, so it needs
168 a color with RGB channels to put that alpha on; a name such as ``red``
169 would otherwise fail only once the figure was being built."""
170 from .plots import color_with_alpha
172 try:
173 color_with_alpha(value, 1.0)
174 except ValueError as exc:
175 raise argparse.ArgumentTypeError(str(exc))
176 return str(value).strip()
179#: The API keyword each schema flag stands for (EXP-13; the raw-gaze one EXP-20).
180_SCHEMA_FLAGS = {
181 "word_schema": "--word-schema",
182 "fix_schema": "--fix-schema",
183 "raw_gaze_schema": "--raw-gaze-schema",
184}
187def _add_schema_flags(group) -> None:
188 """``--word-schema`` / ``--fix-schema`` / ``--keep-columns`` on ``render``
189 and ``analyze`` — the loader's own options."""
190 for flag, table in (("--word-schema", "--words"), ("--fix-schema", "--fixations")):
191 group.add_argument(
192 flag,
193 metavar="JSON",
194 help=f"Column mapping for the {table} table, replacing auto-detection: "
195 "a JSON object (or a path to a .json file holding one) from each "
196 'field to a column name, e.g. \'{"trial": "TRIAL_INDEX", '
197 '"word_id": "IA_ID", ...}\' — the same dict '
198 "api.load_scanpath_data takes. Needed only when a column isn't "
199 "recognized; the error then prints a mapping to start from.",
200 )
201 group.add_argument(
202 "--keep-columns",
203 nargs="+",
204 metavar="COLUMN",
205 default=None,
206 help="Further columns of your own to carry through loading under their "
207 "own names (e.g. a pupil size), from whichever table has them — "
208 "otherwise loading keeps only the mapped and recognized fields. "
209 "A kept fixation column can then be --color-by, an axis or a hover "
210 "field. The keep_columns= of api.load_scanpath_data.",
211 )
214def _parse_schema_arg(value: str | None, flag: str) -> dict | None:
215 """``--word-schema`` / ``--fix-schema`` → the mapping dict (EXP-13).
217 Inline JSON when it starts with ``{``, a path to a ``.json`` file otherwise,
218 so a mapping too long for a shell line can live next to the data."""
219 if value is None:
220 return None
221 text = str(value).strip()
222 if not text.startswith("{"):
223 try:
224 text = Path(text).expanduser().read_text(encoding="utf-8")
225 except OSError as exc:
226 raise SystemExit(
227 f"{flag}: {value!r} is neither a JSON object nor a readable file "
228 f"({exc.strerror or exc})."
229 )
230 try:
231 mapping = json.loads(text)
232 except json.JSONDecodeError as exc:
233 raise SystemExit(
234 f"{flag}: not valid JSON ({exc.msg}, line {exc.lineno} column "
235 f'{exc.colno}). Expected an object such as \'{{"trial": "TRIAL_INDEX"}}\'.'
236 )
238 def is_column(item) -> bool:
239 return item is None or isinstance(item, str)
241 if not isinstance(mapping, dict) or not all(
242 isinstance(key, str)
243 and (
244 is_column(column)
245 or (isinstance(column, list) and all(isinstance(c, str) for c in column))
246 )
247 for key, column in mapping.items()
248 ):
249 raise SystemExit(
250 f"{flag} expects a JSON object from each field to a column name (a "
251 "list of names for a composite id, or null to leave a field unmapped)."
252 )
253 return mapping
256def _load_error_message(exc: Exception, *, schema_flags: bool = True) -> str:
257 """A load failure as the command line should say it (EXP-13).
259 A :class:`api.SchemaError` ends with the API's "pass ``word_schema={…}``"
260 hint, which a shell user has no way to act on; this swaps it for the
261 ``--word-schema`` / ``--fix-schema`` form. ``schema_flags=False`` is for the
262 second comparison dataset, which has no mapping flag of its own."""
263 from .api import SchemaError
264 from .data import StimulusJoinError
266 if isinstance(exc, StimulusJoinError):
267 # DATA-49: the fix is a mapping, and here a mapping is a flag.
268 message = str(exc).replace("`", "'")
269 if not schema_flags:
270 return message
271 return (
272 f"{message}\nOn the command line, map it with --word-schema and "
273 "--fix-schema: each takes that table's full mapping as JSON (or a "
274 'path to a .json file), with "text_id" naming its text column.'
275 )
276 if not isinstance(exc, SchemaError):
277 return str(exc)
278 flag = _SCHEMA_FLAGS.get(exc.param)
279 if flag is None or not schema_flags:
280 return (
281 f"{exc.detail}\nThis table's columns have to be auto-detected here — "
282 "rename them, or build the figure in Python, where "
283 f"api.load_scanpath_data takes {exc.param}=."
284 )
285 detail = exc.detail.replace(exc.param, flag)
286 if exc.mapping is None:
287 return f"{detail}\nCorrect the column names in {flag}, or drop it to use auto-detection."
288 example = json.dumps(exc.mapping)
289 return (
290 f"{detail}\nTo map the columns yourself, pass the full mapping as JSON — it "
291 "replaces auto-detection, so it needs every required key:\n"
292 f" {flag} {shlex.quote(example)}\n(or a path to a .json file holding it)."
293 )
296def _theme_cli_flags() -> list[str]:
297 """The branded theme as ``--theme.*`` CLI flags (BUG-6).
299 Streamlit resolves ``.streamlit/config.toml`` relative to the launch
300 directory, so ``python -m scanpath_studio`` from outside ``app/`` (or a
301 ``pip``-installed console script) misses the bundled config and renders the
302 default red accent. Passing the theme explicitly makes every launch path
303 match. Values live in ``constants`` (kept in sync with the config file)."""
304 from .constants import APP_THEME, APP_THEME_DARK
306 flags = [f"--theme.{key}={value}" for key, value in APP_THEME.items()]
307 flags += [f"--theme.dark.{key}={value}" for key, value in APP_THEME_DARK.items()]
308 return flags
311def _max_upload_cli_flags(extra_args) -> list[str]:
312 """Raise the per-file upload cap past Streamlit's 200 MB default.
314 Same reason as the theme above: ``.streamlit/config.toml`` is resolved
315 against the *launch* directory, so a pip-installed ``scanpath-studio`` run
316 from anywhere but the repo root never saw the bundled
317 ``server.maxUploadSize`` and rejected any table over 200 MB — which is a
318 normal size for a real fixation report. An explicit ``--server.*`` flag from
319 the caller still wins.
321 ENG-68: a deployment's own ``SCANPATH_MAX_UPLOAD_MB``, when lower, is
322 passed to the server as well, so the cap the upload boxes show is one the
323 server enforces rather than only the browser.
324 """
325 from .constants import UPLOAD_MAX_SIZE_MB, configured_upload_limit_mb
327 if any(str(arg).startswith("--server.maxUploadSize") for arg in extra_args):
328 return []
329 limit = min(configured_upload_limit_mb() or UPLOAD_MAX_SIZE_MB, UPLOAD_MAX_SIZE_MB)
330 return [f"--server.maxUploadSize={limit}"]
333#: Where ``scanpath-studio`` listens unless told otherwise (ENG-55).
334LOOPBACK_ADDRESS = "127.0.0.1"
337def _configured_server_address() -> str | None:
338 """``server.address`` from a ``config.toml`` that ``streamlit run`` reads.
340 Read here with ``tomllib`` rather than through ``streamlit.config``, whose
341 parse is cached: parsing once now and again (with the flags) at launch makes
342 Streamlit log that the ``[server]`` section changed and must be restarted."""
343 import tomllib
345 from streamlit import config as st_config
347 for path in st_config.get_config_files("config.toml"):
348 try:
349 with open(path, "rb") as handle:
350 address = (tomllib.load(handle).get("server") or {}).get("address")
351 except (OSError, tomllib.TOMLDecodeError):
352 continue
353 if address:
354 return str(address)
355 return None
358def _bind_cli_flags(extra_args) -> list[str]:
359 """Bind to loopback unless the caller chose an address (ENG-55).
361 Streamlit's own default is every interface (``0.0.0.0``), and the app has
362 no login: a local run on a campus or café network served the loaded corpus
363 — and the on-device recovery cache — to anyone who could reach the port.
364 The desktop launcher already bound loopback (S1); every other launch now
365 does too. An address the user set anywhere Streamlit reads one — a
366 ``--server.address`` flag, ``STREAMLIT_SERVER_ADDRESS``, or a
367 ``config.toml`` — wins, so serving on a network stays one flag away."""
368 if any(str(arg).startswith("--server.address") for arg in extra_args):
369 return []
370 if os.environ.get("STREAMLIT_SERVER_ADDRESS"):
371 return []
372 if _configured_server_address() is not None:
373 return []
374 return [f"--server.address={LOOPBACK_ADDRESS}"]
377def _consume_download_dir(extra_args: list[str]) -> list[str]:
378 """Strip ``--download-dir DIR`` / ``--download-dir=DIR`` into its env var."""
379 from .constants import DOWNLOAD_DIR_ENV
381 rest: list[str] = []
382 args = iter(extra_args)
383 for arg in args:
384 if arg == "--download-dir":
385 value = next(args, None)
386 if value is None:
387 raise SystemExit("--download-dir needs a folder")
388 os.environ[DOWNLOAD_DIR_ENV] = value
389 elif str(arg).startswith("--download-dir="):
390 os.environ[DOWNLOAD_DIR_ENV] = str(arg).split("=", 1)[1]
391 else:
392 rest.append(arg)
393 return rest
396def launch_app(extra_args: list[str]) -> None:
397 """Launch the Streamlit app via ``streamlit run``, forwarding extra args."""
398 from streamlit.web import cli as stcli
400 # ENG-30: `--no-persist` is ours, not Streamlit's — consume it here (it would
401 # otherwise reach `streamlit run` as an unknown flag) and set the env var the
402 # app reads, so one launch runs without the on-device recovery cache.
403 if "--no-persist" in extra_args:
404 from .persistence import PERSIST_ENV_VAR
406 extra_args = [arg for arg in extra_args if arg != "--no-persist"]
407 os.environ[PERSIST_ENV_VAR] = "0"
409 # UX-184: `--download-dir DIR` (or `=DIR`) is ours too — where ⬇ Download
410 # saves the public corpora when the Data Management page's Download folder is blank.
411 extra_args = _consume_download_dir(extra_args)
413 # Inject the branded theme unless the caller passes their own ``--theme.*``
414 # (explicit flags win), so the app looks the same regardless of where it was
415 # launched from (BUG-6).
416 theme_args = (
417 []
418 if any(str(arg).startswith("--theme") for arg in extra_args)
419 else _theme_cli_flags()
420 )
421 # Streamlit's usage stats default to ON, and `.streamlit/config.toml` is
422 # resolved against the *launch* directory — which for a pip-installed
423 # `scanpath-studio` is wherever the user happened to be. Opt out explicitly,
424 # same override rule as the theme: an explicit flag from the caller wins
425 # (DATA-12). The desktop launcher already passes this.
426 stats_args = (
427 []
428 if any(str(arg).startswith("--browser.gatherUsageStats") for arg in extra_args)
429 else ["--browser.gatherUsageStats=false"]
430 )
431 # UX-183: Streamlit's toolbar resolves to "developer" on localhost, which
432 # puts a Deploy button (to Streamlit Community Cloud) in the header. The
433 # "viewer" toolbar drops it and the other developer-only items but keeps
434 # the ⋮ menu with its System / Light / Dark switch. Same override
435 # rule: `--client.toolbarMode=developer` brings the full toolbar back.
436 toolbar_args = (
437 []
438 if any(str(arg).startswith("--client.toolbarMode") for arg in extra_args)
439 else ["--client.toolbarMode=viewer"]
440 )
441 # The entry shim, not app.py itself: it runs the app inside crash_report's
442 # guard, which an import-time error in app.py would otherwise escape.
443 app_resource = resources.files(__package__).joinpath("streamlit_entry.py")
444 with resources.as_file(app_resource) as app_path:
445 sys.argv = [
446 "streamlit",
447 "run",
448 str(app_path),
449 *theme_args,
450 *stats_args,
451 *toolbar_args,
452 *_max_upload_cli_flags(extra_args),
453 *_bind_cli_flags(extra_args),
454 *extra_args,
455 ]
456 sys.exit(stcli.main())
459def _render_parser() -> argparse.ArgumentParser:
460 from .alignment import ALGORITHMS
462 parser = _ShortErrorParser(
463 prog="scanpath-studio render",
464 description=(
465 "Render one trial's scanpath to a file without launching the app. "
466 "HTML output is interactive and needs no browser; PNG/SVG/PDF need "
467 "Chrome, Chromium or Edge (or run `plotly_get_chrome -y` once)."
468 ),
469 )
470 src = parser.add_argument_group("input (bundled demo, or words and/or fixations)")
471 src.add_argument(
472 "--sample",
473 action="store_true",
474 help="Use the bundled OneStop demo: 2 participants, 12 trials each "
475 "(--list-trials shows them).",
476 )
477 src.add_argument(
478 "--authoring",
479 metavar="PATH",
480 help="An authoring file from the app's Author a scanpath screen.",
481 )
482 src.add_argument(
483 "--words",
484 metavar="PATH",
485 nargs="+",
486 help="Words table(s) (csv/tsv/txt/tab/parquet/feather/xlsx/xls, or a .zip of them); columns are "
487 "auto-detected from EyeLink, Gazepoint, Tobii, SMI, Pupil Labs and "
488 "snake_case names. Multiple paths or a quoted glob pattern concatenate "
489 "multi-file datasets.",
490 )
491 src.add_argument(
492 "--fixations",
493 metavar="PATH",
494 nargs="+",
495 help="Fixations table(s) (csv/tsv/txt/tab/parquet/feather/xlsx/xls, or a .zip of them), auto-detected like "
496 "--words. Multiple paths or a quoted glob pattern concatenate "
497 "multi-file datasets (e.g. one file per participant).",
498 )
499 src.add_argument(
500 "--image-root",
501 metavar="DIR",
502 help="Local stimulus-image folder. Files are matched per row using "
503 "--image-pattern.",
504 )
505 src.add_argument(
506 "--image-pattern",
507 default="{text_id}.png",
508 metavar="PATTERN",
509 help="Relative filename pattern with row placeholders, for example "
510 "'{text_id}.png' or '{participant_id}/{trial_id}.png'.",
511 )
512 src.add_argument(
513 "--trial-parts-manifest",
514 metavar="PATH",
515 help="JSON manifest that assigns arbitrary source rows to ordered screens "
516 "inside each logical trial. Use with --words/--fixations when the source "
517 "tables have no explicit screen columns.",
518 )
519 _add_schema_flags(src)
520 src.add_argument(
521 "--potec",
522 metavar="DIR",
523 help="Load the PoTeC corpus (DiLi-Lab/PoTeC) from DIR, downloading "
524 "the needed files (~45 MB) on first use. Participants are the corpus's "
525 "75 ids (sparse within 0–105; --list-trials shows them); a trial is "
526 "one participant reading one text, <participant>_<text> (0_b0), "
527 "with texts b0–b5 and p0–p5.",
528 )
530 # DATA-55: the harmonised benchmark corpora are held back from the beta, the
531 # same way DATA-54 holds back MultiplEYE's flags below: they still parse and
532 # work, but `--help` (and the generated CLI reference) doesn't list them.
533 def benchmark_help(text: str) -> str:
534 return text if benchmark_corpora_enabled() else argparse.SUPPRESS
536 src.add_argument(
537 "--eyegenbench",
538 metavar="DIR",
539 help=benchmark_help(
540 "EyeGenBench bundle directory (built by "
541 "scripts/prepare_eyegenbench.py). Pick the corpus with "
542 "--eyegenbench-dataset."
543 ),
544 )
545 src.add_argument(
546 "--eyegenbench-dataset",
547 metavar="NAME",
548 help=benchmark_help("Which EyeGenBench corpus to render, e.g. PoTeC."),
549 )
550 src.add_argument(
551 "--onestop",
552 metavar="DIR",
553 help="Load the OneStop corpus from DIR. For the public variant the "
554 "chosen regime + parts' reports are downloaded from OSF on first use "
555 "(tens–hundreds MB each); the lacclab variant reads a local export. "
556 "Tune with --onestop-regime / --onestop-part / --onestop-variant.",
557 )
558 src.add_argument(
559 "--onestop-regime",
560 metavar="REGIME",
561 choices=[
562 "ordinary",
563 "information_seeking",
564 "repeated",
565 "information_seeking_repeated",
566 ],
567 default="ordinary",
568 help="OneStop reading regime for --onestop (default: ordinary).",
569 )
570 src.add_argument(
571 "--onestop-part",
572 metavar="PART",
573 action="append",
574 choices=[
575 "Title",
576 "Question_Preview",
577 "Paragraph",
578 "Questions",
579 "Answers",
580 "QA",
581 "Feedback",
582 ],
583 help="OneStop trial part(s) for --onestop; repeatable (default: "
584 "Paragraph). Loading several makes each part its own trial.",
585 )
586 src.add_argument(
587 "--onestop-variant",
588 metavar="VARIANT",
589 choices=["public", "lacclab"],
590 default="public",
591 help="OneStop source variant for --onestop: 'public' (OSF download) or "
592 "'lacclab' (a local lab-processed export; no download).",
593 )
595 # DATA-54: MultiplEYE is held back from the beta. Its flags still parse and
596 # work, so a script that already uses them keeps running (PRE-22's rule), but
597 # `--help` — and the docs' reference, generated from it — don't list them.
598 def mpe_help(text: str) -> str:
599 return text if multipleye_enabled() else argparse.SUPPRESS
601 src.add_argument(
602 "--source",
603 metavar="NAME",
604 choices=["multipleye"],
605 help=mpe_help(
606 "Load a native server-bundle corpus from its RAW export instead of "
607 "raw words/fixations tables. Currently only 'multipleye' — pair with "
608 "--export DIR. Renders through the same native loader (correct word "
609 "boxes/text/page layout, 1920x1080 monitor) as the interactive viewer."
610 ),
611 )
612 src.add_argument(
613 "--export",
614 metavar="DIR",
615 help=mpe_help(
616 "Raw export root for --source (e.g. a MultiplEYE_*_* export dir with "
617 "per-session scanpaths/ subfolders). Defaults to $MULTIPLEYE_DATA_DIR "
618 "for --source multipleye."
619 ),
620 )
621 src.add_argument(
622 "--no-question-screens",
623 action="store_true",
624 help=mpe_help(
625 "--source multipleye: load the reading pages only, leaving out the "
626 "comprehension-question screens (they are included by default, as "
627 "screens of the same trial)."
628 ),
629 )
631 src.add_argument(
632 "--participant-metadata",
633 metavar="FILE",
634 help="Participant-level metadata table: one row per participant, an "
635 "id column plus anything known about them. The join is validated and "
636 "reported against the loaded participants, and the fields are added to "
637 "--list-trials output.",
638 )
640 src.add_argument(
641 "--trial-metadata",
642 metavar="FILE",
643 help="Trial-level metadata table: one row per trial, a "
644 "trial-id column plus anything known about it. Validated and reported "
645 "the same way, and its fields are added to --list-trials output.",
646 )
647 src.add_argument(
648 "--trial-metadata-participant-column",
649 "--trial-metadata-reader-column",
650 dest="trial_metadata_reader_column",
651 metavar="COLUMN",
652 help="Key the --trial-metadata table by participant AND trial, using "
653 "this column as the participant id. Without it the table is keyed by "
654 "trial id alone: a row describes a text, and every trial of it "
655 "inherits that row. Never inferred: nothing in the file says which "
656 "of the two a corpus means.",
657 )
658 src.add_argument(
659 "--text-metadata",
660 metavar="FILE",
661 help="Text-level metadata table: one row per text, a text-id column "
662 "plus anything known about it. Validated and reported the same way, "
663 "and its fields are added to --list-trials output. Never keyed by "
664 "participant: a text is a stimulus, not something one participant owns.",
665 )
667 parser.add_argument(
668 "-p", "--participant", help="Participant id (default: first available)."
669 )
670 parser.add_argument(
671 "-t", "--trial", help="Trial id (default: first for the participant)."
672 )
673 parser.add_argument(
674 "--screen",
675 help="Screen/part id inside a multipart trial (default: first screen).",
676 )
677 parser.add_argument(
678 "--list-trials",
679 action="store_true",
680 help="Print every trial (participant, trial and text id) and exit; "
681 "pass the trial id to -t.",
682 )
683 parser.add_argument(
684 "--list-parts",
685 action="store_true",
686 help="Print ordered multipart screens, optionally narrowed by -p/-t, and exit.",
687 )
688 parser.add_argument(
689 "--all-screens",
690 action="store_true",
691 help="Render every screen of the selected parent trial. Screen ids are "
692 "inserted before the output extension.",
693 )
694 parser.add_argument(
695 "--screens",
696 metavar="ID[,ID...]",
697 help="Like --all-screens, but only these screens of the parent trial "
698 "(comma-separated screen ids, e.g. Title,Paragraph); see --list-parts.",
699 )
700 parser.add_argument(
701 "--screen-transition",
702 choices=["instant", "recorded"],
703 default="instant",
704 help="For --all-screens --animate, record zero or observed inter-screen "
705 "delay in each output's metadata (default: instant).",
706 )
707 parser.add_argument(
708 "-o",
709 "--output",
710 metavar="PATH",
711 help="Output file; format from extension (.html/.png/.svg/.pdf).",
712 )
713 parser.add_argument(
714 "--animate",
715 action="store_true",
716 help="Render the animated replay instead of the static figure (HTML only).",
717 )
719 viz = parser.add_argument_group(
720 "visualization (draws the app's Scanpath design: fixations, saccades "
721 "and the text; add --word-boxes, --heatmap or --fixation-index, or "
722 "hide a layer with its --no-* flag)"
723 )
724 # #374 F21: every layer switch defaults to None — "not given" — so only a
725 # flag on the line overrides the API's default (the app's Scanpath design).
726 viz.add_argument(
727 "--word-boxes",
728 dest="show_words",
729 action="store_true",
730 default=None,
731 help="Draw the word boxes.",
732 )
733 viz.add_argument(
734 "--no-word-boxes",
735 "--no-words",
736 dest="show_words",
737 action="store_false",
738 default=None,
739 help="Hide the word boxes (the default).",
740 )
741 viz.add_argument(
742 "--no-text",
743 "--no-labels",
744 dest="show_word_labels",
745 action="store_false",
746 default=None,
747 help="Hide the reading text.",
748 )
749 viz.add_argument(
750 "--no-fixations",
751 dest="show_fixations",
752 action="store_false",
753 default=None,
754 help="Hide fixation markers.",
755 )
756 viz.add_argument(
757 "--fixation-index",
758 dest="show_order",
759 action="store_true",
760 default=None,
761 help="Number the fixations in reading order.",
762 )
763 viz.add_argument(
764 "--no-fixation-index",
765 "--no-order",
766 dest="show_order",
767 action="store_false",
768 default=None,
769 help="Hide the fixation numbers (the default).",
770 )
771 viz.add_argument(
772 "--word-hover-fields",
773 metavar="FIELDS",
774 help="Comma-separated word columns shown on hover (e.g. "
775 "text,word_id,gpt2_surprisal).",
776 )
777 viz.add_argument(
778 "--fixation-hover-fields",
779 metavar="FIELDS",
780 help="Comma-separated fixation columns shown on hover (e.g. "
781 "order_in_trial,duration_ms,eye).",
782 )
783 viz.add_argument(
784 "--no-saccades",
785 dest="show_saccades",
786 action="store_false",
787 default=None,
788 help="Hide saccade lines.",
789 )
790 viz.add_argument(
791 "--heatmap",
792 dest="show_heatmap",
793 action="store_true",
794 default=None,
795 help="Draw the heatmap.",
796 )
797 viz.add_argument(
798 "--no-heatmap",
799 dest="show_heatmap",
800 action="store_false",
801 default=None,
802 help="Hide the heatmap (the default).",
803 )
804 viz.add_argument(
805 "--saccade-arrows",
806 dest="show_saccade_arrows",
807 action="store_true",
808 default=None,
809 help="Draw saccade direction arrowheads.",
810 )
811 viz.add_argument(
812 "--saccade-color",
813 metavar="COLOR",
814 help=f"Saccade line/arrow color, hex or CSS name (default: {SACCADE_COLOR}).",
815 )
816 viz.add_argument(
817 "--saccade-style",
818 choices=list(SACCADE_DASH_OPTIONS.values()),
819 help="Saccade line dash style (default: solid).",
820 )
821 viz.add_argument(
822 "--saccade-width",
823 type=float,
824 metavar="PX",
825 help=f"Saccade line width in px, "
826 f"{SACCADE_WIDTH_BOUNDS[0]:g}–{SACCADE_WIDTH_BOUNDS[1]:g} "
827 f"(default: {DEFAULT_SACCADE_WIDTH:g}).",
828 )
829 viz.add_argument(
830 "--saccade-color-by-type",
831 dest="saccade_color_by_type",
832 action="store_true",
833 help="Color each saccade by its reading type (forward / skip / "
834 "refixation / return sweep / regression) instead of one uniform color.",
835 )
836 viz.add_argument(
837 "--saccade-color-by-direction",
838 dest="saccade_color_by_direction",
839 action="store_true",
840 help="Color saccades forward vs. regression only — the two-way split "
841 "between one uniform color and the full --saccade-color-by-type "
842 "breakdown.",
843 )
844 viz.add_argument(
845 "--saccade-type-color",
846 dest="saccade_type_colors",
847 metavar="CLASS=COLOR",
848 action="append",
849 help="Override a reading-type color, e.g. --saccade-type-color "
850 "regression=#000000 (repeatable; classes: forward, skip, refixation, "
851 "return_sweep, regression). Implies --saccade-color-by-type, unless "
852 "--saccade-color-by-direction is given — then it recolors that two-way "
853 "split (its forward and regression colors).",
854 )
855 viz.add_argument(
856 "--no-saccade-type-legend",
857 dest="saccade_type_legend",
858 action="store_false",
859 help="With --saccade-color-by-type: hide the saccade-type color key on "
860 "the figure (the colored lines still draw). Legend shows by default.",
861 )
862 viz.add_argument(
863 "--fix-index-range",
864 dest="fix_index_range",
865 metavar="START:END",
866 help="Draw only fixations START through END of the trial "
867 "(1-based, both inclusive), e.g. --fix-index-range 1:40. Honored by "
868 "--animate too, which then replays only that window, and by "
869 "--compare-with, which windows both scanpaths (unless "
870 "--compare-fix-index-range gives B its own).",
871 )
872 viz.add_argument(
873 "--highlight-column",
874 dest="highlight_column",
875 metavar="COLUMN",
876 help="Boolean words column marking the text to highlight — the "
877 "critical span (default: is_in_aspan, OneStop's answer span). Pass "
878 "--highlight-column '' to highlight nothing. How it is drawn is "
879 "--critical-span-style.",
880 )
881 viz.add_argument(
882 "--critical-span-style",
883 dest="critical_span_style",
884 choices=("mark-text", "mark-border", "none"),
885 help="How the --highlight-column words are marked: mark-text recolors "
886 "them, mark-border outlines their boxes, none draws neither "
887 "(default: mark-text).",
888 )
889 viz.add_argument(
890 "--fixation-flag",
891 dest="fixation_flags",
892 action="append",
893 metavar="SPEC",
894 help="The app's Filters & highlights for fixations, repeatable. SPEC is "
895 "CATEGORY=MODE[,threshold_ms=N][,symbol=S][,color=#RRGGBB] with "
896 "CATEGORY one of short, long, oob (outside every word box), blink and "
897 "MODE one of off, highlight, discard — e.g. --fixation-flag "
898 "short=discard,threshold_ms=80. discard drops those fixations from the "
899 "drawing only; measures and exports are untouched. threshold_ms applies "
900 "to short/long only.",
901 )
902 viz.add_argument(
903 "--legend",
904 dest="legend_layout",
905 action="append",
906 metavar="SPEC",
907 help="Place one legend, repeatable. SPEC is KIND=POSITION[,ARRANGEMENT]"
908 "[,SIZE] with KIND one of compare, saccades, colors (the fixation "
909 "colour categories), size-key; POSITION one of auto, above, below, "
910 "left, right, top-left, top-right, bottom-left, bottom-right (the last "
911 "four inside the plot); ARRANGEMENT stacked or side-by-side; SIZE the "
912 "text size in px — e.g. --legend saccades=right,stacked,14. Whether a "
913 "legend is drawn at all is still its own switch.",
914 )
915 viz.add_argument(
916 "--saccade-classes",
917 dest="saccade_classes",
918 metavar="CLASSES",
919 help="Draw only these reading classes, comma-separated, e.g. "
920 "--saccade-classes regression,return_sweep (classes: forward, skip, "
921 "refixation, return_sweep, regression, other). Hidden classes lose "
922 "their line and their direction arrow. Default: all.",
923 )
924 viz.add_argument(
925 "--saccade-arcs",
926 dest="saccade_arcs",
927 action="store_true",
928 help="Draw saccades as upward arcs (the linear-reading diagram) instead "
929 "of straight connectors.",
930 )
931 viz.add_argument(
932 "--snap-fixations",
933 dest="snap_fixations",
934 action="store_true",
935 help="Snap each fixation above the word it lands on instead of its raw "
936 "gaze point.",
937 )
938 viz.add_argument(
939 "--illustration",
940 action="store_true",
941 help="Apply the clean schematic preset: snapped fixations, arced "
942 "saccades, uniform colors, and no analytical overlays.",
943 )
944 viz.add_argument(
945 "--illustration-label",
946 choices=["auto", "show", "hide"],
947 default="auto",
948 help="Auto-label transformed/schematic figures, force the label, or "
949 "explicitly hide it (default: auto).",
950 )
951 viz.add_argument(
952 "--illustration-text",
953 metavar="TEXT",
954 help='The Illustration label\'s text (default: "Illustration · <reasons>").',
955 )
956 # PRE-3: vertical drift correction. The algorithm list below is spelled out
957 # for `--help`; `alignment.ALGORITHMS` stays the source of truth (the flag
958 # validates against it via _drift_algorithm, and a test pins the two lists
959 # together).
960 #
961 # PRE-21: the flags are not *added* while the feature is gated off, so
962 # `--help` doesn't advertise something that would then refuse, and passing
963 # one is an ordinary argparse "unrecognized arguments" error. `args` still
964 # carries the attributes below via `set_defaults`, so no downstream branch
965 # needs to know whether the flag exists.
966 if drift_correction_enabled():
967 viz.add_argument(
968 "--drift-correction",
969 metavar="ALGORITHM",
970 type=_drift_algorithm,
971 default=None,
972 help="Correct vertical drift before plotting: snap each "
973 "fixation to its assigned text line and color the fixations by "
974 "line, exactly like the app's 👁️ Fixations ▾ → Drift correction. "
975 f"ALGORITHM is one of: {', '.join(ALGORITHMS)} "
976 "(default: no correction). Static figures only — not honored with "
977 "--animate.",
978 )
979 viz.add_argument(
980 "--drift-connectors",
981 dest="drift_connectors",
982 action="store_true",
983 help="With --drift-correction: draw a faint line from each "
984 "fixation's original y to its corrected one, so the size of the "
985 "shift stays visible.",
986 )
987 else:
988 viz.set_defaults(drift_correction=None, drift_connectors=False)
989 viz.add_argument(
990 "--palette",
991 type=_palette_name,
992 metavar="{default,print,high-contrast}",
993 help="Color palette for the marks (the app's Palette): default "
994 "(colorblind-safe, Okabe–Ito), print (grayscale, survives a B&W print) "
995 "or high-contrast. The app's own names work too. Individual "
996 "--*-color flags override it.",
997 )
998 viz.add_argument(
999 "--color-by",
1000 metavar="FIELD",
1001 help=f"Fixation column to color by, e.g. duration_ms, or 'line' to "
1002 f"color each fixation by its text line (same as --color-by-line). "
1003 f"Default: '{UNIFORM_COLOR_FIELD}', one flat color, since marker size "
1004 f"already shows duration.",
1005 )
1006 viz.add_argument(
1007 "--fixation-color",
1008 metavar="COLOR",
1009 help=f"Flat fixation marker color used when --color-by is "
1010 f"{UNIFORM_COLOR_FIELD} (default: {DEFAULT_FIXATION_COLOR}).",
1011 )
1012 viz.add_argument(
1013 "--fixation-symbol",
1014 choices=list(FIXATION_SYMBOLS),
1015 help="Fixation marker shape. Unlike color, shape survives a "
1016 f"grayscale print (default: {DEFAULT_FIXATION_SYMBOL}).",
1017 )
1018 viz.add_argument(
1019 "--heatmap-metric",
1020 metavar="COLUMN",
1021 help="Heatmap weighting: the fixation duration column — under your "
1022 "file's name or as duration_ms (the default) — or counts.",
1023 )
1024 viz.add_argument(
1025 "--heatmap-style",
1026 choices=["word-boxes", "interpolated"],
1027 help="Heatmap geometry (default: word-boxes).",
1028 )
1029 viz.add_argument(
1030 "--heatmap-sigma",
1031 type=float,
1032 metavar="PX",
1033 help="Gaussian σ in px for --heatmap-style interpolated (default: 2%% of "
1034 "the data's larger span, at least 8 px).",
1035 )
1036 viz.add_argument(
1037 "--heatmap-colorscale",
1038 metavar="NAME",
1039 type=_colorscale_name,
1040 help=f"Heatmap color scale, e.g. Greens (default: {DEFAULT_HEATMAP_COLORSCALE}).",
1041 )
1042 viz.add_argument(
1043 "--heatmap-norm",
1044 choices=["linear", "log"],
1045 help="Heatmap color scaling: linear (default) or log — log compresses "
1046 "heavy-tailed dwell times so a few hot words don't wash out the rest.",
1047 )
1048 viz.add_argument(
1049 "--fixation-colorscale",
1050 metavar="NAME",
1051 type=_colorscale_name,
1052 help=f"Color scale for --color-by, e.g. Viridis (default: "
1053 f"{DEFAULT_FIXATION_COLORSCALE}).",
1054 )
1055 viz.add_argument(
1056 "--marker-size-range",
1057 nargs=2,
1058 type=int,
1059 metavar=("MIN", "MAX"),
1060 help="Min/max fixation marker size in px, e.g. 4 12 (default: 8 24). "
1061 "Smaller ranges suit small thumbnails.",
1062 )
1063 viz.add_argument(
1064 "--marker-size-scale",
1065 choices=("sqrt", "linear", "log", "relative"),
1066 help="How duration sets marker size (default: sqrt). sqrt / linear / log "
1067 "map --marker-duration-range onto --marker-size-range the same way for "
1068 "every figure, so one duration is one size across trials, comparisons "
1069 "and replays; sqrt makes marker area grow with duration. relative "
1070 "stretches each figure from its own shortest to longest fixation.",
1071 )
1072 viz.add_argument(
1073 "--marker-duration-range",
1074 nargs=2,
1075 type=float,
1076 metavar=("LO", "HI"),
1077 help="Durations in ms given the smallest and largest marker on a fixed "
1078 "scale (default: 50 600). Shorter and longer fixations clamp to them.",
1079 )
1080 viz.add_argument(
1081 "--no-duration-size-legend",
1082 dest="duration_size_legend",
1083 action="store_false",
1084 help="Hide the duration-size key (reference circles labelled in ms) "
1085 "drawn on a fixed --marker-size-scale.",
1086 )
1087 viz.add_argument(
1088 "--canvas",
1089 metavar="WxH",
1090 help="Monitor size in px, e.g. 2560x1440 (default: the source's screen — "
1091 "2560x1440 for --sample/--onestop, 1680x1050 for --potec — else "
1092 "estimated from the data).",
1093 )
1094 viz.add_argument(
1095 "--coordinate-grid",
1096 action="store_true",
1097 help="Overlay a monitor-pixel X/Y grid on the scanpath.",
1098 )
1099 viz.add_argument(
1100 "--coordinate-grid-spacing",
1101 type=float,
1102 metavar="PX",
1103 help="Pin the major coordinate-grid interval in pixels. Implies "
1104 "--coordinate-grid; omit for automatic 1/2/5×10ⁿ spacing.",
1105 )
1106 # VIZ-4: overlay an image stimulus (a screenshot of the reading screen) under
1107 # the scanpath. The API already supports background_image*; these expose it on
1108 # the CLI. Works with --animate too.
1109 viz.add_argument(
1110 "--stimulus-image",
1111 metavar="PATH",
1112 help="Draw an image (PNG/JPG) as the stimulus background under the "
1113 "scanpath. By default it's stretched to the image's own pixel "
1114 "size (PNG) or the canvas; set --stimulus-image-size / -origin to place "
1115 "a crop precisely in fixation coordinates.",
1116 )
1117 viz.add_argument(
1118 "--stimulus-image-size",
1119 metavar="WxH",
1120 help="Stimulus-image size in px, e.g. 1310x991 (default: the PNG's own "
1121 "pixel size, else the canvas). Use with --stimulus-image.",
1122 )
1123 viz.add_argument(
1124 "--stimulus-image-origin",
1125 metavar="X,Y",
1126 help="Top-left of the stimulus image in monitor px, e.g. 305,44 (default: "
1127 "0,0). Use with --stimulus-image to align a centered crop to the "
1128 "fixation coordinates.",
1129 )
1130 viz.add_argument(
1131 "--stimulus-image-opacity",
1132 type=float,
1133 metavar="O",
1134 help="Stimulus-image opacity 0.1–1.0 (default: 1.0 = opaque). Lower it to "
1135 "dim a busy image so the fixations / saccades / word boxes read over it.",
1136 )
1137 # EXP-20 — a flag for every figure option `render` could not say before, so
1138 # the command the Share subtab prints (`code_snippet._CLI_EMITTERS`) draws
1139 # the figure rather than naming what it left out. Each is spelled after its
1140 # figure option and takes that option's own value; `_DIRECT_OPTION_FLAGS` /
1141 # `_SWITCH_OPTION_FLAGS` below hand them to the builder.
1142 viz.add_argument(
1143 "--fixation-opacity",
1144 type=float,
1145 metavar="O",
1146 help="Fixation marker opacity, 0.1–1.0 (default: 0.7, so overlapping "
1147 "fixations show through).",
1148 )
1149 viz.add_argument(
1150 "--hollow-fixations",
1151 action="store_true",
1152 help="Draw the fixations as outlines instead of filled markers.",
1153 )
1154 viz.add_argument(
1155 "--color-by-line",
1156 action="store_true",
1157 help="Color each fixation by the text line it lands on (lines inferred "
1158 "from the word boxes); overrides --color-by. Same as --color-by line.",
1159 )
1160 viz.add_argument(
1161 "--fixation-color-range",
1162 nargs=2,
1163 type=float,
1164 metavar=("LO", "HI"),
1165 help="Pin the --color-by color scale to LO..HI instead of the trial's "
1166 "own range, so several figures share one scale.",
1167 )
1168 viz.add_argument(
1169 "--heatmap-range",
1170 nargs=2,
1171 type=float,
1172 metavar=("LO", "HI"),
1173 help="Pin the heatmap's color scale to LO..HI instead of the trial's "
1174 "own range.",
1175 )
1176 viz.add_argument(
1177 "--order-font-size",
1178 type=int,
1179 metavar="PX",
1180 help="Fixation index label size (default: 10).",
1181 )
1182 viz.add_argument(
1183 "--order-font-color",
1184 metavar="COLOR",
1185 help="Fixation index label color (default: #111111).",
1186 )
1187 viz.add_argument(
1188 "--text-color",
1189 metavar="COLOR",
1190 help="Reading-text color (default: #000000).",
1191 )
1192 viz.add_argument(
1193 "--highlight-text-color",
1194 metavar="COLOR",
1195 help="Color of the --highlight-column words under --critical-span-style "
1196 "mark-text (default: #D55E00).",
1197 )
1198 viz.add_argument(
1199 "--span-border-color",
1200 metavar="COLOR",
1201 help="Box color under --critical-span-style mark-border (default: #000000).",
1202 )
1203 viz.add_argument(
1204 "--background-color",
1205 metavar="COLOR",
1206 help="Plot background color (default: #ffffff).",
1207 )
1208 viz.add_argument(
1209 "--line-spacing",
1210 type=float,
1211 metavar="N",
1212 help="Line slots each word box stands for, which sizes the reading text "
1213 "(default: 3 — OneStop's one blank line above and below).",
1214 )
1215 viz.add_argument(
1216 "--no-scale-text-to-boxes",
1217 dest="scale_text_to_boxes",
1218 action="store_false",
1219 help="Draw the reading text at --font-size instead of sizing it from the "
1220 "word boxes.",
1221 )
1222 viz.add_argument(
1223 "--word-hover-measure",
1224 metavar="FIELD",
1225 help="The reading measure a word's hover shows (default: "
1226 "total_fixation_duration_ms; '' for none).",
1227 )
1228 viz.add_argument(
1229 "--word-heatmap-col",
1230 metavar="COLUMN",
1231 help="For a words-only dataset (no fixations): tint each word box by this "
1232 "numeric words column — e.g. gpt2_surprisal — instead of its dwell time.",
1233 )
1234 viz.add_argument(
1235 "--word-heatmap-title",
1236 metavar="TEXT",
1237 help="Color-bar title for --word-heatmap-col (default: Value).",
1238 )
1239 viz.add_argument(
1240 "--x-field",
1241 metavar="FIELD",
1242 help="Fixation column on the x axis (default: x). A non-spatial one "
1243 "draws a chart of the fixations instead of the scanpath.",
1244 )
1245 viz.add_argument(
1246 "--y-field",
1247 metavar="FIELD",
1248 help="Fixation column on the y axis (default: y).",
1249 )
1250 viz.add_argument(
1251 "--crop-to-data",
1252 "--no-full-monitor",
1253 dest="fit_to_monitor",
1254 action="store_false",
1255 help="Frame the axes on the data instead of the whole --canvas monitor "
1256 "(the app's Crop to data).",
1257 )
1258 viz.add_argument(
1259 "--no-fixation-colorbar",
1260 dest="show_fixation_colorbar",
1261 action="store_false",
1262 help="Leave out --color-by's color bar.",
1263 )
1264 viz.add_argument(
1265 "--fixation-colorbar-orientation",
1266 choices=["vertical", "horizontal"],
1267 help="Fixation color bar: beside the plot (vertical, default) or below it.",
1268 )
1269 viz.add_argument(
1270 "--fixation-colorbar-tickangle",
1271 type=int,
1272 metavar="DEG",
1273 help="Fixation color bar: tick-label angle, -90–90 (default: 0).",
1274 )
1275 viz.add_argument(
1276 "--fixation-colorbar-tickfont-size",
1277 type=int,
1278 metavar="PX",
1279 help="Fixation color bar: tick-label size (default: 12).",
1280 )
1281 viz.add_argument(
1282 "--no-heatmap-colorbar",
1283 dest="show_heatmap_colorbar",
1284 action="store_false",
1285 help="Leave out the heatmap's color bar.",
1286 )
1287 viz.add_argument(
1288 "--heatmap-colorbar-orientation",
1289 choices=["vertical", "horizontal"],
1290 help="Heatmap color bar: beside the plot (vertical, default) or below it.",
1291 )
1292 viz.add_argument(
1293 "--heatmap-colorbar-tickangle",
1294 type=int,
1295 metavar="DEG",
1296 help="Heatmap color bar: tick-label angle, -90–90 (default: 0).",
1297 )
1298 viz.add_argument(
1299 "--heatmap-colorbar-tickfont-size",
1300 type=int,
1301 metavar="PX",
1302 help="Heatmap color bar: tick-label size (default: 12).",
1303 )
1304 # v0.33.0's shared colour-bar flags, kept so a script written for it still
1305 # runs (round 9) but not listed: `--colorbars` asked for what is now the
1306 # default, and each `--colorbar-*` sets both bars unless the bar's own flag
1307 # is given too (`_apply_shared_colorbar_flags`).
1308 viz.add_argument(
1309 "--colorbars",
1310 dest="shared_colorbars",
1311 action="store_true",
1312 help=argparse.SUPPRESS,
1313 )
1314 viz.add_argument(
1315 "--colorbar-orientation",
1316 dest="shared_colorbar_orientation",
1317 choices=["vertical", "horizontal"],
1318 help=argparse.SUPPRESS,
1319 )
1320 for setting in ("tickangle", "tickfont_size"):
1321 viz.add_argument(
1322 f"--colorbar-{setting.replace('_', '-')}",
1323 dest=f"shared_colorbar_{setting}",
1324 type=int,
1325 help=argparse.SUPPRESS,
1326 )
1327 viz.add_argument(
1328 "--raw-gaze",
1329 metavar="PATH",
1330 nargs="+",
1331 help="Raw (sample-level) gaze table(s) to draw under the fixations, "
1332 "columns auto-detected like --fixations (same formats; several "
1333 "paths or a quoted glob concatenate). Static figures and --compare-with "
1334 "comparisons, where each scanpath's samples take its color "
1335 "(not --animate). On its own "
1336 "(no other input) it is the dataset: its trials are listed and drawn "
1337 "as recorded — no fixations are detected from the samples.",
1338 )
1339 viz.add_argument(
1340 "--no-raw-gaze",
1341 dest="show_raw_gaze",
1342 action="store_const",
1343 const=False,
1344 default=None,
1345 help="Load the --raw-gaze table but hide its layer — the app's 🔵 Raw "
1346 "gaze switch turned off. With raw gaze as the only input the figure then "
1347 "draws no gaze.",
1348 )
1349 viz.add_argument(
1350 "--sample-raw-gaze",
1351 action="store_true",
1352 help="With --sample: draw the bundled demo's raw gaze (synthesized, for "
1353 "one trial — the one the app overlays it on).",
1354 )
1355 viz.add_argument(
1356 "--raw-gaze-schema",
1357 metavar="JSON",
1358 help="Column mapping for the --raw-gaze table, replacing auto-detection "
1359 "(same shape as --fix-schema); needed only when a column isn't "
1360 "recognized.",
1361 )
1362 viz.add_argument(
1363 "--word-box-color",
1364 metavar="COLOR",
1365 help="Word-box outline color (default: #6c757d). A comparison outlines "
1366 "each scanpath's boxes in its own color instead.",
1367 )
1368 viz.add_argument(
1369 "--word-box-line-opacity",
1370 type=float,
1371 metavar="O",
1372 help="Word-box outline opacity, 0–1; 0 draws the fill only (default: 1). "
1373 "Below 1 the outline color must be #rrggbb, #rgb or rgb(r, g, b).",
1374 )
1375 viz.add_argument(
1376 "--word-box-fill-color",
1377 metavar="COLOR",
1378 type=_fill_color,
1379 help="Word-box fill color, drawn at --word-box-fill-opacity: #rrggbb, "
1380 "#rgb or rgb(r, g, b) (default: #646464).",
1381 )
1382 viz.add_argument(
1383 "--word-box-fill-opacity",
1384 type=float,
1385 metavar="O",
1386 help="Word-box fill opacity, 0–1; 0 draws outlines only (default: 0.05).",
1387 )
1388 viz.add_argument(
1389 "--raw-gaze-color",
1390 metavar="COLOR",
1391 help="Raw-gaze sample color (default: #888888). A comparison draws "
1392 "each scanpath's samples in its own color instead.",
1393 )
1394 viz.add_argument(
1395 "--raw-gaze-marker-size",
1396 type=float,
1397 metavar="PX",
1398 help="Raw-gaze sample size, 1–12 (default: 4).",
1399 )
1400 viz.add_argument(
1401 "--raw-gaze-opacity",
1402 type=float,
1403 metavar="O",
1404 help="Raw-gaze sample opacity, 0.1–1.0 (default: 0.6).",
1405 )
1406 viz.add_argument(
1407 "--width",
1408 type=int,
1409 metavar="PX",
1410 help="Image width in px for PNG/SVG/PDF (default: the figure's own "
1411 "size). Use with --height for fixed-size thumbnails.",
1412 )
1413 viz.add_argument(
1414 "--height",
1415 type=int,
1416 metavar="PX",
1417 help="Image height in px for PNG/SVG/PDF (default: the figure's own size).",
1418 )
1419 viz.add_argument(
1420 "--scale",
1421 type=float,
1422 default=2.0,
1423 metavar="X",
1424 help="Raster pixel-density multiplier (PNG/SVG/PDF; default: 2.0).",
1425 )
1426 # #374 F28 — the app's Export → Current figure Width + DPI.
1427 print_width = viz.add_mutually_exclusive_group()
1428 print_width.add_argument(
1429 "--width-mm",
1430 type=float,
1431 metavar="MM",
1432 help="Print width of a PNG in mm, drawn at --dpi (replaces --scale).",
1433 )
1434 print_width.add_argument(
1435 "--width-in",
1436 type=float,
1437 metavar="IN",
1438 help="Print width of a PNG in inches, drawn at --dpi.",
1439 )
1440 viz.add_argument(
1441 "--dpi",
1442 type=int,
1443 metavar="N",
1444 help="Resolution of --width-mm / --width-in (default: 300).",
1445 )
1446 viz.add_argument(
1447 "--font-size",
1448 type=int,
1449 default=16,
1450 metavar="PX",
1451 help="Base figure font size (default: 16).",
1452 )
1453 viz.add_argument(
1454 "--font-family",
1455 default=None,
1456 metavar="NAME",
1457 help=f"Font for all figure text (default: {FONT_FAMILY}).",
1458 )
1459 viz.add_argument(
1460 "--title",
1461 default=None,
1462 metavar="TEXT",
1463 help="Title band stamped on the figure; off by default. The "
1464 "figure grows to make room rather than shrinking the plot.",
1465 )
1466 viz.add_argument(
1467 "--caption",
1468 default=None,
1469 metavar="TEXT",
1470 help="Caption band stamped on the figure; off by default.",
1471 )
1472 viz.add_argument(
1473 "--separable-layers",
1474 action="store_true",
1475 help="Also write the figure split into one file per layer (word boxes / "
1476 "fixations / saccades / heatmap / labels / stimulus image) in a "
1477 "`<output>_layers/` folder, so each can be restyled in Illustrator / "
1478 "Inkscape. Static image output only (.svg/.pdf/.png); the layers register "
1479 "when stacked.",
1480 )
1481 viz.add_argument(
1482 "--playback-speed",
1483 type=float,
1484 default=1.0,
1485 metavar="X",
1486 help="Animation speed multiplier for --animate (default: 1.0 = real time).",
1487 )
1488 viz.add_argument(
1489 "--no-autoplay",
1490 dest="autoplay",
1491 action="store_false",
1492 help="With --animate: start the replay paused (press ▶ Play to run it). "
1493 "By default the saved HTML autoplays on load at the playback speed.",
1494 )
1495 viz.add_argument(
1496 "--anim-grid-step-ms",
1497 type=float,
1498 default=None,
1499 metavar="MS",
1500 help="With --animate: emit a frame every MS of reading time (default: "
1501 "100). Smaller is smoother and larger to export.",
1502 )
1503 viz.add_argument(
1504 "--anim-max-frames",
1505 type=int,
1506 default=None,
1507 metavar="N",
1508 help="With --animate: cap the frame count at N (default: 360). A long "
1509 "trial coarsens the grid to stay under it.",
1510 )
1511 # EXP-7: the same reproduction snippet the app's 🔗 Share subtab shows,
1512 # for the invocation you just typed. Chiefly a *translation*: "I have this
1513 # render command, give me the Python for my notebook."
1514 viz.add_argument(
1515 "--print-code",
1516 choices=["python", "cli", "both"],
1517 default=None,
1518 metavar="FLAVOR",
1519 help="Print the API / CLI code that reproduces this figure to stdout "
1520 "(python | cli | both), then render as usual. Only the options "
1521 "that differ from the defaults are written.",
1522 )
1523 viz.add_argument(
1524 "--print-code-explicit",
1525 action="store_true",
1526 help="With --print-code: write every figure option at its current "
1527 "value instead of only the non-defaults.",
1528 )
1530 # CMP-9 — compare mode's CLI surface. B comes either from the dataset
1531 # already loaded (--compare-with alone) or from a second pair of tables.
1532 # Deliberately files-only for the second dataset: twinning every source flag
1533 # (--compare-potec, --compare-onestop + its regime/part/variant, …) would
1534 # roughly double this parser for a narrow case, and `api.compare_scanpaths`
1535 # takes B's frames directly, so a Python caller has no such limit.
1536 cmp_group = parser.add_argument_group(
1537 "comparison: draw a second scanpath beside or over the first"
1538 )
1539 cmp_group.add_argument(
1540 "--compare-with",
1541 metavar="PARTICIPANT:TRIAL",
1542 help="Compare against a second scanpath, named as participant:trial. "
1543 "Taken from the loaded dataset unless --compare-words/--compare-fixations "
1544 "name a second one.",
1545 )
1546 cmp_group.add_argument(
1547 "--compare-screen",
1548 metavar="SCREEN_ID",
1549 help="Screen of the second scanpath's multipart trial (default: its first "
1550 "screen), looked up in its own trial. --screen picks the first "
1551 "scanpath's. Each scanpath is drawn from one screen.",
1552 )
1553 cmp_group.add_argument(
1554 "--compare-layout",
1555 choices=["overlay", "side-by-side", "stacked"],
1556 default="overlay",
1557 help="How the two scanpaths are arranged (default: overlay). Across two "
1558 "datasets, overlay needs both canvases to be the same size — two "
1559 "different canvases are refused rather than silently split, so pass "
1560 "side-by-side or stacked for them. Matching canvases that a dataset "
1561 "never recorded still overlay, with a warning.",
1562 )
1563 cmp_group.add_argument(
1564 "--compare-stimulus",
1565 choices=["both", "a", "b"],
1566 default="both",
1567 help="On an overlay, whose word boxes and text to draw (default: both). "
1568 "Two datasets' word boxes coincide only when the text is identical.",
1569 )
1570 # EXP-8 §1. Named `-a` / `-b` after the `style_a` / `style_b` pair rather
1571 # than `--label`, which would read as a sibling of `--no-labels` (the word
1572 # labels on the stimulus) and mean something else entirely.
1573 cmp_group.add_argument(
1574 "--label-a",
1575 metavar="TEXT",
1576 help="Name for the FIRST scanpath in the legend and hover, instead of "
1577 "the default. Applies to the comparison figure and the --animate "
1578 "co-animation. Requires --label-b.",
1579 )
1580 cmp_group.add_argument(
1581 "--label-b",
1582 metavar="TEXT",
1583 help="Name for the SECOND scanpath in the legend and hover, instead "
1584 "of the default. Requires --label-a.",
1585 )
1586 cmp_group.add_argument(
1587 "--compare-legend",
1588 dest="show_legend",
1589 action=argparse.BooleanOptionalAction,
1590 default=None,
1591 help="Draw the legend naming the two scanpaths (the app's A/B legend; "
1592 "on by default, as in the app). Applies to the --animate co-animation "
1593 "too.",
1594 )
1595 # EXP-20. Named after `compare_scanpaths`'s `style_a` / `style_b`, like the
1596 # `--label-a` / `--label-b` pair above.
1597 for side, which in (("a", "FIRST"), ("b", "SECOND")):
1598 cmp_group.add_argument(
1599 f"--style-{side}",
1600 dest=f"style_{side}",
1601 action="append",
1602 metavar="SPEC",
1603 help=f"Styling for the {which} scanpath, repeatable: KEY=VALUE[,...]. "
1604 "Colors (#RRGGBB): fix_color, saccade_color, box_color (word-box "
1605 "outline; default fix_color), box_fill_color (default "
1606 "--word-box-fill-color), raw_gaze_color (default fix_color). Also "
1607 "heatmap_colorscale (default --heatmap-colorscale, on the shared "
1608 f"range), saccade_style ({'|'.join(SACCADE_DASH_OPTIONS.values())}), "
1609 "saccade_width (px), marker_size_range (MIN:MAX), opacity (0.1–1), "
1610 f"hollow (true|false). E.g. --style-{side} fix_color=#D55E00,opacity=0.5. "
1611 "--animate uses them too, except box_color, box_fill_color and "
1612 "raw_gaze_color.",
1613 )
1614 # CMP-24: scanpath B's own filters — the app's "· B" blocks under 🧹 Filter.
1615 # A's are the ordinary --fixation-flag / --saccade-classes /
1616 # --fix-index-range, which on their own filter both scanpaths.
1617 cmp_group.add_argument(
1618 "--compare-fixation-flag",
1619 dest="compare_fixation_flags",
1620 action="append",
1621 metavar="SPEC",
1622 help="Filters & highlights for the SECOND scanpath only, repeatable; "
1623 "same SPEC as --fixation-flag, e.g. --compare-fixation-flag "
1624 "short=discard,threshold_ms=80. Replaces --fixation-flag for B.",
1625 )
1626 cmp_group.add_argument(
1627 "--compare-saccade-classes",
1628 dest="compare_saccade_classes",
1629 metavar="CLASSES",
1630 help="The reading classes the SECOND scanpath draws, comma-separated "
1631 "(same names as --saccade-classes). Replaces --saccade-classes for B. "
1632 "Not with --animate, which draws every class.",
1633 )
1634 cmp_group.add_argument(
1635 "--compare-fix-index-range",
1636 dest="compare_fix_index_range",
1637 metavar="START:END",
1638 help="Draw only fixations START through END of the SECOND scanpath "
1639 "(1-based, inclusive). Replaces --fix-index-range for B.",
1640 )
1641 cmp_group.add_argument(
1642 "--stimulus-image-b",
1643 metavar="PATH",
1644 help="The SECOND scanpath's stimulus image, for a side-by-side or stacked "
1645 "comparison across two datasets (each panel draws its own page). Sized "
1646 "and placed like --stimulus-image.",
1647 )
1648 cmp_group.add_argument(
1649 "--stimulus-image-size-b",
1650 metavar="WxH",
1651 help="Size of --stimulus-image-b in px (default: the PNG's own size, "
1652 "else --compare-canvas, else --canvas).",
1653 )
1654 cmp_group.add_argument(
1655 "--stimulus-image-origin-b",
1656 metavar="X,Y",
1657 help="Top-left of --stimulus-image-b in the second screen's px (default: 0,0).",
1658 )
1659 cmp_group.add_argument(
1660 "--compare-words",
1661 metavar="PATH",
1662 nargs="+",
1663 help="Words table(s) for the SECOND dataset. Same formats and "
1664 "globbing as --words.",
1665 )
1666 cmp_group.add_argument(
1667 "--compare-fixations",
1668 metavar="PATH",
1669 nargs="+",
1670 help="Fixations table(s) for the SECOND dataset. Same formats and "
1671 "globbing as --fixations.",
1672 )
1673 cmp_group.add_argument(
1674 "--compare-raw-gaze",
1675 metavar="PATH",
1676 nargs="+",
1677 help="Raw gaze table(s) for the SECOND dataset, drawn under B's scanpath. "
1678 "Same formats as --raw-gaze; with no second dataset, "
1679 "--raw-gaze already covers both scanpaths.",
1680 )
1681 cmp_group.add_argument(
1682 "--compare-dataset-name",
1683 metavar="NAME",
1684 default="Dataset B",
1685 help="Label for the second dataset, used in the trace names (default: "
1686 "'Dataset B').",
1687 )
1688 cmp_group.add_argument(
1689 "--compare-canvas",
1690 metavar="WxH",
1691 help="Second dataset's monitor size in px, e.g. 1680x1050. Read off its "
1692 "data when omitted. An overlay, or an --animate co-animation, compares "
1693 "this against --canvas.",
1694 )
1695 # BUG-85 removed --monitor-mm / --viewing-distance and their --compare-*
1696 # twins: they were recorded on the setup snapshots and read by nothing —
1697 # CMP-11 is a gate on pixels, not a rescaling, so no figure used them.
1698 return parser
1701#: EXP-20 — flags whose value *is* the figure option's value, each named after
1702#: the option (`--fixation-opacity` → `fixation_opacity`), so they reach the
1703#: builder unchanged whenever given.
1704_DIRECT_OPTION_FLAGS = (
1705 "marker_size_scale",
1706 "fixation_opacity",
1707 "order_font_size",
1708 "order_font_color",
1709 "text_color",
1710 "highlight_text_color",
1711 "span_border_color",
1712 "background_color",
1713 "line_spacing",
1714 "word_hover_measure",
1715 "x_field",
1716 "y_field",
1717 "fixation_colorbar_tickangle",
1718 "fixation_colorbar_tickfont_size",
1719 "heatmap_colorbar_tickangle",
1720 "heatmap_colorbar_tickfont_size",
1721 "illustration_text",
1722 "word_box_color",
1723 "word_box_line_opacity",
1724 "word_box_fill_color",
1725 "word_box_fill_opacity",
1726 "raw_gaze_color",
1727 "raw_gaze_marker_size",
1728 "raw_gaze_opacity",
1729 "word_heatmap_col",
1730 "word_heatmap_title",
1731)
1733#: The direct options whose ``None`` is a choice, written ``''`` on the command
1734#: line (`code_snippet._optional_valued`).
1735_NONE_WHEN_EMPTY = frozenset(
1736 {"word_hover_measure", "word_heatmap_col", "word_heatmap_title"}
1737)
1739#: …and the switches, as ``option → the value the flag sets``. Passed only when
1740#: flipped, so a bare `render` keeps handing the builder its own defaults.
1741_SWITCH_OPTION_FLAGS = {
1742 "hollow_fixations": True,
1743 "color_by_line": True,
1744 "show_fixation_colorbar": False,
1745 "show_heatmap_colorbar": False,
1746 "scale_text_to_boxes": False,
1747 "fit_to_monitor": False,
1748 "duration_size_legend": False,
1749}
1751#: The keys `--style-a` / `--style-b` take, each with its value parser.
1752_STYLE_KEYS = (
1753 "fix_color",
1754 "saccade_color",
1755 "box_color",
1756 "box_fill_color",
1757 "raw_gaze_color",
1758 "heatmap_colorscale",
1759 "saccade_style",
1760 "saccade_width",
1761 "marker_size_range",
1762 "opacity",
1763 "hollow",
1764)
1767def _parse_style_spec(specs: list[str] | None, flag: str) -> dict | None:
1768 """``["fix_color=#aa0000,opacity=0.5"]`` → ``compare_scanpaths``'s style dict.
1770 The inverse of `code_snippet._style_spec`. Colors are ``#RRGGBB`` only —
1771 the value is split on commas, so a CSS ``rgb(…)`` could never arrive whole —
1772 and every value is checked here rather than left to fail inside the builder.
1773 """
1774 if not specs:
1775 return None
1776 dashes = tuple(SACCADE_DASH_OPTIONS.values())
1777 style: dict = {}
1778 for spec in specs:
1779 for option in (part.strip() for part in spec.split(",") if part.strip()):
1780 name, sep, raw = option.partition("=")
1781 name, raw = name.strip(), raw.strip()
1782 try:
1783 if not sep or name not in _STYLE_KEYS:
1784 raise ValueError
1785 if name in (
1786 "fix_color",
1787 "saccade_color",
1788 "box_color",
1789 "box_fill_color",
1790 "raw_gaze_color",
1791 ):
1792 if not re.fullmatch(r"#[0-9A-Fa-f]{6}", raw):
1793 raise ValueError
1794 style[name] = raw
1795 elif name == "heatmap_colorscale":
1796 try:
1797 style[name] = _colorscale_name(raw)
1798 except argparse.ArgumentTypeError:
1799 raise ValueError from None
1800 elif name == "saccade_style":
1801 if raw not in dashes:
1802 raise ValueError
1803 style[name] = raw
1804 elif name == "marker_size_range":
1805 lo, hi = (int(part) for part in raw.split(":"))
1806 style[name] = (min(lo, hi), max(lo, hi))
1807 elif name == "hollow":
1808 if raw.lower() not in ("1", "0", "true", "false", "yes", "no"):
1809 raise ValueError
1810 style[name] = raw.lower() in ("1", "true", "yes")
1811 else: # saccade_width, opacity
1812 style[name] = float(raw)
1813 except ValueError:
1814 raise SystemExit(
1815 f"{flag}: can't read {option!r}. Expected KEY=VALUE with KEY "
1816 f"one of {', '.join(_STYLE_KEYS)} — colors as #RRGGBB, "
1817 "heatmap_colorscale a Plotly color scale, "
1818 f"saccade_style one of {', '.join(dashes)}, marker_size_range "
1819 "as MIN:MAX, hollow as true/false."
1820 )
1821 return style
1824def _compare_labels(args) -> tuple[str, str] | None:
1825 """The `--label-a` / `--label-b` pair, or None for the composed default.
1827 Validated both-or-neither in `render`'s argument checks, so by the time
1828 this runs, one flag being set means both are.
1829 """
1830 if args.label_a is None:
1831 return None
1832 return (str(args.label_a), str(args.label_b))
1835def _parse_compare_with(value: str) -> tuple:
1836 """``"p01:t03"`` → ``("p01", "t03")``, or a clear SystemExit.
1838 Split on the LAST colon: a participant id may legitimately contain one
1839 (MultiplEYE's ``001_ZH_CH_1_ET1`` style ids do not, but composite trial ids
1840 joined with ``_`` sit next to corpora that use colons), while a trial id
1841 naming a screen never trails one.
1842 """
1843 text = str(value or "")
1844 participant, sep, trial = text.rpartition(":")
1845 if not sep or not participant.strip() or not trial.strip():
1846 raise SystemExit(
1847 f"--compare-with expects PARTICIPANT:TRIAL, got {value!r}. "
1848 "Use --list-trials to see the available pairs."
1849 )
1850 return participant.strip(), trial.strip()
1853def _compare_second_dataset(api, args, words, fixations):
1854 """``(words_b, fixations_b)`` for the comparison — A's frames unless given.
1856 Returns the *whole* second dataset, not one trial; both callers slice it.
1857 """
1858 if not (args.compare_words or args.compare_fixations):
1859 return words, fixations, False
1860 try:
1861 return (
1862 *api.load_scanpath_data(
1863 args.compare_words, args.compare_fixations, names="canonical"
1864 ),
1865 True,
1866 )
1867 except (ValueError, FileNotFoundError, OSError) as exc:
1868 raise SystemExit(
1869 "--compare-words/--compare-fixations: "
1870 + _load_error_message(exc, schema_flags=False)
1871 )
1874def _listed(table: pd.DataFrame, column_names: dict) -> pd.DataFrame:
1875 """A trial or screen listing with its ids under the dataset's own names
1876 (DATA-66) — what ``--list-trials`` / ``--list-parts`` print."""
1877 if not column_names:
1878 return table
1879 return _cn.as_written(table, _cn.across_tables(column_names).identity())
1882def _compare_animation_frames(api, args, words, fixations, canvas) -> dict:
1883 """`animate_scanpath`'s keywords for scanpath B of a dual co-animation.
1885 B's single-trial frames and, when they come from a second dataset, that
1886 dataset's name and whatever screens the flags state. A co-animation draws
1887 both readings on one clock in one coordinate space — an overlay — so the API
1888 refuses two different screens on exactly the terms `compare_scanpaths`
1889 refuses ``layout="overlay"``, reading a screen the flags don't state off its
1890 data (CMP-21). This used to check only when ``--compare-canvas`` was given,
1891 and co-animated without looking otherwise.
1892 """
1893 from .data import respell_reading, trial_keys
1894 from .utils import extract_trial
1896 participant_b, trial_b = _parse_compare_with(args.compare_with)
1897 words_b, fixations_b, cross_dataset = _compare_second_dataset(
1898 api, args, words, fixations
1899 )
1900 # An id spelled before composite ids escaped a `_` in a part still finds B.
1901 participant_b, trial_b = respell_reading(
1902 participant_b, trial_b, trial_keys(fixations_b)
1903 )
1904 trial_words_b = extract_trial(words_b, participant_b, trial_b)
1905 trial_fix_b = extract_trial(fixations_b, participant_b, trial_b)
1906 if trial_fix_b.empty:
1907 raise SystemExit(
1908 f"No fixations for the compared scanpath participant={participant_b!r}, "
1909 f"trial={trial_b!r}. Use --list-trials to see the available pairs."
1910 )
1911 frames = {"words_b": trial_words_b, "fixations_b": trial_fix_b}
1912 if args.compare_screen is not None:
1913 frames["screen_b"] = args.compare_screen
1914 if cross_dataset:
1915 frames.update(
1916 dataset_b=args.compare_dataset_name,
1917 setup=_compare_setup_snapshot(canvas),
1918 setup_b=_compare_setup_snapshot(
1919 _parse_canvas(args.compare_canvas, "--compare-canvas")
1920 ),
1921 )
1922 return frames
1925def _inferred_screen_hint(args, canvas: tuple | None) -> str:
1926 """The flag that states a screen a refusal only read off the data (CMP-21).
1928 `setups_comparable` says the readings were *recorded* on different screens,
1929 but a screen no flag gives is the extent of that trial's data — rarely the
1930 whole display — so `render` names the flag that states it.
1931 """
1932 a_inferred, b_inferred = canvas is None, args.compare_canvas is None
1933 if a_inferred and b_inferred:
1934 return (
1935 " Neither screen was stated, so both were read off the data, which "
1936 "rarely spans the whole screen; if they were shown on one, state it "
1937 "with --canvas and --compare-canvas."
1938 )
1939 if b_inferred:
1940 return (
1941 " The second dataset's screen was read off its data, which rarely "
1942 "spans the whole screen; if both were shown on one, state it with "
1943 "--compare-canvas."
1944 )
1945 if a_inferred:
1946 return (
1947 " The first dataset's screen was read off its data, which rarely "
1948 "spans the whole screen; if both were shown on one, state it with "
1949 "--canvas."
1950 )
1951 return ""
1954def _compare_setup_snapshot(canvas: tuple | None):
1955 """A `SetupSnapshot` for a canvas the caller stated, or ``None`` if silent.
1957 ``None`` lets `api.compare_scanpaths` and `api.animate_scanpath` infer the
1958 screen from the data, which is the right default — inventing a canvas here
1959 would be a claim the caller never made. A stated canvas is a known screen:
1960 ``MEASURED``.
1961 """
1962 from .experimental_setup import Provenance, SetupSnapshot
1964 if canvas is None:
1965 return None
1966 return SetupSnapshot(
1967 canvas_width=int(canvas[0]),
1968 canvas_height=int(canvas[1]),
1969 screen_provenance=Provenance.MEASURED,
1970 )
1973def _print_combined_rows(report) -> None:
1974 """Say how many duplicate rows a metadata table folded together."""
1975 combined = int(getattr(report, "combined_rows", 0) or 0)
1976 if combined:
1977 print(
1978 f" combined {combined} compatible duplicate row"
1979 f"{'s' if combined != 1 else ''} (each field keeps the one value "
1980 "they hold)",
1981 file=sys.stderr,
1982 )
1985def _format_trial_key(key) -> str:
1986 """One trial-metadata report key as text (DATA-29).
1988 A table keyed by reader *and* trial reports ``(participant, trial)`` pairs
1989 while one keyed by trial alone reports bare ids, so the two shapes are
1990 printed by the same helper rather than by two branches at each call site.
1991 """
1992 if isinstance(key, tuple):
1993 return "/".join(str(part) for part in key)
1994 return str(key)
1997def _parse_canvas(value: str | None, flag: str = "--canvas") -> tuple | None:
1998 if not value:
1999 return None
2000 try:
2001 w, h = (int(part) for part in value.lower().split("x"))
2002 except ValueError:
2003 raise SystemExit(f"{flag} expects WxH (e.g. 2560x1440), got {value!r}")
2004 if w <= 0 or h <= 0:
2005 raise SystemExit(f"{flag} dimensions must be positive, got {value!r}")
2006 return (w, h)
2009#: Figure options whose flag is not spelled after them (#374).
2010_OPTION_FLAG_NAMES = {
2011 "background_image": "--stimulus-image",
2012 "background_image_size": "--stimulus-image-size",
2013 "background_image_origin": "--stimulus-image-origin",
2014 "background_image_opacity": "--stimulus-image-opacity",
2015 "saccade_render_mode": "--saccade-arcs",
2016 "fixation_snap_to_word": "--snap-fixations",
2017 "heatmap_sigma_px": "--heatmap-sigma",
2018 "saccade_class_colors": "--saccade-type-color",
2019 "saccade_color_mode": "--saccade-color-by-type",
2020 "show_connectors": "--drift-connectors",
2021 "connector_y": "--drift-connectors",
2022 "illustration_reasons": "--illustration",
2023}
2026def _flags_for(keys, overrides: dict) -> list[str]:
2027 """The ``render`` flags that set figure options ``keys`` — the names a
2028 warning should use, since those are what the user typed (#374)."""
2029 by_dest: dict[str, list] = {}
2030 for action in _render_parser()._actions:
2031 by_dest.setdefault(action.dest, []).append(action)
2032 names = []
2033 for key in keys:
2034 flag = _OPTION_FLAG_NAMES.get(key)
2035 if flag is None:
2036 value = overrides.get(key)
2037 actions = by_dest.get(key, [])
2038 match = [
2039 a for a in actions if getattr(a, "const", None) is value
2040 ] or actions
2041 longs = [
2042 o for a in match[:1] for o in a.option_strings if o.startswith("--")
2043 ]
2044 flag = longs[0] if longs else key
2045 names.append(flag)
2046 return sorted(set(names))
2049def _require_image(path: str, flag: str) -> None:
2050 """#374: a mistyped image path drew the figure without its stimulus."""
2051 if not str(path).startswith("data:") and not Path(path).is_file():
2052 raise SystemExit(f"{flag}: image not found: {path}. Nothing was written.")
2055def _parse_saccade_classes_arg(value: str, flag: str) -> list[str]:
2056 """A ``--saccade-classes``-style list → the classes in canonical order (VIZ-31)."""
2057 names = [p.strip() for p in value.split(",") if p.strip()]
2058 unknown = [n for n in names if n not in SACCADE_CLASS_ORDER]
2059 if unknown or not names:
2060 raise SystemExit(
2061 f"{flag} expects a comma-separated subset of "
2062 f"{', '.join(SACCADE_CLASS_ORDER)}; got {value!r}."
2063 )
2064 return [cls for cls in SACCADE_CLASS_ORDER if cls in set(names)]
2067def _parse_fix_index_range(value: str | None) -> tuple | None:
2068 """``"1:40"`` → ``(1, 40)`` — VIZ-7's fixation-index window (both inclusive)."""
2069 if not value:
2070 return None
2071 try:
2072 lo, hi = (int(part) for part in value.replace("-", ":").split(":"))
2073 except ValueError:
2074 raise SystemExit(
2075 f"--fix-index-range expects START:END (e.g. 1:40), got {value!r}"
2076 )
2077 if lo < 1 or hi < lo:
2078 raise SystemExit(
2079 f"--fix-index-range needs 1 <= START <= END (1-based, both "
2080 f"inclusive), got {value!r}"
2081 )
2082 return (lo, hi)
2085#: `--critical-span-style` choice → the settings vocabulary's own spelling.
2086_CRITICAL_SPAN_STYLES = {
2087 "mark-text": "Mark text",
2088 "mark-border": "Mark border",
2089 "none": "None",
2090}
2092#: PRE-2 category → whether it takes a `threshold_ms`. `oob` and `blink` are
2093#: classified from geometry / the recording, not from a duration.
2094_FIXCLASS_CATEGORIES = {"short": True, "long": True, "oob": False, "blink": False}
2097def _parse_legend_layout(specs: list[str]) -> dict:
2098 """``["saccades=right,stacked,14"]`` → the ``legend_layout`` dict.
2100 One ``KIND=SPEC`` per flag; SPEC is ``plots.parse_legend_spec``'s spelling,
2101 the one the ``legend_<kind>`` link parameters use too.
2102 """
2103 from .plots import normalize_legend_layout, parse_legend_spec
2105 layout: dict = {}
2106 for spec in specs:
2107 kind, _, text = spec.partition("=")
2108 kind = kind.strip().lower().replace("-", "_")
2109 try:
2110 layout[kind] = parse_legend_spec(text)
2111 normalize_legend_layout(layout)
2112 except ValueError as exc:
2113 raise SystemExit(f"--legend {spec!r}: {exc}") from None
2114 return layout
2117def _parse_fixation_flags(specs: list[str]) -> dict:
2118 """``["short=discard,threshold_ms=80"]`` → the ``fixation_flags`` dict.
2120 The app builds the same dict from its ``global_fixclass_*`` keys
2121 (``controls._collect_fixation_flags``) and a saved config carries it whole,
2122 so the CLI's job is only to spell one category per flag. Unspecified
2123 categories are left out entirely, which the builder reads as *Off*.
2124 """
2125 from .controls import _FIXCLASS_MODES, _OUT_OF_TEXT_MARKERS
2127 modes = {mode.lower(): mode for mode in _FIXCLASS_MODES}
2128 flags: dict = {}
2129 for spec in specs:
2130 head, _, rest = spec.partition(",")
2131 category, _, mode = head.partition("=")
2132 category, mode = category.strip().lower(), mode.strip().lower()
2133 if category not in _FIXCLASS_CATEGORIES or mode not in modes:
2134 raise SystemExit(
2135 f"--fixation-flag expects CATEGORY=MODE with CATEGORY one of "
2136 f"{', '.join(_FIXCLASS_CATEGORIES)} and MODE one of "
2137 f"{', '.join(modes)}; got {spec!r}."
2138 )
2139 entry: dict = {"mode": modes[mode]}
2140 for option in (part.strip() for part in rest.split(",") if part.strip()):
2141 name, _, raw = option.partition("=")
2142 name, raw = name.strip(), raw.strip()
2143 if name == "threshold_ms" and _FIXCLASS_CATEGORIES[category]:
2144 try:
2145 entry["threshold_ms"] = float(raw)
2146 except ValueError:
2147 raise SystemExit(
2148 f"--fixation-flag threshold_ms expects a number, got {raw!r}."
2149 )
2150 elif name == "symbol" and raw in _OUT_OF_TEXT_MARKERS:
2151 entry["symbol"] = raw
2152 elif name == "color" and re.fullmatch(r"#[0-9A-Fa-f]{6}", raw):
2153 entry["color"] = raw
2154 else:
2155 raise SystemExit(
2156 f"--fixation-flag: unknown or invalid option {option!r} for "
2157 f"category {category!r}. Valid: "
2158 + ("threshold_ms=N, " if _FIXCLASS_CATEGORIES[category] else "")
2159 + f"symbol=<{'|'.join(_OUT_OF_TEXT_MARKERS)}>, color=#RRGGBB."
2160 )
2161 flags[category] = entry
2162 return flags
2165def _snippet_source_from_args(args) -> SnippetSource:
2166 """Which ``SnippetSource`` the render command's own input flags describe.
2168 The inverse of the ``--sample`` / ``--words`` / ``--potec`` / … group, so
2169 ``--print-code python`` hands back a loader call that reads the same corpus
2170 this invocation just read."""
2171 from . import code_snippet as cs
2173 if args.authoring:
2174 return cs.SnippetSource(
2175 kind=cs.SOURCE_AUTHOR, label="authored", options={"path": args.authoring}
2176 )
2177 if args.potec:
2178 return cs.SnippetSource(
2179 kind=cs.SOURCE_POTEC, label="PoTeC", options={"root": args.potec}
2180 )
2181 if args.eyegenbench:
2182 return cs.SnippetSource(
2183 kind=cs.SOURCE_BENCHMARK,
2184 label=args.eyegenbench_dataset or "benchmark",
2185 options={
2186 "root": args.eyegenbench,
2187 "dataset": args.eyegenbench_dataset or "",
2188 },
2189 )
2190 if args.onestop:
2191 return cs.SnippetSource(
2192 kind=cs.SOURCE_ONESTOP,
2193 label="OneStop",
2194 options={
2195 "root": args.onestop,
2196 "regime": args.onestop_regime,
2197 "variant": args.onestop_variant,
2198 "parts": list(args.onestop_part or ["Paragraph"]),
2199 },
2200 )
2201 if args.source == "multipleye":
2202 return cs.SnippetSource(
2203 kind=cs.SOURCE_MULTIPLEYE,
2204 label="MultiplEYE",
2205 options={"root": args.export or "data/MultiplEYE"},
2206 )
2207 if args.words or args.fixations:
2208 options = {
2209 "words": list(args.words or []),
2210 "fixations": list(args.fixations or []),
2211 }
2212 # The mapping the files were read with, or the snippet's loader
2213 # auto-detects columns this command was told to read otherwise.
2214 for option, flag in (
2215 ("word_schema", "--word-schema"),
2216 ("fix_schema", "--fix-schema"),
2217 ):
2218 schema = _parse_schema_arg(getattr(args, option, None), flag)
2219 if schema:
2220 options[option] = schema
2221 return cs.SnippetSource(kind=cs.SOURCE_FILES, label="files", options=options)
2222 if args.raw_gaze and not args.sample:
2223 # VIZ-45: --raw-gaze as the only input — the samples are the dataset.
2224 return cs.SnippetSource(
2225 kind=cs.SOURCE_RAW_GAZE,
2226 label="raw gaze",
2227 options={"raw_gaze": list(args.raw_gaze)},
2228 )
2229 return cs.SnippetSource(kind=cs.SOURCE_DEMO, label="Bundled Demo")
2232def _print_reproduction_code(
2233 api, args, overrides: dict, canvas, participant, trial, *, raw_gaze: bool = False
2234):
2235 """EXP-7: print the snippet that rebuilds the figure this invocation renders.
2237 Built from ``overrides`` — the very dict handed to the builder a few lines
2238 below — merged onto the kind's effective defaults, so the printed recipe is
2239 a serializer over the figure's own input rather than a second reading of
2240 ``args``. ``--palette`` is expanded first, exactly as ``api`` expands it, or
2241 the snippet would name a preset the figure had already resolved away.
2242 """
2243 from . import code_snippet as cs
2245 kind = (
2246 "animation"
2247 if args.animate
2248 else "comparison"
2249 if args.compare_with is not None
2250 else "static"
2251 )
2252 settings = {**api.figure_options(kind), **api._expand_palette(dict(overrides))}
2253 # EXP-20: `plot_scanpath` turns the raw-gaze layer on for the frame it is
2254 # handed, so the flag never reaches `overrides`; the snippet reads it here.
2255 # VIZ-48: `compare_scanpaths` too — whose samples may all be B's.
2256 draws_b = kind == "comparison" and bool(args.compare_raw_gaze)
2257 if (raw_gaze or draws_b) and kind in {"static", "comparison"}:
2258 settings["show_raw_gaze"] = args.show_raw_gaze is not False
2259 # These two are passed to `animate_scanpath` beside the overrides rather
2260 # than through them, so they never reached `settings` — a straight silent
2261 # drop of two real `figure_options("animation")` keys.
2262 if kind == "animation":
2263 for name, value in (
2264 ("anim_grid_step_ms", args.anim_grid_step_ms),
2265 ("anim_max_frames", args.anim_max_frames),
2266 ):
2267 if value is not None:
2268 settings[name] = value
2269 # EXP-20: the co-animation's B-side keywords ride `anim_kwargs`, not
2270 # `overrides`, for the same reason — so a printed recipe for `--animate
2271 # --compare-with … --label-a …` quietly lost its labels and stimulus.
2272 if args.compare_with is not None:
2273 settings["compare_stimulus"] = args.compare_stimulus
2274 labels = _compare_labels(args)
2275 if labels is not None:
2276 settings["label_a"], settings["label_b"] = labels
2277 compare = None
2278 if args.compare_with is not None:
2279 compare_participant, compare_trial = _parse_compare_with(args.compare_with)
2280 second = bool(args.compare_words or args.compare_fixations)
2281 compare = cs.CompareTarget(
2282 participant=compare_participant,
2283 trial=compare_trial,
2284 screen=args.compare_screen,
2285 layout=args.compare_layout,
2286 compare_stimulus=args.compare_stimulus,
2287 labels=_compare_labels(args),
2288 # CMP-8: B came from a second corpus exactly when its own frames
2289 # were given, so B's ids are that corpus's — the same caveat the
2290 # app's Share panel raises.
2291 dataset=(str(args.compare_dataset_name or "Dataset B") if second else ""),
2292 # EXP-21: the tables and screen this invocation read B from, so the
2293 # printed recipe loads the same B rather than placeholders.
2294 canvas=_parse_canvas(args.compare_canvas, "--compare-canvas")
2295 if second
2296 else None,
2297 words=tuple(args.compare_words or ()) if second else (),
2298 fixations=tuple(args.compare_fixations or ()) if second else (),
2299 # VIZ-48: B's own samples, drawn only when this run loads them.
2300 raw_gaze=(
2301 tuple(args.compare_raw_gaze)
2302 if second and args.compare_raw_gaze
2303 else None
2304 ),
2305 primary_raw_gaze=raw_gaze,
2306 )
2307 state = cs.FigureState(
2308 kind=kind,
2309 settings=settings,
2310 participant=str(participant),
2311 trial=str(trial),
2312 screen=args.screen,
2313 canvas=canvas,
2314 base_font_size=args.font_size,
2315 font_family=args.font_family or FONT_FAMILY,
2316 title=args.title or "",
2317 caption=args.caption or "",
2318 illustration_label=str(args.illustration_label or "auto").lower(),
2319 drift_correction=args.drift_correction,
2320 drift_connectors=bool(args.drift_connectors),
2321 # A builder *parameter* rather than a figure keyword, like the drift
2322 # pair above — so it has to be named here or the printed recipe would
2323 # silently widen the window back to the whole trial.
2324 fix_index_range=_parse_fix_index_range(args.fix_index_range),
2325 playback_speed=args.playback_speed,
2326 autoplay=args.autoplay,
2327 compare=compare,
2328 )
2329 # `save_figure`'s own defaults, so a plain invocation writes a plain save
2330 # line and only a deliberate `--width` / `--scale` shows up.
2331 save_kwargs = {
2332 name: value
2333 for name, value in _save_kwargs(args).items()
2334 if value is not None and not (name == "scale" and value == 2.0)
2335 }
2336 if "width_mm" in save_kwargs or "width_in" in save_kwargs:
2337 save_kwargs.pop("scale", None) # the print width replaces it
2338 caveats = []
2339 if args.screens:
2340 caveats.append(
2341 "--screens renders the screens named; the snippet rebuilds one "
2342 "screen. Use api.render_parent_trial(..., screens=[...]) for the set."
2343 )
2344 elif args.all_screens:
2345 caveats.append(
2346 "--all-screens renders every screen of the parent trial; the "
2347 "snippet rebuilds one screen. Use api.render_parent_trial(...) for "
2348 "the whole set."
2349 )
2350 # A `render` input that changes the figure but has no `FigureState` field is
2351 # named, never dropped — the same rule `cli_unsupported` applies in the other
2352 # direction. Translating a command into a notebook cell has to be honest
2353 # about the parts of the command that didn't come along.
2354 if args.image_root:
2355 caveats.append(
2356 "--image-root / --image-pattern resolve one stimulus image per row; "
2357 "the snippet names no image. Pass the resolved file as "
2358 "`background_image=`."
2359 )
2360 if args.all_screens and args.screen_transition != "instant":
2361 caveats.append(
2362 f"--screen-transition {args.screen_transition} only affects the "
2363 "--all-screens / --screens metadata, which the single-figure "
2364 "snippet omits."
2365 )
2366 source = _snippet_source_from_args(args)
2367 if args.raw_gaze:
2368 # EXP-20: the raw-gaze table is part of the data half — the snippet's
2369 # loader reads it beside the corpus, under the same mapping.
2370 extra = {"raw_gaze": list(args.raw_gaze)}
2371 schema = _parse_schema_arg(args.raw_gaze_schema, "--raw-gaze-schema")
2372 if schema is not None:
2373 extra["raw_gaze_schema"] = schema
2374 source = replace(source, options={**source.options, **extra})
2375 code = cs.reproduction_code(
2376 source,
2377 state,
2378 explicit=bool(args.print_code_explicit),
2379 output=args.output or "scanpath.png",
2380 save_kwargs=save_kwargs,
2381 extra_caveats=tuple(caveats),
2382 )
2383 blocks = []
2384 if args.print_code in ("python", "both"):
2385 blocks.append(code.python)
2386 if args.print_code in ("cli", "both"):
2387 cli = code.cli
2388 if code.cli_unsupported:
2389 cli += "\n# No `render` flag for: " + ", ".join(code.cli_unsupported)
2390 blocks.append(cli)
2391 # stdout, not stderr: this is the requested output, and it has to survive a
2392 # `> recipe.py` redirect while the progress lines stay on the terminal.
2393 print("\n\n".join(blocks))
2394 for note in code.caveats:
2395 print(f"Note: {note}", file=sys.stderr)
2398def _parse_xy(value: str | None, flag: str = "--stimulus-image-origin") -> tuple | None:
2399 """Parse an ``X,Y`` origin (VIZ-4 --stimulus-image-origin) to floats."""
2400 if not value:
2401 return None
2402 try:
2403 x, y = (float(part) for part in value.split(","))
2404 except ValueError:
2405 raise SystemExit(f"{flag} expects X,Y (e.g. 305,44), got {value!r}")
2406 return (x, y)
2409def _load_multipleye_render(
2410 export: str | None,
2411 participant: str | None,
2412 trial: str | None,
2413 *,
2414 list_only: bool = False,
2415 include_question_screens: bool = True,
2416):
2417 """Native MultiplEYE load for `render --source multipleye`.
2419 Loads the same normalized frames as the interactive viewer's MultiplEYE
2420 server-bundle source (correct word boxes/text + image-origin coordinate
2421 offsets), straight from the RAW export — never the review app's reshaped
2422 per-pid parquet.
2424 ``export`` is the raw export root (defaults to ``$MULTIPLEYE_DATA_DIR``).
2425 ``participant`` is the session label, resolved case-insensitively (the review
2426 app passes it lowercased, e.g. ``001_zh_ch_1_et2``; export ids are uppercase).
2428 ``trial`` selection (DATA-24: a trial is one *reading of a stimulus*, and its
2429 pages / question screens are screens inside it, picked with ``--screen``):
2430 * a literal trial id — the stimulus, ``Lit_Alchemist_4`` — is used as-is;
2431 * an integer N (the review app's trial number) selects the stimulus whose
2432 ``trial_num == N``. With no ``--screen`` that renders its first screen,
2433 i.e. reading page 1 — the single representative page a thumbnail shows.
2435 ``include_question_screens`` mirrors the loader kwarg (``--no-question-screens``).
2437 Returns ``(words, fixations, participant_id, trial_id)`` — the resolved ids
2438 are passed straight to ``api.plot_scanpath``. With ``list_only`` the trial is
2439 left unresolved (the caller just prints the combos).
2440 """
2441 import os
2443 from .datasets import multipleye_inventory
2445 root = (export or os.environ.get("MULTIPLEYE_DATA_DIR", "")).strip()
2446 if not root:
2447 raise SystemExit(
2448 "--source multipleye needs --export DIR (or $MULTIPLEYE_DATA_DIR) "
2449 "pointing at a MultiplEYE raw export root."
2450 )
2452 # Resolve the (possibly lowercased) pid to the export's canonical session id.
2453 sessions, _ = multipleye_inventory(root, fixation_source="scanpaths")
2454 if not sessions:
2455 raise FileNotFoundError(
2456 f"No MultiplEYE sessions under {root} — expected a raw export root "
2457 "with per-session subfolders under scanpaths/."
2458 )
2459 session = None
2460 if participant is not None:
2461 want = str(participant).strip().lower()
2462 session = next((s for s in sessions if s.lower() == want), None)
2463 if session is None:
2464 raise ValueError(
2465 f"No MultiplEYE session matching participant {participant!r} "
2466 f"(available: {', '.join(sessions)})."
2467 )
2469 from .datasets import load_multipleye
2471 # Load only the requested session (sub-second); all sessions for --list-trials
2472 # without a -p. Normalized frames, exactly like the viewer.
2473 words, fixations = load_multipleye(
2474 root,
2475 sessions=[session] if session else None,
2476 fixation_source="scanpaths",
2477 include_question_screens=include_question_screens,
2478 names="canonical",
2479 )
2480 if list_only:
2481 return words, fixations, session, None
2483 pid = session
2484 tid = trial
2485 known = set(fixations["trial_id"].astype(str)) if not fixations.empty else set()
2486 if trial is not None and str(trial) not in known:
2487 # An integer trial number (the review app's id): map trial_num → the
2488 # stimulus trial it names. Its screens are selected with --screen /
2489 # --all-screens; with neither, the first screen (reading page 1) renders,
2490 # which is the single representative page a thumbnail shows.
2491 try:
2492 trial_num = int(str(trial))
2493 except (TypeError, ValueError):
2494 raise ValueError(
2495 f"--trial {trial!r} is neither a MultiplEYE trial id (the "
2496 "stimulus, e.g. Lit_Alchemist_4) nor an integer trial number."
2497 )
2498 if "trial_num" not in fixations.columns:
2499 raise ValueError(
2500 "MultiplEYE fixations carry no 'trial_num' column — pass the "
2501 "stimulus trial id to --trial instead."
2502 )
2503 match = fixations[fixations["trial_num"].astype("Int64") == trial_num]
2504 if match.empty:
2505 avail = sorted(
2506 fixations["trial_num"].dropna().astype(int).unique().tolist()
2507 )
2508 raise ValueError(
2509 f"No MultiplEYE trial_num={trial_num} for session {session!r} "
2510 f"(available: {avail})."
2511 )
2512 tid = min(match["trial_id"].astype(str).unique())
2514 return words, fixations, pid, tid
2517def _apply_shared_colorbar_flags(args: argparse.Namespace) -> None:
2518 """Hand v0.33.0's shared ``--colorbar-*`` values to each bar that was not
2519 given its own; a bar's own flag wins, wherever it sits on the line.
2520 ``--colorbars`` needs nothing: both bars are shown unless a ``--no-*``
2521 leaves one out, which it still does beside ``--colorbars``."""
2522 for setting in ("orientation", "tickangle", "tickfont_size"):
2523 shared = getattr(args, f"shared_colorbar_{setting}")
2524 if shared is None:
2525 continue
2526 for bar in ("fixation", "heatmap"):
2527 if getattr(args, f"{bar}_colorbar_{setting}") is None:
2528 setattr(args, f"{bar}_colorbar_{setting}", shared)
2531def _save_kwargs(args) -> dict:
2532 """`api.save_figure`'s size keywords from the render flags; the print
2533 width only when one was given (#374, F28)."""
2534 kwargs = {"scale": args.scale, "width": args.width, "height": args.height}
2535 if args.width_mm is not None:
2536 kwargs["width_mm"] = args.width_mm
2537 if args.width_in is not None:
2538 kwargs["width_in"] = args.width_in
2539 if args.dpi is not None:
2540 kwargs["dpi"] = args.dpi
2541 return kwargs
2544def _parse_screen_list(value: str | None) -> tuple[str, ...] | None:
2545 """``--screens``' comma-separated ids, or ``None`` when not given."""
2546 if value is None:
2547 return None
2548 screens = tuple(part.strip() for part in value.split(",") if part.strip())
2549 if not screens:
2550 raise SystemExit("--screens names no screen; pass e.g. --screens Paragraph.")
2551 return screens
2554def render(argv: list[str]) -> None:
2555 # Bound, not inlined: DATA-27's --eyegenbench branch calls
2556 # `parser.error(...)` further down to reject a missing --eyegenbench-dataset.
2557 parser = _render_parser()
2558 args = parser.parse_args(argv)
2559 _apply_shared_colorbar_flags(args)
2560 # #374 F28: a print width sizes a PNG; say so before the data loads.
2561 printed = args.width_mm is not None or args.width_in is not None
2562 if args.dpi is not None and not printed:
2563 parser.error("--dpi is the resolution of --width-mm / --width-in; add one.")
2564 if printed and args.output and not str(args.output).lower().endswith(".png"):
2565 parser.error("--width-mm / --width-in size a PNG; write to a .png file.")
2566 # Validate everything derivable from argv before the (possibly minutes-long
2567 # on full corpora) data load.
2568 corpus_inputs = [
2569 args.sample,
2570 bool(args.authoring),
2571 bool(args.potec),
2572 bool(args.eyegenbench),
2573 bool(args.onestop),
2574 bool(args.source),
2575 ]
2576 # VIZ-45: your own tables are words and/or fixations — or raw gaze alone,
2577 # for a dataset recorded as samples only. Beside any other input,
2578 # --raw-gaze stays what it always was: a layer drawn over that input.
2579 own_tables = bool(args.words or args.fixations) or (
2580 bool(args.raw_gaze) and not any(corpus_inputs)
2581 )
2582 if sum([*corpus_inputs, own_tables]) != 1:
2583 # Only the inputs `--help` lists: the DATA-54/55 held-back sources still
2584 # count towards the guard, but the message doesn't advertise them.
2585 inputs = ["--sample", "--authoring PATH", "--potec DIR"]
2586 if benchmark_corpora_enabled():
2587 inputs.append("--eyegenbench DIR --eyegenbench-dataset NAME")
2588 inputs.append("--onestop DIR")
2589 if multipleye_enabled():
2590 inputs.append("--source NAME [--export DIR]")
2591 raise SystemExit(
2592 f"Provide exactly one input: {', '.join(inputs)}, or your own tables "
2593 "(--words and/or --fixations; one of them is enough for "
2594 "single-report datasets; --raw-gaze alone for raw gaze only)."
2595 )
2596 if not (args.list_trials or args.list_parts) and not args.output:
2597 raise SystemExit("Missing -o/--output (or use --list-trials/--list-parts).")
2598 if args.trial_parts_manifest and not (args.words or args.fixations):
2599 raise SystemExit("--trial-parts-manifest requires --words and/or --fixations.")
2600 # EXP-13: a mapping describes one of *your* tables, so it needs that table.
2601 if args.word_schema is not None and not args.words:
2602 raise SystemExit("--word-schema maps the --words table; pass --words too.")
2603 if args.fix_schema is not None and not args.fixations:
2604 raise SystemExit(
2605 "--fix-schema maps the --fixations table; pass --fixations too."
2606 )
2607 word_schema = _parse_schema_arg(args.word_schema, "--word-schema")
2608 fix_schema = _parse_schema_arg(args.fix_schema, "--fix-schema")
2609 # EXP-20: raw gaze is a third table, from a file or — for the demo — the
2610 # bundled one. Checked before the load, like the two schemas above.
2611 if args.sample_raw_gaze and not args.sample:
2612 raise SystemExit(
2613 "--sample-raw-gaze draws the bundled demo's raw gaze; it needs "
2614 "--sample. Pass your own table with --raw-gaze PATH."
2615 )
2616 if args.sample_raw_gaze and args.raw_gaze:
2617 raise SystemExit("Pass --raw-gaze PATH or --sample-raw-gaze, not both.")
2618 if args.show_raw_gaze is False and not (args.raw_gaze or args.sample_raw_gaze):
2619 print(
2620 "Warning: --no-raw-gaze hides the raw-gaze layer, and no --raw-gaze "
2621 "table was given; ignoring it.",
2622 file=sys.stderr,
2623 )
2624 if args.compare_raw_gaze and args.compare_with is None:
2625 raise SystemExit(
2626 "--compare-raw-gaze is scanpath B's raw gaze; pass --compare-with "
2627 "PARTICIPANT:TRIAL too."
2628 )
2629 if args.raw_gaze_schema is not None and not args.raw_gaze:
2630 raise SystemExit(
2631 "--raw-gaze-schema maps the --raw-gaze table; pass --raw-gaze too."
2632 )
2633 raw_gaze_schema = _parse_schema_arg(args.raw_gaze_schema, "--raw-gaze-schema")
2634 # EXP-20: these describe the second scanpath of a comparison, so on their
2635 # own there is nothing for them to style — refused, like a lone --label-a.
2636 compare_only = [
2637 flag
2638 for flag, given in (
2639 ("--compare-legend", args.show_legend is not None),
2640 ("--style-a", args.style_a),
2641 ("--style-b", args.style_b),
2642 ("--stimulus-image-b", args.stimulus_image_b),
2643 ("--stimulus-image-size-b", args.stimulus_image_size_b),
2644 ("--stimulus-image-origin-b", args.stimulus_image_origin_b),
2645 ("--compare-fixation-flag", args.compare_fixation_flags),
2646 ("--compare-saccade-classes", args.compare_saccade_classes),
2647 ("--compare-fix-index-range", args.compare_fix_index_range),
2648 ("--compare-screen", args.compare_screen),
2649 )
2650 if given
2651 ]
2652 if compare_only and args.compare_with is None:
2653 raise SystemExit(
2654 f"{', '.join(compare_only)} style a comparison of two scanpaths; "
2655 "pass --compare-with PARTICIPANT:TRIAL too."
2656 )
2657 # A comparison is one figure of two readings; --all-screens writes one figure
2658 # per child screen of a multipart trial. There is no defined pairing between
2659 # the two, and without this guard the compare branch left `figures` unbound
2660 # and the run died on an UnboundLocalError instead of saying so.
2661 # --screens is --all-screens cut to the screens named.
2662 chosen_screens = _parse_screen_list(args.screens)
2663 if chosen_screens is not None:
2664 if args.screen is not None:
2665 raise SystemExit(
2666 "--screen renders one screen and --screens several; pass one of them."
2667 )
2668 args.all_screens = True
2669 if args.compare_with is not None and args.all_screens:
2670 raise SystemExit(
2671 "--compare-with cannot be combined with --all-screens or --screens: a comparison "
2672 "is a single figure of two trials. Render one screen at a time with "
2673 "--screen SCREEN_ID."
2674 )
2675 # `compare_scanpaths` takes `labels` as a pair or not at all — there is no
2676 # way to name one side and leave the builder to compose the other — so a
2677 # lone flag is refused rather than silently dropped.
2678 if (args.label_a is None) != (args.label_b is None):
2679 raise SystemExit(
2680 "--label-a and --label-b go together: name both scanpaths, or neither."
2681 )
2682 # ENG-53: each panel of a split layout draws its own reading's stimulus, so
2683 # there is no shared set of word boxes to pick from — the builder ignores
2684 # the choice there. Say so, rather than letting the docs' old "side by side,
2685 # showing only B's word boxes" example quietly draw both.
2686 if (
2687 args.compare_with is not None
2688 and not args.animate
2689 and args.compare_layout != "overlay"
2690 and args.compare_stimulus != "both"
2691 ):
2692 print(
2693 f"Warning: --compare-stimulus {args.compare_stimulus} only applies to "
2694 f"--compare-layout overlay; each {args.compare_layout} panel draws its "
2695 "own trial's stimulus. Ignoring it.",
2696 file=sys.stderr,
2697 )
2698 if args.label_a is not None and args.compare_with is None:
2699 raise SystemExit(
2700 "--label-a/--label-b label the two scanpaths of a comparison or of "
2701 "an --animate co-animation; both need --compare-with."
2702 )
2703 canvas = _parse_canvas(args.canvas)
2704 if args.coordinate_grid_spacing is not None and args.coordinate_grid_spacing <= 0:
2705 raise SystemExit("--coordinate-grid-spacing must be a positive number.")
2706 if args.animate and args.output and not args.output.lower().endswith(".html"):
2707 raise SystemExit(
2708 "--animate writes interactive HTML — use a .html output. For GIF or "
2709 "MP4, use the app's Export, or "
2710 "scanpath_studio.animation_export.export_animation in Python."
2711 )
2712 # PRE-3: the connectors draw *between* the original and corrected y, so on
2713 # their own there is nothing to connect. Warn rather than fail — the render
2714 # is still valid, just uncorrected.
2715 if args.drift_connectors and not args.drift_correction:
2716 print(
2717 "Warning: --drift-connectors has no effect without "
2718 "--drift-correction ALGORITHM; ignoring it.",
2719 file=sys.stderr,
2720 )
2722 from . import api
2724 # DATA-66: `render` works in the internal names, which the metadata joins,
2725 # the trial checks and every option below are written against, and keeps
2726 # each loader's map of the dataset's own names (`ScanpathData.column_names`)
2727 # for what it prints, writes and draws.
2728 column_names: dict = {}
2729 # Each fixed-screen source's monitor is `code_snippet.source_canvas`, the
2730 # table `api.figure_code` reads too, so both flavours of a recipe agree.
2731 if args.sample:
2732 data = api.load_sample_data(names="canonical")
2733 words, fixations = data
2734 column_names = dict(data.column_names)
2735 canvas = canvas or source_canvas(SOURCE_DEMO)
2736 elif args.authoring:
2737 try:
2738 words, fixations = api.load_authored_scanpath(args.authoring)
2739 except FileNotFoundError:
2740 raise SystemExit(f"--authoring: file not found: {args.authoring}") from None
2741 except (ValueError, OSError) as exc:
2742 raise SystemExit(str(exc)) from exc
2743 canvas = canvas or source_canvas(SOURCE_AUTHOR)
2744 elif args.potec:
2745 from .data import split_composite_id
2746 from .datasets import load_potec
2748 try:
2749 data = load_potec(
2750 args.potec,
2751 names="canonical",
2752 # Narrow the 900-file load when the trial is known — its
2753 # text is the part after the reader (`0_b0` → `b0`); reader
2754 # ids always need the full reader list for --list-trials so
2755 # only narrow with an explicit -p.
2756 readers=[args.participant] if args.participant else None,
2757 texts=[split_composite_id(args.trial)[-1]] if args.trial else None,
2758 download=True,
2759 )
2760 except (ValueError, FileNotFoundError, OSError) as exc:
2761 raise SystemExit(str(exc))
2762 words, fixations = data
2763 column_names = dict(data.column_names)
2764 canvas = canvas or source_canvas(SOURCE_POTEC)
2765 elif args.eyegenbench:
2766 if not args.eyegenbench_dataset:
2767 parser.error("--eyegenbench requires --eyegenbench-dataset NAME")
2768 from .eyegenbench import eyegenbench_monitor, load_eyegenbench
2770 try:
2771 data = load_eyegenbench(
2772 args.eyegenbench, dataset=args.eyegenbench_dataset, names="canonical"
2773 )
2774 words, fixations = data
2775 column_names = dict(data.column_names)
2776 # `eyegenbench_monitor` answers None for a corpus whose manifest
2777 # only carries the invented default screen, so `render` falls back
2778 # to the data's own extents there — the same call the app's picker
2779 # entry makes (I3). Before this the CLI drew those corpora at
2780 # 1920x1080 while the app drew them at data extents.
2781 canvas = canvas or eyegenbench_monitor(
2782 args.eyegenbench, args.eyegenbench_dataset
2783 )
2784 except (ValueError, FileNotFoundError, OSError) as exc:
2785 raise SystemExit(str(exc))
2786 elif args.onestop:
2787 from .datasets import load_onestop
2789 try:
2790 data = load_onestop(
2791 args.onestop,
2792 regime=args.onestop_regime,
2793 parts=args.onestop_part, # None → Paragraph default
2794 variant=args.onestop_variant,
2795 # The lacclab variant is local (no download); the public one
2796 # fetches the chosen regime + parts from OSF on first use.
2797 download=args.onestop_variant == "public",
2798 names="canonical",
2799 )
2800 except (ValueError, FileNotFoundError, OSError) as exc:
2801 raise SystemExit(str(exc))
2802 words, fixations = data
2803 column_names = dict(data.column_names)
2804 canvas = canvas or source_canvas(SOURCE_ONESTOP)
2805 elif args.source == "multipleye":
2806 try:
2807 words, fixations, args.participant, args.trial = _load_multipleye_render(
2808 args.export,
2809 args.participant,
2810 args.trial,
2811 list_only=args.list_trials,
2812 include_question_screens=not args.no_question_screens,
2813 )
2814 except (ValueError, FileNotFoundError, OSError) as exc:
2815 raise SystemExit(str(exc))
2816 # Same authoritative monitor the viewer's MultiplEYE bundle source snaps
2817 # to — coords are offset onto the centered stimulus on the real screen.
2818 canvas = canvas or source_canvas(SOURCE_MULTIPLEYE)
2819 else:
2820 manifest = None
2821 if args.trial_parts_manifest:
2822 try:
2823 manifest = json.loads(
2824 Path(args.trial_parts_manifest).read_text(encoding="utf-8")
2825 )
2826 except (OSError, json.JSONDecodeError) as exc:
2827 raise SystemExit(f"Could not read trial-parts manifest: {exc}") from exc
2828 # EXP-13: the one input branch that had no guard, so a missing file or
2829 # an unrecognised column ended the run in a traceback — whose hint
2830 # named a `word_schema=` argument the command line could not pass.
2831 if not (args.words or args.fixations):
2832 # VIZ-45: raw gaze alone. The two frames are the empty canonical
2833 # ones `load_scanpath_data` returns for a table it is not given;
2834 # the samples are loaded below with every other raw-gaze input.
2835 from .data import empty_fixations_frame, empty_words_frame
2837 words, fixations = empty_words_frame(), empty_fixations_frame()
2838 else:
2839 try:
2840 data = api.load_scanpath_data(
2841 args.words,
2842 args.fixations,
2843 word_schema=word_schema,
2844 fix_schema=fix_schema,
2845 image_root=args.image_root,
2846 image_pattern=args.image_pattern,
2847 trial_parts_manifest=manifest,
2848 keep_columns=args.keep_columns,
2849 names="canonical",
2850 )
2851 except (ValueError, OSError) as exc:
2852 raise SystemExit(_load_error_message(exc)) from exc
2853 words, fixations = data
2854 column_names = dict(data.column_names)
2856 if args.image_root and not (args.words or args.fixations):
2857 from .data import resolve_stimulus_image_paths
2859 try:
2860 words = resolve_stimulus_image_paths(
2861 words, args.image_root, args.image_pattern
2862 )
2863 fixations = resolve_stimulus_image_paths(
2864 fixations, args.image_root, args.image_pattern
2865 )
2866 except ValueError as exc:
2867 raise SystemExit(str(exc)) from exc
2869 # EXP-20: raw gaze is a third table. Loaded before the metadata joins and
2870 # --list-trials, so a raw-gaze-only input joins and lists its own trials
2871 # (VIZ-45).
2872 raw_gaze = None
2873 if args.raw_gaze or args.sample_raw_gaze:
2874 try:
2875 raw_gaze = (
2876 api.load_sample_raw_gaze()
2877 if args.sample_raw_gaze
2878 else api.load_raw_gaze(args.raw_gaze, raw_gaze_schema=raw_gaze_schema)
2879 )
2880 except (ValueError, OSError) as exc:
2881 raise SystemExit("--raw-gaze: " + _load_error_message(exc)) from exc
2882 # DATA-66: its own names kept beside it, the frame itself canonical.
2883 if (found := _cn.frame_names(raw_gaze)) is not None:
2884 column_names[found[0]] = found[1]
2885 raw_gaze = _cn.to_canonical_frame(raw_gaze)
2887 # VIZ-45: what the metadata tables are joined against — the samples, when
2888 # they are the only table, or every join would report "0 matched".
2889 join_frame = (
2890 raw_gaze
2891 if raw_gaze is not None and fixations.empty and words.empty
2892 else fixations
2893 )
2895 # DATA-20: attaching a participant table headlessly is worth doing for the
2896 # join report alone — a mistyped id column or a cohort file from the wrong
2897 # study is exactly what you want to hear about before rendering 300 figures.
2898 if args.participant_metadata:
2899 try:
2900 attached = api.load_participant_metadata(
2901 args.participant_metadata, participants=join_frame
2902 )
2903 except (ValueError, FileNotFoundError, OSError) as exc:
2904 raise SystemExit(str(exc))
2905 report = attached.report
2906 print(
2907 f"Participant metadata: {_count(len(attached.fields), 'field')} "
2908 f"({', '.join(attached.names)}) for "
2909 f"{_count(len(report.matched), 'participant')}.",
2910 file=sys.stderr,
2911 )
2912 for label, ids in (
2913 ("in the data, not the table", report.only_in_data),
2914 ("in the table, not the data", report.only_in_table),
2915 ("rows that disagree (left empty)", report.conflicting),
2916 ):
2917 if ids:
2918 print(f" {len(ids)} {label}: {', '.join(ids)}", file=sys.stderr)
2919 _print_combined_rows(report)
2921 # DATA-29: the same, one grain down. A trial table's report is worth more
2922 # than the participant one, not less: getting the *key* wrong is silent
2923 # (a table keyed by trial alone still joins, it just means something else),
2924 # and "0 matched" here is the one thing that says so out loud.
2925 attached_trials = None
2926 if args.trial_metadata:
2927 try:
2928 attached_trials = api.load_trial_metadata(
2929 args.trial_metadata,
2930 participant_column=args.trial_metadata_reader_column,
2931 trials=join_frame,
2932 )
2933 except (ValueError, FileNotFoundError, OSError) as exc:
2934 raise SystemExit(str(exc))
2935 report = attached_trials.report
2936 keyed = (
2937 "participant + trial"
2938 if attached_trials.keyed_by_participant
2939 else "trial id"
2940 )
2941 print(
2942 f"Trial metadata: {_count(len(attached_trials.fields), 'field')} "
2943 f"({', '.join(attached_trials.names)}) for "
2944 f"{_count(len(report.matched), 'trial')}, keyed by {keyed}.",
2945 file=sys.stderr,
2946 )
2947 for label, keys in (
2948 ("in the data, not the table", report.only_in_data),
2949 ("in the table, not the data", report.only_in_table),
2950 ("rows that disagree (left empty)", report.conflicting),
2951 ):
2952 if keys:
2953 shown = ", ".join(_format_trial_key(key) for key in keys[:20])
2954 # Plain "..." rather than an ellipsis character: this line
2955 # goes to stderr, and a Windows console in its default
2956 # code page prints the single glyph as a replacement mark.
2957 more = "" if len(keys) <= 20 else f", ... (+{len(keys) - 20})"
2958 print(f" {len(keys)} {label}: {shown}{more}", file=sys.stderr)
2959 _print_combined_rows(report)
2960 elif args.trial_metadata_reader_column:
2961 raise SystemExit(
2962 "--trial-metadata-participant-column needs --trial-metadata: it names a "
2963 "column in that table."
2964 )
2966 # The third grain, flat like the participant table — a text is a
2967 # stimulus, so it always joins on text id alone.
2968 attached_texts = None
2969 if args.text_metadata:
2970 try:
2971 attached_texts = api.load_text_metadata(
2972 args.text_metadata, texts=words if not words.empty else join_frame
2973 )
2974 except (ValueError, FileNotFoundError, OSError) as exc:
2975 raise SystemExit(str(exc))
2976 report = attached_texts.report
2977 print(
2978 f"Text metadata: {_count(len(attached_texts.fields), 'field')} "
2979 f"({', '.join(attached_texts.names)}) for "
2980 f"{_count(len(report.matched), 'text')}.",
2981 file=sys.stderr,
2982 )
2983 for label, ids in (
2984 ("in the data, not the table", report.only_in_data),
2985 ("in the table, not the data", report.only_in_table),
2986 ("rows that disagree (left empty)", report.conflicting),
2987 ):
2988 if ids:
2989 print(f" {len(ids)} {label}: {', '.join(ids)}", file=sys.stderr)
2990 _print_combined_rows(report)
2992 if args.list_trials:
2993 combos = api.list_trials(words, fixations, raw_gaze=raw_gaze)
2994 if (
2995 args.participant_metadata
2996 or attached_trials is not None
2997 or attached_texts is not None
2998 ):
2999 from scanpath_studio import metadata as _metadata
3001 if args.participant_metadata:
3002 combos = _metadata.project(attached, combos)
3003 if attached_trials is not None:
3004 combos = _metadata.project_trials(attached_trials, combos)
3005 if attached_texts is not None:
3006 # `list_trials`'s combos is deliberately just
3007 # (participant_id, trial_id) — text_id isn't part of its
3008 # public contract — so bring it in here, from whichever
3009 # frame has it, before projecting the text table onto it.
3010 source = (
3011 fixations
3012 if not fixations.empty
3013 else words
3014 if not words.empty
3015 else join_frame
3016 )
3017 if "text_id" in source.columns:
3018 combos = combos.merge(
3019 source[
3020 ["participant_id", "trial_id", "text_id"]
3021 ].drop_duplicates(),
3022 on=["participant_id", "trial_id"],
3023 how="left",
3024 )
3025 combos = _metadata.project_texts(attached_texts, combos)
3026 # #374 F21: the text id too — the id the app shows a trial by.
3027 source = fixations if not fixations.empty else words
3028 if (
3029 "text_id" not in combos.columns
3030 and source is not None
3031 and {"participant_id", "trial_id", "text_id"} <= set(source.columns)
3032 ):
3033 combos = combos.merge(
3034 source[["participant_id", "trial_id", "text_id"]].drop_duplicates(
3035 ["participant_id", "trial_id"]
3036 ),
3037 on=["participant_id", "trial_id"],
3038 how="left",
3039 )
3040 print(
3041 f"{len(combos)} trial{'' if len(combos) == 1 else 's'}. Pass the "
3042 "participant as -p and the trial as -t; the app shows a trial by "
3043 "its participant and text.",
3044 file=sys.stderr,
3045 )
3046 # DATA-66: the ids under the dataset's own names.
3047 print(_listed(combos, column_names).to_string(index=False))
3048 return
3049 if args.list_parts:
3050 parts = api.list_parts(
3051 words, fixations, args.participant, args.trial, raw_gaze=raw_gaze
3052 )
3053 if parts.empty:
3054 print("No multipart screens (the selected data is single-screen).")
3055 else:
3056 print(_listed(parts, column_names).to_string(index=False))
3057 return
3059 try:
3060 # A given -p/-t must match exactly (mistyped ids are errors, never
3061 # silently swapped for another trial); only genuinely unspecified
3062 # parts default to the first available combo, like the app. VIZ-45:
3063 # a trial only the raw gaze has is one of them.
3064 participant, trial = api._resolve_trial(
3065 words,
3066 fixations,
3067 args.participant,
3068 args.trial,
3069 default_first=True,
3070 raw_gaze=raw_gaze,
3071 )
3072 except ValueError as exc:
3073 raise SystemExit(str(exc))
3074 trial_fixations_missing = raw_gaze is not None and (
3075 fixations.empty
3076 or not (
3077 (fixations["participant_id"].astype(str) == str(participant))
3078 & (fixations["trial_id"].astype(str) == str(trial))
3079 ).any()
3080 )
3081 if trial_fixations_missing:
3082 # VIZ-45: say it in the command's own terms before the API says it in
3083 # Python's. Both modes are made of fixations, and none are detected
3084 # from the samples. Decided for this trial, not the dataset.
3085 for flag, given in (
3086 ("--animate", args.animate),
3087 ("--compare-with", args.compare_with is not None),
3088 ):
3089 if given:
3090 raise SystemExit(
3091 f"{flag} draws fixations, and this trial has none — only "
3092 "raw gaze samples, which Scanpath Studio does not turn into "
3093 f"fixations. Drop {flag} to draw the samples."
3094 )
3095 against = f", compared with {args.compare_with}" if args.compare_with else ""
3096 print(
3097 f"Rendering participant={participant} trial={trial}{against}",
3098 file=sys.stderr,
3099 )
3101 overrides = {
3102 key: getattr(args, key)
3103 for key in (
3104 "show_words",
3105 "show_word_labels",
3106 "show_fixations",
3107 "show_order",
3108 "show_saccades",
3109 "show_heatmap",
3110 "show_saccade_arrows",
3111 )
3112 # Only a flag on the line overrides the API's default (#374, F21).
3113 if getattr(args, key) is not None
3114 }
3115 # VIZ-45: only when given — `plot_scanpath` turns the layer on for the
3116 # frame it is handed, and an override is the one thing that says off.
3117 if args.show_raw_gaze is False:
3118 overrides["show_raw_gaze"] = False
3119 if args.coordinate_grid or args.coordinate_grid_spacing is not None:
3120 overrides["show_coordinate_grid"] = True
3121 overrides["coordinate_grid_spacing"] = args.coordinate_grid_spacing
3122 if args.word_hover_fields is not None:
3123 overrides["word_hover_fields"] = [
3124 field.strip()
3125 for field in args.word_hover_fields.split(",")
3126 if field.strip()
3127 ]
3128 if args.fixation_hover_fields is not None:
3129 overrides["fixation_hover_fields"] = [
3130 field.strip()
3131 for field in args.fixation_hover_fields.split(",")
3132 if field.strip()
3133 ]
3134 # VIZ-18: the palette rides along as an override; api._expand_palette turns
3135 # it into colour kwargs and lets any explicit --*-color below win.
3136 if args.palette:
3137 overrides["palette"] = args.palette
3138 if args.color_by:
3139 overrides["color_by"] = args.color_by
3140 if args.fixation_color: # VIZ-17 flat fixation colour
3141 overrides["fixation_color"] = args.fixation_color
3142 if args.fixation_symbol: # VIZ-15 marker shape
3143 overrides["fixation_symbol"] = args.fixation_symbol
3144 if args.heatmap_metric:
3145 overrides["heatmap_metric"] = args.heatmap_metric
3146 if args.heatmap_style:
3147 overrides["heatmap_style"] = {
3148 "word-boxes": "Word boxes",
3149 "interpolated": "Interpolated",
3150 }[args.heatmap_style]
3151 if args.heatmap_sigma is not None:
3152 overrides["heatmap_sigma_px"] = args.heatmap_sigma
3153 if args.heatmap_colorscale:
3154 overrides["heatmap_colorscale"] = args.heatmap_colorscale
3155 if args.heatmap_norm:
3156 overrides["heatmap_norm"] = args.heatmap_norm.capitalize() # linear→Linear
3157 if args.fixation_colorscale:
3158 overrides["fixation_colorscale"] = args.fixation_colorscale
3159 if args.marker_size_range:
3160 overrides["marker_size_range"] = tuple(args.marker_size_range)
3161 if args.saccade_color:
3162 overrides["saccade_color"] = args.saccade_color
3163 if args.saccade_style:
3164 overrides["saccade_style"] = args.saccade_style
3165 if args.saccade_width is not None:
3166 overrides["saccade_width"] = args.saccade_width
3167 # VIZ-8: colour saccades by reading type. Either flag turns the mode on; each
3168 # CLASS=COLOR pair overrides one class colour; --no-saccade-type-legend hides
3169 # the colour key.
3170 # VIZ-19: --saccade-color-by-direction is the two-way fold; the full five-way
3171 # split wins if both are given (it's the more specific request).
3172 if args.saccade_color_by_direction:
3173 overrides["saccade_color_mode"] = "Forward / regression"
3174 # EXP-20: a class colour beside --saccade-color-by-direction recolours the
3175 # two-way split rather than overriding the mode the user asked for, so the
3176 # fold's own colours have a flag too.
3177 if args.saccade_color_by_type or (
3178 args.saccade_type_colors and not args.saccade_color_by_direction
3179 ):
3180 overrides["saccade_color_mode"] = "By type"
3181 if not args.saccade_type_legend:
3182 overrides["saccade_type_legend"] = False
3183 if args.saccade_type_colors:
3184 # Over the palette's class colours when one is named: the explicit dict
3185 # wins over `--palette` wholesale in `api._expand_palette`, so starting
3186 # from the stock set would put back every class the flags left alone —
3187 # and a printed recipe restates only the classes the palette got wrong.
3188 class_colors = dict(
3189 palette_settings(args.palette)["saccade_class_colors"]
3190 if args.palette
3191 else SACCADE_CLASS_COLORS
3192 )
3193 for pair in args.saccade_type_colors:
3194 cls_name, _, color = pair.partition("=")
3195 cls_name = cls_name.strip()
3196 if not color or cls_name not in SACCADE_CLASS_EDITABLE:
3197 raise SystemExit(
3198 f"--saccade-type-color expects CLASS=COLOR with CLASS one of "
3199 f"{', '.join(SACCADE_CLASS_EDITABLE)}; got {pair!r}."
3200 )
3201 class_colors[cls_name] = color.strip()
3202 overrides["saccade_class_colors"] = class_colors
3203 # The critical-span pair. `--highlight-column ''` is a real request (mark
3204 # nothing) and must survive the truthiness test the other options use, so it
3205 # is compared against None.
3206 if args.highlight_column is not None:
3207 overrides["highlight_column"] = args.highlight_column or None
3208 if args.critical_span_style:
3209 overrides["critical_span_style"] = _CRITICAL_SPAN_STYLES[
3210 args.critical_span_style
3211 ]
3212 if args.fixation_flags:
3213 overrides["fixation_flags"] = _parse_fixation_flags(args.fixation_flags)
3214 if args.legend_layout:
3215 overrides["legend_layout"] = _parse_legend_layout(args.legend_layout)
3216 # VIZ-31: the reading-class filter. Independent of the colour mode above —
3217 # "only the regressions, in one colour" is as valid as "all of them, coloured
3218 # by type" — so it is its own flag rather than a mode.
3219 if args.saccade_classes:
3220 overrides["saccade_classes"] = _parse_saccade_classes_arg(
3221 args.saccade_classes, "--saccade-classes"
3222 )
3223 # VIZ-9: linear-reading mode.
3224 if args.saccade_arcs:
3225 overrides["saccade_render_mode"] = "Arc"
3226 if args.snap_fixations:
3227 overrides["fixation_snap_to_word"] = True
3228 if args.illustration:
3229 preset = dict(
3230 show_words=False,
3231 show_word_labels=True,
3232 show_fixations=True,
3233 show_order=False,
3234 show_saccades=True,
3235 show_saccade_arrows=False,
3236 show_heatmap=False,
3237 color_by=UNIFORM_COLOR_FIELD,
3238 saccade_color_mode="Uniform",
3239 saccade_render_mode="Arc",
3240 fixation_snap_to_word=True,
3241 fixation_opacity=1.0,
3242 )
3243 # BUG-85 review: an explicit flag wins over the preset, as it does over
3244 # `plot_scanpath(illustration=True, …)` — the preset used to overwrite
3245 # `--color-by`, `--no-labels` and the rest set above. A layer switch is
3246 # in `overrides` only when its flag was given.
3247 stated = {key for key in preset if key in overrides}
3248 overrides.update({k: v for k, v in preset.items() if k not in stated})
3249 # VIZ-4: image stimulus background. make_scanpath_figure only draws the image
3250 # when a size is known, so default to the PNG's own pixel size, then the
3251 # canvas.
3252 if args.stimulus_image:
3253 from .plots import _png_pixel_size
3255 _require_image(args.stimulus_image, "--stimulus-image")
3256 overrides["background_image"] = args.stimulus_image
3257 overrides["background_image_size"] = (
3258 _parse_canvas(args.stimulus_image_size, "--stimulus-image-size")
3259 or _png_pixel_size(args.stimulus_image)
3260 or canvas
3261 )
3262 overrides["background_image_origin"] = _parse_xy(
3263 args.stimulus_image_origin
3264 ) or (0.0, 0.0)
3265 if args.stimulus_image_opacity is not None:
3266 overrides["background_image_opacity"] = args.stimulus_image_opacity
3267 # EXP-20: the rest of the figure options. After `--illustration` on purpose,
3268 # so an explicit flag wins over the preset — the order `plot_scanpath`'s own
3269 # `illustration=True` applies them in.
3270 for key in _DIRECT_OPTION_FLAGS:
3271 value = getattr(args, key)
3272 if value is not None:
3273 # `--word-hover-measure ''` is the real request "no measure on
3274 # hover", the option's own `None` — the `--highlight-column ''` rule.
3275 overrides[key] = None if value == "" and key in _NONE_WHEN_EMPTY else value
3276 for key, flipped in _SWITCH_OPTION_FLAGS.items():
3277 if getattr(args, key) == flipped:
3278 overrides[key] = flipped
3279 if args.marker_duration_range:
3280 overrides["marker_duration_range"] = tuple(args.marker_duration_range)
3281 if args.fixation_color_range:
3282 overrides["fixation_color_range"] = tuple(args.fixation_color_range)
3283 if args.heatmap_range:
3284 overrides["heatmap_range"] = tuple(args.heatmap_range)
3285 for bar in ("fixation", "heatmap"):
3286 orientation = getattr(args, f"{bar}_colorbar_orientation")
3287 if orientation:
3288 overrides[f"{bar}_colorbar_orientation"] = orientation.capitalize()
3289 if args.compare_with is not None:
3290 if args.show_legend is not None:
3291 overrides["show_legend"] = args.show_legend
3292 for side in ("a", "b"):
3293 style = _parse_style_spec(getattr(args, f"style_{side}"), f"--style-{side}")
3294 if style:
3295 overrides[f"style_{side}"] = style
3296 # CMP-24: B's own filters. A comparison reads them off B's style; the
3297 # co-animation takes B's flags as a setting and draws every class.
3298 b_flags = (
3299 _parse_fixation_flags(args.compare_fixation_flags)
3300 if args.compare_fixation_flags
3301 else None
3302 )
3303 b_classes = (
3304 _parse_saccade_classes_arg(
3305 args.compare_saccade_classes, "--compare-saccade-classes"
3306 )
3307 if args.compare_saccade_classes
3308 else None
3309 )
3310 if args.animate:
3311 if b_classes is not None:
3312 raise SystemExit(
3313 "--compare-saccade-classes filters a comparison figure; the "
3314 "--animate co-animation has no saccade-class filter."
3315 )
3316 if b_flags is not None:
3317 overrides["fixation_flags_b"] = b_flags
3318 elif b_flags is not None or b_classes is not None:
3319 style_b = dict(overrides.get("style_b") or {})
3320 if b_flags is not None:
3321 style_b["fixation_flags"] = b_flags
3322 if b_classes is not None:
3323 style_b["saccade_classes"] = b_classes
3324 overrides["style_b"] = style_b
3325 if args.stimulus_image_b:
3326 from .plots import _png_pixel_size
3328 _require_image(args.stimulus_image_b, "--stimulus-image-b")
3329 overrides["background_image_b"] = args.stimulus_image_b
3330 overrides["background_image_size_b"] = (
3331 _parse_canvas(args.stimulus_image_size_b, "--stimulus-image-size-b")
3332 or _png_pixel_size(args.stimulus_image_b)
3333 or _parse_canvas(args.compare_canvas, "--compare-canvas")
3334 or canvas
3335 )
3336 overrides["background_image_origin_b"] = _parse_xy(
3337 args.stimulus_image_origin_b, "--stimulus-image-origin-b"
3338 ) or (0.0, 0.0)
3340 common = dict(
3341 canvas_size=canvas,
3342 base_font_size=args.font_size,
3343 font_family=args.font_family or FONT_FAMILY,
3344 title=args.title or "",
3345 caption=args.caption or "",
3346 # DATA-66: the figure's text and the column flags in the dataset's own
3347 # names — the frames themselves stay canonical.
3348 column_names=column_names or None,
3349 )
3350 if args.print_code:
3351 _print_reproduction_code(
3352 api,
3353 args,
3354 overrides,
3355 canvas,
3356 participant,
3357 trial,
3358 raw_gaze=raw_gaze is not None,
3359 )
3360 try:
3361 if args.animate:
3362 # EXP-10: which options the replay takes is `figure_options
3363 # ("animation")` — the set `animate_scanpath` validates against and
3364 # the snippet serializer diffs against. A hand-kept list here drifted
3365 # from it twice over: `--fixation-symbol` / `--fixation-color` /
3366 # `--palette` were dropped without a word, and `--color-by` /
3367 # `--marker-size-range` / `--fixation-colorscale` were refused as
3368 # unsupported though the builder honours them — so an animation
3369 # snippet copied from the app drew a different figure. `palette` is
3370 # not an option but `animate_scanpath` expands it, keeping only the
3371 # colours the replay can draw.
3372 animation_options = api.figure_options("animation")
3373 anim_kwargs = {
3374 key: value
3375 for key, value in overrides.items()
3376 if key in animation_options or key == "palette"
3377 }
3378 # A key the replay can't take is only worth a warning when it moves
3379 # the static figure off its default — `--heatmap`, not the bare run.
3380 static_defaults = api.figure_options("static")
3381 ignored = [
3382 key
3383 for key, value in overrides.items()
3384 if key not in anim_kwargs and value != static_defaults.get(key)
3385 ]
3386 # PRE-3 drift correction is a plot_scanpath-only parameter (the
3387 # animation builder has no line-snapping path), so name it here too.
3388 if args.drift_correction:
3389 ignored.append("drift_correction")
3390 if args.drift_connectors:
3391 ignored.append("drift_connectors")
3392 # Raw gaze is a `plot_scanpath` frame; the replay draws none.
3393 if raw_gaze is not None:
3394 ignored.append("raw_gaze")
3395 if args.compare_raw_gaze:
3396 ignored.append("compare_raw_gaze")
3397 if ignored:
3398 print(
3399 f"Warning: --animate cannot draw these, ignoring them: "
3400 f"{', '.join(_flags_for(ignored, overrides))}",
3401 file=sys.stderr,
3402 )
3403 # CMP-9/CMP-11: `--animate --compare-with` is the *dual* co-animation
3404 # the app renders when both modes are on — both readings on one clock.
3405 # That is an overlay, so it needs one coordinate space, and it is gated
3406 # on the same `setups_comparable` predicate the static overlay uses.
3407 if args.compare_with is not None:
3408 anim_kwargs.update(
3409 _compare_animation_frames(api, args, words, fixations, canvas)
3410 )
3411 anim_kwargs["compare_stimulus"] = args.compare_stimulus
3412 # EXP-8 §1. `animate_scanpath` names the co-animation's two
3413 # trace labels `label_a` / `label_b` where `compare_scanpaths`
3414 # takes one `labels=` pair, but to the user they are the same
3415 # thing, so one flag pair serves both. They had no flag at all
3416 # before, and an animation snippet reported them unsupported.
3417 labels = _compare_labels(args)
3418 if labels is not None:
3419 anim_kwargs["label_a"], anim_kwargs["label_b"] = labels
3420 # CMP-24: B's own window.
3421 if args.compare_fix_index_range:
3422 anim_kwargs["fix_index_range_b"] = _parse_fix_index_range(
3423 args.compare_fix_index_range
3424 )
3425 animation_options = dict(
3426 playback_speed=args.playback_speed,
3427 autoplay=args.autoplay,
3428 # VIZ-7's window replays too — same parameter, same semantics.
3429 fix_index_range=_parse_fix_index_range(args.fix_index_range),
3430 illustration_label=args.illustration_label,
3431 anim_grid_step_ms=args.anim_grid_step_ms,
3432 anim_max_frames=args.anim_max_frames,
3433 **anim_kwargs,
3434 **common,
3435 )
3436 if args.all_screens:
3437 figures = api.render_parent_trial(
3438 words,
3439 fixations,
3440 participant,
3441 trial,
3442 animate=True,
3443 transition_mode=args.screen_transition,
3444 screens=chosen_screens,
3445 **animation_options,
3446 )
3447 fig = next(iter(figures.values()))
3448 else:
3449 from .experimental_setup import IncomparableScreensError
3451 try:
3452 fig = api.animate_scanpath(
3453 words,
3454 fixations,
3455 participant,
3456 trial,
3457 screen=args.screen,
3458 **animation_options,
3459 )
3460 except IncomparableScreensError as exc:
3461 # CMP-21: the API's way out is Python. BUG-85: dropping
3462 # --animate alone lands on the default overlay, refused on
3463 # the same terms — so this names the layout flag too.
3464 raise SystemExit(
3465 f"{exc.reason} An animated comparison replays both "
3466 "scanpaths on one clock in one coordinate space, so it "
3467 "needs one screen too. Drop --animate and pass "
3468 "--compare-layout side-by-side (or stacked) to compare "
3469 "them in separate panels." + _inferred_screen_hint(args, canvas)
3470 ) from None
3471 elif args.compare_with is not None:
3472 # `is not None`, not truthiness: `--compare-with ""` is a malformed
3473 # request, and falling through here would silently render an ordinary
3474 # single-trial figure for someone who asked for a comparison.
3475 # CMP-9. Compare owns the whole figure, so it is a peer of the
3476 # animate/static branches rather than an option on one of them: the
3477 # comparison builder takes neither `--animate`'s playback settings
3478 # nor the static path's per-layer extras.
3479 from .experimental_setup import IncomparableScreensError
3481 compare_participant, compare_trial = _parse_compare_with(args.compare_with)
3482 loaded_b, loaded_fix_b, cross_dataset = _compare_second_dataset(
3483 api, args, words, fixations
3484 )
3485 raw_gaze_b = None
3486 if args.compare_raw_gaze:
3487 if not cross_dataset:
3488 raise SystemExit(
3489 "--compare-raw-gaze is the second dataset's raw gaze; pass "
3490 "--compare-words/--compare-fixations too, or use "
3491 "--raw-gaze, which covers both scanpaths of one dataset."
3492 )
3493 try:
3494 raw_gaze_b = api.load_raw_gaze(
3495 args.compare_raw_gaze, names="canonical"
3496 )
3497 except (ValueError, FileNotFoundError, OSError) as exc:
3498 raise SystemExit(
3499 "--compare-raw-gaze: "
3500 + _load_error_message(exc, schema_flags=False)
3501 )
3502 # None keeps `compare_scanpaths` on its same-dataset path, which is
3503 # what skips the namespacing.
3504 words_b = loaded_b if cross_dataset else None
3505 fixations_b = loaded_fix_b if cross_dataset else None
3506 try:
3507 fig = api.compare_scanpaths(
3508 words,
3509 fixations,
3510 (participant, trial),
3511 (compare_participant, compare_trial),
3512 # One screen per scanpath, each picked in its own trial.
3513 screen=args.screen,
3514 screen_b=args.compare_screen,
3515 words_b=words_b,
3516 fixations_b=fixations_b,
3517 dataset_b=args.compare_dataset_name,
3518 layout=args.compare_layout,
3519 compare_stimulus=args.compare_stimulus,
3520 labels=_compare_labels(args),
3521 setup=_compare_setup_snapshot(canvas),
3522 setup_b=_compare_setup_snapshot(
3523 _parse_canvas(args.compare_canvas, "--compare-canvas")
3524 ),
3525 drift_correction=args.drift_correction,
3526 # EXP-11: a builder parameter, like the drift correction
3527 # beside it, so it has to be named here — it is not in
3528 # `overrides`. Left out, a windowed comparison drew both
3529 # whole trials while the `--print-code` recipe said otherwise.
3530 fix_index_range=_parse_fix_index_range(args.fix_index_range),
3531 # CMP-24: B's own window, when given.
3532 fix_index_range_b=_parse_fix_index_range(
3533 args.compare_fix_index_range
3534 ),
3535 # VIZ-48: each reading's samples under its scanpath.
3536 raw_gaze=raw_gaze,
3537 raw_gaze_b=raw_gaze_b,
3538 **overrides,
3539 **common, # carries canvas_size / fonts / title / caption
3540 )
3541 except IncomparableScreensError as exc:
3542 # BUG-85: the API's way out is Python (`layout='side_by_side'`),
3543 # which this used to print verbatim to someone at a shell.
3544 raise SystemExit(
3545 f"{exc.reason} So no overlay was drawn; pass --compare-layout "
3546 "side-by-side (or stacked) to compare them in separate panels, "
3547 "each drawn to its own screen."
3548 + _inferred_screen_hint(args, canvas)
3549 ) from None
3550 else:
3551 static_options = dict(
3552 raw_gaze=raw_gaze,
3553 drift_correction=args.drift_correction,
3554 drift_connectors=args.drift_connectors,
3555 # VIZ-7's fixation-index window is a `plot_scanpath` parameter
3556 # rather than a figure keyword, so it is named here alongside
3557 # drift correction rather than folded into `overrides`.
3558 fix_index_range=_parse_fix_index_range(args.fix_index_range),
3559 illustration_label=args.illustration_label,
3560 **overrides,
3561 **common,
3562 )
3563 if args.all_screens:
3564 figures = api.render_parent_trial(
3565 words,
3566 fixations,
3567 participant,
3568 trial,
3569 screens=chosen_screens,
3570 **static_options,
3571 )
3572 fig = next(iter(figures.values()))
3573 else:
3574 fig = api.plot_scanpath(
3575 words,
3576 fixations,
3577 participant,
3578 trial,
3579 screen=args.screen,
3580 **static_options,
3581 )
3582 if args.all_screens:
3583 target = Path(args.output)
3584 written = []
3585 for position, (screen_id, screen_figure) in enumerate(
3586 figures.items(), start=1
3587 ):
3588 # The screen's place in its trial, which --screens can skip past.
3589 meta = screen_figure.layout.meta
3590 if isinstance(meta, dict) and meta.get("screen_index"):
3591 position = int(meta["screen_index"])
3592 safe_screen = "".join(
3593 char if char.isalnum() or char in "-_" else "_"
3594 for char in str(screen_id)
3595 )
3596 screen_path = target.with_name(
3597 f"{target.stem}__screen-{position:03d}-{safe_screen}{target.suffix}"
3598 )
3599 written.append(
3600 api.save_figure(screen_figure, screen_path, **_save_kwargs(args))
3601 )
3602 print(
3603 f"Wrote {_count(len(written), 'screen figure')}: "
3604 + ", ".join(str(path) for path in written),
3605 file=sys.stderr,
3606 )
3607 return
3608 out = api.save_figure(fig, args.output, **_save_kwargs(args))
3609 # VIZ-5: also drop a per-layer breakdown next to the output.
3610 layer_paths = None
3611 if args.separable_layers:
3612 suffix = os.path.splitext(args.output)[1].lower().lstrip(".")
3613 if args.animate or suffix not in ("svg", "pdf", "png"):
3614 print(
3615 "Warning: --separable-layers needs a static image output "
3616 "(.svg/.pdf/.png) and no --animate; skipping the layer split.",
3617 file=sys.stderr,
3618 )
3619 else:
3620 layer_dir = f"{os.path.splitext(args.output)[0]}_layers"
3621 layer_paths = api.save_figure_layers(
3622 fig,
3623 layer_dir,
3624 fmt=suffix,
3625 scale=int(args.scale),
3626 width=args.width,
3627 height=args.height,
3628 )
3629 except (ValueError, RuntimeError, OSError) as exc:
3630 raise SystemExit(str(exc))
3631 print(f"Wrote {out}", file=sys.stderr)
3632 if layer_paths:
3633 print(
3634 f"Wrote {len(layer_paths)} layer files to {os.path.splitext(args.output)[0]}"
3635 "_layers/",
3636 file=sys.stderr,
3637 )
3640def _analyze_parser() -> argparse.ArgumentParser:
3641 """The `analyze` parser — its own function so the docs' CLI reference is
3642 generated from it rather than restated (ENG-79)."""
3643 parser = _ShortErrorParser(
3644 prog="scanpath-studio analyze",
3645 description="Write fixation, saccade, word, sentence, trial, participant, "
3646 "character, and cleaning-QA tables without launching the app.",
3647 )
3648 parser.add_argument(
3649 "--words", nargs="+", required=True, help="Words table(s), as for render."
3650 )
3651 parser.add_argument(
3652 "--fixations",
3653 nargs="+",
3654 required=True,
3655 help="Fixations table(s), as for render.",
3656 )
3657 parser.add_argument(
3658 "--trial-parts-manifest",
3659 help="JSON manifest assigning source rows to ordered screens.",
3660 )
3661 parser.add_argument(
3662 "--output-dir",
3663 required=True,
3664 help="Folder for the CSV tables and run_config.json (created if missing).",
3665 )
3666 parser.add_argument(
3667 "--short-policy",
3668 choices=["off", "merge", "merge-then-discard", "discard"],
3669 default="off",
3670 help="Fixations shorter than --short-threshold-ms: merge folds each "
3671 "into its nearer neighbour within --merge-distance-chars (a short last "
3672 "fixation that cannot merge is excluded); merge-then-discard also "
3673 "excludes every other one that cannot merge; discard excludes them "
3674 "all. Excluded rows are marked, never dropped (default: off).",
3675 )
3676 parser.add_argument(
3677 "--short-threshold-ms",
3678 type=float,
3679 default=80.0,
3680 help="What counts as a short fixation, in ms (default: 80).",
3681 )
3682 parser.add_argument(
3683 "--merge-distance-chars",
3684 type=float,
3685 default=1.0,
3686 help="How close, in character widths, a neighbour must be for a short "
3687 "fixation to merge into it (default: 1.0).",
3688 )
3689 parser.add_argument(
3690 "--discard-blink-adjacent",
3691 action="store_true",
3692 help="Exclude blinks and the fixations either side of one.",
3693 )
3694 parser.add_argument(
3695 "--pixels-per-degree",
3696 type=float,
3697 help="Screen pixels per degree of visual angle; adds degree-valued "
3698 "amplitudes to the saccade table.",
3699 )
3700 _add_schema_flags(parser)
3701 return parser
3704def analyze(argv: list[str]) -> None:
3705 """Preprocess data and export the complete EXP-3 analysis family."""
3706 args = _analyze_parser().parse_args(argv)
3707 word_schema = _parse_schema_arg(args.word_schema, "--word-schema")
3708 fix_schema = _parse_schema_arg(args.fix_schema, "--fix-schema")
3710 from . import api
3712 manifest = None
3713 if args.trial_parts_manifest:
3714 try:
3715 manifest = json.loads(
3716 Path(args.trial_parts_manifest).read_text(encoding="utf-8")
3717 )
3718 except (OSError, json.JSONDecodeError) as exc:
3719 raise SystemExit(f"Could not read trial-parts manifest: {exc}") from exc
3720 try:
3721 words, fixations = api.load_scanpath_data(
3722 args.words,
3723 args.fixations,
3724 word_schema=word_schema,
3725 fix_schema=fix_schema,
3726 trial_parts_manifest=manifest,
3727 keep_columns=args.keep_columns,
3728 )
3729 except (ValueError, OSError) as exc:
3730 raise SystemExit(_load_error_message(exc)) from exc
3731 policy = {
3732 "off": "Off",
3733 "merge": "Merge",
3734 "merge-then-discard": "Merge then discard",
3735 "discard": "Discard",
3736 }[args.short_policy]
3737 words, fixations, qa = api.preprocess_data(
3738 words,
3739 fixations,
3740 enabled=policy != "Off" or args.discard_blink_adjacent,
3741 short_policy=policy,
3742 short_threshold_ms=args.short_threshold_ms,
3743 merge_distance_chars=args.merge_distance_chars,
3744 discard_blink_adjacent=args.discard_blink_adjacent,
3745 )
3746 tables = api.analysis_tables(
3747 words, fixations, pixels_per_degree=args.pixels_per_degree
3748 )
3749 # EXP-16: with preprocessing off `preprocess_data` returns an empty report,
3750 # and writing it over the family's own one left `cleaning_qa.csv` a single
3751 # newline that `pd.read_csv` refuses. The family's report is the per-trial
3752 # "nothing excluded, policy Off" table the export bundle writes, so it
3753 # stands unless preprocessing actually ran.
3754 if not qa.empty:
3755 tables["cleaning_qa"] = qa
3756 from .data import shareable_frame
3757 from .export import strip_local_paths
3759 destination = Path(args.output_dir)
3760 destination.mkdir(parents=True, exist_ok=True)
3761 # DATA-66: the tables come back under the dataset's own names (the loader's
3762 # default); `columns.json` maps the two that are the dataset's own tables
3763 # back to the internal names, as an export bundle's does.
3764 written: dict[str, list] = {}
3765 for name, table in tables.items():
3766 found = _cn.frame_names(table)
3767 if found is not None and name in ("fixations", "word_measures"):
3768 written[name] = _cn.written_columns(
3769 shareable_frame(_cn.to_canonical_frame(table)), found[1]
3770 )
3771 # The paths a stimulus image was found at are this machine's, not data.
3772 table = strip_local_paths(shareable_frame(table))
3773 table.to_csv(destination / f"{name}.csv", index=False)
3774 if any(written.values()):
3775 (destination / "columns.json").write_text(
3776 json.dumps(_cn.columns_manifest(written), indent=2), encoding="utf-8"
3777 )
3778 config = {
3779 "short_policy": policy,
3780 "short_threshold_ms": args.short_threshold_ms,
3781 "merge_distance_chars": args.merge_distance_chars,
3782 "discard_blink_adjacent": args.discard_blink_adjacent,
3783 "pixels_per_degree": args.pixels_per_degree,
3784 }
3785 (destination / "run_config.json").write_text(
3786 json.dumps(config, indent=2), encoding="utf-8"
3787 )
3788 extras = (
3789 "columns.json + run_config.json"
3790 if any(written.values())
3791 else ("run_config.json")
3792 )
3793 print(f"Wrote {len(tables)} tables + {extras} to {destination}")
3794 if "word_measures" not in tables:
3795 # AN-32 / EXP-23: measures are the dataset's own; none are computed.
3796 print(
3797 "No word_measures.csv: the words table brings no reading measures, "
3798 "and none are computed. EyeLink IA_* columns are found on their own; "
3799 "map others with --word-schema's measure_* keys, e.g. "
3800 '\'{"measure_tfd": "dwell_ms"}\'.'
3801 )
3804def _corpus_parser() -> argparse.ArgumentParser:
3805 """The `corpus` parser (see `_analyze_parser`)."""
3806 parser = _ShortErrorParser(
3807 prog="scanpath-studio corpus",
3808 description="Render a styled corpus figure from a tidy CSV you already "
3809 "have (api.plot_corpus_figure).",
3810 )
3811 parser.add_argument(
3812 "--input",
3813 required=True,
3814 metavar="PATH",
3815 help="The CSV. profile reads word_id plus the value column (and "
3816 "optional lo / hi), distribution the value column, difference word_id "
3817 "and diff.",
3818 )
3819 parser.add_argument(
3820 "--kind",
3821 choices=["profile", "distribution", "difference"],
3822 required=True,
3823 help="A per-word profile, a distribution, or a difference profile.",
3824 )
3825 parser.add_argument(
3826 "-o",
3827 "--output",
3828 required=True,
3829 metavar="PATH",
3830 help="Output file; any extension save_figure writes (.html, .png, .svg, .pdf).",
3831 )
3832 parser.add_argument(
3833 "--measure-label",
3834 default="Value",
3835 help="Axis / legend label for the value (default: Value).",
3836 )
3837 parser.add_argument(
3838 "--series-col",
3839 default="series",
3840 help="Column naming the overlaid series, when present (default: series).",
3841 )
3842 parser.add_argument(
3843 "--value-col",
3844 default="value",
3845 help="The value column (default: value).",
3846 )
3847 parser.add_argument(
3848 "--primary-color",
3849 default="#1f77b4",
3850 help="First series color (default: #1f77b4).",
3851 )
3852 parser.add_argument(
3853 "--secondary-color",
3854 default="#e45756",
3855 help="Second series color (default: #e45756).",
3856 )
3857 return parser
3860def corpus(argv: list[str]) -> None:
3861 """Render a styled corpus figure from a tidy CSV (AN-29)."""
3862 args = _corpus_parser().parse_args(argv)
3863 from . import api
3865 # EXP-13: each of these ended in a traceback — a missing or unparseable
3866 # --input, a table without the columns --kind reads, an output extension
3867 # save_figure doesn't write.
3868 try:
3869 data = pd.read_csv(args.input)
3870 except (OSError, ValueError) as exc:
3871 raise SystemExit(f"--input: could not read {args.input!r}: {exc}") from exc
3872 try:
3873 fig = api.plot_corpus_figure(
3874 data,
3875 kind=args.kind,
3876 measure_label=args.measure_label,
3877 series_col=args.series_col,
3878 value_col=args.value_col,
3879 colors=(args.primary_color, args.secondary_color),
3880 )
3881 out = api.save_figure(fig, args.output)
3882 except (ValueError, RuntimeError, OSError) as exc:
3883 raise SystemExit(str(exc)) from exc
3884 print(f"Wrote {out}")
3887def _cache_parser() -> argparse.ArgumentParser:
3888 """The `cache` parser (see `_analyze_parser`)."""
3889 parser = _ShortErrorParser(
3890 prog="scanpath-studio cache",
3891 description="Show what a local run has stored on this computer "
3892 "(uploaded datasets, mappings, view settings, saved designs, "
3893 "metadata tables, annotations), where it lives, and delete it. The hosted app stores "
3894 "nothing. Set SCANPATH_STUDIO_STATE_DIR to keep it somewhere else.",
3895 )
3896 parser.add_argument(
3897 "--path", action="store_true", help="Print the cache folder and exit."
3898 )
3899 parser.add_argument("--json", action="store_true", help="Print the status as JSON.")
3900 parser.add_argument(
3901 "--clear", action="store_true", help="Delete the recovery cache."
3902 )
3903 return parser
3906def _count(n: int, noun: str) -> str:
3907 """``"1 setting"``, ``"205 settings"``, ``"no settings"`` (#374, F38)."""
3908 if not n:
3909 return f"no {noun}s"
3910 return f"{n:,} {noun}" + ("" if n == 1 else "s")
3913def cache(argv: list[str]) -> None:
3914 """Inspect or clear the on-device recovery cache (ENG-30).
3916 The terminal counterpart of the app's 🗂️ Data Management → *Saved on this computer*
3917 section, so the storage a local run creates can be found, measured and deleted without
3918 launching the app (or after closing it).
3919 """
3920 args = _cache_parser().parse_args(argv)
3921 # `api` quiets Streamlit's bare-mode "No runtime found" cache warnings at
3922 # import, and must be imported before `persistence` pulls in `data`, whose
3923 # decorators fire them.
3924 from . import api # noqa: F401
3925 from .persistence import PERSIST_ENV_VAR, cache_status, clear_local_state
3926 from .persistence import human_size as _human_size
3928 # A local run is what this cache belongs to, so report enablement for one
3929 # (the env override still wins) rather than for the CLI process itself.
3930 status = cache_status(url="http://localhost")
3931 if args.path:
3932 print(status["directory"])
3933 return
3934 if args.clear:
3935 existed = status["exists"]
3936 clear_local_state()
3937 print(
3938 f"Cleared {status['directory']}"
3939 if existed
3940 else f"Nothing stored in {status['directory']}"
3941 )
3942 return
3943 if args.json:
3944 print(json.dumps(status, indent=2))
3945 return
3947 print(f"Folder: {status['directory']}")
3948 print(
3949 "Saving: "
3950 + ("enabled" if status["enabled"] else "disabled")
3951 + (f" ({PERSIST_ENV_VAR}={status['override']})" if status["override"] else "")
3952 + " for local runs"
3953 )
3954 if not status["exists"]:
3955 print("Stored: nothing")
3956 return
3957 if not status["readable"]:
3958 print(
3959 "Stored: unreadable (wrong schema or incomplete) — the app opens "
3960 "without it and leaves it as it is"
3961 )
3962 print(f"Size: {_human_size(status['bytes'])}")
3963 return
3964 datasets = [entry["name"] for entry in status["datasets"]]
3965 print(
3966 f"Stored: {_count(len(datasets), 'dataset')}: {', '.join(datasets)}"
3967 if datasets
3968 else "Stored: no datasets"
3969 )
3970 # rows is None for a cache written before the manifest carried row counts
3971 # (the app backfills it on its next save) — don't print a false 0.
3972 rows = (
3973 _count(status["rows"], "row") if status["rows"] is not None else "rows unknown"
3974 )
3975 print(
3976 f" {rows} · {_count(status['annotations'], 'annotated trial')} · "
3977 f"{_count(status['designs'], 'saved design')} · "
3978 # DATA-38 — the attached metadata tables, the panel's own count.
3979 f"{_count(status.get('metadata', 0), 'metadata table')} · "
3980 f"{_count(status['settings'], 'setting')}"
3981 )
3982 # A stored dataset the app cannot restore (a file gone, an entry damaged):
3983 # the app holds it back, restores the rest, and keeps it in the cache.
3984 for entry in status.get("damaged") or []:
3985 print(f"Damaged: {entry['name']} — {entry['reason']} (kept; not restored)")
3986 if status.get("damaged_metadata"):
3987 print(
3988 f"Damaged: metadata tables — {status['damaged_metadata']} "
3989 "(kept; not restored)"
3990 )
3991 print(f"Size: {_human_size(status['bytes'])}")
3992 print(f"Written: {status['saved_at']}")
3993 print("Delete with `scanpath-studio cache --clear`.")
3996def _version_parser() -> argparse.ArgumentParser:
3997 """The `version` parser (see `_analyze_parser`)."""
3998 parser = _ShortErrorParser(
3999 prog="scanpath-studio version",
4000 description="Show which build of Scanpath Studio this is and how it was "
4001 "installed. With --check, also ask GitHub whether a newer release is out "
4002 "and how to update — the only time this command uses the network.",
4003 )
4004 parser.add_argument(
4005 "--check",
4006 action="store_true",
4007 help="Ask GitHub for the latest release and say how to update this install.",
4008 )
4009 parser.add_argument(
4010 "--timeout",
4011 type=float,
4012 default=5.0,
4013 metavar="SECONDS",
4014 help="How long to wait for GitHub (default 5).",
4015 )
4016 return parser
4019def version(argv: list[str]) -> None:
4020 """Print which build this is, and with ``--check`` whether a newer release is out (#139).
4022 The terminal counterpart of Help → About and ``api.check_for_updates``.
4023 Exits 1 only when the check itself could not be made.
4024 """
4025 args = _version_parser().parse_args(argv)
4026 from .build_info import INSTALL_KINDS, build_info, install_kind
4028 info = build_info()
4029 print(f"scanpath-studio {info.version}")
4030 print(f"Build: {info.describe()}")
4031 print(f"Installed: {INSTALL_KINDS[install_kind(info)]}")
4032 if not args.check:
4033 return
4034 from .updates import check_for_updates
4036 result = check_for_updates(args.timeout)
4037 if result.status == "error":
4038 print(result.message, file=sys.stderr)
4039 raise SystemExit(1)
4040 print()
4041 print(result.message)
4042 if result.command:
4043 print(f"Update: {result.command}")
4044 if result.download is not None:
4045 print(f"Download: {result.download.url}")
4046 if result.status == "update_available" and result.latest is not None:
4047 print(f"What's new: {result.latest.url}")
4050def _check_parser() -> argparse.ArgumentParser:
4051 """The `check` parser (see `_analyze_parser`)."""
4052 parser = _ShortErrorParser(
4053 prog="scanpath-studio check",
4054 description="Run the Data Management page's Data checks on your tables without "
4055 "launching the app: fixations lasting 0 ms or less or with an infinite "
4056 "duration or onset, fixations and raw-gaze samples with no finite "
4057 "position, word boxes with no area or no finite position, and per-screen "
4058 "screen sizes that are not finite and positive. Reports what it finds "
4059 "and changes nothing; the exit status is 0 whatever it finds (an "
4060 "unreadable table is an error).",
4061 )
4062 parser.add_argument(
4063 "--sample",
4064 action="store_true",
4065 help="Check the bundled OneStop demo instead of your own tables.",
4066 )
4067 parser.add_argument(
4068 "--words", nargs="+", metavar="PATH", help="Words table(s), as for render."
4069 )
4070 parser.add_argument(
4071 "--fixations",
4072 nargs="+",
4073 metavar="PATH",
4074 help="Fixations table(s), as for render.",
4075 )
4076 parser.add_argument(
4077 "--raw-gaze",
4078 nargs="+",
4079 metavar="PATH",
4080 help="Raw (sample-level) gaze table(s), as for render.",
4081 )
4082 parser.add_argument(
4083 "--raw-gaze-schema",
4084 metavar="JSON",
4085 help="Column mapping for the --raw-gaze table, replacing auto-detection "
4086 "(same shape as --fix-schema).",
4087 )
4088 parser.add_argument(
4089 "--trial-parts-manifest",
4090 metavar="PATH",
4091 help="JSON manifest assigning source rows to ordered screens.",
4092 )
4093 parser.add_argument(
4094 "--json",
4095 action="store_true",
4096 help="Print the findings as JSON (the rows api.check_data_health returns).",
4097 )
4098 _add_schema_flags(parser)
4099 return parser
4102def _health_report(findings, counts: dict[str, int]) -> str:
4103 """The data-check findings as plain text for a terminal."""
4104 checked = ", ".join(f"{name} {n:,} rows" for name, n in counts.items())
4105 if not findings:
4106 return f"Data checks: every check passed ({checked})."
4107 lines = [f"Data checks: {_count(len(findings), 'finding')} ({checked})."]
4108 for f in findings:
4109 trials = f" in {_count(f.trials, 'trial')}" if f.trials else ""
4110 label = "note" if f.severity == "note" else "warning"
4111 lines += [
4112 "",
4113 f"[{label}] {f.title} — {f.table}.{'/'.join(f.columns)}: "
4114 f"{f.rows:,} of {f.total_rows:,} rows{trials}",
4115 ]
4116 if f.breakdown:
4117 kinds = ", ".join(f"{n:,} {k}" for k, n in f.breakdown.items())
4118 lines.append(f" by kind: {kinds}")
4119 for example in f.examples:
4120 row = ", ".join(f"{k}={v}" for k, v in example.items())
4121 lines.append(f" e.g. {row}")
4122 lines.append(f" in the app: {f.consequence}")
4123 return "\n".join(lines)
4126def check(argv: list[str]) -> None:
4127 """Run the data-health checks on loaded tables (the Data page's *Data checks*).
4129 Loads the tables the way ``render`` / ``analyze`` do and prints
4130 ``api.check_data_health``'s findings. Findings are information, not a
4131 failure: nothing is dropped from the tables, so the exit status stays 0.
4132 """
4133 args = _check_parser().parse_args(argv)
4134 if args.sample and (args.words or args.fixations or args.raw_gaze):
4135 raise SystemExit("Pass --sample or your own tables, not both.")
4136 if not (args.sample or args.words or args.fixations or args.raw_gaze):
4137 raise SystemExit(
4138 "Nothing to check: pass --words and/or --fixations (and/or "
4139 "--raw-gaze), or --sample."
4140 )
4141 if args.raw_gaze_schema and not args.raw_gaze:
4142 raise SystemExit(
4143 "--raw-gaze-schema maps the --raw-gaze table; pass --raw-gaze too."
4144 )
4145 word_schema = _parse_schema_arg(args.word_schema, "--word-schema")
4146 fix_schema = _parse_schema_arg(args.fix_schema, "--fix-schema")
4147 raw_gaze_schema = _parse_schema_arg(args.raw_gaze_schema, "--raw-gaze-schema")
4149 from . import api
4151 words = fixations = raw_gaze = None
4152 if args.sample:
4153 words, fixations = api.load_sample_data()
4154 elif args.words or args.fixations:
4155 manifest = None
4156 if args.trial_parts_manifest:
4157 try:
4158 manifest = json.loads(
4159 Path(args.trial_parts_manifest).read_text(encoding="utf-8")
4160 )
4161 except (OSError, json.JSONDecodeError) as exc:
4162 raise SystemExit(f"Could not read trial-parts manifest: {exc}") from exc
4163 try:
4164 words, fixations = api.load_scanpath_data(
4165 args.words,
4166 args.fixations,
4167 word_schema=word_schema,
4168 fix_schema=fix_schema,
4169 trial_parts_manifest=manifest,
4170 keep_columns=args.keep_columns,
4171 )
4172 except (ValueError, OSError) as exc:
4173 raise SystemExit(_load_error_message(exc)) from exc
4174 # A table that was not given loads as an empty canonical frame; it was
4175 # not checked, so it is not reported as checked.
4176 if not args.words:
4177 words = None
4178 if not args.fixations:
4179 fixations = None
4180 if args.raw_gaze:
4181 try:
4182 raw_gaze = api.load_raw_gaze(args.raw_gaze, raw_gaze_schema=raw_gaze_schema)
4183 except (ValueError, OSError) as exc:
4184 raise SystemExit("--raw-gaze: " + _load_error_message(exc)) from exc
4186 findings = api._health_findings(words, fixations, raw_gaze)
4187 if args.json:
4188 from .data_health import findings_frame
4190 records = findings_frame(findings).to_dict("records")
4191 print(json.dumps(records, indent=2, default=str))
4192 return
4193 counts = {
4194 name: len(frame)
4195 for name, frame in (
4196 ("words", words),
4197 ("fixations", fixations),
4198 ("raw_gaze", raw_gaze),
4199 )
4200 if frame is not None
4201 }
4202 print(_health_report(findings, counts))
4205_HELP = f"""scanpath-studio {__version__} — visualize eye-tracking-while-reading scanpaths
4207usage:
4208 scanpath-studio launch the interactive app (Streamlit)
4209 scanpath-studio run [args…] same, forwarding args to `streamlit run`
4210 (its --help lists Streamlit's options only)
4211 scanpath-studio [run] --no-persist
4212 launch without the on-device recovery cache
4213 (this run only; see `cache` below)
4214 scanpath-studio [run] --download-dir DIR
4215 where Download saves public datasets when
4216 the Data Management page's Download folder is blank
4217 scanpath-studio render … render one trial to .html/.png/.svg/.pdf
4218 (see `scanpath-studio render --help`)
4219 scanpath-studio corpus … render a styled corpus-analysis figure
4220 scanpath-studio check … run the Data checks on your tables
4221 scanpath-studio cache … show / clear the on-device recovery cache
4222 scanpath-studio version [--check]
4223 show this build and how it was installed;
4224 --check asks GitHub whether a newer
4225 release is out
4226 scanpath-studio --version print the version
4228Unrecognized flags are forwarded to `streamlit run` (e.g.
4229`scanpath-studio --server.port 8502`); an unknown command word is an error.
4230The app listens on this computer only; `--server.address 0.0.0.0` serves it
4231on your network (it has no login) with local folder access off, unless
4232SCANPATH_LOCAL_FS=1."""
4235#: The subcommands `main` dispatches, for the did-you-mean below.
4236_COMMANDS = ("run", "render", "analyze", "corpus", "check", "cache", "version")
4239def _commands() -> tuple[str, ...]:
4240 """The commands this build offers: `analyze` is held back with the other
4241 computed measures (`constants.computed_measures_enabled`)."""
4242 from .constants import computed_measures_enabled
4244 if computed_measures_enabled():
4245 return _COMMANDS
4246 return tuple(c for c in _COMMANDS if c != "analyze")
4249def _help_text() -> str:
4250 from .constants import computed_measures_enabled
4252 if not computed_measures_enabled():
4253 return _HELP
4254 return _HELP.replace(
4255 " scanpath-studio corpus …",
4256 " scanpath-studio analyze … export preprocessing + the full measure "
4257 "family\n scanpath-studio corpus …",
4258 )
4261def _refuse_unknown_command(word: str) -> None:
4262 """ENG-54: a mistyped subcommand is an error, not a Streamlit argument.
4264 Everything unrecognized is forwarded to ``streamlit run`` so bare Streamlit
4265 flags keep working — but a bare *word* was forwarded too, so
4266 ``scanpath-studio rendr --sample`` reached Streamlit as a script argument
4267 and died on "No such option: --sample" (or, with no flags, quietly launched
4268 the app). Only a word is refused: a leading ``-`` is a Streamlit flag, and a
4269 ``.py`` path is left to Streamlit as before."""
4270 import difflib
4272 close = difflib.get_close_matches(word, _commands(), n=1, cutoff=0.6)
4273 hint = f" — did you mean {close[0]!r}?" if close else "."
4274 raise SystemExit(
4275 f"scanpath-studio: unknown command {word!r}{hint} Commands: "
4276 f"{', '.join(_commands())}; `scanpath-studio --help` lists them. "
4277 "Streamlit flags (starting with --) still launch the app."
4278 )
4281#: Flags of ours that `launch_app` takes before the Streamlit ones.
4282_LAUNCH_FLAGS = ("--no-persist", "--download-dir")
4285def _refuse_misplaced_options(argv: list[str]) -> None:
4286 """#374 F21: ``scanpath-studio --sample render …`` (or a ``render`` flag
4287 with no command) used to reach Streamlit and die on "No such option:
4288 --sample". Say where the options go instead. A Streamlit flag
4289 (``--server.port``) or one of ours is left alone."""
4290 command = next((word for word in argv if word in _commands()), None)
4291 if command is None:
4292 flag = argv[0].split("=")[0]
4293 if "." in flag or flag in _LAUNCH_FLAGS:
4294 return
4295 known = {
4296 option
4297 for action in _render_parser()._actions
4298 for option in action.option_strings
4299 }
4300 if flag not in known:
4301 return
4302 command = "render"
4303 rest = [word for word in argv if word != command]
4304 raise SystemExit(
4305 "scanpath-studio: put options after the command: "
4306 f"scanpath-studio {command} {shlex.join(rest)}"
4307 )
4310def main(argv: list[str] | None = None) -> None:
4311 argv = list(argv) if argv is not None else sys.argv[1:]
4312 if (
4313 argv
4314 and argv[0].startswith("-")
4315 and argv[0]
4316 not in (
4317 "-h",
4318 "--help",
4319 "-V",
4320 "--version",
4321 )
4322 ):
4323 _refuse_misplaced_options(argv)
4324 if not argv:
4325 launch_app([])
4326 elif argv[0] == "run":
4327 launch_app(argv[1:])
4328 elif argv[0] == "render":
4329 render(argv[1:])
4330 elif argv[0] == "analyze":
4331 from .constants import EXPERIMENTAL_ENV_VAR, computed_measures_enabled
4333 if not computed_measures_enabled():
4334 raise SystemExit(
4335 "scanpath-studio: `analyze` is not available in this release: the "
4336 "tables it writes are computed by Scanpath Studio and have not "
4337 f"been validated yet. Set {EXPERIMENTAL_ENV_VAR}=1 to use it anyway."
4338 )
4339 analyze(argv[1:])
4340 elif argv[0] == "corpus":
4341 corpus(argv[1:])
4342 elif argv[0] == "check":
4343 check(argv[1:])
4344 elif argv[0] == "cache":
4345 cache(argv[1:])
4346 elif argv[0] == "version":
4347 version(argv[1:])
4348 elif argv[0] in ("-h", "--help"):
4349 print(_help_text())
4350 elif argv[0] in ("-V", "--version"):
4351 print(__version__)
4352 elif not argv[0].startswith("-") and not argv[0].endswith(".py"):
4353 _refuse_unknown_command(argv[0])
4354 else:
4355 # Backward compatibility: bare streamlit flags launch the app.
4356 launch_app(argv)
4359if __name__ == "__main__":
4360 main()