detect_TLS()
scan_clustering() (Optional)calc_icat()detect_tic()summarize_TLS()plot_TLS()Tertiary lymphoid structures (TLS) are ectopic lymphoid organs that form in non-lymphoid tissues — most notably in tumours — and are associated with improved patient outcomes and immunotherapy response. tlsR provides a fast, reproducible pipeline for detecting TLS and characterising their spatial organisation in multiplexed tissue imaging data (e.g. mIHC, CODEX, IMC).
The core pipeline is:
Raw ldata list
│
▼
detect_TLS() ← KNN-based B+T co-localisation
│
├──► scan_clustering() ← Optional: local Ripley's L
│
├──► calc_icat() ← ICAT linearity score per TLS
│
├──► detect_tic() ← T-cell clusters outside TLS
│
├──► summarize_TLS() ← Tidy summary table
│
└──► plot_TLS() ← Publication-ready spatial plot
tlsR expects a named list of data
frames (ldata), one element per tissue sample.
Each data frame must contain at minimum:
| Column | Type | Description |
|---|---|---|
x |
numeric | X coordinate in microns |
y |
numeric | Y coordinate in microns |
phenotype |
character | Cell label; must contain "B cell" /
"T cell" |
Additional columns (e.g. cell area, marker intensities) are silently ignored.
library(tlsR)
data(toy_ldata)
# Structure of the built-in example dataset
str(toy_ldata)
#> List of 1
#> $ ToySample:'data.frame': 2130 obs. of 5 variables:
#> ..$ x : num [1:2130] 863 2365 1227 2649 2821 ...
#> ..$ y : num [1:2130] 479 434 448 1543 1478 ...
#> ..$ phenotype: chr [1:2130] "Other" "Other" "Other" "Other" ...
#> ..$ row_index: int [1:2130] 1 2 3 4 5 6 7 8 9 10 ...
#> ..$ cflag : int [1:2130] 0 0 0 0 0 0 0 0 0 0 ...
table(toy_ldata[["ToySample"]]$phenotype)
#>
#> B cells Other T cells
#> 143 1850 137detect_TLS()detect_TLS() identifies B-cell-rich regions with
sufficient T-cell co-localisation using a KNN density approach.
# Ensure toy data has expected columns for the new validation
data(toy_ldata)
if (!"phenotype" %in% names(toy_ldata[["ToySample"]])) {
toy_ldata[["ToySample"]]$phenotype <- toy_ldata[["ToySample"]]$coarse_phen_vec # or whatever the correct mapping is
}
ldata <- detect_TLS(
LSP = "ToySample",
k = 30, # neighbours for density estimation
bcell_density_threshold = 15, # min avg 1/k-distance (um)
min_B_cells = 50, # min B cells per candidate TLS
min_T_cells_nearby = 30, # min T cells within max_distance_T
max_distance_T = 50, # search radius (um)
ldata = toy_ldata
)
#> Sample 'ToySample': 1 TLS detected.
table(ldata[["ToySample"]]$tls_id_knn)
#>
#> 0 1
#> 2050 80The new column tls_id_knn is 0 for non-TLS
cells and a positive integer for cells assigned to TLS 1, 2, 3, … .
df <- ldata[["ToySample"]]
col <- ifelse(df$tls_id_knn == 0, "grey80",
c("#0072B2", "#009E73", "#CC79A7")[df$tls_id_knn])
plot(df$x, df$y,
col = col, pch = 19, cex = 0.3,
xlab = "x (um)", ylab = "y (um)",
main = "Detected TLS — ToySample")
legend("topright",
legend = c("Background", paste0("TLS ", sort(unique(df$tls_id_knn[df$tls_id_knn > 0])))),
col = c("grey80", "#0072B2", "#009E73", "#CC79A7"),
pch = 19, pt.cex = 1.2, bty = "n")scan_clustering()
(Optional)scan_clustering() slides a square window across the
tissue and tests for statistically significant immune cell clustering
using Ripley’s L with a Monte Carlo CSR envelope.
# eval=FALSE because this step can take ~10–30 s on real data
windows <- scan_clustering(
ws = 500, # window side (um)
sample = "ToySample",
phenotype = "B cells",
nsim = 39, # Monte Carlo simulations (39 → p < 0.05)
plot = FALSE,
ldata = ldata
)
cat("Significant windows:", length(windows), "\n")
# Access the first window's centre and cell count:
if (length(windows) > 0) {
cat("Centre:", windows[[1]]$window_center, "\n")
cat("Cells: ", windows[[1]]$n_cells, "\n")
}calc_icat()The ICAT (Immune Cell Arrangement Trace) index quantifies how linearly organised cells are within a TLS. A higher value indicates a more structured (germinal-centre-like) arrangement.
n_tls <- max(ldata[["ToySample"]]$tls_id_knn, na.rm = TRUE)
if (n_tls >= 1) {
icat_scores <- vapply(
seq_len(n_tls),
function(id) calc_icat("ToySample", tlsID = id, ldata = ldata),
numeric(1)
)
names(icat_scores) <- paste0("TLS", seq_len(n_tls))
print(icat_scores)
}
#> TLS1
#> -2.401757calc_icat() returns NA (with a message) if
a TLS has too few cells or if FastICA fails to converge — no errors are
thrown.
detect_tic()T-cell clusters (TIC) that lie outside TLS are identified
with HDBSCAN. The min_pts and min_cluster_size
arguments let you control sensitivity.
ldata <- detect_tic(
sample = "ToySample",
min_pts = 10, # HDBSCAN minPts
min_cluster_size = 10, # drop clusters smaller than this
ldata = ldata
)
#> detect_tic: 3 T-cell cluster(s) detected in 'ToySample'.
table(ldata[["ToySample"]]$tcell_cluster_hdbscan, useNA = "ifany")
#>
#> 0 1 2 3 <NA>
#> 22 50 41 24 1993summarize_TLS()summarize_TLS() produces a tidy one-row-per-sample
summary — convenient for downstream statistical analysis.
sumtbl <- summarize_TLS(ldata, calc_icat_scores = FALSE)
print(sumtbl)
#> sample n_TLS total_cells TLS_cells TLS_fraction mean_TLS_size n_TIC
#> 1 ToySample 1 2130 80 0.03755869 80 3With calc_icat_scores = TRUE a list-column
icat_scores is appended containing named numeric vectors of
per-TLS ICAT values.
plot_TLS()plot_TLS() produces a ggplot2 scatter plot with TLS and
TIC coloured distinctly using a colourblind-friendly palette.
p <- plot_TLS(
sample = "ToySample",
ldata = ldata,
show_tic = TRUE,
point_size = 0.5,
alpha = 0.7
)The returned ggplot object can be further customised
with standard ggplot2 functions:
tlsR is designed to scale naturally to many samples.
Simply pass your full ldata list and iterate:
samples <- names(ldata)
ldata <- Reduce(function(ld, s) detect_TLS(s, ldata = ld), samples, ldata)
ldata <- Reduce(function(ld, s) detect_tic(s, ldata = ld), samples, ldata)
summary_all <- summarize_TLS(ldata)
print(summary_all)sessionInfo()
#> R version 4.5.2 (2025-10-31)
#> Platform: aarch64-apple-darwin20
#> Running under: macOS Tahoe 26.3.1
#>
#> Matrix products: default
#> BLAS: /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBLAS.dylib
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
#>
#> locale:
#> [1] C/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
#>
#> time zone: America/New_York
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] ggplot2_4.0.2 tlsR_0.2.0
#>
#> loaded via a namespace (and not attached):
#> [1] sass_0.4.10 generics_0.1.4 spatstat.explore_3.8-0
#> [4] tensor_1.5.1 spatstat.data_3.1-9 lattice_0.22-9
#> [7] digest_0.6.39 magrittr_2.0.4 spatstat.utils_3.2-2
#> [10] evaluate_1.0.5 grid_4.5.2 RColorBrewer_1.1-3
#> [13] fastmap_1.2.0 jsonlite_2.0.0 Matrix_1.7-5
#> [16] spatstat.sparse_3.1-0 scales_1.4.0 jquerylib_0.1.4
#> [19] abind_1.4-8 cli_3.6.5 rlang_1.1.7
#> [22] polyclip_1.10-7 fastICA_1.2-7 withr_3.0.2
#> [25] cachem_1.1.0 yaml_2.3.12 otel_0.2.0
#> [28] spatstat.univar_3.1-7 FNN_1.1.4.1 tools_4.5.2
#> [31] deldir_2.0-4 dplyr_1.2.0 spatstat.geom_3.7-3
#> [34] vctrs_0.7.2 R6_2.6.1 lifecycle_1.0.5
#> [37] dbscan_1.2.4 pkgconfig_2.0.3 pillar_1.11.1
#> [40] bslib_0.10.0 gtable_0.3.6 glue_1.8.0
#> [43] Rcpp_1.1.1 xfun_0.57 tibble_3.3.1
#> [46] tidyselect_1.2.1 rstudioapi_0.18.0 knitr_1.51
#> [49] dichromat_2.0-0.1 goftest_1.2-3 farver_2.1.2
#> [52] nlme_3.1-169 htmltools_0.5.9 spatstat.random_3.4-5
#> [55] labeling_0.4.3 rmarkdown_2.31 compiler_4.5.2
#> [58] S7_0.2.1