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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .github/tasks.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
## Tasks

### Bitmask segmentation annotation mode
Per-annotation binary masks, COCO-style RLE serialization, brush + erase interaction.

- [x] Phase 1: Data model + RLE utils + tests (`src/mask_utils.ts`, `ULabelSpatialType`, `SPATIAL_TYPE_SET`)
- [x] Phase 2: Bitmask rendering layer (`draw_bitmask`, dispatch, redraw/clear)
- [x] Phase 3: Brush/erase paints pixels (begin/continue/finish for bitmask)
- [x] Phase 4: Undo/redo patch diffs for brush strokes (single-stroke `bitmask_stroke` action)
- [x] Phase 5: Toolbox + mode registration (mode button, brush enable/disable, keybinds)
- [ ] Phase 6: Export/import round-trip + tests + demo page

8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented here.

## [unreleased]

## [0.26.0] - Aug 11th, 2026
- **Memory leak fix on teardown.** Bitmask annotations attach a decoded pixel `Uint8Array` (`_mask`) and a tinted stencil canvas (`_mask_render`) to each annotation object. These persisted after `remove_listeners()`, and consumers that rebuild ULabel per navigation could accumulate multi-GB retained heap. Changes:
- New `destroy()` method on `ULabel`. Idempotent; releases per-annotation bitmask caches, empties action/undo streams, breaks toolbox back-references, clears the resize-observer array, and wipes the container DOM. Callers should prefer `destroy()` over `remove_listeners()` going forward.
- `destroy_annotation_context()` now also drops `_mask` / `_mask_render` / `_bitmask_box_hint` on the destroyed annotation, so `set_annotations()` also frees mask memory when it recycles contexts.
- New `auto_destroy_on_detach` config option (default `true`). When enabled, ULabel installs a `MutationObserver` on the container's root and calls `destroy()` automatically after the container leaves the DOM.
- `set_annotations()`, `get_annotations()`, and `redraw_all_annotations()` now short-circuit with a warning if called after `destroy()`.
- Fixed a latent typo in `set_annotations()` that wrote to a non-existent `undo_stack` property instead of clearing the real `undone_stack`.


## [0.25.1] - Aug 10th, 2026
- The `ConfidenceSlider` toolbox item now also filters `bitmask` annotations
Expand Down
25 changes: 24 additions & 1 deletion api_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ ulabel.init(() => {/* behavior on ready */})

`ULabel` is the only name that `ulabel.js` will add to the global namespace.

> ULabel is designed for **a single instance per page**. Toolbox handlers, id dialogs, and global keybinds bind to shared DOM ids and delegated selectors on `document`, so mounting more than one `ULabel` simultaneously is not supported.

The constructor is used to specify the configuration for an "annotation session". It has the following interface

```javascript
Expand Down Expand Up @@ -79,7 +81,8 @@ class ULabel({
annotation_size_minus_keybind: string,
annotation_vanish_keybind: string,
fly_to_max_zoom: number,
n_annos_per_canvas: number
n_annos_per_canvas: number,
auto_destroy_on_detach: boolean
})
```

Expand Down Expand Up @@ -615,6 +618,9 @@ If `true`, the user can click and drag to contiuously place points for polyline
### `allow_annotations_outside_image`
When `false`, new annotations will be limited to points within the image, and attempts to move annotations outside the image will bounce back to inside the image. Default is `true`.

### `auto_destroy_on_detach`
When `true` (the default), ULabel installs a `MutationObserver` on the container's root and calls [`destroy()`](#destroy) automatically after the container is removed from the DOM. The observer holds the ULabel instance through a `WeakRef` (so it cannot pin the instance in memory on its own) and defers the teardown decision by one animation frame so brief detach/reattach cycles (portals, jQuery `.detach()`, layout reparenting) do not trigger a false-positive teardown. Set to `false` to opt out and manage teardown manually via [`destroy()`](#destroy).


## Display Utility Functions

Expand Down Expand Up @@ -653,6 +659,23 @@ Display utilities are provided for a constructed `ULabel` object.
*() => void* -- Removes persistent event listeners from the document and window. Listeners attached directly to html elements are not explicitly removed.
Note that ULabel will not function properly after this method is called. Designed for use in single-page applications before navigating away from the annotation page.

> Prefer [`destroy()`](#destroy) for new code — it also releases the heavy per-annotation bitmask caches and the container DOM.

### `destroy()`

*() => void* -- Fully tears down this ULabel instance. Idempotent (subsequent calls are no-ops). Releases:

- per-bitmask runtime state (`_mask` `Uint8Array`, `_mask_render` tinted stencil canvas, `_bitmask_box_hint`),
- action stream and redo stack (which retain per-stroke `before_rle` / `after_rle` payloads),
- toolbox item back-references to the instance,
- resize observers and (if [`auto_destroy_on_detach`](#auto_destroy_on_detach) installed one) the auto-teardown `MutationObserver`,
- pending toast/interaction timers,
- all DOM under the configured container.

After calling `destroy()` the instance MUST NOT be used again. `set_annotations()`, `get_annotations()`, and `redraw_all_annotations()` short-circuit with a warning if called on a destroyed instance.

With [`auto_destroy_on_detach`](#auto_destroy_on_detach) enabled (the default), `destroy()` is called automatically after the container is removed from the DOM. Callers that opt out of the auto path should call `destroy()` explicitly during their unmount / teardown.

### `fly_to_next_annotation(increment)`
Sets the zoom to focus on a non-deprecated, spatial annotation in the active subtask's ordering that is an `<increment>` number away from the previously focused annotation, if any. Returns `true` on success and `false` on failure (eg, no valid annotations exist, or an annotation is currently actively being edited).

Expand Down
11 changes: 11 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ export type ULabelConstructorArgs = {
initial_line_size?: number;
instructions_url?: string;
toolbox_order?: AllowedToolboxItem[];
auto_destroy_on_detach?: boolean;
/** @deprecated Use top-level properties instead. */
config_data?: object;
};
Expand Down Expand Up @@ -439,6 +440,16 @@ export class ULabel {
// Listeners
public remove_listeners(): void;

// Full teardown: releases bitmask caches, action streams, toolbox refs, and container DOM.
public destroy(): void;
// True after destroy() has run; subsequent destroy() calls are no-ops.
is_destroyed: boolean;
// The container HTMLElement this instance owns, captured at init. Used by destroy() and
// the auto-teardown observer to avoid mistaking a same-id replacement for our container.
_owned_container: HTMLElement | null;
// Install the auto-teardown MutationObserver; called by init and idempotent.
_install_auto_destroy_observer(): void;

// Static functions
static version(): string;
static get_time(): string;
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ulabel",
"description": "An image annotation tool.",
"version": "0.25.1",
"version": "0.26.0",
"main": "dist/ulabel.min.js",
"module": "dist/ulabel.min.js",
"types": "dist/index.d.ts",
Expand Down
2 changes: 2 additions & 0 deletions src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ export class Configuration {

public allow_annotations_outside_image: boolean = true;

public auto_destroy_on_detach: boolean = true;

constructor(...kwargs: { [key: string]: unknown }[]) {
this.modify_config(...kwargs);
}
Expand Down
169 changes: 167 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,83 @@ export class ULabel {
remove_ulabel_listeners(this);
}

/**
* Fully tear down this ULabel instance and release its heavy runtime state
* (bitmask masks, tinted stencil canvases, undo/redo streams, toolbox refs,
* DOM under the container). After calling this the instance MUST NOT be used
* again. Callers with a strong reference to the instance no longer keep the
* bitmask `Uint8Array`s alive. Safe to call more than once.
*/
destroy() {
if (this.is_destroyed) return;

// 1. Disconnect the MutationObserver first so a mid-teardown throw can't
// leave a live observer pinning `this` via the browser's observer registry.
if (this.mutation_observer != null) {
this.mutation_observer.disconnect();
this.mutation_observer = null;
}

// 2. Existing listener / observer cleanup.
this.remove_listeners();
Comment thread
TrevorBurgoyne marked this conversation as resolved.

// 3. Any pending toast/interaction timers hold `this` in their closure.
if (this.annotation_navigation_toast_timeout !== null) {
clearTimeout(this.annotation_navigation_toast_timeout);
this.annotation_navigation_toast_timeout = null;
}
// In-progress bitmask move snapshot canvas (skipped by end_bitmask_move on interrupt).
if (this.state) {
this.state["bitmask_move_overlay"] = null;
}

// 4. Drop bitmask caches on every annotation, in every subtask, and clear the
// action streams so retained RLE payloads (before/after) are collectible.
for (const subtask of Object.values(this.subtasks ?? {})) {
const access = subtask?.annotations?.access ?? {};
for (const anno of Object.values(access)) {
delete anno["_mask"];
delete anno["_mask_render"];
delete anno["_bitmask_box_hint"];
}
subtask.annotations = { ordering: [], access: {} };
if (subtask.actions) {
subtask.actions.stream = [];
subtask.actions.undone_stack = [];
}
if (subtask.state) {
subtask.state["annotation_contexts"] = {};
}
Comment thread
TrevorBurgoyne marked this conversation as resolved.
}

// 5. Break the toolbox <-> ulabel back-reference. Toolbox items keep a
// `this.ulabel` and are stored on `this.toolbox.items`.
if (this.toolbox?.items) {
for (const item of this.toolbox.items) {
if (item) item.ulabel = null;
}
this.toolbox.items.length = 0;
}
this.toolbox = null;

// 6. Drop the resize-observer array so disconnected observers stop retaining state.
this.resize_observers = [];

// 7. Wipe the container DOM (canvases, id dialogs, brush circle, enders, overlays).
// Prefer the captured owned-container node so a same-id replacement mounted by an SPA
// between detach and this call is left untouched.
let container = this._owned_container;
if (container == null) {
const container_id = this.config?.["container_id"];
if (container_id) container = document.getElementById(container_id);
}
Comment thread
TrevorBurgoyne marked this conversation as resolved.
if (container) container.innerHTML = "";
this._owned_container = null;

this.is_init = false;
this.is_destroyed = true;
}

static process_allowed_modes(ul, subtask_key, subtask) {
// TODO(v1) check to make sure these are known modes
ul.subtasks[subtask_key]["allowed_modes"] = subtask["allowed_modes"];
Expand Down Expand Up @@ -674,6 +751,13 @@ export class ULabel {
// Track global state
this.is_shaking = false;
this.annotation_navigation_toast_timeout = null;
// Set true by destroy(); subsequent calls short-circuit.
this.is_destroyed = false;
// MutationObserver used by opt-in auto-teardown; may be null.
this.mutation_observer = null;
// The specific container HTMLElement this instance owns. Captured at observer-install
// time so an SPA replacement using the same id can't be mistaken for our container.
this._owned_container = null;
}

init(callback) {
Expand All @@ -695,6 +779,49 @@ export class ULabel {
}
}

/**
* Watch for the container leaving the document and call `destroy()` when it does.
* Callback holds a WeakRef so this observer alone can't pin the instance in memory,
* and defers the teardown decision by one animation frame so brief detach/reattach
* (portals, jQuery `.detach()`, layout reparenting) does not trigger a false positive.
*/
_install_auto_destroy_observer() {
if (this.is_destroyed) return;
if (this.mutation_observer != null) return;
if (!this.config?.["auto_destroy_on_detach"]) return;
if (typeof MutationObserver === "undefined" || typeof WeakRef === "undefined") return;
// _owned_container is captured by ulabel_init(); fall back to id lookup for direct callers.
let container = this._owned_container;
if (container == null) {
const container_id = this.config?.["container_id"];
if (!container_id) return;
container = document.getElementById(container_id);
if (container == null) return;
this._owned_container = container;
}
if (!container.isConnected) return;

const root = typeof container.getRootNode === "function" ? container.getRootNode() : document;
const weak = new WeakRef(this);
const observer = new MutationObserver(() => {
const self = weak.deref();
if (!self || self.is_destroyed) return;
if (container.isConnected) return;
requestAnimationFrame(() => {
const s = weak.deref();
if (!s || s.is_destroyed) return;
if (container.isConnected) return;
try {
s.destroy();
} catch (err) {
log_message(`auto-teardown destroy() threw: ${err}`, LogLevel.ERROR, true);
}
});
});
observer.observe(root, { childList: true, subtree: true });
this.mutation_observer = observer;
}

version() {
return ULabel.version();
}
Expand Down Expand Up @@ -1318,8 +1445,10 @@ export class ULabel {
subtask = this.get_current_subtask_key();
}

const annotation = this.subtasks[subtask]["annotations"]["access"][annotation_id];

// Remove the annotation_id from the canvas context list
const canvas_id = this.subtasks[subtask]["annotations"]["access"][annotation_id]["canvas_id"];
const canvas_id = annotation["canvas_id"];
const canvas_context = this.subtasks[subtask]["state"]["annotation_contexts"][canvas_id];
const annotation_ids = canvas_context["annotation_ids"];
const idx = annotation_ids.indexOf(annotation_id);
Expand All @@ -1335,6 +1464,14 @@ export class ULabel {
// Otherwise, redraw the remaining annotations
this.redraw_all_annotations_in_annotation_context(canvas_id, subtask);
}

// Release bitmask-only caches so the mask Uint8Array and tinted stencil canvas
// are collectible even if the annotation object outlives this call.
if (annotation["spatial_type"] === "bitmask") {
delete annotation["_mask"];
delete annotation["_mask_render"];
delete annotation["_bitmask_box_hint"];
}
}

// Get the element id for a nonspatial annotation row
Expand Down Expand Up @@ -2283,6 +2420,10 @@ export class ULabel {
* @param {boolean} nonspatial_only if true, only redraw nonspatial annotations
*/
redraw_all_annotations(subtask = null, offset = null, nonspatial_only = false) {
if (this.is_destroyed) {
log_message("redraw_all_annotations called on a destroyed ULabel instance", LogLevel.WARNING, true);
return;
}
// TODO(3d)
if (subtask === null) {
for (const st in this.subtasks) {
Expand Down Expand Up @@ -6852,6 +6993,10 @@ export class ULabel {
}

async swap_frame_image(new_src, frame = 0) {
if (this.is_destroyed) {
log_message("swap_frame_image called on a destroyed ULabel instance", LogLevel.WARNING, true);
return null;
}
const img = $(`img#${this.config["image_id_pfx"]}__${frame}`);
const ret = img.attr("src");

Expand All @@ -6861,6 +7006,12 @@ export class ULabel {
// Yield so the browser can paint the loader before swapping the image
await ULabelLoader.wait_for_render();

// Recheck: destroy() may have run during the paint yield.
if (this.is_destroyed) {
log_message("swap_frame_image aborted; ULabel was destroyed during load", LogLevel.WARNING, true);
return null;
}

try {
img.attr("src", new_src);
// Wait for the new image to be decoded and ready to display
Expand Down Expand Up @@ -6937,6 +7088,10 @@ export class ULabel {

// Allow for external access and modification of annotations within a subtask
get_annotations(subtask) {
if (this.is_destroyed) {
log_message("get_annotations called on a destroyed ULabel instance", LogLevel.WARNING, true);
return [];
}
let ret = [];
for (let i = 0; i < this.subtasks[subtask]["annotations"]["ordering"].length; i++) {
let id = this.subtasks[subtask]["annotations"]["ordering"][i];
Expand All @@ -6948,17 +7103,27 @@ export class ULabel {
}

async set_annotations(new_annotations, subtask) {
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();

// Recheck: destroy() (manual or auto) may have run during the paint yield.
if (this.is_destroyed) {
log_message("set_annotations aborted; ULabel was destroyed during load", LogLevel.WARNING, true);
return;
}

try {
// Undo/redo won't work through a get/set
this.reset_interaction_state();
this.subtasks[subtask]["actions"]["stream"] = [];
this.subtasks[subtask]["actions"]["undo_stack"] = [];
this.subtasks[subtask]["actions"]["undone_stack"] = [];

// Remove canvases for spatial annotations
for (let i = 0; i < this.subtasks[subtask]["annotations"]["ordering"].length; i++) {
Expand Down
Loading
Loading