Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
27 changes: 26 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,11 @@ 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).

> **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.


## Display Utility Functions

Expand Down Expand Up @@ -653,6 +661,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
Loading
Loading