Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/camera/camera_android_camerax/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.6.24

* Fixes `IllegalArgumentException` that could occur during image streaming.

## 0.6.23

* Converts NV21-compatible streamed images to NV21 when requested. In doing so,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,55 +18,66 @@ public class ImageProxyUtils {
*/
@NonNull
public static ByteBuffer planesToNV21(@NonNull List<PlaneProxy> planes, int width, int height) {
if (!areUVPlanesNV21(planes, width, height)) {
if (planes.size() < 3) {
throw new IllegalArgumentException(
"Provided UV planes are not in NV21 layout and thus cannot be converted.");
"The plane list must contain at least 3 planes (Y, U, V).");
}

int imageSize = width * height;
int nv21Size = imageSize + 2 * (imageSize / 4);
byte[] nv21Bytes = new byte[nv21Size];
PlaneProxy yPlane = planes.get(0);
PlaneProxy uPlane = planes.get(1);
PlaneProxy vPlane = planes.get(2);

// Copy Y plane.
ByteBuffer yBuffer = planes.get(0).getBuffer();
yBuffer.rewind();
yBuffer.get(nv21Bytes, 0, imageSize);

// Copy interleaved VU plane (NV21 layout).
ByteBuffer vBuffer = planes.get(2).getBuffer();
ByteBuffer uBuffer = planes.get(1).getBuffer();
ByteBuffer yBuffer = yPlane.getBuffer();
ByteBuffer uBuffer = uPlane.getBuffer();
ByteBuffer vBuffer = vPlane.getBuffer();

vBuffer.rewind();
// Rewind buffers to start to ensure full read.
yBuffer.rewind();
uBuffer.rewind();
vBuffer.get(nv21Bytes, imageSize, 1);
uBuffer.get(nv21Bytes, imageSize + 1, 2 * imageSize / 4 - 1);

return ByteBuffer.wrap(nv21Bytes);
}
vBuffer.rewind();

public static boolean areUVPlanesNV21(@NonNull List<PlaneProxy> planes, int width, int height) {
int imageSize = width * height;
int ySize = yBuffer.remaining();
byte[] nv21Bytes = new byte[ySize + (width * height / 2)];
int position = 0;

int yRowStride = yPlane.getRowStride();
if (yRowStride == width) {
// If no padding, copy entire Y plane at once.
yBuffer.get(nv21Bytes, 0, ySize);
position = ySize;
} else {
// Copy row by row if padding exists.
for (int row = 0; row < height; row++) {
yBuffer.get(nv21Bytes, position, width);
position += width;
if (row < height - 1) {
yBuffer.position(yBuffer.position() - width + yRowStride);
}
}
}

ByteBuffer uBuffer = planes.get(1).getBuffer();
ByteBuffer vBuffer = planes.get(2).getBuffer();
int uRowStride = uPlane.getRowStride();
int vRowStride = vPlane.getRowStride();
int uPixelStride = uPlane.getPixelStride();
int vPixelStride = vPlane.getPixelStride();

// Backup buffer properties.
int vBufferPosition = vBuffer.position();
int uBufferLimit = uBuffer.limit();
byte[] uRowBuffer = new byte[uRowStride];
byte[] vRowBuffer = new byte[vRowStride];

// Advance the V buffer by 1 byte, since the U buffer will not contain the first V value.
vBuffer.position(vBufferPosition + 1);
// Chop off the last byte of the U buffer, since the V buffer will not contain the last U value.
uBuffer.limit(uBufferLimit - 1);
for (int row = 0; row < height / 2; row++) {
// Read full row from U and V planes into temporary buffers.
uBuffer.get(uRowBuffer, 0, Math.min(uBuffer.remaining(), uRowStride));
vBuffer.get(vRowBuffer, 0, Math.min(vBuffer.remaining(), vRowStride));

// Check that the buffers are equal and have the expected number of elements.
boolean areNV21 =
(vBuffer.remaining() == (2 * imageSize / 4 - 2)) && (vBuffer.compareTo(uBuffer) == 0);
for (int col = 0; col < width / 2; col++) {
int vPixelIndex = col * vPixelStride;
int uPixelIndex = col * uPixelStride;

// Restore buffers to their initial state.
vBuffer.position(vBufferPosition);
uBuffer.limit(uBufferLimit);
nv21Bytes[position++] = vRowBuffer[vPixelIndex]; // V (Cr)
nv21Bytes[position++] = uRowBuffer[uPixelIndex]; // U (Cb)
}
}

return areNV21;
return ByteBuffer.wrap(nv21Bytes);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
package io.flutter.plugins.camerax;

import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import androidx.camera.core.ImageProxy.PlaneProxy;
import java.nio.BufferUnderflowException;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.List;
Expand Down Expand Up @@ -35,18 +39,19 @@ public void planesToNV21_throwsExceptionForNonNV21Layout() {
List<PlaneProxy> planes = Arrays.asList(yPlane, uPlane, vPlane);

assertThrows(
IllegalArgumentException.class, () -> ImageProxyUtils.planesToNV21(planes, width, height));
BufferUnderflowException.class, () -> ImageProxyUtils.planesToNV21(planes, width, height));
}

@Test
public void planesToNV21_returnsExpectedBufferWhenPlanesAreNV21Compatible() {
int width = 4;
int height = 2;
int imageSize = width * height; // 8

// Y plane.
byte[] y = new byte[] {0, 1, 2, 3, 4, 5, 6, 7};
PlaneProxy yPlane = mockPlaneProxyWithData(y);
when(yPlane.getPixelStride()).thenReturn(1);
when(yPlane.getRowStride()).thenReturn(width);

// U and V planes in NV21 format. Both have 2 bytes that are overlapping (5, 7).
ByteBuffer vBuffer = ByteBuffer.wrap(new byte[] {9, 5, 7});
Expand All @@ -58,6 +63,12 @@ public void planesToNV21_returnsExpectedBufferWhenPlanesAreNV21Compatible() {
Mockito.when(uPlane.getBuffer()).thenReturn(uBuffer);
Mockito.when(vPlane.getBuffer()).thenReturn(vBuffer);

// Set pixelStride and rowStride for UV planes to trigger NV21 shortcut
Mockito.when(uPlane.getPixelStride()).thenReturn(2);
Mockito.when(uPlane.getRowStride()).thenReturn(width);
Mockito.when(vPlane.getPixelStride()).thenReturn(2);
Mockito.when(vPlane.getRowStride()).thenReturn(width);

List<PlaneProxy> planes = Arrays.asList(yPlane, uPlane, vPlane);

ByteBuffer nv21Buffer = ImageProxyUtils.planesToNV21(planes, width, height);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1280x720 → ~4.6 ms per frame. Further evaluation is needed to assess the impact on the processing pipeline.

Expand Down Expand Up @@ -87,19 +98,62 @@ public void planesToNV21_returnsExpectedBufferWhenPlanesAreNV21Compatible() {
assertArrayEquals(expected, nv21);
}

@Test
public void areUVPlanesNV21_handlesVBufferAtLimitGracefully() {
int width = 1280;
int height = 720;

// --- Mock Y plane ---
byte[] yData = new byte[width * height];
PlaneProxy yPlane = mock(PlaneProxy.class);
ByteBuffer yBuffer = ByteBuffer.wrap(yData);
when(yPlane.getBuffer()).thenReturn(yBuffer);
when(yPlane.getPixelStride()).thenReturn(1);
when(yPlane.getRowStride()).thenReturn(width);

// --- Mock U plane ---
ByteBuffer uBuffer = ByteBuffer.allocate(width * height / 4);
PlaneProxy uPlane = mock(PlaneProxy.class);
when(uPlane.getBuffer()).thenReturn(uBuffer);
when(uPlane.getPixelStride()).thenReturn(1);
when(uPlane.getRowStride()).thenReturn(width / 2);

// --- Mock V plane ---
ByteBuffer vBuffer = ByteBuffer.allocate(width * height / 4);
vBuffer.position(vBuffer.limit()); // position == limit
PlaneProxy vPlane = mock(PlaneProxy.class);
when(vPlane.getBuffer()).thenReturn(vBuffer);
when(vPlane.getPixelStride()).thenReturn(1);
when(vPlane.getRowStride()).thenReturn(width / 2);

List<PlaneProxy> planes = Arrays.asList(yPlane, uPlane, vPlane);

ByteBuffer nv21Buffer = ImageProxyUtils.planesToNV21(planes, width, height);
byte[] nv21 = new byte[nv21Buffer.remaining()];
nv21Buffer.get(nv21);

assertEquals(width * height + (width * height / 2), nv21.length);
}

// Creates a mock PlaneProxy with a buffer (of zeroes) of the given size.
private PlaneProxy mockPlaneProxy(int bufferSize) {
PlaneProxy plane = Mockito.mock(PlaneProxy.class);
PlaneProxy plane = mock(PlaneProxy.class);
ByteBuffer buffer = ByteBuffer.allocate(bufferSize);
Mockito.when(plane.getBuffer()).thenReturn(buffer);
when(plane.getBuffer()).thenReturn(buffer);
return plane;
}

// Creates a mock PlaneProxy with specific data.
private PlaneProxy mockPlaneProxyWithData(byte[] data) {
PlaneProxy plane = Mockito.mock(PlaneProxy.class);
ByteBuffer buffer = ByteBuffer.wrap(Arrays.copyOf(data, data.length));
Mockito.when(plane.getBuffer()).thenReturn(buffer);
when(plane.getBuffer()).thenReturn(buffer);

// Set pixelStride and rowStride to safe defaults for tests
// For Y plane: pixelStride = 1, rowStride = width (approximate)
when(plane.getPixelStride()).thenReturn(1);
when(plane.getRowStride()).thenReturn(data.length); // rowStride ≥ width

return plane;
}
}
2 changes: 1 addition & 1 deletion packages/camera/camera_android_camerax/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: camera_android_camerax
description: Android implementation of the camera plugin using the CameraX library.
repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_android_camerax
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22
version: 0.6.23
version: 0.6.24

environment:
sdk: ^3.8.1
Expand Down