Android Camera2 教程 · 第四章 · 拍照
DarylGo关注
Android Camera
上一章《Camera2 预览》我们学习了如何配置预览,接下来我们来学习如何拍照。
阅读完本章,你将会学到以下几个知识点:
- 理解 Capture 工作流程
- 如何拍摄单张照片
- 如何连续拍摄多张照片
- 如何连拍照片
- 如何配置缩略图尺寸
- 如何播放快门音效
- 如何矫正图片方向
- 如何切换前后置摄像头
你可以在 https://github.com/darylgo/Camera2Sample 下载相关的源码,并且切换到 Tutorial4 标签下。
1 理解 Capture 工作流程
在正式介绍如何拍照之前,我们有必要深入理解几种不同模式的 Capture 的工作流程,只要理解它们的工作流程就很容易掌握各种拍照模式的实现原理,在第一章《Camera2 概览》 里我们介绍了 Capture 有以下几种不同模式:
-
单次模式(One-shot):指的是只执行一次的 Capture 操作,例如设置闪光灯模式、对焦模式和拍一张照片等。多个单次模式的 Capture 会进入队列按顺序执行。
-
多次模式(Burst):指的是连续多次执行指定的 Capture 操作,该模式和多次执行单次模式的最大区别是连续多次 Capture 期间不允许插入其他任何 Capture 操作,例如连续拍摄 100 张照片,在拍摄这 100 张照片期间任何新的 Capture 请求都会排队等待,直到拍完 100 张照片。多组多次模式的 Capture 会进入队列按顺序执行。
-
重复模式(Repeating):指的是不断重复执行指定的 Capture 操作,当有其他模式的 Capture 提交时会暂停该模式,转而执行其他被模式的 Capture,当其他模式的 Capture 执行完毕后又会自动恢复继续执行该模式的 Capture,例如显示预览画面就是不断 Capture 获取每一帧画面。该模式的 Capture 是全局唯一的,也就是新提交的重复模式 Capture 会覆盖旧的重复模式 Capture。
我们举个例子来进一步说明上面三种模式,假设我们的相机应用程序开启了预览,所以会提交一个重复模式的 Capture 用于不断获取预览画面,然后我们提交一个单次模式的 Capture,接着我们又提交了一组连续三次的多次模式的 Capture,这些不同模式的 Capture 会按照下图所示被执行:
Capture 工作原理
下面是几个重要的注意事项:
-
无论 Capture 以何种模式被提交,它们都是按顺序串行执行的,不存在并行执行的情况。
-
重复模式是一个比较特殊的模式,因为它会保留我们提交的 CaptureRequest 对象用于不断重复执行 Capture 操作,所以大多数情况下重复模式的 CaptureRequest 和其他模式的 CaptureRequest 是独立的,这就会导致重复模式的参数和其他模式的参数会有一定的差异,例如重复模式不会配置
CaptureRequest.AF_TRIGGER_START
,因为这会导致相机不断触发对焦的操作。 -
如果某一次的 Capture 没有配置预览的 Surface,例如拍照的时候,就会导致本次 Capture 不会将画面输出到预览的 Surface 上,进而导致预览画面卡顿的情况,所以大部分情况下我们都会将预览的 Surface 添加到所有的 CaptureRequest 里。
2 如何拍摄单张照片
拍摄单张照片是最简单的拍照模式,它使用的就是单次模式的 Capture,我们会使用 ImageReader 创建一个接收照片的 Surface,并且把它添加到 CaptureRequest 里提交给相机进行拍照,最后通过 ImageReader 的回调获取 Image 对象,进而获取 JPEG 图像数据进行保存。
2.1 定义回调接口
当拍照完成的时候我们会得到两个数据对象,一个是通过 onImageAvailable()
回调给我们的存储图像数据的 Image,一个是通过 onCaptureCompleted()
回调给我们的存储拍照信息的 CaptureResult,它们是一一对应的,所以我们定义了如下两个回调接口:
private val captureResults: BlockingQueue<CaptureResult> = LinkedBlockingDeque()
private inner class CaptureImageStateCallback : CameraCaptureSession.CaptureCallback() {
@MainThread
override fun onCaptureCompleted(session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult) {
super.onCaptureCompleted(session, request, result)
captureResults.put(result)
}
}
private inner class OnJpegImageAvailableListener : ImageReader.OnImageAvailableListener {
@WorkerThread
override fun onImageAvailable(imageReader: ImageReader) {
val image = imageReader.acquireNextImage()
val captureResult = captureResults.take()
if (image != null && captureResult != null) {
// Save image into sdcard.
}
}
}
2.2 创建 ImageReader
创建 ImageReader 需要我们指定照片的大小,所以首先我们要获取支持的照片尺寸列表,并且从中筛选出合适的尺寸,假设我们要求照片的尺寸最大不能超过 4032x3024,并且比例必须是 4:3,所以会有如下筛选尺寸的代码片段:
@WorkerThread
private fun getOptimalSize(cameraCharacteristics: CameraCharacteristics, clazz: Class<*>, maxWidth: Int, maxHeight: Int): Size? {
val streamConfigurationMap = cameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
val supportedSizes = streamConfigurationMap?.getOutputSizes(clazz)
return getOptimalSize(supportedSizes, maxWidth, maxHeight)
}
@AnyThread
private fun getOptimalSize(supportedSizes: Array<Size>?, maxWidth: Int, maxHeight: Int): Size? {
val aspectRatio = maxWidth.toFloat() / maxHeight
if (supportedSizes != null) {
for (size in supportedSizes) {
if (size.width.toFloat() / size.height == aspectRatio && size.height <= maxHeight && size.width <= maxWidth) {
return size
}
}
}
return null
}
接着我们就可以筛选出合适的尺寸,然后创建一个图像格式是 JPEG 的 ImageReader 对象,并且获取它的 Surface:
val imageSize = getOptimalSize(cameraCharacteristics, ImageReader::class.java, maxWidth, maxHeight)!!
jpegImageReader = ImageReader.newInstance(imageSize.width, imageSize.height, ImageFormat.JPEG, 5)
jpegImageReader?.setOnImageAvailableListener(OnJpegImageAvailableListener(), cameraHandler)
jpegSurface = jpegImageReader?.surface
2.3 创建 CaptureRequest
接下来我们使用 TEMPLATE_STILL_CAPTURE
模板创建一个用于拍照的 CaptureRequest.Builder 对象,并且添加拍照的 Surface 和预览的 Surface 到其中:
captureImageRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)
captureImageRequestBuilder.addTarget(previewDataSurface)
captureImageRequestBuilder.addTarget(jpegSurface)
你可能会疑问为什么拍照用的 CaptureRequest 对象需要添加预览的 Surface,这一点我们在前面有解释过了,如果某一次的 Capture 没有配置预览的 Surface,例如拍照的时候,就会导致本次 Capture 不会将画面输出到预览的 Surface 上,进而导致预览画面卡顿的情况,所以大部分情况下我们都会将预览的 Surface 添加到所有的 CaptureRequest 里。
2.4 矫正 JPEG 图片方向
在 《Camera2 预览》 里我们介绍了一些方向的概念,也提到了摄像头传感器的方向很多时候都不是 0°,这就会导致我们拍出来的照片方向是错误的,例如手机摄像头传感器方向是 90° 的时候,垂直拿着手机拍出来的照片很可能是横着的:
在进行图片方向矫正的时候,我们的目的是做到所见即所得,也就是用户在预览画面里看到的是什么样,输出的图片就是什么样。为了做到图片所见即所得,我们要同时考虑设备方向和摄像头传感器方向,下面是一段来自官方的图片矫正代码:
private fun getJpegOrientation(cameraCharacteristics: CameraCharacteristics, deviceOrientation: Int): Int {
var myDeviceOrientation = deviceOrientation
if (myDeviceOrientation == android.view.OrientationEventListener.ORIENTATION_UNKNOWN) {
return 0
}
val sensorOrientation = cameraCharacteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)!!
// Round device orientation to a multiple of 90
myDeviceOrientation = (myDeviceOrientation + 45) / 90 * 90
// Reverse device orientation for front-facing cameras
val facingFront = cameraCharacteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT
if (facingFront) {
myDeviceOrientation = -myDeviceOrientation
}
// Calculate desired JPEG orientation relative to camera orientation to make
// the image upright relative to the device orientation
return (sensorOrientation + myDeviceOrientation + 360) % 360
}
如果你已经理解 《Camera2 预览》 里我们介绍的一些方向概念,那么上面这段代码其实就很容易理解,唯一特别的地方是前置摄像头输出的画面底层默认做了镜像的翻转才能保证我们在预览的时候看到的画面就想照镜子一样,所以前置摄像头给的 SENSOR_ORIENTATION 值也是经过镜像的,但是相机在输出 JPEG 的时候并没有进行镜像操作,所以在计算 JPEG 矫正角度的时候要对这个默认镜像的操作进行逆向镜像。
计算出图片的矫正角度后,我们要通过 CaptureRequest.JPEG_ORIENTATION
配置这个角度,相机在拍照输出 JPEG 图像的时候会参考这个角度值从以下两种方式选一种进行图像方向矫正:
- 直接对图像进行旋转,并且将 Exif 的 ORIENTATION 标签赋值为 0。
- 不对图像进行旋转,而是将旋转信息写入 Exif 的 ORIENTATION 标签里。
客户端在显示图片的时候一定要去检查 Exif 的ORIENTATION 标签的值,并且根据这个值对图片进行对应角度的旋转才能保证图片显示方向是正确的。
val deviceOrientation = deviceOrientationListener.orientation
val jpegOrientation = getJpegOrientation(cameraCharacteristics, deviceOrientation)
captureImageRequestBuilder[CaptureRequest.JPEG_ORIENTATION] = jpegOrientation
2.5 设置缩略图尺寸
相机在输出 JPEG 图片的时候,同时会根据我们通过 CaptureRequest.JPEG_THUMBNAIL_SZIE
配置的缩略图尺寸生成一张缩略图写入图片的 Exif 信息里。在设置缩略图尺寸之前,我们首先要获取相机支持哪些缩略图尺寸,与获取预览尺寸或照片尺寸列表方式不一样的是,缩略图尺寸列表是直接通过 CameraCharacteristics.JPEG_AVAILABLE_THUMBNAIL_SIZES
获取的。配置缩略图尺寸的代码如下所示:
val availableThumbnailSizes = cameraCharacteristics[CameraCharacteristics.JPEG_AVAILABLE_THUMBNAIL_SIZES]
val thumbnailSize = getOptimalSize(availableThumbnailSizes, maxWidth, maxHeight)
在获取图片缩略图的时候,我们不能总是假设图片一定会在 Exif 写入缩略图,当 Exif 里面没有缩略图数据的时候,我们要转而直接 Decode 原图获取缩略图,另外无论是原图还是缩略图,都要根据 Exif 的 ORIENTATION 角度进行角度矫正才能正确显示,下面是我们 Demo 中获取图片缩略图的代码:
@WorkerThread
private fun getThumbnail(jpegPath: String): Bitmap? {
val exifInterface = ExifInterface(jpegPath)
val orientationFlag = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
val orientation = when (orientationFlag) {
ExifInterface.ORIENTATION_NORMAL -> 0.0F
ExifInterface.ORIENTATION_ROTATE_90 -> 90.0F
ExifInterface.ORIENTATION_ROTATE_180 -> 180.0F
ExifInterface.ORIENTATION_ROTATE_270 -> 270.0F
else -> 0.0F
}
var thumbnail = if (exifInterface.hasThumbnail()) {
exifInterface.thumbnailBitmap
} else {
val options = BitmapFactory.Options()
options.inSampleSize = 16
BitmapFactory.decodeFile(jpegPath, options)
}
if (orientation != 0.0F && thumbnail != null) {
val matrix = Matrix()
matrix.setRotate(orientation)
thumbnail = Bitmap.createBitmap(thumbnail, 0, 0, thumbnail.width, thumbnail.height, matrix, true)
}
return thumbnail
}
2.6 设置定位信息
拍照的时候,通常都会在图片的 Exif 写入定位信息,我们可以通过 CaptureRequest.JPEG_GPS_LOCATION
配置定位信息,代码如下:
@WorkerThread
private fun getLocation(): Location? {
val locationManager = getSystemService(LocationManager::class.java)
if (locationManager != null && ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
return locationManager.getLastKnownLocation(LocationManager.PASSIVE_PROVIDER)
}
return null
}
val location = getLocation()
captureImageRequestBuilder[CaptureRequest.JPEG_GPS_LOCATION] = location
2.7 播放快门音效
在进行拍照之前,我们还需要配置拍照时播放的快门音效,因为 Camera2 和 Camera1 不一样,拍照时不会有任何声音,需要我们在适当的时候通过 MediaSoundPlayer 播放快门音效,通常情况我们是在 CaptureStateCallback.onCaptureStarted()
回调的时候播放快门音效:
private val mediaActionSound: MediaActionSound = MediaActionSound()
private inner class CaptureImageStateCallback : CameraCaptureSession.CaptureCallback() {
@MainThread
override fun onCaptureStarted(session: CameraCaptureSession, request: CaptureRequest, timestamp: Long, frameNumber: Long) {
super.onCaptureStarted(session, request, timestamp, frameNumber)
// Play the shutter click sound.
cameraHandler?.post { mediaActionSound.play(MediaActionSound.SHUTTER_CLICK) }
}
@MainThread
override fun onCaptureCompleted(session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult) {
super.onCaptureCompleted(session, request, result)
captureResults.put(result)
}
}
2.8 拍照并保存图片
经过一连串的配置之后,我们终于可以开拍照了,直接调用 CameraCaptureSession.capture()
方法把 CaptureRequest 对象提交给相机就可以等待相机输出图片了,该方法要求我们设置三个参数:
- request:本次 Capture 操作使用的 CaptureRequest 对象。
- listener:监听 Capture 状态的回调接口。
- handler:回调 Capture 状态监听接口的 Handler 对象。
captureSession.capture(captureImageRequest, CaptureImageStateCallback(), mainHandler)
如果一切顺利,相机在拍照完成的时候会通过 CaptureStateCallback.onCaptureCompleted()
回调一个 CaptureResult 对象给我们,里面包含了本次拍照的所有信息,另外还会通过 OnImageAvailableListener.onImageAvailable()
回调一个代表图像数据的 Image 对象给我们。在我们的 Demo 中,我们将获取到的 CaptureResult 对象保存到一个阻塞队列中,在 OnImageAvailableListener.onImageAvailable()
回调的时候就从这个阻塞队列获取 CaptureResult 对象,结合 Image 对象对图片进行保存操作,并且还会在图片保存完毕的时候获取图片的缩略图用于刷新 UI,代码如下所示:
private inner class OnJpegImageAvailableListener : ImageReader.OnImageAvailableListener {
private val dateFormat: DateFormat = SimpleDateFormat("yyyyMMddHHmmssSSS", Locale.getDefault())
private val cameraDir: String = "${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)}/Camera"
@WorkerThread
override fun onImageAvailable(imageReader: ImageReader) {
val image = imageReader.acquireNextImage()
val captureResult = captureResults.take()
if (image != null && captureResult != null) {
image.use {
val jpegByteBuffer = it.planes[0].buffer// Jpeg image data only occupy the planes[0].
val jpegByteArray = ByteArray(jpegByteBuffer.remaining())
jpegByteBuffer.get(jpegByteArray)
val width = it.width
val height = it.height
saveImageExecutor.execute {
val date = System.currentTimeMillis()
val title = "IMG_${dateFormat.format(date)}"// e.g. IMG_20190211100833786
val displayName = "$title.jpeg"// e.g. IMG_20190211100833786.jpeg
val path = "$cameraDir/$displayName"// e.g. /sdcard/DCIM/Camera/IMG_20190211100833786.jpeg
val orientation = captureResult[CaptureResult.JPEG_ORIENTATION]
val location = captureResult[CaptureResult.JPEG_GPS_LOCATION]
val longitude = location?.longitude ?: 0.0
val latitude = location?.latitude ?: 0.0
// Write the jpeg data into the specified file.
File(path).writeBytes(jpegByteArray)
// Insert the image information into the media store.
val values = ContentValues()
values.put(MediaStore.Images.ImageColumns.TITLE, title)
values.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, displayName)
values.put(MediaStore.Images.ImageColumns.DATA, path)
values.put(MediaStore.Images.ImageColumns.DATE_TAKEN, date)
values.put(MediaStore.Images.ImageColumns.WIDTH, width)
values.put(MediaStore.Images.ImageColumns.HEIGHT, height)
values.put(MediaStore.Images.ImageColumns.ORIENTATION, orientation)
values.put(MediaStore.Images.ImageColumns.LONGITUDE, longitude)
values.put(MediaStore.Images.ImageColumns.LATITUDE, latitude)
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
// Refresh the thumbnail of image.
val thumbnail = getThumbnail(path)
if (thumbnail != null) {
runOnUiThread {
thumbnailView.setImageBitmap(thumbnail)
thumbnailView.scaleX = 0.8F
thumbnailView.scaleY = 0.8F
thumbnailView.animate().setDuration(50).scaleX(1.0F).scaleY(1.0F).start()
}
}
}
}
}
}
}
2.9 前置摄像头拍照的镜像问题
如果你使用前置摄像头进行拍照,虽然照片的方向已经被我们矫正了,但是你会发现画面却是相反的,例如你在预览的时候人脸在左边,拍出来的照片人脸却是在右边。出现这个问题的原因是默认情况下相机不会对 JPEG 图像进行镜像操作,导致输出的原始画面是非镜像的。解决这个问题的一个办法是拿到 JPEG 数据之后再次对图像进行镜像操作,然后才保存图片。
3 如何连续拍摄多张图片
在我们的 Demo 中有一个特殊的拍照功能,就是当用户双击快门按钮的时候会连续拍摄 10 张照片,其实现原理就是采用了多次模式的 Capture,所有的配置流程和拍摄单张照片一样,唯一的区别是我们使用 CameraCaptureSession.captureBurst()
进行拍照,该方法要求我们传递一下三个参数:
- requests:按顺序连续执行的 CaptureRequest 对象列表,每一个 CaptureRequest 对象都可以有自己的配置,在我们的 Demo 里出于简化的目的,10 个 CaptureRequest 对象实际上的都是同一个。
- listener:监听 Capture 状态的回调接口,需要注意的是有多少个 CaptureRequest 对象就会回调该接口多少次。
- handler:回调 Capture 状态监听接口的 Handler 对象。
val captureImageRequest = captureImageRequestBuilder.build()
val captureImageRequests = mutableListOf<CaptureRequest>()
for (i in 1..burstNumber) {
captureImageRequests.add(captureImageRequest)
}
captureSession.captureBurst(captureImageRequests, CaptureImageStateCallback(), mainHandler)
接下来所有的流程就和拍摄单招照片一样了,每输出一张图片我们就将其保存到 SD 卡并且刷新媒体库和缩略图。
4 如何连拍
连拍这个功能在 Camera2 出现之前是不可能实现的,现在我们只需要使用重复模式的 Capture 就可以轻松实现连拍功能。在《Camera2 预览》里我们使用了重复模式的 Capture 来实现预览功能,而这一次我们不仅要用该模式进行预览,还要在预览的同时也输出照片,所以我们会使用 CameraCaptureSession.setRepeatingRequest()
方法开始进行连拍:
val captureImageRequest = captureImageRequestBuilder.build()
captureSession.setRepeatingRequest(captureImageRequest, CaptureImageStateCallback(), mainHandler)
停止连拍有以下两种方式:
- 调用
CameraCaptueSession.stopRepeating()
方法停止重复模式的 Capture,但是这会导致预览也停止。 - 调用
CameraCaptueSession.setRepeatingRequest()
方法并且使用预览的 CaptureRequest 对象,停止输出照片。
在我们的 Demo 里使用了第二种方式:
@MainThread
private fun stopCaptureImageContinuously() {
// Restart preview to stop the continuous image capture.
startPreview()
}
5 如何切换前后置摄像头
切换前后置摄像头是一个很常见的功能,虽然和本章的主要内容不相关,但是在 Demo 中已经实现,所以这里也顺便提一下。我们只要按照以下顺序进行操作就可以轻松实现前后置摄像头的切换:
- 关闭当前摄像头
- 开启新的摄像头
- 创建新的 Session
- 开启预览
下面是代码片段,详细代码大家可以自行查看 Demo 源码:
@MainThread
private fun switchCamera() {
val cameraDevice = cameraDeviceFuture?.get()
val oldCameraId = cameraDevice?.id
val newCameraId = if (oldCameraId == frontCameraId) backCameraId else frontCameraId
if (newCameraId != null) {
closeCamera()
openCamera(newCameraId)
createCaptureRequestBuilders()
setPreviewSize(MAX_PREVIEW_WIDTH, MAX_PREVIEW_HEIGHT)
setImageSize(MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT)
createSession()
startPreview()
}
}
6 总结
本章主要讲述了如何实现几种常见的拍照模式,其核心要领就是理解【重复模式】、【单词模式】和【多次模式】的工作流程,根据实际业务情况灵活运用,下面是几个小建议:
- 重复模式和多次模式都可以实现连拍功能,其中重复模式适合没有连拍上限的情况,而多次模式适合有连拍上限的情况。
- 一个 CaptureRequest 可以添加多个 Surface,这就意味着你可以同时拍摄多张照片。
- 拍照获取 CaptureResult 和 Image 对象走的是两个不同的回调接口,灵活运用子线程的阻塞操作可以简化你的代码逻辑。
36人点赞