ML Kit을 이용해 바코드 스캐너 만들기

이번 포스팅에서는 구글의 ML Kit을 이용해 바코드 스캐너를 만드는 방법에 대해 알아보도록 하겠습니다.

들어가기

구글에서는 안드로이드에서 인공지능 기능을 사용할 수 있도록 ML Kit이라는 라이브러리를 발표했는데 이 킷을 이용하면 바코드 스캔, 얼굴 인식, 텍스트 인식, 사물 트래킹 등의 기능을 구현할 수 있습니다.

바코드를 스캔하는데에는 ZXing (“zebra crossing”)이라는 라이브러리가 많이 사용됩니다만, 현재는 기능 추가 없이 유지보수만 이루어지고 있습니다. 그래서 여기서는 기능 개선이 적극적으로 이루어지고 있는 ML Kit을 바코드 인식에 사용할 겁니다.

카메라 사용 준비

바코드 스캐너는 크게 카메라를 구동하는 부분과 캡처화면의 바코드를 인식하는 두 파트로 나뉩니다. 안드로이드의 카메라 모듈은 Jetpack의 CameraX 라이브러리를 써서 구동합니다.

우선은 디펜던시를 추가해주는데요, CameraXMLKit을 같이 추가해 주겠습니다.

1
2
3
4
5
6
7
8
9
dependencies {
    def camerax_version = "1.2.0"
    implementation "androidx.camera:camera-core:${camerax_version}"
    implementation "androidx.camera:camera-camera2:${camerax_version}"
    implementation "androidx.camera:camera-lifecycle:${camerax_version}"
    implementation "androidx.camera:camera-view:${camerax_version}"

    implementation 'com.google.mlkit:barcode-scanning:17.0.3'
}

다음은 AndroidManifest에서 카메라를 사용할 수 있도록 권한을 부여합니다. 그리고 화면전환이 일어나면 카메라 좌표축도 바뀌기 때문에 단순화를 위해 여기서는 세로모드로 화면을 고정했습니다.

 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
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.barcodereader">

+    <uses-feature android:name="android.hardware.camera.any" />
+    <uses-permission android:name="android.permission.CAMERA" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.BarcodeReader">
        <activity
            android:name=".MainActivity"
+            android:screenOrientation="portrait"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

다음은 뷰에 카메라 화면을 표시하는 PreviewView와, 읽어들인 바코드의 내용을 표시하는 텍스트뷰를 하나씩 추가해 줍니다.

 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
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.camera.view.PreviewView
        android:id="@+id/viewFinder"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toTopOf="@+id/resultView" />
    <TextView
        android:id="@+id/resultView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="16dp"
        android:layout_alignParentBottom="true"
        app:layout_constraintBottom_toBottomOf="parent"
        android:background="@color/teal_200"
        android:textColor="@android:color/black"
        android:textStyle="bold"
        android:textSize="18sp"
        android:height="@android:dimen/thumbnail_width"/>

</androidx.constraintlayout.widget.ConstraintLayout>

다음은 카메라의 사용권한을 취득하는 파트를 작성합니다. 권한을 취득하는 구체적인 방법은 Runtime Permission 이해하고 요청 구현하기 강의에서 다루었으니 참고 바랍니다.

코드를 간단히 설명하면 REQUIRED_PERMISSIONS에 취득할 권한을 정의하고 isAllPermissionsGranted에서 권한이 취득되었는지 확인합니다. 권한이 없으면 requestMultiplePermissions로 권한을 취득하고, 권한이 확인되었으면 startCamera로 카메라 구동을 시작하는 구조로 되어 있습니다.

 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
class MainActivity : AppCompatActivity() {
    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        // Request camera permissions
        if (isAllPermissionsGranted()) {
            startCamera()
        } else {
            requestMultiplePermissions.launch(REQUIRED_PERMISSIONS)
        }

        cameraExecutor = Executors.newSingleThreadExecutor()
    }

    private fun startCamera() {

    }

    private fun isAllPermissionsGranted(): Boolean = REQUIRED_PERMISSIONS.all { permission ->
        ContextCompat.checkSelfPermission(
            applicationContext, permission) == PackageManager.PERMISSION_GRANTED
    }

    private val requestMultiplePermissions =
        registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
            permissions.entries.forEach {
                when {
                    it.value -> {
                        Snackbar.make(binding.root, "Permission granted", Snackbar.LENGTH_SHORT).show()
                        startCamera()
                    }
                    shouldShowRequestPermissionRationale(REQUIRED_PERMISSIONS[0]) -> {
                        Snackbar.make(binding.root,
                            "Permission required to use app!", Snackbar.LENGTH_SHORT).show()
                        startCamera()
                    }
                    else -> Snackbar.make(binding.root, "Permission denied", Snackbar.LENGTH_SHORT).show()
                }
            }
        }


    companion object {
        private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
    }
}

다음은 startCamera에서 카메라를 구동합니다. CameraX는 ProcessCameraProvider를 사용해 현재 화면의 lifecycleowner와 카메라의 lifecycle을 연동합니다. 그리고 bindToLifecycle로 카메라의 세팅값과 Use case를 전달해 주면 카메라가 작동하게 되지요. 여기서는 preview 기능을 사용할 것이므로 Preview를 만들어서 전달해 주었습니다. 이 때 카메라에 대해 enableTorch를 사용하면 플래시 사용 여부를 결정할 수도 있습니다.

 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
@SuppressLint("UnsafeOptInUsageError")
private fun startCamera() {
    val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
    // 카메라 세팅
    val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA // Select back camera as a default
    // 프리뷰 세팅
    val preview = Preview.Builder()
        .build()
        .also {
            it.setSurfaceProvider(binding.viewFinder.surfaceProvider)
        }

    cameraProviderFuture.addListener(Runnable {
        // 카메라의 lifecycle을 lifecycleowner와 연결시킴
        val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

        try {
            // Unbind use cases before rebinding
            cameraProvider.unbindAll()
            // Bind use cases to camera
            val camera = cameraProvider.bindToLifecycle(
                this,
                cameraSelector,
                preview,
            )

            //Handle flash
            camera.cameraControl.enableTorch(false)
        } catch(e: Exception) {
            e.printStackTrace()
        }
    }, ContextCompat.getMainExecutor(this))
}

바코드 분석기 작성

다음은 바코드를 분석하는 Use case를 카메라에 추가로 전달할 겁니다. 그것을 위해서 ImageAnalysis.Analyzer를 상속하는 BarcodeAnalyzer를 만들어줍니다. 이 클래스는 analyze 내부에서 머신러닝을 이용해 이미지를 분석합니다.

 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
@ExperimentalGetImage
class BarcodeAnalyzer(
    private val onBarcodesDetected: (barcodes: List<Barcode>) -> Unit
) : ImageAnalysis.Analyzer {

    override fun analyze(image: ImageProxy) {
        image.image?.let {
            val inputImage = InputImage.fromMediaImage(it, image.imageInfo.rotationDegrees)
            val barcodeScannerOptions = BarcodeScannerOptions.Builder()
                .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
                .build()
            val scanner = BarcodeScanning.getClient(barcodeScannerOptions)
            scanner.process(inputImage)
                .addOnSuccessListener { barcodes ->
                    onBarcodesDetected(barcodes.toList())
                }
                .addOnFailureListener { failure ->
                    failure.printStackTrace()
                }
                .addOnCompleteListener {
                    image.close()
                }
        }
    }
}

우선 InputImage를 써서 캡처한 이미지를 불러옵니다. 그리고 BarcodeScanner의 인스턴스를 만들어 주는데요, 이때 스캔할 바코드 포맷은 다음 중에서 고를 수 있습니다. FORMAT_ALL_FORMATS이 기본값이지만 처리가 늦어지므로 필요한 포맷만을 정확히 지정하는 것이 좋습니다.

  • Barcode.FORMAT_UNKNOWN
  • Barcode.FORMAT_ALL_FORMATS
  • Barcode.FORMAT_CODE_128
  • Barcode.FORMAT_CODE_39
  • Barcode.FORMAT_CODE_93
  • Barcode.FORMAT_CODABAR
  • Barcode.FORMAT_DATA_MATRIX
  • Barcode.FORMAT_EAN_13
  • Barcode.FORMAT_EAN_8
  • Barcode.FORMAT_ITF
  • Barcode.FORMAT_QR_CODE
  • Barcode.FORMAT_UPC_A
  • Barcode.FORMAT_UPC_E
  • Barcode.FORMAT_PDF417
  • Barcode.FORMAT_AZTEC

그 다음은 스캐너에 이미지를 전달한 후 콜백에 처리 결과를 등록하면 되는데요, 여기서는 처리가 성공할 경우 그 결과를 반환하고 image 객체를 닫아주도록 했습니다.

바코드 분석기를 카메라와 결합

다음은 바코드 분석기의 인스턴스를 imageAnalyzer로 만들고 camera에 Usecase로 전달합니다. 바코드를 분석하는 작업은 백그라운드 스레드에서 진행할건데 스레드가 새로 계속 생성되지 않도록 newSingleThreadExecutor로 만들어 줍니다.

 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
class MainActivity : AppCompatActivity() {
+    private lateinit var cameraExecutor: ExecutorService

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)
+        cameraExecutor = Executors.newSingleThreadExecutor()
        ...
    }

    @SuppressLint("UnsafeOptInUsageError")
    private fun startCamera() {
        ...

+        // 이미지 분석
+        val imageAnalyzer = ImageAnalysis.Builder().build().also {
+            it.setAnalyzer(cameraExecutor, BarcodeAnalyzer { barcodes ->
+                barcodes.forEach { barcode ->
+                    val value = barcode.rawValue
+//                    Log.d("TAG", value.toString())
+                    binding.resultView.text = value
+                }
+            })
+        }

        cameraProviderFuture.addListener(Runnable {
            // Used to bind the lifecycle of cameras to the lifecycle owner
            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

            try {
                // Unbind use cases before rebinding
                cameraProvider.unbindAll()
                // Bind use cases to camera
                val camera = cameraProvider.bindToLifecycle(
                    this,
                    cameraSelector,
                    preview,
+                    imageAnalyzer
                )

                //Handle flash
                camera.cameraControl.enableTorch(false)
            } catch(e: Exception) {
                e.printStackTrace()
            }
        }, ContextCompat.getMainExecutor(this))
    }

+    override fun onDestroy() {
+        super.onDestroy()
+        cameraExecutor.shutdown()
+    }
}

이렇게해서 MLKit으로 바코드 스캐너를 구현하는 방법에 대해 알아보았습니다.

Built with Hugo
Theme Stack designed by Jimmy