在上传头像过程中,经常会需要将图片裁切成指定的大小,给定一个指定大小的裁切框,调整裁切框位置裁切出理想的图片,今天在这里实现一个简单的图片裁切的应用
已实现功能简介
为了方便演示,做了一个简单展示界面
目前我实现了以下一些功能:
- 加载图片,并在图片上添加裁切框,右下角显示了裁切框大小
- 裁切框大小和位置均可调整,且在图片内部,不超出图片
- 裁切框中央颜色不变,裁切框外部颜色变深
- 裁切框内部的样式线条数可以配置
- 裁切框可以固定尺寸,或者设置放缩规则,等比(1:1)缩放或者自由缩放
- 按住键盘
ctrl
缩放为固定比例缩放, 按住键盘alt
缩放为 1:1 长宽缩放
整体代码思路
现在先简单解释一下实现的 demo 逻辑
- 使用
QLabel
显示图片,我这里创建一个ImageShowLabel
类来显示图片 - 在
QLabel
上添加一个QWidget
作为裁切框, 在创建一个继承QWidget
的CropBox
类来表示裁切框 - 剩下的主要的就是裁切框
CropBox
大小位置,放缩等逻辑
ImageShowLabel 的实现
先简单看一下头文件 imageshowlabel.h 定义的一些函数
#include <QLabel>
#include "cropbox.h"
class QPixmap;
class ImageShowLabel : public QLabel
{
public:
ImageShowLabel(QWidget *parent = 0);
void setImage(const QPixmap &image);
QPixmap getCroppedImage();
void setCropBoxLine(const int & widthcount,const int& heightcount);
void setCropBoxShape(CropBox::CropBoxShape shape = CropBox::Rect);
void setCropBoxZoomMode(CropBox::ZoomMode mode = CropBox::Free);
void setEnableKeyPressEvent(bool enabled);
void setfixCropBox(const int & width, const int& height, bool fixed = true);
protected:
void paintEvent(QPaintEvent *event);
private:
CropBox * m_pCropBox;
QPixmap m_orginalImg;
};
ImageShowLabel
对象主要就是显示图片和返回裁切后的图片
- void setImage(const QPixmap &image) 设置图片
- QPixmap getCroppedImage(); 获取裁切框里的图片
作为裁切框 m_pCropBox
父对象的它,此外也需要提供设置 m_pCropBox
对象的接口
函数 | 描述 |
---|---|
void setCropBoxLine(const int & widthcount,const int& heightcount); | 设置 m_pCropBox 内部线条数 |
void setCropBoxShape(CropBox::CropBoxShape shape = CropBox::Rect); | 设置 m_pCropBox 的形状,是方形还是圆形 |
void setCropBoxZoomMode(CropBox::ZoomMode mode = CropBox::Free); | 设置 m_pCropBox 放缩的模式 |
void setEnableKeyPressEvent(bool enabled); | 设置 m_pCropBox 是否监听键盘事件 |
void setfixCropBox(const int & width, const int& height, bool fixed = true); | 设置 m_pCropBox 是否固定大小 |
这里只需要注意一点 void paintEvent(QPaintEvent *event); 函数的实现,这个函数需要实现裁切框内部高亮,外部变暗的功能
//enum CropBoxShape {
// Rect,
// Round
//};
void ImageShowLabel::paintEvent(QPaintEvent *event)
{
// 调用 QLabel 的 paintEvent 函数是为了绘制图片
QLabel::paintEvent(event);
QPainterPath border, cropbox;
// 获取 ImageShowLabel 整体区域
border.setFillRule(Qt::WindingFill);
border.addRect(0, 0, this->width(), this->height());
// 获取裁切框 m_pCropBox 形状,根据形状,确定阴影的样式
cropbox.setFillRule(Qt::WindingFill);
if (m_pCropBox->getCropBoxShape() == CropBox::Rect)
cropbox.addRect(m_pCropBox->pos().x()+2,m_pCropBox->pos().y()+2, m_pCropBox->width()-4, m_pCropBox->height()-4);
else
cropbox.addEllipse(m_pCropBox->pos().x()+2,m_pCropBox->pos().y()+2, m_pCropBox->width()-4, m_pCropBox->height()-4);
// 2者相减,得到裁切框外部的区域
QPainterPath end_path = border.subtracted(cropbox);
// 使用画笔,对这个区域简单加一层有一定透明度的遮罩
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.fillPath(end_path, QColor(0, 0, 0, 100));
}
ImageShowLabel
这个类最主要就是这个 paintEvent
函数,我们可以得到如下的一个样式图,所以接下来主要就是绘制 CropBox
的样式
CropBox 的实现
CropBox
实际上裁切框实现的类,这个类需要实现
- 裁切框的样式
- 形状
- 圆形
- 方形
- 背景,内部的线条,边框的线条,边框的一些标志点,以及裁切框大小的显示
- 形状
- 裁切框在图片内移动
- 裁切框在图片内放缩
- 8方向均可放缩,鼠标样式的修改
- 放缩的模式
- 自由放缩
- 固定比例放缩
- 1:1 放缩
裁切框的样式
先看一下显示的样式图,我们结合样式图,来逐步解释代码的逻辑
void CropBox::paintEvent(QPaintEvent *event)
{
Q_UNUSED(event);
QPainter painter(this);
// 是否绘制内部的线条
if (m_bDrawInternalLines)
drawInternalLines(painter);
// 绘制边框
drawBorder(painter);
// 绘制一些边框上的点
drawPoints(painter);
// 显示裁切框大小
drawSizeText(painter);
}
我又用网格图,绘制了一个大概的样式
绘制边框的 drawBorder
#define LINEWIDTH 1
#define SPACING 2
enum CropBoxShape {
Rect,
Round
};
void CropBox::drawBorder(QPainter &painter)
{
// 绘制外边框线,外边框实线
painter.setPen( QPen{QColor{3,125,203},SPACING});
painter.drawRect( SPACING, SPACING, this->width()-SPACING*2, this->height()-SPACING*2 );
// 当形状是方形时,内边框线和外边框线是一样的,可以不用画
// 当形状是圆形时,外边框不变,需要增加圆形的内边框线,内边框虚线
if (m_shape == Round) {
painter.setPen( QPen{QColor{255,255,255},LINEWIDTH,Qt::DashLine});
painter.drawEllipse(SPACING, SPACING, this->width()-SPACING*2, this->height()-SPACING*2 );
}
}
绘制边框上点的 drawPoints
// 绘制外边框线上的几个标准点,我这边只画了8个,点缀一下
#define POINTSIZE 5
void CropBox::drawPoints(QPainter &painter)
{
painter.setPen( QPen{QColor{3,125,203},POINTSIZE});
painter.drawPoint(SPACING,SPACING);
painter.drawPoint(this->width()/2, SPACING);
painter.drawPoint(this->width()-SPACING, SPACING);
painter.drawPoint(SPACING, this->height()/2);
painter.drawPoint(SPACING, this->height()-SPACING);
painter.drawPoint(this->width()-SPACING, this->height()/2);
painter.drawPoint(this->width()-SPACING, this->height()-SPACING);
painter.drawPoint(this->width()/2, this->height()-SPACING);
}
绘制内部线条的 drawInternalLines
结合最开始的样例,内部的线条是虚线
void CropBox::drawInternalLines(QPainter &painter)
{
// 需要先计算出,内部线条的绘画区域,方形和圆形是有区分
QPainterPath cropbox_path;
if (m_shape == Round)
cropbox_path.addEllipse(SPACING, SPACING, this->width()-SPACING*2, this->height()-SPACING*2);
else
cropbox_path.addRect(SPACING, SPACING, this->width()-SPACING*2, this->height()-SPACING*2);
// 设置被限制的绘画区域
painter.setClipPath(cropbox_path);
painter.setClipping(true);
// 绘画内部虚线线条
painter.setPen( QPen{QColor{230,230,230},LINEWIDTH,Qt::DashLine});
for (int i=1; i<m_widthCount; i++) {
int width = this->width() / m_widthCount;
painter.drawLine( i*width, SPACING , i*width , this->height()-SPACING);
}
for (int i=1; i<m_heightCount; i++) {
int heigth = this->height()/ m_heightCount;
painter.drawLine( SPACING ,i*heigth, this->width()- SPACING, i*heigth);
}
// 绘画完,取消被限制的区域
painter.setClipping(false);
}
绘制裁切框大小 drawSizeText
void CropBox::drawSizeText(QPainter &painter)
{
painter.setPen( QPen{QColor{255,0,0}});
// 设置显示的内容,绘制Text 的区域, 字体呈现的对齐方式
QString showText = QString("(") + QString::number(this->width()) + "," + QString::number(this->height()) + ")";
QPointF topleft{(qreal)this->width()-(qreal)m_minWidth, (qreal)this->height()-(qreal)20};
QSizeF size{(qreal)m_minWidth,20};
QRectF position {topleft, size};
QTextOption option{Qt::AlignVCenter | Qt::AlignRight };
painter.drawText(position, showText, option);
}
裁切框的移动
移动这个功能的操作是:裁切框接收到鼠标左击事件,鼠标不松开的前提下移动鼠标,裁切框随鼠标移动,鼠标松开时,移动停止
为了确保鼠标移动事件的捕获,CropBox
初始化中需要添加 this->setMouseTracking(true);
所以窗口移动主要就涉及到以下3个函数
void mousePressEvent(QMouseEvent *event);
void mouseMoveEvent(QMouseEvent *event);
void mouseReleaseEvent(QMouseEvent *event);
但是移动过程中有个细节需要注意,裁切框不能移动到图像外侧,需要对移动的位置进行判断,所以将判断移动到另外一个函数中
void handleMove(QPoint mouse_globalpos);
mousePressEvent
鼠标点击的时候,需要记录一下点击的状态 m_bMovingFlag=true
,移动过程中,需要判断是否点击了,鼠标松开时, 设置 m_bMovingFlag=false
于此同时,需要记录2个点坐标,裁切框初始的位置 this->pos()
,以及鼠标的全局坐标 event->globalPos()
那么在移动过程中裁切框的实时位置计算公式是 移动过程中鼠标实时全局坐标 - 鼠标点击时的全局坐标 + 鼠标点击时的初始位置
, 为了方便, 所以点击的时候,记录了 鼠标点击时的全部坐标-鼠标点击时的初始位置
的值,也就是 m_dragPosition = event->globalPos() - this->pos();
这样移动过程中,只需要用 移动过程中鼠标的实时全局坐标- m_dragPosition
即可
void CropBox::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton) {
m_bMovingFlag = true;
m_dragPosition = event->globalPos() - this->pos();
}
event->accept();
}
mouseMoveEvent
因为裁切框有移动和放缩的功能,所以鼠标在移动过程中,需要做一些额外的处理
- 未点击状态下的鼠标移动时,当鼠标在裁切框边缘时,根据鼠标的位置,对鼠标样式进行对应的调整(8方向的鼠标设置)
- 非边缘位置,鼠标样式为正常指针样式
- 边缘位置,根据上下左右单独设置样式
- 点击状态下,需要根据点击的位置,也就是鼠标的样式,区分是移动还是放缩
代码如下:
void CropBox::mouseMoveEvent(QMouseEvent *event)
{
// event->pos() 获取的坐标是鼠标相对于裁切框的坐标
QPoint point = event->pos();
// 将这个坐标转换成相对于父对象的坐标,位置后放缩判断做准备
QPoint parent_point = mapToParent(point);
QPoint global_point = event->globalPos();
if (!m_bMovingFlag) {
setDirection(point);
} else {
if (m_curDirec == NONE)
handleMove(global_point);
else
handleResize(parent_point);
}
event->accept();
}
mouseReleaseEvent
void CropBox::mouseReleaseEvent(QMouseEvent *event)
{
this->setCursor(QCursor(Qt::ArrowCursor));
if(event->button()==Qt::LeftButton)
m_bMovingFlag = false;
event->accept();
}
handleMove
因为需要裁切框在图片内部移动,所以需要获取图片的坐标, 由于是使用 QLabel
来展示图片, QLabel
的大小其实就是裁切框移动的范围
void move(const QPoint &);
参数的值是相对于父对象的坐标
void CropBox::handleMove(QPoint mouse_globalpos)
{
QWidget* parent_widget = (QWidget *)this->parent();
QPoint end_point = mouse_globalpos - m_dragPosition ;
if (parent_widget) {
// 保证最后移动到的位置是图片内部的位置,不超出图片
int new_x = judgePosition(end_point.x(), 0, parent_widget->width()-this->width());
end_point.setX(new_x);
int new_y = judgePosition(end_point.y(), 0, parent_widget->height()-this->height());
end_point.setY( new_y );
}
move( end_point );
}
inline int CropBox::judgePosition(int origin, int min, int max)
{
if (origin < min) return min;
if (origin > max) return max;
return origin;
}
放缩
放缩主要分为2部分
- 为了美观设置鼠标的样式, 8方向
- 实现放缩功能,并且放缩模式分为自由放缩,固定比例放缩,1:1放缩 (首先需要明确一点
1:1放缩
就是裁切框一直保持是一个正方形,也就是长宽比是 1, 而固定长宽比
此时的长宽比是任意值) - 键盘控制放缩的模式
定义鼠标的位置, 8方向外加一个中央位置的 NONE
enum Direction { UP=0, DOWN, LEFT, RIGHT, LEFTTOP, LEFTBOTTOM, RIGHTBOTTOM, RIGHTTOP, NONE };
定义放缩模式的枚举
enum ZoomMode {
Free,
FixedRatio,
Square,
};
放缩的鼠标样式
这一部分主要就是判断鼠标当前的位置距离裁切框的位置,然后设置成对应的鼠标样式
// 判断的阈值
#define PADDING 2
void CropBox::setDirection(QPoint point)
{
// 固定尺寸时,不存在放缩功能,不需要设置鼠标样式
if (m_bFixSized) {
m_curDirec = NONE;
this->setCursor(QCursor(Qt::ArrowCursor));
return;
}
int width = this->width();
int heigth = this->height();
if ( PADDING >= point.x() && 0 <= point.x() && PADDING >= point.y() && 0 <= point.y())
{
m_curDirec = LEFTTOP;
this->setCursor(QCursor(Qt::SizeFDiagCursor));
}
else if(width - PADDING <= point.x() && width >= point.x() && heigth - PADDING <= point.y() && heigth >= point.y())
{
m_curDirec = RIGHTBOTTOM;
this->setCursor(QCursor(Qt::SizeFDiagCursor));
}
else if(PADDING >= point.x() && 0 <= point.x() && heigth - PADDING <= point.y() && heigth >= point.y())
{
m_curDirec = LEFTBOTTOM;
this->setCursor(QCursor(Qt::SizeBDiagCursor));
}
else if(PADDING >= point.y() && 0 <= point.y() && width - PADDING <= point.x() && width >= point.x())
{
m_curDirec = RIGHTTOP;
this->setCursor(QCursor(Qt::SizeBDiagCursor));
}
else if(PADDING >= point.x() && 0 <= point.x())
{
m_curDirec = LEFT;
this->setCursor(QCursor(Qt::SizeHorCursor));
}
else if(PADDING >= point.y() && 0 <= point.y())
{
m_curDirec = UP;
this->setCursor(QCursor(Qt::SizeVerCursor));
}
else if(width - PADDING <= point.x() && width >= point.x())
{
m_curDirec = RIGHT;
this->setCursor(QCursor(Qt::SizeHorCursor));
}
else if(heigth - PADDING <= point.y() && heigth >= point.y())
{
m_curDirec = DOWN;
this->setCursor(QCursor(Qt::SizeVerCursor));
}
else
{
m_curDirec = NONE;
this->setCursor(QCursor(Qt::ArrowCursor));
}
}
放缩逻辑
放缩分为8方向,对应每个方向有单独的放缩规则,所以封装成对应的处理函数,在函数里在根据放缩的模式进行放缩
通过 this->geometry();
获取裁切框的几何形状 QRect rectMove
, 而在放缩过程中:
- 上,下,左, 右这4个方向放缩,对于裁切框而言只是需要修改
QRect rectMove
对应的上,下,左,右 的值 - 左上, 右上,右下,左下 这4个方向放缩的时候,也是一样, 修改
QRect rectMove
对应方向的2个值 - 最后将新的几何形状重新赋予裁切框
this->setGeometry(rectMove);
即可实现放缩功能
大致的代码逻辑如下:
void CropBox::handleResize(QPoint mouse_parentpos)
{
if (!m_bMovingFlag)
return;
// 记录当前的
QRect rectMove = this->geometry();
// 当鼠标移出图像外侧时,对放缩使用的坐标进行修正,这里只对最大值进行了修正,最小值,因为方向的问题,需要交给对应方向放缩的函数处理
QPoint valid_point{mouse_parentpos} ;
QWidget* parent_widget = (QWidget *)this->parent();
valid_point.setX( judgePosition(valid_point.x(), 0, parent_widget->width()) );
valid_point.setY( judgePosition(valid_point.y(), 0, parent_widget->height()) );
switch(m_curDirec) {
case UP:
handleResizeUp(valid_point, rectMove, parent_widget);
break;
case DOWN:
handleResizeDown(valid_point, rectMove, parent_widget);
break;
case LEFT:
handleResizeLeft(valid_point, rectMove, parent_widget);
break;
case RIGHT:
handleResizeRight(valid_point, rectMove, parent_widget);
break;
case RIGHTTOP:
handleResizeRightTop(valid_point, rectMove, parent_widget);
break;
case RIGHTBOTTOM:
handleResizeRightBottom(valid_point, rectMove, parent_widget);
break;
case LEFTTOP:
handleResizeLeftTop(valid_point, rectMove, parent_widget);
break;
case LEFTBOTTOM:
handleResizeLeftBottom(valid_point, rectMove, parent_widget);
break;
default:
break;
}
this->setGeometry(rectMove);
}
8个方向处理放缩其实本质是一致的,都是计算新的几何形状,所以只举2个例子
以向上为例 handleResizeUp
如图,最大值在传入该函数的时候就做了限制,所以该函数做了此方向上的最小值判断
- 当放缩方式是
自由
放缩的时候,等于只要改变几何形状的上
的值 - 当放缩方式是
1:1
或者固定长宽比
放缩的时候,此时长宽同时变化,所有直接调用handleResizeRightTop()
函数,此时 向上放缩 等同于 向右上放缩,这个是我自己规定的,可以根据实际自己定义
所以代码如下:
void CropBox::handleResizeUp(QPoint &valid_point, QRect &rectNew, const QWidget *parent_widget)
{
if (m_zoomMode != Free) {
handleResizeRightTop(valid_point, rectNew, parent_widget);
return;
}
if (rectNew.bottom() - valid_point.y() + 1 <= m_minHeight)
valid_point.setY( rectNew.bottom() - m_minHeight + 1);
rectNew.setTop( valid_point.y() );
}
这里有个坑 rectNew.bottom() - valid_point.y() + 1 <= m_minHeight
计算长度的时候 +1, 之前缩放到最小高度的时候,例如80时,裁切框得到的最小高度永远是 81,查看 Qt 的文档时,可以看到这样文档描述
大致意思就是,因为历史原因
right() - left() + 1 = width()
bottom() - top() + 1 = height()
以右上为例 handleResizeRightTop
- 当放缩时
自由
放缩的时候,此时等于是同时改变几何形状的右
和上
的, 一样判断最小值 - 当放缩方式是
1:1
或者固定长宽比
放缩时,难点依旧是对于如何不让裁切框出边界的问题
再次强调一下 1:1放缩
就是裁切框一直保持是一个正方形,也就是长宽比是 1, 而 固定长宽比
此时的长宽比是任意值,所以可以使用 m_heightwidthRatio
值记录放缩前的长宽比
并且此时放缩的长宽的最小值会跟用户设置的最小值是有出入的,长或宽在长宽比限制的情况下,很难同时到达最小点 (除非用户设置的最小值长宽比和放缩时的长宽比一样),所以需要单独记录
m_ratioMinWidth = m_minWidth * m_heightwidthRatio > m_minHeight? m_minWidth : m_minHeight / m_heightwidthRatio;
m_ratioMinHeight = m_minWidth * m_heightwidthRatio > m_minHeight? m_minWidth * m_heightwidthRatio : m_minHeight;
- 先将鼠标的位置转换成合理的图像内的坐标
- 使用鼠标某个方向上的坐标得出 长或者宽,根据长宽比反推出 宽或者长
- 然后在判断新的 几何形状 是否在图像的内部
- 满足,直接设置新的 几何形状
- 不满足,重新计算一下新的 几何形状
代码如下:
void CropBox::handleResizeRightTop(QPoint &valid_point, QRect &rectNew, const QWidget *parent_widget)
{
if (m_zoomMode != Free) {
if (valid_point.x() - rectNew.left() + 1 <= m_ratioMinWidth)
valid_point.setX( rectNew.left() + m_ratioMinWidth - 1);
if (rectNew.bottom() - valid_point.y() + 1 <= m_ratioMinHeight)
valid_point.setY( rectNew.bottom() - m_ratioMinHeight + 1);
int right = (rectNew.bottom()- valid_point.y() + 1)/m_heightwidthRatio + rectNew.left() - 1 ;
if ( right > parent_widget->width() ) {
right = parent_widget->width();
valid_point.setY( rectNew.bottom() - (parent_widget->width() - rectNew.left() + 1)*m_heightwidthRatio + 1 );
}
valid_point.setX( right );
} else {
if (valid_point.x() - rectNew.left() + 1 <= m_minWidth)
valid_point.setX( rectNew.left() + m_minWidth - 1);
if (rectNew.bottom() - valid_point.y() + 1 <= m_minHeight )
valid_point.setY( rectNew.bottom() - m_minHeight + 1 );
}
rectNew.setRight(valid_point.x());
rectNew.setTop(valid_point.y());
}
结合键盘按键放缩
ctrl
固定比例放缩alt
1:1 放缩
CropBox
初始化的时候需要监听键盘事件 this->setEnableKeyPressEvent(true);
代码如下:
void CropBox::keyPressEvent(QKeyEvent *event)
{
// m_keyPressZoomMode 记录按键前原始的放缩模式
m_keyPressZoomMode = m_zoomMode;
if (event->key() == Qt::Key_Control) {
this->setZoomMode(FixedRatio);
return;
} else if(event->key() == Qt::Key_Alt) {
this->setZoomMode(Square);
return;
} else {
QWidget::keyPressEvent(event);
}
}
void CropBox::keyReleaseEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Control || event->key() == Qt::Key_Alt) {
this->setZoomMode(m_keyPressZoomMode);
return;
}
QWidget::keyPressEvent(event);
}
需要优化的地方
图片尺寸过大
当图片尺寸过大(超过了显示器的分辨率),ImageShowLabel
会显示不下,想到了以下2种解决方法
- 使用一个
QScrollArea
包含了ImageShowLabel
, 这样会出现滑动轴,通过拖动来保证可以展示完整。 - 限制用户输入图片的大小
这2种方法感觉都不是很好
计算放缩后的几何形状的坐标
因为使用 QRect
的 right()
, left()
, top()
, bottom()
这些函数,导致计算过程中总是出现 +1,-1 的代码, 但是改变形状,可以直接调用对应的 set 函数就能直接改变形状;
官方推荐的方法中 x()
, width()
, y()
, height()
等计算有效范围会简洁很多,但是设置新的形状的时候,需要设置 x()
, width()
, y()
, height()
, 设置起来更麻烦了
例如向上放缩的函数 handleResizeUp
void CropBox::handleResizeUp(QPoint &valid_point, QRect &rectNew, const QWidget *parent_widget)
{
if (m_zoomMode != Free) {
handleResizeRightTop(valid_point, rectNew, parent_widget);
return;
}
int oldHeight = rectNew.height();
int oldY = rectNew.y();
if ( oldY + oldHeight - valid_point.y() <= m_minHeight)
valid_point.setY( oldY + oldHeight- m_minHeight);
rectNew.setY( valid_point.y() );
rectNew.setHeight( oldY + oldHeight - valid_point.y() );
}
代码地址
github 地址 :https://github.com/catcheroftime/CropPicture