From 7eedabd03d3ac74958afdd364cf865bd580e0983 Mon Sep 17 00:00:00 2001 From: an-altosian Date: Thu, 30 Apr 2026 19:16:14 +0000 Subject: [PATCH 01/11] docs(base.config): explain errorStrategy exit codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add inline comment documenting the semantics of each exit-code range in the errorStrategy retry list. Notably, 2147483647 is Integer.MAX_VALUE — Nextflow's sentinel for tasks that died before writing .exitcode (e.g. AWS Batch spot reclamation, kubernetes preemption, grid-scheduler cancellations) — not an AWS-specific exit code. Cite Nextflow docs/aws.md for the AWS case. Addresses PR #139 review comment r3165322845. --- conf/base.config | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/conf/base.config b/conf/base.config index e1668aaf..45b62604 100644 --- a/conf/base.config +++ b/conf/base.config @@ -16,6 +16,15 @@ process { // resourceLimits = [ cpus: 192, memory: 750.GB, time: 72.h ] + // Retry signal-induced exits and "killed without exit code" cases: + // 130..145 = signal exits (SIGINT=130, SIGKILL=137, SIGTERM=143, etc.) + // 104 = ECONNRESET (transient network failures during stage-in/out) + // 2147483647 = Integer.MAX_VALUE, Nextflow's sentinel for tasks that died + // before writing .exitcode (Nextflow surfaces this as + // "terminated for an unknown reason -- Likely it has been + // terminated by the external system"). Common on AWS Batch + // spot capacity, kubernetes preemption, and grid-scheduler + // cancellations. See nextflow docs/aws.md for the AWS case. errorStrategy = { task.exitStatus in ((130..145) + 104 + 2147483647) ? 'retry' : 'finish' } maxRetries = 3 maxErrors = '-1' From 1585a9dfa3b800c83c0becc22691a2ddae706286 Mon Sep 17 00:00:00 2001 From: an-altosian Date: Thu, 30 Apr 2026 19:16:38 +0000 Subject: [PATCH 02/11] style(modules.config): replace findAll().join(' ') with join(' ').trim() for STARDIST ext.args Replaces the Groovy-fallthrough `findAll()` (no closure) with the documented stdlib `join` + `trim` chain. Internal double-spaces from empty-string entries get collapsed by bash word-splitting at `${args}` interpolation; `.trim()` removes leading/trailing whitespace. Matches the existing project idiom used in subworkflows/local/utils_nfcore_spatialxe_pipeline/main.nf:341,352 (toolCitationText / toolBibliographyText). Addresses PR #139 review comment r3165345526. --- conf/modules.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/modules.config b/conf/modules.config index 22726919..b5066cf5 100644 --- a/conf/modules.config +++ b/conf/modules.config @@ -307,7 +307,7 @@ process { params.stardist_prob_thresh != null ? "--prob_thresh ${params.stardist_prob_thresh}" : "", params.stardist_nms_thresh != null ? "--nms_thresh ${params.stardist_nms_thresh}" : "", params.stardist_n_tiles != null ? "--n_tiles ${params.stardist_n_tiles}" : "", - ].findAll().join(' ')} + ].join(' ').trim()} } withName: 'STARDIST_NUCLEI' { From 74aa9171df54256b9a7eddd9de1efa0c89d117c5 Mon Sep 17 00:00:00 2001 From: an-altosian Date: Thu, 30 Apr 2026 19:16:50 +0000 Subject: [PATCH 03/11] chore: remove orphan modules/local/utility/Dockerfile This Dockerfile was unreferenced by any module: a repo-wide grep for "modules/local/utility/Dockerfile" returns zero hits. Each utility submodule (convert_mask_uint32, extract_dapi, resize_tif, segger2xr, etc.) declares its own container directly via `container "..."`. The associated `conda.yml` it expected (per the COPY instruction) does not exist next to it. Leftover from an earlier development phase; no longer needed. Addresses PR #139 review comment r3165438419. --- modules/local/utility/Dockerfile | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 modules/local/utility/Dockerfile diff --git a/modules/local/utility/Dockerfile b/modules/local/utility/Dockerfile deleted file mode 100644 index 79213ebb..00000000 --- a/modules/local/utility/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM mambaorg/micromamba:1.5.10-noble -COPY --chown=$MAMBA_USER:$MAMBA_USER conda.yml /tmp/conda.yml -RUN micromamba install -y -n base -f /tmp/conda.yml \ - && micromamba install -y -n base conda-forge::procps-ng \ - && micromamba env export --name base --explicit > environment.lock \ - && echo ">> CONDA_LOCK_START" \ - && cat environment.lock \ - && echo "<< CONDA_LOCK_END" \ - && micromamba clean -a -y -USER root -ENV PATH="$MAMBA_ROOT_PREFIX/bin:$PATH" From d59fac34b1290b7474b21ae25abb2695e343a1ae Mon Sep 17 00:00:00 2001 From: an-altosian Date: Thu, 30 Apr 2026 19:17:03 +0000 Subject: [PATCH 04/11] feat(schema): add format validators (directory-path, file-path) to schema_input.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add nf-schema `format` keys so the validator confirms paths exist: - bundle: format: directory-path - image: format: file-path These validate path existence at runtime, not name conventions — so the team's concern about varying 10x bundle/image naming doesn't conflict. The format key is documented at https://nextflow-io.github.io/nf-schema/latest/nextflow_schema/nextflow_schema_specification/ Addresses PR #139 review comments r3165291219 (C3) and r3165292878 (C4). --- assets/schema_input.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/schema_input.json b/assets/schema_input.json index 2dfb357a..5561c0f1 100644 --- a/assets/schema_input.json +++ b/assets/schema_input.json @@ -15,11 +15,13 @@ }, "bundle": { "type": "string", + "format": "directory-path", "pattern": "^\\S+$", "errorMessage": "Please provide a bundle as input data" }, "image": { "type": "string", + "format": "file-path", "pattern": "^\\S+$", "errorMessage": "You can provide an image. If you do not then please leave the field empty." } From cf0d42da9eb7fb5fd27491f34b8b75a6f2a9b80f Mon Sep 17 00:00:00 2001 From: an-altosian Date: Thu, 30 Apr 2026 19:17:14 +0000 Subject: [PATCH 05/11] fix(xenium_patch/stitch): thread baysor_tiling_min_transcripts_per_cell via ext.args Removes the inline `params.baysor_tiling_min_transcripts_per_cell` reference from the stitch process script. Instead: - The XENIUM_PATCH_STITCH withName: block in conf/modules.config sets the flag via ext.args - The .nf script declares `def args = task.ext.args ?: ''` and interpolates `${args}` into the command Per Florian's narrowing directive, only the cited param at line 40 is fixed. Other params references in the file (if any) remain as-is for follow-up work. Addresses PR #139 review comment r3165442902. --- conf/modules.config | 1 + modules/local/xenium_patch/stitch/main.nf | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/conf/modules.config b/conf/modules.config index b5066cf5..bb5a8786 100644 --- a/conf/modules.config +++ b/conf/modules.config @@ -157,6 +157,7 @@ process { ext.filter_method = params.patch_filter_method ?: null ext.iqr_multiplier = params.patch_filter_iqr_multiplier ext.z_threshold = params.patch_filter_z_threshold + ext.args = { "--min-transcripts-per-cell ${params.baysor_tiling_min_transcripts_per_cell}" } publishDir = [ path: { "${params.outdir}/${meta.id}/xenium_patch" }, mode: params.publish_dir_mode, diff --git a/modules/local/xenium_patch/stitch/main.nf b/modules/local/xenium_patch/stitch/main.nf index 3d523971..5f050e23 100644 --- a/modules/local/xenium_patch/stitch/main.nf +++ b/modules/local/xenium_patch/stitch/main.nf @@ -33,11 +33,12 @@ process XENIUM_PATCH_STITCH { task.ext.when == null || task.ext.when script: + def args = task.ext.args ?: '' """ stitch_transcripts.py \\ --patches ${patches} \\ --output output \\ - --min-transcripts-per-cell ${params.baysor_tiling_min_transcripts_per_cell} + ${args} # Post-process: ensure all GeoJSON geometries are Polygon. # make_valid() and solve_conflicts() can produce MultiPolygon, From 1db7443d7c9db983f3e6e070f193ac1a50d95f9f Mon Sep 17 00:00:00 2001 From: an-altosian Date: Thu, 30 Apr 2026 19:17:31 +0000 Subject: [PATCH 06/11] refactor(segger): extract create_dataset orchestration to module binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the three inline `python3 - <<'PYEOF'` heredoc blocks (parquet column statistics, tile-split workaround, NaN bd.x fix) plus the bundle symlink shell loop with a single module binary at resources/usr/bin/run_create_dataset.py. The .nf script block shrinks from ~190 lines to ~15. The container (quay.io/dongzehe/segger:1.0.14) stays general — pipeline-specific workarounds for upstream segger bugs now live in the module binary where they can be reviewed, tested, and removed when fixed upstream. Each WORKAROUND function in the binary has a section comment naming the upstream bug and removal condition. Pattern matches existing baysor reference modules (modules/local/baysor/{create_dataset,preprocess}/) which use resources/usr/bin/ scripts called without ${moduleDir}/ prefix. Addresses PR #139 review comment r3165415041 (segger create_dataset only; predict refactor in next commit; train kept as-is per single-line debug P2 carve-out). --- modules/local/segger/create_dataset/main.nf | 194 +------------- .../resources/usr/bin/run_create_dataset.py | 253 ++++++++++++++++++ 2 files changed, 264 insertions(+), 183 deletions(-) create mode 100755 modules/local/segger/create_dataset/resources/usr/bin/run_create_dataset.py diff --git a/modules/local/segger/create_dataset/main.nf b/modules/local/segger/create_dataset/main.nf index 06848c41..520ef344 100644 --- a/modules/local/segger/create_dataset/main.nf +++ b/modules/local/segger/create_dataset/main.nf @@ -22,7 +22,6 @@ process SEGGER_CREATE_DATASET { } def args = task.ext.args ?: '' - def script_path = "/workspace/segger_dev/src/segger/cli/create_dataset_fast.py" prefix = task.ext.prefix ?: "${meta.id}" // check for platform values @@ -31,193 +30,22 @@ process SEGGER_CREATE_DATASET { } """ - # Set numba cache directory to avoid caching issues in container export NUMBA_CACHE_DIR=\$PWD/.numba_cache mkdir -p \$NUMBA_CACHE_DIR - # Create local bundle directory with symlinks to all original files - # This is necessary because input files from S3/Fusion are read-only - # Use absolute paths to avoid broken relative symlinks - mkdir -p bundle_local - for item in ${base_dir}/*; do - # Resolve to absolute path (follow any symlinks) - abs_path=\$(readlink -f "\$item" 2>/dev/null || realpath "\$item" 2>/dev/null || echo "\$item") - basename=\$(basename "\$item") - ln -sf "\$abs_path" "bundle_local/\$basename" - done - - # Segger expects nucleus_boundaries.parquet but Xenium bundles have cell_boundaries.parquet - # Create the symlink if nucleus_boundaries doesn't exist but cell_boundaries does - if [ ! -e "bundle_local/nucleus_boundaries.parquet" ] && [ -e "bundle_local/cell_boundaries.parquet" ]; then - echo "Creating nucleus_boundaries.parquet symlink from cell_boundaries.parquet" - cell_bounds_path=\$(readlink -f "bundle_local/cell_boundaries.parquet" 2>/dev/null || realpath "bundle_local/cell_boundaries.parquet" 2>/dev/null) - ln -sf "\$cell_bounds_path" bundle_local/nucleus_boundaries.parquet - fi - - # List bundle contents for debugging - echo "Bundle contents:" - ls -la bundle_local/ - - # Fix: Add parquet column statistics for segger - echo "Adding statistics to parquet files..." - python3 - << 'PYEOF' -import pyarrow.parquet as pq -import os - -def add_stats(inp, out): - if not os.path.exists(inp): - print(f" Skip {inp}") - return - t = pq.read_table(inp) - pq.write_table(t, out, write_statistics=True, compression='snappy') - print(f" Done {os.path.basename(inp)} ({len(t)} rows)") - -os.makedirs('bundle_stats', exist_ok=True) -for f in ['transcripts.parquet', 'nucleus_boundaries.parquet']: - add_stats(f'bundle_local/{f}', f'bundle_stats/{f}') - -for item in os.listdir('bundle_local'): - s, d = f'bundle_local/{item}', f'bundle_stats/{item}' - if not os.path.exists(d): - os.symlink(os.path.realpath(s), d) -print("Done") - -# Debug: Check overlaps_nucleus column data -print("") -print("=== Debugging overlaps_nucleus data ===") -import pyarrow.compute as pc - -tx = pq.read_table('bundle_stats/transcripts.parquet') -bd = pq.read_table('bundle_stats/nucleus_boundaries.parquet') - -if 'overlaps_nucleus' in tx.column_names: - col = tx.column('overlaps_nucleus') - print(f"overlaps_nucleus dtype: {col.type}") - unique_vals = pc.unique(col) - print(f"overlaps_nucleus unique values: {unique_vals.to_pylist()[:10]}") - val_counts = pc.value_counts(col) - print(f"overlaps_nucleus value_counts: {val_counts.to_pylist()}") -else: - print("WARNING: overlaps_nucleus column NOT FOUND in transcripts.parquet") - -# Check cell_id overlap between transcripts and boundaries -if 'cell_id' in tx.column_names and 'cell_id' in bd.column_names: - tx_cells = set(pc.unique(tx.column('cell_id')).to_pylist()) - bd_cells = set(pc.unique(bd.column('cell_id')).to_pylist()) - overlap = tx_cells & bd_cells - print("") - print(f"Transcripts unique cell_ids: {len(tx_cells)}") - print(f"Boundaries unique cell_ids: {len(bd_cells)}") - print(f"Overlapping cell_ids: {len(overlap)}") - -print("=== End Debug ===") -PYEOF - ls -la bundle_stats/ - - python3 ${script_path} \\ - --base_dir bundle_stats \\ - --data_dir ${prefix} \\ - --sample_type ${params.format} \\ - --tile_width ${params.tile_width} \\ - --tile_height ${params.tile_height} \\ - --n_workers ${task.cpus} \\ + run_create_dataset.py \\ + --bundle-dir ${base_dir} \\ + --output-dir ${prefix} \\ + --sample-type ${params.format} \\ + --tile-width ${params.tile_width} \\ + --tile-height ${params.tile_height} \\ + --n-workers ${task.cpus} \\ ${args} - # Verify tiles were created and show distribution - echo "Dataset split (before fix):" - echo " train_tiles: \$(ls ${prefix}/train_tiles/processed/ 2>/dev/null | wc -l) files" - echo " val_tiles: \$(ls ${prefix}/val_tiles/processed/ 2>/dev/null | wc -l) files" - echo " test_tiles: \$(ls ${prefix}/test_tiles/processed/ 2>/dev/null | wc -l) files" - - # Workaround: segger commit 0787167 has a bug where all tiles go to test_tiles - # regardless of test_prob/val_prob settings. Move ONLY trainable tiles (those with - # edge_label_index) from test_tiles to train_tiles. - # Tiles without tx-belongs-bd edges don't have edge_label_index and cannot be used for training. - train_count=\$(ls ${prefix}/train_tiles/processed/ 2>/dev/null | wc -l) - test_count=\$(ls ${prefix}/test_tiles/processed/ 2>/dev/null | wc -l) - - if [ "\$train_count" -eq 0 ] && [ "\$test_count" -gt 0 ]; then - echo "Applying workaround: filtering trainable tiles from test_tiles (segger split bug)" - export SEGGER_PREFIX="${prefix}" - python3 - << 'PYEOF' -import torch -import os -import shutil - -prefix = os.environ['SEGGER_PREFIX'] -test_dir = f"{prefix}/test_tiles/processed" -train_dir = f"{prefix}/train_tiles/processed" - -moved = 0 -skipped = 0 - -for f in os.listdir(test_dir): - if not f.endswith('.pt'): - continue - fpath = os.path.join(test_dir, f) - try: - tile = torch.load(fpath, weights_only=False) - edge_store = tile['tx', 'belongs', 'bd'] - # Check if edge_label_index exists and has data - if hasattr(edge_store, 'edge_label_index') and edge_store.edge_label_index.numel() > 0: - shutil.move(fpath, os.path.join(train_dir, f)) - moved += 1 - else: - skipped += 1 - except Exception as e: - print(f"Warning: Could not process {f}: {e}") - skipped += 1 - -print(f"Moved {moved} trainable tiles to train_tiles") -print(f"Skipped {skipped} test-only tiles (no edge_label_index)") -PYEOF - fi - - echo "Dataset split (after fix):" - echo " train_tiles: \$(ls ${prefix}/train_tiles/processed/ 2>/dev/null | wc -l) files" - echo " val_tiles: \$(ls ${prefix}/val_tiles/processed/ 2>/dev/null | wc -l) files" - echo " test_tiles: \$(ls ${prefix}/test_tiles/processed/ 2>/dev/null | wc -l) files" - - train_tiles_dir="${prefix}/train_tiles/processed" - if [ ! -d "\$train_tiles_dir" ] || [ -z "\$(ls -A \$train_tiles_dir 2>/dev/null)" ]; then - echo "ERROR: No trainable tiles were created in \$train_tiles_dir" - echo "This usually means no transcripts overlap with nucleus boundaries in the dataset." - echo "Check if the Xenium bundle contains valid overlaps_nucleus data in transcripts.parquet." - exit 1 - fi - echo "Successfully created \$(ls \$train_tiles_dir | wc -l) trainable tiles" - - # Workaround: Segger's get_polygon_props() produces NaN boundary features (bd.x) - # when polygon geometries have zero area or index misalignment during GeoDataFrame - # construction. Replace NaN bd.x with zeros so BCEWithLogitsLoss doesn't propagate NaN. - export SEGGER_PREFIX="${prefix}" - python3 - << 'PYEOF' -import torch -import os - -prefix = os.environ['SEGGER_PREFIX'] -fixed = 0 -total = 0 - -for split in ['train_tiles', 'test_tiles', 'val_tiles']: - tile_dir = f"{prefix}/{split}/processed" - if not os.path.isdir(tile_dir): - continue - for f in os.listdir(tile_dir): - if not f.endswith('.pt'): - continue - total += 1 - fpath = os.path.join(tile_dir, f) - tile = torch.load(fpath, weights_only=False) - bd_x = tile['bd'].x - if bd_x.isnan().any(): - tile['bd'].x = torch.nan_to_num(bd_x, nan=0.0) - torch.save(tile, fpath) - fixed += 1 - -print(f"Fixed NaN bd.x in {fixed}/{total} tiles") -PYEOF - + cat <<-END_VERSIONS > versions.yml + "${task.process}": + segger: 0.1.0 + END_VERSIONS """ stub: diff --git a/modules/local/segger/create_dataset/resources/usr/bin/run_create_dataset.py b/modules/local/segger/create_dataset/resources/usr/bin/run_create_dataset.py new file mode 100755 index 00000000..c73ab006 --- /dev/null +++ b/modules/local/segger/create_dataset/resources/usr/bin/run_create_dataset.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +Run segger create_dataset with spatialxe-specific preprocessing and workarounds. + +Wraps segger's create_dataset_fast.py with: + - bundle_local symlink prep (handles read-only S3/Fusion mounts) + - parquet column statistics (segger needs these) + - WORKAROUND: filter trainable tiles from test_tiles when segger commit 0787167 mis-splits + - WORKAROUND: replace NaN bd.x with zeros after get_polygon_props produces NaN + +Each WORKAROUND should be removable when the upstream segger bug is fixed. +""" + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +# imports for actual work (used in functions below) +import pyarrow.parquet as pq +import pyarrow.compute as pc +import torch + + +SEGGER_CLI = "/workspace/segger_dev/src/segger/cli/create_dataset_fast.py" + + +def parse_args(): + p = argparse.ArgumentParser() + p.add_argument("--bundle-dir", required=True) + p.add_argument("--output-dir", required=True) + p.add_argument("--sample-type", required=True, choices=["xenium"]) + p.add_argument("--tile-width", type=int, required=True) + p.add_argument("--tile-height", type=int, required=True) + p.add_argument("--n-workers", type=int, required=True) + # remaining args forwarded to segger CLI + args, extra = p.parse_known_args() + return args, extra + + +def prepare_bundle(bundle_dir): + """Create local bundle dir with absolute symlinks (S3/Fusion read-only-safe).""" + Path("bundle_local").mkdir(exist_ok=True) + for item in Path(bundle_dir).iterdir(): + try: + abs_path = item.resolve() + except Exception: + abs_path = item + target = Path("bundle_local") / item.name + if target.exists() or target.is_symlink(): + target.unlink() + target.symlink_to(abs_path) + + # Segger expects nucleus_boundaries.parquet but Xenium bundles have cell_boundaries.parquet + nb = Path("bundle_local/nucleus_boundaries.parquet") + cb = Path("bundle_local/cell_boundaries.parquet") + if not nb.exists() and cb.exists(): + print( + "Creating nucleus_boundaries.parquet symlink from cell_boundaries.parquet" + ) + nb.symlink_to(cb.resolve()) + + print("Bundle contents:") + for item in sorted(Path("bundle_local").iterdir()): + print(f" {item.name}") + + +def add_parquet_stats(): + """Rewrite key parquet files with column statistics (segger requires them).""" + Path("bundle_stats").mkdir(exist_ok=True) + for fname in ["transcripts.parquet", "nucleus_boundaries.parquet"]: + src = Path("bundle_local") / fname + dst = Path("bundle_stats") / fname + if not src.exists(): + print(f" Skip {src}") + continue + t = pq.read_table(str(src)) + pq.write_table(t, str(dst), write_statistics=True, compression="snappy") + print(f" Done {fname} ({len(t)} rows)") + + # Symlink everything else from bundle_local into bundle_stats + for item in Path("bundle_local").iterdir(): + dst = Path("bundle_stats") / item.name + if not dst.exists(): + dst.symlink_to(item.resolve()) + + # Debug: check overlaps_nucleus column in transcripts + print("\n=== Debugging overlaps_nucleus data ===") + tx = pq.read_table("bundle_stats/transcripts.parquet") + bd = pq.read_table("bundle_stats/nucleus_boundaries.parquet") + if "overlaps_nucleus" in tx.column_names: + col = tx.column("overlaps_nucleus") + print(f"overlaps_nucleus dtype: {col.type}") + unique_vals = pc.unique(col) + print(f"overlaps_nucleus unique values: {unique_vals.to_pylist()[:10]}") + val_counts = pc.value_counts(col) + print(f"overlaps_nucleus value_counts: {val_counts.to_pylist()}") + else: + print("WARNING: overlaps_nucleus column NOT FOUND in transcripts.parquet") + + if "cell_id" in tx.column_names and "cell_id" in bd.column_names: + tx_cells = set(pc.unique(tx.column("cell_id")).to_pylist()) + bd_cells = set(pc.unique(bd.column("cell_id")).to_pylist()) + overlap = tx_cells & bd_cells + print(f"Transcripts unique cell_ids: {len(tx_cells)}") + print(f"Boundaries unique cell_ids: {len(bd_cells)}") + print(f"Overlapping cell_ids: {len(overlap)}") + print("=== End Debug ===\n") + + +def run_segger_cli(args, extra): + cmd = [ + "python3", + SEGGER_CLI, + "--base_dir", + "bundle_stats", + "--data_dir", + args.output_dir, + "--sample_type", + args.sample_type, + "--tile_width", + str(args.tile_width), + "--tile_height", + str(args.tile_height), + "--n_workers", + str(args.n_workers), + *extra, + ] + print(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd) + if result.returncode != 0: + sys.exit(result.returncode) + + +def filter_trainable_tiles_if_needed(prefix): + """ + WORKAROUND: segger commit 0787167 has a bug where all tiles end up in test_tiles + regardless of test_prob/val_prob settings. Move ONLY trainable tiles (those with + edge_label_index) from test_tiles to train_tiles. + + Remove this function once segger >= 0.1.x is bumped with the upstream fix. + """ + train_dir = Path(prefix) / "train_tiles" / "processed" + test_dir = Path(prefix) / "test_tiles" / "processed" + val_dir = Path(prefix) / "val_tiles" / "processed" + + train_count = len(list(train_dir.iterdir())) if train_dir.exists() else 0 + test_count = len(list(test_dir.iterdir())) if test_dir.exists() else 0 + val_count = len(list(val_dir.iterdir())) if val_dir.exists() else 0 + print( + f"Dataset split (before fix): train={train_count} val={val_count} test={test_count}" + ) + + if train_count == 0 and test_count > 0: + print( + "Applying workaround: filtering trainable tiles from test_tiles (segger split bug)" + ) + moved = 0 + skipped = 0 + for tile_path in list(test_dir.iterdir()): + if not tile_path.name.endswith(".pt"): + continue + try: + tile = torch.load(str(tile_path), weights_only=False) + edge_store = tile["tx", "belongs", "bd"] + if ( + hasattr(edge_store, "edge_label_index") + and edge_store.edge_label_index.numel() > 0 + ): + shutil.move(str(tile_path), str(train_dir / tile_path.name)) + moved += 1 + else: + skipped += 1 + except Exception as e: + print(f"Warning: Could not process {tile_path.name}: {e}") + skipped += 1 + print(f"Moved {moved} trainable tiles to train_tiles") + print(f"Skipped {skipped} test-only tiles (no edge_label_index)") + + train_count = len(list(train_dir.iterdir())) if train_dir.exists() else 0 + test_count = len(list(test_dir.iterdir())) if test_dir.exists() else 0 + val_count = len(list(val_dir.iterdir())) if val_dir.exists() else 0 + print( + f"Dataset split (after fix): train={train_count} val={val_count} test={test_count}" + ) + + if train_count == 0: + print(f"ERROR: No trainable tiles were created in {train_dir}", file=sys.stderr) + print( + "This usually means no transcripts overlap with nucleus boundaries in the dataset.", + file=sys.stderr, + ) + print( + "Check if the Xenium bundle contains valid overlaps_nucleus data in transcripts.parquet.", + file=sys.stderr, + ) + sys.exit(1) + print(f"Successfully created {train_count} trainable tiles") + + +def fix_bd_x_nan(prefix): + """ + WORKAROUND: segger's get_polygon_props() produces NaN boundary features (bd.x) + when polygon geometries have zero area or index misalignment during GeoDataFrame + construction. Replace NaN bd.x with zeros so BCEWithLogitsLoss doesn't propagate NaN. + + Remove this function once segger >= 0.1.x is bumped with the upstream fix. + """ + fixed = 0 + total = 0 + for split in ["train_tiles", "test_tiles", "val_tiles"]: + tile_dir = Path(prefix) / split / "processed" + if not tile_dir.is_dir(): + continue + for tile_path in tile_dir.iterdir(): + if not tile_path.name.endswith(".pt"): + continue + total += 1 + tile = torch.load(str(tile_path), weights_only=False) + bd_x = tile["bd"].x + if bd_x.isnan().any(): + tile["bd"].x = torch.nan_to_num(bd_x, nan=0.0) + torch.save(tile, str(tile_path)) + fixed += 1 + print(f"Fixed NaN bd.x in {fixed}/{total} tiles") + + +def main(): + args, extra = parse_args() + + # Ensure numba cache dir is writable (env var should be set by caller, but belt-and-suspenders) + os.environ.setdefault("NUMBA_CACHE_DIR", os.path.join(os.getcwd(), ".numba_cache")) + os.makedirs(os.environ["NUMBA_CACHE_DIR"], exist_ok=True) + + prepare_bundle(args.bundle_dir) + print("Adding statistics to parquet files...") + add_parquet_stats() + + # Sanity-check bundle_stats + print("bundle_stats contents:") + for item in sorted(Path("bundle_stats").iterdir()): + print(f" {item.name}") + + run_segger_cli(args, extra) + + filter_trainable_tiles_if_needed(args.output_dir) + fix_bd_x_nan(args.output_dir) + + +if __name__ == "__main__": + main() From 381c9e368b773b0bafe9d22b60ffd4efbb21b792 Mon Sep 17 00:00:00 2001 From: an-altosian Date: Thu, 30 Apr 2026 19:17:43 +0000 Subject: [PATCH 07/11] refactor(segger): extract predict orchestration to module binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces inline GPU enumeration (`python3 -c` for torch.cuda.device_count), predict_parquet.py path resolver, and two `sed -i` runtime patches against installed segger source with a single module binary at resources/usr/bin/run_predict.py. The .nf script block shrinks from ~50 lines to ~15. The two sed patches (torch.no_grad() for VRAM savings, deterministic GPU seed) move into a named `patch_predict_parquet()` function with a clear "remove once upstreamed to segger" comment. Container (quay.io/dongzehe/segger:1.0.14) stays general — patches live in the pipeline. Addresses PR #139 review comment r3165415041 (segger predict). --- modules/local/segger/predict/main.nf | 58 ++------ .../predict/resources/usr/bin/run_predict.py | 137 ++++++++++++++++++ 2 files changed, 151 insertions(+), 44 deletions(-) create mode 100755 modules/local/segger/predict/resources/usr/bin/run_predict.py diff --git a/modules/local/segger/predict/main.nf b/modules/local/segger/predict/main.nf index a7aec75d..3a8f58cd 100644 --- a/modules/local/segger/predict/main.nf +++ b/modules/local/segger/predict/main.nf @@ -25,53 +25,23 @@ process SEGGER_PREDICT { } def args = task.ext.args ?: '' - def script_path = "/workspace/segger_dev/src/segger/cli/predict_fast.py" prefix = task.ext.prefix ?: "${meta.id}" """ - # Limit cupy GPU memory to 80% so PyTorch has headroom for graph attention ops - export CUPY_GPU_MEMORY_LIMIT="80%" - # Belt-and-suspenders: ensure PyTorch uses expandable segments (also set in env {} block) - export PYTORCH_CUDA_ALLOC_CONF="expandable_segments:True,max_split_size_mb:512" - - # Set numba cache directory to avoid caching issues in container - export NUMBA_CACHE_DIR=\$PWD/.numba_cache - mkdir -p \$NUMBA_CACHE_DIR - - # GPU detection logging - echo "=== GPU Detection (SEGGER_PREDICT) ===" - nvidia-smi 2>/dev/null && echo "GPU available: yes" || echo "GPU available: no (nvidia-smi failed)" - python3 -c "import torch; print(f'PyTorch CUDA available: {torch.cuda.is_available()}'); print(f'CUDA device count: {torch.cuda.device_count()}')" 2>/dev/null || echo "PyTorch CUDA check failed" - echo "======================================" - - # Use all available GPUs (autocast reduces VRAM ~50%, so multi-GPU is safe) - GPU_IDS=\$(python3 -c " -import torch -n = torch.cuda.device_count() -print(','.join(str(i) for i in range(n)) if n > 0 else '0') -" 2>/dev/null || echo "0") - echo "Using GPUs: \$GPU_IDS" - - # Patch predict_parquet.py at runtime (avoids Docker rebuild) - PRED_PY=\$(python3 -c "import segger.prediction.predict_parquet as m; print(m.__file__)") - - # 1. Add torch.no_grad() to disable gradient graphs during inference (~30-50% VRAM savings) - sed -i 's/with cp.cuda.Device(gpu_id):/with cp.cuda.Device(gpu_id), torch.no_grad():/' "\$PRED_PY" - - # 2. Seed random for deterministic GPU assignment (avoids stochastic OOM) - sed -i 's/gpu_id = random.choice(gpu_ids)/random.seed(0); gpu_id = random.choice(gpu_ids)/' "\$PRED_PY" - echo "Patched \$PRED_PY: torch.no_grad() + round-robin GPU assignment" - - python3 ${script_path} \\ - --models_dir ${models_dir} \\ - --segger_data_dir ${segger_dataset} \\ - --transcripts_file ${transcripts} \\ - --benchmarks_dir benchmarks_dir \\ - --batch_size ${params.batch_size_predict} \\ - --use_cc ${params.cc_analysis} \\ - --knn_method ${params.segger_knn_method} \\ - --num_workers ${task.cpus} \\ - --gpu_ids \$GPU_IDS \\ + run_predict.py \\ + --models-dir ${models_dir} \\ + --segger-data-dir ${segger_dataset} \\ + --transcripts-file ${transcripts} \\ + --benchmarks-dir benchmarks_dir \\ + --batch-size ${params.batch_size_predict} \\ + --use-cc ${params.cc_analysis} \\ + --knn-method ${params.segger_knn_method} \\ + --num-workers ${task.cpus} \\ ${args} + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + segger: 0.1.0 + END_VERSIONS """ stub: diff --git a/modules/local/segger/predict/resources/usr/bin/run_predict.py b/modules/local/segger/predict/resources/usr/bin/run_predict.py new file mode 100755 index 00000000..56a77ffc --- /dev/null +++ b/modules/local/segger/predict/resources/usr/bin/run_predict.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Run segger predict with spatialxe-specific preprocessing. + +Wraps segger's predict_fast.py with: + - GPU enumeration (replaces inline python3 -c torch check) + - WORKAROUND: patch predict_parquet.py at runtime to add torch.no_grad() for ~30-50% VRAM savings + - WORKAROUND: seed random.choice for deterministic GPU assignment (avoids stochastic OOM) + +Both WORKAROUNDs should be removable once the patches are upstreamed to segger. +""" + +import argparse +import os +import subprocess +import sys + + +SEGGER_CLI = "/workspace/segger_dev/src/segger/cli/predict_fast.py" + + +def parse_args(): + p = argparse.ArgumentParser() + p.add_argument("--models-dir", required=True) + p.add_argument("--segger-data-dir", required=True) + p.add_argument("--transcripts-file", required=True) + p.add_argument("--benchmarks-dir", required=True) + p.add_argument("--batch-size", type=int, required=True) + p.add_argument("--use-cc", required=True) + p.add_argument("--knn-method", required=True) + p.add_argument("--num-workers", type=int, required=True) + args, extra = p.parse_known_args() + return args, extra + + +def detect_gpus(): + """Return comma-separated list of available CUDA device ids (or "0" if none).""" + import torch + + print("=== GPU Detection (SEGGER_PREDICT) ===") + print(f"PyTorch CUDA available: {torch.cuda.is_available()}") + n = torch.cuda.device_count() + print(f"CUDA device count: {n}") + print("======================================") + if n > 0: + return ",".join(str(i) for i in range(n)) + return "0" + + +def patch_predict_parquet(): + """ + WORKAROUND: patch segger.prediction.predict_parquet at runtime. + + Avoids rebuilding the segger Docker image. Two patches: + 1. Add torch.no_grad() to disable gradient graphs during inference (~30-50% VRAM savings). + 2. Seed random for deterministic GPU assignment (avoids stochastic OOM). + + Remove this function once the patches are upstreamed to segger. + """ + import segger.prediction.predict_parquet as m + + pred_py = m.__file__ + print(f"Patching {pred_py}: torch.no_grad() + round-robin GPU assignment") + # Use sed via subprocess for in-place edit (matches the original behavior exactly) + subprocess.run( + [ + "sed", + "-i", + "s/with cp.cuda.Device(gpu_id):/with cp.cuda.Device(gpu_id), torch.no_grad():/", + pred_py, + ], + check=True, + ) + subprocess.run( + [ + "sed", + "-i", + "s/gpu_id = random.choice(gpu_ids)/random.seed(0); gpu_id = random.choice(gpu_ids)/", + pred_py, + ], + check=True, + ) + + +def run_segger_cli(args, extra, gpu_ids): + cmd = [ + "python3", + SEGGER_CLI, + "--models_dir", + args.models_dir, + "--segger_data_dir", + args.segger_data_dir, + "--transcripts_file", + args.transcripts_file, + "--benchmarks_dir", + args.benchmarks_dir, + "--batch_size", + str(args.batch_size), + "--use_cc", + str(args.use_cc), + "--knn_method", + args.knn_method, + "--num_workers", + str(args.num_workers), + "--gpu_ids", + gpu_ids, + *extra, + ] + print(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd) + if result.returncode != 0: + sys.exit(result.returncode) + + +def main(): + args, extra = parse_args() + + # Limit cupy GPU memory to 80% so PyTorch has headroom for graph attention ops + os.environ.setdefault("CUPY_GPU_MEMORY_LIMIT", "80%") + # Belt-and-suspenders: ensure PyTorch uses expandable segments + os.environ.setdefault( + "PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True,max_split_size_mb:512" + ) + # Numba cache directory + os.environ.setdefault("NUMBA_CACHE_DIR", os.path.join(os.getcwd(), ".numba_cache")) + os.makedirs(os.environ["NUMBA_CACHE_DIR"], exist_ok=True) + + gpu_ids = detect_gpus() + print(f"Using GPUs: {gpu_ids}") + + patch_predict_parquet() + + run_segger_cli(args, extra, gpu_ids) + + +if __name__ == "__main__": + main() From 7df0c497e5a38abd64e62c742b76f08dc4b6c9f8 Mon Sep 17 00:00:00 2001 From: an-altosian Date: Thu, 30 Apr 2026 20:12:38 +0000 Subject: [PATCH 08/11] refactor(workflows/spatialxe): extract 23 params.* references to take: inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the project rule that params.* should only be read in the entry workflow (main.nf), the SPATIALXE named workflow now declares formal `take:` inputs for all 23 unique params.* references that were inline in the workflow body. The 23 unique params (50 total reference sites in the workflow body): baysor_prior, baysor_tiling, buffer_samples, buffer_size, cellpose_model, features, gene_panel, gene_synonyms, method, mode, multiqc_config, multiqc_logo, multiqc_methods_description, offtarget_probe_tracking, outdir, probes_fasta, qupath_polygons, reference_annotations, relabel_genes, run_qc, segmentation_mask, tiling, xeniumranger_only Addresses PR #139 review comment r3165543921 thoroughly — full SPATIALXE-level sweep instead of just the cited buffer_samples / buffer_size, matching the project preference for clean workflow interfaces. Subworkflow-level params.* extractions (~82 references across local subworkflows) are out of scope and tracked separately. --- main.nf | 25 ++++++++- workflows/spatialxe.nf | 125 ++++++++++++++++++++++++----------------- 2 files changed, 98 insertions(+), 52 deletions(-) diff --git a/main.nf b/main.nf index 8b5c6d59..00d36efc 100644 --- a/main.nf +++ b/main.nf @@ -39,7 +39,30 @@ workflow NFCORE_SPATIALXE { // WORKFLOW: Run pipeline // SPATIALXE ( - samplesheet + samplesheet, + params.baysor_prior, + params.baysor_tiling, + params.buffer_samples, + params.buffer_size, + params.cellpose_model, + params.features, + params.gene_panel, + params.gene_synonyms, + params.method, + params.mode, + params.multiqc_config, + params.multiqc_logo, + params.multiqc_methods_description, + params.offtarget_probe_tracking, + params.outdir, + params.probes_fasta, + params.qupath_polygons, + params.reference_annotations, + params.relabel_genes, + params.run_qc, + params.segmentation_mask, + params.tiling, + params.xeniumranger_only, ) emit: multiqc_report = SPATIALXE.out.multiqc_report // channel: /path/to/multiqc_report.html diff --git a/workflows/spatialxe.nf b/workflows/spatialxe.nf index 71047930..1c2c0101 100644 --- a/workflows/spatialxe.nf +++ b/workflows/spatialxe.nf @@ -54,7 +54,30 @@ include { OPT_FLIP_TRACK_STAT } from '../subworkflo workflow SPATIALXE { take: - ch_samplesheet // channel: samplesheet read in from --input + ch_samplesheet // channel: samplesheet read in from --input + baysor_prior + baysor_tiling + buffer_samples + buffer_size + cellpose_model + features + gene_panel + gene_synonyms + method + mode + multiqc_config + multiqc_logo + multiqc_methods_description + offtarget_probe_tracking + outdir + probes_fasta + qupath_polygons + reference_annotations + relabel_genes + run_qc + segmentation_mask + tiling + xeniumranger_only main: @@ -120,8 +143,8 @@ workflow SPATIALXE { // for all other profile runs // check if samples are buffered - if (params.buffer_samples) { - ch_input = ch_samplesheet.buffer(size: params.buffer_size).map + if (buffer_samples) { + ch_input = ch_samplesheet.buffer(size: buffer_size).map { buffered_sample -> def (meta, bundle, tif) = buffered_sample[0] tuple(meta, bundle, tif) @@ -197,69 +220,69 @@ workflow SPATIALXE { .flatten() // get segmentation mask if provided with --segmentation_mask for the baysor method - if (params.segmentation_mask) { + if (segmentation_mask) { ch_segmentation_mask = channel.fromPath( - params.segmentation_mask, + segmentation_mask, checkIfExists: true ) .flatten() } // get a list of features if provided with the --features for the ficture method - ch_features = params.features - ? channel.fromPath(params.features, checkIfExists: true).flatten() + ch_features = features + ? channel.fromPath(features, checkIfExists: true).flatten() : channel.value([]) // get custom cellpose model if provided with the --cellpose_model for the cellpose method - if (params.cellpose_model) { + if (cellpose_model) { ch_cellpose_model = channel.fromPath( - params.cellpose_model, + cellpose_model, checkIfExists: true ) .flatten() } // get panel probes fasta for off-target-probe tracking - if (params.probes_fasta) { + if (probes_fasta) { ch_panel_probes_fasta = channel.fromPath( - params.probes_fasta, + probes_fasta, checkIfExists: true ) .flatten() } // get reference annotation files (gff,fa) for off-target-probe tracking - if (params.reference_annotations) { + if (reference_annotations) { ch_reference_annotations = channel.fromPath( - "${params.reference_annotations}/*.{fa,gff}".toString(), + "${reference_annotations}/*.{fa,gff}".toString(), checkIfExists: true ) .flatten() } // get gene synonyms for off-target-probe tracking - if (params.gene_synonyms) { + if (gene_synonyms) { ch_gene_synonyms = channel.fromPath( - params.gene_synonyms, + gene_synonyms, checkIfExists: true ) .flatten() } // get qupath ploygons - if (params.qupath_polygons) { + if (qupath_polygons) { ch_qupath_polygons = channel.fromPath( - "${params.qupath_polygons}/*.geojson", + "${qupath_polygons}/*.geojson", checkIfExists: true ) .flatten() } // get gene_panel.json if provided with --gene_panel, sets relabel_genes to true - def do_relabel = params.gene_panel ? true : params.relabel_genes - if (params.gene_panel) { + def do_relabel = gene_panel ? true : relabel_genes + if (gene_panel) { - def gene_panel_file = file(params.gene_panel, checkIfExists: true) + def gene_panel_file = file(gene_panel, checkIfExists: true) ch_gene_panel = ch_input.map { meta, _bundle, _image -> return [meta, gene_panel_file] } @@ -301,7 +324,7 @@ workflow SPATIALXE { ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ // run baysor preview if `generate_preview ` is true - if (params.mode == 'preview') { + if (mode == 'preview') { BAYSOR_GENERATE_PREVIEW( ch_transcripts_file, @@ -316,7 +339,7 @@ workflow SPATIALXE { ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ // run only xeniumranger import segmentation with changes xr specific params - if (params.mode == 'image' && params.xeniumranger_only) { + if (mode == 'image' && xeniumranger_only) { XENIUMRANGER_IMPORT_SEGMENTATION_REDEFINE_BUNDLE( ch_bundle_path @@ -331,10 +354,10 @@ workflow SPATIALXE { SPATIALXE - IMAGE-BASED SEGMENTATION LAYER ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ - if (params.mode == 'image') { + if (mode == 'image') { // trigger the default image-based workflow if no method is specified - if (!params.method) { + if (!method) { CELLPOSE_BAYSOR_IMPORT_SEGMENTATION( ch_morphology_image, @@ -348,7 +371,7 @@ workflow SPATIALXE { } // run xeniumranger resegment with morphology_ome.tif - if (params.method == 'xeniumranger') { + if (method == 'xeniumranger') { XENIUMRANGER_RESEGMENT_MORPHOLOGY_OME_TIF( ch_bundle_path @@ -358,9 +381,9 @@ workflow SPATIALXE { } // run baysor run with morphology_ome.tif - if (params.method == 'baysor') { + if (method == 'baysor') { - if (params.segmentation_mask) { + if (segmentation_mask) { BAYSOR_RUN_PRIOR_SEGMENTATION_MASK( ch_bundle_path, ch_transcripts_file, @@ -373,7 +396,7 @@ workflow SPATIALXE { } // run cellpose on the morphology_ome.tif - if (params.method == 'cellpose') { + if (method == 'cellpose') { CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF( ch_morphology_image, @@ -384,7 +407,7 @@ workflow SPATIALXE { } // run stardist on the morphology_ome.tif - if (params.method == 'stardist') { + if (method == 'stardist') { STARDIST_RESOLIFT_MORPHOLOGY_OME_TIF( ch_morphology_image, @@ -400,12 +423,12 @@ workflow SPATIALXE { SPATIALXE - TRANSCRIPT-BASED SEGMENTATION LAYER ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ - if (params.mode == 'coordinate') { + if (mode == 'coordinate') { // run proseg with transcripts.parquet if method = proseg or is not provided (default workflow) - if (!params.method || params.method == 'proseg') { + if (!method || method == 'proseg') { - if (params.tiling) { + if (tiling) { PROSEG_PRESET_PROSEG2BAYSOR_TILED( ch_bundle_path, ch_transcripts_file, @@ -423,7 +446,7 @@ workflow SPATIALXE { } // run segger with transcripts.parquet - if (params.method == 'segger') { + if (method == 'segger') { SEGGER_CREATE_TRAIN_PREDICT( ch_bundle_path, @@ -434,10 +457,10 @@ workflow SPATIALXE { } // run baysor with transcripts.parquet (unified tiled/non-tiled subworkflow) - if (params.method == 'baysor') { + if (method == 'baysor') { // Image-based prior (cellpose mask) requires non-tiled Baysor - if ( params.baysor_tiling && params.baysor_prior == 'cellpose' ) { + if ( baysor_tiling && baysor_prior == 'cellpose' ) { error "ERROR: baysor_prior='cellpose' (image-based) requires baysor_tiling=false. " + "For tiled Baysor, use baysor_prior='cells' (column-based)." } @@ -465,7 +488,7 @@ workflow SPATIALXE { */ // run spatialdata modules to generate sd objects in image or coordinate mode - if (params.mode == 'image' || params.mode == 'coordinate') { + if (mode == 'image' || mode == 'coordinate') { SPATIALDATA_WRITE_META_MERGE( ch_bundle_path, @@ -481,9 +504,9 @@ workflow SPATIALXE { */ // check to run the qc layer - if (params.mode == 'qc' || params.run_qc) { + if (mode == 'qc' || run_qc) { - if (params.offtarget_probe_tracking) { + if (offtarget_probe_tracking) { // run off-target probe tracking OPT_FLIP_TRACK_STAT( @@ -500,10 +523,10 @@ workflow SPATIALXE { SPATIALXE - SEGMENTATION-FREE LAYER ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ - if (params.mode == 'segfree') { + if (mode == 'segfree') { // trigger the default segfree workflow if no method or if the method is baysor - if (!params.method || params.method == 'baysor') { + if (!method || method == 'baysor') { BAYSOR_GENERATE_SEGFREE( ch_transcripts_file, @@ -512,7 +535,7 @@ workflow SPATIALXE { } // run ficture with transcripts.parquet - if (params.method == 'ficture') { + if (method == 'ficture') { FICTURE_PREPROCESS_MODEL( ch_transcripts_file, @@ -536,7 +559,7 @@ workflow SPATIALXE { softwareVersionsToYAML(ch_versions.mix(ch_topic_versions)) .collectFile( - storeDir: "${params.outdir}/pipeline_info", + storeDir: "${outdir}/pipeline_info", name: 'nf_core_' + 'spatialxe_software_' + 'mqc_' + 'versions.yml', sort: true, newLine: true, @@ -553,12 +576,12 @@ workflow SPATIALXE { checkIfExists: true ) - ch_multiqc_custom_config = params.multiqc_config - ? channel.fromPath(params.multiqc_config, checkIfExists: true) + ch_multiqc_custom_config = multiqc_config + ? channel.fromPath(multiqc_config, checkIfExists: true) : channel.empty() - ch_multiqc_logo = params.multiqc_logo - ? channel.fromPath(params.multiqc_logo, checkIfExists: true) + ch_multiqc_logo = multiqc_logo + ? channel.fromPath(multiqc_logo, checkIfExists: true) : channel.empty() // Combine default and custom configs into a single list for the tuple-based MULTIQC input @@ -575,8 +598,8 @@ workflow SPATIALXE { ch_workflow_summary.collectFile(name: 'workflow_summary_mqc.yaml') ) - ch_multiqc_custom_methods_description = params.multiqc_methods_description - ? file(params.multiqc_methods_description, checkIfExists: true) + ch_multiqc_custom_methods_description = multiqc_methods_description + ? file(multiqc_methods_description, checkIfExists: true) : file("${projectDir}/assets/methods_description_template.yml", checkIfExists: true) ch_methods_description = channel.value( @@ -592,7 +615,7 @@ workflow SPATIALXE { ) ) - if (params.mode == 'image' || params.mode == 'coordinate') { + if (mode == 'image' || mode == 'coordinate') { // get path to the raw bundle ch_multiqc_files = ch_multiqc_files.mix( @@ -633,7 +656,7 @@ workflow SPATIALXE { // get the qc htmls if qc mode is run - if (params.mode == 'qc' || params.run_qc) { + if (mode == 'qc' || run_qc) { ch_multiqc_files = ch_multiqc_files.mix( ch_qc_reports.map { _meta, qc_reports -> qc_reports }.collect().ifEmpty([]) @@ -643,7 +666,7 @@ workflow SPATIALXE { // get the preview html if preview mode is run - if (params.mode == 'preview') { + if (mode == 'preview') { ch_multiqc_files = ch_multiqc_files.mix( ch_preview_html.map { _meta, preview_html -> preview_html }.collect().ifEmpty([]) From 6acfc3c545f638e1f242eef6ec9b9163d487e8b8 Mon Sep 17 00:00:00 2001 From: an-altosian Date: Thu, 30 Apr 2026 20:13:56 +0000 Subject: [PATCH 09/11] =?UTF-8?q?revert(schema):=20drop=20format=20validat?= =?UTF-8?q?ors=20on=20bundle/image=20=E2=80=94=20incompatible=20with=20tar?= =?UTF-8?q?.gz=20test=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The format: directory-path / file-path validators added in d59fac3 break CI because: - bundle field accepts EITHER a directory path (real Xenium bundle) OR a .tar.gz archive URL (test data, extracted at runtime by UNTAR). Neither 'directory-path' nor 'file-path' fits both, and both fail against remote URLs. - image field is similar — can be a local OME-TIFF path, a URL, or empty. The reviewer's suggestion in r3165291219 / r3165292878 was reasonable in isolation but conflicts with this pipeline's dual-purpose input semantics where the same field can be a path or a URL or an archive. Reverts the format keys; keeps the type / pattern / errorMessage entries. --- assets/schema_input.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/assets/schema_input.json b/assets/schema_input.json index 5561c0f1..2dfb357a 100644 --- a/assets/schema_input.json +++ b/assets/schema_input.json @@ -15,13 +15,11 @@ }, "bundle": { "type": "string", - "format": "directory-path", "pattern": "^\\S+$", "errorMessage": "Please provide a bundle as input data" }, "image": { "type": "string", - "format": "file-path", "pattern": "^\\S+$", "errorMessage": "You can provide an image. If you do not then please leave the field empty." } From df013fb7d14c6a9681be7b73ed7a23223b5d3c70 Mon Sep 17 00:00:00 2001 From: an-altosian Date: Thu, 30 Apr 2026 21:18:09 +0000 Subject: [PATCH 10/11] refactor(subworkflows): extract params.* references to take: inputs across 13 local subworkflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends PR #150's SPATIALXE-level params extraction down to all local subworkflows. After this commit, params.* is read only in the entry workflow (main.nf). workflows/spatialxe.nf and all subworkflows/local/*/main.nf declare formal take: blocks; named workflows and helper functions accept their inputs explicitly. 13 subworkflows refactored: utils_nfcore_spatialxe_pipeline (16 sites — validateInputParameters refactored to take 13 explicit args; PIPELINE_INITIALISATION take: block extended; main.nf passes them through) cellpose_baysor_import_segmentation (13) cellpose_resolift_morphology_ome_tif (9) xeniumranger_import_segmentation_redefine_bundle (8) baysor_run_transcripts_parquet (8) baysor_run_prior_segmentation_mask (6) baysor_generate_segfree (6) spatialdata_write_meta_merge (5) baysor_run_transcripts_parquet_tiled (5) stardist_resolift_morphology_ome_tif (2) segger_create_train_predict (2) xeniumranger_resegment_morphology_ome_tif (1) ficture_preprocess_model (1) SPATIALXE workflow's take: block extended from 24 to 40 inputs to cover all params consumed by the subworkflows it calls (alphabetical order). PIPELINE_INITIALISATION's take: block extended from 9 to 21 inputs to plumb validation params from main.nf. main.nf updated to pass the new params from the entry workflow. Closes the params-in-workflow anti-pattern across the whole pipeline. Verified with `nextflow inspect` (parses cleanly) and `nextflow run -profile test -stub-run` (pipeline assembles, parameter validation succeeds). --- main.nf | 30 +++++- .../local/baysor_generate_segfree/main.nf | 17 ++-- .../main.nf | 17 ++-- .../baysor_run_transcripts_parquet/main.nf | 25 +++-- .../main.nf | 15 ++- .../main.nf | 40 +++++--- .../main.nf | 31 +++--- .../local/ficture_preprocess_model/main.nf | 3 +- .../local/segger_create_train_predict/main.nf | 5 +- .../spatialdata_write_meta_merge/main.nf | 19 ++-- .../main.nf | 12 ++- .../utils_nfcore_spatialxe_pipeline/main.nf | 94 +++++++++++++------ .../main.nf | 22 +++-- .../main.nf | 5 +- workflows/spatialxe.nf | 66 ++++++++++++- 15 files changed, 291 insertions(+), 110 deletions(-) diff --git a/main.nf b/main.nf index 00d36efc..14193a1c 100644 --- a/main.nf +++ b/main.nf @@ -40,19 +40,32 @@ workflow NFCORE_SPATIALXE { // SPATIALXE ( samplesheet, + params.alignment_csv, + params.baysor_config, params.baysor_prior, + params.baysor_scale, params.baysor_tiling, + params.baysor_tiling_scale, params.buffer_samples, params.buffer_size, + params.cell_segmentation_only, + params.cellpose_downscale, params.cellpose_model, + params.expansion_distance, params.features, params.gene_panel, params.gene_synonyms, + params.max_x, + params.max_y, params.method, + params.min_qv, + params.min_x, + params.min_y, params.mode, params.multiqc_config, params.multiqc_logo, params.multiqc_methods_description, + params.nucleus_segmentation_only, params.offtarget_probe_tracking, params.outdir, params.probes_fasta, @@ -60,7 +73,10 @@ workflow NFCORE_SPATIALXE { params.reference_annotations, params.relabel_genes, params.run_qc, + params.segger_model, params.segmentation_mask, + params.sharpen_tiff, + params.stardist_nuclei_model, params.tiling, params.xeniumranger_only, ) @@ -88,7 +104,19 @@ workflow { params.input, params.help, params.help_full, - params.show_hidden + params.show_hidden, + params.gene_panel, + params.gene_synonyms, + params.image_seg_methods, + params.method, + params.mode, + params.nucleus_segmentation_only, + params.offtarget_probe_tracking, + params.probes_fasta, + params.reference_annotations, + params.relabel_genes, + params.segmentation_mask, + params.transcript_seg_methods, ) // diff --git a/subworkflows/local/baysor_generate_segfree/main.nf b/subworkflows/local/baysor_generate_segfree/main.nf index 52e2e699..74a160f0 100644 --- a/subworkflows/local/baysor_generate_segfree/main.nf +++ b/subworkflows/local/baysor_generate_segfree/main.nf @@ -10,6 +10,11 @@ workflow BAYSOR_GENERATE_SEGFREE { take: ch_transcripts_file // channel: [ val(meta), ["transcripts.parquet"] ] ch_config // channel: [ ["path-to-xenium.toml"] ] + max_x // value: spatial filter upper x bound + max_y // value: spatial filter upper y bound + min_qv // value: minimum transcript QV + min_x // value: spatial filter lower x bound + min_y // value: spatial filter lower y bound main: @@ -17,14 +22,14 @@ workflow BAYSOR_GENERATE_SEGFREE { // Always preprocess transcripts.parquet to CSV for Baysor 0.7.1 compatibility. // Baysor's Julia Parquet.jl cannot read zstd-compressed parquet files from Xenium bundles. - // Also applies optional spatial/QV filtering when params.filter_transcripts is true. + // Also applies optional spatial/QV filtering when filter_transcripts is true. BAYSOR_PREPROCESS_TRANSCRIPTS( ch_transcripts_file, - params.min_qv, - params.max_x, - params.min_x, - params.max_y, - params.min_y, + min_qv, + max_x, + min_x, + max_y, + min_y, ) ch_transcripts = BAYSOR_PREPROCESS_TRANSCRIPTS.out.transcripts_file diff --git a/subworkflows/local/baysor_run_prior_segmentation_mask/main.nf b/subworkflows/local/baysor_run_prior_segmentation_mask/main.nf index 17025571..d5acc0a1 100644 --- a/subworkflows/local/baysor_run_prior_segmentation_mask/main.nf +++ b/subworkflows/local/baysor_run_prior_segmentation_mask/main.nf @@ -13,6 +13,11 @@ workflow BAYSOR_RUN_PRIOR_SEGMENTATION_MASK { ch_transcripts_file // channel: [ val(meta), ["path-to-transcripts.parquet"] ] ch_segmentation_mask // channel: [ ["path-to-prior-segmentation-mask"] ] ch_config // channel: [ "path-to-xenium.toml" ] + max_x // value: spatial filter upper x bound + max_y // value: spatial filter upper y bound + min_qv // value: minimum transcript QV + min_x // value: spatial filter lower x bound + min_y // value: spatial filter lower y bound main: @@ -23,14 +28,14 @@ workflow BAYSOR_RUN_PRIOR_SEGMENTATION_MASK { // Always preprocess transcripts.parquet to CSV for Baysor 0.7.1 compatibility. // Baysor's Julia Parquet.jl cannot read zstd-compressed parquet files from Xenium bundles. - // Also applies optional spatial/QV filtering when params.filter_transcripts is true. + // Also applies optional spatial/QV filtering when filter_transcripts is true. BAYSOR_PREPROCESS_TRANSCRIPTS( ch_transcripts_file, - params.min_qv, - params.max_x, - params.min_x, - params.max_y, - params.min_y, + min_qv, + max_x, + min_x, + max_y, + min_y, ) ch_transcripts = BAYSOR_PREPROCESS_TRANSCRIPTS.out.transcripts_file diff --git a/subworkflows/local/baysor_run_transcripts_parquet/main.nf b/subworkflows/local/baysor_run_transcripts_parquet/main.nf index 26f9283b..23637669 100644 --- a/subworkflows/local/baysor_run_transcripts_parquet/main.nf +++ b/subworkflows/local/baysor_run_transcripts_parquet/main.nf @@ -26,12 +26,21 @@ workflow BAYSOR_RUN_TRANSCRIPTS_PARQUET { ch_morphology_image // channel: [ val(meta), ["morphology_focus.ome.tif"] ] ch_config // channel: ["path-to-xenium.toml"] ch_prior_mask // channel: [ val(meta), ["resized_mask.tif"] ] or empty (cellpose) + baysor_config // value: path to baysor config TOML (or null) + baysor_scale // value: Baysor --scale for non-tiled runs + baysor_tiling // value: bool — enable tiling + baysor_tiling_scale // value: Baysor --scale for tiled runs + max_x // value: spatial filter upper x bound + max_y // value: spatial filter upper y bound + min_qv // value: minimum transcript QV + min_x // value: spatial filter lower x bound + min_y // value: spatial filter lower y bound main: ch_coordinate_space = channel.value("microns") - if ( params.baysor_tiling ) { + if ( baysor_tiling ) { // ── TILED PATH ────────────────────────────────────────────────── @@ -61,7 +70,7 @@ workflow BAYSOR_RUN_TRANSCRIPTS_PARQUET { // convergence producing smaller cells on tile-sized datasets. BAYSOR_RUN ( PARQUET_TO_CSV.out.csv.map { meta, transcripts -> - tuple(meta, transcripts, [], params.baysor_config ? file(params.baysor_config) : [], params.baysor_tiling_scale) + tuple(meta, transcripts, [], baysor_config ? file(baysor_config) : [], baysor_tiling_scale) } ) @@ -116,11 +125,11 @@ workflow BAYSOR_RUN_TRANSCRIPTS_PARQUET { // Preprocess: parquet → CSV with optional spatial/QV filtering BAYSOR_PREPROCESS_TRANSCRIPTS( ch_transcripts_file, - params.min_qv, - params.max_x, - params.min_x, - params.max_y, - params.min_y, + min_qv, + max_x, + min_x, + max_y, + min_y, ) // Run Baysor on full transcripts (with optional image-based prior mask) @@ -132,7 +141,7 @@ workflow BAYSOR_RUN_TRANSCRIPTS_PARQUET { ch_baysor_input = ch_csv_with_mask .combine(ch_config) .map { meta, transcripts, mask, config -> - tuple(meta, transcripts, mask, config, params.baysor_scale) + tuple(meta, transcripts, mask, config, baysor_scale) } BAYSOR_RUN(ch_baysor_input) diff --git a/subworkflows/local/baysor_run_transcripts_parquet_tiled/main.nf b/subworkflows/local/baysor_run_transcripts_parquet_tiled/main.nf index 16cb06dd..0815bbe9 100644 --- a/subworkflows/local/baysor_run_transcripts_parquet_tiled/main.nf +++ b/subworkflows/local/baysor_run_transcripts_parquet_tiled/main.nf @@ -14,6 +14,11 @@ workflow BAYSOR_RUN_TRANSCRIPTS_PARQUET_TILED { ch_bundle_path // channel: [ val(meta), ["xenium-bundle"] ] ch_transcripts_file // channel: [ val(meta), ["transcripts.parquet"] ] ch_config // channel: ["path-to-xenium.toml"] + max_x // value: spatial filter upper x bound + max_y // value: spatial filter upper y bound + min_qv // value: minimum transcript QV + min_x // value: spatial filter lower x bound + min_y // value: spatial filter lower y bound main: @@ -38,11 +43,11 @@ workflow BAYSOR_RUN_TRANSCRIPTS_PARQUET_TILED { // Baysor's Julia Parquet.jl cannot read zstd-compressed parquet files BAYSOR_PREPROCESS_TRANSCRIPTS ( ch_patches, - params.min_qv, - params.max_x, - params.min_x, - params.max_y, - params.min_y, + min_qv, + max_x, + min_x, + max_y, + min_y, ) // Step 4: Run Baysor on each patch independently diff --git a/subworkflows/local/cellpose_baysor_import_segmentation/main.nf b/subworkflows/local/cellpose_baysor_import_segmentation/main.nf index 011d9477..38bbcc74 100644 --- a/subworkflows/local/cellpose_baysor_import_segmentation/main.nf +++ b/subworkflows/local/cellpose_baysor_import_segmentation/main.nf @@ -20,6 +20,16 @@ workflow CELLPOSE_BAYSOR_IMPORT_SEGMENTATION { ch_transcripts_file // channel: [ val(meta), ["path-to-transcripts.parquet"] ] ch_experiment_metadata // channel: [ val(meta), ["path-to-experiment.xenium"] ] ch_config // channel: ["path-to-xenium.toml"] + cell_segmentation_only // value: bool + cellpose_model // value: path to cellpose model (or null) + max_x // value: spatial filter upper x bound + max_y // value: spatial filter upper y bound + min_qv // value: minimum transcript QV + min_x // value: spatial filter lower x bound + min_y // value: spatial filter lower y bound + nucleus_segmentation_only // value: bool + sharpen_tiff // value: bool + stardist_nuclei_model // value: stardist pretrained model name main: @@ -29,11 +39,11 @@ workflow CELLPOSE_BAYSOR_IMPORT_SEGMENTATION { // Use empty list when no model is provided; path input for official cellpose module - cellpose_model = params.cellpose_model ? file(params.cellpose_model) : [] - stardist_nuclei_model = params.stardist_nuclei_model ?: '2D_versatile_fluo' + cellpose_model_path = cellpose_model ? file(cellpose_model) : [] + stardist_model = stardist_nuclei_model ?: '2D_versatile_fluo' // sharpen morphology tiff if param - sharpen_tiff is true - if (params.sharpen_tiff) { + if (sharpen_tiff) { RESOLIFT(ch_morphology_image) @@ -46,17 +56,17 @@ workflow CELLPOSE_BAYSOR_IMPORT_SEGMENTATION { // run cellpose on the morphology (enhanced) tiff - if (params.cell_segmentation_only) { + if (cell_segmentation_only) { - CELLPOSE_CELLS(ch_image, cellpose_model) + CELLPOSE_CELLS(ch_image, cellpose_model_path) } - if (params.nucleus_segmentation_only) { + if (nucleus_segmentation_only) { // Extract DAPI channel, run StarDist, convert to uint32 EXTRACT_DAPI(ch_image) - STARDIST_NUCLEI(EXTRACT_DAPI.out.dapi, [stardist_nuclei_model, []]) + STARDIST_NUCLEI(EXTRACT_DAPI.out.dapi, [stardist_model, []]) CONVERT_MASK_UINT32(STARDIST_NUCLEI.out.mask) } @@ -64,20 +74,20 @@ workflow CELLPOSE_BAYSOR_IMPORT_SEGMENTATION { // Always preprocess transcripts.parquet to CSV for Baysor 0.7.1 compatibility. // Baysor's Julia Parquet.jl cannot read zstd-compressed parquet files from Xenium bundles. - // Also applies optional spatial/QV filtering when params.filter_transcripts is true. + // Also applies optional spatial/QV filtering when filter_transcripts is true. BAYSOR_PREPROCESS_TRANSCRIPTS( ch_transcripts_file, - params.min_qv, - params.max_x, - params.min_x, - params.max_y, - params.min_y, + min_qv, + max_x, + min_x, + max_y, + min_y, ) ch_transcripts = BAYSOR_PREPROCESS_TRANSCRIPTS.out.transcripts_file // run baysor with cellpose results - if (params.nucleus_segmentation_only) { + if (nucleus_segmentation_only) { // check if the size of the segmentation mask matches the max transcripts coordinate range ch_resizetif_input = ch_transcripts @@ -108,7 +118,7 @@ workflow CELLPOSE_BAYSOR_IMPORT_SEGMENTATION { } BAYSOR_RUN(ch_baysor_input) } - else if (params.cell_segmentation_only) { + else if (cell_segmentation_only) { // check if the size of the segmentation mask matches the max transcripts coordinate range ch_resizetif_input = ch_transcripts diff --git a/subworkflows/local/cellpose_resolift_morphology_ome_tif/main.nf b/subworkflows/local/cellpose_resolift_morphology_ome_tif/main.nf index bc81a5d7..6bb38ded 100644 --- a/subworkflows/local/cellpose_resolift_morphology_ome_tif/main.nf +++ b/subworkflows/local/cellpose_resolift_morphology_ome_tif/main.nf @@ -13,8 +13,13 @@ include { XENIUMRANGER_IMPORT_SEGMENTATION } from '../../../modules/nf-core/xeni workflow CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF { take: - ch_morphology_image // channel: [ val(meta), ["path-to-morphology.ome.tiff"] ] - ch_bundle_path // channel: [ val(meta), ["path-to-xenium-bundle"] ] + ch_morphology_image // channel: [ val(meta), ["path-to-morphology.ome.tiff"] ] + ch_bundle_path // channel: [ val(meta), ["path-to-xenium-bundle"] ] + cellpose_downscale // value: bool + cellpose_model // value: path to cellpose model (or null) + nucleus_segmentation_only // value: bool + sharpen_tiff // value: bool + stardist_nuclei_model // value: stardist pretrained model name main: @@ -22,11 +27,11 @@ workflow CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF { ch_coordinate_space = channel.value("pixels") // Use empty list when no model is provided; path input for official cellpose module - cellpose_model = params.cellpose_model ? file(params.cellpose_model) : [] - stardist_nuclei_model = params.stardist_nuclei_model ?: '2D_versatile_fluo' + cellpose_model_path = cellpose_model ? file(cellpose_model) : [] + stardist_model = stardist_nuclei_model ?: '2D_versatile_fluo' // sharpen morphology tiff if param - sharpen_tiff is true - if (params.sharpen_tiff) { + if (sharpen_tiff) { RESOLIFT(ch_morphology_image) @@ -39,7 +44,7 @@ workflow CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF { // Optional pre-downscale for large images to avoid cellpose OOM // Only needed when running cellpose for cells (not nucleus_segmentation_only) - if (params.cellpose_downscale && !params.nucleus_segmentation_only) { + if (cellpose_downscale && !nucleus_segmentation_only) { DOWNSCALE_MORPHOLOGY(ch_image) @@ -53,14 +58,14 @@ workflow CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF { } // run cellpose on morphology tiff (or downscaled version) - if (!params.nucleus_segmentation_only) { - CELLPOSE_CELLS(ch_cellpose_input, cellpose_model) + if (!nucleus_segmentation_only) { + CELLPOSE_CELLS(ch_cellpose_input, cellpose_model_path) } // StarDist for nuclei — extract DAPI first, then run on original resolution EXTRACT_DAPI(ch_image) - STARDIST_NUCLEI(EXTRACT_DAPI.out.dapi, [stardist_nuclei_model, []]) + STARDIST_NUCLEI(EXTRACT_DAPI.out.dapi, [stardist_model, []]) // Convert StarDist mask to uint32 for XeniumRanger compatibility CONVERT_MASK_UINT32(STARDIST_NUCLEI.out.mask) @@ -69,9 +74,9 @@ workflow CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF { // Upscale cellpose cells mask back to original resolution if downscaled // StarDist nuclei mask is already at original resolution (no upscale needed) - if (params.cellpose_downscale) { + if (cellpose_downscale) { - if (!params.nucleus_segmentation_only) { + if (!nucleus_segmentation_only) { ch_cells_for_upscale = CELLPOSE_CELLS.out.mask .combine(ch_scale_info, by: 0) UPSCALE_CELLS(ch_cells_for_upscale) @@ -80,13 +85,13 @@ workflow CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF { } else { - if (!params.nucleus_segmentation_only) { + if (!nucleus_segmentation_only) { ch_cells_mask = CELLPOSE_CELLS.out.mask } } // run import-segmentation with cellpose results - if (params.nucleus_segmentation_only) { + if (nucleus_segmentation_only) { ch_imp_seg_inputs = ch_bundle_path .combine(ch_nuclei_mask, by: 0) diff --git a/subworkflows/local/ficture_preprocess_model/main.nf b/subworkflows/local/ficture_preprocess_model/main.nf index 06d4edf2..c45713ec 100644 --- a/subworkflows/local/ficture_preprocess_model/main.nf +++ b/subworkflows/local/ficture_preprocess_model/main.nf @@ -12,6 +12,7 @@ workflow FICTURE_PREPROCESS_MODEL { take: ch_transcripts_file // channel: [ val(meta), [ "transcripts.parquet" ] ] ch_features // channel: [ ["features"] ] + features // value: path to features list (or null) main: @@ -24,7 +25,7 @@ workflow FICTURE_PREPROCESS_MODEL { FICTURE_PREPROCESS(ch_transcripts, ch_features) // run the ficture wrapper pipeline - ch_features_clean = params.features ? FICTURE_PREPROCESS.out.features : channel.value([]) + ch_features_clean = features ? FICTURE_PREPROCESS.out.features : channel.value([]) FICTURE( FICTURE_PREPROCESS.out.transcripts, FICTURE_PREPROCESS.out.coordinate_minmax, diff --git a/subworkflows/local/segger_create_train_predict/main.nf b/subworkflows/local/segger_create_train_predict/main.nf index 7e3b8b74..e2486150 100644 --- a/subworkflows/local/segger_create_train_predict/main.nf +++ b/subworkflows/local/segger_create_train_predict/main.nf @@ -12,6 +12,7 @@ workflow SEGGER_CREATE_TRAIN_PREDICT { take: ch_bundle // channel: [ val(meta), ["path-to-xenium-bundle"] ] ch_transcripts_file // channel: [ val(meta), [bundle + "/transcripts.parquet"]] + segger_model // value: path to a pre-trained segger model checkpoint (or null) main: @@ -25,9 +26,9 @@ workflow SEGGER_CREATE_TRAIN_PREDICT { // Determine model source and join all PREDICT inputs by meta. // Without meta-based join, queue channels align by emission order, // which is non-deterministic and causes cross-sample input mispairing. - if (params.segger_model) { + if (segger_model) { // Use pre-trained model - skip training - def model_path = file(params.segger_model) + def model_path = file(segger_model) ch_predict_paired = SEGGER_CREATE_DATASET.out.datasetdir .join(ch_transcripts_file) .map { meta, dataset, tx -> [meta, dataset, model_path, tx] } diff --git a/subworkflows/local/spatialdata_write_meta_merge/main.nf b/subworkflows/local/spatialdata_write_meta_merge/main.nf index 32052d85..7a407c88 100644 --- a/subworkflows/local/spatialdata_write_meta_merge/main.nf +++ b/subworkflows/local/spatialdata_write_meta_merge/main.nf @@ -9,24 +9,27 @@ include { SPATIALDATA_WRITE as SPATIALDATA_WRITE_REDEFINED_BUNDLE } from '../../ workflow SPATIALDATA_WRITE_META_MERGE { take: - ch_bundle_path // channel: [ val(meta), [ "path-to-xenium-bundle" ] ] - ch_redefined_bundle // channel: [ val(meta), [ "redefined-xenium-bundle" ] ] - ch_coordinate_space // channel: [ "pixels" or "microns" ] + ch_bundle_path // channel: [ val(meta), [ "path-to-xenium-bundle" ] ] + ch_redefined_bundle // channel: [ val(meta), [ "redefined-xenium-bundle" ] ] + ch_coordinate_space // channel: [ "pixels" or "microns" ] + cell_segmentation_only // value: bool + mode // value: pipeline mode (image/coordinate/...) + nucleus_segmentation_only // value: bool main: ch_segmented_object = channel.empty() // check segmentation - only nuclei, cells or both cells & nuclei - if (params.mode == 'image') { + if (mode == 'image') { - if (params.nucleus_segmentation_only && params.cell_segmentation_only) { + if (nucleus_segmentation_only && cell_segmentation_only) { ch_segmented_object = channel.value('cells_and_nuclei') } - else if (params.nucleus_segmentation_only) { + else if (nucleus_segmentation_only) { ch_segmented_object = channel.value('nuclei') } - else if (params.cell_segmentation_only) { + else if (cell_segmentation_only) { ch_segmented_object = channel.value('cells') } else { @@ -35,7 +38,7 @@ workflow SPATIALDATA_WRITE_META_MERGE { } // set all boundaries as false - default - if (params.mode == 'coordinate') { + if (mode == 'coordinate') { ch_segmented_object = channel.value([]) } diff --git a/subworkflows/local/stardist_resolift_morphology_ome_tif/main.nf b/subworkflows/local/stardist_resolift_morphology_ome_tif/main.nf index ad2188c2..bc255409 100644 --- a/subworkflows/local/stardist_resolift_morphology_ome_tif/main.nf +++ b/subworkflows/local/stardist_resolift_morphology_ome_tif/main.nf @@ -10,8 +10,10 @@ include { XENIUMRANGER_IMPORT_SEGMENTATION } from '../../../modules/nf-core/xeni workflow STARDIST_RESOLIFT_MORPHOLOGY_OME_TIF { take: - ch_morphology_image // channel: [ val(meta), ["path-to-morphology.ome.tiff"] ] - ch_bundle_path // channel: [ val(meta), ["path-to-xenium-bundle"] ] + ch_morphology_image // channel: [ val(meta), ["path-to-morphology.ome.tiff"] ] + ch_bundle_path // channel: [ val(meta), ["path-to-xenium-bundle"] ] + sharpen_tiff // value: bool + stardist_nuclei_model // value: stardist pretrained model name main: @@ -19,10 +21,10 @@ workflow STARDIST_RESOLIFT_MORPHOLOGY_OME_TIF { ch_coordinate_space = channel.value("pixels") // Use default model when no model is provided - stardist_nuclei_model = params.stardist_nuclei_model ?: '2D_versatile_fluo' + stardist_model = stardist_nuclei_model ?: '2D_versatile_fluo' // sharpen morphology tiff if param - sharpen_tiff is true - if (params.sharpen_tiff) { + if (sharpen_tiff) { RESOLIFT(ch_morphology_image) @@ -37,7 +39,7 @@ workflow STARDIST_RESOLIFT_MORPHOLOGY_OME_TIF { EXTRACT_DAPI(ch_image) // Run StarDist nuclei segmentation on DAPI channel - STARDIST_NUCLEI(EXTRACT_DAPI.out.dapi, [stardist_nuclei_model, []]) + STARDIST_NUCLEI(EXTRACT_DAPI.out.dapi, [stardist_model, []]) // Convert mask to uint32 for XeniumRanger compatibility CONVERT_MASK_UINT32(STARDIST_NUCLEI.out.mask) diff --git a/subworkflows/local/utils_nfcore_spatialxe_pipeline/main.nf b/subworkflows/local/utils_nfcore_spatialxe_pipeline/main.nf index b812e1c7..6439211b 100644 --- a/subworkflows/local/utils_nfcore_spatialxe_pipeline/main.nf +++ b/subworkflows/local/utils_nfcore_spatialxe_pipeline/main.nf @@ -25,15 +25,27 @@ include { UTILS_NEXTFLOW_PIPELINE } from '../../nf-core/utils_nextflow_pipeline' workflow PIPELINE_INITIALISATION { take: - version // boolean: Display version and exit - validate_params // boolean: Boolean whether to validate parameters against the schema at runtime - monochrome_logs // boolean: Do not use coloured log outputs - nextflow_cli_args // array: List of positional nextflow CLI args - outdir // string: The output directory where the results will be saved - input // string: Path to input samplesheet - help // boolean: Display help message and exit - help_full // boolean: Show the full help message - show_hidden // boolean: Show hidden parameters in the help message + version // boolean: Display version and exit + validate_params // boolean: Boolean whether to validate parameters against the schema at runtime + monochrome_logs // boolean: Do not use coloured log outputs + nextflow_cli_args // array: List of positional nextflow CLI args + outdir // string: The output directory where the results will be saved + input // string: Path to input samplesheet + help // boolean: Display help message and exit + help_full // boolean: Show the full help message + show_hidden // boolean: Show hidden parameters in the help message + gene_panel // string: path to gene panel + gene_synonyms // string: path to gene synonyms + image_seg_methods // list: valid image-mode segmentation methods + method // string: chosen segmentation method + mode // string: pipeline mode + nucleus_segmentation_only // boolean + offtarget_probe_tracking // boolean + probes_fasta // string: path to probes fasta + reference_annotations // string: path to reference annotations + relabel_genes // boolean + segmentation_mask // string: path to segmentation mask + transcript_seg_methods // list: valid coordinate-mode segmentation methods main: @@ -91,11 +103,25 @@ workflow PIPELINE_INITIALISATION { // // Custom validation for pipeline parameters // - validateInputParameters() + validateInputParameters( + input, + mode, + method, + image_seg_methods, + transcript_seg_methods, + relabel_genes, + gene_panel, + nucleus_segmentation_only, + segmentation_mask, + offtarget_probe_tracking, + probes_fasta, + reference_annotations, + gene_synonyms, + ) log.info("✅ Pipeline parameters validated.") // - // Create channel from input file provided through params.input + // Create channel from input file provided through --input // try { @@ -180,7 +206,21 @@ workflow PIPELINE_COMPLETION { // // Check and validate pipeline parameters // -def validateInputParameters() { +def validateInputParameters( + input, + mode, + method, + image_seg_methods, + transcript_seg_methods, + relabel_genes, + gene_panel, + nucleus_segmentation_only, + segmentation_mask, + offtarget_probe_tracking, + probes_fasta, + reference_annotations, + gene_synonyms +) { // check if conda profile is provided if (workflow.profile.contains('conda')) { @@ -189,52 +229,52 @@ def validateInputParameters() { } // check if the samplesheet provided with the test config is assets/samplesheet.csv - if (workflow.profile.contains('test') && !"${params.input}".endsWith("assets/samplesheet.csv")) { + if (workflow.profile.contains('test') && !"${input}".endsWith("assets/samplesheet.csv")) { log.error("❌ Error: Use the samplesheet at: ${projectDir}/assets/samplesheet.csv with `--input` when running the pipeline in test profile.") exit(1) } // check if the segmentation method provided is valid for a mode - if (params.mode == 'image' && params.method) { - if (!params.image_seg_methods.contains(params.method)) { - log.error("❌ Error: Invalid segmentation method: ${params.method} provided for the `image` based mode. Options: ${params.image_seg_methods}") + if (mode == 'image' && method) { + if (!image_seg_methods.contains(method)) { + log.error("❌ Error: Invalid segmentation method: ${method} provided for the `image` based mode. Options: ${image_seg_methods}") exit(1) } } - if (params.mode == 'coordinate' && params.method) { - if (!params.transcript_seg_methods.contains(params.method)) { - log.error("❌ Error: Invalid segmentation method: `${params.method}` provided for the `coordinate` based mode. Options: ${params.transcript_seg_methods}") + if (mode == 'coordinate' && method) { + if (!transcript_seg_methods.contains(method)) { + log.error("❌ Error: Invalid segmentation method: `${method}` provided for the `coordinate` based mode. Options: ${transcript_seg_methods}") exit(1) } } // check if --relabel_genes is true but --gene_panel is not provided - if (params.relabel_genes && !params.gene_panel) { + if (relabel_genes && !gene_panel) { log.warn("⚠️ Relabel genes is enabled, but gene panel is not provided with the `--gene_panel`. Using `gene_panel.json` in the xenium bundle.") } // check if --relabel_genes is true but --gene_panel is not provided - if (params.gene_panel && !params.relabel_genes) { + if (gene_panel && !relabel_genes) { log.warn("⚠️ Gene panel provided, but relabel genes is disabled. Using `gene_panel.json` only to generate metadata.") } // check if segmentation method is xeniumranger and nucleus_ony_segmentation is enabled - if (params.method == 'xeniumranger' && !params.nucleus_segmentation_only) { + if (method == 'xeniumranger' && !nucleus_segmentation_only) { log.warn("⚠️ Nucleus segmentation is disabled. Running xeniumranger resegment module to redefine xenium bundle without nucleus segmentation.") log.warn("⚠️ Use --nucleus_segmentation_only to enable nucleus segmentation to redefine xenium bundle with import-segmentation module.") } // check if segmentation mask is provided in image mode and baysor method - if (params.mode == 'image' && params.method == 'baysor') { - if (!params.segmentation_mask) { - log.warn("⚠️ Missing segmentation mask with `--segmentation_mask` when pipeline is run in ${params.mode} and with the ${params.method}. Running in coordinate mode.") + if (mode == 'image' && method == 'baysor') { + if (!segmentation_mask) { + log.warn("⚠️ Missing segmentation mask with `--segmentation_mask` when pipeline is run in ${mode} and with the ${method}. Running in coordinate mode.") } } // check if required arguments are provided for off-target probe tracking - if (!params.mode && params.offtarget_probe_tracking) { - if(!params.probes_fasta || !params.reference_annotations || !params.gene_synonyms) { + if (!mode && offtarget_probe_tracking) { + if(!probes_fasta || !reference_annotations || !gene_synonyms) { log.error("❌ Error: Missing required param(s) for off-target-proebe detection.") exit(1) } diff --git a/subworkflows/local/xeniumranger_import_segmentation_redefine_bundle/main.nf b/subworkflows/local/xeniumranger_import_segmentation_redefine_bundle/main.nf index e18b36ec..bc89be53 100644 --- a/subworkflows/local/xeniumranger_import_segmentation_redefine_bundle/main.nf +++ b/subworkflows/local/xeniumranger_import_segmentation_redefine_bundle/main.nf @@ -9,7 +9,11 @@ include { XENIUMRANGER_IMPORT_SEGMENTATION as IMP_SEG_TRANS_MATRIX_INPUT workflow XENIUMRANGER_IMPORT_SEGMENTATION_REDEFINE_BUNDLE { take: - ch_bundle_path // channel: [ val(meta), [ "path-to-xenium-bundle" ] ] + ch_bundle_path // channel: [ val(meta), [ "path-to-xenium-bundle" ] ] + alignment_csv // value: path to alignment csv (or null) + expansion_distance // value: nuclear expansion distance + nucleus_segmentation_only // value: bool + qupath_polygons // value: path to qupath polygons dir (or null) main: @@ -22,7 +26,7 @@ workflow XENIUMRANGER_IMPORT_SEGMENTATION_REDEFINE_BUNDLE { } // scenario - 1 change nuclear expansion distance / create a nucleus-only count matrix(--expansion_distance=0) - if (params.expansion_distance == 0 || params.expansion_distance != 5) { + if (expansion_distance == 0 || expansion_distance != 5) { ch_coordinate_space = "microns" ch_imp_seg_inputs = ch_bundle_path .combine(cells, by: 0) @@ -46,11 +50,11 @@ workflow XENIUMRANGER_IMPORT_SEGMENTATION_REDEFINE_BUNDLE { } // scenario - 2 polygon input - geojson format (from QuPath) - if (params.qupath_polygons && params.nucleus_segmentation_only) { + if (qupath_polygons && nucleus_segmentation_only) { ch_coordinate_space = "microns" ch_imp_seg_inputs = ch_bundle_path - .combine(params.qupath_polygons) + .combine(qupath_polygons) .map { meta, bundle, polygons_geojson -> tuple( meta, @@ -69,11 +73,11 @@ workflow XENIUMRANGER_IMPORT_SEGMENTATION_REDEFINE_BUNDLE { ) ch_redefined_bundle = IMP_SEG_POLYGON_GEOJSON_INPUT.out.outs } - else if (params.qupath_polygons) { + else if (qupath_polygons) { ch_coordinate_space = "microns" ch_imp_seg_inputs = ch_bundle_path - .combine(params.qupath_polygons) + .combine(qupath_polygons) .map { meta, bundle, polygons_geojson -> tuple( meta, @@ -98,11 +102,11 @@ workflow XENIUMRANGER_IMPORT_SEGMENTATION_REDEFINE_BUNDLE { // scenario 4 - transcript assignment input - included in the baysor & proseg subworkflows // scenario 5 - transformation matrix input - if (params.qupath_polygons && params.alignment_csv) { + if (qupath_polygons && alignment_csv) { ch_imp_seg_inputs = ch_bundle_path - .combine(params.qupath_polygons) - .combine(params.alignment_csv) + .combine(qupath_polygons) + .combine(alignment_csv) .map { meta, bundle, polygons_geojson, alignment_csv -> tuple( meta, diff --git a/subworkflows/local/xeniumranger_resegment_morphology_ome_tif/main.nf b/subworkflows/local/xeniumranger_resegment_morphology_ome_tif/main.nf index fce90bb6..5a186a11 100644 --- a/subworkflows/local/xeniumranger_resegment_morphology_ome_tif/main.nf +++ b/subworkflows/local/xeniumranger_resegment_morphology_ome_tif/main.nf @@ -7,7 +7,8 @@ include { XENIUMRANGER_IMPORT_SEGMENTATION } from '../../../modules/nf-core/xeni workflow XENIUMRANGER_RESEGMENT_MORPHOLOGY_OME_TIF { take: - ch_bundle_path // channel: [ val(meta), ["path-to-xenium-bundle"] ] + ch_bundle_path // channel: [ val(meta), ["path-to-xenium-bundle"] ] + nucleus_segmentation_only // value: bool main: @@ -25,7 +26,7 @@ workflow XENIUMRANGER_RESEGMENT_MORPHOLOGY_OME_TIF { } // adjust the nuclear expansion distance without altering nuclei detection - if (params.nucleus_segmentation_only) { + if (nucleus_segmentation_only) { def ch_imp_seg_inputs = ch_bundle_path .join(XENIUMRANGER_RESEGMENT.out.outs, by: 0) diff --git a/workflows/spatialxe.nf b/workflows/spatialxe.nf index 1c2c0101..a702b4cc 100644 --- a/workflows/spatialxe.nf +++ b/workflows/spatialxe.nf @@ -55,19 +55,32 @@ include { OPT_FLIP_TRACK_STAT } from '../subworkflo workflow SPATIALXE { take: ch_samplesheet // channel: samplesheet read in from --input + alignment_csv + baysor_config baysor_prior + baysor_scale baysor_tiling + baysor_tiling_scale buffer_samples buffer_size + cell_segmentation_only + cellpose_downscale cellpose_model + expansion_distance features gene_panel gene_synonyms + max_x + max_y method + min_qv + min_x + min_y mode multiqc_config multiqc_logo multiqc_methods_description + nucleus_segmentation_only offtarget_probe_tracking outdir probes_fasta @@ -75,7 +88,10 @@ workflow SPATIALXE { reference_annotations relabel_genes run_qc + segger_model segmentation_mask + sharpen_tiff + stardist_nuclei_model tiling xeniumranger_only @@ -342,7 +358,11 @@ workflow SPATIALXE { if (mode == 'image' && xeniumranger_only) { XENIUMRANGER_IMPORT_SEGMENTATION_REDEFINE_BUNDLE( - ch_bundle_path + ch_bundle_path, + alignment_csv, + expansion_distance, + nucleus_segmentation_only, + qupath_polygons, ) ch_redefined_bundle = XENIUMRANGER_IMPORT_SEGMENTATION_REDEFINE_BUNDLE.out.redefined_bundle ch_coordinate_space = XENIUMRANGER_IMPORT_SEGMENTATION_REDEFINE_BUNDLE.out.coordinate_space @@ -365,6 +385,16 @@ workflow SPATIALXE { ch_transcripts_file, ch_exp_metadata, ch_config, + cell_segmentation_only, + cellpose_model, + max_x, + max_y, + min_qv, + min_x, + min_y, + nucleus_segmentation_only, + sharpen_tiff, + stardist_nuclei_model, ) ch_redefined_bundle = CELLPOSE_BAYSOR_IMPORT_SEGMENTATION.out.redefined_bundle ch_coordinate_space = CELLPOSE_BAYSOR_IMPORT_SEGMENTATION.out.coordinate_space @@ -374,7 +404,8 @@ workflow SPATIALXE { if (method == 'xeniumranger') { XENIUMRANGER_RESEGMENT_MORPHOLOGY_OME_TIF( - ch_bundle_path + ch_bundle_path, + nucleus_segmentation_only, ) ch_redefined_bundle = XENIUMRANGER_RESEGMENT_MORPHOLOGY_OME_TIF.out.redefined_bundle ch_coordinate_space = XENIUMRANGER_RESEGMENT_MORPHOLOGY_OME_TIF.out.coordinate_space @@ -389,6 +420,11 @@ workflow SPATIALXE { ch_transcripts_file, ch_segmentation_mask, ch_config, + max_x, + max_y, + min_qv, + min_x, + min_y, ) } ch_redefined_bundle = BAYSOR_RUN_PRIOR_SEGMENTATION_MASK.out.redefined_bundle @@ -401,6 +437,11 @@ workflow SPATIALXE { CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF( ch_morphology_image, ch_bundle_path, + cellpose_downscale, + cellpose_model, + nucleus_segmentation_only, + sharpen_tiff, + stardist_nuclei_model, ) ch_redefined_bundle = CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF.out.redefined_bundle ch_coordinate_space = CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF.out.coordinate_space @@ -412,6 +453,8 @@ workflow SPATIALXE { STARDIST_RESOLIFT_MORPHOLOGY_OME_TIF( ch_morphology_image, ch_bundle_path, + sharpen_tiff, + stardist_nuclei_model, ) ch_redefined_bundle = STARDIST_RESOLIFT_MORPHOLOGY_OME_TIF.out.redefined_bundle ch_coordinate_space = STARDIST_RESOLIFT_MORPHOLOGY_OME_TIF.out.coordinate_space @@ -451,6 +494,7 @@ workflow SPATIALXE { SEGGER_CREATE_TRAIN_PREDICT( ch_bundle_path, ch_transcripts_file, + segger_model, ) ch_redefined_bundle = SEGGER_CREATE_TRAIN_PREDICT.out.redefined_bundle ch_coordinate_space = SEGGER_CREATE_TRAIN_PREDICT.out.coordinate_space @@ -473,6 +517,15 @@ workflow SPATIALXE { ch_morphology_image, ch_config, ch_prior_mask, + baysor_config, + baysor_scale, + baysor_tiling, + baysor_tiling_scale, + max_x, + max_y, + min_qv, + min_x, + min_y, ) ch_redefined_bundle = BAYSOR_RUN_TRANSCRIPTS_PARQUET.out.redefined_bundle ch_coordinate_space = BAYSOR_RUN_TRANSCRIPTS_PARQUET.out.coordinate_space @@ -494,6 +547,9 @@ workflow SPATIALXE { ch_bundle_path, ch_redefined_bundle, ch_coordinate_space, + cell_segmentation_only, + mode, + nucleus_segmentation_only, ) } @@ -531,6 +587,11 @@ workflow SPATIALXE { BAYSOR_GENERATE_SEGFREE( ch_transcripts_file, ch_config, + max_x, + max_y, + min_qv, + min_x, + min_y, ) } @@ -540,6 +601,7 @@ workflow SPATIALXE { FICTURE_PREPROCESS_MODEL( ch_transcripts_file, ch_features, + features, ) } } From 0224be03c9c409109854d3962ccfcefebeb2e4dd Mon Sep 17 00:00:00 2001 From: an-altosian Date: Thu, 30 Apr 2026 21:28:58 +0000 Subject: [PATCH 11/11] chore(segger): drop versions.yml heredocs from create_dataset and predict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heredocs were inadvertently introduced when extracting the inline Python to module binaries. Project convention (CLAUDE.md "Version Reporting") is topic-channel only — no `versions.yml` files. Topic channels (versions_segger, versions_python, etc.) emit the actual versions; the hardcoded `segger: 0.1.0` heredoc was unused and risked going stale. No other modules/local/* writes a versions.yml file. Aligns the segger modules with the rest of the pipeline. --- modules/local/segger/create_dataset/main.nf | 5 ----- modules/local/segger/predict/main.nf | 5 ----- 2 files changed, 10 deletions(-) diff --git a/modules/local/segger/create_dataset/main.nf b/modules/local/segger/create_dataset/main.nf index 520ef344..81320eff 100644 --- a/modules/local/segger/create_dataset/main.nf +++ b/modules/local/segger/create_dataset/main.nf @@ -41,11 +41,6 @@ process SEGGER_CREATE_DATASET { --tile-height ${params.tile_height} \\ --n-workers ${task.cpus} \\ ${args} - - cat <<-END_VERSIONS > versions.yml - "${task.process}": - segger: 0.1.0 - END_VERSIONS """ stub: diff --git a/modules/local/segger/predict/main.nf b/modules/local/segger/predict/main.nf index 3a8f58cd..0da7a594 100644 --- a/modules/local/segger/predict/main.nf +++ b/modules/local/segger/predict/main.nf @@ -37,11 +37,6 @@ process SEGGER_PREDICT { --knn-method ${params.segger_knn_method} \\ --num-workers ${task.cpus} \\ ${args} - - cat <<-END_VERSIONS > versions.yml - "${task.process}": - segger: 0.1.0 - END_VERSIONS """ stub: