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

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

אני רוצה לנסות

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

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

    pod 'GoogleMLKit/DigitalInkRecognition', '3.2.0'
    
    
  2. אחרי שמתקינים או מעדכנים את קבוצות ה-Pod של הפרויקט, פותחים את פרויקט Xcode באמצעות .xcworkspace. יש תמיכה ב-ML Kit ב-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 באותיות קטנות או גדולות, לבין פסיק לעומת קו נטוי לפנים.

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

כשמציינים את אזור הכתיבה, צריך לציין את הרוחב והגובה שלו באותן יחידות של קואורדינטות הקווים. לארגומנטים של קואורדינטות x,y אין דרישה ליחידה – ה-API מנרמל את כל היחידות, כך שהדבר היחיד שחשוב הוא הגודל והמיקום היחסי של הקווים. אתם יכולים להעביר קואורדינטות בכל קנה מידה שמתאים למערכת שלכם.

לפני ההקשר

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

לדוגמה, לעיתים קרובות, האותיות "n" ו-"u" נחשבות בטעות אחת לשנייה. אם המשתמש כבר הזין את המילה החלקית 'arg', יכול להיות שהוא ימשיך עם קווים שאפשר לזהות כ'ument' או כ'nment'. ציון ה'ארגומנט' לפני ההקשר פותר את אי-הבהירות, כי סביר יותר שהמילה 'ארגומנט' מאשר 'ארגומנט'.

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