智能眼镜开发:图片翻译与EXIF的重要性

79 阅读17分钟

Android 图片翻译中的 EXIF:从 CameraX、Glide、ImageView 到服务端 OCR

最近有一个需求是竖屏拍照并翻译(见下图),开发完才发现细节很多,特有此文做记录并方便回顾。

Call Translation - 语音.png Call Translation - 语音2.png

图片翻译看似是“拍照、上传、OCR、返回译图”四步,但只要页面锁定竖屏、用户横着拿手机拍照,方向问题就会贯穿整条链路:

手机物理方向
    ↓
CameraX 输出像素与 EXIF
    ↓
上传前压缩是否保留 EXIF
    ↓
服务端是否按 EXIF 旋正后 OCR
    ↓
Glide 是否按 EXIF 解码
    ↓
ImageView 是否还需要额外旋转

任何一层把“像素方向”和“显示方向”混为一谈,都可能出现预览正常、OCR 横着识别,或者服务端结果正常、客户端却再次旋转的问题。

本文先介绍 EXIF,再完整说明这几层之间的关系,以及强制竖屏页面如何根据手机物理方向生成正确的照片,并在同一个 ImageView 中适配四种持机方向。

一、什么是 EXIF

EXIF(Exchangeable Image File Format)是图片文件中的一组元数据,常见于 JPEG,也可以出现在部分其他图片容器中。

EXIF 可以记录:

  • 拍摄时间;
  • 相机品牌与型号;
  • 曝光、焦距、ISO;
  • GPS 位置;
  • 缩略图;
  • 图片方向 Orientation

图片方向问题主要关注 Orientation。它描述的不是“把文件里的像素真的旋转了多少度”,而是告诉解码器:应该怎样变换原始像素矩阵,才能得到人眼看到的正向图片。

例如,一张 JPEG 的原始像素尺寸可能是 4032 × 3024,但 EXIF Orientation 为 6。支持 EXIF 的查看器会顺时针旋转 90° 后显示,用户看到的视觉尺寸就相当于 3024 × 4032

因此,一张图同时存在两个方向概念:

Stored pixels     = 文件中实际保存的像素排列
Visual image      = 对像素应用 EXIF Orientation 后看到的图片

Orientation 的八种取值

EXIF Orientation 不只有 0°、90°、180°、270°,还包含镜像情况:

Android 常量显示变换
1ORIENTATION_NORMAL不变换
2ORIENTATION_FLIP_HORIZONTAL水平镜像
3ORIENTATION_ROTATE_180旋转 180°
4ORIENTATION_FLIP_VERTICAL垂直镜像
5ORIENTATION_TRANSPOSE转置
6ORIENTATION_ROTATE_90顺时针旋转 90°
7ORIENTATION_TRANSVERSE横向转置
8ORIENTATION_ROTATE_270顺时针旋转 270°

读取时可以把缺失、非法值降级为正常方向:

private fun readExifOrientation(file: File): Int {
    val orientation = runCatching {
        ExifInterface(file.absolutePath).getAttributeInt(
            ExifInterface.TAG_ORIENTATION,
            ExifInterface.ORIENTATION_NORMAL
        )
    }.getOrDefault(ExifInterface.ORIENTATION_NORMAL)

    return if (
        orientation in ExifInterface.ORIENTATION_NORMAL..
            ExifInterface.ORIENTATION_ROTATE_270
    ) {
        orientation
    } else {
        ExifInterface.ORIENTATION_NORMAL
    }
}

二、文件 EXIF、Glide 与 ImageView 是什么关系

理解图片方向时,可以把客户端拆成三层:

图片文件
  ├─ 原始像素矩阵
  └─ EXIF Orientation
          ↓
Glide 解码层
  └─ 读取 EXIF,对像素应用方向变换,得到正向 Drawable
          ↓
ImageView 展示层
  ├─ scaleType 决定 Drawable 如何放入 View
  └─ rotation 决定整个 View 如何在页面坐标系中旋转

Glide 会处理 EXIF,ImageView 不会

使用 Glide 直接加载 JPEG 文件时:

Glide.with(imageView)
    .load(imageFile)
    .into(imageView)

Glide 会在解码阶段读取图片方向,并把交给 ImageView 的资源调整为正确视觉方向。

ImageView 自己不认识 EXIF。它只接收 Glide 解码完成的 Drawable,然后根据自己的宽高、scaleType 和变换属性绘制。

因此下面三个动作完全不同:

修改 EXIF Orientation  → 修改文件元数据,不立即改变 View
Glide 按 EXIF 解码      → 改变交给 ImageView 的视觉像素方向
imageView.rotation      → 旋转页面上的 View,不修改文件和 EXIF

如果 Glide 已经按 EXIF 把图片旋正,再根据同一个 EXIF 值手动旋转 ImageView,就会发生二次旋转。

Bitmap 是一个重要边界

EXIF 属于文件,不属于 Bitmap。一旦使用 BitmapFactory 把 JPEG 解码成 Bitmap,得到的只是像素数据,原 EXIF 不会附着在 Bitmap 对象上。

随后执行:

bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)

会生成新的 JPEG,但不会自动复制原图 EXIF。若后续 OCR 依赖 Orientation,就必须显式写回。

三、服务端 OCR 为什么也必须理解 EXIF

OCR 真正处理的是像素。服务端收到图片后,如果直接把 JPEG 的原始像素交给 OCR,而没有应用 EXIF Orientation,文字可能会横着或倒着进入识别模型。

正确的数据契约是:

客户端上传:原始像素轴 + 与像素匹配的 EXIF Orientation
                         ↓
服务端解码:读取并应用 Orientation,得到视觉正向图片
                         ↓
OCR:在正向图片上检测文字、识别和翻译
                         ↓
译图:保持正确的像素与 EXIF 关系

方向处理只有两种自洽方式:

  1. 不旋转原始像素,保留原 Orientation;
  2. 物理旋正像素,同时把 Orientation 改为 ORIENTATION_NORMAL 或移除。

不能物理旋正像素后仍保留原 Orientation,否则服务端或 Glide 会再次旋转。也不能保持原始像素轴却丢掉 Orientation,否则 OCR 会看到错误方向。

四、上传前压缩如何保留 OCR 方向

图片翻译通常有上传体积限制。实现中采用以下规则:

  • 文件没有超过限制时,直接上传原文件,像素和完整 EXIF 都保持不变;
  • 文件超过限制时,按 JPEG 质量优先压缩;
  • 重编码后的文件只写回 OCR 需要的 Orientation;
  • 当前尺寸最低质量仍超限时,再逐级等比缩小。

核心原则是:压缩过程中保持原始像素轴,不提前按 EXIF 旋转 Bitmap,然后把原 Orientation 写回新文件。

suspend fun prepareImageForUpload(
    context: Context,
    sourceFile: File
): File = withContext(Dispatchers.IO) {
    require(sourceFile.exists() && sourceFile.length() > 0L)

    if (sourceFile.length() <= MAX_UPLOAD_SIZE) {
        return@withContext sourceFile
    }

    val orientation = readExifOrientation(sourceFile)
    compressWithoutChangingPixelAxis(
        context = context,
        sourceFile = sourceFile,
        orientation = orientation
    )
}

BitmapFactory.decodeFile() 在这条链路中负责解码存储像素,不主动根据 EXIF 旋正。Bitmap 重新编码为 JPEG 后元数据会丢失,因此需要把 Orientation 写回:

private fun writeJpeg(
    targetFile: File,
    bitmap: Bitmap,
    quality: Int,
    orientation: Int
) {
    FileOutputStream(targetFile).use { outputStream ->
        check(
            bitmap.compress(
                Bitmap.CompressFormat.JPEG,
                quality,
                outputStream
            )
        )
    }

    ExifInterface(targetFile.absolutePath).apply {
        setAttribute(
            ExifInterface.TAG_ORIENTATION,
            orientation.toString()
        )
        saveAttributes()
    }
}

写入 EXIF 后要再次检查文件体积,因为 saveAttributes() 也会改变最终文件:

writeJpeg(targetFile, bitmap, quality, orientation)

if (targetFile.length() in 1..MAX_UPLOAD_SIZE) {
    return targetFile
}

targetFile.delete()

这里刻意只复制 Orientation,不把 GPS、设备型号等无关元数据写入压缩产物。没有经过重编码的小图则保持原文件字节,因此也会保留原文件已有的其他 EXIF 信息。

这意味着“小图原样上传”也可能同时上传 GPS、设备型号和拍摄时间。采用这种策略时,应确保服务端数据处理和隐私声明覆盖这些元数据;如果业务只需要 OCR 方向,真正不可缺少的字段只有 Orientation。

一个容易判断方向是否正确的方法

假设原 JPEG 的存储尺寸是 640 × 480,Orientation 是 6

  • 压缩后仍是 640 × 480,Orientation 仍是 6:像素轴与 EXIF 保持一致;
  • 压缩后变成 480 × 640,Orientation 仍是 6:很可能已经旋转像素又保留方向,会二次旋转;
  • 压缩后仍是 640 × 480,Orientation 丢失:OCR 很可能横着识别。

五、强制竖屏为什么拿不到正确的拍摄方向

图片翻译页常因交互布局固定为竖屏:

<activity
    android:name=".ImageTranslateActivity"
    android:screenOrientation="portrait" />

不是读取 EXIF 失败,而是写入 EXIF 的方向依据失真

强制竖屏不会妨碍 ExifInterface 读取一张已有图片的 Orientation,也不会修改相册图片原本的 EXIF。

问题发生在拍摄新照片时。CameraX 保存 JPEG 前,需要根据相机传感器方向和 ImageCapture.targetRotation 计算输出方向,再决定怎样组织像素或写入旋转元数据:

相机 Sensor orientation + ImageCapture.targetRotation
                         ↓
             JPEG 像素与 EXIF Orientation

正常的可旋转页面中,用户横竖切换手机会带动 Display rotation 变化,应用可以把新的 rotation 传给 CameraX。但页面被锁定为竖屏后,Android 需要维持竖屏窗口,下面这些值通常只描述“当前竖屏窗口”,不再描述手机真实的物理姿态:

信息来源强制竖屏后的表现
resources.configuration.orientation始终是 ORIENTATION_PORTRAIT
windowManager.defaultDisplay.rotation通常保持 Surface.ROTATION_0
previewView.display.rotation跟随锁定后的显示 Surface,通常也保持 0°
onConfigurationChanged()横持手机时不会得到一次正常的横屏布局切换

因此,下面这类常见写法在强制竖屏页面里只能得到窗口方向:

imageCapture.targetRotation =
    previewView.display?.rotation ?: Surface.ROTATION_0

假设用户把手机逆时针横持 90°,页面和 previewView.display.rotation 仍然是 ROTATION_0。CameraX 收到的 targetRotation 没有变化,就会继续按竖持状态计算 JPEG 方向。最终文件的像素可能是横着的,但 EXIF 却无法表达用户这次真实的横持姿态。Glide 和服务端 OCR 即使完全按照 EXIF 工作,也只能忠实地应用这份错误或过期的方向信息。

所以更准确的说法不是“强制竖屏读不到正确 EXIF”,而是:

强制竖屏切断了物理持机方向到 Display rotation 的传递;
CameraX 使用固定的 targetRotation,因而无法生成与真实拍摄姿态一致的方向信息。

EXIF 不能反过来告诉 CameraX 用户怎样拿手机

EXIF 是拍照输出的一部分,只有文件保存后才能读取。它不是一个实时方向传感器,不能在按快门前用来决定本次照片应该写什么方向。

如果拍完后才发现 Orientation 不正确,再凭 JPEG 宽高猜测用户横持方向也不可靠:后置相机传感器可能天然是横向安装,CameraX 可能旋转像素,也可能使用元数据表达方向,而且 4032 × 3024 无法区分用户是向左横持还是向右横持。

因此,拍摄前必须从独立于 Display 的传感器通道获取物理方向。

Android 的 OrientationEventListener 封装了底层方向传感器计算,可以根据重力方向返回 0..359 的设备角度。业务层不需要直接处理加速度计的三轴值,只要把角度归入四个稳定象限。

六、把物理方向映射为 CameraX targetRotation

OrientationEventListener 的角度方向与 CameraX 使用的 Surface rotation 映射不是简单同值。四个稳定方向使用下面的映射:

fun resolveTargetRotation(
    orientationDegrees: Int,
    fallbackRotation: Int
): Int {
    if (orientationDegrees !in 0..359) {
        return fallbackRotation
    }

    return when (orientationDegrees) {
        in 45..134 -> Surface.ROTATION_270
        in 135..224 -> Surface.ROTATION_180
        in 225..314 -> Surface.ROTATION_90
        else -> Surface.ROTATION_0
    }
}

对应关系如下:

方向监听角度CameraX targetRotation
315°..359°、0°..44°Surface.ROTATION_0
45°..134°Surface.ROTATION_270
135°..224°Surface.ROTATION_180
225°..314°Surface.ROTATION_90

在象限边界附近,角度可能抖动。这里按最新稳定象限更新;当监听器返回 ORIENTATION_UNKNOWN 时,沿用上一次有效值,而不是随机回到 0°。

private var captureTargetRotation = Surface.ROTATION_0

private val orientationListener = object : OrientationEventListener(this) {
    override fun onOrientationChanged(orientation: Int) {
        val nextRotation = resolveTargetRotation(
            orientationDegrees = orientation,
            fallbackRotation = captureTargetRotation
        )
        if (nextRotation == captureTargetRotation) return

        captureTargetRotation = nextRotation
        imageCapture?.targetRotation = nextRotation
    }
}

监听器只在页面前台运行:

override fun onResume() {
    super.onResume()
    orientationListener.enable()
}

override fun onPause() {
    orientationListener.disable()
    super.onPause()
}

设备不支持方向检测时,继续使用竖屏方向,不能因此阻断基础拍照能力。

七、让 CameraX 写入正确的 EXIF

创建 ImageCapture 时就设置最近一次稳定方向:

val imageCapture = ImageCapture.Builder()
    .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
    .setTargetRotation(captureTargetRotation)
    .build()

按下快门时再次设置,并冻结本次拍摄方向:

private fun takePhoto(outputFile: File) {
    val frozenTargetRotation = captureTargetRotation
    val displayRotationDegrees = when (frozenTargetRotation) {
        Surface.ROTATION_90 -> 90
        Surface.ROTATION_180 -> 180
        Surface.ROTATION_270 -> 270
        else -> 0
    }

    imageCapture.targetRotation = frozenTargetRotation

    val outputOptions = ImageCapture.OutputFileOptions.Builder(outputFile)
        .build()

    imageCapture.takePicture(
        outputOptions,
        cameraExecutor,
        object : ImageCapture.OnImageSavedCallback {
            override fun onImageSaved(
                result: ImageCapture.OutputFileResults
            ) {
                runOnUiThread {
                    showCapturedImage(
                        file = outputFile,
                        displayRotationDegrees = displayRotationDegrees
                    )
                    uploadForTranslation(outputFile)
                }
            }

            override fun onError(exception: ImageCaptureException) = Unit
        }
    )
}

这里不是手动计算 TAG_ORIENTATION=68 再写入照片。物理方向先转换成 CameraX 的 targetRotation,CameraX 在保存 JPEG 时负责让输出像素和 EXIF Orientation 保持一致。

方向必须在快门时冻结。拍摄完成后用户可能已经转动手机,如果展示原图和译图时继续读取实时传感器角度,两张静态图会跟着手机变化,甚至与本次照片的 EXIF 失去对应关系。

八、强制竖屏时 ImageView 如何适配 EXIF

强制竖屏下的静态图展示包含两个独立步骤:

  1. Glide 按文件自己的 EXIF 解码,得到视觉正向的 Drawable;
  2. ImageView.rotation 使用快门时冻结的角度,让图片在锁定竖屏的页面中仍朝向当时横持或倒持手机的用户。
Glide.with(imageView)
    .load(imageFile)
    .into(imageView)

imageView.rotation = displayRotationDegrees.toFloat()

第二步不是再次处理 EXIF。它处理的是两个坐标系之间的差异:

EXIF / Glide   → 文件像素坐标系中的正确方向
ImageView      → 强制竖屏页面坐标系中的用户观看方向

为什么 90° 和 270° 要交换 ImageView 宽高

View.rotation 只改变绘制变换,不会触发父布局按旋转后的包围盒重新测量。

如果一个 match_parent × match_parentImageView 直接旋转 90°,它仍然保留旋转前的布局宽高。容器不是正方形时,横图就可能被缩窄或裁切。

可以在旋转 90° 或 270° 时,先交换 ImageView 的布局宽高,再绕中心旋转:

private fun applyDisplayRotation(
    container: FrameLayout,
    imageView: ImageView,
    rotationDegrees: Int
) {
    container.doOnLayout {
        val swapDimensions =
            rotationDegrees == 90 || rotationDegrees == 270

        imageView.updateLayoutParams<FrameLayout.LayoutParams> {
            width = if (swapDimensions) {
                container.height
            } else {
                ViewGroup.LayoutParams.MATCH_PARENT
            }
            height = if (swapDimensions) {
                container.width
            } else {
                ViewGroup.LayoutParams.MATCH_PARENT
            }
            gravity = Gravity.CENTER
        }

        imageView.rotation = rotationDegrees.toFloat()
    }
}

配合 fitCenter,同一个 ImageView 就能覆盖 0°、90°、180°、270°,不需要准备横竖两套布局:

<ImageView
    android:id="@+id/static_image"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:scaleType="fitCenter" />

相册图片没有本次快门时的物理方向,因此额外 View 旋转使用 0°,只让 Glide 根据文件自身 EXIF 正常展示。

九、服务端译图返回后的方向处理

服务端返回的译图字节直接写入缓存,不在客户端重新编码:

suspend fun writeResultToCache(
    targetFile: File,
    imageBytes: ByteArray
): File = withContext(Dispatchers.IO) {
    require(imageBytes.isNotEmpty())

    FileOutputStream(targetFile).use { outputStream ->
        outputStream.write(imageBytes)
    }
    targetFile
}

这样可以避免缓存过程破坏服务端返回图片的像素和 EXIF。展示时仍然交给 Glide:

Glide.with(resultImageView)
    .load(translatedFile)
    .into(resultImageView)

如果译图保留了 Orientation,Glide 会按它解码;如果服务端已经物理旋正像素并移除了 Orientation,Glide 也会按普通正向图片显示。

对于拍照来源,原图和译图共用快门时冻结的 displayRotationDegrees,保证用户在强制竖屏页面里切换两张图时方向一致。相册来源则统一使用 0° 的额外 View 旋转。

十、EXIF 错误如何影响下载和分享

图片在当前页面里看起来正确,不代表下载或分享出去的文件也是正确的。

这是因为 ImageView.rotation 只作用于当前应用的 View:

ImageView 中看起来正确
        ≠
文件像素和 EXIF 一定正确

用户下载图片或通过 FileProvider 分享时,传递给外部应用的是图片文件,而不是当前 ImageView 的绘制结果。ImageView 的 rotation、缩放和布局宽高都不会写回 JPEG。

fun shareImage(
    activity: Activity,
    imageFile: File,
    authority: String,
    mimeType: String
) {
    val imageUri = FileProvider.getUriForFile(
        activity,
        authority,
        imageFile
    )

    val intent = Intent(Intent.ACTION_SEND).apply {
        type = mimeType
        putExtra(Intent.EXTRA_STREAM, imageUri)
        addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    }
    activity.startActivity(Intent.createChooser(intent, null))
}

这段分享代码只是授予接收方读取原文件的权限,不会替图片修复方向。如果文件 EXIF 不正确,用户可能遇到以下体验:

  • 系统相册或文件管理器中的缩略图横着、倒着;
  • 聊天软件预览正常,但发送完成后的大图方向错误;
  • 某些社交平台上传时移除 EXIF,原本依靠错误或残缺元数据勉强显示的图片永久变横;
  • 图片编辑器按错误 Orientation 打开,裁剪框和文字方向不一致;
  • 再次导出、压缩或转码后,不同应用得到不同方向;
  • 用户保存服务端译图后,译文虽然正确,但整张图片需要手动旋转才能使用。

不同应用对 EXIF 的处理并不完全一致:有的在解码时应用 Orientation,有的会在上传时物理旋正并删除 EXIF,还有的直接剥离元数据但不旋转像素。因此,错误文件可能在 A 应用里正常、在 B 应用里横倒,造成非常不稳定的使用体验。

为什么应用内的额外旋转可能掩盖问题

假设文件的像素和 EXIF 本身不匹配,但页面又执行了:

imageView.rotation = 90f

图片可能恰好在当前页面中转正,让问题在测试时不容易被发现。一旦分享原文件,外部应用不会知道这次 View 旋转,错误方向就会重新出现。

所以验收图片方向时,不能只看应用内的 ImageView,还要检查真正被上传、下载和分享的文件。

像素与 Orientation 的结果组合

文件像素EXIF Orientation应用内与外部使用结果
已物理旋正NORMAL 或缺失最终文件方向自洽
保持相机原始像素轴与像素匹配支持 EXIF 的解码器可正确显示
已物理旋正仍保留原旋转值解码器会二次旋转
保持原始像素轴NORMAL、缺失或错误图片横倒,OCR 和外部应用都可能出错

本文采用“保持像素轴并保留正确 Orientation”。服务端下载得到的译图则原样落盘,分享时也直接分享该文件。因此,服务端返回结果必须继续满足“像素与 Orientation 成对正确”这一条件。

下载和分享前应验证什么

至少要覆盖四种拍摄方向,并分别验证:

  1. 原图在 Glide 中显示正确;
  2. 上传文件的原始像素宽高与 Orientation 匹配;
  3. 服务端 OCR 识别方向正确;
  4. 下载后的译图在系统相册中方向正确;
  5. 译图分享至至少一个聊天应用和一个图片编辑器后方向正确;
  6. 接收方再次保存或转码后,图片仍没有发生二次旋转。

这一步能发现仅靠应用内 ImageView 无法暴露的问题。

十一、完整链路

最终的方向链路可以概括为:

OrientationEventListener 感知手机物理方向
                    ↓
四象限映射为 CameraX targetRotation
                    ↓
按下快门,冻结 targetRotation 和页面显示角度
                    ↓
CameraX 保存像素与 EXIF 一致的 JPEG
                    ↓
上传前若重编码,只复制 EXIF Orientation
                    ↓
服务端按 Orientation 旋正后进行 OCR 和翻译
                    ↓
译图原始字节落盘,不在客户端二次重编码
        ├───────────┴───────────┐
        ↓                       ↓
Glide 按 EXIF 解码       下载或 FileProvider 分享原文件
        ↓                       ↓
ImageView 补偿页面角度    外部应用按各自策略处理 EXIF

这套实现最重要的原则是:EXIF 负责描述文件像素,Glide 负责把文件解码正确,ImageView 负责页面坐标系中的展示,服务端 OCR 负责在识别前应用文件方向。四层各自只处理自己的职责,就不会发生丢失方向或重复旋转。

总结

图片方向不是一个单独的角度,而是一份贯穿客户端、文件和服务端的契约:

  • EXIF Orientation 描述原始像素应如何变换后显示;
  • Glide 会读取图片 EXIF,ImageView 本身不会;
  • ImageView.rotation 是额外的界面变换,不会修改 EXIF;
  • Bitmap 重编码会丢失 EXIF,上传前必须显式保留 Orientation;
  • 服务端必须应用 EXIF 后再进行 OCR;
  • 强制竖屏时要独立感知物理方向,并把它映射为 CameraX targetRotation
  • 90°/270° 展示时先交换 ImageView 宽高,再执行 View 旋转;
  • 原图和译图应共用快门时冻结的页面显示角度;
  • 下载与分享传递的是文件,ImageView 的旋转不会随文件传出去。

只要始终分清“文件像素方向”“EXIF 显示方向”和“View 页面方向”,图片翻译链路中的旋转问题就会从一组偶发现象,变成一套可以验证的确定性规则。

注:部分内容由AI根据项目代码总结生成。

参考资料