From a36a4fef738881dafa8b14752436696ebc991854 Mon Sep 17 00:00:00 2001 From: hemanthkapa Date: Thu, 9 Jul 2026 14:11:19 -0700 Subject: [PATCH 1/8] Add pycurv-gpu dispatcher in curvature.run_pycurv. Introduce run_pycurv_gpu() as a thin wrapper around core.api.run_pipeline so surface_morphometrics can opt into the GPU backend without changing the default CPU path. Co-authored-by: Cursor --- surface_morphometrics/curvature.py | 83 +++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/surface_morphometrics/curvature.py b/surface_morphometrics/curvature.py index 7e22bfb..c66f927 100755 --- a/surface_morphometrics/curvature.py +++ b/surface_morphometrics/curvature.py @@ -14,6 +14,74 @@ from .curvature_calculation import new_workflow, extract_curvatures_after_new_workflow + +def _require_gpu_backend(device=None): + """Fail fast if pycurv-gpu / torch are missing or CUDA was requested but unavailable.""" + try: + import torch + except ImportError as e: + raise RuntimeError( + 'GPU pycurv requires PyTorch. Install the GPU extra: pip install -e ".[gpu]"' + ) from e + try: + from core.api import run_pipeline # noqa: F401 + except ImportError as e: + raise RuntimeError( + 'GPU pycurv requires pycurv-gpu. Install the GPU extra: pip install -e ".[gpu]"' + ) from e + resolved = device + if resolved is None: + if torch.cuda.is_available(): + resolved = 'cuda' + elif getattr(torch.backends, 'mps', None) and torch.backends.mps.is_available(): + resolved = 'mps' + else: + resolved = 'cpu' + if resolved == 'cuda' and not torch.cuda.is_available(): + raise RuntimeError('use_gpu was requested but CUDA is not available.') + return resolved + + +def run_pycurv_gpu(filename, folder, scale=1, radius_hit=10, min_component=30, + exclude_borders=0, cores=16, remove_wrong_borders=False, + device=None, batch_size=1024): + """Run pycurv-gpu on a vtp surface file (drop-in for run_pycurv).""" + assert filename.endswith(".surface.vtp"), "Surface must be a vtp file ending with .surface.vtp" + basename = filename[:-len(".surface.vtp")] + device = _require_gpu_backend(device) + + from core.api import run_pipeline + + t_begin = time.time() + print("\nCalculating curvatures (GPU) for {}".format(basename)) + run_pipeline( + os.path.join(folder, filename), + output_dir=folder, + radius_hit=radius_hit, + pixel_size=scale, + min_component=min_component, + exclude_borders=exclude_borders, + remove_wrong_borders=remove_wrong_borders, + device=device, + batch_size=batch_size, + write_gt=True, + write_vtp=True, + cores=cores, + ) + + t_end = time.time() + duration = t_end - t_begin + minutes, seconds = divmod(duration, 60) + if not folder.endswith("/"): + folder += "/" + rh_str = str(int(radius_hit)) if radius_hit == int(radius_hit) else str(radius_hit) + print('\nTotal GPU pycurv time: {} min {} s'.format(int(minutes), seconds)) + print("Final outputs written:") + print("VTP file for paraview: " + folder + basename + f'.AVV_rh{rh_str}.vtp') + print("CSV file for pandas based quantification: " + folder + basename + f'.AVV_rh{rh_str}.csv') + print("GT file for further morphometrics quantification: " + folder + basename + f'.AVV_rh{rh_str}.gt') + + @click.command() @click.argument('filename') @click.argument('folder') @@ -34,7 +102,9 @@ def run_pycurv_cli(filename, folder, scale, radius_hit, min_component, exclude_b raise SystemExit(1) run_pycurv(filename, folder, scale, radius_hit, min_component, exclude_borders, cores, remove_wrong_borders) -def run_pycurv(filename, folder, scale=1, radius_hit=10, min_component=30, exclude_borders=0, cores=16, remove_wrong_borders=False): +def run_pycurv(filename, folder, scale=1, radius_hit=10, min_component=30, exclude_borders=0, + cores=16, remove_wrong_borders=False, use_gpu=False, gpu_device=None, + gpu_batch_size=1024): """Run pycurv on a vtp surface file and extract curvatures filename (str): vtp surface file @@ -45,7 +115,18 @@ def run_pycurv(filename, folder, scale=1, radius_hit=10, min_component=30, exclu exclude_borders (int): distance in surface units (angstroms or nm) to exclude from the curvature calculation. Default is 0. cores (int): number of cores to use for the calculation. Default is 16. remove_wrong_borders (bool): eat back the surface before calculations. If using screened poisson workflow leave this False. + use_gpu (bool): if True, dispatch to pycurv-gpu instead of CPU pycurv. + gpu_device (str or None): optional torch device ('cuda', 'cpu', 'mps'). + gpu_batch_size (int): SSSP/voting batch size for pycurv-gpu. """ + if use_gpu: + return run_pycurv_gpu( + filename, folder, scale=scale, radius_hit=radius_hit, + min_component=min_component, exclude_borders=exclude_borders, cores=cores, + remove_wrong_borders=remove_wrong_borders, device=gpu_device, + batch_size=gpu_batch_size, + ) + assert filename.endswith(".surface.vtp"), "Surface must be a vtp file of a surface, ending with .surface.vtp" basename = filename[:-len(".surface.vtp")] runtimes_file = "{}{}_runtimes.csv".format(folder, basename) From d679aee7ba9b6be2d88ce122af0af5ef5a5b6b29 Mon Sep 17 00:00:00 2001 From: hemanthkapa Date: Thu, 9 Jul 2026 14:31:31 -0700 Subject: [PATCH 2/8] Wire --gpu flag and config use_gpu into morphometrics pycurv CLI. Read curvature_measurements.use_gpu / gpu_device / gpu_batch_size from config, let --gpu override, fail fast if the GPU backend is missing, and pass the flags through to curvature.run_pycurv(). Co-authored-by: Cursor --- surface_morphometrics/run_pycurv.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/surface_morphometrics/run_pycurv.py b/surface_morphometrics/run_pycurv.py index 6e624bc..af5372c 100755 --- a/surface_morphometrics/run_pycurv.py +++ b/surface_morphometrics/run_pycurv.py @@ -37,7 +37,9 @@ @click.argument("surface", required=False, default=None) @click.option("-f", "--force", is_flag=True, default=False, help="Skip interactive confirmation prompts.") -def run_pycurv_cli(configfile, surface, force): +@click.option("--gpu", is_flag=True, + help="Use pycurv-gpu (CUDA) instead of CPU pycurv.") +def run_pycurv_cli(configfile, surface, force, gpu): """Run pycurv vector-voting curvature analysis on surface meshes. CONFIGFILE: path to config.yml. @@ -45,6 +47,9 @@ def run_pycurv_cli(configfile, surface, force): in work_dir are processed. """ config = load_config(configfile, require=("work_dir",)) + use_gpu = gpu or config["curvature_measurements"].get("use_gpu", False) + if use_gpu: + curvature._require_gpu_backend(config["curvature_measurements"].get("gpu_device")) # Warn if configured cores exceed logical cores cores = config["cores"] @@ -79,7 +84,10 @@ def run_pycurv_cli(configfile, surface, force): radius_hit=config["curvature_measurements"]["radius_hit"], min_component=config["curvature_measurements"]["min_component"], exclude_borders=config["curvature_measurements"]["exclude_borders"], - cores=config["cores"]) + cores=config["cores"], + use_gpu=use_gpu, + gpu_device=config["curvature_measurements"].get("gpu_device"), + gpu_batch_size=config["curvature_measurements"].get("gpu_batch_size", 1024)) print("Completed {}\n".format(surface_file)) except Exception as e: print("WARNING: Skipping {} due to error: {}\n".format(surface_file, e)) @@ -91,7 +99,8 @@ def run_pycurv_cli(configfile, surface, force): print(" - {}".format(s)) print("-------------------------------------------------------") - print("Pycurv complete. It is highly recommended to check the AVV vtp file with paraview to confirm good results.") + label = "GPU pycurv" if use_gpu else "Pycurv" + print(f"{label} complete. It is highly recommended to check the AVV vtp file with paraview to confirm good results.") print("Pycurv Citation: Salfer M, Collado JF, Baumeister W, Fernández-Busnadiego R, Martínez-Sánchez A. Reliable estimation of membrane curvature for cryo-electron tomography. PLOS Comp Biol 2020.") print("Pipeline Citation: Barad BA*, Medina M*, Fuentes D, Wiseman RL, Grotjahn DA. Quantifying organellar ultrastructure in cryo-electron tomography using a surface morphometrics pipeline. J Cell Biol 2023.") From c72cb26cb42fdbafcc084026c39087238f12f7e6 Mon Sep 17 00:00:00 2001 From: hemanthkapa Date: Thu, 9 Jul 2026 14:38:15 -0700 Subject: [PATCH 3/8] Document use_gpu options in config_template.yml. Add use_gpu, gpu_device, and gpu_batch_size under curvature_measurements so new configs expose the optional pycurv-gpu backend. Co-authored-by: Cursor --- surface_morphometrics/config_template.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/surface_morphometrics/config_template.yml b/surface_morphometrics/config_template.yml index b7babf2..6742592 100755 --- a/surface_morphometrics/config_template.yml +++ b/surface_morphometrics/config_template.yml @@ -28,6 +28,9 @@ curvature_measurements: radius_hit: 9 # This corresponds to the radius of the smallest feature of interest, roughly, for neighborhood determination. 8-15 seems to be a good range for mitochondria min_component: 30 # The minimum number of triangles for a component to be considered for curvature measurement. exclude_borders: 1 # Values greater than zero exclude the border from the curvature calculation by n nm/angstroms. + use_gpu: false # Set true to use pycurv-gpu (CUDA) instead of CPU pycurv. Requires pip install -e ".[gpu]". + gpu_device: null # Optional torch device: cuda, cpu, or mps. null = auto-select. + gpu_batch_size: 1024 # SSSP/voting batch size for pycurv-gpu. distance_and_orientation_measurements: mindist: 3 # Minimum distance between two points for them to be considered for distance measurement. maxdist: 400 # Maximum distance between two points for them to be considered for distance measurement. From f3fca8ecd990ca9e91ccc990228b67d958da2644 Mon Sep 17 00:00:00 2001 From: hemanthkapa Date: Thu, 9 Jul 2026 14:45:30 -0700 Subject: [PATCH 4/8] Pass use_gpu through refine_mesh and clear GPU NVV cache. Read curvature_measurements GPU settings in refine_mesh, forward them into the pycurv subprocess, and delete stale .gpu_normals.npz alongside existing .gt/.vtp caches after vertex displacement. Co-authored-by: Cursor --- surface_morphometrics/refine_mesh.py | 46 +++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/surface_morphometrics/refine_mesh.py b/surface_morphometrics/refine_mesh.py index c962dc6..9cd2676 100644 --- a/surface_morphometrics/refine_mesh.py +++ b/surface_morphometrics/refine_mesh.py @@ -624,7 +624,8 @@ def recompute_normals(surf): return normals_filter.GetOutput() -def run_pycurv_refinement(vtp_file, output_base, pixel_size, radius_hit, cores=6): +def run_pycurv_refinement(vtp_file, output_base, pixel_size, radius_hit, cores=6, + use_gpu=False, gpu_device=None, gpu_batch_size=1024): """ Run pycurv normal vector voting on a refined surface. @@ -645,6 +646,12 @@ def run_pycurv_refinement(vtp_file, output_base, pixel_size, radius_hit, cores=6 Radius for normal vector voting cores : int Number of cores for parallel processing + use_gpu : bool + If True, dispatch to pycurv-gpu in the subprocess + gpu_device : str or None + Optional torch device for pycurv-gpu + gpu_batch_size : int + SSSP/voting batch size for pycurv-gpu Returns ------- @@ -671,9 +678,11 @@ def run_pycurv_refinement(vtp_file, output_base, pixel_size, radius_hit, cores=6 # Must include NVV_rh*.gt and AVV_rh*.gt: pycurv skips NVV if NVV_rh*.gt # exists, and uses that stale graph for curvature estimation, which then # propagates wrong normals into the next iteration's profile sampling. + # Also delete pycurv-gpu's .gpu_normals.npz Pass-1 cache for the same reason. stale_exts = [ ".scaled_cleaned.gt", ".scaled_cleaned.vtp", f".NVV_rh{radius_hit}.gt", + f".NVV_rh{radius_hit}.gpu_normals.npz", f".AVV_rh{radius_hit}.gt", f".AVV_rh{radius_hit}.vtp", f".AVV_rh{radius_hit}.csv", f".AVV_rh{radius_hit}_sampling.csv", ] @@ -700,16 +709,22 @@ def run_pycurv_refinement(vtp_file, output_base, pixel_size, radius_hit, cores=6 "os.environ['OMP_NUM_THREADS'] = '1'\n" # before importing graph-tool/pycurv "from surface_morphometrics import curvature\n" "surf, outdir, rh_s, cores = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])\n" + "use_gpu = sys.argv[5] == '1'\n" + "gpu_device = sys.argv[6] or None\n" + "gpu_batch_size = int(sys.argv[7])\n" "try:\n" " rh = int(rh_s)\n" # preserve int vs float so AVV_rh filenames match "except ValueError:\n" " rh = float(rh_s)\n" "curvature.run_pycurv(surf, outdir, scale=1.0, radius_hit=rh,\n" - " min_component=0, exclude_borders=0, cores=cores)\n" + " min_component=0, exclude_borders=0, cores=cores,\n" + " use_gpu=use_gpu, gpu_device=gpu_device,\n" + " gpu_batch_size=gpu_batch_size)\n" ) proc = subprocess.run( [sys.executable, "-c", runner, - f"{basename}.surface.vtp", output_dir, str(radius_hit), str(cores)], + f"{basename}.surface.vtp", output_dir, str(radius_hit), str(cores), + "1" if use_gpu else "0", gpu_device or "", str(gpu_batch_size)], env={**os.environ, "OMP_NUM_THREADS": "1"}, ) if proc.returncode != 0: @@ -785,7 +800,8 @@ def build_lightweight_graph(surf, output_gt_path): def finalize_surface_with_pycurv(surface_vtp, mrc_file, output_base, pixel_size, radius_hit, sample_spacing, scan_range, angstroms, - cores, average_radius, compute_thickness=True): + cores, average_radius, compute_thickness=True, + use_gpu=False, gpu_device=None, gpu_batch_size=1024): """Run full pycurv on an already-refined surface to produce the final output. Intermediate refinement iterations skip pycurv (they build fast lightweight @@ -809,6 +825,12 @@ def finalize_surface_with_pycurv(surface_vtp, mrc_file, output_base, pixel_size, compute_thickness : bool If True, also measure the local thickness distribution on the finalized surface (for the convergence histogram). + use_gpu : bool + If True, dispatch final pycurv to pycurv-gpu. + gpu_device : str or None + Optional torch device for pycurv-gpu. + gpu_batch_size : int + SSSP/voting batch size for pycurv-gpu. Returns ------- @@ -824,7 +846,8 @@ def finalize_surface_with_pycurv(surface_vtp, mrc_file, output_base, pixel_size, gc.collect() print("Running full pycurv normal vector voting for a curvature-ready final surface...") graph_file, surface_file = run_pycurv_refinement( - surface_vtp, output_base, pixel_size, radius_hit, cores) + surface_vtp, output_base, pixel_size, radius_hit, cores, + use_gpu=use_gpu, gpu_device=gpu_device, gpu_batch_size=gpu_batch_size) result = { 'graph_file': graph_file, @@ -863,7 +886,8 @@ def refine_mesh_iteration(graph_file, vtp_file, mrc_file, output_base, pixel_siz original_positions=None, max_total_offset=None, use_xcorr=False, smooth_offsets=True, offset_smoothing_radius=None, laplacian_iterations=0, laplacian_lambda=0.5, lowpass_sigma=0, - run_full_pycurv=True, compute_thickness=False): + run_full_pycurv=True, compute_thickness=False, + use_gpu=False, gpu_device=None, gpu_batch_size=1024): """ Perform a single iteration of mesh refinement. @@ -1057,7 +1081,8 @@ def refine_mesh_iteration(graph_file, vtp_file, mrc_file, output_base, pixel_siz if run_full_pycurv: print("Running pycurv normal vector voting...") new_graph_file, new_surface_file = run_pycurv_refinement( - refined_vtp, output_base, pixel_size, radius_hit, cores + refined_vtp, output_base, pixel_size, radius_hit, cores, + use_gpu=use_gpu, gpu_device=gpu_device, gpu_batch_size=gpu_batch_size, ) sampling_csv = f"{output_base}.AVV_rh{radius_hit}_sampling.csv" else: @@ -1267,6 +1292,9 @@ def refine_mesh(config_file, iterations=5, damping_factor=0.6, output_dir=None, # Try to get pixel size from a known location or default # Note: pixel_size might need to be determined from the data radius_hit = curvature_config.get("radius_hit", 9) + use_gpu = curvature_config.get("use_gpu", False) + gpu_device = curvature_config.get("gpu_device") + gpu_batch_size = curvature_config.get("gpu_batch_size", 1024) # Average radius: prefer mesh_refinement, fall back to thickness_measurements average_radius = refinement_config.get("average_radius", thickness_config.get("average_radius", 12)) # Progressive radius reduction settings @@ -1709,7 +1737,9 @@ def refine_mesh(config_file, iterations=5, damping_factor=0.6, output_dir=None, fin = finalize_surface_with_pycurv( current_vtp, mrc_file, iter_output_base, pixel_size, radius_hit, sample_spacing, scan_range, angstroms, cores, - current_avg_radius, compute_thickness=True) + current_avg_radius, compute_thickness=True, + use_gpu=use_gpu, gpu_device=gpu_device, + gpu_batch_size=gpu_batch_size) final_entry = iteration_stats[-1] final_entry['graph_file'] = fin['graph_file'] final_entry['surface_file'] = fin['surface_file'] From 57789be8ea6ce87900281e887936c166f158a76d Mon Sep 17 00:00:00 2001 From: hemanthkapa Date: Thu, 9 Jul 2026 14:49:48 -0700 Subject: [PATCH 5/8] Install pycurv-gpu with the morphometrics conda env. Add pycurv-gpu next to pycurv in environment.yml / environment-ubuntu.yml so GPU curvature is available by default; update install error messages accordingly. Co-authored-by: Cursor --- environment-ubuntu.yml | 1 + environment.yml | 3 ++- surface_morphometrics/config_template.yml | 2 +- surface_morphometrics/curvature.py | 7 +++++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/environment-ubuntu.yml b/environment-ubuntu.yml index 318c317..e48f4ff 100644 --- a/environment-ubuntu.yml +++ b/environment-ubuntu.yml @@ -29,6 +29,7 @@ dependencies: - pymeshlab>=2023.12 - git+https://github.com/vladanl/Pyto.git - git+https://github.com/bbarad/pycurv.git + - git+https://github.com/hemanthkapa/pycurv-gpu.git - git+https://github.com/hemanthkapa/napari-vedo-bridge.git - napari-tomoslice - napari-timelapse-processor diff --git a/environment.yml b/environment.yml index 17cb419..26ee276 100755 --- a/environment.yml +++ b/environment.yml @@ -26,8 +26,9 @@ dependencies: - pip: - git+https://github.com/vladanl/Pyto.git - git+https://github.com/bbarad/pycurv.git + - git+https://github.com/hemanthkapa/pycurv-gpu.git - git+https://github.com/hemanthkapa/napari-vedo-bridge.git - napari-tomoslice - napari-timelapse-processor # Install this toolkit and the `morphometrics` command (pulls remaining pip deps, e.g. starfile) - - -e . \ No newline at end of file + - -e . diff --git a/surface_morphometrics/config_template.yml b/surface_morphometrics/config_template.yml index 6742592..2b9bb70 100755 --- a/surface_morphometrics/config_template.yml +++ b/surface_morphometrics/config_template.yml @@ -28,7 +28,7 @@ curvature_measurements: radius_hit: 9 # This corresponds to the radius of the smallest feature of interest, roughly, for neighborhood determination. 8-15 seems to be a good range for mitochondria min_component: 30 # The minimum number of triangles for a component to be considered for curvature measurement. exclude_borders: 1 # Values greater than zero exclude the border from the curvature calculation by n nm/angstroms. - use_gpu: false # Set true to use pycurv-gpu (CUDA) instead of CPU pycurv. Requires pip install -e ".[gpu]". + use_gpu: false # Set true to use pycurv-gpu (CUDA) instead of CPU pycurv. pycurv-gpu is installed with the env. gpu_device: null # Optional torch device: cuda, cpu, or mps. null = auto-select. gpu_batch_size: 1024 # SSSP/voting batch size for pycurv-gpu. distance_and_orientation_measurements: diff --git a/surface_morphometrics/curvature.py b/surface_morphometrics/curvature.py index c66f927..884de73 100755 --- a/surface_morphometrics/curvature.py +++ b/surface_morphometrics/curvature.py @@ -21,13 +21,16 @@ def _require_gpu_backend(device=None): import torch except ImportError as e: raise RuntimeError( - 'GPU pycurv requires PyTorch. Install the GPU extra: pip install -e ".[gpu]"' + "GPU pycurv requires PyTorch. Install a CUDA torch build " + "(https://pytorch.org/get-started/locally/) into the morphometrics env." ) from e try: from core.api import run_pipeline # noqa: F401 except ImportError as e: raise RuntimeError( - 'GPU pycurv requires pycurv-gpu. Install the GPU extra: pip install -e ".[gpu]"' + "GPU pycurv requires pycurv-gpu. Reinstall the morphometrics env " + "(environment.yml includes pycurv-gpu) or: " + "pip install git+https://github.com/hemanthkapa/pycurv-gpu.git" ) from e resolved = device if resolved is None: From 7f08746eeb51abbde1b0e56b6712601cc1be3144 Mon Sep 17 00:00:00 2001 From: hemanthkapa Date: Thu, 9 Jul 2026 14:53:13 -0700 Subject: [PATCH 6/8] Add unit tests for pycurv-gpu dispatch and config defaults. Cover use_gpu routing, write_gt=True on the GPU path, CLI --gpu / config use_gpu wiring, and keep DEFAULTS in sync with the template. Co-authored-by: Cursor --- surface_morphometrics/config_utils.py | 3 + tests/test_pycurv_gpu_dispatch.py | 129 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 tests/test_pycurv_gpu_dispatch.py diff --git a/surface_morphometrics/config_utils.py b/surface_morphometrics/config_utils.py index af2e8f7..3cb7e97 100644 --- a/surface_morphometrics/config_utils.py +++ b/surface_morphometrics/config_utils.py @@ -40,6 +40,9 @@ "radius_hit": 9, "min_component": 30, "exclude_borders": 1, + "use_gpu": False, + "gpu_device": None, + "gpu_batch_size": 1024, }, "distance_and_orientation_measurements": { "mindist": 3, diff --git a/tests/test_pycurv_gpu_dispatch.py b/tests/test_pycurv_gpu_dispatch.py new file mode 100644 index 0000000..8c56282 --- /dev/null +++ b/tests/test_pycurv_gpu_dispatch.py @@ -0,0 +1,129 @@ +"""Unit tests for pycurv-gpu dispatch (no CUDA / no real mesh required).""" +import sys +import textwrap +from types import ModuleType +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +# Stub heavy optional deps only when missing so collection works outside the +# full morphometrics env, without shadowing a real graph-tool install. +for _name in ("graph_tool", "pathos", "pathos.pools", "pycurv", "pyto"): + if _name not in sys.modules: + try: + __import__(_name) + except ImportError: + sys.modules[_name] = MagicMock() + +from surface_morphometrics import curvature # noqa: E402 +from surface_morphometrics import run_pycurv as run_pycurv_mod # noqa: E402 + + +def test_run_pycurv_dispatches_to_gpu(): + with patch.object(curvature, "run_pycurv_gpu") as mock_gpu: + curvature.run_pycurv( + "mesh.surface.vtp", "/tmp/work/", + use_gpu=True, gpu_device="cuda", gpu_batch_size=512, + ) + mock_gpu.assert_called_once() + kwargs = mock_gpu.call_args.kwargs + assert kwargs["device"] == "cuda" + assert kwargs["batch_size"] == 512 + + +def test_run_pycurv_gpu_calls_pipeline_with_write_gt(): + mock_pipeline = MagicMock(return_value={}) + fake_api = ModuleType("core.api") + fake_api.run_pipeline = mock_pipeline + fake_core = ModuleType("core") + with patch.object(curvature, "_require_gpu_backend", return_value="cuda"), \ + patch.dict(sys.modules, {"core": fake_core, "core.api": fake_api}): + curvature.run_pycurv_gpu( + "mesh.surface.vtp", "/tmp/work/", + scale=1.0, radius_hit=9, min_component=30, exclude_borders=1, + device="cuda", batch_size=512, + ) + + mock_pipeline.assert_called_once() + kwargs = mock_pipeline.call_args.kwargs + assert kwargs["write_gt"] is True + assert kwargs["write_vtp"] is True + assert kwargs["radius_hit"] == 9 + assert kwargs["pixel_size"] == 1.0 + assert kwargs["device"] == "cuda" + assert kwargs["batch_size"] == 512 + assert kwargs["min_component"] == 30 + assert kwargs["exclude_borders"] == 1 + + +def test_run_pycurv_cpu_path_calls_new_workflow(): + with patch.object(curvature, "new_workflow") as mock_nw, \ + patch.object(curvature, "extract_curvatures_after_new_workflow") as mock_ex, \ + patch.object(curvature, "run_pycurv_gpu") as mock_gpu: + curvature.run_pycurv( + "mesh.surface.vtp", "/tmp/work/", + scale=1.0, radius_hit=9, min_component=30, exclude_borders=1, + use_gpu=False, + ) + mock_gpu.assert_not_called() + mock_nw.assert_called_once() + mock_ex.assert_called_once() + + +def test_cli_passes_gpu_flag(tmp_path): + work = tmp_path / "work" + work.mkdir() + (work / "mesh.surface.vtp").write_text("placeholder") + cfg = tmp_path / "config.yml" + cfg.write_text(textwrap.dedent(f"""\ + work_dir: "{work}/" + cores: 1 + curvature_measurements: + radius_hit: 9 + min_component: 30 + exclude_borders: 1 + use_gpu: false + """)) + + with patch.object(curvature, "_require_gpu_backend", return_value="cuda") as mock_req, \ + patch.object(curvature, "run_pycurv") as mock_run: + runner = CliRunner() + result = runner.invoke( + run_pycurv_mod.run_pycurv_cli, + [str(cfg), "mesh.surface.vtp", "--gpu", "-f"], + ) + assert result.exit_code == 0, result.output + mock_req.assert_called_once() + mock_run.assert_called_once() + assert mock_run.call_args.kwargs["use_gpu"] is True + + +def test_cli_reads_use_gpu_from_config(tmp_path): + work = tmp_path / "work" + work.mkdir() + (work / "mesh.surface.vtp").write_text("placeholder") + cfg = tmp_path / "config.yml" + cfg.write_text(textwrap.dedent(f"""\ + work_dir: "{work}/" + cores: 1 + curvature_measurements: + radius_hit: 9 + min_component: 30 + exclude_borders: 1 + use_gpu: true + gpu_device: cuda + gpu_batch_size: 256 + """)) + + with patch.object(curvature, "_require_gpu_backend", return_value="cuda"), \ + patch.object(curvature, "run_pycurv") as mock_run: + runner = CliRunner() + result = runner.invoke( + run_pycurv_mod.run_pycurv_cli, + [str(cfg), "mesh.surface.vtp", "-f"], + ) + assert result.exit_code == 0, result.output + kwargs = mock_run.call_args.kwargs + assert kwargs["use_gpu"] is True + assert kwargs["gpu_device"] == "cuda" + assert kwargs["gpu_batch_size"] == 256 From f4770da4b93c93eca87d2580783a26e8df8c4432 Mon Sep 17 00:00:00 2001 From: hemanthkapa Date: Thu, 9 Jul 2026 14:57:30 -0700 Subject: [PATCH 7/8] Document GPU pycurv usage in the README. Add a short pycurv-gpu section covering use_gpu / --gpu, install notes for CUDA torch, and troubleshooting for missing GPU backends. Co-authored-by: Cursor --- README.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 29fb6cd..57755a3 100755 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Everything is driven by a single `morphometrics` command plus a `config.yml` fil - [Quick start](#quick-start) - [Example data](#example-data) - [The pipeline](#the-pipeline) +- [GPU curvature (pycurv-gpu)](#gpu-curvature-pycurv-gpu) - [Analysis & visualization](#analysis--visualization) - [Reference](#reference) - [Troubleshooting](#troubleshooting) @@ -32,7 +33,8 @@ The fastest, easiest starting point for most Linux boxes, and now for Mac as wel 1. Clone the repository: `git clone https://github.com/baradlab/surface_morphometrics.git` 2. Create the environment (this also installs the toolkit and the `morphometrics` command via `pip install -e .`): `conda env create -f environment.yml` 3. Activate it: `conda activate morphometrics` -4. Check the install: `morphometrics --help` should list the pipeline subcommands. +4. *(GPU machines)* Install a CUDA-enabled PyTorch build into the same env — see [pytorch.org](https://pytorch.org/get-started/locally/). `pycurv-gpu` is already installed by `environment.yml`. +5. Check the install: `morphometrics --help` should list the pipeline subcommands. > Older Ubuntu installs (and some other Linux distributions) have known issues with graph-tool. If the main environment file fails, try `conda env create -f environment-ubuntu.yml` instead (tested on Ubuntu 22.04 LTS). @@ -106,7 +108,7 @@ Each step reads a `config.yml` and writes its outputs into the configured `work_ | 0 | Create a config | `morphometrics new_config` | | – | *(optional)* [Validate the setup](#checking-your-setup-and-progress) | `morphometrics validate config.yml` | | 1 | Segmentations → meshes | `morphometrics make_meshes config.yml` | -| 2 | Curvature (pycurv) | `morphometrics pycurv config.yml` | +| 2 | Curvature (pycurv) | `morphometrics pycurv config.yml` ([GPU](#gpu-curvature-pycurv-gpu) optional) | | 3 | *(optional)* [Mesh refinement](#mesh-refinement-optional) | `morphometrics refine_mesh config.yml` → `accept_refinement` | | 4 | Distances & orientations | `morphometrics distances_orientations config.yml` | | 5 | [Thickness](#data-organization-for-thickness-and-refinement) (needs tomograms) | `morphometrics sample_density config.yml` → `measure_thickness` | @@ -114,6 +116,24 @@ Each step reads a `config.yml` and writes its outputs into the configured `work_ Most steps also accept a single input (e.g. `morphometrics pycurv config.yml TE1_OMM.surface.vtp`, `morphometrics distances_orientations config.yml TE1.mrc`) so you can parallelize per tomogram on a cluster. +### GPU curvature (pycurv-gpu) +Step 2 can use [pycurv-gpu](https://github.com/hemanthkapa/pycurv-gpu) instead of CPU pycurv. Outputs (`.vtp` / `.csv` / `.gt`) keep the same names and properties, so distances, thickness, refinement, and the GUI work unchanged. + +Enable it either way: +```yaml +# in config.yml under curvature_measurements +use_gpu: true +# gpu_device: null # optional: cuda / cpu / mps (null = auto) +# gpu_batch_size: 1024 # optional tuning +``` +```bash +morphometrics pycurv config.yml --gpu +morphometrics pycurv config.yml TE1_OMM.surface.vtp --gpu +``` +`--gpu` overrides `use_gpu: false`. `refine_mesh` also honors `use_gpu` from config for its final pycurv pass. + +Requirements: CUDA-capable GPU + a CUDA PyTorch build in the morphometrics env (`pycurv-gpu` itself is installed by `environment.yml`). Without CUDA, leave `use_gpu: false` (default) and use CPU pycurv. + ### Configuration `morphometrics new_config` writes a fully-commented `config.yml` into the current directory (`-o NAME` for a different name); edit it for your project. For a stripped-down starting point, `morphometrics new_config --simple` writes a minimal config with just the most commonly-adjusted settings — directories, `segmentation_values`, cores, the main meshing options, and the thickness/refinement averaging radii; everything omitted falls back to documented defaults, so a partial config still runs. A few starting tips: - For higher-quality meshes with near-equilateral triangles, set `isotropic_remesh: true` and `simplify: false` in `surface_generation`. `target_area` of 1.0–3.0 nm² works well (smaller = finer but slower). @@ -249,6 +269,7 @@ The toolkit is the `surface_morphometrics` Python package; the pipeline steps ar - `Gaussian or Mean curvature of X has a large computation error` — safe to ignore (pycurv cleans these up); they are suppressed by default. - MRC files from AMIRA (and some other software, e.g. Dragonfly) lack proper machine stamps; open with `mrcfile.open(filename, permissive=True)`. - If pycurv seems to hang indefinitely, try setting `cores: 1` in the config. +- `GPU pycurv requires PyTorch` / `CUDA is not available` — install a CUDA torch build into the morphometrics env, or leave `use_gpu: false`. --- @@ -276,7 +297,7 @@ Two config keys were also renamed: `data_dir` → `seg_dir`, and `max_triangles` --- ## Dependencies -Numpy, Scipy, Pandas, mrcfile, Click, Matplotlib, starfile, Pymeshlab, and [PyCurv](https://github.com/kalemaria/pycurv) (which pulls in Pyto and graph-tool). The conda environment files install everything; see [Installation](#installation). +Numpy, Scipy, Pandas, mrcfile, Click, Matplotlib, starfile, Pymeshlab, [PyCurv](https://github.com/kalemaria/pycurv) (which pulls in Pyto and graph-tool), and [pycurv-gpu](https://github.com/hemanthkapa/pycurv-gpu) for optional GPU curvature. The conda environment files install everything except a CUDA PyTorch build (install that separately on GPU machines); see [Installation](#installation). --- From d1903c42c2d1d211995c184163d19d32210fc19b Mon Sep 17 00:00:00 2001 From: Hemanth Kapa Date: Fri, 17 Jul 2026 14:08:27 -0700 Subject: [PATCH 8/8] Fail GPU curvature cleanly without CUDA. Reject CPU and MPS devices so enabling the GPU backend never silently falls back to torch on CPU. --- README.md | 2 +- surface_morphometrics/config_template.yml | 4 +-- surface_morphometrics/curvature.py | 41 +++++++++++++++++------ tests/test_pycurv_gpu_dispatch.py | 37 ++++++++++++++++++++ 4 files changed, 70 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 57755a3..c0320ea 100755 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Enable it either way: ```yaml # in config.yml under curvature_measurements use_gpu: true -# gpu_device: null # optional: cuda / cpu / mps (null = auto) +# gpu_device: null # optional: cuda (null = auto; never falls back to CPU) # gpu_batch_size: 1024 # optional tuning ``` ```bash diff --git a/surface_morphometrics/config_template.yml b/surface_morphometrics/config_template.yml index 2b9bb70..05a5b50 100755 --- a/surface_morphometrics/config_template.yml +++ b/surface_morphometrics/config_template.yml @@ -28,8 +28,8 @@ curvature_measurements: radius_hit: 9 # This corresponds to the radius of the smallest feature of interest, roughly, for neighborhood determination. 8-15 seems to be a good range for mitochondria min_component: 30 # The minimum number of triangles for a component to be considered for curvature measurement. exclude_borders: 1 # Values greater than zero exclude the border from the curvature calculation by n nm/angstroms. - use_gpu: false # Set true to use pycurv-gpu (CUDA) instead of CPU pycurv. pycurv-gpu is installed with the env. - gpu_device: null # Optional torch device: cuda, cpu, or mps. null = auto-select. + use_gpu: false # Set true to use pycurv-gpu (CUDA) instead of CPU pycurv. + gpu_device: null # Optional torch device: cuda. null = auto-select. gpu_batch_size: 1024 # SSSP/voting batch size for pycurv-gpu. distance_and_orientation_measurements: mindist: 3 # Minimum distance between two points for them to be considered for distance measurement. diff --git a/surface_morphometrics/curvature.py b/surface_morphometrics/curvature.py index 884de73..41442dd 100755 --- a/surface_morphometrics/curvature.py +++ b/surface_morphometrics/curvature.py @@ -16,7 +16,7 @@ def _require_gpu_backend(device=None): - """Fail fast if pycurv-gpu / torch are missing or CUDA was requested but unavailable.""" + """Fail fast if pycurv-gpu / torch / a CUDA device are unavailable.""" try: import torch except ImportError as e: @@ -32,16 +32,35 @@ def _require_gpu_backend(device=None): "(environment.yml includes pycurv-gpu) or: " "pip install git+https://github.com/hemanthkapa/pycurv-gpu.git" ) from e - resolved = device - if resolved is None: - if torch.cuda.is_available(): - resolved = 'cuda' - elif getattr(torch.backends, 'mps', None) and torch.backends.mps.is_available(): - resolved = 'mps' - else: - resolved = 'cpu' - if resolved == 'cuda' and not torch.cuda.is_available(): - raise RuntimeError('use_gpu was requested but CUDA is not available.') + + cuda_ok = torch.cuda.is_available() + + if device is None: + if cuda_ok: + return 'cuda' + raise RuntimeError( + "use_gpu was requested but CUDA is not available. " + "Install a CUDA PyTorch build on a GPU machine, or set use_gpu: false " + "to use CPU pycurv. GPU mode will not fall back to CPU torch." + ) + + resolved = str(device).lower() + if resolved in ('cpu', 'cpu:0'): + raise RuntimeError( + "gpu_device='cpu' is not supported. use_gpu requires CUDA; " + "set use_gpu: false to run CPU pycurv instead." + ) + if resolved == 'mps': + raise RuntimeError( + "gpu_device='mps' is not supported. use_gpu requires CUDA; " + "set use_gpu: false to run CPU pycurv instead." + ) + if resolved.startswith('cuda') and not cuda_ok: + raise RuntimeError( + "use_gpu was requested with gpu_device={!r} but CUDA is not available. " + "Install a CUDA PyTorch build, or set use_gpu: false for CPU pycurv." + .format(device) + ) return resolved diff --git a/tests/test_pycurv_gpu_dispatch.py b/tests/test_pycurv_gpu_dispatch.py index 8c56282..1cca98d 100644 --- a/tests/test_pycurv_gpu_dispatch.py +++ b/tests/test_pycurv_gpu_dispatch.py @@ -19,6 +19,43 @@ from surface_morphometrics import run_pycurv as run_pycurv_mod # noqa: E402 +def test_require_gpu_backend_fails_without_accelerator(): + fake_torch = MagicMock() + fake_torch.cuda.is_available.return_value = False + fake_api = ModuleType("core.api") + fake_api.run_pipeline = MagicMock() + fake_core = ModuleType("core") + with patch.dict(sys.modules, { + "torch": fake_torch, + "core": fake_core, + "core.api": fake_api, + }): + try: + curvature._require_gpu_backend(None) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "CUDA is not available" in str(exc) + assert "will not fall back to CPU torch" in str(exc) + + +def test_require_gpu_backend_rejects_explicit_cpu(): + fake_torch = MagicMock() + fake_torch.cuda.is_available.return_value = True + fake_api = ModuleType("core.api") + fake_api.run_pipeline = MagicMock() + fake_core = ModuleType("core") + with patch.dict(sys.modules, { + "torch": fake_torch, + "core": fake_core, + "core.api": fake_api, + }): + try: + curvature._require_gpu_backend("cpu") + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "gpu_device='cpu' is not supported" in str(exc) + + def test_run_pycurv_dispatches_to_gpu(): with patch.object(curvature, "run_pycurv_gpu") as mock_gpu: curvature.run_pycurv(