├── .github └── workflows │ ├── publish.yml │ └── testing.yml ├── .gitignore ├── LICENSE ├── README.md ├── cvpack ├── __init__.py ├── cvtypes.py ├── extras │ ├── __init__.py │ ├── clustering.py │ ├── imgproc.py │ ├── iterators.py │ └── matlab.py ├── imgio.py ├── py.typed └── videoio.py ├── mypy.ini ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── test_point_size.py ├── test_rect.py └── test_rotated_rect.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Poetry when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip poetry 24 | - name: Build and publish 25 | env: 26 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 27 | run: | 28 | poetry build 29 | poetry publish -u __token__ -p $PYPI_TOKEN 30 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Static check and testing 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: [3.6, 3.7, 3.8, 3.9] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip poetry 29 | poetry install -n 30 | - name: Check format with black 31 | run: poetry run black cvpack/ tests/ --check 32 | - name: Static typing 33 | run: poetry run mypy cvpack --strict 34 | - name: Test with pytest 35 | run: poetry run pytest 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__/ 2 | **/.pytest_cache 3 | **/.hypothesis 4 | .DS_Store 5 | .venv/ 6 | .vscode/ 7 | docs/ 8 | dist/ 9 | venv/ 10 | .idea/ 11 | *.egg-info/ 12 | opencv-cpp-test/ 13 | *.egg-info/ 14 | cpp/ 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alexander Reynolds 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cvpack 2 | 3 | OpenCV extensions for more Pythonic interactions. 4 | 5 | ## Install 6 | 7 | ```sh 8 | pip install cvpack-alkasm 9 | ``` 10 | 11 | ## Types 12 | 13 | `cvpack` includes types that exist in the main C++ OpenCV codebase, but that aren't included in the Python bindings. They are compatible as arguments to OpenCV functions, and they implement the same interfaces (with some new additions). The types that are included are `Point`, `Point3`, `Rect`, `RotatedRect`, `Size`, `TermCriteria`. They are implemented as namedtuples, and as such are immutable. 14 | 15 | ```python 16 | import cvpack 17 | 18 | img = cvpack.imread("img.png") 19 | p1 = cvpack.Point(50, 50) 20 | p2 = cvpack.Point(100, 100) 21 | rect = cvpack.Rect.from_points(p1, p2) 22 | roi = img[rect.slice()] 23 | roi_size = cvpack.Size.from_image(roi) 24 | assert roi_size == rect.size() 25 | ``` 26 | 27 | The overloaded constructors are available as `from_` classmethods, like `from_points` shown above. They also follow the same operator overloads that OpenCV has: two points summed is a point, adding a point to a rectangle shifts it, you can `&` two rectangles to get the intersection as a new rectangle, and so on. 28 | 29 | ## Image IO 30 | 31 | Wrappers for `imread`, `imwrite`, and `imshow` simplify usage by checking errors and allowing path-like objects for path arguments. Additionally, `cvpack` provides functions to read images from a URL (`imread_url`), display to a browser (`imshow_browser`) for statically serving images while working in an interpreter, and displaying images in a Jupyter notebook (`imshow_jupyter`) as HTML directly rather than the typical `plt.imshow` from `matplotlib`. Some other utilities related to display are also included. 32 | 33 | ```python 34 | from pathlib import Path 35 | import cvpack 36 | 37 | for path in Path("folder").glob("*.png"): 38 | img = cvpack.imread(path) 39 | big = cvpack.add_grid(cvpack.enlarge(img)) 40 | cvpack.imshow_browser(img, route=str(path)) 41 | ``` 42 | 43 | ## Video IO 44 | 45 | Working with video requires acquiring and releasing resources, so `cvpack` provides context managers for video readers and writers which wrap the classes from OpenCV. Reading video frames is simplified to iterating over the capture object. 46 | 47 | ```python 48 | import cv2 49 | import cvpack 50 | 51 | with cvpack.VideoCapture("video.mp4") as cap: 52 | with cvpack.VideoWriter("reversed.mp4", fourcc=int(cap.fourcc), fps=cap.fps) as writer: 53 | for frame in cap: 54 | flipped = cv2.flip(frame, 0) 55 | writer.write(flipped) 56 | ``` 57 | -------------------------------------------------------------------------------- /cvpack/__init__.py: -------------------------------------------------------------------------------- 1 | """Utilities for computer vision.""" 2 | 3 | from .imgio import * 4 | from .cvtypes import * 5 | from .videoio import * 6 | -------------------------------------------------------------------------------- /cvpack/cvtypes.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module mimics some of the OpenCV built-in types that don't get translated 3 | into Python directly. Generally, these just get mapped to/from tuples. 4 | 5 | However, a lot of OpenCV code would benefit from types that describe the tuple, 6 | in addition to named attributes. As OpenCV expects tuples for these datatypes, 7 | subclassing from tuples and namedtuples allows for flexibility without breaking 8 | compatibility. 9 | 10 | Wherever it made sense, functionality was copied from OpenCV. For overloaded 11 | CPP functions, there is not a hard rule for deciding which version becomes the 12 | defacto Python method, but in some cases, both have been provided. Alternative 13 | constructors are given in the usual Python way---as classmethods prepended with 14 | the verb "from". 15 | 16 | Some liberties have been taken with respect to naming. In particular, camelCase 17 | method names have been translated to snake_case. Some methods and attributes 18 | are provided that OpenCV doesn't contain. 19 | 20 | Aside from Scalars (where you can use a numpy array for vector operations), 21 | the usual arithmetic operations available in OpenCV are available here. 22 | 23 | You can average two points by adding them and dividing by two; you can add a 24 | point to a rect to shift it; and so on. 25 | """ 26 | 27 | from typing import NamedTuple, Tuple, Optional, Union 28 | from collections.abc import Sequence 29 | import enum 30 | import numpy as np 31 | import cv2 as cv 32 | 33 | SeqOperable = Union[float, Sequence, np.ndarray] 34 | 35 | 36 | class Point(NamedTuple): 37 | x: float 38 | y: float 39 | 40 | def __add__(self, other: SeqOperable) -> "Point": 41 | return self.__class__(*(np.array(self).__add__(np.array(other)))) 42 | 43 | def __sub__(self, other: SeqOperable) -> "Point": 44 | return self.__class__(*(np.array(self).__sub__(np.array(other)))) 45 | 46 | def __mul__(self, other: SeqOperable) -> "Point": 47 | return self.__class__(*(np.array(self).__mul__(np.array(other)))) 48 | 49 | def __truediv__(self, other: SeqOperable) -> "Point": 50 | return self.__class__(*(np.array(self).__truediv__(np.array(other)))) 51 | 52 | def __floordiv__(self, other: SeqOperable) -> "Point": 53 | return self.__class__(*(np.array(self).__floordiv__(np.array(other)))) 54 | 55 | def __rsub__(self, other: SeqOperable) -> "Point": 56 | return self.__class__(*(np.array(self).__rsub__(np.array(other)))) 57 | 58 | def __rtruediv__(self, other: SeqOperable) -> "Point": 59 | return self.__class__(*(np.array(self).__rtruediv__(np.array(other)))) 60 | 61 | def __rfloordiv__(self, other: SeqOperable) -> "Point": 62 | return self.__class__(*(np.array(self).__rfloordiv__(np.array(other)))) 63 | 64 | def __radd__(self, other: SeqOperable) -> "Point": 65 | return self.__class__(*(np.array(self).__radd__(np.array(other)))) 66 | 67 | def __rmul__(self, other: SeqOperable) -> "Point": 68 | return self.__class__(*(np.array(self).__rmul__(np.array(other)))) 69 | 70 | def __pos__(self) -> "Point": 71 | return self.__class__(*np.array(self).__pos__()) 72 | 73 | def __neg__(self) -> "Point": 74 | return self.__class__(*np.array(self).__neg__()) 75 | 76 | def __abs__(self) -> "Point": 77 | return self.__class__(*np.array(self).__abs__()) 78 | 79 | def __round__(self, ndigits: Optional[int] = None) -> "Point": 80 | return self.__class__(*(round(v, ndigits=ndigits) for v in self)) 81 | 82 | def __floor__(self) -> "Point": 83 | return self.__class__(*np.floor(self)) 84 | 85 | def __ceil__(self) -> "Point": 86 | return self.__class__(*np.ceil(self)) 87 | 88 | def cross(self, point: "Point") -> float: 89 | return float(np.cross(self, point)) 90 | 91 | def dot(self, point: "Point") -> float: 92 | return float(np.dot(self, point)) 93 | 94 | def ddot(self, point: "Point") -> float: 95 | return self.dot(point) 96 | 97 | def inside(self, rect: "Rect") -> bool: 98 | """checks whether the point is inside the specified rectangle""" 99 | rect = Rect(*rect) 100 | return rect.contains(self) 101 | 102 | 103 | class Point3(NamedTuple): 104 | x: float 105 | y: float 106 | z: float 107 | 108 | def __add__(self, other: SeqOperable) -> "Point3": 109 | return self.__class__(*(np.array(self).__add__(np.array(other)))) 110 | 111 | def __sub__(self, other: SeqOperable) -> "Point3": 112 | return self.__class__(*(np.array(self).__sub__(np.array(other)))) 113 | 114 | def __mul__(self, other: SeqOperable) -> "Point3": 115 | return self.__class__(*(np.array(self).__mul__(np.array(other)))) 116 | 117 | def __truediv__(self, other: SeqOperable) -> "Point3": 118 | return self.__class__(*(np.array(self).__truediv__(np.array(other)))) 119 | 120 | def __floordiv__(self, other: SeqOperable) -> "Point3": 121 | return self.__class__(*(np.array(self).__floordiv__(np.array(other)))) 122 | 123 | def __rsub__(self, other: SeqOperable) -> "Point3": 124 | return self.__class__(*(np.array(self).__rsub__(np.array(other)))) 125 | 126 | def __rtruediv__(self, other: SeqOperable) -> "Point3": 127 | return self.__class__(*(np.array(self).__rtruediv__(np.array(other)))) 128 | 129 | def __rfloordiv__(self, other: SeqOperable) -> "Point3": 130 | return self.__class__(*(np.array(self).__rfloordiv__(np.array(other)))) 131 | 132 | def __radd__(self, other: SeqOperable) -> "Point3": 133 | return self.__class__(*(np.array(self).__radd__(np.array(other)))) 134 | 135 | def __rmul__(self, other: SeqOperable) -> "Point3": 136 | return self.__class__(*(np.array(self).__rmul__(np.array(other)))) 137 | 138 | def __pos__(self) -> "Point3": 139 | return self.__class__(*np.array(self).__pos__()) 140 | 141 | def __neg__(self) -> "Point3": 142 | return self.__class__(*np.array(self).__neg__()) 143 | 144 | def __abs__(self) -> "Point3": 145 | return self.__class__(*np.array(self).__abs__()) 146 | 147 | def __round__(self, ndigits: Optional[int] = None) -> "Point3": 148 | return self.__class__(*(round(v, ndigits=ndigits) for v in self)) 149 | 150 | def __floor__(self) -> "Point3": 151 | return self.__class__(*np.floor(self)) 152 | 153 | def __ceil__(self) -> "Point3": 154 | return self.__class__(*np.ceil(self)) 155 | 156 | def cross(self, point: "Point3") -> "Point3": 157 | return self.__class__(*np.cross(self, point)) 158 | 159 | def dot(self, point: "Point3") -> float: 160 | return float(np.dot(self, point)) 161 | 162 | def ddot(self, point: "Point3") -> float: 163 | return self.dot(point) 164 | 165 | 166 | class Size(NamedTuple): 167 | width: float 168 | height: float 169 | 170 | def __add__(self, other: SeqOperable) -> "Size": 171 | return self.__class__(*(np.array(self).__add__(np.array(other)))) 172 | 173 | def __sub__(self, other: SeqOperable) -> "Size": 174 | return self.__class__(*(np.array(self).__sub__(np.array(other)))) 175 | 176 | def __mul__(self, other: SeqOperable) -> "Size": 177 | return self.__class__(*(np.array(self).__mul__(np.array(other)))) 178 | 179 | def __truediv__(self, other: SeqOperable) -> "Size": 180 | return self.__class__(*(np.array(self).__truediv__(np.array(other)))) 181 | 182 | def __floordiv__(self, other: SeqOperable) -> "Size": 183 | return self.__class__(*(np.array(self).__floordiv__(np.array(other)))) 184 | 185 | def __rsub__(self, other: SeqOperable) -> "Size": 186 | return self.__class__(*(np.array(self).__rsub__(np.array(other)))) 187 | 188 | def __rtruediv__(self, other: SeqOperable) -> "Size": 189 | return self.__class__(*(np.array(self).__rtruediv__(np.array(other)))) 190 | 191 | def __rfloordiv__(self, other: SeqOperable) -> "Size": 192 | return self.__class__(*(np.array(self).__rfloordiv__(np.array(other)))) 193 | 194 | def __radd__(self, other: SeqOperable) -> "Size": 195 | return self.__class__(*(np.array(self).__radd__(np.array(other)))) 196 | 197 | def __rmul__(self, other: SeqOperable) -> "Size": 198 | return self.__class__(*(np.array(self).__rmul__(np.array(other)))) 199 | 200 | def __pos__(self) -> "Size": 201 | return self.__class__(*np.array(self).__pos__()) 202 | 203 | def __neg__(self) -> "Size": 204 | return self.__class__(*np.array(self).__neg__()) 205 | 206 | def __abs__(self) -> "Size": 207 | return self.__class__(*np.array(self).__abs__()) 208 | 209 | def __round__(self, ndigits: Optional[int] = None) -> "Size": 210 | return self.__class__(*(round(v, ndigits=ndigits) for v in self)) 211 | 212 | def __floor__(self) -> "Size": 213 | return self.__class__(*np.floor(self)) 214 | 215 | def __ceil__(self) -> "Size": 216 | return self.__class__(*np.ceil(self)) 217 | 218 | def area(self) -> float: 219 | return self.height * self.width 220 | 221 | def empty(self) -> bool: 222 | """true if empty""" 223 | return self.width <= 0 or self.height <= 0 224 | 225 | @classmethod 226 | def from_image(cls, image: np.ndarray) -> "Size": 227 | h, w = image.shape[:2] 228 | return cls(w, h) 229 | 230 | 231 | class Rect(NamedTuple): 232 | """Mimics cv::Rect while maintaining compatibility with OpenCV's Python bindings. 233 | 234 | Reference: https://docs.opencv.org/master/d2/d44/classcv_1_1Rect__.html 235 | """ 236 | 237 | x: float 238 | y: float 239 | width: float 240 | height: float 241 | 242 | def __add__(self, other: Union[Point, Size]) -> "Rect": # type: ignore[override] 243 | """Shift or alter the size of the rectangle. 244 | rect ± point (shifting a rectangle by a certain offset) 245 | rect ± size (expanding or shrinking a rectangle by a certain amount) 246 | """ 247 | if isinstance(other, Point): 248 | origin = Point(self.x + other.x, self.y + other.y) 249 | return self.from_origin(origin, self.size()) 250 | elif isinstance(other, Size): 251 | size = Size(self.width + other.width, self.height + other.height) 252 | return self.from_origin(self.tl(), size) 253 | raise NotImplementedError( 254 | "Adding to a rectangle generically is ambiguous.\n" 255 | "Add a Point to shift the top-left point, or a Size to expand the rectangle." 256 | ) 257 | 258 | def __sub__(self, other: Union[Point, Size]) -> "Rect": 259 | """Shift or alter the size of the rectangle. 260 | rect ± point (shifting a rectangle by a certain offset) 261 | rect ± size (expanding or shrinking a rectangle by a certain amount) 262 | """ 263 | if isinstance(other, Point): 264 | origin = Point(self.x - other.x, self.y - other.y) 265 | return self.from_origin(origin, self.size()) 266 | elif isinstance(other, Size): 267 | w = max(self.width - other.width, 0) 268 | h = max(self.height - other.height, 0) 269 | return self.from_origin(self.tl(), Size(w, h)) 270 | raise NotImplementedError( 271 | "Subtracting from a rectangle generically is ambiguous.\n" 272 | "Subtract a Point to shift the top-left point, or a Size to shrink the rectangle." 273 | ) 274 | 275 | def __and__(self, other: "Rect") -> "Rect": 276 | """rectangle intersection""" 277 | x = max(self.x, other.x) 278 | y = max(self.y, other.y) 279 | w = min(self.x + self.width, other.x + other.width) - x 280 | h = min(self.y + self.height, other.y + other.height) - y 281 | 282 | return ( 283 | self.__class__(0, 0, 0, 0) 284 | if (w <= 0 or h <= 0) 285 | else self.__class__(x, y, w, h) 286 | ) 287 | 288 | def __or__(self, other: "Rect") -> "Rect": 289 | """minimum area rectangle containing self and other.""" 290 | if self.empty(): 291 | return other 292 | elif not other.empty(): 293 | x = min(self.x, other.x) 294 | y = min(self.y, other.y) 295 | w = max(self.x + self.width, other.x + other.width) - x 296 | h = max(self.y + self.height, other.y + other.height) - y 297 | return self.__class__(x, y, w, h) 298 | return self 299 | 300 | def tl(self) -> Point: 301 | """top left point""" 302 | return Point(self.x, self.y) 303 | 304 | def br(self) -> Point: 305 | """bottom right point""" 306 | return Point(self.x + self.width, self.y + self.height) 307 | 308 | def area(self) -> float: 309 | return self.height * self.width 310 | 311 | def size(self) -> Size: 312 | """size (width, height) of the rectangle""" 313 | return Size(self.width, self.height) 314 | 315 | def contains(self, point: Point) -> bool: 316 | """checks whether the rectangle contains the point""" 317 | return ( 318 | self.x <= point.x <= self.x + self.width 319 | and self.y <= point.y <= self.y + self.height 320 | ) 321 | 322 | def empty(self) -> bool: 323 | """true if empty""" 324 | return self.width <= 0 or self.height <= 0 325 | 326 | @classmethod 327 | def from_points(cls, top_left: Point, bottom_right: Point) -> "Rect": 328 | """Alternative constructor using two points.""" 329 | x1, y1 = top_left 330 | x2, y2 = bottom_right 331 | w = x2 - x1 332 | h = y2 - y1 333 | return cls(x1, y1, w, h) 334 | 335 | @classmethod 336 | def from_origin(cls, origin: Point, size: Size) -> "Rect": 337 | """Alternative constructor using a point and size.""" 338 | x, y = origin 339 | w, h = size 340 | return cls(x, y, w, h) 341 | 342 | @classmethod 343 | def from_center(cls, center: Point, size: Size) -> "Rect": 344 | """Alternative constructor using a center point and size.""" 345 | w, h = size 346 | xc, yc = center 347 | x = xc - w / 2 348 | y = yc - h / 2 349 | return cls(x, y, w, h) 350 | 351 | def slice(self) -> Tuple[slice, slice]: 352 | """Returns a slice for a numpy array. Not included in OpenCV. 353 | 354 | img[rect.slice()] == img[rect.y : rect.y + rect.height, rect.x : rect.x + rect.width] 355 | """ 356 | return slice(self.y, self.y + self.height), slice(self.x, self.x + self.width) 357 | 358 | def center(self) -> Point: 359 | """Returns the center of the rectangle as a point (xc, yc). Not included in OpenCV. 360 | 361 | rect.center() == (rect.x + rect.width / 2, rect.y + rect.height / 2) 362 | """ 363 | return Point(self.x + self.width / 2, self.y + self.height / 2) 364 | 365 | def intersection(self, other: "Rect") -> float: 366 | """Return the area of the intersection of two rectangles. Not included in OpenCV.""" 367 | if self.empty() or other.empty(): 368 | return 0 369 | w = min(self.x + self.width, other.x + other.width) - max(self.x, other.x) 370 | h = min(self.y + self.height, other.y + other.height) - max(self.y, other.y) 371 | return w * h 372 | 373 | def union(self, other: "Rect") -> float: 374 | """Return the area of the union of two rectangles. Not included in OpenCV.""" 375 | return self.area() + other.area() - self.intersection(other) 376 | 377 | 378 | class RotatedRect(NamedTuple): 379 | center: Point 380 | size: Size 381 | angle: float 382 | 383 | def bounding_rect(self) -> Rect: 384 | """returns the minimal rectangle containing the rotated rectangle""" 385 | pts = self.points() 386 | r = Rect.from_points( 387 | Point(np.floor(min(pt.x for pt in pts)), np.floor(min(pt.y for pt in pts))), 388 | Point(np.ceil(max(pt.x for pt in pts)), np.ceil(max(pt.y for pt in pts))), 389 | ) 390 | return r 391 | 392 | def points(self) -> Tuple[Point, Point, Point, Point]: 393 | """returns 4 vertices of the rectangle. The order is bottom left, top left, top right, bottom right.""" 394 | b = np.cos(np.radians(self.angle)) * 0.5 395 | a = np.sin(np.radians(self.angle)) * 0.5 396 | 397 | pt0 = Point( 398 | self.center.x - a * self.size.height - b * self.size.width, 399 | self.center.y + b * self.size.height - a * self.size.width, 400 | ) 401 | pt1 = Point( 402 | self.center.x + a * self.size.height - b * self.size.width, 403 | self.center.y - b * self.size.height - a * self.size.width, 404 | ) 405 | 406 | pt2 = Point(2 * self.center.x - pt0.x, 2 * self.center.y - pt0.y) 407 | pt3 = Point(2 * self.center.x - pt1.x, 2 * self.center.y - pt1.y) 408 | 409 | return pt0, pt1, pt2, pt3 410 | 411 | @classmethod 412 | def from_points(cls, point1: Point, point2: Point, point3: Point) -> "RotatedRect": 413 | """Any 3 end points of the RotatedRect. They must be given in order (either clockwise or anticlockwise).""" 414 | center = (point1 + point3) * 0.5 415 | vecs = [point1 - point2, point2 - point3] 416 | x = max(np.linalg.norm(pt) for pt in (point1, point2, point3)) 417 | a = min(np.linalg.norm(vecs[0]), np.linalg.norm(vecs[1])) 418 | 419 | # check that given sides are perpendicular 420 | if abs(vecs[0].dot(vecs[1])) * a > np.finfo(np.float32).eps * 9 * x * ( 421 | np.linalg.norm(vecs[0]) * np.linalg.norm(vecs[1]) 422 | ): 423 | raise ValueError( 424 | "The three points do not define a rotated rect. The three points should form a right triangle." 425 | ) 426 | 427 | # wd_i stores which vector (0,1) or (1,2) will make the width 428 | # One of them will definitely have slope within -1 to 1 429 | wd_i = 1 if abs(vecs[1][1]) < abs(vecs[1][0]) else 0 430 | ht_i = (wd_i + 1) % 2 431 | 432 | angle = np.degrees(np.arctan2(vecs[wd_i][1], vecs[wd_i][0])) 433 | width = np.linalg.norm(vecs[wd_i]) 434 | height = np.linalg.norm(vecs[ht_i]) 435 | size = Size(width, height) 436 | 437 | return cls(center, size, angle) 438 | 439 | 440 | class TermCriteria(NamedTuple): 441 | class Type(enum.IntFlag): # type: ignore[misc] 442 | COUNT: int = cv.TermCriteria_COUNT 443 | MAX_ITER: int = cv.TermCriteria_MAX_ITER 444 | EPS: int = cv.TermCriteria_EPS 445 | 446 | # Without this, the MAX_ITER alias won't show up in some interpreters 447 | Type._member_names_ = ["COUNT", "MAX_ITER", "EPS"] # type: ignore[misc] 448 | 449 | type: int = Type.COUNT 450 | max_count: int = 0 451 | epsilon: float = 0 452 | 453 | def is_valid(self) -> bool: 454 | is_count = bool(self.type & self.Type.COUNT) and self.max_count > 0 455 | is_eps = bool(self.type & self.Type.EPS) and not np.isnan(self.epsilon) 456 | return is_count or is_eps 457 | -------------------------------------------------------------------------------- /cvpack/extras/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alkasm/cvpack/7d7aceddf18aca03ac77ccf8e0da7f71ef6674a3/cvpack/extras/__init__.py -------------------------------------------------------------------------------- /cvpack/extras/clustering.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | 7 | TWO_PI = 2 * np.pi 8 | 9 | 10 | def kmeans_periodic(columns, intervals, data, *args, **kwargs): 11 | """Runs kmeans with periodicity in a subset of dimensions. 12 | 13 | Transforms columns with periodicity on the specified intervals into two 14 | columns with coordinates on the unit circle for kmeans. After running 15 | through kmeans, the centers are transformed back to the range specified 16 | by the intervals. 17 | 18 | Arguments 19 | --------- 20 | columns : sequence 21 | Sequence of indexes specifying the columns that have periodic data 22 | intervals : sequence of length-2 sequences 23 | Sequence of (min, max) intervals, one interval per column 24 | See help(cv2.kmeans) for all other arguments, which are passed through. 25 | 26 | Returns 27 | ------- 28 | See help(cv2.kmeans) for outputs, which are passed through; except centers, 29 | which is modified so that it returns centers corresponding to the input 30 | data, instead of the transformed data. 31 | 32 | Raises 33 | ------ 34 | cv2.error 35 | If len(columns) != len(intervals) 36 | """ 37 | 38 | # Check each periodic column has an associated interval 39 | if len(columns) != len(intervals): 40 | raise cv2.error("number of intervals must be equal to number of columns") 41 | 42 | ndims = data.shape[1] 43 | ys = [] 44 | 45 | # transform each periodic column into two columns with the x and y coordinate 46 | # of the angles for kmeans; x coord at original column, ys are appended 47 | for col, interval in zip(columns, intervals): 48 | a, b = min(interval), max(interval) 49 | width = b - a 50 | data[:, col] = TWO_PI * (data[:, col] - a) / width % TWO_PI 51 | ys.append(width * np.sin(data[:, col])) 52 | data[:, col] = width * np.cos(data[:, col]) 53 | 54 | # append the ys to the end 55 | ys = np.array(ys).transpose() 56 | data = np.hstack((data, ys)).astype(np.float32) 57 | 58 | # run kmeans 59 | retval, bestLabels, centers = cv2.kmeans(data, *args, **kwargs) 60 | 61 | # transform the centers back to range they came from 62 | for i, (col, interval) in enumerate(zip(columns, intervals)): 63 | a, b = min(interval), max(interval) 64 | angles = np.arctan2(centers[:, ndims + i], centers[:, col]) % TWO_PI 65 | centers[:, col] = a + (b - a) * angles / TWO_PI 66 | centers = centers[:, :ndims] 67 | 68 | return retval, bestLabels, centers 69 | -------------------------------------------------------------------------------- /cvpack/extras/imgproc.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | import cv2 as cv 4 | import numpy as np 5 | 6 | 7 | def resize_pad(img, size, pad_color=(0, 0, 0), interpolation=None): 8 | 9 | h, w = img.shape[:2] 10 | sw, sh = size 11 | aspect = w / h 12 | 13 | if interpolation is None: 14 | interpolation = cv.INTER_AREA if (h > sh) or (w > sw) else cv.INTER_CUBIC 15 | 16 | # compute scaling and pad sizing 17 | if aspect > 1: 18 | nh = int(np.round(sw / aspect)) 19 | new_size = (sw, nh) 20 | pad_vert = (sh - nh) // 2 21 | padding = (pad_vert, sh - pad_vert, 0, 0) 22 | elif aspect < 1: 23 | nw = int(np.round(sh * aspect)) 24 | new_size = (nw, sh) 25 | pad_horz = (sw - nw) // 2 26 | padding = (0, 0, pad_horz, sw - pad_horz) 27 | else: 28 | new_size = (sw, sh) 29 | padding = (0, 0, 0, 0) 30 | 31 | # scale and pad 32 | scaled_img = cv.resize(img, new_size, interpolation=interpolation) 33 | scaled_img = cv.copyMakeBorder( 34 | scaled_img, *padding, borderType=cv.BORDER_CONSTANT, value=pad_color 35 | ) 36 | 37 | return scaled_img 38 | 39 | 40 | def circular_gradient(w, h, center=None, radius=None, invert=False): 41 | """Makes a gradient, white at the center point, fading out to entirely black 42 | at radius. Colors are flipped if invert == True. 43 | 44 | By default, center is the center of (w, h) and radius is the distance to 45 | the nearer edge. 46 | """ 47 | center = center or (w // 2, h // 2) 48 | radius = radius or min(w, h) // 2 49 | 50 | mask = np.zeros((h, w), dtype=np.uint8) 51 | mask = cv.circle(mask, center=center, radius=radius, color=255, thickness=-1) 52 | grad = cv.distanceTransform(mask, cv.DIST_L2, 3) 53 | grad = grad / grad.max() 54 | 55 | if invert: 56 | grad = 1 - grad 57 | 58 | return grad 59 | 60 | 61 | def circular_mask(w, h, center=None, radius=None): 62 | """Creates a circular binary/logical mask. 63 | 64 | Parameters 65 | ========== 66 | w, h : int 67 | Width and height of the mask. 68 | center : tuple(numeric, numeric) (default: (h/2, w/2)) 69 | The center of the circular mask. 70 | radius : tuple(numeric, numeric) (default: nearest image bound to center) 71 | Radius of the circle extending from the center point. Note that pixels 72 | that touch the radius will be included. 73 | 74 | Returns 75 | ======= 76 | mask : np.ndarray 77 | Boolean array with shape (h, w) where values are True inside the circle, 78 | False otherwise. 79 | 80 | Notes 81 | ===== 82 | From this Stack Overflow answer: https://stackoverflow.com/a/44874588/5087436 83 | """ 84 | 85 | if center is None: # use the middle of the image 86 | center = [int(w / 2), int(h / 2)] 87 | if radius is None: # use the smallest distance between the center and image walls 88 | radius = min(center[0], center[1], w - center[0], h - center[1]) 89 | 90 | Y, X = np.ogrid[:h, :w] 91 | dist_from_center = np.sqrt((X - center[0]) ** 2 + (Y - center[1]) ** 2) 92 | 93 | return dist_from_center <= radius 94 | -------------------------------------------------------------------------------- /cvpack/extras/iterators.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | from itertools import tee 4 | 5 | 6 | def pairwise(iterable): 7 | """s -> (s0, s1), (s1, s2), ...""" 8 | a, b = tee(iterable) 9 | next(b, None) 10 | return zip(a, b) 11 | 12 | 13 | class LineIterator: 14 | """Iterates through the pixels in a line between two points. 15 | 16 | The line will be clipped on the image boundaries 17 | The line can be 4- or 8-connected. 18 | If left_to_right=True, then the iteration is always 19 | done from the left-most point to the right most, 20 | not to depend on the ordering of pt1 and pt2 parameters. 21 | """ 22 | 23 | def __init__(self, img, pt1, pt2, connectivity=8, left_to_right=False): 24 | 25 | count = -1 26 | cols, rows = img.shape[:2] 27 | self.step = cols 28 | x1, y1 = pt1 29 | x2, y2 = pt2 30 | 31 | if not (0, 0, 0, 0) <= (x1, x2, y1, y2) < (cols, cols, rows, rows): 32 | clip, pt1, pt2 = cv2.clipLine((0, 0, cols, rows), pt1, pt2) 33 | x1, y1 = pt1 34 | x2, y2 = pt2 35 | if not clip: 36 | self.count = 0 37 | return 38 | 39 | bt_pix = 1 40 | istep = self.step 41 | 42 | dx = x2 - x1 43 | dy = y2 - y1 44 | s = -1 if dx < 0 else 0 45 | 46 | if left_to_right: 47 | dx = (dx ^ s) - s 48 | dy = (dy ^ s) - s 49 | x1 ^= (x1 ^ x2) & s 50 | y1 ^= (y1 ^ y2) & s 51 | else: 52 | dx = (dx ^ s) - s 53 | bt_pix = (bt_pix ^ s) - s 54 | 55 | self.index = y1 * istep + x1 56 | 57 | s = -1 if dy < 0 else 0 58 | dy = (dy ^ s) - s 59 | istep = (istep ^ s) - s 60 | 61 | # conditional swaps 62 | s = -1 if dy > dx else 0 63 | dx ^= dy & s 64 | dy ^= dx & s 65 | dx ^= dy & s 66 | 67 | bt_pix ^= istep & s 68 | istep ^= bt_pix & s 69 | bt_pix ^= istep & s 70 | 71 | assert dx >= 0 and dy >= 0 72 | if connectivity == 8: 73 | self.err = dx - (dy + dy) 74 | self.plus_delta = dx + dx 75 | self.plus_step = int(istep) 76 | self.count = dx + 1 77 | else: 78 | self.err = 0 79 | self.plus_delta = (dx + dx) + (dy + dy) 80 | self.plus_step = int(istep - bt_pix) 81 | self.count = dx + dy + 1 82 | self.minus_delta = -(dy + dy) 83 | self.minus_step = int(bt_pix) 84 | 85 | def __iter__(self): 86 | for i in range(self.count): 87 | y = int(self.index / self.step) 88 | x = int(self.index - y * self.step) 89 | yield (x, y) 90 | mask = -1 if self.err < 0 else 0 91 | self.err += self.minus_delta + (self.plus_delta & mask) 92 | self.index += self.minus_step + (self.plus_step & mask) 93 | -------------------------------------------------------------------------------- /cvpack/extras/matlab.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | import numpy as np 3 | import cv2 4 | 5 | 6 | def bwdist( 7 | img, 8 | method=cv2.DIST_L2, 9 | dist_mask=cv2.DIST_MASK_5, 10 | label_type=cv2.DIST_LABEL_CCOMP, 11 | ravel=True, 12 | ): 13 | """Mimics Matlab's bwdist function, similar to OpenCV's distanceTransform() 14 | but with different output. 15 | 16 | https://www.mathworks.com/help/images/ref/bwdist.html 17 | 18 | Available metrics: 19 | https://docs.opencv.org/3.4/d7/d1b/group__imgproc__misc.html#gaa2bfbebbc5c320526897996aafa1d8eb 20 | 21 | Available distance masks: 22 | https://docs.opencv.org/3.4/d7/d1b/group__imgproc__misc.html#gaaa68392323ccf7fad87570e41259b497 23 | 24 | Available label types: 25 | https://docs.opencv.org/3.4/d7/d1b/group__imgproc__misc.html#ga3fe343d63844c40318ee627bd1c1c42f 26 | """ 27 | flip = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV)[1] 28 | dist, labeled = cv2.distanceTransformWithLabels(flip, method, dist_mask) 29 | 30 | if ravel: # return linear indices if ravel == True (default) 31 | idx = np.zeros(img.shape, dtype=np.intp) 32 | idx_func = np.flatnonzero 33 | else: # return two-channel indices if ravel == False 34 | idx = np.zeros((*img.shape, 2), dtype=np.intp) 35 | idx_func = lambda masked: np.dstack(np.where(masked)) 36 | 37 | for l in np.unique(labeled): 38 | mask = labeled == l 39 | idx[mask] = idx_func(img * mask) 40 | return dist, idx 41 | 42 | 43 | def imfill(bin_img): 44 | """Fills holes in the input binary image. 45 | 46 | Achieves the same output as the imfill(BW, 'holes') variant. 47 | 48 | https://www.mathworks.com/help/images/ref/imfill.html 49 | """ 50 | contours = cv2.findContours(bin_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[1] 51 | return cv2.drawContours(bin_img, contours, -1, 255, -1) 52 | -------------------------------------------------------------------------------- /cvpack/imgio.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from concurrent.futures import ThreadPoolExecutor 3 | from http.server import BaseHTTPRequestHandler, HTTPServer 4 | from pathlib import Path 5 | from typing import cast, Any, Optional, Tuple, Union 6 | import urllib.request 7 | import webbrowser 8 | import cv2 as cv 9 | import numpy as np 10 | 11 | __all__ = [ 12 | "imread_url", 13 | "imwrite", 14 | "imshow", 15 | "imshow_jupyter", 16 | "imshow_browser", 17 | "color_labels", 18 | "normalize", 19 | "enlarge", 20 | "add_grid", 21 | ] 22 | 23 | 24 | def imread(imgpath: Union[Path, str], *args: Any, **kwargs: Any) -> np.ndarray: 25 | """Reads an image, providing helpful errors on failed reads. 26 | 27 | Allows pathlib.Path objects as well as strings for the imgpath. 28 | """ 29 | img = cv.imread(str(imgpath), *args, **kwargs) 30 | if img is None: 31 | p = Path(imgpath) 32 | if p.exists(): 33 | raise ValueError("Image is empty!") 34 | raise FileNotFoundError(f"Image path {p.absolute()} doesn't exist!") 35 | 36 | return img 37 | 38 | 39 | def imread_url(url: str, *args: Any, **kwargs: Any) -> np.ndarray: 40 | """Reads an image from a given url. 41 | 42 | Additional args and kwargs passed onto cv.imdecode() 43 | """ 44 | r = urllib.request.urlopen(url) 45 | content_type = r.headers.get_content_maintype() 46 | if content_type != "image": 47 | raise ValueError(f"Unknown content type {content_type}.") 48 | buf = np.frombuffer(r.read(), np.uint8) 49 | return cv.imdecode(buf, *args, **kwargs) 50 | 51 | 52 | def imwrite( 53 | imgpath: Union[Path, str], img: np.ndarray, *args: Any, **kwargs: Any 54 | ) -> bool: 55 | """Writes an image, providing helpful errors on failed writes. 56 | 57 | Allows pathlib.Path objects as well as strings for the imgpath. 58 | Will create the directories included in the imgpath if they don't exist. 59 | 60 | Additional args and kwargs passed to cv.imwrite(). 61 | """ 62 | if img is None: 63 | raise ValueError("Image is empty!") 64 | Path(imgpath).parent.mkdir(parents=True, exist_ok=True) 65 | imgpath = str(imgpath) 66 | return cast(bool, cv.imwrite(imgpath, img, *args, **kwargs)) 67 | 68 | 69 | def imshow(img: np.ndarray, wait: int = 0, window_name: str = "") -> int: 70 | """Combines cv.imshow() and cv.waitkey(), and checks for bad image reads.""" 71 | if img is None: 72 | raise ValueError( 73 | "Image is empty; ensure you are reading from the correct path." 74 | ) 75 | cv.imshow(window_name, img) 76 | return cast(int, cv.waitKey(wait) & 0xFF) 77 | 78 | 79 | def imshow_jupyter(img: np.ndarray) -> None: 80 | """Shows an image in a Jupyter notebook. 81 | 82 | Raises ValueError if img is None or if img cannot be encoded. 83 | """ 84 | if img is None: 85 | raise ValueError("Image has no data (img is None).") 86 | 87 | success, encoded = cv.imencode(".png", img) 88 | if not success: 89 | raise ValueError("Error encoding image.") 90 | 91 | try: 92 | from IPython.display import Image, display 93 | 94 | display(Image(encoded)) 95 | except ImportError: 96 | print("You must have IPython installed to use the IPython display.") 97 | raise 98 | 99 | 100 | def imshow_browser( 101 | img: np.ndarray, host: str = "localhost", port: int = 32830, route: str = "imshow" 102 | ) -> None: 103 | """Display an image in a browser. 104 | 105 | Spins up a single-request server to serve the image. 106 | Opens the browser to make that request, then shuts down the server. 107 | """ 108 | 109 | class ImshowRequestHandler(_ImshowRequestHandler): 110 | imshow_img = img 111 | imshow_route = route 112 | 113 | server = HTTPServer((host, port), ImshowRequestHandler) 114 | with ThreadPoolExecutor(max_workers=1) as executor: 115 | # handle_request() blocks, so submit in an executor. 116 | # the browser can open the window and get served the image, 117 | # at which point the submitted task is completed. 118 | executor.submit(server.handle_request) 119 | webbrowser.open_new(f"http://{host}:{port}/{route}") 120 | 121 | 122 | def _html_imshow(img: np.ndarray) -> str: 123 | success, encoded_img = cv.imencode(".png", img) 124 | 125 | html = """ 126 | cvpack/imshow 127 | 128 | 129 | 130 | 131 | 132 |
133 | 134 |
135 | 136 | 137 | """ 138 | 139 | b64_encoded_img = base64.b64encode(encoded_img).decode() if success else "" 140 | return html.format(encoded_img=b64_encoded_img) 141 | 142 | 143 | class _ImshowRequestHandler(BaseHTTPRequestHandler): 144 | imshow_route: str = "" 145 | imshow_img: Optional[np.ndarray] = None 146 | 147 | # handle GET request from browser 148 | def do_GET(self) -> None: 149 | self.send_response(200) 150 | if self.path == f"/{self.imshow_route}": 151 | self.send_header("Content-type", "text/html") 152 | self.end_headers() 153 | self.wfile.write(_html_imshow(self.imshow_img).encode()) 154 | return 155 | 156 | # remove logging statements by returning nothing here 157 | def log_message(self, format: str, *args: Any) -> None: 158 | return 159 | 160 | 161 | def color_labels(labels: np.ndarray) -> np.ndarray: 162 | # Map component labels to hue val 163 | label_hue = np.uint8(179 * labels / np.max(labels)) 164 | blank_ch = 255 * np.ones_like(label_hue) 165 | labeled_img = cv.merge([label_hue, blank_ch, blank_ch]) 166 | 167 | # cvt to BGR for display 168 | labeled_img = cv.cvtColor(labeled_img, cv.COLOR_HSV2BGR) 169 | 170 | # set bg label to black 171 | labeled_img[label_hue == 0] = 0 172 | return labeled_img 173 | 174 | 175 | def normalize(img: np.ndarray) -> np.ndarray: 176 | return cv.normalize(img, None, 0, 255, cv.NORM_MINMAX, cv.CV_8U) 177 | 178 | 179 | def enlarge(img: np.ndarray, scale: int = 10) -> np.ndarray: 180 | return cv.resize(img, None, fx=scale, fy=scale, interpolation=cv.INTER_NEAREST) 181 | 182 | 183 | def add_grid( 184 | img: np.ndarray, 185 | spacing: int = 10, 186 | color: Union[float, Tuple[float, float, float]] = 200, 187 | ) -> np.ndarray: 188 | viz = img.copy() 189 | h, w = img.shape[:2] 190 | 191 | partial_grid = np.zeros((spacing, spacing), dtype=bool) 192 | partial_grid[0, :] = True 193 | partial_grid[:, 0] = True 194 | gridlines = np.tile(partial_grid, (h // spacing, w // spacing)) 195 | viz[gridlines] = color 196 | 197 | pad_sizes = ((0, 1), (0, 1), (0, 0)) if len(img.shape) == 3 else ((0, 1), (0, 1)) 198 | viz = np.pad(viz, pad_sizes) 199 | viz[-1, :] = color 200 | viz[:, -1] = color 201 | 202 | return viz 203 | -------------------------------------------------------------------------------- /cvpack/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alkasm/cvpack/7d7aceddf18aca03ac77ccf8e0da7f71ef6674a3/cvpack/py.typed -------------------------------------------------------------------------------- /cvpack/videoio.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Iterable, Optional, Type, Union, cast 2 | from pathlib import Path 3 | import cv2 as cv 4 | import numpy as np 5 | from .cvtypes import Size 6 | 7 | 8 | class VideoCaptureProperty: 9 | """Descriptors to alias cap.get(cv.CAP_PROP_*) and cap.set(cv.CAP_PROP_*, value). 10 | 11 | Raises AttributeError when setting if that property is not supported. 12 | """ 13 | 14 | _set_err: str = ( 15 | "Unable to set the property {p}. The property might not be supported by\n" 16 | "the backend used by the cv.VideoCapture() instance." 17 | ) 18 | 19 | def __init__(self, prop: str): 20 | self.prop = prop 21 | 22 | def __get__( 23 | self, obj: "VideoCapture", objtype: Optional[Type["VideoCapture"]] = None 24 | ) -> float: 25 | return cast(float, obj.cap.get(self.prop)) 26 | 27 | def __set__(self, obj: "VideoCapture", value: float) -> None: 28 | if not obj.cap.set(self.prop, value): 29 | raise AttributeError(self._set_err.format(p=self.prop)) 30 | 31 | 32 | class VideoCapture: 33 | """An adapter for `cv.VideoCapture`, giving a more Pythonic interface.""" 34 | 35 | pos_msec = VideoCaptureProperty(cv.CAP_PROP_POS_MSEC) 36 | pos_frames = VideoCaptureProperty(cv.CAP_PROP_POS_FRAMES) 37 | pos_avi_ratio = VideoCaptureProperty(cv.CAP_PROP_POS_AVI_RATIO) 38 | frame_width = VideoCaptureProperty(cv.CAP_PROP_FRAME_WIDTH) 39 | frame_height = VideoCaptureProperty(cv.CAP_PROP_FRAME_HEIGHT) 40 | fps = VideoCaptureProperty(cv.CAP_PROP_FPS) 41 | fourcc = VideoCaptureProperty(cv.CAP_PROP_FOURCC) 42 | frame_count = VideoCaptureProperty(cv.CAP_PROP_FRAME_COUNT) 43 | format = VideoCaptureProperty(cv.CAP_PROP_FORMAT) 44 | 45 | def __init__(self, *args: Any, **kwargs: Any) -> None: 46 | self.cap = cv.VideoCapture(*args, **kwargs) 47 | if not self.cap.isOpened(): 48 | raise ValueError( 49 | f"Unable to open video source: args: {args} kwargs: {kwargs}" 50 | ) 51 | 52 | def __getattr__(self, key: str) -> Any: 53 | return getattr(self.cap, key) 54 | 55 | def __iter__(self) -> Iterable[np.ndarray]: 56 | """Iterate through frames in the video.""" 57 | noread = (False, None) 58 | if self.cap.isOpened(): 59 | for _, frame in iter(self.cap.read, noread): 60 | yield frame 61 | 62 | def __enter__(self) -> "VideoCapture": 63 | """Enter the context manager.""" 64 | return self 65 | 66 | def __exit__(self, *args: Any, **kwargs: Any) -> None: 67 | """Releases the video capture object on exiting the context manager.""" 68 | self.cap.release() 69 | 70 | 71 | class VideoWriter: 72 | filename: str 73 | fourcc: int 74 | fps: float 75 | 76 | _nowriter = object() 77 | 78 | def __init__( 79 | self, 80 | filename: Union[Path, str], 81 | fourcc: Union[int, Iterable[str]] = "mp4v", 82 | fps: float = 30, 83 | frameSize: Any = None, 84 | **kwargs: Any, 85 | ) -> None: 86 | self.filename = str(filename) 87 | self.fourcc = ( 88 | fourcc if isinstance(fourcc, int) else cv.VideoWriter_fourcc(*fourcc) 89 | ) 90 | self.fps = fps 91 | self._kwargs = kwargs 92 | 93 | # wait to create writer based on first frame if size is not provided 94 | self._writer = ( 95 | self._nowriter if frameSize is None else self._makewriter(frameSize) 96 | ) 97 | 98 | def __enter__(self) -> "VideoWriter": 99 | return self 100 | 101 | def __exit__(self, *args: Any, **kwargs: Any) -> None: 102 | self.release() 103 | 104 | def _makewriter(self, frame_size: Any) -> cv.VideoWriter: 105 | return cv.VideoWriter( 106 | filename=self.filename, 107 | fourcc=self.fourcc, 108 | fps=self.fps, 109 | frameSize=frame_size, 110 | **self._kwargs, 111 | ) 112 | 113 | def write(self, frame: np.ndarray) -> bool: 114 | try: 115 | return cast(bool, self._writer.write(frame)) 116 | except AttributeError as e: 117 | if self._writer is self._nowriter: 118 | size = Size.from_image(frame) 119 | self._writer = self._makewriter(size) 120 | return self.write(frame) 121 | else: 122 | raise e 123 | 124 | def release(self) -> None: 125 | try: 126 | self._writer.release() 127 | except AttributeError as e: 128 | if self._writer is not self._nowriter: 129 | raise e 130 | 131 | 132 | class VideoPlayer: 133 | cap: VideoCapture 134 | rate: float 135 | 136 | _actions = {"quit": {ord("\x1b"), ord("q")}} 137 | 138 | def __init__(self, cap: VideoCapture) -> None: 139 | self.cap = cap 140 | self.rate = int(1000 / self.cap.fps) 141 | 142 | def play( 143 | self, 144 | window_name: str = "VideoPlayer", 145 | framefunc: Optional[Callable[[np.ndarray], np.ndarray]] = None, 146 | loop: bool = False, 147 | ) -> None: 148 | """Plays through the video file with OpenCV's imshow().""" 149 | frames = ( 150 | self.cap.__iter__() 151 | if framefunc is None 152 | else map(framefunc, self.cap.__iter__()) 153 | ) 154 | for frame in frames: 155 | cv.imshow(window_name, frame) 156 | key = cv.waitKey(self.rate) & 0xFF 157 | if key in self._actions["quit"]: 158 | return 159 | 160 | if loop: 161 | return self.play(window_name, framefunc, loop) 162 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | # Global options: 2 | 3 | [mypy] 4 | python_version = 3.6 5 | strict = True 6 | 7 | # Per-module options: 8 | 9 | [mypy-cv2.*] 10 | ignore_missing_imports = True 11 | 12 | [mypy-numpy.*] 13 | ignore_missing_imports = True 14 | 15 | [mypy-IPython.display.*] 16 | ignore_missing_imports = True 17 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "appdirs" 3 | version = "1.4.4" 4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "appnope" 11 | version = "0.1.2" 12 | description = "Disable App Nap on macOS >= 10.9" 13 | category = "main" 14 | optional = false 15 | python-versions = "*" 16 | 17 | [[package]] 18 | name = "atomicwrites" 19 | version = "1.4.0" 20 | description = "Atomic file writes." 21 | category = "dev" 22 | optional = false 23 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 24 | 25 | [[package]] 26 | name = "attrs" 27 | version = "20.3.0" 28 | description = "Classes Without Boilerplate" 29 | category = "dev" 30 | optional = false 31 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 32 | 33 | [package.extras] 34 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] 35 | docs = ["furo", "sphinx", "zope.interface"] 36 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 37 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] 38 | 39 | [[package]] 40 | name = "backcall" 41 | version = "0.2.0" 42 | description = "Specifications for callback functions passed in to an API" 43 | category = "main" 44 | optional = false 45 | python-versions = "*" 46 | 47 | [[package]] 48 | name = "black" 49 | version = "19.10b0" 50 | description = "The uncompromising code formatter." 51 | category = "dev" 52 | optional = false 53 | python-versions = ">=3.6" 54 | 55 | [package.dependencies] 56 | appdirs = "*" 57 | attrs = ">=18.1.0" 58 | click = ">=6.5" 59 | pathspec = ">=0.6,<1" 60 | regex = "*" 61 | toml = ">=0.9.4" 62 | typed-ast = ">=1.4.0" 63 | 64 | [package.extras] 65 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 66 | 67 | [[package]] 68 | name = "click" 69 | version = "7.1.2" 70 | description = "Composable command line interface toolkit" 71 | category = "dev" 72 | optional = false 73 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 74 | 75 | [[package]] 76 | name = "colorama" 77 | version = "0.4.4" 78 | description = "Cross-platform colored terminal text." 79 | category = "main" 80 | optional = false 81 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 82 | 83 | [[package]] 84 | name = "decorator" 85 | version = "5.0.7" 86 | description = "Decorators for Humans" 87 | category = "main" 88 | optional = false 89 | python-versions = ">=3.5" 90 | 91 | [[package]] 92 | name = "hypothesis" 93 | version = "5.49.0" 94 | description = "A library for property-based testing" 95 | category = "dev" 96 | optional = false 97 | python-versions = ">=3.6" 98 | 99 | [package.dependencies] 100 | attrs = ">=19.2.0" 101 | sortedcontainers = ">=2.1.0,<3.0.0" 102 | 103 | [package.extras] 104 | all = ["black (>=19.10b0)", "click (>=7.0)", "django (>=2.2)", "dpcontracts (>=0.4)", "lark-parser (>=0.6.5)", "libcst (>=0.3.16)", "numpy (>=1.9.0)", "pandas (>=0.25)", "pytest (>=4.3)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "importlib-resources (>=3.3.0)", "importlib-metadata", "backports.zoneinfo (>=0.2.1)", "tzdata (>=2020.4)"] 105 | cli = ["click (>=7.0)", "black (>=19.10b0)"] 106 | codemods = ["libcst (>=0.3.16)"] 107 | dateutil = ["python-dateutil (>=1.4)"] 108 | django = ["pytz (>=2014.1)", "django (>=2.2)"] 109 | dpcontracts = ["dpcontracts (>=0.4)"] 110 | ghostwriter = ["black (>=19.10b0)"] 111 | lark = ["lark-parser (>=0.6.5)"] 112 | numpy = ["numpy (>=1.9.0)"] 113 | pandas = ["pandas (>=0.25)"] 114 | pytest = ["pytest (>=4.3)"] 115 | pytz = ["pytz (>=2014.1)"] 116 | redis = ["redis (>=3.0.0)"] 117 | zoneinfo = ["importlib-resources (>=3.3.0)", "backports.zoneinfo (>=0.2.1)", "tzdata (>=2020.4)"] 118 | 119 | [[package]] 120 | name = "importlib-metadata" 121 | version = "4.0.1" 122 | description = "Read metadata from Python packages" 123 | category = "dev" 124 | optional = false 125 | python-versions = ">=3.6" 126 | 127 | [package.dependencies] 128 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 129 | zipp = ">=0.5" 130 | 131 | [package.extras] 132 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 133 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 134 | 135 | [[package]] 136 | name = "ipython" 137 | version = "7.16.1" 138 | description = "IPython: Productive Interactive Computing" 139 | category = "main" 140 | optional = false 141 | python-versions = ">=3.6" 142 | 143 | [package.dependencies] 144 | appnope = {version = "*", markers = "sys_platform == \"darwin\""} 145 | backcall = "*" 146 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 147 | decorator = "*" 148 | jedi = ">=0.10" 149 | pexpect = {version = "*", markers = "sys_platform != \"win32\""} 150 | pickleshare = "*" 151 | prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" 152 | pygments = "*" 153 | traitlets = ">=4.2" 154 | 155 | [package.extras] 156 | all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"] 157 | doc = ["Sphinx (>=1.3)"] 158 | kernel = ["ipykernel"] 159 | nbconvert = ["nbconvert"] 160 | nbformat = ["nbformat"] 161 | notebook = ["notebook", "ipywidgets"] 162 | parallel = ["ipyparallel"] 163 | qtconsole = ["qtconsole"] 164 | test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.14)"] 165 | 166 | [[package]] 167 | name = "ipython-genutils" 168 | version = "0.2.0" 169 | description = "Vestigial utilities from IPython" 170 | category = "main" 171 | optional = false 172 | python-versions = "*" 173 | 174 | [[package]] 175 | name = "jedi" 176 | version = "0.18.0" 177 | description = "An autocompletion tool for Python that can be used for text editors." 178 | category = "main" 179 | optional = false 180 | python-versions = ">=3.6" 181 | 182 | [package.dependencies] 183 | parso = ">=0.8.0,<0.9.0" 184 | 185 | [package.extras] 186 | qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] 187 | testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<6.0.0)"] 188 | 189 | [[package]] 190 | name = "more-itertools" 191 | version = "8.7.0" 192 | description = "More routines for operating on iterables, beyond itertools" 193 | category = "dev" 194 | optional = false 195 | python-versions = ">=3.5" 196 | 197 | [[package]] 198 | name = "mypy" 199 | version = "0.790" 200 | description = "Optional static typing for Python" 201 | category = "dev" 202 | optional = false 203 | python-versions = ">=3.5" 204 | 205 | [package.dependencies] 206 | mypy-extensions = ">=0.4.3,<0.5.0" 207 | typed-ast = ">=1.4.0,<1.5.0" 208 | typing-extensions = ">=3.7.4" 209 | 210 | [package.extras] 211 | dmypy = ["psutil (>=4.0)"] 212 | 213 | [[package]] 214 | name = "mypy-extensions" 215 | version = "0.4.3" 216 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 217 | category = "dev" 218 | optional = false 219 | python-versions = "*" 220 | 221 | [[package]] 222 | name = "numpy" 223 | version = "1.19.5" 224 | description = "NumPy is the fundamental package for array computing with Python." 225 | category = "main" 226 | optional = false 227 | python-versions = ">=3.6" 228 | 229 | [[package]] 230 | name = "opencv-python" 231 | version = "4.5.1.48" 232 | description = "Wrapper package for OpenCV python bindings." 233 | category = "main" 234 | optional = false 235 | python-versions = ">=3.6" 236 | 237 | [[package]] 238 | name = "packaging" 239 | version = "20.9" 240 | description = "Core utilities for Python packages" 241 | category = "dev" 242 | optional = false 243 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 244 | 245 | [package.dependencies] 246 | pyparsing = ">=2.0.2" 247 | 248 | [[package]] 249 | name = "parso" 250 | version = "0.8.2" 251 | description = "A Python Parser" 252 | category = "main" 253 | optional = false 254 | python-versions = ">=3.6" 255 | 256 | [package.extras] 257 | qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] 258 | testing = ["docopt", "pytest (<6.0.0)"] 259 | 260 | [[package]] 261 | name = "pathspec" 262 | version = "0.8.1" 263 | description = "Utility library for gitignore style pattern matching of file paths." 264 | category = "dev" 265 | optional = false 266 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 267 | 268 | [[package]] 269 | name = "pexpect" 270 | version = "4.8.0" 271 | description = "Pexpect allows easy control of interactive console applications." 272 | category = "main" 273 | optional = false 274 | python-versions = "*" 275 | 276 | [package.dependencies] 277 | ptyprocess = ">=0.5" 278 | 279 | [[package]] 280 | name = "pickleshare" 281 | version = "0.7.5" 282 | description = "Tiny 'shelve'-like database with concurrency support" 283 | category = "main" 284 | optional = false 285 | python-versions = "*" 286 | 287 | [[package]] 288 | name = "pluggy" 289 | version = "0.13.1" 290 | description = "plugin and hook calling mechanisms for python" 291 | category = "dev" 292 | optional = false 293 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 294 | 295 | [package.dependencies] 296 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 297 | 298 | [package.extras] 299 | dev = ["pre-commit", "tox"] 300 | 301 | [[package]] 302 | name = "prompt-toolkit" 303 | version = "3.0.3" 304 | description = "Library for building powerful interactive command lines in Python" 305 | category = "main" 306 | optional = false 307 | python-versions = ">=3.6" 308 | 309 | [package.dependencies] 310 | wcwidth = "*" 311 | 312 | [[package]] 313 | name = "ptyprocess" 314 | version = "0.7.0" 315 | description = "Run a subprocess in a pseudo terminal" 316 | category = "main" 317 | optional = false 318 | python-versions = "*" 319 | 320 | [[package]] 321 | name = "py" 322 | version = "1.10.0" 323 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 324 | category = "dev" 325 | optional = false 326 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 327 | 328 | [[package]] 329 | name = "pygments" 330 | version = "2.8.1" 331 | description = "Pygments is a syntax highlighting package written in Python." 332 | category = "main" 333 | optional = false 334 | python-versions = ">=3.5" 335 | 336 | [[package]] 337 | name = "pyparsing" 338 | version = "2.4.7" 339 | description = "Python parsing module" 340 | category = "dev" 341 | optional = false 342 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 343 | 344 | [[package]] 345 | name = "pytest" 346 | version = "5.4.3" 347 | description = "pytest: simple powerful testing with Python" 348 | category = "dev" 349 | optional = false 350 | python-versions = ">=3.5" 351 | 352 | [package.dependencies] 353 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 354 | attrs = ">=17.4.0" 355 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 356 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 357 | more-itertools = ">=4.0.0" 358 | packaging = "*" 359 | pluggy = ">=0.12,<1.0" 360 | py = ">=1.5.0" 361 | wcwidth = "*" 362 | 363 | [package.extras] 364 | checkqa-mypy = ["mypy (==v0.761)"] 365 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 366 | 367 | [[package]] 368 | name = "regex" 369 | version = "2021.4.4" 370 | description = "Alternative regular expression module, to replace re." 371 | category = "dev" 372 | optional = false 373 | python-versions = "*" 374 | 375 | [[package]] 376 | name = "six" 377 | version = "1.15.0" 378 | description = "Python 2 and 3 compatibility utilities" 379 | category = "main" 380 | optional = false 381 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 382 | 383 | [[package]] 384 | name = "sortedcontainers" 385 | version = "2.3.0" 386 | description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" 387 | category = "dev" 388 | optional = false 389 | python-versions = "*" 390 | 391 | [[package]] 392 | name = "toml" 393 | version = "0.10.2" 394 | description = "Python Library for Tom's Obvious, Minimal Language" 395 | category = "dev" 396 | optional = false 397 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 398 | 399 | [[package]] 400 | name = "traitlets" 401 | version = "4.3.3" 402 | description = "Traitlets Python config system" 403 | category = "main" 404 | optional = false 405 | python-versions = "*" 406 | 407 | [package.dependencies] 408 | decorator = "*" 409 | ipython-genutils = "*" 410 | six = "*" 411 | 412 | [package.extras] 413 | test = ["pytest", "mock"] 414 | 415 | [[package]] 416 | name = "typed-ast" 417 | version = "1.4.3" 418 | description = "a fork of Python 2 and 3 ast modules with type comment support" 419 | category = "dev" 420 | optional = false 421 | python-versions = "*" 422 | 423 | [[package]] 424 | name = "typing-extensions" 425 | version = "3.7.4.3" 426 | description = "Backported and Experimental Type Hints for Python 3.5+" 427 | category = "dev" 428 | optional = false 429 | python-versions = "*" 430 | 431 | [[package]] 432 | name = "wcwidth" 433 | version = "0.2.5" 434 | description = "Measures the displayed width of unicode strings in a terminal" 435 | category = "main" 436 | optional = false 437 | python-versions = "*" 438 | 439 | [[package]] 440 | name = "zipp" 441 | version = "3.4.1" 442 | description = "Backport of pathlib-compatible object wrapper for zip files" 443 | category = "dev" 444 | optional = false 445 | python-versions = ">=3.6" 446 | 447 | [package.extras] 448 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 449 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 450 | 451 | [metadata] 452 | lock-version = "1.1" 453 | python-versions = "^3.6" 454 | content-hash = "8c022a1174b1f1e7ef978468aa54d1f455ab25cace13b62ced28211ee7b71c8c" 455 | 456 | [metadata.files] 457 | appdirs = [ 458 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 459 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 460 | ] 461 | appnope = [ 462 | {file = "appnope-0.1.2-py2.py3-none-any.whl", hash = "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442"}, 463 | {file = "appnope-0.1.2.tar.gz", hash = "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"}, 464 | ] 465 | atomicwrites = [ 466 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 467 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 468 | ] 469 | attrs = [ 470 | {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, 471 | {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, 472 | ] 473 | backcall = [ 474 | {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, 475 | {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, 476 | ] 477 | black = [ 478 | {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, 479 | {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, 480 | ] 481 | click = [ 482 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 483 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 484 | ] 485 | colorama = [ 486 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 487 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 488 | ] 489 | decorator = [ 490 | {file = "decorator-5.0.7-py3-none-any.whl", hash = "sha256:945d84890bb20cc4a2f4a31fc4311c0c473af65ea318617f13a7257c9a58bc98"}, 491 | {file = "decorator-5.0.7.tar.gz", hash = "sha256:6f201a6c4dac3d187352661f508b9364ec8091217442c9478f1f83c003a0f060"}, 492 | ] 493 | hypothesis = [ 494 | {file = "hypothesis-5.49.0-py3-none-any.whl", hash = "sha256:e91111f2f01abf2566041c4c86366aa7f08bfd5b3d858cc77a545fcf67df335e"}, 495 | {file = "hypothesis-5.49.0.tar.gz", hash = "sha256:36a4d5587c34193125d654b61bf9284e24a227d1edd339c49143378658a10c7d"}, 496 | ] 497 | importlib-metadata = [ 498 | {file = "importlib_metadata-4.0.1-py3-none-any.whl", hash = "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d"}, 499 | {file = "importlib_metadata-4.0.1.tar.gz", hash = "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581"}, 500 | ] 501 | ipython = [ 502 | {file = "ipython-7.16.1-py3-none-any.whl", hash = "sha256:2dbcc8c27ca7d3cfe4fcdff7f45b27f9a8d3edfa70ff8024a71c7a8eb5f09d64"}, 503 | {file = "ipython-7.16.1.tar.gz", hash = "sha256:9f4fcb31d3b2c533333893b9172264e4821c1ac91839500f31bd43f2c59b3ccf"}, 504 | ] 505 | ipython-genutils = [ 506 | {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, 507 | {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"}, 508 | ] 509 | jedi = [ 510 | {file = "jedi-0.18.0-py2.py3-none-any.whl", hash = "sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93"}, 511 | {file = "jedi-0.18.0.tar.gz", hash = "sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707"}, 512 | ] 513 | more-itertools = [ 514 | {file = "more-itertools-8.7.0.tar.gz", hash = "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713"}, 515 | {file = "more_itertools-8.7.0-py3-none-any.whl", hash = "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced"}, 516 | ] 517 | mypy = [ 518 | {file = "mypy-0.790-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:bd03b3cf666bff8d710d633d1c56ab7facbdc204d567715cb3b9f85c6e94f669"}, 519 | {file = "mypy-0.790-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:2170492030f6faa537647d29945786d297e4862765f0b4ac5930ff62e300d802"}, 520 | {file = "mypy-0.790-cp35-cp35m-win_amd64.whl", hash = "sha256:e86bdace26c5fe9cf8cb735e7cedfe7850ad92b327ac5d797c656717d2ca66de"}, 521 | {file = "mypy-0.790-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e97e9c13d67fbe524be17e4d8025d51a7dca38f90de2e462243ab8ed8a9178d1"}, 522 | {file = "mypy-0.790-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0d34d6b122597d48a36d6c59e35341f410d4abfa771d96d04ae2c468dd201abc"}, 523 | {file = "mypy-0.790-cp36-cp36m-win_amd64.whl", hash = "sha256:72060bf64f290fb629bd4a67c707a66fd88ca26e413a91384b18db3876e57ed7"}, 524 | {file = "mypy-0.790-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:eea260feb1830a627fb526d22fbb426b750d9f5a47b624e8d5e7e004359b219c"}, 525 | {file = "mypy-0.790-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c614194e01c85bb2e551c421397e49afb2872c88b5830e3554f0519f9fb1c178"}, 526 | {file = "mypy-0.790-cp37-cp37m-win_amd64.whl", hash = "sha256:0a0d102247c16ce93c97066443d11e2d36e6cc2a32d8ccc1f705268970479324"}, 527 | {file = "mypy-0.790-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cf4e7bf7f1214826cf7333627cb2547c0db7e3078723227820d0a2490f117a01"}, 528 | {file = "mypy-0.790-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:af4e9ff1834e565f1baa74ccf7ae2564ae38c8df2a85b057af1dbbc958eb6666"}, 529 | {file = "mypy-0.790-cp38-cp38-win_amd64.whl", hash = "sha256:da56dedcd7cd502ccd3c5dddc656cb36113dd793ad466e894574125945653cea"}, 530 | {file = "mypy-0.790-py3-none-any.whl", hash = "sha256:2842d4fbd1b12ab422346376aad03ff5d0805b706102e475e962370f874a5122"}, 531 | {file = "mypy-0.790.tar.gz", hash = "sha256:2b21ba45ad9ef2e2eb88ce4aeadd0112d0f5026418324176fd494a6824b74975"}, 532 | ] 533 | mypy-extensions = [ 534 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 535 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 536 | ] 537 | numpy = [ 538 | {file = "numpy-1.19.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc6bd4fd593cb261332568485e20a0712883cf631f6f5e8e86a52caa8b2b50ff"}, 539 | {file = "numpy-1.19.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:aeb9ed923be74e659984e321f609b9ba54a48354bfd168d21a2b072ed1e833ea"}, 540 | {file = "numpy-1.19.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8b5e972b43c8fc27d56550b4120fe6257fdc15f9301914380b27f74856299fea"}, 541 | {file = "numpy-1.19.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:43d4c81d5ffdff6bae58d66a3cd7f54a7acd9a0e7b18d97abb255defc09e3140"}, 542 | {file = "numpy-1.19.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a4646724fba402aa7504cd48b4b50e783296b5e10a524c7a6da62e4a8ac9698d"}, 543 | {file = "numpy-1.19.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:2e55195bc1c6b705bfd8ad6f288b38b11b1af32f3c8289d6c50d47f950c12e76"}, 544 | {file = "numpy-1.19.5-cp36-cp36m-win32.whl", hash = "sha256:39b70c19ec771805081578cc936bbe95336798b7edf4732ed102e7a43ec5c07a"}, 545 | {file = "numpy-1.19.5-cp36-cp36m-win_amd64.whl", hash = "sha256:dbd18bcf4889b720ba13a27ec2f2aac1981bd41203b3a3b27ba7a33f88ae4827"}, 546 | {file = "numpy-1.19.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:603aa0706be710eea8884af807b1b3bc9fb2e49b9f4da439e76000f3b3c6ff0f"}, 547 | {file = "numpy-1.19.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cae865b1cae1ec2663d8ea56ef6ff185bad091a5e33ebbadd98de2cfa3fa668f"}, 548 | {file = "numpy-1.19.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:36674959eed6957e61f11c912f71e78857a8d0604171dfd9ce9ad5cbf41c511c"}, 549 | {file = "numpy-1.19.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:06fab248a088e439402141ea04f0fffb203723148f6ee791e9c75b3e9e82f080"}, 550 | {file = "numpy-1.19.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6149a185cece5ee78d1d196938b2a8f9d09f5a5ebfbba66969302a778d5ddd1d"}, 551 | {file = "numpy-1.19.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:50a4a0ad0111cc1b71fa32dedd05fa239f7fb5a43a40663269bb5dc7877cfd28"}, 552 | {file = "numpy-1.19.5-cp37-cp37m-win32.whl", hash = "sha256:d051ec1c64b85ecc69531e1137bb9751c6830772ee5c1c426dbcfe98ef5788d7"}, 553 | {file = "numpy-1.19.5-cp37-cp37m-win_amd64.whl", hash = "sha256:a12ff4c8ddfee61f90a1633a4c4afd3f7bcb32b11c52026c92a12e1325922d0d"}, 554 | {file = "numpy-1.19.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cf2402002d3d9f91c8b01e66fbb436a4ed01c6498fffed0e4c7566da1d40ee1e"}, 555 | {file = "numpy-1.19.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1ded4fce9cfaaf24e7a0ab51b7a87be9038ea1ace7f34b841fe3b6894c721d1c"}, 556 | {file = "numpy-1.19.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:012426a41bc9ab63bb158635aecccc7610e3eff5d31d1eb43bc099debc979d94"}, 557 | {file = "numpy-1.19.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:759e4095edc3c1b3ac031f34d9459fa781777a93ccc633a472a5468587a190ff"}, 558 | {file = "numpy-1.19.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:a9d17f2be3b427fbb2bce61e596cf555d6f8a56c222bd2ca148baeeb5e5c783c"}, 559 | {file = "numpy-1.19.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:99abf4f353c3d1a0c7a5f27699482c987cf663b1eac20db59b8c7b061eabd7fc"}, 560 | {file = "numpy-1.19.5-cp38-cp38-win32.whl", hash = "sha256:384ec0463d1c2671170901994aeb6dce126de0a95ccc3976c43b0038a37329c2"}, 561 | {file = "numpy-1.19.5-cp38-cp38-win_amd64.whl", hash = "sha256:811daee36a58dc79cf3d8bdd4a490e4277d0e4b7d103a001a4e73ddb48e7e6aa"}, 562 | {file = "numpy-1.19.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c843b3f50d1ab7361ca4f0b3639bf691569493a56808a0b0c54a051d260b7dbd"}, 563 | {file = "numpy-1.19.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d6631f2e867676b13026e2846180e2c13c1e11289d67da08d71cacb2cd93d4aa"}, 564 | {file = "numpy-1.19.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7fb43004bce0ca31d8f13a6eb5e943fa73371381e53f7074ed21a4cb786c32f8"}, 565 | {file = "numpy-1.19.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2ea52bd92ab9f768cc64a4c3ef8f4b2580a17af0a5436f6126b08efbd1838371"}, 566 | {file = "numpy-1.19.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:400580cbd3cff6ffa6293df2278c75aef2d58d8d93d3c5614cd67981dae68ceb"}, 567 | {file = "numpy-1.19.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:df609c82f18c5b9f6cb97271f03315ff0dbe481a2a02e56aeb1b1a985ce38e60"}, 568 | {file = "numpy-1.19.5-cp39-cp39-win32.whl", hash = "sha256:ab83f24d5c52d60dbc8cd0528759532736b56db58adaa7b5f1f76ad551416a1e"}, 569 | {file = "numpy-1.19.5-cp39-cp39-win_amd64.whl", hash = "sha256:0eef32ca3132a48e43f6a0f5a82cb508f22ce5a3d6f67a8329c81c8e226d3f6e"}, 570 | {file = "numpy-1.19.5-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:a0d53e51a6cb6f0d9082decb7a4cb6dfb33055308c4c44f53103c073f649af73"}, 571 | {file = "numpy-1.19.5.zip", hash = "sha256:a76f502430dd98d7546e1ea2250a7360c065a5fdea52b2dffe8ae7180909b6f4"}, 572 | ] 573 | opencv-python = [ 574 | {file = "opencv-python-4.5.1.48.tar.gz", hash = "sha256:78a6db8467639383caedf1d111da3510a4ee1a0aacf2117821cae2ee8f92ce37"}, 575 | {file = "opencv_python-4.5.1.48-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:bcb27773cfd5340b2b599b303d9f5499838ef4780c20c038f6030175408c64df"}, 576 | {file = "opencv_python-4.5.1.48-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9646875c501788b1b098f282d777b667d6da69801739504f1b2fd1268970d1da"}, 577 | {file = "opencv_python-4.5.1.48-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:ebe83901971a6755512424c4fe9f63341cca501b7c497bf608dd38ee31ba3f4c"}, 578 | {file = "opencv_python-4.5.1.48-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:d8aefcb30b71064dbbaa2b0ace161a36464c29375a83998fbda39a1d1740f942"}, 579 | {file = "opencv_python-4.5.1.48-cp36-cp36m-win32.whl", hash = "sha256:32dee1c9fd3e31e28edef7b56f868e2b40e280b7062304f9fb8a14dbc51547d5"}, 580 | {file = "opencv_python-4.5.1.48-cp36-cp36m-win_amd64.whl", hash = "sha256:9c77d508e6822f1f40c727d21b822d017622d8305dce7eccf0ab06caac16d5c6"}, 581 | {file = "opencv_python-4.5.1.48-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:4982fa8ccc38310a2bd93e06334ba090b12b6aff2f6fcb8ff9613e3c9bc48f48"}, 582 | {file = "opencv_python-4.5.1.48-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:c0503bfaa2b7b743d6ff5d81f1dd8428dbf4c33e7e4f836456d11be20c2e7721"}, 583 | {file = "opencv_python-4.5.1.48-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:e27d062fa1098d90f48b6c047351c89816492a08906a021c973ce510b04a7b9d"}, 584 | {file = "opencv_python-4.5.1.48-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:6d8434a45e8f75c4da5fd0068ce001f4f8e35771cc851d746d4721eeaf517e25"}, 585 | {file = "opencv_python-4.5.1.48-cp37-cp37m-win32.whl", hash = "sha256:e2c17714da59d9d516ceef0450766ff9557ee232d62f702665af905193557582"}, 586 | {file = "opencv_python-4.5.1.48-cp37-cp37m-win_amd64.whl", hash = "sha256:efac9893d9e21cfb599828801c755ecde8f1e657f05ec6f002efe19422456d5a"}, 587 | {file = "opencv_python-4.5.1.48-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:e77d0feaff37326f62b127098264e2a7099deb476e38432b1083ce11cdedf560"}, 588 | {file = "opencv_python-4.5.1.48-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:ffc75c614b8dc3d8102f3ba15dafd6ec0400c7ffa71a91953d41511964ee50e0"}, 589 | {file = "opencv_python-4.5.1.48-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:c1159d91f29a85c3333edef6ca420284566d9bcdae46dda2fe7282515b48c8b6"}, 590 | {file = "opencv_python-4.5.1.48-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:d16144c435b816c5536d5ff012c1a2b7e93155017db7103942ff7efb98c4df1f"}, 591 | {file = "opencv_python-4.5.1.48-cp38-cp38-win32.whl", hash = "sha256:b2b9ac86aec5f2dd531545cebdea1a1ef4f81ef1fb1760d78b4725f9575504f9"}, 592 | {file = "opencv_python-4.5.1.48-cp38-cp38-win_amd64.whl", hash = "sha256:30edebc81b260bcfeb760b3600c367c5261dfb2fe41e5d1408d5357d0867b40d"}, 593 | {file = "opencv_python-4.5.1.48-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:e38fbd7b2db03204ec09930609b7313d6b6d2b271c8fe2c0aa271fa69b726a1b"}, 594 | {file = "opencv_python-4.5.1.48-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:fc1472b825d26c8a4f1cfb172a90c3cc47733e4af7522276c1c2efe8f6006a8b"}, 595 | {file = "opencv_python-4.5.1.48-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:c4ea4f8b217f3e8be6247fc0787fb81797d85202c722523f41070124a7a621c7"}, 596 | {file = "opencv_python-4.5.1.48-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:a1dfa0486db367594510c0c799ec7481247dc86e651b69008806d875ab731471"}, 597 | {file = "opencv_python-4.5.1.48-cp39-cp39-win32.whl", hash = "sha256:5172cb37dfd8a0b4945b071a493eb36e5f17675a160637fa380f9c1d9d80535c"}, 598 | {file = "opencv_python-4.5.1.48-cp39-cp39-win_amd64.whl", hash = "sha256:c8cc1f5ff3c352ebe756119014c4e4ec7ae5ac536d1f66b0316667ced37637c8"}, 599 | ] 600 | packaging = [ 601 | {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, 602 | {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, 603 | ] 604 | parso = [ 605 | {file = "parso-0.8.2-py2.py3-none-any.whl", hash = "sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22"}, 606 | {file = "parso-0.8.2.tar.gz", hash = "sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398"}, 607 | ] 608 | pathspec = [ 609 | {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, 610 | {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, 611 | ] 612 | pexpect = [ 613 | {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, 614 | {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, 615 | ] 616 | pickleshare = [ 617 | {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, 618 | {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, 619 | ] 620 | pluggy = [ 621 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 622 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 623 | ] 624 | prompt-toolkit = [ 625 | {file = "prompt_toolkit-3.0.3-py3-none-any.whl", hash = "sha256:c93e53af97f630f12f5f62a3274e79527936ed466f038953dfa379d4941f651a"}, 626 | {file = "prompt_toolkit-3.0.3.tar.gz", hash = "sha256:a402e9bf468b63314e37460b68ba68243d55b2f8c4d0192f85a019af3945050e"}, 627 | ] 628 | ptyprocess = [ 629 | {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, 630 | {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, 631 | ] 632 | py = [ 633 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, 634 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, 635 | ] 636 | pygments = [ 637 | {file = "Pygments-2.8.1-py3-none-any.whl", hash = "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"}, 638 | {file = "Pygments-2.8.1.tar.gz", hash = "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94"}, 639 | ] 640 | pyparsing = [ 641 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 642 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 643 | ] 644 | pytest = [ 645 | {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, 646 | {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, 647 | ] 648 | regex = [ 649 | {file = "regex-2021.4.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000"}, 650 | {file = "regex-2021.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711"}, 651 | {file = "regex-2021.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11"}, 652 | {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968"}, 653 | {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0"}, 654 | {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4"}, 655 | {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a"}, 656 | {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7"}, 657 | {file = "regex-2021.4.4-cp36-cp36m-win32.whl", hash = "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29"}, 658 | {file = "regex-2021.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79"}, 659 | {file = "regex-2021.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8"}, 660 | {file = "regex-2021.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31"}, 661 | {file = "regex-2021.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a"}, 662 | {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5"}, 663 | {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82"}, 664 | {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765"}, 665 | {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e"}, 666 | {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439"}, 667 | {file = "regex-2021.4.4-cp37-cp37m-win32.whl", hash = "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d"}, 668 | {file = "regex-2021.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3"}, 669 | {file = "regex-2021.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500"}, 670 | {file = "regex-2021.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14"}, 671 | {file = "regex-2021.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480"}, 672 | {file = "regex-2021.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc"}, 673 | {file = "regex-2021.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093"}, 674 | {file = "regex-2021.4.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10"}, 675 | {file = "regex-2021.4.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f"}, 676 | {file = "regex-2021.4.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87"}, 677 | {file = "regex-2021.4.4-cp38-cp38-win32.whl", hash = "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac"}, 678 | {file = "regex-2021.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2"}, 679 | {file = "regex-2021.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17"}, 680 | {file = "regex-2021.4.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605"}, 681 | {file = "regex-2021.4.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9"}, 682 | {file = "regex-2021.4.4-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7"}, 683 | {file = "regex-2021.4.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8"}, 684 | {file = "regex-2021.4.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed"}, 685 | {file = "regex-2021.4.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c"}, 686 | {file = "regex-2021.4.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042"}, 687 | {file = "regex-2021.4.4-cp39-cp39-win32.whl", hash = "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6"}, 688 | {file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"}, 689 | {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"}, 690 | ] 691 | six = [ 692 | {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, 693 | {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, 694 | ] 695 | sortedcontainers = [ 696 | {file = "sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f"}, 697 | {file = "sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"}, 698 | ] 699 | toml = [ 700 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 701 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 702 | ] 703 | traitlets = [ 704 | {file = "traitlets-4.3.3-py2.py3-none-any.whl", hash = "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44"}, 705 | {file = "traitlets-4.3.3.tar.gz", hash = "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7"}, 706 | ] 707 | typed-ast = [ 708 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, 709 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, 710 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, 711 | {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, 712 | {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, 713 | {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, 714 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, 715 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, 716 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, 717 | {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, 718 | {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, 719 | {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, 720 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, 721 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, 722 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, 723 | {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, 724 | {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, 725 | {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, 726 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, 727 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, 728 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, 729 | {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, 730 | {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, 731 | {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, 732 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, 733 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, 734 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, 735 | {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, 736 | {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, 737 | {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, 738 | ] 739 | typing-extensions = [ 740 | {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, 741 | {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, 742 | {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, 743 | ] 744 | wcwidth = [ 745 | {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, 746 | {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, 747 | ] 748 | zipp = [ 749 | {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, 750 | {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"}, 751 | ] 752 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "cvpack-alkasm" 3 | packages = [ 4 | { include = "cvpack" }, 5 | ] 6 | version = "1.1.0" 7 | description = "Utilities for OpenCV in Python" 8 | authors = ["Alexander Reynolds "] 9 | homepage = "https://github.com/alkasm/cvpack" 10 | repository = "https://github.com/alkasm/cvpack" 11 | keywords = ["cvpack", "computer vision", "cv", "opencv"] 12 | readme = "README.md" 13 | license = "MIT" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | ] 19 | include = ["py.typed"] 20 | 21 | [tool.poetry.dependencies] 22 | python = "^3.6" 23 | opencv-python = "!=4.2.0.32" 24 | IPython = {version = "~=7.16", optional = true} 25 | numpy = "^1.19.5" 26 | 27 | [tool.poetry.dev-dependencies] 28 | pytest = "^5.4.1" 29 | black = "^19.10b0" 30 | hypothesis = "^5.8.0" 31 | IPython = "~=7.16" 32 | mypy = "^0.790" 33 | 34 | [build-system] 35 | requires = ["poetry>=0.12"] 36 | build-backend = "poetry.masonry.api" 37 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alkasm/cvpack/7d7aceddf18aca03ac77ccf8e0da7f71ef6674a3/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_point_size.py: -------------------------------------------------------------------------------- 1 | import math 2 | import functools 3 | from hypothesis import given, assume 4 | from hypothesis.strategies import builds, integers, one_of, none, fractions 5 | import pytest 6 | import numpy as np 7 | import cv2 as cv 8 | from cvpack import Point, Point3, Size 9 | 10 | 11 | # Hard coded test cases 12 | 13 | 14 | def make_type_drop_arg(type_, *args): 15 | if type_ == Point3: 16 | return type_(*args) 17 | return type_(*args[:-1]) 18 | 19 | 20 | def test_unary_operators(): 21 | for type_ in (Point, Point3, Size): 22 | T = functools.partial(make_type_drop_arg, type_) 23 | assert +T(1, 2, 1) == T(1, 2, 1) 24 | assert -T(1, 2, -1) == T(-1, -2, 1) 25 | assert abs(T(-1, -2, 1)) == T(1, 2, 1) 26 | assert round(T(1.1, 1.91, 2.5)) == T(1, 2, 2) 27 | assert round(T(1.1, 1.91, 1), ndigits=1) == T(1.1, 1.9, 1.0) 28 | assert math.floor(T(1.1, 1.9, 1.0)) == T(1, 1, 1) 29 | assert math.ceil(T(1.1, 1.9, 1.0)) == T(2, 2, 1) 30 | 31 | 32 | def test_binary_operators(): 33 | for type_ in (Point, Point3, Size): 34 | T = functools.partial(make_type_drop_arg, type_) 35 | assert T(1, 3, 0) + T(1, 3, 1) == T(2, 6, 1) 36 | assert T(1, 3, 1.1) * T(1, 3, 2) == T(1, 9, 2.2) 37 | assert T(1, 3, 2) - T(2, 3, 0) == T(-1, 0, 2) 38 | assert T(2, 3, 2) / T(1, 2, 2) == T(2, 1.5, 1) 39 | assert T(2, 3, 2) // T(1, 2, 2) == T(2, 1, 1) 40 | 41 | 42 | def test_binary_operators_broadcast(): 43 | for type_ in (Point, Point3, Size): 44 | T = functools.partial(make_type_drop_arg, type_) 45 | assert T(1, 3, 0) + 2 == T(3, 5, 2) 46 | assert T(1, 3, 0) * 2 == T(2, 6, 0) 47 | assert T(1, 3, 0) - 2 == T(-1, 1, -2) 48 | assert T(2, 3, 1) / 2 == T(1, 1.5, 0.5) 49 | assert T(2, 3, 1) // 2 == T(1, 1, 0) 50 | 51 | 52 | def test_binary_operators_broadcast_rhs(): 53 | for type_ in (Point, Point3, Size): 54 | T = functools.partial(make_type_drop_arg, type_) 55 | assert 2 + T(1, 3, 0) == T(3, 5, 2) 56 | assert 2 * T(1, 3, 0) == T(2, 6, 0) 57 | assert 2 - T(1, 3, 0) == T(1, -1, 2) 58 | assert 3 / T(2, 3, 1) == T(1.5, 1, 3) 59 | assert 2 // T(2, 3, 1) == T(1, 0, 2) 60 | 61 | 62 | # generated test cases 63 | # using rationals to check math exactly instead of dealing with floating point errors 64 | 65 | Rationals = fractions() 66 | PositiveRationals = fractions(min_value=0) 67 | PointStrategy = builds(Point, Rationals, Rationals) 68 | Point3Strategy = builds(Point3, Rationals, Rationals, Rationals) 69 | SizeStrategy = builds(Size, PositiveRationals, PositiveRationals) 70 | ArithmeticTupleStrategy = one_of(PointStrategy, Point3Strategy, SizeStrategy) 71 | 72 | 73 | @given(ArithmeticTupleStrategy) 74 | def test_unary_operators(o): 75 | assert +o == o 76 | assert -o == tuple(-v for v in o) 77 | assert o == -(-o) 78 | assert abs(o) == tuple(abs(v) for v in o) 79 | 80 | 81 | @given(ArithmeticTupleStrategy, one_of(integers(min_value=0, max_value=10), none())) 82 | def test_unary_truncation_operators(o, ndigits): 83 | assume(all(math.isfinite(v) for v in o)) # cannot round/truncate/etc inf or nan 84 | assert round(o, ndigits) == tuple(round(v, ndigits) for v in o) 85 | assert math.floor(o) == tuple(math.floor(v) for v in o) 86 | assert math.ceil(o) == tuple(math.ceil(v) for v in o) 87 | 88 | 89 | @given(ArithmeticTupleStrategy, ArithmeticTupleStrategy) 90 | def test_binary_operators(lhs, rhs): 91 | assume(type(lhs) == type(rhs)) 92 | 93 | # commutative 94 | assert lhs + rhs == rhs + lhs == tuple(l + r for l, r in zip(lhs, rhs)) 95 | assert lhs * rhs == rhs * lhs == tuple(l * r for l, r in zip(lhs, rhs)) 96 | 97 | # non-commutative 98 | assert lhs - rhs == tuple(l - r for l, r in zip(lhs, rhs)) 99 | assume(all(v != 0 for v in rhs)) 100 | assert lhs / rhs == tuple(l / r for l, r in zip(lhs, rhs)) 101 | assert lhs // rhs == tuple(l // r for l, r in zip(lhs, rhs)) 102 | 103 | 104 | @given(ArithmeticTupleStrategy, Rationals) 105 | def test_binary_operators_scalar(lhs, rhs): 106 | assert lhs + rhs == rhs + lhs == tuple(l + rhs for l in lhs) 107 | assert lhs * rhs == rhs * lhs == tuple(l * rhs for l in lhs) 108 | assert lhs - rhs == tuple(l - rhs for l in lhs) 109 | assert rhs - lhs == tuple(rhs - l for l in lhs) 110 | 111 | if rhs == 0.0: 112 | with pytest.raises(ZeroDivisionError): 113 | lhs / rhs 114 | 115 | with pytest.raises(ZeroDivisionError): 116 | lhs // rhs 117 | 118 | assume(not math.isclose(0, rhs)) 119 | assert lhs / rhs == tuple(l / rhs for l in lhs) 120 | assert lhs // rhs == tuple(l // rhs for l in lhs) 121 | 122 | 123 | # OpenCV functions 124 | 125 | max_size = 50 126 | 127 | 128 | def blank_img(): 129 | return np.zeros((max_size, max_size), dtype=np.uint8) 130 | 131 | 132 | ImagePointStrategy = builds( 133 | Point, 134 | integers(min_value=0, max_value=max_size - 1), 135 | integers(min_value=0, max_value=max_size - 1), 136 | ) 137 | EllipseSizeStrategy = builds( 138 | Size, 139 | integers(min_value=1, max_value=max_size), 140 | integers(min_value=1, max_value=max_size), 141 | ) 142 | 143 | 144 | @given(ImagePointStrategy) 145 | def test_cv_circle(p): 146 | img = cv.circle(blank_img(), p, 5, 255, -1) 147 | assert img[p.y, p.x] == 255 148 | 149 | 150 | @given(ImagePointStrategy) 151 | def test_cv_draw_marker(p): 152 | img = cv.drawMarker(blank_img(), p, 255) 153 | assert img[p.y, p.x] == 255 154 | 155 | 156 | @given(ImagePointStrategy, EllipseSizeStrategy) 157 | def test_cv_ellipse(p, s): 158 | img = cv.ellipse(blank_img(), p, s, 0, 0, 360, 255, -1) 159 | assert img[p.y, p.x] == 255 160 | 161 | 162 | @given(ImagePointStrategy, ImagePointStrategy) 163 | def test_cv_line(p1, p2): 164 | img = cv.line(blank_img(), p1, p2, 255, 2) 165 | assert img[p1.y, p1.x] == 255 166 | assert img[p2.y, p2.x] == 255 167 | -------------------------------------------------------------------------------- /tests/test_rect.py: -------------------------------------------------------------------------------- 1 | from hypothesis import given, assume 2 | from hypothesis.strategies import builds, integers 3 | import numpy as np 4 | import cv2 as cv 5 | from cvpack import Rect 6 | from .test_point_size import Rationals, PositiveRationals, PointStrategy, SizeStrategy 7 | 8 | 9 | def test_rect_slice(): 10 | img_rect = Rect(0, 0, 160, 90) 11 | a = np.random.rand(img_rect.height, img_rect.width) 12 | assert np.all(a == a[img_rect.slice()]) 13 | 14 | rect = Rect(10, 12, 14, 16) 15 | assert np.all( 16 | a[rect.y : rect.y + rect.height, rect.x : rect.x + rect.width] 17 | == a[rect.slice()] 18 | ) 19 | 20 | empty_rect = Rect(0, 0, 0, 0) 21 | assert a[empty_rect.slice()].size == 0 22 | 23 | 24 | RectStrategy = builds(Rect, Rationals, Rationals, PositiveRationals, PositiveRationals) 25 | Integers100 = integers(min_value=0, max_value=100) 26 | RegionStrategy = builds(Rect, Integers100, Integers100, Integers100, Integers100) 27 | 28 | 29 | @given(RectStrategy) 30 | def test_rect(r): 31 | assert r.area() == r.width * r.height 32 | assert r.area() == r.size().area() 33 | assert r.tl() == (r.x, r.y) 34 | assert r.br() == (r.x + r.width, r.y + r.height) 35 | assert r.size() == (r.width, r.height) 36 | assert r.empty() == r.size().empty() 37 | assert r.empty() == (r.area() == 0) 38 | assert r.center().x == r.x + r.width / 2 39 | assert r.center().y == r.y + r.height / 2 40 | assert r.contains(r.center()) 41 | assert r.contains(r.tl()) 42 | assert r.contains(r.br()) 43 | 44 | assert r == Rect.from_center(r.center(), r.size()) 45 | assert r == Rect.from_points(r.tl(), r.br()) 46 | 47 | 48 | @given(RectStrategy) 49 | def test_rect_intersection_union(r): 50 | assert r.area() == r.intersection(r) 51 | assert r.area() == r.union(r) 52 | 53 | extended_rect = Rect.from_points(r.tl() - 1, r.br() + 1) 54 | assert r.area() == r.intersection(extended_rect) 55 | assert extended_rect.area() == r.union(extended_rect) 56 | 57 | non_intersecting_rect = Rect.from_origin(r.br(), r.size()) 58 | assert r.intersection(non_intersecting_rect) == 0 59 | assert r.union(non_intersecting_rect) == r.area() + non_intersecting_rect.area() 60 | 61 | empty_rect = Rect(0, 0, 0, 0) 62 | assert r.intersection(empty_rect) == 0 63 | assert r.union(empty_rect) == r.area() 64 | 65 | intersecting_rect = Rect.from_center(r.br(), r.size()) 66 | assert r.intersection(intersecting_rect) == r.area() / 4 67 | assert r.union(intersecting_rect) == r.area() * 7 / 4 68 | 69 | 70 | @given(RectStrategy, PointStrategy) 71 | def test_rect_point_ops(r, p): 72 | sp = r + p 73 | assert sp.tl() == r.tl() + p 74 | assert sp.br() == r.br() + p 75 | assert sp.center() == r.center() + p 76 | assert r.area() == sp.area() 77 | assert r.size() == sp.size() 78 | 79 | sn = r - p 80 | assert sn.tl() == r.tl() - p 81 | assert sn.br() == r.br() - p 82 | assert sn.center() == r.center() - p 83 | assert r.area() == sn.area() 84 | assert r.size() == sn.size() 85 | 86 | 87 | @given(RectStrategy, SizeStrategy) 88 | def test_rect_size_ops(r, s): 89 | e = r + s 90 | assert e.tl() == r.tl() 91 | assert e.br() == r.br() + s 92 | assert e.center() == r.center() + s / 2 93 | assert r.intersection(e) == r.area() 94 | assert r.union(e) == e.area() 95 | assert e.size() == r.size() + s 96 | assert r.width <= e.width 97 | assert r.height <= e.height 98 | 99 | assume(s.width <= r.width and s.height <= r.height) 100 | c = r - s 101 | assert c.tl() == r.tl() 102 | assert c.br() == r.br() - s 103 | assert c.center() == r.center() - s / 2 104 | assert r.intersection(c) == c.area() 105 | assert r.union(c) == r.area() 106 | assert c.size() == r.size() - s 107 | assert c.width <= r.width 108 | assert c.height <= r.height 109 | 110 | 111 | @given(RegionStrategy) 112 | def test_rect_slices(r): 113 | img = np.random.rand(200, 200) 114 | assert np.all(img[r.y : r.y + r.height, r.x : r.x + r.width] == img[r.slice()]) 115 | assert img[r.slice()].shape == r.size()[::-1] 116 | assert img[r.slice()].size == r.area() 117 | 118 | 119 | # OpenCV functions 120 | 121 | 122 | @given(RegionStrategy) 123 | def test_cv_rectangle(r): 124 | blank_img = np.zeros((200, 200), dtype=np.uint8) 125 | pts_img = cv.rectangle(blank_img, r.tl(), r.br(), color=255, thickness=-1) 126 | rect_img = cv.rectangle(blank_img, r, color=255, thickness=-1) 127 | assert np.all(pts_img == rect_img) 128 | assert np.all(pts_img[r.slice()] == 255) 129 | assert np.all(rect_img[r.slice()] == 255) 130 | assert not np.any((pts_img == 0)[r.slice()]) 131 | assert not np.any((rect_img == 0)[r.slice()]) 132 | -------------------------------------------------------------------------------- /tests/test_rotated_rect.py: -------------------------------------------------------------------------------- 1 | from hypothesis import given, assume 2 | from hypothesis.strategies import builds, integers 3 | from cvpack import Rect, RotatedRect, Point 4 | import pytest 5 | 6 | Integers = integers(min_value=-1_000_000, max_value=1_000_000) 7 | PositiveIntegers = integers(min_value=0, max_value=1_000_000) 8 | 9 | RationalRectStrategy = builds( 10 | Rect, Integers, Integers, PositiveIntegers, PositiveIntegers, 11 | ) 12 | 13 | 14 | @given(RationalRectStrategy) 15 | def test_rotated_rect_to_from_rect(r): 16 | def assert_contains(rotated_rect, rect): 17 | # bounding rect is integral, so check intersection and union 18 | bound = rotated_rect.bounding_rect() 19 | assert bound.intersection(rect) == rect.area() 20 | assert bound.union(rect) == bound.area() 21 | 22 | rr0 = RotatedRect(r.center(), r.size(), 0) 23 | rr180 = RotatedRect(r.center(), r.size(), 180) 24 | assert_contains(rr0, r) 25 | assert_contains(rr180, r) 26 | 27 | tl = r.tl() 28 | tr = Point(r.x + r.width, r.y) 29 | br = r.br() 30 | bl = Point(r.x, r.y + r.height) 31 | 32 | triplets = [ 33 | (tl, tr, br), 34 | (tr, br, bl), 35 | (br, bl, tl), 36 | (bl, tl, tr), 37 | ] # clockwise 38 | triplets += [t[::-1] for t in triplets] # counter-clockwise 39 | 40 | for p1, p2, p3 in triplets: 41 | rr = RotatedRect.from_points(p1, p2, p3) 42 | assert r.area() == rr.size.area() 43 | assert rr.angle % 90 == 0 44 | assert_contains(rr, r) 45 | 46 | assume(r.height > 1 and r.width > 1) 47 | for p1, p2, p3 in triplets: 48 | avgp = (p1 + p3) / 2 49 | with pytest.raises(ValueError): 50 | rr = RotatedRect.from_points(p1, avgp, p3) 51 | --------------------------------------------------------------------------------