HarmonyOS APP开发:手写文字识别与笔迹分析
HarmonyOS APP开发:手写文字识别与笔迹分析
核心要点:深入掌握HarmonyOS手写文字识别的技术原理与实战方案,包括在线手写识别(笔迹实时采集与识别)和离线手写识别(图片手写体OCR),实现笔迹特征提取与书写者分析,以及手写笔记数字化、手写公式识别等进阶应用。
一、背景与动机
“你写的字,只有你自己看得懂。”
这句话,大概每个人都听过。手写文字的多样性、随意性、个性化,使得手写识别成为OCR领域中最具挑战性的问题之一。同样是写一个"永"字,一千个人能写出一千种样子——有人写得龙飞凤舞,有人写得歪歪扭扭,有人连笔带草,有人一笔一划。
但手写识别的需求又是真实而迫切的:
- 教育场景:老师批改作业、学生做笔记,都需要将手写内容数字化
- 签批场景:领导在文件上签字批示,需要将签批内容提取为可检索文本
- 笔记场景:手写笔记APP需要将手写内容转为可编辑文本
- 表单场景:快递单、保险单、申请表上的手写信息需要自动录入
- 无障碍场景:帮助视障用户理解手写内容
HarmonyOS为手写识别提供了两种不同的技术路线:
- 在线手写识别(Online HWR):通过触摸屏实时采集笔迹数据(坐标序列+时间戳),在书写过程中即时识别
- 离线手写识别(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)面临的核心挑战:
- 书写风格差异大:每个人的书写习惯不同,字体大小、倾斜角度、连笔程度千差万别
- 背景干扰:手写文字通常出现在信纸、笔记本、表格等有背景纹理的介质上
- 墨迹质量不一:有些字迹清晰,有些模糊,有些甚至有涂改
- 排版不规范:手写文字的行距、字距、对齐方式都不像印刷体那样规整
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书法模式
日韩文手写识别
压力倾斜角支持
异步引擎创建
核心知识点回顾:
- 两种路线各有优势:在线手写识别精度高(有时序信息),离线手写识别适用广(不需要专用输入设备),根据场景选择。
- 笔迹数据是金矿:在线手写识别采集的坐标、时间戳、压力数据,不仅可以用于文字识别,还可以做笔迹特征提取和书写者分析。
- 草书是最大敌人:连笔字是手写识别精度下降的首要原因,在UI设计中应引导用户减少连笔。
- 背景干扰需预处理:离线手写识别前,应尽可能去除背景纹理干扰,提升识别精度。
- 笔迹分析需谨慎:笔迹特征可以用于辅助身份验证,但不能作为唯一认证手段;笔迹与性格的关联缺乏科学共识,避免过度解读。
- HarmonyOS 6的书法模式:新增的CALLIGRAPHY模式为书法教育和文化传承提供了技术支持,是一个值得关注的新特性。
至此,我们的「文字识别」系列5篇文章全部完成。从OCR核心技术原理,到身份证、银行卡专项识别,到通用多语言识别,再到手写识别与笔迹分析——我们完整覆盖了HarmonyOS文字识别服务的主要能力。希望这个系列能帮助你在实际项目中快速落地OCR功能,也期待看到更多基于HarmonyOS文字识别能力的创新应用!
- 点赞
- 收藏
- 关注作者
评论(0)