Skip to content

Commit 266aa5d

Browse files
authored
Merge from PMCC-BioinformaticsCore/crock
# Version 1.1.0 ## `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.
2 parents 763cf82 + 9f66e05 commit 266aa5d

18 files changed

Lines changed: 672 additions & 107 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,21 @@
33
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
44
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55

6-
## v1.0.0dev - [date]
6+
## v1.1.0dev - [date]
77

8-
Initial release of nf-core/microscopy, created with the [nf-core](https://nf-co.re/) template.
8+
First update to nfcore/microscopy.
99

1010
### `Added`
1111

12+
* Selection of segmentation method as a parameter.
13+
* Altered workflow to handle pre-stitched or legacy HALO tiff inputs.
14+
* Added a downscaling step which reduces resolution to 1px/um for faster segmentation.
15+
* Expanded README and added a metro diagram.
16+
1217
### `Fixed`
1318

19+
* Fixed a bug where warnings were exported into tiff metadata xml.
20+
1421
### `Dependencies`
1522

1623
### `Deprecated`

README.md

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,47 +7,52 @@
77

88
## Introduction
99

10-
**nf-core/microscopy** is a bioinformatics pipeline that ...
10+
**nf-core/microscopy** is a bioinformatics pipeline designed to streamline the analysis of multiplex immunohistochemsitry (mIHC) samples.
1111

12-
<!-- TODO nf-core:
13-
Complete this sentence with a 2-3 sentence summary of what types of data the pipeline ingests, a brief overview of the
14-
major pipeline sections and the types of output it produces. You're giving an overview to someone new
15-
to nf-core here, in 15-20 seconds. For an example, see https://github.com/nf-core/rnaseq/blob/master/README.md#introduction
16-
-->
12+
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.
1713

18-
<!-- TODO nf-core: Include a figure that guides the user through the major workflow steps. Many nf-core
19-
workflows use the "tube map" design for that. See https://nf-co.re/docs/guidelines/graphic_design/workflow_diagrams#examples for examples. -->
20-
<!-- TODO nf-core: Fill in short bullet-pointed list of the default steps in the pipeline -->
14+
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.
15+
16+
![nf-core/microscopy metro diagram](assets/microscopy_metro.png)
2117

2218
## Usage
2319

2420
> [!NOTE]
2521
> 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.
2622
27-
<!-- TODO nf-core: Describe the minimum required steps to execute the pipeline, e.g. how to prepare samplesheets.
28-
Explain what rows and columns represent. For instance (please edit as appropriate):
29-
30-
First, prepare a samplesheet with your input data that looks as follows:
23+
First, prepare a samplesheet that looks as follows:
3124

3225
`samplesheet.csv`:
3326

3427
```csv
35-
sample,fastq_1,fastq_2
36-
CONTROL_REP1,AEG588A1_S1_L002_R1_001.fastq.gz,AEG588A1_S1_L002_R2_001.fastq.gz
28+
sample,tiffs
29+
SAMPLE_NAME,/path/to/tiff/directory
3730
```
3831

39-
Each row represents a fastq file (single-end) or a pair of fastq files (paired end).
32+
Each row represents a directory which contains several .tiff tiles.
4033

41-
-->
34+
Next, you'll need to prepare a list of your markers:
4235

43-
Now, you can run the pipeline using:
36+
`markers.csv`:
37+
38+
```csv
39+
marker_name
40+
DAPI
41+
PTPRC
42+
HMWCK
43+
CD3
44+
CD11c
45+
```
4446

45-
<!-- TODO nf-core: update the following command to include all required parameters for a minimal example -->
47+
Each row represents a different channel in your images. Note that marker names cannot contain spaces!
48+
49+
Now, you can run the pipeline using:
4650

4751
```bash
4852
nextflow run nf-core/microscopy \
4953
-profile <docker/singularity/.../institute> \
5054
--input samplesheet.csv \
55+
--markers markers.csv \
5156
--outdir <OUTDIR>
5257
```
5358

@@ -67,8 +72,8 @@ For more details about the output files and reports, please refer to the
6772
nf-core/microscopy was originally written by Song Li.
6873

6974
We thank the following people for their extensive assistance in the development of this pipeline:
75+
* Patrick Crock
7076

71-
<!-- TODO nf-core: If applicable, make list of people who have also contributed -->
7277

7378
## Contributions and Support
7479

assets/microscopy_metro.png

234 KB
Loading

assets/schema_input.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,16 @@
1717
"type": "string",
1818
"format": "directory-path",
1919
"exists": true,
20-
"pattern": "^\\S+$",
21-
"errorMessage": "TIFF folder path must be provided and cannot contain spaces"
20+
"pattern": "^.+$",
21+
"errorMessage": "TIFF folder path must be provided, and if it contains spaces, it should be quoted like so: 'example/path to/tiffs'"
22+
},
23+
"format": {
24+
"type": "string",
25+
"pattern": "^(tiles|stitched|fused)$",
26+
"default": "tiles",
27+
"errorMessage": "Format must be one of: tiles, stitched, fused",
28+
"meta": ["format"],
29+
"help_text": "Input format: 'tiles' for tiled TIFFs (default), 'stitched' for .ome.tiff files, 'fused' for HALO-fused .tiff files"
2230
}
2331
},
2432
"required": ["sample", "tiffs"]

bin/convert_ome_tiff.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env python
22

3-
# Written by Song Li
4-
# Version: 0.0.1
3+
# Written by Song Li & Patrick Crock
4+
# Version: 0.0.2 Changed default order to match upstream outputs.
55

66
import argparse
77
import tifffile
@@ -11,8 +11,8 @@ def main():
1111
parser = argparse.ArgumentParser(description="Extract channel from image")
1212
parser.add_argument("-o", "--output", type=str, required=True, help="Output .tif file for extracted channel")
1313
parser.add_argument("-i", "--image", type=str, required=True, help="Input .tif image")
14-
parser.add_argument("--order", type=str, default='2,0,1', help="Transpose dimensions, assuming Y,X,C input dimension order")
15-
14+
parser.add_argument("--order", type=str, default='0,1,2', help="Transpose dimensions, assuming X,Y,C input dimension order")
15+
1616
args = parser.parse_args()
1717

1818
img = tifffile.imread(args.image)
@@ -29,4 +29,4 @@ def main():
2929

3030

3131
if __name__ == "__main__":
32-
main()
32+
main()

bin/extract_image_channel.py

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,52 @@
11
#!/usr/bin/env python
22

3-
# Written by Song Li
4-
# Version 0.0.1
5-
3+
# Written by Song Li & Patrick Crock
4+
# Version 0.0.3 - Made extraction robust to dimension order
65
import sys, os
76
import argparse
8-
import xmltodict
9-
import skimage
7+
import xml.etree.ElementTree as ET
8+
import skimage.io
9+
import tifffile
10+
import numpy as np
1011

1112
def extract_channel(xml, channel_name):
12-
with open(xml, 'r') as file:
13-
xml_str = file.read()
13+
tree = ET.parse(xml)
14+
root = tree.getroot()
1415

15-
xml_dict = xmltodict.parse(xml_str, attr_prefix='', cdata_key='')
16-
channels = xml_dict['OME']['Image']['Pixels']['Channel']
16+
# Handle namespace
17+
ns = {'ome': 'http://www.openmicroscopy.org/Schemas/OME/2016-06'}
18+
channels = root.findall('.//ome:Channel', ns)
1719

18-
for channel in channels:
19-
if channel_name in channel['Name'].upper():
20-
ch_extracted = channel['ID']
21-
ch_extracted = ch_extracted.split(':')[-1]
20+
channel_name_upper = channel_name.upper()
2221

23-
return int(ch_extracted)
22+
for i, channel in enumerate(channels):
23+
name = channel.get('Name', '').upper()
24+
if channel_name_upper in name:
25+
return i # Return the channel index directly
2426

25-
return
27+
return None
2628

2729
def save_channel_image(ch, img_path, out_path):
28-
img = skimage.io.imread(img_path)
29-
channel = img[:,:,3]
30+
with tifffile.TiffFile(img_path) as tif:
31+
arr = tif.asarray()
32+
axes = tif.series[0].axes
33+
print("Array shape:", arr.shape, "Axes:", axes)
34+
35+
if "C" in axes:
36+
c_index = axes.index("C")
37+
elif "S" in axes:
38+
c_index = axes.index("S")
39+
else:
40+
raise RuntimeError(f"No channel axis in axes string {axes}")
41+
42+
# move channel axis to last for consistency
43+
arr = np.moveaxis(arr, c_index, -1)
44+
45+
if ch >= arr.shape[-1]:
46+
print(f"Error: Channel {ch} not found, image has {arr.shape[-1]} channels")
47+
sys.exit(os.EX_SOFTWARE)
48+
49+
channel = arr[..., ch]
3050
skimage.io.imsave(out_path, channel)
3151

3252
def main():
@@ -35,19 +55,18 @@ def main():
3555
parser.add_argument("-i", "--image", type=str, required=True, help="Input .tif image")
3656
parser.add_argument("-x", "--xml", type=str, required=True, help="Metadata .xml for .tif image")
3757
parser.add_argument("-c", "--channel", type=str, default='DAPI', help="Channel name to extract")
38-
39-
args = parser.parse_args()
4058

59+
args = parser.parse_args()
4160
channel_extracted = extract_channel(args.xml, args.channel)
4261

43-
if channel_extracted:
62+
if channel_extracted is not None:
4463
save_channel_image(channel_extracted, args.image, args.output)
64+
print(f"Successfully extracted channel {channel_extracted} ({args.channel}) to {args.output}")
4565
else:
4666
print(f"{args.channel} channel could not be found")
4767
sys.exit(os.EX_SOFTWARE)
4868

4969
return
5070

51-
5271
if __name__ == "__main__":
53-
main()
72+
main()

bin/indicaTIFF_to_ome.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env python
2+
3+
## from: https://forum.image.sc/t/trouble-generating-ome-tiffs-in-the-right-shape/89191/7
4+
5+
import argparse
6+
from xml.etree import ElementTree
7+
from tifffile import TiffFile, TiffWriter
8+
from xml.dom import minidom
9+
10+
def tiles(series):
11+
# yield raw tiles from all pages in TIFF series
12+
fh = series.parent.filehandle
13+
for page in series:
14+
for offset, bytecount in zip(page.dataoffsets, page.databytecounts):
15+
fh.seek(offset)
16+
yield fh.read(bytecount)
17+
18+
19+
def main():
20+
parser = argparse.ArgumentParser(description="Convert HALO/Indica tif to OME tif")
21+
parser.add_argument("-o", "--output", type=str, required=True, help="Output .ome.tif file")
22+
parser.add_argument("-i", "--image", type=str, required=True, help="Input .tif image")
23+
24+
args = parser.parse_args()
25+
26+
with TiffFile(args.image) as tif:
27+
print(tif)
28+
29+
tree = ElementTree.fromstring(tif.pages.first.description)
30+
channel_names = [
31+
channel.attrib['name'] for channel in tree.iter('channel')
32+
]
33+
34+
objective_value = float(tree.find('.//objective').attrib['value'])
35+
36+
with TiffWriter(
37+
args.output, bigtiff=True, ome=True, byteorder=tif.byteorder
38+
) as ome:
39+
for series in tif.series:
40+
print("SERIES:", series)
41+
print(" series.axes:", series.axes, "series.shape:", series.shape)
42+
assert series.axes == 'IYX'
43+
assert series.shape[0] == len(channel_names)
44+
45+
for i, level in enumerate(series.levels):
46+
page = level.keyframe
47+
print(" LEVEL", i, "level.shape:", level.shape, "page.keyframe.shape:", getattr(page, 'shape', None))
48+
print(" page.imagewidth:", getattr(page, 'imagewidth', None),
49+
"page.imagelength:", getattr(page, 'imagelength', None))
50+
print(" page.tile:", getattr(page, 'tile', None))
51+
print(" TileWidth/TileLength tags:",
52+
page.tags.get('TileWidth'), page.tags.get('TileLength'))
53+
54+
assert page.is_tiled
55+
56+
# --- compute rows/cols and samples confidently from page tags ---
57+
rows = getattr(page, 'imagelength', None)
58+
cols = getattr(page, 'imagewidth', None)
59+
# number of channels/samples (fall back to level.shape if unsure)
60+
samples = series.shape[0] if series.shape else (level.shape[0] if len(level.shape) == 3 else 1)
61+
62+
# If level.shape looks like (rows, cols, samples) convert to (samples, rows, cols)
63+
if len(level.shape) == 3:
64+
a, b, c = level.shape
65+
if (a == rows and b == cols and c == samples):
66+
inferred_shape = (samples, rows, cols)
67+
elif (a == samples and b == rows and c == cols):
68+
inferred_shape = (a, b, c) # already (samples, rows, cols)
69+
else:
70+
# fallback to explicit (samples, rows, cols)
71+
inferred_shape = (samples, rows, cols)
72+
else:
73+
inferred_shape = (rows, cols)
74+
75+
# get tile dims from tags if present (TileWidth, TileLength)
76+
try:
77+
tile_w = page.tags['TileWidth'].value
78+
tile_l = page.tags['TileLength'].value
79+
tile = (tile_w, tile_l)
80+
except Exception:
81+
tile = page.tile or None
82+
83+
if i == 0:
84+
# base-level metadata construction (unchanged)
85+
if len(series.levels) > 1:
86+
subifds = len(series.levels) - 1
87+
else:
88+
subifds = None
89+
resx, resy = page.get_resolution('micrometer')
90+
91+
# Correct resolution by objective magnification
92+
resx = resx / objective_value
93+
resy = resy / objective_value
94+
95+
metadata = {
96+
'axes': 'CYX',
97+
'PhysicalSizeX': resx,
98+
'PhysicalSizeXUnit': 'µm',
99+
'PhysicalSizeY': resy,
100+
'PhysicalSizeYUnit': 'µm',
101+
'Channel': {'Name': channel_names},
102+
}
103+
else:
104+
subifds = None
105+
metadata = None
106+
107+
dtype = 'float32' if level.dtype == 'uint32' else level.dtype
108+
109+
print(" -> writing with shape:", inferred_shape, "tile:", tile)
110+
111+
ome.write(
112+
tiles(level),
113+
shape=inferred_shape,
114+
dtype=dtype,
115+
photometric=page.photometric,
116+
compression=page.compression,
117+
resolution=page.resolution,
118+
resolutionunit=page.resolutionunit,
119+
tile=tile,
120+
subifds=subifds,
121+
metadata=metadata,
122+
)
123+
124+
return
125+
126+
127+
if __name__ == "__main__":
128+
main()

main.nf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ workflow {
6464
params.input
6565
)
6666

67-
67+
6868
// WORKFLOW: Run main workflow
6969
//
7070
NFCORE_MICROSCOPY (

0 commit comments

Comments
 (0)