システム概略と検出メカニズム
この実装は、輸送用段ボールに既存のメーカーラベル(FBA マーケティングシール等)が存在する場合、新しい配送ラベルを適切に配置するための画像解析技術です。主に明度差(グレースケール値)を利用し、段ボールの素材色と印刷された白色ラベルの色調差分を検出することで、被覆箇所を特定します。
- ROI 抽出: カメラ視野とボックスサイズが固定であることを前提とし、物理寸法からピクセル座標への変換を行います。
- 閾値分析: 多数サンプルからの統計により、ラベル材特有の明るさ範囲を定義します。
- 領域判定: ラベル存在エリア以外の空きグリッドを探索し、衝突しない座標を返却します。
解像度換算と初期化パラメータ
まず、物理サイズ(mm)と画像解像度(DPI)の比率を計算して、ピクセル単位の作業基準を確立します。
// DPI から mm あたりのピクセル密度を算出
// 例:300DPI の場合、約 11.81 ピクセル/mm
private const double Dpi = 300.0;
private static readonly double PixelsPerMillimeter = Dpi / 25.4;
// 目標ラベルサイズ(mm)をピクセル単位へ変換
private const int TargetLabelWidthMm = 100; // 縦横 100mm と仮定
private static readonly int LabelPixelSize = (int)Math.Ceiling(TargetLabelWidthMm * PixelsPerMillimeter);
// ラベル特有の灰度値候補リスト(白系統)
private static readonly string[] WhitePaletteHex =
{
"#FFFFFF", "#FCFCFC", "#FEFEFE", "#E2E2E0",
"#DEDEDC", "#E0E0DE", "#CCCCCA", "#B2B2B0"
};
// ラベル輝度の最小・最大許容値
private static int MinLumThreshold;
private static int MaxLumThreshold;
画像ローディングと輝度レンジ演算
入力イメージを読み込み、事前収集されたホワイトパターンに基づいて二値化の閾値を設定する処理を行います。
public void Initialize(string imagePath)
{
// グレースケールで画像ロード
var srcMat = Cv2.ImRead(imagePath, ImreadModes.Grayscale);
if (srcMat.Empty()) throw new FileNotFoundException("Invalid Image Path");
DetermineLuminanceBoundaries();
Console.WriteLine($"有効な輝度範囲: {MinLumThreshold} - {MaxLumThreshold}");
ProcessAnalysis(srcMat);
srcMat.Dispose();
}
private void DetermineLuminanceBoundaries()
{
// 実際のコードでは Hex を Gray に変換して min/max を取得
var grayValues = WhitePaletteHex.Select(Conversion.HexToGrayscale);
MinLumThreshold = grayValues.Min();
MaxLumThreshold = grayValues.Max();
}
物理サイズに基づく領域クリップ
カメラ設置高さとボックス配置が固定されているため、画像の右下端から箱の物理的範囲に合わせて画像を切り取ります。
private Mat ExtractBoxArea(Mat source, float widthMm, float heightMm)
{
int imgW = source.Cols;
int imgH = source.Rows;
// mm から px へ変換
int cropW = (int)(widthMm * PixelsPerMillimeter);
int cropH = (int)(heightMm * PixelsPerMillimeter);
// 切り取り開始点(右下寄せ)
int startX = Math.Max(0, imgW - cropW);
int startY = Math.Max(0, imgH - cropH);
// ブードル外への逸脱防止
cropW = Math.Min(cropW, imgW - startX);
cropH = Math.Min(cropH, imgH - startY);
if (cropW <= 0 || cropH <= 0) return source.Clone();
Console.WriteLine($"トリミング実行: 原点({startX}, {startY}), サイズ({cropW}x{cropH})");
return new Mat(source, new Rect(startX, startY, cropW, cropH));
}
既存ラベル位置の同定処理
切り出した画像に対して閾値処理を施し、ノイズ除去を行った後に輪郭を検出します。面積フィルタリングにより、微小な傷や汚れを除外し、ラベルとしての形状を抽出します。
public List<DetectedLabelInfo> ScanExistingLabels(Mat targetImg)
{
var detectedLabels = new List<DetectedLabelInfo>();
// 1. 明度ベースの二値化(逆二値化の場合あり、今回は白拉記物を抽出)
var binImg = new Mat();
Cv2.Threshold(targetImg, binImg, MinLumThreshold, 255, ThresholdTypes.Binary);
// 2. 構造要素によるモルフォロジー処理(欠損埋め合わせとノイズ除去)
var kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(5, 5));
Cv2.MorphologyEx(binImg, binImg, MorphTypes.Open, kernel);
// 3. 輪郭検出
Cv2.FindContours(binImg, out Point[][] contours, out _, RetrievalModes.External, ContourApproximationModes.ApproxSimple);
foreach (var cnt in contours)
{
// 面積フィルタリング(最小サイズ以下は無視)
if (Cv2.ContourArea(cnt) < 1500) continue;
var boundingRect = Cv2.BoundingRect(cnt);
detectedLabels.Add(new DetectedLabelInfo
{
Boundary = boundingRect,
PositionName = ToGridNotation(boundingRect, targetImg.Rows, targetImg.Cols)
});
}
return detectedLabels;
}
空きグリッドの探索ロジック
ラベルサイズの矩形で画像全体をグリッド分割し、既存ラベルのバウンディングボックスと重ならない、かつ画素値が均一なエリアを探します。
public List<string> QueryValidPlacementSlots(Mat workingImage, List<DetectedLabelInfo> foundLabels)
{
var validSlots = new List<string>();
int h = workingImage.Rows;
int w = workingImage.Cols;
int gridCountW = (int)Math.Ceiling((double)w / LabelPixelSize);
int gridCountH = (int)Math.Ceiling((double)h / LabelPixelSize);
for (int r = 1; r <= gridCountH; r++)
{
for (int c = 1; c <= gridCountW; c++)
{
// グリッド座標計算(右下を基準とする場合の調整が必要か確認)
int rectX = w - c * LabelPixelSize;
int rectY = h - r * LabelPixelSize;
// 境界条件チェック
if (rectX < 0) rectX = 0;
if (rectY < 0) rectY = 0;
Rect testArea = new Rect(rectX, rectY, LabelPixelSize, LabelPixelSize);
// 既存ラベルとの当たり判定
bool overlapFound = foundLabels.Any(lbl => lbl.Boundary.IntersectsWith(testArea));
if (!overlapFound)
{
// 区域内部の濃淡変動を確認(段ボール自体の影などを避ける)
if (CheckUniformTexture(workingImage, testArea))
{
validSlots.Add($"{r}-{c}");
}
}
}
}
return validSlots;
}
解析結果の可視化表示
最終的に、緑色の網掛けで全グリッド、赤で検出済みのラベル、青で推奨貼付エリアを描画します。
public Mat RenderVisualization(Mat src, List<DetectedLabelInfo> occupied, List<string> freeZones)
{
// RGB 画像へ変換
var resultImg = new Mat();
Cv2.CvtColor(src, resultImg, ColorConversionCodes.GRAY2BGR);
int rows = src.Rows;
int cols = src.Cols;
// グリッドライン描画(Green)
for (int x = 0; x < cols; x += LabelPixelSize)
Cv2.Line(resultImg, new Point(x, 0), new Point(x, rows), Scalar.Green, 1);
for (int y = 0; y < rows; y += LabelPixelSize)
Cv2.Line(resultImg, new Point(0, y), new Point(cols, y), Scalar.Green, 1);
// 既在ラベル表示(Red)
foreach (var lbl in occupied)
{
Cv2.Rectangle(resultImg, lbl.Boundary, Scalar.Red, 2);
Cv2.PutText(resultImg, lbl.PositionName,
new Point(lbl.Boundary.X, lbl.Boundary.Y - 5),
HersheyFonts.HersheyDuplex, 0.6, Scalar.Red, 1);
}
// 推奨位置表示(Blue)
foreach (var zone in freeZones)
{
// パースして座標復元
var coords = zone.Split('-');
int r = int.Parse(coords[0]);
int c = int.Parse(coords[1]);
int cx = Math.Max(0, cols - c * LabelPixelSize);
int cy = Math.Max(0, rows - r * LabelPixelSize);
Cv2.Rectangle(resultImg,
new Point(cx, cy),
new Point(cx + LabelPixelSize, cy + LabelPixelSize),
Scalar.Blue, 2);
Cv2.PutText(resultImg, zone,
new Point(cx + 5, cy + 20),
HersheyFonts.HersheyComplexSmall, 0.4, Scalar.Blue, 1);
}
return resultImg;
}