summaryrefslogtreecommitdiff
path: root/pixpat-python/tests
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/tests
Squashed 'subprojects/pixpat/' content from commit d444626
git-subtree-dir: subprojects/pixpat git-subtree-split: d444626e6ba988ec6d487800721e447f94b1eaf5
Diffstat (limited to 'pixpat-python/tests')
-rw-r--r--pixpat-python/tests/test_basic.py539
-rw-r--r--pixpat-python/tests/test_numpy.py110
-rw-r--r--pixpat-python/tests/test_threading.py187
3 files changed, 836 insertions, 0 deletions
diff --git a/pixpat-python/tests/test_basic.py b/pixpat-python/tests/test_basic.py
new file mode 100644
index 0000000..2ed8bb7
--- /dev/null
+++ b/pixpat-python/tests/test_basic.py
@@ -0,0 +1,539 @@
+"""Smoke tests for the pixpat Python bindings."""
+
+import pytest
+
+import pixpat
+
+
+def test_supported_formats_nonempty():
+ formats = pixpat.supported_formats()
+ assert isinstance(formats, list)
+ assert len(formats) > 0
+ assert all(isinstance(f, str) for f in formats)
+ assert 'XRGB8888' in formats
+ assert 'NV12' in formats
+
+
+def test_is_supported():
+ assert pixpat.is_supported('XRGB8888')
+ assert pixpat.is_supported('NV12')
+ assert not pixpat.is_supported('NOT_A_REAL_FORMAT')
+
+
+def test_draw_pattern_xrgb8888():
+ w, h = 64, 32
+ stride = w * 4
+ data = bytearray(stride * h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ pixpat.draw_pattern(buf, 'smpte')
+ assert any(b != 0 for b in data)
+
+
+def test_draw_pattern_unknown_pattern_raises():
+ data = bytearray(64 * 32 * 4)
+ buf = pixpat.Buffer([data], 'XRGB8888', 64, 32, [64 * 4])
+ with pytest.raises(ValueError):
+ pixpat.draw_pattern(buf, 'bogus')
+
+
+def test_draw_pattern_unknown_format_raises():
+ data = bytearray(64 * 32 * 4)
+ buf = pixpat.Buffer([data], 'NOT_A_REAL_FORMAT', 64, 32, [64 * 4])
+ with pytest.raises(pixpat.PixpatError):
+ pixpat.draw_pattern(buf)
+
+
+def test_draw_pattern_readonly_buffer_raises():
+ data = bytes(64 * 32 * 4)
+ buf = pixpat.Buffer([data], 'XRGB8888', 64, 32, [64 * 4])
+ with pytest.raises((TypeError, BufferError)):
+ pixpat.draw_pattern(buf)
+
+
+def _alloc_xrgb(w, h):
+ stride = w * 4
+ return bytearray(stride * h), stride
+
+
+def _first_pixel_bgr(data):
+ # BGR888-style byte order: pixpat XRGB8888 stores B at byte 0,
+ # G at byte 1, R at byte 2, X at byte 3. See
+ # README "Format names and byte order".
+ return data[0], data[1], data[2]
+
+
+def test_draw_pattern_plain_red_str_params():
+ w, h = 32, 16
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ pixpat.draw_pattern(buf, 'plain', params='color=ff0000')
+ b, g, r = _first_pixel_bgr(data)
+ assert (b, g, r) == (0, 0, 0xFF)
+
+
+def test_draw_pattern_plain_red_dict_params():
+ w, h = 32, 16
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ pixpat.draw_pattern(buf, 'plain', params={'color': 'ff0000'})
+ b, g, r = _first_pixel_bgr(data)
+ assert (b, g, r) == (0, 0, 0xFF)
+
+
+def test_draw_pattern_plain_alpha_passes_through_to_argb():
+ w, h = 16, 8
+ stride = w * 4
+ data = bytearray(stride * h)
+ buf = pixpat.Buffer([data], 'ARGB8888', w, h, [stride])
+ # 8-char form is alpha-first: AARRGGBB.
+ pixpat.draw_pattern(buf, 'plain', params={'color': '80112233'})
+ # ARGB8888 byte order (LSB-first): B, G, R, A
+ assert (data[0], data[1], data[2], data[3]) == (0x33, 0x22, 0x11, 0x80)
+
+
+def test_draw_pattern_plain_accepts_0x_prefix():
+ w, h = 16, 8
+ stride = w * 4
+ data = bytearray(stride * h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ pixpat.draw_pattern(buf, 'plain', params={'color': '0xff0000'})
+ assert (data[0], data[1], data[2]) == (0, 0, 0xFF)
+
+
+def test_draw_pattern_plain_16bpc_rgb():
+ """16-bpc form: 12 hex chars, RRRRGGGGBBBB. ABGR16161616 names
+ A-B-G-R MSB-first in a 64-bit word; stored little-endian, the
+ bytes are R lo, R hi, G lo, G hi, B lo, B hi, A lo, A hi."""
+ w, h = 4, 2
+ stride = w * 8
+ data = bytearray(stride * h)
+ buf = pixpat.Buffer([data], 'ABGR16161616', w, h, [stride])
+ pixpat.draw_pattern(buf, 'plain', params={'color': '0xfedc00000000'})
+ # R=0xFEDC, G=0x0000, B=0x0000, A=0xFFFF (default opaque).
+ pix = bytes(data[:8])
+ assert pix == b'\xdc\xfe\x00\x00\x00\x00\xff\xff'
+
+
+def test_draw_pattern_plain_16bpc_argb():
+ w, h = 4, 2
+ stride = w * 8
+ data = bytearray(stride * h)
+ buf = pixpat.Buffer([data], 'ABGR16161616', w, h, [stride])
+ # 16-char form is alpha-first: AAAARRRRGGGGBBBB.
+ pixpat.draw_pattern(buf, 'plain', params={'color': '0x80007f000000ffff'})
+ pix = bytes(data[:8])
+ # R=0x7F00, G=0x0000, B=0xFFFF, A=0x8000.
+ assert pix == b'\x00\x7f\x00\x00\xff\xff\x00\x80'
+
+
+def test_draw_pattern_plain_missing_color_raises():
+ w, h = 32, 16
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ with pytest.raises(pixpat.PixpatError):
+ pixpat.draw_pattern(buf, 'plain')
+ with pytest.raises(pixpat.PixpatError):
+ pixpat.draw_pattern(buf, 'plain', params='')
+
+
+def test_draw_pattern_plain_malformed_color_raises():
+ w, h = 32, 16
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ for bad in ('color=zzzzzz', 'color=abc', 'color=', 'color=1234567'):
+ with pytest.raises(pixpat.PixpatError):
+ pixpat.draw_pattern(buf, 'plain', params=bad)
+
+
+def test_draw_pattern_malformed_params_string_raises():
+ """Top-level params parse failure (not pattern-specific)."""
+ w, h = 32, 16
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ for bad in ('foo,bar', '=ff0000', ',color=ff0000'):
+ with pytest.raises(pixpat.PixpatError):
+ pixpat.draw_pattern(buf, 'plain', params=bad)
+
+
+def test_draw_pattern_dict_params_with_separators_rejected_by_python():
+ w, h = 32, 16
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ with pytest.raises(ValueError):
+ pixpat.draw_pattern(buf, 'plain', params={'color': 'ff,00,00'})
+ with pytest.raises(ValueError):
+ pixpat.draw_pattern(buf, 'plain', params={'col=or': 'ff0000'})
+
+
+def test_draw_pattern_unknown_params_keys_ignored():
+ """Patterns silently ignore keys they don't read; unknown keys
+ alongside the recognised one still let the call succeed."""
+ w, h = 32, 16
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ pixpat.draw_pattern(buf, 'plain', params={'color': 'ff0000', 'unknown_key': '42'})
+ b, g, r = _first_pixel_bgr(data)
+ assert (b, g, r) == (0, 0, 0xFF)
+
+
+def test_draw_pattern_params_ignored_by_kmstest():
+ """Patterns that don't read params accept arbitrary params strings."""
+ w, h = 32, 16
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ pixpat.draw_pattern(buf, 'kmstest', params={'whatever': 'foo'})
+ assert any(b != 0 for b in data)
+
+
+def _xrgb_pixel_at(data, stride, x, y):
+ off = y * stride + x * 4
+ return data[off], data[off + 1], data[off + 2] # B, G, R
+
+
+@pytest.mark.parametrize(
+ 'pattern,extra',
+ [
+ ('hramp', {}),
+ ('vramp', {}),
+ ('dramp', {}),
+ ('zoneplate', {}),
+ ('checker', {}),
+ ('checker', {'params': 'cell=1'}),
+ ('checker', {'params': {'cell': '32'}}),
+ ],
+)
+def test_draw_pattern_phase3_smoke(pattern, extra):
+ w, h = 64, 32
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ pixpat.draw_pattern(buf, pattern, **extra)
+ assert any(b != 0 for b in data)
+
+
+def test_draw_pattern_checker_default_cell_first_pixel_white():
+ w, h = 64, 32
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ pixpat.draw_pattern(buf, 'checker')
+ # (0,0) is in the first cell -> white.
+ assert _xrgb_pixel_at(data, stride, 0, 0) == (0xFF, 0xFF, 0xFF)
+ # Default cell=8: (8,0) is the next cell horizontally -> black.
+ assert _xrgb_pixel_at(data, stride, 8, 0) == (0, 0, 0)
+
+
+def test_draw_pattern_checker_cell1_alternates():
+ w, h = 16, 8
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ pixpat.draw_pattern(buf, 'checker', params={'cell': '1'})
+ p00 = _xrgb_pixel_at(data, stride, 0, 0)
+ p10 = _xrgb_pixel_at(data, stride, 1, 0)
+ p01 = _xrgb_pixel_at(data, stride, 0, 1)
+ p11 = _xrgb_pixel_at(data, stride, 1, 1)
+ assert p00 != p10
+ assert p00 != p01
+ assert p00 == p11 # diagonal repeats
+
+
+@pytest.mark.parametrize('cell', ['0', '-1', 'oops', '1.5'])
+def test_draw_pattern_checker_invalid_cell_raises(cell):
+ w, h = 16, 8
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ with pytest.raises(pixpat.PixpatError):
+ pixpat.draw_pattern(buf, 'checker', params={'cell': cell})
+
+
+def test_draw_pattern_hramp_stripes():
+ """hramp: 4 horizontal stripes (R, G, B, gray), each ramping along x.
+ The right edge (x=W-1) hits 0xFF in the active channel(s)."""
+ w, h = 32, 16 # h=16 → stripes at rows [0,3], [4,7], [8,11], [12,15]
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ pixpat.draw_pattern(buf, 'hramp')
+ assert _xrgb_pixel_at(data, stride, w - 1, 0) == (0, 0, 0xFF) # R
+ assert _xrgb_pixel_at(data, stride, w - 1, 4) == (0, 0xFF, 0) # G
+ assert _xrgb_pixel_at(data, stride, w - 1, 8) == (0xFF, 0, 0) # B
+ assert _xrgb_pixel_at(data, stride, w - 1, h - 1) == (0xFF, 0xFF, 0xFF) # gray
+ # Left edge is the 0 end of every ramp.
+ for y in (0, 4, 8, 12):
+ assert _xrgb_pixel_at(data, stride, 0, y) == (0, 0, 0)
+
+
+def test_draw_pattern_vramp_columns():
+ """vramp: 4 vertical columns (R, G, B, gray), each ramping along y."""
+ w, h = 16, 32
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ pixpat.draw_pattern(buf, 'vramp')
+ # Bottom row hits max in the active channel(s).
+ assert _xrgb_pixel_at(data, stride, 0, h - 1) == (0, 0, 0xFF) # R
+ assert _xrgb_pixel_at(data, stride, 4, h - 1) == (0, 0xFF, 0) # G
+ assert _xrgb_pixel_at(data, stride, 8, h - 1) == (0xFF, 0, 0) # B
+ assert _xrgb_pixel_at(data, stride, 12, h - 1) == (0xFF, 0xFF, 0xFF) # gray
+ # Top row is the 0 end.
+ for x in (0, 4, 8, 12):
+ assert _xrgb_pixel_at(data, stride, x, 0) == (0, 0, 0)
+ # Within a column, luma is monotonic.
+ prev = -1
+ for y in range(h):
+ b, g, r = _xrgb_pixel_at(data, stride, 12, y) # gray column
+ assert b == g == r
+ assert b >= prev
+ prev = b
+
+
+def test_draw_pattern_zoneplate_center_white():
+ """Even sizes don't sample exactly at the center; pick odd dimensions
+ so (W//2, H//2) is the center pixel where cos(0)=1 → white."""
+ w, h = 65, 33
+ data, stride = _alloc_xrgb(w, h)
+ buf = pixpat.Buffer([data], 'XRGB8888', w, h, [stride])
+ pixpat.draw_pattern(buf, 'zoneplate')
+ b, g, r = _xrgb_pixel_at(data, stride, w // 2, h // 2)
+ # Center pixel: phase=0, gray ≈ 1.0.
+ assert b == g == r and b >= 0xFE
+
+
+def test_convert_roundtrip_xrgb8888():
+ w, h = 64, 32
+ src = bytearray(w * h * 4)
+ mid = bytearray(w * h * 8)
+ dst = bytearray(w * h * 4)
+ pixpat.draw_pattern(pixpat.Buffer([src], 'XRGB8888', w, h, [w * 4]), 'smpte')
+ pixpat.convert(
+ pixpat.Buffer([mid], 'ABGR16161616', w, h, [w * 8]),
+ pixpat.Buffer([src], 'XRGB8888', w, h, [w * 4]),
+ )
+ pixpat.convert(
+ pixpat.Buffer([dst], 'XRGB8888', w, h, [w * 4]),
+ pixpat.Buffer([mid], 'ABGR16161616', w, h, [w * 8]),
+ )
+ assert src == dst
+
+
+def test_convert_readonly_bytes_src():
+ """Source planes may be read-only (bytes); the C library only reads src."""
+ w, h = 64, 32
+ src_writable = bytearray(w * h * 4)
+ pixpat.draw_pattern(pixpat.Buffer([src_writable], 'XRGB8888', w, h, [w * 4]), 'smpte')
+ src_readonly = bytes(src_writable)
+ dst = bytearray(w * h * 8)
+ pixpat.convert(
+ pixpat.Buffer([dst], 'ABGR16161616', w, h, [w * 8]),
+ pixpat.Buffer([src_readonly], 'XRGB8888', w, h, [w * 4]),
+ )
+ assert any(b != 0 for b in dst)
+
+
+def test_convert_dimension_mismatch_raises():
+ w, h = 64, 32
+ src = bytearray(w * h * 4)
+ dst = bytearray((w * 2) * h * 8)
+ with pytest.raises(ValueError, match='dimensions'):
+ pixpat.convert(
+ pixpat.Buffer([dst], 'ABGR16161616', w * 2, h, [w * 2 * 8]),
+ pixpat.Buffer([src], 'XRGB8888', w, h, [w * 4]),
+ )
+
+
+def test_draw_pattern_nv12_semi_planar():
+ """Semi-planar NV12: separate Y and interleaved-UV planes."""
+ w, h = 64, 32
+ y = bytearray(w * h)
+ uv = bytearray(w * (h // 2))
+ pixpat.draw_pattern(pixpat.Buffer([y, uv], 'NV12', w, h, [w, w]), 'smpte')
+ assert any(b != 0 for b in y)
+ assert any(b != 0 for b in uv)
+
+
+def test_draw_pattern_yuv420_three_planes():
+ """Fully planar YUV420: Y, U and V all at separate plane pointers."""
+ w, h = 64, 32
+ yp = bytearray(w * h)
+ up = bytearray((w // 2) * (h // 2))
+ vp = bytearray((w // 2) * (h // 2))
+ pixpat.draw_pattern(
+ pixpat.Buffer([yp, up, vp], 'YUV420', w, h, [w, w // 2, w // 2]),
+ 'smpte',
+ )
+ assert any(b != 0 for b in yp)
+ assert any(b != 0 for b in up)
+ assert any(b != 0 for b in vp)
+
+
+def test_convert_xrgb_to_nv12_writes_both_planes():
+ """Convert into a multi-plane destination must populate every plane."""
+ w, h = 64, 32
+ src = bytearray(w * h * 4)
+ pixpat.draw_pattern(pixpat.Buffer([src], 'XRGB8888', w, h, [w * 4]), 'smpte')
+ y = bytearray(w * h)
+ uv = bytearray(w * (h // 2))
+ pixpat.convert(
+ pixpat.Buffer([y, uv], 'NV12', w, h, [w, w]),
+ pixpat.Buffer([src], 'XRGB8888', w, h, [w * 4]),
+ )
+ assert any(b != 0 for b in y)
+ assert any(b != 0 for b in uv)
+
+
+def test_convert_yuv420_src_three_planes():
+ """Planar YUV is a supported convert source — the C library reads
+ all three plane pointers, so make sure the binding wires them up."""
+ w, h = 64, 32
+ yp = bytearray(w * h)
+ up = bytearray((w // 2) * (h // 2))
+ vp = bytearray((w // 2) * (h // 2))
+ pixpat.draw_pattern(
+ pixpat.Buffer([yp, up, vp], 'YUV420', w, h, [w, w // 2, w // 2]),
+ 'smpte',
+ )
+ dst = bytearray(w * h * 4)
+ pixpat.convert(
+ pixpat.Buffer([dst], 'XRGB8888', w, h, [w * 4]),
+ pixpat.Buffer([yp, up, vp], 'YUV420', w, h, [w, w // 2, w // 2]),
+ )
+ assert any(b != 0 for b in dst)
+
+
+def test_convert_bayer_src():
+ """Bayer formats are supported as a convert source (decoded by
+ nearest-neighbor demosaic per the public header docstring)."""
+ w, h = 64, 32
+ src = bytearray(w * h)
+ pixpat.draw_pattern(pixpat.Buffer([src], 'SRGGB8', w, h, [w]), 'smpte')
+ dst = bytearray(w * h * 4)
+ pixpat.convert(
+ pixpat.Buffer([dst], 'XRGB8888', w, h, [w * 4]),
+ pixpat.Buffer([src], 'SRGGB8', w, h, [w]),
+ )
+ assert any(b != 0 for b in dst)
+
+
+def test_convert_roundtrip_yuyv():
+ """YUYV (packed 4:2:2) — chroma is averaged on write and replicated on
+ read, so the second leg must not change the buffer."""
+ w, h = 64, 32
+ src = bytearray(w * h * 2)
+ mid = bytearray(w * h * 8)
+ dst = bytearray(w * h * 2)
+ pixpat.draw_pattern(pixpat.Buffer([src], 'YUYV', w, h, [w * 2]), 'smpte')
+ pixpat.convert(
+ pixpat.Buffer([mid], 'AVUY16161616', w, h, [w * 8]),
+ pixpat.Buffer([src], 'YUYV', w, h, [w * 2]),
+ )
+ pixpat.convert(
+ pixpat.Buffer([dst], 'YUYV', w, h, [w * 2]),
+ pixpat.Buffer([mid], 'AVUY16161616', w, h, [w * 8]),
+ )
+ assert src == dst
+
+
+def test_convert_roundtrip_y8():
+ """Y8 grayscale — chroma is discarded on write and synthesized on read,
+ Y values themselves should roundtrip exactly."""
+ w, h = 64, 32
+ src = bytearray(w * h)
+ mid = bytearray(w * h * 8)
+ dst = bytearray(w * h)
+ pixpat.draw_pattern(pixpat.Buffer([src], 'Y8', w, h, [w]), 'smpte')
+ pixpat.convert(
+ pixpat.Buffer([mid], 'AVUY16161616', w, h, [w * 8]),
+ pixpat.Buffer([src], 'Y8', w, h, [w]),
+ )
+ pixpat.convert(
+ pixpat.Buffer([dst], 'Y8', w, h, [w]),
+ pixpat.Buffer([mid], 'AVUY16161616', w, h, [w * 8]),
+ )
+ assert src == dst
+
+
+def test_convert_roundtrip_nv12():
+ """NV12 semi-planar 4:2:0 — chroma averaged into a 2x2 block on write,
+ replicated on read; second leg is a no-op."""
+ w, h = 64, 32
+ y_src = bytearray(w * h)
+ uv_src = bytearray(w * (h // 2))
+ mid = bytearray(w * h * 8)
+ y_dst = bytearray(w * h)
+ uv_dst = bytearray(w * (h // 2))
+ pixpat.draw_pattern(pixpat.Buffer([y_src, uv_src], 'NV12', w, h, [w, w]), 'smpte')
+ pixpat.convert(
+ pixpat.Buffer([mid], 'AVUY16161616', w, h, [w * 8]),
+ pixpat.Buffer([y_src, uv_src], 'NV12', w, h, [w, w]),
+ )
+ pixpat.convert(
+ pixpat.Buffer([y_dst, uv_dst], 'NV12', w, h, [w, w]),
+ pixpat.Buffer([mid], 'AVUY16161616', w, h, [w * 8]),
+ )
+ assert y_src == y_dst
+ assert uv_src == uv_dst
+
+
+def test_convert_roundtrip_xv15():
+ """P030 semi-planar 4:2:0, 10 bits packed 3-per-32-bit. Width and height
+ chosen so the Y plane fits 3 samples per word and chroma is on a 2x2 grid."""
+ w, h = 96, 32 # multiples of 3 (Y group) and 2 (v_sub)
+ y_words = (w // 3) * h
+ uv_words = (w // 3 // 2) * (h // 2)
+ y_src = bytearray(y_words * 4)
+ uv_src = bytearray(uv_words * 8)
+ mid = bytearray(w * h * 8)
+ y_dst = bytearray(y_words * 4)
+ uv_dst = bytearray(uv_words * 8)
+ pixpat.draw_pattern(
+ pixpat.Buffer([y_src, uv_src], 'P030', w, h, [(w // 3) * 4, (w // 3 // 2) * 8]),
+ 'smpte',
+ )
+ pixpat.convert(
+ pixpat.Buffer([mid], 'AVUY16161616', w, h, [w * 8]),
+ pixpat.Buffer([y_src, uv_src], 'P030', w, h, [(w // 3) * 4, (w // 3 // 2) * 8]),
+ )
+ pixpat.convert(
+ pixpat.Buffer([y_dst, uv_dst], 'P030', w, h, [(w // 3) * 4, (w // 3 // 2) * 8]),
+ pixpat.Buffer([mid], 'AVUY16161616', w, h, [w * 8]),
+ )
+ assert y_src == y_dst
+ assert uv_src == uv_dst
+
+
+def test_convert_roundtrip_x403():
+ """T430 fully-planar 4:4:4 with 3-samples-per-32-bit packing on each
+ plane; no chroma subsampling so first leg is already lossless."""
+ w, h = 96, 32 # width must be a multiple of 3
+ plane_words = (w // 3) * h
+ y_src = bytearray(plane_words * 4)
+ cb_src = bytearray(plane_words * 4)
+ cr_src = bytearray(plane_words * 4)
+ mid = bytearray(w * h * 8)
+ y_dst = bytearray(plane_words * 4)
+ cb_dst = bytearray(plane_words * 4)
+ cr_dst = bytearray(plane_words * 4)
+ strides = [(w // 3) * 4] * 3
+ pixpat.draw_pattern(
+ pixpat.Buffer([y_src, cb_src, cr_src], 'T430', w, h, strides),
+ 'smpte',
+ )
+ pixpat.convert(
+ pixpat.Buffer([mid], 'AVUY16161616', w, h, [w * 8]),
+ pixpat.Buffer([y_src, cb_src, cr_src], 'T430', w, h, strides),
+ )
+ pixpat.convert(
+ pixpat.Buffer([y_dst, cb_dst, cr_dst], 'T430', w, h, strides),
+ pixpat.Buffer([mid], 'AVUY16161616', w, h, [w * 8]),
+ )
+ assert y_src == y_dst
+ assert cb_src == cb_dst
+ assert cr_src == cr_dst
+
+
+def test_strides_length_mismatch_multi_plane_raises():
+ """Strides count must match planes count — caught in Python before C."""
+ w, h = 64, 32
+ y = bytearray(w * h)
+ uv = bytearray(w * (h // 2))
+ with pytest.raises(ValueError, match='strides'):
+ pixpat.draw_pattern(
+ pixpat.Buffer([y, uv], 'NV12', w, h, [w]), # 1 stride, 2 planes
+ 'smpte',
+ )
diff --git a/pixpat-python/tests/test_numpy.py b/pixpat-python/tests/test_numpy.py
new file mode 100644
index 0000000..6dbd7bb
--- /dev/null
+++ b/pixpat-python/tests/test_numpy.py
@@ -0,0 +1,110 @@
+"""Empirical check that pixpat works correctly with numpy.
+
+pixpat does not depend on numpy. This file proves that ndarrays work
+as ``Buffer`` planes through the buffer protocol, in the shapes a
+caller would typically use. Skipped when numpy is missing.
+"""
+
+import pixpat
+import pytest
+
+np = pytest.importorskip('numpy')
+
+
+def test_xrgb8888_into_uint8_3d_ndarray():
+ w, h = 64, 32
+ arr = np.zeros((h, w, 4), dtype=np.uint8)
+ pixpat.draw_pattern(pixpat.Buffer([arr], 'XRGB8888', w, h, [arr.strides[0]]), 'smpte')
+ assert arr.any()
+
+
+def test_abgr16161616_into_uint16_3d_ndarray():
+ w, h = 64, 32
+ arr = np.zeros((h, w, 4), dtype=np.uint16)
+ pixpat.draw_pattern(pixpat.Buffer([arr], 'ABGR16161616', w, h, [arr.strides[0]]), 'smpte')
+ assert arr.any()
+ assert arr.dtype == np.uint16
+
+
+def test_nv12_into_two_ndarrays():
+ w, h = 64, 32
+ y = np.zeros((h, w), dtype=np.uint8)
+ uv = np.zeros((h // 2, w), dtype=np.uint8) # interleaved U,V at half-height
+ pixpat.draw_pattern(
+ pixpat.Buffer(
+ planes=[y, uv],
+ fmt='NV12',
+ width=w,
+ height=h,
+ strides=[y.strides[0], uv.strides[0]],
+ ),
+ 'smpte',
+ )
+ assert y.any()
+ assert uv.any()
+
+
+def test_convert_with_readonly_ndarray_src():
+ """A numpy view with writeable=False must work as the convert source."""
+ w, h = 64, 32
+ src = np.zeros((h, w, 4), dtype=np.uint8)
+ pixpat.draw_pattern(pixpat.Buffer([src], 'XRGB8888', w, h, [src.strides[0]]), 'smpte')
+ src.flags.writeable = False
+
+ dst = np.zeros((h, w, 4), dtype=np.uint16)
+ pixpat.convert(
+ pixpat.Buffer([dst], 'ABGR16161616', w, h, [dst.strides[0]]),
+ pixpat.Buffer([src], 'XRGB8888', w, h, [src.strides[0]]),
+ )
+ assert dst.any()
+
+
+def test_roundtrip_ndarrays_byte_for_byte():
+ """End-to-end XRGB8888 -> ABGR16161616 -> XRGB8888 with ndarrays only."""
+ w, h = 64, 32
+ src = np.zeros((h, w, 4), dtype=np.uint8)
+ mid = np.zeros((h, w, 4), dtype=np.uint16)
+ dst = np.zeros((h, w, 4), dtype=np.uint8)
+
+ pixpat.draw_pattern(pixpat.Buffer([src], 'XRGB8888', w, h, [src.strides[0]]), 'smpte')
+ pixpat.convert(
+ pixpat.Buffer([mid], 'ABGR16161616', w, h, [mid.strides[0]]),
+ pixpat.Buffer([src], 'XRGB8888', w, h, [src.strides[0]]),
+ )
+ pixpat.convert(
+ pixpat.Buffer([dst], 'XRGB8888', w, h, [dst.strides[0]]),
+ pixpat.Buffer([mid], 'ABGR16161616', w, h, [mid.strides[0]]),
+ )
+ assert np.array_equal(src, dst)
+
+
+def test_writable_view_via_view_method():
+ """`arr.view()` shares memory and is writable by default — drawing into
+ it must update the original."""
+ w, h = 64, 32
+ arr = np.zeros((h, w, 4), dtype=np.uint8)
+ view = arr.view()
+ pixpat.draw_pattern(pixpat.Buffer([view], 'XRGB8888', w, h, [view.strides[0]]), 'smpte')
+ assert arr.any() # the original sees the writes
+
+
+def test_noncontiguous_source_smoke():
+ """Discovery test: what happens if the caller hands us a non-C-contiguous
+ array? pixpat reads ``stride`` bytes per row, so any row-contiguous
+ layout where ``arr.strides[0]`` is the row stride should work; a
+ transposed array (where rows aren't even contiguous) is misuse.
+
+ This test passes a *valid* row-contiguous slice — the top half of a
+ bigger image, taken via slicing — and expects success. It exists to
+ document the contract: 'rows must be contiguous in memory; pass
+ arr.strides[0] as the stride'.
+ """
+ big = np.zeros((64, 64, 4), dtype=np.uint8)
+ top_half = big[:32] # shape (32, 64, 4); rows still C-contiguous
+ assert top_half.strides[0] == 64 * 4
+ pixpat.draw_pattern(
+ pixpat.Buffer([top_half], 'XRGB8888', 64, 32, [top_half.strides[0]]),
+ 'smpte',
+ )
+ assert big[:32].any()
+ assert not big[32:].any() # the other half stayed untouched
diff --git a/pixpat-python/tests/test_threading.py b/pixpat-python/tests/test_threading.py
new file mode 100644
index 0000000..99d4620
--- /dev/null
+++ b/pixpat-python/tests/test_threading.py
@@ -0,0 +1,187 @@
+"""Multi-threaded == single-threaded parity tests.
+
+The chosen ``num_threads`` is supposed to be transparent: the output
+bytes must match the single-threaded output exactly. Anything else is
+a threading bug.
+
+These tests parametrize across one format per writer template so that
+every code path in the dispatcher is covered.
+"""
+
+import pytest
+
+import pixpat
+
+
+# Width must be divisible by 3 (T430/XYYY2101010 pack 3-per-32-bit), 4
+# (Bayer 10P packs 4 pixels per group), and 2 (h_sub).
+# Height must be divisible by 2 (v_sub for 4:2:0 formats).
+#
+# Two sizes: a small one for fast format-coverage parametrization, and
+# a larger one (~HD-width) where worker-stripe alignment can interact
+# with the writers' chroma logic in non-obvious ways.
+SMALL = (192, 64)
+LARGE = (1920, 64)
+
+
+def _alloc(fmt, w, h):
+ """Return (planes, strides) sized for `fmt` at (w, h)."""
+ if fmt in ('NV12', 'NV21'): # semi-planar 4:2:0
+ return [bytearray(w * h), bytearray(w * (h // 2))], [w, w]
+ if fmt in ('NV16', 'NV61'): # semi-planar 4:2:2
+ return [bytearray(w * h), bytearray(w * h)], [w, w]
+ if fmt == 'P030': # semi-planar 4:2:0, 10-bit packed 3-per-32
+ ys, uvs = (w // 3) * 4, (w // 3 // 2) * 8
+ return [bytearray(ys * h), bytearray(uvs * (h // 2))], [ys, uvs]
+ if fmt == 'P230': # semi-planar 4:2:2, 10-bit packed 3-per-32
+ ys, uvs = (w // 3) * 4, (w // 3 // 2) * 8
+ return [bytearray(ys * h), bytearray(uvs * h)], [ys, uvs]
+ if fmt in ('YUV420', 'YVU420'): # planar 4:2:0
+ return (
+ [bytearray(w * h), bytearray((w // 2) * (h // 2)), bytearray((w // 2) * (h // 2))],
+ [w, w // 2, w // 2],
+ )
+ if fmt in ('YUV422', 'YVU422'): # planar 4:2:2
+ return (
+ [bytearray(w * h), bytearray((w // 2) * h), bytearray((w // 2) * h)],
+ [w, w // 2, w // 2],
+ )
+ if fmt in ('YUV444', 'YVU444'): # planar 4:4:4
+ return [bytearray(w * h)] * 3, [w] * 3
+ if fmt == 'T430': # planar packed 4:4:4, 10-bit
+ s = (w // 3) * 4
+ return [bytearray(s * h)] * 3, [s] * 3
+ if fmt in ('Y8', 'R8', 'RGB332'):
+ return [bytearray(w * h)], [w]
+ if fmt == 'XYYY2101010':
+ s = (w // 3) * 4
+ return [bytearray(s * h)], [s]
+ if fmt in ('YUYV', 'YVYU', 'UYVY', 'VYUY'):
+ return [bytearray(w * h * 2)], [w * 2]
+ if fmt in ('Y210', 'Y212', 'Y216'):
+ # 4:2:2, two pixels per 64-bit word.
+ return [bytearray(w * h * 4)], [w * 4]
+ if fmt == 'VUY888':
+ # 24-bit packed YUV, 3 bytes per pixel (storage uint32_t).
+ return [bytearray(w * h * 3)], [w * 3]
+ if fmt in ('XVUY2101010', 'XVUY8888'):
+ return [bytearray(w * h * 4)], [w * 4]
+ if fmt in ('AVUY16161616', 'ABGR16161616'):
+ return [bytearray(w * h * 8)], [w * 8]
+ # MIPI CSI-2 byte packing (Bayer SXXXX10P/12P and grayscale Y10P/Y12P).
+ if fmt.endswith('10P'):
+ s = (w // 4) * 5
+ return [bytearray(s * h)], [s]
+ if fmt.endswith('12P'):
+ s = (w // 2) * 3
+ return [bytearray(s * h)], [s]
+ # Plain Bayer
+ if fmt.startswith('S') and fmt[-1] == '8':
+ return [bytearray(w * h)], [w]
+ if fmt.startswith('S'):
+ return [bytearray(w * h * 2)], [w * 2]
+ # 16-bit RGB
+ if fmt.endswith('565') or fmt.endswith('1555') or fmt.endswith('4444'):
+ return [bytearray(w * h * 2)], [w * 2]
+ # 32-bit RGB (8888 / 2101010 / 1010102 / 888 in 32-bit storage)
+ return [bytearray(w * h * 4)], [w * 4]
+
+
+# One format per writer template in pixpat.cpp.
+DRAW_FORMATS = [
+ 'XRGB8888', # ARGB_Writer
+ 'RGB565', # ARGB_Writer (16-bit)
+ 'ABGR16161616', # ARGB_Writer (normalized wide)
+ 'XVUY2101010', # YUV_Writer
+ 'AVUY16161616', # YUV_Writer (normalized wide)
+ 'YUYV', # YUVPackedWriter
+ 'NV12', # YUVSemiPlanarWriter v_sub=2
+ 'NV16', # YUVSemiPlanarWriter v_sub=1
+ 'P030', # YUVSemiPlanarWriter v_sub=2, 10-bit packed
+ 'P230', # YUVSemiPlanarWriter v_sub=1, 10-bit packed
+ 'YUV420', # YUVPlanarWriter v_sub=2
+ 'YUV422', # YUVPlanarWriter v_sub=1
+ 'YUV444', # YUVPlanarWriter no subsampling
+ 'T430', # YUVPlanarPackedWriter
+ 'Y8', # Y_Writer (1 sample per byte)
+ 'XYYY2101010', # Y_Writer (3 samples per word)
+ 'Y10P', # GrayPacked_Writer (CSI-2 byte packing, Y-only)
+ 'Y210', # YUVPackedWriter with X-padding entries (4:2:2 in 64-bit word)
+ 'R8', # MonoRGB_Writer (single R channel)
+ 'SRGGB8', # Bayer_Writer
+ 'SRGGB10', # Bayer_Writer
+ 'SRGGB10P', # BayerPacked_Writer
+ 'SRGGB12P', # BayerPacked_Writer
+]
+
+# Convert sources: same minus Bayer (Bayer formats have no read path).
+CONVERT_FORMATS = [f for f in DRAW_FORMATS if not f.startswith('S')]
+
+
+@pytest.mark.parametrize('fmt', DRAW_FORMATS)
+@pytest.mark.parametrize(
+ 'pattern',
+ ['smpte', 'kmstest', 'checker', 'hramp', 'vramp', 'dramp', 'zoneplate'],
+)
+@pytest.mark.parametrize('size', [SMALL, LARGE], ids=['small', 'large'])
+def test_draw_pattern_threaded_matches_single(fmt, pattern, size):
+ """draw_pattern with num_threads=4 must produce identical bytes
+ to num_threads=1 for every supported format."""
+ w, h = size
+ s_planes, strides = _alloc(fmt, w, h)
+ pixpat.draw_pattern(pixpat.Buffer(s_planes, fmt, w, h, strides), pattern, num_threads=1)
+
+ t_planes, _ = _alloc(fmt, w, h)
+ pixpat.draw_pattern(pixpat.Buffer(t_planes, fmt, w, h, strides), pattern, num_threads=4)
+
+ for i, (s, t) in enumerate(zip(s_planes, t_planes)):
+ assert s == t, f'{fmt} ({pattern}, {w}x{h}): plane {i} differs threaded vs single'
+
+
+@pytest.mark.parametrize('fmt', ['XRGB8888', 'NV12', 'YUV420', 'BGR888'])
+@pytest.mark.parametrize('size', [SMALL, LARGE], ids=['small', 'large'])
+def test_draw_pattern_plain_threaded_matches_single(fmt, size):
+ """draw_pattern with params= must also be bit-identical across
+ thread counts. Spot-check a few representative formats."""
+ w, h = size
+ params = {'color': '12ab34'}
+ s_planes, strides = _alloc(fmt, w, h)
+ pixpat.draw_pattern(
+ pixpat.Buffer(s_planes, fmt, w, h, strides), 'plain', num_threads=1, params=params
+ )
+
+ t_planes, _ = _alloc(fmt, w, h)
+ pixpat.draw_pattern(
+ pixpat.Buffer(t_planes, fmt, w, h, strides), 'plain', num_threads=4, params=params
+ )
+
+ for i, (s, t) in enumerate(zip(s_planes, t_planes)):
+ assert s == t, f'{fmt} (plain, {w}x{h}): plane {i} differs threaded vs single'
+
+
+@pytest.mark.parametrize('src_fmt', CONVERT_FORMATS)
+@pytest.mark.parametrize('dst_fmt', ['ABGR16161616', 'AVUY16161616', 'XRGB8888', 'NV12', 'YUV420'])
+@pytest.mark.parametrize('size', [SMALL, LARGE], ids=['small', 'large'])
+def test_convert_threaded_matches_single(src_fmt, dst_fmt, size):
+ """convert with num_threads=4 must produce identical bytes
+ to num_threads=1."""
+ w, h = size
+ src_planes, src_strides = _alloc(src_fmt, w, h)
+ pixpat.draw_pattern(pixpat.Buffer(src_planes, src_fmt, w, h, src_strides), 'smpte')
+
+ s_dst_planes, dst_strides = _alloc(dst_fmt, w, h)
+ pixpat.convert(
+ pixpat.Buffer(s_dst_planes, dst_fmt, w, h, dst_strides),
+ pixpat.Buffer(src_planes, src_fmt, w, h, src_strides),
+ num_threads=1,
+ )
+
+ t_dst_planes, _ = _alloc(dst_fmt, w, h)
+ pixpat.convert(
+ pixpat.Buffer(t_dst_planes, dst_fmt, w, h, dst_strides),
+ pixpat.Buffer(src_planes, src_fmt, w, h, src_strides),
+ num_threads=4,
+ )
+
+ for i, (s, t) in enumerate(zip(s_dst_planes, t_dst_planes)):
+ assert s == t, f'{src_fmt}->{dst_fmt} ({w}x{h}): plane {i} differs threaded vs single'