diff --git a/.github/tasks.md b/.github/tasks.md index 1cd7fc41..2b7f9ad1 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -454,9 +454,12 @@ time than at save time. - [x] 6.1 Unterminated `/**` block in `annotation_operators.ts`, left behind by the 1.3 `mark_hidden` removal. -- [ ] 6.2 Mask barrier escape hatch. Read-only bitmasks still participate in - `resolve_bitmask_overlap`, so a `prediction` or `diff` mask invisibly clips - a GT brush stroke. Needed before segmentation editing ships. +- [x] 6.2 Brush strokes no longer interact with other subtasks' masks by + default: new `brush_overlap_across_subtasks` config flag (default false) + scopes overlap resolution to the active subtask. Supersedes the planned + read-only "barrier escape hatch": with the flag off a `prediction` or + `diff` mask cannot invisibly clip a GT stroke at all; with it on, + read-only masks still act as barriers under overwrite. - [ ] 6.3 (only if live GT editing during diff review is required) Apply an edit to a non-current subtask and record it in that subtask's undo stream. `set_subtask` clears hover and `fly_to_idx`, so a review queue cannot @@ -473,13 +476,60 @@ time than at save time. derived from (GT, run) and goes stale the moment GT is edited - the resolved FN keeps rendering as an FN. That is a repaint problem, which is what 7.9 solves without client-side rematching. -- [ ] 6.4 Two different `get_active_class_id` implementations disagree. The +- [x] 6.4 Two different `get_active_class_id` implementations disagree. The `ULabel` *method* (`index.js`) parses the selected toolbox anchor's id out of the DOM; the *utility* of the same name (`utilities.ts`) reads `state.id_payload`. The method throws outright before the toolbox has rendered, and the two can diverge whenever state changes without a DOM sync. Phase 5 uses the state-based one; the method's four remaining call sites should follow, and one of the two names should go. + RESOLVED in Phase 9: the method is now a thin delegate to the state-based + utility, so there is a single implementation (the DOM parse is gone). +- [x] 6.5 Switching subtasks left the Brush toolbox buttons lit: brush state + is per-subtask but the buttons are global, and `set_subtask` never tore the + outgoing brush down. Button display is now centralized in + `update_brush_toolbox_display()` (derived from current-subtask state) and + `set_subtask` disables the outgoing brush while it is still current. +- [x] 6.6 An opacity-0 subtask was still fully interactive (invisible + annotations could be created/edited). Vanish mode already encodes + "invisible implies non-interactive"; its gates (create, suggest_edits, + drag start, resize) now check a shared `is_subtask_hidden()` helper + (vanished OR opacity slider at 0). Draw gates deliberately still check + only `is_vanished`: opacity is CSS-only, so content must stay drawn for + the slider to reveal it without a redraw. +- [x] 6.7 Class keybinds edited live in the Keybinds toolbox item only wrote + the current subtask's class defs, while storage and the init restore are + id-keyed across all subtasks — shared-id classes (model-registry GT/pred) + showed the bind but didn't respond until a reload. Edits/resets now write + every subtask holding the id (`set_class_keybind_in_all_subtasks`). The + keypress listener's read-only gate also moved inside the reclass branch, + so selection keybinds work on read-only subtasks. +- [x] 6.8 Optional `show_loader = true` parameter on `set_annotations` / + `set_annotations_batch`: hosts pass false when every changed subtask is a + background layer, so the loading overlay doesn't flash over an unchanged + on-screen view. +- [x] 6.9 Host callback `on_focus_active_class_change(subtask_key, enabled)` + config option, fired from the single writer (`set_focus_active_class`, + which the focus keybind also funnels through) only on actual change — the + guard is what lets a host re-sync other subtasks from the callback + without recursing. +- [x] 6.10 Review fixes: `get_active_class_id_idx()` returned -1 in delete + modes (the active class resolves to DELETE_CLASS_ID, which has no index) + and three callers used it as an index — a class keybind press (through the + dead gate below) or an API `set_active_class` during a delete mode with an + annotation hovered zeroed its classification (class buttons are hidden in + delete modes, so no button path). Now falls back to the frozen real + selection, and `handle_id_dialog_click` rejects out-of-range indices. + Also: the class-keybind delete-mode gate read the never-assigned + `state.spatial_type` (now `annotation_mode`), `set_subtask` clears the + outgoing subtask's move/edit candidates, and `set_class_counter_options` / + `set_class_color(s)` gained `is_destroyed` guards. +- [x] 6.11 Layer opacity is cached in subtask state (`state.layer_opacity`) + instead of read from the slider DOM on every mousemove: + `readjust_subtask_opacities` is the DOM-to-state sync point (it already + runs on every slider input and at the end of `set_subtask`, covering all + slider writers) and `set_subtask_opacity` writes the cache directly. Also + removed the never-assigned `state.spatial_type` type field (the 6.10 trap). ### Phase 7 - model-registry (branch `three-fixed-subtasks` off `cropped-bitmasks-trevor`) @@ -572,6 +622,89 @@ time than at save time. the back-canvas removal) as a PR that can land immediately, architecture as another. Manual work, since `cropped-bitmasks`'s three commits mix both. +## Plan: fold class focus into class selection (edit-mode prep) + +Verified starting facts: `focused_class` is written only by `set_class_focus` +(host API; nothing in ULabel's UI touches it), toolbox class selection +(`id_payload`) is an independent axis, and model-registry never exercises the +null-focus state - `activeDiffLabel` falls back to `labels[0]` and +`activeOutcome` defaults `"tp"`, with no deselect gesture, so exactly one +class/outcome is focused at all times. Two axes that must always agree is +drift waiting for edit mode: the user could draw with a class that is +currently defocused. Fold focus into the selection under a per-subtask +opt-in, and give "set the active class" a real API instead of DOM clicks. + +### Phase 9 - ULabel: `set_active_class` + `focus_active_class` + +- [x] 9.1 Public `set_active_class(class_id, subtask_key?)`: extract the body + of `handle_soft_id_toolbox_button_click` (the `sel` swap, + `set_id_dialog_payload_nopin`, dialog display update, active-annotation + reclass, delete re-toggle, and the trailing + `sync_annotation_modes_to_active_class`) into the method; the DOM click + handler becomes a thin wrapper. Mode buttons stay correct because the sync + is the handler's last unconditional statement today. For a non-current + subtask, skip the button/DOM sync - `set_subtask` already runs the sync on + activation, so state is written now and the DOM reconciles on switch. + Subsumes 6.4: the state-based `get_active_class_id` (utilities) becomes + canonical and the DOM-parsing method's call sites migrate. +- [x] 9.2 Migrate the internal DOM-click workarounds to `set_active_class`: + `toggle_delete_class_id_in_toolbox` (3 trigger sites: delete-class on + entry; first class or hovered annotation's class on exit), + `update_id_toolbox_display` (state -> click -> handler -> state round + trip), the class keybind handler (`listeners.ts` class_button click), and + the soft-id handler's delete re-toggle. model-registry has no workarounds + to migrate (verified: zero `toolbox_sel`/`id_payload` references). +- [x] 9.3 Per-subtask `focus_active_class: boolean` (default false, so no + behavior change for vanilla consumers - the defocus gates restrict + hover/Tab/list, not just drawing, and must not engage unasked). When true, + focus derives from the *persistent* selection: a new + `get_selected_class_id(subtask)` resolves from `id_payload` and skips + `get_active_class_id`'s delete-mode `DELETE_CLASS_ID` short-circuit, so + focus freezes at the selected class during delete modes (no saved/restored + state, no dim flicker; precedent: the mode sync already bails on + `DELETE_CLASS_ID`). The delete-class button is excluded from the focus + path. Remove `set_class_focus` / `focused_class` as an independent axis; + migrate the `class_focus` tests to selection-driven focus. +- [x] 9.4 Focus-gate the bulk-delete collection loop + (`delete_polygon`/`delete_bbox`) on `is_annotation_defocused`. Restores + the protection the removed `hidden` machinery had, and is what makes + freeze-during-delete safe: focus scopes what is legible, interactive, + navigable - and deletable. Single-annotation delete is already gated via + hover (`get_edit_candidates` skips defocused). +- [x] 9.5 model-registry: sidebar class rows / outcome legend call + `set_active_class` instead of `set_class_focus`; all three subtasks set + `focus_active_class: true`. Matches the UI's actual invariant (always + exactly one focused class); the null-focus branch was only reachable with + an empty ontology. +- [x] 9.6 Enforce per-class modes on *reclassification*. The id-dialog pie, + the class keybind (already-selected branch), and `set_active_class`'s + reclass branch all funnel through `handle_id_dialog_click`, so one gate + there covers every gesture; `assign_annotation_id` stays ungated so + undo/redo replay history faithfully. New predicate + `can_annotation_be_class(annotation, class_id)` over + `get_class_allowed_modes`. Rejection: `shake_screen()` + quiet warning. + The gate checks only the *target* class, so an annotation whose current + class/type pairing is already invalid (bad import) can still be + reclassified to a valid class. +- [x] 9.7 The pie only offers classes compatible with the dialog's + annotation's spatial type (rebuilt per show when the compatible subset + changes; wedge hit-testing runs over the displayed subset and maps back to + the full class list). When fewer than two classes are compatible there is + nothing to choose, so no dialog appears at all. No shake on rejection - + the 9.6 gate stays as a silent backstop for keybind paths. +- [x] 9.8 Load-time validation: warn (never drop - the data is authoritative + and round-trips on export) when an imported annotation's class does not + allow its spatial type. NOTE: no subtask-level load check exists either + (`process_resume_from` only errors on *missing* type/payload); checking + against the class's effective modes covers both levels since class modes + are already a subset of the subtask's. +- [x] 9.9 Host callbacks `on_active_class_change(subtask_key, class_id)` and + `on_subtask_change(subtask_key, old_subtask_key)` config options, fired + from the single writers (`set_active_class` / `set_subtask`) only on + actual change. Needed because the Keybinds toolbox item lets users bind + class-select keys at runtime, so ULabel-side class changes are reachable + even when the host ships `keybind: null` and no id toolbox. + ### Verification - [ ] V3 ULabel: lint + jest + e2e green after each phase; 4.1-4.4 and @@ -584,3 +717,8 @@ time than at save time. GT annotation is corrected *in place* by the reclassify pie, keeping its id, `encord_object_hash` and undo coherence - no delete-and-recreate across subtasks. Must pass before the edit path ships. +- [ ] V6 Phase 9: with `focus_active_class` on, selecting a class (API, + toolbox button, or keybind) moves focus, dimming and mode buttons together; + entering a delete mode changes none of them; a delete polygon removes only + focused-class annotations. Vanilla configs (flag off) show zero behavior + change across the whole suite. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13a56693..f54c78a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,12 @@ jobs: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + # playwright's dep install runs apt-get update, which fails outright when any + # configured repo is mid-republish (hash mismatch). The runner image ships + # Google's and Microsoft's repos; this job needs neither, so drop them. + - name: Remove unneeded third-party apt repos + run: sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/apt/sources.list.d/microsoft-prod.list + - name: Install Playwright browsers if: steps.playwright-cache.outputs.cache-hit != 'true' run: npx playwright install --with-deps diff --git a/CHANGELOG.md b/CHANGELOG.md index 5026acb6..8a624fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,17 @@ All notable changes to this project will be documented here. -## [unreleased] - -## [0.28.0] - Sept 8th, 2026 +## [0.28.0] - Sept 10th, 2026 +- New `set_active_class(class_id, subtask_key?, redraw?)` and `get_selected_class_id(subtask_key?)` public API methods; all internal class-selection paths (toolbox clicks, keybinds, delete-mode toggles) route through `set_active_class`. +- New per-subtask `focus_active_class` option (default `false`): the selected class is the focused class — other classes dim to `defocused_opacity` and drop out of hover, Tab navigation, the annotation list, and bulk delete. Toggleable via `set_focus_active_class()` or `toggle_class_focus_keybind` (default `shift+f`). +- Per-class `allowed_modes` are now enforced on reclassification: the id-dialog pie only offers classes compatible with the annotation's spatial type; with fewer than two, no pie appears and the edit ring collapses. Importing an incompatible annotation logs a warning but still loads. +- New host callback config options `on_active_class_change`, `on_subtask_change`, and `on_focus_active_class_change`: fired from any writer (API, toolbox, keybind), and only when the value actually changes. +- New `brush_overlap_across_subtasks` config option (default `false`). **Behavior change**: brush overlap resolution now stays within the active subtask; reaching masks in other subtasks (including read-only barriers) is opt-in. +- `set_annotations()` and `set_annotations_batch()` gained an optional `show_loader` parameter (default `true`); pass `false` to swap background layers without flashing the loading overlay. +- A subtask with its layer opacity slider at 0 is now non-interactive, matching vanish mode (new `is_subtask_hidden()` helper). +- Fix the Brush/Erase toolbox buttons staying lit after a subtask switch. +- Fix class keybinds edited in the Keybinds toolbox item not applying to other subtasks sharing the class id until a reload; class-select keybinds now also work in read-only subtasks, and are correctly inert while a delete mode is active (the gate for this read a field that was never assigned). +- Fix stale containing boxes when `allow_annotations_outside_image = false` clamps loaded annotations at init. - Removed unused per-subtask back canvas. - `set_annotations()` gained a `skip_toolbox_update` parameter for batching several per-subtask swaps, plus a `refresh_toolbox()` method to run the deferred filter-distance + toolbox update once at the end. - `ClassCounter` toolbox item options via `class_counter_toolbox_item` config: `subtasks` (`string[] | "current"`) selects which subtasks to count, `layout` (`"current" | "grouped" | "flat"`) controls rendering (`grouped` adds a heading per subtask, `flat` merges shared class ids into one summed list). New `set_class_counter_options()` public API method changes them at runtime. diff --git a/api_spec.md b/api_spec.md index ac3fdda4..ab8f91b5 100644 --- a/api_spec.md +++ b/api_spec.md @@ -71,6 +71,7 @@ class ULabel({ decrease_brush_size_keybind: string, mask_annotation_opacity: number, default_brush_overlap_mode: BrushOverlapMode, + brush_overlap_across_subtasks: boolean, set_brush_overlap_none_keybind: string, set_brush_overlap_exclude_keybind: string, set_brush_overlap_overwrite_keybind: string, @@ -81,10 +82,14 @@ class ULabel({ annotation_size_plus_keybind: string, annotation_size_minus_keybind: string, annotation_vanish_keybind: string, + toggle_class_focus_keybind: string, fly_to_max_zoom: number, min_zoom_fit_ratio: number, n_annos_per_canvas: number, - auto_destroy_on_detach: boolean + auto_destroy_on_detach: boolean, + on_active_class_change: function, + on_subtask_change: function, + on_focus_active_class_change: function }) ``` @@ -310,7 +315,7 @@ The `"bitmask"` mode enables raster (per-pixel) segmentation. Each bitmask annot **Overlap modes** -When painting, the brush can enforce mutual exclusivity with *other* undeprecated bitmask annotations. The mode is a single **global** value, persisted to localStorage, and is chosen via the Brush toolbox item (shown in bitmask mode) or the overlap keybinds. Its initial value comes from [`default_brush_overlap_mode`](#default_brush_overlap_mode). +When painting, the brush can enforce mutual exclusivity with *other* undeprecated bitmask annotations. The mode is a single **global** value, persisted to localStorage, and is chosen via the Brush toolbox item (shown in bitmask mode) or the overlap keybinds. Its initial value comes from [`default_brush_overlap_mode`](#default_brush_overlap_mode). Resolution stays within the active subtask unless [`brush_overlap_across_subtasks`](#brush_overlap_across_subtasks) is set. - `"none"` (default): painting only adds to the active mask; other masks are untouched (pixels may be owned by multiple annotations). - `"exclude"`: newly-painted pixels never cover pixels owned by other bitmask annotations (existing masks win). @@ -608,6 +613,9 @@ The fill opacity (`0`-`1`) used when rendering `bitmask` (raster segmentation) a ### `default_brush_overlap_mode` The initial [brush overlap mode](#overlap-modes) for bitmask painting: `"none"` (default), `"exclude"`, or `"overwrite"`. The live value is global and persisted to localStorage, so a user's last choice takes precedence over this default on subsequent sessions. +### `brush_overlap_across_subtasks` +When `true`, [brush overlap resolution](#overlap-modes) also reaches undeprecated bitmask annotations in *other* subtasks: `"exclude"` clips the stroke against them, and `"overwrite"` carves them — except masks in `read_only` subtasks, which act as barriers (the stroke is clipped around them instead). Default is `false`: a stroke only interacts with masks in the active subtask. + ### `set_brush_overlap_none_keybind` Keybind to set the brush overlap mode to `none`. Default is `shift+n`. @@ -641,6 +649,9 @@ Keybind to toggle vanish mode for annotations in the current subtask. Default is ### `annotation_vanish_all_keybind` Keybind to toggle vanish mode for all subtasks. Default is `shift+v` +### `toggle_class_focus_keybind` +Keybind to toggle `focus_active_class` on the current subtask: with it on, classes other than the active one dim to `defocused_opacity` and drop out of hover, navigation, the annotation list, and bulk delete. Default is `shift+f`. + ### `fly_to_max_zoom` Maximum zoom factor used when flying-to an annotation. Default is `10`, value must be > `0`. @@ -661,6 +672,15 @@ When `true` (the default), ULabel installs a `MutationObserver` on the container > **Same-id replacement caveat.** With the default `true`, the one-frame grace period means a caller who removes the old container and mounts a new `
` with the same `container_id` *within the same animation frame* can briefly have two `ULabel` instances attached to `document`; when the old instance's teardown runs it will remove `.ulabel`-namespaced document/window handlers belonging to the new instance too. If your SPA does synchronous same-id replacement, set `auto_destroy_on_detach: false` and call `oldUlabel.destroy()` yourself *before* mounting the replacement — `destroy()` is synchronous, so this ordering is race-free. +### `on_active_class_change` +*(subtask_key: string, class_id: number) => void* -- Called after a subtask's active class actually changes, whatever the writer: `set_active_class`, a toolbox class-button click, or a class-select keybind (including keybinds users customize through the `Keybinds` toolbox item). Not called for no-op re-selections, rejected ids, or delete-mode toggles (which freeze the selection). Default is `null`. + +### `on_subtask_change` +*(subtask_key: string, old_subtask_key: string) => void* -- Called after the current subtask actually changes, whatever the writer: `set_subtask`, a toolbox tab click, or the `switch_subtask_keybind`. Not called when the target subtask is already current. Default is `null`. + +### `on_focus_active_class_change` +*(subtask_key: string, enabled: boolean) => void* -- Called after a subtask's `focus_active_class` flag actually changes, whatever the writer: `set_focus_active_class` or the `toggle_class_focus_keybind`. Not called when the flag is already at the target value, so a host may re-sync other subtasks from the callback without recursing. Default is `null`. + ## Display Utility Functions @@ -688,11 +708,15 @@ The new image must match the dimensions this instance was initialized with: the *(string) => array* -- Gets the current list of annotations within the provided subtask. -### `set_annotations(new_annotations, subtask, skip_toolbox_update=false)` +### `set_annotations(new_annotations, subtask, skip_toolbox_update=false, show_loader=true)` + +*(array, string, bool, bool) => Promise<void>* -- Sets the annotations for the provided subtask. Displays the loading spinner while re-initializing the annotations (similar to a new init); pass `show_loader = false` to swap silently, e.g. when the target subtask isn't the one on screen. Returns a `Promise` that resolves once the annotations have been set and redrawn; `await` it if you need to run code after the update completes. + +When batching several per-subtask swaps, prefer [`set_annotations_batch()`](#set_annotations_batchannotations_by_subtask-show_loadertrue); alternatively pass `skip_toolbox_update = true` on each call to suppress the per-call distance-filter and toolbox updates, then call [`refresh_toolbox()`](#refresh_toolbox) once at the end. -*(array, string, bool) => Promise<void>* -- Sets the annotations for the provided subtask. Displays the loading spinner while re-initializing the annotations (similar to a new init). Returns a `Promise` that resolves once the annotations have been set and redrawn; `await` it if you need to run code after the update completes. +### `set_annotations_batch(annotations_by_subtask, show_loader=true)` -When batching several per-subtask swaps, pass `skip_toolbox_update = true` on each call to suppress the per-call distance-filter and toolbox updates, then call [`refresh_toolbox()`](#refresh_toolbox) once at the end. +*(object, bool) => Promise<void>* -- Replaces several subtasks' annotations as a single update: one loader cycle and one toolbox refresh for the whole set (per-subtask calls would flash the loader once per layer). `annotations_by_subtask` maps subtask keys to annotation arrays in `resume_from` form; unknown keys are warned and skipped. Pass `show_loader = false` to swap silently, e.g. when every changed subtask is a background layer. ### `refresh_toolbox()` diff --git a/demo.js b/demo.js index 708b5a5a..6c497ab1 100644 --- a/demo.js +++ b/demo.js @@ -16,5 +16,6 @@ console.log(`http://localhost:${port}/read-only.html`); console.log(`http://localhost:${port}/row-filtering-example.html`); console.log(`http://localhost:${port}/bitmask-example.html`); console.log(`http://localhost:${port}/set-annotations.html`); +console.log(`http://localhost:${port}/class-focus.html`); console.log(`http://localhost:${port}/live_demo.html`); console.log(`http://localhost:${port}/offset-container.html`); \ No newline at end of file diff --git a/demo/bitmask-example.html b/demo/bitmask-example.html index babd2792..76eb2a4a 100644 --- a/demo/bitmask-example.html +++ b/demo/bitmask-example.html @@ -48,6 +48,23 @@ "resume_from": null, "task_meta": null, "annotation_meta": null + }, + // Second mask layer for trying brush_overlap_across_subtasks: + // paint here, switch back, and brush over it with exclude/overwrite. + "reference": { + "display_name": "Reference", + "classes": [ + { + "name": "Reference", + "color": "#888888", + "id": 20 + } + ], + "allowed_modes": ["bitmask"], + "resume_from": null, + "task_meta": null, + "annotation_meta": null, + "inactive_opacity": 0.6 } }; @@ -61,7 +78,13 @@ }); // Wait for ULabel instance to finish initialization ulabel.init(function() { - // ULabel is now ready for use + // The gate reads the config live, so these apply to the next stroke + document.getElementById("across-subtasks").addEventListener("change", function() { + ulabel.config.brush_overlap_across_subtasks = this.checked; + }); + document.getElementById("reference-read-only").addEventListener("change", function() { + ulabel.subtasks.reference.read_only = this.checked; + }); }); }); @@ -69,5 +92,10 @@
+
+ + +
Paint in Reference, switch back, then brush over it with overlap exclude (shift+e) or overwrite (shift+o).
+
diff --git a/demo/class-focus.html b/demo/class-focus.html new file mode 100644 index 00000000..694e0f1b --- /dev/null +++ b/demo/class-focus.html @@ -0,0 +1,271 @@ + + + + + ULabel - Class Focus + + + + + + + + + + + + +
+ + + + diff --git a/index.d.ts b/index.d.ts index 9cc7b611..0590a943 100644 --- a/index.d.ts +++ b/index.d.ts @@ -321,6 +321,14 @@ export type ULabelConstructorArgs = { toolbox_order?: AllowedToolboxItem[]; auto_destroy_on_detach?: boolean; class_counter_toolbox_item?: ClassCounterConfig; + /** Let bitmask brush overlap resolution reach masks in other subtasks. Default false. */ + brush_overlap_across_subtasks?: boolean; + /** Fired after a subtask's active class changes, from any writer (API, toolbox click, class keybind). */ + on_active_class_change?: (subtask_key: string, class_id: number) => void; + /** Fired after the current subtask changes, from any writer (API, tab click, switch keybind). */ + on_subtask_change?: (subtask_key: string, old_subtask_key: string) => void; + /** Fired after a subtask's `focus_active_class` flag changes, from any writer (API, focus keybind). */ + on_focus_active_class_change?: (subtask_key: string, enabled: boolean) => void; /** @deprecated Use top-level properties instead. */ config_data?: object; }; @@ -422,16 +430,27 @@ export class ULabel { public get_current_subtask_key(): string; public get_current_subtask(): ULabelSubtask; public is_current_subtask_read_only(): boolean; + /** Whether a subtask's annotations are hidden: vanished, or layer opacity 0. Hidden implies non-interactive. */ + public is_subtask_hidden(subtask_key?: string): boolean; public readjust_subtask_opacities(): void; public set_subtask(st_key: string): void; public switch_to_next_subtask(): void; /** - * Focus a subtask on a single class. Other classes dim to the subtask's - * `defocused_opacity` and drop out of hover/grab, annotation navigation and - * the annotation list; geometry still sees every annotation. `null` clears. + * Set a subtask's active class: id payload, toolbox selection, per-class + * mode sync, and - on subtasks with `focus_active_class` - the class focus + * (other classes dim to `defocused_opacity` and drop out of hover/grab, + * annotation navigation, bulk delete and the annotation list; geometry + * still sees every annotation). Returns whether the class was accepted. */ - public set_class_focus(subtask_key: string, class_id?: number | null, redraw?: boolean): void; + public set_active_class(class_id: number, subtask_key?: string | null, redraw?: boolean): boolean; + /** + * The last non-delete class selected on a subtask. Delete-mode toggles + * don't move it, so class focus stays put while deleting. + */ + public get_selected_class_id(subtask_key?: string | null): number | null; public is_annotation_defocused(annotation: ULabelAnnotation, subtask_key: string): boolean; + /** Turn focus-follows-active-class on or off for a subtask at runtime. */ + public set_focus_active_class(subtask_key: string, enabled: boolean, redraw?: boolean): void; /** * Opacity for annotations outside the focused class. 0 skips drawing them * entirely, which is cheaper but loses them as visual context. @@ -457,12 +476,12 @@ export class ULabel { * `skip_toolbox_update = true` on each call and run `refresh_toolbox()` once * at the end. */ - public set_annotations(annotations: ULabelAnnotation[], subtask: string, skip_toolbox_update?: boolean): Promise; + public set_annotations(annotations: ULabelAnnotation[], subtask: string, skip_toolbox_update?: boolean, show_loader?: boolean): Promise; /** * Replace several subtasks' annotations as a single update: one loader cycle * and one toolbox refresh, so a multi-layer swap doesn't flicker. */ - public set_annotations_batch(annotations_by_subtask: Record): Promise; + public set_annotations_batch(annotations_by_subtask: Record, show_loader?: boolean): Promise; /** Deferred half of a batched `set_annotations` sequence: filter distances + toolbox redraw. */ public refresh_toolbox(): void; public set_saved(saved: boolean): void; diff --git a/src/active_class.ts b/src/active_class.ts new file mode 100644 index 00000000..0306adad --- /dev/null +++ b/src/active_class.ts @@ -0,0 +1,264 @@ +/** + * Active-class selection. + * + * The active class is per-subtask state: `state.id_payload` drives what new + * annotations get, and `state.selected_class_id` remembers the last *real* + * class selected (delete-mode toggles clobber `id_payload`, so the memory is + * what class focus reads — it freezes at the real selection while a delete + * mode is active). The toolbox buttons are one consumer of this API rather + * than the mechanism itself. + */ + +import type { ULabel, ULabelSpatialType } from "../index"; +import { DELETE_CLASS_ID, DELETE_MODES } from "./annotation"; +import { ULabelSubtask } from "./subtask"; +import { log_message, LogLevel } from "./error_logging"; + +/** + * The last non-delete class selected on a subtask, or null for an unknown + * subtask. + */ +export function get_selected_class_id(ulabel: ULabel, subtask_key: string): number | null { + const subtask = ulabel.subtasks[subtask_key]; + if (subtask == null) return null; + return subtask.state.selected_class_id ?? null; +} + +/** + * Set a subtask's active class: id payload, toolbox selection, id-dialog + * display, per-class mode sync, and — on subtasks with `focus_active_class` — + * the class focus. Selecting a class while an annotation is active/hovered + * reassigns that annotation, matching the toolbox-button behaviour this + * replaces. For a non-current subtask only state is written; `set_subtask` + * reconciles the DOM on activation. + * + * Fires `config.on_active_class_change` when the remembered selection changes. + * + * @param ulabel ULabel instance + * @param class_id class to select (the reserved delete class is accepted but + * never becomes the remembered selection, so focus freezes across it) + * @param subtask_key defaults to the current subtask + * @param redraw whether a focus change repaints immediately + * @returns whether the class was accepted + */ +export function set_active_class( + ulabel: ULabel, + class_id: number, + subtask_key: string | null = null, + redraw: boolean = true, +): boolean { + subtask_key ??= ulabel.get_current_subtask_key(); + const subtask = ulabel.subtasks[subtask_key]; + if (subtask === undefined) { + log_message(`set_active_class: unknown subtask key ${subtask_key}`, LogLevel.WARNING, true); + return false; + } + // class_defs rather than class_ids so the reserved delete class validates + if (!subtask.class_defs.some((class_def) => class_def.id === class_id)) { + log_message( + `set_active_class: class id ${class_id} is not in subtask ${subtask_key}`, + LogLevel.WARNING, + true, + ); + return false; + } + + const previous_selected = subtask.state.selected_class_id ?? null; + if (class_id !== DELETE_CLASS_ID) { + subtask.state.selected_class_id = class_id; + } + + if (subtask_key === ulabel.get_current_subtask_key()) { + apply_active_class_to_dom(ulabel, subtask_key, class_id); + } else { + // Non-current (or pre-init) subtask: write the payload state now so the + // selection can't drift from what new annotations would get; the DOM + // reconciles when the subtask becomes current. + write_id_payload(subtask, class_id); + } + + // Focus follows the selection; whatever was hovered or mid-fly-to may no + // longer be interactive. + if ( + subtask.focus_active_class && + class_id !== DELETE_CLASS_ID && + previous_selected !== class_id + ) { + // Keep the hover when the hovered annotation follows the selection into + // focus (a keybind reclass of the hovered annotation lands here): it + // stayed interactive, so its outline should survive the redraw. + const hovered_annid = subtask.state.hovered_annid; + if (hovered_annid != null) { + const hovered = subtask.annotations.access[hovered_annid]; + if (hovered == null || ulabel.is_annotation_defocused(hovered, subtask_key)) { + subtask.state.hovered_annid = null; + } + } + subtask.state.fly_to_idx = null; + if (redraw) { + ulabel.redraw_all_annotations(subtask_key); + // Toolbox items filter on the focus, so they go stale otherwise. + ulabel.toolbox?.redraw_update_items(ulabel); + } + } + + if (class_id !== DELETE_CLASS_ID && previous_selected !== class_id) { + ulabel.config.on_active_class_change?.(subtask_key, class_id); + } + return true; +} + +/** + * Turn focus-follows-active-class on or off for a subtask at runtime. + * + * @param ulabel ULabel instance + * @param subtask_key subtask to toggle + * @param enabled whether the selected class should also be the focused class + * @param redraw whether the change repaints immediately + */ +export function set_focus_active_class( + ulabel: ULabel, + subtask_key: string, + enabled: boolean, + redraw: boolean = true, +): void { + const subtask = ulabel.subtasks[subtask_key]; + if (subtask === undefined) { + log_message(`set_focus_active_class: unknown subtask key ${subtask_key}`, LogLevel.WARNING, true); + return; + } + const previous = subtask.focus_active_class === true; + subtask.focus_active_class = enabled === true; + + // What's hovered or mid-fly-to may have just gained or lost interactivity + subtask.state.hovered_annid = null; + subtask.state.fly_to_idx = null; + + if (redraw) { + ulabel.redraw_all_annotations(subtask_key); + ulabel.toolbox?.redraw_update_items(ulabel); + } + + // The guard lets a host re-sync other subtasks from the callback without recursing + if (previous !== subtask.focus_active_class) { + ulabel.config.on_focus_active_class_change?.(subtask_key, subtask.focus_active_class); + } +} + +/** + * Opacity for annotations outside the focused class. 0 skips drawing them + * entirely, which is cheaper but loses them as visual context. + * + * @param ulabel ULabel instance + * @param subtask_key subtask to adjust + * @param opacity clamped to 0..1 + * @param redraw whether the change repaints immediately + */ +export function set_defocused_opacity( + ulabel: ULabel, + subtask_key: string, + opacity: number, + redraw: boolean = true, +): void { + const subtask = ulabel.subtasks[subtask_key]; + if (subtask === undefined) { + log_message(`set_defocused_opacity: unknown subtask key ${subtask_key}`, LogLevel.WARNING, true); + return; + } + subtask.state.defocused_opacity = Math.min(Math.max(opacity, 0), 1); + if (redraw) { + ulabel.redraw_all_annotations(subtask_key); + } +} + +/** + * Whether an annotation's spatial type is allowed by a class's modes, i.e. + * whether the annotation may be reclassified to that class. + */ +export function can_annotation_be_class( + ulabel: ULabel, + annotation: { spatial_type?: string }, + class_id: number, + subtask_key: string | null = null, +): boolean { + if (annotation.spatial_type == null) return false; + return ulabel + .get_class_allowed_modes(class_id, subtask_key ?? ulabel.get_current_subtask_key()) + .includes(annotation.spatial_type as ULabelSpatialType); +} + +/** + * Point a subtask's id payload at one class (full confidence, others zero). + * Equivalent to `set_id_dialog_payload_nopin(idx, 1.0)` but subtask-scoped. + */ +function write_id_payload(subtask: ULabelSubtask, class_id: number): void { + const class_ids = subtask.class_ids; + const selected_index = class_ids.indexOf(class_id); + for (let i = 0; i < class_ids.length; i++) { + subtask.state.id_payload[i] = { + class_id: class_ids[i], + confidence: i === selected_index ? 1.0 : 0.0, + }; + } +} + +/** + * The DOM half of a selection change on the current subtask: toolbox `sel` + * swap, id payload, dialog display, active-annotation reclass, delete + * re-toggle, and mode sync. Extracted from the toolbox button click handler. + */ +function apply_active_class_to_dom(ulabel: ULabel, subtask_key: string, class_id: number): void { + const subtask = ulabel.subtasks[subtask_key]; + const pfx = "div#tb-id-app--" + subtask_key; + const current_id_button = $(pfx + " a.tbid-opt.sel"); + const old_id_attr = current_id_button.attr("id"); + const old_id = old_id_attr === undefined ? null : parseInt(old_id_attr.split("_").at(-1)!); + // Re-selecting the current class is a no-op, matching the old handler's + // href gate; notably it leaves a caller-written `id_payload` intact. + if (old_id === class_id) return; + + current_id_button.attr("href", "#"); + current_id_button.removeClass("sel"); + const target_button = $(pfx + ` a#toolbox_sel_${class_id}`); + target_button.addClass("sel"); + target_button.removeAttr("href"); + + ulabel.set_id_dialog_payload_nopin(subtask.class_ids.indexOf(class_id), 1.0); + ulabel.update_id_dialog_display(); + + // Update the class of the active annotation, + // except when toggling on the delete class or in a read-only subtask + if (class_id !== DELETE_CLASS_ID && !ulabel.is_current_subtask_read_only()) { + let target_id: string | null = null; + if (subtask.state.active_id !== null) { + target_id = subtask.state.active_id; + } else if (subtask.state.move_candidate !== null) { + target_id = subtask.state.move_candidate["annid"]; + } + if (target_id !== null) { + // Set the annotation's class to the selected class + ulabel.handle_id_dialog_click( + ulabel.state["last_move"], + target_id, + ulabel.get_active_class_id_idx(), + ); + } else { + // No active annotation; still update the brush circle if in brush mode + ulabel.recolor_brush_circle(); + } + } + + /* + If switching off the delete class while still in delete mode, re-select the + delete class. This occurs when a keybind changes a hovered annotation's + class while in delete mode. + */ + if ( + old_id === DELETE_CLASS_ID && + DELETE_MODES.includes(subtask.state.annotation_mode) + ) { + set_active_class(ulabel, DELETE_CLASS_ID, subtask_key); + } + + ulabel.sync_annotation_modes_to_active_class(); +} diff --git a/src/configuration.ts b/src/configuration.ts index 0779cd6e..ef085411 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -142,6 +142,9 @@ export class Configuration { // The live value is global and persisted to localStorage; this is the initial default. public default_brush_overlap_mode: BrushOverlapMode = "none"; public brush_overlap_mode: BrushOverlapMode = "none"; + // Whether stroke overlap resolution reaches masks in other subtasks; off, a + // stroke only interacts with masks in the active subtask. + public brush_overlap_across_subtasks: boolean = false; // Configuration for the annotation task itself public image_data: ImageData | null = null; public allow_soft_id: boolean = false; @@ -158,6 +161,11 @@ export class Configuration { public instructions_url: string | null = null; public submit_buttons: ULabelSubmitButton[] = []; + // Host notification callbacks; each fires only when the value actually changes + public on_active_class_change: ((subtask_key: string, class_id: number) => void) | null = null; + public on_subtask_change: ((subtask_key: string, old_subtask_key: string) => void) | null = null; + public on_focus_active_class_change: ((subtask_key: string, enabled: boolean) => void) | null = null; + // Passthrough public task_meta: object = {}; public annotation_meta: object = {}; @@ -267,6 +275,8 @@ export class Configuration { public annotation_vanish_all_keybind: string = "shift+v"; + public toggle_class_focus_keybind: string = "shift+f"; + public fly_to_next_annotation_keybind: string = "tab"; public fly_to_previous_annotation_keybind: string = "shift+tab"; diff --git a/src/index.js b/src/index.js index bf035321..0be43d9e 100644 --- a/src/index.js +++ b/src/index.js @@ -32,6 +32,7 @@ import { initialize_annotation_canvases } from "../build/canvas_utils"; import { record_action, record_finish, record_finish_edit, record_finish_move, undo, redo } from "../build/actions"; import { ULabelMask, is_raw_mask_payload } from "../build/mask_utils"; import { get_active_class_id, get_local_storage_item, set_local_storage_item } from "../build/utilities"; +import { set_active_class, get_selected_class_id, set_focus_active_class, set_defocused_opacity, can_annotation_be_class } from "../build/active_class"; import { get_idd_string } from "../build/html_builder"; import $ from "jquery"; @@ -522,6 +523,18 @@ export class ULabel { }, ); + // Warn (never drop: the data is authoritative and round-trips on + // export) when the class doesn't allow the annotation's spatial type + const cand_class_id = parseInt(get_annotation_class_id(cand)); + if (!ul.get_class_allowed_modes(cand_class_id, subtask_key).includes(cand.spatial_type)) { + log_message( + `Annotation ${cand.id} has spatial type ${cand.spatial_type}, ` + + `which class ${cand_class_id} in subtask "${subtask_key}" does not allow`, + LogLevel.WARNING, + true, + ); + } + // Push to ordering and add to access ul.subtasks[subtask_key]["annotations"]["ordering"].push(cand.id); ul.subtasks[subtask_key]["annotations"]["access"][cand.id] = cand; @@ -587,6 +600,9 @@ export class ULabel { idd_thumbnail: false, id_payload: id_payload, delete_mode_id_payload: [{ class_id: -1, confidence: 1 }], + // Class ids currently rendered in this subtask's pies; shrinks to + // the classes compatible with the dialog's annotation + idd_displayed_class_ids: [...ul.subtasks[subtask_key]["class_ids"]], first_explicit_assignment: false, // Annotation state @@ -603,8 +619,11 @@ export class ULabel { move_candidate: null, hovered_annid: null, fly_to_idx: null, - focused_class: null, + // The last non-delete class selected; what class focus follows + selected_class_id: ul.subtasks[subtask_key]["class_ids"][0] ?? null, defocused_opacity: raw_subtask["defocused_opacity"] ?? DEFAULT_DEFOCUSED_OPACITY, + // Cache of the layer opacity slider, synced by readjust_subtask_opacities + layer_opacity: 1, line_size: ul.config.initial_line_size, // Rendering context @@ -1084,10 +1103,31 @@ export class ULabel { return this.get_current_subtask()["read_only"] === true; } + /** + * Whether a subtask's annotations are hidden from view: vanished, or its + * layer opacity slider is at 0. Hidden implies non-interactive, so the + * interaction gates check this rather than `is_vanished` alone. + * @param {string} subtask_key defaults to the current subtask + */ + is_subtask_hidden(subtask_key = null) { + subtask_key ??= this.get_current_subtask_key(); + const subtask = this.subtasks[subtask_key]; + if (subtask == null) return false; + if (subtask["state"]["is_vanished"]) return true; + return subtask["state"]["layer_opacity"] === 0; + } + readjust_subtask_opacities() { for (const st_key in this.subtasks) { - let sliderval = $("#tb-st-range--" + st_key).val(); - $("div#canvasses__" + st_key).css("opacity", sliderval / 100); + const sliderval = $("#tb-st-range--" + st_key).val(); + // Pre-init there are no sliders yet; keep the state defaults + if (sliderval === undefined) continue; + const opacity = Number(sliderval) / 100; + // Cached for is_subtask_hidden, which runs on every mousemove and + // shouldn't pay a DOM read; this sync runs on every slider input + // and at the end of set_subtask, covering all slider writers. + this.subtasks[st_key]["state"]["layer_opacity"] = opacity; + $("div#canvasses__" + st_key).css("opacity", opacity); } } @@ -1105,6 +1145,7 @@ export class ULabel { } const clamped = Math.min(Math.max(opacity, 0), 1); subtask["inactive_opacity"] = clamped; + subtask["state"]["layer_opacity"] = clamped; $("input#tb-st-range--" + subtask_key).val(Math.round(100 * clamped)); $("div#canvasses__" + subtask_key).css("opacity", clamped); } @@ -1119,44 +1160,44 @@ export class ULabel { */ is_annotation_defocused(annotation, subtask_key) { const subtask = this.subtasks[subtask_key]; - if (subtask == null) return false; - const focused_class = subtask["state"]["focused_class"]; - if (focused_class == null) return false; - return get_annotation_class_id(annotation) !== String(focused_class); + if (subtask == null || !subtask["focus_active_class"]) return false; + const selected_class_id = get_selected_class_id(this, subtask_key); + if (selected_class_id == null) return false; + return get_annotation_class_id(annotation) !== String(selected_class_id); } /** - * Focus a subtask on a single class. Other classes stay visible but dim to - * `defocused_opacity` and drop out of hover, Tab and the annotation list. - * @param {string} subtask_key - * @param {number|null} class_id null clears the focus - * @param {boolean} redraw + * Set a subtask's active class: id payload, toolbox selection, per-class + * mode sync, and - on subtasks with `focus_active_class` - the class focus. + * + * @param {number} class_id class to select + * @param {string|null} subtask_key defaults to the current subtask + * @param {boolean} redraw whether a focus change repaints immediately + * @returns {boolean} whether the class was accepted */ - set_class_focus(subtask_key, class_id = null, redraw = true) { - const subtask = this.subtasks[subtask_key]; - if (subtask === undefined) { - log_message(`set_class_focus: unknown subtask key ${subtask_key}`, LogLevel.WARNING, true); - return; - } - if (class_id !== null && !subtask["class_ids"].includes(class_id)) { - log_message( - `set_class_focus: class id ${class_id} is not in subtask ${subtask_key}`, - LogLevel.WARNING, - true, - ); - return; - } - subtask["state"]["focused_class"] = class_id; + set_active_class(class_id, subtask_key = null, redraw = true) { + return set_active_class(this, class_id, subtask_key, redraw); + } - // Whatever was hovered or mid-fly-to may no longer be interactive. - subtask["state"]["hovered_annid"] = null; - subtask["state"]["fly_to_idx"] = null; + /** + * The last non-delete class selected on a subtask. Delete-mode toggles + * don't move it, so class focus stays put while deleting. + * + * @param {string|null} subtask_key defaults to the current subtask + * @returns {number|null} + */ + get_selected_class_id(subtask_key = null) { + return get_selected_class_id(this, subtask_key ?? this.get_current_subtask_key()); + } - if (redraw) { - this.redraw_all_annotations(subtask_key); - // Toolbox items filter on focused_class, so they go stale otherwise. - this.toolbox?.redraw_update_items(this); - } + /** + * Turn focus-follows-active-class on or off for a subtask at runtime. + * @param {string} subtask_key + * @param {boolean} enabled + * @param {boolean} redraw + */ + set_focus_active_class(subtask_key, enabled, redraw = true) { + set_focus_active_class(this, subtask_key, enabled, redraw); } /** @@ -1167,15 +1208,7 @@ export class ULabel { * @param {boolean} redraw */ set_defocused_opacity(subtask_key, opacity, redraw = true) { - const subtask = this.subtasks[subtask_key]; - if (subtask === undefined) { - log_message(`set_defocused_opacity: unknown subtask key ${subtask_key}`, LogLevel.WARNING, true); - return; - } - subtask["state"]["defocused_opacity"] = Math.min(Math.max(opacity, 0), 1); - if (redraw) { - this.redraw_all_annotations(subtask_key); - } + set_defocused_opacity(this, subtask_key, opacity, redraw); } set_subtask(st_key) { @@ -1192,6 +1225,16 @@ export class ULabel { this.redraw_all_annotations_in_annotation_context(prev_ann["canvas_id"], old_st); } } + // Stale action candidates must not survive the switch: a later keybind + // reclass reads move_candidate and would target an unhovered annotation. + old_subtask["state"]["move_candidate"] = null; + old_subtask["state"]["edit_candidate"] = null; + + // Brush state is per-subtask, so tear the outgoing brush down while it is + // still current; otherwise the global toolbox buttons stay lit for it. + if (old_subtask["state"]["is_in_brush_mode"]) { + this.disable_bitmask_brush(); + } // Change object state this.state["current_subtask"] = st_key; @@ -1236,6 +1279,10 @@ export class ULabel { // Redraw demo this.redraw_demo(); + + if (st_key !== old_st) { + this.config.on_subtask_change?.(st_key, old_st); + } } /** @@ -1397,6 +1444,10 @@ export class ULabel { * @returns {boolean} whether the ClassCounter toolbox item was found */ set_class_counter_options(options, redraw = true) { + if (this.is_destroyed) { + log_message("set_class_counter_options called on a destroyed ULabel instance", LogLevel.WARNING, true); + return false; + } const item = this.toolbox.items.find((item) => item.get_toolbox_item_type() === "ClassCounter"); if (item === undefined) return false; item.set_options(options); @@ -1430,12 +1481,12 @@ export class ULabel { if (show_delete) { // Show the delete class id in the toolbox $("a#toolbox_sel_" + DELETE_CLASS_ID).css("display", "inline-block"); - // Select the delete class id in the toolbox by clicking it - $("a#toolbox_sel_" + DELETE_CLASS_ID).trigger("click"); + // Select the delete class id in the toolbox + this.set_active_class(DELETE_CLASS_ID); } else { // Hide the delete class id in the toolbox $("a#toolbox_sel_" + DELETE_CLASS_ID).css("display", "none"); - // If the delete class id is selected, select the first class id in the toolbox + // If the delete class id is selected, select a real class instead if ($("a#toolbox_sel_" + DELETE_CLASS_ID).hasClass("sel")) { // Check if we are hovering an annotation let target_id = null; @@ -1444,9 +1495,9 @@ export class ULabel { } else if (current_subtask.state.move_candidate !== null) { target_id = current_subtask.state.move_candidate["annid"]; } - // If we are not hovering an annotation, select default to the first class + // If we are not hovering an annotation, default to the first class if (target_id === null) { - $("a.tbid-opt").first().trigger("click"); + this.set_active_class(current_subtask["class_ids"][0]); } else { // If we are hovering an annotation, select the class id of the annotation // which is the class with the highest confidence @@ -1454,7 +1505,7 @@ export class ULabel { const target_class_id = classification_payloads.reduce((acc, curr) => { return curr.confidence > acc.confidence ? curr : acc; })["class_id"]; - $("a#toolbox_sel_" + target_class_id).trigger("click"); + this.set_active_class(target_class_id); } } } @@ -1488,9 +1539,8 @@ export class ULabel { // Delete modes are exempt: they remove annotations rather than create them if (!DELETE_MODES.includes(annotation_mode)) { const class_id = get_active_class_id(this); + // Silent: callers probe modes (e.g. brush toggle) and expect false if (!this.get_class_allowed_modes(class_id).includes(annotation_mode)) { - // Callers probe modes and expect false, so this must not alert. - log_message(`Annotation mode ${annotation_mode} is not allowed for class ${class_id}`, LogLevel.WARNING, true); return false; } } @@ -1899,27 +1949,39 @@ export class ULabel { } /** - * Rebuild every subtask's id-dialog color pies from the current `color_info`. + * Rebuild every subtask's id-dialog color pies from the current `color_info`, + * keeping each pie's current class subset. */ rebuild_id_dialog_pies() { + for (const subtask_key in this.subtasks) { + this._rebuild_subtask_pies(subtask_key); + } + } + + /** + * Rebuild one subtask's id-dialog pies, showing only `displayed_class_ids` + * (defaults to the subset already rendered). + */ + _rebuild_subtask_pies(subtask_key, displayed_class_ids = null) { + const subtask = this.subtasks[subtask_key]; + displayed_class_ids ??= subtask["state"]["idd_displayed_class_ids"]; + subtask["state"]["idd_displayed_class_ids"] = displayed_class_ids; + const width = this.config["outer_diameter"]; const inner_radius = this.config["inner_prop"] * width / 2; - for (const subtask_key in this.subtasks) { - const subtask = this.subtasks[subtask_key]; - const idd_id = subtask["state"]["idd_id"]; - const idd_id_front = subtask["state"]["idd_id_front"]; + const idd_id = subtask["state"]["idd_id"]; + const idd_id_front = subtask["state"]["idd_id_front"]; - const dialog_html = get_idd_string(idd_id, width, subtask["class_ids"], inner_radius, this.color_info); - const front_dialog_html = get_idd_string(idd_id_front, width, subtask["class_ids"], inner_radius, this.color_info); + const dialog_html = get_idd_string(idd_id, width, displayed_class_ids, inner_radius, this.color_info); + const front_dialog_html = get_idd_string(idd_id_front, width, displayed_class_ids, inner_radius, this.color_info); - $("#" + idd_id).remove(); - $("#" + idd_id_front).remove(); - $("#dialogs__" + subtask_key).append(dialog_html); - $("#front_dialogs__" + subtask_key).append(front_dialog_html); - } + $("#" + idd_id).remove(); + $("#" + idd_id_front).remove(); + $("#dialogs__" + subtask_key).append(dialog_html); + $("#front_dialogs__" + subtask_key).append(front_dialog_html); // Replacing the pies dropped their hover listeners; re-add - $(".id_dialog").on("mousemove.ulabel", (mouse_event) => { + $("#" + idd_id + ", #" + idd_id_front).on("mousemove.ulabel", (mouse_event) => { if (!this.get_current_subtask()["state"]["idd_thumbnail"]) { this.handle_id_dialog_hover(mouse_event); } @@ -1936,6 +1998,10 @@ export class ULabel { * false when batching several color changes, then redraw once at the end. */ set_class_color(class_id, color, redraw = true) { + if (this.is_destroyed) { + log_message("set_class_color called on a destroyed ULabel instance", LogLevel.WARNING, true); + return; + } this._apply_class_color(class_id, color); this.rebuild_id_dialog_pies(); @@ -1953,6 +2019,10 @@ export class ULabel { * @param {boolean} redraw whether to redraw annotations immediately */ set_class_colors(colors_by_class_id, redraw = true) { + if (this.is_destroyed) { + log_message("set_class_colors called on a destroyed ULabel instance", LogLevel.WARNING, true); + return; + } const class_ids = Object.keys(colors_by_class_id); if (class_ids.length === 0) return; @@ -2794,8 +2864,7 @@ export class ULabel { draw_context_in_focus_passes(canvas_id, subtask, draw) { const context_entry = this.subtasks[subtask]["state"]["annotation_contexts"][canvas_id]; const annotation_ids = context_entry["annotation_ids"]; - const focused_class = this.subtasks[subtask]["state"]["focused_class"]; - if (focused_class == null) { + if (!this.subtasks[subtask]["focus_active_class"]) { for (const annid of annotation_ids) draw(annid); return; } @@ -3169,8 +3238,6 @@ export class ULabel { if (!is_in_polygon_mode && !is_in_bitmask_mode) { return; } - $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); } // If in erase mode, turn it off first @@ -3190,11 +3257,10 @@ export class ULabel { } // Show the brush circle this.create_brush_circle(this.get_global_mouse_x(mouse_event), this.get_global_mouse_y(mouse_event)); - $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); } else { this.destroy_brush_circle(); - $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); } + this.update_brush_toolbox_display(); } toggle_erase_mode(mouse_event) { @@ -3205,17 +3271,8 @@ export class ULabel { } // Toggle erase mode - if (current_subtask["state"]["is_in_erase_mode"]) { - $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - // "Erase mode" is a subset of "brush mode" - if (current_subtask["state"]["is_in_brush_mode"]) { - $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - } - } else { - $("#erase-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - } current_subtask["state"]["is_in_erase_mode"] = !current_subtask["state"]["is_in_erase_mode"]; + this.update_brush_toolbox_display(); // Update brush circle color const brush_circle_id = "brush_circle"; @@ -3237,11 +3294,22 @@ export class ULabel { const state = this.get_current_subtask()["state"]; state["is_in_brush_mode"] = false; state["is_in_erase_mode"] = false; - $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + this.update_brush_toolbox_display(); this.destroy_brush_circle(); } + // Sync the Brush toolbox buttons to the current subtask's brush state. + // The buttons are global (one Brush toolbox item), so every brush-state + // change must route through here or they go stale. + update_brush_toolbox_display() { + const state = this.get_current_subtask()["state"]; + $("#brush-mode").toggleClass( + BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS, + state["is_in_brush_mode"] && !state["is_in_erase_mode"], + ); + $("#erase-mode").toggleClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS, state["is_in_erase_mode"] === true); + } + // ================= Brush overlap mode (global, localStorage-persisted) ================= // Load the global brush overlap mode from localStorage, falling back to the config default. @@ -3564,6 +3632,7 @@ export class ULabel { // Get the list of annotations const annotations = this.get_current_subtask()["annotations"]["access"]; + const current_subtask_key = this.get_current_subtask_key(); // Track the ids of deprecated annotations for undo let deprecated_ids = []; // Track id and annotation pairs of modified annotations for undo @@ -3574,6 +3643,10 @@ export class ULabel { if (annotation["deprecated"]) { continue; } + // A focus-scoped view must not delete what it has dimmed + if (this.is_annotation_defocused(annotation, current_subtask_key)) { + continue; + } // Skip non-spatial annotations and 3D annotations const spatial_type = annotation["spatial_type"]; if (NONSPATIAL_MODES.includes(spatial_type) || MODES_3D.includes(spatial_type)) { @@ -3888,10 +3961,21 @@ export class ULabel { let idd_x; let idd_y; const is_read_only = this.is_current_subtask_read_only(); + // With fewer than two compatible classes there is nothing to reassign + // to, so the reid button and the pie thumbnail don't apply (the same + // treatment single_class_mode gives every annotation) + const can_reassign = this._get_compatible_class_ids( + current_subtask["annotations"]["access"][annid], + ).length > 1; if (nonspatial_id === null) { let esid = "global_edit_suggestion__" + subtask_key; var esjq = $("#" + esid); esjq.css("display", "block"); + // Collapse to the compact single-class ring when there is nothing to + // reassign to: `mcm` carries the wide 3-slot width and scale, and the + // reid button drops out of flow so move/delete close the gap. + esjq.toggleClass("mcm", !current_subtask["single_class_mode"] && can_reassign); + esjq.find("a.reid_suggestion").css("display", can_reassign ? "" : "none"); // Hide the move/reid/delete buttons on read-only subtasks; use visibility rather than // display so the button ring geometry stays measurable for the dialogs around it. esjq.find(".global_sub_suggestion").css("visibility", is_read_only ? "hidden" : ""); @@ -3938,9 +4022,13 @@ export class ULabel { } // let placeholder = $("#global_edit_suggestion a.reid_suggestion"); - if (!current_subtask["single_class_mode"] && !is_read_only) { + if (!current_subtask["single_class_mode"] && !is_read_only && can_reassign) { // Show id dialog thumbnail this.show_id_dialog(idd_x, idd_y, annid, true, nonspatial_id != null); + } else { + // can_reassign varies per annotation, so a thumbnail shown for the + // previously hovered annotation must not linger over this one + this.hide_id_dialog(); } } @@ -3950,10 +4038,37 @@ export class ULabel { this.set_hovered_annotation(null); } + /** + * Class ids in the current subtask that can take an annotation's spatial + * type. A null annotation places no restriction. + */ + _get_compatible_class_ids(annotation = null) { + const class_ids = this.get_current_subtask()["class_ids"]; + if (annotation == null) return class_ids; + return class_ids.filter((class_id) => can_annotation_be_class(this, annotation, class_id)); + } + // ID dialog: color wheel to change the ID of an annotation show_id_dialog(gbx, gby, active_ann, thumbnail = false, nonspatial = false) { let stkey = this.get_current_subtask_key(); + // Only offer classes that can take this annotation's spatial type. With + // fewer than two options there is nothing to choose: no dialog at all. + const displayed_class_ids = this._get_compatible_class_ids( + this.get_current_subtask()["annotations"]["access"][active_ann] ?? null, + ); + if (displayed_class_ids.length <= 1) { + this.hide_id_dialog(); + return; + } + const rendered = this.get_current_subtask()["state"]["idd_displayed_class_ids"]; + if ( + displayed_class_ids.length !== rendered.length || + displayed_class_ids.some((class_id, idx) => class_id !== rendered[idx]) + ) { + this._rebuild_subtask_pies(stkey, displayed_class_ids); + } + // Record which annotation this dialog is associated with // TODO // am_dialog_associated_ann = active_ann; @@ -4039,12 +4154,25 @@ export class ULabel { } hide_id_dialog() { - let idd_id = this.get_current_subtask()["state"]["idd_id"]; - let idd_id_front = this.get_current_subtask()["state"]["idd_id_front"]; - this.get_current_subtask()["state"]["idd_visible"] = false; - this.get_current_subtask()["state"]["idd_associated_annotation"] = null; + const current_subtask = this.get_current_subtask(); + let idd_id = current_subtask["state"]["idd_id"]; + let idd_id_front = current_subtask["state"]["idd_id_front"]; + current_subtask["state"]["idd_visible"] = false; + current_subtask["state"]["idd_associated_annotation"] = null; $("#" + idd_id).css("display", "none"); $("#" + idd_id_front).css("display", "none"); + + // The dialog borrows id_payload as its display model (it holds the shown + // annotation's classes while open); restore the persistent selection so + // the next draw uses the selected class, not the last-hovered one. + // Delete modes keep their own payload. + if (!DELETE_MODES.includes(current_subtask["state"]["annotation_mode"])) { + const selected_class_id = get_selected_class_id(this, this.get_current_subtask_key()); + const selected_idx = current_subtask["class_ids"].indexOf(selected_class_id); + if (selected_idx !== -1) { + this.set_id_dialog_payload_nopin(selected_idx, 1.0); + } + } } // ================= Annotation Utilities ================= @@ -4074,8 +4202,8 @@ export class ULabel { // Grab constants for convenience const current_subtask = this.get_current_subtask(); - // Exit if subtask is vanished - if (current_subtask["state"]["is_vanished"]) { + // Exit if the subtask is hidden (vanished or opacity 0) + if (this.is_subtask_hidden()) { return; } @@ -5548,14 +5676,17 @@ export class ULabel { this.render_bitmask_other_edits(other_edits); } - // {id, subtask} for all undeprecated bitmask annotations except the given one, across all - // subtasks. Matches the render frame-gate (see draw_annotation_from_id): a stroke can only - // affect masks that are visible on the current frame. + // {id, subtask} for all undeprecated bitmask annotations except the given one. Scoped to the + // active subtask unless `brush_overlap_across_subtasks` is set. Matches the render frame-gate + // (see draw_annotation_from_id): a stroke can only affect masks visible on the current frame. get_other_bitmask_ids(active_id) { const active_st = this.get_current_subtask_key(); const current_frame = this.state["current_frame"]; + const subtask_keys = this.config["brush_overlap_across_subtasks"] ? + Object.keys(this.subtasks) : + [active_st]; const ids = []; - for (const st_key in this.subtasks) { + for (const st_key of subtask_keys) { const access = this.subtasks[st_key]["annotations"]["access"]; for (const oid of this.subtasks[st_key]["annotations"]["ordering"]) { // ID collisions are only prevented within a subtask, so scope the active-skip @@ -6629,7 +6760,7 @@ export class ULabel { // Don't show any dialogs when currently drawing/editing an annotation, // And hide just edit dialogs when moving if ( - current_subtask["state"]["is_vanished"] || + this.is_subtask_hidden() || current_subtask["state"]["is_in_progress"] || current_subtask["state"]["starting_complex_polygon"] || current_subtask["state"]["is_in_brush_mode"] || @@ -6832,17 +6963,18 @@ export class ULabel { return null; } - // Get array of classes by name in the dialog - // TODO handle nesting case - // TODO this is not efficient - let class_ids = this.get_current_subtask()["class_ids"]; + // Get array of classes shown in the dialog (a subset when some classes + // can't take the dialog's annotation's spatial type) + const displayed_class_ids = this.get_current_subtask()["state"]["idd_displayed_class_ids"]; - // Get the index of that class currently hovering over - const class_ind = ( + // Get the index of the wedge currently hovered, then translate it back + // to the subtask's full class list for the callers + const wedge_ind = ( -1 * Math.floor( - Math.atan2(idd_y, idd_x) / (2 * Math.PI) * class_ids.length, - ) + class_ids.length - ) % class_ids.length; + Math.atan2(idd_y, idd_x) / (2 * Math.PI) * displayed_class_ids.length, + ) + displayed_class_ids.length + ) % displayed_class_ids.length; + const class_ind = this.get_current_subtask()["class_ids"].indexOf(displayed_class_ids[wedge_ind]); // Get the distance proportion of the hover let dist_prop = (mouse_rad - inner_rad) / (outer_rad - inner_rad); @@ -6871,16 +7003,18 @@ export class ULabel { } } - // Grab the active class id from the toolbox + // Grab the active class id from the id payload state get_active_class_id() { - const pfx = "div#tb-id-app--" + this.get_current_subtask_key(); - const idarr = $(pfx + " a.tbid-opt.sel").attr("id").split("_"); - return parseInt(idarr[idarr.length - 1]); + return get_active_class_id(this); } get_active_class_id_idx() { const class_ids = this.get_current_subtask()["class_ids"]; - return class_ids.indexOf(this.get_active_class_id()); + const idx = class_ids.indexOf(this.get_active_class_id()); + if (idx >= 0) return idx; + // Delete modes resolve the active class to DELETE_CLASS_ID, which has no + // index; fall back to the frozen real selection so callers never see -1. + return Math.max(class_ids.indexOf(this.get_selected_class_id()), 0); } set_id_dialog_payload_to_init(annid, pyld = null) { @@ -6934,13 +7068,15 @@ export class ULabel { update_id_dialog_display(front = false) { const inner_rad = this.config["inner_prop"] * this.config["outer_diameter"] / 2; const outer_rad = 0.5 * this.config["outer_diameter"]; - let class_ids = this.get_current_subtask()["class_ids"]; - for (var i = 0; i < class_ids.length; i++) { - // Skip - let srt_prop = this.get_current_subtask()["state"]["id_payload"][i]["confidence"]; - - let cum_prop = i / class_ids.length; - let srk_prop = 1 / class_ids.length; + const class_ids = this.get_current_subtask()["class_ids"]; + // The pie may show a subset; its geometry is sized to that subset + const displayed_class_ids = this.get_current_subtask()["state"]["idd_displayed_class_ids"]; + for (var i = 0; i < displayed_class_ids.length; i++) { + const payload = this.get_current_subtask()["state"]["id_payload"][class_ids.indexOf(displayed_class_ids[i])]; + let srt_prop = payload?.["confidence"] ?? 0; + + let cum_prop = i / displayed_class_ids.length; + let srk_prop = 1 / displayed_class_ids.length; let gap_prop = 1.0 - srk_prop; let rad_frnt = inner_rad + srt_prop * (outer_rad - inner_rad) / 2; @@ -6951,24 +7087,13 @@ export class ULabel { let gap_frnt = 2 * Math.PI * rad_frnt * gap_prop; let off_frnt = 2 * Math.PI * rad_frnt * cum_prop; - // TODO this is kind of a mess. If it works as is, the commented region below should be deleted - // var circ = document.getElementById("circ_" + class_ids[i]); - // circ.setAttribute("r", rad_frnt); - // circ.setAttribute("stroke-dasharray", `${srk_frnt} ${gap_frnt}`); - // circ.setAttribute("stroke-dashoffset", off_frnt); - // circ.setAttribute("stroke-width", wdt_frnt); let idd_id; if (!front) { idd_id = this.get_current_subtask()["state"]["idd_id"]; } else { idd_id = this.get_current_subtask()["state"]["idd_id_front"]; } - var circ = $(`#${idd_id}__circ_` + class_ids[i]); - // circ.attr("r", rad_frnt); - // circ.attr("stroke-dasharray", `${srk_frnt} ${gap_frnt}`) - // circ.attr("stroke-dashoffset", off_frnt) - // circ.attr("stroke-width", wdt_frnt) - // circ = $(`#${idd_id}__circ_` + class_ids[i]) + var circ = $(`#${idd_id}__circ_` + displayed_class_ids[i]); circ.attr("r", rad_frnt); circ.attr("stroke-dasharray", `${srk_frnt} ${gap_frnt}`); circ.attr("stroke-dashoffset", off_frnt); @@ -7000,10 +7125,13 @@ export class ULabel { } // Get the index of the new class new_class_idx = class_ids.indexOf(new_class_id); + // Init-time numeric payloads carry no selection yet; the first + // class is the default (matching get_active_class_id) + if (new_class_idx === -1) new_class_idx = 0; } - // Select the desired class by clicking on the toolbox selector - $(`#toolbox_sel_${class_ids[new_class_idx]}`).trigger("click"); + // Select the desired class + this.set_active_class(class_ids[new_class_idx]); } } @@ -7083,12 +7211,41 @@ export class ULabel { handle_id_dialog_click(mouse_event, annotation_id = null, new_class_idx = null) { const current_subtask = this.get_current_subtask(); - // Handle explicitly setting the class + // Reject a class that doesn't allow the annotation's spatial type. Only + // user gestures route through here; undo/redo replay through + // assign_annotation_id directly and stay faithful to history. + const target_annotation_id = annotation_id ?? current_subtask["state"]["idd_associated_annotation"]; + const annotation = current_subtask["annotations"]["access"][target_annotation_id]; + if (annotation == null) { + // A stale dialog (e.g. shown before its association was cleared) must + // not fall through to an assignment with no target + log_message("handle_id_dialog_click: no annotation is associated with the id dialog", LogLevel.WARNING, true); + return; + } + let pos_evt = null; if (new_class_idx !== null) { - const pos_evt = { class_ind: new_class_idx, dist_prop: 1.0 }; - this.handle_id_dialog_hover(mouse_event, pos_evt); + pos_evt = { class_ind: new_class_idx, dist_prop: 1.0 }; + } else { + // Pie click: resolve the wedge from the click position rather than + // id_payload, which the (gated) hover may not have updated + const front = current_subtask["state"]["idd_which"] === "front"; + pos_evt = this.lookup_id_dialog_mouse_pos(mouse_event, front); + // The click landed on no wedge (center hole or outside the ring) + if (pos_evt == null) return; + } + const target_class_id = current_subtask["class_ids"][pos_evt.class_ind]; + // An out-of-range index resolves to no class; assigning it would zero + // the annotation's entire payload + if (target_class_id === undefined) return; + // Silently refuse a class that doesn't allow the annotation's spatial + // type (reachable via class keybinds; the pie excludes such classes) + if (!can_annotation_be_class(this, annotation, target_class_id)) { + return; } - // TODO need to differentiate between first click and a reassign -- potentially with global state + + // Write the chosen wedge into the payload; the assignment below reads it. + // Without this, a click with no preceding hover would assign stale state. + this.handle_id_dialog_hover(mouse_event, pos_evt); this.assign_annotation_id(annotation_id); current_subtask["state"]["first_explicit_assignment"] = false; } @@ -7139,10 +7296,10 @@ export class ULabel { if (drag_key != null) { // Suppress browser defaults (e.g. middle-click auto-scroll) mouse_event.preventDefault(); - // Don't start new drag while id_dialog is visible or subtask is vanished + // Don't start new drag while id_dialog is visible or subtask is hidden if ( (this.get_current_subtask()["state"]["idd_visible"] && !this.get_current_subtask()["state"]["idd_thumbnail"]) || - (this.get_current_subtask()["state"]["is_vanished"] && drag_key !== "pan" && drag_key !== "zoom") + (this.is_subtask_hidden() && drag_key !== "pan" && drag_key !== "zoom") ) { return; } @@ -7868,17 +8025,21 @@ export class ULabel { * @param {boolean} skip_toolbox_update when batching several swaps, pass true on * each call and run `refresh_toolbox()` once at the end instead of paying * the filter-distance + toolbox redraw per subtask. + * @param {boolean} show_loader pass false to swap without the loading overlay, + * e.g. when the target subtask isn't the one on screen. */ - async set_annotations(new_annotations, subtask, skip_toolbox_update = false) { + async set_annotations(new_annotations, subtask, skip_toolbox_update = false, show_loader = true) { if (this.is_destroyed) { log_message("set_annotations called on a destroyed ULabel instance", LogLevel.WARNING, true); return; } // Show the loader while re-initializing annotations, since this is similar to a new init - const container = document.getElementById(this.config["container_id"]); - ULabelLoader.add_loader_div(container); - // Yield so the browser can paint the loader before the heavy synchronous work below - await ULabelLoader.wait_for_render(); + if (show_loader) { + const container = document.getElementById(this.config["container_id"]); + ULabelLoader.add_loader_div(container); + // Yield so the browser can paint the loader before the heavy synchronous work below + await ULabelLoader.wait_for_render(); + } // Recheck: destroy() (manual or auto) may have run during the paint yield. if (this.is_destroyed) { @@ -7892,7 +8053,9 @@ export class ULabel { this.refresh_toolbox(); } } finally { - ULabelLoader.remove_loader_div(); + if (show_loader) { + ULabelLoader.remove_loader_div(); + } } } @@ -7903,8 +8066,10 @@ export class ULabel { * * @param {Record} annotations_by_subtask subtask key to * annotations in `resume_from` form + * @param {boolean} show_loader pass false to swap without the loading overlay, + * e.g. when every changed subtask is a background layer. */ - async set_annotations_batch(annotations_by_subtask) { + async set_annotations_batch(annotations_by_subtask, show_loader = true) { if (this.is_destroyed) { log_message("set_annotations_batch called on a destroyed ULabel instance", LogLevel.WARNING, true); return; @@ -7917,9 +8082,11 @@ export class ULabel { }); if (subtask_keys.length === 0) return; - const container = document.getElementById(this.config["container_id"]); - ULabelLoader.add_loader_div(container); - await ULabelLoader.wait_for_render(); + if (show_loader) { + const container = document.getElementById(this.config["container_id"]); + ULabelLoader.add_loader_div(container); + await ULabelLoader.wait_for_render(); + } if (this.is_destroyed) { log_message("set_annotations_batch aborted; ULabel was destroyed during load", LogLevel.WARNING, true); @@ -7933,7 +8100,9 @@ export class ULabel { } this.refresh_toolbox(); } finally { - ULabelLoader.remove_loader_div(); + if (show_loader) { + ULabelLoader.remove_loader_div(); + } } } diff --git a/src/initializer.ts b/src/initializer.ts index aab24046..7c39fd58 100644 --- a/src/initializer.ts +++ b/src/initializer.ts @@ -12,7 +12,7 @@ import { add_style_to_document, build_confidence_dialog, build_edit_suggestion, import { create_ulabel_listeners } from "./listeners"; import { ULabelLoader } from "./loader"; import { ULabelSubtask } from "./subtask"; -import { ULabelAnnotation } from "./annotation"; +import { ULabelAnnotation, NONSPATIAL_MODES } from "./annotation"; import { get_local_storage_item } from "./utilities"; /** @@ -176,9 +176,14 @@ export async function ulabel_init( if (!ulabel.config.allow_annotations_outside_image) { const image_height = ulabel.config["image_height"]; const image_width = ulabel.config["image_width"]; - for (const subtask of Object.values(ulabel.subtasks) as ULabelSubtask[]) { + for (const subtask_key in ulabel.subtasks) { + const subtask: ULabelSubtask = ulabel.subtasks[subtask_key]; for (const anno of Object.values(subtask.annotations.access) as ULabelAnnotation[]) { + if (NONSPATIAL_MODES.includes(anno.spatial_type!)) continue; anno.clamp_annotation_to_image_bounds(image_width!, image_height!); + // The containing box was built from the pre-clamp payload; a stale + // box anchors the hover dialogs (and hit-testing) off the image + ulabel.rebuild_containing_box(anno.id!, false, subtask_key); } } } diff --git a/src/listeners.ts b/src/listeners.ts index 40bd312f..05dd676d 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -8,7 +8,7 @@ import type { ULabel } from "../index"; import { NightModeCookie } from "./cookies"; -import { DELETE_CLASS_ID, DELETE_MODES, NONSPATIAL_MODES } from "./annotation"; +import { DELETE_MODES, NONSPATIAL_MODES } from "./annotation"; import { set_local_storage_item } from "./utilities"; import { AnnotationResizeItem, SMALL_ANNOTATION_SIZE, LARGE_ANNOTATION_SIZE, INCREMENT_ANNOTATION_SIZE } from "./toolbox"; @@ -164,15 +164,24 @@ function handle_keypress_event( return; } + // Toggle class focus (focus follows the active class) on the current subtask + if (event_matches_keybind(keypress_event, ulabel.config.toggle_class_focus_keybind)) { + const st_key = ulabel.get_current_subtask_key(); + ulabel.set_focus_active_class(st_key, !ulabel.subtasks[st_key].focus_active_class); + return; + } + // Check for class keybinds - if (!DELETE_MODES.includes(current_subtask.state.spatial_type)) { + if (!DELETE_MODES.includes(current_subtask.state.annotation_mode)) { for (let i = 0; i < current_subtask.class_defs.length; i++) { const class_def = current_subtask.class_defs[i]; if (class_def.keybind !== null && event_matches_keybind(keypress_event, class_def.keybind!)) { - if (is_read_only) return; const st_key = ulabel.get_current_subtask_key(); const class_button = $(`#tb-id-app--${st_key} a.tbid-opt`).eq(i); if (class_button.hasClass("sel")) { + // Reclassifying the active annotation is an edit; the + // selection branch below is not, so it stays available + if (is_read_only) return; // If the class button is already selected, // check if there is an active annotation, and if so, get it let target_id = null; @@ -191,8 +200,8 @@ function handle_keypress_event( ); } } else { - // Click the class button if not already selected - class_button.trigger("click"); + // Select the class if not already selected + ulabel.set_active_class(class_def.id); } return; } @@ -201,7 +210,8 @@ function handle_keypress_event( } /** - * Handle a click on a soft ID toolbox button. + * Handle a click on a soft ID toolbox button. Thin wrapper: the selection + * logic lives in `set_active_class`, of which this click is one caller. * * @param click_event Click event * @param ulabel ULabel instance @@ -211,64 +221,10 @@ function handle_soft_id_toolbox_button_click( ulabel: ULabel, ) { const tgt_jq = $(click_event.currentTarget); - const pfx = "div#tb-id-app--" + ulabel.get_current_subtask_key(); - const current_subtask = ulabel.get_current_subtask(); - if (tgt_jq.attr("href") === "#") { - const current_id_button = $(pfx + " a.tbid-opt.sel"); - current_id_button.attr("href", "#"); - current_id_button.removeClass("sel"); - const old_id = parseInt(current_id_button.attr("id")!.split("_").at(-1)!); - tgt_jq.addClass("sel"); - tgt_jq.removeAttr("href"); - const idarr = tgt_jq.attr("id")!.split("_"); - const rawid = parseInt(idarr[idarr.length - 1]); - ulabel.set_id_dialog_payload_nopin( - current_subtask["class_ids"].indexOf(rawid), - 1.0, - ); - ulabel.update_id_dialog_display(); - - // Update the class of the active annotation, - // except when toggling on the delete class or in a read-only subtask - if (rawid !== DELETE_CLASS_ID && !ulabel.is_current_subtask_read_only()) { - // Get the active annotation, if any - let target_id = null; - if (current_subtask.state.active_id !== null) { - target_id = current_subtask.state.active_id; - } else if (current_subtask.state.move_candidate !== null) { - target_id = current_subtask.state.move_candidate["annid"]; - } - - // Update the class of the active annotation - if (target_id !== null) { - // Set the annotation's class to the selected class - ulabel.handle_id_dialog_click( - ulabel.state["last_move"], - target_id, - ulabel.get_active_class_id_idx(), - ); - } else { - // If there is not active annotation, - // still update the brush circle if in brush mode - ulabel.recolor_brush_circle(); - } - } - - /* - If toggling off a delete class while still in delete mode, - re-toggle the delete class. - This occurs when using a keybind to change a hovered annotation's - class while in delete mode. - */ - if ( - old_id === DELETE_CLASS_ID && - DELETE_MODES.includes(current_subtask.state.annotation_mode) - ) { - $("#toolbox_sel_" + DELETE_CLASS_ID).trigger("click"); - } - - ulabel.sync_annotation_modes_to_active_class(); - } + // The selected button has no href; clicking it is a no-op + if (tgt_jq.attr("href") !== "#") return; + const idarr = tgt_jq.attr("id")!.split("_"); + ulabel.set_active_class(parseInt(idarr[idarr.length - 1])); } /** @@ -682,6 +638,9 @@ export function create_ulabel_listeners( (click_event) => { const crst = ulabel.get_current_subtask(); const annid = crst["state"]["idd_associated_annotation"]; + // No association means no dialog to open (e.g. it was suppressed + // because the annotation has no valid reassignment targets) + if (annid == null) return; ulabel.hide_global_edit_suggestion(); ulabel.show_id_dialog( ulabel.get_global_mouse_x(click_event), diff --git a/src/mask_utils.ts b/src/mask_utils.ts index fde5e2dd..c7d71071 100644 --- a/src/mask_utils.ts +++ b/src/mask_utils.ts @@ -767,7 +767,12 @@ export class ULabelMask { } if (payload.box !== undefined) { const box = payload.box; - if (box.tlx < 0 || box.tly < 0 || box.brx >= width || box.bry >= height) { + if ( + box.tlx < 0 || box.tly < 0 || box.brx >= width || box.bry >= height || + // Reversed on both axes would pass the length check below + // (two negative extents multiply to a positive expected size) + box.brx < box.tlx || box.bry < box.tly + ) { throw new Error(`Invalid raw mask box [${box.tlx}, ${box.tly}, ${box.brx}, ${box.bry}] for ${height}x${width}`); } const expected = (box.brx - box.tlx + 1) * (box.bry - box.tly + 1); diff --git a/src/subtask.ts b/src/subtask.ts index d7a630e6..5dc478f8 100644 --- a/src/subtask.ts +++ b/src/subtask.ts @@ -38,6 +38,8 @@ export class ULabelSubtask { idd_id_front: string; idd_thumbnail: boolean; idd_visible: boolean; + // Class ids currently rendered in the pies (compatible-class subset) + idd_displayed_class_ids: number[]; is_in_edit: boolean; is_in_move: boolean; is_in_progress: boolean; @@ -48,11 +50,13 @@ export class ULabelSubtask { visible_dialogs: { [key: string]: ULabelDialogPosition; }; - spatial_type: ULabelSpatialType; fly_to_idx: number | null; - // Presentation/input only. Defocused annotations are still real data. - focused_class: number | null; + // The last non-delete class selected; what class focus follows when + // `focus_active_class` is set. Defocused annotations are still real data. + selected_class_id: number | null; defocused_opacity: number; + // Cache of the layer opacity slider, synced by readjust_subtask_opacities + layer_opacity: number; line_size: number; }; @@ -72,6 +76,8 @@ export class ULabelSubtask { public annotation_meta: object | string, public read_only?: boolean, public inactive_opacity: number = 0.4, + /** Focus follows the active class: other classes dim and drop out of input. */ + public focus_active_class: boolean = false, ) { this.actions = { stream: [], @@ -90,6 +96,7 @@ export class ULabelSubtask { subtask_json["annotation_meta"], ); ret.read_only = ("read_only" in subtask_json) && (subtask_json["read_only"] === true); + ret.focus_active_class = subtask_json["focus_active_class"] === true; if ("inactive_opacity" in subtask_json && typeof subtask_json["inactive_opacity"] == "number") { ret.inactive_opacity = Math.min(Math.max(subtask_json["inactive_opacity"], 0.0), 1.0); } diff --git a/src/toolbox.ts b/src/toolbox.ts index 11f64cd4..652c3971 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -6,7 +6,7 @@ import type { } from "../index"; import { ULabel } from "../src/index"; import { DEFAULT_FILTER_DISTANCE_CONFIG, AllowedToolboxItem } from "./configuration"; -import { ULabelAnnotation } from "./annotation"; +import { ULabelAnnotation, DELETE_CLASS_ID } from "./annotation"; import { ULabelSubtask } from "./subtask"; import { filter_points_distance_from_line, @@ -374,7 +374,7 @@ export class ToolboxTab { sel = " sel"; val = 100; } - console.log(subtask.display_name, subtask); + log_message(`Building toolbox tab for subtask: ${subtask.display_name}`, LogLevel.VERBOSE); this.html = `
${this.subtask.display_name}