library(segmantR)
#> segmantR v0.2.0 -- Cell Segmentation with Human-in-the-Loop
#> Optional backends are checked when used; see sg_interchange_capabilities().This vignette walks through the reproducible, exchangeable side of segmantR: versioned segmentation protocols, the neutral interchange format, training datasets and model bundles, and the QuPath/StarDist adapter. Everything executed here runs in R alone; steps that need Python or QuPath are shown but not run.
Protocols are declarative JSON definitions with whitelisted parameters.
protocols <- sg_protocol_list()
protocols[, c("id", "version", "status", "backend")]
#> # A tibble: 10 × 4
#> id version status backend
#> <chr> <chr> <chr> <chr>
#> 1 cellpose.2d.v1 1.0.0 optional python-cellpose
#> 2 mesmer.2d.v1 1.0.0 optional python-deepcell
#> 3 postprocess.label-cleanup.v1 1.0.0 core segmantR-core
#> 4 propagate.voronoi.v1 1.0.0 core segmantR-core
#> 5 stardist.2d.v1 1.0.0 optional python-stardist
#> 6 threshold.adaptive.v1 1.0.0 core segmantR-core
#> 7 threshold.otsu.v1 1.0.0 core segmantR-core
#> 8 threshold.triangle.v1 1.0.0 core segmantR-core
#> 9 watershed.distance.v1 1.0.0 core segmantR-core
#> 10 watershed.h_minima.v1 1.0.0 core segmantR-coreEach parameter has a type, bounds, unit and calibration requirement:
spec <- sg_protocol_schema("threshold.otsu.v1")
spec[!spec$name %in% c("band_a_nm", "band_b_nm", "band_min_nm",
"band_max_nm"),
c("name", "type", "unit", "maps_to")]
#> # A tibble: 9 × 4
#> name type unit maps_to
#> <chr> <chr> <chr> <chr>
#> 1 band_operation string none <channel selection>
#> 2 channel integer index <channel selection>
#> 3 channel_name string name <channel selection>
#> 4 fill_holes boolean none morphology.fill_holes
#> 5 max_area integer px2 max_area
#> 6 min_area integer px2 min_area
#> 7 open_size integer px morphology.open
#> 8 wavelength_nm number nm <channel selection>
#> 9 wavelength_tolerance_nm number nm <channel selection>Running a protocol delegates to the direct function with a recorded argument mapping. The run envelope makes declarative and direct runs comparable:
img <- sg_example_image("fluorescence_nuclei")
run <- sg_protocol_run(img, "threshold.otsu.v1", min_area = 5L,
output = "run")
#> ℹ Threshold applied using "otsu" method.
#> ✔ Segmented 8 objects via threshold (otsu).
run
#> <sg_run> "threshold.otsu.v1" 1.0.0: succeeded
#> Objects: 8 (instance, draft)
#> Revision:
#> sha256:fc0b0829ae7ff1d61736ab125bcdcaff87900097cbd417fcdd48c60b2fe9a0d1
str(run$record$delegate$arguments)
#> List of 6
#> $ channel : int 1
#> $ image : chr "<input:image>"
#> $ max_area : int 5000
#> $ method : chr "otsu"
#> $ min_area : int 5
#> $ morphology:List of 2
#> ..$ fill_holes: logi TRUE
#> ..$ open : int 5
direct <- sg_segment_threshold(img, method = "otsu", min_area = 5L)
#> ℹ Threshold applied using "otsu" method.
#> ✔ Segmented 8 objects via threshold (otsu).
identical(direct$labels, run$mask$labels)
#> [1] TRUEInvalid input is refused with classified conditions instead of being guessed:
res <- sg_protocol_validate("threshold.otsu.v1", img, min_area = -1L,
error = FALSE)
res$error$code
#> [1] "PARAMETER_OUT_OF_RANGE"
try(sg_protocol_run(img, "mesmer.2d.v1"))
#> Error in .sg_check_inputs(p, inputs, params) :
#> "mesmer.2d.v1" needs at least 2 channels; the image has 1.Post-processing is a protocol too:
clean <- sg_protocol_run(run$mask, "postprocess.label-cleanup.v1",
min_area = 40L, fill_holes = TRUE)
clean$n_cells
#> [1] 8Bands are selected by index, name, wavelength or a registered operation; value semantics are declared, never inferred.
cube <- array(runif(32 * 32 * 3), dim = c(32, 32, 3))
hsi <- new_sg_image(cube, channels = c("b460", "b550", "b650"),
bands = data.frame(wavelength_nm = c(460, 550, 650)),
value_semantics = "reflectance")
nd <- sg_select_channel(hsi, band_operation = "normalized_difference",
band_a_nm = 650, band_b_nm = 550)
nd$provenance$band_selection[c("operation", "wavelength_nm")]
#> $operation
#> [1] "normalized_difference"
#>
#> $wavelength_nm
#> [1] 650 550Optional DNN protocols check their backend when they run and never fall back to another method:
A bundle contains an integer label TIFF, exact pixel-edge GeoJSON with stable object ids, the legend, long-format measurements, a manifest and a SHA-256 inventory.
dest <- file.path(tempdir(), "segmantR-bundle")
unlink(dest, recursive = TRUE)
bundle <- sg_export_interchange(run, dest, image = img)
list.files(dest)
#> [1] "integrity.json" "manifest.json" "mask.legend.json" "mask.tif"
#> [5] "measurements.csv" "objects.geojson"
bundle$bundle_digest
#> [1] "sha256:2ecd70b54f713a3eb548cba32549be4a1889a62168312734bc5528f0b1b4759a"Imports verify integrity, schema, dtype, orientation, plane, legend and the agreement between GeoJSON and TIFF, and return a staged mask:
imported <- sg_import_interchange(dest,
expected_digest = bundle$bundle_digest)
imported$checks[, c("check", "status")]
#> # A tibble: 15 × 2
#> check status
#> <chr> <chr>
#> 1 format ok
#> 2 integrity ok
#> 3 expected_digest ok
#> 4 manifest ok
#> 5 assets ok
#> 6 plane ok
#> 7 legend_sidecar ok
#> 8 mask_tiff ok
#> 9 mask_content_digest ok
#> 10 geojson_features ok
#> 11 geojson_vs_tiff ok
#> 12 legend ok
#> 13 mask_contract ok
#> 14 mask_revision ok
#> 15 measurements ok
sg_mask_status(imported$mask)$status
#> [1] "staged"Reviewed masks are protected:
reviewed <- sg_review_mask(imported$mask,
expected_revision = sg_mask_revision(imported$mask))
candidate <- clean
try(sg_replace_mask(reviewed, candidate))
#> <sg_mask>: 64 x 64, 8 cells
#> Status: reviewed
replaced <- sg_replace_mask(reviewed, candidate,
expected_revision = sg_mask_revision(reviewed),
overwrite = TRUE)
sg_mask_status(replaced)$status
#> [1] "reviewed"Dataset manifests tile image/mask pairs and split them by origin so tiles of one subject never appear in two splits.
mask <- sg_cleanup_labels(sg_example_mask("fluorescence_nuclei"),
disconnected = "split")
pairs <- lapply(c("A", "B", "C"), function(s) {
list(image = img, mask = mask, subject_id = s)
})
ts <- sg_prepare_training_data(pairs, tile_size = 32L,
split = c(train = 0.34, validation = 0.33,
test = 0.33))
ts
#> <sg_training_set> dataset-59bd8753d8c7688e: 12 tiles (instance)
#> Splits: test=4, train=4, validation=4
sg_validate_training_manifest(ts$manifest, mask_type = "instance")$ok
#> [1] TRUEA foreground/background mask is not accepted as instance training data:
binary <- new_sg_mask(ifelse(mask$labels > 0L, 1L, 0L))
try(sg_dataset_manifest(list(list(image = img, mask = binary,
subject_id = "A")), tile_size = 32L))
#> Error in .sg_assert_training_mask(msk, mask_type, i) :
#> Pair 1: the mask looks like a foreground/background mask, not an
#> instance mask.
#> ✖ 1 instance label(s) consist of several disconnected parts (e.g. 1)
#> ℹ Instance training needs a unique positive id per object and background 0
#> (split objects with sg_cleanup_labels(disconnected = 'split') after checking
#> them).Training needs the Python stardist and
tensorflow modules; all data checks run before Python is
touched:
model <- sg_train_stardist(ts, n_epochs = 50L, save_path = "models",
model_card = list(
data_domain = "synthetic fluorescence nuclei",
limitations = "demonstration only",
license = list(id = "CC-BY-4.0")))
sg_package_model(model, "nuclei.segmantR")
loaded <- sg_load_model("nuclei.segmantR") # checksums verified
pred <- sg_predict_model(loaded, img, output = "run")
sg_mask_status(pred$mask)$status # "staged"sg_stardist_parameter_map() shows how every protocol
parameter maps to the QuPath StarDist extension, including what cannot
be honoured:
map <- sg_stardist_parameter_map("stardist.2d.v1", "qupath",
normalize_scope = "tile",
normalize_low = 1, normalize_high = 99)
map[, c("parameter", "target_option", "status")]
#> # A tibble: 16 × 3
#> parameter target_option status
#> <chr> <chr> <chr>
#> 1 channel channels mapped
#> 2 channel_name channels not_applicable
#> 3 wavelength_nm channels not_applicable
#> 4 wavelength_tolerance_nm <export> runner
#> 5 model StarDist2D.builder(modelPath) not_applicable
#> 6 prob_thresh threshold mapped
#> 7 nms_thresh <NA> approximated
#> 8 normalize_low normalizePercentiles mapped
#> 9 normalize_high normalizePercentiles mapped
#> 10 normalize_scope <builder choice> mapped
#> 11 pixel_size_um pixelSize mapped
#> 12 tile_size tileSize mapped
#> 13 include_probability includeProbability mapped
#> 14 cell_expansion_um cellExpansion mapped
#> 15 create_annotations createAnnotations mapped
#> 16 classification classify mappedA QuPath program contains run.json, the Groovy templates
and hashed resources. segmantR never starts QuPath itself.
calibrated <- img
calibrated$resolution <- list(x_um = 0.5, y_um = 0.5)
prog <- sg_export_qupath(run$mask, file.path(tempdir(), "qupath-import"),
image = calibrated, image_name = "nuclei.tif",
save_policy = "project", overwrite = TRUE)
prog$commands
#> $import
#> [1] "QuPath script --project=<project.qpproj> --image=\"nuclei.tif\" --args=run.json --save segmantR_import.groovy"
#>
#> $export
#> [1] "QuPath script --project=<project.qpproj> --image=\"nuclei.tif\" --args=run_export.json segmantR_export.groovy"For StarDist in QuPath, pass a model file (.pb,
SavedModel or bioimage.io) and an image describing the binding, then run
the template headless or from the Script Editor and import the
results:
sd <- sg_export_qupath("stardist.2d.v1", "qupath-stardist",
image = calibrated, image_name = "nuclei.tif",
model = "dsb2018_heavy_augment.pb",
normalize_scope = "tile", normalize_low = 1,
normalize_high = 99, pixel_size_um = 0.5,
save_policy = "project")
# In a shell, from qupath-stardist/:
# QuPath script --project=project.qpproj --image="nuclei.tif" \
# --args=run.json --save segmantR_stardist.groovy
back <- sg_import_qupath("qupath-stardist/out", image = calibrated,
expected_run_id = sd$run$run_id)
back$qupath$agreementcaps <- sg_interchange_capabilities()
vapply(caps$profiles, function(p) p$status, character(1))
#> I0 I1 app
#> "supported" "supported" "planned"
#> control datasets models
#> "planned" "supported" "supported"
#> partner_hyperannotatR partner_qupflowR qupath_programs
#> "planned" "planned" "planned"
#> stardist_python stardist_qupath
#> "unavailable" "planned"See
system.file("interop", "INTEROP.md", package = "segmantR")
for the full contract, migration notes and security boundaries.