From 58f79be44ecee085a299c0f12cf8fb90bbd0ddc7 Mon Sep 17 00:00:00 2001 From: Elliott Imhoff Date: Mon, 31 Aug 2026 15:21:42 -0500 Subject: [PATCH 1/3] Support cropped bitmasks --- .github/tasks.md | 37 ++ index.d.ts | 11 + src/index.js | 263 ++++++++++--- src/initializer.ts | 13 +- src/mask_utils.ts | 545 ++++++++++++++++++++++----- src/toolbox.ts | 21 +- src/toolbox_items/annotation_list.ts | 3 + tests/mask_utils.test.js | 27 +- 8 files changed, 743 insertions(+), 177 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 826c5841..8e08c5df 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -1,3 +1,40 @@ ## Tasks +Bitmask/segmentation viewer performance work, driven by the model-registry +integration. Items 1/3/4/5 touch this repo; item 2 is model-registry only. + +- [x] 1. Cache the bitmask hover outline on the render object + - `draw_bitmask` allocates a canvas and does 8 dilation blits on every draw + while hovered. Cache on `_mask_render`, which already invalidates on mask + version + color change. +- [x] 2. (model-registry) Drop `wrapperSize` from `viewerKey` + - A container resize currently forces a full ULabel rebuild. +- [x] 3. Windowed `ULabelMask` + - Store pixels for a sub-rectangle instead of the full frame, keeping the + public API in image coordinates. Removes the `objects x width x height` + memory bound, so model-registry can go back to one annotation per Encord + object instead of one merged mask per class. + - Accept an optional `box` on the raw payload so callers can hand over an + already-cropped buffer with no copy. + - Grow the window on paint so editing still works. + - Verified in the browser on run #3: GT renders 99 separate objects on the + densest item sampled (was 1 merged mask per class), item-to-item switching + ~90 ms, heap flat around 1 GB with no OOM. +- [x] 4. Swap subtasks in place instead of rebuilding the instance + - Add `replace_subtasks`, so a GT/Pred/Diff switch reuses the decoded image, + listeners and toolbox rather than running `destroy()` + `init()`. + - Only the annotation set can change: `replace_subtasks` returns `null` when + the subtask shape (keys, allowed modes, class defs) differs, so the caller + knows it still has to rebuild. + - Verified in the browser on run #3: four consecutive confidence-threshold + changes in pred mode produced zero rebuilds (previously one full + `destroy()` + `init()` each). Mode switches on that run still rebuild + because its GT `Row` class is a polyline while pred renders it as a + bitmask, which is a genuine shape change. +- [x] 5. Decode RLE off the main thread + - (model-registry) move `rleRecordToRawMask` into a worker and transfer the + cropped buffers back. + - Verified in the browser: GT still renders after the move, heap 193 MB on a + fresh load, no page errors. + diff --git a/index.d.ts b/index.d.ts index a22ca5c2..01038817 100644 --- a/index.d.ts +++ b/index.d.ts @@ -382,6 +382,17 @@ export class ULabel { public readjust_subtask_opacities(): void; public set_subtask(st_key: string): void; public switch_to_next_subtask(): void; + /** + * Swap in a new set of subtask specs without tearing the instance down, + * reusing the decoded image, canvases, toolbox and listeners. Subtasks + * whose annotations are already loaded are skipped. + * + * Resolves to the keys that were swapped, or to null — leaving the instance + * untouched — when the subtask keys, classes or allowed modes differ from + * what the instance was built with, since those are baked into the DOM and + * event bindings. Callers should rebuild the instance in that case. + */ + public replace_subtasks(subtasks: ULabelSubtasks): Promise; // Annotations public get_annotations(subtask: string): ULabelAnnotation[]; diff --git a/src/index.js b/src/index.js index 74daee59..709754cc 100644 --- a/src/index.js +++ b/src/index.js @@ -60,6 +60,9 @@ jQuery.fn.outer_html = function () { // Valid brush overlap modes for bitmask painting (see set_brush_overlap_mode). const BRUSH_OVERLAP_MODES = ["none", "exclude", "overwrite"]; +// Width, in image pixels, of the contour drawn around a hovered bitmask. +const BITMASK_OUTLINE_BORDER = 2; + export class ULabel { static version() { return ULABEL_VERSION; @@ -371,6 +374,11 @@ export class ULabel { data: new Uint8Array(raw_payload.data), size: [raw_payload.size[0], raw_payload.size[1]], }; + // A cropped payload's box has to survive the clone, or the buffer would be + // misread as full-frame. + if (raw_payload.box !== undefined) { + cloned.spatial_payload.box = { ...raw_payload.box }; + } return cloned; } return JSON.parse(JSON.stringify(raw)); @@ -2108,7 +2116,7 @@ export class ULabel { // the cache; only a mask edit (version bump) or color change forces a rebuild. let render = annotation_object["_mask_render"]; if (render == null || render.mask !== mask || render.version !== mask.version || render.color !== color) { - render = this.build_bitmask_render(annotation_object, mask, color); + render = this.build_bitmask_render(mask, color); if (render === null) return; // Empty mask, nothing to draw this.set_bitmask_render(annotation_object, render); } @@ -2132,53 +2140,53 @@ export class ULabel { ctx.globalAlpha = 1.0; if (this.is_annotation_hovered(annotation_object)) { - // Build a white contour by dilating the mask shape and cutting out the interior - const border = 2; - const ow = render.box_width + border * 2; - const oh = render.box_height + border * 2; - const outline = document.createElement("canvas"); - outline.width = ow; - outline.height = oh; - const octx = outline.getContext("2d"); - const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [-1, 1], [1, -1], [1, 1]]; - for (const [ox, oy] of dirs) { - octx.drawImage(render.canvas, border + ox * border, border + oy * border); - } - octx.globalCompositeOperation = "source-in"; - octx.fillStyle = "white"; - octx.fillRect(0, 0, ow, oh); - octx.globalCompositeOperation = "destination-out"; - octx.drawImage(render.canvas, border, border); - const blit_x = (render.tlx + diffX - border) * px_per_px; - const blit_y = (render.tly + diffY - border) * px_per_px; - ctx.drawImage(outline, blit_x, blit_y, ow * px_per_px, oh * px_per_px); - } - } - - // Build a native-resolution, class-colored stencil for a bitmask's bounding box. - // Returns { canvas, tlx, tly, box_width, box_height, mask, version, color } or null if empty. - build_bitmask_render(annotation_object, mask, color) { - const image_width = this.config["image_width"]; - const image_height = this.config["image_height"]; + const outline = this.get_bitmask_outline(render); + const blit_x = (render.tlx + diffX - BITMASK_OUTLINE_BORDER) * px_per_px; + const blit_y = (render.tly + diffY - BITMASK_OUTLINE_BORDER) * px_per_px; + ctx.drawImage(outline, blit_x, blit_y, outline.width * px_per_px, outline.height * px_per_px); + } + } - // 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 null; + // Build (once per render) a white contour by dilating the mask shape and cutting out the + // interior. Cached on the render, which is already discarded whenever the mask version or + // class color changes, so hovering never re-rasterizes. + get_bitmask_outline(render) { + if (render.outline != null) return render.outline; + + const border = BITMASK_OUTLINE_BORDER; + const ow = render.box_width + border * 2; + const oh = render.box_height + border * 2; + const outline = document.createElement("canvas"); + outline.width = ow; + outline.height = oh; + const octx = outline.getContext("2d"); + const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [-1, 1], [1, -1], [1, 1]]; + for (const [ox, oy] of dirs) { + octx.drawImage(render.canvas, border + ox * border, border + oy * border); } + octx.globalCompositeOperation = "source-in"; + octx.fillStyle = "white"; + octx.fillRect(0, 0, ow, oh); + octx.globalCompositeOperation = "destination-out"; + octx.drawImage(render.canvas, border, border); - 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 null; + render.outline = outline; + return outline; + } - // Build an opaque white stencil of just the box region at native resolution + // Build a native-resolution, class-colored stencil for a bitmask's stored window. + // Returns { canvas, tlx, tly, box_width, box_height, mask, version, color } or null if empty. + build_bitmask_render(mask, color) { + // A mask only holds pixels for its window, which is already a superset of the + // foreground, so the window is exactly the region worth rasterizing. + const box = mask.get_window_box(); + if (box === null) return null; // Empty mask, nothing to draw + + const box_width = mask.window_width; + const box_height = mask.window_height; + + // Stencil the window at native resolution. Only alpha is written; the tint below + // fills color wherever alpha survives, so this is one store per foreground pixel. const offscreen = document.createElement("canvas"); offscreen.width = box_width; offscreen.height = box_height; @@ -2186,17 +2194,9 @@ export class ULabel { const image_data = offscreen_ctx.createImageData(box_width, box_height); const data = image_data.data; const mask_data = mask.data; - 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; - } + for (let i = 0, j = 3; i < mask_data.length; i++, j += 4) { + if (mask_data[i] !== 0) { + data[j] = 255; } } offscreen_ctx.putImageData(image_data, 0, 0); @@ -2209,8 +2209,8 @@ export class ULabel { return { canvas: offscreen, - tlx: tlx, - tly: tly, + tlx: box.tlx, + tly: box.tly, box_width: box_width, box_height: box_height, mask: mask, @@ -7490,6 +7490,155 @@ export class ULabel { this.subtasks[subtask]["state"]["annotation_contexts"] = {}; } + /** + * Whether `subtask_specs` describes the same subtask layer this instance was + * built with, differing only in annotations. + * + * Subtask keys, class definitions and allowed modes are baked into DOM ids, + * toolbox tabs and event bindings at init time, so those must match for an + * in-place swap to be safe. + */ + _subtask_shape_matches(subtask_specs) { + const old_keys = Object.keys(this.subtasks); + const new_keys = Object.keys(subtask_specs); + if (old_keys.length !== new_keys.length) return false; + + for (const st of new_keys) { + const current = this.subtasks[st]; + if (current === undefined) return false; + + const spec = subtask_specs[st]; + const modes = spec["allowed_modes"] ?? []; + if (modes.length !== current["allowed_modes"].length) return false; + for (let i = 0; i < modes.length; i++) { + if (modes[i] !== current["allowed_modes"][i]) return false; + } + + const classes = spec["classes"] ?? []; + const class_defs = current["class_defs"]; + if (classes.length !== class_defs.length) return false; + for (let i = 0; i < classes.length; i++) { + const raw = classes[i]; + // Classes may be given as bare ids, in which case only the id + // can differ from what was processed at init. + const id = typeof raw === "object" ? raw["id"] : raw; + if (id !== class_defs[i]["id"]) return false; + if (typeof raw !== "object") continue; + if (raw["name"] !== undefined && raw["name"] !== class_defs[i]["name"]) return false; + if (raw["color"] !== undefined && raw["color"] !== class_defs[i]["color"]) return false; + } + } + return true; + } + + /** + * Whether `annotations` is the set already loaded on `subtask`, compared by + * id and order. Lets replace_subtasks skip untouched subtasks, so pushing a + * new spec for one class doesn't re-import and redraw its siblings. + */ + _subtask_annotations_unchanged(subtask, annotations) { + const ordering = this.subtasks[subtask]["annotations"]["ordering"]; + if (ordering.length !== annotations.length) return false; + for (let i = 0; i < ordering.length; i++) { + if (ordering[i] !== annotations[i]["id"]) return false; + } + return true; + } + + /** + * Swap in a new set of subtask specs without tearing the instance down. + * + * Reuses the decoded image, the subtask canvases, the toolbox and every + * listener, rebuilding only the annotation layer — far cheaper than + * destroy() + new ULabel() + init() when a host application switches + * between views of the same image. Subtasks whose annotations are already + * loaded are left alone, so pushing a whole spec set costs only the + * subtasks that actually changed. + * + * Only annotations (and `read_only` / `inactive_opacity`) can change this + * way. If the subtask keys, classes or allowed modes differ, the DOM and + * toolbox no longer describe the incoming layer, so this returns false and + * leaves the instance untouched; the caller should rebuild instead. + * + * @param {object} subtask_specs Same shape as the constructor's `subtasks`. + * @returns {Promise} the keys that were swapped, or null if + * the caller must rebuild the instance. + */ + async replace_subtasks(subtask_specs) { + if (this.is_destroyed) { + log_message("replace_subtasks called on a destroyed ULabel instance", LogLevel.WARNING, true); + return null; + } + if (!this.is_init) return null; + if (!this._subtask_shape_matches(subtask_specs)) return null; + + const stale = []; + for (const st in subtask_specs) { + const incoming = subtask_specs[st]["resume_from"] ?? []; + if (!this._subtask_annotations_unchanged(st, incoming)) stale.push(st); + } + if (stale.length === 0) return []; + + const container = document.getElementById(this.config["container_id"]); + ULabelLoader.add_loader_div(container); + // Yield so the browser can paint the loader before the heavy synchronous work below + await ULabelLoader.wait_for_render(); + + // Recheck: destroy() (manual or auto) may have run during the paint yield. + if (this.is_destroyed) return stale; + + try { + for (const st of stale) { + const spec = subtask_specs[st]; + // Undo/redo won't survive a wholesale annotation swap. + this.reset_interaction_state(st); + this.subtasks[st]["actions"]["stream"] = []; + this.subtasks[st]["actions"]["undone_stack"] = []; + this._clear_subtask_annotation_canvases(st); + + ULabel.process_resume_from(this, st, { resume_from: spec["resume_from"] ?? [] }); + + if (spec["read_only"] !== undefined) { + this.subtasks[st]["read_only"] = spec["read_only"]; + } + if (spec["inactive_opacity"] !== undefined) { + this.subtasks[st]["inactive_opacity"] = spec["inactive_opacity"]; + } + // Keep the raw config in step so a later get/set round trip sees + // the annotations that are actually on screen. + this.config.subtasks[st] = spec; + } + + // Yield the event loop so the loader's reveal timer can fire if the work above + // took long enough to cross the reveal threshold. + await new Promise((resolve) => setTimeout(resolve, 0)); + if (this.is_destroyed) return stale; + + if (!this.config.allow_annotations_outside_image) { + const image_width = this.config["image_width"]; + const image_height = this.config["image_height"]; + for (const st of stale) { + for (const anno of Object.values(this.subtasks[st]["annotations"]["access"])) { + anno.clamp_annotation_to_image_bounds(image_width, image_height); + } + } + } + + for (const st of stale) { + initialize_annotation_canvases(this, st); + this.redraw_all_annotations(st); + } + this.readjust_subtask_opacities(); + // Calculate distances for all annotations if FilterDistance is present + this.update_filter_distance(null, false, true); + // Update class counter in toolbox + this.toolbox.redraw_update_items(this); + } finally { + ULabelLoader.remove_loader_div(); + } + return stale; + } + // Change frame update_frame(delta = null, new_frame = null) { if (this.config["image_data"]["frames"].length === 1) { diff --git a/src/initializer.ts b/src/initializer.ts index 3ccde13c..ff690e0b 100644 --- a/src/initializer.ts +++ b/src/initializer.ts @@ -208,18 +208,6 @@ export async function ulabel_init( // Create listers to manipulate and export this object create_ulabel_listeners(ulabel); - // Restore toolbox collapsed state from localStorage - const is_collapsed = get_local_storage_item("ulabel_toolbox_collapsed"); - if (is_collapsed === "true") { - const toolbox = $("#" + ulabel.config["toolbox_id"]); - const container = $(".full_ulabel_container_"); - const btn = $(".toolbox-collapse-btn"); - toolbox.addClass("collapsed"); - container.addClass("toolbox-collapsed"); - btn.text("▶"); - btn.attr("title", "Expand toolbox"); - } - ulabel.handle_toolbox_overflow(); // Set the canvas elements in the correct stacking order given current subtask @@ -233,6 +221,7 @@ export async function ulabel_init( ulabel.is_init = true; ulabel.show_initial_crop(); + $(".full_ulabel_container_").addClass("ulabel-cropped"); ulabel.update_frame(); // Draw demo annotation diff --git a/src/mask_utils.ts b/src/mask_utils.ts index a86ae8c8..fde5e2dd 100644 --- a/src/mask_utils.ts +++ b/src/mask_utils.ts @@ -1,8 +1,15 @@ // 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. +// A bitmask annotation covers a per-pixel binary occupancy grid the size of the +// image, but pixels are only *stored* for a sub-rectangle of it -- the mask's +// "window". Everything outside the window is background by definition. The +// public API is entirely in image coordinates, so callers never see the window; +// it exists so memory scales with the area an annotation actually covers rather +// than with the image, which is what lets a frame hold hundreds of objects. +// +// Within the window the grid is a row-major Uint8Array (values 0 or 1). For +// serialization it is encoded as COCO-style, column-major run-length counts over +// the full image, so the wire format is unchanged. // COCO-style run-length encoding of a binary mask. // - counts: alternating run lengths (in column-major / Fortran order) that always @@ -15,20 +22,38 @@ export type ULabelMaskPayload = { }; // Raw pixel-buffer form of a bitmask payload. Row-major, one byte per pixel -// (non-zero = foreground). `size` is [height, width] to match ULabelMaskPayload. -// Accepted as an alternative to the RLE form on load; callers that already have -// the mask as a Uint8Array avoid the encode-then-decode round-trip. +// (non-zero = foreground). `size` is [height, width] of the *image*, to match +// ULabelMaskPayload. Accepted as an alternative to the RLE form on load; callers +// that already have the mask as a Uint8Array avoid the encode-then-decode +// round-trip. +// +// If `box` is given, `data` covers only that inclusive image-space rectangle +// (row stride `brx - tlx + 1`) rather than the whole image, and is adopted as the +// mask's window as-is. Producing a cropped payload is the cheapest way to import +// a dense frame: nothing full-size is ever allocated. export type ULabelRawMaskPayload = { data: Uint8Array; size: [number, number]; + box?: BoundingBox; }; +// Duck-type check for a bounding box of integer bounds. +function is_bounding_box(box: unknown): box is BoundingBox { + if (box === null || typeof box !== "object") return false; + const b = box as Record; + for (const key of ["tlx", "tly", "brx", "bry"]) { + if (typeof b[key] !== "number" || !Number.isInteger(b[key])) return false; + } + return true; +} + // Duck-type check for the raw payload shape. export function is_raw_mask_payload(payload: unknown): payload is ULabelRawMaskPayload { if (payload === null || typeof payload !== "object") return false; - const p = payload as { data?: unknown; size?: unknown }; + const p = payload as { data?: unknown; size?: unknown; box?: unknown }; if (!(p.data instanceof Uint8Array) && !(p.data instanceof Uint8ClampedArray)) return false; if (!Array.isArray(p.size) || p.size.length !== 2) return false; + if (p.box !== undefined && !is_bounding_box(p.box)) return false; return Number.isInteger(p.size[0]) && Number.isInteger(p.size[1]); } @@ -49,37 +74,148 @@ function clamp_int(value: number, min: number, max: number): number { } export class ULabelMask { + // Stored pixels for the window only; length is window_width * window_height. public data: Uint8Array; + // Full image dimensions -- the mask's coordinate space, not its allocation. public readonly width: number; public readonly height: number; // Bumped by every mutating method so render caches can detect changes by comparison. public version: number = 0; - constructor(width: number, height: number, data?: Uint8Array) { + // Window origin and extent, in image coordinates. A zero-area window means the + // mask is entirely background and holds no buffer. + private win_x: number = 0; + private win_y: number = 0; + private win_w: number = 0; + private win_h: number = 0; + + // `data` without `box` is treated as a full-frame buffer, preserving the + // original constructor contract. With `box`, `data` covers just that + // rectangle. With neither, the mask starts empty and grows as it is painted. + constructor(width: number, height: number, data?: Uint8Array, box?: BoundingBox) { this.width = width; this.height = height; - if (data !== undefined) { + + if (data === undefined) { + this.data = new Uint8Array(0); + return; + } + + if (box === undefined) { if (data.length !== width * height) { throw new Error( `Mask data length ${data.length} does not match dimensions ${width}x${height}`, ); } + this.win_w = width; + this.win_h = height; this.data = data; - } else { - this.data = new Uint8Array(width * height); + return; + } + + const win_w = box.brx - box.tlx + 1; + const win_h = box.bry - box.tly + 1; + if (data.length !== win_w * win_h) { + throw new Error( + `Mask data length ${data.length} does not match window ${win_w}x${win_h}`, + ); } + if (box.tlx < 0 || box.tly < 0 || box.brx >= width || box.bry >= height) { + throw new Error( + `Mask window [${box.tlx}, ${box.tly}, ${box.brx}, ${box.bry}] lies outside ${width}x${height}`, + ); + } + this.win_x = box.tlx; + this.win_y = box.tly; + this.win_w = win_w; + this.win_h = win_h; + this.data = data; } - // Create an empty (all-background) mask. + // Create an empty (all-background) mask. Allocates nothing until painted. 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; + public get window_x(): number { + return this.win_x; + } + + public get window_y(): number { + return this.win_y; + } + + public get window_width(): number { + return this.win_w; + } + + public get window_height(): number { + return this.win_h; + } + + // The window as an inclusive image-space box, or null if the mask is empty. + // Renderers use this to walk `data` directly instead of probing get_pixel. + public get_window_box(): BoundingBox | null { + if (this.win_w === 0 || this.win_h === 0) return null; + return { + tlx: this.win_x, + tly: this.win_y, + brx: this.win_x + this.win_w - 1, + bry: this.win_y + this.win_h - 1, + }; + } + + // Index into `data` for an image coordinate, or -1 if outside the window. + private idx(x: number, y: number): number { + const lx = x - this.win_x; + const ly = y - this.win_y; + if (lx < 0 || ly < 0 || lx >= this.win_w || ly >= this.win_h) return -1; + return ly * this.win_w + lx; + } + + // Grow the window so it contains `box` (clamped to the image), reallocating and + // copying existing rows across. No-op when already covered. + private ensure_window(box: BoundingBox): void { + const tlx = Math.max(0, Math.floor(box.tlx)); + const tly = Math.max(0, Math.floor(box.tly)); + const brx = Math.min(this.width - 1, Math.ceil(box.brx)); + const bry = Math.min(this.height - 1, Math.ceil(box.bry)); + if (brx < tlx || bry < tly) return; + + if (this.win_w === 0 || this.win_h === 0) { + this.win_x = tlx; + this.win_y = tly; + this.win_w = brx - tlx + 1; + this.win_h = bry - tly + 1; + this.data = new Uint8Array(this.win_w * this.win_h); + return; + } + + const cur_brx = this.win_x + this.win_w - 1; + const cur_bry = this.win_y + this.win_h - 1; + if (tlx >= this.win_x && tly >= this.win_y && brx <= cur_brx && bry <= cur_bry) return; + + const new_x = Math.min(this.win_x, tlx); + const new_y = Math.min(this.win_y, tly); + const new_w = Math.max(cur_brx, brx) - new_x + 1; + const new_h = Math.max(cur_bry, bry) - new_y + 1; + const grown = new Uint8Array(new_w * new_h); + const row_offset = this.win_x - new_x; + for (let ly = 0; ly < this.win_h; ly++) { + const src = ly * this.win_w; + const dst = (ly + this.win_y - new_y) * new_w + row_offset; + grown.set(this.data.subarray(src, src + this.win_w), dst); } - return this.data[y * this.width + x]; + this.win_x = new_x; + this.win_y = new_y; + this.win_w = new_w; + this.win_h = new_h; + this.data = grown; + } + + public get_pixel(x: number, y: number): number { + const i = this.idx(x, y); + return i < 0 ? 0 : this.data[i]; } public set_pixel(x: number, y: number, value: number): void { @@ -87,7 +223,13 @@ export class ULabelMask { return; } this.version++; - this.data[y * this.width + x] = value ? 1 : 0; + if (value) { + this.ensure_window({ tlx: x, tly: y, brx: x, bry: y }); + } + const i = this.idx(x, y); + // Erasing outside the window is already a no-op + if (i < 0) return; + this.data[i] = value ? 1 : 0; } // Paint (value = 1) or erase (value = 0) a filled circle into the mask. @@ -100,14 +242,21 @@ export class ULabelMask { 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 circle_box = { tlx: min_x, tly: min_y, brx: max_x, bry: max_y }; + if (v === 1) { + this.ensure_window(circle_box); + } + const b = this.clamp_box_to_window(circle_box); + if (b === null) return false; const r_sq = r * r; let changed = false; - for (let y = min_y; y <= max_y; y++) { + for (let y = b.y0; y <= b.y1; y++) { const dy = y - cy; - for (let x = min_x; x <= max_x; x++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + for (let x = b.x0; x <= b.x1; x++) { const dx = x - cx; if (dx * dx + dy * dy <= r_sq) { - const idx = y * this.width + x; + const idx = row + x; if (this.data[idx] !== v) { this.data[idx] = v; changed = true; @@ -129,16 +278,20 @@ export class ULabelMask { // 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 b = this.clamp_box_to_window({ + tlx: clamp_int(cx - r, 0, this.width - 1), + tly: clamp_int(cy - r, 0, this.height - 1), + brx: clamp_int(cx + r, 0, this.width - 1), + bry: clamp_int(cy + r, 0, this.height - 1), + }); + if (b === null) return false; const r_sq = r * r; - for (let y = min_y; y <= max_y; y++) { + for (let y = b.y0; y <= b.y1; y++) { const dy = y - cy; - for (let x = min_x; x <= max_x; x++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + for (let x = b.x0; x <= b.x1; x++) { const dx = x - cx; - if (dx * dx + dy * dy <= r_sq && this.data[y * this.width + x] !== 0) { + if (dx * dx + dy * dy <= r_sq && this.data[row + x] !== 0) { return true; } } @@ -149,52 +302,69 @@ export class ULabelMask { // Axis-aligned bounding box of foreground pixels, or null if empty. // Returned as { tlx, tly, brx, bry } in image pixel coordinates. public get_bounding_box(): BoundingBox | null { - let min_x = this.width; - let min_y = this.height; + let min_x = this.win_w; + let min_y = this.win_h; 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; + for (let ly = 0; ly < this.win_h; ly++) { + const row = ly * this.win_w; + for (let lx = 0; lx < this.win_w; lx++) { + if (this.data[row + lx] !== 0) { + if (lx < min_x) min_x = lx; + if (lx > max_x) max_x = lx; + if (ly < min_y) min_y = ly; + if (ly > max_y) max_y = ly; } } } if (max_x < 0) { return null; } - return { tlx: min_x, tly: min_y, brx: max_x, bry: max_y }; + return { + tlx: min_x + this.win_x, + tly: min_y + this.win_y, + brx: max_x + this.win_x, + bry: max_y + this.win_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 box = this.get_window_box(); + if (box === null) return shifted; const idx = Math.round(dx); const idy = Math.round(dy); - for (let y = 0; y < this.height; y++) { - const ny = y + idy; + if (box.brx + idx < 0 || box.bry + idy < 0) return shifted; + if (box.tlx + idx >= this.width || box.tly + idy >= this.height) return shifted; + shifted.ensure_window({ + tlx: box.tlx + idx, + tly: box.tly + idy, + brx: box.brx + idx, + bry: box.bry + idy, + }); + for (let ly = 0; ly < this.win_h; ly++) { + const ny = ly + this.win_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; + const src_row = ly * this.win_w; + for (let lx = 0; lx < this.win_w; lx++) { + if (this.data[src_row + lx] !== 0) { + const nx = lx + this.win_x + idx; if (nx < 0 || nx >= this.width) continue; - shifted.data[dst_row + nx] = 1; + const i = shifted.idx(nx, ny); + if (i >= 0) shifted.data[i] = 1; } } } return shifted; } - // Return a copy of this mask. + // Return a copy of this mask, window and all. public clone(): ULabelMask { - return new ULabelMask(this.width, this.height, this.data.slice()); + const box = this.get_window_box(); + if (box === null) return new ULabelMask(this.width, this.height); + return new ULabelMask(this.width, this.height, this.data.slice(), box); } // Ensure another mask has the same dimensions as this one. @@ -206,16 +376,41 @@ export class ULabelMask { } } + // Clamp a box to the intersection of the image and this mask's window, returning + // integer inclusive image-space bounds or null if empty. + private clamp_box_to_window(box: BoundingBox): { x0: number; y0: number; x1: number; y1: number } | null { + if (this.win_w === 0 || this.win_h === 0) return null; + const x0 = Math.max(this.win_x, Math.floor(box.tlx)); + const y0 = Math.max(this.win_y, Math.floor(box.tly)); + const x1 = Math.min(this.win_x + this.win_w - 1, Math.ceil(box.brx)); + const y1 = Math.min(this.win_y + this.win_h - 1, Math.ceil(box.bry)); + if (x1 < x0 || y1 < y0) return null; + return { x0, y0, x1, y1 }; + } + + // Inclusive image-space bounds covered by both masks' windows, or null. + private window_overlap(other: ULabelMask): { x0: number; y0: number; x1: number; y1: number } | null { + const box = other.get_window_box(); + if (box === null) return null; + return this.clamp_box_to_window(box); + } + // 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); this.version++; + const b = this.window_overlap(other); + if (b === null) return false; 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; + for (let y = b.y0; y <= b.y1; y++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + const other_row = (y - other.win_y) * other.win_w - other.win_x; + for (let x = b.x0; x <= b.x1; x++) { + if (this.data[row + x] !== 0 && other.data[other_row + x] !== 0) { + this.data[row + x] = 0; + changed = true; + } } } return changed; @@ -227,9 +422,10 @@ export class ULabelMask { // pixel changed. Used to apply ULabel's polygon/bbox delete modes to raster masks. public subtract_polygon(polygon: [number, number][]): boolean { if (polygon.length < 3) return false; + if (this.win_w === 0 || this.win_h === 0) return false; this.version++; - // Restrict work to the polygon's vertical extent, clamped to the image. + // Restrict work to the polygon's vertical extent, clamped to the window. let min_py = Infinity; let max_py = -Infinity; for (let i = 0; i < polygon.length; i++) { @@ -237,8 +433,8 @@ export class ULabelMask { 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)); + const y_start = Math.max(this.win_y, Math.ceil(min_py)); + const y_end = Math.min(this.win_y + this.win_h - 1, Math.floor(max_py)); let changed = false; const n = polygon.length; @@ -257,10 +453,10 @@ export class ULabelMask { } if (xs.length < 2) continue; xs.sort((a, b) => a - b); - const row = y * this.width; + const row = (y - this.win_y) * this.win_w - this.win_x; 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])); + const x_start = Math.max(this.win_x, Math.ceil(xs[k])); + const x_end = Math.min(this.win_x + this.win_w - 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; @@ -276,9 +472,18 @@ export class ULabelMask { public add_mask(other: ULabelMask): void { this.assert_same_dims(other); this.version++; - for (let i = 0; i < this.data.length; i++) { - if (other.data[i] !== 0) { - this.data[i] = 1; + const other_box = other.get_window_box(); + if (other_box === null) return; + this.ensure_window(other_box); + const b = this.window_overlap(other); + if (b === null) return; + for (let y = b.y0; y <= b.y1; y++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + const other_row = (y - other.win_y) * other.win_w - other.win_x; + for (let x = b.x0; x <= b.x1; x++) { + if (other.data[other_row + x] !== 0) { + this.data[row + x] = 1; + } } } } @@ -287,9 +492,24 @@ export class ULabelMask { public intersect(other: ULabelMask): void { this.assert_same_dims(other); this.version++; - for (let i = 0; i < this.data.length; i++) { - if (other.data[i] === 0) { - this.data[i] = 0; + const b = this.window_overlap(other); + if (b === null) { + this.data.fill(0); + return; + } + const win_x1 = this.win_x + this.win_w - 1; + for (let y = this.win_y; y <= this.win_y + this.win_h - 1; y++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + // Rows the other mask doesn't reach are cleared wholesale. + if (y < b.y0 || y > b.y1) { + this.data.fill(0, row + this.win_x, row + win_x1 + 1); + continue; + } + const other_row = (y - other.win_y) * other.win_w - other.win_x; + for (let x = this.win_x; x <= win_x1; x++) { + if (x < b.x0 || x > b.x1 || other.data[other_row + x] === 0) { + this.data[row + x] = 0; + } } } } @@ -297,20 +517,28 @@ export class ULabelMask { // 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; + const b = this.window_overlap(other); + if (b === null) return false; + for (let y = b.y0; y <= b.y1; y++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + const other_row = (y - other.win_y) * other.win_w - other.win_x; + for (let x = b.x0; x <= b.x1; x++) { + if (this.data[row + x] !== 0 && other.data[other_row + x] !== 0) { + return true; + } } } return false; } - // Clamp a bounding box to the image, returning integer inclusive bounds or null if empty. - private clamp_box_to_image(box: BoundingBox): { x0: number; y0: number; x1: number; y1: number } | null { - const x0 = Math.max(0, Math.floor(box.tlx)); - const y0 = Math.max(0, Math.floor(box.tly)); - const x1 = Math.min(this.width - 1, Math.ceil(box.brx)); - const y1 = Math.min(this.height - 1, Math.ceil(box.bry)); + // Inclusive bounds covered by `box` and both masks' windows, or null. + private overlap_in_box(other: ULabelMask, box: BoundingBox): { x0: number; y0: number; x1: number; y1: number } | null { + const b = this.window_overlap(other); + if (b === null) return null; + const x0 = Math.max(b.x0, Math.floor(box.tlx)); + const y0 = Math.max(b.y0, Math.floor(box.tly)); + const x1 = Math.min(b.x1, Math.ceil(box.brx)); + const y1 = Math.min(b.y1, Math.ceil(box.bry)); if (x1 < x0 || y1 < y0) return null; return { x0, y0, x1, y1 }; } @@ -318,13 +546,13 @@ export class ULabelMask { // True if any pixel within `box` is foreground in both masks. O(box area). public intersects_in_box(other: ULabelMask, box: BoundingBox): boolean { this.assert_same_dims(other); - const b = this.clamp_box_to_image(box); + const b = this.overlap_in_box(other, box); if (b === null) return false; for (let y = b.y0; y <= b.y1; y++) { - const row = y * this.width; + const row = (y - this.win_y) * this.win_w - this.win_x; + const other_row = (y - other.win_y) * other.win_w - other.win_x; for (let x = b.x0; x <= b.x1; x++) { - const i = row + x; - if (this.data[i] !== 0 && other.data[i] !== 0) return true; + if (this.data[row + x] !== 0 && other.data[other_row + x] !== 0) return true; } } return false; @@ -334,16 +562,16 @@ export class ULabelMask { // Returns true if any pixel changed. O(box area). public subtract_in_box(other: ULabelMask, box: BoundingBox): boolean { this.assert_same_dims(other); - const b = this.clamp_box_to_image(box); + const b = this.overlap_in_box(other, box); if (b === null) return false; this.version++; let changed = false; for (let y = b.y0; y <= b.y1; y++) { - const row = y * this.width; + const row = (y - this.win_y) * this.win_w - this.win_x; + const other_row = (y - other.win_y) * other.win_w - other.win_x; for (let x = b.x0; x <= b.x1; x++) { - const i = row + x; - if (this.data[i] !== 0 && other.data[i] !== 0) { - this.data[i] = 0; + if (this.data[row + x] !== 0 && other.data[other_row + x] !== 0) { + this.data[row + x] = 0; changed = true; } } @@ -356,16 +584,23 @@ export class ULabelMask { public subtract_intersection_in_box(a: ULabelMask, b: ULabelMask, box: BoundingBox): boolean { this.assert_same_dims(a); this.assert_same_dims(b); - const bx = this.clamp_box_to_image(box); - if (bx === null) return false; + const bounds = this.overlap_in_box(a, box); + const b_box = b.get_window_box(); + if (bounds === null || b_box === null) return false; + const x0 = Math.max(bounds.x0, b_box.tlx); + const y0 = Math.max(bounds.y0, b_box.tly); + const x1 = Math.min(bounds.x1, b_box.brx); + const y1 = Math.min(bounds.y1, b_box.bry); + if (x1 < x0 || y1 < y0) return false; this.version++; let changed = false; - for (let y = bx.y0; y <= bx.y1; y++) { - const row = y * this.width; - for (let x = bx.x0; x <= bx.x1; x++) { - const i = row + x; - if (this.data[i] !== 0 && a.data[i] !== 0 && b.data[i] !== 0) { - this.data[i] = 0; + for (let y = y0; y <= y1; y++) { + const row = (y - this.win_y) * this.win_w - this.win_x; + const a_row = (y - a.win_y) * a.win_w - a.win_x; + const b_row = (y - b.win_y) * b.win_w - b.win_x; + for (let x = x0; x <= x1; x++) { + if (this.data[row + x] !== 0 && a.data[a_row + x] !== 0 && b.data[b_row + x] !== 0) { + this.data[row + x] = 0; changed = true; } } @@ -373,7 +608,7 @@ export class ULabelMask { return changed; } - // Encode to COCO-style, column-major run-length counts. + // Encode to COCO-style, column-major run-length counts over the full image. public to_rle(): ULabelMaskPayload { const counts: number[] = []; // Runs always start with background (0). Compare foreground-truthiness rather @@ -381,9 +616,24 @@ export class ULabelMask { // (e.g. 0/255 masks, multi-valued upstream buffers) still encode correctly. let current_is_fg = false; let run = 0; + const win_x1 = this.win_x + this.win_w; + const win_y1 = this.win_y + this.win_h; for (let x = 0; x < this.width; x++) { + // Columns outside the window are background end to end; skip the scan. + if (x < this.win_x || x >= win_x1) { + if (current_is_fg) { + counts.push(run); + current_is_fg = false; + run = this.height; + } else { + run += this.height; + } + continue; + } + const col = x - this.win_x; for (let y = 0; y < this.height; y++) { - const is_fg = this.data[y * this.width + x] !== 0; + const is_fg = y >= this.win_y && y < win_y1 && + this.data[(y - this.win_y) * this.win_w + col] !== 0; if (is_fg === current_is_fg) { run++; } else { @@ -437,13 +687,57 @@ export class ULabelMask { } } - // Decode a COCO-style RLE payload into a mask. + // Bounding box of the foreground implied by a column-major RLE, without decoding + // it. Walks runs rather than pixels, so this is O(runs). + private static rle_bounding_box(payload: ULabelMaskPayload): BoundingBox | null { + const [height] = payload.size; + if (height === 0) return null; + let min_x = Infinity; + let min_y = Infinity; + let max_x = -1; + let max_y = -1; + let idx = 0; + let value = 0; + for (let c = 0; c < payload.counts.length; c++) { + const run = payload.counts[c]; + if (value === 1 && run > 0) { + const last = idx + run - 1; + const first_x = Math.floor(idx / height); + const last_x = Math.floor(last / height); + if (first_x < min_x) min_x = first_x; + if (last_x > max_x) max_x = last_x; + if (last_x > first_x) { + // Crossing a column boundary means the run reaches the bottom of its + // first column and the top of its last, so it spans every row. + min_y = 0; + max_y = height - 1; + } else { + const first_y = idx % height; + const last_y = last % height; + if (first_y < min_y) min_y = first_y; + if (last_y > max_y) max_y = last_y; + } + } + idx += run; + value = value === 0 ? 1 : 0; + } + if (max_x < 0) return null; + return { tlx: min_x, tly: min_y, brx: max_x, bry: max_y }; + } + + // Decode a COCO-style RLE payload into a mask, allocating only the foreground's + // bounding box rather than the whole frame. 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); + const box = ULabelMask.rle_bounding_box(payload); + if (box === null) return new ULabelMask(width, height); + + const win_w = box.brx - box.tlx + 1; + const data = new Uint8Array(win_w * (box.bry - box.tly + 1)); + const mask = new ULabelMask(width, height, data, box); let idx = 0; // column-major index let value = 0; for (let c = 0; c < payload.counts.length; c++) { @@ -453,7 +747,7 @@ export class ULabelMask { const col_idx = idx + k; const x = Math.floor(col_idx / height); const y = col_idx % height; - mask.data[y * width + x] = 1; + data[(y - box.tly) * win_w + (x - box.tlx)] = 1; } } idx += run; @@ -471,6 +765,17 @@ export class ULabelMask { if (height < 0 || width < 0) { throw new Error(`Invalid raw mask size: expected non-negative integers, got [${height}, ${width}]`); } + if (payload.box !== undefined) { + const box = payload.box; + if (box.tlx < 0 || box.tly < 0 || box.brx >= width || box.bry >= height) { + throw new Error(`Invalid raw mask box [${box.tlx}, ${box.tly}, ${box.brx}, ${box.bry}] for ${height}x${width}`); + } + const expected = (box.brx - box.tlx + 1) * (box.bry - box.tly + 1); + if (payload.data.length !== expected) { + throw new Error(`Invalid raw mask data length: expected ${expected} bytes for box, got ${payload.data.length}`); + } + return; + } const expected = height * width; if (payload.data.length !== expected) { throw new Error(`Invalid raw mask data length: expected ${expected} bytes for ${height}x${width}, got ${payload.data.length}`); @@ -478,14 +783,56 @@ export class ULabelMask { } // Wrap a raw pixel-buffer payload as a ULabelMask. - // By default copies the input so the caller can safely mutate their own array; - // pass `copy: false` when the caller (e.g. process_resume_from) already copied. + // + // With `box`, the buffer is already cropped and is adopted as the window -- by + // default copied so the caller can safely mutate their own array; pass + // `copy: false` when the caller (e.g. process_resume_from) already copied. + // + // Without `box`, the buffer is full-frame and is cropped to its bounding box on + // import, so the full-size allocation becomes garbage immediately instead of + // being retained for the life of the annotation. `copy` is moot in that case; + // the crop is always a fresh buffer. public static from_raw(payload: ULabelRawMaskPayload, validate: boolean = true, copy: boolean = true): ULabelMask { if (validate) { ULabelMask.validate_raw(payload); } const [height, width] = payload.size; - const data = copy ? new Uint8Array(payload.data) : payload.data; - return new ULabelMask(width, height, data); + + if (payload.box !== undefined) { + const data = copy ? new Uint8Array(payload.data) : payload.data; + return new ULabelMask(width, height, data, payload.box); + } + + const src = payload.data; + let min_x = width; + let min_y = height; + let max_x = -1; + let max_y = -1; + for (let y = 0; y < height; y++) { + const row = y * width; + for (let x = 0; x < width; x++) { + if (src[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 new ULabelMask(width, height); + + const win_w = max_x - min_x + 1; + const win_h = max_y - min_y + 1; + const data = new Uint8Array(win_w * win_h); + for (let y = 0; y < win_h; y++) { + const src_start = (y + min_y) * width + min_x; + data.set(src.subarray(src_start, src_start + win_w), y * win_w); + } + return new ULabelMask(width, height, data, { + tlx: min_x, + tly: min_y, + brx: max_x, + bry: max_y, + }); } } diff --git a/src/toolbox.ts b/src/toolbox.ts index be7355fd..022cc132 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -124,6 +124,12 @@ export class Toolbox { height: 100%; } + /* The image is inserted at its natural size and only scaled once the + initial crop lands, so keep it hidden until then. */ + .full_ulabel_container_:not(.ulabel-cropped) .imwrap_cls { + visibility: hidden; + } + #toolbox { width: 320px; background-color: white; @@ -272,17 +278,26 @@ export class Toolbox { images: string, ULABEL_VERSION: string, ): string { + // Bake the persisted collapsed state into the markup. Applying it after + // init instead paints a frame with the toolbox expanded, which reads as + // a flicker every time the instance is rebuilt. + const is_collapsed = get_local_storage_item("ulabel_toolbox_collapsed") === "true"; + const container_class = is_collapsed ? "full_ulabel_container_ toolbox-collapsed" : "full_ulabel_container_"; + const toolbox_class = is_collapsed ? "toolbox_cls collapsed" : "toolbox_cls"; + const collapse_arrow = is_collapsed ? "◀" : "▶"; + const collapse_title = is_collapsed ? "Expand toolbox" : "Collapse toolbox"; + // Setup base div and ULabel version header let toolbox_html = ` -
+
${frame_annotation_dialogs}
${images}
- -
+ +

ULabel v${ULABEL_VERSION}