---
title: "Explore slide results with Shiny"
output:
rmarkdown::html_vignette:
toc: true
css: style.css
vignette: >
%\VignetteIndexEntry{Explore slide results with Shiny}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
resource_files:
- figures/logo.svg
- figures/logo-dark.svg
---
```{r setup, include=FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
library(bloodspottR)
```
## Start a local explorer
Install the optional **shiny** package, then run one of these commands in an
interactive R session. The first starts empty; loading the demonstration is an
explicit action. The second opens deterministic synthetic measurements and keeps
the demonstration label visible.
```{r launch, eval=FALSE}
bs_app()
bs_app(bs_example())
```
Stop the app with R's stop button or Escape/Ctrl+C in the console. The app displays
recorded measurements and linked slide images. It does not edit annotations, run inference, train a model,
or save changes to the supplied result object.
## Browse annotated slides
The viewer accepts any organ labels present in the results. The built-in viewer
example includes liver, spleen, lung and kidney, and generates only synthetic
images and measurements:
```{r viewer-example}
demo <- bs_viewer_example()
unique(demo$results$slides$organ)
demo$images[, c("image_id", "slide_id", "label")]
```
```{r viewer-launch, eval=FALSE}
bs_app(demo$results, images = demo$images, title = "My slide collection")
```
Use Previous/Next or the Slide selector to move through the filtered slides.
Choose an image/layer when a slide has several regions or a raw/overlay pair.
Drag to pan, scroll or use the zoom buttons, and choose Fit image to return to the
whole image. Click a vector annotation to see its saved properties and
measurements; the annotation selector provides a keyboard alternative. A baked
PNG overlay is viewable but its marks are not individually selectable without
corresponding vector annotations.
The `images` manifest has one row per image, with these fields:
| Field | Meaning |
| --- | --- |
| `image_id` | Unique character image identity |
| `slide_id` | Matching `slide_id` or `physical_slide_id` in the results |
| `path` | Existing PNG/JPEG preview or native whole-slide file |
| `label` | Optional display name for the region or layer |
| `annotations` | Optional saved QuPath GeoJSON or point CSV (`x`, `y`, optional `annotation_id`, plus recorded measurements) |
| `scale_x`, `scale_y` | Positive annotation-to-image coordinate scales, default 1 |
| `offset_x`, `offset_y` | Annotation-to-image offsets in displayed image pixels, default 0 |
A CSV manifest resolves file paths relative to its own directory. A data frame
uses paths relative to the R working directory. Only these explicitly supplied
files are served to the session; paths in uploaded result tables do not grant
access to server files.
Coordinates are in the displayed image's pixel frame, with x rightward and y
downward. QuPath exports use full-resolution image pixels. For a preview at
one-eighth resolution, set `scale_x = scale_y = 1/8`. For crops, supply the
corresponding offsets. The transform is `display = annotation * scale + offset`.
No registration is inferred. Points, lines and polygons (including polygon holes
and multipart geometries) are supported. Limit vector overlays to 10,000 objects
and 20 MiB per image; use review regions or rendered overlays for dense fields.
## Open native whole-slide images
Native tiled viewing uses [OpenSlide](https://openslide.org/api/python/) and the
bundled [OpenSeadragon](https://openseadragon.github.io/) browser viewer. PNG/JPEG
previews do not need Python. For native images, create an optional isolated
Python environment and install the reader:
```{bash, eval=FALSE}
python3 -m venv slide-reader
slide-reader/bin/python -m pip install openslide-python openslide-bin Pillow
```
On Windows, use `slide-reader/Scripts/python.exe`. Then point `slide_python` to
that executable (or set `BLOODSPOTTR_PYTHON`). No installation or network download
is performed by the app.
```{r native-viewer, eval=FALSE}
images <- data.frame(
image_id = "image-001", slide_id = "001",
path = "/data/slides/slide-001.svs",
annotations = "/data/annotations/slide-001.geojson",
label = "Whole slide"
)
bs_app(results, images = images, slide_python = "/path/to/slide-reader/bin/python")
```
Support depends on the installed OpenSlide reader and the actual file, not its
extension alone. Native Aperio SVS and preview PNG paths are exercised during
local qualification. A QuPath `.qpproj`/`.qpdata` or TissueGnostics `.aqproj` is a
project container, not an OpenSlide image: export the image and QuPath GeoJSON
first. This viewer does not write back to the native project.
## Customize and watch saved results
`title`, `group_by` and `group_label` adapt the interface to other tissue panels
or a different metadata grouping. Labels and organ choices are not fixed:
```{r custom-viewer, eval=FALSE}
results$slides$sample_type <- c("Brain", "Skin", "Heart", "Lung",
"Kidney", "Muscle", "Tumor", "Other")
bs_app(results, images = images, title = "My experiment",
group_by = "sample_type", group_label = "Sample type")
```
Pass a saved result file path to enable measurement refresh, and use Refresh saved
data or Watch saved files. Watch mode checks every two seconds; saving a new
annotation export refreshes its properties without rerunning analysis. A failed
result read retains the last successfully loaded measurements and displays an
error. The original files remain unchanged. Watch mode follows the supplied
result path, not an active QuPath editor's unsaved state. Selecting a browser
upload or demonstration stops following that original result file.
```{r saved-results, eval=FALSE}
bs_app("/data/results/results.json", images = "/data/results/images.csv")
```
## Prepare an upload
A CSV needs the following columns. `physical_slide_id` is accepted in place of
`slide_id`; if both are supplied, they must agree. Optional `organ` and
`qc_stratum` (or `stratum`) columns populate the filters.
```{r minimal-table}
slides <- data.frame(
slide_id = c("001", "002", "003"),
organ = c("Liver", "Liver", "Spleen"),
tissue_area_mm2 = c(1, 9, 2),
red_area_mm2 = c(0.1, 0, NA_real_),
candidate_spots = c(10, 0, NA_real_)
)
slides
```
Save this as CSV and select it with **Import slide results**. Files must be at
most 20 MiB. Counts are checked as decimal text before conversion, so an integer
outside the exact numeric range is rejected rather than rounded. Blank numeric
cells and `NA` mean missing. Numeric-looking IDs retain leading zeros; the literal
ID `NA` remains an identifier. JSON should use the package's canonical export,
with a `slides` table and schema version `"1.0"`.
```{r upload-file}
file <- tempfile(fileext = ".csv")
utils::write.csv(slides, file, row.names = FALSE, na = "")
readLines(file)
unlink(file)
```
An invalid upload displays the reason and retains the previous results. A
successful upload resets filters. CSV downloads contain the filtered rows,
including rows with missing measurements; formula-like text is escaped for
spreadsheet viewing. Use `bs_export_results()` and its canonical JSON when exact
metadata round-tripping is required.
## Interpret the four cards
The cards show selected slide count, summed tissue area, pooled red-positive
percentage and pooled candidate density. Rates divide summed numerators by
summed support. They do not average percentages across slides.
For the two liver slides above, the red-positive percentage is
`100 * (0.1 + 0) / (1 + 9) = 1%`, and the candidate density is
`(10 + 0) / (1 + 9) = 1/mm²`. The unweighted mean of their percentages would be 5%,
which answers a different question.
```{r weighted-example, fig.width=6, fig.height=3.2, fig.alt="Two bars compare the correct pooled liver red-positive percentage of one percent with the unweighted mean of five percent."}
old <- par(mar = c(5, 4, 1, 1))
barplot(c("Pooled by tissue area" = 1, "Unweighted slide mean" = 5),
col = c("#185FA5", "#A3ADB5"), ylab = "Red-positive tissue (%)",
ylim = c(0, 6), las = 1, border = NA)
par(old)
```
Selecting all three rows makes both pooled rates unavailable because the spleen
measurements are unknown. Missing is not zero. A slide with zero tissue support
also has undefined rates. The completeness line explains how many selected rows
can appear in the plot; the cards still account for every selected row.
## Navigate the results
- **Slide viewer:** linked image layers, zoom/pan, recorded slide measurements and selectable annotations.
- **Overview:** one point per slide with finite measurements and positive tissue
support. Tissue has both a color and a symbol. The exact values are in Slides.
- **Slides:** all selected records, including missing measurements. Wide tables
scroll within their panel on small screens.
- **Compare:** a supplied comparison table, filtered by physical-slide identity.
Build it with `bs_compare()` after declaring common support. The app does not
infer a second run or recompute a comparison.
- **Methods & provenance:** the pooled arithmetic and supplied source metadata.
Missing provenance remains explicitly unknown.
Use Tissue, Quality stratum and a literal Slide ID substring to narrow the view.
An empty result is a filter outcome, not proof that no signal was measured.
Keyboard users can start with **Skip to results**, then navigate the tabs and
controls. On a narrow screen the controls stack above the results.
## Scientific and hosting boundaries
A candidate event is not necessarily one erythrocyte. QC labels are supplied
review decisions, not automatic diagnoses; low signal does not establish a
negative control. Filtering changes descriptive support and must be recorded if
used for a report. Biological replication, animal-level inference and validation
require a separate study design.
The app is intended for a local R session. If you deploy it to a server, uploads
are sent to that server; “local session” is not a confidentiality guarantee for a
hosted deployment. Authentication, access control and retention policies belong
to the hosting environment. Uploaded CSV/JSON is parsed as data, not executed.
```{r viewer-cleanup, include=FALSE}
unlink(demo$directory, recursive = TRUE)
```