tlsR Workflow: From Raw Imaging Data to TLS Characterisation

Ali Amiryousefi

2026-04-09

Introduction

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

Data Format

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     137

Step 1 — Detect TLS with detect_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   80

The new column tls_id_knn is 0 for non-TLS cells and a positive integer for cells assigned to TLS 1, 2, 3, … .

Quick base-R check plot

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")

Scatter plot of ToySample cells coloured by TLS membership


Step 2 — Local Ripley’s L with 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")
}

Step 3 — ICAT Score with 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.401757

calc_icat() returns NA (with a message) if a TLS has too few cells or if FastICA fails to converge — no errors are thrown.


Step 4 — Detect T-cell Clusters with 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 1993

Step 5 — Summary Table with summarize_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     3

With calc_icat_scores = TRUE a list-column icat_scores is appended containing named numeric vectors of per-TLS ICAT values.


Step 6 — Visualise with 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:

library(ggplot2)
p + theme_dark() + labs(title = "ToySample — dark theme")

Customised TLS plot with dark theme


Multi-Sample Workflow

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)

Session Info

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