Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented here.

## [unreleased]

## [0.26.2] - Aug 13th, 2026
- **Fix regression in `set_annotations()` where hover feedback and the brush stopped working after a swap.** The 0.26.1 bulk-teardown path called `$("#canvasses__<subtask>").empty()`, which removed not just the per-annotation canvases but also the subtask's front canvas and the `#dialogs__<subtask>` container (which owns the brush circle and polygon ender). `state.front_context` was left pointing at a detached canvas so hover highlights painted into nothing, and the brush had no parent to attach to. The teardown now removes only `> canvas.annotation_canvas` children, leaving the front/back canvases and dialogs container intact.
- New demo: [`demo/set-annotations.html`](demo/set-annotations.html) with buttons that swap through empty / small / medium / large / RLE-bitmask / raw-Uint8Array-bitmask presets so this regression stays visible.

## [0.26.1] - Aug 13th, 2026
- **Bitmask annotations can now be imported as raw `Uint8Array` payloads.** In addition to the existing RLE `{ counts, size }` shape, `spatial_payload` may now be `{ data: Uint8Array, size: [height, width] }`. This lets callers that already have the mask as a pixel buffer skip the RLE encode step before handing it to ULabel.
- **Loader no longer flashes on fast operations.** `ULabelLoader.add_loader_div()` now appends the overlay hidden and reveals it only after a delay (200 ms by default). Callers can pass an explicit `delay_ms` — including `0` to opt back into immediate-show.
Expand Down
1 change: 1 addition & 0 deletions demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ console.log(`http://localhost:${port}/box-roi.html`);
console.log(`http://localhost:${port}/resume-from.html`);
console.log(`http://localhost:${port}/row-filtering-example.html`);
console.log(`http://localhost:${port}/bitmask-example.html`);
console.log(`http://localhost:${port}/set-annotations.html`);
console.log(`http://localhost:${port}/live_demo.html`);
294 changes: 294 additions & 0 deletions demo/set-annotations.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html>

<head>
<title>ULabel - set_annotations Demo</title>

<!-- ULabel Library -->
<script src="/ulabel.js"></script>

<!-- JQuery Library -->
<script src="https://code.jquery.com/jquery-3.5.1.min.js"
integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>

<style>
body { margin: 0; font-family: system-ui, sans-serif; }
#container {
width: 100%;
height: calc(100vh - 84px);
position: absolute;
top: 84px;
left: 0;
}
#controls {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 84px;
box-sizing: border-box;
padding: 8px 12px;
background: #1e293b;
color: #f8fafc;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
z-index: 1000;
border-bottom: 1px solid #334155;
}
#controls button {
background: #334155;
color: #f8fafc;
border: 1px solid #475569;
padding: 6px 12px;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
}
#controls button:hover { background: #475569; }
#controls button:active { background: #64748b; }
#status {
margin-left: auto;
font-size: 12px;
color: #cbd5e1;
}
.checklist {
font-size: 11px;
color: #94a3b8;
width: 100%;
margin-top: 4px;
}
.checklist code { background: #0f172a; padding: 1px 4px; border-radius: 3px; }
</style>
</head>

<body>
<div id="controls">
<span style="font-weight: 600;">set_annotations:</span>
<button id="btn-empty">Empty</button>
<button id="btn-small">Small (3 bboxes)</button>
<button id="btn-medium">Medium (50 bboxes + polygons)</button>
<button id="btn-large">Large (500 bboxes)</button>
<button id="btn-bitmask-rle">Bitmasks via RLE</button>
<button id="btn-bitmask-raw">Bitmasks via raw Uint8Array</button>
<button id="btn-restore">Restore original</button>
<span id="status">idle</span>
<div class="checklist">
After each swap, verify: hover an annotation shows the id-dialog highlight;
press <code>g</code> to toggle brush &mdash; the brush circle must appear at the cursor.
Open devtools and inspect <code>window.ulabel</code>.
</div>
</div>
<div id="container"></div>

<script>
/* global $, ULabel */
$(window).on("load", function () {

function on_submit(annotations) {
const element = document.createElement("a");
element.setAttribute(
"href",
"data:text/plain;charset=utf-8," +
encodeURIComponent(JSON.stringify(annotations, null, 2))
);
element.setAttribute("download", "annotations.json");
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}

// Original preset — restored via the "Restore original" button.
const original_annotations = [
{
spatial_type: "bbox",
spatial_payload: [[100, 100], [300, 250]],
classification_payloads: [{ class_id: 10, confidence: 1.0 }],
},
{
spatial_type: "bbox",
spatial_payload: [[400, 150], [600, 320]],
classification_payloads: [{ class_id: 11, confidence: 1.0 }],
},
{
spatial_type: "polygon",
spatial_payload: [[[700, 400], [900, 400], [900, 600], [700, 600], [700, 400]]],
classification_payloads: [{ class_id: 12, confidence: 1.0 }],
},
];

const subtasks = {
segmentation: {
display_name: "Segmentation Demo",
classes: [
{ name: "Sedan", color: "blue", id: 10, keybind: "1" },
{ name: "SUV", color: "green", id: 11, keybind: "2" },
{ name: "Truck", color: "orange", id: 12, keybind: "3" },
],
allowed_modes: ["bbox", "polygon", "point", "bitmask"],
resume_from: JSON.parse(JSON.stringify(original_annotations)),
task_meta: null,
annotation_meta: null,
},
};

const ulabel = new ULabel({
container_id: "container",
image_data: "https://ulabel.s3.us-east-2.amazonaws.com/cs-demo-0.png",
username: "DemoUser",
submit_buttons: [{ name: "Submit", hook: on_submit }],
subtasks: subtasks,
initial_line_size: 2,
});

window.ulabel = ulabel;

const status_el = document.getElementById("status");
function set_status(text) {
status_el.textContent = text;
}

// Helpers for building annotation shapes.
function make_bbox(class_id, x0, y0, x1, y1) {
return {
spatial_type: "bbox",
spatial_payload: [[x0, y0], [x1, y1]],
classification_payloads: [{ class_id: class_id, confidence: 1.0 }],
};
}

function make_polygon(class_id, cx, cy, radius, n_points) {
const pts = [];
for (let i = 0; i < n_points; i++) {
const t = (i / n_points) * 2 * Math.PI;
pts.push([cx + Math.cos(t) * radius, cy + Math.sin(t) * radius]);
}
pts.push(pts[0].slice());
return {
spatial_type: "polygon",
spatial_payload: [pts],
classification_payloads: [{ class_id: class_id, confidence: 1.0 }],
};
}

// Fill a raw Uint8Array mask with a circle at (cx, cy) of radius r.
function make_raw_bitmask_annotation(class_id, cx, cy, r) {
const width = ulabel.config.image_width;
const height = ulabel.config.image_height;
const data = new Uint8Array(width * height);
const r_sq = r * r;
const min_x = Math.max(0, cx - r);
const max_x = Math.min(width - 1, cx + r);
const min_y = Math.max(0, cy - r);
const max_y = Math.min(height - 1, cy + r);
for (let y = min_y; y <= max_y; y++) {
const dy = y - cy;
const row = y * width;
for (let x = min_x; x <= max_x; x++) {
const dx = x - cx;
if (dx * dx + dy * dy <= r_sq) data[row + x] = 1;
}
}
return {
spatial_type: "bitmask",
// The new raw shape; ULabel copies the buffer defensively.
spatial_payload: { data: data, size: [height, width] },
classification_payloads: [{ class_id: class_id, confidence: 1.0 }],
};
}

// Build a bitmask annotation via the pre-existing RLE path.
function make_rle_bitmask_annotation(class_id, cx, cy, r) {
const raw = make_raw_bitmask_annotation(class_id, cx, cy, r);
// Encode row-major -> column-major RLE. Cheapest way for demo purposes:
// let ULabel do the decode via the raw path, then read the mask back and
// hand-encode. Instead we just reuse the raw path for construction and
// wrap it in the RLE shape via a scratch ULabelMask.
// Simpler for the demo: keep only the counts derivation below.
const width = ulabel.config.image_width;
const height = ulabel.config.image_height;
const data = raw.spatial_payload.data;
const counts = [];
let current_is_fg = false;
let run = 0;
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const is_fg = data[y * width + x] !== 0;
if (is_fg === current_is_fg) run++;
else { counts.push(run); current_is_fg = is_fg; run = 1; }
}
}
counts.push(run);
return {
spatial_type: "bitmask",
spatial_payload: { counts: counts, size: [height, width] },
classification_payloads: [{ class_id: class_id, confidence: 1.0 }],
};
}

async function swap(label, annotations) {
set_status(`swapping to ${label} (${annotations.length} annotations)...`);
const t0 = performance.now();
await ulabel.set_annotations(annotations, "segmentation");
const dt = (performance.now() - t0).toFixed(1);
set_status(`${label}: ${annotations.length} annotations, ${dt} ms`);
}

ulabel.init(function () {
set_status(`ready (${original_annotations.length} original annotations)`);

document.getElementById("btn-empty").addEventListener("click", () => {
swap("empty", []);
});
document.getElementById("btn-small").addEventListener("click", () => {
swap("small", [
make_bbox(10, 200, 200, 320, 320),
make_bbox(11, 500, 200, 620, 320),
make_bbox(12, 800, 200, 920, 320),
]);
});
document.getElementById("btn-medium").addEventListener("click", () => {
const anns = [];
for (let i = 0; i < 40; i++) {
const x = 50 + (i % 8) * 120;
const y = 50 + Math.floor(i / 8) * 120;
anns.push(make_bbox(10 + (i % 3), x, y, x + 90, y + 90));
}
for (let i = 0; i < 10; i++) {
anns.push(make_polygon(10 + (i % 3), 200 + i * 80, 700, 40, 8));
}
swap("medium", anns);
});
document.getElementById("btn-large").addEventListener("click", () => {
const anns = [];
for (let i = 0; i < 500; i++) {
const x = 10 + (i % 40) * 40;
const y = 10 + Math.floor(i / 40) * 40;
anns.push(make_bbox(10 + (i % 3), x, y, x + 30, y + 30));
}
swap("large", anns);
});
document.getElementById("btn-bitmask-rle").addEventListener("click", () => {
swap("bitmask-rle", [
make_rle_bitmask_annotation(10, 300, 300, 80),
make_rle_bitmask_annotation(11, 700, 400, 120),
]);
});
document.getElementById("btn-bitmask-raw").addEventListener("click", () => {
swap("bitmask-raw", [
make_raw_bitmask_annotation(10, 400, 300, 100),
make_raw_bitmask_annotation(11, 800, 500, 140),
make_raw_bitmask_annotation(12, 600, 700, 80),
]);
});
document.getElementById("btn-restore").addEventListener("click", () => {
swap("original", JSON.parse(JSON.stringify(original_annotations)));
});
});
});
</script>
</body>

</html>
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ulabel",
"description": "An image annotation tool.",
"version": "0.26.1",
"version": "0.26.2",
Comment thread
TrevorBurgoyne marked this conversation as resolved.
"main": "dist/ulabel.min.js",
"module": "dist/ulabel.min.js",
"types": "dist/index.d.ts",
Expand Down
5 changes: 4 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7227,7 +7227,10 @@ export class ULabel {
delete anno["_bitmask_box_hint"];
}
}
$("#canvasses__" + subtask).empty();
// Only remove per-annotation canvases. The subtask's front/back canvases and the
// #dialogs__<subtask> container (which owns the brush circle, polygon ender, and
// id dialogs) live in the same parent and MUST survive.
$("#canvasses__" + subtask + " > canvas.annotation_canvas").remove();
this.subtasks[subtask]["state"]["annotation_contexts"] = {};
}

Expand Down
2 changes: 1 addition & 1 deletion src/version.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const ULABEL_VERSION = "0.26.1";
export const ULABEL_VERSION = "0.26.2";
30 changes: 24 additions & 6 deletions tests/annotation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,23 @@ describe("Annotation Processing", () => {
const canvasses = document.createElement("div");
canvasses.id = "canvasses__test_task";
document.body.appendChild(canvasses);
const fake_canvas = document.createElement("canvas");
fake_canvas.id = "canvas__fake";
canvasses.appendChild(fake_canvas);
// Mirror the init-time DOM: back canvas, front canvas, dialogs container, then
// per-annotation canvases (which are the only children we should be removing).
const back = document.createElement("canvas");
back.id = "back_canvas";
canvasses.appendChild(back);
const front = document.createElement("canvas");
front.id = "front_canvas";
canvasses.appendChild(front);
const dialogs = document.createElement("div");
dialogs.id = "dialogs__test_task";
canvasses.appendChild(dialogs);
const anno_canvas = document.createElement("canvas");
anno_canvas.id = "canvas__anno";
anno_canvas.classList.add("annotation_canvas");
canvasses.appendChild(anno_canvas);
ulabel.subtasks.test_task.state.annotation_contexts = {
canvas__fake: { annotation_ids: [anno_id], context: {} },
canvas__anno: { annotation_ids: [anno_id], context: {} },
};

ulabel._clear_subtask_annotation_canvases("test_task");
Expand All @@ -394,8 +406,14 @@ describe("Annotation Processing", () => {
expect(annotation._mask).toBeUndefined();
expect(annotation._mask_render).toBeUndefined();
expect(annotation._bitmask_box_hint).toBeUndefined();
// Canvas DOM subtree wiped.
expect(canvasses.children.length).toBe(0);
// Per-annotation canvas removed.
expect(document.getElementById("canvas__anno")).toBeNull();
// Back/front canvases and the dialogs container MUST survive; otherwise hover
// feedback (front canvas) and brush/polygon-ender attach points (dialogs div)
// become detached-DOM ghosts.
expect(document.getElementById("back_canvas")).not.toBeNull();
expect(document.getElementById("front_canvas")).not.toBeNull();
expect(document.getElementById("dialogs__test_task")).not.toBeNull();
// Annotation-context bookkeeping reset.
expect(ulabel.subtasks.test_task.state.annotation_contexts).toEqual({});
});
Expand Down
Loading