用 C# + YoloDotNet + OpenCvSharp 实现手机轨迹追踪
c# + YOLODotNet+OpenCVSharp 实现物品轨迹追踪 - 上位机康工
开发者用 C# / .NET 8 / WinForms 结合 YoloDotNet 4.2 与 OpenCvSharp,调用 YOLOv8n(COCO 中 cell phone 类别索引 67)实现摄像头实时手机检测与最近 6 秒轨迹绘制。
前言
实际项目需求是做个包裹错分检测系统,通过视觉方式检测包裹流向,根据追踪轨迹的方式来实现包裹定位。考虑到模型训练需要大量包裹来做样品标记,所以用自己办公电脑的前置摄像头做个手机追踪的示例考虑最终可行性。
技术栈:C# / .NET 8 / WinForms / YOLOv8 / YoloDotNet / OpenCvSharp / ONNX Runtime
项目目标:调用电脑摄像头,实时检测画面中的手机,标记手机中心位置,并绘制最近 6 秒的移动轨迹。
无图言屌
博客有动图大小限制,看不到的自行脑补下,就是跟着手机移动,实时绘制手机运行轨迹

一、项目效果与需求
这是一个运行在 Windows 桌面端的实时视觉小工具,主界面由 WinForms 实现。点击「开始检测」后,程序会:
- 打开本机摄像头,实时显示画面;
- 使用 YOLOv8n 检测画面中的手机;
- 用橙色框标出手机,四角高亮,并显示置信度;
- 用绿色十字标记手机中心点,同时显示坐标;
- 绘制最近 6 秒的移动轨迹,旧轨迹偏蓝,新轨迹偏黄;
- 在状态栏显示实际显示 FPS、推理耗时和轨迹点数量;
- 支持切换摄像头、调整置信度、显示/隐藏轨迹、清除轨迹。
最终在本机(i9-13900HX,CPU 推理)实测:
| 指标 | 结果 |
|---|---|
| 采集分辨率 | 1280×720 |
| 摄像头出帧 | 30.3 FPS(Media Foundation) |
| 应用显示 | 25~31 FPS |
| 单帧推理 | 19~26 ms |
| 轨迹绘制 | 最近 6 秒 |
整篇文章会从技术选型、核心实现,一直讲到性能排查过程中踩过的坑。
二、为什么选择这套技术栈
| 模块 | 技术 | 选择原因 |
|---|---|---|
| 桌面界面 | .NET 8 + WinForms | 开发快,部署简单,调用摄像头和图像控件方便 |
| 摄像头采集 | OpenCvSharp4 | 封装 OpenCV,取帧、绘图、缩放都很直接 |
| 目标检测 | YoloDotNet 4.2 | 纯 C# 调用 ONNX 模型,自带 YOLO 前后处理 |
| 推理后端 | ONNX Runtime CPU Provider | 无 GPU 也能跑,换 CUDA 只改执行提供程序 |
| 检测模型 | YOLOv8n(COCO) | 体积小、CPU 推理快,COCO 自带 cell phone 类别 |
YOLOv8n 的 COCO 模型共有 80 个类别,其中手机对应:
索引 67 → cell phone
使用官方 Ultralytics 工具导出 ONNX 模型:
yolo export model=yolov8n.pt format=onnx opset=17 imgsz=640
YoloDotNet 对 YOLOv5u~YOLOv12 建议使用 opset 17,模型输入为 640×640。
三、整体处理流程
摄像头(OpenCV VideoCapture,优先 MSMF)
↓ BGR Mat
BGR → BGRA → 固定内存 byte[] → SKImage(零拷贝)
↓
YoloDotNet / ONNX Runtime(YOLOv8n)
↓ List<ObjectDetection>
过滤 cell phone 标签 + 最近邻关联
↓
One Euro 平滑(中心点 X/Y + 检测框宽高)
↓
OpenCV 绘制:检测框、中心十字、6 秒轨迹
↓
按显示尺寸预缩放 → Bitmap
↓
VideoSurface 1:1 绘制(UI 线程)
整个链路都在 C# 中完成,不需要 Python 服务,也没有跨进程通信。
四、核心实现
4.1 打开摄像头:优先 Media Foundation
很多示例代码会直接写:
var capture = new VideoCapture(0, VideoCaptureAPIs.DSHOW);
这在部分摄像头上会导致帧率异常。我在本机实测同一颗摄像头、同样 1280×720:
| 后端 | 实际出帧率 |
|---|---|
| DirectShow | 16.9 FPS |
| Media Foundation | 30.3 FPS |
原因和摄像头驱动、分辨率、MJPG/YUY2 协商有关。最稳妥的方式是按顺序尝试多个后端:
private static VideoCapture OpenCamera(int index)
{
// 优先 Media Foundation:同一台摄像头在 DirectShow 下可能只有 15~20 FPS
foreach (var api in new[]
{
VideoCaptureAPIs.MSMF,
VideoCaptureAPIs.DSHOW,
VideoCaptureAPIs.ANY
})
{
VideoCapture? camera = null;
try
{
camera = new VideoCapture(index, api);
if (camera.IsOpened())
{
camera.Set(VideoCaptureProperties.FrameWidth, 1280);
camera.Set(VideoCaptureProperties.FrameHeight, 720);
camera.Set(VideoCaptureProperties.Fps, 30);
camera.Set(VideoCaptureProperties.BufferSize, 1);
return camera;
}
}
catch
{
// 当前后端不可用,尝试下一个
}
camera?.Dispose();
}
throw new InvalidOperationException($"无法打开摄像头 {index}");
}
BufferSize = 1 可以让程序尽量拿到最新帧,减少内部缓冲带来的延迟。
4.2 OpenCV Mat 到 Skia SKImage:避免多余拷贝
OpenCvSharp 取到的是 BGR 三通道 Mat,而 YoloDotNet 使用 SkiaSharp 的 SKImage。如果每次都先转成 Bitmap 再交给推理,会带来额外的内存分配和像素拷贝。
这里的做法是:
- 用 OpenCV 把 BGR 转成 BGRA;
- 固定(pin)一个复用的
byte[]; - 用
SKImage.FromPixels直接引用这块内存; - 推理结束后立刻释放
GCHandle。
Cv2.CvtColor(frame, bgra, ColorConversionCodes.BGR2BGRA);
int length = checked((int)bgra.Step() * bgra.Rows);
if (pixelBuffer.Length < length)
pixelBuffer = new byte[length];
Marshal.Copy(bgra.Data, pixelBuffer, 0, length);
var handle = GCHandle.Alloc(pixelBuffer, GCHandleType.Pinned);
try
{
var info = new SKImageInfo(
bgra.Width, bgra.Height,
SKColorType.Bgra8888,
SKAlphaType.Opaque);
using var image = SKImage.FromPixels(
info, handle.AddrOfPinnedObject(), (int)bgra.Step());
return yolo.RunObjectDetection(image, confidence: confidence, iou: 0.7);
}
finally
{
handle.Free();
}
注意 SKImage.FromPixels 不会复制像素,所以推理期间必须保证缓冲区一直有效。
4.3 手机检测与多目标关联
先按标签过滤结果。不同模型的标签名称可能略有差异,这里兼容了几种常见写法:
private static readonly HashSet<string> PhoneLabels =
new(StringComparer.OrdinalIgnoreCase)
{
"cell phone",
"cellphone",
"mobile phone",
"phone",
"smartphone"
};
同屏出现多部手机时,不能每帧都重新选置信度最高的目标,否则框会在几部手机之间来回跳。这里用上一帧中心点的最近邻做关联:
if (previousCenter is Point2f previous)
{
return phones.MinBy(
p => DistanceSquared(ToCenter(p.BoundingBox), previous));
}
return phones.MaxBy(p => p.Confidence);
没有历史位置时,选置信度最高的目标;有历史位置时,选距离上一帧最近的目标。这样在只有一部手机时能稳定跟踪,多部手机时也不容易跳目标。
4.4 抖动治理:用 One Euro 滤波器代替简单 EMA
原始检测框会持续抖动。最早我用的是指数移动平均:
smoothed = previous + 0.45 * (raw - previous)
效果是平滑了,但存在两个问题:
- 静止时仍然能看出细小的抖动;
- 移动时平滑参数固定,跟手性和稳定性无法兼顾。
后来改成了 One Euro 滤波。它的核心思想是:根据目标速度动态调整截止频率,速度越快,滤波越弱,从而既能在静止时强力去抖,又能在快速移动时保持跟手。
public double Filter(double value, double dt)
{
double rawDerivative = (value - _previousRawValue) / dt;
_previousRawValue = value;
double derivativeAlpha = Alpha(dt, _derivativeCutoff);
_filteredDerivative = derivativeAlpha * rawDerivative
+ (1 - derivativeAlpha) * _filteredDerivative;
double cutoff = _minCutoff + _beta * Math.Abs(_filteredDerivative);
double alpha = Alpha(dt, cutoff);
_filteredValue = alpha * value + (1 - alpha) * _filteredValue;
return _filteredValue;
}
private static double Alpha(double dt, double cutoff)
{
double tau = 1.0 / (2.0 * Math.PI * cutoff);
return 1.0 / (1.0 + tau / dt);
}
工程里分别对 4 个量做滤波:
| 滤波对象 | minCutoff | beta |
|---|---|---|
| 中心点 X | 1.3 | 0.02 |
| 中心点 Y | 1.3 | 0.02 |
| 检测框宽度 | 0.9 | 0.008 |
| 检测框高度 | 0.9 | 0.008 |
只平滑中心点是不够的:如果框的宽高仍使用原始值,框的四个边仍会抖动。因此这里把中心点和宽高都平滑,再用平滑后的结果重新组合成检测框。
4.5 漏检保持:解决检测框闪烁
即使模型检测率很高,也难免偶尔漏掉一两帧。如果直接在漏检帧不画框,画面看起来就会一闪一闪。
解决方法是加一个短暂的「保持时间」:
- 漏检 ≤ 300 ms:保持上一帧的检测框和中心点,不新增轨迹点;
- 漏检 > 300 ms:隐藏检测框;
- 漏检 > 800 ms:清空轨迹并重置滤波器;
- 重新捕获目标时,如果间隔超过 300 ms,断开旧轨迹,避免把两个位置连成一条长直线。
这样短暂遮挡或模型抖动不会造成闪烁,手机真正离开画面后轨迹也会自动结束。
4.6 轨迹绘制:Catmull-Rom 插值 + 分段批量绘制
摄像头大约 30 FPS,如果直接用相邻两个点画线段,快速移动时轨迹会有明显的折线感。
这里使用 Catmull-Rom 插值,在每两个轨迹点之间再生成 4 个采样点:
private static Point2f CatmullRom(
Point2f p0, Point2f p1, Point2f p2, Point2f p3, float t)
{
float t2 = t * t;
float t3 = t2 * t;
float x = 0.5f * (2f * p1.X
+ (-p0.X + p2.X) * t
+ (2f * p0.X - 5f * p1.X + 4f * p2.X - p3.X) * t2
+ (-p0.X + 3f * p1.X - 3f * p2.X + p3.X) * t3);
float y = 0.5f * (2f * p1.Y
+ (-p0.Y + p2.Y) * t
+ (2f * p0.Y - 5f * p1.Y + 4f * p2.Y - p3.Y) * t2
+ (-p0.Y + 3f * p1.Y - 3f * p2.Y + p3.Y) * t3);
return new Point2f(x, y);
}
轨迹颜色从旧到新渐变:
private static Scalar TrailColor(double t)
=> new(255 * (1 - t), 80 + 175 * t, 255 * t);
其中 t = 0 偏蓝,t = 1 偏黄。为了控制开销,轨迹没有逐段调用 Cv2.Line,而是按颜色分成 16 个区间,用 Cv2.Polylines 批量绘制。
另外,轨迹点会保留最近 6 秒;如果手机几乎静止,新点和上一个点的距离小于 1 像素,就只更新最后一个点,避免原地堆出大量重复点。
4.7 UI 显示:预缩放 + 1:1 绘制
实时视频的显示链路也很容易成为瓶颈。
一开始使用 PictureBox 的 SizeMode = Zoom,让控件自动缩放 1280×720 的画面。后来实测发现,每帧都让 GDI+ 做插值缩放会带来额外开销,尤其是在高 DPI、窗口较大的情况下。
优化方法是:
- 在采集线程按显示区域尺寸预缩放;
- UI 线程只负责把现成的
Bitmap按 1:1 画出来; - 如果显示区域比视频大,就按原始分辨率居中显示,不再放大;
- 使用自定义
VideoSurface,开启双缓冲和AllPaintingInWmPaint。
protected override void OnPaint(PaintEventArgs e)
{
var frame = _frame;
if (frame is null)
{
e.Graphics.Clear(BackColor);
return;
}
e.Graphics.CompositingMode = CompositingMode.SourceCopy;
e.Graphics.PixelOffsetMode = PixelOffsetMode.Half;
if (frame.Width <= ClientSize.Width && frame.Height <= ClientSize.Height)
{
int x = (ClientSize.Width - frame.Width) / 2;
int y = (ClientSize.Height - frame.Height) / 2;
e.Graphics.DrawImageUnscaled(frame, x, y);
}
else
{
e.Graphics.DrawImage(frame, ClientRectangle);
}
}
UI 更新使用 BeginInvoke。如果上一帧还没画完,就丢弃当前帧,避免消息队列越积越多、画面延迟越来越大。
另外,状态栏里的 FPS 统计的是实际显示帧数,而不是采集循环次数。这样才能真正确认「计算了 30 帧,但界面只显示了 17 帧」这类问题。
五、性能优化记录
优化前的现象是:推理只要 23 ms 左右,但画面明显不够流畅。于是对整条链路逐段计时,得到下面这些数据:
| 环节 | 优化前 | 优化后 |
|---|---|---|
| 摄像头后端 | DirectShow 16.9 FPS | MSMF 30.3 FPS |
| 应用显示 | 约 16.7 FPS | 25~31 FPS |
| 单帧推理 | 约 23 ms | 19~26 ms |
| 预缩放 + 合成 | 无 | 约 2 ms |
| Mat → Bitmap | 每帧缩放后转换 | 约 1 ms |
| UI 掉帧 | 有 | 实测 0 |
主要的优化点有三个:
-
换摄像头后端
DirectShow 在这台机器上只有 16.9 FPS,这是最大的瓶颈。改为优先 MSMF 后,摄像头直接跑满 30 FPS。 -
把缩放从 UI 线程挪到采集线程
用 OpenCV 预缩放比让 GDI+ 每帧插值更稳定,也更容易控制显示尺寸。 -
批量绘制轨迹
轨迹最多保留 6 秒、约 180 个点,每个点插值 4 次后会接近 700 个线段。逐段画线会产生大量 GDI/OpenCV 绘制调用,按颜色分桶后用Polylines批量画,能明显降低绘制开销。
六、踩坑记录
坑 1:帧率低,不一定是模型慢
一开始看到画面卡,第一反应是 YOLO 推理太慢。但把推理单独计时后发现只有 20 多毫秒,完全够 30 FPS。
后来用 Python OpenCV 单独测摄像头:
cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
# 读取 3 秒,统计帧数
结果 DirectShow 只有 16.9 FPS,换成 MSMF 后变成 30.3 FPS。先测数据源,再怀疑算法,这次省了很多时间。
坑 2:OpenCvSharp 的 Mat.Create 参数顺序
Mat.Create 的参数是 (rows, cols, type),而不是 (width, height, type)。
我在预缩放时写成了:
display.Create(targetWidth, targetHeight, MatType.CV_8UC3);
结果创建出来的 Mat 宽高互换,随后构造 ROI 时直接抛出:
0 <= roi.x && 0 <= roi.width && roi.x + roi.width <= m.cols ...
正确写法是:
display.Create(targetHeight, targetWidth, MatType.CV_8UC3);
这个坑很隐蔽,因为编译完全正常,只在运行时才暴露。
坑 3:检测框和中心点必须一起平滑
一开始只平滑中心点,结果中心十字很稳,但检测框的边缘仍然持续抖动,整体观感还是不流畅。后来把宽、高也纳入 One Euro 滤波,视觉上才真正稳定下来。
坑 4:UI 显示帧率要和采集帧率分开统计
之前用采集循环的计数当作 FPS,看到的是 30 FPS;但实际 UI 可能在丢帧。后来在 UI 线程单独统计显示帧率,才发现问题。
“处理了多少帧”和“用户看到了多少帧”是两个不同的指标。
七、如何运行
环境
- Windows 10/11
- .NET 8 SDK
- 可用的摄像头
- Visual Studio 2022/2026 或命令行
主要 NuGet 包
<PackageReference Include="OpenCvSharp4" Version="4.13.0.20260627" />
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.13.0.20260627" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.13.0.20260627" />
<PackageReference Include="YoloDotNet" Version="4.2.0" />
<PackageReference Include="YoloDotNet.ExecutionProvider.Cpu" Version="1.1.0" />
启动
dotnet run --project PhoneMotionTracker/PhoneMotionTracker.csproj
操作
- 选择摄像头编号(默认 0);
- 调整置信度阈值(默认 0.35);
- 点击「开始检测」;
- 手机进入画面,观察检测框、中心标记和运动轨迹;
- 可以随时清除轨迹、显示/隐藏轨迹或停止检测。
项目结构
PhoneMotionTracker.sln
README.md
PhoneMotionTracker/
├── Form1.cs # 采集、推理、平滑、轨迹绘制
├── Form1.Designer.cs # WinForms 界面
├── VideoSurface.cs # 1:1 快速视频显示控件
├── Program.cs
├── PhoneMotionTracker.csproj
└── models/yolov8n.onnx # 默认检测模型
八、还可以继续优化
- GPU 推理:把
YoloDotNet.ExecutionProvider.Cpu换成 CUDA 或 TensorRT Provider,推理时间可以进一步下降; - 更小的输入尺寸:把模型导出为 512 或 480 输入,减少 CPU 推理耗时;
- 多目标跟踪:引入 SORT、ByteTrack 等算法,为每部手机分配稳定 ID;
- 轨迹导出:把手机中心点坐标按时间序列导出为 CSV/JSON,用于后续分析;
- 区域入侵检测:结合 ROI,实现“手机进入某个区域”的告警;
- 录像功能:使用 OpenCV
VideoWriter保存带叠加层的视频。
九、总结
这个项目本身不复杂,但把「实时视觉」做好,远不只是调用一个 YOLO 模型:
- 摄像头后端选错,可能直接损失一半帧率;
- 检测框抖动需要针对中心点和宽高分别做自适应平滑;
- 漏检保持能显著改善观感;
- 轨迹插值和批量绘制决定了画面是否“丝滑”;
- UI 线程的渲染链路也必须纳入性能统计。
如果你也在用 C# 做实时摄像头 + AI 检测,希望这篇文章能帮你少踩几个坑。
模型许可提醒:Ultralytics YOLO 预训练模型通常采用 AGPL-3.0 或需要商业授权,商用前请确认许可;YoloDotNet 本身采用 MIT 许可
最后
如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享只是的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。
公众号会经常发一些实用的小工具和demo源码,有兴趣的麻烦点个关注,赠人玫瑰,手留余香,感谢您的关注,期待和您一起探讨!再会!
(源码地址会分享到微信公众号评论区)


来源:博客园 · cnblogs.com