From 076e0d1ce563e814f003a0df7692ec056bfd7b6e Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Tue, 4 Aug 2026 10:44:16 -0500 Subject: [PATCH 01/13] intial test of bitmask mode --- .github/tasks.md | 10 + demo.js | 1 + demo/bitmask-example.html | 75 ++++++ index.d.ts | 7 +- src/actions.ts | 10 + src/annotation.ts | 6 + src/blobs.d.ts | 1 + src/blobs.js | 17 ++ src/configuration.ts | 3 + src/html_builder.ts | 2 + src/index.js | 465 ++++++++++++++++++++++++++++++++++++++ src/mask_utils.ts | 189 ++++++++++++++++ src/toolbox.ts | 30 ++- tests/mask_utils.test.js | 134 +++++++++++ 14 files changed, 941 insertions(+), 9 deletions(-) create mode 100644 demo/bitmask-example.html create mode 100644 src/mask_utils.ts create mode 100644 tests/mask_utils.test.js diff --git a/.github/tasks.md b/.github/tasks.md index d51ecef5..162d72e3 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -1,2 +1,12 @@ ## Tasks +### Bitmask segmentation annotation mode +Per-annotation binary masks, COCO-style RLE serialization, brush + erase interaction. + +- [x] Phase 1: Data model + RLE utils + tests (`src/mask_utils.ts`, `ULabelSpatialType`, `SPATIAL_TYPE_SET`) +- [x] Phase 2: Bitmask rendering layer (`draw_bitmask`, dispatch, redraw/clear) +- [x] Phase 3: Brush/erase paints pixels (begin/continue/finish for bitmask) +- [x] Phase 4: Undo/redo patch diffs for brush strokes (single-stroke `bitmask_stroke` action) +- [x] Phase 5: Toolbox + mode registration (mode button, brush enable/disable, keybinds) +- [ ] Phase 6: Export/import round-trip + tests + demo page + diff --git a/demo.js b/demo.js index c4e5dc0a..d57087a0 100644 --- a/demo.js +++ b/demo.js @@ -13,4 +13,5 @@ console.log(`http://localhost:${port}/frames.html`); console.log(`http://localhost:${port}/box-roi.html`); console.log(`http://localhost:${port}/resume-from.html`); console.log(`http://localhost:${port}/row-filtering-example.html`); +console.log(`http://localhost:${port}/bitmask-example.html`); console.log(`http://localhost:${port}/live_demo.html`); \ No newline at end of file diff --git a/demo/bitmask-example.html b/demo/bitmask-example.html new file mode 100644 index 00000000..d8e8229e --- /dev/null +++ b/demo/bitmask-example.html @@ -0,0 +1,75 @@ + + + + ULabel - Bitmask Segmentation + + + + + + + + + + + +
+ + diff --git a/index.d.ts b/index.d.ts index bcddb4bf..5086e9f8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -182,7 +182,7 @@ export type ULabelSubmitHandler = (submitData: ULabelSubmitData) => void; /** * @link https://github.com/SenteraLLC/ulabel/blob/main/api_spec.md#subtasks */ -export type ULabelSpatialType = "contour" | "polygon" | "polyline" | "bbox" | "tbar" | "bbox3" | "whole-image" | "global" | "point"; +export type ULabelSpatialType = "contour" | "polygon" | "polyline" | "bbox" | "tbar" | "bbox3" | "whole-image" | "global" | "point" | "bitmask"; // A 2D spatial payload is a list of 2D points export type ULabelSpatialPayload = [number, number][]; @@ -243,6 +243,7 @@ export type ULabelActionType = "create_nonspatial_annotation" | "simplify_polygon_complex_layer" | "begin_brush" | "continue_brush" | + "bitmask_stroke" | "finish_modify_annotation" | "assign_annotation_id"; @@ -416,6 +417,8 @@ export class ULabel { // TODO (joshua-dean): should these actually be optional? public toggle_erase_mode(mouse_event?: JQuery.TriggeredEvent): void; public toggle_brush_mode(mouse_event?: JQuery.TriggeredEvent): void; + public enable_bitmask_brush(): void; + public disable_bitmask_brush(): void; public toggle_delete_class_id_in_toolbox(): void; public change_brush_size(scale_factor: number): void; public recolor_brush_circle(): void; @@ -504,11 +507,13 @@ export class ULabel { public simplify_polygon_complex_layer__undo(annotation_id: string, undo_payload: object): void; public delete_annotations_in_polygon__undo(undo_payload: object): void; public begin_brush__undo(annotation_id: string, undo_payload: object): void; + public bitmask_stroke__undo(annotation_id: string, undo_payload: object): void; public finish_modify_annotation__undo(annotation_id: string, undo_payload: object): void; // Redo public redo(): void; public finish_annotation__redo(annotation_id: string): void; + public bitmask_stroke__redo(annotation_id: string, redo_payload: object): void; public begin_edit__redo(annotation_id: string, redo_payload: object): void; public begin_move__redo(annotation_id: string, redo_payload: object): void; public delete_annotation__redo(annotation_id: string): void; diff --git a/src/actions.ts b/src/actions.ts index 0ff3094a..ccb947fa 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -279,6 +279,10 @@ function trigger_action_listeners( begin_brush: { undo: on_annotation_revert, }, + bitmask_stroke: { + // Undo/redo handling and re-rendering are managed by the + // bitmask_stroke__undo / bitmask_stroke__redo methods directly. + }, delete_annotations_in_polygon: { // No listener for this action. // It handles the re-rendering of the affected annotations itself. @@ -613,6 +617,9 @@ function undo_action(ulabel: ULabel, action: ULabelAction) { case "begin_brush": ulabel.begin_brush__undo(annotation_id, undo_payload); break; + case "bitmask_stroke": + ulabel.bitmask_stroke__undo(annotation_id, undo_payload); + break; case "finish_modify_annotation": ulabel.finish_modify_annotation__undo(annotation_id, undo_payload); break; @@ -683,6 +690,9 @@ export function redo_action(ulabel: ULabel, action: ULabelAction) { case "finish_modify_annotation": ulabel.finish_modify_annotation__redo(annotation_id, redo_payload); break; + case "bitmask_stroke": + ulabel.bitmask_stroke__redo(annotation_id, redo_payload); + break; default: log_message(`Action type not recognized for redo: ${action.act_type}`, LogLevel.WARNING); break; diff --git a/src/annotation.ts b/src/annotation.ts index ea291a5e..d69425ac 100644 --- a/src/annotation.ts +++ b/src/annotation.ts @@ -26,6 +26,7 @@ const SPATIAL_TYPE_SET: Record = { "whole-image": true, "global": true, "point": true, + "bitmask": true, }; // Every ULabelSpatialType (spatial and non-spatial modes) as a runtime array. @@ -199,6 +200,11 @@ export class ULabelAnnotation { */ public clamp_annotation_to_image_bounds(image_width: number, image_height: number): ULabelAnnotation { if (!this.is_delete_annotation()) { + // Bitmask annotations store a raster payload (not point arrays) that is + // inherently within image bounds, so there is nothing to clamp. + if (this.spatial_type === "bitmask") { + return this; + } // Ensure each point in the payload is within the image // for polygons, we'll need to loop through all points let active_spatial_payload = this.spatial_payload; diff --git a/src/blobs.d.ts b/src/blobs.d.ts index 457f2a73..9b3aee7a 100644 --- a/src/blobs.d.ts +++ b/src/blobs.d.ts @@ -9,6 +9,7 @@ export const TBAR_SVG: string; export const POLYLINE_SVG: string; export const WHOLE_IMAGE_SVG: string; export const GLOBAL_SVG: string; +export const BITMASK_SVG: string; export const DEMO_ANNOTATION: object; export function get_init_style(ulabel_id: string): string; export const COLORS: string[]; diff --git a/src/blobs.js b/src/blobs.js index 9a24d7e9..0c1943c5 100644 --- a/src/blobs.js +++ b/src/blobs.js @@ -2228,8 +2228,25 @@ const COLORS = [ "fuchsia", ]; +// Simple icon representing a raster/bitmask segmentation mode (a filled pixel grid) +const BITMASK_SVG = ` + + + + + + + + + + + + +`; + export { BBOX_SVG, DELETE_BBOX_SVG, BBOX3_SVG, POINT_SVG, POLYGON_SVG, DELETE_POLYGON_SVG, CONTOUR_SVG, TBAR_SVG, POLYLINE_SVG, WHOLE_IMAGE_SVG, GLOBAL_SVG, + BITMASK_SVG, DEMO_ANNOTATION, get_init_style, COLORS, BUTTON_LOADER_HTML, diff --git a/src/configuration.ts b/src/configuration.ts index ce10a91b..9c276f1c 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -134,6 +134,9 @@ export class Configuration { public edit_handle_size: number = 30; public brush_size: number = 60; + // Fill opacity (0-1) used when rendering bitmask (raster segmentation) annotations + public mask_annotation_opacity: number = 0.45; + // Configuration for the annotation task itself public image_data: ImageData | null = null; public allow_soft_id: boolean = false; diff --git a/src/html_builder.ts b/src/html_builder.ts index 29820b9d..ab093574 100644 --- a/src/html_builder.ts +++ b/src/html_builder.ts @@ -15,6 +15,7 @@ import { POLYLINE_SVG, WHOLE_IMAGE_SVG, GLOBAL_SVG, + BITMASK_SVG, get_init_style, } from "../src/blobs"; import { ULabelLoader } from "./loader"; @@ -173,6 +174,7 @@ export function prep_window_html(ulabel: ULabel, toolbox_item_order: unknown[] | get_md_button("bbox", "Bounding Box", BBOX_SVG, curmd, ulabel.subtasks), get_md_button("point", "Point", POINT_SVG, curmd, ulabel.subtasks), get_md_button("polygon", "Polygon", POLYGON_SVG, curmd, ulabel.subtasks), + get_md_button("bitmask", "Bitmask", BITMASK_SVG, curmd, ulabel.subtasks), get_md_button("tbar", "T-Bar", TBAR_SVG, curmd, ulabel.subtasks), get_md_button("polyline", "Polyline", POLYLINE_SVG, curmd, ulabel.subtasks), get_md_button("contour", "Contour", CONTOUR_SVG, curmd, ulabel.subtasks), diff --git a/src/index.js b/src/index.js index fb3a4f5e..89b9e68b 100644 --- a/src/index.js +++ b/src/index.js @@ -30,6 +30,7 @@ import { remove_ulabel_listeners } from "../build/listeners"; import { log_message, LogLevel } from "../build/error_logging"; import { initialize_annotation_canvases } from "../build/canvas_utils"; import { record_action, record_finish, record_finish_edit, record_finish_move, undo, redo } from "../build/actions"; +import { ULabelMask } from "../build/mask_utils"; import $ from "jquery"; const jQuery = $; @@ -675,6 +676,12 @@ export class ULabel { for (const toolbox_item of this.toolbox.items) { toolbox_item.after_init(); } + + // If bitmask is the initial mode, enable its brush right away + if (this.get_current_subtask()["state"]["annotation_mode"] === "bitmask") { + BrushToolboxItem.show_brush_toolbox_item(); + this.enable_bitmask_brush(); + } } version() { @@ -1732,6 +1739,123 @@ export class ULabel { } } + /** + * Get (and lazily decode/cache) the runtime ULabelMask for a bitmask annotation. + * The decoded mask is stored as a non-enumerable property so it is not included + * when annotations are serialized (e.g. via `get_annotations`). + * + * @param {object} annotation_object bitmask annotation + * @returns {ULabelMask} decoded mask + */ + get_bitmask(annotation_object) { + if (annotation_object["_mask"] == null) { + let mask; + const payload = annotation_object["spatial_payload"]; + if (payload != null && payload["counts"] !== undefined) { + mask = ULabelMask.from_rle(payload); + } else { + mask = ULabelMask.create_empty(this.config["image_width"], this.config["image_height"]); + } + this.set_bitmask(annotation_object, mask); + } + return annotation_object["_mask"]; + } + + // Attach a decoded mask to an annotation as a non-enumerable property so it is + // excluded from serialization. + set_bitmask(annotation_object, mask) { + Object.defineProperty(annotation_object, "_mask", { + value: mask, + enumerable: false, + writable: true, + configurable: true, + }); + } + + // Replace an annotation's cached mask from an RLE payload (or empty if null). + set_bitmask_from_rle(annotation_object, rle) { + let mask; + if (rle != null && rle["counts"] !== undefined) { + mask = ULabelMask.from_rle(rle); + } else { + mask = ULabelMask.create_empty(this.config["image_width"], this.config["image_height"]); + } + this.set_bitmask(annotation_object, mask); + return mask; + } + + // Recompute a bitmask annotation's containing box from its mask. + rebuild_bitmask_containing_box(annotation_object) { + const bbox = this.get_bitmask(annotation_object).get_bounding_box(); + if (bbox === null) { + annotation_object["containing_box"] = null; + } else { + annotation_object["containing_box"] = { tlx: bbox.tlx, tly: bbox.tly, brx: bbox.brx, bry: bbox.bry }; + } + } + + // Parse a "#rrggbb" hex color into [r, g, b]. Falls back to the default color on failure. + hex_to_rgb(color_hex) { + if (typeof color_hex === "string" && color_hex[0] === "#" && color_hex.length >= 7) { + const r = parseInt(color_hex.slice(1, 3), 16); + const g = parseInt(color_hex.slice(3, 5), 16); + const b = parseInt(color_hex.slice(5, 7), 16); + if (!isNaN(r) && !isNaN(g) && !isNaN(b)) { + return [r, g, b]; + } + } + return [250, 157, 42]; + } + + draw_bitmask(annotation_object, ctx, offset = null) { + const px_per_px = this.config["px_per_px"]; + const image_width = this.config["image_width"]; + const image_height = this.config["image_height"]; + + const mask = this.get_bitmask(annotation_object); + if (mask === null || image_width == null || image_height == null) return; + + // Build an ImageData at native image resolution from the mask, tinted by class color + const [r, g, b] = this.hex_to_rgb(this.get_annotation_color(annotation_object)); + const alpha = Math.round(255 * this.config["mask_annotation_opacity"]); + + const offscreen = document.createElement("canvas"); + offscreen.width = image_width; + offscreen.height = image_height; + const offscreen_ctx = offscreen.getContext("2d"); + const image_data = offscreen_ctx.createImageData(image_width, image_height); + const data = image_data.data; + const mask_data = mask.data; + for (let i = 0; i < mask_data.length; i++) { + if (mask_data[i] !== 0) { + const j = i * 4; + data[j] = r; + data[j + 1] = g; + data[j + 2] = b; + data[j + 3] = alpha; + } + } + offscreen_ctx.putImageData(image_data, 0, 0); + + // Draw the native-resolution mask scaled onto the target context, 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 = 1.0; + ctx.drawImage( + offscreen, + diffX * px_per_px, + diffY * px_per_px, + image_width * px_per_px, + image_height * px_per_px, + ); + } + draw_contour(annotation_object, ctx, offset = null) { const px_per_px = this.config["px_per_px"]; let diffX = 0; @@ -1902,6 +2026,9 @@ export class ULabel { case "tbar": this.draw_tbar(annotation_object, context, offset); break; + case "bitmask": + this.draw_bitmask(annotation_object, context, offset); + break; case "whole-image": this.draw_whole_image_annotation(annotation_object, subtask); break; @@ -2223,6 +2350,16 @@ export class ULabel { toggle_brush_mode(mouse_event) { // Try and switch to polygon annotation if not already in it const current_subtask = this.get_current_subtask_key(); + // In bitmask mode the brush is always active; "brush" selects paint (non-erase) + if (this.subtasks[current_subtask]["state"]["annotation_mode"] === "bitmask") { + const state = this.subtasks[current_subtask]["state"]; + state["is_in_brush_mode"] = true; + state["is_in_erase_mode"] = false; + $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + this.recolor_brush_circle(); + return; + } let is_in_polygon_mode = this.subtasks[current_subtask]["state"]["annotation_mode"] === "polygon"; // Try and switch to polygon mode if not already in it if (!is_in_polygon_mode) { @@ -2261,6 +2398,22 @@ export class ULabel { toggle_erase_mode(mouse_event) { const current_subtask = this.get_current_subtask(); + // In bitmask mode the brush is always active; only the paint/erase toggle changes + if (current_subtask["state"]["annotation_mode"] === "bitmask") { + current_subtask["state"]["is_in_brush_mode"] = true; + current_subtask["state"]["is_in_erase_mode"] = !current_subtask["state"]["is_in_erase_mode"]; + if (current_subtask["state"]["is_in_erase_mode"]) { + $("#erase-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + } else { + $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + } + $("#brush_circle").css({ + "background-color": current_subtask["state"]["is_in_erase_mode"] ? "red" : this.get_active_class_color(), + }); + return; + } // If not in brush mode, turn it on if (!current_subtask["state"]["is_in_brush_mode"]) { this.toggle_brush_mode(mouse_event); @@ -2294,6 +2447,27 @@ export class ULabel { } } + // Enable the brush for bitmask mode (painting is the only interaction) + enable_bitmask_brush() { + const state = this.get_current_subtask()["state"]; + state["is_in_brush_mode"] = true; + state["is_in_erase_mode"] = false; + $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + // Create the brush circle; it will follow the cursor on the next mouse move + this.create_brush_circle(0, 0); + } + + // Disable the brush when leaving bitmask mode + disable_bitmask_brush() { + const state = this.get_current_subtask()["state"]; + state["is_in_brush_mode"] = false; + state["is_in_erase_mode"] = false; + $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + this.destroy_brush_circle(); + } + // Create a brush circle at the mouse location create_brush_circle(gmx, gmy) { // Create brush circle id @@ -3711,6 +3885,12 @@ export class ULabel { return; } + // Bitmask annotations derive their containing box from the mask's bounding box. + if (spatial_type === "bitmask") { + this.rebuild_bitmask_containing_box(this.subtasks[subtask]["annotations"]["access"][actid]); + return; + } + let spatial_payload = []; if (spatial_type === "polygon") { // Collapse the list[list[points]] into a single list of points @@ -4031,6 +4211,11 @@ export class ULabel { // Start annotating or erasing with the brush begin_brush(mouse_event) { const current_subtask = this.get_current_subtask(); + // Raster bitmask mode uses its own brush painting pipeline + if (current_subtask["state"]["annotation_mode"] === "bitmask") { + this.begin_bitmask(mouse_event); + return; + } // First, we check if there is an annotation touching the brush let brush_cand_active_id = null; const global_x = this.get_global_mouse_x(mouse_event); @@ -4109,6 +4294,11 @@ export class ULabel { } continue_brush(mouse_event) { + // Raster bitmask mode uses its own brush painting pipeline + if (this.get_current_subtask()["state"]["annotation_mode"] === "bitmask") { + this.continue_bitmask(mouse_event); + return; + } // Get global mouse position const gmx = this.get_global_mouse_x(mouse_event); const gmy = this.get_global_mouse_y(mouse_event); @@ -4198,6 +4388,274 @@ export class ULabel { } } + // ================= Bitmask (raster segmentation) brush ================= + + // Create a new, empty bitmask annotation and return its id. + create_bitmask_annotation() { + const subtask_key = this.get_current_subtask_key(); + const current_subtask = this.subtasks[subtask_key]; + const annotation_id = this.make_new_annotation_id(); + const canvas_id = this.get_init_canvas_context_id(annotation_id, subtask_key); + const init_id_payload = this.get_init_id_payload("bitmask"); + + current_subtask["annotations"]["access"][annotation_id] = { + id: annotation_id, + created_by: this.config.username, + created_at: ULabel.get_time(), + last_edited_at: ULabel.get_time(), + last_edited_by: this.config.username, + deprecated: false, + deprecated_by: { human: false }, + spatial_type: "bitmask", + spatial_payload: null, + classification_payloads: init_id_payload, + containing_box: null, + frame: this.state["current_frame"], + canvas_id: canvas_id, + text_payload: "", + annotation_meta: this.config["annotation_meta"], + }; + current_subtask["annotations"]["ordering"].push(annotation_id); + + // Attach a fresh, empty mask + this.get_bitmask(current_subtask["annotations"]["access"][annotation_id]); + return annotation_id; + } + + // Find the topmost undeprecated bitmask annotation with foreground under the brush. + find_bitmask_under_brush(imx, imy, radius) { + const current_subtask = this.get_current_subtask(); + const ordering = current_subtask["annotations"]["ordering"]; + const access = current_subtask["annotations"]["access"]; + for (let i = ordering.length - 1; i >= 0; i--) { + const annotation = access[ordering[i]]; + if (annotation["deprecated"] || annotation["spatial_type"] !== "bitmask") continue; + if (this.get_bitmask(annotation).has_foreground_in_circle(imx, imy, radius)) { + return ordering[i]; + } + } + return null; + } + + // Paint (or erase) a stroke as a series of interpolated circles between two image points. + paint_bitmask_line(mask, x0, y0, x1, y1, radius, value) { + const dx = x1 - x0; + const dy = y1 - y0; + const dist = Math.sqrt(dx * dx + dy * dy); + const step = Math.max(1, radius / 2); + const steps = Math.max(1, Math.ceil(dist / step)); + let changed = false; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + if (mask.paint_circle(x0 + dx * t, y0 + dy * t, radius, value)) { + changed = true; + } + } + return changed; + } + + begin_bitmask(mouse_event) { + const current_subtask = this.get_current_subtask(); + const annotations = current_subtask["annotations"]["access"]; + const gmx = this.get_global_mouse_x(mouse_event); + const gmy = this.get_global_mouse_y(mouse_event); + const imx = gmx / this.config["px_per_px"]; + const imy = gmy / this.config["px_per_px"]; + const radius = this.config["brush_size"] / 2; + const is_erase = current_subtask["state"]["is_in_erase_mode"]; + const in_bounds = GeometricUtils.point_is_within_image_bounds( + [imx, imy], + this.config["image_width"], + this.config["image_height"], + ); + + let target_id = this.find_bitmask_under_brush(imx, imy, radius); + let was_new = false; + + if (is_erase) { + // Nothing to erase under the brush + if (target_id === null) { + this.move_brush_circle(gmx, gmy); + return; + } + } else { + // Don't start a new annotation fully outside the image + if (!in_bounds) { + this.shake_screen(); + this.move_brush_circle(gmx, gmy); + return; + } + // Only continue an existing annotation if it matches the active class + if (target_id !== null && get_annotation_class_id(annotations[target_id]) !== this.get_active_class_id()) { + target_id = null; + } + if (target_id === null) { + target_id = this.create_bitmask_annotation(); + was_new = true; + } + } + + current_subtask["state"]["active_id"] = target_id; + current_subtask["state"]["is_in_progress"] = true; + current_subtask["state"]["id_payload"] = JSON.parse(JSON.stringify(annotations[target_id]["classification_payloads"])); + this.update_id_toolbox_display(); + this.recolor_brush_circle(); + + // Snapshot state for a single-stroke undo + current_subtask["state"]["bitmask_stroke"] = { + annotation_id: target_id, + was_new: was_new, + before_rle: was_new ? null : this.get_bitmask(annotations[target_id]).to_rle(), + last_point: null, + }; + + this.state["last_brush_stroke"] = null; + this.continue_bitmask(mouse_event); + } + + continue_bitmask(mouse_event) { + const gmx = this.get_global_mouse_x(mouse_event); + const gmy = this.get_global_mouse_y(mouse_event); + this.move_brush_circle(gmx, gmy); + + // Throttle dabs based on distance moved + const min_brush_distance = this.config["brush_size"] / 8; + if (this.state["last_brush_stroke"] !== null) { + const [last_gmx, last_gmy] = this.state["last_brush_stroke"]; + if (Math.abs(gmx - last_gmx) < min_brush_distance && Math.abs(gmy - last_gmy) < min_brush_distance) { + return; + } + } + this.state["last_brush_stroke"] = [gmx, gmy]; + + const current_subtask = this.get_current_subtask(); + const active_id = current_subtask["state"]["active_id"]; + const stroke = current_subtask["state"]["bitmask_stroke"]; + if (active_id === null || stroke == null) return; + + const annotation = current_subtask["annotations"]["access"][active_id]; + const mask = this.get_bitmask(annotation); + const imx = gmx / this.config["px_per_px"]; + const imy = gmy / this.config["px_per_px"]; + const radius = this.config["brush_size"] / 2; + const value = current_subtask["state"]["is_in_erase_mode"] ? 0 : 1; + + let changed; + if (stroke.last_point !== null) { + changed = this.paint_bitmask_line(mask, stroke.last_point[0], stroke.last_point[1], imx, imy, radius, value); + } else { + changed = mask.paint_circle(imx, imy, radius, value); + } + stroke.last_point = [imx, imy]; + + if (changed) { + this.redraw_annotation(active_id); + } + } + + finish_bitmask() { + const current_subtask = this.get_current_subtask(); + const annotations = current_subtask["annotations"]["access"]; + const active_id = current_subtask["state"]["active_id"]; + const stroke = current_subtask["state"]["bitmask_stroke"]; + + this.state["last_brush_stroke"] = null; + current_subtask["state"]["is_in_progress"] = false; + current_subtask["state"]["active_id"] = null; + current_subtask["state"]["bitmask_stroke"] = null; + + if (active_id == null || stroke == null) return; + + const annotation = annotations[active_id]; + const mask = this.get_bitmask(annotation); + const after_empty = mask.is_empty(); + + // Encode the mask to an RLE payload and update the containing box + annotation["spatial_payload"] = mask.to_rle(); + this.rebuild_bitmask_containing_box(annotation); + + // If the stroke erased the whole mask, deprecate the annotation (ULabel's delete semantics) + if (after_empty) { + mark_deprecated(annotation, true); + } + + if (current_subtask["single_class_mode"]) { + annotation["classification_payloads"] = [ + { + class_id: current_subtask["class_defs"][0]["id"], + confidence: 1.0, + }, + ]; + } + + // Record the whole stroke as a single undoable action. Both payloads carry the + // full before/after state so undo and redo can each reconstruct it. + const stroke_payload = { + before_rle: stroke.before_rle, + after_rle: annotation["spatial_payload"], + was_new: stroke.was_new, + after_empty: after_empty, + }; + record_action(this, { + act_type: "bitmask_stroke", + annotation_id: active_id, + frame: this.state["current_frame"], + undo_payload: stroke_payload, + redo_payload: stroke_payload, + }); + + this.redraw_annotation(active_id); + this.suggest_edits(null, null, true); + this.toolbox.redraw_update_items(this); + } + + bitmask_stroke__undo(annotation_id, undo_payload) { + const annotations = this.get_current_subtask()["annotations"]["access"]; + const annotation = annotations[annotation_id]; + if (annotation === undefined) return; + + if (undo_payload.was_new) { + // Undo creation of a brand-new annotation + this.set_bitmask_from_rle(annotation, null); + annotation["spatial_payload"] = null; + annotation["containing_box"] = null; + mark_deprecated(annotation, true); + } else { + this.set_bitmask_from_rle(annotation, undo_payload.before_rle); + annotation["spatial_payload"] = undo_payload.before_rle; + mark_deprecated(annotation, false); + this.rebuild_bitmask_containing_box(annotation); + } + + this.redraw_annotation(annotation_id); + this.suggest_edits(null, null, true); + this.toolbox.redraw_update_items(this); + } + + bitmask_stroke__redo(annotation_id, redo_payload) { + const annotations = this.get_current_subtask()["annotations"]["access"]; + const annotation = annotations[annotation_id]; + if (annotation === undefined) return; + + this.set_bitmask_from_rle(annotation, redo_payload.after_rle); + annotation["spatial_payload"] = redo_payload.after_rle; + mark_deprecated(annotation, redo_payload.after_empty === true); + this.rebuild_bitmask_containing_box(annotation); + + this.redraw_annotation(annotation_id); + this.suggest_edits(null, null, true); + this.toolbox.redraw_update_items(this); + + // Re-record so the stroke can be undone again + record_action(this, { + act_type: "bitmask_stroke", + annotation_id: annotation_id, + frame: this.state["current_frame"], + undo_payload: redo_payload, + redo_payload: redo_payload, + }, true); + } + /** * Undo an annotation modification, for example a brush stroke * @@ -4491,6 +4949,13 @@ export class ULabel { // Initialize required variables let active_id = current_subtask["state"]["active_id"]; let annotation = annotations[active_id]; + + // Raster bitmask strokes are finalized by their own pipeline + if (annotation != null && annotation["spatial_type"] === "bitmask") { + this.finish_bitmask(); + return; + } + let spatial_payload = annotation["spatial_payload"]; let active_spatial_payload = spatial_payload; let should_record_action = false; diff --git a/src/mask_utils.ts b/src/mask_utils.ts new file mode 100644 index 00000000..02a11dab --- /dev/null +++ b/src/mask_utils.ts @@ -0,0 +1,189 @@ +// Utilities for raster "bitmask" segmentation annotations. +// +// A bitmask annotation stores a per-pixel binary occupancy grid the size of the +// image. At runtime the grid is held as a row-major Uint8Array (values 0 or 1). +// For serialization it is encoded as COCO-style, column-major run-length counts. + +// COCO-style run-length encoding of a binary mask. +// - counts: alternating run lengths (in column-major / Fortran order) that always +// start with a background (0) run. A leading foreground pixel is represented by +// a leading count of 0. +// - size: [height, width], matching COCO's convention. +export type ULabelMaskPayload = { + counts: number[]; + size: [number, 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); + if (rounded < min) return min; + if (rounded > max) return max; + return rounded; +} + +export class ULabelMask { + public data: Uint8Array; + public readonly width: number; + public readonly height: number; + + constructor(width: number, height: number, data?: Uint8Array) { + this.width = width; + this.height = height; + if (data !== undefined) { + if (data.length !== width * height) { + throw new Error( + `Mask data length ${data.length} does not match dimensions ${width}x${height}`, + ); + } + this.data = data; + } else { + this.data = new Uint8Array(width * height); + } + } + + // Create an empty (all-background) mask. + public static create_empty(width: number, height: number): ULabelMask { + return new ULabelMask(width, height); + } + + public get_pixel(x: number, y: number): number { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) { + return 0; + } + return this.data[y * this.width + x]; + } + + public set_pixel(x: number, y: number, value: number): void { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) { + return; + } + 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 { + const v = value ? 1 : 0; + const r = Math.max(0, radius); + const min_x = clamp_int(cx - r, 0, this.width - 1); + const max_x = clamp_int(cx + r, 0, this.width - 1); + const min_y = clamp_int(cy - r, 0, this.height - 1); + const max_y = clamp_int(cy + r, 0, this.height - 1); + const r_sq = r * r; + let changed = false; + for (let y = min_y; y <= max_y; y++) { + const dy = y - cy; + for (let x = min_x; x <= max_x; x++) { + const dx = x - cx; + if (dx * dx + dy * dy <= r_sq) { + const idx = y * this.width + x; + if (this.data[idx] !== v) { + this.data[idx] = v; + changed = true; + } + } + } + } + return changed; + } + + // True if the mask contains no foreground pixels. + public is_empty(): boolean { + for (let i = 0; i < this.data.length; i++) { + if (this.data[i] !== 0) return false; + } + return true; + } + + // True if any foreground pixel lies within the given circle. + public has_foreground_in_circle(cx: number, cy: number, radius: number): boolean { + const r = Math.max(0, radius); + const min_x = clamp_int(cx - r, 0, this.width - 1); + const max_x = clamp_int(cx + r, 0, this.width - 1); + const min_y = clamp_int(cy - r, 0, this.height - 1); + const max_y = clamp_int(cy + r, 0, this.height - 1); + const r_sq = r * r; + for (let y = min_y; y <= max_y; y++) { + const dy = y - cy; + for (let x = min_x; x <= max_x; x++) { + const dx = x - cx; + if (dx * dx + dy * dy <= r_sq && this.data[y * this.width + x] !== 0) { + return true; + } + } + } + return false; + } + + // 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 { + let min_x = this.width; + let min_y = this.height; + let max_x = -1; + let max_y = -1; + for (let y = 0; y < this.height; y++) { + const row = y * this.width; + for (let x = 0; x < this.width; x++) { + if (this.data[row + x] !== 0) { + if (x < min_x) min_x = x; + if (x > max_x) max_x = x; + if (y < min_y) min_y = y; + if (y > max_y) max_y = y; + } + } + } + if (max_x < 0) { + return null; + } + return { tlx: min_x, tly: min_y, brx: max_x, bry: max_y }; + } + + // Encode to COCO-style, column-major run-length counts. + public to_rle(): ULabelMaskPayload { + const counts: number[] = []; + let current = 0; // runs always start with background + let run = 0; + for (let x = 0; x < this.width; x++) { + for (let y = 0; y < this.height; y++) { + const value = this.data[y * this.width + x]; + if (value === current) { + run++; + } else { + counts.push(run); + current = value; + run = 1; + } + } + } + counts.push(run); + return { + counts: counts, + size: [this.height, this.width], + }; + } + + // Decode a COCO-style RLE payload into a mask. + public static from_rle(payload: ULabelMaskPayload): ULabelMask { + const [height, width] = payload.size; + const mask = new ULabelMask(width, height); + let idx = 0; // column-major index + let value = 0; + const total = width * height; + for (let c = 0; c < payload.counts.length; c++) { + const run = payload.counts[c]; + if (value === 1) { + for (let k = 0; k < run && idx < total; k++) { + const col_idx = idx + k; + const x = Math.floor(col_idx / height); + const y = col_idx % height; + mask.data[y * width + x] = 1; + } + } + idx += run; + value = value === 0 ? 1 : 0; + } + return mask; + } +} diff --git a/src/toolbox.ts b/src/toolbox.ts index 54ed3b8a..fc9d87d2 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -419,21 +419,35 @@ export class ModeSelectionToolboxItem extends ToolboxItem { if (target_jq.hasClass("sel") || current_subtask["state"]["is_in_progress"]) return; // Get the new mode and set it to ulabel's current mode + const prev_mode = current_subtask["state"]["annotation_mode"]; const new_mode = target_jq.attr("id")!.split("--")[1]; current_subtask["state"]["annotation_mode"] = new_mode; - // Show the BrushToolboxItem when polygon mode is selected + // Show the BrushToolboxItem when polygon or bitmask mode is selected if (new_mode === "polygon") { BrushToolboxItem.show_brush_toolbox_item(); + // Leaving bitmask requires tearing down its brush state + if (prev_mode === "bitmask") { + ulabel.disable_bitmask_brush(); + } + } else if (new_mode === "bitmask") { + BrushToolboxItem.show_brush_toolbox_item(); + // Bitmask painting always uses the brush + ulabel.enable_bitmask_brush(); } else { BrushToolboxItem.hide_brush_toolbox_item(); - // Turn off erase mode if it's on - if (current_subtask["state"]["is_in_erase_mode"]) { - ulabel.toggle_erase_mode(e); - } - // Turn off brush mode if it's on - if (current_subtask["state"]["is_in_brush_mode"]) { - ulabel.toggle_brush_mode(e); + if (prev_mode === "bitmask") { + // Tear down the bitmask brush without forcing a polygon switch + ulabel.disable_bitmask_brush(); + } else { + // Turn off erase mode if it's on + if (current_subtask["state"]["is_in_erase_mode"]) { + ulabel.toggle_erase_mode(e); + } + // Turn off brush mode if it's on + if (current_subtask["state"]["is_in_brush_mode"]) { + ulabel.toggle_brush_mode(e); + } } } diff --git a/tests/mask_utils.test.js b/tests/mask_utils.test.js new file mode 100644 index 00000000..090ca1c5 --- /dev/null +++ b/tests/mask_utils.test.js @@ -0,0 +1,134 @@ +// Tests for bitmask (raster segmentation) mask utilities +const { ULabelMask } = require("../build/mask_utils"); + +describe("ULabelMask", () => { + describe("construction", () => { + test("creates an empty mask of the right size", () => { + const mask = ULabelMask.create_empty(4, 3); + expect(mask.width).toBe(4); + expect(mask.height).toBe(3); + expect(mask.data.length).toBe(12); + expect(mask.is_empty()).toBe(true); + }); + + test("throws when provided data length mismatches dimensions", () => { + expect(() => new ULabelMask(2, 2, new Uint8Array(3))).toThrow(); + }); + }); + + describe("get/set pixel", () => { + test("sets and gets pixels", () => { + const mask = ULabelMask.create_empty(4, 4); + mask.set_pixel(1, 2, 1); + expect(mask.get_pixel(1, 2)).toBe(1); + expect(mask.get_pixel(0, 0)).toBe(0); + expect(mask.is_empty()).toBe(false); + }); + + test("ignores out-of-bounds writes and reads", () => { + const mask = ULabelMask.create_empty(2, 2); + mask.set_pixel(-1, 0, 1); + mask.set_pixel(5, 5, 1); + expect(mask.is_empty()).toBe(true); + expect(mask.get_pixel(-1, 0)).toBe(0); + expect(mask.get_pixel(10, 10)).toBe(0); + }); + }); + + describe("paint_circle", () => { + test("paints a filled circle and reports change", () => { + const mask = ULabelMask.create_empty(11, 11); + const changed = mask.paint_circle(5, 5, 2, 1); + expect(changed).toBe(true); + expect(mask.get_pixel(5, 5)).toBe(1); + expect(mask.get_pixel(5, 7)).toBe(1); + // Corner should be outside the radius-2 circle + expect(mask.get_pixel(0, 0)).toBe(0); + }); + + test("erasing clears previously painted pixels", () => { + const mask = ULabelMask.create_empty(11, 11); + mask.paint_circle(5, 5, 3, 1); + expect(mask.get_pixel(5, 5)).toBe(1); + const changed = mask.paint_circle(5, 5, 3, 0); + expect(changed).toBe(true); + expect(mask.get_pixel(5, 5)).toBe(0); + expect(mask.is_empty()).toBe(true); + }); + + test("returns false when nothing changes", () => { + const mask = ULabelMask.create_empty(11, 11); + const changed = mask.paint_circle(5, 5, 2, 0); + expect(changed).toBe(false); + }); + }); + + describe("bounding box", () => { + test("returns null for empty mask", () => { + const mask = ULabelMask.create_empty(4, 4); + expect(mask.get_bounding_box()).toBeNull(); + }); + + test("returns tight box around foreground", () => { + const mask = ULabelMask.create_empty(6, 6); + mask.set_pixel(2, 1, 1); + mask.set_pixel(4, 3, 1); + expect(mask.get_bounding_box()).toEqual({ tlx: 2, tly: 1, brx: 4, bry: 3 }); + }); + }); + + describe("RLE round-trip", () => { + test("encodes an empty mask as a single background run", () => { + const mask = ULabelMask.create_empty(3, 2); + const rle = mask.to_rle(); + expect(rle.size).toEqual([2, 3]); + expect(rle.counts).toEqual([6]); + }); + + test("round-trips a mask with foreground pixels", () => { + const mask = ULabelMask.create_empty(5, 4); + mask.paint_circle(2, 2, 2, 1); + mask.set_pixel(0, 0, 1); + const rle = mask.to_rle(); + const restored = ULabelMask.from_rle(rle); + expect(restored.width).toBe(5); + expect(restored.height).toBe(4); + expect(Array.from(restored.data)).toEqual(Array.from(mask.data)); + }); + + test("leading foreground pixel produces a leading zero count", () => { + const mask = ULabelMask.create_empty(2, 2); + // Column-major order visits (0,0) first + mask.set_pixel(0, 0, 1); + const rle = mask.to_rle(); + expect(rle.counts[0]).toBe(0); + const restored = ULabelMask.from_rle(rle); + expect(Array.from(restored.data)).toEqual(Array.from(mask.data)); + }); + + test("round-trips a fully-filled mask", () => { + const mask = ULabelMask.create_empty(3, 3); + for (let i = 0; i < mask.data.length; i++) { + mask.data[i] = 1; + } + const rle = mask.to_rle(); + const restored = ULabelMask.from_rle(rle); + expect(Array.from(restored.data)).toEqual(Array.from(mask.data)); + }); + + test("survives a JSON serialization round-trip (export/import contract)", () => { + const mask = ULabelMask.create_empty(8, 6); + mask.paint_circle(4, 3, 2, 1); + mask.set_pixel(0, 0, 1); + mask.set_pixel(7, 5, 1); + + // Emulate how a bitmask annotation is serialized: spatial_payload holds the RLE + const annotation = { spatial_type: "bitmask", spatial_payload: mask.to_rle() }; + const exported = JSON.parse(JSON.stringify(annotation)); + + expect(exported.spatial_payload.size).toEqual([6, 8]); + const restored = ULabelMask.from_rle(exported.spatial_payload); + expect(Array.from(restored.data)).toEqual(Array.from(mask.data)); + }); + }); +}); From fe18c1cb7d19d528b6a35d9caebd4ee89def6287 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Tue, 4 Aug 2026 11:31:43 -0500 Subject: [PATCH 02/13] fix bugs with class and moves --- demo/bitmask-example.html | 6 +- index.d.ts | 1 - src/actions.ts | 4 +- src/index.js | 180 ++++++++++++++++++++------------------ src/mask_utils.ts | 22 +++++ src/toolbox.ts | 3 +- tests/mask_utils.test.js | 24 +++++ 7 files changed, 145 insertions(+), 95 deletions(-) diff --git a/demo/bitmask-example.html b/demo/bitmask-example.html index d8e8229e..a8d40cb9 100644 --- a/demo/bitmask-example.html +++ b/demo/bitmask-example.html @@ -34,18 +34,16 @@ "display_name": "Segmentation", "classes": [ { - "name": "Road", + "name": "Vehicle", "color": "orange", "id": 10 }, { - "name": "Vegetation", + "name": "Obstacle", "color": "green", "id": 11 } ], - // Bitmask (raster) segmentation mode. Paint with the brush, - // hold to erase, and use [ / ] to change brush size. "allowed_modes": ["bitmask", "polygon"], "resume_from": null, "task_meta": null, diff --git a/index.d.ts b/index.d.ts index 5086e9f8..6739e372 100644 --- a/index.d.ts +++ b/index.d.ts @@ -417,7 +417,6 @@ export class ULabel { // TODO (joshua-dean): should these actually be optional? public toggle_erase_mode(mouse_event?: JQuery.TriggeredEvent): void; public toggle_brush_mode(mouse_event?: JQuery.TriggeredEvent): void; - public enable_bitmask_brush(): void; public disable_bitmask_brush(): void; public toggle_delete_class_id_in_toolbox(): void; public change_brush_size(scale_factor: number): void; diff --git a/src/actions.ts b/src/actions.ts index ccb947fa..bc6a5bb2 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -280,8 +280,8 @@ function trigger_action_listeners( undo: on_annotation_revert, }, bitmask_stroke: { - // Undo/redo handling and re-rendering are managed by the - // bitmask_stroke__undo / bitmask_stroke__redo methods directly. + action: on_finish_annotation_spatial_modification, + undo: on_finish_annotation_spatial_modification, }, delete_annotations_in_polygon: { // No listener for this action. diff --git a/src/index.js b/src/index.js index 89b9e68b..9cae3451 100644 --- a/src/index.js +++ b/src/index.js @@ -677,10 +677,9 @@ export class ULabel { toolbox_item.after_init(); } - // If bitmask is the initial mode, enable its brush right away + // Show the brush toolbox if bitmask is the initial mode (brush starts off; toggle to paint) if (this.get_current_subtask()["state"]["annotation_mode"] === "bitmask") { BrushToolboxItem.show_brush_toolbox_item(); - this.enable_bitmask_brush(); } } @@ -1794,17 +1793,11 @@ export class ULabel { } } - // Parse a "#rrggbb" hex color into [r, g, b]. Falls back to the default color on failure. - hex_to_rgb(color_hex) { - if (typeof color_hex === "string" && color_hex[0] === "#" && color_hex.length >= 7) { - const r = parseInt(color_hex.slice(1, 3), 16); - const g = parseInt(color_hex.slice(3, 5), 16); - const b = parseInt(color_hex.slice(5, 7), 16); - if (!isNaN(r) && !isNaN(g) && !isNaN(b)) { - return [r, g, b]; - } - } - return [250, 157, 42]; + // Shift a bitmask annotation's mask by (dx, dy) image pixels and re-encode it. + translate_bitmask(annotation_object, dx, dy) { + const shifted = this.get_bitmask(annotation_object).translate(dx, dy); + this.set_bitmask(annotation_object, shifted); + annotation_object["spatial_payload"] = shifted.to_rle(); } draw_bitmask(annotation_object, ctx, offset = null) { @@ -1815,10 +1808,7 @@ export class ULabel { const mask = this.get_bitmask(annotation_object); if (mask === null || image_width == null || image_height == null) return; - // Build an ImageData at native image resolution from the mask, tinted by class color - const [r, g, b] = this.hex_to_rgb(this.get_annotation_color(annotation_object)); - const alpha = Math.round(255 * this.config["mask_annotation_opacity"]); - + // Build an opaque white stencil of the mask at native image resolution const offscreen = document.createElement("canvas"); offscreen.width = image_width; offscreen.height = image_height; @@ -1829,14 +1819,20 @@ export class ULabel { for (let i = 0; i < mask_data.length; i++) { if (mask_data[i] !== 0) { const j = i * 4; - data[j] = r; - data[j + 1] = g; - data[j + 2] = b; - data[j + 3] = alpha; + data[j] = 255; + data[j + 1] = 255; + data[j + 2] = 255; + data[j + 3] = 255; } } offscreen_ctx.putImageData(image_data, 0, 0); + // 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.fillRect(0, 0, image_width, image_height); + // Draw the native-resolution mask scaled onto the target context, honoring any offset let diffX = 0; let diffY = 0; @@ -1846,7 +1842,7 @@ export class ULabel { } ctx.imageSmoothingEnabled = false; ctx.globalCompositeOperation = "source-over"; - ctx.globalAlpha = 1.0; + ctx.globalAlpha = this.config["mask_annotation_opacity"]; ctx.drawImage( offscreen, diffX * px_per_px, @@ -1854,6 +1850,7 @@ export class ULabel { image_width * px_per_px, image_height * px_per_px, ); + ctx.globalAlpha = 1.0; } draw_contour(annotation_object, ctx, offset = null) { @@ -2350,14 +2347,25 @@ export class ULabel { toggle_brush_mode(mouse_event) { // Try and switch to polygon annotation if not already in it const current_subtask = this.get_current_subtask_key(); - // In bitmask mode the brush is always active; "brush" selects paint (non-erase) + // In bitmask mode, the brush toggles on/off (so edit/id dialogs remain usable when off) if (this.subtasks[current_subtask]["state"]["annotation_mode"] === "bitmask") { const state = this.subtasks[current_subtask]["state"]; - state["is_in_brush_mode"] = true; - state["is_in_erase_mode"] = false; - $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - this.recolor_brush_circle(); + state["is_in_brush_mode"] = !state["is_in_brush_mode"]; + if (state["is_in_brush_mode"]) { + // Hide edit/id dialogs while painting + this.suggest_edits(); + state["move_candidate"] = null; + $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + const gmx = this.get_global_mouse_x(mouse_event); + const gmy = this.get_global_mouse_y(mouse_event); + this.create_brush_circle(gmx, gmy); + } else { + // Turning the brush off also exits erase mode + state["is_in_erase_mode"] = false; + $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + this.destroy_brush_circle(); + } return; } let is_in_polygon_mode = this.subtasks[current_subtask]["state"]["annotation_mode"] === "polygon"; @@ -2398,9 +2406,11 @@ export class ULabel { toggle_erase_mode(mouse_event) { const current_subtask = this.get_current_subtask(); - // In bitmask mode the brush is always active; only the paint/erase toggle changes + // In bitmask mode, erasing is a subset of the brush; ensure the brush is on if (current_subtask["state"]["annotation_mode"] === "bitmask") { - current_subtask["state"]["is_in_brush_mode"] = true; + if (!current_subtask["state"]["is_in_brush_mode"]) { + this.toggle_brush_mode(mouse_event); + } current_subtask["state"]["is_in_erase_mode"] = !current_subtask["state"]["is_in_erase_mode"]; if (current_subtask["state"]["is_in_erase_mode"]) { $("#erase-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); @@ -2447,17 +2457,6 @@ export class ULabel { } } - // Enable the brush for bitmask mode (painting is the only interaction) - enable_bitmask_brush() { - const state = this.get_current_subtask()["state"]; - state["is_in_brush_mode"] = true; - state["is_in_erase_mode"] = false; - $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - // Create the brush circle; it will follow the cursor on the next mouse move - this.create_brush_circle(0, 0); - } - // Disable the brush when leaving bitmask mode disable_bitmask_brush() { const state = this.get_current_subtask()["state"]; @@ -4459,8 +4458,8 @@ export class ULabel { const annotations = current_subtask["annotations"]["access"]; const gmx = this.get_global_mouse_x(mouse_event); const gmy = this.get_global_mouse_y(mouse_event); - const imx = gmx / this.config["px_per_px"]; - const imy = gmy / this.config["px_per_px"]; + const imx = gmx; + const imy = gmy; const radius = this.config["brush_size"] / 2; const is_erase = current_subtask["state"]["is_in_erase_mode"]; const in_bounds = GeometricUtils.point_is_within_image_bounds( @@ -4479,17 +4478,15 @@ export class ULabel { return; } } else { - // Don't start a new annotation fully outside the image - if (!in_bounds) { - this.shake_screen(); - this.move_brush_circle(gmx, gmy); - return; - } - // Only continue an existing annotation if it matches the active class - if (target_id !== null && get_annotation_class_id(annotations[target_id]) !== this.get_active_class_id()) { - target_id = null; - } + // Extend whichever bitmask annotation the stroke starts over (option b: + // brushing over an existing mask adds to it). Otherwise, start a new one. if (target_id === null) { + // Don't start a new annotation fully outside the image + if (!in_bounds) { + this.shake_screen(); + this.move_brush_circle(gmx, gmy); + return; + } target_id = this.create_bitmask_annotation(); was_new = true; } @@ -4535,8 +4532,8 @@ export class ULabel { const annotation = current_subtask["annotations"]["access"][active_id]; const mask = this.get_bitmask(annotation); - const imx = gmx / this.config["px_per_px"]; - const imy = gmy / this.config["px_per_px"]; + const imx = gmx; + const imy = gmy; const radius = this.config["brush_size"] / 2; const value = current_subtask["state"]["is_in_erase_mode"] ? 0 : 1; @@ -4570,9 +4567,8 @@ export class ULabel { const mask = this.get_bitmask(annotation); const after_empty = mask.is_empty(); - // Encode the mask to an RLE payload and update the containing box + // Encode the mask to an RLE payload annotation["spatial_payload"] = mask.to_rle(); - this.rebuild_bitmask_containing_box(annotation); // If the stroke erased the whole mask, deprecate the annotation (ULabel's delete semantics) if (after_empty) { @@ -4603,10 +4599,6 @@ export class ULabel { undo_payload: stroke_payload, redo_payload: stroke_payload, }); - - this.redraw_annotation(active_id); - this.suggest_edits(null, null, true); - this.toolbox.redraw_update_items(this); } bitmask_stroke__undo(annotation_id, undo_payload) { @@ -4618,18 +4610,12 @@ export class ULabel { // Undo creation of a brand-new annotation this.set_bitmask_from_rle(annotation, null); annotation["spatial_payload"] = null; - annotation["containing_box"] = null; mark_deprecated(annotation, true); } else { this.set_bitmask_from_rle(annotation, undo_payload.before_rle); annotation["spatial_payload"] = undo_payload.before_rle; mark_deprecated(annotation, false); - this.rebuild_bitmask_containing_box(annotation); } - - this.redraw_annotation(annotation_id); - this.suggest_edits(null, null, true); - this.toolbox.redraw_update_items(this); } bitmask_stroke__redo(annotation_id, redo_payload) { @@ -4640,11 +4626,6 @@ export class ULabel { this.set_bitmask_from_rle(annotation, redo_payload.after_rle); annotation["spatial_payload"] = redo_payload.after_rle; mark_deprecated(annotation, redo_payload.after_empty === true); - this.rebuild_bitmask_containing_box(annotation); - - this.redraw_annotation(annotation_id); - this.suggest_edits(null, null, true); - this.toolbox.redraw_update_items(this); // Re-record so the stroke can be undone again record_action(this, { @@ -5201,6 +5182,15 @@ export class ULabel { let spatial_payload = annotation["spatial_payload"]; let active_spatial_payload = spatial_payload; + // Bitmask masks are translated wholesale rather than point-by-point + if (spatial_type === "bitmask") { + this.translate_bitmask(annotation, diffX, diffY); + current_subtask["state"]["active_id"] = null; + current_subtask["state"]["is_in_move"] = false; + record_finish_move(this, diffX, diffY, diffZ, false); + return; + } + // if a polygon, n_iters is the length the spatial payload // else n_iters is 1 let n_iters = spatial_type === "polygon" ? spatial_payload.length : 1; @@ -5270,6 +5260,12 @@ export class ULabel { const spatial_payload = annotations[annotation_id]["spatial_payload"]; let active_spatial_payload = spatial_payload; + // Bitmask masks are translated wholesale rather than point-by-point + if (spatial_type === "bitmask") { + this.translate_bitmask(annotations[annotation_id], diffX, diffY); + return; + } + // if a polygon, n_iters is the length the spatial payload // else n_iters is 1 let n_iters = spatial_type === "polygon" ? spatial_payload.length : 1; @@ -5311,21 +5307,26 @@ export class ULabel { const spatial_payload = annotations[annotation_id]["spatial_payload"]; let active_spatial_payload = spatial_payload; - // if a polygon, n_iters is the length the spatial payload - // else n_iters is 1 - let n_iters = spatial_type === "polygon" ? spatial_payload.length : 1; + // Bitmask masks are translated wholesale rather than point-by-point + if (spatial_type === "bitmask") { + this.translate_bitmask(annotations[annotation_id], diffX, diffY); + } else { + // if a polygon, n_iters is the length the spatial payload + // else n_iters is 1 + let n_iters = spatial_type === "polygon" ? spatial_payload.length : 1; - for (let i = 0; i < n_iters; i++) { - // for polygons, we need to move the points in each part of the spatial payload - if (spatial_type === "polygon") { - active_spatial_payload = spatial_payload[i]; - } + for (let i = 0; i < n_iters; i++) { + // for polygons, we need to move the points in each part of the spatial payload + if (spatial_type === "polygon") { + active_spatial_payload = spatial_payload[i]; + } - for (var spi = 0; spi < active_spatial_payload.length; spi++) { - active_spatial_payload[spi][0] += diffX; - active_spatial_payload[spi][1] += diffY; - if (active_spatial_payload[spi].length > 2) { - active_spatial_payload[spi][2] += diffZ; + for (var spi = 0; spi < active_spatial_payload.length; spi++) { + active_spatial_payload[spi][0] += diffX; + active_spatial_payload[spi][1] += diffY; + if (active_spatial_payload[spi].length > 2) { + active_spatial_payload[spi][2] += diffZ; + } } } } @@ -5421,6 +5422,12 @@ export class ULabel { is_a_containing_annotation = true; } break; + case "bitmask": + // The mouse must be over a painted pixel of the mask + if (this.get_bitmask(annotation).get_pixel(Math.round(gblx), Math.round(gbly))) { + is_a_containing_annotation = true; + } + break; default: break; @@ -6095,7 +6102,8 @@ export class ULabel { switch (drag_key) { case "annotation": annmd = this.get_current_subtask()["state"]["annotation_mode"]; - if (!NONSPATIAL_MODES.includes(annmd) && !this.get_current_subtask()["state"]["is_in_progress"]) { + // Bitmask annotations are only created via the brush, not by clicking the canvas + if (annmd !== "bitmask" && !NONSPATIAL_MODES.includes(annmd) && !this.get_current_subtask()["state"]["is_in_progress"]) { this.begin_annotation(mouse_event); } break; diff --git a/src/mask_utils.ts b/src/mask_utils.ts index 02a11dab..d49169d3 100644 --- a/src/mask_utils.ts +++ b/src/mask_utils.ts @@ -140,6 +140,28 @@ export class ULabelMask { return { tlx: min_x, tly: min_y, brx: max_x, bry: max_y }; } + // Return a new mask with all foreground pixels shifted by (dx, dy) image pixels. + // Pixels shifted outside the image are dropped. + public translate(dx: number, dy: number): ULabelMask { + const shifted = new ULabelMask(this.width, this.height); + const idx = Math.round(dx); + const idy = Math.round(dy); + for (let y = 0; y < this.height; y++) { + const ny = y + idy; + if (ny < 0 || ny >= this.height) continue; + const src_row = y * this.width; + const dst_row = ny * this.width; + for (let x = 0; x < this.width; x++) { + if (this.data[src_row + x] !== 0) { + const nx = x + idx; + if (nx < 0 || nx >= this.width) continue; + shifted.data[dst_row + nx] = 1; + } + } + } + return shifted; + } + // Encode to COCO-style, column-major run-length counts. public to_rle(): ULabelMaskPayload { const counts: number[] = []; diff --git a/src/toolbox.ts b/src/toolbox.ts index fc9d87d2..41b01861 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -432,8 +432,7 @@ export class ModeSelectionToolboxItem extends ToolboxItem { } } else if (new_mode === "bitmask") { BrushToolboxItem.show_brush_toolbox_item(); - // Bitmask painting always uses the brush - ulabel.enable_bitmask_brush(); + // Brush starts off so edit/id dialogs remain usable; the user toggles it to paint } else { BrushToolboxItem.hide_brush_toolbox_item(); if (prev_mode === "bitmask") { diff --git a/tests/mask_utils.test.js b/tests/mask_utils.test.js index 090ca1c5..7c8c150b 100644 --- a/tests/mask_utils.test.js +++ b/tests/mask_utils.test.js @@ -77,6 +77,30 @@ describe("ULabelMask", () => { }); }); + describe("translate", () => { + test("shifts foreground pixels by the given offset", () => { + const mask = ULabelMask.create_empty(6, 6); + mask.set_pixel(1, 1, 1); + const shifted = mask.translate(2, 3); + expect(shifted.get_pixel(1, 1)).toBe(0); + expect(shifted.get_pixel(3, 4)).toBe(1); + }); + + test("drops pixels shifted outside the image", () => { + const mask = ULabelMask.create_empty(4, 4); + mask.set_pixel(0, 0, 1); + const shifted = mask.translate(-1, -1); + expect(shifted.is_empty()).toBe(true); + }); + + test("rounds fractional offsets", () => { + const mask = ULabelMask.create_empty(6, 6); + mask.set_pixel(2, 2, 1); + const shifted = mask.translate(1.4, -0.6); + expect(shifted.get_pixel(3, 1)).toBe(1); + }); + }); + describe("RLE round-trip", () => { test("encodes an empty mask as a single background run", () => { const mask = ULabelMask.create_empty(3, 2); From 77771c552a6774a3a74991fdf904e60d4984ce82 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Tue, 4 Aug 2026 11:52:20 -0500 Subject: [PATCH 03/13] fix brush --- src/index.js | 103 +++++++++++++++++---------------------------------- 1 file changed, 35 insertions(+), 68 deletions(-) diff --git a/src/index.js b/src/index.js index 9cae3451..050c7d7e 100644 --- a/src/index.js +++ b/src/index.js @@ -2345,85 +2345,52 @@ export class ULabel { } toggle_brush_mode(mouse_event) { - // Try and switch to polygon annotation if not already in it + // The brush is only valid in polygon or bitmask mode const current_subtask = this.get_current_subtask_key(); - // In bitmask mode, the brush toggles on/off (so edit/id dialogs remain usable when off) - if (this.subtasks[current_subtask]["state"]["annotation_mode"] === "bitmask") { - const state = this.subtasks[current_subtask]["state"]; - state["is_in_brush_mode"] = !state["is_in_brush_mode"]; - if (state["is_in_brush_mode"]) { - // Hide edit/id dialogs while painting - this.suggest_edits(); - state["move_candidate"] = null; - $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - const gmx = this.get_global_mouse_x(mouse_event); - const gmy = this.get_global_mouse_y(mouse_event); - this.create_brush_circle(gmx, gmy); - } else { - // Turning the brush off also exits erase mode - state["is_in_erase_mode"] = false; - $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - this.destroy_brush_circle(); - } - return; - } - let is_in_polygon_mode = this.subtasks[current_subtask]["state"]["annotation_mode"] === "polygon"; - // Try and switch to polygon mode if not already in it - if (!is_in_polygon_mode) { + const state = this.subtasks[current_subtask]["state"]; + let is_in_polygon_mode = state["annotation_mode"] === "polygon"; + let is_in_bitmask_mode = state["annotation_mode"] === "bitmask"; + + // If in neither mode, try to switch to one (preferring polygon) + if (!is_in_polygon_mode && !is_in_bitmask_mode) { is_in_polygon_mode = this.set_and_update_annotation_mode("polygon"); + if (!is_in_polygon_mode) { + is_in_bitmask_mode = this.set_and_update_annotation_mode("bitmask"); + } + // Bail if neither brush-compatible mode is allowed + if (!is_in_polygon_mode && !is_in_bitmask_mode) { + return; + } $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); } - // If we're in polygon mode, toggle brush mode - if (is_in_polygon_mode) { - // If in erase mode, turn it off - if (this.subtasks[current_subtask]["state"]["is_in_erase_mode"]) { - this.toggle_erase_mode(); - } - // Toggle brush mode - this.subtasks[current_subtask]["state"]["is_in_brush_mode"] = !this.subtasks[current_subtask]["state"]["is_in_brush_mode"]; - if (this.subtasks[current_subtask]["state"]["is_in_brush_mode"]) { - // Hide edit/id dialogs - this.suggest_edits(); - // Clear any move candidates - this.subtasks[current_subtask]["state"]["move_candidate"] = null; - // If in starting_complex_polygon mode, end it by undoing - if (this.subtasks[current_subtask]["state"]["starting_complex_polygon"]) { - undo(this, true); - } - // Show brush circle - let gmx = this.get_global_mouse_x(mouse_event); - let gmy = this.get_global_mouse_y(mouse_event); - this.create_brush_circle(gmx, gmy); - $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - } else { - this.destroy_brush_circle(); - $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + + // If in erase mode, turn it off first + if (state["is_in_erase_mode"]) { + this.toggle_erase_mode(); + } + + // Toggle brush mode + state["is_in_brush_mode"] = !state["is_in_brush_mode"]; + if (state["is_in_brush_mode"]) { + // Hide edit/id dialogs and clear any move candidate while painting + this.suggest_edits(); + state["move_candidate"] = null; + // Polygon-only: end an in-progress complex polygon by undoing + if (is_in_polygon_mode && state["starting_complex_polygon"]) { + undo(this, true); } + // Show the brush circle + this.create_brush_circle(this.get_global_mouse_x(mouse_event), this.get_global_mouse_y(mouse_event)); + $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + } else { + this.destroy_brush_circle(); + $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); } } toggle_erase_mode(mouse_event) { const current_subtask = this.get_current_subtask(); - // In bitmask mode, erasing is a subset of the brush; ensure the brush is on - if (current_subtask["state"]["annotation_mode"] === "bitmask") { - if (!current_subtask["state"]["is_in_brush_mode"]) { - this.toggle_brush_mode(mouse_event); - } - current_subtask["state"]["is_in_erase_mode"] = !current_subtask["state"]["is_in_erase_mode"]; - if (current_subtask["state"]["is_in_erase_mode"]) { - $("#erase-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - } else { - $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - } - $("#brush_circle").css({ - "background-color": current_subtask["state"]["is_in_erase_mode"] ? "red" : this.get_active_class_color(), - }); - return; - } // If not in brush mode, turn it on if (!current_subtask["state"]["is_in_brush_mode"]) { this.toggle_brush_mode(mouse_event); From bfc86d32f91e731a60f56d60d607a6961e1f3338 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Tue, 4 Aug 2026 12:04:12 -0500 Subject: [PATCH 04/13] initial changelog --- CHANGELOG.md | 7 +++++++ api_spec.md | 46 +++++++++++++++++++++++++++++++++++++++----- package-lock.json | 4 ++-- package.json | 2 +- src/configuration.ts | 2 +- 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2901d96c..3109d831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented here. ## [unreleased] +## [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. + - Brushing over an existing bitmask adds to it; brushing empty space starts a new mask. Hover a mask (with the brush off) to change its class via the ID dialog, or move/delete it like any other spatial annotation. + - Each bitmask annotation stores a single binary mask. On export, `spatial_payload` is a COCO-style run-length-encoded object: `{ "counts": , "size": [, ] }` (column-major, starting with a background run). Fully-erased masks are deprecated (ULabel's delete semantics). + - Configurable render opacity for bitmask classes via `mask_annotation_opacity` (default `0.2`). + ## [0.24.0] - July 22nd, 2026 - Add `ConfidenceSlider` toolbox item (`AllowedToolboxItem.ConfidenceSlider`) that deprecates (hides) or shows spatial annotations based on their confidence values. Unlike the now-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. - Supports a single global "all" slider and/or per-class sliders, controlled by `class_filter_mode` (`"toggle"`, `"all-only"`, or `"class-only"`). diff --git a/api_spec.md b/api_spec.md index 18f8987d..45139776 100644 --- a/api_spec.md +++ b/api_spec.md @@ -66,6 +66,7 @@ class ULabel({ toggle_erase_mode_keybind: string, increase_brush_size_keybind: string, decrease_brush_size_keybind: string, + mask_annotation_opacity: number, fly_to_next_annotation_keybind: string, fly_to_previous_annotation_keybind: string, annotation_size_small_keybind: string, @@ -178,7 +179,9 @@ As you can see, each subtask will have a corresponding list of annotation object "spatial_type": "", // (nullable) e.g. [[x1, y1], [x2, y2], ...] - "spatial_payload": "", + // For "bitmask" annotations this is instead a run-length-encoded object: + // { "counts": , "size": [, ] }. See Bitmask annotations. + "spatial_payload": "", // The class associated with the annotation "classification_payloads": [ @@ -281,9 +284,39 @@ The full list of `"allowed_modes"` that are currently supported is: - `"whole-image"`: A label to be applied to an entire frame - `"global"`: A label to be applied to the entire series of frames - `"point"`: A keypoint within a single frame +- `"bitmask"`: A raster (per-pixel) segmentation mask, painted with the brush. See [Bitmask annotations](#bitmask-annotations). - `"delete_polygon"`: Allows drawing a polygon around an area, and all annotations within that area will be deleted - `"delete_bbox"`: Allows drawing a bounding box around an area, and all annotations within that area will be deleted +#### Bitmask annotations + +The `"bitmask"` mode enables raster (per-pixel) segmentation. Each bitmask annotation stores a single binary mask the size of the image. + +**Interaction** + +- Painting uses the brush, shared with the `polygon` brush. Toggle the brush with `toggle_brush_mode_keybind` (default `g`) or the Brush toolbox item, erase with `toggle_erase_mode_keybind` (default `e`), and resize the brush with `increase_brush_size_keybind` / `decrease_brush_size_keybind` (defaults `]` / `[`) or `alt+scroll`. +- Starting a stroke over an existing bitmask adds to that mask; starting over empty space creates a new bitmask annotation. +- With the brush off, hovering a mask surfaces the usual edit dialogs: change its class via the ID dialog, or move/delete it like any other spatial annotation. Erasing a mask entirely deprecates the annotation (ULabel's delete semantics). +- Requires the `Brush` toolbox item (`AllowedToolboxItem.Brush`) to be present. + +**Serialization** + +A bitmask's `spatial_payload` is a COCO-style, uncompressed run-length encoding: + +```javascript +{ + // Alternating run lengths in column-major (Fortran) order, always starting + // with a background (0) run. A leading foreground pixel is a leading 0. + "counts": [, ...], + // [height, width] of the mask (matches COCO's size convention) + "size": [, ] +} +``` + +Note this is the *uncompressed* form (`counts` as an integer array), not the LEB128-packed string used by `pycocotools`. Masks import from and export to this same object shape. + +The render opacity of bitmask annotations is configurable via [`mask_annotation_opacity`](#mask_annotation_opacity). + The `resume_from` attributes are used to import existing annotations into the annotation session for each subtask, respectively. Existing annotations must be provided as a list of annotations of the form specified above. ### `task_meta` and `annotation_meta` @@ -506,16 +539,19 @@ Keybind to toggle between annotation and selection modes. Default is `u`. Keybind to create a bounding box annotation around the `initial_crop`. Default is `f`. Requires the active subtask to have a `bbox` mode. ### `toggle_brush_mode_keybind` -Keybind to toggle brush mode for polygon annotations. Default is `g`. Requires the active subtask to have a `polygon` mode. +Keybind to toggle brush mode for `polygon` and `bitmask` annotations. Default is `g`. Requires the active subtask to have a `polygon` or `bitmask` mode. ### `toggle_erase_mode_keybind` -Keybind to toggle erase mode for polygon annotations. Default is `e`. Requires the active subtask to have a `polygon` mode. +Keybind to toggle erase mode for `polygon` and `bitmask` annotations. Default is `e`. Requires the active subtask to have a `polygon` or `bitmask` mode. ### `increase_brush_size_keybind` -Keybind to increase the brush size. Default is `]`. Requires the active subtask to have a `polygon` mode. +Keybind to increase the brush size. Default is `]`. Requires the active subtask to have a `polygon` or `bitmask` mode. ### `decrease_brush_size_keybind` -Keybind to decrease the brush size. Default is `[`. Requires the active subtask to have a `polygon` mode. +Keybind to decrease the brush size. Default is `[`. Requires the active subtask to have a `polygon` or `bitmask` mode. + +### `mask_annotation_opacity` +The fill opacity (`0`-`1`) used when rendering `bitmask` (raster segmentation) annotations. Default is `0.45`. ### `fly_to_next_annotation_keybind` Keybind to set the zoom to focus on the next annotation. Default is `Tab`, which also will disable any default browser behavior for `Tab`. diff --git a/package-lock.json b/package-lock.json index 1a6b629d..5e9f7648 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ulabel", - "version": "0.23.7", + "version": "0.25.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ulabel", - "version": "0.23.7", + "version": "0.25.0", "license": "MIT", "devDependencies": { "@eslint/config-inspector": "^1.3.0", diff --git a/package.json b/package.json index 273fa93f..0fa6406c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ulabel", "description": "An image annotation tool.", - "version": "0.24.0", + "version": "0.25.0", "main": "dist/ulabel.min.js", "module": "dist/ulabel.min.js", "types": "dist/index.d.ts", diff --git a/src/configuration.ts b/src/configuration.ts index 9c276f1c..dc71abc7 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -135,7 +135,7 @@ export class Configuration { public brush_size: number = 60; // Fill opacity (0-1) used when rendering bitmask (raster segmentation) annotations - public mask_annotation_opacity: number = 0.45; + public mask_annotation_opacity: number = 0.2; // Configuration for the annotation task itself public image_data: ImageData | null = null; From 04957d093abd25bd31db1cb697c065456744014d Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Tue, 4 Aug 2026 13:02:19 -0500 Subject: [PATCH 05/13] add exclude/overwrite brush modes --- CHANGELOG.md | 5 ++ api_spec.md | 28 +++++- index.d.ts | 7 ++ src/configuration.ts | 15 +++- src/index.js | 190 +++++++++++++++++++++++++++++++++++---- src/listeners.ts | 14 +++ src/mask_utils.ts | 59 ++++++++++++ src/toolbox.ts | 61 ++++++++++++- tests/mask_utils.test.js | 72 +++++++++++++++ 9 files changed, 429 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3109d831..ba7bb575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to this project will be documented here. ## [unreleased] +- Add brush **overlap modes** for bitmask painting, controlled globally and persisted to localStorage: `none` (default), `exclude`, and `overwrite`. + - `exclude`: newly-painted pixels never cover pixels owned by other undeprecated bitmask annotations (existing masks win). + - `overwrite`: newly-painted pixels are removed from any other bitmask annotation that owned them (the new mask wins); a mask fully carved away is deprecated. + - Resolution is deferred to the end of a stroke (you see the active mask cover others, then it snaps to the resolved result on release) and only affects the pixels a stroke adds; erase is unaffected. + - Selectable via the Brush toolbox item (shown in bitmask mode) and keybinds `set_brush_overlap_none_keybind` / `set_brush_overlap_exclude_keybind` / `set_brush_overlap_overwrite_keybind` (defaults `shift+n` / `shift+e` / `shift+o`). Initial value configurable via `default_brush_overlap_mode`. ## [0.25.0] - Aug 5th, 2026 - Add a `bitmask` annotation mode for raster (per-pixel) segmentation, selectable via `allowed_modes: ["bitmask", ...]`. diff --git a/api_spec.md b/api_spec.md index 45139776..49268a74 100644 --- a/api_spec.md +++ b/api_spec.md @@ -67,6 +67,10 @@ class ULabel({ increase_brush_size_keybind: string, decrease_brush_size_keybind: string, mask_annotation_opacity: number, + default_brush_overlap_mode: BrushOverlapMode, + set_brush_overlap_none_keybind: string, + set_brush_overlap_exclude_keybind: string, + set_brush_overlap_overwrite_keybind: string, fly_to_next_annotation_keybind: string, fly_to_previous_annotation_keybind: string, annotation_size_small_keybind: string, @@ -299,6 +303,16 @@ The `"bitmask"` mode enables raster (per-pixel) segmentation. Each bitmask annot - With the brush off, hovering a mask surfaces the usual edit dialogs: change its class via the ID dialog, or move/delete it like any other spatial annotation. Erasing a mask entirely deprecates the annotation (ULabel's delete semantics). - Requires the `Brush` toolbox item (`AllowedToolboxItem.Brush`) to be present. +**Overlap modes** + +When painting, the brush can enforce mutual exclusivity with *other* undeprecated bitmask annotations. The mode is a single **global** value, persisted to localStorage, and is chosen via the Brush toolbox item (shown in bitmask mode) or the overlap keybinds. Its initial value comes from [`default_brush_overlap_mode`](#default_brush_overlap_mode). + +- `"none"` (default): painting only adds to the active mask; other masks are untouched (pixels may be owned by multiple annotations). +- `"exclude"`: newly-painted pixels never cover pixels owned by other bitmask annotations (existing masks win). +- `"overwrite"`: newly-painted pixels are removed from any other bitmask annotation that owned them (the new mask wins); a mask fully carved away is deprecated. + +Resolution is **deferred to the end of a stroke** and only affects the pixels the stroke adds (pre-existing overlaps are left alone). Erase strokes are unaffected. These modes govern *new strokes only* — they do not retroactively de-overlap already-imported masks. + **Serialization** A bitmask's `spatial_payload` is a COCO-style, uncompressed run-length encoding: @@ -551,7 +565,19 @@ Keybind to increase the brush size. Default is `]`. Requires the active subtask Keybind to decrease the brush size. Default is `[`. Requires the active subtask to have a `polygon` or `bitmask` mode. ### `mask_annotation_opacity` -The fill opacity (`0`-`1`) used when rendering `bitmask` (raster segmentation) annotations. Default is `0.45`. +The fill opacity (`0`-`1`) used when rendering `bitmask` (raster segmentation) annotations. Default is `0.4`. + +### `default_brush_overlap_mode` +The initial [brush overlap mode](#overlap-modes) for bitmask painting: `"none"` (default), `"exclude"`, or `"overwrite"`. The live value is global and persisted to localStorage, so a user's last choice takes precedence over this default on subsequent sessions. + +### `set_brush_overlap_none_keybind` +Keybind to set the brush overlap mode to `none`. Default is `shift+n`. + +### `set_brush_overlap_exclude_keybind` +Keybind to set the brush overlap mode to `exclude`. Default is `shift+e`. + +### `set_brush_overlap_overwrite_keybind` +Keybind to set the brush overlap mode to `overwrite`. Default is `shift+o`. ### `fly_to_next_annotation_keybind` Keybind to set the zoom to focus on the next annotation. Default is `Tab`, which also will disable any default browser behavior for `Tab`. diff --git a/index.d.ts b/index.d.ts index 6739e372..717d1492 100644 --- a/index.d.ts +++ b/index.d.ts @@ -223,6 +223,10 @@ export type ImageData = { export type AnnoScalingMode = "fixed" | "inverse-zoom" | "match-zoom"; +// How the brush resolves overlap with other undeprecated bitmask annotations +// when painting. Resolution is deferred to the end of a stroke. +export type BrushOverlapMode = "none" | "exclude" | "overwrite"; + export type ULabelActionType = "create_nonspatial_annotation" | "create_annotation" | "begin_annotation" | @@ -418,6 +422,9 @@ export class ULabel { public toggle_erase_mode(mouse_event?: JQuery.TriggeredEvent): void; public toggle_brush_mode(mouse_event?: JQuery.TriggeredEvent): void; public disable_bitmask_brush(): void; + public load_brush_overlap_mode(): void; + public get_brush_overlap_mode(): BrushOverlapMode; + public set_brush_overlap_mode(mode: string): void; public toggle_delete_class_id_in_toolbox(): void; public change_brush_size(scale_factor: number): void; public recolor_brush_circle(): void; diff --git a/src/configuration.ts b/src/configuration.ts index dc71abc7..5e9c620a 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -7,6 +7,7 @@ import type { RecolorActiveConfig, ULabelSubmitButton, AnnoScalingMode, + BrushOverlapMode, } from "../index"; import { ModeSelectionToolboxItem, @@ -135,8 +136,12 @@ export class Configuration { public brush_size: number = 60; // Fill opacity (0-1) used when rendering bitmask (raster segmentation) annotations - public mask_annotation_opacity: number = 0.2; - + public mask_annotation_opacity: number = 0.4; + // How the bitmask brush resolves overlap with other undeprecated bitmask + // annotations when painting. Resolution is deferred to the end of a stroke. + // The live value is global and persisted to localStorage; this is the initial default. + public default_brush_overlap_mode: BrushOverlapMode = "none"; + public brush_overlap_mode: BrushOverlapMode = "none"; // Configuration for the annotation task itself public image_data: ImageData | null = null; public allow_soft_id: boolean = false; @@ -241,6 +246,12 @@ export class Configuration { public decrease_brush_size_keybind: string = "["; + public set_brush_overlap_none_keybind: string = "shift+n"; + + public set_brush_overlap_exclude_keybind: string = "shift+e"; + + public set_brush_overlap_overwrite_keybind: string = "shift+o"; + public annotation_size_small_keybind: string = "s"; public annotation_size_large_keybind: string = "l"; diff --git a/src/index.js b/src/index.js index 050c7d7e..39582764 100644 --- a/src/index.js +++ b/src/index.js @@ -56,6 +56,10 @@ jQuery.fn.outer_html = function () { }; export class ULabel { + // Valid brush overlap modes and the localStorage key used to persist the global choice + static BRUSH_OVERLAP_MODES = ["none", "exclude", "overwrite"]; + static BRUSH_OVERLAP_STORAGE_KEY = "ulabel_brush_overlap_mode"; + static version() { return ULABEL_VERSION; } @@ -570,6 +574,9 @@ export class ULabel { // Create the config and add ulabel dependent data this.config = new Configuration(kwargs); + // Seed the global brush overlap mode from localStorage (falling back to the config default) + this.load_brush_overlap_mode(); + // Useful for the efficient redraw of nonspatial annotations this.tmp_nonspatial_element_ids = {}; @@ -2434,6 +2441,41 @@ export class ULabel { this.destroy_brush_circle(); } + // ================= Brush overlap mode (global, localStorage-persisted) ================= + + // Load the global brush overlap mode from localStorage, falling back to the config default. + load_brush_overlap_mode() { + let mode = this.config["default_brush_overlap_mode"]; + try { + const stored = window.localStorage.getItem(ULabel.BRUSH_OVERLAP_STORAGE_KEY); + if (stored !== null && ULabel.BRUSH_OVERLAP_MODES.includes(stored)) { + mode = stored; + } + } catch { + // localStorage may be unavailable; fall back to the config default + } + this.config["brush_overlap_mode"] = mode; + } + + get_brush_overlap_mode() { + return this.config["brush_overlap_mode"]; + } + + // Set the global brush overlap mode, persist it, and update the toolbox buttons. + set_brush_overlap_mode(mode) { + if (!ULabel.BRUSH_OVERLAP_MODES.includes(mode)) { + log_message(`Invalid brush overlap mode: ${mode}`, LogLevel.WARNING); + return; + } + this.config["brush_overlap_mode"] = mode; + try { + window.localStorage.setItem(ULabel.BRUSH_OVERLAP_STORAGE_KEY, mode); + } catch { + // Ignore persistence failures (e.g. localStorage unavailable) + } + BrushToolboxItem.update_overlap_mode_buttons(mode); + } + // Create a brush circle at the mouse location create_brush_circle(gmx, gmy) { // Create brush circle id @@ -4469,6 +4511,7 @@ export class ULabel { current_subtask["state"]["bitmask_stroke"] = { annotation_id: target_id, was_new: was_new, + is_erase: is_erase, before_rle: was_new ? null : this.get_bitmask(annotations[target_id]).to_rle(), last_point: null, }; @@ -4532,6 +4575,23 @@ export class ULabel { const annotation = annotations[active_id]; const mask = this.get_bitmask(annotation); + + // Resolve overlap with other bitmask annotations (deferred to end of stroke). + // Only paint strokes are resolved; erase is unaffected. + let other_edits = []; + const overlap_mode = this.get_brush_overlap_mode(); + if (!stroke.is_erase && overlap_mode !== "none") { + // Compute the pixels this stroke added (delta = current AND NOT before) + const before_mask = stroke.before_rle != null ? + ULabelMask.from_rle(stroke.before_rle) : + ULabelMask.create_empty(this.config["image_width"], this.config["image_height"]); + const delta = mask.clone(); + delta.subtract(before_mask); + if (!delta.is_empty()) { + other_edits = this.resolve_bitmask_overlap(active_id, delta, overlap_mode); + } + } + const after_empty = mask.is_empty(); // Encode the mask to an RLE payload @@ -4552,12 +4612,14 @@ export class ULabel { } // Record the whole stroke as a single undoable action. Both payloads carry the - // full before/after state so undo and redo can each reconstruct it. + // full before/after state so undo and redo can each reconstruct it. `other_edits` + // captures any masks carved by "overwrite" so they can be restored too. const stroke_payload = { before_rle: stroke.before_rle, after_rle: annotation["spatial_payload"], was_new: stroke.was_new, after_empty: after_empty, + other_edits: other_edits, }; record_action(this, { act_type: "bitmask_stroke", @@ -4566,33 +4628,127 @@ export class ULabel { undo_payload: stroke_payload, redo_payload: stroke_payload, }); + + // The active annotation is re-rendered by the action listener; render the others here + this.render_bitmask_other_edits(other_edits); + } + + // Ids of all undeprecated bitmask annotations except the given one. + get_other_bitmask_ids(active_id) { + const current_subtask = this.get_current_subtask(); + const access = current_subtask["annotations"]["access"]; + 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); + } + } + return ids; + } + + // Apply the brush overlap mode to the pixels a stroke added (`delta`). + // - exclude: clip the active mask so the new pixels don't cover other masks. + // - 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 other_ids = this.get_other_bitmask_ids(active_id); + + 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]); + // 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); + } + 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 before_rle = other_mask.to_rle(); + other_mask.subtract(delta); + const after_empty = other_mask.is_empty(); + access[oid]["spatial_payload"] = other_mask.to_rle(); + if (after_empty) { + mark_deprecated(access[oid], true); + } + other_edits.push({ + annotation_id: oid, + before_rle: before_rle, + after_rle: access[oid]["spatial_payload"], + after_empty: after_empty, + }); + } + return other_edits; + } + + // 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) { + if (access[edit.annotation_id] === undefined) continue; + this.rebuild_bitmask_containing_box(access[edit.annotation_id]); + this.redraw_annotation(edit.annotation_id); + } + this.toolbox.redraw_update_items(this); } bitmask_stroke__undo(annotation_id, undo_payload) { const annotations = this.get_current_subtask()["annotations"]["access"]; const annotation = annotations[annotation_id]; - if (annotation === undefined) return; - - if (undo_payload.was_new) { - // Undo creation of a brand-new annotation - this.set_bitmask_from_rle(annotation, null); - annotation["spatial_payload"] = null; - mark_deprecated(annotation, true); - } else { - this.set_bitmask_from_rle(annotation, undo_payload.before_rle); - annotation["spatial_payload"] = undo_payload.before_rle; - mark_deprecated(annotation, false); + if (annotation !== undefined) { + if (undo_payload.was_new) { + // Undo creation of a brand-new annotation + this.set_bitmask_from_rle(annotation, null); + annotation["spatial_payload"] = null; + mark_deprecated(annotation, true); + } else { + this.set_bitmask_from_rle(annotation, undo_payload.before_rle); + annotation["spatial_payload"] = undo_payload.before_rle; + mark_deprecated(annotation, false); + } + } + // 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]; + 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); } } bitmask_stroke__redo(annotation_id, redo_payload) { const annotations = this.get_current_subtask()["annotations"]["access"]; const annotation = annotations[annotation_id]; - if (annotation === undefined) return; - - this.set_bitmask_from_rle(annotation, redo_payload.after_rle); - annotation["spatial_payload"] = redo_payload.after_rle; - mark_deprecated(annotation, redo_payload.after_empty === true); + if (annotation !== undefined) { + this.set_bitmask_from_rle(annotation, redo_payload.after_rle); + annotation["spatial_payload"] = redo_payload.after_rle; + mark_deprecated(annotation, redo_payload.after_empty === true); + } + // 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]; + 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); + } // Re-record so the stroke can be undone again record_action(this, { diff --git a/src/listeners.ts b/src/listeners.ts index 2ab75dab..4802f13d 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -133,6 +133,20 @@ function handle_keypress_event( return; } + // Set the brush overlap mode (bitmask). Persisted globally. + if (event_matches_keybind(keypress_event, ulabel.config.set_brush_overlap_none_keybind)) { + ulabel.set_brush_overlap_mode("none"); + return; + } + if (event_matches_keybind(keypress_event, ulabel.config.set_brush_overlap_exclude_keybind)) { + ulabel.set_brush_overlap_mode("exclude"); + return; + } + if (event_matches_keybind(keypress_event, ulabel.config.set_brush_overlap_overwrite_keybind)) { + ulabel.set_brush_overlap_mode("overwrite"); + return; + } + // Reset zoom to initial crop if (event_matches_keybind(keypress_event, ulabel.config.reset_zoom_keybind)) { ulabel.show_initial_crop(); diff --git a/src/mask_utils.ts b/src/mask_utils.ts index d49169d3..54ecbc43 100644 --- a/src/mask_utils.ts +++ b/src/mask_utils.ts @@ -162,6 +162,65 @@ export class ULabelMask { return shifted; } + // Return a copy of this mask. + public clone(): ULabelMask { + return new ULabelMask(this.width, this.height, this.data.slice()); + } + + // Ensure another mask has the same dimensions as this one. + private assert_same_dims(other: ULabelMask): void { + if (other.width !== this.width || other.height !== this.height) { + throw new Error( + `Mask dimension mismatch: ${this.width}x${this.height} vs ${other.width}x${other.height}`, + ); + } + } + + // Remove another mask's foreground from this one (this = this AND NOT other). + // Returns true if any pixel changed. + public subtract(other: ULabelMask): boolean { + this.assert_same_dims(other); + let changed = false; + for (let i = 0; i < this.data.length; i++) { + if (this.data[i] !== 0 && other.data[i] !== 0) { + this.data[i] = 0; + changed = true; + } + } + return changed; + } + + // Add another mask's foreground into this one (this = this OR other). + public add_mask(other: ULabelMask): void { + this.assert_same_dims(other); + for (let i = 0; i < this.data.length; i++) { + if (other.data[i] !== 0) { + this.data[i] = 1; + } + } + } + + // Keep only pixels present in both masks (this = this AND other). + public intersect(other: ULabelMask): void { + this.assert_same_dims(other); + for (let i = 0; i < this.data.length; i++) { + if (other.data[i] === 0) { + this.data[i] = 0; + } + } + } + + // True if this mask shares any foreground pixel with another. + public intersects(other: ULabelMask): boolean { + this.assert_same_dims(other); + for (let i = 0; i < this.data.length; i++) { + if (this.data[i] !== 0 && other.data[i] !== 0) { + return true; + } + } + return false; + } + // Encode to COCO-style, column-major run-length counts. public to_rle(): ULabelMaskPayload { const counts: number[] = []; diff --git a/src/toolbox.ts b/src/toolbox.ts index 41b01861..a1f679fb 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -426,15 +426,19 @@ export class ModeSelectionToolboxItem extends ToolboxItem { // Show the BrushToolboxItem when polygon or bitmask mode is selected if (new_mode === "polygon") { BrushToolboxItem.show_brush_toolbox_item(); + // Overlap modes are bitmask-only for now + BrushToolboxItem.hide_overlap_controls(); // Leaving bitmask requires tearing down its brush state if (prev_mode === "bitmask") { ulabel.disable_bitmask_brush(); } } else if (new_mode === "bitmask") { BrushToolboxItem.show_brush_toolbox_item(); + BrushToolboxItem.show_overlap_controls(); // Brush starts off so edit/id dialogs remain usable; the user toggles it to paint } else { BrushToolboxItem.hide_brush_toolbox_item(); + BrushToolboxItem.hide_overlap_controls(); if (prev_mode === "bitmask") { // Tear down the bitmask brush without forcing a polygon switch ulabel.disable_bitmask_brush(); @@ -647,6 +651,23 @@ export class BrushToolboxItem extends ToolboxItem { #toolbox div.brush button.brush-button.${BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS} { background-color: #1c2d4d; } + + #toolbox div.brush div.brush-overlap { + margin-top: 0.5rem; + text-align: center; + } + + #toolbox div.brush div.brush-overlap .brush-overlap-label { + display: block; + font-size: 0.8rem; + margin-bottom: 0.25rem; + } + + #toolbox div.brush div.brush-overlap span.brush-overlap-buttons { + display: flex; + justify-content: center; + gap: 0.5rem; + } `; // Create an id so this specific style tag can be referenced const style_id = "brush-toolbox-item-styles"; @@ -689,6 +710,12 @@ export class BrushToolboxItem extends ToolboxItem { break; }; }); + + // Overlap mode selection (bitmask only) + $(document).on("click.ulabel", ".brush-overlap-button", (event) => { + const mode = ($(event.currentTarget).attr("id") || "").replace("brush-overlap-", ""); + this.ulabel.set_brush_overlap_mode(mode); + }); } public get_html() { @@ -705,6 +732,14 @@ export class BrushToolboxItem extends ToolboxItem { +
+ Overlap + + + + + +
`; } @@ -719,11 +754,33 @@ export class BrushToolboxItem extends ToolboxItem { $(".brush").addClass("ulabel-hidden"); } + // Show/hide the overlap-mode controls (bitmask only) + public static show_overlap_controls() { + $(".brush-overlap").removeClass("ulabel-hidden"); + } + + public static hide_overlap_controls() { + $(".brush-overlap").addClass("ulabel-hidden"); + } + + // Reflect the active overlap mode on the toolbox buttons + public static update_overlap_mode_buttons(mode: string) { + $(".brush-overlap-button").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + $(`#brush-overlap-${mode}`).addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); + } + public after_init() { - // Only show BrushToolboxItem if the current mode is polygon - if (this.ulabel.get_current_subtask().state["annotation_mode"] !== "polygon") { + // Reflect the persisted overlap mode on the buttons + BrushToolboxItem.update_overlap_mode_buttons(this.ulabel.get_brush_overlap_mode()); + // Only show BrushToolboxItem if the current mode is polygon or bitmask + const mode = this.ulabel.get_current_subtask().state["annotation_mode"]; + if (mode !== "polygon" && mode !== "bitmask") { BrushToolboxItem.hide_brush_toolbox_item(); } + // Overlap controls are bitmask-only + if (mode !== "bitmask") { + BrushToolboxItem.hide_overlap_controls(); + } } public get_toolbox_item_type() { diff --git a/tests/mask_utils.test.js b/tests/mask_utils.test.js index 7c8c150b..3bf3faff 100644 --- a/tests/mask_utils.test.js +++ b/tests/mask_utils.test.js @@ -155,4 +155,76 @@ describe("ULabelMask", () => { expect(Array.from(restored.data)).toEqual(Array.from(mask.data)); }); }); + + describe("boolean operations", () => { + test("clone produces an independent copy", () => { + const mask = ULabelMask.create_empty(4, 4); + mask.set_pixel(1, 1, 1); + const copy = mask.clone(); + copy.set_pixel(2, 2, 1); + expect(mask.get_pixel(2, 2)).toBe(0); + expect(copy.get_pixel(1, 1)).toBe(1); + }); + + test("subtract removes the other mask's pixels and reports change", () => { + const a = ULabelMask.create_empty(4, 4); + a.set_pixel(1, 1, 1); + a.set_pixel(2, 2, 1); + const b = ULabelMask.create_empty(4, 4); + b.set_pixel(2, 2, 1); + const changed = a.subtract(b); + expect(changed).toBe(true); + expect(a.get_pixel(1, 1)).toBe(1); + expect(a.get_pixel(2, 2)).toBe(0); + }); + + test("subtract returns false when nothing overlaps", () => { + const a = ULabelMask.create_empty(4, 4); + a.set_pixel(0, 0, 1); + const b = ULabelMask.create_empty(4, 4); + b.set_pixel(3, 3, 1); + expect(a.subtract(b)).toBe(false); + expect(a.get_pixel(0, 0)).toBe(1); + }); + + test("add_mask unions the other mask in", () => { + const a = ULabelMask.create_empty(4, 4); + a.set_pixel(0, 0, 1); + const b = ULabelMask.create_empty(4, 4); + b.set_pixel(3, 3, 1); + a.add_mask(b); + expect(a.get_pixel(0, 0)).toBe(1); + expect(a.get_pixel(3, 3)).toBe(1); + }); + + test("intersect keeps only shared pixels", () => { + const a = ULabelMask.create_empty(4, 4); + a.set_pixel(1, 1, 1); + a.set_pixel(2, 2, 1); + const b = ULabelMask.create_empty(4, 4); + b.set_pixel(2, 2, 1); + b.set_pixel(3, 3, 1); + a.intersect(b); + expect(a.get_pixel(1, 1)).toBe(0); + expect(a.get_pixel(2, 2)).toBe(1); + expect(a.get_pixel(3, 3)).toBe(0); + }); + + test("intersects detects any shared pixel", () => { + const a = ULabelMask.create_empty(4, 4); + a.set_pixel(1, 1, 1); + const b = ULabelMask.create_empty(4, 4); + b.set_pixel(1, 1, 1); + const c = ULabelMask.create_empty(4, 4); + c.set_pixel(3, 3, 1); + expect(a.intersects(b)).toBe(true); + expect(a.intersects(c)).toBe(false); + }); + + test("boolean ops throw on dimension mismatch", () => { + const a = ULabelMask.create_empty(4, 4); + const b = ULabelMask.create_empty(5, 4); + expect(() => a.subtract(b)).toThrow(); + }); + }); }); From 2da60812adc62247a11da5a6101cc7f2a8d9785c Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Tue, 4 Aug 2026 13:30:31 -0500 Subject: [PATCH 06/13] fix bitmask to properly overwrite when drawing with a new class --- api_spec.md | 2 +- src/index.js | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/api_spec.md b/api_spec.md index 49268a74..53ee150a 100644 --- a/api_spec.md +++ b/api_spec.md @@ -299,7 +299,7 @@ The `"bitmask"` mode enables raster (per-pixel) segmentation. Each bitmask annot **Interaction** - Painting uses the brush, shared with the `polygon` brush. Toggle the brush with `toggle_brush_mode_keybind` (default `g`) or the Brush toolbox item, erase with `toggle_erase_mode_keybind` (default `e`), and resize the brush with `increase_brush_size_keybind` / `decrease_brush_size_keybind` (defaults `]` / `[`) or `alt+scroll`. -- Starting a stroke over an existing bitmask adds to that mask; starting over empty space creates a new bitmask annotation. +- Starting a paint stroke over an existing bitmask of the **currently-selected class** adds to that mask; otherwise (a different class is selected, or you start over empty space) a new bitmask annotation of the selected class is created. Erasing is class-agnostic — it removes from whichever mask is under the brush. (This class-aware joining differs from the `polygon` brush, which joins any polygon under the brush.) - With the brush off, hovering a mask surfaces the usual edit dialogs: change its class via the ID dialog, or move/delete it like any other spatial annotation. Erasing a mask entirely deprecates the annotation (ULabel's delete semantics). - Requires the `Brush` toolbox item (`AllowedToolboxItem.Brush`) to be present. diff --git a/src/index.js b/src/index.js index 39582764..b9759466 100644 --- a/src/index.js +++ b/src/index.js @@ -4431,13 +4431,18 @@ export class ULabel { } // Find the topmost undeprecated bitmask annotation with foreground under the brush. - find_bitmask_under_brush(imx, imy, radius) { + // Find the topmost undeprecated bitmask annotation with foreground under the brush. + // When `class_id` is provided, only annotations of that class are considered. + find_bitmask_under_brush(imx, imy, radius, class_id = null) { const current_subtask = this.get_current_subtask(); const ordering = current_subtask["annotations"]["ordering"]; const access = current_subtask["annotations"]["access"]; for (let i = ordering.length - 1; i >= 0; i--) { const annotation = access[ordering[i]]; if (annotation["deprecated"] || annotation["spatial_type"] !== "bitmask") continue; + // Optionally only join annotations that match the given class + // (get_annotation_class_id returns a string, so coerce for comparison) + if (class_id !== null && get_annotation_class_id(annotation) !== String(class_id)) continue; if (this.get_bitmask(annotation).has_foreground_in_circle(imx, imy, radius)) { return ordering[i]; } @@ -4477,18 +4482,22 @@ export class ULabel { this.config["image_height"], ); - let target_id = this.find_bitmask_under_brush(imx, imy, radius); + let target_id; let was_new = false; if (is_erase) { + // Erase whatever mask is under the brush, regardless of class + target_id = this.find_bitmask_under_brush(imx, imy, radius); // Nothing to erase under the brush if (target_id === null) { this.move_brush_circle(gmx, gmy); return; } } else { - // Extend whichever bitmask annotation the stroke starts over (option b: - // brushing over an existing mask adds to it). Otherwise, start a new one. + // Only join an existing mask of the currently-selected class; otherwise start + // a new mask of that class (so painting a different class over another mask + // creates a new annotation rather than joining the one underneath). + target_id = this.find_bitmask_under_brush(imx, imy, radius, this.get_active_class_id()); if (target_id === null) { // Don't start a new annotation fully outside the image if (!in_bounds) { From 99b892337c009d438c053d489e9961ae60194400 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Tue, 4 Aug 2026 13:47:51 -0500 Subject: [PATCH 07/13] fix brush keybinds, update tooltips and changelog --- CHANGELOG.md | 14 ++++++++------ src/toolbox.ts | 22 ++++++++-------------- src/toolbox_items/keybinds.ts | 24 ++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7bb575..4fdacb59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,18 +3,20 @@ All notable changes to this project will be documented here. ## [unreleased] -- Add brush **overlap modes** for bitmask painting, controlled globally and persisted to localStorage: `none` (default), `exclude`, and `overwrite`. - - `exclude`: newly-painted pixels never cover pixels owned by other undeprecated bitmask annotations (existing masks win). - - `overwrite`: newly-painted pixels are removed from any other bitmask annotation that owned them (the new mask wins); a mask fully carved away is deprecated. - - Resolution is deferred to the end of a stroke (you see the active mask cover others, then it snaps to the resolved result on release) and only affects the pixels a stroke adds; erase is unaffected. - - Selectable via the Brush toolbox item (shown in bitmask mode) and keybinds `set_brush_overlap_none_keybind` / `set_brush_overlap_exclude_keybind` / `set_brush_overlap_overwrite_keybind` (defaults `shift+n` / `shift+e` / `shift+o`). Initial value configurable via `default_brush_overlap_mode`. ## [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. - - Brushing over an existing bitmask adds to it; brushing empty space starts a new mask. Hover a mask (with the brush off) to change its class via the ID dialog, or move/delete it like any other spatial annotation. + - A paint stroke only joins an existing mask of the currently-selected class; painting a different class (or over empty space) starts a new mask of the selected class. Erase is class-agnostic. This differs from the `polygon` brush, which joins any polygon under the brush. + - Hover a mask (with the brush off) to change its class via the ID dialog, or move/delete it like any other spatial annotation. - Each bitmask annotation stores a single binary mask. On export, `spatial_payload` is a COCO-style run-length-encoded object: `{ "counts": , "size": [, ] }` (column-major, starting with a background run). Fully-erased masks are deprecated (ULabel's delete semantics). - Configurable render opacity for bitmask classes via `mask_annotation_opacity` (default `0.2`). +- Add brush **overlap modes** for bitmask painting, controlled globally and persisted to localStorage: `none` (default), `exclude`, and `overwrite`. + - `exclude`: newly-painted pixels never cover pixels owned by other undeprecated bitmask annotations (existing masks win). + - `overwrite`: newly-painted pixels are removed from any other bitmask annotation that owned them (the new mask wins); a mask fully carved away is deprecated. + - Resolution is deferred to the end of a stroke (you see the active mask cover others, then it snaps to the resolved result on release) and only affects the pixels a stroke adds; erase is unaffected. + - Selectable via the Brush toolbox item (shown in bitmask mode) and keybinds `set_brush_overlap_none_keybind` / `set_brush_overlap_exclude_keybind` / `set_brush_overlap_overwrite_keybind` (defaults `shift+n` / `shift+e` / `shift+o`), which also appear in the `Keybinds` toolbox item. Initial value configurable via `default_brush_overlap_mode`. + - Brush toolbox buttons have hover tooltips. ## [0.24.0] - July 22nd, 2026 - Add `ConfidenceSlider` toolbox item (`AllowedToolboxItem.ConfidenceSlider`) that deprecates (hides) or shows spatial annotations based on their confidence values. Unlike the now-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. diff --git a/src/toolbox.ts b/src/toolbox.ts index a1f679fb..a2e5103a 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -657,12 +657,6 @@ export class BrushToolboxItem extends ToolboxItem { text-align: center; } - #toolbox div.brush div.brush-overlap .brush-overlap-label { - display: block; - font-size: 0.8rem; - margin-bottom: 0.25rem; - } - #toolbox div.brush div.brush-overlap span.brush-overlap-buttons { display: flex; justify-content: center; @@ -724,20 +718,20 @@ export class BrushToolboxItem extends ToolboxItem {

Brush Tool

- - + + - - + +
- Overlap +

Overlap

- - - + + +
diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index bc8a26ba..a5b56c48 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -380,6 +380,30 @@ export class KeybindsToolboxItem extends ToolboxItem { config_key: "decrease_brush_size_keybind", }); + keybinds.push({ + key: config.set_brush_overlap_none_keybind, + label: "Brush: None", + description: "Set the bitmask brush overlap mode to none", + configurable: true, + config_key: "set_brush_overlap_none_keybind", + }); + + keybinds.push({ + key: config.set_brush_overlap_exclude_keybind, + label: "Brush: Exclude", + description: "Set the bitmask brush overlap mode to exclude", + configurable: true, + config_key: "set_brush_overlap_exclude_keybind", + }); + + keybinds.push({ + key: config.set_brush_overlap_overwrite_keybind, + label: "Brush: Overwrite", + description: "Set the bitmask brush overlap mode to overwrite", + configurable: true, + config_key: "set_brush_overlap_overwrite_keybind", + }); + keybinds.push({ key: config.fly_to_next_annotation_keybind, label: "Next Annotation", From 2f020277c3eae18a640c02e4dca00d5aa2fcd465 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Tue, 4 Aug 2026 13:54:46 -0500 Subject: [PATCH 08/13] add bitmask save/load test --- src/version.js | 2 +- tests/annotation.test.js | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/version.js b/src/version.js index 386b2d32..1a902fc9 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const ULABEL_VERSION = "0.24.0"; +export const ULABEL_VERSION = "0.25.0"; diff --git a/tests/annotation.test.js b/tests/annotation.test.js index 3938c472..c1fc1814 100644 --- a/tests/annotation.test.js +++ b/tests/annotation.test.js @@ -1,5 +1,6 @@ // Tests for annotation processing and manipulation const { ULabel } = require("./testing-utils/build_loader"); +const { ULabelMask } = require("../build/mask_utils"); describe("Annotation Processing", () => { let mock_config; @@ -72,6 +73,57 @@ describe("Annotation Processing", () => { expect(annotation.deprecated).toBe(false); }); + test("should round-trip a bitmask annotation through resume_from without data loss", () => { + // Build a mask and encode it to an RLE payload (the "saved" form) + const mask = ULabelMask.create_empty(8, 6); + mask.paint_circle(4, 3, 2, 1); + mask.set_pixel(0, 0, 1); + mask.set_pixel(7, 5, 1); + const saved_payload = mask.to_rle(); + + const resume_config = { + ...mock_config, + subtasks: { + test_task: { + ...mock_config.subtasks.test_task, + allowed_modes: ["bbox", "polygon", "point", "bitmask"], + resume_from: [ + { + spatial_type: "bitmask", + // Deep copy so the input isn't mutated by processing + spatial_payload: JSON.parse(JSON.stringify(saved_payload)), + classification_payloads: [{ class_id: 1, confidence: 1.0 }], + }, + ], + }, + }, + }; + + const ulabel_with_resume = new ULabel(resume_config); + const annotations = ulabel_with_resume.subtasks.test_task.annotations; + + expect(annotations.ordering).toHaveLength(1); + const annotation = annotations.access[annotations.ordering[0]]; + expect(annotation.spatial_type).toBe("bitmask"); + expect(annotation.deprecated).toBe(false); + + // Emulate an export/save: JSON round-trip strips the non-enumerable `_mask` cache + const exported = JSON.parse(JSON.stringify(annotation)); + + // The RLE payload survived the load unchanged (no data lost) + expect(exported.spatial_payload.size).toEqual(saved_payload.size); + expect(exported.spatial_payload.counts).toEqual(saved_payload.counts); + // The runtime mask cache must not leak into the export + expect(exported._mask).toBeUndefined(); + + // The decoded mask matches the original pixel-for-pixel + const restored = ULabelMask.from_rle(exported.spatial_payload); + expect(Array.from(restored.data)).toEqual(Array.from(mask.data)); + + // The containing box was rebuilt from the mask's foreground bounds + expect(annotation.containing_box).toEqual({ tlx: 0, tly: 0, brx: 7, bry: 5 }); + }); + test("should throw an error for missing spatial_type", () => { const invalid_resume_config = { ...mock_config, From f360a53c0a819451eae4c81ba7480fd1850794d3 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 5 Aug 2026 11:09:06 -0500 Subject: [PATCH 09/13] fix comments from review, rle validation on load and better redraw performance --- CHANGELOG.md | 2 +- demo/bitmask-example.html | 2 +- index.d.ts | 1 + src/actions.ts | 3 + src/annotation.ts | 10 +++ src/index.js | 143 +++++++++++++++++++++++++++++++------- src/mask_utils.ts | 45 +++++++++++- tests/annotation.test.js | 24 +++++++ tests/mask_utils.test.js | 47 +++++++++++++ 9 files changed, 248 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fdacb59..52a374dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to this project will be documented here. - A paint stroke only joins an existing mask of the currently-selected class; painting a different class (or over empty space) starts a new mask of the selected class. Erase is class-agnostic. This differs from the `polygon` brush, which joins any polygon under the brush. - Hover a mask (with the brush off) to change its class via the ID dialog, or move/delete it like any other spatial annotation. - Each bitmask annotation stores a single binary mask. On export, `spatial_payload` is a COCO-style run-length-encoded object: `{ "counts": , "size": [, ] }` (column-major, starting with a background run). Fully-erased masks are deprecated (ULabel's delete semantics). - - Configurable render opacity for bitmask classes via `mask_annotation_opacity` (default `0.2`). + - Configurable render opacity for bitmask classes via `mask_annotation_opacity`. - Add brush **overlap modes** for bitmask painting, controlled globally and persisted to localStorage: `none` (default), `exclude`, and `overwrite`. - `exclude`: newly-painted pixels never cover pixels owned by other undeprecated bitmask annotations (existing masks win). - `overwrite`: newly-painted pixels are removed from any other bitmask annotation that owned them (the new mask wins); a mask fully carved away is deprecated. diff --git a/demo/bitmask-example.html b/demo/bitmask-example.html index a8d40cb9..babd2792 100644 --- a/demo/bitmask-example.html +++ b/demo/bitmask-example.html @@ -44,7 +44,7 @@ "id": 11 } ], - "allowed_modes": ["bitmask", "polygon"], + "allowed_modes": ["bitmask", "polygon", "delete_bbox"], "resume_from": null, "task_meta": null, "annotation_meta": null diff --git a/index.d.ts b/index.d.ts index 717d1492..f1808485 100644 --- a/index.d.ts +++ b/index.d.ts @@ -247,6 +247,7 @@ export type ULabelActionType = "create_nonspatial_annotation" | "simplify_polygon_complex_layer" | "begin_brush" | "continue_brush" | + "continue_bitmask" | "bitmask_stroke" | "finish_modify_annotation" | "assign_annotation_id"; diff --git a/src/actions.ts b/src/actions.ts index bc6a5bb2..0c612df5 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -222,6 +222,9 @@ function trigger_action_listeners( continue_brush: { action: on_in_progress_annotation_spatial_modification, }, + continue_bitmask: { + action: on_in_progress_annotation_spatial_modification, + }, continue_annotation: { action: on_in_progress_annotation_spatial_modification, }, diff --git a/src/annotation.ts b/src/annotation.ts index d69425ac..7637b53d 100644 --- a/src/annotation.ts +++ b/src/annotation.ts @@ -6,6 +6,7 @@ import type { ULabelSpatialType, } from "../index"; import { GeometricUtils } from "./geometric_utils"; +import { ULabelMask } from "./mask_utils"; import { log_message, LogLevel } from "./error_logging"; // Modes used to draw an area in the which to delete all annotations @@ -121,6 +122,15 @@ export class ULabelAnnotation { // ensure polygon spatial_payloads are updated to support complex polygons public ensure_compatible_spatial_payloads() { + if (this.spatial_type === "bitmask") { + // Reject malformed / corrupt raster payloads, surfacing the specific reason + try { + ULabelMask.validate_rle(this.spatial_payload); + } catch (error) { + log_message(`Skipping bitmask annotation id ${this.id}: ${(error as Error).message}`, LogLevel.WARNING, true); + return false; + } + } if (this.spatial_type === "polygon") { // Catch empty spatial payloads if (this.spatial_payload === undefined || this.spatial_payload.length === 0) { diff --git a/src/index.js b/src/index.js index b9759466..2ac973c3 100644 --- a/src/index.js +++ b/src/index.js @@ -1758,7 +1758,7 @@ export class ULabel { let mask; const payload = annotation_object["spatial_payload"]; if (payload != null && payload["counts"] !== undefined) { - mask = ULabelMask.from_rle(payload); + mask = ULabelMask.from_rle(payload, false); } else { mask = ULabelMask.create_empty(this.config["image_width"], this.config["image_height"]); } @@ -1782,7 +1782,7 @@ export class ULabel { set_bitmask_from_rle(annotation_object, rle) { let mask; if (rle != null && rle["counts"] !== undefined) { - mask = ULabelMask.from_rle(rle); + mask = ULabelMask.from_rle(rle, false); } else { mask = ULabelMask.create_empty(this.config["image_width"], this.config["image_height"]); } @@ -1790,8 +1790,66 @@ export class ULabel { return mask; } + // Attach an incremental containing-box hint to a bitmask annotation as a non-enumerable + // property (like `_mask`) so it is excluded from serialization. + set_bitmask_box_hint(annotation_object, hint) { + Object.defineProperty(annotation_object, "_bitmask_box_hint", { + value: hint, + enumerable: false, + writable: true, + configurable: true, + }); + } + + // Compute the clamped, integer image-space bounding box of a single brush dab + // (a circle, or the capsule swept between the previous and current dab centers). + get_bitmask_dab_box(prev_point, imx, imy, radius) { + const image_width = this.config["image_width"]; + const image_height = this.config["image_height"]; + let min_x = imx - radius; + let max_x = imx + radius; + let min_y = imy - radius; + let max_y = imy + radius; + if (prev_point !== null) { + min_x = Math.min(min_x, prev_point[0] - radius); + max_x = Math.max(max_x, prev_point[0] + radius); + min_y = Math.min(min_y, prev_point[1] - radius); + max_y = Math.max(max_y, prev_point[1] + radius); + } + return { + tlx: Math.max(0, Math.floor(min_x)), + tly: Math.max(0, Math.floor(min_y)), + brx: Math.min(image_width - 1, Math.ceil(max_x)), + bry: Math.min(image_height - 1, Math.ceil(max_y)), + }; + } + // Recompute a bitmask annotation's containing box from its mask. + // If an incremental hint was left on the annotation during an in-progress stroke, + // apply it in O(1) instead of rescanning the whole mask: + // - { skip: true }: leave the (superset) box unchanged (used by erase dabs) + // - a box { tlx, tly, brx, bry }: union it with the existing box (paint dabs) rebuild_bitmask_containing_box(annotation_object) { + const hint = annotation_object["_bitmask_box_hint"]; + if (hint != null) { + // Clear the hint so subsequent full rebuilds (e.g. at stroke end) still run. + annotation_object["_bitmask_box_hint"] = null; + if (hint.skip) { + return; + } + const existing = annotation_object["containing_box"]; + if (existing != null) { + annotation_object["containing_box"] = { + tlx: Math.min(existing.tlx, hint.tlx), + tly: Math.min(existing.tly, hint.tly), + brx: Math.max(existing.brx, hint.brx), + bry: Math.max(existing.bry, hint.bry), + }; + } else { + annotation_object["containing_box"] = { tlx: hint.tlx, tly: hint.tly, brx: hint.brx, bry: hint.bry }; + } + return; + } const bbox = this.get_bitmask(annotation_object).get_bounding_box(); if (bbox === null) { annotation_object["containing_box"] = null; @@ -1815,21 +1873,43 @@ export class ULabel { const mask = this.get_bitmask(annotation_object); if (mask === null || image_width == null || image_height == null) return; - // Build an opaque white stencil of the mask at native image resolution + // 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 + } + + // 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; + + // Build an opaque white stencil of just the box region at native resolution const offscreen = document.createElement("canvas"); - offscreen.width = image_width; - offscreen.height = image_height; + offscreen.width = box_width; + offscreen.height = box_height; const offscreen_ctx = offscreen.getContext("2d"); - const image_data = offscreen_ctx.createImageData(image_width, image_height); + const image_data = offscreen_ctx.createImageData(box_width, box_height); const data = image_data.data; const mask_data = mask.data; - for (let i = 0; i < mask_data.length; i++) { - if (mask_data[i] !== 0) { - const j = i * 4; - data[j] = 255; - data[j + 1] = 255; - data[j + 2] = 255; - data[j + 3] = 255; + for (let y = tly; y <= bry; y++) { + const mask_row = y * image_width; + const local_row = (y - tly) * box_width; + for (let x = tlx; x <= brx; x++) { + if (mask_data[mask_row + x] !== 0) { + const j = (local_row + (x - tlx)) * 4; + data[j] = 255; + data[j + 1] = 255; + data[j + 2] = 255; + data[j + 3] = 255; + } } } offscreen_ctx.putImageData(image_data, 0, 0); @@ -1838,9 +1918,9 @@ export class ULabel { // 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.fillRect(0, 0, image_width, image_height); + offscreen_ctx.fillRect(0, 0, box_width, box_height); - // Draw the native-resolution mask scaled onto the target context, honoring any offset + // 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) { @@ -1852,10 +1932,10 @@ export class ULabel { ctx.globalAlpha = this.config["mask_annotation_opacity"]; ctx.drawImage( offscreen, - diffX * px_per_px, - diffY * px_per_px, - image_width * px_per_px, - image_height * px_per_px, + (tlx + diffX) * px_per_px, + (tly + diffY) * px_per_px, + box_width * px_per_px, + box_height * px_per_px, ); ctx.globalAlpha = 1.0; } @@ -4430,7 +4510,6 @@ export class ULabel { return annotation_id; } - // Find the topmost undeprecated bitmask annotation with foreground under the brush. // Find the topmost undeprecated bitmask annotation with foreground under the brush. // When `class_id` is provided, only annotations of that class are considered. find_bitmask_under_brush(imx, imy, radius, class_id = null) { @@ -4556,16 +4635,32 @@ export class ULabel { const radius = this.config["brush_size"] / 2; const value = current_subtask["state"]["is_in_erase_mode"] ? 0 : 1; + const prev_point = stroke.last_point; let changed; - if (stroke.last_point !== null) { - changed = this.paint_bitmask_line(mask, stroke.last_point[0], stroke.last_point[1], imx, imy, radius, value); + if (prev_point !== null) { + changed = this.paint_bitmask_line(mask, prev_point[0], prev_point[1], imx, imy, radius, value); } else { changed = mask.paint_circle(imx, imy, radius, value); } stroke.last_point = [imx, imy]; if (changed) { - this.redraw_annotation(active_id); + // Provide an incremental containing-box hint so the action listener can update + // the box in O(1) instead of rescanning the whole mask on each dab. + if (value === 0) { + // Erasing can only shrink the box; keep the current (superset) box until the + // stroke ends, when a full rebuild runs. + this.set_bitmask_box_hint(annotation, { skip: true }); + } else { + this.set_bitmask_box_hint(annotation, this.get_bitmask_dab_box(prev_point, imx, imy, radius)); + } + record_action(this, { + act_type: "continue_bitmask", + annotation_id: active_id, + frame: this.state["current_frame"], + undo_payload: {}, + redo_payload: {}, + }, false, false); } } @@ -4592,7 +4687,7 @@ export class ULabel { if (!stroke.is_erase && overlap_mode !== "none") { // Compute the pixels this stroke added (delta = current AND NOT before) const before_mask = stroke.before_rle != null ? - ULabelMask.from_rle(stroke.before_rle) : + ULabelMask.from_rle(stroke.before_rle, false) : ULabelMask.create_empty(this.config["image_width"], this.config["image_height"]); const delta = mask.clone(); delta.subtract(before_mask); diff --git a/src/mask_utils.ts b/src/mask_utils.ts index 54ecbc43..25bdabc7 100644 --- a/src/mask_utils.ts +++ b/src/mask_utils.ts @@ -245,17 +245,56 @@ export class ULabelMask { }; } + // Validate a run-length payload before decoding. Throws with a descriptive + // message on any malformed shape. Used to guard against corrupt/untrusted + // imported (`resume_from`) data producing a silently partial mask. + public static validate_rle(payload: unknown): void { + if (payload === null || typeof payload !== "object") { + throw new Error("Invalid RLE payload: expected an object with `counts` and `size`"); + } + const p = payload as { size?: unknown; counts?: unknown }; + const size = p.size; + if ( + !Array.isArray(size) || + size.length !== 2 || + !Number.isInteger(size[0]) || + !Number.isInteger(size[1]) || + size[0] < 0 || + size[1] < 0 + ) { + throw new Error(`Invalid RLE size: expected [height, width] of non-negative integers, got ${JSON.stringify(size)}`); + } + const counts = p.counts; + if (!Array.isArray(counts)) { + throw new Error("Invalid RLE counts: expected an array of run lengths"); + } + const total = size[0] * size[1]; + let sum = 0; + for (let i = 0; i < counts.length; i++) { + const run = counts[i]; + if (typeof run !== "number" || !Number.isInteger(run) || run < 0) { + throw new Error(`Invalid RLE run length at index ${i}: expected a non-negative integer, got ${run}`); + } + sum += run; + } + if (sum !== total) { + throw new Error(`Invalid RLE: run lengths sum to ${sum} but mask has ${total} pixels (${size[0]}x${size[1]})`); + } + } + // Decode a COCO-style RLE payload into a mask. - public static from_rle(payload: ULabelMaskPayload): ULabelMask { + public static from_rle(payload: ULabelMaskPayload, validate: boolean = true): ULabelMask { + if (validate) { + ULabelMask.validate_rle(payload); + } const [height, width] = payload.size; const mask = new ULabelMask(width, height); let idx = 0; // column-major index let value = 0; - const total = width * height; for (let c = 0; c < payload.counts.length; c++) { const run = payload.counts[c]; if (value === 1) { - for (let k = 0; k < run && idx < total; k++) { + for (let k = 0; k < run; k++) { const col_idx = idx + k; const x = Math.floor(col_idx / height); const y = col_idx % height; diff --git a/tests/annotation.test.js b/tests/annotation.test.js index c1fc1814..230e0e82 100644 --- a/tests/annotation.test.js +++ b/tests/annotation.test.js @@ -124,6 +124,30 @@ describe("Annotation Processing", () => { expect(annotation.containing_box).toEqual({ tlx: 0, tly: 0, brx: 7, bry: 5 }); }); + test("should skip a bitmask annotation with a malformed RLE payload", () => { + const resume_config = { + ...mock_config, + subtasks: { + test_task: { + ...mock_config.subtasks.test_task, + allowed_modes: ["bbox", "polygon", "point", "bitmask"], + resume_from: [ + { + spatial_type: "bitmask", + // Counts under-run the 8x6 mask (sum 5 != 48) + spatial_payload: { counts: [1, 4], size: [6, 8] }, + classification_payloads: [{ class_id: 1, confidence: 1.0 }], + }, + ], + }, + }, + }; + + const ulabel_with_resume = new ULabel(resume_config); + // The malformed annotation is skipped rather than partially decoded + expect(ulabel_with_resume.subtasks.test_task.annotations.ordering).toHaveLength(0); + }); + test("should throw an error for missing spatial_type", () => { const invalid_resume_config = { ...mock_config, diff --git a/tests/mask_utils.test.js b/tests/mask_utils.test.js index 3bf3faff..1103cefd 100644 --- a/tests/mask_utils.test.js +++ b/tests/mask_utils.test.js @@ -227,4 +227,51 @@ describe("ULabelMask", () => { expect(() => a.subtract(b)).toThrow(); }); }); + + describe("RLE validation", () => { + // A valid 2x2 mask: one background pixel then three foreground + const valid = { counts: [1, 3], size: [2, 2] }; + + test("accepts a well-formed payload", () => { + expect(() => ULabelMask.validate_rle(valid)).not.toThrow(); + expect(() => ULabelMask.from_rle(valid)).not.toThrow(); + }); + + test("rejects a non-object payload", () => { + expect(() => ULabelMask.validate_rle(null)).toThrow(); + expect(() => ULabelMask.validate_rle(42)).toThrow(); + expect(() => ULabelMask.from_rle(null)).toThrow(); + }); + + test("rejects a malformed size", () => { + expect(() => ULabelMask.validate_rle({ counts: [4], size: [2] })).toThrow(); + expect(() => ULabelMask.validate_rle({ counts: [4], size: [2, -2] })).toThrow(); + expect(() => ULabelMask.validate_rle({ counts: [4], size: [2, 2.5] })).toThrow(); + expect(() => ULabelMask.validate_rle({ counts: [4], size: "2x2" })).toThrow(); + }); + + test("rejects non-array counts", () => { + expect(() => ULabelMask.validate_rle({ counts: 4, size: [2, 2] })).toThrow(); + }); + + test("rejects negative run lengths", () => { + expect(() => ULabelMask.validate_rle({ counts: [-1, 5], size: [2, 2] })).toThrow(); + expect(() => ULabelMask.from_rle({ counts: [-1, 5], size: [2, 2] })).toThrow(); + }); + + test("rejects non-integer run lengths", () => { + expect(() => ULabelMask.validate_rle({ counts: [1.5, 2.5], size: [2, 2] })).toThrow(); + expect(() => ULabelMask.from_rle({ counts: [1.5, 2.5], size: [2, 2] })).toThrow(); + }); + + test("rejects counts that under-run the mask size", () => { + expect(() => ULabelMask.validate_rle({ counts: [1, 1], size: [2, 2] })).toThrow(); + expect(() => ULabelMask.from_rle({ counts: [1, 1], size: [2, 2] })).toThrow(); + }); + + test("rejects counts that over-run the mask size", () => { + expect(() => ULabelMask.validate_rle({ counts: [1, 99], size: [2, 2] })).toThrow(); + expect(() => ULabelMask.from_rle({ counts: [1, 99], size: [2, 2] })).toThrow(); + }); + }); }); From e7a8c8c6f500cbe72c28605e7e038642be29882d Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 5 Aug 2026 11:17:11 -0500 Subject: [PATCH 10/13] handle bitmask in delete modes --- src/index.js | 17 ++++++++++++++ src/mask_utils.ts | 50 ++++++++++++++++++++++++++++++++++++++++ tests/mask_utils.test.js | 47 +++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/src/index.js b/src/index.js index 2ac973c3..1b37950e 100644 --- a/src/index.js +++ b/src/index.js @@ -2947,6 +2947,23 @@ export class ULabel { } break; + // Erase the delete polygon's region from the raster mask + case "bitmask": { + const mask = this.get_bitmask(annotation); + if (mask.subtract_polygon(delete_polygon)) { + annotation["spatial_payload"] = mask.to_rle(); + if (mask.is_empty()) { + mark_deprecated(annotation, true); + } + this.rebuild_bitmask_containing_box(annotation); + // A full-annotation copy (taken above, pre-mutation) restores the + // original mask and deprecation state on undo. + modified_annotations[annid] = JSON.parse(JSON.stringify(og_annotation)); + needs_redraw = true; + } + break; + } + // TODO: handle other spatial types } // Redraw if needed diff --git a/src/mask_utils.ts b/src/mask_utils.ts index 25bdabc7..4ed860ca 100644 --- a/src/mask_utils.ts +++ b/src/mask_utils.ts @@ -190,6 +190,56 @@ export class ULabelMask { return changed; } + // Erase (set to 0) every foreground pixel whose integer coordinate falls inside the + // given simple polygon (a single ring of [x, y] image-space points). Uses an even-odd + // scanline fill and only touches the polygon's vertical extent. Returns true if any + // 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; + + // Restrict work to the polygon's vertical extent, clamped to the image. + let min_py = Infinity; + let max_py = -Infinity; + for (let i = 0; i < polygon.length; i++) { + const py = polygon[i][1]; + if (py < min_py) min_py = py; + if (py > max_py) max_py = py; + } + const y_start = Math.max(0, Math.ceil(min_py)); + const y_end = Math.min(this.height - 1, Math.floor(max_py)); + + let changed = false; + const n = polygon.length; + const xs: number[] = []; + for (let y = y_start; y <= y_end; y++) { + // Collect x-intersections of polygon edges with the horizontal line at this row. + xs.length = 0; + for (let i = 0, j = n - 1; i < n; j = i++) { + const yi = polygon[i][1]; + const yj = polygon[j][1]; + if ((yi > y) !== (yj > y)) { + const xi = polygon[i][0]; + const xj = polygon[j][0]; + xs.push(xi + ((y - yi) / (yj - yi)) * (xj - xi)); + } + } + if (xs.length < 2) continue; + xs.sort((a, b) => a - b); + const row = y * this.width; + for (let k = 0; k + 1 < xs.length; k += 2) { + const x_start = Math.max(0, Math.ceil(xs[k])); + const x_end = Math.min(this.width - 1, Math.floor(xs[k + 1])); + for (let x = x_start; x <= x_end; x++) { + if (this.data[row + x] !== 0) { + this.data[row + x] = 0; + changed = true; + } + } + } + } + return changed; + } + // Add another mask's foreground into this one (this = this OR other). public add_mask(other: ULabelMask): void { this.assert_same_dims(other); diff --git a/tests/mask_utils.test.js b/tests/mask_utils.test.js index 1103cefd..ae32d913 100644 --- a/tests/mask_utils.test.js +++ b/tests/mask_utils.test.js @@ -228,6 +228,53 @@ describe("ULabelMask", () => { }); }); + describe("subtract_polygon", () => { + test("erases foreground pixels inside a simple polygon", () => { + const mask = ULabelMask.create_empty(10, 10); + // Fill a 4x4 block from (2,2) to (5,5) + for (let y = 2; y <= 5; y++) { + for (let x = 2; x <= 5; x++) { + mask.set_pixel(x, y, 1); + } + } + // Delete polygon covering the block's lower-right quadrant + const polygon = [[3, 3], [6, 3], [6, 6], [3, 6], [3, 3]]; + const changed = mask.subtract_polygon(polygon); + expect(changed).toBe(true); + // Inside the delete polygon: erased + expect(mask.get_pixel(4, 4)).toBe(0); + expect(mask.get_pixel(5, 5)).toBe(0); + // Outside the delete polygon: preserved + expect(mask.get_pixel(2, 2)).toBe(1); + expect(mask.get_pixel(2, 5)).toBe(1); + }); + + test("returns false when the polygon covers no foreground", () => { + const mask = ULabelMask.create_empty(10, 10); + mask.set_pixel(1, 1, 1); + const polygon = [[5, 5], [8, 5], [8, 8], [5, 8], [5, 5]]; + expect(mask.subtract_polygon(polygon)).toBe(false); + expect(mask.get_pixel(1, 1)).toBe(1); + }); + + test("can erase an entire mask", () => { + const mask = ULabelMask.create_empty(6, 6); + mask.set_pixel(2, 2, 1); + mask.set_pixel(3, 3, 1); + // Polygon covering the whole image + const polygon = [[-1, -1], [7, -1], [7, 7], [-1, 7], [-1, -1]]; + expect(mask.subtract_polygon(polygon)).toBe(true); + expect(mask.is_empty()).toBe(true); + }); + + test("returns false for a degenerate polygon", () => { + const mask = ULabelMask.create_empty(6, 6); + mask.set_pixel(2, 2, 1); + expect(mask.subtract_polygon([[2, 2], [3, 2]])).toBe(false); + expect(mask.get_pixel(2, 2)).toBe(1); + }); + }); + describe("RLE validation", () => { // A valid 2x2 mask: one background pixel then three foreground const valid = { counts: [1, 3], size: [2, 2] }; From 11715f02cfc7957cb8cf05a1a3e393ae3e881d7c Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 5 Aug 2026 11:34:52 -0500 Subject: [PATCH 11/13] refactor brush mode localstorage --- src/index.js | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/index.js b/src/index.js index 1b37950e..597030b2 100644 --- a/src/index.js +++ b/src/index.js @@ -31,6 +31,7 @@ import { log_message, LogLevel } from "../build/error_logging"; import { initialize_annotation_canvases } from "../build/canvas_utils"; import { record_action, record_finish, record_finish_edit, record_finish_move, undo, redo } from "../build/actions"; import { ULabelMask } from "../build/mask_utils"; +import { get_local_storage_item, set_local_storage_item } from "../build/utilities"; import $ from "jquery"; const jQuery = $; @@ -55,11 +56,10 @@ jQuery.fn.outer_html = function () { return jQuery("
").append(this.eq(0).clone()).html(); }; -export class ULabel { - // Valid brush overlap modes and the localStorage key used to persist the global choice - static BRUSH_OVERLAP_MODES = ["none", "exclude", "overwrite"]; - static BRUSH_OVERLAP_STORAGE_KEY = "ulabel_brush_overlap_mode"; +// Valid brush overlap modes for bitmask painting (see set_brush_overlap_mode). +const BRUSH_OVERLAP_MODES = ["none", "exclude", "overwrite"]; +export class ULabel { static version() { return ULABEL_VERSION; } @@ -2526,13 +2526,9 @@ export class ULabel { // Load the global brush overlap mode from localStorage, falling back to the config default. load_brush_overlap_mode() { let mode = this.config["default_brush_overlap_mode"]; - try { - const stored = window.localStorage.getItem(ULabel.BRUSH_OVERLAP_STORAGE_KEY); - if (stored !== null && ULabel.BRUSH_OVERLAP_MODES.includes(stored)) { - mode = stored; - } - } catch { - // localStorage may be unavailable; fall back to the config default + const stored = get_local_storage_item("ulabel_brush_overlap_mode"); + if (stored !== null && BRUSH_OVERLAP_MODES.includes(stored)) { + mode = stored; } this.config["brush_overlap_mode"] = mode; } @@ -2543,16 +2539,12 @@ export class ULabel { // Set the global brush overlap mode, persist it, and update the toolbox buttons. set_brush_overlap_mode(mode) { - if (!ULabel.BRUSH_OVERLAP_MODES.includes(mode)) { + if (!BRUSH_OVERLAP_MODES.includes(mode)) { log_message(`Invalid brush overlap mode: ${mode}`, LogLevel.WARNING); return; } this.config["brush_overlap_mode"] = mode; - try { - window.localStorage.setItem(ULabel.BRUSH_OVERLAP_STORAGE_KEY, mode); - } catch { - // Ignore persistence failures (e.g. localStorage unavailable) - } + set_local_storage_item("ulabel_brush_overlap_mode", mode); BrushToolboxItem.update_overlap_mode_buttons(mode); } From 7aef03a2eb77b46125d63e3d39a82dde0744c14f Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 5 Aug 2026 11:44:08 -0500 Subject: [PATCH 12/13] add bitmask to live demo --- demo/live_demo.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/live_demo.html b/demo/live_demo.html index b821f634..d7727c58 100644 --- a/demo/live_demo.html +++ b/demo/live_demo.html @@ -146,7 +146,7 @@ "id": 11 } ], - "allowed_modes": ["point", "bbox", "polygon", "contour", "polyline", "tbar", "delete_bbox", "delete_polygon"], + "allowed_modes": ["point", "bbox", "polygon", "bitmask", "contour", "polyline", "tbar", "delete_bbox", "delete_polygon"], "resume_from": resume_from, "task_meta": null, "annotation_meta": null From f6fa1602c2d5d5b7beb7b19c8247711da0fa606a Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 5 Aug 2026 12:05:34 -0500 Subject: [PATCH 13/13] add demo bitmask resume from --- demo/live_demo.html | 620 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 620 insertions(+) diff --git a/demo/live_demo.html b/demo/live_demo.html index d7727c58..5ab839a1 100644 --- a/demo/live_demo.html +++ b/demo/live_demo.html @@ -116,6 +116,626 @@ {"class_id": 10, "confidence": 0.65}, ], }, + { + "classification_payloads": [ + {"class_id": 10, "confidence": 0.8}, + ], + "id": "d015d5ca-6f8f-4f40-b9a0-f1347c353c25", + "spatial_payload": { + "counts": [ + 446831, + 40, + 982, + 45, + 977, + 50, + 971, + 54, + 958, + 7, + 2, + 58, + 955, + 71, + 951, + 76, + 947, + 77, + 946, + 78, + 946, + 77, + 946, + 78, + 946, + 78, + 946, + 77, + 946, + 77, + 947, + 76, + 948, + 76, + 948, + 75, + 949, + 75, + 902, + 1, + 46, + 75, + 890, + 30, + 17, + 87, + 889, + 134, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 133, + 891, + 133, + 891, + 132, + 892, + 132, + 892, + 133, + 891, + 133, + 891, + 133, + 891, + 132, + 892, + 133, + 891, + 133, + 891, + 133, + 891, + 133, + 891, + 133, + 891, + 133, + 891, + 133, + 891, + 133, + 891, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 134, + 890, + 135, + 889, + 135, + 889, + 135, + 889, + 135, + 889, + 136, + 888, + 136, + 888, + 137, + 887, + 138, + 886, + 139, + 885, + 139, + 885, + 140, + 884, + 141, + 883, + 142, + 882, + 142, + 883, + 142, + 882, + 143, + 882, + 143, + 881, + 143, + 881, + 144, + 880, + 145, + 879, + 145, + 879, + 146, + 878, + 146, + 878, + 147, + 877, + 148, + 876, + 148, + 876, + 149, + 875, + 150, + 874, + 151, + 873, + 152, + 872, + 153, + 871, + 154, + 870, + 155, + 869, + 156, + 867, + 158, + 867, + 158, + 866, + 159, + 864, + 161, + 863, + 162, + 862, + 163, + 861, + 164, + 860, + 164, + 860, + 165, + 860, + 174, + 851, + 177, + 847, + 180, + 844, + 182, + 843, + 183, + 841, + 185, + 839, + 188, + 836, + 192, + 832, + 195, + 829, + 197, + 827, + 199, + 825, + 201, + 823, + 202, + 822, + 204, + 821, + 205, + 819, + 206, + 818, + 207, + 818, + 208, + 816, + 209, + 815, + 210, + 814, + 211, + 813, + 212, + 813, + 213, + 811, + 214, + 810, + 215, + 809, + 215, + 809, + 216, + 809, + 215, + 809, + 215, + 809, + 215, + 809, + 215, + 809, + 215, + 810, + 214, + 810, + 214, + 810, + 214, + 810, + 214, + 810, + 214, + 810, + 214, + 811, + 213, + 811, + 213, + 811, + 213, + 812, + 212, + 812, + 212, + 812, + 211, + 813, + 211, + 813, + 211, + 813, + 210, + 814, + 209, + 816, + 208, + 816, + 208, + 816, + 207, + 817, + 206, + 819, + 205, + 819, + 205, + 819, + 205, + 820, + 201, + 823, + 198, + 826, + 196, + 828, + 161, + 10, + 1, + 853, + 158, + 866, + 157, + 867, + 157, + 867, + 156, + 869, + 155, + 869, + 155, + 869, + 155, + 870, + 154, + 870, + 153, + 871, + 153, + 871, + 153, + 871, + 152, + 873, + 151, + 874, + 151, + 873, + 151, + 873, + 151, + 874, + 150, + 874, + 150, + 874, + 150, + 875, + 149, + 875, + 149, + 875, + 149, + 875, + 149, + 876, + 148, + 876, + 148, + 876, + 148, + 877, + 147, + 877, + 147, + 878, + 146, + 878, + 146, + 878, + 146, + 878, + 146, + 878, + 10, + 6, + 122, + 887, + 6, + 18, + 91, + 1, + 2, + 2, + 2, + 1, + 2, + 925, + 32, + 20, + 31, + 1007, + 11, + 1352297 + ], + "size": [ + 1024, + 2048 + ] + }, + "spatial_type": "bitmask", + } ]; function on_submit(annotations) {