修改证件照背景颜色

近期我们的某个需求中有一个需求是需要替换证件照背景颜色,且我们不希望使用三方框架,且需要使用原生来实现,之前并未接触过此类图片处理相关的任务,因此需要仔细调研下各类实现方式,确认是否满足产品要求。

方案

为证件照替换背景颜色,我们可以想到的无非就是这几种方案

  • 利用coreImage方法直接替换图片中的某个颜色为另一个颜色
  • 利用VisionKit边缘检测抠图

具体实现

替换图片中某个指定的颜色

这个方案我们的思路就是找到图片中对应要替换的颜色的像素点 然后替换
首先我们需要一个对比颜色是否相同的方法,这里我们通过对比图片的RGB来判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
}

然后我们需要一个从图片某个点取色的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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)
}
}
}

下面是颜色替换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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)
}

下面我们要找到对应颜色,然后设置透明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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

api说明

VisionKit中的这两个类VNGenerateObjectnessBasedSaliencyImageRequest可获取图片显著性区域,VNDetectContoursRequest可进行边缘检测通过这两部来抠出识别出显著区域的图片

直接使用系统的方法,我们这里也不多废话 直接看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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)")
}
}

效果如下:
效果图

鉴于实现思路与第一个方案是类似的,所以其优缺点也基本是一致的。这里我们不在赘述

我们再来看下结果:

VNDetectContoursRequest结果

还是会有锯齿的出现,效果不太满意, 我们在来看下这个方案的优缺点

优点:

  • 采用的是边缘检测的思路,不会出现颜色替换时范围不可控的问题
  • 使用系统API 调用很简单 不需要借助其他环境

缺点:

  • 系统API需要iOS13+
  • 效果比锯齿更加明显

OpenCV

在调研过程中,我们发现使用C++实际上有很多现有的方法,但是使用iOS目前可用的比较少,因此我们决定先配置一个C++的OpenCV的环境,先使用C++进行尝试,如果可行在使用Swift进行翻译,这样会快一点

OpenCV在MacOS上的环境配置大家可以参考我的这篇文章Mac 配置C++ OpenCV环境

待续……