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.
- pypixelpack/__init__.py +29 -0
- pypixelpack/_backend.py +72 -0
- pypixelpack/encoding.py +287 -0
- pypixelpack/layouts.py +333 -0
- pypixelpack/py.typed +0 -0
- pypixelpack-0.2.0.dist-info/METADATA +138 -0
- pypixelpack-0.2.0.dist-info/RECORD +9 -0
- pypixelpack-0.2.0.dist-info/WHEEL +4 -0
- pypixelpack-0.2.0.dist-info/licenses/LICENSE +28 -0
pypixelpack/__init__.py
ADDED
|
@@ -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
|
+
]
|
pypixelpack/_backend.py
ADDED
|
@@ -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())
|
pypixelpack/encoding.py
ADDED
|
@@ -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,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.
|