ガウシアンビームの中心座標を自動で算出
概要
ライブ映像上に十字線(重心)をオーバーレイ表示します。拡大縮小しても太さ/見た目が安定するように描画し、重心は OpenCV(OpenCvSharp)で二値化 → 最大ブロブ → 強度重み付きモーメントから求めています。
ここで使用する照明は、懐中電灯のような輝度分布がガウシアンに近い局所的な光源を対象としています。二値化やブロブ抽出は「明確に光っている部分」と「背景」が分離できることを前提としているため、均一に照らされた照明環境や複雑な模様の照明では適切に動作しません。その場合は、しきい値の動的調整や異なる特徴量(輪郭抽出、エッジ解析など)を組み合わせる必要があります。
以下にプログラムのロジックの要約を記載しますのでパラメータやロジックの参考にしてください。
サンプルプログラム
利用した開発環境 | Visual Studio™ 2022 |
---|---|
SDK | IC Imaging Control 3.5(Python, C#, VB.NET) |
デバイスドライバ | Cam33U_setup,gigecam_setup,usbcam,AFU420_setup,usb2pro_drv |
デバイス | TISカメラ全般(MIPI CSI-2&FPD-Link IIIカメラを除く) |
サンプル(C#) | Beam_Profile_Measurement_Tool_cs_3.5.zip |
サンプル(VB.NET) | ー |
exeファイル アプリケーション |
ー |
別途ファイル | ー |
関連参照URL | ー |
解説
Form読み込み時の処理
private OverlayBitmap _overlayBitmap;
private FrameQueueSink _frameSink;
private volatile Mat _latestMat;
private readonly object _matLock = new object();
private int _zoom10 = 10; // 10=1.0x
private volatile bool _isStopping = false;
private void Form1_Load(object sender, EventArgs e)
{
icImagingControl1.MouseWheel += icImagingControl1_MouseWheel;
icImagingControl1.ShowDeviceSettingsDialog();
if (!icImagingControl1.DeviceValid) { cmdStartLive.Enabled=false; cmdSettings.Enabled=false; return; }
icImagingControl1.LiveDisplayDefault = false;
icImagingControl1.LiveDisplaySize = icImagingControl1.Size;
_frameSink = new FrameQueueSink(ShowImage, new FrameType(MediaSubtypes.RGB32), 5);
icImagingControl1.Sink = _frameSink;
icImagingControl1.OverlayBitmapPosition = PathPositions.Display;
_overlayBitmap = icImagingControl1.OverlayBitmapAtPath[PathPositions.Display];
_overlayBitmap.Enable = true;
cmdStoplive.Enabled = false;
}
ここでは、フォームが読み込まれるタイミングでライブ表示の制御、デジタルズーム操作、オーバーレイ描画機能の有効化を行っています。
まず、MouseWheelイベントに後述のicImagingControl1_MouseWheel関数を登録することで、Ctrlキーを押しながらマウスホイールを操作することでズーム倍率の調整ができるようになります。
次に、オーバーレイ描画の対象パスをDisplayに指定しています。これにより、表示パス上で十字線を描画するための設定が整います。 そして、カメラからの画像データを処理するために、RGB32形式に限定したFrameQueueSinkを定義しています。このSinkは後述のShowImageのコールバック関数を通じて各フレームごとに処理(画像表示など)を実行します。そのSinkを icImagingControl1.Sink に設定することで、取得された映像がこのパイプラインを通して処理されるようになります。 最後に、OverlayBitmapAtPath[Display] を有効にすることで、ライブ映像上にオーバーレイを描画する準備が整います。
Ctrl+マウスホイールで画面の拡大縮小
private void icImagingControl1_MouseWheel(object sender, MouseEventArgs e)
{
if ((Control.ModifierKeys & Keys.Control) != Keys.Control) return;
if (icImagingControl1.LiveDisplayDefault) return;
int value = (e.Delta * SystemInformation.MouseWheelScrollLines / 360) + _zoom10;
if (1 <= value && value <= 80)
{
_zoom10 = value;
icImagingControl1.LiveDisplayZoomFactor = _zoom10 / 10.0f;
}
}
ここではCtrlキーを押しながらマウスホイールを回転させることで、IC Imaging Control上のライブ映像のズーム倍率を変更することができます。まず、Control.ModifierKeysを用いてCtrlキーが押されているかを確認します。ホイールの回転量は e.Deltaから取得し、移動量を調整してから現在の倍率intsldZoom に加算します。最後に倍率は0.1単位で設定することで、拡大縮小が滑らかに行えるようにしています。
FrameQueueSinkのコールバック関数
private FrameQueuedResult ShowImage(IFrameQueueBuffer buffer)
{
using (Bitmap bmp = FrameExtensions.CreateBitmapCopy(buffer))
{
Mat mat = null;
try
{
mat = OpenCvSharp.Extensions.BitmapConverter.ToMat(bmp); // 8UC3(BGR)
lock (_matLock)
{
_latestMat?.Dispose();
_latestMat = mat;
mat = null; // 所有権移譲
}
}
finally { mat?.Dispose(); }
}
return FrameQueuedResult.ReQueue;
}
ShowImage は、FrameQueueSinkのコールバック関数でカメラから送られてくる全フレームをBitmapからOpenCV Matに変換し、icImagingControl1_OverlayUpdateで使用するために_latestMatに保存するための処理しています。最後に、バッファを再利用可能な状態に戻すため FrameQueuedResult.ReQueue を返しています。
オーバーレイ描画のアップデート(重心算出→十字線描画)
private readonly Font _overlayFont = new Font("Arial", 32, FontStyle.Bold);
private void icImagingControl1_OverlayUpdate(object sender, ICImagingControl.OverlayUpdateEventArgs e)
{
// 停止中、デバイス無効、ライブ映像が停止中なら処理を抜ける
if (_isStopping || !icImagingControl1.DeviceValid || !icImagingControl1.LiveVideoRunning) return;
// オーバーレイオブジェクトを取得
var ob = e.overlay;
ob.DropOutColor = Color.Magenta; // 透過色(マゼンタ)を設定
ob.Fill(ob.DropOutColor); // オーバーレイ全体を透過色で塗りつぶし初期化
Mat src = null;
// 最新の画像フレームをスレッドセーフにコピー
lock (_matLock) { if (_latestMat != null) src = _latestMat.Clone(); }
if (src == null) return; // フレームが取得できなければ終了
try
{
// グレースケール画像からレーザーの重心座標を取得
if (TryCentroidLaserGray(src, out var c, flipVertical:false))
{
// 重心座標を四捨五入して整数化
int x = (int)Math.Round(c.X);
int y = (int)Math.Round(c.Y);
// オーバーレイ描画用Graphicsを取得、太さ6pxの赤ペン作成
using (var g = ob.GetGraphics())
using (var pen = new Pen(Color.Red, 6))
{
// 座標値を赤文字で表示(少し右下にオフセット)
g.DrawString($"{x},{y}", _overlayFont, Brushes.Red, new PointF(x + 6, y + 6));
// クロスヘア(水平線)
g.DrawLine(pen, x - 80, y, x + 80, y);
// クロスヘア(垂直線)
g.DrawLine(pen, x, y - 80, x, y + 80);
}
}
}
finally { src.Dispose(); } // Matリソースを解放
}
このコードはIC Imaging ControlのOverlayUpdateイベントで、レーザーの重心位置をライブ映像に重ねて表示します。カメラが有効かつライブ中でない場合は中断し、オーバーレイを初期化します。最新フレームをコピーし、OpenCVで重心座標を取得。成功時には座標を整数化し、太い赤ペンでクロスヘアを描画し座標を文字表示します。
重心の位置を計算
public static bool TryCentroidLaserGray(Mat imgInput, out Point2d center, bool flipVertical = false, Rect? roi = null, int blurKsize = 3)
{
// ---- 初期値と入力チェック ----
center = new Point2d(-1, -1); // 失敗時の戻り用に仮の座標を入れておく
if (imgInput == null || imgInput.Empty()) return false; // 入力画像が無い/空なら処理しない
Mat imgRoi = null, work = null, gray = null;
Mat mask = null, labels = null, stats = null, cents = null, maskLargest = null, f32 = null, weighted = null;
try
{
// 1) 平滑化処理
// 目的:軽い平滑化でノイズや微小な輝点を抑え、後の二値化・ブロブ抽出で外れ値が出ないようにする
// 均一照明や模様の多い背景では逆に精度が下がることもあるので注意。
work = new Mat();
if (blurKsize > 1)
Cv2.GaussianBlur(imgInput, work, new OpenCvSharp.Size(blurKsize, blurKsize), 0);
else
imgInput.CopyTo(work);
// 2) Gray(Otsu要件:8UC1 or 16UC1)
// 目的:二値化や連結成分解析のためにグレースケールに変換。
if (work.Channels() > 1)
{
gray = new Mat();
Cv2.CvtColor(work, gray, ColorConversionCodes.BGR2GRAY);
}
else
{
gray = work.Clone();
}
// 3) 二値化(Otsu or 固定しきい値)
// 目的:明るい領域(スポット)と背景を白黒に分ける。
mask = new Mat();
Cv2.Threshold(gray, mask, 0, 255, ThresholdTypes.Binary | ThresholdTypes.Otsu);
//Cv2.Threshold(gray, mask, 125, 255, ThresholdTypes.Binary); // 固定値で二値化
if (_isBinaryDisplay)
{
// デバッグ表示:二値化結果を目視で確認
Cv2.NamedWindow("Binary", WindowFlags.Normal);
Cv2.ImShow("Binary", mask);
Cv2.ResizeWindow("Binary", 800, 600);
Cv2.WaitKey(1);
}
else
{
Cv2.DestroyAllWindows();
}
// 4) 最大ブロブ
// 目的:白領域の塊をラベリングし、最も大きな領域=目的のスポットを選ぶ。
labels = new Mat(); stats = new Mat(); cents = new Mat();
int n = Cv2.ConnectedComponentsWithStats(mask, labels, stats, cents, PixelConnectivity.Connectivity8);
if (n <= 1) return false;
int maxIdx = 1;
int maxArea = stats.Get<int>(1, (int)ConnectedComponentsTypes.Area);
for (int i = 2; i < n; i++)
{
int area = stats.Get<int>(i, (int)ConnectedComponentsTypes.Area);
if (area > maxArea) { maxArea = area; maxIdx = i; }
}
// 5) largest mask
// 目的:最大ブロブだけを残したマスクを作成
maskLargest = new Mat();
Cv2.Compare(labels, maxIdx, maskLargest, CmpType.EQ);
// 6) 重み付きモーメント
// 目的:輝度を重みにした重心を計算。スポットがガウシアン状に光っている前提で最適。
f32 = new Mat();
gray.ConvertTo(f32, MatType.CV_32F);
weighted = new Mat();
Cv2.BitwiseAnd(f32, f32, weighted, maskLargest);
Moments m = Cv2.Moments(weighted, false);
if (m.M00 <= 0) return false;
center = new Point2d(m.M10 / m.M00, m.M01 / m.M00);
return true;
}
finally
{
if (weighted != null) weighted.Dispose();
if (f32 != null) f32.Dispose();
if (maskLargest != null) maskLargest.Dispose();
if (cents != null) cents.Dispose();
if (stats != null) stats.Dispose();
if (labels != null) labels.Dispose();
if (mask != null) mask.Dispose();
if (gray != null) gray.Dispose();
if (work != null) work.Dispose();
if (imgRoi != null) imgRoi.Dispose();
}
}
1)平滑化 — Cv2.GaussianBlur
Cv2.GaussianBlur(imgInput, work, new OpenCvSharp.Size(blurKsize, blurKsize), 0);
- imgInput : 入力画像(通常はROI領域)。
- work : 出力画像(平滑化済み画像)。
- new Size(blurKsize, blurKsize) : フィルタ窓(カーネル)の大きさ。大きいほど強いぼかしになりますが、レーザースポットが広がりすぎると重心がズレるリスクがあります。フィルタ窓は画像上を移動させて演算する行列で、中央を基準にするためblurKsizeは必ず奇数を指定する必要があります。
- 0 (sigmaX) : ガウシアン分布の標準偏差。「0」を指定すると、カーネルサイズに基づいてOpenCVが自動的に計算します。標準偏差を大きく指定すると滑らかになりますが、本来の情報が失われてしまいます。
画像を平滑化して、後続の二値化処理でノイズや外れ値が影響しないようにします。小さな輝点やノイズのばらつきを抑えることで、重心計算の精度を上げることができます。
2)グレースケール変換 — Cv2.CvtColor
Cv2.CvtColor(work, gray, ColorConversionCodes.BGR2GRAY)
- work:平滑化後の入力画像(BGR形式、3ch)
- gray:変換後のグレースケール画像(1ch)
- ColorConversionCodes.BGR2GRAY:BGR → Gray 変換を行う指定コード
画像はカメラの設定によって Y800(モノクロ8bit)で取得されますが、このプログラムでは一度Bitmapを経由して取り込んでいます。その際に BGR形式(3チャンネル)になっているので、各ピクセルがB,G,Rの3成分(3バイト)を持つ CV_8UC3になります。しかし、後述の二値化処理Cv2.Thresholdでは1チャンネル(グレースケール)を前提としているため、そのままでは使えません。そこで、CV_8UC3を明るさのみのCV_8UC1に変換するためにCv2.CvtColorを利用します。この変換により、モノクロの輝度値だけの画像にし、二値化処理を実行できるようにしています。
3) 二値化 — Cv2.Threshold
Cv2.Threshold(gray, mask, 125, 255, ThresholdTypes.Binary)
- gray:入力(グレースケール画像、CV_8UC1)
- mask:出力(二値化画像)
- 125:閾値(ここで境界を決める)
- 255:白にする値
- Binary:二値化方式(白/黒に分類)
Cv2.Threshold は「ある輝度を境に白と黒に分ける」処理です。例えば固定閾値を125にすると、輝度が125以上 → 白(255)、輝度が125未満 → 黒(0)に画素を分類できます。 なお、ThresholdTypes.Otsu を引数で指定すると閾値を自動的に計算してくれます。
Cv2.Threshold(gray, mask, 0, 255, ThresholdTypes.Binary | ThresholdTypes.Otsu)
Otsu法ではヒストグラムを解析し、画素を「暗いクラス」と「明るいクラス」に仮分類しながら全ての閾値候補を調べます。それぞれのクラスについてピクセル数の割合(重み)と平均輝度を計算し、クラス間の分散を算出し、その分散が最大となる閾値を「最適なしきい値」として選択します。これにより、明暗わかりやすい画像(レーザー点や強いスポット光)では自動で適切なしきい値が決まります。ただし、背景が均一に明るい、模様が多い場合は、Otsu法が安定せず誤判定しやすいので、その場合は固定閾値にしてください。
4)白いエリア(ブロブ)をグループ分けする — Cv2.ConnectedComponentsWithStats
int n = Cv2.ConnectedComponentsWithStats(mask, labels, stats, cents, PixelConnectivity.Connectivity8)
- mask:入力画像(二値化画像)ここで使うのは先述のThresholdで作成した二値化の白黒の画像。
- labels(出力):各ピクセルのラベル番号が格納された画像。各ピクセルが「どのブロブに属しているか」を表します。例えば背景は「0」、1番目のブロブは「1」、2番目は「2」…と番号が割り振られます。見た目はただのグレー画像に見えますが、実際は整数のラベルが各ピクセルに格納されています。
- stats(出力):各ブロブの統計情報(位置・サイズ、面積など)。
- cents(出力):ピクセルの分布から求めた重心(x, y座標)。そのブロブに属する画素を全部同じ重さで平均して算出しているので輝度の強弱は一切考えず白い領域の真ん中を返します。
- PixelConnectivity.Connectivity8:隣接の定義のことで上下左右+斜めもつながっていれば塊とみなしています。
- n(戻り値):ブロブ数背景も含むので、例えばレーザーの点が1個なら n=2(背景+白い領域)になります。
二値化された画像の中から複数の明るい領域を見つけて、その中で一番大きい領域(レーザー点)を特定します。
補足
labelsの画像は各ピクセルに番号が書かれた地図(ラベルマップ)になっていて下記のようなデータになっています。(数字はブロブ番号):
0 0 1 1 0
0 2 2 1 0
0 2 2 0 0
int maxIdx = 1;
int maxArea = stats.Get<int>(1, (int)ConnectedComponentsTypes.Area);
for (int i = 2; i < n; i++)
{
int area = stats.Get<int>(i, (int)ConnectedComponentsTypes.Area);
if (area > maxArea) { maxArea = area; maxIdx = i; }
}
stats.Get(i, (int)ConnectedComponentsTypes.Area)i番目のブロブの面積を取り出しています。maxIdx は「最大面積を持つブロブのインデックス」。つまり一番大きい塊(多分レーザースポット)を選んでいます。
5)白いエリアの最大面積を抽出 — Cv2.Compare
Cv2.Compare(labels, maxIdx, maskLargest, CmpType.EQ)
- labels(入力):画像は先述の各ピクセルのラベル番号が格納された画像。ConnectedComponentsWithStatsの出力で、各ピクセルが「どのブロブに属するか」を示す番号(ラベル)が格納されています。背景は「0」、1番目のブロブは「1」、2番目は「2」…と番号が振られています。
- maxIdx:整数値(int)「最大の面積を持つブロブ」のラベル番号です。例: もし2番目のブロブが最大なら maxIdx = 2。labelsの各ピクセルの値と、このmaxIdxを比較します。
- maskLargest:出力先の画像(モノクロ8bit)結果が白黒の二値画像として格納されます。labelsの値が maxIdxと等しいピクセル → 白(255)それ以外 → 黒(0)、つまり「最大ブロブだけを切り出したマスク画像」が作成されます。
- CmpType.EQ:比較条件EQ = Equal(等しいかどうか)。labelsの各画素がmaxIdxと同じなら白、違えば黒にしています。
余分なノイズ成分を削除するために、ラベル画像labelsの中から最大ブロブmaxIdxに一致する部分だけを白にしたマスク画像maskLargestを作成しています。
補足
最大ブロブが「2番」だった場合 (maxIdx=2)、maskLargestは下記の通りになります(255=白, 0=黒):
0 0 0 0 0
0 255 255 0 0
0 255 255 0 0
6)輝度重み付き重心
f32 = new Mat();
gray.ConvertTo(f32, MatType.CV_32F);
- gray:二値化前のグレースケール画像(値は0〜255の整数)。
- ConvertTo:画素の型をCV_32F(32bit浮動小数点)に変換しています。後で「明るさの重み付き計算(モーメント)」を行うときに、整数型のままだと計算精度が不足するため浮動小数点にしています。小数も扱えるようにすることで、より正確な重心計算が可能になります。
- f32:出力の浮動小数点画像。grayと同じ見た目の画像ですが、内部表現がfloat(浮動小数点)形式になっています。
まず、2)のグレースケール変換で得た画像grayを重心位置の計算がしやすくなるように32bit浮動小数点の形式にします。
weighted = new Mat();
Cv2.BitwiseAnd(f32, f32, weighted, maskLargest);
- f32:入力画像(浮動小数点グレースケール)。
- weighted:マスクが白い部分の値を残し、それ以外を0(黒)にした画像を出力します。
- maskLargest:最大ブロブだけを残したマスク(白=対象領域、黒=背景)。
レーザー領域の輝度分布を使って「光の中心(重心)」を求めます。6)の最大ブロブのマスク画像と2)のグレースケール変換で求めた輝度値を重ね合わせて、必要な部分だけを切り出した二値化前のモノクロ変換画像を抽出します。 Cv2.BitwiseAndは画像同士をAND演算する関数です。切り出した画像maskLargestに対して元のグレースケールの画像を重ね合わせています。maskLargestが白の場所ではf32の値をそのままコピー、黒の場所は0にしています。背景やノイズを除外し、「レーザー領域の輝度分布だけ」を切り出しています。背景やノイズを除外したweightedを使えば、レーザー光の部分の明るさ分布だけで重心を計算できます。
Moments m = Cv2.Moments(weighted, false);
- weighted:レーザー領域のみの強度画像。
- false:二値モードではなく、画素の「明るさの値」を重みとしてモーメントを計算する指定。
次にモーメントを計算します。モーメントとは画像の「位置」「広がり」「傾き」などを数式で表すための値で、重心(中心座標)を求めるときによく使われます。
関数 Cv2.Moments を呼び出すと、下記の値が返されます。
- M00:0次モーメント = 画像の「重みの総和」(グレースケールの場合は画素値の合計。すなわち光の総強度)
- M10:1次モーメント(X方向) = 各ピクセルの x座標 × 画素値の総和
- M01:1次モーメント(Y方向) = 各ピクセルの y座標 × 画素値の総和
このとき求められる重心は輝度値の重み付き重心ですので、領域内の各画素の「明るさ」を重みとして計算した中心点を表します。
if (m.M00 <= 0) return false;
center = new Point2d(m.M10 / m.M00, m.M01 / m.M00);
- m.M10 / m.M00:X座標の重心。明るさで重み付けした平均X位置(x座標×光の強さの合計 ÷ 光の強さの合計)。
- m.M01 / m.M00:Y座標の重心。明るさで重み付けした平均Y位置(y座標×光の強さの合計 ÷ 光の強さの合計)。
結果として (x, y) が「レーザー光の明るさを考慮した重心」になります。