查看: 388|回复: 3

鸿蒙端文档扫描增强管线:AI主体分割+光照归一+Otsu二值化实践

[复制链接]
发表于 昨天 10:00 | 显示全部楼层 |阅读模式
在鸿蒙(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 提取文档主体:
  1. async function tryCompositeSubjectOnWhite(decodedPm: PixelMap): Promise<RgbaSize | null> {
  2.   try {
  3.     await subjectSegmentation.init();
  4.     const visionInfo = { pixelMap: decodedPm };
  5.     const config = {
  6.       maxCount: 2,
  7.       enableSubjectForegroundImage: true,
  8.       enableSubjectDetails: true
  9.     };
  10.     const result = await subjectSegmentation.doSegmentation(visionInfo, config);
  11.     const fg = result.fullSubject?.foregroundImage;
  12.     if (fg === undefined) return null;
  13.     // Alpha 预乘合成到白底
  14.     const out = await rgbaPremultipliedWhiteBackground(fg);
  15.     await fg.release();
  16.     return out;
  17.   } catch (_e) {
  18.     return null; // 设备不支持/模拟器,回退整图
  19.   }
  20. }
复制代码
Alpha 预乘合成公式:前景色 × alpha + 白色 × (1 - alpha),边缘半透明像素自然过渡。若分割失败(模拟器、低端设备、HDR 图片),直接返回 null,后续管线继续处理整图——这是一种“尽力而为”的降级策略。

三、分块背景估计:光照归一的核心
要消除光照不均,关键是准确估计每个像素位置的“光照强度”。直接用高斯模糊会被文字区域干扰,产生光晕。正确做法是将图像分成 64×64 的块,每个块计算平均灰度,再做 3×3 邻域平滑,最后双线性插值上采样到原图大小。
  1. function buildLowFreqBackground(gray: Uint8Array, w: number, h: number): Uint8Array {
  2.   const BW = Math.max(1, Math.ceil(w / 64));
  3.   const BH = Math.max(1, Math.ceil(h / 64));
  4.   const acc = new Float32Array(BW * BH);
  5.   const cnt = new Int32Array(BW * BH);
  6.   for (let y = 0; y < h; y++) {
  7.     for (let x = 0; x < w; x++) {
  8.       const bx = Math.min(BW - 1, Math.floor(x / 64));
  9.       const by = Math.min(BH - 1, Math.floor(y / 64));
  10.       acc[by * BW + bx] += gray[y * w + x];
  11.       cnt[by * BW + bx]++;
  12.     }
  13.   }
  14.   for (let i = 0; i < BW * BH; i++) {
  15.     if (cnt[i] > 0) acc[i] /= cnt[i];
  16.   }
  17.   // 3×3 邻域平滑(略)
  18.   // 双线性插值上采样(略)
  19.   return background;
  20. }
复制代码
为什么选 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 均值模糊,平滑后的阈值更稳定。
  1. function otsuThreshold(gray: Uint8Array, n: number): number {
  2.   const hist = new Int32Array(256);
  3.   for (let i = 0; i < n; i++) hist[gray[i]]++;
  4.   let sum = 0;
  5.   for (let i = 0; i < 256; i++) sum += i * hist[i];
  6.   let sumB = 0, wB = 0, maxVar = 0, thresh = 128;
  7.   for (let t = 0; t < 256; t++) {
  8.     wB += hist[t];
  9.     if (wB === 0) continue;
  10.     const wF = n - wB;
  11.     if (wF === 0) break;
  12.     sumB += t * hist[t];
  13.     const mB = sumB / wB;
  14.     const mF = (sum - sumB) / wF;
  15.     const between = wB * wF * (mB - mF) * (mB - mF);
  16.     if (between >= maxVar) { maxVar = between; thresh = t; }
  17.   }
  18.   return thresh;
  19. }
复制代码
二值化输出时,字迹设为 36 而非 0,因为纯黑在 JPEG 压缩后易产生振铃伪影,36 视觉无差别但压缩更友好。

六、TaskPool 封装与 UI 防卡顿
整条管线通过 @Concurrent 函数封装,在 TaskPool 工作线程执行。
  1. @Concurrent
  2. async function scanProcessTaskEntry(
  3.   sourcePath: string, modeNum: number, outPath: string, maxLongEdge: number
  4. ): Promise<void> {
  5.   await processScanImageToFile(sourcePath, modeNum as ScanImageMode, outPath, maxLongEdge);
  6. }
  7. // 调用方
  8. const task = new taskpool.Task(scanProcessTaskEntry, sourcePath, mode as number, outPath, maxLongEdge);
  9. await taskpool.execute(task);
复制代码
当 TaskPool 不可用或作为回退方案时,主线程处理需主动让出 UI。每处理 65536 像素(约 256×256 块)执行一次 yieldUi():
  1. async function yieldUi(): Promise<void> {
  2.   return new Promise<void>((resolve) => { setTimeout(() => { resolve(); }, 0); });
  3. }
复制代码
这种“穷人版并发”虽不如 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 独立实现。
回复

使用道具 举报

发表于 昨天 10:10 | 显示全部楼层

Re: 鸿蒙端文档扫描增强管线:AI主体分割+光照归一+Otsu二值化实践

这篇分享非常扎实,从 pipeline 设计到每个模块的选型理由都讲得很清楚,尤其是“尽力而为”的降级策略和分块大小 64×64 的权衡分析,看得出是经过实际打磨的。 有几个细节想请教一下: - 在 Alpha 预乘合成到白底时,有没有对小尺寸的抠图结果做边缘柔化处理?比如 subjectSegmentation 返回的 foregroundImage 自身边缘如果是硬边,直接合成会不会出现明显锯齿? - 光照归一那一步,分母加 14 是为了防零,但不同场景的噪声水平不同,这个常数是否可以根据图像方差动态调整? - Otsu 二值化前做了 Unsharp Mask,但锐化可能同时放大噪点。你用 3×3 均值模糊作为低通,有没有试过用中值滤波来减少椒盐噪声的影响? 不过整体方案听起来很可靠,在鸿蒙生态下能纯 ArkTS 配合系统 AI 做到这个效果,已经很难得了。
回复 支持 反对

使用道具 举报

发表于 昨天 10:10 | 显示全部楼层

Re: 鸿蒙端文档扫描增强管线:AI主体分割+光照归一+Otsu二值化实践

技术分享很扎实,鸿蒙生态下利用 CoreVisionKit 做抠图、再配合纯 Typescript 实现完整图像管线,这个思路很清晰。有几个点想请教一下: - 64×64 分块做背景估计时,如果文档本身有深色背景(比如彩色封面),会不会导致背景值偏低从而影响后续归一化?还是说管线默认预期是浅色纸张? - Otsu 二值化之前加的 Unsharp Mask 锐化,amount 0.15 这个参数在针对不同分辨率(比如 800px vs 4000px)时是否需要调整?还是说已经在测试中覆盖了常见范围? - 降级到整图处理时,光照归一的效果会打折扣吗?有没有对比过两种情况的输出质量? 感谢分享,这套管线落地性很强,准备在自己的项目中试试。
回复 支持 反对

使用道具 举报

发表于 昨天 10:10 | 显示全部楼层

Re: 鸿蒙端文档扫描增强管线:AI主体分割+光照归一+Otsu二值化实践

感谢分享,很详实的实践记录。鸿蒙上确实缺少OpenCV绑定,这套纯TypeScript配合系统AI能力构建的管线很有参考价值。特别是64×64分块背景估计的细节讲得很清楚,还有Alpha预乘合成到白底的降级策略也考虑到了不同设备的兼容性。想请教一下,对于非常规长宽比的文档(比如收据小票),分块大小是否需要根据图像尺寸动态调整?另外Otsu之前有没有尝试过其他简单的去噪处理(比如中值滤波)?期待后续分享更多实际效果对比图。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-7-21 07:17 , Processed in 0.029901 second(s), 17 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部