07/09/2018, 15:32

Swift Tutorial: Ứng dụng nhận diện khuôn mặt đơn giản (Phần 3)

Phần 1: Hướng dẫn cơ bản về setup project, quản lý thư viện ngoài bằng Cocoapods, hướng dẫn sử dụng auto-layout để tạo giao diện ... Phần 2: Cài đặt Camera Session và sử dụng chức năng Face Recognition có sẵn trong CoreImage của iOS Phần 3: Một vài kiến thức nâng cao về xử lý ảnh ...

  • Phần 1: Hướng dẫn cơ bản về setup project, quản lý thư viện ngoài bằng Cocoapods, hướng dẫn sử dụng auto-layout để tạo giao diện ...
  • Phần 2: Cài đặt Camera Session và sử dụng chức năng Face Recognition có sẵn trong CoreImage của iOS
  • Phần 3: Một vài kiến thức nâng cao về xử lý ảnh (gray-scale image, resize image ...)

Ở 2 phần tut trước, mình đã hướng dẫn khá chi tiết cách viết một ứng dụng camera có tích hợp chức năng nhận diện khuôn mặt. Tuy nhiên sau khi viết 2 bài tut dài như vậy, mình tự hỏi liệu bài tut của mình có hấp dẫn không? Liệu có nhiều người đọc và làm theo tut không? Vì quả thực là bài khá dài và nhiều code, nên việc thực hành theo tut có lẽ chẳng khác một bài thực hành copy/paste (Mình làm theo nhiều tut và cũng rất chán việc phải copy paste, đến khi gặp vấn đề tương tự thì lại không biết phải code thế nào). Thế nên ở phần cuối này, mình sẽ viết theo hướng mới để người đọc có thể dễ nhớ và "nạp skill" hơn, đó là đưa ra các vấn đề riêng lẻ, hướng dẫn giải quyết bằng các code snippet nhỏ kèm giải thích. Từ đó mọi người sẽ hiểu rõ hơn về cách thức chương trình hoạt động.

Ok, let's go

Mình đã đẩy branch mới lên repo của project trên github. Ở bài tut lần này chúng ta sẽ thực hành trên branch đó.
Các bạn setup như sau:

git clone https://github.com/muzix/goatcamera.git
cd goatcamera
git checkout experiment
pod install

Mở file GoatCamera.xcworkspace và build & run.

App lần này có những chức năng mới hơn so với app ở phần 2:

  • Chụp ảnh gắn râu và lưu ảnh vào album.
  • Ảnh vừa chụp hiển thị ở thumb góc dưới.
  • Có thể chuyển giữa camera trước và sau.

App có đủ chức năng như vậy rồi thì tiếp theo chúng ta làm gì đây :D
Hehe, ở phần 2 mình có nói về cách thức sử dụng CameraSession trên một background serial queue riêng để không làm ảnh hưởng tới hoạt động của UI chạy trên main queue của app. Vậy thì đầu tiên chúng ta sẽ thử nghịch ngợm cái queue này một chút.

Thực ra đây là lỗi đầu tiên mình gặp phải trong quá trình viết app này. Sau khi viết xong class CameraSession và test thử khả năng lấy dữ liệu và chụp hình của camera thì mình gặp ngay bug này. Đó là thao tác chụp hình bị delay khá lâu sau khi bấm nút chụp hình.
why
Sau một hồi xem lại code ở hàm captureImage thì mình phát hiện ra vấn đề nằm ở background queue mà mình sử dụng cho task capture image này.

dispatch_async(self.sessionQueue, {
    NSLog("Connections %d", self.stillImageOutput.connections.count)
    self.stillImageOutput.captureStillImageAsynchronouslyFromConnection(self.stillImageOutput.connectionWithMediaType(AVMediaTypeVideo), completionHandler: {
        (imageDataSampleBuffer: CMSampleBuffer?, error: NSError?) -> Void in
        if imageDataSampleBuffer == nil || error != nil {
            completion!(image:nil, error:nil)
        }
        else if imageDataSampleBuffer != nil {
            var imageData: NSData = AVCaptureStillImageOutput.jpegStillImageNSDataRepresentation(imageDataSampleBuffer)
            var image: UIImage = UIImage(data: imageData)!
            completion!(image:image, error:nil)
        }
    })
  })

Ở đây mình đã sử dụng sessionQueue mà mình đã khởi tạo cùng với CameraSession.

self.sessionQueue = dispatch_queue_create("CameraSessionController Session", DISPATCH_QUEUE_SERIAL)

Đây là serial queue mình sử dụng cho delegate lấy sample output từ camera

self.videoDeviceOutput.setSampleBufferDelegate(self, queue: self.sessionQueue)

Đặc điểm của serial queue là nó sẽ thực hiện các code block lần lượt theo thứ tự call. Tại một thời điểm chỉ thực hiện một task. Khi sử dụng queue này cho block code capture ảnh, ta có thể hình dung là mình đưa lệnh capture vào hàng đợi, trước lệnh capture là một động lệnh xử lý nhận diện khuôn mặt đang đợi đến lượt. Vậy thì phải chờ mút chỉ thì ảnh mới được chụp o_O. Các bạn có thể reproduce bug này bằng cách thay self.sessionQueue vào phần queue đang dùng ở hàm captureImage

dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
thay bằng
self.sessionQueue

Sau khi hiểu lý do thì sửa bug cũng đơn giản. Cho lệnh capture này sang queue riêng như trong code hiện tại. Không phải xếp hàng gì sất, lên luôn (rock)

Thế là xong thí nghiệm 1. Bây giờ ta làm gì nữa ...

Khi làm việc với CALayer của iOS, mặc định giữa các CATransaction, iOS sẽ thực hiện animation để chuyển đổi trạng thái layer. Ví dụ khi thay đổi toạ độ x,y của layer, iOS sẽ tự động làm animation di chuyển layer đó đến giá trị toạ độ được gán.
Để bật chế độ animation đó lên, chúng ta comment lại dòng code sau trong hàm drawSticker ở class ViewController

CATransaction.setValue(kCFBooleanTrue, forKey: kCATransactionDisableActions)

Build và chạy, bạn sẽ thấy cái râu sẽ bay qua lại trên màn hình, nhìn rất mượt chứ không "quẩy" cà tưng như trước :D

Xong rồi, bây giờ chúng ta bỏ comment code trên để tắt animation đi, phục vụ cho các thí nghiệm tiếp theo.

Nghe có vẻ thú vị :D Slow-motion là chế độ camera khi quay xong xem lại video thì thấy mọi thứ diễn ra với tốc độ chậm hơn bình thường nhưng các chuyển động rất mượt và chi tiết.
Để có thể capture lại được những đoạn video như vậy, thiết bị cần phải đẩy tốc độ khung hình lên mức tối đa có thể để khi slow-down xuống thì tốc độ khung hình vẫn phải đảm bảo ít nhất là 24fps để mắt người không cảm thấy giật.
Từ iphone 5s trở đi, camera đã có khả năng quay với tốc độ 120fps. iPhone 5 tuy không có khả năng quay với tốc độ 120fps nhưng có thể sử dụng API của iOS 7 để kích hoạt chế độ 60fps, sau đó playback với tốc độ 0.5x để tạo hiệu ứng slow-motion.

Ở thí nghiệm này, ta sẽ kích hoạt camera lên tốc độ khung hình cao nhất có thể của thiết bị. Ví dụ thiết bị mình test là iphone 5s thì sẽ là 120fps.

Trước hết ta cần chỉnh sửa hàm sampleoutput delegate để log ra thông tin fps cũng như xoá hết các đoạn code xử lý nhận diện khuôn mặt để đảm bảo việc log được chính xác và không bị gián đoạn :D

Chúng ta comment lại toàn bộ phần code delegate samle output và thay bằng đoạn code sau

var methodCount:Int = 0
var startTime:NSDate = NSDate()
func cameraSessionDidOutputSampleBuffer(sampleBuffer: CMSampleBuffer!, fromConnection connection: AVCaptureConnection!) {
    dispatch_async(dispatch_get_main_queue(), { [unowned self] () -> Void in
        if (self.methodCount == 0) {
            self.startTime = NSDate()
        }
        self.methodCount = self.methodCount + 1
        let endTime = NSDate()
        var executeTime:NSTimeInterval = endTime.timeIntervalSinceDate(self.startTime)

        if (executeTime * 1000 > 1000) {
            var lblString = String(format: "%d", self.methodCount)
            NSLog("Framerate %@", lblString)
            self.methodCount = 0
        }
    })
}

Build và chạy thử, chúng ta sẽ thấy tốc độ framerate được log ra ở console của Xcode
Ở đây mình thấy fps của cả camera trước và sau là 30fps

Tiếp theo, ta sẽ thêm đoạn code vào hàm addBackVideoInput trong class CameraSession để kích hoạt chế độ 120fps ở camera sau.

var maxFramerate = 0
for vFormat in videoDevice.formats {
    let description:CMFormatDescriptionRef = vFormat.formatDescription
    let frameRateRanges:NSArray = vFormat.videoSupportedFrameRateRanges as NSArray
    let frameRateRange:AVFrameRateRange = frameRateRanges[0] as! AVFrameRateRange
    var maxrate:Int = Int(frameRateRange.maxFrameRate)
    if maxrate >= maxFramerate {
            maxFramerate = maxrate
            videoDevice.lockForConfiguration(nil)
            videoDevice.activeFormat = vFormat as! AVCaptureDeviceFormat
            videoDevice.activeVideoMinFrameDuration = CMTimeMake(1,Int32(maxFramerate))
            videoDevice.activeVideoMaxFrameDuration = CMTimeMake(1,Int32(maxFramerate))
            videoDevice.unlockForConfiguration()

    }
}
NSLog("Max framerate: %d", maxFramerate)

Ta thêm vào cuối của hàm addBackVideoInput, đoạn code trên sẽ tìm giá trị maxFramerate trong profile của camera và configure camera fps với giá trị đó. Lưu ý là các format trong profile không được sắp xếp gì cả nên ta cần duyệt qua tất cả các profile để tìm ra giá trị maxFramerate.

Build và chạy thử. Ta sẽ thấy console log ra giá trị framerate là 120fps. Nice!

Tiếp theo, ta đặt câu hỏi: Khi chạy 120fps đồng nghĩa với việc hàm delegate sample output được call tới 120 lần / giây. Như vậy nếu ta xử lý nhận dạng khuôn mặt trong delegate thì chuyện gì sẽ xảy ra, liệu cái râu có "quẩy" nhanh hơn không ?!

Ở thí nghiệm 1, ta đã biết delegate được xử lý trên serial queue. Như vậy kể cả với tốc độ 120 fps thì task trong delegate vẫn được thực hiện lần lượt. Do trong thí nghiệm này ta đã comment lại phần xử lý nhận diện, nên task trong delegate chỉ là log ra console, với tốc độ 120fps thì commandCount sẽ được cộng lên tới 120 lần trong 1s, cứ mỗi giây delegate sẽ log ra console giá trị commandCount 1 lần và reset commandCount.
Khi ta thêm lại code xử lý nhận diện khuôn mặt lại vào trong delegate, do task nhận diện chỉ chạy với tốc độ giới hạn (phụ thuộc vào phần cứng và xử lý trong code). Nên trong 1 giây, task trong delegate sẽ không thể thực hiện đủ 120 lần do phải chạy trên serial queue. Bởi vậy framerate in ra console chỉ vào khoảng 15, 16, bất kể framerate của camera là 30, 60, hay 120. Ta có thể thấy cái râu sẽ vẫn "quẩy" với tốc độ như cũ, chứ không nhanh hơn.

Việc tăng FPS của camera sẽ không giúp ích gì cho việc xử lý nhận dạng khuôn mặt, mà lại làm thiết bị tốn pin, máy nóng hơn :D
Chỉ có cách là tối ưu bản thân phần xử lý nhận diện để cái râu "quẩy" nhanh hơn, "dính" vào mặt nhanh hơn.
Nhưng mà tối ưu thuật toán thì chịu rồi, pó tay T_T. Vậy thì người nông dân phải làm sao ?
{}how

Ý tưởng 1: giảm kích thước ảnh đầu vào thì tốc độ xử lý có nhanh hơn không ?

Mình đã thử với nhiều độ phân giải và kết quả là: Độ phân giải ít nhiều có ảnh hưởng tới tốc độ xử lý, tuy nhiên sự khác biệt là không đáng kể khi mình test trên iphone 5s với cấu hình cao. Nhưng nếu ảnh có độ phân giải quá thấp, hình ảnh quá mờ thì tốc độ nhận diện khuôn mặt sẽ bị chậm lại thấy rõ.

Các bạn có thể lần lượt thay đổi giá trị session preset ở mức low, 640x480, high và theo dõi fps log ra ở console. (Chú ý disable đoạn code max framerate, max framerate thì mặc định chất lượng là high luôn)

self.session.sessionPreset = AVCaptureSessionPresetLow

Test trên iphone 5s

  • Với quality low: fps khoảng 12,13
  • Với quality 640x480: fps khoảng 19,20
  • Với quality high: fps khoảng 17,18

Ý tưởng 2: Ta vẫn sẽ đặt camera ở độ phân giải cao, nhưng sẽ thực hiện resize lại ảnh trong delegate trước khi cho nhận diện. Làm thế này thì ảnh chụp sẽ có độ phân giải cao, nhưng delegate lại phải gánh cả task resize ảnh và task nhận diện T_T. Liệu có khả thi?

Đầu tiên mình để camera ở preset high.

Sau đó, mình đã đọc bài so sánh khá hay tại đây:
http://nshipster.com/image-resizing/
Bài viết chỉ ra, Core Graphics và Image I/O có tốc độ nhanh nhất khi crop hay resize ảnh.

  • Với CoreGraphics, ta sẽ sử dụng hàm CGContextDrawImage để resize ảnh
  • Với Image I/O, ta dùng hàm CGImageSourceCreateThumbnailAtIndex

Ở đây mình sẽ thử với Core Graphics để resize, đồng thời thử nghiệm luôn cách lấy ảnh grayscale từ camera. Code mình tham khảo ở framework GPUImage của BradLarson trên Github (thư viện này "khủng" lắm, có thể dùng để làm các ứng dụng xử lý ảnh chuyên nghiệp như camera 360, photo wonder ...)
https://github.com/BradLarson/GPUImage

Trước tiên, trong Class CameraSession, hàm addVideoOutput, các bạn để ý phần config sau:

var settings: [String: Int] = [
    kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
    // kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
]

Mình comment cái trên lại và bỏ comment dòng dưới.
Format ảnh này mình cũng không có rành lắm. Chỉ tham khảo qua ở wiki:
http://en.wikipedia.org/wiki/YCbCr
The Y image is essentially a greyscale copy of the main image.
Sau đó tham khảo qua code ở GPUImage và chuyển nó sang Swift

Các bạn tìm đoạn code sau ở trong hàm updateStickerPosition và comment lại

var sourceImageColor: CIImage = CIImage(CVPixelBuffer: pixelBuffer)
var awidth = sourceImageColor.extent().size.awidth
var height = sourceImageColor.extent().size.height

Thay vào bằng đoạn code sau để lấy ảnh grayscale và resize ảnh về kích thước có awidth cố định bằng 480 pixel.

// experimental (http://nshipster.com/image-resizing/)
CVPixelBufferLockBaseAddress(pixelBuffer, 0)
var yPlaneAddress = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0)
var bytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
var awidth = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0)
var height = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0)

var colorSpace = CGColorSpaceCreateDeviceGray()
let bitmapInfo = CGBitmapInfo(CGImageAlphaInfo.None.rawValue)
var dataProvider = CGDataProviderCreateWithData(nil, yPlaneAddress, height * bytesPerRow, nil)
var newImageRef = CGImageCreate(awidth, height, 8, 8, bytesPerRow, colorSpace, bitmapInfo, dataProvider, nil, false, kCGRenderingIntentDefault)

var finalWidth:Int = 480
var scaleRatio:Double = Double(finalWidth) / Double(awidth);
var finalHeight:Int = Int(Double(height) * scaleRatio)
let imageData = UnsafeMutablePointer<GLubyte>(calloc(Int(Double(finalWidth) * Double(finalHeight) * 4), Int(sizeof(GLubyte))))
let imageContext:CGContextRef = CGBitmapContextCreate(imageData, Int(finalWidth), Int(finalHeight), 8, Int(finalWidth * 4), colorSpace,  bitmapInfo);
CGContextDrawImage(imageContext, CGRectMake(0.0, 0.0, CGFloat(finalWidth), CGFloat(finalHeight)), newImageRef);

let scaledImage = CGBitmapContextCreateImage(imageContext)
free(imageData)
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0)

Cuối cùng sửa lại đầu vào của hàm detectFaces

let faceFeatures = FaceDetector.detectFaces(inImage: CIImage(CGImage:scaledImage))

Build và chạy thử ...
Nếu bạn thấy cái râu bị lệch toạ độ so với mặt mình một khoảng cố định thì code resize hoạt động chuẩn rồi đó. Để râu đúng vị trí thì ta cần phải sửa lại đoạn scale layer một chút. Mình lười quá chưa sửa, hehe
Ở đây ta để ý kết quả fps log ra.
Có vẻ như kết quả không được như mong muốn, thậm chí còn giảm. Có thể do việc resize ảnh và grayscale đã tạo thêm gánh nặng cho delegate, huhu.
{}fail

Nhưng mà dù sao thì ta cũng học được một vài trick thú vị ;)

YEAH, Congratulation!
Chúng ta đã hoàn thành xong buổi thí nghiệm này.
Mình rất mong nhận được ý tưởng, bổ sung và chỉnh sửa bài viết từ các bạn.

Happy Hacking !
Hoang

Đăng lại từ bài gốc trên blog của tác giả: http://tech.thucdon24.com/swift-tutorial-a-simple-face-recognition-ios-app-part-3/

0