Skip to contents

Introduction

bgnorm (Kharbanda, Tubelleza et al. 2025) is a generative statistical framework for background correction, normalisation, and quality control of multiplex spatial proteomics data, including data from the Akoya PhenoCycler-Fusion (formerly CODEX), Cell DIVE, IMC, and CosMx platforms.

The framework models log2-transformed fluorescence intensities as a three-component Gaussian Mixture Model (GMM):

Component Biological interpretation
1 Background (empty space, instrument noise)
2 Non-specific binding / autofluorescence
3 True biological signal
Three-component signal model: each pixel intensity X is the sum of independent latent sources U1 (background), U2 (non-specific), and U3 (signal).

Three-component signal model: each pixel intensity X is the sum of independent latent sources U1 (background), U2 (non-specific), and U3 (signal).

A closed-form deconvolution step isolates the signal component from background sources. An optional quantile normalisation step (bgnormQ) unifies the dynamic range across markers, samples, and 3-D tissue slices, enabling a single fixed positivity threshold across the dataset.

The Jensen-Shannon Divergence (JSD) between components 2 and 3 provides an automated quality control metric: higher JSD indicates better staining quality.

Installation

if (!requireNamespace("BiocManager", quietly = TRUE))
    install.packages("BiocManager")
BiocManager::install("bgnormR")

Example dataset

The package includes a cropped head-and-neck cancer (HNC) tissue section imaged on an Akoya PhenoCycler-Fusion platform. The file ships at inst/extdata/PA_HNC_sample.ome.tiff and contains five markers across a 550 × 800 pixel region.

path <- system.file("extdata", "PA_HNC_sample.ome.tiff", package = "bgnormR")
img  <- read_qptiff(path)
img
#> QPTIFFImage
#>   Dimensions: 550 x 800 (H x W)
#>   Channels  : 5 
#>   Names     : CD20, CD3e, CD8, PanCK, Vimentin  
#>   Format    : ome_tiff 
#>   Levels    : 1

read_qptiff() parses the channel names from the embedded metadata, so names(img) returns the protein names directly:

names(img)   # five-plex panel
#> [1] "CD20"     "CD3e"     "CD8"      "PanCK"    "Vimentin"
dim(img)     # height × width × channels
#> [1] 550 800   5

The same function also reads standard OME-TIFF and OME-Zarr (OME-NGFF) images, auto-detecting the container from the path (OME-Zarr requires the Rarr package). Rich per-channel and slide metadata is organised into an OME-style QPTIFFMetadata object, available via metadata() and summarised as a tidy table by channel_table():

meta <- metadata(img)
qpi_format(meta)     # e.g. "fusion_paged", "ome_tiff", "ome_zarr"
#> [1] "ome_tiff"
channel_table(meta)[, c("name", "fluorophore", "exposure_time_us")]
#>       name fluorophore exposure_time_us
#> 1     CD20         CY5           150000
#> 2     CD3e     ATTO550           150000
#> 3      CD8         CY5           150000
#> 4    PanCK       AF750           150000
#> 5 Vimentin         CY5           150000

The native, Java-free TIFF/QPTIFF reader and writer implemented here was translated from the bioio-tifffile fork by Rafael Tubelleza.


Pixel-level normalisation

Raw intensity maps

Before normalisation, visualise the raw 16-bit intensities to confirm the image loaded correctly. plot_qptiff() renders one panel per requested channel.

p_raw <- plot_qptiff(img, markers = c("PanCK", "Vimentin", "CD20")) +
  labs(title = "Raw")
p_raw

Running bgnorm_pixels

bgnorm_pixels() fits a three-component GMM to each channel independently. sample_prop controls the fraction of non-zero pixels used for fitting — the default 0.1 (10 %) is sufficient for full-resolution Akoya images while keeping runtime short.

res <- bgnorm_pixels(img, sample_prop = 0.1)
res
#> QPTIFFImage
#>   Dimensions: 550 x 800 (H x W)
#>   Channels  : 5 
#>   Names     : CD20, CD3e, CD8, PanCK, Vimentin  
#>   Format    : ome_tiff 
#>   Levels    : 1 
#>   bgnorm    : yes (5 channel(s))

res is a QPTIFFImage whose pixel values are the background-adjusted log2-intensities. Per-channel model parameters are stored as an attribute and retrieved with bgnorm_results():

br_panck <- bgnorm_results(res)[["PanCK"]]
print(br_panck)
#> BgnormResult (pixel-level)
#>   n = 440000 
#>   Component means: 0.031 0.162 1.882 
#>   JSD (QC metric): 0.8829 
#>   BIC (G=2): -1.69   BIC (G=3): -1.4 
#>   No signal detected: FALSE 
#>   Quantile normalised: FALSE 
#>   Tissue positivity: 68.2%

summary() shows the full component table:

summary(br_panck)
#> BgnormResult summary (pixel-level)
#>   JSD: 0.8829 
#>   Quantile normalised: FALSE 
#> 
#>     component   mean     sd proportion
#>    Background 0.0308 0.0193     0.2417
#>  Non-specific 0.1622 0.0885     0.2411
#>        Signal 1.8820 0.9459     0.5172

Intensity distributions with GMM overlay

plot_distributions() draws the raw log2-intensity histogram alongside the fitted GMM component densities.

The three overlaid curves correspond to the background (blue), non-specific (orange), and signal (red) components. Markers with well-separated signal components (PanCK, Vimentin) show a clearly distinct red peak at high intensity.

Background class assignment

plot_pixel_classes() colours each pixel by its MAP component assignment (background / non-specific / signal).

plot_pixel_classes(res, markers = c("PanCK", "CD20"))

Adjusted intensity maps

After normalisation, visualise the background-adjusted images. The adjusted values are stored inside res (the returned QPTIFFImage), so plot_qptiff(res) displays them directly — applying the 2^x back-transform so the display is in linear intensity units.

p_bg <- plot_qptiff(res, markers = c("PanCK", "Vimentin", "CD20"), scale = "marker") +
  labs(title = "Adjusted (bgnorm)")
p_bg

Quantile normalisation (bgnormQ)

Setting quantile_norm = TRUE rescales adjusted intensities so that the 75th percentile of the fitted signal component equals 1 across all markers. This enables a single positivity threshold to be applied consistently across markers, samples, and tissue sections.

res_q <- bgnorm_pixels(img, sample_prop = 0.1, quantile_norm = TRUE)
p_bgq <- plot_qptiff(res_q, markers = c("PanCK", "Vimentin", "CD20"), scale = "marker") +
  labs(title = "Adjusted (bgnormQ)")
p_raw

p_bg

p_bgq


Quality control

JSD summary table

qc_summary() computes the JSD between the non-specific and signal components for every marker in a single call.

qc_df <- qc_summary(res)
qc_df
#>       name       jsd prop_signal
#> 1     CD20 0.4690014  0.13549615
#> 2     CD3e 0.4485430  0.11911444
#> 3      CD8 0.7178658  0.06411746
#> 4    PanCK 0.8828847  0.51723126
#> 5 Vimentin 0.7544078  0.43843742

JSD values below 0.1 indicate likely failed staining; values between 0.1 and 0.2 warrant manual inspection (Kharbanda et al. 2025).

JSD heatmap

plot_jsd_heatmap() summarises the JSD per marker as a heatmap, with circles indicating data quality (white ≥ 0.2, orange 0.1–0.2, red < 0.1). Pass a named list of QPTIFFImages to compare multiple samples side-by-side.


Exporting normalised images

write_qptiff() saves a QPTIFFImage as a multi-page 16-bit TIFF, one page per channel. For bgnorm-adjusted images the pixel values are back-transformed to a linear scale (2^x) before writing. Channel names and bgnorm model parameters are embedded in each page’s ImageDescription tag so the file remains self-documenting.

out <- file.path(tempdir(), "PA_HNC_bgnorm.tif")
write_qptiff(res, out)

# Read back – channel names are preserved
img_out <- read_qptiff(out)
names(img_out)
#> [1] "CD20"     "CD3e"     "CD8"      "PanCK"    "Vimentin"
dim(img_out)
#> [1] 550 800   5

Cell-level normalisation

When only per-cell intensity summaries (mean or median intensity per cell) are available, use bgnorm_cells(), which applies a two-component GMM (non-specific / signal) rather than the full three-component pixel model.

Single-marker: bgnorm_cells

set.seed(3)
cell_intensities <- inv_log_transform(
    c(rnorm(300, mean = 3, sd = 0.5),
      rnorm(100, mean = 7, sd = 0.9)),
    cofactor = 150
)
res_cell <- bgnorm_cells(cell_intensities)
print(res_cell)
#> BgnormResult (cell-level)
#>   n = 400 
#>   Component means: 3.022 6.965 
#>   JSD (QC metric): 0.986 
#>   No signal detected: FALSE 
#>   Quantile normalised: FALSE 
#>   Tissue positivity: 25.1%

Integration with SummarizedExperiment

bgnorm_sce() processes a SummarizedExperiment (or any subclass such as SingleCellExperiment or SpatialExperiment): it reads the requested assay, runs bgnorm_cells() independently for each marker, and stores the adjusted intensities in a new assay alongside per-marker BgnormResult objects in metadata().

library(SummarizedExperiment)
set.seed(5)

n_cells   <- 200L
n_markers <- 5L
marker_names <- c("CD20", "CD3e", "CD8", "PanCK", "Vimentin")

make_cell_raw <- function(j) {
    inv_log_transform(
        c(rnorm(round(n_cells * 0.7), 3, 0.5 + 0.1 * j),
          rnorm(round(n_cells * 0.3), 7, 0.8 + 0.1 * j)),
        cofactor = 150
    )
}
raw_mat <- do.call(rbind, lapply(seq_len(n_markers), make_cell_raw))
rownames(raw_mat) <- marker_names
colnames(raw_mat) <- paste0("cell", seq_len(n_cells))

se <- SummarizedExperiment(assays = list(counts = raw_mat))
se <- bgnorm_sce(se, assay.type = "counts", name = "bgnorm")
assayNames(se)
#> [1] "counts" "bgnorm"

Per-marker model parameters are accessible via metadata():

bgnorm_meta <- S4Vectors::metadata(se)$bgnorm_results
cat("JSD for CD20:", round(bgnorm_meta$CD20$jsd, 3), "\n")
#> JSD for CD20: 0.989

Distributions and QC for cell-level data

plot_distributions() and plot_jsd_heatmap() both accept a SummarizedExperiment with bgnorm results stored in metadata().

qc_summary(bgnorm_meta)
#>       name       jsd prop_signal
#> 1     CD20 0.9892432   0.2964197
#> 2     CD3e 0.9684877   0.2811681
#> 3      CD8 0.9548634   0.2952482
#> 4    PanCK 0.9244953   0.2446019
#> 5 Vimentin 0.8745865   0.2712931

Mathematical background

Log2 transform

Ilog=log2(Ic+1),c=150I_{\log} = \log_2\!\left(\frac{I}{c} + 1\right), \quad c = 150

Three-component GMM

f(Xi)=j=13πj𝒩(Xiμj,σj2)f(X_i) = \sum_{j=1}^{3} \pi_j \, \mathcal{N}(X_i \mid \mu_j, \sigma_j^2)

Components are ordered so that μ1<μ2<μ3\mu_1 < \mu_2 < \mu_3 (background, non-specific, signal).

Background deconvolution

Xadj(x)=P(C=3x)[(μ3μ2)+σ32+σ222min(σ22,σ32)σ32(xμ3)]X_{\text{adj}}(x) = P(C\!=\!3 \mid x) \left[ (\mu_3 - \mu_2) + \frac{\sigma_3^2 + \sigma_2^2 - 2\min(\sigma_2^2, \sigma_3^2)}{\sigma_3^2} (x - \mu_3) \right]

Quantile normalisation factor

qnorm=P(C=3q0.75)[(μ3μ2)+σ32+σ222min(σ22,σ32)σ32(q0.75μ3)]q_{\text{norm}} = P(C\!=\!3 \mid q_{0.75}) \left[ (\mu_3 - \mu_2) + \frac{\sigma_3^2 + \sigma_2^2 - 2\min(\sigma_2^2,\sigma_3^2)}{\sigma_3^2} (q_{0.75} - \mu_3) \right]

Jensen-Shannon Divergence

JSD(PQ)=12DKL(PM)+12DKL(QM),M=P+Q2\text{JSD}(P \| Q) = \frac{1}{2} D_{\text{KL}}(P \| M) + \frac{1}{2} D_{\text{KL}}(Q \| M), \quad M = \frac{P + Q}{2}


Session information

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.4 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.26.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
#>  [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
#>  [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
#> [10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   
#> 
#> time zone: UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats4    stats     graphics  grDevices utils     datasets  methods  
#> [8] base     
#> 
#> other attached packages:
#>  [1] SummarizedExperiment_1.42.0 Biobase_2.72.0             
#>  [3] GenomicRanges_1.64.0        Seqinfo_1.2.0              
#>  [5] IRanges_2.46.0              S4Vectors_0.50.1           
#>  [7] BiocGenerics_0.58.1         generics_0.1.4             
#>  [9] MatrixGenerics_1.24.0       matrixStats_1.5.0          
#> [11] ggplot2_4.0.3               bgnormR_0.99.4             
#> [13] BiocStyle_2.40.0           
#> 
#> loaded via a namespace (and not attached):
#>  [1] gtable_0.3.6                rjson_0.2.23               
#>  [3] xfun_0.60                   bslib_0.11.0               
#>  [5] htmlwidgets_1.6.4           lattice_0.22-9             
#>  [7] vctrs_0.7.3                 tools_4.6.1                
#>  [9] parallel_4.6.1              tibble_3.3.1               
#> [11] pkgconfig_2.0.3             Matrix_1.7-5               
#> [13] RColorBrewer_1.1-3          S7_0.2.2                   
#> [15] desc_1.4.3                  lifecycle_1.0.5            
#> [17] compiler_4.6.1              farver_2.1.2               
#> [19] textshaping_1.0.5           tiff_0.1-12                
#> [21] codetools_0.2-20            htmltools_0.5.9            
#> [23] sass_0.4.10                 yaml_2.3.12                
#> [25] pkgdown_2.2.1               pillar_1.11.1              
#> [27] jquerylib_0.1.4             BiocParallel_1.46.0        
#> [29] SingleCellExperiment_1.34.0 DelayedArray_0.38.2        
#> [31] cachem_1.1.0                magick_2.9.1               
#> [33] abind_1.4-8                 mclust_6.1.3               
#> [35] tidyselect_1.2.1            digest_0.6.39              
#> [37] dplyr_1.2.1                 bookdown_0.47              
#> [39] labeling_0.4.3              fastmap_1.2.0              
#> [41] grid_4.6.1                  cli_3.6.6                  
#> [43] SparseArray_1.12.2          magrittr_2.0.5             
#> [45] S4Arrays_1.12.0             withr_3.0.3                
#> [47] scales_1.4.0                rmarkdown_2.31             
#> [49] XVector_0.52.0              otel_0.2.0                 
#> [51] ragg_1.5.2                  SpatialExperiment_1.22.0   
#> [53] evaluate_1.0.5              knitr_1.51                 
#> [55] viridisLite_0.4.3           rlang_1.3.0                
#> [57] Rcpp_1.1.2                  glue_1.8.1                 
#> [59] xml2_1.6.0                  BiocManager_1.30.27        
#> [61] jsonlite_2.0.0              R6_2.6.1                   
#> [63] systemfonts_1.3.2           fs_2.1.0