summaryrefslogtreecommitdiff
path: root/pixpat-python/pixpat
diff options
context:
space:
mode:
authorTomi Valkeinen <tomi.valkeinen@ideasonboard.com>2026-05-08 17:22:58 +0300
committerTomi Valkeinen <tomi.valkeinen@ideasonboard.com>2026-05-08 17:22:58 +0300
commite0b7d30fd437292c88141fb08d60681870b86c6e (patch)
tree7d7f4e94cbec0f4f494042f7cbf39c7c8e7234fe /pixpat-python/pixpat
Squashed 'subprojects/pixpat/' content from commit d444626
git-subtree-dir: subprojects/pixpat git-subtree-split: d444626e6ba988ec6d487800721e447f94b1eaf5
Diffstat (limited to 'pixpat-python/pixpat')
-rw-r--r--pixpat-python/pixpat/__init__.py489
-rw-r--r--pixpat-python/pixpat/_lib/.gitkeep3
-rw-r--r--pixpat-python/pixpat/_native.py203
3 files changed, 695 insertions, 0 deletions
diff --git a/pixpat-python/pixpat/__init__.py b/pixpat-python/pixpat/__init__.py
new file mode 100644
index 0000000..6531662
--- /dev/null
+++ b/pixpat-python/pixpat/__init__.py
@@ -0,0 +1,489 @@
+"""Python bindings for libpixpat.
+
+Loads the bundled shared library via ctypes — no CPython extension. Works on
+any CPython >= 3.9 for the wheel's target architecture.
+
+The library draws test patterns and converts pixel data directly into
+caller-owned pixel buffers, in a wide range of pixel formats
+(planar/semi-planar/packed YUV, RGB, raw, …). The caller is responsible for
+allocating each plane with the correct size and stride for the chosen format.
+
+Pixel data is described by :class:`Buffer`, a passive struct mirroring the
+C ``pixpat_buffer``: format name, width, height, one writable buffer per
+plane, and one row stride per plane. Use :func:`draw_pattern` to fill a
+buffer with a test pattern, and :func:`convert` to convert pixel data
+between formats.
+
+Format names follow the convention used by `kms++` and `pixutils`
+(e.g. ``"XRGB8888"``, ``"NV12"``, ``"YUYV"``) — not the DRM/V4L2
+four-character codes (``"XR24"``, etc.).
+
+Example:
+ >>> import pixpat
+ >>> width, height = 1920, 1080
+ >>> stride = width * 4 # XRGB8888: 4 bytes per pixel
+ >>> data = bytearray(stride * height)
+ >>> buf = pixpat.Buffer(planes=[data], fmt="XRGB8888",
+ ... width=width, height=height, strides=[stride])
+ >>> pixpat.draw_pattern(buf, "smpte")
+
+Use :func:`supported_formats` to enumerate the format names accepted by
+``Buffer.fmt``, and :func:`is_supported` to test a single format.
+
+Using numpy buffers
+-------------------
+
+pixpat does not import numpy and does not depend on it, but any
+C-contiguous ``numpy.ndarray`` works as a plane via Python's buffer
+protocol. The caller is responsible for matching the array's dtype and
+shape to the pixel format and for passing the correct row stride:
+
+ >>> import numpy as np
+ >>> arr = np.zeros((height, width, 4), dtype=np.uint8)
+ >>> stride = arr.strides[0] # bytes per row
+ >>> buf = pixpat.Buffer(planes=[arr], fmt="XRGB8888",
+ ... width=width, height=height, strides=[stride])
+ >>> pixpat.draw_pattern(buf, "smpte")
+
+For multi-plane formats like ``"NV12"`` pass one ndarray per plane (e.g.
+``(h, w)`` uint8 for Y and ``(h//2, w)`` uint8 for the interleaved UV
+plane).
+
+If you already hold all planes in a single contiguous buffer — the layout
+OpenCV uses for NV12, an ``(h * 3 // 2, w)`` uint8 array with Y on top
+and the interleaved UV plane below — slice it into per-plane views and
+pass those::
+
+ >>> nv12 = np.zeros((h * 3 // 2, w), dtype=np.uint8)
+ >>> y, uv = nv12[:h], nv12[h:].reshape(h // 2, w)
+ >>> pixpat.Buffer([y, uv], "NV12", w, h,
+ ... [y.strides[0], uv.strides[0]])
+
+The source side of :func:`convert` accepts read-only buffers (``bytes``,
+``arr.view()`` with ``writeable=False``, mmap'd files, …). The
+destination side must always be writable.
+"""
+
+import ctypes
+from dataclasses import dataclass, field
+from enum import IntEnum
+from typing import Mapping, Optional, Sequence, Union
+
+from ._native import (
+ _Buffer,
+ _ConvertOpts,
+ _PatternOpts,
+ _PinnedBuffers,
+ _fill_buffer,
+ _lib,
+)
+
+_VALID_PATTERNS = frozenset(
+ {
+ 'kmstest',
+ 'smpte',
+ 'plain',
+ 'checker',
+ 'hramp',
+ 'vramp',
+ 'hbar',
+ 'vbar',
+ 'dramp',
+ 'zoneplate',
+ }
+)
+
+
+class Rec(IntEnum):
+ """YCbCr color encoding standard used for YUV output formats.
+
+ Selects the matrix used to convert from internal RGB to YUV. Has no
+ effect when drawing into an RGB or raw format.
+
+ Attributes:
+ BT601: ITU-R BT.601 (standard definition).
+ BT709: ITU-R BT.709 (HD).
+ BT2020: ITU-R BT.2020 (UHD / HDR, non-constant luminance).
+ """
+
+ BT601 = 0
+ BT709 = 1
+ BT2020 = 2
+
+
+class Range(IntEnum):
+ """Quantization range used for YUV output formats.
+
+ Attributes:
+ LIMITED: "TV" / studio range — Y in [16, 235], C in [16, 240]
+ (scaled to bit depth). What most video pipelines expect.
+ FULL: "PC" / full range — every component uses the full code range
+ (e.g. [0, 255] for 8-bit).
+ """
+
+ LIMITED = 0
+ FULL = 1
+
+
+class PixpatError(RuntimeError):
+ """Raised when the underlying ``pixpat_*`` call fails.
+
+ Most commonly indicates an unknown format name or buffer dimensions /
+ plane layout that the format does not allow (e.g. odd width for a
+ horizontally-subsampled YUV format).
+ """
+
+
+@dataclass
+class Buffer:
+ """Plane data + format + dimensions, mirroring the C ``pixpat_buffer``.
+
+ A passive struct: no allocation, no format knowledge, no
+ numpy/cv2 awareness. The caller decides plane shapes and strides
+ based on its own format knowledge (e.g. via ``pixutils``).
+
+ Attributes:
+ planes: One buffer-protocol object per plane (e.g. ``bytearray``,
+ ``array.array``, ``numpy.ndarray``, ``mmap.mmap``,
+ ``memoryview``). Up to 4 planes are supported. Each plane
+ must hold at least ``strides[i] * plane_height`` bytes,
+ where ``plane_height`` is ``height`` for the main plane and
+ ``height // vertical_subsampling`` for chroma planes (e.g.
+ ``height // 2`` for the UV plane of ``NV12``). For
+ destination buffers each plane must be writable; for the
+ source side of :func:`convert` read-only objects (such as
+ ``bytes``) are accepted.
+ fmt: Pixel-format name; see :func:`supported_formats`.
+ width: Image width in pixels. Some formats constrain this — for
+ chroma-subsampled YUV formats it must be a multiple of the
+ horizontal subsampling factor (e.g. 2 for ``NV12``), and
+ packed formats add a further multiple from their
+ pixel-group size (e.g. 6 for ``P030``, 4 for ``SBGGR10P``).
+ height: Image height in pixels. For vertically-subsampled
+ formats (e.g. ``NV12``, ``YUV420``) it must be a multiple
+ of the vertical subsampling factor.
+ strides: Per-plane row stride in bytes. Must have the same
+ length as ``planes``. Strides larger than the minimum row
+ size are allowed.
+ """
+
+ planes: Sequence
+ fmt: str
+ width: int
+ height: int
+ strides: Sequence[int] = field(default_factory=list)
+
+
+def supported_formats() -> list[str]:
+ """Return the list of pixel-format names accepted by :class:`Buffer`.
+
+ Format names follow the convention used by `kms++` and `pixutils`
+ (e.g. ``"XRGB8888"``, ``"NV12"``, ``"YUYV"``) — not the DRM/V4L2
+ four-character codes.
+ """
+ n = _lib.pixpat_format_count()
+ return [_lib.pixpat_format_name(i).decode('ascii') for i in range(n)]
+
+
+def is_supported(fmt: str) -> bool:
+ """Return whether ``fmt`` is a known pixel-format name.
+
+ Equivalent to ``fmt in supported_formats()`` but cheaper — it does not
+ materialize the full list.
+ """
+ return bool(_lib.pixpat_format_supported(fmt.encode('ascii')))
+
+
+def _serialize_pattern_params(
+ params: Optional[Union[str, Mapping[str, object]]],
+) -> Optional[bytes]:
+ """Encode `params` for the C ABI.
+
+ Accepts a ready-made string (passed through verbatim) or a mapping of
+ string keys to stringifiable values, which is serialized to the
+ ``"key=val,key=val"`` form pixpat parses on the C side. Returns None
+ if there is nothing to pass.
+ """
+ if params is None:
+ return None
+ if isinstance(params, str):
+ return params.encode('ascii')
+ if isinstance(params, Mapping):
+ items = []
+ for k, v in params.items():
+ if not isinstance(k, str):
+ raise TypeError(f'pattern params keys must be str, got {type(k).__name__}')
+ sv = str(v)
+ if ',' in k or '=' in k:
+ raise ValueError(f'pattern params key contains , or =: {k!r}')
+ if ',' in sv or '=' in sv:
+ raise ValueError(f'pattern params value contains , or =: {sv!r}')
+ items.append(f'{k}={sv}')
+ return ','.join(items).encode('ascii')
+ raise TypeError(f'pattern params must be None, str, or a Mapping, got {type(params).__name__}')
+
+
+def draw_pattern(
+ dst: Buffer,
+ pattern: Optional[str] = None,
+ *,
+ rec: Rec = Rec.BT601,
+ color_range: Range = Range.LIMITED,
+ num_threads: int = 0,
+ params: Optional[Union[str, Mapping[str, object]]] = None,
+) -> None:
+ """Draw a test pattern into ``dst``.
+
+ Args:
+ dst: Destination buffer; all planes must be writable.
+ pattern: Name of the pattern to draw. ``None`` selects the default
+ (equivalent to ``"kmstest"``). Recognized values:
+
+ * ``"kmstest"`` — color gradients with ramps, in the style of
+ the original ``kmstest`` tool. The default.
+ * ``"smpte"`` — SMPTE RP 219-1 color bars (with PLUGE).
+ * ``"plain"`` — solid color fill from ``params["color"]``.
+ See `params` below.
+ * ``"checker"`` — black/white checkerboard. Optional
+ ``params["cell"]`` (decimal positive integer, default 8)
+ sets the cell size in pixels. ``"cell": "1"`` is a
+ 1-pixel chroma-subsampling stress test.
+ * ``"hramp"`` — four horizontal stripes (R, G, B, gray),
+ each a 0..max ramp along x. Combined per-channel and
+ luma quantization check.
+ * ``"vramp"`` — same as ``"hramp"`` rotated 90°: four
+ vertical columns ramping along y.
+ * ``"hbar"`` — horizontal bar (full image width, narrow
+ along y) over a black background. Required
+ ``params["pos"]`` (signed integer, top edge in pixels);
+ optional ``params["width"]`` (positive integer, default
+ 32). The bar is split into seven equal-width regions
+ colored white/red/white/green/white/blue/white.
+ * ``"vbar"`` — same as ``"hbar"`` rotated 90°: vertical
+ bar with ``pos`` measured along x.
+ * ``"dramp"`` — diagonal RGB ramp (R on x, G on y,
+ B on x+y).
+ * ``"zoneplate"`` — centered radial cosine pattern,
+ frequency ramping from DC at the center to Nyquist at
+ the longer edge. Useful for spotting scaling/aliasing.
+
+ ``"smpte"`` is defined by the spec in BT.709 / Limited.
+ Pass ``rec=Rec.BT709, color_range=Range.LIMITED`` for
+ spec-correct output; other settings produce visibly-wrong
+ colors when drawing into RGB sinks (the caller's matrix is
+ applied to BT.709-encoded values). Callers are trusted —
+ pixpat does not silently override the spec.
+ rec: YCbCr matrix for YUV formats. Ignored for RGB / raw formats.
+ Defaults to :attr:`Rec.BT601`.
+ color_range: Quantization range for YUV formats. Ignored for
+ RGB / raw formats. Defaults to :attr:`Range.LIMITED`.
+ num_threads: Worker-thread count. ``0`` selects a sensible
+ default (one per online CPU, capped to a sane maximum);
+ ``1`` runs single-threaded with no thread-spawn overhead;
+ ``N > 1`` uses exactly ``N`` workers. Defaults to ``0``.
+ Output is bit-identical regardless of the chosen count.
+ params: Optional pattern-specific parameters. Either a mapping
+ (``{"color": "ff0000"}``) — serialized to ``"color=ff0000"``
+ for the C ABI — or a raw ``"key=val,key=val"`` string.
+ Unknown keys are silently ignored; patterns that don't read
+ params (``kmstest``, ``smpte``) ignore this entirely.
+ Per-pattern keys:
+
+ * ``"plain"`` reads ``"color"`` as a hex RGB(A) string with
+ an optional ``"0x"`` prefix. The hex-digit count selects
+ the layout: 6 → 8-bit ``RRGGBB``, 8 → 8-bit ``AARRGGBB``
+ (alpha first), 12 → 16-bit ``RRRRGGGGBBBB``, 16 → 16-bit
+ ``AAAARRRRGGGGBBBB``. Missing or malformed ``"color"``
+ raises :class:`PixpatError`.
+ * ``"checker"`` reads optional ``"cell"`` as a positive
+ decimal integer; default 8. A non-positive or non-numeric
+ value raises :class:`PixpatError`.
+ * ``"hbar"`` / ``"vbar"`` read required ``"pos"`` (signed
+ decimal integer, top/left edge of the bar in pixels;
+ negative values clip at the edge) and optional ``"width"``
+ (positive decimal integer, bar thickness; default 32).
+ Missing/non-numeric ``pos`` or non-positive ``width``
+ raises :class:`PixpatError`.
+
+ Raises:
+ ValueError: ``dst.planes`` exceeds the maximum plane count,
+ ``dst.strides`` length does not match ``dst.planes``,
+ ``pattern`` is not one of the recognized names, or a
+ ``params`` mapping value contains a forbidden ``,`` or ``=``.
+ TypeError: A plane is read-only (e.g. ``bytes``, a read-only
+ ``memoryview``), or ``params`` has an unsupported type.
+ PixpatError: The underlying C call failed — typically an unknown
+ ``fmt``, dimensions / strides incompatible with the format,
+ or pattern parameters that the pattern rejected.
+
+ Example:
+ >>> w, h = 640, 480
+ >>> stride = w * 4
+ >>> data = bytearray(stride * h)
+ >>> dst = Buffer([data], "XRGB8888", w, h, [stride])
+ >>> draw_pattern(dst, "smpte")
+ >>> draw_pattern(dst, "plain", params={"color": "ff0000"})
+ """
+ if pattern is None:
+ pattern = 'kmstest'
+ elif pattern not in _VALID_PATTERNS:
+ raise ValueError(
+ f'unknown pattern {pattern!r}; expected one of {sorted(_VALID_PATTERNS)} or None'
+ )
+ if num_threads < 0:
+ raise ValueError(f'num_threads must be >= 0, got {num_threads}')
+
+ params_bytes = _serialize_pattern_params(params)
+
+ c_buf = _Buffer()
+ opts = _PatternOpts()
+ opts.rec = int(rec)
+ opts.range = int(color_range)
+ opts.num_threads = num_threads
+ opts.params = params_bytes
+
+ with _PinnedBuffers() as pins:
+ _fill_buffer(
+ c_buf,
+ pins,
+ dst.planes,
+ dst.fmt,
+ dst.width,
+ dst.height,
+ dst.strides,
+ writable=True,
+ )
+ rc = _lib.pixpat_draw_pattern(
+ ctypes.byref(c_buf), pattern.encode('ascii'), ctypes.byref(opts)
+ )
+
+ if rc != 0:
+ raise PixpatError(f'pixpat_draw_pattern failed (rc={rc}); check format name and dimensions')
+
+
+def convert(
+ dst: Buffer,
+ src: Buffer,
+ *,
+ rec: Rec = Rec.BT601,
+ color_range: Range = Range.LIMITED,
+ num_threads: int = 0,
+) -> None:
+ """Convert pixel data from ``src`` into ``dst``.
+
+ Both buffers must describe an image of the same width and height;
+ only the pixel format may differ. Any format accepted by
+ :func:`supported_formats` works as both source and destination in
+ the default build (custom build profiles can mark individual formats
+ read-only or write-only). Conversion is routed internally through a
+ 16-bit normalized RGB or YUV intermediate, so format-to-format
+ conversions in either direction (e.g. ``NV12`` -> ``YUV420``,
+ ``XRGB8888`` -> ``NV12``, ``SRGGB10`` -> ``BGR888``) are a single
+ call. Bayer sources are decoded with a 3x3 bilinear demosaic.
+
+ ``src.planes`` may hold read-only buffers (e.g. ``bytes``, mmap'd
+ files, numpy arrays with ``writeable=False``); ``dst.planes`` must
+ be writable.
+
+ Args:
+ dst: Destination buffer.
+ src: Source buffer.
+ rec: YCbCr matrix used when the conversion crosses the RGB/YUV
+ boundary. Ignored when ``src.fmt`` and ``dst.fmt`` share
+ the same color kind. Defaults to :attr:`Rec.BT601`.
+ color_range: Quantization range used when the conversion crosses
+ the RGB/YUV boundary. Ignored when ``src.fmt`` and
+ ``dst.fmt`` share the same color kind. Defaults to
+ :attr:`Range.LIMITED`.
+ num_threads: Worker-thread count. ``0`` selects a sensible
+ default (one per online CPU, capped to a sane maximum);
+ ``1`` runs single-threaded with no thread-spawn overhead;
+ ``N > 1`` uses exactly ``N`` workers. Defaults to ``0``.
+ Output is bit-identical regardless of the chosen count.
+
+ Raises:
+ ValueError: ``dst`` and ``src`` have mismatched dimensions, a
+ plane sequence exceeds the maximum plane count, or a
+ strides length does not match its planes length.
+ TypeError: A ``dst`` plane is read-only.
+ PixpatError: The underlying C call failed — typically an
+ unknown format name, a format disabled in the current build
+ (or disabled in the requested direction), or dimensions /
+ strides incompatible with one of the formats.
+
+ Example:
+ Cross-color-kind, multi-plane on both sides — paint an NV12
+ source via :func:`draw_pattern`, then convert it to planar
+ YUV420:
+
+ >>> w, h = 64, 32
+ >>> y_src = bytearray(w * h)
+ >>> uv_src = bytearray(w * h // 2)
+ >>> draw_pattern(Buffer([y_src, uv_src], "NV12", w, h, [w, w]),
+ ... "smpte")
+ >>> y_dst = bytearray(w * h)
+ >>> u_dst = bytearray(w * h // 4)
+ >>> v_dst = bytearray(w * h // 4)
+ >>> convert(
+ ... Buffer([y_dst, u_dst, v_dst], "YUV420", w, h,
+ ... [w, w // 2, w // 2]),
+ ... Buffer([y_src, uv_src], "NV12", w, h, [w, w]),
+ ... )
+ """
+ if dst.width != src.width or dst.height != src.height:
+ raise ValueError(
+ f'dst dimensions {dst.width}x{dst.height} do not match '
+ f'src dimensions {src.width}x{src.height}'
+ )
+ if num_threads < 0:
+ raise ValueError(f'num_threads must be >= 0, got {num_threads}')
+
+ c_dst = _Buffer()
+ c_src = _Buffer()
+ opts = _ConvertOpts()
+ opts.rec = int(rec)
+ opts.range = int(color_range)
+ opts.num_threads = num_threads
+
+ with _PinnedBuffers() as pins:
+ _fill_buffer(
+ c_dst,
+ pins,
+ dst.planes,
+ dst.fmt,
+ dst.width,
+ dst.height,
+ dst.strides,
+ writable=True,
+ role='dst',
+ )
+ _fill_buffer(
+ c_src,
+ pins,
+ src.planes,
+ src.fmt,
+ src.width,
+ src.height,
+ src.strides,
+ writable=False,
+ role='src',
+ )
+ rc = _lib.pixpat_convert(ctypes.byref(c_dst), ctypes.byref(c_src), ctypes.byref(opts))
+
+ if rc != 0:
+ raise PixpatError(
+ f'pixpat_convert failed (rc={rc}); check format names, dimensions, '
+ f'and that src.fmt is a supported source format'
+ )
+
+
+__all__ = [
+ 'Buffer',
+ 'Rec',
+ 'Range',
+ 'PixpatError',
+ 'supported_formats',
+ 'is_supported',
+ 'draw_pattern',
+ 'convert',
+]
diff --git a/pixpat-python/pixpat/_lib/.gitkeep b/pixpat-python/pixpat/_lib/.gitkeep
new file mode 100644
index 0000000..7319712
--- /dev/null
+++ b/pixpat-python/pixpat/_lib/.gitkeep
@@ -0,0 +1,3 @@
+# Placeholder so the _lib/ directory exists in source control.
+# `pip install .` and `pip install -e .` populate libpixpat.so.0 here
+# (copy and symlink respectively); see setup.py.
diff --git a/pixpat-python/pixpat/_native.py b/pixpat-python/pixpat/_native.py
new file mode 100644
index 0000000..46fe452
--- /dev/null
+++ b/pixpat-python/pixpat/_native.py
@@ -0,0 +1,203 @@
+"""ctypes plumbing for libpixpat.
+
+Private module — public API lives in :mod:`pixpat`. Loads the bundled
+shared library, declares the C structs and function signatures, and
+provides :func:`_fill_buffer` to translate Python plane sequences into
+the C ``pixpat_buffer`` layout while pinning the underlying memory via
+the buffer protocol.
+
+The buffer-protocol path uses ``PyObject_GetBuffer`` / ``PyBuffer_Release``
+directly (rather than ctypes ``from_buffer``) so that read-only inputs
+work for the ``src`` side of :func:`pixpat.convert`. ``from_buffer``
+unconditionally requires a writable buffer.
+"""
+
+import ctypes
+import os
+import pathlib
+from typing import Sequence
+
+_PIXPAT_MAX_PLANES = 4
+
+_lib_override = os.environ.get('PIXPAT_LIB')
+if _lib_override:
+ if not pathlib.Path(_lib_override).exists():
+ raise ImportError(f'pixpat: PIXPAT_LIB={_lib_override} does not exist')
+ _lib = ctypes.CDLL(_lib_override)
+else:
+ _lib_dir = pathlib.Path(__file__).parent / '_lib'
+ _so_candidates = sorted(_lib_dir.glob('libpixpat.so*'))
+ if not _so_candidates:
+ raise ImportError(f'pixpat: no libpixpat.so* found in {_lib_dir}')
+ _lib = ctypes.CDLL(str(_so_candidates[0]))
+
+
+class _Buffer(ctypes.Structure):
+ _fields_ = [
+ ('format', ctypes.c_char_p),
+ ('width', ctypes.c_uint32),
+ ('height', ctypes.c_uint32),
+ ('num_planes', ctypes.c_uint32),
+ ('planes', ctypes.c_void_p * _PIXPAT_MAX_PLANES),
+ ('strides', ctypes.c_uint32 * _PIXPAT_MAX_PLANES),
+ ]
+
+
+class _PatternOpts(ctypes.Structure):
+ _fields_ = [
+ ('rec', ctypes.c_int),
+ ('range', ctypes.c_int),
+ ('num_threads', ctypes.c_int),
+ ('params', ctypes.c_char_p),
+ ]
+
+
+class _ConvertOpts(ctypes.Structure):
+ _fields_ = [
+ ('rec', ctypes.c_int),
+ ('range', ctypes.c_int),
+ ('num_threads', ctypes.c_int),
+ ]
+
+
+_lib.pixpat_draw_pattern.argtypes = [
+ ctypes.POINTER(_Buffer),
+ ctypes.c_char_p,
+ ctypes.POINTER(_PatternOpts),
+]
+_lib.pixpat_draw_pattern.restype = ctypes.c_int
+
+_lib.pixpat_convert.argtypes = [
+ ctypes.POINTER(_Buffer),
+ ctypes.POINTER(_Buffer),
+ ctypes.POINTER(_ConvertOpts),
+]
+_lib.pixpat_convert.restype = ctypes.c_int
+
+_lib.pixpat_format_supported.argtypes = [ctypes.c_char_p]
+_lib.pixpat_format_supported.restype = ctypes.c_int
+
+_lib.pixpat_format_count.argtypes = []
+_lib.pixpat_format_count.restype = ctypes.c_size_t
+
+_lib.pixpat_format_name.argtypes = [ctypes.c_size_t]
+_lib.pixpat_format_name.restype = ctypes.c_char_p
+
+
+class _Py_buffer(ctypes.Structure):
+ _fields_ = [
+ ('buf', ctypes.c_void_p),
+ ('obj', ctypes.py_object),
+ ('len', ctypes.c_ssize_t),
+ ('itemsize', ctypes.c_ssize_t),
+ ('readonly', ctypes.c_int),
+ ('ndim', ctypes.c_int),
+ ('format', ctypes.c_char_p),
+ ('shape', ctypes.POINTER(ctypes.c_ssize_t)),
+ ('strides', ctypes.POINTER(ctypes.c_ssize_t)),
+ ('suboffsets', ctypes.POINTER(ctypes.c_ssize_t)),
+ ('internal', ctypes.c_void_p),
+ ]
+
+
+_PyObject_GetBuffer = ctypes.pythonapi.PyObject_GetBuffer
+_PyObject_GetBuffer.argtypes = [
+ ctypes.py_object,
+ ctypes.POINTER(_Py_buffer),
+ ctypes.c_int,
+]
+_PyObject_GetBuffer.restype = ctypes.c_int
+
+_PyBuffer_Release = ctypes.pythonapi.PyBuffer_Release
+_PyBuffer_Release.argtypes = [ctypes.POINTER(_Py_buffer)]
+_PyBuffer_Release.restype = None
+
+_PyBUF_SIMPLE = 0
+_PyBUF_WRITABLE = 0x0001
+
+
+class _PinnedBuffers:
+ """Holds Py_buffer views for the lifetime of a pixpat call.
+
+ Releases each view in ``__exit__`` (or when garbage-collected) so
+ the underlying objects' buffer-export count drops back to zero.
+ """
+
+ def __init__(self) -> None:
+ self._views: list[_Py_buffer] = []
+
+ def acquire(self, obj, *, writable: bool) -> int:
+ view = _Py_buffer()
+ flags = _PyBUF_WRITABLE if writable else _PyBUF_SIMPLE
+ # ctypes.pythonapi propagates the set exception (BufferError /
+ # TypeError) automatically on failure, so no rc check is needed.
+ _PyObject_GetBuffer(obj, ctypes.byref(view), flags)
+ self._views.append(view)
+ return view.buf or 0
+
+ def release(self) -> None:
+ while self._views:
+ view = self._views.pop()
+ _PyBuffer_Release(ctypes.byref(view))
+
+ def __enter__(self) -> '_PinnedBuffers':
+ return self
+
+ def __exit__(self, exc_type, exc, tb) -> None:
+ self.release()
+
+ def __del__(self) -> None:
+ self.release()
+
+
+def _fill_buffer(
+ buf: _Buffer,
+ pins: _PinnedBuffers,
+ planes: Sequence,
+ fmt: str,
+ width: int,
+ height: int,
+ strides: Sequence[int],
+ *,
+ writable: bool,
+ role: str = '',
+) -> None:
+ """Populate ``buf`` from a Python plane sequence, pinning each plane.
+
+ ``writable`` selects whether each plane must be writable: True for
+ destination buffers (the C library writes into them), False for
+ source buffers (the C library only reads).
+ """
+ label = f'{role} ' if role else ''
+ if len(planes) > _PIXPAT_MAX_PLANES:
+ raise ValueError(f'too many {label}planes: {len(planes)} (max {_PIXPAT_MAX_PLANES})')
+ if len(strides) != len(planes):
+ raise ValueError(f'{label}strides has {len(strides)} entries, expected {len(planes)}')
+
+ plane_ptrs = (ctypes.c_void_p * _PIXPAT_MAX_PLANES)()
+ stride_arr = (ctypes.c_uint32 * _PIXPAT_MAX_PLANES)()
+ for i, plane in enumerate(planes):
+ try:
+ addr = pins.acquire(plane, writable=writable)
+ except BufferError as e:
+ kind = 'writable' if writable else 'buffer-protocol'
+ raise TypeError(f'{label}plane {i} does not support the {kind} interface: {e}') from e
+ plane_ptrs[i] = addr
+ stride_arr[i] = strides[i]
+
+ buf.format = fmt.encode('ascii')
+ buf.width = width
+ buf.height = height
+ buf.num_planes = len(planes)
+ buf.planes = plane_ptrs
+ buf.strides = stride_arr
+
+
+__all__ = [
+ '_Buffer',
+ '_PatternOpts',
+ '_ConvertOpts',
+ '_PinnedBuffers',
+ '_lib',
+ '_fill_buffer',
+]