Coverage for main/threads/import_thread.py: 83%

499 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-03 15:51 +0000

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.")