From 58f79be44ecee085a299c0f12cf8fb90bbd0ddc7 Mon Sep 17 00:00:00 2001 From: Elliott Imhoff Date: Mon, 31 Aug 2026 15:21:42 -0500 Subject: [PATCH 01/29] 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}

+ + + + + + + + + + +
+ + + + diff --git a/index.d.ts b/index.d.ts index 9cc7b611..ca30076d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -426,12 +426,21 @@ export class ULabel { public set_subtask(st_key: string): void; public switch_to_next_subtask(): void; /** - * Focus a subtask on a single class. Other classes dim to the subtask's - * `defocused_opacity` and drop out of hover/grab, annotation navigation and - * the annotation list; geometry still sees every annotation. `null` clears. + * Set a subtask's active class: id payload, toolbox selection, per-class + * mode sync, and - on subtasks with `focus_active_class` - the class focus + * (other classes dim to `defocused_opacity` and drop out of hover/grab, + * annotation navigation, bulk delete and the annotation list; geometry + * still sees every annotation). Returns whether the class was accepted. */ - public set_class_focus(subtask_key: string, class_id?: number | null, redraw?: boolean): void; + public set_active_class(class_id: number, subtask_key?: string | null, redraw?: boolean): boolean; + /** + * The last non-delete class selected on a subtask. Delete-mode toggles + * don't move it, so class focus stays put while deleting. + */ + public get_selected_class_id(subtask_key?: string | null): number | null; public is_annotation_defocused(annotation: ULabelAnnotation, subtask_key: string): boolean; + /** Turn focus-follows-active-class on or off for a subtask at runtime. */ + public set_focus_active_class(subtask_key: string, enabled: boolean, redraw?: boolean): void; /** * Opacity for annotations outside the focused class. 0 skips drawing them * entirely, which is cheaper but loses them as visual context. diff --git a/src/active_class.ts b/src/active_class.ts new file mode 100644 index 00000000..2e8bdc44 --- /dev/null +++ b/src/active_class.ts @@ -0,0 +1,227 @@ +/** + * Active-class selection. + * + * The active class is per-subtask state: `state.id_payload` drives what new + * annotations get, and `state.selected_class_id` remembers the last *real* + * class selected (delete-mode toggles clobber `id_payload`, so the memory is + * what class focus reads — it freezes at the real selection while a delete + * mode is active). The toolbox buttons are one consumer of this API rather + * than the mechanism itself. + */ + +import type { ULabel } from "../index"; +import { DELETE_CLASS_ID, DELETE_MODES } from "./annotation"; +import { ULabelSubtask } from "./subtask"; +import { log_message, LogLevel } from "./error_logging"; + +/** + * The last non-delete class selected on a subtask, or null for an unknown + * subtask. + */ +export function get_selected_class_id(ulabel: ULabel, subtask_key: string): number | null { + const subtask = ulabel.subtasks[subtask_key]; + if (subtask == null) return null; + return subtask.state.selected_class_id ?? null; +} + +/** + * Set a subtask's active class: id payload, toolbox selection, id-dialog + * display, per-class mode sync, and — on subtasks with `focus_active_class` — + * the class focus. Selecting a class while an annotation is active/hovered + * reassigns that annotation, matching the toolbox-button behaviour this + * replaces. For a non-current subtask only state is written; `set_subtask` + * reconciles the DOM on activation. + * + * @param ulabel ULabel instance + * @param class_id class to select (the reserved delete class is accepted but + * never becomes the remembered selection, so focus freezes across it) + * @param subtask_key defaults to the current subtask + * @param redraw whether a focus change repaints immediately + * @returns whether the class was accepted + */ +export function set_active_class( + ulabel: ULabel, + class_id: number, + subtask_key: string | null = null, + redraw: boolean = true, +): boolean { + subtask_key ??= ulabel.get_current_subtask_key(); + const subtask = ulabel.subtasks[subtask_key]; + if (subtask === undefined) { + log_message(`set_active_class: unknown subtask key ${subtask_key}`, LogLevel.WARNING, true); + return false; + } + // class_defs rather than class_ids so the reserved delete class validates + if (!subtask.class_defs.some((class_def) => class_def.id === class_id)) { + log_message( + `set_active_class: class id ${class_id} is not in subtask ${subtask_key}`, + LogLevel.WARNING, + true, + ); + return false; + } + + const previous_selected = subtask.state.selected_class_id ?? null; + if (class_id !== DELETE_CLASS_ID) { + subtask.state.selected_class_id = class_id; + } + + if (subtask_key === ulabel.get_current_subtask_key()) { + apply_active_class_to_dom(ulabel, subtask_key, class_id); + } else { + // Non-current (or pre-init) subtask: write the payload state now so the + // selection can't drift from what new annotations would get; the DOM + // reconciles when the subtask becomes current. + write_id_payload(subtask, class_id); + } + + // Focus follows the selection; whatever was hovered or mid-fly-to may no + // longer be interactive. + if ( + subtask.focus_active_class && + class_id !== DELETE_CLASS_ID && + previous_selected !== class_id + ) { + subtask.state.hovered_annid = null; + subtask.state.fly_to_idx = null; + if (redraw) { + ulabel.redraw_all_annotations(subtask_key); + // Toolbox items filter on the focus, so they go stale otherwise. + ulabel.toolbox?.redraw_update_items(ulabel); + } + } + return true; +} + +/** + * Turn focus-follows-active-class on or off for a subtask at runtime. + * + * @param ulabel ULabel instance + * @param subtask_key subtask to toggle + * @param enabled whether the selected class should also be the focused class + * @param redraw whether the change repaints immediately + */ +export function set_focus_active_class( + ulabel: ULabel, + subtask_key: string, + enabled: boolean, + redraw: boolean = true, +): void { + const subtask = ulabel.subtasks[subtask_key]; + if (subtask === undefined) { + log_message(`set_focus_active_class: unknown subtask key ${subtask_key}`, LogLevel.WARNING, true); + return; + } + subtask.focus_active_class = enabled === true; + + // What's hovered or mid-fly-to may have just gained or lost interactivity + subtask.state.hovered_annid = null; + subtask.state.fly_to_idx = null; + + if (redraw) { + ulabel.redraw_all_annotations(subtask_key); + ulabel.toolbox?.redraw_update_items(ulabel); + } +} + +/** + * Opacity for annotations outside the focused class. 0 skips drawing them + * entirely, which is cheaper but loses them as visual context. + * + * @param ulabel ULabel instance + * @param subtask_key subtask to adjust + * @param opacity clamped to 0..1 + * @param redraw whether the change repaints immediately + */ +export function set_defocused_opacity( + ulabel: ULabel, + subtask_key: string, + opacity: number, + redraw: boolean = true, +): void { + const subtask = ulabel.subtasks[subtask_key]; + if (subtask === undefined) { + log_message(`set_defocused_opacity: unknown subtask key ${subtask_key}`, LogLevel.WARNING, true); + return; + } + subtask.state.defocused_opacity = Math.min(Math.max(opacity, 0), 1); + if (redraw) { + ulabel.redraw_all_annotations(subtask_key); + } +} + +/** + * Point a subtask's id payload at one class (full confidence, others zero). + * Equivalent to `set_id_dialog_payload_nopin(idx, 1.0)` but subtask-scoped. + */ +function write_id_payload(subtask: ULabelSubtask, class_id: number): void { + const class_ids = subtask.class_ids; + const selected_index = class_ids.indexOf(class_id); + for (let i = 0; i < class_ids.length; i++) { + subtask.state.id_payload[i] = { + class_id: class_ids[i], + confidence: i === selected_index ? 1.0 : 0.0, + }; + } +} + +/** + * The DOM half of a selection change on the current subtask: toolbox `sel` + * swap, id payload, dialog display, active-annotation reclass, delete + * re-toggle, and mode sync. Extracted from the toolbox button click handler. + */ +function apply_active_class_to_dom(ulabel: ULabel, subtask_key: string, class_id: number): void { + const subtask = ulabel.subtasks[subtask_key]; + const pfx = "div#tb-id-app--" + subtask_key; + const current_id_button = $(pfx + " a.tbid-opt.sel"); + const old_id_attr = current_id_button.attr("id"); + const old_id = old_id_attr === undefined ? null : parseInt(old_id_attr.split("_").at(-1)!); + // Re-selecting the current class is a no-op, matching the old handler's + // href gate; notably it leaves a caller-written `id_payload` intact. + if (old_id === class_id) return; + + current_id_button.attr("href", "#"); + current_id_button.removeClass("sel"); + const target_button = $(pfx + ` a#toolbox_sel_${class_id}`); + target_button.addClass("sel"); + target_button.removeAttr("href"); + + ulabel.set_id_dialog_payload_nopin(subtask.class_ids.indexOf(class_id), 1.0); + ulabel.update_id_dialog_display(); + + // Update the class of the active annotation, + // except when toggling on the delete class or in a read-only subtask + if (class_id !== DELETE_CLASS_ID && !ulabel.is_current_subtask_read_only()) { + let target_id: string | null = null; + if (subtask.state.active_id !== null) { + target_id = subtask.state.active_id; + } else if (subtask.state.move_candidate !== null) { + target_id = subtask.state.move_candidate["annid"]; + } + if (target_id !== null) { + // Set the annotation's class to the selected class + ulabel.handle_id_dialog_click( + ulabel.state["last_move"], + target_id, + ulabel.get_active_class_id_idx(), + ); + } else { + // No active annotation; still update the brush circle if in brush mode + ulabel.recolor_brush_circle(); + } + } + + /* + If switching off the delete class while still in delete mode, re-select the + delete class. This occurs when a keybind changes a hovered annotation's + class while in delete mode. + */ + if ( + old_id === DELETE_CLASS_ID && + DELETE_MODES.includes(subtask.state.annotation_mode) + ) { + set_active_class(ulabel, DELETE_CLASS_ID, subtask_key); + } + + ulabel.sync_annotation_modes_to_active_class(); +} diff --git a/src/index.js b/src/index.js index bf035321..31a5a4e4 100644 --- a/src/index.js +++ b/src/index.js @@ -32,6 +32,7 @@ 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, is_raw_mask_payload } from "../build/mask_utils"; import { get_active_class_id, get_local_storage_item, set_local_storage_item } from "../build/utilities"; +import { set_active_class, get_selected_class_id, set_focus_active_class, set_defocused_opacity } from "../build/active_class"; import { get_idd_string } from "../build/html_builder"; import $ from "jquery"; @@ -603,7 +604,8 @@ export class ULabel { move_candidate: null, hovered_annid: null, fly_to_idx: null, - focused_class: null, + // The last non-delete class selected; what class focus follows + selected_class_id: ul.subtasks[subtask_key]["class_ids"][0] ?? null, defocused_opacity: raw_subtask["defocused_opacity"] ?? DEFAULT_DEFOCUSED_OPACITY, line_size: ul.config.initial_line_size, @@ -1119,44 +1121,44 @@ export class ULabel { */ is_annotation_defocused(annotation, subtask_key) { const subtask = this.subtasks[subtask_key]; - if (subtask == null) return false; - const focused_class = subtask["state"]["focused_class"]; - if (focused_class == null) return false; - return get_annotation_class_id(annotation) !== String(focused_class); + if (subtask == null || !subtask["focus_active_class"]) return false; + const selected_class_id = get_selected_class_id(this, subtask_key); + if (selected_class_id == null) return false; + return get_annotation_class_id(annotation) !== String(selected_class_id); } /** - * Focus a subtask on a single class. Other classes stay visible but dim to - * `defocused_opacity` and drop out of hover, Tab and the annotation list. - * @param {string} subtask_key - * @param {number|null} class_id null clears the focus - * @param {boolean} redraw + * Set a subtask's active class: id payload, toolbox selection, per-class + * mode sync, and - on subtasks with `focus_active_class` - the class focus. + * + * @param {number} class_id class to select + * @param {string|null} subtask_key defaults to the current subtask + * @param {boolean} redraw whether a focus change repaints immediately + * @returns {boolean} whether the class was accepted */ - set_class_focus(subtask_key, class_id = null, redraw = true) { - const subtask = this.subtasks[subtask_key]; - if (subtask === undefined) { - log_message(`set_class_focus: unknown subtask key ${subtask_key}`, LogLevel.WARNING, true); - return; - } - if (class_id !== null && !subtask["class_ids"].includes(class_id)) { - log_message( - `set_class_focus: class id ${class_id} is not in subtask ${subtask_key}`, - LogLevel.WARNING, - true, - ); - return; - } - subtask["state"]["focused_class"] = class_id; + set_active_class(class_id, subtask_key = null, redraw = true) { + return set_active_class(this, class_id, subtask_key, redraw); + } - // Whatever was hovered or mid-fly-to may no longer be interactive. - subtask["state"]["hovered_annid"] = null; - subtask["state"]["fly_to_idx"] = null; + /** + * The last non-delete class selected on a subtask. Delete-mode toggles + * don't move it, so class focus stays put while deleting. + * + * @param {string|null} subtask_key defaults to the current subtask + * @returns {number|null} + */ + get_selected_class_id(subtask_key = null) { + return get_selected_class_id(this, subtask_key ?? this.get_current_subtask_key()); + } - if (redraw) { - this.redraw_all_annotations(subtask_key); - // Toolbox items filter on focused_class, so they go stale otherwise. - this.toolbox?.redraw_update_items(this); - } + /** + * Turn focus-follows-active-class on or off for a subtask at runtime. + * @param {string} subtask_key + * @param {boolean} enabled + * @param {boolean} redraw + */ + set_focus_active_class(subtask_key, enabled, redraw = true) { + set_focus_active_class(this, subtask_key, enabled, redraw); } /** @@ -1167,15 +1169,7 @@ export class ULabel { * @param {boolean} redraw */ set_defocused_opacity(subtask_key, opacity, redraw = true) { - const subtask = this.subtasks[subtask_key]; - if (subtask === undefined) { - log_message(`set_defocused_opacity: unknown subtask key ${subtask_key}`, LogLevel.WARNING, true); - return; - } - subtask["state"]["defocused_opacity"] = Math.min(Math.max(opacity, 0), 1); - if (redraw) { - this.redraw_all_annotations(subtask_key); - } + set_defocused_opacity(this, subtask_key, opacity, redraw); } set_subtask(st_key) { @@ -1430,12 +1424,12 @@ export class ULabel { if (show_delete) { // Show the delete class id in the toolbox $("a#toolbox_sel_" + DELETE_CLASS_ID).css("display", "inline-block"); - // Select the delete class id in the toolbox by clicking it - $("a#toolbox_sel_" + DELETE_CLASS_ID).trigger("click"); + // Select the delete class id in the toolbox + this.set_active_class(DELETE_CLASS_ID); } else { // Hide the delete class id in the toolbox $("a#toolbox_sel_" + DELETE_CLASS_ID).css("display", "none"); - // If the delete class id is selected, select the first class id in the toolbox + // If the delete class id is selected, select a real class instead if ($("a#toolbox_sel_" + DELETE_CLASS_ID).hasClass("sel")) { // Check if we are hovering an annotation let target_id = null; @@ -1444,9 +1438,9 @@ export class ULabel { } else if (current_subtask.state.move_candidate !== null) { target_id = current_subtask.state.move_candidate["annid"]; } - // If we are not hovering an annotation, select default to the first class + // If we are not hovering an annotation, default to the first class if (target_id === null) { - $("a.tbid-opt").first().trigger("click"); + this.set_active_class(current_subtask["class_ids"][0]); } else { // If we are hovering an annotation, select the class id of the annotation // which is the class with the highest confidence @@ -1454,7 +1448,7 @@ export class ULabel { const target_class_id = classification_payloads.reduce((acc, curr) => { return curr.confidence > acc.confidence ? curr : acc; })["class_id"]; - $("a#toolbox_sel_" + target_class_id).trigger("click"); + this.set_active_class(target_class_id); } } } @@ -2794,8 +2788,7 @@ export class ULabel { draw_context_in_focus_passes(canvas_id, subtask, draw) { const context_entry = this.subtasks[subtask]["state"]["annotation_contexts"][canvas_id]; const annotation_ids = context_entry["annotation_ids"]; - const focused_class = this.subtasks[subtask]["state"]["focused_class"]; - if (focused_class == null) { + if (!this.subtasks[subtask]["focus_active_class"]) { for (const annid of annotation_ids) draw(annid); return; } @@ -3564,6 +3557,7 @@ export class ULabel { // Get the list of annotations const annotations = this.get_current_subtask()["annotations"]["access"]; + const current_subtask_key = this.get_current_subtask_key(); // Track the ids of deprecated annotations for undo let deprecated_ids = []; // Track id and annotation pairs of modified annotations for undo @@ -3574,6 +3568,10 @@ export class ULabel { if (annotation["deprecated"]) { continue; } + // A focus-scoped view must not delete what it has dimmed + if (this.is_annotation_defocused(annotation, current_subtask_key)) { + continue; + } // Skip non-spatial annotations and 3D annotations const spatial_type = annotation["spatial_type"]; if (NONSPATIAL_MODES.includes(spatial_type) || MODES_3D.includes(spatial_type)) { @@ -6871,11 +6869,9 @@ export class ULabel { } } - // Grab the active class id from the toolbox + // Grab the active class id from the id payload state get_active_class_id() { - const pfx = "div#tb-id-app--" + this.get_current_subtask_key(); - const idarr = $(pfx + " a.tbid-opt.sel").attr("id").split("_"); - return parseInt(idarr[idarr.length - 1]); + return get_active_class_id(this); } get_active_class_id_idx() { @@ -7000,10 +6996,13 @@ export class ULabel { } // Get the index of the new class new_class_idx = class_ids.indexOf(new_class_id); + // Init-time numeric payloads carry no selection yet; the first + // class is the default (matching get_active_class_id) + if (new_class_idx === -1) new_class_idx = 0; } - // Select the desired class by clicking on the toolbox selector - $(`#toolbox_sel_${class_ids[new_class_idx]}`).trigger("click"); + // Select the desired class + this.set_active_class(class_ids[new_class_idx]); } } diff --git a/src/listeners.ts b/src/listeners.ts index 40bd312f..aef207d0 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -8,7 +8,7 @@ import type { ULabel } from "../index"; import { NightModeCookie } from "./cookies"; -import { DELETE_CLASS_ID, DELETE_MODES, NONSPATIAL_MODES } from "./annotation"; +import { DELETE_MODES, NONSPATIAL_MODES } from "./annotation"; import { set_local_storage_item } from "./utilities"; import { AnnotationResizeItem, SMALL_ANNOTATION_SIZE, LARGE_ANNOTATION_SIZE, INCREMENT_ANNOTATION_SIZE } from "./toolbox"; @@ -191,8 +191,8 @@ function handle_keypress_event( ); } } else { - // Click the class button if not already selected - class_button.trigger("click"); + // Select the class if not already selected + ulabel.set_active_class(class_def.id); } return; } @@ -201,7 +201,8 @@ function handle_keypress_event( } /** - * Handle a click on a soft ID toolbox button. + * Handle a click on a soft ID toolbox button. Thin wrapper: the selection + * logic lives in `set_active_class`, of which this click is one caller. * * @param click_event Click event * @param ulabel ULabel instance @@ -211,64 +212,10 @@ function handle_soft_id_toolbox_button_click( ulabel: ULabel, ) { const tgt_jq = $(click_event.currentTarget); - const pfx = "div#tb-id-app--" + ulabel.get_current_subtask_key(); - const current_subtask = ulabel.get_current_subtask(); - if (tgt_jq.attr("href") === "#") { - const current_id_button = $(pfx + " a.tbid-opt.sel"); - current_id_button.attr("href", "#"); - current_id_button.removeClass("sel"); - const old_id = parseInt(current_id_button.attr("id")!.split("_").at(-1)!); - tgt_jq.addClass("sel"); - tgt_jq.removeAttr("href"); - const idarr = tgt_jq.attr("id")!.split("_"); - const rawid = parseInt(idarr[idarr.length - 1]); - ulabel.set_id_dialog_payload_nopin( - current_subtask["class_ids"].indexOf(rawid), - 1.0, - ); - ulabel.update_id_dialog_display(); - - // Update the class of the active annotation, - // except when toggling on the delete class or in a read-only subtask - if (rawid !== DELETE_CLASS_ID && !ulabel.is_current_subtask_read_only()) { - // Get the active annotation, if any - let target_id = null; - if (current_subtask.state.active_id !== null) { - target_id = current_subtask.state.active_id; - } else if (current_subtask.state.move_candidate !== null) { - target_id = current_subtask.state.move_candidate["annid"]; - } - - // Update the class of the active annotation - if (target_id !== null) { - // Set the annotation's class to the selected class - ulabel.handle_id_dialog_click( - ulabel.state["last_move"], - target_id, - ulabel.get_active_class_id_idx(), - ); - } else { - // If there is not active annotation, - // still update the brush circle if in brush mode - ulabel.recolor_brush_circle(); - } - } - - /* - If toggling off a delete class while still in delete mode, - re-toggle the delete class. - This occurs when using a keybind to change a hovered annotation's - class while in delete mode. - */ - if ( - old_id === DELETE_CLASS_ID && - DELETE_MODES.includes(current_subtask.state.annotation_mode) - ) { - $("#toolbox_sel_" + DELETE_CLASS_ID).trigger("click"); - } - - ulabel.sync_annotation_modes_to_active_class(); - } + // The selected button has no href; clicking it is a no-op + if (tgt_jq.attr("href") !== "#") return; + const idarr = tgt_jq.attr("id")!.split("_"); + ulabel.set_active_class(parseInt(idarr[idarr.length - 1])); } /** diff --git a/src/subtask.ts b/src/subtask.ts index d7a630e6..d942e09c 100644 --- a/src/subtask.ts +++ b/src/subtask.ts @@ -50,8 +50,9 @@ export class ULabelSubtask { }; spatial_type: ULabelSpatialType; fly_to_idx: number | null; - // Presentation/input only. Defocused annotations are still real data. - focused_class: number | null; + // The last non-delete class selected; what class focus follows when + // `focus_active_class` is set. Defocused annotations are still real data. + selected_class_id: number | null; defocused_opacity: number; line_size: number; }; @@ -72,6 +73,8 @@ export class ULabelSubtask { public annotation_meta: object | string, public read_only?: boolean, public inactive_opacity: number = 0.4, + /** Focus follows the active class: other classes dim and drop out of input. */ + public focus_active_class: boolean = false, ) { this.actions = { stream: [], @@ -90,6 +93,7 @@ export class ULabelSubtask { subtask_json["annotation_meta"], ); ret.read_only = ("read_only" in subtask_json) && (subtask_json["read_only"] === true); + ret.focus_active_class = subtask_json["focus_active_class"] === true; if ("inactive_opacity" in subtask_json && typeof subtask_json["inactive_opacity"] == "number") { ret.inactive_opacity = Math.min(Math.max(subtask_json["inactive_opacity"], 0.0), 1.0); } diff --git a/src/toolbox.ts b/src/toolbox.ts index 11f64cd4..7eab4f22 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -6,7 +6,7 @@ import type { } from "../index"; import { ULabel } from "../src/index"; import { DEFAULT_FILTER_DISTANCE_CONFIG, AllowedToolboxItem } from "./configuration"; -import { ULabelAnnotation } from "./annotation"; +import { ULabelAnnotation, DELETE_CLASS_ID } from "./annotation"; import { ULabelSubtask } from "./subtask"; import { filter_points_distance_from_line, @@ -1277,6 +1277,8 @@ export class ClassCounterToolboxItem extends ToolboxItem { } } return subtask.class_defs + // The reserved delete class is a mode implement, not a real class + .filter((class_def) => class_def.id !== DELETE_CLASS_ID) // MF-Tassels Hack: OVERWRITE classes are internal and never displayed .filter((class_def) => !class_def.name.includes("OVERWRITE")) .map((class_def) => ({ diff --git a/src/toolbox_items/annotation_list.ts b/src/toolbox_items/annotation_list.ts index 7b301a96..e2d088f1 100644 --- a/src/toolbox_items/annotation_list.ts +++ b/src/toolbox_items/annotation_list.ts @@ -390,17 +390,17 @@ export class AnnotationListToolboxItem extends ToolboxItem { */ private get_filtered_annotations(subtask: ULabelSubtask): ULabelAnnotation[] { const annotations: ULabelAnnotation[] = []; - const focused_class = subtask.state.focused_class; + const subtask_key = this.ulabel.get_current_subtask_key(); for (const annotation_id of subtask.annotations.ordering) { const annotation = subtask.annotations.access[annotation_id]; - // Skip deprecated if option is disabled + // Skip deprecated annotations unless the option to show them is enabled if (!this.show_deprecated && annotation.deprecated) { continue; } - if (focused_class != null && this.get_annotation_class_id(annotation) !== focused_class) { + if (this.ulabel.is_annotation_defocused(annotation, subtask_key)) { continue; } diff --git a/tests/class_counter.test.js b/tests/class_counter.test.js index b01cb5d3..489695ed 100644 --- a/tests/class_counter.test.js +++ b/tests/class_counter.test.js @@ -109,6 +109,20 @@ describe("ClassCounterToolboxItem", () => { expect(item.inner_HTML).toContain("Crop: 1"); expect(item.inner_HTML).not.toContain("OVERWRITE"); }); + + test("hides the reserved delete class", () => { + // Subtasks with delete modes get a "Delete" class def appended, but it + // is never in class_ids, so it would otherwise render "Delete: undefined" + const subtask = make_subtask("A", [CROP], [make_annotation(1)]); + subtask.class_defs.push({ name: "Delete", id: -1, color: "crimson" }); + const ulabel = make_ulabel({ st: subtask }, "st"); + const item = new ClassCounterToolboxItem(ulabel); + + item.update_toolbox_counter(ulabel); + + expect(item.inner_HTML).toContain("Crop: 1"); + expect(item.inner_HTML).not.toContain("Delete"); + }); }); describe("subtasks option", () => { diff --git a/tests/class_focus.test.js b/tests/class_focus.test.js index f45476fa..9d6a9ef6 100644 --- a/tests/class_focus.test.js +++ b/tests/class_focus.test.js @@ -1,6 +1,7 @@ -// Unit tests for the class focus: subtask state that dims other classes and -// scopes input to one. Deliberately not a per-annotation flag, so it has to -// survive annotation swaps. +// Unit tests for class focus driven by the active class: on subtasks with +// `focus_active_class`, the selected class is the focused class. Focus is +// derived state, so it has to survive annotation swaps and freeze across +// delete-mode selection churn. const { ULabel } = require("./testing-utils/build_loader"); let next_id = 0; @@ -34,6 +35,14 @@ const mock_config = { }, }; +// Same subtask with focus following the active class +const focus_config = { + ...mock_config, + subtasks: { + st: { ...mock_config.subtasks.st, focus_active_class: true }, + }, +}; + function load(ulabel, annotations) { const access = {}; const ordering = []; @@ -44,80 +53,171 @@ function load(ulabel, annotations) { ulabel.subtasks.st.annotations = { access, ordering }; } -describe("set_class_focus", () => { - test("defaults to null", () => { +describe("set_active_class", () => { + test("selection defaults to the first class", () => { const ulabel = new ULabel(mock_config); - expect(ulabel.subtasks.st.state.focused_class).toBeNull(); + expect(ulabel.get_selected_class_id("st")).toBe(1); }); - test("sets and clears the focus", () => { + test("changes the selected class", () => { const ulabel = new ULabel(mock_config); - ulabel.set_class_focus("st", 2, false); - expect(ulabel.subtasks.st.state.focused_class).toBe(2); + expect(ulabel.set_active_class(2, "st", false)).toBe(true); - ulabel.set_class_focus("st", null, false); - expect(ulabel.subtasks.st.state.focused_class).toBeNull(); + expect(ulabel.get_selected_class_id("st")).toBe(2); }); - test("ignores an unknown subtask key", () => { + test("rejects an unknown subtask key", () => { const ulabel = new ULabel(mock_config); - expect(() => ulabel.set_class_focus("nope", 1, false)).not.toThrow(); - expect(ulabel.subtasks.st.state.focused_class).toBeNull(); + expect(ulabel.set_active_class(1, "nope", false)).toBe(false); }); - test("ignores a class id the subtask doesn't declare", () => { + test("rejects a class id the subtask doesn't declare", () => { const ulabel = new ULabel(mock_config); - ulabel.set_class_focus("st", 99, false); - - expect(ulabel.subtasks.st.state.focused_class).toBeNull(); + expect(ulabel.set_active_class(99, "st", false)).toBe(false); + expect(ulabel.get_selected_class_id("st")).toBe(1); }); - test("drops hover and fly-to position, which may now be off screen", () => { + test("writes the id payload so new annotations get the class", () => { const ulabel = new ULabel(mock_config); + + ulabel.set_active_class(2, "st", false); + + const payload = ulabel.subtasks.st.state.id_payload; + expect(payload.find((p) => p.class_id === 2).confidence).toBe(1); + expect(payload.find((p) => p.class_id === 1).confidence).toBe(0); + }); + + test("with focus_active_class, drops hover and fly-to position on a change", () => { + const ulabel = new ULabel(focus_config); ulabel.subtasks.st.state.hovered_annid = "anno_x"; ulabel.subtasks.st.state.fly_to_idx = 4; - ulabel.set_class_focus("st", 1, false); + ulabel.set_active_class(2, "st", false); expect(ulabel.subtasks.st.state.hovered_annid).toBeNull(); expect(ulabel.subtasks.st.state.fly_to_idx).toBeNull(); }); - test("redraws the subtask unless told not to", () => { - const ulabel = new ULabel(mock_config); + test("with focus_active_class, redraws the subtask unless told not to", () => { + const ulabel = new ULabel(focus_config); ulabel.redraw_all_annotations = jest.fn(); - ulabel.set_class_focus("st", 1); + ulabel.set_active_class(2, "st"); expect(ulabel.redraw_all_annotations).toHaveBeenCalledWith("st"); ulabel.redraw_all_annotations.mockClear(); - ulabel.set_class_focus("st", 2, false); + ulabel.set_active_class(1, "st", false); + expect(ulabel.redraw_all_annotations).not.toHaveBeenCalled(); + }); + + test("without focus_active_class, a selection change never redraws", () => { + const ulabel = new ULabel(mock_config); + ulabel.redraw_all_annotations = jest.fn(); + ulabel.subtasks.st.state.hovered_annid = "anno_x"; + + ulabel.set_active_class(2, "st"); + expect(ulabel.redraw_all_annotations).not.toHaveBeenCalled(); + expect(ulabel.subtasks.st.state.hovered_annid).toBe("anno_x"); + }); + + test("toolbox display sync falls back to the first class on init-time numeric payloads", () => { + const ulabel = new ULabel(mock_config); + ulabel.state.current_subtask = "st"; + + // Pre-selection id_payload entries are plain numbers; the sync must + // treat that as "first class" rather than set_active_class(undefined) + ulabel.update_id_toolbox_display(); + + expect(console.warn).not.toHaveBeenCalled(); + expect(ulabel.get_selected_class_id("st")).toBe(1); + }); +}); + +describe("delete modes freeze the selection", () => { + const delete_config = { + ...focus_config, + subtasks: { + st: { + ...focus_config.subtasks.st, + allowed_modes: ["bbox", "delete_polygon"], + }, + }, + }; + + test("selecting the delete class leaves the remembered selection", () => { + const ulabel = new ULabel(delete_config); + ulabel.set_active_class(2, "st", false); + + // The reserved delete class validates (its def is auto-added) but is + // transient, so focus stays on the real class. + expect(ulabel.set_active_class(-1, "st", false)).toBe(true); + + expect(ulabel.get_selected_class_id("st")).toBe(2); + expect(ulabel.is_annotation_defocused(make_annotation(2), "st")).toBe(false); + expect(ulabel.is_annotation_defocused(make_annotation(1), "st")).toBe(true); + }); +}); + +describe("set_focus_active_class", () => { + test("toggles the focus behavior at runtime", () => { + const ulabel = new ULabel(mock_config); + const weed = make_annotation(2); + + expect(ulabel.is_annotation_defocused(weed, "st")).toBe(false); + + ulabel.set_focus_active_class("st", true, false); + expect(ulabel.is_annotation_defocused(weed, "st")).toBe(true); + + ulabel.set_focus_active_class("st", false, false); + expect(ulabel.is_annotation_defocused(weed, "st")).toBe(false); + }); + + test("drops hover, and redraws unless told not to", () => { + const ulabel = new ULabel(mock_config); + ulabel.redraw_all_annotations = jest.fn(); + ulabel.subtasks.st.state.hovered_annid = "anno_x"; + + ulabel.set_focus_active_class("st", true); + + expect(ulabel.subtasks.st.state.hovered_annid).toBeNull(); + expect(ulabel.redraw_all_annotations).toHaveBeenCalledWith("st"); + }); + + test("ignores an unknown subtask key", () => { + const ulabel = new ULabel(mock_config); + + expect(() => ulabel.set_focus_active_class("nope", true, false)).not.toThrow(); + expect(ulabel.subtasks.st.focus_active_class).toBe(false); }); }); describe("is_annotation_defocused", () => { - test("passes everything through when no focus is set", () => { + test("passes everything through without focus_active_class", () => { const ulabel = new ULabel(mock_config); expect(ulabel.is_annotation_defocused(make_annotation(1), "st")).toBe(false); expect(ulabel.is_annotation_defocused(make_annotation(2), "st")).toBe(false); }); - test("excludes only other classes", () => { - const ulabel = new ULabel(mock_config); - ulabel.set_class_focus("st", 1, false); + test("excludes only classes other than the selected one", () => { + const ulabel = new ULabel(focus_config); expect(ulabel.is_annotation_defocused(make_annotation(1), "st")).toBe(false); expect(ulabel.is_annotation_defocused(make_annotation(2), "st")).toBe(true); + + ulabel.set_active_class(2, "st", false); + + expect(ulabel.is_annotation_defocused(make_annotation(1), "st")).toBe(true); + expect(ulabel.is_annotation_defocused(make_annotation(2), "st")).toBe(false); }); test("passes everything through for an unknown subtask", () => { - const ulabel = new ULabel(mock_config); + const ulabel = new ULabel(focus_config); expect(ulabel.is_annotation_defocused(make_annotation(2), "demo")).toBe(false); }); @@ -125,30 +225,67 @@ describe("is_annotation_defocused", () => { describe("defocused annotations are skipped by navigation", () => { test("fly_to_annotation refuses a defocused annotation", () => { - const ulabel = new ULabel(mock_config); + const ulabel = new ULabel(focus_config); const weed = make_annotation(2); load(ulabel, [make_annotation(1), weed]); - ulabel.set_class_focus("st", 1, false); - expect(ulabel.fly_to_annotation(weed, "st")).toBe(false); }); }); -describe("the focus is state, not a per-annotation flag", () => { +describe("the focus is derived state, not a per-annotation flag", () => { test("annotations swapped in afterwards are defocused on arrival", () => { - const ulabel = new ULabel(mock_config); + const ulabel = new ULabel(focus_config); load(ulabel, [make_annotation(1)]); - ulabel.set_class_focus("st", 1, false); const swapped_in = make_annotation(2); load(ulabel, [swapped_in]); - expect(ulabel.subtasks.st.state.focused_class).toBe(1); + expect(ulabel.get_selected_class_id("st")).toBe(1); expect(ulabel.is_annotation_defocused(swapped_in, "st")).toBe(true); }); }); +describe("bulk delete is focus-gated", () => { + test("a delete polygon only removes focused-class annotations", () => { + const ulabel = new ULabel({ + ...focus_config, + subtasks: { + st: { + ...focus_config.subtasks.st, + allowed_modes: ["point", "delete_polygon"], + }, + }, + }); + const crop = { ...make_annotation(1), spatial_type: "point", spatial_payload: [[5, 5]] }; + const weed = { ...make_annotation(2), spatial_type: "point", spatial_payload: [[5, 5]] }; + const eraser = { + id: "del0", + spatial_type: "delete_polygon", + spatial_payload: [[-1, -1], [20, -1], [20, 20], [-1, 20], [-1, -1]], + classification_payloads: [{ class_id: -1, confidence: 1.0 }], + deprecated: false, + }; + load(ulabel, [crop, weed, eraser]); + // delete_annotations_in_polygon resolves through the current subtask + ulabel.state.current_subtask = "st"; + // Stub the rendering/bookkeeping tail; only the collection loop is under test + ulabel.redraw_annotation = jest.fn(); + ulabel.update_filter_distance = jest.fn(); + ulabel.toolbox = { redraw_update_items: jest.fn() }; + ulabel.destroy_polygon_ender = jest.fn(); + ulabel.destroy_annotation_context = jest.fn(); + ulabel.remove_annotation_from_access_and_ordering = jest.fn(); + ulabel.remove_recorded_events_for_annotation = jest.fn(); + + ulabel.delete_annotations_in_polygon("del0"); + + expect(crop.deprecated).toBe(true); + // Defocused annotation survives: never delete what's dimmed out of view + expect(weed.deprecated).toBe(false); + }); +}); + describe("set_defocused_opacity", () => { test("defaults to 0.4", () => { const ulabel = new ULabel(mock_config); @@ -214,12 +351,11 @@ describe("draw passes", () => { }); test("defocused annotations are blitted back as one dimmed layer, underneath", () => { - const ulabel = new ULabel(mock_config); + const ulabel = new ULabel(focus_config); const crop = make_annotation(1); const weed = make_annotation(2); // Defocused first in id order, so ordering alone wouldn't prove the split. const live = setup_context(ulabel, [weed, crop]); - ulabel.set_class_focus("st", 1, false); const alpha_at_blit = []; live.drawImage = jest.fn(() => alpha_at_blit.push(live.globalAlpha)); const drawn = []; @@ -233,10 +369,9 @@ describe("draw passes", () => { }); test("zero opacity skips the defocused pass entirely", () => { - const ulabel = new ULabel(mock_config); + const ulabel = new ULabel(focus_config); const crop = make_annotation(1); const live = setup_context(ulabel, [make_annotation(2), crop]); - ulabel.set_class_focus("st", 1, false); ulabel.set_defocused_opacity("st", 0, false); const drawn = []; @@ -247,9 +382,8 @@ describe("draw passes", () => { }); test("the live context is restored even if a draw throws", () => { - const ulabel = new ULabel(mock_config); + const ulabel = new ULabel(focus_config); const live = setup_context(ulabel, [make_annotation(2), make_annotation(1)]); - ulabel.set_class_focus("st", 1, false); expect(() => ulabel.draw_context_in_focus_passes("c0", "st", () => { @@ -264,10 +398,9 @@ describe("draw passes", () => { describe("draw_annotation gate", () => { test("a defocused annotation only draws inside the defocus pass", () => { - const ulabel = new ULabel(mock_config); + const ulabel = new ULabel(focus_config); const weed = make_annotation(2); load(ulabel, [weed]); - ulabel.set_class_focus("st", 1, false); ulabel.draw_bounding_box = jest.fn(); ulabel.subtasks.st.state.annotation_contexts = { c0: { context: {}, annotation_ids: [weed.id] }, diff --git a/tests/set_annotations_batch.test.js b/tests/set_annotations_batch.test.js index 744ae335..23e7dda4 100644 --- a/tests/set_annotations_batch.test.js +++ b/tests/set_annotations_batch.test.js @@ -109,12 +109,14 @@ describe("set_annotations_batch", () => { expect(ulabel._swap_subtask_annotations).not.toHaveBeenCalled(); }); - test("leaves class filters in place, so a swapped-in layer arrives filtered", async () => { + test("leaves class focus in place, so a swapped-in layer arrives scoped", async () => { const ulabel = make_ulabel(); - ulabel.set_class_focus("a", 1, false); + ulabel.subtasks.a.focus_active_class = true; await ulabel.set_annotations_batch({ a: [], b: [] }); - expect(ulabel.subtasks.a.state.focused_class).toBe(1); + // Selection (and therefore focus) survives the swap untouched + expect(ulabel.get_selected_class_id("a")).toBe(1); + expect(ulabel.subtasks.a.focus_active_class).toBe(true); }); }); From bc223e9dde6abc7b72a8e2e1f0bd2d3725df814e Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 9 Sep 2026 12:39:56 -0500 Subject: [PATCH 17/29] enforce allowed modes in id dialog --- .github/tasks.md | 32 +++- CHANGELOG.md | 3 +- api_spec.md | 4 + demo/class-focus.html | 19 ++- src/active_class.ts | 18 ++- src/configuration.ts | 2 + src/index.js | 195 +++++++++++++++++------ src/listeners.ts | 10 ++ src/subtask.ts | 2 + src/toolbox_items/keybinds.ts | 8 + tests/class_allowed_modes.test.js | 247 ++++++++++++++++++++++++++++++ tests/class_focus.test.js | 17 ++ 12 files changed, 497 insertions(+), 60 deletions(-) diff --git a/.github/tasks.md b/.github/tasks.md index 2880f516..3f30f31b 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -586,7 +586,7 @@ opt-in, and give "set the active class" a real API instead of DOM clicks. ### Phase 9 - ULabel: `set_active_class` + `focus_active_class` -- [ ] 9.1 Public `set_active_class(class_id, subtask_key?)`: extract the body +- [x] 9.1 Public `set_active_class(class_id, subtask_key?)`: extract the body of `handle_soft_id_toolbox_button_click` (the `sel` swap, `set_id_dialog_payload_nopin`, dialog display update, active-annotation reclass, delete re-toggle, and the trailing @@ -597,14 +597,14 @@ opt-in, and give "set the active class" a real API instead of DOM clicks. activation, so state is written now and the DOM reconciles on switch. Subsumes 6.4: the state-based `get_active_class_id` (utilities) becomes canonical and the DOM-parsing method's call sites migrate. -- [ ] 9.2 Migrate the internal DOM-click workarounds to `set_active_class`: +- [x] 9.2 Migrate the internal DOM-click workarounds to `set_active_class`: `toggle_delete_class_id_in_toolbox` (3 trigger sites: delete-class on entry; first class or hovered annotation's class on exit), `update_id_toolbox_display` (state -> click -> handler -> state round trip), the class keybind handler (`listeners.ts` class_button click), and the soft-id handler's delete re-toggle. model-registry has no workarounds to migrate (verified: zero `toolbox_sel`/`id_payload` references). -- [ ] 9.3 Per-subtask `focus_active_class: boolean` (default false, so no +- [x] 9.3 Per-subtask `focus_active_class: boolean` (default false, so no behavior change for vanilla consumers - the defocus gates restrict hover/Tab/list, not just drawing, and must not engage unasked). When true, focus derives from the *persistent* selection: a new @@ -615,17 +615,39 @@ opt-in, and give "set the active class" a real API instead of DOM clicks. `DELETE_CLASS_ID`). The delete-class button is excluded from the focus path. Remove `set_class_focus` / `focused_class` as an independent axis; migrate the `class_focus` tests to selection-driven focus. -- [ ] 9.4 Focus-gate the bulk-delete collection loop +- [x] 9.4 Focus-gate the bulk-delete collection loop (`delete_polygon`/`delete_bbox`) on `is_annotation_defocused`. Restores the protection the removed `hidden` machinery had, and is what makes freeze-during-delete safe: focus scopes what is legible, interactive, navigable - and deletable. Single-annotation delete is already gated via hover (`get_edit_candidates` skips defocused). -- [ ] 9.5 model-registry: sidebar class rows / outcome legend call +- [x] 9.5 model-registry: sidebar class rows / outcome legend call `set_active_class` instead of `set_class_focus`; all three subtasks set `focus_active_class: true`. Matches the UI's actual invariant (always exactly one focused class); the null-focus branch was only reachable with an empty ontology. +- [x] 9.6 Enforce per-class modes on *reclassification*. The id-dialog pie, + the class keybind (already-selected branch), and `set_active_class`'s + reclass branch all funnel through `handle_id_dialog_click`, so one gate + there covers every gesture; `assign_annotation_id` stays ungated so + undo/redo replay history faithfully. New predicate + `can_annotation_be_class(annotation, class_id)` over + `get_class_allowed_modes`. Rejection: `shake_screen()` + quiet warning. + The gate checks only the *target* class, so an annotation whose current + class/type pairing is already invalid (bad import) can still be + reclassified to a valid class. +- [x] 9.7 The pie only offers classes compatible with the dialog's + annotation's spatial type (rebuilt per show when the compatible subset + changes; wedge hit-testing runs over the displayed subset and maps back to + the full class list). When fewer than two classes are compatible there is + nothing to choose, so no dialog appears at all. No shake on rejection - + the 9.6 gate stays as a warning-only backstop for keybind paths. +- [x] 9.8 Load-time validation: warn (never drop - the data is authoritative + and round-trips on export) when an imported annotation's class does not + allow its spatial type. NOTE: no subtask-level load check exists either + (`process_resume_from` only errors on *missing* type/payload); checking + against the class's effective modes covers both levels since class modes + are already a subset of the subtask's. ### Verification diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a3164d..5324bb89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ All notable changes to this project will be documented here. ## [unreleased] - New `set_active_class(class_id, subtask_key?, redraw?)` public API method: sets a subtask's active class (id payload, toolbox selection, id-dialog display, per-class mode sync) — previously only reachable by clicking the toolbox class button. All internal class-selection paths (delete-mode toggles, keybinds, id-dialog syncing) now route through it. New `get_selected_class_id(subtask_key?)` returns the last non-delete class selected. -- New per-subtask `focus_active_class` option (default `false`): the selected class becomes the focused class — other classes dim to `defocused_opacity` and drop out of hover, Tab navigation, the annotation list, and bulk delete. Replaces the separate `set_class_focus()` API (never released), so selection and focus can no longer disagree. The selection (and therefore focus) freezes at the last real class while a delete mode is active. Toggleable at runtime via `set_focus_active_class(subtask_key, enabled, redraw?)`. +- New per-subtask `focus_active_class` option (default `false`): the selected class becomes the focused class — other classes dim to `defocused_opacity` and drop out of hover, Tab navigation, the annotation list, and bulk delete. Replaces the separate `set_class_focus()` API (never released), so selection and focus can no longer disagree. The selection (and therefore focus) freezes at the last real class while a delete mode is active. Toggleable at runtime via `set_focus_active_class(subtask_key, enabled, redraw?)` or the `toggle_class_focus_keybind` (default `shift+f`, current subtask). - Bulk delete (`delete_polygon`/`delete_bbox`) now skips defocused annotations, so a focus-scoped view cannot delete what it has dimmed. +- Per-class `allowed_modes` are now enforced on reclassification: the id-dialog pie only offers classes that allow the annotation's spatial type. When fewer than two classes qualify, no pie appears and the edit-button ring collapses to the compact single-class layout (no reid button). Class keybinds and `set_active_class` refuse an incompatible reclass with a console warning. Undo/redo replay history unchanged. Importing an annotation whose class doesn't allow its spatial type logs a warning (the annotation still loads and round-trips). ## [0.28.0] - Sept 8th, 2026 - Removed unused per-subtask back canvas. diff --git a/api_spec.md b/api_spec.md index ac3fdda4..b501ee0d 100644 --- a/api_spec.md +++ b/api_spec.md @@ -81,6 +81,7 @@ class ULabel({ annotation_size_plus_keybind: string, annotation_size_minus_keybind: string, annotation_vanish_keybind: string, + toggle_class_focus_keybind: string, fly_to_max_zoom: number, min_zoom_fit_ratio: number, n_annos_per_canvas: number, @@ -641,6 +642,9 @@ Keybind to toggle vanish mode for annotations in the current subtask. Default is ### `annotation_vanish_all_keybind` Keybind to toggle vanish mode for all subtasks. Default is `shift+v` +### `toggle_class_focus_keybind` +Keybind to toggle `focus_active_class` on the current subtask: with it on, classes other than the active one dim to `defocused_opacity` and drop out of hover, navigation, the annotation list, and bulk delete. Default is `shift+f`. + ### `fly_to_max_zoom` Maximum zoom factor used when flying-to an annotation. Default is `10`, value must be > `0`. diff --git a/demo/class-focus.html b/demo/class-focus.html index d5e84af8..2fa4a8fb 100644 --- a/demo/class-focus.html +++ b/demo/class-focus.html @@ -207,7 +207,7 @@ "annotation_meta": null, "focus_active_class": true, // Make the dimming more dramatic than the 0.4 default - "defocused_opacity": 0.15, + "defocused_opacity": 0.2, } }; @@ -225,6 +225,7 @@ "subtasks": subtasks, "initial_line_size": 3, "toolbox_order": [ + AllowedToolboxItem.Keybinds, AllowedToolboxItem.SubmitButtons, AllowedToolboxItem.ModeSelect, AllowedToolboxItem.ZoomPan, @@ -239,15 +240,21 @@ console.log(ulabel); }); - // External control: toggle focus-follows-active-class on both subtasks - let focus_on = true; + // External control: toggle focus-follows-active-class on both subtasks. + // Derives from instance state so the shift+f keybind stays in sync. + function update_focus_label() { + const on = Object.keys(subtasks).some((key) => ulabel.subtasks[key].focus_active_class); + $("#toggle-focus").text("Class focus: " + (on ? "ON" : "OFF")); + } $("#toggle-focus").on("click", function () { - focus_on = !focus_on; + const next = !Object.keys(subtasks).every((key) => ulabel.subtasks[key].focus_active_class); for (const subtask_key of Object.keys(subtasks)) { - ulabel.set_focus_active_class(subtask_key, focus_on); + ulabel.set_focus_active_class(subtask_key, next); } - $(this).text("Class focus: " + (focus_on ? "ON" : "OFF")); + update_focus_label(); }); + // The keybind toggles only the current subtask; keep the label honest + $(document).on("keyup", update_focus_label); // Expose ulabel instance globally for testing window.ulabel = ulabel; diff --git a/src/active_class.ts b/src/active_class.ts index 2e8bdc44..cc4e28c4 100644 --- a/src/active_class.ts +++ b/src/active_class.ts @@ -9,7 +9,7 @@ * than the mechanism itself. */ -import type { ULabel } from "../index"; +import type { ULabel, ULabelSpatialType } from "../index"; import { DELETE_CLASS_ID, DELETE_MODES } from "./annotation"; import { ULabelSubtask } from "./subtask"; import { log_message, LogLevel } from "./error_logging"; @@ -150,6 +150,22 @@ export function set_defocused_opacity( } } +/** + * Whether an annotation's spatial type is allowed by a class's modes, i.e. + * whether the annotation may be reclassified to that class. + */ +export function can_annotation_be_class( + ulabel: ULabel, + annotation: { spatial_type?: string }, + class_id: number, + subtask_key: string | null = null, +): boolean { + if (annotation.spatial_type == null) return false; + return ulabel + .get_class_allowed_modes(class_id, subtask_key ?? ulabel.get_current_subtask_key()) + .includes(annotation.spatial_type as ULabelSpatialType); +} + /** * Point a subtask's id payload at one class (full confidence, others zero). * Equivalent to `set_id_dialog_payload_nopin(idx, 1.0)` but subtask-scoped. diff --git a/src/configuration.ts b/src/configuration.ts index 0779cd6e..b619a546 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -267,6 +267,8 @@ export class Configuration { public annotation_vanish_all_keybind: string = "shift+v"; + public toggle_class_focus_keybind: string = "shift+f"; + public fly_to_next_annotation_keybind: string = "tab"; public fly_to_previous_annotation_keybind: string = "shift+tab"; diff --git a/src/index.js b/src/index.js index 31a5a4e4..28f46d20 100644 --- a/src/index.js +++ b/src/index.js @@ -32,7 +32,7 @@ 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, is_raw_mask_payload } from "../build/mask_utils"; import { get_active_class_id, get_local_storage_item, set_local_storage_item } from "../build/utilities"; -import { set_active_class, get_selected_class_id, set_focus_active_class, set_defocused_opacity } from "../build/active_class"; +import { set_active_class, get_selected_class_id, set_focus_active_class, set_defocused_opacity, can_annotation_be_class } from "../build/active_class"; import { get_idd_string } from "../build/html_builder"; import $ from "jquery"; @@ -523,6 +523,18 @@ export class ULabel { }, ); + // Warn (never drop: the data is authoritative and round-trips on + // export) when the class doesn't allow the annotation's spatial type + const cand_class_id = parseInt(get_annotation_class_id(cand)); + if (!ul.get_class_allowed_modes(cand_class_id, subtask_key).includes(cand.spatial_type)) { + log_message( + `Annotation ${cand.id} has spatial type ${cand.spatial_type}, ` + + `which class ${cand_class_id} in subtask "${subtask_key}" does not allow`, + LogLevel.WARNING, + true, + ); + } + // Push to ordering and add to access ul.subtasks[subtask_key]["annotations"]["ordering"].push(cand.id); ul.subtasks[subtask_key]["annotations"]["access"][cand.id] = cand; @@ -588,6 +600,9 @@ export class ULabel { idd_thumbnail: false, id_payload: id_payload, delete_mode_id_payload: [{ class_id: -1, confidence: 1 }], + // Class ids currently rendered in this subtask's pies; shrinks to + // the classes compatible with the dialog's annotation + idd_displayed_class_ids: [...ul.subtasks[subtask_key]["class_ids"]], first_explicit_assignment: false, // Annotation state @@ -1893,27 +1908,39 @@ export class ULabel { } /** - * Rebuild every subtask's id-dialog color pies from the current `color_info`. + * Rebuild every subtask's id-dialog color pies from the current `color_info`, + * keeping each pie's current class subset. */ rebuild_id_dialog_pies() { + for (const subtask_key in this.subtasks) { + this._rebuild_subtask_pies(subtask_key); + } + } + + /** + * Rebuild one subtask's id-dialog pies, showing only `displayed_class_ids` + * (defaults to the subset already rendered). + */ + _rebuild_subtask_pies(subtask_key, displayed_class_ids = null) { + const subtask = this.subtasks[subtask_key]; + displayed_class_ids ??= subtask["state"]["idd_displayed_class_ids"]; + subtask["state"]["idd_displayed_class_ids"] = displayed_class_ids; + const width = this.config["outer_diameter"]; const inner_radius = this.config["inner_prop"] * width / 2; - for (const subtask_key in this.subtasks) { - const subtask = this.subtasks[subtask_key]; - const idd_id = subtask["state"]["idd_id"]; - const idd_id_front = subtask["state"]["idd_id_front"]; + const idd_id = subtask["state"]["idd_id"]; + const idd_id_front = subtask["state"]["idd_id_front"]; - const dialog_html = get_idd_string(idd_id, width, subtask["class_ids"], inner_radius, this.color_info); - const front_dialog_html = get_idd_string(idd_id_front, width, subtask["class_ids"], inner_radius, this.color_info); + const dialog_html = get_idd_string(idd_id, width, displayed_class_ids, inner_radius, this.color_info); + const front_dialog_html = get_idd_string(idd_id_front, width, displayed_class_ids, inner_radius, this.color_info); - $("#" + idd_id).remove(); - $("#" + idd_id_front).remove(); - $("#dialogs__" + subtask_key).append(dialog_html); - $("#front_dialogs__" + subtask_key).append(front_dialog_html); - } + $("#" + idd_id).remove(); + $("#" + idd_id_front).remove(); + $("#dialogs__" + subtask_key).append(dialog_html); + $("#front_dialogs__" + subtask_key).append(front_dialog_html); // Replacing the pies dropped their hover listeners; re-add - $(".id_dialog").on("mousemove.ulabel", (mouse_event) => { + $("#" + idd_id + ", #" + idd_id_front).on("mousemove.ulabel", (mouse_event) => { if (!this.get_current_subtask()["state"]["idd_thumbnail"]) { this.handle_id_dialog_hover(mouse_event); } @@ -3886,10 +3913,21 @@ export class ULabel { let idd_x; let idd_y; const is_read_only = this.is_current_subtask_read_only(); + // With fewer than two compatible classes there is nothing to reassign + // to, so the reid button and the pie thumbnail don't apply (the same + // treatment single_class_mode gives every annotation) + const can_reassign = this._get_compatible_class_ids( + current_subtask["annotations"]["access"][annid], + ).length > 1; if (nonspatial_id === null) { let esid = "global_edit_suggestion__" + subtask_key; var esjq = $("#" + esid); esjq.css("display", "block"); + // Collapse to the compact single-class ring when there is nothing to + // reassign to: `mcm` carries the wide 3-slot width and scale, and the + // reid button drops out of flow so move/delete close the gap. + esjq.toggleClass("mcm", !current_subtask["single_class_mode"] && can_reassign); + esjq.find("a.reid_suggestion").css("display", can_reassign ? "" : "none"); // Hide the move/reid/delete buttons on read-only subtasks; use visibility rather than // display so the button ring geometry stays measurable for the dialogs around it. esjq.find(".global_sub_suggestion").css("visibility", is_read_only ? "hidden" : ""); @@ -3936,9 +3974,13 @@ export class ULabel { } // let placeholder = $("#global_edit_suggestion a.reid_suggestion"); - if (!current_subtask["single_class_mode"] && !is_read_only) { + if (!current_subtask["single_class_mode"] && !is_read_only && can_reassign) { // Show id dialog thumbnail this.show_id_dialog(idd_x, idd_y, annid, true, nonspatial_id != null); + } else { + // can_reassign varies per annotation, so a thumbnail shown for the + // previously hovered annotation must not linger over this one + this.hide_id_dialog(); } } @@ -3948,10 +3990,37 @@ export class ULabel { this.set_hovered_annotation(null); } + /** + * Class ids in the current subtask that can take an annotation's spatial + * type. A null annotation places no restriction. + */ + _get_compatible_class_ids(annotation = null) { + const class_ids = this.get_current_subtask()["class_ids"]; + if (annotation == null) return class_ids; + return class_ids.filter((class_id) => can_annotation_be_class(this, annotation, class_id)); + } + // ID dialog: color wheel to change the ID of an annotation show_id_dialog(gbx, gby, active_ann, thumbnail = false, nonspatial = false) { let stkey = this.get_current_subtask_key(); + // Only offer classes that can take this annotation's spatial type. With + // fewer than two options there is nothing to choose: no dialog at all. + const displayed_class_ids = this._get_compatible_class_ids( + this.get_current_subtask()["annotations"]["access"][active_ann] ?? null, + ); + if (displayed_class_ids.length <= 1) { + this.hide_id_dialog(); + return; + } + const rendered = this.get_current_subtask()["state"]["idd_displayed_class_ids"]; + if ( + displayed_class_ids.length !== rendered.length || + displayed_class_ids.some((class_id, idx) => class_id !== rendered[idx]) + ) { + this._rebuild_subtask_pies(stkey, displayed_class_ids); + } + // Record which annotation this dialog is associated with // TODO // am_dialog_associated_ann = active_ann; @@ -4037,12 +4106,25 @@ export class ULabel { } hide_id_dialog() { - let idd_id = this.get_current_subtask()["state"]["idd_id"]; - let idd_id_front = this.get_current_subtask()["state"]["idd_id_front"]; - this.get_current_subtask()["state"]["idd_visible"] = false; - this.get_current_subtask()["state"]["idd_associated_annotation"] = null; + const current_subtask = this.get_current_subtask(); + let idd_id = current_subtask["state"]["idd_id"]; + let idd_id_front = current_subtask["state"]["idd_id_front"]; + current_subtask["state"]["idd_visible"] = false; + current_subtask["state"]["idd_associated_annotation"] = null; $("#" + idd_id).css("display", "none"); $("#" + idd_id_front).css("display", "none"); + + // The dialog borrows id_payload as its display model (it holds the shown + // annotation's classes while open); restore the persistent selection so + // the next draw uses the selected class, not the last-hovered one. + // Delete modes keep their own payload. + if (!DELETE_MODES.includes(current_subtask["state"]["annotation_mode"])) { + const selected_class_id = get_selected_class_id(this, this.get_current_subtask_key()); + const selected_idx = current_subtask["class_ids"].indexOf(selected_class_id); + if (selected_idx !== -1) { + this.set_id_dialog_payload_nopin(selected_idx, 1.0); + } + } } // ================= Annotation Utilities ================= @@ -6830,17 +6912,18 @@ export class ULabel { return null; } - // Get array of classes by name in the dialog - // TODO handle nesting case - // TODO this is not efficient - let class_ids = this.get_current_subtask()["class_ids"]; + // Get array of classes shown in the dialog (a subset when some classes + // can't take the dialog's annotation's spatial type) + const displayed_class_ids = this.get_current_subtask()["state"]["idd_displayed_class_ids"]; - // Get the index of that class currently hovering over - const class_ind = ( + // Get the index of the wedge currently hovered, then translate it back + // to the subtask's full class list for the callers + const wedge_ind = ( -1 * Math.floor( - Math.atan2(idd_y, idd_x) / (2 * Math.PI) * class_ids.length, - ) + class_ids.length - ) % class_ids.length; + Math.atan2(idd_y, idd_x) / (2 * Math.PI) * displayed_class_ids.length, + ) + displayed_class_ids.length + ) % displayed_class_ids.length; + const class_ind = this.get_current_subtask()["class_ids"].indexOf(displayed_class_ids[wedge_ind]); // Get the distance proportion of the hover let dist_prop = (mouse_rad - inner_rad) / (outer_rad - inner_rad); @@ -6930,13 +7013,15 @@ export class ULabel { update_id_dialog_display(front = false) { const inner_rad = this.config["inner_prop"] * this.config["outer_diameter"] / 2; const outer_rad = 0.5 * this.config["outer_diameter"]; - let class_ids = this.get_current_subtask()["class_ids"]; - for (var i = 0; i < class_ids.length; i++) { - // Skip - let srt_prop = this.get_current_subtask()["state"]["id_payload"][i]["confidence"]; - - let cum_prop = i / class_ids.length; - let srk_prop = 1 / class_ids.length; + const class_ids = this.get_current_subtask()["class_ids"]; + // The pie may show a subset; its geometry is sized to that subset + const displayed_class_ids = this.get_current_subtask()["state"]["idd_displayed_class_ids"]; + for (var i = 0; i < displayed_class_ids.length; i++) { + const payload = this.get_current_subtask()["state"]["id_payload"][class_ids.indexOf(displayed_class_ids[i])]; + let srt_prop = payload?.["confidence"] ?? 0; + + let cum_prop = i / displayed_class_ids.length; + let srk_prop = 1 / displayed_class_ids.length; let gap_prop = 1.0 - srk_prop; let rad_frnt = inner_rad + srt_prop * (outer_rad - inner_rad) / 2; @@ -6947,24 +7032,13 @@ export class ULabel { let gap_frnt = 2 * Math.PI * rad_frnt * gap_prop; let off_frnt = 2 * Math.PI * rad_frnt * cum_prop; - // TODO this is kind of a mess. If it works as is, the commented region below should be deleted - // var circ = document.getElementById("circ_" + class_ids[i]); - // circ.setAttribute("r", rad_frnt); - // circ.setAttribute("stroke-dasharray", `${srk_frnt} ${gap_frnt}`); - // circ.setAttribute("stroke-dashoffset", off_frnt); - // circ.setAttribute("stroke-width", wdt_frnt); let idd_id; if (!front) { idd_id = this.get_current_subtask()["state"]["idd_id"]; } else { idd_id = this.get_current_subtask()["state"]["idd_id_front"]; } - var circ = $(`#${idd_id}__circ_` + class_ids[i]); - // circ.attr("r", rad_frnt); - // circ.attr("stroke-dasharray", `${srk_frnt} ${gap_frnt}`) - // circ.attr("stroke-dashoffset", off_frnt) - // circ.attr("stroke-width", wdt_frnt) - // circ = $(`#${idd_id}__circ_` + class_ids[i]) + var circ = $(`#${idd_id}__circ_` + displayed_class_ids[i]); circ.attr("r", rad_frnt); circ.attr("stroke-dasharray", `${srk_frnt} ${gap_frnt}`); circ.attr("stroke-dashoffset", off_frnt); @@ -7082,6 +7156,33 @@ export class ULabel { handle_id_dialog_click(mouse_event, annotation_id = null, new_class_idx = null) { const current_subtask = this.get_current_subtask(); + // Reject a class that doesn't allow the annotation's spatial type. Only + // user gestures route through here; undo/redo replay through + // assign_annotation_id directly and stay faithful to history. + const target_annotation_id = annotation_id ?? current_subtask["state"]["idd_associated_annotation"]; + const annotation = current_subtask["annotations"]["access"][target_annotation_id]; + if (annotation == null) { + // A stale dialog (e.g. shown before its association was cleared) must + // not fall through to an assignment with no target + log_message("handle_id_dialog_click: no annotation is associated with the id dialog", LogLevel.WARNING, true); + return; + } + let target_idx = new_class_idx; + if (target_idx === null) { + // Pie click: resolve the wedge from the click position rather than + // id_payload, which the (gated) hover may not have updated + const front = current_subtask["state"]["idd_which"] === "front"; + target_idx = this.lookup_id_dialog_mouse_pos(mouse_event, front)?.class_ind ?? null; + } + if (target_idx !== null) { + const target_class_id = current_subtask["class_ids"][target_idx]; + // Silently refuse a class that doesn't allow the annotation's spatial + // type (reachable via class keybinds; the pie excludes such classes) + if (!can_annotation_be_class(this, annotation, target_class_id)) { + return; + } + } + // Handle explicitly setting the class if (new_class_idx !== null) { const pos_evt = { class_ind: new_class_idx, dist_prop: 1.0 }; diff --git a/src/listeners.ts b/src/listeners.ts index aef207d0..dff1d5d6 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -164,6 +164,13 @@ function handle_keypress_event( return; } + // Toggle class focus (focus follows the active class) on the current subtask + if (event_matches_keybind(keypress_event, ulabel.config.toggle_class_focus_keybind)) { + const st_key = ulabel.get_current_subtask_key(); + ulabel.set_focus_active_class(st_key, !ulabel.subtasks[st_key].focus_active_class); + return; + } + // Check for class keybinds if (!DELETE_MODES.includes(current_subtask.state.spatial_type)) { for (let i = 0; i < current_subtask.class_defs.length; i++) { @@ -629,6 +636,9 @@ export function create_ulabel_listeners( (click_event) => { const crst = ulabel.get_current_subtask(); const annid = crst["state"]["idd_associated_annotation"]; + // No association means no dialog to open (e.g. it was suppressed + // because the annotation has no valid reassignment targets) + if (annid == null) return; ulabel.hide_global_edit_suggestion(); ulabel.show_id_dialog( ulabel.get_global_mouse_x(click_event), diff --git a/src/subtask.ts b/src/subtask.ts index d942e09c..5a24b31f 100644 --- a/src/subtask.ts +++ b/src/subtask.ts @@ -38,6 +38,8 @@ export class ULabelSubtask { idd_id_front: string; idd_thumbnail: boolean; idd_visible: boolean; + // Class ids currently rendered in the pies (compatible-class subset) + idd_displayed_class_ids: number[]; is_in_edit: boolean; is_in_move: boolean; is_in_progress: boolean; diff --git a/src/toolbox_items/keybinds.ts b/src/toolbox_items/keybinds.ts index a5b56c48..2d893f0f 100644 --- a/src/toolbox_items/keybinds.ts +++ b/src/toolbox_items/keybinds.ts @@ -340,6 +340,14 @@ export class KeybindsToolboxItem extends ToolboxItem { config_key: "toggle_annotation_mode_keybind", }); + keybinds.push({ + key: config.toggle_class_focus_keybind, + label: "Toggle Class Focus", + description: "Focus the active class: other classes dim and drop out of hover and navigation", + configurable: true, + config_key: "toggle_class_focus_keybind", + }); + keybinds.push({ key: config.create_bbox_on_initial_crop_keybind, label: "Create BBox on Crop", diff --git a/tests/class_allowed_modes.test.js b/tests/class_allowed_modes.test.js index 052452b2..17fb3d5c 100644 --- a/tests/class_allowed_modes.test.js +++ b/tests/class_allowed_modes.test.js @@ -195,3 +195,250 @@ describe("findAllClassDefinitions", () => { expect(names).toEqual(["Crop", "Row", "Any"]); }); }); + +describe("reclassification gate", () => { + // A polyline Row annotation: Crop (bbox-only) must refuse it + function load_polyline(ulabel) { + const annotation = { + id: "row0", + spatial_type: "polyline", + spatial_payload: [[0, 0], [10, 10]], + classification_payloads: [ + { class_id: 1, confidence: 0 }, + { class_id: 2, confidence: 1 }, + { class_id: 3, confidence: 0 }, + ], + deprecated: false, + }; + ulabel.subtasks.st.annotations = { + access: { row0: annotation }, + ordering: ["row0"], + }; + return annotation; + } + + function make_gated_ulabel() { + const ulabel = make_ulabel(make_config([CROP, ROW, ANY])); + ulabel.assign_annotation_id = jest.fn(); + return ulabel; + } + + test("blocks an explicit reclass to a class that disallows the spatial type", () => { + const ulabel = make_gated_ulabel(); + load_polyline(ulabel); + + // Crop is index 0 (bbox only) + ulabel.handle_id_dialog_click(null, "row0", 0); + + expect(ulabel.assign_annotation_id).not.toHaveBeenCalled(); + }); + + test("allows a reclass the class's modes permit", () => { + const ulabel = make_gated_ulabel(); + load_polyline(ulabel); + + // Any inherits the subtask's modes, which include polyline + ulabel.handle_id_dialog_click(null, "row0", 2); + + expect(ulabel.assign_annotation_id).toHaveBeenCalledWith("row0"); + }); + + test("blocks a pie click resolved from the mouse position", () => { + const ulabel = make_gated_ulabel(); + load_polyline(ulabel); + ulabel.subtasks.st.state.idd_associated_annotation = "row0"; + // Wedge under the cursor is Crop + ulabel.lookup_id_dialog_mouse_pos = jest.fn().mockReturnValue({ class_ind: 0, dist_prop: 1.0 }); + + ulabel.handle_id_dialog_click({}); + + expect(ulabel.assign_annotation_id).not.toHaveBeenCalled(); + }); + + test("a dialog click with no associated annotation is a safe no-op", () => { + const ulabel = make_gated_ulabel(); + load_polyline(ulabel); + ulabel.subtasks.st.state.idd_associated_annotation = null; + + expect(() => ulabel.handle_id_dialog_click({})).not.toThrow(); + expect(ulabel.assign_annotation_id).not.toHaveBeenCalled(); + }); + + test("undo/redo path stays ungated", () => { + const ulabel = make_ulabel(make_config([CROP, ROW, ANY])); + const annotation = load_polyline(ulabel); + // Replaying redraws the annotation and toolbox; only the gate bypass is under test + ulabel.redraw_annotation = jest.fn(); + ulabel.toolbox = { redraw_update_items: jest.fn() }; + + // Replaying history writes the payload regardless of class modes + ulabel.assign_annotation_id("row0", { + old_id_payload: annotation.classification_payloads, + new_id_payload: [ + { class_id: 1, confidence: 1 }, + { class_id: 2, confidence: 0 }, + { class_id: 3, confidence: 0 }, + ], + }); + + expect(annotation.classification_payloads[0].confidence).toBe(1); + }); +}); + +describe("pie class exclusion", () => { + function load_annotation(ulabel, spatial_type) { + const annotation = { + id: "a0", + spatial_type, + spatial_payload: [[0, 0], [10, 10]], + classification_payloads: [ + { class_id: 1, confidence: 0 }, + { class_id: 2, confidence: 1 }, + { class_id: 3, confidence: 0 }, + ], + deprecated: false, + }; + ulabel.subtasks.st.annotations = { + access: { a0: annotation }, + ordering: ["a0"], + }; + return annotation; + } + + // show_id_dialog's non-suppressed path needs the reid button + dialog DOM + function scaffold_dialog_dom(ulabel) { + const idd_id = ulabel.subtasks.st.state.idd_id; + const idd_id_front = ulabel.subtasks.st.state.idd_id_front; + document.body.innerHTML = ` +
+
+
+ `; + } + + test("the pie only offers classes compatible with the annotation", () => { + const ulabel = make_ulabel(make_config([CROP, ROW, ANY])); + load_annotation(ulabel, "polyline"); + scaffold_dialog_dom(ulabel); + + ulabel.show_id_dialog(10, 10, "a0", true); + + // Crop (bbox-only) is excluded; Row and Any remain + expect(ulabel.subtasks.st.state.idd_displayed_class_ids).toEqual([2, 3]); + const idd_id = ulabel.subtasks.st.state.idd_id; + expect(document.querySelector(`#${idd_id}__circ_1`)).toBeNull(); + expect(document.querySelector(`#${idd_id}__circ_2`)).not.toBeNull(); + expect(ulabel.subtasks.st.state.idd_visible).toBe(true); + }); + + test("no dialog appears when only one class can take the type", () => { + // Crop is the only bbox-capable class here + const ulabel = make_ulabel(make_config([CROP, ROW], ["bbox", "polyline"])); + load_annotation(ulabel, "bbox"); + scaffold_dialog_dom(ulabel); + + ulabel.show_id_dialog(10, 10, "a0", true); + + expect(ulabel.subtasks.st.state.idd_visible).toBe(false); + }); + + test("wedge hit-testing maps back to the full class list", () => { + const ulabel = make_ulabel(make_config([CROP, ROW, ANY])); + load_annotation(ulabel, "polyline"); + // Pie shows [2, 3]; the wedge math must return full-list indices + ulabel.subtasks.st.state.idd_displayed_class_ids = [2, 3]; + const idd_id = ulabel.subtasks.st.state.idd_id; + document.body.innerHTML = `
`; + const dialog = document.getElementById(idd_id); + dialog.getBoundingClientRect = () => ({ left: 0, top: 0, width: 200, height: 200 }); + + // Hover on the right side of the ring (angle 0 -> first wedge = class 2) + const pos_evt = ulabel.lookup_id_dialog_mouse_pos({ pageX: 180, pageY: 100 }, false); + + expect(pos_evt).not.toBeNull(); + // class 2 sits at index 1 of the full class list + expect(pos_evt.class_ind).toBe(1); + }); + + test("the button ring collapses like single-class mode when nothing can be reassigned", () => { + const ulabel = make_ulabel(make_config([CROP, ROW, ANY])); + // bbox: only Crop and Any qualify (2 targets); polyline: Row and Any (2 targets); + // narrow Any to make bbox single-target + ulabel.subtasks.st.class_defs[2].allowed_modes = ["polyline"]; + const annotation = load_annotation(ulabel, "bbox"); + annotation.classification_payloads = [ + { class_id: 1, confidence: 1 }, + { class_id: 2, confidence: 0 }, + { class_id: 3, confidence: 0 }, + ]; + annotation.containing_box = { tlx: 0, tly: 0, brx: 10, bry: 10 }; + document.body.innerHTML = ` +
+ + + +
+ `; + ulabel.subtasks.st.state.visible_dialogs["global_edit_suggestion__st"] = { left: 0, top: 0, pin: "center" }; + ulabel.config.image_width = 100; + ulabel.config.image_height = 100; + + // bbox can only be Crop -> compact ring, no reid button + ulabel.show_global_edit_suggestion("a0"); + + const container = document.getElementById("global_edit_suggestion__st"); + expect(container.classList.contains("mcm")).toBe(false); + expect(document.querySelector("a.reid_suggestion").style.display).toBe("none"); + }); + + test("a thumbnail from the previous hover hides when the next annotation has no targets", () => { + const ulabel = make_ulabel(make_config([CROP, ROW, ANY])); + ulabel.subtasks.st.class_defs[2].allowed_modes = ["polyline"]; + const annotation = load_annotation(ulabel, "bbox"); + annotation.classification_payloads = [{ class_id: 1, confidence: 1 }]; + annotation.containing_box = { tlx: 0, tly: 0, brx: 10, bry: 10 }; + document.body.innerHTML = `
`; + ulabel.subtasks.st.state.visible_dialogs["global_edit_suggestion__st"] = { left: 0, top: 0, pin: "center" }; + ulabel.config.image_width = 100; + ulabel.config.image_height = 100; + // A pie left visible by the previously hovered annotation + ulabel.subtasks.st.state.idd_visible = true; + ulabel.subtasks.st.state.idd_associated_annotation = "other"; + + ulabel.show_global_edit_suggestion("a0"); + + expect(ulabel.subtasks.st.state.idd_visible).toBe(false); + expect(ulabel.subtasks.st.state.idd_associated_annotation).toBeNull(); + }); +}); + +describe("load-time class/type validation", () => { + test("warns when an imported annotation's class disallows its spatial type", () => { + const config = make_config([CROP, ROW, ANY]); + // A polyline claiming to be Crop (bbox-only) + config.subtasks.st.resume_from = [{ + spatial_type: "polyline", + spatial_payload: [[0, 0], [10, 10]], + classification_payloads: [{ class_id: 1, confidence: 1 }], + }]; + + const ulabel = new ULabel(config); + + // Warn, never drop: the data must round-trip on export + expect(ulabel.subtasks.st.annotations.ordering).toHaveLength(1); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("does not allow")); + }); + + test("stays quiet for a compatible import", () => { + const config = make_config([CROP, ROW, ANY]); + config.subtasks.st.resume_from = [{ + spatial_type: "polyline", + spatial_payload: [[0, 0], [10, 10]], + classification_payloads: [{ class_id: 2, confidence: 1 }], + }]; + + new ULabel(config); + + expect(console.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/class_focus.test.js b/tests/class_focus.test.js index 9d6a9ef6..9a56d369 100644 --- a/tests/class_focus.test.js +++ b/tests/class_focus.test.js @@ -136,6 +136,23 @@ describe("set_active_class", () => { expect(console.warn).not.toHaveBeenCalled(); expect(ulabel.get_selected_class_id("st")).toBe(1); }); + + test("a hovered annotation's dialog does not change what class draws next", () => { + const ulabel = new ULabel(mock_config); + ulabel.state.current_subtask = "st"; + ulabel.set_active_class(1, "st", false); + const weed = make_annotation(2); + load(ulabel, [weed]); + + // The dialog borrows id_payload to display the hovered annotation's class + ulabel.set_id_dialog_payload_to_init(weed.id); + // ...and hiding it must hand the payload back to the selection + ulabel.hide_id_dialog(); + + const payload = ulabel.subtasks.st.state.id_payload; + expect(payload.find((p) => p.class_id === 1).confidence).toBe(1); + expect(ulabel.get_selected_class_id("st")).toBe(1); + }); }); describe("delete modes freeze the selection", () => { From 51a6c1af49e458363366c69fc5a04e24c8840005 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 9 Sep 2026 12:55:55 -0500 Subject: [PATCH 18/29] misc bug fixes --- CHANGELOG.md | 1 + demo/class-focus.html | 4 ++-- src/index.js | 3 +-- src/initializer.ts | 9 +++++++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5324bb89..11f5142d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented here. - New `set_active_class(class_id, subtask_key?, redraw?)` public API method: sets a subtask's active class (id payload, toolbox selection, id-dialog display, per-class mode sync) — previously only reachable by clicking the toolbox class button. All internal class-selection paths (delete-mode toggles, keybinds, id-dialog syncing) now route through it. New `get_selected_class_id(subtask_key?)` returns the last non-delete class selected. - New per-subtask `focus_active_class` option (default `false`): the selected class becomes the focused class — other classes dim to `defocused_opacity` and drop out of hover, Tab navigation, the annotation list, and bulk delete. Replaces the separate `set_class_focus()` API (never released), so selection and focus can no longer disagree. The selection (and therefore focus) freezes at the last real class while a delete mode is active. Toggleable at runtime via `set_focus_active_class(subtask_key, enabled, redraw?)` or the `toggle_class_focus_keybind` (default `shift+f`, current subtask). - Bulk delete (`delete_polygon`/`delete_bbox`) now skips defocused annotations, so a focus-scoped view cannot delete what it has dimmed. +- Fix stale containing boxes when `allow_annotations_outside_image = false` clamps loaded annotations at init: the boxes were built from the pre-clamp payloads, anchoring the hover dialogs (and hit-testing) off the image. They are now rebuilt after the clamp. - Per-class `allowed_modes` are now enforced on reclassification: the id-dialog pie only offers classes that allow the annotation's spatial type. When fewer than two classes qualify, no pie appears and the edit-button ring collapses to the compact single-class layout (no reid button). Class keybinds and `set_active_class` refuse an incompatible reclass with a console warning. Undo/redo replay history unchanged. Importing an annotation whose class doesn't allow its spatial type logs a warning (the annotation still loads and round-trips). ## [0.28.0] - Sept 8th, 2026 diff --git a/demo/class-focus.html b/demo/class-focus.html index 2fa4a8fb..694e0f1b 100644 --- a/demo/class-focus.html +++ b/demo/class-focus.html @@ -165,7 +165,7 @@ "color": "orange", "id": 11, "keybind": "2", - "allowed_modes": ["bbox", "polygon"], + "allowed_modes": ["bbox", "polygon", "bitmask"], }, { "name": "Lane", @@ -177,7 +177,7 @@ ], // The union of the classes' modes, plus delete modes to // exercise the focus freeze + focus-gated bulk delete - "allowed_modes": ["bbox", "polygon", "polyline", "delete_polygon", "delete_bbox"], + "allowed_modes": ["bbox", "polygon", "polyline", "bitmask", "delete_polygon", "delete_bbox"], "resume_from": vehicle_annotations, "task_meta": null, "annotation_meta": null, diff --git a/src/index.js b/src/index.js index 28f46d20..fe118f33 100644 --- a/src/index.js +++ b/src/index.js @@ -1497,9 +1497,8 @@ export class ULabel { // Delete modes are exempt: they remove annotations rather than create them if (!DELETE_MODES.includes(annotation_mode)) { const class_id = get_active_class_id(this); + // Silent: callers probe modes (e.g. brush toggle) and expect false if (!this.get_class_allowed_modes(class_id).includes(annotation_mode)) { - // Callers probe modes and expect false, so this must not alert. - log_message(`Annotation mode ${annotation_mode} is not allowed for class ${class_id}`, LogLevel.WARNING, true); return false; } } diff --git a/src/initializer.ts b/src/initializer.ts index aab24046..7c39fd58 100644 --- a/src/initializer.ts +++ b/src/initializer.ts @@ -12,7 +12,7 @@ import { add_style_to_document, build_confidence_dialog, build_edit_suggestion, import { create_ulabel_listeners } from "./listeners"; import { ULabelLoader } from "./loader"; import { ULabelSubtask } from "./subtask"; -import { ULabelAnnotation } from "./annotation"; +import { ULabelAnnotation, NONSPATIAL_MODES } from "./annotation"; import { get_local_storage_item } from "./utilities"; /** @@ -176,9 +176,14 @@ export async function ulabel_init( if (!ulabel.config.allow_annotations_outside_image) { const image_height = ulabel.config["image_height"]; const image_width = ulabel.config["image_width"]; - for (const subtask of Object.values(ulabel.subtasks) as ULabelSubtask[]) { + for (const subtask_key in ulabel.subtasks) { + const subtask: ULabelSubtask = ulabel.subtasks[subtask_key]; for (const anno of Object.values(subtask.annotations.access) as ULabelAnnotation[]) { + if (NONSPATIAL_MODES.includes(anno.spatial_type!)) continue; anno.clamp_annotation_to_image_bounds(image_width!, image_height!); + // The containing box was built from the pre-clamp payload; a stale + // box anchors the hover dialogs (and hit-testing) off the image + ulabel.rebuild_containing_box(anno.id!, false, subtask_key); } } } From f6542fdeebdc39e52fcb1284d92bcdd54c70d5d8 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 9 Sep 2026 13:07:06 -0500 Subject: [PATCH 19/29] fix browser install --- .github/workflows/test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13a56693..f54c78a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,12 @@ jobs: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + # playwright's dep install runs apt-get update, which fails outright when any + # configured repo is mid-republish (hash mismatch). The runner image ships + # Google's and Microsoft's repos; this job needs neither, so drop them. + - name: Remove unneeded third-party apt repos + run: sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/apt/sources.list.d/microsoft-prod.list + - name: Install Playwright browsers if: steps.playwright-cache.outputs.cache-hit != 'true' run: npx playwright install --with-deps From 551ce4d0d1e92548d4c45fe8838a7bb761c37f15 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 9 Sep 2026 13:35:20 -0500 Subject: [PATCH 20/29] add on_change listeners for active class and subtask --- .github/tasks.md | 6 +++ CHANGELOG.md | 1 + api_spec.md | 10 +++- index.d.ts | 4 ++ src/active_class.ts | 6 +++ src/configuration.ts | 4 ++ src/index.js | 4 ++ tests/host_callbacks.test.js | 95 ++++++++++++++++++++++++++++++++++++ 8 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 tests/host_callbacks.test.js diff --git a/.github/tasks.md b/.github/tasks.md index 3f30f31b..d27baa98 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -648,6 +648,12 @@ opt-in, and give "set the active class" a real API instead of DOM clicks. (`process_resume_from` only errors on *missing* type/payload); checking against the class's effective modes covers both levels since class modes are already a subset of the subtask's. +- [x] 9.9 Host callbacks `on_active_class_change(subtask_key, class_id)` and + `on_subtask_change(subtask_key, old_subtask_key)` config options, fired + from the single writers (`set_active_class` / `set_subtask`) only on + actual change. Needed because the Keybinds toolbox item lets users bind + class-select keys at runtime, so ULabel-side class changes are reachable + even when the host ships `keybind: null` and no id toolbox. ### Verification diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f5142d..c34b5379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented here. - New per-subtask `focus_active_class` option (default `false`): the selected class becomes the focused class — other classes dim to `defocused_opacity` and drop out of hover, Tab navigation, the annotation list, and bulk delete. Replaces the separate `set_class_focus()` API (never released), so selection and focus can no longer disagree. The selection (and therefore focus) freezes at the last real class while a delete mode is active. Toggleable at runtime via `set_focus_active_class(subtask_key, enabled, redraw?)` or the `toggle_class_focus_keybind` (default `shift+f`, current subtask). - Bulk delete (`delete_polygon`/`delete_bbox`) now skips defocused annotations, so a focus-scoped view cannot delete what it has dimmed. - Fix stale containing boxes when `allow_annotations_outside_image = false` clamps loaded annotations at init: the boxes were built from the pre-clamp payloads, anchoring the hover dialogs (and hit-testing) off the image. They are now rebuilt after the clamp. +- New host callback config options `on_active_class_change(subtask_key, class_id)` and `on_subtask_change(subtask_key, old_subtask_key)`: fired only when the value actually changes, from any writer (API call, toolbox click, or keybind — including keybinds users customize through the `Keybinds` toolbox item). Lets hosts keep their own UI in sync without polling or wrapping methods via `on()`. - Per-class `allowed_modes` are now enforced on reclassification: the id-dialog pie only offers classes that allow the annotation's spatial type. When fewer than two classes qualify, no pie appears and the edit-button ring collapses to the compact single-class layout (no reid button). Class keybinds and `set_active_class` refuse an incompatible reclass with a console warning. Undo/redo replay history unchanged. Importing an annotation whose class doesn't allow its spatial type logs a warning (the annotation still loads and round-trips). ## [0.28.0] - Sept 8th, 2026 diff --git a/api_spec.md b/api_spec.md index b501ee0d..0f8393ff 100644 --- a/api_spec.md +++ b/api_spec.md @@ -85,7 +85,9 @@ class ULabel({ fly_to_max_zoom: number, min_zoom_fit_ratio: number, n_annos_per_canvas: number, - auto_destroy_on_detach: boolean + auto_destroy_on_detach: boolean, + on_active_class_change: function, + on_subtask_change: function }) ``` @@ -665,6 +667,12 @@ When `true` (the default), ULabel installs a `MutationObserver` on the container > **Same-id replacement caveat.** With the default `true`, the one-frame grace period means a caller who removes the old container and mounts a new `
` with the same `container_id` *within the same animation frame* can briefly have two `ULabel` instances attached to `document`; when the old instance's teardown runs it will remove `.ulabel`-namespaced document/window handlers belonging to the new instance too. If your SPA does synchronous same-id replacement, set `auto_destroy_on_detach: false` and call `oldUlabel.destroy()` yourself *before* mounting the replacement — `destroy()` is synchronous, so this ordering is race-free. +### `on_active_class_change` +*(subtask_key: string, class_id: number) => void* -- Called after a subtask's active class actually changes, whatever the writer: `set_active_class`, a toolbox class-button click, or a class-select keybind (including keybinds users customize through the `Keybinds` toolbox item). Not called for no-op re-selections, rejected ids, or delete-mode toggles (which freeze the selection). Default is `null`. + +### `on_subtask_change` +*(subtask_key: string, old_subtask_key: string) => void* -- Called after the current subtask actually changes, whatever the writer: `set_subtask`, a toolbox tab click, or the `switch_subtask_keybind`. Not called when the target subtask is already current. Default is `null`. + ## Display Utility Functions diff --git a/index.d.ts b/index.d.ts index ca30076d..e66dfbf4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -321,6 +321,10 @@ export type ULabelConstructorArgs = { toolbox_order?: AllowedToolboxItem[]; auto_destroy_on_detach?: boolean; class_counter_toolbox_item?: ClassCounterConfig; + /** Fired after a subtask's active class changes, from any writer (API, toolbox click, class keybind). */ + on_active_class_change?: (subtask_key: string, class_id: number) => void; + /** Fired after the current subtask changes, from any writer (API, tab click, switch keybind). */ + on_subtask_change?: (subtask_key: string, old_subtask_key: string) => void; /** @deprecated Use top-level properties instead. */ config_data?: object; }; diff --git a/src/active_class.ts b/src/active_class.ts index cc4e28c4..b80f6241 100644 --- a/src/active_class.ts +++ b/src/active_class.ts @@ -32,6 +32,8 @@ export function get_selected_class_id(ulabel: ULabel, subtask_key: string): numb * replaces. For a non-current subtask only state is written; `set_subtask` * reconciles the DOM on activation. * + * Fires `config.on_active_class_change` when the remembered selection changes. + * * @param ulabel ULabel instance * @param class_id class to select (the reserved delete class is accepted but * never becomes the remembered selection, so focus freezes across it) @@ -90,6 +92,10 @@ export function set_active_class( ulabel.toolbox?.redraw_update_items(ulabel); } } + + if (class_id !== DELETE_CLASS_ID && previous_selected !== class_id) { + ulabel.config.on_active_class_change?.(subtask_key, class_id); + } return true; } diff --git a/src/configuration.ts b/src/configuration.ts index b619a546..e3d77fa2 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -158,6 +158,10 @@ export class Configuration { public instructions_url: string | null = null; public submit_buttons: ULabelSubmitButton[] = []; + // Host notification callbacks; each fires only when the value actually changes + public on_active_class_change: ((subtask_key: string, class_id: number) => void) | null = null; + public on_subtask_change: ((subtask_key: string, old_subtask_key: string) => void) | null = null; + // Passthrough public task_meta: object = {}; public annotation_meta: object = {}; diff --git a/src/index.js b/src/index.js index fe118f33..6c6e6081 100644 --- a/src/index.js +++ b/src/index.js @@ -1245,6 +1245,10 @@ export class ULabel { // Redraw demo this.redraw_demo(); + + if (st_key !== old_st) { + this.config.on_subtask_change?.(st_key, old_st); + } } /** diff --git a/tests/host_callbacks.test.js b/tests/host_callbacks.test.js new file mode 100644 index 00000000..4570b999 --- /dev/null +++ b/tests/host_callbacks.test.js @@ -0,0 +1,95 @@ +// Host callbacks fire from the single writers (`set_active_class`, +// `set_subtask`), so every path — API call, toolbox click, keybind — notifies +// the host, and only when the value actually changes. +const { ULabel } = require("./testing-utils/build_loader"); + +const mock_config = { + container_id: "container", + image_data: "test.jpg", + username: "test_user", + submit_buttons: [{ name: "Submit", hook: jest.fn() }], + subtasks: { + first: { + display_name: "A", + classes: [ + { name: "Crop", id: 1, color: "green" }, + { name: "Weed", id: 2, color: "red" }, + ], + allowed_modes: ["bbox", "delete_polygon"], + resume_from: null, + }, + second: { + display_name: "B", + classes: [{ name: "Sign", id: 3, color: "blue" }], + allowed_modes: ["point"], + resume_from: null, + }, + }, +}; + +describe("on_active_class_change", () => { + test("fires with the subtask key and class id on a real change", () => { + const on_active_class_change = jest.fn(); + const ulabel = new ULabel({ ...mock_config, on_active_class_change }); + + ulabel.set_active_class(2, "first", false); + + expect(on_active_class_change).toHaveBeenCalledTimes(1); + expect(on_active_class_change).toHaveBeenCalledWith("first", 2); + }); + + test("does not fire when the class is already selected", () => { + const on_active_class_change = jest.fn(); + const ulabel = new ULabel({ ...mock_config, on_active_class_change }); + + // Class 1 is the initial selection + ulabel.set_active_class(1, "first", false); + + expect(on_active_class_change).not.toHaveBeenCalled(); + }); + + test("does not fire on rejected ids or delete-mode toggles", () => { + const on_active_class_change = jest.fn(); + const ulabel = new ULabel({ ...mock_config, on_active_class_change }); + + ulabel.set_active_class(99, "first", false); // not in the subtask + ulabel.set_active_class(-1, "first", false); // delete class freezes the selection + + expect(on_active_class_change).not.toHaveBeenCalled(); + }); +}); + +describe("on_subtask_change", () => { + // `set_subtask` reconciles toolbox DOM that unit tests don't build + function make_switchable_ulabel(config) { + const ulabel = new ULabel(config); + ulabel.state.current_subtask = "first"; // normally set during init + ulabel.toolbox = { redraw_update_items: jest.fn(), tabs: [] }; + ulabel.update_annotation_mode = jest.fn(); + ulabel.update_current_class = jest.fn(); + ulabel.toggle_delete_class_id_in_toolbox = jest.fn(); + ulabel.sync_annotation_modes_to_active_class = jest.fn(); + ulabel.readjust_subtask_opacities = jest.fn(); + ulabel.redraw_demo = jest.fn(); + return ulabel; + } + + test("fires with the new and old subtask keys", () => { + const on_subtask_change = jest.fn(); + const ulabel = make_switchable_ulabel({ ...mock_config, on_subtask_change }); + + ulabel.set_subtask("second"); + + expect(on_subtask_change).toHaveBeenCalledTimes(1); + expect(on_subtask_change).toHaveBeenCalledWith("second", "first"); + }); + + test("does not fire when the subtask is already current", () => { + const on_subtask_change = jest.fn(); + const ulabel = make_switchable_ulabel({ ...mock_config, on_subtask_change }); + + ulabel.set_subtask("first"); + + expect(on_subtask_change).not.toHaveBeenCalled(); + }); +}); From 8503de840dffe5dd497a83d7deb7891ea6304d7e Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 9 Sep 2026 16:24:09 -0500 Subject: [PATCH 21/29] add flag for brush overlap behavior with inactive subtasks, fix small bugs with brush state and interactions at subtask opacity 0 --- .github/tasks.md | 25 ++++++++++-- CHANGELOG.md | 3 ++ api_spec.md | 6 ++- demo/bitmask-example.html | 30 +++++++++++++- index.d.ts | 4 ++ src/configuration.ts | 3 ++ src/index.js | 73 +++++++++++++++++++++++------------ src/toolbox.ts | 4 +- tests/bitmask_overlap.test.js | 55 ++++++++++++++++++++++++++ tests/brush_state.test.js | 62 +++++++++++++++++++++++++++++ tests/e2e/bitmask.spec.js | 35 +++++++++++++++++ tests/subtask_hidden.test.js | 73 +++++++++++++++++++++++++++++++++++ 12 files changed, 340 insertions(+), 33 deletions(-) create mode 100644 tests/brush_state.test.js create mode 100644 tests/subtask_hidden.test.js diff --git a/.github/tasks.md b/.github/tasks.md index d27baa98..5cb2a056 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -454,9 +454,12 @@ time than at save time. - [x] 6.1 Unterminated `/**` block in `annotation_operators.ts`, left behind by the 1.3 `mark_hidden` removal. -- [ ] 6.2 Mask barrier escape hatch. Read-only bitmasks still participate in - `resolve_bitmask_overlap`, so a `prediction` or `diff` mask invisibly clips - a GT brush stroke. Needed before segmentation editing ships. +- [x] 6.2 Brush strokes no longer interact with other subtasks' masks by + default: new `brush_overlap_across_subtasks` config flag (default false) + scopes overlap resolution to the active subtask. Supersedes the planned + read-only "barrier escape hatch": with the flag off a `prediction` or + `diff` mask cannot invisibly clip a GT stroke at all; with it on, + read-only masks still act as barriers under overwrite. - [ ] 6.3 (only if live GT editing during diff review is required) Apply an edit to a non-current subtask and record it in that subtask's undo stream. `set_subtask` clears hover and `fly_to_idx`, so a review queue cannot @@ -473,13 +476,27 @@ time than at save time. derived from (GT, run) and goes stale the moment GT is edited - the resolved FN keeps rendering as an FN. That is a repaint problem, which is what 7.9 solves without client-side rematching. -- [ ] 6.4 Two different `get_active_class_id` implementations disagree. The +- [x] 6.4 Two different `get_active_class_id` implementations disagree. The `ULabel` *method* (`index.js`) parses the selected toolbox anchor's id out of the DOM; the *utility* of the same name (`utilities.ts`) reads `state.id_payload`. The method throws outright before the toolbox has rendered, and the two can diverge whenever state changes without a DOM sync. Phase 5 uses the state-based one; the method's four remaining call sites should follow, and one of the two names should go. + RESOLVED in Phase 9: the method is now a thin delegate to the state-based + utility, so there is a single implementation (the DOM parse is gone). +- [x] 6.5 Switching subtasks left the Brush toolbox buttons lit: brush state + is per-subtask but the buttons are global, and `set_subtask` never tore the + outgoing brush down. Button display is now centralized in + `update_brush_toolbox_display()` (derived from current-subtask state) and + `set_subtask` disables the outgoing brush while it is still current. +- [x] 6.6 An opacity-0 subtask was still fully interactive (invisible + annotations could be created/edited). Vanish mode already encodes + "invisible implies non-interactive"; its gates (create, suggest_edits, + drag start, resize) now check a shared `is_subtask_hidden()` helper + (vanished OR opacity slider at 0). Draw gates deliberately still check + only `is_vanished`: opacity is CSS-only, so content must stay drawn for + the slider to reveal it without a redraw. ### Phase 7 - model-registry (branch `three-fixed-subtasks` off `cropped-bitmasks-trevor`) diff --git a/CHANGELOG.md b/CHANGELOG.md index c34b5379..8623ed3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,10 @@ All notable changes to this project will be documented here. - New `set_active_class(class_id, subtask_key?, redraw?)` public API method: sets a subtask's active class (id payload, toolbox selection, id-dialog display, per-class mode sync) — previously only reachable by clicking the toolbox class button. All internal class-selection paths (delete-mode toggles, keybinds, id-dialog syncing) now route through it. New `get_selected_class_id(subtask_key?)` returns the last non-delete class selected. - New per-subtask `focus_active_class` option (default `false`): the selected class becomes the focused class — other classes dim to `defocused_opacity` and drop out of hover, Tab navigation, the annotation list, and bulk delete. Replaces the separate `set_class_focus()` API (never released), so selection and focus can no longer disagree. The selection (and therefore focus) freezes at the last real class while a delete mode is active. Toggleable at runtime via `set_focus_active_class(subtask_key, enabled, redraw?)` or the `toggle_class_focus_keybind` (default `shift+f`, current subtask). - Bulk delete (`delete_polygon`/`delete_bbox`) now skips defocused annotations, so a focus-scoped view cannot delete what it has dimmed. +- Fix the Brush/Erase toolbox buttons staying lit after a subtask switch: brush state is per-subtask but the buttons are global. `set_subtask` now tears down the outgoing subtask's brush, and button display is derived from state in one place (`update_brush_toolbox_display`). +- A subtask whose layer opacity slider is at 0 is now non-interactive, matching vanish mode: no annotation creation, edit suggestions, drags (pan/zoom still work), or resizes on annotations that aren't on screen. Centralized in a new `is_subtask_hidden(subtask_key?)` helper (vanished or opacity 0) used by all such gates. - Fix stale containing boxes when `allow_annotations_outside_image = false` clamps loaded annotations at init: the boxes were built from the pre-clamp payloads, anchoring the hover dialogs (and hit-testing) off the image. They are now rebuilt after the clamp. +- New `brush_overlap_across_subtasks` config option (default `false`): bitmask brush overlap resolution (`exclude`/`overwrite`) is now scoped to the active subtask. **Behavior change**: reaching masks in other subtasks — including read-only masks acting as `overwrite` barriers — is now opt-in via the flag. [`demo/bitmask-example.html`](demo/bitmask-example.html) gained a second mask layer and runtime toggles for the flag and the read-only barrier. - New host callback config options `on_active_class_change(subtask_key, class_id)` and `on_subtask_change(subtask_key, old_subtask_key)`: fired only when the value actually changes, from any writer (API call, toolbox click, or keybind — including keybinds users customize through the `Keybinds` toolbox item). Lets hosts keep their own UI in sync without polling or wrapping methods via `on()`. - Per-class `allowed_modes` are now enforced on reclassification: the id-dialog pie only offers classes that allow the annotation's spatial type. When fewer than two classes qualify, no pie appears and the edit-button ring collapses to the compact single-class layout (no reid button). Class keybinds and `set_active_class` refuse an incompatible reclass with a console warning. Undo/redo replay history unchanged. Importing an annotation whose class doesn't allow its spatial type logs a warning (the annotation still loads and round-trips). diff --git a/api_spec.md b/api_spec.md index 0f8393ff..0c439391 100644 --- a/api_spec.md +++ b/api_spec.md @@ -71,6 +71,7 @@ class ULabel({ decrease_brush_size_keybind: string, mask_annotation_opacity: number, default_brush_overlap_mode: BrushOverlapMode, + brush_overlap_across_subtasks: boolean, set_brush_overlap_none_keybind: string, set_brush_overlap_exclude_keybind: string, set_brush_overlap_overwrite_keybind: string, @@ -313,7 +314,7 @@ The `"bitmask"` mode enables raster (per-pixel) segmentation. Each bitmask annot **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). +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). Resolution stays within the active subtask unless [`brush_overlap_across_subtasks`](#brush_overlap_across_subtasks) is set. - `"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). @@ -611,6 +612,9 @@ The fill opacity (`0`-`1`) used when rendering `bitmask` (raster segmentation) a ### `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. +### `brush_overlap_across_subtasks` +When `true`, [brush overlap resolution](#overlap-modes) also reaches undeprecated bitmask annotations in *other* subtasks: `"exclude"` clips the stroke against them, and `"overwrite"` carves them — except masks in `read_only` subtasks, which act as barriers (the stroke is clipped around them instead). Default is `false`: a stroke only interacts with masks in the active subtask. + ### `set_brush_overlap_none_keybind` Keybind to set the brush overlap mode to `none`. Default is `shift+n`. diff --git a/demo/bitmask-example.html b/demo/bitmask-example.html index babd2792..76eb2a4a 100644 --- a/demo/bitmask-example.html +++ b/demo/bitmask-example.html @@ -48,6 +48,23 @@ "resume_from": null, "task_meta": null, "annotation_meta": null + }, + // Second mask layer for trying brush_overlap_across_subtasks: + // paint here, switch back, and brush over it with exclude/overwrite. + "reference": { + "display_name": "Reference", + "classes": [ + { + "name": "Reference", + "color": "#888888", + "id": 20 + } + ], + "allowed_modes": ["bitmask"], + "resume_from": null, + "task_meta": null, + "annotation_meta": null, + "inactive_opacity": 0.6 } }; @@ -61,7 +78,13 @@ }); // Wait for ULabel instance to finish initialization ulabel.init(function() { - // ULabel is now ready for use + // The gate reads the config live, so these apply to the next stroke + document.getElementById("across-subtasks").addEventListener("change", function() { + ulabel.config.brush_overlap_across_subtasks = this.checked; + }); + document.getElementById("reference-read-only").addEventListener("change", function() { + ulabel.subtasks.reference.read_only = this.checked; + }); }); }); @@ -69,5 +92,10 @@
+
+ + +
Paint in Reference, switch back, then brush over it with overlap exclude (shift+e) or overwrite (shift+o).
+
diff --git a/index.d.ts b/index.d.ts index e66dfbf4..2c398943 100644 --- a/index.d.ts +++ b/index.d.ts @@ -321,6 +321,8 @@ export type ULabelConstructorArgs = { toolbox_order?: AllowedToolboxItem[]; auto_destroy_on_detach?: boolean; class_counter_toolbox_item?: ClassCounterConfig; + /** Let bitmask brush overlap resolution reach masks in other subtasks. Default false. */ + brush_overlap_across_subtasks?: boolean; /** Fired after a subtask's active class changes, from any writer (API, toolbox click, class keybind). */ on_active_class_change?: (subtask_key: string, class_id: number) => void; /** Fired after the current subtask changes, from any writer (API, tab click, switch keybind). */ @@ -426,6 +428,8 @@ export class ULabel { public get_current_subtask_key(): string; public get_current_subtask(): ULabelSubtask; public is_current_subtask_read_only(): boolean; + /** Whether a subtask's annotations are hidden: vanished, or layer opacity 0. Hidden implies non-interactive. */ + public is_subtask_hidden(subtask_key?: string): boolean; public readjust_subtask_opacities(): void; public set_subtask(st_key: string): void; public switch_to_next_subtask(): void; diff --git a/src/configuration.ts b/src/configuration.ts index e3d77fa2..e430c93c 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -142,6 +142,9 @@ export class Configuration { // 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"; + // Whether stroke overlap resolution reaches masks in other subtasks; off, a + // stroke only interacts with masks in the active subtask. + public brush_overlap_across_subtasks: boolean = false; // Configuration for the annotation task itself public image_data: ImageData | null = null; public allow_soft_id: boolean = false; diff --git a/src/index.js b/src/index.js index 6c6e6081..b4c2e496 100644 --- a/src/index.js +++ b/src/index.js @@ -1101,6 +1101,21 @@ export class ULabel { return this.get_current_subtask()["read_only"] === true; } + /** + * Whether a subtask's annotations are hidden from view: vanished, or its + * layer opacity slider is at 0. Hidden implies non-interactive, so the + * interaction gates check this rather than `is_vanished` alone. + * @param {string} subtask_key defaults to the current subtask + */ + is_subtask_hidden(subtask_key = null) { + subtask_key ??= this.get_current_subtask_key(); + const subtask = this.subtasks[subtask_key]; + if (subtask == null) return false; + if (subtask["state"]["is_vanished"]) return true; + const sliderval = $("#tb-st-range--" + subtask_key).val(); + return sliderval !== undefined && Number(sliderval) === 0; + } + readjust_subtask_opacities() { for (const st_key in this.subtasks) { let sliderval = $("#tb-st-range--" + st_key).val(); @@ -1202,6 +1217,12 @@ export class ULabel { } } + // Brush state is per-subtask, so tear the outgoing brush down while it is + // still current; otherwise the global toolbox buttons stay lit for it. + if (old_subtask["state"]["is_in_brush_mode"]) { + this.disable_bitmask_brush(); + } + // Change object state this.state["current_subtask"] = st_key; @@ -3192,8 +3213,6 @@ export class ULabel { 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 in erase mode, turn it off first @@ -3213,11 +3232,10 @@ export class ULabel { } // 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); } + this.update_brush_toolbox_display(); } toggle_erase_mode(mouse_event) { @@ -3228,17 +3246,8 @@ export class ULabel { } // Toggle erase mode - if (current_subtask["state"]["is_in_erase_mode"]) { - $("#erase-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - // "Erase mode" is a subset of "brush mode" - if (current_subtask["state"]["is_in_brush_mode"]) { - $("#brush-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - } - } else { - $("#erase-mode").addClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - $("#brush-mode").removeClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS); - } current_subtask["state"]["is_in_erase_mode"] = !current_subtask["state"]["is_in_erase_mode"]; + this.update_brush_toolbox_display(); // Update brush circle color const brush_circle_id = "brush_circle"; @@ -3260,11 +3269,22 @@ export class ULabel { 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.update_brush_toolbox_display(); this.destroy_brush_circle(); } + // Sync the Brush toolbox buttons to the current subtask's brush state. + // The buttons are global (one Brush toolbox item), so every brush-state + // change must route through here or they go stale. + update_brush_toolbox_display() { + const state = this.get_current_subtask()["state"]; + $("#brush-mode").toggleClass( + BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS, + state["is_in_brush_mode"] && !state["is_in_erase_mode"], + ); + $("#erase-mode").toggleClass(BrushToolboxItem.BRUSH_BTN_ACTIVE_CLS, state["is_in_erase_mode"] === true); + } + // ================= Brush overlap mode (global, localStorage-persisted) ================= // Load the global brush overlap mode from localStorage, falling back to the config default. @@ -4157,8 +4177,8 @@ export class ULabel { // Grab constants for convenience const current_subtask = this.get_current_subtask(); - // Exit if subtask is vanished - if (current_subtask["state"]["is_vanished"]) { + // Exit if the subtask is hidden (vanished or opacity 0) + if (this.is_subtask_hidden()) { return; } @@ -5631,14 +5651,17 @@ export class ULabel { this.render_bitmask_other_edits(other_edits); } - // {id, subtask} for all undeprecated bitmask annotations except the given one, across all - // subtasks. Matches the render frame-gate (see draw_annotation_from_id): a stroke can only - // affect masks that are visible on the current frame. + // {id, subtask} for all undeprecated bitmask annotations except the given one. Scoped to the + // active subtask unless `brush_overlap_across_subtasks` is set. Matches the render frame-gate + // (see draw_annotation_from_id): a stroke can only affect masks visible on the current frame. get_other_bitmask_ids(active_id) { const active_st = this.get_current_subtask_key(); const current_frame = this.state["current_frame"]; + const subtask_keys = this.config["brush_overlap_across_subtasks"] ? + Object.keys(this.subtasks) : + [active_st]; const ids = []; - for (const st_key in this.subtasks) { + for (const st_key of subtask_keys) { const access = this.subtasks[st_key]["annotations"]["access"]; for (const oid of this.subtasks[st_key]["annotations"]["ordering"]) { // ID collisions are only prevented within a subtask, so scope the active-skip @@ -6712,7 +6735,7 @@ export class ULabel { // Don't show any dialogs when currently drawing/editing an annotation, // And hide just edit dialogs when moving if ( - current_subtask["state"]["is_vanished"] || + this.is_subtask_hidden() || current_subtask["state"]["is_in_progress"] || current_subtask["state"]["starting_complex_polygon"] || current_subtask["state"]["is_in_brush_mode"] || @@ -7242,10 +7265,10 @@ export class ULabel { if (drag_key != null) { // Suppress browser defaults (e.g. middle-click auto-scroll) mouse_event.preventDefault(); - // Don't start new drag while id_dialog is visible or subtask is vanished + // Don't start new drag while id_dialog is visible or subtask is hidden if ( (this.get_current_subtask()["state"]["idd_visible"] && !this.get_current_subtask()["state"]["idd_thumbnail"]) || - (this.get_current_subtask()["state"]["is_vanished"] && drag_key !== "pan" && drag_key !== "zoom") + (this.is_subtask_hidden() && drag_key !== "pan" && drag_key !== "zoom") ) { return; } diff --git a/src/toolbox.ts b/src/toolbox.ts index 7eab4f22..4368a57b 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -1515,8 +1515,8 @@ export class AnnotationResizeItem extends ToolboxItem { const subtask = ulabel.subtasks[subtask_key]; if (subtask === null) return; - // If the annotations are currently vanished, don't resize them - if (subtask.state.is_vanished) return; + // If the annotations are currently hidden (vanished or opacity 0), don't resize them + if (ulabel.is_subtask_hidden(subtask_key)) return; // Set the size of the subtask to the given size AnnotationResizeItem.update_subtask_line_size(subtask, size, increment); diff --git a/tests/bitmask_overlap.test.js b/tests/bitmask_overlap.test.js index 27bf414e..6a0ca9e7 100644 --- a/tests/bitmask_overlap.test.js +++ b/tests/bitmask_overlap.test.js @@ -90,3 +90,58 @@ describe("bitmask overlap semantics (mask level)", () => { .toEqual(Array.from(ULabelMask.from_rle(other_before, false).data)); }); }); + +// The candidate-collection gate: which subtasks' masks a stroke may interact with. +describe("brush overlap subtask scoping (get_other_bitmask_ids)", () => { + const { ULabel } = require("./testing-utils/build_loader"); + + const make_annotation = (id) => ({ + id, + spatial_type: "bitmask", + deprecated: false, + }); + + function make_two_subtask_ulabel(extra_config = {}) { + const subtask = (name) => ({ + display_name: name, + classes: [{ name: "Mask", id: 1, color: "green" }], + allowed_modes: ["bitmask"], + resume_from: null, + }); + const ulabel = new ULabel({ + container_id: "container", + image_data: "test.jpg", + username: "test_user", + submit_buttons: [{ name: "Submit", hook: jest.fn() }], + subtasks: { a: subtask("A"), b: subtask("B") }, + ...extra_config, + }); + ulabel.state.current_subtask = "a"; // normally set during init + ulabel.subtasks.a.annotations = { + access: { active: make_annotation("active"), same_st: make_annotation("same_st") }, + ordering: ["active", "same_st"], + }; + ulabel.subtasks.b.annotations = { + access: { other_st: make_annotation("other_st") }, + ordering: ["other_st"], + }; + return ulabel; + } + + test("defaults to masks in the active subtask only", () => { + const ulabel = make_two_subtask_ulabel(); + + expect(ulabel.get_other_bitmask_ids("active")).toEqual([ + { id: "same_st", subtask: "a" }, + ]); + }); + + test("brush_overlap_across_subtasks reaches other subtasks", () => { + const ulabel = make_two_subtask_ulabel({ brush_overlap_across_subtasks: true }); + + expect(ulabel.get_other_bitmask_ids("active")).toEqual([ + { id: "same_st", subtask: "a" }, + { id: "other_st", subtask: "b" }, + ]); + }); +}); diff --git a/tests/brush_state.test.js b/tests/brush_state.test.js new file mode 100644 index 00000000..c439c465 --- /dev/null +++ b/tests/brush_state.test.js @@ -0,0 +1,62 @@ +// Brush state is per-subtask while the Brush toolbox buttons are global, so +// set_subtask must tear down the outgoing subtask's brush (state, buttons, +// circle) instead of leaving it lit for a subtask that is no longer current. +const { ULabel } = require("./testing-utils/build_loader"); + +const mock_config = { + container_id: "container", + image_data: "test.jpg", + username: "test_user", + submit_buttons: [{ name: "Submit", hook: jest.fn() }], + subtasks: { + first: { + display_name: "A", + classes: [{ name: "Crop", id: 1, color: "green" }], + allowed_modes: ["bitmask"], + resume_from: null, + }, + second: { + display_name: "B", + classes: [{ name: "Weed", id: 2, color: "red" }], + allowed_modes: ["bitmask"], + resume_from: null, + }, + }, +}; + +// `set_subtask` reconciles toolbox DOM that unit tests don't build +function make_switchable_ulabel() { + const ulabel = new ULabel(mock_config); + ulabel.state.current_subtask = "first"; // normally set during init + ulabel.toolbox = { redraw_update_items: jest.fn(), tabs: [] }; + ulabel.update_annotation_mode = jest.fn(); + ulabel.update_current_class = jest.fn(); + ulabel.toggle_delete_class_id_in_toolbox = jest.fn(); + ulabel.sync_annotation_modes_to_active_class = jest.fn(); + ulabel.readjust_subtask_opacities = jest.fn(); + ulabel.redraw_demo = jest.fn(); + ulabel.destroy_brush_circle = jest.fn(); + return ulabel; +} + +describe("set_subtask brush teardown", () => { + test("disables the outgoing subtask's brush and erase state", () => { + const ulabel = make_switchable_ulabel(); + ulabel.subtasks.first.state.is_in_brush_mode = true; + ulabel.subtasks.first.state.is_in_erase_mode = true; + + ulabel.set_subtask("second"); + + expect(ulabel.subtasks.first.state.is_in_brush_mode).toBe(false); + expect(ulabel.subtasks.first.state.is_in_erase_mode).toBe(false); + expect(ulabel.destroy_brush_circle).toHaveBeenCalled(); + }); + + test("leaves the brush circle alone when the outgoing brush was off", () => { + const ulabel = make_switchable_ulabel(); + + ulabel.set_subtask("second"); + + expect(ulabel.destroy_brush_circle).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/e2e/bitmask.spec.js b/tests/e2e/bitmask.spec.js index 148788f3..894bcd78 100644 --- a/tests/e2e/bitmask.spec.js +++ b/tests/e2e/bitmask.spec.js @@ -60,6 +60,8 @@ test.describe("Bitmask overlap + move", () => { await u.set_annotations([make("other", 2, 10, 10, 30, 30)], "b"); ["active", "far"].forEach((id) => rebuild("a", id)); rebuild("b", "other"); + // Cross-subtask reach is opt-in + u.config.brush_overlap_across_subtasks = true; u.set_subtask("a"); u.subtasks["a"].state.active_id = "active"; @@ -93,6 +95,7 @@ test.describe("Bitmask overlap + move", () => { u.set_subtask("a"); u.subtasks["a"].state.active_id = "active"; + u.config.brush_overlap_across_subtasks = true; const delta = u.get_bitmask(u.subtasks["a"].annotations.access["active"]); u.resolve_bitmask_overlap("active", delta, "exclude"); @@ -121,6 +124,7 @@ test.describe("Bitmask overlap + move", () => { u.set_subtask("a"); u.subtasks["a"].state.active_id = "active"; + u.config.brush_overlap_across_subtasks = true; const active_ann = u.subtasks["a"].annotations.access["active"]; const active_before = active_ann.spatial_payload; const delta = u.get_bitmask(active_ann); @@ -273,6 +277,7 @@ test.describe("Bitmask overlap + move", () => { u.set_subtask("a"); u.subtasks["a"].state.active_id = "dup"; + u.config.brush_overlap_across_subtasks = true; const delta = u.get_bitmask(u.subtasks["a"].annotations.access["dup"]); u.resolve_bitmask_overlap("dup", delta, "overwrite"); @@ -303,6 +308,7 @@ test.describe("Bitmask overlap + move", () => { // Move the other mask to a different frame than the stroke. u.subtasks["b"].annotations.access["other"].frame = 3; u.state.current_frame = 0; + u.config.brush_overlap_across_subtasks = true; u.set_subtask("a"); u.subtasks["a"].state.active_id = "active"; @@ -330,6 +336,7 @@ test.describe("Bitmask overlap + move", () => { rebuild("a", "active"); rebuild("b", "ro"); u.subtasks["b"].read_only = true; + u.config.brush_overlap_across_subtasks = true; u.set_subtask("a"); u.subtasks["a"].state.active_id = "active"; @@ -351,6 +358,34 @@ test.describe("Bitmask overlap + move", () => { expect(res.active_clipped_in_overlap).toBe(0); expect(res.active_kept_outside).toBe(1); }); + + test("by default overlap resolution stays within the active subtask", async ({ page }) => { + await wait_for_ulabel_init(page, "/bitmask-e2e.html"); + + const res = await page.evaluate(async () => { + const u = window.ulabel; + const { make, rebuild, pix } = window.__mask_helpers(u); + await u.set_annotations([make("active", 1, 20, 20, 40, 40), make("same_st", 1, 10, 10, 30, 30)], "a"); + await u.set_annotations([make("other_st", 2, 10, 10, 30, 30)], "b"); + ["active", "same_st"].forEach((id) => rebuild("a", id)); + rebuild("b", "other_st"); + + u.set_subtask("a"); + u.subtasks["a"].state.active_id = "active"; + const delta = u.get_bitmask(u.subtasks["a"].annotations.access["active"]); + const edits = u.resolve_bitmask_overlap("active", delta, "overwrite"); + + return { + same_st_carved: pix("a", "same_st", 25, 25), // same subtask still resolves -> 0 + other_st_kept: pix("b", "other_st", 25, 25), // other subtask untouched -> 1 + edits_subtasks: edits.map((e) => e.subtask), + }; + }); + + expect(res.same_st_carved).toBe(0); + expect(res.other_st_kept).toBe(1); + expect(res.edits_subtasks).toEqual(["a"]); + }); }); // End-to-end coverage for the bitmask move bounce-back: diff --git a/tests/subtask_hidden.test.js b/tests/subtask_hidden.test.js new file mode 100644 index 00000000..be1b7b7d --- /dev/null +++ b/tests/subtask_hidden.test.js @@ -0,0 +1,73 @@ +// A subtask is "hidden" when vanished or when its layer opacity slider is at +// 0; hidden implies non-interactive, matching vanish mode's existing gates. +const { ULabel } = require("./testing-utils/build_loader"); + +const mock_config = { + container_id: "container", + image_data: "test.jpg", + username: "test_user", + submit_buttons: [{ name: "Submit", hook: jest.fn() }], + subtasks: { + st: { + display_name: "A", + classes: [{ name: "Crop", id: 1, color: "green" }], + allowed_modes: ["bbox"], + resume_from: null, + }, + }, +}; + +function make_ulabel() { + const ulabel = new ULabel(mock_config); + ulabel.state.current_subtask = "st"; // normally set during init + return ulabel; +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("is_subtask_hidden", () => { + test("false by default (no slider in the DOM, not vanished)", () => { + const ulabel = make_ulabel(); + + expect(ulabel.is_subtask_hidden("st")).toBe(false); + }); + + test("true when the subtask is vanished", () => { + const ulabel = make_ulabel(); + ulabel.subtasks.st.state.is_vanished = true; + + expect(ulabel.is_subtask_hidden("st")).toBe(true); + }); + + test("tracks the opacity slider: hidden at 0, visible above it", () => { + const ulabel = make_ulabel(); + document.body.innerHTML = ""; + + expect(ulabel.is_subtask_hidden("st")).toBe(true); + + $("#tb-st-range--st").val(40); + expect(ulabel.is_subtask_hidden("st")).toBe(false); + }); + + test("defaults to the current subtask and is false for unknown keys", () => { + const ulabel = make_ulabel(); + ulabel.subtasks.st.state.is_vanished = true; + + expect(ulabel.is_subtask_hidden()).toBe(true); + expect(ulabel.is_subtask_hidden("nope")).toBe(false); + }); +}); + +describe("hidden-subtask interaction gates", () => { + test("create_annotation is a no-op when the opacity slider is at 0", () => { + const ulabel = make_ulabel(); + ulabel.subtasks.st.annotations = { access: {}, ordering: [] }; + document.body.innerHTML = ""; + + ulabel.create_annotation("bbox", [[0, 0], [10, 10]]); + + expect(ulabel.subtasks.st.annotations.ordering).toHaveLength(0); + }); +}); From c49ee0b389596999d29a4acaf11f803372b8da18 Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Wed, 9 Sep 2026 16:44:49 -0500 Subject: [PATCH 22/29] use log message --- src/toolbox.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toolbox.ts b/src/toolbox.ts index 4368a57b..652c3971 100644 --- a/src/toolbox.ts +++ b/src/toolbox.ts @@ -374,7 +374,7 @@ export class ToolboxTab { sel = " sel"; val = 100; } - console.log(subtask.display_name, subtask); + log_message(`Building toolbox tab for subtask: ${subtask.display_name}`, LogLevel.VERBOSE); this.html = `
${this.subtask.display_name}