Coverage for scanpath_studio/illustration.py: 94%
36 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"""Detection policy for schematic/altered scanpath labels (VIZ-22)."""
3from __future__ import annotations
5import math
6from collections.abc import Sequence
8from .constants import AUTHOR_CHOICE, SYNTHETIC_CHOICE
10#: The reason recorded when *Show* labels a figure nothing was detected on; the
11#: figure draws "Illustration" alone for it (#374).
12MANUAL_LABEL_REASON = "manual label"
15def illustration_reasons(
16 settings: dict,
17 *,
18 data_source: str | None = None,
19 fix_index_range: Sequence[int] | None = None,
20 full_fixation_range: Sequence[int] | None = None,
21 synthetic: bool = False,
22 fixation_flags_b: dict | None = None,
23 fix_index_range_b: Sequence[int] | None = None,
24 full_fixation_range_b: Sequence[int] | None = None,
25) -> list[str]:
26 """Return visible, substantive transformations; ignore cosmetic styling.
28 CMP-24: in Compare, scanpath B has filters of its own —
29 ``fixation_flags_b`` and a window ``fix_index_range_b`` against B's
30 ``full_fixation_range_b`` — and either one alters the figure as much as A's
31 does, so it discloses the same way.
33 VIZ-45: raw gaze is never a reason. The samples are drawn as recorded —
34 nothing in the app derives fixations (or anything else) from them — so a
35 figure of raw gaze alone is the least transformed figure there is, and the
36 "derived from raw gaze" reason it used to carry named a derivation that
37 never happened."""
38 reasons: list[str] = []
39 if settings.get("fixation_snap_to_word"):
40 reasons.append("fixations snapped to words")
41 if settings.get("saccade_render_mode") == "Arc":
42 reasons.append("schematic saccade arcs")
43 algorithm = settings.get("align_algorithm", "Off")
44 if algorithm and algorithm != "Off":
45 reasons.append(f"drift correction: {algorithm}")
46 flag_sets = [settings.get("fixation_flags") or {}, fixation_flags_b or {}]
47 if any(
48 (value or {}).get("mode") == "Discard"
49 for flags in flag_sets
50 for value in flags.values()
51 ):
52 reasons.append("flagged fixations hidden")
53 windows = [
54 (fix_index_range, full_fixation_range),
55 (fix_index_range_b, full_fixation_range_b),
56 ]
57 if any(
58 window is not None and full is not None and tuple(window) != tuple(full)
59 for window, full in windows
60 ):
61 reasons.append("fixation subset")
62 # #374: the app's own made-up sources only, never a user's dataset whose
63 # name happens to contain "synthetic" or "author".
64 if synthetic or data_source in (SYNTHETIC_CHOICE, AUTHOR_CHOICE):
65 reasons.append("synthetic source")
66 playback_speed = settings.get("playback_speed", 1.0)
67 try:
68 playback_speed = float(playback_speed)
69 except (TypeError, ValueError):
70 playback_speed = 1.0
71 if not math.isclose(playback_speed, 1.0):
72 reasons.append(f"playback speed ×{playback_speed:g}")
73 return reasons
76def resolve_label_reasons(mode: str, reasons: Sequence[str]) -> list[str]:
77 """Apply the Auto / Show / Hide manual override contract."""
78 if mode == "Hide":
79 return []
80 if mode == "Show" and not reasons:
81 return [MANUAL_LABEL_REASON]
82 return list(reasons)