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

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

רוצה לנסות?

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

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

    pod 'GoogleMLKit/DigitalInkRecognition', '7.0.0'
    
    
  2. אחרי שמתקינים או מעדכנים את ה-Pods של הפרויקט, פותחים את פרויקט 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.");
                                }];
}

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

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

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

אזור הכתיבה

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