궤도

[백엔드] 시험지 채점 알고리즘 만들기 (1) 본문

💻 현생/📃 VIVA

[백엔드] 시험지 채점 알고리즘 만들기 (1)

영이오 2021. 5. 20. 13:07

오늘은 이 그림에서 7번을 구현할 것이다.

 

먼저 시험지를 촬영해서 욜로에 보내면...욜로는 우리가 설정한 클래스의 객체를 찾아낸다.

그리고 제이슨 형태로 결과를 반환하는데

뭐...

 

label : OOO

recognition word : OOO

w : OOO

h : OOO

x : OOO

y : OOO

 

객체마다 이런식으로 반환한다.

직접 보여주면 좋겠지만 내 담당이 아니라 여기서 보여주기가 좀 그렇다

 

우리가 찾아야 하는 클래스는 문제번호, 페이지번호, 객관식 체크 정보, 주관식 답안 정보인데

다른건 다 잘 찾는데 객관식 체크 정보를 아직도 몇 개씩 빠뜨린다. 그래서 백엔드에서 보정해줘야 한다.

일단은 객관식을 다 찾았다는 아주 희망적인 가정을 하고 알고리즘을 짜보겠다.

 

우리의 테스트 셋이다.

 

앞서 말했지만 보안상의 이유...라기엔 깃허브에 코드가 다 올라왔지만 아무튼 그래서 설명을 대충할 것이다.

    for (var i in json.yolo_result[index]) {
        let cur = json.yolo_result[index][i];
        let spn_list;
        if (cur.label == "spn" || cur.label == "page_num") { //한 문제가 끝난 것
            if (i > 0) {
                if (cnt > 1) //객관식 정리
                    check_list = refactoringCheck(check_list);
                ans[ans_cnt] = new ans_info(spn, check_list);
                check_list = new Array(); //초기화
                cnt = 0;
                ans_cnt++;
            }
            spn_list = new Array();
            spn_list = cur.recognition_word; //spn에서 찾은 모든 단어
            spn = findSpn(spn_list);
        } else if (cur.label == "uncheck_box" || cur.label == "check_box") { //객관식 정보 입력
            check_list[cnt] = new check_info(cur.label, cur.x, cur.y);
            cnt++;
        } else if (cur.label == "short_ans") { //주관식 정보 입력
            check_list[cnt] = Number(cur.recognition_word);
            cnt++;
        }
    }

먼저 각 페이지에서 찾은 객체들을 하나하나 살펴볼 것이다.

플라스크에서 넘어온 json은 각 객체의 정보만 담고 있을 뿐 이걸 각 문제별로 맵핑하지는 않았다.

그래서 난 (21번 문제의 객체들), (22번 문제의 객체들) 이런식으로 묶을 예정이다.

 

        if (cur.label == "spn" || cur.label == "page_num") { //한 문제가 끝난 것
            if (i > 0) {
                if (cnt > 1) //객관식 정리
                    check_list = refactoringCheck(check_list);
                ans[ans_cnt] = new ans_info(spn, check_list);
                check_list = new Array(); //초기화
                cnt = 0;
                ans_cnt++;
            }
            spn_list = new Array();
            spn_list = cur.recognition_word; //spn에서 찾은 모든 단어
            spn = findSpn(spn_list);
        } else if (cur.label == "uncheck_box" || cur.label == "check_box") { //객관식 정보 입력
            check_list[cnt] = new check_info(cur.label, cur.x, cur.y);
            cnt++;
        } else if (cur.label == "short_ans") { //주관식 정보 입력
            check_list[cnt] = Number(cur.recognition_word);
            cnt++;
        }

각 객체의 라벨은 spn(문제번호), page_num(페이지 번호), (un)check_box(객관식 정보), short_ans(주관식 정보)가 있다.

플라스크에서 객체를 그대로 보내주는 건 아니고 1차 정렬을 해서 보내주는데,

 

spn

객/주관식 정보 어쩌구저쩌구

spn

객/주관식 정보 어쩌구저쩌구

page_num

 

이런 순서로 보내준다. 그니까 spn또는 page_num인 라벨이 나왔다면 문제 하나가 끝난 것이라고 봐도 된다는 것이다.

        if (cur.label == "spn" || cur.label == "page_num") { //한 문제가 끝난 것
            if (i > 0) {
                if (cnt > 1) //객관식 정리
                    check_list = refactoringCheck(check_list);
                ans[ans_cnt] = new ans_info(spn, check_list);
                check_list = new Array(); //초기화
                cnt = 0;
                ans_cnt++;
            }
            spn_list = new Array();
            spn_list = cur.recognition_word; //spn에서 찾은 모든 단어
            spn = findSpn(spn_list);
        }

바로 이 부분이다.

한 페이지의 첫번째 객체의 라벨 역시 spn이기 때문에 i>0인 경우에만 문제가 끝났다고 하고 처리한다.

이 부분은 이따가 다시 보도록 하자

 

        else if (cur.label == "uncheck_box" || cur.label == "check_box") { //객관식 정보 입력
            check_list[cnt] = new check_info(cur.label, cur.x, cur.y);
            cnt++;
        }

라벨이 객관식 정보인 경우다.

이때는 check_list 배열에 해당 객체의 라벨 이름과 x좌표와 y좌표를 저장한다.

 

이런식으로 저장된다는 것이다.

 

        else if (cur.label == "short_ans") { //주관식 정보 입력
            check_list[cnt] = Number(cur.recognition_word);
            cnt++;
        }

이번엔 라벨이 주관식 정보인 경우다.

이때는 check_list 배열에 해당 객체의 내용을 ocr로 읽은 recognition_word를 저장한다.

 

이렇게

사실 이건 112라고 쓴 답을 12라고 읽은건데 이건 ocr이 이렇게 읽은거라 우리가 보정을 할 수 없다...

 

아무튼 이런식으로 새로운 spn, 또는 page_num이 나올때까지 객관식 정보 5개 이상(왜 이상인지는 뒤에 설명)과 주관식 정보 1개가 check_list에 저장될 것이다.

 

        if (cur.label == "spn" || cur.label == "page_num") { //한 문제가 끝난 것
            if (i > 0) {
                if (cnt > 1) //객관식 정리
                    check_list = refactoringCheck(check_list);
                ans[ans_cnt] = new ans_info(spn, check_list);
                check_list = new Array(); //초기화
                cnt = 0;
                ans_cnt++;
            }
            spn_list = new Array();
            spn_list = cur.recognition_word; //spn에서 찾은 모든 단어
            spn = findSpn(spn_list);
        }

다시 여기로 돌아와서

아까 check_list를 처리할 때 사용한 cnt 변수가 기억난다면 cnt>1이 어디에 쓰이는지 알 수 있을 것이다.

주관식 문제였다면 cnt는 1일 것이다. 만약 cnt가 1보다 크다면 check_list에는 객관식 정보가 있다는 뜻이다.

 

근데 사실 객관식들은 이렇게 정렬도 안됐고, 이 경우엔 5개만 나왔지만 6~7개가 나올 때도 있다.

왜 6~7개가 나오냐면 욜로에서 threadhold인가 그거 때문에 한 객체를 중복해서 찾은 그런 문제라고 했는데...

사실 이 문제는 모든 객체에 있지만 spn, short_ans, page_num 등은 연속해서 동일 라벨이 나오는 경우가 없기 때문에 플라스크에서 이미 중복 제거를 다 해준 상태이다.

 

하지만, 연속으로 나올 수 있는 (un)check_box를 이런식으로 처리할 순 없기 때문에 내가 처리해줘야 한다.

겸사겸사 정렬도 해줘야 하고

 

그래서 check_list에 담긴 것이 객관식이라면 refactoringCheck로 간다.

 

function refactoringCheck(check_list) { //객관식 답안 중복 제거하고 정렬
    let first_y, second_y = 0;
    for (var i in check_list) {
        if (i == 0) //첫번째 원소의 y 좌표로 전부 통일할 것
            first_y = check_list[i].y_pos;
        else if (Math.abs(check_list[i].y_pos - first_y) < 15) //같은 줄
            check_list[i].y_pos = first_y;
        else { //다른 줄
            if (second_y == 0) //second_y가 없으면 초기화
                second_y = check_list[i].y_pos;
            check_list[i].y_pos = second_y; //두번째 줄
        }
    }
    check_list.sort(function (a, b) { //오름차순 정렬
        if (a.y_pos == b.y_pos) { //같은 줄
            if (a.x_pos > b.x_pos) return 1;
            return -1;
        } else { //다른 줄
            if (a.y_pos > b.y_pos) return 1;
            return -1;
        }
    })
    for (var i in check_list) {
        if (i == 0)
            continue;
        if (Math.abs(check_list[i].x_pos - check_list[i - 1].x_pos) < 10) { //이전 것과 x_pos 차이 거의 없으면 중복
            check_list.splice(i, 1);
            i--;
        }
    }
    return check_list;
}

객관식의 번호 배치는 2가지가 있다.

 

이렇게 일자로 배치되거나

 

이렇게 두 줄에 걸쳐서 배치되거나

 

첫번째 경우는 간단하다. 5개의 y좌표를 모두 하나로 통일하고 x좌표에 대해 정렬하면 된다.

하지만 두번째 경우는 y좌표를 통일할 수 없다. 근데 뭐 의외로 간단했다.

 

    let first_y, second_y = 0;
    for (var i in check_list) {
        if (i == 0) //첫번째 원소의 y 좌표로 전부 통일할 것
            first_y = check_list[i].y_pos;
        else if (Math.abs(check_list[i].y_pos - first_y) < 15) //같은 줄
            check_list[i].y_pos = first_y;
        else { //다른 줄
            if (second_y == 0) //second_y가 없으면 초기화
                second_y = check_list[i].y_pos;
            check_list[i].y_pos = second_y; //두번째 줄
        }
    }

좌표를 통일하는 부분이다.

일자로 배치된 문제는 first_y만 사용될 것이고, 두 줄로 배치된 문제는 second_y도 쓰일 것이다.

변수이름을 보면 대충 알겠지만 각각 처음으로 발견된 y좌표와 그 좌표의 아래 또는 위에 있을 y좌표이다.

 

그니까 두번째 경우에서 first_y는 1, 2, 3번 중 하나의 좌표거나 또는 4, 5번 중 하나의 좌표일 수 있고

second_y는 first_y가 1, 2, 3번 중 하나일 땐 4, 5번 중 하나의 좌표이며

first_y가 4, 5번 중 하나일 땐 1, 2, 3번 중 하나의 좌표다.

 

        if (i == 0) //첫번째 원소의 y 좌표로 전부 통일할 것
            first_y = check_list[i].y_pos;

일단 처음에 있는 객체의 y좌표를 first_y라고 하고

 

        else if (Math.abs(check_list[i].y_pos - first_y) < 15) //같은 줄
            check_list[i].y_pos = first_y;

그 이후에 발견된 객체의 y좌표와 first_y의 차이가 15보다 작다면 둘은 같은 줄이다.

사실 더 tight하게 잡아도 되는데 그냥 이정도로 했다.

 

        else { //다른 줄
            if (second_y == 0) //second_y가 없으면 초기화
                second_y = check_list[i].y_pos;
            check_list[i].y_pos = second_y; //두번째 줄
        }

한편 둘의 차이가 15이상이면 다른 줄에 위치한 객체라는 것이다.

second_y가 0이라면 아직 second_y가 초기화되지 않은 것이니 초기화 해주고

그렇지 않을 경우엔 이미 있는 second_y로 y좌표를 바꿔준다.

 

y_pos가 통일됐다.

 

이제 정렬을 하자.

    check_list.sort(function (a, b) { //오름차순 정렬
        if (a.y_pos == b.y_pos) { //같은 줄
            if (a.x_pos > b.x_pos) return 1;
            return -1;
        } else { //다른 줄
            if (a.y_pos > b.y_pos) return 1;
            return -1;
        }
    })

그냥 간단하게 둘이 같은 줄이면 x좌표 기준으로 정렬하고 아니라면 y좌표가 큰게 더 뒤로 가도록 했다.

 

정렬됐다.

 

마지막으로 이 경우엔 없지만 중복 발견된 객체를 지워주는 작업을 해야한다.

    for (var i in check_list) {
        if (i == 0)
            continue;
        if (Math.abs(check_list[i].x_pos - check_list[i - 1].x_pos) < 10) { //이전 것과 x_pos 차이 거의 없으면 중복
            check_list.splice(i, 1);
            i--;
        }
    }

정렬된 상태에서 바로 직전 객체와의 x좌표 차이가 유의미하게 적으면 동일 객체로 판단하고 지운다.

 

이렇게 객관식을 보정했다.

 

        if (cur.label == "spn" || cur.label == "page_num") { //한 문제가 끝난 것
            if (i > 0) {
                if (cnt > 1) //객관식 정리
                    check_list = refactoringCheck(check_list);
                ans[ans_cnt] = new ans_info(spn, check_list);
                check_list = new Array(); //초기화
                cnt = 0;
                ans_cnt++;
            }
            spn_list = new Array();
            spn_list = cur.recognition_word; //spn에서 찾은 모든 단어
            spn = findSpn(spn_list);
        }

또다시 여기로 돌아와서...

그럼 이렇게 보정된 객관식 또는 주관식을 ans에 spn과 함께 넣어주고 check_list와 cnt를 초기화 하는데

spn은 어디서 찾았었나...

바로 그 아래 3줄에 있다.

 

사실 spn은 short_ans와 달리

recognition word가 1개가 아닐 수 있다.

 

이건 spn의 경계가 다른 글씨랑 너무 붙어있어서 ocr에 넘어가는 사진이

이것보단 작지만 아무튼 이런식으로 잘려서 발생하는 문제인데

그럼 여기서 spn만 찾아주는 작업을 해야한다. 그걸 하는게 findSpn 함수다.

 

function findSpn(spn_list) { //spn만 찾음
    let spn = '';
    for (var i in spn_list[0]) {
        if (spn_list[0][i] >= '0' && spn_list[0][i] <= '9')
            spn += spn_list[0][i];
        else
            break;
    }
    return spn;
}

별거 없다.

spn은 spn_list의 첫번째 인덱스 어딘가에 존재하기 때문에 첫번쨰 인덱스만 보면서 숫자가 나올때까지만 확인하고

결과를 반환한다.

 

            spn_list = new Array();
            spn_list = cur.recognition_word; //spn에서 찾은 모든 단어
            spn = findSpn(spn_list);

아무튼 각 spn은 이런식으로 발견됐다는 것...여기까지 하면 우리의 중구난방 json은

 

이렇게 된다.

뭐...주관식은 이대로 넘겨도 괜찮을지 몰라도 객관식은 아직 그래서 뭐가 체크된건지 알 수 없는 상태다.

그리고 spn도 integer가 아니라 string이라 이것도 바꿔주면 좋을 것 같다.

 

    let final_list = finalList(ans);

그래서 최종 리스트를 만든다.

 

function finalList(ans_list) {
    let final_list = new Array();
    for (var i in ans_list) {
        let spn = Number(ans_list[i].spn); //spn 숫자화
        let checked = 0; //답을 뭐라고 했을까
        if (ans_list[i].ans.length == 1)  //길이가 1이라면 주관식
            checked = ans_list[i].ans[0];
        for (var j in ans_list[i].ans) { //객관식
            if (ans_list[i].ans[j].label == "check_box") { //체크박스
                checked += (Number(j) + 1);
                break;
            }
        }
        final_list[i] = new ans_info(spn, checked); //문제번호 - 답안 쌍
    }
    return final_list;
}

먼저 spn을 숫자로 바꿔주고

ans의 길이가 1이라면 주관식이란 뜻이니 checked 변수에 그냥 그 값을 그대로 넣으면 되고

객관식이라면 check_box 라벨을 찾아 그 위치를 checked 변수에 넣는다.

 

이제 이걸 들고 데베에 가서 채점만하면 된다.

 

그 전에...이제 객관식을 몇 개 찾지 못한 절망적인 상황을 보정하러 가보자

Comments