from __future__ import annotations

import collections
import enum
import typing
from collections.abc import Callable
from typing import Any
from typing import Final
from typing import Literal
from typing import cast

import imgviz
import numpy as np
import osam
from loguru import logger
from PyQt5 import QtCore
from PyQt5 import QtGui
from PyQt5 import QtWidgets
from PyQt5.QtCore import QPoint
from PyQt5.QtCore import QPointF
from PyQt5.QtCore import QRectF
from PyQt5.QtCore import Qt

import labelme.utils
from labelme import _automation
from labelme import _shape
from labelme._shape import POLYLINE_SHAPE_TYPES
from labelme._shape import Shape
from labelme._shape import ShapeType

from .download import download_ai_model

CURSOR_DEFAULT = Qt.ArrowCursor
CURSOR_POINT = Qt.PointingHandCursor
CURSOR_DRAW = Qt.CrossCursor
CURSOR_MOVE = Qt.ClosedHandCursor
CURSOR_GRAB = Qt.OpenHandCursor

MOVE_SPEED: float = 5.0

_CreateMode = Literal[
    "polygon",
    "rectangle",
    "oriented_rectangle",
    "circle",
    "line",
    "point",
    "linestrip",
    "ai_points_to_shape",
    "ai_box_to_shape",
]

_AI_CREATE_MODES: Final[tuple[_CreateMode, ...]] = (
    "ai_points_to_shape",
    "ai_box_to_shape",
)


_CREATE_MODE_TO_SHAPE_TYPE: Final[dict[_CreateMode, ShapeType]] = {
    "polygon": "polygon",
    "rectangle": "rectangle",
    "oriented_rectangle": "oriented_rectangle",
    "circle": "circle",
    "line": "line",
    "point": "point",
    "linestrip": "linestrip",
    "ai_points_to_shape": "points",
    "ai_box_to_shape": "rectangle",
}


# Modes whose seed point cannot be reinterpreted as the start of another mode.
# `point` finalizes on click so never has a partial shape; AI modes carry
# per-point positive/negative labels. Every other mode in _CreateMode shares a
# 1-click anchor and is seed-compatible by default — new modes participate
# unless explicitly listed here.
_SEED_INCOMPATIBLE_CREATE_MODES: Final[tuple[_CreateMode, ...]] = (
    "point",
    "ai_points_to_shape",
    "ai_box_to_shape",
)


class _CanvasMode(enum.Enum):
    CREATE = enum.auto()
    EDIT = enum.auto()


class Canvas(QtWidgets.QWidget):
    pixmap: QtGui.QPixmap
    _pixmap_hash: int | None
    _cursor: QtCore.Qt.CursorShape
    shapes: list[Shape]
    shape_backups: collections.deque[list[Shape]]
    _is_moving_shape: bool
    selected_shapes: list[Shape]
    _selected_shapes_copy: list[Shape]
    _current: Shape | None
    hovered_shape: Shape | None
    _last_hovered_shape: Shape | None
    _hovered_vertex: int | None
    _last_hovered_vertex: int | None
    _hovered_edge: int | None
    _last_hovered_edge: int | None
    _hovered_rotation: int | None
    _last_hovered_rotation: int | None

    zoom_request = QtCore.pyqtSignal(int, QPointF)
    scroll_request = QtCore.pyqtSignal(int, int)
    pan_request = QtCore.pyqtSignal(QPoint)
    new_shape = QtCore.pyqtSignal()
    inference_produced_no_shapes = QtCore.pyqtSignal()
    degenerate_shape_rejected = QtCore.pyqtSignal()
    selection_changed = QtCore.pyqtSignal(list)
    shape_moved = QtCore.pyqtSignal()
    drawing_polygon = QtCore.pyqtSignal(bool)
    vertex_selected = QtCore.pyqtSignal(bool)
    edge_selected = QtCore.pyqtSignal(bool)
    mouse_moved = QtCore.pyqtSignal(QPointF)
    status_updated = QtCore.pyqtSignal(str)

    mode: _CanvasMode = _CanvasMode.EDIT

    _create_mode: _CreateMode = "polygon"

    _fill_drawing = False

    _prev_point: QPointF
    _prev_move_point: QPointF
    _drag_anchor: tuple[QPointF, QRectF]
    _rotation_center: QPointF
    _rotation_initial_angle: float
    _rotation_original_points: list[QPointF]

    _pan_anchor: QPointF | None

    _osam_session_model_name: str = "sam2:latest"
    _osam_session: _automation.OsamSession | None
    _ai_output_format: _automation.AiOutputFormat = "polygon"

    def __init__(self, *args: Any, **kwargs: Any) -> None:  # noqa: ANN401
        self._epsilon: float = kwargs.pop("epsilon", 10.0)
        self._double_click = kwargs.pop("double_click", "close")
        if self._double_click not in [None, "close"]:
            raise ValueError(
                f"Unexpected value for double_click event: {self._double_click}"
            )
        self._num_backups: int = kwargs.pop("num_backups", 10)
        self._crosshair = kwargs.pop(
            "crosshair",
            {
                "polygon": False,
                "rectangle": True,
                "oriented_rectangle": False,
                "circle": False,
                "line": False,
                "point": False,
                "linestrip": False,
                "ai_points_to_shape": False,
                "ai_box_to_shape": True,
            },
        )
        super().__init__(*args, **kwargs)

        self._cursor = CURSOR_DEFAULT
        self.reset_state()

        # self._line represents:
        #   - create_mode == 'polygon': edge from last point to current
        #   - create_mode == 'rectangle': diagonal line of the rectangle
        #   - create_mode == 'line': the line
        #   - create_mode == 'point': the point
        self._line = Shape()
        self._prev_point = QPointF()
        self._prev_move_point = QPointF()
        self._drag_anchor = (QPointF(), QRectF())
        self._rotation_center = QPointF()
        self._rotation_initial_angle = 0.0
        self._rotation_original_points = []
        self.scale: float = 1.0
        self._osam_session = None
        self._snapping = True
        self._hovered_shape_is_selected: bool = False
        self._painter = QtGui.QPainter()
        self._pan_anchor = None
        # Menus:
        # 0: right-click without selection and dragging of shapes
        # 1: right-click with selection and dragging of shapes
        self.menus = (QtWidgets.QMenu(), QtWidgets.QMenu())
        self.setMouseTracking(True)
        self.setFocusPolicy(Qt.WheelFocus)

    def set_fill_drawing(self, value: bool) -> None:
        self._fill_drawing = value

    @property
    def is_drawing(self) -> bool:
        return self._current is not None

    @property
    def create_mode(self) -> _CreateMode:
        return self._create_mode

    @create_mode.setter
    def create_mode(self, value: str) -> None:
        if value not in typing.get_args(_CreateMode):
            raise ValueError(f"Unsupported create_mode: {value}")
        new_mode = cast(_CreateMode, value)
        if new_mode == self._create_mode:
            return
        old_mode = self._create_mode
        # Update the mode before reconciling so any signals fired from a cancel
        # observe the new mode rather than the one being left behind.
        self._create_mode = new_mode
        self._reconcile_partial_shape_on_mode_switch(
            old_mode=old_mode, new_mode=new_mode
        )

    def _reconcile_partial_shape_on_mode_switch(
        self, *, old_mode: _CreateMode, new_mode: _CreateMode
    ) -> None:
        if self._current is None:
            return
        if not (
            len(self._current.points) == 1
            and old_mode not in _SEED_INCOMPATIBLE_CREATE_MODES
            and new_mode not in _SEED_INCOMPATIBLE_CREATE_MODES
        ):
            self._cancel_current_shape()
            return
        # Shape type is identity, not state: construct fresh shapes rather than
        # mutating in place. The prior mode's _update_drawing_line left
        # _line.points as a valid [seed, cursor] pair — carry it forward so a
        # click before the next mouseMoveEvent extends at the real cursor.
        seed_point = self._current.points[0]
        seed_label = self._current.point_labels[0]
        new_current = Shape(shape_type=new_mode)
        new_current.add_point(seed_point, label=seed_label)
        self._current = new_current
        self._line = _rebuild_line(self._line, shape_type=new_mode)
        self.update()

    def get_ai_model_name(self) -> str:
        return self._osam_session_model_name

    def set_ai_model_name(self, model_name: str) -> None:
        self._osam_session_model_name = model_name

    def set_ai_output_format(self, output_format: _automation.AiOutputFormat) -> None:
        self._ai_output_format = output_format

    def _get_osam_session(self) -> _automation.OsamSession:
        if (
            self._osam_session is None
            or self._osam_session.model_name != self._osam_session_model_name
        ):
            self._osam_session = _automation.OsamSession(
                model_name=self._osam_session_model_name
            )
        return self._osam_session

    def _shapes_from_ai_points(
        self, points: list[QPointF], point_labels: list[int]
    ) -> list[Shape]:
        image: np.ndarray = labelme.utils.img_qt_to_arr(img_qt=self.pixmap.toImage())
        response: osam.types.GenerateResponse = self._get_osam_session().run(
            image=imgviz.asrgb(image),  # type: ignore[arg-type]
            image_id=str(self._pixmap_hash),
            points=np.array([[p.x(), p.y()] for p in points]),
            point_labels=np.array(point_labels),
        )
        # iou_threshold is hardcoded here because the ai-points/box flow has
        # no user-facing IoU control (unlike app.py's ai-text flow). 0.5 is
        # the same default the ai-text widget ships with.
        detections = _automation.suppress_detections_greedy(
            detections=_detections_from_annotations(response.annotations),
            iou_threshold=0.5,
        )
        detections = _automation.suppress_detections_overlapping_existing_shapes(
            detections=detections,
            existing_shapes=self.shapes,
        )
        return _automation.shapes_from_detections(
            detections=detections,
            shape_type=self._ai_output_format,
        )

    def backup_shapes(self) -> None:
        self.shape_backups.append([s.copy() for s in self.shapes])

    @property
    def can_restore_shape(self) -> bool:
        # The latest entry on the backup stack mirrors the current state, so
        # at least one prior entry must exist for an undo to be meaningful.
        return len(self.shape_backups) >= 2

    def restore_last_shape(self) -> None:
        # Undo coordinates with app.py::undo_shape_edit, app.py::load_shapes,
        # and Canvas::load_shapes; this method only adjusts the backup stack.
        if not self.can_restore_shape:
            return
        self.shape_backups.pop()  # discard current state

        # load_shapes (called downstream by the application) will re-push
        # this entry as the new current state.
        self.shapes = self.shape_backups.pop()
        for shape in self.shapes:
            shape.selected = False
        self.selected_shapes.clear()
        self.update()

    def enterEvent(self, a0: QtCore.QEvent) -> None:
        self._apply_cursor(self._cursor)
        self._update_status()

    def leaveEvent(self, a0: QtCore.QEvent) -> None:
        if self._set_highlight(
            hovered_shape=None,
            hovered_edge=None,
            hovered_vertex=None,
            hovered_rotation=None,
        ):
            self.update()
        self._release_cursor()
        self._update_status()

    def focusOutEvent(self, a0: QtGui.QFocusEvent) -> None:
        self._release_cursor()
        self._update_status()

    def set_editing(self, value: bool = True) -> None:
        self.mode = _CanvasMode.EDIT if value else _CanvasMode.CREATE
        if self.mode == _CanvasMode.EDIT:
            # CREATE -> EDIT
            self.update()  # clear crosshair
        else:
            # EDIT -> CREATE
            need_update: bool = self._set_highlight(
                hovered_shape=None,
                hovered_edge=None,
                hovered_vertex=None,
                hovered_rotation=None,
            )
            need_update |= self.deselect_shape()
            if need_update:
                self.update()

    def _set_highlight(
        self,
        hovered_shape: Shape | None,
        hovered_edge: int | None,
        hovered_vertex: int | None,
        hovered_rotation: int | None,
    ) -> bool:
        previous_shape: Shape | None = self.hovered_shape
        need_update: bool = hovered_shape is not None
        if previous_shape is not None:
            previous_shape.clear_highlight()
            need_update = True
        # NOTE: Store last highlighted for adding/removing points.
        self._last_hovered_shape = (
            previous_shape if hovered_shape is None else hovered_shape
        )
        self._last_hovered_vertex = (
            self._hovered_vertex if hovered_vertex is None else hovered_vertex
        )
        self._last_hovered_edge = (
            self._hovered_edge if hovered_edge is None else hovered_edge
        )
        self._last_hovered_rotation = (
            self._hovered_rotation if hovered_rotation is None else hovered_rotation
        )
        self.hovered_shape = hovered_shape
        self._hovered_vertex = hovered_vertex
        self._hovered_edge = hovered_edge
        self._hovered_rotation = hovered_rotation
        return need_update

    def _is_vertex_selected(self) -> bool:
        return self._hovered_vertex is not None

    def _is_edge_selected(self) -> bool:
        return self._hovered_edge is not None

    def _is_rotation_point_selected(self) -> bool:
        return self._hovered_rotation is not None

    def _update_status(self, extra_messages: list[str] | None = None) -> None:
        messages: list[str] = []
        if self.mode == _CanvasMode.CREATE:
            messages.append(self.tr("Creating %r") % self.create_mode)
            messages.append(self._get_create_mode_message())
            if self._current is not None:
                messages.append(self.tr("ESC to cancel"))
            if self._can_close_shape():
                messages.append(self.tr("Enter or Space to finalize"))
        else:
            assert self.mode == _CanvasMode.EDIT
            messages.append(self.tr("Editing shapes"))
        if extra_messages:
            messages.extend(extra_messages)
        self.status_updated.emit(" • ".join(messages))

    def _get_create_mode_message(self) -> str:
        assert self.mode == _CanvasMode.CREATE
        is_new: bool = self._current is None
        if self.create_mode == "ai_points_to_shape":
            return self.tr(
                "Click points to include or Shift+Click to exclude."
                " Ctrl+LeftClick ends creation."
            )
        if self.create_mode == "ai_box_to_shape":
            if is_new:
                return self.tr("Click first corner of bbox for AI segmentation")
            else:
                return self.tr("Click opposite corner to segment object")
        if self.create_mode == "line":
            if is_new:
                return self.tr("Click start point for line")
            else:
                return self.tr("Click end point for line")
        if self.create_mode == "linestrip":
            if is_new:
                return self.tr("Click start point for linestrip")
            else:
                return self.tr(
                    "Click next point or finish by Ctrl/Cmd+Click for linestrip"
                )
        if self.create_mode == "circle":
            if is_new:
                return self.tr("Click center point for circle")
            else:
                return self.tr("Click point on circumference for circle")
        if self.create_mode == "rectangle":
            if is_new:
                return self.tr("Click first corner for rectangle")
            else:
                return self.tr("Click opposite corner for rectangle (Shift for square)")
        if self.create_mode == "oriented_rectangle":
            if is_new:
                return self.tr("Click first corner for oriented rectangle")
            assert self._current is not None
            if len(self._current.points) == 1:
                return self.tr("Click second corner to set orientation")
            return self.tr("Click third corner to close oriented rectangle")
        return self.tr("Click to add point")

    def mouseMoveEvent(self, a0: QtGui.QMouseEvent) -> None:
        try:
            pos = self._transform_point_widget_to_image(a0.localPos())
        except AttributeError:
            return
        self.mouse_moved.emit(pos)
        self._prev_move_point = pos
        self._dispatch_pointer_move(pos=pos, event=a0)

    def _dispatch_pointer_move(self, pos: QPointF, event: QtGui.QMouseEvent) -> None:
        if self._pan_anchor is not None:
            self._advance_pan(event=event)
            return
        if self.mode == _CanvasMode.CREATE:
            self._track_drawing_cursor(pos=pos, event=event)
            return
        buttons = event.buttons()
        if buttons & Qt.RightButton:
            self._continue_right_button_drag(pos=pos)
            return
        if buttons & Qt.LeftButton:
            self._continue_left_button_drag(pos=pos, event=event)
            return
        self._refresh_hover_state(pos=pos)

    def _advance_pan(self, event: QtGui.QMouseEvent) -> None:
        assert self._pan_anchor is not None
        # Use screen coordinates so the anchor does not drift when our own
        # pan emit shifts the canvas widget under the scroll area — a
        # widget-local frame would oscillate and cause juggling.
        cursor: QPointF = QPointF(self.mapToGlobal(event.pos()))
        step: QPointF = cursor - self._pan_anchor
        self._pan_anchor = cursor
        self.pan_request.emit(QPoint(int(step.x()), int(step.y())))

    def _track_drawing_cursor(self, pos: QPointF, event: QtGui.QMouseEvent) -> None:
        desired_line_shape_type = _CREATE_MODE_TO_SHAPE_TYPE[self.create_mode]
        if self._line.shape_type != desired_line_shape_type:
            self._line = _rebuild_line(self._line, shape_type=desired_line_shape_type)
        self._apply_cursor(CURSOR_DRAW)
        if self._current is None:
            self.update()
            self._update_status()
            return
        is_shift_pressed = bool(event.modifiers() & Qt.ShiftModifier)
        pos = self._project_drawing_pos_into_image(pos=pos)
        self._update_drawing_line(pos=pos, is_shift_pressed=is_shift_pressed)
        assert len(self._line.points) == len(self._line.point_labels)
        self.update()
        self._update_status()

    def _project_drawing_pos_into_image(self, pos: QPointF) -> QPointF:
        current = self._current
        assert current is not None
        if self.create_mode == "oriented_rectangle" and len(current.points) == 4:
            # The second click only locks the orientation of the first edge,
            # not its length. The third-corner cursor drives parallelogram
            # completion through the diagonal anchor at points[0], so the
            # clicked points[1] slides along the locked axis as the cursor
            # changes the rectangle's extent in that direction.
            MOVING_CORNER_INDEX: Final[int] = 2
            self._bounded_move_vertex(
                shape=current,
                vertex_index=MOVING_CORNER_INDEX,
                pos=pos,
                is_shift_pressed=False,
            )
            return current.points[MOVING_CORNER_INDEX]
        if self.is_out_of_pixmap(pos):
            return _compute_intersection_edges_image(
                current.points[-1], pos, image_size=self.pixmap.size()
            )
        if not self._cursor_should_snap_to_polygon_origin(pos=pos):
            return pos
        self._apply_cursor(CURSOR_POINT)
        current.highlight_vertex(index=0, mode="near")
        return current.points[0]

    def _cursor_should_snap_to_polygon_origin(self, pos: QPointF) -> bool:
        if not self._snapping:
            return False
        if self.create_mode != "polygon":
            return False
        current = self._current
        if current is None or len(current.points) <= 1:
            return False
        return self._is_close_enough(pos, current.points[0])

    def _refresh_hover_state(self, pos: QPointF) -> None:
        status_messages: list[str] = []
        self._highlight_hover_shape(pos=pos, status_messages=status_messages)
        self.vertex_selected.emit(self._hovered_vertex is not None)
        self.edge_selected.emit(self._hovered_edge is not None)
        self._update_status(extra_messages=status_messages)

    def _update_drawing_line(self, pos: QPointF, is_shift_pressed: bool) -> QPointF:
        current = self._current
        assert current is not None
        mode = self.create_mode
        if mode in POLYLINE_SHAPE_TYPES:
            self._line.points = [current.points[-1], pos]
            self._line.point_labels = [1, 1]
        elif mode == "ai_points_to_shape":
            self._line.points = [current.points[-1], pos]
            self._line.point_labels = [
                current.point_labels[-1],
                0 if is_shift_pressed else 1,
            ]
        elif mode in ("rectangle", "ai_box_to_shape"):
            if is_shift_pressed:
                pos = _snap_cursor_pos_for_square(
                    pos=pos, opposite_vertex=current.points[0]
                )
                self._prev_move_point = pos
            self._line.points = [current.points[0], pos]
            self._line.point_labels = [1, 1]
            self._line.close()
        elif mode == "oriented_rectangle":
            origin = (
                current.points[0] if len(current.points) == 1 else current.points[1]
            )
            self._line.points = [origin, pos]
            self._line.point_labels = [1, 1]
        elif mode == "circle":
            self._line.points = [current.points[0], pos]
            self._line.point_labels = [1, 1]
        elif mode == "line":
            self._line.points = [current.points[0], pos]
            self._line.point_labels = [1, 1]
            self._line.close()
        elif mode == "point":
            self._line.points = [current.points[0]]
            self._line.point_labels = [1]
            self._line.close()
        return pos

    def _continue_right_button_drag(self, pos: QPointF) -> None:
        if self._selected_shapes_copy:
            self._apply_cursor(CURSOR_MOVE)
            self._drag_shapes(shapes=self._selected_shapes_copy, cursor=pos)
            self.update()
        elif self.selected_shapes:
            self._selected_shapes_copy = [s.copy() for s in self.selected_shapes]
            self.update()
        self._update_status()

    def _continue_left_button_drag(
        self, pos: QPointF, event: QtGui.QMouseEvent
    ) -> None:
        is_shift_pressed = bool(event.modifiers() & Qt.ShiftModifier)
        if self._is_vertex_selected():
            self._drag_hovered_vertex(pos=pos, is_shift_pressed=is_shift_pressed)
            return
        if self._is_rotation_point_selected():
            self._drag_hovered_rotation_point(pos=pos)
            return
        if not self.selected_shapes:
            return
        self._drag_selected_shapes(pos=pos)

    def _drag_hovered_vertex(self, pos: QPointF, is_shift_pressed: bool) -> None:
        assert self._hovered_vertex is not None
        assert self.hovered_shape is not None
        self._bounded_move_vertex(
            shape=self.hovered_shape,
            vertex_index=self._hovered_vertex,
            pos=pos,
            is_shift_pressed=is_shift_pressed,
        )
        self.update()
        self._is_moving_shape = True

    def _drag_hovered_rotation_point(self, pos: QPointF) -> None:
        assert self.hovered_shape is not None
        assert self._rotation_original_points, (
            "_capture_rotation_anchors must be called before dragging"
        )
        current_angle = labelme.utils.direction_angle(
            start=self._rotation_center, end=pos
        )
        _shape.rotate(
            shape=self.hovered_shape,
            center=self._rotation_center,
            angle=current_angle - self._rotation_initial_angle,
            source_points=self._rotation_original_points,
        )
        self.update()
        self._is_moving_shape = True

    def _capture_rotation_anchors(self) -> None:
        assert self.hovered_shape is not None
        assert self._hovered_rotation is not None
        handle = _shape.get_rotation_handle(
            shape=self.hovered_shape, index=self._hovered_rotation
        )
        self._rotation_center = _shape.oriented_rectangle_center(
            shape=self.hovered_shape
        )
        self._rotation_initial_angle = labelme.utils.direction_angle(
            start=self._rotation_center, end=handle
        )
        self._rotation_original_points = self.hovered_shape.points[:]

    def _drag_selected_shapes(self, pos: QPointF) -> None:
        self._apply_cursor(CURSOR_MOVE)
        self._drag_shapes(shapes=self.selected_shapes, cursor=pos)
        self.update()
        self._is_moving_shape = True

    def _highlight_hover_shape(self, pos: QPointF, status_messages: list[str]) -> None:
        ordered_shapes: list[Shape] = (
            [self.hovered_shape] if self.hovered_shape else []
        ) + [s for s in reversed(self.shapes) if s.visible and s != self.hovered_shape]

        for shape in ordered_shapes:
            index_vertex: int | None = _shape.nearest_vertex_index(
                shape=shape, point=pos, epsilon=self._epsilon
            )
            if index_vertex is None:
                continue
            self._set_highlight(
                hovered_shape=shape,
                hovered_edge=None,
                hovered_vertex=index_vertex,
                hovered_rotation=None,
            )
            shape.highlight_vertex(index=index_vertex, mode="move")
            self._apply_cursor(CURSOR_POINT)
            status_messages.append(self.tr("Click & drag to move point"))
            if shape.can_remove_point():
                status_messages.append(self.tr("ALT + SHIFT + Click to delete point"))
            self.update()
            return

        for shape in ordered_shapes:
            index_rotation: int | None = _shape.nearest_rotation_point_index(
                shape=shape, point=pos, epsilon=self._epsilon
            )
            if index_rotation is None:
                continue
            self._set_highlight(
                hovered_shape=shape,
                hovered_edge=None,
                hovered_vertex=None,
                hovered_rotation=index_rotation,
            )
            shape.highlight_rotation_point(index=index_rotation, mode="move")
            self._apply_cursor(CURSOR_POINT)
            status_messages.append(self.tr("Click & drag to rotate the shape"))
            self.update()
            return

        for shape in ordered_shapes:
            index_edge: int | None = _shape.nearest_edge_index(
                shape=shape, point=pos, epsilon=self._epsilon
            )
            if index_edge is None or not shape.can_add_point():
                continue
            self._set_highlight(
                hovered_shape=shape,
                hovered_edge=index_edge,
                hovered_vertex=None,
                hovered_rotation=None,
            )
            self._apply_cursor(CURSOR_POINT)
            status_messages.append(self.tr("ALT + Click to create point on shape"))
            self.update()
            return

        for shape in ordered_shapes:
            if not _shape.contains_point(shape=shape, point=pos):
                continue
            self._set_highlight(
                hovered_shape=shape,
                hovered_edge=None,
                hovered_vertex=None,
                hovered_rotation=None,
            )
            status_messages.extend(
                [
                    self.tr("Click & drag to move shape"),
                    self.tr("Right-click & drag to copy shape"),
                ]
            )
            self._apply_cursor(CURSOR_GRAB)
            self.update()
            return

        self._release_cursor()
        if self._set_highlight(
            hovered_shape=None,
            hovered_edge=None,
            hovered_vertex=None,
            hovered_rotation=None,
        ):
            self.update()

    def add_point_to_edge(self) -> None:
        shape = self._last_hovered_shape
        index = self._last_hovered_edge
        point = self._prev_move_point
        if shape is None or index is None or point is None:
            return
        shape.insert_point(index, point)
        shape.highlight_vertex(index=index, mode="move")
        self.hovered_shape = shape
        self._hovered_vertex = index
        self._hovered_edge = None
        self._is_moving_shape = True

    def remove_selected_point(self) -> None:
        shape = self._last_hovered_shape
        index = self._last_hovered_vertex
        if shape is None or index is None:
            return
        shape.remove_point(index)
        shape.clear_highlight()
        self.hovered_shape = shape
        self._last_hovered_vertex = None
        self._is_moving_shape = True  # Save changes

    def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None:
        pos: QPointF = self._transform_point_widget_to_image(a0.localPos())
        self._dispatch_pointer_press(pos=pos, event=a0)
        self._update_status()

    def _dispatch_pointer_press(self, pos: QPointF, event: QtGui.QMouseEvent) -> None:
        button = event.button()
        if button == Qt.LeftButton:
            self._press_left(pos=pos, event=event)
            return
        if button == Qt.RightButton and self.mode == _CanvasMode.EDIT:
            self._press_right(pos=pos, event=event)
            return
        if button == Qt.MiddleButton and self._is_image_overflowing_viewport():
            self._begin_pan(event=event)

    def _press_left(self, pos: QPointF, event: QtGui.QMouseEvent) -> None:
        is_shift_pressed = bool(event.modifiers() & Qt.ShiftModifier)
        if self.mode == _CanvasMode.CREATE:
            self._press_left_while_drawing(
                pos=pos, event=event, is_shift_pressed=is_shift_pressed
            )
            return
        if self.mode == _CanvasMode.EDIT:
            self._press_left_while_editing(pos=pos, event=event)

    def _press_left_while_drawing(
        self,
        pos: QPointF,
        event: QtGui.QMouseEvent,
        is_shift_pressed: bool,
    ) -> None:
        if self._current is not None:
            self._extend_current_shape(current=self._current, event=event)
            return
        if self.is_out_of_pixmap(pos):
            return
        self._start_new_shape(pos=pos, event=event, is_shift_pressed=is_shift_pressed)

    def _extend_current_shape(self, current: Shape, event: QtGui.QMouseEvent) -> None:
        mode = self.create_mode
        modifiers = event.modifiers()
        if mode == "polygon":
            current.add_point(self._line.points[1], autoclose=True)
            self._line.points[0] = current.points[-1]
            if current.is_closed():
                self._finalize()
        elif mode == "oriented_rectangle":
            if len(current.points) == 4:
                self._finalize()
            else:
                assert len(current.points) == 1
                self._lock_oriented_rectangle_first_edge(current=current)
        elif mode in ("rectangle", "circle", "line", "ai_box_to_shape"):
            assert len(current.points) == 1
            current.points = self._line.points
            self._finalize()
        elif mode == "linestrip":
            current.add_point(self._line.points[1])
            self._line.points[0] = current.points[-1]
            if int(modifiers) == Qt.ControlModifier:
                self._finalize()
        elif mode == "ai_points_to_shape":
            current.add_point(
                self._line.points[1],
                label=self._line.point_labels[1],
            )
            self._line.points[0] = current.points[-1]
            self._line.point_labels[0] = current.point_labels[-1]
            if modifiers & Qt.ControlModifier:
                self._finalize()

    def _lock_oriented_rectangle_first_edge(self, current: Shape) -> None:
        first_corner = self._line.points[0]
        second_corner = self._line.points[1]
        current.points = [
            first_corner,
            second_corner,
            QPointF(second_corner),
            QPointF(first_corner),
        ]
        current.point_labels = [1, 1, 1, 1]
        self._line.points[0] = second_corner

    def _unlock_oriented_rectangle_first_edge(self, current: Shape) -> None:
        anchor = current.points[0]
        current.points = [anchor]
        current.point_labels = [current.point_labels[0]]
        self._line.points = [anchor, anchor]

    def _start_new_shape(
        self,
        pos: QPointF,
        event: QtGui.QMouseEvent,
        is_shift_pressed: bool,
    ) -> None:
        mode = self.create_mode
        if mode in ("ai_points_to_shape", "ai_box_to_shape") and not download_ai_model(
            model_name=self._osam_session_model_name, parent=self
        ):
            return

        new_shape = Shape(shape_type=_CREATE_MODE_TO_SHAPE_TYPE[mode])
        new_shape.add_point(pos, label=0 if is_shift_pressed else 1)
        self._current = new_shape

        if mode == "point":
            self._finalize()
            return
        if mode == "ai_points_to_shape" and event.modifiers() & Qt.ControlModifier:
            self._finalize()
            return

        self._line.points = [pos, pos]
        self._line.point_labels = (
            [0, 0] if mode == "ai_points_to_shape" and is_shift_pressed else [1, 1]
        )
        self.drawing_polygon.emit(True)
        self.update()

    def _press_left_while_editing(self, pos: QPointF, event: QtGui.QMouseEvent) -> None:
        modifiers = event.modifiers()
        self._maybe_modify_polygon_topology(modifiers=modifiers)
        if self._is_rotation_point_selected():
            self._capture_rotation_anchors()
        self._select_shape_point(
            pos,
            multiple_selection_mode=int(modifiers) == Qt.ControlModifier,
        )
        self._prev_point = pos
        self.update()

    def _maybe_modify_polygon_topology(self, modifiers: Qt.KeyboardModifiers) -> None:
        if self._is_edge_selected() and modifiers == Qt.AltModifier:
            self.add_point_to_edge()
            return
        if self._is_vertex_selected() and modifiers == (
            Qt.AltModifier | Qt.ShiftModifier
        ):
            self.remove_selected_point()

    def _press_right(self, pos: QPointF, event: QtGui.QMouseEvent) -> None:
        if _should_reselect_on_right_press(
            selected_shapes=self.selected_shapes, hovered_shape=self.hovered_shape
        ):
            self._select_shape_point(
                pos,
                multiple_selection_mode=int(event.modifiers()) == Qt.ControlModifier,
            )
            self.update()
        self._prev_point = pos

    def _begin_pan(self, event: QtGui.QMouseEvent) -> None:
        self._apply_cursor(CURSOR_GRAB)
        self._pan_anchor = QPointF(self.mapToGlobal(event.pos()))

    def mouseReleaseEvent(self, a0: QtGui.QMouseEvent) -> None:
        self._dispatch_pointer_release(event=a0)
        self._commit_pending_shape_move()
        self._update_status()

    def _dispatch_pointer_release(self, event: QtGui.QMouseEvent) -> None:
        button = event.button()
        if button == Qt.RightButton:
            self._release_right(event=event)
            return
        if button == Qt.LeftButton:
            self._release_left()
            return
        if button == Qt.MiddleButton:
            self._finish_pan()

    def _release_right(self, event: QtGui.QMouseEvent) -> None:
        menu = self.menus[len(self._selected_shapes_copy) > 0]
        self._release_cursor()
        if menu.exec_(self.mapToGlobal(event.pos())):  # type: ignore
            return
        if not self._selected_shapes_copy:
            return
        self._selected_shapes_copy.clear()
        self.update()

    def _release_left(self) -> None:
        if self.mode != _CanvasMode.EDIT:
            return
        if self.hovered_shape is None:
            return
        if not self._hovered_shape_is_selected:
            return
        if self._is_moving_shape:
            return
        self.selection_changed.emit(
            [s for s in self.selected_shapes if s != self.hovered_shape]
        )

    def _finish_pan(self) -> None:
        # Reset state and cursor unconditionally so a stray middle-button
        # release can never leave a grab cursor stuck on screen.
        self._pan_anchor = None
        self._release_cursor()

    def _is_image_overflowing_viewport(self) -> bool:
        if self.pixmap.isNull():
            return False
        viewport = self._scroll_viewport()
        if viewport is None:
            return False
        scaled_w = self.pixmap.width() * self.scale
        scaled_h = self.pixmap.height() * self.scale
        return scaled_w > viewport.width() or scaled_h > viewport.height()

    def _scroll_viewport(self) -> QtWidgets.QWidget | None:
        # Walk up the parent chain to the enclosing scroll area and return
        # its viewport. Returning None when no scroll area is found lets
        # callers degrade gracefully if the canvas is reparented (e.g. into
        # a splitter or a test harness).
        node: QtWidgets.QWidget | None = self.parentWidget()
        while node is not None:
            if isinstance(node, QtWidgets.QAbstractScrollArea):
                return node.viewport()
            node = node.parentWidget()
        return None

    def _commit_pending_shape_move(self) -> None:
        moved = _pick_pending_moved_shape(
            is_moving_shape=self._is_moving_shape,
            hovered_shape=self.hovered_shape,
            shapes=self.shapes,
        )
        if moved is None:
            return
        index = self.shapes.index(moved)
        if self.shape_backups[-1][index].points != self.shapes[index].points:
            self.backup_shapes()
            self.shape_moved.emit()
        self._is_moving_shape = False

    def end_move(self, copy: bool) -> bool:
        assert self.selected_shapes and self._selected_shapes_copy
        assert len(self._selected_shapes_copy) == len(self.selected_shapes)
        if copy:
            self._apply_copy_move()
        else:
            self._apply_in_place_move()
        self._selected_shapes_copy.clear()
        self.update()
        self.backup_shapes()
        return True

    def _apply_copy_move(self) -> None:
        for i, (original, copy) in enumerate(
            zip(self.selected_shapes, self._selected_shapes_copy)
        ):
            self.shapes.append(copy)
            original.selected = False
            self.selected_shapes[i] = copy

    def _apply_in_place_move(self) -> None:
        for original, copy in zip(self.selected_shapes, self._selected_shapes_copy):
            original.points = copy.points

    def _can_close_shape(self) -> bool:
        if self.mode != _CanvasMode.CREATE:
            return False
        if self._current is None:
            return False
        if self.create_mode == "ai_points_to_shape":
            return True
        if self.create_mode == "linestrip":
            return len(self._current.points) >= 2
        if self.create_mode == "oriented_rectangle":
            # Points 2 and 3 are seeded as duplicates of points 1 and 0 after
            # the first edge is locked; mouse movement reprojects them. Treat
            # the shape as closeable only once the third corner has moved.
            return (
                len(self._current.points) == 4
                and self._current.points[2] != self._current.points[1]
            )
        return len(self._current.points) >= 3

    def mouseDoubleClickEvent(self, a0: QtGui.QMouseEvent) -> None:
        if self._double_click != "close":
            return
        if not self._can_close_shape():
            return
        self._finalize()

    def select_shapes(self, shapes: list[Shape]) -> None:
        self.selection_changed.emit(shapes)
        self.update()

    def _select_shape_point(
        self, point: QPointF, multiple_selection_mode: bool
    ) -> None:
        if self._hovered_vertex is not None:
            assert self.hovered_shape is not None
            self.hovered_shape.highlight_vertex(index=self._hovered_vertex, mode="move")
            if self.deselect_shape():
                self.update()
            return

        clicked_shape = self._find_shape_at_point(point)
        if clicked_shape is None:
            if self.deselect_shape():
                self.update()
            return

        already_selected = clicked_shape in self.selected_shapes
        if already_selected:
            self._hovered_shape_is_selected = True
        else:
            new_selection = (
                self.selected_shapes + [clicked_shape]
                if multiple_selection_mode
                else [clicked_shape]
            )
            self.selection_changed.emit(new_selection)
            self._hovered_shape_is_selected = False
        self._record_drag_anchor(click=point)

    def _find_shape_at_point(self, point: QPointF) -> Shape | None:
        for shape in reversed(self.shapes):
            if shape.visible and _shape.contains_point(shape=shape, point=point):
                return shape
        return None

    def _record_drag_anchor(self, click: QPointF) -> None:
        if not self.selected_shapes:
            self._drag_anchor = (QPointF(), QRectF())
            return
        bounds = _shape.bounds(shape=self.selected_shapes[0])
        for s in self.selected_shapes[1:]:
            bounds = bounds.united(_shape.bounds(shape=s))
        self._drag_anchor = (bounds.topLeft() - click, bounds)

    def _bounded_move_vertex(
        self, shape: Shape, vertex_index: int, pos: QPointF, is_shift_pressed: bool
    ) -> None:
        if vertex_index >= len(shape.points):
            logger.warning(
                "vertex_index is out of range: vertex_index={:d}, len(points)={:d}",
                vertex_index,
                len(shape.points),
            )
            return

        if shape.shape_type == "oriented_rectangle":
            self._bounded_move_oriented_rectangle_vertex(
                shape=shape, vertex_index=vertex_index, pos=pos
            )
            return

        if self.is_out_of_pixmap(pos):
            pos = _compute_intersection_edges_image(
                shape.points[vertex_index], pos, image_size=self.pixmap.size()
            )

        if is_shift_pressed and shape.shape_type == "rectangle":
            pos = _snap_cursor_pos_for_square(
                pos=pos, opposite_vertex=shape.points[1 - vertex_index]
            )

        shape.move_vertex(i=vertex_index, pos=pos)

    def _bounded_move_oriented_rectangle_vertex(
        self, shape: Shape, vertex_index: int, pos: QPointF
    ) -> None:
        assert len(shape.points) == 4
        image_size = self.pixmap.size()
        anchor = shape.points[(vertex_index - 2) % 4]
        edge_axis = shape.points[(vertex_index - 1) % 4]
        moving = pos
        adjacent_perp, adjacent_para = _project_oriented_rectangle_corners(
            anchor=anchor, edge_axis=edge_axis, moving=moving
        )

        if self.is_out_of_pixmap(moving):
            edge_a = _compute_intersection_edges_image(
                p1=adjacent_perp, p2=moving, image_size=image_size
            )
            edge_b = _compute_intersection_edges_image(
                p1=adjacent_para, p2=moving, image_size=image_size
            )
            moving = labelme.utils.project_point_on_line(
                point=moving, line_start=edge_a, line_end=edge_b
            )
            adjacent_perp, adjacent_para = _project_oriented_rectangle_corners(
                anchor=anchor, edge_axis=adjacent_para, moving=moving
            )

        if self.is_out_of_pixmap(adjacent_perp):
            adjacent_perp = _compute_intersection_edges_image(
                p1=anchor, p2=adjacent_perp, image_size=image_size
            )
            moving = _opposite_corner_in_parallelogram(
                opposite_to=anchor, neighbor1=adjacent_perp, neighbor2=adjacent_para
            )

        if self.is_out_of_pixmap(adjacent_para):
            adjacent_para = _compute_intersection_edges_image(
                p1=anchor, p2=adjacent_para, image_size=image_size
            )
            moving = _opposite_corner_in_parallelogram(
                opposite_to=anchor, neighbor1=adjacent_perp, neighbor2=adjacent_para
            )

        shape.move_vertex(i=vertex_index, pos=moving)
        shape.move_vertex(i=(vertex_index + 1) % 4, pos=adjacent_perp)
        shape.move_vertex(i=(vertex_index - 1) % 4, pos=adjacent_para)

    def _drag_shapes(self, shapes: list[Shape], cursor: QPointF) -> bool:
        if self.is_out_of_pixmap(cursor):
            return False

        rel_tl, bounds = self._drag_anchor
        pw = float(self.pixmap.width())
        ph = float(self.pixmap.height())

        target = cursor + rel_tl
        target.setX(max(0.0, target.x()))
        target.setY(max(0.0, target.y()))
        target.setX(min(target.x(), pw - bounds.width()))
        target.setY(min(target.y(), ph - bounds.height()))

        new_cursor = target - rel_tl
        delta = new_cursor - self._prev_point
        if delta.isNull():
            return False

        for shape in shapes:
            shape.translate(offset=delta)
        self._prev_point = new_cursor
        return True

    def deselect_shape(self) -> bool:
        if not self.selected_shapes:
            return False
        self.selection_changed.emit([])
        self._hovered_shape_is_selected = False
        return True

    def delete_selected(self) -> list[Shape]:
        if not self.selected_shapes:
            return []
        removed = list(self.selected_shapes)
        self.shapes = [s for s in self.shapes if s not in self.selected_shapes]
        self.backup_shapes()
        self.selected_shapes.clear()
        self.update()
        return removed

    def delete_shape(self, shape: Shape) -> None:
        if shape in self.selected_shapes:
            self.selected_shapes.remove(shape)
        self.shapes = [s for s in self.shapes if s is not shape]
        self.backup_shapes()
        self.update()

    def paintEvent(self, a0: QtGui.QPaintEvent) -> None:
        if self.pixmap.isNull():
            super().paintEvent(a0)
            return
        self._render_canvas()
        if self._current is not None:
            self._current.clear_highlight()

    def _render_canvas(self) -> None:
        painter: QtGui.QPainter = self._painter
        painter.begin(self)
        try:
            self._setup_world_transform(painter=painter)
            for layer in self._render_layers():
                layer(painter)
        finally:
            painter.end()

    def _setup_world_transform(self, painter: QtGui.QPainter) -> None:
        for hint in (
            QtGui.QPainter.Antialiasing,
            QtGui.QPainter.HighQualityAntialiasing,
            QtGui.QPainter.SmoothPixmapTransform,
        ):
            painter.setRenderHint(hint)
        painter.translate(self._compute_image_origin_offset() * self.scale)
        Shape.scale = self.scale

    def _render_layers(self) -> tuple[Callable[[QtGui.QPainter], None], ...]:
        # Order is z-order, back-to-front.
        return (
            self._draw_pixmap_layer,
            self._draw_crosshair_layer,
            self._draw_committed_shapes_layer,
            self._draw_active_shape_layer,
            self._draw_drag_copy_layer,
            self._draw_preview_overlay_layer,
        )

    def _draw_pixmap_layer(self, painter: QtGui.QPainter) -> None:
        target = QtCore.QRectF(
            0.0,
            0.0,
            self.pixmap.width() * self.scale,
            self.pixmap.height() * self.scale,
        )
        painter.drawPixmap(target, self.pixmap, QtCore.QRectF(self.pixmap.rect()))

    def _draw_crosshair_layer(self, painter: QtGui.QPainter) -> None:
        cursor: QPointF | None = self._prev_move_point
        if not self._should_draw_crosshair(cursor=cursor):
            return
        assert cursor is not None
        painter.setPen(QtGui.QColor(0, 0, 0))
        cx = int(cursor.x() * self.scale)
        cy = int(cursor.y() * self.scale)
        max_x = int(self.pixmap.width() * self.scale) - 1
        max_y = int(self.pixmap.height() * self.scale) - 1
        painter.drawLine(0, cy, max_x, cy)
        painter.drawLine(cx, 0, cx, max_y)

    def _should_draw_crosshair(self, cursor: QPointF | None) -> bool:
        if self.mode != _CanvasMode.CREATE:
            return False
        if not self._crosshair[self._create_mode]:
            return False
        if cursor is None:
            return False
        return not self.is_out_of_pixmap(cursor)

    def _draw_committed_shapes_layer(self, painter: QtGui.QPainter) -> None:
        for shape in self.shapes:
            if not shape.visible:
                continue
            shape.fill = _is_shape_filled(shape=shape, hovered_shape=self.hovered_shape)
            _shape.paint(shape=shape, painter=painter)

    def _draw_active_shape_layer(self, painter: QtGui.QPainter) -> None:
        if self._current is None:
            return
        _shape.paint(shape=self._current, painter=painter)
        assert len(self._line.points) == len(self._line.point_labels)
        _shape.paint(shape=self._line, painter=painter)

    def _draw_drag_copy_layer(self, painter: QtGui.QPainter) -> None:
        for copy_shape in self._selected_shapes_copy:
            _shape.paint(shape=copy_shape, painter=painter)

    def _draw_preview_overlay_layer(self, painter: QtGui.QPainter) -> None:
        preview = self._build_preview_shape()
        if preview is None:
            return
        preview.fill = self._fill_drawing
        preview.selected = self._fill_drawing
        _shape.paint(shape=preview, painter=painter)

    def _build_preview_shape(self) -> Shape | None:
        if self._current is None:
            return None
        if self.create_mode == "polygon":
            return self._build_polygon_preview(current=self._current)
        if self.create_mode == "ai_points_to_shape":
            return self._build_ai_points_preview(current=self._current)
        return None

    def _build_polygon_preview(self, current: Shape) -> Shape:
        preview: Shape = current.copy()
        if not self._fill_drawing:
            return preview
        if len(preview.points) < 2:
            return preview
        assert preview.fill_color is not None
        if preview.fill_color.getRgb()[3] == 0:
            logger.warning(
                "fill_drawing=true, but fill_color is transparent,"
                " so forcing to be opaque."
            )
            preview.fill_color.setAlpha(64)
        preview.add_point(point=self._line.points[1], autoclose=True)
        return preview

    def _build_ai_points_preview(self, current: Shape) -> Shape:
        preview: Shape = current.copy()
        preview.add_point(
            point=self._line.points[1],
            label=self._line.point_labels[1],
        )
        ai_shapes = self._shapes_from_ai_points(
            points=preview.points,
            point_labels=preview.point_labels,
        )
        if ai_shapes:
            return ai_shapes[0]
        return preview

    def _transform_point_widget_to_image(self, point: QPointF) -> QPointF:
        origin = self._compute_image_origin_offset()
        image_x = point.x() / self.scale - origin.x()
        image_y = point.y() / self.scale - origin.y()
        return QPointF(image_x, image_y)

    def _compute_image_origin_offset(self) -> QPointF:
        area = super().size()
        scaled_w = self.pixmap.width() * self.scale
        scaled_h = self.pixmap.height() * self.scale
        slack_w = max(area.width() - scaled_w, 0.0)
        slack_h = max(area.height() - scaled_h, 0.0)
        return QPointF(slack_w, slack_h) / (2.0 * self.scale)

    def is_out_of_pixmap(self, p: QPointF) -> bool:
        x = p.x()
        y = p.y()
        if x < 0 or y < 0:
            return True
        if x > self.pixmap.width() or y > self.pixmap.height():
            return True
        return False

    def _finalize(self) -> None:
        assert self._current is not None
        if self.create_mode in _AI_CREATE_MODES:
            new_shapes = self._build_new_shapes_from_ai_inference()
            if not new_shapes:
                self.inference_produced_no_shapes.emit()
                self._cancel_current_shape()
                return
        else:
            self._current.close()
            if _is_degenerate_shape(self._current):
                self.degenerate_shape_rejected.emit()
                self._cancel_current_shape()
                return
            new_shapes = [self._current]
        self.shapes.extend(new_shapes)
        self.backup_shapes()
        self._reset_after_shape_creation()

    def _build_new_shapes_from_ai_inference(self) -> list[Shape]:
        assert self._current is not None
        if self.create_mode == "ai_points_to_shape":
            return self._shapes_from_ai_points(
                points=self._current.points,
                point_labels=self._current.point_labels,
            )
        if self.create_mode == "ai_box_to_shape":
            # point_labels: 2=box corner, 3=opposite box corner (SAM convention)
            return self._shapes_from_ai_points(
                points=_normalize_bbox_points(bbox_points=self._current.points),
                point_labels=[2, 3],
            )
        raise AssertionError(f"unreachable: {self.create_mode}")

    def _reset_after_shape_creation(self) -> None:
        self._current = None
        self.new_shape.emit()
        self.update()

    def _cancel_current_shape(self) -> None:
        self._current = None
        self.drawing_polygon.emit(False)
        self.update()

    def _is_close_enough(self, p1: QPointF, p2: QPointF) -> bool:
        # d = distance(p1 - p2)
        # m = (p1-p2).manhattanLength()
        # print "d %.2f, m %d, %.2f" % (d, m, d - m)
        threshold = self._epsilon / self.scale
        return labelme.utils.distance(p1 - p2) < threshold

    # Required by QScrollArea: it queries these to compute the
    # scrollable viewport whenever adjustSize() is called.
    def _compute_canvas_size(self) -> QtCore.QSize:
        if self.pixmap.isNull():
            return super().minimumSizeHint()
        scaled_w = int(self.pixmap.width() * self.scale)
        scaled_h = int(self.pixmap.height() * self.scale)
        viewport = self._scroll_viewport()
        if viewport is None:
            return QtCore.QSize(scaled_w, scaled_h)
        # Overscroll only along axes where the image actually overflows the
        # viewport. Half a viewport of slack (split evenly around the centered
        # image) lets each edge be panned a quarter-viewport past the viewport
        # boundary, derived from the viewport rather than a fixed multiplier.
        slack_w = viewport.width() // 2 if scaled_w > viewport.width() else 0
        slack_h = viewport.height() // 2 if scaled_h > viewport.height() else 0
        return QtCore.QSize(scaled_w + slack_w, scaled_h + slack_h)

    def sizeHint(self) -> QtCore.QSize:
        return self._compute_canvas_size()

    def minimumSizeHint(self) -> QtCore.QSize:
        return self._compute_canvas_size()

    def wheelEvent(self, a0: QtGui.QWheelEvent) -> None:
        mods: Qt.KeyboardModifiers = a0.modifiers()
        delta: QPoint = a0.angleDelta()
        if Qt.ControlModifier == int(mods):
            # with Ctrl/Command key
            # zoom
            self.zoom_request.emit(delta.y(), a0.posF())
        elif int(mods) == int(Qt.ShiftModifier) and delta.x() == 0:
            # Shift+wheel scrolls horizontally. macOS swaps the axis for us,
            # but Linux/Windows deliver the delta on y and expect the app to
            # remap it.
            self.scroll_request.emit(delta.y(), Qt.Horizontal)
        else:
            # scroll
            self.scroll_request.emit(delta.x(), Qt.Horizontal)
            self.scroll_request.emit(delta.y(), Qt.Vertical)
        a0.accept()

    def _move_by_keyboard(self, offset: QPointF) -> None:
        if not self.selected_shapes:
            return
        self._drag_shapes(shapes=self.selected_shapes, cursor=self._prev_point + offset)
        self.update()
        self._is_moving_shape = True

    def keyPressEvent(self, a0: QtGui.QKeyEvent) -> None:
        modifiers = a0.modifiers()
        key = a0.key()
        if self.mode == _CanvasMode.CREATE:
            if key == Qt.Key_Escape and self._current is not None:
                self._cancel_current_shape()
            elif key in (Qt.Key_Return, Qt.Key_Space) and self._can_close_shape():
                self._finalize()
            elif modifiers == Qt.AltModifier:
                self._snapping = False
        elif self.mode == _CanvasMode.EDIT:
            if key == Qt.Key_Up:
                self._move_by_keyboard(QPointF(0.0, -MOVE_SPEED))
            elif key == Qt.Key_Down:
                self._move_by_keyboard(QPointF(0.0, MOVE_SPEED))
            elif key == Qt.Key_Left:
                self._move_by_keyboard(QPointF(-MOVE_SPEED, 0.0))
            elif key == Qt.Key_Right:
                self._move_by_keyboard(QPointF(MOVE_SPEED, 0.0))
            elif a0.matches(QtGui.QKeySequence.SelectAll):
                self.select_shapes(shapes=self.shapes[:])
        self._update_status()

    def keyReleaseEvent(self, a0: QtGui.QKeyEvent) -> None:
        modifiers = a0.modifiers()
        if self.mode == _CanvasMode.CREATE:
            if int(modifiers) == 0:
                self._snapping = True
        elif self.mode == _CanvasMode.EDIT:
            if (
                self._is_moving_shape
                and self.selected_shapes
                and self.selected_shapes[0] in self.shapes
            ):
                index = self.shapes.index(self.selected_shapes[0])
                if self.shape_backups[-1][index].points != self.shapes[index].points:
                    self.backup_shapes()
                    self.shape_moved.emit()

                self._is_moving_shape = False

    def set_last_label(self, text: str, flags: dict[str, bool]) -> list[Shape]:
        if not text:
            raise ValueError("text must not be empty")
        shapes = []
        for shape in reversed(self.shapes):
            if shape.label is not None:
                break
            shapes.append(shape)
        shapes.reverse()
        for shape in shapes:
            shape.label = text
            shape.flags = flags
        self.shape_backups.pop()
        self.backup_shapes()
        return shapes

    def undo_last_line(self) -> None:
        assert self.shapes
        if self.create_mode in _AI_CREATE_MODES:
            # Remove all unlabeled shapes at the tail (added by AI in one shot)
            while self.shapes and self.shapes[-1].label is None:
                self.shapes.pop()
            self._cancel_current_shape()
            return
        self._current = self.shapes.pop()
        self._current.open()
        if self.create_mode in POLYLINE_SHAPE_TYPES:
            self._line.points = [self._current.points[-1], self._current.points[0]]
        elif self.create_mode in (
            "rectangle",
            "line",
            "circle",
            "ai_box_to_shape",
        ):
            self._current.points = self._current.points[0:1]
        elif self.create_mode == "point":
            self._current = None
        else:
            assert self.create_mode == "oriented_rectangle"
        self.drawing_polygon.emit(True)

    def undo_last_point(self) -> None:
        current = self._current
        if current is None or current.is_closed():
            return
        if self.create_mode == "oriented_rectangle" and len(current.points) == 4:
            self._unlock_oriented_rectangle_first_edge(current=current)
            self.update()
            return
        current.pop_point()
        if len(current.points) > 0:
            self._line.points[0] = current.points[-1]
            self.update()
        else:
            self._cancel_current_shape()

    def _reset_interaction_state(self) -> None:
        self._current = None
        self.hovered_shape = None
        self._hovered_vertex = None
        self._hovered_edge = None
        self._hovered_rotation = None

    def load_pixmap(self, pixmap: QtGui.QPixmap, clear_shapes: bool = True) -> None:
        pixmap_arr = labelme.utils.img_qt_to_arr(img_qt=pixmap.toImage())
        self.pixmap = pixmap
        self._pixmap_hash = hash(pixmap_arr.tobytes())
        if clear_shapes:
            self.shapes = []
        self.update()

    def load_shapes(self, shapes: list[Shape], replace: bool = True) -> None:
        self.shapes = list(shapes) if replace else self.shapes + list(shapes)
        self.backup_shapes()
        self._reset_interaction_state()
        self.update()

    def set_shape_visible(self, shape: Shape, value: bool) -> None:
        if shape.visible == value:
            return
        shape.visible = value
        self.update()

    def _apply_cursor(self, cursor: QtCore.Qt.CursorShape) -> None:
        if cursor == self._cursor:
            return
        # Push on first apply; swap the top of the stack we already own afterwards.
        if self._cursor == CURSOR_DEFAULT:
            QtWidgets.QApplication.setOverrideCursor(cursor)
        else:
            QtWidgets.QApplication.changeOverrideCursor(cursor)
        self._cursor = cursor

    def _release_cursor(self) -> None:
        if self._cursor == CURSOR_DEFAULT:
            return
        self._cursor = CURSOR_DEFAULT
        QtWidgets.QApplication.restoreOverrideCursor()

    def reset_state(self) -> None:
        self._release_cursor()
        self.pixmap = QtGui.QPixmap()
        self._pixmap_hash = None
        self.shapes = []
        self.shape_backups = collections.deque(maxlen=self._num_backups)
        self._is_moving_shape = False
        self.selected_shapes = []
        self._selected_shapes_copy = []
        self._current = None
        self.hovered_shape = None
        self._last_hovered_shape = None
        self._hovered_vertex = None
        self._last_hovered_vertex = None
        self._hovered_edge = None
        self._last_hovered_edge = None
        self._hovered_rotation = None
        self._last_hovered_rotation = None
        self.update()


def _detections_from_annotations(
    annotations: list[osam.types.Annotation],
) -> list[_automation.Detection]:
    if not annotations:
        logger.warning("No annotations returned")
        return []
    sorted_annotations = sorted(
        annotations,
        key=lambda a: a.score if a.score is not None else 0,
        reverse=True,
    )
    detections: list[_automation.Detection] = []
    for annotation in sorted_annotations:
        bbox: tuple[float, float, float, float] | None = None
        if annotation.bounding_box is not None:
            bb = annotation.bounding_box
            bbox = (bb.xmin, bb.ymin, bb.xmax, bb.ymax)
        detections.append(_automation.Detection(bbox=bbox, mask=annotation.mask))
    return detections


def _rebuild_line(line: Shape, *, shape_type: ShapeType) -> Shape:
    new_line = Shape(shape_type=shape_type)
    new_line.points = list(line.points)
    new_line.point_labels = list(line.point_labels)
    return new_line


def _is_degenerate_shape(shape: Shape) -> bool:
    points = shape.points
    shape_type = shape.shape_type
    if shape_type == "polygon":
        return len({(p.x(), p.y()) for p in points}) < 3
    if shape_type == "linestrip":
        return len({(p.x(), p.y()) for p in points}) < 2
    if shape_type == "rectangle":
        return (
            len(points) != 2
            or points[0].x() == points[1].x()
            or points[0].y() == points[1].y()
        )
    if shape_type in ("circle", "line"):
        return len(points) != 2 or points[0] == points[1]
    if shape_type == "oriented_rectangle":
        return len(points) != 4 or points[0] == points[1] or points[1] == points[2]
    return False


def _normalize_bbox_points(bbox_points: list[QPointF]) -> list[QPointF]:
    if len(bbox_points) != 2:
        raise ValueError(f"Expected 2 points for bbox, got {len(bbox_points)}")

    p1, p2 = bbox_points
    xmin = min(p1.x(), p2.x())
    ymin = min(p1.y(), p2.y())
    xmax = max(p1.x(), p2.x())
    ymax = max(p1.y(), p2.y())
    return [QPointF(xmin, ymin), QPointF(xmax, ymax)]


def _snap_cursor_pos_for_square(pos: QPointF, opposite_vertex: QPointF) -> QPointF:
    pos_from_opposite: QPointF = pos - opposite_vertex
    square_size: float = min(abs(pos_from_opposite.x()), abs(pos_from_opposite.y()))
    return opposite_vertex + QPointF(
        np.sign(pos_from_opposite.x()) * square_size,
        np.sign(pos_from_opposite.y()) * square_size,
    )


def _compute_intersection_edges_image(
    p1: QPointF, p2: QPointF, image_size: QtCore.QSize
) -> QPointF:
    width = image_size.width()
    height = image_size.height()

    start_x = np.clip(p1.x(), 0.0, width)
    start_y = np.clip(p1.y(), 0.0, height)
    delta_x = p2.x() - start_x
    delta_y = p2.y() - start_y

    # Liang-Barsky line clipping.
    boundary_pairs = (
        (start_x, -delta_x),
        (width - start_x, delta_x),
        (start_y, -delta_y),
        (height - start_y, delta_y),
    )
    t_exit = 1.0
    for numerator, denominator in boundary_pairs:
        if denominator > 0.0:
            t_exit = min(t_exit, numerator / denominator)

    if t_exit > 0.0:
        return QPointF(start_x + t_exit * delta_x, start_y + t_exit * delta_y)

    # t_exit == 0: start is on a boundary, p2 is exterior — slide along the edge.
    if start_x <= 0.0 or start_x >= width:
        return QPointF(start_x, np.clip(p2.y(), 0.0, height))
    return QPointF(np.clip(p2.x(), 0.0, width), start_y)


def _should_reselect_on_right_press(
    selected_shapes: list[Shape], hovered_shape: Shape | None
) -> bool:
    if not selected_shapes:
        return True
    if hovered_shape is None:
        return False
    return hovered_shape not in selected_shapes


def _pick_pending_moved_shape(
    is_moving_shape: bool, hovered_shape: Shape | None, shapes: list[Shape]
) -> Shape | None:
    if not is_moving_shape:
        return None
    if hovered_shape is None:
        return None
    if hovered_shape not in shapes:
        return None
    return hovered_shape


def _is_shape_filled(shape: Shape, hovered_shape: Shape | None) -> bool:
    return shape.selected or shape is hovered_shape


def _opposite_corner_in_parallelogram(
    *, opposite_to: QPointF, neighbor1: QPointF, neighbor2: QPointF
) -> QPointF:
    return neighbor1 + neighbor2 - opposite_to


def _project_oriented_rectangle_corners(
    *, anchor: QPointF, edge_axis: QPointF, moving: QPointF
) -> tuple[QPointF, QPointF]:
    perp = labelme.utils.project_point_on_perpendicular_line(
        point=moving, line_start=edge_axis, line_end=anchor
    )
    para = _opposite_corner_in_parallelogram(
        opposite_to=perp, neighbor1=anchor, neighbor2=moving
    )
    return perp, para
