diff --git a/.github/tasks.md b/.github/tasks.md index 826c5841..2b7f9ad1 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -1,3 +1,724 @@ ## Tasks +Bitmask/segmentation viewer performance work, driven by the model-registry +integration. Items 1/3/4/5 touch this repo; item 2 is model-registry only. +- [x] 1. Cache the bitmask hover outline on the render object + - `draw_bitmask` allocates a canvas and does 8 dilation blits on every draw + while hovered. Cache on `_mask_render`, which already invalidates on mask + version + color change. +- [x] 2. (model-registry) Drop `wrapperSize` from `viewerKey` + - A container resize currently forces a full ULabel rebuild. +- [x] 3. Windowed `ULabelMask` + - Store pixels for a sub-rectangle instead of the full frame, keeping the + public API in image coordinates. Removes the `objects x width x height` + memory bound, so model-registry can go back to one annotation per Encord + object instead of one merged mask per class. + - Accept an optional `box` on the raw payload so callers can hand over an + already-cropped buffer with no copy. + - Grow the window on paint so editing still works. + - Verified in the browser on run #3: GT renders 99 separate objects on the + densest item sampled (was 1 merged mask per class), item-to-item switching + ~90 ms, heap flat around 1 GB with no OOM. +- [x] 4. Swap subtasks in place instead of rebuilding the instance + - Add `replace_subtasks`, so a GT/Pred/Diff switch reuses the decoded image, + listeners and toolbox rather than running `destroy()` + `init()`. + - Only the annotation set can change: `replace_subtasks` returns `null` when + the subtask shape (keys, allowed modes, class defs) differs, so the caller + knows it still has to rebuild. + - Verified in the browser on run #3: four consecutive confidence-threshold + changes in pred mode produced zero rebuilds (previously one full + `destroy()` + `init()` each). Mode switches on that run still rebuild + because its GT `Row` class is a polyline while pred renders it as a + bitmask, which is a genuine shape change. +- [x] 5. Decode RLE off the main thread + - (model-registry) move `rleRecordToRawMask` into a worker and transfer the + cropped buffers back. + - Verified in the browser: GT still renders after the move, heap 193 MB on a + fresh load, no page errors. + +## Architecture: subtask per data set, class as class + +The viewer models GT/Pred/Diff as *per-class* subtasks whose set changes with +the view mode, so a mode switch is a subtask-shape change and forces a rebuild. +Diff goes further and replaces the class with the outcome (`FP`/`FN`/`TP`), so +class identity is destroyed and "false negatives for Crop" is inexpressible. + +Target: three fixed subtasks (`groundtruth`, `prediction`, `diff`), each with +the real class defs, present in every mode with only their annotations +swapping. Outcome moves to annotation metadata. This is also what diff-driven +groundtruth editing needs, since applying a diff region to a GT mask requires +both loaded together with class identity intact. + +- [x] 6. Per-annotation color resolver + - `get_annotation_color` looks up `color_info[class_id]`. Add an optional + per-annotation hook so the diff subtask can color by outcome while keeping + real classes. Every draw path already funnels through this one function. + - Added `annotation_color_resolver` to `Configuration` and the constructor + args. Returning `null` falls back to the class color, and the confidence + gradient still applies either way. +- [x] 7. Class-aware annotation canvases + - `get_next_available_canvas_id` packs annotations into the first non-full + canvas regardless of class, and per-subtask opacity/z-index is what dims + inactive layers today. Group canvases by class so the same CSS mechanism + gives per-class dimming and bring-to-front once classes share a subtask. + - Canvases now nest under a `div.class_canvasses` per class, and + `set_active_class_layer(subtask, class_id, inactive_opacity)` mirrors + `readjust_subtask_opacities` one level down. Verified: lint clean, 161 + jest tests and 102 Chromium e2e tests pass. +- [x] 8. `hidden_by` visibility map + - Mirror the keyed composition of `deprecated_by` for view filtering. + Separate from `deprecated`, which means "deleted" and is about to start + flowing back to Encord. + - Added `mark_hidden` plus a public `filter_annotations(hidden_by_key, + should_hide, subtask, redraw)`. Keys compose, so class/outcome/confidence + controls can be applied in any order. `hidden` gates drawing, edit + candidates, and annotation navigation, and also skips bulk polygon delete + so it can't remove something the user can't see. Export is untouched. +- [x] 9. (model-registry) Rebuild subtask construction on the new model + - Three fixed subtasks, real class defs, `match_outcome` in + `annotation_meta`, Encord object hash carried on GT annotations, and class + chips driving filters rather than `set_subtask`. + - `buildViewSubtasks` replaces `buildClassSubtasks`/`buildDiffSubtasks`: all + three subtasks share one class list and one `allowed_modes` union derived + from the ontology, so the subtask shape no longer changes with the data. + - Diff layers by outcome instead of class, which class-keyed canvases alone + could not express. Added `annotation_canvas_group_resolver` to ULabel and + generalized `set_active_class_layer`'s `inactive_opacity` to accept a + per-key map, preserving the old fn 0.6 / fp 0.6 / tp 0.4 dim values. + - Sharing class ids across subtasks tripped ULabel's duplicate-id warning, + which checked the global `valid_class_ids`. Scoped the check to duplicates + within a subtask and made `valid_class_ids` a true set. Colors are written + idempotently and `findAllClassDefinitions` already de-duplicates by id, so + the confidence slider still shows one entry per class. + - Verified in browser on eval run #3: all three modes paint with no console + warnings; `canvasses__prediction` groups by class id (`0`/`1`/`2`) and + `canvasses__diff` by outcome (`tp`/`fp`/`fn`), with the selected layer at + opacity 1 / z-index 76 and the rest dimmed. GT shows polyline and bitmask + classes together in one subtask. Lint clean, 166 jest tests pass. + +## Plan: subtask-per-class + subtask-per-outcome architecture + +Supersedes the "three fixed subtasks" model (items 4/6/7/8/9 above). For a job +with classes {crop, weed, row}: subtasks `crop`/`weed`/`row` hold GT *or* pred +annotations (swapped on mode change), plus fixed `tp`/`fp`/`fn` subtasks for +diff whose class defs are the *real* classes. Rationale (from design review): + +- Per-class fly-to in gt/pred and per-outcome fly-to in diff fall out of + ULabel's existing subtask-scoped `fly_to_next_annotation` — no new nav code. +- "FN for crop" = crop class inside the `fn` subtask; `ClassCounter` on `fn` + shows per-class FN counts natively. No `match_outcome` metadata. +- Subtask keys are stable across items/modes/thresholds (they change only with + the ontology), so annotation swaps never hit a shape change. +- `viewerKey` in model-registry already encodes the shape, making ULabel-side + shape checking redundant; per-subtask memos + reference equality in the + frontend make ULabel-side staleness diffing redundant. + +Sequencing: 1.1 and 1.4 remove API the current frontend still calls, so those +removals ship in a ULabel version that model-registry adopts in the same PR as +its Phase 3 migration (frontend stays pinned until then). + +### Phase 0 - confidence card positioning (in progress) + +- [x] 0.1 Fix card geometry: include the card's natural flow offset + (`offsetTop - margin`) and hug the button ring (`button_half + gap`) in + both modes; read-only keeps ring flow space via `visibility: hidden`, so + the card lands in the same spot with or without buttons. +- [x] 0.2 Rewrite the two failing e2e specs to assert flip geometry (card vs + anchor) instead of the old literal `-9.5em`/`-1em` margins. +- [ ] 0.3 Missing card tests (gaps found in audit): + - read-only parity: same annotation, `read_only` toggled, card rect equal + (protects the visibility-preserves-flow invariant) + - ring proximity upper bound: card bottom within + `button_half * scale + gap + slack` of the anchor (the "too high" bug + passes the current >=5px assertions) + - single-class demo variant: card position at the 0.666 dialog scale + (only the 0.5 mcm path is exercised today) + +### Phase 1 - ULabel removals (this repo) + +- [x] 1.1 Remove `replace_subtasks`, `_subtask_shape_matches`, + `_subtask_annotations_unchanged` and their `index.d.ts` entries. + `set_annotations` becomes the single swap path. (Also moots the mid-yield + destroy return-value bug and the `config.subtasks` retention concern from + the branch review.) No tests to delete (browser-verified only); add a + regression test for the frontend's pattern: N sequential per-subtask + `set_annotations` swaps on a live instance. +- [x] 1.2 Add `skip_toolbox_update = false` param to `set_annotations` so the + frontend can batch N per-subtask swaps with one `update_filter_distance` + + toolbox redraw at the end (expose a small `refresh_toolbox()` if needed). + Unit tests: flag suppresses toolbox/filter updates; `refresh_toolbox()` + triggers them once. +- [x] 1.3 Remove the dead `hidden` machinery: `hidden`/`hidden_by` fields, + `mark_hidden`, `filter_annotations`, `HiddenBy`/`ValidHiddenBy` types, and + the gates in draw / suggest_edits / fly_to / nav toast / bulk delete. + No consumer exists (verified in model-registry) and the new architecture + covers visibility with subtask structure + vanish + layer dimming. +- [x] 1.4 Remove all three resolvers (`annotation_color_resolver`, + `annotation_canvas_group_resolver`, `annotation_display_name_resolver`). + Every subtask is single-class with its own id, color, and name (outcome + subtasks are literally named "True Positive" etc., so the hover card reads + the same through the plain class-name path). (Class-grouped canvases + + `set_active_class_layer` were initially kept, then removed in 2.3.) +- [x] 1.5 Remove the per-subtask back canvas. VERIFIED vestigial: write-only + since the first commit (Nov 2020) - assigned at init, nulled in destroy, + zero draw calls ever; all rendering targets front/annotation/demo contexts + and the image is an ``. Not in README/api_spec/index.d.ts; no id + references in tests, demos, or model-registry; all src selectors touching + it are class-based (no positional/stacking assumptions). + IMPLEMENTED: element creation, `canvas_bid_pfx`, + `subtask.canvas_bid`, `state.back_context` (init/destroy/types), test + fixtures, and stale comments removed; breaking-change CHANGELOG entry added. + - [x] Local validation: lint + build + 166 jest pass + +### Phase 2 - ULabel changes (this repo) + +- [x] 2.1 `ClassCounter` options (config `class_counter_toolbox_item` + a + runtime setter, since view mode lives in the host): + - `subtasks: string[] | "current"` - which subtasks to count + - `layout: "current" | "grouped" | "flat"` + ClassCounter has zero tests today - backfill current behavior (per-class + counts, deprecated skipped) alongside the new options. +- [x] 2.2 Public `set_class_color(class_id, color, redraw = true)`: writes + `color_info`, syncs the toolbox swatch + id-dialog pie, optional redraw. + Refactor `RecolorActive.update_color` (private, does the same steps by + hand) to call it; add to `index.d.ts`. Replaces raw `color_info` mutation + in model-registry's recolor effect, which currently skips the pie sync. + Unit tests: color_info write, swatch/pie sync, redraw flag both ways. +- [x] 2.3 Remove `set_active_class_layer` and class-grouped canvases entirely + (supersedes the 1.4 "keep" decision). With every subtask single-class + (3.4), a subtask has exactly one canvas group, so within-subtask layer + dimming has nothing to act on; class visibility is expressed with subtasks + (vanish / `readjust_subtask_opacities`). Removes: the method + + `active_class_layer` state and its `get_edit_candidates` gate, the + `div.class_canvasses` wrappers + CSS, `class_key` bookkeeping in + `annotation_contexts`, `get_canvas_class_key` / `get_class_canvasses_id` / + `get_annotation_canvas_group`, the class_id params on the canvas-creation + path, and the `index.d.ts` entries. Recoverable from git history if a + multi-class subtask ever returns. + +### Phase 3 - model-registry + +- [x] 3.1 `buildViewSubtasks` -> per-class specs (single real class each, + narrow allowed_modes) + `tp`/`fp`/`fn` specs (single outcome class each, + per 3.4). Keys derived from the run-selection label union via + `classSubtaskKeys` (slugified, deduped, never colliding with outcome keys). +- [x] 3.2 Replace the monolithic `subtasks` memo with per-subtask annotation + memos; push changes via `set_annotations(annos, key, skip_toolbox_update)` + per changed subtask (annotation-id signature diff in `UlabelCanvas`, since + ids are content-derived), final `refresh_toolbox()`. +- [x] 3.3 Mode switch: gt<->pred swaps class-subtask annotations; diff mode + swaps outcome-subtask annotations. Explicit vanish proved unnecessary: + fetching is mode-gated, so the non-active mode's subtasks are swapped to + empty and render nothing. Inactive-subtask dimming keeps the old 0.4 for + class subtasks; outcome subtasks carry `inactive_opacity: 1` so all three + outcomes stay at full opacity (preserves item 10's behavior). +- [x] 3.4 Outcome subtasks are single-class (TP=0, FP=1, FN=2 fixed ids + first; real classes at 3..N+2), colors from `useDiffColors`. Dropped + `match_outcome` metadata, `outcomeOf`, and all resolver usage; the hover + card names outcomes through the plain class-name path. +- [x] 3.5 Dropped `color` from the `viewerKey` shape (keys/ids/names/modes + kept). Recolor effect rewritten on `set_class_color` (2.2): registry + classes by name, outcome classes by fixed id -> `useDiffColors`; batched + with `redraw = false` + one final `redraw_all_annotations()`. +- [x] 3.6 Class chips: gt/pred -> `set_subtask(class_key)`; diff -> chip-driven + annotation swap (only the active class's outcomes are loaded, per 3.4). + The Diff Colors legend rows additionally select the current *outcome* + subtask, scoping hover + Tab navigation (hover is subtask-scoped now). + ClassCounter runs `layout: "flat"` over class subtasks in gt/pred and over + outcome subtasks in diff via `set_class_counter_options`. +- [x] 3.7 Kept: ConfidenceSlider flow as-is (hidden DOM sliders driven from + the sidebar; latent-FN filter override untouched; per-class slider ids and + `default_values` moved to the 3..N+2 range with `target_class_ids` so + outcome classes never get sliders), segmentation threshold scrubs as + per-subtask swaps (only stale subtasks re-import via the signature diff). + +### Verification + +- [ ] V1 ULabel: lint + jest + e2e green after each phase; each item above + carries its own test additions (0.3, 1.1, 1.2, 2.1-2.3). +- [ ] V2 model-registry on eval run #3: mode switches produce zero rebuilds; + threshold scrub swaps only affected subtasks; Tab cycles within + class (gt/pred) and outcome (diff); FN-per-class counts visible; heap + comparable to the 3-subtask baseline after back-canvas removal. + + + +- [x] 10. Show every diff outcome at once, and keep the hover card off the annotation + - (model-registry) Dropped the TP/FP/FN layer picker: diff mode now calls + `set_active_class_layer(key, null, 1)` so all three outcome groups stay at + full opacity. A null active layer is also what makes them all hover + targets, since `get_edit_candidates` skips groups that aren't active. + Passing the opacity explicitly matters: with no active class every group + takes the `inactive_opacity` branch, so the default would dim all of them. + The sidebar "Diff Colors" rows are now a legend plus recolor. + - Added `annotation_display_name_resolver` to ULabel, alongside the existing + color and canvas-group resolvers, so the hover card can name the diff + outcome instead of the class. Every diff annotation carries the same class, + which made the old class name useless there. + - The hover card was anchored at the containing box's centre, so it covered + whatever was under the cursor. It now clears the box by half its on-screen + height plus a gap, flipping below only when there isn't room above. + Offsets are divided by the dialog container's CSS scale (0.5 / 0.66666 + from `.global_edit_suggestion`), which otherwise halves them. + - Verified in browser on eval run #3 item 503: `canvasses__diff` holds `fn`, + `fp` and `tp` all at opacity 1, each is hover-targetable, the card reads + "True Positive" / "False Negative", and it sits a 10 px gap above the + hovered box in every sampled position. Lint clean in both repos. + +- [x] 11. Hover on the annotation boundary, not its containing box + - `get_edit_candidates` already hit-tests exactly (`get_pixel` for bitmasks, + point-in-polygon for polygons), so this cost nothing extra. The stray + hovers came from the fallback underneath: when nothing contains the + cursor, it still picked the smallest annotation whose *containing box* + was within `dst_thresh`. That fallback exists so you can grab an + annotation to edit it, which a read-only subtask never needs. + - Now skipped when the subtask is read-only and the spatial type has an + exact test. Types without one (polyline, tbar, contour) keep the box + fallback, so they stay hoverable. + - Verified on run #3 item 503: across six probes the hover card appeared if + and only if the cursor was over a painted mask pixel, comparing against + the coordinates ULabel itself received. GT polylines still hover and read + "Row". 166 unit tests pass. + +## Plan: three fixed subtasks, outcome as class, filter as subtask state + +Supersedes the subtask-per-class + subtask-per-outcome plan (phases 0-3). +Branch `three-fixed-subtasks`, cut from `cropped-bitmasks-prepare`. + +The viewer slices three ways - source (GT / a run's predictions), class, and +diff outcome - but ULabel has two structural slots (subtask, class within +subtask). Every layout so far is a different way of cramming three into two: + +- Per-class subtasks whose set changed with the mode (pre-256), so every mode + switch was a `destroy()` + `init()`: flicker, and zoom lost. +- Three fixed subtasks with outcome in `annotation_meta` (256). The diff + subtask told ULabel its annotations were crops and used resolvers to draw + them as FPs, so `ClassCounter`, the confidence slider, the colour swatch and + the id dialog all disagreed with the canvas. +- One subtask per class plus `tp`/`fp`/`fn` (257). Every subtask is + single-class, so `single_class_mode` disables the reclassify pie, force- + overwrites `classification_payloads` after edits, fragments undo into N + stacks, and scopes hover to the pre-selected class. Subtask count grows + with the ontology. + +Target - three subtasks, fixed at construction, never rebuilt: + +| key | classes | ids | read_only | contents | +| --- | --- | --- | --- | --- | +| `groundtruth` | all real classes | 3..N+2 | no | GT; never swapped | +| `prediction` | all real classes | 3..N+2 | yes | selected run's predictions | +| `diff` | TP / FP / FN | 0/1/2 | yes | diff(GT, run), filtered class | + +Why this shape: + +- `groundtruth` is multi-class, so `single_class_mode` stays false: the + reclassify pie works, undo is one stream, and any annotation is grabbable + without first selecting its class. Every layout except 256 failed this, and + it is the whole point of editing in ULabel rather than a viewer. +- `diff`'s classes *are* the outcomes, so colour, `ClassCounter`, the id + dialog and the hover card agree with the canvas through the plain class + path. No resolvers, no `match_outcome`. +- `groundtruth` is never swapped, because `actions.stream` points into it. + Everything else is read-only, so swapping there costs nothing. +- Diff is always GT vs one run, never run vs run, so one `prediction` and one + `diff` slot suffice. Switching runs swaps their contents at fixed zoom - a + blink comparator, which beats side-by-side for spotting differences. +- Three image-sized front canvases regardless of ontology size, which settles + the V2 memory question instead of leaving it assumed. + +Class focus becomes subtask *state*, not structure and not a per-annotation +flag. State survives `set_annotations`, so swapped-in annotations are filtered +the moment they land - the bug `layerEpoch` existed to paper over - and +changing the filter is a field write rather than a pass over every annotation. +Do not call it "active class": `get_active_class_id` already means the class +assigned to newly drawn annotations. + +### Phase 4 - ULabel: filtering and layer control + +- [x] 4.1 `state.class_filter: number | null` plus + `set_class_filter(subtask_key, class_id | null)`. Gate on it in + `draw_annotation`, `get_edit_candidates`, `fly_to_annotation`, the + visible-count loop, and `annotation_list`. Explicitly *not* in the bitmask + geometry paths (`merge/join`, `resolve_bitmask_overlap`): a filtered mask is + still real data and must keep acting as a stroke barrier, or painting over + hidden pixels silently breaks the no-overlap invariant. + - **Superseded by 4.5**: renamed to `set_class_focus` / `focused_class` / + `is_annotation_defocused`, and the draw gate dims instead of hiding. The + five gate sites and the geometry carve-out are unchanged. + - Added `is_annotation_filtered(annotation, subtask_key)` as the single + gate, and both methods to `index.d.ts`. Setting a filter drops + `hovered_annid` and `fly_to_idx`, which may now point off screen. + - Rejects a class id the subtask doesn't declare, so a stale host-side id + can't silently blank a layer. + - Tests in `tests/class_focus.test.js`, including the property that + motivated state over a per-annotation flag: annotations swapped in after + the focus is set are scoped on arrival, with no re-application step. + - Local validation: lint + build + 196 jest pass. +- [x] 4.2 `set_subtask_opacity(subtask_key, value)` wrapping + `readjust_subtask_opacities`, which already drives `div#canvasses__{key}` + from the toolbox slider. Lets the host dim backing layers at runtime and + retires the `inactive_opacity: 1` construction hack from 3.3. + - Writes `inactive_opacity` as well as the DOM, because `set_subtask` + resets every non-current slider from that field; without it a host-set + opacity silently reverts on the next subtask switch. 6 tests in + `tests/set_subtask_opacity.test.js`, one of which is exactly that. +- [x] 4.3 `set_annotations_batch(Record)` - one + loader show/hide, one filter + opacity pass, one toolbox update. Today N + subtasks means N spinner cycles and 2N awaits; a run switch swaps + `prediction` and `diff` and has to be one atomic, flicker-free update for + the blink comparator to work. Removes the host's `pushChainRef`. + - Extracted the per-subtask swap body into `_swap_subtask_annotations` so + `set_annotations` and the batch share it; `set_annotations` keeps its + signature and behaviour. + - Unknown subtask keys are dropped with a warning rather than aborting, so + one stale key can't lose the whole swap. An all-unknown map does not + cycle the loader. + - 8 tests in `tests/set_annotations_batch.test.js` covering the batching + contract (one loader, one `refresh_toolbox`, destroyed-mid-swap + unwinding). The swap body itself stays covered by the bitmask e2e specs. +- [x] 4.4 `set_class_colors(Record)` - one + `rebuild_id_dialog_pies()` for the whole map. `set_class_color` rebuilds + every subtask's pies on each call even with `redraw = false`, so the host's + batched recolor loop is O(N^2) DOM churn. + - Split the cheap half (`color_info` + toolbox swatch) into + `_apply_class_color`; both entry points share it and `set_class_color` is + unchanged externally. + - Also fixed the same quadratic loop *inside* ULabel: + `RecolorActiveItem.read_local_storage` called `set_class_color` per class + at construction, rebuilding every subtask's pies once per class. + - 5 tests added to `tests/set_class_color.test.js`. +- [x] 4.5 Class focus dims rather than hides. 4.1 skipped non-focused + annotations outright, which was a behaviour regression: the N+3 layout drew + every class and only dimmed the inactive ones. Renamed `class_filter` -> + `focused_class`, `set_class_filter` -> `set_class_focus`, + `is_annotation_filtered` -> `is_annotation_defocused`. Only the + `draw_annotation` gate changed meaning; the four input/navigation gates + still skip, so hover, Tab and the annotation list stay scoped to one class + while the rest remain visible. + - `state.defocused_opacity` (default 0.4, settable per subtask at + construction or via `set_defocused_opacity`). **0 restores 4.1's skip**, + so a wide segmentation ontology can still opt out of the extra draws. + - Defocused annotations render to one shared scratch canvas and are blitted + back as a single layer. Two reasons, both load-bearing: canvases pack + annotations by fill order rather than by class, so a defocused annotation + can otherwise composite *over* a focused one; and blitting once means + overlapping defocused annotations dim as a group instead of compounding + alpha. It also avoids threading an alpha argument through every + `draw_*` primitive - several of them assign and reset `globalAlpha` + themselves (`draw_bounding_box`, `draw_polygon`, `draw_bitmask`) and + would each have had to multiply instead. + - `draw_annotation` only draws a defocused annotation inside that pass, so + the direct-draw callers (undo/redo, in-progress edits) can't leak one at + full alpha. + - 9 more tests in `tests/class_focus.test.js` (20 total) covering pass + ordering, the blit alpha, the 0 opacity skip, and live-context restore on + a throwing draw. + +### Phase 5 - ULabel: class to spatial type binding + +Motivation: a merged `groundtruth` allows the union of the ontology's modes, +so nothing stops a user drawing a polyline "Crop". Per-class subtasks enforced +this structurally - the pre-256 `buildClassSubtasks` even threaded a `modeFor` +callback - and collapsing to three subtasks gives that up unless ULabel can +express it. A polyline crop is a data-corruption bug better caught at draw +time than at save time. + +- [x] 5.1 Optional `allowed_modes?: ULabelSpatialType[]` on + `ClassDefinition`. Undefined inherits the subtask's list, so every existing + consumer is unaffected. + - A class can only *narrow*: a mode the subtask doesn't allow is dropped + with a warning, and a class whose every declared mode is invalid falls + back to the subtask's list rather than being undrawable. + - The key is only attached when the class actually narrows, so `class_defs` + keeps its existing shape for the common case. + - `get_class_allowed_modes(class_id, subtask_key?)` is the single resolver. +- [x] 5.2 Enforce it both ways: changing class auto-switches to that class's + mode, and the modes it disallows are disabled in the toolbox. + `set_and_update_annotation_mode` rejects a disallowed mode for callers that + bypass the buttons. + - `sync_annotation_modes_to_active_class()` does both, called from + `set_subtask`, `after_init` (the configured initial mode may not suit the + initial class) and the class-button handler. + - Delete modes are exempt in both places: they can't create a wrong-typed + annotation, and hiding them would make the delete class unreachable. + - Re-entrancy guarded, because switching mode can trigger the delete-class + toggle, which clicks a class button, which re-enters the sync. + - 13 tests in `tests/class_allowed_modes.test.js`. +- [x] 5.3 Narrow `findAllClassDefinitions`, which filters class defs by + *subtask* modes today only because per-class modes were not expressible. + - Now asks each class first, falling back to its subtask. The subtask-level + early-out is gone: it would have masked the per-class check. + +### Phase 6 - ULabel: defects found in review + +- [x] 6.1 Unterminated `/**` block in `annotation_operators.ts`, left behind + by the 1.3 `mark_hidden` removal. +- [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 + resolve into GT without losing its place. Prefer 7.9 and skip this. + - Why it is needed at all: currency does triple duty. `record_action` and + `undo` both resolve through `get_current_subtask()`, and + `get_edit_candidates` and Tab only search the current subtask. So + "Tab walks FNs in `diff`" and "my correction lands on `groundtruth`'s + undo stream" are not simultaneously expressible - review and edit are + separate modes unless this ships. + - Note the undo stream is *not* the fragile part: `class_filter` changes + swap nothing, and a run switch leaves `groundtruth` untouched, so GT's + stream is continuous across both. The fragile part is that `diff` is + 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. +- [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`) + +- [x] 7.1 `buildViewSubtasks` back to three specs. Deleted `classSubtaskKeys` + and its slug/dedup path, and the `classKeys[i]` threading through + `ImageViewer`'s call sites. `classAllowedModesFor` **kept**: it is now fed + to ULabel as per-class `allowed_modes` (5.1) so a class still narrows the + subtask's union to its own geometry. Keys are `groundtruth` / `prediction` / + `diff` (`VIEW_SUBTASK_KEYS`), with `SUBTASK_FOR_MODE` mapping the view mode + onto the layer. +- [x] 7.2 `diff` declares TP/FP/FN at ids 0/1/2; real classes keep + `REAL_CLASS_ID_OFFSET = 3`, since `color_info` is instance-wide. Real class + travels as `annotation_meta.diff_class` on both diff paths (segmentation + and matches-doc), which are already scoped to one class. +- [x] 7.3 Class chips drive `set_class_focus` on `groundtruth` / `prediction`; + Diff Colors legend rows drive `set_class_focus("diff", ...)`. One + `set_subtask` per *mode*, not per chip. Non-focused classes dim rather than + disappear (see 4.5), so this keeps the old layout's visual context while + still scoping hover and Tab to one class. +- [x] 7.4 Run switch swaps `prediction` + `diff` in one + `set_annotations_batch`; `groundtruth` is untouched. **Not yet verified in + a browser** - zoom/scroll survival and the single-frame read still need a + manual pass. +- [ ] 7.5 **Deliberately not done.** `pushChainRef` is kept. Batching makes a + swap one call, but `set_annotations_batch` still yields internally (loader + paint + `setTimeout`), so two overlapping batches touching the same subtask + can still interleave clear/init and leave duplicate stacked canvases. + Batching shrinks the window; it does not close it. Dropping the chain needs + a re-entrancy guard inside ULabel, not just fewer calls. `subtaskSig` / + `contentKey` re-checked and unchanged - both are per-key and three stable + keys only make them cheaper. +- [x] 7.6 Recolor through one `set_class_colors` call; the per-class + `set_class_color` + trailing `redraw_all_annotations` loop is gone. +- [x] 7.7 `viewerKey` falls back to `[null, annoEvalItemId]` when + `effectiveImgDims` is null, so two differently-sized items can no longer + share a key. +- [x] 7.8 `ClassCounter` back to `subtasks: "current"` - the current subtask + already carries the right classes in every mode. +- [ ] 7.9 Resolution overlay for the review queue: record accept / reject / + confirm per item app-side and reconcile on save, letting the server + recompute the diff. Task-type agnostic, so it removes the segmentation + (derived client-side) vs keypoint (fetched per threshold) asymmetry, avoids + reimplementing bipartite matching in the worker, and makes 6.3 unnecessary. +- [ ] 7.10 Unpin the ulabel git SHA to a published `^0.28.x` before merge. + Now pinned to `#275509f` on the pushed `three-fixed-subtasks` branch, so a + fresh `npm ci` resolves correctly. Still a branch SHA, not a release. +- [ ] 7.11 `groundtruth` ships `read_only: true` behind an + `editableGroundtruth` flag (default off). The shape supports editing - it is + multi-class, never swapped on a run switch, and owns its own undo stream - + but there is no save path yet, so an editable layer would be a data-loss + footgun. Flip the flag when 7.9 lands. +- [x] 7.12 `classAllowedModesFor` short-circuited to `["bitmask"]` for every + class on a segmentation run, discarding the backend's + `class_spatial_types`. Weeds-Soybean declares `Row: polyline` and the GT for + an item really does load 10 Row polylines alongside 89 Crop bitmasks, so + focusing Row would have forced bitmask mode and made those polylines + uneditable. Dropped the short-circuit; the general path already unions the + class's own geometry with the fallback its diff artifacts render as. + +### Phase 7b - review fixes + +- [x] 7b.1 `set_class_focus` only called `redraw_all_annotations`, which does + not touch the toolbox, but the same change taught + `AnnotationListToolboxItem.get_filtered_annotations` to filter on + `subtask.state.focused_class`. The list kept showing defocused annotations + until some unrelated action refreshed it. Now calls + `toolbox?.redraw_update_items` under the existing `redraw` flag - + `redraw_update_items` rather than `refresh_toolbox` because a focus change + cannot alter filter distances. +- [x] 7b.2 `destroy()` left `state["defocus_scratch"]` holding an image-sized + canvas backing store, the exact leak `front_context = null` exists to + prevent. Nulled alongside `last_brush_stroke`. +- [x] 7b.3 New `LogLevel.WARNING` calls on host-driven APIs + (`set_subtask_opacity`, `set_class_focus`, `set_defocused_opacity`, + `set_annotations_batch`) omitted `hide_alert`, so an ordinary labels/ + subtasks race in the host popped a modal `alert()`. All now pass `true`. + Same fix on the new per-class check in `set_and_update_annotation_mode`, + which is reached from a probe that *expects* false. Init-time config + validation keeps alerting. + +### Phase 8 - history + +- [ ] 8.1 At merge, retarget rather than stack: `gh pr edit 257 --base main` + and close 256, likewise 12 and 10. Both branches are linear descendants of + main with zero divergence, so retargeting needs no rebase and cancels the + add-then-delete churn (resolvers, `hidden`, `replace_subtasks`, + class-grouped canvases) out of main's history and `git blame`. +- [ ] 8.2 Optional, only if the combined diff is too large to review well: + re-slice by nature rather than chronology - bitmask perf (items 1/3/5 plus + 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 + 5.1-5.3 carry their own tests. +- [ ] V4 model-registry on eval run #3: run and mode switches produce zero + rebuilds and preserve zoom with no visible flicker; Tab in `diff` walks + only the filtered outcome; `ClassCounter` reads TP/FP/FN in diff; three + front canvases, heap at or below the N+3 baseline. +- [ ] V5 The edit test, which every layout except 256 failed: a misclassified + 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 38571f19..8a624fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,23 @@ All notable changes to this project will be documented here. -## [unreleased] +## [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. +- New `set_class_color(class_id, color, redraw?)` public API method: writes `color_info` and syncs the id-toolbox swatch and id-dialog color pies. Also fixes the front id-dialog pie not updating (and duplicating) on recolor via the `RecolorActive` toolbox item. +- Fix the hover confidence card sitting on top of the hovered annotation: the card is now populated before it is measured and positioned, so it hugs the edit-button ring instead of drifting onto the anchor. +- `swap_frame_image()` now rejects (and restores the old image) when the new image's dimensions don't match the ones the instance was initialized with, instead of silently misaligning annotations against the new frame. Changing image dimensions requires reinitializing the ULabel instance. ## [0.27.0] - Aug 18th, 2026 - Hovering a spatial annotation now draws a white outline that hugs its shape. diff --git a/api_spec.md b/api_spec.md index fc2bb064..ab8f91b5 100644 --- a/api_spec.md +++ b/api_spec.md @@ -54,6 +54,7 @@ class ULabel({ toolbox_order: AllowedToolboxItem[], distance_filter_toolbox_item: FilterDistanceConfig, image_filters_toolbox_item: ImageFiltersConfig, + class_counter_toolbox_item: ClassCounterConfig, reset_zoom_keybind: string, show_full_image_keybind: string, create_point_annotation_keybind: string, @@ -70,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, @@ -80,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 }) ``` @@ -309,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). @@ -539,6 +545,24 @@ type ConfidenceSliderConfig = { } ``` +### `class_counter_toolbox_item` + +Options for the `ClassCounter` toolbox item (added to `toolbox_order` via `AllowedToolboxItem.ClassCounter`), which displays per-class counts of non-deprecated annotations. + +```javascript +type ClassCounterConfig = { + // Which subtasks to count. "current" follows the active subtask. Default: "current" + "subtasks"?: string[] | "current", + // How counts are laid out. Default: "current" + // - "current": one plain per-class list per counted subtask + // - "grouped": adds a heading per counted subtask + // - "flat": merges shared class ids across subtasks into one summed list + "layout"?: "current" | "grouped" | "flat", +} +``` + +Both options can also be changed at runtime via [`set_class_counter_options()`](#set_class_counter_optionsoptions-redrawtrue). + ### `reset_zoom_keybind` Keybind to reset the zoom level to the `initial_crop`. Default is `r`. @@ -589,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`. @@ -622,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`. @@ -642,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 @@ -651,6 +690,8 @@ Display utilities are provided for a constructed `ULabel` object. *(string, int) => Promise<string>* -- Changes the image source for a given frame. Displays the loading spinner while the new image loads. Returns a `Promise` that resolves with the old source once the new image has been decoded; `await` it if you need to run code after the swap completes. +The new image must match the dimensions this instance was initialized with: the canvases, zoom math, and loaded annotations are all in the init-time image's coordinate space. On a mismatch the old image is restored and the returned `Promise` rejects. Rebuild the ULabel instance to change image dimensions. + ### `swap_anno_bg_color(new_bg_color)` *(string) => string* -- Changes the background color for the annotation box. Returns the old color. @@ -667,9 +708,27 @@ Display utilities are provided for a constructed `ULabel` object. *(string) => array* -- Gets the current list of annotations within the provided subtask. -### `set_annotations(new_annotations, subtask)` +### `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. + +### `set_annotations_batch(annotations_by_subtask, show_loader=true)` + +*(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()` + +*() => void* -- Runs the deferred half of a batched [`set_annotations()`](#set_annotationsnew_annotations-subtask-skip_toolbox_updatefalse) sequence: recomputes distance filtering and redraws the toolbox items once. + +### `set_class_color(class_id, color, redraw=true)` + +*(number | string, string, bool) => void* -- Sets a class's color and syncs every view of it: `color_info`, the id-toolbox swatch, and the id-dialog color pies. When `redraw` is `true`, annotations are redrawn immediately; pass `false` when batching several color changes, then call `redraw_all_annotations()` once at the end. + +### `set_class_counter_options(options, redraw=true)` -*(array, string) => 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. +*(ClassCounterConfig, bool) => bool* -- Updates the [`ClassCounter`](#class_counter_toolbox_item) toolbox item's options at runtime; omitted options keep their current values. When `redraw` is `true` the counter re-renders immediately. Returns whether the `ClassCounter` toolbox item was found. ### `set_saved(saved)` 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/demo/single-class.html b/demo/single-class.html index 5f9a9685..0f78d296 100644 --- a/demo/single-class.html +++ b/demo/single-class.html @@ -59,6 +59,9 @@ // ULabel is now ready for use }); + // Expose ulabel instance globally for testing + window.ulabel = ulabel; + }); diff --git a/index.d.ts b/index.d.ts index a22ca5c2..0590a943 100644 --- a/index.d.ts +++ b/index.d.ts @@ -67,6 +67,11 @@ export type ClassDefinition = { id: number; color: string; keybind: string | null; + /** + * Spatial types this class may be drawn as, narrowing the subtask's + * `allowed_modes`. Undefined or null inherits the subtask's list. + */ + allowed_modes?: ULabelSpatialType[] | null; }; export type SliderInfo = { @@ -158,6 +163,20 @@ export type ConfidenceSliderConfig = { }; }; +/** + * Config object for the ClassCounter ToolboxItem. + */ +export type ClassCounterConfig = { + /** Which subtasks to count. "current" follows the active subtask. Default "current". */ + subtasks?: string[] | "current"; + /** + * How counts are laid out. "current" keeps the plain per-class list, + * "grouped" adds a heading per counted subtask, "flat" merges shared class + * ids across subtasks into one summed list. Default "current". + */ + layout?: "current" | "grouped" | "flat"; +}; + export type ULabelSubmitButton = { name: string; hook: (submit_data: ULabelSubmitData) => void; @@ -301,6 +320,15 @@ export type ULabelConstructorArgs = { instructions_url?: string; 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; }; @@ -334,6 +362,18 @@ export class ULabel { config: Configuration; toolbox: Toolbox; + drag_state: { + active_key: string | null; + release_button: number | null; + } & Record< + "annotation" | "brush" | "edit" | "pan" | "zoom" | "move" | "right", + { + mouse_start: [number, number] | null; + offset_start: [number, number] | null; + zoom_val_start: number | null; + } + >; + color_info: { [key: number]: string }; valid_class_ids: number[]; toolbox_order?: number[]; @@ -374,18 +414,76 @@ export class ULabel { public show_whole_image(): void; public swap_frame_image(new_src: string, frame?: number): Promise; public swap_anno_bg_color(new_bg_color: string): string; + /** + * Set a class's color and sync every view of it: `color_info`, the + * id-toolbox swatch, and the id-dialog pies. Pass `redraw = false` when + * batching several color changes, then redraw once at the end. + */ + public set_class_color(class_id: number | string, color: string, redraw?: boolean): void; + /** + * Recolor several classes as one update, rebuilding the id-dialog pies once + * for the whole map instead of once per class. + */ + public set_class_colors(colors_by_class_id: Record, redraw?: boolean): void; // Subtasks 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; + /** + * 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_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. + */ + public set_defocused_opacity(subtask_key: string, opacity: number, redraw?: boolean): void; + /** + * Set a subtask's layer opacity. Also writes `inactive_opacity` so the value + * survives a subtask switch. + */ + public set_subtask_opacity(subtask_key: string, opacity: number): void; + /** The spatial types a class may be drawn as; falls back to the subtask's list. */ + public get_class_allowed_modes(class_id: number, subtask_key?: string | null): ULabelSpatialType[]; + /** + * Hide the mode buttons the active class disallows and switch off a mode it + * disallows. Delete modes are exempt. + */ + public sync_annotation_modes_to_active_class(): void; // Annotations public get_annotations(subtask: string): ULabelAnnotation[]; - public set_annotations(annotations: ULabelAnnotation[], subtask: string): Promise; + /** + * Replace a subtask's annotations in place. When batching several swaps, pass + * `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, 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, 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; public draw_annotation_from_id(id: string, offset?: Offset, subtask?: string): void; public redraw_annotation(annotation_id: string, subtask?: string, offset?: Offset): void; @@ -416,6 +514,12 @@ export class ULabel { ): void; public get_keypoint_slider_value(): number | null; public get_distance_filter_value(): DistanceFromPolylineClasses | null; + /** + * Update the ClassCounter toolbox item's options at runtime. + * + * @returns whether the ClassCounter toolbox item was found + */ + public set_class_counter_options(options: ClassCounterConfig, redraw?: boolean): boolean; 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; @@ -573,6 +677,7 @@ export class ULabel { ): void; public hide_global_edit_suggestion(): void; public hide_edit_suggestion(): void; + public hide_and_clear_action_candidates(): void; // Edit utils public get_with_access_string( diff --git a/package-lock.json b/package-lock.json index e5b03b98..17e7f478 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ulabel", - "version": "0.27.0", + "version": "0.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ulabel", - "version": "0.27.0", + "version": "0.28.0", "license": "MIT", "devDependencies": { "@eslint/config-inspector": "^1.3.0", diff --git a/package.json b/package.json index 35773fd2..bda50be3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ulabel", "description": "An image annotation tool.", - "version": "0.27.0", + "version": "0.28.0", "main": "dist/ulabel.min.js", "module": "dist/ulabel.min.js", "types": "dist/index.d.ts", @@ -36,7 +36,7 @@ "build-and-demo": "npm run build && npm run demo", "build-dev-and-demo": "npm run build-dev && npm run demo", "build-and-test": "npm run build && npm run test:both", - "prepare": "husky", + "prepare": "node scripts/prepare.js", "lint": "tsc --noEmit && eslint . --no-fix" }, "lint-staged": { diff --git a/scripts/prepare.js b/scripts/prepare.js new file mode 100644 index 00000000..99534dc5 --- /dev/null +++ b/scripts/prepare.js @@ -0,0 +1,38 @@ +// npm "prepare" hook. +// +// This runs both for local development installs and when a consumer installs +// ULabel straight from git (e.g. `npm install github:SenteraLLC/ulabel#`). +// In the git case it is the only chance to turn the source checkout into an +// installable package: npm packs the result using the "files" field, so dist/ +// must exist by the time this script exits. + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const root_dir = path.resolve(__dirname, ".."); +const dist_entry = path.join(root_dir, "dist", "ulabel.min.js"); +const dist_types = path.join(root_dir, "dist", "index.d.ts"); + +function run(command) { + execSync(command, { cwd: root_dir, stdio: "inherit" }); +} + +function setup_git_hooks() { + // Only meaningful in a working clone; consumers and CI have no use for it. + if (process.env.CI || !fs.existsSync(path.join(root_dir, ".git"))) return; + try { + run("husky"); + } catch { + console.warn("[prepare] skipping husky setup"); + } +} + +function build_if_needed() { + if (fs.existsSync(dist_entry) && fs.existsSync(dist_types)) return; + console.log("[prepare] dist/ is missing, running build"); + run("npm run build"); +} + +setup_git_hooks(); +build_if_needed(); 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/annotation_operators.ts b/src/annotation_operators.ts index 287f80fb..39416343 100644 --- a/src/annotation_operators.ts +++ b/src/annotation_operators.ts @@ -625,11 +625,12 @@ export function filter_points_distance_from_line(ulabel: ULabel, recalculate_dis /** * 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. + * restricts to classes that allow at least one of the provided `allowed_modes`, honouring a + * class's own `allowed_modes` where it narrows its subtask's. Class definitions are + * de-duplicated by id, and the reserved delete class is skipped. * * @param ulabel ULabel object - * @param allowed_modes If provided, only include subtasks that allow at least one of these modes + * @param allowed_modes If provided, only include classes that allow at least one of these modes * @returns A de-duplicated list of class definitions */ export function findAllClassDefinitions(ulabel: ULabel, allowed_modes: ULabelSpatialType[] | null = null): ClassDefinition[] { @@ -640,11 +641,6 @@ export function findAllClassDefinitions(ulabel: ULabel, allowed_modes: ULabelSpa for (const subtask_key in ulabel.subtasks) { const subtask = ulabel.subtasks[subtask_key]; - // 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 @@ -652,6 +648,12 @@ export function findAllClassDefinitions(ulabel: ULabel, allowed_modes: ULabelSpa // Skip classes we've already added (de-duplicate by id) if (seen_ids.has(current_class_def.id)) return; + // A class may narrow the subtask's modes, so ask the class first + if (allowed_modes !== null) { + const class_modes = current_class_def.allowed_modes ?? subtask.allowed_modes; + if (!allowed_modes.some((mode) => class_modes.includes(mode))) return; + } + seen_ids.add(current_class_def.id); class_defs.push(current_class_def); }); diff --git a/src/blobs.js b/src/blobs.js index be942e96..4da4358f 100644 --- a/src/blobs.js +++ b/src/blobs.js @@ -1862,7 +1862,8 @@ div#${prntid} div.dialogs_container { position: absolute; top: 0; left: 0; - z-index: ${BACK_Z_INDEX + 1}; + /* Above the annotation canvases */ + z-index: ${BACK_Z_INDEX + 2}; } div.toolbox_inner_cls { diff --git a/src/configuration.ts b/src/configuration.ts index 066ad150..ef085411 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,6 +1,7 @@ import type { FilterDistanceConfig, ConfidenceSliderConfig, + ClassCounterConfig, ImageFiltersConfig, InitialCrop, ImageData, @@ -119,7 +120,6 @@ export class Configuration { public annbox_id: string = "annbox"; public imwrap_id: string = "imwrap"; public canvas_fid_pfx: string = "front-canvas"; - public canvas_bid_pfx: string = "back-canvas"; public canvas_did: string = "demo-canvas"; public canvas_class: string = "easel"; public image_id_pfx: string = "ann_image"; @@ -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 = {}; @@ -215,6 +223,9 @@ export class Configuration { // Config for ConfidenceSlider public confidence_slider_toolbox_item: ConfidenceSliderConfig = DEFAULT_CONFIDENCE_SLIDER_CONFIG; + // Config for ClassCounterToolboxItem. Option defaults resolve in the item. + public class_counter_toolbox_item: ClassCounterConfig = {}; + // Config for ImageFiltersToolboxItem public image_filters_toolbox_item: ImageFiltersConfig = DEFAULT_IMAGE_FILTERS_CONFIG; @@ -264,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/geometric_utils.ts b/src/geometric_utils.ts index b9373846..5c178fb3 100644 --- a/src/geometric_utils.ts +++ b/src/geometric_utils.ts @@ -543,6 +543,48 @@ export class GeometricUtils { return false; } + /** Squared distance from a point to a line segment, endpoints included. */ + public static point_segment_distance_squared( + point: Point2D, + kp1: Point2D, + kp2: Point2D, + ): number { + const dx: number = kp2[0] - kp1[0]; + const dy: number = kp2[1] - kp1[1]; + const len_sq: number = dx * dx + dy * dy; + let t: number = 0; + if (len_sq > 0) { + t = ((point[0] - kp1[0]) * dx + (point[1] - kp1[1]) * dy) / len_sq; + t = Math.max(0, Math.min(1, t)); + } + const nx: number = kp1[0] + t * dx - point[0]; + const ny: number = kp1[1] + t * dy - point[1]; + return nx * nx + ny * ny; + } + + /** Whether a point falls within `threshold` of a polyline's path. */ + public static point_is_near_polyline( + point: Point2D, + polyline: ULabelSpatialPayload2D, + threshold: number, + ): boolean { + if (polyline.length === 0) return false; + const threshold_sq: number = threshold * threshold; + if (polyline.length === 1) { + const dx: number = polyline[0][0] - point[0]; + const dy: number = polyline[0][1] - point[1]; + return dx * dx + dy * dy <= threshold_sq; + } + for (let i = 0; i < polyline.length - 1; i++) { + if ( + GeometricUtils.point_segment_distance_squared(point, polyline[i], polyline[i + 1]) <= threshold_sq + ) { + return true; + } + } + return false; + } + // Convert a bbox to a simple polygon by adding the last point public static bbox_to_simple_polygon( bbox: ULabelSpatialPayload2D, diff --git a/src/html_builder.ts b/src/html_builder.ts index aeea5d49..8cf7d920 100644 --- a/src/html_builder.ts +++ b/src/html_builder.ts @@ -548,7 +548,9 @@ export function build_confidence_dialog(ulabel: ULabel) {
`); - // Style the dialog + // Style the dialog. Absolutely positioned against the 0-height edit-suggestion + // container, whose top edge is the annotation's anchor; show_global_edit_suggestion + // sets top/bottom per hover to hug the button ring above or below the anchor. $("#" + global_id).css({ "background-color": "rgba(0, 0, 0, 0.75)", "color": "white", @@ -556,10 +558,10 @@ export function build_confidence_dialog(ulabel: ULabel) { "width": "auto", "min-width": "8em", "padding": "0.2em 0.4em", - "margin-top": "-9.5em", "border-radius": "0.5em", "font-size": "1.1em", - "position": "relative", + "position": "absolute", + "bottom": "40px", "left": "50%", "transform": "translateX(-50%)", "pointer-events": "none", diff --git a/src/index.js b/src/index.js index 74daee59..0be43d9e 100644 --- a/src/index.js +++ b/src/index.js @@ -31,7 +31,9 @@ import { log_message, LogLevel } from "../build/error_logging"; 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_local_storage_item, set_local_storage_item } from "../build/utilities"; +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"; const jQuery = $; @@ -60,6 +62,12 @@ jQuery.fn.outer_html = function () { // Valid brush overlap modes for bitmask painting (see set_brush_overlap_mode). const BRUSH_OVERLAP_MODES = ["none", "exclude", "overwrite"]; +// Width, in image pixels, of the contour drawn around a hovered bitmask. +const BITMASK_OUTLINE_BORDER = 2; + +// Opacity of annotations outside a subtask's focused class. 0 skips them. +const DEFAULT_DEFOCUSED_OPACITY = 0.4; + export class ULabel { static version() { return ULABEL_VERSION; @@ -194,9 +202,8 @@ export class ULabel { } if (subtask.state) { subtask.state["annotation_contexts"] = {}; - // Front/back contexts each retain an image-sized canvas backing store; letting - // them go lets `container.innerHTML = ""` below actually release those pixels. - subtask.state["back_context"] = null; + // The front context retains an image-sized canvas backing store; letting + // it go lets `container.innerHTML = ""` below actually release those pixels. subtask.state["front_context"] = null; // A stroke interrupted before finish_bitmask() leaves a full pre-stroke RLE here. subtask.state["bitmask_stroke"] = null; @@ -204,6 +211,8 @@ export class ULabel { } if (this.state) { this.state["last_brush_stroke"] = null; + // Image-sized scratch canvas used to composite defocused annotations. + this.state["defocus_scratch"] = null; } // 5. Break the toolbox <-> ulabel back-reference. Toolbox items keep a @@ -269,6 +278,20 @@ export class ULabel { // Set to single class mode if applicable subtask.single_class_mode = (raw_subtask_json.classes.length === 1); + // A class may narrow the subtask's spatial types, but never widen them. + const narrow_allowed_modes = (raw_modes, class_name) => { + if (!Array.isArray(raw_modes)) return null; + const narrowed = raw_modes.filter((mode) => { + if (subtask.allowed_modes.includes(mode)) return true; + log_message( + `Class "${class_name}" in subtask "${subtask_key}" allows mode ${mode}, which the subtask does not. Ignoring it.`, + LogLevel.WARNING, + ); + return false; + }); + return narrowed.length > 0 ? narrowed : null; + }; + // Populate allowed classes vars // TODO might be nice to recognize duplicate classes and assign same color... idk // TODO better handling of default class ids would definitely be a good idea @@ -279,7 +302,7 @@ export class ULabel { for (const class_definition of raw_subtask_json.classes) { // Create a class definition based on the provided class_definition that will be saved to the subtask let modifed_class_definition = {}; - let name, id, color, keybind; + let name, id, color, keybind, class_allowed_modes; switch (typeof class_definition) { case "string": modifed_class_definition = { @@ -304,8 +327,8 @@ export class ULabel { // Only create an id if one wasn't provided id = class_definition.id ?? ULabel.create_unused_class_id(ulabel); - if (ulabel.valid_class_ids.includes(id)) { - log_message(`Duplicate class id ${id} detected. This is not supported and may result in unintended side-effects. + if (subtask.class_ids.includes(id)) { + log_message(`Duplicate class id ${id} detected within subtask ${subtask_key}. This is not supported and may result in unintended side-effects. This may be caused by mixing string and object class definitions, or by assigning the same id to two or more object class definitions.`, LogLevel.WARNING); } @@ -322,6 +345,12 @@ export class ULabel { color: color, keybind: keybind, }; + + // Only present when the class actually narrows the subtask + class_allowed_modes = narrow_allowed_modes(class_definition.allowed_modes, name); + if (class_allowed_modes !== null) { + modifed_class_definition.allowed_modes = class_allowed_modes; + } break; default: log_message(`Entry in classes not understood: ${class_definition}\n${class_definition} must either be a string or an object.`, LogLevel.ERROR); @@ -331,8 +360,11 @@ export class ULabel { subtask.class_defs.push(modifed_class_definition); subtask.class_ids.push(modifed_class_definition.id); - // Also save the id and color_info on the ULabel object - ulabel.valid_class_ids.push(modifed_class_definition.id); + // Also save the id and color_info on the ULabel object. Subtasks may + // legitimately share a class, so this stays a set. + if (!ulabel.valid_class_ids.includes(modifed_class_definition.id)) { + ulabel.valid_class_ids.push(modifed_class_definition.id); + } ulabel.color_info[modifed_class_definition.id] = modifed_class_definition.color; } @@ -345,7 +377,9 @@ export class ULabel { color: COLORS[1], keybind: null, }); - ulabel.valid_class_ids.push(DELETE_CLASS_ID); + if (!ulabel.valid_class_ids.includes(DELETE_CLASS_ID)) { + ulabel.valid_class_ids.push(DELETE_CLASS_ID); + } ulabel.color_info[DELETE_CLASS_ID] = COLORS[1]; } } @@ -371,6 +405,11 @@ export class ULabel { data: new Uint8Array(raw_payload.data), size: [raw_payload.size[0], raw_payload.size[1]], }; + // A cropped payload's box has to survive the clone, or the buffer would be + // misread as full-frame. + if (raw_payload.box !== undefined) { + cloned.spatial_payload.box = { ...raw_payload.box }; + } return cloned; } return JSON.parse(JSON.stringify(raw)); @@ -484,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; @@ -533,7 +584,6 @@ export class ULabel { // Label canvasses and initialize context with null ul.subtasks[subtask_key]["canvas_fid"] = ul.config["canvas_fid_pfx"] + "__" + subtask_key; - ul.subtasks[subtask_key]["canvas_bid"] = ul.config["canvas_bid_pfx"] + "__" + subtask_key; // Store state of ID dialog element // TODO much more here when full interaction is built @@ -550,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 @@ -566,11 +619,15 @@ export class ULabel { move_candidate: null, hovered_annid: null, fly_to_idx: 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 front_context: null, - back_context: null, annotation_contexts: {}, // {canvas_id: {context: ctx, annotation_ids: []}, ...} // Generic dialogs @@ -809,6 +866,9 @@ export class ULabel { toolbox_item.after_init(); } + // The configured initial mode may not be one the initial class allows + this.sync_annotation_modes_to_active_class(); + // Show the brush toolbox if bitmask is the initial mode (brush starts off; toggle to paint) if (this.get_current_subtask()["state"]["annotation_mode"] === "bitmask") { BrushToolboxItem.show_brush_toolbox_item(); @@ -1043,13 +1103,114 @@ 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); } } + /** + * Set a subtask's layer opacity. Also writes `inactive_opacity` so the value + * survives a subtask switch, which otherwise resets every slider. + * @param {string} subtask_key + * @param {number} opacity between 0 and 1 + */ + set_subtask_opacity(subtask_key, opacity) { + const subtask = this.subtasks[subtask_key]; + if (subtask === undefined) { + log_message(`set_subtask_opacity: unknown subtask key ${subtask_key}`, LogLevel.WARNING, true); + return; + } + 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); + } + + /** + * Whether a class focus is active on a subtask and this annotation is not in it. + * Affects drawing and input targeting only: a defocused annotation is still + * real data, so geometry (bitmask overlap, merges) must ignore this. + * @param {object} annotation + * @param {string} subtask_key + * @returns {boolean} + */ + is_annotation_defocused(annotation, subtask_key) { + const subtask = this.subtasks[subtask_key]; + 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); + } + + /** + * 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_active_class(class_id, subtask_key = null, redraw = true) { + return set_active_class(this, class_id, subtask_key, redraw); + } + + /** + * 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()); + } + + /** + * 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); + } + + /** + * Opacity for annotations outside the focused class. 0 skips drawing them + * entirely, which is cheaper but loses them as visual context. + * @param {string} subtask_key + * @param {number} opacity + * @param {boolean} redraw + */ + set_defocused_opacity(subtask_key, opacity, redraw = true) { + set_defocused_opacity(this, subtask_key, opacity, redraw); + } + set_subtask(st_key) { let old_st = this.get_current_subtask_key(); @@ -1064,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; @@ -1079,6 +1250,7 @@ export class ULabel { // Show appropriate set of annotation modes $("a.md-btn").css("display", "none"); $("a.md-btn.md-en4--" + st_key).css("display", "inline-block"); + this.sync_annotation_modes_to_active_class(); // Show appropriate set of class options $("div.tb-id-app").css("display", "none"); @@ -1107,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); + } } /** @@ -1260,6 +1436,27 @@ export class ULabel { return item.get_current_values(); } + /** + * Update the ClassCounter toolbox item's options at runtime. + * + * @param {object} options `{subtasks?: string[] | "current", layout?: "current" | "grouped" | "flat"}` + * @param {boolean} redraw whether to re-render the counter immediately + * @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); + if (redraw) { + item.redraw_update(this); + } + return true; + } + // Show annotation mode show_annotation_mode(el = null) { if (el === null) { @@ -1284,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; @@ -1298,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 @@ -1308,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); } } } @@ -1339,11 +1536,72 @@ export class ULabel { log_message(`Annotation mode ${annotation_mode} is not allowed for subtask ${this.get_current_subtask_key()}`, LogLevel.WARNING); return false; } + // 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)) { + return false; + } + } // Set the new mode via the toolbox document.getElementById("md-btn--" + annotation_mode).click(); return true; } + /** + * The spatial types a class may be drawn as, falling back to the subtask's + * list for classes that don't narrow it. + * + * @param {number} class_id + * @param {string|null} subtask_key defaults to the current subtask + * @returns {string[]} + */ + get_class_allowed_modes(class_id, subtask_key = null) { + const subtask = this.subtasks[subtask_key ?? this.get_current_subtask_key()]; + if (subtask === undefined) return []; + const class_def = subtask["class_defs"].find((def) => def["id"] === class_id); + const class_modes = class_def == null ? null : class_def["allowed_modes"]; + if (class_modes == null || class_modes.length === 0) return subtask["allowed_modes"]; + return class_modes; + } + + /** + * Hide the mode buttons the active class disallows and, if the current mode + * is one of them, switch to a mode the class does allow. Without this a + * multi-class subtask lets any class be drawn as any of its spatial types. + * + * Delete modes are exempt: they can't produce a wrong-typed annotation, and + * hiding them would make the delete class unreachable. + */ + sync_annotation_modes_to_active_class() { + // Switching modes can re-enter this through the delete-class toggle + if (this._is_syncing_modes_to_class) return; + + const subtask = this.get_current_subtask(); + // State, not the toolbox DOM: this runs before the toolbox has rendered + const class_id = get_active_class_id(this); + if (class_id === undefined || class_id === DELETE_CLASS_ID) return; + + const allowed = this.get_class_allowed_modes(class_id); + this._is_syncing_modes_to_class = true; + try { + for (const mode of subtask["allowed_modes"]) { + if (DELETE_MODES.includes(mode)) continue; + $("a#md-btn--" + mode).css("display", allowed.includes(mode) ? "inline-block" : "none"); + } + + const current_mode = subtask["state"]["annotation_mode"]; + if (DELETE_MODES.includes(current_mode) || allowed.includes(current_mode)) return; + + const fallback = allowed.find((mode) => !DELETE_MODES.includes(mode)); + if (fallback === undefined) return; + document.getElementById("md-btn--" + fallback)?.click(); + } finally { + this._is_syncing_modes_to_class = false; + } + } + // Draw demo annotation in demo canvas redraw_demo() { // this.state["demo_canvas_context"].clearRect(0, 0, this.config["demo_width"] * this.config["px_per_px"], this.config["demo_height"] * this.config["px_per_px"]); @@ -1433,10 +1691,10 @@ export class ULabel { if (subtask === null) { subtask = this.get_current_subtask_key(); } - const canvas_ids = Object.keys(this.subtasks[subtask]["state"]["annotation_contexts"]); + const contexts = this.subtasks[subtask]["state"]["annotation_contexts"]; + const canvas_ids = Object.keys(contexts); for (let i = 0; i < canvas_ids.length; i++) { - // If the canvas has less than n_annos_per_canvas annotations, return its ID - if (this.subtasks[subtask]["state"]["annotation_contexts"][canvas_ids[i]]["annotation_ids"].length < this.config.n_annos_per_canvas) { + if (contexts[canvas_ids[i]]["annotation_ids"].length < this.config.n_annos_per_canvas) { return canvas_ids[i]; } } @@ -1453,7 +1711,6 @@ export class ULabel { create_annotation_canvas(subtask) { const canvas_id = `canvas__${this.make_new_annotation_id()}`; - // Add canvas to the "canvasses__${subtask}" div $("#canvasses__" + subtask).append(` { + if (!this.get_current_subtask()["state"]["idd_thumbnail"]) { + this.handle_id_dialog_hover(mouse_event); + } + }); + } + + /** + * Set a class's color and sync every view of it: `color_info`, the + * id-toolbox swatch, and the id-dialog pies. + * + * @param {number|string} class_id class id to recolor + * @param {string} color new color + * @param {boolean} redraw whether to redraw annotations immediately. Pass + * 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(); + + if (redraw) { + this.redraw_all_annotations(); + } + } + + /** + * Recolor several classes as one update. The id-dialog pies are rebuilt + * once for the whole map rather than once per class, which is what makes a + * recolor loop quadratic in DOM work. + * + * @param {Record} colors_by_class_id class id to color + * @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; + + for (const class_id of class_ids) { + this._apply_class_color(class_id, colors_by_class_id[class_id]); + } + this.rebuild_id_dialog_pies(); + + if (redraw) { + this.redraw_all_annotations(); + } + } + + /** + * The cheap half of a recolor. Leaves the id-dialog pies to the caller so a + * batch can pay for that rebuild once. + */ + _apply_class_color(class_id, color) { + this.color_info[class_id] = color; + + // Toolbox swatch for this class + const button_color_square = document.querySelector(`#${this.config["toolbox_id"]}_sel_${class_id} > div`); + if (button_color_square) { + button_color_square.style.backgroundColor = color; + } + } + get_annotation_color(annotation) { + const gradient_val = $("#gradient-slider").val() / 100; + // Use the annotation's class id to get the color of the annotation const class_id = get_annotation_class_id(annotation); const color = this.color_info[class_id]; @@ -1704,7 +2065,7 @@ export class ULabel { // Return the color after applying a gradient to it based on its confidence // If gradients are disabled, get_gradient will return the passed in color - return get_gradient(annotation, color, get_annotation_confidence, $("#gradient-slider").val() / 100); + return get_gradient(annotation, color, get_annotation_confidence, gradient_val); } get_active_class_color() { @@ -2108,7 +2469,7 @@ export class ULabel { // the cache; only a mask edit (version bump) or color change forces a rebuild. let render = annotation_object["_mask_render"]; if (render == null || render.mask !== mask || render.version !== mask.version || render.color !== color) { - render = this.build_bitmask_render(annotation_object, mask, color); + render = this.build_bitmask_render(mask, color); if (render === null) return; // Empty mask, nothing to draw this.set_bitmask_render(annotation_object, render); } @@ -2132,53 +2493,53 @@ export class ULabel { ctx.globalAlpha = 1.0; if (this.is_annotation_hovered(annotation_object)) { - // Build a white contour by dilating the mask shape and cutting out the interior - const border = 2; - const ow = render.box_width + border * 2; - const oh = render.box_height + border * 2; - const outline = document.createElement("canvas"); - outline.width = ow; - outline.height = oh; - const octx = outline.getContext("2d"); - const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [-1, 1], [1, -1], [1, 1]]; - for (const [ox, oy] of dirs) { - octx.drawImage(render.canvas, border + ox * border, border + oy * border); - } - octx.globalCompositeOperation = "source-in"; - octx.fillStyle = "white"; - octx.fillRect(0, 0, ow, oh); - octx.globalCompositeOperation = "destination-out"; - octx.drawImage(render.canvas, border, border); - const blit_x = (render.tlx + diffX - border) * px_per_px; - const blit_y = (render.tly + diffY - border) * px_per_px; - ctx.drawImage(outline, blit_x, blit_y, ow * px_per_px, oh * px_per_px); - } - } - - // Build a native-resolution, class-colored stencil for a bitmask's bounding box. - // Returns { canvas, tlx, tly, box_width, box_height, mask, version, color } or null if empty. - build_bitmask_render(annotation_object, mask, color) { - const image_width = this.config["image_width"]; - const image_height = this.config["image_height"]; + const outline = this.get_bitmask_outline(render); + const blit_x = (render.tlx + diffX - BITMASK_OUTLINE_BORDER) * px_per_px; + const blit_y = (render.tly + diffY - BITMASK_OUTLINE_BORDER) * px_per_px; + ctx.drawImage(outline, blit_x, blit_y, outline.width * px_per_px, outline.height * px_per_px); + } + } - // Only rasterize the mask's bounding box rather than the whole image. The containing - // box is maintained as a superset of the foreground (see rebuild_bitmask_containing_box), - // so every painted pixel is covered. Fall back to a full scan only if it is missing. - let box = annotation_object["containing_box"]; - if (box == null) { - box = mask.get_bounding_box(); - if (box === null) return null; + // Build (once per render) a white contour by dilating the mask shape and cutting out the + // interior. Cached on the render, which is already discarded whenever the mask version or + // class color changes, so hovering never re-rasterizes. + get_bitmask_outline(render) { + if (render.outline != null) return render.outline; + + const border = BITMASK_OUTLINE_BORDER; + const ow = render.box_width + border * 2; + const oh = render.box_height + border * 2; + const outline = document.createElement("canvas"); + outline.width = ow; + outline.height = oh; + const octx = outline.getContext("2d"); + const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [-1, 1], [1, -1], [1, 1]]; + for (const [ox, oy] of dirs) { + octx.drawImage(render.canvas, border + ox * border, border + oy * border); } + octx.globalCompositeOperation = "source-in"; + octx.fillStyle = "white"; + octx.fillRect(0, 0, ow, oh); + octx.globalCompositeOperation = "destination-out"; + octx.drawImage(render.canvas, border, border); + + render.outline = outline; + return outline; + } + + // Build a native-resolution, class-colored stencil for a bitmask's stored window. + // Returns { canvas, tlx, tly, box_width, box_height, mask, version, color } or null if empty. + build_bitmask_render(mask, color) { + // A mask only holds pixels for its window, which is already a superset of the + // foreground, so the window is exactly the region worth rasterizing. + const box = mask.get_window_box(); + if (box === null) return null; // Empty mask, nothing to draw - const tlx = Math.max(0, Math.floor(box.tlx)); - const tly = Math.max(0, Math.floor(box.tly)); - const brx = Math.min(image_width - 1, Math.ceil(box.brx)); - const bry = Math.min(image_height - 1, Math.ceil(box.bry)); - const box_width = brx - tlx + 1; - const box_height = bry - tly + 1; - if (box_width <= 0 || box_height <= 0) return null; + const box_width = mask.window_width; + const box_height = mask.window_height; - // Build an opaque white stencil of just the box region at native resolution + // Stencil the window at native resolution. Only alpha is written; the tint below + // fills color wherever alpha survives, so this is one store per foreground pixel. const offscreen = document.createElement("canvas"); offscreen.width = box_width; offscreen.height = box_height; @@ -2186,17 +2547,9 @@ export class ULabel { const image_data = offscreen_ctx.createImageData(box_width, box_height); const data = image_data.data; const mask_data = mask.data; - for (let y = tly; y <= bry; y++) { - const mask_row = y * image_width; - const local_row = (y - tly) * box_width; - for (let x = tlx; x <= brx; x++) { - if (mask_data[mask_row + x] !== 0) { - const j = (local_row + (x - tlx)) * 4; - data[j] = 255; - data[j + 1] = 255; - data[j + 2] = 255; - data[j + 3] = 255; - } + for (let i = 0, j = 3; i < mask_data.length; i++, j += 4) { + if (mask_data[i] !== 0) { + data[j] = 255; } } offscreen_ctx.putImageData(image_data, 0, 0); @@ -2209,8 +2562,8 @@ export class ULabel { return { canvas: offscreen, - tlx: tlx, - tly: tly, + tlx: box.tlx, + tly: box.tly, box_width: box_width, box_height: box_height, mask: mask, @@ -2383,8 +2736,10 @@ export class ULabel { draw_annotation(annotation_object, offset = null, subtask = null) { // DEBUG left here for refactor reference, but I don't think it's needed moving forward // there may be a use case for drawing depreacted annotations - // Don't draw if deprecated if (annotation_object["deprecated"]) return; + // Defocused annotations are only drawn by the scratch pass, which dims + // them as a layer; anything else reaching here would draw at full alpha. + if (!this.state["drawing_defocused"] && this.is_annotation_defocused(annotation_object, subtask)) return; // Get actual context from context key and subtask let context = null; @@ -2472,25 +2827,95 @@ export class ULabel { // If the subtask is vanished, don't draw anything if (this.subtasks[subtask]["state"]["is_vanished"]) return; - // Handle redraw of each annotation in the context - for (const annid of this.subtasks[subtask]["state"]["annotation_contexts"][canvas_id]["annotation_ids"]) { + const draw = (annid) => { // Only draw with offset if the annotation is in the list of annotations to offset, or if the list is null if (annotation_ids_to_offset === null || annotation_ids_to_offset.includes(annid)) { this.draw_annotation_from_id(annid, offset, subtask); } else { this.draw_annotation_from_id(annid, null, subtask); } - } + }; + this.draw_context_in_focus_passes(canvas_id, subtask, draw); } // Redraw a context, skipping one annotation. Used to snapshot the static masks during a move. redraw_annotation_context_excluding(canvas_id, subtask, exclude_id) { this.clear_annotation_canvas(canvas_id, subtask); if (this.subtasks[subtask]["state"]["is_vanished"]) return; - for (const annid of this.subtasks[subtask]["state"]["annotation_contexts"][canvas_id]["annotation_ids"]) { - if (annid === exclude_id) continue; + this.draw_context_in_focus_passes(canvas_id, subtask, (annid) => { + if (annid === exclude_id) return; this.draw_annotation_from_id(annid, null, subtask); + }); + } + + /** + * Draw a whole annotation context, honouring the subtask's class focus. + * + * Defocused annotations go to a scratch canvas first and are blitted back as + * one layer, so they dim as a group rather than compounding alpha, and they + * land underneath the focused ones. Canvases pack annotations by fill order, + * not by class, so without the separate pass a defocused annotation drawn + * later would composite over a focused one. + * + * @param {string} canvas_id + * @param {string} subtask + * @param {(annid: string) => void} draw draws one annotation into the live context + */ + 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"]; + if (!this.subtasks[subtask]["focus_active_class"]) { + for (const annid of annotation_ids) draw(annid); + return; } + + const access = this.subtasks[subtask]["annotations"]["access"]; + const defocused = []; + const focused = []; + for (const annid of annotation_ids) { + if (this.is_annotation_defocused(access[annid], subtask)) { + defocused.push(annid); + } else { + focused.push(annid); + } + } + + const defocused_opacity = this.subtasks[subtask]["state"]["defocused_opacity"]; + if (defocused.length > 0 && defocused_opacity > 0) { + const live = context_entry["context"]; + const scratch = this.get_defocus_scratch_context(live.canvas); + // Redirect draws at the scratch: the draw primitives resolve their + // context through this entry rather than taking it as an argument. + context_entry["context"] = scratch; + this.state["drawing_defocused"] = true; + try { + for (const annid of defocused) draw(annid); + } finally { + this.state["drawing_defocused"] = false; + context_entry["context"] = live; + } + live.globalAlpha = defocused_opacity; + live.drawImage(scratch.canvas, 0, 0); + live.globalAlpha = 1.0; + } + for (const annid of focused) draw(annid); + } + + // One scratch canvas per instance; every defocus pass blits and finishes + // before the next begins, so it can be shared across subtasks. + get_defocus_scratch_context(live_canvas) { + let scratch = this.state["defocus_scratch"]; + if (scratch == null) { + scratch = document.createElement("canvas").getContext("2d"); + this.state["defocus_scratch"] = scratch; + } + if (scratch.canvas.width !== live_canvas.width || scratch.canvas.height !== live_canvas.height) { + scratch.canvas.width = live_canvas.width; + scratch.canvas.height = live_canvas.height; + } else { + scratch.clearRect(0, 0, scratch.canvas.width, scratch.canvas.height); + } + return scratch; } // Snapshot the moving bitmask's canvas with the moving mask removed, so each move frame @@ -2813,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 @@ -2834,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) { @@ -2849,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"; @@ -2881,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. @@ -3208,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 @@ -3218,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)) { @@ -3532,12 +3961,24 @@ 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"); - // Hide the move/reid/delete buttons on read-only subtasks; the confidence card still shows - esjq.find(".global_sub_suggestion").css("display", is_read_only ? "none" : ""); + // 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" : ""); let cbox = current_subtask["annotations"]["access"][annid]["containing_box"]; let new_lft = (cbox["tlx"] + cbox["brx"] + 2 * diffX) / (2 * this.config["image_width"]); let new_top = (cbox["tly"] + cbox["bry"] + 2 * diffY) / (2 * this.config["image_height"]); @@ -3545,11 +3986,31 @@ export class ULabel { current_subtask["state"]["visible_dialogs"][esid]["top"] = new_top; // Decide confidence card position from the un-offset cbox so it stays stable during moves. // Account for annbox scroll: what matters is the visible position, not the image-space position. - const cbox_center_y_imwrap = ((cbox["tly"] + cbox["bry"]) / 2) * this.state["zoom_val"]; const scroll_top = $("#" + this.config["annbox_id"]).scrollTop() || 0; const conf_id = `global_annotation_confidence__${subtask_key}`; - const flip_below = (cbox_center_y_imwrap - scroll_top) < 100; - $(`#${conf_id}`).css("margin-top", flip_below ? "-1em" : "-9.5em"); + const conf_jq = $(`#${conf_id}`); + // The dialog container is CSS-scaled about the anchor, so offsets set + // here land `scale` times as far; the cbox measurements are in screen px. + const es_el = esjq[0]; + const scale = es_el.offsetWidth > 0 ? + es_el.getBoundingClientRect().width / es_el.offsetWidth : + 1; + const card_height = conf_jq.outerHeight() || 0; + const gap = 10; + // The buttons ring the anchor (box centre) via translateY(-50%); hug the + // ring by clearing half a button's height (local units) plus the gap. + // The card is positioned absolutely against the 0-height anchor container: + // deriving the position from the card's own layout (offsetTop) feeds + // integer rounding back into the next mousemove and makes the card jitter. + const ring_clearance = (esjq.find("a.global_sub_suggestion").outerHeight() || 60) / 2 + gap / scale; + const anchor_visible = ((cbox["tly"] + cbox["bry"]) / 2 * this.state["zoom_val"]) - scroll_top; + const card_top_visible = anchor_visible - (ring_clearance + card_height) * scale; + const flip_below = card_top_visible < 0; + conf_jq.css( + flip_below ? + { top: `${ring_clearance}px`, bottom: "auto" } : + { top: "auto", bottom: `${ring_clearance}px` }, + ); this.reposition_dialogs(); idd_x = (cbox["tlx"] + cbox["brx"] + 2 * diffX) / 2; idd_y = (cbox["tly"] + cbox["bry"] + 2 * diffY) / 2; @@ -3561,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(); } } @@ -3573,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; @@ -3662,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 ================= @@ -3697,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; } @@ -4279,7 +4784,10 @@ export class ULabel { init_id_payload = redo_payload.init_payload; } - let canvas_id = this.get_init_canvas_context_id(annotation_id, subtask_key); + let canvas_id = this.get_init_canvas_context_id( + annotation_id, + subtask_key, + ); // TODO(3d) if (NONSPATIAL_MODES.includes(annotation_mode)) { @@ -4911,8 +5419,11 @@ export class ULabel { const subtask_key = this.get_current_subtask_key(); const current_subtask = this.subtasks[subtask_key]; const annotation_id = this.make_new_annotation_id(); - const canvas_id = this.get_init_canvas_context_id(annotation_id, subtask_key); const init_id_payload = this.get_init_id_payload("bitmask"); + const canvas_id = this.get_init_canvas_context_id( + annotation_id, + subtask_key, + ); current_subtask["annotations"]["access"][annotation_id] = { id: annotation_id, @@ -5165,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 @@ -6114,11 +6628,21 @@ export class ULabel { }; let minsize = Infinity; let found_containing_annotation = false; + // A read-only subtask has nothing to grab, so the near-miss fallback + // below is just noise: require a real hit wherever one can be tested. + const require_exact_hit = this.is_current_subtask_read_only(); + const current_subtask_key = this.get_current_subtask_key(); + // One cursor pixel spans several image pixels when zoomed out, so the + // exact tests get that much slack. Without it a thin mask or the edge + // of a large one is unhittable: the pixel under the cursor is empty + // even though the downscaled render looks filled there. + const slack = 2 / this.get_empirical_scale(); // TODO(3d) for (let edi = 0; edi < this.get_current_subtask()["annotations"]["ordering"].length; edi++) { const annotation_id = this.get_current_subtask()["annotations"]["ordering"][edi]; let annotation = this.get_current_subtask()["annotations"]["access"][annotation_id]; if (annotation["deprecated"]) continue; + if (this.is_annotation_defocused(annotation, current_subtask_key)) continue; let cbox = annotation["containing_box"]; let frame = annotation["frame"]; const spatial_type = annotation["spatial_type"]; @@ -6147,6 +6671,9 @@ export class ULabel { (this.state["current_frame"] <= cbox["brz"]) ) { let is_a_containing_annotation = false; + // Whether this spatial type can be hit-tested against its real + // boundary rather than just its containing box. + let has_exact_test = true; let boxsize = (cbox["brx"] - cbox["tlx"]) * (cbox["bry"] - cbox["tly"]); switch (spatial_type) { case "polygon": @@ -6166,14 +6693,28 @@ export class ULabel { is_a_containing_annotation = true; } break; + case "polyline": + // Within the drawn stroke of the line itself + if (GeometricUtils.point_is_near_polyline( + [gblx, gbly], + annotation["spatial_payload"], + (annotation["line_size"] ?? this.get_subtask_line_size()) / 2 + slack, + )) { + is_a_containing_annotation = true; + } + break; case "bitmask": // The mouse must be over a painted pixel of the mask - if (this.get_bitmask(annotation).get_pixel(Math.round(gblx), Math.round(gbly))) { + if (this.get_bitmask(annotation).has_foreground_in_circle( + Math.round(gblx), + Math.round(gbly), + slack, + )) { is_a_containing_annotation = true; } break; default: - + has_exact_test = false; break; } @@ -6195,7 +6736,7 @@ export class ULabel { }; } } - } else if (boxsize < minsize) { + } else if (!(require_exact_hit && has_exact_test) && boxsize < minsize) { ret["candidate_ids"].push(annotation_id); minsize = boxsize; ret["best"] = { @@ -6219,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"] || @@ -6296,11 +6837,12 @@ export class ULabel { } // Both spatial/non-spatial can have the global suggestions - this.show_global_edit_suggestion(best_candidate.annid, null, nonspatial_id); current_subtask["state"]["edit_candidate"] = best_candidate; - // Must be called after active_annotation is updated + // Populate the confidence card before showing the dialog: its position is + // computed from its measured height, so the text must be in place first. this.update_confidence_dialog(); + this.show_global_edit_suggestion(best_candidate.annid, null, nonspatial_id); } hide_edits() { @@ -6326,18 +6868,31 @@ export class ULabel { // ================= Mouse event interpreters ================= + /** + * Page coordinates of the annbox's content origin, which is where the image + * starts. `offset()` gives the border box, so any border on the annbox would + * otherwise shift every position by its width. + */ + get_annbox_content_origin() { + const annbox = $("#" + this.config["annbox_id"]); + const offset = annbox.offset(); + const el = annbox[0]; + return { + left: offset.left + (el?.clientLeft ?? 0) - annbox.scrollLeft(), + top: offset.top + (el?.clientTop ?? 0) - annbox.scrollTop(), + }; + } + // Get the mouse position on the screen get_global_mouse_x(mouse_event) { const scale = this.get_empirical_scale(); - const annbox = $("#" + this.config["annbox_id"]); - const raw = (mouse_event.pageX - annbox.offset().left + annbox.scrollLeft()) / scale; + const raw = (mouse_event.pageX - this.get_annbox_content_origin().left) / scale; return raw; } get_global_mouse_y(mouse_event) { const scale = this.get_empirical_scale(); - const annbox = $("#" + this.config["annbox_id"]); - const raw = (mouse_event.pageY - annbox.offset().top + annbox.scrollTop()) / scale; + const raw = (mouse_event.pageY - this.get_annbox_content_origin().top) / scale; return raw; } @@ -6365,16 +6920,14 @@ export class ULabel { get_global_element_center_x(jqel) { const scale = this.get_empirical_scale(); - const annbox = $("#" + this.config["annbox_id"]); - const raw = (jqel.offset().left + jqel.width() / 2 - annbox.offset().left + annbox.scrollLeft()) / scale; + const raw = (jqel.offset().left + jqel.width() / 2 - this.get_annbox_content_origin().left) / scale; // return Math.round(raw); return raw; } get_global_element_center_y(jqel) { const scale = this.get_empirical_scale(); - const annbox = $("#" + this.config["annbox_id"]); - const raw = (jqel.offset().top + jqel.height() / 2 - annbox.offset().top + annbox.scrollTop()) / scale; + const raw = (jqel.offset().top + jqel.height() / 2 - this.get_annbox_content_origin().top) / scale; // return Math.round(); return raw; } @@ -6410,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); @@ -6449,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) { @@ -6512,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; @@ -6529,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); @@ -6578,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]); } } @@ -6661,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; } @@ -6717,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; } @@ -7162,6 +7741,11 @@ export class ULabel { return false; } + // Navigation should only visit what's on screen + if (this.is_annotation_defocused(annotation, subtask_key ?? this.get_current_subtask_key())) { + return false; + } + // Set the current subtask if necessary if (subtask_key !== null && subtask_key !== this.state.current_subtask) { this.set_subtask(subtask_key); @@ -7222,9 +7806,10 @@ export class ULabel { // Count non-deprecated annotations up to and including this one let visible_count = 0; let current_visible_idx = -1; + const current_subtask_key = this.get_current_subtask_key(); for (let i = 0; i < ordering.length; i++) { const ann = current_subtask["annotations"]["access"][ordering[i]]; - if (!ann["deprecated"]) { + if (!ann["deprecated"] && !this.is_annotation_defocused(ann, current_subtask_key)) { if (ordering[i] === annotation_id) { current_visible_idx = visible_count; } @@ -7319,6 +7904,20 @@ export class ULabel { img.attr("src", new_src); // Wait for the new image to be decoded and ready to display await img[0].decode(); + // The canvases, zoom math, and loaded annotations are all in the + // init-time image's coordinate space, so a different-size image + // would silently misalign everything. Restore the old image and reject. + const el = img[0]; + if (el.naturalWidth !== this.config["image_width"] || el.naturalHeight !== this.config["image_height"]) { + img.attr("src", ret); + log_message( + `swap_frame_image rejected: new image is ${el.naturalWidth}x${el.naturalHeight}, ` + + `but this instance was initialized at ${this.config["image_width"]}x${this.config["image_height"]}. ` + + `Rebuild the ULabel instance to change image dimensions.`, + LogLevel.ERROR, + true, + ); + } } finally { ULabelLoader.remove_loader_div(); } @@ -7418,16 +8017,29 @@ export class ULabel { return JSON.parse(JSON.stringify(ret)); } - async set_annotations(new_annotations, subtask) { + /** + * Replace a subtask's annotations in place. + * + * @param {object[]} new_annotations annotations in `resume_from` form + * @param {string} subtask subtask key + * @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, 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) { @@ -7436,37 +8048,111 @@ export class ULabel { } try { - // Undo/redo won't work through a get/set. Scope the reset to the target subtask - // so unrelated subtasks keep their interaction state. - this.reset_interaction_state(subtask); - this.subtasks[subtask]["actions"]["stream"] = []; - this.subtasks[subtask]["actions"]["undone_stack"] = []; - - // Bulk teardown of outgoing annotations: much cheaper than a per-annotation loop. - this._clear_subtask_annotation_canvases(subtask); - - // Set new annotations and initialize canvases - ULabel.process_resume_from(this, subtask, { resume_from: new_annotations }); - - // Yield the event loop so the loader's reveal timer can fire if the load above - // (or everything before it) took long enough to cross the reveal threshold. - // Without this yield the remaining sync work would block the timer entirely and - // long swaps would show no loader at all. - await new Promise((resolve) => setTimeout(resolve, 0)); - if (this.is_destroyed) return; - - initialize_annotation_canvases(this, subtask); - // Redraw all annotations to render them - this.redraw_all_annotations(subtask); - // Calculate distances for all annotations if FilterDistance is present - this.update_filter_distance(null, false, true); - // Update class counter in toolbox - this.toolbox.redraw_update_items(this); + const swapped = await this._swap_subtask_annotations(new_annotations, subtask); + if (swapped && !skip_toolbox_update) { + this.refresh_toolbox(); + } } finally { - ULabelLoader.remove_loader_div(); + if (show_loader) { + ULabelLoader.remove_loader_div(); + } } } + /** + * Replace several subtasks' annotations as a single update: one loader cycle + * and one toolbox refresh for the whole set. Swapping layers one call at a + * time cycles the loader per subtask, which reads as a flicker. + * + * @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, show_loader = true) { + if (this.is_destroyed) { + log_message("set_annotations_batch called on a destroyed ULabel instance", LogLevel.WARNING, true); + return; + } + + const subtask_keys = Object.keys(annotations_by_subtask).filter((subtask_key) => { + if (this.subtasks[subtask_key] !== undefined) return true; + log_message(`set_annotations_batch: unknown subtask key ${subtask_key}`, LogLevel.WARNING, true); + return false; + }); + if (subtask_keys.length === 0) return; + + 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); + return; + } + + try { + for (const subtask_key of subtask_keys) { + const swapped = await this._swap_subtask_annotations(annotations_by_subtask[subtask_key], subtask_key); + if (!swapped) return; + } + this.refresh_toolbox(); + } finally { + if (show_loader) { + ULabelLoader.remove_loader_div(); + } + } + } + + /** + * The per-subtask half of an annotation swap, without the loader or the + * toolbox refresh so a batched caller can pay for those once. + * @returns {Promise} false if the instance was destroyed mid-swap + */ + async _swap_subtask_annotations(new_annotations, subtask) { + // Undo/redo won't work through a get/set. Scope the reset to the target subtask + // so unrelated subtasks keep their interaction state. + this.reset_interaction_state(subtask); + this.subtasks[subtask]["actions"]["stream"] = []; + this.subtasks[subtask]["actions"]["undone_stack"] = []; + + // Bulk teardown of outgoing annotations: much cheaper than a per-annotation loop. + this._clear_subtask_annotation_canvases(subtask); + + // Set new annotations and initialize canvases + ULabel.process_resume_from(this, subtask, { resume_from: new_annotations }); + + // Yield the event loop so the loader's reveal timer can fire if the load above + // (or everything before it) took long enough to cross the reveal threshold. + // Without this yield the remaining sync work would block the timer entirely and + // long swaps would show no loader at all. + await new Promise((resolve) => setTimeout(resolve, 0)); + if (this.is_destroyed) return false; + + initialize_annotation_canvases(this, subtask); + // Redraw all annotations to render them + this.redraw_all_annotations(subtask); + return true; + } + + /** + * Recompute filter distances (when the FilterDistance item is present) and + * redraw every toolbox item. The deferred half of a batched + * `set_annotations(..., skip_toolbox_update = true)` sequence. + */ + refresh_toolbox() { + if (this.is_destroyed) { + log_message("refresh_toolbox called on a destroyed ULabel instance", LogLevel.WARNING, true); + return; + } + // Calculate distances for all annotations if FilterDistance is present + this.update_filter_distance(null, false, true); + // Update class counter in toolbox + this.toolbox.redraw_update_items(this); + } + /** * Bulk-teardown of a subtask's spatial annotation canvases + bitmask caches. * Faster than looping destroy_annotation_context() per annotation, which would @@ -7483,10 +8169,10 @@ export class ULabel { delete anno["_bitmask_box_hint"]; } } - // Only remove per-annotation canvases. The subtask's front/back canvases and the + // Only remove per-annotation canvases. The subtask's front canvas and the // #dialogs__ container (which owns the brush circle, polygon ender, and // id dialogs) live in the same parent and MUST survive. - $("#canvasses__" + subtask + " > canvas.annotation_canvas").remove(); + $("#canvasses__" + subtask + " canvas.annotation_canvas").remove(); this.subtasks[subtask]["state"]["annotation_contexts"] = {}; } diff --git a/src/initializer.ts b/src/initializer.ts index 3ccde13c..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"; /** @@ -33,11 +33,6 @@ function make_image_canvases( for (const st in ulabel.subtasks) { $("#" + ulabel.config["imwrap_id"]).append(`
- document.getElementById(ulabel.subtasks[st]["canvas_bid"]); const canvas_fid = document.getElementById(ulabel.subtasks[st]["canvas_fid"]); - ulabel.subtasks[st]["state"]["back_context"] = canvas_bid.getContext("2d")!; ulabel.subtasks[st]["state"]["front_context"] = canvas_fid.getContext("2d")!; } } @@ -183,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); } } } @@ -208,18 +206,6 @@ export async function ulabel_init( // Create listers to manipulate and export this object create_ulabel_listeners(ulabel); - // Restore toolbox collapsed state from localStorage - const is_collapsed = get_local_storage_item("ulabel_toolbox_collapsed"); - if (is_collapsed === "true") { - const toolbox = $("#" + ulabel.config["toolbox_id"]); - const container = $(".full_ulabel_container_"); - const btn = $(".toolbox-collapse-btn"); - toolbox.addClass("collapsed"); - container.addClass("toolbox-collapsed"); - btn.text("▶"); - btn.attr("title", "Expand toolbox"); - } - ulabel.handle_toolbox_overflow(); // Set the canvas elements in the correct stacking order given current subtask @@ -233,6 +219,7 @@ export async function ulabel_init( ulabel.is_init = true; ulabel.show_initial_crop(); + $(".full_ulabel_container_").addClass("ulabel-cropped"); ulabel.update_frame(); // Draw demo annotation diff --git a/src/listeners.ts b/src/listeners.ts index 83d3ddf5..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,62 +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"); - } - } + // 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])); } /** @@ -680,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), @@ -699,6 +660,20 @@ export function create_ulabel_listeners( }, ); + $(document).on( + "mouseleave" + ULABEL_NAMESPACE, + "#" + ulabel.config["annbox_id"], + () => { + // The suggestion dialogs live inside the annbox, so moving onto one + // of them doesn't count as leaving. + if (ulabel.drag_state["active_key"] !== null) return; + const state = ulabel.get_current_subtask()["state"]; + // A clicked-open id dialog is its own interaction; don't yank it away + if (state["idd_visible"] && !state["idd_thumbnail"]) return; + ulabel.hide_and_clear_action_candidates(); + }, + ); + // Button to toggle night mode $(document).on( "click" + ULABEL_NAMESPACE, diff --git a/src/mask_utils.ts b/src/mask_utils.ts index a86ae8c8..c7d71071 100644 --- a/src/mask_utils.ts +++ b/src/mask_utils.ts @@ -1,8 +1,15 @@ // Utilities for raster "bitmask" segmentation annotations. // -// A bitmask annotation stores a per-pixel binary occupancy grid the size of the -// image. At runtime the grid is held as a row-major Uint8Array (values 0 or 1). -// For serialization it is encoded as COCO-style, column-major run-length counts. +// A bitmask annotation covers a per-pixel binary occupancy grid the size of the +// image, but pixels are only *stored* for a sub-rectangle of it -- the mask's +// "window". Everything outside the window is background by definition. The +// public API is entirely in image coordinates, so callers never see the window; +// it exists so memory scales with the area an annotation actually covers rather +// than with the image, which is what lets a frame hold hundreds of objects. +// +// Within the window the grid is a row-major Uint8Array (values 0 or 1). For +// serialization it is encoded as COCO-style, column-major run-length counts over +// the full image, so the wire format is unchanged. // COCO-style run-length encoding of a binary mask. // - counts: alternating run lengths (in column-major / Fortran order) that always @@ -15,20 +22,38 @@ export type ULabelMaskPayload = { }; // Raw pixel-buffer form of a bitmask payload. Row-major, one byte per pixel -// (non-zero = foreground). `size` is [height, width] to match ULabelMaskPayload. -// Accepted as an alternative to the RLE form on load; callers that already have -// the mask as a Uint8Array avoid the encode-then-decode round-trip. +// (non-zero = foreground). `size` is [height, width] of the *image*, to match +// ULabelMaskPayload. Accepted as an alternative to the RLE form on load; callers +// that already have the mask as a Uint8Array avoid the encode-then-decode +// round-trip. +// +// If `box` is given, `data` covers only that inclusive image-space rectangle +// (row stride `brx - tlx + 1`) rather than the whole image, and is adopted as the +// mask's window as-is. Producing a cropped payload is the cheapest way to import +// a dense frame: nothing full-size is ever allocated. export type ULabelRawMaskPayload = { data: Uint8Array; size: [number, number]; + box?: BoundingBox; }; +// Duck-type check for a bounding box of integer bounds. +function is_bounding_box(box: unknown): box is BoundingBox { + if (box === null || typeof box !== "object") return false; + const b = box as Record; + for (const key of ["tlx", "tly", "brx", "bry"]) { + if (typeof b[key] !== "number" || !Number.isInteger(b[key])) return false; + } + return true; +} + // Duck-type check for the raw payload shape. export function is_raw_mask_payload(payload: unknown): payload is ULabelRawMaskPayload { if (payload === null || typeof payload !== "object") return false; - const p = payload as { data?: unknown; size?: unknown }; + const p = payload as { data?: unknown; size?: unknown; box?: unknown }; if (!(p.data instanceof Uint8Array) && !(p.data instanceof Uint8ClampedArray)) return false; if (!Array.isArray(p.size) || p.size.length !== 2) return false; + if (p.box !== undefined && !is_bounding_box(p.box)) return false; return Number.isInteger(p.size[0]) && Number.isInteger(p.size[1]); } @@ -49,37 +74,148 @@ function clamp_int(value: number, min: number, max: number): number { } export class ULabelMask { + // Stored pixels for the window only; length is window_width * window_height. public data: Uint8Array; + // Full image dimensions -- the mask's coordinate space, not its allocation. public readonly width: number; public readonly height: number; // Bumped by every mutating method so render caches can detect changes by comparison. public version: number = 0; - constructor(width: number, height: number, data?: Uint8Array) { + // Window origin and extent, in image coordinates. A zero-area window means the + // mask is entirely background and holds no buffer. + private win_x: number = 0; + private win_y: number = 0; + private win_w: number = 0; + private win_h: number = 0; + + // `data` without `box` is treated as a full-frame buffer, preserving the + // original constructor contract. With `box`, `data` covers just that + // rectangle. With neither, the mask starts empty and grows as it is painted. + constructor(width: number, height: number, data?: Uint8Array, box?: BoundingBox) { this.width = width; this.height = height; - if (data !== undefined) { + + if (data === undefined) { + this.data = new Uint8Array(0); + return; + } + + if (box === undefined) { if (data.length !== width * height) { throw new Error( `Mask data length ${data.length} does not match dimensions ${width}x${height}`, ); } + this.win_w = width; + this.win_h = height; this.data = data; - } else { - this.data = new Uint8Array(width * height); + return; + } + + const win_w = box.brx - box.tlx + 1; + const win_h = box.bry - box.tly + 1; + if (data.length !== win_w * win_h) { + throw new Error( + `Mask data length ${data.length} does not match window ${win_w}x${win_h}`, + ); } + if (box.tlx < 0 || box.tly < 0 || box.brx >= width || box.bry >= height) { + throw new Error( + `Mask window [${box.tlx}, ${box.tly}, ${box.brx}, ${box.bry}] lies outside ${width}x${height}`, + ); + } + this.win_x = box.tlx; + this.win_y = box.tly; + this.win_w = win_w; + this.win_h = win_h; + this.data = data; } - // Create an empty (all-background) mask. + // Create an empty (all-background) mask. Allocates nothing until painted. public static create_empty(width: number, height: number): ULabelMask { return new ULabelMask(width, height); } - public get_pixel(x: number, y: number): number { - if (x < 0 || y < 0 || x >= this.width || y >= this.height) { - return 0; + public get window_x(): number { + return this.win_x; + } + + public get window_y(): number { + return this.win_y; + } + + public get window_width(): number { + return this.win_w; + } + + public get window_height(): number { + return this.win_h; + } + + // The window as an inclusive image-space box, or null if the mask is empty. + // Renderers use this to walk `data` directly instead of probing get_pixel. + public get_window_box(): BoundingBox | null { + if (this.win_w === 0 || this.win_h === 0) return null; + return { + tlx: this.win_x, + tly: this.win_y, + brx: this.win_x + this.win_w - 1, + bry: this.win_y + this.win_h - 1, + }; + } + + // Index into `data` for an image coordinate, or -1 if outside the window. + private idx(x: number, y: number): number { + const lx = x - this.win_x; + const ly = y - this.win_y; + if (lx < 0 || ly < 0 || lx >= this.win_w || ly >= this.win_h) return -1; + return ly * this.win_w + lx; + } + + // Grow the window so it contains `box` (clamped to the image), reallocating and + // copying existing rows across. No-op when already covered. + private ensure_window(box: BoundingBox): void { + const tlx = Math.max(0, Math.floor(box.tlx)); + const tly = Math.max(0, Math.floor(box.tly)); + const brx = Math.min(this.width - 1, Math.ceil(box.brx)); + const bry = Math.min(this.height - 1, Math.ceil(box.bry)); + if (brx < tlx || bry < tly) return; + + if (this.win_w === 0 || this.win_h === 0) { + this.win_x = tlx; + this.win_y = tly; + this.win_w = brx - tlx + 1; + this.win_h = bry - tly + 1; + this.data = new Uint8Array(this.win_w * this.win_h); + return; + } + + const cur_brx = this.win_x + this.win_w - 1; + const cur_bry = this.win_y + this.win_h - 1; + if (tlx >= this.win_x && tly >= this.win_y && brx <= cur_brx && bry <= cur_bry) return; + + const new_x = Math.min(this.win_x, tlx); + const new_y = Math.min(this.win_y, tly); + const new_w = Math.max(cur_brx, brx) - new_x + 1; + const new_h = Math.max(cur_bry, bry) - new_y + 1; + const grown = new Uint8Array(new_w * new_h); + const row_offset = this.win_x - new_x; + for (let ly = 0; ly < this.win_h; ly++) { + const src = ly * this.win_w; + const dst = (ly + this.win_y - new_y) * new_w + row_offset; + grown.set(this.data.subarray(src, src + this.win_w), dst); } - return this.data[y * this.width + x]; + this.win_x = new_x; + this.win_y = new_y; + this.win_w = new_w; + this.win_h = new_h; + this.data = grown; + } + + public get_pixel(x: number, y: number): number { + const i = this.idx(x, y); + return i < 0 ? 0 : this.data[i]; } public set_pixel(x: number, y: number, value: number): void { @@ -87,7 +223,13 @@ export class ULabelMask { return; } this.version++; - this.data[y * this.width + x] = value ? 1 : 0; + if (value) { + this.ensure_window({ tlx: x, tly: y, brx: x, bry: y }); + } + const i = this.idx(x, y); + // Erasing outside the window is already a no-op + if (i < 0) return; + this.data[i] = value ? 1 : 0; } // Paint (value = 1) or erase (value = 0) a filled circle into the mask. @@ -100,14 +242,21 @@ export class ULabelMask { const max_x = clamp_int(cx + r, 0, this.width - 1); const min_y = clamp_int(cy - r, 0, this.height - 1); const max_y = clamp_int(cy + r, 0, this.height - 1); + const circle_box = { tlx: min_x, tly: min_y, brx: max_x, bry: max_y }; + if (v === 1) { + this.ensure_window(circle_box); + } + const b = this.clamp_box_to_window(circle_box); + if (b === null) return false; const r_sq = r * r; let changed = false; - for (let y = min_y; y <= max_y; y++) { + for (let y = b.y0; y <= b.y1; y++) { const dy = y - cy; - for (let x = min_x; x <= max_x; x++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + for (let x = b.x0; x <= b.x1; x++) { const dx = x - cx; if (dx * dx + dy * dy <= r_sq) { - const idx = y * this.width + x; + const idx = row + x; if (this.data[idx] !== v) { this.data[idx] = v; changed = true; @@ -129,16 +278,20 @@ export class ULabelMask { // True if any foreground pixel lies within the given circle. public has_foreground_in_circle(cx: number, cy: number, radius: number): boolean { const r = Math.max(0, radius); - const min_x = clamp_int(cx - r, 0, this.width - 1); - const max_x = clamp_int(cx + r, 0, this.width - 1); - const min_y = clamp_int(cy - r, 0, this.height - 1); - const max_y = clamp_int(cy + r, 0, this.height - 1); + const b = this.clamp_box_to_window({ + tlx: clamp_int(cx - r, 0, this.width - 1), + tly: clamp_int(cy - r, 0, this.height - 1), + brx: clamp_int(cx + r, 0, this.width - 1), + bry: clamp_int(cy + r, 0, this.height - 1), + }); + if (b === null) return false; const r_sq = r * r; - for (let y = min_y; y <= max_y; y++) { + for (let y = b.y0; y <= b.y1; y++) { const dy = y - cy; - for (let x = min_x; x <= max_x; x++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + for (let x = b.x0; x <= b.x1; x++) { const dx = x - cx; - if (dx * dx + dy * dy <= r_sq && this.data[y * this.width + x] !== 0) { + if (dx * dx + dy * dy <= r_sq && this.data[row + x] !== 0) { return true; } } @@ -149,52 +302,69 @@ export class ULabelMask { // Axis-aligned bounding box of foreground pixels, or null if empty. // Returned as { tlx, tly, brx, bry } in image pixel coordinates. public get_bounding_box(): BoundingBox | null { - let min_x = this.width; - let min_y = this.height; + let min_x = this.win_w; + let min_y = this.win_h; let max_x = -1; let max_y = -1; - for (let y = 0; y < this.height; y++) { - const row = y * this.width; - for (let x = 0; x < this.width; x++) { - if (this.data[row + x] !== 0) { - if (x < min_x) min_x = x; - if (x > max_x) max_x = x; - if (y < min_y) min_y = y; - if (y > max_y) max_y = y; + for (let ly = 0; ly < this.win_h; ly++) { + const row = ly * this.win_w; + for (let lx = 0; lx < this.win_w; lx++) { + if (this.data[row + lx] !== 0) { + if (lx < min_x) min_x = lx; + if (lx > max_x) max_x = lx; + if (ly < min_y) min_y = ly; + if (ly > max_y) max_y = ly; } } } if (max_x < 0) { return null; } - return { tlx: min_x, tly: min_y, brx: max_x, bry: max_y }; + return { + tlx: min_x + this.win_x, + tly: min_y + this.win_y, + brx: max_x + this.win_x, + bry: max_y + this.win_y, + }; } // Return a new mask with all foreground pixels shifted by (dx, dy) image pixels. // Pixels shifted outside the image are dropped. public translate(dx: number, dy: number): ULabelMask { const shifted = new ULabelMask(this.width, this.height); + const box = this.get_window_box(); + if (box === null) return shifted; const idx = Math.round(dx); const idy = Math.round(dy); - for (let y = 0; y < this.height; y++) { - const ny = y + idy; + if (box.brx + idx < 0 || box.bry + idy < 0) return shifted; + if (box.tlx + idx >= this.width || box.tly + idy >= this.height) return shifted; + shifted.ensure_window({ + tlx: box.tlx + idx, + tly: box.tly + idy, + brx: box.brx + idx, + bry: box.bry + idy, + }); + for (let ly = 0; ly < this.win_h; ly++) { + const ny = ly + this.win_y + idy; if (ny < 0 || ny >= this.height) continue; - const src_row = y * this.width; - const dst_row = ny * this.width; - for (let x = 0; x < this.width; x++) { - if (this.data[src_row + x] !== 0) { - const nx = x + idx; + const src_row = ly * this.win_w; + for (let lx = 0; lx < this.win_w; lx++) { + if (this.data[src_row + lx] !== 0) { + const nx = lx + this.win_x + idx; if (nx < 0 || nx >= this.width) continue; - shifted.data[dst_row + nx] = 1; + const i = shifted.idx(nx, ny); + if (i >= 0) shifted.data[i] = 1; } } } return shifted; } - // Return a copy of this mask. + // Return a copy of this mask, window and all. public clone(): ULabelMask { - return new ULabelMask(this.width, this.height, this.data.slice()); + const box = this.get_window_box(); + if (box === null) return new ULabelMask(this.width, this.height); + return new ULabelMask(this.width, this.height, this.data.slice(), box); } // Ensure another mask has the same dimensions as this one. @@ -206,16 +376,41 @@ export class ULabelMask { } } + // Clamp a box to the intersection of the image and this mask's window, returning + // integer inclusive image-space bounds or null if empty. + private clamp_box_to_window(box: BoundingBox): { x0: number; y0: number; x1: number; y1: number } | null { + if (this.win_w === 0 || this.win_h === 0) return null; + const x0 = Math.max(this.win_x, Math.floor(box.tlx)); + const y0 = Math.max(this.win_y, Math.floor(box.tly)); + const x1 = Math.min(this.win_x + this.win_w - 1, Math.ceil(box.brx)); + const y1 = Math.min(this.win_y + this.win_h - 1, Math.ceil(box.bry)); + if (x1 < x0 || y1 < y0) return null; + return { x0, y0, x1, y1 }; + } + + // Inclusive image-space bounds covered by both masks' windows, or null. + private window_overlap(other: ULabelMask): { x0: number; y0: number; x1: number; y1: number } | null { + const box = other.get_window_box(); + if (box === null) return null; + return this.clamp_box_to_window(box); + } + // Remove another mask's foreground from this one (this = this AND NOT other). // Returns true if any pixel changed. public subtract(other: ULabelMask): boolean { this.assert_same_dims(other); this.version++; + const b = this.window_overlap(other); + if (b === null) return false; let changed = false; - for (let i = 0; i < this.data.length; i++) { - if (this.data[i] !== 0 && other.data[i] !== 0) { - this.data[i] = 0; - changed = true; + for (let y = b.y0; y <= b.y1; y++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + const other_row = (y - other.win_y) * other.win_w - other.win_x; + for (let x = b.x0; x <= b.x1; x++) { + if (this.data[row + x] !== 0 && other.data[other_row + x] !== 0) { + this.data[row + x] = 0; + changed = true; + } } } return changed; @@ -227,9 +422,10 @@ export class ULabelMask { // pixel changed. Used to apply ULabel's polygon/bbox delete modes to raster masks. public subtract_polygon(polygon: [number, number][]): boolean { if (polygon.length < 3) return false; + if (this.win_w === 0 || this.win_h === 0) return false; this.version++; - // Restrict work to the polygon's vertical extent, clamped to the image. + // Restrict work to the polygon's vertical extent, clamped to the window. let min_py = Infinity; let max_py = -Infinity; for (let i = 0; i < polygon.length; i++) { @@ -237,8 +433,8 @@ export class ULabelMask { if (py < min_py) min_py = py; if (py > max_py) max_py = py; } - const y_start = Math.max(0, Math.ceil(min_py)); - const y_end = Math.min(this.height - 1, Math.floor(max_py)); + const y_start = Math.max(this.win_y, Math.ceil(min_py)); + const y_end = Math.min(this.win_y + this.win_h - 1, Math.floor(max_py)); let changed = false; const n = polygon.length; @@ -257,10 +453,10 @@ export class ULabelMask { } if (xs.length < 2) continue; xs.sort((a, b) => a - b); - const row = y * this.width; + const row = (y - this.win_y) * this.win_w - this.win_x; for (let k = 0; k + 1 < xs.length; k += 2) { - const x_start = Math.max(0, Math.ceil(xs[k])); - const x_end = Math.min(this.width - 1, Math.floor(xs[k + 1])); + const x_start = Math.max(this.win_x, Math.ceil(xs[k])); + const x_end = Math.min(this.win_x + this.win_w - 1, Math.floor(xs[k + 1])); for (let x = x_start; x <= x_end; x++) { if (this.data[row + x] !== 0) { this.data[row + x] = 0; @@ -276,9 +472,18 @@ export class ULabelMask { public add_mask(other: ULabelMask): void { this.assert_same_dims(other); this.version++; - for (let i = 0; i < this.data.length; i++) { - if (other.data[i] !== 0) { - this.data[i] = 1; + const other_box = other.get_window_box(); + if (other_box === null) return; + this.ensure_window(other_box); + const b = this.window_overlap(other); + if (b === null) return; + for (let y = b.y0; y <= b.y1; y++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + const other_row = (y - other.win_y) * other.win_w - other.win_x; + for (let x = b.x0; x <= b.x1; x++) { + if (other.data[other_row + x] !== 0) { + this.data[row + x] = 1; + } } } } @@ -287,9 +492,24 @@ export class ULabelMask { public intersect(other: ULabelMask): void { this.assert_same_dims(other); this.version++; - for (let i = 0; i < this.data.length; i++) { - if (other.data[i] === 0) { - this.data[i] = 0; + const b = this.window_overlap(other); + if (b === null) { + this.data.fill(0); + return; + } + const win_x1 = this.win_x + this.win_w - 1; + for (let y = this.win_y; y <= this.win_y + this.win_h - 1; y++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + // Rows the other mask doesn't reach are cleared wholesale. + if (y < b.y0 || y > b.y1) { + this.data.fill(0, row + this.win_x, row + win_x1 + 1); + continue; + } + const other_row = (y - other.win_y) * other.win_w - other.win_x; + for (let x = this.win_x; x <= win_x1; x++) { + if (x < b.x0 || x > b.x1 || other.data[other_row + x] === 0) { + this.data[row + x] = 0; + } } } } @@ -297,20 +517,28 @@ export class ULabelMask { // True if this mask shares any foreground pixel with another. public intersects(other: ULabelMask): boolean { this.assert_same_dims(other); - for (let i = 0; i < this.data.length; i++) { - if (this.data[i] !== 0 && other.data[i] !== 0) { - return true; + const b = this.window_overlap(other); + if (b === null) return false; + for (let y = b.y0; y <= b.y1; y++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + const other_row = (y - other.win_y) * other.win_w - other.win_x; + for (let x = b.x0; x <= b.x1; x++) { + if (this.data[row + x] !== 0 && other.data[other_row + x] !== 0) { + return true; + } } } return false; } - // Clamp a bounding box to the image, returning integer inclusive bounds or null if empty. - private clamp_box_to_image(box: BoundingBox): { x0: number; y0: number; x1: number; y1: number } | null { - const x0 = Math.max(0, Math.floor(box.tlx)); - const y0 = Math.max(0, Math.floor(box.tly)); - const x1 = Math.min(this.width - 1, Math.ceil(box.brx)); - const y1 = Math.min(this.height - 1, Math.ceil(box.bry)); + // Inclusive bounds covered by `box` and both masks' windows, or null. + private overlap_in_box(other: ULabelMask, box: BoundingBox): { x0: number; y0: number; x1: number; y1: number } | null { + const b = this.window_overlap(other); + if (b === null) return null; + const x0 = Math.max(b.x0, Math.floor(box.tlx)); + const y0 = Math.max(b.y0, Math.floor(box.tly)); + const x1 = Math.min(b.x1, Math.ceil(box.brx)); + const y1 = Math.min(b.y1, Math.ceil(box.bry)); if (x1 < x0 || y1 < y0) return null; return { x0, y0, x1, y1 }; } @@ -318,13 +546,13 @@ export class ULabelMask { // True if any pixel within `box` is foreground in both masks. O(box area). public intersects_in_box(other: ULabelMask, box: BoundingBox): boolean { this.assert_same_dims(other); - const b = this.clamp_box_to_image(box); + const b = this.overlap_in_box(other, box); if (b === null) return false; for (let y = b.y0; y <= b.y1; y++) { - const row = y * this.width; + const row = (y - this.win_y) * this.win_w - this.win_x; + const other_row = (y - other.win_y) * other.win_w - other.win_x; for (let x = b.x0; x <= b.x1; x++) { - const i = row + x; - if (this.data[i] !== 0 && other.data[i] !== 0) return true; + if (this.data[row + x] !== 0 && other.data[other_row + x] !== 0) return true; } } return false; @@ -334,16 +562,16 @@ export class ULabelMask { // Returns true if any pixel changed. O(box area). public subtract_in_box(other: ULabelMask, box: BoundingBox): boolean { this.assert_same_dims(other); - const b = this.clamp_box_to_image(box); + const b = this.overlap_in_box(other, box); if (b === null) return false; this.version++; let changed = false; for (let y = b.y0; y <= b.y1; y++) { - const row = y * this.width; + const row = (y - this.win_y) * this.win_w - this.win_x; + const other_row = (y - other.win_y) * other.win_w - other.win_x; for (let x = b.x0; x <= b.x1; x++) { - const i = row + x; - if (this.data[i] !== 0 && other.data[i] !== 0) { - this.data[i] = 0; + if (this.data[row + x] !== 0 && other.data[other_row + x] !== 0) { + this.data[row + x] = 0; changed = true; } } @@ -356,16 +584,23 @@ export class ULabelMask { public subtract_intersection_in_box(a: ULabelMask, b: ULabelMask, box: BoundingBox): boolean { this.assert_same_dims(a); this.assert_same_dims(b); - const bx = this.clamp_box_to_image(box); - if (bx === null) return false; + const bounds = this.overlap_in_box(a, box); + const b_box = b.get_window_box(); + if (bounds === null || b_box === null) return false; + const x0 = Math.max(bounds.x0, b_box.tlx); + const y0 = Math.max(bounds.y0, b_box.tly); + const x1 = Math.min(bounds.x1, b_box.brx); + const y1 = Math.min(bounds.y1, b_box.bry); + if (x1 < x0 || y1 < y0) return false; this.version++; let changed = false; - for (let y = bx.y0; y <= bx.y1; y++) { - const row = y * this.width; - for (let x = bx.x0; x <= bx.x1; x++) { - const i = row + x; - if (this.data[i] !== 0 && a.data[i] !== 0 && b.data[i] !== 0) { - this.data[i] = 0; + for (let y = y0; y <= y1; y++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + const a_row = (y - a.win_y) * a.win_w - a.win_x; + const b_row = (y - b.win_y) * b.win_w - b.win_x; + for (let x = x0; x <= x1; x++) { + if (this.data[row + x] !== 0 && a.data[a_row + x] !== 0 && b.data[b_row + x] !== 0) { + this.data[row + x] = 0; changed = true; } } @@ -373,7 +608,7 @@ export class ULabelMask { return changed; } - // Encode to COCO-style, column-major run-length counts. + // Encode to COCO-style, column-major run-length counts over the full image. public to_rle(): ULabelMaskPayload { const counts: number[] = []; // Runs always start with background (0). Compare foreground-truthiness rather @@ -381,9 +616,24 @@ export class ULabelMask { // (e.g. 0/255 masks, multi-valued upstream buffers) still encode correctly. let current_is_fg = false; let run = 0; + const win_x1 = this.win_x + this.win_w; + const win_y1 = this.win_y + this.win_h; for (let x = 0; x < this.width; x++) { + // Columns outside the window are background end to end; skip the scan. + if (x < this.win_x || x >= win_x1) { + if (current_is_fg) { + counts.push(run); + current_is_fg = false; + run = this.height; + } else { + run += this.height; + } + continue; + } + const col = x - this.win_x; for (let y = 0; y < this.height; y++) { - const is_fg = this.data[y * this.width + x] !== 0; + const is_fg = y >= this.win_y && y < win_y1 && + this.data[(y - this.win_y) * this.win_w + col] !== 0; if (is_fg === current_is_fg) { run++; } else { @@ -437,13 +687,57 @@ export class ULabelMask { } } - // Decode a COCO-style RLE payload into a mask. + // Bounding box of the foreground implied by a column-major RLE, without decoding + // it. Walks runs rather than pixels, so this is O(runs). + private static rle_bounding_box(payload: ULabelMaskPayload): BoundingBox | null { + const [height] = payload.size; + if (height === 0) return null; + let min_x = Infinity; + let min_y = Infinity; + let max_x = -1; + let max_y = -1; + let idx = 0; + let value = 0; + for (let c = 0; c < payload.counts.length; c++) { + const run = payload.counts[c]; + if (value === 1 && run > 0) { + const last = idx + run - 1; + const first_x = Math.floor(idx / height); + const last_x = Math.floor(last / height); + if (first_x < min_x) min_x = first_x; + if (last_x > max_x) max_x = last_x; + if (last_x > first_x) { + // Crossing a column boundary means the run reaches the bottom of its + // first column and the top of its last, so it spans every row. + min_y = 0; + max_y = height - 1; + } else { + const first_y = idx % height; + const last_y = last % height; + if (first_y < min_y) min_y = first_y; + if (last_y > max_y) max_y = last_y; + } + } + idx += run; + value = value === 0 ? 1 : 0; + } + if (max_x < 0) return null; + return { tlx: min_x, tly: min_y, brx: max_x, bry: max_y }; + } + + // Decode a COCO-style RLE payload into a mask, allocating only the foreground's + // bounding box rather than the whole frame. public static from_rle(payload: ULabelMaskPayload, validate: boolean = true): ULabelMask { if (validate) { ULabelMask.validate_rle(payload); } const [height, width] = payload.size; - const mask = new ULabelMask(width, height); + const box = ULabelMask.rle_bounding_box(payload); + if (box === null) return new ULabelMask(width, height); + + const win_w = box.brx - box.tlx + 1; + const data = new Uint8Array(win_w * (box.bry - box.tly + 1)); + const mask = new ULabelMask(width, height, data, box); let idx = 0; // column-major index let value = 0; for (let c = 0; c < payload.counts.length; c++) { @@ -453,7 +747,7 @@ export class ULabelMask { const col_idx = idx + k; const x = Math.floor(col_idx / height); const y = col_idx % height; - mask.data[y * width + x] = 1; + data[(y - box.tly) * win_w + (x - box.tlx)] = 1; } } idx += run; @@ -471,6 +765,22 @@ export class ULabelMask { if (height < 0 || width < 0) { throw new Error(`Invalid raw mask size: expected non-negative integers, got [${height}, ${width}]`); } + if (payload.box !== undefined) { + const box = payload.box; + 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); + if (payload.data.length !== expected) { + throw new Error(`Invalid raw mask data length: expected ${expected} bytes for box, got ${payload.data.length}`); + } + return; + } const expected = height * width; if (payload.data.length !== expected) { throw new Error(`Invalid raw mask data length: expected ${expected} bytes for ${height}x${width}, got ${payload.data.length}`); @@ -478,14 +788,56 @@ export class ULabelMask { } // Wrap a raw pixel-buffer payload as a ULabelMask. - // By default copies the input so the caller can safely mutate their own array; - // pass `copy: false` when the caller (e.g. process_resume_from) already copied. + // + // With `box`, the buffer is already cropped and is adopted as the window -- by + // default copied so the caller can safely mutate their own array; pass + // `copy: false` when the caller (e.g. process_resume_from) already copied. + // + // Without `box`, the buffer is full-frame and is cropped to its bounding box on + // import, so the full-size allocation becomes garbage immediately instead of + // being retained for the life of the annotation. `copy` is moot in that case; + // the crop is always a fresh buffer. public static from_raw(payload: ULabelRawMaskPayload, validate: boolean = true, copy: boolean = true): ULabelMask { if (validate) { ULabelMask.validate_raw(payload); } const [height, width] = payload.size; - const data = copy ? new Uint8Array(payload.data) : payload.data; - return new ULabelMask(width, height, data); + + if (payload.box !== undefined) { + const data = copy ? new Uint8Array(payload.data) : payload.data; + return new ULabelMask(width, height, data, payload.box); + } + + const src = payload.data; + let min_x = width; + let min_y = height; + let max_x = -1; + let max_y = -1; + for (let y = 0; y < height; y++) { + const row = y * width; + for (let x = 0; x < width; x++) { + if (src[row + x] !== 0) { + if (x < min_x) min_x = x; + if (x > max_x) max_x = x; + if (y < min_y) min_y = y; + if (y > max_y) max_y = y; + } + } + } + if (max_x < 0) return new ULabelMask(width, height); + + const win_w = max_x - min_x + 1; + const win_h = max_y - min_y + 1; + const data = new Uint8Array(win_w * win_h); + for (let y = 0; y < win_h; y++) { + const src_start = (y + min_y) * width + min_x; + data.set(src.subarray(src_start, src_start + win_w), y * win_w); + } + return new ULabelMask(width, height, data, { + tlx: min_x, + tly: min_y, + brx: max_x, + bry: max_y, + }); } } diff --git a/src/subtask.ts b/src/subtask.ts index a43a955c..5dc478f8 100644 --- a/src/subtask.ts +++ b/src/subtask.ts @@ -19,13 +19,11 @@ export class ULabelSubtask { ordering: string[]; }; - public canvas_bid!: string; public canvas_fid!: string; public single_class_mode!: boolean; public state!: { active_id: string; annotation_mode: string; - back_context: CanvasRenderingContext2D; edit_candidate: ULabelActionCandidate | null; move_candidate: ULabelActionCandidate | null; first_explicit_assignment: boolean; @@ -40,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; @@ -50,8 +50,13 @@ export class ULabelSubtask { visible_dialogs: { [key: string]: ULabelDialogPosition; }; - spatial_type: ULabelSpatialType; fly_to_idx: 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; }; @@ -71,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: [], @@ -89,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 be7355fd..652c3971 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -1,18 +1,19 @@ import type { + ClassCounterConfig, DistanceFromPolylineClasses, FilterDistanceConfig, RecolorActiveConfig, } 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, findAllPolylineClassDefinitions, get_point_and_line_annotations, } from "./annotation_operators"; -import { SliderHandler, get_idd_string } from "./html_builder"; +import { SliderHandler } from "./html_builder"; import { FilterDistanceOverlay } from "./overlays"; import { get_active_class_id, @@ -124,6 +125,12 @@ export class Toolbox { height: 100%; } + /* The image is inserted at its natural size and only scaled once the + initial crop lands, so keep it hidden until then. */ + .full_ulabel_container_:not(.ulabel-cropped) .imwrap_cls { + visibility: hidden; + } + #toolbox { width: 320px; background-color: white; @@ -272,17 +279,26 @@ export class Toolbox { images: string, ULABEL_VERSION: string, ): string { + // Bake the persisted collapsed state into the markup. Applying it after + // init instead paints a frame with the toolbox expanded, which reads as + // a flicker every time the instance is rebuilt. + const is_collapsed = get_local_storage_item("ulabel_toolbox_collapsed") === "true"; + const container_class = is_collapsed ? "full_ulabel_container_ toolbox-collapsed" : "full_ulabel_container_"; + const toolbox_class = is_collapsed ? "toolbox_cls collapsed" : "toolbox_cls"; + const collapse_arrow = is_collapsed ? "◀" : "▶"; + const collapse_title = is_collapsed ? "Expand toolbox" : "Collapse toolbox"; + // Setup base div and ULabel version header let toolbox_html = ` -
+
${frame_annotation_dialogs}
${images}
- -
+ +

ULabel v${ULABEL_VERSION}