Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Package: ClassiPyR
Title: A Shiny App for Manual Image Classification and Validation of IFCB Data
Version: 0.2.0
Version: 0.2.0.9000
Authors@R: c(
person("Anders", "Torstensson", email = "anders.torstensson@smhi.se", role = c("aut", "cre"),
comment = c("Swedish Meteorological and Hydrological Institute", ORCID = "0000-0002-8283-656X")),
Expand Down
4 changes: 4 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export(build_worms_match_rows)
export(copy_images_to_class_folders)
export(create_empty_changes_log)
export(create_new_classifications)
export(delete_annotations_db)
export(download_dashboard_adc)
export(download_dashboard_autoclass)
export(download_dashboard_image_single)
Expand Down Expand Up @@ -46,6 +47,7 @@ export(load_global_class_list_db)
export(parse_dashboard_url)
export(read_roi_dimensions)
export(rescan_file_index)
export(resolve_sample_dataset)
export(run_app)
export(sanitize_string)
export(sanitize_worms_query)
Expand All @@ -71,11 +73,13 @@ importFrom(curl,curl_fetch_memory)
importFrom(curl,new_handle)
importFrom(dplyr,filter)
importFrom(iRfcb,ifcb_annotate_samples)
importFrom(iRfcb,ifcb_create_class2use)
importFrom(iRfcb,ifcb_create_manual_file)
importFrom(iRfcb,ifcb_download_dashboard_data)
importFrom(iRfcb,ifcb_extract_pngs)
importFrom(iRfcb,ifcb_get_ecotaxa_example)
importFrom(iRfcb,ifcb_get_mat_variable)
importFrom(iRfcb,ifcb_zip_matlab)
importFrom(iRfcb,ifcb_zip_pngs)
importFrom(jsonlite,fromJSON)
importFrom(reticulate,py_available)
Expand Down
31 changes: 29 additions & 2 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
# ClassiPyR (development version)

## New features

- **Export SQLite → MATLAB ZIP**: New "SQLite → MATLAB ZIP" button in Settings that bundles `.mat` annotation files, feature CSVs, a `class2use.mat` config file, optional raw data, and READMEs into a distributable MATLAB-format ZIP archive via `iRfcb::ifcb_zip_matlab()`. When using SQLite-only storage, annotations are automatically converted to `.mat` files (requires Python with scipy). Supports the same README metadata fields as the existing PNG ZIP export.
- **IFCB instrument filter for ZIP exports**: Both ZIP and MATLAB ZIP export dialogs now include a "Filter by IFCB" dropdown when the database contains samples from multiple instruments. Select one or more instruments to include; deselect instruments to exclude them from the archive.
- **Local classifier files in dashboard mode**: When "Use dashboard auto-classifications" is disabled and a Classification Folder is configured, local classifier output files (CSV/H5/MAT) are now scanned during dashboard sync. Classified samples show the correct status (checkmark) in the sample dropdown, consistent with the loading behavior.
- **Clear Annotations**: Added a "Clear Annotations" button in sample mode that permanently deletes a sample's annotations from the SQLite database (and removes the `.mat` file if present). The button is disabled outside annotation mode and shows a confirmation dialog before proceeding. After clearing, the sample resets to a blank unclassified state.
- New exported function `delete_annotations_db()` for programmatic deletion of a sample's annotations from the database.

## UI Improvements

- **Reorganised Settings panel**: Folder paths are now grouped into clear "Input Folders" and "Output" sections. The ROI/PNG data folder is listed first as the primary input. Database folder and annotation storage format are grouped together under Output. The former standalone "Annotation Storage" section has been merged into Output.
- Added descriptive help text to all folder path fields in Settings.
- Help text is now visually closer to the field it describes, with more spacing before the next field.
- The **Predict** button is now always visible (greyed out when disabled).

## Bug fixes

- **Dashboard auto-classification URL**: Fixed class_scores CSV download failing for dashboard URLs without a `?dataset=` parameter. The IFCB Dashboard serves class_scores files from a dataset-specific path (e.g., `/mvco/`) rather than the generic `/data/` endpoint used for other file types. The new `resolve_sample_dataset()` function automatically queries the Dashboard bin API to determine the correct dataset when not explicitly provided in the URL.
- **Mode switching in dashboard mode**: Fixed "No ROI dimensions available to create annotations" error when switching from validation to annotation mode for dashboard-loaded samples. The mode-switching code now checks the dashboard PNG cache for ROI dimension inference, not just local PNG paths.
- **Python environment detection**: Fixed `py_discover_config()` returning system Python instead of the active virtualenv in the Shiny runtime, causing iRfcb's scipy check to fail for all `.mat` file operations (class list download, SQLite → .mat export, MATLAB ZIP export). The fix sets `RETICULATE_PYTHON` before Python initialization so that `py_discover_config()` resolves to the correct virtualenv binary.
- **Dashboard auto-classification fallback**: When "Use dashboard auto-classifications" is enabled but the dashboard has no classifier output for a sample, a user-friendly notification is now shown instead of only logging a console warning. Local classifier files are no longer loaded as an unintended fallback in this mode.
- The validation/annotation mode toggle now appears whenever auto-classification data exists for a sample, not only when both manual annotations AND auto-classifications pre-exist. This allows switching to annotation mode for samples that only have auto-classifications (✓), creating blank annotations on the fly. Previously these samples had no toggle and were locked in validation mode.
- The session cache now stores and restores the mode toggle state, so the toggle no longer disappears after switching between cached samples.
- Fixed "argument is of length zero" warning when closing the app, caused by the autosave-on-close handler failing to read the annotator name and class list after the session had already ended.

# ClassiPyR 0.2.0

## New features
Expand Down Expand Up @@ -73,7 +100,7 @@ Initial release of ClassiPyR, a Shiny application for manual classification and
- Resume previous annotations from saved files
- Navigate between samples with previous/next/random buttons
- Filter samples by classification status (all/classified/annotated/unannotated)
- Samples with both manual annotations AND auto-classifications can switch between modes
- Samples with auto-classifications can switch between validation and annotation modes

### Classification Loading
- Load classifications from CSV files (recursive folder search)
Expand All @@ -82,7 +109,7 @@ Initial release of ClassiPyR, a Shiny application for manual classification and
- Automatic sample status indicators in dropdown:
- ✎ = Has manual annotation
- ✓ = Has auto-classification
- ✎✓ = Has both (can switch between modes)
- ✎✓ = Has both
- * = Unannotated

### Image Gallery
Expand Down
46 changes: 44 additions & 2 deletions R/dashboard.R
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,36 @@ list_dashboard_bins <- function(base_url, dataset_name = NULL) {
as.character(bins)
}

#' Resolve the dataset name for a sample from the Dashboard API
#'
#' Queries the \code{/api/bin/<sample>} endpoint to retrieve the
#' \code{primary_dataset} field. Useful when the user did not provide a
#' \code{?dataset=} query parameter in the dashboard URL.
#'
#' @param base_url Character. Dashboard base URL (no trailing slash).
#' @param sample_name Character. Sample name (bin PID).
#' @return Character dataset name, or NULL if it could not be resolved.
#' @export
resolve_sample_dataset <- function(base_url, sample_name) {
api_url <- paste0(sub("/+$", "", base_url), "/api/bin/", sample_name)

tryCatch({
response <- curl::curl_fetch_memory(api_url,
handle = curl::new_handle(httpheader = c(Accept = "application/json")))

if (response$status_code != 200) return(NULL)

json_content <- rawToChar(response$content)
Encoding(json_content) <- "UTF-8"
parsed <- jsonlite::fromJSON(json_content, flatten = TRUE)

ds <- parsed[["primary_dataset"]]
if (!is.null(ds) && is.character(ds) && nzchar(ds)) ds else NULL
}, error = function(e) {
NULL
})
}

#' Download and extract PNG images from the Dashboard
#'
#' Downloads a zip file of PNG images for a sample from the Dashboard.
Expand Down Expand Up @@ -281,6 +311,8 @@ download_dashboard_adc <- function(base_url, sample_name,
#' @param base_url Character. Dashboard base URL.
#' @param sample_name Character. Sample name.
#' @param cache_dir Character. Cache directory.
#' @param dataset_name Optional character. Dataset slug (e.g. \code{"mvco"}).
#' If NULL, resolved automatically via \code{\link{resolve_sample_dataset}}.
#' @param parallel_downloads Integer. Number of parallel downloads.
#' @param sleep_time Numeric. Seconds to sleep between download batches.
#' @param multi_timeout Numeric. Timeout in seconds for multi-file downloads.
Expand All @@ -290,10 +322,20 @@ download_dashboard_adc <- function(base_url, sample_name,
#' @export
download_dashboard_autoclass <- function(base_url, sample_name,
cache_dir = get_dashboard_cache_dir(),
dataset_name = NULL,
parallel_downloads = 5, sleep_time = 2,
multi_timeout = 120, max_retries = 3) {
# The dashboard URL needs to include the dataset path for autoclass
dashboard_url <- paste0(sub("/+$", "", base_url), "/")
# Class scores are served from the dataset path (e.g., /mvco/), not /data/
# If dataset_name is not provided, resolve it from the bin API
if (is.null(dataset_name) || !nzchar(dataset_name)) {
dataset_name <- resolve_sample_dataset(base_url, sample_name)
}

if (!is.null(dataset_name) && nzchar(dataset_name)) {
dashboard_url <- paste0(sub("/+$", "", base_url), "/", dataset_name, "/")
} else {
dashboard_url <- paste0(sub("/+$", "", base_url), "/")
}

tryCatch({
ifcb_download_dashboard_data(
Expand Down
61 changes: 56 additions & 5 deletions R/database.R
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

#' @importFrom DBI dbConnect dbDisconnect dbWriteTable dbGetQuery dbExecute
#' @importFrom RSQLite SQLite
#' @importFrom iRfcb ifcb_create_manual_file ifcb_extract_pngs ifcb_get_ecotaxa_example ifcb_zip_pngs
#' @importFrom iRfcb ifcb_create_manual_file ifcb_extract_pngs ifcb_get_ecotaxa_example ifcb_zip_pngs ifcb_create_class2use ifcb_zip_matlab
NULL

#' Get path to the annotations SQLite database
Expand Down Expand Up @@ -405,6 +405,47 @@ save_annotations_db <- function(db_path, sample_name, classifications,
})
}

#' Delete annotations for a sample from the SQLite database
#'
#' Removes all rows for the given sample from both the \code{annotations} and
#' \code{class_lists} tables in a single transaction. This is a permanent
#' operation — the sample will appear unannotated after deletion.
#'
#' @param db_path Path to the SQLite database file
#' @param sample_name Sample name to delete
#' @return \code{TRUE} on success, \code{FALSE} on error (with a warning)
#' @export
#' @examples
#' \dontrun{
#' db_path <- get_db_path("/data/local_db")
#' delete_annotations_db(db_path, "D20230101T120000_IFCB134")
#' }
delete_annotations_db <- function(db_path, sample_name) {
if (!file.exists(db_path)) {
warning("Database file does not exist: ", db_path)
return(FALSE)
}

con <- dbConnect(SQLite(), db_path)
on.exit(dbDisconnect(con), add = TRUE)

tryCatch({
dbExecute(con, "BEGIN TRANSACTION")

dbExecute(con, "DELETE FROM annotations WHERE sample_name = ?",
params = list(sample_name))
dbExecute(con, "DELETE FROM class_lists WHERE sample_name = ?",
params = list(sample_name))

dbExecute(con, "COMMIT")
TRUE
}, error = function(e) {
tryCatch(dbExecute(con, "ROLLBACK"), error = function(e2) NULL)
warning("Failed to delete annotations from database: ", e$message)
FALSE
})
}

#' Load annotations from the SQLite database
#'
#' Reads annotations for a single sample and returns a data frame in the same
Expand Down Expand Up @@ -744,6 +785,9 @@ import_all_mat_to_db <- function(mat_folder, db_path,
#'
#' @param db_path Path to the SQLite database file
#' @param output_folder Folder where .mat files will be written
#' @param samples Optional character vector of sample names to export. When
#' \code{NULL} (the default), all annotated samples in the database are
#' exported.
#' @return Named list with counts: \code{success}, \code{failed}
#' @export
#' @examples
Expand All @@ -752,8 +796,10 @@ import_all_mat_to_db <- function(mat_folder, db_path,
#' result <- export_all_db_to_mat(db_path, "/data/manual")
#' cat(result$success, "exported,", result$failed, "failed\n")
#' }
export_all_db_to_mat <- function(db_path, output_folder) {
samples <- list_annotated_samples_db(db_path)
export_all_db_to_mat <- function(db_path, output_folder, samples = NULL) {
if (is.null(samples)) {
samples <- list_annotated_samples_db(db_path)
}

counts <- list(success = 0L, failed = 0L)

Expand Down Expand Up @@ -935,6 +981,9 @@ import_png_folder_to_db <- function(png_folder, db_path, class2use,
#' paths. Samples without an entry are skipped.
#' @param skip_class Character vector of class names to exclude from export
#' (e.g. \code{"unclassified"}). Default \code{NULL} exports all classes.
#' @param samples Optional character vector of sample names to export. When
#' \code{NULL} (the default), all annotated samples in the database are
#' exported.
#' @return Named list with counts: \code{success}, \code{failed}, \code{skipped}
#' @export
#' @examples
Expand All @@ -946,8 +995,10 @@ import_png_folder_to_db <- function(png_folder, db_path, class2use,
#' cat(result$success, "exported,", result$failed, "failed,", result$skipped, "skipped\n")
#' }
export_all_db_to_png <- function(db_path, png_folder, roi_path_map,
skip_class = NULL) {
samples <- list_annotated_samples_db(db_path)
skip_class = NULL, samples = NULL) {
if (is.null(samples)) {
samples <- list_annotated_samples_db(db_path)
}

counts <- list(success = 0L, failed = 0L, skipped = 0L)

Expand Down
Loading