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); + }); + }); });