Perceptual Color

chromahueimageparameters.cpp
1// SPDX-FileCopyrightText: Lukas Sommer <sommerluk@gmail.com>
2// SPDX-License-Identifier: BSD-2-Clause OR MIT
3
4// Own headers
5// First the interface, which forces the header to be self-contained.
6#include "chromahueimageparameters.h"
7
8#include "asyncimagerendercallback.h"
9#include "cielchd50values.h"
10#include "helperconstants.h"
11#include "helperimage.h"
12#include "helpermath.h"
13#include "helperqttypes.h"
14#include "interlacingpass.h"
15#include "rgbcolorspace.h"
16#include <lcms2.h>
17#include <qcolor.h>
18#include <qimage.h>
19#include <qmath.h>
20#include <qnamespace.h>
21#include <qpainter.h>
22#include <qrgb.h>
23#include <qsharedpointer.h>
24#include <qsize.h>
25#include <type_traits>
26
27namespace PerceptualColor
28{
29/** @brief Equal operator
30 *
31 * @param other The object to compare with.
32 *
33 * @returns <tt>true</tt> if equal, <tt>false</tt> otherwise. */
34bool ChromaHueImageParameters::operator==(const ChromaHueImageParameters &other) const
35{
36 return ( //
37 (borderPhysical == other.borderPhysical) //
38 && (devicePixelRatioF == other.devicePixelRatioF) //
39 && (imageSizePhysical == other.imageSizePhysical) //
40 && (lightness == other.lightness) //
41 && (rgbColorSpace == other.rgbColorSpace) //
42 );
43}
44
45/** @brief Unequal operator
46 *
47 * @param other The object to compare with.
48 *
49 * @returns <tt>true</tt> if unequal, <tt>false</tt> otherwise. */
50bool ChromaHueImageParameters::operator!=(const ChromaHueImageParameters &other) const
51{
52 return !(*this == other);
53}
54
55/** @brief Render an image.
56 *
57 * The function will render the image with the given parameters,
58 * and deliver the result of each interlacing pass and also the final
59 * result by means of <tt>callbackObject</tt>.
60 *
61 * This function is thread-safe as long as each call of this function
62 * uses different <tt>variantParameters</tt> and <tt>callbackObject</tt>.
63 *
64 * @param variantParameters A <tt>QVariant</tt> that contains the
65 * image parameters.
66 * @param callbackObject Pointer to the object for the callbacks.
67 *
68 * @todo Could we get better performance? Even online tools like
69 * https://bottosson.github.io/misc/colorpicker/#ff2a00 or
70 * https://oklch.evilmartians.io/#65.4,0.136,146.7,100 get quite good
71 * performance. How do they do that? */
72void ChromaHueImageParameters::render(const QVariant &variantParameters, AsyncImageRenderCallback &callbackObject)
73{
74 if (!variantParameters.canConvert<ChromaHueImageParameters>()) {
75 return;
76 }
77 const ChromaHueImageParameters parameters = //
78 variantParameters.value<ChromaHueImageParameters>();
79
80 // From Qt Example’s documentation:
81 //
82 // “If we discover […] that restart has been set
83 // to true (by render()), we break out […] immediately […].
84 // Similarly, if we discover that abort has been set
85 // to true (by the […] destructor), we return from the
86 // function immediately […].”
87 if (callbackObject.shouldAbort()) {
88 return;
89 }
90 // Create a new QImage with correct image size.
91 QImage myImage(
92 // size:
93 QSize(parameters.imageSizePhysical, parameters.imageSizePhysical),
94 // format:
96 // Calculate the radius of the circle we want to paint (and which will
97 // finally have the background color, while everything around will be
98 // transparent).
99 const qreal circleRadius = //
100 (parameters.imageSizePhysical - 2 * parameters.borderPhysical) / 2.;
101 if ((circleRadius <= 0) || parameters.rgbColorSpace.isNull()) {
102 // The border is too big the and image size too small: The size
103 // of the circle is zero. Or: There is no color space with which
104 // we can work (and dereferencing parameters.rgbColorSpace will
105 // crash).
106 // In either case: The image will therefore be transparent.
107 // Initialize the image as completely transparent and return.
108 myImage.fill(Qt::transparent);
109 // Set the correct scaling information for the image and return
110 myImage.setDevicePixelRatio(parameters.devicePixelRatioF);
111 callbackObject.deliverInterlacingPass( //
112 myImage, //
113 QImage(), //
114 variantParameters, //
115 AsyncImageRenderCallback::InterlacingState::Final);
116 return;
117 }
118
119 // If we continue, the circle will at least be visible.
120
121 // Initialize the hole image background:
122 myImage.fill(Qt::transparent);
123
124 // Prepare for gamut painting
125 cmsCIELab cielabD50;
126 cielabD50.L = parameters.lightness;
127 QRgb tempColor;
128 const auto chromaRange = //
129 parameters.rgbColorSpace->profileMaximumCielchD50Chroma();
130 const qreal scaleFactor = static_cast<qreal>(2 * chromaRange)
131 // The following line will never be 0 because we have have
132 // tested above that circleRadius is > 0, so this line will
133 // we > 0 also.
134 / (parameters.imageSizePhysical - 2 * parameters.borderPhysical);
135
136 // Paint the gamut.
137
138 // The pixel at position QPoint(x, y) is the square with the top-left
139 // edge at coordinate point QPoint(x, y) and the bottom-right edge at
140 // coordinate point QPoint(x+1, y+1). This pixel is supposed to have
141 // the color from coordinate point QPoint(x+0.5, y+0.5), which is
142 // the middle of this pixel. Therefore, with an offset of 0.5 we
143 // can convert from the pixel position to the point in the middle of
144 // the pixel.
145 constexpr qreal pixelOffset = 0.5;
146
147 const auto shift = pixelOffset - parameters.borderPhysical;
148
149 // The reference size (assumed to be a typical/common size) for the image:
150 constexpr double referenceSizePhysical = 343;
151 const auto factor = parameters.imageSizePhysical / referenceSizePhysical;
152 // The number of appropriate interlacing passes at the reference size:
153 constexpr int numberOfPassesAtReferenceSize = 5;
154 static_assert(isOdd(numberOfPassesAtReferenceSize));
155 // The number of actual passes
156 const auto numberOfPasses = //
157 numberOfPassesAtReferenceSize //
158 // qMax makes sure std::log2() is never called with a parameter ≤ 0
159 + 2 * std::log2(qMax(0.01, factor));
160 InterlacingPass currentPass(numberOfPasses);
161
162 QPainter myPainter(&myImage);
163 myPainter.setRenderHint(QPainter::Antialiasing, false);
164 while (true) {
165 for (int y = currentPass.lineOffset; //
166 y < parameters.imageSizePhysical; //
167 y += currentPass.lineFrequency) //
168 {
169 if (callbackObject.shouldAbort()) {
170 return;
171 }
172 cielabD50.b = chromaRange //
173 - (y + shift) * scaleFactor;
174 for (int x = currentPass.columnOffset; //
175 x < parameters.imageSizePhysical; //
176 x += currentPass.columnFrequency //
177 ) {
178 cielabD50.a = //
179 (x + shift) * scaleFactor //
180 - chromaRange;
181 if ( //
182 (qPow(cielabD50.a, 2) + qPow(cielabD50.b, 2)) //
183 <= (qPow(chromaRange + overlap, 2)) //
184 ) {
185 tempColor = //
186 parameters
187 .rgbColorSpace //
188 ->fromCielabD50ToQRgbOrTransparent(cielabD50);
189 if (qAlpha(tempColor) != 0) {
190 // The pixel is within the gamut!
191 myPainter.fillRect(
192 //
193 x, //
194 y, //
195 currentPass.rectangleSize.width(), //
196 currentPass.rectangleSize.height(), //
197 QColor(tempColor));
198 } else {
199 myPainter.save();
200 myPainter.setCompositionMode(
201 // Allow making the background transparent.
203 myPainter.fillRect(
204 //
205 x, //
206 y, //
207 currentPass.rectangleSize.width(), //
208 currentPass.rectangleSize.height(), //
210 myPainter.restore();
211 }
212 }
213 }
214 }
215
216 myImage.setDevicePixelRatio(parameters.devicePixelRatioF);
217 callbackObject.deliverInterlacingPass( //
218 myImage, //
219 QImage(), //
220 variantParameters, //
221 // We return the state “Intermediate” even when the final
222 // interlacing step of the Adam-interlacing has finished.
223 // This is because we will still to some antialiasing in a
224 // final step, which is independent from the Adam-interlacing.
225 AsyncImageRenderCallback::InterlacingState::Intermediate);
226 myImage.setDevicePixelRatio(1);
227
228 if (currentPass.countdown > 1) {
229 currentPass.switchToNextPass();
230 } else {
231 break;
232 }
233 }
234
235 // cppcheck-suppress knownConditionTrueFalse // false positive
236 if (callbackObject.shouldAbort()) {
237 return;
238 }
239
240 // Anti-aliasing
241
242 // The drawn gamut body has a sharp, non-anti-aliased border against the
243 // background, which looks unappealing. While recalculating the entire
244 // image at a higher resolution and then downscaling would provide
245 // anti-aliasing, this approach is computationally expensive. Instead, we
246 // take an optimized approach: we detect all pixels located at the border
247 // between the gamut body and the background (on both sides of the
248 // boundary) and store their coordinates in a duplicate-free container.
249 // Anti-aliased values are then computed exclusively for these pixels,
250 // reducing overhead while improving visual quality.
251
252 // NOTE: Outside the circle, artefacts from previous rendering steps may
253 // persist, as subsequent steps clean up artefacts only within the circle
254 // for performance reasons. When detecting boundary pixels, some artefact
255 // pixels might be included in the search results. However, this does not
256 // negatively impact the image, as it only affects pixels outside the
257 // defined circle. While performing unnecessary rendering operations is
258 // inefficient, filtering out these artefacts beforehand would be complex.
259 // Thus, for now, we leave the code as-is.
260
261 QList<QPoint> antiAliasCoordinates = findBoundary(myImage);
262
263 // cppcheck-suppress knownConditionTrueFalse // false positive
264 if (callbackObject.shouldAbort()) {
265 return;
266 }
267
268 const auto myColorFunction = [parameters, shift, scaleFactor, chromaRange](const double x, const double y) -> QRgb {
269 cmsCIELab myCielabD50;
270 myCielabD50.L = parameters.lightness;
271 myCielabD50.b = chromaRange - (y + shift) * scaleFactor;
272 myCielabD50.a = (x + shift) * scaleFactor - chromaRange;
273 return parameters.rgbColorSpace->fromCielabD50ToQRgbOrTransparent( //
274 myCielabD50);
275 };
276 doAntialias(myImage, antiAliasCoordinates, myColorFunction);
277
278 if (callbackObject.shouldAbort()) {
279 return;
280 }
281
282 myImage.setDevicePixelRatio(parameters.devicePixelRatioF);
283 callbackObject.deliverInterlacingPass( //
284 myImage, //
285 QImage(), //
286 variantParameters, //
287 AsyncImageRenderCallback::InterlacingState::Final);
288}
289
290static_assert(std::is_standard_layout_v<ChromaHueImageParameters>);
291
292} // namespace PerceptualColor
The namespace of this library.
void doAntialias(QImage &image, const QList< QPoint > &antiAliasCoordinates, const std::function< QRgb(const double x, const double y)> &colorFunction)
Calculates anti-alias for gamut diagrams.
QList< QPoint > findBoundary(const QImage &image)
Find boundaries between fully opaque and fully transparent pixels.
Format_ARGB32_Premultiplied
CompositionMode_Clear
transparent
bool canConvert() const const
T value() const const
This file is part of the KDE documentation.
Documentation copyright © 1996-2025 The KDE developers.
Generated on Fri Apr 25 2025 12:03:13 by doxygen 1.13.2 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.