Skip to content
Closed
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ All notable changes to this project will be documented here.

Nothing yet.

## [0.20.0] - Oct 15th, 2025
- Add `fly-to` functions, which sets the zoom and focus to a specific annotation
- `fly_to_next_annotation()`
- `fly_to_annotation_id()`
- `fly_to_annotation()`
- Add `Tab` and `Tab+Shift` default keybinds to fly-to the next/previous annotation, respectively
- Keybinds are configurable:
- `fly_to_next_annotation_keybind`
- `fly_to_previous_annotation_keybind`

## [0.19.1] - Oct 9th, 2025
- Add automated testing to the repo
- Fix circular webpack builds by forcibly cleaning the `dist/` directory before each build
Expand Down
23 changes: 23 additions & 0 deletions api_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ This should eventually be replaced with a more comprehensive approach to documen
- Hold `shift` when moving the cursor inside a polygon to begin annotating a new region or hole.
- Press `Escape` or `crtl+z` to cancel the start of a new region or hole.
- Press `Escape` to exit brush/erase mode.
- Press `Tab` to set the zoom to focus on the next annotation
- Press `Shift+Tab` to set the zoom to focus on the previous annotation

## ULabel Constructor

Expand Down Expand Up @@ -69,6 +71,9 @@ class ULabel({
toggle_erase_mode_keybind: string,
increase_brush_size_keybind: string,
decrease_brush_size_keybind: string,
fly_to_next_annotation_keybind: string,
fly_to_previous_annotation_keybind: string | null,
fly_to_max_zoom: number,
n_annos_per_canvas: number
})
```
Expand Down Expand Up @@ -429,6 +434,15 @@ Keybind to increase the brush size. Default is `]`. Requires the active subtask
### `decrease_brush_size_keybind`
Keybind to decrease the brush size. Default is `[`. Requires the active subtask to have a `polygon` mode.

### `fly_to_next_annotation_keybind`
Keybind to set the zoom to focus on the next annotation. Default is `Tab`, which also will disable any default browser behavior for `Tab`.

### `fly_to_previous_annotation_keybind`
Keybind to set the zoom to focus on the previous annotation. Default is `<null>`, which will default to `Shift+<fly_to_next_annotation_keybind>`.

### `fly_to_max_zoom`
Maximum zoom factor used when flying-to an annotation. Default is `10`, value must be > `0`.

### `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 Expand Up @@ -476,6 +490,15 @@ Display utilities are provided for a constructed `ULabel` object.
*() => void* -- Removes persistent event listeners from the document and window. Listeners attached directly to html elements are not explicitly removed.
Note that ULabel will not function properly after this method is called. Designed for use in single-page applications before navigating away from the annotation page.

### `fly_to_next_annotation(increment)`
Sets the zoom to focus on a non-deprecated, spatial annotation in the active subtask's ordering that is an `<increment>` number away from the previously focused annotation, if any. Returns `true` on success and `false` on failure (eg, no valid annotations exist, or an annotation is currently actively being edited).

### `fly_to_annotation_id(annotation_id, subtask_key, max_zoom)`
Sets the zoom to focus on the provided annotation id, and switches to its subtask. Returns `true` on success and `false` on failure (eg, annotation doesn't exist in subtask, is not a spatial annotation, or is deprecated).

### `fly_to_annotation(annotation, subtask_key, max_zoom)`
Sets the zoom to focus on the provided annotation, and switches to its subtask if provided. Returns `true` on success and `false` on failure (eg, annotation doesn't exist in subtask, is not a spatial annotation, or is deprecated).

## Generic Callbacks

Callbacks can be provided by calling `.on(fn, callback)` on a `ULabel` object.
Expand Down
3 changes: 3 additions & 0 deletions demo/resume-from.html
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,9 @@
"subtasks": subtasks,
"anno_scaling_mode": "inverse-zoom",
"allow_annotations_outside_image": false,
"fly_to_next_annotation_keybind": "w",
"fly_to_previous_annotation_keybind": "`",
"fly_to_max_zoom": 6
});
// Wait for ULabel instance to finish initialization
ulabel.init(function() {
Expand Down
2 changes: 1 addition & 1 deletion dist/ulabel.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/ulabel.min.js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,9 @@ export class ULabel {
force_filter_all?: boolean,
offset?: Offset,
): void;
public fly_to_next_annotation(increment: number, max_zoom?: number): boolean;
public fly_to_annotation_id(annotation_id: string, subtask_key?: string, max_zoom?: number): boolean;
public fly_to_annotation(annotation: ULabelAnnotation, subtask_key?: string, max_zoom?: number): boolean;

// Brush
// TODO (joshua-dean): should these actually be optional?
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.19.1",
"version": "0.20.0",
"main": "dist/ulabel.js",
"module": "dist/ulabel.js",
"scripts": {
Expand Down
7 changes: 7 additions & 0 deletions src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ export class Configuration {

public decrease_brush_size_keybind: string = "[";

public fly_to_next_annotation_keybind: string = "Tab";

// null -> Shift+fly_to_next_annotation_keybind
public fly_to_previous_annotation_keybind: string | null = null;

public fly_to_max_zoom: number = 10;

public n_annos_per_canvas: number = DEFAULT_N_ANNOS_PER_CANVAS;

public click_and_drag_poly_annotations: boolean = true;
Expand Down
99 changes: 95 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ export class ULabel {
is_in_erase_mode: false,
edit_candidate: null,
move_candidate: null,
fly_to_idx: null,

// Rendering context
front_context: null,
Expand Down Expand Up @@ -771,7 +772,7 @@ export class ULabel {
let lft_cntr = initial_crop["left"] + initial_crop["width"] / 2;
let top_cntr = initial_crop["top"] + initial_crop["height"] / 2;

this.state["zoom_val"] = Math.min(this.get_viewport_height_ratio(height), this.get_viewport_width_ratio(width));
this.set_zoom_val(Math.min(this.get_viewport_height_ratio(height), this.get_viewport_width_ratio(width)));
this.rezoom(lft_cntr, top_cntr, true);

// Redraw the filter_distance_overlay if it exists
Expand Down Expand Up @@ -799,7 +800,7 @@ export class ULabel {
const top_left_corner_y = 0;

// Calculate minimum zoom value required to show the whole image
this.state["zoom_val"] = Math.min(this.get_viewport_height_ratio(height), this.get_viewport_width_ratio(width));
this.set_zoom_val(Math.min(this.get_viewport_height_ratio(height), this.get_viewport_width_ratio(width)));

this.rezoom(top_left_corner_x, top_left_corner_y, true);

Expand Down Expand Up @@ -5531,14 +5532,20 @@ export class ULabel {
// Handle zooming by click-drag
drag_rezoom(mouse_event) {
const aY = mouse_event.clientY;
this.state["zoom_val"] = (
this.set_zoom_val(
this.drag_state["zoom"]["zoom_val_start"] * Math.pow(
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]);
}

// 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);
}

// Handle zooming at a certain focus
rezoom(foc_x = null, foc_y = null, abs = false) {
// JQuery convenience
Expand Down Expand Up @@ -5601,6 +5608,90 @@ export class ULabel {
}
}

// Zoom to the next annotation in the ordering
fly_to_next_annotation(increment = 1, max_zoom = 10) {
const current_subtask = this.get_current_subtask();
const ordering = current_subtask["annotations"]["ordering"];
// Don't interrupt if currently editing an annotation
if (ordering.length === 0 || current_subtask["state"]["active_id"] !== null) {
return false;
}

// Find the next non-deprecated, spatial annotation
let start_idx = current_subtask["state"]["fly_to_idx"];
const single_increment = increment > 0 ? 1 : -1;
if (start_idx === null) {
start_idx = increment > 0 ? -1 : 0;
}

// Start with the full increment amount
let next_idx = (start_idx + increment + ordering.length) % ordering.length;
const first_checked_idx = next_idx;
Comment on lines +5627 to +5629
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A nice QoL for this that I've seen for other "searching" mechanisms would be a toast or similar pop-up indicating once you have looped around. A display of "current index / total" might also be nice.

Thoughts? If it is out of scope, we can make a separate issue.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do like this idea, it fits with a larger idea I have of having an annotation list you can interact with in the toolbox that will also leverage this flyto functionality. I created issue #234 to track


// Continue until the fly-to succeeds or we've checked all annotations
do {
const next_ann = current_subtask["annotations"]["access"][ordering[next_idx]];
if (this.fly_to_annotation(next_ann, null, max_zoom)) {
current_subtask["state"]["fly_to_idx"] = next_idx;
return true;
}
// Increment by a single step and try again
next_idx = (next_idx + single_increment + ordering.length) % ordering.length;
} while (next_idx !== first_checked_idx);

return false;
}

fly_to_annotation_id(annotation_id, subtask_key = null, max_zoom = 10) {
if (subtask_key !== null && subtask_key !== this.state.current_subtask) {
this.set_subtask(subtask_key);
}
const annotation = this.get_current_subtask()["annotations"]["access"][annotation_id];
return this.fly_to_annotation(annotation, null, max_zoom);
}

fly_to_annotation(annotation, subtask_key = null, max_zoom = 10) {
// Handle null, deprecated, and non-spatial annotations
if (
annotation === null ||
annotation === undefined ||
annotation["deprecated"] ||
NONSPATIAL_MODES.includes(annotation["spatial_type"])
) {
return false;
}

// Set the current subtask if necessary
if (subtask_key !== null && subtask_key !== this.state.current_subtask) {
this.set_subtask(subtask_key);
}

// Zoom based on the containing box of the annotation
const bbox = annotation["containing_box"];
const annbox = $("#" + this.config["annbox_id"]);

// Get viewport dimensions
const viewport_width = annbox.width();
const viewport_height = annbox.height();

// Get annotation dimensions in image coordinates
const bbox_width = bbox["brx"] - bbox["tlx"];
const bbox_height = bbox["bry"] - bbox["tly"];

// Calculate zoom to fit annotation with some padding
const padding_factor = 0.9;
const zoom_x = (viewport_width * padding_factor) / bbox_width;
const zoom_y = (viewport_height * padding_factor) / bbox_height;

// Use the smaller zoom to ensure annotation fits in both dimensions
this.set_zoom_val(Math.min(zoom_x, zoom_y, max_zoom));

// Center on the annotation
this.rezoom((bbox["tlx"] + bbox["brx"]) / 2, (bbox["tly"] + bbox["bry"]) / 2, true);

return true;
}

// Shake the screen
shake_screen() {
if (!this.is_shaking) {
Expand Down
21 changes: 19 additions & 2 deletions src/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,8 @@ function handle_keydown_event(
return false;
} else {
const current_subtask = ulabel.get_current_subtask();
switch (keydown_event.key) {
case "Escape":
switch (keydown_event.key.toLowerCase()) {
case "escape":
// If in erase or brush mode, cancel the brush
if (current_subtask.state.is_in_erase_mode) {
ulabel.toggle_erase_mode();
Expand All @@ -236,6 +236,23 @@ function handle_keydown_event(
ulabel.cancel_annotation();
}
break;
case ulabel.config.fly_to_next_annotation_keybind.toLowerCase():
// For 'tab', prevent default
if (keydown_event.key.toLowerCase() === "tab") {
keydown_event.preventDefault();
}

if (ulabel.config.fly_to_previous_annotation_keybind === null && shift) {
ulabel.fly_to_next_annotation(-1, ulabel.config.fly_to_max_zoom);
} else if (!shift) {
ulabel.fly_to_next_annotation(1, ulabel.config.fly_to_max_zoom);
}
break;
case ulabel.config.fly_to_previous_annotation_keybind.toLowerCase():
if (ulabel.config.fly_to_previous_annotation_keybind !== null) {
ulabel.fly_to_next_annotation(-1, ulabel.config.fly_to_max_zoom);
}
break;
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/subtask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class ULabelSubtask {
[key: string]: ULabelDialogPosition;
};
spatial_type: ULabelSpatialType;
fly_to_idx: number | null;
};

constructor(
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.19.1";
export const ULABEL_VERSION = "0.20.0";