iOS 图片上传与 OCR 识别的几个坑
最近在做 OCR 识别相关的工作,图片都由前端上传,过程中遇到了两个问题:
- 用 JS 动态创建 input 标签来实现选图和拍照,在 iOS 上拍照点"使用照片"后没有任何反应;
- iOS 拍的照片在 OCR 识别时失败率很高。
下面分别记录排查和解决的过程。
一、iOS 拍照后 change 回调不触发
唤起图片上传的代码如下:
const launchHtmlCamera = () => {
const inputElement = document.createElement('input');
inputElement.id = 'upload-image';
inputElement.type = 'file';
inputElement.accept = 'image/*';
inputElement.multiple = true; // 允许多选
inputElement.style.display = 'none';
// 如果只允许拍照,可以加上 capture 属性(取值为 environment 后置 / user 前置)
// inputElement.capture = 'environment';
inputElement.addEventListener('change', handleImageSelect);
inputElement.click();
};逻辑很简单:创建 input、设置属性,再模拟一次点击唤起选择器。但测试时发现,iOS 上拍完照确认后,handleImageSelect 始终没有执行。
在 Stack Overflow 上找到了解决办法,但没有找到官方说明。一种比较常见的解释是:没有挂到 DOM 上的 input 只被局部变量引用,iOS 在打开相机、页面切到后台期间可能会把它回收,监听器也就跟着丢了。要在 iOS 上正常工作,需要同时满足两个条件:
- input 必须被插入到 DOM 中;
- 必须用
addEventListener监听change,不能用onchange。
所以在模拟点击之前补一行即可:
document.body.appendChild(inputElement); // 不插入 DOM 的话,iOS 上监听器不会被触发顺带遇到的闭包问题
项目用的是 React Hooks。input 插入 DOM 后,绑定在上面的 handleImageSelect 是第一次渲染时创建的那个函数,它闭包里拿到的 imgList 永远是初始值。结果就是:添加一张图后再添加,只会替换,不会追加。
推荐做法:用函数式更新。 问题的根源是回调里读了 imgList,那就干脆不读它,把"基于最新值追加"交给 React:
const handleImageSelect = async (event) => {
const res = await readFiles(event.target.files); // 读取/处理选中的文件
setImgList((prev) => [...prev, ...res.filter(Boolean)]);
event.target.value = ''; // 清空,否则再次选择同一张图不会触发 change
};setImgList 传入函数时,React 会把最新的 state 作为 prev 传进来,跟回调是哪次渲染创建的无关,闭包问题自然消失。这样 input 只需创建一次并一直留在 DOM 里,唤起前判断一下是否已存在即可:
const existing = document.getElementById('upload-image');
if (existing) {
existing.click();
return;
}我当时实际用过另外两种办法,也能解决,但都更绕,仅作参考:
- 每次用完就移除 input:在回调末尾
removeEventListener并把 input 从 DOM 中删掉,下次唤起时重新创建、绑定最新的回调; - 用 useRef 同步最新值:在
useEffect里把imgList写进 ref,回调中读ref.current。
只要回调里需要的仅仅是"上一次的 state",函数式更新就够了;只有回调还要读其他会变的值(比如某个配置项)时,才需要 ref 这类方案。
二、iOS 拍照后 OCR 识别失败
iOS 拍的照片在手机上预览是正的,但 OCR 系统却反馈识别失败。
原因在于照片的像素数据本身并不是"正"的。手机拍照时会把拍摄方向写进 EXIF 的 Orientation 字段,相册和浏览器显示时再按这个字段旋转回来,所以我们看到的是正常的方向。而 OCR 系统通常直接按原始像素、当作旋转角度为 0 来处理。

比如上面这张图,Orientation 为 6(Rotated 90° CCW):原始像素相对正向是逆时针转了 90 度的,在设备上看是正的,是因为系统显示时帮我们顺时针转了回来。OCR 往往按区域识别,比如要求图片顶部某块区域必须是二维码,但在原始像素里这块区域跑到了左侧,文字也是横躺的,OCR 框定的区域自然找不到它。

Orientation 的常见取值
| 值 | 原始像素相对正向 | 显示时需要 |
|---|---|---|
| 1 | 正常 | 不处理 |
| 3 | 旋转了 180° | 旋转 180° |
| 6 | 逆时针转了 90°(竖拍常见) | 顺时针旋转 90° |
| 8 | 顺时针转了 90° | 逆时针旋转 90° |
读取 Orientation 当时用的是开源库 exif-js(下面的代码基于它,注意这个库已经多年没有维护,新项目不建议再引入),原理可参考:笔记:使用 JavaScript 读取 JPEG 文件 EXIF 信息中的 Orientation 值。
按 Orientation 手动还原图片
/**
* 根据 EXIF Orientation 还原图片方向
* @param {File} file
* @returns {Promise<Blob>}
*/
export const getExif = (file) => {
return new Promise((resolve) => {
const img = new Image();
const reader = new FileReader();
reader.onload = (e) => {
img.onload = () => {
EXIF.getData(img, () => {
const orientation = EXIF.getTag(img, 'Orientation');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
switch (orientation) {
case 3: // 180°,宽高不变
canvas.width = img.width;
canvas.height = img.height;
ctx.rotate(Math.PI);
ctx.drawImage(img, -img.width, -img.height);
break;
case 6: // 顺时针 90°,宽高互换
canvas.width = img.height;
canvas.height = img.width;
ctx.rotate(Math.PI / 2);
ctx.drawImage(img, 0, -img.height);
break;
case 8: // 逆时针 90°,宽高互换
canvas.width = img.height;
canvas.height = img.width;
ctx.rotate(-Math.PI / 2);
ctx.drawImage(img, -img.width, 0);
break;
default:
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
}
canvas.toBlob(resolve, file.type);
});
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
};实际采用的做法:用 canvas 重绘一遍
最终交给 OCR 的图片,我没有手动旋转,而是直接用 canvas 把图片重绘一遍再导出:
/**
* 用 canvas 重绘图片,导出不带 EXIF 方向信息的图片
* @param {File} file
* @returns {Promise<Blob>}
*/
export const formatOrientation = (file) => {
return new Promise((resolve) => {
const img = new Image();
const reader = new FileReader();
reader.onload = (e) => {
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// 0.5 为压缩质量,只对 image/jpeg、image/webp 生效,PNG 会忽略该参数
canvas.toBlob(resolve, file.type, 0.5);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
};这样处理后,OCR 识别失败的情况少了很多。如果仍然识别不了,再让 OCR 服务端把图片旋转 180 度重试一次。
为什么重绘一遍就有效
现代浏览器(Chrome 81+、Safari 13.1+、Firefox 77+)会在显示和 drawImage 时自动按 EXIF 方向摆正图片(CSS 的 image-orientation 默认值就是 from-image)。所以 drawImage 画到 canvas 上的已经是摆正后的像素,而 canvas 导出的图片不带 EXIF 信息。两者叠加,得到的就是一张"像素本身就是正的"图片,OCR 自然能识别。
这也意味着:在这些浏览器上不要再用上面的 getExif 手动旋转,否则会转两次。getExif 只适合需要兼容老版本浏览器的场景。
如果不需要兼容老浏览器,也可以省掉 FileReader 和 Image,直接用 createImageBitmap 解码,写法更短:
export const formatOrientation = async (file) => {
const bitmap = await createImageBitmap(file); // 默认按 EXIF 方向解码
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d').drawImage(bitmap, 0, 0);
bitmap.close();
return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.8));
};不过各浏览器对 createImageBitmap 方向处理的支持时间不一,上线前最好在目标机型上实测一张竖拍照片。
小结
- iOS 上动态创建的 file input,要插入 DOM 并用
addEventListener监听change; - Hooks 里给原生 DOM 绑定回调要注意闭包,更新 state 优先用函数式写法
setState(prev => ...); - 手机照片的方向靠 EXIF 记录,交给 OCR 等后端服务前,用 canvas 重绘一遍把方向"固化"到像素里。