在 iOS 系统中使用机器学习套件识别数字手写内容

借助机器学习套件的数字手写识别功能,您可以识别数字平面上数百种语言的手写文本,还可以对草图进行分类。

试试看

准备工作

  1. 在 Podfile 中添加以下机器学习套件库:

    pod 'GoogleMLKit/DigitalInkRecognition', '8.0.0'
    
    
  2. 安装或更新项目的 Pod 之后,请使用 Xcode 项目的 .xcworkspace 来打开项目。Xcode 13.2.1 版或更高版本支持机器学习套件。

现在,您已准备好开始识别 Ink 对象中的文本。

构建 Ink 对象

构建 Ink 对象的主要方法是在触摸屏上绘制。在 iOS 上, 您可以使用 UIImageView 以及 触摸事件 处理程序 ,这些处理程序会在屏幕上绘制笔划,还会存储笔划的点以构建 Ink 对象。以下代码段演示了这种通用模式。如需查看更完整的示例,请参阅快速入门 应用,该示例将触摸事件处理、屏幕绘制、 和笔划数据管理分开。

Swift

@IBOutlet weak var mainImageView: UIImageView!
var kMillisecondsPerTimeInterval = 1000.0
var lastPoint = CGPoint.zero
private var strokes: [Stroke] = []
private var points: [StrokePoint] = []

func drawLine(from fromPoint: CGPoint, to toPoint: CGPoint) {
  UIGraphicsBeginImageContext(view.frame.size)
  guard let context = UIGraphicsGetCurrentContext() else {
    return
  }
  mainImageView.image?.draw(in: view.bounds)
  context.move(to: fromPoint)
  context.addLine(to: toPoint)
  context.setLineCap(.round)
  context.setBlendMode(.normal)
  context.setLineWidth(10.0)
  context.setStrokeColor(UIColor.white.cgColor)
  context.strokePath()
  mainImageView.image = UIGraphicsGetImageFromCurrentImageContext()
  mainImageView.alpha = 1.0
  UIGraphicsEndImageContext()
}

override func touchesBegan(_ touches: Set, with event: UIEvent?) {
  guard let touch = touches.first else {
    return
  }
  lastPoint = touch.location(in: mainImageView)
  let t = touch.timestamp
  points = [StrokePoint.init(x: Float(lastPoint.x),
                             y: Float(lastPoint.y),
                             t: Int(t * kMillisecondsPerTimeInterval))]
  drawLine(from:lastPoint, to:lastPoint)
}

override func touchesMoved(_ touches: Set, with event: UIEvent?) {
  guard let touch = touches.first else {
    return
  }
  let currentPoint = touch.location(in: mainImageView)
  let t = touch.timestamp
  points.append(StrokePoint.init(x: Float(currentPoint.x),
                                 y: Float(currentPoint.y),
                                 t: Int(t * kMillisecondsPerTimeInterval)))
  drawLine(from: lastPoint, to: currentPoint)
  lastPoint = currentPoint
}

override func touchesEnded(_ touches: Set, with event: UIEvent?) {
  guard let touch = touches.first else {
    return
  }
  let currentPoint = touch.location(in: mainImageView)
  let t = touch.timestamp
  points.append(StrokePoint.init(x: Float(currentPoint.x),
                                 y: Float(currentPoint.y),
                                 t: Int(t * kMillisecondsPerTimeInterval)))
  drawLine(from: lastPoint, to: currentPoint)
  lastPoint = currentPoint
  strokes.append(Stroke.init(points: points))
  self.points = []
  doRecognition()
}

Objective-C

// Interface
@property (weak, nonatomic) IBOutlet UIImageView *mainImageView;
@property(nonatomic) CGPoint lastPoint;
@property(nonatomic) NSMutableArray *strokes;
@property(nonatomic) NSMutableArray *points;

// Implementations
static const double kMillisecondsPerTimeInterval = 1000.0;

- (void)drawLineFrom:(CGPoint)fromPoint to:(CGPoint)toPoint {
  UIGraphicsBeginImageContext(self.mainImageView.frame.size);
  [self.mainImageView.image drawInRect:CGRectMake(0, 0, self.mainImageView.frame.size.width,
                                                  self.mainImageView.frame.size.height)];
  CGContextMoveToPoint(UIGraphicsGetCurrentContext(), fromPoint.x, fromPoint.y);
  CGContextAddLineToPoint(UIGraphicsGetCurrentContext(), toPoint.x, toPoint.y);
  CGContextSetLineCap(UIGraphicsGetCurrentContext(), kCGLineCapRound);
  CGContextSetLineWidth(UIGraphicsGetCurrentContext(), 10.0);
  CGContextSetRGBStrokeColor(UIGraphicsGetCurrentContext(), 1, 1, 1, 1);
  CGContextSetBlendMode(UIGraphicsGetCurrentContext(), kCGBlendModeNormal);
  CGContextStrokePath(UIGraphicsGetCurrentContext());
  CGContextFlush(UIGraphicsGetCurrentContext());
  self.mainImageView.image = UIGraphicsGetImageFromCurrentImageContext();
  UIGraphicsEndImageContext();
}

- (void)touchesBegan:(NSSet *)touches withEvent:(nullable UIEvent *)event {
  UITouch *touch = [touches anyObject];
  self.lastPoint = [touch locationInView:self.mainImageView];
  NSTimeInterval time = [touch timestamp];
  self.points = [NSMutableArray array];
  [self.points addObject:[[MLKStrokePoint alloc] initWithX:self.lastPoint.x
                                                         y:self.lastPoint.y
                                                         t:time * kMillisecondsPerTimeInterval]];
  [self drawLineFrom:self.lastPoint to:self.lastPoint];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(nullable UIEvent *)event {
  UITouch *touch = [touches anyObject];
  CGPoint currentPoint = [touch locationInView:self.mainImageView];
  NSTimeInterval time = [touch timestamp];
  [self.points addObject:[[MLKStrokePoint alloc] initWithX:currentPoint.x
                                                         y:currentPoint.y
                                                         t:time * kMillisecondsPerTimeInterval]];
  [self drawLineFrom:self.lastPoint to:currentPoint];
  self.lastPoint = currentPoint;
}

- (void)touchesEnded:(NSSet *)touches withEvent:(nullable UIEvent *)event {
  UITouch *touch = [touches anyObject];
  CGPoint currentPoint = [touch locationInView:self.mainImageView];
  NSTimeInterval time = [touch timestamp];
  [self.points addObject:[[MLKStrokePoint alloc] initWithX:currentPoint.x
                                                         y:currentPoint.y
                                                         t:time * kMillisecondsPerTimeInterval]];
  [self drawLineFrom:self.lastPoint to:currentPoint];
  self.lastPoint = currentPoint;
  if (self.strokes == nil) {
    self.strokes = [NSMutableArray array];
  }
  [self.strokes addObject:[[MLKStroke alloc] initWithPoints:self.points]];
  self.points = nil;
  [self doRecognition];
}

请注意,该代码段包含一个示例函数,用于将笔画绘制到 UIImageView中 ,您应根据需要针对自己的应用进行调整。我们建议您在绘制线段时使用圆端,以便将零长度线段绘制为点(想想小写字母 i 上的点)。系统会在每次写入笔划后调用 doRecognition() 函数,该函数将在下文定义。

获取 DigitalInkRecognizer 的实例

如需执行识别,我们需要将 Ink 对象传递给 DigitalInkRecognizer 实例。如需获取 DigitalInkRecognizer 实例,我们首先需要下载所需语言的识别器模型,并将该模型加载到 RAM 中。这可以使用以下代码段完成,为简单起见,该代码段放置在 viewDidLoad() 方法中,并使用硬编码的语言名称。如需查看如何向用户显示可用语言列表并下载所选语言的示例,请参阅快速入门 应用

Swift

override func viewDidLoad() {
  super.viewDidLoad()
  let languageTag = "en-US"
  let identifier = DigitalInkRecognitionModelIdentifier(forLanguageTag: languageTag)
  if identifier == nil {
    // no model was found or the language tag couldn't be parsed, handle error.
  }
  let model = DigitalInkRecognitionModel.init(modelIdentifier: identifier!)
  let modelManager = ModelManager.modelManager()
  let conditions = ModelDownloadConditions.init(allowsCellularAccess: true,
                                         allowsBackgroundDownloading: true)
  modelManager.download(model, conditions: conditions)
  // Get a recognizer for the language
  let options: DigitalInkRecognizerOptions = DigitalInkRecognizerOptions.init(model: model)
  recognizer = DigitalInkRecognizer.digitalInkRecognizer(options: options)
}

Objective-C

- (void)viewDidLoad {
  [super viewDidLoad];
  NSString *languagetag = @"en-US";
  MLKDigitalInkRecognitionModelIdentifier *identifier =
      [MLKDigitalInkRecognitionModelIdentifier modelIdentifierForLanguageTag:languagetag];
  if (identifier == nil) {
    // no model was found or the language tag couldn't be parsed, handle error.
  }
  MLKDigitalInkRecognitionModel *model = [[MLKDigitalInkRecognitionModel alloc]
                                          initWithModelIdentifier:identifier];
  MLKModelManager *modelManager = [MLKModelManager modelManager];
  [modelManager downloadModel:model conditions:[[MLKModelDownloadConditions alloc]
                                                initWithAllowsCellularAccess:YES
                                                allowsBackgroundDownloading:YES]];
  MLKDigitalInkRecognizerOptions *options =
      [[MLKDigitalInkRecognizerOptions alloc] initWithModel:model];
  self.recognizer = [MLKDigitalInkRecognizer digitalInkRecognizerWithOptions:options];
}

快速入门应用包含其他代码,这些代码展示了如何同时处理多个下载,以及如何通过处理完成通知来确定哪个下载成功。

识别 Ink 对象

接下来,我们来看一下 doRecognition() 函数,为简单起见,该函数是从 touchesEnded() 调用的。在其他应用中,您可能希望仅在超时后或用户按下按钮以触发识别时调用识别。

Swift

func doRecognition() {
  let ink = Ink.init(strokes: strokes)
  recognizer.recognize(
    ink: ink,
    completion: {
      [unowned self]
      (result: DigitalInkRecognitionResult?, error: Error?) in
      var alertTitle = ""
      var alertText = ""
      if let result = result, let candidate = result.candidates.first {
        alertTitle = "I recognized this:"
        alertText = candidate.text
      } else {
        alertTitle = "I hit an error:"
        alertText = error!.localizedDescription
      }
      let alert = UIAlertController(title: alertTitle,
                                  message: alertText,
                           preferredStyle: UIAlertController.Style.alert)
      alert.addAction(UIAlertAction(title: "OK",
                                    style: UIAlertAction.Style.default,
                                  handler: nil))
      self.present(alert, animated: true, completion: nil)
    }
  )
}

Objective-C

- (void)doRecognition {
  MLKInk *ink = [[MLKInk alloc] initWithStrokes:self.strokes];
  __weak typeof(self) weakSelf = self;
  [self.recognizer
      recognizeInk:ink
        completion:^(MLKDigitalInkRecognitionResult *_Nullable result,
                     NSError *_Nullable error) {
    typeof(weakSelf) strongSelf = weakSelf;
    if (strongSelf == nil) {
      return;
    }
    NSString *alertTitle = nil;
    NSString *alertText = nil;
    if (result.candidates.count > 0) {
      alertTitle = @"I recognized this:";
      alertText = result.candidates[0].text;
    } else {
      alertTitle = @"I hit an error:";
      alertText = [error localizedDescription];
    }
    UIAlertController *alert =
        [UIAlertController alertControllerWithTitle:alertTitle
                                            message:alertText
                                     preferredStyle:UIAlertControllerStyleAlert];
    [alert addAction:[UIAlertAction actionWithTitle:@"OK"
                                              style:UIAlertActionStyleDefault
                                            handler:nil]];
    [strongSelf presentViewController:alert animated:YES completion:nil];
  }];
}

管理模型下载

我们已经了解了如何下载识别模型。以下代码段说明了如何检查模型是否已下载,或者在不再需要模型时如何删除模型以回收存储空间。

检查模型是否已下载

Swift

let model : DigitalInkRecognitionModel = ...
let modelManager = ModelManager.modelManager()
modelManager.isModelDownloaded(model)

Objective-C

MLKDigitalInkRecognitionModel *model = ...;
MLKModelManager *modelManager = [MLKModelManager modelManager];
[modelManager isModelDownloaded:model];

删除已下载的模型

Swift

let model : DigitalInkRecognitionModel = ...
let modelManager = ModelManager.modelManager()

if modelManager.isModelDownloaded(model) {
  modelManager.deleteDownloadedModel(
    model!,
    completion: {
      error in
      if error != nil {
        // Handle error
        return
      }
      NSLog(@"Model deleted.");
    })
}

Objective-C

MLKDigitalInkRecognitionModel *model = ...;
MLKModelManager *modelManager = [MLKModelManager modelManager];

if ([self.modelManager isModelDownloaded:model]) {
  [self.modelManager deleteDownloadedModel:model
                                completion:^(NSError *_Nullable error) {
                                  if (error) {
                                    // Handle error.
                                    return;
                                  }
                                  NSLog(@"Model deleted.");
                                }];
}

提高文字识别准确率的提示

文字识别的准确率因语言而异。准确率还取决于书写风格。虽然数字手写识别功能经过训练可以处理多种书写风格,但结果可能因用户而异。

以下是一些提高文字识别器准确率的方法。请注意,这些技巧不适用于表情符号、自动绘制和形状的绘制分类器。

书写区域

许多应用都有明确定义的用户输入书写区域。 符号的含义部分取决于其相对于包含该符号的书写区域的大小。 例如,小写或大写字母“o”或“c”与逗号和正斜杠之间的区别。

告知识别器书写区域的宽度和高度可以提高准确率。不过,识别器假定书写区域仅包含一行文本。如果物理书写区域足够大,允许用户书写两行或更多行,那么您可以传入一个 WritingArea,其高度是您对单行文本高度的最佳估计值,这样可能会获得更好的结果。您传递给识别器的 WritingArea 对象不必与屏幕上的物理书写区域完全对应。以这种方式更改 WritingArea 高度在某些语言中的效果比其他语言更好。

指定书写区域时,请以与笔划坐标相同的单位指定其宽度和高度。x,y 坐标实参没有单位要求 - API 会对所有单位进行归一化,因此唯一重要的是笔划的相对大小和位置。您可以自由地以适合您系统的任何比例传入坐标。

前文

前文是指您尝试识别的 Ink 中紧接在笔划之前的文本。您可以告知识别器前文,以帮助识别器。

例如,草书字母“n”和“u”经常被误认为彼此。如果用户已输入部分字词“arg”,他们可能会继续输入可识别为“ument”或“nment”的笔划。指定前文“arg”可消除歧义,因为“argument”一词比“argnment”更常见。

前文还可以帮助识别器识别字词分隔符(字词之间的空格)。您可以输入空格字符,但无法绘制空格字符,那么识别器如何确定一个字词何时结束以及下一个字词何时开始?如果用户已书写“hello”并继续书写“world”,则在没有前文的情况下,识别器会返回字符串“world”。不过,如果您指定前文“hello”,则模型将返回字符串“ world”(带前导空格),因为“hello world”比“helloword”更有意义。

您应提供尽可能长的前文字符串,最多 20 个字符,包括空格。如果字符串更长,识别器只会使用最后 20 个字符。

以下代码示例展示了如何定义书写区域以及如何使用 RecognitionContext 对象指定前文。

Swift

let ink: Ink = ...;
let recognizer: DigitalInkRecognizer =  ...;
let preContext: String = ...;
let writingArea = WritingArea.init(width: ..., height: ...);

let context: DigitalInkRecognitionContext.init(
    preContext: preContext,
    writingArea: writingArea);

recognizer.recognizeHandwriting(
  from: ink,
  context: context,
  completion: {
    (result: DigitalInkRecognitionResult?, error: Error?) in
    if let result = result, let candidate = result.candidates.first {
      NSLog("Recognized \(candidate.text)")
    } else {
      NSLog("Recognition error \(error)")
    }
  })

Objective-C

MLKInk *ink = ...;
MLKDigitalInkRecognizer *recognizer = ...;
NSString *preContext = ...;
MLKWritingArea *writingArea = [MLKWritingArea initWithWidth:...
                                              height:...];

MLKDigitalInkRecognitionContext *context = [MLKDigitalInkRecognitionContext
       initWithPreContext:preContext
       writingArea:writingArea];

[recognizer recognizeHandwritingFromInk:ink
            context:context
            completion:^(MLKDigitalInkRecognitionResult
                         *_Nullable result, NSError *_Nullable error) {
                               NSLog(@"Recognition result %@",
                                     result.candidates[0].text);
                         }];

笔划顺序

识别准确率对笔划顺序很敏感。识别器希望笔划按照人们自然书写的顺序出现;例如,英语从左到右。任何偏离此模式的情况(例如,从最后一个字词开始书写英语句子)都会导致结果不太准确。

另一个示例是,当 Ink 中间的字词被移除并替换为另一个字词时。修订可能位于句子的中间,但修订的笔划位于笔划序列的末尾。在这种情况下,我们建议您将新书写的字词单独发送给 API,并使用您自己的逻辑将结果与之前的识别结果合并。

处理含义不明确的形状

在某些情况下,提供给识别器的形状的含义不明确。例如,边缘非常圆润的矩形可以被视为矩形或椭圆形。

如果识别分数可用,则可以使用识别分数来处理这些不明确的情况。只有形状分类器提供分数。如果模型非常自信,则最佳结果的分数将远高于次佳结果。如果存在不确定性,则最佳结果和次佳结果的分数将接近。此外,请注意,形状分类器会将整个 Ink 解释为单个形状。例如,如果 Ink 包含一个矩形和一个彼此相邻的椭圆形,则识别器可能会返回其中一个(或完全不同的内容)作为结果,因为单个识别候选对象无法表示两个形状。