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

1"""Detection policy for schematic/altered scanpath labels (VIZ-22).""" 

2 

3from __future__ import annotations 

4 

5import math 

6from collections.abc import Sequence 

7 

8from .constants import AUTHOR_CHOICE, SYNTHETIC_CHOICE 

9 

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" 

13 

14 

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. 

27 

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. 

32 

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 

74 

75 

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)