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; }
}
View Code

  创建相机对象时,类似于这样:

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;
}
View Code

  相机的退出:

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); // 监听相机连接
}
View Code

  获取相机对象:

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;
}
View Code

  相机连接之后的相机设置:

// 获取相机名称
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);
}
View Code

  开始实时取景,将画面传输到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;
}
View Code

  关闭实时取景:

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;
}
View Code

  获取实时取景画面:

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);
}
View Code

  拍摄:

 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;
     }
 }
View Code

  开始录像:

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;
}
View Code

  停止录像:

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;
}
View Code

  录像使用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);
}
View Code

  如果是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 ();
}
View Code

  下面说一下新人容易踩到的坑:

  1、EDSDK的API不能同时调用,否则会卡死;为了解决这个问题,加了一个锁,保证多条线程不能同时调API;

  2、同时执行多条API期间可能需要等待500ms,真是坑;

  3、图像回调还需要下载,而且下载的是Jpeg文件流而不是BGR24或YUV等RAW数据;因此还需要解码获取BGR24数据;

  4、录像必须保存到相机,因此需要存储卡,并且录像文件未编码,因此特别大,1秒1兆的样子,再传回电脑特别慢,再加上上面加锁的关系,卡住其他功能操作;还有录像结束后会自动停止实时图像传输,因此在停止录像后需要等待几秒再打开实时图像传输;并且打开录像模式之后,实时图像传输明显变卡;综合以上原因,我决定不打开录像模式,而是在实时图像传输时保存视频帧;

  佳能相机在30分钟未操作后,会自动进入休眠模式,需要通电(或关闭再打开相机)才能调用,这里的解决方案是,创建了相机对象,只要不调用Dispose方法,即使初始化失败,当相机重新连接时,会自动初始化并打开实时图像传输;

 

posted @ 2020-11-13 16:18  孤独成派  阅读(1928)  评论(0编辑  收藏  举报