Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
58f79be
Support cropped bitmasks
elliott-imhoff Aug 31, 2026
4d628db
Fix bugs
elliott-imhoff Sep 2, 2026
0c4ee0f
Fix hover bug
elliott-imhoff Sep 2, 2026
2a1187b
fix prepare for git installs
TrevorBurgoyne Sep 4, 2026
1bbed14
fix read only hover
TrevorBurgoyne Sep 4, 2026
bd3cd48
bump version, fix test
TrevorBurgoyne Sep 4, 2026
c8e5f44
update plan
TrevorBurgoyne Sep 4, 2026
d640905
remove back canvas
TrevorBurgoyne Sep 4, 2026
0d1dda2
first phase of cleanup
TrevorBurgoyne Sep 4, 2026
10b594d
finish rest of plan
TrevorBurgoyne Sep 4, 2026
8b32589
fix confidence tag jitter
TrevorBurgoyne Sep 4, 2026
7a2c7b3
add guard to swap frame image
TrevorBurgoyne Sep 4, 2026
275509f
three fixed subtasks: class focus, batched swaps, per-class modes
elliott-imhoff Sep 9, 2026
9978c52
update plan
elliott-imhoff Sep 9, 2026
17cf81f
Clean up
elliott-imhoff Sep 9, 2026
1a806c6
merge class focus with active class state
TrevorBurgoyne Sep 9, 2026
bc223e9
enforce allowed modes in id dialog
TrevorBurgoyne Sep 9, 2026
51a6c1a
misc bug fixes
TrevorBurgoyne Sep 9, 2026
f6542fd
fix browser install
TrevorBurgoyne Sep 9, 2026
551ce4d
add on_change listeners for active class and subtask
TrevorBurgoyne Sep 9, 2026
8503de8
add flag for brush overlap behavior with inactive subtasks, fix small…
TrevorBurgoyne Sep 9, 2026
c49ee0b
use log message
TrevorBurgoyne Sep 9, 2026
1a555cc
fix class keybinds
TrevorBurgoyne Sep 9, 2026
641fc90
show_loader arg
TrevorBurgoyne Sep 10, 2026
54c5f51
add on_focus_active_class_change listener
TrevorBurgoyne Sep 10, 2026
74f3650
cleanup changelog
TrevorBurgoyne Sep 10, 2026
c2a3f92
add tests, address review
TrevorBurgoyne Sep 10, 2026
e50ae54
changes from review
TrevorBurgoyne Sep 10, 2026
ae10b9c
move opacity to subtask state
TrevorBurgoyne Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
721 changes: 721 additions & 0 deletions .github/tasks.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
TrevorBurgoyne marked this conversation as resolved.
- `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.
Expand Down
67 changes: 63 additions & 4 deletions api_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
})
```

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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`.

Expand All @@ -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 `<div>` 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

Expand All @@ -651,6 +690,8 @@ Display utilities are provided for a constructed `ULabel` object.

*(string, int) => Promise&lt;string&gt;* -- 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.
Expand All @@ -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&lt;void&gt;* -- 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&lt;void&gt;* -- 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&lt;void&gt;* -- 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)`

Expand Down
1 change: 1 addition & 0 deletions demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
30 changes: 29 additions & 1 deletion demo/bitmask-example.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
};

Expand All @@ -61,13 +78,24 @@
});
// 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;
});
});

});
</script>
</head>
<body>
<div id="container" style="width: 100%; height: 100vh; position: absolute; top: 0; left: 0;"></div>
<div style="position: fixed; top: 10px; right: 320px; z-index: 1000; background: #fff; border: 1px solid #ccc; border-radius: 4px; padding: 8px 12px; font-family: sans-serif; font-size: 13px;">
<label style="display: block;"><input type="checkbox" id="across-subtasks"> Brush overlap across subtasks</label>
<label style="display: block;"><input type="checkbox" id="reference-read-only"> Reference layer read-only (overwrite barrier)</label>
<div style="color: #666; margin-top: 4px;">Paint in Reference, switch back, then brush over it with overlap exclude (shift+e) or overwrite (shift+o).</div>
</div>
</body>
</html>
Loading
Loading