File indexing completed on 2024-04-28 15:39:45

0001 // SPDX-FileCopyrightText: 2003-2020 Jesper K. Pedersen <blackie@kde.org>
0002 // SPDX-FileCopyrightText: 2022 Johannes Zarl-Zierl <johannes@zarl-zierl.at>
0003 //
0004 // SPDX-License-Identifier: GPL-2.0-or-later
0005 
0006 #include "ImagePreview.h"
0007 
0008 #include "Logging.h"
0009 #include "ResizableFrame.h"
0010 
0011 #include <DB/CategoryCollection.h>
0012 #include <DB/ImageDB.h>
0013 #include <ImageManager/AsyncLoader.h>
0014 #include <Utilities/ImageUtil.h>
0015 
0016 #include <KLocalizedString>
0017 #include <KMessageBox>
0018 #include <QImageReader>
0019 #include <QMouseEvent>
0020 #include <QRubberBand>
0021 #include <QTimer>
0022 #include <math.h>
0023 
0024 using namespace AnnotationDialog;
0025 
0026 ImagePreview::ImagePreview(QWidget *parent)
0027     : QLabel(parent)
0028     , m_reloadTimer(new QTimer(this))
0029 {
0030     setAlignment(Qt::AlignCenter);
0031     setMinimumSize(64, 64);
0032     // "the widget can make use of extra space, so it should get as much space as possible"
0033     setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::MinimumExpanding);
0034 
0035     m_reloadTimer->setSingleShot(true);
0036     connect(m_reloadTimer, &QTimer::timeout, this, &ImagePreview::resizeFinished);
0037 }
0038 
0039 void ImagePreview::resizeEvent(QResizeEvent *ev)
0040 {
0041     qCDebug(AnnotationDialogLog) << "Resizing from" << ev->oldSize() << "to" << ev->size();
0042     // during resizing, a scaled image will do
0043     QImage scaledImage = m_currentImage.getImage().scaled(size(), Qt::KeepAspectRatio);
0044     setPixmap(QPixmap::fromImage(scaledImage));
0045     updateScaleFactors();
0046 
0047     // (re)start the timer to do a full reload
0048     m_reloadTimer->start(200);
0049 
0050     QLabel::resizeEvent(ev);
0051 }
0052 
0053 int ImagePreview::heightForWidth(int width) const
0054 {
0055     int height = width * m_aspectRatio;
0056     return height;
0057 }
0058 
0059 QSize ImagePreview::sizeHint() const
0060 {
0061     QSize hint = m_info.size();
0062     qCDebug(AnnotationDialogLog) << "Preview size hint is" << hint;
0063     return hint;
0064 }
0065 
0066 void ImagePreview::rotate(int angle)
0067 {
0068     if (!m_info.isNull()) {
0069         m_currentImage.setAngle(m_info.angle());
0070         m_info.rotate(angle, DB::RotateImageInfoOnly);
0071     } else {
0072         // Can this really happen?
0073         m_angle += angle;
0074     }
0075 
0076     m_preloader.cancelPreload();
0077     m_lastImage.reset();
0078     reload();
0079 
0080     rotateAreas(angle);
0081 }
0082 
0083 void ImagePreview::setImage(const DB::ImageInfo &info)
0084 {
0085     m_info = info;
0086     reload();
0087 }
0088 
0089 /**
0090    This method should only be used for the non-user images. Currently this includes
0091    two images: the search image and the configure several images at a time image.
0092 */
0093 void ImagePreview::setImage(const QString &fileName)
0094 {
0095     m_fileName = fileName;
0096     m_info = DB::ImageInfo();
0097     m_angle = 0;
0098     // Set the current angle that will be passed to m_lastImage
0099     m_currentImage.setAngle(m_info.angle());
0100     reload();
0101 }
0102 
0103 void ImagePreview::reload()
0104 {
0105     m_aspectRatio = 1;
0106     if (!m_info.isNull()) {
0107         if (m_preloader.has(m_info.fileName(), m_info.angle())) {
0108             qCDebug(AnnotationDialogLog) << "reload(): set preloader image";
0109             setCurrentImage(m_preloader.getImage());
0110         } else if (m_lastImage.has(m_info.fileName(), m_info.angle())) {
0111             qCDebug(AnnotationDialogLog) << "reload(): set last image";
0112             // don't pass by reference, the additional constructor is needed here
0113             // see setCurrentImage for the reason (where m_lastImage is changed...)
0114             setCurrentImage(QImage(m_lastImage.getImage()));
0115         } else {
0116             if (!m_currentImage.has(m_info.fileName(), m_info.angle())) {
0117                 // erase old image to prevent a laggy feel,
0118                 // but only erase old image if it is a different image
0119                 // (otherwise we get flicker when resizing)
0120                 setPixmap(QPixmap());
0121             }
0122             qCDebug(AnnotationDialogLog) << "reload(): set another image";
0123             ImageManager::AsyncLoader::instance()->stop(this);
0124             ImageManager::ImageRequest *request = new ImageManager::ImageRequest(m_info.fileName(), size(), m_info.angle(), this);
0125             request->setPriority(ImageManager::Viewer);
0126             ImageManager::AsyncLoader::instance()->load(request);
0127         }
0128     } else {
0129         qCDebug(AnnotationDialogLog) << "reload(): set image from file";
0130         QImage img(m_fileName);
0131         img = rotateAndScale(img, width(), height(), m_angle);
0132         setPixmap(QPixmap::fromImage(img));
0133     }
0134 }
0135 
0136 int ImagePreview::angle() const
0137 {
0138     Q_ASSERT(!m_info.isNull());
0139     return m_angle;
0140 }
0141 
0142 QSize ImagePreview::getActualImageSize()
0143 {
0144     if (!m_info.size().isValid()) {
0145         // We have to fetch the size from the image
0146         m_info.setSize(QImageReader(m_info.fileName().absolute()).size());
0147         m_aspectRatio = m_info.size().height() / m_info.size().width();
0148     }
0149     return m_info.size();
0150 }
0151 
0152 void ImagePreview::setCurrentImage(const QImage &image)
0153 {
0154     // Cache the current image as the last image before changing it
0155     m_lastImage.set(m_currentImage);
0156 
0157     m_currentImage.set(m_info.fileName(), image, m_info.angle());
0158     setPixmap(QPixmap::fromImage(image));
0159 
0160     if (!m_anticipated.m_fileName.isNull())
0161         m_preloader.preloadImage(m_anticipated.m_fileName, width(), height(), m_anticipated.m_angle);
0162 
0163     updateScaleFactors();
0164 
0165     // Clear the full size image (if we have loaded one)
0166     m_fullSizeImage = QImage();
0167 }
0168 
0169 void ImagePreview::pixmapLoaded(ImageManager::ImageRequest *request, const QImage &image)
0170 {
0171     const DB::FileName fileName = request->databaseFileName();
0172     const bool loadedOK = request->loadedOK();
0173 
0174     if (loadedOK && !m_info.isNull()) {
0175         if (m_info.fileName() == fileName)
0176             setCurrentImage(image);
0177     }
0178 }
0179 
0180 void ImagePreview::anticipate(DB::ImageInfo &info1)
0181 {
0182     // We cannot call m_preloader.preloadImage right here:
0183     // this function is called before reload(), so if we preload here,
0184     // the preloader will always be loading the image after the next image.
0185     m_anticipated.set(info1.fileName(), info1.angle());
0186 }
0187 
0188 ImagePreview::PreloadInfo::PreloadInfo()
0189     : m_angle(0)
0190 {
0191 }
0192 
0193 void ImagePreview::PreloadInfo::set(const DB::FileName &fileName, int angle)
0194 {
0195     m_fileName = fileName;
0196     m_angle = angle;
0197 }
0198 
0199 bool ImagePreview::PreviewImage::has(const DB::FileName &fileName, int angle) const
0200 {
0201     return fileName == m_fileName && !m_image.isNull() && angle == m_angle;
0202 }
0203 
0204 QImage &ImagePreview::PreviewImage::getImage()
0205 {
0206     return m_image;
0207 }
0208 
0209 void ImagePreview::PreviewImage::set(const DB::FileName &fileName, const QImage &image, int angle)
0210 {
0211     m_fileName = fileName;
0212     m_image = image;
0213     m_angle = angle;
0214 }
0215 
0216 void ImagePreview::PreviewImage::set(const PreviewImage &other)
0217 {
0218     m_fileName = other.m_fileName;
0219     m_image = other.m_image;
0220     m_angle = other.m_angle;
0221 }
0222 
0223 void ImagePreview::PreviewImage::setAngle(int angle)
0224 {
0225     m_angle = angle;
0226 }
0227 
0228 void ImagePreview::PreviewImage::reset()
0229 {
0230     m_fileName = DB::FileName();
0231     m_image = QImage();
0232 }
0233 
0234 void ImagePreview::PreviewLoader::pixmapLoaded(ImageManager::ImageRequest *request, const QImage &image)
0235 {
0236     if (request->loadedOK()) {
0237         const DB::FileName fileName = request->databaseFileName();
0238         set(fileName, image, request->angle());
0239     }
0240 }
0241 
0242 void ImagePreview::PreviewLoader::preloadImage(const DB::FileName &fileName, int width, int height, int angle)
0243 {
0244     // no need to worry about concurrent access: everything happens in the event loop thread
0245     reset();
0246     ImageManager::AsyncLoader::instance()->stop(this);
0247     ImageManager::ImageRequest *request = new ImageManager::ImageRequest(fileName, QSize(width, height), angle, this);
0248     request->setPriority(ImageManager::ViewerPreload);
0249     ImageManager::AsyncLoader::instance()->load(request);
0250 }
0251 
0252 void ImagePreview::PreviewLoader::cancelPreload()
0253 {
0254     reset();
0255     ImageManager::AsyncLoader::instance()->stop(this);
0256 }
0257 
0258 QImage ImagePreview::rotateAndScale(QImage img, int width, int height, int angle) const
0259 {
0260     if (angle != 0) {
0261         QTransform matrix;
0262         matrix.rotate(angle);
0263         img = img.transformed(matrix);
0264     }
0265     img = Utilities::scaleImage(img, QSize(width, height), Qt::KeepAspectRatio);
0266     return img;
0267 }
0268 
0269 void ImagePreview::updateScaleFactors()
0270 {
0271     if (m_info.isNull())
0272         return; // search mode
0273 
0274     // Calculate a scale factor from the original image's size and it's current preview
0275     QSize actualSize = getActualImageSize();
0276     // TODO(jzarl): remove once we don't care about Debian 11 anymore:
0277 #if QT_DEPRECATED_SINCE(5, 15)
0278     QSize previewSize = pixmap()->size();
0279 #else
0280     QSize previewSize = pixmap().size();
0281 #endif
0282     m_scaleWidth = double(actualSize.width()) / double(previewSize.width());
0283     m_scaleHeight = double(actualSize.height()) / double(previewSize.height());
0284 
0285     // Calculate the min and max coordinates inside the preview widget
0286     int previewWidth = previewSize.width();
0287     int previewHeight = previewSize.height();
0288     int widgetWidth = this->frameGeometry().width();
0289     int widgetHeight = this->frameGeometry().height();
0290     m_minX = (widgetWidth - previewWidth) / 2;
0291     m_maxX = m_minX + previewWidth - 1;
0292     m_minY = (widgetHeight - previewHeight) / 2;
0293     m_maxY = m_minY + previewHeight - 1;
0294 
0295     // Put all areas to their respective position on the preview
0296     remapAreas();
0297 }
0298 
0299 void ImagePreview::mousePressEvent(QMouseEvent *event)
0300 {
0301     if (!m_areaCreationEnabled) {
0302         return;
0303     }
0304 
0305     if (event->button() & Qt::LeftButton) {
0306         if (!m_selectionRect) {
0307             m_selectionRect = new QRubberBand(QRubberBand::Rectangle, this);
0308         }
0309 
0310         m_areaStart = event->pos();
0311         if (m_areaStart.x() < m_minX || m_areaStart.x() > m_maxX || m_areaStart.y() < m_minY || m_areaStart.y() > m_maxY) {
0312             // Dragging started outside of the preview image
0313             return;
0314         }
0315 
0316         m_selectionRect->setGeometry(QRect(m_areaStart, QSize()));
0317         m_selectionRect->show();
0318     }
0319 }
0320 
0321 void ImagePreview::mouseMoveEvent(QMouseEvent *event)
0322 {
0323     if (!m_areaCreationEnabled) {
0324         return;
0325     }
0326 
0327     if (m_selectionRect && m_selectionRect->isVisible()) {
0328         m_currentPos = event->pos();
0329 
0330         // Restrict the coordinates to the preview images's size
0331         if (m_currentPos.x() < m_minX) {
0332             m_currentPos.setX(m_minX);
0333         }
0334         if (m_currentPos.y() < m_minY) {
0335             m_currentPos.setY(m_minY);
0336         }
0337         if (m_currentPos.x() > m_maxX) {
0338             m_currentPos.setX(m_maxX);
0339         }
0340         if (m_currentPos.y() > m_maxY) {
0341             m_currentPos.setY(m_maxY);
0342         }
0343 
0344         m_selectionRect->setGeometry(QRect(m_areaStart, m_currentPos).normalized());
0345     }
0346 }
0347 
0348 void ImagePreview::mouseReleaseEvent(QMouseEvent *event)
0349 {
0350     if (!m_areaCreationEnabled) {
0351         return;
0352     }
0353 
0354     if (event->button() & Qt::LeftButton && m_selectionRect->isVisible()) {
0355         m_areaEnd = event->pos();
0356         processNewArea();
0357         m_selectionRect->hide();
0358     }
0359 }
0360 
0361 QPixmap ImagePreview::grabAreaImage(QRect area)
0362 {
0363     return QPixmap::fromImage(m_currentImage.getImage().copy(area.left() - m_minX,
0364                                                              area.top() - m_minY,
0365                                                              area.width(),
0366                                                              area.height()));
0367 }
0368 
0369 QRect ImagePreview::areaPreviewToActual(QRect area) const
0370 {
0371     return QRect(QPoint(int(double(area.left() - m_minX) * m_scaleWidth),
0372                         int(double(area.top() - m_minY) * m_scaleHeight)),
0373                  QPoint(int(double(area.right() - m_minX) * m_scaleWidth),
0374                         int(double(area.bottom() - m_minY) * m_scaleHeight)));
0375 }
0376 
0377 QRect ImagePreview::areaActualToPreview(QRect area) const
0378 {
0379     return QRect(QPoint(int(double(area.left() / m_scaleWidth)) + m_minX,
0380                         int(double(area.top() / m_scaleHeight)) + m_minY),
0381                  QPoint(int(double(area.right() / m_scaleWidth)) + m_minX,
0382                         int(double(area.bottom() / m_scaleHeight)) + m_minY));
0383 }
0384 
0385 void ImagePreview::createNewArea(QRect geometry, QRect actualGeometry)
0386 {
0387     // Create a ResizableFrame (cleaned up in Dialog::tidyAreas())
0388     ResizableFrame *newArea = new ResizableFrame(this);
0389 
0390     newArea->setGeometry(geometry);
0391     // Be sure not to create an invisible area
0392     newArea->checkGeometry();
0393     // In case the geometry has been changed by checkGeometry()
0394     actualGeometry = areaPreviewToActual(newArea->geometry());
0395     // Store the coordinates on the real image (not on the preview)
0396     newArea->setActualCoordinates(actualGeometry);
0397     Q_EMIT areaCreated(newArea);
0398 
0399     newArea->show();
0400     newArea->showContextMenu();
0401 }
0402 
0403 void ImagePreview::processNewArea()
0404 {
0405     if (m_areaStart == m_areaEnd) {
0406         // It was just a click, no area has been dragged
0407         return;
0408     }
0409 
0410     QRect newAreaPreview = QRect(m_areaStart, m_currentPos).normalized();
0411     createNewArea(newAreaPreview, areaPreviewToActual(newAreaPreview));
0412 }
0413 
0414 void ImagePreview::remapAreas()
0415 {
0416     const auto allAreas = this->findChildren<ResizableFrame *>();
0417     for (ResizableFrame *area : allAreas) {
0418         area->setGeometry(areaActualToPreview(area->actualCoordinates()));
0419     }
0420 }
0421 
0422 QRect ImagePreview::rotateArea(QRect originalAreaGeometry, int angle)
0423 {
0424     // This is the current state of the image. We need the state before, so ...
0425     QSize unrotatedOriginalImageSize = getActualImageSize();
0426     // ... un-rotate it
0427     unrotatedOriginalImageSize.transpose();
0428 
0429     QRect rotatedAreaGeometry;
0430     rotatedAreaGeometry.setWidth(originalAreaGeometry.height());
0431     rotatedAreaGeometry.setHeight(originalAreaGeometry.width());
0432 
0433     if (angle == 90) {
0434         rotatedAreaGeometry.moveTo(
0435             unrotatedOriginalImageSize.height() - (originalAreaGeometry.height() + originalAreaGeometry.y()),
0436             originalAreaGeometry.x());
0437     } else {
0438         rotatedAreaGeometry.moveTo(
0439             originalAreaGeometry.y(),
0440             unrotatedOriginalImageSize.width() - (originalAreaGeometry.width() + originalAreaGeometry.x()));
0441     }
0442 
0443     return rotatedAreaGeometry;
0444 }
0445 
0446 void ImagePreview::rotateAreas(int angle)
0447 {
0448     // Map all areas to their respective coordinates on the rotated actual image
0449     const auto allAreas = this->findChildren<ResizableFrame *>();
0450     for (ResizableFrame *area : allAreas) {
0451         area->setActualCoordinates(rotateArea(area->actualCoordinates(), angle));
0452     }
0453 }
0454 
0455 void ImagePreview::resizeFinished()
0456 {
0457     qCDebug(AnnotationDialogLog) << "Reloading image after resize";
0458     m_preloader.cancelPreload();
0459     m_lastImage.reset();
0460     reload();
0461 }
0462 
0463 QRect ImagePreview::minMaxAreaPreview() const
0464 {
0465     return QRect(m_minX, m_minY, m_maxX, m_maxY);
0466 }
0467 
0468 void ImagePreview::createTaggedArea(QString category, QString tag, QRect geometry, bool showArea)
0469 {
0470     // Create a ResizableFrame (cleaned up in Dialog::tidyAreas())
0471     ResizableFrame *newArea = new ResizableFrame(this);
0472 
0473     Q_EMIT areaCreated(newArea);
0474 
0475     newArea->setGeometry(areaActualToPreview(geometry));
0476     newArea->setActualCoordinates(geometry);
0477     newArea->setTagData(category, tag, AutomatedChange);
0478     newArea->setVisible(showArea);
0479 }
0480 
0481 void ImagePreview::setAreaCreationEnabled(bool state)
0482 {
0483     m_areaCreationEnabled = state;
0484 }
0485 
0486 // Currently only called when face detection/recognition is used
0487 void ImagePreview::fetchFullSizeImage()
0488 {
0489     if (m_fullSizeImage.isNull()) {
0490         m_fullSizeImage = QImage(m_info.fileName().absolute());
0491     }
0492 
0493     if (m_angle != m_info.angle()) {
0494         QTransform matrix;
0495         matrix.rotate(m_info.angle());
0496         m_fullSizeImage = m_fullSizeImage.transformed(matrix);
0497     }
0498 }
0499 
0500 void ImagePreview::acceptProposedTag(QPair<QString, QString> tagData, ResizableFrame *area)
0501 {
0502     // Be sure that we do have the category the proposed tag belongs to
0503     bool categoryFound = false;
0504 
0505     // Any warnings should only happen when the recognition database is e. g. copied from another
0506     // database location or has been changed outside of KPA. Anyways, this m_can_ happen, so we
0507     // have to handle it.
0508 
0509     QList<DB::CategoryPtr> categories = DB::ImageDB::instance()->categoryCollection()->categories();
0510     for (QList<DB::CategoryPtr>::ConstIterator categoryIt = categories.constBegin();
0511          categoryIt != categories.constEnd(); ++categoryIt) {
0512         if ((*categoryIt)->name() == tagData.first) {
0513             if (!(*categoryIt)->positionable()) {
0514                 KMessageBox::error(this, i18n("<p><b>Can't associate tag \"%2\"</b></p>"
0515                                               "<p>The category \"%1\" the tag \"%2\" belongs to is not positionable.</p>"
0516                                               "<p>If you want to use this tag, change this in the settings dialog. "
0517                                               "If this tag shouldn't be in the recognition database anymore, it can "
0518                                               "be deleted in the settings.</p>",
0519                                               tagData.first, tagData.second));
0520                 return;
0521             }
0522             categoryFound = true;
0523             break;
0524         }
0525     }
0526 
0527     if (!categoryFound) {
0528         KMessageBox::error(this, i18n("<p><b>Can't associate tag \"%2\"</b></p>"
0529                                       "<p>The category \"%1\" the tag \"%2\" belongs to does not exist.</p>"
0530                                       "<p>If you want to use this tag, add this category and mark it as positionable. "
0531                                       "If this tag shouldn't be in the recognition database anymore, it can "
0532                                       "be deleted in the settings dialog.</p>",
0533                                       tagData.first, tagData.second));
0534         return;
0535     }
0536 
0537     // Tell all ListSelects that we accepted a proposed tag, so that the ListSelect
0538     // holding the respective category can ensure that the tag is checked
0539     Q_EMIT proposedTagSelected(tagData.first, tagData.second);
0540 
0541     // Associate the area with the proposed tag
0542     area->setTagData(tagData.first, tagData.second);
0543 }
0544 
0545 bool ImagePreview::fuzzyAreaExists(QList<QRect> &existingAreas, QRect area)
0546 {
0547     float maximumDeviation;
0548     for (int i = 0; i < existingAreas.size(); ++i) {
0549         // maximumDeviation is 15% of the mean value of the width and height of each area
0550         maximumDeviation = float(existingAreas.at(i).width() + existingAreas.at(i).height()) * 0.075;
0551         if (
0552             distance(existingAreas.at(i).topLeft(), area.topLeft()) < maximumDeviation && distance(existingAreas.at(i).topRight(), area.topRight()) < maximumDeviation && distance(existingAreas.at(i).bottomLeft(), area.bottomLeft()) < maximumDeviation && distance(existingAreas.at(i).bottomRight(), area.bottomRight()) < maximumDeviation) {
0553             return true;
0554         }
0555     }
0556 
0557     return false;
0558 }
0559 
0560 float ImagePreview::distance(QPoint point1, QPoint point2)
0561 {
0562     QPoint difference = point1 - point2;
0563     return sqrt(pow(difference.x(), 2) + pow(difference.y(), 2));
0564 }
0565 
0566 // vi:expandtab:tabstop=4 shiftwidth=4:
0567 
0568 #include "moc_ImagePreview.cpp"