这几天学习QT的2D绘图,可因为坐标系统把我拦住了。不但资料少,而且这些资料都是内容雷同。本来这是件非常简单的事情,却有种越描越黑的感觉。经过几天的折腾,总算是理解了这层关系。现在总结一下,不要让大家再走冤枉路。
一、我们为什么要有两种坐标系统?
谈到QT绘图都会跟你说到好几个坐标系,然后就开始被绕晕了。那为什么会这么啰嗦呢?我们换个角度来想一下我们要在屏幕上显示一个图形,程序需要知道哪些东西?
首先你得有一个具体的可描述尺寸的对象,比如现在有一个手机150x50mm,或者一个图片800x600像素,又或者一个房子占地30x6米。这是具体的,与显示设备无关的单位。描述这些内容的坐标系称为逻辑坐标系。因为眼睛看不来那么多东西,所以你还会有一个特别关注的地方,比如说一幅上百米的清明上河图,你要是拉远了就可以看到全部,拉近了就只能看到几个人的画像。描述你需要关注的这个部分矩形就是窗口,决定了你需要显示的内容。
接下来是显示器,这是一个以像素为基本单位进行描述的设备。比如说1024x768分辨率的显示器,或者说一个100x200像素的QWidget。那么描述这种环境的坐标系就是物理坐标系。要正确显示的话,程序需要知道的也是两个方面的东西,你在设备环境的什么地方,以多大的范围给你显示出来。
先看一会图,再来扯具体的概念性的东西~~
二、概念理解
1、逻辑坐标与窗口
逻辑坐标:现实工作中作用的坐标系统称为逻辑坐标,使用的单位称为逻辑单位。比如说一个手机尺寸150*50mm。该系统是与显示设备无关的。比如我们常见的Y轴向上的数学坐标系。
窗口:逻辑环境中的一小部分,是一个矩形框,使用逻辑单位。可以理解为需要显示在屏幕上的显示范围。比如说一部手机,我要放大只看手机屏幕那一部分,那么手机屏幕这个部分就是窗口。如果我要缩小看放手机的桌子,那桌子范围就是窗口。可理解为需要展示的内容。
2、设备环境与视口
设备坐标:显示器、打印机,具体的显示控件等等为设备环境。为了便于理解,以下可用QWidget指代设备环境。它的坐标系为设备坐标系(或物理坐标系)。显示器以像素为单位,打印机以点为单位。原点在左上角,正X轴向右,正Y轴向下,固定不变,不可修改!其X、Y的负半轴为虚设,超出设备的部分无法显示或无法打印图形。不管最终的转换结果如何,最后图形还是要转换成屏幕上最终的像素点上。利用QT的方法取得的坐标值一般就是这个。比如QWidget内部的任一点有它位于QWidget左上角的相对坐标位置和位于整个屏幕的绝对坐标位置。利用鼠标的事件event->pos()得到的坐标值是在QWidget内的相对坐标位置,而event->globalPos()的则是位于整个屏幕的绝对位置。
视口:设备环境中的一部分,一个矩形框,使用的单位同设备环境相同。设置视口相当于指定在设备的什么地方,以多大的范围完全显示指定的窗口内容。这就好比电视的画中画功能,需要指定一个区域显示内容。
3、窗口与视口关系
视口是窗口按比例在显示设备(如QWidget)上的投影。窗口解决内容的问题,视口解决显示的问题。二者结合就定义了这两个坐标系的转换关系。注意,它们仅仅是确定转换关系,并非像画中画功能一样,把显示内容限制在视口范围内。只要显示区域足够大,指定的窗口外的其它内容同样也会显示出来。
以上的说法可能并不准确,实现的过程是基于坐标原点的偏移运算,但我觉得这样说会显得更容易理解,可以帮助理清它们之间的关系。
4、默认约定
a)在不作任何设置的默认情况下逻辑坐标和设备坐标系是重合的,它们的初始值都是原点(0,0)在设备环境(如QWidget)的左上角,范围为设备环境的原始大小。也就是说此时的逻辑坐标的位置和范围与设备环境(如QWidget)的初始值是一样的。,并不是我们熟悉的数学坐标。而是与设备坐标一样倒过来的。如果不加以设定,则你看到的绘图的结果就像下面的美女图那样是倒过来的。
b)用painter.drawLine等方法绘图的时候,都是以逻辑坐标为准的。包括设定的笔刷宽度,也是用的逻辑坐标值。需要自己转换成实际的像素宽度,比如你设置画笔宽度,你设置为1就一定会以1像素显示出来吗?不一定噢!如下图所示要强制以1像素显示出来就先计算一下1像素所对应的逻辑单位值mmPerPix吧。 这个mmPerPix后面会讲到。
-
//创建画笔
-
QPen FastPen(Qt::yellow,
1*mmPerPix.x(), Qt::DashLine, Qt::RoundCap, Qt::RoundJoin);
//1像素宽度显示
-
QPen CutPen(Qt::green,
2*mmPerPix.x(),Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin);
//2像素宽度显示
5、坐标变换
坐标变换依赖于描述坐标系变换关系的矩阵。也就是常说的齐次坐标矩阵方程。上面提到了三种坐标系统。那么在QPainter类中,也存储了对应的三种坐标转换矩阵。
-
painter
.transform();
-
painter
.worldTransform();
-
painter
.deviceTransform();
最后还有一个重要的复合转换矩阵,直接进行逻辑坐标到设备坐标的变换。也就是说直接定义了逻辑坐标点和具体的某个像素对应关系。等一下还会提到。
painter.combinedTransform() //复合转换矩阵
QPainter类中,提供了直接操作变换参数的方法,如下面所示。这些变换方法是直接改变世界坐标系的变换矩阵。当然了,不管改变哪层变换关系,最后的结果也是一样的。就好比1*2和2*1的结果是一样的道理。
-
painter.translate();
//平移
-
painter.rotate();
//旋转
-
painter.scale();
//比例
-
painter.shear();
//扭曲
你要是嫌麻烦,或者想直接调用已有的变换关系,那么可以直接把这关系塞进去:
-
painter
.setMatrix();
-
painter
.setTransform();
-
painter
.setWorldTransform();
三、相关转换代码的实现。
好了,废话是解决不了问题的。概念再清淅最后还不得回归到代码来解决问题么?更多的其它代码就不贴出来了,大家自行网上查找吧,下面只贴出与问题相关的代码。
1、设置窗口和视口
-
painter
.setWindow(10,12,50,
-100);
-
painter
.setViewport(13,15,100,200);
这是基本的设置函数,表示了我在逻辑坐标上的起点(10mm,12mm),宽50mm,高100mm的内容要显示在QWidget起点(13px,15px),宽100px,高200px的位置上,这里有两个特殊情况需要处理。
a) 把显示区域的坐标转成数学坐标(Y轴向上),只要把高的参数改成负数就行了,比如:
painter.setWindow(10,12,50,-100);
如果这个也很难理解的话可以用另一个方法解决,看代码
-
//代替 painter.setWindow 的另一方法
-
WinSize.setCoords(MinX,MaxY,MaxX,MinY);
//直接输入逻辑坐标系的左上角坐标和右下角坐标
b)最终的图形显示受窗口和视口变换矩阵同时影响,如果设置不正确则可能出现图形变形的情况。解决方法就是保证窗口的高宽比和视口的高宽比相同。比如上面的50/100与100/200的结果值是相同的,这样则不会变形。最简单的就是设置窗口与视口都为正方形。下面是一段根据窗口比例计算视口高宽的例程。
-
//按比例显示全部图形
-
QRect WinSize;
//窗口范围
-
QRect ViewSize;
//视口范围
-
WinSize.setLeft(MinX);
//设置窗口范围
-
WinSize.setTop(MaxY);
-
WinSize.setWidth(Width);
-
WinSize.setHeight(-Height);
-
-
int AxisOffset=
20;
//视口偏移
-
if (Width>=Height)
//图形横放
-
{
-
ViewSize.setWidth(rect().width()-AxisOffset*
2);
-
ViewSize.setHeight(ViewSize.width()*Height/Width);
-
}
-
else
-
{
-
ViewSize.setHeight(rect().height()-AxisOffset*
2);
-
ViewSize.setWidth(ViewSize.height()*Width/Height);
-
}
-
//居中显示
-
ViewSize.moveLeft((rect().width()-ViewSize.width())/
2);
-
ViewSize.moveTop((rect().height()-ViewSize.height())/
2);
-
painter.setWindow(WinSize);
-
painter.setViewport(ViewSize);
2、坐标值互换
上面提到过,用鼠标的QMouseEvent得到的是设备的底层物理坐标。这个事件中可以得到两种值分别是:
-
event->pos();
//QWidget上的绝对坐标
-
event->globalPos();
//屏幕上的绝对坐标
还可以通过QWidget上的函数相互转换:
-
mapTo();
//转换成某个QWidget内的坐标
-
mapToParent();
//转换成在父对象上的坐标
-
mapToGlobal();
//转换成屏幕绝对坐标
mapForm的是反向转换,不多说了,自己看图。
3、转换成逻辑坐标
然而对于专门画图的我来说,上面几个坐标值对我没什么用。比如用CAD画一个图,我需要知道的是mm这些逻辑坐标而已。那怎么把鼠标当前的位置转换成逻辑坐标呢?用矩阵!刚才提到的painter.combinedTransform()这时就派上用场了。在QTransform类中提供了一个map方法,可以把输入点转换成最后的坐标点。
QPoint NowDragPos = painter.combinedTransform().map(event->pos());
看到这里,你以为就完了吗?No……No……No…… 忘了告诉你,这个转换都是从逻辑坐标向下转成物理坐标的。所以你把event事件中取得的pos()坐标输进去后会得到一堆不知什么鬼的东西。我明明是要从物理坐标转成逻辑坐标呢,也就是说我要反向转换。好吧,QTransform还提供了一种方法inverted()可以返回该变换的逆矩阵。正确的做法是 :
QPoint NowDragPos = painter.combinedTransform().inverted().map(event->pos());
这个时候就从像素点返回了逻辑坐标点了。还有一件事需要提醒下,在每次的paintEvent事件中,这些矩阵都是恢复为默认值的,所以你需要在类中定义一个QTransform对象来保存这种变换关系以便随时调用。
ViewToWinTrans=painter.combinedTransform().inverted()(); //保存当前变换的逆矩阵
4、精度控制
刚才只是用到了QTransform类中map方法的一个重载,用于QPoint的转换。在QTransform中还提供了多种类型的变换,比如QRect。对于QPoint你懂的,xy坐标是int值。对于我这要求严格的来说远远不够用。那就可以用下面这两个重载。
-
void QTransform::
map(qreal x, qreal y, qreal *tx, qreal *ty)
const
-
QPointF QTransform::
map(
const QPointF &p)
const
5、最后上个效果图吧,在鼠标移动过程中返回该鼠标在逻辑坐标系上的位置,不要在意误差哈。
6、准确返回每像素对应的逻辑坐标值
上面说到画笔时提到了mmPerPix这个变量,QT没有直接提供这个方法返回。好吧,自己动手。原理也很简单,直接返回一条1像素的直线长度不就可以了吗?看下面的代码:
-
ViewToWinTrans=painter.combinedTransform().inverted();
//保存当前变换的逆矩阵,即每像素的逻辑尺寸
-
QLineF LineXY(0,0,1,1);
-
LineXY=ViewToWinTrans.
map(LineXY);
-
mmPerPix.setX(LineXY.dx());
-
mmPerPix.setY(LineXY.dy());
7、关于绘图速度
如果你需要绘制的图形线段非常多,那可能会造成一点卡滞。因为经测试后发现,设置画笔宽度后会大大增加绘图时间。如果把画笔宽度设为0,则默认以1像素去绘图,此时画图速度飞快。原因我也不情楚,希望有知道的大神提点一下原因和解决方法。当然也不是没有办法。给个效果图,速度还是可以的。当然了,这个跟电脑配置也有关系,下面也把我的配置贴出来给大家参考一下。至少对于我来说,这个效率不会比所谓的OpenGL差了。所以说如果觉得绘图速度慢的要好好优化一下程序了。
四、最后的话
为了这个坐标转换,花了我不少时间,本来是挺简单的一个事情,却搞得好像很复杂一样。技术就是一层纸,捅破了也就没什么了。刚学QT不久,也是第一次发贴,写得不好的地方希望大家多多包含。有错误的地方还请指出,谢谢!
修改于:2018-11-24