####
# Main Shiny App ####
####
#' annotateSpatialData
#'
#' A mini shiny app to for annotating spatial points
#'
#' @param object a VoltRon object
#' @param label the name of the new metadata column (default: annotation) annotating spatial points by selected polygons
#' @param assay assay name (exp: Assay1) or assay class (exp: Visium, Xenium), see \link{SampleMetadata}.
#' if NULL, the default assay will be used, see \link{vrMainAssay}.
#' @param annotation_assay name of the annotation assay ()
#' @param use.image.only if TRUE, use only the image
#' @param shiny.options a list of shiny options (launch.browser, host, port etc.) passed \code{options} arguement of \link{shinyApp}. For more information, see \link{runApp}
#' @param image_name the name/key of the image
#' @param channel the name of the main channel
#' @param ... additional parameters passed to \link{vrSpatialPlot}.
#'
#' @import shiny
#' @importFrom shinyjs useShinyjs show hide
#' @importFrom stats median
#' @importFrom sp point.in.polygon
#' @import ggplot2
#'
#' @export
#'
#' @examples
#' \dontrun{
#' # Annotate based on images
#' visium_data <- annotateSpatialData(visium_data, use.image.only = TRUE)
#'
#' # Annotate based on spatial plot
#' xenium_data <- annotateSpatialData(xenium_data, group.by = "clusters")
#' }
annotateSpatialData <- function(object, label = "annotation", assay = NULL, annotation_assay = "ROIAnnotation", use.image.only = FALSE,
shiny.options = list(launch.browser = getOption("shiny.launch.browser", interactive())),
image_name = NULL, channel = NULL, ...) {
if(!inherits(object, "VoltRon"))
stop("Please provide a VoltRon object!")
## Importing images ####
# get assay names, and always get a single assay
assay_names <- vrAssayNames(object, assay = assay)
if(length(assay_names) > 0)
assay <- assay_names[1]
# get image name and channel
if(is.null(image_name)){
image_name <- vrMainSpatial(object[[assay]])
}
# get image
img <- vrImages(object[[assay]], name = image_name, channel = channel, as.raster = TRUE)
if(!inherits(img, "Image_Array")){
if(!requireNamespace("ImageArray")){
message("Please install ImageArray package to speed up visualization")
img <- magick::image_read(img)
} else{
img <- ImageArray::createImageArray(img)
}
}
if(!use.image.only){
# get spatial plot
g_spatial <- vrSpatialPlot(object, assay = assay, spatial = image_name, channel = channel, scale.image = FALSE, ...)
g_spatial <- g_spatial$layers[[2]]
}
# get image info
max.pixel.size <- 1200
imginfo <- getImageInfo(img)
## Get previous annotation ####
# set label names
sample_metadata <- SampleMetadata(object)
metadata <- Metadata(object, assay = sample_metadata[assay, "Assay"])
coords <- vrCoordinates(object, assay = assay)
if(label %in% colnames(metadata)){
unique_names <- make.unique(c(colnames(metadata)[grepl(paste0("^", label), colnames(metadata))], label))
label <- unique_names[length(unique_names)]
}
# get segmentations (if exists) from the same layer
if(!is.null(annotation_assay)){
layer_metadata <- sample_metadata[sample_metadata$Layer == sample_metadata[assay, "Layer"] & sample_metadata$Sample == sample_metadata[assay, "Sample"],]
if(annotation_assay %in% layer_metadata$Assay){
# get segments
segments <- vrSegments(object, assay = annotation_assay)
segments <- lapply(segments, function(seg) seg[,colnames(seg)[colnames(seg) != "id"]])
segment_names <- gsub("_Assay[0-9]+$", "", names(segments))
# remove the latest annotation
all_assay_names <- vrAssayNames(object, assay = "all")
object <- subsetVoltRon(object, assays = all_assay_names[!all_assay_names %in% rownames(layer_metadata)[layer_metadata$Assay == annotation_assay]])
} else {
segments <- list()
segment_names <- c()
}
}
## UI and Server ####
# Define UI for the application
ui <- fluidPage(
sidebarLayout(position = "left",
sidebarPanel(
# margin settings
tags$style(make_css(list('.well', 'margin', '7%'))),
# # specific settings for dealing with simultaneous click and brush events
# # https://jokergoo.github.io/2021/02/20/differentiate-brush-and-click-event-in-shiny/
tags$script(HTML("
$('#plot').mousedown(function(e) {
var parentOffset = $(this).offset();
var relX = e.pageX - parentOffset.left;
var relY = e.pageY - parentOffset.top;
Shiny.setInputValue('x1', relX);
Shiny.setInputValue('y1', relY);
}).mouseup(function(e) {
var parentOffset = $(this).offset();
var relX = e.pageX - parentOffset.left;
var relY = e.pageY - parentOffset.top;
Shiny.setInputValue('x2', relX);
Shiny.setInputValue('y2', relY);
Shiny.setInputValue('action', Math.random());
});
")),
# Interface
fluidRow(
column(12,h4("Spatial Annotation")),
column(12,shiny::actionButton("reset_btn", "Reset Points ")),
column(12,shiny::actionButton("rmvlast_btn", "Remove Last Point")),
column(12,shiny::actionButton("addregion_btn", "Add Region ")),
),
br(),
fluidRow(
column(6,shiny::selectInput("region_type", label = "Region Type", choices = c("Polygon", "Circle"), selected = "Polygon")),
column(6,shiny::sliderInput("alpha", "Transparency", min = 0, max = 1, value = 0.2)),
),
fluidRow(
column(6,shiny::sliderInput("label_size", label = "Label Size", min = 1, max = 5, step = 0.5, value = 4)),
),
# instructions
h4("How to use"),
p(style="font-size: 12px;", strong("Single-L-click"), " to select polygon or circle points"),
p(style="font-size: 12px;", strong("Add Region"), " to set points as a new region"),
p(style="font-size: 12px;", strong("Circles"), " require only 2 points"),
p(style="font-size: 12px;", strong("Polygons"), " require at least 4 points"),
br(),
# Subsets
fluidRow(
column(12,h4("Selected Regions")),
br(),
column(12,shiny::actionButton("done", "Done")),
br()
),
# Subsets
fluidRow(
br(),
uiOutput("textbox_ui"),
br()
),
width = 4
),
mainPanel(
shinyjs::useShinyjs(),
plotOutput("image_plot",
height = "1000px",
click = "plot_click",
dblclick = "plot_dblclick",
brush = brushOpts(
id = "plot_brush", fill = "green",
resetOnNew = TRUE
)),
width = 8
)
)
)
# Define server logic required to create, add, and remove textboxes
server <- function(input, output, session) {
# Reactive values ####
selected_corners_list <- reactiveVal(segments)
selected_corners <- reactiveVal(data.frame(x = numeric(0), y = numeric(0)))
ranges <- reactiveValues(x = c(0, imginfo$width), y = c(0, imginfo$height))
# Zoom Event ####
manageImageBrush(img, ranges, max.pixel.size, input, output, session)
# Corner Events ####
observeEvent(input$reset_btn, {
selected_corners(data.frame(x = numeric(0), y = numeric(0)))
})
observeEvent(input$rmvlast_btn, {
selected_corners(selected_corners()[-nrow(selected_corners()),])
})
manageSelectedCorners(selected_corners, img, ranges, max.pixel.size, input, output, session)
# Region Events ####
n <- length(segments)
textboxes <- reactiveVal(if (n > 0) seq_len(n) else numeric(0))
if (n > 0) {
segment_names <- as.list(segment_names)
names(segment_names) <- paste0("region", seq_len(n))
textbox_values <- do.call("reactiveValues", segment_names)
} else {
textbox_values <- reactiveValues()
}
# Dynamically generate UI for textboxes and remove buttons
output$textbox_ui <- renderUI({
lapply(rev(textboxes()), function(i) {
column(12,
textInputwithButton(textinputId = paste0("region", i), label = paste0("Region ", i),
buttoninputId = paste0("remove", i), value = isolate(textbox_values[[paste0("region", i)]]),
onclick = sprintf('Shiny.setInputValue("remove", %d)', i))
)
})
})
### update Region text ####
observe({
lapply(textboxes(), function(i) {
observeEvent(input[[paste0("region", i)]], {
textbox_values[[paste0("region", i)]] <- isolate(input[[paste0("region", i)]])
}, ignoreNULL = FALSE)
})
})
### remove a Region ####
# Observe event to remove textbox when the button is clicked
observeEvent(input$remove, {
# remove one point
selected_corners_list(selected_corners_list()[!(textboxes() == as.numeric(isolate(input$remove)))])
# Update the reactive value to remove the textbox
textboxes(setdiff(textboxes(), as.numeric(isolate(input$remove))))
# Remove the value from textbox_values
textbox_values[[paste0("region", as.numeric(input$remove))]] <- NULL
}, ignoreInit = TRUE)
### add a Region ####
# Observe event to add a new textbox
observeEvent(input$addregion_btn, {
# Polygon selection
if(isolate(input$region_type == "Polygon")){
if(nrow(selected_corners()) > 3){
# add to region list
selected_corners_list(c(selected_corners_list(), list(selected_corners())))
# remove selected points
selected_corners(data.frame(x = numeric(0), y = numeric(0)))
# add buttons
new_id <- if (length(textboxes()) == 0) 1 else max(textboxes()) + 1
textboxes(c(textboxes(), new_id))
textbox_values[[paste0("region", new_id)]] <- ""
} else {
showNotification("You must selected at least 4 points for each polygon!")
}
}
# Circle selection
if(isolate(input$region_type == "Circle")){
if(nrow(selected_corners()) == 2){
# add to region list
circle <- makeCircleData(selected_corners())
selected_corners_list(c(selected_corners_list(), list(circle)))
# remove selected points
selected_corners(data.frame(x = numeric(0), y = numeric(0)))
# add buttons
new_id <- if (length(textboxes()) == 0) 1 else max(textboxes()) + 1
textboxes(c(textboxes(), new_id))
textbox_values[[paste0("region", new_id)]] <- ""
} else {
showNotification("You must selected only 2 points for each circle!")
}
}
})
# Image output ####
output$image_plot <- renderPlot({
## get image ####
zoom_info <- FromBoxToCrop(cbind(ranges$x, ranges$y), imageinfo = imginfo)
img <- cropImage(img, zoom_info)
g <- plotImage(img, max.pixel.size = max.pixel.size) + labs(title = "")
if(!use.image.only){
g_spatial_clone <- cloneLayer(g_spatial)
g <- g + transformSpatialLayer(g_spatial_clone, img, ranges, max.pixel.size)
}
# visualize currently selected corners ####
transformed_corners <- transformSelectedCorners(selected_corners(), img, ranges, max.pixel.size)
if(nrow(transformed_corners) > 0){
if(isolate(input$region_type == "Polygon")){
g <- g +
ggplot2::geom_polygon(aes(x = x, y = y), data = transformed_corners, alpha = input$alpha, color = "red")
} else {
circle <- makeCircleData(transformed_corners)
g <- g +
ggforce::geom_ellipse(aes(x0 = as.numeric(x), y0 = as.numeric(y), a = as.numeric(rx), b = as.numeric(ry), angle = 0),
data = circle, alpha = input$alpha, color = "red", fill = "red")
}
g <- g +
ggplot2::geom_point(aes(x = x, y = y), data = transformed_corners, color = "red", shape = 16)
}
# visualize already selected regions ####
transformed_corners_list <- lapply(selected_corners_list(), transformSelectedCorners, img, ranges, max.pixel.size)
if(length(transformed_corners_list) > 0){
for (i in seq_len(length(transformed_corners_list))){
cur_corners <- transformed_corners_list[[i]]
# visualize regions
if(ncol(cur_corners) == 2){
g <- g +
ggplot2::geom_polygon(aes(x = x, y = y, group = "region"), data = cur_corners, alpha = input$alpha, color = "red")
} else {
g <- g +
ggforce::geom_ellipse(aes(x0 = as.numeric(x), y0 = as.numeric(y), a = as.numeric(rx), b = as.numeric(ry), angle = 0),
data = cur_corners, alpha = input$alpha, color = "red", fill = "red")
}
# visualize labels
if(ncol(cur_corners) == 2){
cur_corners <- data.frame(x = mean(cur_corners[,1]), y = max(cur_corners[,2]), region = paste("Region ", isolate(textboxes()[i])))
} else {
cur_corners <- data.frame(x = cur_corners[,1], y = cur_corners[,2] + cur_corners[,3], region = paste("Region ", isolate(textboxes()[i])))
}
g <- g +
ggrepel::geom_label_repel(mapping = aes(x = x, y = y, label = region), data = cur_corners,
size = input$label_size, direction = "y", nudge_y = 6, box.padding = 0, label.padding = input$label_size*0.1, seed = 1, color = "red")
}
}
# return graph
g
})
# Return values for the shiny app ####
observe({
if(length(selected_corners_list()) > 0){
shinyjs::show(id = "done")
} else {
shinyjs::hide(id = "done")
}
})
observeEvent(input$done, {
# selected list
selected_polygon_list <- selected_corners_list()
# collect labels
selected_label_list <- vapply(seq_len(length(selected_polygon_list)), function(i) input[[paste0("region",i)]], character(1))
if(length(selected_corners_list()) == 0){
showNotification("You have not annotated the data yet!")
} else if(any(selected_label_list == "")) {
showNotification("Some regions have blank annotations (empty labels!)")
} else {
### annotate spatial points ####
if(inherits(metadata, "data.table")){
spatialpoints <- as.vector(metadata$id)
} else {
spatialpoints <- rownames(metadata)
}
new_label <- rep("undefined", length(spatialpoints))
names(new_label) <- spatialpoints
result_list <- list()
for(i in seq_len(length(selected_polygon_list))){
cur_poly <- selected_polygon_list[[i]]
if(ncol(cur_poly) > 2){
in.list <- point.in.circle(coords[,1], coords[,2], cur_poly[,1], cur_poly[,2], cur_poly[,3])
} else {
in.list <- sp::point.in.polygon(coords[,1], coords[,2], cur_poly[,1], cur_poly[,2])
}
new_label[rownames(coords)[!!in.list]] <- selected_label_list[i]
}
# place annotation to metadata
metadata[[label]] <- new_label
Metadata(object, assay = sample_metadata[assay, "Assay"]) <- metadata
# add polygons to a new assay ####
segments <- list()
for(i in seq_len(length(selected_label_list))){
segments[[selected_label_list[i]]] <- data.frame(id = i, selected_polygon_list[[i]])
}
coords <- t(vapply(segments, function(seg){
apply(seg[,c("x", "y")], 2, mean)
}, numeric(2)))
new_assay <- formAssay(coords = coords,
segments = segments,
type = "ROI",
image = vrImages(object, assay = assay),
main_image = vrMainImage(object[[assay]]),
name = assay)
metadata <- data.frame(check.rows = FALSE, row.names = rownames(coords), selected_label_list)
colnames(metadata) <- label
object <- addAssayVoltRon(object,
assay = new_assay,
metadata = metadata,
assay_name = annotation_assay,
sample = sample_metadata[assay, "Sample"],
layer = sample_metadata[assay, "Layer"])
# stop app and return
stopApp(object)
}
})
}
# Run App ####
shiny.options <- configure_shiny_options(shiny.options)
shiny::runApp(
shiny::shinyApp(ui, server, options = list(host = shiny.options[["host"]], port = shiny.options[["port"]], launch.browser = shiny.options[["launch.browser"]]),
onStart = function() {
onStop(function() {
})
})
)
}
####
# Annotation Utilities ####
####
manageImageBrush <- function(image, ranges, max.pixel.size, input, output, session){
imginfo <- getImageInfo(image)
observeEvent(input$plot_dblclick, {
brush <- isolate(input$plot_brush)
if (!is.null(brush)) {
# get brush
brush_mat <- data.frame(x = c(brush$xmin, brush$xmax),
y = c(brush$ymin, brush$ymax))
# if width is large, then correct the brush event for the downsize (scaling) effect
limits <- data.frame(x = ranges$x, y = ranges$y)
width <- limits[2,1]-limits[1,1]
height <- limits[2,2]-limits[1,2]
if(max(height,width) > max.pixel.size){
if(inherits(image, "Image_Array")){
n.series <- ImageArray::len(image)
cur_width <- width
cur_height <- height
for(ii in 2:n.series){
cur_width <- width/(2^(ii-1))
cur_height <- height/(2^(ii-1))
if(max(cur_height, cur_width) <= max.pixel.size){
break
}
}
brush_mat <- brush_mat*width/ceiling(cur_width)
} else {
brush_mat <- brush_mat*width/max.pixel.size
}
}
# correct brush for the zoom effect
brush_mat[,1] <- brush_mat[,1] + limits[1,1]
brush_mat[,2] <- brush_mat[,2] + limits[1,2]
brush_mat[1,1] <- floor(brush_mat[1,1])
brush_mat[1,2] <- floor(brush_mat[1,2])
brush_mat[2,1] <- ceiling(brush_mat[2,1])
brush_mat[2,2] <- ceiling(brush_mat[2,2])
# update ranges
ranges$x <- brush_mat[,1]
ranges$y <- brush_mat[,2]
} else {
ranges$x <- c(0, imginfo$width)
ranges$y <- c(0, imginfo$height)
}
})
}
manageSelectedCorners <- function(selected_corners, image, ranges, max.pixel.size, input, output, session){
observeEvent(input$plot_click, {
brush <- isolate(input$plot_brush)
if(is.null(brush)) {
# if a circle, dont allow more than two points
if(isolate(input$region_type == "Circle")){
if(nrow(selected_corners()) == 2){
selected_corners(data.frame(x = numeric(0), y = numeric(0)))
}
}
# get points
click <- input$plot_click
pts <- c(click$x, click$y)
# correct for zoom effect
limits <- data.frame(x = ranges$x, y = ranges$y)
width <- limits[2,1]-limits[1,1]
height <- limits[2,2]-limits[1,2]
if(max(height,width) > max.pixel.size){
if(inherits(image, c("Image_Array"))){
n.series <- ImageArray::len(image)
cur_width <- width
cur_height <- height
for(ii in 2:n.series){
cur_width <- width/(2^(ii-1))
cur_height <- height/(2^(ii-1))
if(max(cur_height, cur_width) <= max.pixel.size){
break
}
}
pts <- pts*width/ceiling(cur_width)
} else {
pts <- pts*width/max.pixel.size
}
}
# correct for offset effect
pts[1] <- pts[1] + limits[1,1]
pts[2] <- pts[2] + limits[1,2]
# Append new point to the data frame
new_point <- data.frame(x = pts[1], y = pts[2])
# add new point
selected_corners(rbind(selected_corners(), new_point))
}
})
}
transformSelectedCorners <- function(selected_corners, image, ranges, max.pixel.size){
# if empty, return
if(nrow(selected_corners) == 0){
return(selected_corners)
}
selected_corners <- as.matrix(selected_corners)
# circle settings
circle <- FALSE
if(ncol(selected_corners) > 2)
circle <- TRUE
# get circle radius
if(circle){
selected_radius <- selected_corners[,3:4, drop = FALSE]
selected_corners <- selected_corners[,seq_len(2), drop = FALSE]
}
# get image info
imginfo <- getImageInfo(image)
# correct for offset effect
limits <- cbind(ranges$x, ranges$y)
selected_corners <- selected_corners - matrix(rep(limits[1,], nrow(selected_corners)), nrow = nrow(selected_corners), byrow = T)
# correct for zoom effect
width <- limits[2,1]-limits[1,1]
height <- limits[2,2]-limits[1,2]
if(max(height,width) > max.pixel.size){
if(inherits(image, "Image_Array")){
n.series <- ImageArray::len(image)
cur_width <- width
cur_height <- height
for(ii in 2:n.series){
cur_width <- width/(2^(ii-1))
cur_height <- height/(2^(ii-1))
if(max(cur_height, cur_width) <= max.pixel.size){
break
}
}
selected_corners <- selected_corners*ceiling(cur_width)/width
if(circle){
selected_radius <- selected_radius*ceiling(cur_width)/width
}
} else {
selected_corners <- selected_corners*max.pixel.size/width
if(circle){
selected_radius <- selected_radius*max.pixel.size/width
}
}
}
# make data frame
if(circle){
selected_corners <- as.data.frame(cbind(selected_corners,selected_radius))
colnames(selected_corners) <- c("x", "y", "rx", "ry")
} else {
selected_corners <- as.data.frame(selected_corners)
colnames(selected_corners) <- c("x", "y")
}
# return
selected_corners
}
#' @importFrom stats na.omit
transformSpatialLayer <- function(g_spatial, image, ranges, max.pixel.size){
# correct for zoom
ind <- (g_spatial$data$x > ranges$x[1] & g_spatial$data$x < ranges$x[2]) & (g_spatial$data$y > ranges$y[1] & g_spatial$data$y < ranges$y[2])
g_spatial$data <- g_spatial$data[ind,]
# is.na
g_spatial$data <- stats::na.omit(g_spatial$data)
# correct for zoom effect
limits <- data.frame(x = ranges$x, y = ranges$y)
width <- limits[2,1]-limits[1,1]
height <- limits[2,2]-limits[1,2]
if(max(height,width) > max.pixel.size){
if(inherits(image, "Image_Array")){
n.series <- ImageArray::len(image)
cur_width <- width
cur_height <- height
for(ii in 2:n.series){
cur_width <- width/(2^(ii-1))
cur_height <- height/(2^(ii-1))
if(max(cur_height, cur_width) <= max.pixel.size){
break
}
}
g_spatial$data$x <- g_spatial$data$x*ceiling(cur_width)/width
g_spatial$data$y <- g_spatial$data$y*ceiling(cur_width)/width
} else {
g_spatial$data$x <- g_spatial$data$x*max.pixel.size/width
g_spatial$data$y <- g_spatial$data$y*max.pixel.size/width
}
}
# correct for offset effect
# g_spatial$data$x <- g_spatial$data$x - min(g_spatial$data$x)
# g_spatial$data$y <- g_spatial$data$y - min(g_spatial$data$y)
g_spatial$data$x <- g_spatial$data$x - limits[1,1]
g_spatial$data$y <- g_spatial$data$y - limits[1,2]
# return
g_spatial
}
####
# Shiny Utilities ####
####
#' internal Text input with button
#'
#' Reproduced since it is not exported in the Shiny namespace.
#'
#' @importFrom shiny tags validateCssUnit
#'
#' @keywords internal
textInputwithButton <- function (textinputId, label, buttoninputId, value = "", width = NULL, placeholder = NULL, ...)
{
textvalue <- restoreInput(id = textinputId, default = value)
buttonvalue <- restoreInput(id = buttoninputId, default = NULL)
div(class = "form-group shiny-input-container",
style = css(width = shiny::validateCssUnit(width), display = "inline-block"),
shinyInputLabel(textinputId, label),
shiny::tags$input(id = textinputId,
style = css(width = "80%", float = "left"),
type = "text", class = "shiny-input-text form-control",
value = textvalue, placeholder = placeholder),
shiny::tags$button(id = buttoninputId,
style = css(width = "20%", float = "left"),
type = "button", class = "btn btn-default action-button",
`data-val` = buttonvalue, disabled = NULL, list(shiny::icon("trash")), ...)
)
}
#' Shiny's internal \code{shinyInputLabel} function
#'
#' Reproduced since it is not exported in the Shiny namespace.
#'
#' @importFrom shiny tags
#'
#' @keywords internal
shinyInputLabel <- function(inputId, label=NULL) {
shiny::tags$label(label,
class = "control-label",
class = if (is.null(label)) "shiny-label-null",
`for` = inputId
)
}
####
# Auxiliary ####
####
#' makeCircleData
#'
#' creates circle ROI from selected corners
#'
#' @keywords internal
makeCircleData <- function(selected_corners) {
radius <- sqrt((selected_corners[1,1] - selected_corners[2,1])^2 + (selected_corners[1,2] - selected_corners[2,2])^2)
data.frame(x = selected_corners[1,1], y = selected_corners[1,2], rx = radius, ry = radius)
}
#' point.in.circle
#'
#' checks if points are in the defined circle ROI with a (x,y) center and radius
#'
#' @keywords internal
point.in.circle <- function(x,y,circle.x, circle.y, radius) {
x <- x - circle.x
y <- y - circle.y
distall <- sqrt((x - circle.x)^2 + (y - circle.y)^2)
return(distall < radius)
}