/* * Copyright 2014 The Android Open Source Project * * 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 android.hardware.camera2; import android.annotation.IntRange; import android.annotation.NonNull; import android.annotation.Nullable; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.ImageFormat; import android.hardware.camera2.impl.CameraMetadataNative; import android.location.Location; import android.media.ExifInterface; import android.media.Image; import android.os.SystemClock; import android.util.Log; import android.util.Size; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.ByteBuffer; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.TimeZone; /** * The {@link DngCreator} class provides functions to write raw pixel data as a DNG file. * *

* This class is designed to be used with the {@link android.graphics.ImageFormat#RAW_SENSOR} * buffers available from {@link android.hardware.camera2.CameraDevice}, or with Bayer-type raw * pixel data that is otherwise generated by an application. The DNG metadata tags will be * generated from a {@link android.hardware.camera2.CaptureResult} object or set directly. *

* *

* The DNG file format is a cross-platform file format that is used to store pixel data from * camera sensors with minimal pre-processing applied. DNG files allow for pixel data to be * defined in a user-defined colorspace, and have associated metadata that allow for this * pixel data to be converted to the standard CIE XYZ colorspace during post-processing. *

* *

* For more information on the DNG file format and associated metadata, please refer to the * * Adobe DNG 1.4.0.0 specification. *

*/ public final class DngCreator implements AutoCloseable { private static final String TAG = "DngCreator"; /** * Create a new DNG object. * *

* It is not necessary to call any set methods to write a well-formatted DNG file. *

*

* DNG metadata tags will be generated from the corresponding parameters in the * {@link android.hardware.camera2.CaptureResult} object. *

*

* For best quality DNG files, it is strongly recommended that lens shading map output is * enabled if supported. See {@link CaptureRequest#STATISTICS_LENS_SHADING_MAP_MODE}. *

* @param characteristics an object containing the static * {@link android.hardware.camera2.CameraCharacteristics}. * @param metadata a metadata object to generate tags from. */ public DngCreator(@NonNull CameraCharacteristics characteristics, @NonNull CaptureResult metadata) { if (characteristics == null || metadata == null) { throw new IllegalArgumentException("Null argument to DngCreator constructor"); } // Find current time in milliseconds since 1970 long currentTime = System.currentTimeMillis(); // Assume that sensor timestamp has that timebase to start long timeOffset = 0; int timestampSource = characteristics.get( CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE); if (timestampSource == CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE_REALTIME) { // This means the same timebase as SystemClock.elapsedRealtime(), // which is CLOCK_BOOTTIME timeOffset = currentTime - SystemClock.elapsedRealtime(); } else if (timestampSource == CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE_UNKNOWN) { // This means the same timebase as System.currentTimeMillis(), // which is CLOCK_MONOTONIC timeOffset = currentTime - SystemClock.uptimeMillis(); } else { // Unexpected time source - treat as CLOCK_MONOTONIC Log.w(TAG, "Sensor timestamp source is unexpected: " + timestampSource); timeOffset = currentTime - SystemClock.uptimeMillis(); } // Find capture time (nanos since boot) Long timestamp = metadata.get(CaptureResult.SENSOR_TIMESTAMP); long captureTime = currentTime; if (timestamp != null) { captureTime = timestamp / 1000000 + timeOffset; } // Create this fresh each time since the time zone may change while a long-running application // is active. final DateFormat dateTimeStampFormat = new SimpleDateFormat(TIFF_DATETIME_FORMAT); dateTimeStampFormat.setTimeZone(TimeZone.getDefault()); // Format for metadata String formattedCaptureTime = dateTimeStampFormat.format(captureTime); nativeInit(characteristics.getNativeCopy(), metadata.getNativeCopy(), formattedCaptureTime); } /** * Set the orientation value to write. * *

* This will be written as the TIFF "Orientation" tag {@code (0x0112)}. * Calling this will override any prior settings for this tag. *

* * @param orientation the orientation value to set, one of: * * @return this {@link #DngCreator} object. */ @NonNull public DngCreator setOrientation(int orientation) { if (orientation < ExifInterface.ORIENTATION_UNDEFINED || orientation > ExifInterface.ORIENTATION_ROTATE_270) { throw new IllegalArgumentException("Orientation " + orientation + " is not a valid EXIF orientation value"); } // ExifInterface and TIFF/EP spec differ on definition of // "Unknown" orientation; other values map directly if (orientation == ExifInterface.ORIENTATION_UNDEFINED) { orientation = TAG_ORIENTATION_UNKNOWN; } nativeSetOrientation(orientation); return this; } /** * Set the thumbnail image. * *

* Pixel data will be converted to a Baseline TIFF RGB image, with 8 bits per color channel. * The alpha channel will be discarded. Thumbnail images with a dimension larger than * {@link #MAX_THUMBNAIL_DIMENSION} will be rejected. *

* * @param pixels a {@link android.graphics.Bitmap} of pixel data. * @return this {@link #DngCreator} object. * @throws java.lang.IllegalArgumentException if the given thumbnail image has a dimension * larger than {@link #MAX_THUMBNAIL_DIMENSION}. */ @NonNull public DngCreator setThumbnail(@NonNull Bitmap pixels) { if (pixels == null) { throw new IllegalArgumentException("Null argument to setThumbnail"); } int width = pixels.getWidth(); int height = pixels.getHeight(); if (width > MAX_THUMBNAIL_DIMENSION || height > MAX_THUMBNAIL_DIMENSION) { throw new IllegalArgumentException("Thumbnail dimensions width,height (" + width + "," + height + ") too large, dimensions must be smaller than " + MAX_THUMBNAIL_DIMENSION); } ByteBuffer rgbBuffer = convertToRGB(pixels); nativeSetThumbnail(rgbBuffer, width, height); return this; } /** * Set the thumbnail image. * *

* Pixel data is interpreted as a {@link android.graphics.ImageFormat#YUV_420_888} image. * Thumbnail images with a dimension larger than {@link #MAX_THUMBNAIL_DIMENSION} will be * rejected. *

* * @param pixels an {@link android.media.Image} object with the format * {@link android.graphics.ImageFormat#YUV_420_888}. * @return this {@link #DngCreator} object. * @throws java.lang.IllegalArgumentException if the given thumbnail image has a dimension * larger than {@link #MAX_THUMBNAIL_DIMENSION}. */ @NonNull public DngCreator setThumbnail(@NonNull Image pixels) { if (pixels == null) { throw new IllegalArgumentException("Null argument to setThumbnail"); } int format = pixels.getFormat(); if (format != ImageFormat.YUV_420_888) { throw new IllegalArgumentException("Unsupported Image format " + format); } int width = pixels.getWidth(); int height = pixels.getHeight(); if (width > MAX_THUMBNAIL_DIMENSION || height > MAX_THUMBNAIL_DIMENSION) { throw new IllegalArgumentException("Thumbnail dimensions width,height (" + width + "," + height + ") too large, dimensions must be smaller than " + MAX_THUMBNAIL_DIMENSION); } ByteBuffer rgbBuffer = convertToRGB(pixels); nativeSetThumbnail(rgbBuffer, width, height); return this; } /** * Set image location metadata. * *

* The given location object must contain at least a valid time, latitude, and longitude * (equivalent to the values returned by {@link android.location.Location#getTime()}, * {@link android.location.Location#getLatitude()}, and * {@link android.location.Location#getLongitude()} methods). *

* * @param location an {@link android.location.Location} object to set. * @return this {@link #DngCreator} object. * * @throws java.lang.IllegalArgumentException if the given location object doesn't * contain enough information to set location metadata. */ @NonNull public DngCreator setLocation(@NonNull Location location) { if (location == null) { throw new IllegalArgumentException("Null location passed to setLocation"); } double latitude = location.getLatitude(); double longitude = location.getLongitude(); long time = location.getTime(); int[] latTag = toExifLatLong(latitude); int[] longTag = toExifLatLong(longitude); String latRef = latitude >= 0 ? GPS_LAT_REF_NORTH : GPS_LAT_REF_SOUTH; String longRef = longitude >= 0 ? GPS_LONG_REF_EAST : GPS_LONG_REF_WEST; String dateTag = sExifGPSDateStamp.format(time); mGPSTimeStampCalendar.setTimeInMillis(time); int[] timeTag = new int[] { mGPSTimeStampCalendar.get(Calendar.HOUR_OF_DAY), 1, mGPSTimeStampCalendar.get(Calendar.MINUTE), 1, mGPSTimeStampCalendar.get(Calendar.SECOND), 1 }; nativeSetGpsTags(latTag, latRef, longTag, longRef, dateTag, timeTag); return this; } /** * Set the user description string to write. * *

* This is equivalent to setting the TIFF "ImageDescription" tag {@code (0x010E)}. *

* * @param description the user description string. * @return this {@link #DngCreator} object. */ @NonNull public DngCreator setDescription(@NonNull String description) { if (description == null) { throw new IllegalArgumentException("Null description passed to setDescription."); } nativeSetDescription(description); return this; } /** * Write the {@link android.graphics.ImageFormat#RAW_SENSOR} pixel data to a DNG file with * the currently configured metadata. * *

* Raw pixel data must have 16 bits per pixel, and the input must contain at least * {@code offset + 2 * width * height)} bytes. The width and height of * the input are taken from the width and height set in the {@link DngCreator} metadata tags, * and will typically be equal to the width and height of * {@link CameraCharacteristics#SENSOR_INFO_PRE_CORRECTION_ACTIVE_ARRAY_SIZE}. Prior to * API level 23, this was always the same as * {@link CameraCharacteristics#SENSOR_INFO_ACTIVE_ARRAY_SIZE}. * The pixel layout in the input is determined from the reported color filter arrangement (CFA) * set in {@link CameraCharacteristics#SENSOR_INFO_COLOR_FILTER_ARRANGEMENT}. If insufficient * metadata is available to write a well-formatted DNG file, an * {@link java.lang.IllegalStateException} will be thrown. *

* * @param dngOutput an {@link java.io.OutputStream} to write the DNG file to. * @param size the {@link Size} of the image to write, in pixels. * @param pixels an {@link java.io.InputStream} of pixel data to write. * @param offset the offset of the raw image in bytes. This indicates how many bytes will * be skipped in the input before any pixel data is read. * * @throws IOException if an error was encountered in the input or output stream. * @throws java.lang.IllegalStateException if not enough metadata information has been * set to write a well-formatted DNG file. * @throws java.lang.IllegalArgumentException if the size passed in does not match the */ public void writeInputStream(@NonNull OutputStream dngOutput, @NonNull Size size, @NonNull InputStream pixels, @IntRange(from=0) long offset) throws IOException { if (dngOutput == null) { throw new IllegalArgumentException("Null dngOutput passed to writeInputStream"); } else if (size == null) { throw new IllegalArgumentException("Null size passed to writeInputStream"); } else if (pixels == null) { throw new IllegalArgumentException("Null pixels passed to writeInputStream"); } else if (offset < 0) { throw new IllegalArgumentException("Negative offset passed to writeInputStream"); } int width = size.getWidth(); int height = size.getHeight(); if (width <= 0 || height <= 0) { throw new IllegalArgumentException("Size with invalid width, height: (" + width + "," + height + ") passed to writeInputStream"); } nativeWriteInputStream(dngOutput, pixels, width, height, offset); } /** * Write the {@link android.graphics.ImageFormat#RAW_SENSOR} pixel data to a DNG file with * the currently configured metadata. * *

* Raw pixel data must have 16 bits per pixel, and the input must contain at least * {@code offset + 2 * width * height)} bytes. The width and height of * the input are taken from the width and height set in the {@link DngCreator} metadata tags, * and will typically be equal to the width and height of * {@link CameraCharacteristics#SENSOR_INFO_PRE_CORRECTION_ACTIVE_ARRAY_SIZE}. Prior to * API level 23, this was always the same as * {@link CameraCharacteristics#SENSOR_INFO_ACTIVE_ARRAY_SIZE}. * The pixel layout in the input is determined from the reported color filter arrangement (CFA) * set in {@link CameraCharacteristics#SENSOR_INFO_COLOR_FILTER_ARRANGEMENT}. If insufficient * metadata is available to write a well-formatted DNG file, an * {@link java.lang.IllegalStateException} will be thrown. *

* *

* Any mark or limit set on this {@link ByteBuffer} is ignored, and will be cleared by this * method. *

* * @param dngOutput an {@link java.io.OutputStream} to write the DNG file to. * @param size the {@link Size} of the image to write, in pixels. * @param pixels an {@link java.nio.ByteBuffer} of pixel data to write. * @param offset the offset of the raw image in bytes. This indicates how many bytes will * be skipped in the input before any pixel data is read. * * @throws IOException if an error was encountered in the input or output stream. * @throws java.lang.IllegalStateException if not enough metadata information has been * set to write a well-formatted DNG file. */ public void writeByteBuffer(@NonNull OutputStream dngOutput, @NonNull Size size, @NonNull ByteBuffer pixels, @IntRange(from=0) long offset) throws IOException { if (dngOutput == null) { throw new IllegalArgumentException("Null dngOutput passed to writeByteBuffer"); } else if (size == null) { throw new IllegalArgumentException("Null size passed to writeByteBuffer"); } else if (pixels == null) { throw new IllegalArgumentException("Null pixels passed to writeByteBuffer"); } else if (offset < 0) { throw new IllegalArgumentException("Negative offset passed to writeByteBuffer"); } int width = size.getWidth(); int height = size.getHeight(); writeByteBuffer(width, height, pixels, dngOutput, DEFAULT_PIXEL_STRIDE, width * DEFAULT_PIXEL_STRIDE, offset); } /** * Write the pixel data to a DNG file with the currently configured metadata. * *

* For this method to succeed, the {@link android.media.Image} input must contain * {@link android.graphics.ImageFormat#RAW_SENSOR} pixel data, otherwise an * {@link java.lang.IllegalArgumentException} will be thrown. *

* * @param dngOutput an {@link java.io.OutputStream} to write the DNG file to. * @param pixels an {@link android.media.Image} to write. * * @throws java.io.IOException if an error was encountered in the output stream. * @throws java.lang.IllegalArgumentException if an image with an unsupported format was used. * @throws java.lang.IllegalStateException if not enough metadata information has been * set to write a well-formatted DNG file. */ public void writeImage(@NonNull OutputStream dngOutput, @NonNull Image pixels) throws IOException { if (dngOutput == null) { throw new IllegalArgumentException("Null dngOutput to writeImage"); } else if (pixels == null) { throw new IllegalArgumentException("Null pixels to writeImage"); } int format = pixels.getFormat(); if (format != ImageFormat.RAW_SENSOR) { throw new IllegalArgumentException("Unsupported image format " + format); } Image.Plane[] planes = pixels.getPlanes(); if (planes == null || planes.length <= 0) { throw new IllegalArgumentException("Image with no planes passed to writeImage"); } ByteBuffer buf = planes[0].getBuffer(); writeByteBuffer(pixels.getWidth(), pixels.getHeight(), buf, dngOutput, planes[0].getPixelStride(), planes[0].getRowStride(), 0); } @Override public void close() { nativeDestroy(); } /** * Max width or height dimension for thumbnails. */ public static final int MAX_THUMBNAIL_DIMENSION = 256; // max pixel dimension for TIFF/EP @Override protected void finalize() throws Throwable { try { close(); } finally { super.finalize(); } } private static final String GPS_LAT_REF_NORTH = "N"; private static final String GPS_LAT_REF_SOUTH = "S"; private static final String GPS_LONG_REF_EAST = "E"; private static final String GPS_LONG_REF_WEST = "W"; private static final String GPS_DATE_FORMAT_STR = "yyyy:MM:dd"; private static final String TIFF_DATETIME_FORMAT = "yyyy:MM:dd HH:mm:ss"; private static final DateFormat sExifGPSDateStamp = new SimpleDateFormat(GPS_DATE_FORMAT_STR); private final Calendar mGPSTimeStampCalendar = Calendar .getInstance(TimeZone.getTimeZone("UTC")); static { sExifGPSDateStamp.setTimeZone(TimeZone.getTimeZone("UTC")); } private static final int DEFAULT_PIXEL_STRIDE = 2; // bytes per sample private static final int BYTES_PER_RGB_PIX = 3; // byts per pixel // TIFF tag values needed to map between public API and TIFF spec private static final int TAG_ORIENTATION_UNKNOWN = 9; /** * Offset, rowStride, and pixelStride are given in bytes. Height and width are given in pixels. */ private void writeByteBuffer(int width, int height, ByteBuffer pixels, OutputStream dngOutput, int pixelStride, int rowStride, long offset) throws IOException { if (width <= 0 || height <= 0) { throw new IllegalArgumentException("Image with invalid width, height: (" + width + "," + height + ") passed to write"); } long capacity = pixels.capacity(); long totalSize = ((long) rowStride) * height + offset; if (capacity < totalSize) { throw new IllegalArgumentException("Image size " + capacity + " is too small (must be larger than " + totalSize + ")"); } int minRowStride = pixelStride * width; if (minRowStride > rowStride) { throw new IllegalArgumentException("Invalid image pixel stride, row byte width " + minRowStride + " is too large, expecting " + rowStride); } pixels.clear(); // Reset mark and limit nativeWriteImage(dngOutput, width, height, pixels, rowStride, pixelStride, offset, pixels.isDirect()); pixels.clear(); } /** * Convert a single YUV pixel to RGB. */ private static void yuvToRgb(byte[] yuvData, int outOffset, /*out*/byte[] rgbOut) { final int COLOR_MAX = 255; float y = yuvData[0] & 0xFF; // Y channel float cb = yuvData[1] & 0xFF; // U channel float cr = yuvData[2] & 0xFF; // V channel // convert YUV -> RGB (from JFIF's "Conversion to and from RGB" section) float r = y + 1.402f * (cr - 128); float g = y - 0.34414f * (cb - 128) - 0.71414f * (cr - 128); float b = y + 1.772f * (cb - 128); // clamp to [0,255] rgbOut[outOffset] = (byte) Math.max(0, Math.min(COLOR_MAX, r)); rgbOut[outOffset + 1] = (byte) Math.max(0, Math.min(COLOR_MAX, g)); rgbOut[outOffset + 2] = (byte) Math.max(0, Math.min(COLOR_MAX, b)); } /** * Convert a single {@link Color} pixel to RGB. */ private static void colorToRgb(int color, int outOffset, /*out*/byte[] rgbOut) { rgbOut[outOffset] = (byte) Color.red(color); rgbOut[outOffset + 1] = (byte) Color.green(color); rgbOut[outOffset + 2] = (byte) Color.blue(color); // Discards Alpha } /** * Generate a direct RGB {@link ByteBuffer} from a YUV420_888 {@link Image}. */ private static ByteBuffer convertToRGB(Image yuvImage) { // TODO: Optimize this with renderscript intrinsic. int width = yuvImage.getWidth(); int height = yuvImage.getHeight(); ByteBuffer buf = ByteBuffer.allocateDirect(BYTES_PER_RGB_PIX * width * height); Image.Plane yPlane = yuvImage.getPlanes()[0]; Image.Plane uPlane = yuvImage.getPlanes()[1]; Image.Plane vPlane = yuvImage.getPlanes()[2]; ByteBuffer yBuf = yPlane.getBuffer(); ByteBuffer uBuf = uPlane.getBuffer(); ByteBuffer vBuf = vPlane.getBuffer(); yBuf.rewind(); uBuf.rewind(); vBuf.rewind(); int yRowStride = yPlane.getRowStride(); int vRowStride = vPlane.getRowStride(); int uRowStride = uPlane.getRowStride(); int yPixStride = yPlane.getPixelStride(); int vPixStride = vPlane.getPixelStride(); int uPixStride = uPlane.getPixelStride(); byte[] yuvPixel = { 0, 0, 0 }; byte[] yFullRow = new byte[yPixStride * (width - 1) + 1]; byte[] uFullRow = new byte[uPixStride * (width / 2 - 1) + 1]; byte[] vFullRow = new byte[vPixStride * (width / 2 - 1) + 1]; byte[] finalRow = new byte[BYTES_PER_RGB_PIX * width]; for (int i = 0; i < height; i++) { int halfH = i / 2; yBuf.position(yRowStride * i); yBuf.get(yFullRow); uBuf.position(uRowStride * halfH); uBuf.get(uFullRow); vBuf.position(vRowStride * halfH); vBuf.get(vFullRow); for (int j = 0; j < width; j++) { int halfW = j / 2; yuvPixel[0] = yFullRow[yPixStride * j]; yuvPixel[1] = uFullRow[uPixStride * halfW]; yuvPixel[2] = vFullRow[vPixStride * halfW]; yuvToRgb(yuvPixel, j * BYTES_PER_RGB_PIX, /*out*/finalRow); } buf.put(finalRow); } yBuf.rewind(); uBuf.rewind(); vBuf.rewind(); buf.rewind(); return buf; } /** * Generate a direct RGB {@link ByteBuffer} from a {@link Bitmap}. */ private static ByteBuffer convertToRGB(Bitmap argbBitmap) { // TODO: Optimize this. int width = argbBitmap.getWidth(); int height = argbBitmap.getHeight(); ByteBuffer buf = ByteBuffer.allocateDirect(BYTES_PER_RGB_PIX * width * height); int[] pixelRow = new int[width]; byte[] finalRow = new byte[BYTES_PER_RGB_PIX * width]; for (int i = 0; i < height; i++) { argbBitmap.getPixels(pixelRow, /*offset*/0, /*stride*/width, /*x*/0, /*y*/i, /*width*/width, /*height*/1); for (int j = 0; j < width; j++) { colorToRgb(pixelRow[j], j * BYTES_PER_RGB_PIX, /*out*/finalRow); } buf.put(finalRow); } buf.rewind(); return buf; } /** * Convert coordinate to EXIF GPS tag format. */ private static int[] toExifLatLong(double value) { // convert to the format dd/1 mm/1 ssss/100 value = Math.abs(value); int degrees = (int) value; value = (value - degrees) * 60; int minutes = (int) value; value = (value - minutes) * 6000; int seconds = (int) value; return new int[] { degrees, 1, minutes, 1, seconds, 100 }; } /** * This field is used by native code, do not access or modify. */ private long mNativeContext; private static native void nativeClassInit(); private synchronized native void nativeInit(CameraMetadataNative nativeCharacteristics, CameraMetadataNative nativeResult, String captureTime); private synchronized native void nativeDestroy(); private synchronized native void nativeSetOrientation(int orientation); private synchronized native void nativeSetDescription(String description); private synchronized native void nativeSetGpsTags(int[] latTag, String latRef, int[] longTag, String longRef, String dateTag, int[] timeTag); private synchronized native void nativeSetThumbnail(ByteBuffer buffer, int width, int height); private synchronized native void nativeWriteImage(OutputStream out, int width, int height, ByteBuffer rawBuffer, int rowStride, int pixStride, long offset, boolean isDirect) throws IOException; private synchronized native void nativeWriteInputStream(OutputStream out, InputStream rawStream, int width, int height, long offset) throws IOException; static { nativeClassInit(); } }