Add image QC and transcript QC subworkflow (ported from nf-xenium-processing dev HEAD) - #210
Closed
an-altosian wants to merge 6 commits into
Closed
Add image QC and transcript QC subworkflow (ported from nf-xenium-processing dev HEAD)#210an-altosian wants to merge 6 commits into
an-altosian wants to merge 6 commits into
Conversation
…v HEAD Ports the QC layer from the internal nf-xenium-processing repo at dev HEAD 5e35cae onto nf-core/spatialaxe dev, alongside the existing spoQC subworkflow. Faithfulness to the source was the priority, because the previous attempt shipped a stale, partial snapshot: its transcript QC was 695 lines against upstream's 1344, its report notebook 333 against 1861, and transcript_stream.py and the threshold config were missing entirely. Reviewers spent their time re-deriving issues that current upstream had already fixed. Provenance, verified rather than asserted: - bin/snr_metrics.py, bin/xenium_image_qc_report.qmd, bin/roi_image_qc_thresholds.yaml, bin/transcript_stream.py: byte-identical to upstream 5e35cae. - bin/image_qc.py: exactly three diff hunks vs upstream, none touching logic — a typing import, removal of the xenium_helpers import region, and the inlined helper block replacing it. The seven inlined symbols are byte-identical to upstream's xenium_helpers.utils (AST-compared). - bin/transcript_qc_processing.py, bin/transcript_qc.qmd, bin/transcript_qc_thresholds.yaml: upstream plus a mechanical molecule -> transcript rename. The rename is recorded as an ordered, replayable rule set; replaying it on pristine upstream reproduces the notebook and threshold YAML byte-for-byte, so re-syncing stays a mechanical step rather than a merge. xenium_helpers is dropped completely: the needed helpers are inlined into each script verbatim, with no shared shim module. This picks up four upstream fixes the stale port missed: the under-scaled noise threshold (now a separate scaled_noise_threshold function), unbounded host memory (now a streaming reader via transcript_stream.py), figure names that did not match their contents, and uncomputed unassigned% / per-FoV QV. Pipeline integration: - nf-core quarto/notebook renders both reports. The older quartonotebook module is deprecated upstream and now hard-asserts, so it is not used. - The versions topic collector now keeps only (process, tool, version) tuples: quarto/notebook also publishes a versions.yml path into that topic, which broke the destructuring. - Resources come from dev's own labels rather than ad-hoc numbers. Image QC carries process_gpu_qc (GPU request only) plus process_xl, which matches what a 230K-cell production bundle actually needed; transcript QC uses process_high, matching upstream's allocation. - process_gpu_qc is defined in conf/base.config and routed in the aws profile; it previously existed unrouted and, on the old branch, inside an unclosed block, so it had never actually applied. - The QC processes read no params.* — every knob arrives as ext.* from conf/modules.config. Four of those keys must stay explicit because the module tests them negated or interpolates them unconditionally. - publishDir no longer double-nests: analysis and report both land in <mode>/qc/<step>/. - 18 QC parameters are declared under the schema's QC options group, with a minimum on image_qc_gpus so a negative device count is rejected at launch; run_qc moved out of Segmentation options. Also fixes four pre-existing mypy failures unrelated to QC (Pillow 12 dropped Image.NEAREST; a rebound tuple annotation; a headerless-CSV None; two str arguments declared as Path), so `make check` passes. mypy.ini excludes only the four vendored QC scripts, since annotating them locally would fork them from upstream. Validated: full -stub run in qc mode executes all four QC processes and publishes reports and metrics; make check clean (ruff, mypy over 27 files, pre-commit); nf-core schema lint 468 passed / 0 failed. Co-authored-by: Malwina Prater <mprater@altoslabs.com> Co-authored-by: Nell Nie <nnie@altoslabs.com> Co-authored-by: Christel Krueger <ckrueger@altoslabs.com>
Reviewer items that needed real fixes (numbering from the PR review):
- 6: --non-gene-prefix and --stain-names had no `nargs`, so the multi-value
form that nextflow_schema.json documents and the module emits (it splits on
';' into separate tokens) exited with argparse error 2. Added nargs="+".
Verified by driving parse_args: two values previously raised SystemExit(2)
and now parse to a list.
- 9: the CuPy import guard caught only ImportError. The container ships
cupy-cuda12x with CUDA runtime wheels but no driver, which raises
RuntimeError/CUDARuntimeError at import — neither an ImportError — so the
intended CPU-only default path aborted instead of falling back. Broadened
the guard. Verified with a stub cupy raising RuntimeError at import.
- 7: estimate_min_mols_per_cell raises from np.quantile on an empty `> mode`
slice, reachable with zero/one cell or zero retained genes (numpy raises
IndexError, not ValueError). Guarded at the two call sites rather than inside
the vendored helper, so the helper stays byte-identical to upstream. A
degenerate input now yields "not computable" (None -> null in the metrics
JSON, cutoff line omitted) instead of an exception; returning the helper's
min_value would have published a real-looking noise floor for empty data.
Normal inputs are unchanged (verified: 48 before and after).
- 5: --non-gene-prefix was parsed and then ignored — the selection hard-coded
startswith("NegControl"). Threaded the prefixes through
scaled_noise_threshold and changed the script default from "NegControlProbe"
to "NegControl" so the default reproduces the previous hard-coded behaviour
exactly and matches the pipeline default; wiring it without that change would
have moved the threshold. Verified: the parameter now changes the threshold
across prefix sets, and the default is identical to the old literal.
- 11: the ROI threshold channel keys were bridged by a hand-written
lowercase-to-mixed-case dict, and a drift between it and the YAML silently
fell back to a 15% WARN fraction where the YAML says 40% — 2.67x stricter
DAPI grading with nothing in the output revealing it. Replaced with a
case-insensitive resolve that raises when a populated config is missing an
expected channel. All resolved values verified unchanged for all three
channels, with and without YAML, and for every XOA-version branch. The same
pattern in _pick_critical was fixed too; without it every intensity floor
silently reverted to the defaults, discarding the XOA-4 values.
Items 8 and 10 needed no code change and are recorded for the reviewers:
10 (the report instructed filtering at QV<=20 while nothing filtered) is gone
in current upstream, and the notebook now states explicitly that the threshold
uses only negative-control features — so the claim matches the code. 8
(--stain-names parsed then discarded) is confirmed still dead: every figure
title and metric key is hard-coded, and the metric keys are the contract the
threshold YAML and the report read, so making stain names rewrite them would
break that contract. Left for a deliberate decision.
CI: nothing ran the QC Python code, which is why these defects survived. Added
.github/workflows/python-tests.yml. It builds a runtime environment rather than
the linting one, because both scripts import matplotlib/tifffile/scanpy at
module scope and cannot even be collected otherwise. The two QC module
environments cannot be co-installed (image QC pins zarr 2.x while scanpy pulls
zarr 3.x), so bin/tests/environment.yml declares one coherent set.
Also repairs the Python suite itself: tests/test_xenium_patch/test_stitch_-
transcripts.py imported the script from an upstream-only
modules/local/.../resources/usr/bin path that does not exist here, so it failed
collection and took the whole pytest run down with it (17 passed, 64 errors
before this). It now loads bin/stitch_transcripts.py, raises a clear error if
that ever moves, and skips cleanly where shapely/sopa are absent instead of
aborting collection.
Verified: 87 passed / 1 skipped in the CI-equivalent environment; make check
clean; the vendored helper blocks re-checked byte-identical to upstream
(7/7 and 11/11 symbols) so re-syncs stay mechanical.
Co-authored-by: Malwina Prater <mprater@altoslabs.com>
Co-authored-by: Nell Nie <nnie@altoslabs.com>
Co-authored-by: Christel Krueger <ckrueger@altoslabs.com>
…ns contract Three defects that only a real (non-stub) run could surface, since -stub never executes the analysis scripts nor renders the notebooks: - The four ported bin/ scripts were committed mode 644 while every other script in bin/ is 755. Nextflow runs bin/ scripts directly, so image QC failed immediately with exit 126 "Permission denied". Recorded the executable bit in the index, not just the working tree. - The nf-core quarto/notebook module requires the notebook to export a versions.csv (package,version) which it folds into the versions topic; the upstream notebooks do not, because upstream renders with its own quarto module. Both reports rendered and then the task failed. Added a small cell to each notebook that reads the versions at runtime via importlib.metadata, matching the pipeline's rule that versions are never hardcoded. This is a third documented divergence in the transcript notebook, so the recorded rename replay no longer reproduces it byte-for-byte and the cell has to be re-applied on re-sync. - The versions collector I had hardened to ignore non-tuple topic entries was discarding the versions.yml that quarto/notebook builds from that very versions.csv — requiring an artifact and then binning it. It now handles both shapes: tuples are formatted directly, and file entries are read in as YAML content (softwareVersionsToYAML parses content, not paths). Validated on a production Xenium bundle (126K cells, 7.8 GB): the pipeline completes, image QC reports status ok with its full metrics set, and both reports render with no failure banner — image_qc.html 58 MB / 19 figures, transcript_qc.html 3.8 MB / 11 figures. The report section of the software versions table now carries the notebook's own numpy, pandas and ipython versions. Co-authored-by: Malwina Prater <mprater@altoslabs.com> Co-authored-by: Nell Nie <nnie@altoslabs.com> Co-authored-by: Christel Krueger <ckrueger@altoslabs.com>
…ner env - Drop `params.stain_names` (reviewer item 8). The script parses `--stain-names` and then discards it: every figure title and metric key is hard-coded, and those keys are the contract the threshold YAML and the report read, so making stain names rewrite them would break that contract. Shipping a knob with no effect is worse than not having it, so the parameter, its schema entry and the `ext`/arg wiring in both modules are removed. The vendored scripts keep their now-unreachable CLI option rather than diverging further from upstream; each module records why it is not passed. - `modules/local/transcript_qc/environment.yml`: `pyarrow` 21.0.0 -> 20.0.0. The pinned set was unsolvable — pyarrow 21 conflicts with scanpy 1.11.4 on python 3.11.0 — so the declared environment could not build at all. Earlier runs passed only because they used the previously published image. pyarrow 20 still provides libarrow-acero, which transcript_stream.py needs. - Bump the transcript QC container to 1.1.0 and point both references at it. The environment genuinely changed (scanpy moved from pip to conda, numba and tqdm added, six pip packages dropped), so 1.0.0 no longer describes it; overwriting a published tag would make earlier runs irreproducible. Image QC stays at 1.0.0 — its environment is unchanged and does solve. - CHANGELOG and docs/output.md: describe the QC subworkflow, every published output file, and the new parameters, which were previously undocumented. - tests/.nftignore: ignore the QC report HTMLs and figure PDFs, which embed a render timestamp and a PDF CreationDate respectively. Names and existence are still asserted. - Rebaseline all six pipeline snapshots: the QC layer adds published outputs by design, and spoqc.nf.test runs `mode = 'qc'` so its output set changes too. Validated: full nf-test pipeline suite green under the CI-pinned Nextflow 26.04.6, with the snapshots stable across an update and a separate verify pass; make check clean; 87 pytest tests pass. Note for reviewers: SPOQC_ANALYSIS_OVERVIEW fails intermittently (three of seven runs here, on unmodified dev as well as on this branch) with `ValueError: Too many bins for data range` raised from spoqc/additional_analysis/cluster_analysis.py. It histograms clustering output derived from the spatialdata parquet writers, which tests/.nftignore already documents as not run-to-run reproducible, so the 5-bin histogram occasionally sees a degenerate range. It is unrelated to this PR — it reproduces with the QC subworkflow call removed — but it will flake in CI. Co-authored-by: Malwina Prater <mprater@altoslabs.com> Co-authored-by: Nell Nie <nnie@altoslabs.com> Co-authored-by: Christel Krueger <ckrueger@altoslabs.com>
|
Warning Newer version of the nf-core template is available. Your pipeline is using an old version of the nf-core template: 4.0.3. For more documentation on how to update your pipeline, please see the Synchronisation documentation. |
|
…st env Two CI failures on this PR, both self-inflicted: - nf-core lint template_strings flagged bin/xenium_image_qc_report.qmd: the QC report notebooks use doubled braces as Python f-string escapes (they emit pandoc callout-note divs), which the check reads as leftover Jinja. Added the documented per-file exemptions. - The Python tests workflow could not create its environment. It still merged the two QC module environments, which cannot be co-installed — transcript QC's anndata 0.12.2 requires pyarrow <21 while image QC pins pyarrow 21.0.0. That is also the real constraint behind the unbuildable container environment fixed in the previous commit; anndata, not scanpy, is what caps pyarrow. bin/tests/environment.yml was already made self-contained for exactly this reason, so the workflow now uses it alone. Also ignore .mypy_cache/, whose binary cache files make the merge_markers lint check report false positives locally. Verified: template_strings passes (366 tests, 0 failed) and bin/tests/environment.yml solves standalone.
❌ nf-test failed with latest Nextflow versionNote Tests with Nextflow's latest version failed but it will not cause a CI workflow failure.
See the full run for details. |
A shard failed with `docker: failed to register layer: write /xeniumranger-xenium4.0/external/cellranger/lib/bin/cr_ana: no space left on device` while pulling quay.io/nf-core/xeniumranger:4.0. The QC layer adds two sizeable images (image QC ~4.6 GB, transcript QC ~3 GB), and a shard that also pulls xeniumranger — which bundles cellranger — no longer fits in the 80 GB volume. Raise it to 120 GB. The same shard passed on latest-everything, whose runner had different residual disk, which is why this looked intermittent rather than systematic. Follow-up worth doing separately: the image QC container carries transitive weight it does not need (botocore ~121 MB, sympy ~81 MB, statsmodels ~56 MB and PySide6 ~43 MB, pulled in via the optional pysal/esda stack used only when Moran's I is enabled, which is off by default). Trimming those would cut the image without touching GPU support.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds the image QC and transcript QC layer, ported from the internal
nf-xenium-processingrepository at dev HEAD5e35cae, ontodevalongside the existing spoQC subworkflow.This replaces #205. That PR was built from a stale upstream snapshot: its transcript QC was 695 lines against upstream's 1344, its report notebook 333 against 1861, and
transcript_stream.pyand the threshold config were missing entirely. @nnie-altos and @nmalwinka spent their review time re-deriving issues that current upstream had already fixed. This branch starts fresh fromdevand from upstream HEAD, so those four items close by provenance rather than by patching.Provenance, verified rather than asserted
5e35caebin/snr_metrics.py,bin/xenium_image_qc_report.qmd,bin/roi_image_qc_thresholds.yaml,bin/transcript_stream.pybin/image_qc.pyxenium_helpersimport, and the inlined helper block replacing itbin/transcript_qc_processing.py,bin/transcript_qc.qmd,bin/transcript_qc_thresholds.yamlmolecule→transcriptrenamexenium_helpersis dropped entirely: the needed helpers are inlined into each script byte-identically to upstream (AST-compared, 7/7 and 11/11 symbols), so a re-sync stays mechanical. The rename is recorded as an ordered, replayable rule set — replaying it on pristine upstream reproduces the notebook and threshold YAML byte-for-byte.Review items from #205
Closed by porting from upstream HEAD: 1 under-scaled noise threshold (now
scaled_noise_threshold), 2 unbounded host memory (now a streaming reader), 3 figure names not matching contents, 4 unassigned% / per-FoV QV, 10 the report's QV-filtering claim (gone upstream).Fixed here, each with before/after evidence:
--non-gene-prefixwas ignored (selection hard-coded toNegControl). Now threaded through, with the script default changed toNegControlso the default reproduces the previous behaviour exactly — wiring it naively would have moved the threshold.--non-gene-prefix/--stain-nameshad nonargs, so the documented multi-value form exited with argparse error 2.estimate_min_mols_per_cellraised fromnp.quantileon an empty slice (IndexError, notValueError). Guarded at the call sites so the vendored helper stays byte-identical; degenerate input now reports "not computable" rather than publishing a plausible-looking noise floor.ImportError; a driverless host raisesRuntimeError, so the intended CPU-only path aborted.intensity_warnis 0.40, not 0.35) with nothing in the output revealing it. Replaced with a case-insensitive resolve that raises on a genuinely missing channel; all resolved values verified unchanged. The same pattern in_pick_criticalwas also live.params.stain_namesis dropped. It was parsed and discarded, and the metric keys it would have to rewrite are the contract the threshold YAML and report read.Also addressed: GPU handling now gates on
use_gpuand caps devices via--max-gpus, withprocess_gpu_qcdefined inconf/base.configand routed in theawsprofile (it previously existed unrouted, and on the old branch inside an unclosed block, so it had never applied);publishDirno longer double-nests; QC parameters moved into the schema's QC group with aminimumonimage_qc_gpus; resources come fromdev's own labels rather than ad-hoc numbers; and the modules read noparams.*.Nothing ran the QC Python in CI
That is why several of the above survived. This adds
.github/workflows/python-tests.ymlandbin/tests/(upstream's own suite, adapted only for layout). It builds a runtime environment, because both scripts import matplotlib/tifffile/scanpy at module scope and cannot otherwise be collected.It also repairs the existing suite:
tests/test_xenium_patch/test_stitch_transcripts.pyimported the script from an upstream-onlymodules/local/.../resources/usr/binpath that does not exist here, so it failed collection and took the whole pytest run down (17 passed, 64 errors before). Result now: 87 passed, 1 skipped.Validation
status: okwith its full metrics set, both reports render with no failure banner (58 MB / 19 figures, 3.8 MB / 11 figures)make checkclean (ruff, mypy over 27 files, pre-commit);nf-core pipelines lintschema checks cleanFour pre-existing
mypyfailures unrelated to QC are fixed somake checkpasses (Pillow 12 droppedImage.NEAREST; a rebound tuple annotation; a headerless-CSVNone; twostrarguments declared asPath).mypy.iniexcludes only the four vendored QC scripts, since annotating them locally would fork them from upstream.Known issues, not introduced here
SPOQC_ANALYSIS_OVERVIEWflakes. It failed in three of seven runs withValueError: Too many bins for data rangefromspoqc/additional_analysis/cluster_analysis.py, and reproduces with the QC subworkflow call removed. It histograms clustering output derived from the spatialdata parquet writers, whichtests/.nftignorealready documents as not run-to-run reproducible, so the 5-bin histogram occasionally sees a degenerate range. @heylf — this will flake in CI regardless of this PR.quay.io/dongzehe/image_qc:1.0.0andtranscript_qc:1.1.0, both public. They need migrating to the nf-core org before release. The transcript tag is1.1.0because its environment genuinely changed and1.0.0was already published from a different one — and becausepyarrow=21.0.0made the declared environment unbuildable (it conflicts with scanpy 1.11.4), now pinned to 20.0.0.Reviewers
@nnie-altos @nmalwinka @ChristelKrueger for the QC content, @heylf for nf-core conventions and the spoQC interaction.