简介:毕业设计级安卓(Android)应用资源,从零实现类似美颜相机/美图秀秀的App,覆盖实时美颜、滤镜特效、照片编辑等核心能力。项目面向计算机相关专业学生及初级移动开发者,有助于完成课程设计、毕业设计或技能进阶,具备完整的项目实战参考价值。资源包为zip格式,整包约20.02MB,已有977人学习下载。内容围绕真实开发流程,涉及Android四大组件、常用布局与Material Design UI设计,Camera/Camera2相机调用,OpenCV与Glide图像处理,OpenGL ES渲染管线与GLSL着色器,磨皮瘦脸大眼等美颜算法,RGB到HSV色彩空间转换,动态滤镜加载,运行时权限适配,图片相册保存与微信微博分享,以及异步任务、内存管理和Android Profiler性能调优。这些知识点搭配实际项目代码,能帮助读者理解美颜相机的整体架构和关键实现路径。通过动手实践与调试,既能系统巩固Android开发技能,也能积累图像处理、用户体验设计和性能优化的综合经验。
1. 从“滤镜堆叠”到相机管线:美颜App的真正门槛
你在 Android Studio 里新建一个项目,调用系统相机 Intent 拍张照,再套几个滤镜,那是两个小时的 Demo。真正的美颜相机,需要从 SurfaceTexture 开始接管相机硬件:Camera2 输出 YUV 帧,OpenGL ES 在 16ms 内把磨皮、调色、瘦脸全部渲给屏幕,而按下快门时又要把同一套算法以更高分辨率跑在离线图片上。这个毕业设计最容易被低估的,就是这条从实时预览到离线后处理的双链路架构。下面按相机捕获、GLSL 美颜、OpenCV 后处理、性能验证四个环节拆开讲,中间会给出可编译的代码骨架和参数边界。
2. Camera2 API 预览与实时帧捕获:选择一条能走到底的相机路径
2.1 Camera1/Camera2/CameraX 的取舍
很多同学一开始会纠结:直接用 CameraX 的 PreviewView 不是很简单吗?确实,CameraX 封装了生命周期、旋转、缩放,但它的 Analyzer 输出的是 ImageProxy,要送进 OpenGL 需要手动搬运 YUV 数据,绕了一圈反而更慢。而 Camera1 的 setPreviewDisplay 只能绑定 SurfaceView,To achieve 实时美颜你得自己写 YUV 转 RGBA,并且 Camera1 在 Android 7.0 以后标为 deprecated,答辩时被问“为什么不用 Camera2”会很难收场。
| 维度 | Camera1 | Camera2 | CameraX |
|---|---|---|---|
| 对象模型 | 单个 Camera 实例 | CameraDevice + CaptureSession | UseCase 生命周期绑定 |
| 帧回调方式 | PreviewCallback 给 byte[] | SurfaceTexture / ImageReader | Analyzer 给 ImageProxy |
| 实时美颜接入 | 需要手动转 RGBA | 可直接绑定 GL 外部纹理 | 纹理桥接不透明 |
| 学习成本 | 低但路线落后 | 高,资料多 | 低,封装厚 |
| 毕业设计深度 | 会被追问 | 可展开讲管线 | 容易讲完没内容 |
我建议直接上 Camera2。不是因为 CameraX 不行,而是这个设计的核心就是实时美颜渲染,Camera2 的 SurfaceTexture+OpenGL 方案能让你把每一帧的处理握在自己手里,性能优化时也才有东西可写。
2.2 用 SurfaceTexture 建立预览通道
预览通道的核心是 SurfaceTexture。它是一个特殊的 Surface 目标,作用是把相机帧交给纹理对象,而不是直接画到 SurfaceView 上。下面的 Kotlin 代码展示了 Camera2 预览建立的最小骨架:
class CameraController( private val surfaceTexture: SurfaceTexture, private val handler: Handler ) { private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager private var cameraDevice: CameraDevice? = null private var captureSession: CameraCaptureSession? = null fun openCamera() { if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { // 在 Activity 中发起运行时权限请求 return } val cameraId = cameraManager.cameraIdList[0] // 判断前后摄像头:前置美颜场景通常在 front camera 上做镜像 val facing = cameraManager .getCameraCharacteristics(cameraId) .get(CameraCharacteristics.LENS_FACING) cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() { override fun onOpened(device: CameraDevice) { cameraDevice = device createPreviewSession() } override fun onDisconnected(device: CameraDevice) { device.close() } override fun onError(device: CameraDevice, error: Int) { device.close() } }, handler) } private fun createPreviewSession() { val texture = surfaceTexture texture.defaultBufferSize = 1280 * 720 val request = cameraDevice!!.createCaptureRequest( CameraDevice.TEMPLATE_PREVIEW ).apply { addTarget(Surface(texture)) set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO) // 关闭厂家自动美颜,避免预览画质不可控 set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, FALSE) } cameraDevice!!.createCaptureSession( listOf(Surface(texture)), object : CameraCaptureSession.StateCallback() { override fun onConfigured(session: CameraCaptureSession) { captureSession = session session.setRepeatingRequest(request.build(), null, handler) } override fun onConfigureFailed(session: CameraCaptureSession) { // 通常是尺寸不兼容或摄像头被占用 } }, handler ) } }这段代码里,addTarget(Surface(texture))把相机输出指向 SurfaceTexture,之后每一帧都会回调到纹理,而不是直接显示在屏幕上。defaultBufferSize必须显式设置,否则很多机型的默认 size 是 0,预览会黑屏。这里设成 1280x720,对实时美颜足够,如果要存原图,离线路上再用 ImageReader 拉高分辨率帧。
2.3 把 Camera2 帧送给 OpenGL 纹理
SurfaceTexture 本身还不是 OpenGL 纹理,它只是纹理的上层包装。要在 GLSurfaceView 里使用,需要一个外部纹理(OES)ID,并把 SurfaceTexture 绑定到当前 GL 上下文。这一步必须在 GL 线程内执行,常见做法是在 Renderer 的onSurfaceCreated里创建。
class OesTextureRenderer : GLSurfaceView.Renderer { private var textureId = 0 private lateinit var surfaceTexture: SurfaceTexture override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) { val ids = IntArray(1) glGenTextures(1, ids, 0) textureId = ids[0] surfaceTexture = SurfaceTexture(textureId) surfaceTexture.setOnFrameAvailableListener { // 这个回调在相机线程,需要标记一下然后 requestRender // 真正调用 updateTexImage() 必须回到 GL 线程 } } override fun onDrawFrame(gl: GL10?) { // 每一次绘制前用最新帧更新纹理 surfaceTexture.updateTexImage() // 此时 textureId 已经被外部帧填充,可以绑定到 shader 里采样 drawSceneWithTexture(textureId) } }updateTexImage()必须在 GL 线程调用,且要先有新的帧事件,否则会重复拿到旧帧。setOnFrameAvailableListener回调的线程不固定,所以一般只用来触发requestRender(),而把真正的内容更新放在onDrawFrame里。这样 Camera2 的每一帧都以外部纹理形式进入 OpenGL 管线,后续的美颜全部在 GPU 上完成,不经过 CPU 拷贝,这是实时性能的关键。
3. 实时美颜的 OpenGL ES 渲染:磨皮、滤镜与 FBO 管理
3.1 渲染链路与 FBO 离屏处理
预览渲染一般用 GLSurfaceView + GLSurfaceView.Renderer,但直接对预览纹理做效果只能有一个最终输出。如果需要叠加磨皮、滤镜、瘦脸三个效果,最好的方式是把它们串成多道 pass。每一道 pass 输出到 FBO(Frame Buffer Object),下一道 pass 再把 FBO 的纹理作为输入,最后再把结果绘制到屏幕默认帧缓冲。
为什么要这么绕?因为实时处理时如果直接读纹理本身,采样会出现反馈,而且每个效果之间难以单独调参。FBO 作为中间画布,可以缓存磨皮后的结果,再交给滤镜 shader,这样下帧到来时前一个效果已经完成。Android 上创建 FBO 的标准流程是生成纹理、绑定 FBO、挂载纹理作为颜色附件。整个过程和 GLSurfaceView 的 EGL 上下文一致,只要保证在 onSurfaceCreated 里完成即可。
3.2 磨皮着色器:高斯模糊 + 肤色权重
磨皮的核心是保边模糊。这里用最简单的高斯模糊做讲解:对当前像素周边 5x5 邻域加权平均,然后用肤色权重在原始像素和模糊像素之间插值,避免头发、眼睛等边缘被抹掉。以下是一个 GLSL 片段着色器:
precision mediump float; varying vec2 vTexCoord; uniform sampler2D uInputTex; uniform vec2 uTexOffset; // 1/width, 1/height uniform float uBlurR; // 高斯核的 sigma 值 uniform float uMix; // 磨皮强度 0.0 ~ 1.0 uniform float uSkinMin; // 肤色权重下界 float gauss(float x, float sigma) { return exp(-(x * x) / (2.0 * sigma * sigma)); } void main() { vec4 sum = vec4(0.0); float weightSum = 0.0; // 5x5 高斯核 for (int i = -2; i <= 2; i++) { for (int j = -2; j <= 2; j++) { vec2 offset = vec2(float(i), float(j)) * uTexOffset; vec4 color = texture2D(uInputTex, vTexCoord + offset); float w = gauss(float(i * i + j * j), uBlurR); sum += color * w; weightSum += w; } } vec4 blurred = sum / weightSum; vec4 origin = texture2D(uInputTex, vTexCoord); // 肤色权重:红色分量明显大于蓝色分量,且整体亮度不过亮 float skin = step(uSkinMin, origin.r - origin.b) * (1.0 - step(0.85, (origin.r + origin.g + origin.b) / 3.0)); gl_FragColor = mix(origin, blurred, uMix * skin); }这段代码里step函数用来做阈值判断,uSkinMin比如 0.2,表示当红色通道比蓝色通道高 0.2 以上时认为是肤色,否则保持原样。uBlurR控制模糊半径,2.0 到 4.0 之间磨皮效果比较自然;uMix太高会导致画面塑料感,一般 0.4 左右即可。此方案适合前置摄像头的浅景深场景,对暗部和强边缘保留效果相对简单,但作为毕业设计功能已经足够清楚。
| uniform | 作用 | 建议范围 |
|---|---|---|
| uTexOffset | 相邻像素归一化步长 | 1/width, 1/height |
| uBlurR | 高斯核 sigma | 1.0 ~ 4.0 |
| uMix | 磨皮强度 | 0.2 ~ 0.8 |
| uSkinMin | 肤色判定阈值 | 0.15 ~ 0.25 |
3.3 滤镜与色彩空间:RGB ↔ HSV 的可调色调映射
美颜相机的滤镜本质上是对色彩空间做变换。RGB 通道直接调亮度容易偏色,HSV 空间更符合直觉:H 控制色相,S 控制饱和度,V 控制亮度。GLSL 中需要自己实现 RGB 到 HSV 的转换,核心逻辑如下:
vec3 rgb2hsv(vec3 c) { vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); float d = q.x - min(q.w, q.y); float e = 1.0e-10; return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); } vec3 hsv2rgb(vec3 c) { vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); }在片元着色器里,先rgb2hsv,再直接修改hsv.x += uHueShift; hsv.y *= uSaturation; hsv.z *= uBrightness;,最后hsv2rgb转回来。注意uHueShift以 0~1 表示一周,而不是角度,换算时要除以 360。实测中,饱和度系数 1.2、亮度系数 1.1 的滤镜最有“通透感”,色相偏移超过 0.08 就会变成偏色效果,适合做日系滤镜。
3.4 瘦脸与大眼的几何形变
几何形变比色彩变换复杂,因为要重新计算每个像素的采样坐标。以大眼效果为例,思想是以眼中心作为控制点,对周围像素做局部缩放,让图像瞳孔区域向外扩张。通常在片段着色器中反算“源像素位置”:输出像素靠近控制点时,采样位置偏离控制点更远,视觉上就是放大了。
vec2 enlarge(vec2 uv, vec2 center, float radius, float strength) { float d = distance(uv, center); if (d < radius) { vec2 dir = normalize(uv - center); // 越靠近中心,偏移越大 float scale = 1.0 - strength * (1.0 - smoothstep(0.0, 1.0, d / radius)); return center + dir * d * scale; } return uv; }使用时,在 main 中先调用vec2 newUv = enlarge(vTexCoord, uEyeCenter, uEyeRadius, uEyeStrength);然后用newUv去采样纹理。这里的center需要是人脸关键点。通用做法是先离线用 ML Kit 或 Face++ 拿到关键点坐标,将其归一化后作为 uniform 传入。如果你只想做简化版,可以在 UI 上放两个可拖动控制点,拖到眼睛区域也能看到效果。瘦脸类似,只是把缩放因子反号:scale = 1.0 + strength * ...时会把中心向外推,视觉上收缩局部区域。几何形变的坑在于幅度过大会出现采样越界,必须用 clamp 或判断 d 减小后不越过 0。
4. 照片后处理与分享:OpenCV 美化链路与运行时权限
4.1 OpenCV 磨皮流程的接入方式
实时预览用 GPU,离线编辑则更推荐用 OpenCV。原因很简单:离线处理不要求速度控制在 16ms,CPU 上跑双边滤波、表面模糊效果比 GLSL 容易调。接入 OpenCV 后,读取 Bitmap 转成 Mat,再用bilateralFilter磨皮,速度通常 200ms 左右,对一次按钮点击完全能接受。
// 将 Bitmap 转为 Mat,YCrCb 空间检测肤色 Mat src = new Mat(); Utils.bitmapToMat(bitmap, src); Mat dst = new Mat(); Imgproc.bilateralFilter(src, dst, 12, 30.0, 30.0); Mat ycrcb = new Mat(); Imgproc.cvtColor(src, ycrcb, Imgproc.COLOR_RGB2YCrCb); Mat mask = new Mat(); // Cr 范围 135~180,Cb 范围 85~135 是常见肤色区间 Core.inRange(ycrcb, new Scalar(0, 135, 85), new Scalar(255, 180, 135), mask); Mat result = new Mat(); Core.bitwise_and(dst, dst, result, mask); Core.bitwise_not(mask, mask); Core.bitwise_and(src, src, src, mask); Core.add(result, src, result); Utils.matToBitmap(result, outputBitmap);逻辑上先用双边滤波拿到模糊后的全图,然后在 YCrCb 空间检测肤色区域,最后用蒙版把模糊图贴到原图上。这里要特别注意,bilateralFilter的第一个参数 d 是直径,12 是经验值,数值太大会破坏五官轮廓。肤色区间按亚洲人肤色调出来的,答辩时如果被问,直接说“这是基于 YCrCb 的统计区间,偏移后可以适应不同肤色”。
4.2 后处理中的瘦脸与大眼
离线瘦脸不能像实时那样靠一两个关键点用 shader 圆润处理,更严谨的做法是基于人脸关键点的局部网格变形。这里给出一种简化但有效的手写算法:局部平移映射,把控制点附近的像素按照目标点偏移方向推动,并限制影响半径。
private Bitmap localScaleWarp(Bitmap src, float cx, float cy, float radius, float strength) { int width = src.getWidth(); int height = src.getHeight(); Bitmap output = src.copy(Bitmap.Config.ARGB_8888, true); for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { float dx = x - cx; float dy = y - cy; float dist = (float) Math.sqrt(dx * dx + dy * dy); if (dist > radius || dist == 0f) continue; float scale = 1.0f - strength * (1.0f - (float) Math.sqrt(dist / radius)); int sx = Math.max(0, Math.min(width - 1, (int) (cx + dx * scale))); int sy = Math.max(0, Math.min(height - 1, (int) (cy + dy * scale))); output.setPixel(x, y, src.getPixel(sx, sy)); } } return output; }这段代码对图像中每个像素,计算它到控制点的距离。当距离小于半径时,反向映射源像素坐标,strength控制映射偏移量。大眼用正 strength,瘦脸用负 strength 也可以,但负值容易把脸拉出锯齿,适当时要做一次高斯模糊平滑。
4.3 图片保存、媒体库通知与分享
美化完的 Bitmap 需要写进系统相册并触发媒体库扫描。Android 10 之前需要 WRITE_EXTERNAL_STORAGE 权限,Android 10+ 推荐用 MediaStore 的IS_PENDING标记,这样处理过程中其它应用看不到半张图片,插入完成后立刻通知相册。
fun saveImage(context: Context, bitmap: Bitmap): Uri? { val values = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, "beauty_${System.currentTimeMillis()}.jpg") put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MyBeauty") put(MediaStore.Images.Media.IS_PENDING, 1) } } val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) } else { MediaStore.Images.Media.EXTERNAL_CONTENT_URI } val uri = context.contentResolver.insert(collection, values) ?: return null context.contentResolver.openOutputStream(uri)?.use { output -> bitmap.compress(Bitmap.CompressFormat.JPEG, 95, output) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { values.clear() values.put(MediaStore.Images.Media.IS_PENDING, 0) context.contentResolver.update(uri, values, null, null) } return uri }分享可以直接用Intent.ACTION_SEND,把 Uri 通过FileProvider.getUriForFile转换后带上FLAG_GRANT_READ_URI_PERMISSION,否则微信、微博等应用没有权限读取。这里有一个容易踩的坑:如果图片是保存到外部存储,应该优先用 MediaStore 返回的 Uri,而不是自己拼路径。自己拼的 file:// 在 Android 7.0 以后会直接抛 FileUriExposedException。
| 权限 | 用途 | 适用版本 |
|---|---|---|
| CAMERA | 预览和拍照 | Android 6.0+ 运行时 |
| WRITE_EXTERNAL_STORAGE | 保存到相册 | Android 8.0 ~ 9.0 |
| READ_MEDIA_IMAGES | 读取系统图片 | Android 13+ |
| READ_EXTERNAL_STORAGE | 读取系统图片 | Android 8.0 ~ 12 |
5. 性能优化、兼容性排错与关键参数验证
5.1 用 Android Profiler 定位掉帧与内存抖动
在 AS 里打开 Android Profiler,切到 GPU 和 Memory 面板,跑一遍相机预览。你需要关注两个点:一是 GPU 渲染曲线是否频繁超过 16ms 绿线,二是 Memory 面板有没有周期性上升后回落。掉帧如果只出现在滑动滤镜时,多半是 shader 中循环过多,需要把采样半径从 5x5 降到 3x3。内存抖动如果伴随 GC 日志,问题通常出在setPixel这种高频用小对象操作上,应该避免在实时链路里处理 Bitmap。
5.2 常见黑屏与崩溃:GL 线程问题
实时预览黑屏最常见的原因有三个:SurfaceTexture 没有在 GL 线程创建、Text 没有设置defaultBufferSize、GL 线程里调用了相机 session 的 API导致死锁。我的经验是,把所有 OpenGL 相关的对象创建全部放进 Renderer 的onSurfaceCreated,不要把它们开在 Activity 主线程,否则很多机型的 EGLContext 和 SurfaceTexture 上下文不一致,表现为第一帧正常之后黑屏。另一个坑是低端机不支持 OES 外部纹理,需要在 GLSL 里声明#extension GL_OES_EGL_image_external : require,并且 sampler 类型写成samplerExternalOES,否则会编译失败。
5.3 用 Choreographer 输出帧率验证表
画布是否流畅,不能靠眼睛感觉,要量化。用 Choreographer 统计每两次doFrame的时间差,超过 16ms 就算掉帧,超过 50ms 用户可感知卡顿。在 Renderer 外面包一个帧率监控器:
class FpsMonitor(private val onFps: (Float) -> Unit) { private var frameCount = 0 private var startNanos = 0L fun start() { Choreographer.getInstance().postFrameCallback(object : Choreographer.FrameCallback { override fun doFrame(frameTimeNanos: Long) { if (startNanos == 0L) startNanos = frameTimeNanos frameCount++ val elapsedMs = (frameTimeNanos - startNanos) / 1_000_000 if (elapsedMs >= 1000) { val fps = frameCount * 1000f / elapsedMs onFps(fps) frameCount = 0 startNanos = frameTimeNanos } Choreographer.getInstance().postFrameCallback(this) } }) } }| 测试场景 | 典型帧率 | 判断 |
|---|---|---|
| 1280x720 磨皮+滤镜 | 55 ~ 60 FPS | 正常 |
| 1920x1080 磨皮+滤镜+瘦脸 | 30 ~ 40 FPS | 需要降级 |
| 低端机 720p 三效果叠加 | 20 FPS 以下 | 必须减少 pass |
这个数据可以贴在毕设论文里作为性能验证。如果你发现某个机型帧率掉一半,优先检查是不是隐式开启了 4K 预览。用StreamConfigurationMap.getOutputSizes列出支持的尺寸,把 camera2 预览限制到 1280x720,大部分中端机都能稳定 60 帧。
本文还有配套的精品资源,点击获取