궤도
[백엔드] 시험지 채점 알고리즘 만들기 (1) 본문
오늘은 이 그림에서 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 변수에 넣는다.
이제 이걸 들고 데베에 가서 채점만하면 된다.
그 전에...이제 객관식을 몇 개 찾지 못한 절망적인 상황을 보정하러 가보자