Source code for unravel.register.reg_compare_fsleyes

#!/usr/bin/env python3

"""
Use ``reg_compare_fsleyes`` (``rcmpf`` or ``rf``) from UNRAVEL to visualize registration
results in FSLeyes for multiple samples in a single session.

Two usage modes:

1. Aggregated mode (default):
   Run inside a directory containing files named like: sample01_autofl_50um_masked_fixed_reg_input.nii.gz, sample01_atlas_`*`_in_tissue_space*.nii.gz
   Files are grouped by sample prefix (sample??_).
   Such directories can be created using ``reg_compare`` to aggregate
   registration outputs across samples.

2. Single-sample mode:
   Run inside a sample directory or inside a reg_outputs* directory.
   Registration outputs are loaded directly from reg_outputs* folders.

Behavior:
    - Loads one autofluo/underlay per sample (if present)
    - Loads all matching atlas overlays per sample
    - Opens one FSLeyes instance with the first sample visible

Usage:
------
    reg_compare_fsleyes [-d dir] [-af autofl_img] [-a atlas ...] [-min min_val] [-max max_val] [-l lut_name] [-al alpha_value] [-p pattern] [-v]
"""

import shutil
import subprocess
import tempfile
from pathlib import Path
from rich import print
from rich.traceback import install

from unravel.core.help_formatter import RichArgumentParser, SuppressMetavar, SM
from unravel.core.utils import log_command, get_stem, get_extension
from unravel.register.reg_compare import _default_suffix_from_folder


[docs] def parse_args(): parser = RichArgumentParser(formatter_class=SuppressMetavar, add_help=False, docstring=__doc__) opts = parser.add_argument_group('Optional arguments') opts.add_argument('-d', '--dir', help='Directory to search for registration outputs. Default: current directory.', default=None, action=SM) opts.add_argument('-af', '--autofl_img', help='Glob for the single autofluo/underlay per sample (within -d). Default: *autofl_50um_masked_fixed_reg_input.nii.gz', default='*autofl_50um_masked_fixed_reg_input.nii.gz', action=SM) opts.add_argument('-a', '--atlas', help='One or more globs for warped atlas overlays (within -d). Default: *atlas*_in_tissue_space*.nii.gz', default=['*atlas*_in_tissue_space*.nii.gz'], nargs='*', action=SM) opts.add_argument('-min', '--min', help='Minimum intensity value for fsleyes display (grayscale underlay). Default: 0', type=float, default=0.0) opts.add_argument('-max', '--max', help='Maximum intensity value for fsleyes display (grayscale underlay). Default: 2000', type=float, default=2000.0) opts.add_argument('-l', '--lut', help='FSLeyes label LUT name. Default: ccfv3_2020', default='ccfv3_2020', action=SM) opts.add_argument('-al', '--alpha', help='Atlas overlay alpha (0-100). Default: 100', type=float, default=100.0) general = parser.add_argument_group('General arguments') general.add_argument('-p', '--pattern', help='Pattern for filename prefix. Default: sample??', default='sample??', action=SM) general.add_argument('-v', '--verbose', help='Increase verbosity. Default: False', action='store_true', default=False) return parser.parse_args()
# TODO: Consider redundancy with reg_check_fsleyes.py (perhaps deprecate reg_check_fsleyes in favor of this more general tool after testing, including missing features, and maybe simplifying) def _glob_sorted(dir_path: Path, pattern: str) -> list[Path]: return sorted(dir_path.glob(pattern)) def _sample_prefix(path: Path) -> str | None: # expects filenames like "sample01_....nii.gz" name = path.name if name.startswith('sample') and '_' in name: pref = name.split('_', 1)[0] + '_' if pref.startswith('sample'): return pref return None def _add_underlay(cmd: list[str], path: Path, args, visible: bool) -> None: cmd.extend([str(path), '-dr', str(args.min), str(args.max)]) if not visible: cmd.append('-d') def _add_atlas_overlay(cmd: list[str], path: Path, args, visible: bool) -> None: cmd.extend([str(path), '-ot', 'label', '-l', str(args.lut), '-o', '-a', str(args.alpha)]) if not visible: cmd.append('-d') def _strip_aggregated_prefix_glob(pat: str) -> str: """If pattern looks like aggregated naming (e.g., '*_foo.nii.gz'), strip '*_' for single-sample mode.""" return pat.split('_', 1)[1] if pat.startswith('*_') else pat def _glob_sorted_many(dirs: list[Path], pattern: str) -> list[Path]: out: list[Path] = [] for d in dirs: out.extend(sorted(d.glob(pattern))) return out def _copy_with_suffix(src: Path, dst_dir: Path, sample_id: str) -> Path: """ Copy src -> dst_dir with a name that includes sample_id and a suffix derived from the parent folder. Example: reg_outputs_no_bc/atlas...nii.gz -> <tmp>/sample75_atlas...__no_bc.nii.gz """ suffix = _default_suffix_from_folder(src.parent.name) stem = get_stem(src) # handles .nii.gz, .ome.tif, etc. ext = get_extension(src) dst = dst_dir / f"{sample_id}_{stem}{suffix}{ext}" shutil.copy2(src, dst) return dst
[docs] @log_command def main(): install() args = parse_args() tmp = None base = Path(args.dir) if args.dir else Path.cwd() if not base.exists(): print(f'[red]Error:[/red] directory not found: {base}') return # ---- Mode A: aggregated directory (current behavior) ---- # Pattern is only used to find candidate prefixes (e.g., sample??_ or sample???_) # We do NOT try to validate/construct "allowed prefixes" beyond what exists in filenames. prefix_glob = f'{args.pattern}_*' pref_files = sorted(base.glob(prefix_glob)) prefixes = sorted({(_sample_prefix(p) or '') for p in pref_files}) prefixes = [p for p in prefixes if p] # Sanity check: do we have any files here at all? has_underlay_here = any(base.glob(args.autofl_img)) has_atlas_here = any(any(base.glob(pat)) for pat in args.atlas) if prefixes and not (has_underlay_here or has_atlas_here): prefixes = [] single_sample_mode = False # ---- Mode B: single-sample directory fallback ---- if not prefixes: # Case 1: running inside sampleXX if base.name.startswith('sample'): sample_dir = base search_dirs = sorted([d for d in sample_dir.glob('reg_outputs*') if d.is_dir()]) if args.verbose: print(f'[cyan]Single-sample mode:[/cyan] {sample_dir.name} (searching {len(search_dirs)} dir(s))') # Case 2: running inside sampleXX/reg_outputs* elif base.name.startswith('reg_outputs') and base.parent.name.startswith('sample'): sample_dir = base.parent # Search just this reg_outputs folder (most specific) search_dirs = [base] if args.verbose: print(f'[cyan]Single-sample mode:[/cyan] {sample_dir.name} (searching 1 dir)') else: print(f'[red]Error:[/red] No files matched prefix glob: {prefix_glob}') print("[red]Error:[/red] Expected aggregated filenames like sample01_*.nii.gz") print("[yellow]Hint:[/yellow] Run inside reg_compare/ OR inside sampleXX/ OR inside sampleXX/reg_outputs*/") return single_sample_mode = True prefixes = [sample_dir.name + '_'] # e.g., sample07_ if not search_dirs: print("[red]Error:[/red] No reg_outputs* directories found for this sample.") return else: print(f' Search dirs: {", ".join(map(str, search_dirs))}') if args.verbose: print(f'[cyan]Single-sample mode:[/cyan] {sample_dir.name} (searching {len(search_dirs)} dir(s))') # Map: prefix -> autofl (list) and atlases (list) autofl_by_prefix: dict[str, list[Path]] = {p: [] for p in prefixes} atlas_by_prefix: dict[str, list[Path]] = {p: [] for p in prefixes} if not single_sample_mode: # ---- Mode A collection (unchanged) ---- for f in _glob_sorted(base, args.autofl_img): pref = _sample_prefix(f) if pref in autofl_by_prefix: autofl_by_prefix[pref].append(f) for pat in args.atlas: for f in _glob_sorted(base, pat): pref = _sample_prefix(f) if pref in atlas_by_prefix: atlas_by_prefix[pref].append(f) else: # ---- Mode B collection (sample dir) ---- pref = prefixes[0] tmp = tempfile.TemporaryDirectory(prefix=f"rcmpf_{sample_dir.name}_") view_dir = Path(tmp.name) # underlay underlay_pat = _strip_aggregated_prefix_glob(args.autofl_img) autofl_hits = _glob_sorted_many(search_dirs, underlay_pat) if autofl_hits: chosen = autofl_hits[0] chosen_view = _copy_with_suffix(chosen, view_dir, sample_dir.name) autofl_by_prefix[pref].append(chosen_view) if args.verbose: print(f"[green]Underlay:[/green] {chosen} -> {chosen_view}") # atlases for pat in args.atlas: pat2 = _strip_aggregated_prefix_glob(pat) for h in _glob_sorted_many(search_dirs, pat2): h_view = _copy_with_suffix(h, view_dir, sample_dir.name) atlas_by_prefix[pref].append(h_view) if args.verbose: print(f"[green]Atlas:[/green] {h} -> {h_view}") # Sanity + warnings n_samples_with_any = 0 for pref in prefixes: afl = autofl_by_prefix.get(pref, []) atl = atlas_by_prefix.get(pref, []) if len(afl) > 1: print( f'[yellow]Warning:[/yellow] {pref} matched multiple autofl files; using the first:\n ' + '\n '.join(map(str, afl)) ) if afl or atl: n_samples_with_any += 1 if n_samples_with_any == 0: print('[red]Error:[/red] No matching autofl or atlas files found.') print(f' Autofl pattern: {args.autofl_img}') print(f' Atlas patterns: {" ".join(args.atlas)}') if single_sample_mode: print(f' Sample dir: {sample_dir}') print(' Search dirs:') for d in search_dirs: print(f' - {d}') underlay_pat = _strip_aggregated_prefix_glob(args.autofl_img) print(f' Underlay tried (single-sample): {underlay_pat}') print(' Atlas tried (single-sample):') for pat in args.atlas: print(f' - {_strip_aggregated_prefix_glob(pat)}') else: print(f' Search directory: {base}') return # Build one fsleyes command: first sample visible, rest hidden (-d) fsleyes_command: list[str] = ['fsleyes'] first = True for pref in prefixes: afl_list = autofl_by_prefix.get(pref, []) atl_list = atlas_by_prefix.get(pref, []) if not afl_list and not atl_list: continue visible = first if afl_list: _add_underlay(fsleyes_command, afl_list[0], args, visible=visible) for atlas_path in atl_list: _add_atlas_overlay(fsleyes_command, atlas_path, args, visible=visible) first = False print(f'[green]Launching[/green] one FSLeyes instance with {n_samples_with_any} samples ' f'(first sample visible; others hidden).') subprocess.run(fsleyes_command) # optional explicit cleanup after fsleyes exits if tmp is not None: tmp.cleanup()
if __name__ == '__main__': main()