opencv-6-图像绘制与opencv Line 函数剖析
开始之前
越到后面, 写的越慢, 之前还抽空去看了下 学堂在线那篇文章提供的方法, 博客第一个人评论的我, 想想还是要给人家一个交代的, 就想着找到一个方法进行下载, 但是尝试了 还没找到, 估计我要花时间自己写一个了, 不是很难, 但是 就是要花时间, 安排到日程上了, 应该会有结果的, 到时候再写博文记录.
之前都是空的, 写起来很快, 后面的话我还要去写具体的代码实现, 争取都能够复现出来, 这样更有实际意义, 所以接下来可能更新的更慢了, 不过应该不会断, 我计划的很长, 但是我想 至少写够20篇吧, 加油.
在这篇文章以及后续的文章中,我们都会 说明一些代码出现的地方, 头文件对应的 opencv 库引用目录 include 文件夹下面的文件 源文件则是 opencv 4.3.0 下的 Source 文件夹里面的文件
同样的, 文件的后面使用 :xx
标识, 在文件的xx 行的地方
头文件
opencv2/opencv.hpp:100
在 opencv.hpp 第100 行开始的地方
源文件modules/imgproc/src/drawing.cpp:1000
则是在 1000 行的地方
目录
-
- 开始之前
- 目录
- 正文
- 在图像上绘制标准图形
- 编码实现
- line 函数 源码分析剖析
- 源码解析
- 其他
正文
在上一篇中, 我们能够进行像素点的操作了, 那么, 很明显的一个问题, 我能不能在图像上画一条线呢, 或者在图像上自己用鼠标绘制呢, 当然可以, opencv 提供了这些功能,
在opencv 的文档中 Basic Drawing, 基础绘图章节, 有相关的例程,
我们在之前的文章中提到了 opencv 座标系的问题
- 以左上角为起始点
- 横向为x, 纵向为y
- 使用 at(rows,cols) 确定座标值 对应的是 (行,列)
- 使用 point(x,y) 定位, 使用的是 (列, 行)
这两个定位是不一样的 ,所一定要注意, opencv 在 绘制图形的时候 使用的是 Point 来进行的定位, 我们可以使用 cv::Point p = cv::Point(20,30)
直接初始化, 或者使用 cv::Point p; p.x = 20,p.y=30;
初始化后进行赋值操作.
在图像上绘制标准图形
我们还以 lena 为例, 在图片上绘制 以 (x,y) 座标的图形
- 直线: 起点(100,200) - 终点(500,300) 绿色
- 直线: 起点 (100,500) - 终点(500,100) 蓝色
- 圆: 圆心 (200,300) 半径 200 红色
- 矩形: 左上角点 (100,120) 右下角点(400,450); 白色
- 文字: 起始点: (100,200), 文字: OpenCV
- ....
在我们绘制之前, 我们要说明一个函数 cv::Scalar
是opencv 的颜色函数, 按照 BGR
的顺序传入三个参数, 用于指名绘制图形的颜色
note: 实际上是 4个参数, 一般我们不使用最后一个参数即可[1]
编码实现
我们根据上面给出的顺序 依次 编写代码, 将图形绘制到 lena 图的上面,
我们可以得到这样的程序, 就是一条一条的写, 我们只需要调用 相应的opencv 函数就能绘制了,
#include
int main(int argc, char *argv[])
{
//QApplication a(argc, argv);
//MainWindow w;
//w.show();
// 设置 要显示的图像路径
std::string img_lena = "./TestImages/lena.png";
// 读取两幅彩色图像 512*512
cv::Mat lena_bgr = cv::imread(img_lena);
// 声明结果图像 1020*1020
cv::Mat res_bgr = cv::Mat::zeros(cv::Size(512,512), CV_8UC3);
// 绘制基本图形
cv::line(lena_bgr, cv::Point(100, 200), cv::Point(500, 300), cv::Scalar(0, 255, 0));
cv::line(lena_bgr, cv::Point(100, 500), cv::Point(500, 100), cv::Scalar(255, 0, 0));
cv::circle(lena_bgr, cv::Point(200, 300), 200, cv::Scalar(0, 0, 255));
cv::rectangle(lena_bgr, cv::Rect(cv::Point(100, 120), cv::Point(400, 500)), cv::Scalar(255, 255, 255));
cv::putText(lena_bgr, "OpenCV", cv::Point(100, 200), cv::FONT_HERSHEY_COMPLEX,1.0, cv::Scalar(0, 255, 255));
cv::imshow("lena_bgr",lena_bgr);
cv::waitKey(0);
return 0;
// return a.exec();
}
最终我们运行之后 ,便能够得到这样的一副图像, 很简单, 具体的参数部分自己选择就好, 这里只是给出一个示例,
line 函数 源码分析剖析
其实opencv 还能绘制其他的图形, 太多了, 可以在Drawing Functions页面去查看, 基本没啥用, 很多时候是需要了自己造个轮子就行了, 不查文档我都不知道能绘制这么多
只需要知道直线, 圆, 矩形怎么绘制的就行了, 多了也用不到
后续的部分 主要是 opencv 怎么去实现的 line 函数了, 涉及到比较基础的, 看不看不影响你的使用, 不想看的就关闭即可..
源码解析
我们以 绘制直线为例, 这个简单, 我们使用了 cv::line
就绘制出来了一条直线
void cv::line ( InputOutputArray img,
Point pt1,
Point pt2,
const Scalar & color,
int thickness = 1,
int lineType = LINE_8,
int shift = 0
)
各个参数意义其实名称还是能够 看出来的
- img: 输入输出图像, 直接在这个图像上绘制
- pt1: 起始点 (x,y) 座标
- pt2: 终止点 (x,y) 座标
- color: Scalar 参数生成的颜色
- *thickness: 直线宽度
- *lineType: 绘制的线的形式 4邻域 8邻域等
- *shift: 精度, 点座标中的小数点等级(第一次看到这个参数, 暂时不管他什么意思)
一般我们只需要前面的四个参数就行了, 一般就是我们程序中的 cv::line(lena_bgr, cv::Point(100, 200), cv::Point(500, 300), cv::Scalar(0, 255, 0));
座标颜色我们都说过了, 那我们去看下 他的源码:
在 modules\imgproc\srcdrawing.cpp:1772
中, 我们能够看到 直线的绘制函数,
void line( InputOutputArray _img, Point pt1, Point pt2, const Scalar& color,
int thickness, int line_type, int shift )
{
CV_INSTRUMENT_REGION();
Mat img = _img.getMat();
if( line_type == CV_AA && img.depth() != CV_8U )
line_type = 8;
CV_Assert( 0 < thickness && thickness <= MAX_THICKNESS );
CV_Assert( 0 <= shift && shift <= XY_SHIFT );
double buf[4];
scalarToRawData( color, buf, img.type(), 0 );
ThickLine( img, pt1, pt2, buf, thickness, line_type, 3, shift );
}
这个只是一个封装, 将颜色分成一个double 数组, 然后 调用了一个新的函数 ThickLine
这里 实际上 这里的scalarToRawData
函数也是根据图像的通道进行调用了, scalarToRawData_
的函数, 将颜色结构体的四个值分别存入数组中, 也为了兼容以前的版本, 所以采用的这种接口.
template <typename T> static inline
void scalarToRawData_(const Scalar& s, T * const buf, const int cn, const int unroll_to)
{
int i = 0;
for(; i < cn; i++)
buf[i] = saturate_cast(s.val[i]);
for(; i < unroll_to; i++)
buf[i] = buf[i-cn];
}
去看了下 opencv 的矩形 rectancle
函数是实际上是基于 polyline
多边形绘制, 而它则是基于 ThickLine
的, opencv 的函数都是这样基于最简单的元素来实现的, 所以我们继续看这个实现就好
感觉线形状的绘制最终都是调用的这个, 那我们去看下具体的实现.
在文件 modules\imgproc\src\drawing.cpp:1602
是这个函数的实现,
static void
ThickLine( Mat& img, Point2l p0, Point2l p1, const void* color,
int thickness, int line_type, int flags, int shift )
{
static const double INV_XY_ONE = 1./XY_ONE;
p0.x <<= XY_SHIFT - shift;
p0.y <<= XY_SHIFT - shift;
p1.x <<= XY_SHIFT - shift;
p1.y <<= XY_SHIFT - shift;
if( thickness <= 1 )
{
if( line_type < CV_AA )
{
if( line_type == 1 || line_type == 4 || shift == 0 )
{
p0.x = (p0.x + (XY_ONE>>1)) >> XY_SHIFT;
p0.y = (p0.y + (XY_ONE>>1)) >> XY_SHIFT;
p1.x = (p1.x + (XY_ONE>>1)) >> XY_SHIFT;
p1.y = (p1.y + (XY_ONE>>1)) >> XY_SHIFT;
Line( img, p0, p1, color, line_type );
}
else
Line2( img, p0, p1, color );
}
else
LineAA( img, p0, p1, color );
}
else
{
Point2l pt[4], dp = Point2l(0,0);
double dx = (p0.x - p1.x)*INV_XY_ONE, dy = (p1.y - p0.y)*INV_XY_ONE;
double r = dx * dx + dy * dy;
int i, oddThickness = thickness & 1;
thickness <<= XY_SHIFT - 1;
if( fabs(r) > DBL_EPSILON )
{
r = (thickness + oddThickness*XY_ONE*0.5)/std::sqrt(r);
dp.x = cvRound( dy * r );
dp.y = cvRound( dx * r );
pt[0].x = p0.x + dp.x;
pt[0].y = p0.y + dp.y;
pt[1].x = p0.x - dp.x;
pt[1].y = p0.y - dp.y;
pt[2].x = p1.x - dp.x;
pt[2].y = p1.y - dp.y;
pt[3].x = p1.x + dp.x;
pt[3].y = p1.y + dp.y;
FillConvexPoly( img, pt, 4, color, line_type, XY_SHIFT );
}
for( i = 0; i < 2; i++ )
{
if( flags & (i+1) )
{
if( line_type < CV_AA )
{
Point center;
center.x = (int)((p0.x + (XY_ONE>>1)) >> XY_SHIFT);
center.y = (int)((p0.y + (XY_ONE>>1)) >> XY_SHIFT);
Circle( img, center, (thickness + (XY_ONE>>1)) >> XY_SHIFT, color, 1 );
}
else
{
EllipseEx( img, p0, Size2l(thickness, thickness),
0, 0, 360, color, -1, line_type );
}
}
p0 = p1;
}
}
}
在讲函数的实现之前, 我们先看几个参数, 线形: linetype
以及线宽 thickness
其中 linetype
这里可以看OpenCV线型lineType 这篇博客, 他做了几种线形的对比,
在 头文件opencv2\imgproc.hpp:804
中, 定义了
enum LineTypes {
FILLED = -1,
LINE_4 = 4, //!< 4-connected line
LINE_8 = 8, //!< 8-connected line
LINE_AA = 16 //!< antialiased line
};
对应的:
- FILLED: 填充
- LINE_4: 四邻域直线,
- LINE_8: 8邻域直线
- LINE_AA: 抗锯齿直线
再看两个宏, 在modules\imgproc\src\drawing.cpp:46
定义了
- XY_SHIFT: 16
- XY_ONE: 1 << XY_SHIFT 表示左移16位 = 65536
我们在进行转换之前, 有一个小点需要关注一下, 我们line
函数输入的点是 Point2i
, 而ThickLine
函数输入的点变成了 Point2l
, 有意思,
在\modules\core\include\opencv2\core\types.hpp:190
, 我们找到了这样的定义, 其实是一样的 , 就是定义的必须是 64长度的 int 类型, 为什么, 好用于移位呀,
typedef Point_<int> Point2i;
typedef Point_ Point2l;
typedef Point2i Point;
这里有一段程序, 是这样的 , 我们去掉 if 再看一下:
p0.x <<= XY_SHIFT - shift;
p0.y <<= XY_SHIFT - shift;
p1.x <<= XY_SHIFT - shift;
p1.y <<= XY_SHIFT - shift;
p0.x = (p0.x + (XY_ONE>>1)) >> XY_SHIFT;
p0.y = (p0.y + (XY_ONE>>1)) >> XY_SHIFT;
p1.x = (p1.x + (XY_ONE>>1)) >> XY_SHIFT;
p1.y = (p1.y + (XY_ONE>>1)) >> XY_SHIFT;
shift 的 默认参数是 0, 这里的shift 实际上是座标移位的一个作用, 实际上结果就是移位得到结果 这里加上一般是为了XY_ONE>>1
实际上 加上0.5 向上取整而已
个人分析, 不一定是真实意图
由于绘制函数的 ThickLine
函数考虑了线宽, 我们先以基础的线宽为例: 上面一通操作只是给座标进行了变换,得到了有效的座标, 然后 来到了我们最终的 进行绘制的函数,Line
,首写字母大写的呦, 这里的核心就是 构建一个直线迭代器, 在每个点的位置上依次填入颜色即可,
static void
Line( Mat& img, Point pt1, Point pt2,
const void* _color, int connectivity = 8 )
{
if( connectivity == 0 )
connectivity = 8;
else if( connectivity == 1 )
connectivity = 4;
LineIterator iterator(img, pt1, pt2, connectivity, true);
int i, count = iterator.count;
int pix_size = (int)img.elemSize();
const uchar* color = (const uchar*)_color;
for( i = 0; i < count; i++, ++iterator )
{
uchar* ptr = *iterator;
if( pix_size == 1 )
ptr[0] = color[0];
else if( pix_size == 3 )
{
ptr[0] = color[0];
ptr[1] = color[1];
ptr[2] = color[2];
}
else
memcpy( *iterator, color, pix_size );
}
}
那么, 还没到最后, 我们继续 在 modules\imgproc\src\drawing.cpp:160
的地方, 找到了 LineIterator
类的 构造函数,
/*
Initializes line iterator.
Returns number of points on the line or negative number if error.
*/
LineIterator::LineIterator(const Mat& img, Point pt1, Point pt2,
int connectivity, bool left_to_right)
{
count = -1;
CV_Assert( connectivity == 8 || connectivity == 4 );
if( (unsigned)pt1.x >= (unsigned)(img.cols) ||
(unsigned)pt2.x >= (unsigned)(img.cols) ||
(unsigned)pt1.y >= (unsigned)(img.rows) ||
(unsigned)pt2.y >= (unsigned)(img.rows) )
{
if( !clipLine( img.size(), pt1, pt2 ) )
{
ptr = img.data;
err = plusDelta = minusDelta = plusStep = minusStep = count = 0;
ptr0 = 0;
step = 0;
elemSize = 0;
return;
}
}
size_t bt_pix0 = img.elemSize(), bt_pix = bt_pix0;
size_t istep = img.step;
int dx = pt2.x - pt1.x;
int dy = pt2.y - pt1.y;
int s = dx < 0 ? -1 : 0;
if( left_to_right )
{
dx = (dx ^ s) - s;
dy = (dy ^ s) - s;
pt1.x ^= (pt1.x ^ pt2.x) & s;
pt1.y ^= (pt1.y ^ pt2.y) & s;
}
else
{
dx = (dx ^ s) - s;
bt_pix = (bt_pix ^ s) - s;
}
ptr = (uchar*)(img.data + pt1.y * istep + pt1.x * bt_pix0);
s = dy < 0 ? -1 : 0;
dy = (dy ^ s) - s;
istep = (istep ^ s) - s;
s = dy > dx ? -1 : 0;
/* conditional swaps */
dx ^= dy & s;
dy ^= dx & s;
dx ^= dy & s;
bt_pix ^= istep & s;
istep ^= bt_pix & s;
bt_pix ^= istep & s;
if( connectivity == 8 )
{
assert( dx >= 0 && dy >= 0 );
err = dx - (dy + dy);
plusDelta = dx + dx;
minusDelta = -(dy + dy);
plusStep = (int)istep;
minusStep = (int)bt_pix;
count = dx + 1;
}
else /* connectivity == 4 */
{
assert( dx >= 0 && dy >= 0 );
err = 0;
plusDelta = (dx + dx) + (dy + dy);
minusDelta = -(dy + dy);
plusStep = (int)(istep - bt_pix);
minusStep = (int)bt_pix;
count = dx + dy + 1;
}
this->ptr0 = img.ptr();
this->step = (int)img.step;
this->elemSize = (int)bt_pix0;
}
做稍微一点的 简化, 实际上就是考虑正负符号的处理呀, 这里主要使用的就是 异或 (xor) 和 与 运算(&)
这里要考虑计算顺序, 注意就好 运算优先级为如下
- 11 a&b 逐位与
- 12 ^ 逐位异或(互斥或)
- 13 | 逐位或(可兼或)
可以参考文章异或的妙用 和文章位运算总结(按位与,或,异或), 算是一个基础的x 运算吧
在计算中, 巧妙的加入了 s 作为符号, 这样减少每次计算正负的比较,
size_t bt_pix0 = img.elemSize(), bt_pix = bt_pix0;
size_t istep = img.step;
int dx = pt2.x - pt1.x;
int dy = pt2.y - pt1.y;
int s = dx < 0 ? -1 : 0;
dx = (dx ^ s) - s;
dy = (dy ^ s) - s;
pt1.x ^= (pt1.x ^ pt2.x) & s;
pt1.y ^= (pt1.y ^ pt2.y) & s;
// 记录起点的指针,
ptr = (uchar*)(img.data + pt1.y * istep + pt1.x * bt_pix0);
// 使用符号, 巧妙的得到 绝对值
s = dy < 0 ? -1 : 0;
dy = (dy ^ s) - s;
istep = (istep ^ s) - s;
s = dy > dx ? -1 : 0;
// 有符号的 值交换 交换 dx dy 与 bt_pix istep
/* conditional swaps */
dx ^= dy & s;
dy ^= dx & s;
dx ^= dy & s;
bt_pix ^= istep & s;
istep ^= bt_pix & s;
bt_pix ^= istep & s;
err = dx - (dy + dy);
plusDelta = dx + dx;
minusDelta = -(dy + dy);
plusStep = (int)istep;
minusStep = (int)bt_pix;
count = dx + 1;
这里的处理真的 很巧妙, 有符号的值交换, 然后设置相应的符号mask, 及其这里求绝对值的方式, 不懂的话 就个8位的数字 代入进行运算, 可以得到想要的结果, 跑一遍就会了
我之后写一篇文章 看下这里的符号运算吧, 真厉害!
在这里完成了起始点的确定, 给出了一共有多少点的存在count=dx+1
, 通过确定了 我们可以假定 dx 会沿着一个方向依次相加 但是dy呢, 我们还是没有解决怎么确定的一条直线,
在Line
函数的这一句, for( i = 0; i < count; i++, ++iterator )
这里的迭代器是累加的, 但是,迭代器是我们自己自定义的, 那么, ++
操作会不会也是自定义的呢,
在modules\imgproc\include\opencv2\imgproc.hpp:4673
行的地方, 我们看到了这里重载了操作符号, 这样我们在进行加加操作的时候, 这里的err 就会自己调节了,
- err >= 0 mask = 0 err += -2dy ptr += minusStep
- err < 0 mask = -1 err += -2dy + 2dx 这里会 得到一个正的值,
这里更加的巧妙, 具体的公式或者过程需要慢慢推导,
inline
LineIterator& LineIterator::operator ++()
{
int mask = err < 0 ? -1 : 0;
err += minusDelta + (plusDelta & mask);
ptr += minusStep + (plusStep & mask);
return *this;
}
inline
LineIterator LineIterator::operator ++(int)
{
LineIterator it = *this;
++(*this);
return it;
}
总之就是 巧妙的使用了两个变量, 使得我们的累加根据斜率进行累加过程, 完成执行, 很巧妙,
我这里 不再进行后续推导了, , 后续有机会在做...
其他
《OpenCV: Basic Drawing》. 见于 2020年4月24日. https://docs.opencv.org/4.3.0/d3/d96/tutorial_basic_geometric_drawing.html. ↩