c# + YOLODotNet+OpenCVSharp 实现物品轨迹追踪

简介: 调用电脑摄像头,实时检测画面中的手机,标记手机中心位置,并绘制最近 6 秒的移动轨迹。

前言

实际项目需求是做个包裹错分检测系统,通过视觉方式检测包裹流向,根据追踪轨迹的方式来实现包裹定位。考虑到模型训练需要大量包裹来做样品标记,所以用自己办公电脑的前置摄像头做个手机追踪的示例考虑最终可行性。

技术栈:C# / .NET 8 / WinForms / YOLOv8 / YoloDotNet / OpenCvSharp / ONNX Runtime

项目目标:调用电脑摄像头,实时检测画面中的手机,标记手机中心位置,并绘制最近 6 秒的移动轨迹。

无图言屌

博客有动图大小限制,看不到的自行脑补下,就是跟着手机移动,实时绘制手机运行轨迹
phone1111.gif


一、项目效果与需求

这是一个运行在 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 再交给推理,会带来额外的内存分配和像素拷贝。

这里的做法是:

  1. 用 OpenCV 把 BGR 转成 BGRA;
  2. 固定(pin)一个复用的 byte[];
  3. 用 SKImage.FromPixels 直接引用这块内存;
  4. 推理结束后立刻释放 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、窗口较大的情况下。

优化方法是:

  1. 在采集线程按显示区域尺寸预缩放;
  2. UI 线程只负责把现成的 Bitmap 按 1:1 画出来;
  3. 如果显示区域比视频大,就按原始分辨率居中显示,不再放大;
  4. 使用自定义 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

主要的优化点有三个:

  1. 换摄像头后端\
    DirectShow 在这台机器上只有 16.9 FPS,这是最大的瓶颈。改为优先 MSMF 后,摄像头直接跑满 30 FPS。

  2. 把缩放从 UI 线程挪到采集线程\
    用 OpenCV 预缩放比让 GDI+ 每帧插值更稳定,也更容易控制显示尺寸。

  3. 批量绘制轨迹\
    轨迹最多保留 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

操作

  1. 选择摄像头编号(默认 0);
  2. 调整置信度阈值(默认 0.35);
  3. 点击「开始检测」;
  4. 手机进入画面,观察检测框、中心标记和运动轨迹;
  5. 可以随时清除轨迹、显示/隐藏轨迹或停止检测。

项目结构

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源码,有兴趣的麻烦点个关注,赠人玫瑰,手留余香,感谢您的关注,期待和您一起探讨!再会!

相关文章
|
8天前
|
机器学习/深度学习 人工智能 缓存
Agent 测评指标怎么选、怎么算?
本文系统解析AI时代Agent测评的四大类核心指标(判定、检索、相似、行为),直击准确率“骗人”等常见误区,用大白话讲清每个指标的计算逻辑、适用场景与坑点,并提供可落地的选指标三步法和避坑清单,助你科学评估Agent质量。
136 1
|
10天前
|
人工智能 监控 安全
我开发了一款免费开源的测试开发刷题学习网站
狂师打造的免费开源AI测试学习平台,含200道深度解析面试题(覆盖大模型测试等10大专题),每题标配面试官视角:考察意图、过线标准、区分度与加分项;配套学习路线、实战教程及140+工具导航,全站免登录,GitHub同步开源。
174 3
我开发了一款免费开源的测试开发刷题学习网站
|
18天前
|
SQL 人工智能 自然语言处理
夯爆了!分享几个我在用的,基于业务场景接口串联测试Skill !
本文介绍AI驱动的业务场景接口串联测试新范式,填补单接口测试无法覆盖的“数据传递、状态流转、一致性、规则联动”四大缺陷。通过`api-scenario-modeler`将自然语言业务流程(如注册→登录→下单→支付)自动建模为标准化YAML编排文件,实现端到端链路验证,真正保障用户核心业务流程通畅。
233 2
夯爆了!分享几个我在用的,基于业务场景接口串联测试Skill !
|
16天前
|
人工智能 运维 Cloud Native
阿里云刚发布的 AgentCore 有何不同?
本文整理自阿里云智能集团云原生应用平台负责人周琦和 AgentCore 产品经理李国强在云栖大会的分享。
|
16天前
|
人工智能 安全 测试技术
2026年AI渗透测试平台产业发展与技术架构研究报告
本报告聚焦2026年AI渗透测试平台发展现状,指出行业正从单工具迈向多Agent协同平台化阶段。研究揭示:AI加速攻防迭代(周期缩至周级),传统年测2–4次已严重滞后;多Agent平台可将高危漏洞暴露期由45天压至7天内,显著提升实战效能。报告构建企业选型评估体系,提示数据安全、合规授权等关键风险。(239字)
|
22天前
|
人工智能 运维 安全
从个人生产力到企业生产力,阿里云发布企业级 Agent 平台 AgentCore
云栖大会期间,阿里云发布了企业级 Agent 平台 AgentCore,旨在通过更加原生的人机协作方式,以及更完整的 Agent 构建和治理的平台能力,提升企业的生产力。
|
24天前
|
消息中间件 SQL 人工智能
Kafka 不止于消息:阿里云发布面向 AI 的流算湖一体化实时数据平台
9 月 22 日,阿里云在云栖大会 AI 实时数据智能论坛上正式发布 Kafka 流算湖一体化实时数据平台——从统一实时数据入口,到数据进来即算、处理后原生入湖,云消息队列 Kafka 版加速向实时数据平台演进。
|
3天前
|
人工智能 自然语言处理 IDE
阿里云Qoder收费价格:19.9元首月(原价59元)包含2000 Credits,2026年双11活动
阿里云Qoder是面向真实开发的智能体编程平台,双十一新客专享19.9元/月(原价59元),含2000 Credits。支持Desktop/IDE/CLI/插件/移动端多形态,兼容GLM、DeepSeek等主流大模型,提供端到端AI开发能力。阿里云Qoder CN官网:https://t.aliyun.com/U/fEiOLV
144 0
|
4天前
|
人工智能 IDE 双11
阿里云Qoder CN双十一2026年最新活动:19.9元1个月,Credits计费说明
阿里云Qoder CN双11特惠:新客首月仅19.9元,含2000 Credits!涵盖IDE插件、桌面应用、CLI、数字员工等AI智能体产品,赋能开发与办公。个人/企业多版本可选,按需计费,额度用完可续购。详情见官网 https://t.aliyun.com/U/fEiOLV
113 0
|
4天前
|
人工智能 弹性计算 测试技术
2026年阿里云双11 Qoder优惠解读,19.9元首月与版本价格表
2026年阿里云双11的Qoder优惠就不只是一个19.9元的噱头,而是一条可以算清楚的成本曲线:首月用19.9元验证,稳定后用年付摊薄,重度场景用高配套夜间折扣,团队用席位换管理能力。选对版本,比抢到最低价更重要。
187 0