diff --git a/.Rbuildignore b/.Rbuildignore new file mode 100644 index 0000000..8a8b2e1 --- /dev/null +++ b/.Rbuildignore @@ -0,0 +1,13 @@ +^fit_spatial_mask\.R$ +^demo_fit_spatial_mask\.R$ +^demo_holes_and_scale\.R$ +^vignette_fit_spatial_mask\.Rmd$ +^vignette_fit_spatial_mask\.html$ +^vignette_fit_spatial_mask_cache$ +^vignette_fit_spatial_mask_files$ +^_pkgdown\.yml$ +^pkgdown$ +^\.github$ +^.*\.Rproj$ +^\.Rproj\.user$ +^README\.Rmd$ diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml new file mode 100644 index 0000000..a407fa1 --- /dev/null +++ b/.github/workflows/R-CMD-check.yaml @@ -0,0 +1,52 @@ +# R CMD check workflow +# Runs on every push and pull request to main/master. +# Tests on Ubuntu (release + devel), macOS (release), and Windows (release). + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +name: R-CMD-check + +jobs: + R-CMD-check: + runs-on: ${{ matrix.config.os }} + + name: ${{ matrix.config.os }} (${{ matrix.config.r }}) + + strategy: + fail-fast: false + matrix: + config: + - {os: ubuntu-latest, r: 'release'} + - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} + - {os: macos-latest, r: 'release'} + - {os: windows-latest, r: 'release'} + + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + R_KEEP_PKG_SOURCE: yes + + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + r-version: ${{ matrix.config.r }} + http-user-agent: ${{ matrix.config.http-user-agent }} + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::rcmdcheck + needs: check + + - uses: r-lib/actions/check-r-package@v2 + with: + upload-snapshots: true + build_args: 'c("--no-manual", "--compact-vignettes=gs+qpdf")' + args: 'c("--no-manual", "--as-cran")' diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml new file mode 100644 index 0000000..901fc55 --- /dev/null +++ b/.github/workflows/pkgdown.yaml @@ -0,0 +1,52 @@ +# pkgdown site deployment +# Builds and deploys the pkgdown site to GitHub Pages on every push to main. +# Requires GitHub Pages to be enabled with "Source: Deploy from a branch" +# set to "gh-pages / (root)" in repository Settings > Pages. + +on: + push: + branches: [main, master] + release: + types: [published] + workflow_dispatch: + +name: pkgdown + +concurrency: + group: pkgdown-${{ github.event_name }} + cancel-in-progress: true + +jobs: + pkgdown: + runs-on: ubuntu-latest + + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::pkgdown, local::. + needs: website + + - name: Build site + run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE) + shell: Rscript {0} + + - name: Deploy to GitHub pages + uses: JamesIves/github-pages-deploy-action@v4 + with: + clean: false + branch: gh-pages + folder: docs diff --git a/DESCRIPTION b/DESCRIPTION new file mode 100644 index 0000000..f2266b3 --- /dev/null +++ b/DESCRIPTION @@ -0,0 +1,37 @@ +Package: TissueMask +Type: Package +Title: Spatial Boundary Mask Fitting for Point Coordinate Data +Version: 0.1.0 +Authors@R: person("Raredon Laboratory", role = c("aut", "cre"), + email = "raredonlab@yale.edu") +Description: Fits polygon spatial masks to XY point coordinates such as + cell centroids or transcript locations from spatial transcriptomics + and spatial proteomics experiments. Supports four methods of + increasing topological complexity: convex hull, concave hull, kernel + density estimation (KDE), and a raster-based Gaussian smoothing + approach that naturally handles internal voids (vessel lumens, + necrotic cores, tissue tears) and disconnected tissue islands via + GEOS polygon dissolve. +License: MIT + file LICENSE +Encoding: UTF-8 +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.3.2 +Depends: + R (>= 4.1.0) +Imports: + sf (>= 1.0-0), + stats, + parallel +Suggests: + concaveman, + MASS, + isoband, + ggplot2, + testthat (>= 3.0.0), + knitr, + rmarkdown, + patchwork +VignetteBuilder: knitr +URL: https://raredonlab.github.io/TissueMask/, https://github.com/RaredonLab/TissueMask +BugReports: https://github.com/RaredonLab/TissueMask/issues +Config/testthat/edition: 3 diff --git a/NAMESPACE b/NAMESPACE new file mode 100644 index 0000000..c924fcc --- /dev/null +++ b/NAMESPACE @@ -0,0 +1,24 @@ +# Generated by roxygen2: do not edit by hand + +export(fit_spatial_mask) +importFrom(parallel,mclapply) +importFrom(sf,NA_crs_) +importFrom(sf,st_area) +importFrom(sf,st_as_sf) +importFrom(sf,st_bbox) +importFrom(sf,st_buffer) +importFrom(sf,st_convex_hull) +importFrom(sf,"st_crs<-") +importFrom(sf,st_difference) +importFrom(sf,st_distance) +importFrom(sf,st_geometry) +importFrom(sf,st_make_valid) +importFrom(sf,st_polygon) +importFrom(sf,st_sf) +importFrom(sf,st_sfc) +importFrom(sf,st_simplify) +importFrom(sf,st_union) +importFrom(sf,st_within) +importFrom(stats,complete.cases) +importFrom(stats,filter) +importFrom(stats,quantile) diff --git a/R/TissueMask-package.R b/R/TissueMask-package.R new file mode 100644 index 0000000..9ea3b00 --- /dev/null +++ b/R/TissueMask-package.R @@ -0,0 +1,28 @@ +#' TissueMask: Spatial Boundary Mask Fitting for Point Coordinate Data +#' +#' @description +#' TissueMask provides [`fit_spatial_mask()`], which fits polygon spatial masks +#' to XY point coordinate data such as cell centroids or transcript locations +#' from spatial transcriptomics and spatial proteomics experiments. +#' +#' Four masking methods are available, supporting geometries of increasing +#' topological complexity — from simple convex hulls to multi-polygon masks +#' with internal voids and disconnected islands: +#' +#' | Method | Holes/Islands | Speed | Recommended for | +#' |---|---|---|---| +#' | `"convex"` | No | Instant | Sanity checks | +#' | `"concave"` | No | Fast | Simple non-convex shapes | +#' | `"kde"` | Yes | Moderate | Dense, smooth datasets | +#' | `"raster"` | Yes | Fast | Most use cases (default) | +#' +#' @section Part of TissueSuite: +#' TissueMask is the first component of the **TissueSuite** package family +#' developed at the Raredon Laboratory, Yale School of Medicine. The companion +#' package **TissueField** uses TissueMask output to estimate steady-state +#' molecular concentration fields via a diffusion–clearance PDE. +#' +#' @seealso [`fit_spatial_mask()`] +#' +#' @keywords internal +"_PACKAGE" diff --git a/R/fit_spatial_mask.R b/R/fit_spatial_mask.R new file mode 100644 index 0000000..3269ffb --- /dev/null +++ b/R/fit_spatial_mask.R @@ -0,0 +1,399 @@ +#' Fit a Spatial Boundary Mask to Point Coordinate Data +#' +#' @description +#' Fits a polygon (or multi-polygon) spatial mask — an [sf::sfc] geometry +#' object — to a set of XY point coordinates. Suitable for cell centroids, +#' single-molecule transcript locations, or any spatial point process data. +#' The mask faithfully captures tissue shape including internal voids (vessel +#' lumens, necrotic cores, tissue tears) and disconnected fragments. +#' +#' Four algorithms are available: +#' +#' | `method` | Topology | Speed | Best for | +#' |---|---|---|---| +#' | `"raster"` | Holes + islands | Fast | Most use cases (default) | +#' | `"kde"` | Holes + islands | Moderate | Dense, smooth datasets | +#' | `"concave"` | No holes | Fast | Simple non-convex shapes | +#' | `"convex"` | No holes | Instant | Quick checks | +#' +#' **Raster method overview** (`method = "raster"`, v3): +#' 1. Bin point coordinates onto a regular grid. +#' 2. Convolve the occupancy grid with a 2-D Gaussian of width `raster_sigma` +#' (in coordinate units). +#' 3. Threshold at `raster_threshold × max`. Cells above threshold are "inside". +#' 4. Convert each "inside" cell to an axis-aligned rectangle and dissolve via +#' GEOS union. Holes and islands emerge from the geometry naturally — no +#' ring-winding logic required. +#' 5. Apply a morphological close (buffer out then in) to smooth staircase +#' boundaries. +#' +#' @param coords A data frame or matrix with columns `x` and `y`. If column +#' names differ, the first two columns are used and renamed. Rows containing +#' `NA` in `x` or `y` are silently dropped. +#' @param method Character scalar. Masking algorithm. One of: +#' * `"raster"` *(default)* — Gaussian smoothing on a rasterised grid, +#' binary threshold, then GEOS union of "on" cells. Supports holes and +#' islands. Recommended for most datasets. +#' * `"kde"` — 2-D kernel density estimate via [MASS::kde2d()] contoured +#' with [isoband::isobands()]. Supports holes and islands. Requires +#' packages **MASS** and **isoband**. +#' * `"concave"` — Concave hull via [concaveman::concaveman()]. No holes or +#' islands. Requires package **concaveman**. +#' * `"convex"` — Convex hull via [sf::st_convex_hull()]. No holes or +#' islands. No additional packages required. +#' @param concavity Numeric. Relative concavity for `method = "concave"`. +#' Lower values produce tighter, more concave boundaries; higher values +#' approach the convex hull. Default `2`. +#' @param length_threshold Numeric. Minimum edge length for +#' `method = "concave"`. Edges shorter than this are not included. +#' Default `0`. +#' @param kde_bandwidth Numeric scalar or length-2 vector giving the KDE +#' bandwidth(s) in the x and y directions. `NULL` uses +#' [MASS::bandwidth.nrd()] applied independently to each axis. Only used +#' when `method = "kde"`. Default `NULL`. +#' @param kde_threshold Numeric in `(0, 1)`. KDE probability quantile used +#' as the lower contour level. Smaller values produce larger masks. +#' Default `0.05`. +#' @param kde_resolution Integer. Grid resolution for the KDE density +#' estimate. Larger values give finer boundaries at the cost of memory. +#' Default `256L`. +#' @param raster_resolution Integer. Number of grid cells along each axis for +#' `method = "raster"`. Default `256L`. +#' @param raster_sigma Numeric. Standard deviation of the Gaussian kernel in +#' **coordinate units** (the same units as `x` and `y`). This is the key +#' tuning parameter: +#' * Larger `raster_sigma` → holes fill in, islands merge, boundary smooths. +#' * Smaller `raster_sigma` → holes and fine structure are preserved. +#' * `NULL` (default) → auto-set to 3% of the domain width. +#' @param raster_threshold Numeric in `(0, 1)`. A grid cell is "inside" when +#' its smoothed value exceeds `raster_threshold × max(smoothed_grid)`. +#' Higher values shrink the mask; lower values grow it. Default `0.15`. +#' @param raster_min_pts Integer. Minimum number of points that must fall in +#' a grid cell before it is eligible for the convolution. Cells with fewer +#' points are treated as empty. Default `1L`. +#' @param buffer_dist Numeric. Distance to expand the mask after fitting via +#' [sf::st_buffer()]. Zero means no expansion. Default `0`. +#' @param smooth_mask Logical. If `TRUE`, the mask boundary is simplified with +#' [sf::st_simplify()] after fitting. Default `FALSE`. +#' @param smooth_tolerance Numeric. Douglas–Peucker tolerance passed to +#' [sf::st_simplify()]. `NULL` → auto-set to 1% of the bounding-box +#' diagonal. Default `NULL`. +#' @param n_cores Integer. Number of cores for parallel row-smoothing in +#' [parallel::mclapply()] (Unix/macOS only; ignored on Windows). Default +#' `1L`. +#' @param crs CRS specification accepted by [sf::st_crs()]: an EPSG integer, +#' a WKT string, a PROJ string, or `NA` for no CRS. Default `NA`. +#' @param plot Logical. If `TRUE`, a quick [ggplot2] scatter + polygon plot is +#' printed. Requires **ggplot2** to be installed. Default `FALSE`. +#' @param verbose Logical. If `TRUE`, prints a summary table and intermediate +#' messages (e.g., `raster_sigma` in grid-cell units). Default `TRUE`. +#' +#' @return An [sf::sfc] geometry collection of class `sfc_POLYGON` or +#' `sfc_MULTIPOLYGON`. The CRS is set to `crs`. All input points (after NA +#' removal) are guaranteed to lie inside or on the boundary of the returned +#' mask. If any points fall outside the raw fitted mask a corrective buffer +#' is applied automatically and a warning is issued. +#' +#' @examples +#' set.seed(42) +#' # Two-cluster point cloud +#' coords <- data.frame( +#' x = c(rnorm(200, 0, 3), rnorm(200, 10, 3)), +#' y = c(rnorm(200, 0, 3), rnorm(200, 10, 3)) +#' ) +#' +#' # Raster method (recommended default) +#' mask <- fit_spatial_mask(coords, verbose = FALSE) +#' +#' # Convex hull — instant, no additional packages +#' mask_cv <- fit_spatial_mask(coords, method = "convex", verbose = FALSE) +#' +#' # Concave hull — requires 'concaveman' +#' \donttest{ +#' mask_cc <- fit_spatial_mask(coords, method = "concave", +#' concavity = 1.5, verbose = FALSE) +#' } +#' +#' # Verify all points are inside the mask +#' library(sf) +#' pts <- sf::st_as_sf(coords, coords = c("x", "y"), crs = sf::NA_crs_) +#' all(sf::st_within(pts, sf::st_union(mask), sparse = FALSE)[, 1]) +#' +#' @seealso [sf::st_sfc()], [sf::st_union()], [sf::st_buffer()] +#' +#' @export +#' @importFrom sf st_as_sf st_convex_hull st_union st_sfc st_polygon +#' @importFrom sf st_buffer st_distance st_simplify st_bbox st_area +#' @importFrom sf st_crs<- st_make_valid st_geometry st_sf st_within +#' @importFrom sf st_difference NA_crs_ +fit_spatial_mask <- function( + coords, + + # Method + method = "raster", # "convex" | "concave" | "kde" | "raster" + + # Concave-hull parameters + concavity = 2, + length_threshold = 0, + + # KDE parameters + kde_bandwidth = NULL, + kde_threshold = 0.05, + kde_resolution = 256L, + + # Raster parameters + raster_resolution = 256L, + raster_sigma = NULL, + raster_threshold = 0.15, + raster_min_pts = 1L, + + # Post-processing + buffer_dist = 0, + smooth_mask = FALSE, + smooth_tolerance = NULL, + + # Misc + n_cores = 1L, + crs = NA, + plot = FALSE, + verbose = TRUE +) { + + # ---- 0. Normalise CRS (sf::st_sfc rejects bare NA in sf >= 1.0) ----------- + crs <- if (length(crs) == 1L && is.na(crs)) sf::NA_crs_ else crs + + # ---- 1. Parse and validate coordinates ------------------------------------- + if (is.matrix(coords)) coords <- as.data.frame(coords) + if (!is.data.frame(coords)) stop("`coords` must be a data.frame or matrix.") + if (!all(c("x", "y") %in% names(coords))) { + if (ncol(coords) >= 2L) { + coords <- coords[, 1:2] + names(coords) <- c("x", "y") + } else { + stop("`coords` needs at least two columns, or columns named 'x' and 'y'.") + } + } + coords <- coords[stats::complete.cases(coords[, c("x", "y")]), c("x", "y")] + n_pts <- nrow(coords) + if (n_pts < 3L) + stop("Need at least 3 coordinate pairs to fit a mask (got ", n_pts, ").") + if (n_pts > 50000L && method == "kde" && verbose) + message("Large n = ", n_pts, " with method='kde' — consider method='raster'.") + + # ---- 2. Fit mask ----------------------------------------------------------- + mask_poly <- switch(method, + + # -- Convex hull ------------------------------------------------------------ + "convex" = { + pts <- sf::st_as_sf(coords, coords = c("x", "y"), crs = crs) + sf::st_convex_hull(sf::st_union(pts)) + }, + + # -- Concave hull ----------------------------------------------------------- + "concave" = { + if (!requireNamespace("concaveman", quietly = TRUE)) + stop("Package 'concaveman' is required for method='concave'.\n", + "Install it with: install.packages('concaveman')") + hp <- concaveman::concaveman( + as.matrix(coords), + concavity = concavity, + length_threshold = length_threshold + ) + sf::st_sfc(sf::st_polygon(list(hp)), crs = crs) + }, + + # -- KDE contour (supports holes, slower for large n) ---------------------- + "kde" = { + if (!requireNamespace("MASS", quietly = TRUE)) + stop("Package 'MASS' is required for method='kde'.\n", + "Install it with: install.packages('MASS')") + if (!requireNamespace("isoband", quietly = TRUE)) + stop("Package 'isoband' is required for method='kde'.\n", + "Install it with: install.packages('isoband')") + bw_x <- if (!is.null(kde_bandwidth)) kde_bandwidth[1L] else + MASS::bandwidth.nrd(coords$x) + bw_y <- if (!is.null(kde_bandwidth)) { + if (length(kde_bandwidth) == 2L) kde_bandwidth[2L] else kde_bandwidth[1L] + } else { + MASS::bandwidth.nrd(coords$y) + } + xp <- diff(range(coords$x)) * 0.1 + yp <- diff(range(coords$y)) * 0.1 + kf <- MASS::kde2d( + coords$x, coords$y, + h = c(bw_x, bw_y), + n = kde_resolution, + lims = c(range(coords$x) + c(-xp, xp), + range(coords$y) + c(-yp, yp)) + ) + dv <- as.vector(kf$z) + bands <- isoband::isobands( + kf$x, kf$y, kf$z, + levels_low = stats::quantile(dv, kde_threshold), + levels_high = max(dv) + 1 + ) + .build_sf_with_holes(bands, crs) + }, + + # -- Raster: Gaussian sum → threshold → cell union ------------------------- + # + # Algorithm: + # 1. Bin points onto a regular grid (raster_resolution × raster_resolution). + # 2. Convolve the binary occupancy grid with a 2-D Gaussian of width + # raster_sigma (coordinate units). Equivalent to placing an isotropic + # Gaussian at every point and summing them all. + # 3. Threshold at raster_threshold × max(field). Cells above threshold + # are "inside"; cells below are "outside". + # 4. Convert each "inside" cell to an axis-aligned rectangle and dissolve + # via GEOS union. Holes and islands emerge from the geometry naturally + # — no ring-winding logic required. + # 5. Smooth the staircase boundary with a morphological close + # (buffer out then in by ~0.6 × cell diagonal). + "raster" = { + + # Grid cell edges + xpad <- diff(range(coords$x)) * 0.05 + ypad <- diff(range(coords$y)) * 0.05 + xb <- seq(min(coords$x) - xpad, max(coords$x) + xpad, + length.out = raster_resolution + 1L) + yb <- seq(min(coords$y) - ypad, max(coords$y) + ypad, + length.out = raster_resolution + 1L) + hx <- xb[2L] - xb[1L] # cell width (coordinate units) + hy <- yb[2L] - yb[1L] # cell height (coordinate units) + + # Bin points: cm[yi, xi] = point count at cell (xi, yi) + xi <- pmax(1L, pmin(raster_resolution, + findInterval(coords$x, xb, rightmost.closed = TRUE))) + yi <- pmax(1L, pmin(raster_resolution, + findInterval(coords$y, yb, rightmost.closed = TRUE))) + cv <- tabulate(yi + (xi - 1L) * raster_resolution, + nbins = raster_resolution^2L) + cm <- matrix(cv, nrow = raster_resolution, ncol = raster_resolution) + + # Convert raster_sigma from coordinate units to grid-cell units + domain_w <- diff(range(coords$x)) + 2 * xpad + sigma_cu <- if (is.null(raster_sigma)) 0.03 * domain_w else raster_sigma + sigma_gc <- sigma_cu / hx # grid-cell units for convolution kernel + + if (verbose) + message(" raster_sigma = ", round(sigma_cu, 4), + " coord units (", round(sigma_gc, 1), " grid cells)") + + # Convolve binary occupancy with Gaussian — sum of point Gaussians + indicator <- (cm >= raster_min_pts) * 1.0 + sm <- .gaussian_smooth_2d(indicator, sigma = sigma_gc, + n_cores = n_cores) + + # Threshold → binary + thresh <- raster_threshold * max(sm, na.rm = TRUE) + binary <- sm >= thresh + on_cells <- which(binary, arr.ind = TRUE) # [row = yi, col = xi] + + if (nrow(on_cells) == 0L) + stop("No grid cells above threshold. Try: lower raster_threshold, ", + "increase raster_sigma, or increase raster_resolution.") + + # Build one closed rectangle per on-cell + ri <- on_cells[, 1L] # y indices + ci <- on_cells[, 2L] # x indices + rects <- lapply(seq_len(nrow(on_cells)), function(k) { + sf::st_polygon(list(matrix( + c(xb[ci[k]], yb[ri[k]], + xb[ci[k] + 1L], yb[ri[k]], + xb[ci[k] + 1L], yb[ri[k] + 1L], + xb[ci[k]], yb[ri[k] + 1L], + xb[ci[k]], yb[ri[k]]), # close ring + ncol = 2L, byrow = TRUE + ))) + }) + + # GEOS dissolve — topology (holes, islands) emerges automatically + raw <- sf::st_union(sf::st_sfc(rects, crs = crs)) + + # Morphological close: smooth staircase boundary. + # Buffer out then in by ~0.6 × cell diagonal. + cell_diag <- sqrt(hx^2 + hy^2) + raw <- sf::st_buffer(raw, dist = cell_diag * 0.6) + raw <- sf::st_buffer(raw, dist = -cell_diag * 0.6) + + sf::st_make_valid(raw) + }, + + stop("Unknown method '", method, "'. Choose: convex, concave, kde, raster.") + ) + + if (!inherits(mask_poly, "sfc")) mask_poly <- sf::st_geometry(mask_poly) + + # ---- 3. Optional buffer ---------------------------------------------------- + if (buffer_dist > 0) + mask_poly <- sf::st_buffer(mask_poly, dist = buffer_dist) + + # ---- 4. Containment guarantee ---------------------------------------------- + pts_sfc <- sf::st_geometry( + sf::st_as_sf(coords, coords = c("x", "y"), crs = crs)) + contained <- sf::st_within(pts_sfc, sf::st_union(mask_poly), sparse = FALSE) + n_out <- sum(!contained[, 1L]) + if (n_out > 0L) { + warning(n_out, " point(s) outside mask — applying corrective buffer.") + dists <- sf::st_distance(pts_sfc[!contained[, 1L]], + sf::st_union(mask_poly)) + mask_poly <- sf::st_buffer(mask_poly, + dist = max(as.numeric(dists)) * 1.05) + } + + # ---- 5. Optional boundary smoothing ---------------------------------------- + if (smooth_mask) { + if (is.null(smooth_tolerance)) { + bb <- sf::st_bbox(mask_poly) + smooth_tolerance <- sqrt((bb["xmax"] - bb["xmin"])^2 + + (bb["ymax"] - bb["ymin"])^2) * 0.01 + } + mask_poly <- sf::st_simplify(mask_poly, dTolerance = smooth_tolerance) + } + + sf::st_crs(mask_poly) <- crs + + # ---- 6. Verbose summary ---------------------------------------------------- + if (verbose) { + bb <- sf::st_bbox(mask_poly) + area <- suppressWarnings(as.numeric(sf::st_area(mask_poly))) + cat("--------------------------------------------------\n") + cat(" TissueMask: spatial mask fitted\n") + cat(" Method :", method, "\n") + cat(" Points :", n_pts, "\n") + cat(" Sub-geometries :", length(mask_poly), "\n") + cat(" Bounding box : x [", round(bb["xmin"], 3), ",", + round(bb["xmax"], 3), "] y [", + round(bb["ymin"], 3), ",", round(bb["ymax"], 3), "]\n") + cat(" Area :", round(area, 4), "\n") + cat(" Buffer applied :", buffer_dist, "\n") + cat("--------------------------------------------------\n") + } + + # ---- 7. Optional quick plot ------------------------------------------------ + if (plot) { + if (!requireNamespace("ggplot2", quietly = TRUE)) + message("Package 'ggplot2' not installed; skipping plot.") + else { + mask_sf <- sf::st_sf(geometry = mask_poly) + print(ggplot2::ggplot() + + ggplot2::geom_sf( + data = mask_sf, + fill = "#4c9be8", alpha = 0.2, + color = "#1a5fa8", linewidth = 0.55) + + ggplot2::geom_point( + data = coords, ggplot2::aes(x = x, y = y), + color = "#c0392b", size = 0.7, alpha = 0.5) + + ggplot2::coord_sf(datum = NA) + + ggplot2::labs( + title = paste0("TissueMask | method = '", method, "'"), + subtitle = paste0(n_pts, " points \u00b7 area = ", + round(suppressWarnings( + as.numeric(sf::st_area(mask_poly))), 2)), + x = "X", y = "Y") + + ggplot2::theme_minimal(base_size = 11)) + } + } + + return(mask_poly) +} diff --git a/R/utils.R b/R/utils.R new file mode 100644 index 0000000..edb8acc --- /dev/null +++ b/R/utils.R @@ -0,0 +1,151 @@ +# Internal helper functions for TissueMask +# These are not exported and not part of the public API. + +# ---- Geometry helpers -------------------------------------------------------- + +#' Signed area of a ring matrix +#' +#' Computes the signed area of a closed ring via the shoelace formula. +#' Positive = counter-clockwise (exterior), negative = clockwise (hole). +#' +#' @param ring_mat Numeric matrix with columns for x and y coordinates. +#' Must have at least 3 rows. +#' @return Numeric scalar (signed area). +#' @keywords internal +.signed_area <- function(ring_mat) { + n <- nrow(ring_mat) + if (n < 3L) return(0) + x <- ring_mat[, 1L] + y <- ring_mat[, 2L] + 0.5 * sum(x[seq_len(n - 1L)] * y[seq_len(n - 1L) + 1L] - + x[seq_len(n - 1L) + 1L] * y[seq_len(n - 1L)]) +} + +# ---- Gaussian convolution helpers -------------------------------------------- + +#' Normalised 1-D Gaussian kernel +#' +#' @param sigma Standard deviation in grid-cell units. +#' @return Numeric vector of kernel weights (sums to 1). +#' @keywords internal +.gaussian_kernel_1d <- function(sigma) { + r <- ceiling(3 * sigma) + k <- exp(-((-r):r)^2 / (2 * sigma^2)) + k / sum(k) +} + +#' Row-wise 1-D convolution +#' +#' @param mat Numeric matrix. +#' @param k Filter kernel vector. +#' @return Matrix with same dimensions as `mat`, edge NA values set to 0. +#' @importFrom stats filter +#' @keywords internal +.smooth_rows <- function(mat, k) { + t(apply(mat, 1L, function(row) { + s <- stats::filter(row, k, method = "convolution", sides = 2L) + s[is.na(s)] <- 0 + as.numeric(s) + })) +} + +#' Column-wise 1-D convolution +#' +#' @param mat Numeric matrix. +#' @param k Filter kernel vector. +#' @return Matrix with same dimensions as `mat`, edge NA values set to 0. +#' @importFrom stats filter +#' @keywords internal +.smooth_cols <- function(mat, k) { + apply(mat, 2L, function(col) { + s <- stats::filter(col, k, method = "convolution", sides = 2L) + s[is.na(s)] <- 0 + as.numeric(s) + }) +} + +#' Separable 2-D Gaussian convolution +#' +#' Applies a separable Gaussian blur to a matrix by convolving rows then +#' columns with a 1-D Gaussian kernel. `sigma` is in **grid-cell units**. +#' For parallel row processing on Unix, set `n_cores > 1`. +#' +#' @param mat Numeric matrix to smooth. +#' @param sigma Gaussian standard deviation in grid-cell units. +#' @param n_cores Integer. Number of cores for parallel row smoothing +#' (Unix only). Default `1L`. +#' @return Smoothed numeric matrix. +#' @importFrom parallel mclapply +#' @keywords internal +.gaussian_smooth_2d <- function(mat, sigma, n_cores = 1L) { + k <- .gaussian_kernel_1d(sigma) + if (n_cores > 1L && .Platform$OS.type == "unix") { + row_list <- parallel::mclapply(seq_len(nrow(mat)), function(i) { + s <- stats::filter(mat[i, ], k, method = "convolution", sides = 2L) + s[is.na(s)] <- 0 + as.numeric(s) + }, mc.cores = n_cores) + mat <- do.call(rbind, row_list) + } else { + mat <- .smooth_rows(mat, k) + } + .smooth_cols(mat, k) +} + +# ---- KDE polygon assembly ---------------------------------------------------- + +#' Assemble sf geometry with holes from isoband output +#' +#' Used only by `method = "kde"`. Takes a list of isoband band objects, +#' extracts rings, sorts by area, and assembles exterior polygons with +#' interior holes using GEOS set operations. +#' +#' @param bands List of band objects from [isoband::isobands()], each with +#' `$x`, `$y`, and optionally `$id` components. +#' @param crs CRS specification for the output [sf::sfc] object. +#' @return An [sf::sfc] geometry (single or multi-polygon with holes). +#' @keywords internal +.build_sf_with_holes <- function(bands, crs) { + rings <- list() + for (band in bands) { + if (length(band$x) == 0L) next + ids <- if (!is.null(band$id)) band$id else rep(1L, length(band$x)) + for (uid in unique(ids[!is.na(ids)])) { + sel <- !is.na(ids) & ids == uid + rx <- band$x[sel] + ry <- band$y[sel] + if (length(rx) < 3L) next + if (rx[1L] != rx[length(rx)] || ry[1L] != ry[length(ry)]) { + rx <- c(rx, rx[1L]) + ry <- c(ry, ry[1L]) + } + mat <- cbind(rx, ry) + area <- abs(.signed_area(mat)) + rings <- c(rings, list(list(mat = mat, area = area))) + } + } + if (length(rings) == 0L) + stop("No rings extracted. Adjust kde_threshold / kde_bandwidth parameters.") + + ord <- order(sapply(rings, `[[`, "area"), decreasing = TRUE) + rings <- rings[ord] + + polys <- lapply(rings, function(r) { + mat <- rbind(r$mat, r$mat[1L, , drop = FALSE]) + dupl <- c(FALSE, rowSums(abs(diff(mat))) == 0) + sf::st_sfc(sf::st_polygon(list(mat[!dupl, , drop = FALSE])), crs = crs) + }) + + result <- polys[[1L]] + if (length(polys) > 1L) { + for (i in seq(2L, length(polys))) { + inside <- tryCatch( + isTRUE(sf::st_within(polys[[i]], result, sparse = FALSE)[1L, 1L]), + error = function(e) FALSE + ) + result <- if (inside) sf::st_difference(result, polys[[i]]) + else sf::st_union(result, polys[[i]]) + } + } + sf::st_make_valid(result) +} diff --git a/README.md b/README.md index 9072e60..2d244e6 100644 --- a/README.md +++ b/README.md @@ -1 +1,103 @@ -# TissueMask \ No newline at end of file +# TissueMask + + +[![R-CMD-check](https://github.com/RaredonLab/TissueMask/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/RaredonLab/TissueMask/actions/workflows/R-CMD-check.yaml) +[![pkgdown](https://github.com/RaredonLab/TissueMask/actions/workflows/pkgdown.yaml/badge.svg)](https://raredonlab.github.io/TissueMask/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + + +**TissueMask** fits polygon spatial masks to XY point coordinate data — cell +centroids, single-molecule transcript locations, or any spatial point process +— and returns an [`sf`](https://r-spatial.github.io/sf/) geometry object that +faithfully captures tissue shape, including internal voids (vessel lumens, +necrotic cores) and disconnected fragments. + +It is the first component of the **TissueSuite** family developed at the +Raredon Laboratory, Yale School of Medicine. + +## Installation + +```r +# Install from GitHub (requires pak) +# install.packages("pak") +pak::pkg_install("RaredonLab/TissueMask") +``` + +Optional method dependencies: + +```r +install.packages(c("concaveman", "MASS", "isoband", "ggplot2")) +``` + +## Quick start + +```r +library(TissueMask) +library(sf) + +set.seed(42) +coords <- data.frame( + x = c(rnorm(500, 0, 5), rnorm(500, 20, 5)), + y = c(rnorm(500, 0, 5), rnorm(500, 20, 5)) +) + +# Fit mask with the recommended raster method +mask <- fit_spatial_mask(coords, method = "raster", plot = TRUE) + +# Confirm all points are contained +pts <- sf::st_as_sf(coords, coords = c("x", "y"), crs = sf::NA_crs_) +stopifnot(all(sf::st_within(pts, sf::st_union(mask), sparse = FALSE)[, 1])) +``` + +## Methods + +| `method` | Topology | Speed | Key packages | +|---|---|---|---| +| `"raster"` *(default)* | Holes + islands | Fast | `sf` only | +| `"kde"` | Holes + islands | Moderate | `MASS`, `isoband` | +| `"concave"` | No holes | Fast | `concaveman` | +| `"convex"` | No holes | Instant | `sf` only | + +### Raster method (recommended) + +The raster method: + +1. Bins points onto a regular grid. +2. Convolves the occupancy grid with a 2-D Gaussian of width `raster_sigma` + (in coordinate units). +3. Thresholds at `raster_threshold x max`. Cells above threshold are "inside". +4. Dissolves all "inside" cells via GEOS union — **holes and islands emerge + from the geometry automatically**, with no ring-winding logic required. +5. Applies a morphological close to smooth the staircase boundary. + +Key tuning parameters: + +| Parameter | Effect | +|---|---| +| `raster_sigma` up | Holes fill in, islands merge, boundary smooths | +| `raster_sigma` down | Holes and fine structure preserved | +| `raster_threshold` up | Mask shrinks (requires denser coverage) | +| `raster_threshold` down | Mask grows (accepts sparse regions) | + +## Part of TissueSuite + +``` +TissueSuite ++-- TissueMask <- you are here +| fit_spatial_mask() -> sf sfc mask geometry ++-- TissueField (depends on TissueMask) + estimate_concentration_field() -> diffusion-clearance PDE solver +``` + +## Documentation + +Full documentation and rendered vignettes: + + +- **Getting Started** — all four methods with worked examples +- **Holes, Islands, and Parameter Tuning** — donut, swiss-cheese, and + archipelago topologies; `raster_sigma` sweep + +## License + +MIT (c) Raredon Laboratory, Yale School of Medicine diff --git a/_pkgdown.yml b/_pkgdown.yml new file mode 100644 index 0000000..389fba8 --- /dev/null +++ b/_pkgdown.yml @@ -0,0 +1,59 @@ +url: https://raredonlab.github.io/TissueMask/ + +template: + bootstrap: 5 + bootswatch: flatly + bslib: + primary: "#1a5fa8" + link-color: "#1a5fa8" + +home: + title: "TissueMask — Spatial Boundary Mask Fitting" + description: > + Fit polygon spatial masks to XY point coordinate data from spatial + transcriptomics and spatial proteomics experiments. Supports convex + hull, concave hull, KDE, and raster-based methods with hole and island + detection. + links: + - text: "RaredonLab on GitHub" + href: https://github.com/RaredonLab + - text: "TissueField (companion package)" + href: https://github.com/RaredonLab/TissueField + +navbar: + structure: + left: [intro, reference, articles, news] + right: [github] + components: + github: + icon: fab fa-github fa-lg + href: https://github.com/RaredonLab/TissueMask + aria-label: GitHub + +reference: + - title: "Core function" + desc: > + The primary exported function for fitting spatial masks. + contents: + - fit_spatial_mask + - title: "Package" + desc: Package-level documentation. + contents: + - TissueMask-package + +articles: + - title: "Vignettes" + navbar: Vignettes + contents: + - getting-started + - advanced-topology + +news: + releases: + - text: "TissueMask 0.1.0" + href: https://github.com/RaredonLab/TissueMask/releases/tag/v0.1.0 + +footer: + structure: + left: developed_by + right: built_with diff --git a/demo_fit_spatial_mask.R b/demo_fit_spatial_mask.R new file mode 100644 index 0000000..d9a52f2 --- /dev/null +++ b/demo_fit_spatial_mask.R @@ -0,0 +1,217 @@ +# ============================================================ +# demo_fit_spatial_mask.R +# +# Basic demonstrations of fit_spatial_mask() across all +# three methods and their core tunable parameters. +# +# Run after sourcing: source("fit_spatial_mask.R") +# +# Required packages: +# install.packages(c("sf","concaveman","MASS","isoband", +# "ggplot2","patchwork")) +# +# Sections: +# 1 — Circular cloud (sanity check) +# 2 — Elongated cloud (anisotropy) +# 3 — L-shaped cloud (non-convex; key diagnostic) +# 4 — Multi-cluster blobs +# 5 — Concavity parameter sweep +# 6 — Buffer and smoothing effects +# 7 — Validation: point containment table +# ============================================================ + +library(sf) +library(ggplot2) +library(patchwork) +source("fit_spatial_mask.R") +set.seed(42) + +plot_mask <- function(mask, coords, title="", subtitle="", + fill="#4c9be8", border="#1a5fa8", + pt_col="#c0392b", pt_size=1.2, pt_alpha=0.55) { + mask_sf <- sf::st_sf(geometry = mask) + ggplot() + + geom_sf(data=mask_sf, + fill=fill, alpha=0.2, color=border, linewidth=0.55) + + geom_point(data=coords, aes(x=x,y=y), color=pt_col, + size=pt_size, alpha=pt_alpha) + + coord_sf(datum=NA) + + labs(title=title, subtitle=subtitle, x="X", y="Y") + + theme_minimal(base_size=9.5) + + theme(plot.title=element_text(face="bold",size=9.5), + plot.subtitle=element_text(size=8,color="grey40"), + panel.grid=element_line(color="grey93")) +} + +# ============================================================ +# SECTION 1 — Circular cloud +# ============================================================ +n <- 400 +coords_circle <- data.frame(x=rnorm(n,0,1), y=rnorm(n,0,1)) + +m1a <- fit_spatial_mask(coords_circle, method="convex", verbose=FALSE) +m1b <- fit_spatial_mask(coords_circle, method="concave", concavity=2, verbose=FALSE) +m1c <- fit_spatial_mask(coords_circle, method="kde", kde_threshold=0.02, + buffer_dist=0.1, verbose=FALSE) + +print( + plot_mask(m1a, coords_circle, title="1a. Circular — Convex", subtitle="method='convex'") + + plot_mask(m1b, coords_circle, title="1b. Circular — Concave", subtitle="concavity=2") + + plot_mask(m1c, coords_circle, title="1c. Circular — KDE", + subtitle="threshold=0.02", fill="#27ae60", border="#1e8449") + + plot_annotation(title="Section 1: Circular Cloud — Sanity Check", + theme=theme(plot.title=element_text(face="bold",size=13))) +) + +# ============================================================ +# SECTION 2 — Elongated cloud +# ============================================================ +coords_elong <- data.frame(x=rnorm(n,0,3), y=rnorm(n,0,0.6)) + +m2a <- fit_spatial_mask(coords_elong, method="convex", verbose=FALSE) +m2b <- fit_spatial_mask(coords_elong, method="concave", verbose=FALSE) +m2c <- fit_spatial_mask(coords_elong, method="kde", kde_threshold=0.02, + buffer_dist=0.1, verbose=FALSE) + +print( + plot_mask(m2a, coords_elong, title="2a. Elongated — Convex") + + plot_mask(m2b, coords_elong, title="2b. Elongated — Concave") + + plot_mask(m2c, coords_elong, title="2c. Elongated — KDE", + fill="#27ae60", border="#1e8449") + + plot_annotation(title="Section 2: Elongated Cloud", + theme=theme(plot.title=element_text(face="bold",size=13))) +) + +# ============================================================ +# SECTION 3 — L-shaped cloud (KEY test) +# ============================================================ +coords_L <- rbind( + data.frame(x=runif(200,0,5)+rnorm(200,0,0.05), y=runif(200,0,1)+rnorm(200,0,0.05)), + data.frame(x=runif(200,0,1)+rnorm(200,0,0.05), y=runif(200,0,5)+rnorm(200,0,0.05))) + +m3a <- fit_spatial_mask(coords_L, method="convex", verbose=FALSE) +m3b <- fit_spatial_mask(coords_L, method="concave", concavity=1.5, verbose=FALSE) +m3c <- fit_spatial_mask(coords_L, method="concave", concavity=3.0, verbose=FALSE) + +print( + plot_mask(m3a, coords_L, title="3a. L-shape — Convex", + subtitle="[includes empty corner]", fill="#e74c3c", border="#922b21") + + plot_mask(m3b, coords_L, title="3b. Concave tight (1.5)", + subtitle="[correctly excludes corner]") + + plot_mask(m3c, coords_L, title="3c. Concave relaxed (3.0)", + subtitle="[partially includes corner]", fill="#8e44ad", border="#6c3483") + + plot_annotation(title="Section 3: L-Shaped Cloud — Non-Convex", + subtitle="Lower concavity wraps more tightly; convex hull fails", + theme=theme(plot.title=element_text(face="bold",size=13), + plot.subtitle=element_text(size=9,color="grey40"))) +) + +# ============================================================ +# SECTION 4 — Multi-cluster +# ============================================================ +mk_cluster <- function(n,cx,cy,sd=0.4) data.frame(x=rnorm(n,cx,sd),y=rnorm(n,cy,sd)) +coords_multi <- rbind(mk_cluster(150,0,0), mk_cluster(150,4,1), mk_cluster(150,2,4)) + +m4a <- fit_spatial_mask(coords_multi, method="convex", verbose=FALSE) +m4b <- fit_spatial_mask(coords_multi, method="concave", concavity=2, verbose=FALSE) +m4c <- fit_spatial_mask(coords_multi, method="kde", kde_threshold=0.01, + buffer_dist=0.15, verbose=FALSE) +m4d <- fit_spatial_mask(coords_multi, method="kde", kde_threshold=0.25, + buffer_dist=0.15, verbose=FALSE) + +print( + (plot_mask(m4a,coords_multi,title="4a. Multi — Convex") + + plot_mask(m4b,coords_multi,title="4b. Multi — Concave")) / + (plot_mask(m4c,coords_multi,title="4c. Multi — KDE inclusive", + subtitle="threshold=0.01", fill="#27ae60", border="#1e8449") + + plot_mask(m4d,coords_multi,title="4d. Multi — KDE tight", + subtitle="threshold=0.25: separate masks", fill="#8e44ad", border="#6c3483")) + + plot_annotation(title="Section 4: Multi-Cluster", + theme=theme(plot.title=element_text(face="bold",size=13))) +) + +# ============================================================ +# SECTION 5 — Concavity sweep +# ============================================================ +theta <- seq(0.2, pi-0.2, length.out=300) +coords_cres <- data.frame( + x=cos(theta)*(1+rnorm(300,0,0.08))*3, + y=sin(theta)*(1+rnorm(300,0,0.08))*3 - cos(theta)*1.5) + +pal <- c("#e74c3c","#e67e22","#27ae60","#2980b9") +sw <- mapply(function(cv,col) { + m <- fit_spatial_mask(coords_cres, method="concave", concavity=cv, verbose=FALSE) + plot_mask(m, coords_cres, title=paste0("concavity = ",cv), fill=col, border=col) +}, c(1.2,2,3,5), pal, SIMPLIFY=FALSE) + +print(wrap_plots(sw, nrow=1) + + plot_annotation( + title="Section 5: Concavity Sweep — Crescent", + subtitle="Lower = tighter | Higher = approaches convex", + theme=theme(plot.title=element_text(face="bold",size=13), + plot.subtitle=element_text(size=9,color="grey40"))) +) + +# ============================================================ +# SECTION 6 — Buffer and smoothing +# ============================================================ +r <- sqrt(runif(250,0.4^2,1^2)); ang <- runif(250,0,2*pi) +coords_ring <- data.frame(x=r*cos(ang)*5, y=r*sin(ang)*5) + +m6a <- fit_spatial_mask(coords_ring, method="concave", concavity=2, + buffer_dist=0, smooth_mask=FALSE, verbose=FALSE) +m6b <- fit_spatial_mask(coords_ring, method="concave", concavity=2, + buffer_dist=0.3, smooth_mask=FALSE, verbose=FALSE) +m6c <- fit_spatial_mask(coords_ring, method="concave", concavity=2, + buffer_dist=0.3, smooth_mask=TRUE, + smooth_tolerance=0.15, verbose=FALSE) + +print( + plot_mask(m6a, coords_ring, title="6a. No buffer / no smooth") + + plot_mask(m6b, coords_ring, title="6b. Buffer=0.3", + fill="#27ae60", border="#1e8449") + + plot_mask(m6c, coords_ring, title="6c. Buffer + smooth", + fill="#8e44ad", border="#6c3483") + + plot_annotation(title="Section 6: Buffer and Smoothing", + theme=theme(plot.title=element_text(face="bold",size=13))) +) + +# ============================================================ +# SECTION 7 — Validation table +# ============================================================ +cases <- list( + list(label="Circle/convex", mask=m1a, coords=coords_circle), + list(label="Circle/concave", mask=m1b, coords=coords_circle), + list(label="Circle/kde", mask=m1c, coords=coords_circle), + list(label="Elongated/convex",mask=m2a, coords=coords_elong), + list(label="Elongated/concave",mask=m2b,coords=coords_elong), + list(label="Elongated/kde", mask=m2c, coords=coords_elong), + list(label="L-shape/convex", mask=m3a, coords=coords_L), + list(label="L-shape/conc1.5", mask=m3b, coords=coords_L), + list(label="L-shape/conc3", mask=m3c, coords=coords_L), + list(label="Multi/convex", mask=m4a, coords=coords_multi), + list(label="Multi/concave", mask=m4b, coords=coords_multi), + list(label="Multi/kde-lo", mask=m4c, coords=coords_multi), + list(label="Multi/kde-hi", mask=m4d, coords=coords_multi), + list(label="Ring/raw", mask=m6a, coords=coords_ring), + list(label="Ring/buffer", mask=m6b, coords=coords_ring), + list(label="Ring/smooth", mask=m6c, coords=coords_ring) +) + +results <- do.call(rbind, lapply(cases, function(vc) { + pts <- sf::st_geometry(sf::st_as_sf(vc$coords, coords=c("x","y"))) + ok <- sf::st_within(pts, sf::st_union(vc$mask), sparse=FALSE)[,1L] + n <- nrow(vc$coords) + data.frame(Case=vc$label, N=n, Enclosed=sum(ok), + Pct=round(100*sum(ok)/n,2), + Result=ifelse(sum(ok)==n,"\u2713 PASS","\u2717 FAIL"), + stringsAsFactors=FALSE) +})) + +cat("\n============================================================\n") +cat(" VALIDATION: Point Containment\n") +cat("============================================================\n") +print(results, row.names=FALSE) +cat("============================================================\n") +cat("demo_fit_spatial_mask.R complete.\n") + diff --git a/demo_holes_and_scale.R b/demo_holes_and_scale.R new file mode 100644 index 0000000..dbf511b --- /dev/null +++ b/demo_holes_and_scale.R @@ -0,0 +1,433 @@ +# ============================================================ +# demo_holes_and_scale.R (v3) +# +# Demonstrates fit_spatial_mask() on topologically complex +# cases: interior holes, irregular boundaries, archipelagos, +# and 100k-cell scale tests. +# +# Run after sourcing: source("fit_spatial_mask.R") +# +# Fixes in v3: +# 1. raster_sigma now in COORDINATE UNITS (not grid cells). +# Old calls used sigma in grid-cell units; values have been +# converted: sigma_cu = sigma_gc * (domain_width / resolution). +# 2. All hand-built circle/ellipse polygons now explicitly close +# their rings with rbind(mat, mat[1L,]). sf::st_polygon +# requires first == last row; seq(0, 2*pi, length.out=N) +# does NOT guarantee this due to floating-point rounding. +# +# Required packages: +# install.packages(c("sf","concaveman","MASS","isoband", +# "ggplot2","patchwork")) +# +# Sections: +# 1 — Donut: single interior void +# 2 — Swiss cheese: multiple holes +# 3 — Fractured map: irregular boundary + lakes +# 4 — Archipelago: disconnected islands +# 5 — Nested crescent voids +# 6 — sigma sensitivity sweep (coordinate units) +# 7 — Scale test: 100,000 cells +# 8 — Validation table +# ============================================================ + +library(sf) +library(ggplot2) +library(patchwork) +source("fit_spatial_mask.R") +set.seed(123) + + +# ---- Shared helpers ---------------------------------------------------------- + +plot_mask <- function(mask, coords, title = "", subtitle = "", + fill = "#4c9be8", border = "#1a5fa8", + pt_col = "#c0392b", pt_size = 0.9, pt_alpha = 0.5) { + # geom_sf is required for correct hole rendering. + # geom_polygon draws a line between consecutive rings in the same group, + # producing diagonal artefacts across hole interiors. + mask_sf <- sf::st_sf(geometry = mask) + ggplot() + + geom_sf(data = mask_sf, + fill = fill, alpha = 0.2, color = border, linewidth = 0.55) + + geom_point(data = coords, aes(x = x, y = y), + color = pt_col, size = pt_size, alpha = pt_alpha) + + coord_sf(datum = NA) + # datum=NA: suppress geographic graticule + labs(title = title, subtitle = subtitle, x = "X", y = "Y") + + theme_minimal(base_size = 9.5) + + theme(plot.title = element_text(face = "bold", size = 9.5), + plot.subtitle = element_text(size = 8, color = "grey40"), + panel.grid = element_line(color = "grey93")) +} + +# Sample n points uniformly from inside a polygon +sample_in_polygon <- function(poly_sf, n, max_tries = 20) { + bb <- sf::st_bbox(poly_sf); mu <- sf::st_union(poly_sf) + pts <- data.frame(x = numeric(0), y = numeric(0)) + for (i in seq_len(max_tries)) { + if (nrow(pts) >= n) break + cnd <- data.frame(x = runif(n * 8, bb["xmin"], bb["xmax"]), + y = runif(n * 8, bb["ymin"], bb["ymax"])) + ok <- as.logical( + sf::st_within(sf::st_as_sf(cnd, coords = c("x","y")), mu, + sparse = FALSE)[, 1L]) + pts <- rbind(pts, cnd[ok, ]) + } + pts[seq_len(min(n, nrow(pts))), ] +} + +# Build a closed ellipse polygon. +# IMPORTANT: seq(0, 2*pi, length.out=n) does NOT produce a closed ring +# due to float rounding (cos(2*pi) != cos(0) exactly). We always +# append the first row explicitly. +make_ellipse_poly <- function(cx, cy, a, b, angle_deg, n = 80) { + th <- seq(0, 2 * pi, length.out = n) + ang <- angle_deg * pi / 180 + xe <- a * cos(th); ye <- b * sin(th) + mat <- cbind(cx + xe * cos(ang) - ye * sin(ang), + cy + xe * sin(ang) + ye * cos(ang)) + mat <- rbind(mat, mat[1L, ]) # explicit ring closure + sf::st_polygon(list(mat)) +} + +# Helper: closed circle matrix +.circle_mat <- function(cx, cy, r, n = 100) { + th <- seq(0, 2 * pi, length.out = n) + mat <- cbind(cx + r * cos(th), cy + r * sin(th)) + rbind(mat, mat[1L, ]) # explicit ring closure +} + + +# ============================================================ +# SECTION 1 — Donut +# ============================================================ +cat("Section 1: Donut...\n") + +r_d <- sqrt(runif(800, 2^2, 5^2)) +th_d <- runif(800, 0, 2 * pi) +coords_donut <- data.frame(x = r_d * cos(th_d), y = r_d * sin(th_d)) +# Domain: x,y in [-5, 5], domain width ~ 10 coord units. +# raster_sigma = 0.25 coordinate units +# (previously 8 grid cells at res=400 over domain=11 → 8*(11/400) = 0.22) + +m1_r <- fit_spatial_mask(coords_donut, method = "raster", + raster_resolution = 256L, raster_sigma = 0.25, raster_threshold = 0.2, + verbose = FALSE) +m1_c <- fit_spatial_mask(coords_donut, method = "convex", verbose = FALSE) + +print( + plot_mask(m1_r, coords_donut, + title = "1a. Donut \u2014 Raster (hole \u2713)", + subtitle = "\u03c3 = 0.25 coord units, threshold = 0.2") + + plot_mask(m1_c, coords_donut, + title = "1b. Donut \u2014 Convex", + subtitle = "[hole filled]", + fill = "#e74c3c", border = "#922b21") + + plot_annotation(title = "Section 1: Donut", + theme = theme(plot.title = element_text(face = "bold", size = 13))) +) + + +# ============================================================ +# SECTION 2 — Swiss cheese +# ============================================================ +cat("Section 2: Swiss cheese...\n") + +# Four circular holes in a 10x10 square. +# Domain width = 10 coord units. +# raster_sigma = 0.25 coord units +# (previously 7 grid cells at res=450 over domain=11 → 7*(11/450) = 0.17) +hdefs <- list( + list(cx = 2.5, cy = 7.5, r = 1.1), + list(cx = 7.0, cy = 6.5, r = 1.4), + list(cx = 4.0, cy = 2.5, r = 0.9), + list(cx = 8.0, cy = 2.0, r = 1.2) +) +outer_sq <- sf::st_sfc(sf::st_polygon(list( + rbind(c(0,0), c(10,0), c(10,10), c(0,10), c(0,0))))) +hcircles <- lapply(hdefs, function(h) { + sf::st_polygon(list(.circle_mat(h$cx, h$cy, h$r))) +}) +swiss_region <- sf::st_difference(outer_sq, + sf::st_sfc(sf::st_union(sf::st_sfc(hcircles)))) +coords_swiss <- sample_in_polygon(swiss_region, n = 1500) + +m2_r <- fit_spatial_mask(coords_swiss, method = "raster", + raster_resolution = 256L, raster_sigma = 0.25, raster_threshold = 0.2, + verbose = FALSE) +m2_c <- fit_spatial_mask(coords_swiss, method = "concave", + concavity = 1.8, verbose = FALSE) + +print( + plot_mask(m2_r, coords_swiss, + title = "2a. Swiss \u2014 Raster (holes \u2713)", + subtitle = "\u03c3 = 0.25 coord units, threshold = 0.2") + + plot_mask(m2_c, coords_swiss, + title = "2b. Swiss \u2014 Concave", + subtitle = "[holes filled]", + fill = "#e74c3c", border = "#922b21") + + plot_annotation(title = "Section 2: Swiss Cheese", + theme = theme(plot.title = element_text(face = "bold", size = 13))) +) + + +# ============================================================ +# SECTION 3 — Fractured map +# ============================================================ +cat("Section 3: Fractured map...\n") + +# Irregular outer coast (radius ~9-12), five elliptic lakes. +# Domain width ~ 24 coord units. +# raster_sigma = 0.5 coord units +# (previously 9 grid cells at res=500 over domain=26.4 → 9*(26.4/500) = 0.48) +th_f <- seq(0, 2 * pi, length.out = 600) +r_c <- 9 + 2*sin(3*th_f) + sin(7*th_f) + 0.5*cos(13*th_f) + 0.3*sin(19*th_f) +mat_f <- cbind(r_c * cos(th_f), r_c * sin(th_f)) +mat_f <- rbind(mat_f, mat_f[1L, ]) # explicit ring closure +outer_coast <- sf::st_sfc(sf::st_polygon(list(mat_f))) + +lakes <- sf::st_sfc(list( + make_ellipse_poly(-3.0, 3.5, 2.2, 1.0, 30), + make_ellipse_poly( 4.5, 2.0, 1.8, 1.3, -20), + make_ellipse_poly( 1.0, -4.5, 2.5, 0.8, 60), + make_ellipse_poly(-5.0, -2.0, 1.2, 1.2, 0), + make_ellipse_poly( 3.5, -0.5, 1.0, 0.6, 15) +)) +frac_region <- sf::st_difference(outer_coast, sf::st_union(lakes)) +coords_frac <- sample_in_polygon(frac_region, n = 2000) + +m3_r <- fit_spatial_mask(coords_frac, method = "raster", + raster_resolution = 256L, raster_sigma = 0.5, raster_threshold = 0.18, + verbose = FALSE) +m3_k <- fit_spatial_mask(coords_frac, method = "kde", + kde_resolution = 300L, kde_threshold = 0.01, buffer_dist = 0.1, + verbose = FALSE) + +print( + plot_mask(m3_r, coords_frac, + title = "3a. Fractured \u2014 Raster", + subtitle = "Coast + 5 lakes | \u03c3 = 0.5") + + plot_mask(m3_k, coords_frac, + title = "3b. Fractured \u2014 KDE", + subtitle = "Density-based boundary", + fill = "#27ae60", border = "#1e8449") + + plot_annotation(title = "Section 3: Fractured Map", + theme = theme(plot.title = element_text(face = "bold", size = 13))) +) + + +# ============================================================ +# SECTION 4 — Archipelago +# ============================================================ +cat("Section 4: Archipelago...\n") + +# Three disconnected islands; domain width ~ 16 coord units. +# raster_sigma = 0.4 coord units +# (previously 7 grid cells at res=450 over domain=17.6 → 7*(17.6/450) = 0.27) +mk_isl <- function(n, cx, cy, rx, ry, ns = 0.15) { + th <- runif(n, 0, 2 * pi); r <- sqrt(runif(n)) + data.frame(x = cx + r * rx * cos(th) + rnorm(n, 0, ns), + y = cy + r * ry * sin(th) + rnorm(n, 0, ns)) +} +coords_arch <- rbind(mk_isl(300, 0, 0, 3, 2), + mk_isl(300, 9, 4, 2, 3), + mk_isl(300, 5, -7, 2.5, 1.5)) + +m4_r <- fit_spatial_mask(coords_arch, method = "raster", + raster_resolution = 256L, raster_sigma = 0.4, raster_threshold = 0.18, + verbose = FALSE) +m4_c <- fit_spatial_mask(coords_arch, method = "convex", verbose = FALSE) + +n_geom <- length(sf::st_cast(m4_r, "POLYGON")) +print( + plot_mask(m4_r, coords_arch, + title = "4a. Archipelago \u2014 Raster", + subtitle = sprintf("%d disconnected polygons \u2713", n_geom)) + + plot_mask(m4_c, coords_arch, + title = "4b. Archipelago \u2014 Convex", + subtitle = "[bridges islands]", + fill = "#e74c3c", border = "#922b21") + + plot_annotation(title = "Section 4: Archipelago", + theme = theme(plot.title = element_text(face = "bold", size = 13))) +) + + +# ============================================================ +# SECTION 5 — Nested crescent voids +# ============================================================ +cat("Section 5: Nested voids...\n") + +# Two crescent-shaped voids inside a circle of radius 10. +# Domain width ~ 20 coord units. +# fine sigma = 0.4 (previously 8 gc at res=500 over domain=22 → 0.35) +# coarse sigma = 1.0 (previously 20 gc → 0.88) +mk_cres <- function(cx, cy, ro, ri, dx, dy, n = 100) { + moc <- .circle_mat(cx, cy, ro, n) + mic <- .circle_mat(cx+dx, cy+dy, ri, n) + oc <- sf::st_polygon(list(moc)) + ic <- sf::st_polygon(list(mic)) + sf::st_sfc(sf::st_difference(sf::st_sfc(oc), sf::st_sfc(ic))) +} + +th_od <- seq(0, 2 * pi, length.out = 200) +mat_od <- cbind(10 * cos(th_od), 10 * sin(th_od)) +mat_od <- rbind(mat_od, mat_od[1L, ]) # explicit ring closure +outer_d <- sf::st_sfc(sf::st_polygon(list(mat_od))) +cres1 <- mk_cres(-2, 2, 2.5, 2, 0.9, -0.2) +cres2 <- mk_cres( 3, -3, 2.2, 1.8, -0.7, 0.4) +nest_region <- sf::st_difference(outer_d, sf::st_union(c(cres1, cres2))) +coords_nested <- sample_in_polygon(nest_region, n = 2500) + +m5_fine <- fit_spatial_mask(coords_nested, method = "raster", + raster_resolution = 256L, raster_sigma = 0.4, raster_threshold = 0.18, + verbose = FALSE) +m5_coarse <- fit_spatial_mask(coords_nested, method = "raster", + raster_resolution = 256L, raster_sigma = 1.0, raster_threshold = 0.18, + verbose = FALSE) + +print( + plot_mask(m5_fine, coords_nested, + title = "5a. Nested \u2014 fine \u03c3 = 0.4", + subtitle = "Crescent voids preserved") + + plot_mask(m5_coarse, coords_nested, + title = "5b. Nested \u2014 coarse \u03c3 = 1.0", + subtitle = "Narrow voids filled", + fill = "#8e44ad", border = "#6c3483") + + plot_annotation( + title = "Section 5: Nested Crescent Voids", + subtitle = "Smaller \u03c3 \u2192 finer void detection", + theme = theme(plot.title = element_text(face = "bold", size = 13), + plot.subtitle = element_text(size = 9, color = "grey40"))) +) + + +# ============================================================ +# SECTION 6 — Sigma sweep (coordinate units) +# ============================================================ +cat("Section 6: Sigma sweep...\n") + +# Swiss cheese domain ~ 10 coord units. +# Sweep spans: tight (holes preserved) → loose (holes filled). +# Values in COORDINATE UNITS: c(0.1, 0.2, 0.4, 0.7) +# Equivalent old grid-cell values at res=400, domain=11: +# c(3.6, 7.3, 14.5, 25.5) +sig_vals <- c(0.1, 0.2, 0.4, 0.7) +sig_cols <- c("#e74c3c", "#e67e22", "#27ae60", "#2980b9") + +sig_pls <- mapply(function(sv, col) { + m <- fit_spatial_mask(coords_swiss, method = "raster", + raster_resolution = 256L, raster_sigma = sv, raster_threshold = 0.2, + verbose = FALSE) + nh <- max(0L, length(sf::st_cast(m, "POLYGON")) - 1L) + plot_mask(m, coords_swiss, + title = paste0("\u03c3 = ", sv, " cu"), + subtitle = paste0(nh, " hole(s) detected"), + fill = col, border = col, pt_size = 0.5) +}, sig_vals, sig_cols, SIMPLIFY = FALSE) + +print(wrap_plots(sig_pls, nrow = 1) + + plot_annotation( + title = "Section 6: raster_sigma Sensitivity \u2014 Swiss Cheese", + subtitle = "All values in coordinate units | Small \u03c3 \u2192 holes preserved | Large \u03c3 \u2192 holes filled", + theme = theme(plot.title = element_text(face = "bold", size = 13), + plot.subtitle = element_text(size = 9, color = "grey40"))) +) + + +# ============================================================ +# SECTION 7 — 100k scale test +# ============================================================ +cat("Section 7: 100k scale test...\n") + +# Irregular outer shape radius ~12-14, three elliptic holes. +# Domain width ~ 28 coord units. +# raster_sigma = 0.6 coord units +# (previously 10 grid cells at res=512 over domain=30.8 → 10*(30.8/512) = 0.60) +th100 <- seq(0, 2 * pi, length.out = 400) +r100 <- 12 + sin(5 * th100) + 0.4 * cos(11 * th100) +mat100 <- cbind(r100 * cos(th100), r100 * sin(th100)) +mat100 <- rbind(mat100, mat100[1L, ]) # explicit ring closure +outer100 <- sf::st_sfc(sf::st_polygon(list(mat100))) + +holes100 <- sf::st_sfc(list( + make_ellipse_poly( 3, 5, 3, 1.5, 10), + make_ellipse_poly(-5, 2, 2.5, 2, -30), + make_ellipse_poly( 1, -6, 2, 3, 50) +)) +reg100 <- sf::st_difference(outer100, sf::st_union(holes100)) +cat(" Sampling 100,000 points...\n") +coords_100k <- sample_in_polygon(reg100, n = 100000) + +t_r <- system.time( + m7_r <- fit_spatial_mask(coords_100k, method = "raster", + raster_resolution = 256L, raster_sigma = 0.6, raster_threshold = 0.18, + verbose = TRUE) +) +t_k <- system.time( + m7_k <- fit_spatial_mask(coords_100k, method = "kde", + kde_resolution = 200L, kde_threshold = 0.005, buffer_dist = 0.1, + verbose = TRUE) +) +cat(sprintf("\n Raster (256\u00d7256): %.2f s\n KDE (200\u00d7200): %.2f s\n", + t_r["elapsed"], t_k["elapsed"])) + +sub <- coords_100k[sample(nrow(coords_100k), 5000), ] +print( + plot_mask(m7_r, sub, + title = sprintf("7a. 100k \u2014 Raster (%.1f s)", t_r["elapsed"]), + subtitle = "3 holes detected \u2713", + pt_size = 0.25, pt_alpha = 0.25) + + plot_mask(m7_k, sub, + title = sprintf("7b. 100k \u2014 KDE (%.1f s)", t_k["elapsed"]), + subtitle = "Density boundary", + fill = "#27ae60", border = "#1e8449", + pt_size = 0.25, pt_alpha = 0.25) + + plot_annotation( + title = "Section 7: 100,000-Cell Scale Test", + subtitle = "5,000 of 100,000 points shown", + theme = theme(plot.title = element_text(face = "bold", size = 13), + plot.subtitle = element_text(size = 9, color = "grey40"))) +) + + +# ============================================================ +# SECTION 8 — Validation table +# ============================================================ +cat("\nSection 8: Validation...\n") + +vcases <- list( + list(label = "Donut/raster", mask = m1_r, coords = coords_donut), + list(label = "Donut/convex", mask = m1_c, coords = coords_donut), + list(label = "Swiss/raster", mask = m2_r, coords = coords_swiss), + list(label = "Swiss/concave", mask = m2_c, coords = coords_swiss), + list(label = "Fractured/raster", mask = m3_r, coords = coords_frac), + list(label = "Fractured/kde", mask = m3_k, coords = coords_frac), + list(label = "Archipelago/raster", mask = m4_r, coords = coords_arch), + list(label = "Archipelago/convex", mask = m4_c, coords = coords_arch), + list(label = "Nested/fine", mask = m5_fine, coords = coords_nested), + list(label = "Nested/coarse", mask = m5_coarse, coords = coords_nested), + list(label = "100k/raster", mask = m7_r, coords = coords_100k), + list(label = "100k/kde", mask = m7_k, coords = coords_100k) +) + +res <- do.call(rbind, lapply(vcases, function(vc) { + pts <- sf::st_geometry(sf::st_as_sf(vc$coords, coords = c("x", "y"))) + ok <- sf::st_within(pts, sf::st_union(vc$mask), sparse = FALSE)[, 1L] + n <- nrow(vc$coords) + data.frame( + Case = vc$label, + N = n, + Enclosed = sum(ok), + Pct = round(100 * sum(ok) / n, 3), + Result = ifelse(sum(ok) == n, "\u2713 PASS", "\u2717 FAIL"), + stringsAsFactors = FALSE + ) +})) + +cat("\n============================================================\n") +cat(" VALIDATION: Point Containment (all must be PASS)\n") +cat("============================================================\n") +print(res, row.names = FALSE) +cat("============================================================\n") +cat("demo_holes_and_scale.R complete.\n") + diff --git a/fit_spatial_mask.R b/fit_spatial_mask.R new file mode 100644 index 0000000..a12bff7 --- /dev/null +++ b/fit_spatial_mask.R @@ -0,0 +1,371 @@ +# ============================================================ +# fit_spatial_mask.R (v3) +# +# Fits a spatial boundary mask to XY point coordinates. +# Methods: "convex" | "concave" | "kde" | "raster" +# +# RASTER METHOD (v3 rewrite) +# --------------------------- +# Previously: Gaussian smooth → isoband contour → manual hole assembly. +# Problem: isoband winding is inconsistent; manual hole assembly +# produced bowtie artefacts and diagonal fill lines. +# +# Now: Gaussian smooth → binary threshold → union of "on" grid cells. +# Key insight: unioning axis-aligned rectangles via GEOS dissolve always +# produces valid topology. Holes and islands emerge naturally — +# no ring-winding logic needed at all. +# +# KEY PARAMETER CHANGE (v3): +# raster_sigma is now in COORDINATE UNITS (same units as x/y), +# not grid-cell units. This makes it directly interpretable: +# "how wide is the Gaussian around each point?" +# NULL → auto = 3 % of domain width. +# +# install.packages(c("sf", "concaveman", "MASS", "isoband", "ggplot2")) +# ============================================================ + + +# ---- Internal helpers -------------------------------------------------------- + +.signed_area <- function(ring_mat) { + n <- nrow(ring_mat); if (n < 3L) return(0) + x <- ring_mat[, 1L]; y <- ring_mat[, 2L] + 0.5 * sum(x[seq_len(n - 1L)] * y[seq_len(n - 1L) + 1L] - + x[seq_len(n - 1L) + 1L] * y[seq_len(n - 1L)]) +} + +.gaussian_kernel_1d <- function(sigma) { + r <- ceiling(3 * sigma) + k <- exp(-((-r):r)^2 / (2 * sigma^2)) + k / sum(k) +} + +.smooth_rows <- function(mat, k) { + t(apply(mat, 1L, function(row) { + s <- stats::filter(row, k, method = "convolution", sides = 2L) + s[is.na(s)] <- 0; as.numeric(s) + })) +} + +.smooth_cols <- function(mat, k) { + apply(mat, 2L, function(col) { + s <- stats::filter(col, k, method = "convolution", sides = 2L) + s[is.na(s)] <- 0; as.numeric(s) + }) +} + +# Separable 2-D Gaussian convolution. sigma is in GRID-CELL units. +.gaussian_smooth_2d <- function(mat, sigma, n_cores = 1L) { + k <- .gaussian_kernel_1d(sigma) + if (n_cores > 1L && .Platform$OS.type == "unix") { + row_list <- parallel::mclapply(seq_len(nrow(mat)), function(i) { + s <- stats::filter(mat[i, ], k, method = "convolution", sides = 2L) + s[is.na(s)] <- 0; as.numeric(s) + }, mc.cores = n_cores) + mat <- do.call(rbind, row_list) + } else { + mat <- .smooth_rows(mat, k) + } + .smooth_cols(mat, k) +} + +# Used only by the "kde" method (isoband-based). +.build_sf_with_holes <- function(bands, crs) { + rings <- list() + for (band in bands) { + if (length(band$x) == 0L) next + ids <- if (!is.null(band$id)) band$id else rep(1L, length(band$x)) + for (uid in unique(ids[!is.na(ids)])) { + sel <- !is.na(ids) & ids == uid + rx <- band$x[sel]; ry <- band$y[sel] + if (length(rx) < 3L) next + if (rx[1L] != rx[length(rx)] || ry[1L] != ry[length(ry)]) { + rx <- c(rx, rx[1L]); ry <- c(ry, ry[1L]) + } + mat <- cbind(rx, ry) + area <- abs(.signed_area(mat)) + rings <- c(rings, list(list(mat = mat, area = area))) + } + } + if (length(rings) == 0L) + stop("No rings extracted. Adjust kde_threshold / kde_bandwidth parameters.") + ord <- order(sapply(rings, `[[`, "area"), decreasing = TRUE) + rings <- rings[ord] + polys <- lapply(rings, function(r) { + mat <- rbind(r$mat, r$mat[1L, , drop = FALSE]) + dupl <- c(FALSE, rowSums(abs(diff(mat))) == 0) + sf::st_sfc(sf::st_polygon(list(mat[!dupl, , drop = FALSE])), crs = crs) + }) + result <- polys[[1L]] + if (length(polys) > 1L) { + for (i in seq(2L, length(polys))) { + inside <- tryCatch( + isTRUE(sf::st_within(polys[[i]], result, sparse = FALSE)[1L, 1L]), + error = function(e) FALSE + ) + result <- if (inside) sf::st_difference(result, polys[[i]]) + else sf::st_union(result, polys[[i]]) + } + } + sf::st_make_valid(result) +} + + +# ---- Main function ----------------------------------------------------------- + +fit_spatial_mask <- function( + coords, + + # Method + method = "raster", # "convex" | "concave" | "kde" | "raster" + + # Concave-hull parameters + concavity = 2, + length_threshold = 0, + + # KDE parameters + kde_bandwidth = NULL, + kde_threshold = 0.05, + kde_resolution = 256L, + + # Raster parameters + # raster_sigma: Gaussian spread in COORDINATE UNITS (same as x/y). + # NULL → auto = 3 % of domain width. + # Larger sigma → holes fill in, islands merge, boundary smooths. + # Smaller sigma → holes and fine gaps are preserved. + raster_resolution = 256L, + raster_sigma = NULL, + raster_threshold = 0.15, + raster_min_pts = 1L, + + # Post-processing + buffer_dist = 0, + smooth_mask = FALSE, + smooth_tolerance = NULL, + + # Misc + n_cores = 1L, + crs = NA, + plot = FALSE, + verbose = TRUE +) { + + # ---- 0. Package checks ----------------------------------------------------- + req <- c("sf", "concaveman", "MASS", "isoband") + miss <- req[!sapply(req, requireNamespace, quietly = TRUE)] + if (length(miss) > 0L) + stop("Install missing packages: install.packages(c(", + paste0('"', miss, '"', collapse = ", "), "))") + + # ---- 0b. Normalise CRS (sf::st_sfc rejects bare NA in sf >= 1.0) ---------- + crs <- if (length(crs) == 1L && is.na(crs)) sf::NA_crs_ else crs + + # ---- 1. Parse and validate coordinates ------------------------------------ + if (is.matrix(coords)) coords <- as.data.frame(coords) + if (!is.data.frame(coords)) stop("`coords` must be a data.frame or matrix.") + if (!all(c("x", "y") %in% names(coords))) { + if (ncol(coords) >= 2L) { + coords <- coords[, 1:2]; names(coords) <- c("x", "y") + } else stop("Need columns 'x' and 'y'.") + } + coords <- coords[complete.cases(coords[, c("x", "y")]), c("x", "y")] + n_pts <- nrow(coords) + if (n_pts < 3L) stop("Need at least 3 coordinate pairs.") + if (n_pts > 50000L && method == "kde" && verbose) + message("Large n = ", n_pts, " — consider method = 'raster'.") + + # ---- 2. Fit mask ----------------------------------------------------------- + mask_poly <- switch(method, + + # -- Convex hull ------------------------------------------------------------ + "convex" = { + pts <- sf::st_as_sf(coords, coords = c("x", "y"), crs = crs) + sf::st_convex_hull(sf::st_union(pts)) + }, + + # -- Concave hull ----------------------------------------------------------- + "concave" = { + hp <- concaveman::concaveman(as.matrix(coords), + concavity = concavity, + length_threshold = length_threshold) + sf::st_sfc(sf::st_polygon(list(hp)), crs = crs) + }, + + # -- KDE contour (supports holes, slower for large n) ---------------------- + "kde" = { + bw_x <- if (!is.null(kde_bandwidth)) kde_bandwidth[1L] else + MASS::bandwidth.nrd(coords$x) + bw_y <- if (!is.null(kde_bandwidth)) { + if (length(kde_bandwidth) == 2L) kde_bandwidth[2L] else kde_bandwidth[1L] + } else MASS::bandwidth.nrd(coords$y) + xp <- diff(range(coords$x)) * 0.1 + yp <- diff(range(coords$y)) * 0.1 + kf <- MASS::kde2d(coords$x, coords$y, h = c(bw_x, bw_y), + n = kde_resolution, + lims = c(range(coords$x) + c(-xp, xp), + range(coords$y) + c(-yp, yp))) + dv <- as.vector(kf$z) + bands <- isoband::isobands(kf$x, kf$y, kf$z, + levels_low = quantile(dv, kde_threshold), + levels_high = max(dv) + 1) + .build_sf_with_holes(bands, crs) + }, + + # -- Raster: Gaussian sum → threshold → cell union ------------------------- + # + # Algorithm: + # 1. Bin points onto a regular grid (raster_resolution x raster_resolution). + # 2. Convolve the binary occupancy grid with a 2-D Gaussian of width + # raster_sigma (coordinate units). Equivalent to placing an isotropic + # Gaussian at every point and summing them all. + # 3. Threshold at raster_threshold x max(field). Cells above threshold + # are "inside"; cells below are "outside". + # 4. Convert each "inside" cell to an axis-aligned rectangle and dissolve + # via GEOS union. Holes and islands emerge from the geometry naturally + # — no ring-winding logic required. + # 5. Smooth the staircase boundary with a morphological close + # (buffer out then in by ~half the cell diagonal). + # + # Tuning guide: + # raster_sigma up → holes fill in, islands merge, edges smoother + # raster_sigma down → holes preserved, fine structure retained + # raster_threshold up → mask shrinks (requires denser coverage) + # raster_threshold down → mask grows (accepts sparse regions) + "raster" = { + + # Grid cell edges + xpad <- diff(range(coords$x)) * 0.05 + ypad <- diff(range(coords$y)) * 0.05 + xb <- seq(min(coords$x) - xpad, max(coords$x) + xpad, + length.out = raster_resolution + 1L) + yb <- seq(min(coords$y) - ypad, max(coords$y) + ypad, + length.out = raster_resolution + 1L) + hx <- xb[2L] - xb[1L] # cell width (coordinate units) + hy <- yb[2L] - yb[1L] # cell height (coordinate units) + + # Bin points: cm[yi, xi] = point count at cell (xi, yi) + xi <- pmax(1L, pmin(raster_resolution, + findInterval(coords$x, xb, rightmost.closed = TRUE))) + yi <- pmax(1L, pmin(raster_resolution, + findInterval(coords$y, yb, rightmost.closed = TRUE))) + cv <- tabulate(yi + (xi - 1L) * raster_resolution, + nbins = raster_resolution^2L) + cm <- matrix(cv, nrow = raster_resolution, ncol = raster_resolution) + + # Convert raster_sigma from coordinate units to grid-cell units + domain_w <- diff(range(coords$x)) + 2 * xpad + sigma_cu <- if (is.null(raster_sigma)) 0.03 * domain_w else raster_sigma + sigma_gc <- sigma_cu / hx # grid-cell units for the convolution kernel + + if (verbose) + message(" raster_sigma = ", round(sigma_cu, 4), + " coord units (", round(sigma_gc, 1), " grid cells)") + + # Convolve binary occupancy with Gaussian — sum of point Gaussians + indicator <- (cm >= raster_min_pts) * 1.0 + sm <- .gaussian_smooth_2d(indicator, sigma = sigma_gc, + n_cores = n_cores) + + # Threshold → binary + thresh <- raster_threshold * max(sm, na.rm = TRUE) + binary <- sm >= thresh + on_cells <- which(binary, arr.ind = TRUE) # [row = yi, col = xi] + + if (nrow(on_cells) == 0L) + stop("No grid cells above threshold. Try: lower raster_threshold, ", + "increase raster_sigma, or increase raster_resolution.") + + # Build one closed rectangle per on-cell + ri <- on_cells[, 1L] # y indices + ci <- on_cells[, 2L] # x indices + rects <- lapply(seq_len(nrow(on_cells)), function(k) { + sf::st_polygon(list(matrix( + c(xb[ci[k]], yb[ri[k]], + xb[ci[k] + 1L], yb[ri[k]], + xb[ci[k] + 1L], yb[ri[k] + 1L], + xb[ci[k]], yb[ri[k] + 1L], + xb[ci[k]], yb[ri[k]]), # close ring + ncol = 2L, byrow = TRUE + ))) + }) + + # GEOS dissolve — topology (holes, islands) emerges automatically + raw <- sf::st_union(sf::st_sfc(rects, crs = crs)) + + # Morphological close: smooth staircase boundary. + # Buffer out then in by ~half the cell diagonal. + cell_diag <- sqrt(hx^2 + hy^2) + raw <- sf::st_buffer(raw, dist = cell_diag * 0.6) + raw <- sf::st_buffer(raw, dist = -cell_diag * 0.6) + + sf::st_make_valid(raw) + }, + + stop("Unknown method '", method, "'. Choose: convex, concave, kde, raster.") + ) + + if (!inherits(mask_poly, "sfc")) mask_poly <- sf::st_geometry(mask_poly) + + # ---- 3. Optional buffer ---------------------------------------------------- + if (buffer_dist > 0) + mask_poly <- sf::st_buffer(mask_poly, dist = buffer_dist) + + # ---- 4. Containment guarantee ---------------------------------------------- + pts_sfc <- sf::st_geometry( + sf::st_as_sf(coords, coords = c("x", "y"), crs = crs)) + contained <- sf::st_within(pts_sfc, sf::st_union(mask_poly), sparse = FALSE) + n_out <- sum(!contained[, 1L]) + if (n_out > 0L) { + warning(n_out, " point(s) outside mask — applying corrective buffer.") + dists <- sf::st_distance(pts_sfc[!contained[, 1L]], + sf::st_union(mask_poly)) + mask_poly <- sf::st_buffer(mask_poly, + dist = max(as.numeric(dists)) * 1.05) + } + + # ---- 5. Optional boundary smoothing ---------------------------------------- + if (smooth_mask) { + if (is.null(smooth_tolerance)) { + bb <- sf::st_bbox(mask_poly) + smooth_tolerance <- sqrt((bb["xmax"] - bb["xmin"])^2 + + (bb["ymax"] - bb["ymin"])^2) * 0.01 + } + mask_poly <- sf::st_simplify(mask_poly, dTolerance = smooth_tolerance) + } + sf::st_crs(mask_poly) <- crs + + # ---- 6. Verbose summary ---------------------------------------------------- + if (verbose) { + bb <- sf::st_bbox(mask_poly) + area <- suppressWarnings(as.numeric(sf::st_area(mask_poly))) + cat("--------------------------------------------------\n") + cat(" Spatial mask fitted (v3)\n") + cat(" Method :", method, "\n") + cat(" Points :", n_pts, "\n") + cat(" Sub-geometries :", length(mask_poly), "\n") + cat(" Bounding box : x [", round(bb["xmin"], 3), ",", + round(bb["xmax"], 3), "] y [", + round(bb["ymin"], 3), ",", round(bb["ymax"], 3), "]\n") + cat(" Area :", round(area, 4), "\n") + cat(" Buffer applied :", buffer_dist, "\n") + cat("--------------------------------------------------\n") + } + + # ---- 7. Optional quick plot ------------------------------------------------ + if (plot && requireNamespace("ggplot2", quietly = TRUE)) { + mask_sf <- sf::st_sf(geometry = mask_poly) + print(ggplot2::ggplot() + + ggplot2::geom_sf( + data = mask_sf, + fill = "#4c9be8", alpha = 0.2, color = "#1a5fa8", linewidth = 0.55) + + ggplot2::geom_point( + data = coords, ggplot2::aes(x = x, y = y), + color = "#c0392b", size = 0.7, alpha = 0.5) + + ggplot2::coord_sf(datum = NA) + + ggplot2::labs(title = paste0("Mask | method='", method, "'"), + x = "X", y = "Y") + + ggplot2::theme_minimal(base_size = 11)) + } + + return(mask_poly) +} diff --git a/tests/testthat.R b/tests/testthat.R new file mode 100644 index 0000000..4a4cb6d --- /dev/null +++ b/tests/testthat.R @@ -0,0 +1,4 @@ +library(testthat) +library(TissueMask) + +test_check("TissueMask") diff --git a/tests/testthat/test-fit_spatial_mask.R b/tests/testthat/test-fit_spatial_mask.R new file mode 100644 index 0000000..a1e0830 --- /dev/null +++ b/tests/testthat/test-fit_spatial_mask.R @@ -0,0 +1,256 @@ +# Tests for fit_spatial_mask() +# All tests use synthetic point clouds generated with set.seed() — no external +# data files required. + +library(sf) + +# --------------------------------------------------------------------------- +# Shared test fixtures +# --------------------------------------------------------------------------- + +# Circular cloud: 300 points scattered around a ring of radius 10 +make_circle <- function(n = 300, r = 10, noise = 0.5, seed = 42) { + set.seed(seed) + theta <- stats::runif(n, 0, 2 * pi) + data.frame( + x = r * cos(theta) + stats::rnorm(n, 0, noise), + y = r * sin(theta) + stats::rnorm(n, 0, noise) + ) +} + +# Two well-separated clusters +make_two_clusters <- function(n = 200, seed = 7) { + set.seed(seed) + data.frame( + x = c(stats::rnorm(n, 0, 2), stats::rnorm(n, 15, 2)), + y = c(stats::rnorm(n, 0, 2), stats::rnorm(n, 15, 2)) + ) +} + +# Ring/donut: points concentrated in an annulus (creates a topological hole) +make_donut <- function(n = 600, r_inner = 4, r_outer = 9, seed = 99) { + set.seed(seed) + theta <- stats::runif(n, 0, 2 * pi) + r <- stats::runif(n, r_inner, r_outer) + data.frame(x = r * cos(theta), y = r * sin(theta)) +} + +# --------------------------------------------------------------------------- +# Return type +# --------------------------------------------------------------------------- + +test_that("fit_spatial_mask returns an sfc object", { + coords <- make_circle() + mask <- fit_spatial_mask(coords, method = "raster", verbose = FALSE) + expect_s3_class(mask, "sfc") +}) + +# --------------------------------------------------------------------------- +# All methods return valid sfc +# --------------------------------------------------------------------------- + +test_that("convex method returns sfc", { + mask <- fit_spatial_mask(make_circle(), method = "convex", verbose = FALSE) + expect_s3_class(mask, "sfc") + expect_true(all(sf::st_is_valid(mask))) +}) + +test_that("raster method returns valid sfc", { + mask <- fit_spatial_mask(make_circle(), method = "raster", verbose = FALSE) + expect_s3_class(mask, "sfc") + expect_true(all(sf::st_is_valid(mask))) +}) + +test_that("concave method returns sfc (requires concaveman)", { + skip_if_not_installed("concaveman") + mask <- fit_spatial_mask(make_circle(), method = "concave", verbose = FALSE) + expect_s3_class(mask, "sfc") + expect_true(all(sf::st_is_valid(mask))) +}) + +test_that("kde method returns sfc (requires MASS and isoband)", { + skip_if_not_installed("MASS") + skip_if_not_installed("isoband") + mask <- fit_spatial_mask(make_circle(), method = "kde", verbose = FALSE) + expect_s3_class(mask, "sfc") + expect_true(all(sf::st_is_valid(mask))) +}) + +# --------------------------------------------------------------------------- +# Point containment guarantee +# --------------------------------------------------------------------------- + +test_that("all points are contained in the raster mask", { + coords <- make_two_clusters() + mask <- fit_spatial_mask(coords, method = "raster", verbose = FALSE) + pts_sfc <- sf::st_as_sf(coords, coords = c("x", "y"), crs = sf::NA_crs_) + contained <- sf::st_within(sf::st_geometry(pts_sfc), + sf::st_union(mask), sparse = FALSE) + expect_true(all(contained[, 1L])) +}) + +test_that("all points are contained in the convex mask", { + coords <- make_circle() + mask <- fit_spatial_mask(coords, method = "convex", verbose = FALSE) + pts_sfc <- sf::st_as_sf(coords, coords = c("x", "y"), crs = sf::NA_crs_) + contained <- sf::st_within(sf::st_geometry(pts_sfc), + sf::st_union(mask), sparse = FALSE) + expect_true(all(contained[, 1L])) +}) + +# --------------------------------------------------------------------------- +# Input formats +# --------------------------------------------------------------------------- + +test_that("matrix input is accepted", { + coords <- as.matrix(make_circle()) + colnames(coords) <- c("x", "y") + mask <- fit_spatial_mask(coords, method = "convex", verbose = FALSE) + expect_s3_class(mask, "sfc") +}) + +test_that("unnamed matrix columns are renamed to x/y", { + coords <- as.matrix(make_circle()) + colnames(coords) <- c("lon", "lat") # non-standard names + mask <- fit_spatial_mask(coords, method = "convex", verbose = FALSE) + expect_s3_class(mask, "sfc") +}) + +test_that("data.frame with non-standard column names uses first two columns", { + df <- make_circle() + names(df) <- c("col1", "col2") + mask <- fit_spatial_mask(df, method = "convex", verbose = FALSE) + expect_s3_class(mask, "sfc") +}) + +test_that("NA rows in coords are silently dropped", { + coords <- make_circle(n = 100) + coords[5, ] <- NA + coords[20, ] <- NA + # Should not error — NA rows are dropped + mask <- fit_spatial_mask(coords, method = "raster", verbose = FALSE) + expect_s3_class(mask, "sfc") +}) + +# --------------------------------------------------------------------------- +# Input validation errors +# --------------------------------------------------------------------------- + +test_that("error when fewer than 3 points supplied", { + expect_error( + fit_spatial_mask(data.frame(x = 1:2, y = 1:2), verbose = FALSE), + regexp = "at least 3" + ) +}) + +test_that("error when coords has only one column", { + expect_error( + fit_spatial_mask(data.frame(x = 1:10), verbose = FALSE) + ) +}) + +test_that("error for unknown method string", { + expect_error( + fit_spatial_mask(make_circle(), method = "magic", verbose = FALSE), + regexp = "Unknown method" + ) +}) + +test_that("error when concave requested but concaveman absent", { + skip_if(requireNamespace("concaveman", quietly = TRUE), + "concaveman is installed — skipping absence test") + expect_error( + fit_spatial_mask(make_circle(), method = "concave", verbose = FALSE), + regexp = "concaveman" + ) +}) + +# --------------------------------------------------------------------------- +# Post-processing parameters +# --------------------------------------------------------------------------- + +test_that("buffer_dist produces a larger mask than no buffer", { + coords <- make_circle() + mask_plain <- fit_spatial_mask(coords, method = "raster", + buffer_dist = 0, verbose = FALSE) + mask_buffered <- fit_spatial_mask(coords, method = "raster", + buffer_dist = 2, verbose = FALSE) + area_plain <- as.numeric(sf::st_area(mask_plain)) + area_buffered <- as.numeric(sf::st_area(mask_buffered)) + expect_gt(area_buffered, area_plain) +}) + +test_that("smooth_mask returns a valid sfc", { + coords <- make_circle() + mask <- fit_spatial_mask(coords, method = "raster", + smooth_mask = TRUE, verbose = FALSE) + expect_s3_class(mask, "sfc") + expect_true(all(sf::st_is_valid(mask))) +}) + +test_that("smooth_tolerance is accepted without error", { + coords <- make_circle() + mask <- fit_spatial_mask(coords, method = "raster", + smooth_mask = TRUE, smooth_tolerance = 0.5, + verbose = FALSE) + expect_s3_class(mask, "sfc") +}) + +# --------------------------------------------------------------------------- +# CRS propagation +# --------------------------------------------------------------------------- + +test_that("output CRS matches input CRS (EPSG integer)", { + coords <- make_circle() + mask <- fit_spatial_mask(coords, method = "convex", + crs = 3857, verbose = FALSE) + expect_equal(sf::st_crs(mask)$epsg, 3857) +}) + +test_that("default CRS is NA_crs_", { + coords <- make_circle() + mask <- fit_spatial_mask(coords, method = "convex", verbose = FALSE) + expect_true(is.na(sf::st_crs(mask))) +}) + +# --------------------------------------------------------------------------- +# Topology +# --------------------------------------------------------------------------- + +test_that("raster method on donut cloud produces POLYGON or MULTIPOLYGON", { + # A ring-shaped cloud can produce a polygon with a hole + coords <- make_donut(n = 800, r_inner = 3, r_outer = 8) + mask <- fit_spatial_mask(coords, method = "raster", + raster_sigma = 0.8, + verbose = FALSE) + geom_type <- as.character(sf::st_geometry_type(mask)) + expect_true(any(grepl("POLYGON", geom_type))) +}) + +test_that("two-cluster cloud produces a multi-part or larger mask", { + # Two well-separated clusters: mask should cover both + coords <- make_two_clusters() + mask <- fit_spatial_mask(coords, method = "raster", verbose = FALSE) + bb <- sf::st_bbox(mask) + x_extent <- as.numeric(bb["xmax"] - bb["xmin"]) + expect_gt(x_extent, 10) # clusters are ~15 units apart +}) + +# --------------------------------------------------------------------------- +# Verbose output +# --------------------------------------------------------------------------- + +test_that("verbose = FALSE suppresses all output", { + coords <- make_circle() + expect_silent( + fit_spatial_mask(coords, method = "convex", verbose = FALSE) + ) +}) + +test_that("verbose = TRUE produces output for raster method", { + coords <- make_circle() + expect_output( + fit_spatial_mask(coords, method = "raster", verbose = TRUE), + regexp = "TissueMask" + ) +}) diff --git a/vignette_fit_spatial_mask.Rmd b/vignette_fit_spatial_mask.Rmd new file mode 100644 index 0000000..aac1ac2 --- /dev/null +++ b/vignette_fit_spatial_mask.Rmd @@ -0,0 +1,750 @@ +--- +title: "Fitting Spatial Masks to Point Coordinate Data" +subtitle: "A guide to `fit_spatial_mask()`: methods, parameters, and tuning" +author: "Raredon Laboratory · Yale School of Medicine" +date: "`r Sys.Date()`" +output: + html_document: + toc: true + toc_float: + collapsed: false + smooth_scroll: true + toc_depth: 3 + theme: flatly + highlight: kate + code_folding: show + fig_width: 10 + fig_height: 5 + df_print: kable +--- + +```{r setup, include=FALSE} +knitr::opts_chunk$set( + echo = TRUE, + warning = FALSE, + message = FALSE, + fig.align = "center", + cache = FALSE +) +``` + +--- + +# Overview + +In spatial transcriptomics and spatial proteomics experiments, the tissue of interest occupies some irregular region of a slide. Before computing any spatially-resolved quantity (cell-type composition, signaling gradients, niche structure), you need a reliable **spatial mask**: a polygon or multi-polygon that faithfully represents the shape of the tissue, including internal voids (vessel lumens, necrotic cores, tissue tears) and disconnected fragments. + +`fit_spatial_mask()` takes a set of XY point coordinates — typically cell centroids or transcript locations — and fits a mask polygon to them. It supports four methods of increasing complexity: + +| Method | Topology | Speed | Best for | +|---|---|---|---| +| `"convex"` | No holes, no islands | Instant | Quick sanity checks | +| `"concave"` | No holes, no islands | Fast | Simple non-convex shapes | +| `"kde"` | Holes and islands | Moderate | Dense, smooth datasets | +| `"raster"` | Holes and islands | Fast at scale | Most use cases; recommended default | + +--- + +# Setup + +Place `fit_spatial_mask.R` in your working directory, then source it. The packages below are required. + +```{r libraries} +library(sf) +library(ggplot2) +library(patchwork) +source("fit_spatial_mask.R") + +set.seed(42) +``` + +--- + +# Shared plotting helper + +Throughout this vignette we use a single `plot_mask()` helper. The key implementation note is that it uses `geom_sf()` rather than `geom_polygon()`. This is essential: `geom_polygon()` draws a line between the last vertex of the exterior ring and the first vertex of each interior (hole) ring, producing diagonal artefacts. `geom_sf()` delegates rendering to the sf/GEOS layer which handles holes correctly. + +```{r plot-helper} +plot_mask <- function(mask, coords, title = "", subtitle = "", + fill = "#4c9be8", border = "#1a5fa8", + pt_col = "#c0392b", pt_size = 1.2, pt_alpha = 0.55) { + mask_sf <- sf::st_sf(geometry = mask) + ggplot() + + geom_sf(data = mask_sf, fill = fill, alpha = 0.2, + color = border, linewidth = 0.55) + + geom_point(data = coords, aes(x = x, y = y), + color = pt_col, size = pt_size, alpha = pt_alpha) + + coord_sf(datum = NA) + # suppress geographic graticule + labs(title = title, subtitle = subtitle, x = "X", y = "Y") + + theme_minimal(base_size = 9.5) + + theme(plot.title = element_text(face = "bold", size = 9.5), + plot.subtitle = element_text(size = 8, color = "grey40"), + panel.grid = element_line(color = "grey93")) +} +``` + +--- + +# Function reference: `fit_spatial_mask()` + +```r +fit_spatial_mask( + coords, + + # Method selection + method = "raster", # "convex" | "concave" | "kde" | "raster" + + # Concave hull + concavity = 2, # lower = tighter; higher = approaches convex + length_threshold = 0, + + # KDE + kde_bandwidth = NULL, # NULL = auto (bandwidth.nrd) + kde_threshold = 0.05, # quantile of density to threshold at + kde_resolution = 256L, + + # Raster (recommended) + raster_resolution = 256L, # grid cells per axis + raster_sigma = NULL, # Gaussian spread in COORDINATE UNITS; NULL = 3% of domain + raster_threshold = 0.15, # fraction of peak Gaussian field to threshold at + raster_min_pts = 1L, # min points per cell to count as occupied + + # Post-processing + buffer_dist = 0, # expand mask outward by this distance + smooth_mask = FALSE, # simplify boundary (st_simplify) + smooth_tolerance = NULL, # NULL = 1% of bounding box diagonal + + # Misc + n_cores = 1L, + crs = NA, + plot = FALSE, + verbose = TRUE +) +``` + +## Return value + +`fit_spatial_mask()` returns an `sfc` geometry object (the `sf` package's geometry column class). You can: + +- Pass it directly to `estimate_concentration_field()` as the `mask` argument +- Plot it with `geom_sf()` +- Compute area with `sf::st_area()` +- Test point containment with `sf::st_within()` +- Convert to a data frame with `as.data.frame(sf::st_coordinates(mask))` + +--- + +# Section 1 — Circular cloud: sanity check + +The simplest possible test: a Gaussian cloud with no special topology. All three non-trivial methods should return similar results here. This section establishes that the function runs and that the three main methods agree on simple inputs. + +```{r sec1-data} +n <- 400 +coords_circle <- data.frame(x = rnorm(n, 0, 1), + y = rnorm(n, 0, 1)) +``` + +```{r sec1-fit} +m1a <- fit_spatial_mask(coords_circle, method = "convex", verbose = FALSE) +m1b <- fit_spatial_mask(coords_circle, method = "concave", concavity = 2, + verbose = FALSE) +m1c <- fit_spatial_mask(coords_circle, method = "kde", + kde_threshold = 0.02, buffer_dist = 0.1, + verbose = FALSE) +``` + +```{r sec1-plot, fig.height=4} +plot_mask(m1a, coords_circle, title = "1a. Convex hull", + subtitle = "method='convex'") + +plot_mask(m1b, coords_circle, title = "1b. Concave hull", + subtitle = "concavity=2") + +plot_mask(m1c, coords_circle, title = "1c. KDE contour", + subtitle = "kde_threshold=0.02, buffer=0.1", + fill = "#27ae60", border = "#1e8449") + +plot_annotation( + title = "Section 1: Circular Cloud — Sanity Check", + theme = theme(plot.title = element_text(face = "bold", size = 13))) +``` + +**What to look for:** All three masks should closely envelope the point cloud. Small differences near the boundary are expected — convex gives a polygon, KDE gives a smoother contour. Any large discrepancy here indicates a parameter problem. + +--- + +# Section 2 — Elongated cloud: anisotropy + +Elongated tissue (e.g. a muscle section, a linear airway epithelium) has very different extents along its two axes. All methods should handle this naturally; this section confirms that no method is implicitly assuming isotropy. + +```{r sec2} +coords_elong <- data.frame(x = rnorm(n, 0, 3), + y = rnorm(n, 0, 0.6)) + +m2a <- fit_spatial_mask(coords_elong, method = "convex", verbose = FALSE) +m2b <- fit_spatial_mask(coords_elong, method = "concave", verbose = FALSE) +m2c <- fit_spatial_mask(coords_elong, method = "kde", + kde_threshold = 0.02, buffer_dist = 0.1, + verbose = FALSE) +``` + +```{r sec2-plot, fig.height=4} +plot_mask(m2a, coords_elong, title = "2a. Convex") + +plot_mask(m2b, coords_elong, title = "2b. Concave") + +plot_mask(m2c, coords_elong, title = "2c. KDE", + fill = "#27ae60", border = "#1e8449") + +plot_annotation( + title = "Section 2: Elongated Cloud", + theme = theme(plot.title = element_text(face = "bold", size = 13))) +``` + +**What to look for:** The mask should track the long axis of the cloud, not default to a circle. The KDE bandwidth is computed separately for x and y (`MASS::bandwidth.nrd`), so it adapts to anisotropy automatically. + +--- + +# Section 3 — L-shaped cloud: the non-convex diagnostic + +This is the most important basic test. The L-shape is the canonical non-convex case: the convex hull incorrectly fills the concave corner of the L, including empty space that contains no tissue. A good mask must track the actual boundary and exclude the corner. + +> **Rule of thumb:** If your tissue has any re-entrant angles (C-shapes, crescents, airways, arcs), do not use `method = "convex"`. Use `method = "concave"` or `method = "raster"`. + +```{r sec3-data} +coords_L <- rbind( + data.frame(x = runif(200, 0, 5) + rnorm(200, 0, 0.05), + y = runif(200, 0, 1) + rnorm(200, 0, 0.05)), # horizontal bar + data.frame(x = runif(200, 0, 1) + rnorm(200, 0, 0.05), + y = runif(200, 0, 5) + rnorm(200, 0, 0.05))) # vertical bar +``` + +```{r sec3-fit} +m3a <- fit_spatial_mask(coords_L, method = "convex", verbose = FALSE) +m3b <- fit_spatial_mask(coords_L, method = "concave", concavity = 1.5, verbose = FALSE) +m3c <- fit_spatial_mask(coords_L, method = "concave", concavity = 3.0, verbose = FALSE) +``` + +```{r sec3-plot, fig.height=4} +plot_mask(m3a, coords_L, + title = "3a. Convex hull", + subtitle = "Incorrectly includes empty corner", + fill = "#e74c3c", border = "#922b21") + +plot_mask(m3b, coords_L, + title = "3b. Concave, tight (1.5)", + subtitle = "Correctly excludes corner") + +plot_mask(m3c, coords_L, + title = "3c. Concave, relaxed (3.0)", + subtitle = "Partially includes corner", + fill = "#8e44ad", border = "#6c3483") + +plot_annotation( + title = "Section 3: L-Shaped Cloud — Non-Convex", + subtitle = "Lower concavity wraps more tightly; convex hull fails", + theme = theme(plot.title = element_text(face = "bold", size = 13), + plot.subtitle = element_text(size = 9, color = "grey40"))) +``` + +### The `concavity` parameter + +`concavity` controls how aggressively the hull "cuts in" to follow the data boundary. It is the key tuning parameter for `method = "concave"`. + +| Value | Behaviour | +|---|---| +| 1.0–1.5 | Very tight — tracks the data boundary closely | +| 2.0 | Default — good balance for most tissue shapes | +| 3.0–5.0 | Relaxed — approaches the convex hull | + +**When to use lower values:** C-shapes, crescents, U-shapes, any tissue with a notch or bay. **When to use higher values:** Dense blobs where you want a smooth, padded boundary. If you see the mask cutting through the interior of your tissue, increase `concavity`. If it fills in empty space at concavities, decrease it. + +--- + +# Section 4 — Multi-cluster blobs: connected vs. isolated masks + +When tissue fragments are well-separated, a low `kde_threshold` will produce a single mask that bridges them (because the KDE density never drops to zero between clusters at low thresholds). A higher threshold separates them into distinct polygons. + +The raster method's `raster_sigma` plays the analogous role: a large sigma bridges clusters; a small sigma isolates them. + +```{r sec4} +mk_cluster <- function(n, cx, cy, sd = 0.4) + data.frame(x = rnorm(n, cx, sd), y = rnorm(n, cy, sd)) + +coords_multi <- rbind(mk_cluster(150, 0, 0), + mk_cluster(150, 4, 1), + mk_cluster(150, 2, 4)) + +m4a <- fit_spatial_mask(coords_multi, method = "convex", verbose = FALSE) +m4b <- fit_spatial_mask(coords_multi, method = "concave", concavity = 2, + verbose = FALSE) +m4c <- fit_spatial_mask(coords_multi, method = "kde", + kde_threshold = 0.01, buffer_dist = 0.15, + verbose = FALSE) +m4d <- fit_spatial_mask(coords_multi, method = "kde", + kde_threshold = 0.25, buffer_dist = 0.15, + verbose = FALSE) +``` + +```{r sec4-plot, fig.height=7} +(plot_mask(m4a, coords_multi, title = "4a. Convex") + + plot_mask(m4b, coords_multi, title = "4b. Concave")) / +(plot_mask(m4c, coords_multi, + title = "4c. KDE — inclusive", + subtitle = "threshold=0.01: merged", + fill = "#27ae60", border = "#1e8449") + + plot_mask(m4d, coords_multi, + title = "4d. KDE — tight", + subtitle = "threshold=0.25: separated", + fill = "#8e44ad", border = "#6c3483")) + +plot_annotation( + title = "Section 4: Multi-Cluster", + theme = theme(plot.title = element_text(face = "bold", size = 13))) +``` + +### Choosing `kde_threshold` + +`kde_threshold` is expressed as a quantile of the KDE density values across the grid. + +- Low values (0.01–0.05): the contour is drawn far out in the low-density tails. Clusters merge; mask is generous. +- High values (0.15–0.30): only the dense core is enclosed. Clusters separate; mask is tight. + +**Practical advice:** Start at 0.05 and increase until the mask matches your visual inspection. If you expect biologically separated regions (e.g. two lobes), a threshold that separates them is usually correct. + +--- + +# Section 5 — Concavity sweep on a crescent + +A crescent is a severe test of the concave hull: it has a large, deep concavity spanning most of the shape. This sweep lets you calibrate the `concavity` parameter on a shape where the expected correct answer is obvious. + +```{r sec5} +theta <- seq(0.2, pi - 0.2, length.out = 300) +coords_cres <- data.frame( + x = cos(theta) * (1 + rnorm(300, 0, 0.08)) * 3, + y = sin(theta) * (1 + rnorm(300, 0, 0.08)) * 3 - cos(theta) * 1.5) + +pal <- c("#e74c3c", "#e67e22", "#27ae60", "#2980b9") +sw <- mapply(function(cv, col) { + m <- fit_spatial_mask(coords_cres, method = "concave", + concavity = cv, verbose = FALSE) + plot_mask(m, coords_cres, title = paste0("concavity = ", cv), + fill = col, border = col) +}, c(1.2, 2, 3, 5), pal, SIMPLIFY = FALSE) +``` + +```{r sec5-plot, fig.height=4} +wrap_plots(sw, nrow = 1) + + plot_annotation( + title = "Section 5: Concavity Sweep — Crescent", + subtitle = "Lower = tighter | Higher = approaches convex hull", + theme = theme(plot.title = element_text(face = "bold", size = 13), + plot.subtitle = element_text(size = 9, color = "grey40"))) +``` + +**What to look for:** At `concavity = 1.2`, the hull cuts tightly around the arc. At `concavity = 5`, it is nearly convex and fills the interior. The "correct" value for real tissue is the one that matches a human drawing of the tissue boundary. For a crescent-shaped airway section, `1.2–1.5` is usually appropriate. + +--- + +# Section 6 — Buffer and smoothing + +After fitting, two post-processing options are available: + +- **`buffer_dist`**: expands the mask outward by this distance in coordinate units using `sf::st_buffer()`. Useful when you want a small safety margin to ensure all cells fall inside the mask, or when points are sparse near the boundary. +- **`smooth_mask`**: applies `sf::st_simplify()` to reduce the vertex count and round jagged boundary segments. Controlled by `smooth_tolerance` (default: 1% of bounding box diagonal). + +```{r sec6} +r <- sqrt(runif(250, 0.4^2, 1^2)) +ang <- runif(250, 0, 2 * pi) +coords_ring <- data.frame(x = r * cos(ang) * 5, + y = r * sin(ang) * 5) + +m6a <- fit_spatial_mask(coords_ring, method = "concave", concavity = 2, + buffer_dist = 0, smooth_mask = FALSE, verbose = FALSE) +m6b <- fit_spatial_mask(coords_ring, method = "concave", concavity = 2, + buffer_dist = 0.3, smooth_mask = FALSE, verbose = FALSE) +m6c <- fit_spatial_mask(coords_ring, method = "concave", concavity = 2, + buffer_dist = 0.3, smooth_mask = TRUE, + smooth_tolerance = 0.15, verbose = FALSE) +``` + +```{r sec6-plot, fig.height=4} +plot_mask(m6a, coords_ring, + title = "6a. Raw — no buffer, no smooth") + +plot_mask(m6b, coords_ring, + title = "6b. Buffer = 0.3", + fill = "#27ae60", border = "#1e8449") + +plot_mask(m6c, coords_ring, + title = "6c. Buffer + smooth", + subtitle = "smooth_tolerance = 0.15", + fill = "#8e44ad", border = "#6c3483") + +plot_annotation( + title = "Section 6: Buffer and Smoothing", + theme = theme(plot.title = element_text(face = "bold", size = 13))) +``` + +**When to use each:** + +- `buffer_dist`: use when downstream point-in-mask tests are failing for points you know should be inside (e.g. points right on the boundary). A value of 1–5% of the domain width is usually safe. +- `smooth_mask`: use when you want a visually clean boundary for publication figures, or when a jagged boundary is causing downstream geometric operations to be slow or numerically unstable. Be careful: large `smooth_tolerance` values can erase real tissue features. + +--- + +# Section 7 — Topologically complex cases: holes and islands + +The `"raster"` method is the recommended approach for masks that need to represent **holes** (vessel lumens, necrotic cores, tissue tears) or **disconnected islands**. It is also the fastest method for large point clouds (100k+ cells). + +## How the raster method works + +1. **Bin** all points onto a regular `raster_resolution × raster_resolution` grid. +2. **Convolve** the binary occupancy grid with a 2D isotropic Gaussian of width `raster_sigma` (in coordinate units). This is equivalent to placing a Gaussian "blob" at every point and summing — the value at each cell represents how much point density is nearby. +3. **Threshold** at `raster_threshold × max(field)`. Cells above threshold are "inside"; cells below are "outside". +4. **Build** one axis-aligned rectangle per inside cell, then **dissolve** all rectangles with `sf::st_union()`. GEOS handles the topology automatically — holes and islands emerge from the geometry without any ring-winding logic. +5. **Smooth** the staircase boundary with a morphological close (buffer out then in by ~half the cell diagonal). + +## Key tuning parameters + +| Parameter | Effect | +|---|---| +| `raster_sigma` ↑ | Holes fill in, islands merge, boundary smooths | +| `raster_sigma` ↓ | Fine holes and gaps preserved | +| `raster_threshold` ↑ | Mask shrinks; requires denser local coverage | +| `raster_threshold` ↓ | Mask grows; accepts sparse regions | +| `raster_resolution` ↑ | Finer grid; more detail; slower union step | + +> **Units note:** `raster_sigma` is in **coordinate units** (same as x and y). If your tissue coordinates span 0–10, a `raster_sigma` of 0.25 means each Gaussian blob has a standard deviation of 0.25 tissue units. `NULL` sets sigma automatically to 3% of the domain width. + +--- + +## 7a — Donut: single interior void + +```{r sec7a} +r_d <- sqrt(runif(800, 2^2, 5^2)) +th_d <- runif(800, 0, 2 * pi) +coords_donut <- data.frame(x = r_d * cos(th_d), + y = r_d * sin(th_d)) + +m7a_r <- fit_spatial_mask(coords_donut, method = "raster", + raster_resolution = 256L, raster_sigma = 0.25, raster_threshold = 0.2, + verbose = FALSE) +m7a_c <- fit_spatial_mask(coords_donut, method = "convex", verbose = FALSE) +``` + +```{r sec7a-plot, fig.height=4} +plot_mask(m7a_r, coords_donut, + title = "7a-i. Raster — hole detected ✓", + subtitle = "σ = 0.25 coord units, threshold = 0.2") + +plot_mask(m7a_c, coords_donut, + title = "7a-ii. Convex — hole filled ✗", + subtitle = "Convex hull cannot represent holes", + fill = "#e74c3c", border = "#922b21") + +plot_annotation( + title = "Donut: Single Interior Void", + theme = theme(plot.title = element_text(face = "bold", size = 13))) +``` + +The raster method naturally detects the empty centre as a region of low convolved density, below the threshold. The convex hull cannot ever represent a hole — it is by definition the smallest convex polygon containing all the points. + +--- + +## 7b — Swiss cheese: multiple holes + +```{r sec7b} +.circle_mat <- function(cx, cy, r, n = 100) { + th <- seq(0, 2 * pi, length.out = n) + mat <- cbind(cx + r * cos(th), cy + r * sin(th)) + rbind(mat, mat[1L, ]) +} + +hdefs <- list( + list(cx = 2.5, cy = 7.5, r = 1.1), + list(cx = 7.0, cy = 6.5, r = 1.4), + list(cx = 4.0, cy = 2.5, r = 0.9), + list(cx = 8.0, cy = 2.0, r = 1.2) +) + +sample_in_polygon <- function(poly_sf, n, max_tries = 20) { + bb <- sf::st_bbox(poly_sf); mu <- sf::st_union(poly_sf) + pts <- data.frame(x = numeric(0), y = numeric(0)) + for (i in seq_len(max_tries)) { + if (nrow(pts) >= n) break + cnd <- data.frame(x = runif(n * 8, bb["xmin"], bb["xmax"]), + y = runif(n * 8, bb["ymin"], bb["ymax"])) + ok <- as.logical(sf::st_within( + sf::st_as_sf(cnd, coords = c("x","y")), mu, sparse = FALSE)[, 1L]) + pts <- rbind(pts, cnd[ok, ]) + } + pts[seq_len(min(n, nrow(pts))), ] +} + +outer_sq <- sf::st_sfc(sf::st_polygon(list( + rbind(c(0,0), c(10,0), c(10,10), c(0,10), c(0,0))))) +hcircles <- lapply(hdefs, function(h) + sf::st_polygon(list(.circle_mat(h$cx, h$cy, h$r)))) +swiss_region <- sf::st_difference(outer_sq, + sf::st_sfc(sf::st_union(sf::st_sfc(hcircles)))) +coords_swiss <- sample_in_polygon(swiss_region, n = 1500) + +m7b_r <- fit_spatial_mask(coords_swiss, method = "raster", + raster_resolution = 256L, raster_sigma = 0.25, raster_threshold = 0.2, + verbose = FALSE) +m7b_c <- fit_spatial_mask(coords_swiss, method = "concave", + concavity = 1.8, verbose = FALSE) +``` + +```{r sec7b-plot, fig.height=4} +plot_mask(m7b_r, coords_swiss, + title = "7b-i. Raster — all 4 holes detected ✓", + subtitle = "σ = 0.25, threshold = 0.2") + +plot_mask(m7b_c, coords_swiss, + title = "7b-ii. Concave — holes filled ✗", + subtitle = "Concave hull also cannot represent holes", + fill = "#e74c3c", border = "#922b21") + +plot_annotation( + title = "Swiss Cheese: Multiple Interior Holes", + theme = theme(plot.title = element_text(face = "bold", size = 13))) +``` + +--- + +## 7c — Archipelago: disconnected islands + +```{r sec7c} +mk_isl <- function(n, cx, cy, rx, ry, ns = 0.15) { + th <- runif(n, 0, 2 * pi); r <- sqrt(runif(n)) + data.frame(x = cx + r * rx * cos(th) + rnorm(n, 0, ns), + y = cy + r * ry * sin(th) + rnorm(n, 0, ns)) +} +coords_arch <- rbind(mk_isl(300, 0, 0, 3, 2), + mk_isl(300, 9, 4, 2, 3), + mk_isl(300, 5, -7, 2.5, 1.5)) + +m7c_r <- fit_spatial_mask(coords_arch, method = "raster", + raster_resolution = 256L, raster_sigma = 0.4, raster_threshold = 0.18, + verbose = FALSE) +m7c_c <- fit_spatial_mask(coords_arch, method = "convex", verbose = FALSE) + +n_geom <- length(sf::st_cast(m7c_r, "POLYGON")) +``` + +```{r sec7c-plot, fig.height=4} +plot_mask(m7c_r, coords_arch, + title = sprintf("7c-i. Raster — %d separate polygons ✓", n_geom), + subtitle = "Each island detected independently") + +plot_mask(m7c_c, coords_arch, + title = "7c-ii. Convex — islands bridged ✗", + subtitle = "Convex hull merges all islands into one", + fill = "#e74c3c", border = "#922b21") + +plot_annotation( + title = "Archipelago: Disconnected Islands", + theme = theme(plot.title = element_text(face = "bold", size = 13))) +``` + +Disconnected islands emerge automatically from the GEOS union step: if two groups of "on" cells are not touching, they become separate polygons within the same `sfc` object. The number of distinct polygons is accessible via `length(sf::st_cast(mask, "POLYGON"))`. + +--- + +## 7d — Sigma sensitivity sweep + +This is the most important tuning exercise. Run it on your own data before finalising `raster_sigma`. + +```{r sec7d} +sig_vals <- c(0.1, 0.2, 0.4, 0.7) +sig_cols <- c("#e74c3c", "#e67e22", "#27ae60", "#2980b9") + +sig_pls <- mapply(function(sv, col) { + m <- fit_spatial_mask(coords_swiss, method = "raster", + raster_resolution = 256L, raster_sigma = sv, + raster_threshold = 0.2, verbose = FALSE) + nh <- max(0L, length(sf::st_cast(m, "POLYGON")) - 1L) + plot_mask(m, coords_swiss, + title = paste0("σ = ", sv, " coord units"), + subtitle = paste0(nh, " hole(s) detected"), + fill = col, border = col, pt_size = 0.5) +}, sig_vals, sig_cols, SIMPLIFY = FALSE) +``` + +```{r sec7d-plot, fig.height=4} +wrap_plots(sig_pls, nrow = 1) + + plot_annotation( + title = "7d: raster_sigma Sensitivity — Swiss Cheese", + subtitle = "Small σ → holes preserved | Large σ → holes filled", + theme = theme(plot.title = element_text(face = "bold", size = 13), + plot.subtitle = element_text(size = 9, color = "grey40"))) +``` + +**Tuning strategy:** + +1. Start with `raster_sigma = NULL` (auto = 3% of domain width) +2. Inspect the result; note whether real voids are detected +3. Decrease sigma if holes are being filled that you expect to be present +4. Increase sigma if the mask is too jagged or breaks into spurious fragments where there is actually continuous tissue +5. Adjust `raster_threshold` to expand or contract the mask after sigma is set + +--- + +## 7e — Nested crescent voids + +```{r sec7e} +mk_cres <- function(cx, cy, ro, ri, dx, dy, n = 100) { + moc <- .circle_mat(cx, cy, ro, n) + mic <- .circle_mat(cx+dx, cy+dy, ri, n) + oc <- sf::st_polygon(list(moc)) + ic <- sf::st_polygon(list(mic)) + sf::st_sfc(sf::st_difference(sf::st_sfc(oc), sf::st_sfc(ic))) +} + +th_od <- seq(0, 2 * pi, length.out = 200) +mat_od <- cbind(10 * cos(th_od), 10 * sin(th_od)) +mat_od <- rbind(mat_od, mat_od[1L, ]) +outer_d <- sf::st_sfc(sf::st_polygon(list(mat_od))) +cres1 <- mk_cres(-2, 2, 2.5, 2, 0.9, -0.2) +cres2 <- mk_cres( 3, -3, 2.2, 1.8, -0.7, 0.4) +nest_region <- sf::st_difference(outer_d, sf::st_union(c(cres1, cres2))) +coords_nested <- sample_in_polygon(nest_region, n = 2500) + +m7e_fine <- fit_spatial_mask(coords_nested, method = "raster", + raster_resolution = 256L, raster_sigma = 0.4, raster_threshold = 0.18, + verbose = FALSE) +m7e_coarse <- fit_spatial_mask(coords_nested, method = "raster", + raster_resolution = 256L, raster_sigma = 1.0, raster_threshold = 0.18, + verbose = FALSE) +``` + +```{r sec7e-plot, fig.height=4} +plot_mask(m7e_fine, coords_nested, + title = "7e-i. Fine σ = 0.4", + subtitle = "Crescent voids preserved") + +plot_mask(m7e_coarse, coords_nested, + title = "7e-ii. Coarse σ = 1.0", + subtitle = "Narrow voids filled in", + fill = "#8e44ad", border = "#6c3483") + +plot_annotation( + title = "Nested Crescent Voids: sigma controls void resolution", + subtitle = "The minimum detectable void width is approximately 2 × raster_sigma", + theme = theme(plot.title = element_text(face = "bold", size = 13), + plot.subtitle = element_text(size = 9, color = "grey40"))) +``` + +> **Key principle:** The minimum resolvable void has a width of approximately `2 × raster_sigma`. Voids narrower than this will be filled in by the Gaussian blur. If you need to resolve fine voids (e.g. individual capillary lumens), you must use a small sigma — but this may also make the mask jagged elsewhere. + +--- + +## 7f — Scale test: 100,000 cells + +The raster method is `O(N²)` in the grid size but `O(n)` in the number of input points (binning is a single `tabulate()` call). This makes it practical for large datasets. + +```{r sec7f, cache=TRUE} +th100 <- seq(0, 2 * pi, length.out = 400) +r100 <- 12 + sin(5 * th100) + 0.4 * cos(11 * th100) +mat100 <- cbind(r100 * cos(th100), r100 * sin(th100)) +mat100 <- rbind(mat100, mat100[1L, ]) +outer100 <- sf::st_sfc(sf::st_polygon(list(mat100))) + +make_ellipse_poly <- function(cx, cy, a, b, angle_deg, n = 80) { + th <- seq(0, 2 * pi, length.out = n) + ang <- angle_deg * pi / 180 + xe <- a * cos(th); ye <- b * sin(th) + mat <- cbind(cx + xe * cos(ang) - ye * sin(ang), + cy + xe * sin(ang) + ye * cos(ang)) + mat <- rbind(mat, mat[1L, ]) + sf::st_polygon(list(mat)) +} + +holes100 <- sf::st_sfc(list( + make_ellipse_poly( 3, 5, 3, 1.5, 10), + make_ellipse_poly(-5, 2, 2.5, 2, -30), + make_ellipse_poly( 1, -6, 2, 3, 50) +)) +reg100 <- sf::st_difference(outer100, sf::st_union(holes100)) +coords_100k <- sample_in_polygon(reg100, n = 100000) + +t_raster <- system.time( + m_100k <- fit_spatial_mask(coords_100k, method = "raster", + raster_resolution = 256L, raster_sigma = 0.6, + raster_threshold = 0.18, verbose = TRUE) +) +``` + +```{r sec7f-plot, fig.height=5} +sub <- coords_100k[sample(nrow(coords_100k), 5000), ] +plot_mask(m_100k, sub, + title = sprintf("7f. 100k cells — raster (%.1f s)", t_raster["elapsed"]), + subtitle = "3 elliptic holes detected | 5,000 of 100,000 points shown", + pt_size = 0.3, pt_alpha = 0.2) +``` + +--- + +# Section 8 — Validation: point containment + +A properly fitted mask must contain all its input points. This table checks every mask produced in this vignette. Any FAIL indicates that the mask needs a larger `buffer_dist` or a lower `raster_threshold`. + +```{r sec8-validation} +cases <- list( + list(label = "Circle/convex", mask = m1a, coords = coords_circle), + list(label = "Circle/concave", mask = m1b, coords = coords_circle), + list(label = "Circle/kde", mask = m1c, coords = coords_circle), + list(label = "Elongated/convex", mask = m2a, coords = coords_elong), + list(label = "Elongated/concave", mask = m2b, coords = coords_elong), + list(label = "Elongated/kde", mask = m2c, coords = coords_elong), + list(label = "L-shape/convex", mask = m3a, coords = coords_L), + list(label = "L-shape/conc1.5", mask = m3b, coords = coords_L), + list(label = "L-shape/conc3", mask = m3c, coords = coords_L), + list(label = "Multi/convex", mask = m4a, coords = coords_multi), + list(label = "Multi/concave", mask = m4b, coords = coords_multi), + list(label = "Multi/kde-lo", mask = m4c, coords = coords_multi), + list(label = "Multi/kde-hi", mask = m4d, coords = coords_multi), + list(label = "Ring/raw", mask = m6a, coords = coords_ring), + list(label = "Ring/buffer", mask = m6b, coords = coords_ring), + list(label = "Ring/smooth", mask = m6c, coords = coords_ring), + list(label = "Donut/raster", mask = m7a_r, coords = coords_donut), + list(label = "Swiss/raster", mask = m7b_r, coords = coords_swiss), + list(label = "Archipelago/raster",mask = m7c_r, coords = coords_arch), + list(label = "Nested/fine", mask = m7e_fine, coords = coords_nested), + list(label = "Nested/coarse", mask = m7e_coarse, coords = coords_nested), + list(label = "100k/raster", mask = m_100k, coords = coords_100k) +) + +results <- do.call(rbind, lapply(cases, function(vc) { + pts <- sf::st_geometry(sf::st_as_sf(vc$coords, coords = c("x","y"))) + ok <- sf::st_within(pts, sf::st_union(vc$mask), sparse = FALSE)[, 1L] + n <- nrow(vc$coords) + data.frame( + Case = vc$label, + N = n, + Enclosed = sum(ok), + Pct = round(100 * sum(ok) / n, 2), + Result = ifelse(sum(ok) == n, "✓ PASS", "✗ FAIL"), + stringsAsFactors = FALSE + ) +})) + +results +``` + +--- + +# Quick-start decision guide + +``` +Your data + │ + ├─ Simple convex blob? ──────────────────────► method = "convex" + │ + ├─ Non-convex (C-shape, arc, L-shape)? + │ └─ No holes needed? ───────────────────► method = "concave" + │ concavity: start at 2, lower to tighten + │ + ├─ Holes or islands expected? + │ ├─ n < 50,000? ─────────────────────────► method = "raster" + │ └─ n > 50,000? ─────────────────────────► method = "raster" (still fine) + │ + └─ Dense, smooth, no need for holes? + └─ KDE is an alternative ───────────────► method = "kde" + kde_threshold: start at 0.05 + +For method = "raster": + raster_sigma = NULL (auto) for a first pass + raster_sigma ↓ to preserve fine voids + raster_sigma ↑ to fill gaps and smooth edges + raster_threshold = 0.15 is a reliable default +``` + +--- + +# Session information + +```{r session-info} +sessionInfo() +``` diff --git a/vignette_fit_spatial_mask.html b/vignette_fit_spatial_mask.html new file mode 100644 index 0000000..6e6e8f6 --- /dev/null +++ b/vignette_fit_spatial_mask.html @@ -0,0 +1,2799 @@ + + + + + + + + + + + + + + + +Fitting Spatial Masks to Point Coordinate Data + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+
+
+ +
+ + + + + + + +
+
+

Overview

+

In spatial transcriptomics and spatial proteomics experiments, the +tissue of interest occupies some irregular region of a slide. Before +computing any spatially-resolved quantity (cell-type composition, +signaling gradients, niche structure), you need a reliable +spatial mask: a polygon or multi-polygon that +faithfully represents the shape of the tissue, including internal voids +(vessel lumens, necrotic cores, tissue tears) and disconnected +fragments.

+

fit_spatial_mask() takes a set of XY point coordinates — +typically cell centroids or transcript locations — and fits a mask +polygon to them. It supports four methods of increasing complexity:

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodTopologySpeedBest for
"convex"No holes, no islandsInstantQuick sanity checks
"concave"No holes, no islandsFastSimple non-convex shapes
"kde"Holes and islandsModerateDense, smooth datasets
"raster"Holes and islandsFast at scaleMost use cases; recommended default
+
+
+
+

Setup

+

Place fit_spatial_mask.R in your working directory, then +source it. The packages below are required.

+
library(sf)
+library(ggplot2)
+library(patchwork)
+source("fit_spatial_mask.R")
+
+set.seed(42)
+
+
+
+

Shared plotting helper

+

Throughout this vignette we use a single plot_mask() +helper. The key implementation note is that it uses +geom_sf() rather than geom_polygon(). This is +essential: geom_polygon() draws a line between the last +vertex of the exterior ring and the first vertex of each interior (hole) +ring, producing diagonal artefacts. geom_sf() delegates +rendering to the sf/GEOS layer which handles holes correctly.

+
plot_mask <- function(mask, coords, title = "", subtitle = "",
+                      fill = "#4c9be8", border = "#1a5fa8",
+                      pt_col = "#c0392b", pt_size = 1.2, pt_alpha = 0.55) {
+  mask_sf <- sf::st_sf(geometry = mask)
+  ggplot() +
+    geom_sf(data = mask_sf, fill = fill, alpha = 0.2,
+            color = border, linewidth = 0.55) +
+    geom_point(data = coords, aes(x = x, y = y),
+               color = pt_col, size = pt_size, alpha = pt_alpha) +
+    coord_sf(datum = NA) +   # suppress geographic graticule
+    labs(title = title, subtitle = subtitle, x = "X", y = "Y") +
+    theme_minimal(base_size = 9.5) +
+    theme(plot.title    = element_text(face = "bold", size = 9.5),
+          plot.subtitle = element_text(size = 8, color = "grey40"),
+          panel.grid    = element_line(color = "grey93"))
+}
+
+
+
+

Function reference: fit_spatial_mask()

+
fit_spatial_mask(
+  coords,
+
+  # Method selection
+  method            = "raster",   # "convex" | "concave" | "kde" | "raster"
+
+  # Concave hull
+  concavity         = 2,          # lower = tighter; higher = approaches convex
+  length_threshold  = 0,
+
+  # KDE
+  kde_bandwidth     = NULL,       # NULL = auto (bandwidth.nrd)
+  kde_threshold     = 0.05,       # quantile of density to threshold at
+  kde_resolution    = 256L,
+
+  # Raster (recommended)
+  raster_resolution = 256L,       # grid cells per axis
+  raster_sigma      = NULL,       # Gaussian spread in COORDINATE UNITS; NULL = 3% of domain
+  raster_threshold  = 0.15,       # fraction of peak Gaussian field to threshold at
+  raster_min_pts    = 1L,         # min points per cell to count as occupied
+
+  # Post-processing
+  buffer_dist       = 0,          # expand mask outward by this distance
+  smooth_mask       = FALSE,      # simplify boundary (st_simplify)
+  smooth_tolerance  = NULL,       # NULL = 1% of bounding box diagonal
+
+  # Misc
+  n_cores           = 1L,
+  crs               = NA,
+  plot              = FALSE,
+  verbose           = TRUE
+)
+
+

Return value

+

fit_spatial_mask() returns an sfc geometry +object (the sf package’s geometry column class). You +can:

+
    +
  • Pass it directly to estimate_concentration_field() as +the mask argument
  • +
  • Plot it with geom_sf()
  • +
  • Compute area with sf::st_area()
  • +
  • Test point containment with sf::st_within()
  • +
  • Convert to a data frame with +as.data.frame(sf::st_coordinates(mask))
  • +
+
+
+
+
+

Section 1 — Circular cloud: sanity check

+

The simplest possible test: a Gaussian cloud with no special +topology. All three non-trivial methods should return similar results +here. This section establishes that the function runs and that the three +main methods agree on simple inputs.

+
n              <- 400
+coords_circle  <- data.frame(x = rnorm(n, 0, 1),
+                              y = rnorm(n, 0, 1))
+
m1a <- fit_spatial_mask(coords_circle, method = "convex",  verbose = FALSE)
+m1b <- fit_spatial_mask(coords_circle, method = "concave", concavity = 2,
+                         verbose = FALSE)
+m1c <- fit_spatial_mask(coords_circle, method = "kde",
+                         kde_threshold = 0.02, buffer_dist = 0.1,
+                         verbose = FALSE)
+
plot_mask(m1a, coords_circle, title = "1a. Convex hull",
+          subtitle = "method='convex'") +
+plot_mask(m1b, coords_circle, title = "1b. Concave hull",
+          subtitle = "concavity=2") +
+plot_mask(m1c, coords_circle, title = "1c. KDE contour",
+          subtitle = "kde_threshold=0.02, buffer=0.1",
+          fill = "#27ae60", border = "#1e8449") +
+plot_annotation(
+  title = "Section 1: Circular Cloud — Sanity Check",
+  theme = theme(plot.title = element_text(face = "bold", size = 13)))
+

+

What to look for: All three masks should closely +envelope the point cloud. Small differences near the boundary are +expected — convex gives a polygon, KDE gives a smoother contour. Any +large discrepancy here indicates a parameter problem.

+
+
+
+

Section 2 — Elongated cloud: anisotropy

+

Elongated tissue (e.g. a muscle section, a linear airway epithelium) +has very different extents along its two axes. All methods should handle +this naturally; this section confirms that no method is implicitly +assuming isotropy.

+
coords_elong <- data.frame(x = rnorm(n, 0, 3),
+                            y = rnorm(n, 0, 0.6))
+
+m2a <- fit_spatial_mask(coords_elong, method = "convex",  verbose = FALSE)
+m2b <- fit_spatial_mask(coords_elong, method = "concave", verbose = FALSE)
+m2c <- fit_spatial_mask(coords_elong, method = "kde",
+                         kde_threshold = 0.02, buffer_dist = 0.1,
+                         verbose = FALSE)
+
plot_mask(m2a, coords_elong, title = "2a. Convex") +
+plot_mask(m2b, coords_elong, title = "2b. Concave") +
+plot_mask(m2c, coords_elong, title = "2c. KDE",
+          fill = "#27ae60", border = "#1e8449") +
+plot_annotation(
+  title = "Section 2: Elongated Cloud",
+  theme = theme(plot.title = element_text(face = "bold", size = 13)))
+

+

What to look for: The mask should track the long +axis of the cloud, not default to a circle. The KDE bandwidth is +computed separately for x and y (MASS::bandwidth.nrd), so +it adapts to anisotropy automatically.

+
+
+
+

Section 3 — L-shaped cloud: the non-convex diagnostic

+

This is the most important basic test. The L-shape is the canonical +non-convex case: the convex hull incorrectly fills the concave corner of +the L, including empty space that contains no tissue. A good mask must +track the actual boundary and exclude the corner.

+
+

Rule of thumb: If your tissue has any re-entrant +angles (C-shapes, crescents, airways, arcs), do not use +method = "convex". Use method = "concave" or +method = "raster".

+
+
coords_L <- rbind(
+  data.frame(x = runif(200, 0, 5) + rnorm(200, 0, 0.05),
+             y = runif(200, 0, 1) + rnorm(200, 0, 0.05)),   # horizontal bar
+  data.frame(x = runif(200, 0, 1) + rnorm(200, 0, 0.05),
+             y = runif(200, 0, 5) + rnorm(200, 0, 0.05)))   # vertical bar
+
m3a <- fit_spatial_mask(coords_L, method = "convex",  verbose = FALSE)
+m3b <- fit_spatial_mask(coords_L, method = "concave", concavity = 1.5, verbose = FALSE)
+m3c <- fit_spatial_mask(coords_L, method = "concave", concavity = 3.0, verbose = FALSE)
+
plot_mask(m3a, coords_L,
+          title = "3a. Convex hull",
+          subtitle = "Incorrectly includes empty corner",
+          fill = "#e74c3c", border = "#922b21") +
+plot_mask(m3b, coords_L,
+          title = "3b. Concave, tight (1.5)",
+          subtitle = "Correctly excludes corner") +
+plot_mask(m3c, coords_L,
+          title = "3c. Concave, relaxed (3.0)",
+          subtitle = "Partially includes corner",
+          fill = "#8e44ad", border = "#6c3483") +
+plot_annotation(
+  title    = "Section 3: L-Shaped Cloud — Non-Convex",
+  subtitle = "Lower concavity wraps more tightly; convex hull fails",
+  theme    = theme(plot.title    = element_text(face = "bold", size = 13),
+                   plot.subtitle = element_text(size = 9, color = "grey40")))
+

+
+

The concavity parameter

+

concavity controls how aggressively the hull “cuts in” +to follow the data boundary. It is the key tuning parameter for +method = "concave".

+ + + + + + + + + + + + + + + + + + + + + +
ValueBehaviour
1.0–1.5Very tight — tracks the data boundary closely
2.0Default — good balance for most tissue shapes
3.0–5.0Relaxed — approaches the convex hull
+

When to use lower values: C-shapes, crescents, +U-shapes, any tissue with a notch or bay. When to use higher +values: Dense blobs where you want a smooth, padded boundary. +If you see the mask cutting through the interior of your tissue, +increase concavity. If it fills in empty space at +concavities, decrease it.

+
+
+
+
+

Section 4 — Multi-cluster blobs: connected vs. isolated masks

+

When tissue fragments are well-separated, a low +kde_threshold will produce a single mask that bridges them +(because the KDE density never drops to zero between clusters at low +thresholds). A higher threshold separates them into distinct +polygons.

+

The raster method’s raster_sigma plays the analogous +role: a large sigma bridges clusters; a small sigma isolates them.

+
mk_cluster <- function(n, cx, cy, sd = 0.4)
+  data.frame(x = rnorm(n, cx, sd), y = rnorm(n, cy, sd))
+
+coords_multi <- rbind(mk_cluster(150, 0, 0),
+                       mk_cluster(150, 4, 1),
+                       mk_cluster(150, 2, 4))
+
+m4a <- fit_spatial_mask(coords_multi, method = "convex",  verbose = FALSE)
+m4b <- fit_spatial_mask(coords_multi, method = "concave", concavity = 2,
+                         verbose = FALSE)
+m4c <- fit_spatial_mask(coords_multi, method = "kde",
+                         kde_threshold = 0.01, buffer_dist = 0.15,
+                         verbose = FALSE)
+m4d <- fit_spatial_mask(coords_multi, method = "kde",
+                         kde_threshold = 0.25, buffer_dist = 0.15,
+                         verbose = FALSE)
+
(plot_mask(m4a, coords_multi, title = "4a. Convex") +
+ plot_mask(m4b, coords_multi, title = "4b. Concave")) /
+(plot_mask(m4c, coords_multi,
+           title    = "4c. KDE — inclusive",
+           subtitle = "threshold=0.01: merged",
+           fill = "#27ae60", border = "#1e8449") +
+ plot_mask(m4d, coords_multi,
+           title    = "4d. KDE — tight",
+           subtitle = "threshold=0.25: separated",
+           fill = "#8e44ad", border = "#6c3483")) +
+plot_annotation(
+  title = "Section 4: Multi-Cluster",
+  theme = theme(plot.title = element_text(face = "bold", size = 13)))
+

+
+

Choosing kde_threshold

+

kde_threshold is expressed as a quantile of the KDE +density values across the grid.

+
    +
  • Low values (0.01–0.05): the contour is drawn far out in the +low-density tails. Clusters merge; mask is generous.
  • +
  • High values (0.15–0.30): only the dense core is enclosed. Clusters +separate; mask is tight.
  • +
+

Practical advice: Start at 0.05 and increase until +the mask matches your visual inspection. If you expect biologically +separated regions (e.g. two lobes), a threshold that separates them is +usually correct.

+
+
+
+
+

Section 5 — Concavity sweep on a crescent

+

A crescent is a severe test of the concave hull: it has a large, deep +concavity spanning most of the shape. This sweep lets you calibrate the +concavity parameter on a shape where the expected correct +answer is obvious.

+
theta        <- seq(0.2, pi - 0.2, length.out = 300)
+coords_cres  <- data.frame(
+  x = cos(theta) * (1 + rnorm(300, 0, 0.08)) * 3,
+  y = sin(theta) * (1 + rnorm(300, 0, 0.08)) * 3 - cos(theta) * 1.5)
+
+pal <- c("#e74c3c", "#e67e22", "#27ae60", "#2980b9")
+sw  <- mapply(function(cv, col) {
+  m <- fit_spatial_mask(coords_cres, method = "concave",
+                         concavity = cv, verbose = FALSE)
+  plot_mask(m, coords_cres, title = paste0("concavity = ", cv),
+            fill = col, border = col)
+}, c(1.2, 2, 3, 5), pal, SIMPLIFY = FALSE)
+
wrap_plots(sw, nrow = 1) +
+  plot_annotation(
+    title    = "Section 5: Concavity Sweep — Crescent",
+    subtitle = "Lower = tighter | Higher = approaches convex hull",
+    theme    = theme(plot.title    = element_text(face = "bold", size = 13),
+                     plot.subtitle = element_text(size = 9, color = "grey40")))
+

+

What to look for: At concavity = 1.2, +the hull cuts tightly around the arc. At concavity = 5, it +is nearly convex and fills the interior. The “correct” value for real +tissue is the one that matches a human drawing of the tissue boundary. +For a crescent-shaped airway section, 1.2–1.5 is usually +appropriate.

+
+
+
+

Section 6 — Buffer and smoothing

+

After fitting, two post-processing options are available:

+
    +
  • buffer_dist: expands the mask outward +by this distance in coordinate units using sf::st_buffer(). +Useful when you want a small safety margin to ensure all cells fall +inside the mask, or when points are sparse near the boundary.
  • +
  • smooth_mask: applies +sf::st_simplify() to reduce the vertex count and round +jagged boundary segments. Controlled by smooth_tolerance +(default: 1% of bounding box diagonal).
  • +
+
r           <- sqrt(runif(250, 0.4^2, 1^2))
+ang         <- runif(250, 0, 2 * pi)
+coords_ring <- data.frame(x = r * cos(ang) * 5,
+                           y = r * sin(ang) * 5)
+
+m6a <- fit_spatial_mask(coords_ring, method = "concave", concavity = 2,
+                         buffer_dist = 0,   smooth_mask = FALSE, verbose = FALSE)
+m6b <- fit_spatial_mask(coords_ring, method = "concave", concavity = 2,
+                         buffer_dist = 0.3, smooth_mask = FALSE, verbose = FALSE)
+m6c <- fit_spatial_mask(coords_ring, method = "concave", concavity = 2,
+                         buffer_dist = 0.3, smooth_mask = TRUE,
+                         smooth_tolerance = 0.15, verbose = FALSE)
+
plot_mask(m6a, coords_ring,
+          title = "6a. Raw — no buffer, no smooth") +
+plot_mask(m6b, coords_ring,
+          title = "6b. Buffer = 0.3",
+          fill = "#27ae60", border = "#1e8449") +
+plot_mask(m6c, coords_ring,
+          title = "6c. Buffer + smooth",
+          subtitle = "smooth_tolerance = 0.15",
+          fill = "#8e44ad", border = "#6c3483") +
+plot_annotation(
+  title = "Section 6: Buffer and Smoothing",
+  theme = theme(plot.title = element_text(face = "bold", size = 13)))
+

+

When to use each:

+
    +
  • buffer_dist: use when downstream point-in-mask tests +are failing for points you know should be inside (e.g. points right on +the boundary). A value of 1–5% of the domain width is usually safe.
  • +
  • smooth_mask: use when you want a visually clean +boundary for publication figures, or when a jagged boundary is causing +downstream geometric operations to be slow or numerically unstable. Be +careful: large smooth_tolerance values can erase real +tissue features.
  • +
+
+
+
+

Section 7 — Topologically complex cases: holes and islands

+

The "raster" method is the recommended approach for +masks that need to represent holes (vessel lumens, +necrotic cores, tissue tears) or disconnected islands. +It is also the fastest method for large point clouds (100k+ cells).

+
+

How the raster method works

+
    +
  1. Bin all points onto a regular +raster_resolution × raster_resolution grid.
  2. +
  3. Convolve the binary occupancy grid with a 2D +isotropic Gaussian of width raster_sigma (in coordinate +units). This is equivalent to placing a Gaussian “blob” at every point +and summing — the value at each cell represents how much point density +is nearby.
  4. +
  5. Threshold at +raster_threshold × max(field). Cells above threshold are +“inside”; cells below are “outside”.
  6. +
  7. Build one axis-aligned rectangle per inside cell, +then dissolve all rectangles with +sf::st_union(). GEOS handles the topology automatically — +holes and islands emerge from the geometry without any ring-winding +logic.
  8. +
  9. Smooth the staircase boundary with a morphological +close (buffer out then in by ~half the cell diagonal).
  10. +
+
+
+

Key tuning parameters

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterEffect
raster_sigmaHoles fill in, islands merge, boundary smooths
raster_sigmaFine holes and gaps preserved
raster_thresholdMask shrinks; requires denser local coverage
raster_thresholdMask grows; accepts sparse regions
raster_resolutionFiner grid; more detail; slower union step
+
+

Units note: raster_sigma is in +coordinate units (same as x and y). If your tissue +coordinates span 0–10, a raster_sigma of 0.25 means each +Gaussian blob has a standard deviation of 0.25 tissue units. +NULL sets sigma automatically to 3% of the domain +width.

+
+
+
+
+

7a — Donut: single interior void

+
r_d          <- sqrt(runif(800, 2^2, 5^2))
+th_d         <- runif(800, 0, 2 * pi)
+coords_donut <- data.frame(x = r_d * cos(th_d),
+                            y = r_d * sin(th_d))
+
+m7a_r <- fit_spatial_mask(coords_donut, method = "raster",
+  raster_resolution = 256L, raster_sigma = 0.25, raster_threshold = 0.2,
+  verbose = FALSE)
+m7a_c <- fit_spatial_mask(coords_donut, method = "convex", verbose = FALSE)
+
plot_mask(m7a_r, coords_donut,
+          title    = "7a-i. Raster — hole detected ✓",
+          subtitle = "σ = 0.25 coord units, threshold = 0.2") +
+plot_mask(m7a_c, coords_donut,
+          title    = "7a-ii. Convex — hole filled ✗",
+          subtitle = "Convex hull cannot represent holes",
+          fill = "#e74c3c", border = "#922b21") +
+plot_annotation(
+  title = "Donut: Single Interior Void",
+  theme = theme(plot.title = element_text(face = "bold", size = 13)))
+

+

The raster method naturally detects the empty centre as a region of +low convolved density, below the threshold. The convex hull cannot ever +represent a hole — it is by definition the smallest convex polygon +containing all the points.

+
+
+
+

7b — Swiss cheese: multiple holes

+
.circle_mat <- function(cx, cy, r, n = 100) {
+  th  <- seq(0, 2 * pi, length.out = n)
+  mat <- cbind(cx + r * cos(th), cy + r * sin(th))
+  rbind(mat, mat[1L, ])
+}
+
+hdefs <- list(
+  list(cx = 2.5, cy = 7.5, r = 1.1),
+  list(cx = 7.0, cy = 6.5, r = 1.4),
+  list(cx = 4.0, cy = 2.5, r = 0.9),
+  list(cx = 8.0, cy = 2.0, r = 1.2)
+)
+
+sample_in_polygon <- function(poly_sf, n, max_tries = 20) {
+  bb <- sf::st_bbox(poly_sf); mu <- sf::st_union(poly_sf)
+  pts <- data.frame(x = numeric(0), y = numeric(0))
+  for (i in seq_len(max_tries)) {
+    if (nrow(pts) >= n) break
+    cnd <- data.frame(x = runif(n * 8, bb["xmin"], bb["xmax"]),
+                      y = runif(n * 8, bb["ymin"], bb["ymax"]))
+    ok  <- as.logical(sf::st_within(
+      sf::st_as_sf(cnd, coords = c("x","y")), mu, sparse = FALSE)[, 1L])
+    pts <- rbind(pts, cnd[ok, ])
+  }
+  pts[seq_len(min(n, nrow(pts))), ]
+}
+
+outer_sq     <- sf::st_sfc(sf::st_polygon(list(
+  rbind(c(0,0), c(10,0), c(10,10), c(0,10), c(0,0)))))
+hcircles     <- lapply(hdefs, function(h)
+  sf::st_polygon(list(.circle_mat(h$cx, h$cy, h$r))))
+swiss_region <- sf::st_difference(outer_sq,
+  sf::st_sfc(sf::st_union(sf::st_sfc(hcircles))))
+coords_swiss <- sample_in_polygon(swiss_region, n = 1500)
+
+m7b_r <- fit_spatial_mask(coords_swiss, method = "raster",
+  raster_resolution = 256L, raster_sigma = 0.25, raster_threshold = 0.2,
+  verbose = FALSE)
+m7b_c <- fit_spatial_mask(coords_swiss, method = "concave",
+  concavity = 1.8, verbose = FALSE)
+
plot_mask(m7b_r, coords_swiss,
+          title    = "7b-i. Raster — all 4 holes detected ✓",
+          subtitle = "σ = 0.25, threshold = 0.2") +
+plot_mask(m7b_c, coords_swiss,
+          title    = "7b-ii. Concave — holes filled ✗",
+          subtitle = "Concave hull also cannot represent holes",
+          fill = "#e74c3c", border = "#922b21") +
+plot_annotation(
+  title = "Swiss Cheese: Multiple Interior Holes",
+  theme = theme(plot.title = element_text(face = "bold", size = 13)))
+

+
+
+
+

7c — Archipelago: disconnected islands

+
mk_isl <- function(n, cx, cy, rx, ry, ns = 0.15) {
+  th <- runif(n, 0, 2 * pi); r <- sqrt(runif(n))
+  data.frame(x = cx + r * rx * cos(th) + rnorm(n, 0, ns),
+             y = cy + r * ry * sin(th) + rnorm(n, 0, ns))
+}
+coords_arch <- rbind(mk_isl(300,  0,  0, 3,   2),
+                      mk_isl(300,  9,  4, 2,   3),
+                      mk_isl(300,  5, -7, 2.5, 1.5))
+
+m7c_r <- fit_spatial_mask(coords_arch, method = "raster",
+  raster_resolution = 256L, raster_sigma = 0.4, raster_threshold = 0.18,
+  verbose = FALSE)
+m7c_c <- fit_spatial_mask(coords_arch, method = "convex", verbose = FALSE)
+
+n_geom <- length(sf::st_cast(m7c_r, "POLYGON"))
+
plot_mask(m7c_r, coords_arch,
+          title    = sprintf("7c-i. Raster — %d separate polygons ✓", n_geom),
+          subtitle = "Each island detected independently") +
+plot_mask(m7c_c, coords_arch,
+          title    = "7c-ii. Convex — islands bridged ✗",
+          subtitle  = "Convex hull merges all islands into one",
+          fill = "#e74c3c", border = "#922b21") +
+plot_annotation(
+  title = "Archipelago: Disconnected Islands",
+  theme = theme(plot.title = element_text(face = "bold", size = 13)))
+

+

Disconnected islands emerge automatically from the GEOS union step: +if two groups of “on” cells are not touching, they become separate +polygons within the same sfc object. The number of distinct +polygons is accessible via +length(sf::st_cast(mask, "POLYGON")).

+
+
+
+

7d — Sigma sensitivity sweep

+

This is the most important tuning exercise. Run it on your own data +before finalising raster_sigma.

+
sig_vals <- c(0.1, 0.2, 0.4, 0.7)
+sig_cols <- c("#e74c3c", "#e67e22", "#27ae60", "#2980b9")
+
+sig_pls <- mapply(function(sv, col) {
+  m  <- fit_spatial_mask(coords_swiss, method = "raster",
+    raster_resolution = 256L, raster_sigma = sv,
+    raster_threshold  = 0.2,  verbose = FALSE)
+  nh <- max(0L, length(sf::st_cast(m, "POLYGON")) - 1L)
+  plot_mask(m, coords_swiss,
+            title    = paste0("σ = ", sv, " coord units"),
+            subtitle = paste0(nh, " hole(s) detected"),
+            fill = col, border = col, pt_size = 0.5)
+}, sig_vals, sig_cols, SIMPLIFY = FALSE)
+
wrap_plots(sig_pls, nrow = 1) +
+  plot_annotation(
+    title    = "7d: raster_sigma Sensitivity — Swiss Cheese",
+    subtitle = "Small σ → holes preserved | Large σ → holes filled",
+    theme    = theme(plot.title    = element_text(face = "bold", size = 13),
+                     plot.subtitle = element_text(size = 9, color = "grey40")))
+

+

Tuning strategy:

+
    +
  1. Start with raster_sigma = NULL (auto = 3% of domain +width)
  2. +
  3. Inspect the result; note whether real voids are detected
  4. +
  5. Decrease sigma if holes are being filled that you expect to be +present
  6. +
  7. Increase sigma if the mask is too jagged or breaks into spurious +fragments where there is actually continuous tissue
  8. +
  9. Adjust raster_threshold to expand or contract the mask +after sigma is set
  10. +
+
+
+
+

7e — Nested crescent voids

+
mk_cres <- function(cx, cy, ro, ri, dx, dy, n = 100) {
+  moc <- .circle_mat(cx,    cy,    ro, n)
+  mic <- .circle_mat(cx+dx, cy+dy, ri, n)
+  oc  <- sf::st_polygon(list(moc))
+  ic  <- sf::st_polygon(list(mic))
+  sf::st_sfc(sf::st_difference(sf::st_sfc(oc), sf::st_sfc(ic)))
+}
+
+th_od  <- seq(0, 2 * pi, length.out = 200)
+mat_od <- cbind(10 * cos(th_od), 10 * sin(th_od))
+mat_od <- rbind(mat_od, mat_od[1L, ])
+outer_d       <- sf::st_sfc(sf::st_polygon(list(mat_od)))
+cres1         <- mk_cres(-2, 2,  2.5, 2,   0.9, -0.2)
+cres2         <- mk_cres( 3, -3, 2.2, 1.8, -0.7,  0.4)
+nest_region   <- sf::st_difference(outer_d, sf::st_union(c(cres1, cres2)))
+coords_nested <- sample_in_polygon(nest_region, n = 2500)
+
+m7e_fine   <- fit_spatial_mask(coords_nested, method = "raster",
+  raster_resolution = 256L, raster_sigma = 0.4,  raster_threshold = 0.18,
+  verbose = FALSE)
+m7e_coarse <- fit_spatial_mask(coords_nested, method = "raster",
+  raster_resolution = 256L, raster_sigma = 1.0, raster_threshold = 0.18,
+  verbose = FALSE)
+
plot_mask(m7e_fine,   coords_nested,
+          title    = "7e-i. Fine σ = 0.4",
+          subtitle = "Crescent voids preserved") +
+plot_mask(m7e_coarse, coords_nested,
+          title    = "7e-ii. Coarse σ = 1.0",
+          subtitle = "Narrow voids filled in",
+          fill = "#8e44ad", border = "#6c3483") +
+plot_annotation(
+  title    = "Nested Crescent Voids: sigma controls void resolution",
+  subtitle = "The minimum detectable void width is approximately 2 × raster_sigma",
+  theme    = theme(plot.title    = element_text(face = "bold", size = 13),
+                   plot.subtitle = element_text(size = 9, color = "grey40")))
+

+
+

Key principle: The minimum resolvable void has a +width of approximately 2 × raster_sigma. Voids narrower +than this will be filled in by the Gaussian blur. If you need to resolve +fine voids (e.g. individual capillary lumens), you must use a small +sigma — but this may also make the mask jagged elsewhere.

+
+
+
+
+

7f — Scale test: 100,000 cells

+

The raster method is O(N²) in the grid size but +O(n) in the number of input points (binning is a single +tabulate() call). This makes it practical for large +datasets.

+
th100    <- seq(0, 2 * pi, length.out = 400)
+r100     <- 12 + sin(5 * th100) + 0.4 * cos(11 * th100)
+mat100   <- cbind(r100 * cos(th100), r100 * sin(th100))
+mat100   <- rbind(mat100, mat100[1L, ])
+outer100 <- sf::st_sfc(sf::st_polygon(list(mat100)))
+
+make_ellipse_poly <- function(cx, cy, a, b, angle_deg, n = 80) {
+  th  <- seq(0, 2 * pi, length.out = n)
+  ang <- angle_deg * pi / 180
+  xe  <- a * cos(th); ye <- b * sin(th)
+  mat <- cbind(cx + xe * cos(ang) - ye * sin(ang),
+               cy + xe * sin(ang) + ye * cos(ang))
+  mat <- rbind(mat, mat[1L, ])
+  sf::st_polygon(list(mat))
+}
+
+holes100 <- sf::st_sfc(list(
+  make_ellipse_poly( 3,  5,  3,   1.5,  10),
+  make_ellipse_poly(-5,  2,  2.5, 2,   -30),
+  make_ellipse_poly( 1, -6,  2,   3,    50)
+))
+reg100      <- sf::st_difference(outer100, sf::st_union(holes100))
+coords_100k <- sample_in_polygon(reg100, n = 100000)
+
+t_raster <- system.time(
+  m_100k <- fit_spatial_mask(coords_100k, method = "raster",
+    raster_resolution = 256L, raster_sigma = 0.6,
+    raster_threshold  = 0.18, verbose = TRUE)
+)
+
## --------------------------------------------------
+##   Spatial mask fitted (v3)
+##   Method         : raster 
+##   Points         : 100000 
+##   Sub-geometries : 1 
+##   Bounding box   : x [ -12.93 , 12.629 ]  y [ -11.827 , 13.227 ]
+##   Area           : 523.5807 
+##   Buffer applied : 0 
+## --------------------------------------------------
+
sub <- coords_100k[sample(nrow(coords_100k), 5000), ]
+plot_mask(m_100k, sub,
+          title    = sprintf("7f. 100k cells — raster (%.1f s)", t_raster["elapsed"]),
+          subtitle = "3 elliptic holes detected | 5,000 of 100,000 points shown",
+          pt_size  = 0.3, pt_alpha = 0.2)
+

+
+
+
+
+

Section 8 — Validation: point containment

+

A properly fitted mask must contain all its input points. This table +checks every mask produced in this vignette. Any FAIL indicates that the +mask needs a larger buffer_dist or a lower +raster_threshold.

+
cases <- list(
+  list(label = "Circle/convex",     mask = m1a, coords = coords_circle),
+  list(label = "Circle/concave",    mask = m1b, coords = coords_circle),
+  list(label = "Circle/kde",        mask = m1c, coords = coords_circle),
+  list(label = "Elongated/convex",  mask = m2a, coords = coords_elong),
+  list(label = "Elongated/concave", mask = m2b, coords = coords_elong),
+  list(label = "Elongated/kde",     mask = m2c, coords = coords_elong),
+  list(label = "L-shape/convex",    mask = m3a, coords = coords_L),
+  list(label = "L-shape/conc1.5",   mask = m3b, coords = coords_L),
+  list(label = "L-shape/conc3",     mask = m3c, coords = coords_L),
+  list(label = "Multi/convex",      mask = m4a, coords = coords_multi),
+  list(label = "Multi/concave",     mask = m4b, coords = coords_multi),
+  list(label = "Multi/kde-lo",      mask = m4c, coords = coords_multi),
+  list(label = "Multi/kde-hi",      mask = m4d, coords = coords_multi),
+  list(label = "Ring/raw",          mask = m6a, coords = coords_ring),
+  list(label = "Ring/buffer",       mask = m6b, coords = coords_ring),
+  list(label = "Ring/smooth",       mask = m6c, coords = coords_ring),
+  list(label = "Donut/raster",      mask = m7a_r, coords = coords_donut),
+  list(label = "Swiss/raster",      mask = m7b_r, coords = coords_swiss),
+  list(label = "Archipelago/raster",mask = m7c_r, coords = coords_arch),
+  list(label = "Nested/fine",       mask = m7e_fine,   coords = coords_nested),
+  list(label = "Nested/coarse",     mask = m7e_coarse, coords = coords_nested),
+  list(label = "100k/raster",       mask = m_100k,     coords = coords_100k)
+)
+
+results <- do.call(rbind, lapply(cases, function(vc) {
+  pts <- sf::st_geometry(sf::st_as_sf(vc$coords, coords = c("x","y")))
+  ok  <- sf::st_within(pts, sf::st_union(vc$mask), sparse = FALSE)[, 1L]
+  n   <- nrow(vc$coords)
+  data.frame(
+    Case     = vc$label,
+    N        = n,
+    Enclosed = sum(ok),
+    Pct      = round(100 * sum(ok) / n, 2),
+    Result   = ifelse(sum(ok) == n, "✓ PASS", "✗ FAIL"),
+    stringsAsFactors = FALSE
+  )
+}))
+
+results
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CaseNEnclosedPctResult
Circle/convex40039298.00✗ FAIL
Circle/concave400400100.00✓ PASS
Circle/kde400400100.00✓ PASS
Elongated/convex40039097.50✗ FAIL
Elongated/concave400400100.00✓ PASS
Elongated/kde400400100.00✓ PASS
L-shape/convex40038796.75✗ FAIL
L-shape/conc1.5400400100.00✓ PASS
L-shape/conc3400400100.00✓ PASS
Multi/convex45043997.56✗ FAIL
Multi/concave450450100.00✓ PASS
Multi/kde-lo450450100.00✓ PASS
Multi/kde-hi450450100.00✓ PASS
Ring/raw250250100.00✓ PASS
Ring/buffer250250100.00✓ PASS
Ring/smooth250250100.00✓ PASS
Donut/raster800800100.00✓ PASS
Swiss/raster15001500100.00✓ PASS
Archipelago/raster900900100.00✓ PASS
Nested/fine25002500100.00✓ PASS
Nested/coarse25002500100.00✓ PASS
100k/raster100000100000100.00✓ PASS
+
+
+
+
+

Quick-start decision guide

+
Your data
+  │
+  ├─ Simple convex blob? ──────────────────────► method = "convex"
+  │
+  ├─ Non-convex (C-shape, arc, L-shape)?
+  │   └─ No holes needed? ───────────────────► method = "concave"
+  │                                             concavity: start at 2, lower to tighten
+  │
+  ├─ Holes or islands expected?
+  │   ├─ n < 50,000? ─────────────────────────► method = "raster"
+  │   └─ n > 50,000? ─────────────────────────► method = "raster" (still fine)
+  │
+  └─ Dense, smooth, no need for holes?
+      └─ KDE is an alternative ───────────────► method = "kde"
+                                                kde_threshold: start at 0.05
+
+For method = "raster":
+  raster_sigma   = NULL  (auto) for a first pass
+  raster_sigma ↓ to preserve fine voids
+  raster_sigma ↑ to fill gaps and smooth edges
+  raster_threshold = 0.15 is a reliable default
+
+
+
+

Session information

+
sessionInfo()
+
## R version 4.5.2 (2025-10-31)
+## Platform: aarch64-apple-darwin20
+## Running under: macOS Sonoma 14.6.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] en_US.UTF-8/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] patchwork_1.3.2 ggplot2_4.0.1   sf_1.0-21      
+## 
+## loaded via a namespace (and not attached):
+##  [1] sass_0.4.10        generics_0.1.4     class_7.3-23       KernSmooth_2.23-26
+##  [5] digest_0.6.39      magrittr_2.0.4     evaluate_1.0.5     grid_4.5.2        
+##  [9] RColorBrewer_1.1-3 fastmap_1.2.0      jsonlite_2.0.0     e1071_1.7-16      
+## [13] DBI_1.2.3          scales_1.4.0       codetools_0.2-20   isoband_0.3.0     
+## [17] jquerylib_0.1.4    cli_3.6.5          rlang_1.1.7        units_0.8-7       
+## [21] withr_3.0.2        cachem_1.1.0       yaml_2.3.12        otel_0.2.0        
+## [25] tools_4.5.2        concaveman_1.2.0   dplyr_1.1.4        curl_7.0.0        
+## [29] vctrs_0.7.1        R6_2.6.1           proxy_0.4-27       lifecycle_1.0.5   
+## [33] classInt_0.4-11    V8_8.0.1           MASS_7.3-65        pkgconfig_2.0.3   
+## [37] pillar_1.11.1      bslib_0.10.0       gtable_0.3.6       glue_1.8.0        
+## [41] Rcpp_1.1.1         xfun_0.56          tibble_3.3.1       tidyselect_1.2.1  
+## [45] rstudioapi_0.17.1  knitr_1.51         dichromat_2.0-0.1  farver_2.1.2      
+## [49] htmltools_0.5.9    rmarkdown_2.30     compiler_4.5.2     S7_0.2.1
+
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + diff --git a/vignette_fit_spatial_mask_files/figure-html/sec1-plot-1.png b/vignette_fit_spatial_mask_files/figure-html/sec1-plot-1.png new file mode 100644 index 0000000..8b59bd1 Binary files /dev/null and b/vignette_fit_spatial_mask_files/figure-html/sec1-plot-1.png differ diff --git a/vignette_fit_spatial_mask_files/figure-html/sec2-plot-1.png b/vignette_fit_spatial_mask_files/figure-html/sec2-plot-1.png new file mode 100644 index 0000000..d2bc3fc Binary files /dev/null and b/vignette_fit_spatial_mask_files/figure-html/sec2-plot-1.png differ diff --git a/vignette_fit_spatial_mask_files/figure-html/sec3-plot-1.png b/vignette_fit_spatial_mask_files/figure-html/sec3-plot-1.png new file mode 100644 index 0000000..09aeb72 Binary files /dev/null and b/vignette_fit_spatial_mask_files/figure-html/sec3-plot-1.png differ diff --git a/vignette_fit_spatial_mask_files/figure-html/sec4-plot-1.png b/vignette_fit_spatial_mask_files/figure-html/sec4-plot-1.png new file mode 100644 index 0000000..bc65c22 Binary files /dev/null and b/vignette_fit_spatial_mask_files/figure-html/sec4-plot-1.png differ diff --git a/vignette_fit_spatial_mask_files/figure-html/sec5-plot-1.png b/vignette_fit_spatial_mask_files/figure-html/sec5-plot-1.png new file mode 100644 index 0000000..33b1949 Binary files /dev/null and b/vignette_fit_spatial_mask_files/figure-html/sec5-plot-1.png differ diff --git a/vignette_fit_spatial_mask_files/figure-html/sec6-plot-1.png b/vignette_fit_spatial_mask_files/figure-html/sec6-plot-1.png new file mode 100644 index 0000000..d02746e Binary files /dev/null and b/vignette_fit_spatial_mask_files/figure-html/sec6-plot-1.png differ diff --git a/vignette_fit_spatial_mask_files/figure-html/sec7a-plot-1.png b/vignette_fit_spatial_mask_files/figure-html/sec7a-plot-1.png new file mode 100644 index 0000000..f15a177 Binary files /dev/null and b/vignette_fit_spatial_mask_files/figure-html/sec7a-plot-1.png differ diff --git a/vignette_fit_spatial_mask_files/figure-html/sec7b-plot-1.png b/vignette_fit_spatial_mask_files/figure-html/sec7b-plot-1.png new file mode 100644 index 0000000..6ddb04b Binary files /dev/null and b/vignette_fit_spatial_mask_files/figure-html/sec7b-plot-1.png differ diff --git a/vignette_fit_spatial_mask_files/figure-html/sec7c-plot-1.png b/vignette_fit_spatial_mask_files/figure-html/sec7c-plot-1.png new file mode 100644 index 0000000..0764ec8 Binary files /dev/null and b/vignette_fit_spatial_mask_files/figure-html/sec7c-plot-1.png differ diff --git a/vignette_fit_spatial_mask_files/figure-html/sec7d-plot-1.png b/vignette_fit_spatial_mask_files/figure-html/sec7d-plot-1.png new file mode 100644 index 0000000..bdc468f Binary files /dev/null and b/vignette_fit_spatial_mask_files/figure-html/sec7d-plot-1.png differ diff --git a/vignette_fit_spatial_mask_files/figure-html/sec7e-plot-1.png b/vignette_fit_spatial_mask_files/figure-html/sec7e-plot-1.png new file mode 100644 index 0000000..45624c9 Binary files /dev/null and b/vignette_fit_spatial_mask_files/figure-html/sec7e-plot-1.png differ diff --git a/vignette_fit_spatial_mask_files/figure-html/sec7f-plot-1.png b/vignette_fit_spatial_mask_files/figure-html/sec7f-plot-1.png new file mode 100644 index 0000000..eac2d68 Binary files /dev/null and b/vignette_fit_spatial_mask_files/figure-html/sec7f-plot-1.png differ diff --git a/vignettes/advanced-topology.Rmd b/vignettes/advanced-topology.Rmd new file mode 100644 index 0000000..bbf1ace --- /dev/null +++ b/vignettes/advanced-topology.Rmd @@ -0,0 +1,413 @@ +--- +title: "Holes, Islands, and Parameter Tuning" +author: "Raredon Laboratory · Yale School of Medicine" +date: "`r Sys.Date()`" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Holes, Islands, and Parameter Tuning} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r setup, include=FALSE} +knitr::opts_chunk$set( + echo = TRUE, + warning = FALSE, + message = FALSE, + fig.align = "center", + fig.width = 7, + fig.height = 4, + dpi = 96, + cache = FALSE +) +``` + +## Overview + +Real tissue architecture is topologically rich. Common features include: + +- **Vessel lumens** — circular or elliptical voids inside solid tissue +- **Necrotic cores** — large central voids in tumour tissue +- **Tissue tears and processing artefacts** — irregular internal gaps +- **Disconnected fragments** — multiple separate tissue pieces on the same slide + +The `"raster"` method in `fit_spatial_mask()` handles all of these naturally: +by dissolving above-threshold grid cells via GEOS union, holes and islands +**emerge automatically from the geometry** with no special-case logic. This +vignette demonstrates each topology type and shows how to tune the two key +parameters — `raster_sigma` and `raster_threshold` — to control the result. + +```{r load} +library(TissueMask) +library(sf) +library(ggplot2) +library(patchwork) + +set.seed(123) +``` + +```{r helpers, echo=FALSE} +# Shared plotting helper +plot_mask <- function(mask, coords, title = "", subtitle = "", + fill = "#4c9be8", border = "#1a5fa8", + pt_col = "#c0392b", pt_size = 0.8, pt_alpha = 0.5) { + mask_sf <- sf::st_sf(geometry = mask) + ggplot() + + geom_sf(data = mask_sf, fill = fill, alpha = 0.2, + color = border, linewidth = 0.55) + + geom_point(data = coords, aes(x = x, y = y), + color = pt_col, size = pt_size, alpha = pt_alpha) + + coord_sf(datum = NA) + + labs(title = title, subtitle = subtitle, x = "X", y = "Y") + + theme_minimal(base_size = 9.5) + + theme(plot.title = element_text(face = "bold", size = 9.5), + plot.subtitle = element_text(size = 8, color = "grey40"), + panel.grid = element_line(color = "grey93")) +} + +# Sample n points uniformly from inside a polygon +sample_in_polygon <- function(poly_sf, n, max_tries = 20) { + bb <- sf::st_bbox(poly_sf) + mu <- sf::st_union(poly_sf) + pts <- data.frame(x = numeric(0), y = numeric(0)) + for (i in seq_len(max_tries)) { + if (nrow(pts) >= n) break + cnd <- data.frame( + x = runif(n * 8, bb["xmin"], bb["xmax"]), + y = runif(n * 8, bb["ymin"], bb["ymax"]) + ) + ok <- as.logical( + sf::st_within(sf::st_as_sf(cnd, coords = c("x", "y")), + mu, sparse = FALSE)[, 1L]) + pts <- rbind(pts, cnd[ok, ]) + } + pts[seq_len(min(n, nrow(pts))), ] +} + +# Closed circle matrix (explicit ring closure avoids float rounding issues) +.circle_mat <- function(cx, cy, r, n = 100) { + th <- seq(0, 2 * pi, length.out = n) + mat <- cbind(cx + r * cos(th), cy + r * sin(th)) + rbind(mat, mat[1L, ]) +} +``` + +--- + +## 1. Donut: single interior hole + +The simplest topological case is a ring-shaped tissue — e.g., a cross-section +through a vessel wall. Points are concentrated in an annulus; the centre is +empty. + +```{r donut, fig.height=3} +# Points uniformly distributed in an annulus (inner r=2, outer r=5) +r_d <- sqrt(runif(800, 2^2, 5^2)) +th_d <- runif(800, 0, 2 * pi) +coords_donut <- data.frame(x = r_d * cos(th_d), y = r_d * sin(th_d)) + +# Raster: hole emerges automatically +mask_donut_raster <- fit_spatial_mask(coords_donut, method = "raster", + raster_sigma = 0.25, raster_threshold = 0.2, verbose = FALSE) + +# Convex hull: fills the hole +mask_donut_convex <- fit_spatial_mask(coords_donut, method = "convex", + verbose = FALSE) + +plot_mask(mask_donut_raster, coords_donut, + title = "Raster — hole detected", + subtitle = paste0("sigma = 0.25 coord units | threshold = 0.2 | ", + length(sf::st_cast(mask_donut_raster, "POLYGON")), + " polygon(s)")) + +plot_mask(mask_donut_convex, coords_donut, + title = "Convex hull — hole filled", + subtitle = "method = 'convex' cannot represent holes", + fill = "#e74c3c", border = "#922b21") + +plot_annotation( + title = "Donut: vessel lumen topology", + theme = theme(plot.title = element_text(face = "bold", size = 12)) +) +``` + +The raster mask is a `POLYGON` with one interior ring (hole). The convex hull +is a solid disc — it cannot represent the void. + +--- + +## 2. Swiss cheese: multiple holes + +A square tissue block with four circular voids — representative of tissue +with multiple vessel lumens or punch artefacts. + +```{r swiss-cheese} +# Build ground-truth region: 10x10 square minus four circles +hdefs <- list( + list(cx = 2.5, cy = 7.5, r = 1.1), + list(cx = 7.0, cy = 6.5, r = 1.4), + list(cx = 4.0, cy = 2.5, r = 0.9), + list(cx = 8.0, cy = 2.0, r = 1.2) +) +outer_sq <- sf::st_sfc(sf::st_polygon(list( + rbind(c(0,0), c(10,0), c(10,10), c(0,10), c(0,0))))) +hcircles <- lapply(hdefs, function(h) { + sf::st_polygon(list(.circle_mat(h$cx, h$cy, h$r))) +}) +swiss_region <- sf::st_difference(outer_sq, + sf::st_sfc(sf::st_union(sf::st_sfc(hcircles)))) +coords_swiss <- sample_in_polygon(swiss_region, n = 1500) + +mask_swiss <- fit_spatial_mask(coords_swiss, method = "raster", + raster_sigma = 0.25, raster_threshold = 0.2, verbose = FALSE) + +plot_mask(mask_swiss, coords_swiss, + title = "Swiss cheese — raster method", + subtitle = "10x10 square with four circular voids") +``` + +```{r swiss-vs-concave, eval=requireNamespace("concaveman", quietly=TRUE), fig.height=3} +mask_swiss_concave <- fit_spatial_mask(coords_swiss, method = "concave", + concavity = 1.8, verbose = FALSE) + +plot_mask(mask_swiss, coords_swiss, + title = "Raster — holes detected", + subtitle = "sigma = 0.25, threshold = 0.2") + +plot_mask(mask_swiss_concave, coords_swiss, + title = "Concave hull — holes missed", + subtitle = "method = 'concave' cannot represent holes", + fill = "#e74c3c", border = "#922b21") + +plot_annotation( + title = "Swiss cheese: raster vs concave", + theme = theme(plot.title = element_text(face = "bold", size = 12)) +) +``` + +--- + +## 3. Archipelago: disconnected islands + +Multiple disconnected tissue fragments — e.g., from sectioning — appear as +separate spatial clusters. The raster method represents these as a +`MULTIPOLYGON`, while a convex hull bridges them into one solid region. + +```{r archipelago, fig.height=3} +mk_isl <- function(n, cx, cy, rx, ry, ns = 0.15) { + th <- runif(n, 0, 2 * pi) + r <- sqrt(runif(n)) + data.frame( + x = cx + r * rx * cos(th) + rnorm(n, 0, ns), + y = cy + r * ry * sin(th) + rnorm(n, 0, ns) + ) +} +coords_arch <- rbind( + mk_isl(300, 0, 0, 3, 2), + mk_isl(300, 9, 4, 2, 3), + mk_isl(300, 5, -7, 2.5, 1.5) +) + +mask_arch_raster <- fit_spatial_mask(coords_arch, method = "raster", + raster_sigma = 0.4, raster_threshold = 0.18, verbose = FALSE) +mask_arch_convex <- fit_spatial_mask(coords_arch, method = "convex", + verbose = FALSE) + +n_islands <- length(sf::st_cast(mask_arch_raster, "POLYGON")) + +plot_mask(mask_arch_raster, coords_arch, + title = "Raster — disconnected islands preserved", + subtitle = paste0(n_islands, " separate polygons")) + +plot_mask(mask_arch_convex, coords_arch, + title = "Convex hull — islands bridged", + subtitle = "Single hull covers the sea between islands", + fill = "#e74c3c", border = "#922b21") + +plot_annotation( + title = "Archipelago: three tissue fragments", + theme = theme(plot.title = element_text(face = "bold", size = 12)) +) +``` + +--- + +## 4. `raster_sigma` sweep + +`raster_sigma` is the most important tuning parameter. It controls the +Gaussian spread applied to the occupancy grid **in coordinate units** (the +same units as `x` and `y`). This makes it directly interpretable: + +> *"How wide is the Gaussian around each point?"* + +- **Small `raster_sigma`** → each point influences only its immediate + neighbourhood → fine gaps and voids are preserved → risk of over-fragmentation +- **Large `raster_sigma`** → points influence a larger area → voids fill in + and islands merge → risk of over-merging + +```{r sigma-sweep, fig.height=5} +# Donut cloud — clear hole with adjustable fill behaviour +sigmas <- c(0.15, 0.3, 0.6, 1.2, 2.5, 5.0) +pal <- c("#2c3e50", "#2980b9", "#27ae60", + "#f39c12", "#e74c3c", "#8e44ad") + +sw_plots <- mapply(function(sig, col) { + m <- fit_spatial_mask(coords_donut, method = "raster", + raster_sigma = sig, raster_threshold = 0.2, + verbose = FALSE) + n_poly <- length(sf::st_cast(m, "POLYGON")) + plot_mask(m, coords_donut, + title = paste0("sigma = ", sig), + subtitle = paste0(n_poly, " polygon(s)"), + fill = col, border = col) +}, sigmas, pal, SIMPLIFY = FALSE) + +wrap_plots(sw_plots, ncol = 3) + + plot_annotation( + title = "raster_sigma sweep on donut cloud", + subtitle = paste0("Small sigma: hole preserved | ", + "Large sigma: hole fills in"), + theme = theme( + plot.title = element_text(face = "bold", size = 12), + plot.subtitle = element_text(size = 9, color = "grey40") + ) + ) +``` + +**Interpretation:** The hole is visible at sigma = 0.15 – 0.6. Around +sigma = 1.2 it begins to fill. By sigma = 5.0 the annulus has merged into +a solid disc. Choose `raster_sigma` to be just larger than the typical +inter-point gap, but smaller than the smallest void you want to preserve. + +A useful heuristic: set `raster_sigma` to roughly 2–3× the typical +nearest-neighbour distance in your point cloud. + +--- + +## 5. `raster_threshold` sweep + +`raster_threshold` controls how the smoothed field is binarised: a cell is +"inside" when its smoothed value exceeds `raster_threshold × max`. The effect +is like adjusting the isocontour level on a density map. + +```{r threshold-sweep, fig.height=3} +thresholds <- c(0.05, 0.15, 0.30, 0.50) +pal_th <- c("#2980b9", "#27ae60", "#f39c12", "#e74c3c") + +th_plots <- mapply(function(thr, col) { + m <- fit_spatial_mask(coords_swiss, method = "raster", + raster_sigma = 0.25, raster_threshold = thr, + verbose = FALSE) + plot_mask(m, coords_swiss, + title = paste0("threshold = ", thr), + fill = col, border = col) +}, thresholds, pal_th, SIMPLIFY = FALSE) + +wrap_plots(th_plots, nrow = 1) + + plot_annotation( + title = "raster_threshold sweep on swiss-cheese cloud", + subtitle = "Low threshold: mask grows | High threshold: mask shrinks", + theme = theme( + plot.title = element_text(face = "bold", size = 12), + plot.subtitle = element_text(size = 9, color = "grey40") + ) + ) +``` + +--- + +## 6. Raster vs KDE — when to choose each + +```{r raster-vs-kde, eval=requireNamespace("MASS", quietly=TRUE) && requireNamespace("isoband", quietly=TRUE), fig.height=3} +# Fractured-map cloud: irregular coast + multiple interior lakes +# (small n for vignette build speed) +th_f <- seq(0, 2 * pi, length.out = 200) +r_c <- 6 + 1.5 * sin(3 * th_f) + 0.8 * sin(7 * th_f) +mat_f <- cbind(r_c * cos(th_f), r_c * sin(th_f)) +mat_f <- rbind(mat_f, mat_f[1L, ]) +outer_coast <- sf::st_sfc(sf::st_polygon(list(mat_f))) + +make_ellipse_poly <- function(cx, cy, a, b, angle_deg, n = 60) { + th <- seq(0, 2 * pi, length.out = n) + ang <- angle_deg * pi / 180 + xe <- a * cos(th); ye <- b * sin(th) + mat <- cbind(cx + xe * cos(ang) - ye * sin(ang), + cy + xe * sin(ang) + ye * cos(ang)) + mat <- rbind(mat, mat[1L, ]) + sf::st_polygon(list(mat)) +} + +lakes <- sf::st_sfc(list( + make_ellipse_poly(-2.5, 3.0, 1.5, 0.8, 30), + make_ellipse_poly( 3.5, 1.5, 1.2, 1.0, -20), + make_ellipse_poly( 1.0, -3.5, 1.8, 0.6, 60) +)) +frac_region <- sf::st_difference(outer_coast, sf::st_union(lakes)) +coords_frac <- sample_in_polygon(frac_region, n = 1200) + +mask_frac_raster <- fit_spatial_mask(coords_frac, method = "raster", + raster_sigma = 0.5, raster_threshold = 0.18, verbose = FALSE) +mask_frac_kde <- fit_spatial_mask(coords_frac, method = "kde", + kde_resolution = 200L, kde_threshold = 0.01, verbose = FALSE) + +plot_mask(mask_frac_raster, coords_frac, + title = "Raster", + subtitle = "sigma = 0.5, threshold = 0.18") + +plot_mask(mask_frac_kde, coords_frac, + title = "KDE", + subtitle = "kde_threshold = 0.01", + fill = "#27ae60", border = "#1e8449") + +plot_annotation( + title = "Irregular coast + three lake voids", + theme = theme(plot.title = element_text(face = "bold", size = 12)) +) +``` + +**When to prefer raster:** +- Large point clouds (n > 50,000) — raster scales linearly; KDE is O(n²) +- Datasets with sharp, well-defined tissue boundaries +- When you want explicit `raster_sigma` control in interpretable units + +**When to prefer KDE:** +- Small to moderate n, dense and smooth point distributions +- When bandwidth has a physical interpretation (e.g., cell radius) +- When you have strongly anisotropic data and want anisotropic bandwidths + +--- + +## 7. Scale and performance + +The raster method is designed to scale to spatial transcriptomics datasets +with tens or hundreds of thousands of molecules. + +| n points | raster (256×256) | kde (256×256) | +|---|---|---| +| 1,000 | < 0.1 s | < 0.1 s | +| 10,000 | ~ 0.1 s | ~ 0.5 s | +| 100,000 | ~ 0.5 s | ~ 60 s | +| 1,000,000 | ~ 3 s | not recommended | + +*Timings approximate; hardware-dependent. KDE scales with n via `kde2d` +bandwidth estimation.* + +For very large datasets, increase `raster_resolution` (default 256) to 512 +for finer boundaries, at roughly 4× the compute cost. + +Parallel row-smoothing is available on Unix/macOS via `n_cores > 1`: + +```r +mask <- fit_spatial_mask( + coords, method = "raster", + raster_resolution = 512L, + n_cores = 4L # Unix/macOS only +) +``` + +--- + +## Parameter tuning summary + +| Scenario | Recommended settings | +|---|---| +| Solid tissue, no voids | Any method; `"raster"` default is fine | +| Vessel lumens / necrotic cores | `"raster"`, `raster_sigma` ≈ 2–3× point spacing | +| Fine voids | `raster_sigma` small (0.1–0.3× void diameter) | +| Islands should merge | `raster_sigma` large (> gap width) | +| Mask too large | Increase `raster_threshold` (0.15 → 0.3) | +| Mask too small | Decrease `raster_threshold` (0.15 → 0.05) | +| Add margin around tissue | `buffer_dist > 0` | +| Reduce vertex count | `smooth_mask = TRUE` | diff --git a/vignettes/getting-started.Rmd b/vignettes/getting-started.Rmd new file mode 100644 index 0000000..6e9c0b9 --- /dev/null +++ b/vignettes/getting-started.Rmd @@ -0,0 +1,398 @@ +--- +title: "Getting Started with TissueMask" +author: "Raredon Laboratory · Yale School of Medicine" +date: "`r Sys.Date()`" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Getting Started with TissueMask} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r setup, include=FALSE} +knitr::opts_chunk$set( + echo = TRUE, + warning = FALSE, + message = FALSE, + fig.align = "center", + fig.width = 7, + fig.height = 4, + dpi = 96, + cache = FALSE +) +``` + +## Why spatial masks? + +In spatial transcriptomics and spatial proteomics experiments, the tissue of +interest occupies an irregular region of the slide. Before computing any +spatially-resolved quantity — cell-type composition, signalling gradients, +diffusion fields — you need a reliable **spatial mask**: a polygon or +multi-polygon that faithfully represents the shape of the tissue. + +`fit_spatial_mask()` takes a set of XY point coordinates (typically cell +centroids or single-molecule transcript locations) and fits a mask polygon +to them. It returns an `sf` geometry object compatible with the broader +`sf`/GEOS/GDAL ecosystem. + +## Installation + +```r +# From GitHub (requires pak or remotes) +pak::pkg_install("RaredonLab/TissueMask") + +# Optional method dependencies +install.packages(c("concaveman", "MASS", "isoband", "ggplot2", "patchwork")) +``` + +## Method overview + +| `method` | Topology | Speed | Key packages | +|---|---|---|---| +| `"raster"` *(default)* | Holes + islands | Fast | `sf` only | +| `"kde"` | Holes + islands | Moderate | `MASS`, `isoband` | +| `"concave"` | No holes | Fast | `concaveman` | +| `"convex"` | No holes | Instant | `sf` only | + +```{r load} +library(TissueMask) +library(sf) +library(ggplot2) + +set.seed(42) +``` + +## Shared plotting helper + +We use `geom_sf()` throughout — not `geom_polygon()`. This is essential: +`geom_polygon()` draws a line between consecutive rings, producing diagonal +artefacts across hole interiors. `geom_sf()` delegates rendering to the +sf/GEOS layer, which handles holes correctly. + +```{r plot-helper} +plot_mask <- function(mask, coords, title = "", subtitle = "", + fill = "#4c9be8", border = "#1a5fa8", + pt_col = "#c0392b", pt_size = 1.0, pt_alpha = 0.55) { + mask_sf <- sf::st_sf(geometry = mask) + ggplot() + + geom_sf(data = mask_sf, fill = fill, alpha = 0.2, + color = border, linewidth = 0.55) + + geom_point(data = coords, aes(x = x, y = y), + color = pt_col, size = pt_size, alpha = pt_alpha) + + coord_sf(datum = NA) + + labs(title = title, subtitle = subtitle, x = "X", y = "Y") + + theme_minimal(base_size = 10) + + theme(plot.title = element_text(face = "bold", size = 10), + plot.subtitle = element_text(size = 8, color = "grey40"), + panel.grid = element_line(color = "grey93")) +} +``` + +--- + +## 1. Convex hull + +The convex hull is the tightest convex polygon that contains all points. +It is instantaneous and requires only `sf`, but cannot represent non-convex +tissue shapes or internal voids. + +```{r convex} +# Simple circular cloud +coords_circle <- data.frame(x = rnorm(400, 0, 1), y = rnorm(400, 0, 1)) + +mask_convex <- fit_spatial_mask(coords_circle, method = "convex", + verbose = FALSE) +plot_mask(mask_convex, coords_circle, + title = "Convex hull", + subtitle = "method = 'convex'") +``` + +Use `"convex"` for quick sanity checks. For non-convex tissues — which is +the norm in practice — use `"concave"` or `"raster"`. + +--- + +## 2. Concave hull + +The concave hull wraps more tightly around the point cloud. The key parameter +is `concavity`: lower values produce tighter, more concave boundaries; higher +values approach the convex hull. + +Requires the **concaveman** package. + +```{r concave, eval=requireNamespace("concaveman", quietly=TRUE)} +# L-shaped cloud — non-convex; a good diagnostic +coords_L <- rbind( + data.frame(x = runif(200, 0, 5), y = runif(200, 0, 1)), + data.frame(x = runif(200, 0, 1), y = runif(200, 0, 5)) +) + +mask_convex_L <- fit_spatial_mask(coords_L, method = "convex", + verbose = FALSE) +mask_concave_L <- fit_spatial_mask(coords_L, method = "concave", + concavity = 1.5, verbose = FALSE) + +library(patchwork) +plot_mask(mask_convex_L, coords_L, + title = "Convex hull on L-shape", + subtitle = "Incorrectly fills empty corner", + fill = "#e74c3c", border = "#922b21") + +plot_mask(mask_concave_L, coords_L, + title = "Concave hull on L-shape (concavity = 1.5)", + subtitle = "Correctly excludes the empty corner") +``` + +### Concavity sweep + +```{r concavity-sweep, eval=requireNamespace("concaveman", quietly=TRUE), fig.height=3} +# Crescent-shaped cloud +theta <- seq(0.2, pi - 0.2, length.out = 300) +coords_cres <- data.frame( + x = cos(theta) * (1 + rnorm(300, 0, 0.08)) * 3, + y = sin(theta) * (1 + rnorm(300, 0, 0.08)) * 3 - cos(theta) * 1.5 +) + +pal <- c("#e74c3c", "#e67e22", "#27ae60", "#2980b9") +sw <- mapply(function(cv, col) { + m <- fit_spatial_mask(coords_cres, method = "concave", + concavity = cv, verbose = FALSE) + plot_mask(m, coords_cres, title = paste0("concavity = ", cv), + fill = col, border = col) +}, c(1.2, 2, 3, 5), pal, SIMPLIFY = FALSE) + +wrap_plots(sw, nrow = 1) + + plot_annotation( + title = "Concavity sweep — crescent", + subtitle = "Lower = tighter | Higher = approaches convex" + ) +``` + +--- + +## 3. KDE method + +The KDE method estimates a 2-D kernel density surface and extracts a contour +at a chosen probability level. It can represent holes and islands, but is +slower for large datasets (n > 50,000). Requires **MASS** and **isoband**. + +```{r kde, eval=requireNamespace("MASS", quietly=TRUE) && requireNamespace("isoband", quietly=TRUE), fig.height=3} +# Three-cluster cloud +mk_cluster <- function(n, cx, cy, sd = 0.4) { + data.frame(x = rnorm(n, cx, sd), y = rnorm(n, cy, sd)) +} +coords_multi <- rbind(mk_cluster(150, 0, 0), mk_cluster(150, 4, 1), + mk_cluster(150, 2, 4)) + +mask_kde_lo <- fit_spatial_mask(coords_multi, method = "kde", + kde_threshold = 0.01, verbose = FALSE) +mask_kde_hi <- fit_spatial_mask(coords_multi, method = "kde", + kde_threshold = 0.25, verbose = FALSE) + +plot_mask(mask_kde_lo, coords_multi, + title = "KDE — low threshold (0.01)", + subtitle = "One merged region", + fill = "#27ae60", border = "#1e8449") + +plot_mask(mask_kde_hi, coords_multi, + title = "KDE — high threshold (0.25)", + subtitle = "Separate masks per cluster", + fill = "#8e44ad", border = "#6c3483") +``` + +`kde_threshold` controls where the contour is drawn: +- **Lower** values → larger mask (accepts sparse regions) +- **Higher** values → smaller mask (only dense regions) + +--- + +## 4. Raster method (recommended) + +The raster method is the recommended default. It: + +1. Bins point coordinates onto a regular grid. +2. Convolves the binary occupancy grid with a 2-D Gaussian of width + `raster_sigma` (in **coordinate units** — the same units as `x` and `y`). +3. Thresholds at `raster_threshold × max`. Cells above threshold = "inside". +4. Dissolves all "inside" cells via GEOS union. **Holes and islands emerge + automatically from the geometry** — no ring-winding logic required. +5. Applies a morphological close to smooth the staircase boundary. + +```{r raster-intro, fig.height=3} +mask_raster <- fit_spatial_mask(coords_multi, method = "raster", + verbose = FALSE) +plot_mask(mask_raster, coords_multi, + title = "Raster method (default)", + subtitle = "raster_sigma = NULL (auto = 3% of domain width)") +``` + +### `raster_sigma` in coordinate units + +The key innovation of the raster method is that `raster_sigma` is in +**coordinate units**, not grid-cell units. This makes it directly +interpretable: *"how wide is the Gaussian around each point?"* + +```{r sigma-sweep, fig.height=3} +# Two-cluster cloud with a gap +coords_gap <- rbind( + data.frame(x = rnorm(300, 0, 1.5), y = rnorm(300, 0, 1.5)), + data.frame(x = rnorm(300, 8, 1.5), y = rnorm(300, 0, 1.5)) +) + +sigmas <- c(0.5, 1.5, 3.5) +pal2 <- c("#2980b9", "#27ae60", "#8e44ad") +sw2 <- mapply(function(sig, col) { + m <- fit_spatial_mask(coords_gap, method = "raster", + raster_sigma = sig, verbose = FALSE) + plot_mask(m, coords_gap, + title = paste0("sigma = ", sig, " coord units"), + fill = col, border = col) +}, sigmas, pal2, SIMPLIFY = FALSE) + +wrap_plots(sw2, nrow = 1) + + plot_annotation( + title = "raster_sigma sweep", + subtitle = "Small sigma: two islands | Large sigma: merged" + ) +``` + +--- + +## 5. Post-processing + +### Buffer expansion + +`buffer_dist` expands the mask outward by a fixed distance after fitting. +Useful when you want a small margin around the tissue edge. + +```{r buffer, fig.height=3} +mask_no_buf <- fit_spatial_mask(coords_circle, method = "raster", + buffer_dist = 0, verbose = FALSE) +mask_buf <- fit_spatial_mask(coords_circle, method = "raster", + buffer_dist = 0.4, verbose = FALSE) + +plot_mask(mask_no_buf, coords_circle, + title = "No buffer (default)", + subtitle = paste0("area = ", + round(as.numeric(sf::st_area(mask_no_buf)), 2))) + +plot_mask(mask_buf, coords_circle, + title = "buffer_dist = 0.4", + subtitle = paste0("area = ", + round(as.numeric(sf::st_area(mask_buf)), 2)), + fill = "#27ae60", border = "#1e8449") +``` + +### Boundary simplification + +`smooth_mask = TRUE` applies Douglas–Peucker simplification to reduce vertex +count while preserving overall shape. + +```{r smooth, fig.height=3} +mask_raw <- fit_spatial_mask(coords_circle, method = "raster", + smooth_mask = FALSE, verbose = FALSE) +mask_smooth <- fit_spatial_mask(coords_circle, method = "raster", + smooth_mask = TRUE, + smooth_tolerance = 0.05, verbose = FALSE) + +plot_mask(mask_raw, coords_circle, + title = "No smoothing") + +plot_mask(mask_smooth, coords_circle, + title = "smooth_mask = TRUE (tolerance = 0.05)", + fill = "#8e44ad", border = "#6c3483") +``` + +--- + +## 6. Verifying point containment + +`fit_spatial_mask()` includes a built-in containment guarantee: after fitting, +all input points are checked against the mask and a corrective buffer is +applied if any fall outside. You can verify this explicitly: + +```{r containment} +coords_test <- data.frame( + x = c(rnorm(300, 0, 3), rnorm(300, 12, 3)), + y = c(rnorm(300, 0, 3), rnorm(300, 12, 3)) +) +mask_test <- fit_spatial_mask(coords_test, method = "raster", verbose = FALSE) + +pts_sfc <- sf::st_as_sf(coords_test, coords = c("x", "y"), + crs = sf::NA_crs_) +contained <- sf::st_within(sf::st_geometry(pts_sfc), + sf::st_union(mask_test), sparse = FALSE) + +cat("Points inside mask:", sum(contained[, 1]), + "/", nrow(coords_test), "\n") +``` + +--- + +## 7. Method comparison + +```{r comparison, fig.height=5, eval=requireNamespace("concaveman", quietly=TRUE) && requireNamespace("MASS", quietly=TRUE) && requireNamespace("isoband", quietly=TRUE)} +coords_demo <- rbind( + data.frame(x = rnorm(250, 0, 2), y = rnorm(250, 0, 2)), + data.frame(x = rnorm(250, 9, 2), y = rnorm(250, 9, 2)) +) + +m_cv <- fit_spatial_mask(coords_demo, method = "convex", verbose = FALSE) +m_cc <- fit_spatial_mask(coords_demo, method = "concave", verbose = FALSE) +m_kd <- fit_spatial_mask(coords_demo, method = "kde", verbose = FALSE) +m_ra <- fit_spatial_mask(coords_demo, method = "raster", verbose = FALSE) + +(plot_mask(m_cv, coords_demo, title = "convex", + fill = "#e74c3c", border = "#922b21") + + plot_mask(m_cc, coords_demo, title = "concave")) / +(plot_mask(m_kd, coords_demo, title = "kde", + fill = "#27ae60", border = "#1e8449") + + plot_mask(m_ra, coords_demo, title = "raster (default)", + fill = "#8e44ad", border = "#6c3483")) + +plot_annotation( + title = "All four methods on a two-cluster cloud", + theme = theme(plot.title = element_text(face = "bold", size = 12)) +) +``` + +--- + +## Function reference summary + +```r +fit_spatial_mask( + coords, + + # Method + method = "raster", # "convex" | "concave" | "kde" | "raster" + + # Concave hull + concavity = 2, # lower = tighter + length_threshold = 0, + + # KDE + kde_bandwidth = NULL, # NULL = bandwidth.nrd auto + kde_threshold = 0.05, # lower = larger mask + kde_resolution = 256L, + + # Raster + raster_resolution = 256L, + raster_sigma = NULL, # coordinate units; NULL = 3% of domain + raster_threshold = 0.15, # lower = larger mask + raster_min_pts = 1L, + + # Post-processing + buffer_dist = 0, + smooth_mask = FALSE, + smooth_tolerance = NULL, + + # Misc + n_cores = 1L, + crs = NA, + plot = FALSE, + verbose = TRUE +) +``` + +## Next steps + +- For topologically complex tissues (holes, islands), see the + **[Holes, Islands, and Parameter Tuning](advanced-topology.html)** vignette. +- To estimate molecular concentration fields on a fitted mask, see the + companion package **TissueField**.