From a8c8204db52fd87e65d4e376598e174809c8db32 Mon Sep 17 00:00:00 2001 From: WyattBlue Date: Sun, 25 Oct 2026 23:37:44 -0400 Subject: [PATCH] Add `VideoFrame.solid()` The color goes through swscale on a 16x16 RGBA block, then av_image_fill_color copies the resulting components across the frame, so the result matches reformat() without converting every pixel. Paletted and Bayer formats are rejected because a single component value cannot describe them. --- CHANGELOG.rst | 4 ++ av/video/frame.py | 89 +++++++++++++++++++++++++++++++++++++++- av/video/frame.pyi | 7 ++++ include/avutil.pxd | 27 ++++++++++++ tests/test_videoframe.py | 37 +++++++++++++++++ 5 files changed, 163 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0530f4315..8176f91ad 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -31,6 +31,10 @@ We are operating with `semantic versioning `_. v19.0.3 (Unreleased) -------------------- +Features: + +- Add ``VideoFrame.solid()`` to create a frame filled with one color, in any non-paletted pixel format. + Fixes: - Support slicing ``Container.streams``, which raised ``TypeError`` despite being typed. diff --git a/av/video/frame.py b/av/video/frame.py index 3e8f3e252..7000ec0d1 100644 --- a/av/video/frame.py +++ b/av/video/frame.py @@ -18,7 +18,8 @@ ) from cython.cimports.cpython.ref import Py_DECREF, Py_INCREF from cython.cimports.hwcontext_cuda import AVCUDADeviceContext, CUstream -from cython.cimports.libc.stdint import int64_t, uint8_t, uintptr_t +from cython.cimports.libc.stddef import ptrdiff_t +from cython.cimports.libc.stdint import int64_t, uint8_t, uint32_t, uintptr_t # Holds the VideoReformatter shared by all frames converted on this thread. _thread_local = threading.local() @@ -562,6 +563,24 @@ def useful_array( return np.ndarray(shape, dtype=dtype_obj, buffer=plane, strides=strides) +@cython.cfunc +def _fill_color(frame: VideoFrame, color: cython.pointer[uint32_t]) -> cython.void: + linesize = cython.declare(ptrdiff_t[4]) + for i in range(4): + linesize[i] = frame.ptr.linesize[i] + err_check( + lib.av_image_fill_color( + frame.ptr.data, + linesize, + cython.cast(lib.AVPixelFormat, frame.ptr.format), + color, + frame.ptr.width, + frame.ptr.height, + 0, + ) + ) + + @cython.cfunc def check_ndarray_shape(array: object, ok: cython.bint) -> cython.void: if not ok: @@ -1599,6 +1618,74 @@ def from_bytes( raise NotImplementedError(f"Format '{format}' is not supported.") return frame + @staticmethod + def solid(width: int, height: int, color="black", format="yuv420p"): + """Create a frame filled with a single color. + + :param color: A color FFmpeg can parse, like ``"red"``, ``"#ff8000"``, or + ``"white@0.5"``; or an ``(r, g, b)`` or ``(r, g, b, a)`` tuple of ints + from 0 to 255. + :param format: The pixel format. The color is converted to it the same + way :meth:`reformat` converts an RGB frame. + + >>> frame = VideoFrame.solid(64, 64, "red", "rgb24") + >>> bytes(frame.planes[0])[:3] + b'\\xff\\x00\\x00' + + """ + rgba = cython.declare(uint8_t[4]) + if isinstance(color, str): + if lib.av_parse_color(rgba, color.encode(), -1, cython.NULL) < 0: + raise ValueError(f"Invalid color {color!r}") + else: + values = tuple(color) + if len(values) == 3: + values += (255,) + if len(values) != 4 or not all(0 <= v <= 255 for v in values): + raise ValueError( + f"Expected an (r, g, b) or (r, g, b, a) tuple of 0-255, got {color!r}" + ) + for i in range(4): + rgba[i] = values[i] + + frame: VideoFrame = VideoFrame(width, height, format) + desc: cython.pointer[cython.const[lib.AVPixFmtDescriptor]] = ( + lib.av_pix_fmt_desc_get(cython.cast(lib.AVPixelFormat, frame.ptr.format)) + ) + if desc.flags & (lib.AV_PIX_FMT_FLAG_PAL | lib.AV_PIX_FMT_FLAG_BAYER): + raise ValueError(f"Cannot fill format '{format}' with a solid color") + + # Let swscale convert a small RGBA block, so the result matches what + # reformat() would produce, then copy its components across the frame + # without converting every pixel. 16x16 covers any chroma subsampling. + color32 = cython.declare(uint32_t[4]) + for i in range(4): + color32[i] = rgba[i] + block: VideoFrame = VideoFrame(16, 16, "rgba") + _fill_color(block, color32) + block = block.reformat(format=format) + + for i in range(desc.nb_components): + lib.av_read_image_line2( + cython.address(color32[i]), + cython.cast( + cython.pointer[cython.pointer[cython.const[uint8_t]]], + block.ptr.data, + ), + block.ptr.linesize, + desc, + 0, + 0, + i, + 1, + 0, + 4, + ) + _fill_color(frame, color32) + frame.ptr.colorspace = block.ptr.colorspace + frame.ptr.color_range = block.ptr.color_range + return frame + @staticmethod def from_dlpack( planes, diff --git a/av/video/frame.pyi b/av/video/frame.pyi index dd545123d..41f80ed29 100644 --- a/av/video/frame.pyi +++ b/av/video/frame.pyi @@ -125,6 +125,13 @@ class VideoFrame(Frame): flip_vertical: bool = False, ) -> VideoFrame: ... @staticmethod + def solid( + width: int, + height: int, + color: str | tuple[int, int, int] | tuple[int, int, int, int] = "black", + format: str = "yuv420p", + ) -> VideoFrame: ... + @staticmethod def from_dlpack( planes: object | tuple[object, ...], format: str = "nv12", diff --git a/include/avutil.pxd b/include/avutil.pxd index b7aa514c5..c9749fa8f 100644 --- a/include/avutil.pxd +++ b/include/avutil.pxd @@ -1,3 +1,4 @@ +from libc.stddef cimport ptrdiff_t from libc.stdint cimport int16_t, int32_t, int64_t, uint8_t, uint16_t, uint32_t, uint64_t @@ -242,6 +243,15 @@ cdef extern from "libavutil/imgutils.h" nogil: uint8_t *ptr, const int linesizes[4] ) + cdef int av_image_fill_color( + uint8_t * const dst_data[4], + const ptrdiff_t dst_linesize[4], + AVPixelFormat pix_fmt, + const uint32_t color[4], + int width, + int height, + int flags + ) cdef extern from "libavutil/log.h" nogil: cdef struct AVClass: @@ -333,6 +343,11 @@ cdef extern from "libavutil/opt.h" nogil: void *obj, const char *name, int search_flags, uint8_t **out_val ) +cdef extern from "libavutil/parseutils.h" nogil: + cdef int av_parse_color( + uint8_t *rgba_color, const char *color_string, int slen, void *log_ctx + ) + cdef extern from "libavutil/pixdesc.h" nogil: # See: http://ffmpeg.org/doxygen/trunk/structAVComponentDescriptor.html cdef struct AVComponentDescriptor: @@ -363,6 +378,18 @@ cdef extern from "libavutil/pixdesc.h" nogil: cdef AVPixelFormat av_get_pix_fmt(const char *name) int av_get_bits_per_pixel(const AVPixFmtDescriptor *pixdesc) int av_get_padded_bits_per_pixel(const AVPixFmtDescriptor *pixdesc) + void av_read_image_line2( + void *dst, + const uint8_t *data[4], + const int linesize[4], + const AVPixFmtDescriptor *desc, + int x, + int y, + int c, + int w, + int read_pal_component, + int dst_element_size + ) cdef extern from "libavutil/rational.h" nogil: cdef int av_reduce(int *dst_num, int *dst_den, int64_t num, int64_t den, int64_t max) diff --git a/tests/test_videoframe.py b/tests/test_videoframe.py index cff564deb..fa67df60b 100644 --- a/tests/test_videoframe.py +++ b/tests/test_videoframe.py @@ -114,6 +114,43 @@ def test_rgb24_planes() -> None: assert frame.planes[0].buffer_size == 640 * 480 * 3 +@pytest.mark.parametrize( + "format", ["rgb24", "bgr0", "gbrp", "yuv420p", "nv12", "yuyv422", "yuv420p10le"] +) +def test_solid(format: str) -> None: + # Odd sizes check the last row and column of subsampled planes get filled. + frame = VideoFrame.solid(33, 17, "#ff8000", format) + assert (frame.width, frame.height, frame.format.name) == (33, 17, format) + + rgb = frame.to_ndarray(format="rgb24").astype(int) + assert numpy.abs(rgb - (255, 128, 0)).max() <= 2 + + +def test_solid_matches_reformat() -> None: + rgb = VideoFrame.solid(16, 16, (30, 140, 220), "rgb24") + expected = rgb.reformat(format="yuv420p") + frame = VideoFrame.solid(16, 16, (30, 140, 220), "yuv420p") + assertNdarraysEqual(frame.to_ndarray(), expected.to_ndarray()) + + +def test_solid_alpha() -> None: + frame = VideoFrame.solid(8, 8, "white@0.5", "rgba") + assert bytes(frame.planes[0])[:4] == b"\xff\xff\xff\x7f" + frame = VideoFrame.solid(8, 8, (0, 0, 255, 64), "rgba") + assert bytes(frame.planes[0])[:4] == b"\x00\x00\xff\x40" + + +def test_solid_invalid() -> None: + with pytest.raises(ValueError, match="Invalid color"): + VideoFrame.solid(8, 8, "notacolor") + with pytest.raises(ValueError): + VideoFrame.solid(8, 8, (255, 0)) # type: ignore[arg-type] + with pytest.raises(ValueError): + VideoFrame.solid(8, 8, (256, 0, 0)) + with pytest.raises(ValueError, match="pal8"): + VideoFrame.solid(8, 8, "red", "pal8") + + def test_memoryview_read() -> None: frame = VideoFrame(640, 480, "rgb24") frame.planes[0].update(b"01234" + (b"x" * (640 * 480 * 3 - 5)))