C# 佳能相机SDK对接,采集并保存视频,使用WriteableBitmap高效渲染
佳能数码单反相机是众多相机SDK里面最难对接的一个,应该说数码相机要比普通工业相机难对接,因为工业相机仅仅只是采集图像,而数码单反相机SDK意味着操作一部相机,有时我们需要像普通相机一样使用数码单反相机,本文就是实现这样的需求,需要实现的功能包括:
1、打开和关闭相机
2、实时显示图像
3、拍照和录像
由于佳能相机拍照和录像的特殊性(通过回调的方式),因此我们定义的相机功能接口如下(适合大部分相机):

/// <summary> /// 相机接口 /// </summary> public interface ICamera : IDisposable { /// <summary> /// 初始化 /// </summary> /// <returns></returns> Boolean Init (out String errMsg); /// <summary> /// 开始运行 /// </summary> /// <returns></returns> Boolean Play (out String errMsg); /// <summary> /// 停止运行 /// </summary> /// <returns></returns> Boolean Stop (out String errMsg); /// <summary> /// 开始录像 /// </summary> /// <returns></returns> Boolean BeginRecord (out String errMsg); /// <summary> /// 停止录像 /// </summary> /// <returns></returns> Boolean EndRecord (out String errMsg); /// <summary> /// 拍照 /// </summary> /// <returns></returns> Boolean TakePicture (out String errMsg); /// <summary> /// 图像源改变事件回调通知 /// </summary> Action<ImageSource> ImageSourceChanged { get; set; } /// <summary> /// 相机名称 /// </summary> String CameraName { get; } /// <summary> /// 新照片回调通知 /// </summary> Action<String> NewImage { get; set; } /// <summary> /// 新录像回调通知 /// </summary> Action<String> NewVideo { get; set; } /// <summary> /// 储存图像文件夹 /// </summary> String ImageFolder { get; set; } /// <summary> /// 储存录像文件夹 /// </summary> String VideoFolder { get; set; } /// <summary> /// 命名规则 /// </summary> Func<String> NamingRulesFunc { get; set; } }
创建相机对象时,类似于这样:
var camera = new Camera { ImageSourceChanged = n => { this.img.Source = n; }, // 更新图像源 ImageFolder = Path.Combine (Environment.CurrentDirectory, "Images"), // 图像保存路径 VideoFolder = Path.Combine (Environment.CurrentDirectory, "Videos"), // 录像保存路径 NamingRulesFunc = () => (DateTime.Now - new DateTime (1970, 1, 1)).TotalMilliseconds.ToString ("0") // 新文件命名方式 };
相机的实现类比较长,代码已上传至Github:https://github.com/LowPlayer/CanonCamera;源码里面有官方SDK文档和Demo,强烈建议看完第六章的示例,因为Demo封装得太多,不易看懂;
相机的连接:

public Boolean Init (out String errMsg) { errMsg = null; lock (sdkLock) { var err = InitCamera (); // 初始化相机 var ret = err == EDSDK.EDS_ERR_OK; if (!ret) { errMsg = "未检测到相机,错误代码:" + err; Close (); // 关闭相机 } return ret; } } private UInt32 InitCamera () { var err = EDSDK.EDS_ERR_OK; if (!isSDKLoaded) { err = EDSDK.EdsInitializeSDK (); // 初始化SDK if (err != EDSDK.EDS_ERR_OK) return err; isSDKLoaded = true; } err = GetFirstCamera (out camera); // 获取相机对象 if (err == EDSDK.EDS_ERR_OK) { // 注册回调函数 err = EDSDK.EdsSetObjectEventHandler (camera, EDSDK.ObjectEvent_All, objectEventHandler, handle); if (err == EDSDK.EDS_ERR_OK) err = EDSDK.EdsSetPropertyEventHandler (camera, EDSDK.PropertyEvent_All, propertyEventHandler, handle); if (err == EDSDK.EDS_ERR_OK) err = EDSDK.EdsSetCameraStateEventHandler (camera, EDSDK.StateEvent_All, stateEventHandler, handle); // 打开会话 if (err == EDSDK.EDS_ERR_OK) err = EDSDK.EdsOpenSession (camera); if (err == EDSDK.EDS_ERR_OK) isSessionOpened = true; } return err; }
相机的退出:

private void Close (Boolean isDisposed = false) { // 关闭实时取景 if ((EvfOutputDevice & EDSDK.EvfOutputDevice_PC) != 0) Stop (out _); // 停止录像 if (videoFileWriter != null) EndRecord (out _); // 结束会话 if (isSessionOpened) { lock (sdkLock) { if (EDSDK.EdsCloseSession (camera) == EDSDK.EDS_ERR_OK) isSessionOpened = false; } } // 释放相机对象 if (camera != IntPtr.Zero) { EDSDK.EdsRelease (camera); camera = IntPtr.Zero; } if (isDisposed) { GCHandle.FromIntPtr (handle).Free (); // 释放当前对象 this.ImageSourceChanged = null; this.NewImage = null; this.NewVideo = null; this.NamingRulesFunc = null; } else EDSDK.EdsSetCameraAddedHandler (cameraAddedHandler, handle); // 监听相机连接 }
获取相机对象:

private UInt32 GetFirstCamera (out IntPtr camera) { camera = IntPtr.Zero; // 获取相机列表对象 var err = EDSDK.EdsGetCameraList (out IntPtr cameraList); if (err == EDSDK.EDS_ERR_OK) { err = EDSDK.EdsGetChildCount (cameraList, out Int32 count); if (err == EDSDK.EDS_ERR_OK && count > 0) { err = EDSDK.EdsGetChildAtIndex (cameraList, 0, out camera); // 释放相机列表对象 EDSDK.EdsRelease (cameraList); cameraList = IntPtr.Zero; return err; } } if (cameraList != IntPtr.Zero) EDSDK.EdsRelease (cameraList); return EDSDK.EDS_ERR_DEVICE_NOT_FOUND; }
相机连接之后的相机设置:

// 获取相机名称 if (err == EDSDK.EDS_ERR_OK) err = EDSDK.EdsGetPropertyData (camera, EDSDK.PropID_ProductName, 0, out cameraName); if (err == EDSDK.EDS_ERR_OK) err = EDSDK.EdsGetPropertySize (camera, EDSDK.PropID_Evf_OutputDevice, 0, out _, out deviceSize); // 保存到计算机 if (err == EDSDK.EDS_ERR_OK) err = SaveToHost (); if (err == EDSDK.EDS_ERR_OK) { // 设置自动曝光 if (ISOSpeed != 0) EDSDK.EdsSetPropertyData (camera, EDSDK.PropID_ISOSpeed, 0, sizeof (UInt32), 0); // 设置拍摄图片质量 if (ImageQualityDesc != null) SetImageQualityJpegOnly (); // 设置曝光补偿+3 if (ExposureCompensation != 0x18) EDSDK.EdsSetPropertyData (camera, EDSDK.PropID_ExposureCompensation, 0, sizeof (UInt32), 0x18); // 设置白平衡;自动:环境优先 if (ExposureCompensation != 0) EDSDK.EdsSetPropertyData (camera, EDSDK.PropID_WhiteBalance, 0, sizeof (UInt32), 0); // 设置测光模式:点测光 if (MeteringMode != 0) EDSDK.EdsSetPropertyData (camera, EDSDK.PropID_MeteringMode, 0, sizeof (UInt32), 0); // 设置单拍模式 if (DriveMode != 0) EDSDK.EdsSetPropertyData (camera, EDSDK.PropID_DriveMode, 0, sizeof (UInt32), 0); // 设置快门速度 if (Tv != 0x60) EDSDK.EdsSetPropertyData (camera, EDSDK.PropID_Tv, 0, sizeof (UInt32), 0x60); }
开始实时取景,将画面传输到PC:

public Boolean Play (out String errMsg) { errMsg = null; if (camera == IntPtr.Zero) { if (!Init (out errMsg)) return false; else Thread.Sleep (500); } if ((EvfOutputDevice & EDSDK.EvfOutputDevice_PC) != 0) return true; UInt32 err = EDSDK.EDS_ERR_OK; lock (sdkLock) { // 不允许设置AE模式转盘 //if (AEMode != EDSDK.AEMode_Tv) // err = EDSDK.EdsSetPropertyData(camera, EDSDK.PropID_Evf_Mode, 0, sizeof(UInt32), EDSDK.AEMode_Tv); // 开启实时取景 if (err == EDSDK.EDS_ERR_OK && (EvfOutputDevice & EDSDK.EvfOutputDevice_PC) == 0) err = EDSDK.EdsSetPropertyData (camera, EDSDK.PropID_Evf_OutputDevice, 0, deviceSize, EvfOutputDevice | EDSDK.EvfOutputDevice_PC); } var ret = err == EDSDK.EDS_ERR_OK; if (ret) { thread_evf = new Thread (ReadEvf) { IsBackground = true }; thread_evf.SetApartmentState (ApartmentState.STA); thread_evf.Start (); } else errMsg = "开启实时图像模式失败,错误代码:" + err; return ret; }
关闭实时取景:

public Boolean Stop (out String errMsg) { errMsg = null; if (camera == IntPtr.Zero || (EvfOutputDevice & EDSDK.EvfOutputDevice_PC) == 0) return true; var err = EDSDK.EDS_ERR_OK; // 停止实时取景 lock (sdkLock) { if (DepthOfFieldPreview != EDSDK.EvfDepthOfFieldPreview_OFF) err = EDSDK.EdsSetPropertyData (camera, EDSDK.PropID_Evf_DepthOfFieldPreview, 0, sizeof (UInt32), EDSDK.EvfDepthOfFieldPreview_OFF); if (err == EDSDK.EDS_ERR_OK && (EvfOutputDevice & EDSDK.EvfOutputDevice_PC) != 0) err = EDSDK.EdsSetPropertyData (camera, EDSDK.PropID_Evf_OutputDevice, 0, deviceSize, EvfOutputDevice & ~EDSDK.EvfOutputDevice_PC); } if (err != EDSDK.EDS_ERR_OK) errMsg = "关闭实时图像模式失败,错误代码:" + err; return err == EDSDK.EDS_ERR_OK; }
获取实时取景画面:

private void ReadEvf () { // 等待实时图像传输开启 SpinWait.SpinUntil (() => (EvfOutputDevice & EDSDK.EvfOutputDevice_PC) != 0, 5000); IntPtr stream = IntPtr.Zero; IntPtr evfImage = IntPtr.Zero; IntPtr evfStream = IntPtr.Zero; UInt64 length = 0, maxLength = 2 * 1024 * 1024; var err = EDSDK.EDS_ERR_OK; // 当实时图像传输开启时,不断地循环 while (isSessionOpened && (EvfOutputDevice & EDSDK.EvfOutputDevice_PC) != 0) { lock (sdkLock) { err = EDSDK.EdsCreateMemoryStream (maxLength, out stream); // 创建用于保存图像的流对象 if (err == EDSDK.EDS_ERR_OK) { err = EDSDK.EdsCreateEvfImageRef (stream, out evfImage); // 创建evf图像对象 if (err == EDSDK.EDS_ERR_OK) err = EDSDK.EdsDownloadEvfImage (camera, evfImage); // 从相机下载evf图像 if (err == EDSDK.EDS_ERR_OK) err = EDSDK.EdsGetPointer (stream, out evfStream); // 获取流对象的流地址 if (err == EDSDK.EDS_ERR_OK) err = EDSDK.EdsGetLength (stream, out length); // 获取流的长度 } } if (err == EDSDK.EDS_ERR_OK) RenderBitmap (evfStream, length); // 渲染图像 if (stream != IntPtr.Zero) { EDSDK.EdsRelease (stream); stream = IntPtr.Zero; } if (evfImage != IntPtr.Zero) { EDSDK.EdsRelease (evfImage); evfImage = IntPtr.Zero; } if (evfStream != IntPtr.Zero) { EDSDK.EdsRelease (evfStream); evfStream = IntPtr.Zero; } } // 停止显示图像 context.Send (n => { WriteableBitmap = null; }, null); }
拍摄:

public Boolean TakePicture (out String errMsg) { errMsg = null; if (camera == IntPtr.Zero) { errMsg = "未检测到相机"; return false; } lock (sdkLock) { // 存储到计算机 var err = SaveToHost (); if (err == EDSDK.EDS_ERR_OK) { err = EDSDK.EdsSendCommand (camera, EDSDK.CameraCommand_PressShutterButton, (Int32) EDSDK.EdsShutterButton.CameraCommand_ShutterButton_Completely); // 按下拍摄按钮 if (err == EDSDK.EDS_ERR_OK) err = EDSDK.EdsSendCommand (camera, EDSDK.CameraCommand_PressShutterButton, (Int32) EDSDK.EdsShutterButton.CameraCommand_ShutterButton_OFF); // 弹起拍摄按钮 } if (err != EDSDK.EDS_ERR_OK) errMsg = "拍照失败,错误代码:" + err; return err == EDSDK.EDS_ERR_OK; } }
开始录像:

public Boolean BeginRecord (out String errMsg) { errMsg = null; if (camera == IntPtr.Zero) { errMsg = "未检测到相机"; return false; } if (videoFileWriter != null) return true; if ((EvfOutputDevice & EDSDK.EvfOutputDevice_PC) == 0 && !Play (out errMsg)) return false; videoFileWriter = new VideoFileWriter (); stopwatch = new Stopwatch (); return true; }
停止录像:

public Boolean EndRecord (out String errMsg) { errMsg = null; if (camera == IntPtr.Zero) { errMsg = "未检测到相机"; return false; } if (videoFileWriter == null) return true; lock (videoFileWriter) { videoFileWriter.Close (); videoFileWriter = null; stopwatch.Stop (); stopwatch = null; } return true; }
录像使用Accord.Video.FFMPEG.VideoFileWriter类,佳能相机的帧率不稳定,这里使用固定帧率16PFS,这会导致录像文件时长不对,因此需要使用计时器StopWatch计算当前帧的时间戳;

using (var bmp = (Bitmap) imageConverter.ConvertFrom (data)) // 解码获取Bitmap { // 获取Bitmap的像素数据指针 var bmpData = bmp.LockBits (new Rectangle (bmpStartPoint, bmp.Size), System.Drawing.Imaging.ImageLockMode.ReadOnly, System.Drawing.Imaging.PixelFormat.Format24bppRgb); if (videoFileWriter != null) { lock (videoFileWriter) { // 保存录像 if (!videoFileWriter.IsOpen) { var folder = VideoFolder ?? Environment.CurrentDirectory; if (!Directory.Exists (folder)) Directory.CreateDirectory (folder); var fileName = NamingRulesFunc?.Invoke () ?? (DateTime.Now - new DateTime (1970, 1, 1)).TotalMilliseconds.ToString ("0"); var filePath = Path.Combine (folder, fileName + ".mp4"); videoFileWriter.Open (filePath, this.width, this.height, 16, VideoCodec.MPEG4); // 使用16FPS,MP4文件保存 spf = 1000 / 16; // 计算一帧毫秒数 stopwatch.Restart (); frameIndex = 0; videoFileWriter.WriteVideoFrame (bmpData); } else { // 写入视频帧时传入时间戳,否则录像时长将对不上 var frame_index = (UInt32) (stopwatch.ElapsedMilliseconds / spf); if (frameIndex != frame_index) { frameIndex = frame_index; videoFileWriter.WriteVideoFrame (bmpData, frameIndex); } } } } bmp.UnlockBits (bmpData); }
如果是winfrom,可以使用PictureBox直接渲染Bitmap,本项目使用wpf技术,使用WriteableBitmap高效渲染,在第一帧时创建WriteableBitmap对象,之后将Bitmap数据写入WriteableBitmap的后台缓冲区,监听程序渲染事件CompositionTarget.Rendering不断更新画面;

private WriteableBitmap writeableBitmap; /// <summary> /// WPF的一个高性能渲染图像,利用后台缓冲区,渲染图像时不必每次都切换线程 /// </summary> private WriteableBitmap WriteableBitmap { get => this.writeableBitmap; set { if (this.writeableBitmap == value) return; if (this.writeableBitmap == null) CompositionTarget.Rendering += OnRender; else if (value == null) CompositionTarget.Rendering -= OnRender; this.writeableBitmap = value; this.ImageSourceChanged?.Invoke (value); } } private void RenderBitmap (IntPtr evfStream, UInt64 length) { var data = new Byte[length]; var bmpStartPoint = new System.Drawing.Point (0, 0); Marshal.Copy (evfStream, data, 0, (Int32) length); // 从流地址拷贝一份到字节数组,再解码获取图像(如果可以写一个从指针解码图像,可以优化此步骤) using (var bmp = (Bitmap) imageConverter.ConvertFrom (data)) // 解码获取Bitmap { if (this.WriteableBitmap == null || this.width != bmp.Width || this.height != bmp.Height) { // 第一次或宽高不对应时创建WriteableBitmap对象 this.width = bmp.Width; this.height = bmp.Height; // 通过线程同步上下文切换到主线程 context.Send (n => { WriteableBitmap = new WriteableBitmap (this.width, this.height, 96, 96, PixelFormats.Bgr24, null); backBuffer = WriteableBitmap.BackBuffer; // 保存后台缓冲区指针 this.stride = WriteableBitmap.BackBufferStride; // 单行像素数据中的字节数 this.length = this.stride * this.height; // 像素数据的总字节数 }, null); } // 获取Bitmap的像素数据指针 var bmpData = bmp.LockBits (new Rectangle (bmpStartPoint, bmp.Size), System.Drawing.Imaging.ImageLockMode.ReadOnly, System.Drawing.Imaging.PixelFormat.Format24bppRgb); // 将Bitmap的像素数据拷贝到WriteableBitmap if (this.stride == bmpData.Stride) Memcpy (backBuffer, bmpData.Scan0, this.length); else { var s = Math.Min (this.stride, bmpData.Stride); var tPtr = backBuffer; var sPtr = bmpData.Scan0; for (var i = 0; i < this.height; i++) { Memcpy (tPtr, sPtr, s); tPtr += this.stride; sPtr += bmpData.Stride; } } bmp.UnlockBits (bmpData); Interlocked.Exchange (ref newFrame, 1); } } private void OnRender (Object sender, EventArgs e) { var curRenderingTime = ((RenderingEventArgs) e).RenderingTime; if (curRenderingTime == lastRenderingTime) return; lastRenderingTime = curRenderingTime; if (Interlocked.CompareExchange (ref newFrame, 0, 1) != 1) return; var bmp = this.WriteableBitmap; bmp.Lock (); bmp.AddDirtyRect (new Int32Rect (0, 0, bmp.PixelWidth, bmp.PixelHeight)); bmp.Unlock (); }
下面说一下新人容易踩到的坑:
1、EDSDK的API不能同时调用,否则会卡死;为了解决这个问题,加了一个锁,保证多条线程不能同时调API;
2、同时执行多条API期间可能需要等待500ms,真是坑;
3、图像回调还需要下载,而且下载的是Jpeg文件流而不是BGR24或YUV等RAW数据;因此还需要解码获取BGR24数据;
4、录像必须保存到相机,因此需要存储卡,并且录像文件未编码,因此特别大,1秒1兆的样子,再传回电脑特别慢,再加上上面加锁的关系,卡住其他功能操作;还有录像结束后会自动停止实时图像传输,因此在停止录像后需要等待几秒再打开实时图像传输;并且打开录像模式之后,实时图像传输明显变卡;综合以上原因,我决定不打开录像模式,而是在实时图像传输时保存视频帧;
佳能相机在30分钟未操作后,会自动进入休眠模式,需要通电(或关闭再打开相机)才能调用,这里的解决方案是,创建了相机对象,只要不调用Dispose方法,即使初始化失败,当相机重新连接时,会自动初始化并打开实时图像传输;
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本