1+ from functools import wraps
2+ import logging
13import os
4+ from pathlib import Path
25import shutil
6+ import signal
37import subprocess
8+ import sys
49import tempfile
10+ import traceback
11+ from datetime import datetime
512from typing import List , Optional
613
714from auxiliary .turbopath import turbopath
1017from .modality import Modality
1118from .registration .registrator import Registrator
1219
20+ logger = logging .getLogger (__name__ )
21+
1322
1423class Preprocessor :
1524 """
@@ -39,6 +48,8 @@ def __init__(
3948 use_gpu : Optional [bool ] = None ,
4049 limit_cuda_visible_devices : Optional [str ] = None ,
4150 ):
51+ self ._setup_logger ()
52+
4253 self .center_modality = center_modality
4354 self .moving_modalities = moving_modalities
4455 self .atlas_image_path = turbopath (atlas_image_path )
@@ -93,16 +104,94 @@ def _cuda_is_available():
93104 except (subprocess .CalledProcessError , FileNotFoundError ):
94105 return False
95106
107+ def ensure_remove_log_file_handler (func ):
108+ @wraps (func )
109+ def wrapper (* args , ** kwargs ):
110+ try :
111+ return func (* args , ** kwargs )
112+ finally :
113+ self = args [0 ]
114+ if isinstance (self , Preprocessor ) and self .log_file_handler :
115+ logging .getLogger ().removeHandler (self .log_file_handler )
116+
117+ return wrapper
118+
119+ def _set_log_file (self , log_file : Optional [str | Path ]) -> None :
120+ """Set the log file and remove the file handler from a potential previous run.
121+
122+ Args:
123+ log_file (str | Path): log file path
124+ """
125+ if self .log_file_handler :
126+ logging .getLogger ().removeHandler (self .log_file_handler )
127+
128+ # ensure parent directories exists
129+ log_file = Path (
130+ log_file
131+ if log_file
132+ else f"brainles_preprocessing_{ datetime .now ().isoformat ()} .log"
133+ )
134+ log_file .parent .mkdir (parents = True , exist_ok = True )
135+
136+ self .log_file_handler = logging .FileHandler (log_file )
137+ self .log_file_handler .setFormatter (
138+ logging .Formatter (
139+ "[%(levelname)-8s | %(module)-15s | L%(lineno)-5d] %(asctime)s: %(message)s" ,
140+ "%Y-%m-%dT%H:%M:%S%z" ,
141+ )
142+ )
143+
144+ # Add the file handler to the !root! logger
145+ logging .getLogger ().addHandler (self .log_file_handler )
146+
147+ def _setup_logger (self ):
148+ """Setup the logger and overwrite system hooks to add logging for exceptions and signals."""
149+
150+ logging .basicConfig (
151+ format = "[%(levelname)s] %(asctime)s: %(message)s" ,
152+ datefmt = "%Y-%m-%dT%H:%M:%S%z" ,
153+ level = logging .INFO ,
154+ )
155+ self .log_file_handler = None
156+
157+ # overwrite system hooks to log exceptions and signals (SIGINT, SIGTERM)
158+ #! NOTE: This will note work in Jupyter Notebooks, (Without extra setup) see https://stackoverflow.com/a/70469055:
159+ def exception_handler (exception_type , value , tb ):
160+ """Handle exceptions
161+
162+ Args:
163+ exception_type (Exception): Exception type
164+ exception (Exception): Exception
165+ traceback (Traceback): Traceback
166+ """
167+ logger .error ("" .join (traceback .format_exception (exception_type , value , tb )))
168+
169+ if issubclass (exception_type , SystemExit ):
170+ # add specific code if exception was a system exit
171+ sys .exit (value .code )
172+
173+ def signal_handler (sig , frame ):
174+ signame = signal .Signals (sig ).name
175+ logger .error (f"Received signal { sig } ({ signame } ), exiting..." )
176+ sys .exit (0 )
177+
178+ sys .excepthook = exception_handler
179+
180+ signal .signal (signal .SIGINT , signal_handler )
181+ signal .signal (signal .SIGTERM , signal_handler )
182+
96183 @property
97184 def all_modalities (self ):
98185 return [self .center_modality ] + self .moving_modalities
99186
187+ @ensure_remove_log_file_handler
100188 def run (
101189 self ,
102190 save_dir_coregistration : Optional [str ] = None ,
103191 save_dir_atlas_registration : Optional [str ] = None ,
104192 save_dir_atlas_correction : Optional [str ] = None ,
105193 save_dir_brain_extraction : Optional [str ] = None ,
194+ log_file : Optional [str ] = None ,
106195 ):
107196 """
108197 Execute the preprocessing pipeline, encompassing coregistration, atlas-based registration,
@@ -113,6 +202,7 @@ def run(
113202 save_dir_atlas_registration (str, optional): Directory path to save atlas registration results.
114203 save_dir_atlas_correction (str, optional): Directory path to save atlas correction results.
115204 save_dir_brain_extraction (str, optional): Directory path to save brain extraction results.
205+ log_file (str, optional): Path to save the log file. Defaults to a timestamped file in the current directory.
116206
117207 This method orchestrates the entire preprocessing workflow by sequentially performing:
118208
@@ -123,12 +213,26 @@ def run(
123213
124214 Results are saved in the specified directories, allowing for modular and configurable output storage.
125215 """
216+ self ._set_log_file (log_file = log_file )
217+ logger .info (f"{ ' Starting preprocessing ' :=^80} " )
218+ logger .info (f"Logs are saved to { self .log_file_handler .baseFilename } " )
219+ logger .info (
220+ f"Received center modality: { self .center_modality .modality_name } and moving modalities: { ', ' .join ([modality .modality_name for modality in self .moving_modalities ])} "
221+ )
222+
223+ logger .info (f"{ ' Starting Coregistration ' :-^80} " )
224+
126225 # Coregister moving modalities to center modality
127226 coregistration_dir = os .path .join (self .temp_folder , "coregistration" )
128227 os .makedirs (coregistration_dir , exist_ok = True )
129-
228+ logger .info (
229+ f"Coregistering { len (self .moving_modalities )} moving modalities to center modality..."
230+ )
130231 for moving_modality in self .moving_modalities :
131232 file_name = f"co__{ self .center_modality .modality_name } __{ moving_modality .modality_name } "
233+ logger .info (
234+ f"Registering modality { moving_modality .modality_name } (file={ file_name } ) to center modality..."
235+ )
132236 moving_modality .register (
133237 registrator = self .registrator ,
134238 fixed_image_path = self .center_modality .current ,
@@ -148,19 +252,31 @@ def run(
148252 src = coregistration_dir ,
149253 save_dir = save_dir_coregistration ,
150254 )
255+ logger .info (
256+ f"Coregistration complete. Output saved to { save_dir_coregistration } "
257+ )
151258
152259 # Register center modality to atlas
260+ logger .info (f"{ ' Starting atlas registration ' :-^80} " )
261+ logger .info (f"Registering center modality to atlas..." )
153262 center_file_name = f"atlas__{ self .center_modality .modality_name } "
154263 transformation_matrix = self .center_modality .register (
155264 registrator = self .registrator ,
156265 fixed_image_path = self .atlas_image_path ,
157266 registration_dir = self .atlas_dir ,
158267 moving_image_name = center_file_name ,
159268 )
269+ logger .info (f"Atlas registration complete. Output saved to { self .atlas_dir } " )
160270
161271 # Transform moving modalities to atlas
272+ logger .info (
273+ f"Transforming { len (self .moving_modalities )} moving modalities to atlas space..."
274+ )
162275 for moving_modality in self .moving_modalities :
163276 moving_file_name = f"atlas__{ moving_modality .modality_name } "
277+ logger .info (
278+ f"Transforming modality { moving_modality .modality_name } (file={ moving_file_name } ) to atlas space..."
279+ )
164280 moving_modality .transform (
165281 registrator = self .registrator ,
166282 fixed_image_path = self .atlas_image_path ,
@@ -172,36 +288,49 @@ def run(
172288 src = self .atlas_dir ,
173289 save_dir = save_dir_atlas_registration ,
174290 )
291+ logger .info (
292+ f"Transformations complete. Output saved to { save_dir_atlas_registration } "
293+ )
175294
176295 # Optional: additional correction in atlas space
296+ logger .info (f"{ ' Checking optional atlas correction ' :-^80} " )
177297 atlas_correction_dir = os .path .join (self .temp_folder , "atlas-correction" )
178298 os .makedirs (atlas_correction_dir , exist_ok = True )
179299
180300 for moving_modality in self .moving_modalities :
181- if moving_modality .atlas_correction is True :
301+ if moving_modality .atlas_correction :
302+ logger .info (
303+ f"Applying optional atlas correction for modality { moving_modality .modality_name } "
304+ )
182305 moving_file_name = f"atlas_corrected__{ self .center_modality .modality_name } __{ moving_modality .modality_name } "
183306 moving_modality .register (
184307 registrator = self .registrator ,
185308 fixed_image_path = self .center_modality .current ,
186309 registration_dir = atlas_correction_dir ,
187310 moving_image_name = moving_file_name ,
188311 )
312+ else :
313+ logger .info ("Skipping optional atlas correction." )
189314
190- if self .center_modality .atlas_correction is True :
315+ if self .center_modality .atlas_correction :
191316 shutil .copyfile (
192317 src = self .center_modality .current ,
193318 dst = os .path .join (
194319 atlas_correction_dir ,
195320 f"atlas_corrected__{ self .center_modality .modality_name } .nii.gz" ,
196321 ),
197322 )
323+ logger .info (
324+ f"Atlas correction complete. Output saved to { save_dir_atlas_correction } "
325+ )
198326
199327 self ._save_output (
200328 src = atlas_correction_dir ,
201329 save_dir = save_dir_atlas_correction ,
202330 )
203331
204332 # now we save images that are not skullstripped
333+ logger .info ("Saving non skull-stripped images..." )
205334 for modality in self .all_modalities :
206335 if modality .raw_skull_output_path is not None :
207336 modality .save_current_image (
@@ -213,21 +342,25 @@ def run(
213342 modality .normalized_skull_output_path ,
214343 normalization = True ,
215344 )
216-
217345 # Optional: Brain extraction
346+ logger .info (f"{ ' Checking optional brain extraction ' :-^80} " )
218347 brain_extraction = any (modality .bet for modality in self .all_modalities )
219348 # print("brain extraction: ", brain_extraction)
220349
221350 if brain_extraction :
351+ logger .info ("Starting brain extraction..." )
222352 bet_dir = os .path .join (self .temp_folder , "brain-extraction" )
223353 os .makedirs (bet_dir , exist_ok = True )
224354 brain_masked_dir = os .path .join (bet_dir , "brain_masked" )
225355 os .makedirs (brain_masked_dir , exist_ok = True )
226-
356+ logger . info ( "Extracting brain region for center modality..." )
227357 atlas_mask = self .center_modality .extract_brain_region (
228358 brain_extractor = self .brain_extractor , bet_dir_path = bet_dir
229359 )
230360 for moving_modality in self .moving_modalities :
361+ logger .info (
362+ f"Applying brain mask to { moving_modality .modality_name } ..."
363+ )
231364 moving_modality .apply_mask (
232365 brain_extractor = self .brain_extractor ,
233366 brain_masked_dir_path = brain_masked_dir ,
@@ -238,8 +371,14 @@ def run(
238371 src = bet_dir ,
239372 save_dir = save_dir_brain_extraction ,
240373 )
374+ logger .info (
375+ f"Brain extraction complete. Output saved to { save_dir_brain_extraction } "
376+ )
377+ else :
378+ logger .info ("Skipping optional brain extraction." )
241379
242380 # now we save images that are skullstripped
381+ logger .info ("Saving skull-stripped images..." )
243382 for modality in self .all_modalities :
244383 if modality .raw_bet_output_path is not None :
245384 modality .save_current_image (
@@ -251,6 +390,7 @@ def run(
251390 modality .normalized_bet_output_path ,
252391 normalization = True ,
253392 )
393+ logger .info (f"{ ' Preprocessing complete ' :=^80} " )
254394
255395 def _save_output (
256396 self ,
0 commit comments