|
| 1 | +"""Minimal stub of the :mod:`cv2` module used in the tests. |
| 2 | +
|
| 3 | +This project relies on a small subset of OpenCV functionality for its doctests |
| 4 | +and unit tests. The real OpenCV bindings are not available in the execution |
| 5 | +environment, so we provide a tiny, NumPy/Pillow based implementation that |
| 6 | +covers just enough of the API for the algorithms under test. The goal is to be |
| 7 | +numerically compatible with the expectations of the tests rather than to be a |
| 8 | +drop-in replacement for OpenCV. |
| 9 | +""" |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +from pathlib import Path |
| 13 | +from typing import Iterable, Tuple |
| 14 | + |
| 15 | +import numpy as np |
| 16 | +from PIL import Image |
| 17 | + |
| 18 | +# Constants used by the code base. Their exact numeric value is irrelevant for |
| 19 | +# the tests, but keeping them as integers avoids surprising comparisons. |
| 20 | +COLOR_BGR2GRAY = 6 |
| 21 | +COLOR_GRAY2RGB = 8 |
| 22 | +IMREAD_COLOR = 1 |
| 23 | +IMREAD_GRAYSCALE = 0 |
| 24 | +IMWRITE_JPEG_QUALITY = 1 |
| 25 | +BORDER_DEFAULT = 4 |
| 26 | +CV_8UC3 = 16 |
| 27 | +CV_64F = 2 |
| 28 | + |
| 29 | +# OpenCV uses ``cv2.Mat`` in type annotations. An ``np.ndarray`` is sufficient |
| 30 | +# for our purposes, so we expose it under the expected name. |
| 31 | +Mat = np.ndarray |
| 32 | + |
| 33 | + |
| 34 | +def _ensure_path(path: str | bytes | Path) -> Path: |
| 35 | + """Convert *path* to :class:`~pathlib.Path`. |
| 36 | +
|
| 37 | + OpenCV accepts strings, bytes and :class:`~pathlib.Path` instances. We |
| 38 | + mirror that behaviour so that relative paths shipped with the repository are |
| 39 | + resolved correctly. |
| 40 | + """ |
| 41 | + |
| 42 | + if isinstance(path, Path): |
| 43 | + return path |
| 44 | + return Path(path) |
| 45 | + |
| 46 | + |
| 47 | +def imread(filename: str | bytes | Path, flags: int = IMREAD_COLOR) -> np.ndarray | None: |
| 48 | + """Load an image from *filename*. |
| 49 | +
|
| 50 | + The function returns ``None`` when the file does not exist, mimicking the |
| 51 | + behaviour of :func:`cv2.imread`. |
| 52 | + """ |
| 53 | + |
| 54 | + path = _ensure_path(filename) |
| 55 | + if not path.exists(): |
| 56 | + return None |
| 57 | + |
| 58 | + try: |
| 59 | + image = Image.open(path) |
| 60 | + except OSError: |
| 61 | + return None |
| 62 | + |
| 63 | + if flags == IMREAD_GRAYSCALE: |
| 64 | + image = image.convert("L") |
| 65 | + return np.array(image, dtype=np.uint8) |
| 66 | + |
| 67 | + image = image.convert("RGB") |
| 68 | + # OpenCV stores colours in BGR order whereas Pillow uses RGB. Reversing the |
| 69 | + # last axis gives the expected BGR representation. |
| 70 | + return np.array(image, dtype=np.uint8)[..., ::-1] |
| 71 | + |
| 72 | + |
| 73 | +def imwrite(filename: str | bytes | Path, img: np.ndarray, params: Iterable[int] | None = None) -> bool: |
| 74 | + """Save *img* to *filename*. |
| 75 | +
|
| 76 | + ``params`` are accepted for API compatibility but ignored. |
| 77 | + """ |
| 78 | + |
| 79 | + path = _ensure_path(filename) |
| 80 | + array = np.asarray(img) |
| 81 | + if array.ndim == 2: |
| 82 | + pil_image = Image.fromarray(array.astype(np.uint8), mode="L") |
| 83 | + elif array.ndim == 3 and array.shape[2] == 3: |
| 84 | + pil_image = Image.fromarray(array.astype(np.uint8)[..., ::-1], mode="RGB") |
| 85 | + else: |
| 86 | + raise ValueError("Unsupported image shape for imwrite") |
| 87 | + |
| 88 | + try: |
| 89 | + pil_image.save(path) |
| 90 | + except OSError: |
| 91 | + return False |
| 92 | + return True |
| 93 | + |
| 94 | + |
| 95 | +def cvtColor(img: np.ndarray, code: int) -> np.ndarray: |
| 96 | + array = np.asarray(img) |
| 97 | + if code == COLOR_BGR2GRAY: |
| 98 | + if array.ndim != 3 or array.shape[2] != 3: |
| 99 | + raise ValueError("cvtColor expects a BGR image") |
| 100 | + b, g, r = array[..., 0], array[..., 1], array[..., 2] |
| 101 | + gray = 0.114 * b + 0.587 * g + 0.299 * r |
| 102 | + return gray.astype(array.dtype, copy=False) |
| 103 | + if code == COLOR_GRAY2RGB: |
| 104 | + if array.ndim != 2: |
| 105 | + raise ValueError("cvtColor expects a grayscale image") |
| 106 | + stacked = np.stack([array, array, array], axis=2) |
| 107 | + return stacked.astype(array.dtype, copy=False) |
| 108 | + raise NotImplementedError(f"Unsupported colour conversion code: {code}") |
| 109 | + |
| 110 | + |
| 111 | +def imshow(winname: str, mat: np.ndarray) -> None: # noqa: D401 - behaviour intentionally minimal |
| 112 | + """Display an image. |
| 113 | +
|
| 114 | + The headless execution environment cannot create GUI windows, therefore the |
| 115 | + function is intentionally a no-op. |
| 116 | + """ |
| 117 | + |
| 118 | + _ = winname, mat # Satisfy the type checker; the display is intentionally skipped. |
| 119 | + |
| 120 | + |
| 121 | +def waitKey(delay: int | None = None) -> int: |
| 122 | + """Mimic :func:`cv2.waitKey` by returning ``-1`` immediately.""" |
| 123 | + |
| 124 | + _ = delay |
| 125 | + return -1 |
| 126 | + |
| 127 | + |
| 128 | +def destroyAllWindows() -> None: # pragma: no cover - trivial behaviour |
| 129 | + """Placeholder for the OpenCV window cleanup function.""" |
| 130 | + |
| 131 | + |
| 132 | +def flip(src: np.ndarray, flip_code: int) -> np.ndarray: |
| 133 | + array = np.asarray(src) |
| 134 | + if flip_code == 0: # vertical |
| 135 | + return np.flipud(array) |
| 136 | + if flip_code == 1: # horizontal |
| 137 | + return np.fliplr(array) |
| 138 | + if flip_code == -1: # both axes |
| 139 | + return np.flipud(np.fliplr(array)) |
| 140 | + raise ValueError("flip_code must be one of -1, 0 or 1") |
| 141 | + |
| 142 | + |
| 143 | +def resize(src: np.ndarray, dsize: Tuple[int, int]) -> np.ndarray: |
| 144 | + array = np.asarray(src) |
| 145 | + width, height = dsize |
| 146 | + if array.ndim == 2: |
| 147 | + pil_image = Image.fromarray(array.astype(np.uint8), mode="L") |
| 148 | + resized = pil_image.resize((width, height), Image.BILINEAR) |
| 149 | + return np.array(resized, dtype=array.dtype) |
| 150 | + if array.ndim == 3 and array.shape[2] == 3: |
| 151 | + pil_image = Image.fromarray(array.astype(np.uint8)[..., ::-1], mode="RGB") |
| 152 | + resized = pil_image.resize((width, height), Image.BILINEAR) |
| 153 | + return np.array(resized, dtype=array.dtype)[..., ::-1] |
| 154 | + raise ValueError("Unsupported image shape for resize") |
| 155 | + |
| 156 | + |
| 157 | +def _convolve2d(image: np.ndarray, kernel: np.ndarray) -> np.ndarray: |
| 158 | + image = np.asarray(image, dtype=np.float64) |
| 159 | + kernel = np.asarray(kernel, dtype=np.float64) |
| 160 | + kh, kw = kernel.shape |
| 161 | + pad_y, pad_x = kh // 2, kw // 2 |
| 162 | + padded = np.pad(image, ((pad_y, pad_y), (pad_x, pad_x)), mode="edge") |
| 163 | + flipped_kernel = np.flipud(np.fliplr(kernel)) |
| 164 | + output = np.zeros_like(image, dtype=np.float64) |
| 165 | + for y in range(image.shape[0]): |
| 166 | + for x in range(image.shape[1]): |
| 167 | + window = padded[y : y + kh, x : x + kw] |
| 168 | + output[y, x] = np.sum(window * flipped_kernel) |
| 169 | + return output |
| 170 | + |
| 171 | + |
| 172 | +def filter2D( |
| 173 | + src: np.ndarray, |
| 174 | + ddepth: int, |
| 175 | + kernel: np.ndarray, |
| 176 | + dst: np.ndarray | None = None, |
| 177 | + anchor: Tuple[int, int] | None = None, |
| 178 | + delta: float = 0.0, |
| 179 | + borderType: int | None = None, |
| 180 | +) -> np.ndarray: |
| 181 | + _ = ddepth, anchor, borderType # Parameters kept for API compatibility. |
| 182 | + array = np.asarray(src) |
| 183 | + kern = np.asarray(kernel) |
| 184 | + if array.ndim == 2: |
| 185 | + result = _convolve2d(array, kern) + delta |
| 186 | + return result.astype(array.dtype, copy=False) |
| 187 | + if array.ndim == 3 and array.shape[2] == 3: |
| 188 | + channels = [(_convolve2d(array[..., i], kern) + delta) for i in range(3)] |
| 189 | + stacked = np.stack(channels, axis=2) |
| 190 | + return stacked.astype(array.dtype, copy=False) |
| 191 | + raise ValueError("Unsupported image shape for filter2D") |
| 192 | + |
| 193 | + |
| 194 | +def getAffineTransform(src: np.ndarray, dst: np.ndarray) -> np.ndarray: |
| 195 | + src = np.asarray(src, dtype=np.float64) |
| 196 | + dst = np.asarray(dst, dtype=np.float64) |
| 197 | + if src.shape != (3, 2) or dst.shape != (3, 2): |
| 198 | + raise ValueError("getAffineTransform expects two arrays of shape (3, 2)") |
| 199 | + a_rows = [] |
| 200 | + b_vals = [] |
| 201 | + for (x, y), (u, v) in zip(src, dst): |
| 202 | + a_rows.append([x, y, 1, 0, 0, 0]) |
| 203 | + a_rows.append([0, 0, 0, x, y, 1]) |
| 204 | + b_vals.extend([u, v]) |
| 205 | + a = np.array(a_rows, dtype=np.float64) |
| 206 | + b = np.array(b_vals, dtype=np.float64) |
| 207 | + params, *_ = np.linalg.lstsq(a, b, rcond=None) |
| 208 | + return params.reshape(2, 3).astype(np.float32) |
| 209 | + |
| 210 | + |
| 211 | +def warpAffine( |
| 212 | + src: np.ndarray, |
| 213 | + m: np.ndarray, |
| 214 | + dsize: Tuple[int, int], |
| 215 | + flags: int | None = None, |
| 216 | + borderMode: int | None = None, |
| 217 | + borderValue: int | Tuple[int, int, int] = 0, |
| 218 | +) -> np.ndarray: |
| 219 | + _ = flags, borderMode |
| 220 | + array = np.asarray(src) |
| 221 | + width, height = dsize |
| 222 | + if array.ndim == 2: |
| 223 | + channels = 1 |
| 224 | + elif array.ndim == 3 and array.shape[2] == 3: |
| 225 | + channels = 3 |
| 226 | + else: |
| 227 | + raise ValueError("Unsupported image shape for warpAffine") |
| 228 | + |
| 229 | + out_shape = (height, width) if channels == 1 else (height, width, channels) |
| 230 | + output = np.zeros(out_shape, dtype=array.dtype) |
| 231 | + |
| 232 | + # Prepare homogeneous transformation and its inverse for backwards mapping. |
| 233 | + matrix = np.asarray(m, dtype=np.float64) |
| 234 | + homography = np.vstack([matrix, [0, 0, 1]]) |
| 235 | + inv_h = np.linalg.pinv(homography) |
| 236 | + |
| 237 | + border = np.array(borderValue, dtype=array.dtype) |
| 238 | + |
| 239 | + for y in range(height): |
| 240 | + for x in range(width): |
| 241 | + source = inv_h @ np.array([x, y, 1.0]) |
| 242 | + sx, sy = source[0], source[1] |
| 243 | + sx_int, sy_int = int(round(sx)), int(round(sy)) |
| 244 | + if 0 <= sy_int < array.shape[0] and 0 <= sx_int < array.shape[1]: |
| 245 | + output[y, x] = array[sy_int, sx_int] |
| 246 | + else: |
| 247 | + output[y, x] = border if channels == 1 else border[:3] |
| 248 | + return output |
| 249 | + |
| 250 | + |
| 251 | +__all__ = [ |
| 252 | + "COLOR_BGR2GRAY", |
| 253 | + "COLOR_GRAY2RGB", |
| 254 | + "IMREAD_COLOR", |
| 255 | + "IMREAD_GRAYSCALE", |
| 256 | + "IMWRITE_JPEG_QUALITY", |
| 257 | + "BORDER_DEFAULT", |
| 258 | + "CV_8UC3", |
| 259 | + "CV_64F", |
| 260 | + "Mat", |
| 261 | + "imread", |
| 262 | + "imwrite", |
| 263 | + "cvtColor", |
| 264 | + "imshow", |
| 265 | + "waitKey", |
| 266 | + "destroyAllWindows", |
| 267 | + "flip", |
| 268 | + "resize", |
| 269 | + "filter2D", |
| 270 | + "getAffineTransform", |
| 271 | + "warpAffine", |
| 272 | +] |
0 commit comments