Android에서 ML Kit를 사용한 디지털 잉크 인식

ML Kit의 디지털 잉크 인식을 통해 디지털 표면에 필기된 텍스트를 수백 개의 언어로 인식하고 스케치를 분류할 수 있습니다.

사용해 보기

  • 샘플 앱을 살펴보고 이 API의 사용 예시를 확인하세요.

시작하기 전에

  1. 프로젝트 수준 build.gradle 파일의 buildscriptallprojects 섹션에 Google의 Maven 저장소가 포함되어야 합니다.
  2. 모듈의 앱 수준 Gradle 파일(일반적으로 app/build.gradle)에 ML Kit Android 라이브러리의 종속 항목을 추가합니다.
dependencies {
  // ...
  implementation 'com.google.mlkit:digital-ink-recognition:18.1.0'
}

이제 Ink 객체의 텍스트 인식을 시작할 준비가 되었습니다.

Ink 객체 빌드

Ink 객체를 빌드하는 주된 방법은 터치스크린에 그리는 것입니다. Android에서는 이를 위해 캔버스를 사용할 수 있습니다. 터치 이벤트 핸들러는 다음 코드 스니펫과 같은 addNewTouchEvent() 메서드를 호출하여 사용자가 Ink 객체에 그리는 획에 지점을 저장해야 합니다.

이 일반적인 패턴은 다음 코드 스니펫에 나와 있습니다. 전체 예시는 ML Kit 빠른 시작 샘플을 참조하세요.

Kotlin

var inkBuilder = Ink.builder()
lateinit var strokeBuilder: Ink.Stroke.Builder

// Call this each time there is a new event.
fun addNewTouchEvent(event: MotionEvent) {
  val action = event.actionMasked
  val x = event.x
  val y = event.y
  var t = System.currentTimeMillis()

  // If your setup does not provide timing information, you can omit the
  // third paramater (t) in the calls to Ink.Point.create
  when (action) {
    MotionEvent.ACTION_DOWN -> {
      strokeBuilder = Ink.Stroke.builder()
      strokeBuilder.addPoint(Ink.Point.create(x, y, t))
    }
    MotionEvent.ACTION_MOVE -> strokeBuilder!!.addPoint(Ink.Point.create(x, y, t))
    MotionEvent.ACTION_UP -> {
      strokeBuilder.addPoint(Ink.Point.create(x, y, t))
      inkBuilder.addStroke(strokeBuilder.build())
    }
    else -> {
      // Action not relevant for ink construction
    }
  }
}

...

// This is what to send to the recognizer.
val ink = inkBuilder.build()

Java

Ink.Builder inkBuilder = Ink.builder();
Ink.Stroke.Builder strokeBuilder;

// Call this each time there is a new event.
public void addNewTouchEvent(MotionEvent event) {
  float x = event.getX();
  float y = event.getY();
  long t = System.currentTimeMillis();

  // If your setup does not provide timing information, you can omit the
  // third paramater (t) in the calls to Ink.Point.create
  int action = event.getActionMasked();
  switch (action) {
    case MotionEvent.ACTION_DOWN:
      strokeBuilder = Ink.Stroke.builder();
      strokeBuilder.addPoint(Ink.Point.create(x, y, t));
      break;
    case MotionEvent.ACTION_MOVE:
      strokeBuilder.addPoint(Ink.Point.create(x, y, t));
      break;
    case MotionEvent.ACTION_UP:
      strokeBuilder.addPoint(Ink.Point.create(x, y, t));
      inkBuilder.addStroke(strokeBuilder.build());
      strokeBuilder = null;
      break;
  }
}

...

// This is what to send to the recognizer.
Ink ink = inkBuilder.build();

DigitalInkRecognizer 인스턴스 가져오기

인식을 수행하려면 Ink 인스턴스를 DigitalInkRecognizer 객체로 전송합니다. 아래 코드는 BCP-47 태그에서 이러한 인식기를 인스턴스화하는 방법을 보여줍니다.

Kotlin

// Specify the recognition model for a language
var modelIdentifier: DigitalInkRecognitionModelIdentifier
try {
  modelIdentifier = DigitalInkRecognitionModelIdentifier.fromLanguageTag("en-US")
} catch (e: MlKitException) {
  // language tag failed to parse, handle error.
}
if (modelIdentifier == null) {
  // no model was found, handle error.
}
var model: DigitalInkRecognitionModel =
    DigitalInkRecognitionModel.builder(modelIdentifier).build()


// Get a recognizer for the language
var recognizer: DigitalInkRecognizer =
    DigitalInkRecognition.getClient(
        DigitalInkRecognizerOptions.builder(model).build())

Java

// Specify the recognition model for a language
DigitalInkRecognitionModelIdentifier modelIdentifier;
try {
  modelIdentifier =
    DigitalInkRecognitionModelIdentifier.fromLanguageTag("en-US");
} catch (MlKitException e) {
  // language tag failed to parse, handle error.
}
if (modelIdentifier == null) {
  // no model was found, handle error.
}

DigitalInkRecognitionModel model =
    DigitalInkRecognitionModel.builder(modelIdentifier).build();

// Get a recognizer for the language
DigitalInkRecognizer recognizer =
    DigitalInkRecognition.getClient(
        DigitalInkRecognizerOptions.builder(model).build());

Ink 객체 처리

Kotlin

recognizer.recognize(ink)
    .addOnSuccessListener { result: RecognitionResult ->
      // `result` contains the recognizer's answers as a RecognitionResult.
      // Logs the text from the top candidate.
      Log.i(TAG, result.candidates[0].text)
    }
    .addOnFailureListener { e: Exception ->
      Log.e(TAG, "Error during recognition: $e")
    }

Java

recognizer.recognize(ink)
    .addOnSuccessListener(
        // `result` contains the recognizer's answers as a RecognitionResult.
        // Logs the text from the top candidate.
        result -> Log.i(TAG, result.getCandidates().get(0).getText()))
    .addOnFailureListener(
        e -> Log.e(TAG, "Error during recognition: " + e));

위의 샘플 코드에서는 다음 섹션에 설명된 대로 인식 모델이 이미 다운로드되었다고 가정합니다.

모델 다운로드 관리

디지털 잉크 인식 API는 수백 개의 언어를 지원하지만 각 언어에서는 인식 전에 일부 데이터를 다운로드해야 합니다. 언어당 약 20MB의 저장용량이 필요합니다. 이 작업은 RemoteModelManager 객체에서 처리합니다.

새 모델 다운로드

Kotlin

import com.google.mlkit.common.model.DownloadConditions
import com.google.mlkit.common.model.RemoteModelManager

var model: DigitalInkRecognitionModel =  ...
val remoteModelManager = RemoteModelManager.getInstance()

remoteModelManager.download(model, DownloadConditions.Builder().build())
    .addOnSuccessListener {
      Log.i(TAG, "Model downloaded")
    }
    .addOnFailureListener { e: Exception ->
      Log.e(TAG, "Error while downloading a model: $e")
    }

Java

import com.google.mlkit.common.model.DownloadConditions;
import com.google.mlkit.common.model.RemoteModelManager;

DigitalInkRecognitionModel model = ...;
RemoteModelManager remoteModelManager = RemoteModelManager.getInstance();

remoteModelManager
    .download(model, new DownloadConditions.Builder().build())
    .addOnSuccessListener(aVoid -> Log.i(TAG, "Model downloaded"))
    .addOnFailureListener(
        e -> Log.e(TAG, "Error while downloading a model: " + e));

모델이 이미 다운로드되었는지 확인하기

Kotlin

var model: DigitalInkRecognitionModel =  ...
remoteModelManager.isModelDownloaded(model)

Java

DigitalInkRecognitionModel model = ...;
remoteModelManager.isModelDownloaded(model);

다운로드한 모델 삭제

기기의 스토리지에서 모델을 삭제하면 여유 공간이 확보됩니다.

Kotlin

var model: DigitalInkRecognitionModel =  ...
remoteModelManager.deleteDownloadedModel(model)
    .addOnSuccessListener {
      Log.i(TAG, "Model successfully deleted")
    }
    .addOnFailureListener { e: Exception ->
      Log.e(TAG, "Error while deleting a model: $e")
    }

Java

DigitalInkRecognitionModel model = ...;
remoteModelManager.deleteDownloadedModel(model)
                  .addOnSuccessListener(
                      aVoid -> Log.i(TAG, "Model successfully deleted"))
                  .addOnFailureListener(
                      e -> Log.e(TAG, "Error while deleting a model: " + e));

텍스트 인식 정확도 개선을 위한 도움말

텍스트 인식의 정확성은 언어마다 다를 수 있습니다. 정확성은 글쓰기 스타일에 따라 달라집니다. 디지털 잉크 인식은 다양한 종류의 쓰기 스타일을 처리하도록 학습되었지만 결과는 사용자마다 다를 수 있습니다.

텍스트 인식기의 정확도를 개선하는 방법은 다음과 같습니다. 이러한 기법은 그림 이모티콘, 자동 그리기, 도형의 그리기 분류 기준에 적용되지 않습니다.

쓰기 영역

많은 애플리케이션에 사용자 입력을 위한 쓰기 영역이 잘 정의되어 있습니다. 기호의 의미는 기호를 포함하는 쓰기 영역의 크기를 기준으로 부분적으로 결정됩니다. 예를 들어 소문자 또는 소문자 'o', 'c', 쉼표와 슬래시의 차이입니다.

인식기에 쓰기 영역의 너비와 높이를 알려면 정확도가 향상될 수 있습니다. 그러나 인식기는 텍스트 영역에 한 줄의 텍스트만 포함된다고 가정합니다. 실제 쓰기 영역이 사용자가 2개 이상의 줄을 쓸 수 있을 만큼 크다면 텍스트 한 줄의 높이를 추정하는 높이인 WRITEArea를 전달하면 더 나은 결과를 얻을 수 있습니다. 인식기에 전달한 WRITEArea 객체는 화면의 실제 쓰기 영역과 정확히 일치하지 않아도 됩니다. 이러한 방식으로 WriteArea 높이를 변경하면 다른 언어보다 일부 언어에서 더 잘 작동합니다.

쓰기 영역을 지정할 때는 너비와 높이를 획 좌표와 동일한 단위로 지정합니다. x,y 좌표 인수에는 단위 요구사항이 없습니다. API는 모든 단위를 정규화하므로 중요한 것은 획의 상대적인 크기와 위치뿐입니다. 시스템에 적합한 배율로 좌표를 자유롭게 전달할 수 있습니다.

사전 컨텍스트

사전 컨텍스트는 인식하려는 Ink에서 획 바로 앞에 오는 텍스트입니다. 인식기에 사전 컨텍스트에 관한 정보를 제공하면 도움이 됩니다.

예를 들어, 필기체 'n'과 'u'는 종종 서로 오인됩니다. 사용자가 'arg'의 일부 단어를 이미 입력한 경우 'ument' 또는 'nment'로 인식될 수 있는 획을 계속 사용할 수 있습니다. 사전 컨텍스트 'arg'를 지정하면 'args'라는 단어가 'argnment'보다 더 의미가 있으므로 모호성이 해결됩니다.

사전 컨텍스트는 인식기가 단어 사이의 공백(단어 띄어쓰기)을 식별하는 데도 도움이 됩니다. 공백 문자를 입력할 수 있지만 그릴 수는 없습니다. 그렇다면 인식기에서 한 단어가 끝나고 다음 단어가 언제 시작될지 어떻게 판단할 수 있을까요? 사용자가 이미 'hello'를 작성하고 'world'라는 단어를 계속 쓰는 경우 인식기가 사전 컨텍스트 없이 'world' 문자열을 반환합니다. 그러나 사전 컨텍스트 'hello'를 지정하면 모델은 선행 공백과 함께 'world' 문자열을 반환합니다. 'hello world'가 'helloword'보다 더 적절하기 때문입니다.

공백을 포함하여 최대 20자의 사전 컨텍스트 문자열을 제공해야 합니다. 문자열이 더 긴 경우 인식자는 마지막 20자만 사용합니다.

아래의 코드 샘플은 쓰기 영역을 정의하고 RecognitionContext 객체를 사용하여 사전 컨텍스트를 지정하는 방법을 보여줍니다.

Kotlin

var preContext : String = ...;
var width : Float = ...;
var height : Float = ...;
val recognitionContext : RecognitionContext =
    RecognitionContext.builder()
        .setPreContext(preContext)
        .setWritingArea(WritingArea(width, height))
        .build()

recognizer.recognize(ink, recognitionContext)

Java

String preContext = ...;
float width = ...;
float height = ...;
RecognitionContext recognitionContext =
    RecognitionContext.builder()
                      .setPreContext(preContext)
                      .setWritingArea(new WritingArea(width, height))
                      .build();

recognizer.recognize(ink, recognitionContext);

획 순서

인식 정확성은 획의 순서에 민감합니다. 인식기에서는 사람들이 자연스러운 쓰기 순서(예: 영어의 경우 왼쪽에서 오른쪽)로 스트로크가 발생할 것으로 기대합니다. 이 패턴을 벗어나는 모든 사례(예: 마지막 단어로 시작하는 영어 문장 작성)는 정확성이 떨어집니다.

또 다른 예는 Ink 중간에 있는 단어를 삭제하고 다른 단어로 바꾸는 경우입니다. 버전이 한 문장의 중앙에 있을 수 있지만, 버전 스트로크는 스트로크 시퀀스의 끝부분에 있습니다. 이 경우 새로 작성된 단어를 API에 별도로 전송하고 자체 로직을 사용하여 결과를 이전 인식과 병합하는 것이 좋습니다.

모호한 형태 처리

인식기에 제공된 도형의 의미가 모호한 경우가 있습니다. 예를 들어 모서리가 매우 둥근 직사각형은 직사각형 또는 타원으로 볼 수 있습니다.

이러한 불분명한 케이스는 가능한 경우 인식 점수를 사용하여 처리할 수 있습니다. 형상 분류 기준만 점수를 제공합니다. 모델의 신뢰도가 높은 경우 최상위 결과의 점수가 두 번째로 높은 점수보다 훨씬 우수합니다. 불확실한 경우 상위 결과 2개의 점수가 닫힙니다. 또한 도형 분류기는 전체 Ink를 단일 도형으로 해석합니다. 예를 들어 Ink에 직사각형과 타원형이 나란히 있으면 인식기는 둘 중 하나 (또는 완전히 다른 것)를 반환할 수 있습니다. 단일 인식 후보가 두 모양을 표현할 수 없기 때문입니다.