improcv 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- improcv/__init__.py +264 -0
- improcv/_compat/__init__.py +0 -0
- improcv/_compat/opencv.py +58 -0
- improcv/_validation.py +363 -0
- improcv/analysis.py +524 -0
- improcv/barcode.py +207 -0
- improcv/color.py +124 -0
- improcv/contours.py +355 -0
- improcv/detectors.py +471 -0
- improcv/drawing.py +451 -0
- improcv/edges.py +210 -0
- improcv/features.py +779 -0
- improcv/filters.py +276 -0
- improcv/hough.py +689 -0
- improcv/morphology.py +269 -0
- improcv/pixels.py +281 -0
- improcv/py.typed +0 -0
- improcv/qrcode.py +270 -0
- improcv/regions.py +474 -0
- improcv/restoration.py +129 -0
- improcv/segmentation.py +193 -0
- improcv/transforms.py +632 -0
- improcv/types.py +49 -0
- improcv/visualization/__init__.py +25 -0
- improcv/visualization/image.py +196 -0
- improcv-0.1.0a1.dist-info/METADATA +277 -0
- improcv-0.1.0a1.dist-info/RECORD +29 -0
- improcv-0.1.0a1.dist-info/WHEEL +4 -0
- improcv-0.1.0a1.dist-info/licenses/LICENSE +21 -0
improcv/__init__.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""improcv: modern image-processing and computer-vision utilities for NumPy and OpenCV."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = _version("improcv")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
# Only happens when running from a source checkout that was never
|
|
10
|
+
# installed (editable or otherwise) -- package metadata doesn't exist
|
|
11
|
+
# yet to read a version from.
|
|
12
|
+
__version__ = "0.0.0.dev0"
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import cv2 as _cv2 # noqa: F401
|
|
16
|
+
except ModuleNotFoundError as _exc:
|
|
17
|
+
# Only a genuinely absent cv2 gets the friendly "install one of these
|
|
18
|
+
# extras" message. A present-but-broken installation (ABI mismatch, a
|
|
19
|
+
# missing shared library, a corrupted build) raises a plain
|
|
20
|
+
# ImportError instead of ModuleNotFoundError, or a ModuleNotFoundError
|
|
21
|
+
# for some *other* module cv2 itself failed to import — in either
|
|
22
|
+
# case, masking that with "you need to install OpenCV" would hide the
|
|
23
|
+
# real problem, so it's left to propagate unmodified.
|
|
24
|
+
if _exc.name != "cv2":
|
|
25
|
+
raise
|
|
26
|
+
raise ImportError(
|
|
27
|
+
"improcv requires an OpenCV installation, which is not installed automatically "
|
|
28
|
+
"(to avoid conflicting with an OpenCV variant you may already have). Install "
|
|
29
|
+
'exactly one of: `pip install "improcv[cv]"` (opencv-python), '
|
|
30
|
+
'`pip install "improcv[cv-headless]"` (opencv-python-headless), '
|
|
31
|
+
'`pip install "improcv[cv-contrib]"` (opencv-contrib-python), or '
|
|
32
|
+
'`pip install "improcv[cv-contrib-headless]"` (opencv-contrib-python-headless) — '
|
|
33
|
+
"or install one of the four `opencv-*` packages yourself."
|
|
34
|
+
) from _exc
|
|
35
|
+
|
|
36
|
+
from improcv.analysis import (
|
|
37
|
+
MeanStdDevResult,
|
|
38
|
+
MinMaxResult,
|
|
39
|
+
Moments,
|
|
40
|
+
TemplateMatchMethod,
|
|
41
|
+
histogram,
|
|
42
|
+
match_template,
|
|
43
|
+
mean_stddev,
|
|
44
|
+
min_max_loc,
|
|
45
|
+
moments,
|
|
46
|
+
)
|
|
47
|
+
from improcv.barcode import Barcode, decode_barcodes
|
|
48
|
+
from improcv.color import bgr_to_rgb, ensure_gray, rgb_to_bgr, to_hsv, to_lab, to_ycrcb
|
|
49
|
+
from improcv.contours import (
|
|
50
|
+
ApproxMethod,
|
|
51
|
+
BoundingBox,
|
|
52
|
+
Contour,
|
|
53
|
+
Hierarchy,
|
|
54
|
+
RetrievalMode,
|
|
55
|
+
RotatedRect,
|
|
56
|
+
SortOrder,
|
|
57
|
+
approx_poly_dp,
|
|
58
|
+
bounding_boxes,
|
|
59
|
+
convex_hull,
|
|
60
|
+
find_contours,
|
|
61
|
+
min_area_rect,
|
|
62
|
+
sort_contours,
|
|
63
|
+
)
|
|
64
|
+
from improcv.detectors import (
|
|
65
|
+
FastType,
|
|
66
|
+
MSERRegion,
|
|
67
|
+
detect_blob_keypoints,
|
|
68
|
+
detect_fast_keypoints,
|
|
69
|
+
detect_mser_regions,
|
|
70
|
+
)
|
|
71
|
+
from improcv.drawing import draw_bounding_boxes, draw_contours, montage
|
|
72
|
+
from improcv.edges import auto_canny, harris_corner, laplacian_edge, sobel_edge
|
|
73
|
+
from improcv.features import (
|
|
74
|
+
DescriptorNorm,
|
|
75
|
+
FeatureMethod,
|
|
76
|
+
Features,
|
|
77
|
+
HomographyResult,
|
|
78
|
+
detect_and_compute,
|
|
79
|
+
find_homography,
|
|
80
|
+
match_features,
|
|
81
|
+
match_features_ratio,
|
|
82
|
+
)
|
|
83
|
+
from improcv.filters import (
|
|
84
|
+
bilateral_filter,
|
|
85
|
+
clahe,
|
|
86
|
+
gamma_correction,
|
|
87
|
+
gaussian_blur,
|
|
88
|
+
histogram_equalization,
|
|
89
|
+
median_blur,
|
|
90
|
+
)
|
|
91
|
+
from improcv.hough import (
|
|
92
|
+
Circle,
|
|
93
|
+
HoughCircleMethod,
|
|
94
|
+
Line,
|
|
95
|
+
LineSegment,
|
|
96
|
+
hough_circles,
|
|
97
|
+
hough_line_segments,
|
|
98
|
+
hough_lines,
|
|
99
|
+
)
|
|
100
|
+
from improcv.morphology import (
|
|
101
|
+
blackhat,
|
|
102
|
+
dilate,
|
|
103
|
+
erode,
|
|
104
|
+
morph_close,
|
|
105
|
+
morph_gradient,
|
|
106
|
+
morph_open,
|
|
107
|
+
threshold,
|
|
108
|
+
tophat,
|
|
109
|
+
)
|
|
110
|
+
from improcv.pixels import (
|
|
111
|
+
adjust_brightness,
|
|
112
|
+
adjust_contrast,
|
|
113
|
+
alpha_blend,
|
|
114
|
+
apply_lut,
|
|
115
|
+
bitwise_and,
|
|
116
|
+
bitwise_or,
|
|
117
|
+
in_range,
|
|
118
|
+
invert,
|
|
119
|
+
)
|
|
120
|
+
from improcv.qrcode import QRCode, decode_qr_code, decode_qr_codes
|
|
121
|
+
from improcv.regions import (
|
|
122
|
+
Centroids,
|
|
123
|
+
ComponentStats,
|
|
124
|
+
Connectivity,
|
|
125
|
+
DistanceMaskSize,
|
|
126
|
+
DistanceType,
|
|
127
|
+
FloodFillResult,
|
|
128
|
+
Labels,
|
|
129
|
+
connected_components,
|
|
130
|
+
connected_components_with_stats,
|
|
131
|
+
distance_transform,
|
|
132
|
+
flood_fill,
|
|
133
|
+
)
|
|
134
|
+
from improcv.restoration import InpaintMethod, inpaint
|
|
135
|
+
from improcv.segmentation import grabcut_rect, watershed
|
|
136
|
+
from improcv.transforms import (
|
|
137
|
+
center_crop,
|
|
138
|
+
crop,
|
|
139
|
+
flip,
|
|
140
|
+
pad,
|
|
141
|
+
resize,
|
|
142
|
+
rotate,
|
|
143
|
+
rotate_bound,
|
|
144
|
+
translate,
|
|
145
|
+
warp_affine,
|
|
146
|
+
warp_perspective,
|
|
147
|
+
)
|
|
148
|
+
from improcv.types import Image, ImageFloat32, ImageU8, Mask, TransformMatrix
|
|
149
|
+
|
|
150
|
+
__all__ = [
|
|
151
|
+
"ApproxMethod",
|
|
152
|
+
"Barcode",
|
|
153
|
+
"BoundingBox",
|
|
154
|
+
"Centroids",
|
|
155
|
+
"Circle",
|
|
156
|
+
"ComponentStats",
|
|
157
|
+
"Connectivity",
|
|
158
|
+
"Contour",
|
|
159
|
+
"DescriptorNorm",
|
|
160
|
+
"DistanceMaskSize",
|
|
161
|
+
"DistanceType",
|
|
162
|
+
"FastType",
|
|
163
|
+
"FeatureMethod",
|
|
164
|
+
"Features",
|
|
165
|
+
"FloodFillResult",
|
|
166
|
+
"Hierarchy",
|
|
167
|
+
"HomographyResult",
|
|
168
|
+
"HoughCircleMethod",
|
|
169
|
+
"Image",
|
|
170
|
+
"ImageFloat32",
|
|
171
|
+
"ImageU8",
|
|
172
|
+
"InpaintMethod",
|
|
173
|
+
"Labels",
|
|
174
|
+
"Line",
|
|
175
|
+
"LineSegment",
|
|
176
|
+
"Mask",
|
|
177
|
+
"MSERRegion",
|
|
178
|
+
"MeanStdDevResult",
|
|
179
|
+
"MinMaxResult",
|
|
180
|
+
"Moments",
|
|
181
|
+
"QRCode",
|
|
182
|
+
"RetrievalMode",
|
|
183
|
+
"RotatedRect",
|
|
184
|
+
"SortOrder",
|
|
185
|
+
"TemplateMatchMethod",
|
|
186
|
+
"TransformMatrix",
|
|
187
|
+
"__version__",
|
|
188
|
+
"adjust_brightness",
|
|
189
|
+
"adjust_contrast",
|
|
190
|
+
"alpha_blend",
|
|
191
|
+
"apply_lut",
|
|
192
|
+
"approx_poly_dp",
|
|
193
|
+
"auto_canny",
|
|
194
|
+
"bgr_to_rgb",
|
|
195
|
+
"bilateral_filter",
|
|
196
|
+
"bitwise_and",
|
|
197
|
+
"bitwise_or",
|
|
198
|
+
"blackhat",
|
|
199
|
+
"bounding_boxes",
|
|
200
|
+
"center_crop",
|
|
201
|
+
"clahe",
|
|
202
|
+
"connected_components",
|
|
203
|
+
"connected_components_with_stats",
|
|
204
|
+
"convex_hull",
|
|
205
|
+
"crop",
|
|
206
|
+
"decode_barcodes",
|
|
207
|
+
"decode_qr_code",
|
|
208
|
+
"decode_qr_codes",
|
|
209
|
+
"detect_and_compute",
|
|
210
|
+
"detect_blob_keypoints",
|
|
211
|
+
"detect_fast_keypoints",
|
|
212
|
+
"detect_mser_regions",
|
|
213
|
+
"dilate",
|
|
214
|
+
"distance_transform",
|
|
215
|
+
"draw_bounding_boxes",
|
|
216
|
+
"draw_contours",
|
|
217
|
+
"ensure_gray",
|
|
218
|
+
"erode",
|
|
219
|
+
"find_contours",
|
|
220
|
+
"find_homography",
|
|
221
|
+
"flip",
|
|
222
|
+
"flood_fill",
|
|
223
|
+
"gamma_correction",
|
|
224
|
+
"gaussian_blur",
|
|
225
|
+
"grabcut_rect",
|
|
226
|
+
"harris_corner",
|
|
227
|
+
"histogram",
|
|
228
|
+
"histogram_equalization",
|
|
229
|
+
"hough_circles",
|
|
230
|
+
"hough_line_segments",
|
|
231
|
+
"hough_lines",
|
|
232
|
+
"in_range",
|
|
233
|
+
"inpaint",
|
|
234
|
+
"invert",
|
|
235
|
+
"laplacian_edge",
|
|
236
|
+
"match_features",
|
|
237
|
+
"match_features_ratio",
|
|
238
|
+
"match_template",
|
|
239
|
+
"mean_stddev",
|
|
240
|
+
"median_blur",
|
|
241
|
+
"min_area_rect",
|
|
242
|
+
"min_max_loc",
|
|
243
|
+
"moments",
|
|
244
|
+
"montage",
|
|
245
|
+
"morph_close",
|
|
246
|
+
"morph_gradient",
|
|
247
|
+
"morph_open",
|
|
248
|
+
"pad",
|
|
249
|
+
"resize",
|
|
250
|
+
"rgb_to_bgr",
|
|
251
|
+
"rotate",
|
|
252
|
+
"rotate_bound",
|
|
253
|
+
"sobel_edge",
|
|
254
|
+
"sort_contours",
|
|
255
|
+
"threshold",
|
|
256
|
+
"to_hsv",
|
|
257
|
+
"to_lab",
|
|
258
|
+
"to_ycrcb",
|
|
259
|
+
"tophat",
|
|
260
|
+
"translate",
|
|
261
|
+
"warp_affine",
|
|
262
|
+
"warp_perspective",
|
|
263
|
+
"watershed",
|
|
264
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Isolates OpenCV 4.x/5.x behavioral differences behind narrow, capability-detected helpers.
|
|
2
|
+
|
|
3
|
+
Nothing here branches on the OpenCV version number: each helper detects the
|
|
4
|
+
actual difference it's normalizing (a shape, a presence of an attribute) and
|
|
5
|
+
handles it directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
__all__: list[str] = []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalize_calc_hist_output(raw: np.ndarray, bins: int) -> np.ndarray:
|
|
16
|
+
"""Normalize cv2.calcHist's output to a flat ``(bins,)`` array.
|
|
17
|
+
|
|
18
|
+
``cv2.calcHist`` returns shape ``(bins, 1)`` on some OpenCV builds and
|
|
19
|
+
``(bins,)`` on others for identical 1D-histogram input (verified
|
|
20
|
+
directly: ``(bins, 1)`` on OpenCV 4.13.0, ``(bins,)`` on OpenCV 5.0.0).
|
|
21
|
+
Detected by the array's actual size, not by checking the OpenCV version.
|
|
22
|
+
"""
|
|
23
|
+
if raw.size != bins:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
f"cv2.calcHist returned an array of size {raw.size}, expected {bins} "
|
|
26
|
+
"-- unexpected OpenCV output shape"
|
|
27
|
+
)
|
|
28
|
+
return raw.reshape(bins)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize_hough_lines_p_output(raw: np.ndarray) -> np.ndarray:
|
|
32
|
+
"""Normalize cv2.HoughLinesP's output to a flat ``(N, 4)`` int32 array.
|
|
33
|
+
|
|
34
|
+
``cv2.HoughLinesP`` returns shape ``(N, 4)`` on some OpenCV builds and
|
|
35
|
+
``(N, 1, 4)`` on others for identical input (verified directly:
|
|
36
|
+
``(N, 1, 4)`` on OpenCV 4.13.0, ``(N, 4)`` on OpenCV 5.0.0). Detected by
|
|
37
|
+
the array's actual shape, not by checking the OpenCV version. Accepts
|
|
38
|
+
only exactly 4 fields per row (``x1, y1, x2, y2``) and ``int32`` --
|
|
39
|
+
anything else is an internally inconsistent OpenCV result, not a shape
|
|
40
|
+
this function knows how to normalize.
|
|
41
|
+
"""
|
|
42
|
+
if not isinstance(raw, np.ndarray):
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
f"cv2.HoughLinesP returned a {type(raw).__name__}, expected an np.ndarray -- "
|
|
45
|
+
"unexpected OpenCV output"
|
|
46
|
+
)
|
|
47
|
+
if raw.dtype != np.int32:
|
|
48
|
+
raise RuntimeError(
|
|
49
|
+
f"cv2.HoughLinesP returned dtype {raw.dtype}, expected int32 -- "
|
|
50
|
+
"unexpected OpenCV output"
|
|
51
|
+
)
|
|
52
|
+
if raw.ndim == 2 and raw.shape[1] == 4:
|
|
53
|
+
return raw
|
|
54
|
+
if raw.ndim == 3 and raw.shape[1:] == (1, 4):
|
|
55
|
+
return raw[:, 0, :]
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
f"cv2.HoughLinesP returned an array of shape {raw.shape} -- unexpected OpenCV output shape"
|
|
58
|
+
)
|
improcv/_validation.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Shared argument validation helpers for improcv's public functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import numbers
|
|
7
|
+
from collections.abc import Collection
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
__all__: list[str] = []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def require_image_ndim(image: np.ndarray, ndims: tuple[int, ...] = (2, 3)) -> None:
|
|
16
|
+
"""Raise ValueError unless `image.ndim` is one of `ndims` and `image` is non-empty.
|
|
17
|
+
|
|
18
|
+
Every public function calls this (directly or via a narrower `ndims`),
|
|
19
|
+
so the empty-image check here is the single, global place that rejects
|
|
20
|
+
an empty image for the whole library. Checks `image.size == 0` rather
|
|
21
|
+
than just height/width: a zero-channel `(H, W, 0)` array has nonzero
|
|
22
|
+
height and width but is still empty, and verified directly to produce
|
|
23
|
+
uninitialized-memory garbage from at least one OpenCV call rather than
|
|
24
|
+
a clear error.
|
|
25
|
+
"""
|
|
26
|
+
if image.ndim not in ndims:
|
|
27
|
+
allowed = " or ".join(str(n) for n in ndims)
|
|
28
|
+
raise ValueError(f"image must have {allowed} dimensions, got {image.ndim}")
|
|
29
|
+
if image.size == 0:
|
|
30
|
+
raise ValueError(f"image must not be empty, got shape {image.shape}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def require_real_number(value: object, name: str) -> None:
|
|
34
|
+
"""Raise TypeError unless `value` is a real number.
|
|
35
|
+
|
|
36
|
+
Accepts plain Python `int`/`float` as well as NumPy scalar types
|
|
37
|
+
(`np.float32`, `np.float64`, `np.int32`, ...) — anything registered as
|
|
38
|
+
`numbers.Real` — but rejects `bool` (a `bool` is technically an `int`
|
|
39
|
+
subclass, but accepting `True`/`False` here would silently
|
|
40
|
+
misinterpret a boolean argument as ``1``/``0``) and non-numeric types
|
|
41
|
+
such as `str`.
|
|
42
|
+
"""
|
|
43
|
+
if isinstance(value, bool) or not isinstance(value, numbers.Real):
|
|
44
|
+
raise TypeError(f"{name} must be a real number, got {type(value).__name__}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _safe_float(value: numbers.Real) -> float:
|
|
48
|
+
"""Convert `value` to float, treating an unconvertible magnitude as infinity.
|
|
49
|
+
|
|
50
|
+
`float(value)` raises a raw `OverflowError` for a Python `int` too huge
|
|
51
|
+
to represent as a float (e.g. ``10**400``) — verified directly. Such a
|
|
52
|
+
value is not representable as a finite float either way, so this
|
|
53
|
+
treats it the same as an already-infinite value rather than letting
|
|
54
|
+
the raw `OverflowError` propagate past validation.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
return float(value)
|
|
58
|
+
except OverflowError:
|
|
59
|
+
# Only a Python int (arbitrary precision) can overflow float() in
|
|
60
|
+
# practice; the cast is purely to satisfy Pyright (numbers.Real
|
|
61
|
+
# doesn't support comparison operators reliably per typeshed) and
|
|
62
|
+
# has no runtime effect.
|
|
63
|
+
return math.inf if cast(int, value) > 0 else -math.inf
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_nan_or_inf(value: numbers.Real) -> bool:
|
|
67
|
+
# _safe_float normalizes any numbers.Real (including NumPy scalar types
|
|
68
|
+
# like np.float32, which math.isnan/math.isinf don't accept directly on
|
|
69
|
+
# some platforms) before checking finiteness.
|
|
70
|
+
return not math.isfinite(_safe_float(value))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def require_positive(value: object, name: str) -> None:
|
|
74
|
+
"""Raise TypeError unless `value` is a real number, then ValueError unless
|
|
75
|
+
it's finite and positive."""
|
|
76
|
+
require_real_number(value, name)
|
|
77
|
+
assert isinstance(value, numbers.Real) # narrows for the type checker
|
|
78
|
+
if _is_nan_or_inf(value):
|
|
79
|
+
raise ValueError(f"{name} must be finite, got {value}")
|
|
80
|
+
if value <= 0:
|
|
81
|
+
raise ValueError(f"{name} must be positive, got {value}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def require_non_negative(value: object, name: str) -> None:
|
|
85
|
+
"""Raise TypeError unless `value` is a real number, then ValueError unless
|
|
86
|
+
it's finite and non-negative."""
|
|
87
|
+
require_real_number(value, name)
|
|
88
|
+
assert isinstance(value, numbers.Real) # narrows for the type checker
|
|
89
|
+
if _is_nan_or_inf(value):
|
|
90
|
+
raise ValueError(f"{name} must be finite, got {value}")
|
|
91
|
+
if value < 0:
|
|
92
|
+
raise ValueError(f"{name} must be non-negative, got {value}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def require_finite(value: object, name: str) -> None:
|
|
96
|
+
"""Raise TypeError unless `value` is a real number, then ValueError unless
|
|
97
|
+
it's finite (not NaN or infinite).
|
|
98
|
+
|
|
99
|
+
Unlike `require_positive`/`require_non_negative`, this carries no sign
|
|
100
|
+
constraint — for parameters where negative values are meaningful (e.g.
|
|
101
|
+
a brightness delta) but NaN/infinity are not.
|
|
102
|
+
"""
|
|
103
|
+
require_real_number(value, name)
|
|
104
|
+
assert isinstance(value, numbers.Real) # narrows for the type checker
|
|
105
|
+
if _is_nan_or_inf(value):
|
|
106
|
+
raise ValueError(f"{name} must be finite, got {value}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def require_int(value: object, name: str) -> None:
|
|
110
|
+
"""Raise TypeError unless `value` is an int.
|
|
111
|
+
|
|
112
|
+
Rejects `bool` (a `bool` is technically an `int` subclass in Python,
|
|
113
|
+
but accepting `True`/`False` here would silently misinterpret a
|
|
114
|
+
boolean argument as ``1``/``0``) and any non-int type, including
|
|
115
|
+
floats — so this also rejects NaN and infinity, which are float-only
|
|
116
|
+
concepts.
|
|
117
|
+
"""
|
|
118
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
119
|
+
raise TypeError(f"{name} must be an int, got {type(value).__name__}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def require_integral(value: object, name: str) -> None:
|
|
123
|
+
"""Raise TypeError unless `value` is an integral number.
|
|
124
|
+
|
|
125
|
+
Accepts plain Python `int` as well as NumPy integer scalar types
|
|
126
|
+
(`np.int32`, `np.int64`, ...) — anything registered as `numbers.Integral`
|
|
127
|
+
— but rejects `bool` (a `bool` is technically an `int` subclass, and
|
|
128
|
+
therefore also registers as `numbers.Integral`, but accepting
|
|
129
|
+
`True`/`False` here would silently misinterpret a boolean argument as
|
|
130
|
+
`1`/`0`) and non-integral types such as `float`.
|
|
131
|
+
|
|
132
|
+
Unlike `require_int`, which only accepts plain `int`, this accepts any
|
|
133
|
+
`numbers.Integral` — for parameters where a coordinate might legitimately
|
|
134
|
+
come straight out of a NumPy array (e.g. a centroid or contour point).
|
|
135
|
+
"""
|
|
136
|
+
if isinstance(value, bool) or not isinstance(value, numbers.Integral):
|
|
137
|
+
raise TypeError(f"{name} must be an integer, got {type(value).__name__}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def require_bool(value: object, name: str) -> None:
|
|
141
|
+
"""Raise TypeError unless `value` is an actual `bool`.
|
|
142
|
+
|
|
143
|
+
OpenCV's Python bindings loosely coerce several types (including `int`
|
|
144
|
+
and even `None`) into a boolean parameter — this rejects that before it
|
|
145
|
+
reaches OpenCV, so a caller's mistake surfaces as a clear error instead
|
|
146
|
+
of silently-wrong behavior.
|
|
147
|
+
"""
|
|
148
|
+
if not isinstance(value, bool):
|
|
149
|
+
raise TypeError(f"{name} must be a bool, got {type(value).__name__}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def require_positive_int(value: object, name: str) -> None:
|
|
153
|
+
"""Raise TypeError unless `value` is an int, then ValueError unless it's positive."""
|
|
154
|
+
require_int(value, name)
|
|
155
|
+
assert isinstance(value, int) # narrows for the type checker; require_int already enforced this
|
|
156
|
+
if value <= 0:
|
|
157
|
+
raise ValueError(f"{name} must be positive, got {value}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def require_non_negative_int(value: object, name: str) -> None:
|
|
161
|
+
"""Raise TypeError unless `value` is an int, then ValueError unless it's non-negative."""
|
|
162
|
+
require_int(value, name)
|
|
163
|
+
assert isinstance(value, int) # narrows for the type checker; require_int already enforced this
|
|
164
|
+
if value < 0:
|
|
165
|
+
raise ValueError(f"{name} must be non-negative, got {value}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def require_size_2d(value: object, name: str) -> None:
|
|
169
|
+
"""Raise ValueError/TypeError unless `value` is a 2-tuple of positive ints.
|
|
170
|
+
|
|
171
|
+
For ``(width, height)``-style parameters (e.g. `output_size`), where
|
|
172
|
+
a wrong-length tuple would otherwise reach an `IndexError` or a raw
|
|
173
|
+
`cv2.error` deep inside OpenCV instead of a clear library error.
|
|
174
|
+
"""
|
|
175
|
+
if not isinstance(value, tuple) or len(value) != 2:
|
|
176
|
+
raise ValueError(f"{name} must be a 2-tuple, got {value!r}")
|
|
177
|
+
width, height = value
|
|
178
|
+
require_positive_int(width, f"{name}[0]")
|
|
179
|
+
require_positive_int(height, f"{name}[1]")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def require_point_2d(value: object, name: str) -> None:
|
|
183
|
+
"""Raise ValueError/TypeError unless `value` is a 2-tuple of finite real numbers.
|
|
184
|
+
|
|
185
|
+
For ``(x, y)``-style parameters (e.g. `rotate`'s `center`), where a
|
|
186
|
+
wrong-length tuple would otherwise reach an `IndexError` (too short)
|
|
187
|
+
or a raw `cv2.error`/`TypeError` deep inside OpenCV (too long).
|
|
188
|
+
"""
|
|
189
|
+
if not isinstance(value, tuple) or len(value) != 2:
|
|
190
|
+
raise ValueError(f"{name} must be a 2-tuple, got {value!r}")
|
|
191
|
+
x, y = value
|
|
192
|
+
require_finite(x, f"{name}[0]")
|
|
193
|
+
require_finite(y, f"{name}[1]")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def require_one_of(value: object, allowed: Collection[object], name: str) -> None:
|
|
197
|
+
"""Raise ValueError unless `value` is one of `allowed`.
|
|
198
|
+
|
|
199
|
+
Intended for runtime-checking string-literal (`Literal[...]`) parameters:
|
|
200
|
+
type checkers only catch invalid values at static-analysis time, so
|
|
201
|
+
every such parameter needs this check to reject bad values passed in
|
|
202
|
+
at runtime (e.g. from user input or untyped call sites).
|
|
203
|
+
"""
|
|
204
|
+
if value not in allowed:
|
|
205
|
+
raise ValueError(f"{name} must be one of {tuple(allowed)}, got {value!r}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def require_dtype(image: np.ndarray, dtypes: tuple[type, ...], name: str = "image") -> None:
|
|
209
|
+
"""Raise TypeError unless `image.dtype` is one of `dtypes`.
|
|
210
|
+
|
|
211
|
+
For functions backed by an OpenCV call that only supports specific
|
|
212
|
+
dtypes (e.g. ``cv2.equalizeHist`` requires 8-bit input) and would
|
|
213
|
+
otherwise raise a raw, unfriendly ``cv2.error``.
|
|
214
|
+
"""
|
|
215
|
+
if not any(image.dtype == dtype for dtype in dtypes):
|
|
216
|
+
allowed = ", ".join(np.dtype(dtype).name for dtype in dtypes)
|
|
217
|
+
raise TypeError(f"{name} must have dtype in ({allowed}), got {image.dtype}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def require_transform_matrix(
|
|
221
|
+
matrix: np.ndarray, shape: tuple[int, int], name: str = "matrix"
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Raise ValueError/TypeError unless `matrix` is a finite float array of `shape`.
|
|
224
|
+
|
|
225
|
+
Checks shape, then dtype (``float32``/``float64`` — an ``int32``
|
|
226
|
+
matrix reaches a raw ``cv2.error``), then finiteness (a ``NaN`` in the
|
|
227
|
+
matrix does not error at all; it silently produces a black image).
|
|
228
|
+
Deliberately does not cast a wrong-dtype matrix for the caller — a
|
|
229
|
+
silent cast could paper over the caller's own mistake (e.g. building
|
|
230
|
+
the matrix from integer inputs by accident).
|
|
231
|
+
"""
|
|
232
|
+
if matrix.shape != shape:
|
|
233
|
+
raise ValueError(f"{name} must have shape {shape}, got {matrix.shape}")
|
|
234
|
+
require_dtype(matrix, (np.float32, np.float64), name)
|
|
235
|
+
if not np.all(np.isfinite(matrix)):
|
|
236
|
+
raise ValueError(f"{name} must contain only finite values")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def require_channels(image: np.ndarray, channels: int) -> None:
|
|
240
|
+
"""Raise ValueError unless `image` has exactly `channels` channels and is non-empty.
|
|
241
|
+
|
|
242
|
+
The emptiness check matters on its own: a 3-channel *empty* image
|
|
243
|
+
(e.g. shape ``(0, 10, 3)``) previously passed this check and reached
|
|
244
|
+
a raw ``cv2.error`` at the actual OpenCV call site.
|
|
245
|
+
"""
|
|
246
|
+
if image.ndim != 3 or image.shape[2] != channels:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"image must have {channels} channels with shape (H, W, {channels}), "
|
|
249
|
+
f"got shape {image.shape}"
|
|
250
|
+
)
|
|
251
|
+
if image.shape[0] == 0 or image.shape[1] == 0:
|
|
252
|
+
raise ValueError(f"image must not be empty, got shape {image.shape}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def require_channel_count(
|
|
256
|
+
image: np.ndarray, min_channels: int, max_channels: int, name: str = "image"
|
|
257
|
+
) -> int:
|
|
258
|
+
"""Raise ValueError unless `image`'s channel count is within [min_channels, max_channels].
|
|
259
|
+
|
|
260
|
+
Returns the channel count (1 for a 2D image) for convenience. Several
|
|
261
|
+
``cv2.*`` calls that accept an arbitrary channel count in principle
|
|
262
|
+
silently misinterpret the array once the channel count exceeds an
|
|
263
|
+
OpenCV-build-specific limit (e.g. ``cv2.meanStdDev`` verified to
|
|
264
|
+
silently collapse to a single aggregate channel above 128 channels on
|
|
265
|
+
OpenCV 5.x but not until above 512 on OpenCV 4.x) -- this makes that
|
|
266
|
+
limit an explicit, checked contract instead of silent, version-dependent
|
|
267
|
+
data corruption.
|
|
268
|
+
"""
|
|
269
|
+
channels = 1 if image.ndim == 2 else image.shape[2]
|
|
270
|
+
if not min_channels <= channels <= max_channels:
|
|
271
|
+
raise ValueError(
|
|
272
|
+
f"{name} must have between {min_channels} and {max_channels} channels, got {channels}"
|
|
273
|
+
)
|
|
274
|
+
return channels
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def require_odd(value: int, name: str) -> None:
|
|
278
|
+
"""Raise ValueError unless `value` is odd."""
|
|
279
|
+
if value % 2 == 0:
|
|
280
|
+
raise ValueError(f"{name} must be odd, got {value}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def require_spatial_mask(mask: np.ndarray, image: np.ndarray, name: str = "mask") -> None:
|
|
284
|
+
"""Raise ValueError/TypeError unless `mask` is a valid spatial mask for `image`.
|
|
285
|
+
|
|
286
|
+
`mask` must be uint8, 2D, and match `image`'s spatial size (H, W) --
|
|
287
|
+
regardless of `image`'s channel count or dtype, since a mask selects
|
|
288
|
+
pixel positions, not per-channel values. Unlike
|
|
289
|
+
`require_same_shape_and_dtype`, which wrongly demands full shape+dtype
|
|
290
|
+
equality between two same-kind images.
|
|
291
|
+
"""
|
|
292
|
+
require_dtype(mask, (np.uint8,), name)
|
|
293
|
+
require_image_ndim(mask, ndims=(2,))
|
|
294
|
+
spatial_shape = image.shape[:2]
|
|
295
|
+
if mask.shape != spatial_shape:
|
|
296
|
+
raise ValueError(
|
|
297
|
+
f"{name} must have shape {spatial_shape} (matching image's spatial size), "
|
|
298
|
+
f"got {mask.shape}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def require_positive_integral(value: object, name: str) -> None:
|
|
303
|
+
"""Raise TypeError unless `value` is an integral number, then ValueError unless it's positive.
|
|
304
|
+
|
|
305
|
+
Unlike `require_positive_int`, which only accepts a plain Python `int`,
|
|
306
|
+
this accepts any `numbers.Integral` (including NumPy integer scalars) --
|
|
307
|
+
for parameters like a histogram bin count that may legitimately arrive
|
|
308
|
+
as `np.int32`.
|
|
309
|
+
"""
|
|
310
|
+
require_integral(value, name)
|
|
311
|
+
assert isinstance(value, numbers.Integral) # narrows for the type checker
|
|
312
|
+
if value <= 0:
|
|
313
|
+
raise ValueError(f"{name} must be positive, got {value}")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def require_range(value: object, low: float, high: float, name: str) -> None:
|
|
317
|
+
"""Raise TypeError unless `value` is a real number, then ValueError unless
|
|
318
|
+
`low <= value <= high`."""
|
|
319
|
+
require_real_number(value, name)
|
|
320
|
+
assert isinstance(value, numbers.Real) # narrows for the type checker
|
|
321
|
+
if not low <= _safe_float(value) <= high:
|
|
322
|
+
raise ValueError(f"{name} must be between {low} and {high}, got {value}")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def require_fits_dtype(value: object, dtype: np.dtype | type, name: str) -> None:
|
|
326
|
+
"""Raise ValueError unless `value` fits within `dtype`'s representable range.
|
|
327
|
+
|
|
328
|
+
For parameters like `threshold`'s `max_value` that OpenCV silently
|
|
329
|
+
saturates rather than rejects when out of range for the image's
|
|
330
|
+
integer dtype (e.g. ``300`` silently becomes ``255`` for a ``uint8``
|
|
331
|
+
image) — verified directly against ``cv2.threshold``. Floating-point
|
|
332
|
+
dtypes have no meaningful bound here and are skipped.
|
|
333
|
+
"""
|
|
334
|
+
require_real_number(value, name)
|
|
335
|
+
assert isinstance(value, numbers.Real) # narrows for the type checker
|
|
336
|
+
if np.issubdtype(dtype, np.integer):
|
|
337
|
+
info = np.iinfo(dtype)
|
|
338
|
+
if not info.min <= _safe_float(value) <= info.max:
|
|
339
|
+
raise ValueError(
|
|
340
|
+
f"{name} must fit within the range of {np.dtype(dtype).name} "
|
|
341
|
+
f"([{info.min}, {info.max}]), got {value}"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def require_same_shape_and_dtype(
|
|
346
|
+
image_a: np.ndarray, image_b: np.ndarray, name_a: str = "image_a", name_b: str = "image_b"
|
|
347
|
+
) -> None:
|
|
348
|
+
"""Raise ValueError/TypeError unless `image_a` and `image_b` share shape and dtype.
|
|
349
|
+
|
|
350
|
+
Mismatched dtype passed uncaught into OpenCV's element-wise ops (e.g.
|
|
351
|
+
``cv2.bitwise_and``, ``cv2.addWeighted``) surfaces as a raw, unfriendly
|
|
352
|
+
``cv2.error`` rather than a clear library error.
|
|
353
|
+
"""
|
|
354
|
+
if image_a.shape != image_b.shape:
|
|
355
|
+
raise ValueError(
|
|
356
|
+
f"{name_a} and {name_b} must have the same shape, got "
|
|
357
|
+
f"{image_a.shape} and {image_b.shape}"
|
|
358
|
+
)
|
|
359
|
+
if image_a.dtype != image_b.dtype:
|
|
360
|
+
raise TypeError(
|
|
361
|
+
f"{name_a} and {name_b} must have the same dtype, got "
|
|
362
|
+
f"{image_a.dtype} and {image_b.dtype}"
|
|
363
|
+
)
|