需求背景

目前我们 app 的发布工具的拍照功能需要更多丰富的元素吸引年轻人,急需一套图像处理工具来做一些有意思的功能来供用户使用,来增加他们UGC的兴趣,丰富社区内容。

需要的功能

产品目前初步需要实现以下功能:人像描边、背景切换、自定义区域处理等,最终实现的效果如下:

image01

以上白点形成的多边形为用户输入的区域,只对区域内的部分做描边和背景替换。

准备工作,如何用OpenGL 定制 imageView

考虑到与安卓端的通用性,一份 shader 两边都能使用,选择使用 OpenGL ES 实现相应功能。主要程序实现了一个GLImageView,其功能相当于一块画板,是内容绘制的主体。GLImageView 继承自 UIView,但是使用mEaglLayer来替换原来的 layer 来完成对应的功能。

GLImageView 绘制流程

GLImageView 的绘制流程放在 layoutSubviews 里面执行,过程很简单:

  1. 创建图层,主要对 mEaglLayer 做一些设置
  2. 创建上下文,设置 context
  3. 清空缓存区,清空上一帧遗留的 buffer 数据
  4. 设置 RenderBuffer,渲染缓存
  5. 设置 FrameBuffer,帧缓存
  6. 开始绘制,下文详述

关于OpenGL 缓存的概念可以这么理解:

buffer分为frame buffer 和 render buffer2个大类。
其中frame buffer 相当于render buffer的管理者。
frame buffer object即称FBO。
render buffer则又可分为3类。colorBuffer、depthBuffer、stencilBuffer。

第 6 步实际的绘制过程其实很简单,加载 shader、往 shader 传参、绘制渲染:

加载 Shader

OpenGL 渲染管线中可编程的阶段有2个:顶点着色器 vsh(Vertex Shader)和片元着色器 fsh(Fragment Shader),在绘制前需要加载编译链接我们编写的 vsh 和 fsh 程序到 GPU 供接下来的渲染使用:

if self.compileShader(&verShader, type: GLenum(GL_VERTEX_SHADER), file: verFile) == false {
    print("Failed to compile vertex shader")
}
// compileshader(with: &fragShader, type: GLenum(GL_FRAGMENT_SHADER), file: fragFIle)
if self.compileShader(&fragShader, type: GLenum(GL_FRAGMENT_SHADER), file: fragFIle) == false {
    print("Failed to compile fragShader")
}

glAttachShader(program, verShader)
glAttachShader(program, fragShader)

//绑定后不需要了要释放掉
glDeleteShader(verShader)
glDeleteShader(fragShader)

往 Shader 传顶点数据

由于我们的目的只是在一块二维区域绘制我们的图片并作处理,暂时我们只需要指定顶点数据、纹理数据即可:

let attrArr: [GLfloat] = [
  1, 1, -1.0,     1.0, 0.0,
  -1, -1, -1.0,     0.0, 1.0,
  -1, 1, -1.0,    0.0, 0.0,

  1, -1, -1.0,      1.0, 1.0,
  -1, -1, -1.0,     0.0, 1.0,
  1, 1, -1.0,     1.0, 0.0,
]

数组的的每 5 位表示一个点,前 3 位为顶点物理坐标的 xyz 值,由于是只需要二维的坐标所以z为恒定值-1,后2位为对应的纹理坐标的 xy 值。如果你认真在画了各个点的位置,会发现坐标位置是上下颠倒的,这是因为 iOS 的坐标系统和 OpenGL坐标系统 y 轴方向不一致的原因。

载入 attrArr 的方法如下:

// 第1步:attrArr 拷贝到显存
var attrBuffer = GLuint()
glGenBuffers(1, &attrBuffer)
glBindBuffer(GLenum(GL_ARRAY_BUFFER), attrBuffer)
glBufferData(GLenum(GL_ARRAY_BUFFER), MemoryLayout<GLfloat>.size * 30, attrArr, GLenum(GL_DYNAMIC_DRAW))
// 第2步:顶点坐标数据传给vsh:position
let position = glGetAttribLocation(mprograme, "position")
glEnableVertexAttribArray(GLuint(position))
glVertexAttribPointer(GLuint(position), 3, GLenum(GL_FLOAT), GLboolean(GL_FALSE), GLsizei(MemoryLayout<GLfloat>.size * 5), nil)
// 第3步:纹理坐标数据传给vsh:textureCoordinate
let textCoor = glGetAttribLocation(mprograme, "textureCoordinate")
glEnableVertexAttribArray(GLuint(textCoor))
glVertexAttribPointer(GLuint(textCoor), 2, GLenum(GL_FLOAT), GLboolean(GL_FALSE), GLsizei(MemoryLayout<GLfloat>.size * 5), BUFFER_OFFSET(MemoryLayout<GLfloat>.size * 3))

通过上面的方法就将顶点坐标数据和对应的纹理坐标数据分别传给 vsh 的 attribute 变量 position 和 textureCoordinate,这里顺便给出 vsh 的代码:

// 输入
attribute vec4 position;
attribute vec2 textureCoordinate;
// 输出给 fsh
varying highp vec2 v_textureCoord;

void main() {
    v_textureCoord = textureCoordinate;
    gl_Position = position;
}

往 Shader 传纹理数据

载入图片纹理的核心代码如下:

----GLImageView.swift----
...
loadTextureFromImge(img: self.image)
glUniform1i(glGetUniformLocation(mprograme, "colorMap"), 0)
...

----shaderStroke.fsh----
...
uniform sampler2D colorMap;
...

其中 loadTextureFromImge() 将一张图片当作纹理载入,glUniform1i() 表示将索引为0的纹理,指定为 Uniform 变量 colorMap 的值。

绘制渲染

绘制和渲染分别调用2个函数:

glDrawArrays(GLenum(GL_TRIANGLES), 0, 6) 
mContext?.presentRenderbuffer(Int(GL_RENDERBUFFER))

准备工作做完了就用 OpenGL 实现了一个自定义的 view 来展示我们的图片,其中部分细节可以到源码里面查看。

人体抠图

这一步的目的是从任意一张图里面,抠出人像,并使人像尽可能的占满图片。其中抠图使用的是百度人像分割 Api,得到的是一张只有人像的图,背景全部替换成透明颜色值。接下来需要求最小的、能包含整个人像的 rect 即可,这一步使用 core graph 来做,直接遍历像素点即可,关键代码如下:

size_t w = CGImageGetWidth(inImage);
d0 = w/64;
unsigned char* data = CGBitmapContextGetData (cgctx);
minX = [self getMinX: data width: w height: h];
- (int) getMinX:(unsigned char*)data width: (CGFloat)w height: (CGFloat)h {
    for (int x=1; x<w; x+=d0){
        if ([self testXWith:data pointX:x width:w height:h]) {
            return x-d0>0 ? x-d0 : 0;
        }
    }
    return 0;
}
// 竖线检测
- (BOOL) testXWith:(unsigned char*)data pointX: (int)p_x width: (int)w height: (int)h {
    for (int i = 0; i<h; i+=d1) {
        int offset = 4*((w*round(i))+round(p_x));
        int alpha =  data[offset];
        if (alpha>10) {
            return true;
        }
    }
    return false;
}

上面代码描述了求 minX 的步骤,往 x 正方向遍历,对每个 X=x 的直线若第一次与人像相交则 x 即为minX 的值。为了防止图片尺寸太大设置便利步长为 d0 = width/64。

得到最小 rect 后直接用 CGContextDrawImage 函数转换就可以得到最小的包含人像的图。这一步的过程如下:

image01

左上为原图,右上为百度处理的结果,下面的为最小包围人像的图。(补充百度的 api 的结果 person_info 字段其实返回了抠出来的每个人像的box信息,将这些box合并成一个大的box也行,但是效果不可控,结果可能会有误差)

人像描边

到这里我们完成了读取一张图片,抠出其中的人像,得到最小的人像凸包并用OpenGL将它渲染出来这一组功能,接下来可以开始做描边了。

描边的方法有很多,最简单的一种实现思路如下:

if (alpha>0,说明当前片元是人像,不需要处理)  return
当前片元坐标往四周发散试探一定距离,
if 试探到 alpha>0 则对当前片元涂上描边的颜色 return
return

对应 shader 的部分代码如下:

varying highp vec2 v_textureCoord;
uniform sampler2D colorMap;
const highp float alphaRV = 0.4;           // alpha<alphaRV 表示是背景色

void main() {
    highp vec2 textureCoord = v_textureCoord;
    highp vec4 myC = texture2D(colorMap, textureCoord);// 从纹理colorMap读取坐标textureCoord的颜色值
    int strokeCount = 0;
    for (int i=0; i<12; ++i) {
        highp float angel = 30.0*float(i);
        highp float rad = angel * 0.01745329252;  // pi/180,角度转弧度
        highp vec4 clr = texture2D(colorMap, vec2(textureCoord.x + outlineSize * cos(rad) / textureSize.x, textureCoord.y + outlineSize * sin(rad) / textureSize.y));
        if (clr.a > alphaRV) {  // clr不是背景色
        if (clr.r + clr.g + clr.b > 0.1) {
            strokeCount = strokeCount + 1;
        }
        strokeCount = strokeCount + 1;
        }
    }
    if (strokeCount > 0) { // 描边颜色
        myC.rgba = outlineColor;
    } else {               // 背景颜色
        myC = vec4(1.0, 0.0, 0.0, 1.0);
    }
    gl_FragColor = myC;
}

这一步后得到结果如下:

image01

背景替换

背景替换的目的是将背景(上图背景为单调的red)替换成指定的图片,丰富图片的显示元素,这里需要使用 OpenGL 加载多纹理,部分代码如下:

let colorMap = loadTextureFromImge(img: self.image)
let colorMapBackground = loadTextureFromImge(img: self.backgroundImage)
// 设置纹理采样器 0 和 1
glUniform1i(glGetUniformLocation(mprograme, "colorMap"), 0)
glUniform1i(glGetUniformLocation(mprograme, "colorMapBackground"), 1)
// 激活绑定纹理
glActiveTexture(GLenum(GL_TEXTURE0));
glBindTexture(GLenum(GL_TEXTURE_2D), colorMap);
glActiveTexture(GLenum(GL_TEXTURE1));
glBindTexture(GLenum(GL_TEXTURE_2D), colorMapBackground);

通过上述代码,读取了2张图片,传给了fsh的 colorMap 和 colorMapBackground,一个为人像图,一个为要使用的背景色。

对应的 fsh 的相关代码如下:

uniform sampler2D colorMap;
uniform sampler2D colorMapBackground;
void main() {
    ...
    if (strokeCount > 0) { // 描边颜色
        myC.rgba = outlineColor;
    } else {               // 背景颜色替换
        // myC = vec4(1.0, 0.0, 0.0, 1.0);
        highp vec4 jtC = texture2D(colorMapBackground, vec2(textureCoord.x, textureCoord.y));
        myC = jtC;
    }
    ...
}

处理结果如下:

image01

支持在用户选择区域的图像处理

这一步的目的是只在用户输入点的多边形内处理图像,最终效果可以看最上面第一张图,用户输入3个点形成一个三角形,在这个三角形内处理了描边和背景替换,外面不做任何处理。输入端代码如下:

    public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let x: GLfloat = GLfloat(touches.first?.location(in: self).x ?? 0.0)
        let y: GLfloat = GLfloat(touches.first?.location(in: self).y ?? 0.0)
        addPoint(x: x, y: y)
        self.layoutSubviews()
    }
    
    public override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        let x: GLfloat = GLfloat(touches.first?.location(in: self).x ?? 0.0)
        let y: GLfloat = GLfloat(touches.first?.location(in: self).y ?? 0.0)
        addPoint(x: x, y: y)
        self.layoutSubviews()
    }
    
    func addPoint(x: GLfloat, y:GLfloat) -> Bool {
        let keyX_Y = NSString(format: "%lf_%lf", x, y)
        let v: Int = touchPointsFlag[keyX_Y] as? Int ?? 0
        if v==0 { // 没有存过
            let count = touchPoints.count
            let toAddX = x*rateX
            let toAddY = y*rateY
            if (count>=2) {
                let lastX: GLfloat = touchPoints[count-2]
                let lastY: GLfloat = touchPoints[count-1]
                let minLength: GLfloat = GLfloat(frame.width+frame.height) / 30.0
                print("addPointTest", (toAddX-lastX)*(toAddX-lastX)+(toAddY-lastY)*(toAddY-lastY), minLength*minLength)
                if ((toAddX-lastX)*(toAddX-lastX)+(toAddY-lastY)*(toAddY-lastY)<minLength*minLength) { // 最近2个点距离太短
                    return false
                }
            }
            
            touchPoints.append(toAddX)
            touchPoints.append(toAddY)
            touchPointsFlag[keyX_Y]=1
            return true
        }
        return false
    }

主要目的是采集用户手指划过的点集,做相近点去重处理。

fsh内部处理的原理如下:

void main() {
    ...
    if (当前点在点集形成的多边形外) return
    继续其它处理
    ...
}   

这里设计一个图形学经典算法,如何判断一个点 p 在点集形成的多边形内部,一个简单判断方法如下:

以p为起始点画任意一条射线,与多边形的每条线段求相交
if(总共相交数为奇数) p在多边形内
else p在多边形外

相关实现代码如下:

// 判断 n 约等于 m , 误差 accuracy
int approximatelyTest(highp float n, highp float m) {
    highp float accuracy = (n+m)/8192.0;
    if ((n-m<accuracy && n-m>-accuracy)) {
        return 1;
    }
    return 0;
}
// 判断纹理坐标 textureCoord 和坐标 point吻合
bool pointTest(highp vec2 point, highp vec2 textureCoord, highp float accuracy) {
    highp float px = point.x;
    highp float anchorSize = accuracy;
    if (textureCoord.x - px < anchorSize/textureSize.x && px - textureCoord.x < anchorSize/textureSize.x) {
        highp float py = point.y;
        if (textureCoord.y - py < anchorSize/textureSize.y && py - textureCoord.y < anchorSize/textureSize.y) {
            return true;
        }
    }
    return false;
    
//    highp float deltaX = point.x-textureCoord.x;
//    highp float deltaY = point.y-textureCoord.y;
//    highp float minLen = accuracy;
//    if (deltaX*deltaX + deltaY*deltaY < minLen*minLen/(textureSize.x*textureSize.y)) {
//        return true;
//    }
//    return false;
}

// 判断纹理坐标 t 在线段 ab 上
int lineTest(highp vec2 a, highp vec2 b, highp vec2 t) {
    highp float ax = a.x;
    highp float ay = a.y;
    highp float bx = b.x;
    highp float by = b.y;
    highp float tx = t.x;
    highp float ty = t.y;
    if (!midTest(ax, bx, tx) || !midTest(ay, by, ty)) {
        return 0;
    }
    highp float k = kOfLine(a,b);
    highp float tmpY = k*(tx-ax)+ay;
    highp vec2 tt = vec2(tx, tmpY);
    if (pointTest(t, tt, 1.0)) {
        return 1;
    }
    return 0;
}
// 判断 p 为起点 y 方向的射线与线段 ab 是否相交
int intersectionTest(highp vec2 a, highp vec2 b, highp vec2 p) {
    highp float ax = a.x;
    highp float ay = a.y;
    highp float bx = b.x;
    highp float by = b.y;
    highp float k = kOfLine(a, b);
    highp float px = p.x;
    highp float py = p.y;
    highp float ty = k*(px-ax)+ay; // x=p.x与ab的交点(px,tmpY)
    if (py>ty) {
        return 0;
    }
    if (approximatelyTest(ax, px)+approximatelyTest(bx, px)>0) {
        return 1;
    }
    highp vec2 tt = vec2(px, ty);
    if (lineTest(a, b, tt)>0) {
        return 1;
    }
    return 0;
}

到这里,就完成了人像描边、背景切换、自定义区域处理功能。

参考文献:

关于glsl:

https://learnopengl-cn.readthedocs.io/zh/latest/01%20Getting%20started/05%20Shaders/