A targeted DESI-MRM imaging run produces one intensity per transition per pixel, across tens of thousands of pixels and several acquisitions. Turning that into a biological result runs into three problems at once.
Most pixels are not tissue. A section occupies part of the scanned area, and the surrounding slide still returns signal – chemical background, carryover and detector noise. Left in, those pixels drag down every summary statistic and make images look more uniform than they are. Which pixels are tissue has to be decided per acquisition, and the decision has to survive being re-applied to a different MRM panel of the same sample.
Not every monitored transition yields usable analyte signal. A transition can be listed in the method and still carry no usable signal in a given section, or sit so close to background that it is indistinguishable from it. Reporting those alongside genuine measurements is how spurious hits reach a figure.
Response is not amount. Response depends on ionisation efficiency and local matrix suppression, so raw responses should not be interpreted as analyte amount, and their comparability between analytes, sections or instruments should not be assumed without appropriate normalisation and calibration.
quantMSImageR addresses each of those: signal-to-noise
(SNR) filtering against background pixels, tissue/background separation,
per-feature ion images including .txt image outputs for
overlaying with other imaging (H&E), quantile heatmaps – and
quantification against calibration standards for the
third.
The package is currently validated for Waters
DESI-MRM data. The software is extendable to targeted imaging
data may be analysed after conversion to a compatible
MSImagingExperiment, but those workflows have not yet been
formally validated.
Within that scope it is designed to handle:
.txt export;It does not do the following, by design – other tools do them better:
| Not provided | Use instead |
|---|---|
| Untargeted peak detection | a general MSI processing package |
| Feature annotation / molecular identification | dedicated annotation tools |
| Vendor-neutral raw-data conversion | vendor or community converters |
| Histological registration | TissUUmaps; our own helper tools are still in development (PMID 42438167) |
| General-purpose image segmentation | image-analysis packages |
| Complex spatial / colocalisation analysis | corrMSI |
Functions are grouped into families, and each section below opens with the family it belongs to. The whole workflow is:
.raw acquisition ion library CSV
| |
+--------> read_mrm() <----+
| MSImagingExperiment, fData joined to library
v
select_tissue_pixels() --> tissue_pixels.csv (x, y, tissue/background)
|
+-------------------+-------------------+
| ASSEMBLE align_features -> combine_MSIs / bind_panels |
| trim_MSI |
+-------------------+-------------------+
v quant_MSImagingExperiment
+-------------------+-------------------+
| FILTER int2response (IS-normalise) |
| zero2na, remove_blank_mzs |
| int2snr -> applySNR, back2NA |
+-------------------+-------------------+
v
+--------------+--------------+
v v
VISUALISE QUANTIFY (needs a standards acquisition)
imageR summarise_cal_levels
quantile_hm v
create_cal_curve
v
int2conc -> "pg_pixel", "pg_mm2"
v
plot_cal_coverage (does calibration cover the data?)
WHOLE STUDY: run_study() (YAML) . generate_txt_images() . run_example()
Per-feature images can also be exported as plain .txt
intensity matrices, which load directly into ImageJ and similar tools.
Useful for downstream multimodal analysis such as co-registration with
histology or other imaging modalities.
Quantification against calibration standards is covered separately in
the Quantification with calibration standards vignette
(vignette("quantification", package = "quantMSImageR")).
The workflow implemented here is described and applied in Smith et al., Analytical Chemistry (2024).
The fastest way to see the whole workflow is
run_example(), which runs the imaging workflow on two
synthetic groups (A, circular tissue, n = 3;
B, square tissue, n = 3), renders the HTML report, and
also demonstrates quantification on the bundled calibration
standards:
library(quantMSImageR)
res <- run_example() # both groups; imaging report + calibration demo
res$calibrated # the calibrated objectThe rest of this vignette reproduces the key steps by hand.
Every acquisition is a Cardinal::MSImagingExperiment.
quantMSImageR adds the
quant_MSImagingExperiment subclass, which carries two extra
slots for calibration and tissue metadata, so most functions coerce with
as() first.
The object is features x pixels: one row per MRM
transition, one column per pixel. Calculated quantities –
response, snr, calibrated amount – are stored
as additional spectra layers. Masking functions are the
exception: applySNR(), back2NA() and
zero2na() modify the selected val_slot in
place, so keep a copy of the object when the unmodified values are
needed afterwards.
| Component | Contains | Accessor |
|---|---|---|
| spectra slots | intensity (raw), response (IS-normalised),
snr, pg_pixel / pg_mm2
(calibrated) |
spectra(x, "name"), spectraData(x) |
| feature metadata | mz, name, transition m/z, analyte type,
ion-library columns |
fData(x) |
| pixel metadata | x, y, run, tissue/background
label |
pData(x) |
| calibration | fitted models, calibration levels, fit diagnostics | calibrationModels(x),
calibrationLevels(x),
calibrationDiagnostics(x) |
| tissue summaries | pixel and ROI matrices | tissueMatrix(x), tissueData(x) |
Multiple acquisitions are held in one object,
distinguished by the run column of pData() –
that is what combine_MSIs() produces and what every
per-sample summary groups on.
Pixel metadata uses consistent vocabulary: run
identifies the acquisition, x/y are grid
coordinates, and the tissue mask lives in sample_name as
tissue_pixels / background_pixels. The
calibration path uses sample_type with Cal /
Tissue / Background.
library(quantMSImageR)
p <- system.file("extdata", "example.raw", "section01.RDS",
package = "quantMSImageR")
obj <- as(readRDS(p), "quant_MSImagingExperiment")
dim(obj) # features x pixels
#> [1] 8 400
names(spectraData(obj)) # available layers
#> [1] "intensity"DT::datatable(as.data.frame(fData(obj)), rownames = FALSE,
options = list(pageLength = 5, scrollX = TRUE))Table 1. Feature metadata: one row per MRM transition.
DT::datatable(head(as.data.frame(pData(obj)), 100), rownames = FALSE,
options = list(pageLength = 5, scrollX = TRUE))Table 2. Pixel metadata, first 100 pixels of one section.
The bundled example ships as .RDS so the vignette is
reproducible, but real acquisitions enter through
read_mrm(), which reads a Waters DESI-MRM .raw
folder and joins the ion library to the features:
obj <- read_mrm(
name = "my_acquisition", # .raw folder name, without the suffix
folder = "path/to/raw", # directory containing my_acquisition.raw
lib_ion_path = "path/to/ion_library.csv"
)The .raw folder must contain the exported per-transition
text files (an imaging/ subdirectory) or a previously
cached MSImagingExperiment.rds. The ion library is a CSV
with one row per transition; the columns used for matching are the
precursor and product m/z, plus transition_id for the
display name and Type to mark internal standards. Any
additional columns – pathway class, polarity, collision energy – are
carried through to fData() and become available for
grouping, as shown in Joining ion-library metadata below.
The result is a features x pixels object exactly like the one
inspected above, ready for select_tissue_pixels() and the
rest of the workflow.
Families: acquisition,
combining acquisitions.
| Function | Takes | Produces |
|---|---|---|
read_mrm() |
.raw folder + ion-library CSV |
MSImagingExperiment, fData joined to the
library |
trim_MSI() |
object with a tissue mask | object with pure-background border rows/columns dropped |
remove_blank_mzs() |
object | object with features that have no data removed |
align_features() |
two objects | both reduced to common features, m/z-keyed |
combine_MSIs() |
two or more aligned objects | one object, each acquisition its own run |
bind_panels() |
two objects on a shared (x, y) grid |
features of both on the pixel-grid intersection |
Note the difference between the last two. combine_MSIs()
stacks different sections side by side, adding pixels.
bind_panels() merges two acquisitions of the same
physical area – typically a positive- and a negative-mode run
of one section, or two MRM panels – adding features to the same
pixels:
pos <- read_mrm("acq_section1_pos", folder = "path/to/raw",
lib_ion_path = "path/to/ion_library.csv")
neg <- read_mrm("acq_section1_neg", folder = "path/to/raw",
lib_ion_path = "path/to/ion_library.csv")
# Pixels are matched on (x, y), so the two runs need not have identical grids;
# pixels present in only one are dropped.
both <- bind_panels(pos, neg, label = "S1")The bundled example is six sections: three of group
A (circular tissue) and three of group
B (square tissue). They ship as RDS, so
read_mrm() is not needed here. combine_MSIs()
binds them into a single object, keeping each section as its own
run:
library(quantMSImageR)
library(ggplot2)
sections <- sprintf("section%02d", 1:6)
objs <- lapply(sections, function(s)
readRDS(system.file("extdata", "example.raw", paste0(s, ".RDS"),
package = "quantMSImageR")))
combined <- do.call(combine_MSIs, objs)
groups <- c("A", "A", "A", "B", "B", "B") # sections 01-03 = A, 04-06 = B
combined
#> quant_MSImagingExperiment with 8 features and 2400 spectra
#> spectraData(1): intensity
#> featureData(6): mz, feature_type, precursor_mz, product_mz, name, IS_norm
#> pixelData(4): x, y, run, sample_name
#> coord(2): x = 1...20, y = 1...20
#> runNames(6): section01, section02, section03, section04, section05, section06
#> experimentData(1): pixelSize
#> mass range: 1 to 8
#> centroided: NAEach section contributed its pixels as a separate run,
on the same feature axis:
dim(combined) # features x (pixels from all six sections)
#> [1] 8 2400
table(pData(combined)$run)
#>
#> section01 section02 section03 section04 section05 section06
#> 400 400 400 400 400 400Everything below operates on this one combined
object.
Family: acquisition.
| Function | Takes | Produces |
|---|---|---|
select_tissue_pixels() |
acquisition name, data path, ion library | tissue_pixels.csv (x, y,
tissue_pixels, background_pixels) plus a
preview of the saved mask |
Before SNR filtering, each acquisition needs a tissue mask.
select_tissue_pixels() displays every feature’s ion image
so you can pick the one that best separates tissue from background, then
opens an interactive ROI selector:
select_tissue_pixels(
name = "my_acquisition", # .raw folder name (no suffix)
data_path = "path/to/raw",
lib_ion_path = "path/to/ion_library.csv"
)It writes the resulting mask as tissue_pixels.csv
inside the acquisition’s .raw folder,
where int2snr() and generate_txt_images() read
it. The mask is simply a per-pixel label – tissue versus background –
which the bundled sections already carry in their
sample_name column:
mask_tbl <- as.data.frame(pData(combined))[, c("run", "x", "y", "sample_name")]
DT::datatable(head(mask_tbl, 500), rownames = FALSE,
options = list(pageLength = 5, scrollX = TRUE))Table 3. Per-pixel tissue/background labelling used
by int2snr() (first 500 pixels).
Plotting that column over the pixel coordinates shows the mask itself, which is the quickest way to confirm a selection before running the workflow – the circular group A and square group B tissue should stand out cleanly from the surrounding background:
ggplot(mask_tbl, aes(x, -y, fill = sample_name)) +
geom_tile() +
facet_wrap(~ run, ncol = 3) +
coord_equal() +
scale_fill_manual(values = c(tissue_pixels = "#1b7837",
background_pixels = "grey85")) +
labs(x = NULL, y = NULL, fill = NULL) +
theme_minimal() +
theme(axis.text = element_blank(), panel.grid = element_blank(),
strip.text = element_text(size = 7),
legend.text = element_text(size = 7))Tissue masks for all six sections. Green pixels are labelled tissue_pixels and contribute to every downstream summary; grey pixels are background_pixels and are used to estimate the noise level in int2snr().
Family: visualisation.
| Function | Takes | Produces |
|---|---|---|
imageR() |
object, feat_ind, val_slot,
sample_lab, scale |
a ggplot ion image |
quantile_hm() |
object, quant_val, sample order/labels,
feature_split |
a ComplexHeatmap::Heatmap (rows = samples, columns =
features) |
quant_palettes() |
palette name, optional n |
the hex colours used by both, for reuse in your own plots |
Colours are consistent across the package and selectable per plot.
imageR() takes palette = "heatmap0" (the
default) or "viridis"; quantile_hm() takes a
diverging palette plus group_palette /
feature_palette for the metadata bars, and
cell_border for the hairline that keeps neighbouring cells
of the same colour from merging into one block. In a YAML-driven study
these are set once in the colours: block:
colours:
ion_image: "heatmap0" # heatmap0 | viridis
heatmap: "heatmap2" # heatmap2 | heatmap0
group: "hat" # hat | reading | heatmap0
feature: "reading" # reading | hat | heatmap0
cell_border: "white" # any colour, or "none"names(quant_palettes())
#> [1] "heatmap0" "heatmap2" "hat" "reading"
quant_palettes("heatmap0")
#> [1] "#001219" "#005F73" "#0A9396" "#94D2BD" "#E9D8A6" "#EE9B00" "#CA6702"
#> [8] "#AE2012" "#9B2226"imageR() returns a ggplot ion image for a
chosen feature. Because it is a plain ggplot, the layout
can be adjusted – here into a 3 x 2 grid:
imageR(combined, feat_ind = 1, sample_lab = "run", scale = "suppress",
palette = "heatmap0") +
facet_wrap(~ sample, ncol = 3) +
theme(strip.text = element_text(size = 7),
legend.text = element_text(size = 7))Ion image of the first feature across all six sections, before SNR filtering. Sections 01-03 are group A (circular tissue), 04-06 are group B (square).
A single ion image can look very different depending on how the
colour scale is built, and the right choice depends on what is being
judged. scale offers three treatments:
"suppress" caps the scale at
percentile (default 99), so a few hot pixels cannot flatten
everything else. This is the default and the right starting point for
comparing sections."histogram" equalises the
distribution, which reveals structure in a feature whose signal spans a
narrow range – at the cost of a colour scale that no longer maps
linearly to response."sqrt" compresses the top of the range
more gently than histogram equalisation, keeping the scale monotonic in
response..one <- combined[, pData(combined)$run == "section01"]
.p <- lapply(c("suppress", "histogram", "sqrt"), function(sc)
imageR(.one, feat_ind = 1, sample_lab = "run", scale = sc) +
labs(title = sc) +
theme(legend.position = "none",
plot.title = element_text(size = 9, face = "bold"),
strip.text = element_blank()))
patchwork::wrap_plots(.p, nrow = 1)
The same feature and section under the three colour-scale treatments.
Only suppress keeps the colour scale linear in response;
the others trade that for visible structure.
perc_scale = TRUE additionally relabels the scale as a
percentage of each image’s maximum, which is useful when the absolute
numbers are not comparable anyway.
The HTML report produced by run_study() shows a related
set of three views per feature, built with Cardinal’s own
image() rather than imageR(): capped at the
95th quantile, histogram-normalised, and unscaled.
The same per-feature images can be written to disk as
.txt intensity matrices
(generate_txt_images(output_txt = TRUE), or
output_txt: true in the YAML config). Those files open
directly in ImageJ, which is the usual route into multimodal
workflows.
build_feature_meta() joins ion-library annotation to
features by rounded (precursor, product) m/z, so extra
columns – such as the pathway class Met-1 – become
available for grouping. We do this before the heatmap because the
heatmap uses Met-1 to split its rows.
The ion library is the CSV you supply as lib_ion_path,
and it is worth looking at before anything else: it defines the panel.
transition_id, precursor_mz,
product_mz, collision_eV, cone_V,
Polarity and Type are required; anything else
is yours to add. Here Met-1 carries the pathway class and
IS_norm names the internal standard each analyte is
normalised to (see [int2response()]).
lib <- read.csv(system.file("extdata", "example_ion_library.csv",
package = "quantMSImageR"), check.names = FALSE)
DT::datatable(lib, rownames = FALSE,
options = list(pageLength = 10, scrollX = TRUE))Table 4. The example ion library, as supplied on disk.
fm <- build_feature_meta(combined, lib)
DT::datatable(fm[, c("name", "precursor_mz", "product_mz", "Type", "Met-1",
"IS_norm")],
rownames = FALSE,
options = list(pageLength = 10, scrollX = TRUE))Table 5. The same metadata after
build_feature_meta() has joined it to each measured feature
by m/z.
quantile_hm() summarises each feature to a per-sample
quantile and returns a ComplexHeatmap::Heatmap.
Rows are samples, grouped by study group; columns are features,
split by the Met-1 class joined above – studies
usually have more samples than features, so the long dimension runs
vertically. Cells are drawn square (stretching to at most 1.5:1 when one
dimension is much longer), so the heatmap grows with the data rather
than distorting to fill the device.
cell_size sets that size in millimetres and
fontsize the label size, which is what to reach for when
long transition names crowd the panel: enlarge the cells and shrink the
text rather than letting the labels dictate the layout.
The quant_val argument chooses which quantile summarises
each feature – a median (0.5) reflects the bulk of the
tissue, while a high quantile (0.95) emphasises
hot-spots:
feature_split <- fm[["Met-1"]]
quantile_hm(combined, quant_val = 0.5,
heatmap_order = sections, heatmap_labs = groups,
feature_split = feature_split, feature_split_name = "Met-1",
cell_size = 10, fontsize = 7)Median (50th percentile) feature intensity per section. Rows are sections grouped by study group; columns are features split by Met-1 class.
quantile_hm(combined, quant_val = 0.95,
heatmap_order = sections, heatmap_labs = groups,
feature_split = feature_split, feature_split_name = "Met-1",
cell_size = 10, fontsize = 7)The same summary at the 95th percentile, which emphasises hot-spots rather than the bulk of the tissue.
Family: filtering.
| Function | Takes | Produces |
|---|---|---|
int2response() |
object + internal-standard name | intensities normalised to the IS (response) |
int2snr() |
object, background/tissue labels,
snr_thresh, optional snr_overrides |
adds an snr slot; below threshold and
non-tissue become NA |
applySNR() |
object carrying an snr slot |
sub-threshold pixels set to NA in
val_slot |
back2NA() |
object + background label | background pixels set to NA |
zero2na() |
object, val_slot |
zeros replaced with NA |
int2snr() computes a per-pixel signal-to-noise ratio for
each feature against the background pixels, and applySNR()
then sets every sub-threshold pixel to NA in the intensity
slot.
combined <- int2snr(combined, val_slot = "intensity", snr_thresh = 3)
combined <- applySNR(combined, val_slot = "intensity")int2snr() added an snr layer rather than
replacing anything, and applySNR() then set the
sub-threshold pixels of intensity to NA:
names(spectraData(combined)) # snr layer added
#> [1] "intensity" "snr"
mean(is.na(spectra(combined, "intensity"))) |> round(3) # fraction now masked
#> [1] 0.625Compare the filtered image below with the unfiltered ion images in Ion images above: the low-signal background has been removed, leaving the tissue.
imageR(combined, feat_ind = 1, sample_lab = "run", val_slot = "intensity",
scale = "suppress") +
facet_wrap(~ sample, ncol = 3) +
theme(strip.text = element_text(size = 7),
legend.text = element_text(size = 7))The same feature after applySNR(). Sub-threshold pixels are now NA, so the low-signal background has been removed and only tissue signal remains (compare to previous ion images).
Family: workflow.
| Function | Takes | Produces |
|---|---|---|
run_study() |
a YAML configuration file | runs the whole study: .txt images, optional
calibration, one HTML report per SNR threshold |
validate_config() |
the same file | a report of every problem found in it, before anything is read or written |
generate_txt_images() |
acquisition names, paths, ion library, SNR settings | list of combined/SNR-filtered objects; optional per-feature
.txt images |
run_example() |
nothing (all paths resolved internally) | the same list plus calibrated and report,
having rendered the HTML report |
Everything above runs inside generate_txt_images(),
which run_study() drives from a YAML file. In practice a
whole study – multiple acquisitions, SNR sweeps, ion ratios and optional
calibration – is configured in one place. A minimal config looks
like:
study: "my_study"
paths:
data_path: "path/to/raw"
out_path: "path/to/results"
image_dir: "path/to/images"
lib_ion_path: "path/to/ion_library.csv"
samples:
- pos: "acq_ctrl"
run_id: "S1"
label: "Ctrl"
- pos: "acq_treated"
run_id: "S2"
label: "Treatment"
parameters:
snr_thresh: [3]
output:
render_report: true
output_txt: true # write per-feature .txt images for ImageJThe shipped template documents every option (SNR overrides, ratios, calibration, …) – open it to use as a starting point:
Then point run_study() at your filled-in config:
The same runner is available from the command line, which is the usual way to run a large study unattended:
Driving a study from a file rather than from a session means a complete human-readable configuration file to document how the data were processed. Every choice that affects the results (which acquisitions belong to which group, the SNR thresholds and any per-analyte overrides, whether internal-standard normalisation ran and against which standard, the calibration design and its weighting, how much extrapolation was tolerated) is stored for future reference.
The file is small enough to deposit alongside the data, cite in a methods section, or track in version control in line with FAIR principles (Wilkinson et al., 2016).
To ensure accuracy of this documentation
validate_config() runs every check rather than stopping at
the first failure, so one call reports every problem in the file:
quantMSImageR validation: 2 error(s), 1 warning(s), 26 check(s)
Errors:
- parameters$average_method = 'geometric'; use 'mean' or 'median'.
- parameters$is_name = 'PGE2-d4' is not a value of the ion library's 'Type'
column. Values present: Analyte, IS. Note this is a type label, not a
transition name.
run_study() calls it first and stops if there are
errors, so a configuration that cannot be trusted never produces an
output. The checks are deliberately cross-file for every enabled
functionality (the ion library, the calibration metadata and the config
have to agree with each other, not merely be individually
well-formed).
Smith MJ, Nie M, Adner M, Säfholm J, Wheelock CE. Development of a Desorption Electrospray Ionization–Multiple-Reaction-Monitoring Mass Spectrometry (DESI-MRM) Workflow for Spatially Mapping Oxylipins in Pulmonary Tissue. Analytical Chemistry (2024). https://doi.org/10.1021/acs.analchem.4c02350
Wilkinson MD, et al. The FAIR Guiding Principles for scientific data management and stewardship. Scientific Data 3, 160018 (2016). https://doi.org/10.1038/sdata.2016.18
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats4 stats graphics grDevices utils datasets methods
#> [8] base
#>
#> other attached packages:
#> [1] quantMSImageR_0.99.0 Cardinal_3.15.0 S4Vectors_0.51.5
#> [4] ProtGenerics_1.45.0 BiocGenerics_0.59.10 generics_0.1.4
#> [7] BiocParallel_1.47.0 ggplot2_4.0.3 BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] gtable_0.3.6 circlize_0.4.18 shape_1.4.6.1
#> [4] rjson_0.2.23 xfun_0.60 bslib_0.11.0
#> [7] htmlwidgets_1.6.4 GlobalOptions_0.1.4 Biobase_2.73.1
#> [10] lattice_0.22-9 vctrs_0.7.3 tools_4.6.1
#> [13] crosstalk_1.2.2 parallel_4.6.1 tibble_3.3.1
#> [16] cluster_2.1.8.2 pkgconfig_2.0.3 Matrix_1.7-5
#> [19] RColorBrewer_1.1-3 S7_0.2.2 lifecycle_1.0.5
#> [22] compiler_4.6.1 farver_2.1.2 codetools_0.2-20
#> [25] ComplexHeatmap_2.29.0 clue_0.3-68 htmltools_0.5.9
#> [28] sys_3.4.3 buildtools_1.0.0 sass_0.4.10
#> [31] yaml_2.3.12 crayon_1.5.3 pillar_1.11.1
#> [34] jquerylib_0.1.4 tidyr_1.3.2 DT_0.34.0
#> [37] cachem_1.1.0 magick_2.9.1 iterators_1.0.14
#> [40] viridis_0.6.5 foreach_1.5.2 nlme_3.1-170
#> [43] tidyselect_1.2.1 digest_0.6.39 dplyr_1.2.1
#> [46] purrr_1.2.2 labeling_0.4.3 maketools_1.3.2
#> [49] fastmap_1.2.0 grid_4.6.1 colorspace_2.1-3
#> [52] cli_3.6.6 magrittr_2.0.5 patchwork_1.3.2
#> [55] utf8_1.2.6 withr_3.0.3 matter_2.15.0
#> [58] scales_1.4.0 chemCal_0.2.3 rmarkdown_2.31
#> [61] matrixStats_1.5.0 otel_0.2.0 gridExtra_2.3.1
#> [64] GetoptLong_1.1.1 png_0.1-9 evaluate_1.0.5
#> [67] knitr_1.51 IRanges_2.47.2 doParallel_1.0.17
#> [70] irlba_2.3.7 viridisLite_0.4.3 CardinalIO_1.11.0
#> [73] rlang_1.3.0 Rcpp_1.1.2 ontologyIndex_2.12
#> [76] glue_1.8.1 BiocManager_1.30.27 jsonlite_2.0.0
#> [79] R6_2.6.1