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.3] - Aug 13th, 2026
- **Fix wheel-zoom and drag-zoom focal point when the ULabel container is offset from the viewport origin.** `handle_wheel` and `drag_rezoom` used to pass raw `clientX`/`clientY` (viewport coords) straight into `rezoom`, which treats its focal-point arguments as annbox-local. When the container sat at viewport `(0, 0)` the two frames coincided and the bug was invisible; anywhere else (e.g. hosted inside a centered dialog, a padded panel, or below a header) zoom would snap by roughly the annbox's screen offset. Both call sites now convert to annbox-local via `getBoundingClientRect()` before calling `rezoom`, so zoom stays anchored to the cursor / mousedown point regardless of how the host embeds ULabel.
- **New config option `min_zoom_fit_ratio`.** Sets a zoom-out floor as a multiplier of the "whole image just fits the viewport" zoom. `0` (default) preserves the existing behavior (no floor). `1.0` prevents users from zooming out past the fit-to-viewport level. `>1` forces the image to always overflow. Zoom-in is unaffected. The floor recomputes from live annbox dimensions, so it adapts to browser resize.

## [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.
Expand Down
4 changes: 4 additions & 0 deletions api_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class ULabel({
annotation_size_minus_keybind: string,
annotation_vanish_keybind: string,
fly_to_max_zoom: number,
min_zoom_fit_ratio: number,
n_annos_per_canvas: number,
auto_destroy_on_detach: boolean
})
Expand Down Expand Up @@ -624,6 +625,9 @@ Keybind to toggle vanish mode for all subtasks. Default is `shift+v`
### `fly_to_max_zoom`
Maximum zoom factor used when flying-to an annotation. Default is `10`, value must be > `0`.

### `min_zoom_fit_ratio`
Zoom-out floor, expressed as a multiplier of the "whole image just fits the viewport" zoom (the same level reached by the `shift+r` keybind / the toolbox "show whole image" button). Default is `0`, which disables the floor. `1.0` prevents users from zooming out past the fit-to-viewport level. Values `> 1` force the image to always overflow the viewport by that factor. Zoom-in is unaffected. The floor recomputes from live annbox dimensions on every zoom, so it adapts to browser resize.

### `n_annos_per_canvas`
The number of annotations to render on a single canvas. Default is `100`. Increasing this number may improve performance for jobs with a large number of annotations.

Expand Down
3 changes: 2 additions & 1 deletion demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ 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`);
console.log(`http://localhost:${port}/live_demo.html`);
console.log(`http://localhost:${port}/offset-container.html`);
Comment thread
TrevorBurgoyne marked this conversation as resolved.
95 changes: 95 additions & 0 deletions demo/offset-container.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html>
<head>
<title>ULabel (offset container)</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>

<!-- ULabel Usage -->
<script>
/* global $ */
/* global ULabel */

$(window).on("load", function() {

function on_submit(annotations) {
var 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);
}

let subtasks = {
"car_detection": {
"display_name": "Car Detection",
"classes": [
{
"name": "Car",
"color": "orange",
"id": 10
}
],
"allowed_modes": ["bbox", "polygon", "contour"],
"resume_from": null,
"task_meta": null,
"annotation_meta": null
}
};

let ulabel = new ULabel({
"container_id": "container",
"image_data": "https://ulabel.s3.us-east-2.amazonaws.com/cs-demo-0.png",
"username": "DemoUser",
"submit_buttons": on_submit,
"subtasks": subtasks
});
ulabel.init(function() {
// ULabel is now ready for use
});

window.ulabel = ulabel;
});
</script>

<style>
html, body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
}
/* Wrapper that pushes the ULabel container away from viewport (0, 0)
so wheel/drag focal-point math cannot rely on the two frames
coinciding. */
#offset-wrapper {
box-sizing: border-box;
width: 100vw;
height: 100vh;
padding: 150px 250px;
background: #eee;
}
#container {
width: 100%;
height: 100%;
position: relative;
background: #fff;
}
</style>
</head>
<body>
<div id="offset-wrapper">
<div id="container"></div>
</div>
</body>
</html>
1 change: 1 addition & 0 deletions demo/set-annotations.html
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
submit_buttons: [{ name: "Submit", hook: on_submit }],
subtasks: subtasks,
initial_line_size: 2,
min_zoom_fit_ratio: 1.0
});

window.ulabel = ulabel;
Expand Down
5 changes: 5 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,11 @@ export class ULabel {
foc_y?: number,
abs?: boolean,
): void;
public set_zoom_val(zoom_val: number): void;
public viewport_to_annbox_local(
client_x: number,
client_y: number,
): { x: number; y: number };
public reposition_dialogs(): void;
public handle_toolbox_overflow(): void;

Expand Down
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.2",
"version": "0.26.3",
"main": "dist/ulabel.min.js",
"module": "dist/ulabel.min.js",
"types": "dist/index.d.ts",
Expand Down
2 changes: 2 additions & 0 deletions src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,8 @@ export class Configuration {

public fly_to_max_zoom: number = 10;

public min_zoom_fit_ratio: number = 0;

public n_annos_per_canvas: number = DEFAULT_N_ANNOS_PER_CANVAS;

public click_and_drag_poly_annotations: boolean = true;
Expand Down
67 changes: 62 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6642,14 +6642,30 @@ export class ULabel {
const dlta = Math.sign(wheel_event.deltaY);

// Apply new zoom
this.state["zoom_val"] *= (1 - dlta / 5);
this.rezoom(wheel_event.clientX, wheel_event.clientY);
this.set_zoom_val(this.state["zoom_val"] * (1 - dlta / 5));
const foc = this.viewport_to_annbox_local(wheel_event.clientX, wheel_event.clientY);
this.rezoom(foc.x, foc.y);

// Only try to update the overlay if it exists
this.filter_distance_overlay?.draw_overlay();
}
}

// Convert a viewport-space point (e.g. `event.clientX`/`clientY`) into the annbox's
// unscaled layout coordinate system, which is what `rezoom` and `annbox.scrollLeft/Top`
// operate in. `getBoundingClientRect()` returns transformed dimensions, so we divide out
// any ancestor CSS scale by comparing rendered vs. layout size.
viewport_to_annbox_local(client_x, client_y) {
const annbox = document.getElementById(this.config["annbox_id"]);
const rect = annbox.getBoundingClientRect();
const scale_x = annbox.clientWidth > 0 ? rect.width / annbox.clientWidth : 1;
const scale_y = annbox.clientHeight > 0 ? rect.height / annbox.clientHeight : 1;
return {
x: (client_x - rect.left) / (scale_x || 1),
y: (client_y - rect.top) / (scale_y || 1),
};
}

// Start dragging to pan around image
// Called when mousedown fires within annbox
start_drag(drag_key, release_button, mouse_event) {
Expand Down Expand Up @@ -6782,13 +6798,54 @@ export class ULabel {
1.1, -(aY - this.drag_state["zoom"]["mouse_start"][1]) / 10,
),
);
this.rezoom(this.drag_state["zoom"]["mouse_start"][0], this.drag_state["zoom"]["mouse_start"][1]);
const foc = this.viewport_to_annbox_local(
this.drag_state["zoom"]["mouse_start"][0],
this.drag_state["zoom"]["mouse_start"][1],
);

// Compute scroll from the drag-start baseline
const annbox = $("#" + this.config["annbox_id"]);

const new_width = Math.round(this.config["image_width"] * this.state["zoom_val"]);
const new_height = Math.round(this.config["image_height"] * this.state["zoom_val"]);

// Resize
var toresize = $("." + this.config["imgsz_class"]);
toresize.css("width", new_width + "px");
toresize.css("height", new_height + "px");
this.filter_distance_overlay?.resize_canvas(new_width, new_height);
this.resize_active_polygon_ender();

// Scroll from drag-start baseline
const start_width = this.config["image_width"] * this.drag_state["zoom"]["zoom_val_start"];
const start_height = this.config["image_height"] * this.drag_state["zoom"]["zoom_val_start"];
const old_left = this.drag_state["zoom"]["offset_start"][0];
const old_top = this.drag_state["zoom"]["offset_start"][1];
annbox.scrollLeft((old_left + foc.x) * new_width / start_width - foc.x);
annbox.scrollTop((old_top + foc.y) * new_height / start_height - foc.y);

this.redraw_demo();
if (this.state.anno_scaling_mode === "inverse-zoom" || this.state.anno_scaling_mode === "match-zoom") {
this.redraw_all_annotations();
}
}

// Set the zoom value in state and render accordingly
set_zoom_val(zoom_val) {
// Prevent zoom val <= 0
this.state["zoom_val"] = Math.max(zoom_val, 0.01);
let floor = 0.01;
// When min_zoom_fit_ratio > 0, refuse to zoom out past a multiple of the
// "whole image just fits" zoom (ratio 1.0 == exactly the fit level).
const fit_ratio = this.config["min_zoom_fit_ratio"];
if (fit_ratio > 0) {
const fit_zoom = Math.min(
this.get_viewport_height_ratio(this.config["image_height"]),
this.get_viewport_width_ratio(this.config["image_width"]),
Comment thread
TrevorBurgoyne marked this conversation as resolved.
);
if (Number.isFinite(fit_zoom) && fit_zoom > 0) {
floor = Math.max(floor, fit_zoom * fit_ratio);
}
}
this.state["zoom_val"] = Math.max(zoom_val, floor);
}

// Handle zooming at a certain focus
Expand Down
17 changes: 17 additions & 0 deletions src/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,23 @@ export function create_ulabel_listeners(
// Store a reference
ulabel.resize_observers.push(tb_overflow_resize_observer);

// Re-clamp zoom to `min_zoom_fit_ratio` when the annbox resizes. `set_zoom_val`
// computes the floor from live viewport dimensions, so we just reapply the
// current value; if it lands above the new floor nothing changes.
const min_zoom_resize_observer = new ResizeObserver(() => {
if (!ulabel.is_init) return;
if (!(ulabel.config["min_zoom_fit_ratio"] > 0)) return;
const current = ulabel.state["zoom_val"];
ulabel.set_zoom_val(current);
if (ulabel.state["zoom_val"] !== current) {
ulabel.rezoom();
}
});
min_zoom_resize_observer.observe(
document.getElementById(ulabel.config["annbox_id"])!,
);
ulabel.resize_observers.push(min_zoom_resize_observer);

// create_soft_id_toolbox_button_listener(ulabel);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- jQuery overloads don't support namespaced event strings
($(document) as any).on(
Expand Down
4 changes: 2 additions & 2 deletions src/toolbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -966,9 +966,9 @@ export class ZoomPanToolboxItem extends ToolboxItem {

$(document).on("click.ulabel", ".ulabel-zoom-button", (event) => {
if ($(event.currentTarget).hasClass("ulabel-zoom-out")) {
this.ulabel.state.zoom_val /= 1.1;
this.ulabel.set_zoom_val(this.ulabel.state.zoom_val / 1.1);
} else if ($(event.currentTarget).hasClass("ulabel-zoom-in")) {
this.ulabel.state.zoom_val *= 1.1;
this.ulabel.set_zoom_val(this.ulabel.state.zoom_val * 1.1);
}

this.ulabel.rezoom();
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.2";
export const ULABEL_VERSION = "0.26.3";
Loading
Loading