시각장애인을 위한 빠르고 정확한 수학교재 인공지능 점자 변환 서비스, Sunny Braille
어떤 교재든 텍스트 뿐만 아니라 수식도 점자로 변환해주는 교육용 AI 점역 소프트웨어
해바라기 팀 소개 영상 ⇒ https://www.youtube.com/watch?v=cuYIEpQx2po&t=8s
Sunny Braille 소스 코드 ⇒ https://github.com/sunnybraille
포스팅 관련 PR ⇒ https://github.com/Sunflower-yonsei/sunflower-server/pull/13
💋 구현 내용
✔️ 목표
현재 해바라기 팀의 서버는 젠부 장고로 구현되어 있다.
초창기에 자바&스프링을 다룰 수 있는 멤버가 없었기 때문 + 다양한 그나름의 이유로 장고로 구현되어 있었는데,
새로운 팀원을 구할 때에도 언어가 장벽이 되고 있고 장고를 사용하던 팀원의 이탈로 이제 유지보수가 정말 어려워졌다
게다가 장고에는 우리 팀이 직접 제작한 비밀 점역 알고리즘과 백엔드 코드가 섞여 있어서 진짜 헬이 되어버렸다.
합류 후 나의 첫 미션은 해바라기의 핵심 기능(이자 사실 이제까지는 기능의 전부인 2가지) API를 빠르게 스프링으로 재구현하고, 비밀 점역 알고리즘은 별도의 API로 분리해 낸다.
++ 또한 추후에 추가될 요구사항에도 유연하게 대응할 수 있게 해야 한다.
Sunny Braille 서비스에서는 사용자가 수학 문제를 촬영한 pdf를 업로드하면, 클라이언트는 pdf를 form 형태로 서버에 보낸다.
이제 나(서버개발자..)는 그 pdf를 포함해서 Mathpix API를 호출해서 수식 사진 ⇒ Latex 파일
으로 변경하는 작업을 해야 한다.
개발중인 API에 대한 이슈는 이 링크를 들어가면 있당
✔️ 구현해야 하는 내용
Mathpix API를 2번 호출해야 한다.
- pdf 파일로 사진을 보내서 pdf로 변환하면, api에서는
pdf_id
를 반환해준다. - 이
pdf_id
를 다시 가지고 호출해서 변환된 결과의 Latex 파일을 받을 수 있다.
그러고, 이제 받은 Latex 파일을 내 데이터베이스에 저장하고 (후에 캐시와 비슷하게 사용할까 생각중이기 때문에 일단 저장하기로 했다.)
저장된 결과를 translations
라는 데이터베이스에 저장한 후, id 값을 클라이언트에 반환해줄 것이다.
이와 별도로 또 클라이언트에서 translations
id를 보내오면 그때 Latex 파일을 반환하는 API를 하나 더 만들어야 할 것이다.
✔️ [개인적 궁금증] Mathpix API 반환을 Latex 파일로 받는 이유
Mathpix API 공식 문서에 따르면, 다양한 형식으로 결과물을 받을 수 있는데
우리는 그중 Latex 파일
로 받기로 했다.
내가 합류하기 전부터 이미 이렇게 하고 있어서 이유를 물어봤는데
- Tex(LaTeX) 형식이 수식을 표현하는데 편함
- 문법이 정형화 되어 있음
- 대부분 OCR에서 수식을 표현할때 레이텍을 사용함
이 세가지 이유라고 한다.
✔️ 느낀점
구현 방법에 앞서 가장 중요한 느낀점부터,,,!
일단 REST API를 통해서 pdf를 다룬 적도 없는데, Latex 형식 또한 너무 낯설어서 생각보다 많은 시간이 걸렸다.
막 그냥 일단 대강 해보려고 했는데 역시나 공식문서 찬찬히 읽는게 가장 돌아가는 듯 해도 가장 빠른 길,, 항상 잊는듯
💋 Mathpix API에 PDF 파일 보내고 식별자 반환 받기!
✔️ Mathpix API에 곧바로 파일 보내는 방식 선택!
이게 pdf 파일의 url을 보낼 수도 있고, pdf 파일을 곧바로 보낼 수도 있다.
기존에 팀에서는 장고 서버에 받은 pdf를 저장했다가 그 url을 호출하는데, 재구현하는 입장에서 딱히 사진을 가지고 있을 필요도 없고 해서 그냥 곧바로 pdf 파일을 보내는 편이 낫다고 생각했다.
친절하게 이런 예시로 보내라고도 공식문서에 써있길래 이대로 코드를 작성해봤는데,,,
curl --location --request POST 'https://api.mathpix.com/v3/pdf' \
--header 'app_id: APP_ID' \
--header 'app_key: APP_KEY' \
--form 'file=@"cs229-notes5.pdf"' \
--form 'options_json="{\"conversion_formats\": {\"docx\": true, \"tex.zip\": true}, \"math_inline_delimiters\": [\"$\", \"$\"], \"rm_spaces\": true}"'
도무지 테스트를 할 수가 없는 것…?!!
어떻게 해야하지 고민하다가…
✔️ 포스트맨으로 pdf 전송하는 방법..?
처음에는 http 파일 작성해서 어떻게 해보려고 했는데 계속 안되다가 갑자기 이전에 이리내가 작성했던 글 생각나서 해냈다. 진짜 휴
body를 form-data로 바꾸면, key에서 text나 file중에 선택할 수 있는데 파일로 변경하면 내 로컬에 있는 파일 중에 고를 수 있다.
이걸로 요청 보내면, 일단 내 톰캣 서버에서는 파일을 잘 받는 것 같다.
근데 이거 어떻게 테스트코드를 작성해야할지 진짜 모르겠다ㅋㅋㅋㅋㅋㅋㅌㅌ 일단 API 찬스 하나씩 써가면서 개발중,,
✔️ Mathpix API 문서대로 ‘제대로’ 호출하기
여러 에러를 만났지만, 역시 그냥 api 문서에 나와있는 모양 정말 그대로 제대로 호출하는 방법 말고는 해결할 방법이 없다.
공식문서에 나온 API 스펙을 열심히 읽어보다보면 이렇게 예시를 준다.
요청을 아래와 같이 보내면,
curl --location --request POST 'https://api.mathpix.com/v3/pdf' \
--header 'app_id: APP_ID' \
--header 'app_key: APP_KEY' \
--form 'file=@"cs229-notes5.pdf"' \
--form 'options_json="{\"conversion_formats\": {\"docx\": true, \"tex.zip\": true}, \"math_inline_delimiters\": [\"$\", \"$\"], \"rm_spaces\": true}"'
아래와 같은 모양의 응답을 받을 수 있다고 한다.
{
"pdf_id": "5049b56d6cf916e713be03206f306f1a"
}
이렇게 응답 바디 파라미터들도 자세히 잘 알려주고 있음. 마음을 활짝 열고 보지 않으면 진짜 보기 싫게 생겼다..ㅋ
위에서 설명한 방법대로 포스트맨으로 보내봤더니 드디어 제대로 response body로 pdf id가 온다!
(쉬워보이지만 나름 여기까지 마음을 열고, 놀던 습관을 잡고 하느라 진짜 오래걸림. 이게 타이거팀 맞나..?)
나는 별도로 MathpixApiPdfProcessApiClient라는 컴포넌트를 별도로 만들어서 거기서 모든 작업을 처리하고 서비스 코드에는 응답으로 받은 pdf id만을 반환하는 메서드만을 열어두었다.
아래는 코드 전문!
package sunflower.server.client;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import static org.springframework.http.MediaType.MULTIPART_FORM_DATA;
@Slf4j
@Component
public class MathpixApiPdfProcessClient {
private final String appURI;
private final String appId;
private final String appKey;
private final RestTemplate restTemplate;
public MathpixApiPdfProcessClient(
@Value("${mathpix.app-uri}") String appURI,
@Value("${mathpix.app-id}") String appId,
@Value("${mathpix.app-key}") String appKey,
RestTemplate restTemplate
) {
this.appURI = appURI;
this.appId = appId;
this.appKey = appKey;
this.restTemplate = restTemplate;
}
public String requestPdfId(final MultipartFile file) {
final ObjectMapper objectMapper = new ObjectMapper();
final HttpHeaders requestHeader = createRequestHeader();
final MultiValueMap<String, Object> requestBody = createRequestBody(file, objectMapper);
final HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(requestBody, requestHeader);
log.info("Request URI: {}", appURI);
log.info("Request Headers: {}", requestHeader);
log.info("Request Parameters: {}", requestBody);
// send request to Mathpix API (process a pdf)
final ResponseEntity<String> response = restTemplate.postForEntity(appURI, requestEntity, String.class);
if (response.getStatusCode() != HttpStatus.OK) {
log.warn("OCR 작업에 실패했습니다. Mathpix API 에러 메세지: {}", response.getBody());
throw new RuntimeException("OCR 작업에 실패했습니다.");
}
try {
final JsonNode root = objectMapper.readTree(response.getBody());
String pdfID = root.get("pdf_id").asText();
log.info("PDF ID: {}", pdfID);
return pdfID;
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
private HttpHeaders createRequestHeader() {
HttpHeaders requestHeader = new HttpHeaders();
requestHeader.setContentType(MULTIPART_FORM_DATA);
requestHeader.set("app_id", appId);
requestHeader.set("app_key", appKey);
return requestHeader;
}
private MultiValueMap<String, Object> createRequestBody(final MultipartFile file,
final ObjectMapper objectMapper) {
MultiValueMap<String, Object> requestBody = new LinkedMultiValueMap<>();
requestBody.add("file", file.getResource());
Map<String, Object> bodyMap = new HashMap<>();
bodyMap.put("conversion_formats", Map.of("docx", true, "tex.zip", true));
bodyMap.put("math_inline_delimiters", Arrays.asList("$", "$"));
bodyMap.put("rm_spaces", true);
String optionsJson = null;
try {
optionsJson = objectMapper.writeValueAsString(bodyMap);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
requestBody.add("options_json", optionsJson);
return requestBody;
}
}
그럼 다음에는 Latex 파일 받는 과정까지를 다뤄보도록 하겠습니당,,
💋 참고자료
- https://docs.mathpix.com/#process-a-pdf
- https://engineerinsight.tistory.com/295
- https://finger-ineedyourhelp.tistory.com/80
- https://docs.mathpix.com/#processing-status
도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟
비밀댓글과 메일을 통해 오는 개인적인 질문은 받지 않고 있습니다. 꼭 공개댓글로 남겨주세요!