From 531077139053367b271fcded04de0cfed478019c Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:33:21 -0400 Subject: [PATCH 01/15] just going to use niivue for this --- petdeface/niivue.html | 55 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 petdeface/niivue.html diff --git a/petdeface/niivue.html b/petdeface/niivue.html new file mode 100644 index 0000000..aead905 --- /dev/null +++ b/petdeface/niivue.html @@ -0,0 +1,55 @@ + + + + + NiiVue + + +
sub-01
+
+
+
Original
+ +
+
+
Defaced
+ +
+
+
+ + + 0 +
+ + + \ No newline at end of file From d09c3a83977afeee4e7c84b5a3804ef3d7b54beb Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Thu, 3 Jul 2025 12:53:20 -0400 Subject: [PATCH 02/15] dynamically render preview --- petdeface/niivue.html | 143 ++++++++++++++++++++++++---------- petdeface/serve.py | 176 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 40 deletions(-) create mode 100644 petdeface/serve.py diff --git a/petdeface/niivue.html b/petdeface/niivue.html index aead905..542c2cb 100644 --- a/petdeface/niivue.html +++ b/petdeface/niivue.html @@ -5,51 +5,114 @@ NiiVue -
sub-01
-
-
-
Original
- + {% for subject in subjects %} +
+ {{ subject.id }}
-
-
Defaced
- +
+ {% for session in subject.sessions %} +
+
+ {{ session.label }}
+ + {{ session.nifti_path.split('/')[-1] }} + +
+ +
+
+ {% endfor %}
-
-
- - - 0 -
+
+ + + 0 + +
+ {% endfor %} + {% set viewer_configs = [] %} + {% for subject in subjects %} + {% for session in subject.sessions %} + {% set _ = viewer_configs.append({'canvasId': 'gl_' ~ subject.id ~ '_' ~ session.label, 'niftiPath': session.nifti_path}) %} + {% endfor %} + {% endfor %} \ No newline at end of file diff --git a/petdeface/serve.py b/petdeface/serve.py new file mode 100644 index 0000000..70f4d91 --- /dev/null +++ b/petdeface/serve.py @@ -0,0 +1,176 @@ +from flask import Flask, render_template, send_from_directory +import os +import socket +import argparse +import json +from glob import glob + + +def create_app(subjects): + app = Flask(__name__, static_folder="../", template_folder=".") + + @app.route("/") + def index(): + return render_template("niivue.html", subjects=subjects) + + # Serve static files (NIfTI, etc.) from the project root + @app.route("/") + def serve_static(filename): + return send_from_directory( + os.path.abspath(os.path.join(os.path.dirname(__file__), "..")), filename + ) + + 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") + subjects.append( + { + "id": sub_id, + "sessions": [ + { + "label": "Original", + "nifti_path": os.path.relpath( + orig_path, os.path.dirname(__file__) + ), + }, + { + "label": "Defaced", + "nifti_path": os.path.relpath( + defaced_path, os.path.dirname(__file__) + ), + }, + ], + } + ) + if not subjects: + print("No matching NIfTI files found in both datasets.") + exit(1) + return subjects + + +def run_server(subjects=None): + if subjects is None: + subjects = sample_subjects + app = create_app(subjects) + 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)", + ) + 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 + + run_server(subjects) + + From e006e608f4b356dc395213a20d8e202fb778f9b5 Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:34:32 -0400 Subject: [PATCH 03/15] matplotlib --- petdeface/serve.py | 529 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 410 insertions(+), 119 deletions(-) diff --git a/petdeface/serve.py b/petdeface/serve.py index 70f4d91..9e320f4 100644 --- a/petdeface/serve.py +++ b/petdeface/serve.py @@ -1,65 +1,270 @@ -from flask import Flask, render_template, send_from_directory -import os -import socket import argparse -import json +import os +import tempfile +import shutil from glob import glob +import nilearn +from nilearn import plotting +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 + + +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) -def create_app(subjects): - app = Flask(__name__, static_folder="../", template_folder=".") + # Get data + orig_data = orig_img.get_fdata() + defaced_data = defaced_img.get_fdata() - @app.route("/") - def index(): - return render_template("niivue.html", subjects=subjects) + # Create figure for animation + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 6)) + fig.suptitle(f"Animated Comparison - {subject_id}", fontsize=16) - # Serve static files (NIfTI, etc.) from the project root - @app.route("/") - def serve_static(filename): - return send_from_directory( - os.path.abspath(os.path.join(os.path.dirname(__file__), "..")), filename + # 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 load_and_preprocess_image(img_path): + """Load image and take mean if it has more than 3 dimensions.""" + 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 app + return img -# 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 create_comparison_html(orig_path, defaced_path, subject_id, output_dir): + """Create HTML comparison page for a subject using nilearn ortho views.""" + + # Get basenames for display + orig_basename = os.path.basename(orig_path) + defaced_basename = os.path.basename(defaced_path) + + # Generate images and get their filenames + image_files = [] + for label, img_path, cmap in [ + ("original", orig_path, "hot"), # Colored for original + ("defaced", defaced_path, "gray"), # Grey for defaced + ]: + # Get the basename for display + basename = os.path.basename(img_path) + + # Load and preprocess image (handle 4D if needed) + img = load_and_preprocess_image(img_path) + + # 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, png_filename) + fig.savefig(png_path, dpi=150) + plt.close(fig) + + image_files.append((label, basename, png_filename)) + # 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

+ + + """ -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) + # Add images side by side + html_content += f""" + + + + + """ + + html_content += """ +
+

{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]} +
+ +

← Back to Index

+ + + """ + + # 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): + """Process a single subject (for parallel processing).""" + print(f"Processing {subject['id']}...") + try: + comparison_file = create_comparison_html( + subject["orig_path"], subject["defaced_path"], subject["id"], output_dir + ) + print(f" Completed: {subject['id']}") + return comparison_file + except Exception as e: + print(f" Error processing {subject['id']}: {e}") + return None + + +def build_subjects_from_datasets(orig_dir, defaced_dir): + """Build subject list with file paths.""" + orig_files = glob(os.path.join(orig_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"): @@ -68,109 +273,195 @@ def strip_ext(path): 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 + def get_unique_key(file_path): + """Create a unique key that includes session information.""" + parts = file_path.split(os.sep) + sub_id = next((p for p in parts if p.startswith("sub-")), "") + session = next((p for p in parts if p.startswith("ses-")), "") + basename = strip_ext(file_path) + + # Create unique key that includes session if present + if session: + return f"{sub_id}_{session}_{basename}" + else: + return f"{sub_id}_{basename}" + + # Create maps with unique keys + orig_map = {get_unique_key(f): f for f in orig_files} + defaced_map = {get_unique_key(f): f for f in defaced_files} 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") + session = next((p for p in parts if p.startswith("ses-")), "") + + # Create a unique subject ID that includes session if present + if session: + subject_id = f"{sub_id}_{session}" + else: + subject_id = sub_id + subjects.append( { - "id": sub_id, - "sessions": [ - { - "label": "Original", - "nifti_path": os.path.relpath( - orig_path, os.path.dirname(__file__) - ), - }, - { - "label": "Defaced", - "nifti_path": os.path.relpath( - defaced_path, os.path.dirname(__file__) - ), - }, - ], + "id": subject_id, + "orig_path": orig_path, + "defaced_path": defaced_path, } ) + if not subjects: print("No matching NIfTI files found in both datasets.") exit(1) + return subjects -def run_server(subjects=None): - if subjects is None: - subjects = sample_subjects - app = create_app(subjects) - 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) +def create_index_html(subjects, output_dir): + """Create index page with all comparisons embedded directly.""" + # Generate all comparison images first + comparisons_html = "" + for subject in subjects: + subject_id = subject["id"] + orig_basename = os.path.basename(subject["orig_path"]) + defaced_basename = os.path.basename(subject["defaced_path"]) -if __name__ == "__main__": + # Check if the PNG files exist + orig_png = f"original_{subject_id}.png" + defaced_png = f"defaced_{subject_id}.png" + + comparisons_html += f""" +

{subject_id}

+ + + + + +
+

Original: {orig_basename}

+ Original: {orig_basename} +
+

Defaced: {defaced_basename}

+ Defaced: {defaced_basename} +
+
+ """ + + html_content = f""" + + + + + PET Deface Comparisons + + +

PET Deface Comparisons

+

Side-by-side comparison of original vs defaced neuroimaging data for all subjects

+ + {comparisons_html} + + + """ + + index_file = os.path.join(output_dir, "index.html") + with open(index_file, "w") as f: + f.write(html_content) + + return index_file + + +def main(): parser = argparse.ArgumentParser( - description="Serve NiiVue viewer with dynamic subjects." + description="Generate static HTML comparisons of PET deface results using nilearn." ) 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)", + "--faced-dir", required=True, help="Directory for original (faced) dataset" ) parser.add_argument( - "--original-dataset", - type=str, - help="Path to the original dataset directory (must be used with --defaced-dataset)", + "--defaced-dir", required=True, help="Directory for defaced dataset" + ) + parser.add_argument( + "--output-dir", + default="petdeface_comparisons", + help="Output directory for HTML files", + ) + parser.add_argument( + "--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)", ) parser.add_argument( - "--defaced-dataset", + "--subject", type=str, - help="Path to the defaced dataset directory (must be used with --original-dataset)", + help="Filter to specific subject (e.g., 'sub-01' or 'sub-01_ses-baseline')", ) 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 - ): + faced_dir = os.path.abspath(args.faced_dir) + defaced_dir = os.path.abspath(args.defaced_dir) + output_dir = os.path.abspath(args.output_dir) + + # Create output directory + os.makedirs(output_dir, 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 args.subject: + original_count = len(subjects) + subjects = [s for s in subjects if args.subject in s["id"]] print( - "Error: --original-dataset and --defaced-dataset must be provided together." + f"Filtered to {len(subjects)} subjects matching '{args.subject}' (from {original_count} total)" ) - 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 - run_server(subjects) + if not subjects: + print(f"No subjects found matching '{args.subject}'") + print("Available subjects:") + all_subjects = build_subjects_from_datasets(faced_dir, defaced_dir) + for s in all_subjects: + print(f" - {s['id']}") + exit(1) + + # Set number of jobs for parallel processing + n_jobs = args.n_jobs if args.n_jobs else mp.cpu_count() + print(f"Using {n_jobs} parallel processes") + + # Process subjects in parallel + print("Generating comparisons...") + with mp.Pool(processes=n_jobs) as pool: + # Create a partial function with the output_dir fixed + process_func = partial(process_subject, output_dir=output_dir) + + # Process all subjects in parallel + results = pool.map(process_func, subjects) + # Count successful results + successful = [r for r in results if r is not None] + print(f"Successfully processed {len(successful)} out of {len(subjects)} subjects") + # Create index page + index_file = create_index_html(subjects, output_dir) + print(f"Created index: {index_file}") + + # Open browser if requested + if args.open_browser: + webbrowser.open(f"file://{index_file}") + print(f"Opened browser to: {index_file}") + + print(f"\nAll files generated in: {output_dir}") + print(f"Open index.html in your browser to view comparisons") + + +if __name__ == "__main__": + main() From 1f2d64a864de0115fecc5e283b7ec2bd2a42dd4d Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:38:56 -0400 Subject: [PATCH 04/15] serves comparison --- petdeface/serve.py | 147 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 116 insertions(+), 31 deletions(-) diff --git a/petdeface/serve.py b/petdeface/serve.py index 9e320f4..e8932fa 100644 --- a/petdeface/serve.py +++ b/petdeface/serve.py @@ -208,32 +208,68 @@ def create_comparison_html(orig_path, defaced_path, subject_id, output_dir): PET Deface Comparison - {subject_id} + -

PET Deface Comparison - {subject_id}

-

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

+
+

PET Deface Comparison - {subject_id}

+

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

+
- +
""" # Add images side by side html_content += f""" -
- - - - """ - - html_content += """ -
-

{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]} -
+
+

{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]} +
+
-

← Back to Index

+
+ ← Back to Index +
""" @@ -335,20 +371,19 @@ def create_index_html(subjects, output_dir): defaced_png = f"defaced_{subject_id}.png" comparisons_html += f""" -

{subject_id}

- - - - - -
+
+

{subject_id}

+
+

Original: {orig_basename}

Original: {orig_basename} -
+ +

Defaced: {defaced_basename}

Defaced: {defaced_basename} -
-
+
+ + """ html_content = f""" @@ -357,12 +392,62 @@ def create_index_html(subjects, output_dir): PET Deface Comparisons + -

PET Deface Comparisons

-

Side-by-side comparison of original vs defaced neuroimaging data for all subjects

+
+

PET Deface Comparisons

+

Side-by-side comparison of original vs defaced neuroimaging data for all subjects

+
- {comparisons_html} +
+ {comparisons_html} +
""" From 7ba2e3eefea967d06b6c38b052004d3141476557 Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Thu, 3 Jul 2025 17:02:10 -0400 Subject: [PATCH 05/15] looking good lewis --- petdeface/serve.py | 511 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 486 insertions(+), 25 deletions(-) diff --git a/petdeface/serve.py b/petdeface/serve.py index e8932fa..0d97ab3 100644 --- a/petdeface/serve.py +++ b/petdeface/serve.py @@ -15,6 +15,7 @@ import multiprocessing as mp from functools import partial import seaborn as sns +from PIL import Image, ImageDraw def create_overlay_comparison(orig_path, defaced_path, subject_id, output_dir): @@ -87,6 +88,44 @@ def animate(frame): 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, image_files[0][2]) # original image + defaced_png_path = os.path.join(output_dir, 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, 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.""" img = nib.load(img_path) @@ -105,7 +144,14 @@ def load_and_preprocess_image(img_path): return img -def create_comparison_html(orig_path, defaced_path, subject_id, output_dir): +def create_comparison_html( + orig_path, + defaced_path, + subject_id, + output_dir, + display_mode="side-by-side", + size="compact", +): """Create HTML comparison page for a subject using nilearn ortho views.""" # Get basenames for display @@ -201,6 +247,11 @@ def create_comparison_html(orig_path, defaced_path, subject_id, output_dir): 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""" @@ -213,6 +264,7 @@ def create_comparison_html(orig_path, defaced_path, subject_id, output_dir): font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; + scroll-behavior: smooth; }} .header {{ text-align: center; @@ -222,21 +274,22 @@ def create_comparison_html(orig_path, defaced_path, subject_id, output_dir): .comparison {{ display: flex; justify-content: center; - gap: 20px; + gap: {20 if size == "compact" else 40}px; margin-bottom: 20px; }} .viewer {{ background: white; - padding: 20px; + padding: {20 if size == "compact" else 30}px; border-radius: 10px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); text-align: center; flex: 1; - max-width: 50%; + max-width: {45 if size == "compact" else 48}%; }} .viewer h3 {{ margin-top: 0; color: #2c3e50; + font-size: {14 if size == "compact" else 18}px; }} .viewer img {{ width: 100%; @@ -244,9 +297,86 @@ def create_comparison_html(orig_path, defaced_path, subject_id, output_dir): border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); }} + .navigation {{ + position: fixed; + top: 20px; + right: 20px; + background: white; + padding: 15px; + border-radius: 10px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + z-index: 1000; + }} + .nav-button {{ + display: block; + margin: 5px 0; + padding: 8px 12px; + background: #3498db; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 12px; + text-decoration: none; + }} + .nav-button:hover {{ + background: #2980b9; + }} + .nav-button:disabled {{ + background: #bdc3c7; + cursor: not-allowed; + }} + + +

PET Deface Comparison - {subject_id}

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

@@ -255,8 +385,10 @@ def create_comparison_html(orig_path, defaced_path, subject_id, output_dir):
""" - # Add images side by side - html_content += f""" + # 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]} @@ -266,7 +398,24 @@ def create_comparison_html(orig_path, defaced_path, subject_id, output_dir): {image_files[1][0].title()}: {image_files[1][1]}
- + """ + 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 +
+
+ """ + else: + html_content += """ + + """ + + html_content += """
← Back to Index
@@ -282,12 +431,17 @@ def create_comparison_html(orig_path, defaced_path, subject_id, output_dir): return comparison_file -def process_subject(subject, output_dir): +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_path"], subject["defaced_path"], subject["id"], output_dir + subject["orig_path"], + subject["defaced_path"], + subject["id"], + output_dir, + "side-by-side", # Always generate side-by-side for individual pages + size, ) print(f" Completed: {subject['id']}") return comparison_file @@ -356,10 +510,9 @@ def get_unique_key(file_path): return subjects -def create_index_html(subjects, output_dir): - """Create index page with all comparisons embedded directly.""" +def create_side_by_side_index_html(subjects, output_dir, size="compact"): + """Create index page for side-by-side comparisons.""" - # Generate all comparison images first comparisons_html = "" for subject in subjects: subject_id = subject["id"] @@ -391,12 +544,13 @@ def create_index_html(subjects, output_dir): - PET Deface Comparisons + PET Deface Comparisons - Side by Side + + +
-

PET Deface Comparisons

-

Side-by-side comparison of original vs defaced neuroimaging data for all subjects

+

PET Deface Comparisons - Side by Side

+

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

@@ -452,7 +682,156 @@ def create_index_html(subjects, output_dir): """ - index_file = os.path.join(output_dir, "index.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"overlay_{subject_id}.gif" + + comparisons_html += f""" +
+

{subject_id}

+
+

Animated Comparison

+

Switching between Original and Defaced images

+ Animated comparison +
+
+ """ + + html_content = f""" + + + + + PET Deface Comparisons - Animated + + + + + + +
+

PET Deface Comparisons - Animated

+

Animated GIF comparison of original vs defaced neuroimaging data

+
+ +
+ {comparisons_html} +
+ + + """ + + index_file = os.path.join(output_dir, "animated.html") with open(index_file, "w") as f: f.write(html_content) @@ -488,6 +867,14 @@ def main(): type=str, help="Filter to specific subject (e.g., 'sub-01' or 'sub-01_ses-baseline')", ) + + 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() faced_dir = os.path.abspath(args.faced_dir) @@ -525,8 +912,12 @@ def main(): # Process subjects in parallel print("Generating comparisons...") with mp.Pool(processes=n_jobs) as pool: - # Create a partial function with the output_dir fixed - process_func = partial(process_subject, output_dir=output_dir) + # Create a partial function with the output_dir and size fixed + process_func = partial( + process_subject, + output_dir=output_dir, + size=args.size, + ) # Process all subjects in parallel results = pool.map(process_func, subjects) @@ -535,9 +926,79 @@ def main(): successful = [r for r in results if r is not None] print(f"Successfully processed {len(successful)} out of {len(subjects)} subjects") - # Create index page - index_file = create_index_html(subjects, output_dir) - print(f"Created index: {index_file}") + # Create both HTML files + side_by_side_file = create_side_by_side_index_html(subjects, output_dir, args.size) + animated_file = create_gif_index_html(subjects, output_dir, args.size) + + # Create a simple index that links to both + index_html = f""" + + + + + PET Deface Comparisons + + + +
+

PET Deface Comparisons

+

Choose your preferred viewing mode:

+ + Side by Side View + Animated GIF View + +

+ Generated with {len(subjects)} subjects +

+
+ + + """ + + index_file = os.path.join(output_dir, "index.html") + with open(index_file, "w") as f: + f.write(index_html) + + print(f"Created side-by-side view: {side_by_side_file}") + print(f"Created animated view: {animated_file}") + print(f"Created main index: {index_file}") # Open browser if requested if args.open_browser: From 3da0a38db7e6655a951f194d91f807142145ec74 Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Thu, 3 Jul 2025 17:10:07 -0400 Subject: [PATCH 06/15] rename to qa --- petdeface/qa.py | 1029 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1029 insertions(+) create mode 100644 petdeface/qa.py diff --git a/petdeface/qa.py b/petdeface/qa.py new file mode 100644 index 0000000..7411077 --- /dev/null +++ b/petdeface/qa.py @@ -0,0 +1,1029 @@ +import argparse +import os +import tempfile +import shutil +from glob import glob +import nilearn +from nilearn import plotting +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 + + +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, image_files[0][2]) # original image + defaced_png_path = os.path.join(output_dir, 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, 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.""" + 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 + + +def create_comparison_html( + orig_path, + defaced_path, + subject_id, + output_dir, + display_mode="side-by-side", + size="compact", +): + """Create HTML comparison page for a subject using nilearn ortho views.""" + + # Get basenames for display + orig_basename = os.path.basename(orig_path) + defaced_basename = os.path.basename(defaced_path) + + # Generate images and get their filenames + image_files = [] + for label, img_path, cmap in [ + ("original", orig_path, "hot"), # Colored for original + ("defaced", defaced_path, "gray"), # Grey for defaced + ]: + # Get the basename for display + basename = os.path.basename(img_path) + + # Load and preprocess image (handle 4D if needed) + img = load_and_preprocess_image(img_path) + + # 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, 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

+
+ +
+ """ + + # 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]} +
+
+ """ + 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 +
+
+ """ + else: + html_content += """ + + """ + + html_content += """ +
+ ← Back to Index +
+ + + """ + + # 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_path"], + subject["defaced_path"], + subject["id"], + output_dir, + "side-by-side", # Always generate side-by-side for individual pages + size, + ) + print(f" Completed: {subject['id']}") + return comparison_file + except Exception as e: + print(f" Error processing {subject['id']}: {e}") + return None + + +def build_subjects_from_datasets(orig_dir, defaced_dir): + """Build subject list with file paths.""" + orig_files = glob(os.path.join(orig_dir, "**", "*.nii*"), recursive=True) + defaced_files = glob(os.path.join(defaced_dir, "**", "*.nii*"), recursive=True) + + 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 + + def get_unique_key(file_path): + """Create a unique key that includes session information.""" + parts = file_path.split(os.sep) + sub_id = next((p for p in parts if p.startswith("sub-")), "") + session = next((p for p in parts if p.startswith("ses-")), "") + basename = strip_ext(file_path) + + # Create unique key that includes session if present + if session: + return f"{sub_id}_{session}_{basename}" + else: + return f"{sub_id}_{basename}" + + # Create maps with unique keys + orig_map = {get_unique_key(f): f for f in orig_files} + defaced_map = {get_unique_key(f): f for f in defaced_files} + common_keys = sorted(set(orig_map.keys()) & set(defaced_map.keys())) + + subjects = [] + for key in common_keys: + orig_path = orig_map[key] + defaced_path = defaced_map[key] + 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-")), "") + + # Create a unique subject ID that includes session if present + if session: + subject_id = f"{sub_id}_{session}" + else: + subject_id = sub_id + + subjects.append( + { + "id": subject_id, + "orig_path": orig_path, + "defaced_path": defaced_path, + } + ) + + if not subjects: + print("No matching NIfTI files found in both datasets.") + exit(1) + + return subjects + + +def create_side_by_side_index_html(subjects, output_dir, size="compact"): + """Create index page for side-by-side comparisons.""" + + comparisons_html = "" + for subject in subjects: + subject_id = subject["id"] + orig_basename = os.path.basename(subject["orig_path"]) + defaced_basename = os.path.basename(subject["defaced_path"]) + + # Check if the PNG files exist + orig_png = f"original_{subject_id}.png" + defaced_png = f"defaced_{subject_id}.png" + + comparisons_html += f""" +
+

{subject_id}

+
+
+

Original: {orig_basename}

+ Original: {orig_basename} +
+
+

Defaced: {defaced_basename}

+ Defaced: {defaced_basename} +
+
+
+ """ + + html_content = f""" + + + + + PET Deface Comparisons - Side by Side + + + + + + +
+

PET Deface Comparisons - Side by Side

+

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

+
+ +
+ {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"overlay_{subject_id}.gif" + + comparisons_html += f""" +
+

{subject_id}

+
+

Animated Comparison

+

Switching between Original and Defaced images

+ Animated comparison +
+
+ """ + + html_content = f""" + + + + + PET Deface Comparisons - Animated + + + + + + +
+

PET Deface Comparisons - Animated

+

Animated GIF comparison of original vs defaced neuroimaging data

+
+ +
+ {comparisons_html} +
+ + + """ + + index_file = os.path.join(output_dir, "animated.html") + with open(index_file, "w") as f: + f.write(html_content) + + return index_file + + +def main(): + parser = argparse.ArgumentParser( + description="Generate static HTML comparisons of PET deface results using nilearn." + ) + 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" + ) + parser.add_argument( + "--output-dir", + default="petdeface_comparisons", + help="Output directory for HTML files", + ) + parser.add_argument( + "--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)", + ) + parser.add_argument( + "--subject", + type=str, + help="Filter to specific subject (e.g., 'sub-01' or 'sub-01_ses-baseline')", + ) + + 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() + + faced_dir = os.path.abspath(args.faced_dir) + defaced_dir = os.path.abspath(args.defaced_dir) + output_dir = os.path.abspath(args.output_dir) + + # Create output directory + os.makedirs(output_dir, 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 args.subject: + original_count = len(subjects) + subjects = [s for s in subjects if args.subject in s["id"]] + print( + f"Filtered to {len(subjects)} subjects matching '{args.subject}' (from {original_count} total)" + ) + + if not subjects: + print(f"No subjects found matching '{args.subject}'") + print("Available subjects:") + all_subjects = build_subjects_from_datasets(faced_dir, defaced_dir) + for s in all_subjects: + print(f" - {s['id']}") + exit(1) + + # Set number of jobs for parallel processing + n_jobs = args.n_jobs if args.n_jobs else mp.cpu_count() + print(f"Using {n_jobs} parallel processes") + + # 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=args.size, + ) + + # Process all subjects in parallel + results = pool.map(process_func, subjects) + + # Count successful results + successful = [r for r in results if r is not None] + print(f"Successfully processed {len(successful)} out of {len(subjects)} subjects") + + # Create both HTML files + side_by_side_file = create_side_by_side_index_html(subjects, output_dir, args.size) + animated_file = create_gif_index_html(subjects, output_dir, args.size) + + # Create a simple index that links to both + index_html = f""" + + + + + PET Deface Comparisons + + + +
+

PET Deface Comparisons

+

Choose your preferred viewing mode:

+ + Side by Side View + Animated GIF View + +

+ Generated with {len(subjects)} subjects +

+
+ + + """ + + index_file = os.path.join(output_dir, "index.html") + with open(index_file, "w") as f: + f.write(index_html) + + print(f"Created side-by-side view: {side_by_side_file}") + print(f"Created animated view: {animated_file}") + print(f"Created main index: {index_file}") + + # Open browser if requested + if args.open_browser: + webbrowser.open(f"file://{index_file}") + print(f"Opened browser to: {index_file}") + + print(f"\nAll files generated in: {output_dir}") + print(f"Open index.html in your browser to view comparisons") + + +if __name__ == "__main__": + main() From 99b13c07f6b41ad6802906d767e46081f2a53310 Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Thu, 3 Jul 2025 17:20:57 -0400 Subject: [PATCH 07/15] ehhhh --- petdeface/qa.py | 66 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/petdeface/qa.py b/petdeface/qa.py index 7411077..6281753 100644 --- a/petdeface/qa.py +++ b/petdeface/qa.py @@ -92,8 +92,12 @@ 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, image_files[0][2]) # original image - defaced_png_path = os.path.join(output_dir, image_files[1][2]) # defaced image + 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) @@ -114,7 +118,7 @@ def create_overlay_gif(image_files, subject_id, output_dir): # Save as GIF gif_filename = f"overlay_{subject_id}.gif" - gif_path = os.path.join(output_dir, gif_filename) + gif_path = os.path.join(output_dir, "images", gif_filename) frames[0].save( gif_path, save_all=True, @@ -241,7 +245,7 @@ def create_comparison_html( # Save as PNG png_filename = f"{label}_{subject_id}.png" - png_path = os.path.join(output_dir, png_filename) + png_path = os.path.join(output_dir, "images", png_filename) fig.savefig(png_path, dpi=150) plt.close(fig) @@ -526,8 +530,8 @@ def create_side_by_side_index_html(subjects, output_dir, size="compact"): defaced_basename = os.path.basename(subject["defaced_path"]) # Check if the PNG files exist - orig_png = f"original_{subject_id}.png" - defaced_png = f"defaced_{subject_id}.png" + orig_png = f"images/original_{subject_id}.png" + defaced_png = f"images/defaced_{subject_id}.png" comparisons_html += f"""
@@ -706,7 +710,7 @@ def create_gif_index_html(subjects, output_dir, size="compact"): comparisons_html = "" for subject in subjects: subject_id = subject["id"] - overlay_gif = f"overlay_{subject_id}.gif" + overlay_gif = f"images/overlay_{subject_id}.gif" comparisons_html += f"""
@@ -866,8 +870,7 @@ def main(): ) parser.add_argument( "--output-dir", - default="petdeface_comparisons", - help="Output directory for HTML files", + help="Output directory for HTML files (default: {orig_folder}_{defaced_folder}_qa)", ) parser.add_argument( "--open-browser", action="store_true", help="Open browser automatically" @@ -895,10 +898,18 @@ def main(): faced_dir = os.path.abspath(args.faced_dir) defaced_dir = os.path.abspath(args.defaced_dir) - output_dir = os.path.abspath(args.output_dir) - # Create output directory + # Create output directory name based on input directories + if args.output_dir: + output_dir = os.path.abspath(args.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") + + # Create output directory and images subdirectory 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 @@ -1012,9 +1023,42 @@ def main(): 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", + args.size, + ] + if args.n_jobs: + command_parts.extend(["--n-jobs", str(args.n_jobs)]) + if args.subject: + command_parts.extend(["--subject", args.subject]) + if args.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 index: {index_file}") + print(f"Saved command to: {command_file}") # Open browser if requested if args.open_browser: From b0ccbc6a01f54e323b90ef87d11fa4c1d6b89884 Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:16:33 -0400 Subject: [PATCH 08/15] added SimpleBeforeAfterRPT image view for T1w --- petdeface/qa.py | 216 ++++++++++++++++++++++++++++++++++++++++++++++++ poetry.lock | 60 ++++++++++++-- pyproject.toml | 10 +++ 3 files changed, 277 insertions(+), 9 deletions(-) diff --git a/petdeface/qa.py b/petdeface/qa.py index 6281753..5320a99 100644 --- a/petdeface/qa.py +++ b/petdeface/qa.py @@ -16,6 +16,218 @@ from functools import partial import seaborn as sns from PIL import Image, ImageDraw +from nipype import Workflow, Node +from nireports.interfaces.reporting.base import SimpleBeforeAfterRPT +from tempfile import TemporaryDirectory +from pathlib import Path + + +def generate_simple_before_and_after(subjects: dict, output_dir): + if not output_dir: + output_dir = TemporaryDirectory() + wf = Workflow( + name="simple_before_after_report", base_dir=Path(output_dir) / "images/" + ) + + # Create a list to store all nodes + nodes = [] + + for s in 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 + valid_name = f"before_after_{s['id'].replace('-', '_').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) + + # Add all nodes to the workflow + wf.add_nodes(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) + + +def collect_svg_reports(wf, output_dir): + """Collect SVG reports from workflow and move them to images folder.""" + import glob + + # 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) + + print(f"Found {len(svg_files)} SVG reports") + + # 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}") + + # Create HTML page for SVG reports + create_svg_index_html(svg_files, output_dir) + + +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

+
+
+ """ + + html_content = f""" + + + + + PET Deface SVG Reports + + + + + + +
+

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): @@ -932,6 +1144,9 @@ def main(): print(f" - {s['id']}") exit(1) + # create nireports svg's for comparison + generate_simple_before_and_after(subjects=subjects, output_dir=output_dir) + # Set number of jobs for parallel processing n_jobs = args.n_jobs if args.n_jobs else mp.cpu_count() print(f"Using {n_jobs} parallel processes") @@ -1010,6 +1225,7 @@ def main(): Side by Side View Animated GIF View + SVG Reports View

Generated with {len(subjects)} subjects diff --git a/poetry.lock b/poetry.lock index d5251b5..b397c86 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,19 +1,16 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "acres" -version = "0.1.0" +version = "0.5.0" description = "Access resources on your terms" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "acres-0.1.0-py3-none-any.whl", hash = "sha256:7bbb3744de84d1499e5cc00f02d10f7e85c880e343722871816ca41502d4d103"}, - {file = "acres-0.1.0.tar.gz", hash = "sha256:4765479683389849368947da9e5319e677e7323ed858d642f9736ad1c070f45b"}, + {file = "acres-0.5.0-py3-none-any.whl", hash = "sha256:fcc32b974b510897de0f041609b4234f9ff03e2e960aea088f63973fb106c772"}, + {file = "acres-0.5.0.tar.gz", hash = "sha256:128b6447bf5df3b6210264feccbfa018b4ac5bd337358319aec6563f99db8f3a"}, ] -[package.dependencies] -importlib_resources = {version = "*", markers = "python_version < \"3.11\""} - [[package]] name = "alabaster" version = "0.7.16" @@ -1512,6 +1509,17 @@ plotting = ["kaleido", "kaleido (==0.1.0.post1)", "matplotlib (>=3.3.0)", "plotl style = ["black", "blacken-docs", "codespell", "flake8", "flake8-docstrings", "flake8-functions", "flake8-use-fstring", "flynt", "isort", "tomli"] test = ["coverage", "pytest (>=6.0.0)", "pytest-cov"] +[[package]] +name = "nipreps" +version = "1.0" +description = "Namespace package for nipreps utilities" +optional = false +python-versions = "*" +files = [ + {file = "nipreps-1.0-py2.py3-none-any.whl", hash = "sha256:9ff935d98301cc36633487674cc66128c52da2ce8e92a5871cc462704bb0e3d8"}, + {file = "nipreps-1.0.tar.gz", hash = "sha256:944e7e55238e1db838c9647eecfff012cae982fc023d7b8042279c044209b7c2"}, +] + [[package]] name = "nipype" version = "1.9.1" @@ -1557,6 +1565,40 @@ ssh = ["paramiko"] tests = ["coverage (>=5.2.1)", "pandas (>=1.5.0)", "pytest (>=6)", "pytest-cov (>=2.11)", "pytest-doctestplus", "pytest-env", "pytest-timeout (>=1.4)", "pytest-xdist (>=2.5)", "sphinx (>=7)"] xvfbwrapper = ["xvfbwrapper"] +[[package]] +name = "nireports" +version = "25.2.0" +description = "NiReports - The Visual Report System (VRS) of NiPreps" +optional = false +python-versions = ">=3.10" +files = [ + {file = "nireports-25.2.0-py3-none-any.whl", hash = "sha256:9d29c67c88782ae5cfe29442c1e86123c00e9c84f7bc348537c7e8a83976fa7e"}, + {file = "nireports-25.2.0.tar.gz", hash = "sha256:e82cd93ed845180dc7a4e4b07bbeede83c46c2b456210c6741e178c286e6c26f"}, +] + +[package.dependencies] +acres = ">=0.2" +lxml = ">=4.6" +matplotlib = ">=3.5" +nibabel = ">=3.0.1" +nilearn = ">=0.8" +nipype = ">=1.8.5" +numpy = ">=1.20" +pandas = ">=1.2" +pybids = ">=0.15.1" +pyyaml = ">=5.4" +seaborn = ">=0.13" +templateflow = ">=23.1" + +[package.extras] +all = ["coverage[toml] (>=5.2.1)", "furo", "packaging", "pre-commit", "pydot (>=1.2.3)", "pydotplus", "pytest (>=6)", "pytest-cov (>=2.11)", "pytest-env", "pytest-xdist (>=2.5)", "ruff", "sphinx", "sphinx (>=6)", "sphinxcontrib-apidoc", "sphinxcontrib-napoleon"] +dev = ["pre-commit", "ruff"] +doc = ["furo", "pydot (>=1.2.3)", "pydotplus", "sphinx", "sphinxcontrib-apidoc", "sphinxcontrib-napoleon"] +docs = ["furo", "pydot (>=1.2.3)", "pydotplus", "sphinx", "sphinxcontrib-apidoc", "sphinxcontrib-napoleon"] +test = ["coverage[toml] (>=5.2.1)", "packaging", "pytest (>=6)", "pytest-cov (>=2.11)", "pytest-env", "pytest-xdist (>=2.5)", "sphinx (>=6)"] +tests = ["coverage[toml] (>=5.2.1)", "packaging", "pytest (>=6)", "pytest-cov (>=2.11)", "pytest-env", "pytest-xdist (>=2.5)", "sphinx (>=6)"] +types = ["lxml-stubs", "pandas-stubs", "pytest", "scipy-stubs", "types-jinja2", "types-pyyaml"] + [[package]] name = "nitransforms" version = "24.1.0" @@ -3261,4 +3303,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.10, <4.0" -content-hash = "6de8a7cc6be569cb4483d6784cb54ac7a6ca8cc97ccbb1dee8ee18f41895bd43" +content-hash = "980a7566badf67e01072461de1f2e76d23db2238b660a4aa4d44181073092a8e" diff --git a/pyproject.toml b/pyproject.toml index 3dfdec5..c37aa7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,16 @@ petutils = "^0.0.1" niworkflows = "^1.11.0" niftifixer = {git = "https://github.com/openneuropet/nifti_fixer.git"} bids-validator-deno = "^2.0.5" +nipreps = "^1.0" +nireports = "^25.2.0" +nibabel = "^5.3.2" +nilearn = "^0.10.4" +matplotlib = "^3.9.2" +numpy = "^2.1.3" +scipy = "^1.14.1" +seaborn = "^0.13.2" +pillow = "^11.0.0" +imageio = "^2.36.0" [tool.poetry.group.dev.dependencies] From d4ba4bce39160756fef2f1e626580a8a395e97c7 Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Mon, 7 Jul 2025 15:55:11 -0400 Subject: [PATCH 09/15] remove unused niivue and flask --- petdeface/niivue.html | 118 ----- petdeface/serve.py | 1013 ----------------------------------------- 2 files changed, 1131 deletions(-) delete mode 100644 petdeface/niivue.html delete mode 100644 petdeface/serve.py diff --git a/petdeface/niivue.html b/petdeface/niivue.html deleted file mode 100644 index 542c2cb..0000000 --- a/petdeface/niivue.html +++ /dev/null @@ -1,118 +0,0 @@ - - - - - NiiVue - - - {% for subject in subjects %} -

- {{ subject.id }} -
-
- {% for session in subject.sessions %} -
-
- {{ session.label }}
- - {{ session.nifti_path.split('/')[-1] }} - -
- -
-
- {% endfor %} -
-
- - - 0 - -
- {% endfor %} - - {% set viewer_configs = [] %} - {% for subject in subjects %} - {% for session in subject.sessions %} - {% set _ = viewer_configs.append({'canvasId': 'gl_' ~ subject.id ~ '_' ~ session.label, 'niftiPath': session.nifti_path}) %} - {% endfor %} - {% endfor %} - - \ No newline at end of file diff --git a/petdeface/serve.py b/petdeface/serve.py deleted file mode 100644 index 0d97ab3..0000000 --- a/petdeface/serve.py +++ /dev/null @@ -1,1013 +0,0 @@ -import argparse -import os -import tempfile -import shutil -from glob import glob -import nilearn -from nilearn import plotting -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 - - -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, image_files[0][2]) # original image - defaced_png_path = os.path.join(output_dir, 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, 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.""" - 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 - - -def create_comparison_html( - orig_path, - defaced_path, - subject_id, - output_dir, - display_mode="side-by-side", - size="compact", -): - """Create HTML comparison page for a subject using nilearn ortho views.""" - - # Get basenames for display - orig_basename = os.path.basename(orig_path) - defaced_basename = os.path.basename(defaced_path) - - # Generate images and get their filenames - image_files = [] - for label, img_path, cmap in [ - ("original", orig_path, "hot"), # Colored for original - ("defaced", defaced_path, "gray"), # Grey for defaced - ]: - # Get the basename for display - basename = os.path.basename(img_path) - - # Load and preprocess image (handle 4D if needed) - img = load_and_preprocess_image(img_path) - - # 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, 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

-
- -
- """ - - # 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]} -
-
- """ - 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 -
-
- """ - else: - html_content += """ -
- """ - - html_content += """ -
- ← Back to Index -
- - - """ - - # 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_path"], - subject["defaced_path"], - subject["id"], - output_dir, - "side-by-side", # Always generate side-by-side for individual pages - size, - ) - print(f" Completed: {subject['id']}") - return comparison_file - except Exception as e: - print(f" Error processing {subject['id']}: {e}") - return None - - -def build_subjects_from_datasets(orig_dir, defaced_dir): - """Build subject list with file paths.""" - orig_files = glob(os.path.join(orig_dir, "**", "*.nii*"), recursive=True) - defaced_files = glob(os.path.join(defaced_dir, "**", "*.nii*"), recursive=True) - - 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 - - def get_unique_key(file_path): - """Create a unique key that includes session information.""" - parts = file_path.split(os.sep) - sub_id = next((p for p in parts if p.startswith("sub-")), "") - session = next((p for p in parts if p.startswith("ses-")), "") - basename = strip_ext(file_path) - - # Create unique key that includes session if present - if session: - return f"{sub_id}_{session}_{basename}" - else: - return f"{sub_id}_{basename}" - - # Create maps with unique keys - orig_map = {get_unique_key(f): f for f in orig_files} - defaced_map = {get_unique_key(f): f for f in defaced_files} - common_keys = sorted(set(orig_map.keys()) & set(defaced_map.keys())) - - subjects = [] - for key in common_keys: - orig_path = orig_map[key] - defaced_path = defaced_map[key] - 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-")), "") - - # Create a unique subject ID that includes session if present - if session: - subject_id = f"{sub_id}_{session}" - else: - subject_id = sub_id - - subjects.append( - { - "id": subject_id, - "orig_path": orig_path, - "defaced_path": defaced_path, - } - ) - - if not subjects: - print("No matching NIfTI files found in both datasets.") - exit(1) - - return subjects - - -def create_side_by_side_index_html(subjects, output_dir, size="compact"): - """Create index page for side-by-side comparisons.""" - - comparisons_html = "" - for subject in subjects: - subject_id = subject["id"] - orig_basename = os.path.basename(subject["orig_path"]) - defaced_basename = os.path.basename(subject["defaced_path"]) - - # Check if the PNG files exist - orig_png = f"original_{subject_id}.png" - defaced_png = f"defaced_{subject_id}.png" - - comparisons_html += f""" -
-

{subject_id}

-
-
-

Original: {orig_basename}

- Original: {orig_basename} -
-
-

Defaced: {defaced_basename}

- Defaced: {defaced_basename} -
-
-
- """ - - html_content = f""" - - - - - PET Deface Comparisons - Side by Side - - - - - - -
-

PET Deface Comparisons - Side by Side

-

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

-
- -
- {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"overlay_{subject_id}.gif" - - comparisons_html += f""" -
-

{subject_id}

-
-

Animated Comparison

-

Switching between Original and Defaced images

- Animated comparison -
-
- """ - - html_content = f""" - - - - - PET Deface Comparisons - Animated - - - - - - -
-

PET Deface Comparisons - Animated

-

Animated GIF comparison of original vs defaced neuroimaging data

-
- -
- {comparisons_html} -
- - - """ - - index_file = os.path.join(output_dir, "animated.html") - with open(index_file, "w") as f: - f.write(html_content) - - return index_file - - -def main(): - parser = argparse.ArgumentParser( - description="Generate static HTML comparisons of PET deface results using nilearn." - ) - 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" - ) - parser.add_argument( - "--output-dir", - default="petdeface_comparisons", - help="Output directory for HTML files", - ) - parser.add_argument( - "--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)", - ) - parser.add_argument( - "--subject", - type=str, - help="Filter to specific subject (e.g., 'sub-01' or 'sub-01_ses-baseline')", - ) - - 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() - - faced_dir = os.path.abspath(args.faced_dir) - defaced_dir = os.path.abspath(args.defaced_dir) - output_dir = os.path.abspath(args.output_dir) - - # Create output directory - os.makedirs(output_dir, 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 args.subject: - original_count = len(subjects) - subjects = [s for s in subjects if args.subject in s["id"]] - print( - f"Filtered to {len(subjects)} subjects matching '{args.subject}' (from {original_count} total)" - ) - - if not subjects: - print(f"No subjects found matching '{args.subject}'") - print("Available subjects:") - all_subjects = build_subjects_from_datasets(faced_dir, defaced_dir) - for s in all_subjects: - print(f" - {s['id']}") - exit(1) - - # Set number of jobs for parallel processing - n_jobs = args.n_jobs if args.n_jobs else mp.cpu_count() - print(f"Using {n_jobs} parallel processes") - - # 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=args.size, - ) - - # Process all subjects in parallel - results = pool.map(process_func, subjects) - - # Count successful results - successful = [r for r in results if r is not None] - print(f"Successfully processed {len(successful)} out of {len(subjects)} subjects") - - # Create both HTML files - side_by_side_file = create_side_by_side_index_html(subjects, output_dir, args.size) - animated_file = create_gif_index_html(subjects, output_dir, args.size) - - # Create a simple index that links to both - index_html = f""" - - - - - PET Deface Comparisons - - - -
-

PET Deface Comparisons

-

Choose your preferred viewing mode:

- - Side by Side View - Animated GIF View - -

- Generated with {len(subjects)} subjects -

-
- - - """ - - index_file = os.path.join(output_dir, "index.html") - with open(index_file, "w") as f: - f.write(index_html) - - print(f"Created side-by-side view: {side_by_side_file}") - print(f"Created animated view: {animated_file}") - print(f"Created main index: {index_file}") - - # Open browser if requested - if args.open_browser: - webbrowser.open(f"file://{index_file}") - print(f"Opened browser to: {index_file}") - - print(f"\nAll files generated in: {output_dir}") - print(f"Open index.html in your browser to view comparisons") - - -if __name__ == "__main__": - main() From 6ba6c609d0e1400ad439afce657107b8978f333e Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Mon, 7 Jul 2025 16:46:03 -0400 Subject: [PATCH 10/15] refactoring to work with 3D images in a tmp dir --- petdeface/qa.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/petdeface/qa.py b/petdeface/qa.py index 5320a99..ca74c3e 100644 --- a/petdeface/qa.py +++ b/petdeface/qa.py @@ -386,6 +386,8 @@ def create_comparison_html( # Load and preprocess image (handle 4D if needed) img = load_and_preprocess_image(img_path) + # save image to temp folder for later loading + # Create single sagittal slice using matplotlib directly img_data = img.get_fdata() x_midpoint = img_data.shape[0] // 2 # Get middle slice index @@ -657,14 +659,25 @@ def process_subject(subject, output_dir, size="compact"): """Process a single subject (for parallel processing).""" print(f"Processing {subject['id']}...") try: + subject_temp_dir = tempfile.TemporaryDirectory() + # load each image file then save it to temp + original_image = load_and_preprocess_image(subject["orig_path"]) + defaced_image = load_and_preprocess_image(subject["defaced_path"]) + original_image = nib.Nifti1Image(original_image.get_fdata(), original_image.affine, original_image.header) + defaced_image = nib.Nifti1Image(defaced_image.get_fdata(), defaced_image.affine, defaced_image.header) + + nib.save(original_image, Path(subject_temp_dir.name) / Path(subject["orig_path"]).name) + nib.save(defaced_image, Path(subject_temp_dir.name) / Path(subject["defaced_path"]).name) + comparison_file = create_comparison_html( - subject["orig_path"], - subject["defaced_path"], + Path(subject_temp_dir.name) / Path(subject["orig_path"]).name, + Path(subject_temp_dir.name) / Path(subject["defaced_path"]).name, subject["id"], output_dir, "side-by-side", # Always generate side-by-side for individual pages size, ) + print(f" Completed: {subject['id']}") return comparison_file except Exception as e: From 4d69a072c0c0b1e03ba3de1ef0b9a3d13c884403 Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Tue, 8 Jul 2025 15:52:40 -0400 Subject: [PATCH 11/15] fixed bug w/ overwriting original images --- petdeface/petdeface.py | 47 ++++++----- petdeface/qa.py | 186 ++++++++++++++++++++++++++++++----------- 2 files changed, 162 insertions(+), 71 deletions(-) diff --git a/petdeface/petdeface.py b/petdeface/petdeface.py index bbc3266..92e72fe 100755 --- a/petdeface/petdeface.py +++ b/petdeface/petdeface.py @@ -297,7 +297,7 @@ def deface(args: Union[dict, argparse.Namespace]) -> None: write_out_dataset_description_json(args.bids_dir) # remove temp outputs - this is commented out to enable easier testing for now - if str(os.getenv("DEBUG", "false")).lower() != "true": + if str(os.getenv("PETDEFACE_DEBUG", "false")).lower() != "true": shutil.rmtree(os.path.join(output_dir, "petdeface_wf")) return {"subjects": subjects} @@ -671,21 +671,21 @@ def wrap_up_defacing( should_exclude = False for excluded_subject in participant_label_exclude: # Handle both cases: excluded_subject with or without 'sub-' prefix - if excluded_subject.startswith('sub-'): + if excluded_subject.startswith("sub-"): subject_pattern = f"/{excluded_subject}/" subject_pattern_underscore = f"/{excluded_subject}_" else: subject_pattern = f"/sub-{excluded_subject}/" subject_pattern_underscore = f"/sub-{excluded_subject}_" - + if subject_pattern in entry or subject_pattern_underscore in entry: should_exclude = True break - + # Skip excluded subject files, but copy everything else (including dataset-level files) if should_exclude: continue - + copy_path = entry.replace(str(path_to_dataset), str(final_destination)) pathlib.Path(copy_path).parent.mkdir( parents=True, exist_ok=True, mode=0o775 @@ -730,7 +730,7 @@ def wrap_up_defacing( desc="defaced", return_type="file", ) - if str(os.getenv("DEBUG", "false")).lower() != "true": + if str(os.getenv("PETDEFAC_DEBUG", "false")).lower() != "true": for extraneous in derivatives: os.remove(extraneous) @@ -741,15 +741,16 @@ def wrap_up_defacing( "placement must be one of ['adjacent', 'inplace', 'derivatives']" ) - # clean up any errantly leftover files with globe in destination folder - leftover_files = [ - pathlib.Path(defaced_nii) - for defaced_nii in glob.glob( - f"{final_destination}/**/*_defaced*.nii*", recursive=True - ) - ] - for leftover in leftover_files: - leftover.unlink() + if not os.getenv("PETDEFACE_DEBUG"): + # clean up any errantly leftover files with glob in destination folder + leftover_files = [ + pathlib.Path(defaced_nii) + for defaced_nii in glob.glob( + f"{final_destination}/**/*_defaced*.nii*", recursive=True + ) + ] + for leftover in leftover_files: + leftover.unlink() print(f"completed copying dataset to {final_destination}") @@ -770,7 +771,9 @@ def move_defaced_images( :param move_files: delete defaced images in "working" directory, e.g. move them to the destination dir instead of copying them there, defaults to False :type move_files: bool, optional """ - # update paths in mapping dict + # Create a new mapping with destination paths + dest_mapping = {} + for defaced, raw in mapping_dict.items(): # get common path and replace with final_destination to get new path common_path = os.path.commonpath([defaced.path, raw.path]) @@ -791,15 +794,13 @@ def move_defaced_images( ] ) ) - mapping_dict[defaced] = new_path + dest_mapping[defaced] = new_path # copy defaced images to new location - for defaced, raw in mapping_dict.items(): - if pathlib.Path(raw).exists() and pathlib.Path(defaced).exists(): - shutil.copy(defaced.path, raw) - else: - pathlib.Path(raw).parent.mkdir(parents=True, exist_ok=True) - shutil.copy(defaced.path, raw) + for defaced, dest_path in dest_mapping.items(): + if pathlib.Path(defaced).exists(): + pathlib.Path(dest_path).parent.mkdir(parents=True, exist_ok=True) + shutil.copy(defaced.path, dest_path) if move_files: os.remove(defaced.path) diff --git a/petdeface/qa.py b/petdeface/qa.py index ca74c3e..ed48ce8 100644 --- a/petdeface/qa.py +++ b/petdeface/qa.py @@ -22,7 +22,73 @@ from pathlib import Path -def generate_simple_before_and_after(subjects: dict, output_dir): +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") + + # Preprocess original image + orig_result = load_and_preprocess_image(s["orig_path"]) + if isinstance(orig_result, nib.Nifti1Image): + # Need to save the averaged image + orig_3d_path = os.path.join(temp_dir, f"orig_{s['id']}.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 + defaced_3d_path = os.path.join(temp_dir, f"defaced_{s['id']}.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") + + # 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) + + # Process all subjects in parallel + preprocessed_subjects = pool.map(preprocess_func, subjects) + + print(f"Preprocessed {len(preprocessed_subjects)} subjects") + return preprocessed_subjects + + +def generate_simple_before_and_after(preprocessed_subjects: dict, output_dir): if not output_dir: output_dir = TemporaryDirectory() wf = Workflow( @@ -32,12 +98,24 @@ def generate_simple_before_and_after(subjects: dict, output_dir): # Create a list to store all nodes nodes = [] - for s in subjects: + 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 - valid_name = f"before_after_{s['id'].replace('-', '_').replace('_', '')}" + # 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"], @@ -52,10 +130,14 @@ def generate_simple_before_and_after(subjects: dict, output_dir): # Add all nodes to the workflow wf.add_nodes(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) + # 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") def collect_svg_reports(wf, output_dir): @@ -343,7 +425,8 @@ def create_overlay_gif(image_files, subject_id, output_dir): def load_and_preprocess_image(img_path): - """Load image and take mean if it has more than 3 dimensions.""" + """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 @@ -356,13 +439,14 @@ def load_and_preprocess_image(img_path): mean_data = np.mean(data, axis=3) # Create new 3D image img = nib.Nifti1Image(mean_data, img.affine, img.header) - - return img + return img # Return nibabel image object + else: + return img_path # Return original path if already 3D def create_comparison_html( - orig_path, - defaced_path, + orig_img, + defaced_img, subject_id, output_dir, display_mode="side-by-side", @@ -371,21 +455,15 @@ def create_comparison_html( """Create HTML comparison page for a subject using nilearn ortho views.""" # Get basenames for display - orig_basename = os.path.basename(orig_path) - defaced_basename = os.path.basename(defaced_path) + 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_path, cmap in [ - ("original", orig_path, "hot"), # Colored for original - ("defaced", defaced_path, "gray"), # Grey for defaced + for label, img, basename, cmap in [ + ("original", orig_img, orig_basename, "hot"), # Colored for original + ("defaced", defaced_img, defaced_basename, "gray"), # Grey for defaced ]: - # Get the basename for display - basename = os.path.basename(img_path) - - # Load and preprocess image (handle 4D if needed) - img = load_and_preprocess_image(img_path) - # save image to temp folder for later loading # Create single sagittal slice using matplotlib directly @@ -659,25 +737,14 @@ def process_subject(subject, output_dir, size="compact"): """Process a single subject (for parallel processing).""" print(f"Processing {subject['id']}...") try: - subject_temp_dir = tempfile.TemporaryDirectory() - # load each image file then save it to temp - original_image = load_and_preprocess_image(subject["orig_path"]) - defaced_image = load_and_preprocess_image(subject["defaced_path"]) - original_image = nib.Nifti1Image(original_image.get_fdata(), original_image.affine, original_image.header) - defaced_image = nib.Nifti1Image(defaced_image.get_fdata(), defaced_image.affine, defaced_image.header) - - nib.save(original_image, Path(subject_temp_dir.name) / Path(subject["orig_path"]).name) - nib.save(defaced_image, Path(subject_temp_dir.name) / Path(subject["defaced_path"]).name) - comparison_file = create_comparison_html( - Path(subject_temp_dir.name) / Path(subject["orig_path"]).name, - Path(subject_temp_dir.name) / Path(subject["defaced_path"]).name, + subject["orig_img"], + subject["defaced_img"], subject["id"], output_dir, "side-by-side", # Always generate side-by-side for individual pages size, ) - print(f" Completed: {subject['id']}") return comparison_file except Exception as e: @@ -687,8 +754,22 @@ def process_subject(subject, output_dir, size="compact"): def build_subjects_from_datasets(orig_dir, defaced_dir): """Build subject list with file paths.""" - orig_files = glob(os.path.join(orig_dir, "**", "*.nii*"), recursive=True) - defaced_files = glob(os.path.join(defaced_dir, "**", "*.nii*"), recursive=True) + + # Get all NIfTI files but exclude derivatives and workflow folders + def get_nifti_files(directory): + all_files = glob(os.path.join(directory, "**", "*.nii*"), recursive=True) + # Filter out files in derivatives, workflow, or other processing folders + filtered_files = [] + for file_path in all_files: + # Skip files in derivatives, workflow, or processing-related directories + path_parts = file_path.split(os.sep) + skip_dirs = ["derivatives", "work", "wf", "tmp", "temp", "scratch", "cache"] + if not any(skip_dir in path_parts for skip_dir in skip_dirs): + filtered_files.append(file_path) + return filtered_files + + orig_files = get_nifti_files(orig_dir) + defaced_files = get_nifti_files(defaced_dir) def strip_ext(path): base = os.path.basename(path) @@ -751,8 +832,8 @@ def create_side_by_side_index_html(subjects, output_dir, size="compact"): comparisons_html = "" for subject in subjects: subject_id = subject["id"] - orig_basename = os.path.basename(subject["orig_path"]) - defaced_basename = os.path.basename(subject["defaced_path"]) + orig_basename = f"orig_{subject_id}.nii.gz" + defaced_basename = f"defaced_{subject_id}.nii.gz" # Check if the PNG files exist orig_png = f"images/original_{subject_id}.png" @@ -1157,13 +1238,18 @@ def main(): print(f" - {s['id']}") exit(1) - # create nireports svg's for comparison - generate_simple_before_and_after(subjects=subjects, output_dir=output_dir) - # Set number of jobs for parallel processing n_jobs = args.n_jobs if args.n_jobs else 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 + ) + # Process subjects in parallel print("Generating comparisons...") with mp.Pool(processes=n_jobs) as pool: @@ -1175,15 +1261,19 @@ def main(): ) # Process all subjects in parallel - results = pool.map(process_func, subjects) + results = pool.map(process_func, preprocessed_subjects) # Count successful results successful = [r for r in results if r is not None] - print(f"Successfully processed {len(successful)} out of {len(subjects)} subjects") + print( + f"Successfully processed {len(successful)} out of {len(preprocessed_subjects)} subjects" + ) # Create both HTML files - side_by_side_file = create_side_by_side_index_html(subjects, output_dir, args.size) - animated_file = create_gif_index_html(subjects, output_dir, args.size) + side_by_side_file = create_side_by_side_index_html( + preprocessed_subjects, output_dir, args.size + ) + animated_file = create_gif_index_html(preprocessed_subjects, output_dir, args.size) # Create a simple index that links to both index_html = f""" @@ -1241,7 +1331,7 @@ def main(): SVG Reports View

- Generated with {len(subjects)} subjects + Generated with {len(preprocessed_subjects)} subjects

From ef594e6171f21b91c5112fa0d46094acc4e31009 Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:34:57 -0400 Subject: [PATCH 12/15] added qa to run at end of petdeface --- petdeface/petdeface.py | 71 ++++++++++++++++++++++++++++++++++++++++++ petdeface/qa.py | 42 +++++++++++++++++++------ 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/petdeface/petdeface.py b/petdeface/petdeface.py index 92e72fe..b9a76c8 100755 --- a/petdeface/petdeface.py +++ b/petdeface/petdeface.py @@ -1057,6 +1057,18 @@ def cli(): required=False, default=[], ) + parser.add_argument( + "--qa", + action="store_true", + default=False, + help="Generate QA reports after defacing is complete", + ) + parser.add_argument( + "--open-browser", + action="store_true", + default=False, + help="Open browser automatically after QA report generation (requires --qa)", + ) arguments = parser.parse_args() return arguments @@ -1257,6 +1269,65 @@ def main(): # noqa: max-complexity: 12 ) petdeface.run() + # Generate QA reports if requested + if args.qa: + print("\n" + "=" * 60) + print("Generating QA reports...") + print("=" * 60) + + try: + # Import qa module + import qa + import sys + + # Determine the defaced directory based on placement + if args.placement == "adjacent": + defaced_dir = args.bids_dir.parent / f"{args.bids_dir.name}_defaced" + elif args.placement == "inplace": + defaced_dir = args.bids_dir + elif args.placement == "derivatives": + defaced_dir = args.bids_dir / "derivatives" / "petdeface" + else: + defaced_dir = ( + args.output_dir + if args.output_dir + else args.bids_dir / "derivatives" / "petdeface" + ) + + # Build QA arguments as sys.argv style + qa_argv = [ + "qa.py", # Script name + "--faced-dir", + str(args.bids_dir), + "--defaced-dir", + str(defaced_dir), + ] + + if args.open_browser: + qa_argv.append("--open-browser") + + if args.participant_label: + qa_argv.extend(["--subject", " ".join(args.participant_label)]) + + # Temporarily replace sys.argv and run QA + original_argv = sys.argv + sys.argv = qa_argv + + try: + qa.main() + finally: + sys.argv = original_argv + + print("\n" + "=" * 60) + print("QA reports generated successfully!") + print("=" * 60) + + except Exception as e: + print(f"\nError generating QA reports: {e}") + print( + "QA report generation failed, but defacing completed successfully." + ) + if __name__ == "__main__": main() diff --git a/petdeface/qa.py b/petdeface/qa.py index ed48ce8..8887a94 100644 --- a/petdeface/qa.py +++ b/petdeface/qa.py @@ -26,11 +26,24 @@ 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") + # 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 - orig_3d_path = os.path.join(temp_dir, f"orig_{s['id']}.nii.gz") + # 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: @@ -41,8 +54,8 @@ def preprocess_single_subject(s, output_dir): # Preprocess defaced image defaced_result = load_and_preprocess_image(s["defaced_path"]) if isinstance(defaced_result, nib.Nifti1Image): - # Need to save the averaged image - defaced_3d_path = os.path.join(temp_dir, f"defaced_{s['id']}.nii.gz") + # 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: @@ -451,12 +464,19 @@ def create_comparison_html( 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 - orig_basename = f"orig_{subject_id}.nii.gz" - defaced_basename = f"defaced_{subject_id}.nii.gz" + # 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 = [] @@ -744,6 +764,8 @@ def process_subject(subject, output_dir, size="compact"): output_dir, "side-by-side", # Always generate side-by-side for individual pages size, + subject["orig_path"], + subject["defaced_path"], ) print(f" Completed: {subject['id']}") return comparison_file @@ -832,8 +854,10 @@ def create_side_by_side_index_html(subjects, output_dir, size="compact"): comparisons_html = "" for subject in subjects: subject_id = subject["id"] - orig_basename = f"orig_{subject_id}.nii.gz" - defaced_basename = f"defaced_{subject_id}.nii.gz" + + # 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"]) # Check if the PNG files exist orig_png = f"images/original_{subject_id}.png" From 53b77be48cb85758134f99b19c17e5c13f3dc1a7 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Wed, 9 Jul 2025 11:47:43 +0200 Subject: [PATCH 13/15] FIX: update import of QA module --- petdeface/petdeface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/petdeface/petdeface.py b/petdeface/petdeface.py index b9a76c8..8a85ddb 100755 --- a/petdeface/petdeface.py +++ b/petdeface/petdeface.py @@ -1277,7 +1277,7 @@ def main(): # noqa: max-complexity: 12 try: # Import qa module - import qa + from petdeface import qa import sys # Determine the defaced directory based on placement From 5e442785927b29fa0b48c36219aeb5096456b934 Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:14:55 -0400 Subject: [PATCH 14/15] always run qa, switched to less hacky invocation of executing qa --- petdeface/petdeface.py | 99 ++++++++++--------------- petdeface/qa.py | 165 ++++++++++++++++++++++++++--------------- 2 files changed, 147 insertions(+), 117 deletions(-) diff --git a/petdeface/petdeface.py b/petdeface/petdeface.py index 8a85ddb..29f8ff6 100755 --- a/petdeface/petdeface.py +++ b/petdeface/petdeface.py @@ -32,11 +32,13 @@ from mideface import Mideface from pet import WeightedAverage from utils import run_validator + from qa import run_qa except ModuleNotFoundError: from .mideface import ApplyMideface from .mideface import Mideface from .pet import WeightedAverage from .utils import run_validator + from .qa import run_qa # collect version from pyproject.toml @@ -1057,17 +1059,11 @@ def cli(): required=False, default=[], ) - parser.add_argument( - "--qa", - action="store_true", - default=False, - help="Generate QA reports after defacing is complete", - ) parser.add_argument( "--open-browser", action="store_true", default=False, - help="Open browser automatically after QA report generation (requires --qa)", + help="Open browser automatically after QA report generation", ) arguments = parser.parse_args() @@ -1270,63 +1266,48 @@ def main(): # noqa: max-complexity: 12 petdeface.run() # Generate QA reports if requested - if args.qa: - print("\n" + "=" * 60) - print("Generating QA reports...") - print("=" * 60) - - try: - # Import qa module - from petdeface import qa - import sys - - # Determine the defaced directory based on placement - if args.placement == "adjacent": - defaced_dir = args.bids_dir.parent / f"{args.bids_dir.name}_defaced" - elif args.placement == "inplace": - defaced_dir = args.bids_dir - elif args.placement == "derivatives": - defaced_dir = args.bids_dir / "derivatives" / "petdeface" - else: - defaced_dir = ( - args.output_dir - if args.output_dir - else args.bids_dir / "derivatives" / "petdeface" - ) - - # Build QA arguments as sys.argv style - qa_argv = [ - "qa.py", # Script name - "--faced-dir", - str(args.bids_dir), - "--defaced-dir", - str(defaced_dir), - ] + print("\n" + "=" * 60) + print("Generating QA reports...") + print("=" * 60) - if args.open_browser: - qa_argv.append("--open-browser") - - if args.participant_label: - qa_argv.extend(["--subject", " ".join(args.participant_label)]) + try: - # Temporarily replace sys.argv and run QA - original_argv = sys.argv - sys.argv = qa_argv + # Determine the defaced directory based on placement + if args.placement == "adjacent": + defaced_dir = args.bids_dir.parent / f"{args.bids_dir.name}_defaced" + elif args.placement == "inplace": + defaced_dir = args.bids_dir + elif args.placement == "derivatives": + defaced_dir = args.bids_dir / "derivatives" / "petdeface" + else: + defaced_dir = ( + args.output_dir + if args.output_dir + else args.bids_dir / "derivatives" / "petdeface" + ) - try: - qa.main() - finally: - sys.argv = original_argv + # Run QA + qa_result = run_qa( + faced_dir=str(args.bids_dir), + defaced_dir=str(defaced_dir), + subject=( + " ".join(args.participant_label) + if args.participant_label + else None + ), + open_browser=args.open_browser, + ) - print("\n" + "=" * 60) - print("QA reports generated successfully!") - print("=" * 60) + print("\n" + "=" * 60) + print("QA reports generated successfully!") + print(f"Reports available at: {qa_result['output_dir']}") + print("=" * 60) - except Exception as e: - print(f"\nError generating QA reports: {e}") - print( - "QA report generation failed, but defacing completed successfully." - ) + except Exception as e: + print(f"\nError generating QA reports: {e}") + print( + "QA report generation failed, but defacing completed successfully." + ) if __name__ == "__main__": diff --git a/petdeface/qa.py b/petdeface/qa.py index 8887a94..fa23c4e 100644 --- a/petdeface/qa.py +++ b/petdeface/qa.py @@ -22,6 +22,9 @@ from pathlib import Path + + + 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") @@ -1188,50 +1191,36 @@ def create_gif_index_html(subjects, output_dir, size="compact"): return index_file -def main(): - parser = argparse.ArgumentParser( - description="Generate static HTML comparisons of PET deface results using nilearn." - ) - 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" - ) - parser.add_argument( - "--output-dir", - help="Output directory for HTML files (default: {orig_folder}_{defaced_folder}_qa)", - ) - parser.add_argument( - "--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)", - ) - parser.add_argument( - "--subject", - type=str, - help="Filter to specific subject (e.g., 'sub-01' or 'sub-01_ses-baseline')", - ) - - 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() - - faced_dir = os.path.abspath(args.faced_dir) - defaced_dir = os.path.abspath(args.defaced_dir) +def run_qa( + faced_dir, + defaced_dir, + output_dir=None, + subject=None, + n_jobs=None, + size="compact", + open_browser=False, +): + """ + Run QA report generation programmatically. + + 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' + open_browser (bool): Whether to open browser automatically + + 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 - if args.output_dir: - output_dir = os.path.abspath(args.output_dir) + 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) @@ -1247,23 +1236,24 @@ def main(): print(f"Found {len(subjects)} subjects with matching files") # Filter to specific subject if requested - if args.subject: + if subject: original_count = len(subjects) - subjects = [s for s in subjects if args.subject in s["id"]] + subjects = [s for s in subjects if subject in s["id"]] print( - f"Filtered to {len(subjects)} subjects matching '{args.subject}' (from {original_count} total)" + f"Filtered to {len(subjects)} subjects matching '{subject}' (from {original_count} total)" ) if not subjects: - print(f"No subjects found matching '{args.subject}'") + 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']}") - exit(1) + raise ValueError(f"No subjects found matching '{subject}'") # Set number of jobs for parallel processing - n_jobs = args.n_jobs if args.n_jobs else mp.cpu_count() + if n_jobs is None: + n_jobs = mp.cpu_count() print(f"Using {n_jobs} parallel processes") # Preprocess all images once (4D→3D conversion) @@ -1281,7 +1271,7 @@ def main(): process_func = partial( process_subject, output_dir=output_dir, - size=args.size, + size=size, ) # Process all subjects in parallel @@ -1295,9 +1285,9 @@ def main(): # Create both HTML files side_by_side_file = create_side_by_side_index_html( - preprocessed_subjects, output_dir, args.size + preprocessed_subjects, output_dir, size ) - animated_file = create_gif_index_html(preprocessed_subjects, output_dir, args.size) + animated_file = create_gif_index_html(preprocessed_subjects, output_dir, size) # Create a simple index that links to both index_html = f""" @@ -1379,13 +1369,13 @@ def main(): "--output-dir", output_dir, "--size", - args.size, + size, ] - if args.n_jobs: - command_parts.extend(["--n-jobs", str(args.n_jobs)]) - if args.subject: - command_parts.extend(["--subject", args.subject]) - if args.open_browser: + 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) @@ -1404,13 +1394,72 @@ def main(): print(f"Saved command to: {command_file}") # Open browser if requested - if args.open_browser: + if open_browser: webbrowser.open(f"file://{index_file}") print(f"Opened browser to: {index_file}") print(f"\nAll files generated in: {output_dir}") print(f"Open index.html in your browser to view comparisons") + return { + "output_dir": output_dir, + "side_by_side_file": side_by_side_file, + "animated_file": animated_file, + "index_file": index_file, + "command_file": command_file, + "subjects_processed": len(successful), + "total_subjects": len(preprocessed_subjects), + } + + +def main(): + parser = argparse.ArgumentParser( + description="Generate static HTML comparisons of PET deface results using nilearn." + ) + 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" + ) + parser.add_argument( + "--output-dir", + help="Output directory for HTML files (default: {orig_folder}_{defaced_folder}_qa)", + ) + parser.add_argument( + "--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)", + ) + parser.add_argument( + "--subject", + type=str, + help="Filter to specific subject (e.g., 'sub-01' or 'sub-01_ses-baseline')", + ) + + 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, + output_dir=args.output_dir, + subject=args.subject, + n_jobs=args.n_jobs, + size=args.size, + open_browser=args.open_browser, + ) + if __name__ == "__main__": main() From 4438bf9675b90e9c725d155d33177c9246d7cdd6 Mon Sep 17 00:00:00 2001 From: Anthony Galassi <28850131+bendhouseart@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:16:23 -0400 Subject: [PATCH 15/15] format with black --- petdeface/petdeface.py | 9 ++------- petdeface/qa.py | 3 --- tests/test_dir_layouts.py | 34 +++++++++++++++++++++------------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/petdeface/petdeface.py b/petdeface/petdeface.py index 29f8ff6..ab5aa07 100755 --- a/petdeface/petdeface.py +++ b/petdeface/petdeface.py @@ -1271,7 +1271,6 @@ def main(): # noqa: max-complexity: 12 print("=" * 60) try: - # Determine the defaced directory based on placement if args.placement == "adjacent": defaced_dir = args.bids_dir.parent / f"{args.bids_dir.name}_defaced" @@ -1291,9 +1290,7 @@ def main(): # noqa: max-complexity: 12 faced_dir=str(args.bids_dir), defaced_dir=str(defaced_dir), subject=( - " ".join(args.participant_label) - if args.participant_label - else None + " ".join(args.participant_label) if args.participant_label else None ), open_browser=args.open_browser, ) @@ -1305,9 +1302,7 @@ def main(): # noqa: max-complexity: 12 except Exception as e: print(f"\nError generating QA reports: {e}") - print( - "QA report generation failed, but defacing completed successfully." - ) + print("QA report generation failed, but defacing completed successfully.") if __name__ == "__main__": diff --git a/petdeface/qa.py b/petdeface/qa.py index fa23c4e..4c21f5f 100644 --- a/petdeface/qa.py +++ b/petdeface/qa.py @@ -22,9 +22,6 @@ from pathlib import Path - - - 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") diff --git a/tests/test_dir_layouts.py b/tests/test_dir_layouts.py index 08e7c53..5b0609c 100644 --- a/tests/test_dir_layouts.py +++ b/tests/test_dir_layouts.py @@ -129,7 +129,7 @@ def test_participant_exclusion(): """Test that participant exclusion works correctly by excluding sub-02""" with tempfile.TemporaryDirectory() as temp_dir: test_dir = Path(temp_dir) - + # Create the test directory and copy our data shutil.copytree(data_dir, test_dir / "participant_exclusion") @@ -145,37 +145,45 @@ def test_participant_exclusion(): # Check the final defaced dataset directory final_defaced_dir = test_dir / "participant_exclusion_defaced" - + # Count files in the final defaced dataset all_files = list(final_defaced_dir.rglob("*")) all_files = [f for f in all_files if f.is_file()] # Only files, not directories - + # Count files by subject sub01_files = [f for f in all_files if "sub-01" in str(f)] sub02_files = [f for f in all_files if "sub-02" in str(f)] - + print(f"Total files in defaced dataset: {len(all_files)}") print(f"sub-01 files: {len(sub01_files)}") print(f"sub-02 files: {len(sub02_files)}") - + # Verify that sub-02 does NOT appear anywhere in the final defaced dataset - assert len(sub02_files) == 0, f"sub-02 should be completely excluded from final defaced dataset, but found {len(sub02_files)} files: {[str(f) for f in sub02_files]}" - + assert ( + len(sub02_files) == 0 + ), f"sub-02 should be completely excluded from final defaced dataset, but found {len(sub02_files)} files: {[str(f) for f in sub02_files]}" + # Verify that sub-01 exists and was processed assert len(sub01_files) > 0, "sub-01 should exist in final defaced dataset" - assert (final_defaced_dir / "sub-01").exists(), "sub-01 directory should exist in final defaced dataset" - + assert ( + final_defaced_dir / "sub-01" + ).exists(), "sub-01 directory should exist in final defaced dataset" + # Verify processing artifacts exist for sub-01 in derivatives derivatives_dir = final_defaced_dir / "derivatives" / "petdeface" if derivatives_dir.exists(): sub01_defacemasks = list(derivatives_dir.glob("**/sub-01*defacemask*")) sub01_lta_files = list(derivatives_dir.glob("**/sub-01*.lta")) - + print(f"sub-01 defacemasks found: {len(sub01_defacemasks)}") print(f"sub-01 lta files found: {len(sub01_lta_files)}") - - assert len(sub01_defacemasks) > 0, "sub-01 should have been processed and have defacemasks" - assert len(sub01_lta_files) > 0, "sub-01 should have been processed and have lta registration files" + + assert ( + len(sub01_defacemasks) > 0 + ), "sub-01 should have been processed and have defacemasks" + assert ( + len(sub01_lta_files) > 0 + ), "sub-01 should have been processed and have lta registration files" def test_no_anat():