#include "Document/BrushStroke.h" #include "Document/EditorSession.h" #include "IO/ProjectStore+Json.h" #include "UI/FilterSheet.h" #include #include #include #include #include #include #include // Black & White, Color Balance: kernels, bounds, keys, sheet. namespace { QImage solid(QColor colour) { QImage image(2, 2, QImage::Format_RGBA8888_Premultiplied); image.fill(colour); return image; } QColor first(const QImage &image) { return image.pixelColor(0, 0); } bool near(const QColor &actual, const QColor &expected) { return std::abs(actual.red() - expected.red()) <= 1 && std::abs(actual.green() - expected.green()) <= 1 && std::abs(actual.blue() - expected.blue()) <= 1 && actual.alpha() == expected.alpha(); } // Photoshop's tint: the gray as lightness at a hue, HSL. QColor tinted(double gray, double hue, double saturation) { const double c = (1 - std::abs(2 * gray - 1)) * saturation, h = hue / 60, x = c * (1 - std::abs(std::fmod(h, 2) - 1)), m = gray - c / 2; const std::array rgb = h < 1 ? std::array{c, x, 0.0} : h < 2 ? std::array{x, c, 0.0} : h < 3 ? std::array{0.0, c, x} : h < 4 ? std::array{0.0, x, c} : h < 5 ? std::array{x, 0.0, c} : std::array{c, 0.0, x}; return QColor(int(std::lround((rgb[0] + m) * 255)), int(std::lround((rgb[1] + m) * 255)), int(std::lround((rgb[2] + m) * 255))); } struct Sheet { EditorSession session; std::unique_ptr sheet; explicit Sheet(FilterKind kind) { session.createDocument(4, 4); const QImage image = solid(QColor(120, 160, 200)); session.insert(ImportedImage(image, image, QStringLiteral("Colour"))); session.beginFilter(kind); sheet = std::make_unique(session); } const FilterSettings &settings() const { return session.filterEdit().value().settings; } QCheckBox &box(const QString &title) const { for (QCheckBox *each : sheet->findChildren()) if (each->text() == title) return *each; throw std::runtime_error("no such box"); } QStringList labels() const { QStringList words; for (const QLabel *each : sheet->findChildren()) words << each->text(); return words; } }; } class ColorAdjustmentTests : public QObject { Q_OBJECT private slots: void blackWhiteWeighsEachFamilyOfColours(); void blackWhiteTintsTheGrayItMakes(); void colorBalanceShiftsEachToneAndCanKeepBrightness(); void boundsFollowSwifts(); void theManifestKeepsEveryKey(); void theSheetShowsBlackAndWhitesFamiliesAndTint(); void theSheetShowsColorBalancesThreeRanges(); void theImageMenusFiltersChangeTheLayer(); void aValuePastItsRangeIsExplained(); }; void ColorAdjustmentTests::blackWhiteWeighsEachFamilyOfColours() { const BlackWhiteSettings defaults; // Photoshop's defaults: red at 40%, yellow 60%, blue 20%. QVERIFY(near(first(defaults.apply(solid(Qt::red))), QColor(102, 102, 102))); QVERIFY(near(first(defaults.apply(solid(Qt::yellow))), QColor(153, 153, 153))); QVERIFY(near(first(defaults.apply(solid(Qt::blue))), QColor(51, 51, 51))); QVERIFY(near(first(defaults.apply(solid(Qt::magenta))), QColor(204, 204, 204))); QVERIFY(near(first(defaults.apply(solid(Qt::white))), QColor(255, 255, 255))); // Gray keeps its level; a colour splits primary, secondary, gray. QVERIFY(near(first(defaults.apply(solid(QColor(90, 90, 90)))), QColor(90, 90, 90))); const double mixed = 0.2 + (0.6 - 0.2) * 0.6 + (1 - 0.6) * 0.4; const int level = int(std::lround(mixed * 255)); QVERIFY(near(first(defaults.apply(solid(QColor::fromRgbF(1, 0.6f, 0.2f)))), QColor(level, level, level))); // Premultiplied pixels keep their alpha. QImage half(1, 1, QImage::Format_RGBA8888_Premultiplied); half.setPixel(0, 0, qRgba(128, 0, 0, 128)); QCOMPARE(defaults.apply(half).pixel(0, 0), qRgba(51, 51, 51, 128)); BlackWhiteSettings bright; bright.reds = 100; QVERIFY(near(first(bright.apply(solid(Qt::red))), QColor(255, 255, 255))); // Yellows and cyans reach the kernel each in its place. const BlackWhiteSettings apart{.yellows = 100, .cyans = 0}; QVERIFY(near(first(apart.apply(solid(Qt::yellow))), QColor(255, 255, 255))); QVERIFY(near(first(apart.apply(solid(Qt::cyan))), QColor(0, 0, 0))); } void ColorAdjustmentTests::blackWhiteTintsTheGrayItMakes() { BlackWhiteSettings settings; settings.tint = true; settings.tintHue = 30; settings.tintSaturation = 50; const double gray = 0.4; QVERIFY(near(first(settings.apply(solid(Qt::red))), tinted(gray, 30, 0.5))); settings.tintHue = 250; QVERIFY(near(first(settings.apply(solid(Qt::red))), tinted(gray, 250, 0.5))); // No saturation tints nothing. settings.tintSaturation = 0; QVERIFY(near(first(settings.apply(solid(Qt::red))), QColor(102, 102, 102))); } void ColorAdjustmentTests::colorBalanceShiftsEachToneAndCanKeepBrightness() { // Untouched, the image is handed back as it is. const QImage gray = solid(QColor(128, 128, 128)); QCOMPARE(ColorBalanceSettings().apply(gray).cacheKey(), gray.cacheKey()); ColorBalanceSettings settings{.midCyanRed = 60, .preserveLuminosity = false}; // A midtone weighs 0.7 in the midtones: red gains 0.42. QVERIFY(near(first(settings.apply(gray)), QColor(235, 128, 128))); // Shadows and highlights leave a midtone alone. QVERIFY(near(first(ColorBalanceSettings{.shadowCyanRed = 60, .preserveLuminosity = false}.apply(gray)), QColor(128, 128, 128))); QVERIFY(near(first(ColorBalanceSettings{.highlightCyanRed = 60, .preserveLuminosity = false}.apply(gray)), QColor(128, 128, 128))); // A shadow takes the shadows' shift. QVERIFY(near(first(ColorBalanceSettings{.shadowYellowBlue = 50, .preserveLuminosity = false}.apply(solid(QColor(20, 20, 20)))), QColor(20, 20, 109))); // Preserve Luminosity scales the result back to its brightness. settings.preserveLuminosity = true; const QColor kept = first(settings.apply(gray)); const double before = 128 / 255.0, after = 0.299 * (235 / 255.0) + 0.587 * before + 0.114 * before; QVERIFY(near(kept, QColor(int(std::lround(235 * before / after)), int(std::lround(128 * before / after)), int(std::lround(128 * before / after))))); } void ColorAdjustmentTests::boundsFollowSwifts() { QVERIFY((BlackWhiteSettings{.reds = 300, .blues = -200}.isValid())); for (const BlackWhiteSettings &broken : {BlackWhiteSettings{.reds = 300.5}, BlackWhiteSettings{.magentas = -200.5}, BlackWhiteSettings{.greens = std::nan("")}, BlackWhiteSettings{.tintHue = 360.5}, BlackWhiteSettings{.tintHue = -0.5}, BlackWhiteSettings{.tintSaturation = -1}, BlackWhiteSettings{.tintSaturation = 100.5}}) { QVERIFY(!broken.isValid()); QVERIFY_THROWS_EXCEPTION(ProjectError, broken.apply(solid(Qt::red))); } QVERIFY((ColorBalanceSettings{.shadowCyanRed = -100, .highlightYellowBlue = 100}.isValid())); for (const ColorBalanceSettings &broken : {ColorBalanceSettings{.midMagentaGreen = 100.5}, ColorBalanceSettings{.shadowYellowBlue = -100.5}, ColorBalanceSettings{.highlightCyanRed = std::nan("")}}) { QVERIFY(!broken.isValid()); QVERIFY_THROWS_EXCEPTION(ProjectError, broken.apply(solid(Qt::red))); } // An adjustment layer refuses settings of either past them. LayerAdjustment adjustment{AdjustmentKind::blackWhite}; adjustment.setBlackWhite(BlackWhiteSettings{.cyans = 301}); QVERIFY(!adjustment.isValid()); adjustment = LayerAdjustment{AdjustmentKind::colorBalance}; adjustment.setColorBalance(ColorBalanceSettings{.midYellowBlue = -101}); QVERIFY(!adjustment.isValid()); } void ColorAdjustmentTests::theManifestKeepsEveryKey() { LayerAdjustment adjustment{AdjustmentKind::blackWhite}; adjustment.setBlackWhite(BlackWhiteSettings{.yellows = 10, .tint = true}); // Nine distinct amounts: no two keys can trade places unseen. adjustment.setColorBalance(ColorBalanceSettings{-40, -30, -20, -10, 10, 20, 30, 40, 50, false}); const QJsonObject object = ManifestJson::encoded(adjustment); const QJsonObject gray = object.value("blackWhiteSettings").toObject(), balance = object.value("colorBalanceSettings").toObject(); QCOMPARE(gray.keys(), (QStringList{"blues", "cyans", "greens", "magentas", "reds", "tint", "tintHue", "tintSaturation", "yellows"})); QCOMPARE(balance.keys(), (QStringList{"highlightCyanRed", "highlightMagentaGreen", "highlightYellowBlue", "midCyanRed", "midMagentaGreen", "midYellowBlue", "preserveLuminosity", "shadowCyanRed", "shadowMagentaGreen", "shadowYellowBlue"})); QVERIFY(ManifestJson::adjustment(object) == adjustment); const std::vector> amounts{ {"shadowCyanRed", -40}, {"shadowMagentaGreen", -30}, {"shadowYellowBlue", -20}, {"midCyanRed", -10}, {"midMagentaGreen", 10}, {"midYellowBlue", 20}, {"highlightCyanRed", 30}, {"highlightMagentaGreen", 40}, {"highlightYellowBlue", 50}}; for (const auto &[key, amount] : amounts) QCOMPARE(balance.value(key).toDouble(), amount); QCOMPARE(balance.value("preserveLuminosity").toBool(), false); // Synthesized decoding needs every key, defaults or not. for (const QString &key : gray.keys()) { QJsonObject missing = object, inner = gray; inner.remove(key); missing.insert("blackWhiteSettings", inner); QVERIFY_THROWS_EXCEPTION(ProjectError, ManifestJson::adjustment(missing)); } for (const QString &key : balance.keys()) { QJsonObject missing = object, inner = balance; inner.remove(key); missing.insert("colorBalanceSettings", inner); QVERIFY_THROWS_EXCEPTION(ProjectError, ManifestJson::adjustment(missing)); } } void ColorAdjustmentTests::theSheetShowsBlackAndWhitesFamiliesAndTint() { Sheet shown(FilterKind::blackWhite); shown.sheet->show(); QVERIFY(QTest::qWaitForWindowExposed(shown.sheet.get())); for (const char *family : {"Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas"}) QVERIFY2(shown.labels().contains(family), family); QSlider &reds = *shown.sheet->findChild("redsSlider"); QCOMPARE(reds.value(), int(std::lround((40.0 + 200) / 500 * 1000))); reds.setValue(1000); QCOMPARE(shown.settings().blackWhite.reds, 300.0); // Tint's rows show while it is on, as Swift's `if`. QSlider &hue = *shown.sheet->findChild("hueSlider"); QVERIFY(hue.parentWidget()->isHidden()); QCheckBox &tint = shown.box("Tint"); QCOMPARE(tint.toolTip(), QString("Color the result while keeping its tones, for a sepia or a cyanotype")); tint.click(); QVERIFY(shown.settings().blackWhite.tint && !hue.parentWidget()->isHidden()); QVERIFY(!shown.sheet->findChild("saturationSlider")->parentWidget()->isHidden()); // Every row writes its own field, each set apart. const std::vector> rows{ {"redsSlider", &BlackWhiteSettings::reds}, {"yellowsSlider", &BlackWhiteSettings::yellows}, {"greensSlider", &BlackWhiteSettings::greens}, {"cyansSlider", &BlackWhiteSettings::cyans}, {"bluesSlider", &BlackWhiteSettings::blues}, {"magentasSlider", &BlackWhiteSettings::magentas}, {"hueSlider", &BlackWhiteSettings::tintHue}, {"saturationSlider", &BlackWhiteSettings::tintSaturation}}; int position = 100; for (const auto &[name, field] : rows) { shown.sheet->findChild(name)->setValue(position); position += 100; } const auto at = [](int travel, double low, double high) { return low + (high - low) * travel / 1000; }; position = 100; for (size_t row = 0; row < rows.size(); ++row, position += 100) { const bool tone = row >= 6; const double expected = tone ? std::round(at(position, 0, row == 6 ? 360 : 100)) : std::round(at(position, -200, 300)); QCOMPARE(shown.settings().blackWhite.*rows[row].second, expected); } // Hue runs to 360 degrees at the slider's end. hue.setValue(1000); QCOMPARE(shown.settings().blackWhite.tintHue, 360.0); tint.click(); QVERIFY(!shown.settings().blackWhite.tint && hue.parentWidget()->isHidden()); } void ColorAdjustmentTests::theSheetShowsColorBalancesThreeRanges() { Sheet shown(FilterKind::colorBalance); const QStringList words = shown.labels(); for (const char *range : {"Shadows", "Midtones", "Highlights"}) QCOMPARE(words.count(range), 1); // Swift's headline: bold, 13 pixels. for (const QLabel *label : shown.sheet->findChildren()) if (label->text() == "Midtones") QVERIFY(label->font().bold() && label->font().pixelSize() == 13); QCOMPARE(words.count("Cyan / Red"), 3); QCOMPARE(words.count("Magenta / Green"), 3); QCOMPARE(words.count("Yellow / Blue"), 3); QCheckBox &keep = shown.box("Preserve Luminosity"); QCOMPARE(keep.toolTip(), QString("Put each pixel's brightness back afterwards, so only the color moves")); QVERIFY(keep.isChecked()); keep.click(); QVERIFY(!shown.settings().colorBalance.preserveLuminosity); // Each range's three rows, in Shadows, Midtones, Highlights order. const std::array pairs{"cyan/RedSlider", "magenta/GreenSlider", "yellow/BlueSlider"}; const std::array, 3> fields{{ {&ColorBalanceSettings::shadowCyanRed, &ColorBalanceSettings::midCyanRed, &ColorBalanceSettings::highlightCyanRed}, {&ColorBalanceSettings::shadowMagentaGreen, &ColorBalanceSettings::midMagentaGreen, &ColorBalanceSettings::highlightMagentaGreen}, {&ColorBalanceSettings::shadowYellowBlue, &ColorBalanceSettings::midYellowBlue, &ColorBalanceSettings::highlightYellowBlue}}}; for (size_t pair = 0; pair < 3; ++pair) { const QList rows = shown.sheet->findChildren(pairs[pair]); QCOMPARE(rows.size(), 3); for (size_t range = 0; range < 3; ++range) rows[qsizetype(range)]->setValue(int(100 + 100 * (pair * 3 + range))); } for (size_t pair = 0; pair < 3; ++pair) for (size_t range = 0; range < 3; ++range) QCOMPARE(shown.settings().colorBalance.*fields[pair][range], -100 + 200 * (100 + 100 * double(pair * 3 + range)) / 1000); } // From the Image menu: one step that changes the pixels. void ColorAdjustmentTests::theImageMenusFiltersChangeTheLayer() { for (const FilterKind kind : {FilterKind::blackWhite, FilterKind::colorBalance}) { Sheet shown(kind); FilterSettings settings = shown.settings(); settings.colorBalance.midCyanRed = 100; shown.session.updateFilter(settings, true); bool done = false; shown.session.commitFilter([&done] { done = true; }); QTRY_VERIFY(done); QCOMPARE(shown.session.history.undoName(), rawValue(kind)); const QColor made = shown.session.activeLayer().value().asset.value().image().pixelColor(1, 1); const QColor expected = first(kind == FilterKind::blackWhite ? BlackWhiteSettings().apply(solid(QColor(120, 160, 200))) : settings.colorBalance.apply(solid(QColor(120, 160, 200)))); QCOMPARE(made, expected); QVERIFY(made != QColor(120, 160, 200)); } } // Swift reports the refusal; its preview and commit never crash. void ColorAdjustmentTests::aValuePastItsRangeIsExplained() { const QString invalid = ProjectError(ProjectError::Kind::invalid).what(); for (const FilterKind kind : {FilterKind::blackWhite, FilterKind::colorBalance}) { Sheet shown(kind); const ImageLayer before = shown.session.activeLayer().value(); const int steps = shown.session.history.undoCount(); FilterSettings settings = shown.settings(); settings.blackWhite.reds = 301; settings.colorBalance.midCyanRed = 101; shown.session.updateFilter(settings, true); QTRY_VERIFY(!shown.session.filterEdit().value().preparing); QCOMPARE(shown.session.filterEdit().value().previewError, std::optional(invalid)); shown.session.updateFilter(settings, false); bool done = false; shown.session.commitFilter([&done] { done = true; }); QTRY_VERIFY(done); QCOMPARE(shown.session.brushError(), std::optional(invalid)); QVERIFY(!shown.session.filterEdit()); QCOMPARE(shown.session.history.undoCount(), steps); QVERIFY(shown.session.activeLayer().value().asset.value().identity() == before.asset.value().identity()); } // Typed into the sheet's field, as a person would. Sheet typed(FilterKind::blackWhite); QLineEdit &field = *typed.sheet->findChild("redsField"); field.setFocus(); field.selectAll(); QTest::keyClicks(&field, "301"); QTest::keyClick(&field, Qt::Key_Return); QTRY_COMPARE(typed.session.filterEdit().value().previewError, std::optional(invalid)); } QTEST_MAIN(ColorAdjustmentTests) #include "ColorAdjustmentTests.moc"