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

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

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

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

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

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

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

יצירת אובייקט Ink

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

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

הקשר מראש

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

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

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