안녕하세요. 이번 포스팅에서는 리멤버 앱에 포함된 명함인식 기능에 관한 내용을 소개하려 합니다. 아래에서 그 내용을 간략히 소개해 드리려 합니다.
리멤버 iOS 앱에서 명함 영역 인식하기
Apple의 iOS SDK는 “CoreImage Framework“를 기본으로 제공해 주고 있는데요, 이것은 이미지 처리와 관련된 다양한 기능을 제공하고 있습니다. 저희 리멤버 iOS 앱도 이를 사용하여 명함 영역을 인식해 보기로 했습니다.
사각형 영역 찾기
종이명함은 기본적으로 직사각형의 형태를 지니고 있기 때문에, 주어진 이미지에서 사각형 형태를 찾아야 합니다. 이것은 CoreImage의 CIDetector를 통해 쉽게 구현할 수 있었습니다. CIDetector는 이미지 내의 Feature를 찾아주는 기능을 가지고 있는데, 현재 iOS9을 기준으로 얼굴, 사각형, QR코드, 문자열을 지원하고 있습니다.
// Specifies a detector type for face recognition. @available(iOS 5.0, *) public let CIDetectorTypeFace: String // Specifies a detector type for rectangle detection. @available(iOS 8.0, *) public let CIDetectorTypeRectangle: String // Specifies a detector type for barcode detection. @available(iOS 8.0, *) public let CIDetectorTypeQRCode: String // Specifies a detector type for text detection. @available(iOS 9.0, *) public let CIDetectorTypeText: String
리멤버에서는 “CIDetectorTypeRectangle” Type의 CIDetector를 사용하여 이미지 내에서 사각형 영역들을 찾아 보도록 하겠습니다.
let detector = CIDetector( ofType: CIDetectorTypeRectangle, context: nil, options: [ CIDetectorAccuracy: CIDetectorAccuracyHigh, CIDetectorMinFeatureSize: NSNumber(float: 0.2) ]) let options = [CIDetectorAspectRatio: NSNumber(float: 1.8)] if let rectangles = detector.featuresInImage(image, options: options) { var maxWidth: CGFloat = 0 var maxHeight: CGFloat = 0 var biggestRect: CIRectangleFeature? for rect in rectangles as! [CIRectangleFeature] { let minX = min(rect.topLeft.x, rect.bottomLeft.x) let minY = min(rect.bottomLeft.y, rect.bottomRight.y) let maxX = max(rect.bottomRight.x, rect.topRight.x) let maxY = max(rect.topLeft.y, rect.topRight.y) if (maxX - minX > maxWidth && maxY - minY > maxHeight) { maxWidth = maxX - minX maxHeight = maxY - minY biggestRect = rect } } ... }
위와 같이 CIDetector를 ‘CIDetectorTypeRectangle’으로 초기화한 뒤, featuresInImage 함수를 이용하면 이미지 내에서 간단하게 사각형 영역을 추출할 수 있습니다.
또한, CIDetector는 사용자가 원하는 특성에 맞는 형테를 찾을 수 있도록 여러가지 옵션을 제공하고 있습니다. 이중에서 리멤버 앱에서 중요한 옵션은 CIDetectorAspectRatio 였습니다. 일반적인 종이 명함의 가로/세로 비율이 1.8 정도인 것을 감안해, 이 옵션을 설정해 주면, CIDetector는 이미지 내에서 가로/세로 비율이 1.8에 근접한 사각형들을 찾아주게 됩니다.
이렇게 원하는 Target의 특성을 고려하여 여러가지 옵션을 설정해 주면 더 정확한 결과를 얻을 수 있습니다.
사각형 영역 보정하기
위에서 찾은 사각형 영역은 불특정 좌표 네개로 구성되어있기 때문에, ‘Perspective correction’ 라는 과정을 거쳐 직사각형 형태로 변환되어야 합니다. 이를 위해 CoreImage의 CIFilter를 사용합니다.
CIFilter는 이미지를 보정하거나 조작하기 위한 기능을 담고 있는 추상화된 클래스입니다. CIFilter는 다양한 이름의 filter를 제공하고 있으며 Core Image Programming Guide에서 확인하실 수 있습니다.
사각형 영역을 보정하기 위해선 “CIPerspectiveCorrection”이라는 이름의 CIFilter를 사용합니다. 해당 filter에 원본 이미지와 CIDetector로 찾은 사각형 영역의 각 모서리 좌표를 입력해 주면 간단하게 직사각형 형태로 변환된 이미지를 얻을 수 있습니다.
let perspectiveCorrection = CIFilter(name: "CIPerspectiveCorrection") perspectiveCorrection?.setValue(image, forKey: "inputImage") perspectiveCorrection?.setValue(CIVector(CGPoint: foundRect.topLeft), forKey: "inputTopLeft") perspectiveCorrection?.setValue(CIVector(CGPoint: foundRect.topRight), forKey: "inputTopRight") perspectiveCorrection?.setValue(CIVector(CGPoint: foundRect.bottomLeft), forKey: "inputBottomLeft") perspectiveCorrection?.setValue(CIVector(CGPoint: foundRect.bottomRight), forKey: "inputBottomRight") let outputImage = perspectiveCorrection?.outputImage
이와 같이 iOS에서는 CoreImage를 통해 수월하게 명함 영역을 찾을 수 있었습니다.
리멤버 Android 앱에서 명함 인식하기
Android에서는 위와 같이 이미지 처리와 관련된 공개된 SDK를 제공하고 있지 않아서, 외부 라이브러리를 활용하여 이미지 내의 명함을 인식하도록 Algorithm을 구현해야 했습니다. 그 내용을 아래에 간략히 소개해 드립니다.
Prerequisites
저희는 실제 명함 인식을 구현하기에 앞서, 몇가지 전제를 정의했습니다.
- 이미지 내에 비스듬하게 놓인 명함은 인식하지 않는다.
- 기준이하 크기의 명함은 인식하지 않는다.
- 세로명함은 추후 고려하도록 한다.
처음부터 광범위한 상황에 대처하기 보다는, 일부 상황에 대한 처리를 우선 진행하기로 한 것 입니다.
Brief algorithm
일단 저희는, 이미지 내의 직사각형 모양의 명함을 인식하기 위해 여러가지 방식을 시도해 보았습니다. 그 결과 저희가 주목한 방식은 ‘Line detection’을 이용한 것이었습니다. ‘Line detection’을 이용하여, 명함의 네 변을 찾아 적절히 조합할 수 있다면, 쉽게 명함 영역을 찾을 수 있겠다는 생각이었습니다.
간략히 Algorithm을 소개해 드리자면, 아래와 같습니다.
- Simplify image data
- Edge detection
- Line detection
- Find largest rectangle
Library 선택
저희에게 Image processing Library 선택은 중요한 문제였습니다. 처음에는 많은 Image processing algorithm을 지원하고 많은 reference를 가진 OpenCV를 사용하는것은 당연했습니다.
그러나 저희는 기능을 개발하면서 난관에 부딪히게 되었는데, 그 문제는 바로 성능이었습니다. 카메라를 통해 들어오는 이미지에서 실시간으로 명함 영역을 찾아야 하는데, 그 속도를 맞춰주지 못하고 있었습니다. 특히 ‘Line detection’ 부분에서 Resource를 많이 사용하고 있었습니다.
그래서 저희는 대안이 필요했고, 더 가벼운 라이브러리를 찾던중 BoofCV라는 Library를 찾게 되었습니다. BoofCV는 Play Store에 Demo App을 배포하고 있었는데요. 해당 앱을 다운받아 Line Detection관련 기능을 테스트 해보니 상당히 빠른 속도를 보여줬습니다. 아래 차트는 BoofCV에서 제공하는 OpenCV와 BoofCV의 몇가지 기능의 성능을 비교한 것인데요, 보시는 바와 같이 저희가 Line detection을 위해 사용하는 ‘Hough Line’에서 BoofCV가 훨씬 빠른 성능을 보이고 있었습니다.
위의 BoofCV를 적용하여 다시 테스트 해본 결과, 충분한 성능을 보여 BoofCV를 사용하기로 결정하게 되었습니다.
이미지 데이터 단순화 하기
이미지 처리는 많은 연산을 필요로 하기 때문에, 연산의 대상이 되는 데이터를 최소화 하는 것이 중요합니다. 이미지의 데이터가 큰 경우(이미지의 품질이 좋거나 크기가 큰)에는, 작은 경우에 비해 같은 연산을 수행해도, 훨씬 큰 리소스를 필요로 할 것입니다. 그래서 비디오 프레임에서 실시간으로 사각형 영역을 찾아야 하는 리멤버 앱에서도 연산의 대상이 되는 이미지의 데이터를 최소화해야 합니다.
컬러이미지를 흑백이미지로, 원본 크기의 이미지를 작은 크기의 이미지로 변환하여, 연산의 대상이 되는 이미지 데이터를 줄이도록 하였습니다. 그러나 이미지 데이터를 줄일 때에는 이미지의 특징이 사라지지 않도록 적절한 방식을 선택해야 합니다.
// 이미지 준비 ImageUInt8 cropUint = new ImageUInt8(bitmap.getWidth(), bitmap.getHeight()); ImageUInt8 scaleImg = new ImageUInt8(width, height); // Bitmap -> ImageUint8 ConvertBitmap.bitmapToGray(bitmap, cropUint, null); // 이미지 축소 AverageDownSampleOps.down(cropUint, scaleImg);
Edge Detecting
Edge detection을 통해 Line detection을 효율적으로 진행하려 했습니다. 이미지 내의 Edge라는 것은 이미지 내에서 색상의 변화가 두드러지게 큰 지점이라고 볼 수 있습니다. 이러한 두드러진 색상의 변화는 이미지 내의 특정 Feature의 윤곽을 단순하게 보여 주기 때문에 명함과 같은 사각형 형태를 찾는데 도움을 줍니다.
저희는 Sobel operator를 활용해서 Line detection을 준비했습니다. Sobel operator는 x-axis, y-axis의 색상변화를 별도로 계산하여 조합한다는 점에서, 저희가 목표로하는 명함의 horizontal line 두개와 vertical line 두개를 찾는데 도움이 될 것이라 판단했습니다.
ImageUInt8 grayProcX = new ImageUInt8(width, height); ImageUInt8 grayProcY = new ImageUInt8(width, height); ImageGradient<ImageUInt8, ImageSInt16> gradient = FactoryDerivative.sobel(ImageUInt8.class, ImageSInt16.class); ImageSInt16 derivX = new ImageSInt16(width, height); ImageSInt16 derivY = new ImageSInt16(width, height); gradient.process(scaleImg, derivX, derivY); Bitmap outputGradient = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); VisualizeImageData.colorizeGradient(derivX, derivX, -1, outputGradient, null); ConvertBitmap.bitmapToGray(outputGradient, grayProcX, null); VisualizeImageData.colorizeGradient(derivY, derivY, -1, outputGradient, null); ConvertBitmap.bitmapToGray(outputGradient, grayProcY, null);
Line detection
앞서 sobel operator의 결과물을 통해 Hough transform을 이용하여 선을 검출합니다. x-axis의 결과물을 vertical line을 검출하는데 사용하고, y-axis의 결과물은 horizontal line을 검출하는데 사용합니다.
DetectLine<ImageUInt8> detectorX = FactoryDetectLineAlgs.houghFoot(configHoughFootX, ImageUInt8.class, ImageSInt16.class); List<LineParametric2D_F32> foundLinesX = detectorX.detect(grayProcX); FastQueue<LineSegment2D_F32> linesX = new FastQueue<>(LineSegment2D_F32.class, true); for (LineParametric2D_F32 p : foundLinesX) { LineSegment2D_F32 ls = LineImageOps.convert(p, grayProcX.width, grayProcX.height); linesX.grow().set(ls.a, ls.b); } DetectLine<ImageUInt8> detectorY = FactoryDetectLineAlgs.houghFoot(configHoughFootY, ImageUInt8.class, ImageSInt16.class); List<LineParametric2D_F32> foundLinesY = detectorY.detect(grayProcY); FastQueue<LineSegment2D_F32> linesY = new FastQueue<>(LineSegment2D_F32.class, true); for (LineParametric2D_F32 p : foundLinesY) { LineSegment2D_F32 ls = LineImageOps.convert(p, grayProcY.width, grayProcY.height); linesY.grow().set(ls.a, ls.b); }
Detect largest rectangle
이제 검출된 선들을 이용하여, 사각형의 네변을 찾는 것만이 남았습니다. 우선, 이미지 내의 중심을 기준으로 horizontal line, vertical line들을 상, 하, 좌, 우로 분류합니다. 이 때, 각각의 기준에서 벗어난 선들은 생략합니다. 그 후 각각에서 중심에서 가장 떨어져 있는 선을 선택하여, 이미지 내의 가장 큰 사각형을 찾아 냅니다.
// 좌, 우, 위, 아래 라인 분리 List<LineSegment2D_F32> upper = new ArrayList<>(); List<LineSegment2D_F32> bottom = new ArrayList<>(); List<LineSegment2D_F32> left = new ArrayList<>(); List<LineSegment2D_F32> right = new ArrayList<>(); for (LineSegment2D_F32 s : linesX.toList()) { double degree = CropUtil.computeDegree(s); if (degree <= 10) { if ((s.a.y + s.b.y) / 2 > width / 2) { right.add(s); } else { left.add(s); } } } for (LineSegment2D_F32 s : linesY.toList()) { double degree = CropUtil.computeDegree(s); if (degree >= 80) { if ((s.a.y + s.b.y) / 2 > H / 2) { upper.add(s); } else { bottom.add(s); } } } // 좌표 정렬 Collections.sort(upper, new YDESCComprator()); Collections.sort(bottom, new YASCComprator()); Collections.sort(left, new XASCComprator()); Collections.sort(right, new XDESCComprator()); // 가장 외곽라인 추출 LineSegment2D_F32 upperLine = upper.size() > 0 ? upper.get(0) : null; LineSegment2D_F32 bottomLine = bottom.size() > 0 ? bottom.get(0) : null; LineSegment2D_F32 leftLine = left.size() > 0 ? left.get(0) : null; LineSegment2D_F32 rightLine = right.size() > 0 ? right.get(0) : null;
Perspective correction
iOS에서와 마찬가지로, 위에서 찾은 불특정 좌표 네개를, perspective correction 과정을 거쳐 직사각형 형태로 변환시켜 주어야 합니다. 아래와 같은 방식으로 간단하게, 변환을 할 수 있습니다.
Matrix matrix = PerspectiveTransformation.matrix(coordinates, bitmap, bitmap.getWidth(), bitmap.getHeight()); Bitmap output = PerspectiveTransformation.transform(pool, bitmap, matrix);
마무리
지금까지 리멤버 앱의 명함인식 기능에 대해 소개해 드렸습니다. 명함인식 기능은 많은 시행착오를 통해 구현되었지만, 아직 부족한 점이 많습니다. 앞으로 개선될 기능을 기대해 주세요.
그럼 이 글이 많은 분들께, 도움이 되었길 바라며 글을 마치도록 하겠습니다.
감사합니다.
안녕하세요. 리멤버 명함인식 관련 글을 보면서 샘플 코드를 따라 만드는 중에
CropUtil.computeDegree(s) 이부분이 어떤걸 의미 하는지 어떤역할을 하는지 궁금해서 글을 남깁니다.
답변이 가능하시다면 답변 부탁드립니다.
좋은 글 감사합니다.