OpenCVの画像処理(アナログタコメーターのデジタル化)
概要
OpenCVを使用してアナログタコメーターの数値を読み取ります。マウス操作で円を描画し、その円周上のエッジを検出。検出したエッジの角度情報を基に、対応する数値を計算します。

サンプルプログラム
Software | IC Imaging Control 3.5, Visual Studio™ 2022 |
---|---|
サンプル(C#) | DistanceAndAngle_measurements_cs3.5.zip |
サンプルツールの外観
このセクションでは、画像上の線分管理に使用される主要フィールドについて解説します。linesは画像上に描画された線分を格納するリストで、始点と終点をTuple形式で保存します。selectedLineはユーザーが現在選択中の線分を格納し、線分の操作をスムーズに行えるようにします。また、isDraggingLineは線分をドラッグ中であることを示すフラグであり、ユーザー操作に対応したリアルタイムのフィードバックを可能にします。
Form Loadイベント
Formをロードしたタイミングでカメラデバイスの設定とオーバーレイ描画の初期化を行うコードです。
private void Form1_Load(object sender, System.EventArgs e)
{
icImagingControl1.LoadShowSaveDeviceState("lastSelectedDeviceState.xml");
if (!icImagingControl1.DeviceValid)
{
Close();
return;
}
icImagingControl1.Sink = new TIS.Imaging.FrameSnapSink();
icImagingControl1.LiveDisplayDefault = false;
// オーバーレイ初期化
_ob = icImagingControl1.OverlayBitmapAtPath[TIS.Imaging.PathPositions.Device];
_ob.Enable = true;
icImagingControl1.DeviceTrigger = false;
icImagingControl1.LiveStart();
}
このコードでは、LoadShowSaveDeviceStateを使用して前回使用したデバイスの設定を復元します。設定が無効な場合はアプリケーションを終了させます。また、カメラ画像を取得するためにFrameSnapSinkを設定し、オーバーレイ描画を有効化してリアルタイムでの視覚的フィードバックを可能にします。最後に、LiveStartを呼び出すことでカメラのライブ表示を開始します。
マウスホイールイベントによるズーム
Ctrlキーとマウスホイールを使用してズームを操作するコードです。
private void icImagingControl1_MouseWheel(object sender, MouseEventArgs e)
{
if ((Control.ModifierKeys & Keys.Control) == Keys.Control)
{
int value = e.Delta / 120;
intZoom += value;
if (intZoom < 1) intZoom = 1;
icImagingControl1.LiveDisplayZoomFactor = (float)intZoom / 10.0f;
}
}
この処理では、Control.ModifierKeysを使用してCtrlキーが押されているかを確認し、ズーム操作を有効化します。マウスホイールの回転量に応じてintZoomの値を調整し、LiveDisplayZoomFactorに反映させます。これにより、ユーザーはリアルタイムでズームインおよびズームアウトを制御できるようになります。
円の描画・編集
ユーザーが画像上に円を描画したり、編集するためのコードです。
マウスクリックの時の処理
private void icImagingControl1_MouseDown(object sender, MouseEventArgs e)
{
System.Drawing.Point clickPosition = e.Location;
float zoomFactor = icImagingControl1.LiveDisplayZoomFactor;
int adjustedX = (int)(clickPosition.X / zoomFactor);
int adjustedY = (int)(clickPosition.Y / zoomFactor);
System.Drawing.Point adjustedClickPosition = new System.Drawing.Point(adjustedX, adjustedY);
if (e.Button == MouseButtons.Left)
{
if (IsPointOnCircleEdge(adjustedClickPosition, circleCenter, circleRadius))
{
//円のエッジチェック
//クリック位置が円のエッジ付近であればリサイズモードに移行します。
isResizingCircle = true;
}
else if (IsPointInsideCircle(adjustedClickPosition, circleCenter, circleRadius))
{
//円内チェック
//クリック位置が円の内部であればドラッグモードに移行します。これは円の中心を移動する際に使用します。
isDraggingCircle = true;
dragStartPosition = adjustedClickPosition;
}
else
{
//新しい円の描画
//円がまだ描画されていない場合、現在のクリック位置を円の中心として新しい円を描画します
circleCenter = adjustedClickPosition;
circleRadius = 0;
isDrawingCircle = true;
}
}
}
このコードは、マウスの左クリックイベントに基づいて円の描画や編集操作を行います。クリック位置をズーム倍率に応じて調整し、位置が円のエッジ付近であればリサイズモード、内部であればドラッグモード、それ以外では新しい円を描画します。これにより、直感的な操作で円の編集が可能になります。
連続読み取り
ユーザーが画像上に円を描画したり、編集するためのコードです。
private void timer1_Tick(object sender, EventArgs e)
{
_ob_AnglePosition.Fill(_ob_AnglePosition.DropOutColor);
_ob_Mark.Fill(_ob_Mark.DropOutColor); // 前の描画をクリア
Cv2.WaitKey(100);
// ライブ画像を取得
TIS.Imaging.FrameSnapSink snapSink = icImagingControl1.Sink as TIS.Imaging.FrameSnapSink;
TIS.Imaging.IFrameQueueBuffer frm = snapSink.SnapSingle(TimeSpan.FromSeconds(5));
CheckBrightnessOnCircle(circleCenter, circleRadius, frm); // 確定した円周上の輝度確認
}
連続読み取りボタンをクリックしたときに下記のタイマーが500ms毎に繰り返し作動します。SnapSingleメソッドで画像を取得した後に撮影してCheckBrightnessOnCircleで指定した円周上でエッジを検出し、輝度解析を行っています。
輝度解析
private void CheckBrightnessOnCircle(System.Drawing.Point center, int radius, TIS.Imaging.IFrameQueueBuffer buffer)
{
Bitmap liveImage = buffer.CreateBitmapCopy();
if (liveImage == null)
{
MessageBox.Show("画像が取得できませんでした。");
return;
}
// 座標リスト
List<System.Drawing.Point> detectedPoints = new List<System.Drawing.Point>();
// BitmapをMatに変換
Mat matImage = OpenCvSharp.Extensions.BitmapConverter.ToMat(liveImage);
// グレースケール変換
Mat grayImage = new Mat();
Cv2.CvtColor(matImage, grayImage, ColorConversionCodes.BGR2GRAY);
// 二値化
Mat binaryImage = new Mat();
Cv2.Threshold(grayImage, binaryImage, 50, 255, ThresholdTypes.Binary);
// エッジ検出
Mat edges = new Mat();
Cv2.Canny(binaryImage, edges,100, 255);
Cv2.ImShow("edges", edges);
// 座標が画像範囲内か確認し、周辺ピクセルも含めてエッジの有無を判定。
List<Tuple<double, double>> detectedAnglesAndValues = new List<Tuple<double, double>>();
for (double angle = 0; angle < 360; angle += 1) // 1度刻みで計算
{
// 円周上の座標を計算
int x = (int)(center.X + radius * Math.Cos(angle * Math.PI / 180.0));
int y = (int)(center.Y + radius * Math.Sin(angle * Math.PI / 180.0));
// 画像範囲外をチェック
if (x < 0 || x >= edges.Width || y < 0 || y >= edges.Height)
continue;
// トップアラインメントの角度を計算
double topAlignedAngle = CalculateAngleFromCenter(new System.Drawing.Point(x, y), circleCenter);
bool isEdgeDetected = false;
// トップアラインメントの角度が開始角度より小さい場合
if (topAlignedAngle> startAngle || topAlignedAngle < endAngle)
{
// 周辺ピクセルも判定する
for (int offsetY = -2; offsetY <= 2; offsetY++) // 縦方向の近傍
{
for (int offsetX = -2; offsetX <= 2; offsetX++) // 横方向の近傍
{
int neighborX = x + offsetX;
int neighborY = y + offsetY;
// 範囲外のピクセルは無視
if (neighborX < 0 || neighborX >= edges.Width || neighborY < 0 || neighborY >= edges.Height)
continue;
// エッジが検出された場合
if (edges.At<byte>(neighborY, neighborX) == 255)
{
isEdgeDetected = true;
break;
}
}
if (isEdgeDetected) break;
}
}
// エッジが検出された場合のみ処理を続行
if (isEdgeDetected)
{
// 検出された座標をリストに追加
detectedPoints.Add(new System.Drawing.Point(x, y));
// 角度を値に変換
double value = MapAngleToValue(topAlignedAngle);
// 検出された角度と値をリストに追加
detectedAnglesAndValues.Add(new Tuple<double, double>(topAlignedAngle, value));
}
}
// オーバーレイを更新
UpdateOverlay();
// 結果を表示
if (detectedAnglesAndValues.Count > 0)
{
// 平均角度と平均値を計算
double averageAngle = Math.Round(detectedAnglesAndValues.Average(item => item.Item1), 2);
double averageValue = Math.Round(detectedAnglesAndValues.Average(item => item.Item2),2);
// 平均座標を計算
int avgX = (int)detectedPoints.Average(point => point.X);
int avgY = (int)detectedPoints.Average(point => point.Y);
System.Drawing.Point averagePoint = new System.Drawing.Point(avgX, avgY);
// 平均座標に目印を描画
_ob_Mark.DrawSolidEllipse(Color.Green, avgX - 20, avgY - 20, avgX + 20, avgY + 20);
// 平均値を表示
label_MeanValue.Text= $"{averageValue.ToString()}g";
// CSVに保存
SaveToCsv(DateTime.Now, averageValue);
// テキストボックスに履歴を追加
AddToListView(DateTime.Now, averageValue,10);
}
}
指定された円周上のエッジを検出し、その結果を解析する処理を行います。最初にライブ画像を取得し、OpenCvSharpを使用して画像をBitmapからMatに変換します。その後、グレースケール化し、さらに二値化することでエッジ検出の準備をします。エッジ検出にはCannyアルゴリズムを使用し、imshowで結果を表示します。

円周上の座標を1度刻みで計算し、各座標が画像範囲内であるかを確認します。さらに、エッジ検出の精度を高めるため、各座標の周辺ピクセルも評価します。エッジが検出された場合、対応する座標をリストに追加し、角度から値を計算して結果リストに記録します。
検出結果から平均値や平均座標を計算し、これをUI上に表示します。また、平均座標には目印を描画し、CSVファイルに検出データを保存します。履歴として結果をリストビューに追加し、過去のデータも参照できるようにしています。
補足:エッジ検出処理について
グレースケール変換、二値化、Canny関数を組み合わせることで、効率的かつ正確なエッジ検出が可能です。以下、それぞれの処理を詳細に解説します。
1.グレースケール変換(Cv2.CvtColor)
グレースケール変換は、カラー画像から不要な色情報を排除し、画像を輝度(明るさ)のみで表現します。これによりデータ量が削減され、後続処理が効率化されます。
Cv2.CvtColor(matImage, grayImage, ColorConversionCodes.BGR2GRAY);
主な引数
matImage (入力画像) |
BGR形式のカラー画像を指定。 |
---|---|
grayImage (出力画像) |
変換後のグレースケール画像が格納される。 |
ColorConversionCodes.BGR2GRAY (変換コード) |
BGR形式からグレースケール形式への変換を指定。 |
他の変換コード例
ColorConversionCodes.BGR2RGB | BGRからRGBに変換。 |
---|---|
ColorConversionCodes.BGR2HSV | BGRからHSV(色相・彩度・明度)に変換。 |
ColorConversionCodes.GRAY2BGR | グレースケールからBGRに変換。 |
2. 二値化(Cv2.Threshold)
二値化は、画像のピクセルを指定した閾値で白(255)または黒(0)に分類する処理です。これにより、対象物の輪郭や形状を強調できます。
Cv2.Threshold(src, dst, thresh, maxval, type);
主な引数
thresh (閾値) |
指定した値以下を黒、それ以上を白に分類。 例:thresh = 50 :明るい領域を多く白として検出。 thresh = 200:非常に明るい領域のみ白として検出。 |
---|---|
maxval (最大値) |
白色(255)の輝度値を設定。通常は255。 |
type (二値化タイプ) |
標準的な二値化(ThresholdTypes.Binary)や反転二値化(ThresholdTypes.BinaryInv)などを指定可能。 |
数値変更による影響
二値化における数値変更の影響は、画像の対象物と背景の分離に大きく影響します。閾値(thresh)を低く設定すると、多くのピクセルが白(255)に分類され、対象物が拡大されるように見えます。一方、閾値を高くすると、白い領域が減少し、対象物の細かい部分が消えやすくなります。通常、閾値の選択は、背景と対象物のコントラストや解析の目的に応じて調整されます。また、最大値(maxval)を変更することで、白の輝度を調整できますが、通常は255のままで使用されます。二値化タイプ(type)を変更すると、対象物と背景を反転させたり、特定の輝度範囲を強調したりすることが可能で、用途に応じて柔軟に調整できます。
3.Canny関数によるエッジ検出(Cv2.Canny)
Canny関数は正確かつ滑らかなエッジ検出に適しています。以下の手順で処理が行われます。
処理の流れ
(1)ノイズ除去
ガウスフィルタを適用して小さなノイズを除去
(2)勾配計算
Sobelフィルタ(平滑化フィルタ)で各ピクセルの輝度変化を計算し、微分からエッジの強さと方向を求める。
(3)非極大値の抑制
求めた勾配の方向から、エッジ検出には関係のない画素を取り除く。
(4)閾値処理
設定した2つの閾値からエッジ検出を行う。
Cv2.Canny(src, edges, threshold1, threshold2, apertureSize, L2gradient);
主な引数
threshold1 (低い閾値) |
微細なエッジを検出する基準値。 低い値:多くのエッジを検出(ノイズ増加)。 高い値:明確なエッジのみ検出。 |
---|---|
threshold2 (高い閾値) |
強いエッジを検出する基準値。 低い値:多くのエッジを検出。 高い値:強いエッジのみ検出。 |
apertureSize※省略可能 (カーネルサイズ) |
Sobelフィルタの範囲を指定(3 ~ 7)。 小さい値:細かいエッジを検出(ノイズ感度増加)。 大きい値:滑らかなエッジを生成(細かい特徴を見逃す)。 |
L2gradient※省略可能 (計算方法) |
エッジ強度の計算方法を指定。 true:計算精度向上(処理速度低下)。 false:高速処理。 |
数値変更による影響
Canny関数は、閾値やカーネルサイズを調整することでエッジ検出結果が大きく変わります。低い閾値(threshold1)は微細なエッジを検出しやすい反面、ノイズも多く含む可能性があります。高い閾値(threshold2)は明確なエッジのみを検出するため、滑らかでノイズの少ない結果が得られますが、細かいエッジを見逃すことがあります。カーネルサイズ(apertureSize)を小さくすると細部のエッジが検出されますが、ノイズ感度が上がります。逆に、大きなカーネルサイズではノイズが抑えられ、エッジが滑らかになりますが、細かい特徴を捉えにくくなります。これらのパラメータを調整することで、画像の特性や解析目的に応じた最適なエッジ検出が可能です。
CSVへの保存
結果をCSVファイルに保存する処理です。
private void SaveToCsv(DateTime timestamp, double averageValue)
{
string filePath = "results.csv";
using (StreamWriter writer = new StreamWriter(filePath, true))
{
if (!File.Exists(filePath))
{
writer.WriteLine("Date,Time,AverageValue");
}
writer.WriteLine($"{timestamp.ToShortDateString()},{timestamp.ToLongTimeString()},{averageValue:F2}");
}
}
このコードでは、結果を指定されたパスのCSVファイルに保存します。ファイルが存在しない場合にはヘッダー行を追加し、日時と平均値を1行ずつ追記します。これにより、検査結果を簡単に保存および共有できるようになります。