Vision을 이용한 얼굴 인식 – 고급편

Vision을 이용해서 이미지 속에서 얼굴을 인식하는 방법을 살펴본 바 있는데, 당시 글에서 VNDetectFaceLandmarksRequest 를 사용했었다. 사실 얼굴이 들어있는 영역만 찾기 위해서는 VNDetectFaceRectsRequest 를 사용하는 것으로도 충분한데, face landmarks를 찾는다는 것은 얼굴의 주요 요소들 – 눈, 코, 입, 윤곽선 등-을 찾아낸다는 의미이다. 

이 글에서는 VNDetectFaceLandmarksRequest를 사용해서 실제로 얼굴의 각 요소를 추적하는 예제를 소개해 보겠다. 

얼굴 탐지의 세부 요소

VNDetectFaceLandmarksRequest 를 통해 분석된 결과는 VNFaceObservation 값이다. 이는 하나의 얼굴에 해당하는 정보를 포함하고 있는 클래스로, 이 중 boundingBox는 이미지 내에서 해당 결과의 전체 영역을 가리키는 (노멀라이즈된) CGRect이다.  그외에 landmarks 라는 프로퍼티가 있는데, 이 프로퍼티가 바로 얼굴 내의 각 요소들을 가리킨다. 

landmarks 프로퍼티의 타입은 VNFaceLankmarks2D이며, 여기에는 얼굴의 각 요소에 해당하는 프로퍼티가 정의되어 있다. 배열의 형태가 아닌, 각 얼굴의 요소에 해당하는  프로퍼티들이 있는데, 이들은 다음과 같다.

  • allPoints
  • faceContour
  • leftEye
  • rightEye
  • leftEyebrow
  • rightEyebrow
  • nose
  • noseCrest
  • medianLine
  • OuterLips
  • innerLips
  • leftPupil
  • rightPupul

각각의 얼굴 요소는 다시 VNFaceLandmarkRegion2D의 타입으로 이는 0~1 사이 범위의 노멀라이즈된 포인트의 좌표의 배열로서 이들을 연결하여 해당 부위의 윤곽선을 찾아내게 된다. 

얼굴의 각 요소를 그리기

얼굴이 나타나는 boundingBox가 아니라, 각 얼굴의 요소요소를 그리는 함수를 작성하고 이를 통해서 원본 이미지의 얼굴 위에 각 부위의 윤곽을 그려보자.

좌표계 변환

먼저해야 할 일은 노멀라이즈된 CGRectCGPoint를 특정 사이즈, 특정 사각형 내의 좌표로 환산하는 도구를 만들어야 한다. 함수를 만드는 것도 좋지만 다음과 같이 새로운 연산자를 정의해보도록 하자.

infix operator ~*

func ~* (lhs: CGPoint, rhs: CGRect) -> CGPoint {
  let x = lhs.x * rhs.size.width + rhs.origin.x
  let y = lhs.y * rhs.size.height + rhs.origin.y
  return CGPoint(x:x, y:y)
}

func * (lhs: CGRect, rhs: CGSize) -> CGRect {
  let origin = CGPoint(x: lhs.origin.x * rhs.width,
                       y: lhs.orign.y * rhs.heigt)
  let size = CGSize(width: lhs.size.width * rhs.width,
                    height: lhs.size.height * rhs.height)
  return CGRect(origin: origin, size: size)
}

위 두 연산자는 노멀라이즈된 CGPoint가 특정한 CGRect의 좌표계에 사상될 때의 좌표로 환산하는 ~* 를 새로 정의한 것과, 임의의 사각형에 사이즈를 곱해서 확대하는 연산을 정의한 것이다.

얼굴에 패스를 만들기

다음은 얼굴들과 이미지 크기를 전달해서, 각 요소의 윤곽을 패스로 만들어 리턴하는 함수를 정의해보자. 

func facialPaths(faces: [VNFaceObservation], imageSize: CGSize) -> CGPath {
  
  let resultPath = CGMutablePath()

  // 얼굴의 각 요소를 배열로 뽑아내는 함수
  let getFaceParts: (VNFaceLandmarks2D) -> [VNFaceLandmarkRegion2d] = {
    return [$0.faceContour,
            $0.outerLips,
            $0.innerLips,
            $0.leftEye,
            $0.rightEye,
            $0.leftEyebrow,
            $0.rightEyebrow,
            $0.leftPupil,
            $0.rightPupil,
            $0.nose,
            $0.noseCrest,
            $0.medianLine].flatMap{ $0 }
  }

  // 각 얼굴에 대해서
  for face in faces where face.landmarks != nil {
    let landmarks = face.landmarks!
    let boundingBox = face.boundingBox ~* imageSize
    // 얼굴부위의 좌표로 패스를 만들고 결과 패스에 추가하는 함수
    let addPath: (VNFaceLandmarkRegion2D) -> Void = { feature in
      let path = CGMutablePath()
      path.addLines(between: feature.normalizedPoints.map{ $0 ~* boundingBox })
      resultPath.addPath(path)
    }
    
    // 실제 각 얼굴 부위 패스를 추가한다.
    for x in getFacialParts(landmarks) {
      addPath(x)
    }
  }
  
  return resultPath
}

얼굴에 패스를 만들었으니, 그 다음은 이전에 했던 것과 동일하다. 비트맵 컨텍스트를 만들어서 이미지와 라인을 각각 그려주면된다. 

func drawLine(on source: CGImage, faces: [VNFaceObservation]) -> CGImage {
  let imageRect = CGRect(x:0, y:0, width: source.width, height: source.height)
  let ctx = CGContext(data: nil,
                      width: source.width,
                      height: source.height,
                      bitsPerComponent: 8,
                      bytesPerRow: 0,
                      space: CGColorSpaceCreateDeviceRGB(),
                      bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!

  ctx.draw(source, in: imageRect)
  ctx.setAlpha(0.4)
  ctx.setLineWidth(1.8)
  ctx.setLineCap(.round)
  ctx.setLineJoin(.round)
  ctx.setAllowsAntialiasing(true)

  ctx.setStrokeColor(NSColor.green.cgColor)
  ctx.addPath(facialPaths(faces: faces, imageSize: CGSize(width: source.width, height: source.height)))
  ctx.strokePath()
  return ctx.makeImage()!
}

이상의 코드를 활용해서 이미지를 주고 얼굴을 찾아 표시한 결과는 아래와 같다. 나머지 전체 코드는 이미지 입출력에 대한 것이니 생략하기로 한다.

이미지를 익명화하는 프로그램 – Vision + CoreImage

Vision을 이용한 얼굴 인식의 보다 상세한 예제로 자동 익명화 프로그램을 작성해보았다. 이 프로그램은 터미널에서 이미지 파일의 이름을 제시하면 해당 이미지에서 사람의 얼굴을 찾아, 해당 영역을 모자이크 처리한 PNG 파일을 생성한다.

이 프로그램은 크게 세 개의 함수로 구성된다.

  1. detectFaces(in: completionHandler:) – 주어진 이미지(CIImage)에서 얼굴을 찾아낸다.
  2. createAnonymizedImage(from: boundingBoxes) – 주어진 이미지와 사각형영역을 사용해서 해당 영역이 모자이크처리된 이미지를 합성한다.
  3. main() – 명령줄 인자로 전달된 정보를 통해 이미지 파일을 열고 1, 2의 함수를 사용해서 얼굴만 모자이크 처리한 이미지를 만들어 PNG 파일로 저장한다.
이미지를 익명화하는 프로그램 – Vision + CoreImage 더보기

Vision을 이용한 이미지 인식

Vision을 이용한 얼굴인식이나 QRCode 인식에 대해서 살펴보았는데, 사실 이러한 기능들은 정확도면에서 조금 뒤떨어질 수는 있지만 코어 이미지에서도 어느 정도 지원했었던 기능이다. 대신에 Vision은 CoreML과 결합하여 이미지 내의 오브젝트를 인식하고 그 이름을 추출할 수 있는 기능을 제공한다.  (고양이 사진을 보고 ‘고양이’를 알아내는 바로 그 기능이다.)

이번 시간에는 Vision + CoreML을 이용한 이미지 내 사물 인식 기능을 어떻게 구현할 수 있는지 알아보자.

Vision을 이용한 이미지 인식 더보기

이니셜라이저 – Swift

Swift의 클래스와 구조체, enum 객체들은 사용하기 전에 반드시 초기화되어야 한다. 그러면 초기화(initialization)이란 무엇인가? 객체의 생성 자체를 초기화과정에 포함시키는 관점과 그렇지 않은 관점이 있지만, 여기서는 “객체를 만들어서 사용가능한 상태로 준비하는 일”이라고 보자. let foo = Foo() 와 같이 특정한 타입의 인스턴스를 생성하는 구문을 실행했을 때 저 아래(?)에서 벌어지는 과정은 다음과 같다.

이니셜라이저 – Swift 더보기

NSImage와 CGImage 변환하는 법

NSImage > CGImage로 변환하기

NSImagecgImage(forProposedRect:Context:hints:)라는 메소드를 가지고 있는데, 이는 어떤 영역에 그려질 최적의 CGImage 객체를 찾아서 리턴하는 기능을 수행한다.  이 때 모든 파라미터는 옵션이며, 전부 nil로 넣어도 상관없다. 다만 파라미터들은 NSImage가 가지고 있는 여러 개의 CGImage 표현형 중에서 어떤 것을 선택할지를 결정하는데 도움을 주는 힌트에 해당한다. 

proposedDestRect는 CG이미지를 사용할 영역에 대한 참조로 주로 어느 해상도(크기)에서 사용될 것인지를 정한다. 만약 nil을 전달하면 NSImage의 크기 영역을 기준으로 삼게 된다. context는 그래픽 컨텍스트이며, hints 값은 그외의 힌트가 된다. 

이미지 표현형을 통해서 얻기

사실 이 방법은 위의 메소드를 호출하는 것과 별반 다르지 않을텐데, NSImage가 이미지를 ‘다루는’데 목표를 둔 반면에 실제로 어떤 그림인지에 대해서는 신경쓰지 않는 특이한 구조에 착안하는 것이다. 표현되는 그림이 무엇인지는 representations 파라미터를 통해서 알 수 있는 것이다. 이는 [NSImageRep] 타입으로 실제 이미지를 나타내는 데이터들이 모여 있다. (그 중에는 비트맵도 있을 수 있고 벡터나 PDF 등 그 형식은 매우 다양할 수 있다.) 

하지만 해당 NSImage가 비트맵 이미지를 기반으로 생성되었다는 사실을 보장할 수 있다면, 해당 이미지의 표현형 중에서 NSBitmapImageRep 인 것을 고르고 그 중 하나 (대표적으로 가장 앞에 있는 것)의 cgImage 속성을 취하는 것이다. 

if let cgImage = (nsImage.representations as? [NSBitmapImageRep])?.first.cgImage {
  // use cgImage...
}

CGImage -> NSImage 로 변환하기

CGImageNSImageView등에서 표현하기 위해서는 다시 NSImage로 변환해야 한다. NSImage의 편의 이니셜라이저 중에는 init(cgImage:size:) 가 있는데 이것을 사용하면 된다. 

참고로 이 때 size 파라미터에 NSZeroSize를 사용하면, 주어진 CGImage의 픽셀 폭/높이를 기준으로 사이즈를 삼게된다. 

다른 한가지 방법으로는 앞에서 비트맵이미지 표현형(NSBitmapImageRep)을 사용한 방법을 거꾸로 한 것이 있다.  NSBitmapImageRepCGImage로 바로 생성이 가능하다. (또 CIImage로도!) 이렇게 만들어진 표현형을 빈 NSImage에 추가하는 것이다.

let rep = NSBitmapImageRep(cgImage: cgimage)
let image = NSImage()
image.addRepresentation(rep)