Skip to content

Commit 7a3cce1

Browse files
an-altosiannmalwinkannie-altosChristelKrueger
committed
fix(qc): address review feedback on PR #205
- GPU: wire --num-gpus into image_qc.py + set CUDA_VISIBLE_DEVICES in the module so num_gpus actually caps device use; add process_gpu_qc to the aws profile GPU-queue routing; add schema minimum:0. - publishDir: stop double-nesting (qc/image_qc/image_qc -> qc/image_qc) for both QC analysis steps. - Finish molecule->transcript rename in outputs (metrics JSON keys + figure filenames) and the report's reads; fix an exposed figure-name collision. - Wire ch_qc_reports from the QC reports so the MultiQC collection isn't dead. - Schema: move QC params into a dedicated qc_options group. - Make ROI tile size configurable via params.image_qc_roi_size. - Commit the previously-missing pytest unit tests for the transcript QC maths. - Docs: CHANGELOG entry + usage.md QC-mode section. Co-authored-by: Malwina Prater <mprater@altoslabs.com> Co-authored-by: Nell Nie <nnie@altoslabs.com> Co-authored-by: Christel Krueger <ckrueger@altoslabs.com>
1 parent 9c9f84e commit 7a3cce1

11 files changed

Lines changed: 310 additions & 96 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
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+
## dev
7+
8+
### `Added`
9+
10+
- Image QC and transcript QC subworkflow (`QC`): runs `IMAGE_QC_ANALYSIS` (focus / SNR / morphology metrics) and `TRANSCRIPT_QC_PROCESSING` (per-transcript and per-cell metrics), each rendering a Quarto HTML report via the shared `QUARTO` module. New `image_qc`, `transcript_qc`, and `quarto` local modules with pinned `environment.yml` and Dockerfiles.
11+
- GPU-optional image QC via `params.num_gpus` (`null` = CPU; `N` = request and cap to N GPUs via the accelerator directive + `CUDA_VISIBLE_DEVICES`).
12+
- New QC parameters: `num_gpus`, `stain_names`, `neg_control_prefix`, `roi_image_qc_thresholds_yaml`, and the `image_qc_*` analysis flags (with `nextflow_schema.json` entries).
13+
614
## 1.0.1 - [06.08.2026]
715

816
Hotfix to tackle some bugs

assets/notebooks/transcript_qc.qmd

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,12 @@ with open(metrics_file, 'r') as f:
6767
qc_metrics = json.load(f)
6868
6969
print("=== TRANSCRIPT QC SUMMARY ===")
70-
print(f"Total molecules: {qc_metrics['total_molecules']:,}")
71-
print(f"Selected molecules: {qc_metrics['selected_molecules']:,}")
70+
print(f"Total molecules: {qc_metrics['total_transcripts']:,}")
71+
print(f"Selected molecules: {qc_metrics['selected_transcripts']:,}")
7272
print(f"Total features: {qc_metrics['total_features']:,}")
7373
print(f"Total cells: {qc_metrics['total_cells']:,}")
7474
print(f"Analyzed genes: {qc_metrics['analyzed_genes']:,}")
75-
print(f"Min molecules per cell: {qc_metrics['min_molecules_per_cell']:,}")
75+
print(f"Min molecules per cell: {qc_metrics['min_transcripts_per_cell']:,}")
7676
print(f"Min genes per cell: {qc_metrics['min_genes_per_cell']:,}")
7777
```
7878

@@ -167,7 +167,7 @@ Next, we examine the distribution of molecules assigned to each feature across t
167167

168168
```{python display-molecules-per-feature}
169169
#| echo: false
170-
display_figure("num_molecules_per_feature.png")
170+
display_figure("num_transcripts_per_feature.png")
171171
```
172172

173173
**Molecule count distribution interpretation:**
@@ -211,7 +211,7 @@ This plot shows the fraction of detected RNA molecules that are located within t
211211

212212
```{python display-nucleus-rna-fraction}
213213
#| echo: false
214-
display_figure("nucleus_molecule_fraction_per_cell_distribution.png")
214+
display_figure("nucleus_transcript_fraction_per_cell_distribution.png")
215215
```
216216

217217
**Nucleus RNA fraction interpretation:**
@@ -249,7 +249,7 @@ We first show a histogram of molecules per cell. The distribution pattern is cru
249249

250250
```{python display-molecules-per-cell}
251251
#| echo: false
252-
display_figure("num_molecules_per_cell.png")
252+
display_figure("num_transcripts_per_cell.png")
253253
```
254254

255255
**Distribution patterns and their meanings:**
@@ -278,7 +278,7 @@ Similarly, we examine the number of unique genes detected per cell, which is ano
278278

279279
```{python display-genes-per-cell}
280280
#| echo: false
281-
display_figure("num_transcripts_per_cell.png")
281+
display_figure("num_genes_per_cell.png")
282282
```
283283

284284
**Gene detection patterns:**

bin/image_qc.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8780,6 +8780,15 @@ def _check_cell_data_exists(xenium_bundle_dir):
87808780
show_default=True,
87818781
help="Gaussian sigma for Laplacian of Gaussian (LoG) pre-smoothing.",
87828782
)
8783+
@click.option(
8784+
"--num-gpus",
8785+
default=None,
8786+
type=int,
8787+
help=(
8788+
"Maximum number of GPUs to use. If not set, all visible GPUs are used. "
8789+
"Set to 0 to force the CPU backend."
8790+
),
8791+
)
87838792
def main(
87848793
xenium_bundle_dir,
87858794
outdir,
@@ -8795,6 +8804,7 @@ def main(
87958804
save_dapi_maps_tiff,
87968805
roi_thresholds_yaml,
87978806
lap_sigma,
8807+
num_gpus,
87988808
):
87998809
"""
88008810
Combined Xenium Image QC pipeline.
@@ -8960,8 +8970,19 @@ def main(
89608970
else:
89618971
logging.info(f"Using tile size: {roi_size}px")
89628972

8963-
# Auto-detect available GPUs
8973+
# Auto-detect available GPUs, then cap to --num-gpus when requested
89648974
available_gpus = detect_gpu_ids()
8975+
if num_gpus is not None:
8976+
if num_gpus <= 0:
8977+
logging.info("--num-gpus=%s requested, forcing CPU backend", num_gpus)
8978+
available_gpus = []
8979+
elif len(available_gpus) > num_gpus:
8980+
logging.info(
8981+
"Capping GPU usage to %d of %d visible device(s) (--num-gpus)",
8982+
num_gpus,
8983+
len(available_gpus),
8984+
)
8985+
available_gpus = available_gpus[:num_gpus]
89658986
if available_gpus:
89668987
logging.info(f"Detected {len(available_gpus)} GPU(s): {available_gpus}")
89678988
else:

bin/transcript_qc_processing.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -399,18 +399,22 @@ def main():
399399
plt.title("Distribution of molecules per feature", fontsize=14, pad=20)
400400
plt.tight_layout()
401401
plt.savefig(
402-
f"{output_fig_dir}/num_molecules_per_feature.pdf", dpi=300, bbox_inches="tight"
402+
f"{output_fig_dir}/num_transcripts_per_feature.pdf",
403+
dpi=300,
404+
bbox_inches="tight",
403405
)
404406
plt.savefig(
405-
f"{output_fig_dir}/num_molecules_per_feature.png", dpi=300, bbox_inches="tight"
407+
f"{output_fig_dir}/num_transcripts_per_feature.png",
408+
dpi=300,
409+
bbox_inches="tight",
406410
)
407411
plt.close(fig)
408412

409413
# Save data for molecules per feature
410414
df_molecules_per_feature = n_mols_per_gene_df.copy()
411415
df_molecules_per_feature["n_mols_threshold"] = n_mols_threshold
412416
df_molecules_per_feature.to_csv(
413-
figures_source_dir / "num_molecules_per_feature.csv", index=True
417+
figures_source_dir / "num_transcripts_per_feature.csv", index=True
414418
)
415419

416420
retained_genes = n_mols_per_gene_df.query(
@@ -501,14 +505,14 @@ def main():
501505
# Save the plot
502506
plt.savefig(
503507
os.path.join(
504-
outdir, "figures", "nucleus_molecule_fraction_per_cell_distribution.pdf"
508+
outdir, "figures", "nucleus_transcript_fraction_per_cell_distribution.pdf"
505509
),
506510
dpi=300,
507511
bbox_inches="tight",
508512
)
509513
plt.savefig(
510514
os.path.join(
511-
outdir, "figures", "nucleus_molecule_fraction_per_cell_distribution.png"
515+
outdir, "figures", "nucleus_transcript_fraction_per_cell_distribution.png"
512516
),
513517
dpi=300,
514518
bbox_inches="tight",
@@ -524,7 +528,7 @@ def main():
524528
}
525529
)
526530
df_nucleus_fraction.to_csv(
527-
figures_source_dir / "nucleus_molecule_fraction_per_cell_distribution.csv",
531+
figures_source_dir / "nucleus_transcript_fraction_per_cell_distribution.csv",
528532
index=False,
529533
)
530534
del cells_parquet
@@ -597,10 +601,10 @@ def main():
597601
plt.title("Distribution of molecules per Cell", fontsize=14, pad=20)
598602
plt.tight_layout()
599603
plt.savefig(
600-
f"{output_fig_dir}/num_molecules_per_cell.pdf", dpi=300, bbox_inches="tight"
604+
f"{output_fig_dir}/num_transcripts_per_cell.pdf", dpi=300, bbox_inches="tight"
601605
)
602606
plt.savefig(
603-
f"{output_fig_dir}/num_molecules_per_cell.png", dpi=300, bbox_inches="tight"
607+
f"{output_fig_dir}/num_transcripts_per_cell.png", dpi=300, bbox_inches="tight"
604608
)
605609
plt.close(fig)
606610
print(f"Threshold for molecules per cell: {n_mols_threshold_cell}")
@@ -613,7 +617,7 @@ def main():
613617
}
614618
)
615619
df_molecules_per_cell.to_csv(
616-
figures_source_dir / "num_molecules_per_cell.csv", index=False
620+
figures_source_dir / "num_transcripts_per_cell.csv", index=False
617621
)
618622

619623
# Convert numpy array to pandas DataFrame
@@ -635,20 +639,18 @@ def main():
635639
plt.title("Distribution of number of detected genes per Cell", fontsize=14, pad=20)
636640
plt.tight_layout()
637641
plt.savefig(
638-
f"{output_fig_dir}/num_transcripts_per_cell.pdf", dpi=300, bbox_inches="tight"
642+
f"{output_fig_dir}/num_genes_per_cell.pdf", dpi=300, bbox_inches="tight"
639643
)
640644
plt.savefig(
641-
f"{output_fig_dir}/num_transcripts_per_cell.png", dpi=300, bbox_inches="tight"
645+
f"{output_fig_dir}/num_genes_per_cell.png", dpi=300, bbox_inches="tight"
642646
)
643647
plt.close(fig)
644648

645649
# Save data for genes per cell
646650
df_genes_per_cell = pd.DataFrame(
647651
{"n_genes_per_cell": n_genes_per_cell, "n_genes_threshold": n_genes_threshold}
648652
)
649-
df_genes_per_cell.to_csv(
650-
figures_source_dir / "num_transcripts_per_cell.csv", index=False
651-
)
653+
df_genes_per_cell.to_csv(figures_source_dir / "num_genes_per_cell.csv", index=False)
652654

653655
# Convert numpy array to pandas DataFrame
654656
n_genes_per_cell_df = pd.DataFrame(n_genes_per_cell, columns=["num_of_genes"])
@@ -659,14 +661,14 @@ def main():
659661

660662
# EXACT CODE FROM ORIGINAL NOTEBOOK - Save metrics
661663
metrics = {
662-
"total_molecules": int(num_molecules),
663-
"selected_molecules": int(num_selected_molecules),
664+
"total_transcripts": int(num_molecules),
665+
"selected_transcripts": int(num_selected_molecules),
664666
"total_features": int(df_spatial.feature_name.nunique()),
665667
"codeword_category_counts": {
666668
str(k): int(v) for k, v in codeword_category_counts.items()
667669
},
668670
"neg_control_quantile": int(n_mols_threshold),
669-
"min_molecules_per_cell": int(n_mols_threshold_cell),
671+
"min_transcripts_per_cell": int(n_mols_threshold_cell),
670672
"min_genes_per_cell": int(n_genes_threshold),
671673
"retained_genes_count": len(retained_genes),
672674
"total_cells": int(ad.shape[0]),

conf/modules.config

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ process {
385385
ext.prefix = 'image_qc'
386386
ext.args = {
387387
[
388+
params.image_qc_roi_size != null ? "--roi-size ${params.image_qc_roi_size}" : '',
388389
params.legacy_focus ? '--legacy-focus' : '',
389390
params.image_qc_no_snr ? '--no-snr' : '',
390391
params.image_qc_snr_no_roi_tx_table ? '--snr-no-roi-tx-table' : '',
@@ -394,8 +395,9 @@ process {
394395
params.image_qc_lap_sigma != null ? "--lap-sigma ${params.image_qc_lap_sigma}" : '',
395396
].findAll { it }.join(' ')
396397
}
398+
// output dir is already named 'image_qc' (ext.prefix), so publish into qc/ (not qc/image_qc) to avoid qc/image_qc/image_qc
397399
publishDir = [
398-
path: { "${params.outdir}/${params.mode}/qc/image_qc" },
400+
path: { "${params.outdir}/${params.mode}/qc" },
399401
mode: params.publish_dir_mode,
400402
]
401403
}
@@ -413,8 +415,9 @@ process {
413415

414416
withName: '.*TRANSCRIPT_QC:ANALYSIS' {
415417
ext.prefix = 'transcript_qc'
418+
// output dir is already named 'transcript_qc' (ext.prefix), so publish into qc/ (not qc/transcript_qc)
416419
publishDir = [
417-
path: { "${params.outdir}/${params.mode}/qc/transcript_qc" },
420+
path: { "${params.outdir}/${params.mode}/qc" },
418421
mode: params.publish_dir_mode,
419422
]
420423
}

docs/usage.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ nextflow run nf-core/spatialaxe \
7171
--mode segfree
7272
```
7373

74+
### QC mode
75+
76+
`IMAGE_QC ➔ TRANSCRIPT_QC`
77+
78+
Runs the quality-control layer only — image QC (focus / SNR / morphology) and transcript QC (per-transcript and per-cell metrics), each producing an HTML report. The QC layer also runs as part of the other modes (`run_qc = true` by default).
79+
80+
```bash
81+
nextflow run nf-core/spatialaxe \
82+
-profile <docker/singularity/.../institute> \
83+
--input samplesheet.csv \
84+
--outdir <OUTDIR> \
85+
--mode qc
86+
```
87+
88+
Image QC is GPU-optional: leave `--num_gpus` unset for CPU, or set `--num_gpus N` to request and use N GPUs (requires a GPU-enabled profile/queue).
89+
7490
### Preview mode <br>
7591

7692
`BAYSOR_PREVIEW`

modules/local/image_qc/main.nf

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,20 @@ process IMAGE_QC_ANALYSIS {
5656
args << "--stain-names 'DAPI;Boundary (ATP1A1/E-Cadherin/CD45);Interior - RNA (18S);Protein (alphaSMA/Vimentin)'"
5757
}
5858

59-
// ROI size parameter
60-
if (param_map.containsKey('ROI_SIZE') && param_map['ROI_SIZE']) {
61-
args << "--roi-size ${param_map['ROI_SIZE']}"
62-
}
63-
else {
64-
args << "--roi-size 35"
59+
// --roi-size comes from conf/modules.config ext.args (params.image_qc_roi_size),
60+
// keeping this module parameter-agnostic.
61+
62+
// GPU cap: pass --num-gpus to the script only when the user set params.num_gpus.
63+
// Mirror modules/local/segger/train pattern -- constrain CUDA_VISIBLE_DEVICES so
64+
// the process never grabs more GPUs than requested (accelerator directive from
65+
// conf/base.config process_gpu_qc already requests this many devices).
66+
def num_gpus = params.num_gpus
67+
def cuda_visible = ''
68+
if (num_gpus != null) {
69+
args << "--num-gpus ${num_gpus}"
70+
cuda_visible = (num_gpus as int) > 0
71+
? "export CUDA_VISIBLE_DEVICES=" + (0..<(num_gpus as int)).join(',')
72+
: "export CUDA_VISIBLE_DEVICES="
6573
}
6674

6775
// Analysis-tuning flags (--legacy-focus, --no-snr, --snr-*, --save-dapi-maps-tiff,
@@ -72,6 +80,7 @@ process IMAGE_QC_ANALYSIS {
7280
export OPENBLAS_NUM_THREADS="${task.cpus}"
7381
export OMP_NUM_THREADS="${task.cpus}"
7482
export NUMBA_NUM_THREADS="${task.cpus}"
83+
${cuda_visible}
7584
7685
image_qc.py \\
7786
${args.join(' \\\n ')} \\

nextflow.config

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ params {
118118
stain_names = null // semicolon-separated stain/channel names for the morphology image (null = image QC defaults)
119119
neg_control_prefix = 'NegControl' // prefix for Xenium negative controls (matches both NegControlProbe_* and NegControlCodeword_*); passed to transcript QC as --non-gene-prefix
120120
roi_image_qc_thresholds_yaml = null // path to ROI threshold config for image QC (null = bundled conf/roi_image_qc_thresholds.yaml)
121+
image_qc_roi_size = 35 // image QC: ROI tile size in pixels for the grid focus/SNR analysis
121122
legacy_focus = false // image QC: use the CPU for-loop focus score instead of GPU convolution
122123
image_qc_no_snr = false // image QC: skip signal-to-noise (SNR) metrics
123124
image_qc_snr_no_roi_tx_table = false // image QC: do not write the per-ROI transcript SNR table
@@ -296,6 +297,16 @@ profiles {
296297
containerOptions = { "--shm-size ${task.memory.toGiga()}g" }
297298
queue = { params.cellpose_queue ?: params.gpu_queue ?: null }
298299
}
300+
withLabel:process_gpu_qc {
301+
// Must repeat base.config label properties — profile withLabel replaces, not merges.
302+
// Route image QC to the GPU queue whenever an accelerator is requested (params.num_gpus).
303+
accelerator = { params.num_gpus ? (params.num_gpus as int) : null }
304+
cpus = { 30 * task.attempt }
305+
memory = { 180.GB * task.attempt }
306+
time = { 8.h * task.attempt }
307+
containerOptions = { "--shm-size ${task.memory.toGiga()}g" }
308+
queue = { params.gpu_queue ?: null }
309+
}
299310
}
300311
}
301312
test { includeConfig 'conf/test.config' }

0 commit comments

Comments
 (0)