From 2999367415be050725fc030118f283ee225cf42d Mon Sep 17 00:00:00 2001 From: XcantloadX <3188996979@qq.com> Date: Mon, 12 May 2025 09:12:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E6=96=B0=E5=A2=9E=E4=B8=80?= =?UTF-8?q?=E4=BA=9B=E5=9F=BA=E7=A1=80=E6=95=B0=E6=8D=AE=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包括向量、点、矩形、图像等 --- kotonebot/primitives/__init__.py | 17 ++ kotonebot/primitives/geometry.py | 290 +++++++++++++++++++++++++++++++ kotonebot/primitives/visual.py | 63 +++++++ 3 files changed, 370 insertions(+) create mode 100644 kotonebot/primitives/geometry.py create mode 100644 kotonebot/primitives/visual.py diff --git a/kotonebot/primitives/__init__.py b/kotonebot/primitives/__init__.py index e69de29..3e5df90 100644 --- a/kotonebot/primitives/__init__.py +++ b/kotonebot/primitives/__init__.py @@ -0,0 +1,17 @@ +from .visual import Image, Template +from .geometry import Point, Rect, Size, Vector2D, Vector3D, is_point, is_rect +from .geometry import RectTuple, PointTuple + +__all__ = [ + 'Image', + 'Template', + 'Point', + 'Rect', + 'Size', + 'Vector2D', + 'Vector3D', + 'is_point', + 'is_rect', + 'RectTuple', + 'PointTuple' +] diff --git a/kotonebot/primitives/geometry.py b/kotonebot/primitives/geometry.py new file mode 100644 index 0000000..9846d96 --- /dev/null +++ b/kotonebot/primitives/geometry.py @@ -0,0 +1,290 @@ +from typing import Generic, TypeVar, TypeGuard, overload + +T = TypeVar('T') + +class Vector2D(Generic[T]): + """2D 坐标类""" + def __init__(self, x: T, y: T, *, name: str | None = None): + self.x = x + self.y = y + self.name: str | None = name + """坐标的名称。""" + + def __getitem__(self, item: int): + if item == 0: + return self.x + elif item == 1: + return self.y + else: + raise IndexError + + def __repr__(self) -> str: + return f'Point<"{self.name}" at ({self.x}, {self.y})>' + + def __str__(self) -> str: + return f'({self.x}, {self.y})' + + +class Vector3D(Generic[T]): + """三元组类。""" + def __init__(self, x: T, y: T, z: T, *, name: str | None = None): + self.x = x + self.y = y + self.z = z + self.name: str | None = name + """坐标的名称。""" + + def __getitem__(self, item: int): + if item == 0: + return self.x + elif item == 1: + return self.y + elif item == 2: + return self.z + else: + raise IndexError + + @property + def xyz(self) -> tuple[T, T, T]: + """ + 三元组 (x, y, z)。OpenCV 格式的坐标。 + """ + return self.x, self.y, self.z + + @property + def xy(self) -> tuple[T, T]: + """ + 二元组 (x, y)。OpenCV 格式的坐标。 + """ + return self.x, self.y + +class Vector4D(Generic[T]): + """四元组类。""" + def __init__(self, x: T, y: T, z: T, w: T, *, name: str | None = None): + self.x = x + self.y = y + self.z = z + self.w = w + self.name: str | None = name + """坐标的名称。""" + + def __getitem__(self, item: int): + if item == 0: + return self.x + elif item == 1: + return self.y + elif item == 2: + return self.z + elif item == 3: + return self.w + else: + raise IndexError + +Size = Vector2D[int] +"""尺寸。相当于 Vector2D[int]""" +RectTuple = tuple[int, int, int, int] +"""矩形。(x, y, w, h)""" +PointTuple = tuple[int, int] +"""点。(x, y)""" + +class Point(Vector2D[int]): + """点。""" + + @property + def xy(self) -> PointTuple: + """ + 二元组 (x, y)。OpenCV 格式的坐标。 + """ + return self.x, self.y + + def offset(self, dx: int, dy: int) -> 'Point': + """ + 偏移坐标。 + + :param dx: 偏移量。 + :param dy: 偏移量。 + :return: 偏移后的坐标。 + """ + return Point(self.x + dx, self.y + dy, name=self.name) + + def __add__(self, other: 'Point | PointTuple') -> 'Point': + """ + 相加。 + + :param other: 另一个 Point 对象或二元组 (x: int, y: int)。 + :return: 相加后的点。 + """ + if isinstance(other, Point): + return Point(self.x + other.x, self.y + other.y, name=self.name) + else: + return Point(self.x + other[0], self.y + other[1], name=self.name) + + def __sub__(self, other: 'Point | PointTuple') -> 'Point': + """ + 相减。 + + :param other: 另一个 Point 对象或二元组 (x: int, y: int)。 + :return: 相减后的点。 + """ + if isinstance(other, Point): + return Point(self.x - other.x, self.y - other.y, name=self.name) + else: + return Point(self.x - other[0], self.y - other[1], name=self.name) + +class Rect: + """ + 矩形类。 + """ + def __init__( + self, + x: int | None = None, + y: int | None = None, + w: int | None = None, + h: int | None = None, + *, + xywh: RectTuple | None = None, + name: str | None = None, + ): + """ + 从给定的坐标信息创建矩形。 + + 参数 `x`, `y`, `w`, `h` 和 `xywh` 必须至少指定一组。 + + :param x: 矩形左上角的 X 坐标。 + :param y: 矩形左上角的 Y 坐标。 + :param w: 矩形的宽度。 + :param h: 矩形的高度。 + :param xywh: 四元组 (x, y, w, h)。 + :param name: 矩形的名称。 + :raises ValueError: 提供的坐标参数不完整时抛出。 + """ + if xywh is not None: + x, y, w, h = xywh + elif ( + x is not None and + y is not None and + w is not None and + h is not None + ): + pass + else: + raise ValueError('Either xywh or x, y, w, h must be provided.') + + self.x1 = x + """矩形左上角的 X 坐标。""" + self.y1 = y + """矩形左上角的 Y 坐标。""" + self.w = w + """矩形的宽度。""" + self.h = h + """矩形的高度。""" + self.name: str | None = name + """矩形的名称。""" + + @classmethod + def from_xyxy(cls, x1: int, y1: int, x2: int, y2: int) -> 'Rect': + """ + 从 (x1, y1, x2, y2) 创建矩形。 + :return: 创建结果。 + """ + return cls(x1, y1, x2 - x1, y2 - y1) + + @property + def x2(self) -> int: + """矩形右下角的 X 坐标。""" + return self.x1 + self.w + + @x2.setter + def x2(self, value: int): + self.w = value - self.x1 + + @property + def y2(self) -> int: + """矩形右下角的 Y 坐标。""" + return self.y1 + self.h + + @y2.setter + def y2(self, value: int): + self.h = value - self.y1 + + @property + def xywh(self) -> RectTuple: + """ + 四元组 (x1, y1, w, h)。OpenCV 格式的坐标。 + """ + return self.x1, self.y1, self.w, self.h + + @property + def xyxy(self) -> RectTuple: + """ + 四元组 (x1, y1, x2, y2)。 + """ + return self.x1, self.y1, self.x2, self.y2 + + @property + def top_left(self) -> Point: + """ + 矩形的左上角点。 + """ + if self.name: + name = "Left-top of rect "+ self.name + else: + name = None + return Point(self.x1, self.y1, name=name) + + @property + def bottom_right(self) -> Point: + """ + 矩形的右下角点。 + """ + if self.name: + name = "Right-bottom of rect "+ self.name + else: + name = None + return Point(self.x2, self.y2, name=name) + + @property + def left_bottom(self) -> Point: + """ + 矩形的左下角点。 + """ + if self.name: + name = "Left-bottom of rect "+ self.name + else: + name = None + return Point(self.x1, self.y2, name=name) + + @property + def right_top(self) -> Point: + """ + 矩形的右上角点。 + """ + if self.name: + name = "Right-top of rect "+ self.name + else: + name = None + return Point(self.x2, self.y1, name=name) + + @property + def center(self) -> Point: + """ + 矩形的中心点。 + """ + if self.name: + name = "Center of rect "+ self.name + else: + name = None + return Point(self.x1 + self.w // 2, self.y1 + self.h // 2, name=name) + + def __repr__(self) -> str: + return f'Rect<"{self.name}" at (x={self.x1}, y={self.y1}, w={self.w}, h={self.h})>' + + def __str__(self) -> str: + return f'(x={self.x1}, y={self.y1}, w={self.w}, h={self.h})' + + +def is_point(obj: object) -> TypeGuard[Point]: + return isinstance(obj, Point) + +def is_rect(obj: object) -> TypeGuard[Rect]: + return isinstance(obj, Rect) diff --git a/kotonebot/primitives/visual.py b/kotonebot/primitives/visual.py new file mode 100644 index 0000000..096a5a8 --- /dev/null +++ b/kotonebot/primitives/visual.py @@ -0,0 +1,63 @@ +import logging + +from cv2.typing import MatLike + +from .geometry import Size +from kotonebot.util import cv2_imread + +logger = logging.getLogger(__name__) + +class Image: + """ + 图像类。 + """ + def __init__( + self, + pixels: MatLike | None = None, + file_path: str | None = None, + lazy_load: bool = False, + name: str | None = None, + description: str | None = None + ): + """ + 从内存数据或图像文件创建图像类。 + + :param pixels: 图像数据。格式必须为 BGR。 + :param file_path: 图像文件路径。 + :param lazy_load: 是否延迟加载图像数据。 + 若为 False,立即载入,否则仅当访问图像数据时才载入。仅当从文件创建图像类时生效。 + :param name: 图像名称。 + :param description: 图像描述。 + """ + self.name: str | None = name + """图像名称。""" + self.description: str | None = description + """图像描述。""" + self.file_path: str | None = file_path + """图像的文件路径。""" + self.__pixels: MatLike | None = None + # 立即加载 + if not lazy_load and self.file_path: + _ = self.pixels + # 传入像素数据而不是文件 + if pixels is not None: + self.__pixels = pixels + + @property + def pixels(self) -> MatLike: + """图像的像素数据。""" + if self.__pixels is None: + if not self.file_path: + raise ValueError('Either pixels or file_path must be provided.') + logger.debug('Loading image "%s" from %s...', self.name or '(unnamed)', self.file_path) + self.__pixels = cv2_imread(self.file_path) + return self.__pixels + + @property + def size(self) -> Size: + return Size(self.pixels.shape[1], self.pixels.shape[0]) + +class Template(Image): + """ + 模板图像类。 + """