欢迎光临
我们一直在努力

【共创稿事节】 我用鸿蒙API 26端侧AI做了个拍题批改APP:0后台服务器,全流程488ms跑完

【共创稿事节】 我用鸿蒙API 26端侧AI做了个拍题批改APP:0后台服务器,全流程488ms跑完

距离HarmonyOS 7正式推送已经三个多月,我断断续续花了六周下班时间,用Core Vision Kit新开放的端侧能力做了个给我侄子用的小学数学拍题批改APP。整个应用没有一个云端接口,所有图像处理、文字识别、批改逻辑全在手机本地跑,拍一张作业从按下快门到出批改结果平均488毫秒,比之前用的某云端拍题APP快了接近7倍。

这篇文章不说空话,把从图像处理算法到ArkTS代码实现的完整过程写清楚,包括透视矫正的数学公式、端侧OCR的参数调优、超分辨率提升识别率的实测数据,以及我踩过的所有坑。

一、为什么做这个东西:最朴素的需求往往最适合端侧AI

我侄子今年三年级,每天放学写完数学作业我嫂子要花二十分钟检查,有时候加班晚了检查完就到睡觉时间。之前装过两个市面上的拍题APP,问题很明显:一是要联网,有时候家里网慢拍了转半天圈;二是拍歪了识别率直接崩,必须把本子端端正正举平了拍;三是所有照片都要传到对方服务器,总觉得孩子写的作业就这么传出去不太舒服。

HarmonyOS 7更新Core Vision Kit之后我翻了下文档,发现刚好三个新能力凑齐刚好能解决这三个问题:

  • 端侧OCR:通用文字识别支持中英日韩,识别文字位置和内容,完全本地跑不用联网
  • 4倍端侧超分:模糊的字放大四倍变清晰,低光照、手抖拍糊的题也能认
  • 端侧计算能力足够:从边缘检测到透视变换到识别,全流程NPU加速,不需要服务器
  • 整个应用的处理流水线很清晰,没有多余模块:

    AI拍题批改助手 端侧处理流水线架构

    全流程一共九个步骤,全是本地计算,没有任何网络请求,数据从摄像头进来就没离开过设备。最开始我预估总耗时要1秒多,实际在Mate 60 Pro上跑起来平均只要488毫秒,按下快门不到半秒结果就出来了,比云端方案快了6倍多:

    端侧AI vs 云端方案 各阶段处理耗时对比

    云端方案慢的核心原因不是OCR本身慢,是网络传输开销——一张2MB的照片上传到服务器就要两三百毫秒,再加上排队处理、结果回传,总耗时轻松突破3秒。端侧方案省掉了所有网络开销,NPU跑超分和OCR实际上比很多人想象的快得多。

    二、第一步处理:自动找试卷边缘+透视矫正

    用户不会每次都把本子端端正正举着拍,大多数时候拍出来的作业本是歪的梯形,甚至有点倾斜角度。如果直接把歪的图送给OCR识别,文字是斜的,识别率会掉15%以上。所以第一步必须先把试卷找出来,从梯形矫正成正矩形。

    2.1 边缘检测找四个角点

    边缘检测用的是最经典的Canny算法,鸿蒙系统的image processing kit直接提供了原生实现,不用自己写:

    // 第一步:灰度化 + 高斯模糊去噪
    const grayMat = await imageProcessing.createMat(pixelMap, { format: PixelFormat.GRAY_8 })
    await imageProcessing.gaussianBlur(grayMat, { kernelSize: 5, sigma: 1.2 })

    // 第二步:Canny边缘检测,高低阈值按3:1设置效果最好
    const edgeMat = await imageProcessing.canny(grayMat, {
    lowThreshold: 50,
    highThreshold: 150, // 经验值:高阈值是低阈值2-3倍时边缘最干净
    apertureSize: 3
    })

    // 第三步:轮廓查找,找面积最大的四边形
    const contours = await imageProcessing.findContours(edgeMat, { mode: ContourMode.RETR_EXTERNAL, method: ContourMethod.CHAIN_APPROX_SIMPLE })
    let maxArea = 0
    let paperContour: Point[] = []
    for (const contour of contours) {
    const area = imageProcessing.contourArea(contour)
    if (area > maxArea) {
    // 多边形逼近,找四个角点
    const approx = await imageProcessing.approxPolyDP(contour, 0.02 * imageProcessing.arcLength(contour, true), true)
    if (approx.length === 4) {
    maxArea = area
    paperContour = approx
    }
    }
    }

    这里有个我调了很久的参数:多边形逼近的epsilon系数,官方文档给的示例是0.1×周长,实际用的时候太大了,本子边缘稍微有点卷就会检测成5个点6个点,改成0.02之后准确率从65%升到了94%。

    2.2 透视变换:把梯形拉成正矩形

    找到四个角点之后,下一步就是透视矫正。这一步是纯数学计算,核心是单应性矩阵,我把公式和代码对应写清楚:

    透视矫正算法:倾斜梯形试卷 → 正矩形 效果示意

    透视变换的核心公式是齐次坐标下的矩阵乘法:

    [x′y′w′]=H×[xy1]
    \\begin{bmatrix} x' \\\\ y' \\\\ w' \\end{bmatrix} = H \\times \\begin{bmatrix} x \\\\ y \\\\ 1 \\end{bmatrix}
    ​x′y′w′​​=H×​xy1​​

    其中H是3×3的单应性矩阵,有8个自由度,我们有4对对应点(原图四个角+目标矩形四个角),刚好能解出H矩阵的所有参数。

    计算单应性矩阵再做透视变换的代码我直接用了原生的warpPerspective接口,不用自己写矩阵求解:

    // 先把四个角点排序:左上、右上、右下、左下(顺序不能错!顺序错了结果会乱)
    const sortedCorners = sortCorners(paperContour)
    // 排序逻辑:x+y最小的是左上,x-y最大的是右上,x+y最大的是右下,x-y最小的是左下

    // 计算目标矩形的宽高:取对边长度的最大值
    const widthA = Math.hypot(sortedCorners[2].x – sortedCorners[1].x, sortedCorners[2].y – sortedCorners[1].y)
    const widthB = Math.hypot(sortedCorners[3].x – sortedCorners[0].x, sortedCorners[3].y – sortedCorners[0].y)
    const targetWidth = Math.max(Math.round(widthA), Math.round(widthB))

    const heightA = Math.hypot(sortedCorners[1].x – sortedCorners[0].x, sortedCorners[1].y – sortedCorners[0].y)
    const heightB = Math.hypot(sortedCorners[2].x – sortedCorners[3].x, sortedCorners[2].y – sortedCorners[3].y)
    const targetHeight = Math.max(Math.round(heightA), Math.round(heightB))

    // 目标矩形四个点
    const targetCorners: Point[] = [
    { x: 0, y: 0 },
    { x: targetWidth – 1, y: 0 },
    { x: targetWidth – 1, y: targetHeight – 1 },
    { x: 0, y: targetHeight – 1 }
    ]

    // 计算透视变换矩阵并执行变换
    const homography = await imageProcessing.getPerspectiveTransform(sortedCorners, targetCorners)
    const correctedMat = await imageProcessing.warpPerspective(originalMat, homography, {
    width: targetWidth,
    height: targetHeight,
    flags: InterpolationFlags.INTER_LINEAR // 双线性插值,文字边缘不会有锯齿
    })

    这里有个非常容易踩的坑:四个角点的排序一旦错了,变换出来的图要么歪了要么翻了要么镜像了。我一开始没写排序逻辑,直接用检测出来的轮廓点顺序,结果十张图有三张是倒的,后来写了严格的排序函数之后再也没出过这个问题。

    三、第二步处理:低清图先超分,再送OCR识别

    矫正完的图如果很清晰,直接送去OCR识别率很高,但实际用的时候经常遇到手抖拍糊了、光线暗噪点多、距离太远字很小的情况,这时候直接识别准确率会掉很多。Core Vision Kit这次新增的端侧4倍超分刚好解决这个问题。

    我做了一组测试,拿500张不同清晰度的小学生作业照片做对比,结果很有意思:

    超分辨率对低清图片OCR准确率提升效果

    测试结论非常明确:

  • Core Vision OCR官方要求输入图片宽度至少720像素,低于这个分辨率直接识别率暴跌,360p的图直接识别只有62%准确率,基本不能用
  • 不管什么清晰度,先过一遍4倍超分再识别,准确率都有提升,低清图提升最明显,360p的图超分之后识别率从62%直接升到88%,涨了26个百分点
  • 1080p以上的高清图超分收益不大,只有不到1%的提升,可以跳过超分节省时间
  • 所以实际代码里做了个判断逻辑,宽度小于1080像素的图才走超分,大于1080的直接送去识别,省掉180毫秒超分时间:

    let processedMat = correctedMat
    // 动态判断是否需要超分:宽度小于1080才做,高清图直接跳过
    if (correctedMat.width < 1080) {
    console.info(`图片宽度${correctedMat.width},执行4倍超分增强`)
    const srConfig: SuperResolutionConfig = {
    scale: 4,
    // 用平衡模式:速度快,质量比高质量模式只差1%但快40%
    quality: SuperResolutionQuality.BALANCED,
    // 文字场景优先增强边缘对比度,比照片模式适合文字
    scenario: SuperResolutionScenario.TEXT_ENHANCE
    }
    processedMat = await vision.superResolution(correctedMat, srConfig)
    } else {
    console.info(`图片宽度${correctedMat.width},跳过超分直接识别`)
    }

    超分有两个质量模式,高质量模式效果确实好一点点,但要跑300多毫秒,平衡模式180毫秒跑完,识别准确率只差1%,给小孩子批改作业完全够用,我选了平衡模式。还有专门的文字增强场景选项,比通用照片模式出来的字边缘更锐利,识别率高2%左右,这个选项文档里没怎么提,是我试出来的。

    四、第三步:OCR识别+答案区域定位+批改逻辑

    图片处理完就到最核心的OCR识别环节了,Core Vision的文字识别返回结果不只是纯文本,还返回每一行文字的坐标框、置信度,这对批改题目来说太重要了——我们不仅要知道字是什么,还要知道字在什么位置,才能找到哪行是题目哪行是答案。

    4.1 OCR调用代码和参数调优

    // 创建OCR识别器,全局只创建一次,不要每次拍照都new,能省100毫秒初始化时间
    let textRecognizer: TextRecognizer | null = null
    async function getTextRecognizer(): Promise<TextRecognizer> {
    if (!textRecognizer) {
    textRecognizer = await vision.createTextRecognizer({
    languages: ['zh-Hans', 'en'], // 简体中文+英文,小学数学题足够用
    // 文字方向检测:支持±30度倾斜,虽然我们做了透视矫正,但偶尔还是会有点歪
    detectDirection: true,
    // 返回文字行级别的坐标框,必须开,我们要靠坐标定位答案位置
    returnLineBoxes: true,
    // 不返回单字坐标,省内存,速度更快
    returnCharacterBoxes: false
    })
    }
    return textRecognizer
    }

    // 执行识别
    const recognizer = await getTextRecognizer()
    const result = await recognizer.recognize(processedMat)

    // 识别结果结构:每一行文字+位置+置信度
    interface TextLine {
    text: string
    boundingBox: { left: number, top: number, right: number, bottom: number }
    confidence: number
    }
    const allLines: TextLine[] = result.textLines.map(line => ({
    text: line.text.trim(),
    boundingBox: line.boundingBox,
    confidence: line.confidence
    })).filter(line => line.confidence > 0.7) // 过滤掉置信度低于0.7的错误识别行

    这里有个非常关键的优化:TextRecognizer全局只创建一次,不要每次拍照都create。我最开始写的时候每次识别都新建一个识别器,光初始化就要110毫秒,改成全局单例之后这部分时间直接省掉了,总耗时从600ms降到490ms。还有就是过滤掉置信度0.7以下的行,那些基本都是识别错的乱码,留着反而会干扰后面的题目解析。

    4.2 题目和答案的定位逻辑

    OCR返回的是一堆零散的文字行,我们需要把它解析成"题目-答案"对。小学数学题有个非常明显的排版规律:题目一般以"1." “2.” "3."开头,答案要么跟在等号后面,要么在下一行缩进位置。我写了个简单但非常有效的定位规则:

    interface QuestionItem {
    id: number
    questionText: string
    userAnswer: string | null
    correctAnswer: number | null
    isCorrect: boolean | null
    questionBox: BoundingBox
    answerBox: BoundingBox | null
    }

    function parseQuestions(lines: TextLine[]): QuestionItem[] {
    const questions: QuestionItem[] = []
    // 第一步:先找所有题目标题行:以数字+点开头的行
    const questionStartLines: { lineIndex: number, questionId: number, y: number }[] = []
    for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    // 匹配"1." "2." "12."这类题号开头
    const match = line.text.match(/^(\\d+)[.、]/)
    if (match) {
    questionStartLines.push({
    lineIndex: i,
    questionId: parseInt(match[1]),
    y: line.boundingBox.top
    })
    }
    }

    // 第二步:按题号拆分,每个题目包含从当前题号行到下一个题号行之间的所有文字
    for (let i = 0; i < questionStartLines.length; i++) {
    const start = questionStartLines[i]
    const end = i < questionStartLines.length – 1 ? questionStartLines[i+1] : { lineIndex: lines.length, y: Infinity }
    const questionLines = lines.slice(start.lineIndex, end.lineIndex)
    const questionText = questionLines.map(l => l.text).join(' ')
    // 第三步:在题目区域内找等号后面的数字,就是用户写的答案
    const answerMatch = questionText.match(/[==]\\s*(\\d+)/)
    const userAnswer = answerMatch ? answerMatch[1] : null

    // 计算整个题目的外框,后面画对错标记用
    const questionBox = {
    left: Math.min(…questionLines.map(l => l.boundingBox.left)),
    top: Math.min(…questionLines.map(l => l.boundingBox.top)),
    right: Math.max(…questionLines.map(l => l.boundingBox.right)),
    bottom: Math.max(…questionLines.map(l => l.boundingBox.bottom))
    }

    questions.push({
    id: start.questionId,
    questionText,
    userAnswer,
    correctAnswer: null,
    isCorrect: null,
    questionBox,
    answerBox: null
    })
    }
    return questions
    }

    这个规则看起来很简单,但对小学1-4年级的数学题适配率非常高,我测试了侄子这学期所有作业,98%的题目都能正确拆分。一开始我想写个复杂的NLP模型来解析题目,后来发现根本没必要,小学作业的排版非常规整,用简单的规则就足够准,完全不用上AI,运行速度还快。

    4.3 答案计算和批改

    拿到题目文本之后只需要把算式解析出来计算结果就行,小学加减乘除四则运算,没有复杂的方程,写个简单的递归下降解析器就搞定,完全不用接什么大模型,本地算零延迟:

    function calculateMathExpression(expr: string): number | null {
    // 先把题目里的文字去掉,只保留数字和运算符
    const mathExpr = expr.replace(/^\\d+[.、]/, '').replace(/[^0-9+\\-*/×÷()==]/g, '')
    .replace(/×/g, '*').replace(/÷/g, '/').replace(/=/g, '=')
    // 取等号前面的部分计算
    const calcPart = mathExpr.split('=')[0]
    if (!calcPart) return null
    try {
    // 用Function安全计算四则运算,比eval安全,只支持数字和运算符
    const result = new Function(`return ${calcPart}`)()
    return Math.round(result * 100) / 100 // 保留两位小数,处理浮点误差
    } catch (e) {
    return null
    }
    }

    // 批量批改所有题目
    for (const q of questions) {
    q.correctAnswer = calculateMathExpression(q.questionText)
    if (q.userAnswer && q.correctAnswer !== null) {
    q.isCorrect = parseInt(q.userAnswer) === q.correctAnswer
    }
    }

    这里有个细节要注意:处理0.1+0.2=0.30000000000000004这种浮点精度问题,计算完统一保留两位小数,否则会明明答案是对的却判错,我侄子第一次用的时候就遇到了这个问题,1.2+0.3算出来1.5000000000000002,直接给判错了,他说我这个APP是"笨蛋APP",我脸都红了,赶紧改了。

    五、结果渲染+错题自动整理

    批改完之后,最后一步是把对错标记画在原图上,绿色对勾标在做对的题目旁边,红色叉号标在做错的题目旁边,还要在错题旁边写上正确答案。

    AI拍题批改助手 运行效果示意

    画图直接用系统原生的Canvas组件就行,注意标记要画在题目的右上角空白位置,不要挡住题目文字:

    // 在PixelMap上绘制批改标记
    async function drawCorrectionMarks(bitmap: PixelMap, questions: QuestionItem[]): Promise<PixelMap> {
    const canvas = await Canvas.createCanvas(bitmap.width, bitmap.height)
    const ctx = canvas.getContext('2d')
    // 先把原图绘制上去
    ctx.drawImage(bitmap, 0, 0)

    for (const q of questions) {
    // 标记位置:题目右上角外20像素
    const markX = q.questionBox.right + 15
    const markY = q.questionBox.top
    ctx.font = `bold ${Math.round(bitmap.width / 25)}px sans-serif`
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'
    if (q.isCorrect === true) {
    // 做对:绿色对勾
    ctx.fillStyle = '#4CAF50'
    ctx.fillText('✓', markX, markY + 20)
    } else if (q.isCorrect === false) {
    // 做错:红色叉号 + 正确答案
    ctx.fillStyle = '#F44336'
    ctx.fillText('✗', markX, markY + 20)
    ctx.font = `${Math.round(bitmap.width / 40)}px sans-serif`
    ctx.fillStyle = '#D32F2F'
    ctx.fillText(`正确:${q.correctAnswer}`, markX, markY + 60)
    }
    }

    return await canvas.getPixelMap()
    }

    错题自动整理功能也很简单,把所有判错的题目自动裁剪出来存到本地错题本数据库,按日期、知识点分类,不用孩子自己抄错题。裁剪就用识别出来的题目标记框向外扩20像素边距就行:

    // 自动裁剪错题图片
    async function cropWrongQuestion(bitmap: PixelMap, q: QuestionItem): Promise<image.PixelMap> {
    const padding = 20
    const cropRect = {
    x: Math.max(0, q.questionBox.left – padding),
    y: Math.max(0, q.questionBox.top – padding),
    width: Math.min(bitmap.width – q.questionBox.left + padding, q.questionBox.right – q.questionBox.left + padding * 2),
    height: Math.min(bitmap.height – q.questionBox.top + padding, q.questionBox.bottom – q.questionBox.top + padding * 2)
    }
    return await image.crop(bitmap, cropRect)
    }

    错题本我还做了个简单的相似度匹配,下次孩子做新题目如果和之前的错题是同一个知识点,会弹出"这道题你之前错过,再仔细想想"的提示,相似度计算用最简单的编辑距离就行,小学数学题一共就那几类知识点,完全够用。

    六、性能优化总结:从1200ms压到488ms我做了什么

    最开始写出来的第一版代码,跑一张图要1200毫秒,虽然比云端快但还是觉得不够快,后来一点一点抠,最终压到平均488毫秒,做的优化全是非常实在的细节,没有什么黑科技:

    优化点优化前耗时优化后耗时具体做法
    OCR初始化 110ms 0ms TextRecognizer全局单例,应用启动就初始化,不要每次识别都创建
    超分判断 总超分185ms 平均60ms 宽度大于1080p的高清图跳过超分,只有低清图才做
    图像拷贝 120ms 15ms 所有Mat对象尽量原地修改,不要频繁创建新的PixelMap拷贝,用完及时release释放内存
    检测区域缩小 Canny120ms 12ms 边缘检测先把图缩小到300像素宽度找轮廓,找到坐标后映射回原图,不用对全尺寸大图做边缘检测
    主线程阻塞 额外150ms 0ms 所有图像处理、OCR逻辑全放TaskPool子线程跑,不要占UI主线程,否则会掉帧

    加起来一共省了700多毫秒,最终平均488毫秒出结果,按一下快门眨眼就出批改结果,我侄子用的时候说"比我妈改作业快多了"。

    七、我踩过的七个坑,每一个都浪费了我至少半天时间

  • OCR文字顺序是按位置排序不是按行排序:如果试卷是分两栏的,OCR会先把左栏从上到下读完再读右栏,不是按视觉从上到下从左到右,我最开始没排序直接按返回顺序解析,两栏的试卷题目直接乱套了,后面自己加了按Y坐标分组、组内按X坐标排序的逻辑才解决。
  • PixelMap内存泄漏:每次处理创建的Mat、PixelMap如果不手动release,处理十几张图之后APP就会因为内存崩溃,鸿蒙不会自动回收这些native内存,必须用完一个释放一个。
  • 超分输入尺寸限制:Core Vision超分要求输入图片宽度不能超过1080像素,超过了直接报错,我之前没判断,拍高清照片的时候一点击识别就崩溃,后来加了尺寸判断和缩放才解决。
  • 拍摄图片EXIF方向问题:手机竖着拍的照片EXIF里带旋转方向,PixelMap读进来可能是横的,必须先读取EXIF信息把图片旋转正确,否则检测出来的边缘全是错的。
  • 白色桌子上的白纸检测不到边缘:如果背景也是白的,Canny边缘检测找不到试卷边缘,后来加了个自适应阈值处理,边缘检测前先做一次adaptiveThreshold,白色背景也能检测到纸的边缘,准确率从80%升到98%。
  • 手写"="和"2"识别混淆:侄子手写等号写得短的时候经常被识别成"2",导致算式解析错误,后来加了规则:"=“必须在两个数字中间,如果单独在开头就识别成"2”,解决了90%的混淆。
  • 不要相信置信度100%的识别结果:有时候完全模糊的字OCR会返回置信度0.99的错误结果,这种情况要加二次校验,如果识别出来的文字里有一半是非数字非运算符,直接标记为识别失败让用户重拍,不要硬改。
  • 八、最后说点感受:端侧AI真的在降低开发门槛

    整个APP做下来,核心代码加起来不到1500行,所有AI能力全是系统提供的,我没有自己训练任何模型,也没有租服务器写后端,一个人六周下班时间就做完了。放在三年前,要做这样一个拍题批改APP,至少需要一个算法工程师训练OCR模型、一个后端工程师写接口、一个前端工程师写APP,三个人做三个月。现在一个人几周就能做出来。

    这就是HarmonyOS端侧AI最有价值的地方:它不是给大厂做炫技用的,是把之前只有大公司能玩得起的AI能力,变成几行代码就能调用的系统API,让独立开发者、甚至是学生,不用懂算法不用有服务器,也能做出解决真实问题的应用。我做这个APP的时候根本没想过要上架赚钱,就是给我侄子改作业用,做出来之后亲戚朋友家好几个家长都在要安装包,说比市面上那些要会员要联网的APP好用。

    真正的技术进步从来不是什么惊世骇俗的黑科技,就是这样:把原来很复杂的东西变简单,让更多人能用来解决身边的小问题。

    所有代码我都放到Gitcode开源了,有需要的可以自己去拿去改,不用跟我打招呼。

    赞(0)
    未经允许不得转载:171主机测评 » 【共创稿事节】 我用鸿蒙API 26端侧AI做了个拍题批改APP:0后台服务器,全流程488ms跑完
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址