無論是跨境電商還是製造業分揀設備,在包裹流轉出入庫的場景,為了保證包裹分揀計劃和測量數據綁定真實性,經常會遇到面單扣取的需求,下面我就通過兩種實現原理來實現這一功能。
一:OpenCVSharp 通過面單輪廓/顏色/邊緣等組合檢測實現
二:通過OCR識別面單內容,根據所有切割點座標點最小外界矩形來定位面單位置(扣面單的場景需求是看清面單內容,當然想要扣取完整面單圖片,可以添加面單尺寸,規則信息等維度計算或者直接用第三種方式)
三:YOLO+Labelme標定工具,通過模型訓練定位扣取(這個抽時間單獨展開一篇解釋)
方式一:OpencvSharp 通過輪廓/顏色/邊緣檢測
這種方式對於包裹和麪單顏色有明顯差異的場景很友好,對於包裹顏色和麪單顏色接近的效果一般(建議考慮第二種方式),雖然可以根據面單樣式或者文字聚集密度等多重維度來組合分析,但是過於複雜,並且定製化程度很高,廢話少説,先看看效果:
原圖:
通過顯示增強後的效果圖:
廢話少説,附上核心代碼:
staticvoidProcessSingleImage(string imagePath) { if (!File.Exists(imagePath)) { Console.WriteLine("文件不存在!"); Console.ReadKey(); return; } try { Console.WriteLine($"處理: {Path.GetFileName(imagePath)}"); var stopwatch = Stopwatch.StartNew(); // 檢測面單 var results = _detector.DetectLabels(imagePath); stopwatch.Stop(); Console.WriteLine($"檢測耗時: {stopwatch.ElapsedMilliseconds}ms"); Console.WriteLine($"找到 {results.Count} 個面單區域"); if (results.Count == 0) { Console.WriteLine("未檢測到面單!"); Console.ReadKey(); return; } // 顯示結果 foreach (var result in results) { Console.WriteLine($"- {result.DetectionMethod}: 置信度 {result.Confidence:F2}, " + $"位置 [{result.BoundingBox.X}, {result.BoundingBox.Y}, " + $"{result.BoundingBox.Width}, {result.BoundingBox.Height}]"); } // 創建輸出目錄 var outputDir = _config.OutputDirectory; if (!Directory.Exists(outputDir)) Directory.CreateDirectory(outputDir); var baseName = Path.GetFileNameWithoutExtension(imagePath); // 保存可視化結果 if (_config.SaveVisualized) { using (var original = new Bitmap(imagePath)) { Bitmap bitResult = ImageProcessor.DrawBoundingBoxesSafe(original, results); var visPath = Path.Combine(outputDir, $"{baseName}_detected.png"); ImageProcessor.SaveImage(bitResult, visPath); Console.WriteLine($"可視化結果已保存: {visPath}"); } } // 保存摳圖結果 if (_config.SaveCropped) { using (var mat = Cv2.ImRead(imagePath)) { for (int i = 0; i < results.Count; i++) { var cropped = _detector.CropLabel(mat, results[i].BoundingBox); if (cropped != null) { // 圖像增強 _detector.EnhanceImage(ref cropped); var cropPath = Path.Combine(outputDir, $"{baseName}_label_{i + 1}.png"); Console.WriteLine(cropped); ImageProcessor.SaveImage(cropped, cropPath); Console.WriteLine($"摳圖已保存: {cropPath}"); cropped.Dispose(); } } } } // 保存檢測結果到JSON SaveResultsToJson(results, Path.Combine(outputDir, $"{baseName}_results.json")); Console.WriteLine("\n處理完成! 按任意鍵繼續..."); } catch (Exception ex) { Console.WriteLine($"處理失敗: {ex.Message}"); } }
通過輪廓檢測、顏色檢測和邊緣檢測三種方式組合定位面單位置
public List<DetectionResult> DetectLabels(string imagePath) { var results = new List<DetectionResult>(); using (var mat = Cv2.ImRead(imagePath, OpenCvSharp.ImreadModes.Color)) { if (mat.Empty()) thrownew FileNotFoundException($"無法加載圖像: {imagePath}"); // 方法1: 輪廓檢測 var contourResults = DetectByContours(mat); results.AddRange(contourResults); // 方法2: 顏色檢測 var colorResults = DetectByColor(mat); results.AddRange(colorResults); // 方法3: 邊緣檢測 var edgeResults = DetectByEdges(mat); results.AddRange(edgeResults); } // 合併和篩選結果 return FilterResults(results); }
輪廓檢測
private List<DetectionResult> DetectByContours(OpenCvSharp.Mat src) { var results = new List<DetectionResult>(); using (var gray = new OpenCvSharp.Mat()) using (var binary = new OpenCvSharp.Mat()) { Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY); // 二值化 Cv2.Threshold(gray, binary, 0, 255, ThresholdTypes.Binary | ThresholdTypes.Otsu); // 形態學操作 var kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new OpenCvSharp.Size(3, 3)); Cv2.MorphologyEx(binary, binary, MorphTypes.Close, kernel); // 查找輪廓 Cv2.FindContours(binary, outvar contours, outvar hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxSimple); foreach (var contour in contours) { var area = Cv2.ContourArea(contour); if (area < _minArea || area > _maxArea) continue; Console.WriteLine($"面積:{area}"); var rect = Cv2.BoundingRect(contour); // 計算寬高比 var aspectRatio = (double)rect.Width / rect.Height; // 面單通常為矩形,寬高比在一定範圍內 if (aspectRatio > 0.5 && aspectRatio < 3.0) { // 計算矩形度 var rectArea = rect.Width * rect.Height; var rectangularity = area / rectArea; Console.WriteLine(rectangularity); if (rectangularity > 0.55) { results.Add(new DetectionResult { BoundingBox = rect.ToRectangle(), Confidence = rectangularity, DetectionMethod = "Contour" }); } } } } return results; }
2.顏色檢測
private List<DetectionResult> DetectByColor(OpenCvSharp.Mat src) { var results = new List<DetectionResult>(); using (var hsv = new OpenCvSharp.Mat()) using (var mask = new OpenCvSharp.Mat()) { // 轉換到HSV色彩空間 Cv2.CvtColor(src, hsv, ColorConversionCodes.BGR2HSV); // 定義白色/淺色範圍 var lowerWhite1 = new Scalar(0, 0, 200); var upperWhite1 = new Scalar(180, 30, 255); var lowerWhite2 = new Scalar(0, 0, 180); var upperWhite2 = new Scalar(180, 80, 255); using (var mask1 = new OpenCvSharp.Mat()) using (var mask2 = new OpenCvSharp.Mat()) { Cv2.InRange(hsv, lowerWhite1, upperWhite1, mask1); Cv2.InRange(hsv, lowerWhite2, upperWhite2, mask2); Cv2.BitwiseOr(mask1, mask2, mask); } // 形態學操作 var kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new OpenCvSharp.Size(5, 5)); Cv2.MorphologyEx(mask, mask, MorphTypes.Close, kernel); Cv2.MorphologyEx(mask, mask, MorphTypes.Open, kernel); // 查找輪廓 Cv2.FindContours(mask, outvar contours, outvar hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxSimple); foreach (var contour in contours) { var area = Cv2.ContourArea(contour); if (area < _minArea || area > _maxArea) continue; var rect = Cv2.BoundingRect(contour); // 計算顏色均勻度 var uniformity = CalculateColorUniformity(src, rect); if (uniformity > _confidenceThreshold) { results.Add(new DetectionResult { BoundingBox = rect.ToRectangle(), Confidence = uniformity, DetectionMethod = "Color" }); } } } return results; } 3.邊緣檢測 private List<DetectionResult> DetectByEdges(OpenCvSharp.Mat src) { var results = new List<DetectionResult>(); using (var gray = new OpenCvSharp.Mat()) using (var edges = new OpenCvSharp.Mat()) { Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY); // 降噪 Cv2.GaussianBlur(gray, gray, new OpenCvSharp.Size(5, 5), 1.5); // 邊緣檢測 Cv2.Canny(gray, edges, 50, 150); // 膨脹 var kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new OpenCvSharp.Size(3, 3)); Cv2.Dilate(edges, edges, kernel, iterations: 2); // 查找輪廓 Cv2.FindContours(edges, outvar contours, outvar hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxSimple); foreach (var contour in contours) { var area = Cv2.ContourArea(contour); if (area < _minArea || area > _maxArea) continue; var rect = Cv2.BoundingRect(contour); // 計算邊緣密度 using (var roi = new OpenCvSharp.Mat(edges, rect)) { var totalPixels = roi.Rows * roi.Cols; var edgePixels = Cv2.CountNonZero(roi); var edgeDensity = (double)edgePixels / totalPixels; if (edgeDensity > 0.1 && rect.Width > 100 && rect.Height > 100) { results.Add(new DetectionResult { BoundingBox = rect.ToRectangle(), Confidence = edgeDensity, DetectionMethod = "Edge" }); } } } } return results; }
方式二:通過OCR識別面單內容,根據所有切割點座標點最小外界矩形來定位面單位置
OCR基礎模型用的是SVTR-LCNet這個架構的網絡模型,論文是公開的,我們在這個基礎上做的復現與調優。話不多説,先看效果
相機拍照原始包裹圖片
OCR識別切割效果(根據識別文字角度自動校正)
定位到每個識別內容的矩形座標,獲取所有當前圖片所有切割矩形的最小外接矩形,然後裁切,就可以得到包含所有面單內容的圖片
摳面單效果(實際會比面單小,但是滿足客户需求,包含了所有面單內容)
廢話不多説,附上代碼
///<summary> /// 返回面單圖片 ///</summary> ///<param name="errorMsg">異常信息</param> ///<param name="IsEnhanceImage">面單是否增強</param> ///<param name="IsSaveLocl">是否本地保存</param> ///<returns></returns> public Bitmap GetLabelImageByBitmap(outstring errorMsg, bool IsEnhanceImage = true, bool IsSaveLocl = true) { Bitmap croppedImage = null; errorMsg = string.Empty; try { if (!File.Exists(imagePath)) { ShellLine.WriteLine($"請確保 {imagePath} 存在"); errorMsg = $"請確保 {imagePath} 存在"; returnnew Bitmap(10, 10); } //圖片目錄 string imageDir = Path.GetDirectoryName(debugImagePath); if (Directory.Exists(imageDir)) { Directory.CreateDirectory(imageDir); } Bitmap bitmap1 = new Bitmap(imagePath); var rr = oCR.GetOCRDataStr(bitmap1, debugImagePath); // 讀取JSON文件 string jsonFilePath = imageDir + "\\content.json"; if (!File.Exists(jsonFilePath)) { errorMsg = $"未找到JSON文件,請確保 {jsonFilePath} 存在"; ShellLine.WriteLine($"未找到JSON文件,請確保 {jsonFilePath} 存在"); returnnew Bitmap(imagePath); } string preRotatedImage = imageDir + "\\preRotatedImg.jpg"; if (!File.Exists(preRotatedImage)) { errorMsg = $"未找到面單文件,請確保包裹面單清晰且存在"; ShellLine.WriteLine($"未找到面單文件,請確保包裹面單清晰且存在"); returnnew Bitmap(imagePath); } // 解析矩形數據並計算最小外接矩形 List<Rectangle> rectangles = ParseRectanglesFromJson(jsonFilePath); if (rectangles.Count == 0) { errorMsg = "未在JSON文件中找到有效的矩形數據"; ShellLine.WriteLine("未在JSON文件中找到有效的矩形數據"); returnnew Bitmap(imagePath); } Rectangle boundingRect = CalculateBoundingRectangle(rectangles); ShellLine.WriteLine($"最小外接矩形: X={boundingRect.X}, Y={boundingRect.Y}, Width={boundingRect.Width}, Height={boundingRect.Height}"); ShellLine.WriteLine($"包含 {rectangles.Count} 個元素"); // 加載圖片並進行裁剪 using (Bitmap originalImage = new Bitmap(preRotatedImage)) { // 確保矩形在圖片範圍內 Rectangle safeRect = GetSafeRectangle(boundingRect, originalImage); // 裁剪圖片 croppedImage = CropImage(originalImage, safeRect); if (IsEnhanceImage) { // 增強顯示 EnhanceImage(ref croppedImage); } if (IsSaveLocl) { // 保存結果 string outputPath = Path.Combine( Path.GetDirectoryName(preRotatedImage), Path.GetFileNameWithoutExtension(preRotatedImage) + "_cropped_enhanced.jpg"); croppedImage.Save(outputPath, ImageFormat.Jpeg); ShellLine.WriteLine($"處理完成!結果已保存到: {outputPath}"); } // 顯示裁剪區域信息 ShellLine.WriteLine($"\n裁剪區域信息:"); ShellLine.WriteLine($" 原始圖片尺寸: {originalImage.Width}x{originalImage.Height}"); ShellLine.WriteLine($" 裁剪區域: {safeRect.X}, {safeRect.Y}, {safeRect.Width}x{safeRect.Height}"); ShellLine.WriteLine($" 增強後圖片尺寸: {croppedImage.Width}x{croppedImage.Height}"); return croppedImage; } } catch (Exception ex) { errorMsg = $"處理過程中出現錯誤: {ex.Message}"; ShellLine.WriteLine($"處理過程中出現錯誤: {ex.Message}"); ShellLine.WriteLine($"堆棧跟蹤: {ex.StackTrace}"); returnnew Bitmap(imagePath); } finally { // 釋放資源 croppedImage?.Dispose(); } }
圖片增強顯示,有需要可以調用
///<summary> /// 圖片增強顯示 ///</summary> ///<param name="image"></param> publicvoidEnhanceImage(ref Bitmap image) { using (var mat = image.ToMat()) using (var lab = new OpenCvSharp.Mat()) { // 轉換為Lab色彩空間 Cv2.CvtColor(mat, lab, ColorConversionCodes.BGR2Lab); Cv2.Split(lab, outvar labChannels); // 對亮度通道進行直方圖均衡化 Cv2.EqualizeHist(labChannels[0], labChannels[0]); Cv2.Merge(labChannels, lab); Cv2.CvtColor(lab, mat, ColorConversionCodes.Lab2BGR); // 釋放通道 foreach (var channel in labChannels) channel.Dispose(); // 更新圖像 image.Dispose(); image = mat.ToBitmap(); } }
獲取包含所有切割字符的最小外接矩形
// 計算包含所有矩形的最小外接矩形 static Rectangle CalculateBoundingRectangle(List<Rectangle> rectangles) { if (rectangles.Count == 0) thrownew ArgumentException("矩形列表為空"); int minX = int.MaxValue; int minY = int.MaxValue; int maxX = int.MinValue; int maxY = int.MinValue; foreach (Rectangle rect in rectangles) { minX = Math.Min(minX, rect.X); minY = Math.Min(minY, rect.Y); maxX = Math.Max(maxX, rect.X + rect.Width); maxY = Math.Max(maxY, rect.Y + rect.Height); } // 添加一些邊距,使裁剪更美觀 int margin = 10; minX = Math.Max(0, minX - margin); minY = Math.Max(0, minY - margin); maxX = maxX + margin; maxY = maxY + margin; returnnew Rectangle(minX, minY, maxX - minX, maxY - minY); } // 確保矩形在圖片範圍內 static Rectangle GetSafeRectangle(Rectangle rect, Bitmap image) { int x = Math.Max(0, Math.Min(rect.X, image.Width - 1)); int y = Math.Max(0, Math.Min(rect.Y, image.Height - 1)); int width = Math.Min(rect.Width, image.Width - x); int height = Math.Min(rect.Height, image.Height - y); returnnew Rectangle(x, y, width, height); } // 裁剪圖片 static Bitmap CropImage(Bitmap source, Rectangle cropArea) { Bitmap target = new Bitmap(cropArea.Width, cropArea.Height); using (Graphics g = Graphics.FromImage(target)) { g.DrawImage(source, new Rectangle(0, 0, cropArea.Width, cropArea.Height), cropArea, GraphicsUnit.Pixel); } return target; }
結束語
感謝各位耐心查閲! 如果您有更好的想法歡迎一起交流,有不懂的也可以微信公眾號聯繫博主,作者公眾號會經常發一些實用的小工具和demo源碼,需要的可以去看看!另外,如果覺得本篇博文對您或者身邊朋友有幫助的,麻煩點個關注!贈人玫瑰,手留餘香,您的支持就是我寫作最大的動力,感謝您的關注,期待和您一起探討!再會!