From 4e8ab3fff978b72a030d211ff1fce8907125e0de Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 13:47:39 +0300 Subject: [PATCH 01/44] Update Python requirement to 3.10 Signed-off-by: Tomi Valkeinen --- .github/workflows/ci.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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' }, ] From b2ae999e20214412ab6b9aec7ffdf6871f224eb9 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Tue, 21 Apr 2026 14:10:54 +0300 Subject: [PATCH 02/44] formats: Add 'libcamera_name' for pixel formats libcamera uses its own names which do not always match pixutils' names. And trying to match based on fourccs is challenging. So let's add a new field, 'libcamera_name', so that we can explicitly map between libcamera and pixutils formats. Signed-off-by: Tomi Valkeinen --- pixutils/formats/pixelformats.py | 136 ++++++++++++++++++++----------- 1 file changed, 89 insertions(+), 47 deletions(-) diff --git a/pixutils/formats/pixelformats.py b/pixutils/formats/pixelformats.py index f664238..261aa12 100644 --- a/pixutils/formats/pixelformats.py +++ b/pixutils/formats/pixelformats.py @@ -37,6 +37,7 @@ def __init__( name: str, drm_fourcc: None | str, v4l2_fourcc: None | str, + libcamera_name: None | str, colorencoding: PixelColorEncoding, packed: bool, pixel_align: tuple[int, int], @@ -45,6 +46,7 @@ def __init__( 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 # pixel alignment (width, height) @@ -172,6 +174,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 +187,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 +196,7 @@ def get_formats(): # RGB 8-bit RGB332 = PixelFormat('RGB332', - 'RGB8', None, + 'RGB8', None, None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -199,14 +206,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 +222,7 @@ def get_formats(): XRGB1555 = PixelFormat('XRGB1555', 'XR15', # DRM_FORMAT_XRGB1555 None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -223,6 +231,7 @@ def get_formats(): RGBX4444 = PixelFormat('RGBX4444', 'RX12', # DRM_FORMAT_RGBX4444 None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -231,6 +240,7 @@ def get_formats(): XRGB4444 = PixelFormat('XRGB4444', 'XR12', # DRM_FORMAT_XRGB4444 None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -242,6 +252,7 @@ def get_formats(): ARGB1555 = PixelFormat('ARGB1555', 'AR15', # DRM_FORMAT_ARGB1555 None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -250,6 +261,7 @@ def get_formats(): RGBA4444 = PixelFormat('RGBA4444', 'RA12', # DRM_FORMAT_RGBA4444 None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -258,6 +270,7 @@ def get_formats(): ARGB4444 = PixelFormat('ARGB4444', 'AR12', # DRM_FORMAT_ARGB4444 None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -269,6 +282,7 @@ def get_formats(): RGB888 = PixelFormat('RGB888', 'RG24', # DRM_FORMAT_RGB888 'BGR3', # V4L2_PIX_FMT_BGR24 + 'RGB888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -277,6 +291,7 @@ def get_formats(): BGR888 = PixelFormat('BGR888', 'BG24', # DRM_FORMAT_BGR888 'RGB3', # V4L2_PIX_FMT_RGB24 + 'BGR888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -288,6 +303,7 @@ def get_formats(): XRGB8888 = PixelFormat('XRGB8888', 'XR24', # DRM_FORMAT_XRGB8888 'XR24', # V4L2_PIX_FMT_XBGR32 + 'XRGB8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -296,6 +312,7 @@ def get_formats(): XBGR8888 = PixelFormat('XBGR8888', 'XB24', # DRM_FORMAT_XBGR8888 'XB24', # V4L2_PIX_FMT_RGBX32 + 'XBGR8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -304,6 +321,7 @@ def get_formats(): RGBX8888 = PixelFormat('RGBX8888', 'RX24', # DRM_FORMAT_RGBX8888 'RX24', # V4L2_PIX_FMT_BGRX32 + 'RGBX8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -312,6 +330,7 @@ def get_formats(): BGRX8888 = PixelFormat('BGRX8888', 'BX24', # DRM_FORMAT_RGBX8888 None, + 'BGRX8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -321,6 +340,7 @@ def get_formats(): XBGR2101010 = PixelFormat('XBGR2101010', 'XB30', # DRM_FORMAT_XBGR2101010 'RX30', # V4L2_PIX_FMT_RGBX1010102 + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -329,6 +349,7 @@ def get_formats(): XRGB2101010 = PixelFormat('XRGB2101010', 'XR30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -337,6 +358,7 @@ def get_formats(): RGBX1010102 = PixelFormat('RGBX1010102', 'RX30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -345,6 +367,7 @@ def get_formats(): BGRX1010102 = PixelFormat('BGRX1010102', 'BX30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -356,6 +379,7 @@ def get_formats(): ARGB8888 = PixelFormat('ARGB8888', 'AR24', # DRM_FORMAT_ARGB8888 'AR24', # V4L2_PIX_FMT_ABGR32 + 'ARGB8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -364,6 +388,7 @@ def get_formats(): ABGR8888 = PixelFormat('ABGR8888', 'AB24', # DRM_FORMAT_ABGR8888 'AB24', # V4L2_PIX_FMT_RGBA32 + 'ABGR8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -372,6 +397,7 @@ def get_formats(): RGBA8888 = PixelFormat('RGBA8888', 'RA24', # DRM_FORMAT_RGBA8888 'RA24', # V4L2_PIX_FMT_BGRA32 + 'RGBA8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -380,6 +406,7 @@ def get_formats(): BGRA8888 = PixelFormat('BGRA8888', 'BA24', None, + 'BGRA8888', PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -390,6 +417,7 @@ def get_formats(): ABGR2101010 = PixelFormat('ABGR2101010', 'AB30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -398,6 +426,7 @@ def get_formats(): ARGB2101010 = PixelFormat('ARGB2101010', 'AR30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -406,6 +435,7 @@ def get_formats(): RGBA1010102 = PixelFormat('RGBA1010102', 'RA30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -414,6 +444,7 @@ def get_formats(): BGRA1010102 = PixelFormat('BGRA1010102', 'BA30', None, + None, PixelColorEncoding.RGB, False, ( 1, 1 ), @@ -423,7 +454,7 @@ def get_formats(): # YUV Packed YUYV = PixelFormat('YUYV', - 'YUYV', 'YUYV', + 'YUYV', 'YUYV', 'YUYV', PixelColorEncoding.YUV, False, ( 2, 1 ), @@ -431,7 +462,7 @@ def get_formats(): ) UYVY = PixelFormat('UYVY', - 'UYVY', 'UYVY', + 'UYVY', 'UYVY', 'UYVY', PixelColorEncoding.YUV, False, ( 2, 1 ), @@ -439,7 +470,7 @@ def get_formats(): ) YVYU = PixelFormat('YVYU', - 'YVYU', 'YVYU', + 'YVYU', 'YVYU', 'YVYU', PixelColorEncoding.YUV, False, ( 2, 1 ), @@ -447,7 +478,7 @@ def get_formats(): ) VYUY = PixelFormat('VYUY', - 'VYUY', 'VYUY', + 'VYUY', 'VYUY', 'VYUY', PixelColorEncoding.YUV, False, ( 2, 1 ), @@ -457,6 +488,7 @@ def get_formats(): VUY888 = PixelFormat('VUY888', 'VU24', # DRM_FORMAT_VUY888 'YUV3', # V4L2_PIX_FMT_YUV24 + None, PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -466,6 +498,7 @@ def get_formats(): XVUY8888 = PixelFormat('XVUY8888', 'XVUY', # DRM_FORMAT_XVUY8888 'YUVX', # V4L2_PIX_FMT_YUVX32 + 'XVUY8888', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -475,6 +508,7 @@ def get_formats(): Y210 = PixelFormat('Y210', 'Y210', # DRM_FORMAT_Y210 None, + None, PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -484,6 +518,7 @@ def get_formats(): Y212 = PixelFormat('Y212', 'Y212', # DRM_FORMAT_Y212 None, + None, PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -493,6 +528,7 @@ def get_formats(): Y216 = PixelFormat('Y216', 'Y216', # DRM_FORMAT_Y216 None, + None, PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -502,7 +538,7 @@ def get_formats(): # YUV Semi Planar NV12 = PixelFormat('NV12', - 'NV12', 'NM12', + 'NV12', 'NM12', 'NV12', PixelColorEncoding.YUV, False, ( 2, 2 ), @@ -511,7 +547,7 @@ def get_formats(): ) NV21 = PixelFormat('NV21', - 'NV21', 'NM21', + 'NV21', 'NM21', 'NV21', PixelColorEncoding.YUV, False, ( 2, 2 ), @@ -520,7 +556,7 @@ def get_formats(): ) NV16 = PixelFormat('NV16', - 'NV16', 'NM16', + 'NV16', 'NM16', 'NV16', PixelColorEncoding.YUV, False, ( 2, 1 ), @@ -529,7 +565,7 @@ def get_formats(): ) NV61 = PixelFormat('NV61', - 'NV61', 'NM61', + 'NV61', 'NM61', 'NV61', PixelColorEncoding.YUV, False, ( 2, 1 ), @@ -538,7 +574,7 @@ def get_formats(): ) XV15 = PixelFormat('XV15', - 'XV15', None, + 'XV15', None, None, PixelColorEncoding.YUV, False, (6, 2), @@ -547,7 +583,7 @@ def get_formats(): ) XV20 = PixelFormat('XV20', - 'XV20', None, + 'XV20', None, None, PixelColorEncoding.YUV, False, (6, 2), @@ -556,7 +592,7 @@ def get_formats(): ) XVUY2101010 = PixelFormat('XVUY2101010', - 'XY30', None, + 'XY30', None, None, PixelColorEncoding.YUV, False, (1, 1), @@ -568,6 +604,7 @@ def get_formats(): YUV420 = PixelFormat('YUV420', 'YU12', None, + 'YUV420', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -579,6 +616,7 @@ def get_formats(): YVU420 = PixelFormat('YVU420', 'YV12', None, + 'YVU420', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -590,6 +628,7 @@ def get_formats(): YUV422 = PixelFormat('YUV422', 'YU16', None, + 'YUV422', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -601,6 +640,7 @@ def get_formats(): YVU422 = PixelFormat('YVU422', 'YV16', None, + 'YVU422', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -612,6 +652,7 @@ def get_formats(): YUV444 = PixelFormat('YUV444', 'YU24', None, + 'YUV444', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -623,6 +664,7 @@ def get_formats(): YVU444 = PixelFormat('YVU444', 'YV24', None, + 'YVU444', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -632,7 +674,7 @@ def get_formats(): ) X403 = PixelFormat('X403', - 'X403', None, + 'X403', None, None, PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -644,7 +686,7 @@ def get_formats(): # Grey formats Y8 = PixelFormat('Y8', - 'GREY', 'GREY', + 'GREY', 'GREY', 'R8', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -652,7 +694,7 @@ def get_formats(): ) Y10 = PixelFormat('Y10', - None, 'Y10 ', + None, 'Y10 ', 'R10', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -660,7 +702,7 @@ def get_formats(): ) Y10P = PixelFormat('Y10P', - None, 'Y10P', + None, 'Y10P', 'R10_CSI2P', PixelColorEncoding.YUV, True, ( 4, 1 ), @@ -668,7 +710,7 @@ def get_formats(): ) Y12 = PixelFormat('Y12', - None, 'Y12 ', + None, 'Y12 ', 'R12', PixelColorEncoding.YUV, False, ( 1, 1 ), @@ -676,7 +718,7 @@ def get_formats(): ) Y12P = PixelFormat('Y12P', - None, 'Y12P', + None, 'Y12P', 'R12_CSI2P', PixelColorEncoding.YUV, True, ( 2, 1 ), @@ -684,7 +726,7 @@ def get_formats(): ) Y10_LE32 = PixelFormat('Y10_P32', - 'YPA4', None, + 'YPA4', None, None, PixelColorEncoding.YUV, False, ( 3, 1 ), @@ -694,7 +736,7 @@ def get_formats(): # RAW Bayer formats SBGGR8 = PixelFormat('SBGGR8', - None, 'BA81', + None, 'BA81', 'SBGGR8', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -702,7 +744,7 @@ def get_formats(): ) SGBRG8 = PixelFormat('SGBRG8', - None, 'GBRG', + None, 'GBRG', 'SGBRG8', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -710,7 +752,7 @@ def get_formats(): ) SGRBG8 = PixelFormat('SGRBG8', - None, 'GRBG', + None, 'GRBG', 'SGRBG8', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -718,7 +760,7 @@ def get_formats(): ) SRGGB8 = PixelFormat('SRGGB8', - None, 'RGGB', + None, 'RGGB', 'SRGGB8', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -726,7 +768,7 @@ def get_formats(): ) SBGGR10 = PixelFormat('SBGGR10', - None, 'BG10', + None, 'BG10', 'SBGGR10', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -734,7 +776,7 @@ def get_formats(): ) SGBRG10 = PixelFormat('SGBRG10', - None, 'GB10', + None, 'GB10', 'SGBRG10', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -742,7 +784,7 @@ def get_formats(): ) SGRBG10 = PixelFormat('SGRBG10', - None, 'BA10', + None, 'BA10', 'SGRBG10', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -750,7 +792,7 @@ def get_formats(): ) SRGGB10 = PixelFormat('SRGGB10', - None, 'RG10', + None, 'RG10', 'SRGGB10', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -758,7 +800,7 @@ def get_formats(): ) SBGGR10P = PixelFormat('SBGGR10P', - None, 'pBAA', + None, 'pBAA', 'SBGGR10_CSI2P', PixelColorEncoding.RAW, True, ( 4, 2 ), @@ -766,7 +808,7 @@ def get_formats(): ) SGBRG10P = PixelFormat('SGBRG10P', - None, 'pGAA', + None, 'pGAA', 'SGBRG10_CSI2P', PixelColorEncoding.RAW, True, ( 4, 2 ), @@ -774,7 +816,7 @@ def get_formats(): ) SGRBG10P = PixelFormat('SGRBG10P', - None, 'pgAA', + None, 'pgAA', 'SGRBG10_CSI2P', PixelColorEncoding.RAW, True, ( 4, 2 ), @@ -782,7 +824,7 @@ def get_formats(): ) SRGGB10P = PixelFormat('SRGGB10P', - None, 'pRAA', + None, 'pRAA', 'SRGGB10_CSI2P', PixelColorEncoding.RAW, True, ( 4, 2 ), @@ -790,7 +832,7 @@ def get_formats(): ) SBGGR12 = PixelFormat('SBGGR12', - None, 'BG12', + None, 'BG12', 'SBGGR12', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -798,7 +840,7 @@ def get_formats(): ) SGBRG12 = PixelFormat('SGBRG12', - None, 'GB12', + None, 'GB12', 'SGBRG12', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -806,7 +848,7 @@ def get_formats(): ) SGRBG12 = PixelFormat('SGRBG12', - None, 'BA12', + None, 'BA12', 'SGRBG12', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -814,7 +856,7 @@ def get_formats(): ) SRGGB12 = PixelFormat('SRGGB12', - None, 'RG12', + None, 'RG12', 'SRGGB12', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -822,7 +864,7 @@ def get_formats(): ) SBGGR12P = PixelFormat('SBGGR12P', - None, 'pBCC', + None, 'pBCC', 'SBGGR12_CSI2P', PixelColorEncoding.RAW, True, ( 2, 2 ), @@ -830,7 +872,7 @@ def get_formats(): ) SGBRG12P = PixelFormat('SGBRG12P', - None, 'pGCC', + None, 'pGCC', 'SGBRG12_CSI2P', PixelColorEncoding.RAW, True, ( 2, 2 ), @@ -838,7 +880,7 @@ def get_formats(): ) SGRBG12P = PixelFormat('SGRBG12P', - None, 'pgCC', + None, 'pgCC', 'SGRBG12_CSI2P', PixelColorEncoding.RAW, True, ( 2, 2 ), @@ -846,7 +888,7 @@ def get_formats(): ) SRGGB12P = PixelFormat('SRGGB12P', - None, 'pRCC', + None, 'pRCC', 'SRGGB12_CSI2P', PixelColorEncoding.RAW, True, ( 2, 2 ), @@ -854,7 +896,7 @@ def get_formats(): ) SBGGR16 = PixelFormat('SBGGR16', - None, 'BYR2', + None, 'BYR2', 'SBGGR16', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -862,7 +904,7 @@ def get_formats(): ) SGBRG16 = PixelFormat('SGBRG16', - None, 'GB16', + None, 'GB16', 'SGBRG16', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -870,7 +912,7 @@ def get_formats(): ) SGRBG16 = PixelFormat('SGRBG16', - None, 'GR16', + None, 'GR16', 'SGRBG16', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -878,7 +920,7 @@ def get_formats(): ) SRGGB16 = PixelFormat('SRGGB16', - None, 'RG16', + None, 'RG16', 'SRGGB16', PixelColorEncoding.RAW, False, ( 2, 2 ), @@ -887,7 +929,7 @@ def get_formats(): # Compressed formats MJPEG = PixelFormat('MJPEG', - 'MJPG', 'MJPG', + 'MJPG', 'MJPG', 'MJPEG', PixelColorEncoding.UNDEFINED, False, ( 1, 1 ), From c69888e9bb81e3bd960b7a3c2ff25b938fd4202f Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Tue, 21 Apr 2026 14:32:01 +0300 Subject: [PATCH 03/44] formats: Rename 'packed' to 'csi2_packed' The 'packed' field was a bit too ambiguous. Signed-off-by: Tomi Valkeinen --- pixutils/conv/opencv.py | 2 +- pixutils/formats/pixelformats.py | 4 ++-- tests/test_conv.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pixutils/conv/opencv.py b/pixutils/conv/opencv.py index 8eae231..e45d138 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 diff --git a/pixutils/formats/pixelformats.py b/pixutils/formats/pixelformats.py index 261aa12..aea5b18 100644 --- a/pixutils/formats/pixelformats.py +++ b/pixutils/formats/pixelformats.py @@ -39,7 +39,7 @@ def __init__( v4l2_fourcc: None | str, libcamera_name: None | str, colorencoding: PixelColorEncoding, - packed: bool, + csi2_packed: bool, pixel_align: tuple[int, int], planes, ) -> None: @@ -48,7 +48,7 @@ def __init__( 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 diff --git a/tests/test_conv.py b/tests/test_conv.py index a38a08a..cf95ab2 100755 --- a/tests/test_conv.py +++ b/tests/test_conv.py @@ -22,10 +22,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.name.endswith('10') and fmt.color == PixelColorEncoding.RAW and not fmt.csi2_packed: 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.name.endswith('12') and fmt.color == PixelColorEncoding.RAW and not fmt.csi2_packed: return (np.uint16, (1 << 12) - 1) # XBGR8888, XRGB8888 formats - mask out alpha channel elif fmt.name in ('XBGR8888', 'XRGB8888'): From 520c4aaca847a58f1a3bfd1b45cd922b3a9b7fea Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Tue, 21 Apr 2026 14:34:42 +0300 Subject: [PATCH 04/44] formats: Add PixelFormat.raw_bpp() Add a helper to get the bitspp for RAW formats. Signed-off-by: Tomi Valkeinen --- pixutils/formats/pixelformats.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pixutils/formats/pixelformats.py b/pixutils/formats/pixelformats.py index aea5b18..fb2ac02 100644 --- a/pixutils/formats/pixelformats.py +++ b/pixutils/formats/pixelformats.py @@ -80,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() From a1b92d301d7e4ffb00b7c0c8d34453f238d98557 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Tue, 21 Apr 2026 14:42:56 +0300 Subject: [PATCH 05/44] Use csi2_packed and raw_bitspp properties Signed-off-by: Tomi Valkeinen --- pixutils/conv/raw.py | 11 ++--------- tests/test_conv.py | 4 ++-- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/pixutils/conv/raw.py b/pixutils/conv/raw.py index ce78743..f8e41a8 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, ) diff --git a/tests/test_conv.py b/tests/test_conv.py index cf95ab2..30b2d7d 100755 --- a/tests/test_conv.py +++ b/tests/test_conv.py @@ -22,10 +22,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.csi2_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.csi2_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'): From 861eb980e64c40f3e29d0903e291c7e091c8f12a Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 09:44:25 +0300 Subject: [PATCH 06/44] utils/conv-perf-test: Support multiple formats Signed-off-by: Tomi Valkeinen --- utils/conv-perf-test.py | 86 ++++++++++++++++++++++++----------------- 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/utils/conv-perf-test.py b/utils/conv-perf-test.py index 723546d..742a729 100755 --- a/utils/conv-perf-test.py +++ b/utils/conv-perf-test.py @@ -11,12 +11,58 @@ from pixutils.conv import buffer_to_bgr888 +def run_one(format_name: str, args: argparse.Namespace, options: dict[str, str | list[str]]): + fmt = PixelFormats.find_by_name(format_name) + + # 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) + bytesperline = 0 if len(fmt.planes) > 1 else stride + + # Warmup run + buffer_to_bgr888(fmt, args.width, args.height, bytesperline, buf, options) + + iters = 0 + t_start = time.monotonic() + while True: + buffer_to_bgr888(fmt, args.width, args.height, bytesperline, buf, options) + iters += 1 + elapsed = time.monotonic() - t_start + if elapsed >= args.time: + break + + backends_str = args.backends if args.backends else 'default' + print( + f'Format: {format_name}, size: {args.width}x{args.height}, backends: {backends_str}, ' + f'stride: {stride}, bufsize: {size}, ' + f'{iters} iters in {elapsed:.3f}s = {iters / elapsed:.1f} iters/s' + ) + + 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( + '-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('--stride', type=int, default=0, help='Stride') parser.add_argument( '--demosaic', @@ -33,24 +79,6 @@ def main(): ) 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 +88,10 @@ 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) + format_names = [s.strip() for s in args.format.split(',') if s.strip()] - 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' - ) + for format_name in format_names: + run_one(format_name, args, options) if __name__ == '__main__': From 4b55b3f17bf8a53be7ed632fc53860cf2ad6e98f Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Thu, 23 Apr 2026 08:51:40 +0300 Subject: [PATCH 07/44] utils/conv-perf-test: Improve perf testing Disable GC for the run, do a bit more warmup, use perf_counter. Signed-off-by: Tomi Valkeinen --- utils/conv-perf-test.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/utils/conv-perf-test.py b/utils/conv-perf-test.py index 742a729..276bfb8 100755 --- a/utils/conv-perf-test.py +++ b/utils/conv-perf-test.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import gc import time import numpy as np @@ -26,22 +27,28 @@ def run_one(format_name: str, args: argparse.Namespace, options: dict[str, str | # Use framesize for both single and multiplanar formats size = fmt.framesize(args.width, args.height) - buf = np.zeros(size, dtype=np.uint8) + rng = np.random.default_rng(0) + buf = rng.integers(0, 256, size=size, dtype=np.uint8) stride = args.stride if args.stride > 0 else fmt.stride(args.width, 0) bytesperline = 0 if len(fmt.planes) > 1 else stride - # Warmup run - buffer_to_bgr888(fmt, args.width, args.height, bytesperline, buf, options) - - iters = 0 - t_start = time.monotonic() - while True: + # Warmup + for _ in range(3): buffer_to_bgr888(fmt, args.width, args.height, bytesperline, buf, options) - iters += 1 - elapsed = time.monotonic() - t_start - if elapsed >= args.time: - break + + gc.disable() + try: + iters = 0 + t_start = time.perf_counter() + while True: + buffer_to_bgr888(fmt, args.width, args.height, bytesperline, buf, options) + iters += 1 + elapsed = time.perf_counter() - t_start + if elapsed >= args.time: + break + finally: + gc.enable() backends_str = args.backends if args.backends else 'default' print( From 9f6c8700d53e98030167b10f697add6211e3eeaf Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Thu, 23 Apr 2026 08:53:08 +0300 Subject: [PATCH 08/44] utils/conv-perf-test: Add --output Signed-off-by: Tomi Valkeinen --- utils/conv-perf-test.py | 75 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/utils/conv-perf-test.py b/utils/conv-perf-test.py index 276bfb8..b9493ec 100755 --- a/utils/conv-perf-test.py +++ b/utils/conv-perf-test.py @@ -4,7 +4,12 @@ import argparse import gc +import json +import platform +import socket +import subprocess import time +from datetime import datetime, timezone import numpy as np @@ -12,7 +17,9 @@ from pixutils.conv import buffer_to_bgr888 -def run_one(format_name: str, args: argparse.Namespace, options: dict[str, str | list[str]]): +def run_one( + format_name: str, args: argparse.Namespace, options: dict[str, str | list[str]] +) -> dict: fmt = PixelFormats.find_by_name(format_name) # Drop this when stride works @@ -57,6 +64,42 @@ def run_one(format_name: str, args: argparse.Namespace, options: dict[str, str | f'{iters} iters in {elapsed:.3f}s = {iters / elapsed:.1f} iters/s' ) + return { + 'format': format_name, + 'strides': list([stride]), + 'bufsize': size, + 'iters': iters, + 'elapsed': elapsed, + 'iters_per_s': iters / elapsed, + } + + +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.') @@ -84,6 +127,13 @@ 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() options: dict[str, str | list[str]] = { @@ -97,8 +147,29 @@ def main(): format_names = [s.strip() for s in args.format.split(',') if s.strip()] + runs = [] for format_name in format_names: - run_one(format_name, args, options) + 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': 0, + '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__': From f12ba8ba9ee67a8acdc3de364c3c7f349e0131c1 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Thu, 23 Apr 2026 08:59:15 +0300 Subject: [PATCH 09/44] utils/conv-perf-test: Add peak iters Signed-off-by: Tomi Valkeinen --- utils/conv-perf-test.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/utils/conv-perf-test.py b/utils/conv-perf-test.py index b9493ec..c43dc0b 100755 --- a/utils/conv-perf-test.py +++ b/utils/conv-perf-test.py @@ -44,24 +44,35 @@ def run_one( for _ in range(3): buffer_to_bgr888(fmt, args.width, args.height, bytesperline, 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, bytesperline, buf, options) iters += 1 - elapsed = time.perf_counter() - t_start + 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: {format_name}, size: {args.width}x{args.height}, backends: {backends_str}, ' - f'stride: {stride}, bufsize: {size}, ' - f'{iters} iters in {elapsed:.3f}s = {iters / elapsed:.1f} iters/s' + f'{format_name} {args.width}x{args.height}, backends: {backends_str}, ' + f'stride: {stride} ' + f'{iters} iters in {elapsed:.3f}s = {mean_iters_per_s:.1f}/s mean, ' + f'{peak_iters_per_s:.1f}/s peak' ) return { @@ -70,7 +81,8 @@ def run_one( 'bufsize': size, 'iters': iters, 'elapsed': elapsed, - 'iters_per_s': iters / elapsed, + 'iters_per_s': mean_iters_per_s, + 'min_iter_s': min_iter_s, } From 9ab15772b904fa5a7e1fa5b48f3993ea514040d4 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 12:05:29 +0300 Subject: [PATCH 10/44] utils: Add conv-perf-compare.py --- utils/conv-perf-compare.py | 245 +++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100755 utils/conv-perf-compare.py 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() From 3502b59ebb90a200e87877025ad532a0f572a1c4 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 12:37:22 +0300 Subject: [PATCH 11/44] utils: Add conv-perf-range.sh Signed-off-by: Tomi Valkeinen --- utils/conv-perf-range.sh | 101 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100755 utils/conv-perf-range.sh 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" From 043d0edfa23e496c8b0721ceb74383cbea5e7ebf Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Tue, 21 Apr 2026 16:10:30 +0300 Subject: [PATCH 12/44] conv: Accept strides-list and pass it around Start fixing the multi-plane stride issues by fixing the public API. Signed-off-by: Tomi Valkeinen --- pixutils/conv/conv.py | 52 +++++++++++++++++++++++------------- pixutils/conv/numba.py | 6 ++--- pixutils/conv/opencv.py | 4 +-- pixutils/conv/opencv_impl.py | 3 ++- pixutils/conv/raw.py | 3 ++- pixutils/conv/raw_nb.py | 3 ++- pixutils/conv/rgb.py | 2 +- pixutils/conv/yuv.py | 7 ++++- pixutils/conv/yuv_nb.py | 7 ++++- 9 files changed, 58 insertions(+), 29 deletions(-) diff --git a/pixutils/conv/conv.py b/pixutils/conv/conv.py index 6521786..78cd9bd 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,7 +20,7 @@ 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]: @@ -29,7 +31,8 @@ def to_bgr888( 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 a single int (0 for no padding) or a + sequence of ints with one value per plane for multiplane formats arr: Numpy array containing the pixel data options: Optional dictionary with conversion options: - backends: List of backends in priority order, e.g. ['opencv', 'numba'] @@ -41,26 +44,38 @@ def to_bgr888( Numpy array containing the image in BGR888 format """ + # Normalize bytesperline to a per-plane tuple of concrete (non-zero) strides + if isinstance(bytesperline, int): + if len(fmt.planes) > 1 and bytesperline != 0: + raise ValueError('Multiplane formats require a sequence of strides or 0 for no padding') + strides = tuple( + bytesperline if bytesperline != 0 else fmt.stride(width, 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): + if arr.size < fmt.planesize(strides[i], height, i): raise ValueError('Input array is too small') - size += stride * height + size += fmt.planesize(strides[i], height, i) # Get a view for the actual data arr = arr[:size] @@ -70,7 +85,7 @@ def to_bgr888( 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 # opencv couldn't handle this format/options, try next backend @@ -78,18 +93,18 @@ def to_bgr888( 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 elif backend == 'numpy': if fmt.color == PixelColorEncoding.YUV: - return yuv_to_bgr888(arr, width, height, fmt, options) + return yuv_to_bgr888(arr, width, height, strides, fmt, options) if fmt.color == PixelColorEncoding.RAW: - return raw_to_bgr888(arr, width, height, bytesperline, fmt, options) + return raw_to_bgr888(arr, width, height, strides, fmt, options) if fmt.color == PixelColorEncoding.RGB: - return rgb_to_bgr888(fmt, width, height, arr) + return rgb_to_bgr888(fmt, width, height, strides, arr) raise ValueError(f'Unsupported format {fmt}') @@ -100,7 +115,7 @@ 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]: @@ -114,7 +129,8 @@ def buffer_to_bgr888( 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 a single int (0 for no padding) or a + list of ints with one value per plane for multiplane formats buffer: Buffer-like object containing the pixel data options: Optional dictionary with conversion options diff --git a/pixutils/conv/numba.py b/pixutils/conv/numba.py index ddb34ad..9e7fee1 100644 --- a/pixutils/conv/numba.py +++ b/pixutils/conv/numba.py @@ -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 e45d138..7045b9f 100644 --- a/pixutils/conv/opencv.py +++ b/pixutils/conv/opencv.py @@ -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..04d682e 100644 --- a/pixutils/conv/opencv_impl.py +++ b/pixutils/conv/opencv_impl.py @@ -130,8 +130,9 @@ 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: + bytesperline = strides[0] stride = bytesperline if bytesperline > 0 else fmt.stride(width, 0) if fmt.color == PixelColorEncoding.YUV: diff --git a/pixutils/conv/raw.py b/pixutils/conv/raw.py index f8e41a8..5c9b151 100644 --- a/pixutils/conv/raw.py +++ b/pixutils/conv/raw.py @@ -274,10 +274,11 @@ 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]: + bytesperline = strides[0] # Parse the format raw_fmt = RawFormat.from_pixelformat(fmt) diff --git a/pixutils/conv/raw_nb.py b/pixutils/conv/raw_nb.py index 5c97a33..a6e8b38 100644 --- a/pixutils/conv/raw_nb.py +++ b/pixutils/conv/raw_nb.py @@ -361,11 +361,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..67a96f1 100644 --- a/pixutils/conv/rgb.py +++ b/pixutils/conv/rgb.py @@ -10,7 +10,7 @@ def rgb_to_bgr888( - fmt: PixelFormat, w: int, h: int, data: npt.NDArray[np.uint8] + fmt: PixelFormat, w: int, h: int, strides: tuple[int, ...], data: npt.NDArray[np.uint8] ) -> npt.NDArray[np.uint8]: if fmt == PixelFormats.RGB888: rgb = data.reshape((h, w, 3)) diff --git a/pixutils/conv/yuv.py b/pixutils/conv/yuv.py index 93a8e4b..9f45e58 100644 --- a/pixutils/conv/yuv.py +++ b/pixutils/conv/yuv.py @@ -163,7 +163,12 @@ def y8_to_bgr888( def yuv_to_bgr888( - arr: npt.NDArray[np.uint8], w: int, h: int, fmt: PixelFormat, options: dict | None + arr: npt.NDArray[np.uint8], + w: int, + h: int, + strides: tuple[int, ...], + fmt: PixelFormat, + options: dict | None, ) -> npt.NDArray[np.uint8]: if fmt == PixelFormats.Y8: return y8_to_bgr888(arr, w, h, options) diff --git a/pixutils/conv/yuv_nb.py b/pixutils/conv/yuv_nb.py index 7609f24..3f7095f 100644 --- a/pixutils/conv/yuv_nb.py +++ b/pixutils/conv/yuv_nb.py @@ -185,7 +185,12 @@ def _nv12_to_bgr888_nb( def yuv_to_bgr888_nb( - arr: npt.NDArray[np.uint8], w: int, h: int, fmt: PixelFormat, options: dict | None + arr: npt.NDArray[np.uint8], + w: int, + h: int, + strides: tuple[int, ...], + fmt: PixelFormat, + options: dict | None, ) -> npt.NDArray[np.uint8]: """Entry point for numba YUV conversions.""" offset, matrix = _get_conversion_matrix(options) From f276fe81e5ac3df31f533b43e4ce6c2e01e58397 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Thu, 23 Apr 2026 08:28:14 +0300 Subject: [PATCH 13/44] conv: Add stride extrapolation If the caller gives an integer as the stride, and the format is a multi-plane format, use similar stride extrapolation as libcamera does. Signed-off-by: Tomi Valkeinen --- pixutils/conv/conv.py | 27 ++++++++++++++++----------- pixutils/formats/pixelformats.py | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/pixutils/conv/conv.py b/pixutils/conv/conv.py index 78cd9bd..fb738df 100644 --- a/pixutils/conv/conv.py +++ b/pixutils/conv/conv.py @@ -31,8 +31,12 @@ def to_bgr888( fmt: The pixel format of the input data width: Width of the image in pixels height: Height of the image in pixels - bytesperline: Bytes per line, either a single int (0 for no padding) or a - sequence of ints with one value per plane for multiplane formats + 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'] @@ -46,12 +50,10 @@ def to_bgr888( # Normalize bytesperline to a per-plane tuple of concrete (non-zero) strides if isinstance(bytesperline, int): - if len(fmt.planes) > 1 and bytesperline != 0: - raise ValueError('Multiplane formats require a sequence of strides or 0 for no padding') - strides = tuple( - bytesperline if bytesperline != 0 else fmt.stride(width, i) - for i in range(len(fmt.planes)) - ) + 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( @@ -73,7 +75,9 @@ def to_bgr888( raise ValueError('bytesperline is too small') if arr.size < fmt.planesize(strides[i], height, i): - raise ValueError('Input array is too small') + raise ValueError( + f'Input array is too small: {arr.size} < {fmt.planesize(strides[i], height, i)}, {bytesperline}, {strides}' + ) size += fmt.planesize(strides[i], height, i) @@ -129,8 +133,9 @@ def buffer_to_bgr888( fmt: The pixel format of the input data width: Width of the image in pixels height: Height of the image in pixels - bytesperline: Bytes per line, either a single int (0 for no padding) or a - list of ints with one value per plane for multiplane formats + 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 diff --git a/pixutils/formats/pixelformats.py b/pixutils/formats/pixelformats.py index fb2ac02..49bdee5 100644 --- a/pixutils/formats/pixelformats.py +++ b/pixutils/formats/pixelformats.py @@ -107,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 From 089ee7910902d20b6dde9c7d71defd9be25e0446 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Tue, 21 Apr 2026 22:07:01 +0300 Subject: [PATCH 14/44] conv: Add strip_padding() helper Signed-off-by: Tomi Valkeinen --- pixutils/conv/utils.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 pixutils/conv/utils.py diff --git a/pixutils/conv/utils.py b/pixutils/conv/utils.py new file mode 100644 index 0000000..542162e --- /dev/null +++ b/pixutils/conv/utils.py @@ -0,0 +1,27 @@ +# 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]: + 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] From 32e101b886ac43206b11b3b20d83cae3724da0d5 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Tue, 21 Apr 2026 22:14:19 +0300 Subject: [PATCH 15/44] conv: Use strip_padding() to fix the padded conversions Signed-off-by: Tomi Valkeinen --- pixutils/conv/opencv_impl.py | 4 ++++ pixutils/conv/rgb.py | 3 +++ pixutils/conv/utils.py | 3 +++ pixutils/conv/yuv.py | 3 +++ pixutils/conv/yuv_nb.py | 3 +++ 5 files changed, 16 insertions(+) diff --git a/pixutils/conv/opencv_impl.py b/pixutils/conv/opencv_impl.py index 04d682e..5b946d5 100644 --- a/pixutils/conv/opencv_impl.py +++ b/pixutils/conv/opencv_impl.py @@ -11,6 +11,7 @@ from numpy.lib.stride_tricks import as_strided from pixutils.formats import PixelFormat, PixelColorEncoding +from .utils import strip_padding __all__ = ['opencv_convert'] @@ -136,6 +137,9 @@ def opencv_convert( stride = bytesperline if bytesperline > 0 else fmt.stride(width, 0) if fmt.color == PixelColorEncoding.YUV: + if len(fmt.planes) > 1: + arr = strip_padding(arr, height, strides, fmt, width) + stride = fmt.stride(width, 0) return _convert_yuv(fmt, width, height, stride, arr) if fmt.color == PixelColorEncoding.RAW: diff --git a/pixutils/conv/rgb.py b/pixutils/conv/rgb.py index 67a96f1..c2f0123 100644 --- a/pixutils/conv/rgb.py +++ b/pixutils/conv/rgb.py @@ -7,11 +7,14 @@ import numpy.typing as npt from pixutils.formats import PixelFormat, PixelFormats +from .utils import strip_padding def rgb_to_bgr888( fmt: PixelFormat, w: int, h: int, strides: tuple[int, ...], data: npt.NDArray[np.uint8] ) -> npt.NDArray[np.uint8]: + data = strip_padding(data, h, strides, fmt, w) + if fmt == PixelFormats.RGB888: rgb = data.reshape((h, w, 3)) rgb = np.flip(rgb, axis=2) # Flip the components diff --git a/pixutils/conv/utils.py b/pixutils/conv/utils.py index 542162e..acd86b6 100644 --- a/pixutils/conv/utils.py +++ b/pixutils/conv/utils.py @@ -14,6 +14,9 @@ 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): diff --git a/pixutils/conv/yuv.py b/pixutils/conv/yuv.py index 9f45e58..097d1e6 100644 --- a/pixutils/conv/yuv.py +++ b/pixutils/conv/yuv.py @@ -7,6 +7,7 @@ import numpy.typing as npt from pixutils.formats import PixelFormat, PixelFormats +from .utils import strip_padding # Generated with './utils/gen-csc.py --format python --transpose' @@ -170,6 +171,8 @@ def yuv_to_bgr888( fmt: PixelFormat, options: dict | None, ) -> npt.NDArray[np.uint8]: + arr = strip_padding(arr, h, strides, fmt, w) + if fmt == PixelFormats.Y8: return y8_to_bgr888(arr, w, h, options) diff --git a/pixutils/conv/yuv_nb.py b/pixutils/conv/yuv_nb.py index 3f7095f..dd69a7a 100644 --- a/pixutils/conv/yuv_nb.py +++ b/pixutils/conv/yuv_nb.py @@ -10,6 +10,7 @@ from numba import njit # type: ignore[import-not-found] from pixutils.conv.yuv import _get_conversion_matrix +from pixutils.conv.utils import strip_padding from pixutils.formats import PixelFormat, PixelFormats __all__ = ['yuv_to_bgr888_nb'] @@ -193,6 +194,8 @@ def yuv_to_bgr888_nb( options: dict | None, ) -> npt.NDArray[np.uint8]: """Entry point for numba YUV conversions.""" + arr = strip_padding(arr, h, strides, fmt, w) + offset, matrix = _get_conversion_matrix(options) if fmt == PixelFormats.YUYV: From 23a20c92a97be6b6e4e151313d998677d250635d Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Tue, 21 Apr 2026 22:34:49 +0300 Subject: [PATCH 16/44] conv: numba: Use stride properly Signed-off-by: Tomi Valkeinen --- pixutils/conv/yuv_nb.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/pixutils/conv/yuv_nb.py b/pixutils/conv/yuv_nb.py index dd69a7a..26e5173 100644 --- a/pixutils/conv/yuv_nb.py +++ b/pixutils/conv/yuv_nb.py @@ -10,7 +10,6 @@ from numba import njit # type: ignore[import-not-found] from pixutils.conv.yuv import _get_conversion_matrix -from pixutils.conv.utils import strip_padding from pixutils.formats import PixelFormat, PixelFormats __all__ = ['yuv_to_bgr888_nb'] @@ -21,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, @@ -40,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] @@ -78,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, @@ -97,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] @@ -135,6 +134,8 @@ def _nv12_to_bgr888_nb( data: npt.NDArray[np.uint8], width: int, height: int, + y_stride: int, + uv_stride: int, offset_y: float, offset_u: float, offset_v: float, @@ -152,17 +153,16 @@ def _nv12_to_bgr888_nb( 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] + y_val = data[y * y_stride + x] # 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 + uv_idx = y_plane_offset + uv_y * uv_stride + uv_x * 2 u = data[uv_idx + 0] v = data[uv_idx + 1] @@ -194,8 +194,6 @@ def yuv_to_bgr888_nb( options: dict | None, ) -> npt.NDArray[np.uint8]: """Entry point for numba YUV conversions.""" - arr = strip_padding(arr, h, strides, fmt, w) - offset, matrix = _get_conversion_matrix(options) if fmt == PixelFormats.YUYV: @@ -203,6 +201,7 @@ def yuv_to_bgr888_nb( arr, w, h, + strides[0], offset[0], offset[1], offset[2], @@ -222,6 +221,7 @@ def yuv_to_bgr888_nb( arr, w, h, + strides[0], offset[0], offset[1], offset[2], @@ -241,6 +241,8 @@ def yuv_to_bgr888_nb( arr, w, h, + strides[0], + strides[1], offset[0], offset[1], offset[2], From 64439b69897c06248eb42b6d9c1300832b41ff1b Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Tue, 21 Apr 2026 22:51:16 +0300 Subject: [PATCH 17/44] conv: Use as_strided() to handle stride Signed-off-by: Tomi Valkeinen --- pixutils/conv/rgb.py | 23 ++++++++++++----------- pixutils/conv/yuv.py | 37 ++++++++++++++++--------------------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/pixutils/conv/rgb.py b/pixutils/conv/rgb.py index c2f0123..ee75051 100644 --- a/pixutils/conv/rgb.py +++ b/pixutils/conv/rgb.py @@ -5,32 +5,33 @@ import numpy as np import numpy.typing as npt +from numpy.lib.stride_tricks import as_strided from pixutils.formats import PixelFormat, PixelFormats -from .utils import strip_padding def rgb_to_bgr888( fmt: PixelFormat, w: int, h: int, strides: tuple[int, ...], data: npt.NDArray[np.uint8] ) -> npt.NDArray[np.uint8]: - data = strip_padding(data, h, strides, fmt, w) + stride = strides[0] if fmt == PixelFormats.RGB888: - rgb = data.reshape((h, w, 3)) - rgb = np.flip(rgb, axis=2) # Flip the components + rgb = as_strided(data, shape=(h, w, 3), strides=(stride, 3, 1), writeable=False) + rgb = np.flip(rgb, axis=2) elif fmt == PixelFormats.BGR888: - rgb = data.reshape((h, w, 3)) + rgb = as_strided(data, shape=(h, w, 3), strides=(stride, 3, 1), writeable=False) + rgb = rgb.copy() elif fmt in [PixelFormats.ARGB8888, PixelFormats.XRGB8888]: - rgb = data.reshape((h, w, 4)) + rgb = as_strided(data, shape=(h, w, 4), strides=(stride, 4, 1), writeable=False) rgb = np.delete(rgb, np.s_[3::4], axis=2) # drop alpha component - rgb = np.flip(rgb, axis=2) # Flip the components + rgb = np.flip(rgb, axis=2) elif fmt in [PixelFormats.ABGR8888, PixelFormats.XBGR8888]: - rgb = data.reshape((h, w, 4)) + rgb = as_strided(data, shape=(h, w, 4), strides=(stride, 4, 1), writeable=False) rgb = np.delete(rgb, np.s_[3::4], axis=2) # drop alpha component elif fmt == PixelFormats.XBGR2101010: - rgb = data.reshape((h, w * 4)) # .astype(np.uint16) - - v = rgb.view(np.dtype(' npt.NDA 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) @@ -111,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) @@ -126,13 +124,12 @@ def uyvy_to_bgr888( def nv12_to_bgr888( - data: npt.NDArray[np.uint8], w: int, h: int, options: dict | None + data: npt.NDArray[np.uint8], w: int, h: int, y_stride: int, uv_stride: int, options: dict | None ) -> npt.NDArray[np.uint8]: - plane1 = data[: w * h] - plane2 = data[w * h :] - - y = plane1.reshape((h, w)) - uv = plane2.reshape((h // 2, w // 2, 2)) + y = as_strided(data, shape=(h, w), strides=(y_stride, 1), writeable=False) + uv = as_strided( + data[y_stride * h :], shape=(h // 2, w // 2, 2), strides=(uv_stride, 2, 1), writeable=False + ) # YUV444 yuv = np.empty((h, w, 3), dtype=np.uint8) @@ -144,11 +141,11 @@ def nv12_to_bgr888( 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) @@ -171,18 +168,16 @@ def yuv_to_bgr888( fmt: PixelFormat, options: dict | None, ) -> npt.NDArray[np.uint8]: - arr = strip_padding(arr, h, strides, fmt, w) - 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 nv12_to_bgr888(arr, w, h, strides[0], strides[1], options) raise RuntimeError(f'Unsupported YUV format {fmt}') From 7a2ab9c3a52f473a54f256c4e4219c08337623c8 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Tue, 21 Apr 2026 23:00:55 +0300 Subject: [PATCH 18/44] conv: Clean up opencv stride handling Signed-off-by: Tomi Valkeinen --- pixutils/conv/opencv_impl.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/pixutils/conv/opencv_impl.py b/pixutils/conv/opencv_impl.py index 5b946d5..b0f287e 100644 --- a/pixutils/conv/opencv_impl.py +++ b/pixutils/conv/opencv_impl.py @@ -48,7 +48,7 @@ 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] @@ -61,12 +61,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)) @@ -133,19 +134,13 @@ def _convert_raw( def opencv_convert( fmt: PixelFormat, width: int, height: int, strides: tuple[int, ...], arr: npt.NDArray[np.uint8] ) -> npt.NDArray[np.uint8] | None: - bytesperline = strides[0] - stride = bytesperline if bytesperline > 0 else fmt.stride(width, 0) - if fmt.color == PixelColorEncoding.YUV: - if len(fmt.planes) > 1: - arr = strip_padding(arr, height, strides, fmt, width) - stride = fmt.stride(width, 0) - 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 From 4f8d8ef04e5d3240fd4c2fe6def6a664890ff239 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Tue, 21 Apr 2026 23:06:48 +0300 Subject: [PATCH 19/44] conv: Clean up bytesperline == 0 checking Signed-off-by: Tomi Valkeinen --- pixutils/conv/raw.py | 16 ++++------------ pixutils/conv/raw_nb.py | 10 +++------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/pixutils/conv/raw.py b/pixutils/conv/raw.py index 5c9b151..8266c57 100644 --- a/pixutils/conv/raw.py +++ b/pixutils/conv/raw.py @@ -62,15 +62,11 @@ def from_pixelformat(cls, fmt: PixelFormat): def prepare_packed_raw( - data: npt.NDArray[np.uint8], width: int, height: int, bits_per_pixel: int, bytesperline: int + data: npt.NDArray[np.uint8], width: int, bits_per_pixel: int, bytesperline: int ) -> 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((len(data) // bytesperline, bytesperline)) # Remove padding if present padded_width = width * bits_per_pixel // 8 @@ -106,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((len(data) // bytesperline, bytesperline)) # Remove padding if present. # The unpacked data is stored in 8 bits for 8bpp, and 16 bits for 10/12/16bpp. @@ -284,7 +276,7 @@ def raw_to_bgr888( # Prepare the raw data into a common 16-bit format if raw_fmt.is_packed: - arr16 = prepare_packed_raw(data, width, height, raw_fmt.bits_per_pixel, bytesperline) + arr16 = prepare_packed_raw(data, width, raw_fmt.bits_per_pixel, bytesperline) else: arr16 = prepare_unpacked_raw(data, width, height, raw_fmt.bits_per_pixel, bytesperline) diff --git a/pixutils/conv/raw_nb.py b/pixutils/conv/raw_nb.py index a6e8b38..7bf21c8 100644 --- a/pixutils/conv/raw_nb.py +++ b/pixutils/conv/raw_nb.py @@ -273,16 +273,12 @@ def _compute_demosaic_planes_nb( def _prepare_packed_raw_nb( - data: npt.NDArray[np.uint8], width: int, height: int, bits_per_pixel: int, bytesperline: int + data: npt.NDArray[np.uint8], width: int, bits_per_pixel: int, bytesperline: int ) -> npt.NDArray[np.uint16]: """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((len(data) // bytesperline, bytesperline)) # Remove padding if present padded_width = width * bits_per_pixel // 8 @@ -372,7 +368,7 @@ def raw_to_bgr888_nb( # Prepare the raw data into a common 16-bit format if raw_fmt.is_packed: - arr16 = _prepare_packed_raw_nb(data, width, height, raw_fmt.bits_per_pixel, bytesperline) + arr16 = _prepare_packed_raw_nb(data, width, raw_fmt.bits_per_pixel, bytesperline) else: arr16 = prepare_unpacked_raw(data, width, height, raw_fmt.bits_per_pixel, bytesperline) From 30391ef6bb5a76ce9ad36a52e596383d66148254 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Thu, 23 Apr 2026 08:55:18 +0300 Subject: [PATCH 20/44] utils/conv-perf-test: Support stride properly Signed-off-by: Tomi Valkeinen --- utils/conv-perf-test.py | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/utils/conv-perf-test.py b/utils/conv-perf-test.py index c43dc0b..3296398 100755 --- a/utils/conv-perf-test.py +++ b/utils/conv-perf-test.py @@ -22,27 +22,15 @@ def run_one( ) -> dict: fmt = PixelFormats.find_by_name(format_name) - # 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) + 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) - stride = args.stride if args.stride > 0 else fmt.stride(args.width, 0) - bytesperline = 0 if len(fmt.planes) > 1 else stride - # Warmup for _ in range(3): - buffer_to_bgr888(fmt, args.width, args.height, bytesperline, buf, options) + buffer_to_bgr888(fmt, args.width, args.height, strides, buf, options) min_iter_s = float('inf') gc.disable() @@ -51,7 +39,7 @@ def run_one( t_start = time.perf_counter() t_prev = t_start while True: - buffer_to_bgr888(fmt, args.width, args.height, bytesperline, buf, options) + buffer_to_bgr888(fmt, args.width, args.height, strides, buf, options) iters += 1 t_now = time.perf_counter() dt = t_now - t_prev @@ -70,14 +58,14 @@ def run_one( backends_str = args.backends if args.backends else 'default' print( f'{format_name} {args.width}x{args.height}, backends: {backends_str}, ' - f'stride: {stride} ' + 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([stride]), + 'strides': list(strides), 'bufsize': size, 'iters': iters, 'elapsed': elapsed, @@ -125,7 +113,12 @@ def main(): 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('--stride', type=int, default=0, help='Stride') + parser.add_argument( + '--padding', + type=int, + default=0, + help="Extra bytes added to each plane's natural stride", + ) parser.add_argument( '--demosaic', type=str, @@ -175,7 +168,7 @@ def main(): 'numpy': np.__version__, 'width': args.width, 'height': args.height, - 'padding': 0, + 'padding': args.padding, 'backends': options.get('backends', ['default']), 'options': {k: v for k, v in options.items() if k != 'backends'}, 'measurement_time': args.time, From 3a344c6501371430094c45b3339b2e157be0ef3c Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Tue, 21 Apr 2026 22:06:13 +0300 Subject: [PATCH 21/44] tests: Add padded/strided tests Signed-off-by: Tomi Valkeinen --- tests/test_conv.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/test_conv.py b/tests/test_conv.py index 30b2d7d..2aaa81d 100755 --- a/tests/test_conv.py +++ b/tests/test_conv.py @@ -195,6 +195,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 +254,54 @@ 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 main(): parser = argparse.ArgumentParser(add_help=False) parser.add_argument( From 99e5f6e8c840517395fc94ea1f54ca4f0fc899d2 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Thu, 23 Apr 2026 08:24:08 +0300 Subject: [PATCH 22/44] tests: Add stride extrapolation tests Signed-off-by: Tomi Valkeinen --- tests/test_conv.py | 71 +++++++++++++++++++++++++++++++++++++++++++ tests/test_formats.py | 41 +++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/tests/test_conv.py b/tests/test_conv.py index 2aaa81d..1aa3057 100755 --- a/tests/test_conv.py +++ b/tests/test_conv.py @@ -302,6 +302,77 @@ def create_stride_test_functions(): 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_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() From ab468443a7709702b9d974fb48dd414f5995d8ef Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 18:48:45 +0300 Subject: [PATCH 23/44] conv: opencv: Fix RGB conversions Many of them were quite wrong... Signed-off-by: Tomi Valkeinen --- pixutils/conv/opencv_impl.py | 45 +++++++++++++++++------------------- tests/test_conv_data.py | 16 ++++++------- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/pixutils/conv/opencv_impl.py b/pixutils/conv/opencv_impl.py index b0f287e..49fc198 100644 --- a/pixutils/conv/opencv_impl.py +++ b/pixutils/conv/opencv_impl.py @@ -10,7 +10,7 @@ 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'] @@ -22,22 +22,6 @@ '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, @@ -76,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), @@ -90,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( diff --git a/tests/test_conv_data.py b/tests/test_conv_data.py index bf566a2..39f68f8 100644 --- a/tests/test_conv_data.py +++ b/tests/test_conv_data.py @@ -23,7 +23,7 @@ {'backends': ['numpy']}), ConvTestCase(PixelFormats.XRGB8888, 'd555e8545b743011df3c27f0a7056552b302a9089b7b6d8d9562402c38c40f1e', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', {'backends': ['opencv']}), ConvTestCase(PixelFormats.XRGB8888, 'd555e8545b743011df3c27f0a7056552b302a9089b7b6d8d9562402c38c40f1e', @@ -31,7 +31,7 @@ {'backends': ['numpy']}), ConvTestCase(PixelFormats.XBGR8888, 'd555e8545b743011df3c27f0a7056552b302a9089b7b6d8d9562402c38c40f1e', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', {'backends': ['opencv']}), ConvTestCase(PixelFormats.XBGR8888, 'd555e8545b743011df3c27f0a7056552b302a9089b7b6d8d9562402c38c40f1e', @@ -39,11 +39,11 @@ {'backends': ['numpy']}), ConvTestCase(PixelFormats.RGBX8888, 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '5dde07cf9a57636662174ee57f21d64534d82a43635b06f99b115e5bf92b64fd', {'backends': ['opencv']}), ConvTestCase(PixelFormats.BGRX8888, 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '94bf56332bf148f9328281f9c23ef88a6aef5c726344c40390052a0a7266ed15', {'backends': ['opencv']}), ConvTestCase(PixelFormats.XBGR2101010, 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', @@ -51,7 +51,7 @@ {'backends': ['numpy']}), ConvTestCase(PixelFormats.ARGB8888, 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', {'backends': ['opencv']}), ConvTestCase(PixelFormats.ARGB8888, 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', @@ -59,7 +59,7 @@ {'backends': ['numpy']}), ConvTestCase(PixelFormats.ABGR8888, 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', {'backends': ['opencv']}), ConvTestCase(PixelFormats.ABGR8888, 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', @@ -67,11 +67,11 @@ {'backends': ['numpy']}), ConvTestCase(PixelFormats.RGBA8888, 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '5dde07cf9a57636662174ee57f21d64534d82a43635b06f99b115e5bf92b64fd', {'backends': ['opencv']}), ConvTestCase(PixelFormats.BGRA8888, 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '94bf56332bf148f9328281f9c23ef88a6aef5c726344c40390052a0a7266ed15', {'backends': ['opencv']}), # Bayer formats ConvTestCase(PixelFormats.SBGGR8, From 9821ab5661f622f8f08a171c9f966daf26b735d5 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 19:13:34 +0300 Subject: [PATCH 24/44] conv: opencv: Fix YUV component ordering Signed-off-by: Tomi Valkeinen --- pixutils/conv/opencv_impl.py | 10 +++++----- tests/test_conv_data.py | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pixutils/conv/opencv_impl.py b/pixutils/conv/opencv_impl.py index 49fc198..dee0da4 100644 --- a/pixutils/conv/opencv_impl.py +++ b/pixutils/conv/opencv_impl.py @@ -23,11 +23,11 @@ } 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, } diff --git a/tests/test_conv_data.py b/tests/test_conv_data.py index 39f68f8..c50d3bf 100644 --- a/tests/test_conv_data.py +++ b/tests/test_conv_data.py @@ -334,7 +334,7 @@ # YUV formats ConvTestCase(PixelFormats.YUYV, '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '32c537a5ef20ba4b8078f9db94be3956de18b548f0d9d14498bacc515dd4c26c', + '9595bd083ca1587b8eab34699d2b0aad78be29879243c55a034eadc1e5520b18', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YUYV, '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', @@ -386,7 +386,7 @@ {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.UYVY, '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '601e7354d710c69888ee0257d82b45f333acc0bf91bfac81d0863a7a0fe95fbc', + 'f742ae0b43dd138ce9375e34532dbfd4f6beb85241d715b9f82419d9aa20ff60', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.UYVY, '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', @@ -438,11 +438,11 @@ {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YVYU, '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'd989b3342f6b1300e498e2a8b1462c33b1d4073d045294e111e9d7c5c1ebd8ee', + 'a6bcd68b20ca0e1397c65d582b21deaf5c3a3cf9e4e35776db0f28b7204a27c2', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV12, '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '3fc9d3a2511ba41f3e7969a1fb2b9ef4ceb9dbe24c24478031022c560cb12612', + '4da513e1220b1491bcb1981ba0e7fb746037bfada3bdad7030204da168687f28', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV12, '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', @@ -494,7 +494,7 @@ {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV21, '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '31e75b443aad217972fcf9c50c2f2dbe8b517e7d7e76f7c8e95b051a4d6863bd', + '3e17faada30f1762eafa3f48f7dea145aefc3e09fcec33777495682e8026b67e', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.Y8, '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', From 0436418937cb60a2590c2d10dd669968b6cba999 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 19:20:35 +0300 Subject: [PATCH 25/44] tests: Add cross-backend consistency test Convert a random buffer to BGR888 with each available backend (opencv, numba, numpy) and compare the outputs pairwise. Catches bugs such as channel swaps, wrong plane offsets, or off-by-one unpacking that locked per-backend SHAs in the existing test cannot detect. Comparison uses max-per-channel mean, 99th percentile, and max of the per-pixel absolute difference. Per-channel (not aggregate) mean is the key: a channel swap leaves one channel near zero while the other two diverge by ~85 on random data, which stands out even when the aggregate mean overlaps with legitimate algorithmic differences (e.g. different demosaic methods). Tolerances are split by color class (RGB exact, YUV a few LSB, RAW loose). Test discovery probes each format at 32x32 with a shared oversized buffer and emits test methods only for formats where >=2 backends accept the conversion, keeping the reported skip count at zero. --- tests/test_backend_compat.py | 149 +++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/test_backend_compat.py diff --git a/tests/test_backend_compat.py b/tests/test_backend_compat.py new file mode 100644 index 0000000..ae2c804 --- /dev/null +++ b/tests/test_backend_compat.py @@ -0,0 +1,149 @@ +#!/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. 32 is divisible by every common pixel +# alignment (2, 4, 8, 16, 32), so almost every format accepts it. The buffer +# is sized as 32×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 = 32 +_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 Exception: + 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() From 45076cfcbbdf5c7af63b6d27447c47f08e601596 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 09:19:22 +0300 Subject: [PATCH 26/44] conv: Ensure we return an independent buffer Signed-off-by: Tomi Valkeinen --- pixutils/conv/conv.py | 34 +++++++++++++++++++++------------- pixutils/conv/rgb.py | 1 - 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/pixutils/conv/conv.py b/pixutils/conv/conv.py index fb738df..5d441b7 100644 --- a/pixutils/conv/conv.py +++ b/pixutils/conv/conv.py @@ -85,13 +85,14 @@ def to_bgr888( 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, 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': @@ -99,20 +100,27 @@ def to_bgr888( 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, strides, fmt, options) - - if fmt.color == PixelColorEncoding.RAW: - return raw_to_bgr888(arr, width, height, strides, fmt, options) - - if fmt.color == PixelColorEncoding.RGB: - return rgb_to_bgr888(fmt, width, height, strides, 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 ValueError(f'Unsupported format {fmt}') + break + + if result is None: + raise ValueError(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( diff --git a/pixutils/conv/rgb.py b/pixutils/conv/rgb.py index ee75051..4836b6a 100644 --- a/pixutils/conv/rgb.py +++ b/pixutils/conv/rgb.py @@ -20,7 +20,6 @@ def rgb_to_bgr888( rgb = np.flip(rgb, axis=2) elif fmt == PixelFormats.BGR888: rgb = as_strided(data, shape=(h, w, 3), strides=(stride, 3, 1), writeable=False) - rgb = rgb.copy() elif fmt in [PixelFormats.ARGB8888, PixelFormats.XRGB8888]: rgb = as_strided(data, shape=(h, w, 4), strides=(stride, 4, 1), writeable=False) rgb = np.delete(rgb, np.s_[3::4], axis=2) # drop alpha component From a8c8ee3aebb240aedc21b31ef8e3eae80662b8fb Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 14:13:37 +0300 Subject: [PATCH 27/44] conv: numpy: rgb: Produce contiguous BGR888 output directly Allocate the output once and do the channel swap into it directly. The alpha-drop paths use a slice-copy instead of np.delete, which is simpler and avoids the strided fancy-indexing. Results are now naturally C-contiguous, and gives a nice increase in perf. Signed-off-by: Tomi Valkeinen --- pixutils/conv/rgb.py | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/pixutils/conv/rgb.py b/pixutils/conv/rgb.py index 4836b6a..091a25b 100644 --- a/pixutils/conv/rgb.py +++ b/pixutils/conv/rgb.py @@ -16,35 +16,31 @@ def rgb_to_bgr888( stride = strides[0] if fmt == PixelFormats.RGB888: - rgb = as_strided(data, shape=(h, w, 3), strides=(stride, 3, 1), writeable=False) - rgb = np.flip(rgb, axis=2) + 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 = as_strided(data, shape=(h, w, 3), strides=(stride, 3, 1), writeable=False) elif fmt in [PixelFormats.ARGB8888, PixelFormats.XRGB8888]: - rgb = as_strided(data, shape=(h, w, 4), strides=(stride, 4, 1), writeable=False) - rgb = np.delete(rgb, np.s_[3::4], axis=2) # drop alpha component - rgb = np.flip(rgb, axis=2) + 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 = as_strided(data, shape=(h, w, 4), strides=(stride, 4, 1), writeable=False) - 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: v = as_strided( data.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 + rgb = np.empty((h, w, 3), dtype=np.uint8) + rgb[:, :, 0] = (v >> 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}') From 4392445dcda06cb7d0f3a407132681094204982a Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 14:33:51 +0300 Subject: [PATCH 28/44] conv: numpy: raw: Shift in-place before dtype cast Shift the intermediate 16-bit array in place and then cast, instead of allocating a shifted copy and then casting. Saves one full-size buffer allocation in the RAW conversion path. --- pixutils/conv/raw.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pixutils/conv/raw.py b/pixutils/conv/raw.py index 8266c57..2813d26 100644 --- a/pixutils/conv/raw.py +++ b/pixutils/conv/raw.py @@ -284,4 +284,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) From 3ae175c45dbe2a01a134d84ae7f4b15a3188a651 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 14:34:23 +0300 Subject: [PATCH 29/44] conv: numpy: yuv: Clip in-place in ycbcr_to_bgr888() np.clip() allocates a new array by default; passing out= makes it modify the dot-product result in place. Avoids one intermediate float-array allocation per frame. --- pixutils/conv/yuv.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pixutils/conv/yuv.py b/pixutils/conv/yuv.py index b1341b7..e4e374e 100644 --- a/pixutils/conv/yuv.py +++ b/pixutils/conv/yuv.py @@ -89,10 +89,8 @@ 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( From 9f5bcded52492c8e96f99d697b56308e612169aa Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 14:34:36 +0300 Subject: [PATCH 30/44] conv: numpy: yuv: Use np.empty() in y8_to_bgr888() All three planes are overwritten immediately after allocation, so np.zeros() needlessly zero-fills the buffer. np.empty() skips the fill. --- pixutils/conv/yuv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pixutils/conv/yuv.py b/pixutils/conv/yuv.py index e4e374e..7d97c06 100644 --- a/pixutils/conv/yuv.py +++ b/pixutils/conv/yuv.py @@ -150,7 +150,7 @@ def y8_to_bgr888( 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 From 5e152bed98d9e7e2eb13602372aa7ad51b8e7261 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 09:59:46 +0300 Subject: [PATCH 31/44] conv: raw: Clean up buffer reshaping --- pixutils/conv/raw.py | 8 ++++---- pixutils/conv/raw_nb.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pixutils/conv/raw.py b/pixutils/conv/raw.py index 2813d26..d26fa80 100644 --- a/pixutils/conv/raw.py +++ b/pixutils/conv/raw.py @@ -62,11 +62,11 @@ def from_pixelformat(cls, fmt: PixelFormat): def prepare_packed_raw( - data: npt.NDArray[np.uint8], width: int, bits_per_pixel: int, bytesperline: int + data: npt.NDArray[np.uint8], width: int, height: int, bits_per_pixel: int, bytesperline: int ) -> npt.NDArray[np.uint16]: assert bits_per_pixel in [10, 12], 'Only 10 and 12 bpp are supported' - data = data.reshape((len(data) // bytesperline, bytesperline)) + data = data.reshape((height, bytesperline)) # Remove padding if present padded_width = width * bits_per_pixel // 8 @@ -102,7 +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]: - data = data.reshape((len(data) // bytesperline, bytesperline)) + 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. @@ -276,7 +276,7 @@ def raw_to_bgr888( # Prepare the raw data into a common 16-bit format if raw_fmt.is_packed: - arr16 = prepare_packed_raw(data, width, raw_fmt.bits_per_pixel, bytesperline) + arr16 = prepare_packed_raw(data, width, height, raw_fmt.bits_per_pixel, bytesperline) else: arr16 = prepare_unpacked_raw(data, width, height, raw_fmt.bits_per_pixel, bytesperline) diff --git a/pixutils/conv/raw_nb.py b/pixutils/conv/raw_nb.py index 7bf21c8..6a1d48a 100644 --- a/pixutils/conv/raw_nb.py +++ b/pixutils/conv/raw_nb.py @@ -273,12 +273,12 @@ def _compute_demosaic_planes_nb( def _prepare_packed_raw_nb( - data: npt.NDArray[np.uint8], width: int, bits_per_pixel: int, bytesperline: int + data: npt.NDArray[np.uint8], width: int, height: int, bits_per_pixel: int, bytesperline: int ) -> npt.NDArray[np.uint16]: """Prepare packed raw data using numba unpacking.""" assert bits_per_pixel in [10, 12], 'Only 10 and 12 bpp are supported' - data = data.reshape((len(data) // bytesperline, bytesperline)) + data = data.reshape((height, bytesperline)) # Remove padding if present padded_width = width * bits_per_pixel // 8 @@ -368,7 +368,7 @@ def raw_to_bgr888_nb( # Prepare the raw data into a common 16-bit format if raw_fmt.is_packed: - arr16 = _prepare_packed_raw_nb(data, width, raw_fmt.bits_per_pixel, bytesperline) + arr16 = _prepare_packed_raw_nb(data, width, height, raw_fmt.bits_per_pixel, bytesperline) else: arr16 = prepare_unpacked_raw(data, width, height, raw_fmt.bits_per_pixel, bytesperline) From 0b00b00ebf1756745f36cd8deed3a0617fb0914a Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 12:26:39 +0300 Subject: [PATCH 32/44] conv: Add NV16 support (numpy & numba) Signed-off-by: Tomi Valkeinen --- pixutils/conv/numba.py | 2 +- pixutils/conv/yuv.py | 20 +++++++++++ pixutils/conv/yuv_nb.py | 76 +++++++++++++++++++++++++++++++++++++++++ tests/test_conv_data.py | 48 ++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/pixutils/conv/numba.py b/pixutils/conv/numba.py index 9e7fee1..e92caa4 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', 'NV16'} def _can_use_numba_yuv(fmt: PixelFormat) -> bool: diff --git a/pixutils/conv/yuv.py b/pixutils/conv/yuv.py index 7d97c06..3d07588 100644 --- a/pixutils/conv/yuv.py +++ b/pixutils/conv/yuv.py @@ -138,6 +138,23 @@ def nv12_to_bgr888( return ycbcr_to_bgr888(yuv, options) +def nv16_to_bgr888( + data: npt.NDArray[np.uint8], w: int, h: int, y_stride: int, uv_stride: int, options: dict | None +) -> npt.NDArray[np.uint8]: + y = as_strided(data, shape=(h, w), strides=(y_stride, 1), writeable=False) + uv = as_strided( + data[y_stride * h :], shape=(h, w // 2, 2), strides=(uv_stride, 2, 1), writeable=False + ) + + # YUV444 + yuv = np.empty((h, w, 3), dtype=np.uint8) + yuv[:, :, 0] = y[:, :] # Y + yuv[:, :, 1] = uv[:, :, 0].repeat(2, axis=1) # U + yuv[:, :, 2] = uv[:, :, 1].repeat(2, axis=1) # V + + return ycbcr_to_bgr888(yuv, options) + + def y8_to_bgr888( data: npt.NDArray[np.uint8], w: int, h: int, stride: int, options: dict | None ) -> npt.NDArray[np.uint8]: @@ -178,4 +195,7 @@ def yuv_to_bgr888( if fmt == PixelFormats.NV12: return nv12_to_bgr888(arr, w, h, strides[0], strides[1], options) + if fmt == PixelFormats.NV16: + return nv16_to_bgr888(arr, w, h, strides[0], strides[1], options) + raise RuntimeError(f'Unsupported YUV format {fmt}') diff --git a/pixutils/conv/yuv_nb.py b/pixutils/conv/yuv_nb.py index 26e5173..f8fb573 100644 --- a/pixutils/conv/yuv_nb.py +++ b/pixutils/conv/yuv_nb.py @@ -185,6 +185,61 @@ def _nv12_to_bgr888_nb( return rgb +@njit(cache=True) +def _nv16_to_bgr888_nb( + data: npt.NDArray[np.uint8], + width: int, + height: int, + y_stride: int, + uv_stride: int, + offset_y: float, + offset_u: float, + offset_v: float, + m00: float, + m01: float, + m02: float, + m10: float, + m11: float, + m12: float, + m20: float, + m21: float, + m22: float, +) -> npt.NDArray[np.uint8]: + """JIT-compiled NV16 to BGR conversion with custom chroma upsampling""" + rgb = np.empty((height, width, 3), dtype=np.uint8) + + # NV16 layout: Y plane followed by interleaved UV plane (4:2:2) + y_plane_offset = y_stride * height + + for y in range(height): + for x in range(width): + y_val = data[y * y_stride + x] + + # Get UV values from chroma plane (subsampled 2x horizontally only) + uv_x = x // 2 + uv_idx = y_plane_offset + y * uv_stride + uv_x * 2 + + u = data[uv_idx + 0] + v = data[uv_idx + 1] + + # Apply offsets + y_adj = y_val + offset_y + u_adj = u + offset_u + v_adj = v + offset_v + + # Matrix multiplication: [Y U V] × Matrix (column-wise produces BGR) + b = m00 * y_adj + m10 * u_adj + m20 * v_adj + g = m01 * y_adj + m11 * u_adj + m21 * v_adj + r = m02 * y_adj + m12 * u_adj + m22 * v_adj + + # Clip and store as BGR + rgb[y, x, 0] = max(0, min(255, int(b))) # B + rgb[y, x, 1] = max(0, min(255, int(g))) # G + rgb[y, x, 2] = max(0, min(255, int(r))) # R + + return rgb + + def yuv_to_bgr888_nb( arr: npt.NDArray[np.uint8], w: int, @@ -257,4 +312,25 @@ def yuv_to_bgr888_nb( matrix[2][2], ) + if fmt == PixelFormats.NV16: + return _nv16_to_bgr888_nb( + arr, + w, + h, + strides[0], + strides[1], + offset[0], + offset[1], + offset[2], + matrix[0][0], + matrix[0][1], + matrix[0][2], + matrix[1][0], + matrix[1][1], + matrix[1][2], + matrix[2][0], + matrix[2][1], + matrix[2][2], + ) + raise RuntimeError(f'Unsupported YUV format {fmt}') diff --git a/tests/test_conv_data.py b/tests/test_conv_data.py index c50d3bf..2cd774f 100644 --- a/tests/test_conv_data.py +++ b/tests/test_conv_data.py @@ -496,6 +496,54 @@ '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', '3e17faada30f1762eafa3f48f7dea145aefc3e09fcec33777495682e8026b67e', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV16, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '5ce7a4094e84ce0672d73ae75816fef12bdabd9d790e9f40d45642a293b9b108', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV16, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '388c1f4457a0101a7812201e9de1c39a64c521e340ed8014a2ac4d113f718536', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV16, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '8e2ae09bf171635e671a543a0b8da2de673512959b1bc5f2066da23ecf6eff96', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV16, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + 'b0aad543efee4f4770b052a3073e126e0ee04dacef5d551015a8878e722dac62', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV16, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '63ab860545f8b5205dd7649264d134415ae94cad97828d2000e5e900e5b3c323', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV16, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + 'a9a44c1ce81fc893daa4124b9520f3b2fd2799888faa45a358e92794f6eabcb7', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV16, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '5ce7a4094e84ce0672d73ae75816fef12bdabd9d790e9f40d45642a293b9b108', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV16, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '388c1f4457a0101a7812201e9de1c39a64c521e340ed8014a2ac4d113f718536', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV16, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '8e2ae09bf171635e671a543a0b8da2de673512959b1bc5f2066da23ecf6eff96', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV16, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + 'b0aad543efee4f4770b052a3073e126e0ee04dacef5d551015a8878e722dac62', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV16, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '63ab860545f8b5205dd7649264d134415ae94cad97828d2000e5e900e5b3c323', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV16, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + 'a9a44c1ce81fc893daa4124b9520f3b2fd2799888faa45a358e92794f6eabcb7', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.Y8, '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', '58226da199a89c0bb0405ba764ce314513f6303ff19f3d25b936b0a82714b3d8', From dea105a583c211fcfa6833e79293bd92e7aef180 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 13:03:38 +0300 Subject: [PATCH 33/44] conv: Add NV21 and NV61 Signed-off-by: Tomi Valkeinen --- pixutils/conv/numba.py | 2 +- pixutils/conv/yuv.py | 40 +++++++++++ pixutils/conv/yuv_nb.py | 153 ++++++++++++++++++++++++++++++++++++++++ tests/test_conv_data.py | 96 +++++++++++++++++++++++++ 4 files changed, 290 insertions(+), 1 deletion(-) diff --git a/pixutils/conv/numba.py b/pixutils/conv/numba.py index e92caa4..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', 'NV16'} +_SUPPORTED_YUV_FORMATS = {'YUYV', 'UYVY', 'NV12', 'NV21', 'NV16', 'NV61'} def _can_use_numba_yuv(fmt: PixelFormat) -> bool: diff --git a/pixutils/conv/yuv.py b/pixutils/conv/yuv.py index 3d07588..dcfb711 100644 --- a/pixutils/conv/yuv.py +++ b/pixutils/conv/yuv.py @@ -138,6 +138,23 @@ def nv12_to_bgr888( return ycbcr_to_bgr888(yuv, options) +def nv21_to_bgr888( + data: npt.NDArray[np.uint8], w: int, h: int, y_stride: int, uv_stride: int, options: dict | None +) -> npt.NDArray[np.uint8]: + y = as_strided(data, shape=(h, w), strides=(y_stride, 1), writeable=False) + uv = as_strided( + data[y_stride * h :], shape=(h // 2, w // 2, 2), strides=(uv_stride, 2, 1), writeable=False + ) + + # YUV444 + yuv = np.empty((h, w, 3), dtype=np.uint8) + yuv[:, :, 0] = y[:, :] # Y + yuv[:, :, 1] = uv[:, :, 1].repeat(2, axis=0).repeat(2, axis=1) # U + yuv[:, :, 2] = uv[:, :, 0].repeat(2, axis=0).repeat(2, axis=1) # V + + return ycbcr_to_bgr888(yuv, options) + + def nv16_to_bgr888( data: npt.NDArray[np.uint8], w: int, h: int, y_stride: int, uv_stride: int, options: dict | None ) -> npt.NDArray[np.uint8]: @@ -155,6 +172,23 @@ def nv16_to_bgr888( return ycbcr_to_bgr888(yuv, options) +def nv61_to_bgr888( + data: npt.NDArray[np.uint8], w: int, h: int, y_stride: int, uv_stride: int, options: dict | None +) -> npt.NDArray[np.uint8]: + y = as_strided(data, shape=(h, w), strides=(y_stride, 1), writeable=False) + uv = as_strided( + data[y_stride * h :], shape=(h, w // 2, 2), strides=(uv_stride, 2, 1), writeable=False + ) + + # YUV444 + yuv = np.empty((h, w, 3), dtype=np.uint8) + yuv[:, :, 0] = y[:, :] # Y + yuv[:, :, 1] = uv[:, :, 1].repeat(2, axis=1) # U + yuv[:, :, 2] = uv[:, :, 0].repeat(2, axis=1) # V + + return ycbcr_to_bgr888(yuv, options) + + def y8_to_bgr888( data: npt.NDArray[np.uint8], w: int, h: int, stride: int, options: dict | None ) -> npt.NDArray[np.uint8]: @@ -195,7 +229,13 @@ def yuv_to_bgr888( if fmt == PixelFormats.NV12: return nv12_to_bgr888(arr, w, h, strides[0], strides[1], options) + if fmt == PixelFormats.NV21: + return nv21_to_bgr888(arr, w, h, strides[0], strides[1], options) + if fmt == PixelFormats.NV16: return nv16_to_bgr888(arr, w, h, strides[0], strides[1], options) + if fmt == PixelFormats.NV61: + return nv61_to_bgr888(arr, w, h, strides[0], strides[1], options) + raise RuntimeError(f'Unsupported YUV format {fmt}') diff --git a/pixutils/conv/yuv_nb.py b/pixutils/conv/yuv_nb.py index f8fb573..8f239ec 100644 --- a/pixutils/conv/yuv_nb.py +++ b/pixutils/conv/yuv_nb.py @@ -185,6 +185,62 @@ def _nv12_to_bgr888_nb( return rgb +@njit(cache=True) +def _nv21_to_bgr888_nb( + data: npt.NDArray[np.uint8], + width: int, + height: int, + y_stride: int, + uv_stride: int, + offset_y: float, + offset_u: float, + offset_v: float, + m00: float, + m01: float, + m02: float, + m10: float, + m11: float, + m12: float, + m20: float, + m21: float, + m22: float, +) -> npt.NDArray[np.uint8]: + """JIT-compiled NV21 to BGR conversion with custom chroma upsampling""" + rgb = np.empty((height, width, 3), dtype=np.uint8) + + # NV21 layout: Y plane followed by interleaved VU plane + y_plane_offset = y_stride * height + + for y in range(height): + for x in range(width): + y_val = data[y * y_stride + x] + + # Get UV values from chroma plane (subsampled by 2x2) + uv_y = y // 2 + uv_x = x // 2 + uv_idx = y_plane_offset + uv_y * uv_stride + uv_x * 2 + + v = data[uv_idx + 0] + u = data[uv_idx + 1] + + # Apply offsets + y_adj = y_val + offset_y + u_adj = u + offset_u + v_adj = v + offset_v + + # Matrix multiplication: [Y U V] × Matrix (column-wise produces BGR) + b = m00 * y_adj + m10 * u_adj + m20 * v_adj + g = m01 * y_adj + m11 * u_adj + m21 * v_adj + r = m02 * y_adj + m12 * u_adj + m22 * v_adj + + # Clip and store as BGR + rgb[y, x, 0] = max(0, min(255, int(b))) # B + rgb[y, x, 1] = max(0, min(255, int(g))) # G + rgb[y, x, 2] = max(0, min(255, int(r))) # R + + return rgb + + @njit(cache=True) def _nv16_to_bgr888_nb( data: npt.NDArray[np.uint8], @@ -240,6 +296,61 @@ def _nv16_to_bgr888_nb( return rgb +@njit(cache=True) +def _nv61_to_bgr888_nb( + data: npt.NDArray[np.uint8], + width: int, + height: int, + y_stride: int, + uv_stride: int, + offset_y: float, + offset_u: float, + offset_v: float, + m00: float, + m01: float, + m02: float, + m10: float, + m11: float, + m12: float, + m20: float, + m21: float, + m22: float, +) -> npt.NDArray[np.uint8]: + """JIT-compiled NV61 to BGR conversion with custom chroma upsampling""" + rgb = np.empty((height, width, 3), dtype=np.uint8) + + # NV61 layout: Y plane followed by interleaved VU plane (4:2:2) + y_plane_offset = y_stride * height + + for y in range(height): + for x in range(width): + y_val = data[y * y_stride + x] + + # Get UV values from chroma plane (subsampled 2x horizontally only) + uv_x = x // 2 + uv_idx = y_plane_offset + y * uv_stride + uv_x * 2 + + v = data[uv_idx + 0] + u = data[uv_idx + 1] + + # Apply offsets + y_adj = y_val + offset_y + u_adj = u + offset_u + v_adj = v + offset_v + + # Matrix multiplication: [Y U V] × Matrix (column-wise produces BGR) + b = m00 * y_adj + m10 * u_adj + m20 * v_adj + g = m01 * y_adj + m11 * u_adj + m21 * v_adj + r = m02 * y_adj + m12 * u_adj + m22 * v_adj + + # Clip and store as BGR + rgb[y, x, 0] = max(0, min(255, int(b))) # B + rgb[y, x, 1] = max(0, min(255, int(g))) # G + rgb[y, x, 2] = max(0, min(255, int(r))) # R + + return rgb + + def yuv_to_bgr888_nb( arr: npt.NDArray[np.uint8], w: int, @@ -312,6 +423,27 @@ def yuv_to_bgr888_nb( matrix[2][2], ) + if fmt == PixelFormats.NV21: + return _nv21_to_bgr888_nb( + arr, + w, + h, + strides[0], + strides[1], + offset[0], + offset[1], + offset[2], + matrix[0][0], + matrix[0][1], + matrix[0][2], + matrix[1][0], + matrix[1][1], + matrix[1][2], + matrix[2][0], + matrix[2][1], + matrix[2][2], + ) + if fmt == PixelFormats.NV16: return _nv16_to_bgr888_nb( arr, @@ -333,4 +465,25 @@ def yuv_to_bgr888_nb( matrix[2][2], ) + if fmt == PixelFormats.NV61: + return _nv61_to_bgr888_nb( + arr, + w, + h, + strides[0], + strides[1], + offset[0], + offset[1], + offset[2], + matrix[0][0], + matrix[0][1], + matrix[0][2], + matrix[1][0], + matrix[1][1], + matrix[1][2], + matrix[2][0], + matrix[2][1], + matrix[2][2], + ) + raise RuntimeError(f'Unsupported YUV format {fmt}') diff --git a/tests/test_conv_data.py b/tests/test_conv_data.py index 2cd774f..d020d67 100644 --- a/tests/test_conv_data.py +++ b/tests/test_conv_data.py @@ -496,6 +496,54 @@ '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', '3e17faada30f1762eafa3f48f7dea145aefc3e09fcec33777495682e8026b67e', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV21, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '8da8c5a50cf708b9e9cf446c5832fe1160accae10818c6bab502ae7ca277d52f', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV21, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '7b36dde7385d79954971d4b9b0a16331810221fe142bed6e110cfdfbe0786794', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV21, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '942bb99394c78300fa0ee7d9d41a0b583ef54ced96979285b5b000b00708122b', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV21, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '26a77caab5a0014b7cfda89e0f51efb727b0d863d536d7959ea3743d69f2a62d', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV21, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + 'b603e28a1d62496213eddc49a2ee1139a437b49da1bd2b84666efad2c03142e5', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV21, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '7329a3c46fea5e007e738478975743e754bf766df267b3ed1c973a30646de259', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV21, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '8da8c5a50cf708b9e9cf446c5832fe1160accae10818c6bab502ae7ca277d52f', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV21, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '7b36dde7385d79954971d4b9b0a16331810221fe142bed6e110cfdfbe0786794', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV21, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '942bb99394c78300fa0ee7d9d41a0b583ef54ced96979285b5b000b00708122b', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV21, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '26a77caab5a0014b7cfda89e0f51efb727b0d863d536d7959ea3743d69f2a62d', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV21, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + 'b603e28a1d62496213eddc49a2ee1139a437b49da1bd2b84666efad2c03142e5', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV21, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '7329a3c46fea5e007e738478975743e754bf766df267b3ed1c973a30646de259', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV16, '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', '5ce7a4094e84ce0672d73ae75816fef12bdabd9d790e9f40d45642a293b9b108', @@ -544,6 +592,54 @@ '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', 'a9a44c1ce81fc893daa4124b9520f3b2fd2799888faa45a358e92794f6eabcb7', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV61, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '2009ce5b2d82047f8863e6ce64c0ddce8bd00802376c983fd5bb2744881b12f1', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV61, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '30e73d96e67223acbb369b18ec903597f288b6afc07717a1fd91495b912384ed', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV61, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + 'fb21ee6953730c7870352499ffbdd1babfd99ceb2a867c1f2cec0456b9a8ad9c', + {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV61, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '63c4f26f4e20db5ca511c86afe66905c308a8eb0a7e79b14ba229c4dad31e1d9', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV61, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '0cd6c5ce61d9f0a69a8dbfe04de56f900e1760ec4ca18962a62294793185aff7', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV61, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '97081b8b6f83760d74b0c65048606814e7401ec76ece47784c44699c7fe8fd0c', + {'backends': ['numba'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV61, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '2009ce5b2d82047f8863e6ce64c0ddce8bd00802376c983fd5bb2744881b12f1', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV61, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '30e73d96e67223acbb369b18ec903597f288b6afc07717a1fd91495b912384ed', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV61, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + 'fb21ee6953730c7870352499ffbdd1babfd99ceb2a867c1f2cec0456b9a8ad9c', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.NV61, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '63c4f26f4e20db5ca511c86afe66905c308a8eb0a7e79b14ba229c4dad31e1d9', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.NV61, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '0cd6c5ce61d9f0a69a8dbfe04de56f900e1760ec4ca18962a62294793185aff7', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.NV61, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '97081b8b6f83760d74b0c65048606814e7401ec76ece47784c44699c7fe8fd0c', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.Y8, '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', '58226da199a89c0bb0405ba764ce314513f6303ff19f3d25b936b0a82714b3d8', From 6c9c13b6ec2fe97e695dd7ca9811b28375604261 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 13:21:28 +0300 Subject: [PATCH 34/44] conv: numpy: Combine NVxx converters Signed-off-by: Tomi Valkeinen --- pixutils/conv/yuv.py | 87 ++++++++++++++------------------------------ 1 file changed, 27 insertions(+), 60 deletions(-) diff --git a/pixutils/conv/yuv.py b/pixutils/conv/yuv.py index dcfb711..6faf6cc 100644 --- a/pixutils/conv/yuv.py +++ b/pixutils/conv/yuv.py @@ -121,70 +121,37 @@ def uyvy_to_bgr888( return ycbcr_to_bgr888(yuv, options) -def nv12_to_bgr888( - data: npt.NDArray[np.uint8], w: int, h: int, y_stride: int, uv_stride: 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]: y = as_strided(data, shape=(h, w), strides=(y_stride, 1), writeable=False) uv = as_strided( - data[y_stride * h :], shape=(h // 2, w // 2, 2), strides=(uv_stride, 2, 1), writeable=False + data[y_stride * h :], + shape=(h // v_subsample, w // 2, 2), + strides=(uv_stride, 2, 1), + writeable=False, ) - # 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 - - return ycbcr_to_bgr888(yuv, options) - - -def nv21_to_bgr888( - data: npt.NDArray[np.uint8], w: int, h: int, y_stride: int, uv_stride: int, options: dict | None -) -> npt.NDArray[np.uint8]: - y = as_strided(data, shape=(h, w), strides=(y_stride, 1), writeable=False) - uv = as_strided( - data[y_stride * h :], shape=(h // 2, w // 2, 2), strides=(uv_stride, 2, 1), writeable=False - ) + u_idx = 0 if u_first else 1 + v_idx = 1 - u_idx - # YUV444 yuv = np.empty((h, w, 3), dtype=np.uint8) - yuv[:, :, 0] = y[:, :] # Y - yuv[:, :, 1] = uv[:, :, 1].repeat(2, axis=0).repeat(2, axis=1) # U - yuv[:, :, 2] = uv[:, :, 0].repeat(2, axis=0).repeat(2, axis=1) # V + yuv[:, :, 0] = y - return ycbcr_to_bgr888(yuv, options) - - -def nv16_to_bgr888( - data: npt.NDArray[np.uint8], w: int, h: int, y_stride: int, uv_stride: int, options: dict | None -) -> npt.NDArray[np.uint8]: - y = as_strided(data, shape=(h, w), strides=(y_stride, 1), writeable=False) - uv = as_strided( - data[y_stride * h :], shape=(h, w // 2, 2), strides=(uv_stride, 2, 1), writeable=False - ) - - # YUV444 - yuv = np.empty((h, w, 3), dtype=np.uint8) - yuv[:, :, 0] = y[:, :] # Y - yuv[:, :, 1] = uv[:, :, 0].repeat(2, axis=1) # U - yuv[:, :, 2] = uv[:, :, 1].repeat(2, axis=1) # V - - return ycbcr_to_bgr888(yuv, options) - - -def nv61_to_bgr888( - data: npt.NDArray[np.uint8], w: int, h: int, y_stride: int, uv_stride: int, options: dict | None -) -> npt.NDArray[np.uint8]: - y = as_strided(data, shape=(h, w), strides=(y_stride, 1), writeable=False) - uv = as_strided( - data[y_stride * h :], shape=(h, w // 2, 2), strides=(uv_stride, 2, 1), writeable=False - ) - - # YUV444 - yuv = np.empty((h, w, 3), dtype=np.uint8) - yuv[:, :, 0] = y[:, :] # Y - yuv[:, :, 1] = uv[:, :, 1].repeat(2, axis=1) # U - yuv[:, :, 2] = uv[:, :, 0].repeat(2, axis=1) # V + 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) @@ -227,15 +194,15 @@ def yuv_to_bgr888( return uyvy_to_bgr888(arr, w, h, strides[0], options) if fmt == PixelFormats.NV12: - return nv12_to_bgr888(arr, w, h, strides[0], strides[1], options) + return nv_to_bgr888(arr, w, h, strides[0], strides[1], 2, True, options) if fmt == PixelFormats.NV21: - return nv21_to_bgr888(arr, w, h, strides[0], strides[1], options) + return nv_to_bgr888(arr, w, h, strides[0], strides[1], 2, False, options) if fmt == PixelFormats.NV16: - return nv16_to_bgr888(arr, w, h, strides[0], strides[1], options) + return nv_to_bgr888(arr, w, h, strides[0], strides[1], 1, True, options) if fmt == PixelFormats.NV61: - return nv61_to_bgr888(arr, w, h, strides[0], strides[1], options) + return nv_to_bgr888(arr, w, h, strides[0], strides[1], 1, False, options) raise RuntimeError(f'Unsupported YUV format {fmt}') From 24e715e39fd4062b7424a0eeb264583e97317c69 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 13:24:44 +0300 Subject: [PATCH 35/44] conv: numba: Combine NVxx converters Signed-off-by: Tomi Valkeinen --- pixutils/conv/yuv_nb.py | 263 ++++------------------------------------ 1 file changed, 25 insertions(+), 238 deletions(-) diff --git a/pixutils/conv/yuv_nb.py b/pixutils/conv/yuv_nb.py index 8f239ec..79d0f3f 100644 --- a/pixutils/conv/yuv_nb.py +++ b/pixutils/conv/yuv_nb.py @@ -130,12 +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, @@ -149,189 +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""" - rgb = np.empty((height, width, 3), dtype=np.uint8) - - # NV12 layout: Y plane followed by interleaved UV plane - y_plane_offset = y_stride * height - - for y in range(height): - for x in range(width): - y_val = data[y * y_stride + x] - - # Get UV values from chroma plane (subsampled by 2x2) - uv_y = y // 2 - uv_x = x // 2 - uv_idx = y_plane_offset + uv_y * uv_stride + uv_x * 2 - - u = data[uv_idx + 0] - v = data[uv_idx + 1] - - # Apply offsets - y_adj = y_val + offset_y - u_adj = u + offset_u - v_adj = v + offset_v - - # Matrix multiplication: [Y U V] × Matrix (column-wise produces BGR) - b = m00 * y_adj + m10 * u_adj + m20 * v_adj - g = m01 * y_adj + m11 * u_adj + m21 * v_adj - r = m02 * y_adj + m12 * u_adj + m22 * v_adj - - # Clip and store as BGR - rgb[y, x, 0] = max(0, min(255, int(b))) # B - rgb[y, x, 1] = max(0, min(255, int(g))) # G - rgb[y, x, 2] = max(0, min(255, int(r))) # R - - return rgb - - -@njit(cache=True) -def _nv21_to_bgr888_nb( - data: npt.NDArray[np.uint8], - width: int, - height: int, - y_stride: int, - uv_stride: int, - offset_y: float, - offset_u: float, - offset_v: float, - m00: float, - m01: float, - m02: float, - m10: float, - m11: float, - m12: float, - m20: float, - m21: float, - m22: float, -) -> npt.NDArray[np.uint8]: - """JIT-compiled NV21 to BGR conversion with custom chroma upsampling""" - rgb = np.empty((height, width, 3), dtype=np.uint8) - - # NV21 layout: Y plane followed by interleaved VU plane - y_plane_offset = y_stride * height - - for y in range(height): - for x in range(width): - y_val = data[y * y_stride + x] - - # Get UV values from chroma plane (subsampled by 2x2) - uv_y = y // 2 - uv_x = x // 2 - uv_idx = y_plane_offset + uv_y * uv_stride + uv_x * 2 - - v = data[uv_idx + 0] - u = data[uv_idx + 1] - - # Apply offsets - y_adj = y_val + offset_y - u_adj = u + offset_u - v_adj = v + offset_v - - # Matrix multiplication: [Y U V] × Matrix (column-wise produces BGR) - b = m00 * y_adj + m10 * u_adj + m20 * v_adj - g = m01 * y_adj + m11 * u_adj + m21 * v_adj - r = m02 * y_adj + m12 * u_adj + m22 * v_adj - - # Clip and store as BGR - rgb[y, x, 0] = max(0, min(255, int(b))) # B - rgb[y, x, 1] = max(0, min(255, int(g))) # G - rgb[y, x, 2] = max(0, min(255, int(r))) # R - - return rgb - + """JIT-compiled NV12/NV21/NV16/NV61 to BGR conversion. -@njit(cache=True) -def _nv16_to_bgr888_nb( - data: npt.NDArray[np.uint8], - width: int, - height: int, - y_stride: int, - uv_stride: int, - offset_y: float, - offset_u: float, - offset_v: float, - m00: float, - m01: float, - m02: float, - m10: float, - m11: float, - m12: float, - m20: float, - m21: float, - m22: float, -) -> npt.NDArray[np.uint8]: - """JIT-compiled NV16 to BGR conversion with custom chroma upsampling""" + 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) - # NV16 layout: Y plane followed by interleaved UV plane (4:2:2) y_plane_offset = y_stride * height for y in range(height): - for x in range(width): - y_val = data[y * y_stride + x] - - # Get UV values from chroma plane (subsampled 2x horizontally only) - uv_x = x // 2 - uv_idx = y_plane_offset + y * uv_stride + uv_x * 2 - - u = data[uv_idx + 0] - v = data[uv_idx + 1] - - # Apply offsets - y_adj = y_val + offset_y - u_adj = u + offset_u - v_adj = v + offset_v + uv_row_base = y_plane_offset + (y // v_subsample) * uv_stride - # Matrix multiplication: [Y U V] × Matrix (column-wise produces BGR) - b = m00 * y_adj + m10 * u_adj + m20 * v_adj - g = m01 * y_adj + m11 * u_adj + m21 * v_adj - r = m02 * y_adj + m12 * u_adj + m22 * v_adj - - # Clip and store as BGR - rgb[y, x, 0] = max(0, min(255, int(b))) # B - rgb[y, x, 1] = max(0, min(255, int(g))) # G - rgb[y, x, 2] = max(0, min(255, int(r))) # R - - return rgb - - -@njit(cache=True) -def _nv61_to_bgr888_nb( - data: npt.NDArray[np.uint8], - width: int, - height: int, - y_stride: int, - uv_stride: int, - offset_y: float, - offset_u: float, - offset_v: float, - m00: float, - m01: float, - m02: float, - m10: float, - m11: float, - m12: float, - m20: float, - m21: float, - m22: float, -) -> npt.NDArray[np.uint8]: - """JIT-compiled NV61 to BGR conversion with custom chroma upsampling""" - rgb = np.empty((height, width, 3), dtype=np.uint8) - - # NV61 layout: Y plane followed by interleaved VU plane (4:2:2) - y_plane_offset = y_stride * height - - for y in range(height): for x in range(width): y_val = data[y * y_stride + x] - # Get UV values from chroma plane (subsampled 2x horizontally only) - uv_x = x // 2 - uv_idx = y_plane_offset + y * uv_stride + uv_x * 2 - - v = data[uv_idx + 0] - u = 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 @@ -402,76 +241,24 @@ def yuv_to_bgr888_nb( matrix[2][2], ) - if fmt == PixelFormats.NV12: - return _nv12_to_bgr888_nb( - arr, - w, - h, - strides[0], - strides[1], - offset[0], - offset[1], - offset[2], - matrix[0][0], - matrix[0][1], - matrix[0][2], - matrix[1][0], - matrix[1][1], - matrix[1][2], - matrix[2][0], - matrix[2][1], - matrix[2][2], - ) - - if fmt == PixelFormats.NV21: - return _nv21_to_bgr888_nb( - arr, - w, - h, - strides[0], - strides[1], - offset[0], - offset[1], - offset[2], - matrix[0][0], - matrix[0][1], - matrix[0][2], - matrix[1][0], - matrix[1][1], - matrix[1][2], - matrix[2][0], - matrix[2][1], - matrix[2][2], - ) - - if fmt == PixelFormats.NV16: - return _nv16_to_bgr888_nb( - arr, - w, - h, - strides[0], - strides[1], - offset[0], - offset[1], - offset[2], - matrix[0][0], - matrix[0][1], - matrix[0][2], - matrix[1][0], - matrix[1][1], - matrix[1][2], - matrix[2][0], - matrix[2][1], - matrix[2][2], - ) + nv_params = { + PixelFormats.NV12: (2, 0, 1), + PixelFormats.NV21: (2, 1, 0), + PixelFormats.NV16: (1, 0, 1), + PixelFormats.NV61: (1, 1, 0), + } - if fmt == PixelFormats.NV61: - return _nv61_to_bgr888_nb( + 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], From 38bb721fe61dac2ce8c6f87f724c43ea92f4f476 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 13:42:34 +0300 Subject: [PATCH 36/44] conv: Add YUV420, YUV422, YVU420, YVU422 conversions Signed-off-by: Tomi Valkeinen --- pixutils/conv/yuv.py | 52 ++++++++++++++++++++++ tests/test_conv_data.py | 96 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/pixutils/conv/yuv.py b/pixutils/conv/yuv.py index 6faf6cc..1c9f2b6 100644 --- a/pixutils/conv/yuv.py +++ b/pixutils/conv/yuv.py @@ -156,6 +156,42 @@ def nv_to_bgr888( 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) + + yuv = np.empty((h, w, 3), dtype=np.uint8) + 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, stride: int, options: dict | None ) -> npt.NDArray[np.uint8]: @@ -205,4 +241,20 @@ def yuv_to_bgr888( 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}') diff --git a/tests/test_conv_data.py b/tests/test_conv_data.py index d020d67..a3457c0 100644 --- a/tests/test_conv_data.py +++ b/tests/test_conv_data.py @@ -640,6 +640,102 @@ '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', '97081b8b6f83760d74b0c65048606814e7401ec76ece47784c44699c7fe8fd0c', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YUV420, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '8199ba6b484690fa02f332676bb7eaee9fc8206e9969349c89724c23bad4d913', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YUV420, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '97e4be28c479839f9b98488ac94e979c3a2db903efb210f9d36185879c457b45', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YUV420, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '0c93cde031f7c43472a7199adcfb0933562d738b0a7d286d8d079c6368d4ae63', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YUV420, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '1fb4e6a83fa604dce9638cde3909e9cd40d4d544adcaefc5607c60aa1b274b80', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YUV420, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '8a235a076c169f6f35c6e4d60ff38a537a77ff643ba89ca36714c7222ab474a4', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YUV420, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + 'f906fc96ca54c190a2b7937390b47a1b94e8d284b2d314711ee4bc4975afe9a0', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YVU420, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '174bbe3361e1721c57d3668e5849c7d07ccfd56e354cf1e878efefa11f3b5b71', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YVU420, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '15865735a0d0d2995bfec1426036535160ec53762559b2346daecce544e26e5a', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YVU420, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + '8d49c2b0364587942ea5aa2cd062d5f04c500dce6367a780278616c43f0a6212', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YVU420, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + 'cf7c5eea10b928ff82fee0ebcc0e692f8d096472c65f96088165989b09a4f8a7', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YVU420, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + 'ff5e54e2a6608fadabf4f93ccc210b753f320c6d7e74c09bbe3e5a6b0c0f68a9', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YVU420, + '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', + 'd26e73f80e6e1db245403f3f58a25a0a7c61e875a8c0bea60f304611c1117b6b', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YUV422, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + 'b0a265abd0b07f0c2b940d2a668b59b01706f5819b4a39ba56b0097f7d5df5bb', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YUV422, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '4dcaf4193d89106fec0828a67012b9f66e300f5da07fe98a63b2f89826407502', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YUV422, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '47cfc33f7e6aad2660c5607a5c004c1a9350b2beccdef9712bb6be6c828c434f', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YUV422, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + 'ad2a6219a4b628a39cd6ccfa8604a521404ee52bff8160c82190c5852a13abc3', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YUV422, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '88d3d74f5a8d7423e33b9a95465935b337588e58077f1e04151d91b1d523bfba', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YUV422, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + 'f24c7273f52ff1b68803ea09b8ae1e4694c2a477285517cf8aca0847cfb4493e', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YVU422, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + 'c6200b02bbca4843275abd74fa64267c819addaafe1e2e764f1b4a62f7fde7db', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YVU422, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '9d7554c98ddf2b432f34209e751345039cbe3d6a5730e7557d84eeecebe265b9', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YVU422, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '142d12e3083e1b92207e82c218f70d7a6b5dee324a7ddc96f4c27899e05499e0', + {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), + ConvTestCase(PixelFormats.YVU422, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + 'f55f1533ef502d1d90773ccbd6e42e5e81cf37f786e61c501816c08b06bd1711', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), + ConvTestCase(PixelFormats.YVU422, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + '2b60c332f26d7e05b01ab1ee009cacc70968353c65dd90e4af4a642f41dd0282', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), + ConvTestCase(PixelFormats.YVU422, + '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', + 'ea3859985ab4a657d2de9020c5c27572651b9705c1b73fa5effdba08ec793113', + {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.Y8, '0617515ed5db0a0ce1945ddd1887d7616137055d424199eddc71dceece53a740', '58226da199a89c0bb0405ba764ce314513f6303ff19f3d25b936b0a82714b3d8', From a3474ab3b25e5746b5b6878abdfdede282222cc8 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 20:20:28 +0300 Subject: [PATCH 37/44] tests: Change test frame width to be more divisible Signed-off-by: Tomi Valkeinen --- tests/test_backend_compat.py | 13 +- tests/test_conv.py | 7 +- tests/test_conv_data.py | 736 +++++++++++++++++------------------ 3 files changed, 381 insertions(+), 375 deletions(-) diff --git a/tests/test_backend_compat.py b/tests/test_backend_compat.py index ae2c804..999cc45 100644 --- a/tests/test_backend_compat.py +++ b/tests/test_backend_compat.py @@ -65,12 +65,13 @@ def _format_options(fmt: PixelFormat) -> dict: # A small probe size used at test-discovery time to find out which backends -# actually handle a given format. 32 is divisible by every common pixel -# alignment (2, 4, 8, 16, 32), so almost every format accepts it. The buffer -# is sized as 32×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 = 32 +# 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) diff --git a/tests/test_conv.py b/tests/test_conv.py index 1aa3057..1de234c 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 diff --git a/tests/test_conv_data.py b/tests/test_conv_data.py index a3457c0..b2baa3f 100644 --- a/tests/test_conv_data.py +++ b/tests/test_conv_data.py @@ -6,743 +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', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '6982eefccba5442abf921b6371ca57b8a4ef224c7c024ab5f7c7935854249fd7', + '44f12c50ca6aca845c69a72ca8dc06c66878287698be23e1c4635640c73bcac0', {'backends': ['opencv']}), ConvTestCase(PixelFormats.XRGB8888, - 'd555e8545b743011df3c27f0a7056552b302a9089b7b6d8d9562402c38c40f1e', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '6982eefccba5442abf921b6371ca57b8a4ef224c7c024ab5f7c7935854249fd7', + '44f12c50ca6aca845c69a72ca8dc06c66878287698be23e1c4635640c73bcac0', {'backends': ['numpy']}), ConvTestCase(PixelFormats.XBGR8888, - 'd555e8545b743011df3c27f0a7056552b302a9089b7b6d8d9562402c38c40f1e', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '6982eefccba5442abf921b6371ca57b8a4ef224c7c024ab5f7c7935854249fd7', + '8e197ce5cacca3137c809708fb4ec576ab4a3a860ebe565c1413514fa3871c02', {'backends': ['opencv']}), ConvTestCase(PixelFormats.XBGR8888, - 'd555e8545b743011df3c27f0a7056552b302a9089b7b6d8d9562402c38c40f1e', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '6982eefccba5442abf921b6371ca57b8a4ef224c7c024ab5f7c7935854249fd7', + '8e197ce5cacca3137c809708fb4ec576ab4a3a860ebe565c1413514fa3871c02', {'backends': ['numpy']}), ConvTestCase(PixelFormats.RGBX8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '5dde07cf9a57636662174ee57f21d64534d82a43635b06f99b115e5bf92b64fd', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + 'f54a7feac1c03623767e92ad64d7ca69d6a51c13e4ca37e59aa602fc30089910', {'backends': ['opencv']}), ConvTestCase(PixelFormats.BGRX8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '94bf56332bf148f9328281f9c23ef88a6aef5c726344c40390052a0a7266ed15', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '85d677ee00b3becf8cad690c1442e312b6824785c48498b7e263c094b83452e7', {'backends': ['opencv']}), ConvTestCase(PixelFormats.XBGR2101010, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '1161cd5862122522010431db27a517ad57dea4e7efe54d647bb9e17bb030aee8', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '91fa2a9252427a35b517400baa0d60d4e1f4ed9c62aad8ea3238e97fdf636a54', {'backends': ['numpy']}), ConvTestCase(PixelFormats.ARGB8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '44f12c50ca6aca845c69a72ca8dc06c66878287698be23e1c4635640c73bcac0', {'backends': ['opencv']}), ConvTestCase(PixelFormats.ARGB8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '5cd101d48be291acd03a7ce2443cebb081f9d6489c8213960b8fbb49274e3e3b', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '44f12c50ca6aca845c69a72ca8dc06c66878287698be23e1c4635640c73bcac0', {'backends': ['numpy']}), ConvTestCase(PixelFormats.ABGR8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '8e197ce5cacca3137c809708fb4ec576ab4a3a860ebe565c1413514fa3871c02', {'backends': ['opencv']}), ConvTestCase(PixelFormats.ABGR8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '366abaf8289cef57ee44410a7468189ece1040821073df18255e126196fad03d', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + '8e197ce5cacca3137c809708fb4ec576ab4a3a860ebe565c1413514fa3871c02', {'backends': ['numpy']}), ConvTestCase(PixelFormats.RGBA8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '5dde07cf9a57636662174ee57f21d64534d82a43635b06f99b115e5bf92b64fd', + '6c46ce9fbc06ac6f680e6c8736d00667e29189afde0bcd6d75a7a5e047194787', + 'f54a7feac1c03623767e92ad64d7ca69d6a51c13e4ca37e59aa602fc30089910', {'backends': ['opencv']}), ConvTestCase(PixelFormats.BGRA8888, - 'c41bc1ebefb5e4b6b187197b28a65a7ff758d8ad4ed2c542e8e3da556eb51f73', - '94bf56332bf148f9328281f9c23ef88a6aef5c726344c40390052a0a7266ed15', + '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', - '9595bd083ca1587b8eab34699d2b0aad78be29879243c55a034eadc1e5520b18', + '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', - 'f742ae0b43dd138ce9375e34532dbfd4f6beb85241d715b9f82419d9aa20ff60', + '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', - 'a6bcd68b20ca0e1397c65d582b21deaf5c3a3cf9e4e35776db0f28b7204a27c2', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'ef5efaa19f73ae483a018248300a00a003d392348e9229bc6139343219144fec', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV12, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '4da513e1220b1491bcb1981ba0e7fb746037bfada3bdad7030204da168687f28', + '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', - '3e17faada30f1762eafa3f48f7dea145aefc3e09fcec33777495682e8026b67e', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'fc0a0560e37470f31ce8107fa360efd02e05777c2ac5b5cc3f843ad11e5b3923', {'backends': ['opencv'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '8da8c5a50cf708b9e9cf446c5832fe1160accae10818c6bab502ae7ca277d52f', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '7a95561c3cf7ad4d088dba6299424e90776101c009a2fe17b36f0ac483f71cab', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '7b36dde7385d79954971d4b9b0a16331810221fe142bed6e110cfdfbe0786794', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '53d069bc282ca54d2c547ff5a206bc4927ffa64a3b3638ee3f2e396698956f48', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '942bb99394c78300fa0ee7d9d41a0b583ef54ced96979285b5b000b00708122b', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'f13572ba3ca9e1e6de56a303f7bb9cd61731465cd83ee464c0a77e3635c29191', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '26a77caab5a0014b7cfda89e0f51efb727b0d863d536d7959ea3743d69f2a62d', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '9731a2991fa933274aa4ca53298fbf9d7160755f139a99e8f88a44fb842b84a1', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'b603e28a1d62496213eddc49a2ee1139a437b49da1bd2b84666efad2c03142e5', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '6ddfe0ecc87ebbb2788aaeadd18de2489d7264455b7cb0512750d18e3fd57e36', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '7329a3c46fea5e007e738478975743e754bf766df267b3ed1c973a30646de259', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'b10d9b950e323b96277fbf311c323c7f5bf1e23abaac1a461ff99cd1fb5cb60d', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '8da8c5a50cf708b9e9cf446c5832fe1160accae10818c6bab502ae7ca277d52f', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '7a95561c3cf7ad4d088dba6299424e90776101c009a2fe17b36f0ac483f71cab', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '7b36dde7385d79954971d4b9b0a16331810221fe142bed6e110cfdfbe0786794', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '53d069bc282ca54d2c547ff5a206bc4927ffa64a3b3638ee3f2e396698956f48', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '942bb99394c78300fa0ee7d9d41a0b583ef54ced96979285b5b000b00708122b', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'f13572ba3ca9e1e6de56a303f7bb9cd61731465cd83ee464c0a77e3635c29191', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '26a77caab5a0014b7cfda89e0f51efb727b0d863d536d7959ea3743d69f2a62d', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '9731a2991fa933274aa4ca53298fbf9d7160755f139a99e8f88a44fb842b84a1', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'b603e28a1d62496213eddc49a2ee1139a437b49da1bd2b84666efad2c03142e5', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '6ddfe0ecc87ebbb2788aaeadd18de2489d7264455b7cb0512750d18e3fd57e36', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV21, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '7329a3c46fea5e007e738478975743e754bf766df267b3ed1c973a30646de259', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'b10d9b950e323b96277fbf311c323c7f5bf1e23abaac1a461ff99cd1fb5cb60d', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '5ce7a4094e84ce0672d73ae75816fef12bdabd9d790e9f40d45642a293b9b108', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '39200b9169545a51f5295c3fd20183b6749cbec75bae65eb45fb176fa38afb80', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '388c1f4457a0101a7812201e9de1c39a64c521e340ed8014a2ac4d113f718536', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '65dde8098b8abddf73df567c722faeb8dab29ed13bed28e83e18c0fa79d3bcfb', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '8e2ae09bf171635e671a543a0b8da2de673512959b1bc5f2066da23ecf6eff96', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '1f6ad8b53040071a709b8951d8d23d4e624c42080107a9dfb51495f2abeaec4e', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'b0aad543efee4f4770b052a3073e126e0ee04dacef5d551015a8878e722dac62', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'd90215e8ba19e4aa20caf228abb478cf7beb11911feb6090c006cd7755a92b63', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '63ab860545f8b5205dd7649264d134415ae94cad97828d2000e5e900e5b3c323', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'c8c16dda9f075ecb615471f5992bcc6f915da2bf07c0861859ba2bb2851398d2', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'a9a44c1ce81fc893daa4124b9520f3b2fd2799888faa45a358e92794f6eabcb7', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '6c2d81fca27699302c09d4ae18e588a8542273b5c2f580f90d074f84f013043d', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '5ce7a4094e84ce0672d73ae75816fef12bdabd9d790e9f40d45642a293b9b108', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '39200b9169545a51f5295c3fd20183b6749cbec75bae65eb45fb176fa38afb80', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '388c1f4457a0101a7812201e9de1c39a64c521e340ed8014a2ac4d113f718536', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '65dde8098b8abddf73df567c722faeb8dab29ed13bed28e83e18c0fa79d3bcfb', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '8e2ae09bf171635e671a543a0b8da2de673512959b1bc5f2066da23ecf6eff96', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '1f6ad8b53040071a709b8951d8d23d4e624c42080107a9dfb51495f2abeaec4e', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'b0aad543efee4f4770b052a3073e126e0ee04dacef5d551015a8878e722dac62', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'd90215e8ba19e4aa20caf228abb478cf7beb11911feb6090c006cd7755a92b63', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '63ab860545f8b5205dd7649264d134415ae94cad97828d2000e5e900e5b3c323', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'c8c16dda9f075ecb615471f5992bcc6f915da2bf07c0861859ba2bb2851398d2', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV16, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'a9a44c1ce81fc893daa4124b9520f3b2fd2799888faa45a358e92794f6eabcb7', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '6c2d81fca27699302c09d4ae18e588a8542273b5c2f580f90d074f84f013043d', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV61, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '2009ce5b2d82047f8863e6ce64c0ddce8bd00802376c983fd5bb2744881b12f1', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'dac86f5051608f1c9c9e3d4bc35a751a353742de78835c96946d105951126a4a', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV61, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '30e73d96e67223acbb369b18ec903597f288b6afc07717a1fd91495b912384ed', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '843448b2b8c6f42676a821bd5ad7a5c60862d8993ba283a7b4e10e09fb92e44c', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV61, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'fb21ee6953730c7870352499ffbdd1babfd99ceb2a867c1f2cec0456b9a8ad9c', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'dfd74c4a87b5b9a5fd408240c148b58df743bb91c19a946b9e5bcc785c026791', {'backends': ['numba'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV61, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '63c4f26f4e20db5ca511c86afe66905c308a8eb0a7e79b14ba229c4dad31e1d9', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '567c5890cb62f257150793e05ae17e7cc971a8b2d6f11866b465f1b1773dc1f0', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV61, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '0cd6c5ce61d9f0a69a8dbfe04de56f900e1760ec4ca18962a62294793185aff7', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'a4ac51a574a3ca645c28fa47f2222471572af9866479f73bf6a86ea309606bcc', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV61, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '97081b8b6f83760d74b0c65048606814e7401ec76ece47784c44699c7fe8fd0c', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '9efda124f2f9ad3b862266a95127cae37c93691b94de54ea4f52fbb20b67b538', {'backends': ['numba'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV61, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '2009ce5b2d82047f8863e6ce64c0ddce8bd00802376c983fd5bb2744881b12f1', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'dac86f5051608f1c9c9e3d4bc35a751a353742de78835c96946d105951126a4a', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV61, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '30e73d96e67223acbb369b18ec903597f288b6afc07717a1fd91495b912384ed', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '843448b2b8c6f42676a821bd5ad7a5c60862d8993ba283a7b4e10e09fb92e44c', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV61, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'fb21ee6953730c7870352499ffbdd1babfd99ceb2a867c1f2cec0456b9a8ad9c', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'dfd74c4a87b5b9a5fd408240c148b58df743bb91c19a946b9e5bcc785c026791', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.NV61, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '63c4f26f4e20db5ca511c86afe66905c308a8eb0a7e79b14ba229c4dad31e1d9', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '567c5890cb62f257150793e05ae17e7cc971a8b2d6f11866b465f1b1773dc1f0', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.NV61, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '0cd6c5ce61d9f0a69a8dbfe04de56f900e1760ec4ca18962a62294793185aff7', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'a4ac51a574a3ca645c28fa47f2222471572af9866479f73bf6a86ea309606bcc', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.NV61, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '97081b8b6f83760d74b0c65048606814e7401ec76ece47784c44699c7fe8fd0c', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '9efda124f2f9ad3b862266a95127cae37c93691b94de54ea4f52fbb20b67b538', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YUV420, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '8199ba6b484690fa02f332676bb7eaee9fc8206e9969349c89724c23bad4d913', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'd7e9c576657afa613dfc6e86bcc8b38fc7588265056da22d2dbb8fd78385d15a', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YUV420, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '97e4be28c479839f9b98488ac94e979c3a2db903efb210f9d36185879c457b45', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'ccde0554847023ee7243a2a04174b65ad014531b646e4267d7dc324d96a12e20', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.YUV420, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '0c93cde031f7c43472a7199adcfb0933562d738b0a7d286d8d079c6368d4ae63', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '1c431924149bff7cbe60ec0725ff60529e358af2748d91213a0e58a5ed055fe2', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YUV420, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '1fb4e6a83fa604dce9638cde3909e9cd40d4d544adcaefc5607c60aa1b274b80', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + 'b6c583c7f7b5bc46eb3e3d7622f0c93d026da393a76766e395240aa46f725cde', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YUV420, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '8a235a076c169f6f35c6e4d60ff38a537a77ff643ba89ca36714c7222ab474a4', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '00d48ea5ae0b9c27601f9f3845082a375ea07556caf792619f9f4564f072d7e9', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.YUV420, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'f906fc96ca54c190a2b7937390b47a1b94e8d284b2d314711ee4bc4975afe9a0', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '9def3464ba807ced27a4f37e1de11198f01e02edcec49623201f345e3a2759b9', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YVU420, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '174bbe3361e1721c57d3668e5849c7d07ccfd56e354cf1e878efefa11f3b5b71', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '48acd74eb0d50a4cfd36da0a1177e9a693f129137d292c1be47bf7d32a55e9e1', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YVU420, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '15865735a0d0d2995bfec1426036535160ec53762559b2346daecce544e26e5a', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '325ece9517437169046fbf3ebab728b71cdaf2ddefd24251afd2d57debe98915', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.YVU420, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - '8d49c2b0364587942ea5aa2cd062d5f04c500dce6367a780278616c43f0a6212', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '4b9848faea889aba72a836e1f826638e90290122a2f95bf645aeacfa2a32d9a9', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YVU420, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'cf7c5eea10b928ff82fee0ebcc0e692f8d096472c65f96088165989b09a4f8a7', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '1898853e75889b4820c58b05b7492b8b4b7a9e690df710af36a795c6007b94e0', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YVU420, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'ff5e54e2a6608fadabf4f93ccc210b753f320c6d7e74c09bbe3e5a6b0c0f68a9', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '9c5b3f47708460eca6b5797a3e93964ef60190e774e9867c1cf491bb11dd3627', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.YVU420, - '7d75356fc0ab885264f4ba453fc0c9eec23b70412fd3a8c261eb0d7ed5a1ea77', - 'd26e73f80e6e1db245403f3f58a25a0a7c61e875a8c0bea60f304611c1117b6b', + '483e4a054fac35411a8c7014bb2f62b96593f2be13ba1bdc73a338e2133a0fd4', + '92231adfddf09dbfa4a48e22a7caeae82f9d6413c928c7536278b98e5395f385', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YUV422, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'b0a265abd0b07f0c2b940d2a668b59b01706f5819b4a39ba56b0097f7d5df5bb', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '03bf75a85a3c17d6dd8857fcae719f96a30e2fb4270cd6f4147d2c9a6310d2aa', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YUV422, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '4dcaf4193d89106fec0828a67012b9f66e300f5da07fe98a63b2f89826407502', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '46d8c87cfe42057f642da5f388c3c341011db8fb66f8fde5c16ee81735d57bf1', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.YUV422, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '47cfc33f7e6aad2660c5607a5c004c1a9350b2beccdef9712bb6be6c828c434f', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '0be4560b45ade606754263f9c9b276852281a08c54b4f6bf186c4c6d8607b616', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YUV422, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'ad2a6219a4b628a39cd6ccfa8604a521404ee52bff8160c82190c5852a13abc3', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '87b1b0d707e71185f82639241b61d1b0fbe859a861713f8089b735c3eeb1a8c7', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YUV422, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '88d3d74f5a8d7423e33b9a95465935b337588e58077f1e04151d91b1d523bfba', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '4a73d260c23893d44af9b25a6a24d53fde24e331b610a5406595ad4f05902670', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.YUV422, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'f24c7273f52ff1b68803ea09b8ae1e4694c2a477285517cf8aca0847cfb4493e', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '59ea8f3ae06f9b51129f2101b711e4ab05c9c57f74262ea4b6df1c2944415f1f', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YVU422, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'c6200b02bbca4843275abd74fa64267c819addaafe1e2e764f1b4a62f7fde7db', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '3ecdbb53c3a6139992e7a367a3f4d27eb59e18a3cc56ca932dc182e9543b7e29', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YVU422, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '9d7554c98ddf2b432f34209e751345039cbe3d6a5730e7557d84eeecebe265b9', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'e452974b9b38fc849dd2e9840ccaa4c4a03eab8a718692a815809a9304b83cb3', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.YVU422, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '142d12e3083e1b92207e82c218f70d7a6b5dee324a7ddc96f4c27899e05499e0', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + '51dc32f34b36376f538ce017639c4e1233dad8d3fa08001e07cc9c128dc95ff9', {'backends': ['numpy'], 'range': 'limited', 'encoding': 'bt2020'}), ConvTestCase(PixelFormats.YVU422, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'f55f1533ef502d1d90773ccbd6e42e5e81cf37f786e61c501816c08b06bd1711', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'f6b09361452a7d6f081815ece950433a181ea45d30af6572a05c0e16b6ead527', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt601'}), ConvTestCase(PixelFormats.YVU422, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - '2b60c332f26d7e05b01ab1ee009cacc70968353c65dd90e4af4a642f41dd0282', + '2ac51203000c4f099694e417c26e53caf7c72834440824b9b315336ad2a21b10', + 'd96c04e10f8b3be1f595c099a239ac29c7698d0fa7ebbb9708ffcd99b0e86e7a', {'backends': ['numpy'], 'range': 'full', 'encoding': 'bt709'}), ConvTestCase(PixelFormats.YVU422, - '50e4efdafe1d8c6cc2b31fb2d9c1be2fa77363d6bd759417cab15ec580ce0f19', - 'ea3859985ab4a657d2de9020c5c27572651b9705c1b73fa5effdba08ec793113', + '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 ] From eb0cf84f8a0998ea5c64d9e30b4a2fa4ec2d45bc Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 15:57:35 +0300 Subject: [PATCH 38/44] conv: Add backward compatibility hack Camshark calls internal conversion functions with an integer stride, which fails now. Add a hack to convert the stride-int to a stride-tuple. Remove later. Signed-off-by: Tomi Valkeinen --- pixutils/conv/raw.py | 5 +++++ pixutils/conv/rgb.py | 5 +++++ pixutils/conv/yuv.py | 7 +++++++ 3 files changed, 17 insertions(+) diff --git a/pixutils/conv/raw.py b/pixutils/conv/raw.py index d26fa80..a5e4e6c 100644 --- a/pixutils/conv/raw.py +++ b/pixutils/conv/raw.py @@ -270,6 +270,11 @@ def raw_to_bgr888( 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) diff --git a/pixutils/conv/rgb.py b/pixutils/conv/rgb.py index 091a25b..c013a84 100644 --- a/pixutils/conv/rgb.py +++ b/pixutils/conv/rgb.py @@ -13,6 +13,11 @@ def rgb_to_bgr888( fmt: PixelFormat, w: int, h: int, strides: tuple[int, ...], data: npt.NDArray[np.uint8] ) -> npt.NDArray[np.uint8]: + + # 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: diff --git a/pixutils/conv/yuv.py b/pixutils/conv/yuv.py index 1c9f2b6..2bae5ec 100644 --- a/pixutils/conv/yuv.py +++ b/pixutils/conv/yuv.py @@ -3,6 +3,8 @@ 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 @@ -220,6 +222,11 @@ def yuv_to_bgr888( fmt: PixelFormat, options: dict | None, ) -> npt.NDArray[np.uint8]: + + # 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, strides[0], options) From 64de77142b69a24a253e830b092d9466190e2825 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 11:00:25 +0300 Subject: [PATCH 39/44] conv: Reset the input array view In most cases this should be no-op, but it makes sure we will handle a non-contig and shaped arrays correctly too. Signed-off-by: Tomi Valkeinen --- pixutils/conv/conv.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pixutils/conv/conv.py b/pixutils/conv/conv.py index 5d441b7..ea1ee99 100644 --- a/pixutils/conv/conv.py +++ b/pixutils/conv/conv.py @@ -48,6 +48,8 @@ def to_bgr888( Numpy array containing the image in BGR888 format """ + 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: From f3ad327760a9326d4414ca5e064f8b876d7d3802 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 18:59:37 +0300 Subject: [PATCH 40/44] conv: Improve conv docstrings Signed-off-by: Tomi Valkeinen --- pixutils/conv/conv.py | 45 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/pixutils/conv/conv.py b/pixutils/conv/conv.py index ea1ee99..2d8e791 100644 --- a/pixutils/conv/conv.py +++ b/pixutils/conv/conv.py @@ -25,7 +25,38 @@ def to_bgr888( 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 @@ -45,7 +76,8 @@ 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) @@ -134,10 +166,9 @@ def buffer_to_bgr888( 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 @@ -150,7 +181,7 @@ def buffer_to_bgr888( 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 From f39781c9b0a4fc42f16b75826b71082e74b88523 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Wed, 22 Apr 2026 20:45:06 +0300 Subject: [PATCH 41/44] conv: Make numpy/numba converters return None for unsupported formats Make numpy/numba converters return None for unsupported formats so that the top-level conv.py can manage unsupported formats in one place. Signed-off-by: Tomi Valkeinen --- pixutils/conv/rgb.py | 4 ++-- pixutils/conv/yuv.py | 4 ++-- pixutils/conv/yuv_nb.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pixutils/conv/rgb.py b/pixutils/conv/rgb.py index c013a84..734158a 100644 --- a/pixutils/conv/rgb.py +++ b/pixutils/conv/rgb.py @@ -12,7 +12,7 @@ def rgb_to_bgr888( fmt: PixelFormat, w: int, h: int, strides: tuple[int, ...], data: npt.NDArray[np.uint8] -) -> 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): @@ -47,6 +47,6 @@ def rgb_to_bgr888( 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/yuv.py b/pixutils/conv/yuv.py index 2bae5ec..529b371 100644 --- a/pixutils/conv/yuv.py +++ b/pixutils/conv/yuv.py @@ -221,7 +221,7 @@ def yuv_to_bgr888( strides: tuple[int, ...], fmt: PixelFormat, options: dict | None, -) -> 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): @@ -264,4 +264,4 @@ def 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 79d0f3f..4f6326b 100644 --- a/pixutils/conv/yuv_nb.py +++ b/pixutils/conv/yuv_nb.py @@ -197,7 +197,7 @@ def yuv_to_bgr888_nb( strides: tuple[int, ...], fmt: PixelFormat, options: dict | None, -) -> npt.NDArray[np.uint8]: +) -> npt.NDArray[np.uint8] | None: """Entry point for numba YUV conversions.""" offset, matrix = _get_conversion_matrix(options) @@ -273,4 +273,4 @@ def yuv_to_bgr888_nb( matrix[2][2], ) - raise RuntimeError(f'Unsupported YUV format {fmt}') + return None From 9592c3d6f71bce1f912143e295e144cd7278dc85 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Thu, 23 Apr 2026 08:14:18 +0300 Subject: [PATCH 42/44] conv: Raise NotImplemented exception for unsupported formats Signed-off-by: Tomi Valkeinen --- pixutils/conv/conv.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pixutils/conv/conv.py b/pixutils/conv/conv.py index 2d8e791..d9ac210 100644 --- a/pixutils/conv/conv.py +++ b/pixutils/conv/conv.py @@ -143,11 +143,11 @@ def to_bgr888( elif fmt.color == PixelColorEncoding.RGB: result = rgb_to_bgr888(fmt, width, height, strides, arr) else: - raise ValueError(f'Unsupported format {fmt}') + raise NotImplementedError(f'Unsupported format {fmt}') break if result is None: - raise ValueError(f'No backend could handle {fmt.name} with given options') + 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 From 69b43b5786a946f170611a610a9656e74f2ac800 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Thu, 23 Apr 2026 08:14:56 +0300 Subject: [PATCH 43/44] tests: Catch only NotImplementedError when trying formats Catching any exception hides actual issues. Signed-off-by: Tomi Valkeinen --- tests/test_backend_compat.py | 2 +- tests/test_conv.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_backend_compat.py b/tests/test_backend_compat.py index 999cc45..9c585d4 100644 --- a/tests/test_backend_compat.py +++ b/tests/test_backend_compat.py @@ -83,7 +83,7 @@ def _probe_backends(fmt: PixelFormat, base_opts: dict) -> list[str]: opts = dict(base_opts) | {'backends': [backend]} try: buffer_to_bgr888(fmt, _PROBE_WIDTH, _PROBE_HEIGHT, 0, _PROBE_BUFFER, opts) - except Exception: + except NotImplementedError: continue working.append(backend) return working diff --git a/tests/test_conv.py b/tests/test_conv.py index 1de234c..d3f0fe1 100755 --- a/tests/test_conv.py +++ b/tests/test_conv.py @@ -64,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 From 4ff1f6336919ea7a383752e8ebbdfd9d24b08ea3 Mon Sep 17 00:00:00 2001 From: Tomi Valkeinen Date: Thu, 23 Apr 2026 11:53:38 +0300 Subject: [PATCH 44/44] conv: qt: Make sure we provide a contiguous array to QImage The earlier check did the job, but it wasn't quite correct either. Signed-off-by: Tomi Valkeinen --- pixutils/conv/qt.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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]