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
9 changes: 8 additions & 1 deletion .github/tasks.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## Tasks
- [x] Remove the "line_size" property from ULabelAnnotation. Update the entire codebase to ensure no reference to it remains. Instead, use the line_size defined for the annotation's subtask when we need a line size for drawing the annotation.
- [x] Read the discussion in issue [#159](https://github.com/SenteraLLC/ulabel/issues/159)
- [x] Implement a vertex deletion keybind for polygon and polyline spatial types it should:
- [x] Delete the vertex when pressed when hovering over it such that the edit suggestion is showing
- [x] Delete the vertex when pressed when dragging/editing the vertex
- [x] For polylines, if only one point remains in the polyline, it should delete the polyline
- [x] For polygons, if fewer than 3 points remain in a polygon layer, the layer should be removed
- [x] Add a test for the keybind in keybind-functionality.spec.js
- [x] Update the api_spec and changelog

8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented here.

## [unreleased]

## [0.23.0] - Jan 16th, 2026
- Add vertex deletion keybind for polygon and polyline annotations
- New configurable `delete_vertex_keybind` (default: `x`)
- Delete individual vertices by hovering over them and pressing the keybind
- Automatically deletes entire polyline if only 1 point remains after deletion
- Automatically removes polygon layer if fewer than 3 points remain after deletion
- Fixed bug where deleting an annotation mid-edit would cause the ULabel state to be stuck in edit mode.

## [0.22.1] - Jan 13th, 2026
- Don't draw annotations when a subtask is vanished
- Add configurable `annotation_vanish_all_keybind`
Expand Down
4 changes: 4 additions & 0 deletions api_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class ULabel({
show_full_image_keybind: string,
create_point_annotation_keybind: string,
delete_annotation_keybind: string,
delete_vertex_keybind: string,
keypoint_slider_default_value: number,
filter_annotations_on_load: boolean,
switch_subtask_keybind: string,
Expand Down Expand Up @@ -433,6 +434,9 @@ Keybind to create a point annotation at the mouse location. Default is `c`. Requ
### `delete_annotation_keybind`
Keybind to delete the annotation that the mouse is hovering over. Default is `d`.

### `delete_vertex_keybind`
Keybind to delete a vertex of a polygon or polyline annotation. The vertex must be the one currently being hovered (showing an edit suggestion) or actively being edited. For polylines, if only one point remains after deletion, the entire polyline is deleted. For polygons, if fewer than 3 points remain in a layer after deletion, that layer is removed. Default is `x`.

### `keypoint_slider_default_value`
Default value for the keypoint slider. Must be a number between 0 and 1. Default is `0`.

Expand Down
10 changes: 10 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export type ULabelActionType = "create_nonspatial_annotation" |
"finish_move" |
"cancel_annotation" |
"delete_annotation" |
"delete_vertex" |
"delete_annotations_in_polygon" |
"start_complex_polygon" |
"merge_polygon_complex_layer" |
Expand Down Expand Up @@ -233,6 +234,7 @@ export type ULabelActionCandidate = {
point: [number, number]; // Mouse location
spatial_type: ULabelSpatialType;
offset?: Offset; // Optional offset for move actions
is_vertex?: boolean; // True if hovering over an actual vertex, false if hovering over a segment
};

export type ULabelSubtasks = { [key: string]: ULabelSubtask };
Expand Down Expand Up @@ -377,6 +379,12 @@ export class ULabel {
redoing?: boolean,
should_record_action?: boolean,
): void;
public delete_vertex(
annotation_id: string,
access_str: string | number | [number, number],
redoing?: boolean,
should_record_action?: boolean,
): void;
public cancel_annotation(annotation_id?: string): void;
public assign_annotation_id(annotation_id?: string, redo_payload?: object): void;
public create_point_annotation_at_mouse_location(): void;
Expand Down Expand Up @@ -414,6 +422,7 @@ export class ULabel {
public begin_edit__undo(annotation_id: string, undo_payload: object): void;
public begin_move__undo(annotation_id: string, undo_payload: object): void;
public delete_annotation__undo(annotation_id: string): void;
public delete_vertex__undo(annotation_id: string, undo_payload: object): void;
public cancel_annotation__undo(annotation_id: string, undo_payload: object): void;
public assign_annotation_id__undo(annotation_id: string, undo_payload: object): void;
public create_annotation__undo(annotation_id: string): void;
Expand All @@ -431,6 +440,7 @@ export class ULabel {
public begin_edit__redo(annotation_id: string, redo_payload: object): void;
public begin_move__redo(annotation_id: string, redo_payload: object): void;
public delete_annotation__redo(annotation_id: string): void;
public delete_vertex__redo(annotation_id: string, redo_payload: object): void;
public create_annotation__redo(annotation_id: string, redo_payload: object): void;
public finish_modify_annotation__redo(annotation_id: string, redo_payload: object): void;

Expand Down
26 changes: 13 additions & 13 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.22.1",
"version": "0.23.0",
"main": "dist/ulabel.min.js",
"module": "dist/ulabel.min.js",
"exports": {
Expand Down
10 changes: 10 additions & 0 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ function trigger_action_listeners(
action: on_annotation_deletion,
undo: on_finish_annotation_spatial_modification,
},
delete_vertex: {
action: on_finish_annotation_spatial_modification,
undo: on_finish_annotation_spatial_modification,
},
assign_annotation_id: {
action: on_annotation_id_change,
undo: on_annotation_id_change,
Expand Down Expand Up @@ -570,6 +574,9 @@ function undo_action(ulabel: ULabel, action: ULabelAction) {
case "delete_annotation":
ulabel.delete_annotation__undo(action.annotation_id);
break;
case "delete_vertex":
ulabel.delete_vertex__undo(action.annotation_id, undo_payload);
break;
case "cancel_annotation":
ulabel.cancel_annotation__undo(action.annotation_id, undo_payload);
break;
Expand Down Expand Up @@ -644,6 +651,9 @@ export function redo_action(ulabel: ULabel, action: ULabelAction) {
case "delete_annotation":
ulabel.delete_annotation__redo(action.annotation_id);
break;
case "delete_vertex":
ulabel.delete_vertex__redo(action.annotation_id, redo_payload);
break;
case "cancel_annotation":
ulabel.cancel_annotation(action.annotation_id);
break;
Expand Down
2 changes: 2 additions & 0 deletions src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ export class Configuration {

public delete_annotation_keybind: string = "d";

public delete_vertex_keybind: string = "x";

public keypoint_slider_default_value: number;

public filter_annotations_on_load: boolean = true;
Expand Down
139 changes: 139 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3087,6 +3087,15 @@ export class ULabel {
current_subtask["state"]["starting_complex_polygon"] = false;
}

// Clear drag state if we're in the middle of a drag
if (this.drag_state["active_key"] !== null) {
this.drag_state["active_key"] = null;
this.drag_state["release_button"] = null;
}

// Clear edit candidate
current_subtask["state"]["edit_candidate"] = null;

let frame = this.state["current_frame"];
if (MODES_3D.includes(spatial_type)) {
frame = null;
Expand All @@ -3110,6 +3119,134 @@ export class ULabel {
this.delete_annotation(annotation_id, true);
}

/**
* Delete a vertex from a polygon or polyline annotation
* @param {string} annotation_id - The ID of the annotation
* @param {string|array} access_str - Access string identifying the vertex to delete
* @param {boolean} redoing - Whether this is a redo operation
* @param {boolean} should_record_action - Whether to record this action in the action stream
*/
delete_vertex(annotation_id, access_str, redoing = false, should_record_action = true) {
const current_subtask = this.get_current_subtask();
const annotation = current_subtask["annotations"]["access"][annotation_id];
const spatial_type = annotation["spatial_type"];

// Only allow vertex deletion for polygons and polylines
if (spatial_type !== "polygon" && spatial_type !== "polyline") {
return;
}

let spatial_payload = annotation["spatial_payload"];
let layer_index = 0;
let vertex_index;
let active_spatial_payload = spatial_payload;
let should_delete = false;

// Parse access string based on spatial type
if (spatial_type === "polygon") {
// For polygons, access_str is [layer_index, vertex_index]
layer_index = parseInt(access_str[0], 10);
active_spatial_payload = spatial_payload[layer_index];
vertex_index = parseInt(access_str[1], 10);
} else {
// For polylines, access_str is just the vertex_index
vertex_index = parseInt(access_str, 10);
}

// Store the old state for undo
const old_spatial_payload = JSON.parse(JSON.stringify(spatial_payload));

// Delete the vertex
if (spatial_type === "polygon") {
// For polygons, handle the special case of first/last point
const n_points = active_spatial_payload.length;
if (vertex_index === 0 || vertex_index === n_points - 1) {
// First and last points are the same in a closed polygon
// Remove both
active_spatial_payload.splice(n_points - 1, 1);
active_spatial_payload.splice(0, 1);
// Make the new first point also the last point
if (active_spatial_payload.length > 0) {
active_spatial_payload.push([...active_spatial_payload[0]]);
}
} else {
// Remove the vertex
active_spatial_payload.splice(vertex_index, 1);
}

// Check if the layer should be removed (fewer than 3 points means fewer than 4 including duplicate)
if (active_spatial_payload.length < 4) {
// Remove the entire layer
spatial_payload.splice(layer_index, 1);

// If no layers remain, delete the entire annotation
if (spatial_payload.length === 0) {
should_delete = true;
}
}
} else {
// For polylines
active_spatial_payload.splice(vertex_index, 1);

// If only one point remains, delete the entire polyline
if (active_spatial_payload.length <= 1) {
should_delete = true;
}
}

// Clear any active edit state
if (current_subtask["state"]["active_id"] === annotation_id) {
current_subtask["state"]["active_id"] = null;
current_subtask["state"]["is_in_edit"] = false;
}

// Clear drag state if we're in the middle of a drag
if (this.drag_state["active_key"] !== null) {
this.drag_state["active_key"] = null;
this.drag_state["release_button"] = null;
}

// Clear edit candidate
current_subtask["state"]["edit_candidate"] = null;

let frame = this.state["current_frame"];
if (MODES_3D.includes(spatial_type)) {
frame = null;
}

// Record the action
record_action(this, {
act_type: "delete_vertex",
annotation_id: annotation_id,
frame: frame,
undo_payload: {
old_spatial_payload: old_spatial_payload,
deleted: should_delete,
},
redo_payload: {
access_str: access_str,
},
}, redoing, should_record_action);

// If the entire annotation should be deleted, do so
if (should_delete) {
this.delete_annotation(annotation_id, false, false);
}
}

delete_vertex__undo(annotation_id, undo_payload) {
const annotation = this.get_current_subtask()["annotations"]["access"][annotation_id];
annotation["spatial_payload"] = undo_payload.old_spatial_payload;

if (undo_payload.deleted) {
this.delete_annotation__undo(annotation_id);
}
}

delete_vertex__redo(annotation_id, redo_payload) {
this.delete_vertex(annotation_id, redo_payload.access_str, true);
}

/**
* Get the annotation with nearest active keypoint (e.g. corners for a bbox, endpoints for polylines) to a point
* @param {*} global_x
Expand All @@ -3124,6 +3261,7 @@ export class ULabel {
access: null,
distance: max_dist / this.get_empirical_scale(),
point: null,
is_vertex: true,
};
if (candidates === null) {
candidates = this.get_current_subtask()["annotations"]["ordering"];
Expand Down Expand Up @@ -3227,6 +3365,7 @@ export class ULabel {
access: null,
distance: max_dist / this.get_empirical_scale(),
point: null,
is_vertex: false,
};
if (candidates === null) {
candidates = this.get_current_subtask()["annotations"]["ordering"];
Expand Down
10 changes: 10 additions & 0 deletions src/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,16 @@ export function create_ulabel_listeners(
ulabel.delete_annotation(edit_cand.annid);
}
}
// Check the key pressed against the delete vertex keybind in the config
if (event_matches_keybind(keypress_event, ulabel.config.delete_vertex_keybind)) {
const current_subtask = ulabel.get_current_subtask();
const edit_cand = current_subtask.state.edit_candidate;

// Only delete if we have an edit candidate that is an actual vertex (not a segment point)
if (edit_cand !== null && edit_cand.is_vertex === true) {
ulabel.delete_vertex(edit_cand.annid, edit_cand.access);
}
}
},
);

Expand Down
Loading