在安卓中实现读取Exif获取照片拍摄日期后以水印文字形式添加到照片上

前言

正如我在某社交平台上填写的简介:

标准理工科直男一枚,喜欢骑行,最近在研究摄影

摄影是我最近在研究的新技能点。

不久之前,我在尝试拍摄延时摄影时发现一个问题,使用相机拍摄时都无法在机内添加水印。(废话,哪个摄影会给照片打水印啊喂)

而我想将拍摄的延时照片每一张都加上拍摄时间的水印,以此增加合成的视频趣味性。(技术不够,花活来凑)

不过好在相机的图片文件 Exif 信息够丰富,其中就包括了拍摄时间。

但是,如果使用后期软件手动添加的话还得一张一张的加,显然不符合我们的需求。

所以,作为一个程序员,当然是想到写一个程序来实现了。

实现

思路

按道理来说,对于这种任务一般都是直接写一个 python 脚本就可以了,但是我的预期功能远不止按 Exif 信息加水印这一个,所以我最终还是决定写一个 APP。

但是,使用安卓手机处理大量的专业相机导出的延时照片显然是不太方便的,所以我的原计划是使用 Compose-jb 编写跨平台程序。

然而,搜索了一圈没有找到合适的跨平台图像处理库,自己写轮子是不可能的。

不得已,只能暂时只写安卓端了。

对了,本文只介绍如何在安卓端读取图片的 Exif 信息以及给图片添加文字水印。

Exif 简介

Exif,全称 Exchangeable image file format 是一种文件格式规范标准。

用于给相机、手机、扫描仪等设备处理图像后,为图像添加特定元数据信息。

Exif最初由日本电子工业发展协会在1996年制定,版本为1.0。 1998年,升级到2.1版,增加了对音频文件的支持。2002年3月,发表了2.2版。

一般来说 Exif 会包含以下信息:

  • 相机信息:包括相机型号,拍摄时的光圈、快门速度、感光度(ISO)、焦距等信息
  • 图像信息:像素尺寸、分辨率、色彩配置等
  • 日期和时间信息
  • 地理位置信息
  • 缩略图
  • 说明信息
  • 版权信息

另外由于 EXif 数据头为 0xFFFF ,后两个字节表示的是 Exif 信息的总长度,所以 Exif 最多只能存储 64 KB 的信息。

我们需要关心的是有关时间的数据。

Exif中对时间的定义有以下几个字段:

  • DateTime 创建图像时的时间日期,一般用于表示该文件最后被修改时的日期,格式 YYYY:MM:DD HH:MM:SS
  • DateTimeOriginal 生成原始图像数据时的时间日期,一般用于表示该图像首次创建的日期,格式 YYYY:MM:DD HH:MM:SS
  • DateTimeDigitized 图像储存时的时间日期,如果相机在捕获图像的同时将其储存,则该值与 DateTimeOriginal 一致,格式 YYYY:MM:DD HH:MM:SS

上面三个标签的最小时间单位都是秒,不过还有三个标签用于标记上述三个标签的亚秒级数据:

  • SubsecTime
  • SubsecTimeOriginal
  • SubsecTimeDigitized

例如,如果 DateTime2022:10:12 16:30:00SubsecTime123 则实际时间为 2022:10:12 16:30:00.123

需要注意的是,亚秒级标签的数据长度不是固定的,所以说具体时间精度将由厂商自己确定。

另外,还有三个标签:

  • OffsetTime
  • OffsetTimeOriginal
  • OffsetTimeDigitized

分别表示上述三个标签相对于UTC(世界协调时)、夏令时的偏移量,其长度为 7 字节(包括终止符),表示偏移的时数和分钟数,例如 +01:00-01:00

需要注意的是,这三个偏移量标签是在 Exif 版本 2.31 中才被加入。

另外还需要注意的是, Exif 标准并没有明确每个字段对应的具体应该是拍摄时的哪个时间点。

举个例子,如果某张照片使用的是长曝光拍摄(例如曝光15s),那么这些标签表示的应该是开始曝光的时间还是曝光结束亦或是曝光过程中的任意时间点?

一张典型的图像的 Exif 信息如下(使用 Windows 自带文件属性查看):

s1

在安卓中读取 Exif 信息

Google 在 AndroidX 中提供了一个 exifinterface 库,如果我们想要读取 Exif 信息的话可以直接使用这个库。

添加依赖:implementation "androidx.exifinterface:exifinterface:1.3.4"

初始化 exifinterfaceval exifInterface = ExifInterface()

ExifInterface 可以接受四种类型的图像数据传入:

  • File
  • String
  • InputStream
  • FileDescriptor

其中,类型为 String 的构造方法接收的是文件名(路径)。

由于安卓储存权限的收紧,我们不太好直接传入 File 或 String,但是我们可以传入使用 SAF(Storage Access Framework, 储存访问框架) 拿到的 Uri 生成的 InputStream:

var input: InputStream? = null
try {
    input = contentResolver.openInputStream(uri) ?: return dateTime
    val exifInterface = ExifInterface(input)
    
    // ……

} catch (tr: Throwable) {
    
} finally {
    try {
        input?.close()
    } catch (tr: Throwable) {
        
    }
}

创建完成 ExifInterface 后,我们可以通过 exifInterface.getAttribute(tag) 读取特定的标签数据。

而这些标签已经在 ExifInterface 中定义了:

s2

例如我们要获取 DateTime 数据的话就可以使用:

dateTime = exifInterface.getAttribute(ExifInterface.TAG_DATETIME)

其实 ExifInterface 中有一个 getDateTime() 方法用于返回计算好的标准时间戳,但是它被标记了仅供同一个库的代码调用,也就是说我们没法调用:

s3

但是我们可以查看它的源码,看看它都做了什么:

private static Long parseDateTime(@Nullable String dateTimeString, @Nullable String subSecs,
        @Nullable String offsetString) {
    if (dateTimeString == null || !NON_ZERO_TIME_PATTERN.matcher(dateTimeString).matches()) {
        return null;
    }

    ParsePosition pos = new ParsePosition(0);
    try {
        // The exif field is in local time. Parsing it as if it is UTC will yield time
        // since 1/1/1970 local time
        Date dateTime = sFormatterPrimary.parse(dateTimeString, pos);
        if (dateTime == null) {
            dateTime = sFormatterSecondary.parse(dateTimeString, pos);
            if (dateTime == null) {
                return null;
            }
        }
        long msecs = dateTime.getTime();
        if (offsetString != null) {
            String sign = offsetString.substring(0, 1);
            int hour = Integer.parseInt(offsetString.substring(1, 3));
            int min = Integer.parseInt(offsetString.substring(4, 6));
            if (("+".equals(sign) || "-".equals(sign))
                    && ":".equals(offsetString.substring(3, 4))
                    && hour <= 14 /* max UTC hour value */) {
                msecs += (hour * 60 + min) * 60 * 1000 * ("-".equals(sign) ? 1 : -1);
            }
        }

        if (subSecs != null) {
            msecs += parseSubSeconds(subSecs);
        }
        return msecs;
    } catch (IllegalArgumentException e) {
        return null;
    }
}

简单说就是接收传入 DateTimeSubsecTimeOffsetTime ,然后解析 DateTime 文本后,处理偏移量,最后加上亚秒级数据得到最终的 Long 类型的标准时间戳。

不过我觉得我们的这个程序不需要这么精细,直接使用 DateTime 的数据就够了,不过后面可以把这个方法 copy 过来,还是解析一下,避免有些设备记录的拍摄时间时区不对。

compose 使用 SAF 选择文件

了解了怎么在安卓中读取 EXif 以及我们需要的是什么 EXif 信息后,我们来写一个 demo 试试吧!

不过在这之前我们需要先解决如何在 Compose 中选择文件。

一般来说,我们想要使用 SAF 选择文件都会用到 startActicityForResult 。但是,显然的,在 Compose 中使用这个 API 非常不方便,因为这个 API 的回调方法都在 Activity 中,而非 Compose 中。

而且,早在几年前,startActicityForResult 就被标记为了 Deprecated 弃用了,取而代之的是 AndroidX 中的 Activity Result API ,幸运的是,Compose 提供了原生的 Activity Result API 支持:rememberLauncherForActivityResult

我们只需要使用 rememberLauncherForActivityResult 创建一个 Launcher 后,在需要的地方调用即可:

// 定义 Launcher
val choosePictureLauncher = rememberLauncherForActivityResult(
    ActivityResultContracts.GetContent()
) { uri: Uri? ->
    if (uri != null) {
        // 已选择图片,在这里做选择后的处理
        // ……
    }
}

// ……

// 在需要的地方调用这个 Launcher (例如点击按钮回调)
choosePictureLauncher.launch("image/*")

rememberLauncherForActivityResult 接收两个参数:

contract 表示需要获取的数据类型,API 预设了几个常用的 contract,例如:

s4

当然,你也可以完全自定义自己的 contract。

还有一个参数 onResult 表示数据回调,在返回数据后将调用这个回调。

关于 Activity Result API 更多信息请自行查阅 再见!onActivityResult!你好,Activity Results API!

写一个demo测试获取 Exif

现在,所有需要的准备工作都完成了,让我们开始写一个简单的 demo 试试吧!

@Composable
fun MainScreen() {
    val contentResolver = LocalContext.current.contentResolver
    var imageUri: Uri? by remember { mutableStateOf(null) }
    var dateTime: String by remember { mutableStateOf("") }

    val choosePictureLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.GetContent()
    ) { uri: Uri? ->
        if (uri != null) {
            dateTime = readDateTimeFromExif(uri, contentResolver).toString()
            imageUri = uri
        }
    }

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Image(
            painter = rememberAsyncImagePainter(model = imageUri),
            contentDescription = null,
            modifier = Modifier.size(500.dp),
            contentScale = ContentScale.Inside
        )

        Text(text = dateTime)

        Button(onClick = {
            choosePictureLauncher.launch("image/*")
        }) {
            Text(text = "点击选择图片")
        }
    }
}

fun readDateTimeFromExif(uri: Uri, contentResolver: ContentResolver): String? {
    var dateTime: String? = null
    var input: InputStream? = null
    try {
        input = contentResolver.openInputStream(uri) ?: return dateTime
        val exifInterface = ExifInterface(input)

        dateTime = exifInterface.getAttribute(ExifInterface.TAG_DATETIME)
    } catch (tr: Throwable) {
        Log.e(TAG, "readExif: ", tr)
    } finally {
        try {
            input?.close()
        } catch (tr: Throwable) {
            Log.e(TAG, "readExif: ", tr)
        }
    }

    return dateTime
}

运行效果如下:

s5

对了,在上面的界面中显示图片用到了 Coil 库。

给图片添加文字水印

在上面我们已经完成了获取图片的 Exif 信息,那么下一步应该是给图片加上 Exif 读取到的日期时间文字。

这个效果要怎么实现呢?

其实说起来也非常简单,我们只需要将 Bitmap 生成 Canvas ,然后在 Canvas 中 drawText 即可。

不过需要注意的是,这里的 Canvas 是安卓原生的 Canvas ,不是 Compose 中的 Canvas 哦。其实也可以使用 Compose 的 Canvas 来添加文字,但是,Compose 的 Canvas 没有提供绘制文字的方法,最终也还是需要在原生 Canvas 中绘制。

import android.graphics.Canvas

// ……

val canvas = Canvas(bitmap)
val paint = Paint().apply {
    color = Color.LTGRAY
    textSize = 100f
}

canvas.drawText(
    "你好,方程",
    50f,
    canvas.height.toFloat() - 50f,
    paint
)

drawText 的第一个参数是需要绘制的文本信息;

第二个参数是绘制文字的 X 轴坐标;

第三个参数是绘制文字的 Y 轴坐标,上面填写的是距离画布底部 50 个像素;

最后一个参数是画笔,我们对绘制内容的配置都写在了画笔中。

可以看到,使用 Canvas 绘制文字十分简单,那么问题来了,怎么把 Uri 资源指向的图片转成 Bitmap?

观察 Bitmap 方法,会发现其中有一个 BitmapFactory.decodeStream() 方法,它接收一个输入流(InputStream)来生成 Bitmap。

输入流?那上面我们实例化 ExifInterface 不就是使用的输入流实例化的吗?所以我们直接改一下就可以了。

var resultBitmap: Bitmap? = null

var input: InputStream? = null
try {
    input = contentResolver.openInputStream(uri) ?: return 

    resultBitmap = BitmapFactory.decodeStream(input)

    // ……
    
} catch (tr: Throwable) {
    Log.e(TAG, "readExif: ", tr)
} finally {
    try {
        input?.close()
    } catch (tr: Throwable) {
        Log.e(TAG, "readExif: ", tr)
    }
}

注意我们这里生成 bitmap 的方法: resultBitmap = BitmapFactory.decodeStream(input) ,如果直接将这个 bitmap 传给 Canvas 然后添加文字将会报错:

java.lang.IllegalStateException: Immutable bitmap passed to Canvas constructor
    at android.graphics.Canvas.<init>(Canvas.java:117)
    ……

从错误信息中也能很清楚的看到出错原因:生成的这个 Bitmap 是不可变的,而构造 Canvas 需要的是可变 Bitmap,所以我们应该这样写:

resultBitmap = BitmapFactory.decodeStream(input).copy(Bitmap.Config.ARGB_8888, true)

复制从输入流中生成的 Bitmap, 并将 isMutable(copy 的第二个参数) 设置为 true。

对了,copy 的第一个参数是颜色配置信息,一般使用 Bitmap.Config.ARGB_8888 即可。

再来一个小demo看看

现在,我们已经知道如何给图像添加水印文字,赶紧再写一个 demo 试试看吧!

@Composable
fun MainScreen() {
    val contentResolver = LocalContext.current.contentResolver
    var bitmap: Bitmap? by remember { mutableStateOf(null) }
    // var dateTime: String by remember { mutableStateOf("") }

    val choosePictureLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.GetContent()
    ) { uri: Uri? ->
        if (uri != null) {
            val dateTime = readDateTimeFromExif(uri, contentResolver).toString()
            bitmap = addDateTime(uri, contentResolver, dateTime)
        }
    }

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Image(
            painter = rememberAsyncImagePainter(model = bitmap),
            contentDescription = null,
            modifier = Modifier.size(500.dp),
            contentScale = ContentScale.Inside
        )

        // Text(text = dateTime)

        Button(onClick = {
            choosePictureLauncher.launch("image/*")
        }) {
            Text(text = "点击选择图片")
        }
    }
}

fun readDateTimeFromExif(uri: Uri, contentResolver: ContentResolver): String? {
    var dateTime: String? = null
    var input: InputStream? = null
    try {
        input = contentResolver.openInputStream(uri) ?: return dateTime
        val exifInterface = ExifInterface(input)

        dateTime = exifInterface.getAttribute(ExifInterface.TAG_DATETIME)
    } catch (tr: Throwable) {
        Log.e(TAG, "readExif: ", tr)
    } finally {
        try {
            input?.close()
        } catch (tr: Throwable) {
            Log.e(TAG, "readExif: ", tr)
        }
    }

    return dateTime
}

fun addDateTime(uri: Uri, contentResolver: ContentResolver, dateTime: String): Bitmap? {
    var resultBitmap: Bitmap? = null

    var input: InputStream? = null
    try {
        input = contentResolver.openInputStream(uri) ?: return resultBitmap

        resultBitmap = BitmapFactory.decodeStream(input).copy(Bitmap.Config.ARGB_8888, true)

        val canvas = Canvas(resultBitmap)
        val paint = Paint().apply {
            color = Color.LTGRAY
            textSize = 100f
        }

        canvas.drawText(
            dateTime,
            50f,
            canvas.height.toFloat() - 50f,
            paint
        )

    } catch (tr: Throwable) {
        Log.e(TAG, "readExif: ", tr)
    } finally {
        try {
            input?.close()
        } catch (tr: Throwable) {
            Log.e(TAG, "readExif: ", tr)
        }
    }

    return resultBitmap
}

上面的 demo 运行效果如下:

s6

可以看到,从 EXif 中读取到的时间已经被添加到了图片左下角。

将 Bitmap 保存至文件

上面已经完成了读取 Exif 信息以及将 Exif 信息写入图片文字水印,下一步,当然是导出这个图片了。

/**
 * 
 * @author equationl
 * 
 * 将 bitmap 保存为图片文件(开启压缩保存为jpg,否则为png)
 * 
 * @param bitmap bitmap
 * @param fileName 保存文件名(不含扩展名)
 * @param savePath 保存路径
 * @param isReduce 是否压缩
 * @param quality 图片质量
 *
 * @return File 返回保存的文件
 */
@Throws(Exception::class)
fun saveBitmap2File(
    bitmap: Bitmap,
    fileName: String,
    savePath: File?,
    isReduce: Boolean,
    quality: Int
): File {
    val f: File
    val imgFormat: Bitmap.CompressFormat
    if (isReduce) {
        f = File(savePath, "$fileName.jpg")
        imgFormat = Bitmap.CompressFormat.JPEG
    } else {
        f = File(savePath, "$fileName.png")
        imgFormat = Bitmap.CompressFormat.PNG
    }
    if (!f.createNewFile()) {
        Log.w(TAG, "file " + f + "has already exist")
    }

    val outputStream = FileOutputStream(f)
    // 将bitmap写入输出流
    if (!bitmap.compress(imgFormat, quality, outputStream)) {
        Log.e(TAG, "saveBitmap2File: write bitmap to file fail!")
        if (isReduce) {
            throw CompressToJpegException("Export picture to jpg fail, Try not using compress picture or reduce picture size!")
        }
        else {
            throw Exception("saveBitmap2File: write bitmap to file fail!")
        }
    }

    try {
        outputStream.flush()
        outputStream.close()
    } catch (e: Exception) {
        Log.e(TAG, "saveBitmap2File: ", e)
    }
    return f
}

代码很简单,核心就是调用 bitmap 的 compress 方法,将 bitmap 写入输出流。

总结

现在这篇文章的篇幅已经不少了,就先说到这里吧。

后面还没有说到的功能还剩下如何批量导入\导出和处理图片,另外,根据我的设想,将所有图片复制到手机上来处理显然是不合理的,所以应该改为监听外部设备插入(U盘、读卡器等)后,自动读取其中指定图片并进行处理。

还有,正如我所说的,如果仅仅是添加日期水印显然不适合于专门做一个 APP,我的想法是添加水印只是其中一个小功能,完整的功能应该是支持直接将照片合成视频,并应该支持进行简单的编辑。

不过这些都是后话了。

最后修改于: 2023-05-21 21:49:09; 备注: 增加系列、修改系列头图和简介、增加轮博图 (abf6a9c)