Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
4e8ab3f
Update Python requirement to 3.10
tomba Apr 22, 2026
b2ae999
formats: Add 'libcamera_name' for pixel formats
tomba Apr 21, 2026
c69888e
formats: Rename 'packed' to 'csi2_packed'
tomba Apr 21, 2026
520c4aa
formats: Add PixelFormat.raw_bpp()
tomba Apr 21, 2026
a1b92d3
Use csi2_packed and raw_bitspp properties
tomba Apr 21, 2026
861eb98
utils/conv-perf-test: Support multiple formats
tomba Apr 22, 2026
4b55b3f
utils/conv-perf-test: Improve perf testing
tomba Apr 23, 2026
9f6c870
utils/conv-perf-test: Add --output
tomba Apr 23, 2026
f12ba8b
utils/conv-perf-test: Add peak iters
tomba Apr 23, 2026
9ab1577
utils: Add conv-perf-compare.py
tomba Apr 22, 2026
3502b59
utils: Add conv-perf-range.sh
tomba Apr 22, 2026
043d0ed
conv: Accept strides-list and pass it around
tomba Apr 21, 2026
f276fe8
conv: Add stride extrapolation
tomba Apr 23, 2026
089ee79
conv: Add strip_padding() helper
tomba Apr 21, 2026
32e101b
conv: Use strip_padding() to fix the padded conversions
tomba Apr 21, 2026
23a20c9
conv: numba: Use stride properly
tomba Apr 21, 2026
64439b6
conv: Use as_strided() to handle stride
tomba Apr 21, 2026
7a2ab9c
conv: Clean up opencv stride handling
tomba Apr 21, 2026
4f8d8ef
conv: Clean up bytesperline == 0 checking
tomba Apr 21, 2026
30391ef
utils/conv-perf-test: Support stride properly
tomba Apr 23, 2026
3a344c6
tests: Add padded/strided tests
tomba Apr 21, 2026
99e5f6e
tests: Add stride extrapolation tests
tomba Apr 23, 2026
ab46844
conv: opencv: Fix RGB conversions
tomba Apr 22, 2026
9821ab5
conv: opencv: Fix YUV component ordering
tomba Apr 22, 2026
0436418
tests: Add cross-backend consistency test
tomba Apr 22, 2026
45076cf
conv: Ensure we return an independent buffer
tomba Apr 22, 2026
a8c8ee3
conv: numpy: rgb: Produce contiguous BGR888 output directly
tomba Apr 22, 2026
4392445
conv: numpy: raw: Shift in-place before dtype cast
tomba Apr 22, 2026
3ae175c
conv: numpy: yuv: Clip in-place in ycbcr_to_bgr888()
tomba Apr 22, 2026
9f5bcde
conv: numpy: yuv: Use np.empty() in y8_to_bgr888()
tomba Apr 22, 2026
5e152be
conv: raw: Clean up buffer reshaping
tomba Apr 22, 2026
0b00b00
conv: Add NV16 support (numpy & numba)
tomba Apr 22, 2026
dea105a
conv: Add NV21 and NV61
tomba Apr 22, 2026
6c9c13b
conv: numpy: Combine NVxx converters
tomba Apr 22, 2026
24e715e
conv: numba: Combine NVxx converters
tomba Apr 22, 2026
38bb721
conv: Add YUV420, YUV422, YVU420, YVU422 conversions
tomba Apr 22, 2026
a3474ab
tests: Change test frame width to be more divisible
tomba Apr 22, 2026
eb0cf84
conv: Add backward compatibility hack
tomba Apr 22, 2026
64de771
conv: Reset the input array view
tomba Apr 22, 2026
f3ad327
conv: Improve conv docstrings
tomba Apr 22, 2026
f39781c
conv: Make numpy/numba converters return None for unsupported formats
tomba Apr 22, 2026
9592c3d
conv: Raise NotImplemented exception for unsupported formats
tomba Apr 23, 2026
69b43b5
tests: Catch only NotImplementedError when trying formats
tomba Apr 23, 2026
4ff1f63
conv: qt: Make sure we provide a contiguous array to QImage
tomba Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 98 additions & 36 deletions pixutils/conv/conv.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

from __future__ import annotations

from collections.abc import Sequence

import numpy as np
import numpy.typing as npt

Expand All @@ -18,18 +20,54 @@ def to_bgr888(
fmt: PixelFormat,
width: int,
height: int,
bytesperline: int,
bytesperline: int | Sequence[int],
arr: npt.NDArray[np.uint8],
options: None | dict = None,
) -> npt.NDArray[np.uint8]:
"""
Convert a numpy array containing pixel data to BGR888 format.
Convert a numpy array containing pixel data to an 8-bit 3-channel image.

Input convention
----------------
Input pixel formats follow the Linux DRM/V4L2 fourcc naming convention,
where the name describes a little-endian machine word from MSB to LSB.
The in-memory byte order is therefore the reverse of the name. Examples
(each byte listed starting at byte 0 of a pixel):

- ``RGB888`` -> B, G, R
- ``BGR888`` -> R, G, B
- ``XRGB8888`` -> B, G, R, X
- ``BGRX8888`` -> X, R, G, B
- ``ARGB8888`` -> B, G, R, A
- ``ABGR8888`` -> R, G, B, A

Output layout
-------------
The return value is a ``(height, width, 3)`` uint8 array. For each pixel,
byte 0 is R, byte 1 is G, byte 2 is B. In DRM fourcc terms this is
``BGR888`` (hence the function name), so the output memory layout follows
the same DRM/V4L2 fourcc convention as the inputs documented above.

Other frameworks label this same ``(R, G, B)``-in-memory layout
differently, typically because they name formats by in-memory byte order
rather than by little-endian machine word: Qt calls it
``QImage::Format_RGB888``, PIL calls it the ``'RGB'`` mode, V4L2 (in its
userspace API) calls it ``V4L2_PIX_FMT_RGB24``, and OpenCV calls it
``RGB``. The result can be passed to any of these without channel
reordering. OpenCV's default entry points (``cv2.imread``,
``cv2.imshow``, ``cv2.imwrite``) instead expect OpenCV-``BGR`` — bytes
``(B, G, R)`` in memory — and therefore require a channel swap.
Comment thread
stefanklug marked this conversation as resolved.

Parameters:
fmt: The pixel format of the input data
width: Width of the image in pixels
height: Height of the image in pixels
bytesperline: Number of bytes per line in the input data or 0 for no padding
bytesperline: Bytes per line. Either:
- 0: no padding, natural strides are used for every plane
- a single non-zero int: stride of plane 0. For multiplane formats the
strides of the other planes are extrapolated, preserving the padding
ratio (matches libcamera convention)
- a sequence of non-zero ints: one stride per plane
arr: Numpy array containing the pixel data
options: Optional dictionary with conversion options:
- backends: List of backends in priority order, e.g. ['opencv', 'numba']
Expand All @@ -38,88 +76,112 @@ def to_bgr888(
- demosaic_method: '3x3', 'bilinear', 'mosaic', or 'opencv' (for RAW formats)

Returns:
Numpy array containing the image in BGR888 format
``(height, width, 3)`` uint8 array with per-pixel bytes (R, G, B)
(see "Output layout" above).
"""

arr = np.ascontiguousarray(arr).reshape(-1).view(np.uint8)

# Normalize bytesperline to a per-plane tuple of concrete (non-zero) strides
if isinstance(bytesperline, int):
if bytesperline == 0:
strides = tuple(fmt.stride(width, i) for i in range(len(fmt.planes)))
else:
strides = tuple(fmt.extrapolate_stride(bytesperline, i) for i in range(len(fmt.planes)))
else:
if len(bytesperline) != len(fmt.planes):
raise ValueError(
f'Strides sequence length {len(bytesperline)} does not match number of planes {len(fmt.planes)}'
)
if any(s == 0 for s in bytesperline):
raise ValueError('Strides sequence must contain non-zero stride for each plane')
strides = tuple(bytesperline)

# Get list of backends to try
backends = get_backends(options.get('backends') if options else None)
if not backends:
raise ValueError('No backends available')

# The function API is broken for multiplane formats. Catch the problematic
# ones with assert for now
assert len(fmt.planes) == 1 or bytesperline == 0

size = 0

for i, plane in enumerate(fmt.planes):
if bytesperline > 0 and bytesperline < fmt.stride(width, i):
for i in range(len(fmt.planes)):
if strides[i] < fmt.stride(width, i):
raise ValueError('bytesperline is too small')

stride = bytesperline if bytesperline > 0 else fmt.stride(width, i)
if arr.size < fmt.planesize(stride, height, i):
raise ValueError('Input array is too small')
if arr.size < fmt.planesize(strides[i], height, i):
raise ValueError(
f'Input array is too small: {arr.size} < {fmt.planesize(strides[i], height, i)}, {bytesperline}, {strides}'
)

size += stride * height
size += fmt.planesize(strides[i], height, i)

# Get a view for the actual data
arr = arr[:size]

# Try backends in priority order
result = None
for backend in backends:
if backend == 'opencv':
from .opencv import opencv_to_bgr888

result = opencv_to_bgr888(fmt, width, height, bytesperline, arr, options)
result = opencv_to_bgr888(fmt, width, height, strides, arr, options)
if result is not None:
return result
break
# opencv couldn't handle this format/options, try next backend
continue
elif backend == 'numba':
from .numba import numba_to_bgr888

result = numba_to_bgr888(fmt, width, height, bytesperline, arr, options)
result = numba_to_bgr888(fmt, width, height, strides, arr, options)
if result is not None:
return result
break
elif backend == 'numpy':
if fmt.color == PixelColorEncoding.YUV:
return yuv_to_bgr888(arr, width, height, fmt, options)

if fmt.color == PixelColorEncoding.RAW:
return raw_to_bgr888(arr, width, height, bytesperline, fmt, options)

if fmt.color == PixelColorEncoding.RGB:
return rgb_to_bgr888(fmt, width, height, arr)

raise ValueError(f'Unsupported format {fmt}')

raise ValueError(f'No backend could handle {fmt.name} with given options')
result = yuv_to_bgr888(arr, width, height, strides, fmt, options)
elif fmt.color == PixelColorEncoding.RAW:
result = raw_to_bgr888(arr, width, height, strides, fmt, options)
elif fmt.color == PixelColorEncoding.RGB:
result = rgb_to_bgr888(fmt, width, height, strides, arr)
else:
raise NotImplementedError(f'Unsupported format {fmt}')
break

if result is None:
raise NotImplementedError(f'No backend could handle {fmt.name} with given options')

# Backends may return a view; guarantee only that it doesn't alias the
# input buffer. Callers that need a specific layout can contiguify
# themselves.
if np.shares_memory(result, arr):
result = result.copy()
return result


def buffer_to_bgr888(
fmt: PixelFormat,
width: int,
height: int,
bytesperline: int,
bytesperline: int | Sequence[int],
buffer,
options: None | dict = None,
) -> npt.NDArray[np.uint8]:
"""
Convert a buffer-like object containing pixel data to BGR888 format.

This function accepts any Buffer-like object, converts it to a numpy array,
and then uses to_bgr888() to perform the conversion.
Convert a buffer-like object containing pixel data to an 8-bit 3-channel
image. Thin wrapper around :func:`to_bgr888`; see that function for input
format conventions and output byte layout.

Parameters:
fmt: The pixel format of the input data
width: Width of the image in pixels
height: Height of the image in pixels
bytesperline: Number of bytes per line in the input data or 0 for no padding
bytesperline: Bytes per line. Either 0 (natural strides), a single
non-zero int (stride of plane 0; other planes extrapolated), or a
sequence of non-zero ints with one value per plane
buffer: Buffer-like object containing the pixel data
options: Optional dictionary with conversion options

Returns:
Numpy array containing the image in BGR888 format
See :func:`to_bgr888`.

TODO:
3.12+ supports collections.abc.Buffer which could be used for the input
Expand Down
8 changes: 4 additions & 4 deletions pixutils/conv/numba.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

__all__ = ['numba_to_bgr888']

_SUPPORTED_YUV_FORMATS = {'YUYV', 'UYVY', 'NV12'}
_SUPPORTED_YUV_FORMATS = {'YUYV', 'UYVY', 'NV12', 'NV21', 'NV16', 'NV61'}


def _can_use_numba_yuv(fmt: PixelFormat) -> bool:
Expand All @@ -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:
Expand All @@ -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
6 changes: 3 additions & 3 deletions pixutils/conv/opencv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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)
Loading
Loading