在上传头像过程中,经常会需要将图片裁切成指定的大小,给定一个指定大小的裁切框,调整裁切框位置裁切出理想的图片,今天在这里实现一个简单的图片裁切的应用
已实现功能简介
为了方便演示,做了一个简单展示界面
目前我实现了以下一些功能:
加载图片,并在图片上添加裁切框,右下角显示了裁切框大小
裁切框大小和位置均可调整,且在图片内部,不超出图片
裁切框中央颜色不变,裁切框外部颜色变深
裁切框内部的样式线条数可以配置
裁切框可以固定尺寸,或者设置放缩规则,等比(1:1)缩放或者自由缩放
按住键盘 ctrl 缩放为固定比例缩放, 按住键盘 alt 缩放为 1:1 长宽缩放
整体代码思路
现在先简单解释一下实现的 demo 逻辑
使用 QLabel 显示图片,我这里创建一个 ImageShowLabel 类来显示图片
在 QLabel 上添加一个 QWidget 作为裁切框, 在创建一个继承 QWidget 的 CropBox 类来表示裁切框
剩下的主要的就是裁切框 CropBox 大小位置,放缩等逻辑
ImageShowLabel 的实现
先简单看一下头文件 imageshowlabel.h 定义的一些函数
#include
#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 int width = this->width() / m_widthCount; painter.drawLine( i*width, SPACING , i*width , this->height()-SPACING); } for (int i=1; 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