Coverage for scanpath_studio/model_scanpaths.py: 97%
94 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"""Synthetic "model-generated" scanpaths over a real text's word boxes.
3This is **placeholder data** for the *Multiple Comparison* tab. The intended
4end state is: for a given participant reading a given text, several scanpath
5*models* each predict a scanpath, and the tab shows the real reading alongside
6each model's prediction plus similarity scores. Until those real model outputs
7are connected, this module fabricates a reading-like scanpath per "model" so the
8whole tab — grid layout, similarity table — can be built and demoed.
10Design goals:
12- **Reading-like, not uniform noise.** Each scanpath is a left-to-right walk
13 over the word boxes with model-specific skip / regression / refixation rates,
14 so the panels actually look like scanpaths and their NLD vs. the real reading
15 spreads out across models.
16- **Reproducible.** A scanpath is deterministic in ``(trial_id, model_index,
17 nonce)``. Streamlit reruns the whole script on every interaction, so a
18 non-seeded generator would reshuffle every panel on each click. The tab's
19 "Regenerate" button bumps the ``nonce`` to deliberately re-draw.
20- **Canonical output.** Frames carry the same columns
21 :func:`scanpath_studio.data.normalize_fixations` produces, so they drop
22 straight into :func:`scanpath_studio.plots.make_scanpath_figure` and the
23 similarity metrics without special-casing.
24"""
26from __future__ import annotations
28import hashlib
29from dataclasses import dataclass
31import numpy as np
32import pandas as pd
34from .measures import cluster_word_lines, word_box_bounds
37@dataclass(frozen=True)
38class ModelProfile:
39 """Behaviour knobs for one synthetic model.
41 ``skip_prob`` / ``regress_prob`` / ``refix_prob`` are per-step probabilities
42 of skipping the next word, regressing to an earlier word, or refixating the
43 current word. ``mean_dur_ms`` / ``dur_sd_ms`` shape fixation durations;
44 ``jitter_frac`` is the Gaussian landing-position jitter as a fraction of the
45 word-box size.
46 """
48 name: str
49 skip_prob: float
50 regress_prob: float
51 refix_prob: float
52 mean_dur_ms: float
53 dur_sd_ms: float
54 jitter_frac: float
57# Ordered from most reading-like (Model 1: few skips/regressions, durations near
58# typical reading) to progressively noisier. The tab slices this to the
59# requested model count. Tuned so the panels look visibly different and the NLD
60# vs. the real scanpath spreads across a useful range.
61MODEL_PROFILES = [
62 ModelProfile("Model 1", 0.08, 0.06, 0.10, 230, 60, 0.12),
63 ModelProfile("Model 2", 0.15, 0.10, 0.12, 215, 70, 0.16),
64 ModelProfile("Model 3", 0.22, 0.14, 0.10, 250, 80, 0.20),
65 ModelProfile("Model 4", 0.30, 0.05, 0.18, 200, 65, 0.18),
66 ModelProfile("Model 5", 0.18, 0.22, 0.14, 240, 90, 0.24),
67 ModelProfile("Model 6", 0.35, 0.18, 0.20, 190, 75, 0.26),
68 ModelProfile("Model 7", 0.12, 0.30, 0.16, 260, 85, 0.22),
69 ModelProfile("Model 8", 0.45, 0.25, 0.22, 175, 70, 0.30),
70]
72MAX_MODELS = len(MODEL_PROFILES)
73DEFAULT_N_MODELS = 6
75# Inter-fixation gap folded into timestamps, approximating saccade + planning
76# time so the synthetic real-time clock reads plausibly in the animation/metrics.
77SACCADE_GAP_MS = 25
79# Fixation durations are clamped to a plausible reading band (ms).
80_MIN_DUR_MS = 50
81_MAX_DUR_MS = 800
83# Canonical fixation columns produced by data.normalize_fixations that the
84# figure builder and metrics rely on. Kept here so an empty frame still has the
85# right shape.
86_FIX_COLUMNS = [
87 "participant_id",
88 "trial_id",
89 "text_id",
90 "x",
91 "y",
92 "duration_ms",
93 "timestamp_ms",
94 "fixation_id",
95 "word_id",
96 "pass_index",
97 "order_in_trial",
98 "eye",
99 "saccade_type",
100]
103def _seed(trial_id: object, model_index: int, nonce: int) -> int:
104 """Stable 32-bit seed from (trial, model, nonce).
106 Uses md5 rather than the builtin ``hash`` because ``hash`` of a str is salted
107 per process, which would make scanpaths non-reproducible across reruns.
108 """
109 raw = f"{trial_id}|{model_index}|{nonce}".encode()
110 return int(hashlib.md5(raw).hexdigest()[:8], 16)
113def _empty_fix_frame() -> pd.DataFrame:
114 return pd.DataFrame({col: [] for col in _FIX_COLUMNS})
117def _ordered_word_rows(words: pd.DataFrame) -> pd.DataFrame:
118 """Words in reading order: by ``word_id`` when usable, else line-then-x.
120 ``word_id`` is reading order in OneStop. When it's missing or has gaps we
121 fall back to clustering rows into visual lines by ``y`` (tolerance ~half a
122 word height) and reading each line left-to-right.
123 """
124 if "word_id" in words.columns and words["word_id"].notna().all():
125 return words.sort_values("word_id").reset_index(drop=True)
126 # Cluster rows into visual lines by vertical position (shared with the
127 # reading-measure geometry), then read each line left-to-right. NaN-safe
128 # line pitch and the all-NaN-height guard live in ``cluster_word_lines``.
129 w = words.copy()
130 w["_line"] = cluster_word_lines(w)
131 return w.sort_values(["_line", "x"]).drop(columns="_line").reset_index(drop=True)
134def _walk_word_indices(n_words: int, profile: ModelProfile, rng) -> list[int]:
135 """Random reading-like walk producing a list of fixated word positions.
137 Positions index into the reading-ordered word list (0..n_words-1). Advances
138 one word by default; with the profile's probabilities it skips ahead two,
139 regresses one or two words, or refixates the current word. Bounded by a cap
140 so heavy refixation/regression can't loop forever.
141 """
142 if n_words <= 0:
143 return [0]
144 seq: list[int] = []
145 pos = 0
146 cap = max(8, n_words * 3)
147 consecutive_refix = 0
148 while pos < n_words and len(seq) < cap:
149 seq.append(pos)
150 # Refixation: stay on the same word (bounded run so we always progress).
151 if rng.random() < profile.refix_prob and consecutive_refix < 2:
152 consecutive_refix += 1
153 continue
154 consecutive_refix = 0
155 roll = rng.random()
156 if roll < profile.regress_prob and pos > 0:
157 pos = max(0, pos - int(rng.integers(1, 3)))
158 elif roll < profile.regress_prob + profile.skip_prob:
159 pos += 2
160 else:
161 pos += 1
162 return seq or [0]
165def generate_model_scanpath(
166 words: pd.DataFrame,
167 profile: ModelProfile,
168 *,
169 model_index: int,
170 reference_trial_id: object,
171 text_id: object | None = None,
172 nonce: int = 0,
173) -> pd.DataFrame:
174 """One model's synthetic scanpath over ``words``, in canonical fixation form.
176 Deterministic in ``(reference_trial_id, model_index, nonce)``.
177 """
178 if words is None or words.empty:
179 return _empty_fix_frame()
181 rng = np.random.default_rng(_seed(reference_trial_id, model_index, nonce))
182 ordered = _ordered_word_rows(words)
183 # Drop words whose box geometry failed to export (NaN x/y/width/height) so
184 # generated fixations always land at finite coordinates — np.maximum(nan, 1)
185 # is nan, so the jitter floor wouldn't otherwise sanitize them.
186 finite_geom = (
187 pd.to_numeric(ordered["x"], errors="coerce").notna()
188 & pd.to_numeric(ordered["y"], errors="coerce").notna()
189 & pd.to_numeric(ordered["width"], errors="coerce").notna()
190 & pd.to_numeric(ordered["height"], errors="coerce").notna()
191 )
192 ordered = ordered[finite_geom.to_numpy()].reset_index(drop=True)
193 if ordered.empty:
194 return _empty_fix_frame()
195 n_words = len(ordered)
197 # Generate inside the experiment's own boxes (BUG-83), so a model fixation
198 # is assigned to the word it was generated for.
199 x_box, y_box, x_right, y_bottom = word_box_bounds(ordered)
200 w_box = x_right - x_box
201 h_box = y_bottom - y_box
202 if "text" in ordered.columns:
203 text_len = ordered["text"].astype(str).str.len().to_numpy(dtype=float)
204 else:
205 text_len = np.full(n_words, 5.0)
206 word_ids = (
207 ordered["word_id"].to_numpy()
208 if "word_id" in ordered.columns
209 else np.full(n_words, np.nan)
210 )
212 seq = _walk_word_indices(n_words, profile, rng)
213 seq_idx = np.asarray(seq, dtype=int)
214 k = len(seq_idx)
216 # Landing position: word-box centre + Gaussian jitter. Vertical jitter is
217 # scaled down (box height encodes the line pitch, ~3 text lines in OneStop)
218 # so a fixation stays near its own line rather than drifting onto neighbours.
219 cx = x_box[seq_idx] + w_box[seq_idx] / 2.0
220 cy = y_box[seq_idx] + h_box[seq_idx] / 2.0
221 jitter_x = rng.normal(0.0, profile.jitter_frac * np.maximum(w_box[seq_idx], 1.0))
222 jitter_y = rng.normal(
223 0.0, profile.jitter_frac * np.maximum(h_box[seq_idx], 1.0) / 3.0
224 )
225 fix_x = cx + jitter_x
226 fix_y = cy + jitter_y
228 # Duration: profile mean ± sd, nudged longer for longer words, then clamped.
229 mean_len = float(text_len.mean()) if text_len.size else 5.0
230 durations = rng.normal(profile.mean_dur_ms, profile.dur_sd_ms, size=k)
231 durations = durations + (text_len[seq_idx] - mean_len) * 4.0
232 durations = np.clip(durations, _MIN_DUR_MS, _MAX_DUR_MS).round().astype(int)
234 # Cumulative timestamps with a saccade gap between fixations.
235 timestamps = np.empty(k, dtype=int)
236 acc = 0
237 for i in range(k):
238 timestamps[i] = acc
239 acc += int(durations[i]) + SACCADE_GAP_MS
241 para = text_id if text_id is not None else reference_trial_id
242 return pd.DataFrame(
243 {
244 "participant_id": [profile.name] * k,
245 "trial_id": [str(reference_trial_id)] * k,
246 "text_id": [str(para)] * k,
247 "x": fix_x,
248 "y": fix_y,
249 "duration_ms": durations,
250 "timestamp_ms": timestamps,
251 "fixation_id": np.arange(1, k + 1),
252 "word_id": word_ids[seq_idx],
253 "pass_index": [1] * k,
254 "order_in_trial": np.arange(1, k + 1),
255 "eye": ["Both"] * k,
256 "saccade_type": ["unknown"] * k,
257 }
258 )
261def generate_model_scanpaths(
262 words: pd.DataFrame,
263 *,
264 n_models: int = DEFAULT_N_MODELS,
265 reference_trial_id: object,
266 text_id: object | None = None,
267 nonce: int = 0,
268) -> dict[str, pd.DataFrame]:
269 """Generate ``n_models`` synthetic scanpaths over ``words``.
271 Returns an insertion-ordered ``{model_name: fixations_df}`` (Python dicts
272 preserve order), so the tab grid and the similarity table list models in the
273 same Model 1..N order.
274 """
275 count = max(1, min(int(n_models), MAX_MODELS))
276 profiles = MODEL_PROFILES[:count]
277 return {
278 profile.name: generate_model_scanpath(
279 words,
280 profile,
281 model_index=index,
282 reference_trial_id=reference_trial_id,
283 text_id=text_id,
284 nonce=nonce,
285 )
286 for index, profile in enumerate(profiles)
287 }