packYuv420 function
Packs a YUV420 camera frame into a single contiguous buffer suitable for
native colour conversion (e.g. opencv's cvtColor with a
COLOR_YUV2BGR_NV21 / COLOR_YUV2BGR_NV12 / COLOR_YUV2BGR_I420 code).
Auto-detects the source layout based on the plane count and the U plane's
YuvPlane.pixelStride:
- 2 planes: NV12. iOS
AVFoundationdefault. - 3 planes, U pixelStride 2: NV21. Most Android devices (semi-planar); the V plane's buffer is used as the VU-interleaved region start.
- 3 planes, U pixelStride 1: I420. Planar Android.
Row-stride padding is stripped so the returned buffer is tightly packed.
Typical usage with opencv_dart:
final packed = packYuv420(
width: image.width,
height: image.height,
y: (bytes: image.planes[0].bytes,
rowStride: image.planes[0].bytesPerRow,
pixelStride: image.planes[0].bytesPerPixel ?? 1),
u: (bytes: image.planes[1].bytes,
rowStride: image.planes[1].bytesPerRow,
pixelStride: image.planes[1].bytesPerPixel ?? 1),
v: image.planes.length > 2
? (bytes: image.planes[2].bytes,
rowStride: image.planes[2].bytesPerRow,
pixelStride: image.planes[2].bytesPerPixel ?? 1)
: null,
);
final code = switch (packed.layout) {
YuvLayout.nv12 => cv.COLOR_YUV2BGR_NV12,
YuvLayout.nv21 => cv.COLOR_YUV2BGR_NV21,
YuvLayout.i420 => cv.COLOR_YUV2BGR_I420,
};
final layout = packed.sourceLayout;
final yuvMat = cv.Mat.create(
rows: layout.rows,
cols: layout.cols,
type: cv.MatType.CV_8UC1,
);
layout.copyTo(yuvMat.data, packed.bytes);
final bgr = cv.cvtColor(yuvMat, code);
yuvMat.dispose();
Returns null for unsupported shapes (non-positive or odd width or
height).
into optionally receives the packed bytes instead of allocating a
fresh buffer, which matters in per-frame camera loops: a 720p pack
otherwise allocates and zero-fills ~1.4 MB per call. Its length must be
exactly width * height * 3 ~/ 2 (throws ArgumentError otherwise).
Every byte is written (truncated source planes zero-fill their tail),
so a reused buffer needs no clearing and the output is byte-identical
to the allocating form.
Implementation
PackedYuv? packYuv420({
required int width,
required int height,
required YuvPlane y,
required YuvPlane u,
YuvPlane? v,
Uint8List? into,
}) {
if (width <= 0 || height <= 0 || (width & 1) != 0 || (height & 1) != 0) {
return null;
}
final int ySize = width * height;
final int uvSize = width * (height ~/ 2);
if (into != null && into.length != ySize + uvSize) {
throw ArgumentError.value(
into.length,
'into.length',
'Expected ${ySize + uvSize} bytes for ${width}x$height YUV420.',
);
}
final Uint8List out = into ?? Uint8List(ySize + uvSize);
_copyPlaneRows(
src: y.bytes,
srcStride: y.rowStride,
rowBytes: width,
rows: height,
dst: out,
dstOffset: 0,
);
if (v == null) {
// 2-plane NV12: planes[1] is the UV-interleaved chroma region.
_copyPlaneRows(
src: u.bytes,
srcStride: u.rowStride,
rowBytes: width,
rows: height ~/ 2,
dst: out,
dstOffset: ySize,
);
return PackedYuv(
bytes: out,
layout: YuvLayout.nv12,
width: width,
height: height,
);
}
if (u.pixelStride == 2) {
// Android semi-planar (NV21). plane[2] points to the V byte, which is
// the first byte of the VU-interleaved region when the device uses NV21.
_copyPlaneRows(
src: v.bytes,
srcStride: v.rowStride,
rowBytes: width,
rows: height ~/ 2,
dst: out,
dstOffset: ySize,
);
return PackedYuv(
bytes: out,
layout: YuvLayout.nv21,
width: width,
height: height,
);
}
// Planar I420.
final int uvWidth = width ~/ 2;
final int uvHeight = height ~/ 2;
_copyPlaneRows(
src: u.bytes,
srcStride: u.rowStride,
rowBytes: uvWidth,
rows: uvHeight,
dst: out,
dstOffset: ySize,
);
_copyPlaneRows(
src: v.bytes,
srcStride: v.rowStride,
rowBytes: uvWidth,
rows: uvHeight,
dst: out,
dstOffset: ySize + uvWidth * uvHeight,
);
return PackedYuv(
bytes: out,
layout: YuvLayout.i420,
width: width,
height: height,
);
}