OpenCV

Opencv+Unity 이용하여 문서인식 앱 만들기

Dean83 2022. 4. 12. 17:11

1. 개요
    - https://dean83.tistory.com/68 에서 설명한 문서인식하는 방법과

    - https://dean83.tistory.com/69?category=1088148 에서 설명한 카메라 띄우는 방법을 활용하였다.

 

2. 소감

    - 사실 문서 인식 앱을 만드는건 어렵지 않다. 하지만, 성능을 내게끔 하는것이 어렵다

    - 어떤 기능을 이용하여 얼만큼 이미지를 튜닝할 것이냐가 그 1번이다.
       - 이것저것 기능을 많이 써서 튜닝을 해봤는데 결국 그 조합이 중요하다. 
       - 예 : 흑백으로 변환 -> 바이너리 변환 -> edge 인식은 그 결과가 대체로 안좋았다.

    - 실질적으론 딥러닝 데이터들을 연계하여야만 좋은 성능을 낼것으로 보인다.

    - 스터디 목적이므로 안드로이드 스토어 출시는 하지 않았고, 기타 다른 기능 추가도 하지 않았다.

      - 기타 다른기능 추가 및 디자인 등 요소를 고려하는데 시간을 쏟는 시간에 다른 스터디를 하는게 이득이라

        판단했다. 
    - 테스트 목적이라 변수명, 클래스명 등이 엉망이다.

3. 간략 설명

    - 카메라로부터 실시간으로 이미지를 받아 화면에 띄운다

    - 버튼을 클릭하여 이미지를 찍고, 문서 판별을 위해 이미지 튜닝을 한다
       - 이미지 흑백 변환

       - 노이즈 제거 (median blur)

       - edge 인식

       - Contour 를 찾아내기
       - ConvexHull을 통해 면을 구하기
       - 문서 인식 부분을 AdaptiveThreshold로 Binary 이미지로 만들기

    - 인식된 문서를 화면에 보여주고, 저장버튼 클릭시 갤러리에 jpg로 저장한다

4. 상세
    - 카메라로부터 실시간 이미지를 받아 화면에 띄우기
      - Start 함수에서 SetCamera 함수를 호출하고, Update에서 화면에 표시한다

private void SetCamera()
{
	// 사실, 전면카메라를 배제하는 기능을 넣으면 좋지만 넣지 않았다.
    // devices[i].isFrontFacing 변수를 보면 알 수 있다.
    string cameraName = "";
    if (WebCamTexture.devices.Length > 0)
        cameraName = WebCamTexture.devices[0].name;

    camTexture = new WebCamTexture(cameraName);
    
    //height와 width를 설정치 않으면 640*480으로 나온다.
    camTexture.requestedHeight = Screen.height;
    camTexture.requestedWidth = Screen.width;
    
    //화면을 그나마 부드럽게 보여준다.
    camTexture.filterMode = FilterMode.Trilinear;

    camTexture.Play();
}

//카메라가 동작중인지 확인. 
// 사실 didThisFrameUpdate 도 사용했었는데, 별 차이가 없어서 지웠다.
private bool IsCamTextureReady()
{
    if (camTexture == null || camTexture.isPlaying == false)
        return false;

    return true;
}

void Update()
{
    if (IsCamTextureReady() == false || isJobFinished)
        return;

    rawImage.texture = camTexture;
    //이미지가 보통 돌아가 있다. 때문에 회전을 해줘야 한다.
    rawImage.GetComponent<RectTransform>().localEulerAngles = new Vector3(0, 0, -camTexture.videoRotationAngle);
    rawImage.GetComponent<RectTransform>().sizeDelta = new Vector2(camTexture.width, camTexture.height);
}

    - 문서 스캔 동작 클래스 변수 생성 및 호출 (대리자 설정)
      - 문서 스캔 동작 완료시, 호출할 대리자를 설정한다.

* 원본 이미지부터. 원래 다른 문서를 썼으나 개인정보가 있어 이 문서로 바꿨다.

public class ScannerScript
{

    //ScanFinished 라는 대리자 변수를 추가한다.
     private ScanFinished scanFinished;

     //생성자에 대리자 설정을 한다
     public ScannerScript(ScanFinished finished)
    {
        scanFinished = finished;
    }
    
    ....
}

    - WebCamTexture Mat으로 변환 및 이미지 흑백 전환

* 흑백 전환 이미지. 원본이랑 큰차이가 없네... 다른 문서로 했을땐 차이가 뚜렷했다.

Mat source = OpenCvSharp.Unity.TextureToMat(texture);           

//원본 이미지를 하나 더 둔다. 나중에 문서 좌표를 구한 후, 원본에서 자를것이다.
Mat origin = new Mat();
source.CopyTo(origin);

//흑백 전환
source = source.CvtColor(ColorConversionCodes.BGR2GRAY);

    - Median Blur를 이용한 노이즈 제거

* 결과 이미지 :  확실히 블러 효과가 크다. 안의 내용은 없어져도 된다. 문서 외각선만 따면 되니까.

//이부분은 거의 Demo 에 있는 기능을 갖다 썼다. 값 설정이 매직넘버인듯 하다.


int medianKernel = 11;                        
double kernelScale = 0.33;
kernelScale *= Math.Max(source.Width, source.Height) / 512.0;

// apply scale
medianKernel = (int)(medianKernel * kernelScale + 0.5);
medianKernel = medianKernel - (medianKernel % 2) + 1;

if (medianKernel > 1)
    source = source.MedianBlur(medianKernel);

    - 윤곽선 찾기 (Canny 알고리즘 및 Contour 찾기)

* Canny 알고리즘 결과 : 희미하게 문서의 선이 보인다.

//Canny 알고리즘을 이용해 외각선을 찾는다.
source = source.AdaptiveEdges();           

//Contour (윤곽선)을 찾고 해당 좌표값을 받아온다
Point[][] contours;
HierarchyIndex[] hierarchy;
Cv2.FindContours(source, out contours, out hierarchy, RetrievalModes.List, ContourApproximationModes.ApproxNone, null);
List<Point> approxResult = new List<Point>();
foreach (Point[] data in contours)
{
    double length = Cv2.ArcLength(data, true);

    //적어도 선의 길이가 너비의 40% 이상 되는 것만 선별한다.
    if (length <= source.Width * 0.4)
        continue;

    //ApproxPolyDP는 윤곽선을 그릴때 필요한 좌표값을 최소화 시킨다.
    //두번째 인자값인 엡실론의 경우, 얼마나 단순화 시킬지를 결정하는 값이다
    //값이 작을수록 원본에 가깝게 표현을 한다. 여기서 ArcLength의 값을 쓰는 이유는, 원본 길이의
    //몇 퍼센트 단순화 시킬지를 의미한다. 즉, 0.01을 곱하면 1% 오차범위로 단순화 한다는 이야기 이다.
    //따라서 원본 길이값을 알아야 하므로 ArcLength 값을 사용한다.
    Point[] approx = Cv2.ApproxPolyDP(data, length * 0.01, true);

    approxResult.AddRange(approx);
}

    - ConvexHull 을 이용하여, 윤곽선 점들을 내포하는 면 찾기
      - 해당 면의 꼭지점 개수가 몇개인지 판단하여 작업하기
      - 4개가 아닌, 2개이상의 좌표를 발견하였을 경우, 최소한의 4각형 좌표를 임의로 만들고

        인접한 실측좌표를 배정하여 임의의 4각형을 만든다. 

//모든점을 내접하는 면을 만들고, 좌표값을 단순화 한다
Point[] hull = Cv2.ConvexHull(approxResult.ToArray());
Point[] hullContour = Cv2.ApproxPolyDP(hull, Cv2.ArcLength(hull, true) * 0.01, true);

Texture2D resultItem = null;
Mat croppedMat = null;
Point[] cropPoint = new Point[4];           

//문서를 정확히 찾았을 경우. 꼭지점 4개, 면의 너비가 이미지의 50% 이상 차지할경우 찾은것으로 간주한다.
if (hullContour.Length == 4 && Cv2.ContourArea(hullContour) >= (source.Width * source.Height) * 0.5)
{
    cropPoint = hullContour;               
}
else if(hullContour.Length > 2)
{
	//적어도 2개의 점이라도 있다면 
    //모든 점을 감싸는 최소한의 사각형을 만든다.
    //좌표를 int 좌표로 변환한다. 
    RotatedRect bounds = Cv2.MinAreaRect(approxResult);
    Point2f[] points = bounds.Points();
    Point[] intPoints = Array.ConvertAll(points, p => new Point(Math.Round(p.X), Math.Round(p.Y)));

    // 최소한의 사각형 좌표와 가장 근접하는 실측 좌표값을 배정하여, 사각형을 만든다.
    Point[] rectPoint = new Point[4];
    for (int i = 0; i < intPoints.Length; i++)
        rectPoint[i] = approxResult.ToArray().ClosestElement(intPoints[i], (Point x, Point y) => Point.Distance(x, y));

    cropPoint = rectPoint;
}

    - 앞에서 구한 사각형 좌표값을 이용해 원본 이미지에서 문서만 자르고, 바이너리 이미지로 만든다.      
       WarpPerspective를 이용하여 이미지 회절 및 자르기를 한다.

 

* 최종 결과 이미지 :  양옆 파란색은 그림판에서 잘라내다보니 깔끔히 못잘라서 그렇다.

//좌상단, 우상단, 우하단, 좌하단 순으로 좌표를 정렬해야만
//WarpPerspective를 이용한 이미지 회절 및 자르기가 가능하다. 
SortRectEdgePoints(ref cropPoint);

//기울어진 이미지를 바로잡기 위해 point2f 로 변경한다.
Point2f[] warpDestPoints = new Point2f[4];
warpDestPoints = Array.ConvertAll(cropPoint, p => new Point2f(p.X, p.Y));

//원본에서 이미지를 자를것이므로, 흑백 변환을 한다.
croppedMat = origin.CvtColor(ColorConversionCodes.BGR2GRAY);

//AdaptiveThreshold를 이용해 바이너리 이미지로 변경한다. 촬영한 원본은 흑백이어도
//그림자 등이 있어 색이 고르지 못하다. 
//33 : 블록사이즈로서, 홀수로 지정해야한다. 값이 클수록 넒은범위를 대상으로 삼는다. 결과물이 크게 차이나진 않는다
//10 : 보정값이다. 평균낸 값에서 이 값을 보정을 한다. 사실상 매직넘버. 상황에 따라 좋은값일수도, 아닐수도 있다. 결과물이 크게 차이난다
croppedMat = croppedMat.AdaptiveThreshold(255, AdaptiveThresholdTypes.MeanC, ThresholdTypes.Binary, 33, 10);

////기울어진 이미지를 WarpPerspective를 이용하여 보정하고 자른다.
croppedMat = croppedMat.UnwrapShape(warpDestPoints);

....

//4각형 4개의 좌표를 각 좌상단, 우상단, 우하단, 좌하단 순으로 정렬한다.
private void SortRectEdgePoints(ref Point[] points)
{
    if (points == null || points.Length < 4)
        return;

    points = (from item in points
            orderby item.Y
            select item).ToArray();

    //좌상단, 우상단 순으로 정렬
    if (points[0].X > points[1].X)
        points.Swap(0, 1);

    //우하단, 좌하단 순으로 정렬
    if (points[2].X < points[3].X)
        points.Swap(2, 3);
}

    - 이미지를 Texture2d로 변환하고 대리자를 통해 전달한다.

if (croppedMat != null)
    resultItem = OpenCvSharp.Unity.MatToTexture(croppedMat);

if (scanFinished != null)
    scanFinished(resultItem);

 

5. 문제점 혹은 개선 필요점

    - 테스트 결과 나름 문서 인식을 잘 한다.

    - 문서 검출을 위한 사각형 좌표에서, 총 너비의 50% 길이인 Contour만 대상으로 삼는데, 가끔 오동작 한다. 개선필요

      - 문서보다 더 넒은범위를 문서로 인식해 버리는 문제가 종종 있다. 

    - AdaptiveThreshold에 매직넘버가 사용된다. 별 문젠 없는데... 상황에 따라 결과가 이상할 수 있다.

      이 부분은 딥러닝 데이터를 써야 하지 않을까?

    - pdf 저장, 이메일로 보내기, 웹으로 다운로드 받기 등등 기능추가는 생략했지만.... 된다면 출시를 해도 될거 같다.

    - 문서가 조금은 구겨져 있으므로, 수동으로 크롭 할 수 있는 기능이 있으면 좋겠다. 

    - 조금 구겨진 부분때문에 일그러짐은 회복이 안된다. 이 부분 또한 딥러닝 연계가 필요할듯 보인다.