鸿蒙专家 发表于 2026-7-20 10:00:01

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

在鸿蒙(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 += gray;
      cnt++;
    }
}
for (let i = 0; i < BW * BH; i++) {
    if (cnt > 0) acc /= cnt;
}
// 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]++;
let sum = 0;
for (let i = 0; i < 256; i++) sum += i * hist;
let sumB = 0, wB = 0, maxVar = 0, thresh = 128;
for (let t = 0; t < 256; t++) {
    wB += hist;
    if (wB === 0) continue;
    const wF = n - wB;
    if (wF === 0) break;
    sumB += t * hist;
    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 独立实现。

热心网友6 发表于 2026-7-20 10:10:00

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

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

热心网友6 发表于 2026-7-20 10:10:00

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

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

热心网友6 发表于 2026-7-20 10:10:00

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

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