You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于OpenCV获取带圆角变形卡牌的精准近似轮廓

万智牌图像提取算法的轮廓拟合问题

我用OpenCV的FindContours和ApproxPolyDP写了一套万智牌图像提取算法:先对图像做模糊、自适应阈值预处理,找到所有轮廓后筛选出近似矩形的最大轮廓,再通过透视变换规整为固定尺寸。但目前ApproxPolyDP无法精准贴合卡牌边缘,导致透视变换时出现切边情况。

推测问题源于卡牌的圆角设计,试过调整ApproxPolyDP的epsilon值,也用过MinAreaRect(能较好对齐单边但解决不了透视问题),均未解决。

算法代码

private Mat ExtractCard(Mat image)
{
    CvInvoke.Imwrite(Path.Join(Path.GetTempPath(), "output_orig.png"), image);

    Mat temp = image.Clone();

    Mat gray = new();
    CvInvoke.CvtColor(image, gray, ColorConversion.Bgr2Gray);

    Mat blurred = new();
    CvInvoke.GaussianBlur(gray, blurred, new Size(5, 5), 0);

    Mat thresh = new();
    CvInvoke.AdaptiveThreshold(blurred, thresh, 255, AdaptiveThresholdType.MeanC, ThresholdType.BinaryInv, 11, 2);

    Mat kernel = CvInvoke.GetStructuringElement(ElementShape.Rectangle, new Size(2, 2), new Point(-1, -1));
    CvInvoke.Dilate(thresh, thresh, kernel, new Point(-1, -1), 1, BorderType.Reflect, default(MCvScalar));
    CvInvoke.Erode(thresh, thresh, kernel, new Point(-1, -1), 1, BorderType.Reflect, default(MCvScalar));

    CvInvoke.Imwrite(Path.Join(Path.GetTempPath(), "output_thresh.png"), thresh);

    var contours = new VectorOfVectorOfPoint();
    Mat hierarchy = new();
    CvInvoke.FindContours(thresh, contours, hierarchy, RetrType.List, ChainApproxMethod.ChainApproxSimple);

    VectorOfPoint? cardContour = null;
    double largestArea = 0;

    for (int i = 0; i < contours.Size; i++)
    {
        VectorOfPoint contour = contours[i];
        double area = CvInvoke.ContourArea(contour);
        VectorOfPoint approxContour = new();
        double epsilon = CvInvoke.ArcLength(contour, true) * 0.02;
        CvInvoke.ApproxPolyDP(contour, approxContour, epsilon, true);

        if (approxContour.Size == 4 && CvInvoke.IsContourConvex(approxContour) && area > largestArea)
        {
            largestArea = area;
            cardContour = approxContour;
        }

    }

    if (cardContour == null)
    {
        throw new Exception("No card found");
    }

    Point[] sortedPoints = SortedPoints(cardContour.ToArray());

    PointF[] src = { sortedPoints[0], sortedPoints[1], sortedPoints[2], sortedPoints[3] };
    PointF[] dst = { new(0, 0), new(800, 0), new(0, 1120), new(800, 1120) };
    var transform = CvInvoke.GetPerspectiveTransform(src, dst);

    var output = new Mat();
    CvInvoke.WarpPerspective(image, output, transform, new System.Drawing.Size(800, 1120));

    CvInvoke.DrawContours(temp, new VectorOfVectorOfPoint(cardContour), 0, new MCvScalar(0,255,0,255));
    CvInvoke.Imwrite(Path.Join(Path.GetTempPath(), "output_contour.png"), temp);
    CvInvoke.Imwrite(Path.Join(Path.GetTempPath(), "output.png"), output);
    
    return output;
}

调试图像说明

  • 原图:卡牌带有轻微倾斜,放置在浅色平面上
  • 阈值处理后:卡牌区域被完整二值化,但边缘因圆角出现不规则轮廓
  • 轮廓图:绿色拟合轮廓明显偏离卡牌实际边缘,四个角向内收缩
  • 输出结果:透视变换后卡牌边缘被裁切,部分图案缺失

解决方案思路

1. 用凸包预处理适配圆角轮廓

卡牌的圆角会让原始轮廓包含圆弧段,直接用ApproxPolyDP拟合会导致顶点内缩。可以先提取轮廓的凸包,再对凸包做多边形拟合:

VectorOfPoint convexHull = new();
CvInvoke.ConvexHull(contour, convexHull);
// 调小epsilon提升拟合精度
double epsilon = CvInvoke.ArcLength(convexHull, true) * 0.01; 
CvInvoke.ApproxPolyDP(convexHull, approxContour, epsilon, true);

凸包会忽略圆角的凹陷,得到接近卡牌外接矩形的轮廓,再拟合多边形能更贴近实际边缘。

2. 替换预处理流程增强边缘连续性

当前的自适应阈值+开闭运算可能导致边缘断裂,尝试用Canny边缘检测替代:

Mat edges = new();
CvInvoke.Canny(blurred, edges, 50, 150);
// 膨胀操作连接断裂的边缘
CvInvoke.Dilate(edges, edges, kernel, new Point(-1, -1), 2);

Canny能更精准地提取边缘轮廓,减少圆角带来的干扰。

3. 基于最小外接矩形优化透视变换

利用MinAreaRect得到贴合卡牌的外接矩形顶点,以此作为透视变换的源点:

// 找到目标轮廓后计算最小外接矩形
RotatedRect minRect = CvInvoke.MinAreaRect(cardContour);
Point2f[] rectPoints = minRect.Points();
// 对顶点排序后用于透视变换
Point[] sortedRectPoints = SortedPoints(rectPoints.Select(p => new Point((int)p.X, (int)p.Y)).ToArray());

MinAreaRect的顶点贴合卡牌外接矩形,能避免圆角导致的内缩问题。

4. 手动补偿拟合误差

如果以上方法仍有轻微误差,可以对拟合的顶点做向外偏移(偏移量根据卡牌实际尺寸调整,比如5-10像素):

// 根据顶点位置(左上、右上、左下、右下)对应调整偏移方向
int offset = 8;
Point[] offsetPoints = new Point[4];
// 左上顶点:X左移,Y上移
offsetPoints[0] = new Point(sortedPoints[0].X - offset, sortedPoints[0].Y - offset);
// 右上顶点:X右移,Y上移
offsetPoints[1] = new Point(sortedPoints[1].X + offset, sortedPoints[1].Y - offset);
// 左下顶点:X左移,Y下移
offsetPoints[2] = new Point(sortedPoints[2].X - offset, sortedPoints[2].Y + offset);
// 右下顶点:X右移,Y下移
offsetPoints[3] = new Point(sortedPoints[3].X + offset, sortedPoints[3].Y + offset);

内容的提问来源于stack exchange,提问作者Brett

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.12 17:43:22