Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ This file contains all notable changes to Bambu-Pipe.

---

## [v0.10.1] - 2026-08-26

### Added
- `--loupe_alignment` for Visium Spatial Gene Expression samples, taking the manual alignment `.json` exported after fiducial alignment and tissue detection in Loupe Browser. Required for `visium-v*` samples
- Out-of-tissue barcodes are filtered from the BAM before transcript discovery and quantification
- Introduce the `VISIUM_BUILD_TISSUE_POSITIONS` module, which builds the tissue positions file for `visium-v*` samples; the spatial metadata in this file is attached to the `colData` of the `SummarizedExperiment` objects
- Loupe alignment example (`examples/loupe_alignment_visium_example.json`), used by the `test_visium` smoke test
- `CB`/`UB` tags in aligned BAM files (minimap2 `-y`), carrying the barcode and UMI from the FASTQ header comments

### Changed
- The spatial metadata attached to Visium `SummarizedExperiment` objects now follows the Space Ranger tissue positions format (`barcode`, `in_tissue`, `array_row`, `array_col`, `pxl_row_in_fullres`, `pxl_col_in_fullres`), replacing the `x_coordinate`/`y_coordinate` columns
- `FILTER_BARCODED_BAM` moved to `modules/prepare_input/shared/` and is used by both the standard Visium and Visium HD workflows; it now fails when no reads remain after filtering
- User-supplied BAM files must carry the barcode and UMI in the `CB`/`UB` tags; barcodes encoded in the read name are no longer supported
Comment thread
ch99l marked this conversation as resolved.

### Fixed
- flexiplex-filter's knee detection could discard most in-tissue barcodes for Visium samples; the inflection search now covers the whole barcode rank curve (`-u 0`) for `visium-v*` chemistries

## [v0.10.0] - 2026-08-17

### Added
Expand Down
115 changes: 74 additions & 41 deletions README.md

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions bin/visium_build_tissue_positions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Build the tissue positions CSV (Space Ranger format) for visium-v* samples from the
Loupe manual alignment JSON and the chemistry's coordinates file. Also writes the
in-tissue barcode list used by samtools to filter out-of-tissue barcodes from the BAM.
"""
import argparse
import csv
import json
import sys


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("loupe_alignment", help="Loupe manual alignment .json")
parser.add_argument("spatial_coordinates", help="chemistry coordinates file (barcode, x, y; 1-based)")
parser.add_argument("tissue_positions", help="output tissue positions CSV")
parser.add_argument("tissue_barcodes", help="output in-tissue barcode list")
args = parser.parse_args()

with open(args.loupe_alignment) as f:
oligos = json.load(f)["oligo"]
spots = {(o["row"], o["col"]): o for o in oligos}

coords = {}
with open(args.spatial_coordinates) as f:
for line in f:
barcode, x, y = line.split()
coords[(int(y) - 1, int(x) - 1)] = barcode

# the JSON grid and the (shifted) coordinates grid must coincide exactly
if set(spots) != set(coords):
sys.exit(
f"error: the {len(spots)} spots in {args.loupe_alignment} do not match the "
f"{len(coords)}-spot layout in {args.spatial_coordinates} -- check that the "
"alignment JSON was made for this slide chemistry"
)

n_tissue = 0
with open(args.tissue_positions, "w", newline="") as fc, open(args.tissue_barcodes, "w") as fb:
writer = csv.writer(fc)
writer.writerow(["barcode", "in_tissue", "array_row", "array_col",
"pxl_row_in_fullres", "pxl_col_in_fullres"])
for (row, col), oligo in sorted(spots.items(), key=lambda item: item[0]):
in_tissue = int(bool(oligo.get("tissue")))
writer.writerow([coords[(row, col)], in_tissue, row, col,
oligo["imageY"], oligo["imageX"]])
if in_tissue:
fb.write(coords[(row, col)] + "\n")
fb.write(coords[(row, col)] + "-1\n")
n_tissue += 1

if n_tissue == 0:
sys.exit("error: no spot in the alignment JSON is marked as tissue")
print(f"{n_tissue} of {len(spots)} spots are in tissue")


if __name__ == "__main__":
main()
5 changes: 3 additions & 2 deletions conf/smoke_test.config
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ profiles {

test_visium {
params {
input = "${projectDir}/examples/samplesheet_test_visium.csv"
output_dir = "${projectDir}/.smoke_test/test_visium/output"
input = "${projectDir}/examples/samplesheet_test_visium.csv"
loupe_alignment = "${projectDir}/examples/loupe_alignment_visium_example.json"
output_dir = "${projectDir}/.smoke_test/test_visium/output"
}
}

Expand Down
1 change: 1 addition & 0 deletions examples/loupe_alignment_visium_example.json

Large diffs are not rendered by default.

20 changes: 19 additions & 1 deletion lib/Validation.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ class Validation {
throw new Exception("params.barcode_mappings '${params.barcode_mappings}' must be a .parquet file")
}
}

// Loupe alignment checks (standard Visium only)
if (params.loupe_alignment != null) {
if (params.visium_hd)
throw new Exception("params.loupe_alignment is not used by the Visium HD workflow — tissue filtering uses the tissue_positions parquet files instead")

if (!params.loupe_alignment.exists())
throw new Exception("params.loupe_alignment '${params.loupe_alignment}' does not exist")

if (params.loupe_alignment.extension != 'json')
throw new Exception("params.loupe_alignment '${params.loupe_alignment}' must be a .json file")
}
}

static def validateVisiumHDRows(rows) {
Expand Down Expand Up @@ -107,10 +119,13 @@ class Validation {
throw new Exception("params.clustering_bin '${clusteringBin}' is not listed in params.bins '${bins}' — available bins: ${resolutions.join(', ')}")
}

static def validateVisiumSampleCount(samples) {
static def validateVisiumSampleCount(samples, loupeAlignment) {
def has_visium = samples.any { sample, path, meta -> meta.chemistry.startsWith('visium') }
if (has_visium && samples.size() > 1)
throw new Exception("Visium chemistry requires exactly 1 sample, but found ${samples.size()}")

if (loupeAlignment != null && !has_visium)
throw new Exception("params.loupe_alignment was provided but no sample uses a visium chemistry")
}

static def validateRow(row, params, log) {
Expand All @@ -130,6 +145,9 @@ class Validation {

if (!params.valid_technologies.contains(row.technology))
throw new Exception("Sample '${row.sample}' has invalid technology '${row.technology}' — must be one of: ${params.valid_technologies.join(', ')}")

if (row.chemistry.startsWith('visium') && params.loupe_alignment == null)
throw new Exception("Visium sample '${row.sample}' requires params.loupe_alignment — a Loupe manual alignment .json with the tissue selection")
}

}
18 changes: 16 additions & 2 deletions main.nf
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ include { EXTRACT_SPOT_BARCODES } from './modules/bambu/visium_hd
include { MAP_CLUSTERS_TO_2UM_SPOTS } from './modules/bambu/visium_hd/map_clusters_to_2um_spots.nf'
include { CONVERT_BARCODE_MAPPINGS } from './modules/prepare_input/visium_hd/convert_barcode_mappings.nf'
include { SPOT_BIN_MAPPINGS } from './modules/prepare_input/visium_hd/spot_bin_mappings.nf'
include { FILTER_BARCODED_BAM } from './modules/prepare_input/shared/filter_barcoded_bam.nf'
include { SEURAT_VISIUM_HD } from './modules/seurat/visium_hd/clustering.nf'

params {
Expand All @@ -39,6 +40,7 @@ params {
deduplicate_umis: Boolean
quantification_mode: String
seurat_resolution: Float
loupe_alignment: Path?
visium_hd: Boolean
barcode_mappings: Path?
bins: Path?
Expand All @@ -56,6 +58,7 @@ workflow STANDARD {
ch_annotation: Path
ndr: Float?
manual_clustering: Boolean
loupe_alignment: Path?

main:
if (!manual_clustering) {
Expand All @@ -68,7 +71,7 @@ workflow STANDARD {

ch_n_samples = ch_rows.count()

PREPARE_INPUT_STANDARD(ch_rows, ch_barcode_coordinate_config)
PREPARE_INPUT_STANDARD(ch_rows, ch_barcode_coordinate_config, loupe_alignment)

// input files are split by type (fastq, bam)
ch_input_fastq = PREPARE_INPUT_STANDARD.out.fastq
Expand All @@ -82,6 +85,17 @@ workflow STANDARD {
if (!params.bam_only) {
// process bam samples
ch_bam_files = ALIGNMENT.out.bam.mix(ch_input_bam)

// remove out-of-tissue barcodes from visium BAMs using the Loupe alignment's tissue selection
if (loupe_alignment != null) {
ch_bam_branched = ch_bam_files.branch { _sample, _path, meta ->
visium: meta.chemistry.startsWith('visium')
other: true
}
FILTER_BARCODED_BAM(ch_bam_branched.visium, PREPARE_INPUT_STANDARD.out.tissue_barcodes)
ch_bam_files = FILTER_BARCODED_BAM.out.bam.mix(ch_bam_branched.other)
}

BAMBU_PREPARE_ANNOTATION(ch_annotation)
BAMBU_CONSTRUCT_READ_CLASS(ch_bam_files, ch_genome, BAMBU_PREPARE_ANNOTATION.out.annotation)

Expand Down Expand Up @@ -231,7 +245,7 @@ workflow {
if (params.visium_hd) {
VISIUM_HD(ch_rows, ch_genome, ch_annotation, params.ndr, params.manual_clustering)
} else {
STANDARD(ch_rows, ch_genome, ch_annotation, params.ndr, params.manual_clustering)
STANDARD(ch_rows, ch_genome, ch_annotation, params.ndr, params.manual_clustering, params.loupe_alignment)
}

channel.topic('versions').collectFile(name: 'software_versions.yml', storeDir: "${params.output_dir}")
Expand Down
2 changes: 1 addition & 1 deletion modules/alignment/align.nf
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ process MINIMAP_ALIGNMENT{
preset="splice"
fi

minimap2 -ax \$preset -uf --junc-bed $bed -t $task.cpus $ref_mmi $newfastq | \
minimap2 -ax \$preset -y -uf --junc-bed $bed -t $task.cpus $ref_mmi $newfastq | \
samtools sort -@ $task.cpus -o ${sample}_demultiplexed.bam
samtools index -@ $task.cpus ${sample}_demultiplexed.bam

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ process FILTER_BARCODED_BAM {
samtools view -@ $task.cpus -D CB:$barcodes -o ${sample}_filtered.bam $bam
samtools index -@ $task.cpus ${sample}_filtered.bam

if [[ \$(samtools view -c -@ $task.cpus ${sample}_filtered.bam) -eq 0 ]]; then
echo "No reads remain after barcode filtering -- check that the BAM carries CB tags matching $barcodes" >&2
exit 1
fi

cat <<-END_VERSIONS > versions.yml
"${task.process}":
samtools: \$(samtools --version 2>&1 | head -1)
Expand Down
5 changes: 2 additions & 3 deletions modules/prepare_input/standard/extract_spatial_coordinates.nf
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ process EXTRACT_10X_SPATIAL_COORDINATES {
path(barcode_coordinate_config)

output:
tuple val(chemistry), path("${chemistry}_spatial_coordinates.txt"), emit: spatial_coordinates
path("spatial_coordinates.txt"), emit: spatial_coordinates

script:
"""
# extract spatial coordinate file path from config csv
IFS=',' read -r _ _ sc_filename < <(awk -F',' -v chem=$chemistry '\$1 == chem' $barcode_coordinate_config)

cp $params.cellranger_dir/\$sc_filename ./${chemistry}_spatial_coordinates.txt
sed -i '1ibarcode\tx_coordinate\ty_coordinate' ./${chemistry}_spatial_coordinates.txt
cp $params.cellranger_dir/\$sc_filename ./spatial_coordinates.txt
"""
}
27 changes: 27 additions & 0 deletions modules/prepare_input/standard/visium_build_tissue_positions.nf
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
process VISIUM_BUILD_TISSUE_POSITIONS {
publishDir "$params.output_dir/intermediate_visium", mode: 'copy', pattern: 'tissue_positions.csv', enabled: params.save_intermediates
publishDir "$params.output_dir/intermediate_visium", mode: 'copy', pattern: 'tissue_barcodes.txt', enabled: params.save_intermediates
label "pyarrow_pandas"
label "low_cpu"
label "low_mem"
label "short"

input:
path(spatial_coordinates)
path(loupe_alignment)

output:
path('tissue_positions.csv'), emit: tissue_positions
path('tissue_barcodes.txt'), emit: tissue_barcodes
path "versions.yml", topic: 'versions'

script:
"""
visium_build_tissue_positions.py $loupe_alignment $spatial_coordinates tissue_positions.csv tissue_barcodes.txt

cat <<-END_VERSIONS > versions.yml
"${task.process}":
python: \$(python3 --version 2>&1)
END_VERSIONS
"""
}
9 changes: 8 additions & 1 deletion modules/preprocess_fastq.nf
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,15 @@ process PREPROCESS_FASTQ {
flank_seq="-x \$left_flank -b \$barcode -u \$umi -x \$right_flank"

# Flexiplex filter (barcode filtering)
# For visium chemistries, search the whole rank curve instead
# (previous runs using the default parameters failed to search the whole rank curve for the inflection point)
if [[ $meta.chemistry == visium-v* ]]; then
max_rank_arg="-u 0"
else
max_rank_arg=""
fi
flexiplex -p $task.cpus \$flank_seq -f 0 ${sample}_chopper_out.fastq
flexiplex-filter -w $whitelist --outfile my_filtered_barcode_list.txt flexiplex_barcodes_counts.txt
flexiplex-filter \$max_rank_arg -w $whitelist --outfile my_filtered_barcode_list.txt flexiplex_barcodes_counts.txt

# Chain commands: Flexiplex demultiplexing -> (Optional) Save intermediate file after flexiplex -> Cutadapt trimming of reverse primer -> \
# Cutadapt re-search for all primer and TSO sequences to remove non-standard reads -> Reverse complementation of reads for 3' and visium chemistry -> Compression with pigz
Expand Down
9 changes: 8 additions & 1 deletion nextflow.config
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ manifest {
name = 'Bambu-Pipe'
description = 'Context-aware transcript quantification from long-read single-cell and spatial transcriptomics data'
author = 'Andre Sim, Chin Hao Lee, Min Hao Ling'
version = 'v0.9-beta'
version = 'v0.10.1'
mainScript = 'main.nf'
nextflowVersion = '!>=26.04.0'
}
Expand Down Expand Up @@ -47,6 +47,13 @@ params {
*/
manual_clustering = false // boolean

/*
Optional: Visium
Note: Set --loupe_alignment to point to the manual alignment .json file exported from
Loupe Browser. Required for visium-v* samples.
*/
loupe_alignment = null

/*
Optional: Visium HD
Note: Set --visium_hd true for a single-sample run starting from a pre-aligned, barcode-tagged BAM file.
Expand Down
43 changes: 27 additions & 16 deletions subworkflows/prepare_input_standard.nf
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
include { EXTRACT_10X_BARCODES } from '../modules/prepare_input/standard/extract_barcodes.nf'
include { EXTRACT_10X_SPATIAL_COORDINATES } from '../modules/prepare_input/standard/extract_spatial_coordinates.nf'
include { VISIUM_BUILD_TISSUE_POSITIONS } from '../modules/prepare_input/standard/visium_build_tissue_positions.nf'

workflow PREPARE_INPUT_STANDARD {
take:
ch_rows
ch_barcode_coordinate_config
loupe_alignment

main:
// parse samplesheet rows into channel of tuples (sample, path, metadata)
Expand All @@ -15,8 +17,8 @@ workflow PREPARE_INPUT_STANDARD {
[row.sample, sample_path, meta]
}

// validate: ensure only one visium sample is processed at a time
ch_samples.collect(flat: false).map { samples -> Validation.validateVisiumSampleCount(samples) }
// validate: for visium runs, ensure only one sample is processed at a time
ch_samples.collect(flat: false).map { samples -> Validation.validateVisiumSampleCount(samples, loupe_alignment) }

// extract distinct chemistries from metadata
ch_unique_chem = ch_samples.map { _sample, _path, meta -> meta.chemistry }.unique()
Expand All @@ -28,23 +30,31 @@ workflow PREPARE_INPUT_STANDARD {
ch_barcodes = EXTRACT_10X_BARCODES.out.barcodes
.mix(ch_unique_custom.map { chem -> [chem, null] })

// extract spatial coordinates for visium chemistries only; add placeholder for non-visium samples
ch_unique_visium = ch_unique_chem.filter { chem -> chem.startsWith('visium') }
ch_unique_non_visium = ch_unique_chem.filter { chem -> !chem.startsWith('visium') }
// extract spatial coordinates for visium chemistries only
ch_unique_visium = ch_unique_chem.filter { chem -> chem.startsWith('visium') }
EXTRACT_10X_SPATIAL_COORDINATES(ch_unique_visium, ch_barcode_coordinate_config)
ch_spatial_coordinates = EXTRACT_10X_SPATIAL_COORDINATES.out.spatial_coordinates
.mix(ch_unique_non_visium.map { chem -> [chem, null] })

// update metadata with barcode and spatial coordinate paths
ch_updated_samples = ch_samples.map { sample, path, meta -> [meta.chemistry, sample, path, meta] }
// update sample tuples with the chemistry's barcode whitelist
ch_keyed_samples = ch_samples.map { sample, path, meta -> [meta.chemistry, sample, path, meta] }
.combine(ch_barcodes, by: 0)
.combine(ch_spatial_coordinates, by: 0)
.map { _chem, sample, path, meta, bc, sc ->
def updated_meta = meta + [
barcode: bc,
spatial_metadata: sc
]
return [sample, path, updated_meta]

// build the tissue positions (spatial metadata) from the Loupe manual alignment file and the spatial
// coordinate whitelist file. Also generate the in-tissue barcode list which will be used to filter
// the BAM file.
if (loupe_alignment != null) {
VISIUM_BUILD_TISSUE_POSITIONS(EXTRACT_10X_SPATIAL_COORDINATES.out.spatial_coordinates, loupe_alignment)
ch_tissue_barcodes = VISIUM_BUILD_TISSUE_POSITIONS.out.tissue_barcodes.first()
ch_updated_samples = ch_keyed_samples
.combine(VISIUM_BUILD_TISSUE_POSITIONS.out.tissue_positions.first())
.map { _chem, sample, path, meta, bc, sc ->
[sample, path, meta + [barcode: bc, spatial_metadata: sc]]
}
} else {
ch_tissue_barcodes = channel.empty()
ch_updated_samples = ch_keyed_samples
.map { _chem, sample, path, meta, bc ->
[sample, path, meta + [barcode: bc, spatial_metadata: null]]
}
}

// split samples by file type
Expand All @@ -55,4 +65,5 @@ workflow PREPARE_INPUT_STANDARD {
emit:
fastq = ch_fastq
bam = ch_bam
tissue_barcodes = ch_tissue_barcodes
}
2 changes: 1 addition & 1 deletion subworkflows/prepare_input_visium_hd.nf
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
include { FILTER_BARCODED_BAM } from '../modules/prepare_input/visium_hd/filter_barcoded_bam.nf'
include { FILTER_BARCODED_BAM } from '../modules/prepare_input/shared/filter_barcoded_bam.nf'
include { CONVERT_BARCODE_MAPPINGS } from '../modules/prepare_input/visium_hd/convert_barcode_mappings.nf'
include { CONVERT_TISSUE_POSITIONS } from '../modules/prepare_input/visium_hd/convert_tissue_positions.nf'

Expand Down
Loading