从零打造 C# 工业视觉检测系统(五):相机连接、连续取流与双路实时预览

从零打造 C# 工业视觉检测系统(五):相机连接、连续取流与双路实时预览

系列标签C# 海康威视 相机取流 IntPtr Bitmap 多线程

本文目标:实现相机的连接与断开、连续取流循环,以及双路相机的实时画面显示


一、前言

上篇文章我们完成了相机的枚举和选择。本文将进入更核心的环节------让相机"动"起来

  1. 连接选中的相机
  2. 启动连续取流(Grab)
  3. 将每一帧图像转换为可显示的 Bitmap
  4. 在界面上实时预览

本项目支持最多 2 路相机,我们将完整讲解双路相机的生命周期管理。


二、相机连接流程

2.1 连接参数说明

ConnectCamera 方法的参数:

csharp 复制代码
int ConnectCamera(
    int selectIndex,       // 用户在列表中选中的相机索引
    int selectCameraIndex, // 逻辑相机编号(0=正面,1=背面)
    ref int CameraId,      // 返回的相机句柄 ID
    ref int ImgWidth,      // 返回的图像宽度
    ref int ImgHeight      // 返回的图像高度
);

2.2 正面相机连接代码

csharp 复制代码
private bool ConnectFontCamera()
{
    if (!CameraData.isConnect[0])
    {
        // 弹出选择窗口
        SelectCamera selectCamera = new SelectCamera();
        selectCamera.ShowDialog();
        CameraData.CameraIndex[0] = (int)selectCamera.selectCameraIndex;
        selectCamera.Dispose();
        
        if (CameraData.CameraIndex[0] != -1)
        {
            int ret = DllMethod.ConnectCamera(
                CameraData.CameraIndex[0], 
                0,  // 逻辑编号 0 = 正面相机
                ref currentCameraCount, 
                ref CameraData.ImageWidthList[0], 
                ref CameraData.ImageHeightList[0]);
            
            if (ret == 0)
            {
                CameraData.StartFrontGrabing();  // 启动取流
                return true;
            }
        }
    }
    return false;
}

2.3 背面相机连接代码

与正面相机几乎一致,只是逻辑编号改为 1

csharp 复制代码
private bool ConnectBackCamera()
{
    if (!CameraData.isConnect[1])
    {
        SelectCamera selectCamera = new SelectCamera();
        selectCamera.ShowDialog();
        CameraData.CameraIndex[1] = (int)selectCamera.selectCameraIndex;
        selectCamera.Dispose();
        
        if (CameraData.CameraIndex[1] != -1)
        {
            int ret = DllMethod.ConnectCamera(
                CameraData.CameraIndex[1], 
                1,  // 逻辑编号 1 = 背面相机
                ref currentCameraCount, 
                ref CameraData.ImageWidthList[1], 
                ref CameraData.ImageHeightList[1]);
            
            if (ret == 0)
            {
                CameraData.StartBackGrabing();
                return true;
            }
        }
    }
    return false;
}

三、连续取流(Grab)实现

3.1 为什么用后台 Task?

相机取流是一个阻塞循环过程:不断从驱动缓冲区读取最新图像。如果在 UI 线程执行,界面会完全卡死。因此必须使用后台线程。

3.2 CameraData 中的取流任务

csharp 复制代码
public static class CameraData
{
    public static Task frontTask;
    public static Task backTask;
    public static CancellationTokenSource frontCancelTokenSource;
    public static CancellationTokenSource backCancelTokenSource;

    public static void StartFrontGrabing()
    {
        frontCancelTokenSource = new CancellationTokenSource();
        frontTask = new Task(() =>
        {
            while (true)
            {
                if (frontCancelTokenSource.IsCancellationRequested)
                    break;
                
                DllMethod.Photograph(0);  // 触发拍照/取流
            }
        }, frontCancelTokenSource.Token);
        
        frontTask.Start();
        isConnect[0] = true;
        ImageShowBufList[0] = DllMethod.GetImageData(0);  // 获取图像缓冲区指针
    }

    public static void StopFrontGrabing()
    {
        frontCancelTokenSource.Cancel();
        frontTask.Wait();           // 等待任务结束
        frontCancelTokenSource.Dispose();
        DllMethod.DisconnectCamera(0);
        DllMethod.ClearImage(0);
        isConnect[0] = false;
        CameraIndex[0] = -1;
    }
}

关键点

  • CancellationTokenSource.Cancel() 发出取消信号
  • frontTask.Wait() 等待循环退出,避免强制中断导致资源泄漏
  • DisconnectCamera 释放 SDK 资源
  • ClearImage 清空图像缓冲区

四、IntPtr 到 Bitmap 的转换

4.1 图像数据在内存中的形态

海康 SDK 返回的图像数据是 24 位 BGR 格式 (每像素 3 字节,蓝绿红顺序),存储在非托管内存中,通过 IntPtr 指针访问。

内存布局:

复制代码
[B][G][R][B][G][R][B][G][R]...
 像素0    像素1    像素2

4.2 ImageUtil.IntptrToBitmap 实现

csharp 复制代码
public static class ImageUtil
{
    public static Bitmap IntptrToBitmap(int width, int height, IntPtr pt)
    {
        // 创建目标 Bitmap
        Bitmap bitmap = new Bitmap(width, height, PixelFormat.Format24bppRgb);
        Rectangle rect = new Rectangle(0, 0, width, height);
        
        // 锁定 Bitmap 的内存
        BitmapData bmpDataSrc = bitmap.LockBits(
            rect, ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb);
        
        IntPtr ptrSrc = bmpDataSrc.Scan0;
        byte[] rgbValues = new byte[width * height * 3];
        
        // 从 SDK 缓冲区拷贝到托管数组
        System.Runtime.InteropServices.Marshal.Copy(pt, rgbValues, 0, width * height * 3);

        // 使用指针将像素写入 Bitmap(高性能)
        unsafe
        {
            byte* ptr = (byte*)(bmpDataSrc.Scan0);
            for (int i = 0; i < bmpDataSrc.Height; i++)
            {
                for (int j = 0; j < bmpDataSrc.Width; j++)
                {
                    ptr[0] = rgbValues[(bmpDataSrc.Width * i + j) * 3 + 0];  // B
                    ptr[1] = rgbValues[(bmpDataSrc.Width * i + j) * 3 + 1];  // G
                    ptr[2] = rgbValues[(bmpDataSrc.Width * i + j) * 3 + 2];  // R
                    ptr += 3;
                }
                ptr += bmpDataSrc.Stride - bmpDataSrc.Width * 3;  // 跳过对齐填充
            }
        }
        
        bitmap.UnlockBits(bmpDataSrc);
        return bitmap;
    }
}

重要细节

  • Stride 是每行实际占用的字节数,可能大于 width * 3(内存对齐填充)
  • unsafe 代码块需要项目开启"允许不安全代码"选项
  • BGR 顺序直接写入即可,Windows GDI+ 的 Format24bppRgb 实际就是 BGR 存储

4.3 项目中的不安全代码配置

FormUI.csproj 中:

xml 复制代码
<PropertyGroup>
  <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

五、双路实时预览

5.1 异步显示任务

csharp 复制代码
Task frontTask;
Task backTask;
CancellationTokenSource frontCancelTokenSource;
CancellationTokenSource backCancelTokenSource;

private void ShowVideo()
{
    if (CameraData.isConnect[0] == true)
    {
        frontCancelTokenSource = new CancellationTokenSource();
        frontTask = Task.Run(async () =>
        {
            while (!frontCancelTokenSource.Token.IsCancellationRequested)
            {
                frontBitmap = ImageUtil.IntptrToBitmap(
                    CameraData.ImageWidthList[0],
                    CameraData.ImageHeightList[0], 
                    CameraData.ImageShowBufList[0]);

                this.BeginInvoke(new Action(() =>
                {
                    FrontPic.Image = frontBitmap;
                    FrontPic.Refresh();
                }));

                await Task.Delay(50);  // 20 FPS 足够预览
            }
        }, frontCancelTokenSource.Token);
    }
    
    if (CameraData.isConnect[1] == true)
    {
        backCancelTokenSource = new CancellationTokenSource();
        backTask = Task.Run(async () =>
        {
            while (!backCancelTokenSource.Token.IsCancellationRequested)
            {
                backBitmap = ImageUtil.IntptrToBitmap(
                    CameraData.ImageWidthList[1],
                    CameraData.ImageHeightList[1], 
                    CameraData.ImageShowBufList[1]);

                this.BeginInvoke(new Action(() =>
                {
                    BackPic.Image = backBitmap;
                    BackPic.Refresh();
                }));

                await Task.Delay(50);
            }
        }, backCancelTokenSource.Token);
    }
}

5.2 停止预览

csharp 复制代码
private void StopVideo()
{
    if (CameraData.isConnect[0])
    {
        frontCancelTokenSource.Cancel();
        frontTask.Wait();
        frontCancelTokenSource?.Dispose();
        
        this.Invoke(new Action(() =>
        {
            FrontPic.Image = null;
            FrontPic.Refresh();
        }));
    }
    
    if (CameraData.isConnect[1])
    {
        backCancelTokenSource.Cancel();
        backTask.Wait();
        backCancelTokenSource?.Dispose();
        
        this.Invoke(new Action(() =>
        {
            BackPic.Image = null;
            BackPic.Refresh();
        }));
    }
}

线程安全要点

  • 后台线程中不能直接接触 UI 控件,必须通过 BeginInvoke / Invoke
  • BeginInvoke 是异步的,不会阻塞取流循环;Invoke 是同步的
  • await Task.Delay(50) 控制刷新频率,减轻 CPU 负担

六、内存优化建议

6.1 Bitmap 对象的生命周期

每帧都 new Bitmap() 会产生大量内存分配。优化方案:

  1. 复用 Bitmap:如果尺寸不变,可以只创建一次,后续更新像素数据
  2. 及时释放 :旧的 Bitmap 需要 Dispose(),否则内存泄漏
  3. 双缓冲:避免画面闪烁

6.2 本项目中的处理方式

当前实现中每帧都新建 Bitmap,但对于预览场景(50ms 一帧)来说,GC 可以承受。如果帧率要求更高(如 100+ FPS),建议采用固定缓冲区 + LockBits 更新的方案。


七、总结

本文完成了相机从连接到显示的全流程:

  1. 连接相机 :通过 ConnectCamera 建立 SDK 会话
  2. 连续取流 :后台 Task + CancellationToken 实现优雅启停
  3. 图像转换IntPtrbyte[]Bitmap 的完整链路
  4. 实时预览:双路异步显示,线程安全更新 UI

下篇预告:《从零打造 C# 工业视觉检测系统(六):OpenCV 图像处理与像素级比例尺标定》

我们将深入讲解图像像素操作、BGR 通道,以及如何将像素距离转换为物理尺寸。


欢迎点赞、收藏、关注,你的支持是我持续输出的动力!

相关推荐
JckOLF04Z1 小时前
C#客户端的异步操作
开发语言·c#
曹牧1 小时前
C#:注释嵌套
开发语言·c#
LccKyI1 小时前
C# 零基础学习 Day01|基础概念梳理 + 思维导图总结
开发语言·笔记·学习·c#
格林威14 小时前
多相机微秒级对齐:硬件触发 vs PTP(IEEE 1588)方案实战对比
开发语言·人工智能·数码相机·机器学习·计算机视觉·视觉检测·机器视觉
雪隐18 小时前
WPF + MVVM 实战系列01-一个老狗回炉重造 WPF 的 12 天血泪史
sqlite·c#·mvvm
xcLeigh18 小时前
Unity基础:Camera相机组件详解——透视投影与正交投影的区别
数码相机·unity·游戏引擎
在世修行19 小时前
从零打造 C# 工业视觉检测系统(四):海康威视 MVS SDK 入门与相机枚举实战
数码相机·c#·视觉检测
神秘的MT20 小时前
C# WinForm Socket 服务器 + 串口转发
服务器·网络·学习·c#
m0_749492221 天前
2026平价运动相机选择指南:科美锐(Komery)PX3与另外两款相机的功能取舍拆解
数码相机