☰
安卓手机前置摄像头实现视线估计:从瞳孔定位到注视点映射的完整链路
2026/11/5 5:49:03 网站建设 项目流程

简介:这份安卓毕业设计源码聚焦眼动追踪交互,面向计算机、软件工程等专业需要完成毕业设计的学生,以及关注无障碍交互与移动传感应用开发的开发者。项目以安卓手机作为眼动传感器,实现校准、眼球控制鼠标移动与点击、滚动及屏幕键盘输入,并通过socket完成手机与Windows PC的通信,为行动不便群体提供低成本的眼球操控电脑方案,涉及安卓客户端移植、网络编程与交互精度调优等关键技术。资源以zip压缩包形式提供,整体约21.87MB,上游未给出具体文件数量与类型明细,可视为一份可直接参考的完整工程源码。目前已有651人浏览学习,具备一定参考热度。读者可从中获取眼动识别在移动端的落地思路、手机与PC端通信的代码组织方式,以及依据识别精度调整交互策略的实践经验,适合作为毕业设计选题参考或二次开发的基础工程。

1. 眼动追踪App:一个能跑在普通安卓手机上的视线估计项目

第一次看到这个项目标题时,我以为是那种需要外接红外摄像头、动辄上万块的专业眼动仪配套软件。下载解压之后才发现,它其实是一个纯安卓端的视线估计应用,用手机前置摄像头就能做瞳孔定位和注视点估计,配套的还有一套完整的毕业设计文档结构。这对正在找安卓毕业设计选题、或者想入门计算机视觉落地的开发者来说,价值很直接:它不是一个空壳Demo,而是把相机采集、人脸检测、眼部区域裁剪、瞳孔中心定位、屏幕坐标映射这条链路串起来了。系统开源,代码结构清晰,适合拿来跑通、拆解,再改成自己的课题方向。下面我按「这项目能干什么 → 怎么跑起来 → 核心代码怎么读 → 怎么改」的顺序,把它拆开讲。

2. 项目定位与技术栈:从相机帧到注视点的一条完整链路

2.1 这个App到底在做什么

EyeTracker 的核心目标很朴素:让手机知道用户在看屏幕的哪个位置。整个流程可以拆成四步。第一步,前置摄像头持续采集画面帧;第二步,用 Android 自带的人脸检测能力定位人脸和眼睛区域;第三步,在眼部小图里做图像处理,找出瞳孔中心;第四步,把瞳孔在眼部图像里的相对位置,映射到手机屏幕的二维坐标上,得到一个注视点。

这里要说清楚一个预期管理的问题。普通手机摄像头没有红外补光,瞳孔和虹膜的对比度远不如专业设备,所以这类项目的精度通常在屏幕的几个百分点到十几百分点之间波动,做「看屏幕左半边还是右半边」这种粗粒度交互是够的,做「精确点击某个按钮」就不现实。我一般会把它定位成「视线区域估计」而不是「精确眼动仪」,这样后续改功能和写论文都好交代。

技术栈上,这是一个标准的原生 Android 项目,Java 为主,构建工具是 Gradle,相机部分用 Camera2 或 CameraX 封装,人脸检测走 Android 的 FaceDetector 或 ML Kit 的人脸检测接口,图像处理部分是自己写的灰度化、二值化、连通域分析那一套。没有引入 OpenCV 的重型依赖,这对毕业设计来说是好事——依赖少、编译快、答辩时也容易讲清楚每一步在干什么。

2.2 目录结构长什么样

拿到源码后,先别急着点运行,花十分钟把目录过一遍,后面改代码会省很多事。典型的工程结构大致是这样:

EyeTracker/ ├── app/ │ ├── src/main/ │ │ ├── java/com/example/eyetracker/ │ │ │ ├── MainActivity.java // 主界面与相机预览 │ │ │ ├── camera/ │ │ │ │ ├── CameraController.java // 相机生命周期管理 │ │ │ │ └── FrameProcessor.java // 帧回调与预处理 │ │ │ ├── vision/ │ │ │ │ ├── FaceDetectorWrapper.java // 人脸/眼睛检测封装 │ │ │ │ ├── PupilLocator.java // 瞳孔中心定位 │ │ │ │ └── GazeEstimator.java // 注视点映射 │ │ │ └── ui/ │ │ │ └── GazeOverlayView.java // 注视点叠加显示 │ │ ├── res/layout/activity_main.xml │ │ └── AndroidManifest.xml │ └── build.gradle ├── build.gradle └── settings.gradle

这个分层方式很典型:camera 包管硬件,vision 包管算法,ui 包管显示。改算法只动 vision,改界面只动 ui,互不干扰。如果你的目标是换检测方案或者加标定流程,基本都落在 vision 包里。

2.3 环境准备与跑通步骤

跑起来之前,有几件事必须先确认。第一,Android Studio 版本不要太老,Gradle 插件和 compileSdk 要对得上,源码里 build.gradle 写的 compileSdk 是多少就装对应的 SDK。第二,必须用真机调试,模拟器没有真实前置摄像头,人脸检测直接失效。第三,相机权限和人脸检测依赖要在 Manifest 里声明齐全。

# 1. 克隆或解压源码后,用 Android Studio 打开根目录 # 2. 确认本地 SDK 版本与 build.gradle 中 compileSdk 一致 # 3. 连接真机,开启 USB 调试 adb devices # 确认设备已识别 # 4. 编译安装 ./gradlew installDebug

adb devices这一步别跳过,很多「装不上」的问题其实是设备没授权。installDebug走的是 debug 变体,签名用默认调试证书,不需要额外配置。装完后打开 App,首次会弹相机权限,允许之后应该能看到前置预览画面,并在画面上叠加一个跟随视线移动的小圆点。

如果预览黑屏,先看 Logcat 里相机相关的报错;如果预览正常但圆点不动,问题多半在 vision 层,下一章会具体讲怎么定位。

提示:真机调试时把屏幕亮度调高、环境光保持稳定,瞳孔检测的稳定性会明显好一些,这是这类无红外方案的通病。

3. 核心模块拆解:相机帧、瞳孔定位与注视点映射

3.1 相机帧采集与预处理

相机模块的关键是把「预览」和「分析」两条流分开。预览给人看,分析给算法用,两者分辨率要求不一样。常见做法是预览用较高分辨率保证观感,分析流降采样到 640×480 甚至更低,减少每帧的计算量。

// FrameProcessor.java 片段:帧回调中做降采样与灰度化 @Override public void onFrameAvailable(Image image) { // 1. 将 YUV 帧转为灰度图,只取 Y 通道,省掉色彩转换开销 byte[] gray = ImageUtils.yuvToGray(image, width, height); // 2. 降采样到固定分析尺寸,保证每帧处理耗时可控 byte[] small = ImageUtils.downsample(gray, width, height, 320, 240); // 3. 回调给检测层,注意这里在子线程执行,不能直接更新 UI detectorWrapper.detectAsync(small, 320, 240, result -> { mainHandler.post(() -> overlayView.updateGaze(result.gazePoint)); }); image.close(); // 必须关闭,否则相机会停止出帧 }

这段代码有三个点值得注意。yuvToGray只取 Y 通道,是因为人脸和瞳孔检测本质上是灰度特征,跳过 RGB 转换能省一大截 CPU。downsample把分析尺寸压到 320×240,是精度和耗时的折中,实测这个尺寸下瞳孔定位还能用,再低就糊了。最后image.close()是血泪经验,Camera2 的 Image 对象不关闭,缓冲区很快耗尽,表现就是预览卡住、帧回调不再触发,很多人第一次跑就栽在这。

3.2 人脸与眼睛区域检测

检测层负责从整帧里框出人脸,再框出眼睛。Android 原生 FaceDetector 能直接给出眼睛的粗略坐标,够用但精度一般;如果源码里用的是 ML Kit,接口会更友好,还能拿到眼睛开合状态。

// FaceDetectorWrapper.java 片段 public void detectAsync(byte[] gray, int w, int h, Callback cb) { Bitmap bmp = BitmapUtils.grayToBitmap(gray, w, h); Face[] faces = faceDetector.findFaces(bmp, faceArray); if (faces == null || faces.length == 0) { cb.onResult(GazeResult.noFace()); // 无人脸时返回空结果,UI 层据此隐藏注视点 return; } Face face = faces[0]; // 单用户场景取第一张脸 PointF leftEye = new PointF(); face.getMidPoint(faceMid); // 眼睛相对人脸中点的偏移,按人脸宽度比例估算 leftEye.x = faceMid.x - face.eyesDistance() * 0.5f; leftEye.y = faceMid.y - face.eyesDistance() * 0.2f; cb.onResult(new GazeResult(leftEye, face.eyesDistance())); }

eyesDistance()返回的是两眼间距,这个值在后续映射里很关键——它相当于一个尺度基准,人脸离屏幕远近不同,眼睛在画面里的大小就不同,用两眼间距做归一化能把距离因素消掉一部分。noFace()分支也别忽略,没有它的话 UI 层会拿着上一帧的坐标继续画,看起来就像「注视点卡住了」。

3.3 瞳孔中心定位

这是整个项目最核心也最容易翻车的部分。思路是在眼睛小图里做阈值分割,把暗色区域(瞳孔)分离出来,再取最大连通域的重心作为瞳孔中心。

// PupilLocator.java 片段:阈值 + 连通域找瞳孔 public PointF locate(byte[] eyeGray, int w, int h) { int threshold = computeOtsuThreshold(eyeGray); // 自适应阈值,比固定值稳 int[] labels = new int[w * h]; int maxArea = 0, maxLabel = 0; // 1. 二值化并做连通域标记 for (int i = 0; i < eyeGray.length; i++) { labels[i] = (eyeGray[i] & 0xFF) < threshold ? 1 : 0; } // 2. 遍历连通域,找面积最大的暗块 for (int y = 1; y < h - 1; y++) { for (int x = 1; x < w - 1; x++) { if (labels[y * w + x] == 1) { int area = floodFill(labels, x, y, w, h); if (area > maxArea) { maxArea = area; maxLabel = y * w + x; } } } } // 3. 返回最大暗块的重心 return centroidOf(labels, maxLabel, w, h); }

computeOtsuThreshold用大津法自适应算阈值,比写死一个 60 或 80 稳得多,因为不同光照下瞳孔的灰度值差别很大。floodFill是四邻域或八邻域填充,用来统计每个暗块的面积。取最大暗块是假设瞳孔是眼部区域里最大的暗色连通块,这个假设在正常光照下成立,但遇到浓眉、眼镜框、睫毛阴影就会失效——这也是后面避坑章节要重点说的。

3.4 注视点映射到屏幕坐标

拿到瞳孔在眼部图像里的相对位置后,要把它换算成屏幕坐标。最简做法是线性映射:瞳孔偏左,注视点偏左;偏右则偏右。

// GazeEstimator.java 片段:归一化 + 线性映射 public PointF estimate(PointF pupil, Rect eyeRect, int screenW, int screenH) { // 1. 瞳孔位置归一化到 [0,1],消除眼部区域尺寸差异 float nx = (pupil.x - eyeRect.left) / (float) eyeRect.width(); float ny = (pupil.y - eyeRect.top) / (float) eyeRect.height(); // 2. 线性映射到屏幕,系数 k 控制灵敏度 float k = 2.0f; float sx = screenW * (0.5f + (nx - 0.5f) * k); float sy = screenH * (0.5f + (ny - 0.5f) * k); // 3. 边界裁剪,防止注视点跑出屏幕 sx = Math.max(0, Math.min(screenW, sx)); sy = Math.max(0, Math.min(screenH, sy)); return new PointF(sx, sy); }

k是灵敏度系数,取 2.0 意味着瞳孔移动眼部区域一半的距离,注视点就扫过整个屏幕,实际调的时候可以按用户反馈微调。归一化那一步很重要,不同帧里眼睛框的大小会变,不归一化的话注视点会跟着人脸远近乱跳。边界裁剪是兜底,避免异常值把圆点画到屏幕外。

4. 避坑与常见问题:跑不通时先查这几处

4.1 预览黑屏或相机启动失败

现象是打开 App 后预览区域一片黑,Logcat 里有CameraAccessException或权限相关报错。原因通常是 Manifest 里漏了android.permission.CAMERA,或者 Android 6.0 以上没有做运行时权限申请。解决方式是检查 Manifest 声明,并在 MainActivity 里用requestPermissions动态申请,回调里再初始化相机。另外部分机型前置摄像头 ID 不是 1,硬编码cameraId = "1"会失败,应该遍历CameraManager.getCameraIdList()找LENS_FACING_FRONT的那个。

4.2 帧回调只触发一次就停

现象是预览正常,但算法只处理了第一帧,之后圆点不动。原因基本是Image.close()没调用,Camera2 的 ImageReader 缓冲区被占满,后续帧进不来。解决方式是在onFrameAvailable的每个分支(包括异常分支)都确保image.close()被执行,用 try-finally 包住最稳。

4.3 瞳孔定位在戴眼镜时乱跳

现象是裸眼时圆点还算跟手,一戴眼镜就满屏乱飞。原因是镜片反光在眼部区域形成亮斑,阈值分割后暗块形状被破坏,最大连通域可能落到镜框阴影上。解决方式有两个方向:一是把检测区域再缩小,只取眼睛中心附近的小窗口,减少镜框干扰;二是在二值化后加一步形态学开运算,去掉细小的噪点连通域,再取最大块。源码里如果没做这步,可以自己补上。

4.4 注视点整体偏移一个固定方向

现象是圆点始终偏左上或偏右下,但跟随趋势是对的。原因是屏幕坐标映射的基准点没对齐,比如前置摄像头在屏幕左上角,而映射时假设它在正中。解决方式是加一个标定步骤:让用户依次看屏幕四个角,记录对应的瞳孔归一化坐标,拟合一个仿射变换矩阵,替代写死的线性映射。这一步加上之后精度会有肉眼可见的提升,也是二次开发最值得做的改造点。

4.5 低端机卡顿掉帧

现象是中低端机型上预览明显卡顿,算法处理耗时超过帧间隔。原因是分析分辨率太高或者每帧都做全图连通域扫描。解决方式是把分析尺寸降到 240×180,连通域扫描只在眼睛小图(通常 80×60 以内)上做,不要在全帧上跑。另外检测可以隔帧执行,比如每两帧处理一次,UI 层做插值平滑,观感上不会差太多。

5. 二次开发改造:加标定、换检测、做交互

5.1 加一个四点标定流程

前面说过,固定线性映射的精度上限不高,加标定是性价比最高的改造。具体做法是在进入主界面前弹一个标定页,屏幕上依次在四个角显示目标点,用户注视每个点时采集约 30 帧瞳孔归一化坐标取平均,得到四组(nx, ny)与屏幕坐标(sx, sy)的对应关系,然后解一个仿射变换。

// CalibrationSolver.java:由四组对应点求仿射矩阵 public float[] solveAffine(List<PointF> pupilPts, List<PointF> screenPts) { // 仿射变换:sx = a*nx + b*ny + c, sy = d*nx + e*ny + f // 四组点恰好六个未知数,构造线性方程组求解 double[][] A = new double[8][6]; double[] B = new double[8]; for (int i = 0; i < 4; i++) { float nx = pupilPts.get(i).x, ny = pupilPts.get(i).y; A[i * 2] = new double[]{nx, ny, 1, 0, 0, 0}; A[i * 2 + 1] = new double[]{0, 0, 0, nx, ny, 1}; B[i * 2] = screenPts.get(i).x; B[i * 2 + 1] = screenPts.get(i).y; } return LinearSolver.leastSquares(A, B); // 返回 [a,b,c,d,e,f] }

leastSquares用最小二乘解,四组点刚好定解,点多的话可以采集更多点做超定求解更稳。标定结果存到 SharedPreferences,下次启动直接加载,不用每次重标。这一步做完,注视点偏移的问题基本能压下去大半。

5.2 换掉原生检测,接更稳的人脸方案

原生 FaceDetector 在侧脸、遮挡时容易丢脸。如果想让项目更稳,可以把FaceDetectorWrapper换成 ML Kit 的人脸检测,接口返回的眼睛坐标更准,还带置信度。改造时保持detectAsync的签名不变,只换内部实现,上层FrameProcessor一行不用动——这就是前面强调分层的好处。换完之后记得在 build.gradle 里加对应依赖,并处理首次加载模型可能带来的延迟。

5.3 把注视点变成可用的交互

光画一个圆点只是演示,真正能写进毕业设计的是交互。一个简单方向是「注视停留触发」:注视点在某块区域停留超过 800ms 就触发点击。实现上在GazeOverlayView里维护一个计时器,每帧判断注视点是否落在热点区域内,落进去就累加时间,移出就清零。

// GazeOverlayView.java:注视停留触发 private void onGazeUpdate(PointF gaze) { if (hotRect.contains(gaze.x, gaze.y)) { if (dwellStart == 0) dwellStart = SystemClock.uptimeMillis(); if (SystemClock.uptimeMillis() - dwellStart > 800) { triggerClick(hotRect); // 触发对应按钮逻辑 dwellStart = 0; // 重置,避免连续触发 } } else { dwellStart = 0; } invalidate(); // 重绘注视点 }

800这个阈值可以调,太短容易误触,太长用户会觉得迟钝,实测 700 到 1000 之间比较舒服。triggerClick里做防抖,触发后短暂忽略后续帧,不然一次停留会连点好几次。

5.4 验证改造效果的方法

改完之后怎么证明有效?我一般做两组对比:固定线性映射 vs 标定后映射,各让同一个人看屏幕上九个固定点,记录注视点与目标点的像素误差,取平均。误差从一两百像素降到几十像素,就是能写进文档的量化结果。另外记录不同光照(室内灯、窗边、暗光)下的丢脸率和定位成功率,这些数据比一句「效果良好」有说服力得多。

从那以后我每次拿到这类视觉项目,都强制先把「采集—检测—映射」三段各自单独跑通、单独打日志验证,再串起来调,不然出了问题根本分不清是哪一段的锅。希望这个拆解能帮你少走点弯路,把这份源码真正跑成自己的东西。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询