14.25: Camera Raw's Light and Color sliders match Photoshop's

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 13:03:24 -04:00
1 parent 15bb440bc9
commit efadd6f7c6
17 files changed
+513 -136

No files matched your search

+1 -1
View File
@@ -132,7 +132,7 @@ void CameraRawSessionTests::hiddenGroupIsLeftOutAndOkIsOneUndoOrNone()
count = session->history.undoCount();
QVERIFY(awaited([&](std::function<void()> done) { session->commitFilter(std::move(done)); }));
QVERIFY(session->history.undoCount() == count + 1 && session->history.undoName() == "Camera Raw Filter");
QVERIFY2(std::abs(pixels(layerImage(*session))[0][0] - 176) <= 2, "OK bakes the grade");
QVERIFY2(std::abs(pixels(layerImage(*session))[0][0] - 181) <= 2, "OK bakes the grade");
}
void CameraRawSessionTests::hiddenGroupStaysOutOfTheNextOpen()
+121
View File
@@ -0,0 +1,121 @@
#include "CameraRawFixtures.h"
#include "Document/CameraRawTables.h"
#include <QtTest>
// The measured tables as Swift reads them, checked against Python.
using namespace CameraRawFixtures;
class CameraRawTablesTests : public QObject {
Q_OBJECT
private slots:
void theTablesDecodeAsWritten();
void aStageBlendsTheTablesAroundItsValue();
void eachSliderPicksItsOwnTables();
void neutralizeFindsTheGridsGrayest();
void identitySlidersComposeNothing();
};
namespace {
const uint8_t *entry(int table, int cell)
{
constexpr size_t entries = size_t(CameraRawTables::size) * CameraRawTables::size * CameraRawTables::size * 3;
return CameraRawTables::tables().data() + table * entries + size_t(cell) * 3;
}
int indexOf(const uint8_t *table)
{
constexpr size_t entries = size_t(CameraRawTables::size) * CameraRawTables::size * CameraRawTables::size * 3;
return table ? int((table - CameraRawTables::tables().data()) / qsizetype(entries)) : -1;
}
}
void CameraRawTablesTests::theTablesDecodeAsWritten()
{
const std::vector<uint8_t> &tables = CameraRawTables::tables();
QCOMPARE(tables.size(), size_t(147) * 17 * 17 * 17 * 3);
quint64 hash = 0xcbf29ce484222325ULL;
for (const uint8_t byte : tables)
hash = (hash ^ byte) * 0x100000001b3ULL;
QCOMPARE(hash, quint64(0x6a9f829e703f2df6ULL));
QCOMPARE(std::vector<int>(entry(40, 2456), entry(40, 2456) + 3), (std::vector<int>{128, 128, 128}));
QCOMPARE(std::vector<int>(entry(81, 100), entry(81, 100) + 3), (std::vector<int>{0, 4, 32}));
QCOMPARE(std::vector<int>(entry(146, 4912), entry(146, 4912) + 3), (std::vector<int>{255, 255, 255}));
}
void CameraRawTablesTests::aStageBlendsTheTablesAroundItsValue()
{
const CameraRawStage balance = CameraRawTables::whiteBalance(30, -60);
QCOMPARE((std::vector<int>{indexOf(balance.table[0]), indexOf(balance.table[1]), indexOf(balance.table[2]), indexOf(balance.table[3])}),
(std::vector<int>{46, 47, 55, 56}));
QCOMPARE((std::vector<float>{balance.weight[0], balance.weight[1], balance.weight[2], balance.weight[3]}),
(std::vector<float>{0.32f, 0.48f, 0.08f, 0.12f}));
// At the far corner the last pair takes it all.
const CameraRawStage corner = CameraRawTables::whiteBalance(100, 100);
QVERIFY(indexOf(corner.table[0]) == 70 && indexOf(corner.table[3]) == 80 && corner.weight[3] == 1 && corner.weight[0] == 0);
// Past the range it holds at the end, never beyond.
const CameraRawStage past = CameraRawTables::whiteBalance(150, 175);
QVERIFY(indexOf(past.table[3]) == 80 && past.weight[3] == 1 && past.weight[0] == 0);
const CameraRawStage low = CameraRawTables::whiteBalance(-100, -100);
QVERIFY(indexOf(low.table[0]) == 0 && low.weight[0] == 1 && low.weight[1] == 0);
}
void CameraRawTablesTests::eachSliderPicksItsOwnTables()
{
CameraRawSettings settings;
settings.exposure = 1.25;
settings.temperature = 30;
settings.contrast = -100;
settings.whites = 100;
settings.blacks = 12.5;
settings.saturation = -62.5;
settings.vibrance = 80;
const CameraRawTables::Stages stages = CameraRawTables::stages(settings);
QCOMPARE(stages.before.size(), size_t(3));
QCOMPARE(stages.after.size(), size_t(4));
const auto pair = [](const CameraRawStage &stage) {
return std::vector<double>{double(indexOf(stage.table[0])), double(indexOf(stage.table[1])), stage.weight[0], stage.weight[1]};
};
QCOMPARE(pair(stages.before[0]), (std::vector<double>{93, 94, 0.5, 0.5}));
QCOMPARE(indexOf(stages.before[1].table[0]), 5 * 9 + 4);
QCOMPARE(pair(stages.before[2]), (std::vector<double>{102, 103, 1, 0}));
QCOMPARE(pair(stages.after[0]), (std::vector<double>{118, 119, 0, 1}));
QCOMPARE(pair(stages.after[1]), (std::vector<double>{124, 125, 0.5, 0.5}));
QCOMPARE(pair(stages.after[2]), (std::vector<double>{130, 131, 0.5, 0.5}));
QVERIFY(indexOf(stages.after[3].table[0]) == 145 && std::abs(stages.after[3].weight[1] - 0.2f) < 1e-6);
// Tint alone still makes the white balance stage.
CameraRawSettings tint;
tint.tint = -25;
const CameraRawTables::Stages tinted = CameraRawTables::stages(tint);
QVERIFY(tinted.before.size() == 1 && tinted.after.empty() && indexOf(tinted.before[0].table[0]) == 4 * 9 + 3);
}
void CameraRawTablesTests::neutralizeFindsTheGridsGrayest()
{
// Python's own search over the same tables found these.
const CameraRawSettings::Balance warm = CameraRawTables::neutralize(160 / 255.0, 140 / 255.0, 120 / 255.0).value();
QVERIFY(warm.temperature == -19 && warm.tint == -7);
const CameraRawSettings::Balance blue = CameraRawTables::neutralize(0.2, 0.45, 0.8).value();
QVERIFY(blue.temperature == 100 && blue.tint == 25.5);
const CameraRawSettings::Balance green = CameraRawTables::neutralize(0.3, 0.5, 0.25).value();
QVERIFY(green.temperature == 5 && green.tint == 90.5);
// White stays white anywhere: the first of the ties wins.
const CameraRawSettings::Balance white = CameraRawTables::neutralize(1, 1, 1).value();
QVERIFY(white.temperature == 0 && white.tint == 0);
QVERIFY(!CameraRawTables::neutralize(0.5, 0.0001, 0.5));
QVERIFY(CameraRawTables::neutralize(0.5, 0.00011, 0.5));
}
void CameraRawTablesTests::identitySlidersComposeNothing()
{
const CameraRawTables::Stages none = CameraRawTables::stages(CameraRawSettings());
QVERIFY(none.before.empty() && none.after.empty() && CameraRawTables::compose(none.before).empty());
CameraRawSettings exposure;
exposure.exposure = 0.5;
const std::vector<float> composed = CameraRawTables::compose(CameraRawTables::stages(exposure).before);
QCOMPARE(composed.size(), size_t(33) * 33 * 33 * 3);
// The corners are its table's own black and white.
QVERIFY(composed[0] == entry(92, 0)[0] / 255.0f && composed.back() == entry(92, 4912)[2] / 255.0f);
}
QTEST_GUILESS_MAIN(CameraRawTablesTests)
#include "CameraRawTablesTests.moc"
+38 -8
View File
@@ -10,9 +10,10 @@ class CameraRawTests : public QObject {
Q_OBJECT
private slots:
void defaultsLeavePixelsAndAlphaAlone();
void exposureAddsOneStopAndContrastPivotsAroundMidGray();
void exposureAndContrastMatchPhotoshop();
void tonalSlidersMoveTheEndTheyName();
void temperatureWarmsAndTintMovesTowardMagenta();
void whiteBalanceAndVibranceMatchPhotoshop();
void vibranceFavorsDullColorsAndProtectsSkinWhileSaturationDoesNot();
void textureAndClaritySharpenAnEdgeAndLeaveAFlatField();
void dehazeDeepensOrLiftsAndKeepsAlpha();
@@ -36,23 +37,24 @@ void CameraRawTests::defaultsLeavePixelsAndAlphaAlone()
QVERIFY(!isImageAdjustment(FilterKind::cameraRaw));
}
void CameraRawTests::exposureAddsOneStopAndContrastPivotsAroundMidGray()
// Photoshop's Camera Raw Filter's own numbers, measured.
void CameraRawTests::exposureAndContrastMatchPhotoshop()
{
const QImage input = gray();
CameraRawSettings settings;
settings.exposure = 1;
const std::array<int, 4> brighter = pixels(settings.apply(input))[0];
QVERIFY2(std::abs(brighter[0] - 176) <= 2, qPrintable(QString::number(brighter[0])));
QVERIFY2(std::abs(brighter[0] - 181) <= 2, qPrintable(QString::number(brighter[0])));
QVERIFY(brighter[0] == brighter[1] && brighter[1] == brighter[2]);
const QImage translucent = gray(4, 4, 0.5);
QCOMPARE(pixels(settings.apply(translucent))[0][3], pixels(translucent)[0][3]);
settings = CameraRawSettings();
settings.contrast = 100;
const auto pushed = pixels(settings.apply(pair(64, 192)));
QVERIFY2(pushed[0][0] < 10 && pushed[1][0] > 250, "contrast +100 drives the pair apart");
QVERIFY2(std::abs(pushed[0][0] - 36) <= 3 && std::abs(pushed[1][0] - 213) <= 3, "contrast +100 drives the pair apart");
settings.contrast = -100;
const auto flat = pixels(settings.apply(pair(64, 192)));
QVERIFY2(std::abs(flat[0][0] - 128) <= 2 && std::abs(flat[1][0] - 128) <= 2, "contrast −100 meets at mid gray");
QVERIFY2(std::abs(flat[0][0] - 88) <= 3 && std::abs(flat[1][0] - 174) <= 3, "contrast −100 draws them together");
}
void CameraRawTests::tonalSlidersMoveTheEndTheyName()
@@ -65,7 +67,7 @@ void CameraRawTests::tonalSlidersMoveTheEndTheyName()
settings = CameraRawSettings();
settings.whites = 100;
const auto clipped = pixels(settings.apply(brightAndMid));
QVERIFY(clipped[0][0] == 255 && std::abs(clipped[1][0] - 128) <= 2);
QVERIFY2(clipped[0][0] == 255 && clipped[1][0] > 128, "whites lift the midtone too, as in Photoshop");
const auto view = pixels(settings.apply(brightAndMid, CameraRawClipping::highlights));
QVERIFY2((view[0] == std::array{255, 255, 255, 255}) && (view[1] == std::array{0, 0, 0, 255}), "highlight clipping is not the grade");
const QImage darkAndMid = pair(20, 128);
@@ -76,7 +78,7 @@ void CameraRawTests::tonalSlidersMoveTheEndTheyName()
settings = CameraRawSettings();
settings.blacks = -100;
const auto crushed = pixels(settings.apply(darkAndMid));
QVERIFY(crushed[0][0] < 20 && std::abs(crushed[1][0] - 128) <= 2);
QVERIFY2(crushed[0][0] == 0 && std::abs(crushed[1][0] - 91) <= 3, "blacks darken the midtone too");
const auto shadowView = pixels(settings.apply(darkAndMid, CameraRawClipping::shadows));
QVERIFY2((shadowView[0] == std::array{0, 0, 0, 255}) && (shadowView[1] == std::array{255, 255, 255, 255}), "shadow clipping is not the grade");
}
@@ -94,6 +96,33 @@ void CameraRawTests::temperatureWarmsAndTintMovesTowardMagenta()
QVERIFY(magenta[1] < 128 && magenta[1] < magenta[0] && magenta[1] < magenta[2]);
}
// Once a fraction of Photoshop's strength (Swift's issue #252).
void CameraRawTests::whiteBalanceAndVibranceMatchPhotoshop()
{
struct Case {
const char *name;
std::array<int, 3> input;
std::function<void(CameraRawSettings &)> adjust;
std::array<int, 3> expected;
};
const std::vector<Case> cases{
{"temperature +100", {128, 128, 128}, [](CameraRawSettings &s) { s.temperature = 100; }, {235, 189, 117}},
{"temperature −50", {128, 128, 128}, [](CameraRawSettings &s) { s.temperature = -50; }, {102, 146, 210}},
{"tint +100", {128, 128, 128}, [](CameraRawSettings &s) { s.tint = 100; }, {159, 125, 215}},
{"tint −100", {128, 128, 128}, [](CameraRawSettings &s) { s.tint = -100; }, {98, 198, 120}},
{"vibrance +100 spares skin", {153, 107, 107}, [](CameraRawSettings &s) { s.vibrance = 100; }, {167, 98, 98}},
{"vibrance +100 fills a dull cyan", {107, 153, 153}, [](CameraRawSettings &s) { s.vibrance = 100; }, {0, 160, 160}},
{"vibrance −100", {92, 92, 230}, [](CameraRawSettings &s) { s.vibrance = -100; }, {164, 164, 210}},
};
for (const Case &test : cases) {
CameraRawSettings settings;
test.adjust(settings);
const std::array<int, 4> got = pixels(settings.apply(image(4, 4, test.input[0] / 255.0, test.input[1] / 255.0, test.input[2] / 255.0)))[0];
for (int c = 0; c < 3; ++c)
QVERIFY2(std::abs(got[c] - test.expected[c]) <= 4, test.name);
}
}
void CameraRawTests::vibranceFavorsDullColorsAndProtectsSkinWhileSaturationDoesNot()
{
const QImage dullGreen = image(4, 4, 77 / 255.0, 153 / 255.0, 77 / 255.0), saturatedGreen = image(4, 4, 20 / 255.0, 200 / 255.0, 20 / 255.0);
@@ -112,7 +141,8 @@ void CameraRawTests::vibranceFavorsDullColorsAndProtectsSkinWhileSaturationDoesN
settings.saturation = 100;
const double redRatio = double(chroma(pixels(settings.apply(red))[0])) / chroma(pixels(red)[0]);
const double blueRatio = double(chroma(pixels(settings.apply(blue))[0])) / chroma(pixels(blue)[0]);
QVERIFY2(std::abs(redRatio - 2) < 0.15 && std::abs(blueRatio - 2) < 0.15, "saturation doubles both");
// Photoshop doubles chroma in linear light: less in sRGB.
QVERIFY2(std::abs(redRatio - 1.85) < 0.15 && std::abs(blueRatio - 1.73) < 0.15, "saturation raises both");
}
void CameraRawTests::textureAndClaritySharpenAnEdgeAndLeaveAFlatField()
+30 -4
View File
@@ -1,4 +1,5 @@
#include "CameraRawFixtures.h"
#include "Document/CameraRawTables.h"
#include "Document/Filters.h"
#include <QPainter>
#include <QtTest>
@@ -65,6 +66,7 @@ class CameraRawWiringTests : public QObject {
Q_OBJECT
private slots:
void aClipViewShowsOnAnUntouchedGrade();
void theTablesRunAroundHighlightsAndShadows();
void autoAveragesLinearLight();
void eachEyeClearsEveryFieldOfItsGroup();
void effectsReachTheKernelInOrder();
@@ -83,11 +85,33 @@ void CameraRawWiringTests::aClipViewShowsOnAnUntouchedGrade()
const QImage picture = noise();
const QImage clipped = CameraRawSettings().apply(picture, CameraRawClipping::highlights);
const QImage direct = kernel(picture, [](uchar *bytes, size_t w, size_t h, size_t s) {
adjust_camera_raw(bytes, w, h, s, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, int(CameraRawClipping::highlights));
adjust_camera_raw(bytes, w, h, s, nullptr, nullptr, CameraRawTables::grid, 0, 0, int(CameraRawClipping::highlights));
});
QVERIFY(pixels(clipped) == pixels(direct) && pixels(clipped) != pixels(picture));
}
// Exposure, balance and Contrast first; Whites to Vibrance after.
void CameraRawWiringTests::theTablesRunAroundHighlightsAndShadows()
{
const QImage picture = noise();
CameraRawSettings settings;
settings.exposure = 1;
settings.temperature = 40;
settings.highlights = -80;
settings.shadows = 60;
settings.whites = -50;
settings.saturation = 30;
const CameraRawTables::Stages stages = CameraRawTables::stages(settings);
const std::vector<float> before = CameraRawTables::compose(stages.before), after = CameraRawTables::compose(stages.after);
const QImage direct = kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t s) {
adjust_camera_raw(bytes, w, h, s, before.data(), after.data(), CameraRawTables::grid, -80, 60, 0);
});
const QImage swapped = kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t s) {
adjust_camera_raw(bytes, w, h, s, after.data(), before.data(), CameraRawTables::grid, -80, 60, 0);
});
QVERIFY(pixels(settings.apply(picture)) == pixels(direct) && pixels(direct) != pixels(swapped));
}
void CameraRawWiringTests::autoAveragesLinearLight()
{
QImage picture = BrushRaster::context(2, 1, false);
@@ -95,10 +119,12 @@ void CameraRawWiringTests::autoAveragesLinearLight()
picture.setPixelColor(1, 0, QColor(20, 60, 120));
const auto mean = [](const std::function<double(double)> &read, int left, int right) { return (read(left / 255.0) + read(right / 255.0)) / 2; };
const auto same = [](double value) { return value; };
const CameraRawSettings::Balance linear =
CameraRawSettings::neutralizeLinear(mean(decode, 200, 20), mean(decode, 100, 60), mean(decode, 50, 120)).value();
// The linear mean encoded again, as Swift's Auto does.
const auto encode = [](double linear) { return linear <= 0.0031308 ? linear * 12.92 : 1.055 * std::pow(linear, 1 / 2.4) - 0.055; };
const CameraRawSettings::Balance linear = CameraRawSettings::neutralizeStraight(encode(mean(decode, 200, 20)), encode(mean(decode, 100, 60)),
encode(mean(decode, 50, 120))).value();
const CameraRawSettings::Balance encoded =
CameraRawSettings::neutralizeLinear(mean(same, 200, 20), mean(same, 100, 60), mean(same, 50, 120)).value();
CameraRawSettings::neutralizeStraight(mean(same, 200, 20), mean(same, 100, 60), mean(same, 50, 120)).value();
const CameraRawSettings::Balance automatic = CameraRawSettings::autoBalance(picture).value();
QVERIFY(std::abs(automatic.temperature - linear.temperature) < 1e-9 && std::abs(automatic.tint - linear.tint) < 1e-9);
QVERIFY(std::abs(automatic.temperature - encoded.temperature) > 1);
+2 -1
View File
@@ -315,6 +315,8 @@ target_include_directories(RawDitherTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(ToolDefaultsTests)
compositor_test(ColorAdjustmentTests)
compositor_test(CameraRawTests)
compositor_test(CameraRawTablesTests)
target_include_directories(CameraRawTablesTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(CameraRawColorNoiseTests)
compositor_test(CameraRawToneOrderTests)
compositor_test(LastFilterTests)
@@ -389,5 +391,4 @@ compositor_test(SliderSnapTests)
compositor_test(ProjectResizeFailureTests)
# The `.svgz` fixture is gzip, written through zlib.
find_package(ZLIB REQUIRED)
target_link_libraries(SVGImportTests PRIVATE ZLIB::ZLIB)