diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3fa57..a3c518f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,14 +3,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## v1.0.0dev - [date] +## v1.1.0dev - [date] -Initial release of nf-core/microscopy, created with the [nf-core](https://nf-co.re/) template. +First update to nfcore/microscopy. ### `Added` +* Selection of segmentation method as a parameter. +* Altered workflow to handle pre-stitched or legacy HALO tiff inputs. +* Added a downscaling step which reduces resolution to 1px/um for faster segmentation. +* Expanded README and added a metro diagram. + ### `Fixed` +* Fixed a bug where warnings were exported into tiff metadata xml. + ### `Dependencies` ### `Deprecated` diff --git a/README.md b/README.md index 0854f43..68816bb 100644 --- a/README.md +++ b/README.md @@ -7,47 +7,52 @@ ## Introduction -**nf-core/microscopy** is a bioinformatics pipeline that ... +**nf-core/microscopy** is a bioinformatics pipeline designed to streamline the analysis of multiplex immunohistochemsitry (mIHC) samples. - +The purpose of the pipeline is to convert tiled or stitched multi-channel microscopy images into clean single-cell data. It takes as input a samplesheet, and an optional list of markers present on the panel. It stitches images together, performs cell segmentation on the DAPI channel, quantifies outputs into single-cell format and then runs a few basic clustering analyses. - - +The segmentation model to be used can be selected (currently mesmer is default, and cellpose is also available). The pipeline returns a stitched image file, segmented image mask, cell x feature spreadsheet, as well as UMAP representations of the data clustered using various methods. + +![nf-core/microscopy metro diagram](assets/microscopy_metro.png) ## Usage > [!NOTE] > If you are new to Nextflow and nf-core, please refer to [this page](https://nf-co.re/docs/usage/installation) on how to set-up Nextflow. Make sure to [test your setup](https://nf-co.re/docs/usage/introduction#how-to-run-a-pipeline) with `-profile test` before running the workflow on actual data. - +Next, you'll need to prepare a list of your markers: -Now, you can run the pipeline using: +`markers.csv`: + +```csv +marker_name +DAPI +PTPRC +HMWCK +CD3 +CD11c +``` - +Each row represents a different channel in your images. Note that marker names cannot contain spaces! + +Now, you can run the pipeline using: ```bash nextflow run nf-core/microscopy \ -profile \ --input samplesheet.csv \ + --markers markers.csv \ --outdir ``` @@ -67,8 +72,8 @@ For more details about the output files and reports, please refer to the nf-core/microscopy was originally written by Song Li. We thank the following people for their extensive assistance in the development of this pipeline: + * Patrick Crock - ## Contributions and Support diff --git a/assets/microscopy_metro.png b/assets/microscopy_metro.png new file mode 100644 index 0000000..b0837b1 Binary files /dev/null and b/assets/microscopy_metro.png differ diff --git a/assets/schema_input.json b/assets/schema_input.json index 8373c31..3df05db 100644 --- a/assets/schema_input.json +++ b/assets/schema_input.json @@ -17,8 +17,16 @@ "type": "string", "format": "directory-path", "exists": true, - "pattern": "^\\S+$", - "errorMessage": "TIFF folder path must be provided and cannot contain spaces" + "pattern": "^.+$", + "errorMessage": "TIFF folder path must be provided, and if it contains spaces, it should be quoted like so: 'example/path to/tiffs'" + }, + "format": { + "type": "string", + "pattern": "^(tiles|stitched|fused)$", + "default": "tiles", + "errorMessage": "Format must be one of: tiles, stitched, fused", + "meta": ["format"], + "help_text": "Input format: 'tiles' for tiled TIFFs (default), 'stitched' for .ome.tiff files, 'fused' for HALO-fused .tiff files" } }, "required": ["sample", "tiffs"] diff --git a/bin/convert_ome_tiff.py b/bin/convert_ome_tiff.py index d92d13d..cacb05d 100755 --- a/bin/convert_ome_tiff.py +++ b/bin/convert_ome_tiff.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -# Written by Song Li -# Version: 0.0.1 +# Written by Song Li & Patrick Crock +# Version: 0.0.2 Changed default order to match upstream outputs. import argparse import tifffile @@ -11,8 +11,8 @@ def main(): parser = argparse.ArgumentParser(description="Extract channel from image") parser.add_argument("-o", "--output", type=str, required=True, help="Output .tif file for extracted channel") parser.add_argument("-i", "--image", type=str, required=True, help="Input .tif image") - parser.add_argument("--order", type=str, default='2,0,1', help="Transpose dimensions, assuming Y,X,C input dimension order") - + parser.add_argument("--order", type=str, default='0,1,2', help="Transpose dimensions, assuming X,Y,C input dimension order") + args = parser.parse_args() img = tifffile.imread(args.image) @@ -29,4 +29,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/bin/extract_image_channel.py b/bin/extract_image_channel.py index 2abdfea..82fcbb2 100755 --- a/bin/extract_image_channel.py +++ b/bin/extract_image_channel.py @@ -1,32 +1,52 @@ #!/usr/bin/env python -# Written by Song Li -# Version 0.0.1 - +# Written by Song Li & Patrick Crock +# Version 0.0.3 - Made extraction robust to dimension order import sys, os import argparse -import xmltodict -import skimage +import xml.etree.ElementTree as ET +import skimage.io +import tifffile +import numpy as np def extract_channel(xml, channel_name): - with open(xml, 'r') as file: - xml_str = file.read() + tree = ET.parse(xml) + root = tree.getroot() - xml_dict = xmltodict.parse(xml_str, attr_prefix='', cdata_key='') - channels = xml_dict['OME']['Image']['Pixels']['Channel'] + # Handle namespace + ns = {'ome': 'http://www.openmicroscopy.org/Schemas/OME/2016-06'} + channels = root.findall('.//ome:Channel', ns) - for channel in channels: - if channel_name in channel['Name'].upper(): - ch_extracted = channel['ID'] - ch_extracted = ch_extracted.split(':')[-1] + channel_name_upper = channel_name.upper() - return int(ch_extracted) + for i, channel in enumerate(channels): + name = channel.get('Name', '').upper() + if channel_name_upper in name: + return i # Return the channel index directly - return + return None def save_channel_image(ch, img_path, out_path): - img = skimage.io.imread(img_path) - channel = img[:,:,3] + with tifffile.TiffFile(img_path) as tif: + arr = tif.asarray() + axes = tif.series[0].axes + print("Array shape:", arr.shape, "Axes:", axes) + + if "C" in axes: + c_index = axes.index("C") + elif "S" in axes: + c_index = axes.index("S") + else: + raise RuntimeError(f"No channel axis in axes string {axes}") + + # move channel axis to last for consistency + arr = np.moveaxis(arr, c_index, -1) + + if ch >= arr.shape[-1]: + print(f"Error: Channel {ch} not found, image has {arr.shape[-1]} channels") + sys.exit(os.EX_SOFTWARE) + + channel = arr[..., ch] skimage.io.imsave(out_path, channel) def main(): @@ -35,19 +55,18 @@ def main(): parser.add_argument("-i", "--image", type=str, required=True, help="Input .tif image") parser.add_argument("-x", "--xml", type=str, required=True, help="Metadata .xml for .tif image") parser.add_argument("-c", "--channel", type=str, default='DAPI', help="Channel name to extract") - - args = parser.parse_args() + args = parser.parse_args() channel_extracted = extract_channel(args.xml, args.channel) - if channel_extracted: + if channel_extracted is not None: save_channel_image(channel_extracted, args.image, args.output) + print(f"Successfully extracted channel {channel_extracted} ({args.channel}) to {args.output}") else: print(f"{args.channel} channel could not be found") sys.exit(os.EX_SOFTWARE) return - if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/bin/indicaTIFF_to_ome.py b/bin/indicaTIFF_to_ome.py new file mode 100755 index 0000000..b7801cb --- /dev/null +++ b/bin/indicaTIFF_to_ome.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python + +## from: https://forum.image.sc/t/trouble-generating-ome-tiffs-in-the-right-shape/89191/7 + +import argparse +from xml.etree import ElementTree +from tifffile import TiffFile, TiffWriter +from xml.dom import minidom + +def tiles(series): + # yield raw tiles from all pages in TIFF series + fh = series.parent.filehandle + for page in series: + for offset, bytecount in zip(page.dataoffsets, page.databytecounts): + fh.seek(offset) + yield fh.read(bytecount) + + +def main(): + parser = argparse.ArgumentParser(description="Convert HALO/Indica tif to OME tif") + parser.add_argument("-o", "--output", type=str, required=True, help="Output .ome.tif file") + parser.add_argument("-i", "--image", type=str, required=True, help="Input .tif image") + + args = parser.parse_args() + + with TiffFile(args.image) as tif: + print(tif) + + tree = ElementTree.fromstring(tif.pages.first.description) + channel_names = [ + channel.attrib['name'] for channel in tree.iter('channel') + ] + + objective_value = float(tree.find('.//objective').attrib['value']) + + with TiffWriter( + args.output, bigtiff=True, ome=True, byteorder=tif.byteorder + ) as ome: + for series in tif.series: + print("SERIES:", series) + print(" series.axes:", series.axes, "series.shape:", series.shape) + assert series.axes == 'IYX' + assert series.shape[0] == len(channel_names) + + for i, level in enumerate(series.levels): + page = level.keyframe + print(" LEVEL", i, "level.shape:", level.shape, "page.keyframe.shape:", getattr(page, 'shape', None)) + print(" page.imagewidth:", getattr(page, 'imagewidth', None), + "page.imagelength:", getattr(page, 'imagelength', None)) + print(" page.tile:", getattr(page, 'tile', None)) + print(" TileWidth/TileLength tags:", + page.tags.get('TileWidth'), page.tags.get('TileLength')) + + assert page.is_tiled + + # --- compute rows/cols and samples confidently from page tags --- + rows = getattr(page, 'imagelength', None) + cols = getattr(page, 'imagewidth', None) + # number of channels/samples (fall back to level.shape if unsure) + samples = series.shape[0] if series.shape else (level.shape[0] if len(level.shape) == 3 else 1) + + # If level.shape looks like (rows, cols, samples) convert to (samples, rows, cols) + if len(level.shape) == 3: + a, b, c = level.shape + if (a == rows and b == cols and c == samples): + inferred_shape = (samples, rows, cols) + elif (a == samples and b == rows and c == cols): + inferred_shape = (a, b, c) # already (samples, rows, cols) + else: + # fallback to explicit (samples, rows, cols) + inferred_shape = (samples, rows, cols) + else: + inferred_shape = (rows, cols) + + # get tile dims from tags if present (TileWidth, TileLength) + try: + tile_w = page.tags['TileWidth'].value + tile_l = page.tags['TileLength'].value + tile = (tile_w, tile_l) + except Exception: + tile = page.tile or None + + if i == 0: + # base-level metadata construction (unchanged) + if len(series.levels) > 1: + subifds = len(series.levels) - 1 + else: + subifds = None + resx, resy = page.get_resolution('micrometer') + + # Correct resolution by objective magnification + resx = resx / objective_value + resy = resy / objective_value + + metadata = { + 'axes': 'CYX', + 'PhysicalSizeX': resx, + 'PhysicalSizeXUnit': 'µm', + 'PhysicalSizeY': resy, + 'PhysicalSizeYUnit': 'µm', + 'Channel': {'Name': channel_names}, + } + else: + subifds = None + metadata = None + + dtype = 'float32' if level.dtype == 'uint32' else level.dtype + + print(" -> writing with shape:", inferred_shape, "tile:", tile) + + ome.write( + tiles(level), + shape=inferred_shape, + dtype=dtype, + photometric=page.photometric, + compression=page.compression, + resolution=page.resolution, + resolutionunit=page.resolutionunit, + tile=tile, + subifds=subifds, + metadata=metadata, + ) + + return + + +if __name__ == "__main__": + main() diff --git a/main.nf b/main.nf index 4f99afd..8e71959 100644 --- a/main.nf +++ b/main.nf @@ -64,7 +64,7 @@ workflow { params.input ) - + // WORKFLOW: Run main workflow // NFCORE_MICROSCOPY ( diff --git a/modules/local/bftools/tiffmetaxml/main.nf b/modules/local/bftools/tiffmetaxml/main.nf index 3c86a4a..acc9e35 100644 --- a/modules/local/bftools/tiffmetaxml/main.nf +++ b/modules/local/bftools/tiffmetaxml/main.nf @@ -11,7 +11,7 @@ process BFTOOLS_TIFFMETAXML { tuple val(meta), path(tif) output: - tuple val(meta), path("*.xml"), emit: xml + tuple val(meta), path("*.xml"), path(tif), emit: xml_tif path "versions.yml" , emit: versions when: @@ -23,6 +23,9 @@ process BFTOOLS_TIFFMETAXML { def prefix = task.ext.prefix ?: "${meta.id}" def BFTOOLS_VERSION='8.0.0' // Edit bftools versions manually """ + export _JAVA_OPTIONS="-XX:-UsePerfData -Xlog:disable" + export BIOFORMATS_UPGRADE_CHECK=never + tiffcomment \\ $args \\ $tif \\ @@ -41,7 +44,7 @@ process BFTOOLS_TIFFMETAXML { def prefix = task.ext.prefix ?: "${meta.id}" def BFTOOLS_VERSION='8.0.0' // Edit bftools versions manually """ - + touch ${prefix}.xml cat <<-END_VERSIONS > versions.yml diff --git a/modules/local/downscaletiff/environment.yml b/modules/local/downscaletiff/environment.yml new file mode 100644 index 0000000..04afbf5 --- /dev/null +++ b/modules/local/downscaletiff/environment.yml @@ -0,0 +1,10 @@ +--- +# yaml-language-server: $schema=https://raw.githubusercontent.com/nf-core/modules/master/modules/environment-schema.json +channels: + - conda-forge + - bioconda +dependencies: + # TODO nf-core: List required Conda package(s). + # Software MUST be pinned to channel (i.e. "bioconda"), version (i.e. "1.10"). + # For Conda, the build (i.e. "h9402c20_2") must be EXCLUDED to support installation on different operating systems. + - "bioconda::bftools=8.0.0" diff --git a/modules/local/downscaletiff/main.nf b/modules/local/downscaletiff/main.nf new file mode 100755 index 0000000..c39aff1 --- /dev/null +++ b/modules/local/downscaletiff/main.nf @@ -0,0 +1,173 @@ +process DOWNSCALE_OME_TIFF { + tag "$meta.id" + label 'process_high_memory' + + conda "${moduleDir}/environment.yml" + container "ghcr.io/patrickcrock/bftools_tifffile:latest" + + input: + tuple val(meta), path(ome_tiff) + + output: + tuple val(meta), path("*.downscaled.ome.tiff"), emit: downscaled + tuple val(meta), path("*.json"), emit: metadata + path "versions.yml", emit: versions + + script: + def prefix = task.ext.prefix ?: "${meta.id}" + def BFTOOLS_VERSION='8.0.0' // Edit bftools versions manually + """ + export _JAVA_OPTIONS="-Xmx${task.memory.toMega()}m" + # Step 0: Calculate scale to achieve 1:1 micron to pixel ratio + origScale=\$(tiffcomment ${ome_tiff} | grep -o 'PhysicalSizeX="[^"]*"' | head -n1 | sed 's/PhysicalSizeX="\\(.*\\)"/\\1/') + + if [ -z "\$origScale" ] || [ "\$origScale" == "0" ]; then + echo "ERROR: Could not extract PhysicalSizeX from OME-TIFF metadata" + exit 1 + fi + + # Calculate effective scale (rounding to nearest integer) + + effScale=\$(awk -v scale="\${origScale}" "BEGIN{print int(1/scale + 0.5)}") + effPhysicalSize=\$(awk -v a="\${origScale}" -v b="\${effScale}" "BEGIN{print a*b}") + + echo "Original PhysicalSizeX: \${origScale} µm" + echo "Effective scale factor: \${effScale}" + echo "Effective PhysicalSizeX after scaling: \${effPhysicalSize} µm" + + # If scale is 1, just extract series 1 of the file + if [ "\$effScale" -eq 1 ]; then + echo "Scale factor is 1, no downscaling needed" + bfconvert ${ome_tiff} \\ + -series 0 \\ + -compression zlib \\ + -noflat \\ + ${prefix}.downscaled.ome.tiff + else + # Step 1: Extract series 0 (full resolution) + bfconvert ${ome_tiff} -series 0 -compression zlib ${prefix}.series0.ome.tiff + + # Step 2: Create new pyramid with base layer plus new rescale + bfconvert ${prefix}.series0.ome.tiff \\ + -pyramid-resolutions 2 \\ + -pyramid-scale \${effScale} \\ + -tilex 256 \\ + -tiley 256 \\ + -compression zlib \\ + -noflat \\ + ${prefix}.rescaled.ome.tiff + + # Step 3: Extract SubIFD level 1 using Python + python3 - "\${effScale}" <<'PYEOF' +import tifffile +import numpy as np +from xml.etree import ElementTree as ET +import sys + + +effScale = float(sys.argv[1]) + +with tifffile.TiffFile("${prefix}.rescaled.ome.tiff") as tif: + series = tif.series[0] + + if hasattr(series, 'levels') and len(series.levels) > 1: + level1_data = series.levels[1].asarray() + level1_shape = level1_data.shape + + print(f"Extracted level 1 shape: {level1_shape}") + + # Parse original OME-XML to extract channel info + channel_names = [] + channel_colors = [] + physical_size_x = None + physical_size_y = None + + if tif.ome_metadata: + root = ET.fromstring(tif.ome_metadata) + ns = {'ome': 'http://www.openmicroscopy.org/Schemas/OME/2016-06'} + + # Extract channel info + for channel in root.findall('.//ome:Channel', ns): + name = channel.get('Name', '') + color = channel.get('Color', '-1') + channel_names.append(name) + channel_colors.append(color) + + # Extract physical sizes + pixels = root.find('.//ome:Pixels', ns) + if pixels is not None: + physical_size_x = pixels.get('PhysicalSizeX') + physical_size_y = pixels.get('PhysicalSizeY') + + # Calculate new physical sizes based on downscale factor + if physical_size_x and physical_size_y: + new_physical_size_x = float(physical_size_x) * effScale + new_physical_size_y = float(physical_size_y) * effScale + else: + new_physical_size_x = None + new_physical_size_y = None + + # Determine axes + if level1_data.ndim == 3 and level1_shape[0] <= 20: + axes = 'CYX' + num_channels = level1_shape[0] + elif level1_data.ndim == 2: + axes = 'YX' + num_channels = 1 + else: + axes = None + num_channels = 1 + + # Build metadata dict + metadata = {'axes': axes} + + if new_physical_size_x and new_physical_size_y: + metadata['PhysicalSizeX'] = new_physical_size_x + metadata['PhysicalSizeY'] = new_physical_size_y + metadata['PhysicalSizeXUnit'] = 'µm' + metadata['PhysicalSizeYUnit'] = 'µm' + + if channel_names and len(channel_names) == num_channels: + metadata['Channel'] = {'Name': channel_names} + + print(f"Channel names: {channel_names}") + print(f"Physical size: {new_physical_size_x} x {new_physical_size_y} µm") + + # Save with metadata + tifffile.imwrite( + "${prefix}.downscaled.ome.tiff", + level1_data, + photometric='minisblack', + compression='deflate', + compressionargs={'level': 6}, + tile=(256, 256), + metadata=metadata, + ome=True, + bigtiff=True + ) + + print(f"Saved downscaled image with shape {level1_shape}") + else: + print("ERROR: No SubIFD levels found") + sys.exit(1) +PYEOF + + # Cleanup intermediate files + rm -f ${prefix}.series0.ome.tiff + rm -f ${prefix}.rescaled.ome.tiff + fi + + # Save metadata + cat > ${prefix}.json < versions.yml +"${task.process}": + bftools: ${BFTOOLS_VERSION} +END_VERSIONS + """ +} diff --git a/modules/local/extractimagechannel/main.nf b/modules/local/extractimagechannel/main.nf index ed49689..1ff80db 100644 --- a/modules/local/extractimagechannel/main.nf +++ b/modules/local/extractimagechannel/main.nf @@ -5,8 +5,7 @@ process EXTRACTIMAGECHANNEL { container "docker://sli0001/xmltodict-skimage:0.0.1" input: - tuple val(meta), path(xml) - tuple val(meta), path(ome_tif) + tuple val(meta), path(xml), path(ome_tif) output: tuple val(meta), path("*.tif") , emit: image @@ -23,7 +22,7 @@ process EXTRACTIMAGECHANNEL { $args \\ --xml ${xml} \\ --image ${ome_tif} \\ - --output ${prefix}.tif + --output ${prefix}.tif cat <<-END_VERSIONS > versions.yml "${task.process}": @@ -35,7 +34,7 @@ process EXTRACTIMAGECHANNEL { stub: def prefix = task.ext.prefix ?: "${meta.id}" """ - + touch ${prefix}.tif cat <<-END_VERSIONS > versions.yml diff --git a/modules/local/halo/indicatifftoome/main.nf b/modules/local/halo/indicatifftoome/main.nf new file mode 100644 index 0000000..58538a8 --- /dev/null +++ b/modules/local/halo/indicatifftoome/main.nf @@ -0,0 +1,65 @@ +process INDICA_TIFF_TO_OME { + tag "$meta.id" + label 'process_low' + + container "ghcr.io/patrickcrock/python-tiff:3.8-zarr" + + input: + tuple val(meta), path(indica_tif) + + output: + tuple val(meta), path("*.ome.tif") , emit: image + path "versions.yml" , emit: versions + + when: + task.ext.when == null || task.ext.when + + script: + def args = task.ext.args ?: '' + def prefix = task.ext.prefix ?: "${meta.id}" + """ + # Collect TIFF files into an array, safe against spaces/newlines + mapfile -d '' tiff_files < <( + find -L "${indica_tif}" -maxdepth 1 -type f \ + '(' -name "*.tif" -o -name "*.tiff" -o -name "*.ome.tif" -o -name "*.ome.tiff" ')' -print0 + ) + + # Check number of TIFF files + if [ \${#tiff_files[@]} -eq 0 ]; then + echo "ERROR: No TIFF files found in ${indica_tif}" + exit 1 + elif [ \${#tiff_files[@]} -gt 1 ]; then + echo "ERROR: Multiple TIFF files found in ${indica_tif}. Expected exactly one." + # Print them safely + for f in "\${tiff_files[@]}"; do + printf ' %s\n' "\$f" + done + exit 1 + fi + + # Pull the tiff file for downstream + tiff_file="\${tiff_files[0]}" + + + indicaTIFF_to_ome.py \\ + $args \\ + --image \${tiff_file} \\ + --output ${prefix}.ome.tif + + cat <<-END_VERSIONS > versions.yml +"${task.process}": + python: \$(python --version | sed 's/Python //g') +END_VERSIONS + """ + + stub: + def prefix = task.ext.prefix ?: "${meta.id}" + """ + touch ${prefix}.ome.tif + + cat <<-END_VERSIONS > versions.yml +"${task.process}": + python: \$(python --version | sed 's/Python //g') +END_VERSIONS + """ +} diff --git a/modules/local/handlestitched/main.nf b/modules/local/handlestitched/main.nf new file mode 100644 index 0000000..bb3315e --- /dev/null +++ b/modules/local/handlestitched/main.nf @@ -0,0 +1,58 @@ +process HANDLE_STITCHED { + tag "$meta.id" + label 'process_low' + + input: + tuple val(meta), path(tiff_dir) + + output: + tuple val(meta), path("*.ome.tiff"), emit: image + path "versions.yml" , emit: versions + + when: + task.ext.when == null || task.ext.when + + script: + def args = task.ext.args ?: '' + def prefix = task.ext.prefix ?: "${meta.id}" + """ + # Collect TIFF files into an array, safe against spaces/newlines + mapfile -d '' tiff_files < <( + find -L "${tiff_dir}" -maxdepth 1 -type f \ + '(' -name "*.tif" -o -name "*.tiff" -o -name "*.ome.tif" -o -name "*.ome.tiff" ')' -print0 + ) + + # Check number of TIFF files + if [ \${#tiff_files[@]} -eq 0 ]; then + echo "ERROR: No TIFF files found in ${tiff_dir}" + exit 1 + elif [ \${#tiff_files[@]} -gt 1 ]; then + echo "ERROR: Multiple TIFF files found in ${tiff_dir}. Expected exactly one." + # Print them safely + for f in "\${tiff_files[@]}"; do + printf ' %s\n' "\$f" + done + exit 1 + fi + + # Create symlink to the single TIFF file + tiff_file="\${tiff_files[0]}" + ln -s $args "\${tiff_file}" "${prefix}.ome.tiff" + + cat < versions.yml +"${task.process}": + handle_stitched: 1.0.0 +EOF + """ + + stub: + def prefix = task.ext.prefix ?: "${meta.id}" + """ + touch ${prefix}.ome.tiff + + cat < versions.yml +"${task.process}": + handle_stitched: 1.0.0 +EOF + """ +} diff --git a/nextflow.config b/nextflow.config index f00f13f..daa3d91 100644 --- a/nextflow.config +++ b/nextflow.config @@ -13,6 +13,10 @@ params { // Input options input = null markers = null + segmentation = 'mesmer' + + // Downscaling parameters + downscale_mode = '1um' // Options: '1um' for downscaling of image to 1 pixel/1um (recommended), 'none' for no downscaling // Boilerplate options outdir = null @@ -43,7 +47,7 @@ params { // Load base.config by default for all pipelines includeConfig 'conf/base.config' -// Load publishdir.config +// Load publishdir.config includeConfig 'conf/publishdir.config' profiles { diff --git a/nextflow_schema.json b/nextflow_schema.json index f487faf..7a91b0e 100644 --- a/nextflow_schema.json +++ b/nextflow_schema.json @@ -32,6 +32,18 @@ "description": "Path to comma-separated file containing information about the markers in the experiment.", "help_text": "" }, + "segmentation": { + "type": "string", + "description": "Segmentation method to use", + "enum": ["mesmer", "cellpose"], + "help_text": "Choose between mesmer or cellpose for cell segmentation" + }, + "downscale_mode": { + "type": "string", + "default": "1um", + "enum": ["1um", "none"], + "description": "Downscaling mode for image processing" + }, "outdir": { "type": "string", "format": "directory-path", @@ -185,4 +197,4 @@ "$ref": "#/$defs/generic_options" } ] -} \ No newline at end of file +} diff --git a/subworkflows/local/utils_nfcore_microscopy_pipeline/main.nf b/subworkflows/local/utils_nfcore_microscopy_pipeline/main.nf index a77a616..2bb71e7 100644 --- a/subworkflows/local/utils_nfcore_microscopy_pipeline/main.nf +++ b/subworkflows/local/utils_nfcore_microscopy_pipeline/main.nf @@ -63,6 +63,16 @@ workflow PIPELINE_INITIALISATION { nextflow_cli_args ) + // + // Validate segmentation parameter + // + validateSegmentationParams() + + // + // Validate downsampling parameters + // + validateDownscaleParams() + // // Create channel from input file provided through params.input // @@ -74,20 +84,20 @@ workflow PIPELINE_INITIALISATION { return [ meta, [ tifs ] ] } .set { ch_samplesheet } - - // + + // // Check if markers file provided through params.markers is valid, and create channel from params.markers - // + // Channel .fromList(samplesheetToList(params.markers, "${projectDir}/assets/schema_markers.json")) .collect({ it[0] }, flat: false) - .map { it -> + .map { it -> validateInputMarkers(it) } Channel .fromPath(params.markers) - .map { it -> + .map { it -> return [ [id: 'markers'], it] } .collect() @@ -151,6 +161,27 @@ workflow PIPELINE_COMPLETION { ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ +// +// Validate segmentation parameter +// +def validateSegmentationParams() { + if (params.segmentation && !(params.segmentation in ['mesmer', 'cellpose'])) { + error("Invalid segmentation method: '${params.segmentation}'. Must be 'mesmer' or 'cellpose'") + } +} + +// +// Validate downsampling parameters +// +def validateDownscaleParams() { + + // downscale mode + if (params.downscale_mode && !(params.downscale_mode in ['1um', 'none'])) { + error("Invalid downsampling selection: '${params.downscale_mode}'. Must be '1um', or 'none'") + } + +} + // // Validate channels from input marker sheet // diff --git a/workflows/microscopy.nf b/workflows/microscopy.nf index f257788..670d28b 100644 --- a/workflows/microscopy.nf +++ b/workflows/microscopy.nf @@ -8,14 +8,18 @@ include { softwareVersionsToYAML } from '../subworkflows/nf-core/utils_nfcore_pi include { methodsDescriptionText } from '../subworkflows/local/utils_nfcore_microscopy_pipeline' include { QUPATH_STITCH } from '../modules/local/qupath/stitch/main' -include { BFTOOLS_TIFFMETAXML } from '../modules/local/bftools/tiffmetaxml/main' -include { EXTRACTIMAGECHANNEL } from '../modules/local/extractimagechannel/main' +include { BFTOOLS_TIFFMETAXML } from '../modules/local/bftools/tiffmetaxml/main' +include { INDICA_TIFF_TO_OME } from '../modules/local/halo/indicatifftoome/main.nf' +include { HANDLE_STITCHED } from '../modules/local/handlestitched/main' +include { EXTRACTIMAGECHANNEL } from '../modules/local/extractimagechannel/main' -include { DEEPCELL_MESMER } from '../modules/nf-core/deepcell/mesmer/main' +include { DOWNSCALE_OME_TIFF } from '../modules/local/downscaletiff' + +include { DEEPCELL_MESMER } from '../modules/nf-core/deepcell/mesmer/main' include { CELLPOSE } from '../modules/local/cellpose/main' // custom module to set cache directories -include { SEPARATEIMAGECHANNELS } from '../modules/local/separateimagechannels/main' -include { MCQUANT } from '../modules/nf-core/mcquant/main' +include { SEPARATEIMAGECHANNELS } from '../modules/local/separateimagechannels/main' +include { MCQUANT } from '../modules/nf-core/mcquant/main' include { SCIMAP_MCMICRO } from '../modules/local/scimap/mcmicro/main' // custom module to get all output contents /* @@ -31,57 +35,97 @@ workflow MICROSCOPY { ch_markers // channel: markers file [[id:markers], params.markers] main: - ch_versions = Channel.empty() - // Stitch images together + // Branch input based on format + ch_samplesheet + .branch { meta, tiffs -> + tiles: meta.format == 'tiles' + stitched: meta.format == 'stitched' + fused: meta.format == 'fused' + } + .set { ch_branched } + + // Process each format type + + // Stitch tiled input stitch_script = "${projectDir}/bin/stitch.groovy" QUPATH_STITCH ( - stitch_script, - ch_samplesheet + stitch_script, + ch_branched.tiles ) - ch_versions = ch_versions.mix(QUPATH_STITCH.out.versions) - // Get DAPI channel - BFTOOLS_TIFFMETAXML ( - QUPATH_STITCH.out.image + // Link to tiff from stitched input + HANDLE_STITCHED ( + ch_branched.stitched ) + + // Process HALO fused input + INDICA_TIFF_TO_OME ( + ch_branched.fused + ) + + // Combine all processed images + ch_images = Channel.empty() + .mix(QUPATH_STITCH.out.image) + .mix(HANDLE_STITCHED.out.image) + .mix(INDICA_TIFF_TO_OME.out.image) + + ch_versions = Channel.empty() + .mix(QUPATH_STITCH.out.versions) + .mix(HANDLE_STITCHED.out.versions) + .mix(INDICA_TIFF_TO_OME.out.versions) + + + // Conditional downscaling based on parameter + if (params.downscale_mode == '1um') { + DOWNSCALE_OME_TIFF( + ch_images + ) + ch_processed_images = DOWNSCALE_OME_TIFF.out.downscaled + ch_versions = ch_versions.mix(DOWNSCALE_OME_TIFF.out.versions) + } else { + ch_processed_images = ch_images + } + + // Extract XML, DAPI channel from processed images + BFTOOLS_TIFFMETAXML(ch_processed_images) + ch_versions = ch_versions.mix(BFTOOLS_TIFFMETAXML.out.versions) EXTRACTIMAGECHANNEL ( - BFTOOLS_TIFFMETAXML.out.xml, - QUPATH_STITCH.out.image + BFTOOLS_TIFFMETAXML.out.xml_tif ) ch_versions = ch_versions.mix(EXTRACTIMAGECHANNEL.out.versions) // Segmentation - use only nuclear image - ch_segmentation = Channel.empty() ch_nuclear_image = EXTRACTIMAGECHANNEL.out.image - DEEPCELL_MESMER ( - ch_nuclear_image, - [[], []] - ) - DEEPCELL_MESMER.out.mask - .map { meta, it -> - return [meta.id, meta + [seg: 'mesmer'], it] - } - .set { ch_mesmer } - ch_versions = ch_versions.mix(EXTRACTIMAGECHANNEL.out.versions) - - CELLPOSE ( - ch_nuclear_image, - [] - ) - CELLPOSE.out.mask - .map { meta, it -> - return [meta.id, meta + [seg: 'cellpose'], it] - } - .set { ch_cellpose } - ch_versions = ch_versions.mix(CELLPOSE.out.versions) + if (params.segmentation == 'mesmer') { + DEEPCELL_MESMER ( + ch_nuclear_image, + [[], []] + ) + ch_segmentation = DEEPCELL_MESMER.out.mask + .map { meta, it -> + return [meta.id, meta + [seg: 'mesmer'], it] + } + ch_versions = ch_versions.mix(DEEPCELL_MESMER.out.versions) + + } else if (params.segmentation == 'cellpose') { + CELLPOSE ( + ch_nuclear_image, + [] + ) + ch_segmentation = CELLPOSE.out.mask + .map { meta, it -> + return [meta.id, meta + [seg: 'cellpose'], it] + } + ch_versions = ch_versions.mix(CELLPOSE.out.versions) + } // Quantification SEPARATEIMAGECHANNELS ( - QUPATH_STITCH.out.image + ch_processed_images ) ch_separatedimg = SEPARATEIMAGECHANNELS.out.image .map { meta, it -> @@ -89,10 +133,9 @@ workflow MICROSCOPY { } ch_versions = ch_versions.mix(SEPARATEIMAGECHANNELS.out.versions) - ch_quant = ch_mesmer - .mix(ch_cellpose) - .combine( ch_separatedimg, by:0 ) - .multiMap { meta1, meta2, seg, meta3, img -> + ch_quant = ch_segmentation + .combine( ch_separatedimg, by:0 ) + .multiMap { meta1, meta2, seg, meta3, img -> image: [meta2, img] mask: [meta2, seg] }