7  Cell localisation between pairs of cell types

Spatial co-localisation analysis helps uncover whether specific cell types are found near each other more (or less) often than expected by chance. This can reveal insights into immune infiltration, tumour-stroma interactions, or the organisation of functional niches.

Before drawing conclusions from co-localisation patterns, it’s important to recognise the potential confounding factors that can distort results. Tissue architecture is rarely random—cells are often organised into structured regions, influenced by development, disease states, or sampling. If this spatial inhomogeneity isn’t accounted for, standard co-localisation methods can misinterpret natural density variations as meaningful interactions. Similarly, differences in cell abundance across images—due to biology or technical variability—can bias association metrics. And finally, variation between samples or replicates must be considered to ensure that observed patterns are consistent and not driven by a few outliers. Any robust co-localisation analysis must address all three of these challenges: local spatial structure, uneven cell-type frequencies, and replicate-level variability.

In this section, we demonstrate how to use the spicyR package to detect co-localisation patterns while accounting for tissue inhomogeneity, image-to-image variability, and cell abundance—ensuring results that are both statistically sound and biologically meaningful.

# set parameters
set.seed(51773)

# whether to use multiple cores (recommended)
use_mc = TRUE
is_windows = .Platform$OS.type == "windows"

if (use_mc) {
  nCores = max(ceiling(parallel::detectCores() / 2), 1)
  
  if (nCores == 1) {
    BPPARAM = BiocParallel::SerialParam()
  } else if (is_windows) {
    BPPARAM = BiocParallel::SnowParam(workers = nCores, type = "SOCK")
  } else {
    BPPARAM = BiocParallel::MulticoreParam(workers = nCores)
  }
} else {
  BPPARAM = BiocParallel::SerialParam()
}

theme_set(theme_classic())

7.1 Quantifying cell type co-localisation with spicyR

spicyR provides a metric to quantify the degree of localisation or dispersion between two cell types. It then tests for changes in this co-localisation metric across different disease states or groups.

Here, we will use the Keren 2018 dataset to demonstrate the use of spicyR. The data is stored as a SpatialExperiment object within the SpatialDatasets package and contains single-cell spatial data from 41 images for three types of breast cancer tumours (cold, compartmentalised, and mixed).

kerenSPE <- SpatialDatasets::spe_Keren_2018()

# remove any missing data in our outcome columns
kerenSPE = kerenSPE[, complete.cases(colData(kerenSPE)[, c("Censored", "Survival_days_capped*",
                                                           "tumour_type")])]

Eleven immune cell types were identified in this dataset by the original authors: (double negative CD3 T cells, CD4 T cells, B cells, monocytes, macrophages, CD8 T cells, neutrophils, natural killer cells, dendritic cells, regulatory T cells), 2 structural cell types (endothelial, mesenchymal), 2 tumour cell types (keratin+ tumour, tumour) and one unidentified category.

7.1.1 Linear modelling

We use the L-function to measure the degree of co-localisation between two cell types. The L-function is a statistical tool used to quantify spatial relationships between two cell types—specifically, whether they tend to be closer together (co-localised) or further apart (dispersed) than would be expected by chance.

For each cell of type A, we count how many cells of type B are found within a certain radius. This is done across all A–B cell pairs in the image, and for a range of radii (e.g., 10, 20, 30 pixels or microns). This gives us an observed pattern of how frequently B cells appear near A cells at different spatial scales.

We then compare this observed pattern to what we would expect if the B cells were distributed completely at random throughout the tissue—this is called the null model, usually based on a Poisson process. If there is no biological interaction between the two cell types, their spatial arrangement should look random.

If the observed number of B cells near A cells is consistently higher than expected under this null model, it suggests positive spatial association—that is, the two cell types are co-localised. If it’s consistently lower, it indicates negative spatial association, or spatial dispersion. The L-function transforms and scales these comparisons in a way that accounts for variance and edge effects, making the differences easier to interpret across images and datasets.

7.1.2 Test for changes in localisation for a specific pair of cells

Firstly, we can test whether one cell type tends to be more localised with another cell type in one condition compared to the other. This can be done using the spicy function, where we specify the condition parameter.

In this example, we want to see whether or not neutrophils (to) tend to be found around CD8 T cells (from) in compartmentalised tumours compared to cold tumours. Given that there are 3 conditions, we can specify the desired conditions by setting the order of our condition factor. spicy will choose the first level of the factor as the base condition and the second level as the comparison condition. spicy will also naturally coerce the condition column into a factor if it is not already a factor.

To draw meaningful conclusions from co-localisation patterns, it’s important to carefully choose the proximity radius r — the distance that defines when two cells are considered “near” each other. This radius should reflect the biological scale of interaction, such as the typical signaling range or physical cell contact. If the radius is too small, real associations might be missed due to slight spatial variability or measurement noise. On the other hand, if the radius is too large, the analysis may pick up false signals driven by overall regional cell density rather than genuine biological interactions. Selecting the right radius is therefore key to accurately capturing the spatial relationships of interest.

By default, spicy calculates the L-function over a range of radii. In the example below, we calculate the L-function for three values of r: 20 pixels, 50 pixels, and 100 pixels to capture both local and global relationships.

The column containing cell type annotations and image IDs can be specified using the cellType and imageID arguments respectively. By default, spicy uses the columns named cellType and imageID.

We obtain a spicy object which details the results of the modelling performed. The topPairs function can be used to obtain the associated coefficients and p-value.

spicyTestPair <- spicy(
  kerenSPE,
  r = c(20, 50, 100),
  condition = "tumour_type",
  from = "CD8_T_cell",
  to = "Neutrophils",
  BPPARAM = BPPARAM
)
Warning: The `BPPARAM` argument of `spicy()` is deprecated as of spicyR 1.18.0.
ℹ Please use the `cores` argument instead.
ℹ The deprecated feature was likely used in the spicyR package.
  Please report the issue at <https://github.com/SydneyBioX/spicyR/issues>.
topPairs(spicyTestPair)
                        intercept coefficient      p.value   adj.pvalue
CD8_T_cell__Neutrophils  -91.5703     94.5402 0.0006792206 0.0006792206
                              from          to
CD8_T_cell__Neutrophils CD8_T_cell Neutrophils
Time for this code chunk to run with 6 cores: 3.6 s

As the coefficient in spicyTestPair is positive, we find that neutrophils are significantly more likely to be found near CD8 T cells in the compartmentalised tumours group compared to the cold tumour group.

Choosing parameters for spicy

How do we select an optimal value for r?`

  • The choice of r will depend on the degree of co-localistion we expect to see and the biological context. Choosing a small value of r is optimal for examining local spatial relationships, and larger values of r will reveal global spatial relationships.

  • When the degree of localistion is unknown, it is best to choose a range of radii to define the co-localisation statistic to capture both local and global relationships.

7.1.3 Test for changes in localisation for all pairwise cell combinations

We can perform what we did above for all pairwise combinations of cell types by excluding the from and to parameters in spicy. Additional covariates can be added using the covariates argument.

spicyTest <- spicy(
  kerenSPE,
  condition = "tumour_type",
  BPPARAM = BPPARAM
)

topPairs(spicyTest)
                         intercept coefficient      p.value   adj.pvalue
Macrophages__dn_T_CD3     64.26984   -57.90199 2.240072e-08 6.160198e-06
dn_T_CD3__Macrophages     62.44605   -55.84542 5.987026e-08 8.232161e-06
Macrophages__CD8_T_cell  105.28040   -98.30184 2.151824e-05 1.525685e-03
CD8_T_cell__Macrophages  103.94483   -96.91337 2.219178e-05 1.525685e-03
Macrophages__DC_or_Mono   74.32427   -60.87678 3.696109e-05 2.032860e-03
DC_or_Mono__Macrophages   72.47461   -59.14177 5.498388e-05 2.520095e-03
Neutrophils__dn_T_CD3    -60.45705    66.94703 1.268374e-04 4.534466e-03
dn_T_CD3__Neutrophils    -60.45215    67.47179 1.319117e-04 4.534466e-03
dn_T_CD3__dn_T_CD3       -60.82745    97.65301 2.359087e-04 7.208323e-03
Macrophages__Macrophages  70.42380   -40.78211 9.662288e-04 1.791030e-02
                                from          to
Macrophages__dn_T_CD3    Macrophages    dn_T_CD3
dn_T_CD3__Macrophages       dn_T_CD3 Macrophages
Macrophages__CD8_T_cell  Macrophages  CD8_T_cell
CD8_T_cell__Macrophages   CD8_T_cell Macrophages
Macrophages__DC_or_Mono  Macrophages  DC_or_Mono
DC_or_Mono__Macrophages   DC_or_Mono Macrophages
Neutrophils__dn_T_CD3    Neutrophils    dn_T_CD3
dn_T_CD3__Neutrophils       dn_T_CD3 Neutrophils
dn_T_CD3__dn_T_CD3          dn_T_CD3    dn_T_CD3
Macrophages__Macrophages Macrophages Macrophages
Time for this code chunk to run with 6 cores: 25.98 s

Again, we obtain a spicy object which outlines the result of the linear models performed for each pairwise combination of cell types.

We can also examine the L-function metrics of individual images by using the convenient bind function on our spicyTest results object.

bind(spicyTest)[1:5, 1:5]
  imageID         condition Keratin_Tumour__Keratin_Tumour
1       1             mixed                      -2.300602
2       2             mixed                      -1.989699
3       3 compartmentalised                      11.373530
4       4 compartmentalised                      33.931133
5       5 compartmentalised                      17.922818
  dn_T_CD3__Keratin_Tumour B_cell__Keratin_Tumour
1                -5.298543             -20.827279
2               -16.020022               3.025815
3               -21.857447             -24.962913
4               -36.438476             -40.470221
5               -20.816783             -38.138076

The results can be represented as a bubble plot using the signifPlot function.

signifPlot(
  spicyTest,
  breaks = c(-3, 3, 1),
  marksToPlot = c("Macrophages", "DC_or_Mono", "dn_T_CD3", "Neutrophils",
                  "CD8_T_cell", "Keratin_Tumour"))

Here, we can observe that the most significant relationships occur between macrophages and double negative CD3 T cells, suggesting that the two cell types are far more dispersed in compartmentalised tumours compared to cold tumours. In general, it appears that our immune cell types become more dispersed in compartmentalised tumours compared to cold tumours.

To examine a specific cell type-cell type relationship in more detail, we can use spicyBoxplot and specify either from = "Macrophages" and to = "dn_T_CD3" or rank = 1.

spicyBoxPlot(results = spicyTest, 
             # from = "Macrophages",
             # to = "dn_T_CD3"
             rank = 1)

The boxplot confirms what we originally found in the bubble plot. Macrophages and double negative CD3 T cells are significantly more dispersed (lower L-function score) in compartmentalised tumours compared to cold tumours.

7.1.4 Linear modelling for custom metrics

spicyR can also be applied to custom distance or abundance metrics. A kNN interactions graph can be generated with the function buildSpatialGraph from the imcRtools package. This generates a colPairs object inside of the SpatialExperiment object.

spicyR provides the function convPairs for converting a colPairs object into an abundance matrix by calculating the average number of nearby cells types for every cell type for a given k. For example, if there exists on average 5 neutrophils for every macrophage in image 1, the column Neutrophil__Macrophage would have a value of 5 for image 1.

kerenSPE <- imcRtools::buildSpatialGraph(kerenSPE, 
                                         img_id = "imageID", 
                                         type = "knn", k = 20,
                                        coords = c("x", "y"))

pairAbundances <- convPairs(kerenSPE,
                  colPair = "knn_interaction_graph")

head(pairAbundances["B_cell__B_cell"])
   B_cell__B_cell
1      12.7349608
10      0.2777778
11      0.0000000
12      1.3333333
13      1.2200957
14      0.0000000

The custom distance or abundance metrics can then be included in the analysis with the alternateResult parameter.

spicyTestColPairs <- spicy(
  kerenSPE,
  condition = "tumour_type",
  alternateResult = pairAbundances,
  weights = FALSE,
  BPPARAM = BPPARAM
)

topPairs(spicyTestColPairs)
                             intercept coefficient    p.value adj.pvalue
Unidentified__CD8_T_cell   0.151346975  0.60159494 0.01687494  0.7034781
Keratin_Tumour__CD8_T_cell 0.117283437 -0.09850513 0.01870063  0.7034781
NK__CD8_T_cell             0.113835243  0.53296190 0.02304383  0.7034781
B_cell__Tumour             0.016326531  0.23053781 0.02626946  0.7034781
Other_Immune__DC_or_Mono   0.144559342 -0.12839560 0.02895838  0.7034781
dn_T_CD3__Neutrophils      0.029731407  0.29756814 0.03260677  0.7034781
Tregs__CD4_T_cell          0.195913616  0.51002005 0.03536184  0.7034781
dn_T_CD3__NK               0.005405405  0.18354680 0.04525721  0.7034781
Other_Immune__NK           0.013333333  0.22116787 0.04660829  0.7034781
CD4_T_cell__DC             2.600122100 -1.36259931 0.04845418  0.7034781
                                     from          to
Unidentified__CD8_T_cell     Unidentified  CD8_T_cell
Keratin_Tumour__CD8_T_cell Keratin_Tumour  CD8_T_cell
NK__CD8_T_cell                         NK  CD8_T_cell
B_cell__Tumour                     B_cell      Tumour
Other_Immune__DC_or_Mono     Other_Immune  DC_or_Mono
dn_T_CD3__Neutrophils            dn_T_CD3 Neutrophils
Tregs__CD4_T_cell                   Tregs  CD4_T_cell
dn_T_CD3__NK                     dn_T_CD3          NK
Other_Immune__NK             Other_Immune          NK
CD4_T_cell__DC                 CD4_T_cell          DC
signifPlot(
  spicyTestColPairs,
  marksToPlot = c("Macrophages", "dn_T_CD3", "CD4_T_cell", 
                  "B_cell", "DC_or_Mono", "Neutrophils", "CD8_T_cell")
)

Using abudance metric yields no cell type pairs which are significantly dispersed or localised in compartmentalised tumours compared to cold tumours.

7.1.5 Mixed effects modelling

spicyR supports mixed effects modelling when multiple images are obtained for each subject. In this case, subject is treated as a random effect and condition is treated as a fixed effect. To perform mixed effects modelling, we can specify the subject parameter in the spicy function.

To demonstrate spicyR’s functionality with mixed effects models, we will use the Damond 2019 dataset.

# load in data
data("diabetesData")

# mixed effects modelling with spicy
spicyMixedTest <- spicy(
  diabetesData,
  condition = "stage",
  subject = "case",
  BPPARAM = BPPARAM
)
Time for this code chunk to run with 6 cores: 117.54 s

As before, we generate a spicy results object, and we can use topPairs to identify the most significant cell type pairs.

topPairs(spicyMixedTest)
                         intercept coefficient      p.value adj.pvalue
beta__delta           6.081500e+01  -15.433244 0.0006616410 0.09171134
delta__beta           6.090722e+01  -15.276364 0.0007164949 0.09171134
B__Th                 2.553231e-15   10.480124 0.0127535339 0.42316692
delta__delta          7.021912e+01  -16.358092 0.0155005315 0.42316692
Th__B                -3.418098e-15   10.003451 0.0173028442 0.42316692
B__unknown            4.199775e-16    4.584274 0.0182158635 0.42316692
otherimmune__naiveTc -3.179087e+00   11.944638 0.0200373935 0.42316692
unknown__macrophage   4.339584e+00   -5.274671 0.0222620877 0.42316692
unknown__B            8.620167e-16    4.680750 0.0244004305 0.42316692
macrophage__unknown   4.305429e+00   -4.886438 0.0249693456 0.42316692
                            from         to
beta__delta                 beta      delta
delta__beta                delta       beta
B__Th                          B         Th
delta__delta               delta      delta
Th__B                         Th          B
B__unknown                     B    unknown
otherimmune__naiveTc otherimmune    naiveTc
unknown__macrophage      unknown macrophage
unknown__B               unknown          B
macrophage__unknown   macrophage    unknown

We can use signifPlot to visualise the results.

signifPlot(spicyMixedTest,
           marksToPlot = c("beta", "delta", "B", "Th", "otherimmune", 
                           "naiveTc", "macrophage", "Tc", "stromal"))

The graph shows a significant decrease in co-localisation between delta and beta cells in the pancreas within the onset diabetes group compared to the non-diabetes group. Additionally, there is a significant increase in co-localisation among certain immune cell groups, including B cells and Th cells, as well as naive Tc cells and other immune cells. These findings align with the results reported in the original study.

7.1.6 Performing survival analysis

spicy can also be used to perform survival analysis to asses whether changes in co-localisation between cell types are associated with survival probability. spicy fits a Cox proportional hazards model to assess the risk of death with the L-function as the explanatory variable. If there are multiple images provided per subject, spicy fits a Cox mixed effects model instead.

To perform survival analysis, spicy requires the SingleCellExperiment object being used to contain a column called survival as a Surv object.

kerenSPE$event = 1 - kerenSPE$Censored
kerenSPE$survival = Surv(kerenSPE$`Survival_days_capped*`, kerenSPE$event)

We can then perform survival analysis using the spicy function by specifying condition = "survival". The corresponding coefficients and p-values can be accessed through the survivalResults slot in the spicy results object.

# Running survival analysis
spicySurvival = spicy(kerenSPE,
                      condition = "survival",
                      BPPARAM = BPPARAM)

# top 10 significant pairs
head(spicySurvival$survivalResults, 10)
# A tibble: 10 × 4
   test                       coef se.coef    p.value
   <chr>                     <dbl>   <dbl>      <dbl>
 1 Other_Immune__Tregs     0.0236  0.00868 0.00000850
 2 CD4_T_cell__Tregs       0.0178  0.00688 0.0000119 
 3 Tregs__Other_Immune     0.0237  0.00875 0.0000124 
 4 Tregs__CD4_T_cell       0.0171  0.00677 0.0000290 
 5 CD8_T_cell__CD8_T_cell  0.00605 0.00273 0.000329  
 6 Tumour__CD8_T_cell     -0.0307  0.0115  0.000582  
 7 CD8_T_cell__Tumour     -0.0307  0.0117  0.000667  
 8 CD4_T_cell__dn_T_CD3    0.00850 0.00354 0.000710  
 9 dn_T_CD3__CD4_T_cell    0.00841 0.00354 0.000925  
10 DC__Other_Immune       -0.0288  0.0123  0.00105   
Time for this code chunk to run with 6 cores: 24.8 s

signifPlot(spicySurvival,
           marksToPlot = c("Tumour", "Tregs", "NK", "Neutrophils", "Mono_or_Neu", 
                           "Macrophages", "Keratin_Tumour", "dn_T_CD3", "DC_or_Mono",
                           "DC", "CD8_T_cell", "CD4_T_cell", "B_cell"))

From the table and the graph above, we can see that the coefficient for Tumour__CD8_T_cell is negative, indicating that localisation between the two cell types is associated with a better prognosis for the patient. We can also see that localisation between most immune cell types (Neutrophils__CD8_T_cell, Tregs__CD4_T_cell, dn_T_CD3__CD4_T_cell) is associated with worse outcomes for the patient.

We can examine the relationship for one pair of cell types (Tumour__CD8_T_cell) more closely using a Kaplan-Meier curve. Below, we extract the survival data from kerenSPE and create a Surv object.

# extracting survival data
survData <- kerenSPE |>
  colData() |>
  data.frame() |>
  select(imageID, survival) |>
  unique()

# creating survival vector
kerenSurv <- survData$survival
names(kerenSurv) <- survData$imageID

kerenSurv
    1     2     3     4     5     6     7     8     9    10    11    12    13 
 2612   745 3130+ 2523+ 1683+ 2275+   584   946 3767+ 3822+ 3774+ 4353+  1072 
   14    15    16    17    18    19    20    21    23    24    25    26    27 
4145+  1754   530  2842 5063+ 3725+ 4761+   635    91   194 4785+ 4430+  3658 
   28    29    31    32    33    34    35    36    37    39    40    41 
3767+  1319  1009 1568+ 1738+ 2832+ 2759+ 3063+ 2853+ 2096+  3573 3355+ 

We can then convert our L-function metrics into a binary metric with two categories: Localised and Dispersed, and plot a Kaplan-Meier curve to view its relationship to survival probability.

# obtain L-function values for a specific cell type
# and convert into localised/dispersed based on the median
survRelationship = bind(spicySurvival)[["Tumour__CD8_T_cell"]]
survRelationship = ifelse(survRelationship > median(survRelationship, na.rm = TRUE),
                          "Localised", "Dispersed")

# ensuring consistency and removing missing values
names(survRelationship) = names(kerenSurv)
survRelationship = survRelationship[!is.na(names(survRelationship))]
kerenSurv = kerenSurv[names(kerenSurv) %in% names(survRelationship)]

# plotting Kaplan-Meier curve
survfit2(kerenSurv ~ survRelationship) |>
  ggsurvfit() +
  ggtitle("Tumour__CD8_T_cell")

The KM curve aligns with that we observed from the bubble plot.

7.1.7 Accounting for tissue inhomogeneity

The spicy function can also account for tissue inhomogeneity to avoid false positives or negatives. This can be done by setting the sigma = parameter within the spicy function. By default, sigma is set to NULL, and spicy assumes a homogeneous tissue structure.

To demonstrate why sigma is a useful parameter, we examine the degree of co-localisation between Keratin_Tumour__Neutrophils in one image using the getPairwise function, which returns the L-function values for each cell type pair. We set the radius over which the L-function should be calculated (Rs = 100) and specify sigma = NULL.

# filter SPE object to obtain image 24 data
kerenSubset = kerenSPE[, colData(kerenSPE)$imageID == "24"]

pairwiseAssoc = getPairwise(kerenSubset, 
                            sigma = NULL, 
                            r = 100) |>
  as.data.frame()

pairwiseAssoc[["Keratin_Tumour__Neutrophils"]]
[1] 10.88892

The calculated L-function is positive, indicating attraction between the two cell types.

When we specify sigma = 20 and re-calculate the L-function, it indicates that there is no relationship between Keratin_Tumour and Neutrophils, i.e., there is no major attraction or dispersion, as it now takes into account tissue inhomogeneity.

pairwiseAssoc = getPairwise(kerenSubset, 
                            sigma = 20, 
                            r = 100) |> as.data.frame()

pairwiseAssoc[["Keratin_Tumour__Neutrophils"]]
[1] 0.9024836

To understand why this might be happening, we can take a closer look at the relationship between Keratin_Tumour and Neutrophils. The plotImage function allows us to plot any two cell types for a specific image. Below, we plot image 24 for the Keratin_Tumour__Neutrophils relationship by specifying from = Keratin_Tumour and to = Neutrophils.

plotImage(kerenSPE, imageToPlot = "24", 
          from = "Keratin_Tumour", 
          to = "Neutrophils")

Plotting image 24 shows that the supposed co-localisation occurs due to the dense cluster of cells near the bottom of the image, and when we take this into account, the localisation disappears.

7.1.8 Adjusting for cell count

The L-function (and by extension, the co-localisation score) is sensitive to the number of cells in an image. Too few cells can cause the L-function to become unstable and obscure meaningful spatial relationships. The issue is particularly pronounced when there is a skewed ratio of cell types in an image.

In the plot below, we can see that the variance of the co-localisation metric is greater when the number of cells is low, and becomes more stable as the number of cells increases.

To address this issue, spicyR uses a shape constrained generalised additive model (GAM) to model the co-localisation metric \(u\) as a function of the number of cells per cell type. The inverse of this fitted curve is used to generate weights which are applied to each image. Images with fewer cells have a lower weight. spicyR can perform image weighting in two ways: by fitting the GAM on the scores from all cell types at once, or by fitting the GAM on each pair of cell types. To perform image weighting by cell type pair, set weightsByPair = TRUE when using spicy.

From the boxplot below, we can identify images that may have very few cells or a skewed cell type distribution. For consistency, we will use the cell type pair Macrophages__dn_T_CD3, which showed significant differences in co-localisation between cold and compartmentalised tumours in our initial analysis.

spicyBoxPlot(spicyTest,
             from = "Macrophages",
             to = "dn_T_CD3")
Warning: Removed 2 rows containing non-finite outside the scale range
(`stat_boxplot()`).

Using the graph, we can filter for compartmentalised images that had an L-function value greater than 20 and examine the structure of these images.

bind(spicyTest) |> 
  dplyr::select(c(imageID, condition, Macrophages__dn_T_CD3)) |>
  # filter for compartmenatalised images with L-function > 20 (an outlier)
  dplyr::filter(condition == "compartmentalised" & Macrophages__dn_T_CD3 > 20)
  imageID         condition Macrophages__dn_T_CD3
1      34 compartmentalised              34.12149

We can use plotImage to examine image 34 more closely.

plotImage(kerenSPE, imageToPlot = 34, from = "Macrophages", to = "dn_T_CD3")

The value of the L-function tells us that there is localisation between macrophages and double negative CD3 T cells in this image. However, when we examine the image in question, it appears that the number of dn_T_CD3 cells is low compared to the number of Macrophages. The L-function is therefore not capturing the full context of the spatial relationship.

We can repeat the process above for mixed tumours.

bind(spicyTest) |> 
  dplyr::select(c(imageID, condition, Macrophages__dn_T_CD3)) |>
  dplyr::filter(condition == "mixed" & Macrophages__dn_T_CD3 > 20)
  imageID condition Macrophages__dn_T_CD3
1      14     mixed              38.71105
2      23     mixed              46.91940
3      33     mixed              36.41111

Here, three images have high L-function values. We will examine image 33.

plotImage(kerenSPE, imageToPlot = 33, from = "Macrophages", to = "dn_T_CD3")

As before, we can see that the number of dn_T_CD3 cells is low, driving the L-function up even though true localisation may not be occurring.

Assessing image quality

How do we distinguish “good” images from “bad” ones?

  • Typically, images with very few cells can skew the L-function, and make it appear as though there is localisation or dispersion when, in reality, no significant spatial pattern exists. These images usually have very high or very low L-function values compared to other images in the same group.

  • Skewed images must be further examined to understand the reason behind the abnormal L-function values. Factors such as imaging artifacts, poor segmentation, or uneven cell distribution could contribute to this skew. In contrast, good quality images typically exhibit a more balanced ratio of cell types and a consistent number of cells across images.

In the next section, we will demonstrate how and why we can derive spatial contexts within tissue samples to produce robust spatial quantifications.

7.2 sessionInfo

R version 4.5.0 (2025-04-11)
Platform: aarch64-apple-darwin20
Running under: macOS Sonoma 14.4.1

Matrix products: default
BLAS:   /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRblas.0.dylib 
LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1

locale:
[1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8

time zone: Australia/Sydney
tzcode source: internal

attached base packages:
[1] stats4    stats     graphics  grDevices utils     datasets  methods  
[8] base     

other attached packages:
 [1] spicyR_1.21.1               testthat_3.2.3             
 [3] ggsurvfit_1.1.0             treekoR_1.16.0             
 [5] tibble_3.2.1                survival_3.8-3             
 [7] dplyr_1.1.4                 imcRtools_1.14.0           
 [9] SpatialDatasets_1.6.3       ExperimentHub_2.16.0       
[11] AnnotationHub_3.16.0        BiocFileCache_2.16.0       
[13] dbplyr_2.5.0                SpatialExperiment_1.18.1   
[15] SingleCellExperiment_1.30.1 SummarizedExperiment_1.38.1
[17] Biobase_2.68.0              GenomicRanges_1.60.0       
[19] GenomeInfoDb_1.44.0         IRanges_2.42.0             
[21] S4Vectors_0.46.0            BiocGenerics_0.54.0        
[23] generics_0.1.4              MatrixGenerics_1.20.0      
[25] matrixStats_1.5.0           ggplot2_3.5.2              
[27] Statial_1.10.0             

loaded via a namespace (and not attached):
  [1] dichromat_2.0-0.1           vroom_1.6.5                
  [3] urlchecker_1.0.1            tiff_0.1-12                
  [5] dcanr_1.24.0                goftest_1.2-3              
  [7] DT_0.33                     Biostrings_2.76.0          
  [9] HDF5Array_1.36.0            TH.data_1.1-3              
 [11] vctrs_0.6.5                 spatstat.random_3.4-1      
 [13] digest_0.6.37               png_0.1-8                  
 [15] shape_1.4.6.1               proxy_0.4-27               
 [17] BiocBaseUtils_1.10.0        ggrepel_0.9.6              
 [19] deldir_2.0-4                magick_2.8.5               
 [21] MASS_7.3-65                 reshape2_1.4.4             
 [23] httpuv_1.6.16               foreach_1.5.2              
 [25] withr_3.0.2                 ggfun_0.1.8                
 [27] xfun_0.52                   ellipsis_0.3.2             
 [29] ggpubr_0.6.0                doRNG_1.8.6.2              
 [31] memoise_2.0.1               RTriangle_1.6-0.15         
 [33] cytomapper_1.20.0           ggbeeswarm_0.7.2           
 [35] RProtoBufLib_2.20.0         profvis_0.4.0              
 [37] systemfonts_1.2.3           tidytree_0.4.6             
 [39] zoo_1.8-14                  GlobalOptions_0.1.2        
 [41] Formula_1.2-5               KEGGREST_1.48.0            
 [43] promises_1.3.2              httr_1.4.7                 
 [45] rstatix_0.7.2               rhdf5filters_1.20.0        
 [47] rhdf5_2.52.0                rstudioapi_0.17.1          
 [49] miniUI_0.1.1.1              UCSC.utils_1.4.0           
 [51] units_0.8-7                 concaveman_1.1.0           
 [53] curl_6.2.3                  ggraph_2.2.1               
 [55] h5mread_1.0.1               polyclip_1.10-7            
 [57] GenomeInfoDbData_1.2.14     SparseArray_1.8.0          
 [59] fftwtools_0.9-11            desc_1.4.3                 
 [61] xtable_1.8-4                stringr_1.5.1              
 [63] doParallel_1.0.17           evaluate_1.0.3             
 [65] S4Arrays_1.8.0              hms_1.1.3                  
 [67] colorspace_2.1-1            filelock_1.0.3             
 [69] spatstat.data_3.1-6         magrittr_2.0.3             
 [71] readr_2.1.5                 later_1.4.2                
 [73] viridis_0.6.5               ggtree_3.16.0              
 [75] lattice_0.22-6              spatstat.geom_3.4-1        
 [77] XML_3.99-0.18               scuttle_1.18.0             
 [79] ggupset_0.4.1               class_7.3-23               
 [81] svgPanZoom_0.3.4            pillar_1.10.2              
 [83] simpleSeg_1.9.2             nlme_3.1-168               
 [85] iterators_1.0.14            EBImage_4.50.0             
 [87] compiler_4.5.0              beachmat_2.24.0            
 [89] stringi_1.8.7               sf_1.0-21                  
 [91] devtools_2.4.5              tensor_1.5                 
 [93] minqa_1.2.8                 ClassifyR_3.12.0           
 [95] plyr_1.8.9                  crayon_1.5.3               
 [97] abind_1.4-8                 gridGraphics_0.5-1         
 [99] locfit_1.5-9.12             sp_2.2-0                   
[101] graphlayouts_1.2.2          bit_4.6.0                  
[103] terra_1.8-50                sandwich_3.1-1             
[105] codetools_0.2-20            multcomp_1.4-28            
[107] textshaping_1.0.1           e1071_1.7-16               
[109] GetoptLong_1.0.5            plotly_4.10.4              
[111] mime_0.13                   MultiAssayExperiment_1.34.0
[113] splines_4.5.0               circlize_0.4.16            
[115] Rcpp_1.0.14                 utf8_1.2.5                 
[117] knitr_1.50                  blob_1.2.4                 
[119] clue_0.3-66                 BiocVersion_3.21.1         
[121] lme4_1.1-37                 fs_1.6.6                   
[123] nnls_1.6                    Rdpack_2.6.4               
[125] pkgbuild_1.4.8              ggplotify_0.1.2            
[127] ggsignif_0.6.4              Matrix_1.7-3               
[129] scam_1.2-19                 statmod_1.5.0              
[131] tzdb_0.5.0                  svglite_2.2.1              
[133] tweenr_2.0.3                pkgconfig_2.0.3            
[135] pheatmap_1.0.12             tools_4.5.0                
[137] cachem_1.1.0                rbibutils_2.3              
[139] RSQLite_2.3.11              viridisLite_0.4.2          
[141] DBI_1.2.3                   numDeriv_2016.8-1.1        
[143] fastmap_1.2.0               rmarkdown_2.29             
[145] scales_1.4.0                grid_4.5.0                 
[147] usethis_3.1.0               shinydashboard_0.7.3       
[149] broom_1.0.8                 patchwork_1.3.0            
[151] BiocManager_1.30.25         carData_3.0-5              
[153] farver_2.1.2                reformulas_0.4.1           
[155] tidygraph_1.3.1             mgcv_1.9-3                 
[157] yaml_2.3.10                 ggthemes_5.1.0             
[159] cli_3.6.5                   purrr_1.0.4                
[161] hopach_2.68.0               lifecycle_1.0.4            
[163] mvtnorm_1.3-3               sessioninfo_1.2.3          
[165] backports_1.5.0             BiocParallel_1.42.0        
[167] cytolib_2.20.0              gtable_0.3.6               
[169] rjson_0.2.23                parallel_4.5.0             
[171] ape_5.8-1                   limma_3.64.1               
[173] jsonlite_2.0.0              edgeR_4.6.2                
[175] bitops_1.0-9                bit64_4.6.0-1              
[177] brio_1.1.5                  Rtsne_0.17                 
[179] yulab.utils_0.2.0           FlowSOM_2.16.0             
[181] spatstat.utils_3.1-4        BiocNeighbors_2.2.0        
[183] ranger_0.17.0               flowCore_2.20.0            
[185] bdsmatrix_1.3-7             spatstat.univar_3.1-3      
[187] lazyeval_0.2.2              shiny_1.10.0               
[189] ConsensusClusterPlus_1.72.0 htmltools_0.5.8.1          
[191] diffcyt_1.28.0              rappdirs_0.3.3             
[193] distances_0.1.12            glue_1.8.0                 
[195] XVector_0.48.0              RCurl_1.98-1.17            
[197] rprojroot_2.0.4             treeio_1.32.0              
[199] classInt_0.4-11             coxme_2.2-22               
[201] jpeg_0.1-11                 gridExtra_2.3              
[203] boot_1.3-31                 igraph_2.1.4               
[205] R6_2.6.1                    tidyr_1.3.1                
[207] ggiraph_0.8.13              labeling_0.4.3             
[209] ggh4x_0.3.0                 cluster_2.1.8.1            
[211] rngtools_1.5.2              pkgload_1.4.0              
[213] Rhdf5lib_1.30.0             aplot_0.2.5                
[215] nloptr_2.2.1                DelayedArray_0.34.1        
[217] tidyselect_1.2.1            vipor_0.4.7                
[219] ggforce_0.4.2               raster_3.6-32              
[221] car_3.1-3                   AnnotationDbi_1.70.0       
[223] KernSmooth_2.23-26          data.table_1.17.4          
[225] htmlwidgets_1.6.4           ComplexHeatmap_2.24.0      
[227] RColorBrewer_1.1-3          rlang_1.1.6                
[229] spatstat.sparse_3.1-0       spatstat.explore_3.4-3     
[231] remotes_2.5.0               lmerTest_3.1-3             
[233] uuid_1.2-1                  colorRamps_2.3.4           
[235] ggnewscale_0.5.1            beeswarm_0.4.0