无论是跨境电商还是制造业分拣设备,在包裹流转出入库的场景,为了保证包裹分拣计划和测量数据绑定真实性,经常会遇到面单扣取的需求,下面我就通过两种实现原理来实现这一功能。
一: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源码,需要的可以去看看!另外,如果觉得本篇博文对您或者身边朋友有帮助的,麻烦点个关注!赠人玫瑰,手留余香,您的支持就是我写作最大的动力,感谢您的关注,期待和您一起探讨!再会!**
