基于OpenCV的隔空画笔

一、项目背景

伴随着人工智能时代与5G技术的来临,许多技术得到了空前的发展。在之前疫情的背景下,让人们更加认识到了线上虚拟技术的强大。
对于喜欢美术的用户来说,在使用各种颜色的实体画笔绘画时,经常会需要用到大量的画笔和各种不同的颜料,在一套画笔中,画笔的数量众多,而且由于使用次数的增加会导致颜色出现偏差,而且在纸上进行绘画时,对于大量的颜料选择中,使用画笔绘画起来需要使用颜料版进行调色,并且需要用水来进行去除画笔上的颜色,在调色和去掉颜色的时候是非常麻烦和不方便的。比如,在纸上进行绘画,由于颜料在纸上需要一段时间才能固定好颜色,而且用户需要手中拿着大量的画笔进行绘制,这是对于创作一幅画时候最为麻烦的外界影响,因此,当前需要使用一种可以画出固定颜色并且不需要使用颜料进行调色和水来进行去除颜色的画笔。
对于线上网课教学和在教室中使用PPT进行教学的老师来说,由于长期使用粉笔在黑板上进行板书的习惯,在使用屏幕中的PPT教学时候使用鼠标进行写板书时候有着极大的不方便性和操作起来很麻烦,不能将PPT投影教学与传统黑板板书的优点结合,当前就需要一种画笔可以像粉笔一样实现在PPT投影中使用。

二、实现目标

对于喜欢美术的用户,可以通过画笔在电脑画面上进行作画并且不需要其他特定的电子画笔,只需要生活中的带颜色的画笔便可以直接在电脑上绘画。
对于线上教学和使用PPT投影教学的老师,可以通过画笔直接在电脑面前进行板书,不需要使用像鼠标和触屏版坐在电脑旁进行操作。

三、功能简介

颜色选择器

android 画笔 空心 镂空画笔_#include


图1是基于图像中画笔的颜色进行选择的步骤示意图。在步骤S1中,获取当前图像,将图像中的颜色由BGR转化为HSV。在步骤S2中,使用inRange()函数获取图像中的进行颜色选择,分别选择出不同画笔的不同颜色。在步骤S3中,通过S2中选择出来的颜色,进行与之匹配的颜色保存在虚拟画笔中。

android 画笔 空心 镂空画笔_#include_02


图2是基于图像中画笔位置而画出不同颜色的步骤示意图。在步骤S1中,获取当前图像,通过BGR转HSV只保留画笔的颜色,并且通过画笔的颜色找到画笔的位置。在步骤S2中,获取当前画笔的位置,产生画笔的边框,通过边框的中点来当做画笔的笔尖并且通过扫描出来的颜色面积大小来进行过滤掉噪音。在步骤S3中,通过获取的边框中点来产生一系列的颜色点。在步骤S4中,通过将摄像头和不同的图片进行画面大小调节使之每一处像素坐标一一对应,并且进行相应的Y轴对称,可以让内置摄像头方向一致。

对于RGB值的解释,格式为颜色(R ,G ,B),分别为红、绿、蓝三种基色,三基色在一起时产生白色,其深浅度都减半就会产生灰色,例如白色(255 ,255 ,255)、灰色(127 ,127 ,127)、黑色(0 ,0 ,0)、红色(255 ,0 ,0)、绿色(0 ,255 ,0)、蓝色(0 ,0 ,255)、青色(0 ,255 ,255)、洋红色(255 ,0 ,255)、黄色(255 ,255 ,0)、橙色(255 ,127 ,0)、紫色(127 ,0 ,255)、粉绿(0 ,225 ,128)湖蓝(0 ,128 ,255)、草绿(128 ,255 ,0)、玫瑰红(255 ,0 ,128),某种颜色的RGB值越接近,这种颜色就越接近灰色或黑白,数值越大就越白,反之越黑。例如RGB(150 ,152 ,183),RGB值比较接近,但是蓝色的成份较多一些,因此我们可以判断出这是一种蓝灰色,某种颜色的RGB值如果其中一值与其它两值相差较大,而其它两值比较接近,那么根据RGB中较大的值可以知道这种颜色是比较接近红、绿、蓝、洋红、青、黄中的一种,例如RGB(150 ,20 ,156),R和B值比较接近,G的值较小,因此这是种深紫红色;而RGB(150 ,200 ,156),R和B值比较接近,G的值较大,因此这是种浅绿色。其中,预设阈值可以根据实际教学环境进行设定,例如给定预设阈值为32,则界限上的RGB值如下:RGB(32,0,0)、RGB(0,32,0)、RGB(0,0,32)、RGB(32,32,0)、RGB(32,0,32)、RGB(0,32,32)、RGB(32,32,32)。

基于RGB的概念解释,在图像处理中使用较多的是 HSV 颜色空间,它比 RGB 更接近人们对彩色的感知经验。非常直观地表达颜色的色调、鲜艳程度和明暗程度,方便进行颜色的对比。

在 HSV 颜色空间下,比 BGR 更容易跟踪某种颜色的物体,常用于分割指定颜色的物体。

  • HSV 表达彩色图像的方式由三个部分组成:
  • Hue(色调、色相)
  • Saturation(饱和度、色彩纯净度)
  • Value(明度)
    用下面这个圆柱体来表示 HSV 颜色空间,圆柱体的横截面可以看做是一个极坐标系 ,H 用极坐标的极角表示,S 用极坐标的极轴长度表示,V 用圆柱中轴的高度表示。

    在Hue一定的情况下,饱和度减小,就是往光谱色中添加白色,光谱色所占的比例也在减小,饱和度减为0,表示光谱色所占的比例为零,导致整个颜色呈现白色。
    明度减小,就是往光谱色中添加黑色,光谱色所占的比例也在减小,明度减为0,表示光谱色所占的比例为零,导致整个颜色呈现黑色。
    HSV 对用户来说是一种比较直观的颜色模型。我们可以很轻松地得到单一颜色,即指定颜色角H,并让V=S=1,然后通过向其中加入黑色和白色来得到我们需要的颜色。增加黑色可以减小V而S不变,同样增加白色可以减小S而V不变。例如,要得到深蓝色,V=0.4 S=1 H=240度。要得到浅蓝色,V=1 S=0.4 H=240度。
    HSV 的拉伸对比度增强就是对 S 和 V 两个分量进行归一化(min-max normalize)即可,H 保持不变。

    RGB颜色空间更加面向于工业,而HSV更加面向于用户,所以在对画笔进行颜色选择的时候我们使用HSV来进行选择,而对于画笔在画面中画出的颜色我们使用RGB。

四、功能实现与代码讲解

颜色选择器:

android 画笔 空心 镂空画笔_#include_03


上图从左到右分别是原图、HSV图和选择出来的橙色图,通过颜色选择器可以在众多颜色和不同的图形中选择出想要的颜色,并且只保留出想要的颜色,如上图中选择了正方形代表的橙色,那么在最右图中只会保留出正方形。

android 画笔 空心 镂空画笔_#include_04


上图为通过调整参数数值选择出来的浅绿色长方形。

#include<opencv2/imgcodecs.hpp>
#include<opencv2/highgui.hpp>
#include<opencv2/imgproc.hpp>
#include<iostream>
using namespace cv;
using namespace std;
int hmin = 0, smin = 0, vmin = 0;
int hmax = 255, smax = 255, vmax = 255;
int main()
{
	string path = "D:\\opencvC++\\Resources\\shapes.png";
	Mat img = imread(path);
	Mat imgHSV, mask;
	cvtColor(img, imgHSV, COLOR_BGR2HSV);//RGB转换成HSV
	namedWindow("Trackbars", (640, 200));//命令框 640*200
	createTrackbar("Hue Min", "Trackbars", &hmin, 255);//调节HUE Min 0到255
	createTrackbar("Sat Min", "Trackbars", &smin, 255);
	createTrackbar("Val Min", "Trackbars", &vmin, 255);
	createTrackbar("Hue Max", "Trackbars", &hmax, 179);
	createTrackbar("Sat Max", "Trackbars", &smax, 255);
	createTrackbar("Val Max", "Trackbars", &vmax, 255);
	while (true)
	{
		Scalar lower(hmin, smin, vmin);
		Scalar upper(hmax, smax, vmax);
		inRange(imgHSV, lower, upper, mask);
		imshow("Image", img);
		imshow("Image imgHSV", imgHSV);
		imshow("Image mask", mask);
		waitKey(1);
	}

	return 0;
}

基于上述代码改为使用摄像头可以动态识别不同颜色并且进行调整参数来保留指定颜色。保存好指定颜色画笔通过RGB值来保存虚拟画笔的颜色。

美术生绘画功能:

android 画笔 空心 镂空画笔_android 画笔 空心_05


上图为通过虚拟黄色画笔在空白画布中画出的小太阳简笔画,可以看出基本效果还是不错的(动态效果展示见文件中的视频)。下面进行代码分析。

首先通过颜色选择器选择好的颜色,再通过画笔便可以选择在白色画面上作画或者直接真实画面中进行绘画。

(1)引入OpenCV相关库,定义画笔的颜色和虚拟画笔画出的颜色并打开内置摄像头。

#include<opencv2/imgcodecs.hpp>
#include<opencv2/highgui.hpp>
#include<opencv2/imgproc.hpp>
#include<iostream>
using namespace cv;
using namespace std;
vector<vector<int> > myColors{ {138,92,199,179,255,255},//收集的粉色
{18,122,175,111,255,255} };//黄色
vector<Scalar> myColorValues{ {255,0,255} ,{0,225,255} };
Mat img, img2;
VideoCapture cap(0);//0默认是内置设备头
vector<vector<int> >newPoints;
Mat img31(512, 512, CV_8UC3, Scalar(255, 255, 255));
Mat img3, img33, img333;

(2)获取边框函数。通过边框的中点来当做画笔的笔尖。通过使用边框膨胀之后的图像进行处理,其中通过对面积大小的计算,过滤噪音,扫描出图像中指定画笔的颜色的边框,并且计算出上边框的中点坐标来作为返回值。

Point getContours(Mat imgDil) {
	vector<vector<Point> > contours;//保存边框
	vector<Vec4i> hierarchy;
	findContours(imgDil, contours, hierarchy, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);
	vector<vector<Point> >conPoly(contours.size());
	vector<Rect> boundRect(contours.size());
	Point myPoint(0, 0);
	//过滤噪音
	for (int i = 0; i < contours.size(); ++i)
	{
		int area = contourArea(contours[i]);//找出每个边框的面积
		if (area > 1000)//过滤面积小于1000
		{
			float peri = arcLength(contours[i], true);//计算轮廓周长
			approxPolyDP(contours[i], conPoly[i], 0.02 * peri, true);
			//指定的点集进行多边形逼近的函数,其逼近的精度可通过参数设置。
			//approxPolyDP(contourMat, approxCurve, 10, true);//找出轮廓的多边形拟合曲线
			//第一个参数 InputArray curve:输入的点集
			//第二个参数OutputArray approxCurve:输出的点集,当前点集是能最小包容指定点集的。画出来即是一个多边形。
			//第三个参数double epsilon:指定的精度,也即是原始曲线与近似曲线之间的最大距离。
			//第四个参数bool closed:若为true,则说明近似曲线是闭合的;反之,若为false,则断开。
			boundRect[i] = boundingRect(conPoly[i]);//计算轮廓的垂直边界最小矩形,矩形是与图像上下边界平行的
			myPoint.x = boundRect[i].x + boundRect[i].width / 2;//边框中间
			myPoint.y = boundRect[i].y;
		}
	}
	return myPoint;
}

(3)寻找画笔颜色函数。通过颜色选择器选择出来的颜色保存在myColors中,在寻找画笔颜色函数中用同样的代码来分离出画笔的颜色,并且将当前画笔所在的位置保存到newPoints中。

vector<vector<int> > findColor(Mat img)//通过画笔的颜色找到画笔的坐标
{
	Mat imgHSV;
	cvtColor(img, imgHSV, COLOR_BGR2HSV);//BGR转换成HSV
	for (int i = 0; i < myColors.size(); ++i)//通过HSV模式下来只保留画笔颜色
	{
		Mat mask;
		Scalar lower(myColors[i][0], myColors[i][1], myColors[i][2]);
		Scalar upper(myColors[i][3], myColors[i][4], myColors[i][5]);
		inRange(imgHSV, lower, upper, mask);
		//imshow(to_string(i), mask);
		Point myPoint = getContours(mask);
		if (myPoint.x != 0)
		{
			newPoints.push_back({ myPoint.x,myPoint.y,i });
		}
	}
	return newPoints;
}

(4)绘画函数。通过寻找画笔颜色函数中获取的newPoints和获取边框函数中的myColorValues,将newPoints中的坐标和myColorValues颜色值传入circle函数中,画出点,通过不断连续调用circle函数进而使绘画中成为线条。

void drawOnCanvas(vector<vector<int> >newPoints, vector<Scalar> myColorValues)//画点的函数
{
	for (int i = 0; i < newPoints.size(); ++i)
	{
		circle(img33, Point(newPoints[i][0], newPoints[i][1]), 4, myColorValues[newPoints[i][2]], FILLED);//绘制出点
	}
}

(5)主函数。生成一个640×480大小的空白画纸来进行绘画,调用上述三个函数实现功能,由于前置摄像头生成的图像并不是镜像画面,而人们更加习惯镜像画面(现实中的左和图像中的右对应),于是将画面改为人们更加习惯的镜像画面。

int main()
{
	resize(img31, img3, Size(640, 480));
	flip(img3, img33, 1);
	while (true)
	{
		cap.read(img);
		newPoints = findColor(img);//找到要画的点的坐标
		drawOnCanvas(newPoints, myColorValues);//画出一个点
		flip(img, img2, 1);//Y轴翻转,使画面同步
		flip(img33, img333, 1);
		//imshow("Image", img2);
		imshow("ti", img333);
		waitKey(1);
	}
	return 0;
}

线上老师教学画笔功能:

android 画笔 空心 镂空画笔_人工智能_06


上图为用虚拟画笔在PPT画面上进行勾画的效果图,老师可以通过非电子设备在PPT上进行修改。

对美术生绘画功能进行改进,将空白画面转换为PPT中的画面,这样可以实现老师直接用画笔在PPT中进行相关板书,完整代码如下。

#include<opencv2/imgcodecs.hpp>
#include<opencv2/highgui.hpp>
#include<opencv2/imgproc.hpp>
#include<iostream>
using namespace cv;
using namespace std;
vector<vector<int> > myColors{ {138,92,199,179,255,255},//收集的粉色
{18,122,175,111,255,255} };//黄色
vector<Scalar> myColorValues{ {255,0,255} ,{0,225,255} };
Mat img, img2;
VideoCapture cap(0);//0默认是内置设备头
vector<vector<int> >newPoints;
string path = "D:\\ID\\ti.png";
Mat img31 = imread(path);
Mat img3, img33, img333;
Point getContours(Mat imgDil)//获取边框,通过边框的中点来当做画笔的笔尖
{
	vector<vector<Point> > contours;//保存边框
	vector<Vec4i> hierarchy;
	findContours(imgDil, contours, hierarchy, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);
	vector<vector<Point> >conPoly(contours.size());
	vector<Rect> boundRect(contours.size());
	Point myPoint(0, 0);
	//过滤噪音

	for (int i = 0; i < contours.size(); ++i)
	{
		int area = contourArea(contours[i]);//找出每个边框的面积
		if (area > 1000)//过滤面积小于1000
		{
			float peri = arcLength(contours[i], true);//计算轮廓周长
			approxPolyDP(contours[i], conPoly[i], 0.02 * peri, true);
			boundRect[i] = boundingRect(conPoly[i]);//计算轮廓的垂直边界最小矩形,矩形是与图像上下边界平行的
			myPoint.x = boundRect[i].x + boundRect[i].width / 2;//边框中间
			myPoint.y = boundRect[i].y;
		}

	}
	return myPoint;
}
vector<vector<int> > findColor(Mat img)//通过画笔的颜色找到画笔的坐标
{
	Mat imgHSV;
	cvtColor(img, imgHSV, COLOR_BGR2HSV);//BGR转换成HSV
	for (int i = 0; i < myColors.size(); ++i)//通过HSV模式下来只保留画笔颜色
	{
		Mat mask;
		Scalar lower(myColors[i][0], myColors[i][1], myColors[i][2]);
		Scalar upper(myColors[i][3], myColors[i][4], myColors[i][5]);
		inRange(imgHSV, lower, upper, mask);
		Point myPoint = getContours(mask);
		if (myPoint.x != 0)
		{
			newPoints.push_back({ myPoint.x,myPoint.y,i });
		}
	}
	return newPoints;
}
void drawOnCanvas(vector<vector<int> >newPoints, vector<Scalar> myColorValues)//画点的函数
{
	for (int i = 0; i < newPoints.size(); ++i)
	{
		circle(img33, Point(newPoints[i][0], newPoints[i][1]), 4, myColorValues[newPoints[i][2]], FILLED);//绘制出点
	}
}
int main()
{
	resize(img31, img3, Size(640, 480));
	flip(img3, img33, 1);

	while (true)
	{
		cap.read(img);
		newPoints = findColor(img);//找到要画的点的坐标
		drawOnCanvas(newPoints, myColorValues);//画出一个点
		flip(img, img2, 1);//Y轴翻转,使画面同步
		flip(img33, img333, 1);
		imshow("ti", img333);
		waitKey(1);
	}
	return 0;
}