From 7e0e0add137d06009d45454a933f208593ed2834 Mon Sep 17 00:00:00 2001 From: dillonj Date: Fri, 26 Dec 2025 17:42:40 -0700 Subject: [PATCH] improved UI --- tk_annotation_gui.py | 171 ++++++++++++++++++++++++++++++------------- 1 file changed, 120 insertions(+), 51 deletions(-) diff --git a/tk_annotation_gui.py b/tk_annotation_gui.py index 93ed6828..f9227067 100644 --- a/tk_annotation_gui.py +++ b/tk_annotation_gui.py @@ -585,11 +585,118 @@ class TkAnnotationApp: return corner return None + def _find_handle_at_point(self, x: float, y: float) -> tuple[int, str] | None: + """Return (box_index, corner) if point hits a resize handle. + + Unlike _find_box_at_point, this can hit even slightly outside the box + bounds (since the drawn handle squares extend beyond the rectangle). + When handles overlap, choose the smallest box by area. + """ + + if self.current_image_path is None: + return None + boxes = self.annotations.get(self.current_image_path.name, []) or [] + + handle_size = 10 + candidates: list[tuple[float, int, str]] = [] # (area, index, corner) + for i, box in enumerate(boxes): + bbox = box.get("bbox") if isinstance(box, dict) else None + if not bbox or len(bbox) != 4: + continue + x1, y1, x2, y2 = bbox + area = (x2 - x1) * (y2 - y1) + dx1, dy1 = self._img_to_disp(x1, y1) + dx2, dy2 = self._img_to_disp(x2, y2) + corners = { + "nw": (dx1, dy1), + "ne": (dx2, dy1), + "sw": (dx1, dy2), + "se": (dx2, dy2), + } + for corner, (cx, cy) in corners.items(): + if cx - handle_size <= x <= cx + handle_size and cy - handle_size <= y <= cy + handle_size: + candidates.append((area, i, corner)) + + if not candidates: + return None + candidates.sort(key=lambda t: t[0]) + _, idx, corner = candidates[0] + return idx, corner + + def _apply_resize_drag(self, event_x: float, event_y: float) -> None: + if ( + not self.dragging + or self.drag_mode != "resize" + or self.resize_corner is None + or self.selected_box_index is None + or self.drag_start is None + or self.current_image_path is None + ): + return + + boxes = self.annotations.get(self.current_image_path.name, []) or [] + if self.selected_box_index >= len(boxes): + return + + bbox = boxes[self.selected_box_index].get("bbox") + if not bbox or len(bbox) != 4: + return + + x1, y1, x2, y2 = bbox + dx = event_x - self.drag_start[0] + dy = event_y - self.drag_start[1] + + # Convert to display coords + dx1, dy1 = self._img_to_disp(x1, y1) + dx2, dy2 = self._img_to_disp(x2, y2) + + if "n" in self.resize_corner: + dy1 += dy + if "s" in self.resize_corner: + dy2 += dy + if "w" in self.resize_corner: + dx1 += dx + if "e" in self.resize_corner: + dx2 += dx + + # Convert back to image coords + ix1, iy1 = self._disp_to_img(dx1, dy1) + ix2, iy2 = self._disp_to_img(dx2, dy2) + + # Ensure min size + if abs(ix2 - ix1) < 2: + ix2 = ix1 + 2 if ix2 > ix1 else ix1 - 2 + if abs(iy2 - iy1) < 2: + iy2 = iy1 + 2 if iy2 > iy1 else iy1 - 2 + + boxes[self.selected_box_index]["bbox"] = [min(ix1, ix2), min(iy1, iy2), max(ix1, ix2), max(iy1, iy2)] + self.drag_start = (event_x, event_y) + self._redraw_boxes() + # ------------------------- Mouse interactions ------------------------- def _on_mouse_down(self, event: tk.Event) -> None: if self.current_image is None or self.current_image_path is None or self.transform is None: return + + # First priority: if cursor is over a corner handle, start resizing (left-drag) + handle_hit = self._find_handle_at_point(event.x, event.y) + if handle_hit is not None: + box_index, corner = handle_hit + self.selected_box_index = box_index + self.dragging = True + self.drag_mode = "resize" + self.resize_corner = corner + self.drag_start = (event.x, event.y) + self._is_selecting = False + self._potential_select = None + self._mouse_moved = False + self._refresh_box_list() + self._redraw_boxes() + return + + # Find which box (if any) is under the cursor + box_index = self._find_box_at_point(event.x, event.y) # Check if Ctrl is held for moving or resizing boxes if event.state & 0x4: # Ctrl key @@ -601,10 +708,11 @@ class TkAnnotationApp: self.drag_mode = 'resize' self.resize_corner = corner self.drag_start = (event.x, event.y) + self._refresh_box_list() + self._redraw_boxes() return # Otherwise, select and move a box - box_index = self._find_box_at_point(event.x, event.y) if box_index is not None: self.selected_box_index = box_index self.dragging = True @@ -613,19 +721,8 @@ class TkAnnotationApp: self._refresh_box_list() self._redraw_boxes() return - - # Normal mode: check if clicking on corner of selected box for resizing - if self.selected_box_index is not None: - corner = self._find_resize_corner(event.x, event.y, self.selected_box_index) - if corner: - self.dragging = True - self.drag_mode = 'resize' - self.resize_corner = corner - self.drag_start = (event.x, event.y) - return - + # Normal mode: check if clicking inside a box to potentially select it - box_index = self._find_box_at_point(event.x, event.y) if box_index is not None: self._potential_select = box_index self._is_selecting = True @@ -640,6 +737,10 @@ class TkAnnotationApp: self._preview_rect_id = None def _on_mouse_move(self, event: tk.Event) -> None: + if self.dragging and self.drag_mode == "resize": + self._apply_resize_drag(event.x, event.y) + return + if self.dragging and self.drag_mode == 'move' and self.drag_start and self.selected_box_index is not None: # Move the box dx = event.x - self.drag_start[0] @@ -687,7 +788,10 @@ class TkAnnotationApp: self.dragging = False self.drag_mode = None self.drag_start = None + self.resize_corner = None self._save_annotations() + self._refresh_box_list() + self._redraw_boxes() return if self._is_selecting: @@ -753,44 +857,7 @@ class TkAnnotationApp: self._redraw_boxes() def _on_right_mouse_move(self, event: tk.Event) -> None: - if not self.dragging or self.drag_mode != 'resize' or self.resize_corner is None or self.selected_box_index is None or self.drag_start is None: - return - if self.current_image_path is None: - return - boxes = self.annotations.get(self.current_image_path.name, []) or [] - if self.selected_box_index >= len(boxes): - return - bbox = boxes[self.selected_box_index]["bbox"] - x1, y1, x2, y2 = bbox - dx = event.x - self.drag_start[0] - dy = event.y - self.drag_start[1] - - # Convert to display coords - dx1, dy1 = self._img_to_disp(x1, y1) - dx2, dy2 = self._img_to_disp(x2, y2) - - if 'n' in self.resize_corner: - dy1 += dy - if 's' in self.resize_corner: - dy2 += dy - if 'w' in self.resize_corner: - dx1 += dx - if 'e' in self.resize_corner: - dx2 += dx - - # Convert back to image coords - ix1, iy1 = self._disp_to_img(dx1, dy1) - ix2, iy2 = self._disp_to_img(dx2, dy2) - - # Ensure min size - if abs(ix2 - ix1) < 2: - ix2 = ix1 + 2 if ix2 > ix1 else ix1 - 2 - if abs(iy2 - iy1) < 2: - iy2 = iy1 + 2 if iy2 > iy1 else iy1 - 2 - - boxes[self.selected_box_index]["bbox"] = [min(ix1, ix2), min(iy1, iy2), max(ix1, ix2), max(iy1, iy2)] - self.drag_start = (event.x, event.y) - self._redraw_boxes() + self._apply_resize_drag(event.x, event.y) def _on_right_mouse_up(self, event: tk.Event) -> None: if self.dragging and self.drag_mode == 'resize': @@ -799,6 +866,8 @@ class TkAnnotationApp: self.resize_corner = None self.drag_start = None self._save_annotations() + self._refresh_box_list() + self._redraw_boxes() def _on_delete_key(self, event: tk.Event) -> None: self.delete_selected_box()