cvgeomkit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.3
2
+ Name: cvgeomkit
3
+ Version: 0.1.0
4
+ Summary: Geometry-based image analysis toolkit
5
+ Author: polymorvic
6
+ Author-email: polymorvic <polymorphic.bite@gmail.com>
7
+ Requires-Dist: matplotlib>=3.10.9
8
+ Requires-Dist: numpy>=2.4.4
9
+ Requires-Dist: opencv-python>=4.13.0.92
10
+ Requires-Dist: pillow>=12.2.0
11
+ Requires-Dist: shapely>=2.1.2
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+
File without changes
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "cvgeomkit"
3
+ version = "0.1.0"
4
+ description = "Geometry-based image analysis toolkit"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "polymorvic", email = "polymorphic.bite@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "matplotlib>=3.10.9",
12
+ "numpy>=2.4.4",
13
+ "opencv-python>=4.13.0.92",
14
+ "pillow>=12.2.0",
15
+ "shapely>=2.1.2",
16
+ ]
17
+
18
+ [project.scripts]
19
+ cvgeomkit = "cvgeomkit:main"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.8.2,<0.9.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "ipykernel>=7.2.0",
28
+ "jupyterlab>=4.5.7",
29
+ "ruff>=0.15.12",
30
+ ]
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from cvgeomkit!")
@@ -0,0 +1,122 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Hashable as SupportsHash
3
+ from enum import StrEnum
4
+ from typing import Self
5
+
6
+ import cv2
7
+ import numpy as np
8
+ from PIL import Image
9
+
10
+
11
+ type ArrayLike = np.ndarray | NumpyImage
12
+
13
+
14
+ type Numeric = float | int
15
+
16
+
17
+ class BBoxFmt(StrEnum):
18
+ XYWH = "xywh"
19
+ XYXY = "xyxy"
20
+ CXCYWH = "cxcxywh"
21
+
22
+
23
+ class ColorSpace(StrEnum):
24
+ GRAY = "gray"
25
+ BGR = "bgr"
26
+ RGB = "rgb"
27
+ HSV = "hsv"
28
+
29
+
30
+ class NumpyImage(np.ndarray):
31
+ """
32
+ A lightweight wrapper around `numpy.ndarray` for easier image shape handling.
33
+
34
+ Provides convenient properties to access image dimensions:
35
+ - `height`: number of rows
36
+ - `width`: number of columns
37
+ - `depth`: number of channels (defaults to 1 if not present)
38
+
39
+ Fully compatible with OpenCV and other libraries that expect a standard
40
+ NumPy array, since it is implemented as a view of the original array.
41
+ """
42
+ def __new__(cls, input_array):
43
+ """Build a `NumpyImage` view over `input_array` (no copy if already ndarray)."""
44
+ obj = np.asarray(input_array).view(cls)
45
+ return obj
46
+
47
+ def to_colorspace(self, dst_space: ColorSpace, src_space: ColorSpace) -> Self:
48
+ """Convert pixels from `src_space` to `dst_space` via OpenCV; same space returns a copy."""
49
+
50
+ if src_space == dst_space:
51
+ return self.copy().view(NumpyImage)
52
+
53
+ conversions = {
54
+ (ColorSpace.BGR, ColorSpace.RGB): cv2.COLOR_BGR2RGB,
55
+ (ColorSpace.RGB, ColorSpace.BGR): cv2.COLOR_RGB2BGR,
56
+
57
+ (ColorSpace.BGR, ColorSpace.GRAY): cv2.COLOR_BGR2GRAY,
58
+ (ColorSpace.GRAY, ColorSpace.BGR): cv2.COLOR_GRAY2BGR,
59
+
60
+ (ColorSpace.RGB, ColorSpace.GRAY): cv2.COLOR_RGB2GRAY,
61
+ (ColorSpace.GRAY, ColorSpace.RGB): cv2.COLOR_GRAY2RGB,
62
+
63
+ (ColorSpace.BGR, ColorSpace.HSV): cv2.COLOR_BGR2HSV,
64
+ (ColorSpace.HSV, ColorSpace.BGR): cv2.COLOR_HSV2BGR,
65
+
66
+ (ColorSpace.RGB, ColorSpace.HSV): cv2.COLOR_RGB2HSV,
67
+ (ColorSpace.HSV, ColorSpace.RGB): cv2.COLOR_HSV2RGB,
68
+ }
69
+
70
+ code = conversions.get((src_space, dst_space))
71
+ if code is None:
72
+ raise ValueError(
73
+ f"Unsupported conversion: {src_space} -> {dst_space}"
74
+ )
75
+
76
+ return cv2.cvtColor(self, code).view(NumpyImage)
77
+
78
+ @property
79
+ def width(self):
80
+ """Column count; 1 for a 1-D array."""
81
+ return self.shape[1] if len(self.shape) > 1 else 1
82
+
83
+ @property
84
+ def height(self):
85
+ """Row count."""
86
+ return self.shape[0]
87
+
88
+ @property
89
+ def depth(self):
90
+ """Channel count; 1 when there is no channel axis."""
91
+ return self.shape[2] if len(self.shape) > 2 else 1
92
+
93
+ def as_array(self):
94
+ """Convert back to regular numpy array for compatibility"""
95
+ return np.asarray(self)
96
+
97
+ @property
98
+ def as_pil(self) -> Image.Image:
99
+ """Pillow image backed by this array's pixel data."""
100
+ return Image.fromarray(self.as_array())
101
+
102
+
103
+ class Hashable(ABC):
104
+ """
105
+ Value-based equality and hashing via `_key_()`.
106
+
107
+ Subclasses return a hashable tuple (or other immutable key); `__eq__` and
108
+ `__hash__` delegate to that key so instances with the same key compare equal.
109
+ """
110
+
111
+ @abstractmethod
112
+ def _key_(self) -> SupportsHash:
113
+ """Return the hashable identity used for `__hash__` and `__eq__`."""
114
+ raise NotImplementedError
115
+
116
+ def __hash__(self) -> int:
117
+ return hash(self._key_())
118
+
119
+ def __eq__(self, other: object) -> bool:
120
+ if not isinstance(other, self.__class__):
121
+ return False
122
+ return self._key_() == other._key_()
File without changes
@@ -0,0 +1,173 @@
1
+ from typing import TYPE_CHECKING, Self
2
+
3
+ import numpy as np
4
+
5
+ from cvgeomkit.common import Hashable, ArrayLike
6
+ from .points import transform_point
7
+
8
+ if TYPE_CHECKING:
9
+ from .lines import Line, transform_line
10
+ from .points import Point
11
+
12
+
13
+ class Intersection(Hashable):
14
+ """
15
+ Represents the intersection point of two lines and the angle between them.
16
+ """
17
+
18
+ def __init__(self, line1: "Line", line2: "Line", intersection_point: "Point") -> None:
19
+ """
20
+ Initialize the Intersection object.
21
+
22
+ Args:
23
+ line1 (Line): The first line.
24
+ line2 (Line): The second line.
25
+ intersection_point (tuple[int, int]): The (x, y) coordinates of the intersection point.
26
+ """
27
+ self.line1 = line1
28
+ self.line2 = line2
29
+ self.point = intersection_point
30
+ self.angle = self._compute_angle(self.line1, self.line2)
31
+
32
+ def __repr__(self) -> str:
33
+ """
34
+ Returns:
35
+ str: Returns a string representation of the intersection point and both lines.
36
+ Lines are shown in order of slope (lower slope first).
37
+ """
38
+
39
+ def format_line(line: "Line") -> str:
40
+ """Helper function to format a line equation."""
41
+ if line.xv is not None:
42
+ return f"x = {line.xv:.2f}"
43
+ else:
44
+ return f"y = {line.slope:.2f} * x + {line.intercept:.2f}"
45
+
46
+ lines = [self.line1, self.line2]
47
+ lines.sort(key=lambda line: line.slope if line.slope is not None else np.inf)
48
+
49
+ line1_eq = format_line(lines[0])
50
+ line2_eq = format_line(lines[1])
51
+
52
+ return f"Point {self.point} line1: [{line1_eq}] line2: [{line2_eq}]"
53
+
54
+ def _key_(self) -> tuple["Point", tuple[float, float]]:
55
+ """
56
+ Returns a tuple of identifying attributes used for hashing and equality comparison.
57
+ Links the intersection point with both lines for unique identification.
58
+
59
+ Returns:
60
+ tuple: A tuple containing the point coordinates and the keys of both lines,
61
+ sorted by slope (lower slope first, vertical lines last) to ensure consistent ordering.
62
+ """
63
+
64
+ def sort_key(line: "Line") -> tuple[float, float]:
65
+ primary = line.slope if line.slope is not None else np.inf
66
+ secondary = line.xv if line.xv is not None else -np.inf
67
+ return (primary, secondary)
68
+
69
+ lines = [self.line1, self.line2]
70
+ lines.sort(key=sort_key)
71
+
72
+ line_keys = [line._key_() for line in lines]
73
+ return (self.point, tuple(line_keys))
74
+
75
+
76
+ def distance(self, another_intersection: Self) -> float:
77
+ """
78
+ Calculate the Euclidean distance to another intersection point.
79
+
80
+ Args:
81
+ another_intersection (Intersection): Another intersection to compute distance to.
82
+
83
+ Returns:
84
+ float: The Euclidean distance.
85
+ """
86
+ return self.point.distance(another_intersection.point)
87
+
88
+
89
+ def other_line(self, used: "Line") -> "Line":
90
+ """
91
+ Return the line from this intersection that is NOT `used`.
92
+ Raises ValueError if `used` doesn't belong to this intersection.
93
+ """
94
+ if self.line1 is used or self.line1._key_() == used._key_():
95
+ return self.line2
96
+ if self.line2 is used or self.line2._key_() == used._key_():
97
+ return self.line1
98
+ raise ValueError("The provided line does not belong to this intersection.")
99
+
100
+
101
+ def _compute_angle(self, line1: "Line", line2: "Line") -> float:
102
+ """Compute the angle in degrees between two Line objects.
103
+
104
+ Args:
105
+ line1 (Line): First line.
106
+ line2 (Line): Second line.
107
+
108
+ Returns:
109
+ float: Angle in degrees between the two lines.
110
+ """
111
+ if line1.xv is None and line2.xv is not None:
112
+ angle = 90 - line1.theta
113
+ elif line1.xv is not None and line2.xv is None:
114
+ angle = 90 - line2.theta
115
+ elif line1.slope * line2.slope == -1:
116
+ angle = 90
117
+ else:
118
+ angle = np.rad2deg(np.arctan((line2.slope - line1.slope) / (1 + line1.slope * line2.slope)))
119
+
120
+ return angle + 180
121
+
122
+
123
+ def compute_intersections(lines: list['Line'], image: ArrayLike) -> list[Intersection]:
124
+ """
125
+ Compute all intersection points between pairs of lines within image boundaries.
126
+
127
+ This function finds all valid intersection points between every pair of lines
128
+ in the provided list that lie within the image boundaries. Each pair of lines
129
+ is checked only once to avoid duplicates.
130
+
131
+ Args:
132
+ lines: List of Line objects to find intersections between
133
+ image: Image array used to determine valid intersection boundaries
134
+
135
+ Returns:
136
+ List of Intersection objects representing valid intersection points
137
+
138
+ Note:
139
+ Only intersections within the image boundaries are included. Each pair
140
+ of lines is processed only once (no duplicates).
141
+ """
142
+ intersections = []
143
+ for i in range(len(lines)):
144
+ for j in range(i + 1, len(lines)):
145
+ intersection = lines[i].intersection(lines[j], image)
146
+ if intersection is not None:
147
+ intersections.append(intersection)
148
+ return intersections
149
+
150
+
151
+ def transform_intersection(
152
+ intersection: Intersection,
153
+ source_img: np.ndarray,
154
+ original_x_start: int,
155
+ original_y_start: int,
156
+ to_global: bool = True,
157
+ ) -> Intersection:
158
+ """
159
+ Transforms an Intersection in one go.
160
+ - If to_global=True: treats inputs as LOCAL and returns GLOBAL.
161
+ - If to_global=False: treats inputs as GLOBAL and returns LOCAL.
162
+
163
+ Note:
164
+ `source_img` should be the image in the *source* space,
165
+ i.e. the space you are transforming FROM. This keeps `limit_to_img`
166
+ correct in both directions.
167
+ """
168
+ from .lines import transform_line
169
+
170
+ transformed_point = transform_point(intersection.point, original_x_start, original_y_start, to_global=to_global)
171
+ line1_t = transform_line(intersection.line1, source_img, original_x_start, original_y_start, to_global)
172
+ line2_t = transform_line(intersection.line2, source_img, original_x_start, original_y_start, to_global)
173
+ return Intersection(line1_t, line2_t, transformed_point)
@@ -0,0 +1,422 @@
1
+ import copy
2
+ from typing import Iterable, Literal, Self, TYPE_CHECKING, Union
3
+
4
+ import numpy as np
5
+
6
+ from cvgeomkit.common import Hashable, Numeric
7
+ from .points import Point, transform_point
8
+
9
+ if TYPE_CHECKING:
10
+ from .intersections import Intersection
11
+
12
+
13
+ class Line(Hashable):
14
+ """
15
+ Represents a 2D line in either slope-intercept form (y = ax + b) or vertical line form (xv = constant).
16
+ Distinguishes the existance of vertical lines where there is no slope and intercept but constant x-value instead.
17
+
18
+ Attributes:
19
+ slope (float | None): The slope (a) of the line. None if the line is vertical.
20
+ intercept (float | None): The y-intercept (b) of the line. None if the line is vertical.
21
+ xv (float | None): The constant x-value for vertical lines. None if the line is not vertical.
22
+
23
+ Note:
24
+ This class overloads `__eq__` and `__hash__` methods based on a unique key composed of the slope,
25
+ intercept, and xv attributes. This allows Line objects to be added to hash-based collections like sets
26
+ or used as dictionary keys. The equality comparison between Line instances is performed based on
27
+ the attributes defined in the internal __key method.
28
+ """
29
+
30
+ def __init__(self, slope: float | None = None, intercept: float | None = None, xv: float | None = None) -> None:
31
+ """
32
+ Initializes a Line instance.
33
+
34
+ Args:
35
+ slope (float | None, optional): The slope of the line. Defaults to None.
36
+ intercept (float | None, optional): The intercept of the line. Defaults to None.
37
+ xv (float | None, optional): The constant x-value for vertical lines. Defaults to None.
38
+ """
39
+ self.slope = slope
40
+ self.intercept = intercept
41
+ self.xv = xv
42
+
43
+ def _key_(self) -> tuple[Numeric, Numeric, Numeric | None]:
44
+ """
45
+ Returns a tuple of identifying attributes used for hashing and equality comparison.
46
+
47
+ Returns:
48
+ tuple: A tuple containing slope, intercept, and xv.
49
+ """
50
+ return (self.slope, self.intercept, self.xv)
51
+
52
+ def __repr__(self) -> str:
53
+ """
54
+ Returns a string representation of the line.
55
+
56
+ Returns:
57
+ str: String representation of the line.
58
+ """
59
+ return f"y = {self.slope} * x + {self.intercept}"
60
+
61
+ def copy(self) -> Self:
62
+ """
63
+ Creates a deep copy of the line.
64
+
65
+ Returns:
66
+ Line: A new Line instance with the same attributes.
67
+ """
68
+ return copy.deepcopy(self)
69
+
70
+ def intersection(self, another_line: Self, image: np.ndarray) -> Union['Intersection', None]:
71
+ """
72
+ Compute the intersection point between this line and another line,
73
+ and return it as an `Intersection` object if it lies within image bounds.
74
+
75
+ Args:
76
+ another_line (Line): The other line to intersect with.
77
+ image (np.ndarray): The image used to check if the intersection point lies within its bounds.
78
+
79
+ Returns:
80
+ Intersection | None: The intersection object if the lines intersect within the image bounds,
81
+ otherwise None.
82
+ """
83
+ from .intersections import Intersection
84
+
85
+ if (self.slope is not None and another_line.slope is not None and self.slope == another_line.slope) or (
86
+ self.xv is not None and another_line.xv is not None
87
+ ):
88
+ return None
89
+
90
+ elif self.xv is not None and another_line.xv is None:
91
+ x = self.xv
92
+ y = another_line.slope * x + another_line.intercept
93
+
94
+ elif self.xv is None and another_line.xv is not None:
95
+ x = another_line.xv
96
+ y = self.slope * x + self.intercept
97
+
98
+ else:
99
+ x = (another_line.intercept - self.intercept) / (self.slope - another_line.slope)
100
+ y = self.slope * x + self.intercept
101
+
102
+ height, width = image.shape[:2]
103
+ if 0 <= x < width and 0 <= y < height:
104
+ return Intersection(self, another_line, Point(int(x), int(y)))
105
+ else:
106
+ return None
107
+
108
+ def y_for_x(self, x: int) -> int | None:
109
+ """
110
+ Calculates the y-coordinate on the line for a given x-coordinate.
111
+ It handles when line instance is vertical, then return None because y doesnt exist.
112
+
113
+ Args:
114
+ x (int): The x-coordinate.
115
+
116
+ Returns:
117
+ int | None: The corresponding y-coordinate if the line is not vertical, otherwise None.
118
+
119
+ Note:
120
+ The return value must be an integer because we are working with images,
121
+ and pixel coordinates must be whole numbers.
122
+ """
123
+ if self.slope is None or self.intercept is None:
124
+ return None
125
+ return int(self.slope * x + self.intercept)
126
+
127
+ def x_for_y(self, y: int) -> int | None:
128
+ """
129
+ Calculates the x-coordinate on the line for a given y-coordinate.
130
+ It handles when line instance is vertical or horizontal.
131
+ If Vertical line: x is constant, in case of horizontal line or undefined slope: no unique x for given y
132
+
133
+ Args:
134
+ y (int): The y-coordinate.
135
+
136
+ Returns:
137
+ int | None: The corresponding x-coordinate as an integer if the line is not horizontal;
138
+ or the stored x-value if the line is vertical; otherwise, None.
139
+
140
+ Note:
141
+ The return value must be an integer because we are working with images,
142
+ and pixel coordinates must be whole numbers.
143
+ """
144
+ if self.xv is not None:
145
+ return int(self.xv)
146
+ if self.slope == 0 or self.slope is None:
147
+ return None
148
+ return int((y - self.intercept) / self.slope)
149
+
150
+ def get_points_by_distance(self, main_point: Point, distance: float) -> tuple[Point, Point]:
151
+ """
152
+ Finds two points on the line that are at a given Euclidean distance from a specified point.
153
+
154
+ Args:
155
+ main_point (tuple[int, int]): The reference (x, y) point from which distance is measured.
156
+ distance (float): The Euclidean distance to measure along the line.
157
+
158
+ Returns:
159
+ tuple[tuple[int, int], tuple[int, int]]: Two (x, y) integer coordinate points on the line.
160
+
161
+ Note:
162
+ Pixel coordinates are returned as integers.
163
+ For vertical lines, points are offset along the y-axis.
164
+ """
165
+ main_x, main_y = main_point
166
+
167
+ if self.xv is not None:
168
+ return Point(int(main_x), int(main_y - distance)), Point(int(main_x), int(main_y + distance))
169
+
170
+ if self.slope is None or self.intercept is None:
171
+ raise ValueError("Cannot compute points: line is not properly defined.")
172
+
173
+ m = self.slope
174
+ b = self.intercept
175
+
176
+ A = 1 + m**2
177
+ B = -2 * main_x + 2 * m * (b - main_y)
178
+ C = main_x**2 + (b - main_y) ** 2 - distance**2
179
+
180
+ discriminant = B**2 - 4 * A * C
181
+ if discriminant < 0:
182
+ raise ValueError("No real solution: check if the distance is too large or the point is far from the line.")
183
+
184
+ sqrt_delta = np.sqrt(discriminant)
185
+
186
+ x1 = int((-B + sqrt_delta) / (2 * A))
187
+ x2 = int((-B - sqrt_delta) / (2 * A))
188
+
189
+ y1 = int(self.y_for_x(x1))
190
+ y2 = int(self.y_for_x(x2))
191
+
192
+ return Point(x1, y1), Point(x2, y2)
193
+
194
+ def limit_to_img(self, img: np.ndarray) -> tuple[Point, Point]:
195
+ """
196
+ Returns two endpoints of the line segment clipped to the image boundaries.
197
+
198
+ Args:
199
+ img (np.ndarray): The image array used to determine dimensions.
200
+
201
+ Returns:
202
+ tuple[tuple[int, int], tuple[int, int]]: Two (x, y) points that define the visible
203
+ part of the line within the image.
204
+ """
205
+ img_width, img_height = img.shape[1] - 1, img.shape[0] - 1
206
+
207
+ if self.xv is not None:
208
+ x = int(self.xv)
209
+ return Point(x, 0), Point(x, img_height)
210
+
211
+ if self.slope == 0:
212
+ y = int(self.intercept)
213
+ return Point(0, y), Point(img_width, y)
214
+
215
+ points = []
216
+
217
+ x_top = self.x_for_y(0)
218
+ if x_top is not None and 0 <= x_top <= img_width:
219
+ points.append(Point(int(x_top), 0))
220
+
221
+ x_bottom = self.x_for_y(img_height)
222
+ if x_bottom is not None and 0 <= x_bottom <= img_width:
223
+ points.append(Point(int(x_bottom), img_height))
224
+
225
+ y_left = self.y_for_x(0)
226
+ if y_left is not None and 0 <= y_left <= img_height:
227
+ points.append(Point(0, int(y_left)))
228
+
229
+ y_right = self.y_for_x(img_width)
230
+ if y_right is not None and 0 <= y_right <= img_height:
231
+ points.append(Point(img_width, int(y_right)))
232
+
233
+ unique_points = list(dict.fromkeys(points))
234
+
235
+ if len(unique_points) >= 2:
236
+ return unique_points[0], unique_points[1]
237
+
238
+ raise ValueError("Line does not intersect the image in at least two places.")
239
+
240
+ def check_point_on_line(self, point: Point, tolerance: int = None) -> bool:
241
+ """
242
+ Checks whether a given point lies on the line, optionally within a specified tolerance.
243
+
244
+ Args:
245
+ point (tuple[int, int]): The (x, y) coordinates of the point to check.
246
+ tolerance (int, optional): Allowed deviation in pixels for both x and y.
247
+ If None, the match must be exact.
248
+
249
+ Returns:
250
+ bool: True if the point lies on the line (within tolerance if provided), False otherwise.
251
+ """
252
+
253
+ y = self.y_for_x(point.x)
254
+ x = self.x_for_y(point.y)
255
+
256
+ if y is None or x is None:
257
+ return False
258
+
259
+ line_point = Point(x, y).as_int()
260
+
261
+ if tolerance is None:
262
+ return point.x == line_point.x and point.y == line_point.y
263
+
264
+ return abs(line_point.y - point.y) < tolerance and abs(line_point.x - point.x) < tolerance
265
+
266
+ @property
267
+ def theta(self) -> float:
268
+ """
269
+ Returns the angle (in degrees) between the line and the horizontal axis.
270
+
271
+ Returns:
272
+ float: The angle in degrees. For vertical lines, returns 90.
273
+ """
274
+ if self.slope is None:
275
+ return 90.0
276
+ return np.degrees(np.arctan(self.slope))
277
+
278
+ @classmethod
279
+ def from_hough_line(cls, hough_line: tuple[int, int, int, int]) -> Self:
280
+ """
281
+ Creates a Line instance from a Hough line segment represented by two points.
282
+
283
+ Args:
284
+ hough_line (tuple[int, int, int, int]): A 4-tuple (x1, y1, x2, y2) representing the endpoints of the line.
285
+
286
+ Returns:
287
+ Line: A Line object representing the line segment.
288
+ """
289
+ x1, y1, x2, y2 = hough_line
290
+ return cls.from_points((x1, y1), (x2, y2))
291
+
292
+ @classmethod
293
+ def from_points(cls, p1: tuple[int, int], p2: tuple[int, int]) -> Self:
294
+ """
295
+ Creates a Line instance from two points.
296
+
297
+ Args:
298
+ p1 (tuple[int, int]): The first point (x1, y1).
299
+ p2 (tuple[int, int]): The second point (x2, y2).
300
+
301
+ Returns:
302
+ Line: A Line object defined by the two points.
303
+ """
304
+ x1, y1 = p1
305
+ x2, y2 = p2
306
+
307
+ if x1 == x2:
308
+ slope, intercept = None, None
309
+ xv = x1
310
+ else:
311
+ slope = (y2 - y1) / (x2 - x1)
312
+ intercept = y1 - slope * x1
313
+ xv = None
314
+
315
+ return cls(slope, intercept, xv)
316
+
317
+
318
+ class LineGroup(Line):
319
+ """
320
+ A group of Line objects that are approximately aligned, represented as a single approximated line.
321
+
322
+ The approximation is based on the median slope/intercept (for non-vertical lines)
323
+ or median x-value (for vertical lines).
324
+ """
325
+
326
+ def __init__(self, lines: list[Line] = None) -> None:
327
+ self.lines = lines or []
328
+
329
+ if not self.lines:
330
+ self.slope = self.intercept = self.xv = None
331
+ else:
332
+ self._calculate_line_approximation()
333
+
334
+ def __repr__(self) -> str:
335
+ """Return a string representation of the approximated line equation."""
336
+ if not self.lines:
337
+ return "LineGroup(empty)"
338
+
339
+ if self.xv is not None:
340
+ return f"LineGroup: x = {self.xv:.2f} (from {len(self.lines)} lines)"
341
+ else:
342
+ return f"LineGroup: y = {self.slope:.2f} * x + {self.intercept:.2f} (from {len(self.lines)} lines)"
343
+
344
+ def process_line(self, line: Line, thresh_theta: float | int, thresh_intercept: float | int) -> bool:
345
+ """
346
+ Try to add a Line to the group if it is similar enough to the reference line.
347
+
348
+ Args:
349
+ line (Line): The line to evaluate and possibly add.
350
+ thresh_theta (float | int): Angular threshold for similarity in orientation.
351
+ thresh_intercept (float | int): Threshold for similarity in intercept (used for non-vertical lines).
352
+
353
+ Returns:
354
+ bool: True if the line was added to the group, False otherwise.
355
+ """
356
+ ref = self.lines[0]
357
+ found = False
358
+
359
+ if abs(ref.theta - line.theta) < thresh_theta:
360
+ if ref.xv is None and line.xv is None:
361
+ if abs(ref.intercept - line.intercept) < thresh_intercept:
362
+ found = True
363
+
364
+ if ref.xv is not None or line.xv is not None:
365
+ found = True
366
+
367
+ if found:
368
+ self.lines.append(line)
369
+ self._calculate_line_approximation()
370
+
371
+ self.lines = sorted(self.lines, key=lambda line: -line.intercept)
372
+ return found
373
+
374
+ def get_line(self, line_type: Literal["min", "max"]) -> Line:
375
+ return {"min": self.lines[0], "max": self.lines[-1]}[line_type]
376
+
377
+ def _calculate_line_approximation(self) -> None:
378
+ """
379
+ Calculate the approximated line for the group based on the median of included lines.
380
+
381
+ - For vertical lines (with xv), median x is used.
382
+ - For non-vertical lines, median slope and intercept are used.
383
+ """
384
+ vertical_lines = [line.xv for line in self.lines if line.xv is not None]
385
+
386
+ if vertical_lines:
387
+ self.xv = np.median(vertical_lines)
388
+ self.slope, self.intercept = None, None
389
+
390
+ else:
391
+ self.xv = None
392
+ self.slope = np.median([line.slope for line in self.lines])
393
+ self.intercept = np.median([line.intercept for line in self.lines])
394
+
395
+
396
+ def transform_line(
397
+ original_line: Line,
398
+ original_img: np.ndarray,
399
+ original_x_start: int,
400
+ original_y_start: int,
401
+ to_global: bool = True
402
+ ) -> Line:
403
+ """
404
+ Transforms a line's coordinates between local and global image reference frames.
405
+
406
+ The function shifts both endpoints of a line by the provided offsets using
407
+ `transform_point` and reconstructs a new line from the transformed coordinates.
408
+
409
+ Args:
410
+ original_line (Line): Line object to transform.
411
+ original_img (np.ndarray): Image used to determine line limits.
412
+ original_x_start (int): X-axis offset.
413
+ original_y_start (int): Y-axis offset.
414
+ to_global (bool, optional): If True, converts from local to global coordinates;
415
+ if False, converts from global to local (default: True).
416
+
417
+ Returns:
418
+ Line: Transformed line object with updated coordinates.
419
+ """
420
+ pts_source: Iterable[Point] = original_line.limit_to_img(original_img)
421
+ pts_transformed = [transform_point(p, original_x_start, original_y_start, to_global=to_global) for p in pts_source]
422
+ return Line.from_points(*pts_transformed)
@@ -0,0 +1,154 @@
1
+ from typing import Iterator, TYPE_CHECKING, Self, Union
2
+
3
+ import numpy as np
4
+
5
+ from cvgeomkit.common import Hashable, Numeric
6
+
7
+ if TYPE_CHECKING:
8
+ from .intersections import Intersection
9
+
10
+
11
+ class Point[T: (Numeric, Numeric)](Hashable):
12
+ """Immutable 2D point represented as a generic point with two numbers."""
13
+
14
+ __slots__ = ("_x", "_y")
15
+
16
+ def __init__(self, x: T, y: T) -> None:
17
+ """
18
+ Create a new Point instance from X and Y coordinates.
19
+
20
+ Args:
21
+ x (T): The X coordinate (int or float).
22
+ y (T): The Y coordinate (int or float).
23
+ """
24
+ object.__setattr__(self, "_x", x)
25
+ object.__setattr__(self, "_y", y)
26
+
27
+ def __setattr__(self, name: str, value: object) -> None:
28
+ """Prevent modification after initialization."""
29
+ raise AttributeError(f"'Point' object attribute '{name}' is read-only")
30
+
31
+ def _key_(self) -> tuple[T, T]:
32
+ """Return the key for hashing and equality comparison."""
33
+ return (self._x, self._y)
34
+
35
+ @classmethod
36
+ def from_xy(cls, x: T, y: T) -> Self:
37
+ """
38
+ Create a Point from separate X and Y values.
39
+
40
+ Args:
41
+ x (T): The X coordinate (int or float).
42
+ y (T): The Y coordinate (int or float).
43
+
44
+ Returns:
45
+ Point[T]: A new immutable Point instance.
46
+ """
47
+ return cls(x, y)
48
+
49
+ @classmethod
50
+ def from_iterable(cls, iterable: tuple[T, T] | list[T, T]) -> Self:
51
+ """
52
+ Create a Point from an iterable of exactly two elements.
53
+
54
+ Args:
55
+ iterable: An iterable containing exactly two numeric elements.
56
+
57
+ Returns:
58
+ Point[T]: A new immutable Point instance.
59
+
60
+ Raises:
61
+ ValueError: If the iterable does not contain exactly two elements.
62
+ """
63
+ values = tuple(iterable)
64
+ if len(values) != 2:
65
+ raise ValueError(f"Expected iterable of length 2, got {len(values)}")
66
+ return cls(values[0], values[1])
67
+
68
+ @property
69
+ def x(self) -> T:
70
+ """Get the X coordinate of the point."""
71
+ return self._x
72
+
73
+ @property
74
+ def y(self) -> T:
75
+ """Get the Y coordinate of the point."""
76
+ return self._y
77
+
78
+ def distance(self, another_point: Self) -> float:
79
+ """Calculate Euclidean distance to another point."""
80
+ return np.linalg.norm(np.array([self.x, self.y]) - np.array([another_point.x, another_point.y]))
81
+
82
+ def is_in_area(self, p1: Self, p2: Self) -> bool:
83
+ return p1.x < self.x < p2.x and p1.y < self.y < p2.y
84
+
85
+ def __getitem__(self, index: int) -> T:
86
+ """Allow indexing like a tuple."""
87
+ if index == 0:
88
+ return self._x
89
+ elif index == 1:
90
+ return self._y
91
+ else:
92
+ raise IndexError("Point index out of range")
93
+
94
+ def __iter__(self) -> Iterator[float]:
95
+ """Allow unpacking like a tuple."""
96
+ yield self._x
97
+ yield self._y
98
+
99
+ def __len__(self) -> int:
100
+ """Return length (always 2)."""
101
+ return 2
102
+
103
+ def __repr__(self) -> str:
104
+ """Return string representation."""
105
+ return f"Point({self._x}, {self._y})"
106
+
107
+ def __str__(self) -> str:
108
+ """Return string representation."""
109
+ return f"({self._x}, {self._y})"
110
+
111
+ def as_int(self) -> Self:
112
+ return Point(int(self.x), int(self.y))
113
+
114
+ def to_tuple(self) -> tuple[T, T]:
115
+ """
116
+ Convert the Point to a tuple (x, y).
117
+
118
+ Returns:
119
+ tuple[T, T]: A tuple containing the X and Y coordinates.
120
+ """
121
+ return (self._x, self._y)
122
+
123
+
124
+ def transform_point(
125
+ point: Union['Intersection', Point],
126
+ original_x_start: int,
127
+ original_y_start: int,
128
+ to_global: bool = True
129
+ ) -> Point:
130
+ """
131
+ Transforms a point's coordinates between local and global image reference frames.
132
+
133
+ The function shifts a point by the provided (x, y) offsets depending on the
134
+ transformation direction. Works with both `Point` and `Intersection` objects.
135
+
136
+ Args:
137
+ point (Intersection | Point): Point or intersection to transform.
138
+ original_x_start (int): X-axis offset.
139
+ original_y_start (int): Y-axis offset.
140
+ to_global (bool, optional): If True, converts from local to global coordinates;
141
+ if False, converts from global to local (default: True).
142
+
143
+ Returns:
144
+ Point: Transformed point with updated coordinates.
145
+ """
146
+ from .intersections import Intersection
147
+
148
+ if isinstance(point, Intersection):
149
+ point = point.point
150
+
151
+ if to_global:
152
+ return Point(point.x + original_x_start, point.y + original_y_start)
153
+ else:
154
+ return Point(point.x - original_x_start, point.y - original_y_start)
File without changes
@@ -0,0 +1,127 @@
1
+ from pathlib import Path
2
+ from typing import Literal, Sequence
3
+
4
+ import cv2
5
+ import numpy as np
6
+
7
+ from cvgeomkit.common import BBoxFmt, NumpyImage, Numeric
8
+ from cvgeomkit.geometry.points import Point
9
+ from cvgeomkit.geometry.lines import LineGroup, Line
10
+
11
+
12
+ def convert_bbox(
13
+ bbox: Sequence[Numeric],
14
+ to_fmt: BBoxFmt,
15
+ returns_int: bool = True,
16
+ ) -> tuple[Numeric, Numeric, Numeric, Numeric]:
17
+ """
18
+ Convert a four-number box into ``to_fmt``.
19
+
20
+ Input layout depends on ``to_fmt``: ``XYXY`` expects ``(x, y, w, h)``;
21
+ ``XYWH`` expects ``(x1, y1, x2, y2)``; ``CXCYWH`` expects ``(x, y, w, h)``
22
+ in top-left ``xywh`` form and returns center ``x, y`` plus ``w, h``.
23
+ """
24
+ if to_fmt == BBoxFmt.XYXY:
25
+ x, y, w, h = bbox
26
+ out = (x, y, x + w, y + h)
27
+ elif to_fmt == BBoxFmt.XYWH:
28
+ x1, y1, x2, y2 = bbox
29
+ out = (x1, y1, x2 - x1, y2 - y1)
30
+ elif to_fmt == BBoxFmt.CXCYWH:
31
+ x, y, w, h = bbox
32
+ out = (x + w / 2, y + h / 2, w, h)
33
+ else:
34
+ raise ValueError(f"unsupported to_fmt: {to_fmt!r}")
35
+
36
+ if returns_int:
37
+ return tuple(map(int, out))
38
+ return out
39
+
40
+
41
+ def rerange_hue(hue: np.ndarray) -> float:
42
+ """
43
+ Shifts the hue channel by 90 with wrap-around (overflow) within the 0–179 range.
44
+ Uses modulo arithmetic to keep the result within the valid hue range
45
+ """
46
+ return (hue + 90) % 180
47
+
48
+
49
+ def group_lines(lines: list[Line],
50
+ thresh_theta: float | int = 5,
51
+ thresh_intercept: float | int = 10
52
+ ) -> list[LineGroup]:
53
+ """
54
+ Group similar Line objects into LineGroups based on orientation and position thresholds.
55
+
56
+ Args:
57
+ lines (list[Line]): A list of Line objects to group.
58
+ thresh_theta (float): Maximum allowed angle difference between lines to be in the same group.
59
+ thresh_intercept (float): Maximum allowed intercept difference (for non-vertical lines).
60
+
61
+ Returns:
62
+ list[LineGroup]: A list of LineGroup objects representing grouped lines.
63
+ """
64
+ groups = []
65
+
66
+ for line in lines:
67
+ for group in groups:
68
+ if group.process_line(line, thresh_theta, thresh_intercept):
69
+ break
70
+ else:
71
+ groups.append(LineGroup([line]))
72
+
73
+ return groups
74
+
75
+
76
+ def read_image_as_numpyimage(path: str | Path, color_mode: Literal["rgb", "hsv", "grayscale"] = "rgb") -> NumpyImage:
77
+ """
78
+ Load an image from disk as a :class:`~cvgeomkit.common.NumpyImage`.
79
+
80
+ ``color_mode`` selects the channel layout after OpenCV's BGR decode:
81
+ ``rgb``/``hsv`` use ``cvtColor``; ``grayscale`` uses single-channel read.
82
+ """
83
+ color_mode = color_mode.lower()
84
+ if color_mode not in {"rgb", "hsv", "grayscale"}:
85
+ raise ValueError("color_mode must be 'RGB', 'HSV', or 'GRAYSCALE'")
86
+
87
+ img = cv2.imread(path, cv2.IMREAD_GRAYSCALE if color_mode == "grayscale" else cv2.IMREAD_COLOR)
88
+ if img is None:
89
+ raise FileNotFoundError(f"Cannot read image: {path}")
90
+
91
+ conversions = {
92
+ "rgb": lambda x: cv2.cvtColor(x, cv2.COLOR_BGR2RGB),
93
+ "hsv": lambda x: cv2.cvtColor(x, cv2.COLOR_BGR2HSV),
94
+ }
95
+
96
+ img = conversions.get(color_mode, lambda x: x)(img)
97
+ return NumpyImage(img)
98
+
99
+
100
+ def order_clockwise(
101
+ points: np.ndarray | Sequence[Point] | Sequence[Sequence[Numeric]],
102
+ ) -> np.ndarray:
103
+ """
104
+ Sort 2D points clockwise around their mean (centroid), by polar angle.
105
+
106
+ Accepts an ``(N, 2)`` array, a sequence of :class:`~cvgeomkit.geometry.points.Point`,
107
+ or nested sequences of two numbers. Fewer than three points are returned unchanged.
108
+ """
109
+ if isinstance(points, np.ndarray):
110
+ arr = np.asarray(points, dtype=float)
111
+
112
+ elif points and isinstance(points[0], Point):
113
+ arr = np.array([[p.x, p.y] for p in points], dtype=float)
114
+
115
+ else:
116
+ arr = np.asarray(points, dtype=float)
117
+
118
+ if arr.ndim != 2 or arr.shape[1] != 2:
119
+ raise ValueError("points must be of shape (N, 2)")
120
+
121
+ if len(arr) < 3:
122
+ return arr
123
+
124
+ center = arr.mean(axis=0)
125
+ angles = np.arctan2(arr[:, 1] - center[1], arr[:, 0] - center[0])
126
+
127
+ return arr[np.argsort(-angles)]
@@ -0,0 +1,35 @@
1
+ import numpy as np
2
+ from shapely.geometry import Polygon
3
+ from cvgeomkit.common import Numeric
4
+ from cvgeomkit.geometry.points import Point
5
+ from .helpers import order_clockwise
6
+
7
+
8
+ def iou(
9
+ area1: list[list[Numeric]] | list[Point] | np.ndarray,
10
+ area2: list[list[Numeric]] | list[Point] | np.ndarray,
11
+ ) -> float:
12
+ """
13
+ Intersection-over-union of two polygons given as vertex rings.
14
+
15
+ Vertices are oriented consistently via :func:`~cvgeomkit.utils.helpers.order_clockwise`
16
+ before building Shapely polygons.
17
+ """
18
+ area1 = order_clockwise(area1)
19
+ area2 = order_clockwise(area2)
20
+
21
+ area1 = Polygon(area1)
22
+ area2 = Polygon(area2)
23
+
24
+ intersection = area1.intersection(area2).area
25
+ union = area1.union(area2).area
26
+
27
+ return intersection / union if union > 0 else 0.0
28
+
29
+
30
+ def euclidean_distance(
31
+ point1: Point | np.ndarray | list[Numeric],
32
+ point2: Point | np.ndarray | list[Numeric]
33
+ ) -> float:
34
+ """Euclidean distance between two 2D points (each as ``Point``, array, or pair of numbers)."""
35
+ return np.linalg.norm(np.array(point1) - np.array(point2))