From 2685f474727b30cf37e0fb196818ee07331e3664 Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Sat, 28 Oct 2017 14:59:51 +0200 Subject: [PATCH] Improve CameraUtils.decodeBitmap (#83) --- .../cameraview/CameraUtilsTest.java | 57 ++++++++++++-- .../cameraview/IntegrationTest.java | 4 +- .../otaliastudios/cameraview/CameraUtils.java | 78 +++++++++++++++++-- .../otaliastudios/cameraview/CropHelper.java | 2 +- .../demo/PicturePreviewActivity.java | 18 ++--- .../res/layout/activity_picture_preview.xml | 8 +- 6 files changed, 136 insertions(+), 31 deletions(-) diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java index cb0e1014..b678e0ce 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraUtilsTest.java @@ -36,15 +36,17 @@ public class CameraUtilsTest extends BaseTest { assertFalse(CameraUtils.hasCameras(context)); } - @Test - public void testDecodeBitmap() { - int w = 100, h = 200, color = Color.WHITE; - Bitmap source = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); - source.setPixel(0, 0, color); - final ByteArrayOutputStream os = new ByteArrayOutputStream(); + // Encodes bitmap and decodes again using our utility. + private Task encodeDecodeTask(Bitmap source) { + return encodeDecodeTask(source, 0, 0); + } + // Encodes bitmap and decodes again using our utility. + private Task encodeDecodeTask(Bitmap source, final int maxWidth, final int maxHeight) { + final ByteArrayOutputStream os = new ByteArrayOutputStream(); // Using lossy JPG we can't have strict comparison of values after compression. source.compress(Bitmap.CompressFormat.PNG, 100, os); + final byte[] data = os.toByteArray(); final Task decode = new Task<>(); decode.listen(); @@ -59,9 +61,23 @@ public class CameraUtilsTest extends BaseTest { ui(new Runnable() { @Override public void run() { - CameraUtils.decodeBitmap(os.toByteArray(), callback); + if (maxWidth > 0 && maxHeight > 0) { + CameraUtils.decodeBitmap(data, maxWidth, maxHeight, callback); + } else { + CameraUtils.decodeBitmap(data, callback); + } } }); + return decode; + } + + @Test + public void testDecodeBitmap() { + int w = 100, h = 200, color = Color.WHITE; + Bitmap source = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + source.setPixel(0, 0, color); + + Task decode = encodeDecodeTask(source); Bitmap other = decode.await(800); assertNotNull(other); assertEquals(100, w); @@ -73,4 +89,31 @@ public class CameraUtilsTest extends BaseTest { // TODO: improve when we add EXIF writing to byte arrays } + + + @Test + public void testDecodeDownscaledBitmap() { + int width = 1000, height = 2000; + Bitmap source = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Task task; + Bitmap other; + + task = encodeDecodeTask(source, 100, 100); + other = task.await(800); + assertNotNull(other); + assertTrue(other.getWidth() <= 100); + assertTrue(other.getHeight() <= 100); + + task = encodeDecodeTask(source, Integer.MAX_VALUE, Integer.MAX_VALUE); + other = task.await(800); + assertNotNull(other); + assertTrue(other.getWidth() == width); + assertTrue(other.getHeight() == height); + + task = encodeDecodeTask(source, 6000, 6000); + other = task.await(800); + assertNotNull(other); + assertTrue(other.getWidth() == width); + assertTrue(other.getHeight() == height); + } } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/IntegrationTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/IntegrationTest.java index 7a855e4d..f67b05e3 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/IntegrationTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/IntegrationTest.java @@ -459,7 +459,7 @@ public class IntegrationTest extends BaseTest { Size size = camera.getCaptureSize(); camera.capturePicture(); byte[] jpeg = waitForPicture(true); - Bitmap b = CameraUtils.decodeBitmap(jpeg); + Bitmap b = CameraUtils.decodeBitmap(jpeg, Integer.MAX_VALUE, Integer.MAX_VALUE); // Result can actually have swapped dimensions // Which one, depends on factors including device physical orientation assertTrue(b.getWidth() == size.getHeight() || b.getWidth() == size.getWidth()); @@ -497,7 +497,7 @@ public class IntegrationTest extends BaseTest { Size size = camera.getPreviewSize(); camera.captureSnapshot(); byte[] jpeg = waitForPicture(true); - Bitmap b = CameraUtils.decodeBitmap(jpeg); + Bitmap b = CameraUtils.decodeBitmap(jpeg, Integer.MAX_VALUE, Integer.MAX_VALUE); // Result can actually have swapped dimensions // Which one, depends on factors including device physical orientation assertTrue(b.getWidth() == size.getHeight() || b.getWidth() == size.getWidth()); diff --git a/cameraview/src/main/utils/com/otaliastudios/cameraview/CameraUtils.java b/cameraview/src/main/utils/com/otaliastudios/cameraview/CameraUtils.java index ed6ba69c..a81870f7 100644 --- a/cameraview/src/main/utils/com/otaliastudios/cameraview/CameraUtils.java +++ b/cameraview/src/main/utils/com/otaliastudios/cameraview/CameraUtils.java @@ -15,7 +15,7 @@ import java.io.IOException; import java.io.InputStream; /** - * Static utilities for dealing with camera I/O, orientation, etc. + * Static utilities for dealing with camera I/O, orientations, etc. */ public class CameraUtils { @@ -27,6 +27,7 @@ public class CameraUtils { * @param context a valid Context * @return whether device has cameras */ + @SuppressWarnings("WeakerAccess") public static boolean hasCameras(Context context) { PackageManager manager = context.getPackageManager(); // There's also FEATURE_CAMERA_EXTERNAL , should we support it? @@ -60,18 +61,34 @@ public class CameraUtils { * is that this cares about orientation, reading it from the EXIF header. * This is executed in a background thread, and returns the result to the original thread. * - * This ignores flipping at the moment. - * TODO care about flipping using Matrix.scale() - * * @param source a JPEG byte array * @param callback a callback to be notified */ + @SuppressWarnings("WeakerAccess") public static void decodeBitmap(final byte[] source, final BitmapCallback callback) { + decodeBitmap(source, Integer.MAX_VALUE, Integer.MAX_VALUE, callback); + } + + /** + * Decodes an input byte array and outputs a Bitmap that is ready to be displayed. + * The difference with {@link android.graphics.BitmapFactory#decodeByteArray(byte[], int, int)} + * is that this cares about orientation, reading it from the EXIF header. + * This is executed in a background thread, and returns the result to the original thread. + * + * The image is also downscaled taking care of the maxWidth and maxHeight arguments. + * + * @param source a JPEG byte array + * @param maxWidth the max allowed width + * @param maxHeight the max allowed height + * @param callback a callback to be notified + */ + @SuppressWarnings("WeakerAccess") + public static void decodeBitmap(final byte[] source, final int maxWidth, final int maxHeight, final BitmapCallback callback) { final Handler ui = new Handler(); WorkerHandler.run(new Runnable() { @Override public void run() { - final Bitmap bitmap = decodeBitmap(source); + final Bitmap bitmap = decodeBitmap(source, maxWidth, maxHeight); ui.post(new Runnable() { @Override public void run() { @@ -83,7 +100,11 @@ public class CameraUtils { } - static Bitmap decodeBitmap(byte[] source) { + // TODO ignores flipping + @SuppressWarnings({"SuspiciousNameCombination", "WeakerAccess"}) + /* for tests */ static Bitmap decodeBitmap(byte[] source, int maxWidth, int maxHeight) { + if (maxWidth <= 0) maxWidth = Integer.MAX_VALUE; + if (maxHeight <= 0) maxHeight = Integer.MAX_VALUE; int orientation; boolean flip; InputStream stream = null; @@ -123,12 +144,30 @@ public class CameraUtils { flip = false; } finally { if (stream != null) { - try { stream.close(); } catch (Exception e) {} + try { stream.close(); } catch (Exception ignored) {} } } + Bitmap bitmap; + if (maxWidth < Integer.MAX_VALUE || maxHeight < Integer.MAX_VALUE) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(source, 0, source.length, options); + + int outHeight = options.outHeight; + int outWidth = options.outWidth; + if (orientation % 180 != 0) { + outHeight = options.outWidth; + outWidth = options.outHeight; + } + + options.inSampleSize = computeSampleSize(outWidth, outHeight, maxWidth, maxHeight); + options.inJustDecodeBounds = false; + bitmap = BitmapFactory.decodeByteArray(source, 0, source.length, options); + } else { + bitmap = BitmapFactory.decodeByteArray(source, 0, source.length); + } - Bitmap bitmap = BitmapFactory.decodeByteArray(source, 0, source.length); if (orientation != 0 || flip) { Matrix matrix = new Matrix(); matrix.setRotate(orientation); @@ -141,7 +180,30 @@ public class CameraUtils { } + private static int computeSampleSize(int width, int height, int maxWidth, int maxHeight) { + // https://developer.android.com/topic/performance/graphics/load-bitmap.html + int inSampleSize = 1; + if (height > maxHeight || width > maxWidth) { + while ((height / inSampleSize) >= maxHeight + || (width / inSampleSize) >= maxWidth) { + inSampleSize *= 2; + } + } + return inSampleSize; + } + + + /** + * Receives callbacks about a bitmap decoding operation. + */ public interface BitmapCallback { + + /** + * Notifies that the bitmap was succesfully decoded. + * This is run on the UI thread. + * + * @param bitmap decoded bitmap + */ @UiThread void onBitmapReady(Bitmap bitmap); } } diff --git a/cameraview/src/main/utils/com/otaliastudios/cameraview/CropHelper.java b/cameraview/src/main/utils/com/otaliastudios/cameraview/CropHelper.java index 51b144c3..2808e39c 100644 --- a/cameraview/src/main/utils/com/otaliastudios/cameraview/CropHelper.java +++ b/cameraview/src/main/utils/com/otaliastudios/cameraview/CropHelper.java @@ -21,7 +21,7 @@ class CropHelper { // In doing so, EXIF data is deleted. static byte[] cropToJpeg(byte[] jpeg, AspectRatio targetRatio, int jpegCompression) { - Bitmap image = CameraUtils.decodeBitmap(jpeg); + Bitmap image = CameraUtils.decodeBitmap(jpeg, Integer.MAX_VALUE, Integer.MAX_VALUE); Rect cropRect = computeCrop(image.getWidth(), image.getHeight(), targetRatio); Bitmap crop = Bitmap.createBitmap(image, cropRect.left, cropRect.top, cropRect.width(), cropRect.height()); image.recycle(); diff --git a/demo/src/main/java/com/otaliastudios/cameraview/demo/PicturePreviewActivity.java b/demo/src/main/java/com/otaliastudios/cameraview/demo/PicturePreviewActivity.java index 4770fdca..906442a7 100644 --- a/demo/src/main/java/com/otaliastudios/cameraview/demo/PicturePreviewActivity.java +++ b/demo/src/main/java/com/otaliastudios/cameraview/demo/PicturePreviewActivity.java @@ -27,8 +27,8 @@ public class PicturePreviewActivity extends Activity { setContentView(R.layout.activity_picture_preview); final ImageView imageView = findViewById(R.id.image); final MessageView nativeCaptureResolution = findViewById(R.id.nativeCaptureResolution); - final MessageView actualResolution = findViewById(R.id.actualResolution); - final MessageView approxUncompressedSize = findViewById(R.id.approxUncompressedSize); + // final MessageView actualResolution = findViewById(R.id.actualResolution); + // final MessageView approxUncompressedSize = findViewById(R.id.approxUncompressedSize); final MessageView captureLatency = findViewById(R.id.captureLatency); final long delay = getIntent().getLongExtra("delay", 0); @@ -40,25 +40,25 @@ public class PicturePreviewActivity extends Activity { return; } - CameraUtils.decodeBitmap(b, new CameraUtils.BitmapCallback() { + CameraUtils.decodeBitmap(b, 1000, 1000, new CameraUtils.BitmapCallback() { @Override public void onBitmapReady(Bitmap bitmap) { imageView.setImageBitmap(bitmap); - approxUncompressedSize.setTitle("Approx. uncompressed size"); - approxUncompressedSize.setMessage(getApproximateFileMegabytes(bitmap) + "MB"); + // approxUncompressedSize.setTitle("Approx. uncompressed size"); + // approxUncompressedSize.setMessage(getApproximateFileMegabytes(bitmap) + "MB"); - captureLatency.setTitle("Capture latency"); + captureLatency.setTitle("Approx. capture latency"); captureLatency.setMessage(delay + " milliseconds"); // ncr and ar might be different when cropOutput is true. AspectRatio nativeRatio = AspectRatio.of(nativeWidth, nativeHeight); - AspectRatio finalRatio = AspectRatio.of(bitmap.getWidth(), bitmap.getHeight()); nativeCaptureResolution.setTitle("Native capture resolution"); nativeCaptureResolution.setMessage(nativeWidth + "x" + nativeHeight + " (" + nativeRatio + ")"); - actualResolution.setTitle("Actual resolution"); - actualResolution.setMessage(bitmap.getWidth() + "x" + bitmap.getHeight() + " (" + finalRatio + ")"); + // AspectRatio finalRatio = AspectRatio.of(bitmap.getWidth(), bitmap.getHeight()); + // actualResolution.setTitle("Actual resolution"); + // actualResolution.setMessage(bitmap.getWidth() + "x" + bitmap.getHeight() + " (" + finalRatio + ")"); } }); diff --git a/demo/src/main/res/layout/activity_picture_preview.xml b/demo/src/main/res/layout/activity_picture_preview.xml index 2411758d..44b09291 100644 --- a/demo/src/main/res/layout/activity_picture_preview.xml +++ b/demo/src/main/res/layout/activity_picture_preview.xml @@ -20,15 +20,15 @@ android:layout_width="match_parent" android:layout_height="wrap_content"/> - + android:layout_height="wrap_content"/--> - + android:layout_height="wrap_content"/-->