threads.import_thread
import_thread.py - ImportThread (part 1/4)
This module contains ImportThread, a QThread subclass responsible for importing medical imaging datasets into the application's workspace. It supports:
- Copying already-BIDS-organized folders
- Converting DICOM series to NIfTI using dcm2niix
- Aggregating NIfTI/JSON pairs into a BIDS-like structure
- Heuristics for detecting whether a folder represents one subject or multiple
This file is presented in parts. Part 1 contains:
- imports
- ImportThread class definition (signals)
- __init__ and run() (main orchestration and top-level flow)
1""" 2import_thread.py - ImportThread (part 1/4) 3 4This module contains ImportThread, a QThread subclass responsible for importing 5medical imaging datasets into the application's workspace. It supports: 6 - Copying already-BIDS-organized folders 7 - Converting DICOM series to NIfTI using dcm2niix 8 - Aggregating NIfTI/JSON pairs into a BIDS-like structure 9 - Heuristics for detecting whether a folder represents one subject or multiple 10 11This file is presented in parts. Part 1 contains: 12 - imports 13 - ImportThread class definition (signals) 14 - __init__ and run() (main orchestration and top-level flow) 15""" 16import json 17import os 18import re 19import shutil 20import subprocess 21import tempfile 22 23import pydicom 24 25from PyQt6.QtCore import QThread, pyqtSignal, QCoreApplication 26 27from logger import get_logger 28from utils import get_bin_path 29 30log = get_logger() 31 32 33class ImportThread(QThread): 34 """ 35 Worker thread that imports one or more folders into the application's workspace. 36 37 The thread emits: 38 - finished(): when the import completes successfully 39 - error(str): in case of an exception 40 - progress(int): progress updates (0-100) 41 42 The thread is cancellable via the `cancel()` method which sets an internal 43 flag and attempts to terminate any running external process (dcm2niix). 44 """ 45 46 # Signal emitted when an operation completes successfully 47 finished = pyqtSignal() 48 """**Signal():** Emitted when the operation finishes successfully and no further progress updates will occur.""" 49 50 # Signal emitted when an error occurs during processing 51 error = pyqtSignal(str) 52 """**Signal(str):** Emitted when an error occurs. 53 Parameter: 54 - `str`: Description or message of the encountered error. 55 """ 56 57 # Signal emitted to report progress updates 58 progress = pyqtSignal(int) 59 """**Signal(int):** Emitted periodically to indicate task progress. 60 Parameter: 61 - `int`: Current progress percentage (0–100). 62 """ 63 64 def __init__(self, context, folders_path, workspace_path): 65 """ 66 Initialize the import thread. 67 68 Args: 69 context (dict): application context (used for callbacks / GUI updates). 70 folders_path (list[str]): list of input paths (files or folders) to import. 71 workspace_path (str): path to the target workspace directory. 72 """ 73 super().__init__() 74 self.context = context 75 # allow either single path or list of paths; stored as list to keep API stable 76 self.folders_path = folders_path 77 self.workspace_path = workspace_path 78 79 # internal state for progress and cancellation 80 self.current_progress = 0 81 self._is_canceled = False 82 83 # reference to subprocess.Popen used during DICOM conversion (so we can terminate it) 84 self.process = None 85 86 87 def run(self): 88 """ 89 Main thread entry point. 90 91 This method orchestrates the following high-level behaviors: 92 - If a single folder is provided: 93 * Detect BIDS folder and copy directly 94 * Else detect whether folder contains multiple patient subfolders or 95 a single subject split across many DICOM series 96 * Convert found DICOM series to NIfTI using dcm2niix in a temporary 97 directory, then convert NIfTI + JSON to a BIDS-like structure inside 98 the workspace. 99 - If multiple folders are provided: 100 * Process each folder independently with `_handle_import`. 101 Progress is emitted periodically. Exceptions are forwarded via the 102 `error` signal. 103 """ 104 try: 105 # initial small progress step 106 self.current_progress = 10 107 self.progress.emit(self.current_progress) 108 109 # Validate input list length and branch accordingly 110 if len(self.folders_path) == 1: 111 # single path handling 112 folder_path = self.folders_path[0] 113 114 # Basic validation 115 if not os.path.isdir(folder_path): 116 raise Exception(QCoreApplication.translate("Threads", "Invalid folders path")) 117 118 # Case A: already a BIDS subject folder -> copy directly 119 if self._is_bids_folder(folder_path): 120 log.debug(f"BIDS structure detected in: {folder_path}") 121 122 if self._is_canceled: 123 return 124 self.current_progress = 50 125 self.progress.emit(self.current_progress) 126 127 # choose next sub-id (e.g. sub-03) and copy tree 128 old_sub_id = os.path.basename(os.path.normpath(folder_path)) 129 new_sub_id = self._get_next_sub_id() 130 dest = os.path.join(self.workspace_path, new_sub_id) 131 shutil.copytree(folder_path, dest, dirs_exist_ok=True) 132 133 if not self._is_canceled: 134 self._rename_bids_files(dest, old_sub_id, new_sub_id) 135 136 if self._is_canceled: 137 return 138 self.current_progress = 100 139 self.progress.emit(self.current_progress) 140 141 log.debug(f"BIDS folder copied and renamed to {new_sub_id}.") # <-- Log aggiornato 142 self.finished.emit() 143 return 144 145 # Case B: folder contains only subfolders (possible multi-patient) 146 subfolders = [ 147 os.path.join(folder_path, d) 148 for d in os.listdir(folder_path) 149 if os.path.isdir(os.path.join(folder_path, d)) 150 ] 151 152 # If directory contains just subfolders and no direct nifti/dicom files, decide: 153 # - If subfolders belong to a single subject (multiple series in separate folders), 154 # treat whole folder as one subject. 155 if subfolders and not any( 156 self._is_nifti_file(f) or self._is_dicom_file(os.path.join(folder_path, f)) 157 for f in os.listdir(folder_path) 158 ): 159 160 # If subfolders are multiple DICOM series belonging to the same patient, 161 # process entire root as a single patient (e.g., MR + PET series in different 162 # DICOM folder exports). 163 if self._subfolders_belong_to_single_subject(subfolders): 164 log.debug(f"Single subject spread across {len(subfolders)} DICOM folders in: {folder_path}") 165 166 # Process the root folder as single subject 167 self._process_single_patient_folder(folder_path) 168 if self._is_canceled: 169 return 170 self.progress.emit(100) 171 self.finished.emit() 172 return 173 174 # Otherwise treat each subfolder as separate patient and import them 175 log.debug(f"Multiple patient folders detected in: {folder_path}") 176 folders_num = len(subfolders) 177 progress_for_folder = 90 / folders_num 178 self.current_progress = 10 179 180 for subfolder in subfolders: 181 if self._is_canceled: 182 return 183 self._handle_import(subfolder) 184 if self._is_canceled: 185 return 186 self.current_progress += progress_for_folder 187 self.progress.emit(self.current_progress) 188 189 if self._is_canceled: 190 return 191 self.progress.emit(100) 192 self.finished.emit() 193 return 194 195 # Case C: folder may contain files (NIfTI, DICOM, misc) -> collect and convert 196 nifti_files = [] 197 dicom_files = [] 198 199 base_folder_name = os.path.basename(os.path.normpath(folder_path)) 200 201 # create temporary directory for conversion and intermediate files 202 temp_dir = tempfile.mkdtemp() 203 temp_base_dir = os.path.join(temp_dir, base_folder_name) 204 205 # Walk the folder, copy non-medical files into the temp tree and list nifti/dicom 206 for root, _, files in os.walk(folder_path): 207 for file in files: 208 if self._is_canceled: 209 # cleanup and exit early when cancelled 210 shutil.rmtree(temp_dir, ignore_errors=True) 211 return 212 file_path = os.path.join(root, file) 213 214 # Keep folder structure inside temp_base_dir 215 relative_path = os.path.relpath(root, folder_path) 216 dest_dir = os.path.join(temp_base_dir, relative_path) 217 os.makedirs(dest_dir, exist_ok=True) 218 219 if self._is_nifti_file(file): 220 # record source and destination pair for later copy 221 nifti_files.append((file_path, os.path.join(dest_dir, file))) 222 elif self._is_dicom_file(file_path): 223 # record dicom file for conversion 224 dicom_files.append(file_path) 225 else: 226 # other files (sidecars, metadata, reports) are copied directly 227 shutil.copy2(file_path, os.path.join(dest_dir, file)) 228 log.debug(f"Imported other file: {os.path.join(relative_path, file)}") 229 230 # Update progress after scan 231 self.current_progress = 20 232 self.progress.emit(self.current_progress) 233 234 # Copy any found NIfTI files into temp structure and update progress incrementally 235 nifti_num = len(nifti_files) 236 if nifti_num > 0: 237 progress_per_nifti = int(40 / nifti_num) 238 for src, dest in nifti_files: 239 if self._is_canceled: 240 shutil.rmtree(temp_dir, ignore_errors=True) 241 return 242 shutil.copy2(src, dest) 243 self.current_progress += progress_per_nifti 244 self.progress.emit(self.current_progress) 245 log.debug(f"Imported NIfTI file: {os.path.relpath(dest, temp_base_dir)}") 246 247 # Midpoint progress 248 self.current_progress = 60 249 self.progress.emit(self.current_progress) 250 251 if self._is_canceled: 252 return 253 254 # If DICOM files were found, convert the original folder to NIfTI into the temp tree 255 if dicom_files: 256 self._convert_dicom_folder_to_nifti(folder_path, temp_base_dir) 257 258 # small progress bump 259 self.current_progress += 10 260 self.progress.emit(self.current_progress) 261 262 # Now transform the temp folder into a BIDS-like structure under workspace 263 self._convert_to_bids_structure(temp_base_dir) 264 265 # Cleanup temporary directory after conversion/copy 266 shutil.rmtree(temp_dir, ignore_errors=True) 267 268 elif len(self.folders_path) == 0: 269 # empty input - emit an error 270 raise Exception(QCoreApplication.translate("Threads", "Invalid folders path")) 271 elif len(self.folders_path) > 1: 272 # multiple root paths - handle each independently 273 progress_for_folder = int(90 / len(self.folders_path)) 274 for path in self.folders_path: 275 if self._is_canceled: 276 return 277 self._handle_import(path) 278 self.current_progress += progress_for_folder 279 self.progress.emit(self.current_progress) 280 else: 281 # fallback guard - shouldn't be reached 282 raise Exception(QCoreApplication.translate("Threads", "Invalid folders path")) 283 284 # Finalize progress and signal completion 285 self.current_progress = 100 286 self.progress.emit(self.current_progress) 287 self.finished.emit() 288 289 except Exception as e: 290 # If cancellation was requested we avoid emitting an error 291 if self._is_canceled: 292 log.info("Import interrupted by user, no error emitted.") 293 return 294 # Otherwise, emit error and log it 295 self.error.emit(str(e)) 296 log.error(f"Error: {str(e)}") 297 298 299 def _handle_import(self, folder_path): 300 """ 301 Handle the import of a single folder (which may itself contain subfolders). 302 303 This method performs heuristics to determine what kind of folder we are dealing with: 304 1. A BIDS folder — copy it directly. 305 2. A folder with direct medical files (NIfTI or DICOM) — process as one patient. 306 3. A folder containing subfolders — analyze if they belong to one or more patients. 307 - If subfolders are DICOM series of the same patient, process as one. 308 - If subfolders look like different patients (e.g., named 'sub-01', 'patient_02'), process each separately. 309 - Otherwise, default to single-patient mode. 310 311 Args: 312 folder_path (str): Path to the folder to process. 313 """ 314 if not os.path.isdir(folder_path): 315 return 316 if self._is_canceled: 317 return 318 319 log.debug(f"Processing folder: {folder_path}") 320 321 # Case 1: The folder is already BIDS-organized -> just copy 322 if self._is_bids_folder(folder_path): 323 log.debug(f"BIDS structure detected in: {folder_path}") 324 325 old_sub_id = os.path.basename(os.path.normpath(folder_path)) 326 new_sub_id = self._get_next_sub_id() 327 dest = os.path.join(self.workspace_path, new_sub_id) 328 shutil.copytree(folder_path, dest, dirs_exist_ok=True) 329 330 if not self._is_canceled: 331 self._rename_bids_files(dest, old_sub_id, new_sub_id) 332 333 log.debug(f"BIDS folder copied and renamed to {new_sub_id}.") # <-- Log aggiornato 334 self.finished.emit() 335 return 336 337 # Case 2: Does the folder directly contain DICOM/NIfTI files (no subfolders)? 338 has_direct_medical_files = False 339 for file in os.listdir(folder_path): 340 file_path = os.path.join(folder_path, file) 341 if os.path.isfile(file_path): 342 if self._is_nifti_file(file) or self._is_dicom_file(file_path): 343 has_direct_medical_files = True 344 break 345 346 if self._is_canceled: 347 return 348 349 # If true, treat this as a single-patient folder 350 if has_direct_medical_files: 351 log.debug(f"Direct medical files found, processing as single patient: {folder_path}") 352 self._process_single_patient_folder(folder_path) 353 return 354 355 if self._is_canceled: 356 return 357 358 # Case 3: Folder has subdirectories — check what they represent 359 subfolders = [ 360 os.path.join(folder_path, d) 361 for d in os.listdir(folder_path) 362 if os.path.isdir(os.path.join(folder_path, d)) 363 ] 364 if not subfolders: 365 log.debug(f"No subfolders found in: {folder_path}") 366 return 367 if self._is_canceled: 368 return 369 370 # Check if all subfolders are DICOM series of the same patient 371 if self._are_dicom_series_of_same_patient(subfolders): 372 log.debug(f"DICOM series of same patient detected: {folder_path}") 373 self._process_single_patient_folder(folder_path) 374 return 375 376 if self._is_canceled: 377 return 378 379 # If subfolder names suggest multiple patients (e.g., 'sub-01', 'patient2'), process separately 380 if self._subfolders_look_like_different_patients(subfolders): 381 log.debug(f"Multiple patients detected by folder names: {folder_path}") 382 for subfolder in subfolders: 383 self._handle_import(subfolder) 384 return 385 386 if self._is_canceled: 387 return 388 389 # Fallback — treat as single-patient data 390 log.debug(f"Default: treating as single patient: {folder_path}") 391 self._process_single_patient_folder(folder_path) 392 393 394 def _are_dicom_series_of_same_patient(self, subfolders): 395 """ 396 Check if multiple subfolders represent DICOM series from the same patient. 397 398 Heuristics: 399 - For each subfolder containing DICOM files, extract the PatientID or PatientName. 400 - If only one unique ID or name is found among all, we assume it's the same patient. 401 402 Args: 403 subfolders (list[str]): List of subfolder paths to inspect. 404 405 Returns: 406 bool: True if the subfolders seem to belong to the same patient. 407 """ 408 log.debug(f"Checking if {len(subfolders)} subfolders are DICOM series of same patient...") 409 410 patient_ids = set() 411 dicom_folders_count = 0 412 413 for subfolder in subfolders: 414 log.debug(f" Checking subfolder: {os.path.basename(subfolder)}") 415 416 has_dicom_in_subfolder = False 417 first_dicom_file = None 418 419 # Look for at least one DICOM file to sample metadata 420 for root, _, files in os.walk(subfolder): 421 for file in files: 422 file_path = os.path.join(root, file) 423 if self._is_dicom_file(file_path): 424 has_dicom_in_subfolder = True 425 first_dicom_file = file_path 426 break 427 if has_dicom_in_subfolder: 428 break 429 430 if self._is_canceled: 431 return 432 433 if has_dicom_in_subfolder: 434 dicom_folders_count += 1 435 log.debug(f" Found DICOM files in: {os.path.basename(subfolder)}") 436 437 try: 438 # Read DICOM metadata 439 dcm = pydicom.dcmread(first_dicom_file, stop_before_pixels=True) 440 patient_id = getattr(dcm, 'PatientID', None) 441 patient_name = getattr(dcm, 'PatientName', None) 442 masked_patient_name = f"{patient_name[:2]}...{patient_name[-2:]}" if patient_name else "unknown" 443 444 log.debug(f" Patient ID: {patient_id}, Patient Name: {masked_patient_name}") 445 446 if patient_id: 447 patient_ids.add(patient_id) 448 elif patient_name: 449 patient_ids.add(str(patient_name)) 450 451 except Exception as e: 452 log.debug(f" Could not read DICOM metadata: {e}") 453 else: 454 log.debug(f" No DICOM files found in: {os.path.basename(subfolder)}") 455 456 log.debug(f"Found {dicom_folders_count} folders with DICOM, {len(patient_ids)} unique patient IDs: {patient_ids}") 457 458 # Criteria: at least one DICOM folder, and at most one unique PatientID 459 is_same_patient = dicom_folders_count > 0 and len(patient_ids) <= 1 460 log.debug(f" Result: is_same_patient = {is_same_patient}") 461 462 return is_same_patient 463 464 465 def _subfolders_look_like_different_patients(self, subfolders): 466 """ 467 Heuristic that checks if subfolder names suggest multiple patients. 468 469 Example folder names that trigger 'multi-patient' assumption: 470 sub-01/, patient_1/, subj3/, p_01/, subject_2/ 471 472 Args: 473 subfolders (list[str]): List of subfolder paths. 474 475 Returns: 476 bool: True if subfolder names suggest multiple subjects. 477 """ 478 patient_like_prefixes = ['sub-', 'patient', 'subj', 'p_', 'subject'] 479 480 for subfolder in subfolders: 481 folder_name = os.path.basename(subfolder).lower() 482 if any(folder_name.startswith(prefix) for prefix in patient_like_prefixes): 483 log.debug(f"Folder name '{folder_name}' suggests different patients") 484 return True 485 486 return False 487 488 489 def _process_single_patient_folder(self, folder_path): 490 """ 491 Process a folder assumed to contain data for a single patient. 492 493 This includes: 494 - Copying NIfTI files directly 495 - Converting DICOM series to NIfTI 496 - Organizing results into a BIDS-like folder structure 497 - Cleaning up temporary directories 498 499 Args: 500 folder_path (str): Path to the patient's folder. 501 """ 502 if self._is_canceled: 503 return 504 505 log.debug(f"Processing single patient folder: {folder_path}") 506 507 nifti_files = [] 508 dicom_files = [] 509 510 base_folder_name = os.path.basename(os.path.normpath(folder_path)) 511 512 # Temporary directory for conversion steps 513 temp_dir = tempfile.mkdtemp() 514 temp_base_dir = os.path.join(temp_dir, base_folder_name) 515 516 # Walk through the folder and collect relevant files 517 for root, _, files in os.walk(folder_path): 518 if self._is_canceled: 519 shutil.rmtree(temp_dir, ignore_errors=True) 520 return 521 for file in files: 522 if self._is_canceled: 523 shutil.rmtree(temp_dir, ignore_errors=True) 524 return 525 526 file_path = os.path.join(root, file) 527 relative_path = os.path.relpath(root, folder_path) 528 dest_dir = os.path.join(temp_base_dir, relative_path) 529 os.makedirs(dest_dir, exist_ok=True) 530 531 if self._is_nifti_file(file): 532 nifti_files.append((file_path, os.path.join(dest_dir, file))) 533 elif self._is_dicom_file(file_path): 534 dicom_files.append(file_path) 535 else: 536 if self._is_canceled: 537 return 538 shutil.copy2(file_path, os.path.join(dest_dir, file)) 539 log.debug(f"Imported other file: {os.path.join(relative_path, file)}") 540 541 if self._is_canceled: 542 shutil.rmtree(temp_dir, ignore_errors=True) 543 return 544 545 # Copy NIfTI files to the temp structure 546 for src, dest in nifti_files: 547 if self._is_canceled: 548 shutil.rmtree(temp_dir, ignore_errors=True) 549 return 550 shutil.copy2(src, dest) 551 log.debug(f"Imported NIfTI file: {os.path.relpath(dest, temp_base_dir)}") 552 553 # If DICOMs exist, convert them into NIfTI using dcm2niix 554 if dicom_files: 555 log.debug(f"Converting {len(dicom_files)} DICOM files to NIfTI...") 556 self._convert_dicom_folder_to_nifti(folder_path, temp_base_dir) 557 558 if self._is_canceled: 559 shutil.rmtree(temp_dir, ignore_errors=True) 560 return 561 562 # Convert the temporary NIfTI/JSON folder into a BIDS structure 563 log.debug(f"Converting to BIDS structure...") 564 self._convert_to_bids_structure(temp_base_dir) 565 566 # Clean up 567 shutil.rmtree(temp_dir, ignore_errors=True) 568 569 # Optionally update UI buttons if context provides callback 570 if self.context and "update_main_buttons" in self.context: 571 self.context["update_main_buttons"]() 572 log.debug("Import completed for single patient.") 573 574 def _convert_dicom_folder_to_nifti(self, dicom_folder, output_folder): 575 """ 576 Converts a DICOM folder into NIfTI format using the external tool `dcm2niix`. 577 578 This function is executed for each single-patient folder that contains DICOM files. 579 It runs `dcm2niix` in a subprocess and places the resulting NIfTI and JSON metadata files 580 in the destination folder. 581 582 Args: 583 src_folder (str): The source folder containing DICOM files. 584 dest_folder (str): The folder where NIfTI files should be saved. 585 """ 586 if os.path.isdir(output_folder): 587 for filename in os.listdir(output_folder): 588 if self._is_canceled: 589 return 590 file_path = os.path.join(output_folder, filename) 591 try: 592 if os.path.isfile(file_path) or os.path.islink(file_path): 593 os.remove(file_path) 594 elif os.path.isdir(file_path): 595 shutil.rmtree(file_path) 596 except Exception as e: 597 log.debug(f"Errore durante la rimozione di {file_path}: {e}") 598 else: 599 os.makedirs(output_folder, exist_ok=True) 600 601 if self._is_canceled: 602 shutil.rmtree(output_folder, ignore_errors=True) 603 return 604 self.current_progress += 10 605 self.progress.emit(self.current_progress) 606 try: 607 log.debug("DCM2NIIX path:" + get_bin_path("dcm2niix")) 608 command = [ 609 get_bin_path("dcm2niix"), 610 "-f", "%p_%s", # Naming format 611 "-p", "y", # Preserve original acquisition order 612 "-z", "y", # Compress output as .nii.gz 613 "-o", output_folder, # Destination folder 614 dicom_folder # Source DICOM folder 615 ] 616 617 self.process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 618 stdout, stderr = self.process.communicate() 619 if self.process.returncode != 0: 620 raise RuntimeError(f"dcm2niix failed: {stderr.decode()}") 621 622 self.current_progress += 10 623 self.progress.emit(self.current_progress) 624 log.debug(f"Converted DICOM in {dicom_folder} to NIfTI using dcm2niix (optimized)") 625 except subprocess.CalledProcessError as e: 626 log.error(f"Conversion error: {e}") 627 raise RuntimeError(QCoreApplication.translate("Threads", "Conversion error: {0}").format(e)) from e 628 except Exception as e: 629 if self._is_canceled: 630 log.info("Import canceled") 631 raise Exception(QCoreApplication.translate("Threads", "Import canceled")) 632 else: 633 log.info(f"Import failed: {e}") 634 raise RuntimeError( 635 QCoreApplication.translate("Threads", "Failed to convert DICOM: {0}").format(e)) from e 636 637 def _convert_to_bids_structure(self, input_folder): 638 """ 639 Converts a folder containing NIfTI and JSON files into a BIDS-like directory structure. 640 641 The function scans all NIfTI files in the given folder and organizes them under: 642 derivatives/ 643 └── deep_learning_seg/ 644 └── sub-XXX/ 645 └── anat/ 646 647 Each new import increments the patient ID number (`sub-001`, `sub-002`, ...). 648 649 Args: 650 source_folder (str): Path to the folder containing NIfTI/JSON files to reorganize. 651 """ 652 if self._is_canceled: 653 return 654 all_json_files = [] 655 for root, _, files in os.walk(input_folder): 656 for file in files: 657 if file.endswith(".json"): 658 all_json_files.append(os.path.join(root, file)) 659 660 if not all_json_files: 661 log.debug("[BIDS] Nessun file JSON trovato. Conversione annullata.") 662 return 663 664 sub_id = self._get_next_sub_id() 665 dest_sub_dir = os.path.join(self.workspace_path, sub_id) 666 os.makedirs(dest_sub_dir, exist_ok=True) 667 668 pet_run_counter = 1 669 mr_run_counter = {} 670 671 for json_path in all_json_files: 672 nii_path = json_path.replace(".json", ".nii.gz") 673 if not os.path.exists(nii_path): 674 log.debug(f"[BIDS] Skipping: no matching NIfTI for {json_path}") 675 continue 676 677 with open(json_path, "r") as f: 678 metadata = json.load(f) 679 680 modality = metadata.get("Modality", "").upper() 681 682 if modality == "MR": 683 series_desc = metadata.get("ProtocolName", "").lower() 684 685 if "flair" in series_desc: 686 suffix = "flair" 687 elif "t1" in series_desc: 688 suffix = "T1w" 689 elif "t2" in series_desc: 690 suffix = "T2w" 691 else: 692 suffix = "T1w" # fallback 693 694 mr_run_counter.setdefault(suffix, 1) 695 run_label = f"run-{mr_run_counter[suffix]}" 696 mr_run_counter[suffix] += 1 697 698 anat_dir = os.path.join(dest_sub_dir, "anat") 699 os.makedirs(anat_dir, exist_ok=True) 700 701 new_base = f"{sub_id}_{run_label}_{suffix}" 702 703 shutil.copy2(nii_path, os.path.join(anat_dir, f"{new_base}.nii.gz")) 704 shutil.copy2(json_path, os.path.join(anat_dir, f"{new_base}.json")) 705 706 elif modality == "PT": 707 raw_trc = metadata.get("Radiopharmaceutical", "unknown") 708 # estrai la prima parola alfabetica come tracciante 709 trc = re.sub(r'[^a-zA-Z]', '', raw_trc.split()[0]).lower() 710 trc_label = f"trc-{trc}" if trc else "trc-unknown" 711 712 raw_task = metadata.get("SeriesDescription", "unknown") 713 task_clean = re.sub(r'[^a-zA-Z0-9]', '', raw_task.lower()) 714 task_label = f"task-{task_clean}" if task_clean else "task-unknown" 715 716 frame_durations = metadata.get("FrameDuration", []) 717 frame_times = metadata.get("FrameReferenceTime", []) 718 if (isinstance(frame_durations, (list, tuple)) and len(frame_durations) > 1) or \ 719 (isinstance(frame_times, (list, tuple)) and len(frame_times) > 1): 720 acq_label = "rec-acdync" 721 ses_label = "ses-02" 722 else: 723 acq_label = "rec-acstat" 724 ses_label = "ses-01" 725 726 pet_dir = os.path.join(dest_sub_dir, ses_label, "pet") 727 os.makedirs(pet_dir, exist_ok=True) 728 729 run_label = f"run-{pet_run_counter}" 730 731 # Add: 732 # trc_label for the tracer 733 # acq_label for the acquisition type (dynamic or static) 734 new_base = f"{sub_id}_{task_label}_{run_label}_pet" 735 736 shutil.copy2(nii_path, os.path.join(pet_dir, f"{new_base}.nii.gz")) 737 shutil.copy2(json_path, os.path.join(pet_dir, f"{new_base}.json")) 738 739 pet_run_counter += 1 740 741 log.debug(f"[BIDS] Imported {sub_id} in BIDS structure.") 742 743 def _is_bids_folder(self, folder_path): 744 """ 745 Detects whether a folder already follows a BIDS structure. 746 747 Heuristic check: 748 - Folder contains 'sub-' directories. 749 - Inside those, there are 'anat', 'func', or 'dwi' subdirectories with NIfTI files. 750 751 Args: 752 folder_path (str): Path to check. 753 754 Returns: 755 bool: True if the folder appears to be BIDS-compatible. 756 """ 757 base = os.path.basename(folder_path.rstrip(os.sep)) 758 759 if not base.startswith("sub-"): 760 return False 761 762 for session_or_modality in os.listdir(folder_path): 763 session_path = os.path.join(folder_path, session_or_modality) 764 if os.path.isdir(session_path): 765 for modality in os.listdir(session_path): 766 modality_path = os.path.join(session_path, modality) 767 if os.path.isdir(modality_path): 768 if any(f.endswith(".nii") or f.endswith(".nii.gz") for f in os.listdir(modality_path)): 769 return True 770 771 if any(f.endswith(".nii") or f.endswith(".nii.gz") for f in os.listdir(session_path)): 772 return True 773 774 return False 775 776 def _get_next_sub_id(self): 777 """ 778 Generates the next available subject ID for the BIDS dataset. 779 780 It scans existing subfolders in the workspace for names like `sub-001`, `sub-002`, ... 781 and returns the next number in sequence. 782 783 Returns: 784 str: The next subject ID, e.g. 'sub-004'. 785 """ 786 existing = [ 787 name for name in os.listdir(self.workspace_path) 788 if os.path.isdir(os.path.join(self.workspace_path, name)) and name.startswith("sub-") 789 ] 790 numbers = sorted([ 791 int(name.split("-")[1]) for name in existing 792 if name.split("-")[1].isdigit() 793 ]) 794 next_number = numbers[-1] + 1 if numbers else 1 795 return f"sub-{next_number:02d}" 796 797 def _rename_bids_files(self, destination_folder, old_sub_id, new_sub_id): 798 """ 799 Recursively scans a copied BIDS folder and renames the files 800 so that they match the new subject ID. 801 802 Example: sub-03_T1w.nii.gz -> sub-09_T1w.nii.gz 803 804 Args: 805 destination_folder (str): The path to the newly copied subject folder (e.g., /path/to/workspace/sub-09). 806 old_sub_id (str): The original subject ID (e.g., "sub-03"). 807 new_sub_id (str): The new subject ID (e.g., "sub-09"). 808 """ 809 # If the subject IDs are identical, no renaming is necessary 810 if old_sub_id == new_sub_id: 811 log.debug(f"Identical subject ids ({new_sub_id}), no renaming needed.") 812 return 813 814 log.debug(f"File BIDS renamed from {old_sub_id} to {new_sub_id} in {destination_folder}") 815 816 # Recursively scan the entire structure of the new subject’s folder 817 for root, _, files in os.walk(destination_folder): 818 for filename in files: 819 # Stop renaming if a cancellation flag is triggered 820 if self._is_canceled: 821 return 822 823 # Check if the filename contains the old subject ID 824 if old_sub_id in filename: 825 try: 826 # Create the new filename by replacing the old ID with the new one 827 # Replace only the first occurrence for safety 828 new_filename = filename.replace(old_sub_id, new_sub_id, 1) 829 830 old_filepath = os.path.join(root, filename) 831 new_filepath = os.path.join(root, new_filename) 832 833 # Perform the actual file rename 834 os.rename(old_filepath, new_filepath) 835 log.debug(f"Renamed: {old_filepath} -> {new_filepath}") 836 837 # Handle OS-level errors (e.g., permission or file access issues) 838 except OSError as e: 839 log.warning(f"Failed renaming the file {filename}: {e}") 840 # Catch any other unexpected errors 841 except Exception as e: 842 log.error(f"Unexpected error renaming {filename}: {e}") 843 844 def _is_dicom_file(self, file_path): 845 """ 846 Determines whether a given file is a valid DICOM file. 847 848 The function attempts to read the file header using `pydicom`. 849 If the file can be parsed as a DICOM dataset (i.e., it contains 850 valid DICOM metadata, including the "DICM" marker at byte offset 128), 851 the function returns True. 852 853 Args: 854 file_path (str): Path to the file to check. 855 856 Returns: 857 bool: True if the file is a readable DICOM file, False otherwise. 858 """ 859 try: 860 dcm = pydicom.dcmread(file_path, stop_before_pixels=True) 861 return True 862 except Exception: 863 return False 864 865 866 def _is_nifti_file(self, file_path): 867 """ 868 Check whether a file is a NIfTI file. 869 870 Args: 871 file_path (str): File path to test. 872 873 Returns: 874 bool: True if the file ends with '.nii' or '.nii.gz'. 875 """ 876 return file_path.endswith(".nii") or file_path.endswith(".nii.gz") 877 878 def _subfolders_belong_to_single_subject(self, subfolders): 879 """ 880 Heuristics: Subfolders belong to the same subject if: 881 - there is at most 1 non-empty PatientID AND at most 1 non-empty PatientName, or 882 - Name + BirthDate + Sex match (<=1), or 883 - a typical MR+PT pattern ({'MR','PT'} mode) with consistent BirthDate/Sex and 884 no clear signs of multiple subjects. 885 """ 886 import re 887 888 ids = set() 889 names = set() 890 births = set() 891 sexes = set() 892 modalities = set() 893 894 found_any_dicom = False 895 896 for sub in subfolders: 897 first_dcm = None 898 for root, _, files in os.walk(sub): 899 for file in files: 900 fp = os.path.join(root, file) 901 if self._is_dicom_file(fp): 902 first_dcm = fp 903 break 904 if first_dcm: 905 break 906 907 if not first_dcm: 908 continue 909 910 found_any_dicom = True 911 try: 912 dcm = pydicom.dcmread(first_dcm, stop_before_pixels=True, force=True) 913 except Exception: 914 continue 915 916 pid = (str(getattr(dcm, 'PatientID', '') or '')).strip().lower() 917 pname = (str(getattr(dcm, 'PatientName', '') or '')).strip().lower() 918 pname = re.sub(r'\\s+', '', pname) # normalizza spazi 919 bdate = (str(getattr(dcm, 'PatientBirthDate', '') or '')).strip() 920 sex = (str(getattr(dcm, 'PatientSex', '') or '')).strip().upper() 921 mod = (str(getattr(dcm, 'Modality', '') or '')).strip().upper() 922 923 if pid: 924 ids.add(pid) 925 if pname: 926 names.add(pname) 927 if bdate: 928 births.add(bdate) 929 if sex: 930 sexes.add(sex) 931 if mod: 932 modalities.add(mod) 933 934 if not found_any_dicom: 935 return False 936 937 if len(ids) <= 1 and len(names) <= 1: 938 return True 939 if len(names) <= 1 and len(births) <= 1 and len(sexes) <= 1: 940 return True 941 942 if modalities.issuperset({'MR', 'PT'}) and len(births) <= 1 and len(sexes) <= 1: 943 return True 944 945 return False 946 947 def cancel(self): 948 """ 949 Gracefully cancels the ongoing import process. 950 951 This method sets the internal `_is_canceled` flag to True, 952 terminates any active subprocess (like `dcm2niix`), and logs the cancellation. 953 954 It’s thread-safe — typically called from the GUI (e.g., a 'Cancel' button) 955 while the import thread is still running. 956 """ 957 self._is_canceled = True 958 if hasattr(self, 'process') and self.process is not None: 959 self.process.terminate() 960 self.process.wait(1) 961 self.process.kill() 962 log.info("Import Canceled.")
34class ImportThread(QThread): 35 """ 36 Worker thread that imports one or more folders into the application's workspace. 37 38 The thread emits: 39 - finished(): when the import completes successfully 40 - error(str): in case of an exception 41 - progress(int): progress updates (0-100) 42 43 The thread is cancellable via the `cancel()` method which sets an internal 44 flag and attempts to terminate any running external process (dcm2niix). 45 """ 46 47 # Signal emitted when an operation completes successfully 48 finished = pyqtSignal() 49 """**Signal():** Emitted when the operation finishes successfully and no further progress updates will occur.""" 50 51 # Signal emitted when an error occurs during processing 52 error = pyqtSignal(str) 53 """**Signal(str):** Emitted when an error occurs. 54 Parameter: 55 - `str`: Description or message of the encountered error. 56 """ 57 58 # Signal emitted to report progress updates 59 progress = pyqtSignal(int) 60 """**Signal(int):** Emitted periodically to indicate task progress. 61 Parameter: 62 - `int`: Current progress percentage (0–100). 63 """ 64 65 def __init__(self, context, folders_path, workspace_path): 66 """ 67 Initialize the import thread. 68 69 Args: 70 context (dict): application context (used for callbacks / GUI updates). 71 folders_path (list[str]): list of input paths (files or folders) to import. 72 workspace_path (str): path to the target workspace directory. 73 """ 74 super().__init__() 75 self.context = context 76 # allow either single path or list of paths; stored as list to keep API stable 77 self.folders_path = folders_path 78 self.workspace_path = workspace_path 79 80 # internal state for progress and cancellation 81 self.current_progress = 0 82 self._is_canceled = False 83 84 # reference to subprocess.Popen used during DICOM conversion (so we can terminate it) 85 self.process = None 86 87 88 def run(self): 89 """ 90 Main thread entry point. 91 92 This method orchestrates the following high-level behaviors: 93 - If a single folder is provided: 94 * Detect BIDS folder and copy directly 95 * Else detect whether folder contains multiple patient subfolders or 96 a single subject split across many DICOM series 97 * Convert found DICOM series to NIfTI using dcm2niix in a temporary 98 directory, then convert NIfTI + JSON to a BIDS-like structure inside 99 the workspace. 100 - If multiple folders are provided: 101 * Process each folder independently with `_handle_import`. 102 Progress is emitted periodically. Exceptions are forwarded via the 103 `error` signal. 104 """ 105 try: 106 # initial small progress step 107 self.current_progress = 10 108 self.progress.emit(self.current_progress) 109 110 # Validate input list length and branch accordingly 111 if len(self.folders_path) == 1: 112 # single path handling 113 folder_path = self.folders_path[0] 114 115 # Basic validation 116 if not os.path.isdir(folder_path): 117 raise Exception(QCoreApplication.translate("Threads", "Invalid folders path")) 118 119 # Case A: already a BIDS subject folder -> copy directly 120 if self._is_bids_folder(folder_path): 121 log.debug(f"BIDS structure detected in: {folder_path}") 122 123 if self._is_canceled: 124 return 125 self.current_progress = 50 126 self.progress.emit(self.current_progress) 127 128 # choose next sub-id (e.g. sub-03) and copy tree 129 old_sub_id = os.path.basename(os.path.normpath(folder_path)) 130 new_sub_id = self._get_next_sub_id() 131 dest = os.path.join(self.workspace_path, new_sub_id) 132 shutil.copytree(folder_path, dest, dirs_exist_ok=True) 133 134 if not self._is_canceled: 135 self._rename_bids_files(dest, old_sub_id, new_sub_id) 136 137 if self._is_canceled: 138 return 139 self.current_progress = 100 140 self.progress.emit(self.current_progress) 141 142 log.debug(f"BIDS folder copied and renamed to {new_sub_id}.") # <-- Log aggiornato 143 self.finished.emit() 144 return 145 146 # Case B: folder contains only subfolders (possible multi-patient) 147 subfolders = [ 148 os.path.join(folder_path, d) 149 for d in os.listdir(folder_path) 150 if os.path.isdir(os.path.join(folder_path, d)) 151 ] 152 153 # If directory contains just subfolders and no direct nifti/dicom files, decide: 154 # - If subfolders belong to a single subject (multiple series in separate folders), 155 # treat whole folder as one subject. 156 if subfolders and not any( 157 self._is_nifti_file(f) or self._is_dicom_file(os.path.join(folder_path, f)) 158 for f in os.listdir(folder_path) 159 ): 160 161 # If subfolders are multiple DICOM series belonging to the same patient, 162 # process entire root as a single patient (e.g., MR + PET series in different 163 # DICOM folder exports). 164 if self._subfolders_belong_to_single_subject(subfolders): 165 log.debug(f"Single subject spread across {len(subfolders)} DICOM folders in: {folder_path}") 166 167 # Process the root folder as single subject 168 self._process_single_patient_folder(folder_path) 169 if self._is_canceled: 170 return 171 self.progress.emit(100) 172 self.finished.emit() 173 return 174 175 # Otherwise treat each subfolder as separate patient and import them 176 log.debug(f"Multiple patient folders detected in: {folder_path}") 177 folders_num = len(subfolders) 178 progress_for_folder = 90 / folders_num 179 self.current_progress = 10 180 181 for subfolder in subfolders: 182 if self._is_canceled: 183 return 184 self._handle_import(subfolder) 185 if self._is_canceled: 186 return 187 self.current_progress += progress_for_folder 188 self.progress.emit(self.current_progress) 189 190 if self._is_canceled: 191 return 192 self.progress.emit(100) 193 self.finished.emit() 194 return 195 196 # Case C: folder may contain files (NIfTI, DICOM, misc) -> collect and convert 197 nifti_files = [] 198 dicom_files = [] 199 200 base_folder_name = os.path.basename(os.path.normpath(folder_path)) 201 202 # create temporary directory for conversion and intermediate files 203 temp_dir = tempfile.mkdtemp() 204 temp_base_dir = os.path.join(temp_dir, base_folder_name) 205 206 # Walk the folder, copy non-medical files into the temp tree and list nifti/dicom 207 for root, _, files in os.walk(folder_path): 208 for file in files: 209 if self._is_canceled: 210 # cleanup and exit early when cancelled 211 shutil.rmtree(temp_dir, ignore_errors=True) 212 return 213 file_path = os.path.join(root, file) 214 215 # Keep folder structure inside temp_base_dir 216 relative_path = os.path.relpath(root, folder_path) 217 dest_dir = os.path.join(temp_base_dir, relative_path) 218 os.makedirs(dest_dir, exist_ok=True) 219 220 if self._is_nifti_file(file): 221 # record source and destination pair for later copy 222 nifti_files.append((file_path, os.path.join(dest_dir, file))) 223 elif self._is_dicom_file(file_path): 224 # record dicom file for conversion 225 dicom_files.append(file_path) 226 else: 227 # other files (sidecars, metadata, reports) are copied directly 228 shutil.copy2(file_path, os.path.join(dest_dir, file)) 229 log.debug(f"Imported other file: {os.path.join(relative_path, file)}") 230 231 # Update progress after scan 232 self.current_progress = 20 233 self.progress.emit(self.current_progress) 234 235 # Copy any found NIfTI files into temp structure and update progress incrementally 236 nifti_num = len(nifti_files) 237 if nifti_num > 0: 238 progress_per_nifti = int(40 / nifti_num) 239 for src, dest in nifti_files: 240 if self._is_canceled: 241 shutil.rmtree(temp_dir, ignore_errors=True) 242 return 243 shutil.copy2(src, dest) 244 self.current_progress += progress_per_nifti 245 self.progress.emit(self.current_progress) 246 log.debug(f"Imported NIfTI file: {os.path.relpath(dest, temp_base_dir)}") 247 248 # Midpoint progress 249 self.current_progress = 60 250 self.progress.emit(self.current_progress) 251 252 if self._is_canceled: 253 return 254 255 # If DICOM files were found, convert the original folder to NIfTI into the temp tree 256 if dicom_files: 257 self._convert_dicom_folder_to_nifti(folder_path, temp_base_dir) 258 259 # small progress bump 260 self.current_progress += 10 261 self.progress.emit(self.current_progress) 262 263 # Now transform the temp folder into a BIDS-like structure under workspace 264 self._convert_to_bids_structure(temp_base_dir) 265 266 # Cleanup temporary directory after conversion/copy 267 shutil.rmtree(temp_dir, ignore_errors=True) 268 269 elif len(self.folders_path) == 0: 270 # empty input - emit an error 271 raise Exception(QCoreApplication.translate("Threads", "Invalid folders path")) 272 elif len(self.folders_path) > 1: 273 # multiple root paths - handle each independently 274 progress_for_folder = int(90 / len(self.folders_path)) 275 for path in self.folders_path: 276 if self._is_canceled: 277 return 278 self._handle_import(path) 279 self.current_progress += progress_for_folder 280 self.progress.emit(self.current_progress) 281 else: 282 # fallback guard - shouldn't be reached 283 raise Exception(QCoreApplication.translate("Threads", "Invalid folders path")) 284 285 # Finalize progress and signal completion 286 self.current_progress = 100 287 self.progress.emit(self.current_progress) 288 self.finished.emit() 289 290 except Exception as e: 291 # If cancellation was requested we avoid emitting an error 292 if self._is_canceled: 293 log.info("Import interrupted by user, no error emitted.") 294 return 295 # Otherwise, emit error and log it 296 self.error.emit(str(e)) 297 log.error(f"Error: {str(e)}") 298 299 300 def _handle_import(self, folder_path): 301 """ 302 Handle the import of a single folder (which may itself contain subfolders). 303 304 This method performs heuristics to determine what kind of folder we are dealing with: 305 1. A BIDS folder — copy it directly. 306 2. A folder with direct medical files (NIfTI or DICOM) — process as one patient. 307 3. A folder containing subfolders — analyze if they belong to one or more patients. 308 - If subfolders are DICOM series of the same patient, process as one. 309 - If subfolders look like different patients (e.g., named 'sub-01', 'patient_02'), process each separately. 310 - Otherwise, default to single-patient mode. 311 312 Args: 313 folder_path (str): Path to the folder to process. 314 """ 315 if not os.path.isdir(folder_path): 316 return 317 if self._is_canceled: 318 return 319 320 log.debug(f"Processing folder: {folder_path}") 321 322 # Case 1: The folder is already BIDS-organized -> just copy 323 if self._is_bids_folder(folder_path): 324 log.debug(f"BIDS structure detected in: {folder_path}") 325 326 old_sub_id = os.path.basename(os.path.normpath(folder_path)) 327 new_sub_id = self._get_next_sub_id() 328 dest = os.path.join(self.workspace_path, new_sub_id) 329 shutil.copytree(folder_path, dest, dirs_exist_ok=True) 330 331 if not self._is_canceled: 332 self._rename_bids_files(dest, old_sub_id, new_sub_id) 333 334 log.debug(f"BIDS folder copied and renamed to {new_sub_id}.") # <-- Log aggiornato 335 self.finished.emit() 336 return 337 338 # Case 2: Does the folder directly contain DICOM/NIfTI files (no subfolders)? 339 has_direct_medical_files = False 340 for file in os.listdir(folder_path): 341 file_path = os.path.join(folder_path, file) 342 if os.path.isfile(file_path): 343 if self._is_nifti_file(file) or self._is_dicom_file(file_path): 344 has_direct_medical_files = True 345 break 346 347 if self._is_canceled: 348 return 349 350 # If true, treat this as a single-patient folder 351 if has_direct_medical_files: 352 log.debug(f"Direct medical files found, processing as single patient: {folder_path}") 353 self._process_single_patient_folder(folder_path) 354 return 355 356 if self._is_canceled: 357 return 358 359 # Case 3: Folder has subdirectories — check what they represent 360 subfolders = [ 361 os.path.join(folder_path, d) 362 for d in os.listdir(folder_path) 363 if os.path.isdir(os.path.join(folder_path, d)) 364 ] 365 if not subfolders: 366 log.debug(f"No subfolders found in: {folder_path}") 367 return 368 if self._is_canceled: 369 return 370 371 # Check if all subfolders are DICOM series of the same patient 372 if self._are_dicom_series_of_same_patient(subfolders): 373 log.debug(f"DICOM series of same patient detected: {folder_path}") 374 self._process_single_patient_folder(folder_path) 375 return 376 377 if self._is_canceled: 378 return 379 380 # If subfolder names suggest multiple patients (e.g., 'sub-01', 'patient2'), process separately 381 if self._subfolders_look_like_different_patients(subfolders): 382 log.debug(f"Multiple patients detected by folder names: {folder_path}") 383 for subfolder in subfolders: 384 self._handle_import(subfolder) 385 return 386 387 if self._is_canceled: 388 return 389 390 # Fallback — treat as single-patient data 391 log.debug(f"Default: treating as single patient: {folder_path}") 392 self._process_single_patient_folder(folder_path) 393 394 395 def _are_dicom_series_of_same_patient(self, subfolders): 396 """ 397 Check if multiple subfolders represent DICOM series from the same patient. 398 399 Heuristics: 400 - For each subfolder containing DICOM files, extract the PatientID or PatientName. 401 - If only one unique ID or name is found among all, we assume it's the same patient. 402 403 Args: 404 subfolders (list[str]): List of subfolder paths to inspect. 405 406 Returns: 407 bool: True if the subfolders seem to belong to the same patient. 408 """ 409 log.debug(f"Checking if {len(subfolders)} subfolders are DICOM series of same patient...") 410 411 patient_ids = set() 412 dicom_folders_count = 0 413 414 for subfolder in subfolders: 415 log.debug(f" Checking subfolder: {os.path.basename(subfolder)}") 416 417 has_dicom_in_subfolder = False 418 first_dicom_file = None 419 420 # Look for at least one DICOM file to sample metadata 421 for root, _, files in os.walk(subfolder): 422 for file in files: 423 file_path = os.path.join(root, file) 424 if self._is_dicom_file(file_path): 425 has_dicom_in_subfolder = True 426 first_dicom_file = file_path 427 break 428 if has_dicom_in_subfolder: 429 break 430 431 if self._is_canceled: 432 return 433 434 if has_dicom_in_subfolder: 435 dicom_folders_count += 1 436 log.debug(f" Found DICOM files in: {os.path.basename(subfolder)}") 437 438 try: 439 # Read DICOM metadata 440 dcm = pydicom.dcmread(first_dicom_file, stop_before_pixels=True) 441 patient_id = getattr(dcm, 'PatientID', None) 442 patient_name = getattr(dcm, 'PatientName', None) 443 masked_patient_name = f"{patient_name[:2]}...{patient_name[-2:]}" if patient_name else "unknown" 444 445 log.debug(f" Patient ID: {patient_id}, Patient Name: {masked_patient_name}") 446 447 if patient_id: 448 patient_ids.add(patient_id) 449 elif patient_name: 450 patient_ids.add(str(patient_name)) 451 452 except Exception as e: 453 log.debug(f" Could not read DICOM metadata: {e}") 454 else: 455 log.debug(f" No DICOM files found in: {os.path.basename(subfolder)}") 456 457 log.debug(f"Found {dicom_folders_count} folders with DICOM, {len(patient_ids)} unique patient IDs: {patient_ids}") 458 459 # Criteria: at least one DICOM folder, and at most one unique PatientID 460 is_same_patient = dicom_folders_count > 0 and len(patient_ids) <= 1 461 log.debug(f" Result: is_same_patient = {is_same_patient}") 462 463 return is_same_patient 464 465 466 def _subfolders_look_like_different_patients(self, subfolders): 467 """ 468 Heuristic that checks if subfolder names suggest multiple patients. 469 470 Example folder names that trigger 'multi-patient' assumption: 471 sub-01/, patient_1/, subj3/, p_01/, subject_2/ 472 473 Args: 474 subfolders (list[str]): List of subfolder paths. 475 476 Returns: 477 bool: True if subfolder names suggest multiple subjects. 478 """ 479 patient_like_prefixes = ['sub-', 'patient', 'subj', 'p_', 'subject'] 480 481 for subfolder in subfolders: 482 folder_name = os.path.basename(subfolder).lower() 483 if any(folder_name.startswith(prefix) for prefix in patient_like_prefixes): 484 log.debug(f"Folder name '{folder_name}' suggests different patients") 485 return True 486 487 return False 488 489 490 def _process_single_patient_folder(self, folder_path): 491 """ 492 Process a folder assumed to contain data for a single patient. 493 494 This includes: 495 - Copying NIfTI files directly 496 - Converting DICOM series to NIfTI 497 - Organizing results into a BIDS-like folder structure 498 - Cleaning up temporary directories 499 500 Args: 501 folder_path (str): Path to the patient's folder. 502 """ 503 if self._is_canceled: 504 return 505 506 log.debug(f"Processing single patient folder: {folder_path}") 507 508 nifti_files = [] 509 dicom_files = [] 510 511 base_folder_name = os.path.basename(os.path.normpath(folder_path)) 512 513 # Temporary directory for conversion steps 514 temp_dir = tempfile.mkdtemp() 515 temp_base_dir = os.path.join(temp_dir, base_folder_name) 516 517 # Walk through the folder and collect relevant files 518 for root, _, files in os.walk(folder_path): 519 if self._is_canceled: 520 shutil.rmtree(temp_dir, ignore_errors=True) 521 return 522 for file in files: 523 if self._is_canceled: 524 shutil.rmtree(temp_dir, ignore_errors=True) 525 return 526 527 file_path = os.path.join(root, file) 528 relative_path = os.path.relpath(root, folder_path) 529 dest_dir = os.path.join(temp_base_dir, relative_path) 530 os.makedirs(dest_dir, exist_ok=True) 531 532 if self._is_nifti_file(file): 533 nifti_files.append((file_path, os.path.join(dest_dir, file))) 534 elif self._is_dicom_file(file_path): 535 dicom_files.append(file_path) 536 else: 537 if self._is_canceled: 538 return 539 shutil.copy2(file_path, os.path.join(dest_dir, file)) 540 log.debug(f"Imported other file: {os.path.join(relative_path, file)}") 541 542 if self._is_canceled: 543 shutil.rmtree(temp_dir, ignore_errors=True) 544 return 545 546 # Copy NIfTI files to the temp structure 547 for src, dest in nifti_files: 548 if self._is_canceled: 549 shutil.rmtree(temp_dir, ignore_errors=True) 550 return 551 shutil.copy2(src, dest) 552 log.debug(f"Imported NIfTI file: {os.path.relpath(dest, temp_base_dir)}") 553 554 # If DICOMs exist, convert them into NIfTI using dcm2niix 555 if dicom_files: 556 log.debug(f"Converting {len(dicom_files)} DICOM files to NIfTI...") 557 self._convert_dicom_folder_to_nifti(folder_path, temp_base_dir) 558 559 if self._is_canceled: 560 shutil.rmtree(temp_dir, ignore_errors=True) 561 return 562 563 # Convert the temporary NIfTI/JSON folder into a BIDS structure 564 log.debug(f"Converting to BIDS structure...") 565 self._convert_to_bids_structure(temp_base_dir) 566 567 # Clean up 568 shutil.rmtree(temp_dir, ignore_errors=True) 569 570 # Optionally update UI buttons if context provides callback 571 if self.context and "update_main_buttons" in self.context: 572 self.context["update_main_buttons"]() 573 log.debug("Import completed for single patient.") 574 575 def _convert_dicom_folder_to_nifti(self, dicom_folder, output_folder): 576 """ 577 Converts a DICOM folder into NIfTI format using the external tool `dcm2niix`. 578 579 This function is executed for each single-patient folder that contains DICOM files. 580 It runs `dcm2niix` in a subprocess and places the resulting NIfTI and JSON metadata files 581 in the destination folder. 582 583 Args: 584 src_folder (str): The source folder containing DICOM files. 585 dest_folder (str): The folder where NIfTI files should be saved. 586 """ 587 if os.path.isdir(output_folder): 588 for filename in os.listdir(output_folder): 589 if self._is_canceled: 590 return 591 file_path = os.path.join(output_folder, filename) 592 try: 593 if os.path.isfile(file_path) or os.path.islink(file_path): 594 os.remove(file_path) 595 elif os.path.isdir(file_path): 596 shutil.rmtree(file_path) 597 except Exception as e: 598 log.debug(f"Errore durante la rimozione di {file_path}: {e}") 599 else: 600 os.makedirs(output_folder, exist_ok=True) 601 602 if self._is_canceled: 603 shutil.rmtree(output_folder, ignore_errors=True) 604 return 605 self.current_progress += 10 606 self.progress.emit(self.current_progress) 607 try: 608 log.debug("DCM2NIIX path:" + get_bin_path("dcm2niix")) 609 command = [ 610 get_bin_path("dcm2niix"), 611 "-f", "%p_%s", # Naming format 612 "-p", "y", # Preserve original acquisition order 613 "-z", "y", # Compress output as .nii.gz 614 "-o", output_folder, # Destination folder 615 dicom_folder # Source DICOM folder 616 ] 617 618 self.process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 619 stdout, stderr = self.process.communicate() 620 if self.process.returncode != 0: 621 raise RuntimeError(f"dcm2niix failed: {stderr.decode()}") 622 623 self.current_progress += 10 624 self.progress.emit(self.current_progress) 625 log.debug(f"Converted DICOM in {dicom_folder} to NIfTI using dcm2niix (optimized)") 626 except subprocess.CalledProcessError as e: 627 log.error(f"Conversion error: {e}") 628 raise RuntimeError(QCoreApplication.translate("Threads", "Conversion error: {0}").format(e)) from e 629 except Exception as e: 630 if self._is_canceled: 631 log.info("Import canceled") 632 raise Exception(QCoreApplication.translate("Threads", "Import canceled")) 633 else: 634 log.info(f"Import failed: {e}") 635 raise RuntimeError( 636 QCoreApplication.translate("Threads", "Failed to convert DICOM: {0}").format(e)) from e 637 638 def _convert_to_bids_structure(self, input_folder): 639 """ 640 Converts a folder containing NIfTI and JSON files into a BIDS-like directory structure. 641 642 The function scans all NIfTI files in the given folder and organizes them under: 643 derivatives/ 644 └── deep_learning_seg/ 645 └── sub-XXX/ 646 └── anat/ 647 648 Each new import increments the patient ID number (`sub-001`, `sub-002`, ...). 649 650 Args: 651 source_folder (str): Path to the folder containing NIfTI/JSON files to reorganize. 652 """ 653 if self._is_canceled: 654 return 655 all_json_files = [] 656 for root, _, files in os.walk(input_folder): 657 for file in files: 658 if file.endswith(".json"): 659 all_json_files.append(os.path.join(root, file)) 660 661 if not all_json_files: 662 log.debug("[BIDS] Nessun file JSON trovato. Conversione annullata.") 663 return 664 665 sub_id = self._get_next_sub_id() 666 dest_sub_dir = os.path.join(self.workspace_path, sub_id) 667 os.makedirs(dest_sub_dir, exist_ok=True) 668 669 pet_run_counter = 1 670 mr_run_counter = {} 671 672 for json_path in all_json_files: 673 nii_path = json_path.replace(".json", ".nii.gz") 674 if not os.path.exists(nii_path): 675 log.debug(f"[BIDS] Skipping: no matching NIfTI for {json_path}") 676 continue 677 678 with open(json_path, "r") as f: 679 metadata = json.load(f) 680 681 modality = metadata.get("Modality", "").upper() 682 683 if modality == "MR": 684 series_desc = metadata.get("ProtocolName", "").lower() 685 686 if "flair" in series_desc: 687 suffix = "flair" 688 elif "t1" in series_desc: 689 suffix = "T1w" 690 elif "t2" in series_desc: 691 suffix = "T2w" 692 else: 693 suffix = "T1w" # fallback 694 695 mr_run_counter.setdefault(suffix, 1) 696 run_label = f"run-{mr_run_counter[suffix]}" 697 mr_run_counter[suffix] += 1 698 699 anat_dir = os.path.join(dest_sub_dir, "anat") 700 os.makedirs(anat_dir, exist_ok=True) 701 702 new_base = f"{sub_id}_{run_label}_{suffix}" 703 704 shutil.copy2(nii_path, os.path.join(anat_dir, f"{new_base}.nii.gz")) 705 shutil.copy2(json_path, os.path.join(anat_dir, f"{new_base}.json")) 706 707 elif modality == "PT": 708 raw_trc = metadata.get("Radiopharmaceutical", "unknown") 709 # estrai la prima parola alfabetica come tracciante 710 trc = re.sub(r'[^a-zA-Z]', '', raw_trc.split()[0]).lower() 711 trc_label = f"trc-{trc}" if trc else "trc-unknown" 712 713 raw_task = metadata.get("SeriesDescription", "unknown") 714 task_clean = re.sub(r'[^a-zA-Z0-9]', '', raw_task.lower()) 715 task_label = f"task-{task_clean}" if task_clean else "task-unknown" 716 717 frame_durations = metadata.get("FrameDuration", []) 718 frame_times = metadata.get("FrameReferenceTime", []) 719 if (isinstance(frame_durations, (list, tuple)) and len(frame_durations) > 1) or \ 720 (isinstance(frame_times, (list, tuple)) and len(frame_times) > 1): 721 acq_label = "rec-acdync" 722 ses_label = "ses-02" 723 else: 724 acq_label = "rec-acstat" 725 ses_label = "ses-01" 726 727 pet_dir = os.path.join(dest_sub_dir, ses_label, "pet") 728 os.makedirs(pet_dir, exist_ok=True) 729 730 run_label = f"run-{pet_run_counter}" 731 732 # Add: 733 # trc_label for the tracer 734 # acq_label for the acquisition type (dynamic or static) 735 new_base = f"{sub_id}_{task_label}_{run_label}_pet" 736 737 shutil.copy2(nii_path, os.path.join(pet_dir, f"{new_base}.nii.gz")) 738 shutil.copy2(json_path, os.path.join(pet_dir, f"{new_base}.json")) 739 740 pet_run_counter += 1 741 742 log.debug(f"[BIDS] Imported {sub_id} in BIDS structure.") 743 744 def _is_bids_folder(self, folder_path): 745 """ 746 Detects whether a folder already follows a BIDS structure. 747 748 Heuristic check: 749 - Folder contains 'sub-' directories. 750 - Inside those, there are 'anat', 'func', or 'dwi' subdirectories with NIfTI files. 751 752 Args: 753 folder_path (str): Path to check. 754 755 Returns: 756 bool: True if the folder appears to be BIDS-compatible. 757 """ 758 base = os.path.basename(folder_path.rstrip(os.sep)) 759 760 if not base.startswith("sub-"): 761 return False 762 763 for session_or_modality in os.listdir(folder_path): 764 session_path = os.path.join(folder_path, session_or_modality) 765 if os.path.isdir(session_path): 766 for modality in os.listdir(session_path): 767 modality_path = os.path.join(session_path, modality) 768 if os.path.isdir(modality_path): 769 if any(f.endswith(".nii") or f.endswith(".nii.gz") for f in os.listdir(modality_path)): 770 return True 771 772 if any(f.endswith(".nii") or f.endswith(".nii.gz") for f in os.listdir(session_path)): 773 return True 774 775 return False 776 777 def _get_next_sub_id(self): 778 """ 779 Generates the next available subject ID for the BIDS dataset. 780 781 It scans existing subfolders in the workspace for names like `sub-001`, `sub-002`, ... 782 and returns the next number in sequence. 783 784 Returns: 785 str: The next subject ID, e.g. 'sub-004'. 786 """ 787 existing = [ 788 name for name in os.listdir(self.workspace_path) 789 if os.path.isdir(os.path.join(self.workspace_path, name)) and name.startswith("sub-") 790 ] 791 numbers = sorted([ 792 int(name.split("-")[1]) for name in existing 793 if name.split("-")[1].isdigit() 794 ]) 795 next_number = numbers[-1] + 1 if numbers else 1 796 return f"sub-{next_number:02d}" 797 798 def _rename_bids_files(self, destination_folder, old_sub_id, new_sub_id): 799 """ 800 Recursively scans a copied BIDS folder and renames the files 801 so that they match the new subject ID. 802 803 Example: sub-03_T1w.nii.gz -> sub-09_T1w.nii.gz 804 805 Args: 806 destination_folder (str): The path to the newly copied subject folder (e.g., /path/to/workspace/sub-09). 807 old_sub_id (str): The original subject ID (e.g., "sub-03"). 808 new_sub_id (str): The new subject ID (e.g., "sub-09"). 809 """ 810 # If the subject IDs are identical, no renaming is necessary 811 if old_sub_id == new_sub_id: 812 log.debug(f"Identical subject ids ({new_sub_id}), no renaming needed.") 813 return 814 815 log.debug(f"File BIDS renamed from {old_sub_id} to {new_sub_id} in {destination_folder}") 816 817 # Recursively scan the entire structure of the new subject’s folder 818 for root, _, files in os.walk(destination_folder): 819 for filename in files: 820 # Stop renaming if a cancellation flag is triggered 821 if self._is_canceled: 822 return 823 824 # Check if the filename contains the old subject ID 825 if old_sub_id in filename: 826 try: 827 # Create the new filename by replacing the old ID with the new one 828 # Replace only the first occurrence for safety 829 new_filename = filename.replace(old_sub_id, new_sub_id, 1) 830 831 old_filepath = os.path.join(root, filename) 832 new_filepath = os.path.join(root, new_filename) 833 834 # Perform the actual file rename 835 os.rename(old_filepath, new_filepath) 836 log.debug(f"Renamed: {old_filepath} -> {new_filepath}") 837 838 # Handle OS-level errors (e.g., permission or file access issues) 839 except OSError as e: 840 log.warning(f"Failed renaming the file {filename}: {e}") 841 # Catch any other unexpected errors 842 except Exception as e: 843 log.error(f"Unexpected error renaming {filename}: {e}") 844 845 def _is_dicom_file(self, file_path): 846 """ 847 Determines whether a given file is a valid DICOM file. 848 849 The function attempts to read the file header using `pydicom`. 850 If the file can be parsed as a DICOM dataset (i.e., it contains 851 valid DICOM metadata, including the "DICM" marker at byte offset 128), 852 the function returns True. 853 854 Args: 855 file_path (str): Path to the file to check. 856 857 Returns: 858 bool: True if the file is a readable DICOM file, False otherwise. 859 """ 860 try: 861 dcm = pydicom.dcmread(file_path, stop_before_pixels=True) 862 return True 863 except Exception: 864 return False 865 866 867 def _is_nifti_file(self, file_path): 868 """ 869 Check whether a file is a NIfTI file. 870 871 Args: 872 file_path (str): File path to test. 873 874 Returns: 875 bool: True if the file ends with '.nii' or '.nii.gz'. 876 """ 877 return file_path.endswith(".nii") or file_path.endswith(".nii.gz") 878 879 def _subfolders_belong_to_single_subject(self, subfolders): 880 """ 881 Heuristics: Subfolders belong to the same subject if: 882 - there is at most 1 non-empty PatientID AND at most 1 non-empty PatientName, or 883 - Name + BirthDate + Sex match (<=1), or 884 - a typical MR+PT pattern ({'MR','PT'} mode) with consistent BirthDate/Sex and 885 no clear signs of multiple subjects. 886 """ 887 import re 888 889 ids = set() 890 names = set() 891 births = set() 892 sexes = set() 893 modalities = set() 894 895 found_any_dicom = False 896 897 for sub in subfolders: 898 first_dcm = None 899 for root, _, files in os.walk(sub): 900 for file in files: 901 fp = os.path.join(root, file) 902 if self._is_dicom_file(fp): 903 first_dcm = fp 904 break 905 if first_dcm: 906 break 907 908 if not first_dcm: 909 continue 910 911 found_any_dicom = True 912 try: 913 dcm = pydicom.dcmread(first_dcm, stop_before_pixels=True, force=True) 914 except Exception: 915 continue 916 917 pid = (str(getattr(dcm, 'PatientID', '') or '')).strip().lower() 918 pname = (str(getattr(dcm, 'PatientName', '') or '')).strip().lower() 919 pname = re.sub(r'\\s+', '', pname) # normalizza spazi 920 bdate = (str(getattr(dcm, 'PatientBirthDate', '') or '')).strip() 921 sex = (str(getattr(dcm, 'PatientSex', '') or '')).strip().upper() 922 mod = (str(getattr(dcm, 'Modality', '') or '')).strip().upper() 923 924 if pid: 925 ids.add(pid) 926 if pname: 927 names.add(pname) 928 if bdate: 929 births.add(bdate) 930 if sex: 931 sexes.add(sex) 932 if mod: 933 modalities.add(mod) 934 935 if not found_any_dicom: 936 return False 937 938 if len(ids) <= 1 and len(names) <= 1: 939 return True 940 if len(names) <= 1 and len(births) <= 1 and len(sexes) <= 1: 941 return True 942 943 if modalities.issuperset({'MR', 'PT'}) and len(births) <= 1 and len(sexes) <= 1: 944 return True 945 946 return False 947 948 def cancel(self): 949 """ 950 Gracefully cancels the ongoing import process. 951 952 This method sets the internal `_is_canceled` flag to True, 953 terminates any active subprocess (like `dcm2niix`), and logs the cancellation. 954 955 It’s thread-safe — typically called from the GUI (e.g., a 'Cancel' button) 956 while the import thread is still running. 957 """ 958 self._is_canceled = True 959 if hasattr(self, 'process') and self.process is not None: 960 self.process.terminate() 961 self.process.wait(1) 962 self.process.kill() 963 log.info("Import Canceled.")
Worker thread that imports one or more folders into the application's workspace.
The thread emits:
- finished(): when the import completes successfully
- error(str): in case of an exception
- progress(int): progress updates (0-100)
The thread is cancellable via the cancel() method which sets an internal
flag and attempts to terminate any running external process (dcm2niix).
65 def __init__(self, context, folders_path, workspace_path): 66 """ 67 Initialize the import thread. 68 69 Args: 70 context (dict): application context (used for callbacks / GUI updates). 71 folders_path (list[str]): list of input paths (files or folders) to import. 72 workspace_path (str): path to the target workspace directory. 73 """ 74 super().__init__() 75 self.context = context 76 # allow either single path or list of paths; stored as list to keep API stable 77 self.folders_path = folders_path 78 self.workspace_path = workspace_path 79 80 # internal state for progress and cancellation 81 self.current_progress = 0 82 self._is_canceled = False 83 84 # reference to subprocess.Popen used during DICOM conversion (so we can terminate it) 85 self.process = None
Initialize the import thread.
Args: context (dict): application context (used for callbacks / GUI updates). folders_path (list[str]): list of input paths (files or folders) to import. workspace_path (str): path to the target workspace directory.
Signal(): Emitted when the operation finishes successfully and no further progress updates will occur.
Signal(str): Emitted when an error occurs.
Parameter:
str: Description or message of the encountered error.
Signal(int): Emitted periodically to indicate task progress.
Parameter:
int: Current progress percentage (0–100).
88 def run(self): 89 """ 90 Main thread entry point. 91 92 This method orchestrates the following high-level behaviors: 93 - If a single folder is provided: 94 * Detect BIDS folder and copy directly 95 * Else detect whether folder contains multiple patient subfolders or 96 a single subject split across many DICOM series 97 * Convert found DICOM series to NIfTI using dcm2niix in a temporary 98 directory, then convert NIfTI + JSON to a BIDS-like structure inside 99 the workspace. 100 - If multiple folders are provided: 101 * Process each folder independently with `_handle_import`. 102 Progress is emitted periodically. Exceptions are forwarded via the 103 `error` signal. 104 """ 105 try: 106 # initial small progress step 107 self.current_progress = 10 108 self.progress.emit(self.current_progress) 109 110 # Validate input list length and branch accordingly 111 if len(self.folders_path) == 1: 112 # single path handling 113 folder_path = self.folders_path[0] 114 115 # Basic validation 116 if not os.path.isdir(folder_path): 117 raise Exception(QCoreApplication.translate("Threads", "Invalid folders path")) 118 119 # Case A: already a BIDS subject folder -> copy directly 120 if self._is_bids_folder(folder_path): 121 log.debug(f"BIDS structure detected in: {folder_path}") 122 123 if self._is_canceled: 124 return 125 self.current_progress = 50 126 self.progress.emit(self.current_progress) 127 128 # choose next sub-id (e.g. sub-03) and copy tree 129 old_sub_id = os.path.basename(os.path.normpath(folder_path)) 130 new_sub_id = self._get_next_sub_id() 131 dest = os.path.join(self.workspace_path, new_sub_id) 132 shutil.copytree(folder_path, dest, dirs_exist_ok=True) 133 134 if not self._is_canceled: 135 self._rename_bids_files(dest, old_sub_id, new_sub_id) 136 137 if self._is_canceled: 138 return 139 self.current_progress = 100 140 self.progress.emit(self.current_progress) 141 142 log.debug(f"BIDS folder copied and renamed to {new_sub_id}.") # <-- Log aggiornato 143 self.finished.emit() 144 return 145 146 # Case B: folder contains only subfolders (possible multi-patient) 147 subfolders = [ 148 os.path.join(folder_path, d) 149 for d in os.listdir(folder_path) 150 if os.path.isdir(os.path.join(folder_path, d)) 151 ] 152 153 # If directory contains just subfolders and no direct nifti/dicom files, decide: 154 # - If subfolders belong to a single subject (multiple series in separate folders), 155 # treat whole folder as one subject. 156 if subfolders and not any( 157 self._is_nifti_file(f) or self._is_dicom_file(os.path.join(folder_path, f)) 158 for f in os.listdir(folder_path) 159 ): 160 161 # If subfolders are multiple DICOM series belonging to the same patient, 162 # process entire root as a single patient (e.g., MR + PET series in different 163 # DICOM folder exports). 164 if self._subfolders_belong_to_single_subject(subfolders): 165 log.debug(f"Single subject spread across {len(subfolders)} DICOM folders in: {folder_path}") 166 167 # Process the root folder as single subject 168 self._process_single_patient_folder(folder_path) 169 if self._is_canceled: 170 return 171 self.progress.emit(100) 172 self.finished.emit() 173 return 174 175 # Otherwise treat each subfolder as separate patient and import them 176 log.debug(f"Multiple patient folders detected in: {folder_path}") 177 folders_num = len(subfolders) 178 progress_for_folder = 90 / folders_num 179 self.current_progress = 10 180 181 for subfolder in subfolders: 182 if self._is_canceled: 183 return 184 self._handle_import(subfolder) 185 if self._is_canceled: 186 return 187 self.current_progress += progress_for_folder 188 self.progress.emit(self.current_progress) 189 190 if self._is_canceled: 191 return 192 self.progress.emit(100) 193 self.finished.emit() 194 return 195 196 # Case C: folder may contain files (NIfTI, DICOM, misc) -> collect and convert 197 nifti_files = [] 198 dicom_files = [] 199 200 base_folder_name = os.path.basename(os.path.normpath(folder_path)) 201 202 # create temporary directory for conversion and intermediate files 203 temp_dir = tempfile.mkdtemp() 204 temp_base_dir = os.path.join(temp_dir, base_folder_name) 205 206 # Walk the folder, copy non-medical files into the temp tree and list nifti/dicom 207 for root, _, files in os.walk(folder_path): 208 for file in files: 209 if self._is_canceled: 210 # cleanup and exit early when cancelled 211 shutil.rmtree(temp_dir, ignore_errors=True) 212 return 213 file_path = os.path.join(root, file) 214 215 # Keep folder structure inside temp_base_dir 216 relative_path = os.path.relpath(root, folder_path) 217 dest_dir = os.path.join(temp_base_dir, relative_path) 218 os.makedirs(dest_dir, exist_ok=True) 219 220 if self._is_nifti_file(file): 221 # record source and destination pair for later copy 222 nifti_files.append((file_path, os.path.join(dest_dir, file))) 223 elif self._is_dicom_file(file_path): 224 # record dicom file for conversion 225 dicom_files.append(file_path) 226 else: 227 # other files (sidecars, metadata, reports) are copied directly 228 shutil.copy2(file_path, os.path.join(dest_dir, file)) 229 log.debug(f"Imported other file: {os.path.join(relative_path, file)}") 230 231 # Update progress after scan 232 self.current_progress = 20 233 self.progress.emit(self.current_progress) 234 235 # Copy any found NIfTI files into temp structure and update progress incrementally 236 nifti_num = len(nifti_files) 237 if nifti_num > 0: 238 progress_per_nifti = int(40 / nifti_num) 239 for src, dest in nifti_files: 240 if self._is_canceled: 241 shutil.rmtree(temp_dir, ignore_errors=True) 242 return 243 shutil.copy2(src, dest) 244 self.current_progress += progress_per_nifti 245 self.progress.emit(self.current_progress) 246 log.debug(f"Imported NIfTI file: {os.path.relpath(dest, temp_base_dir)}") 247 248 # Midpoint progress 249 self.current_progress = 60 250 self.progress.emit(self.current_progress) 251 252 if self._is_canceled: 253 return 254 255 # If DICOM files were found, convert the original folder to NIfTI into the temp tree 256 if dicom_files: 257 self._convert_dicom_folder_to_nifti(folder_path, temp_base_dir) 258 259 # small progress bump 260 self.current_progress += 10 261 self.progress.emit(self.current_progress) 262 263 # Now transform the temp folder into a BIDS-like structure under workspace 264 self._convert_to_bids_structure(temp_base_dir) 265 266 # Cleanup temporary directory after conversion/copy 267 shutil.rmtree(temp_dir, ignore_errors=True) 268 269 elif len(self.folders_path) == 0: 270 # empty input - emit an error 271 raise Exception(QCoreApplication.translate("Threads", "Invalid folders path")) 272 elif len(self.folders_path) > 1: 273 # multiple root paths - handle each independently 274 progress_for_folder = int(90 / len(self.folders_path)) 275 for path in self.folders_path: 276 if self._is_canceled: 277 return 278 self._handle_import(path) 279 self.current_progress += progress_for_folder 280 self.progress.emit(self.current_progress) 281 else: 282 # fallback guard - shouldn't be reached 283 raise Exception(QCoreApplication.translate("Threads", "Invalid folders path")) 284 285 # Finalize progress and signal completion 286 self.current_progress = 100 287 self.progress.emit(self.current_progress) 288 self.finished.emit() 289 290 except Exception as e: 291 # If cancellation was requested we avoid emitting an error 292 if self._is_canceled: 293 log.info("Import interrupted by user, no error emitted.") 294 return 295 # Otherwise, emit error and log it 296 self.error.emit(str(e)) 297 log.error(f"Error: {str(e)}")
Main thread entry point.
This method orchestrates the following high-level behaviors:
- If a single folder is provided:
- Detect BIDS folder and copy directly
- Else detect whether folder contains multiple patient subfolders or a single subject split across many DICOM series
- Convert found DICOM series to NIfTI using dcm2niix in a temporary directory, then convert NIfTI + JSON to a BIDS-like structure inside the workspace.
- If multiple folders are provided:
- Process each folder independently with
_handle_import. Progress is emitted periodically. Exceptions are forwarded via theerrorsignal.
- Process each folder independently with
948 def cancel(self): 949 """ 950 Gracefully cancels the ongoing import process. 951 952 This method sets the internal `_is_canceled` flag to True, 953 terminates any active subprocess (like `dcm2niix`), and logs the cancellation. 954 955 It’s thread-safe — typically called from the GUI (e.g., a 'Cancel' button) 956 while the import thread is still running. 957 """ 958 self._is_canceled = True 959 if hasattr(self, 'process') and self.process is not None: 960 self.process.terminate() 961 self.process.wait(1) 962 self.process.kill() 963 log.info("Import Canceled.")
Gracefully cancels the ongoing import process.
This method sets the internal _is_canceled flag to True,
terminates any active subprocess (like dcm2niix), and logs the cancellation.
It’s thread-safe — typically called from the GUI (e.g., a 'Cancel' button) while the import thread is still running.