Skip to content

Commit 5502041

Browse files
authored
[camera_android_x] Refactor ImageProxyUtils.planesToNV21 to prevent buffer issues (#10163)
This change replaces the previous `planesToNV21() `implementation with a direct and stride-aware conversion. The prior version assumed that the U and V planes followed the same layout used by Camera2, which is not always true for CameraX. This assumption could cause color distortion or corrupted frames because CameraX applies internal transformations (rotation, cropping, or proxy wrapping) that modify rowStride, pixelStride, and overall buffer layout. After investigating inconsistencies between the original `areUVPlanesNV21()`-based logic and the actual ImageProxy buffers returned by CameraX, this version explicitly reconstructs NV21 by reading each plane row by row while respecting their individual strides, rather than simply validating whether the format already matches `NV21`. Fixes flutter/flutter#176410 ## Pre-Review Checklist **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
1 parent 9ec29b6 commit 5502041

File tree

4 files changed

+130
-47
lines changed

4 files changed

+130
-47
lines changed

packages/camera/camera_android_camerax/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.6.23+5
2+
3+
* Fixes `IllegalArgumentException` that could occur during image streaming when using NV21.
4+
15
## 0.6.23+4
26

37
* Updates examples to use the new RadioGroup API instead of deprecated Radio parameters.

packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageProxyUtils.java

Lines changed: 66 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -18,55 +18,80 @@ public class ImageProxyUtils {
1818
*/
1919
@NonNull
2020
public static ByteBuffer planesToNV21(@NonNull List<PlaneProxy> planes, int width, int height) {
21-
if (!areUVPlanesNV21(planes, width, height)) {
21+
if (planes.size() < 3) {
2222
throw new IllegalArgumentException(
23-
"Provided UV planes are not in NV21 layout and thus cannot be converted.");
23+
"The plane list must contain at least 3 planes (Y, U, V).");
2424
}
2525

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

30-
// Copy Y plane.
31-
ByteBuffer yBuffer = planes.get(0).getBuffer();
32-
yBuffer.rewind();
33-
yBuffer.get(nv21Bytes, 0, imageSize);
34-
35-
// Copy interleaved VU plane (NV21 layout).
36-
ByteBuffer vBuffer = planes.get(2).getBuffer();
37-
ByteBuffer uBuffer = planes.get(1).getBuffer();
30+
ByteBuffer yBuffer = yPlane.getBuffer();
31+
ByteBuffer uBuffer = uPlane.getBuffer();
32+
ByteBuffer vBuffer = vPlane.getBuffer();
3833

39-
vBuffer.rewind();
34+
// Rewind buffers to start to ensure full read.
35+
yBuffer.rewind();
4036
uBuffer.rewind();
41-
vBuffer.get(nv21Bytes, imageSize, 1);
42-
uBuffer.get(nv21Bytes, imageSize + 1, 2 * imageSize / 4 - 1);
43-
44-
return ByteBuffer.wrap(nv21Bytes);
45-
}
46-
47-
public static boolean areUVPlanesNV21(@NonNull List<PlaneProxy> planes, int width, int height) {
48-
int imageSize = width * height;
49-
50-
ByteBuffer uBuffer = planes.get(1).getBuffer();
51-
ByteBuffer vBuffer = planes.get(2).getBuffer();
52-
53-
// Backup buffer properties.
54-
int vBufferPosition = vBuffer.position();
55-
int uBufferLimit = uBuffer.limit();
56-
57-
// Advance the V buffer by 1 byte, since the U buffer will not contain the first V value.
58-
vBuffer.position(vBufferPosition + 1);
59-
// Chop off the last byte of the U buffer, since the V buffer will not contain the last U value.
60-
uBuffer.limit(uBufferLimit - 1);
37+
vBuffer.rewind();
6138

62-
// Check that the buffers are equal and have the expected number of elements.
63-
boolean areNV21 =
64-
(vBuffer.remaining() == (2 * imageSize / 4 - 2)) && (vBuffer.compareTo(uBuffer) == 0);
39+
// Allocate a byte array for the NV21 frame.
40+
// NV21 = Y plane + interleaved VU plane.
41+
// Y = width * height; VU = (width * height) / 2 (4:2:0 subsampling).
42+
// If the Y plane includes padding, ySize may be larger than width*height,
43+
// but only the valid Y bytes are copied, so output size remains correct.
44+
int ySize = yBuffer.remaining();
45+
byte[] nv21Bytes = new byte[ySize + (width * height / 2)];
46+
int position = 0;
47+
48+
int yRowStride = yPlane.getRowStride();
49+
if (yRowStride == width) {
50+
// If no padding, copy entire Y plane at once.
51+
yBuffer.get(nv21Bytes, 0, ySize);
52+
position = ySize;
53+
} else {
54+
// Copy row by row if padding exists.
55+
byte[] row = new byte[width];
56+
for (int rowIndex = 0; rowIndex < height; rowIndex++) {
57+
yBuffer.get(row, 0, width);
58+
System.arraycopy(row, 0, nv21Bytes, position, width);
59+
position += width;
60+
// Adjust buffer position to start of next row.
61+
// After reading 'width' bytes, move ahead by (yRowStride - width)
62+
// to skip any padding bytes at the end of the current row.
63+
if (rowIndex < height - 1) {
64+
yBuffer.position(yBuffer.position() - width + yRowStride);
65+
}
66+
}
67+
}
6568

66-
// Restore buffers to their initial state.
67-
vBuffer.position(vBufferPosition);
68-
uBuffer.limit(uBufferLimit);
69+
int uRowStride = uPlane.getRowStride();
70+
int vRowStride = vPlane.getRowStride();
71+
int uPixelStride = uPlane.getPixelStride();
72+
int vPixelStride = vPlane.getPixelStride();
73+
74+
byte[] uRowBuffer = new byte[uRowStride];
75+
byte[] vRowBuffer = new byte[vRowStride];
76+
77+
// Read full row from U and V planes into temporary buffers.
78+
for (int row = 0; row < height / 2; row++) {
79+
int uRemaining = Math.min(uBuffer.remaining(), uRowStride);
80+
int vRemaining = Math.min(vBuffer.remaining(), vRowStride);
81+
82+
uBuffer.get(uRowBuffer, 0, uRemaining);
83+
vBuffer.get(vRowBuffer, 0, vRemaining);
84+
85+
// Interleave V and U chroma data into the NV21 buffer.
86+
// In NV21, chroma bytes follow the Y plane in repeating VU pairs (VUVU...).
87+
for (int col = 0; col < width / 2; col++) {
88+
int vIndex = col * vPixelStride;
89+
int uIndex = col * uPixelStride;
90+
nv21Bytes[position++] = vRowBuffer[vIndex];
91+
nv21Bytes[position++] = uRowBuffer[uIndex];
92+
}
93+
}
6994

70-
return areNV21;
95+
return ByteBuffer.wrap(nv21Bytes);
7196
}
7297
}

packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageProxyUtilsTest.java

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
package io.flutter.plugins.camerax;
66

77
import static org.junit.Assert.assertArrayEquals;
8+
import static org.junit.Assert.assertEquals;
89
import static org.junit.Assert.assertThrows;
10+
import static org.mockito.Mockito.mock;
11+
import static org.mockito.Mockito.when;
912

1013
import androidx.camera.core.ImageProxy.PlaneProxy;
14+
import java.nio.BufferUnderflowException;
1115
import java.nio.ByteBuffer;
1216
import java.util.Arrays;
1317
import java.util.List;
@@ -35,18 +39,19 @@ public void planesToNV21_throwsExceptionForNonNV21Layout() {
3539
List<PlaneProxy> planes = Arrays.asList(yPlane, uPlane, vPlane);
3640

3741
assertThrows(
38-
IllegalArgumentException.class, () -> ImageProxyUtils.planesToNV21(planes, width, height));
42+
BufferUnderflowException.class, () -> ImageProxyUtils.planesToNV21(planes, width, height));
3943
}
4044

4145
@Test
4246
public void planesToNV21_returnsExpectedBufferWhenPlanesAreNV21Compatible() {
4347
int width = 4;
4448
int height = 2;
45-
int imageSize = width * height; // 8
4649

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

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

66+
// Set pixelStride and rowStride for UV planes to trigger NV21 shortcut
67+
Mockito.when(uPlane.getPixelStride()).thenReturn(2);
68+
Mockito.when(uPlane.getRowStride()).thenReturn(width);
69+
Mockito.when(vPlane.getPixelStride()).thenReturn(2);
70+
Mockito.when(vPlane.getRowStride()).thenReturn(width);
71+
6172
List<PlaneProxy> planes = Arrays.asList(yPlane, uPlane, vPlane);
6273

6374
ByteBuffer nv21Buffer = ImageProxyUtils.planesToNV21(planes, width, height);
@@ -87,19 +98,62 @@ public void planesToNV21_returnsExpectedBufferWhenPlanesAreNV21Compatible() {
8798
assertArrayEquals(expected, nv21);
8899
}
89100

101+
@Test
102+
public void areUVPlanesNV21_handlesVBufferAtLimitGracefully() {
103+
int width = 1280;
104+
int height = 720;
105+
106+
// --- Mock Y plane ---
107+
byte[] yData = new byte[width * height];
108+
PlaneProxy yPlane = mock(PlaneProxy.class);
109+
ByteBuffer yBuffer = ByteBuffer.wrap(yData);
110+
when(yPlane.getBuffer()).thenReturn(yBuffer);
111+
when(yPlane.getPixelStride()).thenReturn(1);
112+
when(yPlane.getRowStride()).thenReturn(width);
113+
114+
// --- Mock U plane ---
115+
ByteBuffer uBuffer = ByteBuffer.allocate(width * height / 4);
116+
PlaneProxy uPlane = mock(PlaneProxy.class);
117+
when(uPlane.getBuffer()).thenReturn(uBuffer);
118+
when(uPlane.getPixelStride()).thenReturn(1);
119+
when(uPlane.getRowStride()).thenReturn(width / 2);
120+
121+
// --- Mock V plane ---
122+
ByteBuffer vBuffer = ByteBuffer.allocate(width * height / 4);
123+
vBuffer.position(vBuffer.limit()); // position == limit
124+
PlaneProxy vPlane = mock(PlaneProxy.class);
125+
when(vPlane.getBuffer()).thenReturn(vBuffer);
126+
when(vPlane.getPixelStride()).thenReturn(1);
127+
when(vPlane.getRowStride()).thenReturn(width / 2);
128+
129+
List<PlaneProxy> planes = Arrays.asList(yPlane, uPlane, vPlane);
130+
131+
ByteBuffer nv21Buffer = ImageProxyUtils.planesToNV21(planes, width, height);
132+
byte[] nv21 = new byte[nv21Buffer.remaining()];
133+
nv21Buffer.get(nv21);
134+
135+
assertEquals(width * height + (width * height / 2), nv21.length);
136+
}
137+
90138
// Creates a mock PlaneProxy with a buffer (of zeroes) of the given size.
91139
private PlaneProxy mockPlaneProxy(int bufferSize) {
92-
PlaneProxy plane = Mockito.mock(PlaneProxy.class);
140+
PlaneProxy plane = mock(PlaneProxy.class);
93141
ByteBuffer buffer = ByteBuffer.allocate(bufferSize);
94-
Mockito.when(plane.getBuffer()).thenReturn(buffer);
142+
when(plane.getBuffer()).thenReturn(buffer);
95143
return plane;
96144
}
97145

98146
// Creates a mock PlaneProxy with specific data.
99147
private PlaneProxy mockPlaneProxyWithData(byte[] data) {
100148
PlaneProxy plane = Mockito.mock(PlaneProxy.class);
101149
ByteBuffer buffer = ByteBuffer.wrap(Arrays.copyOf(data, data.length));
102-
Mockito.when(plane.getBuffer()).thenReturn(buffer);
150+
when(plane.getBuffer()).thenReturn(buffer);
151+
152+
// Set pixelStride and rowStride to safe defaults for tests
153+
// For Y plane: pixelStride = 1, rowStride = width (approximate)
154+
when(plane.getPixelStride()).thenReturn(1);
155+
when(plane.getRowStride()).thenReturn(data.length); // rowStride ≥ width
156+
103157
return plane;
104158
}
105159
}

packages/camera/camera_android_camerax/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: camera_android_camerax
22
description: Android implementation of the camera plugin using the CameraX library.
33
repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_android_camerax
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22
5-
version: 0.6.23+4
5+
version: 0.6.23+5
66

77
environment:
88
sdk: ^3.9.0

0 commit comments

Comments
 (0)