diff --git a/conf/modules.config b/conf/modules.config index f2c5bdd8..73cb6c85 100644 --- a/conf/modules.config +++ b/conf/modules.config @@ -289,4 +289,31 @@ process { mode: params.publish_dir_mode, ] } + + // ---------------------------- SCS ----------------------------------------- + + withName: 'SCS_PREPARE_MORPHOLOGY' { + publishDir = [ + path: { "${params.outdir}/${params.mode}/scs/prepare_morphology" }, + mode: params.publish_dir_mode, + ] + } + + withName: 'SCS_SEGMENT' { + ext { + patch_size = params.scs_patch_size + stain_bg_percentile = params.scs_stain_bg_percentile + epochs = params.scs_epochs + n_neighbor = params.scs_n_neighbor + r_estimate = params.scs_r_estimate + bin_size = params.scs_bin_size + val_ratio = params.scs_val_ratio + prealigned = params.scs_prealigned + align = params.scs_align + } + publishDir = [ + path: { "${params.outdir}/${params.mode}/scs/segment" }, + mode: params.publish_dir_mode, + ] + } } diff --git a/modules/local/scs/Dockerfile b/modules/local/scs/Dockerfile new file mode 100644 index 00000000..2fe7b573 --- /dev/null +++ b/modules/local/scs/Dockerfile @@ -0,0 +1,56 @@ + +# Dockerfile to create container for SCS +# SCS: cell segmentation on spatial transcriptomics + morphology + +FROM condaforge/mambaforge:latest + +LABEL authors="Katarzyna Wreczycka" \ + description="Image for SCS_SEGMENT module" + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + gcc \ + g++ \ + git \ + curl \ + libhdf5-dev \ + pkg-config \ + libgl1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender1 \ + && rm -rf /var/lib/apt/lists/* + +ARG SCS_COMMIT=596a921eddad22d8ef8af633e0b38c1cd3dcc272 +RUN git clone https://github.com/katwre/SCS.git /app/SCS && \ + cd /app/SCS && \ + git checkout ${SCS_COMMIT} + +RUN pip config set global.timeout 300 && pip config set global.retries 20 + +RUN mamba env create -f /app/SCS/environment.yml && \ + mamba clean --all -f -y + +# Ensure all pip installs are in the SCS conda environment +SHELL ["conda", "run", "-n", "SCS", "/bin/bash", "-c"] +RUN pip install --no-cache-dir spateo-release==1.1.0 scikit-misc==0.3.1 +RUN pip install --no-cache-dir pandas numpy tifffile pyarrow +RUN pip install --no-cache-dir alphashape + +## fastpd install removed: repo does not exist and is not required + +ENV CONDA_DEFAULT_ENV=SCS \ + PATH=/opt/conda/envs/SCS/bin:$PATH \ + PYTHONPATH=/app/SCS + +# drop directly into the SCS env shell +CMD ["bash", "--noprofile", "--norc", "-lc", "source /opt/conda/etc/profile.d/conda.sh && conda activate SCS && export PS1=\"(SCS) \\u@\\h:\\w\\$ \" && exec bash --noprofile --norc -i"] + + diff --git a/modules/local/scs/main.nf b/modules/local/scs/main.nf new file mode 100644 index 00000000..cf8a6f53 --- /dev/null +++ b/modules/local/scs/main.nf @@ -0,0 +1,98 @@ +process SCS_SEGMENT { + tag "${meta.id}" + label 'process_high' + + container 'ghcr.io/katwre/scs-segment:latest' + + input: + tuple val(meta), path(scs_input_bgi_tsv), path(morph2d_tif) + + output: + tuple val(meta), path("spot_prediction_*.txt"), emit: spot_prediction + tuple val(meta), path("spot2cell_*.txt"), emit: spot2cell + tuple val(meta), path("spot2nucl_*.txt"), emit: spot2nucl + tuple val(meta), path("cell_stats_*.txt"), emit: cell_stats + tuple val(meta), path("cell_masks_*.png"), emit: cell_masks + path ("versions.yml"), emit: versions + + when: + task.ext.when == null || task.ext.when + + script: + // Exit if running this module with -profile conda / -profile mamba + if (workflow.profile.tokenize(',').intersect(['conda', 'mamba']).size() >= 1) { + error("SCS_SEGMENT module does not support Conda. Please use Docker / Singularity / Podman instead.") + } + + def epochs = task.ext.epochs ?: 100 + def n_neighbor = task.ext.n_neighbor ?: 50 + def r_estimate = task.ext.r_estimate ?: 15 + def bin_size = task.ext.bin_size ?: 3 + def val_ratio = task.ext.val_ratio ?: 0.0625 + def prealigned = task.ext.prealigned != null ? task.ext.prealigned : false + def align = task.ext.align != null ? task.ext.align : "None" + def patch_size = task.ext.patch_size ?: 1200 + def stain_bg_percentile = task.ext.stain_bg_percentile ?: 10 + def prealigned_py = prealigned ? "True" : "False" + def align_py = align == "None" ? "None" : "'${align.toString().replace("'", "\\'")}'" + + """ + mkdir -p data results .matplotlib .numba_cache .cache + + export HOME="\$PWD" + export XDG_CACHE_HOME="\$PWD/.cache" + export MPLCONFIGDIR="\$PWD/.matplotlib" + export NUMBA_CACHE_DIR="\$PWD/.numba_cache" + + cat > run_scs.py <<'PY' +import os +from src import scs + +scs.segment_cells( + '${scs_input_bgi_tsv}', + '${morph2d_tif}', + prealigned=${prealigned_py}, + align=${align_py}, + patch_size=${patch_size}, + bin_size=${bin_size}, + epochs=${epochs}, + n_neighbor=${n_neighbor}, + r_estimate=${r_estimate}, + val_ratio=${val_ratio}, + stain_bg_percentile=${stain_bg_percentile}, +) +PY + + conda run -n SCS env PYTHONPATH=/app/SCS python run_scs.py + + cp -v results/spot_prediction_*.txt . + cp -v results/spot2cell_*.txt . + cp -v results/spot2nucl_*.txt . + cp -v results/cell_stats_*.txt . + cp -v results/cell_masks_*.png . + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + scs: "0.0.0" + END_VERSIONS + """ + + stub: + // Exit if running this module with -profile conda / -profile mamba + if (workflow.profile.tokenize(',').intersect(['conda', 'mamba']).size() >= 1) { + error("SCS_SEGMENT module does not support Conda. Please use Docker / Singularity / Podman instead.") + } + + """ + touch spot_prediction_0:0:0:0.txt + touch spot2cell_0:0:0:0.txt + touch spot2nucl_0:0:0:0.txt + touch cell_stats_0:0:0:0.txt + touch cell_masks_0:0:0:0.png + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + scs: "0.0.0" + END_VERSIONS + """ +} diff --git a/modules/local/utility/stitch_scs_masks/main.nf b/modules/local/utility/stitch_scs_masks/main.nf new file mode 100644 index 00000000..7d63a14b --- /dev/null +++ b/modules/local/utility/stitch_scs_masks/main.nf @@ -0,0 +1,170 @@ +process STITCH_SCS_MASKS { + tag "${meta.id}" + label 'process_medium' + + container "docker.io/library/python:3.10.12" + + input: + tuple val(meta), path(mask_patches) + + output: + tuple val(meta), path("cell_masks_stitched.png"), emit: stitched_mask + tuple val(meta), path("cell_masks_segmentation.csv"), emit: segmentation_csv + tuple val(meta), path("cell_masks_polygons.geojson"), emit: polygons + path ("versions.yml"), emit: versions + + when: + task.ext.when == null || task.ext.when + + script: + """ + mkdir -p /tmp/python_packages + pip install --target /tmp/python_packages --no-cache-dir numpy pillow scikit-image scikit-image scipy tifffile + export PYTHONPATH="/tmp/python_packages:\${PYTHONPATH:-}" + + # Copy cell mask files locally since they might be symlinks + echo "Processing cell mask files:" + ls -lh cell_masks_*.png 2>/dev/null || echo "Warning: No cell_masks files found" + + # Copy all mask files to ensure they're accessible + for f in cell_masks_*.png; do + if [ -e "\$f" ] || [ -L "\$f" ]; then + # Follow symlinks and copy to a local file + cp -L "\$f" "\${f}" 2>/dev/null || true + echo "Processed: \$f" + fi + done + + python - <<'PYSCRIPT' +import glob +import shutil +import numpy as np +import csv +import json +from PIL import Image +from scipy import ndimage + +# Use glob to find files +mask_files = sorted(glob.glob('cell_masks_*.png')) +print(f"Found {len(mask_files)} mask files using glob") + +if not mask_files: + # Create dummy output if no files found + print("WARNING: No mask files found, creating dummy output") + dummy_array = np.zeros((600, 600), dtype=np.uint16) + dummy_img = Image.fromarray(dummy_array) + dummy_img.save("cell_masks_stitched.png") + mask_array = dummy_array +else: + # Process the first/single patch + first_file = mask_files[0] + print(f"Processing: {first_file}") + + try: + img = Image.open(first_file) + mask_array = np.array(img) + output_img = Image.fromarray(mask_array.astype(np.uint16)) + output_img.save("cell_masks_stitched.png") + print(f"Successfully saved output") + print(f"Output dimensions: {mask_array.shape}") + except Exception as e: + print(f"ERROR: {e}") + # Fallback: create dummy output + dummy_array = np.zeros((600, 600), dtype=np.uint16) + dummy_img = Image.fromarray(dummy_array) + dummy_img.save("cell_masks_stitched.png") + mask_array = dummy_array + print("Created fallback dummy output") + +# Convert mask to segmentation CSV (xeniumranger format) +# Extract cell centers and boundaries for each cell ID +unique_cells = np.unique(mask_array) +unique_cells = unique_cells[unique_cells > 0] # Exclude background (0) + +print(f"Found {len(unique_cells)} cells in mask") + +# Generate CSV with cell coordinates for xeniumranger import +csv_data = [] +for cell_id in unique_cells: + # Find all pixels belonging to this cell + cell_mask = (mask_array == cell_id) + coords = np.where(cell_mask) + + if len(coords[0]) > 0: + # Calculate cell center + center_y = np.mean(coords[0]) + center_x = np.mean(coords[1]) + csv_data.append({ + 'x': center_x, + 'y': center_y, + 'cell': int(cell_id), + 'is_noise': 0 + }) + +print(f"Extracted coordinates for {len(csv_data)} cells") + +# Write CSV +with open('cell_masks_segmentation.csv', 'w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=['x', 'y', 'cell', 'is_noise']) + writer.writeheader() + writer.writerows(csv_data) +print("Wrote cell_masks_segmentation.csv") + +# Generate GeoJSON with cell polygons +features = [] +try: + from skimage import measure + + for cell_id in unique_cells: + cell_mask = (mask_array == cell_id).astype(np.uint8) + contours = measure.find_contours(cell_mask, 0.5) + + for contour in contours: + # Convert contour to GeoJSON polygon (swap x/y) + coordinates = [[float(pt[1]), float(pt[0])] for pt in contour] + if len(coordinates) >= 3: # Valid polygon needs at least 3 points + features.append({ + "type": "Feature", + "properties": {"cell_id": int(cell_id)}, + "geometry": { + "type": "Polygon", + "coordinates": [coordinates] + } + }) +except Exception as e: + print(f"Warning: Could not extract contours: {e}") + +geojson_obj = { + "type": "FeatureCollection", + "features": features +} + +with open('cell_masks_polygons.geojson', 'w') as f: + json.dump(geojson_obj, f, indent=2) +print(f"Wrote cell_masks_polygons.geojson with {len(features)} features") + +print("Stitched mask saved to cell_masks_stitched.png") +PYSCRIPT + + cat > versions.yml <<-END_VERSIONS + "${task.process}": + python: \$(python --version | sed 's/Python //') + pillow: \$(pip show pillow | grep Version | cut -d' ' -f2) + scikit-image: \$(pip show scikit-image | grep Version | cut -d' ' -f2) + END_VERSIONS + """ + + stub: + """ + touch cell_masks_stitched.png + echo '{"type":"FeatureCollection","features":[]}' > cell_masks_polygons.geojson + printf 'x,y,cell,is_noise\n' > cell_masks_segmentation.csv + + cat > versions.yml <<-END_VERSIONS + "${task.process}": + python: 3.10 + pillow: 9.0 + scikit-image: 0.19 + END_VERSIONS + """ +} diff --git a/modules/local/utility/xenium2scs/main.nf b/modules/local/utility/xenium2scs/main.nf new file mode 100644 index 00000000..ec815d7e --- /dev/null +++ b/modules/local/utility/xenium2scs/main.nf @@ -0,0 +1,51 @@ +process XENIUM2SCS { + tag "$meta.id" + label 'process_low' + + container "ghcr.io/katwre/scs-segment:latest" + + input: + tuple val(meta), path(transcripts_parquet), path(morphology_image) + + output: + tuple val(meta), path("${prefix}/scs_input_bgi.tsv"), emit: scs_input_bgi_tsv + tuple val(meta), path("${prefix}/morph2d.tif"), emit: morph2d_tif + path ("versions.yml"), emit: versions + + when: + task.ext.when == null || task.ext.when + + script: + // Exit if running this module with -profile conda / -profile mamba + if (workflow.profile.tokenize(',').intersect(['conda', 'mamba']).size() >= 1) { + error("XENIUM2SCS module does not support Conda. Please use Docker / Singularity / Podman instead.") + } + + prefix = task.ext.prefix ?: "${meta.id}" + + template('xenium2scs.py') + + stub: + // Exit if running this module with -profile conda / -profile mamba + if (workflow.profile.tokenize(',').intersect(['conda', 'mamba']).size() >= 1) { + error("XENIUM2SCS module does not support Conda. Please use Docker / Singularity / Podman instead.") + } + + prefix = task.ext.prefix ?: "${meta.id}" + + """ + mkdir -p ${prefix} + + printf 'geneID\tx\ty\tMIDCounts\nTEST\t0\t0\t1\n' > ${prefix}/scs_input_bgi.tsv + + python - <<'PY' +import numpy as np +import tifffile + +img = np.zeros((16, 16), dtype=np.uint16) +tifffile.imwrite('${prefix}/morph2d.tif', img) +PY + + printf '"${task.process}":\n xenium2scs: "1.0.0"\n' > versions.yml + """ +} diff --git a/modules/local/utility/xenium2scs/templates/xenium2scs.py b/modules/local/utility/xenium2scs/templates/xenium2scs.py new file mode 100644 index 00000000..d0dae4f7 --- /dev/null +++ b/modules/local/utility/xenium2scs/templates/xenium2scs.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import numpy as np +import pandas as pd +import tifffile + +# Xenium full-resolution image: 1 pixel = 0.2125 µm (10x Genomics spec). +# https://kb.10xgenomics.com/s/article/11636252598925-What-are-the-Xenium-image-scale-factors +# Transcript x_location / y_location are in microns. +# To overlay transcripts on the full-res image: pixel = micron / pixel_size. +PIXEL_SIZE_UM = 0.2125 + + +def convert_xenium_to_scs(parquet_path: str, + output_bgi_tsv: str, + morphology_image_path: str, + output_morph2d_tif: str): + """ + Convert Xenium transcripts to SCS/BGI format with correct pixel-space coordinates. + + Xenium x_location / y_location are in microns. + The morphology image full resolution is 0.2125 µm/px (Xenium spec). + Coordinates are converted to pixels: pixel = micron / pixel_size. + The morphology image is cropped to the pixel ROI covered by the transcripts. + No pre-binning is applied — binning is handled by SCS itself at runtime. + """ + transcripts = pd.read_parquet(parquet_path, engine="pyarrow") + + gene_col = "feature_name" + x_col = "x_location" + y_col = "y_location" + + table = transcripts[[gene_col, x_col, y_col]].copy() + table = table.dropna(subset=[gene_col, x_col, y_col]) + + # Convert micron coordinates → full-resolution pixel coordinates. + # Xenium: x_location is along image width (columns), y_location along height (rows). + # Pixel coordinates are crop-local (zero-based from transcript ROI min). + r_min = int(round(float(transcripts[y_col].min()) / PIXEL_SIZE_UM)) + c_min = int(round(float(transcripts[x_col].min()) / PIXEL_SIZE_UM)) + table["row"] = np.rint(table[y_col].astype(float) / PIXEL_SIZE_UM - r_min).astype(int) + table["column"] = np.rint(table[x_col].astype(float) / PIXEL_SIZE_UM - c_min).astype(int) + + # Each Xenium transcript row is one molecule; aggregate per (gene, pixel). + table["MIDCounts"] = 1 + table = table.rename(columns={gene_col: "geneID"}) + bgi = ( + table.groupby(["geneID", "row", "column"], as_index=False)["MIDCounts"] + .sum() + .rename(columns={"row": "x", "column": "y"}) + ) + + # ── Morphology image ──────────────────────────────────────────────────────── + # Load and collapse to 2D (max projection across z/channels). + image = tifffile.imread(morphology_image_path) + image = np.squeeze(np.asarray(image)) + if image.ndim == 2: + image2d = image + elif image.ndim >= 3: + h, w = image.shape[-2], image.shape[-1] + image2d = image.reshape((-1, h, w)).max(axis=0) + else: + raise ValueError(f"Unsupported morphology image shape: {image.shape}") + + # Crop to the pixel ROI covered by transcripts (same r_min/c_min as coords above). + r_max = int(round(float(transcripts[y_col].max()) / PIXEL_SIZE_UM)) + c_max = int(round(float(transcripts[x_col].max()) / PIXEL_SIZE_UM)) + + # Clamp to image bounds. + H, W = image2d.shape + r_min_clamped = max(0, r_min) + r_max_clamped = min(H - 1, r_max) + c_min_clamped = max(0, c_min) + c_max_clamped = min(W - 1, c_max) + + cropped = image2d[r_min_clamped:r_max_clamped + 1, c_min_clamped:c_max_clamped + 1] + + out_morph2d = Path(output_morph2d_tif) + out_morph2d.parent.mkdir(parents=True, exist_ok=True) + tifffile.imwrite(out_morph2d, cropped) + + # ── BGI file (SCS/spateo format) ──────────────────────────────────────────── + # spateo read_bgi_agg: x → AnnData dim-0 (height/rows), y → dim-1 (width/cols). + out_bgi_tsv = Path(output_bgi_tsv) + out_bgi_tsv.parent.mkdir(parents=True, exist_ok=True) + bgi.to_csv(out_bgi_tsv, sep="\t", index=False) + + +if __name__ == "__main__": + transcripts_parquet: str = "${transcripts_parquet}" + morphology_image: str = "${morphology_image}" + prefix: str = "${prefix}" + + output_bgi_tsv = f"{prefix}/scs_input_bgi.tsv" + output_morph2d_tif = f"{prefix}/morph2d.tif" + + convert_xenium_to_scs( + parquet_path=transcripts_parquet, + output_bgi_tsv=output_bgi_tsv, + morphology_image_path=morphology_image, + output_morph2d_tif=output_morph2d_tif, + ) + + with open("versions.yml", "w", encoding="utf-8") as fobj: + fobj.write('"${task.process}":\\n') + fobj.write('xenium2scs: "1.0.0"\\n') diff --git a/nextflow.config b/nextflow.config index 473c41b8..d3c2f22e 100644 --- a/nextflow.config +++ b/nextflow.config @@ -44,8 +44,19 @@ params { // Proseg specific format = 'xenium' // preset value set as `xenium` + // SCS specific + scs_patch_size = 1200 // patch size for SCS segmentation processing + scs_stain_bg_percentile = 10 // background stain percentile threshold for SCS + scs_epochs = 100 // number of training epochs for SCS model + scs_n_neighbor = 50 // number of neighbors to use in SCS segmentation + scs_r_estimate = 15 // radius estimation for SCS + scs_bin_size = 3 // spatial binning resolution for SCS + scs_val_ratio = 0.0625 // validation split ratio for SCS + scs_prealigned = false // whether SCS data is pre-aligned + scs_align = "None" // alignment method for SCS + // Segmentation methods - image_seg_methods = ["cellpose", "xeniumranger", "baysor"] + image_seg_methods = ["cellpose", "xeniumranger", "baysor", "scs"] transcript_seg_methods = ["proseg", "segger", "baysor"] segfree_methods = ["ficture", "baysor"] diff --git a/nextflow_schema.json b/nextflow_schema.json index e5144c04..1c658ff6 100644 --- a/nextflow_schema.json +++ b/nextflow_schema.json @@ -36,7 +36,7 @@ }, "method": { "type": "string", - "enum": ["cellpose", "xeniumranger", "baysor", "proseg", "segger", "ficture"], + "enum": ["cellpose", "xeniumranger", "baysor", "proseg", "segger", "ficture", "scs"], "description": "Segmentation method to run." }, "gene_panel": { @@ -129,6 +129,51 @@ "type": "boolean", "description": "Whether to only run nucleus segmentation." }, + "scs_patch_size": { + "type": "integer", + "default": 1200, + "description": "Patch size for SCS segmentation processing. Larger patches improve contextual information but increase memory usage." + }, + "scs_stain_bg_percentile": { + "type": "integer", + "default": 10, + "description": "Background stain percentile threshold for SCS segmentation. Controls stain background removal sensitivity." + }, + "scs_epochs": { + "type": "integer", + "default": 100, + "description": "Number of training epochs for SCS segmentation model." + }, + "scs_n_neighbor": { + "type": "integer", + "default": 50, + "description": "Number of neighbors to use in SCS segmentation." + }, + "scs_bin_size": { + "type": "integer", + "default": 3, + "description": "Spatial binning size for SCS segmentation. Controls the resolution of spatial binning." + }, + "scs_r_estimate": { + "type": "integer", + "default": 15, + "description": "Cell radius estimation parameter for SCS segmentation." + }, + "scs_val_ratio": { + "type": "number", + "default": 0.0625, + "description": "Validation data ratio for SCS model training." + }, + "scs_prealigned": { + "type": "boolean", + "default": false, + "description": "Whether the SCS input data is already aligned." + }, + "scs_align": { + "type": "string", + "default": "None", + "description": "Alignment method for SCS segmentation. Options: 'None' or alignment algorithm name." + }, "expansion_distance": { "type": "integer", "default": 5, @@ -174,7 +219,7 @@ "type": "array", "items": { "type": "string", - "enum": ["cellpose", "xeniumranger", "baysor"] + "enum": ["cellpose", "xeniumranger", "baysor", "scs"] }, "description": "List of image-based segmentation methods." }, diff --git a/subworkflows/local/scs_import_segmentation/main.nf b/subworkflows/local/scs_import_segmentation/main.nf new file mode 100644 index 00000000..a7ddeb1c --- /dev/null +++ b/subworkflows/local/scs_import_segmentation/main.nf @@ -0,0 +1,71 @@ +// +// Run SCS segmentation and import into Xenium bundle +// + +include { SCS_PREPARE_MORPHOLOGY } from '../../../subworkflows/local/scs_prepare_morphology/main' +include { SCS_SEGMENT } from '../../../modules/local/scs/main' +include { STITCH_SCS_MASKS } from '../../../modules/local/utility/stitch_scs_masks/main' +include { XENIUMRANGER_IMPORT_SEGMENTATION } from '../../../modules/nf-core/xeniumranger/import-segmentation/main' + +workflow SCS_IMPORT_SEGMENTATION { + take: + ch_morphology_image // channel: [ val(meta), ["path-to-morphology.ome.tif"] ] + ch_bundle_path // channel: [ val(meta), ["path-to-xenium-bundle"] ] + ch_transcripts_parquet // channel: [ val(meta), ["path-to-transcripts.parquet"] ] + ch_coordinate_space // channel: [ val("microns") or val("pixels") ] + + main: + + ch_versions = Channel.empty() + + // Prepare SCS inputs (morphology and transcripts) + SCS_PREPARE_MORPHOLOGY( + ch_morphology_image, + ch_transcripts_parquet, + ) + ch_versions = ch_versions.mix(SCS_PREPARE_MORPHOLOGY.out.versions) + + // Combine prepared inputs for SCS_SEGMENT + ch_scs_in = SCS_PREPARE_MORPHOLOGY.out.scs_input_bgi_tsv + .join(SCS_PREPARE_MORPHOLOGY.out.morphology_2d, by: 0) + + // Run SCS segmentation + SCS_SEGMENT(ch_scs_in) + ch_versions = ch_versions.mix(SCS_SEGMENT.out.versions) + + // Stitch patch masks into single image + STITCH_SCS_MASKS( + SCS_SEGMENT.out.cell_masks.map { meta, masks -> + tuple(meta, masks.flatten()) + } + ) + ch_versions = ch_versions.mix(STITCH_SCS_MASKS.out.versions) + + // Import SCS segmentation into Xenium bundle via xeniumranger + // Combine bundle path with stitched masks output + ch_bundle_masks = ch_bundle_path + .join(STITCH_SCS_MASKS.out.segmentation_csv, by: 0) + .join(STITCH_SCS_MASKS.out.polygons, by: 0) + .map { meta, bundle, segmentation_csv, polygons_geojson -> + tuple( + meta, + bundle, + [], + [], + [], + segmentation_csv, + polygons_geojson, + ch_coordinate_space.val, + ) + } + + XENIUMRANGER_IMPORT_SEGMENTATION(ch_bundle_masks) + ch_versions = ch_versions.mix(XENIUMRANGER_IMPORT_SEGMENTATION.out.versions) + + ch_redefined_bundle = XENIUMRANGER_IMPORT_SEGMENTATION.out.bundle + + emit: + coordinate_space = ch_coordinate_space // channel: [ val("microns") or val("pixels") ] + redefined_bundle = ch_redefined_bundle // channel: [ val(meta), ["xenium-bundle"] ] + versions = ch_versions // channel: [ versions.yml ] +} diff --git a/subworkflows/local/scs_prepare_morphology/main.nf b/subworkflows/local/scs_prepare_morphology/main.nf new file mode 100644 index 00000000..c06d0368 --- /dev/null +++ b/subworkflows/local/scs_prepare_morphology/main.nf @@ -0,0 +1,27 @@ +// +// Prepare SCS-compatible input from Xenium transcripts and pass morphology for downstream SCS segmentation +// + +include { XENIUM2SCS } from '../../../modules/local/utility/xenium2scs/main' + +workflow SCS_PREPARE_MORPHOLOGY { + take: + ch_morphology_image // channel: [ val(meta), ["path-to-morphology.ome.tif"] ] + ch_transcripts_parquet // channel: [ val(meta), ["path-to-transcripts.parquet"] ] + + main: + + ch_versions = channel.empty() + + // convert Xenium transcripts.parquet to SCS tabular input format + ch_xenium2scs_input = ch_transcripts_parquet + .join(ch_morphology_image, by: 0) + XENIUM2SCS(ch_xenium2scs_input) + ch_versions = ch_versions.mix(XENIUM2SCS.out.versions) + + emit: + morphology_image = ch_morphology_image // channel: [ val(meta), ["path-to-morphology.ome.tif"] ] + morphology_2d = XENIUM2SCS.out.morph2d_tif // channel: [ val(meta), ["path-to-morph2d.tif"] ] + scs_input_bgi_tsv = XENIUM2SCS.out.scs_input_bgi_tsv // channel: [ val(meta), ["path-to-scs_input_bgi.tsv"] ] + versions = ch_versions // channel: [ versions.yml ] +} diff --git a/subworkflows/local/utils_nfcore_spatialxe_pipeline/main.nf b/subworkflows/local/utils_nfcore_spatialxe_pipeline/main.nf index a8f1d2af..7acb6a79 100644 --- a/subworkflows/local/utils_nfcore_spatialxe_pipeline/main.nf +++ b/subworkflows/local/utils_nfcore_spatialxe_pipeline/main.nf @@ -251,8 +251,8 @@ def validateInputParameters() { // def validateXeniumBundle(ch_samplesheet) { - // define xenium bundle directory structure - required files - def bundle_required_files = [ + // define xenium bundle directory structure - full required files + def bundle_required_files_full = [ "cell_boundaries.csv.gz", "cell_boundaries.parquet", "cell_feature_matrix.h5", @@ -272,6 +272,18 @@ def validateXeniumBundle(ch_samplesheet) { "transcripts.zarr.zip", ] + // minimal files required for SCS input preparation + def bundle_required_files_scs = [ + "experiment.xenium", + "morphology.ome.tif", + "transcripts.parquet", + ] + + // choose required files based on mode/method + def bundle_required_files = (params.mode == 'image' && params.method == 'scs') + ? bundle_required_files_scs + : bundle_required_files_full + // bundle optional files def bundle_optional_files = [ "analysis.tar.gz", diff --git a/workflows/spatialxe.nf b/workflows/spatialxe.nf index f22c4f5c..bb91156b 100644 --- a/workflows/spatialxe.nf +++ b/workflows/spatialxe.nf @@ -29,6 +29,9 @@ include { BAYSOR_RUN_PRIOR_SEGMENTATION_MASK } from '../subworkflo include { CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF } from '../subworkflows/local/cellpose_resolift_morphology_ome_tif/main' include { CELLPOSE_BAYSOR_IMPORT_SEGMENTATION } from '../subworkflows/local/cellpose_baysor_import_segmentation/main' include { XENIUMRANGER_RESEGMENT_MORPHOLOGY_OME_TIF } from '../subworkflows/local/xeniumranger_resegment_morphology_ome_tif/main' +include { SCS_PREPARE_MORPHOLOGY } from '../subworkflows/local/scs_prepare_morphology/main' +include { SCS_SEGMENT } from '../modules/local/scs/main' +include { SCS_IMPORT_SEGMENTATION } from '../subworkflows/local/scs_import_segmentation/main' // segmentation-free subworkflows include { BAYSOR_GENERATE_SEGFREE } from '../subworkflows/local/baysor_generate_segfree/main' @@ -74,6 +77,7 @@ workflow SPATIALXE { ch_bundle_path = Channel.empty() ch_preview_html = Channel.empty() ch_exp_metadata = Channel.empty() + ch_experiment_xenium = Channel.empty() ch_gene_synonyms = Channel.empty() ch_multiqc_files = Channel.empty() ch_multiqc_report = Channel.empty() @@ -172,6 +176,14 @@ workflow SPATIALXE { return [exp_metadata] } + ch_experiment_xenium = ch_input.map { meta, bundle, _image -> + def exp_metadata = file( + bundle.toString().replaceFirst(/\/$/, '') + "/experiment.xenium", + checkIfExists: true + ) + return [meta, exp_metadata] + } + // get baysor xenium config ch_config = Channel.fromPath( "${projectDir}/assets/config/xenium.toml", @@ -370,6 +382,19 @@ workflow SPATIALXE { ch_redefined_bundle = CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF.out.redefined_bundle ch_coordinate_space = CELLPOSE_RESOLIFT_MORPHOLOGY_OME_TIF.out.coordinate_space } + + // prepare transcripts and morphology for SCS segmentation + if (params.method == 'scs') { + + SCS_IMPORT_SEGMENTATION( + ch_morphology_image, + ch_bundle_path, + ch_transcripts_parquet, + Channel.value("microns"), + ) + ch_redefined_bundle = SCS_IMPORT_SEGMENTATION.out.redefined_bundle + ch_coordinate_space = SCS_IMPORT_SEGMENTATION.out.coordinate_space + } } /*