diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52a374dd..2f4ab387 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@ All notable changes to this project will be documented here.
## [unreleased]
+
+## [0.25.1] - Aug 10th, 2026
+- The `ConfidenceSlider` toolbox item now also filters `bitmask` annotations
+- The `set_annotations()` and `swap_frame_image()` public API methods now display the loading spinner while they run and return a `Promise`
+- Bitmask brush **overlap modes** (`exclude` / `overwrite`) now resolve against bitmask annotations across **all subtasks**, not just the active one.
+- Performance improvements for jobs with many large bitmask annotations:
+ - Overlap resolution (`exclude` / `overwrite`) now uses a bounding-box pre-filter and box-limited pixel operations, skipping masks that don't overlap the stroke and touching only the overlapping region of the ones that do, instead of scanning the whole mask for each candidate.
+ - Moving a bitmask renders only the moving mask over a cached snapshot of the others (throttled to one render per animation frame) instead of re-rasterizing every mask on the canvas each frame.
+ - Each bitmask caches a pre-tinted render that is reused across redraws (brush dabs, moves, pan, zoom) and rebuilt only when the mask or its color changes, removing per-frame pixel-by-pixel rasterization.
+
## [0.25.0] - Aug 5th, 2026
- Add a `bitmask` annotation mode for raster (per-pixel) segmentation, selectable via `allowed_modes: ["bitmask", ...]`.
- Painted with the brush (toggle with `toggle_brush_mode_keybind`, default `g`); erase with `toggle_erase_mode_keybind` (default `e`); resize the brush with `increase_brush_size_keybind` / `decrease_brush_size_keybind` (defaults `]` / `[`) or `alt+scroll`. The brush/erase toggles now apply to both `polygon` and `bitmask` modes.
diff --git a/api_spec.md b/api_spec.md
index 53ee150a..f302894b 100644
--- a/api_spec.md
+++ b/api_spec.md
@@ -484,7 +484,7 @@ This toolbox item requires no configuration and can be added to the `toolbox_ord
### `confidence_slider_toolbox_item`
-The `ConfidenceSlider` toolbox item (added to `toolbox_order` via `AllowedToolboxItem.ConfidenceSlider`) deprecates (hides) or shows spatial annotations based on their confidence values. Unlike the deprecated `KeypointSlider`, it works with **all** spatial annotation types that have a confidence payload (`bbox`, `bbox3`, `polygon`, `polyline`, `contour`, `tbar`, and `point`), across every subtask.
+The `ConfidenceSlider` toolbox item (added to `toolbox_order` via `AllowedToolboxItem.ConfidenceSlider`) deprecates (hides) or shows spatial annotations based on their confidence values. Unlike the deprecated `KeypointSlider`, it works with **all** spatial annotation types that have a confidence payload (`bbox`, `bbox3`, `polygon`, `polyline`, `contour`, `tbar`, `point`, and `bitmask`), across every subtask.
It supports two modes:
@@ -622,7 +622,7 @@ Display utilities are provided for a constructed `ULabel` object.
### `swap_frame_image(new_src, frame=0)`
-*(string, int) => string* -- Changes the image source for a given frame. Returns the old source.
+*(string, int) => Promise<string>* -- Changes the image source for a given frame. Displays the loading spinner while the new image loads. Returns a `Promise` that resolves with the old source once the new image has been decoded; `await` it if you need to run code after the swap completes.
### `swap_anno_bg_color(new_bg_color)`
@@ -642,7 +642,7 @@ Display utilities are provided for a constructed `ULabel` object.
### `set_annotations(new_annotations, subtask)`
-*(array, string) => void* -- Sets the annotations for the provided subtask.
+*(array, string) => Promise<void>* -- Sets the annotations for the provided subtask. Displays the loading spinner while re-initializing the annotations (similar to a new init). Returns a `Promise` that resolves once the annotations have been set and redrawn; `await` it if you need to run code after the update completes.
### `set_saved(saved)`
diff --git a/demo/bitmask-e2e.html b/demo/bitmask-e2e.html
new file mode 100644
index 00000000..da1d6008
--- /dev/null
+++ b/demo/bitmask-e2e.html
@@ -0,0 +1,71 @@
+
+
+
+ ULabel - Bitmask e2e fixture
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/index.d.ts b/index.d.ts
index f1808485..1d119abb 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -371,7 +371,7 @@ export class ULabel {
public after_init(): void;
public show_initial_crop(): void;
public show_whole_image(): void;
- public swap_frame_image(new_src: string, frame?: number): string;
+ public swap_frame_image(new_src: string, frame?: number): Promise;
public swap_anno_bg_color(new_bg_color: string): string;
// Subtasks
@@ -383,10 +383,11 @@ export class ULabel {
// Annotations
public get_annotations(subtask: string): ULabelAnnotation[];
- public set_annotations(annotations: ULabelAnnotation[], subtask: string): void;
+ public set_annotations(annotations: ULabelAnnotation[], subtask: string): Promise;
public set_saved(saved: boolean): void;
public draw_annotation_from_id(id: string, offset?: Offset, subtask?: string): void;
public redraw_annotation(annotation_id: string, subtask?: string, offset?: Offset): void;
+ public render_bitmask_move(annotation_id: string, subtask: string, offset: Offset): void;
public redraw_all_annotations(
subtask?: string,
offset?: number | null,
diff --git a/package-lock.json b/package-lock.json
index 5e9f7648..09f3c256 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ulabel",
- "version": "0.25.0",
+ "version": "0.25.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ulabel",
- "version": "0.25.0",
+ "version": "0.25.1",
"license": "MIT",
"devDependencies": {
"@eslint/config-inspector": "^1.3.0",
diff --git a/package.json b/package.json
index 0fa6406c..f16f467e 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ulabel",
"description": "An image annotation tool.",
- "version": "0.25.0",
+ "version": "0.25.1",
"main": "dist/ulabel.min.js",
"module": "dist/ulabel.min.js",
"types": "dist/index.d.ts",
diff --git a/src/actions.ts b/src/actions.ts
index 0c612df5..937fcaf4 100644
--- a/src/actions.ts
+++ b/src/actions.ts
@@ -345,6 +345,13 @@ function on_in_progress_annotation_spatial_modification(
diffY: 0,
diffZ: 0,
};
+ // Fast path for in-progress bitmask moves: only the moving mask is redrawn (see begin_bitmask_move).
+ const in_progress_ann = current_subtask.annotations.access[action.annotation_id!];
+ if (action.act_type === "continue_move" && in_progress_ann?.spatial_type === "bitmask") {
+ ulabel.render_bitmask_move(action.annotation_id!, subtask_key, offset);
+ ulabel.suggest_edits();
+ return;
+ }
// Update the toolbox filter distance
ulabel.update_filter_distance_during_polyline_move(action.annotation_id!, true, false, offset);
// Update the annotation rendering
diff --git a/src/index.js b/src/index.js
index 597030b2..6936daff 100644
--- a/src/index.js
+++ b/src/index.js
@@ -51,6 +51,7 @@ import {
} from "./blobs";
import { ULABEL_VERSION } from "./version";
import { ulabel_init } from "../build/initializer";
+import { ULabelLoader } from "../build/loader";
jQuery.fn.outer_html = function () {
return jQuery("").append(this.eq(0).clone()).html();
@@ -588,6 +589,10 @@ export class ULabel {
last_move: null,
current_frame: 0,
+ // Overlay snapshot + rAF flag used to keep in-progress bitmask moves cheap
+ bitmask_move_overlay: null,
+ move_raf_pending: false,
+
// Global annotation state (subtasks also maintain an annotation state)
current_subtask: null, // The key of the current subtask
last_brush_stroke: null,
@@ -1776,6 +1781,8 @@ export class ULabel {
writable: true,
configurable: true,
});
+ // The cached render belonged to the previous mask; drop it so it rebuilds.
+ this.set_bitmask_render(annotation_object, null);
}
// Replace an annotation's cached mask from an RLE payload (or empty if null).
@@ -1873,23 +1880,59 @@ export class ULabel {
const mask = this.get_bitmask(annotation_object);
if (mask === null || image_width == null || image_height == null) return;
+ const color = this.get_annotation_color(annotation_object);
+
+ // Reuse a cached, pre-tinted native-resolution stencil when the mask and color are
+ // unchanged. The move/zoom/pan offset is applied at blit time, so it doesn't invalidate
+ // the cache; only a mask edit (version bump) or color change forces a rebuild.
+ let render = annotation_object["_mask_render"];
+ if (render == null || render.mask !== mask || render.version !== mask.version || render.color !== color) {
+ render = this.build_bitmask_render(annotation_object, mask, color);
+ if (render === null) return; // Empty mask, nothing to draw
+ this.set_bitmask_render(annotation_object, render);
+ }
+
+ let diffX = 0;
+ let diffY = 0;
+ if (offset != null) {
+ diffX = offset["diffX"];
+ diffY = offset["diffY"];
+ }
+ ctx.imageSmoothingEnabled = false;
+ ctx.globalCompositeOperation = "source-over";
+ ctx.globalAlpha = this.config["mask_annotation_opacity"];
+ ctx.drawImage(
+ render.canvas,
+ (render.tlx + diffX) * px_per_px,
+ (render.tly + diffY) * px_per_px,
+ render.box_width * px_per_px,
+ render.box_height * px_per_px,
+ );
+ ctx.globalAlpha = 1.0;
+ }
+
+ // Build a native-resolution, class-colored stencil for a bitmask's bounding box.
+ // Returns { canvas, tlx, tly, box_width, box_height, mask, version, color } or null if empty.
+ build_bitmask_render(annotation_object, mask, color) {
+ const image_width = this.config["image_width"];
+ const image_height = this.config["image_height"];
+
// Only rasterize the mask's bounding box rather than the whole image. The containing
// box is maintained as a superset of the foreground (see rebuild_bitmask_containing_box),
// so every painted pixel is covered. Fall back to a full scan only if it is missing.
let box = annotation_object["containing_box"];
if (box == null) {
box = mask.get_bounding_box();
- if (box === null) return; // Empty mask, nothing to draw
+ if (box === null) return null;
}
- // Clamp the box to the image and compute its pixel dimensions
const tlx = Math.max(0, Math.floor(box.tlx));
const tly = Math.max(0, Math.floor(box.tly));
const brx = Math.min(image_width - 1, Math.ceil(box.brx));
const bry = Math.min(image_height - 1, Math.ceil(box.bry));
const box_width = brx - tlx + 1;
const box_height = bry - tly + 1;
- if (box_width <= 0 || box_height <= 0) return;
+ if (box_width <= 0 || box_height <= 0) return null;
// Build an opaque white stencil of just the box region at native resolution
const offscreen = document.createElement("canvas");
@@ -1917,27 +1960,30 @@ export class ULabel {
// Tint the stencil with the class color. Using fillStyle lets the canvas resolve
// both named CSS colors (e.g. "green") and hex strings, matching every other draw fn.
offscreen_ctx.globalCompositeOperation = "source-in";
- offscreen_ctx.fillStyle = this.get_annotation_color(annotation_object);
+ offscreen_ctx.fillStyle = color;
offscreen_ctx.fillRect(0, 0, box_width, box_height);
- // Draw the box region scaled onto the target context at its image position, honoring any offset
- let diffX = 0;
- let diffY = 0;
- if (offset != null) {
- diffX = offset["diffX"];
- diffY = offset["diffY"];
- }
- ctx.imageSmoothingEnabled = false;
- ctx.globalCompositeOperation = "source-over";
- ctx.globalAlpha = this.config["mask_annotation_opacity"];
- ctx.drawImage(
- offscreen,
- (tlx + diffX) * px_per_px,
- (tly + diffY) * px_per_px,
- box_width * px_per_px,
- box_height * px_per_px,
- );
- ctx.globalAlpha = 1.0;
+ return {
+ canvas: offscreen,
+ tlx: tlx,
+ tly: tly,
+ box_width: box_width,
+ box_height: box_height,
+ mask: mask,
+ version: mask.version,
+ color: color,
+ };
+ }
+
+ // Attach a cached render to a bitmask annotation as a non-enumerable property so it is
+ // excluded from serialization.
+ set_bitmask_render(annotation_object, render) {
+ Object.defineProperty(annotation_object, "_mask_render", {
+ value: render,
+ enumerable: false,
+ writable: true,
+ configurable: true,
+ });
}
draw_contour(annotation_object, ctx, offset = null) {
@@ -2167,6 +2213,51 @@ export class ULabel {
}
}
+ // Redraw a context, skipping one annotation. Used to snapshot the static masks during a move.
+ redraw_annotation_context_excluding(canvas_id, subtask, exclude_id) {
+ this.clear_annotation_canvas(canvas_id, subtask);
+ if (this.subtasks[subtask]["state"]["is_vanished"]) return;
+ for (const annid of this.subtasks[subtask]["state"]["annotation_contexts"][canvas_id]["annotation_ids"]) {
+ if (annid === exclude_id) continue;
+ this.draw_annotation_from_id(annid, null, subtask);
+ }
+ }
+
+ // Snapshot the moving bitmask's canvas with the moving mask removed, so each move frame
+ // only has to blit the snapshot and redraw the single moving mask (not the whole context).
+ begin_bitmask_move(active_id, subtask) {
+ const canvas_id = this.subtasks[subtask]["annotations"]["access"][active_id]["canvas_id"];
+ const ctx = this.subtasks[subtask]["state"]["annotation_contexts"][canvas_id]["context"];
+ this.redraw_annotation_context_excluding(canvas_id, subtask, active_id);
+ const live = ctx.canvas;
+ const snapshot = document.createElement("canvas");
+ snapshot.width = live.width;
+ snapshot.height = live.height;
+ snapshot.getContext("2d").drawImage(live, 0, 0);
+ this.state["bitmask_move_overlay"] = { ctx: ctx, snapshot: snapshot };
+ }
+
+ // Per-frame render for an in-progress bitmask move: restore the snapshot, draw the moving mask.
+ render_bitmask_move(active_id, subtask, offset) {
+ const overlay = this.state["bitmask_move_overlay"];
+ if (overlay == null) {
+ // No snapshot (shouldn't happen); fall back to the generic redraw
+ this.rebuild_containing_box(active_id, false, subtask);
+ this.redraw_annotation(active_id, subtask, offset);
+ return;
+ }
+ const w = this.config["image_width"] * this.config["px_per_px"];
+ const h = this.config["image_height"] * this.config["px_per_px"];
+ overlay.ctx.clearRect(0, 0, w, h);
+ overlay.ctx.drawImage(overlay.snapshot, 0, 0);
+ this.draw_annotation_from_id(active_id, offset, subtask);
+ }
+
+ // Discard the move snapshot once a bitmask move ends.
+ end_bitmask_move() {
+ this.state["bitmask_move_overlay"] = null;
+ }
+
redraw_all_annotations_in_subtask(subtask, offset = null, nonspatial_only = false) {
// Clear the canvas
this.clear_front_canvas(subtask);
@@ -4746,16 +4837,24 @@ export class ULabel {
this.render_bitmask_other_edits(other_edits);
}
- // Ids of all undeprecated bitmask annotations except the given one.
+ // {id, subtask} for all undeprecated bitmask annotations except the given one, across all
+ // subtasks. Matches the render frame-gate (see draw_annotation_from_id): a stroke can only
+ // affect masks that are visible on the current frame.
get_other_bitmask_ids(active_id) {
- const current_subtask = this.get_current_subtask();
- const access = current_subtask["annotations"]["access"];
+ const active_st = this.get_current_subtask_key();
+ const current_frame = this.state["current_frame"];
const ids = [];
- for (const oid of current_subtask["annotations"]["ordering"]) {
- if (oid === active_id) continue;
- const ann = access[oid];
- if (!ann["deprecated"] && ann["spatial_type"] === "bitmask") {
- ids.push(oid);
+ for (const st_key in this.subtasks) {
+ const access = this.subtasks[st_key]["annotations"]["access"];
+ for (const oid of this.subtasks[st_key]["annotations"]["ordering"]) {
+ // ID collisions are only prevented within a subtask, so scope the active-skip
+ // to the active subtask.
+ if (st_key === active_st && oid === active_id) continue;
+ const ann = access[oid];
+ if (ann["deprecated"] || ann["spatial_type"] !== "bitmask") continue;
+ const frame = ann["frame"];
+ if (frame != null && frame !== current_frame) continue;
+ ids.push({ id: oid, subtask: st_key });
}
}
return ids;
@@ -4766,28 +4865,46 @@ export class ULabel {
// - overwrite: carve the new pixels out of every other mask.
// Returns the list of edits made to other annotations (empty for exclude/none).
resolve_bitmask_overlap(active_id, delta, overlap_mode) {
- const access = this.get_current_subtask()["annotations"]["access"];
+ const active_access = this.get_current_subtask()["annotations"]["access"];
const other_ids = this.get_other_bitmask_ids(active_id);
+ // Only the pixels this stroke added matter; use their bounding box to skip
+ // any mask whose containing box doesn't overlap, and to bound the pixel ops.
+ const delta_box = delta.get_bounding_box();
+ if (delta_box === null) return [];
+
if (overlap_mode === "exclude") {
- const active_mask = this.get_bitmask(access[active_id]);
- for (const oid of other_ids) {
- const other_mask = this.get_bitmask(access[oid]);
+ const active_mask = this.get_bitmask(active_access[active_id]);
+ for (const { id: oid, subtask: st } of other_ids) {
+ const other_ann = this.subtasks[st]["annotations"]["access"][oid];
+ const box = this.intersect_boxes(delta_box, other_ann["containing_box"]);
+ if (box === null) continue;
+ const other_mask = this.get_bitmask(other_ann);
// Remove only the newly-added pixels that land on this other mask
- const to_remove = delta.clone();
- to_remove.intersect(other_mask);
- active_mask.subtract(to_remove);
+ active_mask.subtract_intersection_in_box(delta, other_mask, box);
}
return [];
}
// overwrite
const other_edits = [];
- for (const oid of other_ids) {
- const other_mask = this.get_bitmask(access[oid]);
- if (!other_mask.intersects(delta)) continue;
+ const active_mask = this.get_bitmask(active_access[active_id]);
+ for (const { id: oid, subtask: st } of other_ids) {
+ const access = this.subtasks[st]["annotations"]["access"];
+ const other_ann = access[oid];
+ const box = this.intersect_boxes(delta_box, other_ann["containing_box"]);
+ if (box === null) continue;
+ const other_mask = this.get_bitmask(other_ann);
+ if (!other_mask.intersects_in_box(delta, box)) continue;
+ // Read-only masks act as barriers: clip the active mask around them instead of
+ // carving them, preserving both the read-only contract and overwrite's mutual
+ // exclusion invariant on export.
+ if (this.subtasks[st]["read_only"]) {
+ active_mask.subtract_intersection_in_box(delta, other_mask, box);
+ continue;
+ }
const before_rle = other_mask.to_rle();
- other_mask.subtract(delta);
+ other_mask.subtract_in_box(delta, box);
const after_empty = other_mask.is_empty();
access[oid]["spatial_payload"] = other_mask.to_rle();
if (after_empty) {
@@ -4795,6 +4912,7 @@ export class ULabel {
}
other_edits.push({
annotation_id: oid,
+ subtask: st,
before_rle: before_rle,
after_rle: access[oid]["spatial_payload"],
after_empty: after_empty,
@@ -4803,14 +4921,25 @@ export class ULabel {
return other_edits;
}
+ // Intersection of two {tlx, tly, brx, bry} boxes, or null if disjoint or either is missing.
+ intersect_boxes(a, b) {
+ if (a == null || b == null) return null;
+ const tlx = Math.max(a["tlx"], b["tlx"]);
+ const tly = Math.max(a["tly"], b["tly"]);
+ const brx = Math.min(a["brx"], b["brx"]);
+ const bry = Math.min(a["bry"], b["bry"]);
+ if (brx < tlx || bry < tly) return null;
+ return { tlx: tlx, tly: tly, brx: brx, bry: bry };
+ }
+
// Rebuild boxes and redraw the annotations carved by an overwrite stroke.
render_bitmask_other_edits(other_edits) {
if (!other_edits || other_edits.length === 0) return;
- const access = this.get_current_subtask()["annotations"]["access"];
for (const edit of other_edits) {
+ const access = this.subtasks[edit.subtask]["annotations"]["access"];
if (access[edit.annotation_id] === undefined) continue;
this.rebuild_bitmask_containing_box(access[edit.annotation_id]);
- this.redraw_annotation(edit.annotation_id);
+ this.redraw_annotation(edit.annotation_id, edit.subtask);
}
this.toolbox.redraw_update_items(this);
}
@@ -4833,13 +4962,13 @@ export class ULabel {
// Restore any other masks carved by an overwrite stroke (they were undeprecated before)
const other_edits = undo_payload.other_edits || [];
for (const edit of other_edits) {
- const other = annotations[edit.annotation_id];
+ const other = this.subtasks[edit.subtask]["annotations"]["access"][edit.annotation_id];
if (other === undefined) continue;
this.set_bitmask_from_rle(other, edit.before_rle);
other["spatial_payload"] = edit.before_rle;
mark_deprecated(other, false);
this.rebuild_bitmask_containing_box(other);
- this.redraw_annotation(edit.annotation_id);
+ this.redraw_annotation(edit.annotation_id, edit.subtask);
}
}
@@ -4854,13 +4983,13 @@ export class ULabel {
// Re-apply the carve to any other masks
const other_edits = redo_payload.other_edits || [];
for (const edit of other_edits) {
- const other = annotations[edit.annotation_id];
+ const other = this.subtasks[edit.subtask]["annotations"]["access"][edit.annotation_id];
if (other === undefined) continue;
this.set_bitmask_from_rle(other, edit.after_rle);
other["spatial_payload"] = edit.after_rle;
mark_deprecated(other, edit.after_empty === true);
this.rebuild_bitmask_containing_box(other);
- this.redraw_annotation(edit.annotation_id);
+ this.redraw_annotation(edit.annotation_id, edit.subtask);
}
// Re-record so the stroke can be undone again
@@ -5374,9 +5503,28 @@ export class ULabel {
},
});
+ // Isolate the moving mask on a snapshot overlay so each frame is cheap
+ if (annotations[active_id]["spatial_type"] === "bitmask") {
+ this.begin_bitmask_move(active_id, this.get_current_subtask_key());
+ }
+
this.continue_move(mouse_event);
}
+ // Coalesce rapid mousemove events into at most one move render per animation frame.
+ schedule_continue_move(mouse_event) {
+ this.state["last_move"] = mouse_event;
+ if (this.state["move_raf_pending"]) return;
+ this.state["move_raf_pending"] = true;
+ requestAnimationFrame(() => {
+ this.state["move_raf_pending"] = false;
+ // The move may have finished before this frame ran
+ if (this.get_current_subtask()["state"]["is_in_move"]) {
+ this.continue_move(this.state["last_move"]);
+ }
+ });
+ }
+
continue_move(mouse_event) {
// Convenience
const current_subtask = this.get_current_subtask();
@@ -5423,6 +5571,7 @@ export class ULabel {
this.translate_bitmask(annotation, diffX, diffY);
current_subtask["state"]["active_id"] = null;
current_subtask["state"]["is_in_move"] = false;
+ this.end_bitmask_move();
record_finish_move(this, diffX, diffY, diffZ, false);
return;
}
@@ -6258,7 +6407,7 @@ export class ULabel {
break;
case "move":
if (!idd_visible || idd_thumbnail) {
- this.continue_move(mouse_event);
+ this.schedule_continue_move(mouse_event);
}
break;
}
@@ -6702,9 +6851,23 @@ export class ULabel {
}
}
- swap_frame_image(new_src, frame = 0) {
- const ret = $(`img#${this.config["image_id_pfx"]}__${frame}`).attr("src");
- $(`img#${this.config["image_id_pfx"]}__${frame}`).attr("src", new_src);
+ async swap_frame_image(new_src, frame = 0) {
+ const img = $(`img#${this.config["image_id_pfx"]}__${frame}`);
+ const ret = img.attr("src");
+
+ // Show the loader while the new image loads, 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 swapping the image
+ await ULabelLoader.wait_for_render();
+
+ try {
+ img.attr("src", new_src);
+ // Wait for the new image to be decoded and ready to display
+ await img[0].decode();
+ } finally {
+ ULabelLoader.remove_loader_div();
+ }
return ret;
}
@@ -6784,29 +6947,39 @@ export class ULabel {
return JSON.parse(JSON.stringify(ret));
}
- set_annotations(new_annotations, subtask) {
- // Undo/redo won't work through a get/set
- this.reset_interaction_state();
- this.subtasks[subtask]["actions"]["stream"] = [];
- this.subtasks[subtask]["actions"]["undo_stack"] = [];
+ async set_annotations(new_annotations, subtask) {
+ // 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();
- // 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);
+ 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"] = [];
+
+ // 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);
+ }
}
+ // Set new annotations and initialize canvases
+ ULabel.process_resume_from(this, subtask, { resume_from: new_annotations });
+ initialize_annotation_canvases(this, subtask);
+ // Redraw all annotations to render them
+ this.redraw_all_annotations(subtask);
+ // Calculate distances for all annotations if FilterDistance is present
+ this.update_filter_distance(null, false, true);
+ // Update class counter in toolbox
+ this.toolbox.redraw_update_items(this);
+ } finally {
+ ULabelLoader.remove_loader_div();
}
- // Set new annotations and initialize canvases
- ULabel.process_resume_from(this, subtask, { resume_from: new_annotations });
- initialize_annotation_canvases(this, subtask);
- // Redraw all annotations to render them
- this.redraw_all_annotations(subtask);
- // Calculate distances for all annotations if FilterDistance is present
- this.update_filter_distance(null, false, true);
- // Update class counter in toolbox
- this.toolbox.redraw_update_items(this);
}
// Change frame
diff --git a/src/loader.ts b/src/loader.ts
index a5ab3c68..6c7a46af 100644
--- a/src/loader.ts
+++ b/src/loader.ts
@@ -25,6 +25,26 @@ export class ULabelLoader {
}
}
+ /**
+ * Resolve after the browser has had a chance to paint.
+ *
+ * Uses a double requestAnimationFrame so a freshly-added loader is actually rendered
+ * before the caller runs heavy synchronous work. Races against a short timer so hidden
+ * documents (where requestAnimationFrame is throttled or paused) don't block indefinitely.
+ */
+ public static wait_for_render(): Promise {
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = () => {
+ if (settled) return;
+ settled = true;
+ resolve();
+ };
+ requestAnimationFrame(() => requestAnimationFrame(finish));
+ setTimeout(finish, 50);
+ });
+ }
+
public static build_loader_style(): HTMLStyleElement {
const css = `
.ulabel-loader-overlay {
diff --git a/src/mask_utils.ts b/src/mask_utils.ts
index 4ed860ca..320de903 100644
--- a/src/mask_utils.ts
+++ b/src/mask_utils.ts
@@ -14,6 +14,14 @@ export type ULabelMaskPayload = {
size: [number, number];
};
+// Axis-aligned bounding box in image pixel coordinates (inclusive bounds).
+export type BoundingBox = {
+ tlx: number;
+ tly: number;
+ brx: number;
+ bry: number;
+};
+
// Clamp a value into the inclusive integer range [min, max].
function clamp_int(value: number, min: number, max: number): number {
const rounded = Math.round(value);
@@ -26,6 +34,8 @@ export class ULabelMask {
public data: Uint8Array;
public readonly width: number;
public readonly height: number;
+ // Bumped by every mutating method so render caches can detect changes by comparison.
+ public version: number = 0;
constructor(width: number, height: number, data?: Uint8Array) {
this.width = width;
@@ -58,12 +68,14 @@ export class ULabelMask {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) {
return;
}
+ this.version++;
this.data[y * this.width + x] = value ? 1 : 0;
}
// Paint (value = 1) or erase (value = 0) a filled circle into the mask.
// Returns true if any pixel changed.
public paint_circle(cx: number, cy: number, radius: number, value: number): boolean {
+ this.version++;
const v = value ? 1 : 0;
const r = Math.max(0, radius);
const min_x = clamp_int(cx - r, 0, this.width - 1);
@@ -118,7 +130,7 @@ export class ULabelMask {
// Axis-aligned bounding box of foreground pixels, or null if empty.
// Returned as { tlx, tly, brx, bry } in image pixel coordinates.
- public get_bounding_box(): { tlx: number; tly: number; brx: number; bry: number } | null {
+ public get_bounding_box(): BoundingBox | null {
let min_x = this.width;
let min_y = this.height;
let max_x = -1;
@@ -180,6 +192,7 @@ export class ULabelMask {
// Returns true if any pixel changed.
public subtract(other: ULabelMask): boolean {
this.assert_same_dims(other);
+ this.version++;
let changed = false;
for (let i = 0; i < this.data.length; i++) {
if (this.data[i] !== 0 && other.data[i] !== 0) {
@@ -196,6 +209,7 @@ export class ULabelMask {
// pixel changed. Used to apply ULabel's polygon/bbox delete modes to raster masks.
public subtract_polygon(polygon: [number, number][]): boolean {
if (polygon.length < 3) return false;
+ this.version++;
// Restrict work to the polygon's vertical extent, clamped to the image.
let min_py = Infinity;
@@ -243,6 +257,7 @@ export class ULabelMask {
// Add another mask's foreground into this one (this = this OR other).
public add_mask(other: ULabelMask): void {
this.assert_same_dims(other);
+ this.version++;
for (let i = 0; i < this.data.length; i++) {
if (other.data[i] !== 0) {
this.data[i] = 1;
@@ -253,6 +268,7 @@ export class ULabelMask {
// Keep only pixels present in both masks (this = this AND other).
public intersect(other: ULabelMask): void {
this.assert_same_dims(other);
+ this.version++;
for (let i = 0; i < this.data.length; i++) {
if (other.data[i] === 0) {
this.data[i] = 0;
@@ -271,6 +287,74 @@ export class ULabelMask {
return false;
}
+ // Clamp a bounding box to the image, returning integer inclusive bounds or null if empty.
+ private clamp_box_to_image(box: BoundingBox): { x0: number; y0: number; x1: number; y1: number } | null {
+ const x0 = Math.max(0, Math.floor(box.tlx));
+ const y0 = Math.max(0, Math.floor(box.tly));
+ const x1 = Math.min(this.width - 1, Math.ceil(box.brx));
+ const y1 = Math.min(this.height - 1, Math.ceil(box.bry));
+ if (x1 < x0 || y1 < y0) return null;
+ return { x0, y0, x1, y1 };
+ }
+
+ // True if any pixel within `box` is foreground in both masks. O(box area).
+ public intersects_in_box(other: ULabelMask, box: BoundingBox): boolean {
+ this.assert_same_dims(other);
+ const b = this.clamp_box_to_image(box);
+ if (b === null) return false;
+ for (let y = b.y0; y <= b.y1; y++) {
+ const row = y * this.width;
+ for (let x = b.x0; x <= b.x1; x++) {
+ const i = row + x;
+ if (this.data[i] !== 0 && other.data[i] !== 0) return true;
+ }
+ }
+ return false;
+ }
+
+ // Remove another mask's foreground from this one within `box` (this = this AND NOT other).
+ // Returns true if any pixel changed. O(box area).
+ public subtract_in_box(other: ULabelMask, box: BoundingBox): boolean {
+ this.assert_same_dims(other);
+ const b = this.clamp_box_to_image(box);
+ if (b === null) return false;
+ this.version++;
+ let changed = false;
+ for (let y = b.y0; y <= b.y1; y++) {
+ const row = y * this.width;
+ for (let x = b.x0; x <= b.x1; x++) {
+ const i = row + x;
+ if (this.data[i] !== 0 && other.data[i] !== 0) {
+ this.data[i] = 0;
+ changed = true;
+ }
+ }
+ }
+ return changed;
+ }
+
+ // Clear pixels of this mask where both `a` and `b` are foreground, within `box`.
+ // Returns true if any pixel changed. O(box area).
+ public subtract_intersection_in_box(a: ULabelMask, b: ULabelMask, box: BoundingBox): boolean {
+ this.assert_same_dims(a);
+ this.assert_same_dims(b);
+ const bx = this.clamp_box_to_image(box);
+ if (bx === null) return false;
+ this.version++;
+ let changed = false;
+ for (let y = bx.y0; y <= bx.y1; y++) {
+ const row = y * this.width;
+ for (let x = bx.x0; x <= bx.x1; x++) {
+ const i = row + x;
+ if (this.data[i] !== 0 && a.data[i] !== 0 && b.data[i] !== 0) {
+ this.data[i] = 0;
+ changed = true;
+ }
+ }
+ }
+ return changed;
+ }
+
// Encode to COCO-style, column-major run-length counts.
public to_rle(): ULabelMaskPayload {
const counts: number[] = [];
diff --git a/src/version.js b/src/version.js
index 1a902fc9..1e78b199 100644
--- a/src/version.js
+++ b/src/version.js
@@ -1 +1 @@
-export const ULABEL_VERSION = "0.25.0";
+export const ULABEL_VERSION = "0.25.1";
diff --git a/tests/annotation_operators.test.js b/tests/annotation_operators.test.js
index f56d2c74..4a6a5cf5 100644
--- a/tests/annotation_operators.test.js
+++ b/tests/annotation_operators.test.js
@@ -44,7 +44,7 @@ describe("get_annotation_confidence_for_class", () => {
describe("CONFIDENCE_FILTERABLE_SPATIAL_TYPES", () => {
test("includes the spatial modes and excludes the non-spatial modes", () => {
expect(CONFIDENCE_FILTERABLE_SPATIAL_TYPES).toEqual(
- expect.arrayContaining(["contour", "polygon", "polyline", "bbox", "tbar", "bbox3", "point"]),
+ expect.arrayContaining(["contour", "polygon", "polyline", "bbox", "tbar", "bbox3", "point", "bitmask"]),
);
expect(CONFIDENCE_FILTERABLE_SPATIAL_TYPES).not.toContain("whole-image");
expect(CONFIDENCE_FILTERABLE_SPATIAL_TYPES).not.toContain("global");
diff --git a/tests/bitmask_overlap.test.js b/tests/bitmask_overlap.test.js
new file mode 100644
index 00000000..27bf414e
--- /dev/null
+++ b/tests/bitmask_overlap.test.js
@@ -0,0 +1,92 @@
+// Browser-free tests for the bitmask overlap-resolution *semantics*.
+// The real resolve_bitmask_overlap lives in the ESM entry (src/index.js), which jest doesn't
+// transform, so here we replicate its exact per-mask steps using the real (importable) box-limited
+// ULabelMask ops. This validates that those primitives compose into correct exclude/overwrite
+// behavior and a lossless undo round-trip — the integrity the Phase 1 collision refactor relies on.
+// (Full cross-subtask iteration + redraw wiring is covered by tests/e2e/bitmask.spec.js in CI.)
+const { ULabelMask } = require("../build/mask_utils");
+
+const W = 64;
+const H = 64;
+
+function rect_mask(x0, y0, x1, y1) {
+ const mask = ULabelMask.create_empty(W, H);
+ for (let x = x0; x <= x1; x++) {
+ for (let y = y0; y <= y1; y++) {
+ mask.set_pixel(x, y, 1);
+ }
+ }
+ return mask;
+}
+
+// Mirror of ULabel.intersect_boxes: overlap of two {tlx,tly,brx,bry} boxes, or null if disjoint.
+function intersect_boxes(a, b) {
+ if (a == null || b == null) return null;
+ const tlx = Math.max(a.tlx, b.tlx);
+ const tly = Math.max(a.tly, b.tly);
+ const brx = Math.min(a.brx, b.brx);
+ const bry = Math.min(a.bry, b.bry);
+ if (brx < tlx || bry < tly) return null;
+ return { tlx: tlx, tly: tly, brx: brx, bry: bry };
+}
+
+describe("bitmask overlap semantics (mask level)", () => {
+ test("overwrite carves the delta out of an overlapping mask, sparing the rest", () => {
+ // active/delta rect [20..40], other rect [10..30] -> overlap [20..30]x[20..30]
+ const other = rect_mask(10, 10, 30, 30);
+ const delta = rect_mask(20, 20, 40, 40);
+
+ const box = intersect_boxes(delta.get_bounding_box(), other.get_bounding_box());
+ expect(box).not.toBeNull();
+ expect(other.intersects_in_box(delta, box)).toBe(true);
+
+ const before_rle = other.to_rle();
+ other.subtract_in_box(delta, box);
+
+ expect(other.get_pixel(25, 25)).toBe(0); // carved
+ expect(other.get_pixel(12, 12)).toBe(1); // outside overlap: preserved
+
+ // undo contract: restoring from before_rle reproduces the original exactly
+ const restored = ULabelMask.from_rle(before_rle, false);
+ const original = rect_mask(10, 10, 30, 30);
+ expect(Array.from(restored.data)).toEqual(Array.from(original.data));
+ });
+
+ test("the bbox pre-filter skips a disjoint mask (no carve)", () => {
+ const far = rect_mask(50, 50, 60, 60);
+ const delta = rect_mask(20, 20, 40, 40);
+
+ const box = intersect_boxes(delta.get_bounding_box(), far.get_bounding_box());
+ expect(box).toBeNull(); // disjoint -> resolve would `continue` before touching pixels
+
+ // Emulate the skip: far is left untouched
+ expect(far.get_pixel(55, 55)).toBe(1);
+ });
+
+ test("overwrite that fully carves a mask leaves it empty (would be deprecated)", () => {
+ const other = rect_mask(10, 10, 20, 20);
+ const delta = rect_mask(0, 0, 63, 63);
+
+ const box = intersect_boxes(delta.get_bounding_box(), other.get_bounding_box());
+ other.subtract_in_box(delta, box);
+
+ expect(other.is_empty()).toBe(true);
+ });
+
+ test("exclude clips the active mask where it overlaps another, leaving the other untouched", () => {
+ const active = rect_mask(20, 20, 40, 40);
+ const other = rect_mask(10, 10, 30, 30);
+ const delta = rect_mask(20, 20, 40, 40); // the stroke-added pixels
+
+ const other_before = other.to_rle();
+ const box = intersect_boxes(delta.get_bounding_box(), other.get_bounding_box());
+ active.subtract_intersection_in_box(delta, other, box);
+
+ expect(active.get_pixel(25, 25)).toBe(0); // clipped where it overlapped "other"
+ expect(active.get_pixel(35, 35)).toBe(1); // outside overlap: kept
+
+ // exclude must not modify the other mask at all
+ expect(Array.from(ULabelMask.from_rle(other.to_rle(), false).data))
+ .toEqual(Array.from(ULabelMask.from_rle(other_before, false).data));
+ });
+});
diff --git a/tests/confidence_slider.test.js b/tests/confidence_slider.test.js
index cfc177ae..43df09fd 100644
--- a/tests/confidence_slider.test.js
+++ b/tests/confidence_slider.test.js
@@ -135,6 +135,22 @@ describe("ConfidenceSlider", () => {
expect(low.deprecated).toBe(false);
});
+ test("filters bitmask annotations by confidence like any other spatial type", () => {
+ const high_mask = make_annotation("high_mask", "bitmask", [{ class_id: 10, confidence: 0.9 }]);
+ const low_mask = make_annotation("low_mask", "bitmask", [{ class_id: 10, confidence: 0.1 }]);
+ const cs = new ConfidenceSlider(make_ulabel([high_mask, low_mask], {
+ class_filter_mode: "all-only",
+ default_values: { all: 60 },
+ filter_on_load: false,
+ }));
+
+ cs.filter_annotations(false);
+
+ expect(high_mask.deprecated).toBe(false);
+ expect(low_mask.deprecated).toBe(true);
+ expect(low_mask.deprecated_by.confidence_slider).toBe(true);
+ });
+
test("lowering the threshold un-deprecates previously filtered annotations", () => {
const low = make_annotation("low", "bbox", [{ class_id: 10, confidence: 0.1 }]);
const cs = new ConfidenceSlider(make_ulabel([low], {
diff --git a/tests/e2e/bitmask.spec.js b/tests/e2e/bitmask.spec.js
new file mode 100644
index 00000000..48a14e66
--- /dev/null
+++ b/tests/e2e/bitmask.spec.js
@@ -0,0 +1,354 @@
+// End-to-end tests for bitmask overlap resolution and the move overlay.
+// These cover the Phase 1 performance changes at the integration level (real ULabel + canvas):
+// - resolve_bitmask_overlap with the bbox pre-filter and cross-subtask reach
+// - exclude vs overwrite semantics
+// - cross-subtask undo restore of carved masks
+// - the bitmask move overlay lifecycle
+// They run against demo/bitmask-e2e.html, a two-subtask bitmask fixture exposing window.ulabel.
+import { test, expect } from "./fixtures";
+import { wait_for_ulabel_init } from "../testing-utils/init_utils";
+
+// Inject browser-side mask test helpers before any page script runs. `__mask_helpers(ulabel)`
+// returns small builders scoped to the given ULabel instance.
+test.beforeEach(async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__mask_helpers = (u) => {
+ const W = u.config.image_width;
+ const H = u.config.image_height;
+ // COCO column-major RLE for a filled rectangle (matches ULabelMask.to_rle).
+ const rect_rle = (x0, y0, x1, y1) => {
+ const counts = [];
+ let cur = 0;
+ let run = 0;
+ for (let x = 0; x < W; x++) {
+ for (let y = 0; y < H; y++) {
+ const v = (x >= x0 && x <= x1 && y >= y0 && y <= y1) ? 1 : 0;
+ if (v === cur) {
+ run++;
+ } else {
+ counts.push(run);
+ cur = v;
+ run = 1;
+ }
+ }
+ }
+ counts.push(run);
+ return { counts: counts, size: [H, W] };
+ };
+ const make = (id, cid, x0, y0, x1, y1) => ({
+ id: id,
+ spatial_type: "bitmask",
+ spatial_payload: rect_rle(x0, y0, x1, y1),
+ classification_payloads: [{ class_id: cid, confidence: 1 }],
+ });
+ const rebuild = (st, id) => u.rebuild_bitmask_containing_box(u.subtasks[st].annotations.access[id]);
+ const pix = (st, id, x, y) => u.get_bitmask(u.subtasks[st].annotations.access[id]).get_pixel(x, y);
+ return { rect_rle: rect_rle, make: make, rebuild: rebuild, pix: pix };
+ };
+ });
+});
+
+test.describe("Bitmask overlap + move", () => {
+ test("overwrite carves overlapping pixels from masks in other subtasks, sparing disjoint ones", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/bitmask-e2e.html");
+
+ const res = await page.evaluate(async () => {
+ const u = window.ulabel;
+ const { make, rebuild, pix } = window.__mask_helpers(u);
+ // active (subtask a) overlaps other (subtask b) in [20..30]x[20..30]; far is disjoint.
+ await u.set_annotations([make("active", 1, 20, 20, 40, 40), make("far", 1, 50, 50, 60, 60)], "a");
+ await u.set_annotations([make("other", 2, 10, 10, 30, 30)], "b");
+ ["active", "far"].forEach((id) => rebuild("a", id));
+ rebuild("b", "other");
+
+ u.set_subtask("a");
+ u.subtasks["a"].state.active_id = "active";
+ const delta = u.get_bitmask(u.subtasks["a"].annotations.access["active"]);
+ u.resolve_bitmask_overlap("active", delta, "overwrite");
+
+ return {
+ other_overlap: pix("b", "other", 25, 25), // carved -> 0
+ other_kept: pix("b", "other", 12, 12), // outside overlap -> 1
+ far_kept: pix("a", "far", 55, 55), // disjoint (bbox pre-filter) -> 1
+ active_kept: pix("a", "active", 35, 35), // overwrite never touches active -> 1
+ };
+ });
+
+ expect(res.other_overlap).toBe(0);
+ expect(res.other_kept).toBe(1);
+ expect(res.far_kept).toBe(1);
+ expect(res.active_kept).toBe(1);
+ });
+
+ test("exclude clips the active mask and leaves other masks untouched", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/bitmask-e2e.html");
+
+ const res = await page.evaluate(async () => {
+ const u = window.ulabel;
+ const { make, rebuild, pix } = window.__mask_helpers(u);
+ await u.set_annotations([make("active", 1, 20, 20, 40, 40)], "a");
+ await u.set_annotations([make("other", 2, 10, 10, 30, 30)], "b");
+ rebuild("a", "active");
+ rebuild("b", "other");
+
+ u.set_subtask("a");
+ u.subtasks["a"].state.active_id = "active";
+ const delta = u.get_bitmask(u.subtasks["a"].annotations.access["active"]);
+ u.resolve_bitmask_overlap("active", delta, "exclude");
+
+ return {
+ active_overlap: pix("a", "active", 25, 25), // clipped -> 0
+ active_kept: pix("a", "active", 35, 35), // outside overlap -> 1
+ other_untouched: pix("b", "other", 25, 25), // exclude never modifies others -> 1
+ };
+ });
+
+ expect(res.active_overlap).toBe(0);
+ expect(res.active_kept).toBe(1);
+ expect(res.other_untouched).toBe(1);
+ });
+
+ test("undo restores a mask carved across subtasks", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/bitmask-e2e.html");
+
+ const res = await page.evaluate(async () => {
+ const u = window.ulabel;
+ const { make, rebuild, pix } = window.__mask_helpers(u);
+ await u.set_annotations([make("active", 1, 20, 20, 40, 40)], "a");
+ await u.set_annotations([make("other", 2, 10, 10, 30, 30)], "b");
+ rebuild("a", "active");
+ rebuild("b", "other");
+
+ u.set_subtask("a");
+ u.subtasks["a"].state.active_id = "active";
+ const active_ann = u.subtasks["a"].annotations.access["active"];
+ const active_before = active_ann.spatial_payload;
+ const delta = u.get_bitmask(active_ann);
+ const other_edits = u.resolve_bitmask_overlap("active", delta, "overwrite");
+ const carved = pix("b", "other", 25, 25); // 0
+
+ // Reconstruct the stroke payload and undo it (cross-subtask restore path)
+ u.bitmask_stroke__undo("active", {
+ was_new: false,
+ before_rle: active_before,
+ after_rle: active_ann.spatial_payload,
+ after_empty: false,
+ other_edits: other_edits,
+ });
+ const restored = pix("b", "other", 25, 25); // 1
+
+ return { carved: carved, restored: restored };
+ });
+
+ expect(res.carved).toBe(0);
+ expect(res.restored).toBe(1);
+ });
+
+ test("bitmask move overlay snapshots and clears without corrupting state", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/bitmask-e2e.html");
+
+ const res = await page.evaluate(async () => {
+ const u = window.ulabel;
+ const { make, rebuild, pix } = window.__mask_helpers(u);
+ await u.set_annotations([make("m", 1, 10, 10, 30, 30)], "a");
+ rebuild("a", "m");
+ u.set_subtask("a");
+
+ u.begin_bitmask_move("m", "a");
+ const has_overlay = u.state.bitmask_move_overlay != null &&
+ u.state.bitmask_move_overlay.snapshot != null;
+
+ // Should render a frame without throwing
+ u.render_bitmask_move("m", "a", { id: "m", diffX: 5, diffY: 5, diffZ: 0 });
+
+ u.end_bitmask_move();
+ const cleared = u.state.bitmask_move_overlay == null;
+
+ // The mask data itself must be unchanged by the (rendering-only) overlay
+ const still_present = pix("a", "m", 20, 20);
+
+ return { has_overlay: has_overlay, cleared: cleared, still_present: still_present };
+ });
+
+ expect(res.has_overlay).toBe(true);
+ expect(res.cleared).toBe(true);
+ expect(res.still_present).toBe(1);
+ });
+
+ test("render cache is reused across redraws and rebuilt on edit / color change, but not on move offset", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/bitmask-e2e.html");
+
+ const res = await page.evaluate(async () => {
+ const u = window.ulabel;
+ const { make, rebuild } = window.__mask_helpers(u);
+ await u.set_annotations([make("m", 1, 10, 10, 30, 30)], "a");
+ rebuild("a", "m");
+ u.set_subtask("a");
+ const ann = u.subtasks["a"].annotations.access["m"];
+
+ // First draw builds the cache
+ u.redraw_annotation("m", "a");
+ const r1 = ann._mask_render;
+ const created = r1 != null && r1.canvas != null;
+
+ // Redraw with no changes reuses the same cached render object
+ u.redraw_annotation("m", "a");
+ const reused = ann._mask_render === r1;
+
+ // Editing the mask bumps its version, which must rebuild the cache
+ u.get_bitmask(ann).paint_circle(20, 20, 3, 1);
+ rebuild("a", "m");
+ u.redraw_annotation("m", "a");
+ const r2 = ann._mask_render;
+ const rebuilt_on_edit = r2 !== r1 && r2 != null && r2.version === u.get_bitmask(ann).version;
+
+ // A color mismatch must rebuild the cache
+ r2.color = "__stale__";
+ u.redraw_annotation("m", "a");
+ const rebuilt_on_color = ann._mask_render !== r2;
+
+ // A move offset is applied at blit time and must NOT rebuild the cache
+ const r3 = ann._mask_render;
+ u.redraw_annotation("m", "a", { id: "m", diffX: 5, diffY: 5, diffZ: 0 });
+ const reused_on_offset = ann._mask_render === r3;
+
+ return {
+ created: created,
+ reused: reused,
+ rebuilt_on_edit: rebuilt_on_edit,
+ rebuilt_on_color: rebuilt_on_color,
+ reused_on_offset: reused_on_offset,
+ };
+ });
+
+ expect(res.created).toBe(true);
+ expect(res.reused).toBe(true);
+ expect(res.rebuilt_on_edit).toBe(true);
+ expect(res.rebuilt_on_color).toBe(true);
+ expect(res.reused_on_offset).toBe(true);
+ });
+
+ test("replacing a mask (undo/translate path) invalidates the render cache", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/bitmask-e2e.html");
+
+ const res = await page.evaluate(async () => {
+ const u = window.ulabel;
+ const { make, rebuild } = window.__mask_helpers(u);
+ await u.set_annotations([make("m", 1, 10, 10, 30, 30)], "a");
+ rebuild("a", "m");
+ u.set_subtask("a");
+ const ann = u.subtasks["a"].annotations.access["m"];
+
+ u.redraw_annotation("m", "a");
+ const r1 = ann._mask_render;
+
+ // set_bitmask_from_rle swaps the mask object (as undo/redo do) and must clear the cache
+ u.set_bitmask_from_rle(ann, ann.spatial_payload);
+ const cleared = ann._mask_render == null;
+
+ rebuild("a", "m");
+ u.redraw_annotation("m", "a");
+ const rebuilt = ann._mask_render != null && ann._mask_render !== r1;
+
+ return { cleared: cleared, rebuilt: rebuilt };
+ });
+
+ expect(res.cleared).toBe(true);
+ expect(res.rebuilt).toBe(true);
+ });
+
+ test("cross-subtask overlap only skips the active mask in the active subtask (same ID in another subtask is still carved)", async ({ page }) => {
+ // process_resume_from only enforces ID uniqueness within a subtask, so two subtasks can
+ // legitimately hold masks with the same id; the active-skip must be scoped by subtask.
+ await wait_for_ulabel_init(page, "/bitmask-e2e.html");
+
+ const res = await page.evaluate(async () => {
+ const u = window.ulabel;
+ const { make, rebuild, pix } = window.__mask_helpers(u);
+ // Both subtasks have a mask with id "dup"; the one in "a" is active.
+ await u.set_annotations([make("dup", 1, 20, 20, 40, 40)], "a");
+ await u.set_annotations([make("dup", 2, 10, 10, 30, 30)], "b");
+ rebuild("a", "dup");
+ rebuild("b", "dup");
+
+ u.set_subtask("a");
+ u.subtasks["a"].state.active_id = "dup";
+ const delta = u.get_bitmask(u.subtasks["a"].annotations.access["dup"]);
+ u.resolve_bitmask_overlap("dup", delta, "overwrite");
+
+ return {
+ active_kept: pix("a", "dup", 35, 35), // active mask (subtask a) must be untouched
+ other_carved: pix("b", "dup", 25, 25), // same-ID mask in subtask b must be carved
+ other_kept: pix("b", "dup", 12, 12), // outside overlap: kept
+ };
+ });
+
+ expect(res.active_kept).toBe(1);
+ expect(res.other_carved).toBe(0);
+ expect(res.other_kept).toBe(1);
+ });
+
+ test("overlap resolution skips masks that are not on the current frame", async ({ page }) => {
+ // draw_annotation_from_id gates rendering by frame, so carving off-frame (invisible)
+ // masks would be a user-visible inconsistency.
+ await wait_for_ulabel_init(page, "/bitmask-e2e.html");
+
+ const res = await page.evaluate(async () => {
+ const u = window.ulabel;
+ const { make, rebuild, pix } = window.__mask_helpers(u);
+ await u.set_annotations([make("active", 1, 20, 20, 40, 40)], "a");
+ await u.set_annotations([make("other", 2, 10, 10, 30, 30)], "b");
+ rebuild("a", "active");
+ rebuild("b", "other");
+ // Move the other mask to a different frame than the stroke.
+ u.subtasks["b"].annotations.access["other"].frame = 3;
+ u.state.current_frame = 0;
+
+ u.set_subtask("a");
+ u.subtasks["a"].state.active_id = "active";
+ const delta = u.get_bitmask(u.subtasks["a"].annotations.access["active"]);
+ const edits = u.resolve_bitmask_overlap("active", delta, "overwrite");
+
+ return {
+ edits_count: edits.length,
+ other_kept: pix("b", "other", 25, 25), // off-frame mask is untouched
+ };
+ });
+
+ expect(res.edits_count).toBe(0);
+ expect(res.other_kept).toBe(1);
+ });
+
+ test("overwrite treats read-only subtasks as barriers: they are not mutated, and the active mask is clipped around them", async ({ page }) => {
+ await wait_for_ulabel_init(page, "/bitmask-e2e.html");
+
+ const res = await page.evaluate(async () => {
+ const u = window.ulabel;
+ const { make, rebuild, pix } = window.__mask_helpers(u);
+ await u.set_annotations([make("active", 1, 20, 20, 40, 40)], "a");
+ await u.set_annotations([make("ro", 2, 10, 10, 30, 30)], "b");
+ rebuild("a", "active");
+ rebuild("b", "ro");
+ u.subtasks["b"].read_only = true;
+
+ u.set_subtask("a");
+ u.subtasks["a"].state.active_id = "active";
+ const delta = u.get_bitmask(u.subtasks["a"].annotations.access["active"]);
+ const edits = u.resolve_bitmask_overlap("active", delta, "overwrite");
+
+ return {
+ edits_count: edits.length, // no other_edits: read-only was not mutated
+ ro_kept_in_overlap: pix("b", "ro", 25, 25), // barrier: read-only mask untouched
+ ro_kept_outside: pix("b", "ro", 12, 12), // outside overlap: untouched
+ active_clipped_in_overlap: pix("a", "active", 25, 25), // clipped where it hit the barrier
+ active_kept_outside: pix("a", "active", 35, 35), // outside overlap: kept
+ };
+ });
+
+ expect(res.edits_count).toBe(0);
+ expect(res.ro_kept_in_overlap).toBe(1);
+ expect(res.ro_kept_outside).toBe(1);
+ expect(res.active_clipped_in_overlap).toBe(0);
+ expect(res.active_kept_outside).toBe(1);
+ });
+});
diff --git a/tests/e2e/confidence-slider.spec.js b/tests/e2e/confidence-slider.spec.js
index 0a67de91..52ca8289 100644
--- a/tests/e2e/confidence-slider.spec.js
+++ b/tests/e2e/confidence-slider.spec.js
@@ -35,7 +35,7 @@ test.describe("ConfidenceSlider", () => {
});
// Inject two bboxes with known confidences
- await page.evaluate(({ stk, cid }) => {
+ await page.evaluate(async ({ stk, cid }) => {
const annotations = [
{
id: "conf-test-low",
@@ -50,7 +50,7 @@ test.describe("ConfidenceSlider", () => {
classification_payloads: [{ class_id: cid, confidence: 0.9 }],
},
];
- window.ulabel.set_annotations(annotations, stk);
+ await window.ulabel.set_annotations(annotations, stk);
}, { stk: subtask_key, cid: class_id });
// Raise the single "all" slider to 50%
diff --git a/tests/mask_utils.test.js b/tests/mask_utils.test.js
index ae32d913..d1f4374a 100644
--- a/tests/mask_utils.test.js
+++ b/tests/mask_utils.test.js
@@ -321,4 +321,165 @@ describe("ULabelMask", () => {
expect(() => ULabelMask.from_rle({ counts: [1, 99], size: [2, 2] })).toThrow();
});
});
+
+ describe("box-limited boolean operations", () => {
+ // Build a mask and set the given [x, y] pixels.
+ const make = (w, h, pts) => {
+ const m = ULabelMask.create_empty(w, h);
+ for (const [x, y] of pts) m.set_pixel(x, y, 1);
+ return m;
+ };
+ const full_box = (m) => ({ tlx: 0, tly: 0, brx: m.width - 1, bry: m.height - 1 });
+
+ test("intersects_in_box only counts shared pixels inside the box", () => {
+ const a = make(8, 8, [[1, 1], [5, 5]]);
+ const b = make(8, 8, [[1, 1], [5, 5]]);
+ expect(a.intersects_in_box(b, { tlx: 0, tly: 0, brx: 2, bry: 2 })).toBe(true);
+ expect(a.intersects_in_box(b, { tlx: 6, tly: 6, brx: 7, bry: 7 })).toBe(false);
+ });
+
+ test("intersects_in_box ignores shared pixels outside the box", () => {
+ const a = make(8, 8, [[5, 5]]);
+ const b = make(8, 8, [[5, 5]]);
+ expect(a.intersects_in_box(b, { tlx: 0, tly: 0, brx: 3, bry: 3 })).toBe(false);
+ expect(a.intersects_in_box(b, { tlx: 4, tly: 4, brx: 6, bry: 6 })).toBe(true);
+ });
+
+ test("intersects_in_box clamps the box to the image", () => {
+ const a = make(4, 4, [[3, 3]]);
+ const b = make(4, 4, [[3, 3]]);
+ expect(a.intersects_in_box(b, { tlx: 2, tly: 2, brx: 100, bry: 100 })).toBe(true);
+ });
+
+ test("subtract_in_box removes only pixels within the box", () => {
+ const a = make(8, 8, [[1, 1], [6, 6]]);
+ const b = make(8, 8, [[1, 1], [6, 6]]);
+ const changed = a.subtract_in_box(b, { tlx: 0, tly: 0, brx: 3, bry: 3 });
+ expect(changed).toBe(true);
+ expect(a.get_pixel(1, 1)).toBe(0); // inside box: removed
+ expect(a.get_pixel(6, 6)).toBe(1); // outside box: preserved
+ });
+
+ test("subtract_in_box returns false when the box has no shared pixels", () => {
+ const a = make(8, 8, [[1, 1]]);
+ const b = make(8, 8, [[6, 6]]);
+ expect(a.subtract_in_box(b, full_box(a))).toBe(false);
+ expect(a.subtract_in_box(b, { tlx: 5, tly: 5, brx: 7, bry: 7 })).toBe(false);
+ });
+
+ test("subtract_intersection_in_box clears pixels set in both a and b within the box", () => {
+ const target = make(8, 8, [[1, 1], [2, 2], [6, 6]]);
+ const a = make(8, 8, [[1, 1], [2, 2], [6, 6]]);
+ const b = make(8, 8, [[2, 2], [6, 6]]);
+ const changed = target.subtract_intersection_in_box(a, b, { tlx: 0, tly: 0, brx: 4, bry: 4 });
+ expect(changed).toBe(true);
+ expect(target.get_pixel(2, 2)).toBe(0); // in a AND b, inside box: cleared
+ expect(target.get_pixel(1, 1)).toBe(1); // not in b: preserved
+ expect(target.get_pixel(6, 6)).toBe(1); // in a AND b but outside box: preserved
+ });
+
+ test("subtract_in_box over the full image matches subtract (optimization equivalence)", () => {
+ const pts_a = [[1, 1], [2, 2], [3, 4], [5, 5], [6, 2]];
+ const pts_b = [[2, 2], [3, 4], [7, 7], [5, 5]];
+ const a1 = make(8, 8, pts_a);
+ const a2 = make(8, 8, pts_a);
+ const b = make(8, 8, pts_b);
+ a1.subtract(b);
+ a2.subtract_in_box(b, full_box(a2));
+ expect(Array.from(a2.data)).toEqual(Array.from(a1.data));
+ });
+
+ test("subtract_in_box over the overlap box matches subtract (box pre-filter equivalence)", () => {
+ // subtract only affects pixels set in both, which lie inside both bounding boxes;
+ // restricting to that overlap box must not change the result.
+ const pts_a = [[1, 1], [2, 2], [3, 3], [5, 6]];
+ const pts_b = [[2, 2], [3, 3], [6, 6]];
+ const a1 = make(8, 8, pts_a);
+ const a2 = make(8, 8, pts_a);
+ const b = make(8, 8, pts_b);
+ a1.subtract(b);
+ a2.subtract_in_box(b, { tlx: 2, tly: 2, brx: 6, bry: 6 });
+ expect(Array.from(a2.data)).toEqual(Array.from(a1.data));
+ });
+
+ test("subtract_intersection_in_box over the full image matches clone+intersect+subtract", () => {
+ // Old exclude path: to_remove = delta ∩ other; active.subtract(to_remove).
+ const pts_active = [[1, 1], [2, 2], [3, 3], [4, 4]];
+ const pts_delta = [[2, 2], [3, 3], [4, 4]];
+ const pts_other = [[3, 3], [4, 4], [7, 7]];
+ const expected = make(8, 8, pts_active);
+ const delta = make(8, 8, pts_delta);
+ const other = make(8, 8, pts_other);
+ const to_remove = delta.clone();
+ to_remove.intersect(other);
+ expected.subtract(to_remove);
+
+ const actual = make(8, 8, pts_active);
+ actual.subtract_intersection_in_box(delta, other, full_box(actual));
+ expect(Array.from(actual.data)).toEqual(Array.from(expected.data));
+ });
+
+ test("box-limited ops throw on dimension mismatch", () => {
+ const a = ULabelMask.create_empty(4, 4);
+ const b = ULabelMask.create_empty(5, 4);
+ const box = { tlx: 0, tly: 0, brx: 3, bry: 3 };
+ expect(() => a.intersects_in_box(b, box)).toThrow();
+ expect(() => a.subtract_in_box(b, box)).toThrow();
+ });
+ });
+
+ describe("mutation version counter", () => {
+ test("starts at 0 and is bumped by mutating methods", () => {
+ const a = ULabelMask.create_empty(8, 8);
+ expect(a.version).toBe(0);
+
+ a.set_pixel(1, 1, 1);
+ expect(a.version).toBe(1);
+
+ a.paint_circle(4, 4, 2, 1);
+ expect(a.version).toBe(2);
+
+ const b = ULabelMask.create_empty(8, 8);
+ b.set_pixel(1, 1, 1);
+ a.subtract(b);
+ expect(a.version).toBe(3);
+
+ a.add_mask(b);
+ expect(a.version).toBe(4);
+
+ a.intersect(b);
+ expect(a.version).toBe(5);
+
+ a.subtract_in_box(b, { tlx: 0, tly: 0, brx: 7, bry: 7 });
+ expect(a.version).toBe(6);
+
+ a.subtract_intersection_in_box(b, b, { tlx: 0, tly: 0, brx: 7, bry: 7 });
+ expect(a.version).toBe(7);
+ });
+
+ test("read-only ops and out-of-bounds writes do not bump the version", () => {
+ const a = ULabelMask.create_empty(8, 8);
+ a.set_pixel(2, 2, 1);
+ const v = a.version;
+
+ a.get_pixel(2, 2);
+ a.is_empty();
+ a.get_bounding_box();
+ a.to_rle();
+ a.clone();
+ const b = ULabelMask.create_empty(8, 8);
+ a.intersects(b);
+ a.set_pixel(-1, -1, 1); // out of bounds: no write
+
+ expect(a.version).toBe(v);
+ });
+
+ test("clone starts its own version at 0", () => {
+ const a = ULabelMask.create_empty(4, 4);
+ a.set_pixel(1, 1, 1);
+ a.set_pixel(2, 2, 1);
+ expect(a.version).toBe(2);
+ expect(a.clone().version).toBe(0);
+ });
+ });
});