简介:一份华南农业大学毕业设计项目源码,实现类似美颜相机/美图秀秀的Android应用,面向计算机专业学生、Java及Android开发学习者,可作为毕业设计或课程设计的完整参考方案。项目覆盖图像采集、滤镜渲染、人像美化等核心模块,包含丰富的界面布局与业务逻辑代码,可帮助理解移动端图像处理的实现思路。压缩包共2000个文件,其中1216个xml布局文件、378个json配置、352个java源码,另有shell、properties、c及md等辅助文件,整体大小约19.43MB,目录结构完整便于对照学习。源码基于Java与Android平台,涉及GPUImage等图像处理框架,适合需要快速搭建同类应用或扩展美颜功能的开发者使用。目前已有118人学习下载,具有不错的参考价值。
1. 别把美颜相机毕设做成滤镜相册:先想清楚链路
毕设答辩时评审最常追问的,往往是那句“你这个磨皮用的是什么原理”。很多同学拿到“类似美颜相机”的 Android 题目后,先花两三周搭界面、接相机、做相册,最后塞一个第三方渲染引擎了事,结果演示时被问两句就露馅。这个题目的本质不是做“滤镜相册”,而是把一条实时图像处理链路完整跑通:相机采集、预览、人脸检测、磨皮美白、瘦脸大眼、特效滤镜,最后输出到屏幕或保存成图片。它适合有一点 Java/Kotlin 基础、愿意碰 Camera2 和 OpenCV 的读者;工程量适中、算法有讲头,是图像处理方向里性价比很高的毕设选题。
2. 工程骨架与选型:Camera2 + TextureView 是最稳的组合
2.1 先把一条美颜链路画清楚,再决定代码往哪放
常见的做法是先把处理流程拆成独立阶段,每个阶段只做一件事,阶段之间用“接口”连接,而不是把所有逻辑堆在一个 Activity 里。典型链路是这样的:相机传感器输出 YUV 帧 → 显示到 TextureView / GLSurfaceView 预览 → 从帧里检测人脸框 → 提取关键点 → 对整帧做磨皮、美白 → 以关键点为圆心做瘦脸、大眼 → 叠加实时滤镜(GLSL)→ 屏幕预览或拍照保存。
画链路的价值在于:你能在动手前就确定每个模块的输入输出。比如“磨皮模块”接收一张 Mat,返回一张 Mat;“滤镜模块”接收纹理 ID,输出到屏幕。这样后续替换算法、调参、加功能都不需要重写相机部分。另一个好处是答辩时要画架构图,你已经有一张现成的模块图,不需要临时编。
我一般会把“预览显示”和“图像处理”分开,预览显示用 TextureView 承接相机帧,图像处理在后台线程里跑 OpenCV Mat。如果混在一起,UI 线程会被拖慢,滑动滤镜参数时会明显卡顿。这个划分几乎决定了整个工程最后能不能稳定跑起来。
2.2 为什么用 Camera2 而不是 CameraX,OpenCV 而不是 RenderScript
选型是毕设里最容易被追问的部分,理由要说得出。CameraX 确实简单:PreviewView 开箱即用,生命周期自动绑定,写几十行就能看到预览。但它有两个问题:一是拿 YUV 帧做处理要走 ImageAnalysis,把 Image 转成 OpenCV Mat 还要自己写转换;二是对预览方向、sensor 旋转等细节控制比较黑盒,答辩时“相机是怎么打开的”这个问题很难讲透。Camera2 代码量大,但每一步都透明:openCamera、CaptureSession、CaptureRequest 全是显式调用,评审问起来你能一条条答清楚。
算法侧要注意 RenderScript 已经被 Google 标记废弃,新工程不要碰。OpenCV Android SDK 提供人脸检测、矩阵运算、颜色空间转换,磨皮美白这类需求直接调用即可。人脸关键点如果要求更高(比如 68 点、106 点),可以接一个轻量级模型,但纯毕设场景下 OpenCV 自带级联检测器已经够用。渲染侧选 GLSurfaceView + GLSL,滤镜用着色器在 GPU 上跑,比在 CPU 上逐像素快两个数量级,这也是“实时美颜”能成立的原因。
2.3 跑通预览的最小步骤:Gradle、权限、开相机和方向修正
先加依赖,版本号以 Android Studio 能拉到的最新稳定版为准,不写死:
implementation("org.opencv:opencv") // 在 AS 的 SDK Manager 里同步最新版本AndroidManifest 里申报相机权限:
<uses-permission android:name="android.permission.CAMERA" /> <uses-feature android:name="android.hardware.camera" android:required="true" />打开相机的最小代码,注意回调要跑在后台线程:
private fun openCamera() { val manager = getSystemService(Context.CAMERA_SERVICE) as CameraManager val cameraId = cameraManager.cameraIdList[if (useFront) 1 else 0] if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { return } manager.openCamera(cameraId, object : CameraDevice.StateCallback() { override fun onOpened(camera: CameraDevice) { device = camera createCameraSession() } override fun onDisconnected(camera: CameraDevice) { camera.close() device = null } override fun onError(camera: CameraDevice, error: Int) { camera.close() device = null } }, backgroundHandler) }这里backgroundHandler必须绑定到HandlerThread的 Looper,不能new Handler()跑主线程,否则相机回调会把主线程堵住。openCamera指定前后摄时,cameraIdList的下标并不总是 0 后 1 前,要看CameraCharacteristics.LENS_FACING判断,这也是一个经典踩坑点。
拿到CameraDevice后创建会话,把 TextureView 的 SurfaceTexture 设成预览目标:
private fun createCameraSession() { val texture = textureView.surfaceTexture ?: return texture.setDefaultBufferSize(PREVIEW_WIDTH, PREVIEW_HEIGHT) // 用 1280x720,别用 4K val surface = Surface(texture) val request = device?.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)?.apply { addTarget(surface) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) } device?.createCaptureSession(listOf(surface), object : CameraCaptureSession.StateCallback() { override fun onConfigured(session: CameraCaptureSession) { session.setRepeatingRequest(request!!.build(), null, null) } override fun onConfigureFailed(session: CameraCaptureSession) { // 提示用户相机不可用,不要静默吞掉 } }, backgroundHandler) }预览方向修正是另一个必做项。相机的 Sensor 方向和屏幕方向不一致,竖屏预览会横过来。常见做法是读SENSOR_ORIENTATION,再结合当前屏幕旋转角度做补偿:
val matrix = Matrix() val displayRotation = windowManager.defaultDisplay.rotation val sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0 val degrees = when (displayRotation) { Surface.ROTATION_90 -> 90 Surface.ROTATION_180 -> 180 Surface.ROTATION_270 -> 270 else -> 0 } matrix.postRotate((sensorOrientation - degrees + 360) % 360) if (useFront) matrix.postScale(-1f, 1f) // 前置要镜像 textureView.setTransform(matrix)这段代码能解决 90% 的方向问题。剩下的那种画面旋转 180 度,多半是TEMPLATE_PREVIEW的传感器像素格式问题,但按上面的写法基本不会遇到。预览跑通后,整个工程就有了“地基”,后续做算法都在这条链路上加。
3. 磨皮美白怎么落地:从肤色检测到 SeekBar 参数
3.1 磨皮不是无脑模糊:肤色蒙版 + 高斯模糊 + 羽化混合
很多人的第一版磨皮是整张图高斯模糊,结果头发丝、眉毛、背景全糊了,脸还像贴了层塑料。正确做法是只对皮肤区域做模糊,而且边缘要过渡自然。完整思路是:先检测肤色区域,生成蒙版;对原图做高斯模糊;用蒙版作为权重,把原图和模糊图线性混合。蒙版越白的地方,模糊的权重越高。
实现代码如下,直接输入一张 OpenCV Mat,返回磨皮后的 Mat:
fun smoothSkin(src: Mat, strength: Double): Mat { // 1. 转 YCrCb,检测肤色 val ycrcb = Mat() Imgproc.cvtColor(src, ycrcb, Imgproc.COLOR_RGB2YCrCb) val mask = Mat() Core.inRange(ycrcb, Scalar(0.0, 133.0, 77.0), Scalar(255.0, 173.0, 127.0), mask) // 2. 羽化蒙版,避免皮肤边缘出现硬边 Imgproc.GaussianBlur(mask, mask, Size(25.0, 25.0), 0.0) // 3. 对原图做高斯模糊 val blur = Mat() Imgproc.GaussianBlur(src, blur, Size(9.0, 9.0), 0.0) // 4. 把蒙版转成 0~1 权重,并扩展成三通道 val maskF = Mat() mask.convertTo(maskF, CvType.CV_32F, 1.0 / 255.0) val w = Mat() Core.merge(listOf(maskF, maskF, maskF), w) // 5. 原图和模糊图也转浮点 val srcF = Mat() src.convertTo(srcF, CvType.CV_32F, 1.0 / 255.0) val blurF = Mat() blur.convertTo(blurF, CvType.CV_32F, 1.0 / 255.0) // 6. 用权重混合:out = blur * w * strength + src * (1 - w * strength) val weight = Mat() Core.multiply(w, Scalar(strength, strength, strength), weight) val oneMinusWeight = Mat() Core.subtract(Scalar(1.0, 1.0, 1.0), weight, oneMinusWeight) val blurPart = Mat() Core.multiply(blurF, weight, blurPart) val srcPart = Mat() Core.multiply(srcF, oneMinusWeight, srcPart) val outF = Mat() Core.add(srcPart, blurPart, outF) val out = Mat() outF.convertTo(out, CvType.CV_8UC3, 255.0) return out }这段代码里最关键的是蒙版的羽化那一步。如果不做GaussianBlur(mask, mask, ...),蒙版边缘会是锐利的硬边,脸上会出现一块一块的“补丁感”。强度控制在 0.3~0.7 之间,超过 0.8 就会变成塑料脸。模糊半径 9 是 720p 下的经验值,分辨率翻倍时要对应增大。
3.2 美白在 YCrCb 空间做:提亮 Y、微调 Cb,防过曝
美白和磨皮是两件事:磨皮解决“皮肤不平”,美白解决“皮肤不够白”。美白在 YCrCb 空间做最直接:Y 通道提亮,Cr/Cb 做小幅偏移,让肤色不发黄、不发绿。实现很轻量:
fun whitening(src: Mat, strength: Double): Mat { val ycrcb = Mat() Imgproc.cvtColor(src, ycrcb, Imgproc.COLOR_RGB2YCrCb) val channels = ArrayList<Mat>() Core.split(ycrcb, channels) // Y 通道提亮,最多提 20% val gain = 1.0 + 0.2 * strength Core.multiply(channels[0], Scalar(gain), channels[0]) // 高光保护:超过 240 的像素压回 240,防止过曝 Core.min(channels[0], Scalar(240.0), channels[0]) // Cb 通道降一点,肤色调得更透 Core.subtract(channels[2], Scalar(10.0 * strength), channels[2]) Core.merge(channels, ycrcb) Imgproc.cvtColor(ycrcb, src, Imgproc.COLOR_YCrCb2RGB) return src }strength范围建议 0~1,Y 通道增益0.05~0.2,Cb 偏移5~10。超过这个范围高光区域会变成一块死白,尤其是额头和鼻梁。高光保护那一步不能省,很多人美白翻车就是栽在这里。
3.3 三组核心参数的范围与映射
预览时参数放轻一点,拍照出图时调重一点,这是美颜相机的通用逻辑。以下是 720p 预览分辨率下的经验值:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| 高斯模糊半径 | 5~15 像素 | 半径越大越糊,超过 15 脸部纹理全丢 |
| 磨皮强度 | 0.3~0.7 | 预览用 0.3~0.4,拍照用 0.5~0.7 |
| 肤色检测 Cr | 133~173 | 数值过大会把背景的红墙、红衣服也当成脸 |
| 肤色检测 Cb | 77~127 | 与 Cr 配合使用,两个范围收敛一点更稳 |
| 美白 Y 增益 | 0.05~0.2 | 超过 0.2 高光区域会过曝 |
| 锐化强度 | 0~0.3 | 磨皮后加一点锐化找回纹理 |
SeekBar 的滑动值一般是 0~100,传到算法里时除以 100 再乘一个系数。注意不能把 SeekBar 的原始值直接当 strength 用,否则调到一半效果就爆表了。
4. 瘦脸大眼和实时滤镜:局部变形 + GLSL 着色器
4.1 瘦脸大眼用“局部液化”就够了:以关键点为圆心的像素偏移
瘦脸大眼在美颜相机里属于“局部变形”算法,核心是让一个圆域内的像素按权重偏移。商业产品用的是液化(Liquify)和网格变形,毕设实现一个简化版完全够讲。思路是:选一个中心点(瘦脸用脸中心,大眼用眼睛中心),给定一个半径,圆内像素距离中心越近偏移越大,圆外不处理。
下面是 Java 写法,直接遍历目标区域内像素,反向采样:
void localWarp(Mat src, Mat dst, Point center, double radius, double strength) { dst = dst.zeros(src.size(), src.type()); int x0 = (int) Math.max(0, center.x - radius); int x1 = (int) Math.min(src.cols() - 1, center.x + radius); int y0 = (int) Math.max(0, center.y - radius); int y1 = (int) Math.min(src.rows() - 1, center.y + radius); for (int y = y0; y <= y1; y++) { for (int x = x0; x <= x1; x++) { double dx = x - center.x; double dy = y - center.y; double dist = Math.sqrt(dx * dx + dy * dy); if (dist >= radius) { dst.put(y, x, src.get(y, x)); continue; } // 权重:越靠近中心偏移越大,边缘平滑过渡到 0 double w = 1.0 - (dist * dist) / (radius * radius); double srcX = x - dx * w * strength; // strength 为正,像素向中心收拢 double srcY = y - dy * w * strength; // 双线性插值采样,避免锯齿 int x0s = (int) srcX, y0s = (int) srcY; double fx = srcX - x0s, fy = srcY - y0s; double[] p00 = src.get(y0s, x0s); double[] p10 = src.get(y0s, Math.min(x0s + 1, src.cols() - 1)); double[] p01 = src.get(Math.min(y0s + 1, src.rows() - 1), x0s); double[] p11 = src.get(Math.min(y0s + 1, src.rows() - 1), Math.min(x0s + 1, src.cols() - 1)); double[] out = new double[p00.length]; for (int c = 0; c < out.length; c++) { out[c] = p00[c] * (1 - fx) * (1 - fy) + p10[c] * fx * (1 - fy) + p01[c] * (1 - fx) * fy + p11[c] * fx * fy; } dst.put(y, x, out); } } }这个写法里strength为正数时像素向中心收拢,用于瘦脸;如果对眼睛中心做负向偏移(像素从中心向外推),就放大眼睛。半径一般取脸宽的 0.4~0.6 倍,太大鼻子会变形,太小看不出效果。注意整帧遍历会卡,实际只处理以关键点为中心裁出来的一块 ROI,能省掉 70% 的计算量。
4.2 滤镜走 GLSL:一段可以放进 GLSurfaceView 的片元着色器
滤镜如果也用 OpenCV 逐像素调色,预览帧率会很难看。正确做法是交给 GPU:把 OpenCV 处理后的 Mat 转成纹理,上传到 GLSurfaceView,片元着色器里做颜色映射。下面是一段基础的美颜滤镜着色器,包含提亮、轻微去饱和和色调微调:
#version 300 es precision mediump float; uniform sampler2D u_texture; uniform float u_brightness; in vec2 v_texCoord; out vec4 fragColor; void main() { vec4 color = texture(u_texture, v_texCoord); // 亮度调整 color.rgb += u_brightness; // 轻微去饱和,让肤色更柔和 float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); color.rgb = mix(color.rgb, vec3(gray), 0.15); fragColor = color; }u_brightness从 Java 侧通过glUniform1f传入,取值 0.02~0.06 就好,太大整体发白。这里有个关键点:Camera2 传过来的纹理是 OES 外部纹理(GL_TEXTURE_EXTERNAL_OES),着色器第一个版本要用#extension GL_OES_EGL_image_external : require,统一在工程里做一层纹理拉直,后续所有滤镜就都能复用。如果你用 GLSurfaceView 配合SurfaceTexture做相机预览,这块可以省掉OpenCV的转Bitmap过程,直接显示。
4.3 720p 预览、隔帧检测、ROI 处理:实时性从这三处抠
实时美颜的瓶颈从来不在滤镜,而在人脸检测和局部变形。常见做法是三个约束:预览处理分辨率锁 720p,不要碰 4K;人脸检测每 5 帧做一次,关键点滞后 100ms 人眼看不出问题;局部变形只处理人脸 ROI,而不是整帧。还有一条容易被忽略:OpenCV 的 Mat 处理完要立刻 release,否则内存会持续上涨。
我用这套约束在“某千元测试机”上把处理帧率稳定在 25 帧以上,而第一版全帧处理只有 12 帧。差距就来自 ROI 和隔帧检测这两处。答辩时被问“你的算法为什么快”,能把这三点答出来,比背一堆理论更有说服力。
5. 美颜相机常见问题排查:黑屏、镜像与内存泄漏
5.1 预览方向颠倒或横屏
现象:竖屏拿着手机,预览画面是横过来的,或者翻转 90 度后才正常。
原因:相机 Sensor 的默认方向和屏幕方向不一致,Camera2 不会帮你自动旋转。很多教程只给了设置TEMPLATE_PREVIEW的代码,没给方向补偿,预览竖起来是运气的。
解决:按第 2.3 节的sensorOrientation - displayRotation公式,对TextureView做setTransform(Matrix)。注意前置和后置的旋转方向相反,前置还要额外镜像。这个方法同时管预览显示和后续拍照方向,存图前对Mat做一次Core.flip(mat, mat, 1)即可。
5.2 权限弹窗后第一次打开黑屏,第二次正常
现象:首次安装进 App,弹出相机权限授权框,点“允许”后预览是黑色的;杀掉重进一次就正常。
原因:onResume()里先执行了openCamera(),但权限授权结果还没回来,checkSelfPermission返回拒绝,代码直接return了。之后权限回调里也没有再次调用openCamera,于是相机一直没打开。
解决:把打开流程改成“条件触发”而不是“顺序触发”。权限授权成功回调里调用openCamera(),TextureView 的SurfaceTextureListener回调里也检查权限,两边都满足才真正打开。最稳妥的是写一个tryStartCamera(),内部判断permissionGranted && surfaceReady,两个条件都满足执行openCamera。这一个改动就能消掉 80% 的黑屏问题。
5.3 前置自拍照片左右颠倒
现象:预览里看自己是正常的,拍出来的照片却是镜像的,和日常自拍习惯相反。
原因:前置相机的画面本身是镜像的,你为了让预览符合习惯做了Matrix.postScale(-1f, 1f),但保存照片时用的是ImageReader的原始帧,没有做同样的翻转。
解决:预览和存储是两条线。预览方向用 Matrix 修正,保存帧在写入文件前对 Mat 做水平翻转:Core.flip(rotatedMat, flippedMat, 1)。同时注意焦点的位置不会因为翻转而偏移,如果是固定点对焦,翻转后还要把焦点坐标映射到翻转后的坐标系里。这个坑在自拍场景下一抓一个准,评审演示时经常 crash 在这。
5.4 反复进出页面内存暴涨,最终闪退
现象:从列表页进相机页再退出,循环五六次,内存曲线一路上涨,最后 OOM 闪退。
原因:CameraDevice没有 close、SurfaceTexture没有释放、OpenCV Mat 没有 recycle。尤其CameraDevice不 close,下一次openCamera会直接抛CAMERA_IN_USE_ERROR。这条在低端机型上是必踩的坑。
解决:onPause()里执行device?.close()并置空;onDestroy()里textureView.surfaceTextureListener = null;后台线程用handlerThread.quitSafely()。另外,你在第 4.1 节 localWarp 里每次dst.create出来的 Mat,用完必须release。我见过最夸张的泄漏是把磨皮中间变量写在while(true)预览循环里不释放,两分钟内存涨了 300MB。检查泄漏最快的方式是开 Android Studio 的 Memory Profiler,反复进出页面时盯着Mat count。
5.5 磨皮过度“塑料脸”
现象:磨皮后皮肤纹理全无,像贴了一张塑料面具,尤其额头和脸颊反光严重。
原因:模糊半径和强度都用得太狠,肤色蒙版还把高光区域全覆盖进去了。很多同学的调参逻辑是“效果越明显越好”,但美颜相机的评审既看效果也看自然度。
解决:把高斯模糊半径控制在 9 像素以内,强度降到 0.4,蒙版羽化半径加大。磨皮之后加一道锐化,把高频细节叠回去 15%~20%,皮肤纹理就不会完全消失。还有个技巧:先做一遍小半径磨皮,再叠加一层更小半径的磨皮,比一次大半径更自然。这套“局部小剂量多次处理”的思路也可以直接写进论文,比单纯讲滤波要有内容。
6. 给毕设加分:离线精修对比 + 帧耗时统计,回答“你的美颜快不快”
实时预览为了流畅只能跑 720p 和轻量参数,但答辩时最加分的效果恰恰是“对比”:同一张原图,左边是原始效果,右边是离线精修后的效果。我建议单独加一个“拍照后离线精修页”,把拍照得到的全尺寸原图跑一遍完整磨皮美白,再叠加瘦脸大眼和滤镜,和原图左右分屏对比。这个页面不要求实时,算法可以跑重参数,效果会比预览好一大截,评审的视觉冲击力也强得多。
第二件事是把自己算法的耗时测出来,用数据回答“实时性够不够”。核心做法是在处理管线外包一层计时:
val t0 = System.nanoTime() val outMat = pipeline.process(frameMat) // 磨皮+美白+瘦脸+滤镜 val costMs = (System.nanoTime() - t0) / 1_000_000.0 runOnUiThread { tvFps.text = "帧耗时 ${costMs}ms / 帧率 ${(1000.0 / costMs).toInt()}fps" }连续跑 30 帧取平均,把数据整理成表格:720p 预览平均耗时 XXms,全部美化开启时帧率 XXfps,这个数据放进论文的“性能分析”一节非常有用。注意测的时候关掉系统省电模式,用同一台测试机跑完整流程,别用模拟器数据,模拟器的帧率没有参考价值。
我的习惯是:先把实时预览做到流畅,再单独做离线精修页,这样即使答辩现场预览翻车,也能用离线对比撑住场面。这也是我从模拟项目 X 的答辩经验里学到的教训——当时我把所有美化都堆在预览链路里,低温环境下低端机掉到 12 帧,现场演示很被动。后来改成 ROI 处理加隔帧检测,才稳定在 25 帧以上。希望你做这个方向时,少走这段弯路,希望帮到你。
本文还有配套的精品资源,点击获取