简介:面向安卓开发与毕业设计人群的这份项目资料,围绕实现一款类似美颜相机、美图秀秀的应用展开,覆盖实时美颜、照片编辑、滤镜特效等核心功能需求。内容系统梳理了安卓开发基础、相机与相机第二代相机接口的调用、使用开放计算机视觉库进行图像处理、利用开放图形库进行实时渲染、色彩空间转换与各类滤镜实现、遵循材料设计规范的界面搭建、动态权限申请、图片保存与社交平台分享,以及性能优化和兼容性测试等关键环节,其中对磨皮、瘦脸、大眼等常见美颜算法的实现思路也有展开,可作为毕业设计选题方案、功能设计参考和排错手册,帮助读者快速搭建项目框架并深入理解相机与图像处理技术栈。压缩包整体约二十兆,体积精简,便于快速获取和本地查阅;截至目前已有九百七十七人学习下载,适合正在筹备安卓图像类毕业设计或希望进阶图像处理与相机应用开发的学习者。
1. 美颜相机 Android App:毕业设计要交付的不是滤镜,是渲染管线
用 Android Studio 开发 app 项目,如果毕业设计选“美颜相机”,很多人的第一版方案是“相机界面 + 随手调几个滤镜”。真正碰过摄像头之后才会意识到,美颜相机与普通拍照 app 的分水岭是帧级的图像处理:相机预览每 33ms 出一帧,美颜要在这一帧上完成人脸位置分析、皮肤区域识别、磨皮美白和瘦脸,再把结果送回到屏幕。美图秀秀这种产品的体验建立在渲染管线上,而不是单个算法上。这个题目适合想同时展示 Android 系统能力、图像处理和性能优化的人,但三个方向都铺开,工作量会失控。把范围锁在“实时磨皮、美白、瘦脸 + 拍照保存”,再用一台真机跑出可接受的帧率,已经是一份能完成、能讲述、经得起追问的毕业设计。后面按采集、算法、渲染、工程验证四层展开。
2. 图像采集与预览:CameraX 取帧、TextureView 显示与摄像头权限
2.1 为什么选 CameraX 而不是 Camera2
毕业设计的美颜 app 需要一个可持续输出帧且不容易崩坏的摄像头入口。CameraX 在 Camera2 之上封了一层与 Activity 生命周期绑定的用例模型:Preview 管屏幕预览,ImageAnalysis 管逐帧回调,ImageCapture 管拍照,三者的绑定与释放交给bindToLifecycle,不需要自己维护 CameraDevice、CaptureSession 和 Surface 状态机。Camera2 能拿到更低层的手动参数和更高的输出帧率,但代价是代码里的回调嵌套和状态流转,一旦 Activity 被回收,线程里还握着相机句柄,闪退现场通常很难复现。
从交付角度,CameraX 的默认行为已经处理了大部分厂商兼容问题,包括预览方向、旋转角度和前后摄切换,这些正是毕业设计阶段最耗时的隐性工作。下面是我在选型时习惯做的对比:
| 对比项 | CameraX | Camera2 |
|---|---|---|
| 生命周期 | 与界面绑定后自动关闭 | 需要手动关闭 CameraDevice 与 Session |
| 预览与分析组合 | Preview + ImageAnalysis 两个用例 | 需要为多个 Surface 调配 Buffer |
| 帧格式 | 默认 YUV_420_888,直接给 ImageProxy | 需要自己协商 ImageReader 格式 |
| 从零跑通工作量 | 半小时左右 | 一周以上 |
| 可控深度 | 中等,足够美颜场景 | 高,适合相机专业模式二次开发 |
选 Camera2 也不会错,但它适合后面要接 RAW 输出、自定义 HDR 连拍的方向。美颜的核心是图像后处理,不是传感器参数,所以 CameraX 是更匹配的入口。
2.2 在 Android Studio 开发 app 实例中接入 CameraX
先加依赖。在build.gradle的 dependencies 块里,CameraX 相关库按功能分开引入,其中camera-view是 PreviewView 所在库:
dependencies { def camerax = "1.3.0" implementation "androidx.camera:camera-core:$camerax" implementation "androidx.camera:camera-camera2:$camerax" implementation "androidx.camera:camera-lifecycle:$camerax" implementation "androidx.camera:camera-view:$camerax" }版本号建议用你新建项目时 SDK Manager 能拉到的已发布正式版,如果已经有更新版本,直接替换字符串即可。出于演示可复现,我固定用 1.3.0 这套配置。
接入代码的核心是“预览和分析两个用例绑定到生命周期”:
class CameraActivity : AppCompatActivity() { private lateinit var previewView: PreviewView private val analysisExecutor = Executors.newSingleThreadExecutor() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_camera) previewView = findViewById(R.id.previewView) if (hasCameraPermission()) { startCamera() } else { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.CAMERA), REQUEST_CAMERA_PERMISSION ) } } private fun startCamera() { val providerFuture = ProcessCameraProvider.getInstance(this) providerFuture.addListener({ val provider = providerFuture.get() val preview = Preview.Builder() .build() .also { it.setSurfaceProvider(previewView.surfaceProvider) } val analysis = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .build() analysis.setAnalyzer(analysisExecutor) { proxy -> // proxy 的 YUV 帧在这里交给后续美颜管线,用完后必须 close() proxy.close() } provider.bindToLifecycle( this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis ) }, ContextCompat.getMainExecutor(this)) } private fun hasCameraPermission() = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED companion object { private const val REQUEST_CAMERA_PERMISSION = 100 } }代码里有两个关键参数需要解释。STRATEGY_KEEP_ONLY_LATEST是最容易被漏掉的设置:它告诉 CameraX 当分析器处理不过来时,直接丢弃旧帧、保留最新帧。如果改成STRATEGY_BLOCK_PRODUCER,帧堆积会让预览延迟越来越严重,看起来就是“越拍越卡”,实际是背压策略没有配对。另一个是setOutputImageFormat,指定成YUV_420_888是当前 Android 相机的通用输出格式,也是后续图像处理阶段最标准的输入。
分析器回调里的proxy.close()不能不写。ImageAnalysis 的回调是串行的,一帧不 close 就卡住下一帧传递;如果抛异常忘记 close,分析线程会一直阻塞到超时。拿到帧之后要尽快把数据拆出来,不要在回调里做耗时操作。常见做法是把proxy转成可以跨线程引用的ImageInfo + ByteBuffer快照,或者直接复制成一个 Mat,再交给人脸检测线程。
另外,previewView是 CameraX 提供的视图容器,内部会根据设备能力选择 SurfaceView、TextureView 或 OpenGL 实现,不需要再手动写一个 TextureView。这样既省掉了 Surface 生命周期管理,也让后续接入美颜渲染时有一个统一的出口。
2.3 帧数据从 YUV 到 RGB:ImageAnalysis 的旋转与裁剪
CameraX 给到的ImageProxy里的 YUV 帧不是一张普通照片,它是 Y、U、V 三个平面按不同行距排列的原始数据,而且手机摄像头传感器方向与屏幕方向不一致,必须做旋转。后置摄像头通常需要顺时针旋转 90 度,前置摄像头需要旋转 270 度并做镜像,否则人脸检测拿到的坐标会和预览画面错位。
答辩最容易问的点是“你写的 YUV 转 RGB 考虑过 rowStride 和 pixelStride 吗”。Y 平面的rowStride(每行像素所占字节数)往往大于图片宽度,U/V 平面的pixelStride通常是 2,即 U 和 V 交替存放,不是紧密排列。如果直接buffer.get()读一行,多出来的 padding 会污染数据。
为了快速交付,一个可行的过渡方案是把ImageProxy拷成 NV21,交给YuvImage压缩成 JPEG 再解成 Bitmap。这个方案慢,但代码路径清晰,先用它跑通人脸上屏,再替换成 GPU 纹理。下面是按行逐行拷贝的转换函数:
fun imageProxyToBitmap(image: ImageProxy): Bitmap? { val width = image.width val height = image.height val ySize = width * height val nv21 = ByteArray(ySize + width * height / 2) var offset = 0 val yPlane = image.planes[0] val yBuffer = yPlane.buffer val yRowStride = yPlane.rowStride for (row in 0 until height) { yBuffer.position(row * yRowStride) yBuffer.get(nv21, offset, width) offset += width } val uPlane = image.planes[1] val vPlane = image.planes[2] val uBuffer = uPlane.buffer val vBuffer = vPlane.buffer val uRowStride = uPlane.rowStride val vRowStride = vPlane.rowStride var uvOffset = ySize val uvHeight = height / 2 for (row in 0 until uvHeight) { uBuffer.position(row * uRowStride) vBuffer.position(row * vRowStride) var col = 0 while (col < width / 2) { // NV21 的字节顺序是 V、U 交替,不是 U、V nv21[uvOffset++] = vBuffer.get() nv21[uvOffset++] = uBuffer.get() col++ } } val out = ByteArrayOutputStream() YuvImage(nv21, ImageFormat.NV21, width, height, null) .compressToJpeg(Rect(0, 0, width, height), 90, out) val bytes = out.toByteArray() return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }这段代码演示了“尊重行距”的正确姿势:Y 平面不能直接整块拷贝,要按行定位到row * rowStride;UV 平面按行读两个 buffer 时,要留意 U 和 V 的rowStride不一定相等。注释里特意标出 NV21 的排列顺序是 V 在前 U 在后,因为YuvImage只认 NV21,顺序写反会出现整张照片偏色。
把 Bitmap 拿到之后,旋转用image.imageInfo.rotationDegrees配合Matrix.postRotate完成。如果预览视图是全屏,还要按视图宽高比做居中裁剪,否则人脸坐标的 y 方向会偏移。这张 Bitmap 可以送给人脸检测,也可以作为 OpenCV 的Mat输入。但要提醒一句:这个流程只是为了先跑通算法,它经过了 JPEG 压缩,会损失细节,而且 1080p 下耗时接近 20ms,不能用于实时管线。实时管线要直接操作ImageProxy的 YUV 平面,或把帧上传成纹理后在 GPU 上做转换。
3. 美颜算法核心:人脸关键点、保边滤波磨皮与三角形网格瘦脸
3.1 人脸关键点选型:ML Kit Face Detection 还是 MediaPipe Face Mesh
美颜的第一件事是知道“脸在哪、五官边界在哪”。没有关键点,磨皮会把睫毛和唇纹一起抹掉,瘦脸更无从谈起。当前可落地的方案主要是 ML Kit Face Detection 和 MediaPipe Face Mesh。
ML Kit 的优势是接入代价小,提供左眼、右眼、鼻尖、左右嘴角等基础关键点,以及一个覆盖脸部的轮廓多边形,足够画脸框和做简单磨皮。MediaPipe Face Mesh 输出 468 个密集关键点,覆盖眉毛、眼睛、嘴唇、下颌线,可以做更精细的瘦脸和下巴调整,但初始化和模型加载的复杂度更高,运行时内存也更大。对毕业设计来说,如果目标只是“磨皮 + 美白 + 轻度瘦脸”,ML Kit 的基础关键点完全够用;如果还想做“V 脸”“大眼”,则需要 MediaPipe 的密集网格。
| 选型项 | ML Kit Face Detection | MediaPipe Face Mesh |
|---|---|---|
| 关键点数量 | 约 68 个(含轮廓点) | 468 个密集点 |
| 集成方式 | Google Play Services 或绑定模型 | AAR + 模型资产 |
| 是否支持离线 | 支持 | 支持 |
| 输出信息 | 脸部矩形、轮廓、表情概率 | 脸部网格、姿态、混合形状 |
| 适合的功能 | 磨皮遮罩、瘦脸轮廓 | V 脸、大眼、下巴调整 |
常见做法是先用 ML Kit 拿到脸部矩形和关键点,画出美颜遮罩,实时性更容易控制。MediaPipe 的 468 个点更适合离线跑通算法后再搬进实时管线。
3.2 磨皮:从高斯模糊到保边滤波,边缘为什么留得住
磨皮的算法本质是低通滤波,把皮肤上的高频瑕疵压平。最朴素的高斯模糊会把整张脸糊成“塑料脸”,原因是它在平滑皮肤纹理的同时也抹掉了眼睛、眉毛和嘴唇这些应该有清晰边缘的区域。所以产品级磨皮要加一个“边缘保护”:只在平坦的皮肤区域做模糊,在边缘区域保留原像素。
OpenCV 里的双边滤波bilateralFilter是保边滤波的代表实现。它的权重由空间距离和像素值差共同决定:距离近的像素贡献大;像素值与中心值差得越大,贡献越小。这正好让边缘两侧的像素互相不“沾”,保留轮廓。一段在 Android 上可运行的 OpenCV Java 代码如下:
Mat src = Utils.loadResource(this, R.raw.portrait); Mat dst = new Mat(); Imgproc.bilateralFilter(src, dst, 15, 80.0, 80.0); Utils.matToBitmap(dst, bitmap);diameter是邻域直径,越大参与计算的像素越多,磨皮感越强;sigmaColor是颜色阈值的标准差,控制“多大色差算边缘”;sigmaSpace控制空间距离的衰减速度。经验值:皮肤占画面 1/2 时可以取diameter = 15, sigmaColor = 80, sigmaSpace = 80;如果想保留更多五官细节,把diameter降到 9,sigmaColor降到 40。
双边滤波是 CPU 上最直观的保边滤波,但 1080p 下单帧耗时经常在 100ms 以上,不适合实时。更常见的实时磨皮做法是“高反差保留”的变体:先用一个小半径高斯模糊或盒状模糊得到平均色,再用原图减平均色得到细节层,最后用细节层控制模糊结果的回显比例。这样磨皮后的五官还是原来的锐利程度,只有大面积皮肤被抹平。这个思路在下一章的 GLSL 着色器里可以直接用。
3.3 美白、红润与瘦脸:肤色调整与局部变形
美白不能在 RGB 三个通道上等比例降低亮度,那样只会得到灰蒙蒙的曝光不足。比较稳妥的做法是把图像转到 YCbCr 色彩空间,对亮度 Y 施加一个增益,对色度 Cb/Cr 向标准肤色方向收拢,相当于让肤色更亮、更均匀:
Y' = Y * (1 + whitenStrength) Cb' = (Cb - 128) * (1 - skinToneAdjust) + 128 Cr' = (Cr - 128) * (1 - skinToneAdjust) + 128whitenStrength一般取 0.1 到 0.25,超过这个值会丢失高光细节;skinToneAdjust取 0.05 到 0.1,只在 Cb/Cr 偏移轻微时有效。如果想做“红润”,可以把 Cr 的系数改成(1 - skinToneAdjust) + 0.03,相当于在收缩色度到标准肤色的同时,给 Cr 一个正向偏置,让肤色偏粉。这个公式是逐像素操作,挪到 GPU 上就能参与实时处理。
瘦脸要麻烦很多。它属于局部图像变形,思路是在脸部区域内定义控制网格,把瞳孔下方、下颌线附近的像素向脸部中心方向收缩,收缩量从控制中心向外衰减到零,避免产生突变。常见的简化实现是先拿到脸部轮廓点,用 Delaunay 三角剖分把脸分成若干个三角形,再把原图中对应三角形区域映射到变形后的网格。最简单的“气球式”瘦脸可以用下面的伪代码表达:
fun warp(point: PointF, center: PointF, radius: Float): PointF { val dx = point.x - center.x val dy = point.y - center.y val distance = sqrt(dx * dx + dy * dy) if (distance >= radius) return point // 中心处收缩最强,边缘处回到原位 val ratio = (1f - distance / radius) * shrinkStrength return PointF(point.x + dx * ratio, point.y + dy * ratio) }这段代码针对一个控制点做“向中心收缩”的位移,shrinkStrength是收缩强度,超过 0.3 人眼会明显感觉到面部变形。实际产品里不会对原图像素做循环,而是把变形作用到三角形网格的顶点坐标上,再用变化后的网格重新采样纹理。这样既能用 GPU 插值,也不会在脸上掏出空洞。答辩时如果能说清楚“变形在网格顶点上做,纹理采样在 GPU 上做”,瘦脸这道题基本就过关了。
4. 实时美颜管线:GLSurfaceView、OES 纹理与 GLSL 磨皮美白滤镜
4.1 从 CPU 到 GPU:为什么实时预览要在 33ms 内完成
如果沿用上一章的 OpenCV 逐帧处理路线,预览帧率会非常难看。用 1080p 输入做一次人脸检测、一次 YUV 转 RGB、一次双边滤波,时间就超过了 33ms,CPU 会持续满载,手机会发热,摄像头回调还会因为背压策略越丢越快。实时美颜必须把像素级操作搬进 GPU,CPU 只负责“脸在哪”这种轻量逻辑。
这里先给一个成本表,帮助你判断哪些步骤适合留在 CPU:
| 管线环节 | 较慢的 CPU 做法 | 较快的 GPU 做法 |
|---|---|---|
| YUV 帧转 RGBA | 逐像素转换,约 15ms | 把 YUV 平面上传纹理,在 Shader 里转,约 2~3ms |
| 磨皮模糊 | 双边滤波,80~200ms | 盒状/高斯采样,< 1ms |
| 美白调整 | 遍历像素,约 10ms | 一次颜色变换,< 1ms |
| 瘦脸变形 | 像素坐标插值,约 30ms | 网格顶点变形 + 纹理采样,约 3~5ms |
| 人脸关键点检测 | 20~40ms | 减采样后约 15~25ms |
从表格可以得出结论:人脸检测仍是瓶颈,但它是 20ms 内的单次操作,且不需要每一帧都做。常见做法是每 2 帧或 3 帧检测一次,中间帧沿用上一帧的关键点,并把检测用的图像降采样到 320 或 640 宽,这样可以让出大量 CPU 时间。GPU 方案用 GLSurfaceView 承载渲染,摄像头通过 SurfaceTexture 把每帧送成 OES 纹理,GLSL 在纹理上直接采样。
4.2 GLSL 顶点与片段着色器:从 OES 纹理采样的美颜滤镜
GLSurfaceView 的渲染器拿到的是外部纹理samplerExternalOES,它不能像普通 2D 纹理那样直接当sampler2D采样,需要先用SurfaceTexture.updateTexImage()把最新帧绑定到 OES 纹理上,再用getTransformMatrix()拿到纹理坐标变换矩阵。顶点着色器里把这个矩阵作用到坐标上:
#version 300 es layout(location = 0) in vec4 aPosition; layout(location = 1) in vec4 aTexCoord; uniform mat4 uSTMatrix; out vec2 vTexCoord; void main() { gl_Position = aPosition; vTexCoord = (uSTMatrix * aTexCoord).xy; }片段着色器里做磨皮和美白,下面是一组可直接放进GLES20.glShaderSource的 GLSL:
#version 300 es #extension GL_OES_EGL_image_external_essl3 : require precision mediump float; in vec2 vTexCoord; out vec4 fragColor; uniform samplerExternalOES uTexture; uniform vec2 uResolution; // 当前渲染缓冲的宽高 uniform float uBeautyLevel; // 0.0 ~ 1.0,磨皮强度 uniform float uBrightness; // 0.0 ~ 0.2,美白强度 void main() { vec2 texelSize = 1.0 / uResolution; vec3 center = texture(uTexture, vTexCoord).rgb; // 3x3 邻域平均,等价于最便宜的盒状模糊 vec3 sum = vec3(0.0); for (int i = -1; i <= 1; i++) { for (int j = -1; j <= 1; j++) { sum += texture(uTexture, vTexCoord + vec2(i, j) * texelSize).rgb; } } vec3 avg = sum / 9.0; // 用亮度差判断当前像素是否处于边缘区域 vec3 diff = abs(center - avg); float edge = dot(diff, vec3(0.299, 0.587, 0.114)); float preserve = smoothstep(0.03, 0.18, edge); // 平坦区域用模糊结果,边缘区域保留原片 vec3 smoothColor = mix(center, avg, uBeautyLevel * (1.0 - preserve)); // 美白:整体加亮,边缘区域不参与磨皮但可以参与提亮 smoothColor += vec3(uBrightness); fragColor = vec4(smoothColor, 1.0); }代码里的uBeautyLevel是滑杆映射出来的磨皮强度,0.0 是原图,1.0 是满磨皮。preserve的计算很有意思:edge表示当前像素与邻域平均的亮度差,在眼睛、嘴唇这类边缘区域这个值很高,preserve接近 1,mix的结果就偏向center,原细节保留;在皮肤区域edge很低,preserve接近 0,结果偏向avg,皮肤被抹平。smoothstep(0.03, 0.18, edge)中间的阈值需要根据实际肤色微调,测试时可以先把区间拉大到 0.05 ~ 0.2,方便观察边缘过渡。
uResolution用来把像素偏移换算成归一化纹理坐标,texelSize就是单个像素在 UV 空间中的宽度。真机调试时先绑定uBeautyLevel = 0.5、uBrightness = 0.08,再逐档往上涨,比一次开满更容易看到边缘是否糊掉。
4.3 拍照美颜与预览共用 Shader:glReadPixels 回读成 Bitmap
预览和拍照如果走两条管线,会出现“预览里是西施,照片里是黛玉”的怪现象。一个干净的思路是让拍照也走 GLSurfaceView 的渲染线程:先把美颜 shader 作用于当前纹理,再调用glReadPixels把渲染结果读回 Bitmap。这样拍出来的文件就是屏幕上看到的美颜结果,不用二次处理。
从 GL 线程读像素的代码模板如下:
val buf = ByteBuffer.allocateDirect(width * height * 4) GLES20.glReadPixels(0, 0, width, height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buf) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) bitmap.copyPixelsFromBuffer(buf) val matrix = Matrix() matrix.postScale(1f, -1f, width / 2f, height / 2f) val flipped = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, true)glReadPixels的坐标原点在左下角,Bitmap.createBitmap的第一行在上方,因此要沿 Y 轴翻转。postScale(1f, -1f, width / 2f, height / 2f)以图像中心为轴做了垂直镜像,翻转后写入 JPEG 时不需要再关心 GL 坐标。还要注意:这段代码必须跑在 GLSurfaceView 的 GL 线程里,不能从 UI 线程直接调用;回读的数据量很大,建议在拍照按钮按下后等queueEvent执行完,再切回主线程落盘。
5. 工程化落地:构建配置、帧率跟踪与答辩演示设计
5.1 release 构建避开 lintVitalAnalyzeRelease 阻塞
很多人在./gradlew assembleRelease给答辩老师打演示包时,会遇到compileReleaseKotlin之后突然出现lintVitalAnalyzeRelease失败的报错。lintVitalAnalyzeRelease是 AGP 在 release 构建里额外触发的 Lint 致命问题检查,它不属于应用代码,而是构建链路上的一环。出现MissingPermission、NewApi、ExportedService这类错误级别问题时,Lint 会直接让发布构建失败。
常见做法是保留 debug 的 Lint 检查,只对 release 开关做降压:
android { lint { checkReleaseBuilds false abortOnError false } }关闭后 release 构建不会再被 lint 阻塞。但建议不要把abortOnError用到 debug 上,否则一边改代码一边被错误列表轰炸,效率很低。如果真的在 release 里报了权限问题,优先去AndroidManifest.xml补齐声明,而不是只关 lint。
5.2 用 dumpsys gfxinfo 验证实时美颜的帧率
实时美颜的最后一项验收是帧率,不能靠肉眼看卡顿。Android 自带的dumpsys gfxinfo可以统计应用渲染的每帧耗时,命令如下:
adb shell dumpsys gfxinfo com.example.beautycamera resets adb shell dumpsys gfxinfo com.example.beautycamera framestats第二条命令输出的Janky frames是丢帧总数,50th percentile、90th percentile、95th percentile是耗时分布。重点关注 90 分位是否小于 33ms,如果 95 分位超过 33ms,说明磨皮强度开满时存在肉眼可见的掉帧。
| 输出字段 | 用途 |
|---|---|
| Total frames rendered | 测试周期内的总帧数 |
| Janky frames | 超过 vsync 周期的帧数 |
| 90th percentile | 90% 帧都低于的耗时,判定流畅度 |
| Number of dropped frames | SurfaceFlinger 记录的丢帧数 |
测试时保持单手滑动“强度滑杆”,让 Shader 从低强度到高强度连续切换,这样采到的数据能反映出 GPU 负载变化。如果 90 分位超过 33ms,优先降低人脸检测频率,并把 Edge 阈值拉大,而不是急着优化 Shader。
5.3 给答辩演示留一个“原图/美颜”切换开关
演示环节最怕“老师一看就知道是滤镜,却不知道你做了什么”。在相机页左上方放一个切换按钮,点击后把渲染器的beautyLevel置为 0,同时关闭美白,预览立刻回到原始相机画面;再点一下恢复满强度美颜。滑杆则绑定时更新 Shader 里的强度值:
beautySlider.addOnChangeListener { _, progress, _ -> renderer.beautyLevel = progress / 100f }这样老师可以在同一次预览里看到原图、磨皮 50%、磨皮 100% 三档效果,比两张静态对比图有说服力。如果还能在屏幕上画出人脸关键点,说明你已经把“人脸检测 → 美颜 → 渲染”这条链路打通了。把beautyLevel = 0f的开关放到相机页左上角,再在右侧放一个横向滑杆,评分老师能在下一个动作里看到同一帧从原图变成磨皮、美白后的效果。
本文还有配套的精品资源,点击获取