Skip to content

Commit 36e5029

Browse files
Merge pull request #239 from SenteraLLC/feature/vertex-delete
Feature/vertex delete
2 parents e4578fd + 35ea0bc commit 36e5029

14 files changed

Lines changed: 384 additions & 17 deletions

File tree

.github/tasks.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
## Tasks
2-
- [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.
2+
- [x] Read the discussion in issue [#159](https://github.com/SenteraLLC/ulabel/issues/159)
3+
- [x] Implement a vertex deletion keybind for polygon and polyline spatial types it should:
4+
- [x] Delete the vertex when pressed when hovering over it such that the edit suggestion is showing
5+
- [x] Delete the vertex when pressed when dragging/editing the vertex
6+
- [x] For polylines, if only one point remains in the polyline, it should delete the polyline
7+
- [x] For polygons, if fewer than 3 points remain in a polygon layer, the layer should be removed
8+
- [x] Add a test for the keybind in keybind-functionality.spec.js
9+
- [x] Update the api_spec and changelog
310

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented here.
44

55
## [unreleased]
66

7+
## [0.23.0] - Jan 16th, 2026
8+
- Add vertex deletion keybind for polygon and polyline annotations
9+
- New configurable `delete_vertex_keybind` (default: `x`)
10+
- Delete individual vertices by hovering over them and pressing the keybind
11+
- Automatically deletes entire polyline if only 1 point remains after deletion
12+
- Automatically removes polygon layer if fewer than 3 points remain after deletion
13+
- Fixed bug where deleting an annotation mid-edit would cause the ULabel state to be stuck in edit mode.
14+
715
## [0.22.1] - Jan 13th, 2026
816
- Don't draw annotations when a subtask is vanished
917
- Add configurable `annotation_vanish_all_keybind`

api_spec.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class ULabel({
5656
show_full_image_keybind: string,
5757
create_point_annotation_keybind: string,
5858
delete_annotation_keybind: string,
59+
delete_vertex_keybind: string,
5960
keypoint_slider_default_value: number,
6061
filter_annotations_on_load: boolean,
6162
switch_subtask_keybind: string,
@@ -433,6 +434,9 @@ Keybind to create a point annotation at the mouse location. Default is `c`. Requ
433434
### `delete_annotation_keybind`
434435
Keybind to delete the annotation that the mouse is hovering over. Default is `d`.
435436
437+
### `delete_vertex_keybind`
438+
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`.
439+
436440
### `keypoint_slider_default_value`
437441
Default value for the keypoint slider. Must be a number between 0 and 1. Default is `0`.
438442

index.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ export type ULabelActionType = "create_nonspatial_annotation" |
194194
"finish_move" |
195195
"cancel_annotation" |
196196
"delete_annotation" |
197+
"delete_vertex" |
197198
"delete_annotations_in_polygon" |
198199
"start_complex_polygon" |
199200
"merge_polygon_complex_layer" |
@@ -233,6 +234,7 @@ export type ULabelActionCandidate = {
233234
point: [number, number]; // Mouse location
234235
spatial_type: ULabelSpatialType;
235236
offset?: Offset; // Optional offset for move actions
237+
is_vertex?: boolean; // True if hovering over an actual vertex, false if hovering over a segment
236238
};
237239

238240
export type ULabelSubtasks = { [key: string]: ULabelSubtask };
@@ -377,6 +379,12 @@ export class ULabel {
377379
redoing?: boolean,
378380
should_record_action?: boolean,
379381
): void;
382+
public delete_vertex(
383+
annotation_id: string,
384+
access_str: string | number | [number, number],
385+
redoing?: boolean,
386+
should_record_action?: boolean,
387+
): void;
380388
public cancel_annotation(annotation_id?: string): void;
381389
public assign_annotation_id(annotation_id?: string, redo_payload?: object): void;
382390
public create_point_annotation_at_mouse_location(): void;
@@ -414,6 +422,7 @@ export class ULabel {
414422
public begin_edit__undo(annotation_id: string, undo_payload: object): void;
415423
public begin_move__undo(annotation_id: string, undo_payload: object): void;
416424
public delete_annotation__undo(annotation_id: string): void;
425+
public delete_vertex__undo(annotation_id: string, undo_payload: object): void;
417426
public cancel_annotation__undo(annotation_id: string, undo_payload: object): void;
418427
public assign_annotation_id__undo(annotation_id: string, undo_payload: object): void;
419428
public create_annotation__undo(annotation_id: string): void;
@@ -431,6 +440,7 @@ export class ULabel {
431440
public begin_edit__redo(annotation_id: string, redo_payload: object): void;
432441
public begin_move__redo(annotation_id: string, redo_payload: object): void;
433442
public delete_annotation__redo(annotation_id: string): void;
443+
public delete_vertex__redo(annotation_id: string, redo_payload: object): void;
434444
public create_annotation__redo(annotation_id: string, redo_payload: object): void;
435445
public finish_modify_annotation__redo(annotation_id: string, redo_payload: object): void;
436446

package-lock.json

Lines changed: 13 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "ulabel",
33
"description": "An image annotation tool.",
4-
"version": "0.22.1",
4+
"version": "0.23.0",
55
"main": "dist/ulabel.min.js",
66
"module": "dist/ulabel.min.js",
77
"exports": {

src/actions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,10 @@ function trigger_action_listeners(
253253
action: on_annotation_deletion,
254254
undo: on_finish_annotation_spatial_modification,
255255
},
256+
delete_vertex: {
257+
action: on_finish_annotation_spatial_modification,
258+
undo: on_finish_annotation_spatial_modification,
259+
},
256260
assign_annotation_id: {
257261
action: on_annotation_id_change,
258262
undo: on_annotation_id_change,
@@ -570,6 +574,9 @@ function undo_action(ulabel: ULabel, action: ULabelAction) {
570574
case "delete_annotation":
571575
ulabel.delete_annotation__undo(action.annotation_id);
572576
break;
577+
case "delete_vertex":
578+
ulabel.delete_vertex__undo(action.annotation_id, undo_payload);
579+
break;
573580
case "cancel_annotation":
574581
ulabel.cancel_annotation__undo(action.annotation_id, undo_payload);
575582
break;
@@ -644,6 +651,9 @@ export function redo_action(ulabel: ULabel, action: ULabelAction) {
644651
case "delete_annotation":
645652
ulabel.delete_annotation__redo(action.annotation_id);
646653
break;
654+
case "delete_vertex":
655+
ulabel.delete_vertex__redo(action.annotation_id, redo_payload);
656+
break;
647657
case "cancel_annotation":
648658
ulabel.cancel_annotation(action.annotation_id);
649659
break;

src/configuration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@ export class Configuration {
198198

199199
public delete_annotation_keybind: string = "d";
200200

201+
public delete_vertex_keybind: string = "x";
202+
201203
public keypoint_slider_default_value: number;
202204

203205
public filter_annotations_on_load: boolean = true;

src/index.js

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3087,6 +3087,15 @@ export class ULabel {
30873087
current_subtask["state"]["starting_complex_polygon"] = false;
30883088
}
30893089

3090+
// Clear drag state if we're in the middle of a drag
3091+
if (this.drag_state["active_key"] !== null) {
3092+
this.drag_state["active_key"] = null;
3093+
this.drag_state["release_button"] = null;
3094+
}
3095+
3096+
// Clear edit candidate
3097+
current_subtask["state"]["edit_candidate"] = null;
3098+
30903099
let frame = this.state["current_frame"];
30913100
if (MODES_3D.includes(spatial_type)) {
30923101
frame = null;
@@ -3110,6 +3119,134 @@ export class ULabel {
31103119
this.delete_annotation(annotation_id, true);
31113120
}
31123121

3122+
/**
3123+
* Delete a vertex from a polygon or polyline annotation
3124+
* @param {string} annotation_id - The ID of the annotation
3125+
* @param {string|array} access_str - Access string identifying the vertex to delete
3126+
* @param {boolean} redoing - Whether this is a redo operation
3127+
* @param {boolean} should_record_action - Whether to record this action in the action stream
3128+
*/
3129+
delete_vertex(annotation_id, access_str, redoing = false, should_record_action = true) {
3130+
const current_subtask = this.get_current_subtask();
3131+
const annotation = current_subtask["annotations"]["access"][annotation_id];
3132+
const spatial_type = annotation["spatial_type"];
3133+
3134+
// Only allow vertex deletion for polygons and polylines
3135+
if (spatial_type !== "polygon" && spatial_type !== "polyline") {
3136+
return;
3137+
}
3138+
3139+
let spatial_payload = annotation["spatial_payload"];
3140+
let layer_index = 0;
3141+
let vertex_index;
3142+
let active_spatial_payload = spatial_payload;
3143+
let should_delete = false;
3144+
3145+
// Parse access string based on spatial type
3146+
if (spatial_type === "polygon") {
3147+
// For polygons, access_str is [layer_index, vertex_index]
3148+
layer_index = parseInt(access_str[0], 10);
3149+
active_spatial_payload = spatial_payload[layer_index];
3150+
vertex_index = parseInt(access_str[1], 10);
3151+
} else {
3152+
// For polylines, access_str is just the vertex_index
3153+
vertex_index = parseInt(access_str, 10);
3154+
}
3155+
3156+
// Store the old state for undo
3157+
const old_spatial_payload = JSON.parse(JSON.stringify(spatial_payload));
3158+
3159+
// Delete the vertex
3160+
if (spatial_type === "polygon") {
3161+
// For polygons, handle the special case of first/last point
3162+
const n_points = active_spatial_payload.length;
3163+
if (vertex_index === 0 || vertex_index === n_points - 1) {
3164+
// First and last points are the same in a closed polygon
3165+
// Remove both
3166+
active_spatial_payload.splice(n_points - 1, 1);
3167+
active_spatial_payload.splice(0, 1);
3168+
// Make the new first point also the last point
3169+
if (active_spatial_payload.length > 0) {
3170+
active_spatial_payload.push([...active_spatial_payload[0]]);
3171+
}
3172+
} else {
3173+
// Remove the vertex
3174+
active_spatial_payload.splice(vertex_index, 1);
3175+
}
3176+
3177+
// Check if the layer should be removed (fewer than 3 points means fewer than 4 including duplicate)
3178+
if (active_spatial_payload.length < 4) {
3179+
// Remove the entire layer
3180+
spatial_payload.splice(layer_index, 1);
3181+
3182+
// If no layers remain, delete the entire annotation
3183+
if (spatial_payload.length === 0) {
3184+
should_delete = true;
3185+
}
3186+
}
3187+
} else {
3188+
// For polylines
3189+
active_spatial_payload.splice(vertex_index, 1);
3190+
3191+
// If only one point remains, delete the entire polyline
3192+
if (active_spatial_payload.length <= 1) {
3193+
should_delete = true;
3194+
}
3195+
}
3196+
3197+
// Clear any active edit state
3198+
if (current_subtask["state"]["active_id"] === annotation_id) {
3199+
current_subtask["state"]["active_id"] = null;
3200+
current_subtask["state"]["is_in_edit"] = false;
3201+
}
3202+
3203+
// Clear drag state if we're in the middle of a drag
3204+
if (this.drag_state["active_key"] !== null) {
3205+
this.drag_state["active_key"] = null;
3206+
this.drag_state["release_button"] = null;
3207+
}
3208+
3209+
// Clear edit candidate
3210+
current_subtask["state"]["edit_candidate"] = null;
3211+
3212+
let frame = this.state["current_frame"];
3213+
if (MODES_3D.includes(spatial_type)) {
3214+
frame = null;
3215+
}
3216+
3217+
// Record the action
3218+
record_action(this, {
3219+
act_type: "delete_vertex",
3220+
annotation_id: annotation_id,
3221+
frame: frame,
3222+
undo_payload: {
3223+
old_spatial_payload: old_spatial_payload,
3224+
deleted: should_delete,
3225+
},
3226+
redo_payload: {
3227+
access_str: access_str,
3228+
},
3229+
}, redoing, should_record_action);
3230+
3231+
// If the entire annotation should be deleted, do so
3232+
if (should_delete) {
3233+
this.delete_annotation(annotation_id, false, false);
3234+
}
3235+
}
3236+
3237+
delete_vertex__undo(annotation_id, undo_payload) {
3238+
const annotation = this.get_current_subtask()["annotations"]["access"][annotation_id];
3239+
annotation["spatial_payload"] = undo_payload.old_spatial_payload;
3240+
3241+
if (undo_payload.deleted) {
3242+
this.delete_annotation__undo(annotation_id);
3243+
}
3244+
}
3245+
3246+
delete_vertex__redo(annotation_id, redo_payload) {
3247+
this.delete_vertex(annotation_id, redo_payload.access_str, true);
3248+
}
3249+
31133250
/**
31143251
* Get the annotation with nearest active keypoint (e.g. corners for a bbox, endpoints for polylines) to a point
31153252
* @param {*} global_x
@@ -3124,6 +3261,7 @@ export class ULabel {
31243261
access: null,
31253262
distance: max_dist / this.get_empirical_scale(),
31263263
point: null,
3264+
is_vertex: true,
31273265
};
31283266
if (candidates === null) {
31293267
candidates = this.get_current_subtask()["annotations"]["ordering"];
@@ -3227,6 +3365,7 @@ export class ULabel {
32273365
access: null,
32283366
distance: max_dist / this.get_empirical_scale(),
32293367
point: null,
3368+
is_vertex: false,
32303369
};
32313370
if (candidates === null) {
32323371
candidates = this.get_current_subtask()["annotations"]["ordering"];

src/listeners.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,16 @@ export function create_ulabel_listeners(
604604
ulabel.delete_annotation(edit_cand.annid);
605605
}
606606
}
607+
// Check the key pressed against the delete vertex keybind in the config
608+
if (event_matches_keybind(keypress_event, ulabel.config.delete_vertex_keybind)) {
609+
const current_subtask = ulabel.get_current_subtask();
610+
const edit_cand = current_subtask.state.edit_candidate;
611+
612+
// Only delete if we have an edit candidate that is an actual vertex (not a segment point)
613+
if (edit_cand !== null && edit_cand.is_vertex === true) {
614+
ulabel.delete_vertex(edit_cand.annid, edit_cand.access);
615+
}
616+
}
607617
},
608618
);
609619

0 commit comments

Comments
 (0)