From 5c047477769d8798087223eb92311eb126e8bcb6 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 13 Aug 2026 10:02:33 -0500 Subject: [PATCH 1/5] support loading decoded rle --- api_spec.md | 15 ++++++++ package.json | 2 +- src/annotation.ts | 8 +++-- src/index.js | 42 ++++++++++++++++++++-- src/mask_utils.ts | 45 +++++++++++++++++++++++ src/version.js | 2 +- tests/annotation.test.js | 77 ++++++++++++++++++++++++++++++++++++++++ tests/mask_utils.test.js | 34 ++++++++++++++++++ 8 files changed, 218 insertions(+), 7 deletions(-) diff --git a/api_spec.md b/api_spec.md index 08bb3b66..2a4422bf 100644 --- a/api_spec.md +++ b/api_spec.md @@ -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": , + // [height, width] of the mask + "size": [, ] +} +``` + +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. diff --git a/package.json b/package.json index 4cd503e8..b0ec51c0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/annotation.ts b/src/annotation.ts index 7637b53d..8793b374 100644 --- a/src/annotation.ts +++ b/src/annotation.ts @@ -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 @@ -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; diff --git a/src/index.js b/src/index.js index 578c58dd..f22ae953 100644 --- a/src/index.js +++ b/src/index.js @@ -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"; @@ -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"] = { @@ -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; } @@ -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"]); } @@ -7114,7 +7143,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)); diff --git a/src/mask_utils.ts b/src/mask_utils.ts index 320de903..5d7d04a1 100644 --- a/src/mask_utils.ts +++ b/src/mask_utils.ts @@ -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; @@ -440,4 +458,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; + return new ULabelMask(width, height, data); + } } diff --git a/src/version.js b/src/version.js index 5abdaa45..2d6761a6 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const ULABEL_VERSION = "0.26.0"; +export const ULABEL_VERSION = "0.26.1"; diff --git a/tests/annotation.test.js b/tests/annotation.test.js index 230e0e82..337285ca 100644 --- a/tests/annotation.test.js +++ b/tests/annotation.test.js @@ -124,6 +124,83 @@ describe("Annotation Processing", () => { expect(annotation.containing_box).toEqual({ tlx: 0, tly: 0, brx: 7, bry: 5 }); }); + test("should accept a raw Uint8Array bitmask payload and export it as RLE", () => { + // Build the same mask as the RLE test above, but pass the raw pixel buffer. + const mask = ULabelMask.create_empty(8, 6); + mask.paint_circle(4, 3, 2, 1); + mask.set_pixel(0, 0, 1); + mask.set_pixel(7, 5, 1); + const original_bytes = new Uint8Array(mask.data); + const expected_rle = mask.to_rle(); + + const raw_payload = { data: original_bytes, size: [6, 8] }; + const resume_config = { + ...mock_config, + subtasks: { + test_task: { + ...mock_config.subtasks.test_task, + allowed_modes: ["bbox", "polygon", "point", "bitmask"], + resume_from: [ + { + spatial_type: "bitmask", + spatial_payload: raw_payload, + classification_payloads: [{ class_id: 1, confidence: 1.0 }], + }, + ], + }, + }, + }; + + const ulabel_with_resume = new ULabel(resume_config); + const annotations = ulabel_with_resume.subtasks.test_task.annotations; + expect(annotations.ordering).toHaveLength(1); + const annotation = annotations.access[annotations.ordering[0]]; + expect(annotation.spatial_type).toBe("bitmask"); + expect(annotation.deprecated).toBe(false); + + // Payload is still raw internally (no RLE encode has happened yet) + expect(annotation.spatial_payload.data).toBeInstanceOf(Uint8Array); + expect(annotation.spatial_payload.size).toEqual([6, 8]); + + // Defensive copy: mutating the caller's buffer must not corrupt the internal mask. + original_bytes[0] = 0; + expect(annotation.spatial_payload.data[0]).toBe(1); + + // get_annotations reads state.current_subtask, which init() would set. + ulabel_with_resume.state.current_subtask = "test_task"; + // Emulate export: get_annotations materializes RLE before JSON round-trip. + const exported = ulabel_with_resume.get_annotations("test_task")[0]; + expect(exported.spatial_payload.counts).toEqual(expected_rle.counts); + expect(exported.spatial_payload.size).toEqual(expected_rle.size); + expect(exported.spatial_payload.data).toBeUndefined(); + expect(exported._mask).toBeUndefined(); + + // The containing box was rebuilt from the mask's foreground bounds + expect(annotation.containing_box).toEqual({ tlx: 0, tly: 0, brx: 7, bry: 5 }); + }); + + test("should skip a bitmask annotation with a malformed raw payload", () => { + const resume_config = { + ...mock_config, + subtasks: { + test_task: { + ...mock_config.subtasks.test_task, + allowed_modes: ["bbox", "polygon", "point", "bitmask"], + resume_from: [ + { + spatial_type: "bitmask", + // Length doesn't match 6*8=48 + spatial_payload: { data: new Uint8Array(10), size: [6, 8] }, + classification_payloads: [{ class_id: 1, confidence: 1.0 }], + }, + ], + }, + }, + }; + const ulabel_with_resume = new ULabel(resume_config); + expect(ulabel_with_resume.subtasks.test_task.annotations.ordering).toHaveLength(0); + }); + test("should skip a bitmask annotation with a malformed RLE payload", () => { const resume_config = { ...mock_config, diff --git a/tests/mask_utils.test.js b/tests/mask_utils.test.js index d1f4374a..d2a581e4 100644 --- a/tests/mask_utils.test.js +++ b/tests/mask_utils.test.js @@ -322,6 +322,40 @@ describe("ULabelMask", () => { }); }); + describe("raw payload validation and construction", () => { + test("from_raw wraps a matching Uint8Array and copies by default", () => { + const data = new Uint8Array([1, 0, 1, 1]); + const mask = ULabelMask.from_raw({ data, size: [2, 2] }); + expect(mask.width).toBe(2); + expect(mask.height).toBe(2); + expect(Array.from(mask.data)).toEqual([1, 0, 1, 1]); + + // Default is copy: mutating the source doesn't affect the mask. + data[0] = 0; + expect(mask.data[0]).toBe(1); + }); + + test("from_raw with copy=false references the caller's buffer", () => { + const data = new Uint8Array([0, 0, 0, 0]); + const mask = ULabelMask.from_raw({ data, size: [2, 2] }, true, false); + data[0] = 1; + expect(mask.data[0]).toBe(1); + }); + + test("validate_raw rejects wrong-length buffers", () => { + expect(() => ULabelMask.validate_raw({ data: new Uint8Array(3), size: [2, 2] })).toThrow(); + }); + + test("validate_raw rejects non-typed-array data", () => { + expect(() => ULabelMask.validate_raw({ data: [1, 0, 1, 0], size: [2, 2] })).toThrow(); + }); + + test("validate_raw rejects a malformed size", () => { + expect(() => ULabelMask.validate_raw({ data: new Uint8Array(4), size: [2] })).toThrow(); + expect(() => ULabelMask.validate_raw({ data: new Uint8Array(4), size: "2x2" })).toThrow(); + }); + }); + describe("box-limited boolean operations", () => { // Build a mask and set the given [x, y] pixels. const make = (w, h, pts) => { From b6c709449ed0a16b9f082741fe485a0efe916ddf Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 13 Aug 2026 10:19:57 -0500 Subject: [PATCH 2/5] performance improvements for set_annotations --- CHANGELOG.md | 2 + src/index.js | 36 +++++++++++----- tests/annotation.test.js | 91 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34988a59..ee8d16bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented here. ## [unreleased] +# [0.26.1] - Aug 13th, 2026 + ## [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. diff --git a/src/index.js b/src/index.js index f22ae953..bd991687 100644 --- a/src/index.js +++ b/src/index.js @@ -7174,19 +7174,15 @@ 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); 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 }); initialize_annotation_canvases(this, subtask); @@ -7201,6 +7197,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) { diff --git a/tests/annotation.test.js b/tests/annotation.test.js index 337285ca..2e0bd113 100644 --- a/tests/annotation.test.js +++ b/tests/annotation.test.js @@ -346,4 +346,95 @@ describe("Annotation Processing", () => { ]); }); }); + + describe("set_annotations bulk teardown", () => { + function build_bitmask_config() { + const mask = ULabelMask.create_empty(8, 6); + mask.paint_circle(4, 3, 2, 1); + return { + ...mock_config, + subtasks: { + test_task: { + ...mock_config.subtasks.test_task, + allowed_modes: ["bbox", "polygon", "point", "bitmask"], + resume_from: [ + { + spatial_type: "bitmask", + spatial_payload: mask.to_rle(), + classification_payloads: [{ class_id: 1, confidence: 1.0 }], + }, + ], + }, + }, + }; + } + + test("_clear_subtask_annotation_canvases drops bitmask caches, DOM canvases, and contexts", () => { + const ulabel = new ULabel(build_bitmask_config()); + const anno_id = ulabel.subtasks.test_task.annotations.ordering[0]; + const annotation = ulabel.subtasks.test_task.annotations.access[anno_id]; + + // Populate _mask cache and plant fake canvas DOM + contexts as init would. + ulabel.get_bitmask(annotation); + expect(annotation._mask).toBeDefined(); + + const canvasses = document.createElement("div"); + canvasses.id = "canvasses__test_task"; + document.body.appendChild(canvasses); + const fake_canvas = document.createElement("canvas"); + fake_canvas.id = "canvas__fake"; + canvasses.appendChild(fake_canvas); + ulabel.subtasks.test_task.state.annotation_contexts = { + canvas__fake: { annotation_ids: [anno_id], context: {} }, + }; + + ulabel._clear_subtask_annotation_canvases("test_task"); + + // Bitmask caches released so the Uint8Array + tinted stencil become collectible. + expect(annotation._mask).toBeUndefined(); + expect(annotation._mask_render).toBeUndefined(); + expect(annotation._bitmask_box_hint).toBeUndefined(); + // Canvas DOM subtree wiped. + expect(canvasses.children.length).toBe(0); + // Annotation-context bookkeeping reset. + expect(ulabel.subtasks.test_task.state.annotation_contexts).toEqual({}); + }); + + test("reset_interaction_state scoped to a subtask preserves other subtasks", () => { + const two_subtask_config = { + ...mock_config, + subtasks: { + subtask_a: { + display_name: "A", + classes: [{ name: "X", id: 1, color: "red" }], + allowed_modes: ["bbox", "point"], + resume_from: null, + }, + subtask_b: { + display_name: "B", + classes: [{ name: "Y", id: 2, color: "blue" }], + allowed_modes: ["bbox", "point"], + resume_from: null, + }, + }, + }; + const ulabel = new ULabel(two_subtask_config); + ulabel.subtasks.subtask_a.state.is_in_edit = true; + ulabel.subtasks.subtask_a.state.is_in_move = true; + ulabel.subtasks.subtask_a.state.active_id = "a_id"; + ulabel.subtasks.subtask_b.state.is_in_edit = true; + ulabel.subtasks.subtask_b.state.is_in_move = true; + ulabel.subtasks.subtask_b.state.active_id = "b_id"; + + ulabel.reset_interaction_state("subtask_a"); + + expect(ulabel.subtasks.subtask_a.state.is_in_edit).toBe(false); + expect(ulabel.subtasks.subtask_a.state.is_in_move).toBe(false); + expect(ulabel.subtasks.subtask_a.state.active_id).toBeNull(); + // subtask_b is untouched. + expect(ulabel.subtasks.subtask_b.state.is_in_edit).toBe(true); + expect(ulabel.subtasks.subtask_b.state.is_in_move).toBe(true); + expect(ulabel.subtasks.subtask_b.state.active_id).toBe("b_id"); + }); + }); }); From f85ad8c1abe11ba5fd8bd7313eb7df074f4d9e52 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 13 Aug 2026 10:35:33 -0500 Subject: [PATCH 3/5] add loader delay --- CHANGELOG.md | 4 +++ src/loader.ts | 44 +++++++++++++++++++++++++---- tests/loader.test.js | 67 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 tests/loader.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8d16bf..4fa5b0ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ 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: diff --git a/src/loader.ts b/src/loader.ts index 6c7a46af..4d5e07bf 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -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 | 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; + }, 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(); } /** diff --git a/tests/loader.test.js b/tests/loader.test.js new file mode 100644 index 00000000..8fd6c7db --- /dev/null +++ b/tests/loader.test.js @@ -0,0 +1,67 @@ +// Tests for the loader overlay's delay-before-show behavior. +const { ULabelLoader } = require("../build/loader"); + +describe("ULabelLoader", () => { + let container; + + beforeEach(() => { + jest.useFakeTimers(); + document.body.innerHTML = ""; + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + ULabelLoader.remove_loader_div(); + jest.useRealTimers(); + }); + + test("appends the overlay hidden, then reveals it after the delay", () => { + ULabelLoader.add_loader_div(container, 200); + const overlay = container.querySelector(".ulabel-loader-overlay"); + expect(overlay).not.toBeNull(); + expect(overlay.style.visibility).toBe("hidden"); + + jest.advanceTimersByTime(199); + expect(overlay.style.visibility).toBe("hidden"); + + jest.advanceTimersByTime(1); + expect(overlay.style.visibility).toBe("visible"); + }); + + test("remove before the delay fires never shows the loader", () => { + ULabelLoader.add_loader_div(container, 200); + const overlay = container.querySelector(".ulabel-loader-overlay"); + expect(overlay.style.visibility).toBe("hidden"); + + // Op finishes fast; caller removes the loader. + ULabelLoader.remove_loader_div(); + expect(container.querySelector(".ulabel-loader-overlay")).toBeNull(); + + // Timer should be cancelled: advancing time doesn't resurrect anything. + jest.advanceTimersByTime(500); + expect(container.querySelector(".ulabel-loader-overlay")).toBeNull(); + }); + + test("delay_ms=0 reveals synchronously (opt-out for callers who want immediate feedback)", () => { + ULabelLoader.add_loader_div(container, 0); + const overlay = container.querySelector(".ulabel-loader-overlay"); + expect(overlay.style.visibility).toBe("visible"); + }); + + test("calling add twice tears down the first overlay so only one is in the DOM", () => { + ULabelLoader.add_loader_div(container, 200); + ULabelLoader.add_loader_div(container, 200); + expect(container.querySelectorAll(".ulabel-loader-overlay").length).toBe(1); + }); + + test("uses DEFAULT_REVEAL_DELAY_MS when no delay is given", () => { + expect(ULabelLoader.DEFAULT_REVEAL_DELAY_MS).toBe(200); + ULabelLoader.add_loader_div(container); + const overlay = container.querySelector(".ulabel-loader-overlay"); + jest.advanceTimersByTime(ULabelLoader.DEFAULT_REVEAL_DELAY_MS - 1); + expect(overlay.style.visibility).toBe("hidden"); + jest.advanceTimersByTime(1); + expect(overlay.style.visibility).toBe("visible"); + }); +}); From 7b9a1c0fadc4d4e30e942c7930eb0e299b078196 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 13 Aug 2026 10:55:00 -0500 Subject: [PATCH 4/5] bump version --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6312a914..f383879b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ulabel", - "version": "0.26.0", + "version": "0.26.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ulabel", - "version": "0.26.0", + "version": "0.26.1", "license": "MIT", "devDependencies": { "@eslint/config-inspector": "^1.3.0", From 768587cbe76ecdc33b0994d39baf546dc12ba657 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Thu, 13 Aug 2026 11:11:46 -0500 Subject: [PATCH 5/5] apply suggestions from review --- CHANGELOG.md | 2 +- src/index.js | 14 +++++++++++++ src/mask_utils.ts | 11 ++++++---- tests/annotation.test.js | 44 ++++++++++++++++++++++++++++++++++++++++ tests/mask_utils.test.js | 20 ++++++++++++++++++ 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa5b0ae..85ce014a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented here. ## [unreleased] -# [0.26.1] - Aug 13th, 2026 +## [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. diff --git a/src/index.js b/src/index.js index bd991687..2b38453c 100644 --- a/src/index.js +++ b/src/index.js @@ -7097,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, @@ -7185,6 +7191,14 @@ export class ULabel { // 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); diff --git a/src/mask_utils.ts b/src/mask_utils.ts index 5d7d04a1..a86ae8c8 100644 --- a/src/mask_utils.ts +++ b/src/mask_utils.ts @@ -376,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; } } diff --git a/tests/annotation.test.js b/tests/annotation.test.js index 2e0bd113..10d71779 100644 --- a/tests/annotation.test.js +++ b/tests/annotation.test.js @@ -436,5 +436,49 @@ describe("Annotation Processing", () => { expect(ulabel.subtasks.subtask_b.state.is_in_move).toBe(true); expect(ulabel.subtasks.subtask_b.state.active_id).toBe("b_id"); }); + + test("reset_interaction_state on a background subtask preserves drag_state", () => { + const two_subtask_config = { + ...mock_config, + subtasks: { + subtask_a: { + display_name: "A", + classes: [{ name: "X", id: 1, color: "red" }], + allowed_modes: ["bbox", "point"], + resume_from: null, + }, + subtask_b: { + display_name: "B", + classes: [{ name: "Y", id: 2, color: "blue" }], + allowed_modes: ["bbox", "point"], + resume_from: null, + }, + }, + }; + const ulabel = new ULabel(two_subtask_config); + // Simulate an in-progress drag on the current subtask (subtask_a). + ulabel.state.current_subtask = "subtask_a"; + ulabel.drag_state.active_key = "move"; + ulabel.drag_state.move.mouse_start = [100, 200]; + ulabel.drag_state.move.zoom_val_start = 1; + + ulabel.reset_interaction_state("subtask_b"); + + // Background reset must NOT clobber the current subtask's drag_state. + expect(ulabel.drag_state.active_key).toBe("move"); + expect(ulabel.drag_state.move.mouse_start).toEqual([100, 200]); + }); + + test("reset_interaction_state on the current subtask does clear drag_state", () => { + const ulabel = new ULabel(mock_config); + ulabel.state.current_subtask = "test_task"; + ulabel.drag_state.active_key = "move"; + ulabel.drag_state.move.mouse_start = [100, 200]; + + ulabel.reset_interaction_state("test_task"); + + expect(ulabel.drag_state.active_key).toBeNull(); + expect(ulabel.drag_state.move.mouse_start).toBeNull(); + }); }); }); diff --git a/tests/mask_utils.test.js b/tests/mask_utils.test.js index d2a581e4..1f997761 100644 --- a/tests/mask_utils.test.js +++ b/tests/mask_utils.test.js @@ -154,6 +154,26 @@ describe("ULabelMask", () => { const restored = ULabelMask.from_rle(exported.spatial_payload); expect(Array.from(restored.data)).toEqual(Array.from(mask.data)); }); + + test("encodes any non-zero byte as foreground (raw payloads with 2, 255, etc.)", () => { + // Raw imports may carry values beyond {0, 1}. Every non-zero byte must encode as fg. + // Layout (2x3): [1, 2, 0, 255, 3, 0] + // Column-major traversal (x then y within column): + // x=0: (0,0)=1, (0,1)=255 -> two foreground pixels + // x=1: (1,0)=2, (1,1)=3 -> two foreground pixels + // x=2: (2,0)=0, (2,1)=0 -> two background pixels + // Expected runs (starting with bg): [0, 4, 2] + const data = new Uint8Array([1, 2, 0, 255, 3, 0]); + const mask = ULabelMask.from_raw({ data, size: [2, 3] }, true, false); + const rle = mask.to_rle(); + expect(rle.size).toEqual([2, 3]); + expect(rle.counts).toEqual([0, 4, 2]); + // Decoding the RLE must produce the same foreground pattern. + const restored = ULabelMask.from_rle(rle); + const fg_pattern = Array.from(restored.data).map((b) => (b !== 0 ? 1 : 0)); + const original_fg = Array.from(data).map((b) => (b !== 0 ? 1 : 0)); + expect(fg_pattern).toEqual(original_fg); + }); }); describe("boolean operations", () => {