pypixelpack 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ """Pixel layouts and wire encodings, device-free (§spec:problem).
2
+
3
+ numpy is the reference backend and the only import; any other array
4
+ namespace is the caller's to supply (§spec:package-shape).
5
+ """
6
+
7
+ from pypixelpack.encoding import MATRICES, decode, encode, encoding_for, legal_codes
8
+ from pypixelpack.layouts import (
9
+ ENCODINGS,
10
+ LAYOUTS,
11
+ SUBSAMPLED_422,
12
+ pack,
13
+ row_bytes,
14
+ unpack,
15
+ )
16
+
17
+ __all__ = [
18
+ "ENCODINGS",
19
+ "LAYOUTS",
20
+ "MATRICES",
21
+ "SUBSAMPLED_422",
22
+ "decode",
23
+ "encode",
24
+ "encoding_for",
25
+ "legal_codes",
26
+ "pack",
27
+ "row_bytes",
28
+ "unpack",
29
+ ]
@@ -0,0 +1,72 @@
1
+ """Duck-typed helpers over a caller-supplied array namespace (§spec:backend).
2
+
3
+ The ``xp`` parameter is the backend mechanism: numpy on CPU hosts, torch
4
+ on GPU hosts. Nothing here imports a backend. Only the spellings that
5
+ differ between the two live here; everything both spell the same is
6
+ called raw in ``layouts`` and ``encoding``.
7
+
8
+ The contract a namespace has to satisfy — recorded here because it is
9
+ the answer to "would backend X work?":
10
+
11
+ - ``asarray``, and ``zeros`` accepting ``dtype`` and ``device`` (numpy 2
12
+ takes ``device="cpu"``, and every array carries ``.device``),
13
+ - ``stack``/``concatenate`` taking ``axis``, ``full_like``, and ``flip``
14
+ taking the axes positionally (torch spells the keyword ``dims``; the
15
+ one call site is ``layouts._swap_word_bytes``),
16
+ - ``round`` (half to even) and ``clip`` taking scalar bounds, on float
17
+ arrays, and ``*``, ``/``, ``+``, ``-`` between a float array and a
18
+ Python float without widening the array (numpy 2 and torch both keep
19
+ float32),
20
+ - dtype attributes ``int32``, ``int64``, ``uint8``, ``uint16`` and
21
+ ``float32``,
22
+ - ``astype`` or ``to`` for dtype conversion, and ``ascontiguousarray``
23
+ on the namespace or ``contiguous`` on the array,
24
+ - ``view(dtype)`` on the array reinterpreting the last axis,
25
+ - ``&``, ``|``, ``<<``, ``>>`` on integer arrays.
26
+
27
+ **Why every shifted intermediate is int64.** numpy would take uint32,
28
+ but torch's unsigned support stops at uint8 for most kernels, and a
29
+ signed 32-bit word would sign-extend on ``>>``. int64 holds every 32-bit
30
+ word and every shift this library performs; the 8-bit layouts shift
31
+ nothing and stay uint8 throughout.
32
+
33
+ **Why serialisation is a dtype view.** A 32-bit word becomes four bytes
34
+ by reinterpreting memory, not by four shift-and-mask passes — that is
35
+ what ``view`` is for, and it is the same call on both backends. It
36
+ assumes a little-endian host, which ``layouts`` checks once at import.
37
+
38
+ **Why the range check is skipped under a compiler.** Comparing a
39
+ reduction against a Python int is control flow on array values, which
40
+ ``torch.compile`` cannot trace (§spec:backend fusion). Eager calls keep
41
+ the check; a compiled caller has already accepted the cost of trusting
42
+ its own inputs.
43
+ """
44
+
45
+ from typing import Any
46
+
47
+
48
+ def astype(array: Any, dtype: Any) -> Any:
49
+ """``array`` as ``dtype`` — numpy's ``astype`` or torch's ``to``.
50
+
51
+ Neither copies when the dtype already matches.
52
+ """
53
+ if hasattr(array, "astype"):
54
+ return array.astype(dtype, copy=False)
55
+ return array.to(dtype)
56
+
57
+
58
+ def contiguous(xp: Any, array: Any) -> Any:
59
+ """``array`` with a contiguous layout, so ``view(dtype)`` is legal.
60
+
61
+ A no-op on both backends when the array already is.
62
+ """
63
+ if hasattr(array, "contiguous"):
64
+ return array.contiguous()
65
+ return xp.ascontiguousarray(array)
66
+
67
+
68
+ def is_compiling(xp: Any) -> bool:
69
+ """Whether ``xp`` is tracing the caller for compilation; false for a
70
+ namespace with no compiler (numpy)."""
71
+ compiler = getattr(xp, "compiler", None)
72
+ return compiler is not None and bool(compiler.is_compiling())
@@ -0,0 +1,287 @@
1
+ """RGB to component samples and back (§spec:encoding).
2
+
3
+ ``encode(rgb)`` takes ``(height, width, 3)`` float RGB in [0, 1] and
4
+ returns ``(height, width, 3)`` ``uint16`` ``[Y, Cb, Cr]`` codes;
5
+ ``decode(ycbcr)`` returns ``float32`` RGB clamped to [0, 1]. An encoding
6
+ is a colour matrix, a level range (``narrow``, ``full``), a chroma
7
+ subsampling (``444``, or ``422`` by pair average) and a bit depth; a
8
+ layout name selects the depth and subsampling a wire format expects.
9
+ Both directions take the array namespace as ``xp`` (§spec:backend), and
10
+ every array operation is whole-array, so the torch path traces under
11
+ ``torch.compile``.
12
+
13
+ Rounding is half to even and the arithmetic is float32 on both
14
+ backends, so host and device produce the same codes; the operation
15
+ order is the GPU render pipeline's this was seeded from, which a
16
+ byte-identity check pins.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, NamedTuple
22
+
23
+ import numpy as np
24
+
25
+ from pypixelpack._backend import astype
26
+ from pypixelpack.layouts import ENCODINGS
27
+
28
+ __all__ = [
29
+ "MATRICES",
30
+ "LegalCodes",
31
+ "decode",
32
+ "encode",
33
+ "encoding_for",
34
+ "legal_codes",
35
+ ]
36
+
37
+ # Luma coefficients (KR, KB) per matrix; KG is what remains.
38
+ MATRICES: dict[str, tuple[float, float]] = {
39
+ "bt709": (0.2126, 0.0722), # ITU-R BT.709-6 Table 3
40
+ "bt2020": (0.2627, 0.0593), # ITU-R BT.2020-2 Table 4
41
+ }
42
+
43
+ # Narrow range is defined from 8 bits up (BT.709-6 section 4.4: luma 16
44
+ # to 235, chroma 16 to 240 about 128), shifted up by ``bits - 8``. Full
45
+ # range is every code, chroma about ``2^(bits-1)`` (H.273 with
46
+ # VideoFullRangeFlag set). Codes are carried as uint16.
47
+ _MIN_BITS, _MAX_BITS = 8, 16
48
+ _NARROW_8BIT = {"luma": (16, 235), "chroma": (16, 240), "chroma_mid": 128}
49
+ _LEVELS = ("narrow", "full")
50
+ _DEFAULT_BITS, _DEFAULT_SUBSAMPLING = 10, "444"
51
+
52
+
53
+ class LegalCodes(NamedTuple):
54
+ """The integer codes a level range can represent at a depth."""
55
+
56
+ luma: range
57
+ chroma: range
58
+ chroma_mid: int
59
+ """The code carrying no colour difference — a neutral patch's Cb and Cr.
60
+
61
+ Exposed because it cannot be derived from `chroma` alone: narrow
62
+ 10-bit chroma runs 64..960, whose midpoint is 512 rather than the 512
63
+ a caller would get from the range's own bounds. A consumer driving
64
+ neutral codes onto the wire needs it, and re-deriving `1 << (bits - 1)`
65
+ outside this library puts the wire's arithmetic somewhere that does
66
+ not own it.
67
+ """
68
+
69
+
70
+ class _Span(NamedTuple):
71
+ """Legal luma and chroma codes, and the achromatic chroma code."""
72
+
73
+ luma: range
74
+ chroma: range
75
+ chroma_mid: int
76
+
77
+
78
+ def _matrix(name: str) -> tuple[float, float, float]:
79
+ coefficients = MATRICES.get(name)
80
+ if coefficients is None:
81
+ raise ValueError(f"unknown matrix: {name!r}")
82
+ kr, kb = coefficients
83
+ return kr, 1.0 - kr - kb, kb
84
+
85
+
86
+ def _span(levels: str, bits: int) -> _Span:
87
+ if levels not in _LEVELS:
88
+ raise ValueError(f"unknown levels: {levels!r}")
89
+ if not _MIN_BITS <= bits <= _MAX_BITS:
90
+ raise ValueError(f"bits must be within {_MIN_BITS}..{_MAX_BITS}, got {bits}")
91
+ if levels == "narrow":
92
+ shift = bits - _MIN_BITS
93
+ (y_lo, y_hi), (c_lo, c_hi) = _NARROW_8BIT["luma"], _NARROW_8BIT["chroma"]
94
+ return _Span(
95
+ range(y_lo << shift, (y_hi << shift) + 1),
96
+ range(c_lo << shift, (c_hi << shift) + 1),
97
+ _NARROW_8BIT["chroma_mid"] << shift,
98
+ )
99
+ every = range(1 << bits)
100
+ return _Span(every, every, 1 << (bits - 1))
101
+
102
+
103
+ def legal_codes(*, levels: str = "narrow", bits: int = _DEFAULT_BITS) -> LegalCodes:
104
+ """The luma and chroma codes ``levels`` represents at ``bits``.
105
+
106
+ ``len(legal_codes().luma)`` is 877: narrow range cannot represent
107
+ every 10-bit code, and a caller driving exact values needs to know
108
+ which ones survive.
109
+ """
110
+ span = _span(levels, bits)
111
+ return LegalCodes(span.luma, span.chroma, span.chroma_mid)
112
+
113
+
114
+ def encoding_for(layout: str) -> tuple[int, str]:
115
+ """The ``(bits, subsampling)`` a component layout carries."""
116
+ encoding = ENCODINGS.get(layout)
117
+ if encoding is None:
118
+ raise ValueError(f"{layout!r} carries no component encoding")
119
+ return encoding
120
+
121
+
122
+ def _resolve(
123
+ layout: str | None, bits: int | None, subsampling: str | None
124
+ ) -> tuple[int, str]:
125
+ """Depth and subsampling from a layout, explicit keywords, or defaults.
126
+
127
+ A layout names what the wire expects; a keyword that contradicts it
128
+ is refused rather than silently overridden.
129
+ """
130
+ if layout is not None:
131
+ l_bits, l_sub = encoding_for(layout)
132
+ if bits not in (None, l_bits) or subsampling not in (None, l_sub):
133
+ raise ValueError(
134
+ f"{layout!r} carries {l_bits}-bit {l_sub}; "
135
+ f"bits={bits!r}, subsampling={subsampling!r} contradict it"
136
+ )
137
+ return l_bits, l_sub
138
+ return (
139
+ _DEFAULT_BITS if bits is None else bits,
140
+ _DEFAULT_SUBSAMPLING if subsampling is None else subsampling,
141
+ )
142
+
143
+
144
+ def _channels(xp: Any, pixels: Any) -> tuple[Any, Any, Any]:
145
+ arr = xp.asarray(pixels)
146
+ if arr.ndim != 3 or arr.shape[2] != 3:
147
+ raise ValueError(
148
+ f"pixels must have shape (height, width, 3), got {tuple(arr.shape)}"
149
+ )
150
+ arr = astype(arr, xp.float32)
151
+ return arr[..., 0], arr[..., 1], arr[..., 2]
152
+
153
+
154
+ def _quantise(xp: Any, normalised: Any, span: range, offset: int) -> Any:
155
+ """A normalised component to its code: scale, offset, round, clamp.
156
+
157
+ In place after the first product — four full-frame temporaries
158
+ become one, and the operation order is unchanged.
159
+ """
160
+ t = normalised * (len(span) - 1)
161
+ t += offset
162
+ xp.round(t, out=t)
163
+ xp.clip(t, span.start, span[-1], out=t)
164
+ return t
165
+
166
+
167
+ def _chroma(component: Any, y_n: Any, k: float) -> Any:
168
+ """``0.5 * (component - y) / (1 - k)`` in bm's operation order, in place."""
169
+ t = component - y_n
170
+ t *= 0.5
171
+ t /= 1.0 - k
172
+ return t
173
+
174
+
175
+ def _codes_444(xp: Any, y: Any, cb: Any, cr: Any, span: _Span) -> Any:
176
+ return xp.stack(
177
+ (
178
+ _quantise(xp, y, span.luma, span.luma.start),
179
+ _quantise(xp, cb, span.chroma, span.chroma_mid),
180
+ _quantise(xp, cr, span.chroma, span.chroma_mid),
181
+ ),
182
+ axis=-1,
183
+ )
184
+
185
+
186
+ def _codes_422(xp: Any, y: Any, cb: Any, cr: Any, span: _Span) -> Any:
187
+ """Chroma averaged over each horizontal pair and written to both pixels.
188
+
189
+ Averaged on the normalised value, quantised at half width, then
190
+ broadcast into pair space beside per-pixel luma, so the chroma
191
+ arithmetic runs once per pair rather than once per pixel. An odd
192
+ trailing column is its own pair.
193
+ """
194
+ height, width = y.shape
195
+ if width % 2:
196
+ y, cb, cr = (xp.concatenate((p, p[:, -1:]), axis=1) for p in (y, cb, cr))
197
+ pairs = y.shape[1] // 2
198
+
199
+ def halve(plane: Any) -> Any:
200
+ p = plane.reshape(height, pairs, 2)
201
+ return (p[..., 0] + p[..., 1]) * 0.5
202
+
203
+ y_q = _quantise(xp, y, span.luma, span.luma.start).reshape(height, pairs, 2)
204
+ cb_q = _quantise(xp, halve(cb), span.chroma, span.chroma_mid)
205
+ cr_q = _quantise(xp, halve(cr), span.chroma, span.chroma_mid)
206
+ shape = (height, pairs, 2)
207
+ codes = xp.stack(
208
+ (
209
+ y_q,
210
+ xp.broadcast_to(cb_q[..., None], shape),
211
+ xp.broadcast_to(cr_q[..., None], shape),
212
+ ),
213
+ axis=-1,
214
+ )
215
+ return codes.reshape(height, pairs * 2, 3)[:, :width]
216
+
217
+
218
+ _ASSEMBLE = {"444": _codes_444, "422": _codes_422}
219
+
220
+
221
+ def encode(
222
+ rgb: Any,
223
+ *,
224
+ matrix: str = "bt709",
225
+ levels: str = "narrow",
226
+ layout: str | None = None,
227
+ bits: int | None = None,
228
+ subsampling: str | None = None,
229
+ xp: Any = np,
230
+ ) -> Any:
231
+ """``(height, width, 3)`` float RGB in [0, 1] to ``uint16`` ``[Y, Cb, Cr]``.
232
+
233
+ ``layout`` selects the depth and subsampling a wire format expects
234
+ (``"v210"`` is 10-bit 4:2:2, ``"2vuy"`` 8-bit 4:2:2); otherwise
235
+ ``bits`` and ``subsampling`` default to 10 and ``"444"``. Codes clamp to the level range's span.
236
+ On ``xp``, on the input's device.
237
+ """
238
+ kr, kg, kb = _matrix(matrix)
239
+ bits, subsampling = _resolve(layout, bits, subsampling)
240
+ span = _span(levels, bits)
241
+ assemble = _ASSEMBLE.get(subsampling)
242
+ if assemble is None:
243
+ raise ValueError(f"unknown subsampling: {subsampling!r}")
244
+ r, g, b = _channels(xp, rgb)
245
+
246
+ y_n = kr * r + kg * g + kb * b
247
+ codes = assemble(xp, y_n, _chroma(b, y_n, kb), _chroma(r, y_n, kr), span)
248
+ return astype(codes, xp.uint16)
249
+
250
+
251
+ def decode(
252
+ ycbcr: Any,
253
+ *,
254
+ matrix: str = "bt709",
255
+ levels: str = "narrow",
256
+ layout: str | None = None,
257
+ bits: int | None = None,
258
+ xp: Any = np,
259
+ ) -> Any:
260
+ """``(height, width, 3)`` integer ``[Y, Cb, Cr]`` to ``float32`` RGB in [0, 1].
261
+
262
+ Inverse of :func:`encode` to within half a code per component. Per
263
+ pixel: a 4:2:2 array as ``unpack`` or ``encode`` produce already has
264
+ its chroma in both pixels of each pair. Any integer dtype is accepted.
265
+ """
266
+ kr, kg, kb = _matrix(matrix)
267
+ bits, _ = _resolve(layout, bits, None)
268
+ span = _span(levels, bits)
269
+ y, cb, cr = _channels(xp, ycbcr)
270
+
271
+ y_n = (y - span.luma.start) / (len(span.luma) - 1)
272
+ cb_n = (cb - span.chroma_mid) / (len(span.chroma) - 1)
273
+ cr_n = (cr - span.chroma_mid) / (len(span.chroma) - 1)
274
+
275
+ # Inverse matrix coefficients, YCbCr -> RGB; accumulated in place
276
+ # with the addition order kept.
277
+ red = (2.0 * (1.0 - kr)) * cr_n
278
+ red += y_n
279
+ green = (-2.0 * kb * (1.0 - kb) / kg) * cb_n
280
+ green += y_n
281
+ green += (-2.0 * kr * (1.0 - kr) / kg) * cr_n
282
+ blue = (2.0 * (1.0 - kb)) * cb_n
283
+ blue += y_n
284
+
285
+ rgb = xp.stack((red, green, blue), axis=-1)
286
+ xp.clip(rgb, 0.0, 1.0, out=rgb)
287
+ return rgb
pypixelpack/layouts.py ADDED
@@ -0,0 +1,333 @@
1
+ """Pack integer RGB/YUV pixel values into wire layouts (§spec:layouts).
2
+
3
+ ``pack(pixels, layout, row_bytes)`` returns a 1-D ``uint8`` buffer of
4
+ ``height * row_bytes``. ``unpack(data, layout, width, height, row_bytes)``
5
+ recovers pixel values from such a buffer. ``unpack(pack(x)) == x`` for
6
+ every layout.
7
+
8
+ Pixel arrays are ``(height, width, 3)`` integer arrays. For RGB layouts
9
+ the channels are ``[R, G, B]``; the alpha channel of ARGB/BGRA is written
10
+ at peak on pack and dropped on unpack. For the 4:2:2 YUV layouts v210
11
+ and 2vuy the channels are ``[Y, Cb, Cr]``; chroma is sampled from even
12
+ columns on pack and replicated across each pair on unpack, so round-trip
13
+ identity holds when chroma is equal within each horizontal pair.
14
+
15
+ Both functions take the array namespace as ``xp`` — numpy by default,
16
+ torch for a frame that stays on its device — and every array operation
17
+ is whole-array: no branch reads a pixel, so the torch path traces under
18
+ ``torch.compile`` (§spec:backend).
19
+
20
+ Each layout is data: its geometry in ``LAYOUTS``, and one bit map that
21
+ both ``pack`` and ``unpack`` read, so the two directions cannot disagree.
22
+ Layouts follow the DeckLink SDK 15.3 manual section 3.4.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import sys
28
+ from typing import Any
29
+
30
+ import numpy as np
31
+
32
+ from pypixelpack._backend import astype, contiguous, is_compiling
33
+
34
+ __all__ = ["ENCODINGS", "LAYOUTS", "SUBSAMPLED_422", "pack", "row_bytes", "unpack"]
35
+
36
+ if sys.byteorder != "little": # pragma: no cover
37
+ raise ImportError(
38
+ "pypixelpack serialises words by memory view on little-endian hosts"
39
+ )
40
+
41
+ # Channel indices within a pixel triple.
42
+ _R, _G, _B = 0, 1, 2
43
+
44
+ # (group_pixels, group_bytes, bit_depth) per layout. bit_depth is not
45
+ # derivable from the group size (argb and r210 share (1, 4) but pack 8 vs
46
+ # 10 bits), so it is carried explicitly.
47
+ LAYOUTS: dict[str, tuple[int, int, int]] = {
48
+ "argb": (1, 4, 8),
49
+ "bgra": (1, 4, 8),
50
+ "r210": (1, 4, 10),
51
+ "r10b": (1, 4, 10),
52
+ "r10l": (1, 4, 10),
53
+ "v210": (6, 16, 10),
54
+ "2vuy": (2, 4, 8),
55
+ "r12b": (8, 36, 12),
56
+ "r12l": (8, 36, 12),
57
+ }
58
+
59
+ # The component encoding a layout carries — (bits, subsampling) — for the
60
+ # layouts that carry one; RGB layouts take code values as given.
61
+ ENCODINGS: dict[str, tuple[int, str]] = {
62
+ "v210": (10, "422"),
63
+ "2vuy": (8, "422"),
64
+ }
65
+
66
+ # Layouts whose chroma is shared across each horizontal pair.
67
+ SUBSAMPLED_422: frozenset[str] = frozenset(
68
+ name for name, (_, subsampling) in ENCODINGS.items() if subsampling == "422"
69
+ )
70
+
71
+ # Memory order of a group's bytes for the 8-bit layouts: (pixel in
72
+ # group, channel), or ``None`` for the alpha byte. On unpack a component
73
+ # with no byte of its own reads pixel 0's, which is the 4:2:2 rule.
74
+ _Y, _CB, _CR = 0, 1, 2
75
+ _BYTE_ORDER: dict[str, tuple[tuple[int, int] | None, ...]] = {
76
+ "argb": (None, (0, _R), (0, _G), (0, _B)),
77
+ "bgra": ((0, _B), (0, _G), (0, _R), None),
78
+ "2vuy": ((0, _CB), (0, _Y), (0, _CR), (1, _Y)),
79
+ }
80
+
81
+ # Bit offset of R, G, B within the 32-bit word, and whether the word is
82
+ # stored big-endian. r210 packs 2:10:10:10; r10b/r10l pack 10:10:10:2.
83
+ _RGB10: dict[str, tuple[tuple[int, int, int], bool]] = {
84
+ "r210": ((20, 10, 0), True),
85
+ "r10b": ((22, 12, 2), True),
86
+ "r10l": ((22, 12, 2), False),
87
+ }
88
+
89
+ # 12-bit RGB is a plain 12-bit little-endian bitstream of R0 G0 B0 R1 …,
90
+ # three bytes per pair of components; r12b stores each 32-bit word
91
+ # byte-swapped. Component pairs per 8-pixel group.
92
+ _R12_PAIRS = 12
93
+
94
+
95
+ def _layout(layout: str) -> tuple[int, int, int]:
96
+ spec = LAYOUTS.get(layout)
97
+ if spec is None:
98
+ raise ValueError(f"unknown layout: {layout!r}")
99
+ return spec
100
+
101
+
102
+ def row_bytes(layout: str, width: int) -> int:
103
+ """The smallest ``row_bytes`` that holds ``width`` pixels of ``layout``."""
104
+ group_px, group_bytes, _ = _layout(layout)
105
+ return ((width + group_px - 1) // group_px) * group_bytes
106
+
107
+
108
+ _min_row_bytes = row_bytes # `row_bytes` is also a parameter name below
109
+
110
+
111
+ def pack(pixels: Any, layout: str, row_bytes: int, *, xp: Any = np) -> Any:
112
+ """Pack ``(height, width, 3)`` integer pixel values into ``layout``.
113
+
114
+ Returns a 1-D ``uint8`` array of length ``height * row_bytes`` on
115
+ ``xp``, on the input's device. ``row_bytes`` must be at least the
116
+ packed active-line size; extra bytes are zero padding.
117
+ """
118
+ group_px, _, bits = _layout(layout)
119
+ arr = xp.asarray(pixels)
120
+ if arr.ndim != 3 or arr.shape[2] != 3:
121
+ raise ValueError(
122
+ f"pixels must have shape (height, width, 3), got {tuple(arr.shape)}"
123
+ )
124
+ height, width, _ = arr.shape
125
+
126
+ min_row = _min_row_bytes(layout, width)
127
+ if row_bytes < min_row:
128
+ raise ValueError(
129
+ f"row_bytes={row_bytes} too small for width={width} "
130
+ f"({layout!r} needs at least {min_row})"
131
+ )
132
+
133
+ # A host-side assertion on the unwidened input: a compiler cannot
134
+ # trace it (see _backend). torch has no uint16 reduction, so that
135
+ # one dtype widens to int32 for the read.
136
+ if not is_compiling(xp) and height and width:
137
+ probe = astype(arr, xp.int32) if arr.dtype == xp.uint16 else arr
138
+ if int(probe.max()) > (1 << bits) - 1:
139
+ raise ValueError(f"pixel value exceeds {bits}-bit range for {layout!r}")
140
+
141
+ # 8-bit layouts shuffle bytes and never shift; the rest work in int64.
142
+ src = astype(arr, xp.uint8 if bits == 8 else xp.int64)
143
+
144
+ padded_w = -(-width // group_px) * group_px
145
+ if padded_w != width:
146
+ pad = xp.zeros(
147
+ (height, padded_w - width, 3), dtype=src.dtype, device=src.device
148
+ )
149
+ src = xp.concatenate([src, pad], axis=1)
150
+
151
+ packer, _ = _CODECS[layout]
152
+ group_data = packer(xp, layout, src) # (height, min_row)
153
+
154
+ if row_bytes != min_row:
155
+ pad = xp.zeros((height, row_bytes - min_row), dtype=xp.uint8, device=src.device)
156
+ group_data = xp.concatenate([group_data, pad], axis=1)
157
+ # A DMA consumer reads from the raw pointer, so the buffer is
158
+ # contiguous by contract, not by the luck of a copy having happened
159
+ # upstream: a one-word big-endian frame reaches here as a flipped view.
160
+ return contiguous(xp, group_data.reshape(-1))
161
+
162
+
163
+ def unpack(
164
+ data: Any,
165
+ layout: str,
166
+ width: int,
167
+ height: int,
168
+ row_bytes: int,
169
+ *,
170
+ xp: Any = np,
171
+ ) -> Any:
172
+ """Recover ``(height, width, 3)`` pixel values from a ``layout`` buffer.
173
+
174
+ Inverse of :func:`pack`. Returns ``uint8`` values for 8-bit layouts and
175
+ ``uint16`` for 10/12-bit layouts, on ``xp``, on the input's device.
176
+ """
177
+ buf = astype(xp.asarray(data), xp.uint8).reshape(-1)
178
+ if buf.shape[0] < height * row_bytes:
179
+ raise ValueError(
180
+ f"data too small: got {buf.shape[0]} bytes, need {height * row_bytes}"
181
+ )
182
+ rows = buf[: height * row_bytes].reshape(height, row_bytes)
183
+ group_data = rows[:, : _min_row_bytes(layout, width)]
184
+
185
+ _, unpacker = _CODECS[layout]
186
+ return unpacker(xp, layout, group_data)[:, :width, :]
187
+
188
+
189
+ # --- word serialisation -----------------------------------------------------
190
+
191
+
192
+ def _swap_word_bytes(xp: Any, b: Any) -> Any:
193
+ """Reverse the four bytes of every 32-bit word in a byte array."""
194
+ words = b.reshape(*b.shape[:-1], -1, 4)
195
+ return xp.flip(words, (-1,)).reshape(b.shape)
196
+
197
+
198
+ def _words_to_bytes(xp: Any, words: Any, big_endian: bool) -> Any:
199
+ """(..., N) int64 words -> (..., N*4) uint8, by memory view."""
200
+ w32 = astype(words, xp.int32) # wraps bit 31 identically on both backends
201
+ out = w32.reshape(*w32.shape, 1).view(xp.uint8).reshape(*w32.shape[:-1], -1)
202
+ return _swap_word_bytes(xp, out) if big_endian else out
203
+
204
+
205
+ def _bytes_to_words(xp: Any, data: Any, big_endian: bool) -> Any:
206
+ """(..., N*4) uint8 -> (..., N) int64 words, by memory view."""
207
+ b = _swap_word_bytes(xp, data) if big_endian else data
208
+ quads = contiguous(xp, b.reshape(*b.shape[:-1], -1, 4))
209
+ w32 = quads.view(xp.int32).reshape(quads.shape[:-1])
210
+ return astype(w32, xp.int64) & 0xFFFFFFFF # undo the sign extension
211
+
212
+
213
+ # --- per-layout codecs ------------------------------------------------------
214
+ # Every packer takes (xp, layout, src) with src (height, padded_width, 3)
215
+ # and returns (height, min_row) uint8; every unpacker is its inverse on
216
+ # (height, min_row) and returns (height, padded_width, 3).
217
+
218
+
219
+ def _pack_bytes(xp: Any, layout: str, src: Any) -> Any:
220
+ """Every 8-bit layout is a byte shuffle read straight off the order table."""
221
+ height = src.shape[0]
222
+ g = src.reshape(height, -1, LAYOUTS[layout][0], 3)
223
+ alpha = xp.full_like(g[..., 0, 0], 0xFF)
224
+ planes = [alpha if e is None else g[..., e[0], e[1]] for e in _BYTE_ORDER[layout]]
225
+ return xp.stack(planes, axis=-1).reshape(height, -1)
226
+
227
+
228
+ def _unpack_bytes(xp: Any, layout: str, data: Any) -> Any:
229
+ height = data.shape[0]
230
+ group_px, group_bytes, _ = LAYOUTS[layout]
231
+ order = _BYTE_ORDER[layout]
232
+ q = data.reshape(height, -1, group_bytes)
233
+ # One stack of views, (pixel, channel) in raster order, each read from
234
+ # its own byte or, for shared chroma, from pixel 0's.
235
+ planes = [
236
+ q[..., order.index((p, c) if (p, c) in order else (0, c))]
237
+ for p in range(group_px)
238
+ for c in (_R, _G, _B)
239
+ ]
240
+ return xp.stack(planes, axis=-1).reshape(height, -1, 3)
241
+
242
+
243
+ def _pack_10bit_rgb(xp: Any, layout: str, src: Any) -> Any:
244
+ (r_lo, g_lo, b_lo), big_endian = _RGB10[layout]
245
+ words = (src[..., _R] << r_lo) | (src[..., _G] << g_lo) | (src[..., _B] << b_lo)
246
+ return _words_to_bytes(xp, words, big_endian)
247
+
248
+
249
+ def _unpack_10bit_rgb(xp: Any, layout: str, data: Any) -> Any:
250
+ shifts, big_endian = _RGB10[layout]
251
+ words = _bytes_to_words(xp, data, big_endian)
252
+ return xp.stack(
253
+ [astype((words >> lo) & 0x3FF, xp.uint16) for lo in shifts], axis=-1
254
+ )
255
+
256
+
257
+ def _pack_v210(xp: Any, layout: str, src: Any) -> Any: # noqa: ARG001 — one codec signature
258
+ height = src.shape[0]
259
+ g = src.reshape(height, -1, 6, 3)
260
+ y, cb, cr = g[..., 0], g[..., 1], g[..., 2] # chroma read at even pixels
261
+ words = xp.stack(
262
+ (
263
+ cb[..., 0] | (y[..., 0] << 10) | (cr[..., 0] << 20),
264
+ y[..., 1] | (cb[..., 2] << 10) | (y[..., 2] << 20),
265
+ cr[..., 2] | (y[..., 3] << 10) | (cb[..., 4] << 20),
266
+ y[..., 4] | (cr[..., 4] << 10) | (y[..., 5] << 20),
267
+ ),
268
+ axis=-1,
269
+ )
270
+ return _words_to_bytes(xp, words, False).reshape(height, -1)
271
+
272
+
273
+ def _unpack_v210(xp: Any, layout: str, data: Any) -> Any: # noqa: ARG001 — one codec signature
274
+ height = data.shape[0]
275
+ words = _bytes_to_words(xp, data.reshape(height, -1, 16), False)
276
+ w0, w1, w2, w3 = words[..., 0], words[..., 1], words[..., 2], words[..., 3]
277
+
278
+ def field(word: Any, lo: int) -> Any:
279
+ return astype((word >> lo) & 0x3FF, xp.uint16)
280
+
281
+ cb0, cb2, cb4 = field(w0, 0), field(w1, 10), field(w2, 20)
282
+ cr0, cr2, cr4 = field(w0, 20), field(w2, 0), field(w3, 10)
283
+ # (pixel, channel) in raster order with chroma shared per pair: one
284
+ # stack of eighteen views rather than a stack of stacks, which copies
285
+ # every plane twice.
286
+ out = xp.stack(
287
+ (
288
+ field(w0, 10), cb0, cr0, field(w1, 0), cb0, cr0,
289
+ field(w1, 20), cb2, cr2, field(w2, 10), cb2, cr2,
290
+ field(w3, 0), cb4, cr4, field(w3, 20), cb4, cr4,
291
+ ),
292
+ axis=-1,
293
+ ) # fmt: skip
294
+ return out.reshape(height, -1, 3)
295
+
296
+
297
+ def _pack_12bit(xp: Any, layout: str, src: Any) -> Any:
298
+ height = src.shape[0]
299
+ pairs = src.reshape(height, -1, _R12_PAIRS, 2)
300
+ c0, c1 = pairs[..., 0], pairs[..., 1]
301
+ out = xp.stack(
302
+ (
303
+ astype(c0 & 0xFF, xp.uint8),
304
+ astype(((c0 >> 8) & 0xF) | ((c1 & 0xF) << 4), xp.uint8),
305
+ astype(c1 >> 4, xp.uint8),
306
+ ),
307
+ axis=-1,
308
+ ).reshape(height, -1)
309
+ return _swap_word_bytes(xp, out) if layout == "r12b" else out
310
+
311
+
312
+ def _unpack_12bit(xp: Any, layout: str, data: Any) -> Any:
313
+ height = data.shape[0]
314
+ b = _swap_word_bytes(xp, data) if layout == "r12b" else data
315
+ triples = astype(b.reshape(height, -1, _R12_PAIRS, 3), xp.int64)
316
+ b0, b1, b2 = triples[..., 0], triples[..., 1], triples[..., 2]
317
+ c0 = astype(b0 | ((b1 & 0xF) << 8), xp.uint16)
318
+ c1 = astype((b1 >> 4) | (b2 << 4), xp.uint16)
319
+ return xp.stack((c0, c1), axis=-1).reshape(height, -1, 3)
320
+
321
+
322
+ _CODECS: dict[str, tuple[Any, Any]] = {
323
+ "argb": (_pack_bytes, _unpack_bytes),
324
+ "bgra": (_pack_bytes, _unpack_bytes),
325
+ "r210": (_pack_10bit_rgb, _unpack_10bit_rgb),
326
+ "r10b": (_pack_10bit_rgb, _unpack_10bit_rgb),
327
+ "r10l": (_pack_10bit_rgb, _unpack_10bit_rgb),
328
+ "v210": (_pack_v210, _unpack_v210),
329
+ "2vuy": (_pack_bytes, _unpack_bytes),
330
+ "r12b": (_pack_12bit, _unpack_12bit),
331
+ "r12l": (_pack_12bit, _unpack_12bit),
332
+ }
333
+ assert set(_CODECS) == set(LAYOUTS)
pypixelpack/py.typed ADDED
File without changes
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.5
2
+ Name: pypixelpack
3
+ Version: 0.2.0
4
+ Summary: Pixel layouts and wire encodings for video I/O, device-free
5
+ Project-URL: Homepage, https://github.com/Fuse-Technical-Group/pypixelpack
6
+ Project-URL: Repository, https://github.com/Fuse-Technical-Group/pypixelpack.git
7
+ Project-URL: Issues, https://github.com/Fuse-Technical-Group/pypixelpack/issues
8
+ Project-URL: Changelog, https://github.com/Fuse-Technical-Group/pypixelpack/blob/main/CHANGELOG.md
9
+ Author-email: Ritchie Argue <4462072+repentsinner@users.noreply.github.com>
10
+ License-Expression: BSD-3-Clause
11
+ License-File: LICENSE
12
+ Keywords: decklink,pixel-format,r210,sdi,st2110,v210,ycbcr
13
+ Classifier: Development Status :: 2 - Pre-Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Multimedia :: Video
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.12
25
+ Requires-Dist: numpy>=2.3.1
26
+ Description-Content-Type: text/markdown
27
+
28
+ # pypixelpack
29
+
30
+ **Pixel layouts and wire encodings for video I/O, device-free.**
31
+
32
+ The bytes a video frame becomes on a wire — v210, r210, the ST 2110-20
33
+ pgroup — and the encoding between a frame's RGB and those bytes: colour
34
+ matrix, range, chroma subsampling. Written once against a caller-supplied
35
+ array namespace, so the same source packs on numpy for a host frame and
36
+ on torch for a frame that never leaves a GPU.
37
+
38
+ Extracted from [pydecklink](https://github.com/Fuse-Technical-Group/pydecklink)'s
39
+ packing module and a GPU render pipeline's colorspace node, which held
40
+ the same layout twice. This repository's [SPEC.md](SPEC.md) and
41
+ [ROADMAP.md](ROADMAP.md) govern the package.
42
+
43
+ ## Installation
44
+
45
+ ```sh
46
+ uv add pypixelpack
47
+ ```
48
+
49
+ numpy is the only dependency. torch is a namespace the caller supplies,
50
+ never a dependency of this package (`§spec:backend`).
51
+
52
+ ## Usage
53
+
54
+ ```python
55
+ import numpy as np
56
+ from pypixelpack import decode, encode, pack, unpack
57
+
58
+ rgb = np.zeros((1080, 1920, 3), dtype=np.float32) # [R, G, B] in [0, 1]
59
+ codes = encode(rgb, subsampling="422") # (H, W, 3) uint16 [Y, Cb, Cr]
60
+ data = pack(codes, "v210", row_bytes=5120) # 1-D uint8, DMA-ready
61
+ back = unpack(data, "v210", width=1920, height=1080, row_bytes=5120)
62
+ rgb_again = decode(back, subsampling="422") # float32 [R, G, B] in [0, 1]
63
+ ```
64
+
65
+ On a GPU host, pass the namespace and the arrays stay resident:
66
+
67
+ ```python
68
+ import torch
69
+
70
+ codes = encode(rgb_on_cuda, subsampling="422", xp=torch)
71
+ data = pack(codes, "v210", row_bytes=5120, xp=torch)
72
+ ```
73
+
74
+ ## API
75
+
76
+ - `pack(pixels, layout, row_bytes, *, xp=numpy)` — `(H, W, 3)` integer
77
+ samples to a 1-D `uint8` buffer of `H × row_bytes` in `layout`, on the
78
+ input's device (`§spec:layouts`). RGB layouts take `[R, G, B]`; `v210`
79
+ and `2vuy` take `[Y, Cb, Cr]` with chroma read from even columns.
80
+ - `unpack(data, layout, width, height, row_bytes, *, xp=numpy)` — the
81
+ inverse; `unpack(pack(x)) == x` for every layout. Returns `uint8` for
82
+ 8-bit layouts and `uint16` otherwise.
83
+ - `row_bytes(layout, width)` — the smallest `row_bytes` that holds a line.
84
+ - `LAYOUTS` — the layout table, `name → (pixels per group, bytes per
85
+ group, bit depth)`: `argb`, `bgra`, `r210`, `r10b`, `r10l`, `v210`,
86
+ `2vuy`, `r12b`, `r12l`.
87
+
88
+ `pack` raises `ValueError` for an unknown layout, a `row_bytes` shorter
89
+ than the packed line, or a sample above the layout's bit depth; the
90
+ last check is skipped under `torch.compile`, where a compiled caller
91
+ trusts its own inputs (`§spec:backend`).
92
+
93
+ ### Encoding
94
+
95
+ - `encode(rgb, *, matrix="bt709", levels="narrow", layout=None,
96
+ bits=None, subsampling=None, xp=numpy)` — `(H, W, 3)` float RGB in
97
+ `[0, 1]` to `(H, W, 3)` `uint16` `[Y, Cb, Cr]` (`§spec:encoding`).
98
+ `matrix` is `bt709` or `bt2020`; `levels` is `narrow` (luma 16–235,
99
+ chroma 16–240 at 8 bits, shifted up by `bits - 8`) or `full`.
100
+ `layout="v210"` selects the depth and subsampling that wire format
101
+ expects — 10-bit 4:2:2; `2vuy` is 8-bit 4:2:2 — and a `bits` or
102
+ `subsampling` that contradicts it is refused; without a layout they
103
+ default to 10 and `444`. `422` averages chroma over each horizontal
104
+ pair and writes it to both pixels, the shape `pack` reads. Rounding is
105
+ half to even, arithmetic is float32 on every backend, and codes clamp
106
+ to the level range's span.
107
+ - `decode(ycbcr, *, matrix, levels, layout=None, bits=None, xp=numpy)`
108
+ — the inverse, per pixel, to within half a code per component;
109
+ returns `float32` RGB clamped to `[0, 1]`.
110
+ - `encoding_for(layout)` — the `(bits, subsampling)` a layout carries;
111
+ `ENCODINGS` is the table behind it.
112
+ - `legal_codes(*, levels="narrow", bits=10)` — the `(luma, chroma)` code
113
+ spans as `range` objects; `len(legal_codes().luma)` is 877, the levels
114
+ 10-bit narrow range can represent.
115
+ - `MATRICES` — `name → (KR, KB)`: `bt709`, `bt2020`.
116
+
117
+ `encode` and `decode` raise `ValueError` for an unknown matrix, levels or
118
+ subsampling, a `bits` outside 8–16, or a layout with no component
119
+ encoding.
120
+
121
+ ## Development
122
+
123
+ ```sh
124
+ uv sync
125
+ uv run ruff format --check . && uv run ruff check . && uv run pyright && uv run pytest
126
+ ```
127
+
128
+ `uv sync` installs torch (CPU) into the dev group so the parity suite
129
+ runs; without it those tests skip. To prove the wheel on numpy alone:
130
+
131
+ ```sh
132
+ uv build
133
+ uv run --isolated --no-project --with dist/*.whl python tools/check_core_install.py
134
+ ```
135
+
136
+ ## License
137
+
138
+ BSD-3-Clause. See [LICENSE](LICENSE).
@@ -0,0 +1,9 @@
1
+ pypixelpack/__init__.py,sha256=t4y8JU7JAOLVPfffaQcM92AIei27oKSKdtuDYGaw8f8,602
2
+ pypixelpack/_backend.py,sha256=Xi2f6TJtNEX_CKNTUXbKvFCFMborpssKd8sfVoIiV8s,3080
3
+ pypixelpack/encoding.py,sha256=duUfERv4aet7HpwEhMmW7-899cilhl5Hk3UG6IvXpdw,9751
4
+ pypixelpack/layouts.py,sha256=3tDL5jwk67wuwZOcYRhWBQrKyjc2y1B2YMKlK44fFY8,12783
5
+ pypixelpack/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ pypixelpack-0.2.0.dist-info/METADATA,sha256=0AwwCHvJcqwKAuLnF9MUZ4ekhtLg3y05n58FJ1g5t0c,5700
7
+ pypixelpack-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ pypixelpack-0.2.0.dist-info/licenses/LICENSE,sha256=efHrtfAxjNNuAuqQJA3cQyzVgXu_p5oEzr6jI70XjFU,1506
9
+ pypixelpack-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026 Fuse Technical Group
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.