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 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
+ )