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.
- speed_analyzer/__init__.py +112 -0
- speed_analyzer/speed_script_events.py +494 -0
- speed_analyzer/video_generator.py +477 -0
- speed_analyzer/yolo_analyzer.py +164 -0
- speed_analyzer-3.6.0.dist-info/METADATA +189 -0
- speed_analyzer-3.6.0.dist-info/RECORD +9 -0
- speed_analyzer-3.6.0.dist-info/WHEEL +5 -0
- speed_analyzer-3.6.0.dist-info/licenses/LICENSE +21 -0
- speed_analyzer-3.6.0.dist-info/top_level.txt +1 -0
|
@@ -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. ---")
|