QueueSinkでMetaDataをCSV出力しながらマルチスレッドで画像処理
概要
このサンプルプログラムは、2台のカメラから同時に映像を取得し、各フレームに付加されたタイムスタンプやフレーム番号などのMetaDataをCSVファイルに記録する処理を実装したものです。本サンプルでは、QueueSinkのFramesQueuedコールバック内で取得したフレームをスレッドプール(Task.Run)で非同期に処理することで、重い画像処理の最中でも次のフレームの受信を妨げない「マルチスレッド構成」を実現しています。これにより、コールバック内の処理遅延が画像取得のパフォーマンスに与える影響を最小限に抑えることが可能になります。
比較対象であるシングルスレッド版では、コールバック処理中に次のフレームがブロックされるため、実際のフレームレートが低下します。一方、本マルチスレッド版では、フレーム処理を別スレッドで並列化することで、設定通りの20fps(50ms間隔)の取得が継続されるかを検証できます。
CSVファイルは、Debugモードで実行した場合、以下のディレクトリに保存されます:
ImageBufferOpenCVLive_multithreading\bin\Debug\net6.0\logs
サンプルプログラム
Software | IC Imaging Control 4.0, Visual Studio™ 2022 |
---|---|
サンプル(C#) | dl/ImageBufferOpenCVLive_multithreading_cs_4.0.zip |
解説
ログファイルの準備
Directory.CreateDirectory(LogDir);
_logCam1 = new StreamWriter(Path.Combine(LogDir, "cam1_log.csv"), true, Encoding.UTF8) { AutoFlush = true };
_logCam2 = new StreamWriter(Path.Combine(LogDir, "cam2_log.csv"), true, Encoding.UTF8) { AutoFlush = true };
_logCam1.WriteLine("TimeStamp,Elapsed_ns,FrameNumber");
_logCam2.WriteLine("TimeStamp,Elapsed_ns,FrameNumber");
この部分では、ログファイルを出力するためのディレクトリとCSVファイルを準備しています。LogDirは実行ディレクトリ配下の logs フォルダを想定しており、存在しない場合には自動で作成しています。
ImageBufferOpenCVLive_multithreading\bin\Debug\net6.0\logs
続いて、cam1_log.csvおよびcam2_log.csvの2つのファイルを StreamWriter で開きます。trueを指定しているため追記モードとなり、すでにファイルが存在していても上書きされず内容が追加される形式になります。
IC4ライブラリの初期化
ic4.Library.Init(apiLogLevel: ic4.LogLevel.Info, logTargets: ic4.LogTarget.WinDebug);
IC4のすべての機能を使用可能にするためには、プログラムの冒頭でこの初期化処理が必要です。この一行でIC4の内部モジュールが読み込まれ、APIが正しく動作するようになります。ここではログ出力レベルにInfoを指定しており、通常の情報・警告・エラーのログが出力されます。また、ログ出力先にWinDebugを指定することで、Visual Studioの「出力」ウィンドウなど、Windowsのデバッグ環境でログを即時確認できるようになっています。
デバイスの選択
var deviceInfo1 = ic4.ConsoleHelper.PresentUserChoice(ic4.DeviceEnum.Devices, d => d.ModelName, "Select device #1:");
var deviceInfo2 = ic4.ConsoleHelper.PresentUserChoice(ic4.DeviceEnum.Devices, d => d.ModelName, "Select device #2:");
if (deviceInfo1 == null || deviceInfo2 == null) return;
DeviceEnum.Devicesによって取得された接続中のカメラの一覧をコンソールに表示し、それぞれのモデル名をもとにユーザーが選択できるようになっています。
ここでは1台目と2台目のカメラを個別に選ぶ構成となっており、どちらか一方でも選択がキャンセルされた場合には return により処理を中断しています。
Grabber(画像取得用オブジェクト)の生成とカメラ接続
var grabber1 = new ic4.Grabber();
var grabber2 = new ic4.Grabber();
grabber1.DeviceOpen(deviceInfo1);
grabber2.DeviceOpen(deviceInfo2);
ここでは各々のカメラとソフトウェアの間を接続するためにGrabberオブジェクトを2つ生成し、選択されたカメラデバイスにそれぞれ接続します。GrabberはIC4における中心的な役割を担うクラスであり、画像の取得、ストリームの制御、プロパティ設定などをすべてこのオブジェクトを通じて行います。DeviceOpen()メソッドによって、Grabberがカメラに接続されると、以後の各々のカメラの画像取得や制御処理が有効になります。
カメラの撮影条件(フレームレートとトリガーモード)の設定
var propertyMap1 = grabber1.DevicePropertyMap;
var propertyMap2 = grabber2.DevicePropertyMap;
propertyMap1.SetValue(ic4.PropId.AcquisitionFrameRate, 20);
propertyMap1.SetValue(ic4.PropId.TriggerMode, false);
propertyMap2.SetValue(ic4.PropId.AcquisitionFrameRate, 20);
propertyMap2.SetValue(ic4.PropId.TriggerMode, false);
Grabberが接続された状態であれば、各カメラの撮影条件を変更することが可能です。ここではフレームレートとトリガーモードの2点が設定されています。まず、DevicePropertyMapを通じてカメラのパラメータにアクセスし、フレームレートを「20fps」に設定します。これは1秒間に20枚のフレームが出力される設定で、1フレームあたりおよそ50ミリ秒の間隔に相当します。
次に、トリガーモードをfalseに設定しています。これにより、カメラは外部信号によって撮影タイミングを制御されるのではなく、設定フレームレートに基づいて自動的にフレームを出力し続ける「フリーラン」動作になります。
映像表示用ウィンドウの作成と「画面を閉じる」で処理を終了する設定
var display1 = new ic4.FloatingDisplay { RenderPosition = ic4.DisplayRenderPosition.StretchCenter };
var display2 = new ic4.FloatingDisplay { RenderPosition = ic4.DisplayRenderPosition.StretchCenter };
var windowClosed = new ManualResetEventSlim(false);
display1.WindowClosed += (_, __) => windowClosed.Set();
display2.WindowClosed += (_, __) => windowClosed.Set();
それぞれのカメラの映像を表示するためのウィンドウのAPI(FloatingDisplay)を使用します。ユーザーがどちらかの表示ウィンドウを閉じた際に、メイン処理を終了させるための仕組みとしてManualResetEventSlimを使用しています。WindowClosedイベントが発火するとSet()が呼ばれ、これによりメインスレッドが待機から解放され、プログラムの終了処理するようになっています。
QueueSink(画像受信バッファ)の作成
var sink1 = new ic4.QueueSink(ic4.PixelFormat.BGR8, maxOutputBuffers: 1);
var sink2 = new ic4.QueueSink(ic4.PixelFormat.BGR8, maxOutputBuffers: 1);
各Grabberから取得される画像データを一時的に保持し、後述する処理に渡すための受信バッファであるQueueSinkを作成します。ここではOpenCVと互換性のあるカラーフォーマットとしてPixelFormat.BGR8(8bit・BGR順)を指定します。また、maxOutputBuffersに「1」を指定することで、QueueSinkが新しいフレームをリングバッファに保持するのは最大1枚としています。
FramesQueued イベント(フレーム受信時の処理)マルチスレッド
// 開始基準時刻(経過時間を ms で残したい場合)
ulong start1 = 0;
ulong start2 = 0;
ulong prev; // スレッドローカルで経過計算
var pool1 = new BufferPool(); // ★ コピー先を発行するプール
var pool2 = new BufferPool(); // ★ コピー先を発行するプール
// --- CAM1 ---
sink1.FramesQueued += (s, e) =>
{
using (ImageBuffer buffer1 = sink1.PopOutputBuffer())
{
var copy = pool1.GetBuffer(buffer1.ImageType);
copy.CopyFrom(buffer1);
var ts = buffer1.MetaData.DeviceTimestampNs;
// CSV ログ
var elapsed = (copy.MetaData.DeviceTimestampNs - start1);
prev = ts;
lock (Lock1)
_logCam1.WriteLine($"{copy.MetaData.DeviceTimestampNs.ToString():O},{elapsed:F3},{copy.MetaData.DeviceFrameNumber.ToString():O}");
start1 = copy.MetaData.DeviceTimestampNs;
Task.Run(() =>
{
using (copy)
{
// OpenCV 処理
var mat = copy.CreateOpenCvWrap();
Cv2.PutText(mat, "OverLay MultiThread",
new Point(100, 100),
HersheyFonts.HersheySimplex, 1,
new Scalar(255, 0, 0), 2);
display1.DisplayBuffer(copy);
Thread.Sleep(200); // 200msの重い処理の例
}
});
}
};
この部分では、1台目のカメラに対応するQueueSink(sink1)に画像が届いたときに自動的に呼び出されるコールバック処理が定義されています。FramesQueuedはIC4におけるイベントで、カメラからの新しいフレームがPCに入ってきたタイミングで非同期に発火します。関数内ではまず、PopOutputBuffer()を呼び出して最新のフレームを取り出します。その後、BufferPoolを使って新しいバッファを確保し、フレームをコピーします。コピーされたフレームのメタデータ(タイムスタンプ・フレーム番号)をCSVに記録し、それをCSVファイルに書き込んでいます。その後、Task.Runを使って、OpenCVの画像処理(「OverLay MultiThread」のオーバーレイ表示と表示処理を非同期で実行しています。なお、このイベント処理はカメラ2(sink2)に対しても同様の構造で記述されており、処理内容は同じです。
この構成では、フレームのコピーとログ記録はイベント内で即座に処理し、重い画像処理(ここでは200msのSleepで模擬)はメインのコールバックとは別スレッドで並列実行されるため、QueueSinkはすぐに次のフレームを受信できます。
マルチスレッドについて
青いバー | カメラからフレームが到着した際の受信イベント(QueueSinkのFramesQueued)でフレームを取得します。 |
---|---|
緑のバー | 受信したフレームをコピーし、タイムスタンプやフレーム番号をCSVに記録する処理です。ほとんど処理時間はかかりません。 |
赤いバー | 200msかかる重い処理(Thread.Sleep(200)で模擬)であり、マルチスレッド構成ではTask.Runによって非同期・並列に実行されます。 |
シングルスレッド構成では、FramesQueuedイベント内で画像処理・表示・ログ記録など、すべての処理を1つのスレッドで直列に実行します。そのため、たとえば画像処理に200msかかる場合、次のフレームの受信はそれが完了するまで待たされます。たとえカメラが20fps(=50ms間隔)でフレームを出力していても、実際には約200msごとにしかフレームを処理できないため、その間にカメラから送られてくるフレームは取得できません。ただし、表示だけなど処理負荷が軽いケースでは、シングルスレッド構成でも十分な性能が得られる場合があります。
マルチスレッド構成では、FramesQueuedイベント内でフレームをコピーし、メタデータをCSVに記録するところまでを素早く処理します。その後、画像処理や表示などの重い処理は Task.Runによりバックグラウンドで非同期実行されます。この構成により、FramesQueuedのイベント処理は数ms~数十msで完了するため、QueueSinkは次のフレームを50ms間隔で安定して受信することができ、カメラの設定通り20fpsを維持可能です。重い処理が並列に走るため安定してFramesQueuedのイベント処理が発生します。
ただし、非同期タスクが増えるほどメモリを多く消費するため、メモリーリークが起きないようにメモリ使用量の管理には注意が必要です。特に高解像度や高フレームレートの環境では、タスク数やバッファプールのサイズ設定に配慮が必要です。
ストリームの開始
grabber1.StreamSetup(sink1);
grabber2.StreamSetup(sink2);
それぞれのGrabberに対してQueueSinkをストリームの出力先として設定しています。StreamSetup()は、カメラから送られてくる映像ストリームがどのように処理されるかを構成するための重要な関数です。
ここで指定されたSink(sink1とsink2)がGrabberに関連付けられると、以降はカメラからの映像がこのSinkに流れ込み、そこでFramesQueuedのコールバックの処理が行われるようになります。
ウィンドウが閉じられるまで待機
windowClosed.Wait();
windowClosed は、ウィンドウが閉じられたことを検知するための同期オブジェクトで、ウィンドウが閉じられるまで待機しています。
終了処理
grabber1.StreamStop();
grabber2.StreamStop();
_logCam1?.Dispose();
_logCam2?.Dispose();
ウィンドウが閉じられ、Wait()が解除されると、すぐに終了処理が始まります。ここではまず、両方のGrabberに対してStreamStop()を呼び出し、カメラからの映像取得を停止させています。これにより、カメラ側のフレーム出力は中断され、QueueSinkへのフレーム流入も止まります。その後、ログファイルのStreamWriterに対してDispose()を呼び出すことで、開かれていたファイルリソースを安全に閉じます。