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.")
log = <Logger GliAAns-UI (WARNING)>
class ImportThread(PyQt6.QtCore.QThread):
 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).

ImportThread(context, folders_path, workspace_path)
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.

def finished(unknown):

Signal(): Emitted when the operation finishes successfully and no further progress updates will occur.

def error(unknown):

Signal(str): Emitted when an error occurs.
Parameter:

  • str: Description or message of the encountered error.
def progress(unknown):

Signal(int): Emitted periodically to indicate task progress.
Parameter:

  • int: Current progress percentage (0–100).
context
folders_path
workspace_path
current_progress
process
def run(self):
 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 the error signal.
def cancel(self):
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.