/** * Copyright 2019 The Google Research Authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.googleresearch.capturesync; import static java.nio.charset.StandardCharsets.UTF_8; import android.graphics.ImageFormat; import android.graphics.Rect; import android.graphics.YuvImage; import android.hardware.camera2.CaptureResult; import android.media.Image; import android.os.Handler; import android.os.HandlerThread; import android.provider.MediaStore; import android.util.Log; import com.googleresearch.capturesync.softwaresync.TimeDomainConverter; import com.googleresearch.capturesync.softwaresync.TimeUtils; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.PrintWriter; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.text.SimpleDateFormat; import java.util.TimeZone; /** A class that processes frames on its own thread. */ public class ResultProcessor { private static final String TAG = "ResultProcessor"; private final Handler handler; private final MainActivity context; private final TimeDomainConverter timeDomainConverter; // Copy from constants... make it a user parameter. private final boolean saveJpgFromNv21; private final int jpgQuality; public ResultProcessor( TimeDomainConverter timeDomainConverter, MainActivity context, boolean saveJpgFromYuv, int jpgQuality) { this.timeDomainConverter = timeDomainConverter; this.context = context; this.saveJpgFromNv21 = saveJpgFromYuv; this.jpgQuality = jpgQuality; HandlerThread thread = new HandlerThread(TAG); thread.start(); // getLooper() blocks until the thread started and its Looper is prepared. handler = new Handler(thread.getLooper()); } /** Submit a request to process a Frame on the processor's thread. */ public void submitProcessRequest(Frame capture, String filename) { handler.post(() -> processStill(capture, filename)); } private void processStill(final Frame frame, String basename) { File captureDir = new File(context.getExternalFilesDir(null), basename); if (!captureDir.exists() && !captureDir.mkdirs()) { throw new IllegalStateException("Could not create dir " + captureDir); } // Timestamp in local domain ie. time since boot in nanoseconds. long localSensorTimestampNs = frame.result.get(CaptureResult.SENSOR_TIMESTAMP); // Timestamp in leader domain ie. synchronized time on leader device in nanoseconds. long syncedSensorTimestampNs = timeDomainConverter.leaderTimeForLocalTimeNs(localSensorTimestampNs); // Use syncedSensorTimestamp in milliseconds for filenames. long syncedSensorTimestampMs = (long) TimeUtils.nanosToMillis(syncedSensorTimestampNs); String filenameTimeString = getTimeStr(syncedSensorTimestampMs); // Save timing metadata. { String metaFilename = "sync_metadata_" + filenameTimeString + ".txt"; File metaFile = new File(captureDir, metaFilename); saveTimingMetadata(syncedSensorTimestampNs, localSensorTimestampNs, metaFile); } for (int i = 0; i < frame.output.images.size(); ++i) { Image image = frame.output.images.get(i); int format = image.getFormat(); if (format == ImageFormat.RAW_SENSOR) { // Note: while using DngCreator works, streaming RAW_SENSOR is too slow. Log.e(TAG, "RAW_SENSOR saving not implemented!"); } else if (format == ImageFormat.JPEG) { Log.e(TAG, "JPEG saving not implemented!"); } else if (format == ImageFormat.RAW10) { Log.e(TAG, "RAW10 saving not implemented!"); } else if (format == ImageFormat.YUV_420_888) { // TODO(jiawen): We know that on Pixel devices, the YUV format is NV21, consisting of a luma // plane and separate interleaved chroma planes. // <--w--> // ^ YYYYYYYZZZ // | YYYYYYYZZZ // h ... // | ... // v YYYYYYYZZZ // // <--w--> // ^ VUVUVUVZZZZZ // | VUVUVUVZZZZZ // h/2 ... // | ... // v VUVUVUVZZZZZ // // where Z is padding bytes. // // TODO(jiawen): To determine if it's NV12 vs NV21, we need JNI to compare the buffer start // addresses. context.notifyCapturing("img_" + filenameTimeString); // Save NV21 raw + metadata. { File nv21File = new File(captureDir, "img_" + filenameTimeString + ".nv21"); File nv21MetadataFile = new File(captureDir, "nv21_metadata_" + filenameTimeString + ".txt"); saveNv21(image, nv21File, nv21MetadataFile); context.notifyCaptured(nv21File.getName()); } // TODO(samansari): Make save JPEG a checkbox in the UI. if (saveJpgFromNv21) { YuvImage yuvImage = yuvImageFromNv21Image(image); File jpgFile = new File(captureDir, "img_" + filenameTimeString + ".jpg"); // Push saving JPEG onto queue to let the frame close faster, necessary for some devices. handler.post(() -> saveJpg(yuvImage, jpgFile)); } } else { Log.e(TAG, String.format("Cannot save unsupported image format: %d", image.getFormat())); } } frame.close(); } private static boolean saveNv21(Image yuvImage, File nv21File, File nv21metadataFile) { long t0 = System.nanoTime(); Image.Plane[] planes = yuvImage.getPlanes(); Image.Plane luma = planes[0]; Image.Plane chromaU = planes[1]; Image.Plane chromaV = planes[2]; int width = yuvImage.getWidth(); int height = yuvImage.getHeight(); // Luma should be tightly packed. assert (luma.getPixelStride() == 1); // TODO(jiawen): Consider relaxing this restriction and write row by row, skipping the row // padding. This requires looping over the luma plane one row at a time. assert (luma.getRowStride() == width); assert (chromaU.getPixelStride() == 2); assert (chromaU.getRowStride() == width); assert (chromaV.getPixelStride() == 2); assert (chromaV.getRowStride() == width); ByteBuffer lumaBuffer = luma.getBuffer().duplicate(); ByteBuffer chromaUBuffer = chromaU.getBuffer().duplicate(); ByteBuffer chromaVBuffer = chromaV.getBuffer().duplicate(); assert (lumaBuffer.capacity() == width * height); assert (chromaUBuffer.capacity() + 1 == width * height / 2); assert (chromaVBuffer.capacity() + 1 == width * height / 2); { // Set chromaUBuffer's position to the last byte. slice() will make a new buffer that's a // view // of the last byte. Send that last byte to FileChannel. ByteBuffer chromaUBufferCopy = chromaUBuffer.duplicate(); chromaUBufferCopy.position(chromaUBufferCopy.capacity() - 1); ByteBuffer lastChromaUByte = chromaUBufferCopy.slice(); try (FileOutputStream outputStream = new FileOutputStream(nv21File)) { FileChannel outputChannel = outputStream.getChannel(); outputChannel.write(lumaBuffer); // The V buffer contains the U data since it's arranged VUVUVUVU... // It contains all but the last U byte. outputChannel.write(chromaVBuffer); outputChannel.write(lastChromaUByte); } catch (IOException e) { // TODO(jiawen,samansari): Toast. Log.w(TAG, "Error saving YUV image to: " + nv21File.getAbsolutePath()); return false; } } // Save NV21 metadata. { try (PrintWriter writer = new PrintWriter(nv21metadataFile, UTF_8.name())) { writer.printf("width: %d\n", width); writer.printf("height: %d\n", height); writer.printf("pixel_format: NV21 (tightly packed)\n"); writer.printf("luma_buffer_bytes: %d\n", lumaBuffer.capacity()); writer.printf("interleaved_chroma_buffers_bytes: %d\n", chromaVBuffer.capacity() + 1); } catch (IOException e) { // TODO(jiawen,samansari): Toast. Log.w(TAG, "Error saving metadata to: " + nv21metadataFile.getAbsolutePath()); return false; } } long t1 = System.nanoTime(); Log.i(TAG, String.format("saveNv21 took %f ms.", (t1 - t0) * 1e-6f)); return true; } private boolean saveJpg(YuvImage yuvImage, File jpgFile) { // Save JPEG and also add to the photos gallery by inserting into MediaStore. long t0 = System.nanoTime(); if (saveJpg(yuvImage, jpgQuality, jpgFile)) { try { MediaStore.Images.Media.insertImage( context.getContentResolver(), jpgFile.getAbsolutePath(), jpgFile.getName(), "Full path: " + jpgFile.getAbsolutePath()); } catch (FileNotFoundException e) { Log.e(TAG, "Unable to find file to link in media store."); } long t1 = System.nanoTime(); Log.i(TAG, String.format("Saving JPG to disk took %f ms.", (t1 - t0) * 1e-6f)); context.notifyCaptured(jpgFile.getName()); return true; } return false; } private static boolean saveJpg(YuvImage src, int quality, File file) { long t0 = System.nanoTime(); try (FileOutputStream outputStream = new FileOutputStream(file)) { Rect rect = new Rect(0, 0, src.getWidth(), src.getHeight()); boolean ok = src.compressToJpeg(rect, quality, outputStream); if (!ok) { // TODO(jiawen,samansari): Toast. Log.w(TAG, "Error saving JPEG to: " + file.getAbsolutePath()); } long t1 = System.nanoTime(); Log.i(TAG, String.format("saveJpg took %f ms.", (t1 - t0) * 1e-6f)); return ok; } catch (IOException e) { // TODO(jiawen,samansari): Toast. Log.w(TAG, "Error saving JPEG image to: " + file.getAbsolutePath()); return false; } } // Utility method to convert an NV21 android.media.Image to an android.graphics.YuvImage. The // latter is just a wrapper around a byte[] but can compress to JPEG. private static YuvImage yuvImageFromNv21Image(Image src) { long t0 = System.nanoTime(); Image.Plane[] planes = src.getPlanes(); Image.Plane luma = planes[0]; Image.Plane chromaU = planes[1]; Image.Plane chromaV = planes[2]; int width = src.getWidth(); int height = src.getHeight(); // Luma should be tightly packed and chroma should be tightly interleaved. assert (luma.getPixelStride() == 1); assert (chromaU.getPixelStride() == 2); assert (chromaV.getPixelStride() == 2); // Duplicate (shallow copy) each buffer so as to not disturb the underlying position/limit/etc. ByteBuffer lumaBuffer = luma.getBuffer().duplicate(); ByteBuffer chromaUBuffer = chromaU.getBuffer().duplicate(); ByteBuffer chromaVBuffer = chromaV.getBuffer().duplicate(); // Yes, y, v, then u since it's NV21. int[] yvuRowStrides = new int[] {luma.getRowStride(), chromaV.getRowStride(), chromaU.getRowStride()}; // Compute bytes needed to concatenate all the (potentially padded) YUV data in one buffer. int lumaBytes = height * luma.getRowStride(); int interleavedChromaBytes = (height / 2) * chromaV.getRowStride(); assert (lumaBuffer.capacity() == lumaBytes); int packedYVUBytes = lumaBytes + interleavedChromaBytes; byte[] packedYVU = new byte[packedYVUBytes]; int packedYVUOffset = 0; lumaBuffer.get( packedYVU, packedYVUOffset, lumaBuffer.capacity()); // packedYVU[0..lumaBytes) <-- lumaBuffer. packedYVUOffset += lumaBuffer.capacity(); // Write the V buffer. Since the V buffer contains U data, write all of V and then check how // much U data is left over. There be at most 1 byte plus padding. chromaVBuffer.get(packedYVU, packedYVUOffset, /*length=*/ chromaVBuffer.capacity()); packedYVUOffset += chromaVBuffer.capacity(); // Write the remaining portion of the U buffer (if any). int chromaUPosition = chromaVBuffer.capacity() - 1; if (chromaUPosition < chromaUBuffer.capacity()) { chromaUBuffer.position(chromaUPosition); int remainingBytes = Math.min(chromaUBuffer.remaining(), lumaBytes - packedYVUOffset); if (remainingBytes > 0) { chromaUBuffer.get(packedYVU, packedYVUOffset, remainingBytes); } } YuvImage yuvImage = new YuvImage(packedYVU, ImageFormat.NV21, width, height, yvuRowStrides); long t1 = System.nanoTime(); Log.i(TAG, String.format("yuvImageFromNv212Image took %f ms.", (t1 - t0) * 1e-6f)); return yuvImage; } private static String getTimeStr(long timestampMs) { SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmss_SSS"); simpleDateFormat.setTimeZone(TimeZone.getDefault()); return simpleDateFormat.format(timestampMs); } // Save metadata. private static void saveTimingMetadata( long leaderSensorTimestamp, long localSensorTimestamp, File metaFile) { try (PrintWriter writer = new PrintWriter(metaFile, UTF_8.name())) { writer.printf("leader_sensor_timestamp_ns: %d\n", leaderSensorTimestamp); writer.printf("local_sensor_timestamp_ns: %d\n", localSensorTimestamp); } catch (IOException e) { Log.e(TAG, "Error saving timing metadata to: " + metaFile.getAbsolutePath()); return; } Log.v(TAG, "Saved timing metadata to: " + metaFile.getAbsolutePath()); } }