修改證件照背景顏色

語言: CN / TW / HK

近期我們的某個需求中有一個需求是需要替換證件照背景顏色,且我們不希望使用三方框架,且需要使用原生來實現,之前並未接觸過此類圖片處理相關的任務,因此需要仔細調研下各類實現方式,確認是否滿足產品要求。

方案

為證件照替換背景顏色,我們可以想到的無非就是這幾種方案

  • 利用coreImage方法直接替換圖片中的某個顏色為另一個顏色
  • 利用VisionKit邊緣檢測摳圖

具體實現

替換圖片中某個指定的顏色

這個方案我們的思路就是找到圖片中對應要替換的顏色的畫素點 然後替換

首先我們需要一個對比顏色是否相同的方法,這裡我們通過對比圖片的RGB來判斷

private func compareColor(
    firstColor: UIColor,
    secondColor: UIColor,
    tolerance: CGFloat
) -> Bool {
    var r1: CGFloat = 0.0, g1: CGFloat = 0.0, b1: CGFloat = 0.0, a1: CGFloat = 0.0;
    var r2: CGFloat = 0.0, g2: CGFloat = 0.0, b2: CGFloat = 0.0, a2: CGFloat = 0.0;

    firstColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
    secondColor.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
    
    return abs(r1 - r2) <= tolerance
        && abs(g1 - g2) <= tolerance
        && abs(b1 - b2) <= tolerance
        && abs(a1 - a2) <= tolerance
}

然後我們需要一個從圖片某個點取色的方法

extension UIImage {
 func getPointColor(at point: CGPoint) -> UIColor? {

        guard CGRect(origin: CGPoint(x: 0, y: 0), size: size).contains(point) else {
            return nil
        }

        let pointX = trunc(point.x);
        let pointY = trunc(point.y);

        let width = size.width;
        let height = size.height;
        let colorSpace = CGColorSpaceCreateDeviceRGB();
        var pixelData: [UInt8] = [0, 0, 0, 0]

        pixelData.withUnsafeMutableBytes { pointer in
            if let context = CGContext(data: pointer.baseAddress, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue), let cgImage = cgImage {
                context.setBlendMode(.copy)
                context.translateBy(x: -pointX, y: pointY - height)
                context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
            }
        }

        let red = CGFloat(pixelData[0]) / CGFloat(255.0)
        let green = CGFloat(pixelData[1]) / CGFloat(255.0)
        let blue = CGFloat(pixelData[2]) / CGFloat(255.0)
        let alpha = CGFloat(pixelData[3]) / CGFloat(255.0)

        if #available(iOS 10.0, *) {
            return UIColor(displayP3Red: red, green: green, blue: blue, alpha: alpha)
        } else {
            return UIColor(red: red, green: green, blue: blue, alpha: alpha)
        }
    }
}

下面是顏色替換

func replaceColor(_ color: UIColor, with: UIColor, tolerance: CGFloat = 0.5) -> UIImage {
        guard let imageRef = self.cgImage else {
            return self
        }
        // 獲取要替換顏色的RGBA資訊方便後續判斷
        let withColorComponents = with.cgColor.components
        let newRed = UInt8(withColorComponents![0] * 255)
        let newGreen = UInt8(withColorComponents![1] * 255)
        let newBlue = UInt8(withColorComponents![2] * 255)
        let newAlpha = UInt8(withColorComponents![3] * 255)

        let width = imageRef.width
        let height = imageRef.height
        
        let bytesPerPixel = 4
        let bytesPerRow = bytesPerPixel * width
        let bitmapByteCount = bytesPerRow * height
        // 申請bitmap要對應的空間
        let rawData = UnsafeMutablePointer<UInt8>.allocate(capacity: bitmapByteCount)
        defer {
            rawData.deallocate()
        }
        
        guard let colorSpace = CGColorSpace(name: CGColorSpace.genericRGBLinear) else {
            return self
        }
        // 根據上述資訊建立一個context
        guard let context = CGContext(
            data: rawData,
            width: width,
            height: height,
            bitsPerComponent: 8,
            bytesPerRow: bytesPerRow,
            space: colorSpace,
            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
                | CGBitmapInfo.byteOrder32Big.rawValue
        ) else {
            return self
        }
        
        let rc = CGRect(x: 0, y: 0, width: width, height: height)
        // 繪製圖片資訊
        context.draw(imageRef, in: rc)
        var byteIndex = 0
        // 依次遍歷每個畫素
        while byteIndex < bitmapByteCount {
            // 獲取圖片當前位置對應的畫素RGBA資訊
            let red = CGFloat(rawData[byteIndex + 0]) / 255
            let green = CGFloat(rawData[byteIndex + 1]) / 255
            let blue = CGFloat(rawData[byteIndex + 2]) / 255
            let alpha = CGFloat(rawData[byteIndex + 3]) / 255
            let currentColor = UIColor(red: red, green: green, blue: blue, alpha: alpha)
            // 比較當前顏色的RGBA資訊與要被替換的圖片的RGBA資訊 如果在允許範圍內 則替換成新的
            if compareColor(firstColor: color, secondColor: currentColor, tolerance: tolerance) {
                rawData[byteIndex + 0] = newRed
                rawData[byteIndex + 1] = newGreen
                rawData[byteIndex + 2] = newBlue
                rawData[byteIndex + 3] = newAlpha
            }
            byteIndex += 4
        }
        
        // 替換完顏色生成對應圖片
        guard let image = context.makeImage() else {
            return self
        }
        let result = UIImage(cgImage: image)
        return result
    }

下面我們來找個照片看下替換效果

優點:

  • 簡單,直接,不用整合其他庫,可實現顏色替換
    缺點:
  • 無法很好的適應圖片,其中tolerance的設定不同圖片可能需要設定不同,無法取一個定值
  • 適合傳入圖片背景顏色固定的 不夠靈活
  • 而且相同色值的就進行替換可能會有誤傷

HSV 顏色透明

在查詢方案的時候,網上有討論要將RGB轉換為HSV然後判斷對應HSV的顏色,將對應HSV中的透明度設定為0。即將想要刪除掉顏色的部分設定為透明,這樣的話圖片就會從一個有背景顏色的圖變換為一個透明背景的圖,這樣就可以隨意更換顏色了。

在開始之前,我們需要一個方法來將RGBA轉換為HSV

var hsba: (hue: CGFloat, saturation: CGFloat, brightness: CGFloat, alpha: CGFloat) {
    /**
     hue:色相
     saturation:飽和度
     brightness:亮度
     alpha:透明度
     */
    var h: CGFloat = 0
    var s: CGFloat = 0
    var b: CGFloat = 0
    var a: CGFloat = 0
    self.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
    return (h * 360, s, b, a)
}

下面我們要找到對應顏色,然後設定透明

struct CubeMap createCubeMap(float h, float s, float v) {
    const unsigned int size = 64;
    struct CubeMap map;
    map.length = size * size * size * sizeof (float) * 4;
    map.dimension = size;
    float *cubeData = (float *)malloc (map.length);
    float rgb[3], hsv[3], *c = cubeData;
    
    for (int z = 0; z < size; z++){
        rgb[2] = ((double)z)/(size-1); // Blue value
        for (int y = 0; y < size; y++){
            rgb[1] = ((double)y)/(size-1); // Green value
            for (int x = 0; x < size; x ++){
                rgb[0] = ((double)x)/(size-1); // Red value
                rgbToHSV(rgb,hsv);
                // Use the hue value to determine which to make transparent
                // The minimum and maximum hue angle depends on
                // the color you want to remove
                float alpha = (hsv[2] == 1 && hsv[1] == 0) ? 0.0f: 1.0f;

                // Calculate premultiplied alpha values for the cube
                c[0] = rgb[0] * alpha;
                c[1] = rgb[1] * alpha;
                c[2] = rgb[2] * alpha;
                c[3] = alpha;
                c += 4; // advance our pointer into memory for the next color value
            }
        }
    }
    map.data = cubeData;
    return map;
}

VNGenerateObjectnessBasedSaliencyImageRequest+VNDetectContoursRequest

VisionKit中的這兩個類VNGenerateObjectnessBasedSaliencyImageRequest可獲取圖片顯著性區域,VNDetectContoursRequest可進行邊緣檢測通過這兩部來摳出識別出顯著區域的圖片

直接使用系統的方法,我們這裡也不多廢話 直接看程式碼

func detectPhoto(photo: UIImage) -> UIImage {
        
        let ciOriginImage = CIImage(cgImage: photo.cgImage!)
        
        let imageHandler = VNImageRequestHandler(ciImage: ciOriginImage, options: [:])
        let attensionRequest = VNGenerateObjectnessBasedSaliencyImageRequest { [weak self] request, error in
            if let err = error {
                print("發生了錯誤 \(err.localizedDescription)")
                return
            }
            if let result = request.results, result.count > 0,
               let observation = result.first as? VNSaliencyImageObservation {
                // 獲取顯著區域熱力圖 接下里對對該圖進行邊緣檢測
                self?.heatMapProcess(pixelBuffer: observation.pixelBuffer, ciImage: ciOriginImage)
            }
        }
        
        do {
            try imageHandler.perform([attensionRequest])
        } catch {
            print(error.localizedDescription)
        }
        
        return photo
        
    }
    
    private func heatMapProcess(pixelBuffer: CVPixelBuffer, ciImage: CIImage) {
        let heatImge = CIImage(cvPixelBuffer: pixelBuffer)
        let contourRequest = VNDetectContoursRequest { [weak self] request, error in
            if let err = error {
                print("發生了錯誤 \(err.localizedDescription)")
                return
            }
            if let result = request.results, result.count > 0,
               let observation = result.first as? VNContoursObservation {
                let cxt = CIContext()
                let origin = cxt.createCGImage(ciImage, from: ciImage.extent)
                let _ = self?.drawContour(contourObv: observation, cgImage: nil, originImg: origin)
            }
            
        }
        contourRequest.revision = VNDetectContourRequestRevision1
        contourRequest.contrastAdjustment = 1.0
        contourRequest.detectsDarkOnLight = false
        contourRequest.maximumImageDimension = 512
        
        let handler = VNImageRequestHandler(ciImage: heatImge, options: [:])
        
        do {
            try handler.perform([contourRequest])
        } catch {
            print("\(error.localizedDescription)")
        }
    }

效果如下:

鑑於實現思路與第一個方案是類似的,所以其優缺點也基本是一致的。這裡我們不在贅述

我們再來看下結果:

還是會有鋸齒的出現,效果不太滿意, 我們在來看下這個方案的優缺點

優點:

  • 採用的是邊緣檢測的思路,不會出現顏色替換時範圍不可控的問題
  • 使用系統API 呼叫很簡單 不需要藉助其他環境

缺點:

  • 系統API需要iOS13+
  • 效果比鋸齒更加明顯

OpenCV

在調研過程中,我們發現使用C++實際上有很多現有的方法,但是使用iOS目前可用的比較少,因此我們決定先配置一個C++的OpenCV的環境,先使用C++進行嘗試,如果可行在使用Swift進行翻譯,這樣會快一點

OpenCV在MacOS上的環境配置大家可以參考我的這篇文章 Mac 配置C++ OpenCV環境

待續……