[React Native] VIVA | 카메라로 문서 스캔하기 (기술블로그 제출용)

Inryu·2021년 5월 18일
0

졸업프로젝트

목록 보기
6/6
post-thumbnail

이 글은 2021-1 캡스톤디자인프로젝트 기술블로그 제출용 글로, 전반적인 프로젝트의 내용과 프론트엔드 화면구성을 기술한 뒤 구현스킬 중 하나인, 카메라를 이용한 문서스캔 구현에 대해 설명하겠습니다.

🥣 프로젝트 개요

딥러닝 기반 언택트 학습 도우미 - 자동 채점 및 오답노트 생성

개발 배경

언택트시대에 선생님-학생 사이에서 숙제검사를 할 때 다음과 같은 프로세스로 진행됩니다.

  1. 학생 : 문제집 촬영, 메신저를 통해 전송
  2. 선생님 : 사진 다운로드 후 채점
  3. 선생님 : 채점 후 학생의 학습 데이터 수집 및 피드백 전송
  4. 학생 : 피드백에 대해 오답노트를 작성하는 등 추후 학습

이는 불필요한 과정이 중복되며 인력낭비를 발생시킵니다.
따라서 인적 자원을 절약하고 학원의 학생 관리가 용이할 수 있도록
수학 문제집 자동채점 및 오답노트 생성 앱 서비스(이하 VIVA)를 제안하게 되었습니다.

주요 기능

  • 문제집 검색 및 추가
  • 카메라 촬영을 통한 문서 스캔, 자동 채점
  • 자동 오답노트 생성
  • 개인 데이터를 이용한 맞춤형 문제 추천

🥣 역할

React Native를 이용하여 프론트엔드를 개발하였습니다.

기능 구현 사항

  • 백엔드 연결

    • fetch, await/async 이용
  • Splash 페이지

    • 앱 실행 시, 회원가입 & 로그인페이지로 갈 지, Main페이지로 갈 지 결정
    • ActivityIndicator를 이용하여 로딩 애니메이션 구현
    • AsyncStorage를 이용한 로그인 여부 확인
  • Main 페이지

    문제집 검색 및 추가, 채점하기 등의 전반적인 모든 기능

    • 내 문제집, 학원 교재, 오답노트 레이아웃
    • 내 문제집 리스트, 학원 교재 리스트
    • 오답노트 보기
    • 문제집 검색 및 추가
    • 오답노트 생성 react-native-raw-bottom-sheet
    • 확인 메세지 출력 react-native-flash-message
    • 문제집 채점하기
  • 회원가입 & 로그인 페이지

    • 로그인, 회원가입
  • 맞춤형 문제 추천 (=미니 모의고사) 페이지

    • 오답노트 선택 하여 그 기반으로 문제 추천, 미니모의고사 생성
    • 내 미니모의고사 리스트
  • 마이페이지

    • 내 정보 확인
    • 내 정보 수정
      • 사진 : FormData로 전송하여 AWS S3에 업로드
      • 닉네임
      • 학년

🥣 React Native navigator 구조, Screen 구성

root가 되는 StackNavigator안에 Stack NavigatorTab Navigator을 쌓는 구조입니다.
이때 Tab Navigator내부는 Stack Navigator 3가지로 이루어지며, 각 Stack Navigator안에 Screen이 존재합니다.

code : https://github.com/Inryu/viva/blob/main/App.js

🥣 카메라를 이용한 문서 스캔 기능 구현

react-native-rectangle-scanner
를 이용하여 직사각형 문서를 인식하고 촬영하는 기능을 구현하였습니다.

1) react-native-rectangle-scanner 설치

npm install react-native-rectangle-scanner --save

직사각형을 detect 한 후 화면에 사각형을 표시해줘야 하기때문에 react-native-svg 를 추가로 설치해줍니다.

yarn add react-native-svg

ios 설정

Cocopods를 위한 단계

cd ios && pod install && cd ..

카메라 권한 설정

viva.xcworkspace 를 Xcode로 열어
Info.plist 파일 열어 다음 항목을 추가합니다.

android 설정

OpenCV 연결

android/setting.gradle

include ':openCVLibrary310'
project(':openCVLibrary310').projectDir = new File(rootProject.projectDir,'../node_modules/react-native-rectangle-scanner/android/openCVLibrary310')

카메라 권한 설정

android/app/src/main/AndroidManifest.xml

<uses-permis
sion android:name="android.permission.CAMERA" />

카메라 접근 권한이 설정 된 모습

2) 기능 구현 (MarkScreen.js)

react-native-rectangle-scanner 깃헙에 올라와있는 샘플 코드를 기반으로,
필요에 맞게 수정하면서 구현하였습니다.

수정한 코드 (github)✨

📄 헤더 커스터마이즈

함수형 컴포넌트에서는 useLayoutEffect를 사용해 헤더 커스터마이즈를 하였지만, 클래스 컴포넌트인 해당 코드는
componentDidMount를 이용해 커스터마이즈합니다.

this.props.navigaion.setOptions를 이용하여 옵션을 지정해줍니다.
이때 headerLeft옵션에 뒤로 가기 버튼을 react-native-vector-icons 패키지를 이용해 커스텀해줍니다.

import Icon from 'react-native-vector-icons/Ionicons';

...

componentDidMount() {
    if (this.state.didLoadInitialLayout && !this.state.isMultiTasking) {
      this.turnOnCamera();
    }

    this.props.navigation.setOptions({
      headerTitleAlign: 'left',
      headerLeft: () => (
        <TouchableOpacity
          onPress={() => {
            {
              this.props.navigation.replace('Home');
            }
          }}>
          <Icon
            name="chevron-back-outline"
            size={33}
            style={{paddingLeft: 10}}
            color="white"
          />
        </TouchableOpacity>
      ),
    });
  }

📄 문서 스캔 후 피드백 화면 보여주기

feedbackState 라는 state를 추가하여 사진을 찍은 후에 true가 되도록 설정해줍니다.

아래 feedbackOverlay 화면은, 따라서 feedbackState 가 true일 때만 나타나는 화면입니다.

  feedbackOverlay() {
    if (this.state.feedbackState) {
      return (
        <>
          <SafeAreaView style={[styles.overlay, {backgroundColor: 'white'}]}>
            <View
              style={{
                height: hp(10),
                justifyContent: 'flex-end',
                alignItems: 'center',
                paddingBottom: hp(3),
              }}>
              <Text style={{fontSize: wp(5), fontWeight: 'bold'}}>
                📄 스캔 결과
              </Text>
            </View>
            <View
              style={{
                justifyContent: 'center',
                alignItems: 'center',
              }}>
              <ScrollView
                style={{
                  height: hp(65),
                  // justifyContent: 'center',
                  // alignItems: 'center',
                }}>
                <AutoHeightImage
                  source={{
                    uri: this.state.currentImage,
                  }}
                  style={styles.feedbackImg}
                  width={wp(90)}
                />
              </ScrollView>
            </View>
            {/*버튼*/}
            <View
              style={{
                height: hp(15),
                paddingTop: hp(7),
              }}>
              <View style={styles.btnContainer}>
                <View style={styles.btnArea_l}>
                  <TouchableOpacity
                    style={styles.delbtnoutline}
                    onPress={() => {
                      {
                        this.feedback(1);
                      }
                    }}>
                    <Text>다시찍기</Text>
                  </TouchableOpacity>
                </View>
                <View style={styles.btnArea_r}>
                  <TouchableOpacity
                    style={styles.delbtn}
                    onPress={() => {
                      {
                        this.feedback(2);
                      }
                    }}>
                    <Text style={{color: 'white'}}>사용하기</Text>
                  </TouchableOpacity>
                </View>
              </View>
            </View>
          </SafeAreaView>
        </>
      );
    }
  }

사진을 찍었을 때 실행 되는 다음 onPictureProcessed 함수에서, croppedImage 에 직사각형으로 잘려진 이미지의 경로가 담겨있고,
currentImage state에 그 경로값을 담아 현재 찍은 이미지의 경로를 저장해줍니다.

onPictureProcessed = ({croppedImage, initialImage}) => {
    // this.props.onPictureProcessed(event);

    this.setState({
      takingPicture: false,
      processingImage: false,
      showScannerView: this.props.cameraIsOn || false,
      feedbackState: true,
      currentImage: croppedImage,
    });

    console.log('===initialImage===');
    console.log(initialImage);
    console.log('===croppedImage===');
    console.log(croppedImage);
  };

따라서 이미지의 uri this.state.currentImage로 설정해주었을 때, 찍힌 사진의 미리보기를 제공할 수 있습니다.

          <AutoHeightImage
                  source={{
                    uri: this.state.currentImage,
                  }}
                  style={styles.feedbackImg}
                  width={wp(90)}
                />

다시찍기와 사용하기 버튼을 눌렀을 경우, feedback 함수를 호출합니다.

               <View style={styles.btnArea_l}>
                  <TouchableOpacity
                    style={styles.delbtnoutline}
                    onPress={() => {
                      {
                        this.feedback(1);
                      }
                    }}>
                    <Text>다시찍기</Text>
                  </TouchableOpacity>
                </View>

                <View style={styles.btnArea_r}>
                  <TouchableOpacity
                    style={styles.delbtn}
                    onPress={() => {
                      {
                        this.feedback(2);
                      }
                    }}>
                    <Text style={{color: 'white'}}>사용하기</Text>
                  </TouchableOpacity>
                </View>
                

feedback
다시찍기의 경우, feedbackState: false 로 하여 스캔 결과 화면을 끄고 사용하기의 경우 스캔 결과 화면을 끈 후
preparedImgages state 배열에 현재 찍은 이미지 파일의 경로를 append 해줍니다.
또한 한 번이라도 사용하기를 선택한 경우 isScanned: true 를 통해 카메라에 완료하기 버튼이 보이도록 해줍니다.

  feedback = (option) => {
    if (option == 1) {
      //다시 찍기
      this.setState({
        feedbackState: false,
      });
    } else {
      //사용하ㄱㅣ
      this.setState({
        feedbackState: false,
        preparedImgages: [
          ...this.state.preparedImgages,
          'file://' + this.state.currentImage,
        ],
        isScanned: true,
      });

      console.log('====this.state.preparedImgages===');
      console.log(this.state.preparedImgages);
    }
  };

위의 코드에서 this.state.preparedImgages를 콘솔에 출력해보면

다음과 같이 찍힌 이미지의 경로가 제대로 담기는 것을 확인할 수 있습니다.


📄 스캔 완료 후 AWS S3에 저장하기

한 번이라도 사용하기를 누른 후에는 isScanned: true에 의해, 카메라 스캔 화면에 완료 버튼이 나타납니다.

완료버튼을 누르면, postImages 함수를 호출합니다.

      {this.state.isScanned && (
              <TouchableOpacity
                style={styles.completebtn}
                onPress={() => {
                  {
                    this.postImages();
                  }
                }}>
                <Text style={{color: 'black', fontSize: wp(4.5)}}>완료</Text>
              </TouchableOpacity>
            )}

postImages
async, await, fetch를 이용해 데이터를 백엔로 보내는 과정입니다.
FormData는 서버에 파일을 보내기 위한 데이터 형식으로,
preparedImgages state배열에 담긴 준비된 이미지 파일경로들을 Formdata로 만들고 이를 서버에 post하는 코드입니다.
이후 해당 이미지들은 백엔드에서 AWS S3로 업로드됩니다.

postImages = async () => {
    const fd = new FormData();
    // console.log('==postimage filedata==');
    // console.log(filedata);

    this.state.preparedImgages.forEach((image) =>
      fd.append('mark', {
        name: image,
        uri: image,
        type: 'image/jpeg',
      }),
    );

    console.log('====fd===');
    console.log(fd);

    await axios
      .post('http://192.168.0.3:3001' + '/api/paper-upload/', fd, {
        headers: {
          'content-type': 'multipart/form-data',
        },
      })
      .then((res) => {
        const response = res.data;
        console.log(response.data.files);
        console.log('The paper images is successfully uploaded');
        // setUserPhoto(response.data.file.location);
        // updateProfile(response.data.file.location);
      })

      .catch((err) => {
        console.log('에러...');
        console.error(err);
      });
  };

최종 결과 화면

References

profile
👩🏻‍💻

0개의 댓글