[Android] OCR로 이미지에서 텍스트 인식하기


개요

넥스터즈 22기 활동에서 프로젝트를 진행하던 도중, 안드로이드 클라이언트에서 사용자가 업로드한 이미지의 텍스트를 인식하는 기능 구현이 필요해졌습니다. 이에 따라 Clean Architecture를 기반으로 Tesseract를 이용한 OCR기능을 통해 사용자가 선택한 이미지의 텍스트를 인식하는 기능을 구현하게 되었습니다.


라이브러리 (Tesseract)

제일 먼저 찾아본 것은 역시 OCR 기능을 구현하기 위한 적당한 라이브러리가 있는지 찾아보는 것이었습니다. 오픈소스에 친화적인 안드로이드인 만큼, 평소와 같이 여러가지 선택지 중 장단점을 비교하며 하나의 라이브러리를 골라 쓰는 과정을 기대하고 있었는데, 의외로 가능한 선택지는 단 하나밖에 없었습니다. 바로 오픈소스 OCR 엔진인 Tesseract를 안드로이드용으로 포팅한 버전이었습니다.

tess-two

가장 처음에는 tess-two 라는 이름의 깃허브 레포지토리를 발견할 수 있었습니다. 스타 수도 1.7k로 충분히 유지보수가 되어있어 바로 사용할 수 있을 것을 기대했습니다만, README를 읽어보니 해당 레포는 더 이상 유지 보수가 되지 않으며, 다른 라이브러리를 사용할 것을 권장하고 있었습니다.

Tesseract4Android

다른 라이브러리로 Tesseract4Android 를 추천하고 있었는데, 살펴 본 결과 기존의 tess-two와 거의 동일하고 엔진 버전 업그레이드 및 버그 픽스를 하고 있는 레포지토리였습니다. 스타 수는 300여개로 기존 라이브러리에 비해 많은 편은 아니지만, 충분히 쓸만해 보이기도 하고 다른 선택지가 없어 해당 라이브러리를 통해 구현하기로 했습니다.


구현

Tesseract 엔진을 구동하기 위해선 먼저 언어 별 학습 데이터가 필요합니다. 해당 학습 데이터 파일은 tesseract-ocr 공식 레포지토리에서 제공하고 있습니다. 사진에서 영어 및 한글을 인식하기 위해선, 해당 링크에서 영어 및 한글 학습 데이터 파일을 받아야 합니다.

이 언어 학습 데이터 파일은 안드로이드에서 tesseract 인스턴스를 초기화 할 때 필요합니다. 따라서 안드로이드 프로젝트 애셋에 해당 학습 데이터 파일들을 추가한 뒤, 앱을 실행했을 때 학습 데이터 파일을 기기 저장소에 복사하여 OCR 기능 동작 시 활용할 수 있도록 해야 합니다.

아래 코드는 Hilt를 이용하여 구현한 OCR 모듈입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@Module
@InstallIn(SingletonComponent::class)
internal object OcrModule {

    // 언어 학습 데이터 파일명
    private const val ENG = "eng.traineddata"
    private const val KOR = "kor.traineddata"
    private const val KOR_VERT = "kor_vert.traineddata"

    @Provides
    @Singleton
    fun provideTess(@ApplicationContext context: Context): TessBaseAPI {
        val tess = TessBaseAPI()

        val dataPath = File(context.filesDir, "tesseract")
        val subdir = File(dataPath, "tessdata")

        // 언어 학습 데이터 체크 및 복사
        checkDir(dataPath, subdir)
        checkTrainedData(context, subdir, ENG, KOR, KOR_VERT)

        tess.init(dataPath.absolutePath, "kor+eng")

        // 문자 인식 최적화(optional)
        tess.setVariable(TessBaseAPI.VAR_CHAR_WHITELIST, "abcdefghijklmnopqrstuvwsyz0123456789");
        tess.setVariable(TessBaseAPI.VAR_CHAR_BLACKLIST, "!@#$%^&*()_+=-[]}{;:'\"\|~`,./<>?");

        return tess
    }

    private fun checkDir(vararg dirs: File) {
        for (dir in dirs) with(dir) { if (!exists()) mkdir() }
    }

    private fun checkTrainedData(context: Context, dir: File, vararg languages: String) {
        for (language in languages) {
            with(File(dir, language)) {
                if (!exists()) copyFrom(context.assets.open(language))
            }
        }
    }

    private fun File.copyFrom(inputStream: InputStream) {
        val outputStream: OutputStream = FileOutputStream(this)

        val buffer = ByteArray(1024)
        while (true) {
            val byteCount = inputStream.read(buffer)
            if (byteCount < 0) break
            outputStream.write(buffer, 0, byteCount)
        }

        outputStream.flush()
        outputStream.close()

        inputStream.close()
    }
}

위 코드에서 확인할 수 있듯이, tesseract 인스턴스를 초기화하기 전에 먼저 기기에 언어 학습 데이터가 복사되어 있는지 확인합니다. 기기에 존재하지 않는다면 프로젝트 애셋으로부터 언어 학습 데이터를 기기로 복사합니다.

해당 학습 데이터를 통해 init() 메서드로 인스턴스를 초기화할 수 있습니다. 인자로는 학습 데이터의 path와 사용할 학습 언어를 string 형식으로 전달하게 되는데 한글을 사용할 경우에는 kor, 영어일 경우에는 eng, 둘 다 사용한다면 위 코드처럼 kor+eng 형태로 +로 연결하여 전달하면 됩니다.

Tesseract 인스턴스 초기화 과정에서 필수적인 요소는 언어 학습 데이터가 전부입니다만, 위 코드처럼 setVariable 메서드를 통해 인식되는 character의 WhiteList 및 BlackList를 지정하여 성능을 어느정도 커스텀 할 수 있습니다. 만약 인식하려는 텍스트가 특수문자가 없고 일반적인 알파벳 위주라면 위 코드처럼 최적화 시킬 수 있습니다. 자세한 성능에 대해서는 후술하겠지만, 실제로 사진에 존재하는 문자가 아님에도 이상한 특수문자들을 억지로 인식하는 경우가 있어 이를 어느정도 방지할 수 있었습니다.

생성한 인스턴스를 사용하는 방법은 매우 간단합니다.

1
2
3
4
5
6
7
8
// 이미지 인식 (File or Bitmap)
tess.setImage(image)

// 텍스트 추출
val text: String = tess.getUTF8Text()

// 인스턴스 release
tess.recycle()

인스턴스에 이미지를 넘겨 분석시키는 setImage() 메서드는 File 타입과 Bitmap 파일을 지원합니다. 여기서 한 가지 이슈가 발생했었는데, 사용자가 갤러리에서 선택한 이미지를 Bitmap으로 전달하면 tesseract failed to read bitmap 라는 오류를 뿜었습니다. 꽤 긴 시간 구글링을 해보았지만, 안드로이드 플랫폼에서 tesseract를 다루는 이슈 자체가 적다보니 결국 해결책을 찾지 못했습니다. 그런데 Bitmap이 아닌 사진 파일의 절대 경로를 통해 File 타입으로 전달하니 정상 작동하는 모습을 볼 수 있었습니다. 그래서 우선은 오류의 원인을 발견하지 못한 채, setImage(File) 메서드를 이용해 구현하게 되었습니다. 혹시라도 같은 오류가 발생한다면 File 타입을 이용해보시기 바랍니다.

getUTFText()는 분석한 이미지에서 실제 문자열을 반환하는 메서드인데, 실제로 이미지 분석 과정은 앞의 setImage()에서 이루어집니다. 따라서 getUTFText()는 인스턴스 내에 이미 저장된 결과물에서 문자열을 가져만 올 뿐, 비동기적인 작업은 모두 setImage()에서 진행됩니다.

OCR 기능을 사용하는 플로우가 완전히 종료되면 recycle()을 통해 인스턴스를 release 할 수 있습니다.

실제로 OCR을 프로젝트에서 Clean Architecture 기반으로 구현할 때에는, core-feature 모듈을 따로 분리하여 tesseract 인스턴스 주입 코드와 인터페이스 구현체를 작성하고, presentation 레이어의 뷰모델에서 해당 인터페이스를 이용하도록 구현하였습니다.


성능

구현을 마치고 나니 가장 궁금한 것은 역시 OCR 엔진의 성능이었습니다. 이전에 조사한 tesseract의 성능은 영어는 괜찮은데 한글은 부족하다라는 평이 많았습니다. 그래도 10년이 넘도록 개발이 이루어진 오픈소스 프로젝트인 만큼 기대를 했었습니다만…


image

결과적으로 안드로이드에서 tesseract를 이용한 한글 인식은 아직 실제 서비스에서 사용할 수준은 아니었습니다.


우선은 가장 간단한 이미지를 테스트하기 위해 단순히 흰 배경에 한글이 검은 글씨로 반듯하게 적혀진 이미지로 테스트 해보았습니다. 하지만 그럼에도 일부 글자가 비슷하게 생긴 다른 글자로 인식되거나, 띄어쓰기를 제대로 인식하지 못하는 등 아주 기초적인 이미지에서도 완벽하지 못한 모습을 보여주었습니다. 위 사진은 실제로 직접 테스트해 본 결과물로, 간단한 사진임에도 불구하고 디테일한 글자와 띄어쓰기에 대한 인식률이 부족하고 하단의 “시작하기” 버튼의 텍스트는 아예 인식하지 못한 것을 확인할 수 있습니다.

또한 진행하고 있던 프로젝트에서 특성 사진에 글씨 존재 여부에 대한 판단 기능도 필요했는데, 안드로이드의 tesseract로는 구현하기 힘들었습니다. 글씨가 없는 일반적인 풍경 사진을 넣더라도 무작위의 특수 문자를 인식하여 반환하는 등, 성능적인 모습에서는 아쉬운 모습을 보였습니다.

다만 이미지 분석에 대한 소요 시간은 빠른 편이었습니다. 대체로는 0 ~ 2초 정도가 소요되었고, 아무런 글씨가 없는 사진에서는 3 ~ 4초 정도가 소요되었습니다. (물론 글씨가 없더라도 문자열은 반환하였습니다.) 따라서 이미지 내 글자 존재 여부를 판단할 때, 완벽하진 않겠지만 이미지 분석 시간에 대한 timeout을 통해 어느정도 구현할 수도 있을 것 같다는 생각이 들었습니다.

결론적으로, 높은 정확도가 필요하고 다양한 사진에 대한 분석을 해야하는 앱이라면 네이버 클로바와 같은 특화 유료 솔루션을 사용해야 할 것 같습니다. 하지만 한글이 아닌 영어이고, 간단한 이미지에서 대강의 정보를 추출하는 것하는 것이 목표라면 tesseract도 충분히 사용은 가능하다고 생각합니다.


마치며

이런 안드로이드에 반해 iOS에서는 굉장히 훌륭한 성능의 OCR 엔진을 기본으로 탑재하고 있습니다. 그래서 외부 라이브러리를 따로 알아보지 않고도 OCR 성능에 아무런 걱정 없이 개발하는 iOS 팀이 굉장히 부러웠습니다. 현재 tesseract가 유일한 솔루션임에도 불구하고 많은 관심을 받지 못하는 상태인 것 같은데, 어서 안드로이드도 이에 대한 공식적인 솔루션이 제공되었으면 좋겠습니다.

0%