Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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

9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ 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`.
- `remove_ulabel_listeners()` now scopes its `.id_dialog` cleanup to the instance's container, so tearing down one ULabel no longer strips id-dialog handlers from siblings on the same page.


## [0.25.1] - Aug 10th, 2026
- The `ConfidenceSlider` toolbox item now also filters `bitmask` annotations
Expand Down
23 changes: 22 additions & 1 deletion api_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,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 +616,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 +657,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
5 changes: 5 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,11 @@ 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;

// Static functions
static version(): string;
static get_time(): string;
Expand Down
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
145 changes: 143 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,79 @@ 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).
const container_id = this.config?.["container_id"];
if (container_id) {
const container = document.getElementById(container_id);
if (container) container.innerHTML = "";
}
Comment thread
TrevorBurgoyne marked this conversation as resolved.

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 +747,10 @@ 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;
}

init(callback) {
Expand All @@ -693,6 +770,48 @@ export class ULabel {
if (this.get_current_subtask()["state"]["annotation_mode"] === "bitmask") {
BrushToolboxItem.show_brush_toolbox_item();
}

// Install the opt-in auto-teardown observer once the container is in the DOM.
if (this.config?.["auto_destroy_on_detach"]) {
this._install_auto_destroy_observer();
}
}

/**
* 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.config?.["auto_destroy_on_detach"]) return;
if (typeof MutationObserver === "undefined" || typeof WeakRef === "undefined") return;
const container_id = this.config?.["container_id"];
if (!container_id) return;
Comment thread
TrevorBurgoyne marked this conversation as resolved.
Outdated
const container = document.getElementById(container_id);
if (container == null || !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;
const c = document.getElementById(self.config?.["container_id"]);
if (c != null && c.isConnected) return;
Comment thread
TrevorBurgoyne marked this conversation as resolved.
Outdated
requestAnimationFrame(() => {
const s = weak.deref();
if (!s || s.is_destroyed) return;
const cc = document.getElementById(s.config?.["container_id"]);
if (cc != null && cc.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() {
Expand Down Expand Up @@ -1318,8 +1437,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 +1456,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 +2412,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 @@ -6937,6 +7070,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,6 +7085,10 @@ 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);
Expand All @@ -6958,7 +7099,7 @@ export class ULabel {
// 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
8 changes: 7 additions & 1 deletion src/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,7 +747,13 @@ export function remove_ulabel_listeners(
// Remove jquery event listeners with the ulabel namespace
$(document).off(ULABEL_NAMESPACE);
$(window).off(ULABEL_NAMESPACE);
$(".id_dialog").off(ULABEL_NAMESPACE);
// Scope id_dialog cleanup to this instance so a sibling ULabel keeps its handlers.
const container_id = ulabel?.config?.container_id;
if (container_id) {
$(`#${container_id} .id_dialog`).off(ULABEL_NAMESPACE);
} else {
$(".id_dialog").off(ULABEL_NAMESPACE);
}
Comment thread
TrevorBurgoyne marked this conversation as resolved.
Outdated

// Go through each resize observer and disconnect them
if (ulabel.resize_observers != null) {
Expand Down
2 changes: 1 addition & 1 deletion src/version.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const ULABEL_VERSION = "0.25.1";
export const ULABEL_VERSION = "0.26.0";
Loading
Loading