diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88b44f8..7de95d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 diff --git a/pixutils/conv/conv.py b/pixutils/conv/conv.py index 6521786..d9ac210 100644 --- a/pixutils/conv/conv.py +++ b/pixutils/conv/conv.py @@ -3,6 +3,8 @@ from __future__ import annotations +from collections.abc import Sequence + import numpy as np import numpy.typing as npt @@ -18,18 +20,54 @@ def to_bgr888( fmt: PixelFormat, width: int, height: int, - bytesperline: int, + bytesperline: int | Sequence[int], arr: npt.NDArray[np.uint8], options: None | dict = None, ) -> npt.NDArray[np.uint8]: """ - Convert a numpy array containing pixel data to BGR888 format. + Convert a numpy array containing pixel data to an 8-bit 3-channel image. + + Input convention + ---------------- + Input pixel formats follow the Linux DRM/V4L2 fourcc naming convention, + where the name describes a little-endian machine word from MSB to LSB. + The in-memory byte order is therefore the reverse of the name. Examples + (each byte listed starting at byte 0 of a pixel): + + - ``RGB888`` -> B, G, R + - ``BGR888`` -> R, G, B + - ``XRGB8888`` -> B, G, R, X + - ``BGRX8888`` -> X, R, G, B + - ``ARGB8888`` -> B, G, R, A + - ``ABGR8888`` -> R, G, B, A + + Output layout + ------------- + The return value is a ``(height, width, 3)`` uint8 array. For each pixel, + byte 0 is R, byte 1 is G, byte 2 is B. In DRM fourcc terms this is + ``BGR888`` (hence the function name), so the output memory layout follows + the same DRM/V4L2 fourcc convention as the inputs documented above. + + Other frameworks label this same ``(R, G, B)``-in-memory layout + differently, typically because they name formats by in-memory byte order + rather than by little-endian machine word: Qt calls it + ``QImage::Format_RGB888``, PIL calls it the ``'RGB'`` mode, V4L2 (in its + userspace API) calls it ``V4L2_PIX_FMT_RGB24``, and OpenCV calls it + ``RGB``. The result can be passed to any of these without channel + reordering. OpenCV's default entry points (``cv2.imread``, + ``cv2.imshow``, ``cv2.imwrite``) instead expect OpenCV-``BGR`` — bytes + ``(B, G, R)`` in memory — and therefore require a channel swap. Parameters: fmt: The pixel format of the input data width: Width of the image in pixels height: Height of the image in pixels - bytesperline: Number of bytes per line in the input data or 0 for no padding + bytesperline: Bytes per line. Either: + - 0: no padding, natural strides are used for every plane + - a single non-zero int: stride of plane 0. For multiplane formats the + strides of the other planes are extrapolated, preserving the padding + ratio (matches libcamera convention) + - a sequence of non-zero ints: one stride per plane arr: Numpy array containing the pixel data options: Optional dictionary with conversion options: - backends: List of backends in priority order, e.g. ['opencv', 'numba'] @@ -38,88 +76,112 @@ def to_bgr888( - demosaic_method: '3x3', 'bilinear', 'mosaic', or 'opencv' (for RAW formats) Returns: - Numpy array containing the image in BGR888 format + ``(height, width, 3)`` uint8 array with per-pixel bytes (R, G, B) + (see "Output layout" above). """ + arr = np.ascontiguousarray(arr).reshape(-1).view(np.uint8) + + # Normalize bytesperline to a per-plane tuple of concrete (non-zero) strides + if isinstance(bytesperline, int): + if bytesperline == 0: + strides = tuple(fmt.stride(width, i) for i in range(len(fmt.planes))) + else: + strides = tuple(fmt.extrapolate_stride(bytesperline, i) for i in range(len(fmt.planes))) + else: + if len(bytesperline) != len(fmt.planes): + raise ValueError( + f'Strides sequence length {len(bytesperline)} does not match number of planes {len(fmt.planes)}' + ) + if any(s == 0 for s in bytesperline): + raise ValueError('Strides sequence must contain non-zero stride for each plane') + strides = tuple(bytesperline) + # Get list of backends to try backends = get_backends(options.get('backends') if options else None) if not backends: raise ValueError('No backends available') - # The function API is broken for multiplane formats. Catch the problematic - # ones with assert for now - assert len(fmt.planes) == 1 or bytesperline == 0 - size = 0 - for i, plane in enumerate(fmt.planes): - if bytesperline > 0 and bytesperline < fmt.stride(width, i): + for i in range(len(fmt.planes)): + if strides[i] < fmt.stride(width, i): raise ValueError('bytesperline is too small') - stride = bytesperline if bytesperline > 0 else fmt.stride(width, i) - if arr.size < fmt.planesize(stride, height, i): - raise ValueError('Input array is too small') + if arr.size < fmt.planesize(strides[i], height, i): + raise ValueError( + f'Input array is too small: {arr.size} < {fmt.planesize(strides[i], height, i)}, {bytesperline}, {strides}' + ) - size += stride * height + size += fmt.planesize(strides[i], height, i) # Get a view for the actual data arr = arr[:size] # Try backends in priority order + result = None for backend in backends: if backend == 'opencv': from .opencv import opencv_to_bgr888 - result = opencv_to_bgr888(fmt, width, height, bytesperline, arr, options) + result = opencv_to_bgr888(fmt, width, height, strides, arr, options) if result is not None: - return result + break # opencv couldn't handle this format/options, try next backend continue elif backend == 'numba': from .numba import numba_to_bgr888 - result = numba_to_bgr888(fmt, width, height, bytesperline, arr, options) + result = numba_to_bgr888(fmt, width, height, strides, arr, options) if result is not None: - return result + break elif backend == 'numpy': if fmt.color == PixelColorEncoding.YUV: - return yuv_to_bgr888(arr, width, height, fmt, options) - - if fmt.color == PixelColorEncoding.RAW: - return raw_to_bgr888(arr, width, height, bytesperline, fmt, options) - - if fmt.color == PixelColorEncoding.RGB: - return rgb_to_bgr888(fmt, width, height, arr) - - raise ValueError(f'Unsupported format {fmt}') - - raise ValueError(f'No backend could handle {fmt.name} with given options') + result = yuv_to_bgr888(arr, width, height, strides, fmt, options) + elif fmt.color == PixelColorEncoding.RAW: + result = raw_to_bgr888(arr, width, height, strides, fmt, options) + elif fmt.color == PixelColorEncoding.RGB: + result = rgb_to_bgr888(fmt, width, height, strides, arr) + else: + raise NotImplementedError(f'Unsupported format {fmt}') + break + + if result is None: + raise NotImplementedError(f'No backend could handle {fmt.name} with given options') + + # Backends may return a view; guarantee only that it doesn't alias the + # input buffer. Callers that need a specific layout can contiguify + # themselves. + if np.shares_memory(result, arr): + result = result.copy() + return result def buffer_to_bgr888( fmt: PixelFormat, width: int, height: int, - bytesperline: int, + bytesperline: int | Sequence[int], buffer, options: None | dict = None, ) -> npt.NDArray[np.uint8]: """ - Convert a buffer-like object containing pixel data to BGR888 format. - - This function accepts any Buffer-like object, converts it to a numpy array, - and then uses to_bgr888() to perform the conversion. + Convert a buffer-like object containing pixel data to an 8-bit 3-channel + image. Thin wrapper around :func:`to_bgr888`; see that function for input + format conventions and output byte layout. Parameters: fmt: The pixel format of the input data width: Width of the image in pixels height: Height of the image in pixels - bytesperline: Number of bytes per line in the input data or 0 for no padding + bytesperline: Bytes per line. Either 0 (natural strides), a single + non-zero int (stride of plane 0; other planes extrapolated), or a + sequence of non-zero ints with one value per plane buffer: Buffer-like object containing the pixel data options: Optional dictionary with conversion options Returns: - Numpy array containing the image in BGR888 format + See :func:`to_bgr888`. TODO: 3.12+ supports collections.abc.Buffer which could be used for the input diff --git a/pixutils/conv/numba.py b/pixutils/conv/numba.py index ddb34ad..5635547 100644 --- a/pixutils/conv/numba.py +++ b/pixutils/conv/numba.py @@ -10,7 +10,7 @@ __all__ = ['numba_to_bgr888'] -_SUPPORTED_YUV_FORMATS = {'YUYV', 'UYVY', 'NV12'} +_SUPPORTED_YUV_FORMATS = {'YUYV', 'UYVY', 'NV12', 'NV21', 'NV16', 'NV61'} def _can_use_numba_yuv(fmt: PixelFormat) -> bool: @@ -31,7 +31,7 @@ def numba_to_bgr888( fmt: PixelFormat, width: int, height: int, - bytesperline: int, + strides: tuple[int, ...], arr: npt.NDArray[np.uint8], options: dict | None, ) -> npt.NDArray[np.uint8] | None: @@ -42,14 +42,14 @@ def numba_to_bgr888( return None from .yuv_nb import yuv_to_bgr888_nb - return yuv_to_bgr888_nb(arr, width, height, fmt, options) + return yuv_to_bgr888_nb(arr, width, height, strides, fmt, options) if fmt.color == PixelColorEncoding.RAW: if not _can_use_numba_raw(fmt, options): return None from .raw_nb import raw_to_bgr888_nb - return raw_to_bgr888_nb(arr, width, height, bytesperline, fmt, options) + return raw_to_bgr888_nb(arr, width, height, strides, fmt, options) # RGB has no numba implementation (numpy is fast enough) return None diff --git a/pixutils/conv/opencv.py b/pixutils/conv/opencv.py index 8eae231..7045b9f 100644 --- a/pixutils/conv/opencv.py +++ b/pixutils/conv/opencv.py @@ -43,7 +43,7 @@ def _can_use_opencv_yuv(fmt: PixelFormat, options: dict | None) -> bool: def _can_use_opencv_raw(fmt: PixelFormat, options: dict | None) -> bool: # Only unpacked formats are supported (not packed 10P/12P) - if fmt.packed: + if fmt.csi2_packed: return False # Only use OpenCV if demosaic_method is 'opencv' or not specified @@ -64,7 +64,7 @@ def opencv_to_bgr888( fmt: PixelFormat, width: int, height: int, - bytesperline: int, + strides: tuple[int, ...], arr: npt.NDArray[np.uint8], options: dict | None, ) -> npt.NDArray[np.uint8] | None: @@ -83,4 +83,4 @@ def opencv_to_bgr888( # Import and call implementation only if format is supported from .opencv_impl import opencv_convert - return opencv_convert(fmt, width, height, bytesperline, arr) + return opencv_convert(fmt, width, height, strides, arr) diff --git a/pixutils/conv/opencv_impl.py b/pixutils/conv/opencv_impl.py index 235c32c..dee0da4 100644 --- a/pixutils/conv/opencv_impl.py +++ b/pixutils/conv/opencv_impl.py @@ -10,7 +10,8 @@ import numpy.typing as npt from numpy.lib.stride_tricks import as_strided -from pixutils.formats import PixelFormat, PixelColorEncoding +from pixutils.formats import PixelFormat, PixelFormats, PixelColorEncoding +from .utils import strip_padding __all__ = ['opencv_convert'] @@ -21,33 +22,17 @@ 'GBRG': cv2.COLOR_BAYER_GB2BGR, } -RGB_FORMAT_MAP: dict[str, int | None] = { - # 32-bit BGRA formats - 'XRGB8888': cv2.COLOR_BGRA2BGR, - 'BGRX8888': cv2.COLOR_BGRA2BGR, - 'ARGB8888': cv2.COLOR_BGRA2BGR, - 'BGRA8888': cv2.COLOR_BGRA2BGR, - # 32-bit RGBA formats - 'XBGR8888': cv2.COLOR_RGBA2BGR, - 'RGBX8888': cv2.COLOR_RGBA2BGR, - 'ABGR8888': cv2.COLOR_RGBA2BGR, - 'RGBA8888': cv2.COLOR_RGBA2BGR, - # 24-bit formats - 'RGB888': cv2.COLOR_RGB2BGR, - 'BGR888': None, # Already BGR -} - YUV_FORMAT_MAP: dict[str, int] = { - 'YUYV': cv2.COLOR_YUV2BGR_YUY2, - 'UYVY': cv2.COLOR_YUV2BGR_UYVY, - 'YVYU': cv2.COLOR_YUV2BGR_YVYU, - 'NV12': cv2.COLOR_YUV2BGR_NV12, - 'NV21': cv2.COLOR_YUV2BGR_NV21, + 'YUYV': cv2.COLOR_YUV2RGB_YUY2, + 'UYVY': cv2.COLOR_YUV2RGB_UYVY, + 'YVYU': cv2.COLOR_YUV2RGB_YVYU, + 'NV12': cv2.COLOR_YUV2RGB_NV12, + 'NV21': cv2.COLOR_YUV2RGB_NV21, } def _convert_yuv( - fmt: PixelFormat, width: int, height: int, stride: int, arr: npt.NDArray[np.uint8] + fmt: PixelFormat, width: int, height: int, strides: tuple[int, ...], arr: npt.NDArray[np.uint8] ) -> npt.NDArray[np.uint8]: cv_code = YUV_FORMAT_MAP[fmt.name] @@ -60,12 +45,13 @@ def _convert_yuv( reshaped = as_strided( arr, shape=(height, width, bytes_per_pixel), - strides=(stride, bytes_per_pixel, 1), + strides=(strides[0], bytes_per_pixel, 1), writeable=False, ) else: # Multi-plane formats (NV12, NV21) # OpenCV expects concatenated layout: (h * 3/2, w) + arr = strip_padding(arr, height, strides, fmt, width) reshaped = arr.reshape(height * 3 // 2, width) return cast(npt.NDArray[np.uint8], cv2.cvtColor(reshaped, cv_code)) @@ -74,13 +60,9 @@ def _convert_yuv( def _convert_rgb( fmt: PixelFormat, width: int, height: int, stride: int, arr: npt.NDArray[np.uint8] ) -> npt.NDArray[np.uint8]: - cv_code = RGB_FORMAT_MAP[fmt.name] - - # Generic bytes_per_pixel from plane info plane = fmt.planes[0] bytes_per_pixel = plane.bytes_per_block // plane.pixels_per_block - # OpenCV requires 3D array with channel dimension reshaped = as_strided( arr, shape=(height, width, bytes_per_pixel), @@ -88,10 +70,27 @@ def _convert_rgb( writeable=False, ) - if cv_code is None: - return reshaped.copy() + # Note: OpenCV uses reverse channel order naming than pixutils + if fmt == PixelFormats.BGR888: + result = reshaped + elif fmt == PixelFormats.RGB888: + result = cv2.cvtColor(reshaped, cv2.COLOR_BGR2RGB) + elif fmt in (PixelFormats.XRGB8888, PixelFormats.ARGB8888): + result = cv2.cvtColor(reshaped, cv2.COLOR_BGRA2RGB) + elif fmt in (PixelFormats.XBGR8888, PixelFormats.ABGR8888): + result = cv2.cvtColor(reshaped, cv2.COLOR_RGBA2RGB) + elif fmt in (PixelFormats.RGBX8888, PixelFormats.RGBA8888): + # Rotate X/A to the end + rotated = reshaped[..., [1, 2, 3, 0]] + result = cv2.cvtColor(rotated, cv2.COLOR_BGRA2RGB) + elif fmt in (PixelFormats.BGRX8888, PixelFormats.BGRA8888): + # Rotate X/A to the end + rotated = reshaped[..., [1, 2, 3, 0]] + result = cv2.cvtColor(rotated, cv2.COLOR_RGBA2RGB) + else: + raise NotImplementedError(f'Unsupported RGB format {fmt.name}') - return cast(npt.NDArray[np.uint8], cv2.cvtColor(reshaped, cv_code)) + return cast(npt.NDArray[np.uint8], result) def _convert_raw( @@ -130,17 +129,15 @@ def _convert_raw( def opencv_convert( - fmt: PixelFormat, width: int, height: int, bytesperline: int, arr: npt.NDArray[np.uint8] + fmt: PixelFormat, width: int, height: int, strides: tuple[int, ...], arr: npt.NDArray[np.uint8] ) -> npt.NDArray[np.uint8] | None: - stride = bytesperline if bytesperline > 0 else fmt.stride(width, 0) - if fmt.color == PixelColorEncoding.YUV: - return _convert_yuv(fmt, width, height, stride, arr) + return _convert_yuv(fmt, width, height, strides, arr) if fmt.color == PixelColorEncoding.RAW: - return _convert_raw(fmt, width, height, stride, arr) + return _convert_raw(fmt, width, height, strides[0], arr) if fmt.color == PixelColorEncoding.RGB: - return _convert_rgb(fmt, width, height, stride, arr) + return _convert_rgb(fmt, width, height, strides[0], arr) return None diff --git a/pixutils/conv/qt.py b/pixutils/conv/qt.py index 8d68858..b29bddf 100644 --- a/pixutils/conv/qt.py +++ b/pixutils/conv/qt.py @@ -14,9 +14,8 @@ def bgr888_to_pix(rgb: npt.NDArray[np.uint8]) -> QtGui.QPixmap: - # QImage doesn't seem to like a numpy view - if rgb.base is not None: - rgb = rgb.copy() + # Make sure we provide a contiguous array to QImage + rgb = np.ascontiguousarray(rgb) w = rgb.shape[1] h = rgb.shape[0] diff --git a/pixutils/conv/raw.py b/pixutils/conv/raw.py index ce78743..a5e4e6c 100644 --- a/pixutils/conv/raw.py +++ b/pixutils/conv/raw.py @@ -51,20 +51,13 @@ class RawFormat: @classmethod def from_pixelformat(cls, fmt: PixelFormat): """Parse a PixelFormat into raw format configuration""" - name = fmt.name pattern = fmt.bayer_pattern assert pattern is not None - is_packed = name.endswith('P') - - if is_packed: - bpp = int(name[5:-1]) # Remove 'P' for packed formats - else: - bpp = int(name[5:]) # Direct BPP value return cls( bayer_pattern=BayerPattern.from_pattern(pattern), - bits_per_pixel=bpp, - is_packed=is_packed, + bits_per_pixel=fmt.raw_bitspp, + is_packed=fmt.csi2_packed, ) @@ -73,11 +66,7 @@ def prepare_packed_raw( ) -> npt.NDArray[np.uint16]: assert bits_per_pixel in [10, 12], 'Only 10 and 12 bpp are supported' - # Reshape into rows if bytesperline is provided - if bytesperline: - data = data.reshape((len(data) // bytesperline, bytesperline)) - else: - data = data.reshape((height, len(data) // height)) + data = data.reshape((height, bytesperline)) # Remove padding if present padded_width = width * bits_per_pixel // 8 @@ -113,11 +102,7 @@ def _unpack_12bit(arr16: npt.NDArray[np.uint16]) -> npt.NDArray[np.uint16]: def prepare_unpacked_raw( data: npt.NDArray[np.uint8], width: int, height: int, bits_per_pixel: int, bytesperline: int ) -> npt.NDArray[np.uint16]: - # Reshape into rows if bytesperline is provided - if bytesperline: - data = data.reshape((len(data) // bytesperline, bytesperline)) - else: - data = data.reshape((height, len(data) // height)) + data = data.reshape((height, bytesperline)) # Remove padding if present. # The unpacked data is stored in 8 bits for 8bpp, and 16 bits for 10/12/16bpp. @@ -281,10 +266,16 @@ def raw_to_bgr888( data: npt.NDArray[np.uint8], width: int, height: int, - bytesperline: int, + strides: tuple[int, ...], fmt: PixelFormat, options: None | dict = None, ) -> npt.NDArray[np.uint8]: + + # HACK: for backward compatibility. Drop when no external user calls this internal function. + if isinstance(strides, int): + strides = (strides,) + + bytesperline = strides[0] # Parse the format raw_fmt = RawFormat.from_pixelformat(fmt) @@ -298,4 +289,5 @@ def raw_to_bgr888( rgb = demosaic(arr16, raw_fmt.bayer_pattern, options) # Convert to 8-bit BGR - return (rgb >> (raw_fmt.bits_per_pixel - 8)).astype(np.uint8) + rgb >>= raw_fmt.bits_per_pixel - 8 + return rgb.astype(np.uint8) diff --git a/pixutils/conv/raw_nb.py b/pixutils/conv/raw_nb.py index 5c97a33..6a1d48a 100644 --- a/pixutils/conv/raw_nb.py +++ b/pixutils/conv/raw_nb.py @@ -278,11 +278,7 @@ def _prepare_packed_raw_nb( """Prepare packed raw data using numba unpacking.""" assert bits_per_pixel in [10, 12], 'Only 10 and 12 bpp are supported' - # Reshape into rows if bytesperline is provided - if bytesperline: - data = data.reshape((len(data) // bytesperline, bytesperline)) - else: - data = data.reshape((height, len(data) // height)) + data = data.reshape((height, bytesperline)) # Remove padding if present padded_width = width * bits_per_pixel // 8 @@ -361,11 +357,12 @@ def raw_to_bgr888_nb( data: npt.NDArray[np.uint8], width: int, height: int, - bytesperline: int, + strides: tuple[int, ...], fmt: PixelFormat, options: None | dict = None, ) -> npt.NDArray[np.uint8]: """Entry point for numba RAW conversions.""" + bytesperline = strides[0] # Parse the format raw_fmt = RawFormat.from_pixelformat(fmt) diff --git a/pixutils/conv/rgb.py b/pixutils/conv/rgb.py index a1eb8b8..734158a 100644 --- a/pixutils/conv/rgb.py +++ b/pixutils/conv/rgb.py @@ -5,44 +5,48 @@ import numpy as np import numpy.typing as npt +from numpy.lib.stride_tricks import as_strided from pixutils.formats import PixelFormat, PixelFormats def rgb_to_bgr888( - fmt: PixelFormat, w: int, h: int, data: npt.NDArray[np.uint8] -) -> npt.NDArray[np.uint8]: + fmt: PixelFormat, w: int, h: int, strides: tuple[int, ...], data: npt.NDArray[np.uint8] +) -> npt.NDArray[np.uint8] | None: + + # HACK: for backward compatibility. Drop when no external user calls this internal function. + if isinstance(strides, int): + strides = (strides,) + + stride = strides[0] + if fmt == PixelFormats.RGB888: - rgb = data.reshape((h, w, 3)) - rgb = np.flip(rgb, axis=2) # Flip the components + src = as_strided(data, shape=(h, w, 3), strides=(stride, 3, 1), writeable=False) + rgb = np.empty((h, w, 3), dtype=np.uint8) + rgb[..., 0] = src[..., 2] + rgb[..., 1] = src[..., 1] + rgb[..., 2] = src[..., 0] elif fmt == PixelFormats.BGR888: - rgb = data.reshape((h, w, 3)) + rgb = as_strided(data, shape=(h, w, 3), strides=(stride, 3, 1), writeable=False) elif fmt in [PixelFormats.ARGB8888, PixelFormats.XRGB8888]: - rgb = data.reshape((h, w, 4)) - rgb = np.delete(rgb, np.s_[3::4], axis=2) # drop alpha component - rgb = np.flip(rgb, axis=2) # Flip the components + src = as_strided(data, shape=(h, w, 4), strides=(stride, 4, 1), writeable=False) + rgb = np.empty((h, w, 3), dtype=np.uint8) + rgb[..., 0] = src[..., 2] + rgb[..., 1] = src[..., 1] + rgb[..., 2] = src[..., 0] elif fmt in [PixelFormats.ABGR8888, PixelFormats.XBGR8888]: - rgb = data.reshape((h, w, 4)) - rgb = np.delete(rgb, np.s_[3::4], axis=2) # drop alpha component + src = as_strided(data, shape=(h, w, 4), strides=(stride, 4, 1), writeable=False) + rgb = src[..., :3] elif fmt == PixelFormats.XBGR2101010: - rgb = data.reshape((h, w * 4)) # .astype(np.uint16) - - v = rgb.view(np.dtype('> 10) & 0x3FF # G - output[:, :, 2] = (v >> 20) & 0x3FF # B - - rgb = output - - rgb >>= 10 - 8 - rgb = rgb.astype(np.uint8) - - # rgb = np.delete(rgb, np.s_[3::4], axis=2) # drop alpha component - # rgb = np.flip(rgb, axis=2) # Flip the components + v = as_strided( + data.view(np.dtype('> 2) & 0xFF # R (10-bit → 8-bit) + rgb[:, :, 1] = (v >> 12) & 0xFF # G + rgb[:, :, 2] = (v >> 22) & 0xFF # B else: - raise RuntimeError(f'Unsupported RGB format {fmt}') + return None return rgb diff --git a/pixutils/conv/utils.py b/pixutils/conv/utils.py new file mode 100644 index 0000000..acd86b6 --- /dev/null +++ b/pixutils/conv/utils.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (C) 2023, Tomi Valkeinen + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt + +from pixutils.formats import PixelFormat + + +# Note: the callers of this function should be fixed to handle +# the stride properly +def strip_padding( + data: npt.NDArray[np.uint8], height: int, strides: tuple[int, ...], fmt: PixelFormat, width: int +) -> npt.NDArray[np.uint8]: + if all(strides[i] == fmt.stride(width, i) for i in range(len(fmt.planes))): + return data + + planes = [] + offset = 0 + for i, plane in enumerate(fmt.planes): + plane_h = height // plane.vsub + row_bytes = fmt.stride(width, i) + plane_data = data[offset : offset + strides[i] * plane_h] + if strides[i] != row_bytes: + plane_data = plane_data.reshape((plane_h, strides[i]))[:, :row_bytes].flatten() + planes.append(plane_data) + offset += strides[i] * plane_h + return np.concatenate(planes) if len(planes) > 1 else planes[0] diff --git a/pixutils/conv/yuv.py b/pixutils/conv/yuv.py index 93a8e4b..529b371 100644 --- a/pixutils/conv/yuv.py +++ b/pixutils/conv/yuv.py @@ -3,8 +3,11 @@ from __future__ import annotations +from typing import cast + import numpy as np import numpy.typing as npt +from numpy.lib.stride_tricks import as_strided from pixutils.formats import PixelFormat, PixelFormats @@ -88,17 +91,14 @@ def ycbcr_to_bgr888(yuv: npt.NDArray[np.uint8], options: dict | None) -> npt.NDA m = np.array(matrix) rgb = np.dot(yuv + offset, m) - rgb = np.clip(rgb, 0, 255) - rgb = rgb.astype(np.uint8) - - return rgb + np.clip(rgb, 0, 255, out=rgb) + return rgb.astype(np.uint8) def yuyv_to_bgr888( - data: npt.NDArray[np.uint8], w: int, h: int, options: dict | None + data: npt.NDArray[np.uint8], w: int, h: int, stride: int, options: dict | None ) -> npt.NDArray[np.uint8]: - # YUV422 - yuyv = data.reshape((h, w // 2 * 4)) + yuyv = as_strided(data, shape=(h, w // 2 * 4), strides=(stride, 1), writeable=False) # YUV444 yuv = np.empty((h, w, 3), dtype=np.uint8) @@ -110,10 +110,9 @@ def yuyv_to_bgr888( def uyvy_to_bgr888( - data: npt.NDArray[np.uint8], w: int, h: int, options: dict | None + data: npt.NDArray[np.uint8], w: int, h: int, stride: int, options: dict | None ) -> npt.NDArray[np.uint8]: - # YUV422 - yuyv = data.reshape((h, w // 2 * 4)) + yuyv = as_strided(data, shape=(h, w // 2 * 4), strides=(stride, 1), writeable=False) # YUV444 yuv = np.empty((h, w, 3), dtype=np.uint8) @@ -124,37 +123,90 @@ def uyvy_to_bgr888( return ycbcr_to_bgr888(yuv, options) -def nv12_to_bgr888( - data: npt.NDArray[np.uint8], w: int, h: int, options: dict | None +def nv_to_bgr888( + data: npt.NDArray[np.uint8], + w: int, + h: int, + y_stride: int, + uv_stride: int, + v_subsample: int, + u_first: bool, + options: dict | None, ) -> npt.NDArray[np.uint8]: - plane1 = data[: w * h] - plane2 = data[w * h :] + y = as_strided(data, shape=(h, w), strides=(y_stride, 1), writeable=False) + uv = as_strided( + data[y_stride * h :], + shape=(h // v_subsample, w // 2, 2), + strides=(uv_stride, 2, 1), + writeable=False, + ) - y = plane1.reshape((h, w)) - uv = plane2.reshape((h // 2, w // 2, 2)) + u_idx = 0 if u_first else 1 + v_idx = 1 - u_idx + + yuv = np.empty((h, w, 3), dtype=np.uint8) + yuv[:, :, 0] = y + + u = uv[:, :, u_idx] + v = uv[:, :, v_idx] + if v_subsample == 2: + u = u.repeat(2, axis=0) + v = v.repeat(2, axis=0) + yuv[:, :, 1] = u.repeat(2, axis=1) + yuv[:, :, 2] = v.repeat(2, axis=1) + + return ycbcr_to_bgr888(yuv, options) + + +def planar_yuv_to_bgr888( + data: npt.NDArray[np.uint8], + w: int, + h: int, + y_stride: int, + c1_stride: int, + c2_stride: int, + v_subsample: int, + u_first: bool, + options: dict | None, +) -> npt.NDArray[np.uint8]: + c_h = h // v_subsample + c_w = w // 2 + + y_size = y_stride * h + c1_size = c1_stride * c_h + + y = as_strided(data, shape=(h, w), strides=(y_stride, 1), writeable=False) + c1 = as_strided(data[y_size:], shape=(c_h, c_w), strides=(c1_stride, 1), writeable=False) + c2 = as_strided( + data[y_size + c1_size :], shape=(c_h, c_w), strides=(c2_stride, 1), writeable=False + ) + + u, v = (c1, c2) if u_first else (c2, c1) - # YUV444 yuv = np.empty((h, w, 3), dtype=np.uint8) - yuv[:, :, 0] = y[:, :] # Y - yuv[:, :, 1] = uv[:, :, 0].repeat(2, axis=0).repeat(2, axis=1) # U - yuv[:, :, 2] = uv[:, :, 1].repeat(2, axis=0).repeat(2, axis=1) # V + yuv[:, :, 0] = y + if v_subsample == 2: + u = u.repeat(2, axis=0) + v = v.repeat(2, axis=0) + yuv[:, :, 1] = u.repeat(2, axis=1) + yuv[:, :, 2] = v.repeat(2, axis=1) return ycbcr_to_bgr888(yuv, options) def y8_to_bgr888( - data: npt.NDArray[np.uint8], w: int, h: int, options: dict | None + data: npt.NDArray[np.uint8], w: int, h: int, stride: int, options: dict | None ) -> npt.NDArray[np.uint8]: color_range = options.get('range', 'full') if options else 'full' - y = data.reshape((h, w)) + y = as_strided(data, shape=(h, w), strides=(stride, 1), writeable=False) if color_range == 'limited': # Convert from limited range (16-235) to full range (0-255) y = np.clip((y.astype(np.float32) - 16) * 255 / 219, 0, 255).astype(np.uint8) # Create grayscale RGB (Y becomes R=G=B) - rgb = np.zeros((h, w, 3), dtype=np.uint8) + rgb = np.empty((h, w, 3), dtype=np.uint8) rgb[:, :, 0] = y # B rgb[:, :, 1] = y # G rgb[:, :, 2] = y # R @@ -163,18 +215,53 @@ def y8_to_bgr888( def yuv_to_bgr888( - arr: npt.NDArray[np.uint8], w: int, h: int, fmt: PixelFormat, options: dict | None -) -> npt.NDArray[np.uint8]: + arr: npt.NDArray[np.uint8], + w: int, + h: int, + strides: tuple[int, ...], + fmt: PixelFormat, + options: dict | None, +) -> npt.NDArray[np.uint8] | None: + + # HACK: for backward compatibility. Drop when no external user calls this internal function. + if isinstance(strides, int): + strides = cast('tuple[int, ...]', (strides,)) + if fmt == PixelFormats.Y8: - return y8_to_bgr888(arr, w, h, options) + return y8_to_bgr888(arr, w, h, strides[0], options) if fmt == PixelFormats.YUYV: - return yuyv_to_bgr888(arr, w, h, options) + return yuyv_to_bgr888(arr, w, h, strides[0], options) if fmt == PixelFormats.UYVY: - return uyvy_to_bgr888(arr, w, h, options) + return uyvy_to_bgr888(arr, w, h, strides[0], options) if fmt == PixelFormats.NV12: - return nv12_to_bgr888(arr, w, h, options) + return nv_to_bgr888(arr, w, h, strides[0], strides[1], 2, True, options) + + if fmt == PixelFormats.NV21: + return nv_to_bgr888(arr, w, h, strides[0], strides[1], 2, False, options) + + if fmt == PixelFormats.NV16: + return nv_to_bgr888(arr, w, h, strides[0], strides[1], 1, True, options) + + if fmt == PixelFormats.NV61: + return nv_to_bgr888(arr, w, h, strides[0], strides[1], 1, False, options) + + if fmt == PixelFormats.YUV420: + return planar_yuv_to_bgr888(arr, w, h, strides[0], strides[1], strides[2], 2, True, options) + + if fmt == PixelFormats.YVU420: + return planar_yuv_to_bgr888( + arr, w, h, strides[0], strides[1], strides[2], 2, False, options + ) + + if fmt == PixelFormats.YUV422: + return planar_yuv_to_bgr888(arr, w, h, strides[0], strides[1], strides[2], 1, True, options) + + if fmt == PixelFormats.YVU422: + return planar_yuv_to_bgr888( + arr, w, h, strides[0], strides[1], strides[2], 1, False, options + ) - raise RuntimeError(f'Unsupported YUV format {fmt}') + return None diff --git a/pixutils/conv/yuv_nb.py b/pixutils/conv/yuv_nb.py index 7609f24..4f6326b 100644 --- a/pixutils/conv/yuv_nb.py +++ b/pixutils/conv/yuv_nb.py @@ -20,6 +20,7 @@ def _yuyv_to_bgr888_nb( data: npt.NDArray[np.uint8], width: int, height: int, + stride: int, offset_y: float, offset_u: float, offset_v: float, @@ -39,8 +40,7 @@ def _yuyv_to_bgr888_nb( for y in range(height): for x in range(0, width, 2): # Process 2 pixels at a time # YUYV layout: Y0 U0 Y1 V0 (4 bytes for 2 pixels) - # Each row has width*2 bytes, each pair of pixels needs 4 bytes - base_idx = y * width * 2 + x * 2 + base_idx = y * stride + x * 2 y0 = data[base_idx + 0] u = data[base_idx + 1] @@ -77,6 +77,7 @@ def _uyvy_to_bgr888_nb( data: npt.NDArray[np.uint8], width: int, height: int, + stride: int, offset_y: float, offset_u: float, offset_v: float, @@ -96,8 +97,7 @@ def _uyvy_to_bgr888_nb( for y in range(height): for x in range(0, width, 2): # Process 2 pixels at a time # UYVY layout: U0 Y0 V0 Y1 (4 bytes for 2 pixels) - # Each row has width*2 bytes, each pair of pixels needs 4 bytes - base_idx = y * width * 2 + x * 2 + base_idx = y * stride + x * 2 u = data[base_idx + 0] y0 = data[base_idx + 1] @@ -130,10 +130,15 @@ def _uyvy_to_bgr888_nb( @njit(cache=True) -def _nv12_to_bgr888_nb( +def _nv_to_bgr888_nb( data: npt.NDArray[np.uint8], width: int, height: int, + y_stride: int, + uv_stride: int, + v_subsample: int, + u_offset: int, + v_offset: int, offset_y: float, offset_u: float, offset_v: float, @@ -147,24 +152,25 @@ def _nv12_to_bgr888_nb( m21: float, m22: float, ) -> npt.NDArray[np.uint8]: - """JIT-compiled NV12 to BGR conversion with custom chroma upsampling""" + """JIT-compiled NV12/NV21/NV16/NV61 to BGR conversion. + + v_subsample: 1 for NV16/NV61 (4:2:2), 2 for NV12/NV21 (4:2:0). + u_offset, v_offset: byte offsets within a chroma pair — (0, 1) for UV-order + (NV12/NV16), (1, 0) for VU-order (NV21/NV61). + """ rgb = np.empty((height, width, 3), dtype=np.uint8) - # NV12 layout: Y plane followed by interleaved UV plane - y_plane_size = width * height + y_plane_offset = y_stride * height for y in range(height): - for x in range(width): - # Get Y value directly - y_val = data[y * width + x] + uv_row_base = y_plane_offset + (y // v_subsample) * uv_stride - # Get UV values from chroma plane (subsampled by 2x2) - uv_y = y // 2 - uv_x = x // 2 - uv_idx = y_plane_size + uv_y * width + uv_x * 2 + for x in range(width): + y_val = data[y * y_stride + x] - u = data[uv_idx + 0] - v = data[uv_idx + 1] + uv_idx = uv_row_base + (x // 2) * 2 + u = data[uv_idx + u_offset] + v = data[uv_idx + v_offset] # Apply offsets y_adj = y_val + offset_y @@ -185,8 +191,13 @@ def _nv12_to_bgr888_nb( def yuv_to_bgr888_nb( - arr: npt.NDArray[np.uint8], w: int, h: int, fmt: PixelFormat, options: dict | None -) -> npt.NDArray[np.uint8]: + arr: npt.NDArray[np.uint8], + w: int, + h: int, + strides: tuple[int, ...], + fmt: PixelFormat, + options: dict | None, +) -> npt.NDArray[np.uint8] | None: """Entry point for numba YUV conversions.""" offset, matrix = _get_conversion_matrix(options) @@ -195,6 +206,7 @@ def yuv_to_bgr888_nb( arr, w, h, + strides[0], offset[0], offset[1], offset[2], @@ -214,6 +226,7 @@ def yuv_to_bgr888_nb( arr, w, h, + strides[0], offset[0], offset[1], offset[2], @@ -228,11 +241,24 @@ def yuv_to_bgr888_nb( matrix[2][2], ) - if fmt == PixelFormats.NV12: - return _nv12_to_bgr888_nb( + nv_params = { + PixelFormats.NV12: (2, 0, 1), + PixelFormats.NV21: (2, 1, 0), + PixelFormats.NV16: (1, 0, 1), + PixelFormats.NV61: (1, 1, 0), + } + + if fmt in nv_params: + v_subsample, u_offset, v_offset = nv_params[fmt] + return _nv_to_bgr888_nb( arr, w, h, + strides[0], + strides[1], + v_subsample, + u_offset, + v_offset, offset[0], offset[1], offset[2], @@ -247,4 +273,4 @@ def yuv_to_bgr888_nb( matrix[2][2], ) - raise RuntimeError(f'Unsupported YUV format {fmt}') + return None diff --git a/pixutils/formats/pixelformats.py b/pixutils/formats/pixelformats.py index f664238..49bdee5 100644 --- a/pixutils/formats/pixelformats.py +++ b/pixutils/formats/pixelformats.py @@ -37,16 +37,18 @@ def __init__( name: str, drm_fourcc: None | str, v4l2_fourcc: None | str, + libcamera_name: None | str, colorencoding: PixelColorEncoding, - packed: bool, + csi2_packed: bool, pixel_align: tuple[int, int], planes, ) -> None: self.name = name self.drm_fourcc = str_to_fourcc(drm_fourcc) if drm_fourcc else None self.v4l2_fourcc = str_to_fourcc(v4l2_fourcc) if v4l2_fourcc else None + self.libcamera_name = libcamera_name self.color = colorencoding - self.packed = packed + self.csi2_packed = csi2_packed # pixel alignment (width, height) self.pixel_align = pixel_align @@ -78,6 +80,15 @@ def bayer_pattern(self) -> str | None: return None return self.name[1:5] + @property + def raw_bitspp(self) -> int: + """Returns bits-per-pixel for RAW formats.""" + assert self.color == PixelColorEncoding.RAW + name = self.name + if name.endswith('P'): + return int(name[5:-1]) + return int(name[5:]) + def stride(self, width: int, plane: int = 0, align=1): if plane >= len(self.planes): raise RuntimeError() @@ -96,6 +107,25 @@ def stride(self, width: int, plane: int = 0, align=1): return stride + def extrapolate_stride(self, plane0_stride: int, plane: int) -> int: + """Derive a plane's stride from plane 0's stride, preserving padding ratio.""" + if plane >= len(self.planes): + raise RuntimeError() + + if plane == 0: + return plane0_stride + + p0 = self.planes[0] + pn = self.planes[plane] + num = plane0_stride * pn.bytes_per_block * p0.pixels_per_block * p0.hsub + den = p0.bytes_per_block * pn.pixels_per_block * pn.hsub + if num % den != 0: + raise ValueError( + f'Cannot extrapolate stride for plane {plane} from plane-0 stride ' + f'{plane0_stride} in format {self.name}: result is not an integer' + ) + return num // den + def planesize(self, stride: int, height: int, plane: int = 0): assert height % self.pixel_align[1] == 0 @@ -172,6 +202,11 @@ def find_by_name(name): PixelFormats.__init_fmt_list() return next(f for f in PixelFormats.__FMT_LIST if f.name == name) + @staticmethod + def find_libcamera_name(name: str): + PixelFormats.__init_fmt_list() + return next(f for f in PixelFormats.__FMT_LIST if f.libcamera_name == name) + @staticmethod def get_formats(): PixelFormats.__init_fmt_list() @@ -180,7 +215,7 @@ def get_formats(): # fmt: off # Single 8-bit channel R8 = PixelFormat('R8', - 'R8 ', None, + 'R8 ', None, None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -189,7 +224,7 @@ def get_formats(): # RGB 8-bit RGB332 = PixelFormat('RGB332', - 'RGB8', None, + 'RGB8', None, None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -199,14 +234,14 @@ def get_formats(): # RGB 16-bit, no alpha RGB565 = PixelFormat('RGB565', - 'RG16', 'RGBP', + 'RG16', 'RGBP', 'RGB565', PixelColorEncoding.RGB, False, ( 1, 1 ), ( ( 2, ), ), ) BGR565 = PixelFormat('BGR565', - 'BG16', None, + 'BG16', None, None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -215,6 +250,7 @@ def get_formats(): XRGB1555 = PixelFormat('XRGB1555', 'XR15', # DRM_FORMAT_XRGB1555 None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -223,6 +259,7 @@ def get_formats(): RGBX4444 = PixelFormat('RGBX4444', 'RX12', # DRM_FORMAT_RGBX4444 None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -231,6 +268,7 @@ def get_formats(): XRGB4444 = PixelFormat('XRGB4444', 'XR12', # DRM_FORMAT_XRGB4444 None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -242,6 +280,7 @@ def get_formats(): ARGB1555 = PixelFormat('ARGB1555', 'AR15', # DRM_FORMAT_ARGB1555 None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -250,6 +289,7 @@ def get_formats(): RGBA4444 = PixelFormat('RGBA4444', 'RA12', # DRM_FORMAT_RGBA4444 None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -258,6 +298,7 @@ def get_formats(): ARGB4444 = PixelFormat('ARGB4444', 'AR12', # DRM_FORMAT_ARGB4444 None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -269,6 +310,7 @@ def get_formats(): RGB888 = PixelFormat('RGB888', 'RG24', # DRM_FORMAT_RGB888 'BGR3', # V4L2_PIX_FMT_BGR24 + 'RGB888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -277,6 +319,7 @@ def get_formats(): BGR888 = PixelFormat('BGR888', 'BG24', # DRM_FORMAT_BGR888 'RGB3', # V4L2_PIX_FMT_RGB24 + 'BGR888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -288,6 +331,7 @@ def get_formats(): XRGB8888 = PixelFormat('XRGB8888', 'XR24', # DRM_FORMAT_XRGB8888 'XR24', # V4L2_PIX_FMT_XBGR32 + 'XRGB8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -296,6 +340,7 @@ def get_formats(): XBGR8888 = PixelFormat('XBGR8888', 'XB24', # DRM_FORMAT_XBGR8888 'XB24', # V4L2_PIX_FMT_RGBX32 + 'XBGR8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -304,6 +349,7 @@ def get_formats(): RGBX8888 = PixelFormat('RGBX8888', 'RX24', # DRM_FORMAT_RGBX8888 'RX24', # V4L2_PIX_FMT_BGRX32 + 'RGBX8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -312,6 +358,7 @@ def get_formats(): BGRX8888 = PixelFormat('BGRX8888', 'BX24', # DRM_FORMAT_RGBX8888 None, + 'BGRX8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -321,6 +368,7 @@ def get_formats(): XBGR2101010 = PixelFormat('XBGR2101010', 'XB30', # DRM_FORMAT_XBGR2101010 'RX30', # V4L2_PIX_FMT_RGBX1010102 + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -329,6 +377,7 @@ def get_formats(): XRGB2101010 = PixelFormat('XRGB2101010', 'XR30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -337,6 +386,7 @@ def get_formats(): RGBX1010102 = PixelFormat('RGBX1010102', 'RX30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -345,6 +395,7 @@ def get_formats(): BGRX1010102 = PixelFormat('BGRX1010102', 'BX30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -356,6 +407,7 @@ def get_formats(): ARGB8888 = PixelFormat('ARGB8888', 'AR24', # DRM_FORMAT_ARGB8888 'AR24', # V4L2_PIX_FMT_ABGR32 + 'ARGB8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -364,6 +416,7 @@ def get_formats(): ABGR8888 = PixelFormat('ABGR8888', 'AB24', # DRM_FORMAT_ABGR8888 'AB24', # V4L2_PIX_FMT_RGBA32 + 'ABGR8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -372,6 +425,7 @@ def get_formats(): RGBA8888 = PixelFormat('RGBA8888', 'RA24', # DRM_FORMAT_RGBA8888 'RA24', # V4L2_PIX_FMT_BGRA32 + 'RGBA8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -380,6 +434,7 @@ def get_formats(): BGRA8888 = PixelFormat('BGRA8888', 'BA24', None, + 'BGRA8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -390,6 +445,7 @@ def get_formats(): ABGR2101010 = PixelFormat('ABGR2101010', 'AB30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -398,6 +454,7 @@ def get_formats(): ARGB2101010 = PixelFormat('ARGB2101010', 'AR30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -406,6 +463,7 @@ def get_formats(): RGBA1010102 = PixelFormat('RGBA1010102', 'RA30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -414,6 +472,7 @@ def get_formats(): BGRA1010102 = PixelFormat('BGRA1010102', 'BA30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -423,7 +482,7 @@ def get_formats(): # YUV Packed YUYV = PixelFormat('YUYV', - 'YUYV', 'YUYV', + 'YUYV', 'YUYV', 'YUYV', PixelColorEncoding.YUV, False, ( 2, 1 ), @@ -431,7 +490,7 @@ def get_formats(): ) UYVY = PixelFormat('UYVY', - 'UYVY', 'UYVY', + 'UYVY', 'UYVY', 'UYVY', PixelColorEncoding.YUV, False, ( 2, 1 ), @@ -439,7 +498,7 @@ def get_formats(): ) YVYU = PixelFormat('YVYU', - 'YVYU', 'YVYU', + 'YVYU', 'YVYU', 'YVYU', PixelColorEncoding.YUV, False, ( 2, 1 ), @@ -447,7 +506,7 @@ def get_formats(): ) VYUY = PixelFormat('VYUY', - 'VYUY', 'VYUY', + 'VYUY', 'VYUY', 'VYUY', PixelColorEncoding.YUV, False, ( 2, 1 ), @@ -457,6 +516,7 @@ def get_formats(): VUY888 = PixelFormat('VUY888', 'VU24', # DRM_FORMAT_VUY888 'YUV3', # V4L2_PIX_FMT_YUV24 + None, PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -466,6 +526,7 @@ def get_formats(): XVUY8888 = PixelFormat('XVUY8888', 'XVUY', # DRM_FORMAT_XVUY8888 'YUVX', # V4L2_PIX_FMT_YUVX32 + 'XVUY8888', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -475,6 +536,7 @@ def get_formats(): Y210 = PixelFormat('Y210', 'Y210', # DRM_FORMAT_Y210 None, + None, PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -484,6 +546,7 @@ def get_formats(): Y212 = PixelFormat('Y212', 'Y212', # DRM_FORMAT_Y212 None, + None, PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -493,6 +556,7 @@ def get_formats(): Y216 = PixelFormat('Y216', 'Y216', # DRM_FORMAT_Y216 None, + None, PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -502,7 +566,7 @@ def get_formats(): # YUV Semi Planar NV12 = PixelFormat('NV12', - 'NV12', 'NM12', + 'NV12', 'NM12', 'NV12', PixelColorEncoding.YUV, False, ( 2, 2 ), @@ -511,7 +575,7 @@ def get_formats(): ) NV21 = PixelFormat('NV21', - 'NV21', 'NM21', + 'NV21', 'NM21', 'NV21', PixelColorEncoding.YUV, False, ( 2, 2 ), @@ -520,7 +584,7 @@ def get_formats(): ) NV16 = PixelFormat('NV16', - 'NV16', 'NM16', + 'NV16', 'NM16', 'NV16', PixelColorEncoding.YUV, False, ( 2, 1 ), @@ -529,7 +593,7 @@ def get_formats(): ) NV61 = PixelFormat('NV61', - 'NV61', 'NM61', + 'NV61', 'NM61', 'NV61', PixelColorEncoding.YUV, False, ( 2, 1 ), @@ -538,7 +602,7 @@ def get_formats(): ) XV15 = PixelFormat('XV15', - 'XV15', None, + 'XV15', None, None, PixelColorEncoding.YUV, False, (6, 2), @@ -547,7 +611,7 @@ def get_formats(): ) XV20 = PixelFormat('XV20', - 'XV20', None, + 'XV20', None, None, PixelColorEncoding.YUV, False, (6, 2), @@ -556,7 +620,7 @@ def get_formats(): ) XVUY2101010 = PixelFormat('XVUY2101010', - 'XY30', None, + 'XY30', None, None, PixelColorEncoding.YUV, False, (1, 1), @@ -568,6 +632,7 @@ def get_formats(): YUV420 = PixelFormat('YUV420', 'YU12', None, + 'YUV420', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -579,6 +644,7 @@ def get_formats(): YVU420 = PixelFormat('YVU420', 'YV12', None, + 'YVU420', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -590,6 +656,7 @@ def get_formats(): YUV422 = PixelFormat('YUV422', 'YU16', None, + 'YUV422', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -601,6 +668,7 @@ def get_formats(): YVU422 = PixelFormat('YVU422', 'YV16', None, + 'YVU422', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -612,6 +680,7 @@ def get_formats(): YUV444 = PixelFormat('YUV444', 'YU24', None, + 'YUV444', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -623,6 +692,7 @@ def get_formats(): YVU444 = PixelFormat('YVU444', 'YV24', None, + 'YVU444', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -632,7 +702,7 @@ def get_formats(): ) X403 = PixelFormat('X403', - 'X403', None, + 'X403', None, None, PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -644,7 +714,7 @@ def get_formats(): # Grey formats Y8 = PixelFormat('Y8', - 'GREY', 'GREY', + 'GREY', 'GREY', 'R8', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -652,7 +722,7 @@ def get_formats(): ) Y10 = PixelFormat('Y10', - None, 'Y10 ', + None, 'Y10 ', 'R10', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -660,7 +730,7 @@ def get_formats(): ) Y10P = PixelFormat('Y10P', - None, 'Y10P', + None, 'Y10P', 'R10_CSI2P', PixelColorEncoding.YUV, True, ( 4, 1 ), @@ -668,7 +738,7 @@ def get_formats(): ) Y12 = PixelFormat('Y12', - None, 'Y12 ', + None, 'Y12 ', 'R12', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -676,7 +746,7 @@ def get_formats(): ) Y12P = PixelFormat('Y12P', - None, 'Y12P', + None, 'Y12P', 'R12_CSI2P', PixelColorEncoding.YUV, True, ( 2, 1 ), @@ -684,7 +754,7 @@ def get_formats(): ) Y10_LE32 = PixelFormat('Y10_P32', - 'YPA4', None, + 'YPA4', None, None, PixelColorEncoding.YUV, False, ( 3, 1 ), @@ -694,7 +764,7 @@ def get_formats(): # RAW Bayer formats SBGGR8 = PixelFormat('SBGGR8', - None, 'BA81', + None, 'BA81', 'SBGGR8', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -702,7 +772,7 @@ def get_formats(): ) SGBRG8 = PixelFormat('SGBRG8', - None, 'GBRG', + None, 'GBRG', 'SGBRG8', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -710,7 +780,7 @@ def get_formats(): ) SGRBG8 = PixelFormat('SGRBG8', - None, 'GRBG', + None, 'GRBG', 'SGRBG8', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -718,7 +788,7 @@ def get_formats(): ) SRGGB8 = PixelFormat('SRGGB8', - None, 'RGGB', + None, 'RGGB', 'SRGGB8', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -726,7 +796,7 @@ def get_formats(): ) SBGGR10 = PixelFormat('SBGGR10', - None, 'BG10', + None, 'BG10', 'SBGGR10', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -734,7 +804,7 @@ def get_formats(): ) SGBRG10 = PixelFormat('SGBRG10', - None, 'GB10', + None, 'GB10', 'SGBRG10', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -742,7 +812,7 @@ def get_formats(): ) SGRBG10 = PixelFormat('SGRBG10', - None, 'BA10', + None, 'BA10', 'SGRBG10', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -750,7 +820,7 @@ def get_formats(): ) SRGGB10 = PixelFormat('SRGGB10', - None, 'RG10', + None, 'RG10', 'SRGGB10', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -758,7 +828,7 @@ def get_formats(): ) SBGGR10P = PixelFormat('SBGGR10P', - None, 'pBAA', + None, 'pBAA', 'SBGGR10_CSI2P', PixelColorEncoding.RAW, True, ( 4, 2 ), @@ -766,7 +836,7 @@ def get_formats(): ) SGBRG10P = PixelFormat('SGBRG10P', - None, 'pGAA', + None, 'pGAA', 'SGBRG10_CSI2P', PixelColorEncoding.RAW, True, ( 4, 2 ), @@ -774,7 +844,7 @@ def get_formats(): ) SGRBG10P = PixelFormat('SGRBG10P', - None, 'pgAA', + None, 'pgAA', 'SGRBG10_CSI2P', PixelColorEncoding.RAW, True, ( 4, 2 ), @@ -782,7 +852,7 @@ def get_formats(): ) SRGGB10P = PixelFormat('SRGGB10P', - None, 'pRAA', + None, 'pRAA', 'SRGGB10_CSI2P', PixelColorEncoding.RAW, True, ( 4, 2 ), @@ -790,7 +860,7 @@ def get_formats(): ) SBGGR12 = PixelFormat('SBGGR12', - None, 'BG12', + None, 'BG12', 'SBGGR12', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -798,7 +868,7 @@ def get_formats(): ) SGBRG12 = PixelFormat('SGBRG12', - None, 'GB12', + None, 'GB12', 'SGBRG12', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -806,7 +876,7 @@ def get_formats(): ) SGRBG12 = PixelFormat('SGRBG12', - None, 'BA12', + None, 'BA12', 'SGRBG12', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -814,7 +884,7 @@ def get_formats(): ) SRGGB12 = PixelFormat('SRGGB12', - None, 'RG12', + None, 'RG12', 'SRGGB12', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -822,7 +892,7 @@ def get_formats(): ) SBGGR12P = PixelFormat('SBGGR12P', - None, 'pBCC', + None, 'pBCC', 'SBGGR12_CSI2P', PixelColorEncoding.RAW, True, ( 2, 2 ), @@ -830,7 +900,7 @@ def get_formats(): ) SGBRG12P = PixelFormat('SGBRG12P', - None, 'pGCC', + None, 'pGCC', 'SGBRG12_CSI2P', PixelColorEncoding.RAW, True, ( 2, 2 ), @@ -838,7 +908,7 @@ def get_formats(): ) SGRBG12P = PixelFormat('SGRBG12P', - None, 'pgCC', + None, 'pgCC', 'SGRBG12_CSI2P', PixelColorEncoding.RAW, True, ( 2, 2 ), @@ -846,7 +916,7 @@ def get_formats(): ) SRGGB12P = PixelFormat('SRGGB12P', - None, 'pRCC', + None, 'pRCC', 'SRGGB12_CSI2P', PixelColorEncoding.RAW, True, ( 2, 2 ), @@ -854,7 +924,7 @@ def get_formats(): ) SBGGR16 = PixelFormat('SBGGR16', - None, 'BYR2', + None, 'BYR2', 'SBGGR16', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -862,7 +932,7 @@ def get_formats(): ) SGBRG16 = PixelFormat('SGBRG16', - None, 'GB16', + None, 'GB16', 'SGBRG16', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -870,7 +940,7 @@ def get_formats(): ) SGRBG16 = PixelFormat('SGRBG16', - None, 'GR16', + None, 'GR16', 'SGRBG16', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -878,7 +948,7 @@ def get_formats(): ) SRGGB16 = PixelFormat('SRGGB16', - None, 'RG16', + None, 'RG16', 'SRGGB16', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -887,7 +957,7 @@ def get_formats(): # Compressed formats MJPEG = PixelFormat('MJPEG', - 'MJPG', 'MJPG', + 'MJPG', 'MJPG', 'MJPEG', PixelColorEncoding.UNDEFINED, False, ( 1, 1 ), diff --git a/pyproject.toml b/pyproject.toml index 2e973c5..18d7362 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = 'setuptools.build_meta' name = 'pixutils' version = '0.1' dependencies = [] -requires-python = '>=3.8' +requires-python = '>=3.10' authors = [ { name = 'Tomi Valkeinen', email = 'tomi.valkeinen@ideasonboard.com' }, ] diff --git a/tests/test_backend_compat.py b/tests/test_backend_compat.py new file mode 100644 index 0000000..9c585d4 --- /dev/null +++ b/tests/test_backend_compat.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 + +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import itertools +import unittest + +import numpy as np + +from pixutils.formats import PixelFormat, PixelColorEncoding, PixelFormats +from pixutils.conv import buffer_to_bgr888 +from test_conv import WIDTH, HEIGHT, generate_test_buffer # type: ignore[import-not-found] + + +BACKENDS = ('opencv', 'numba', 'numpy') + +# For each backend pair we compute the per-pixel absolute BGR difference and +# summarize it with three statistics: +# * ch_mean — mean absolute diff computed independently per B/G/R channel, +# then take the max across the three. Using per-channel (not aggregate) +# means is what makes a channel swap stand out: when R and B are swapped, +# G matches exactly (~0) while R and B each diverge by ~85 on random +# uniform data; the aggregate mean would be ~57, which is close to the +# honest disagreement between different demosaic algorithms, but the +# per-channel max of ~85 stays well above any legitimate difference. +# * p99 — 99th percentile of the per-pixel diff. Tolerates rare outliers +# (chroma samples at a subsampling boundary, clipped saturated pixels). +# * max — worst-case single-pixel diff. +# +# Thresholds vary by color class because the backends agree to different +# degrees: +# * RGB — every backend just reorders bytes, so outputs must be identical. +# * YUV — all backends share BT.601 limited-range coefficients, but opencv +# interpolates chroma at subsampling boundaries while numba/numpy do +# nearest-neighbor, so a handful of LSBs of disagreement is expected. +# * RAW — opencv, numba and numpy each use a different demosaic algorithm, +# so per-pixel differences are routinely large (just not channel-swap +# large). +TOLERANCES = { + 'RGB': {'ch_mean': 0.0, 'p99': 0, 'max': 0}, + 'YUV': {'ch_mean': 3.0, 'p99': 15, 'max': 25}, + 'RAW': {'ch_mean': 55.0, 'p99': 140, 'max': 210}, +} + + +def _category(fmt: PixelFormat) -> str: + if fmt.color == PixelColorEncoding.RGB: + return 'RGB' + if fmt.color == PixelColorEncoding.YUV: + return 'YUV' + if fmt.color == PixelColorEncoding.RAW: + return 'RAW' + return 'OTHER' + + +def _format_options(fmt: PixelFormat) -> dict: + if fmt.color == PixelColorEncoding.YUV and fmt != PixelFormats.Y8: + # Only range='limited' + encoding='bt601' is accepted by opencv; numba + # and numpy accept it too, so this enables 3-way comparison where + # possible and still allows 2-way (numba+numpy) for the rest. + return {'range': 'limited', 'encoding': 'bt601'} + return {} + + +# A small probe size used at test-discovery time to find out which backends +# actually handle a given format. 48 is a multiple of every width alignment +# used by the defined formats (LCM is 12: driven by XV15/XV20 at 6 and +# Y10_P32 at 3), and 32 is a multiple of every height alignment (LCM 2). +# The buffer is sized as 48×32 × 8 bytes/pixel × 3 planes — an upper bound +# over every defined format at the probe dimensions; `to_bgr888` slices it +# down to the real framesize. +_PROBE_WIDTH = 48 +_PROBE_HEIGHT = 32 +_PROBE_BUFFER = np.zeros(_PROBE_WIDTH * _PROBE_HEIGHT * 8 * 3, dtype=np.uint8) + + +def _probe_backends(fmt: PixelFormat, base_opts: dict) -> list[str]: + """Return the list of backends that accept this format at the probe size.""" + working = [] + for backend in BACKENDS: + opts = dict(base_opts) | {'backends': [backend]} + try: + buffer_to_bgr888(fmt, _PROBE_WIDTH, _PROBE_HEIGHT, 0, _PROBE_BUFFER, opts) + except NotImplementedError: + continue + working.append(backend) + return working + + +def compare_bgr(a: np.ndarray, b: np.ndarray, cat: str) -> str: + assert a.shape == b.shape and a.dtype == b.dtype == np.uint8 + diff = np.abs(a.astype(np.int16) - b.astype(np.int16)) + if int(diff.max()) == 0: + return 'identical' + tol = TOLERANCES[cat] + ch_mean = diff.mean(axis=(0, 1)) + max_ch_mean = float(ch_mean.max()) + p99 = int(np.percentile(diff, 99)) + mx = int(diff.max()) + msg = f'per-ch-mean={ch_mean.round(2).tolist()} p99={p99} max={mx}' + assert max_ch_mean <= tol['ch_mean'], ( + f'max-per-ch-mean {max_ch_mean:.2f} > {tol["ch_mean"]}; {msg}' + ) + assert p99 <= tol['p99'], f'p99 {p99} > {tol["p99"]}; {msg}' + assert mx <= tol['max'], f'max {mx} > {tol["max"]}; {msg}' + return f'within tolerance ({msg})' + + +class TestBackendCompat(unittest.TestCase): + pass + + +def _make_test(fmt: PixelFormat, backends: tuple[str, ...]): + cat = _category(fmt) + base_opts = _format_options(fmt) + + def test(self): + buf = generate_test_buffer(fmt) + results = { + b: buffer_to_bgr888(fmt, WIDTH, HEIGHT, 0, buf, dict(base_opts) | {'backends': [b]}) + for b in backends + } + for b1, b2 in itertools.combinations(backends, 2): + with self.subTest(pair=f'{b1}_vs_{b2}'): + compare_bgr(results[b1], results[b2], cat) + + return test + + +def _create_tests(): + for fmt in PixelFormats.get_formats(): + if _category(fmt) == 'OTHER': + continue + supported = _probe_backends(fmt, _format_options(fmt)) + if len(supported) < 2: + continue + setattr( + TestBackendCompat, + f'test_compat_{fmt.name}', + _make_test(fmt, tuple(supported)), + ) + + +_create_tests() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_conv.py b/tests/test_conv.py index a38a08a..d3f0fe1 100755 --- a/tests/test_conv.py +++ b/tests/test_conv.py @@ -11,7 +11,12 @@ from pixutils.conv import buffer_to_bgr888 from test_conv_data import FMTS # type: ignore[import-not-found] -WIDTH = 640 +# 636 is the nearest multiple of 12 to 640. 12 is the LCM of the width +# pixel_align values across all defined formats (driven by XV15/XV20 which +# require width % 6 == 0 and Y10_P32 which requires width % 3 == 0), so +# WIDTH=636 lets every format pass fmt.stride()'s alignment assertion. +# Height alignment LCM is 2, so HEIGHT=480 was already fine. +WIDTH = 636 HEIGHT = 480 SEED = 1234 @@ -22,10 +27,10 @@ def get_bit_mask(fmt: PixelFormat): """Returns (dtype, mask) tuple for masking padding bits, or None if no masking needed.""" # RAW 10-bit formats (SRGGB10, SBGGR10, SGRBG10, SGBRG10) - if fmt.name.endswith('10') and fmt.color == PixelColorEncoding.RAW and not fmt.packed: + if fmt.color == PixelColorEncoding.RAW and not fmt.csi2_packed and fmt.raw_bitspp == 10: return (np.uint16, (1 << 10) - 1) # RAW 12-bit formats (SRGGB12, SBGGR12, SGRBG12, SGBRG12) - elif fmt.name.endswith('12') and fmt.color == PixelColorEncoding.RAW and not fmt.packed: + elif fmt.color == PixelColorEncoding.RAW and not fmt.csi2_packed and fmt.raw_bitspp == 12: return (np.uint16, (1 << 12) - 1) # XBGR8888, XRGB8888 formats - mask out alpha channel elif fmt.name in ('XBGR8888', 'XRGB8888'): @@ -59,7 +64,7 @@ def generate_test_case(pixel_format, options=None): rgb_sha = hashlib.sha256(rgb_buf.tobytes()).hexdigest() return (src_sha, rgb_sha) - except Exception as e: + except NotImplementedError as e: print(f'# Skipping {pixel_format.name} with options {options}: {e}', file=sys.stderr) return None @@ -195,6 +200,24 @@ def save_test_data(): gz.write(rgb_buf.tobytes()) +PADDING_BYTES = 128 + + +def _make_padded_buf(fmt: PixelFormat, base_buf: np.ndarray) -> np.ndarray: + buf_u8 = np.ascontiguousarray(base_buf).view(np.uint8) + planes = [] + offset = 0 + for i in range(len(fmt.planes)): + stride = fmt.stride(WIDTH, i) + plane_size = fmt.planesize(stride, HEIGHT, i) + plane_height = plane_size // stride + rows = buf_u8[offset : offset + plane_size].reshape((plane_height, stride)) + padding = np.zeros((plane_height, PADDING_BYTES), dtype=np.uint8) + planes.append(np.hstack([rows, padding]).ravel()) + offset += plane_size + return np.ascontiguousarray(np.concatenate(planes)) + + class TestConv(unittest.TestCase): # Test functions added dynamically below pass @@ -236,6 +259,125 @@ def create_test_functions(): create_test_functions() +class TestConvStride(unittest.TestCase): + # Test functions added dynamically below + pass + + +def create_stride_test_function(test_case, padded: bool): + def test_function(self): + fmt = test_case.pixel_format + base_buf = generate_test_buffer(fmt) + + if len(fmt.planes) == 1: + stride = fmt.stride(WIDTH, 0) + bpl: int | tuple[int, ...] = stride + PADDING_BYTES if padded else stride + else: + bpl = ( + tuple(fmt.stride(WIDTH, i) + PADDING_BYTES for i in range(len(fmt.planes))) + if padded + else tuple(fmt.stride(WIDTH, i) for i in range(len(fmt.planes))) + ) + + test_buf = _make_padded_buf(fmt, base_buf) if padded else base_buf + + try: + rgb_buf = buffer_to_bgr888(fmt, WIDTH, HEIGHT, bpl, test_buf, test_case.options) + except ValueError as e: + if str(e) == 'No backends available': + self.skipTest('No backend available') + raise + + rgb_sha = hashlib.sha256(rgb_buf.tobytes()).hexdigest() + self.assertEqual( + rgb_sha, test_case.rgb_sha, f'SHA mismatch for {test_case.description} stride={bpl}' + ) + + return test_function + + +def create_stride_test_functions(): + for test_case in FMTS: + for padded in [False, True]: + suffix = 'padded' if padded else 'exact' + name = f'test_conv_stride_{suffix}_{test_case.description}' + setattr(TestConvStride, name, create_stride_test_function(test_case, padded)) + + +create_stride_test_functions() + + +def _make_extrapolated_padded_buf( + fmt: PixelFormat, base_buf: np.ndarray, plane0_padding: int +) -> np.ndarray: + """Pad each plane by the amount implied by extrapolating plane 0's padded stride.""" + buf_u8 = np.ascontiguousarray(base_buf).view(np.uint8) + padded0 = fmt.stride(WIDTH, 0) + plane0_padding + planes = [] + offset = 0 + for i in range(len(fmt.planes)): + natural = fmt.stride(WIDTH, i) + padded = fmt.extrapolate_stride(padded0, i) + plane_size = fmt.planesize(natural, HEIGHT, i) + plane_height = plane_size // natural + rows = buf_u8[offset : offset + plane_size].reshape((plane_height, natural)) + padding = np.zeros((plane_height, padded - natural), dtype=np.uint8) + planes.append(np.hstack([rows, padding]).ravel()) + offset += plane_size + return np.ascontiguousarray(np.concatenate(planes)) + + +class TestConvIntStride(unittest.TestCase): + # Test functions added dynamically below + pass + + +def create_int_stride_test_function(test_case, padded: bool): + def test_function(self): + fmt = test_case.pixel_format + base_buf = generate_test_buffer(fmt) + + plane0_stride = fmt.stride(WIDTH, 0) + bpl = plane0_stride + PADDING_BYTES if padded else plane0_stride + test_buf = ( + _make_extrapolated_padded_buf(fmt, base_buf, PADDING_BYTES) if padded else base_buf + ) + + try: + rgb_buf = buffer_to_bgr888(fmt, WIDTH, HEIGHT, bpl, test_buf, test_case.options) + except ValueError as e: + if str(e) == 'No backends available': + self.skipTest('No backend available') + raise + + rgb_sha = hashlib.sha256(rgb_buf.tobytes()).hexdigest() + self.assertEqual( + rgb_sha, test_case.rgb_sha, f'SHA mismatch for {test_case.description} int stride={bpl}' + ) + + return test_function + + +def create_int_stride_test_functions(): + # The int-bytesperline code path only differs from the sequence path for + # multiplane formats (where it extrapolates non-plane-0 strides). Single-plane + # formats are already covered by TestConvStride. Limit to one representative + # test case per multiplane format to keep the matrix small. + seen_formats = set() + for test_case in FMTS: + fmt = test_case.pixel_format + if len(fmt.planes) < 2 or fmt.name in seen_formats: + continue + seen_formats.add(fmt.name) + for padded in [False, True]: + suffix = 'padded' if padded else 'exact' + name = f'test_conv_int_stride_{suffix}_{test_case.description}' + setattr(TestConvIntStride, name, create_int_stride_test_function(test_case, padded)) + + +create_int_stride_test_functions() + + def main(): parser = argparse.ArgumentParser(add_help=False) parser.add_argument( diff --git a/tests/test_conv_data.py b/tests/test_conv_data.py index bf566a2..b2baa3f 100644 --- a/tests/test_conv_data.py +++ b/tests/test_conv_data.py @@ -6,503 +6,743 @@ FMTS = [ # RGB formats ConvTestCase(PixelFormats.RGB888, - 'dc1f1d9cf96911bc33682edcf9b81af2b3184d88890aa118c09a7ba0932826c2', - '9c2ced2d2d19197a6f49b513dcc5a0aa02837bb1876c5f8629820e998cafadad', + 'c66793558eabb4f67d9ecc469468915866d4a1000930c28e8d7f9fcbd47b7614', + '3fd8b4be0cc2f48dfb15b994cc3eeb27a18844815ecb41fea8f3e470c5ce7337', {'backends': ['opencv']}), ConvTestCase(PixelFormats.RGB888, - 'dc1f1d9cf96911bc33682edcf9b81af2b3184d88890aa118c09a7ba0932826c2', - '9c2ced2d2d19197a6f49b513dcc5a0aa02837bb1876c5f8629820e998cafadad', + 'c66793558eabb4f67d9ecc469468915866d4a1000930c28e8d7f9fcbd47b7614', + '3fd8b4be0cc2f48dfb15b994cc3eeb27a18844815ecb41fea8f3e470c5ce7337', {'backends': ['numpy']}), ConvTestCase(PixelFormats.BGR888, - 'dc1f1d9cf96911bc33682edcf9b81af2b3184d88890aa118c09a7ba0932826c2', - 'dc1f1d9cf96911bc33682edcf9b81af2b3184d88890aa118c09a7ba0932826c2', + 'c66793558eabb4f67d9ecc469468915866d4a1000930c28e8d7f9fcbd47b7614', + 'c66793558eabb4f67d9ecc469468915866d4a1000930c28e8d7f9fcbd47b7614', {'backends': ['opencv']}), ConvTestCase(PixelFormats.BGR888, - 'dc1f1d9cf96911bc33682edcf9b81af2b3184d88890aa118c09a7ba0932826c2', - 'dc1f1d9cf96911bc33682edcf9b81af2b3184d88890aa118c09a7ba0932826c2', + 'c66793558eabb4f67d9ecc469468915866d4a1000930c28e8d7f9fcbd47b7614', + 'c66793558eabb4f67d9ecc469468915866d4a1000930c28e8d7f9fcbd47b7614', {'backends': ['numpy']}), ConvTestCase(PixelFormats.XRGB8888, - 'd555e8545b743011df3c27f0a7056552b302a9089b7b6d8d9562402c38c40f1e', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '6982eefccba5442abf921b6371ca57b8a4ef224c7c024ab5f7c7935854249fd7', + '44f12c50ca6aca845c69a72ca8dc06c66878287698be23e1c4635640c73bcac0', {'backends': ['opencv']}), ConvTestCase(PixelFormats.XRGB8888, - 'd555e8545b743011df3c27f0a7056552b302a9089b7b6d8d9562402c38c40f1e', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '6982eefccba5442abf921b6371ca57b8a4ef224c7c024ab5f7c7935854249fd7', + '44f12c50ca6aca845c69a72ca8dc06c66878287698be23e1c4635640c73bcac0', {'backends': ['numpy']}), ConvTestCase(PixelFormats.XBGR8888, - 'd555e8545b743011df3c27f0a7056552b302a9089b7b6d8d9562402c38c40f1e', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '6982eefccba5442abf921b6371ca57b8a4ef224c7c024ab5f7c7935854249fd7', + '8e197ce5cacca3137c809708fb4ec576ab4a3a860ebe565c1413514fa3871c02', {'backends': ['opencv']}), ConvTestCase(PixelFormats.XBGR8888, - 'd555e8545b743011df3c27f0a7056552b302a9089b7b6d8d9562402c38c40f1e', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '6982eefccba5442abf921b6371ca57b8a4ef224c7c024ab5f7c7935854249fd7', + '8e197ce5cacca3137c809708fb4ec576ab4a3a860ebe565c1413514fa3871c02', {'backends': ['numpy']}), ConvTestCase(PixelFormats.RGBX8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + 'f54a7feac1c03623767e92ad64d7ca69d6a51c13e4ca37e59aa602fc30089910', {'backends': ['opencv']}), ConvTestCase(PixelFormats.BGRX8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '85d677ee00b3becf8cad690c1442e312b6824785c48498b7e263c094b83452e7', {'backends': ['opencv']}), ConvTestCase(PixelFormats.XBGR2101010, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '1161cd5862122522010431db27a517ad57dea4e7efe54d647bb9e17bb030aee8', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '91fa2a9252427a35b517400baa0d60d4e1f4ed9c62aad8ea3238e97fdf636a54', {'backends': ['numpy']}), ConvTestCase(PixelFormats.ARGB8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '44f12c50ca6aca845c69a72ca8dc06c66878287698be23e1c4635640c73bcac0', {'backends': ['opencv']}), ConvTestCase(PixelFormats.ARGB8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '44f12c50ca6aca845c69a72ca8dc06c66878287698be23e1c4635640c73bcac0', {'backends': ['numpy']}), ConvTestCase(PixelFormats.ABGR8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '8e197ce5cacca3137c809708fb4ec576ab4a3a860ebe565c1413514fa3871c02', {'backends': ['opencv']}), ConvTestCase(PixelFormats.ABGR8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '8e197ce5cacca3137c809708fb4ec576ab4a3a860ebe565c1413514fa3871c02', {'backends': ['numpy']}), ConvTestCase(PixelFormats.RGBA8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + 'f54a7feac1c03623767e92ad64d7ca69d6a51c13e4ca37e59aa602fc30089910', {'backends': ['opencv']}), ConvTestCase(PixelFormats.BGRA8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '85d677ee00b3becf8cad690c1442e312b6824785c48498b7e263c094b83452e7', {'backends': ['opencv']}), # Bayer formats ConvTestCase(PixelFormats.SBGGR8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - '491f80a7d0c2ac8d19925f830105d067a21cd00dbd1db33f274823f116b45f28', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + '20b4f68820f141c962d0e282222ccb5d1359149f5ef2c58fa4ca9ede29089d2a', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SBGGR8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - 'e46e00c942b6125c80dbe6e03406d80725679060382aa260d1209eb8dff19ce0', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + '24d206cb39f1d85e5683b8a6475d5580d067ac0563c871ea13483176539ebab5', {'backends': ['numba']}), ConvTestCase(PixelFormats.SBGGR8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - 'e46e00c942b6125c80dbe6e03406d80725679060382aa260d1209eb8dff19ce0', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + '24d206cb39f1d85e5683b8a6475d5580d067ac0563c871ea13483176539ebab5', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SGBRG8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - '671e13f7df721a936a83772b88ea2e333177865d7b03e75faf4454be57b9ed15', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + 'fb6da92d710d6ec504117070eb99d77ccd9725ba66a39de0f710ad41c697d0f5', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SGBRG8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - '5aeae3054548d52f9c455a4d772719eeee763b991a2643cb6c72af7c4e179e7f', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + 'fc5fe78fe18aff5b3525b0f19ca8dff0a4e517a7076998d50871ac31c0292a34', {'backends': ['numba']}), ConvTestCase(PixelFormats.SGBRG8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - '5aeae3054548d52f9c455a4d772719eeee763b991a2643cb6c72af7c4e179e7f', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + 'fc5fe78fe18aff5b3525b0f19ca8dff0a4e517a7076998d50871ac31c0292a34', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SGRBG8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - '0929f46335883a325d9fa54423990e5531f986c9a4956a2fc7f739d61b8bccb1', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + '458ff69a2ce30de1e6f05f2adffb048a9dd5a303e2a8d51345a1a1d8f9aac30b', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SGRBG8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - '82ceb727f4e39881592eba1dbc6f6613617c377dd23465fafd46f98c86e42f9d', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + '81f75d08574b647314a7070b5318def89c429eedcb1fd69492e6d014b1941b55', {'backends': ['numba']}), ConvTestCase(PixelFormats.SGRBG8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - '82ceb727f4e39881592eba1dbc6f6613617c377dd23465fafd46f98c86e42f9d', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + '81f75d08574b647314a7070b5318def89c429eedcb1fd69492e6d014b1941b55', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SRGGB8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - 'a09fe4424446b3f8254d456197bd8af95b7403561a559268a288a3d8e2c2dfc6', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + '22254252d74defb13f2e28ccd944bf67b8a2411697b8646dc526811d1cd7607f', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SRGGB8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - '4ebaac89404d809284c4ca4beab07dc6f77a8d8250cbc7cf225d13f40b8579be', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + 'f300d2f7e5988cf0309df44d54a4c5cc0502ecd9f2f35624be7ebec31a3d32e8', {'backends': ['numba']}), ConvTestCase(PixelFormats.SRGGB8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - '4ebaac89404d809284c4ca4beab07dc6f77a8d8250cbc7cf225d13f40b8579be', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + 'f300d2f7e5988cf0309df44d54a4c5cc0502ecd9f2f35624be7ebec31a3d32e8', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SBGGR10, - '3eafcda7e182be78032d6296002c2a4780ce8317a67f30578453b97d24dd3205', - '8b7e46de9c2ed9c818868836dc025e80c945746be366a60bc6ddf58a67248ee8', + '8224fef7c6d897391f1a48f3c3f3589110b513682b66a2fa650fe3325467d588', + '7381db137edecbff32b503c198d41e4619ad73a47ef1a144ae96fc70a6f66447', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SBGGR10, - '3eafcda7e182be78032d6296002c2a4780ce8317a67f30578453b97d24dd3205', - '34b1541f81cad855ecf7c5bed89f7a8f23299ba34554eb844eff4283c40cd6ae', + '8224fef7c6d897391f1a48f3c3f3589110b513682b66a2fa650fe3325467d588', + '38f94caf22cd4224d20d951f204a807144a0b98ed627822afd9328e179644113', {'backends': ['numba']}), ConvTestCase(PixelFormats.SBGGR10, - '3eafcda7e182be78032d6296002c2a4780ce8317a67f30578453b97d24dd3205', - '34b1541f81cad855ecf7c5bed89f7a8f23299ba34554eb844eff4283c40cd6ae', + '8224fef7c6d897391f1a48f3c3f3589110b513682b66a2fa650fe3325467d588', + '38f94caf22cd4224d20d951f204a807144a0b98ed627822afd9328e179644113', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SGBRG10, - '3eafcda7e182be78032d6296002c2a4780ce8317a67f30578453b97d24dd3205', - '9049472d0a99228333d41afeda3377546ee59c4c20c6d298138a0263849da688', + '8224fef7c6d897391f1a48f3c3f3589110b513682b66a2fa650fe3325467d588', + '8304eb57fef1cab4b0f5f48d03603240ea918967455ee4f10a8f6df045b8f8fe', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SGBRG10, - '3eafcda7e182be78032d6296002c2a4780ce8317a67f30578453b97d24dd3205', - '0e402efd0b0ff19343af7d71e73d1c89800e3a1b03f515e1efc85a0b0a4eb847', + '8224fef7c6d897391f1a48f3c3f3589110b513682b66a2fa650fe3325467d588', + '14d52cfbb34b0d645e9502e778030ecdcbe1d24bafdb73a447c64f897bf679d0', {'backends': ['numba']}), ConvTestCase(PixelFormats.SGBRG10, - '3eafcda7e182be78032d6296002c2a4780ce8317a67f30578453b97d24dd3205', - '0e402efd0b0ff19343af7d71e73d1c89800e3a1b03f515e1efc85a0b0a4eb847', + '8224fef7c6d897391f1a48f3c3f3589110b513682b66a2fa650fe3325467d588', + '14d52cfbb34b0d645e9502e778030ecdcbe1d24bafdb73a447c64f897bf679d0', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SGRBG10, - '3eafcda7e182be78032d6296002c2a4780ce8317a67f30578453b97d24dd3205', - '76848bbe501d4f7cbff7e536e57368f3f179968c20d33858e2c5e2e97544c0d4', + '8224fef7c6d897391f1a48f3c3f3589110b513682b66a2fa650fe3325467d588', + 'a902ba571e4889b31e4c2cb71993eb51ee092b5b21a09052a662ad86a4eb4c0e', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SGRBG10, - '3eafcda7e182be78032d6296002c2a4780ce8317a67f30578453b97d24dd3205', - '6d67b15331993052de5c2fed7c0d70f244e1e365128b934a7b41fa7715250a6c', + '8224fef7c6d897391f1a48f3c3f3589110b513682b66a2fa650fe3325467d588', + '2d61a506053972e2b442b5b3a1977e14421ffdab139e4e4730ff9c01172eaa4f', {'backends': ['numba']}), ConvTestCase(PixelFormats.SGRBG10, - '3eafcda7e182be78032d6296002c2a4780ce8317a67f30578453b97d24dd3205', - '6d67b15331993052de5c2fed7c0d70f244e1e365128b934a7b41fa7715250a6c', + '8224fef7c6d897391f1a48f3c3f3589110b513682b66a2fa650fe3325467d588', + '2d61a506053972e2b442b5b3a1977e14421ffdab139e4e4730ff9c01172eaa4f', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SRGGB10, - '3eafcda7e182be78032d6296002c2a4780ce8317a67f30578453b97d24dd3205', - 'f99b8300ea90cc4df28022a1fc0c135e07440dc0f27c23399743d78f70d8ff63', + '8224fef7c6d897391f1a48f3c3f3589110b513682b66a2fa650fe3325467d588', + 'ed3d3636ed59fbfbc5e3030577ddd236a4824ba7d1e3c4c1bc61e34b763193ae', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SRGGB10, - '3eafcda7e182be78032d6296002c2a4780ce8317a67f30578453b97d24dd3205', - '59b206fa98863196fb3b8276b43aa9e54d4b6826b6e64d3c21bc76ce8914cf89', + '8224fef7c6d897391f1a48f3c3f3589110b513682b66a2fa650fe3325467d588', + '765fa172fb54c6cd994c2e33214d65217bad343139a69684a1ea2715e3cf05ce', {'backends': ['numba']}), ConvTestCase(PixelFormats.SRGGB10, - '3eafcda7e182be78032d6296002c2a4780ce8317a67f30578453b97d24dd3205', - '59b206fa98863196fb3b8276b43aa9e54d4b6826b6e64d3c21bc76ce8914cf89', + '8224fef7c6d897391f1a48f3c3f3589110b513682b66a2fa650fe3325467d588', + '765fa172fb54c6cd994c2e33214d65217bad343139a69684a1ea2715e3cf05ce', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SBGGR10P, - 'ab7ddc194c770396ae961630df80e05cfaf63a7de93fb19ff49aef999234310d', - '6387ada9716380628e3c0c4bae90c405cbabc426ac4c8e4b2de5eed397a75fe9', + '1c4fa7add306c15d7a1c68fc19508fbe9c988199d420fd23080ad778624477e3', + 'cd9d2ce659407efaae67920f4915ba09608f7e58858657446d9cc562dfe8e4c5', {'backends': ['numba']}), ConvTestCase(PixelFormats.SBGGR10P, - 'ab7ddc194c770396ae961630df80e05cfaf63a7de93fb19ff49aef999234310d', - '6387ada9716380628e3c0c4bae90c405cbabc426ac4c8e4b2de5eed397a75fe9', + '1c4fa7add306c15d7a1c68fc19508fbe9c988199d420fd23080ad778624477e3', + 'cd9d2ce659407efaae67920f4915ba09608f7e58858657446d9cc562dfe8e4c5', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SGBRG10P, - 'ab7ddc194c770396ae961630df80e05cfaf63a7de93fb19ff49aef999234310d', - 'd41464101f47f49731482428981c5f9e6f1220819697c507fb867f1bc73ef651', + '1c4fa7add306c15d7a1c68fc19508fbe9c988199d420fd23080ad778624477e3', + 'cd411943482b8a5fb6de749f83b0e13b9dfbf900237847e039fa95283c39e341', {'backends': ['numba']}), ConvTestCase(PixelFormats.SGBRG10P, - 'ab7ddc194c770396ae961630df80e05cfaf63a7de93fb19ff49aef999234310d', - 'd41464101f47f49731482428981c5f9e6f1220819697c507fb867f1bc73ef651', + '1c4fa7add306c15d7a1c68fc19508fbe9c988199d420fd23080ad778624477e3', + 'cd411943482b8a5fb6de749f83b0e13b9dfbf900237847e039fa95283c39e341', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SGRBG10P, - 'ab7ddc194c770396ae961630df80e05cfaf63a7de93fb19ff49aef999234310d', - '25b1fdef6da2b5042b0def945af3aa0c9c2ba3cd0f4b2e5ee5365255032680bc', + '1c4fa7add306c15d7a1c68fc19508fbe9c988199d420fd23080ad778624477e3', + '675c3665e891d19cdecdc304ec823d6902ea48357556ffd7b5a27eb22913e5cc', {'backends': ['numba']}), ConvTestCase(PixelFormats.SGRBG10P, - 'ab7ddc194c770396ae961630df80e05cfaf63a7de93fb19ff49aef999234310d', - '25b1fdef6da2b5042b0def945af3aa0c9c2ba3cd0f4b2e5ee5365255032680bc', + '1c4fa7add306c15d7a1c68fc19508fbe9c988199d420fd23080ad778624477e3', + '675c3665e891d19cdecdc304ec823d6902ea48357556ffd7b5a27eb22913e5cc', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SRGGB10P, - 'ab7ddc194c770396ae961630df80e05cfaf63a7de93fb19ff49aef999234310d', - '9603db169d70d12f8495fad8efac4a15d141ab8964803cd4f5ad7cce430ca42b', + '1c4fa7add306c15d7a1c68fc19508fbe9c988199d420fd23080ad778624477e3', + '910ecfb6e55691fef5f246c57292a451fde1516dcf564f230d56ba42756709ca', {'backends': ['numba']}), ConvTestCase(PixelFormats.SRGGB10P, - 'ab7ddc194c770396ae961630df80e05cfaf63a7de93fb19ff49aef999234310d', - '9603db169d70d12f8495fad8efac4a15d141ab8964803cd4f5ad7cce430ca42b', + '1c4fa7add306c15d7a1c68fc19508fbe9c988199d420fd23080ad778624477e3', + '910ecfb6e55691fef5f246c57292a451fde1516dcf564f230d56ba42756709ca', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SBGGR12, - 'dccb1922642869f266ed341b27200dca58add54c344c69a8991cedf6232fbb33', - 'e8bf86805c77520b2b7be792f805f94148d9846327eecab6a9bf2a9ec0d6d901', + '523868431c14ff2566f25b13ba3be8c08e0439fea136c636f6214d5f406e68f4', + '630ca7cf57ba7a10b93534a2ae83895a7914feeffce0d21ce4c58d2256e79730', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SBGGR12, - 'dccb1922642869f266ed341b27200dca58add54c344c69a8991cedf6232fbb33', - '758dea8c5b3da39ca7383f4b0366e24837744b46c870c3bafe273dffbc93191d', + '523868431c14ff2566f25b13ba3be8c08e0439fea136c636f6214d5f406e68f4', + '61ee0e93d88a0231a59a6a4ad100517873a5643d1a63614c2071ae484fe6451a', {'backends': ['numba']}), ConvTestCase(PixelFormats.SBGGR12, - 'dccb1922642869f266ed341b27200dca58add54c344c69a8991cedf6232fbb33', - '758dea8c5b3da39ca7383f4b0366e24837744b46c870c3bafe273dffbc93191d', + '523868431c14ff2566f25b13ba3be8c08e0439fea136c636f6214d5f406e68f4', + '61ee0e93d88a0231a59a6a4ad100517873a5643d1a63614c2071ae484fe6451a', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SGBRG12, - 'dccb1922642869f266ed341b27200dca58add54c344c69a8991cedf6232fbb33', - '843abee9330b3d09f4becfc34c6fa5bc133f79765453c2937a0cea77cbe88857', + '523868431c14ff2566f25b13ba3be8c08e0439fea136c636f6214d5f406e68f4', + '48c39a077e19e4e6d2c01b4b61f19d387c1e3eb01750478164475d573c3b5347', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SGBRG12, - 'dccb1922642869f266ed341b27200dca58add54c344c69a8991cedf6232fbb33', - '4beb2dfbc9d2622e320bed5e62f132426d32277b8a8abddd99a8b79741d7f369', + '523868431c14ff2566f25b13ba3be8c08e0439fea136c636f6214d5f406e68f4', + 'ceb04b2481dd18210907d9490bc7c5251ba83a509b4a3c0cefe1c6f6ec68f854', {'backends': ['numba']}), ConvTestCase(PixelFormats.SGBRG12, - 'dccb1922642869f266ed341b27200dca58add54c344c69a8991cedf6232fbb33', - '4beb2dfbc9d2622e320bed5e62f132426d32277b8a8abddd99a8b79741d7f369', + '523868431c14ff2566f25b13ba3be8c08e0439fea136c636f6214d5f406e68f4', + 'ceb04b2481dd18210907d9490bc7c5251ba83a509b4a3c0cefe1c6f6ec68f854', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SGRBG12, - 'dccb1922642869f266ed341b27200dca58add54c344c69a8991cedf6232fbb33', - '6609513e2b2c56e26f369ac9e06c45199949a41bda5b7156287b1c2d4da02f8b', + '523868431c14ff2566f25b13ba3be8c08e0439fea136c636f6214d5f406e68f4', + 'a209f3e7b68a99d3541d185ebb86e8a6a5d0511ce45d9487c83ca132e8d27a31', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SGRBG12, - 'dccb1922642869f266ed341b27200dca58add54c344c69a8991cedf6232fbb33', - '4da37f278e6aae4bce48881a6678dd18ff8770a14bc7b66aff096978c89c77e8', + '523868431c14ff2566f25b13ba3be8c08e0439fea136c636f6214d5f406e68f4', + '353b477a3ba40554c6532b69c225bf077b4b468daf2e1d5c24230b47786dfdba', {'backends': ['numba']}), ConvTestCase(PixelFormats.SGRBG12, - 'dccb1922642869f266ed341b27200dca58add54c344c69a8991cedf6232fbb33', - '4da37f278e6aae4bce48881a6678dd18ff8770a14bc7b66aff096978c89c77e8', + '523868431c14ff2566f25b13ba3be8c08e0439fea136c636f6214d5f406e68f4', + '353b477a3ba40554c6532b69c225bf077b4b468daf2e1d5c24230b47786dfdba', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SRGGB12, - 'dccb1922642869f266ed341b27200dca58add54c344c69a8991cedf6232fbb33', - '5c661ae0a66accd07caf3d620b81a063876e9fed3195c30fe6e066105dff7b9d', + '523868431c14ff2566f25b13ba3be8c08e0439fea136c636f6214d5f406e68f4', + '74d797c49b0f7f7dc0059ed1aa08adfe17fbed5fb8973be7f43dd439de71a5bf', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SRGGB12, - 'dccb1922642869f266ed341b27200dca58add54c344c69a8991cedf6232fbb33', - '49999659bafe57a0e90672f176ccb06f6d14dfe67c84a1f1a44249d2513c24a9', + '523868431c14ff2566f25b13ba3be8c08e0439fea136c636f6214d5f406e68f4', + 'dc352a44143710aa3eb465bb9ece5546b7e88decc7f76491d914820cb0e898a6', {'backends': ['numba']}), ConvTestCase(PixelFormats.SRGGB12, - 'dccb1922642869f266ed341b27200dca58add54c344c69a8991cedf6232fbb33', - '49999659bafe57a0e90672f176ccb06f6d14dfe67c84a1f1a44249d2513c24a9', + '523868431c14ff2566f25b13ba3be8c08e0439fea136c636f6214d5f406e68f4', + 'dc352a44143710aa3eb465bb9ece5546b7e88decc7f76491d914820cb0e898a6', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SBGGR12P, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '526e23110337c40a4180a977e648eb4be0cfde57bbeb7b319868fc16180da2b7', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'b7144ef9708f1246d6b3814c65aede14696d289599fa9d2b66d4b4c305279f98', {'backends': ['numba']}), ConvTestCase(PixelFormats.SBGGR12P, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '526e23110337c40a4180a977e648eb4be0cfde57bbeb7b319868fc16180da2b7', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'b7144ef9708f1246d6b3814c65aede14696d289599fa9d2b66d4b4c305279f98', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SGBRG12P, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'b512728bd77981e434b02b1ae21c3015f05ddb25162250bba82012d3b34ad340', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'e17876b00750550c9b6ca68a48b26e65e9840baf4e76c63dddd660425ca59160', {'backends': ['numba']}), ConvTestCase(PixelFormats.SGBRG12P, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'b512728bd77981e434b02b1ae21c3015f05ddb25162250bba82012d3b34ad340', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'e17876b00750550c9b6ca68a48b26e65e9840baf4e76c63dddd660425ca59160', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SGRBG12P, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '345aed59896a9da8d97f59ea350191131846db776b5ff20a6ea61045163e6c41', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '0fcbd93a775e23fe8d4a386e848cb28c7bb904a642bc6f124036511e9aa59768', {'backends': ['numba']}), ConvTestCase(PixelFormats.SGRBG12P, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '345aed59896a9da8d97f59ea350191131846db776b5ff20a6ea61045163e6c41', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '0fcbd93a775e23fe8d4a386e848cb28c7bb904a642bc6f124036511e9aa59768', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SRGGB12P, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '5aff7ae27f65e12bcc1be731d0d442dd9bac7406411fe4e866d2eb9e1251b38f', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'b3f7cb69a0b391ff2058f9d3653171e072320be7f809a613765ad5a01db39e93', {'backends': ['numba']}), ConvTestCase(PixelFormats.SRGGB12P, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '5aff7ae27f65e12bcc1be731d0d442dd9bac7406411fe4e866d2eb9e1251b38f', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'b3f7cb69a0b391ff2058f9d3653171e072320be7f809a613765ad5a01db39e93', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SBGGR16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'aa6c40894469d390f7ae695f84da26f3111509ae5450a708811bbca2ed1a6fe2', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'f83a3eafc8e33af79e162c37a413c7c82f87cfebe05e94a577cc128b4148c060', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SBGGR16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '280042ed68c2722f3257cf951dd7e01d65813d4d1e0a1a6c57e531554962e951', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '604d7dbde2bc71ff07c26e4733aa5ec7d58cb56c28794e448e75b5f88d1eac63', {'backends': ['numba']}), ConvTestCase(PixelFormats.SBGGR16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '280042ed68c2722f3257cf951dd7e01d65813d4d1e0a1a6c57e531554962e951', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '604d7dbde2bc71ff07c26e4733aa5ec7d58cb56c28794e448e75b5f88d1eac63', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SGBRG16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'd439d574e5df2d4f32e64a01f8a79e29e582ea50effcbd7b3e2143faad24abc1', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'e40474a3da569bca42299be5ca3c341316b05e6b3d65c7ef9f973ca41e100653', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SGBRG16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '4d8782b78f380d765f64bb0c62f057b9e72e15a44857b8a06d1436146b7402bb', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'b83b7c42788ef54d8d7860da7bcf89468eab9e7949ab9d64d64411a14e7d29f2', {'backends': ['numba']}), ConvTestCase(PixelFormats.SGBRG16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '4d8782b78f380d765f64bb0c62f057b9e72e15a44857b8a06d1436146b7402bb', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'b83b7c42788ef54d8d7860da7bcf89468eab9e7949ab9d64d64411a14e7d29f2', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SGRBG16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '01cff6d2d8d1e11dad559dac2e53c83ccbc42047d92abc3d7b5b9becd74d0d89', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '69483ef0eda7487889a7e38c547bf7b3d30bb6d56e32ff8c013f7aec59621ea8', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SGRBG16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '54f41a4bdd41b7db26de1eb5f2d2fd43c83146f22d4f6cfec7804312a3091f31', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'f547e5d65523956344df155cd70eb472c6424773dd8b1cced30cd3aa10045306', {'backends': ['numba']}), ConvTestCase(PixelFormats.SGRBG16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '54f41a4bdd41b7db26de1eb5f2d2fd43c83146f22d4f6cfec7804312a3091f31', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'f547e5d65523956344df155cd70eb472c6424773dd8b1cced30cd3aa10045306', {'backends': ['numpy']}), ConvTestCase(PixelFormats.SRGGB16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '342364cd88064546e1934b4fcce2e0a629be8d92ab638f1112fedb0e8c445853', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '8d69e733bad62558c2ffe2f97e304f2dd5a0d08572591f738ad720b8a63fb299', {'backends': ['opencv']}), ConvTestCase(PixelFormats.SRGGB16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '93b4137b70cccb9f45ff78cabef0406c2150e5ad7ac708dbd4aaaddf42dff15b', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '2a5637504397a65dbd140fdef70a689542d153b97a4690b6d4bc22460c7af060', {'backends': ['numba']}), ConvTestCase(PixelFormats.SRGGB16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '93b4137b70cccb9f45ff78cabef0406c2150e5ad7ac708dbd4aaaddf42dff15b', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '2a5637504397a65dbd140fdef70a689542d153b97a4690b6d4bc22460c7af060', {'backends': ['numpy']}), # YUV formats ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '32c537a5ef20ba4b8078f9db94be3956de18b548f0d9d14498bacc515dd4c26c', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '7beed25a562b0eb531e087a194d928335ba914a3fd4fe52a66f5597fda050cc4', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'ac251d8071d7f7243b2c53b2d63679719fc5edae16862e19f7899d99e5a9e4e4', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '3d8fb9fa05a818b51968ea8139270a8022258caceb8424e676085c420998a30d', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '61cf0819e39b9b231f5fdb9029b038d1d6bdc8e47b1302d1d932c6fe2fef580a', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'a26f5f2b39f622f2047acfba8bd3106fe9e28f2e3ce558c605ca693d7e6c653f', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'f9a4441f56773c5a2a77db4f82c0b281c9fcc64c535dbf6f60161f6a4743e15e', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'd65a6569eaf94dc1918340e6432eec494ac6cfe22303c6ad99d5f7d9bf5a360f', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '28b9e58b020acd551467062c1b0f97bfb99ec57da58f3e7816b59e85bd382c34', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'dc1e3eeab46b5b1a0eabf310193ab068f43e929839b4eb8e022d6bde87609512', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'a15e00dca28540a87ab64f00ecf25a94c3674cadc758b0d9e37d49b5c4d09631', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '5df420a5cfdca8bfa92b6b58fad9afcc666c336634fc90179b156acc3a60196b', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '09603f7ef68c63d3ac98c5c105273bf206f960ebbd311c9aa0fcab1a214e4ae4', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '96c8d2af6aa40dfcc18402b0c40fd1a89a4ed059c8ab24075dcf14e19079e7f2', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'ac251d8071d7f7243b2c53b2d63679719fc5edae16862e19f7899d99e5a9e4e4', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '3d8fb9fa05a818b51968ea8139270a8022258caceb8424e676085c420998a30d', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '61cf0819e39b9b231f5fdb9029b038d1d6bdc8e47b1302d1d932c6fe2fef580a', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'a26f5f2b39f622f2047acfba8bd3106fe9e28f2e3ce558c605ca693d7e6c653f', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'f9a4441f56773c5a2a77db4f82c0b281c9fcc64c535dbf6f60161f6a4743e15e', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'd65a6569eaf94dc1918340e6432eec494ac6cfe22303c6ad99d5f7d9bf5a360f', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '28b9e58b020acd551467062c1b0f97bfb99ec57da58f3e7816b59e85bd382c34', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'dc1e3eeab46b5b1a0eabf310193ab068f43e929839b4eb8e022d6bde87609512', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'a15e00dca28540a87ab64f00ecf25a94c3674cadc758b0d9e37d49b5c4d09631', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '5df420a5cfdca8bfa92b6b58fad9afcc666c336634fc90179b156acc3a60196b', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.YUYV, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '09603f7ef68c63d3ac98c5c105273bf206f960ebbd311c9aa0fcab1a214e4ae4', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '96c8d2af6aa40dfcc18402b0c40fd1a89a4ed059c8ab24075dcf14e19079e7f2', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '601e7354d710c69888ee0257d82b45f333acc0bf91bfac81d0863a7a0fe95fbc', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'ffd6b4c72d317afc34985a4c89897c4a29a11c865f3cb5656dcd3d90310d8664', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '11873b9f7a02d2644c850a0a7a52cd5a1181472642e58d5a2cc5f8eef84772a5', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '903926eb064570b0552b00d31429c6309e678b0ced17a4e98f4a5fd507133c79', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'e7e5b9cccb80295ef433573bb93ea7e00e8d4737fcc2eae82ebd82b2f32f8dcd', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '2393015fcf5c21fa67c59cbbd292fd4b2f6bf9f877180ae130600c90033e88e9', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'f30c6c690b5f500ceac2c6aab10f20c588a508b8f59a5428df6beeecb7a0a168', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '454a40c63d8edb962576e6cfc23378dbfd6337f649b2fb6b26605314fb248b80', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'abe42655a7b8ddfe5d0dd469fd99f2fed79d8636327f877abf38c2d88885873d', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '9201a1789bc17a203a23aa003339799e34058a55801a5cdf7219c4be22d98083', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '4061daa00faa3792778dc62f44c7d651c1f51f56b84fad551696cb0d01af6f27', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '563e346790ac149fc0ab4d12c097f6888744dd07ea04e817d6c8f45d58aa9ad1', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '2724fde25cae3a5055096d78dfb38db7cd6f2ac7d7ea7cc2a72e936950bb0c9a', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'e5b27c19da728d4545d5e357d6f5f8f47482f7d8aa7cea1dd30e782841df26c1', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '11873b9f7a02d2644c850a0a7a52cd5a1181472642e58d5a2cc5f8eef84772a5', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '903926eb064570b0552b00d31429c6309e678b0ced17a4e98f4a5fd507133c79', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'e7e5b9cccb80295ef433573bb93ea7e00e8d4737fcc2eae82ebd82b2f32f8dcd', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '2393015fcf5c21fa67c59cbbd292fd4b2f6bf9f877180ae130600c90033e88e9', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'f30c6c690b5f500ceac2c6aab10f20c588a508b8f59a5428df6beeecb7a0a168', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '454a40c63d8edb962576e6cfc23378dbfd6337f649b2fb6b26605314fb248b80', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'abe42655a7b8ddfe5d0dd469fd99f2fed79d8636327f877abf38c2d88885873d', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '9201a1789bc17a203a23aa003339799e34058a55801a5cdf7219c4be22d98083', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '4061daa00faa3792778dc62f44c7d651c1f51f56b84fad551696cb0d01af6f27', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '563e346790ac149fc0ab4d12c097f6888744dd07ea04e817d6c8f45d58aa9ad1', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.UYVY, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '2724fde25cae3a5055096d78dfb38db7cd6f2ac7d7ea7cc2a72e936950bb0c9a', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'e5b27c19da728d4545d5e357d6f5f8f47482f7d8aa7cea1dd30e782841df26c1', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YVYU, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'd989b3342f6b1300e498e2a8b1462c33b1d4073d045294e111e9d7c5c1ebd8ee', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'ef5efaa19f73ae483a018248300a00a003d392348e9229bc6139343219144fec', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '3fc9d3a2511ba41f3e7969a1fb2b9ef4ceb9dbe24c24478031022c560cb12612', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '54e723900e8f036c914927b648f2e6d0a49be9ee74be9612aef211ff1517578c', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'b344baf79eb4f8ead648fd4c7e426710884a563e87e77e1fb5902dec1846b123', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '100d45ec955fddd0fcb5810847fd70fc5cc3d6cb8cc814c6d92b6993e4fc49f3', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'ee1d9c5348ef3957a931e238f6fcec28e5b8126ed24aa6930701d0f1eb594717', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '9d4f0669a10925a8109eeffdecde42e79e9463351603130eccf8f20f6c142e83', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '9ab7c63fabb7fed66d5a1aa1638763dfa969cd570463c67eae93f7ed26287508', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '0f97df33a6d3e57ee343ca06402b4d6466c6ecbf11069b72d0b0e2e630f08b66', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'fc69d5bdd58defe8bb75f74a09cd0f720e6ea8b9af248c3f7a1dbb12381feeda', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '0a26f1ccb71059c128946dc825ba21cadeeba167a550f2b711532f537ae44018', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '9fcf635aab87c8c606cc6b4122fba6cf5d2d2659702d4878d0655995cc4204ff', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '5c048f78ecd5c8299d76ba9fec8b82764c2ba4a387347ba5b713ec6a11a2e931', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '690b4a5e4d11542d1724a343ac6c66e5339f3969f929d152ecb2c7de42760272', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '5a84e6af0755b01c68bcd9a02e0efc83ae3ee2eb5a21b99240c3b711c89273d6', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'b344baf79eb4f8ead648fd4c7e426710884a563e87e77e1fb5902dec1846b123', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '100d45ec955fddd0fcb5810847fd70fc5cc3d6cb8cc814c6d92b6993e4fc49f3', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'ee1d9c5348ef3957a931e238f6fcec28e5b8126ed24aa6930701d0f1eb594717', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '9d4f0669a10925a8109eeffdecde42e79e9463351603130eccf8f20f6c142e83', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '9ab7c63fabb7fed66d5a1aa1638763dfa969cd570463c67eae93f7ed26287508', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '0f97df33a6d3e57ee343ca06402b4d6466c6ecbf11069b72d0b0e2e630f08b66', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'fc69d5bdd58defe8bb75f74a09cd0f720e6ea8b9af248c3f7a1dbb12381feeda', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '0a26f1ccb71059c128946dc825ba21cadeeba167a550f2b711532f537ae44018', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '9fcf635aab87c8c606cc6b4122fba6cf5d2d2659702d4878d0655995cc4204ff', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '5c048f78ecd5c8299d76ba9fec8b82764c2ba4a387347ba5b713ec6a11a2e931', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '690b4a5e4d11542d1724a343ac6c66e5339f3969f929d152ecb2c7de42760272', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '5a84e6af0755b01c68bcd9a02e0efc83ae3ee2eb5a21b99240c3b711c89273d6', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '31e75b443aad217972fcf9c50c2f2dbe8b517e7d7e76f7c8e95b051a4d6863bd', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'fc0a0560e37470f31ce8107fa360efd02e05777c2ac5b5cc3f843ad11e5b3923', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV21, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '7a95561c3cf7ad4d088dba6299424e90776101c009a2fe17b36f0ac483f71cab', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV21, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '53d069bc282ca54d2c547ff5a206bc4927ffa64a3b3638ee3f2e396698956f48', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV21, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'f13572ba3ca9e1e6de56a303f7bb9cd61731465cd83ee464c0a77e3635c29191', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV21, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '9731a2991fa933274aa4ca53298fbf9d7160755f139a99e8f88a44fb842b84a1', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV21, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '6ddfe0ecc87ebbb2788aaeadd18de2489d7264455b7cb0512750d18e3fd57e36', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV21, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'b10d9b950e323b96277fbf311c323c7f5bf1e23abaac1a461ff99cd1fb5cb60d', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV21, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '7a95561c3cf7ad4d088dba6299424e90776101c009a2fe17b36f0ac483f71cab', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV21, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '53d069bc282ca54d2c547ff5a206bc4927ffa64a3b3638ee3f2e396698956f48', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV21, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'f13572ba3ca9e1e6de56a303f7bb9cd61731465cd83ee464c0a77e3635c29191', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV21, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '9731a2991fa933274aa4ca53298fbf9d7160755f139a99e8f88a44fb842b84a1', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV21, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '6ddfe0ecc87ebbb2788aaeadd18de2489d7264455b7cb0512750d18e3fd57e36', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV21, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'b10d9b950e323b96277fbf311c323c7f5bf1e23abaac1a461ff99cd1fb5cb60d', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV16, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '39200b9169545a51f5295c3fd20183b6749cbec75bae65eb45fb176fa38afb80', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV16, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '65dde8098b8abddf73df567c722faeb8dab29ed13bed28e83e18c0fa79d3bcfb', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV16, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '1f6ad8b53040071a709b8951d8d23d4e624c42080107a9dfb51495f2abeaec4e', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV16, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'd90215e8ba19e4aa20caf228abb478cf7beb11911feb6090c006cd7755a92b63', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV16, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'c8c16dda9f075ecb615471f5992bcc6f915da2bf07c0861859ba2bb2851398d2', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV16, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '6c2d81fca27699302c09d4ae18e588a8542273b5c2f580f90d074f84f013043d', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV16, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '39200b9169545a51f5295c3fd20183b6749cbec75bae65eb45fb176fa38afb80', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV16, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '65dde8098b8abddf73df567c722faeb8dab29ed13bed28e83e18c0fa79d3bcfb', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV16, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '1f6ad8b53040071a709b8951d8d23d4e624c42080107a9dfb51495f2abeaec4e', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV16, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'd90215e8ba19e4aa20caf228abb478cf7beb11911feb6090c006cd7755a92b63', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV16, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'c8c16dda9f075ecb615471f5992bcc6f915da2bf07c0861859ba2bb2851398d2', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV16, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '6c2d81fca27699302c09d4ae18e588a8542273b5c2f580f90d074f84f013043d', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV61, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'dac86f5051608f1c9c9e3d4bc35a751a353742de78835c96946d105951126a4a', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV61, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '843448b2b8c6f42676a821bd5ad7a5c60862d8993ba283a7b4e10e09fb92e44c', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV61, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'dfd74c4a87b5b9a5fd408240c148b58df743bb91c19a946b9e5bcc785c026791', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV61, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '567c5890cb62f257150793e05ae17e7cc971a8b2d6f11866b465f1b1773dc1f0', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV61, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'a4ac51a574a3ca645c28fa47f2222471572af9866479f73bf6a86ea309606bcc', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV61, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '9efda124f2f9ad3b862266a95127cae37c93691b94de54ea4f52fbb20b67b538', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV61, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'dac86f5051608f1c9c9e3d4bc35a751a353742de78835c96946d105951126a4a', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV61, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '843448b2b8c6f42676a821bd5ad7a5c60862d8993ba283a7b4e10e09fb92e44c', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV61, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'dfd74c4a87b5b9a5fd408240c148b58df743bb91c19a946b9e5bcc785c026791', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV61, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '567c5890cb62f257150793e05ae17e7cc971a8b2d6f11866b465f1b1773dc1f0', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV61, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'a4ac51a574a3ca645c28fa47f2222471572af9866479f73bf6a86ea309606bcc', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV61, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '9efda124f2f9ad3b862266a95127cae37c93691b94de54ea4f52fbb20b67b538', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YUV420, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'd7e9c576657afa613dfc6e86bcc8b38fc7588265056da22d2dbb8fd78385d15a', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YUV420, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'ccde0554847023ee7243a2a04174b65ad014531b646e4267d7dc324d96a12e20', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YUV420, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '1c431924149bff7cbe60ec0725ff60529e358af2748d91213a0e58a5ed055fe2', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YUV420, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'b6c583c7f7b5bc46eb3e3d7622f0c93d026da393a76766e395240aa46f725cde', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YUV420, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '00d48ea5ae0b9c27601f9f3845082a375ea07556caf792619f9f4564f072d7e9', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YUV420, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '9def3464ba807ced27a4f37e1de11198f01e02edcec49623201f345e3a2759b9', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YVU420, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '48acd74eb0d50a4cfd36da0a1177e9a693f129137d292c1be47bf7d32a55e9e1', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YVU420, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '325ece9517437169046fbf3ebab728b71cdaf2ddefd24251afd2d57debe98915', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YVU420, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '4b9848faea889aba72a836e1f826638e90290122a2f95bf645aeacfa2a32d9a9', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YVU420, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '1898853e75889b4820c58b05b7492b8b4b7a9e690df710af36a795c6007b94e0', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YVU420, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '9c5b3f47708460eca6b5797a3e93964ef60190e774e9867c1cf491bb11dd3627', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YVU420, + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '92231adfddf09dbfa4a48e22a7caeae82f9d6413c928c7536278b98e5395f385', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YUV422, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '03bf75a85a3c17d6dd8857fcae719f96a30e2fb4270cd6f4147d2c9a6310d2aa', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YUV422, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '46d8c87cfe42057f642da5f388c3c341011db8fb66f8fde5c16ee81735d57bf1', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YUV422, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '0be4560b45ade606754263f9c9b276852281a08c54b4f6bf186c4c6d8607b616', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YUV422, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '87b1b0d707e71185f82639241b61d1b0fbe859a861713f8089b735c3eeb1a8c7', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YUV422, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '4a73d260c23893d44af9b25a6a24d53fde24e331b610a5406595ad4f05902670', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YUV422, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '59ea8f3ae06f9b51129f2101b711e4ab05c9c57f74262ea4b6df1c2944415f1f', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YVU422, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '3ecdbb53c3a6139992e7a367a3f4d27eb59e18a3cc56ca932dc182e9543b7e29', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YVU422, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'e452974b9b38fc849dd2e9840ccaa4c4a03eab8a718692a815809a9304b83cb3', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YVU422, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '51dc32f34b36376f538ce017639c4e1233dad8d3fa08001e07cc9c128dc95ff9', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YVU422, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'f6b09361452a7d6f081815ece950433a181ea45d30af6572a05c0e16b6ead527', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YVU422, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'd96c04e10f8b3be1f595c099a239ac29c7698d0fa7ebbb9708ffcd99b0e86e7a', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YVU422, + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '656236bbdf96d5be81a84191fc8ed63efa90910b51bfd05732e8dd30a2ec9f7a', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.Y8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - '58226da199a89c0bb0405ba764ce314513f6303ff19f3d25b936b0a82714b3d8', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + '049ffe7a6db39bd6563ab481afa50b2d5eab4d77a36cf3e80929929e3bdc4673', {'backends': ['numpy'], 'range': 'limited'}), ConvTestCase(PixelFormats.Y8, - '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', - 'aed54f9e7b5c2197c1ce85a4d5d4c6efc23ad7073bd2dd9c3353488cac270706', + '170bae8380e86c8080fa6ae6ac0b9b309ce55dff20a0a986a85142a487503b96', + '169f20c4f00a21243b376eb3d81afbb620ad900d29555e33cd8b0ed787e82b06', {'backends': ['numpy'], 'range': 'full'}), # Other formats ] diff --git a/tests/test_formats.py b/tests/test_formats.py index de1db5e..1c52ac8 100755 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -103,5 +103,46 @@ def run_data(self, data: FormatTestData): self.assertEqual(size, dumb_size, f'dumb size failed for {fmt}') +class TestExtrapolateStride(unittest.TestCase): + def test_matches_natural_stride(self): + # For every planar/semi-planar format, extrapolating from plane-0's + # natural stride must reproduce the natural stride of every other plane. + width = 1920 + for fmt in PixelFormats.get_formats(): + if len(fmt.planes) < 2: + continue + s0 = fmt.stride(width, 0) + for i in range(len(fmt.planes)): + self.assertEqual( + fmt.extrapolate_stride(s0, i), + fmt.stride(width, i), + f'extrapolation mismatch for {fmt.name} plane {i}', + ) + + def test_preserves_padding_ratio(self): + # NV12: chroma stride equals luma stride (ratio 1), padding carries over. + nv12 = PixelFormats.NV12 + self.assertEqual(nv12.extrapolate_stride(1920, 1), 1920) + self.assertEqual(nv12.extrapolate_stride(2048, 1), 2048) + + # YUV420: chroma stride is half of luma stride; even padding halves. + yuv420 = PixelFormats.YUV420 + self.assertEqual(yuv420.extrapolate_stride(1920, 1), 960) + self.assertEqual(yuv420.extrapolate_stride(2048, 1), 1024) + self.assertEqual(yuv420.extrapolate_stride(2048, 2), 1024) + + def test_plane_zero_returns_input(self): + self.assertEqual(PixelFormats.NV12.extrapolate_stride(1921, 0), 1921) + + def test_raises_on_non_integer_result(self): + # YUV420 chroma has hsub=2, so an odd plane-0 stride cannot be halved. + with self.assertRaises(ValueError): + PixelFormats.YUV420.extrapolate_stride(1921, 1) + + def test_raises_on_invalid_plane_index(self): + with self.assertRaises(RuntimeError): + PixelFormats.NV12.extrapolate_stride(1920, 2) + + if __name__ == '__main__': unittest.main() diff --git a/utils/conv-perf-compare.py b/utils/conv-perf-compare.py new file mode 100755 index 0000000..a85f8ee --- /dev/null +++ b/utils/conv-perf-compare.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import os +import sys + + +SHARED_KEYS = ('width', 'height', 'padding', 'backends', 'options') + + +def load(path: str) -> dict: + with open(path) as f: + data = json.load(f) + if 'meta' not in data or 'runs' not in data: + sys.exit(f'{path}: not a perf-test result file (missing meta/runs)') + return data + + +def validate_shared_config(files: list[tuple[str, dict]]) -> None: + base_path, base_data = files[0] + base_meta = base_data['meta'] + mismatches: list[str] = [] + for path, data in files[1:]: + meta = data['meta'] + for key in SHARED_KEYS: + if meta.get(key) != base_meta.get(key): + mismatches.append( + f' {key}: {base_path}={base_meta.get(key)!r} vs {path}={meta.get(key)!r}' + ) + if mismatches: + sys.exit('Shared config differs between files:\n' + '\n'.join(mismatches)) + + +def mean_peak(run: dict) -> tuple[float, float]: + return run['iters_per_s'], 1.0 / run['min_iter_s'] + + +def commit_label(meta: dict) -> str: + commit = meta.get('git_commit') or '-' + if meta.get('git_dirty'): + commit += '*' + return commit + + +def format_options(options: dict) -> str: + if not options: + return '{}' + return '{' + ', '.join(f'{k}:{v}' for k, v in options.items()) + '}' + + +def print_table(rows: list[list[str]], aligns: list[str]) -> None: + widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))] + for r_idx, row in enumerate(rows): + parts = [] + for i, cell in enumerate(row): + if aligns[i] == 'l': + parts.append(cell.ljust(widths[i])) + else: + parts.append(cell.rjust(widths[i])) + print(' '.join(parts).rstrip()) + if r_idx == 0: + print(' '.join('-' * w for w in widths)) + + +def format_order_union(files: list[tuple[str, dict]]) -> list[str]: + """Baseline format order, then any formats that appear only in later files.""" + baseline_formats = [r['format'] for r in files[0][1]['runs']] + seen = set(baseline_formats) + extras: list[str] = [] + for _, data in files[1:]: + for r in data['runs']: + if r['format'] not in seen: + extras.append(r['format']) + seen.add(r['format']) + return baseline_formats + extras + + +def print_default(files: list[tuple[str, dict]], sort: str) -> None: + file_rows: list[list[str]] = [['File', 'Commit', 'When']] + for path, data in files: + m = data['meta'] + ts = m.get('timestamp', '').replace('T', ' ').rsplit('+', 1)[0].rsplit('-', 0)[0][:16] + file_rows.append([os.path.basename(path), commit_label(m), ts]) + print_table(file_rows, ['l', 'l', 'l']) + print() + + by_file: list[dict[str, tuple[float, float]]] = [ + {r['format']: mean_peak(r) for r in data['runs']} for _, data in files + ] + format_order = format_order_union(files) + + if sort == 'baseline': + format_order.sort(key=lambda f: -(by_file[0].get(f, (0.0, 0.0))[1])) + elif sort == 'delta' and len(files) >= 2: + + def delta_key(f: str) -> float: + b = by_file[0].get(f) + c = by_file[1].get(f) + if b is None or c is None: + return float('inf') + return (c[1] - b[1]) / b[1] + + format_order.sort(key=delta_key) + + base_name = os.path.basename(files[0][0]) + header = ['Format', f'{base_name} mean', f'{base_name} peak'] + aligns = ['l', 'r', 'r'] + for path, _ in files[1:]: + name = os.path.basename(path) + header.extend([f'{name} mean', f'{name} peak', 'mean', 'peak']) + aligns.extend(['r', 'r', 'r', 'r']) + + rows: list[list[str]] = [header] + for fmt in format_order: + base = by_file[0].get(fmt) + if base is None: + row = [fmt, '—', '—'] + else: + row = [fmt, f'{base[0]:.1f}/s', f'{base[1]:.1f}/s'] + for d in by_file[1:]: + v = d.get(fmt) + if v is None: + row.extend(['—', '—', '—', '—']) + elif base is None: + row.extend([f'{v[0]:.1f}/s', f'{v[1]:.1f}/s', '—', '—']) + else: + pct_mean = (v[0] - base[0]) / base[0] * 100.0 + pct_peak = (v[1] - base[1]) / base[1] * 100.0 + row.extend( + [f'{v[0]:.1f}/s', f'{v[1]:.1f}/s', f'{pct_mean:+.1f}%', f'{pct_peak:+.1f}%'] + ) + rows.append(row) + + print_table(rows, aligns) + + +def print_timeline_for_format(files: list[tuple[str, dict]], fmt: str) -> None: + print(f'Format: {fmt}') + vals: list[tuple[float, float] | None] = [ + next( + (mean_peak(r) for r in data['runs'] if r['format'] == fmt), + None, + ) + for _, data in files + ] + base = vals[0] + prev = base + + rows: list[list[str]] = [ + [ + 'Commit', + 'mean', + 'peak', + 'base mean', + 'base peak', + 'prev mean', + 'prev peak', + 'Subject', + ] + ] + for i, ((_, data), v) in enumerate(zip(files, vals)): + row = [commit_label(data['meta'])] + if v is None: + row.extend(['—', '—']) + else: + row.extend([f'{v[0]:.1f}/s', f'{v[1]:.1f}/s']) + + if i == 0 or v is None or base is None: + row.extend(['—', '—']) + else: + row.extend( + [ + f'{(v[0] - base[0]) / base[0] * 100:+.1f}%', + f'{(v[1] - base[1]) / base[1] * 100:+.1f}%', + ] + ) + + if i == 0 or v is None or prev is None: + row.extend(['—', '—']) + else: + row.extend( + [ + f'{(v[0] - prev[0]) / prev[0] * 100:+.1f}%', + f'{(v[1] - prev[1]) / prev[1] * 100:+.1f}%', + ] + ) + + row.append(data['meta'].get('git_subject') or '') + rows.append(row) + if v is not None: + prev = v + + print_table(rows, ['l', 'r', 'r', 'r', 'r', 'r', 'r', 'l']) + + +def print_timeline(files: list[tuple[str, dict]]) -> None: + format_order = format_order_union(files) + for i, fmt in enumerate(format_order): + if i > 0: + print() + print_timeline_for_format(files, fmt) + + +def main(): + parser = argparse.ArgumentParser(description='Compare conv-perf-test.py JSON results.') + parser.add_argument('files', nargs='+', help='Result files (first is baseline)') + parser.add_argument( + '--sort', + choices=['format', 'delta', 'baseline'], + default='format', + help="Row sort order (default: 'format' = baseline order)", + ) + parser.add_argument( + '--timeline', + action='store_true', + help='Transposed layout: commits as rows, formats as columns, with Δbase and Δprev', + ) + args = parser.parse_args() + + if len(args.files) < 2: + sys.exit('Need at least 2 files to compare') + + files = [(p, load(p)) for p in args.files] + validate_shared_config(files) + + base_meta = files[0][1]['meta'] + print( + f'Config: {base_meta["width"]}x{base_meta["height"]} ' + f'padding={base_meta["padding"]} ' + f'backends={",".join(base_meta["backends"])} ' + f'options={format_options(base_meta["options"])}' + ) + print() + + if args.timeline: + print_timeline(files) + else: + print_default(files, args.sort) + + +if __name__ == '__main__': + main() diff --git a/utils/conv-perf-range.sh b/utils/conv-perf-range.sh new file mode 100755 index 0000000..5a0dd9c --- /dev/null +++ b/utils/conv-perf-range.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: BSD-3-Clause +# +# Run utils/conv-perf-test.py at each commit in a git range, then compare. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: conv-perf-range.sh [-- ...] + +Run utils/conv-perf-test.py at each commit in and print a +comparison table. Results are kept in a mktemp dir for later re-compare. + +Note: follows git's A..B semantics (B included, A excluded). +Use A^..B to include A as the baseline commit. + +Examples: + conv-perf-range.sh HEAD~5..HEAD -- -f NV12,YUYV -t 2 + conv-perf-range.sh origin/main..HEAD -- --width 3840 --height 2160 \ + -f NV12,XRGB8888 --backends numpy +EOF +} + +if [[ $# -eq 0 || "${1:-}" == '-h' || "${1:-}" == '--help' ]]; then + usage + exit 0 +fi + +RANGE="$1" +shift + +# Optional '--' separator; everything after is forwarded to conv-perf-test.py. +if [[ $# -gt 0 && "$1" == '--' ]]; then + shift +fi +PERF_ARGS=("$@") + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PERF_TEST="$SCRIPT_DIR/conv-perf-test.py" +PERF_COMPARE="$SCRIPT_DIR/conv-perf-compare.py" + +for f in "$PERF_TEST" "$PERF_COMPARE"; do + if [[ ! -f "$f" ]]; then + echo "error: missing $f" >&2 + exit 1 + fi +done + +if [[ -n "$(git status --porcelain)" ]]; then + echo 'error: working tree is dirty; commit or stash first' >&2 + exit 1 +fi + +# Record starting ref so we can restore it in the EXIT trap. +if START_REF="$(git symbolic-ref --short -q HEAD)"; then + : +else + START_REF="$(git rev-parse HEAD)" +fi + +restore() { + if ! git checkout --quiet "$START_REF" 2>/dev/null; then + echo "warning: could not restore starting ref '$START_REF'" >&2 + echo " you may need to run: git checkout $START_REF" >&2 + fi +} +trap restore EXIT + +mapfile -t COMMITS < <(git rev-list --reverse "$RANGE") +if [[ ${#COMMITS[@]} -eq 0 ]]; then + echo "error: no commits in range '$RANGE'" >&2 + exit 1 +fi + +OUTDIR="$(mktemp -d -t conv-perf-XXXXXX)" +echo "Results dir: $OUTDIR" +echo "Commits to test (${#COMMITS[@]}): ${COMMITS[*]}" +echo + +i=0 +for sha in "${COMMITS[@]}"; do + i=$((i + 1)) + short="$(git rev-parse --short "$sha")" + json_path="$OUTDIR/$(printf '%03d' "$i")-${short}.json" + echo "=== [$i/${#COMMITS[@]}] $short ===" + git checkout --quiet "$sha" + python3 "$PERF_TEST" "${PERF_ARGS[@]}" -o "$json_path" + echo +done + +# Trap restores HEAD; run the compare after restore so imports resolve against +# the starting commit's source. +restore +trap - EXIT + +echo '=== Comparison ===' +python3 "$PERF_COMPARE" --timeline "$OUTDIR"/*.json + +echo +echo "Results kept in: $OUTDIR" diff --git a/utils/conv-perf-test.py b/utils/conv-perf-test.py index 723546d..3296398 100755 --- a/utils/conv-perf-test.py +++ b/utils/conv-perf-test.py @@ -3,7 +3,13 @@ from __future__ import annotations import argparse +import gc +import json +import platform +import socket +import subprocess import time +from datetime import datetime, timezone import numpy as np @@ -11,13 +17,108 @@ from pixutils.conv import buffer_to_bgr888 +def run_one( + format_name: str, args: argparse.Namespace, options: dict[str, str | list[str]] +) -> dict: + fmt = PixelFormats.find_by_name(format_name) + + strides = tuple(fmt.stride(args.width, i) + args.padding for i in range(len(fmt.planes))) + size = sum(fmt.planesize(strides[i], args.height, i) for i in range(len(fmt.planes))) + + rng = np.random.default_rng(0) + buf = rng.integers(0, 256, size=size, dtype=np.uint8) + + # Warmup + for _ in range(3): + buffer_to_bgr888(fmt, args.width, args.height, strides, buf, options) + + min_iter_s = float('inf') + gc.disable() + try: + iters = 0 + t_start = time.perf_counter() + t_prev = t_start + while True: + buffer_to_bgr888(fmt, args.width, args.height, strides, buf, options) + iters += 1 + t_now = time.perf_counter() + dt = t_now - t_prev + if dt < min_iter_s: + min_iter_s = dt + t_prev = t_now + elapsed = t_now - t_start + if elapsed >= args.time: + break + finally: + gc.enable() + + mean_iters_per_s = iters / elapsed + peak_iters_per_s = 1.0 / min_iter_s + + backends_str = args.backends if args.backends else 'default' + print( + f'{format_name} {args.width}x{args.height}, backends: {backends_str}, ' + f'padding: {args.padding}, strides: {strides}, ' + f'{iters} iters in {elapsed:.3f}s = {mean_iters_per_s:.1f}/s mean, ' + f'{peak_iters_per_s:.1f}/s peak' + ) + + return { + 'format': format_name, + 'strides': list(strides), + 'bufsize': size, + 'iters': iters, + 'elapsed': elapsed, + 'iters_per_s': mean_iters_per_s, + 'min_iter_s': min_iter_s, + } + + +def git_info() -> tuple[str | None, bool, str | None]: + try: + commit = subprocess.run( + ['git', 'rev-parse', '--short', 'HEAD'], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + dirty = bool( + subprocess.run( + ['git', 'status', '--porcelain'], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + ) + subject = subprocess.run( + ['git', 'show', '-s', '--format=%s', 'HEAD'], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + return commit, dirty, subject + except (subprocess.CalledProcessError, FileNotFoundError): + return None, False, None + + def main(): parser = argparse.ArgumentParser(description='Test conversion performance.') parser.add_argument('--width', type=int, default=1920, help='Image width') parser.add_argument('--height', type=int, default=1080, help='Image height') - parser.add_argument('-f', '--format', type=str, default='XRGB8888', help='Pixel format') - parser.add_argument('-l', '--loops', type=int, default=100, help='Number of loops') - parser.add_argument('--stride', type=int, default=0, help='Stride') + parser.add_argument( + '-f', + '--format', + type=str, + default='XRGB8888', + help='Pixel format (comma-separated list for multiple formats)', + ) + parser.add_argument('-t', '--time', type=float, default=1.0, help='Measurement time in seconds') + parser.add_argument( + '--padding', + type=int, + default=0, + help="Extra bytes added to each plane's natural stride", + ) parser.add_argument( '--demosaic', type=str, @@ -31,26 +132,15 @@ def main(): default=None, help='Comma-separated list of backends in priority order', ) + parser.add_argument( + '-o', + '--output', + type=str, + default=None, + help='Write results as JSON to this file (for conv-perf-compare.py)', + ) args = parser.parse_args() - fmt = PixelFormats.find_by_name(args.format) - - # Drop this when stride works - if len(fmt.planes) > 1 and args.stride > 0: - raise ValueError('Custom stride is not supported with multiplanar formats') - - # Calculate total buffer size for all planes - if args.stride > 0: - # Single plane format with custom stride - size = fmt.planesize(args.stride, args.height, 0) - else: - # Use framesize for both single and multiplanar formats - size = fmt.framesize(args.width, args.height) - - buf = np.zeros(size, dtype=np.uint8) - - stride = args.stride if args.stride > 0 else fmt.stride(args.width, 0) - options: dict[str, str | list[str]] = { 'range': 'limited', 'encoding': 'bt601', @@ -60,22 +150,31 @@ def main(): if args.backends: options['backends'] = [b.strip() for b in args.backends.split(',')] - bytesperline = 0 if len(fmt.planes) > 1 else stride - - # Warmup run - buffer_to_bgr888(fmt, args.width, args.height, bytesperline, buf, options) - - t1 = time.monotonic() - - for _ in range(args.loops): - buffer_to_bgr888(fmt, args.width, args.height, bytesperline, buf, options) - - t2 = time.monotonic() - backends_str = args.backends if args.backends else 'default' - print( - f'Image size: {args.width}x{args.height}, format: {args.format}, backends: {backends_str}, ' - f'stride: {stride}, size {size}, {args.loops} loops took {(t2 - t1) * 1000:.3f} ms' - ) + format_names = [s.strip() for s in args.format.split(',') if s.strip()] + + runs = [] + for format_name in format_names: + runs.append(run_one(format_name, args, options)) + + if args.output: + commit, dirty, subject = git_info() + meta = { + 'timestamp': datetime.now(timezone.utc).isoformat(timespec='seconds'), + 'git_commit': commit, + 'git_dirty': dirty, + 'git_subject': subject, + 'hostname': socket.gethostname(), + 'python': platform.python_version(), + 'numpy': np.__version__, + 'width': args.width, + 'height': args.height, + 'padding': args.padding, + 'backends': options.get('backends', ['default']), + 'options': {k: v for k, v in options.items() if k != 'backends'}, + 'measurement_time': args.time, + } + with open(args.output, 'w') as f: + json.dump({'meta': meta, 'runs': runs}, f, indent=2) if __name__ == '__main__':