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-23

Introduction

Tertiary lymphoid structures (TLS) are ectopic lymphoid organs that form in non-lymphoid tissues – most notably in tumors – and are associated with improved patient outcomes and immunotherapy response. tlsR provides a fast, reproducible pipeline for detecting TLS and characterizing their spatial organisation in multiplexed tissue imaging data (e.g. mIHC, CODEX, IMC).

The core pipeline is:

Raw ldata list
     |
     v
detect_TLS()        <- KNN-based B+T co-localisation
     |
     +--> scan_clustering()   <- Sliding-window Ripley's L clustering map
     |
     +--> calc_icat()         <- ICAT spatial-spread 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':   322951 obs. of  4 variables:
#>   ..$ x        : int [1:322951] 423 355 731 814 1415 1847 2623 2626 2625 3433 ...
#>   ..$ y        : int [1:322951] 234 460 38 420 24 54 353 353 357 30 ...
#>   ..$ cflag    : int [1:322951] 0 0 0 0 0 0 0 0 0 0 ...
#>   ..$ phenotype: chr [1:322951] "Others" "Others" "Others" "Others" ...
table(toy_ldata[["ToySample"]]$phenotype)
#> 
#>           B cells Endothelial cells     Myeloid cells            Others 
#>              4446             15843             35189            150350 
#>     Stromal cells           T cells 
#>            106412             10711

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.

data(toy_ldata)

ldata <- detect_TLS(
  LSP                     = "ToySample",
  k                       = 10,     # neighbours for density estimation
  bcell_density_threshold = 17,     # min avg 1/k-distance (um)
  min_B_cells             = 100,    # min B cells per candidate TLS
  min_T_cells_nearby      = 5,      # min T cells within max_distance_T
  max_distance_T          = 50,     # search radius (um)
  expand_distance         = 100,    # expanding radius
  ldata                   = toy_ldata
)
#> Detected TLS: 5

table(ldata[["ToySample"]]$tls_id_knn)
#> 
#>      0      1      2      3      4      5 
#> 317892    568    533    414   2250   1294

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

plot(df$x[df$tls_id_knn == 0],
     df$y[df$tls_id_knn == 0],
     col  = "grey80", pch = 19, cex = 0.3,
     xlab = "x (um)", ylab = "y (um)",
     main = "Detected TLS -- ToySample")

points(df$x[df$tls_id_knn > 0],
       df$y[df$tls_id_knn > 0],
       col = "#0072B2", pch = 19, cex = 0.4)

legend("bottomright",
       legend = c("Background", "TLS"),
       col    = c("grey80", "#0072B2"),
       pch    = 19, pt.cex = 1.2, bty = "n")

Scatter plot of ToySample cells coloured by TLS membership


Step 2 – Local Ripley’s L Map with scan_clustering()

scan_clustering() slides a square window across the tissue and computes the K-integral clustering index in each window – the mean positive excess of the observed Ripley’s L over the theoretical CSR value.

When plot = TRUE (the default) a spatial map is produced showing:

Single-phenotype map

# eval=FALSE because this can take ~10--30 s on real data
L_B <- scan_clustering(
  ws             = 1000,        # window side (um)
  sample         = "ToySample",
  phenotype      = "B cells",
  plot           = TRUE,
  creep          = 1L,
  min_cells      = 10L,
  min_phen_cells = 5L,
  label_cex      = 1.1,        # increase if CI labels look small
  ldata          = ldata
)

cat("B-cell windows analysed:", length(L_B$B), "\n")
L_T <- scan_clustering(
  ws        = 500,
  sample    = "ToySample",
  phenotype = "T cells",
  plot      = TRUE,
  ldata     = ldata
)

cat("T-cell windows analysed:", length(L_T$T), "\n")

Side-by-side B and T cell panels

When phenotype = "Both" two panels are drawn side by side – one for B cells and one for T cells – with a shared super-title, making it easy to compare clustering intensity across compartments.

L_both <- scan_clustering(
  ws        = 3000,
  sample    = "ToySample",
  phenotype = "Both",
  plot      = TRUE,
  ldata     = ldata
)

cat("B windows:", length(L_both$B), " | T windows:", length(L_both$T), "\n")

The returned list has named elements $B and $T, each containing Lest objects for the qualifying windows of that phenotype. Individual L curves can be inspected or plotted directly from these objects.


Step 3 – ICAT Score with calc_icat()

The ICAT (Immune Cell Arrangement Trace) index quantifies the spatial spread and linear organisation of cells within a TLS. A higher value indicates a more spatially extended, structured cluster.

How it works

calc_icat() applies FastICA to the centred (x, y) coordinates of TLS cells, reconstructs the data as \(\hat{X} = S A^T + \mu\), and computes the normalised trace-standard-deviation: \[ \text{ICAT} = 100 \times \frac{\sqrt{v_1 + v_2 + 2\sqrt{v_1 v_2}}}{\text{nrow}(X)} \] where \(v_1, v_2\) are the marginal variances of \(\hat{X}\). This formulation is always non-negative – it reflects average spatial spread per cell in microns, rather than the signed trace of the raw mixing matrix which can be negative due to ICA sign ambiguity.

n_tls <- max(ldata[["ToySample"]]$tls_id_knn, na.rm = TRUE)

if (n_tls >= 1L) {
  icat_scores <- vapply(
    seq_len(n_tls),
    function(id) calc_icat("ToySample", tlsID = id, ldata = ldata),
    numeric(1L)
  )
  names(icat_scores) <- paste0("TLS", seq_len(n_tls))
  print(icat_scores)
}
#>      TLS1      TLS2      TLS3      TLS4      TLS5 
#> 15.962265 17.608387 21.657093  4.321363  6.282807

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          = 20,    # HDBSCAN minPts
  min_cluster_size = 100,   # drop clusters smaller than this
  ldata            = ldata
)
#> detect_tic: 14 T-cell cluster(s) detected in 'ToySample'.

table(
  ldata[["ToySample"]]$tcell_cluster_hdbscan[
    ldata[["ToySample"]]$tcell_cluster_hdbscan != 0
  ],
  useNA = "ifany"
)
#> 
#>      1      2      3      4      5      6      7      8      9     10     11 
#>    117    117    102    129    253    209    173    117    189    141    105 
#>     12     13     14   <NA> 
#>    386    110    219 312966

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     5      322951      5059   0.01566492        1011.8    14

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


Step 6 – Visualise with plot_TLS()

plot_TLS() produces a ggplot2 scatter plot with TLS and TIC coloured distinctly using a colourblind-friendly palette.

Rendering improvements

Two aesthetics have been tuned for clarity:

Both parameters are fully exposed as function arguments so you can fine-tune them for your data density.

p <- plot_TLS(
  sample        = "ToySample",
  ldata         = ldata,
  show_tic      = TRUE,
  point_size    = 0.5,
  alpha         = 0.7,     # TLS / TIC cells
  bg_alpha      = 0.25,    # background cells (more transparent)
  tic_size_mult = 0.8      # TIC cells drawn 1.8x larger
)

The returned ggplot object can be further customised with standard ggplot2 functions:

library(ggplot2)
p + labs(title = "ToySample -- Your custom title")

Customised TLS plot with additional title


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)

For scan_clustering() across many samples:

# Generate one spatial map per sample (side-by-side B and T panels)
for (s in names(ldata)) {
  scan_clustering(
    ws        = 500,
    sample    = s,
    phenotype = "Both",    # two-panel plot: B cells | T cells
    plot      = TRUE,
    label_cex = 1.2,       # slightly larger CI labels for presentation
    ldata     = ldata
  )
}

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.3.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.5         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.6              rlang_1.2.0           
#> [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.1            spatstat.geom_3.7-3   
#> [34] vctrs_0.7.3            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-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-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.