在鸿蒙(HarmonyOS)上做文档扫描增强,不能直接套用 OpenCV,因为鸿蒙原生没有 OpenCV 绑定。但纯 TypeScript(ArkTS)配合系统 AI 能力,可以构建一套完整的像素级处理管线,效果媲美专业扫描 App。本文拆解一套已落地的方案:先用 CoreVisionKit 的 AI 分割能力把文档主体从背景中抠出,铺白底;然后通过分块背景估计实现光照归一,消除阴影和渐变;接着做百分位拉伸、纸面漂白,再用 Unsharp Mask 锐化提升字迹清晰度;最后用 Otsu 自适应二值化将前景/背景彻底分离。全部计算逻辑放在 TaskPool 工作线程中,主线程只负责 UI。
一、整体处理流程
用户选图或拍照后,管线按顺序执行以下步骤:
1. 按长边上限缩放解码为 RGBA PixelMap(优先 SDR 解码,失败回退 AUTO)。
2. 尝试 AI 主体分割,若成功则将前景合成到纯白背景上,失败则直接处理整图。
3. 将图像转为灰度图,用 64×64 分块均值和双线性插值估计低频背景。
4. 光照归一:每个像素灰度乘以 192 再除以(背景值+14)。
5. 1%~99.1% 分位拉伸,将灰度范围映射到 0~255。
6. 按 92% 高分位漂白纸面至 252,超过 248 的部分压缩防止笔画断裂。
7. 3×3 均值模糊 + Unsharp Mask 锐化(amount=0.15)。
8. 对锐化后的灰度图做 Otsu 自适应二值化,字迹设为 36(防 JPEG 振铃),纸面设为 255。
9. 编码为 JPEG(质量 90)并写入文件,再校验文件大小和重新解码尺寸。
二、主体分割:AI 抠图铺白底
拍照扫文档时,桌面杂物、杯子和阴影会被误判为字迹。利用系统 CoreVisionKit 的 subjectSegmentation API 提取文档主体:- async function tryCompositeSubjectOnWhite(decodedPm: PixelMap): Promise<RgbaSize | null> {
- try {
- await subjectSegmentation.init();
- const visionInfo = { pixelMap: decodedPm };
- const config = {
- maxCount: 2,
- enableSubjectForegroundImage: true,
- enableSubjectDetails: true
- };
- const result = await subjectSegmentation.doSegmentation(visionInfo, config);
- const fg = result.fullSubject?.foregroundImage;
- if (fg === undefined) return null;
- // Alpha 预乘合成到白底
- const out = await rgbaPremultipliedWhiteBackground(fg);
- await fg.release();
- return out;
- } catch (_e) {
- return null; // 设备不支持/模拟器,回退整图
- }
- }
复制代码 Alpha 预乘合成公式:前景色 × alpha + 白色 × (1 - alpha),边缘半透明像素自然过渡。若分割失败(模拟器、低端设备、HDR 图片),直接返回 null,后续管线继续处理整图——这是一种“尽力而为”的降级策略。
三、分块背景估计:光照归一的核心
要消除光照不均,关键是准确估计每个像素位置的“光照强度”。直接用高斯模糊会被文字区域干扰,产生光晕。正确做法是将图像分成 64×64 的块,每个块计算平均灰度,再做 3×3 邻域平滑,最后双线性插值上采样到原图大小。- function buildLowFreqBackground(gray: Uint8Array, w: number, h: number): Uint8Array {
- const BW = Math.max(1, Math.ceil(w / 64));
- const BH = Math.max(1, Math.ceil(h / 64));
- const acc = new Float32Array(BW * BH);
- const cnt = new Int32Array(BW * BH);
- for (let y = 0; y < h; y++) {
- for (let x = 0; x < w; x++) {
- const bx = Math.min(BW - 1, Math.floor(x / 64));
- const by = Math.min(BH - 1, Math.floor(y / 64));
- acc[by * BW + bx] += gray[y * w + x];
- cnt[by * BW + bx]++;
- }
- }
- for (let i = 0; i < BW * BH; i++) {
- if (cnt[i] > 0) acc[i] /= cnt[i];
- }
- // 3×3 邻域平滑(略)
- // 双线性插值上采样(略)
- return background;
- }
复制代码 为什么选 64×64?32×32 太细会导致字迹笔画压低背景,128×128 太大则大字标题会污染背景估计。64×64 在 1600px 边长上约 25 个块,既能覆盖全局渐变,又被单个大字符污染的风险最低。
四、光照归一与后续增强
背景估计完成后,逐像素做除法:gray * 192 / (bg + 14)。分母加 14 防除零,分子 192 为目标亮度(非 255,给后续留余量)。这一步后阴影提亮、亮区压暗,但对比度可能下降,需要后续强化。
1. 百分位拉伸:取 1% 和 99.1% 分位的灰度值做线性映射到 0~255,忽略极端的噪点和反光点。
2. 纸面漂白:找到 92% 分位作为纸面代表值,映射到 252,超过 248 的部分乘以 0.28 压缩,防止笔画边缘断裂。
3. Unsharp Mask 锐化:out = original + 0.15 * (original - boxBlur3)。用 3×3 均值模糊代替高斯模糊(代码更少,差异极小),amount 取 0.15 避免二值化后锯齿。
五、Otsu 二值化与平滑的必要性
Otsu 算法遍历 0~255 寻找类间方差最大的阈值,自然适应双峰分布的文档图像。但必须注意:直接对光照归一后的灰度图做 Otsu,字迹边缘的噪点会被保留。解决方案是在 Otsu 之前先做一轮 3×3 均值模糊,平滑后的阈值更稳定。- function otsuThreshold(gray: Uint8Array, n: number): number {
- const hist = new Int32Array(256);
- for (let i = 0; i < n; i++) hist[gray[i]]++;
- let sum = 0;
- for (let i = 0; i < 256; i++) sum += i * hist[i];
- let sumB = 0, wB = 0, maxVar = 0, thresh = 128;
- for (let t = 0; t < 256; t++) {
- wB += hist[t];
- if (wB === 0) continue;
- const wF = n - wB;
- if (wF === 0) break;
- sumB += t * hist[t];
- const mB = sumB / wB;
- const mF = (sum - sumB) / wF;
- const between = wB * wF * (mB - mF) * (mB - mF);
- if (between >= maxVar) { maxVar = between; thresh = t; }
- }
- return thresh;
- }
复制代码 二值化输出时,字迹设为 36 而非 0,因为纯黑在 JPEG 压缩后易产生振铃伪影,36 视觉无差别但压缩更友好。
六、TaskPool 封装与 UI 防卡顿
整条管线通过 @Concurrent 函数封装,在 TaskPool 工作线程执行。- @Concurrent
- async function scanProcessTaskEntry(
- sourcePath: string, modeNum: number, outPath: string, maxLongEdge: number
- ): Promise<void> {
- await processScanImageToFile(sourcePath, modeNum as ScanImageMode, outPath, maxLongEdge);
- }
- // 调用方
- const task = new taskpool.Task(scanProcessTaskEntry, sourcePath, mode as number, outPath, maxLongEdge);
- await taskpool.execute(task);
复制代码 当 TaskPool 不可用或作为回退方案时,主线程处理需主动让出 UI。每处理 65536 像素(约 256×256 块)执行一次 yieldUi():- async function yieldUi(): Promise<void> {
- return new Promise<void>((resolve) => { setTimeout(() => { resolve(); }, 0); });
- }
复制代码 这种“穷人版并发”虽不如 TaskPool 高效,但能保证 UI 不冻结。
七、解码容错与写入校验
图片解码时优先尝试 SDR 解码,因为后续处理基于 8-bit 无符号整数;若失败则回退到 AUTO。写入 JPEG 后必须双重校验:statSync 检查文件大小 > 0,再重新解码校验宽高有效,防止磁盘满或权限不足导致空文件及后续布局崩溃。
八、踩坑记录与设计原则
关键经验:
- 分块背景估计必须做 3×3 邻域平滑,否则块边界出现阶梯状亮度跳变,光照归一后产生网格纹。
- Otsu 前必须用 boxBlur3 平滑,否则字迹边缘噪点导致阈值得抖。
- 所有像素运算后必须 clamp255,浮点精度可能产生略超 255 的值。
- TaskPool 工作线程中 fileIo 尽量使用同步 API(writeSync、statSync),避免异步回调兼容问题。
- JPEG 质量选 90 是平衡:95 文件大 40% 但视觉无区别,80 字迹边缘出现伪影。
这套管线的核心设计哲学是分层处理、每层只解决一个问题,且任何步骤失败都不中断流程,实现优雅降级。对于不愿引入第三方 SDK 的鸿蒙团队,完全可以用 ArkTS 独立实现。 |