From c11f809f8d5e2cbd278b3252b64af5a720a9ee12 Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Thu, 28 Aug 2025 10:21:06 -0400 Subject: [PATCH 1/3] Update QC report and workflow to produce necessary qa items during main run (#63) * use niivue to dynamically render report with rotating heads * serve nifti's to niivue over http * scan niivue preview added to full report * generating svg's for comparison w/ good ole nipype * add entrypoint for qa reports * standardize commands * refactored from defaced dir to bids dir to make qa usage more clear * clean up toml and fix import --- README.md | 63 + docs/modules.rst | 8 + docs/usage.rst | 75 +- petdeface/pet.py | 20 +- petdeface/petdeface.py | 241 +++- petdeface/qa.py | 1948 ++++++++++-------------------- petdeface/serve.py | 321 +++++ petdeface/templates/niivue.html | 95 ++ petdeface/templates/scan.html | 234 ++++ petdeface/templates/subject.html | 226 ++++ petdeface/templates/viewer.html | 20 + pyproject.toml | 13 +- 12 files changed, 1949 insertions(+), 1315 deletions(-) create mode 100644 petdeface/serve.py create mode 100644 petdeface/templates/niivue.html create mode 100644 petdeface/templates/scan.html create mode 100644 petdeface/templates/subject.html create mode 100644 petdeface/templates/viewer.html diff --git a/README.md b/README.md index ffb6f30..4e96e3e 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ options: Options: 't1' (included T1w template), 'mni' (MNI template), or 'pet' (averaged PET image). --open_browser Open browser to show QA reports after completion + --qa-port QA_PORT Port for NIfTI preview server (default: 8000) ``` Working example usage: @@ -106,6 +107,68 @@ Example usage with template anatomical: petdeface /inputfolder /outputfolder --use_template_anat t1 --n_procs 16 ``` +### Quality Assessment (QA) Reports + +PETdeface includes a comprehensive quality assessment system that generates reports to help validate defacing results. The QA system creates both SVG reports and interactive NIfTI viewers. + +#### QA Report Location + +QA reports are automatically generated in the input BIDS directory under `derivatives/petdeface/qa/`. This includes: +- SVG reports showing before/after defacing comparisons +- Interactive HTML viewers for 3D NIfTI visualization +- An index page linking to all available reports + +#### Running QA Reports + +**Automatic QA generation:** +```bash +petdeface /inputfolder /outputfolder --open_browser +``` + +**Manual QA generation using the separate QA tool:** +```bash +petdeface-qa /inputfolder --open-browser --start-server +``` + +#### NIfTI Preview Server + +For 3D NIfTI visualization, PETdeface can start a local HTTP server to serve NIfTI files. This is required due to browser security restrictions. + +**Start with server:** +```bash +petdeface-qa /inputfolder --start-server --open-browser +``` + +**Custom port:** +```bash +petdeface-qa /inputfolder --start-server --qa-port 8080 +``` + +#### QA Tool Options + +The `petdeface-qa` command provides the following options: + +```bash +usage: petdeface-qa [-h] bids_dir [--output-dir OUTPUT_DIR] + [--open-browser] [--start-server] [--qa-port QA_PORT] + +Generate SVG QA reports for PET deface workflow. + +positional arguments: + bids_dir BIDS directory containing the defaced dataset (with derivatives/petdeface) + +options: + -h, --help show this help message and exit + --output-dir OUTPUT_DIR, --output_dir OUTPUT_DIR + Output directory for HTML files (default: derivatives/petdeface/qa/) + --open-browser Open browser automatically + --start-server Start local HTTP server for NIfTI file access (required for NIfTI viewers) + --qa-port QA_PORT, --qa_port QA_PORT + Port for NIfTI preview server (default: 8000) +``` + +**Note**: The NIfTI preview server is required for 3D visualization due to browser CORS restrictions. Keep the terminal running while viewing NIfTI files. + ### Docker Usage Requirements: diff --git a/docs/modules.rst b/docs/modules.rst index e5e5987..9895ef3 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -26,6 +26,14 @@ petdeface.petdeface module -------------------------- .. automodule:: petdeface.petdeface + :members: + :undoc-members: + :show-inheritance: + +petdeface.qa module +------------------ + +.. automodule:: petdeface.qa :members: :undoc-members: :show-inheritance: \ No newline at end of file diff --git a/docs/usage.rst b/docs/usage.rst index 3d1fede..23867d9 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -127,6 +127,7 @@ options: Options: 't1' (included T1w template), 'mni' (MNI template), or 'pet' (averaged PET image). --open_browser Following defacing this flag will open the browser to view the defacing results + --qa-port QA_PORT Port for NIfTI preview server (default: 8000) Docker Based ------------ @@ -205,4 +206,76 @@ Example usage with template anatomical: .. code-block:: bash - petdeface /inputfolder /outputfolder --use_template_anat t1 --n_procs 16 \ No newline at end of file + petdeface /inputfolder /outputfolder --use_template_anat t1 --n_procs 16 + +Quality Assessment (QA) Reports +------------------------------ + +PETdeface includes a comprehensive quality assessment system that generates reports to help validate defacing results. The QA system creates both SVG reports and interactive NIfTI viewers. + +QA Report Location +~~~~~~~~~~~~~~~~~ + +QA reports are automatically generated in the input BIDS directory under ``derivatives/petdeface/qa/``. This includes: + +- SVG reports showing before/after defacing comparisons +- Interactive HTML viewers for 3D NIfTI visualization +- An index page linking to all available reports + +Running QA Reports +~~~~~~~~~~~~~~~~~ + +**Automatic QA generation:** + +.. code-block:: bash + + petdeface /inputfolder /outputfolder --open_browser + +**Manual QA generation using the separate QA tool:** + +.. code-block:: bash + + petdeface-qa /inputfolder --open-browser --start-server + +NIfTI Preview Server +~~~~~~~~~~~~~~~~~~~ + +For 3D NIfTI visualization, PETdeface can start a local HTTP server to serve NIfTI files. This is required due to browser security restrictions. + +**Start with server:** + +.. code-block:: bash + + petdeface-qa /inputfolder --start-server --open-browser + +**Custom port:** + +.. code-block:: bash + + petdeface-qa /inputfolder --start-server --qa-port 8080 + +QA Tool Options +~~~~~~~~~~~~~~ + +The ``petdeface-qa`` command provides the following options: + +.. code-block:: bash + + usage: petdeface-qa [-h] bids_dir [--output-dir OUTPUT_DIR] + [--open-browser] [--start-server] [--qa-port QA_PORT] + + Generate SVG QA reports for PET deface workflow. + + positional arguments: + bids_dir BIDS directory containing the defaced dataset (with derivatives/petdeface) + + options: + -h, --help show this help message and exit + --output-dir OUTPUT_DIR, --output_dir OUTPUT_DIR + Output directory for HTML files (default: derivatives/petdeface/qa/) + --open-browser Open browser automatically + --start-server Start local HTTP server for NIfTI file access (required for NIfTI viewers) + --qa-port QA_PORT, --qa_port QA_PORT + Port for NIfTI preview server (default: 8000) + +**Note**: The NIfTI preview server is required for 3D visualization due to browser CORS restrictions. Keep the terminal running while viewing NIfTI files. \ No newline at end of file diff --git a/petdeface/pet.py b/petdeface/pet.py index e2631fe..2c4e6a8 100644 --- a/petdeface/pet.py +++ b/petdeface/pet.py @@ -12,6 +12,11 @@ class WeightedAverageInputSpec(BaseInterfaceInputSpec): pet_file = File(exists=True, desc="Dynamic PET", mandatory=True) + sidecar_file = File( + exists=True, + desc="Optional sidecar JSON file for timing info. If not provided, uses sidecar from pet_file.", + mandatory=False, + ) class WeightedAverageOutputSpec(TraitedSpec): @@ -47,9 +52,18 @@ def _run_interface(self, runtime): img = nib.load(pet_file) data = img.get_fdata() - meta = ReadSidecarJSON( - in_file=pet_file, bids_dir=bids_dir, bids_validate=False - ).run() + # Use optional sidecar file if provided, otherwise use pet_file's sidecar + if hasattr(self.inputs, "sidecar_file") and self.inputs.sidecar_file: + sidecar_file = self.inputs.sidecar_file + sidecar_bids_dir = os.path.dirname(sidecar_file) + meta = ReadSidecarJSON( + in_file=sidecar_file, bids_dir=sidecar_bids_dir, bids_validate=False + ).run() + else: + # Default behavior: use pet_file's sidecar + meta = ReadSidecarJSON( + in_file=pet_file, bids_dir=bids_dir, bids_validate=False + ).run() frames_start = np.array(meta.outputs.out_dict["FrameTimesStart"]) frames_duration = np.array(meta.outputs.out_dict["FrameDuration"]) diff --git a/petdeface/petdeface.py b/petdeface/petdeface.py index 3aef18d..4ce5993 100755 --- a/petdeface/petdeface.py +++ b/petdeface/petdeface.py @@ -11,7 +11,9 @@ from pathlib import Path import subprocess from typing import Union -from nipype.interfaces.freesurfer import MRICoreg + + +from nipype.interfaces.freesurfer import MRICoreg, ApplyVolTransform from nipype.interfaces.io import DataSink from nipype.interfaces.base.traits_extension import File as traits_extensionFile from nipype.pipeline import Node @@ -19,6 +21,7 @@ from niworkflows.utils.bids import collect_data from niworkflows.utils.bids import collect_participants from niworkflows.utils.misc import check_valid_fs_license +from nireports.interfaces.reporting.base import SimpleBeforeAfterRPT from petutils.petutils import collect_anat_and_pet from importlib.metadata import version @@ -412,6 +415,10 @@ def init_single_subject_wf( datasink.inputs.substitutions = [ (".face-after", "_desc-faceafter_T1w"), (".face-before", "_desc-facebefore_T1w"), + # Fix SVG naming to match actual file names + ("_t1w_before_after.svg", "_desc-beforeafter_T1w.svg"), + ("_pet_before_after.svg", "_desc-beforeafter_pet.svg"), + ("_registration_to_t1w.svg", "_desc-petregistration_pet.svg"), ] # deface t1w(s) @@ -564,12 +571,66 @@ def init_single_subject_wf( coreg_pet_to_t1w = Node(mricoreg, "coreg_pet_to_t1w") + # warp pet image to T1w space for later QA + warp_pet_to_t1w_space = Node(ApplyVolTransform(), "warp_pet_to_t1w_space") + warp_pet_to_t1w_space.inputs.target_file = t1w_file + + # warp defaced pet image to T1w space for QA comparison + warp_defaced_pet_to_t1w_space = Node( + ApplyVolTransform(), "warp_defaced_pet_to_t1w_space" + ) + warp_defaced_pet_to_t1w_space.inputs.target_file = t1w_file + + # weighted average of the defaced PET image (4D -> 3D) using timing from original PET + # Use the modified WeightedAverage interface with the original PET's sidecar + weighted_average_defaced = Node( + WeightedAverage(pet_file=pet_file), name="weighted_average_defaced" + ) + # Set the original PET file as the sidecar source for timing info + weighted_average_defaced.inputs.sidecar_file = pet_file + deface_pet = Node(ApplyMideface(in_file=pet_file), name="deface_pet") + # create simple before and after reports + t1w_before_after_report = Node( + SimpleBeforeAfterRPT( + before_label="Faced T1w", + after_label="Defaced T1w", + out_report=f"{anat_string}_t1w_before_after.svg", + ), + name=f"{anat_string}_before_and_after_report", + ) + + t1w_before_after_report.inputs.before = t1w_file + + pet_before_and_after_report = Node( + SimpleBeforeAfterRPT( + before_label=f"Faced {pet_string}", + after_label=f"Defaced {pet_string}", + out_report=f"{pet_string}_pet_before_after.svg", + ), + name=f"{pet_string}_before_and_after_report", + ) + pet_to_t1w_coregistration = Node( + SimpleBeforeAfterRPT( + before_label=f"{pet_string}", + after_label=f"{anat_string}", + out_report=f"{pet_string}_registration_to_t1w.svg", + ), + name=f"{pet_string}_to_{anat_string}_registration", + ) + + # Connect all the nodes in the PET workflow pet_wf.connect( [ + # Connection 1: weighted_average -> coreg_pet_to_t1w + # Takes the averaged PET image and feeds it as the source for registration (weighted_average, coreg_pet_to_t1w, [("out_file", "source_file")]), + # Connection 2: coreg_pet_to_t1w -> deface_pet + # Takes the registration matrix (LTA file) and feeds it to the defacing node (coreg_pet_to_t1w, deface_pet, [("out_lta_file", "lta_file")]), + # Connection 3: coreg_pet_to_t1w -> datasink + # Saves the registration matrix (LTA file) to the output directory ( coreg_pet_to_t1w, datasink, @@ -580,6 +641,34 @@ def init_single_subject_wf( ) ], ), + # Connection 4: weighted_average -> warp_pet_to_t1w_space + # Takes the averaged PET image and feeds it as the source for warping to T1w space + ( + weighted_average, + warp_pet_to_t1w_space, + [("out_file", "source_file")], + ), + # Connection 5: coreg_pet_to_t1w -> warp_pet_to_t1w_space + # Takes the registration matrix and feeds it as the transformation for warping + ( + coreg_pet_to_t1w, + warp_pet_to_t1w_space, + [("out_lta_file", "reg_file")], + ), + # Connection 6: warp_pet_to_t1w_space -> datasink + # Saves the PET image warped to T1w space for QA purposes + ( + warp_pet_to_t1w_space, + datasink, + [ + ( + "transformed_file", + f"{pet_string.replace('_', '.')}.pet.@warped{run_id}", + ) + ], + ), + # Connection 7: deface_pet -> datasink + # Saves the final defaced PET image to the output directory ( deface_pet, datasink, @@ -590,17 +679,119 @@ def init_single_subject_wf( ) ], ), + # Connection 8: deface_pet -> weighted_average_defaced + # Takes the defaced PET image (4D) and feeds it to weighted averaging + ( + deface_pet, + weighted_average_defaced, + [("out_file", "pet_file")], + ), + # Connection 9: weighted_average_defaced -> warp_defaced_pet_to_t1w_space + # Takes the weighted averaged defaced PET image (3D) and feeds it as the source for warping + ( + weighted_average_defaced, + warp_defaced_pet_to_t1w_space, + [("out_file", "source_file")], + ), + # Connection 10: coreg_pet_to_t1w -> warp_defaced_pet_to_t1w_space + # Takes the registration matrix and feeds it as the transformation for warping defaced PET + ( + coreg_pet_to_t1w, + warp_defaced_pet_to_t1w_space, + [("out_lta_file", "reg_file")], + ), + # Connection 11: warp_defaced_pet_to_t1w_space -> datasink + # Saves the defaced PET image warped to T1w space for QA comparison + ( + warp_defaced_pet_to_t1w_space, + datasink, + [ + ( + "transformed_file", + f"{pet_string.replace('_', '.')}.pet.@defaced_warped{run_id}", + ) + ], + ), + # create simple before and after report for pet image + ( + warp_pet_to_t1w_space, + pet_before_and_after_report, + [("transformed_file", "before")], + ), + ( + warp_defaced_pet_to_t1w_space, + pet_before_and_after_report, + [("transformed_file", "after")], + ), + # create a simple report showing the registration for pet + ( + warp_defaced_pet_to_t1w_space, + pet_to_t1w_coregistration, + [("transformed_file", "before")], + ), + ( + t1w_workflows[t1w_file]["workflow"].get_node( + f"deface_t1w_{t1w_workflows[t1w_file]['anat_string']}" + ), + pet_to_t1w_coregistration, + [("out_file", "after")], + ), + # create a before and after image for the t1w defacing + ( + t1w_workflows[t1w_file]["workflow"].get_node( + f"deface_t1w_{t1w_workflows[t1w_file]['anat_string']}" + ), + t1w_before_after_report, + [("out_file", "after")], + ), + # Move svg reports to datasink + ( + pet_before_and_after_report, + datasink, + [ + ( + "out_report", + f"{pet_string.replace('_','.')}.pet.@beforeafter{run_id}", + ) + ], + ), + ( + pet_to_t1w_coregistration, + datasink, + [ + ( + "out_report", + f"{pet_string.replace('_', '.')}.pet.@registration{run_id}", + ) + ], + ), + ( + t1w_before_after_report, + datasink, + [ + ( + "out_report", + f"{anat_string.replace('_', '.')}.anat.@beforeafter{run_id}", + ) + ], + ), ] ) + # Connect the T1w workflow to the PET workflow + # This connects the facemask from the T1w defacing to the PET defacing workflow.connect( [ ( - t1w_workflows[t1w_file]["workflow"], - pet_wf, + t1w_workflows[t1w_file][ + "workflow" + ], # Source: T1w defacing workflow + pet_wf, # Target: PET workflow [ ( + # Output from T1w defacing: the facemask file f"deface_t1w_{t1w_workflows[t1w_file]['anat_string']}.out_facemask", + # Input to PET defacing: the facemask file (same mask used for both T1w and PET) "deface_pet.facemask", ) ], @@ -1086,6 +1277,7 @@ def cli(): help="Only deface anatomical images", ) parser.add_argument( + "--participant-label", "--participant_label", "-pl", help="The label(s) of the participant/subject to be processed. When specifying multiple subjects separate them with spaces.", @@ -1109,11 +1301,17 @@ def cli(): help="Run in singularity container", ), parser.add_argument( + "--n-procs", "--n_procs", help="Number of processors to use when running the workflow", default=2, ) - parser.add_argument("--skip_bids_validator", action="store_true", default=False) + parser.add_argument( + "--skip-bids-validator", + "--skip_bids_validator", + action="store_true", + default=False, + ) parser.add_argument( "--version", "-v", @@ -1132,6 +1330,7 @@ def cli(): default="adjacent", ) parser.add_argument( + "--remove-existing", "--remove_existing", "-r", help="Remove existing output files in output_dir.", @@ -1139,12 +1338,14 @@ def cli(): default=False, ) parser.add_argument( + "--preview-pics", "--preview_pics", help="Create preview pictures of defacing, defaults to false for docker", action="store_true", default=False, ) parser.add_argument( + "--participant-label-exclude", "--participant_label_exclude", help="Exclude a subject(s) from the defacing workflow. e.g. --participant_label_exclude sub-01 sub-02", type=str, @@ -1153,6 +1354,7 @@ def cli(): default=[], ) parser.add_argument( + "--session-label", "--session_label", help="Select only a specific session(s) to include in the defacing workflow", type=str, @@ -1161,6 +1363,7 @@ def cli(): default=[], ) parser.add_argument( + "--session-label-exclude", "--session_label_exclude", help="Select a specific session(s) to exclude from the defacing workflow", type=str, @@ -1169,18 +1372,18 @@ def cli(): default=[], ) parser.add_argument( - "--use_template_anat", - help="Use template anatomical image when no T1w is available for PET scans. Options: 't1' (included T1w template), 'mni' (MNI template), or 'pet' (averaged PET image).", - type=str, - required=False, - default=False, - ) - parser.add_argument( + "--open-browser", "--open_browser", - help="Open browser to show QA reports after completion", action="store_true", default=False, ) + parser.add_argument( + "--qa-port", + "--qa_port", + type=int, + default=8000, + help="User can manually choose a default port to serve the nifti images at for 3D previewing of defacing should the default value of 8000 not work.", + ) arguments = parser.parse_args() return arguments @@ -1390,15 +1593,12 @@ def main(): # noqa: max-complexity: 12 try: # Determine the defaced directory based on placement - # Run QA + # Run QA with server if open_browser is requested qa_result = run_qa( - faced_dir=str(args.bids_dir), - defaced_dir=str(args.output_dir), - output_dir=str(args.bids_dir / "derivatives" / "petdeface"), - subject=( - " ".join(args.participant_label) if args.participant_label else None - ), + defaced_dir=str(args.bids_dir), open_browser=args.open_browser, + start_server=args.open_browser, # Start server when opening browser + server_port=args.qa_port, ) print("\n" + "=" * 60) @@ -1410,6 +1610,9 @@ def main(): # noqa: max-complexity: 12 print(f"\nError generating QA reports: {e}") print("QA report generation failed, but defacing completed successfully.") + print("launch QA reports with:") + print(f"petdeface-qa {args.bids_dir} --open_browser --start_server") + if __name__ == "__main__": main() diff --git a/petdeface/qa.py b/petdeface/qa.py index 108349e..3b15bc8 100644 --- a/petdeface/qa.py +++ b/petdeface/qa.py @@ -1,24 +1,19 @@ -import argparse +#!/usr/bin/env python3 +""" +Simple QA system for PET deface SVG reports. +""" + import os -import tempfile -import shutil -import sys -from glob import glob -import nilearn -from nilearn import plotting +import glob +import argparse import webbrowser -import nibabel as nib -import numpy as np -from nilearn import image -import matplotlib.pyplot as plt -from matplotlib.animation import FuncAnimation -import imageio -import multiprocessing as mp -from functools import partial -import seaborn as sns -from PIL import Image, ImageDraw -from nipype import Workflow, Node -from tempfile import TemporaryDirectory +import json +import http.server +import socketserver +import time +import subprocess +import signal +import sys from pathlib import Path # Handle imports for both script and module execution (including debugger) @@ -39,1368 +34,758 @@ from nireports.interfaces.reporting.base import SimpleBeforeAfterRPT -def preprocess_single_subject(s, output_dir): - """Preprocess a single subject's images (for parallel processing).""" - temp_dir = os.path.join(output_dir, "temp_3d_images") - - # Debug: Print what files we're processing - print(f"Preprocessing subject {s['id']}:") - print(f" Original: {s['orig_path']}") - print(f" Defaced: {s['defaced_path']}") - - # Extract BIDS suffix from original path to preserve meaningful naming - orig_basename = os.path.basename(s["orig_path"]) - defaced_basename = os.path.basename(s["defaced_path"]) - - # Extract the meaningful part (e.g., "sub-01_ses-baseline_T1w" or "sub-01_ses-baseline_pet") - def extract_bids_name(basename): - # Remove .nii.gz extension - name = basename.replace(".nii.gz", "").replace(".nii", "") - return name - - orig_bids_name = extract_bids_name(orig_basename) - defaced_bids_name = extract_bids_name(defaced_basename) - - # Preprocess original image - orig_result = load_and_preprocess_image(s["orig_path"]) - if isinstance(orig_result, nib.Nifti1Image): - # Need to save the averaged image with meaningful name - orig_3d_path = os.path.join(temp_dir, f"orig_{orig_bids_name}.nii.gz") - nib.save(orig_result, orig_3d_path) - orig_img = orig_result - else: - # Already 3D, use original path and load image - orig_3d_path = orig_result - orig_img = nib.load(orig_result) - - # Preprocess defaced image - defaced_result = load_and_preprocess_image(s["defaced_path"]) - if isinstance(defaced_result, nib.Nifti1Image): - # Need to save the averaged image with meaningful name - defaced_3d_path = os.path.join(temp_dir, f"defaced_{defaced_bids_name}.nii.gz") - nib.save(defaced_result, defaced_3d_path) - defaced_img = defaced_result - else: - # Already 3D, use original path and load image - defaced_3d_path = defaced_result - defaced_img = nib.load(defaced_result) - - # Create new subject dict with preprocessed paths (update paths to 3D versions) - preprocessed_subject = { - "id": s["id"], - "orig_path": orig_3d_path, # Update to 3D path - "defaced_path": defaced_3d_path, # Update to 3D path - "orig_img": orig_img, # Keep loaded image for direct use - "defaced_img": defaced_img, # Keep loaded image for direct use - } - - print(f" Preprocessed {s['id']}") - return preprocessed_subject - - -def preprocess_images(subjects: dict, output_dir, n_jobs=None): - """Preprocess all images once: load and convert 4D to 3D if needed.""" - print("Preprocessing images (4D→3D conversion)...") - - # Create temp directory - temp_dir = os.path.join(output_dir, "temp_3d_images") - os.makedirs(temp_dir, exist_ok=True) - - # Set number of jobs for parallel processing - if n_jobs is None: - n_jobs = mp.cpu_count() - print(f"Using {n_jobs} parallel processes for preprocessing") +def collect_svg_reports(defaced_dir, output_dir): + """Collect SVG files from derivatives/petdeface directory.""" + derivatives_dir = os.path.join(defaced_dir, "derivatives", "petdeface") + if not os.path.exists(derivatives_dir): + print( + f"Warning: derivatives/petdeface directory not found at {derivatives_dir}" + ) + print("Looking for SVG files in the main defaced directory...") + derivatives_dir = defaced_dir - # Process subjects in parallel - with mp.Pool(processes=n_jobs) as pool: - # Create a partial function with the output_dir fixed - preprocess_func = partial(preprocess_single_subject, output_dir=output_dir) + # Find all SVG files recursively, excluding the qa directory + svg_files = [] + for svg_file in glob.glob( + os.path.join(derivatives_dir, "**", "*.svg"), recursive=True + ): + # Skip files in the qa directory + if "qa" not in svg_file: + svg_files.append(svg_file) - # Process all subjects in parallel - preprocessed_subjects = pool.map(preprocess_func, subjects) + if not svg_files: + print("No SVG files found!") + return [] - print(f"Preprocessed {len(preprocessed_subjects)} subjects") - return preprocessed_subjects + print(f"Found {len(svg_files)} SVG files") + # Just return the original file paths - no copying needed + for svg_file in svg_files: + print(f"Found: {Path(svg_file).name}") -def generate_simple_before_and_after(preprocessed_subjects: dict, output_dir): - if not output_dir: - output_dir = TemporaryDirectory() - wf = Workflow( - name="simple_before_after_report", base_dir=Path(output_dir) / "images/" - ) + return svg_files - # Create a list to store all nodes - nodes = [] - - for s in preprocessed_subjects: - # only run this on the T1w images for now - if "T1w" in s["orig_path"]: - o_path = Path(s["orig_path"]) - # Create a valid node name by replacing invalid characters but preserving session info - # Use the full path to ensure uniqueness - path_parts = s["orig_path"].split(os.sep) - subject_part = next( - (p for p in path_parts if p.startswith("sub-")), s["id"] - ) - session_part = next((p for p in path_parts if p.startswith("ses-")), "") - if session_part: - valid_name = f"before_after_{subject_part}_{session_part}".replace( - "-", "_" - ) - else: - valid_name = f"before_after_{subject_part}".replace("-", "_") - node = Node( - SimpleBeforeAfterRPT( - before=s["orig_path"], - after=s["defaced_path"], - before_label="Original", - after_label="Defaced", - out_report=f"{s['id']}_simple_before_after.svg", - ), - name=valid_name, - ) - nodes.append(node) +def kill_process_on_port(port): + """Kill any process using the specified port.""" + try: + # Find process using the port + result = subprocess.run( + ["lsof", "-ti", str(port)], capture_output=True, text=True, check=False + ) - # Add all nodes to the workflow - wf.add_nodes(nodes) + if result.returncode == 0 and result.stdout.strip(): + pids = result.stdout.strip().split("\n") + for pid in pids: + if pid: + print(f"Killing process {pid} using port {port}") + try: + os.kill(int(pid), signal.SIGKILL) + except ProcessLookupError: + print(f"Process {pid} already terminated") + except ValueError: + print(f"Invalid PID: {pid}") + + # Give it a moment to fully terminate + time.sleep(0.5) + print(f"Port {port} is now free") + else: + print(f"Port {port} is already free") - # Only run if we have nodes to process - if nodes: - wf.run(plugin="MultiProc", plugin_args={"n_procs": mp.cpu_count()}) - # Collect SVG files and move them to images folder - collect_svg_reports(wf, output_dir) - else: - print("No T1w images found for SVG report generation") + except FileNotFoundError: + print("Warning: 'lsof' command not found, cannot check for existing processes") + except Exception as e: + print(f"Error killing process on port {port}: {e}") -def collect_svg_reports(wf, output_dir): - """Collect SVG reports from workflow and move them to images folder.""" - import glob +def start_local_server(port=8000, directory=None): + """Start a simple HTTP server to serve files locally.""" + if directory is None: + directory = os.getcwd() - # Find all SVG files in the workflow directory - workflow_dir = wf.base_dir - svg_files = glob.glob(os.path.join(workflow_dir, "**", "*.svg"), recursive=True) + os.chdir(directory) - print(f"Found {len(svg_files)} SVG reports") + class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + # Add CORS headers to allow cross-origin requests + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "*") + super().end_headers() - # Move each SVG to the images folder - for svg_file in svg_files: - filename = os.path.basename(svg_file) - dest_path = os.path.join(output_dir, "images", filename) - shutil.move(svg_file, dest_path) - print(f" Moved: {filename}") + handler = CustomHTTPRequestHandler - # Create HTML page for SVG reports - create_svg_index_html(svg_files, output_dir) + with socketserver.TCPServer(("", port), handler) as httpd: + print(f"Server started at http://localhost:{port}") + print(f"Serving files from: {directory}") + print("Press Ctrl+C to stop the server") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nServer stopped.") -def create_svg_index_html(svg_files, output_dir): - """Create HTML index page for SVG reports.""" - svg_entries = "" - for svg_file in svg_files: - filename = os.path.basename(svg_file) - subject_id = filename.replace("_simple_before_after.svg", "") - - svg_entries += f""" -
-

{subject_id}

- -

Your browser does not support SVG. Download SVG

-
-
- """ +def collect_nifti_files(defaced_dir): + """Collect NIfTI files containing 'defaced' from derivatives/petdeface directory.""" + derivatives_dir = os.path.join(defaced_dir, "derivatives", "petdeface") + if not os.path.exists(derivatives_dir): + print( + f"Warning: derivatives/petdeface directory not found at {derivatives_dir}" + ) + return {} + + # Find all NIfTI files containing 'defaced' + nifti_files = {} + for nifti_file in glob.glob( + os.path.join(derivatives_dir, "**", "*.nii*"), recursive=True + ): + filename = Path(nifti_file).name + + # Check if file contains 'defaced' + if ("defaced" in filename and "T1w" in filename) or ( + "defaced" in filename and "wavg" in filename and "pet" in filename + ): + # Extract subject ID from path + path_parts = Path(nifti_file).parts + subject_id = None + for part in path_parts: + if part.startswith("sub-"): + subject_id = part + break + + if subject_id: + if subject_id not in nifti_files: + nifti_files[subject_id] = [] + nifti_files[subject_id].append(nifti_file) + + print(f"Found NIfTI files for {len(nifti_files)} subjects") + for subject_id, files in nifti_files.items(): + print(f" {subject_id}: {len(files)} files") + for file in files: + print(f" {Path(file).name}") + + return nifti_files + + +def create_nifti_viewer_html(subject_id, nifti_files, output_dir, server_port=8000): + """Create HTML viewer with rotating NIfTI images for a subject.""" + + # Filter files to include only specific types + filtered_files = [] + for nifti_file in nifti_files: + filename = Path(nifti_file).name.lower() + + # Include files that are: + # 1. T1w defaced images + # 2. PET defaced images that are averaged (wavg) + # 3. PET defaced images that are warped + # 4. PET defaced images that are in T1w space + if "defaced" in filename: + if "t1w" in filename: + # Include T1w defaced images + filtered_files.append(nifti_file) + elif "pet" in filename: + # For PET images, only include if they are averaged (wavg) or have special processing + if ( + "wavg" in filename + or "warped" in filename + or "space-t1w" in filename + ): + filtered_files.append(nifti_file) + + if not filtered_files: + print(f"No qualifying NIfTI files found for {subject_id}") + return None + # Create HTML content html_content = f""" - + - PET Deface SVG Reports + NiiVue - {subject_id} Defaced Images - - - - - -
-

PET Deface SVG Reports

-

Before/After comparison reports using nireports

-
- -
- {svg_entries} -
- -
- ← Back to Index -
- - - """ - - svg_index_file = os.path.join(output_dir, "SimpleBeforeAfterRPT.html") - with open(svg_index_file, "w") as f: - f.write(html_content) - - print(f"Created SVG reports index: {svg_index_file}") - - -def create_overlay_comparison(orig_path, defaced_path, subject_id, output_dir): - """Create overlay comparison with original as background and defaced as overlay.""" - - # Load images - orig_img = image.load_img(orig_path) - defaced_img = image.load_img(defaced_path) - - # Create overlay plot - fig = plotting.plot_anat( - orig_img, - title=f"Overlay: Original (background) + Defaced (overlay) - {subject_id}", - display_mode="ortho", - cut_coords=(0, 0, 0), - colorbar=True, - annotate=True, - ) - - # Add defaced as overlay - plotting.plot_roi(defaced_img, bg_img=orig_img, figure=fig, alpha=0.7, color="red") - - # Save overlay - overlay_file = os.path.join(output_dir, f"overlay_{subject_id}.png") - fig.savefig(overlay_file, dpi=150) - fig.close() - - return overlay_file - - -def create_animated_gif(orig_path, defaced_path, subject_id, output_dir, n_slices=20): - """Create animated GIF showing different slices through the volume.""" - - # Load images - orig_img = image.load_img(orig_path) - defaced_img = image.load_img(defaced_path) - - # Get data - orig_data = orig_img.get_fdata() - defaced_data = defaced_img.get_fdata() - - # Create figure for animation - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 6)) - fig.suptitle(f"Animated Comparison - {subject_id}", fontsize=16) - - # Initialize plots - slice_idx = orig_data.shape[2] // 2 - im1 = ax1.imshow(orig_data[:, :, slice_idx], cmap="gray") - im2 = ax2.imshow(defaced_data[:, :, slice_idx], cmap="hot") - - ax1.set_title("Original") - ax2.set_title("Defaced") - ax1.axis("off") - ax2.axis("off") - - def animate(frame): - slice_idx = int(frame * orig_data.shape[2] / n_slices) - im1.set_array(orig_data[:, :, slice_idx]) - im2.set_array(defaced_data[:, :, slice_idx]) - return [im1, im2] - - # Create animation - anim = FuncAnimation(fig, animate, frames=n_slices, interval=200, blit=True) - - # Save as GIF - gif_file = os.path.join(output_dir, f"animation_{subject_id}.gif") - anim.save(gif_file, writer="pillow", fps=5) - plt.close() - - return gif_file - - -def create_overlay_gif(image_files, subject_id, output_dir): - """Create an animated GIF switching between original and defaced.""" - - # Load the PNG images - orig_png_path = os.path.join( - output_dir, "images", image_files[0][2] - ) # original image - defaced_png_path = os.path.join( - output_dir, "images", image_files[1][2] - ) # defaced image - - # Open images - orig_img = Image.open(orig_png_path) - defaced_img = Image.open(defaced_png_path) - - # Ensure same size - if orig_img.size != defaced_img.size: - defaced_img = defaced_img.resize(orig_img.size, Image.Resampling.LANCZOS) - - # Create frames for the GIF - frames = [] - - # Frame 1: Original - frames.append(orig_img.copy()) - - # Frame 2: Defaced - frames.append(defaced_img.copy()) - - # Save as GIF - gif_filename = f"overlay_{subject_id}.gif" - gif_path = os.path.join(output_dir, "images", gif_filename) - frames[0].save( - gif_path, - save_all=True, - append_images=frames[1:], - duration=1500, # 1.5 seconds per frame - loop=0, - ) - - return gif_filename - - -def load_and_preprocess_image(img_path): - """Load image and take mean if it has more than 3 dimensions. - Returns nibabel image if averaging was needed, otherwise returns original path.""" - img = nib.load(img_path) - - # Check if image has more than 3 dimensions - if len(img.shape) > 3: - print( - f" Converting 4D image to 3D by taking mean: {os.path.basename(img_path)}" - ) - # Take mean across the 4th dimension - data = img.get_fdata() - mean_data = np.mean(data, axis=3) - # Create new 3D image - img = nib.Nifti1Image(mean_data, img.affine, img.header) - return img # Return nibabel image object - else: - return img_path # Return original path if already 3D - - -def create_comparison_html( - orig_img, - defaced_img, - subject_id, - output_dir, - display_mode="side-by-side", - size="compact", - orig_path=None, - defaced_path=None, -): - """Create HTML comparison page for a subject using nilearn ortho views.""" - - # Get basenames for display - use actual filenames with BIDS suffixes if available - if orig_path and defaced_path: - orig_basename = os.path.basename(orig_path) - defaced_basename = os.path.basename(defaced_path) - else: - # Fallback to generic names if paths not provided - orig_basename = f"orig_{subject_id}.nii.gz" - defaced_basename = f"defaced_{subject_id}.nii.gz" - - # Generate images and get their filenames - image_files = [] - for label, img, basename, cmap in [ - ("original", orig_img, orig_basename, "hot"), # Colored for original - ("defaced", defaced_img, defaced_basename, "gray"), # Grey for defaced - ]: - # Debug: Print what we're processing - print(f"Processing {label} image: {basename}") - print(f" Image shape: {img.shape}") - print(f" Image data type: {img.get_data_dtype()}") - print( - f" Image min/max: {img.get_fdata().min():.3f}/{img.get_fdata().max():.3f}" - ) - - # Create single sagittal slice using matplotlib directly - img_data = img.get_fdata() - x_midpoint = img_data.shape[0] // 2 # Get middle slice index - - # Extract the sagittal slice and rotate it properly using matrix multiplication - sagittal_slice = img_data[x_midpoint, :, :] - - # Create 270-degree rotation matrix (to face left and right-side up) - angle_rad = np.radians(270) - cos_theta = np.cos(angle_rad) - sin_theta = np.sin(angle_rad) - rotation_matrix = np.array([[cos_theta, -sin_theta], [sin_theta, cos_theta]]) - - # Get image dimensions - h, w = sagittal_slice.shape - - # Create coordinate grid - y, x = np.mgrid[0:h, 0:w] - coords = np.vstack([x.ravel(), y.ravel()]) - - # Center the coordinates - center = np.array([w / 2, h / 2]).reshape(2, 1) - coords_centered = coords - center - - # Apply rotation - rotated_coords = rotation_matrix @ coords_centered - - # Move back to original coordinate system - rotated_coords = rotated_coords + center - - # Interpolate the rotated image - from scipy.interpolate import griddata - - rotated_slice = griddata( - (rotated_coords[0], rotated_coords[1]), - sagittal_slice.ravel(), - (x, y), - method="linear", - fill_value=0, - ) - - # Crop the image to remove empty black space - # Find non-zero regions - non_zero_mask = rotated_slice > 0 - if np.any(non_zero_mask): - # Get bounding box of non-zero pixels - rows = np.any(non_zero_mask, axis=1) - cols = np.any(non_zero_mask, axis=0) - rmin, rmax = np.where(rows)[0][[0, -1]] - cmin, cmax = np.where(cols)[0][[0, -1]] - - # Add some padding - padding = 10 - rmin = max(0, rmin - padding) - rmax = min(rotated_slice.shape[0], rmax + padding) - cmin = max(0, cmin - padding) - cmax = min(rotated_slice.shape[1], cmax + padding) - - # Crop the image - cropped_slice = rotated_slice[rmin:rmax, cmin:cmax] - else: - cropped_slice = rotated_slice - - # Create figure with matplotlib - fig, ax = plt.subplots(figsize=(8, 8)) - im = ax.imshow(cropped_slice, cmap=cmap, aspect="equal") - ax.set_title(f"{label.title()}: {basename} ({cmap} colormap)") - ax.axis("off") - - # Save as PNG - png_filename = f"{label}_{subject_id}.png" - png_path = os.path.join(output_dir, "images", png_filename) - fig.savefig(png_path, dpi=150) - plt.close(fig) - - image_files.append((label, basename, png_filename)) - - # Create overlay GIF if we have both images - if len(image_files) == 2: - overlay_gif = create_overlay_gif(image_files, subject_id, output_dir) - image_files.append(("overlay", "comparison", overlay_gif)) - - # Create the comparison HTML with embedded images - html_content = f""" - - - - - PET Deface Comparison - {subject_id} - - - -
-

PET Deface Comparison - {subject_id}

-

Side-by-side comparison of original vs defaced neuroimaging data

+
{subject_id}
+
Defaced NIfTI Images
-
- """ +
+""" + + # Add a viewer for each NIfTI file + for i, nifti_file in enumerate(filtered_files): + filename = Path(nifti_file).name + # Create a label based on the filename + label = filename.replace(".nii.gz", "").replace(".nii", "") + label = label.replace("_", " ").replace("-", " ") + + # Convert file path to HTTP URL + # Get the relative path from the derivatives directory + derivatives_dir = os.path.join(os.path.dirname(output_dir), "..") + relative_path = os.path.relpath(nifti_file, derivatives_dir) + http_url = f"http://localhost:{server_port}/{relative_path}" - # Add content based on display mode - if display_mode == "side-by-side": - # Add images side by side only html_content += f"""
-

{image_files[0][0].title()}: {image_files[0][1]}

- {image_files[0][0].title()}: {image_files[0][1]} -
-
-

{image_files[1][0].title()}: {image_files[1][1]}

- {image_files[1][0].title()}: {image_files[1][1]} -
+
{label}
+
{http_url}
+
""" - elif display_mode == "gif": - # Show only the GIF - if len(image_files) > 2: - html_content += f""" -
-

Animated Comparison

-

Switching between Original and Defaced images

- Animated comparison -
-
+ + html_content += ( """ - else: - html_content += """ - """ - - html_content += """ -
- ← Back to Index + +
+
+ + + 0
- - - """ - - # Save the comparison HTML - comparison_file = os.path.join(output_dir, f"comparison_{subject_id}.html") - with open(comparison_file, "w") as f: - f.write(html_content) + +
- return comparison_file - - -def process_subject(subject, output_dir, size="compact"): - """Process a single subject (for parallel processing).""" - print(f"Processing {subject['id']}...") - try: - comparison_file = create_comparison_html( - subject["orig_img"], - subject["defaced_img"], - subject["id"], - output_dir, - "side-by-side", # Always generate side-by-side for individual pages - size, - subject["orig_path"], - subject["defaced_path"], + + + +""" + ) - comparisons_html = "" - for subject in subjects: - subject_id = subject["id"] + # Save the HTML file + html_path = os.path.join(output_dir, f"{subject_id}_nifti_viewer.html") + with open(html_path, "w") as f: + f.write(html_content) - # Use actual filenames with BIDS suffixes instead of generic names - orig_basename = os.path.basename(subject["orig_path"]) - defaced_basename = os.path.basename(subject["defaced_path"]) + print(f"Created NIfTI viewer: {html_path}") + return html_path - # Check if the PNG files exist - orig_png = f"images/original_{subject_id}.png" - defaced_png = f"images/defaced_{subject_id}.png" - comparisons_html += f""" -
-

{subject_id}

-
-
-

Original: {orig_basename}

- Original: {orig_basename} -
-
-

Defaced: {defaced_basename}

- Defaced: {defaced_basename} -
-
-
- """ +def create_simple_viewer_html(svg_files, output_dir): + """Create simple HTML viewer with all SVG images on one page.""" - html_content = f""" + # Create HTML content + html_content = """ - PET Deface Comparisons - Side by Side + PET Deface SVG Reports - + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + text-align: center; + min-height: 90vh; + display: flex; + flex-direction: column; + } + + .svg-item h3 { + margin: 0 0 10px 0; + color: #2c3e50; + font-size: 16px; + word-break: break-all; + } + + .svg-item svg { + flex: 1; + max-width: 100%; + max-height: calc(90vh - 50px); + width: auto; + height: auto; + border: 1px solid #ddd; + border-radius: 4px; + } + - -
-

PET Deface Comparisons - Side by Side

-

Static side-by-side comparison of original vs defaced neuroimaging data

+

PET Deface SVG Reports

+

All generated SVG reports

-
- {comparisons_html} -
- - - """ - - index_file = os.path.join(output_dir, "side_by_side.html") - with open(index_file, "w") as f: - f.write(html_content) - - return index_file - +
+""" -def create_gif_index_html(subjects, output_dir, size="compact"): - """Create index page for GIF comparisons.""" - - comparisons_html = "" - for subject in subjects: - subject_id = subject["id"] - overlay_gif = f"images/overlay_{subject_id}.gif" + # Add all SVG files + for svg_file in svg_files: + filename = os.path.basename(svg_file) + with open(svg_file, "r") as f: + svg_content = f.read() - comparisons_html += f""" -
-

{subject_id}

-
-

Animated Comparison

-

Switching between Original and Defaced images

- Animated comparison -
+ html_content += f""" +
+

{filename}

+ {svg_content}
""" - html_content = f""" - - - - - PET Deface Comparisons - Animated - - - - - - -
-

PET Deface Comparisons - Animated

-

Animated GIF comparison of original vs defaced neuroimaging data

-
- -
- {comparisons_html} + html_content += """
""" - index_file = os.path.join(output_dir, "animated.html") - with open(index_file, "w") as f: + # Write the HTML file + html_file = os.path.join(output_dir, "svg_reports.html") + with open(html_file, "w") as f: f.write(html_content) - return index_file + return html_file def run_qa( - faced_dir, defaced_dir, output_dir=None, - subject=None, - n_jobs=None, - size="compact", open_browser=False, + start_server=False, + server_port=8000, ): """ - Run QA report generation programmatically. + Run QA report generation for SVG reports in derivatives/petdeface. Args: - faced_dir (str): Path to original (faced) dataset directory - defaced_dir (str): Path to defaced dataset directory - output_dir (str, optional): Output directory for HTML files - subject (str, optional): Filter to specific subject - n_jobs (int, optional): Number of parallel jobs - size (str): Image size - 'compact' or 'full' + defaced_dir (str): Path to defaced dataset directory (containing derivatives/petdeface) + output_dir (str, optional): Output directory for HTML files (defaults to derivatives/petdeface/qa/) open_browser (bool): Whether to open browser automatically + start_server (bool): Whether to start a local HTTP server for NIfTI files + server_port (int): Port for the HTTP server Returns: dict: Information about generated files """ - faced_dir = os.path.abspath(faced_dir) defaced_dir = os.path.abspath(defaced_dir) - # Create output directory name based on input directories + # Create output directory in derivatives/petdeface/qa/ if output_dir: output_dir = os.path.abspath(output_dir) else: - orig_folder = os.path.basename(faced_dir) - defaced_folder = os.path.basename(defaced_dir) - output_dir = os.path.abspath(f"{orig_folder}_{defaced_folder}_qa") + # Look for derivatives/petdeface directory + derivatives_dir = os.path.join(defaced_dir, "derivatives", "petdeface") + if not os.path.exists(derivatives_dir): + print( + f"Warning: derivatives/petdeface directory not found at {derivatives_dir}" + ) + print("Looking for derivatives directory in parent...") + # Try parent directory (in case defaced_dir is the derivatives folder) + derivatives_dir = os.path.join( + os.path.dirname(defaced_dir), "derivatives", "petdeface" + ) + if not os.path.exists(derivatives_dir): + print( + f"Warning: derivatives/petdeface directory not found at {derivatives_dir}" + ) + print("Creating QA output in current directory...") + output_dir = os.path.abspath("qa_reports") + else: + output_dir = os.path.join(derivatives_dir, "qa") + else: + output_dir = os.path.join(derivatives_dir, "qa") - # Create output directory and images subdirectory + # Create output directory os.makedirs(output_dir, exist_ok=True) - os.makedirs(os.path.join(output_dir, "images"), exist_ok=True) print(f"Output directory: {output_dir}") - # Build subjects list - subjects = build_subjects_from_datasets(faced_dir, defaced_dir) - print(f"Found {len(subjects)} subjects with matching files") - - # Filter to specific subject if requested - if subject: - original_count = len(subjects) - subjects = [s for s in subjects if subject in s["id"]] - print( - f"Filtered to {len(subjects)} subjects matching '{subject}' (from {original_count} total)" - ) + # Collect SVG reports + print(f"Collecting SVG reports from {defaced_dir}...") + svg_files = collect_svg_reports(defaced_dir, output_dir) - if not subjects: - print(f"No subjects found matching '{subject}'") - print("Available subjects:") - all_subjects = build_subjects_from_datasets(faced_dir, defaced_dir) - for s in all_subjects: - print(f" - {s['id']}") - raise ValueError(f"No subjects found matching '{subject}'") - - # Set number of jobs for parallel processing - if n_jobs is None: - n_jobs = mp.cpu_count() - print(f"Using {n_jobs} parallel processes") - - # Preprocess all images once (4D→3D conversion) - preprocessed_subjects = preprocess_images(subjects, output_dir, n_jobs) - - # create nireports svg's for comparison - generate_simple_before_and_after( - preprocessed_subjects=preprocessed_subjects, output_dir=output_dir - ) + if not svg_files: + print("No SVG files found to process!") + return {"output_dir": output_dir, "error": "No SVG files found"} - # Process subjects in parallel - print("Generating comparisons...") - with mp.Pool(processes=n_jobs) as pool: - # Create a partial function with the output_dir and size fixed - process_func = partial( - process_subject, - output_dir=output_dir, - size=size, - ) + # Create HTML viewer for SVG reports + print("Creating SVG HTML viewer...") + svg_html_file = create_simple_viewer_html(svg_files, output_dir) - # Process all subjects in parallel - results = pool.map(process_func, preprocessed_subjects) + # Collect and create NIfTI viewers + print("Collecting NIfTI files...") + nifti_files_by_subject = collect_nifti_files(defaced_dir) - # Count successful results - successful = [r for r in results if r is not None] - print( - f"Successfully processed {len(successful)} out of {len(preprocessed_subjects)} subjects" - ) + nifti_viewer_files = [] + if nifti_files_by_subject: + print("Creating NIfTI viewers...") + for subject_id, nifti_files in nifti_files_by_subject.items(): + viewer_file = create_nifti_viewer_html( + subject_id, nifti_files, output_dir, server_port + ) + if viewer_file: + nifti_viewer_files.append(viewer_file) + else: + print("No NIfTI files found for viewing") - # Create both HTML files - side_by_side_file = create_side_by_side_index_html( - preprocessed_subjects, output_dir, size - ) - animated_file = create_gif_index_html(preprocessed_subjects, output_dir, size) + # Generate NIfTI viewer links with server detection + nifti_links = "" + for viewer_file in nifti_viewer_files: + viewer_name = Path(viewer_file).name + subject_id = viewer_name.replace("_nifti_viewer.html", "") + nifti_links += f'{subject_id} NIfTI Viewer\n ' - # Create a simple index that links to both + # Create simple index index_html = f""" - PET Deface Comparisons + PET Deface QA Reports -
-

PET Deface Comparisons

-

Choose your preferred viewing mode:

- - Side by Side View - Animated GIF View - SVG Reports View - -

- Generated with {len(preprocessed_subjects)} subjects +

+

PET Deface QA Reports

+

Quality assessment reports for defacing workflow

+
+ +
+

Available Reports

+ +
+

SVG Reports

+

Click the link below to view all SVG reports:

+ View All SVG Reports +

+ Found {len(svg_files)} SVG report(s) +

+
+ +
+

NIfTI Viewers

+

Click the links below to view rotating 3D NIfTI images:

+ +

+ Found {len(nifti_viewer_files)} NIfTI viewer(s)

+
+
+ + """ @@ -1409,42 +794,10 @@ def run_qa( with open(index_file, "w") as f: f.write(index_html) - # Save the command with full expanded paths - import sys - - command_parts = [ - sys.executable, - os.path.abspath(__file__), - "--faced-dir", - faced_dir, - "--defaced-dir", - defaced_dir, - "--output-dir", - output_dir, - "--size", - size, - ] - if n_jobs: - command_parts.extend(["--n-jobs", str(n_jobs)]) - if subject: - command_parts.extend(["--subject", subject]) - if open_browser: - command_parts.append("--open-browser") - - command_str = " ".join(command_parts) - - command_file = os.path.join(output_dir, "command.txt") - with open(command_file, "w") as f: - f.write(f"# Command used to generate this comparison\n") - f.write( - f"# Generated on: {__import__('datetime').datetime.now().isoformat()}\n\n" - ) - f.write(command_str + "\n") - - print(f"Created side-by-side view: {side_by_side_file}") - print(f"Created animated view: {animated_file}") - print(f"Created main report: {index_file}") - print(f"Saved command to: {command_file}") + print(f"Created main index: {index_file}") + print(f"Created SVG viewer: {svg_html_file}") + if nifti_viewer_files: + print(f"Created {len(nifti_viewer_files)} NIfTI viewer(s)") # Open browser if requested if open_browser: @@ -1452,65 +805,80 @@ def run_qa( print(f"Opened browser to: {index_file}") print(f"\nAll files generated in: {output_dir}") - print(f"Open petdeface_report.html in your browser to view comparisons") + print(f"Open index.html in your browser to view reports") + + # Start server if requested + if start_server and nifti_viewer_files: + print(f"\nStarting HTTP server on port {server_port}...") + print("This server is needed for NIfTI file access due to CORS restrictions.") + print("Keep this terminal open while viewing NIfTI files.") + + # Kill any existing process on the port + kill_process_on_port(server_port) + + # Get the derivatives directory to serve from + derivatives_dir = os.path.join(os.path.dirname(output_dir), "..") + + print(f"Server is running at http://localhost:{server_port}") + print("NIfTI viewers will now work properly.") + print("Press Ctrl+C to stop the server") + + # Start server in foreground (this will block) + start_local_server(server_port, derivatives_dir) return { "output_dir": output_dir, - "side_by_side_file": side_by_side_file, - "animated_file": animated_file, - "report_file": index_file, - "command_file": command_file, - "subjects_processed": len(successful), - "total_subjects": len(preprocessed_subjects), + "index_file": index_file, + "svg_viewer": svg_html_file, + "svg_files_count": len(svg_files), + "nifti_viewers": nifti_viewer_files, + "nifti_viewers_count": len(nifti_viewer_files), + "server_started": start_server and nifti_viewer_files, + "server_port": server_port if start_server and nifti_viewer_files else None, } def main(): parser = argparse.ArgumentParser( - description="Generate static HTML comparisons of PET deface results using nilearn." + description="Generate SVG QA reports for PET deface workflow." ) parser.add_argument( - "--faced-dir", required=True, help="Directory for original (faced) dataset" - ) - parser.add_argument( - "--defaced-dir", required=True, help="Directory for defaced dataset" + "bids_dir", + help="BIDS directory containing the defaced dataset (with derivatives/petdeface)", ) parser.add_argument( "--output-dir", - help="Output directory for HTML files (default: {orig_folder}_{defaced_folder}_qa)", + "--output_dir", + help="Output directory for HTML files (default: derivatives/petdeface/qa/)", ) parser.add_argument( - "--open-browser", action="store_true", help="Open browser automatically" + "--open-browser", + "--open_browser", + action="store_true", + help="Open browser automatically", ) parser.add_argument( - "--n-jobs", - type=int, - default=None, - help="Number of parallel jobs (default: all cores)", + "--start-server", + "--start_server", + action="store_true", + help="Start local HTTP server for NIfTI file access (required for NIfTI viewers)", ) parser.add_argument( - "--subject", - type=str, - help="Filter to specific subject (e.g., 'sub-01' or 'sub-01_ses-baseline')", + "--qa-port", + "--qa_port", + type=int, + default=8000, + help="User can manually choose a default port to serve the nifti images at for 3D previewing of defacing should the default value of 8000 not work.", ) - parser.add_argument( - "--size", - type=str, - choices=["compact", "full"], - default="compact", - help="Image size: 'compact' for closer together or 'full' for entire page width", - ) args = parser.parse_args() return run_qa( - faced_dir=args.faced_dir, - defaced_dir=args.defaced_dir, + defaced_dir=args.bids_dir, output_dir=args.output_dir, - subject=args.subject, - n_jobs=args.n_jobs, - size=args.size, open_browser=args.open_browser, + start_server=args.start_server, + server_port=args.qa_port, ) diff --git a/petdeface/serve.py b/petdeface/serve.py new file mode 100644 index 0000000..151feb0 --- /dev/null +++ b/petdeface/serve.py @@ -0,0 +1,321 @@ +from flask import Flask, render_template, send_from_directory, request, abort +import os +import socket +import argparse +import json +from glob import glob +from pathlib import Path + + +def create_app(subjects): + app = Flask(__name__, static_folder="../", template_folder="templates") + + @app.route("/") + def index(): + # Create a list of all scan comparisons + scan_comparisons = [] + for subject in subjects: + # Group sessions by scan type (anat, pet, defacemask) + scan_groups = {} + for session in subject["sessions"]: + # Extract scan type from path + path_parts = session["nifti_path"].split("/") + scan_type = "unknown" + if "anat" in path_parts: + scan_type = "anat" + elif "pet" in path_parts: + scan_type = "pet" + elif "defacemask" in path_parts: + scan_type = "defacemask" + + if scan_type not in scan_groups: + scan_groups[scan_type] = [] + scan_groups[scan_type].append(session) + + # Create a comparison for each scan type + for scan_type, sessions in scan_groups.items(): + if len(sessions) == 2: # Original and Defaced + comparison = { + "subject_id": subject["id"], + "scan_type": scan_type, + "sessions": sessions, + } + scan_comparisons.append(comparison) + + # Prefix all nifti_paths with /file/ (only if not already prefixed) + for comparison in scan_comparisons: + for session in comparison["sessions"]: + # Remove any existing /file/ prefix first, then add it back + if session["nifti_path"].startswith("/file/"): + session["nifti_path"] = session["nifti_path"][6:] # Remove /file/ + if session["nifti_path"].startswith("/"): + session["nifti_path"] = f"/file{session['nifti_path']}" + + return render_template("niivue.html", scan_comparisons=scan_comparisons) + + @app.route("/scan//") + def scan_page(subject_id, scan_type): + """Serve a single scan comparison page""" + # Find the specific scan comparison + target_comparison = None + for subject in subjects: + if subject["id"] == subject_id: + # Check if this subject has the requested scan type + if "scan_type" in subject and subject["scan_type"] == scan_type: + # Use the sessions directly if scan type matches + if len(subject["sessions"]) == 2: # Original and Defaced + target_comparison = { + "subject_id": subject_id, + "scan_type": scan_type, + "sessions": subject["sessions"], + } + break + else: + # Fallback to path-based detection for backward compatibility + sessions = [] + for session in subject["sessions"]: + path_parts = session["nifti_path"].split("/") + current_scan_type = "unknown" + if "anat" in path_parts: + current_scan_type = "anat" + elif "pet" in path_parts: + current_scan_type = "pet" + + if current_scan_type == scan_type: + sessions.append(session) + + if len(sessions) == 2: # Original and Defaced + target_comparison = { + "subject_id": subject_id, + "scan_type": scan_type, + "sessions": sessions, + } + break + + if not target_comparison: + abort(404, description="Scan comparison not found") + + # Prefix nifti_paths with /file/ + for session in target_comparison["sessions"]: + if session["nifti_path"].startswith("/") and not session[ + "nifti_path" + ].startswith("/file/"): + session["nifti_path"] = f"/file{session['nifti_path']}" + + return render_template("scan.html", comparison=target_comparison) + + # Serve static files (NIfTI, etc.) from the project root + @app.route("/") + def serve_static(filename): + return send_from_directory( + __file__, + filename, + # os.path.dirname(__file__) + ) + + # New route to serve files from anywhere on the filesystem + @app.route("/file/") + def serve_file(filepath): + """ + Serve files from anywhere on the local filesystem. + URL format: http://localhost:8000/file/path/to/file + Example: http://localhost:8000/file/Users/username/Documents/file.txt + """ + try: + # Convert URL path to filesystem path + # Remove any leading slashes and decode URL encoding + clean_path = filepath.lstrip("/") + + # Security check: prevent directory traversal attacks + if ".." in clean_path or clean_path.startswith("/"): + abort(403, description="Access denied") + + # Convert to absolute path - reconstruct the full path + # The filepath comes from the URL without the leading slash + # So we need to add it back to make it an absolute path + if not clean_path.startswith("/"): + clean_path = "/" + clean_path + + file_path = Path(clean_path) + + # Additional security: ensure the file exists and is readable + if not file_path.exists(): + abort(404, description="File not found") + + if not file_path.is_file(): + abort(400, description="Not a file") + + # Serve the file + return send_from_directory( + directory=str(file_path.parent), + path=file_path.name, + as_attachment=False, + ) + + except Exception as e: + abort(500, description=f"Error serving file: {str(e)}") + + return app + + +# Default sample data +sample_subjects = [ + { + "id": "sub-01", + "sessions": [ + { + "label": "Original", + "nifti_path": "data/sub-01/ses-baseline/anat/sub-01_ses-baseline_T1w.nii", + }, + { + "label": "Defaced", + "nifti_path": "data/sub-01/ses-baseline/anat/sub-01_ses-baseline_T1w.nii", + }, + ], + }, + { + "id": "sub-02", + "sessions": [ + { + "label": "Original", + "nifti_path": "data/sub-02/ses-baseline/anat/sub-02_ses-baseline_T1w.nii", + }, + { + "label": "Defaced", + "nifti_path": "data/sub-02/ses-baseline/anat/sub-02_ses-baseline_T1w.nii", + }, + ], + }, +] + + +def build_subjects_from_datasets(original_dir, defaced_dir): + # Find all NIfTI files in both datasets + orig_files = glob(os.path.join(original_dir, "**", "*.nii*"), recursive=True) + defaced_files = glob(os.path.join(defaced_dir, "**", "*.nii*"), recursive=True) + + # Build a mapping from base filename (without extension) to path + def strip_ext(path): + base = os.path.basename(path) + if base.endswith(".gz"): + base = os.path.splitext(os.path.splitext(base)[0])[0] + else: + base = os.path.splitext(base)[0] + return base + + orig_map = {strip_ext(f): f for f in orig_files} + defaced_map = {strip_ext(f): f for f in defaced_files} + # Find intersection of base names + common_keys = sorted(set(orig_map.keys()) & set(defaced_map.keys())) + subjects = [] + for key in common_keys: + # Try to extract subject/session from path (BIDS-like) + orig_path = orig_map[key] + defaced_path = defaced_map[key] + # Try to get subject id from path + parts = orig_path.split(os.sep) + sub_id = next((p for p in parts if p.startswith("sub-")), key) + session = next((p for p in parts if p.startswith("ses-")), "session") + print(f"defaced_path {defaced_path}") + print(f"os.path.dirname(__file__) {os.path.dirname(__file__)}") + print( + f"os.path.relpath(defaced_path, os.path.dirname(__file__)) {os.path.relpath(defaced_path, os.path.dirname(__file__))}" + ) + subjects.append( + { + "id": sub_id, + "sessions": [ + { + "label": "Original", + "nifti_path": orig_path, + }, + { + "label": "Defaced", + "nifti_path": defaced_path, + }, + ], + } + ) + if not subjects: + print("No matching NIfTI files found in both datasets.") + exit(1) + return subjects + + +def run_server(subjects=None, port=None): + if subjects is None: + subjects = sample_subjects + app = create_app(subjects) + + # If no port specified, find an available port + if port is None: + port = 8000 + while True: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("127.0.0.1", port)) + s.close() + break + except OSError: + port += 1 + + print(f"Serving on http://127.0.0.1:{port}") + app.run(debug=True, port=port) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Serve NiiVue viewer with dynamic subjects." + ) + parser.add_argument( + "--subject-list", + type=str, + help="Either a path to a JSON file or a JSON string containing the subjects list (mutually exclusive with --original-dataset/--defaced-dataset)", + ) + parser.add_argument( + "--original-dataset", + type=str, + help="Path to the original dataset directory (must be used with --defaced-dataset)", + ) + parser.add_argument( + "--defaced-dataset", + type=str, + help="Path to the defaced dataset directory (must be used with --original-dataset)", + ) + parser.add_argument( + "--port", + type=int, + default=8000, + help="Port to run the Flask server on (default: 8000)", + ) + args = parser.parse_args() + + # Mutually exclusive logic + if (args.original_dataset and not args.defaced_dataset) or ( + args.defaced_dataset and not args.original_dataset + ): + print( + "Error: --original-dataset and --defaced-dataset must be provided together." + ) + exit(1) + if args.original_dataset and args.defaced_dataset: + subjects = build_subjects_from_datasets( + args.original_dataset, args.defaced_dataset + ) + elif args.subject_list: + if os.path.isfile(args.subject_list): + with open(args.subject_list, "r") as f: + subjects = json.load(f) + else: + try: + subjects = json.loads(args.subject_list) + except json.JSONDecodeError as e: + print( + f"Error: --subject-list is not a valid file path or JSON string. {e}" + ) + exit(1) + else: + subjects = sample_subjects + + print(subjects) + run_server(subjects, args.port) diff --git a/petdeface/templates/niivue.html b/petdeface/templates/niivue.html new file mode 100644 index 0000000..dc1fb6b --- /dev/null +++ b/petdeface/templates/niivue.html @@ -0,0 +1,95 @@ + + + + + NiiVue - Scan Comparisons + + + +
+
NiiVue Viewer
+
Select a scan comparison to view
+
+ +
+ {% for comparison in scan_comparisons %} +
+
{{ comparison.subject_id }}
+
{{ comparison.scan_type.upper() }}
+
Original vs Defaced
+ View Comparison +
+ {% endfor %} +
+ + \ No newline at end of file diff --git a/petdeface/templates/scan.html b/petdeface/templates/scan.html new file mode 100644 index 0000000..3a92950 --- /dev/null +++ b/petdeface/templates/scan.html @@ -0,0 +1,234 @@ + + + + + NiiVue - {{ comparison.subject_id }} {{ comparison.scan_type.upper() }} + + + +
+ ← Back to All Comparisons +
{{ comparison.subject_id }}
+
{{ comparison.scan_type.upper() }}
+
+ +
+ {% for session in comparison.sessions %} +
+
{{ session.label }}
+
+ {{ session.nifti_path.replace('/file', '') if session.nifti_path.startswith('/file') else session.nifti_path }} +
+ +
+ {% endfor %} +
+ +
+
+ + + 0 +
+ +
+ + + + \ No newline at end of file diff --git a/petdeface/templates/subject.html b/petdeface/templates/subject.html new file mode 100644 index 0000000..6485566 --- /dev/null +++ b/petdeface/templates/subject.html @@ -0,0 +1,226 @@ + + + + + NiiVue - {{ subject.id }} + + + +
+ ← Back to All Subjects +
{{ subject.id }}
+
+ +
+ {% for session in subject.sessions %} +
+
{{ session.label }}
+
+ {{ session.nifti_path.replace('/file', '') if session.nifti_path.startswith('/file') else session.nifti_path }} +
+ +
+ {% endfor %} +
+ +
+
+ + + 0 +
+ +
+ + + + \ No newline at end of file diff --git a/petdeface/templates/viewer.html b/petdeface/templates/viewer.html new file mode 100644 index 0000000..6f053ae --- /dev/null +++ b/petdeface/templates/viewer.html @@ -0,0 +1,20 @@ + + + + + NiiVue Viewer + + + + + + + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index cba2b8e..3254f4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "petdeface" -version = "0.3.0" +version = "0.3.1" description = "A nipype PET and MR defacing pipeline for BIDS datasets utilizing FreeSurfer's MiDeFace." authors = [ {name = "Martin Nørgaard", email = "martin.noergaard@nru.dk"}, @@ -48,6 +48,7 @@ dev = [ [project.scripts] petdeface = "petdeface.petdeface:main" +petdeface-qa = "petdeface.qa:main" # please update the bids version to the latest compliant version when making modifications to this code here [tool.bids] @@ -65,5 +66,13 @@ build-backend = "setuptools.build_meta" [tool.setuptools] packages = ["petdeface"] include-package-data = false -package-data = { "petdeface" = ["data/sub-01/**/*T1w.nii", "data/sub-01/**/*T1w.nii.gz", "data/sub-01/**/*T1w.json", "data/sub-mni305/**/*T1w.nii", "data/sub-mni305/**/*T1w.nii.gz", "data/sub-mni305/**/*T1w.json"] } +package-data = { "petdeface" = [ + "data/sub-01/**/*T1w.nii", + "data/sub-01/**/*T1w.nii.gz", + "data/sub-01/**/*T1w.json", + "data/sub-mni305/**/*T1w.nii", + "data/sub-mni305/**/*T1w.nii.gz", + "data/sub-mni305/**/*T1w.json", + "templates/*.html" +] } exclude-package-data = { "petdeface" = ["data/sub-02/**/*"] } From 9a8ed4aa73b4c0b72968e875e3731105ee33d922 Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Thu, 28 Aug 2025 19:02:13 -0400 Subject: [PATCH 2/3] Merged in the wrong branch earlier for QA (#67) * serving from across the host file system * comparison works for sure * scan niivue preview added to full report * update avg and warp to occur during main pipeline * generating svg's for comparison w/ good ole nipype * renamed svg outputs * rotating heads are back * add entrypoint for qa reports * standardize commands * refactored from defaced dir to bids dir to make qa usage more clear * clean up toml and fix import * Add missing --use_template_anat argument to fix AttributeError * add badge to readme --- README.md | 1 + petdeface/petdeface.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/README.md b/README.md index 4e96e3e..8599757 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This software can be installed via source or via pip from PyPi with `pip install | CI | Status | |---------| ------ | +| `Run on test data` | [![Run on test data](https://github.com/openneuropet/petdeface/actions/workflows/run_on_test_data.yaml/badge.svg)](https://github.com/openneuropet/petdeface/actions/workflows/run_on_test_data.yaml) | | `docker build . -t petdeface` | ![docker_build](https://codebuild.us-east-1.amazonaws.com/badges?uuid=eyJlbmNyeXB0ZWREYXRhIjoiYzdXV0tYSkQzTVNkcG04cHA2S055UXlKRlZTU1VONThUMVRoZVcwU3l1aHFhdVBlNDNaRGVCYzdWM1Q0WjYzQ1lRU2ZTSHpmSERPWFRkVXVyb3k3RTZBPSIsIml2UGFyYW1ldGVyU3BlYyI6IjRCZFFIQnNGT2lKcDA1VG4iLCJtYXRlcmlhbFNldFNlcmlhbCI6MX0%3D&branch=main) | | `docker push` | ![docker push icon](https://codebuild.us-east-1.amazonaws.com/badges?uuid=eyJlbmNyeXB0ZWREYXRhIjoia0c1bEJYUGI2SXlWYi9JMm1tcGtiYWVTdVd3bmlnOUFaTjN4QjJITU5PTVpvQnN3TlowajhxNmhHY2RwQ2Z5SU93OExqc2xvMzFnTHFvajlqVk1MV2FzPSIsIml2UGFyYW1ldGVyU3BlYyI6Ikl6SzRyc1RabzBnSkplTjciLCJtYXRlcmlhbFNldFNlcmlhbCI6MX0%3D&branch=main) | | `Python 3.14 >= 3.10` | [![Check Python Compatibility](https://github.com/openneuropet/petdeface/actions/workflows/check_python_compatibility.yaml/badge.svg)](https://github.com/openneuropet/petdeface/actions/workflows/check_python_compatibility.yaml) | diff --git a/petdeface/petdeface.py b/petdeface/petdeface.py index 4ce5993..89b2051 100755 --- a/petdeface/petdeface.py +++ b/petdeface/petdeface.py @@ -1371,6 +1371,14 @@ def cli(): required=False, default=[], ) + parser.add_argument( + "--use_template_anat", + help="Use template anatomical image when no T1w is available for PET scans. Options: 't1' (included T1w template), 'mni' (MNI template), or 'pet' (averaged PET image).", + type=str, + choices=["t1", "mni", "pet"], + required=False, + default=None, + ) parser.add_argument( "--open-browser", "--open_browser", From d9bbeedb91de898288b3199ccb80eac22e591a7e Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Thu, 28 Aug 2025 20:09:38 -0400 Subject: [PATCH 3/3] added t1w and pet strings to fix naming collisions for before and after reports --- petdeface/petdeface.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/petdeface/petdeface.py b/petdeface/petdeface.py index 89b2051..0a0f866 100755 --- a/petdeface/petdeface.py +++ b/petdeface/petdeface.py @@ -598,7 +598,7 @@ def init_single_subject_wf( after_label="Defaced T1w", out_report=f"{anat_string}_t1w_before_after.svg", ), - name=f"{anat_string}_before_and_after_report", + name=f"{anat_string}_t1w_before_and_after_report", ) t1w_before_after_report.inputs.before = t1w_file @@ -609,7 +609,7 @@ def init_single_subject_wf( after_label=f"Defaced {pet_string}", out_report=f"{pet_string}_pet_before_after.svg", ), - name=f"{pet_string}_before_and_after_report", + name=f"{pet_string}_pet_before_and_after_report", ) pet_to_t1w_coregistration = Node( SimpleBeforeAfterRPT(