Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented here.

## [unreleased]

## [0.26.1] - Aug 13th, 2026
- **Bitmask annotations can now be imported as raw `Uint8Array` payloads.** In addition to the existing RLE `{ counts, size }` shape, `spatial_payload` may now be `{ data: Uint8Array, size: [height, width] }`. This lets callers that already have the mask as a pixel buffer skip the RLE encode step before handing it to ULabel.
- **Loader no longer flashes on fast operations.** `ULabelLoader.add_loader_div()` now appends the overlay hidden and reveals it only after a delay (200 ms by default). Callers can pass an explicit `delay_ms` — including `0` to opt back into immediate-show.
- **Performance: bulk teardown in `set_annotations()`.** The old per-annotation `destroy_annotation_context` loop redrew remaining siblings on each canvas after every removal. The new path drops caches on outgoing annotations, empties the subtask's canvasses container in one shot, and resets `annotation_contexts`. Eliminates wasted redraws.
- **Correctness: `reset_interaction_state()` in `set_annotations()` is now scoped to the target subtask.** Previously the reset ran on every subtask, wiping `is_in_edit` / `active_id` on subtasks the caller wasn't touching.

## [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.
Expand Down
15 changes: 15 additions & 0 deletions api_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,21 @@ A bitmask's `spatial_payload` is a COCO-style, uncompressed run-length encoding:

Note this is the *uncompressed* form (`counts` as an integer array), not the LEB128-packed string used by `pycocotools`. Masks import from and export to this same object shape.

**Raw payload (import only)**

To skip the RLE encode step on the caller side, bitmask annotations may be imported with a raw pixel-buffer payload:

```javascript
{
// Row-major, one byte per pixel. Non-zero = foreground. Length must be height * width.
"data": <Uint8Array>,
// [height, width] of the mask
"size": [<height>, <width>]
}
```

The `Uint8Array` is defensively copied on load. This shape is accepted for input only — `get_annotations()` always exports the RLE form so downstream consumers see one format. Internally, an annotation loaded with a raw payload is upgraded to RLE on first export or edit.

The render opacity of bitmask annotations is configurable via [`mask_annotation_opacity`](#mask_annotation_opacity).

The `resume_from` attributes are used to import existing annotations into the annotation session for each subtask, respectively. Existing annotations must be provided as a list of annotations of the form specified above.
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.26.0",
"version": "0.26.1",
"main": "dist/ulabel.min.js",
"module": "dist/ulabel.min.js",
"types": "dist/index.d.ts",
Expand Down
8 changes: 6 additions & 2 deletions src/annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
ULabelSpatialType,
} from "../index";
import { GeometricUtils } from "./geometric_utils";
import { ULabelMask } from "./mask_utils";
import { ULabelMask, is_raw_mask_payload } from "./mask_utils";
import { log_message, LogLevel } from "./error_logging";

// Modes used to draw an area in the which to delete all annotations
Expand Down Expand Up @@ -125,7 +125,11 @@ export class ULabelAnnotation {
if (this.spatial_type === "bitmask") {
// Reject malformed / corrupt raster payloads, surfacing the specific reason
try {
ULabelMask.validate_rle(this.spatial_payload);
if (is_raw_mask_payload(this.spatial_payload)) {
ULabelMask.validate_raw(this.spatial_payload);
} else {
ULabelMask.validate_rle(this.spatial_payload);
}
} catch (error) {
log_message(`Skipping bitmask annotation id ${this.id}: ${(error as Error).message}`, LogLevel.WARNING, true);
return false;
Expand Down
92 changes: 79 additions & 13 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { remove_ulabel_listeners } from "../build/listeners";
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 } from "../build/mask_utils";
import { ULabelMask, is_raw_mask_payload } from "../build/mask_utils";
import { get_local_storage_item, set_local_storage_item } from "../build/utilities";

import $ from "jquery";
Expand Down Expand Up @@ -347,6 +347,32 @@ export class ULabel {
}
}

/**
* Deep-clone an incoming annotation from resume_from / set_annotations input.
*
* The default path is JSON.parse(JSON.stringify(...)), which is fine for RLE and
* polygon payloads but silently corrupts a raw Uint8Array bitmask payload (JSON
* turns a typed array into an object with numeric string keys). For the raw
* `{ data: Uint8Array, size: [h, w] }` shape we shallow-clone the annotation and
* copy the mask bytes separately so the typed array reference survives.
*
* @param {object} raw incoming annotation
* @returns {object} deep copy suitable for from_json / mutation by ULabel internals
*/
static clone_incoming_annotation(raw) {
if (raw != null && raw.spatial_type === "bitmask" && is_raw_mask_payload(raw.spatial_payload)) {
const raw_payload = raw.spatial_payload;
const bare = { ...raw, spatial_payload: undefined };
const cloned = JSON.parse(JSON.stringify(bare));
cloned.spatial_payload = {
data: new Uint8Array(raw_payload.data),
size: [raw_payload.size[0], raw_payload.size[1]],
};
return cloned;
}
return JSON.parse(JSON.stringify(raw));
}

static process_resume_from(ul, subtask_key, subtask) {
// Initialize to no annotations
ul.subtasks[subtask_key]["annotations"] = {
Expand All @@ -356,7 +382,7 @@ export class ULabel {
if (subtask["resume_from"] != null) {
for (var i = 0; i < subtask["resume_from"].length; i++) {
// Get copy of annotation to import for modification before incorporation
let cand = ULabelAnnotation.from_json(JSON.parse(JSON.stringify(subtask["resume_from"][i])));
let cand = ULabelAnnotation.from_json(ULabel.clone_incoming_annotation(subtask["resume_from"][i]));
if (cand === null) {
continue;
}
Expand Down Expand Up @@ -1919,6 +1945,9 @@ export class ULabel {
const payload = annotation_object["spatial_payload"];
if (payload != null && payload["counts"] !== undefined) {
mask = ULabelMask.from_rle(payload, false);
} else if (is_raw_mask_payload(payload)) {
// Raw payload was already copied by clone_incoming_annotation; skip a second copy.
mask = ULabelMask.from_raw(payload, false, false);
} else {
mask = ULabelMask.create_empty(this.config["image_width"], this.config["image_height"]);
}
Expand Down Expand Up @@ -7068,6 +7097,12 @@ export class ULabel {
this.subtasks[q[i]]["state"]["active_id"] = null;
this.subtasks[q[i]]["state"]["fly_to_idx"] = null;
}
// drag_state is instance-wide, not per-subtask. Only clobber it when resetting the
// current subtask (or all subtasks) — otherwise an in-progress drag on the current
// subtask loses its mouse_start and the next continue_move throws on null[0].
if (subtask !== null && subtask !== this.state["current_subtask"]) {
return;
}
this.drag_state = {
active_key: null,
release_button: null,
Expand Down Expand Up @@ -7114,7 +7149,14 @@ export class ULabel {
for (let i = 0; i < this.subtasks[subtask]["annotations"]["ordering"].length; i++) {
let id = this.subtasks[subtask]["annotations"]["ordering"][i];
if (id != this.get_current_subtask()["state"]["active_id"]) {
ret.push(this.subtasks[subtask]["annotations"]["access"][id]);
const anno = this.subtasks[subtask]["annotations"]["access"][id];
// Bitmasks loaded with a raw Uint8Array payload would be mangled by JSON.stringify
// (typed arrays become plain objects with numeric string keys). Materialize RLE
// in-place now; the annotation ends up normalized to RLE for all future exports.
if (anno.spatial_type === "bitmask" && is_raw_mask_payload(anno.spatial_payload)) {
anno.spatial_payload = this.get_bitmask(anno).to_rle();
}
ret.push(anno);
}
}
return JSON.parse(JSON.stringify(ret));
Expand All @@ -7138,21 +7180,25 @@ export class ULabel {
}

try {
// Undo/redo won't work through a get/set
this.reset_interaction_state();
// 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);
Comment thread
TrevorBurgoyne marked this conversation as resolved.
this.subtasks[subtask]["actions"]["stream"] = [];
this.subtasks[subtask]["actions"]["undone_stack"] = [];

// Remove canvases for spatial annotations
for (let i = 0; i < this.subtasks[subtask]["annotations"]["ordering"].length; i++) {
// If a spatial annotation, delete the canvas
let id = this.subtasks[subtask]["annotations"]["ordering"][i];
if (!NONSPATIAL_MODES.includes(this.subtasks[subtask]["annotations"]["access"][id]["spatial_type"])) {
this.destroy_annotation_context(id, subtask);
}
}
// 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);
Expand All @@ -7165,6 +7211,26 @@ export class ULabel {
}
}

/**
* Bulk-teardown of a subtask's spatial annotation canvases + bitmask caches.
* Faster than looping destroy_annotation_context() per annotation, which would
* redraw the remaining siblings on every removal — wasted work when the caller
* is about to replace everything and redraw once.
*/
_clear_subtask_annotation_canvases(subtask) {
const access = this.subtasks[subtask]["annotations"]["access"];
for (const id of this.subtasks[subtask]["annotations"]["ordering"]) {
const anno = access[id];
if (anno?.spatial_type === "bitmask") {
delete anno["_mask"];
delete anno["_mask_render"];
delete anno["_bitmask_box_hint"];
}
}
$("#canvasses__" + subtask).empty();
this.subtasks[subtask]["state"]["annotation_contexts"] = {};
}

// Change frame
update_frame(delta = null, new_frame = null) {
if (this.config["image_data"]["frames"].length === 1) {
Expand Down
44 changes: 38 additions & 6 deletions src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,59 @@
* Animated loader for initial loading screen.
*/
export class ULabelLoader {
// Default delay before the loader becomes visible. Operations that finish
// in less than this never flash a loader on screen. See CHANGELOG for rationale.
public static readonly DEFAULT_REVEAL_DELAY_MS: number = 200;

// Non-null while a loader is pending or shown. Static because ULabel currently
// supports one instance per page (see api_spec.md); no per-instance tracking needed.
private static reveal_timer: ReturnType<typeof setTimeout> | null = null;
private static overlay: HTMLElement | null = null;

public static add_loader_div(
container: HTMLElement,
delay_ms: number = ULabelLoader.DEFAULT_REVEAL_DELAY_MS,
) {
// Tear down any prior overlay so overlapping ops don't stack DOM nodes.
ULabelLoader.remove_loader_div();

const loader_overlay = document.createElement("div");
loader_overlay.classList.add("ulabel-loader-overlay");
// Hidden until the reveal timer fires; fast ops never flash a loader.
loader_overlay.style.visibility = "hidden";

const loader = document.createElement("div");
loader.classList.add("ulabel-loader");

const style = ULabelLoader.build_loader_style();

loader_overlay.appendChild(loader);
loader_overlay.appendChild(style);
loader_overlay.appendChild(ULabelLoader.build_loader_style());
container.appendChild(loader_overlay);
ULabelLoader.overlay = loader_overlay;

if (delay_ms <= 0) {
loader_overlay.style.visibility = "visible";
return;
}
ULabelLoader.reveal_timer = setTimeout(() => {
if (ULabelLoader.overlay) {
ULabelLoader.overlay.style.visibility = "visible";
}
ULabelLoader.reveal_timer = null;
Comment thread
TrevorBurgoyne marked this conversation as resolved.
}, delay_ms);
}

public static remove_loader_div() {
const loader = document.querySelector(".ulabel-loader-overlay");
if (loader) {
loader.remove();
if (ULabelLoader.reveal_timer != null) {
clearTimeout(ULabelLoader.reveal_timer);
ULabelLoader.reveal_timer = null;
}
if (ULabelLoader.overlay) {
ULabelLoader.overlay.remove();
ULabelLoader.overlay = null;
}
// Sweep any stray overlay (older code paths, hot reloads, etc.).
const stray = document.querySelector(".ulabel-loader-overlay");
if (stray) stray.remove();
}

/**
Expand Down
56 changes: 52 additions & 4 deletions src/mask_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ export type ULabelMaskPayload = {
size: [number, number];
};

// 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.
export type ULabelRawMaskPayload = {
data: Uint8Array;
size: [number, number];
};

// 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 };
if (!(p.data instanceof Uint8Array) && !(p.data instanceof Uint8ClampedArray)) return false;
if (!Array.isArray(p.size) || p.size.length !== 2) return false;
return Number.isInteger(p.size[0]) && Number.isInteger(p.size[1]);
}

// Axis-aligned bounding box in image pixel coordinates (inclusive bounds).
export type BoundingBox = {
tlx: number;
Expand Down Expand Up @@ -358,16 +376,19 @@ export class ULabelMask {
// Encode to COCO-style, column-major run-length counts.
public to_rle(): ULabelMaskPayload {
const counts: number[] = [];
let current = 0; // runs always start with background
// Runs always start with background (0). Compare foreground-truthiness rather
// than literal byte equality so raw imported payloads with any non-{0,1} values
// (e.g. 0/255 masks, multi-valued upstream buffers) still encode correctly.
let current_is_fg = false;
let run = 0;
for (let x = 0; x < this.width; x++) {
for (let y = 0; y < this.height; y++) {
const value = this.data[y * this.width + x];
if (value === current) {
const is_fg = this.data[y * this.width + x] !== 0;
if (is_fg === current_is_fg) {
run++;
} else {
counts.push(run);
current = value;
current_is_fg = is_fg;
run = 1;
}
}
Expand Down Expand Up @@ -440,4 +461,31 @@ export class ULabelMask {
}
return mask;
}

// Validate a raw pixel-buffer payload before wrapping it in a mask.
public static validate_raw(payload: unknown): void {
if (!is_raw_mask_payload(payload)) {
throw new Error("Invalid raw mask payload: expected { data: Uint8Array, size: [height, width] }");
}
const [height, width] = payload.size;
if (height < 0 || width < 0) {
throw new Error(`Invalid raw mask size: expected non-negative integers, got [${height}, ${width}]`);
}
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}`);
}
}

// 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.
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;
Comment thread
TrevorBurgoyne marked this conversation as resolved.
return new ULabelMask(width, height, data);
}
}
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.26.0";
export const ULABEL_VERSION = "0.26.1";
Loading
Loading