clabtoolkit 0.4.3__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/HISTORY.rst +18 -0
  2. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/PKG-INFO +1 -1
  3. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/__init__.py +1 -1
  4. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/dicomtools.py +328 -65
  5. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/dwitools.py +33 -131
  6. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/parcellationtools.py +327 -0
  7. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/tracttools.py +363 -202
  8. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit.egg-info/PKG-INFO +1 -1
  9. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit.egg-info/SOURCES.txt +3 -2
  10. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/index.rst +3 -0
  11. clabtoolkit-0.4.4/docs/source/modules/colorstools.rst +142 -0
  12. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/modules/connectivitytools.rst +1 -1
  13. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/modules/dicomtools.rst +1 -0
  14. clabtoolkit-0.4.4/docs/source/modules/dwitools.rst +115 -0
  15. clabtoolkit-0.4.4/docs/source/modules/freesurfertools.rst +181 -0
  16. clabtoolkit-0.4.4/docs/source/modules/imagetools.rst +168 -0
  17. clabtoolkit-0.4.4/docs/source/modules/morphometrytools.rst +151 -0
  18. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/modules/networktools.rst +1 -1
  19. clabtoolkit-0.4.4/docs/source/modules/parcellationtools.rst +154 -0
  20. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/modules/pipelinetools.rst +1 -1
  21. clabtoolkit-0.4.4/docs/source/modules/pointstools.rst +113 -0
  22. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/modules/segmentationtools.rst +1 -1
  23. clabtoolkit-0.4.4/docs/source/modules/surfacetools.rst +143 -0
  24. clabtoolkit-0.4.4/docs/source/modules/tracttools.rst +168 -0
  25. clabtoolkit-0.4.4/docs/source/modules/visualizationtools.rst +139 -0
  26. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/pyproject.toml +1 -1
  27. clabtoolkit-0.4.3/clabtoolkit/connectome.py +0 -723
  28. clabtoolkit-0.4.3/clabtoolkit/old_dwitool.py +0 -1537
  29. clabtoolkit-0.4.3/docs/source/modules/dwitools.rst +0 -90
  30. clabtoolkit-0.4.3/docs/source/modules/freesurfertools.rst +0 -85
  31. clabtoolkit-0.4.3/docs/source/modules/imagetools.rst +0 -114
  32. clabtoolkit-0.4.3/docs/source/modules/morphometrytools.rst +0 -96
  33. clabtoolkit-0.4.3/docs/source/modules/parcellationtools.rst +0 -71
  34. clabtoolkit-0.4.3/docs/source/modules/surfacetools.rst +0 -95
  35. clabtoolkit-0.4.3/docs/source/modules/visualizationtools.rst +0 -82
  36. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/AUTHORS.rst +0 -0
  37. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/CONTRIBUTING.rst +0 -0
  38. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/LICENSE +0 -0
  39. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/MANIFEST.in +0 -0
  40. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/README.rst +0 -0
  41. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/bidstools.py +0 -0
  42. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/build_visualization_layout.py +0 -0
  43. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/colorstools.py +0 -0
  44. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/config/bids.json +0 -0
  45. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/config/config.json +0 -0
  46. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/config/lobes.json +0 -0
  47. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/config/stats_mapping.json +0 -0
  48. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/config/viz_views.json +0 -0
  49. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/config/viz_views_test.json +0 -0
  50. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/connectivitytools.py +0 -0
  51. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/dwitools_utils.py +0 -0
  52. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/freesurfertools.py +0 -0
  53. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/imagetools.py +0 -0
  54. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/imagetools_utils.py +0 -0
  55. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/misctools.py +0 -0
  56. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/misctools_utils.py +0 -0
  57. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/morphometrytools.py +0 -0
  58. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/networktools.py +0 -0
  59. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/pipelinetools.py +0 -0
  60. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/plottools.py +0 -0
  61. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/pointstools.py +0 -0
  62. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/qcqatools.py +0 -0
  63. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/segmentationtools.py +0 -0
  64. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/surfacetools.py +0 -0
  65. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/visualization_utils.py +0 -0
  66. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit/visualizationtools.py +0 -0
  67. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit.egg-info/dependency_links.txt +0 -0
  68. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit.egg-info/requires.txt +0 -0
  69. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/clabtoolkit.egg-info/top_level.txt +0 -0
  70. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/Makefile +0 -0
  71. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/clabtoolkit.rst +0 -0
  72. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/make.bat +0 -0
  73. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/modules.rst +0 -0
  74. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/_static/clabtoolkit_ecosystem.png +0 -0
  75. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/_static/clabtoolkit_favicon.png +0 -0
  76. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/_static/clabtoolkit_logo.png +0 -0
  77. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/_static/clabtoolkit_logo_notext.png +0 -0
  78. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/conf.py +0 -0
  79. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/modules/bidstools.rst +0 -0
  80. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/modules/misctools.rst +0 -0
  81. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/modules/plottools.rst +0 -0
  82. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/docs/source/modules/qcqatools.rst +0 -0
  83. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/setup.cfg +0 -0
  84. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/usage_examples/VisClasses4Paper.ipynb +0 -0
  85. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/usage_examples/clabtoolkit-OHBM2026-final.ipynb +0 -0
  86. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/usage_examples/clabtoolkit_examples.ipynb +0 -0
  87. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/usage_examples/examples-bidstools-methods.ipynb +0 -0
  88. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/usage_examples/examples-colorstools-methods.ipynb +0 -0
  89. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/usage_examples/examples-freesurfertools-methods.ipynb +0 -0
  90. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/usage_examples/examples-misctools-methods.ipynb +0 -0
  91. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/usage_examples/examples-parcellationtools-methods.ipynb +0 -0
  92. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/usage_examples/examples-surfacetools-methods.ipynb +0 -0
  93. {clabtoolkit-0.4.3 → clabtoolkit-0.4.4}/usage_examples/examples-visualizationtools-methods.ipynb +0 -0
@@ -2,6 +2,24 @@
2
2
  History
3
3
  =======
4
4
 
5
+ 0.4.4 (2026-07-17)
6
+ ------------------
7
+
8
+ * Added get/set accessor methods for Parcellation attributes (data, affine, index, names and colors) with validation
9
+ * Added support for organizing DICOM folders without requiring individual per-series folders
10
+ * Improved the method to compress DICOM sessions
11
+ * Fixed save_tractogram writing the wrong format when overwriting an existing file
12
+ * Fixed colortable loading in tracttools for point-only maps and for maps absent from both data_per_point and data_per_streamline
13
+ * Fixed tractogram merging in tracttools
14
+ * Fixed out_image=None crash and added pathlib.Path support in delete_dwi_volumes
15
+ * Fixed docstrings and type hints in tracttools
16
+ * Renamed the streamline point count variable to nb_points for consistency
17
+ * Removed surf as a variable name for tractograms
18
+ * Removed the old connectome and old_dwitool modules (use connectivitytools and dwitools instead)
19
+ * Reorganized the package imports
20
+ * Documented the tracttools, pointstools and colorstools modules for the first time
21
+ * Corrected the module documentation to match the actual API
22
+
5
23
  0.4.3 (2026-06-10)
6
24
  ------------------
7
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clabtoolkit
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: A comprehensive toolkit for neuroimaging data processing and analysis
5
5
  Author-email: Yasser Alemán-Gómez <yasseraleman@protonmail.com>
6
6
  Maintainer-email: Yasser Alemán-Gómez <yasseraleman@protonmail.com>
@@ -2,4 +2,4 @@
2
2
 
3
3
  __author__ = """Yasser Alemán-Gómez"""
4
4
  __email__ = "yasseraleman@protonmail.com"
5
- __version__ = "0.4.3"
5
+ __version__ = "0.4.4"
@@ -1,23 +1,32 @@
1
+ # Standard library
1
2
  import os
2
- from glob import glob
3
+ import sys
4
+ import time
3
5
  import tarfile
4
6
  import shutil
5
- import pandas as pd
6
- import sys
7
- import pydicom
7
+ from glob import glob
8
8
  from datetime import datetime
9
9
  from pathlib import Path
10
10
  from shutil import copyfile
11
- from concurrent.futures import ThreadPoolExecutor
12
-
13
- import numpy as np
14
- from rich.progress import Progress
11
+ from functools import partial
15
12
  from threading import Lock
16
- import time
17
-
18
13
  from typing import List, Optional, Union
14
+ from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed
19
15
 
20
- # Importing local modules
16
+ # Third-party
17
+ import numpy as np
18
+ import pandas as pd
19
+ import pydicom
20
+ from rich.progress import (
21
+ Progress,
22
+ SpinnerColumn,
23
+ BarColumn,
24
+ TextColumn,
25
+ TimeElapsedColumn,
26
+ MofNCompleteColumn,
27
+ )
28
+
29
+ # Local
21
30
  from . import misctools as cltmisc
22
31
 
23
32
 
@@ -359,6 +368,235 @@ def org_conv_dicoms(
359
368
  compress_dicom_session(out_dic_dir)
360
369
 
361
370
 
371
+ ##
372
+ def organize_dicom_files(
373
+ in_dic_dir: str,
374
+ out_dic_dir: str,
375
+ no_sub_folder: bool = False,
376
+ demog_file: str = None,
377
+ ids_file: str = None,
378
+ ses_id: str = None,
379
+ nosub: bool = False,
380
+ booldic: bool = True,
381
+ boolcomp: bool = False,
382
+ force: bool = False,
383
+ nthreads: int = 0,
384
+ ):
385
+ """
386
+ Alias for org_conv_dicoms to maintain backward compatibility.
387
+ """
388
+
389
+ if no_sub_folder:
390
+ org_dicom_folder(
391
+ in_dic_dir=in_dic_dir,
392
+ out_dic_dir=out_dic_dir,
393
+ boolcomp=boolcomp,
394
+ force=force,
395
+ nthreads=nthreads,
396
+ )
397
+ else:
398
+
399
+ org_conv_dicoms(
400
+ in_dic_dir=in_dic_dir,
401
+ out_dic_dir=out_dic_dir,
402
+ demog_file=demog_file,
403
+ ids_file=ids_file,
404
+ ses_id=ses_id,
405
+ nosub=nosub,
406
+ booldic=booldic,
407
+ boolcomp=boolcomp,
408
+ force=force,
409
+ nthreads=nthreads,
410
+ )
411
+
412
+
413
+ ##########################################################################################################
414
+ def _copy_dicomfile_noid(
415
+ dic_file: str, out_dic_dir: str, force: bool = False
416
+ ) -> tuple[str, str | None, str | None]:
417
+ """
418
+ Module-level worker: read one DICOM file, derive its destination path and copy it.
419
+ Must be defined at module level (not nested) so ProcessPoolExecutor can pickle it.
420
+
421
+ Parameters
422
+ ----------
423
+ dic_file : str
424
+ Full path to the source DICOM file.
425
+ out_dic_dir : str
426
+ Root output directory where the organised hierarchy will be written.
427
+ force : bool, optional
428
+ If True, overwrite an existing file at the destination. Default is False.
429
+
430
+ Returns
431
+ -------
432
+ dic_file : str
433
+ The input path (echoed back for caller bookkeeping).
434
+ dest_dic_dir : str or None
435
+ The destination directory the file was copied into, or None on failure.
436
+ error_msg : str or None
437
+ Human-readable reason for failure, or None on success.
438
+ """
439
+ try:
440
+ dataset = pydicom.dcmread(dic_file, stop_before_pixels=True)
441
+ dic_name = os.path.basename(dic_file)
442
+
443
+ attributes = dataset.dir("")
444
+ if not attributes:
445
+ return dic_file, None, "No DICOM attributes found"
446
+
447
+ subj_id = str(dataset.data_element("PatientID").value).strip()
448
+ ses_id, ser_id = create_session_series_names(dataset)
449
+ ses_id = "ses-" + ses_id
450
+
451
+ dest_dic_dir = os.path.join(out_dic_dir, subj_id, ses_id, ser_id)
452
+ Path(dest_dic_dir).mkdir(parents=True, exist_ok=True)
453
+
454
+ dest_dic = os.path.join(dest_dic_dir, dic_name)
455
+ if force:
456
+ if os.path.isfile(dest_dic):
457
+ os.remove(dest_dic)
458
+ copyfile(dic_file, dest_dic)
459
+ else:
460
+ if not os.path.isfile(dest_dic):
461
+ copyfile(dic_file, dest_dic)
462
+
463
+ return dic_file, dest_dic_dir, None
464
+
465
+ except pydicom.errors.InvalidDicomError:
466
+ return dic_file, None, "Not a valid DICOM file"
467
+ except KeyError as exc:
468
+ return dic_file, None, f"Missing DICOM tag: {exc}"
469
+ except Exception as exc:
470
+ return dic_file, None, f"{type(exc).__name__}: {exc}"
471
+
472
+
473
+ ##########################################################################################################
474
+ def org_dicom_folder(
475
+ in_dic_dir: str,
476
+ out_dic_dir: str,
477
+ boolcomp: bool = False,
478
+ force: bool = False,
479
+ nthreads: int = 0,
480
+ ):
481
+ """
482
+ Organizes DICOM files into a subject/session/series folder hierarchy.
483
+ The subject folder is named after the PatientID DICOM attribute.
484
+
485
+ Parameters
486
+ ----------
487
+ in_dic_dir : str
488
+ Path to the input directory containing DICOM files (searched recursively).
489
+
490
+ out_dic_dir : str
491
+ Path to the output directory where organised files will be written.
492
+
493
+ boolcomp : bool, optional
494
+ If True, compress the organised sessions into tar.gz archives after copying. Default is False.
495
+
496
+ force : bool, optional
497
+ If True, overwrite existing files at the destination. Default is False.
498
+
499
+ nthreads : int, optional
500
+ Number of parallel worker processes.
501
+ 0 (default) = use all available CPUs minus one.
502
+ 1 = sequential (no parallelism).
503
+
504
+ Returns
505
+ -------
506
+ None
507
+ This function performs file organisation operations and does not return a value.
508
+
509
+ Raises
510
+ ------
511
+ FileNotFoundError
512
+ If the input directory does not exist.
513
+ PermissionError
514
+ If there are insufficient permissions to write to the output directory.
515
+
516
+ Examples
517
+ --------
518
+ >>> org_dicom_folder('/path/to/raw/dicoms', '/path/to/organised')
519
+ >>> org_dicom_folder('/path/to/raw/dicoms', '/path/to/organised', force=True, nthreads=8)
520
+ """
521
+ if not os.path.isdir(in_dic_dir):
522
+ raise FileNotFoundError(f"Input directory not found: {in_dic_dir}")
523
+
524
+ Path(out_dic_dir).mkdir(parents=True, exist_ok=True)
525
+
526
+ dicom_files = cltmisc.get_all_files(in_dic_dir)
527
+ n_files = len(dicom_files)
528
+
529
+ if n_files == 0:
530
+ print("No files found in the input directory.")
531
+ return
532
+
533
+ if nthreads == 0:
534
+ nthreads = max(1, (os.cpu_count() or 2) - 1)
535
+ nthreads = max(1, nthreads)
536
+
537
+ print(f"Organising {n_files} files using {nthreads} worker(s)...")
538
+
539
+ failed_files = []
540
+
541
+ with Progress(
542
+ SpinnerColumn(),
543
+ TextColumn("[bold blue]{task.description}"),
544
+ BarColumn(),
545
+ MofNCompleteColumn(),
546
+ TextColumn("[progress.percentage]{task.percentage:>5.1f}%"),
547
+ TimeElapsedColumn(),
548
+ transient=False,
549
+ ) as progress:
550
+
551
+ task = progress.add_task("Copying DICOM files", total=n_files)
552
+
553
+ if nthreads == 1:
554
+ for dic_file in dicom_files:
555
+ try:
556
+ _, dest, reason = _copy_dicomfile_noid(dic_file, out_dic_dir, force)
557
+ except Exception as exc:
558
+ dest, reason = None, f"Unexpected: {exc}"
559
+ finally:
560
+ progress.advance(task)
561
+ if dest is None:
562
+ failed_files.append((dic_file, reason))
563
+ else:
564
+ worker = partial(_copy_dicomfile_noid, out_dic_dir=out_dic_dir, force=force)
565
+ with ProcessPoolExecutor(max_workers=nthreads) as executor:
566
+ futures = {executor.submit(worker, f): f for f in dicom_files}
567
+ for future in as_completed(futures):
568
+ try:
569
+ _, dest, reason = future.result()
570
+ except Exception as exc:
571
+ dest, reason = None, f"Future raised: {exc}"
572
+ finally:
573
+ progress.advance(task)
574
+ if dest is None:
575
+ failed_files.append((futures[future], reason))
576
+
577
+ # Force a final render before the context closes
578
+ progress.refresh()
579
+
580
+ succeeded = n_files - len(failed_files)
581
+ print(f"\n✓ Done. {succeeded}/{n_files} files organised successfully.")
582
+
583
+ # Compress sessions if requested
584
+
585
+ if failed_files:
586
+ print(f"\n⚠ {len(failed_files)} file(s) skipped:")
587
+ for path, reason in failed_files:
588
+ print(f" • {path}\n Reason: {reason}")
589
+
590
+ # Compress sessions if requested
591
+
592
+ if boolcomp:
593
+
594
+ print("\nCompressing organised sessions...")
595
+ print(out_dic_dir)
596
+ compress_dicom_session(out_dic_dir)
597
+ print("Compression complete.")
598
+
599
+
362
600
  ####################################################################################################
363
601
  def copy_dicom_file(
364
602
  dic_file: str,
@@ -765,7 +1003,8 @@ def compress_dicom_session(
765
1003
  subj_ids : str, list of str, or None, optional
766
1004
  Subject IDs to be considered. Can be:
767
1005
  - None: consider all subjects in the directory (default)
768
- - str: path to text file containing subject IDs (one per line)
1006
+ - str: path to a text file containing subject IDs (one per line),
1007
+ or a comma-separated list of subject IDs
769
1008
  - list of str: explicit list of subject IDs
770
1009
 
771
1010
  remove_original : bool, optional, default=True
@@ -818,6 +1057,11 @@ def compress_dicom_session(
818
1057
  ... subj_ids='/path/to/subject_ids.txt'
819
1058
  ... )
820
1059
  """
1060
+
1061
+ # Normalise Path input
1062
+ if isinstance(subj_ids, Path):
1063
+ subj_ids = str(subj_ids)
1064
+
821
1065
  # Validate input directory
822
1066
  dic_path = Path(dic_dir)
823
1067
  if not dic_path.exists():
@@ -827,35 +1071,41 @@ def compress_dicom_session(
827
1071
 
828
1072
  # Process subject IDs
829
1073
  if subj_ids is None:
830
- # Get all subjects with 'sub-' prefix
831
- subj_ids = [
832
- item.name
833
- for item in dic_path.iterdir()
834
- if item.is_dir() and item.name.startswith("sub-")
835
- ]
836
- subj_ids.sort()
1074
+ # Only pick up directories that follow the BIDS 'sub-' convention
1075
+ subj_ids = sorted(item.name for item in dic_path.iterdir() if item.is_dir())
1076
+
837
1077
  elif isinstance(subj_ids, str):
838
- # Read subject IDs from file
839
- try:
840
- with open(subj_ids, "r", encoding="utf-8") as file:
841
- subj_ids = [line.strip() for line in file if line.strip()]
842
- except FileNotFoundError:
843
- raise FileNotFoundError(f"Subject IDs file {subj_ids} not found")
844
- except Exception as e:
845
- raise ValueError(f"Error reading subject IDs file: {e}")
1078
+ if os.path.isfile(subj_ids):
1079
+ # Read one subject ID per line from the file
1080
+ try:
1081
+ with open(subj_ids, "r", encoding="utf-8") as fh:
1082
+ subj_ids = [line.strip() for line in fh if line.strip()]
1083
+ except Exception as e:
1084
+ raise ValueError(f"Error reading subject IDs file: {e}")
1085
+ else:
1086
+ # Treat the string as a comma-separated list of IDs
1087
+ subj_ids = [s.strip() for s in subj_ids.split(",") if s.strip()]
1088
+ if not subj_ids:
1089
+ raise ValueError(
1090
+ f"subj_ids string '{subj_ids}' is neither a valid file path "
1091
+ "nor a comma-separated list of subject IDs"
1092
+ )
1093
+
846
1094
  elif isinstance(subj_ids, list):
847
- # Validate list elements
848
- if not all(isinstance(subj_id, str) for subj_id in subj_ids):
1095
+ if not all(isinstance(s, str) for s in subj_ids):
849
1096
  raise ValueError("All subject IDs must be strings")
1097
+
850
1098
  else:
851
- raise ValueError("subj_ids must be None, str (file path), or list of str")
1099
+ raise ValueError(
1100
+ "subj_ids must be None, str (file path or CSV), or list of str"
1101
+ )
852
1102
 
853
1103
  if not subj_ids:
854
- print("No subjects found to process")
1104
+ print("No subjects found to process.")
855
1105
  return []
856
1106
 
857
1107
  n_subj = len(subj_ids)
858
- failed_sessions = []
1108
+ failed_sessions: List[str] = []
859
1109
  total_sessions = 0
860
1110
  compressed_sessions = 0
861
1111
 
@@ -867,79 +1117,92 @@ def compress_dicom_session(
867
1117
 
868
1118
  pb.update(
869
1119
  task_id=task,
870
- description=f"[green]Processing {subj_id} ({i+1}/{n_subj})",
871
- completed=i,
1120
+ description=f"[green]Processing {subj_id} ({i + 1}/{n_subj})",
1121
+ completed=i, # mark the *previous* subject as done while this one runs
872
1122
  )
873
1123
 
874
- # Skip if subject directory doesn't exist
875
1124
  if not subj_dir.exists():
876
- print(f"Warning: Subject directory {subj_dir} not found, skipping...")
1125
+ print(f"Warning: subject directory '{subj_dir}' not found — skipping.")
877
1126
  continue
878
1127
 
879
- # Find all session directories (starting with 'ses-')
880
- session_dirs = [
1128
+ # Collect BIDS session directories (must start with 'ses-')
1129
+ session_dirs = sorted(
881
1130
  item
882
1131
  for item in subj_dir.iterdir()
883
1132
  if item.is_dir() and item.name.startswith("ses-")
884
- ]
1133
+ )
1134
+
1135
+ if not session_dirs:
1136
+ print(
1137
+ f"Warning: no 'ses-' directories found in '{subj_dir}' — skipping subject."
1138
+ )
1139
+ continue
885
1140
 
886
1141
  total_sessions += len(session_dirs)
887
1142
 
888
1143
  for ses_dir in session_dirs:
889
1144
  tar_file_path = ses_dir.with_suffix(".tar.gz")
1145
+ # ses_dir.with_suffix replaces only the last suffix; for a dir named
1146
+ # 'ses-01' that gives 'ses-01.tar.gz' as intended, but we use
1147
+ # Path(str(ses_dir) + ".tar.gz") to be unambiguous:
1148
+ tar_file_path = Path(str(ses_dir) + ".tar.gz")
890
1149
 
891
- # Skip if tar file already exists
892
1150
  if tar_file_path.exists():
893
- print(f"Warning: {tar_file_path} already exists, skipping...")
1151
+ print(f"Warning: '{tar_file_path}' already exists — skipping.")
894
1152
  continue
895
1153
 
896
1154
  try:
897
- # Create tar.gz archive using Python's tarfile module
898
1155
  with tarfile.open(tar_file_path, "w:gz") as tar:
899
- # Add the session directory to the archive
900
- # Use arcname to preserve the directory structure
901
1156
  tar.add(ses_dir, arcname=ses_dir.name)
902
1157
 
903
- # Remove original directory if requested and compression succeeded
904
1158
  if remove_original:
905
1159
  shutil.rmtree(ses_dir)
906
1160
 
907
1161
  compressed_sessions += 1
908
1162
 
909
1163
  except tarfile.TarError as e:
910
- print(f"Error compressing {ses_dir}: {e}")
1164
+ print(f"Error: failed to compress '{ses_dir}': {e}")
911
1165
  failed_sessions.append(str(ses_dir))
912
- # Clean up partially created tar file
913
- if tar_file_path.exists():
914
- try:
915
- tar_file_path.unlink()
916
- except Exception:
917
- pass
1166
+ _safe_unlink(tar_file_path)
1167
+
918
1168
  except PermissionError as e:
919
- print(f"Permission error with {ses_dir}: {e}")
1169
+ print(f"Error: permission denied for '{ses_dir}': {e}")
920
1170
  failed_sessions.append(str(ses_dir))
921
- except Exception as e:
922
- print(f"Unexpected error with {ses_dir}: {e}")
1171
+ _safe_unlink(tar_file_path)
1172
+
1173
+ except OSError as e:
1174
+ print(f"Error: OS error with '{ses_dir}': {e}")
923
1175
  failed_sessions.append(str(ses_dir))
1176
+ _safe_unlink(tar_file_path)
924
1177
 
1178
+ # Mark all subjects as complete
925
1179
  pb.update(
926
- task_id=task, description=f"[green]Completed compression", completed=n_subj
1180
+ task_id=task, description="[green]Compression complete", completed=n_subj
927
1181
  )
928
1182
 
929
- # Report results
930
- if failed_sessions:
931
- print("\nTHE PROCESS FAILED TO COMPRESS THE FOLLOWING SESSIONS:")
932
- for failed_session in failed_sessions:
933
- print(f" - {failed_session}")
934
- else:
935
- print("\nAll sessions compressed successfully!")
936
-
1183
+ # Summary
937
1184
  print(
938
- f"\nProcessed {n_subj} subjects, {compressed_sessions}/{total_sessions} sessions compressed successfully."
1185
+ f"\nProcessed {n_subj} subject(s), "
1186
+ f"{compressed_sessions}/{total_sessions} session(s) compressed successfully."
939
1187
  )
1188
+
1189
+ if failed_sessions:
1190
+ print("\nThe following sessions could not be compressed:")
1191
+ for s in failed_sessions:
1192
+ print(f" - {s}")
1193
+
940
1194
  return failed_sessions
941
1195
 
942
1196
 
1197
+ def _safe_unlink(path: Path) -> None:
1198
+ """Remove a file, silently ignoring errors (used for partial tar cleanup)."""
1199
+ try:
1200
+ if path.exists():
1201
+ path.unlink()
1202
+ except Exception:
1203
+ pass
1204
+
1205
+
943
1206
  #####################################################################################################
944
1207
  def get_dicom_info(
945
1208
  dicom_file: str,