基于 AForge.Net 框架的扑克牌计算机视觉识别

© Conmajia 2012, Nazmi Altun 2011
Init. 24 May 2012
SN: 125.1

本文为翻译文章,已获原作者 Nazmi Altun 授权。

下载资源:
源代码(148.6 KB)、Demo(3.1 MB)

简介

(图片上的字:方块 4、方块 J、黑桃 2)

机器人搭配上扑克牌识别系统,即可在二十一点之类的扑克游戏中扮演荷官或者玩家的角色。实现这样的程序同样也是学习计算机视觉和模式识别的好路子。

本文涉及到关于 AForge.NET 框架的技术有二值化、边缘检测、仿射变换、BLOB 处理和模板匹配算法等。

需要注意的是,本文介绍的系统针对英美扑克设计,可能不适用于其他种类的扑克(如日本、朝鲜传统花牌——译者注)。当然,本文详述了扑克牌检测和识别的基本方法,具体的识别算法按特定的牌型特点据此修改即可。

这里有一个 演示视频。

扑克检测

我们需要检测视频图像上的扑克对象,以便能进行下一步的识别。为了完成检测,我们会用一些图像滤镜对视频画面进行预处理。

第一步,将图像去色,即灰度化。去色是将彩色图像转换成 8bit 图像的一种操作。我们需要将彩色图像转换为灰度图像以便对其进行二值化。

我们把彩色图像转为灰度图像后,对其进行二值化。二值化是将灰度图像转换为黑白图像的过程。本文使用 Otsu 算法进行全局阈值化,代码如下。

// 复制原始图像
Bitmap temp= source.Clone() as Bitmap;
FiltersSequence seq = new FiltersSequence();
// 添加灰度滤镜
seq.Add(Grayscale.CommonAlgorithms.BT709);
// 添加二值化滤镜
seq.Add(new OtsuThreshold()); 
// 应用滤镜
temp = seq.Apply(source); 

其过程如下图。

(图片上的字:原始图像、灰度图像、二值/黑白图像)

有了二值图像后,就可以用 BLOB(图块)算法检测扑克牌了。我们使用 AForge.Net 的 BlobCounter 类完成这项任务。该类利用连通区域标记算法统计并提取出图像中的独立对象。

// 从图像中提取宽度和高度大于 150 的 blob
BlobCounter extractor = new BlobCounter();
extractor.FilterBlobs = true;
extractor.MinWidth = extractor.MinHeight = 150;
extractor.MaxWidth = extractor.MaxHeight = 350;
extractor.ProcessImage(temp);

执行完上述代码后,BlobCounter 类会滤掉宽度和高度不在 [150,350] 像素之间的图块。这有助于我们区分出图像中其他物体(如果有的话)。根据测试环境的不同,我们需要改变滤镜参数。例如,假设地面和相机之间距离增大,则图像中的扑克牌会变小。此时,我们需要相应的改变最小、最大宽度和高度参数。

现在,我们可以通过调用 extractor.GetObjectsInformation() 方法得到所有的图块信息(边缘点、矩形区域、中心点、面积、完整度,等等)。然而,我们只需要图块的边缘点来计算矩形区域中心点,并通过调用 PointsCloud.FindQuadriteralCorners 函数来计算。

foreach (Blob blob in extractor.GetObjectsInformation()) {// 获取扑克牌的边缘点List< IntPoint > edgePoints = extractor.GetBlobsEdgePoints(blob);// 利用边缘点,在原始图像上找到四角List< IntPoint > corners =  PointsCloud.FindQuadrilateralCorners(edgePoints);
}

(图片上的字:在图像上绘制边缘点、寻找每张扑克的边角点)

找到扑克牌的四角后,我们就可以从原始图像中提取出正常的扑克牌图像了。由上图可以看出,扑克牌可以横放。扑克牌是否横放是非常容易检测的。在扑克牌放下后,因为我们知道,牌的高度是大于宽度的,所以如果提取的图像宽度大于高度,那么牌必然是横放的。随后,我们用 RotateFlip 函数旋转扑克牌至正常位置。

注意,为了正确识别,所有的扑克应当具有相同的尺寸。不过,鉴于相机角度不同,扑克牌的尺寸是会变化的,这样容易导致识别失败。为了防止这样的问题,我们把所有变换后的扑克牌图像都调整为 200x300 像素大小。

// 用于从原始图像提取扑克牌
QuadrilateralTransformation quadTransformer = new QuadrilateralTransformation();
// 用于调整扑克牌大小
ResizeBilinear resizer = new ResizeBilinear(CardWidth, CardHeight);
foreach (Blob blob in extractor.GetObjectsInformation()) {// 获取扑克牌边缘点List<IntPoint> edgePoints = extractor.GetBlobsEdgePoints(blob);// 利用边缘点,在原始图像上找到四角List<IntPoint> corners =  PointsCloud.FindQuadrilateralCorners(edgePoints);Bitmap cardImg = quadTransformer.Apply(source); // 提取扑克牌图像if (cardImg.Width > cardImg.Height) // 如果扑克牌横放cardImg.RotateFlip(RotateFlipType.Rotate90FlipNone); // 旋转之cardImg =  resizer.Apply(cardImg); // 归一化(重设大小)扑克牌.....
}

(图片上的字:使用 QuariterialTransformation 类从原始图像提取出的扑克牌。该类基于每张牌的四个角进行变换)

到目前为止,我们已经找到了原始图像上每张扑克牌的四角,并从图像中提取出了扑克牌,还调整到统一的尺寸。现在,我们可以开始进行识别了。

识别扑克牌

有好几种用于识别的技术用于识别扑克牌。本文用到的是基于牌型(如扑克牌上的形状)及模板匹配技术。扑克牌的花色和大小是分开识别的。我们可以这样枚举:

public enum Rank {NOT_RECOGNIZED = 0,Ace = 1,Two,Three,Four,Five,Six,Seven,Eight,Nine,Ten,Jack,Queen,King
}
public enum Suit {NOT_RECOGNIZED = 0,Hearts,Diamonds,Spades,Clubs
}

我们还将创建如下的 Card 类来表示识别到的扑克牌。这个类包括了牌的大小、花色、提取到的扑克牌图像和其在原始图像上的四角点。

public class Card {// 变量private Rank rank; // 大小private Suit suit; // 花色private Bitmap image; // 提取出的图像private Point[] corners ;// 四角点// 属性public Point[] Corners { get { return this.corners; } }public Rank Rank { set { this.rank = value; } }public Suit Suit { set { this.suit = value; } }public Bitmap Image { get { return this.image; } }// 构造函数public Card(Bitmap cardImg, IntPoint[] cornerIntPoints) {this.image = cardImg;// 将 AForge.IntPoint 数组转化为 System.Drawing.Point 数组int total = cornerIntPoints.Length;corners = new Point[total];for(int i = 0 ; i < total ; i++) {this.corners[i].X = cornerIntPoints[i].X;this.corners[i].Y = cornerIntPoints[i].Y;}}
}

识别花色

标准的扑克牌有四种花色:黑桃、梅花、方块和红桃。其中方块和红桃是红色,黑桃和梅花是黑色。再有就是方块的宽度大于红桃,而梅花的宽度大于黑桃。这两个特点可以有助于我们识别花色。

识别颜色

首先,我们从识别颜色开始。正确识别出颜色,将帮助我们消除另外两种花色。我们将通过分析扑克牌图像的右上角来识别颜色。

public Bitmap GetTopRightPart() {if (image == null)return null;Crop crop = new Crop(new Rectangle(image.Width - 37, 10, 30, 60));return crop.Apply(image);
}

(图片上的字:裁剪扑克牌右上角、再次裁剪前次结果的底部)

裁剪了扑克牌右上角后,我们得到一张 30 x 60 像素的图像。但是该图像同时包含了花色和大小。因为我们只是分析花色,所以再次裁剪下半部分,得到 30 x 30 像素的图像。

现在,我们可以遍历图像中红色像素和黑色像素的总数。如果一个像素的红色分量比蓝色分量和绿色分量的总和还打,就可以认为该像素是红色。如果红、绿、蓝分量小于 50,且红色分量不大于蓝色和绿色分量和,则认为该像素是黑色。

关于处理 Bitmap 过程中的锁定与解锁,可以参见《GDI+ 位图读写速度测试》一文。——译者注

char color = 'B';
// 锁像素
BitmapData imageData = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height),
ImageLockMode.ReadOnly, bmp.PixelFormat);
int totalRed = 0;
int totalBlack = 0;
unsafe {// 统计红与黑try {UnmanagedImage img = new UnmanagedImage(imageData);int height = img.Height;int width = img.Width;int pixelSize = (img.PixelFormat == PixelFormat.Format24bppRgb) ? 3 : 4;byte* p = (byte*)img.ImageData.ToPointer();// 逐行for (int y = 0; y < height; y++) {// 逐像素for (int x = 0; x < width; x++, p += pixelSize) {int r = (int)p[RGB.R]; // 红int g = (int)p[RGB.G]; // 绿int b = (int)p[RGB.B]; // 蓝if (r > g + b)  // 红 > 绿 + 蓝totalRed++;  // 认为是红色if (r <= g + b && r < 50 && g < 50 && b < 50) // 红绿蓝均小于50totalBlack++; // 认为是黑色}}}finally {bmp.UnlockBits(imageData); // 解锁}
}
if (totalRed > totalBlack) // 红色占优
color = 'R'; // 设置颜色为红,否则默认黑色
return color;

(图片上的字:(左边)总红色=82 总黑色=0;(右边)总红色=0 总黑色=80)

注意 .NET 的 Bitmap.GetPixel() 函数运行缓慢,所以我们使用了指针来遍历像素。

区分人物牌和数字牌

识别了颜色后,我们需要确定扑克牌是否是人物牌。人物牌的牌面为 J、Q、K。人物牌和数字牌之间有一个很突出的特点,即数字牌牌面有很多花色符号指示其大小,而人物牌很好辨认,其牌面有人物头像。我们可以简单的设定一个大个的花色形状来分析扑克,而不是对其使用复杂的模板匹配算法。这样,识别数字牌就可以变得更快。

为了找出一张扑克牌到底是人物牌还是数字牌非常简单。人物牌上面有大的人物图,而数字牌没有。如果我们对牌进行边缘检测和图块处理,找到最大图块,就可以从图块的大小上判断到底是人物牌还是数字牌了。

private bool IsFaceCard(Bitmap bmp) {FiltersSequence commonSeq = new FiltersSequence();commonSeq.Add(Grayscale.CommonAlgorithms.BT709);commonSeq.Add(new BradleyLocalThresholding());commonSeq.Add(new DifferenceEdgeDetector());Bitmap temp = this.commonSeq.Apply(bmp);ExtractBiggestBlob extractor = new ExtractBiggestBlob();temp = extractor.Apply(temp); // 提取最大图块if (temp.Width > bmp.Width / 2)  // 如果宽度大于整个牌的一般宽 return true; // 人物牌return false;  // 数字牌
}

我们不断的对扑克牌图像进行灰度变换、局部阈值化和边缘检测。注意我们使用局部阈值化而不是全局阈值化来消除照明不良的问题。

(图片上的字:原始扑克图像、灰度、Bradley 局部阈值、边缘检测、提取最大图块)

正如你所看到的,人物牌最大图块几乎和整张扑克牌一样大,很容易区分。

前面提到过,出于性能上的考虑,我们将使用不同的识别技术对人物牌和数字牌进行识别。对于数字牌,我们直接提取派上最大图块并识别其宽度和颜色。

private Suit ScanSuit(Bitmap suitBmp, char color) {Bitmap temp = commonSeq.Apply(suitBmp);// 提取最大图块ExtractBiggestBlob extractor = new ExtractBiggestBlob();temp = extractor.Apply(temp);Suit suit = Suit.NOT_RECOGNIZED;// 判断花色if (color == 'R')suit = temp.Width >= 55 ? Suit.Diamonds : Suit.Hearts;if (color == 'B')suit = temp.Width <= 48 ? Suit.Spades : Suit.Clubs;return suit;
}

(图片上的字:宽度 = 52 px、43 px、47 px、57 px)

上述测试最大误差 2 像素。一般来说,因为我们把扑克牌尺寸都调整到了 200 x 300 像素,所以测试的结果都会是相同的大小。

人物牌牌面上没有类似数字牌的最大花色图像,只有角上的小花色图。这就是为什么我们会裁剪扑克图像的右上角并对其应用模板匹配算法来识别花色。

二值化模板图像已经包含在了本文提供的下载资源中。

AForge.NET 提供了一个叫做 ExhaustiveTemplateMatching 的类实现穷尽模板匹配算法。该类对原始图进行完全扫描,用相应的模板对每个像素进行比较。尽管该算法的性能不佳,但我们只是用于一个小区域(30 x 60),也不必过于关心性能。

private Suit ScanFaceSuit(Bitmap bmp, char color) {Bitmap clubs, diamonds, spades, hearts; // 花色模板// 载入模板资源clubs = PlayingCardRecognition.Properties.Resources.Clubs;diamonds = PlayingCardRecognition.Properties.Resources.Diamonds;spades = PlayingCardRecognition.Properties.Resources.Spades;hearts = PlayingCardRecognition.Properties.Resources.Hearts;// 用0.8的相似度阈值初始化模板匹配类ExhaustiveTemplateMatching templateMatching = new ExhaustiveTemplateMatching(0.8f);Suit suit = Suit.NOT_RECOGNIZED;if (color == 'R') {if (templateMatching.ProcessImage(bmp, hearts).Length > 0)suit = Suit.Hearts; //匹配红桃if (templateMatching.ProcessImage(bmp, diamonds).Length > 0)suit = Suit.Diamonds; // 匹配方块}else {if (templateMatching.ProcessImage(bmp,spades).Length > 0)suit = Suit.Spades; // 匹配黑桃if (templateMatching.ProcessImage(bmp, clubs).Length > 0)suit = Suit.Clubs; // 匹配梅花}return suit;
}

(图片上的字:模板匹配?是/否)

当然,模板不能 100% 匹配样本,所以我们使用 0.8(80%)的相似度阈值。

识别大小

识别大小和识别花色类似,也是单独对人物牌和数字牌进行识别。由于数字牌可以只靠计算牌面上的花色图块数量就可以识别,而不用模板匹配,所以利用简单的图像滤镜就可以完成任务。

下面所示的 ScanRank 函数过滤小图块(小于 30 像素长或宽)并计算剩余的图块数。

private Rank ScanRank(Bitmap cardImage) {Rank rank = Rank.NOT_RECOGNIZED;int total = 0;Bitmap temp = commonSeq.Apply(cardImage); // 应用滤镜BlobCounter blobCounter = new BlobCounter();blobCounter.FilterBlobs = true;// 过滤小图块blobCounter.MinHeight = blobCounter.MinWidth = 30;blobCounter.ProcessImage(temp);total = blobCounter.GetObjectsInformation().Length; // 获取总数rank = (Rank)total; // 转换成大小(枚举类型)	return rank;
}

(图片上的字:边缘检测、过滤宽高小于 30 像素的图块、剩余图块总数为 10,即扑克的点数)

所以,数字牌不用模板匹配算法或是 OCR 即可识别。但是,对人物卡,我们需要再次使用模板匹配进行识别。

private Rank ScanFaceRank(Bitmap bmp) {Bitmap j, k, q; // 人物牌人物模板 4// 载入资源j = PlayingCardRecognition.Properties.Resources.J;k = PlayingCardRecognition.Properties.Resources.K;q = PlayingCardRecognition.Properties.Resources.Q;// 用0.75进行初始化ExhaustiveTemplateMatching templateMatchin =new ExhaustiveTemplateMatching(0.75f);Rank rank = Rank.NOT_RECOGNIZED;if (templateMatchin.ProcessImage(bmp, j).Length > 0) // Jrank = Rank.Jack;if (templateMatchin.ProcessImage(bmp, k).Length > 0)// Krank = Rank.King;if (templateMatchin.ProcessImage(bmp, q).Length > 0)// Qrank = Rank.Queen;return rank;
}

由于识别难度较大,这次我们使用 0.75(75%)作为相似度阈值。

已知问题

本文给出的实例代码,只能识别分开的扑克牌,无法应对重叠牌。另一个已知问题是光线环境变化常造成识别错误。

结论

本文用到的图像用例来自 AForge.NET 框架。AForge.NET 为机器视觉和机器学习领域的开发者提供了大量有用的特性。对我来说,它同样非常简单。

本文还可提高,例如如何在牌还没有分放置的时候就进行识别。另一种提升是用这套系统做成 AI 二十一点玩家。

历史

7th, Oct., 2011: 初稿

许可

本文及附带的源文件代码和文件,遵循 CodeProject 网站开源许可(CPOL)

关于作者

Nazmi Altun,来自土耳其的软件开发者

© Conmajia 2012, Nazmi Altun 2011

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/581382.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【Spring实战】09 MyBatis Generator

文章目录 1. 依赖2. 配置文件3. 生成代码4. 详细介绍 generatorConfig.xml5. 代码详细总结 Spring MyBatis Generator 是 MyBatis 官方提供的一个强大的工具&#xff0c;它能够基于数据库表结构自动生成 MyBatis 持久层的代码&#xff0c;包括实体类、Mapper 接口和 XML 映射文…

WPF项目创建HTTP WEB服务,不使用IIS业务 WPF桌面程序WebApi WPF 集成WebApi C# 创建HTTP Web API服务

在C# WPF应用程序中直接创建HTTP服务或WebAPI服务有以下优点&#xff1a; 自托管服务&#xff1a; 简化部署&#xff1a;无需依赖外部服务器或IIS&#xff08;Internet Information Services&#xff09;&#xff0c;可以直接在应用程序内部启动和运行Web服务。 集成紧密&…

工具系列:TimeGPT_(8)使用不规则时间戳进行时间序列预测

文章目录 介绍不规则时间戳的单变量时间预测不规则时间戳的外生变量时间预测 介绍 在处理时间序列数据时&#xff0c;时间戳的频率是一个关键因素&#xff0c;可以对预测结果产生重大影响。像每日、每周或每月这样的常规频率很容易处理。然而&#xff0c;像工作日这样的不规则…

【ARMv8M Cortex-M33 系列 2 -- Cortex-M33 JLink 连接 及 JFlash 烧写介绍】

文章目录 Jlink 工具JLink 命令行示例JFlash 烧写问题Jlink 工具 J-Link 是 SEGGER 提供的一款流行的 JTAG 调试器,它支持多个平台和处理器。JLink.exe 是 J-Link 调试器的命令行接口,它允许用户通过命令行执行一系列操作,例如编程、擦除、调试等。 工具链接: https://ww…

可运营的Leadshop开源商城小程序源码 +H5公众号+带视频教程

源码简介 Leadshop是一款出色的开源电商系统&#xff0c;具备轻量级、高性能的特点&#xff0c;并提供持续更新和迭代服务。该系统采用前后端分离架构&#xff08;uniappyii2.0&#xff09;&#xff0c;以实现最佳用户体验为目标。 前端部分采用了uni-app、ES6、Vue、Vuex、V…

使用Visual Studio 2022 winform项目打包成安装程序.exe

winform项目打包 1.安装扩展插件 Microsoft Visual Studio Installer Projects 20222.在解决方案上新建一个setup project 项目3.新建成功如下图&#xff0c;之后添加你的winform程序生成之后的debug下的文件4.在Application Folder上点击右键->Add->项目输出->主输出…

Echarts中饼图-实现放大显示数据

示例 代码演示 option {tooltip: {trigger: item},legend: {top: 5%,left: center},series: [{name: Access From,type: pie,radius: [40%, 70%],avoidLabelOverlap: false,label: {show: false,position: center},emphasis: {scale: true,//是否开启高亮后扇区的放大效果。s…

2024年HTML+CSS+JS 网页版烟花代码

&#x1f482; 个人网站:【 海拥】【神级代码资源网站】【办公神器】&#x1f91f; 基于Web端打造的&#xff1a;&#x1f449;轻量化工具创作平台&#x1f485; 想寻找共同学习交流的小伙伴&#xff0c;请点击【全栈技术交流群】 直接跳到末尾 获取完整源码 在线体验地址&…

磁盘管理与文件系统

步骤&#xff1a; 1.建立分区&#xff08;必须分区&#xff09; 在文件中的格式开头为b &#xff0c;块设备 2.文件系统 因公安是个硬件设备&#xff0c;是一类软件的总称&#xff0c;管理文件的功能&#xff0c;下载文件占硬盘的空间 3.挂载 将硬盘与系统内的文件夹做关…

华为OD机试 - 两个字符串间的最短路径问题(Java JS Python C)

题目描述 给定两个字符串,分别为字符串 A 与字符串 B。 例如 A字符串为 "ABCABBA",B字符串为 "CBABAC" 可以得到下图 m * n 的二维数组,定义原点为(0,0),终点为(m,n),水平与垂直的每一条边距离为1,映射成坐标系如下图。 从原点 (0,0) 到 (0,A) 为水…

从实际业务问题出发去分析Eureka-Server端源码

文章目录 前言1.EnableEurekaServer2.初始化缓存3.jersey应用程序构建3.1注册jeseryFilter3.2构建JerseyApplication 4.处理注册请求5.registry&#xff08;&#xff09; 前言 前段时间遇到了一个业务问题就是k8s滚动发布Eureka微服务的过程中接口会有很多告警&#xff0c;当时…

【队列】【实现构造函数和方法】Leetcode 903 最近的请求次数

【队列相关】【实现构造函数和方法】Leetcode 903 最近的请求次数 解法1 利用列表的相关操作 ---------------&#x1f388;&#x1f388;题目链接&#x1f388;&#x1f388;------------------- 解法1 利用列表的相关操作 1、新建类型为Queue<Integer>&#xff0c;表示…

原创AI图片可定制可商用

欢迎欣赏&#xff0c;一起交流学习。交流学习

C# WPF上位机开发(MVVM模式开发)

【 声明&#xff1a;版权所有&#xff0c;欢迎转载&#xff0c;请勿用于商业用途。 联系信箱&#xff1a;feixiaoxing 163.com】 学习过vue的同学都知道mvvm这个名词。从字面上理解&#xff0c;可能有点拗口&#xff0c;但是我们可以去理解一下它的优点是什么。mvc相信大家都明…

Havenask 分布式索引构建服务 --Build Service

Havenask 是阿里巴巴智能引擎事业部自研的开源高性能搜索引擎&#xff0c;深度支持了包括淘宝、天猫、菜鸟、高德、饿了么在内几乎整个阿里的搜索业务。本文针对性介绍了 Havenask 分布式索引构建服务——Build Service&#xff0c;主打稳定、快速、易管理&#xff0c;是在线系…

websocket 介绍

目录 1&#xff0c;前端如何实现即时通讯短轮询长轮询 2&#xff0c;websocket2.1&#xff0c;握手2.2&#xff0c;握手过程举例2.3&#xff0c;socket.io 3&#xff0c;websocket 对比 http 的优势 1&#xff0c;前端如何实现即时通讯 在 websocket 协议出现之前&#xff0c;…

助力智能车损计算,基于高精度YOLOv8开发构建智能化车辆受损区域分割检测识别分析系统

车辆受损评估本身有专业的评估流程&#xff0c;本文并不是要探究这块的内容&#xff0c;而是想要通过技术手段来对车辆受损区域的面积做自动化的计算&#xff0c;在前面的博文中我们已经有了相关的开发实践了&#xff0c;感兴趣的话可以自行移步阅读即可&#xff1a; 《基于yo…

数据结构入门到入土——List的介绍

目录 一&#xff0c;什么是List&#xff1f; 二&#xff0c;常见接口介绍 三&#xff0c;List的使用 一&#xff0c;什么是List&#xff1f; 在集合框架中&#xff0c;List是一个接口&#xff0c;继承自Collection。 Collection也是一个接口&#xff0c;该接口中规范了后序容…

【智慧门店】东胜物联蓝牙网关助力解决方案商,推动汽车后市场企业智能化升级

截至2023年9月底&#xff0c;我国汽车保有量达3.3亿辆&#xff0c;后市场前景广阔。 随着人工智能、5G、物联网等新技术的普及&#xff0c;汽车后市场企业希望向智能化迈进&#xff0c;借助新兴科技的力量提升汽车维修、车辆保养等服务质量&#xff0c;满足消费者日益增长的需…

算法模板之单调栈和单调队列图文详解

&#x1f308;个人主页&#xff1a;聆风吟 &#x1f525;系列专栏&#xff1a;算法模板、数据结构 &#x1f516;少年有梦不应止于心动&#xff0c;更要付诸行动。 文章目录 &#x1f4cb;前言一. ⛳️单调栈讲解1.1 &#x1f514;单调栈的定义1.2 &#x1f514;如何维护一个单…