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()!
}

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