ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • OCR 구현 여정 🚀
    개발여정 2024. 6. 28. 22:40

    최근 회사 프로젝트에서 모바일 환경에서 동작하는 모바일 애플리케이션을 만들기로 하였습니다. 이 때까지 웹 애플리케이션만 만들어 왔기 때문에 저는 모바일 애플리케이션 개발을 하고 싶은 마음에 "무조건 제가 하겠습니다!" 라고 당당히 외쳤지만... 시간이 지날수록 자꾸만 작아지는건 왜 일까요...? 🥲

    무엇을 개발해야 할까?

    모바일 애플리케이션 개발이 처음이라 시간이 지날수록 계속해서 작아졌지만, 정신을 차리고 우선 구현되어야 하는 몇 가지 요구사항을 파악했습니다. 🔍

    * 크로스 플랫폼(IOS/Android)이면 좋지만 Android를 우선해서 동작하도록 한다.
    * 바코드 스캔이 되어야 한다.
    * OCR (Optical Character Recognition) 문자 인식이 되어야 한다.
    * 나머지 기타 등등...

    무엇으로 개발하지?

    이러한 요구사항을 받았을 때 먼저 고민했던 것은 "어떤 언어로 개발할까?" 였습니다. 플랫폼은 Android에서만 동작하면 되었지만 왠만하면 크로스 플랫폼을 지원하였으면 좋겠다는 말에, 크로스 플랫폼을 지원하는 개발 언어를 찾게 되었고 Flutter와 React Native 두 가지 언어를 놓고 고민을 하게 되었습니다. 🤔

    Flutter

    * 다양한 커스터마이징이 가능한 위젯을 제공하여 비교적 아름답고 일관된 UI를 쉽게 만들 수 있었습니다.
    * 구글이 적극적으로 지원하며, 커뮤니티가 활성화 되어 있었습니다.
    * 하지만 Dart 언어와 Flutter 프레임워크 자체를 새로 배우는 것이 필요했습니다.

    React Native

    * JavaScript의 방대한 라이브러리와 생태계를 활용할 수 있었습니다.
    * 많은 개발자들이 사용하고 있어 풍부한 자료와 지원을 받을 수 있었습니다.
    * React를 알고 있으면 React Native 문법을 새로 배울 필요가 없었습니다.

    이렇게 봤을 때, React를 알고 있으면 React Native의 문법을 따로 배울 필요가 없는 것이 매력적으로 느껴졌고, 또한 팀에서도 React에 익숙하기 때문에 React Native로 개발하면 저 외에도 다른 멤버가 개발 및 유지보수를 할 수 있다고 판단하여 React Native로 개발하기로 하였습니다.

    OCR 너의 정체가 뭐야?

    요구사항 중 OCR이 되어야 한다는 말을 들었을 때, "OCR이 뭐에요?" 라고 질문했던 기억이 납니다. 멤버들에게 OCR이 어떤건지 전해 들었지만 정확한 의미가 궁금해서 OCR이 뭔지 검색해 보았습니다.

    검색한 내용을 정리해 보면, 이미지 파일에서 문자를 추출하여 텍스트 데이터를 얻을 수 있다고 나옵니다. 모바일에서 카메라를 사용하여 비디오 영상의 프레임이나 카메라 촬영한 이미지에서 텍스트를 추출하는 것으로 활용할 수 있습니다.

    OCR 어떻게 구현할 수 있을까?

    이제 개발 언어도 정해졌고, 구현할 기능이 어떤건지도 파악했으니 저보다 먼저 개발한 OCR을 개발한 사람을 찾아보며 구글링을 해보았습니다. 수두룩히 나오는 OCR 구현 예제를 보고 기쁜 마음에 덥석 "4시간만 주면 PoC 할 수 있을 것 같습니다!" 라고 멤버들에게 당당히 말했습니다. 😎

    하지만 쉬운 일은 없나 봅니다... 구글링에 나온 수많은 예제를 따라하고 지우기를 반복했지만, 안된다는 것을 깨닫게 된 저는 과거의 저를 원망하며 "멈춰... 안돼... Stay..." 라고 속으로 되뇌었습니다. 😭

    예제를 따라할 때 간과 했던 것 중 하나는 예제로 나온 OCR 예제에 사용되는 `react-native-camera` 라는 패키지의 유지보수를 더 이상 지원하지 않았기 때문입니다.

    하지만 친절하게도 이 패키지의 README에는 유지보수는 더 이상 하지 않지만, `react-native-vision-camera` 라는 패키지로 대체할 수 있다는 말이 남겨져 있었습니다.

    이제 저는 헐레벌떡 `react-native-vision-camera` 패키지의 공식문서를 살펴보게 되었고, 영상의 프레임으로 OCR을 구현할 수 있도록 플러그인의 형태로 지원되는 것을 발견했습니다! 🙌

    영상 프레임으로 OCR을 구현하다. 하지만...

    `react-native-vision-camera` 패키지에서 플러그인의 형태로 OCR을 지원하는 것을 알았으니, 이제 구현을 해볼 차례입니다.

    음... 구현에 성공하였지만 코드가 사라졌습니다?!! 😅

    이렇게 `react-native-vision-camera` 패키지의 OCR 플러그인으로 영상 프레임 단위로 문자를 인식하여 OCR PoC를 마칠 수 있었으나?! 실제 사용해보니 기기의 성능 문제 때문인지 카메라를 움직일 때마다 문자가 제대로 인식이 되지 않는 현상이 발생했습니다. 😱

    이미지로 OCR을 구현하다

    이를 해결하기 위해서 고민한 끝에, 프레임 단위로 문자를 인식하게 하지 않고 카메라의 촬영을 활용해서 이미지를 얻은 다음 멈춰있는 이미지의 문자를 인식하도록 로직을 변경하도록 결정하였습니다.

    패키지 설치

    먼저 필요한 패키지를 설치해 봅시다.

    # react-native-vision-camera: 카메라 기능을 제공하는 패키지  
    # @react-native-ml-kit/text-recognition: 이미지에서 문자를 추출할 수 있는 패키지  
    npm install react-native-vision-camera @react-native-ml-kit/text-recognition  

    카메라 접근 허용 설정 추가

    `react-native-vision-camera` 패키지에서 제공하는 카메라 기능을 사용하려면 다음과 같은 설정을 해야 합니다.

    Android 설정

    # android/gradle.properties  
    VisionCamera_enableCodeScanner=true  
    <!-- android/app/src/main/AndroidManifest.xml -->  
    <uses-permission android:name="android.permission.CAMERA" />  

    IOS 설정

    <!-- ios/OCRPoC/Info.plist -->  
    <key>NSCameraUsageDescription</key>  
    <string>$(PRODUCT_NAME) needs access to your Camera.</string>  

    Scanner 인터페이스 정의

    먼저 `Scanner` 인터페이스를 정의했습니다.

    import { Camera, CameraDevice, CodeScanner } from 'react-native-vision-camera';  
    
    export interface IScanner {  
      // 카메라 화면으로 전환할 것인지  
      isShowing: boolean;  
    
      // 카메라를 활성화할 것인지  
      isActive: boolean;  
    
      // CameraDevice 정보 > react-native-vision-camera에서 useCameraDevice 훅으로 제공  
      device: CameraDevice | undefined;  
    
      // 카메라 접근 허용을 확인  
      checkPermission: () => Promise<boolean>;  
    
      // 카메라 활성화  
      activateScanner: () => void;  
    
      // 카메라 비활성화  
      inactivateScanner: () => void;  
    
      // 카메라 위치(전면/후면) 변경  
      toggleCameraPosition: () => void;  
    
      // 사진 촬영  
      takePhoto: (camera: Camera | null) => Promise<void>;  
    }  

    useOCRScanner 커스텀 훅 구현

    `Scanner` 인터페이스를 구현한 `useOCRScanner` 커스텀 훅을 만들었습니다. 이 커스텀 훅은 `onProcessOCR` 이라는 콜백 함수를 인자로 받고 있는데, `takePhoto` 라는 함수가 호출되어 사진 촬영이 끝나고 OCR 결과를 전달할 것입니다.

    import { IScanner } from '@/types/Scanner';  
    import TextRecognition, {  
      TextLine,  
      TextRecognitionScript,  
    } from '@react-native-ml-kit/text-recognition';  
    import { useCallback, useState } from 'react';  
    import {  
      CameraPosition,  
      PhotoFile,  
      useCameraDevice,  
      useCameraPermission,  
    } from 'react-native-vision-camera';  
    
    // OCR 성공 결과  
    type SuccessResult = {  
      status: 'success';  
      amount: number;  
    };  
    // OCR 실패 결과  
    type FailResult = {  
      status: 'fail';  
      reason: string;  
    };  
    // OCR 결과  
    type ScanResult = SuccessResult | FailResult;  
    
    type Props = {  
      onProcessOCR: (result: ScanResult) => void; // OCR 결과를 처리할 콜백 함수  
    };  
    
    const useOCRScanner = ({ onProcessOCR }: Props): IScanner => {  
      // 카메라 화면으로 전환할 것인지  
      const [isShowing, setIsShowing] = useState(false);  
      // 카메라를 활성화할 것인지  
      const [isActive, setIsActive] = useState(false);  
      // 카메라 위치(전면/후면)  
      const [cameraPosition, setCameraPosition] = useState<CameraPosition>('back');  
    
      // CameraDevice 정보  
      const device = useCameraDevice(cameraPosition);  
      // 카메라 접근 권한 조회 및 요청  
      const { hasPermission, requestPermission } = useCameraPermission();  
    
      // 카메라 접근 허용을 확인  
    const checkPermission: IScanner['checkPermission'] =  
        useCallback(async (): Promise<boolean> => {  
          if (!hasPermission) {  
            const permitted = await requestPermission();  
            return permitted ? true : false;  
          } else {  
            return true;  
          }  
        }, [hasPermission, requestPermission]);  
    
      // 카메라 활성화  
      const activateScanner: IScanner['activateScanner'] = useCallback(() => {  
        setIsActive(true);  
        setIsShowing(true);  
      }, []);  
    
      // 카메라 비활성화  
      const inactivateScanner: IScanner['inactivateScanner'] = useCallback(() => {  
        setIsActive(false);  
        setIsShowing(false);  
      }, []);  
    
      // !! TO DO: 전면 카메라로 ocr 인식시 사진을 반전시킬 수 있으면, 토글 가능하도록 변경해도 됨  
      const toggleCameraPosition: IScanner['toggleCameraPosition'] =  
        useCallback(() => {  
          inactivateScanner();  
          if (cameraPosition === 'front') {  
            setCameraPosition('back');  
          } else {  
            setCameraPosition('front');  
          }  
          setTimeout(() => {  
            activateScanner();  
          }, 100);  
        }, [cameraPosition, activateScanner, inactivateScanner]);  
    
      // 문자를 인식할 범위를 확인  
      const checkValidArea = (photo: PhotoFile, line: TextLine): boolean => {  
        // 사진 크기  
        const photoWidth = photo.width;  
        const photoHeight = photo.height;  
    
        // 라인 프레임 위치  
        const lineFrameTop = line.frame?.top;  
        const lineFrameLeft = line.frame?.left;  
    
        // 인식된 글자가 유효 범위 안에 들어오는지 확인  
        if (!lineFrameTop || !lineFrameLeft) {  
          return false;  
        }  
    
        if (  
          photoWidth * 0.3 <= lineFrameLeft &&  
          lineFrameLeft <= photoWidth * 0.6 &&  
          photoHeight * 0.3 <= lineFrameTop &&  
          lineFrameTop <= photoHeight * 0.6  
        ) {  
          return true;  
        } else {  
          return false;  
        }  
      };  
    
      // 숫자가 포함되는지 판단  
      const containsNumber = (text: string): boolean => {  
        return /\d/.test(text);  
      };  
    
      // 인식된 문자 중 숫자만 추출  
      const extractNumbers = (text: string): number | null => {  
        const num = text.match(/\d+/g)?.join('');  
        return num ? Number(num) : null;  
      };  
    
      // 사진 촬영  
      const takePhoto: IScanner['takePhoto'] = async camera => {  
        const photo = await camera?.takePhoto();  
    
        if (!photo) {  
          onProcessOCR({  
            status: 'fail',  
            reason: '사진을 촬영하는데 실패했습니다. 다시 시도해주세요.',  
          });  
          return;  
        }  
    
        // 문자 추출  
        const ocrResult = await TextRecognition.recognize(  
          `file://${photo.path}`, // 메모리에 임시로 사진을 저장  
          TextRecognitionScript.KOREAN, // 추출된 문자를 한글로 인식  
        );  
    
        for (let block of ocrResult.blocks) {  
          for (let line of block.lines) {  
            // 유효 범위에 있고 숫자를 포함하는 라인을 선택  
            if (checkValidArea(photo, line) && containsNumber(line.text)) {  
              const result = extractNumbers(line.text);  
              if (result !== null && result >= 0) {  
                onProcessOCR({  
                  status: 'success',  
                  amount: result,  
                });  
                inactivateScanner();  
                return;  
              } else {  
                onProcessOCR({  
                  status: 'fail',  
                  reason:  
                    '인식에 실패하였습니다.\\n박스 안에 결제금액을 맞춰 다시 시도해주세요.',  
                });  
                return;  
              }  
            }  
          }  
        }  
    
        onProcessOCR({  
          status: 'fail',  
          reason:  
            '인식에 실패하였습니다.\n박스 안에 결제금액을 맞춰 다시 시도해주세요.',  
        });  
        return;  
      };  
    
      return {  
        isShowing,  
        isActive,  
        device,  
        checkPermission,  
        activateScanner,  
        inactivateScanner,  
        toggleCameraPosition,  
        takePhoto,  
      };  
    };  
    
    export default useOCRScanner;  

    Scanner 컴포넌트에 useOCRScanner 커스텀 훅 사용하기

    이제 방금 만들었던 `useOCRScanner` 커스텀 훅을 `Scanner` 컴포넌트에 사용할 차례입니다.

    import { IScanner } from '@/types/Scanner';  
    import Window from '@/utils/Window';  
    import React, { useRef } from 'react';  
    import { Alert, Linking, StyleSheet } from 'react-native';  
    import { Camera } from 'react-native-vision-camera';  
    import styled from 'styled-components/native';  
    import useOCRScanner from '@/hooks/Scanner/useOCRScanner';  
    
    const Scanner = ({ scanner }: Props) => {  
      const camera = useRef<Camera>(null);  
    
      // Scanner 인스턴스 생성  
      const scanner: IScanner = useOCRScanner({  
        onProcessOCR: result => {  
          if (result.status === 'success') {  
            // OCR 성공시  
            console.log(result.amount);  
          } else {  
            // OCR 실패시  
            Alert.alert(result.reason);  
          }  
        },  
      });  
    
      // 카메라를 켤 때  
      const onOpenScanner = async () => {  
        // 권한 확인  
        const hasPermission = await scanner.checkPermission();  
        if (hasPermission) {  
          // 카메라 접근 권한이 있다면 카메라 활성화  
          scanner.activateScanner();  
        } else {  
          // 카메라 접근 권한이 없다면 접근 권한 요청  
          Alert.alert('카메라 접근 권한을 허용해 주세요', '', [  
            {  
              onPress: () => Linking.openSettings(),  
            },  
          ]);  
        }  
      };  
    
      return (  
        <Wrapper>  
          <ScannerButton onPress={onOpenScanner} />  
          {scanner.isShowing && !!scanner.device && (  
            <>  
              <CameraContainer>  
                <Camera  
                  style={StyleSheet.absoluteFill}  
                  ref={camera}  
                  device={scanner.device}  
                  isActive={scanner.isActive}  
                  codeScanner={scanner.codeScanner}  
                  photo={scanner.usePhoto}  
                />  
              </CameraContainer>  
              <CloseButton onPress={scanner.inactivateScanner}>  
                <CloseIcon width={'100%'} height={'100%'} />  
              </CloseButton>  
              {scanner.type === 'barcode' && (  
                <SwitchButton onPress={scanner.toggleCameraPosition}>  
                  <SwitchCameraText>카메라 전환</SwitchCameraText>  
                </SwitchButton>  
              )}  
              {scanner.usePhoto && (  
                <TakePhotoButton  
                  onPress={() =>  
                    scanner.takePhoto && scanner.takePhoto(camera.current)  
                  }  
                />  
              )}  
            </>  
          )}  
        </Wrapper>  
      );  
    };  
    
    export default Scanner;  

    짜잔! 이렇게 하면 OCR 구현이 끝났습니다! 🎉

    마치며

    이제까지 웹 애플리케이션만 개발만 하다가 우연히 모바일 애플리케이션 개발할 기회가 주어졌고 모바일 애플리케이션 출시까지의 여정을 마쳤습니다.

    웹 애플리케이션과 비슷하면서도 다른 모바일 애플리케이션 개발을 하면서 수 많은 이슈들과 마주쳤고, 그 중에서도 OCR PoC를 하며 일어난 일을 글로 녹여 보았습니다.

    사실 OCR PoC의 여정 중에는 글에 있는 이슈 외에도 `react-native-vision-camera` 패키지의 플러그인임에도 불구하고 호환이 되지 않았던 이슈, `react-native-vision-camera` 패키지의 결함이 있어 빌드가 되지 않았던 이슈(다행히 이러한 이슈를 발견하고 대략 2주가 지나서 베타 버전에서 해결되었습니다 🥲) 등 수 많은 삽질을 했지만 글에 다 녹여 내지 못해 아쉬운 마음입니다.

    하지만 수 많은 삽질하며 힘을 다 써버린 저는... 이만 여기서 포스팅을 마무리하겠습니다! 👋

    댓글

Designed by Tistory.