Perceptual Color

chromalightnessdiagram.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 "chromalightnessdiagram.h"
7// Second, the private implementation.
8#include "chromalightnessdiagram_p.h" // IWYU pragma: associated
9
10#include "abstractdiagram.h"
11#include "cielchd50values.h"
12#include "constpropagatingrawpointer.h"
13#include "constpropagatinguniquepointer.h"
14#include "helperconstants.h"
15#include "rgbcolorspace.h"
16#include <optional>
17#include <qcolor.h>
18#include <qevent.h>
19#include <qimage.h>
20#include <qlist.h>
21#include <qmath.h>
22#include <qnamespace.h>
23#include <qpainter.h>
24#include <qpen.h>
25#include <qpoint.h>
26#include <qrect.h>
27#include <qrgb.h>
28#include <qsharedpointer.h>
29#include <qsizepolicy.h>
30#include <qwidget.h>
31#include <type_traits>
32#include <utility>
33
34namespace PerceptualColor
35{
36/** @brief The constructor.
37 *
38 * @param colorSpace The color space within which the widget should operate.
39 * Can be created with @ref RgbColorSpaceFactory.
40 *
41 * @param parent Passed to the QWidget base class constructor */
42ChromaLightnessDiagram::ChromaLightnessDiagram(const QSharedPointer<PerceptualColor::RgbColorSpace> &colorSpace, QWidget *parent)
43 : AbstractDiagram(parent)
44 , d_pointer(new ChromaLightnessDiagramPrivate(this))
45{
46 // Setup the color space must be the first thing to do because
47 // other operations rely on a working color space.
48 d_pointer->m_rgbColorSpace = colorSpace;
49
50 // Initialization
51 setFocusPolicy(Qt::FocusPolicy::StrongFocus);
53 d_pointer->m_chromaLightnessImageParameters.imageSizePhysical = //
54 d_pointer->calculateImageSizePhysical();
55 d_pointer->m_chromaLightnessImageParameters.rgbColorSpace = colorSpace;
56 d_pointer->m_chromaLightnessImage.setImageParameters( //
57 d_pointer->m_chromaLightnessImageParameters);
58
59 // Connections
60 connect(&d_pointer->m_chromaLightnessImage, //
61 &AsyncImageProvider<ChromaLightnessImageParameters>::interlacingPassCompleted, //
62 this, //
63 &ChromaLightnessDiagram::callUpdate);
64}
65
66/** @brief Default destructor */
67ChromaLightnessDiagram::~ChromaLightnessDiagram() noexcept
68{
69}
70
71/** @brief Constructor
72 *
73 * @param backLink Pointer to the object from which <em>this</em> object
74 * is the private implementation. */
75ChromaLightnessDiagramPrivate::ChromaLightnessDiagramPrivate(ChromaLightnessDiagram *backLink)
76 : m_currentColorCielchD50(CielchD50Values::srgbVersatileInitialColor)
77 , q_pointer(backLink)
78{
79}
80
81/**
82 * @brief Updates @ref ChromaLightnessDiagram::currentColorCielchD50
83 * corresponding to the given widget pixel position.
84 *
85 * @param widgetPixelPosition The position of a pixel within the widget’s
86 * coordinate system. This does not necessarily need to intersect with the
87 * actually displayed diagram or the gamut. It might even be negative or
88 * outside the widget.
89 *
90 * @post If the pixel position is within the gamut, then the corresponding
91 * @ref ChromaLightnessDiagram::currentColorCielchD50 is set. If the pixel
92 * position is outside the gamut, than a nearby in-gamut color is set (hue is
93 * preserved, chroma and lightness are adjusted). Exception: If the
94 * widget is so small that no diagram is displayed, nothing will happen. */
95void ChromaLightnessDiagramPrivate::setCurrentColorFromWidgetPixelPosition(const QPoint widgetPixelPosition)
96{
97 const GenericColor color = fromWidgetPixelPositionToCielchD50(widgetPixelPosition);
98 q_pointer->setCurrentColorCielchD50(
99 // Search for the nearest color without changing the hue:
100 nearestInGamutCielchD50ByAdjustingChromaLightness(color.second, color.first));
101}
102
103/** @brief The border between the widget outer top, right and bottom
104 * border and the diagram itself.
105 *
106 * @returns The border between the widget outer top, right and bottom
107 * border and the diagram itself.
108 *
109 * The diagram is not painted on the whole extend of the widget.
110 * A border is left to allow that the selection handle can be painted
111 * completely even when a pixel on the border of the diagram is
112 * selected.
113 *
114 * This is the value for the top, right and bottom border. For the left
115 * border, see @ref leftBorderPhysical() instead.
116 *
117 * Measured in <em>physical pixels</em>. */
118int ChromaLightnessDiagramPrivate::defaultBorderPhysical() const
119{
120 const qreal border = q_pointer->handleRadius() //
121 + q_pointer->handleOutlineThickness() / 2.0;
122 return qCeil(border * q_pointer->devicePixelRatioF());
123}
124
125/** @brief The left border between the widget outer left border and the
126 * diagram itself.
127 *
128 * @returns The left border between the widget outer left border and the
129 * diagram itself.
130 *
131 * The diagram is not painted on the whole extend of the widget.
132 * A border is left to allow that the selection handle can be painted
133 * completely even when a pixel on the border of the diagram is
134 * selected. Also, there is space left for the focus indicator.
135 *
136 * This is the value for the left border. For the other three borders,
137 * see @ref defaultBorderPhysical() instead.
138 *
139 * Measured in <em>physical pixels</em>. */
140int ChromaLightnessDiagramPrivate::leftBorderPhysical() const
141{
142 const int focusIndicatorThickness = qCeil( //
143 q_pointer->handleOutlineThickness() * q_pointer->devicePixelRatioF());
144
145 // Candidate 1:
146 const int candidateOne = defaultBorderPhysical() + focusIndicatorThickness;
147
148 // Candidate 2: Generally recommended value for focus indicator:
149 const int candidateTwo = qCeil( //
150 q_pointer->spaceForFocusIndicator() * q_pointer->devicePixelRatioF());
151
152 return qMax(candidateOne, candidateTwo);
153}
154
155/** @brief Calculate a size for @ref m_chromaLightnessImage that corresponds
156 * to the current widget size.
157 *
158 * @returns The size for @ref m_chromaLightnessImage that corresponds
159 * to the current widget size. Measured in <em>physical pixels</em>. */
160QSize ChromaLightnessDiagramPrivate::calculateImageSizePhysical() const
161{
162 const QSize borderSizePhysical(
163 // Borders:
164 leftBorderPhysical() + defaultBorderPhysical(), // left + right
165 2 * defaultBorderPhysical() // top + bottom
166 );
167 return q_pointer->physicalPixelSize() - borderSizePhysical;
168}
169
170/** @brief Converts widget pixel positions to color.
171 *
172 * @param widgetPixelPosition The position of a pixel of the widget
173 * coordinate system. The given value does not necessarily need to
174 * be within the actual displayed widget. It might even be negative.
175 *
176 * @returns The corresponding color for the (center of the) given
177 * widget pixel position. (The value is not normalized. It might have
178 * a negative C value if the position is on the left of the diagram,
179 * or an L value smaller than 0 or bigger than 100…) Exception: If
180 * the widget is too small to show a diagram, a default color is
181 * returned.
182 *
183 * @sa @ref measurementdetails */
184GenericColor ChromaLightnessDiagramPrivate::fromWidgetPixelPositionToCielchD50(const QPoint widgetPixelPosition) const
185{
186 const QPointF offset(leftBorderPhysical(), defaultBorderPhysical());
187 const QPointF imageCoordinatePoint = widgetPixelPosition
188 // Offset to pass from widget reference system
189 // to image reference system:
190 - offset / q_pointer->devicePixelRatioF()
191 // Offset to pass from pixel positions to coordinate points:
192 + QPointF(0.5, 0.5);
193 GenericColor color;
194 color.third = m_currentColorCielchD50.third;
195 const qreal diagramHeight = //
196 calculateImageSizePhysical().height() / q_pointer->devicePixelRatioF();
197 if (diagramHeight > 0) {
198 color.first = imageCoordinatePoint.y() * 100.0 / diagramHeight * (-1.0) + 100.0;
199 color.second = imageCoordinatePoint.x() * 100.0 / diagramHeight;
200 } else {
201 color.first = 50;
202 color.second = 0;
203 }
204 return color;
205}
206
207/** @brief React on a mouse press event.
208 *
209 * Reimplemented from base class.
210 *
211 * Does not differentiate between left, middle and right mouse click.
212 *
213 * If the mouse moves inside the <em>displayed</em> gamut, the handle
214 * is displaced there. If the mouse moves outside the <em>displayed</em>
215 * gamut, the handle is displaced to a nearby in-gamut color.
216 *
217 * @param event The corresponding mouse event
218 *
219 * @internal
220 *
221 * @todo This widget reacts on mouse press events also when they occur
222 * within the border. It might be nice if it would not. On the other
223 * hand: The border is small. Would it really be worth the pain to
224 * implement this? */
225void ChromaLightnessDiagram::mousePressEvent(QMouseEvent *event)
226{
227 d_pointer->m_isMouseEventActive = true;
228 d_pointer->setCurrentColorFromWidgetPixelPosition(event->pos());
229 if (d_pointer->isWidgetPixelPositionInGamut(event->pos())) {
230 setCursor(Qt::BlankCursor);
231 } else {
232 unsetCursor();
233 }
234}
235
236/** @brief React on a mouse move event.
237 *
238 * Reimplemented from base class.
239 *
240 * If the mouse moves inside the <em>displayed</em> gamut, the handle
241 * is displaced there. If the mouse moves outside the <em>displayed</em>
242 * gamut, the handle is displaced to a nearby in-gamut color.
243 *
244 * @param event The corresponding mouse event */
245void ChromaLightnessDiagram::mouseMoveEvent(QMouseEvent *event)
246{
247 d_pointer->setCurrentColorFromWidgetPixelPosition(event->pos());
248 if (d_pointer->isWidgetPixelPositionInGamut(event->pos())) {
249 setCursor(Qt::BlankCursor);
250 } else {
251 unsetCursor();
252 }
253}
254
255/** @brief React on a mouse release event.
256 *
257 * Reimplemented from base class. Does not differentiate between left,
258 * middle and right mouse click.
259 *
260 * If the mouse moves inside the <em>displayed</em> gamut, the handle
261 * is displaced there. If the mouse moves outside the <em>displayed</em>
262 * gamut, the handle is displaced to a nearby in-gamut color.
263 *
264 * @param event The corresponding mouse event */
265void ChromaLightnessDiagram::mouseReleaseEvent(QMouseEvent *event)
266{
267 d_pointer->setCurrentColorFromWidgetPixelPosition(event->pos());
268 unsetCursor();
269}
270
271/** @brief Paint the widget.
272 *
273 * Reimplemented from base class.
274 *
275 * @param event the paint event */
276void ChromaLightnessDiagram::paintEvent(QPaintEvent *event)
277{
278 Q_UNUSED(event)
279 // We do not paint directly on the widget, but on a QImage buffer first:
280 // Render anti-aliased looks better. But as Qt documentation says:
281 //
282 // “Renderhints are used to specify flags to QPainter that may or
283 // may not be respected by any given engine.”
284 //
285 // Painting here directly on the widget might lead to different
286 // anti-aliasing results depending on the underlying window system. This
287 // is especially problematic as anti-aliasing might shift or not a pixel
288 // to the left or to the right. So we paint on a QImage first. As QImage
289 // (at difference to QPixmap and a QWidget) is independent of native
290 // platform rendering, it guarantees identical anti-aliasing results on
291 // all platforms. Here the quote from QPainter class documentation:
292 //
293 // “To get the optimal rendering result using QPainter, you should
294 // use the platform independent QImage as paint device; i.e. using
295 // QImage will ensure that the result has an identical pixel
296 // representation on any platform.”
297 QImage paintBuffer(physicalPixelSize(), //
299 paintBuffer.fill(Qt::transparent);
300 QPainter painter(&paintBuffer);
301 QPen pen;
302 painter.setRenderHint(QPainter::Antialiasing, false);
303
304 // Paint the diagram itself.
305 // Request image update. If the cache is not up-to-date, this
306 // will trigger a new paint event, once the cache has been updated.
307 d_pointer->m_chromaLightnessImage.refreshAsync();
308 const QColor myNeutralGray = //
309 d_pointer->m_rgbColorSpace->fromCielchD50ToQRgbBound(CielchD50Values::neutralGray);
310 painter.setPen(Qt::NoPen);
311 painter.setBrush(myNeutralGray);
312 const auto imageSize = //
313 d_pointer->m_chromaLightnessImage.imageParameters().imageSizePhysical;
314 painter.drawRect( // Paint diagram background
315 // Operating in physical pixels:
316 d_pointer->leftBorderPhysical(), // x position (top-left)
317 d_pointer->defaultBorderPhysical(), // y position (top-left));
318 imageSize.width(),
319 imageSize.height());
320 painter.drawImage( // Paint the diagram itself as available in the cache.
321 // Operating in physical pixels:
322 d_pointer->leftBorderPhysical(), // x position (top-left)
323 d_pointer->defaultBorderPhysical(), // y position (top-left)
324 d_pointer->m_chromaLightnessImage.getCache() // image
325 );
326
327 // Paint a focus indicator.
328 //
329 // We could paint a focus indicator (round or rectangular) around the
330 // handle. Depending on the currently selected hue for the diagram,
331 // it looks ugly because the colors of focus indicator and diagram
332 // do not harmonize, or it is mostly invisible the the colors are
333 // similar. So this approach does not work well.
334 //
335 // It seems better to paint a focus indicator for the whole widget.
336 // We could use the style primitives to paint a rectangular focus
337 // indicator around the whole widget:
338 //
339 // style()->drawPrimitive(
340 // QStyle::PE_FrameFocusRect,
341 // &option,
342 // &painter,
343 // this
344 // );
345 //
346 // However, this does not work well because the chroma-lightness
347 // diagram has usually a triangular shape. The style primitive, however,
348 // often paints just a line at the bottom of the widget. That does not
349 // look good. An alternative approach is that we paint ourselves a focus
350 // indicator only on the left of the diagram (which is the place of
351 // black/gray/white, so the won't be any problems with non-harmonic
352 // colors).
353 //
354 // Then we have to design the line that we want to display. It is better
355 // to do that ourselves instead of relying on generic QStyle::PE_Frame or
356 // similar solutions as their result seems to be quite unpredictable across
357 // various styles. So we use handleOutlineThickness as line width and
358 // paint it at the left-most possible position.
359 if (hasFocus()) {
360 pen = QPen();
361 pen.setWidthF(handleOutlineThickness() * devicePixelRatioF());
362 pen.setColor(focusIndicatorColor());
363 pen.setCapStyle(Qt::PenCapStyle::FlatCap);
364 painter.setPen(pen);
365 painter.setRenderHint(QPainter::Antialiasing, true);
366 const QPointF pointOne(
367 // x:
368 handleOutlineThickness() * devicePixelRatioF() / 2.0,
369 // y:
370 0 + d_pointer->defaultBorderPhysical());
371 const QPointF pointTwo(
372 // x:
373 handleOutlineThickness() * devicePixelRatioF() / 2.0,
374 // y:
375 physicalPixelSize().height() - d_pointer->defaultBorderPhysical());
376 painter.drawLine(pointOne, pointTwo);
377 }
378
379 // Paint the handle on-the-fly.
380 const int diagramHeight = d_pointer->calculateImageSizePhysical().height();
381 QPointF colorCoordinatePoint = QPointF(
382 // x:
383 d_pointer->m_currentColorCielchD50.second * diagramHeight / 100.0,
384 // y:
385 d_pointer->m_currentColorCielchD50.first * diagramHeight / 100.0 * (-1) + diagramHeight);
386 colorCoordinatePoint += QPointF(
387 // horizontal offset:
388 d_pointer->leftBorderPhysical(),
389 // vertical offset:
390 d_pointer->defaultBorderPhysical());
391 pen = QPen();
392 pen.setWidthF(handleOutlineThickness() * devicePixelRatioF());
393 pen.setColor(handleColorFromBackgroundLightness(d_pointer->m_currentColorCielchD50.first));
394 painter.setPen(pen);
395 painter.setBrush(Qt::NoBrush);
396 painter.setRenderHint(QPainter::Antialiasing, true);
397 painter.drawEllipse(colorCoordinatePoint, // center
398 handleRadius() * devicePixelRatioF(), // x radius
399 handleRadius() * devicePixelRatioF() // y radius
400 );
401
402 // Paint the buffer to the actual widget
403 paintBuffer.setDevicePixelRatio(devicePixelRatioF());
404 QPainter widgetPainter(this);
405 widgetPainter.setRenderHint(QPainter::Antialiasing, true);
406 widgetPainter.drawImage(0, 0, paintBuffer);
407}
408
409/** @brief React on key press events.
410 *
411 * Reimplemented from base class.
412 *
413 * When the arrow keys are pressed, it moves the
414 * handle a small step into the desired direction.
415 * When <tt>Qt::Key_PageUp</tt>, <tt>Qt::Key_PageDown</tt>,
416 * <tt>Qt::Key_Home</tt> or <tt>Qt::Key_End</tt> are pressed, it moves the
417 * handle a big step into the desired direction.
418 *
419 * Other key events are forwarded to the base class.
420 *
421 * @param event the event
422 *
423 * @internal
424 *
425 * @todo Is the current behaviour (when pressing right arrow while yet
426 * at the right border of the gamut, also the lightness is adjusted to
427 * allow moving actually to the right) really a good idea? Anyway, it
428 * has a bug, and arrow-down does not work on blue hues because the
429 * gamut has some sort of corner, and there, the curser blocks. */
430void ChromaLightnessDiagram::keyPressEvent(QKeyEvent *event)
431{
432 GenericColor temp = d_pointer->m_currentColorCielchD50;
433 switch (event->key()) {
434 case Qt::Key_Up:
435 temp.first += singleStepLightness;
436 break;
437 case Qt::Key_Down:
438 temp.first -= singleStepLightness;
439 break;
440 case Qt::Key_Left:
441 temp.second = qMax<double>(0, temp.second - singleStepChroma);
442 break;
443 case Qt::Key_Right:
444 temp.second += singleStepChroma;
445 temp = d_pointer->m_rgbColorSpace->reduceCielchD50ChromaToFitIntoGamut(temp);
446 break;
447 case Qt::Key_PageUp:
448 temp.first += pageStepLightness;
449 break;
450 case Qt::Key_PageDown:
451 temp.first -= pageStepLightness;
452 break;
453 case Qt::Key_Home:
454 temp.second += pageStepChroma;
455 temp = d_pointer->m_rgbColorSpace->reduceCielchD50ChromaToFitIntoGamut(temp);
456 break;
457 case Qt::Key_End:
458 temp.second = qMax<double>(0, temp.second - pageStepChroma);
459 break;
460 default:
461 // Quote from Qt documentation:
462 //
463 // “If you reimplement this handler, it is very important that
464 // you call the base class implementation if you do not act
465 // upon the key.
466 //
467 // The default implementation closes popup widgets if the
468 // user presses the key sequence for QKeySequence::Cancel
469 // (typically the Escape key). Otherwise the event is
470 // ignored, so that the widget’s parent can interpret it.“
472 return;
473 }
474 // Here we reach only if the key has been recognized. If not, in the
475 // default branch of the switch statement, we would have passed the
476 // keyPressEvent yet to the parent and returned.
477
478 // Set the new color (only takes effect when the color is indeed different).
479 setCurrentColorCielchD50(
480 // Search for the nearest color without changing the hue:
481 d_pointer->m_rgbColorSpace->reduceCielchD50ChromaToFitIntoGamut(temp));
482 // TODO Instead of this, simply do setCurrentColor(temp); but guarantee
483 // for up, down, page-up and page-down that the lightness is raised
484 // or reduced until fitting into the gamut. Maybe find a way to share
485 // code with reduceChromaToFitIntoGamut ?
486}
487
488/** @brief Tests if a given widget pixel position is within
489 * the <em>displayed</em> gamut.
490 *
491 * @param widgetPixelPosition The position of a pixel of the widget coordinate
492 * system. The given value does not necessarily need to be within the
493 * actual displayed diagram or even the gamut itself. It might even be
494 * negative.
495 *
496 * @returns <tt>true</tt> if the widget pixel position is within the
497 * <em>currently displayed gamut</em>. Otherwise <tt>false</tt>.
498 *
499 * @internal
500 *
501 * @todo How does isInGamut() react? Does it also control valid chroma
502 * and lightness ranges? */
503bool ChromaLightnessDiagramPrivate::isWidgetPixelPositionInGamut(const QPoint widgetPixelPosition) const
504{
505 if (calculateImageSizePhysical().isEmpty()) {
506 // If there is no displayed gamut, the answer must be false.
507 // But fromWidgetPixelPositionToColor() would return an in-gamut
508 // fallback color nevertheless. Therefore, we have to catch
509 // the special case with an empty diagram here manually.
510 return false;
511 }
512
513 const GenericColor color = fromWidgetPixelPositionToCielchD50(widgetPixelPosition);
514
515 // Test if C is in range. This is important because a negative C value
516 // can be in-gamut, but is not in the _displayed_ gamut.
517 if (color.second < 0) {
518 return false;
519 }
520
521 // Actually for in-gamut color:
522 return m_rgbColorSpace->isCielchD50InGamut(color);
523}
524
525/** @brief Setter for the @ref currentColorCielchD50() property.
526 *
527 * @param newCurrentColorCielchD50 the new @ref currentColorCielchD50
528 *
529 * @todo When an out-of-gamut color is given, both lightness and chroma
530 * are adjusted. But does this really make sense? In @ref WheelColorPicker,
531 * when using the hue wheel, also <em>both</em>, lightness <em>and</em> chroma
532 * will change. Isn’t that confusing? */
533void ChromaLightnessDiagram::setCurrentColorCielchD50(const PerceptualColor::GenericColor &newCurrentColorCielchD50)
534{
535 if (newCurrentColorCielchD50 == d_pointer->m_currentColorCielchD50) {
536 return;
537 }
538
539 double oldHue = d_pointer->m_currentColorCielchD50.third;
540 d_pointer->m_currentColorCielchD50 = newCurrentColorCielchD50;
541 if (d_pointer->m_currentColorCielchD50.third != oldHue) {
542 // Update the diagram (only if the hue has changed):
543 d_pointer->m_chromaLightnessImageParameters.hue = //
544 d_pointer->m_currentColorCielchD50.third;
545 d_pointer->m_chromaLightnessImage.setImageParameters( //
546 d_pointer->m_chromaLightnessImageParameters);
547 }
548 update(); // Schedule a paint event
549 Q_EMIT currentColorCielchD50Changed(newCurrentColorCielchD50);
550}
551
552/** @brief React on a resize event.
553 *
554 * Reimplemented from base class.
555 *
556 * @param event The corresponding event */
557void ChromaLightnessDiagram::resizeEvent(QResizeEvent *event)
558{
559 Q_UNUSED(event)
560 d_pointer->m_chromaLightnessImageParameters.imageSizePhysical = //
561 d_pointer->calculateImageSizePhysical();
562 d_pointer->m_chromaLightnessImage.setImageParameters( //
563 d_pointer->m_chromaLightnessImageParameters);
564 // As by Qt documentation:
565 // “The widget will be erased and receive a paint event
566 // immediately after processing the resize event. No drawing
567 // need be (or should be) done inside this handler.”
568}
569
570/** @brief Recommended size for the widget.
571 *
572 * Reimplemented from base class.
573 *
574 * @returns Recommended size for the widget.
575 *
576 * @sa @ref minimumSizeHint() */
577QSize ChromaLightnessDiagram::sizeHint() const
578{
579 return minimumSizeHint() * scaleFromMinumumSizeHintToSizeHint;
580}
581
582/** @brief Recommended minimum size for the widget
583 *
584 * Reimplemented from base class.
585 *
586 * @returns Recommended minimum size for the widget.
587 *
588 * @sa @ref sizeHint() */
589QSize ChromaLightnessDiagram::minimumSizeHint() const
590{
591 const int minimumHeight = qRound(
592 // Top border and bottom border:
593 2.0 * d_pointer->defaultBorderPhysical() / devicePixelRatioF()
594 // Add the height for the diagram:
595 + gradientMinimumLength());
596 const int minimumWidth = qRound(
597 // Left border and right border:
598 (d_pointer->leftBorderPhysical() + d_pointer->defaultBorderPhysical()) / devicePixelRatioF()
599 // Add the gradient minimum length from y axis, multiplied with
600 // the factor to allow at correct scaling showing up the whole
601 // chroma range of the gamut.
602 + gradientMinimumLength() * d_pointer->m_rgbColorSpace->profileMaximumCielchD50Chroma() / 100.0);
603 // Expand to the global minimum size for GUI elements
604 return QSize(minimumWidth, minimumHeight);
605}
606
607// No documentation here (documentation of properties
608// and its getters are in the header)
609GenericColor PerceptualColor::ChromaLightnessDiagram::currentColorCielchD50() const
610{
611 return d_pointer->m_currentColorCielchD50;
612}
613
614/** @brief An abstract Nearest-neighbor-search algorithm.
615 *
616 * There are many different solutions for
617 * <a href="https://en.wikipedia.org/wiki/Nearest_neighbor_search">
618 * Nearest-neighbor-searches</a>. This one is not naive, but still quite easy
619 * to implement. It is based on <a href="https://stackoverflow.com/a/307523">
620 * this Stackoverflow answer</a>.
621 *
622 * @param point The point to which the nearest neighbor is searched.
623 * @param searchRectangle The rectangle within which the algorithm searches
624 * for a nearest neighbor. All points outside this rectangle are
625 * ignored.
626 * @param doesPointExist A callback function that must return <tt>true</tt>
627 * for points that are considered to exist, and <tt>false</tt> for
628 * points that are considered to no exist. This callback function will
629 * never be called with points outside the search rectangle.
630 * @returns The nearest neighbor, if any. An empty value otherwise. If there
631 * are multiple non-transparent pixels at the same distance, it is
632 * indeterminate which one is returned. Note that the point itself is
633 * considered to be itself its nearest neighbor if it is within the
634 * search rectangle and considered by the test function to exist. */
635std::optional<QPoint>
636ChromaLightnessDiagramPrivate::nearestNeighborSearch(const QPoint point, const QRect searchRectangle, const std::function<bool(const QPoint)> &doesPointExist)
637{
638 if (!searchRectangle.isValid()) {
639 return std::nullopt;
640 }
641 // A valid QRect is non-empty, as described by QRect documentation…
642
643 // Test for special case:
644 // originalPixelPosition itself is within the image and non-transparent
645 if (searchRectangle.contains(point)) {
646 if (doesPointExist(point)) {
647 return point;
648 }
649 }
650
651 // We search the perimeter of a square that we keep moving out one pixel
652 // at a time from the original point (“offset”).
653
654 const auto hDistanceFromRect = distanceFromRange(searchRectangle.left(), //
655 point.x(),
656 searchRectangle.right());
657 const auto vDistanceFromRect = distanceFromRange(searchRectangle.top(), //
658 point.y(),
659 searchRectangle.bottom());
660 // As described at https://stackoverflow.com/a/307523:
661 // An offset of “0” means that only the original point itself is searched
662 // for. This is inefficient, because all eight search points will be
663 // identical for an offset of “0”. And because we test yet for the
664 // original point itself as a special case above, we can start here with
665 // an offset ≥ 0.
666 const auto initialOffset = qMax(1, //
667 qMax(hDistanceFromRect, vDistanceFromRect));
668 const auto hMaxDistance = qMax(qAbs(point.x() - searchRectangle.left()), //
669 qAbs(point.x() - searchRectangle.right()));
670 const auto vMaxDistance = qMax(qAbs(point.y() - searchRectangle.top()), //
671 qAbs(point.y() - searchRectangle.bottom()));
672 const auto maximumOffset = qMax(hMaxDistance, vMaxDistance);
673 std::optional<QPoint> nearestPointTillNow;
674 int nearestPointTillNowDistanceSquare = 0;
675 qreal nearestPointTillNowDistance = 0.0;
676 QPoint searchPoint;
677 auto searchPointOffsets = [](int i, int j) -> QList<QPoint> {
678 return QList<QPoint>({
679 QPoint(i, j), // right
680 QPoint(i, -j), // right
681 QPoint(-i, j), // left
682 QPoint(-i, -j), // left
683 QPoint(j, i), // bottom
684 QPoint(-j, i), // bottom
685 QPoint(j, -i), // top
686 QPoint(-j, -i) // top
687 });
688 };
689 int i;
690 int j;
691 // As described at https://stackoverflow.com/a/307523:
692 // The search starts at the four points that intersect the axes and moves
693 // one pixel at a time towards the corners. (We have have 8 moving search
694 // points). As soon as we locate an existing point, there is no need to
695 // continue towards the corners, as the remaining points are all further
696 // from the original point.
697 for (i = initialOffset; //
698 (i <= maximumOffset) && (!nearestPointTillNow.has_value()); //
699 ++i //
700 ) {
701 for (j = 0; (j <= i) && (!nearestPointTillNow.has_value()); ++j) {
702 const auto container = searchPointOffsets(i, j);
703 for (const QPoint &temp : std::as_const(container)) {
704 // TODO A possible optimization might be to not always use all
705 // eight search points. Imagine you have an original point
706 // that is outside the image, at its left side. The search
707 // point on the left line of the search perimeter rectangle
708 // will always be out-of-boundary, so there is no need
709 // to calculate the search points, just to find out later
710 // that these points are outside the searchRectangle. But
711 // how could an elegant implementation look like?
712 searchPoint = point + temp;
713 if (searchRectangle.contains(searchPoint)) {
714 if (doesPointExist(searchPoint)) {
715 nearestPointTillNow = searchPoint;
716 nearestPointTillNowDistanceSquare = //
717 temp.x() * temp.x() + temp.y() * temp.y();
718 nearestPointTillNowDistance = qSqrt( //
719 nearestPointTillNowDistanceSquare);
720 break;
721 }
722 }
723 }
724 }
725 }
726
727 if (!nearestPointTillNow.has_value()) {
728 // There is not one single pixel that is valid in the
729 // whole searchRectangle.
730 return nearestPointTillNow;
731 }
732
733 i += 1;
734 // After the initial search for the nearest-neighbor-point, we must
735 // continue to search the perimeter of wider squares until we reach an
736 // offset of “nearestPointTillNowDistance”. However, the search points
737 // no longer have to travel ("j") all the way to the corners: They can
738 // stop when they reach a pixel that is farther away from the original
739 // point than the current "nearest-neighbor-point" candidate."
740 for (; i < nearestPointTillNowDistance; ++i) {
741 qreal maximumJ = qSqrt(nearestPointTillNowDistanceSquare - i * i);
742 for (j = 0; j < maximumJ; ++j) {
743 const auto container = searchPointOffsets(i, j);
744 for (const QPoint &temp : std::as_const(container)) {
745 searchPoint = point + temp;
746 if (searchRectangle.contains(searchPoint)) {
747 if (doesPointExist(searchPoint)) {
748 nearestPointTillNow = searchPoint;
749 nearestPointTillNowDistanceSquare = //
750 temp.x() * temp.x() + temp.y() * temp.y();
751 nearestPointTillNowDistance = qSqrt( //
752 nearestPointTillNowDistanceSquare);
753 maximumJ = qSqrt( //
754 nearestPointTillNowDistanceSquare - i * i);
755 break;
756 }
757 }
758 }
759 }
760 }
761
762 return nearestPointTillNow;
763}
764
765/** @brief Search the nearest in-gamut neighbor pixel.
766 *
767 * @param originalPixelPosition The pixel for which you search the nearest
768 * neighbor, expressed in the coordinate system of the image. This pixel may
769 * be inside or outside the image.
770 * @returns The nearest non-transparent pixel of @ref m_chromaLightnessImage,
771 * if any. An empty value otherwise. If there are multiple
772 * non-transparent pixels at the same distance, it is
773 * indeterminate which one is returned. Note that the point itself
774 * is considered to be itself its nearest neighbor if it is within
775 * the image and non-transparent.
776 *
777 * @note This function waits until a full-quality @ref m_chromaLightnessImage
778 * is available, which might take some time.
779 *
780 * @todo A possible optimization might be to search initially, after a new
781 * image is available, entire columns, starting from the right, until we hit
782 * the first column that has a non-transparent pixel. This information can be
783 * used to reduce the search rectangle significantly. */
784std::optional<QPoint> ChromaLightnessDiagramPrivate::nearestInGamutPixelPosition(const QPoint originalPixelPosition)
785{
786 m_chromaLightnessImage.refreshSync();
787 const auto upToDateImage = m_chromaLightnessImage.getCache();
788
789 auto isOpaqueFunction = [&upToDateImage](const QPoint point) -> bool {
790 return (qAlpha(upToDateImage.pixel(point)) != 0);
791 };
792 return nearestNeighborSearch(originalPixelPosition, //
793 QRect(QPoint(0, 0), upToDateImage.size()), //
794 isOpaqueFunction);
795}
796
797/** @brief Find the nearest in-gamut pixel.
798 *
799 * The hue is assumed to be the current hue at @ref m_currentColorCielchD50.
800 * Chroma and lightness are sacrificed, but the hue is preserved. This function
801 * works at the precision of the current @ref m_chromaLightnessImage.
802 *
803 * @param chroma Chroma of the original color.
804 *
805 * @param lightness Lightness of the original color.
806 *
807 * @note This function waits until a full-quality @ref m_chromaLightnessImage
808 * is available, which might take some time.
809 *
810 * @returns The nearest in-gamut pixel with the same hue as the original
811 * color. */
812PerceptualColor::GenericColor ChromaLightnessDiagramPrivate::nearestInGamutCielchD50ByAdjustingChromaLightness(const double chroma, const double lightness)
813{
814 // Initialization
815 GenericColor temp;
816 temp.first = lightness;
817 temp.second = chroma;
818 temp.third = m_currentColorCielchD50.third;
819 if (temp.second < 0) {
820 temp.second = 0;
821 }
822
823 // Return is we are within the gamut.
824 // NOTE Calling isInGamut() is slower than simply testing for the pixel,
825 // it is more exact.
826 if (m_rgbColorSpace->isCielchD50InGamut(temp)) {
827 return temp;
828 }
829
830 const auto imageHeight = calculateImageSizePhysical().height();
831 QPoint myPixelPosition( //
832 qRound(temp.second * (imageHeight - 1) / 100.0),
833 qRound(imageHeight - 1 - temp.first * (imageHeight - 1) / 100.0));
834
835 myPixelPosition = //
836 nearestInGamutPixelPosition(myPixelPosition).value_or(QPoint(0, 0));
837 GenericColor result = temp;
838 result.second = myPixelPosition.x() * 100.0 / (imageHeight - 1);
839 result.first = 100 - myPixelPosition.y() * 100.0 / (imageHeight - 1);
840 return result;
841}
842
843} // namespace PerceptualColor
AKONADI_CALENDAR_EXPORT KCalendarCore::Event::Ptr event(const Akonadi::Item &item)
void update(Part *part, const QByteArray &data, qint64 dataSize)
KGUIADDONS_EXPORT qreal chroma(const QColor &)
The namespace of this library.
Format_ARGB32_Premultiplied
void setCapStyle(Qt::PenCapStyle style)
void setColor(const QColor &color)
void setWidthF(qreal width)
int x() const const
int y() const const
qreal x() const const
qreal y() const const
int bottom() const const
bool contains(const QPoint &point, bool proper) const const
bool isValid() const const
int left() const const
int right() const const
int top() const const
int height() const const
BlankCursor
transparent
QFuture< ArgsType< Signal > > connect(Sender *sender, Signal signal)
virtual void keyPressEvent(QKeyEvent *event)
This file is part of the KDE documentation.
Documentation copyright © 1996-2024 The KDE developers.
Generated on Sat Dec 21 2024 16:57:17 by doxygen 1.12.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.