File indexing completed on 2024-05-12 04:44:32

0001 // SPDX-FileCopyrightText: Lukas Sommer <sommerluk@gmail.com>
0002 // SPDX-License-Identifier: BSD-2-Clause OR MIT
0003 
0004 // Own headers
0005 // First the interface, which forces the header to be self-contained.
0006 #include "colorwheelimage.h"
0007 
0008 #include "cielchd50values.h"
0009 #include "helperconstants.h"
0010 #include "helperconversion.h"
0011 #include "helpermath.h"
0012 #include "polarpointf.h"
0013 #include "rgbcolorspace.h"
0014 #include <lcms2.h>
0015 #include <qbrush.h>
0016 #include <qmath.h>
0017 #include <qnamespace.h>
0018 #include <qpainter.h>
0019 #include <qpen.h>
0020 #include <qpoint.h>
0021 #include <qrect.h>
0022 #include <qrgb.h>
0023 #include <qsize.h>
0024 
0025 namespace PerceptualColor
0026 {
0027 /** @brief Constructor
0028  * @param colorSpace The color space within which the image should operate.
0029  * Can be created with @ref RgbColorSpaceFactory. */
0030 ColorWheelImage::ColorWheelImage(const QSharedPointer<PerceptualColor::RgbColorSpace> &colorSpace)
0031     : m_rgbColorSpace(colorSpace)
0032 {
0033 }
0034 
0035 /** @brief Setter for the border property.
0036  *
0037  * The border is the space between the outer outline of the wheel and the
0038  * limits of the image. The wheel is always centered within the limits of
0039  * the image. The default value is <tt>0</tt>, which means that the wheel
0040  * touches the limits of the image.
0041  *
0042  * @param newBorder The new border size, measured in <em>physical
0043  * pixels</em>. */
0044 void ColorWheelImage::setBorder(const qreal newBorder)
0045 {
0046     qreal tempBorder;
0047     if (newBorder >= 0) {
0048         tempBorder = newBorder;
0049     } else {
0050         tempBorder = 0;
0051     }
0052     if (m_borderPhysical != tempBorder) {
0053         m_borderPhysical = tempBorder;
0054         // Free the memory used by the old image.
0055         m_image = QImage();
0056     }
0057 }
0058 
0059 /** @brief Setter for the device pixel ratio (floating point).
0060  *
0061  * This value is set as device pixel ratio (floating point) in the
0062  * <tt>QImage</tt> that this class holds. It does <em>not</em> change
0063  * the <em>pixel</em> size of the image or the pixel size of wheel
0064  * thickness or border.
0065  *
0066  * This is for HiDPI support. You can set this to
0067  * <tt>QWidget::devicePixelRatioF()</tt> to get HiDPI images in the correct
0068  * resolution for your widgets. Within a method of a class derived
0069  * from <tt>QWidget</tt>, you could write:
0070  *
0071  * @snippet testcolorwheelimage.cpp ColorWheelImage HiDPI usage
0072  *
0073  * The default value is <tt>1</tt> which means no special scaling.
0074  *
0075  * @param newDevicePixelRatioF the new device pixel ratio as a
0076  * floating point data type. */
0077 void ColorWheelImage::setDevicePixelRatioF(const qreal newDevicePixelRatioF)
0078 {
0079     qreal tempDevicePixelRatioF;
0080     if (newDevicePixelRatioF >= 1) {
0081         tempDevicePixelRatioF = newDevicePixelRatioF;
0082     } else {
0083         tempDevicePixelRatioF = 1;
0084     }
0085     if (m_devicePixelRatioF != tempDevicePixelRatioF) {
0086         m_devicePixelRatioF = tempDevicePixelRatioF;
0087         // Free the memory used by the old image.
0088         m_image = QImage();
0089     }
0090 }
0091 
0092 /** @brief Setter for the image size property.
0093  *
0094  * This value fixes the size of the image. The image will be a square
0095  * of <tt>QSize(newImageSize, newImageSize)</tt>.
0096  *
0097  * @param newImageSize The new image size, measured in <em>physical
0098  * pixels</em>. */
0099 void ColorWheelImage::setImageSize(const int newImageSize)
0100 {
0101     int tempImageSize;
0102     if (newImageSize >= 0) {
0103         tempImageSize = newImageSize;
0104     } else {
0105         tempImageSize = 0;
0106     }
0107     if (m_imageSizePhysical != tempImageSize) {
0108         m_imageSizePhysical = tempImageSize;
0109         // Free the memory used by the old image.
0110         m_image = QImage();
0111     }
0112 }
0113 
0114 /** @brief Setter for the wheel thickness property.
0115  *
0116  * The wheel thickness is the distance between the inner outline and the
0117  * outer outline of the wheel.
0118  *
0119  * @param newWheelThickness The new wheel thickness, measured
0120  * in <em>physical pixels</em>. */
0121 void ColorWheelImage::setWheelThickness(const qreal newWheelThickness)
0122 {
0123     qreal temp;
0124     if (newWheelThickness >= 0) {
0125         temp = newWheelThickness;
0126     } else {
0127         temp = 0;
0128     }
0129     if (m_wheelThicknessPhysical != temp) {
0130         m_wheelThicknessPhysical = temp;
0131         // Free the memory used by the old image.
0132         m_image = QImage();
0133     }
0134 }
0135 
0136 /** @brief Delivers an image of a color wheel
0137  *
0138  * @returns Delivers a square image of a color wheel. Its size
0139  * is <tt>QSize(imageSize, imageSize)</tt>. All pixels
0140  * that do not belong to the wheel itself will be transparent.
0141  * Antialiasing is used, so there is no sharp border between
0142  * transparent and non-transparent parts. Depending on the
0143  * values for lightness and chroma and the available colors in
0144  * the current color space, there may be some hue who is out of
0145  *  gamut; if so, this part of the wheel will be transparent.
0146  *
0147  * @todo Out-of-gamut situations should automatically be handled. */
0148 QImage ColorWheelImage::getImage()
0149 {
0150     // If image is in cache, simply return the cache.
0151     if (!m_image.isNull()) {
0152         return m_image;
0153     }
0154 
0155     // If no cache is available (m_image.isNull()), render a new image.
0156 
0157     // Special case: zero-size-image
0158     if (m_imageSizePhysical <= 0) {
0159         return m_image;
0160     }
0161 
0162     // construct our final QImage with transparent background
0163     m_image = QImage(QSize(m_imageSizePhysical, m_imageSizePhysical), //
0164                      QImage::Format_ARGB32_Premultiplied);
0165     m_image.fill(Qt::transparent);
0166 
0167     // Calculate diameter of the outer circle
0168     const qreal outerCircleDiameter = //
0169         m_imageSizePhysical - 2 * m_borderPhysical;
0170 
0171     // Special case: an empty image
0172     if (outerCircleDiameter <= 0) {
0173         // Make sure to return a completely transparent image.
0174         // If we would continue, in spite of an outer diameter of 0,
0175         // we might get a non-transparent pixel in the middle.
0176         // Set the correct scaling information for the image and return
0177         m_image.setDevicePixelRatio(m_devicePixelRatioF);
0178         return m_image;
0179     }
0180 
0181     // Generate a temporary non-anti-aliased, intermediate, color wheel,
0182     // but with some pixels extra at the inner and outer side. The overlap
0183     // defines an overlap for the wheel, so there are some more pixels that
0184     // are drawn at the outer and at the inner border of the wheel, to allow
0185     // later clipping with anti-aliasing
0186     PolarPointF polarCoordinates;
0187     int x;
0188     int y;
0189     QRgb rgbColor;
0190     cmsCIELCh cielchD50;
0191     const qreal center = (m_imageSizePhysical - 1) / static_cast<qreal>(2);
0192     m_image = QImage(QSize(m_imageSizePhysical, m_imageSizePhysical), //
0193                      QImage::Format_ARGB32_Premultiplied);
0194     // Because there may be out-of-gamut colors for some hue (depending on the
0195     // given lightness and chroma value) which are drawn transparent, it is
0196     // important to initialize this image with a transparent background.
0197     m_image.fill(Qt::transparent);
0198     cielchD50.L = CielchD50Values::neutralLightness;
0199     cielchD50.C = CielchD50Values::srgbVersatileChroma;
0200     // minimumRadius: Adding "+ 1" would reduce the workload (less pixel to
0201     // process) and still work mostly, but not completely. It creates sometimes
0202     // artifacts in the anti-aliasing process. So we don't do that.
0203     const qreal minimumRadius = //
0204         center - m_wheelThicknessPhysical - m_borderPhysical - overlap;
0205     const qreal maximumRadius = center - m_borderPhysical + overlap;
0206     for (x = 0; x < m_imageSizePhysical; ++x) {
0207         for (y = 0; y < m_imageSizePhysical; ++y) {
0208             polarCoordinates = PolarPointF(QPointF(x - center, center - y));
0209             if (isInRange<qreal>(minimumRadius, polarCoordinates.radius(), maximumRadius)
0210 
0211             ) {
0212                 // We are within the wheel
0213                 cielchD50.h = polarCoordinates.angleDegree();
0214                 rgbColor = m_rgbColorSpace->fromCielabD50ToQRgbOrTransparent( //
0215                     toCmsLab(cielchD50));
0216                 if (qAlpha(rgbColor) != 0) {
0217                     m_image.setPixelColor(x, y, rgbColor);
0218                 }
0219             }
0220         }
0221     }
0222 
0223     // Anti-aliased cut off everything outside the circle (that
0224     // means: the overlap)
0225     // The natural way would be to simply draw a circle with
0226     // QPainter::CompositionMode_DestinationIn which should make transparent
0227     // everything that is not in the circle. Unfortunately, this does not
0228     // seem to work. Therefore, we use a workaround and draw a very think
0229     // circle outline around the circle with QPainter::CompositionMode_Clear.
0230     const qreal circleRadius = outerCircleDiameter / 2;
0231     const qreal cutOffThickness = //
0232         qSqrt(qPow(m_imageSizePhysical, 2) * 2) / 2 // ½ of image diagonal
0233         - circleRadius // circle radius
0234         + overlap; // just to be sure
0235     QPainter myPainter(&m_image);
0236     myPainter.setRenderHint(QPainter::Antialiasing, true);
0237     myPainter.setPen(QPen(Qt::SolidPattern, cutOffThickness));
0238     myPainter.setCompositionMode(QPainter::CompositionMode_Clear);
0239     const qreal halfImageSize = m_imageSizePhysical / static_cast<qreal>(2);
0240     myPainter.drawEllipse(QPointF(halfImageSize, halfImageSize), // center
0241                           circleRadius + cutOffThickness / 2, // width
0242                           circleRadius + cutOffThickness / 2 // height
0243     );
0244 
0245     // set the inner circle of the wheel to anti-aliased transparency
0246     const qreal innerCircleDiameter = //
0247         m_imageSizePhysical - 2 * (m_wheelThicknessPhysical + m_borderPhysical);
0248     if (innerCircleDiameter > 0) {
0249         myPainter.setCompositionMode(QPainter::CompositionMode_Clear);
0250         myPainter.setRenderHint(QPainter::Antialiasing, true);
0251         myPainter.setPen(QPen(Qt::NoPen));
0252         myPainter.setBrush(QBrush(Qt::SolidPattern));
0253         myPainter.drawEllipse( //
0254             QRectF(m_wheelThicknessPhysical + m_borderPhysical, //
0255                    m_wheelThicknessPhysical + m_borderPhysical, //
0256                    innerCircleDiameter, //
0257                    innerCircleDiameter));
0258     }
0259 
0260     // Set the correct scaling information for the image and return
0261     m_image.setDevicePixelRatio(m_devicePixelRatioF);
0262     return m_image;
0263 }
0264 
0265 } // namespace PerceptualColor