זיהוי דיו דיגיטלי באמצעות ערכת ML ב-iOS

בעזרת התכונה 'זיהוי דיו דיגיטלי' של ML Kit, אפשר לזהות טקסט בכתב יד על משטח דיגיטלי במאות שפות, וגם לסווג סקיצות.

רוצה לנסות?

לפני שמתחילים

  1. צריך לכלול את הספריות הבאות של ML Kit ב-Podfile:

    pod 'GoogleMLKit/DigitalInkRecognition', '8.0.0'
    
    
  2. אחרי שמתקינים או מעדכנים את ה-Pods של הפרויקט, פותחים את פרויקט Xcode באמצעות .xcworkspace. ‫ML Kit נתמך ב-Xcode בגרסה 13.2.1 ומעלה.

עכשיו אפשר להתחיל לזהות טקסט באובייקטים של Ink.

הרכבת אובייקט Ink

הדרך העיקרית ליצור אובייקט Ink היא לצייר אותו במסך מגע. ב-iOS, אפשר להשתמש ב-UIImageView יחד עם touch event handlers שמציירים את הקווים על המסך וגם שומרים את הנקודות של הקווים כדי ליצור את האובייקט 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, וצריך להתאים אותה לאפליקציה שלכם לפי הצורך. מומלץ להשתמש ב-roundcaps כשמציירים את קטעי הקו, כדי שקטעים באורך אפס יצוירו כנקודה (כמו הנקודה באות 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.");
                                }];
}

טיפים לשיפור הדיוק של זיהוי הטקסט

רמת הדיוק של זיהוי הטקסט עשויה להשתנות בין שפות שונות. רמת הדיוק תלויה גם בסגנון הכתיבה. ההכרה בדיו דיגיטלי אומנה לטפל בסגנונות כתיבה רבים, אבל התוצאות עשויות להיות שונות ממשתמש למשתמש.

כמה דרכים לשיפור הדיוק של כלי לזיהוי טקסט. חשוב לזכור שהטכניקות האלה לא רלוונטיות לסיווגים של שרטוטים של אמוג'י, AutoDraw וצורות.

אזור הכתיבה

להרבה אפליקציות יש אזור כתיבה מוגדר היטב לקלט של משתמשים. המשמעות של סמל נקבעת בחלקה לפי הגודל שלו ביחס לגודל של אזור הכתיבה שמכיל אותו. לדוגמה, ההבדל בין האותיות o או c באותיות קטנות או גדולות, ובין פסיק לבין קו נטוי.

ציון הרוחב והגובה של אזור הכתיבה למערכת הזיהוי יכול לשפר את הדיוק. עם זאת, הכלי לזיהוי כתב יד מניח שאזור הכתיבה מכיל רק שורה אחת של טקסט. אם אזור הכתיבה הפיזי גדול מספיק כדי לאפשר למשתמש לכתוב שתי שורות או יותר, יכול להיות שתקבלו תוצאות טובות יותר אם תעבירו 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 מכיל מלבן ואליפסה אחד ליד השני, יכול להיות שהמערכת תחזיר את אחד מהם (או משהו שונה לגמרי) כתוצאה, כי מועמד יחיד לזיהוי לא יכול לייצג שתי צורות.