☰
Unity C#触摸桌物体识别算法:从架构到代码的完整实现
2026/10/20 1:28:16 网站建设 项目流程

简介:这是一份面向Unity开发者的触摸屏物体识别桌算法实现资源,适合正在开发互动桌面、AR/VR或触控展示项目的初中级工程师参考。内容围绕Unity C#与Lean Touch插件展开,讲解如何通过触摸监听、屏幕坐标转世界坐标、Physics.Raycast射线检测来识别用户触摸到的场景物体,并涉及性能优化、UI交互与调试测试等环节。压缩包共3640个文件,约24.54MB,包含220个cs脚本、106个dll库、31个unity场景、12个prefab预制体、14个mat材质及大量meta、info、bin等工程与缓存文件,构成一个可直接导入Unity的完整项目结构。目前已有2476人学习下载。读者可从中获取触摸识别核心脚本、LeanSelectable继承示例、射线层级过滤思路以及可运行的工程目录,便于快速对照实现并迭代自己的触控交互方案。

1. 触摸桌项目里最容易被低估的一环

去年帮一个做展厅互动的团队看项目,他们那台 55 寸触摸桌在演示时总是“认错”物体——明明放的是红色方块,屏幕却弹出了蓝色三角的说明。现场工程师第一反应是硬件问题,换了触摸框、换了标签、换了摄像头,折腾两天没解决。最后定位到代码里一个坐标映射的符号写反了,物体识别本身没问题,是识别结果往屏幕坐标转换时翻了车。

这就是 Unity C# 触摸屏物体识别桌算法的典型处境:它不炫技,但一旦出错就是现场事故。这套算法要解决的核心问题是——当用户在触摸桌上放置一个物理标记物(通常是带图案的圆片或方块),系统需要通过摄像头或触摸框采集到的信号,判断出“这是什么物体”“在桌子哪个位置”“朝向如何”,然后驱动 Unity 场景做出对应反馈。适合做展厅互动、教育沙盘、儿童益智桌、零售导购台的开发者,尤其是那些已经会用 Unity 但没接触过物体识别链路的人。下面我把这套东西从架构到代码拆一遍,重点讲清楚怎么跑起来、怎么改。

2. 识别链路拆解:从触摸信号到 Unity 场景对象

2.1 整体架构与数据流

这套算法在工程上通常分成四层,每层职责清晰,方便单独替换或调试。

第一层是信号采集层。触摸桌的硬件方案常见两种:红外触摸框和摄像头方案。红外框输出的是触摸点坐标(可能多点),摄像头方案输出的是原始图像帧。这一层在 Unity 里一般用 C# 的Thread或Task跑在后台,避免阻塞主线程渲染。

第二层是标记识别层。如果是摄像头方案,这里会做图像预处理(灰度化、二值化、去噪),然后跑标记检测算法。常见做法是用类似 ARToolKit 或自定义的连通域分析,找出画面中的标记区域并计算其 ID 和位姿。如果是红外框方案,这一层会简化成“触摸点聚类”,把连续多个触摸点归并成一个物体区域。

第三层是坐标映射层。这是最容易翻车的地方。摄像头坐标系、触摸框坐标系、Unity 世界坐标系三者原点和轴向都不同,需要做仿射变换或透视变换。我一般会在这里加一个可视化调试面板,实时画出映射后的点位,方便肉眼校验。

第四层是业务逻辑层。识别结果通过事件或队列传给 Unity 主线程,由ObjectManager之类的脚本根据物体 ID 激活对应预制体、播放动画、触发 UI。

数据流大致是:硬件信号 → 后台线程识别 → 识别结果入队 → 主线程出队 → 坐标映射 → 场景对象更新。这个链路里任何一个环节的延迟都会导致“放上去没反应”或“反应慢半拍”。

2.2 核心识别脚本的代码结构

下面这段代码是标记识别层的核心骨架,我把它简化成一个可读版本,保留了关键逻辑。实际项目里会根据硬件 SDK 做适配,但结构大同小异。

using System.Collections.Generic; using System.Threading; using UnityEngine; public class MarkerDetector : MonoBehaviour { // 识别结果队列,后台线程写入,主线程读取 private Queue<MarkerResult> resultQueue = new Queue<MarkerResult>(); private Thread detectThread; private volatile bool isRunning = false; // 摄像头或触摸框的原始数据接口 private IHardwareInput hardwareInput; void Start() { hardwareInput = GetComponent<IHardwareInput>(); isRunning = true; detectThread = new Thread(DetectLoop); detectThread.IsBackground = true; detectThread.Start(); } void DetectLoop() { while (isRunning) { // 1. 获取原始帧或触摸点集 var rawData = hardwareInput.GetRawFrame(); if (rawData == null) { Thread.Sleep(5); continue; } // 2. 预处理:灰度化 + 高斯模糊 + 二值化 var binary = Preprocess(rawData); // 3. 连通域分析,找出候选标记区域 var contours = FindContours(binary); // 4. 对每个轮廓做形状匹配和 ID 解码 var results = new List<MarkerResult>(); foreach (var c in contours) { if (c.Area < 200) continue; // 过滤噪点 var id = DecodeMarkerId(c); if (id < 0) continue; var center = c.Centroid; var angle = ComputeOrientation(c); results.Add(new MarkerResult(id, center, angle)); } // 5. 写入队列,加锁保证线程安全 lock (resultQueue) { resultQueue.Clear(); foreach (var r in results) resultQueue.Enqueue(r); } Thread.Sleep(10); // 控制识别频率约 100Hz } } void Update() { // 主线程消费识别结果 lock (resultQueue) { while (resultQueue.Count > 0) { var result = resultQueue.Dequeue(); ProcessMarker(result); } } } void OnDestroy() { isRunning = false; detectThread?.Join(200); } }

这段代码有几个关键点值得展开。DetectLoop跑在后台线程,所有耗时操作都在这里完成,主线程的Update只负责消费结果,这样不会掉帧。lock保护队列是必须的,否则会出现“集合已修改”的异常。Thread.Sleep(10)控制识别频率,实际项目里可以根据硬件帧率调整,一般 30 到 60 次每秒足够。

Preprocess和FindContours的具体实现依赖你用的图像库。常见做法是用 OpenCVSharp 或 EmguCV,在 Unity 里通过 NuGet 或手动导入 DLL。如果不想引入外部库,也可以自己写简单的阈值分割和连通域标记,但性能和鲁棒性会差一些。

2.3 坐标映射与触摸点校准

坐标映射是整套算法里最需要“手感”的部分。假设摄像头分辨率是 1920×1080,触摸桌物理尺寸是 1.2m×0.8m,Unity 场景里桌面是一个 12×8 单位的平面。你需要把摄像头画面里的像素坐标 (px, py) 映射到 Unity 世界坐标 (wx, wz)。

常见做法是用四点校准:在桌面四角放置标记,记录它们在摄像头画面里的像素坐标和对应的 Unity 世界坐标,然后解一个透视变换矩阵。

using UnityEngine; public class CoordinateMapper : MonoBehaviour { // 校准点:摄像头像素坐标 public Vector2[] camPoints = new Vector2[4]; // 校准点:Unity 世界坐标(XZ 平面) public Vector3[] worldPoints = new Vector3[4]; private Matrix4x4 homography; public void Calibrate() { // 构造透视变换矩阵,把 camPoints 映射到 worldPoints 的 XZ 平面 // 这里用简化的仿射变换示例,实际项目建议用 OpenCV 的 findHomography var src = new Vector2[4]; var dst = new Vector2[4]; for (int i = 0; i < 4; i++) { src[i] = camPoints[i]; dst[i] = new Vector2(worldPoints[i].x, worldPoints[i].z); } homography = ComputeHomography(src, dst); } public Vector3 MapToWorld(Vector2 camPixel) { // 齐次坐标变换 Vector3 homo = new Vector3(camPixel.x, camPixel.y, 1f); Vector3 mapped = homography.MultiplyPoint(homo); // 归一化 mapped.x /= mapped.z; mapped.y /= mapped.z; return new Vector3(mapped.x, 0f, mapped.y); } Matrix4x4 ComputeHomography(Vector2[] src, Vector2[] dst) { // 简化实现:用最小二乘解 8 个参数 // 实际项目建议直接调用 OpenCV 的 findHomography // 这里省略具体矩阵求解代码,核心是构造 8x8 线性方程组 return Matrix4x4.identity; // 占位 } }

校准点的选取很关键。四点要尽量分散在桌面四角,不要集中在中间。校准完成后,我习惯在场景里放几个调试小球,实时显示映射后的位置,用肉眼确认是否和物理标记对齐。如果发现边缘区域偏移大,通常是镜头畸变导致的,需要先做畸变校正再映射。

提示:校准数据建议持久化到PlayerPrefs或 JSON 文件,避免每次启动都要重新校准。现场部署时这一点能省很多事。

3. 跑起来:环境搭建与最小可运行示例

3.1 Unity 工程配置与依赖导入

先确认 Unity 版本。这套算法在 2021 LTS 和 2022 LTS 上都跑过,推荐用 2021.3 以上的版本,因为Thread和Task的 API 比较稳定。新建工程时选 3D 模板即可,不需要 URP 或 HDRP,除非你的展示效果有特殊需求。

依赖方面,如果走摄像头方案,需要导入 OpenCVSharp。常见做法是从 NuGet 下载OpenCvSharp4和OpenCvSharp4.runtime.win,把 DLL 放到Assets/Plugins目录下。注意平台要选对,Windows 桌面端用 win-x64,Android 端需要另外编译。如果走红外触摸框方案,一般硬件厂商会提供 C# SDK,直接导入即可。

工程目录建议这样组织:

Assets/ Scripts/ Core/ MarkerDetector.cs CoordinateMapper.cs ObjectManager.cs Hardware/ IHardwareInput.cs TouchFrameInput.cs CameraInput.cs UI/ DebugPanel.cs Prefabs/ Markers/ Scenes/ Main.unity

IHardwareInput是一个接口,定义GetRawFrame()方法,触摸框和摄像头各实现一版。这样切换硬件方案时只需要换实现类,上层识别逻辑不用动。

3.2 最小可运行场景的搭建步骤

第一步,在场景里创建一个空物体,命名为RecognitionRoot,挂上MarkerDetector和CoordinateMapper脚本。

第二步,创建一个ObjectManager脚本,负责根据识别结果激活预制体。核心逻辑是维护一个Dictionary<int, GameObject>,key 是标记 ID,value 是对应的预制体。

using System.Collections.Generic; using UnityEngine; public class ObjectManager : MonoBehaviour { // 在 Inspector 里配置:标记 ID -> 预制体 [System.Serializable] public struct MarkerPrefab { public int id; public GameObject prefab; } public List<MarkerPrefab> markerPrefabs; private Dictionary<int, GameObject> activeObjects = new Dictionary<int, GameObject>(); private Dictionary<int, GameObject> prefabMap = new Dictionary<int, GameObject>(); void Awake() { foreach (var mp in markerPrefabs) prefabMap[mp.id] = mp.prefab; } public void OnMarkerDetected(int id, Vector3 worldPos, float angle) { if (!prefabMap.ContainsKey(id)) return; if (!activeObjects.ContainsKey(id)) { // 首次出现,实例化预制体 var obj = Instantiate(prefabMap[id], worldPos, Quaternion.Euler(0, angle, 0)); activeObjects[id] = obj; } else { // 已存在,更新位置和朝向 var obj = activeObjects[id]; obj.transform.position = Vector3.Lerp(obj.transform.position, worldPos, 0.5f); obj.transform.rotation = Quaternion.Euler(0, angle, 0); } } public void OnMarkerLost(int id) { if (activeObjects.ContainsKey(id)) { Destroy(activeObjects[id]); activeObjects.Remove(id); } } }

第三步,在MarkerDetector的ProcessMarker方法里调用ObjectManager.OnMarkerDetected。注意要处理“标记消失”的情况——如果某一帧没有检测到某个 ID,需要调用OnMarkerLost销毁对应对象。常见做法是维护一个“上一帧存在的 ID 集合”,每帧对比。

第四步,运行场景,在摄像头前放置一个标记物,观察场景里是否出现对应预制体。如果没反应,先看DebugPanel里有没有识别到轮廓,再看坐标映射后的位置是否在相机视野内。

3.3 调试面板与日志排查

调试面板是这套算法的“后悔药”。我一般会在屏幕角落放一个OnGUI面板,实时显示以下信息:当前识别到的标记数量、每个标记的 ID 和像素坐标、映射后的世界坐标、识别帧率。

void OnGUI() { GUILayout.BeginArea(new Rect(10, 10, 300, 400)); GUILayout.Label($"Detected: {lastResults.Count}"); foreach (var r in lastResults) { GUILayout.Label($"ID:{r.id} Cam:({r.center.x:F0},{r.center.y:F0}) World:({r.worldPos.x:F2},{r.worldPos.z:F2})"); } GUILayout.Label($"FPS: {1f / Time.smoothDeltaTime:F1}"); GUILayout.EndArea(); }

如果识别不到标记,排查顺序是:先确认硬件输入是否有数据(在GetRawFrame里打日志),再确认预处理后的二值图是否正常(可以把二值图存成 PNG 看),然后确认轮廓面积阈值是否过滤掉了有效标记,最后确认 ID 解码逻辑是否匹配你的标记图案。

注意:后台线程里不要调用 Unity 的 API,比如Debug.Log在某些版本里是线程安全的,但Instantiate、transform这些绝对不能在子线程碰。所有场景操作都放到主线程的Update里做。

4. 避坑与常见问题排查

4.1 识别抖动严重,物体位置一直在跳

现象:预制体在场景里不停抖动,位置和角度都不稳定。

原因:通常是标记检测的质心计算受噪声影响,或者坐标映射矩阵精度不够。摄像头方案的图像噪声、光照变化都会导致轮廓质心偏移。

解决:在识别结果上加一层滤波。常见做法是滑动平均或卡尔曼滤波。简单点可以用Vector3.Lerp做平滑,但要注意延迟。我一般会维护每个 ID 最近 5 帧的位置,取中位数或平均值。角度的话,如果标记是对称的,角度跳变可能是解码时方向判断错误,需要检查ComputeOrientation里的主轴计算。

4.2 多个标记同时放置时互相干扰

现象:放一个标记正常,放两个就有一个识别不到,或者 ID 串了。

原因:连通域分析时,两个标记的轮廓可能粘连在一起,被当成一个区域。或者 ID 解码时,图案相似导致误判。

解决:在FindContours之后加一步轮廓分割,如果某个轮廓的面积明显大于单个标记的预期面积,尝试用分水岭或距离变换把它拆开。ID 设计上,尽量让不同标记的图案差异大一些,比如用不同数量的圆点或不同方向的条纹。解码时加一个置信度阈值,低于阈值的结果直接丢弃。

4.3 触摸延迟明显,放上去要等半秒才反应

现象:物理标记放上去后,屏幕上的反馈明显滞后。

原因:可能是识别线程的Sleep时间太长,或者主线程消费队列的频率不够。另外,如果ProcessMarker里做了耗时操作(比如加载资源),也会卡住主线程。

解决:把Thread.Sleep(10)改成Thread.Sleep(5)或更小,但要注意 CPU 占用。主线程的Update里只做轻量操作,资源加载提前在Awake里完成。如果用的是摄像头方案,检查摄像头本身的曝光时间,曝光太长会导致运动模糊,识别延迟增加。

4.4 校准后边缘区域偏移大

现象:桌面中心区域映射准确,但靠近边缘的地方偏差明显。

原因:镜头畸变,尤其是广角镜头。或者校准点选取太集中,没有覆盖边缘。

解决:先做镜头畸变校正,OpenCV 有现成的calibrateCamera和undistort。如果不想做畸变校正,至少把校准点放在桌面四角,并且增加校准点数量,用最小二乘拟合。另外,触摸框方案一般没有畸变问题,如果边缘偏移大,检查触摸框的坐标输出范围是否和配置一致。

4.5 打包后识别失效,编辑器里正常

现象:在 Unity 编辑器里跑得好好的,打包成 exe 或 apk 后识别不到任何标记。

原因:常见的是 DLL 平台不匹配,或者摄像头权限没开。Android 端还需要在AndroidManifest.xml里声明相机权限。

解决:检查Assets/Plugins下的 DLL 是否勾选了正确的平台。Windows 端确认OpenCvSharp4.runtime.win的 DLL 在打包后存在于Plugins目录。Android 端确认权限声明,并且摄像头调用要用 Android 的 API 而不是 Windows 的。另外,打包后日志输出会变少,建议在关键节点写文件日志,方便排查。

5. 二次开发:换标记、加交互、提性能

5.1 自定义标记图案与 ID 解码

这套算法的标记图案不是固定的,你可以根据项目需求设计。常见做法是用 4×4 或 5×5 的黑白格子,每个格子代表一位二进制,这样能编码出 2^16 或 2^25 个 ID。解码时先做透视校正,把标记区域拉正,然后逐格判断黑白。

int DecodeMarkerId(Contour contour) { // 1. 获取轮廓的最小外接矩形 var rect = Cv2.MinAreaRect(contour.Points); // 2. 透视变换到标准大小,比如 100x100 var warped = WarpPerspective(contour, rect, 100, 100); // 3. 二值化 var binary = Threshold(warped); // 4. 按网格采样,比如 4x4 int id = 0; int cellSize = 100 / 4; for (int row = 0; row < 4; row++) { for (int col = 0; col < 4; col++) { var cell = binary.Region(row * cellSize, col * cellSize, cellSize, cellSize); double mean = cell.Mean().Val0; if (mean > 128) id |= (1 << (row * 4 + col)); } } return id; }

这段代码的关键是透视校正。MinAreaRect给出标记的旋转矩形,WarpPerspective把它拉成正方形,这样后续的网格采样才准确。网格数量决定了 ID 容量,4×4 是 16 位,5×5 是 25 位。实际项目里还要加校验位,防止误码。

5.2 从识别到交互:事件驱动改造

原始代码里ObjectManager是轮询式的,每帧检查识别结果。如果要做复杂的交互逻辑,建议改成事件驱动。定义一个MarkerEvent结构,包含 ID、位置、角度、时间戳,然后通过 C# 的event或Action分发。

public class MarkerEventSystem : MonoBehaviour { public static event System.Action<int, Vector3, float> OnMarkerPlaced; public static event System.Action<int> OnMarkerRemoved; public static event System.Action<int, Vector3, float> OnMarkerMoved; private HashSet<int> lastFrameIds = new HashSet<int>(); public void Dispatch(List<MarkerResult> results) { var currentIds = new HashSet<int>(); foreach (var r in results) { currentIds.Add(r.id); if (!lastFrameIds.Contains(r.id)) OnMarkerPlaced?.Invoke(r.id, r.worldPos, r.angle); else OnMarkerMoved?.Invoke(r.id, r.worldPos, r.angle); } foreach (var id in lastFrameIds) { if (!currentIds.Contains(id)) OnMarkerRemoved?.Invoke(id); } lastFrameIds = currentIds; } }

这样业务逻辑可以独立成监听器,比如AudioFeedback监听OnMarkerPlaced播放音效,ScoreManager监听OnMarkerRemoved计分。模块之间解耦,改起来方便。

5.3 性能优化:从 30fps 到 60fps 的调整

如果识别帧率上不去,先看瓶颈在哪。用 Unity Profiler 看主线程耗时,如果Update里消费队列的时间很短,那瓶颈在后台线程的识别算法。常见优化手段:降低图像分辨率(比如从 1920×1080 降到 640×480),减少预处理步骤,用查表法代替实时计算。

我一般会做一个自适应策略:如果连续 10 帧识别耗时超过 20ms,自动降低分辨率或跳帧。另外,FindContours是耗时大户,可以先用形态学操作把候选区域缩小,再跑轮廓分析。

提示:如果项目部署在低功耗设备上(比如平板或一体机),建议把识别频率控制在 30Hz 左右,留出 CPU 给渲染。识别频率太高对用户体验提升有限,但耗电和发热会明显增加。

5.4 一个我踩过的坑

早期做的一个项目里,我在DetectLoop里直接调用了Instantiate来创建调试用的可视化小球,结果打包后偶发崩溃。查了很久才意识到子线程不能碰 Unity 的 API。从那以后我每次写多线程识别逻辑,都强制走一遍“子线程只算数据,主线程只做场景操作”的检查清单。另外,volatile关键字在isRunning上不能省,否则线程可能读不到退出信号,导致编辑器卡死。

希望帮到你。

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

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

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

立即咨询