Source code for thesis.workflows.mrtrix3.operations.density_map

"""Density-map post-processing for MRtrix3 tractography outputs.

Bundles the steps that do not have Nipype interfaces:

* ``run_tcksift2_task`` — wraps ``tcksift2`` (per-streamline weighting)
  and captures the proportionality coefficient ``mu`` via ``-out_mu``.
  ``mu`` is required to make SIFT2 weight sums comparable across
  subjects and methods (units: fibre cross-sectional area).
* ``rename_density_map_task`` — moves the ``ComputeTDI`` output to the
  ``fdt_paths.nii.gz`` filename consumed by the existing tract_similarity
  workflow, preserving compatibility with the ProbTrackX2 layout.
* ``write_waytotal_task`` — writes a single-line ``waytotal`` file. When a
  SIFT2 weights file is supplied, the value is the sum of those weights
  (matches the units of the SIFT2-weighted TDI). Otherwise the raw
  streamline count from ``tckinfo`` is written.
* ``read_sift2_mu`` — module-level helper that reads ``sift2_mu.txt``
  produced by ``tcksift2 -out_mu``.
"""

# NOTE: Function-node bodies run in subprocesses and cannot pickle loguru;
# diagnostics use ``print``.


[docs] def run_tcksift2_task( tracks_file: str, fod_file: str, fivett_file: str, output_dir: str, ) -> tuple[str, str]: """Run ``tcksift2`` to produce per-streamline weights and mu. Args: tracks_file: Streamline file (``tracks.tck``). fod_file: WM FOD image used by SIFT2 (typically the mtnormalise output). fivett_file: 5TT image for ACT-aware proportionality. output_dir: Directory where ``sift2_weights.txt`` and ``sift2_mu.txt`` are written. Returns: ``(weights_file, mu_file)`` — absolute paths to the per-streamline weights file and the single-float ``mu`` proportionality coefficient file. """ import shutil import subprocess from pathlib import Path out_dir = Path(output_dir) out_dir.mkdir(parents=True, exist_ok=True) weights = out_dir / "sift2_weights.txt" mu_path = out_dir / "sift2_mu.txt" if shutil.which("tcksift2") is None: raise RuntimeError("tcksift2 not found on PATH; ensure MRtrix3 is installed.") cmd = [ "tcksift2", str(tracks_file), str(fod_file), str(weights), "-act", str(fivett_file), "-out_mu", str(mu_path), "-force", ] print(f"[mrtrix3] {' '.join(cmd)}") # capture_output so the actual MRtrix error (e.g. "no streamlines to # weight" when tracks.tck is empty) reaches the user instead of a bare # CalledProcessError. Trial 2026-05-05_trials_30 spent a day finding # the cause precisely because stderr was swallowed here. result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError( f"tcksift2 exited with code {result.returncode}.\n" f"Command: {' '.join(cmd)}\n" f"stderr:\n{result.stderr}\n" f"stdout (tail):\n{(result.stdout or '')[-2000:]}" ) return str(weights), str(mu_path)
[docs] def read_sift2_mu(mu_file): """Read the single-float ``mu`` proportionality coefficient. Args: mu_file: Path to ``sift2_mu.txt`` written by ``tcksift2 -out_mu``. Returns: ``mu`` as a float, or ``None`` if the file is missing or empty. """ from pathlib import Path if mu_file is None: return None p = Path(mu_file) if not p.is_file(): return None try: text = p.read_text().strip() except OSError: return None if not text: return None for token in text.split(): try: return float(token) except ValueError: continue return None
[docs] def rename_density_map_task(in_file: str, output_dir: str) -> str: """Copy the TDI output to ``fdt_paths.nii.gz`` for downstream compatibility. The MRtrix tckmap interface writes to a temporary path inside the Nipype run-dir; downstream consumers (tract_similarity) expect the file to live next to a ``waytotal`` under the run output directory. Args: in_file: Path to the ComputeTDI output NIfTI. output_dir: Directory where ``fdt_paths.nii.gz`` is written. Returns: Absolute path to ``fdt_paths.nii.gz``. """ import shutil from pathlib import Path out_dir = Path(output_dir) out_dir.mkdir(parents=True, exist_ok=True) target = out_dir / "fdt_paths.nii.gz" shutil.copyfile(str(in_file), str(target)) return str(target)
[docs] def write_waytotal_task( tracks_file: str, output_dir: str, weights_file: str | None = None, ) -> str: """Write a single-line ``waytotal`` file matching the TDI denominator. The downstream atlas / tract_similarity readers divide the TDI (``fdt_paths.nii.gz``) by this value to get a streamline fraction. The written value therefore must match the units of the TDI numerator: * When ``weights_file`` points at a SIFT2 weights text file (``sift2_weights.txt``), the TDI was generated with ``-tck_weights_in`` and each voxel holds a sum of weights. The correct denominator is the sum of all per-streamline weights; we compute it here. * When ``weights_file`` is ``None`` or missing, the TDI is a raw streamline count, so we fall back to ``tckinfo -count``. Args: tracks_file: Path to ``tracks.tck``. output_dir: Directory where ``waytotal`` is written. weights_file: Optional path to a SIFT2 weights file (one float per line). When supplied and readable, the sum is written instead of the raw streamline count. Returns: Absolute path to the written ``waytotal`` file. """ import shutil import subprocess from pathlib import Path out_dir = Path(output_dir) out_dir.mkdir(parents=True, exist_ok=True) target = out_dir / "waytotal" if weights_file: weights_path = Path(weights_file) if weights_path.is_file(): total = 0.0 with weights_path.open() as fh: for line in fh: for token in line.split(): try: total += float(token) except ValueError: continue target.write_text(f"{total:.6f}\n") return str(target) print(f"[mrtrix3] weights_file {weights_file!r} missing; using tckinfo count") count = 0 if shutil.which("tckinfo") is not None: try: result = subprocess.run( ["tckinfo", str(tracks_file), "-count"], check=True, capture_output=True, text=True, ) for line in (result.stdout or "").splitlines(): stripped = line.strip() if not stripped: continue last_token = stripped.split()[-1] if last_token.isdigit(): count = int(last_token) except (subprocess.CalledProcessError, ValueError) as exc: print(f"[mrtrix3] tckinfo failed for {tracks_file}: {exc}") target.write_text(f"{count}\n") return str(target)