[업무일지] EXIF 회전 정보 때문에 빗나간 신분증 마스킹
신분증에서 주민번호 뒷자리를 가리는 기능을 만들었는데, 일부 이미지에서 엉뚱한 영역이 칠해졌습니다. OCR 이 준 좌표는 정확했고 문제는 EXIF 회전 정보였습니다. 마스킹 앞에 EXIF 회전 보정을 넣어서 해결했습니다.
셀러가 계약할 때 신분증과 사업자등록증을 올립니다. 그 서류에서 필요한 값을 뽑아 쓰고 주민등록번호 뒷자리는 가려서 저장해야 했습니다.
처음엔 이걸 단순한 작업으로 봤습니다. OCR 이 텍스트 위치를 좌표로 돌려주니까 그 위에 검정 사각형을 덮으면 끝이라고요.
OCR 이 준 좌표에 다각형을 채우는 것까지는 쉬웠다
OCR 응답에는 인식한 각 필드의 영역이 꼭짓점 좌표 목록으로 들어옵니다. 사각형이 아니라 다각형인 이유는 사진이 비스듬하게 찍히면 텍스트 영역도 기울어지기 때문입니다.
그래서 Graphics2D 로 다각형을 채우는 유틸을 만들었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static byte[] applyPolygonMaskToImage(byte[] imageBytes, List<List<Point>> polygons, String format) throws IOException {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageBytes));
Graphics2D graphic = image.createGraphics();
graphic.setColor(Color.BLACK);
for (List<Point> poly : polygons) {
Polygon polygon = new Polygon();
for (Point p : poly) {
polygon.addPoint((int) p.getX(), (int) p.getY());
}
graphic.fillPolygon(polygon);
}
graphic.dispose();
return convertBufferedImageToByteArray(image, format);
}
Java 17, Spring Boot 3.3.x 환경입니다. 로컬에서 테스트할 때는 잘 됐습니다. 제가 캡처해서 만든 샘플 이미지로만 봤으니까요.
휴대폰으로 찍은 사진에서 다른 곳이 칠해졌다
QA 에서 실제 휴대폰으로 찍은 신분증을 올리니 마스킹이 엉뚱한 데 들어갔습니다. 어떤 건 아래쪽 여백이 칠해지고, 어떤 건 이름 위에 칠해졌습니다.
처음엔 OCR 이 좌표를 잘못 준 줄 알았습니다. 응답을 찍어보니 좌표는 정확했습니다. 그러면 제 다각형 그리는 코드가 문제인가 싶었는데 그것도 아니었죠. 같은 좌표로 같은 코드가 어떤 이미지에서는 맞고 어떤 이미지에서는 틀렸습니다.
한참 헤매다 이미지 자체를 뜯어봤습니다. 원인은 EXIF Orientation 이었습니다.
휴대폰으로 세로로 찍은 사진은 픽셀 데이터가 가로로 저장되고, “이건 90도 돌려서 봐야 한다”는 정보를 EXIF 메타데이터에 따로 넣습니다. 이미지 뷰어나 브라우저는 그 값을 읽어서 알아서 회전해 보여줍니다.
그런데 여기서 두 가지가 어긋납니다.
- OCR 서버는 회전을 적용한 상태, 즉 사람이 보는 방향으로 인식해서 좌표를 돌려줍니다.
ImageIO.read()는 EXIF 를 무시하고 저장된 픽셀 그대로 읽습니다.
그러니까 OCR 은 “회전 후 기준”의 좌표를 주는데 저는 “회전 전 픽셀”에 그걸 그리고 있었던 겁니다. 90도 어긋난 좌표계에 칠하고 있었으니 엉뚱한 곳이 가려지는 게 당연했습니다.
마스킹 전에 회전을 먼저 적용했다
해결은 간단했습니다. 마스킹하기 전에 EXIF 회전을 먼저 적용해서 OCR 이 본 것과 같은 좌표계로 맞추는 것입니다.
1
2
3
4
5
6
public static byte[] applyPolygonMaskToImage(byte[] imageBytes, List<List<Point>> polygons, String format) throws IOException {
// 마스킹 전에 EXIF 회전을 반영해 OCR 이 본 좌표계와 일치시킨다
BufferedImage image = correctImageOrientation(imageBytes);
Graphics2D graphic = image.createGraphics();
// ... 이하 동일
}
correctImageOrientation() 은 metadata-extractor 로 EXIF 의 Orientation 태그를 읽고 그 값에 맞게 이미지를 회전시켜 BufferedImage 로 돌려줍니다. 회전 정보가 없으면 원본을 그대로 반환합니다.
여기서 부수적으로 하나 더 걸렸습니다. 마스킹본을 원래 요청 파일 자리에 넣어서 뒤에 이어지는 변환과 업로드를 그대로 태워야 하는데, 그 처리들이 MultipartFile 을 받도록 되어 있었거든요. MultipartFile 은 요청에 실려 오는 파일을 표현하는 인터페이스라 운영 코드에서 바이트 배열을 담아 쓸 구현체가 없습니다(spring-test 의 MockMultipartFile 은 테스트용이고요).
그래서 MultipartFile 을 직접 구현했습니다.
1
2
3
4
5
6
7
public class CustomMultipartFile implements MultipartFile {
private final String name;
private final String originalFilename;
private final String contentType;
private final byte[] content;
// getter 와 getInputStream 등 인터페이스 메서드 구현
}
변환 처리가 바이트 배열도 받도록 바꾸는 방법도 있었습니다. 그런데 그 변환 처리를 쓰는 곳이 여러 군데였고, 각 호출부의 사정을 제가 다 알지 못했습니다. 그래서 호출부를 건드리지 않는 쪽을 골랐습니다. 인터페이스를 바꾸는 쪽이 더 깔끔하긴 합니다. 그래도 지금은 이게 안전한 선택이라고 봤습니다.
빗나가면 주민번호가 그대로 남는다
마스킹이 빗나가면 기능이 안 되는 데서 끝나지 않습니다. 가려야 할 주민번호가 그대로 남거든요. 에러도 안 나니 누가 열어 보기 전까지는 아무도 모르고요. 그래서 마스킹 결과를 눈으로 확인하는 절차를 QA 에 넣었습니다.
EXIF 회전은 처리했지만 심하게 기울어진 사진은 여전히 OCR 인식률이 낮습니다. 기울기를 보정하는 전처리까지 넣을지는 아직 결정하지 못했습니다.