HarmonyOS APP开发:手写文字识别与笔迹分析

举报
Jack20 发表于 2026/06/21 12:07:15 2026/06/21
【摘要】 HarmonyOS APP开发:手写文字识别与笔迹分析核心要点:深入掌握HarmonyOS手写文字识别的技术原理与实战方案,包括在线手写识别(笔迹实时采集与识别)和离线手写识别(图片手写体OCR),实现笔迹特征提取与书写者分析,以及手写笔记数字化、手写公式识别等进阶应用。 一、背景与动机“你写的字,只有你自己看得懂。”这句话,大概每个人都听过。手写文字的多样性、随意性、个性化,使得手写识别...

HarmonyOS APP开发:手写文字识别与笔迹分析

核心要点:深入掌握HarmonyOS手写文字识别的技术原理与实战方案,包括在线手写识别(笔迹实时采集与识别)和离线手写识别(图片手写体OCR),实现笔迹特征提取与书写者分析,以及手写笔记数字化、手写公式识别等进阶应用。


一、背景与动机

“你写的字,只有你自己看得懂。”

这句话,大概每个人都听过。手写文字的多样性、随意性、个性化,使得手写识别成为OCR领域中最具挑战性的问题之一。同样是写一个"永"字,一千个人能写出一千种样子——有人写得龙飞凤舞,有人写得歪歪扭扭,有人连笔带草,有人一笔一划。

但手写识别的需求又是真实而迫切的:

  • 教育场景:老师批改作业、学生做笔记,都需要将手写内容数字化
  • 签批场景:领导在文件上签字批示,需要将签批内容提取为可检索文本
  • 笔记场景:手写笔记APP需要将手写内容转为可编辑文本
  • 表单场景:快递单、保险单、申请表上的手写信息需要自动录入
  • 无障碍场景:帮助视障用户理解手写内容

HarmonyOS为手写识别提供了两种不同的技术路线:

  1. 在线手写识别(Online HWR):通过触摸屏实时采集笔迹数据(坐标序列+时间戳),在书写过程中即时识别
  2. 离线手写识别(Offline HWR):对包含手写文字的图片进行OCR识别

两种路线的技术原理和适用场景完全不同,今天我们就把这两种路线都讲透,并深入探讨笔迹特征提取与书写者分析这一进阶话题。


二、核心原理

2.1 在线手写识别 vs 离线手写识别

这是理解手写识别的第一道分水岭。

维度 在线手写识别 离线手写识别
输入数据 笔迹坐标序列+时间戳 手写文字图片
信息丰富度 高(包含书写顺序、速度、压力) 低(只有像素信息)
识别精度 较高 较低
适用场景 手写输入法、笔记APP 表单录入、文档数字化
技术路线 序列模型(RNN/LSTM) 图像模型(CNN)
实时性 实时 非实时

在线手写识别之所以精度更高,是因为它拥有离线识别所没有的"时序信息"——笔画的书写顺序、书写速度、笔尖压力等。这些信息对于区分形近字至关重要。比如"人"和"入",在图片上可能看起来一模一样,但书写顺序完全不同:"人"先写撇再写捺,"入"先写捺再写撇。

2.2 在线手写识别处理流程

flowchart TD
    A[触摸屏笔迹采集] --> B[笔迹预处理]
    
    B --> B1[坐标归一化]
    B --> B2[噪声过滤]
    B --> B3[重采样]
    
    B1 --> C[特征提取]
    B2 --> C
    B3 --> C
    
    C --> C1[几何特征]
    C --> C2[运动特征]
    C --> C3[上下文特征]
    
    C1 --> D[序列模型推理]
    C2 --> D
    C3 --> D
    
    D --> D1[LSTM编码]
    D1 --> D2[CTC解码]
    
    D2 --> E[候选结果排序]
    E --> F[输出最佳识别结果]
    
    A --> G[笔迹特征库]
    G --> G1[书写速度]
    G --> G2[笔画压力]
    G --> G3[连笔程度]
    G --> G4[字体大小]
    
    G1 --> H[书写者分析]
    G2 --> H
    G3 --> H
    G4 --> H

    classDef primary fill:#4FC3F7,stroke:#0288D1,color:#000
    classDef warning fill:#FFB74D,stroke:#F57C00,color:#000
    classDef error fill:#EF5350,stroke:#C62828,color:#fff
    classDef info fill:#81C784,stroke:#388E3C,color:#000
    classDef purple fill:#CE93D8,stroke:#7B1FA2,color:#000

    class A,B,B1,B2,B3 primary
    class C,C1,C2,C3 warning
    class D,D1,D2,E,F info
    class G,G1,G2,G3,G4 purple
    class H error

2.3 离线手写识别的挑战

离线手写识别(即对图片中的手写文字做OCR)面临的核心挑战:

  1. 书写风格差异大:每个人的书写习惯不同,字体大小、倾斜角度、连笔程度千差万别
  2. 背景干扰:手写文字通常出现在信纸、笔记本、表格等有背景纹理的介质上
  3. 墨迹质量不一:有些字迹清晰,有些模糊,有些甚至有涂改
  4. 排版不规范:手写文字的行距、字距、对齐方式都不像印刷体那样规整

HarmonyOS的通用文字识别服务在PRECISE模式下对手写体有一定的识别能力,但如果你需要更高的手写识别精度,建议使用专门的手写识别引擎。

2.4 笔迹特征与书写者分析

笔迹学(Graphology)认为,一个人的笔迹可以反映其性格特征。虽然这一说法在学术上存在争议,但笔迹中的某些客观特征确实可以用于书写者识别和验证:

笔迹特征 描述 潜在应用
书写速度 笔画的平均移动速度 签名验证
笔画压力 笔尖对屏幕的压力变化 情绪状态推断
连笔程度 相邻笔画之间的连接频率 书写熟练度评估
字体大小 字符的平均高度/宽度 自信度推断
倾斜角度 字符相对竖直方向的偏转 性格特征分析
基线稳定性 书写基线的波动程度 注意力/疲劳度评估

在HarmonyOS中,我们可以通过触摸事件采集笔迹数据,然后提取这些特征进行分析。


三、代码实战

3.1 在线手写识别:手写板组件

这是一个完整的手写板组件,支持实时笔迹采集和手写识别。

// 在线手写识别组件
import { handwritingRecognition } from '@kit.AI.Intelligent';

// 笔迹点数据
interface StrokePoint {
  x: number;
  y: number;
  timestamp: number;  // 时间戳(毫秒)
  pressure: number;   // 压力值(0-1)
}

// 笔画数据
interface Stroke {
  points: StrokePoint[];
}

// 手写识别结果
interface HandwritingResult {
  text: string;
  confidence: number;
  alternatives: string[];  // 候选结果
}

@Entry
@Component
struct HandwritingCanvasPage {
  @State recognizedText: string = '请在下方书写...';
  @State confidence: number = 0;
  @State strokeCount: number = 0;
  @State isAnalyzing: boolean = false;

  // 笔迹数据
  private currentStroke: StrokePoint[] = [];
  private allStrokes: Stroke[] = [];
  private canvasWidth: number = 360;
  private canvasHeight: number = 400;
  private settings: DrawingSettings = {
    strokeColor: '#4FC3F7',
    strokeWidth: 3,
    showGrid: true
  };

  // 手写识别引擎
  private hwEngine: handwritingRecognition.HandwritingRecognitionEngine | null = null;

  aboutToAppear(): void {
    // 初始化手写识别引擎
    this.hwEngine = handwritingRecognition.HandwritingRecognitionEngine.create(
      handwritingRecognition.HandwritingRecognitionPreset.COMMON
    );
  }

  aboutToDisappear(): void {
    this.hwEngine?.close();
    this.hwEngine = null;
  }

  // 处理触摸事件:采集笔迹
  handleTouchDown(event: TouchEvent): void {
    const touch = event.touches[0];
    this.currentStroke = [{
      x: touch.x,
      y: touch.y,
      timestamp: Date.now(),
      pressure: touch.force || 0.5
    }];
  }

  handleTouchMove(event: TouchEvent): void {
    const touch = event.touches[0];
    this.currentStroke.push({
      x: touch.x,
      y: touch.y,
      timestamp: Date.now(),
      pressure: touch.force || 0.5
    });
  }

  handleTouchUp(): void {
    if (this.currentStroke.length > 1) {
      this.allStrokes.push({ points: [...this.currentStroke] });
      this.strokeCount = this.allStrokes.length;
    }
    this.currentStroke = [];
  }

  // 执行手写识别
  async recognizeHandwriting(): Promise<void> {
    if (!this.hwEngine || this.allStrokes.length === 0) {
      this.recognizedText = '请先书写内容';
      return;
    }

    this.isAnalyzing = true;

    try {
      // 将笔迹数据转换为引擎所需格式
      const strokeData = this.convertStrokesForEngine(this.allStrokes);

      // 执行识别
      const result = await this.hwEngine.recognize(strokeData);

      this.recognizedText = result.text || '未能识别';
      this.confidence = result.confidence || 0;
    } catch (error) {
      console.error(`手写识别失败: ${JSON.stringify(error)}`);
      this.recognizedText = '识别失败,请重试';
    } finally {
      this.isAnalyzing = false;
    }
  }

  // 转换笔迹数据格式
  private convertStrokesForEngine(strokes: Stroke[]): handwritingRecognition.HandwritingStroke[] {
    return strokes.map(stroke => {
      const points: handwritingRecognition.HandwritingPoint[] = stroke.points.map(p => ({
        x: p.x,
        y: p.y,
        timestamp: p.timestamp,
        pressure: p.pressure
      }));
      return { points };
    });
  }

  // 清除画布
  clearCanvas(): void {
    this.allStrokes = [];
    this.currentStroke = [];
    this.strokeCount = 0;
    this.recognizedText = '请在下方书写...';
    this.confidence = 0;
  }

  // 撤销最后一笔
  undoLastStroke(): void {
    if (this.allStrokes.length > 0) {
      this.allStrokes.pop();
      this.strokeCount = this.allStrokes.length;
    }
  }

  build() {
    Column() {
      // 标题
      Text('手写文字识别')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#e0e0e0')
        .margin({ bottom: 16 })

      // 识别结果展示
      Column() {
        Text('识别结果')
          .fontSize(13)
          .fontColor('#888')
          .margin({ bottom: 4 })
        Text(this.recognizedText)
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor('#4FC3F7')
        if (this.confidence > 0) {
          Text(`置信度: ${(this.confidence * 100).toFixed(1)}%`)
            .fontSize(12)
            .fontColor(this.confidence > 0.7 ? '#81C784' : '#FFB74D')
            .margin({ top: 4 })
        }
      }
      .width('90%')
      .padding(16)
      .backgroundColor('#1a1a2e')
      .borderRadius(12)
      .alignItems(HorizontalAlign.Center)
      .margin({ bottom: 16 })

      // 手写画布
      Stack() {
        // 网格背景
        if (this.settings.showGrid) {
          Canvas(null)
            .width('100%')
            .height('100%')
            .onReady(() => {})
        }

        // 书写区域
        Column() {
          Text(`笔画数: ${this.strokeCount}`)
            .fontSize(11)
            .fontColor('#555')
            .alignSelf(ItemAlign.End)
            .margin({ right: 8, top: 4 })
        }
        .width('100%')
        .height('100%')
      }
      .width('90%')
      .height(this.canvasHeight)
      .backgroundColor('#111122')
      .borderRadius(12)
      .border({ width: 1, color: '#333' })

      // 操作按钮
      Row() {
        Button('撤销')
          .width(80)
          .height(40)
          .backgroundColor('#333')
          .fontColor('#e0e0e0')
          .onClick(() => this.undoLastStroke())

        Button('清除')
          .width(80)
          .height(40)
          .backgroundColor('#333')
          .fontColor('#e0e0e0')
          .onClick(() => this.clearCanvas())

        Button(this.isAnalyzing ? '识别中...' : '识别')
          .width(120)
          .height(40)
          .backgroundColor('#4FC3F7')
          .fontColor('#000')
          .enabled(!this.isAnalyzing)
          .onClick(() => this.recognizeHandwriting())
      }
      .margin({ top: 16 })
      .justifyContent(FlexAlign.SpaceEvenly)
      .width('90%')
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#0d0d1a')
  }
}

// 绘制设置
interface DrawingSettings {
  strokeColor: string;
  strokeWidth: number;
  showGrid: boolean;
}

3.2 笔迹特征提取与书写者分析

这个模块从笔迹数据中提取客观特征,用于书写者分析和笔迹比对。

// 笔迹特征提取与书写者分析工具
class HandwritingAnalyzer {
  /**
   * 从笔迹数据中提取综合特征
   */
  static extractFeatures(strokes: Stroke[]): HandwritingFeatures {
    if (strokes.length === 0) {
      return this.emptyFeatures();
    }

    // 收集所有点
    const allPoints = strokes.flatMap(s => s.points);

    // 计算各项特征
    const speedFeatures = this.calcSpeedFeatures(strokes);
    const pressureFeatures = this.calcPressureFeatures(allPoints);
    const geometricFeatures = this.calcGeometricFeatures(strokes);
    const connectivityFeatures = this.calcConnectivityFeatures(strokes);

    return {
      // 速度特征
      avgSpeed: speedFeatures.avgSpeed,
      maxSpeed: speedFeatures.maxSpeed,
      speedVariance: speedFeatures.speedVariance,

      // 压力特征
      avgPressure: pressureFeatures.avgPressure,
      pressureVariance: pressureFeatures.pressureVariance,
      pressureRange: pressureFeatures.pressureRange,

      // 几何特征
      avgCharWidth: geometricFeatures.avgCharWidth,
      avgCharHeight: geometricFeatures.avgCharHeight,
      aspectRatio: geometricFeatures.aspectRatio,
      slantAngle: geometricFeatures.slantAngle,
      baselineStability: geometricFeatures.baselineStability,

      // 连笔特征
      connectivityRate: connectivityFeatures.connectivityRate,
      avgStrokeLength: connectivityFeatures.avgStrokeLength,

      // 综合指标
      writingFluency: this.calcWritingFluency(speedFeatures, connectivityFeatures),
      writingPressure: this.calcWritingPressure(pressureFeatures),
      writingSize: this.calcWritingSize(geometricFeatures),
    };
  }

  // 计算速度特征
  private static calcSpeedFeatures(strokes: Stroke[]): {
    avgSpeed: number;
    maxSpeed: number;
    speedVariance: number;
  } {
    const speeds: number[] = [];

    for (const stroke of strokes) {
      for (let i = 1; i < stroke.points.length; i++) {
        const prev = stroke.points[i - 1];
        const curr = stroke.points[i];

        const dx = curr.x - prev.x;
        const dy = curr.y - prev.y;
        const dt = curr.timestamp - prev.timestamp;

        if (dt > 0) {
          const distance = Math.sqrt(dx * dx + dy * dy);
          const speed = distance / dt;  // 像素/毫秒
          speeds.push(speed);
        }
      }
    }

    if (speeds.length === 0) {
      return { avgSpeed: 0, maxSpeed: 0, speedVariance: 0 };
    }

    const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
    const maxSpeed = Math.max(...speeds);
    const speedVariance = speeds.reduce((sum, s) => sum + Math.pow(s - avgSpeed, 2), 0) / speeds.length;

    return { avgSpeed, maxSpeed, speedVariance };
  }

  // 计算压力特征
  private static calcPressureFeatures(points: StrokePoint[]): {
    avgPressure: number;
    pressureVariance: number;
    pressureRange: number;
  } {
    const pressures = points.map(p => p.pressure);

    const avgPressure = pressures.reduce((a, b) => a + b, 0) / pressures.length;
    const pressureVariance = pressures.reduce((sum, p) => sum + Math.pow(p - avgPressure, 2), 0) / pressures.length;
    const pressureRange = Math.max(...pressures) - Math.min(...pressures);

    return { avgPressure, pressureVariance, pressureRange };
  }

  // 计算几何特征
  private static calcGeometricFeatures(strokes: Stroke[]): {
    avgCharWidth: number;
    avgCharHeight: number;
    aspectRatio: number;
    slantAngle: number;
    baselineStability: number;
  } {
    // 计算每个笔画的边界框
    const bounds = strokes.map(stroke => {
      const xs = stroke.points.map(p => p.x);
      const ys = stroke.points.map(p => p.y);
      return {
        minX: Math.min(...xs),
        maxX: Math.max(...xs),
        minY: Math.min(...ys),
        maxY: Math.max(...ys),
        centerY: (Math.min(...ys) + Math.max(...ys)) / 2
      };
    });

    // 平均字符尺寸
    const widths = bounds.map(b => b.maxX - b.minX);
    const heights = bounds.map(b => b.maxY - b.minY);
    const avgCharWidth = widths.reduce((a, b) => a + b, 0) / widths.length;
    const avgCharHeight = heights.reduce((a, b) => a + b, 0) / heights.length;
    const aspectRatio = avgCharHeight > 0 ? avgCharWidth / avgCharHeight : 1;

    // 倾斜角度(简化计算:基于笔画的主方向)
    let totalSlant = 0;
    let slantCount = 0;
    for (const stroke of strokes) {
      if (stroke.points.length < 2) continue;
      const first = stroke.points[0];
      const last = stroke.points[stroke.points.length - 1];
      const dx = last.x - first.x;
      const dy = last.y - first.y;
      if (Math.abs(dx) > 5) {  // 忽略太短的笔画
        totalSlant += Math.atan2(dy, dx) * 180 / Math.PI;
        slantCount++;
      }
    }
    const slantAngle = slantCount > 0 ? totalSlant / slantCount : 0;

    // 基线稳定性(Y中心的标准差)
    const centerYPixels = bounds.map(b => b.centerY);
    const avgCenterY = centerYPixels.reduce((a, b) => a + b, 0) / centerYPixels.length;
    const baselineStability = Math.sqrt(
      centerYPixels.reduce((sum, y) => sum + Math.pow(y - avgCenterY, 2), 0) / centerYPixels.length
    );

    return { avgCharWidth, avgCharHeight, aspectRatio, slantAngle, baselineStability };
  }

  // 计算连笔特征
  private static calcConnectivityFeatures(strokes: Stroke[]): {
    connectivityRate: number;
    avgStrokeLength: number;
  } {
    // 计算每个笔画的长度
    const strokeLengths = strokes.map(stroke => {
      let length = 0;
      for (let i = 1; i < stroke.points.length; i++) {
        const dx = stroke.points[i].x - stroke.points[i - 1].x;
        const dy = stroke.points[i].y - stroke.points[i - 1].y;
        length += Math.sqrt(dx * dx + dy * dy);
      }
      return length;
    });

    const avgStrokeLength = strokeLengths.reduce((a, b) => a + b, 0) / strokeLengths.length;

    // 连笔率:相邻笔画终点和起点距离很近的比例
    let connectedCount = 0;
    const connectionThreshold = avgStrokeLength * 0.3;  // 30%平均笔画长度

    for (let i = 1; i < strokes.length; i++) {
      const prevLast = strokes[i - 1].points[strokes[i - 1].points.length - 1];
      const currFirst = strokes[i].points[0];

      const dx = currFirst.x - prevLast.x;
      const dy = currFirst.y - prevLast.y;
      const distance = Math.sqrt(dx * dx + dy * dy);

      if (distance < connectionThreshold) {
        connectedCount++;
      }
    }

    const connectivityRate = strokes.length > 1 ? connectedCount / (strokes.length - 1) : 0;

    return { connectivityRate, avgStrokeLength };
  }

  // 综合指标计算
  private static calcWritingFluency(
    speed: { avgSpeed: number; speedVariance: number },
    connectivity: { connectivityRate: number }
  ): number {
    // 流畅度 = 速度归一化 × (1 - 速度方差归一化) × 连笔率
    // 结果范围 0-1
    const speedScore = Math.min(speed.avgSpeed / 2, 1);  // 2像素/ms为满分
    const stabilityScore = 1 - Math.min(speed.speedVariance / 1, 1);
    const connectScore = connectivity.connectivityRate;

    return (speedScore * 0.4 + stabilityScore * 0.3 + connectScore * 0.3);
  }

  private static calcWritingPressure(pressure: { avgPressure: number }): string {
    if (pressure.avgPressure < 0.3) return '轻';
    if (pressure.avgPressure > 0.7) return '重';
    return '适中';
  }

  private static calcWritingSize(geometric: { avgCharHeight: number }): string {
    if (geometric.avgCharHeight < 20) return '小';
    if (geometric.avgCharHeight > 50) return '大';
    return '适中';
  }

  private static emptyFeatures(): HandwritingFeatures {
    return {
      avgSpeed: 0, maxSpeed: 0, speedVariance: 0,
      avgPressure: 0, pressureVariance: 0, pressureRange: 0,
      avgCharWidth: 0, avgCharHeight: 0, aspectRatio: 0,
      slantAngle: 0, baselineStability: 0,
      connectivityRate: 0, avgStrokeLength: 0,
      writingFluency: 0, writingPressure: '未知', writingSize: '未知'
    };
  }

  /**
   * 比较两组笔迹的相似度
   * 用于书写者验证
   */
  static compareHandwriting(features1: HandwritingFeatures, features2: HandwritingFeatures): {
    similarity: number;
    isSameWriter: boolean;
  } {
    // 特征权重
    const weights = {
      speed: 0.15,
      pressure: 0.15,
      size: 0.20,
      slant: 0.20,
      connectivity: 0.15,
      fluency: 0.15
    };

    // 计算各维度的相似度
    const speedSim = this.normalizeSimilarity(features1.avgSpeed, features2.avgSpeed, 2);
    const pressureSim = this.normalizeSimilarity(features1.avgPressure, features2.avgPressure, 1);
    const sizeSim = this.normalizeSimilarity(features1.avgCharHeight, features2.avgCharHeight, 100);
    const slantSim = 1 - Math.min(Math.abs(features1.slantAngle - features2.slantAngle) / 45, 1);
    const connectSim = 1 - Math.abs(features1.connectivityRate - features2.connectivityRate);
    const fluencySim = 1 - Math.abs(features1.writingFluency - features2.writingFluency);

    // 加权综合相似度
    const similarity = 
      speedSim * weights.speed +
      pressureSim * weights.pressure +
      sizeSim * weights.size +
      slantSim * weights.slant +
      connectSim * weights.connectivity +
      fluencySim * weights.fluency;

    // 阈值判断
    const threshold = 0.65;
    const isSameWriter = similarity >= threshold;

    return { similarity, isSameWriter };
  }

  // 归一化相似度计算
  private static normalizeSimilarity(v1: number, v2: number, range: number): number {
    return 1 - Math.min(Math.abs(v1 - v2) / range, 1);
  }
}

// 笔迹特征数据结构
interface HandwritingFeatures {
  // 速度特征
  avgSpeed: number;
  maxSpeed: number;
  speedVariance: number;

  // 压力特征
  avgPressure: number;
  pressureVariance: number;
  pressureRange: number;

  // 几何特征
  avgCharWidth: number;
  avgCharHeight: number;
  aspectRatio: number;
  slantAngle: number;
  baselineStability: number;

  // 连笔特征
  connectivityRate: number;
  avgStrokeLength: number;

  // 综合指标
  writingFluency: number;    // 0-1,越高越流畅
  writingPressure: string;   // 轻/适中/重
  writingSize: string;       // 小/适中/大
}

3.3 离线手写识别:图片手写体OCR

对于已有的手写文字图片(如拍照的笔记、扫描的表单),我们需要使用离线手写识别。

// 离线手写识别服务
import { textRecognition } from '@kit.AI.Intelligent';
import { image } from '@kit.ImageKit';
import { picker } from '@kit.CoreFileKit';

interface OfflineHwrResult {
  text: string;
  blocks: HandwrittenBlock[];
  overallQuality: WritingQuality;
  processingTime: number;
}

interface HandwrittenBlock {
  text: string;
  confidence: number;
  bounds: Rect;
  isHandwritten: boolean;  // 是否为手写体(vs印刷体)
}

enum WritingQuality {
  EXCELLENT = '优秀',
  GOOD = '良好',
  FAIR = '一般',
  POOR = '较差'
}

@Entry
@Component
struct OfflineHwrPage {
  @State result: OfflineHwrResult | null = null;
  @State isProcessing: boolean = false;
  @State imageUri: string = '';

  // 选择图片并识别
  async selectAndRecognize(): Promise<void> {
    this.isProcessing = true;
    const startTime = Date.now();

    try {
      const photoSelectOptions = new picker.PhotoSelectOptions();
      photoSelectOptions.MIMEType = picker.PhotoViewMIMEType.IMAGE_TYPE;
      photoSelectOptions.maxSelectNumber = 1;

      const photoViewPicker = new picker.PhotoViewPicker();
      const selectResult = await photoViewPicker.select(photoSelectOptions);

      if (selectResult.photoUris.length === 0) {
        this.isProcessing = false;
        return;
      }

      this.imageUri = selectResult.photoUris[0];

      // 创建图片源
      const imageSource = image.createImageSource(this.imageUri);
      const pixelMap = await imageSource.createPixelMap();

      // 使用PRECISE模式进行手写识别
      const engine = textRecognition.TextRecognitionEngine.create(
        textRecognition.TextRecognitionPreset.PRECISE
      );

      const config: textRecognition.TextRecognitionConfig = {
        isTextDetectionEnabled: true,
        isDirectionDetectionEnabled: true
      };

      const rawResult = await engine.recognizeText(pixelMap, config);

      // 解析结果
      const blocks: HandwrittenBlock[] = [];
      const textParts: string[] = [];
      let totalConfidence = 0;

      for (const block of rawResult.textBlocks) {
        // 判断是否为手写体(基于置信度和文本特征)
        const isHandwritten = this.isLikelyHandwritten(block.textValue, block.confidence);

        blocks.push({
          text: block.textValue,
          confidence: block.confidence,
          bounds: this.extractBounds(block),
          isHandwritten
        });

        textParts.push(block.textValue);
        totalConfidence += block.confidence;
      }

      // 评估书写质量
      const avgConfidence = blocks.length > 0 ? totalConfidence / blocks.length : 0;
      const overallQuality = this.assessWritingQuality(avgConfidence, blocks);

      this.result = {
        text: textParts.join('\n'),
        blocks,
        overallQuality,
        processingTime: Date.now() - startTime
      };

      engine.close();
    } catch (error) {
      console.error(`识别失败: ${JSON.stringify(error)}`);
    } finally {
      this.isProcessing = false;
    }
  }

  // 判断文本是否可能为手写体
  private isLikelyHandwritten(text: string, confidence: number): boolean {
    // 手写体的识别置信度通常低于印刷体
    // 这是一个简化的启发式判断
    if (confidence < 0.7) return true;

    // 手写体通常有更多的字符变体
    // 这里可以加入更多启发式规则
    return false;
  }

  // 评估书写质量
  private assessWritingQuality(avgConfidence: number, blocks: HandwrittenBlock[]): WritingQuality {
    if (avgConfidence >= 0.9) return WritingQuality.EXCELLENT;
    if (avgConfidence >= 0.75) return WritingQuality.GOOD;
    if (avgConfidence >= 0.6) return WritingQuality.FAIR;
    return WritingQuality.POOR;
  }

  // 提取边界矩形
  private extractBounds(block: textRecognition.TextBlock): Rect {
    const corners = block.corners;
    if (!corners || corners.length < 4) {
      return { left: 0, top: 0, width: 0, height: 0 };
    }
    const xCoords = corners.map(c => c.x);
    const yCoords = corners.map(c => c.y);
    return {
      left: Math.min(...xCoords),
      top: Math.min(...yCoords),
      width: Math.max(...xCoords) - Math.min(...xCoords),
      height: Math.max(...yCoords) - Math.min(...yCoords)
    };
  }

  build() {
    Scroll() {
      Column() {
        Text('离线手写识别')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor('#e0e0e0')
          .margin({ bottom: 20 })

        // 图片预览
        if (this.imageUri) {
          Image(this.imageUri)
            .width('90%')
            .height(200)
            .objectFit(ImageFit.Contain)
            .borderRadius(12)
            .margin({ bottom: 16 })
        }

        // 识别按钮
        Button(this.isProcessing ? '识别中...' : '选择手写图片')
          .width('90%')
          .height(48)
          .backgroundColor('#4FC3F7')
          .fontColor('#000')
          .enabled(!this.isProcessing)
          .onClick(() => this.selectAndRecognize())

        // 识别结果
        if (this.result) {
          // 质量评估
          Row() {
            Text('书写质量:')
              .fontSize(14)
              .fontColor('#888')
            Text(this.result.overallQuality)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.getQualityColor(this.result.overallQuality))
          }
          .padding(12)
          .backgroundColor('#1a1a2e')
          .borderRadius(8)
          .margin({ top: 16 })

          // 识别文本
          Scroll() {
            Text(this.result.text)
              .fontSize(16)
              .fontColor('#e0e0e0')
              .lineHeight(26)
              .padding(16)
              .backgroundColor('#1a1a2e')
              .borderRadius(12)
              .margin({ top: 12 })
          }
          .layoutWeight(1)

          // 详细块信息
          List() {
            ForEach(this.result.blocks, (block: HandwrittenBlock, index: number) => {
              ListItem() {
                Row() {
                  Text(block.isHandwritten ? '✍️' : '🖨️')
                    .fontSize(14)
                  Text(block.text)
                    .fontSize(14)
                    .fontColor('#e0e0e0')
                    .layoutWeight(1)
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(`${(block.confidence * 100).toFixed(0)}%`)
                    .fontSize(12)
                    .fontColor(block.confidence > 0.7 ? '#81C784' : '#FFB74D')
                }
                .padding(8)
                .backgroundColor('#1a1a2e')
                .borderRadius(6)
                .margin({ bottom: 4 })
              }
            }, (block: HandwrittenBlock, index: number) => `${index}`)
          }
          .width('100%')
          .height(200)
          .margin({ top: 12 })
        }
      }
      .width('100%')
      .padding(16)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0d0d1a')
  }

  private getQualityColor(quality: WritingQuality): string {
    switch (quality) {
      case WritingQuality.EXCELLENT: return '#81C784';
      case WritingQuality.GOOD: return '#4FC3F7';
      case WritingQuality.FAIR: return '#FFB74D';
      case WritingQuality.POOR: return '#EF5350';
    }
  }
}

interface Rect {
  left: number;
  top: number;
  width: number;
  height: number;
}

四、踩坑与注意事项

4.1 在线手写识别的笔迹采集精度

在线手写识别的精度,很大程度上取决于笔迹采集的质量。常见问题:

  • 采样率不足:如果触摸事件的采样率太低(比如低于60Hz),快速书写时笔迹会出现锯齿,影响识别精度
  • 坐标抖动:手指在屏幕上停留时,坐标会有微小的随机抖动,需要做平滑处理
  • 压力数据缺失:部分设备的触摸屏不支持压力检测,touch.force始终为0或固定值

解决方案

// 笔迹平滑处理:使用移动平均滤波
function smoothStroke(points: StrokePoint[], windowSize: number = 3): StrokePoint[] {
  if (points.length <= windowSize) return points;

  const smoothed: StrokePoint[] = [];

  for (let i = 0; i < points.length; i++) {
    const start = Math.max(0, i - Math.floor(windowSize / 2));
    const end = Math.min(points.length, i + Math.ceil(windowSize / 2));
    const window = points.slice(start, end);

    const avgX = window.reduce((sum, p) => sum + p.x, 0) / window.length;
    const avgY = window.reduce((sum, p) => sum + p.y, 0) / window.length;

    smoothed.push({
      x: avgX,
      y: avgY,
      timestamp: points[i].timestamp,
      pressure: points[i].pressure
    });
  }

  return smoothed;
}

4.2 手写识别的"草书陷阱"

草书(连笔字)是手写识别最大的敌人。当多个笔画连在一起时,字符之间的分割变得极其困难,识别精度会大幅下降。

应对策略

  • 在手写板UI中引导用户"一笔一划"书写,减少连笔
  • 对于中文,建议用户按标准笔顺书写,不要倒笔画
  • 如果识别结果不理想,提供候选列表让用户选择

4.3 离线手写识别的背景干扰

手写文字通常出现在有背景纹理的介质上——横线纸、方格纸、信纸等。这些背景线条会严重干扰文字检测。

解决方案

  • 在图像预处理阶段,尝试去除背景线条
  • 可以使用形态学操作(开运算/闭运算)来分离文字和背景
  • 如果背景过于复杂,建议用户在白纸上重新书写

4.4 手写识别的字符集限制

HarmonyOS的手写识别引擎支持的字符集可能比通用OCR更有限。特别是一些生僻字、异体字、特殊符号,手写识别可能不支持。

应对策略

  • 对于生僻字,可以提供"手写+拼音"的混合输入方式
  • 如果识别失败,提供候选列表和手动输入的备选方案
  • 在应用文档中明确说明支持的字符范围

4.5 笔迹分析的法律与伦理问题

笔迹分析(尤其是书写者识别)涉及敏感的个人生物特征信息,在使用时需要注意:

  • 不得作为唯一认证手段:笔迹特征的唯一性远低于指纹、虹膜等生物特征,不能作为身份认证的唯一依据
  • 用户知情同意:采集笔迹数据前必须告知用户用途
  • 数据保护:笔迹特征数据应加密存储,不得泄露
  • 避免过度解读:笔迹与性格、情绪之间的关联缺乏科学共识,不应在产品中做过度宣传

五、HarmonyOS 6适配

5.1 API变更

变更项 API 12/13 API 14
手写识别引擎 HandwritingRecognitionEngine.create() 新增createAsync()
支持语言 中文、英文 新增日文、韩文手写识别
笔迹输入 坐标+时间戳 新增压力和倾斜角支持
识别模式 COMMON 新增CALLIGRAPHY(书法模式)

5.2 书法模式

HarmonyOS 6新增了CALLIGRAPHY模式,专门优化了毛笔书法风格的识别:

// API 14 新增:书法模式手写识别
const engine = handwritingRecognition.HandwritingRecognitionEngine.create(
  handwritingRecognition.HandwritingRecognitionPreset.CALLIGRAPHY
);

书法模式的特点:

  • 针对毛笔笔画的粗细变化做了优化
  • 支持识别楷书、行书、草书等不同书法风格
  • 对笔画顺序的容错性更强

5.3 压力和倾斜角支持

API 14的触摸事件新增了更丰富的笔迹信息:

// API 14 新增笔迹数据字段
interface EnhancedStrokePoint {
  x: number;
  y: number;
  timestamp: number;
  pressure: number;      // 压力值
  tiltX: number;         // X方向倾斜角(度)
  tiltY: number;         // Y方向倾斜角(度)
  azimuth: number;       // 方位角(度)
}

这些额外信息对于笔迹分析和书写者识别非常有价值。

5.4 迁移建议

  • 如果你的应用面向书法爱好者或教育场景,升级到HarmonyOS 6后可以启用CALLIGRAPHY模式
  • 利用新增的压力和倾斜角数据,可以做更精细的笔迹分析
  • 异步引擎创建可以避免主线程卡顿

六、总结

mindmap
  root((手写识别))
    在线手写识别
      笔迹实时采集
      坐标+时间戳+压力
      序列模型推理
      实时反馈
    离线手写识别
      图片手写体OCR
      PRECISE模式
      背景干扰处理
      书写质量评估
    笔迹特征提取
      速度特征
      压力特征
      几何特征
      连笔特征
    书写者分析
      笔迹相似度比较
      书写流畅度评估
      综合特征匹配
    注意事项
      草书识别困难
      背景干扰处理
      字符集限制
      法律伦理问题
    HarmonyOS 6
      CALLIGRAPHY书法模式
      日韩文手写识别
      压力倾斜角支持
      异步引擎创建

核心知识点回顾

  1. 两种路线各有优势:在线手写识别精度高(有时序信息),离线手写识别适用广(不需要专用输入设备),根据场景选择。
  2. 笔迹数据是金矿:在线手写识别采集的坐标、时间戳、压力数据,不仅可以用于文字识别,还可以做笔迹特征提取和书写者分析。
  3. 草书是最大敌人:连笔字是手写识别精度下降的首要原因,在UI设计中应引导用户减少连笔。
  4. 背景干扰需预处理:离线手写识别前,应尽可能去除背景纹理干扰,提升识别精度。
  5. 笔迹分析需谨慎:笔迹特征可以用于辅助身份验证,但不能作为唯一认证手段;笔迹与性格的关联缺乏科学共识,避免过度解读。
  6. HarmonyOS 6的书法模式:新增的CALLIGRAPHY模式为书法教育和文化传承提供了技术支持,是一个值得关注的新特性。

至此,我们的「文字识别」系列5篇文章全部完成。从OCR核心技术原理,到身份证、银行卡专项识别,到通用多语言识别,再到手写识别与笔迹分析——我们完整覆盖了HarmonyOS文字识别服务的主要能力。希望这个系列能帮助你在实际项目中快速落地OCR功能,也期待看到更多基于HarmonyOS文字识别能力的创新应用!

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。