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
가 아니라, 각 얼굴의 요소요소를 그리는 함수를 작성하고 이를 통해서 원본 이미지의 얼굴 위에 각 부위의 윤곽을 그려보자.
좌표계 변환
먼저해야 할 일은 노멀라이즈된 CGRect
와 CGPoint
를 특정 사이즈, 특정 사각형 내의 좌표로 환산하는 도구를 만들어야 한다. 함수를 만드는 것도 좋지만 다음과 같이 새로운 연산자를 정의해보도록 하자.
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()!
}
이상의 코드를 활용해서 이미지를 주고 얼굴을 찾아 표시한 결과는 아래와 같다. 나머지 전체 코드는 이미지 입출력에 대한 것이니 생략하기로 한다.