Coverage for main/threads/import_thread.py: 83%
499 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
1"""
2import_thread.py - ImportThread (part 1/4)
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
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
23import pydicom
25from PyQt6.QtCore import QThread, pyqtSignal, QCoreApplication
27from logger import get_logger
28from utils import get_bin_path
30log = get_logger()
33class ImportThread(QThread):
34 """
35 Worker thread that imports one or more folders into the application's workspace.
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)
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 """
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."""
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 """
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 """
64 def __init__(self, context, folders_path, workspace_path):
65 """
66 Initialize the import thread.
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
79 # internal state for progress and cancellation
80 self.current_progress = 0
81 self._is_canceled = False
83 # reference to subprocess.Popen used during DICOM conversion (so we can terminate it)
84 self.process = None
87 def run(self):
88 """
89 Main thread entry point.
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)
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]
114 # Basic validation
115 if not os.path.isdir(folder_path):
116 raise Exception(QCoreApplication.translate("Threads", "Invalid folders path"))
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}")
122 if self._is_canceled:
123 return
124 self.current_progress = 50
125 self.progress.emit(self.current_progress)
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)
133 if not self._is_canceled:
134 self._rename_bids_files(dest, old_sub_id, new_sub_id)
136 if self._is_canceled:
137 return
138 self.current_progress = 100
139 self.progress.emit(self.current_progress)
141 log.debug(f"BIDS folder copied and renamed to {new_sub_id}.") # <-- Log aggiornato
142 self.finished.emit()
143 return
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 ]
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 ):
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}")
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
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
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)
189 if self._is_canceled:
190 return
191 self.progress.emit(100)
192 self.finished.emit()
193 return
195 # Case C: folder may contain files (NIfTI, DICOM, misc) -> collect and convert
196 nifti_files = []
197 dicom_files = []
199 base_folder_name = os.path.basename(os.path.normpath(folder_path))
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)
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)
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)
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)}")
230 # Update progress after scan
231 self.current_progress = 20
232 self.progress.emit(self.current_progress)
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)}")
247 # Midpoint progress
248 self.current_progress = 60
249 self.progress.emit(self.current_progress)
251 if self._is_canceled:
252 return
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)
258 # small progress bump
259 self.current_progress += 10
260 self.progress.emit(self.current_progress)
262 # Now transform the temp folder into a BIDS-like structure under workspace
263 self._convert_to_bids_structure(temp_base_dir)
265 # Cleanup temporary directory after conversion/copy
266 shutil.rmtree(temp_dir, ignore_errors=True)
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"))
284 # Finalize progress and signal completion
285 self.current_progress = 100
286 self.progress.emit(self.current_progress)
287 self.finished.emit()
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)}")
299 def _handle_import(self, folder_path):
300 """
301 Handle the import of a single folder (which may itself contain subfolders).
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.
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
319 log.debug(f"Processing folder: {folder_path}")
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}")
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)
330 if not self._is_canceled:
331 self._rename_bids_files(dest, old_sub_id, new_sub_id)
333 log.debug(f"BIDS folder copied and renamed to {new_sub_id}.") # <-- Log aggiornato
334 self.finished.emit()
335 return
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
346 if self._is_canceled:
347 return
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
355 if self._is_canceled:
356 return
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
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
376 if self._is_canceled:
377 return
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
386 if self._is_canceled:
387 return
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)
394 def _are_dicom_series_of_same_patient(self, subfolders):
395 """
396 Check if multiple subfolders represent DICOM series from the same patient.
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.
402 Args:
403 subfolders (list[str]): List of subfolder paths to inspect.
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...")
410 patient_ids = set()
411 dicom_folders_count = 0
413 for subfolder in subfolders:
414 log.debug(f" Checking subfolder: {os.path.basename(subfolder)}")
416 has_dicom_in_subfolder = False
417 first_dicom_file = None
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
430 if self._is_canceled:
431 return
433 if has_dicom_in_subfolder:
434 dicom_folders_count += 1
435 log.debug(f" Found DICOM files in: {os.path.basename(subfolder)}")
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"
444 log.debug(f" Patient ID: {patient_id}, Patient Name: {masked_patient_name}")
446 if patient_id:
447 patient_ids.add(patient_id)
448 elif patient_name:
449 patient_ids.add(str(patient_name))
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)}")
456 log.debug(f"Found {dicom_folders_count} folders with DICOM, {len(patient_ids)} unique patient IDs: {patient_ids}")
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}")
462 return is_same_patient
465 def _subfolders_look_like_different_patients(self, subfolders):
466 """
467 Heuristic that checks if subfolder names suggest multiple patients.
469 Example folder names that trigger 'multi-patient' assumption:
470 sub-01/, patient_1/, subj3/, p_01/, subject_2/
472 Args:
473 subfolders (list[str]): List of subfolder paths.
475 Returns:
476 bool: True if subfolder names suggest multiple subjects.
477 """
478 patient_like_prefixes = ['sub-', 'patient', 'subj', 'p_', 'subject']
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
486 return False
489 def _process_single_patient_folder(self, folder_path):
490 """
491 Process a folder assumed to contain data for a single patient.
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
499 Args:
500 folder_path (str): Path to the patient's folder.
501 """
502 if self._is_canceled:
503 return
505 log.debug(f"Processing single patient folder: {folder_path}")
507 nifti_files = []
508 dicom_files = []
510 base_folder_name = os.path.basename(os.path.normpath(folder_path))
512 # Temporary directory for conversion steps
513 temp_dir = tempfile.mkdtemp()
514 temp_base_dir = os.path.join(temp_dir, base_folder_name)
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
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)
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)}")
541 if self._is_canceled:
542 shutil.rmtree(temp_dir, ignore_errors=True)
543 return
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)}")
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)
558 if self._is_canceled:
559 shutil.rmtree(temp_dir, ignore_errors=True)
560 return
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)
566 # Clean up
567 shutil.rmtree(temp_dir, ignore_errors=True)
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.")
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`.
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.
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)
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 ]
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()}")
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
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.
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/
647 Each new import increments the patient ID number (`sub-001`, `sub-002`, ...).
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))
660 if not all_json_files:
661 log.debug("[BIDS] Nessun file JSON trovato. Conversione annullata.")
662 return
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)
668 pet_run_counter = 1
669 mr_run_counter = {}
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
677 with open(json_path, "r") as f:
678 metadata = json.load(f)
680 modality = metadata.get("Modality", "").upper()
682 if modality == "MR":
683 series_desc = metadata.get("ProtocolName", "").lower()
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
694 mr_run_counter.setdefault(suffix, 1)
695 run_label = f"run-{mr_run_counter[suffix]}"
696 mr_run_counter[suffix] += 1
698 anat_dir = os.path.join(dest_sub_dir, "anat")
699 os.makedirs(anat_dir, exist_ok=True)
701 new_base = f"{sub_id}_{run_label}_{suffix}"
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"))
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"
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"
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"
726 pet_dir = os.path.join(dest_sub_dir, ses_label, "pet")
727 os.makedirs(pet_dir, exist_ok=True)
729 run_label = f"run-{pet_run_counter}"
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"
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"))
739 pet_run_counter += 1
741 log.debug(f"[BIDS] Imported {sub_id} in BIDS structure.")
743 def _is_bids_folder(self, folder_path):
744 """
745 Detects whether a folder already follows a BIDS structure.
747 Heuristic check:
748 - Folder contains 'sub-' directories.
749 - Inside those, there are 'anat', 'func', or 'dwi' subdirectories with NIfTI files.
751 Args:
752 folder_path (str): Path to check.
754 Returns:
755 bool: True if the folder appears to be BIDS-compatible.
756 """
757 base = os.path.basename(folder_path.rstrip(os.sep))
759 if not base.startswith("sub-"):
760 return False
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
771 if any(f.endswith(".nii") or f.endswith(".nii.gz") for f in os.listdir(session_path)):
772 return True
774 return False
776 def _get_next_sub_id(self):
777 """
778 Generates the next available subject ID for the BIDS dataset.
780 It scans existing subfolders in the workspace for names like `sub-001`, `sub-002`, ...
781 and returns the next number in sequence.
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}"
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.
802 Example: sub-03_T1w.nii.gz -> sub-09_T1w.nii.gz
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
814 log.debug(f"File BIDS renamed from {old_sub_id} to {new_sub_id} in {destination_folder}")
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
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)
830 old_filepath = os.path.join(root, filename)
831 new_filepath = os.path.join(root, new_filename)
833 # Perform the actual file rename
834 os.rename(old_filepath, new_filepath)
835 log.debug(f"Renamed: {old_filepath} -> {new_filepath}")
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}")
844 def _is_dicom_file(self, file_path):
845 """
846 Determines whether a given file is a valid DICOM file.
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.
853 Args:
854 file_path (str): Path to the file to check.
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
866 def _is_nifti_file(self, file_path):
867 """
868 Check whether a file is a NIfTI file.
870 Args:
871 file_path (str): File path to test.
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")
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
888 ids = set()
889 names = set()
890 births = set()
891 sexes = set()
892 modalities = set()
894 found_any_dicom = False
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
907 if not first_dcm:
908 continue
910 found_any_dicom = True
911 try:
912 dcm = pydicom.dcmread(first_dcm, stop_before_pixels=True, force=True)
913 except Exception:
914 continue
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()
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)
934 if not found_any_dicom:
935 return False
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
942 if modalities.issuperset({'MR', 'PT'}) and len(births) <= 1 and len(sexes) <= 1:
943 return True
945 return False
947 def cancel(self):
948 """
949 Gracefully cancels the ongoing import process.
951 This method sets the internal `_is_canceled` flag to True,
952 terminates any active subprocess (like `dcm2niix`), and logs the cancellation.
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.")