summaryrefslogtreecommitdiff
path: root/pixpat-python/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'pixpat-python/scripts')
-rwxr-xr-xpixpat-python/scripts/build_wheel.sh39
-rwxr-xr-xpixpat-python/scripts/perf_test.py434
2 files changed, 473 insertions, 0 deletions
diff --git a/pixpat-python/scripts/build_wheel.sh b/pixpat-python/scripts/build_wheel.sh
new file mode 100755
index 0000000..1eb2586
--- /dev/null
+++ b/pixpat-python/scripts/build_wheel.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# Build a pixpat wheel for the given target architecture.
+#
+# Usage: pixpat-python/scripts/build_wheel.sh <x86_64|aarch64>
+#
+# Meson is invoked from setup.py during the wheel build; this script just
+# selects the target arch and hands off to `python -m build`. The meson
+# build dir lands at pixpat-python/build-<arch>/native/, so cross-compiling
+# both arches in turn keeps each arch's compile incremental.
+#
+# Prerequisites:
+# - meson, ninja in PATH (e.g. `pip install meson ninja`)
+# - For aarch64: gcc-aarch64-linux-gnu, g++-aarch64-linux-gnu
+# (sudo apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu)
+# - Python with `build` package: pip install build
+
+set -euo pipefail
+
+ARCH="${1:-}"
+case "$ARCH" in
+ x86_64|aarch64) ;;
+ *)
+ echo "usage: $0 <x86_64|aarch64>" >&2
+ exit 1
+ ;;
+esac
+
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$REPO_ROOT"
+
+# --no-isolation: reuse the host environment so meson's per-arch build dir
+# (pixpat-python/build-<arch>/native/) survives across invocations and stays
+# incremental. Build isolation copies the source to a temp dir, so the meson
+# build dir would be thrown away each run.
+PIXPAT_TARGET_ARCH="$ARCH" python -m build --wheel --no-isolation
+
+echo
+echo "Wheel(s) in dist/:"
+ls -1 dist/*.whl
diff --git a/pixpat-python/scripts/perf_test.py b/pixpat-python/scripts/perf_test.py
new file mode 100755
index 0000000..100c16e
--- /dev/null
+++ b/pixpat-python/scripts/perf_test.py
@@ -0,0 +1,434 @@
+#!/usr/bin/env python3
+"""Micro-benchmark pixpat across draw_pattern and convert call paths.
+
+Each row times one ``<source> -> <destination>`` operation:
+
+* a pattern draw, where ``source`` is a pattern name (``smpte``,
+ ``kmstest`` etc.) and the row times ``pixpat.draw_pattern``;
+* a format conversion, where both sides are format names and the row
+ times ``pixpat.convert``.
+
+Buffer allocation is driven by ``pixutils.formats.PixelFormats`` —
+``framesize`` / ``planesize`` / ``stride`` give us per-plane geometry
+without per-format hand-coding.
+
+Filtering is uniform across both kinds because the case name always
+has the form ``"<lhs> -> <rhs>"``: ``--ionly kmstest`` keeps that
+pattern's cases, ``--ionly NV12`` keeps NV12-source convert cases,
+``--oonly BGR888`` keeps any case (pattern or convert) writing
+BGR888, and so on.
+
+Usage:
+ python perf_test.py [--width 1920] [--height 1080] [--iters 200]
+ [--warmup 10] [--pp-threads 1]
+ [--rec BT709] [--range LIMITED]
+ [--only-pattern | --only-convert]
+ [--only STR] [--ionly STR] [--oonly STR]
+ [--cases STR] [--tsv]
+"""
+
+from __future__ import annotations
+
+import argparse
+import gc
+import math
+import sys
+import time
+from dataclasses import dataclass
+from typing import Callable, Optional
+
+import numpy as np
+
+import pixpat
+from pixutils.formats import PixelFormat, PixelFormats
+
+
+PATTERNS = ['kmstest', 'smpte', 'plain']
+
+PATTERN_SINK_FMTS = [
+ 'XRGB8888',
+ 'BGR888',
+ 'RGB565',
+ 'RGBA1010102',
+ 'ABGR16161616',
+ 'Y8',
+ 'YUYV',
+ 'UYVY',
+ 'NV12',
+ 'NV16',
+ 'YUV420',
+ 'SRGGB8',
+ 'SRGGB10P',
+]
+
+CONVERT_PAIRS: list[tuple[str, str]] = [
+ # RGB shuffles
+ ('RGB888', 'BGR888'),
+ ('BGR888', 'BGRA8888'),
+ ('BGRA8888', 'BGR888'),
+ # Grayscale
+ ('BGR888', 'Y8'),
+ ('Y8', 'BGR888'),
+ # Packed YUV decode
+ ('YUYV', 'BGR888'),
+ ('UYVY', 'BGR888'),
+ # Semiplanar YUV decode
+ ('NV12', 'BGR888'),
+ ('NV21', 'BGR888'),
+ # Planar YUV decode
+ ('YUV420', 'BGR888'),
+ ('YVU420', 'BGR888'),
+ # Planar YUV encode
+ ('BGR888', 'YUV420'),
+ ('BGR888', 'YVU420'),
+ # NV12 encode/decode against XRGB8888
+ ('XRGB8888', 'NV12'),
+ ('BGR888', 'NV12'),
+ ('NV12', 'XRGB8888'),
+ # Cold path: neither side is BGR888
+ ('XRGB8888', 'RGBA8888'),
+ ('RGBA8888', 'XRGB8888'),
+ ('NV12', 'YUV420'),
+ ('YUV420', 'NV12'),
+ ('NV12', 'YUYV'),
+ ('NV12', 'RGB565'),
+ ('Y8', 'NV12'),
+ # Wider coverage
+ ('BGR888', 'YUYV'),
+ ('BGR888', 'NV16'),
+ ('NV16', 'BGR888'),
+ ('BGR888', 'YUV422'),
+ ('YUV422', 'BGR888'),
+ ('BGR888', 'YUV444'),
+ ('YUV444', 'BGR888'),
+ # 16-bit packed RGB
+ ('BGR888', 'RGB565'),
+ ('RGB565', 'BGR888'),
+ # 10-bit packed RGB
+ ('BGR888', 'RGBA1010102'),
+ ('RGBA1010102', 'BGR888'),
+ # 64-bit packed RGB (pixpat's normalized wide form)
+ ('BGR888', 'ABGR16161616'),
+ ('ABGR16161616', 'BGR888'),
+ # Multi-pixel-per-word semiplanar YUV
+ ('BGR888', 'P030'),
+ ('P030', 'BGR888'),
+ ('BGR888', 'P230'),
+ # Multi-pixel-per-word planar YUV
+ ('BGR888', 'T430'),
+ ('T430', 'BGR888'),
+ # Multi-pixel-per-word grayscale
+ ('BGR888', 'XYYY2101010'),
+ # Single-pixel-per-word YUV
+ ('BGR888', 'XVUY2101010'),
+ ('BGR888', 'AVUY16161616'),
+ # Bayer
+ ('BGR888', 'SRGGB8'),
+ ('SRGGB8', 'BGR888'),
+ ('BGR888', 'SRGGB10P'),
+ ('SRGGB10P', 'BGR888'),
+ ('BGR888', 'SRGGB12P'),
+ ('SRGGB12P', 'BGR888'),
+ # Single-channel RGB
+ ('BGR888', 'R8'),
+ ('R8', 'BGR888'),
+ # MIPI CSI-2 packed grayscale
+ ('BGR888', 'Y10P'),
+ ('Y10P', 'BGR888'),
+ # 4:2:2 packed YUV in 64-bit words
+ ('BGR888', 'Y210'),
+ ('Y210', 'BGR888'),
+]
+
+
+_PF_BY_NAME = {v.name: v for v in PixelFormats.__dict__.values() if isinstance(v, PixelFormat)}
+
+
+def _required_align(pf: PixelFormat) -> tuple[int, int]:
+ # pf.pixel_align doesn't always capture the per-plane pixels_per_block /
+ # vsub requirements (e.g. T430 lists (1,1) but each plane is 3 px wide),
+ # so combine them to get the effective alignment we need to skip cleanly.
+ w_align = pf.pixel_align[0]
+ h_align = pf.pixel_align[1]
+ for p in pf.planes:
+ w_align = math.lcm(w_align, p.pixels_per_block * p.hsub)
+ h_align = math.lcm(h_align, p.vsub)
+ return w_align, h_align
+
+
+def _alloc_buffer(fmt_name: str, w: int, h: int) -> Optional[tuple[pixpat.Buffer, np.ndarray]]:
+ """Build a pixpat.Buffer + its 1-D backing array, or None when the
+ resolution doesn't fit the format's alignment."""
+ pf = _PF_BY_NAME[fmt_name]
+ w_align, h_align = _required_align(pf)
+ if w % w_align or h % h_align:
+ return None
+
+ backing = np.zeros(pf.framesize(w, h), dtype=np.uint8)
+ planes: list[np.ndarray] = []
+ strides: list[int] = []
+ offset = 0
+ for i in range(len(pf.planes)):
+ s = pf.stride(w, i)
+ psize = pf.planesize(s, h, i)
+ view = backing[offset : offset + psize].reshape(psize // s, s)
+ planes.append(view)
+ strides.append(s)
+ offset += psize
+ return pixpat.Buffer(planes, fmt_name, w, h, strides), backing
+
+
+@dataclass
+class Case:
+ name: str
+ kind: str # 'pattern' or 'convert'
+ dst: pixpat.Buffer
+ src: Optional[pixpat.Buffer] = None
+ src_arr: Optional[np.ndarray] = None # 1-D backing for random fill
+
+
+def _build_cases(w: int, h: int, kinds: set[str]) -> list[Case]:
+ cases: list[Case] = []
+
+ if 'pattern' in kinds:
+ for fmt in PATTERN_SINK_FMTS:
+ alloc = _alloc_buffer(fmt, w, h)
+ if alloc is None:
+ continue
+ buf, _ = alloc
+ for pat in PATTERNS:
+ cases.append(Case(name=f'{pat} -> {fmt}', kind='pattern', dst=buf))
+
+ if 'convert' in kinds:
+ for src_fmt, dst_fmt in CONVERT_PAIRS:
+ src_alloc = _alloc_buffer(src_fmt, w, h)
+ dst_alloc = _alloc_buffer(dst_fmt, w, h)
+ if src_alloc is None or dst_alloc is None:
+ continue
+ src_buf, src_backing = src_alloc
+ dst_buf, _ = dst_alloc
+ cases.append(
+ Case(
+ name=f'{src_fmt} -> {dst_fmt}',
+ kind='convert',
+ dst=dst_buf,
+ src=src_buf,
+ src_arr=src_backing,
+ )
+ )
+
+ return cases
+
+
+def _parse_filter(s: Optional[str]) -> Optional[set[str]]:
+ if s is None:
+ return None
+ return {x.strip().upper() for x in s.split(',') if x.strip()}
+
+
+def _norm_case(name: str) -> str:
+ return ' '.join(name.upper().split())
+
+
+def _filter_cases(
+ cases: list[Case],
+ only: Optional[str],
+ ionly: Optional[str],
+ oonly: Optional[str],
+ cases_filter: Optional[str],
+) -> list[Case]:
+ only_set = _parse_filter(only)
+ ionly_set = _parse_filter(ionly)
+ oonly_set = _parse_filter(oonly)
+ cases_set: Optional[set[str]] = None
+ if cases_filter is not None:
+ cases_set = {_norm_case(x) for x in cases_filter.split(',') if x.strip()}
+ if only_set is None and ionly_set is None and oonly_set is None and cases_set is None:
+ return cases
+
+ out: list[Case] = []
+ for c in cases:
+ lhs, _, rhs = c.name.partition(' -> ')
+ lhs, rhs = lhs.upper(), rhs.upper()
+ if cases_set is not None and _norm_case(c.name) not in cases_set:
+ continue
+ if only_set is not None and lhs not in only_set and rhs not in only_set:
+ continue
+ if ionly_set is not None and lhs not in ionly_set:
+ continue
+ if oonly_set is not None and rhs not in oonly_set:
+ continue
+ out.append(c)
+ return out
+
+
+def _bind(
+ case: Case,
+ num_threads: int,
+ rec: 'pixpat.Rec',
+ color_range: 'pixpat.Range',
+) -> Callable[[], object]:
+ if case.kind == 'pattern':
+ fn = pixpat.draw_pattern
+ dst = case.dst
+ pat = case.name.partition(' -> ')[0]
+ # `plain` needs an explicit color; the rest ignore params.
+ params = {'color': 'ff0000'} if pat == 'plain' else None
+ return lambda: fn(
+ dst,
+ pat,
+ rec=rec,
+ color_range=color_range,
+ num_threads=num_threads,
+ params=params,
+ )
+ fn = pixpat.convert
+ assert case.src is not None
+ src, dst = case.src, case.dst
+ return lambda: fn(dst, src, rec=rec, color_range=color_range, num_threads=num_threads)
+
+
+def _time_n(fn: Callable[[], object], iters: int) -> float:
+ """Return min seconds over ``iters`` calls."""
+ best = float('inf')
+ for _ in range(iters):
+ t0 = time.perf_counter_ns()
+ fn()
+ dt = time.perf_counter_ns() - t0
+ if dt < best:
+ best = dt
+ return best * 1e-9
+
+
+def main() -> int:
+ p = argparse.ArgumentParser(
+ description='Micro-benchmark pixpat across draw_pattern and convert.'
+ )
+ p.add_argument('--width', type=int, default=1920)
+ p.add_argument('--height', type=int, default=1080)
+ p.add_argument('--iters', type=int, default=200)
+ p.add_argument('--warmup', type=int, default=10)
+ p.add_argument(
+ '--pp-threads',
+ type=int,
+ default=1,
+ help='pixpat thread count; 0 = sensible default',
+ )
+ p.add_argument(
+ '--rec',
+ default='BT709',
+ choices=[r.name for r in pixpat.Rec],
+ help='YCbCr matrix for YUV cases (default BT709)',
+ )
+ p.add_argument(
+ '--range',
+ dest='color_range',
+ default='LIMITED',
+ choices=[r.name for r in pixpat.Range],
+ help='Quantization range for YUV cases (default LIMITED)',
+ )
+ kind_group = p.add_mutually_exclusive_group()
+ kind_group.add_argument(
+ '--only-pattern',
+ action='store_true',
+ help='restrict to pattern-draw cases',
+ )
+ kind_group.add_argument(
+ '--only-convert',
+ action='store_true',
+ help='restrict to convert cases',
+ )
+ p.add_argument(
+ '--only',
+ default=None,
+ help='comma-separated names; keep cases whose lhs OR rhs of " -> " matches',
+ )
+ p.add_argument(
+ '--ionly',
+ default=None,
+ help='comma-separated names; keep cases whose lhs (pattern or src fmt) matches',
+ )
+ p.add_argument(
+ '--oonly',
+ default=None,
+ help='comma-separated names; keep cases whose rhs (dst fmt) matches',
+ )
+ p.add_argument(
+ '--cases',
+ default=None,
+ help='comma-separated full case names (e.g. "smpte -> BGR888,RGB888 -> BGR888")',
+ )
+ p.add_argument(
+ '--tsv',
+ action='store_true',
+ help='emit tab-separated rows on stdout; meta and warnings go to stderr',
+ )
+ args = p.parse_args()
+
+ w, h = args.width, args.height
+ mp = (w * h) / 1e6
+
+ rec = pixpat.Rec[args.rec]
+ color_range = pixpat.Range[args.color_range]
+
+ kinds: set[str] = {'pattern', 'convert'}
+ if args.only_pattern:
+ kinds = {'pattern'}
+ elif args.only_convert:
+ kinds = {'convert'}
+
+ cases = _build_cases(w, h, kinds)
+ cases = _filter_cases(cases, args.only, args.ionly, args.oonly, args.cases)
+ if not cases:
+ print('no cases matched filter')
+ return 1
+
+ info: Callable[[str], None] = (lambda m: print(m, file=sys.stderr)) if args.tsv else print
+
+ info(
+ f'Resolution: {w}x{h} ({mp:.2f} MP/frame), '
+ f'iters={args.iters}, warmup={args.warmup}, '
+ f'pp threads={args.pp_threads}, '
+ f'rec={rec.name}, range={color_range.name}'
+ )
+
+ name_w = max((len(c.name) for c in cases), default=20) + 2
+
+ if args.tsv:
+ print('\t'.join(['case', 'pp_mps', 'pp_fps']))
+ else:
+ print()
+ header = f'{"case":<{name_w}} {"pp MP/s":>9} {"pp fps":>8}'
+ print(header)
+ print('-' * len(header))
+
+ rng = np.random.default_rng(0)
+
+ for case in cases:
+ if case.kind == 'convert' and case.src_arr is not None:
+ case.src_arr[...] = rng.integers(0, 256, size=case.src_arr.shape, dtype=np.uint8)
+
+ run = _bind(case, args.pp_threads, rec=rec, color_range=color_range)
+ gc.disable()
+ try:
+ for _ in range(args.warmup):
+ run()
+ t = _time_n(run, args.iters)
+ except (pixpat.PixpatError, ValueError, TypeError) as e:
+ info(f'{case.name:<{name_w}} skipped: {type(e).__name__}: {e}')
+ continue
+ finally:
+ gc.enable()
+
+ fps = 1.0 / t
+ mps = fps * mp
+ if args.tsv:
+ print(f'{case.name}\t{mps:.0f}\t{fps:.0f}')
+ else:
+ print(f'{case.name:<{name_w}} {mps:>9.0f} {fps:>8.0f}')
+
+ return 0
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())