diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..d59504ad --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "js/ts.tsdk.path": "node_modules\\typescript\\lib" +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 23739dfe..2901d96c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented here. ## [unreleased] +## [0.24.0] - July 22nd, 2026 +- Add `ConfidenceSlider` toolbox item (`AllowedToolboxItem.ConfidenceSlider`) that deprecates (hides) or shows spatial annotations based on their confidence values. Unlike the now-deprecated `KeypointSlider`, it works with all spatial annotation types that have a confidence payload (`bbox`, `bbox3`, `polygon`, `polyline`, `contour`, `tbar`, and `point`), across every subtask. + - Supports a single global "all" slider and/or per-class sliders, controlled by `class_filter_mode` (`"toggle"`, `"all-only"`, or `"class-only"`). + - Configurable via `confidence_slider_toolbox_item`, including `filter_min`/`filter_max`/`step_value`, per-class `default_values`, `target_spatial_types`, and `target_class_ids`. +- Add `get_confidence_slider_value()` public API method to get the current confidence slider values. +- The default `toolbox_order` (used when none is provided) now includes `ConfidenceSlider` instead of `KeypointSlider`. +- Log a warning when both the `KeypointSlider` and `ConfidenceSlider` toolbox items are enabled at once. +- Deprecate the `KeypointSlider` toolbox item and `get_keypoint_slider_value()` in favor of the `ConfidenceSlider` and `get_confidence_slider_value()`. The `KeypointSlider` is retained for backwards compatibility and continues to filter only `point` annotations. It will be removed in a future release. + ## [0.23.7] - July 21st, 2026 - Ship generated `.d.ts` declarations (`dist/types/`) and a generated type entry (`dist/index.d.ts`) instead of pointing consumers' type resolution at the raw `.ts` source. This stops downstream TypeScript projects from compiling ULabel's source under their own (stricter) `tsconfig`. - Stop publishing the `src/` directory in the npm package (`files` now ships `dist/` only). diff --git a/api_spec.md b/api_spec.md index dcd90f80..18f8987d 100644 --- a/api_spec.md +++ b/api_spec.md @@ -346,6 +346,7 @@ enum AllowedToolboxItem { ImageFilters, // 10 AnnotationList, // 11 Keybinds, // 12 + ConfidenceSlider, // 13 } ``` You can access the AllowedToolboxItem enum by calling the static method: @@ -434,6 +435,44 @@ The `AnnotationList` toolbox item displays all annotations in the current subtas This toolbox item requires no configuration and can be added to the `toolbox_order` array using `AllowedToolboxItem.AnnotationList`. +### `confidence_slider_toolbox_item` + +The `ConfidenceSlider` toolbox item (added to `toolbox_order` via `AllowedToolboxItem.ConfidenceSlider`) deprecates (hides) or shows spatial annotations based on their confidence values. Unlike the deprecated `KeypointSlider`, it works with **all** spatial annotation types that have a confidence payload (`bbox`, `bbox3`, `polygon`, `polyline`, `contour`, `tbar`, and `point`), across every subtask. + +It supports two modes: + +- **"all" mode** (default): a single slider applies one confidence threshold to every targeted spatial annotation across all subtasks, using each annotation's highest confidence value. +- **Per-class mode**: one slider is shown per targeted class id. Each slider only filters annotations whose assigned (highest-confidence) class matches that slider, using that class's confidence value. + +The `class_filter_mode` config controls whether these modes are user-toggleable: +- `"toggle"` (default): a checkbox lets the user switch between the two modes. +- `"all-only"`: only the single global "all" slider is shown. +- `"class-only"`: only the per-class sliders are shown. + +Any annotation with a confidence at or above the threshold is shown; any below is deprecated. Thresholds are expressed as percentages, and the slider's range and increment are configurable via `filter_min`, `filter_max`, and `step_value`. + +Configuration object with the following custom definitions: +```javascript +type ConfidenceSliderClasses = { + "all": number, // percentage threshold (0-100) used by the single global slider + [classId: string]?: number // per-class-id thresholds (class id as a string key) +} + +type ConfidenceSliderConfig = { + "name"?: string, // Default: "Confidence Filter" + "filter_min"?: number, // Default: 0 (%) + "filter_max"?: number, // Default: 100 (%) + "default_values"?: ConfidenceSliderClasses, // Default: {"all": 0} + "step_value"?: number, // Default: 1 (%) + "class_filter_mode"?: "toggle" | "all-only" | "class-only", // Default: "toggle" + "filter_on_load"?: boolean, // Default: true + // The spatial types to filter. Defaults to all confidence-filterable spatial types. + "target_spatial_types"?: ULabelSpatialType[], + // The class ids to create sliders for in class-only/toggle mode. Defaults to all class ids. + "target_class_ids"?: number[], +} +``` + ### `reset_zoom_keybind` Keybind to reset the zoom level to the `initial_crop`. Default is `r`. @@ -452,6 +491,8 @@ Keybind to delete a vertex of a polygon or polyline annotation. The vertex must ### `keypoint_slider_default_value` Default value for the keypoint slider. Must be a number between 0 and 1. Default is `0`. +> **Deprecated:** The `KeypointSlider` toolbox item only filters `point` annotations. Use the `ConfidenceSlider` toolbox item (`confidence_slider_toolbox_item`) instead, which filters all spatial annotation types and supports per-class targeting and multiple sliders. + ### `filter_annotations_on_load` If true, the annotations will be filtered on load based on the `keypoint_slider_default_value`. Default is `true`. @@ -563,10 +604,16 @@ Sets the zoom to focus on the provided annotation, and switches to its subtask i *() => number | null* -- Returns the current keypoint slider value as a number between 0 and 1. Returns `null` if the KeypointSlider toolbox item is not active or the slider element is not found. +> **Deprecated:** Prefer `get_confidence_slider_value()` with the `ConfidenceSlider` toolbox item. + ### `get_distance_filter_value()` *() => object | null* -- Returns an object mapping class identifiers to their distance filter values (in pixels). The object always includes a `closest_row` key for the single-class slider. In multi-class mode, additional keys correspond to each class ID. Returns `null` if the FilterDistance toolbox item is not active or no sliders are found. +### `get_confidence_slider_value()` + +*() => object | null* -- Returns an object mapping class identifiers to their confidence threshold values (as percentages, 0–100). The object always includes an `all` key for the single global slider. In per-class mode, additional keys correspond to each class ID. Returns `null` if the ConfidenceSlider toolbox item is not active or no sliders are found. + ## Generic Callbacks Callbacks can be provided by calling `.on(fn, callback)` on a `ULabel` object. diff --git a/demo/live_demo.html b/demo/live_demo.html index 5f5aa3a4..b821f634 100644 --- a/demo/live_demo.html +++ b/demo/live_demo.html @@ -22,7 +22,7 @@ "spatial_type": "bbox", "spatial_payload": [[871.84, 480.78], [833.71, 396.29]], "classification_payloads": [ - {"class_id": 11, "confidence": 1.0}, + {"class_id": 11, "confidence": 0.75}, ], }, { @@ -30,7 +30,90 @@ "spatial_type": "bbox", "spatial_payload": [[1257.66, 541.35], [1079.71, 411.99]], "classification_payloads": [ - {"class_id": 10, "confidence": 1.0}, + {"class_id": 10, "confidence": 0.85}, + ], + }, + // Additional annotations with varied confidences to demo the ConfidenceSlider + { + "id": "71be2a9d-5f1c-4a98-835e-e7826ebec6cf", + "spatial_type": "bbox", + "spatial_payload": [[1381.27, 510.38], [1268.86, 410.89]], + "classification_payloads": [ + {"class_id": 10, "confidence": 0.9}, + ], + }, + { + "id": "79c81e3e-0a08-4fa1-a6b0-1e58a498116f", + "spatial_type": "polygon", + "spatial_payload": [ + [ + [452.24, 540.10], [625.38, 532.35], [642.18, 483.25], + [630.55, 447.07], [591.79, 414.77], [462.58, 413.48], + [445.78, 421.23], [447.07, 469.04], [434.15, 503.92], + [421.23, 510.38], [416.06, 537.52], [452.24, 540.10] + ] + ], + "spatial_payload_holes": [false], + "spatial_payload_child_indices": [[]], + "classification_payloads": [ + {"class_id": 10, "confidence": 0.35}, + ], + }, + { + "id": "c170e38a-eafd-429d-87b1-9a1ee75e25b0", + "spatial_type": "bbox", + "spatial_payload": [[1447.58, 484.52], [1418.42, 397.04]], + "classification_payloads": [ + {"class_id": 11, "confidence": 0.55}, + ], + }, + { + "id": "ec96ec1f-2007-4d10-a286-16af43775105", + "spatial_type": "bbox", + "spatial_payload": [[1477.49, 480.78], [1453.56, 401.52]], + "classification_payloads": [ + {"class_id": 11, "confidence": 0.2}, + ], + }, + { + "id": "ca9209d9-f787-42e4-8d4b-4408e10ae1ed", + "spatial_type": "bbox", + "spatial_payload": [[1064.00, 468.07], [1031.85, 400.03]], + "classification_payloads": [ + {"class_id": 11, "confidence": 0.7}, + ], + }, + { + "id": "39f9f056-383a-4b25-9e31-e71951a1ecaf", + "spatial_type": "bbox", + "spatial_payload": [[1037.67, 477.22], [1013.63, 393.07]], + "classification_payloads": [ + {"class_id": 11, "confidence": 0.45}, + ], + }, + { + "id": "9cf8c7ef-814e-48e1-aa1a-e5e74e922938", + "spatial_type": "bbox", + "spatial_payload": [[952.93, 470.00], [898.24, 409.90]], + "classification_payloads": [ + {"class_id": 11, "confidence": 0.6}, + ], + }, + { + "id": "84ba0141-fc65-4f89-9057-38eef9d93586", + "spatial_type": "polygon", + "spatial_payload": [ + [ + [4.78, 791.18], [341.47, 702.03], [358.98, 666.22], + [374.90, 595.38], [369.32, 543.64], [332.71, 497.47], + [305.65, 486.33], [275.40, 484.74], [175.91, 423.45], + [71.64, 410.71], [0, 408.33], [4.78, 791.18] + ] + ], + "spatial_payload_holes": [false], + "spatial_payload_child_indices": [[]], + "classification_payloads": [ + {"class_id": 10, "confidence": 0.65}, ], }, ]; diff --git a/demo/multi-class.html b/demo/multi-class.html index 2d18a3c2..dc75865c 100644 --- a/demo/multi-class.html +++ b/demo/multi-class.html @@ -127,7 +127,8 @@ AllowedToolboxItem.AnnotationID, AllowedToolboxItem.ClassCounter, AllowedToolboxItem.AnnotationResize, - AllowedToolboxItem.KeypointSlider, + AllowedToolboxItem.KeypointSlider, + AllowedToolboxItem.ConfidenceSlider, AllowedToolboxItem.FilterDistance, AllowedToolboxItem.RecolorActive, ], @@ -141,6 +142,9 @@ "brightness": 120 }, }, + "confidence_slider_toolbox_item": { + "step_value": 5, + }, }); // Wait for ULabel instance to finish initialization ulabel.init(function () { diff --git a/demo/resume-from.html b/demo/resume-from.html index bef0f67a..ba506d29 100644 --- a/demo/resume-from.html +++ b/demo/resume-from.html @@ -36,7 +36,7 @@ "spatial_payload": [[300, 300]], "classification_payloads": [ {"class_id": 12, "confidence": 0.0}, - {"class_id": 11, "confidence": 1.0}, + {"class_id": 11, "confidence": 0.9}, {"class_id": 10, "confidence": 0.0}, ], }, @@ -45,7 +45,7 @@ "spatial_type": "point", "spatial_payload": [[400, 300]], "classification_payloads": [ - {"class_id": 10, "confidence": 1.0}, + {"class_id": 10, "confidence": 0.25}, ], }, { @@ -53,7 +53,7 @@ "spatial_type": "point", "spatial_payload": [[300, 400]], "classification_payloads": [ - {"class_id": 12, "confidence": 1.0}, + {"class_id": 12, "confidence": 0.55}, ], }, { @@ -102,7 +102,7 @@ ] ], "classification_payloads": [ - {"class_id": 11, "confidence": 1.0}, + {"class_id": 11, "confidence": 0.75}, {"class_id": -1, "confidence": 0.0} ], }, @@ -175,7 +175,7 @@ "classification_payloads": [ { "class_id": 10, - "confidence": 1 + "confidence": 0.15 }, { "class_id": 11, @@ -209,7 +209,7 @@ "classification_payloads": [ { "class_id": 10, - "confidence": 1 + "confidence": 0.6 }, { "class_id": 11, @@ -275,6 +275,7 @@ }; // Initial ULabel configuration + const AllowedToolboxItem = ULabel.get_allowed_toolbox_item_enum(); let ulabel = new ULabel({ "container_id": "container", "image_data": "https://ulabel.s3.us-east-2.amazonaws.com/cs-demo-0.png", @@ -285,7 +286,16 @@ "allow_annotations_outside_image": false, "fly_to_next_annotation_keybind": "w", "fly_to_previous_annotation_keybind": "`", - "fly_to_max_zoom": 6 + "fly_to_max_zoom": 6, + "toolbox_order": [ + AllowedToolboxItem.ModeSelect, + AllowedToolboxItem.ZoomPan, + AllowedToolboxItem.AnnotationID, + AllowedToolboxItem.ClassCounter, + AllowedToolboxItem.AnnotationResize, + AllowedToolboxItem.ConfidenceSlider, + AllowedToolboxItem.SubmitButtons, + ], }); // Wait for ULabel instance to finish initialization ulabel.init(function() { diff --git a/demo/row-filtering-example.html b/demo/row-filtering-example.html index b4e37e03..76da73fc 100644 --- a/demo/row-filtering-example.html +++ b/demo/row-filtering-example.html @@ -745,7 +745,7 @@ AllowedToolboxItem.ZoomPan, AllowedToolboxItem.AnnotationID, AllowedToolboxItem.ClassCounter, - AllowedToolboxItem.KeypointSlider, + AllowedToolboxItem.ConfidenceSlider, AllowedToolboxItem.FilterDistance, AllowedToolboxItem.AnnotationResize, AllowedToolboxItem.SubmitButtons, @@ -761,7 +761,13 @@ // "disable_multi_class_mode": true, // "filter_during_polyline_move": false, }, - "keypoint_slider_default_value": 0.2, + "confidence_slider_toolbox_item": { + "class_filter_mode": "class-only", + "target_class_ids": [10], // Plant only + "default_values": { + "all": 20, // % + }, + }, }); // Wait for ULabel instance to finish initialization ulabel.init(function () { diff --git a/index.d.ts b/index.d.ts index 767bb9ed..bcddb4bf 100644 --- a/index.d.ts +++ b/index.d.ts @@ -36,12 +36,13 @@ export type Offset = { /** * Valid keys for the DeprecatedBy type */ -export type ValidDeprecatedBy = "human" | "confidence_filter" | "distance_from_row"; +export type ValidDeprecatedBy = "human" | "confidence_filter" | "distance_from_row" | "confidence_slider"; export type DeprecatedBy = { human?: boolean; confidence_filter?: boolean; distance_from_row?: boolean; + confidence_slider?: boolean; }; /** @@ -118,6 +119,45 @@ export type FilterDistanceConfig = { filter_during_polyline_move?: boolean; }; +/** + * Stores the current confidence slider threshold values (percentages, 0-100). + * The key is the class id. "all" is a special key that stores the threshold applied to + * all annotations in single-class mode. + */ +export type ConfidenceSliderClasses = { + all: number; + [key: string]: number; +}; + +/** + * Controls how the ConfidenceSlider ToolboxItem filters by class: + * - "toggle": a checkbox lets the user switch between the single "all" slider and per-class sliders + * - "all-only": only a single global slider (keyed "all") is shown, filtering every targeted annotation + * - "class-only": only per-class sliders are shown + */ +export type ConfidenceSliderClassFilterMode = "toggle" | "all-only" | "class-only"; + +/** + * Config object for the ConfidenceSlider ToolboxItem. + */ +export type ConfidenceSliderConfig = { + name?: string; + filter_min?: number; + filter_max?: number; + default_values?: ConfidenceSliderClasses; + step_value?: number; + class_filter_mode?: ConfidenceSliderClassFilterMode; + filter_on_load?: boolean; + // The spatial types to filter. Defaults to all confidence-filterable spatial types. + target_spatial_types?: ULabelSpatialType[]; + // The class ids to create sliders for in class-only/toggle mode. Defaults to all class ids. + target_class_ids?: number[]; + keybinds?: { + increment: string; + decrement: string; + }; +}; + export type ULabelSubmitButton = { name: string; hook: (submit_data: ULabelSubmitData) => void; @@ -367,6 +407,7 @@ export class ULabel { ): void; public get_keypoint_slider_value(): number | null; public get_distance_filter_value(): DistanceFromPolylineClasses | null; + public get_confidence_slider_value(): ConfidenceSliderClasses | null; public fly_to_next_annotation(increment: number, max_zoom?: number): boolean; public fly_to_annotation_id(annotation_id: string, subtask_key?: string | null, max_zoom?: number): boolean; public fly_to_annotation(annotation: ULabelAnnotation, subtask_key?: string, max_zoom?: number): boolean; diff --git a/jest.config.js b/jest.config.js index 3fc5e57c..a8e28b89 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,6 +18,11 @@ module.exports = { coverageReporters: ["text", "lcov", "html"], moduleNameMapper: { "^@/(.*)$": "/src/$1", + // The `build/` CJS modules reference the raw ESM sources `src/version.js` and + // `src/blobs.js`, which jest cannot transform. Stub them for unit tests that load + // `build/` modules (e.g. the ConfidenceSlider toolbox item via html_builder). + "src/version(\\.js)?$": "/tests/testing-utils/version_stub.js", + "src/blobs(\\.js)?$": "/tests/testing-utils/blobs_stub.js", }, transform: { "^.+\\.(js|ts)$": "babel-jest", diff --git a/package.json b/package.json index 7a210dfd..273fa93f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ulabel", "description": "An image annotation tool.", - "version": "0.23.7", + "version": "0.24.0", "main": "dist/ulabel.min.js", "module": "dist/ulabel.min.js", "types": "dist/index.d.ts", diff --git a/src/actions.ts b/src/actions.ts index 05d41044..0ff3094a 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -4,8 +4,6 @@ import type { ULabelActionRaw, ULabelActionType, } from "../index"; -// Import ULabel from ../src/index - TypeScript will find ../src/index.d.ts for types -// and resolve to ../src/index.js at runtime after compilation import { ULabel } from "../src/index"; import { FilterPointDistanceFromRow } from "./toolbox"; import { AllowedToolboxItem } from "./configuration"; diff --git a/src/annotation.ts b/src/annotation.ts index 8bb496b5..ea291a5e 100644 --- a/src/annotation.ts +++ b/src/annotation.ts @@ -14,6 +14,23 @@ export const DELETE_CLASS_ID = -1; export const MODES_3D = ["global", "bbox3"]; export const NONSPATIAL_MODES = ["whole-image", "global"]; +// A record enumerating every ULabelSpatialType. Adding a member to the union without updating this +// record is a compile error, which keeps ALL_SPATIAL_TYPES in sync with the type. +const SPATIAL_TYPE_SET: Record = { + "contour": true, + "polygon": true, + "polyline": true, + "bbox": true, + "tbar": true, + "bbox3": true, + "whole-image": true, + "global": true, + "point": true, +}; + +// Every ULabelSpatialType (spatial and non-spatial modes) as a runtime array. +export const ALL_SPATIAL_TYPES = Object.keys(SPATIAL_TYPE_SET) as ULabelSpatialType[]; + export type PolygonSpatialData = { // TODO (joshua-dean): validate this type spatial_payload: [number[]][]; diff --git a/src/annotation_operators.ts b/src/annotation_operators.ts index 49e4cb93..287f80fb 100644 --- a/src/annotation_operators.ts +++ b/src/annotation_operators.ts @@ -7,10 +7,9 @@ import type { ValidDeprecatedBy, ClassDefinition, } from "../index"; -// Import ULabel from ../src/index - TypeScript will find ../src/index.d.ts for types import { ULabel } from "../src/index"; -import { ULabelAnnotation, DELETE_CLASS_ID } from "./annotation"; +import { ULabelAnnotation, DELETE_CLASS_ID, ALL_SPATIAL_TYPES, NONSPATIAL_MODES } from "./annotation"; import { ULabelSubtask } from "./subtask"; /** @@ -30,6 +29,22 @@ export function get_annotation_confidence(annotation: ULabelAnnotation) { return current_confidence; } +/** + * Returns the confidence that the passed in ULabelAnnotation is a specific class id. + * + * @param annotation ULabelAnnotation + * @param class_id The class id to get the confidence for + * @returns The confidence for the given class id, or -1 if the class id is not present + */ +export function get_annotation_confidence_for_class(annotation: ULabelAnnotation, class_id: number): number { + for (const payload of annotation.classification_payloads ?? []) { + if (payload.class_id === class_id) { + return payload.confidence; + } + } + return -1; +} + /** * Returns the class id of a ULabelAnnotation as a string. * @@ -417,6 +432,48 @@ export function get_point_and_line_annotations(ulabel: ULabel): [ULabelAnnotatio return [point_annotations, line_annotations]; } +/** + * The spatial annotation types that can be filtered by confidence: every spatial type except the + * non-spatial modes (`whole-image` and `global`). + */ +export const CONFIDENCE_FILTERABLE_SPATIAL_TYPES: ULabelSpatialType[] = + ALL_SPATIAL_TYPES.filter((type) => !NONSPATIAL_MODES.includes(type)); + +/** + * Gathers all spatial annotations across every subtask that have a confidence payload and whose + * spatial type is included in `spatial_types`. Each returned annotation has its `subtask_key` set. + * + * @param ulabel ULabel object + * @param spatial_types The spatial types to include. Defaults to all confidence-filterable spatial types. + * @returns A list of spatial annotations that can be filtered by confidence + */ +export function get_spatial_annotations_with_confidence( + ulabel: ULabel, + spatial_types: ULabelSpatialType[] = CONFIDENCE_FILTERABLE_SPATIAL_TYPES, +): ULabelAnnotation[] { + const annotations: ULabelAnnotation[] = []; + + // Loop through each subtask + for (const [subtask_key, subtask] of Object.entries(ulabel.subtasks) as [string, ULabelSubtask][]) { + // Then go through each annotation in the subtask + for (const annotation_key in subtask.annotations.access) { + const annotation: ULabelAnnotation = subtask.annotations.access[annotation_key]; + + // Skip annotations without a spatial type we're targeting + if (annotation.spatial_type == null || !spatial_types.includes(annotation.spatial_type)) continue; + + // Skip annotations that lack a classification payload with confidence + if (annotation.classification_payloads == null || annotation.classification_payloads.length === 0) continue; + + // Note the annotation's subtask and add it to the set + annotation.subtask_key = subtask_key; + annotations.push(annotation); + } + } + + return annotations; +} + /** * Using the value of the FilterPointDistanceFromRow's slider, filter all point annotations based on their distance * from a polyline annotation. @@ -567,27 +624,47 @@ export function filter_points_distance_from_line(ulabel: ULabel, recalculate_dis } /** - * Goes through all subtasks and finds all classes that polylines can be. Then returns a list of them. + * Goes through all subtasks and finds all class definitions that annotations can be. Optionally + * restricts to subtasks that allow at least one of the provided `allowed_modes`. Class definitions + * are de-duplicated by id, and the reserved delete class is skipped. * - * @returns A list of all classes which can be polylines + * @param ulabel ULabel object + * @param allowed_modes If provided, only include subtasks that allow at least one of these modes + * @returns A de-duplicated list of class definitions */ -export function findAllPolylineClassDefinitions(ulabel: ULabel) { - // Initialize potential class definitions - const potential_class_defs: ClassDefinition[] = []; +export function findAllClassDefinitions(ulabel: ULabel, allowed_modes: ULabelSpatialType[] | null = null): ClassDefinition[] { + // Initialize potential class definitions and a set to track seen ids + const class_defs: ClassDefinition[] = []; + const seen_ids = new Set(); - // Check each subtask to see if polyline is one of its allowed modes for (const subtask_key in ulabel.subtasks) { - // Grab the subtask const subtask = ulabel.subtasks[subtask_key]; - if (subtask.allowed_modes.includes("polyline")) { - // Loop through all the classes in the subtask - subtask.class_defs.forEach((current_class_def) => { - // Skip the reserved delete class - if (current_class_def.id === DELETE_CLASS_ID) return; - potential_class_defs.push(current_class_def); - }); + // If allowed_modes is provided, skip subtasks that don't allow any of them + if (allowed_modes !== null && !allowed_modes.some((mode) => subtask.allowed_modes.includes(mode))) { + continue; } + + // Loop through all the classes in the subtask + subtask.class_defs.forEach((current_class_def) => { + // Skip the reserved delete class + if (current_class_def.id === DELETE_CLASS_ID) return; + // Skip classes we've already added (de-duplicate by id) + if (seen_ids.has(current_class_def.id)) return; + + seen_ids.add(current_class_def.id); + class_defs.push(current_class_def); + }); } - return potential_class_defs; + + return class_defs; +} + +/** + * Goes through all subtasks and finds all classes that polylines can be. Then returns a list of them. + * + * @returns A list of all classes which can be polylines + */ +export function findAllPolylineClassDefinitions(ulabel: ULabel) { + return findAllClassDefinitions(ulabel, ["polyline"]); } diff --git a/src/configuration.ts b/src/configuration.ts index 47473c04..ce10a91b 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,5 +1,6 @@ import type { FilterDistanceConfig, + ConfidenceSliderConfig, ImageFiltersConfig, InitialCrop, ImageData, @@ -14,11 +15,11 @@ import { ClassCounterToolboxItem, AnnotationResizeItem, RecolorActiveItem, - KeypointSliderItem, FilterPointDistanceFromRow, BrushToolboxItem, ToolboxItem, } from "./toolbox"; +import { ConfidenceSlider, KeypointSliderItem } from "./toolbox_items/confidence_slider"; import { SubmitButtons } from "./toolbox_items/submit_buttons"; import { ImageFiltersToolboxItem } from "./toolbox_items/image_filters"; import { AnnotationListToolboxItem } from "./toolbox_items/annotation_list"; @@ -40,6 +41,7 @@ export enum AllowedToolboxItem { ImageFilters, // 10 AnnotationList, // 11 Keybinds, // 12 + ConfidenceSlider, // 13 } /* eslint-enable @stylistic/no-multi-spaces */ @@ -61,6 +63,16 @@ export const DEFAULT_FILTER_DISTANCE_CONFIG: FilterDistanceConfig = { filter_during_polyline_move: true, }; +export const DEFAULT_CONFIDENCE_SLIDER_CONFIG: ConfidenceSliderConfig = { + name: "Confidence Filter", + filter_min: 0, + filter_max: 100, + default_values: { all: 0 }, + step_value: 1, + class_filter_mode: "toggle", + filter_on_load: true, +}; + export const DEFAULT_IMAGE_FILTERS_CONFIG: ImageFiltersConfig = { default_values: { brightness: 100, @@ -165,6 +177,7 @@ export class Configuration { [AllowedToolboxItem.ImageFilters, ImageFiltersToolboxItem], [AllowedToolboxItem.AnnotationList, AnnotationListToolboxItem], [AllowedToolboxItem.Keybinds, KeybindsToolboxItem], + [AllowedToolboxItem.ConfidenceSlider, ConfidenceSlider], ]); // Default toolbox order used when the user doesn't specify one @@ -179,7 +192,7 @@ export class Configuration { AllowedToolboxItem.AnnotationID, AllowedToolboxItem.RecolorActive, AllowedToolboxItem.ClassCounter, - AllowedToolboxItem.KeypointSlider, + AllowedToolboxItem.ConfidenceSlider, AllowedToolboxItem.SubmitButtons, ]; @@ -191,6 +204,9 @@ export class Configuration { // Config for FilterDistanceToolboxItem public distance_filter_toolbox_item: FilterDistanceConfig = DEFAULT_FILTER_DISTANCE_CONFIG; + // Config for ConfidenceSlider + public confidence_slider_toolbox_item: ConfidenceSliderConfig = DEFAULT_CONFIDENCE_SLIDER_CONFIG; + // Config for ImageFiltersToolboxItem public image_filters_toolbox_item: ImageFiltersConfig = DEFAULT_IMAGE_FILTERS_CONFIG; diff --git a/src/html_builder.ts b/src/html_builder.ts index fe2ffd13..29820b9d 100644 --- a/src/html_builder.ts +++ b/src/html_builder.ts @@ -1,5 +1,4 @@ import type { SliderInfo } from "../index"; -// Import ULabel from ../src/index - TypeScript will find ../src/index.d.ts for types import { ULabel } from "../src/index"; import { Toolbox, ZoomPanToolboxItem } from "./toolbox"; import { ULABEL_VERSION } from "../src/version"; diff --git a/src/index.js b/src/index.js index 3ed5acd5..fb3a4f5e 100644 --- a/src/index.js +++ b/src/index.js @@ -1046,6 +1046,19 @@ export class ULabel { return item.get_current_values(); } + /** + * Get the current confidence slider values. + * + * @returns {object|null} An object mapping class identifiers (and "all") to their confidence + * threshold values, or null if the ConfidenceSlider toolbox item is not active or no sliders + * are found + */ + get_confidence_slider_value() { + const item = this.toolbox.items.find((item) => item.get_toolbox_item_type() === "ConfidenceSlider"); + if (item === undefined) return null; + return item.get_current_values(); + } + // Show annotation mode show_annotation_mode(el = null) { if (el === null) { diff --git a/src/initializer.ts b/src/initializer.ts index 0422ff2b..4ccc4844 100644 --- a/src/initializer.ts +++ b/src/initializer.ts @@ -4,7 +4,6 @@ * This also includes "staggered" initializers to test loading. */ -// Import ULabel from ../src/index - TypeScript will find ../src/index.d.ts for types import { ULabel } from "../src/index"; import { initialize_annotation_canvases } from "./canvas_utils"; import { Configuration } from "./configuration"; diff --git a/src/toolbox.ts b/src/toolbox.ts index 06c453e1..54ed3b8a 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -2,17 +2,12 @@ import type { DistanceFromPolylineClasses, FilterDistanceConfig, RecolorActiveConfig, - ValidDeprecatedBy, } from "../index"; -// Import ULabel from ../src/index - TypeScript will find ../src/index.d.ts for types import { ULabel } from "../src/index"; -import { DEFAULT_FILTER_DISTANCE_CONFIG } from "./configuration"; +import { DEFAULT_FILTER_DISTANCE_CONFIG, AllowedToolboxItem } from "./configuration"; import { ULabelAnnotation } from "./annotation"; import { ULabelSubtask } from "./subtask"; import { - get_annotation_confidence, - value_is_lower_than_filter, - mark_deprecated, filter_points_distance_from_line, findAllPolylineClassDefinitions, get_point_and_line_annotations, @@ -76,6 +71,22 @@ export class Toolbox { throw new Error("No Toolbox Items Given"); } + // Warn if both the deprecated KeypointSlider and the ConfidenceSlider are enabled, since + // they overlap in functionality and use different deprecation keys. + const toolbox_keys = toolbox_item_order.map((item) => + typeof item === "number" ? item : (item as [number, object])[0], + ); + if ( + toolbox_keys.includes(AllowedToolboxItem.KeypointSlider) && + toolbox_keys.includes(AllowedToolboxItem.ConfidenceSlider) + ) { + log_message( + "Both the deprecated KeypointSlider and the ConfidenceSlider toolbox items are enabled. " + + "The KeypointSlider is deprecated; consider using only the ConfidenceSlider.", + LogLevel.WARNING, + ); + } + this.add_styles(); const toolbox_instance_list = []; @@ -1787,193 +1798,8 @@ export class RecolorActiveItem extends ToolboxItem { } } -export class KeypointSliderItem extends ToolboxItem { - public html!: string; - public inner_HTML: string; - public name: string; - public slider_bar_id: string; - public filter_function: (value: number, filter: number) => boolean; - public get_confidence: (annotation: ULabelAnnotation) => number; - public mark_deprecated: ( - annotation: ULabelAnnotation, - deprecated: boolean, - deprecated_by_key?: ValidDeprecatedBy, - ) => void; - - filter_value: number = 0; - ulabel: ULabel; - keybinds: { - increment: string; - decrement: string; - }; - - // TODO (joshua-dean): See if we can narrow this any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(ulabel: ULabel, kwargs: { [name: string]: any }) { - super(); - this.inner_HTML = `

Keypoint Slider

`; - this.ulabel = ulabel; - - // Use properties in kwargs if kwargs is present - if (kwargs !== undefined) { - this.name = kwargs.name; - this.filter_function = kwargs.filter_function; - this.get_confidence = kwargs.confidence_function; - this.mark_deprecated = kwargs.mark_deprecated; - this.keybinds = kwargs.keybinds; - } else { - // Otherwise use defaults - this.name = "Keypoint Slider"; - this.filter_function = value_is_lower_than_filter; - this.get_confidence = get_annotation_confidence; - this.mark_deprecated = mark_deprecated; - this.keybinds = { - increment: "2", - decrement: "1", - }; - kwargs = {}; - } - - // Create slider bar id - this.slider_bar_id = this.name.replaceLowerConcat(" ", "-"); - - // If the config has a default value override the filter_value - const has_filter_override = Object.prototype.hasOwnProperty.call( - this.ulabel.config, - this.name.replaceLowerConcat(" ", "_", "_default_value"), - ); - if (has_filter_override) { - // Set the filter value - this.filter_value = this.ulabel.config[this.name.replaceLowerConcat(" ", "_", "_default_value")]; - } - - // Check the config to see if we should update the annotations with the default filter on load - if (this.ulabel.config.filter_annotations_on_load) { - this.filter_annotations(this.ulabel); - } - - this.add_styles(); - } - - /** - * Create the css for this ToolboxItem and append it to the page. - */ - protected add_styles() { - // Define the css - const css = ` - /* Component has no css?? */ - `; - // Create an id so this specific style tag can be referenced - const style_id = "keypoint-slider-toolbox-item-styles"; - - // Don't add the style tag if its already been added once - if (document.getElementById(style_id)) return; - - // Grab the document's head and create a style tag - const head = document.head || document.querySelector("head"); - const style = document.createElement("style"); - - // Add the css and id to the style tag - style.appendChild(document.createTextNode(css)); - style.id = style_id; - - // Add the style tag to the document's head - head.appendChild(style); - } - - /** - * Given the ulabel object and a filter value, go through each annotation and decide whether or - * not to deprecate it. - * - * @param ulabel ULabel object - * @param filter_value The number between 0-100 which annotation's confidence is compared against - * @param redraw whether or not to redraw the annotations after filtering - * @returns Annotations that were modified, organized by subtask key - */ - private filter_annotations(ulabel: ULabel, filter_value: number | null = null, redraw: boolean = false): void { - if (filter_value === null) { - // Use stored filter value if none is passed in - filter_value = Math.round(this.filter_value * 100); - } - // Store which annotations need to be redrawn - const annotations_ids_to_redraw_by_subtask: { [key: string]: string[] } = {}; - // Initialize the object with the subtask keys - for (const subtask_key in ulabel.subtasks) { - annotations_ids_to_redraw_by_subtask[subtask_key] = []; - } - - // Get all point annotations - const point_and_line_annotations = get_point_and_line_annotations(ulabel); - for (const annotation of point_and_line_annotations[0]) { - // Get the annotation's confidence as decimal between 0-1 - let confidence: number = this.get_confidence(annotation); - - // filter_value will be a number between 0-100, so convert the confidence to a percentage as well - confidence = Math.round(confidence * 100); - - // Compare the confidence value against the filter value - const should_deprecate: boolean = this.filter_function(confidence, filter_value); - // Check if an annotation should be deprecated or undeprecated, else do nothing - if ( - (should_deprecate && !annotation.deprecated) || - (!should_deprecate && annotation.deprecated) - ) { - // Mark this annotation as either deprecated or undeprecated by the confidence filter - this.mark_deprecated(annotation, should_deprecate, "confidence_filter"); - annotations_ids_to_redraw_by_subtask[annotation.subtask_key!].push(annotation.id!); - } - } - - if (redraw) { - // Redraw each subtask's annotations - for (const subtask_key in annotations_ids_to_redraw_by_subtask) { - ulabel.redraw_multiple_spatial_annotations(annotations_ids_to_redraw_by_subtask[subtask_key], subtask_key); - } - // Update class counter - ulabel.toolbox.redraw_update_items(ulabel); - } - } - - public get_html() { - // Create a SliderHandler instance to handle slider interactions - const slider_handler = new SliderHandler({ - id: this.name.replaceLowerConcat(" ", "-"), - class: "keypoint-slider", - default_value: Math.round(this.filter_value * 100).toString(), - label_units: "%", - slider_event: (slider_value: number | string) => { - // Filter the annotations, then redraw them - this.filter_annotations(this.ulabel, Number(slider_value), true); - }, - }); - - return ` -
-

${this.name}

- ` + slider_handler.getSliderHTML() + ` -
- `; - } - - /** - * Get the current keypoint slider value by reading the DOM slider element. - * - * @returns The current slider value as a number between 0 and 1, or null if the slider is not found - */ - public get_current_value(): number | null { - const slider = document.querySelector(`#${this.slider_bar_id}`); - if (slider === null) return null; - return slider.valueAsNumber / 100; - } - - public after_init() { - // This toolbox item doesn't need to do anything after initialization - } - - public get_toolbox_item_type() { - return "KeypointSlider"; - } -} +/* ConfidenceSlider and the deprecated KeypointSliderItem now live in + * ./toolbox_items/confidence_slider.ts */ export class FilterPointDistanceFromRow extends ToolboxItem { name!: string; // Component name shown to users diff --git a/src/toolbox_items/confidence_slider.ts b/src/toolbox_items/confidence_slider.ts new file mode 100644 index 00000000..93c8aa85 --- /dev/null +++ b/src/toolbox_items/confidence_slider.ts @@ -0,0 +1,593 @@ +import type { + ULabel, + ConfidenceSliderConfig, + ConfidenceSliderClasses, + ULabelSpatialType, + ValidDeprecatedBy, +} from "../../index"; +import { ToolboxItem } from "../toolbox"; +import { ULabelAnnotation } from "../annotation"; +import { DEFAULT_CONFIDENCE_SLIDER_CONFIG } from "../configuration"; +import { SliderHandler } from "../html_builder"; +import { + get_annotation_confidence, + get_annotation_confidence_for_class, + get_annotation_class_id, + get_spatial_annotations_with_confidence, + value_is_lower_than_filter, + mark_deprecated, + findAllClassDefinitions, + get_point_and_line_annotations, + CONFIDENCE_FILTERABLE_SPATIAL_TYPES, +} from "../annotation_operators"; + +/** + * ToolboxItem for filtering (deprecating/showing) spatial annotations by their confidence. + * + * Supports two modes: + * - Single-class mode: one slider applies a confidence threshold to all targeted spatial + * annotations across every subtask, using each annotation's highest confidence. + * - Class mode: one slider per targeted class id. Each slider filters only the annotations + * whose assigned (highest-confidence) class matches, using that class's confidence. + * + * The `class_filter_mode` config controls whether the mode is user-toggleable (`"toggle"`), or + * locked to `"all-only"` (single global slider) or `"class-only"` (per-class sliders). + */ +export class ConfidenceSlider extends ToolboxItem { + public ulabel: ULabel; + public config!: ConfidenceSliderConfig; + public name!: string; + public filter_min!: number; + public filter_max!: number; + public step_value!: number; + public default_values!: ConfidenceSliderClasses; + public filter_on_load!: boolean; + public target_spatial_types!: ULabelSpatialType[]; + public target_class_ids!: number[] | null; + public collapse_options: boolean = false; + + // Whether the class-filter-mode toggle is shown, and whether per-class sliders are active + public show_class_toggle!: boolean; + public is_class_mode!: boolean; + + public filter_function: (value: number, filter: number) => boolean; + public get_confidence: (annotation: ULabelAnnotation) => number; + public mark_deprecated: ( + annotation: ULabelAnnotation, + deprecated: boolean, + deprecated_by_key?: ValidDeprecatedBy, + ) => void; + + // The deprecated_by key this item uses. Overridden by deprecated subclasses. + protected deprecated_by_key: ValidDeprecatedBy = "confidence_slider"; + + // DOM naming used to build and read this item's sliders + protected component_prefix: string = "confidence-slider"; + protected slider_class: string = "confidence-slider-input"; + + // TODO (joshua-dean): See if we can narrow this any + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any + constructor(ulabel: ULabel, kwargs: { [name: string]: any } | null = null) { + super(); + this.ulabel = ulabel; + + // Default the deprecating utilities. Subclasses may override in their own constructor. + this.filter_function = value_is_lower_than_filter; + this.get_confidence = get_annotation_confidence; + this.mark_deprecated = mark_deprecated; + + // Deprecated subclasses (e.g. KeypointSliderItem) perform their own setup. + if (this.get_toolbox_item_type() !== "ConfidenceSlider") return; + + // Get this component's config from ulabel's config, defaulting any missing keys + this.config = this.ulabel.config.confidence_slider_toolbox_item ?? {}; + for (const key in DEFAULT_CONFIDENCE_SLIDER_CONFIG) { + if (!Object.prototype.hasOwnProperty.call(this.config, key)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.config as any)[key] = (DEFAULT_CONFIDENCE_SLIDER_CONFIG as any)[key]; + } + } + + // Assign config properties to this instance + this.name = this.config.name!; + this.filter_min = this.config.filter_min!; + this.filter_max = this.config.filter_max!; + this.step_value = this.config.step_value!; + // Always ensure an "all" threshold exists (used by the global slider and as the per-class + // fallback), even if a user supplies default_values with only per-class keys. + this.default_values = { all: 0, ...this.config.default_values }; + this.filter_on_load = this.config.filter_on_load!; + this.target_spatial_types = this.config.target_spatial_types ?? CONFIDENCE_FILTERABLE_SPATIAL_TYPES; + this.target_class_ids = this.config.target_class_ids ?? null; + + // Resolve the class-filter-mode option into runtime state + const class_filter_mode = this.config.class_filter_mode!; + this.show_class_toggle = class_filter_mode === "toggle"; + this.is_class_mode = class_filter_mode === "class-only"; + + this.add_styles(); + this.add_event_listeners(); + + // Filter on load if configured + if (this.filter_on_load) { + this.filter_annotations(false); + } + } + + /** + * Create the css for this ToolboxItem and append it to the page. + */ + protected add_styles() { + const css = ` + #toolbox div.confidence-slider { + text-align: left; + } + + #toolbox div.confidence-slider fieldset.confidence-slider-options { + display: inline-block; + position: relative; + left: 1rem; + margin-bottom: 0.5rem; + font-size: 80%; + user-select: none; + } + + #toolbox div.confidence-slider fieldset.confidence-slider-options legend { + border-radius: 0.1rem; + padding: 0.1rem 0.3rem; + cursor: pointer; + } + + #toolbox div.confidence-slider fieldset.confidence-slider-options legend:hover { + background-color: rgba(128, 128, 128, 0.3); + } + + #toolbox div.confidence-slider fieldset.confidence-slider-options.ulabel-collapsed { + border: none; + margin-bottom: 0; + padding: 0; + padding-left: calc(0.75em + 2px); + } + + #toolbox div.confidence-slider fieldset.confidence-slider-options.ulabel-collapsed :not(legend) { + display: none; + } + + #toolbox div.confidence-slider fieldset.confidence-slider-options input[type="checkbox"] { + margin: 0; + } + + #toolbox div.confidence-slider fieldset.confidence-slider-options label { + position: relative; + top: -0.2rem; + font-size: smaller; + }`; + + const style_id = "confidence-slider-toolbox-item-styles"; + + // Don't add the style tag if its already been added once + if (document.getElementById(style_id)) return; + + const head = document.head || document.querySelector("head"); + const style = document.createElement("style"); + style.appendChild(document.createTextNode(css)); + style.id = style_id; + head.appendChild(style); + } + + private add_event_listeners(): void { + // Toggle the options fieldset when its legend is clicked + $(document).on("click.ulabel", "fieldset.confidence-slider-options > legend", () => this.toggleCollapsedOptions()); + + // Switch between the "all" slider and the per-class sliders when the checkbox is clicked + $(document).on("click.ulabel", `#${this.component_prefix}-multi-checkbox`, (event) => { + this.is_class_mode = event.currentTarget.checked; + $(`#${this.component_prefix}-single-class-mode`).toggleClass("ulabel-hidden"); + $(`#${this.component_prefix}-multi-class-mode`).toggleClass("ulabel-hidden"); + this.filter_annotations(true); + }); + } + + private toggleCollapsedOptions(): void { + $("fieldset.confidence-slider-options").toggleClass("ulabel-collapsed"); + this.collapse_options = !this.collapse_options; + } + + /** + * Get the class definitions that should receive a slider in multi-class mode. + */ + private get_target_class_defs() { + let class_defs = findAllClassDefinitions(this.ulabel, this.target_spatial_types); + if (this.target_class_ids !== null) { + class_defs = class_defs.filter((class_def) => this.target_class_ids!.includes(class_def.id)); + } + return class_defs; + } + + /** + * Read the current threshold values, preferring the DOM sliders and falling back to defaults. + * + * @returns A map of class identifiers (and "all") to their threshold values + */ + public get_filter_values(): ConfidenceSliderClasses { + // Seed with defaults so filtering works before the sliders are rendered (e.g. on load). + const values: ConfidenceSliderClasses = { all: this.default_values.all }; + + // In class mode, seed every targeted class (those that get a slider) with its explicit + // default or the "all" fallback. This matches the rendered slider defaults from + // createMultiFilterHTML(), so class-mode filter-on-load behaves consistently before the + // DOM sliders exist. + if (this.is_class_mode) { + for (const class_def of this.get_target_class_defs()) { + values[class_def.id] = this.default_values[class_def.id] ?? this.default_values.all; + } + } + + // Read the single-class slider if present + const all_slider = document.querySelector(`#${this.component_prefix}-all`); + if (all_slider !== null) { + values.all = all_slider.valueAsNumber; + } + + // Read the per-class sliders if present (override the seeded defaults) + const sliders = document.querySelectorAll(`.${this.slider_class}`); + for (let idx = 0; idx < sliders.length; idx++) { + const slider_class_name = /[^-]*$/.exec(sliders[idx].id)![0]; + if (slider_class_name === "all") continue; + values[slider_class_name] = sliders[idx].valueAsNumber; + } + + return values; + } + + /** + * Deprecate or show every targeted spatial annotation across all subtasks based on the + * current threshold values and mode. + * + * @param redraw whether or not to redraw the annotations after filtering + */ + public filter_annotations(redraw: boolean = false): void { + const values = this.get_filter_values(); + const annotations = get_spatial_annotations_with_confidence(this.ulabel, this.target_spatial_types); + + // Store which annotations need to be redrawn, organized by subtask key + const annotations_ids_to_redraw_by_subtask: { [key: string]: string[] } = {}; + for (const subtask_key in this.ulabel.subtasks) { + annotations_ids_to_redraw_by_subtask[subtask_key] = []; + } + + for (const annotation of annotations) { + let should_deprecate = false; + + if (this.is_class_mode) { + // In class-only mode, only filter annotations whose assigned class has a slider + const class_id = Number(get_annotation_class_id(annotation)); + if (values[class_id] !== undefined) { + const confidence = Math.round(get_annotation_confidence_for_class(annotation, class_id) * 100); + should_deprecate = this.filter_function(confidence, values[class_id]); + } + } else { + // In single-class mode, filter all targeted annotations by their highest confidence + const confidence = Math.round(this.get_confidence(annotation) * 100); + should_deprecate = this.filter_function(confidence, values.all); + } + + // Mark deprecated and, if the visible state changed, queue a redraw + const was_deprecated = annotation.deprecated; + this.mark_deprecated(annotation, should_deprecate, this.deprecated_by_key); + if (annotation.deprecated !== was_deprecated) { + annotations_ids_to_redraw_by_subtask[annotation.subtask_key!].push(annotation.id!); + } + } + + if (redraw) { + for (const subtask_key in annotations_ids_to_redraw_by_subtask) { + this.ulabel.redraw_multiple_spatial_annotations(annotations_ids_to_redraw_by_subtask[subtask_key], subtask_key); + } + this.ulabel.toolbox.redraw_update_items(this.ulabel); + } + } + + /** + * Builds one slider per targeted class for multi-class mode. + */ + private createMultiFilterHTML(): string { + const class_defs = this.get_target_class_defs(); + + let multi_class_html = ``; + for (const class_def of class_defs) { + const default_value = ( + this.default_values[class_def.id] !== undefined ? + this.default_values[class_def.id] : + this.default_values.all + ).toString(); + + const slider = new SliderHandler({ + id: `${this.component_prefix}-${class_def.id}`, + class: `${this.slider_class} ${this.slider_class}-class`, + min: this.filter_min.toString(), + max: this.filter_max.toString(), + default_value: default_value, + step: this.step_value.toString(), + label_units: "%", + main_label: class_def.name, + slider_event: () => this.filter_annotations(true), + }); + + multi_class_html += slider.getSliderHTML(); + } + + return multi_class_html; + } + + public get_html(): string { + // Only build per-class sliders when they can actually be shown (class-only or toggle mode). + // In all-only mode the multi-class container is never revealed, so skip the wasted DOM and + // SliderHandler event listeners. + const multi_class_html = (this.is_class_mode || this.show_class_toggle) ? this.createMultiFilterHTML() : ""; + + const single_class_slider = new SliderHandler({ + id: `${this.component_prefix}-all`, // "all" is extracted using regex + class: this.slider_class, + default_value: this.default_values.all.toString(), + min: this.filter_min.toString(), + max: this.filter_max.toString(), + step: this.step_value.toString(), + label_units: "%", + slider_event: () => this.filter_annotations(true), + }); + + let options_html = ``; + if (this.show_class_toggle) { + options_html = ` +
+ Options ˅ +
+ + +
+
`; + } + + return ` +
+

${this.name}

+ ${options_html} +
+ ${single_class_slider.getSliderHTML()} +
+
+ ${multi_class_html} +
+
+ `; + } + + /** + * Get the current confidence slider values by reading the DOM slider elements. + * + * @returns A map of class identifiers to their threshold values, or null if no sliders are found + */ + public get_current_values(): ConfidenceSliderClasses | null { + const all_slider = document.querySelector(`#${this.component_prefix}-all`); + if (all_slider === null) return null; + + const values: ConfidenceSliderClasses = { all: all_slider.valueAsNumber }; + + // In class-only mode, also read the per-class sliders + if (this.is_class_mode) { + const sliders = document.querySelectorAll(`.${this.slider_class}`); + for (let idx = 0; idx < sliders.length; idx++) { + const slider_class_name = /[^-]*$/.exec(sliders[idx].id)![0]; + if (slider_class_name === "all") continue; + values[slider_class_name] = sliders[idx].valueAsNumber; + } + } + + return values; + } + + public after_init() { + // This toolbox item doesn't need to do anything after initialization + } + + public get_toolbox_item_type() { + return "ConfidenceSlider"; + } +} + +/** + * ToolboxItem for filtering point ("keypoint") annotations by their confidence. + * + * @deprecated Use {@link ConfidenceSlider} (`AllowedToolboxItem.ConfidenceSlider`) instead, + * which supports all spatial annotation types, per-class targeting, and multiple sliders. + * This item is retained for backwards compatibility and only filters point annotations. + */ +export class KeypointSliderItem extends ConfidenceSlider { + public html!: string; + public inner_HTML: string; + public slider_bar_id: string; + + filter_value: number = 0; + keybinds: { + increment: string; + decrement: string; + }; + + // TODO (joshua-dean): See if we can narrow this any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(ulabel: ULabel, kwargs: { [name: string]: any }) { + super(ulabel); + this.inner_HTML = `

Keypoint Slider

`; + this.ulabel = ulabel; + + // Use properties in kwargs if kwargs is present + if (kwargs !== undefined) { + this.name = kwargs.name; + this.filter_function = kwargs.filter_function; + this.get_confidence = kwargs.confidence_function; + this.mark_deprecated = kwargs.mark_deprecated; + this.keybinds = kwargs.keybinds; + } else { + // Otherwise use defaults + this.name = "Keypoint Slider"; + this.filter_function = value_is_lower_than_filter; + this.get_confidence = get_annotation_confidence; + this.mark_deprecated = mark_deprecated; + this.keybinds = { + increment: "2", + decrement: "1", + }; + } + + // Create slider bar id + this.slider_bar_id = this.name.replaceLowerConcat(" ", "-"); + + // If the config has a default value override the filter_value + const has_filter_override = Object.prototype.hasOwnProperty.call( + this.ulabel.config, + this.name.replaceLowerConcat(" ", "_", "_default_value"), + ); + if (has_filter_override) { + // Set the filter value + this.filter_value = this.ulabel.config[this.name.replaceLowerConcat(" ", "_", "_default_value")]; + } + + // Check the config to see if we should update the annotations with the default filter on load + if (this.ulabel.config.filter_annotations_on_load) { + this.filter_keypoint_annotations(this.ulabel); + } + + this.add_styles(); + } + + /** + * Create the css for this ToolboxItem and append it to the page. + */ + protected add_styles() { + // Define the css + const css = ` + /* Component has no css?? */ + `; + // Create an id so this specific style tag can be referenced + const style_id = "keypoint-slider-toolbox-item-styles"; + + // Don't add the style tag if its already been added once + if (document.getElementById(style_id)) return; + + // Grab the document's head and create a style tag + const head = document.head || document.querySelector("head"); + const style = document.createElement("style"); + + // Add the css and id to the style tag + style.appendChild(document.createTextNode(css)); + style.id = style_id; + + // Add the style tag to the document's head + head.appendChild(style); + } + + /** + * Given the ulabel object and a filter value, go through each point annotation and decide + * whether or not to deprecate it. + * + * @param ulabel ULabel object + * @param filter_value The number between 0-100 which annotation's confidence is compared against + * @param redraw whether or not to redraw the annotations after filtering + */ + private filter_keypoint_annotations(ulabel: ULabel, filter_value: number | null = null, redraw: boolean = false): void { + if (filter_value === null) { + // Use stored filter value if none is passed in + filter_value = Math.round(this.filter_value * 100); + } + // Store which annotations need to be redrawn + const annotations_ids_to_redraw_by_subtask: { [key: string]: string[] } = {}; + // Initialize the object with the subtask keys + for (const subtask_key in ulabel.subtasks) { + annotations_ids_to_redraw_by_subtask[subtask_key] = []; + } + + // Get all point annotations + const point_and_line_annotations = get_point_and_line_annotations(ulabel); + for (const annotation of point_and_line_annotations[0]) { + // Get the annotation's confidence as decimal between 0-1 + let confidence: number = this.get_confidence(annotation); + + // filter_value will be a number between 0-100, so convert the confidence to a percentage as well + confidence = Math.round(confidence * 100); + + // Compare the confidence value against the filter value + const should_deprecate: boolean = this.filter_function(confidence, filter_value); + // Check if an annotation should be deprecated or undeprecated, else do nothing + if ( + (should_deprecate && !annotation.deprecated) || + (!should_deprecate && annotation.deprecated) + ) { + // Mark this annotation as either deprecated or undeprecated by the confidence filter + this.mark_deprecated(annotation, should_deprecate, "confidence_filter"); + annotations_ids_to_redraw_by_subtask[annotation.subtask_key!].push(annotation.id!); + } + } + + if (redraw) { + // Redraw each subtask's annotations + for (const subtask_key in annotations_ids_to_redraw_by_subtask) { + ulabel.redraw_multiple_spatial_annotations(annotations_ids_to_redraw_by_subtask[subtask_key], subtask_key); + } + // Update class counter + ulabel.toolbox.redraw_update_items(ulabel); + } + } + + public get_html() { + // Create a SliderHandler instance to handle slider interactions + const slider_handler = new SliderHandler({ + id: this.name.replaceLowerConcat(" ", "-"), + class: "keypoint-slider", + default_value: Math.round(this.filter_value * 100).toString(), + label_units: "%", + slider_event: (slider_value: number | string) => { + // Filter the annotations, then redraw them + this.filter_keypoint_annotations(this.ulabel, Number(slider_value), true); + }, + }); + + return ` +
+

${this.name}

+ ` + slider_handler.getSliderHTML() + ` +
+ `; + } + + /** + * Get the current keypoint slider value by reading the DOM slider element. + * + * @returns The current slider value as a number between 0 and 1, or null if the slider is not found + */ + public get_current_value(): number | null { + const slider = document.querySelector(`#${this.slider_bar_id}`); + if (slider === null) return null; + return slider.valueAsNumber / 100; + } + + public after_init() { + // This toolbox item doesn't need to do anything after initialization + } + + public get_toolbox_item_type() { + return "KeypointSlider"; + } +} diff --git a/src/utilities.ts b/src/utilities.ts index 7388f8ca..0558c5c7 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -2,7 +2,6 @@ * File for storing useful utilities that are not strictly ULabel related. */ -// Import ULabel from ../src/index - TypeScript will find ../src/index.d.ts for types import { ULabel } from "../src/index"; import { ULabelSubtask } from "./subtask"; import { DELETE_CLASS_ID, DELETE_MODES } from "./annotation"; diff --git a/src/version.js b/src/version.js index 655de88a..386b2d32 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const ULABEL_VERSION = "0.23.7"; +export const ULABEL_VERSION = "0.24.0"; diff --git a/tests/annotation_operators.test.js b/tests/annotation_operators.test.js new file mode 100644 index 00000000..f56d2c74 --- /dev/null +++ b/tests/annotation_operators.test.js @@ -0,0 +1,152 @@ +// Unit tests for confidence-slider-related annotation operators +const { + get_annotation_confidence_for_class, + get_spatial_annotations_with_confidence, + findAllClassDefinitions, + findAllPolylineClassDefinitions, + CONFIDENCE_FILTERABLE_SPATIAL_TYPES, +} = require("../build/annotation_operators"); + +function make_annotation(spatial_type, classification_payloads, extra = {}) { + return { + spatial_type, + classification_payloads, + deprecated: false, + ...extra, + }; +} + +function make_ulabel(subtasks) { + return { subtasks }; +} + +describe("get_annotation_confidence_for_class", () => { + test("returns the confidence for the matching class id", () => { + const annotation = make_annotation("bbox", [ + { class_id: 1, confidence: 0.9 }, + { class_id: 2, confidence: 0.1 }, + ]); + expect(get_annotation_confidence_for_class(annotation, 1)).toBe(0.9); + expect(get_annotation_confidence_for_class(annotation, 2)).toBe(0.1); + }); + + test("returns -1 when the class id is not present", () => { + const annotation = make_annotation("bbox", [{ class_id: 1, confidence: 0.9 }]); + expect(get_annotation_confidence_for_class(annotation, 99)).toBe(-1); + }); + + test("returns -1 (does not throw) when classification_payloads is missing", () => { + expect(get_annotation_confidence_for_class({ classification_payloads: null }, 1)).toBe(-1); + expect(get_annotation_confidence_for_class({}, 1)).toBe(-1); + }); +}); + +describe("CONFIDENCE_FILTERABLE_SPATIAL_TYPES", () => { + test("includes the spatial modes and excludes the non-spatial modes", () => { + expect(CONFIDENCE_FILTERABLE_SPATIAL_TYPES).toEqual( + expect.arrayContaining(["contour", "polygon", "polyline", "bbox", "tbar", "bbox3", "point"]), + ); + expect(CONFIDENCE_FILTERABLE_SPATIAL_TYPES).not.toContain("whole-image"); + expect(CONFIDENCE_FILTERABLE_SPATIAL_TYPES).not.toContain("global"); + }); +}); + +describe("get_spatial_annotations_with_confidence", () => { + test("collects spatial annotations with confidence across all subtasks and sets subtask_key", () => { + const bbox = make_annotation("bbox", [{ class_id: 1, confidence: 0.8 }], { id: "a" }); + const polygon = make_annotation("polygon", [{ class_id: 2, confidence: 0.5 }], { id: "b" }); + const ulabel = make_ulabel({ + st1: { annotations: { access: { a: bbox } } }, + st2: { annotations: { access: { b: polygon } } }, + }); + + const result = get_spatial_annotations_with_confidence(ulabel); + + expect(result).toHaveLength(2); + expect(bbox.subtask_key).toBe("st1"); + expect(polygon.subtask_key).toBe("st2"); + }); + + test("excludes non-spatial modes (whole-image and global)", () => { + const whole_image = make_annotation("whole-image", [{ class_id: 1, confidence: 1 }], { id: "a" }); + const global = make_annotation("global", [{ class_id: 1, confidence: 1 }], { id: "b" }); + const point = make_annotation("point", [{ class_id: 1, confidence: 1 }], { id: "c" }); + const ulabel = make_ulabel({ + st1: { annotations: { access: { a: whole_image, b: global, c: point } } }, + }); + + const result = get_spatial_annotations_with_confidence(ulabel); + + expect(result).toHaveLength(1); + expect(result[0].spatial_type).toBe("point"); + }); + + test("excludes annotations without a classification payload", () => { + const with_payload = make_annotation("bbox", [{ class_id: 1, confidence: 1 }], { id: "a" }); + const without_payload = make_annotation("bbox", [], { id: "b" }); + const null_payload = make_annotation("bbox", null, { id: "c" }); + const ulabel = make_ulabel({ + st1: { annotations: { access: { a: with_payload, b: without_payload, c: null_payload } } }, + }); + + const result = get_spatial_annotations_with_confidence(ulabel); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("a"); + }); + + test("respects the spatial_types filter argument", () => { + const bbox = make_annotation("bbox", [{ class_id: 1, confidence: 1 }], { id: "a" }); + const polygon = make_annotation("polygon", [{ class_id: 2, confidence: 1 }], { id: "b" }); + const ulabel = make_ulabel({ + st1: { annotations: { access: { a: bbox, b: polygon } } }, + }); + + const result = get_spatial_annotations_with_confidence(ulabel, ["polygon"]); + + expect(result).toHaveLength(1); + expect(result[0].spatial_type).toBe("polygon"); + }); +}); + +describe("findAllClassDefinitions", () => { + const ulabel = make_ulabel({ + st1: { + allowed_modes: ["bbox", "polygon"], + class_defs: [ + { name: "Car", id: 1, color: "red", keybind: "1" }, + { name: "Truck", id: 2, color: "blue", keybind: "2" }, + { name: "Delete", id: -1, color: "gray", keybind: null }, + ], + }, + st2: { + allowed_modes: ["polyline"], + class_defs: [ + { name: "Car", id: 1, color: "red", keybind: "1" }, // duplicate id + { name: "Row", id: 3, color: "green", keybind: "3" }, + ], + }, + }); + + test("returns de-duplicated class defs across subtasks and skips the delete class", () => { + const result = findAllClassDefinitions(ulabel); + const ids = result.map((cd) => cd.id); + + expect(ids).toEqual([1, 2, 3]); + expect(ids).not.toContain(-1); + }); + + test("restricts to subtasks that allow one of the provided modes", () => { + const result = findAllClassDefinitions(ulabel, ["polyline"]); + const ids = result.map((cd) => cd.id); + + expect(ids).toEqual([1, 3]); + }); + + test("findAllPolylineClassDefinitions only includes polyline subtasks", () => { + const result = findAllPolylineClassDefinitions(ulabel); + const ids = result.map((cd) => cd.id); + + expect(ids).toEqual([1, 3]); + }); +}); diff --git a/tests/confidence_slider.test.js b/tests/confidence_slider.test.js new file mode 100644 index 00000000..cfc177ae --- /dev/null +++ b/tests/confidence_slider.test.js @@ -0,0 +1,398 @@ +// Unit tests for the ConfidenceSlider toolbox item +// NOTE: require `configuration` before `confidence_slider`. `configuration` pulls in `toolbox` +// (which defines the `ToolboxItem` base class) and then `confidence_slider`, ensuring the base +// class is initialized before `ConfidenceSlider extends ToolboxItem` evaluates. Requiring +// `confidence_slider` first hits the circular import before `ToolboxItem` is defined. +const { Configuration, AllowedToolboxItem } = require("../build/configuration"); +const { ConfidenceSlider } = require("../build/toolbox_items/confidence_slider"); +// Loaded from the bundled build to exercise the ULabel public API method directly. +const { ULabel } = require("./testing-utils/build_loader"); + +/** + * Build a minimal annotation-like object. + */ +function make_annotation(id, spatial_type, payloads, extra = {}) { + return { + id, + spatial_type, + classification_payloads: payloads, + deprecated: false, + deprecated_by: { human: false }, + ...extra, + }; +} + +/** + * Build a minimal mock ULabel object with a single subtask. + */ +function make_ulabel(annotations, config = {}) { + const access = {}; + for (const annotation of annotations) { + access[annotation.id] = annotation; + } + return { + config: { confidence_slider_toolbox_item: config }, + subtasks: { + st: { + annotations: { access }, + allowed_modes: ["bbox", "polygon", "point", "polyline"], + class_defs: [ + { name: "Car", id: 10, color: "red", keybind: null }, + { name: "Truck", id: 11, color: "blue", keybind: null }, + ], + }, + }, + redraw_multiple_spatial_annotations: jest.fn(), + toolbox: { redraw_update_items: jest.fn() }, + }; +} + +describe("ConfidenceSlider", () => { + beforeEach(() => { + // Reset the DOM so slider lookups fall back to defaults + document.body.innerHTML = ""; + }); + + describe("construction and config resolution", () => { + test("applies defaults and identifies as ConfidenceSlider", () => { + const cs = new ConfidenceSlider(make_ulabel([], { filter_on_load: false })); + + expect(cs.get_toolbox_item_type()).toBe("ConfidenceSlider"); + expect(cs.filter_min).toBe(0); + expect(cs.filter_max).toBe(100); + expect(cs.step_value).toBe(1); + expect(cs.deprecated_by_key).toBe("confidence_slider"); + }); + + test("class_filter_mode 'toggle' shows the toggle and starts in all mode", () => { + const cs = new ConfidenceSlider(make_ulabel([], { class_filter_mode: "toggle", filter_on_load: false })); + expect(cs.show_class_toggle).toBe(true); + expect(cs.is_class_mode).toBe(false); + }); + + test("class_filter_mode 'all-only' hides the toggle and stays in all mode", () => { + const cs = new ConfidenceSlider(make_ulabel([], { class_filter_mode: "all-only", filter_on_load: false })); + expect(cs.show_class_toggle).toBe(false); + expect(cs.is_class_mode).toBe(false); + }); + + test("class_filter_mode 'class-only' hides the toggle and starts in class mode", () => { + const cs = new ConfidenceSlider(make_ulabel([], { class_filter_mode: "class-only", filter_on_load: false })); + expect(cs.show_class_toggle).toBe(false); + expect(cs.is_class_mode).toBe(true); + }); + + test("merges partial config with the defaults", () => { + const cs = new ConfidenceSlider(make_ulabel([], { filter_max: 50, filter_on_load: false })); + expect(cs.filter_max).toBe(50); // provided + expect(cs.filter_min).toBe(0); // default + expect(cs.step_value).toBe(1); // default + }); + + test("defaults an 'all' threshold when default_values omits it", () => { + const cs = new ConfidenceSlider(make_ulabel([], { + class_filter_mode: "class-only", + default_values: { 10: 60 }, // no "all" key + filter_on_load: false, + })); + expect(cs.default_values.all).toBe(0); + expect(cs.default_values[10]).toBe(60); + // get_html must not throw when "all" was omitted by the user + expect(() => cs.get_html()).not.toThrow(); + }); + }); + + describe("filter_annotations - all mode", () => { + test("deprecates annotations below the threshold and shows those at/above", () => { + const high = make_annotation("high", "bbox", [{ class_id: 10, confidence: 0.9 }]); + const mid = make_annotation("mid", "bbox", [{ class_id: 10, confidence: 0.5 }]); + const low = make_annotation("low", "bbox", [{ class_id: 10, confidence: 0.1 }]); + const ulabel = make_ulabel([high, mid, low], { + class_filter_mode: "all-only", + default_values: { all: 60 }, + filter_on_load: false, + }); + const cs = new ConfidenceSlider(ulabel); + + cs.filter_annotations(false); + + expect(high.deprecated).toBe(false); + expect(mid.deprecated).toBe(true); + expect(low.deprecated).toBe(true); + expect(mid.deprecated_by.confidence_slider).toBe(true); + }); + + test("a threshold of 0 deprecates nothing", () => { + const low = make_annotation("low", "bbox", [{ class_id: 10, confidence: 0.1 }]); + const cs = new ConfidenceSlider(make_ulabel([low], { + class_filter_mode: "all-only", + default_values: { all: 0 }, + filter_on_load: false, + })); + + cs.filter_annotations(false); + + expect(low.deprecated).toBe(false); + }); + + test("lowering the threshold un-deprecates previously filtered annotations", () => { + const low = make_annotation("low", "bbox", [{ class_id: 10, confidence: 0.1 }]); + const cs = new ConfidenceSlider(make_ulabel([low], { + class_filter_mode: "all-only", + default_values: { all: 50 }, + filter_on_load: false, + })); + + cs.filter_annotations(false); + expect(low.deprecated).toBe(true); + + // Lower the threshold and re-filter + cs.default_values.all = 0; + cs.filter_annotations(false); + + expect(low.deprecated).toBe(false); + expect(low.deprecated_by.confidence_slider).toBe(false); + }); + }); + + describe("filter_annotations - class mode", () => { + test("filters each class by its own threshold and ignores untargeted classes", () => { + const car = make_annotation("car", "bbox", [{ class_id: 10, confidence: 0.5 }]); + const truck = make_annotation("truck", "bbox", [{ class_id: 11, confidence: 0.5 }]); + const other = make_annotation("other", "bbox", [{ class_id: 12, confidence: 0.5 }]); + const cs = new ConfidenceSlider(make_ulabel([car, truck, other], { + class_filter_mode: "class-only", + default_values: { all: 0, 10: 60, 11: 30 }, + filter_on_load: false, + })); + + cs.filter_annotations(false); + + expect(car.deprecated).toBe(true); // 50 < 60 + expect(truck.deprecated).toBe(false); // 50 >= 30 + expect(other.deprecated).toBe(false); // class 12 has no slider + }); + + test("targets an annotation by its highest-confidence (argmax) class", () => { + // argmax class is 11 (0.8); the class-11 threshold should apply + const anno = make_annotation("multi", "bbox", [ + { class_id: 10, confidence: 0.3 }, + { class_id: 11, confidence: 0.8 }, + ]); + const cs = new ConfidenceSlider(make_ulabel([anno], { + class_filter_mode: "class-only", + default_values: { all: 0, 10: 90, 11: 50 }, + filter_on_load: false, + })); + + cs.filter_annotations(false); + + // 80 >= 50 (class-11 threshold) -> shown, even though 80 would fail class 10's 90 + expect(anno.deprecated).toBe(false); + }); + + test("does not filter classes excluded from target_class_ids, even if present in default_values", () => { + const targeted = make_annotation("targeted", "bbox", [{ class_id: 10, confidence: 0.1 }]); + const untargeted = make_annotation("untargeted", "bbox", [{ class_id: 11, confidence: 0.1 }]); + const cs = new ConfidenceSlider(make_ulabel([targeted, untargeted], { + class_filter_mode: "class-only", + target_class_ids: [10], + // class 11 has a threshold but is NOT targeted (no slider is rendered for it) + default_values: { all: 0, 10: 50, 11: 90 }, + filter_on_load: false, + })); + + cs.filter_annotations(false); + + expect(targeted.deprecated).toBe(true); // class 10 targeted: 10% < 50% + expect(untargeted.deprecated).toBe(false); // class 11 not targeted: must be ignored + }); + + test("filters on load using the 'all' fallback when per-class defaults are omitted", () => { + const car = make_annotation("car", "bbox", [{ class_id: 10, confidence: 0.1 }]); + const truck = make_annotation("truck", "bbox", [{ class_id: 11, confidence: 0.1 }]); + // filter_on_load runs during construction, before any DOM sliders exist + new ConfidenceSlider(make_ulabel([car, truck], { + class_filter_mode: "class-only", + default_values: { all: 50 }, // no explicit per-class thresholds + filter_on_load: true, + })); + + // Both classes should be filtered by the "all" fallback (10% < 50%) + expect(car.deprecated).toBe(true); + expect(truck.deprecated).toBe(true); + }); + }); + + describe("target_spatial_types", () => { + test("only filters the configured spatial types", () => { + const point = make_annotation("p", "point", [{ class_id: 10, confidence: 0.1 }]); + const box = make_annotation("b", "bbox", [{ class_id: 10, confidence: 0.1 }]); + const cs = new ConfidenceSlider(make_ulabel([point, box], { + class_filter_mode: "all-only", + default_values: { all: 50 }, + target_spatial_types: ["bbox"], + filter_on_load: false, + })); + + cs.filter_annotations(false); + + expect(box.deprecated).toBe(true); + expect(point.deprecated).toBe(false); + expect(point.deprecated_by.confidence_slider).toBeUndefined(); + }); + }); + + describe("deprecated_by OR-logic", () => { + test("does not un-deprecate an annotation deprecated by a human", () => { + const anno = make_annotation("h", "bbox", [{ class_id: 10, confidence: 0.9 }], { + deprecated: true, + deprecated_by: { human: true }, + }); + const cs = new ConfidenceSlider(make_ulabel([anno], { + class_filter_mode: "all-only", + default_values: { all: 0 }, + filter_on_load: false, + })); + + cs.filter_annotations(false); + + expect(anno.deprecated_by.confidence_slider).toBe(false); + expect(anno.deprecated).toBe(true); // still deprecated by human + }); + }); + + describe("filter_on_load", () => { + test("filters during construction when filter_on_load is true", () => { + const low = make_annotation("low", "bbox", [{ class_id: 10, confidence: 0.1 }]); + new ConfidenceSlider(make_ulabel([low], { + class_filter_mode: "all-only", + default_values: { all: 50 }, + filter_on_load: true, + })); + + expect(low.deprecated).toBe(true); + }); + + test("does not filter during construction when filter_on_load is false", () => { + const low = make_annotation("low", "bbox", [{ class_id: 10, confidence: 0.1 }]); + new ConfidenceSlider(make_ulabel([low], { + class_filter_mode: "all-only", + default_values: { all: 50 }, + filter_on_load: false, + })); + + expect(low.deprecated).toBe(false); + }); + }); + + describe("redraw", () => { + test("redraws changed annotations and updates toolbox items when redraw is true", () => { + const low = make_annotation("low", "bbox", [{ class_id: 10, confidence: 0.1 }]); + const ulabel = make_ulabel([low], { + class_filter_mode: "all-only", + default_values: { all: 50 }, + filter_on_load: false, + }); + const cs = new ConfidenceSlider(ulabel); + + cs.filter_annotations(true); + + expect(ulabel.redraw_multiple_spatial_annotations).toHaveBeenCalledWith(["low"], "st"); + expect(ulabel.toolbox.redraw_update_items).toHaveBeenCalled(); + }); + }); + + describe("get_current_values", () => { + test("returns null when no sliders are in the DOM", () => { + const cs = new ConfidenceSlider(make_ulabel([], { filter_on_load: false })); + expect(cs.get_current_values()).toBeNull(); + }); + + test("reads the single 'all' slider value", () => { + const cs = new ConfidenceSlider(make_ulabel([], { class_filter_mode: "all-only", filter_on_load: false })); + document.body.innerHTML = ``; + + expect(cs.get_current_values()).toEqual({ all: 40 }); + }); + + test("reads per-class slider values in class mode", () => { + const cs = new ConfidenceSlider(make_ulabel([], { class_filter_mode: "class-only", filter_on_load: false })); + document.body.innerHTML = ` + + + `; + + expect(cs.get_current_values()).toEqual({ + all: 40, + 10: 30, + }); + }); + }); + + describe("get_html", () => { + test("renders the multi-class toggle only in toggle mode", () => { + const toggle = new ConfidenceSlider(make_ulabel([], { class_filter_mode: "toggle", filter_on_load: false })); + expect(toggle.get_html()).toContain("confidence-slider-multi-checkbox"); + + const allOnly = new ConfidenceSlider(make_ulabel([], { class_filter_mode: "all-only", filter_on_load: false })); + expect(allOnly.get_html()).not.toContain("confidence-slider-multi-checkbox"); + }); + + test("target_class_ids limits which per-class sliders are rendered", () => { + const cs = new ConfidenceSlider(make_ulabel([], { + class_filter_mode: "class-only", + target_class_ids: [10], + filter_on_load: false, + })); + const html = cs.get_html(); + + expect(html).toContain("confidence-slider-all"); + expect(html).toContain("confidence-slider-10"); + expect(html).not.toContain("confidence-slider-11"); + }); + + test("does not render per-class sliders in all-only mode", () => { + const cs = new ConfidenceSlider(make_ulabel([], { class_filter_mode: "all-only", filter_on_load: false })); + const html = cs.get_html(); + + expect(html).toContain("confidence-slider-all"); + expect(html).not.toContain("confidence-slider-10"); + expect(html).not.toContain("confidence-slider-11"); + }); + }); +}); + +describe("Default toolbox order", () => { + test("includes ConfidenceSlider and not the deprecated KeypointSlider", () => { + const config = new Configuration(); + expect(config.toolbox_order).toContain(AllowedToolboxItem.ConfidenceSlider); + expect(config.toolbox_order).not.toContain(AllowedToolboxItem.KeypointSlider); + }); +}); + +describe("get_confidence_slider_value (tuple-safe lookup)", () => { + const slider_item = { + get_toolbox_item_type: () => "ConfidenceSlider", + get_current_values: () => ({ all: 25 }), + }; + const invoke = (toolbox_order, items) => + ULabel.prototype.get_confidence_slider_value.call({ + config: { toolbox_order }, + toolbox: { items }, + }); + + test("finds the item when configured as a plain enum entry", () => { + expect(invoke([AllowedToolboxItem.ConfidenceSlider], [slider_item])) + .toEqual({ all: 25 }); + }); + + test("finds the item when configured as an [enum, kwargs] tuple", () => { + expect(invoke([[AllowedToolboxItem.ConfidenceSlider, { step_value: 5 }]], [slider_item])) + .toEqual({ all: 25 }); + }); + + test("returns null when the item is not active", () => { + expect(invoke([AllowedToolboxItem.ConfidenceSlider], [])).toBeNull(); + }); +}); diff --git a/tests/e2e/confidence-slider.spec.js b/tests/e2e/confidence-slider.spec.js new file mode 100644 index 00000000..0a67de91 --- /dev/null +++ b/tests/e2e/confidence-slider.spec.js @@ -0,0 +1,108 @@ +// End-to-end tests for the ConfidenceSlider toolbox item. +// These exercise browser-specific behavior (DOM wiring, modes, config, coexistence warning) +// that the jest unit tests can't cover. They run against demo/multi-class.html, which enables +// both the ConfidenceSlider and the deprecated KeypointSlider and exposes window.ulabel. +import { test, expect } from "./fixtures"; +import { wait_for_ulabel_init } from "../testing-utils/init_utils"; + +test.describe("ConfidenceSlider", () => { + test("logs a warning when KeypointSlider and ConfidenceSlider are both enabled", async ({ page }) => { + const messages = []; + page.on("console", (msg) => messages.push(msg.text())); + + await wait_for_ulabel_init(page); + + expect( + messages.some((text) => text.includes("KeypointSlider") && text.includes("ConfidenceSlider")), + ).toBe(true); + }); + + test("reflects the configured step_value on the slider", async ({ page }) => { + await wait_for_ulabel_init(page); + + // multi-class.html configures step_value: 5 + const step = await page.evaluate(() => document.querySelector("#confidence-slider-all").getAttribute("step")); + expect(step).toBe("5"); + }); + + test("deprecates annotations below the threshold when the slider is raised", async ({ page }) => { + await wait_for_ulabel_init(page); + + const subtask_key = await page.evaluate(() => window.ulabel.get_current_subtask_key()); + const class_id = await page.evaluate(() => { + const subtask = window.ulabel.get_current_subtask(); + return subtask.class_defs.find((cd) => cd.id >= 0).id; + }); + + // Inject two bboxes with known confidences + await page.evaluate(({ stk, cid }) => { + const annotations = [ + { + id: "conf-test-low", + spatial_type: "bbox", + spatial_payload: [[100, 100], [200, 200]], + classification_payloads: [{ class_id: cid, confidence: 0.2 }], + }, + { + id: "conf-test-high", + spatial_type: "bbox", + spatial_payload: [[300, 300], [400, 400]], + classification_payloads: [{ class_id: cid, confidence: 0.9 }], + }, + ]; + window.ulabel.set_annotations(annotations, stk); + }, { stk: subtask_key, cid: class_id }); + + // Raise the single "all" slider to 50% + await page.evaluate(() => { + const slider = document.querySelector("#confidence-slider-all"); + slider.value = "50"; + slider.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const result = await page.evaluate((stk) => { + const annotations = window.ulabel.get_annotations(stk); + const low = annotations.find((a) => a.id === "conf-test-low"); + const high = annotations.find((a) => a.id === "conf-test-high"); + return { low: low.deprecated, high: high.deprecated }; + }, subtask_key); + + expect(result.low).toBe(true); // 20% < 50% + expect(result.high).toBe(false); // 90% >= 50% + }); + + test("shows per-class sliders when Per-Class Filtering is enabled", async ({ page }) => { + await wait_for_ulabel_init(page); + + // The per-class container starts hidden (default mode is the single "all" slider) + const hidden_before = await page.evaluate(() => + document.querySelector("#confidence-slider-multi-class-mode").classList.contains("ulabel-hidden"), + ); + expect(hidden_before).toBe(true); + + // Enable per-class filtering via the toggle checkbox + await page.click("#confidence-slider-multi-checkbox"); + + const hidden_after = await page.evaluate(() => + document.querySelector("#confidence-slider-multi-class-mode").classList.contains("ulabel-hidden"), + ); + expect(hidden_after).toBe(false); + + const class_slider_count = await page.evaluate(() => + document.querySelectorAll("#confidence-slider-multi-class-mode .confidence-slider-input").length, + ); + expect(class_slider_count).toBeGreaterThan(0); + }); + + test("get_confidence_slider_value includes per-class keys in per-class mode", async ({ page }) => { + await wait_for_ulabel_init(page); + + await page.click("#confidence-slider-multi-checkbox"); + + const value = await page.evaluate(() => window.ulabel.get_confidence_slider_value()); + + expect(value).toHaveProperty("all"); + const class_keys = Object.keys(value).filter((key) => key !== "all"); + expect(class_keys.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/slider-api.spec.js b/tests/e2e/slider-api.spec.js index 66a5b361..79a12acf 100644 --- a/tests/e2e/slider-api.spec.js +++ b/tests/e2e/slider-api.spec.js @@ -77,4 +77,41 @@ test.describe("Slider Public API", () => { expect(typeof value.closest_row.distance).toBe("number"); }); }); + + test.describe("get_confidence_slider_value", () => { + test("should return default confidence slider value after init", async ({ page }) => { + await wait_for_ulabel_init(page); + + const value = await page.evaluate(() => window.ulabel.get_confidence_slider_value()); + + // Should have the "all" key with the default threshold of 0 + expect(value).not.toBeNull(); + expect(value.all).toBe(0); + }); + + test("should reflect value after moving the slider", async ({ page }) => { + await wait_for_ulabel_init(page); + + // Set the single-class confidence slider to 60 + await page.evaluate(() => { + const slider = document.querySelector("#confidence-slider-all"); + slider.value = "60"; + slider.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const value = await page.evaluate(() => window.ulabel.get_confidence_slider_value()); + + expect(value.all).toBe(60); + }); + + test("should return an object with an all key", async ({ page }) => { + await wait_for_ulabel_init(page); + + const value = await page.evaluate(() => window.ulabel.get_confidence_slider_value()); + + expect(typeof value).toBe("object"); + expect(value).toHaveProperty("all"); + expect(typeof value.all).toBe("number"); + }); + }); }); diff --git a/tests/testing-utils/blobs_stub.js b/tests/testing-utils/blobs_stub.js new file mode 100644 index 00000000..0826c628 --- /dev/null +++ b/tests/testing-utils/blobs_stub.js @@ -0,0 +1,5 @@ +// Test stub for the ESM `src/blobs.js` module (SVG icon strings). +// The real file uses ESM `export` syntax which jest does not transform, and the SVG contents are +// irrelevant to unit tests. Any named import resolves to an empty string. See jest.config.js +// `moduleNameMapper`. +module.exports = new Proxy({}, { get: () => "" }); diff --git a/tests/testing-utils/version_stub.js b/tests/testing-utils/version_stub.js new file mode 100644 index 00000000..7a829953 --- /dev/null +++ b/tests/testing-utils/version_stub.js @@ -0,0 +1,4 @@ +// Test stub for the ESM `src/version.js` module. +// The real file uses ESM `export` syntax which jest does not transform, and its value is +// irrelevant to unit tests. See jest.config.js `moduleNameMapper`. +module.exports = { ULABEL_VERSION: "0.0.0-test" }; diff --git a/tests/types/index.test-d.ts b/tests/types/index.test-d.ts index 048e8127..bd0d7b62 100644 --- a/tests/types/index.test-d.ts +++ b/tests/types/index.test-d.ts @@ -86,4 +86,5 @@ const all_items: AllowedToolboxItem[] = [ AllowedToolboxItem.ImageFilters, AllowedToolboxItem.AnnotationList, AllowedToolboxItem.Keybinds, + AllowedToolboxItem.ConfidenceSlider, ];