speed-analyzer 3.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ # src/speed_analyzer/__init__.py
2
+ import pandas as pd
3
+ from pathlib import Path
4
+ import json
5
+ import shutil
6
+ import logging
7
+ from typing import Optional, Dict
8
+
9
+ # Importa i moduli di analisi dalla sottocartella
10
+ from .analysis_modules import speed_script_events
11
+ from .analysis_modules import yolo_analyzer
12
+ from .analysis_modules import video_generator
13
+
14
+ # Esporta la funzione principale per renderla accessibile con "from speed_analyzer import run_full_analysis"
15
+ __all__ = ["run_full_analysis"]
16
+
17
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
18
+
19
+ def _prepare_working_directory(output_dir: Path, raw_dir: Path, unenriched_dir: Path, enriched_dir: Optional[Path], events_df: pd.DataFrame):
20
+ working_dir = output_dir / 'eyetracking_files'
21
+ working_dir.mkdir(parents=True, exist_ok=True)
22
+ logging.info(f"Preparing working directory at: {working_dir}")
23
+ try:
24
+ external_video_path = next(unenriched_dir.glob('*.mp4'))
25
+ except StopIteration:
26
+ raise FileNotFoundError(f"No .mp4 file found in {unenriched_dir}")
27
+ file_map = {
28
+ 'internal.mp4': raw_dir / 'Neon Sensor Module v1 ps1.mp4',
29
+ 'external.mp4': external_video_path,
30
+ 'fixations.csv': unenriched_dir / 'fixations.csv',
31
+ 'gaze.csv': unenriched_dir / 'gaze.csv',
32
+ 'blinks.csv': unenriched_dir / 'blinks.csv',
33
+ 'saccades.csv': unenriched_dir / 'saccades.csv',
34
+ '3d_eye_states.csv': unenriched_dir / '3d_eye_states.csv',
35
+ 'world_timestamps.csv': unenriched_dir / 'world_timestamps.csv',
36
+ }
37
+ if enriched_dir:
38
+ file_map.update({
39
+ 'surface_positions.csv': enriched_dir / 'surface_positions.csv',
40
+ 'gaze_enriched.csv': enriched_dir / 'gaze.csv',
41
+ 'fixations_enriched.csv': enriched_dir / 'fixations.csv',
42
+ })
43
+ for dest, source in file_map.items():
44
+ if source and source.exists():
45
+ shutil.copy(source, working_dir / dest)
46
+ else:
47
+ logging.warning(f"Optional file not found and not copied: {source}")
48
+ if not events_df.empty:
49
+ events_df.to_csv(working_dir / 'events.csv', index=False)
50
+ return working_dir
51
+
52
+ def run_full_analysis(
53
+ raw_data_path: str, unenriched_data_path: str, output_path: str, subject_name: str,
54
+ enriched_data_path: Optional[str] = None, events_df: Optional[pd.DataFrame] = None,
55
+ run_yolo: bool = False, yolo_model_path: str = 'yolov8n.pt',
56
+ generate_plots: bool = True, plot_selections: Optional[Dict[str, bool]] = None,
57
+ generate_video: bool = True, video_options: Optional[Dict] = None
58
+ ) -> Path:
59
+ raw_dir = Path(raw_data_path)
60
+ unenriched_dir = Path(unenriched_data_path)
61
+ output_dir = Path(output_path)
62
+ enriched_dir = Path(enriched_data_path) if enriched_data_path else None
63
+ output_dir.mkdir(parents=True, exist_ok=True)
64
+ un_enriched_mode = enriched_dir is None
65
+
66
+ if events_df is None:
67
+ logging.info("No events DataFrame provided, loading 'events.csv' from un-enriched folder.")
68
+ events_file = unenriched_dir / 'events.csv'
69
+ events_df = pd.read_csv(events_file) if events_file.exists() else pd.DataFrame()
70
+
71
+ working_dir = _prepare_working_directory(output_dir, raw_dir, unenriched_dir, enriched_dir, events_df)
72
+ selected_event_names = events_df['name'].tolist() if not events_df.empty else []
73
+
74
+ logging.info(f"--- STARTING CORE ANALYSIS FOR {subject_name} ---")
75
+ speed_script_events.run_analysis(
76
+ subj_name=subject_name, data_dir_str=str(working_dir), output_dir_str=str(output_dir),
77
+ un_enriched_mode=un_enriched_mode, selected_events=selected_event_names
78
+ )
79
+ logging.info("--- CORE ANALYSIS COMPLETE ---")
80
+
81
+ if run_yolo:
82
+ logging.info("--- STARTING YOLO ANALYSIS ---")
83
+ yolo_analyzer.run_yolo_analysis(
84
+ data_dir=working_dir, output_dir=output_dir, subj_name=subject_name, model_path=yolo_model_path
85
+ )
86
+ logging.info("--- YOLO ANALYSIS COMPLETE ---")
87
+
88
+ if generate_plots:
89
+ logging.info("--- STARTING PLOT GENERATION ---")
90
+ if plot_selections is None:
91
+ plot_selections = { "path_plots": True, "heatmaps": True, "histograms": True, "pupillometry": True, "advanced_timeseries": True, "fragmentation": True }
92
+ config = {"unenriched_mode": un_enriched_mode, "source_folders": {"unenriched": str(unenriched_dir)}}
93
+ with open(output_dir / 'config.json', 'w') as f: json.dump(config, f)
94
+ speed_script_events.generate_plots_on_demand(
95
+ output_dir_str=str(output_dir), subj_name=subject_name,
96
+ plot_selections=plot_selections, un_enriched_mode=un_enriched_mode
97
+ )
98
+ logging.info("--- PLOT GENERATION COMPLETE ---")
99
+
100
+ if generate_video:
101
+ logging.info("--- STARTING VIDEO GENERATION ---")
102
+ if video_options is None:
103
+ video_options = { "output_filename": f"video_output_{subject_name}.mp4", "overlay_gaze": True, "overlay_event_text": True }
104
+ video_generator.create_custom_video(
105
+ data_dir=working_dir, output_dir=output_dir, subj_name=subject_name,
106
+ options=video_options, un_enriched_mode=un_enriched_mode,
107
+ selected_events=selected_event_names
108
+ )
109
+ logging.info("--- VIDEO GENERATION COMPLETE ---")
110
+
111
+ logging.info(f"Analysis complete. Results saved in: {output_dir.resolve()}")
112
+ return output_dir
@@ -0,0 +1,494 @@
1
+ # speed_script_events.py
2
+ import pandas as pd
3
+ import numpy as np
4
+ import matplotlib.pyplot as plt
5
+ import matplotlib.patches as mpatches
6
+ import cv2
7
+ from pathlib import Path
8
+ import traceback
9
+ import pickle
10
+ from scipy.signal import welch, spectrogram
11
+ from scipy.stats import gaussian_kde
12
+ import gc
13
+ import json
14
+ import logging
15
+ from concurrent.futures import ProcessPoolExecutor, as_completed
16
+ from functools import partial
17
+ #------------------------------------------------
18
+
19
+ # --- Constants ---
20
+ SAMPLING_FREQ = 200 # Hz
21
+ NS_TO_S = 1e9
22
+ PLOT_DPI = 25
23
+
24
+ # ==============================================================================
25
+ # HELPER AND DATA LOADING FUNCTIONS
26
+ # ==============================================================================
27
+
28
+ def euclidean_distance(x1, y1, x2, y2):
29
+ """Calculates the Euclidean distance between two points."""
30
+ return np.sqrt((x2 - x1)**2 + (y2 - y1)**2)
31
+
32
+ def load_all_data(data_dir: Path, un_enriched_mode: bool):
33
+ """Loads all necessary CSV files from the data directory."""
34
+ files_to_load = {
35
+ 'events': 'events.csv', 'fixations_not_enr': 'fixations.csv', 'gaze_not_enr': 'gaze.csv',
36
+ 'pupil': '3d_eye_states.csv', 'blinks': 'blinks.csv', 'saccades': 'saccades.csv'
37
+ }
38
+ if not un_enriched_mode:
39
+ files_to_load.update({'gaze_enr': 'gaze_enriched.csv', 'fixations_enr': 'fixations_enriched.csv'})
40
+
41
+ dataframes = {}
42
+ for name, filename in files_to_load.items():
43
+ try:
44
+ dataframes[name] = pd.read_csv(data_dir / filename)
45
+ except FileNotFoundError:
46
+ if name in ['gaze_enr', 'fixations_enr']:
47
+ dataframes[name] = pd.DataFrame()
48
+ else:
49
+ raise FileNotFoundError(f"Required data file not found: {filename} in {data_dir}")
50
+ return dataframes
51
+
52
+ def get_timestamp_col(df):
53
+ """Gets the correct timestamp column from a dataframe."""
54
+ for col in ['start timestamp [ns]', 'timestamp [ns]']:
55
+ if col in df.columns:
56
+ return col
57
+ return None
58
+
59
+ def filter_data_by_segment(all_data, start_ts, end_ts, rec_id, is_last=False):
60
+ """Filters all dataframes for a specific time segment."""
61
+ segment_data = {}
62
+ for name, df in all_data.items():
63
+ if df.empty or name == 'events':
64
+ segment_data[name] = df
65
+ continue
66
+ ts_col = get_timestamp_col(df)
67
+ if ts_col:
68
+ if is_last:
69
+ mask = (df[ts_col] >= start_ts) & (df[ts_col] <= end_ts)
70
+ else:
71
+ mask = (df[ts_col] >= start_ts) & (df[ts_col] < end_ts)
72
+
73
+ if 'recording id' in df.columns and rec_id is not None:
74
+ mask &= (df['recording id'] == rec_id)
75
+ segment_data[name] = df[mask].copy().reset_index(drop=True)
76
+ else:
77
+ segment_data[name] = pd.DataFrame(columns=df.columns)
78
+ return segment_data
79
+
80
+ def get_video_dimensions(video_path: Path):
81
+ """Gets width and height from a video file."""
82
+ if not video_path.exists():
83
+ logging.warning(f"Video file not found at {video_path}. Cannot get dimensions.")
84
+ return None, None
85
+ cap = cv2.VideoCapture(str(video_path))
86
+ if not cap.isOpened():
87
+ logging.warning(f"Could not open video file {video_path}.")
88
+ return None, None
89
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
90
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
91
+ cap.release()
92
+ return width, height
93
+
94
+ # ==============================================================================
95
+ # CORE DATA ANALYSIS FUNCTIONS
96
+ # ==============================================================================
97
+
98
+ def calculate_summary_features(data, subj_name, event_name, un_enriched_mode: bool, video_width: int, video_height: int):
99
+ """Calculates a dictionary of summary features for a segment."""
100
+ pupil, blinks, saccades = data.get('pupil', pd.DataFrame()), data.get('blinks', pd.DataFrame()), data.get('saccades', pd.DataFrame())
101
+ fixations_enr, fixations_not_enr = data.get('fixations_enr', pd.DataFrame()), data.get('fixations_not_enr', pd.DataFrame())
102
+ gaze_not_enr = data.get('gaze_not_enr', pd.DataFrame())
103
+
104
+ results = {'participant': subj_name, 'event': event_name}
105
+
106
+ fixations_to_analyze = fixations_not_enr
107
+ is_enriched_fixation = False
108
+ if not un_enriched_mode and not fixations_enr.empty and 'fixation detected on surface' in fixations_enr.columns:
109
+ fixations_on_surface = fixations_enr[fixations_enr['fixation detected on surface'] == True]
110
+ if not fixations_on_surface.empty:
111
+ fixations_to_analyze = fixations_on_surface
112
+ is_enriched_fixation = True
113
+
114
+ if not fixations_to_analyze.empty and 'duration [ms]' in fixations_to_analyze.columns:
115
+ results.update({
116
+ 'n_fixation': fixations_to_analyze['fixation id'].nunique(),
117
+ 'fixation_avg_duration_ms': fixations_to_analyze['duration [ms]'].mean(),
118
+ 'fixation_std_duration_ms': fixations_to_analyze['duration [ms]'].std()
119
+ })
120
+ x_coords, y_coords = pd.Series(dtype='float64'), pd.Series(dtype='float64')
121
+ if is_enriched_fixation and 'fixation x [normalized]' in fixations_to_analyze.columns:
122
+ x_coords, y_coords = fixations_to_analyze['fixation x [normalized]'], fixations_to_analyze['fixation y [normalized]']
123
+ elif 'fixation x [px]' in fixations_to_analyze.columns and video_width and video_height:
124
+ x_coords = fixations_to_analyze['fixation x [px]'] / video_width
125
+ y_coords = fixations_to_analyze['fixation y [px]'] / video_height
126
+ if not x_coords.empty:
127
+ results.update({'fixation_avg_x': x_coords.mean(), 'fixation_std_x': x_coords.std(), 'fixation_avg_y': y_coords.mean(), 'fixation_std_y': y_coords.std()})
128
+
129
+ if not blinks.empty and 'duration [ms]' in blinks.columns:
130
+ results.update({'n_blink': len(blinks), 'blink_avg_duration_ms': blinks['duration [ms]'].mean()})
131
+ if not saccades.empty and 'duration [ms]' in saccades.columns:
132
+ results.update({'n_saccade': len(saccades), 'saccade_avg_duration_ms': saccades['duration [ms]'].mean()})
133
+ if not pupil.empty and 'pupil diameter left [mm]' in pupil.columns:
134
+ pupil_diam = pupil['pupil diameter left [mm]'].dropna()
135
+ if not pupil_diam.empty:
136
+ results.update({'pupil_avg_mm': pupil_diam.mean(), 'pupil_std_mm': pupil_diam.std()})
137
+ if not gaze_not_enr.empty and 'gaze_speed_px_per_s' in gaze_not_enr.columns:
138
+ results['fragmentation_avg_px_per_s'] = gaze_not_enr['gaze_speed_px_per_s'].mean()
139
+ results['fragmentation_std_px_per_s'] = gaze_not_enr['gaze_speed_px_per_s'].std()
140
+ return results
141
+
142
+ def run_analysis(subj_name: str, data_dir_str: str, output_dir_str: str, un_enriched_mode: bool, selected_events: list):
143
+ """
144
+ Performs data analysis, calculates stats, and saves processed data for later use.
145
+ """
146
+ pd.options.mode.chained_assignment = None
147
+ data_dir, output_dir = Path(data_dir_str), Path(output_dir_str)
148
+ processed_data_dir = output_dir / 'processed_data'
149
+ processed_data_dir.mkdir(parents=True, exist_ok=True)
150
+ video_width, video_height = get_video_dimensions(data_dir / 'external.mp4')
151
+
152
+ all_data = load_all_data(data_dir, un_enriched_mode)
153
+ events_df = all_data.get('events')
154
+ if events_df is None or events_df.empty:
155
+ raise ValueError("events.csv not found or is empty. Cannot proceed with analysis.")
156
+
157
+ if 'name' in events_df.columns:
158
+ events_df['name'] = events_df['name'].astype(str).str.replace(r'[\\/]', '_', regex=True)
159
+
160
+ if selected_events:
161
+ initial_count = len(events_df)
162
+ events_df = events_df[events_df['name'].isin(selected_events)].copy()
163
+ logging.info(f"Filtered events based on selection. Kept {len(events_df)} out of {initial_count} events.")
164
+
165
+ gaze_not_enr = all_data.get('gaze_not_enr')
166
+ if gaze_not_enr is not None and not gaze_not_enr.empty:
167
+ gaze_not_enr.sort_values('timestamp [ns]', inplace=True)
168
+ gaze_not_enr['gaze_speed_px_per_s'] = euclidean_distance(gaze_not_enr['gaze x [px]'].shift(), gaze_not_enr['gaze y [px]'].shift(), gaze_not_enr['gaze x [px]'], gaze_not_enr['gaze y [px]']) / (gaze_not_enr['timestamp [ns]'].diff() / NS_TO_S)
169
+ all_data['gaze_not_enr'] = gaze_not_enr
170
+
171
+ all_results = []
172
+ events_df.sort_values('timestamp [ns]', inplace=True)
173
+ logging.info(f"Found {len(events_df)} selected events. Processing segments between them.")
174
+
175
+ for i in range(len(events_df)):
176
+ event_row = events_df.iloc[i]
177
+ start_ts = event_row['timestamp [ns]']
178
+ next_events_in_df = events_df[events_df['timestamp [ns]'] > start_ts]
179
+
180
+ if not next_events_in_df.empty:
181
+ end_ts = next_events_in_df.iloc[0]['timestamp [ns]']
182
+ else:
183
+ max_ts = start_ts
184
+ for df_name in ['pupil', 'gaze_not_enr', 'gaze_enr']:
185
+ df = all_data.get(df_name)
186
+ if df is not None and not df.empty:
187
+ ts_col = get_timestamp_col(df)
188
+ if ts_col: max_ts = max(max_ts, df[ts_col].max())
189
+ end_ts = max_ts
190
+
191
+ event_name = event_row.get('name', f"segment_{i}")
192
+ rec_id = event_row.get('recording id')
193
+ logging.info(f"--- Analyzing segment for event: '{event_name}' ({i+1}/{len(events_df)}) ---")
194
+
195
+ is_last_event = (i == len(events_df) - 1)
196
+ segment_data = filter_data_by_segment(all_data, start_ts, end_ts, rec_id, is_last=is_last_event)
197
+
198
+ if all(df.empty for name, df in segment_data.items() if name != 'events'):
199
+ logging.warning(f" -> Skipping segment '{event_name}' due to no data in the time range.")
200
+ continue
201
+
202
+ event_results = calculate_summary_features(segment_data, subj_name, event_name, un_enriched_mode, video_width, video_height)
203
+ all_results.append(event_results)
204
+ safe_event_name = "".join(c for c in event_name if c.isalnum() or c in ('_', '-')).rstrip()
205
+ segment_output_path = processed_data_dir / f"segment_{i}_{safe_event_name}.pkl"
206
+ with open(segment_output_path, 'wb') as f:
207
+ pickle.dump(segment_data, f)
208
+ logging.info(f" -> Saved processed data to {segment_output_path}")
209
+
210
+ if all_results:
211
+ results_df = pd.DataFrame(all_results)
212
+ results_df.to_csv(output_dir / f'summary_results_{subj_name}.csv', index=False)
213
+ logging.info("\nAggregated summary results saved.")
214
+ else:
215
+ logging.warning("\nNo results were generated from the analysis.")
216
+
217
+ # ==============================================================================
218
+ # ON-DEMAND PLOT GENERATION FUNCTIONS
219
+ # ==============================================================================
220
+
221
+ def _plot_histogram(data_series, title, xlabel, output_path):
222
+ try:
223
+ if data_series.dropna().empty: return
224
+ plt.figure(figsize=(10, 6), dpi=PLOT_DPI)
225
+ plt.hist(data_series.dropna(), bins=25, color='royalblue', edgecolor='black', alpha=0.7)
226
+ plt.title(title, fontsize=15); plt.xlabel(xlabel, fontsize=12); plt.ylabel('Frequency', fontsize=12)
227
+ plt.grid(axis='y', linestyle='--', alpha=0.7); plt.tight_layout()
228
+ plt.savefig(output_path)
229
+ except Exception as e:
230
+ logging.error(f"Failed to generate plot '{title}': {e}", exc_info=True)
231
+ finally:
232
+ plt.close('all'); gc.collect()
233
+
234
+ def _plot_path(df, x_col, y_col, title, output_path, is_normalized, color, w=None, h=None):
235
+ try:
236
+ if df.empty or x_col not in df.columns or y_col not in df.columns or df[x_col].isnull().all(): return
237
+ plt.figure(figsize=(10, 8), dpi=PLOT_DPI)
238
+ plt.plot(df[x_col], df[y_col], marker='o', linestyle='-', color=color, markersize=4, alpha=0.6)
239
+ plt.title(title, fontsize=15)
240
+ if is_normalized:
241
+ plt.xlabel('Normalized X'); plt.ylabel('Normalized Y'); plt.xlim(0, 1); plt.ylim(1, 0)
242
+ else:
243
+ plt.xlabel('Pixel X'); plt.ylabel('Pixel Y'); plt.gca().invert_yaxis()
244
+ if w and h: plt.xlim(0, w); plt.ylim(h, 0)
245
+ plt.grid(True); plt.tight_layout(); plt.savefig(output_path)
246
+ except Exception as e:
247
+ logging.error(f"Failed to generate plot '{title}': {e}", exc_info=True)
248
+ finally:
249
+ plt.close('all'); gc.collect()
250
+
251
+ def _plot_heatmap(df, x_col, y_col, title, output_path, is_normalized, w=None, h=None):
252
+ try:
253
+ if df.empty or x_col not in df.columns or y_col not in df.columns: return
254
+ x = df[x_col].dropna(); y = df[y_col].dropna()
255
+ if len(x) < 3: return
256
+ k = gaussian_kde(np.vstack([x, y]))
257
+ x_range = (x.min(), x.max()) if not is_normalized else (0,1)
258
+ y_range = (y.min(), y.max()) if not is_normalized else (0,1)
259
+ xi, yi = np.mgrid[x_range[0]:x_range[1]:100j, y_range[0]:y_range[1]:100j]
260
+ zi = k(np.vstack([xi.flatten(), yi.flatten()]))
261
+ plt.figure(figsize=(10, 8), dpi=PLOT_DPI)
262
+ plt.pcolormesh(xi, yi, zi.reshape(xi.shape), shading='auto', cmap='Reds')
263
+ plt.title(title, fontsize=15)
264
+ if is_normalized:
265
+ plt.xlabel('Normalized X'); plt.ylabel('Normalized Y'); plt.xlim(0, 1); plt.ylim(1, 0)
266
+ else:
267
+ plt.xlabel('Pixel X'); plt.ylabel('Pixel Y'); plt.gca().invert_yaxis()
268
+ if w and h: plt.xlim(0, w); plt.ylim(h, 0)
269
+ plt.colorbar(label="Density"); plt.grid(True); plt.tight_layout(); plt.savefig(output_path)
270
+ except Exception as e:
271
+ logging.error(f"Failed during heatmap generation for '{title}': {e}", exc_info=True)
272
+ finally:
273
+ plt.close('all'); gc.collect()
274
+
275
+ def _plot_spectral_analysis(pupil_series, title_prefix, output_dir):
276
+ ts = pupil_series.dropna()
277
+ if len(ts) <= SAMPLING_FREQ: return
278
+ try:
279
+ freqs, Pxx = welch(ts.to_numpy(), fs=SAMPLING_FREQ, nperseg=min(len(ts), 256))
280
+ plt.figure(figsize=(10, 5), dpi=PLOT_DPI); plt.semilogy(freqs, Pxx)
281
+ plt.title(f'Periodogram - {title_prefix}'); plt.xlabel('Frequency [Hz]'); plt.ylabel('PSD')
282
+ plt.savefig(output_dir / f'periodogram_{title_prefix}.pdf')
283
+ except Exception as e: logging.warning(f"Failed Periodogram for '{title_prefix}': {e}")
284
+ finally: plt.close('all')
285
+ try:
286
+ f, t, Sxx = spectrogram(ts.to_numpy(), fs=SAMPLING_FREQ, nperseg=min(len(ts), 256))
287
+ plt.figure(figsize=(10, 5), dpi=PLOT_DPI)
288
+ if Sxx.size > 0:
289
+ plt.pcolormesh(t, f, 10 * np.log10(np.maximum(Sxx, 1e-10)), shading='gouraud')
290
+ plt.colorbar(label='Power [dB]')
291
+ plt.title(f'Spectrogram - {title_prefix}'); plt.ylabel('Frequency [Hz]'); plt.xlabel('Time [s]')
292
+ plt.savefig(output_dir / f'spectrogram_{title_prefix}.pdf')
293
+ except Exception as e: logging.warning(f"Failed Spectrogram for '{title_prefix}': {e}")
294
+ finally:
295
+ plt.close('all'); gc.collect()
296
+
297
+ def _plot_generic_timeseries(x_data, y_data_dict, title, xlabel, ylabel, output_path, span_df=None):
298
+ """
299
+ MODIFIED: Draws green spans for 'on surface' and red spans for 'off surface'.
300
+ """
301
+ try:
302
+ fig, ax = plt.subplots(figsize=(12, 6), dpi=PLOT_DPI)
303
+
304
+ # --- LOGICA PER SFONDO VERDE/ROSSO ---
305
+ if span_df is not None and 'gaze detected on surface' in span_df.columns and 'time_sec' in span_df.columns:
306
+ time_diffs = span_df['time_sec'].diff().fillna(method='bfill').fillna(method='ffill').fillna(0)
307
+
308
+ on_surface_spans = span_df[span_df['gaze detected on surface'] == True]
309
+ for index, row in on_surface_spans.iterrows():
310
+ start_time = row['time_sec']
311
+ duration = time_diffs.loc[index]
312
+ ax.axvspan(start_time, start_time + duration, facecolor='green', alpha=0.2, edgecolor='none')
313
+
314
+ off_surface_spans = span_df[span_df['gaze detected on surface'] == False]
315
+ for index, row in off_surface_spans.iterrows():
316
+ start_time = row['time_sec']
317
+ duration = time_diffs.loc[index]
318
+ ax.axvspan(start_time, start_time + duration, facecolor='red', alpha=0.15, edgecolor='none')
319
+ # --- FINE LOGICA ---
320
+
321
+ for label, y_data in y_data_dict.items():
322
+ ax.plot(x_data, y_data, label=label, alpha=0.8)
323
+
324
+ ax.set_title(title, fontsize=15)
325
+ ax.set_xlabel(xlabel)
326
+ ax.set_ylabel(ylabel)
327
+ ax.legend()
328
+ ax.grid(True, linestyle='--', alpha=0.6)
329
+ fig.tight_layout()
330
+ plt.savefig(output_path)
331
+ except Exception as e:
332
+ logging.error(f"Failed to generate generic time series plot '{title}': {e}", exc_info=True)
333
+ finally:
334
+ plt.close('all')
335
+ gc.collect()
336
+
337
+ def _plot_binary_timeseries(df, start_col, duration_col, total_duration, title, ylabel, output_path):
338
+ try:
339
+ fig, ax = plt.subplots(figsize=(12, 3), dpi=PLOT_DPI)
340
+ for _, row in df.iterrows():
341
+ ax.add_patch(mpatches.Rectangle((row[start_col], 0), row[duration_col], 1, facecolor='red', alpha=0.5))
342
+ ax.set_xlim(0, total_duration); ax.set_ylim(0, 1)
343
+ ax.set_title(title, fontsize=15); ax.set_xlabel("Time (s)"); ax.set_ylabel(ylabel)
344
+ ax.get_yaxis().set_ticks([]); plt.tight_layout(); plt.savefig(output_path)
345
+ except Exception as e:
346
+ logging.error(f"Failed to generate binary plot '{title}': {e}", exc_info=True)
347
+ finally:
348
+ plt.close('all'); gc.collect()
349
+
350
+ def _generate_plots_for_segment_process(pkl_file: Path, plots_dir: Path, plot_selections: dict, un_enriched_mode: bool, video_width: int, video_height: int):
351
+ """
352
+ This function is executed in a separate process to generate all plots for a single segment.
353
+ MODIFIED: Uses a unique segment identifier for plot filenames to prevent overwriting.
354
+ """
355
+ try:
356
+ plt.switch_backend('Agg')
357
+
358
+ # --- MODIFICA PER NOMI FILE UNICI ---
359
+ unique_filename_id = pkl_file.stem
360
+ event_name_for_title = "_".join(unique_filename_id.split('_')[2:])
361
+ logging.info(f"--- [Process] Generating plots for event segment: '{event_name_for_title}' ({unique_filename_id}) ---")
362
+ # --- FINE MODIFICA ---
363
+
364
+ with open(pkl_file, 'rb') as f:
365
+ segment_data = pickle.load(f)
366
+
367
+ fixations_enr = segment_data.get('fixations_enr', pd.DataFrame())
368
+ fixations_not_enr = segment_data.get('fixations_not_enr', pd.DataFrame())
369
+ gaze_enr = segment_data.get('gaze_enr', pd.DataFrame())
370
+ gaze_not_enr = segment_data.get('gaze_not_enr', pd.DataFrame())
371
+ blinks = segment_data.get('blinks', pd.DataFrame())
372
+ saccades = segment_data.get('saccades', pd.DataFrame())
373
+ pupil = segment_data.get('pupil', pd.DataFrame())
374
+
375
+ t_min, total_duration = 0, 1
376
+ base_df_for_time = pupil if not pupil.empty else (gaze_not_enr if not gaze_not_enr.empty else pd.DataFrame())
377
+ if not base_df_for_time.empty:
378
+ ts_col = get_timestamp_col(base_df_for_time)
379
+ if ts_col:
380
+ t_min = base_df_for_time[ts_col].min()
381
+ for df_name, df in segment_data.items():
382
+ if not df.empty and df_name != 'events':
383
+ ts_col_inner = get_timestamp_col(df)
384
+ if ts_col_inner:
385
+ df['time_sec'] = (df[ts_col_inner] - t_min) / NS_TO_S
386
+ if 'time_sec' in base_df_for_time.columns:
387
+ total_duration = base_df_for_time['time_sec'].max()
388
+
389
+ span_df = None
390
+ if not un_enriched_mode and not pupil.empty and not gaze_enr.empty and 'gaze detected on surface' in gaze_enr.columns:
391
+ if 'time_sec' not in gaze_enr.columns and 'timestamp [ns]' in gaze_enr.columns:
392
+ gaze_enr['time_sec'] = (gaze_enr['timestamp [ns]'] - t_min) / NS_TO_S
393
+ if 'time_sec' in pupil.columns and 'time_sec' in gaze_enr.columns:
394
+ span_df = pd.merge_asof(pupil.sort_values('time_sec'), gaze_enr[['time_sec', 'gaze detected on surface']].sort_values('time_sec'), on='time_sec', direction='nearest')
395
+
396
+ # --- MODIFICA PER NOMI FILE UNICI ---
397
+ if plot_selections.get("histograms"):
398
+ if 'duration [ms]' in fixations_not_enr.columns: _plot_histogram(fixations_not_enr['duration [ms]'], f"Fixation Duration (Un-enriched) - {event_name_for_title}", "Duration [ms]", plots_dir / f"hist_fix_unenriched_{unique_filename_id}.pdf")
399
+ if not un_enriched_mode and 'duration [ms]' in fixations_enr.columns: _plot_histogram(fixations_enr['duration [ms]'], f"Fixation Duration (Enriched) - {event_name_for_title}", "Duration [ms]", plots_dir / f"hist_fix_enriched_{unique_filename_id}.pdf")
400
+ if 'duration [ms]' in blinks.columns: _plot_histogram(blinks['duration [ms]'], f"Blink Duration - {event_name_for_title}", "Duration [ms]", plots_dir / f"hist_blinks_{unique_filename_id}.pdf")
401
+ if 'duration [ms]' in saccades.columns: _plot_histogram(saccades['duration [ms]'], f"Saccade Duration - {event_name_for_title}", "Duration [ms]", plots_dir / f"hist_saccades_{unique_filename_id}.pdf")
402
+
403
+ if plot_selections.get("path_plots"):
404
+ _plot_path(fixations_not_enr, 'fixation x [px]', 'fixation y [px]', f"Fixation Path (Un-enriched) - {event_name_for_title}", plots_dir / f"path_fix_unenriched_{unique_filename_id}.pdf", False, 'purple', video_width, video_height)
405
+ _plot_path(gaze_not_enr, 'gaze x [px]', 'gaze y [px]', f"Gaze Path (Un-enriched) - {event_name_for_title}", plots_dir / f"path_gaze_unenriched_{unique_filename_id}.pdf", False, 'blue', video_width, video_height)
406
+ if not un_enriched_mode and not fixations_enr.empty: _plot_path(fixations_enr, 'fixation x [normalized]', 'fixation y [normalized]', f"Fixation Path (Enriched) - {event_name_for_title}", plots_dir / f"path_fix_enriched_{unique_filename_id}.pdf", True, 'green')
407
+ if not un_enriched_mode and not gaze_enr.empty: _plot_path(gaze_enr, 'gaze position on surface x [normalized]', 'gaze position on surface y [normalized]', f"Gaze Path (Enriched) - {event_name_for_title}", plots_dir / f"path_gaze_enriched_{unique_filename_id}.pdf", True, 'red')
408
+
409
+ if plot_selections.get("heatmaps"):
410
+ _plot_heatmap(fixations_not_enr, 'fixation x [px]', 'fixation y [px]', f"Fixation Heatmap (Un-enriched) - {event_name_for_title}", plots_dir / f"heatmap_fix_unenriched_{unique_filename_id}.pdf", False, video_width, video_height)
411
+ _plot_heatmap(gaze_not_enr, 'gaze x [px]', 'gaze y [px]', f"Gaze Heatmap (Un-enriched) - {event_name_for_title}", plots_dir / f"heatmap_gaze_unenriched_{unique_filename_id}.pdf", False, video_width, video_height)
412
+ if not un_enriched_mode and not fixations_enr.empty: _plot_heatmap(fixations_enr, 'fixation x [normalized]', 'fixation y [normalized]', f"Fixation Heatmap (Enriched) - {event_name_for_title}", plots_dir / f"heatmap_fix_enriched_{unique_filename_id}.pdf", True)
413
+ if not un_enriched_mode and not gaze_enr.empty: _plot_heatmap(gaze_enr, 'gaze position on surface x [normalized]', 'gaze position on surface y [normalized]', f"Gaze Heatmap (Enriched) - {event_name_for_title}", plots_dir / f"heatmap_gaze_enriched_{unique_filename_id}.pdf", True)
414
+
415
+ if plot_selections.get("pupillometry") and not pupil.empty and 'time_sec' in pupil.columns:
416
+ y_data = {}
417
+ if 'pupil diameter left [mm]' in pupil.columns: y_data['Left Pupil'] = pupil['pupil diameter left [mm]']
418
+ if 'pupil diameter right [mm]' in pupil.columns: y_data['Right Pupil'] = pupil['pupil diameter right [mm]']
419
+ if y_data: _plot_generic_timeseries(pupil['time_sec'], y_data, f"Pupil Diameter - {event_name_for_title}", "Time (s)", "Diameter [mm]", plots_dir / f"pupillometry_{unique_filename_id}.pdf", span_df)
420
+ if 'pupil diameter left [mm]' in pupil.columns: _plot_spectral_analysis(pupil['pupil diameter left [mm]'], f"total_{unique_filename_id}", plots_dir)
421
+ if span_df is not None and not span_df.empty and 'gaze detected on surface' in span_df.columns:
422
+ if 'pupil diameter left [mm]' in span_df.columns: _plot_spectral_analysis(span_df[span_df['gaze detected on surface'] == True]['pupil diameter left [mm]'], f"onsurface_{unique_filename_id}", plots_dir)
423
+
424
+ if plot_selections.get("fragmentation") and not gaze_not_enr.empty and 'gaze_speed_px_per_s' in gaze_not_enr.columns and 'time_sec' in gaze_not_enr.columns:
425
+ _plot_generic_timeseries(gaze_not_enr['time_sec'], {'Fragmentation': gaze_not_enr['gaze_speed_px_per_s']}, f"Gaze Fragmentation (Speed) - {event_name_for_title}", "Time (s)", "Speed (pixels/sec)", plots_dir / f"fragmentation_{unique_filename_id}.pdf")
426
+
427
+ if plot_selections.get("advanced_timeseries"):
428
+ if not pupil.empty and 'pupil diameter left [mm]' in pupil.columns and 'pupil diameter right [mm]' in pupil.columns and 'time_sec' in pupil.columns:
429
+ pupil['pupil_mean_mm'] = pupil[['pupil diameter left [mm]', 'pupil diameter right [mm]']].mean(axis=1)
430
+ _plot_generic_timeseries(pupil['time_sec'], {'Mean Pupil Diameter': pupil['pupil_mean_mm']}, f"Mean Pupil Diameter - {event_name_for_title}", "Time (s)", "Diameter [mm]", plots_dir / f"pupil_diameter_mean_{unique_filename_id}.pdf", span_df)
431
+ if not saccades.empty and 'time_sec' in saccades.columns:
432
+ vel_y_data, vel_unit = {}, None
433
+ if 'mean velocity [px/s]' in saccades.columns: vel_y_data['Mean Velocity'] = saccades['mean velocity [px/s]']; vel_y_data['Peak Velocity'] = saccades['peak velocity [px/s]']; vel_unit = "px/s"
434
+ if vel_y_data: _plot_generic_timeseries(saccades['time_sec'], vel_y_data, f"Saccade Velocity - {event_name_for_title}", "Time (s)", f"Velocity [{vel_unit}]", plots_dir / f"saccade_velocities_{unique_filename_id}.pdf")
435
+ amp_y_data, amp_unit = {}, None
436
+ if 'amplitude [px]' in saccades.columns: amp_y_data['Amplitude'] = saccades['amplitude [px]']; amp_unit = "px"
437
+ if amp_y_data: _plot_generic_timeseries(saccades['time_sec'], amp_y_data, f"Saccade Amplitude - {event_name_for_title}", "Time (s)", f"Amplitude [{amp_unit}]", plots_dir / f"saccade_amplitude_{unique_filename_id}.pdf")
438
+ if not blinks.empty and 'start timestamp [ns]' in blinks.columns and 'duration [ms]' in blinks.columns and 'time_sec' in blinks.columns:
439
+ blinks['duration_sec'] = blinks['duration [ms]'] / 1000
440
+ _plot_binary_timeseries(blinks, 'time_sec', 'duration_sec', total_duration, f"Blink Events - {event_name_for_title}", "Blink", plots_dir / f"blink_time_series_{unique_filename_id}.pdf")
441
+
442
+ return f"Successfully generated plots for {event_name_for_title}"
443
+ except Exception as e:
444
+ error_message = f"ERROR: Failed to generate plots for event segment '{pkl_file.stem}'. Error: {e}\n{traceback.format_exc()}"
445
+ logging.error(error_message)
446
+ return error_message
447
+
448
+ def generate_plots_on_demand(output_dir_str: str, subj_name: str, plot_selections: dict, un_enriched_mode: bool):
449
+ """
450
+ Generates plots using a ProcessPoolExecutor for memory efficiency and robustness.
451
+ """
452
+ output_dir = Path(output_dir_str)
453
+ plots_dir = output_dir / 'plots'
454
+ plots_dir.mkdir(parents=True, exist_ok=True)
455
+ processed_data_dir = output_dir / 'processed_data'
456
+
457
+ if not processed_data_dir.exists():
458
+ raise FileNotFoundError("Processed data directory not found. Please run the Core Analysis first.")
459
+
460
+ try:
461
+ with open(output_dir / 'config.json', 'r') as f:
462
+ config = json.load(f)
463
+ un_enriched_mode = config.get("unenriched_mode", False) # Sovrascrive per sicurezza
464
+ data_dir = Path(config['source_folders']['unenriched'])
465
+ video_width, video_height = get_video_dimensions(next(data_dir.glob('*.mp4')))
466
+ except (FileNotFoundError, StopIteration, KeyError):
467
+ logging.warning("Could not determine original video dimensions. Plots might be affected.")
468
+ video_width, video_height = None, None
469
+
470
+ pkl_files = sorted(processed_data_dir.glob("*.pkl"))
471
+
472
+ if not pkl_files:
473
+ logging.warning("No processed data segment (.pkl) files found. Cannot generate plots.")
474
+ return
475
+
476
+ worker_function = partial(
477
+ _generate_plots_for_segment_process,
478
+ plots_dir=plots_dir,
479
+ plot_selections=plot_selections,
480
+ un_enriched_mode=un_enriched_mode,
481
+ video_width=video_width,
482
+ video_height=video_height
483
+ )
484
+
485
+ with ProcessPoolExecutor() as executor:
486
+ futures = {executor.submit(worker_function, pkl_file): pkl_file for pkl_file in pkl_files}
487
+ for future in as_completed(futures):
488
+ result_message = future.result()
489
+ if "ERROR" in result_message:
490
+ logging.error(f"A process failed: {result_message}")
491
+ else:
492
+ logging.info(result_message)
493
+
494
+ logging.info("--- Plot generation finished. ---")