The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.

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

These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.