Skip to content

Commit 0f39217

Browse files
authored
refactor: Enhance image format validation and optimize InputImage conversion (#847)
1 parent 9d4b21f commit 0f39217

2 files changed

Lines changed: 138 additions & 27 deletions

File tree

packages/example/lib/vision_detector_views/camera_view.dart

Lines changed: 111 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -357,29 +357,126 @@ class _CameraViewState extends State<CameraView> {
357357

358358
// get image format
359359
final format = InputImageFormatValue.fromRawValue(image.format.raw);
360-
// validate format depending on platform
361-
// only supported formats:
362-
// * nv21 for Android
363-
// * bgra8888 for iOS
364-
if (format == null ||
365-
(Platform.isAndroid && format != InputImageFormat.nv21) ||
360+
if (format == null) {
361+
print('could not find format from raw value: ${image.format.raw}');
362+
return null;
363+
}
364+
// Validate format depending on platform
365+
const androidSupportedFormats = [
366+
InputImageFormat.nv21,
367+
InputImageFormat.yv12,
368+
InputImageFormat.yuv_420_888
369+
];
370+
371+
if ((Platform.isAndroid && !androidSupportedFormats.contains(format)) ||
366372
(Platform.isIOS && format != InputImageFormat.bgra8888)) {
373+
print('image format is not supported: $format');
367374
return null;
368375
}
369376

370-
// since format is constraint to nv21 or bgra8888, both only have one plane
371-
if (image.planes.length != 1) return null;
372-
final plane = image.planes.first;
377+
InputImageFormat resolvedFormat = format;
378+
final Uint8List bytes;
379+
380+
if (image.planes.length == 1) {
381+
bytes = image.planes.first.bytes;
382+
} else if (Platform.isAndroid &&
383+
(format == InputImageFormat.yuv_420_888 ||
384+
format == InputImageFormat.yv12) &&
385+
image.planes.length == 3) {
386+
bytes = _convertYUV420ToNV21(image);
387+
resolvedFormat = InputImageFormat.nv21;
388+
} else {
389+
bytes = _concatenatePlanes(image);
390+
}
373391

374-
// compose InputImage using bytes
375392
return InputImage.fromBytes(
376-
bytes: plane.bytes,
393+
bytes: bytes,
377394
metadata: InputImageMetadata(
378395
size: Size(image.width.toDouble(), image.height.toDouble()),
379-
rotation: rotation, // used only in Android
380-
format: format, // used only in iOS
381-
bytesPerRow: plane.bytesPerRow, // used only in iOS
396+
rotation: rotation,
397+
format: resolvedFormat,
398+
bytesPerRow: image.planes.first.bytesPerRow,
382399
),
383400
);
384401
}
402+
403+
// Reusable buffer to avoid per-frame allocations when concatenating planes.
404+
Uint8List? _reusablePlaneBuffer;
405+
406+
Uint8List _concatenatePlanes(CameraImage image) {
407+
// Calculate the total number of bytes across all planes.
408+
final int totalBytes = image.planes.fold<int>(
409+
0,
410+
(int sum, Plane plane) => sum + plane.bytes.length,
411+
);
412+
413+
// Ensure the reusable buffer is allocated and large enough.
414+
var buffer = _reusablePlaneBuffer;
415+
if (buffer == null || buffer.length < totalBytes) {
416+
buffer = Uint8List(totalBytes);
417+
_reusablePlaneBuffer = buffer;
418+
}
419+
420+
// Copy each plane's bytes into the reusable buffer.
421+
var offset = 0;
422+
for (final Plane plane in image.planes) {
423+
final bytes = plane.bytes;
424+
buffer.setRange(offset, offset + bytes.length, bytes);
425+
offset += bytes.length;
426+
}
427+
428+
// Return the reusable buffer directly when sizes match, or a zero-copy view otherwise.
429+
if (totalBytes == buffer.length) {
430+
return buffer;
431+
}
432+
return Uint8List.sublistView(buffer, 0, totalBytes);
433+
}
434+
435+
Uint8List? _reusableNv21Buffer;
436+
int _lastNv21Size = 0;
437+
Uint8List _convertYUV420ToNV21(CameraImage image) {
438+
final int width = image.width;
439+
final int height = image.height;
440+
final int ySize = width * height;
441+
final int uvSize = ySize ~/ 2;
442+
final int requiredSize = ySize + uvSize;
443+
444+
if (_reusableNv21Buffer == null || _lastNv21Size != requiredSize) {
445+
_reusableNv21Buffer = Uint8List(requiredSize);
446+
_lastNv21Size = requiredSize;
447+
}
448+
449+
final Uint8List nv21 = _reusableNv21Buffer!;
450+
451+
// Copy Y plane (strip row padding)
452+
final Plane yPlane = image.planes[0];
453+
int destIndex = 0;
454+
for (int row = 0; row < height; row++) {
455+
final int srcRowStart = row * yPlane.bytesPerRow;
456+
nv21.setRange(destIndex, destIndex + width, yPlane.bytes, srcRowStart);
457+
destIndex += width;
458+
}
459+
460+
// Interleave V and U planes into NV21 (VU order)
461+
final Plane uPlane = image.planes[1];
462+
final Plane vPlane = image.planes[2];
463+
final int uvPixelStride = uPlane.bytesPerPixel ?? 1;
464+
final int vPixelStride = vPlane.bytesPerPixel ?? 1;
465+
466+
int uvIndex = ySize;
467+
for (int row = 0; row < height ~/ 2; row++) {
468+
final int uRowStart = row * uPlane.bytesPerRow;
469+
final int vRowStart = row * vPlane.bytesPerRow;
470+
471+
for (int col = 0; col < width ~/ 2; col++) {
472+
final int uIndex = uRowStart + col * uvPixelStride;
473+
final int vIndex = vRowStart + col * vPixelStride;
474+
475+
nv21[uvIndex++] = vPlane.bytes[vIndex];
476+
nv21[uvIndex++] = uPlane.bytes[uIndex];
477+
}
478+
}
479+
480+
return nv21;
481+
}
385482
}

packages/google_mlkit_commons/android/src/main/kotlin/com/google_mlkit_commons/InputImageConverter.kt

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -157,19 +157,33 @@ object InputImageConverter {
157157

158158
val height = metaData["height"]?.toString()?.toDouble()?.toInt() ?: throw IllegalArgumentException("Height is null")
159159

160-
if (
161-
imageFormat == ImageFormat.NV21 ||
162-
imageFormat == ImageFormat.YUV_420_888 ||
163-
imageFormat == ImageFormat.YV12
164-
) {
165-
InputImage.fromByteArray(data, width, height, rotationDegrees, imageFormat)
166-
} else {
167-
result.error(
168-
"InputImageConverterError",
169-
"ImageFormat is not supported. Supported: NV21(17), YUV_420_888(35), YV12. Got: $imageFormat",
170-
null,
171-
)
172-
null
160+
when (imageFormat) {
161+
ImageFormat.NV21, ImageFormat.YV12 -> {
162+
InputImage.fromByteArray(data, width, height, rotationDegrees, imageFormat)
163+
}
164+
165+
ImageFormat.YUV_420_888 -> {
166+
// Create an InputImage directly from a YUV_420_888 byte array using the reported image format.
167+
InputImage.fromByteArray(
168+
data,
169+
width,
170+
height,
171+
rotationDegrees,
172+
imageFormat,
173+
)
174+
}
175+
176+
else -> {
177+
result.error(
178+
"InputImageConverterError",
179+
"ImageFormat $imageFormat is not supported. Supported formats are: " +
180+
"NV21 (${ImageFormat.NV21}), " +
181+
"YV12 (${ImageFormat.YV12}), " +
182+
"YUV_420_888 (${ImageFormat.YUV_420_888}).",
183+
null,
184+
)
185+
null
186+
}
173187
}
174188
} catch (e: Exception) {
175189
Log.e("ImageError", "Getting Image failed")

0 commit comments

Comments
 (0)