#include "CameraRawFixtures.h" #include "Document/CameraRawTables.h" #include "Document/Filters.h" #include #include #include extern "C" { #include "AdjustPixels.h" } // Each setting reaches its kernel argument, each eye its fields. using namespace CameraRawFixtures; namespace { // Colours that differ everywhere, so every kernel argument shows. QImage noise(int side = 32) { QImage picture = BrushRaster::context(side, side, false); quint32 state = 7; for (int y = 0; y < side; ++y) for (int x = 0; x < side; ++x) { std::array channel{}; for (int &value : channel) { state = state * 1664525 + 1013904223; value = int(state >> 24); } picture.setPixelColor(x, y, QColor(channel[0], channel[1], channel[2])); } return picture; } QImage kernel(const QImage &picture, const std::function &run) { QImage copy = picture.copy(); run(copy.bits(), size_t(copy.width()), size_t(copy.height()), size_t(copy.bytesPerLine())); return copy; } // The picture drawn under a transform, as the warp does. QImage placed(const QImage &picture, const QTransform &transform) { QImage result = BrushRaster::context(picture.width(), picture.height(), false); QPainter painter(&result); painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform); painter.setTransform(transform); painter.drawImage(QRectF(0, 0, picture.width(), picture.height()), picture); return result; } int farthest(const std::vector> &left, const std::vector> &right) { int most = 0; for (size_t index = 0; index < left.size(); ++index) for (size_t channel = 0; channel < 4; ++channel) most = std::max(most, std::abs(left[index][channel] - right[index][channel])); return most; } double decode(double encoded) { return encoded <= 0.04045 ? encoded / 12.92 : std::pow((encoded + 0.055) / 1.055, 2.4); } } class CameraRawWiringTests : public QObject { Q_OBJECT private slots: void aClipViewShowsOnAnUntouchedGrade(); void theTablesRunAroundHighlightsAndShadows(); void autoAveragesLinearLight(); void eachEyeClearsEveryFieldOfItsGroup(); void effectsReachTheKernelInOrder(); void opticsAndDetailReachTheKernelsInOrder(); void offsetAndAspectPlaceThePicture(); void constrainCropFitsTheWholeFrame(); void theScopeCountsEachChannelAndHue(); void curveMixerAndGradingReachTheKernelInOrder(); void theSharpenMaskAndEachLensFlagReachTheirKernels(); void grainScalesByThePreview(); void turnVerticalAndScalePlaceThePicture(); }; 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, 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 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); picture.setPixelColor(0, 0, QColor(200, 100, 50)); picture.setPixelColor(1, 0, QColor(20, 60, 120)); const auto mean = [](const std::function &read, int left, int right) { return (read(left / 255.0) + read(right / 255.0)) / 2; }; const auto same = [](double value) { return 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::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); } void CameraRawWiringTests::eachEyeClearsEveryFieldOfItsGroup() { CameraRawSettings all; double next = 1; for (double CameraRawSettings::*field : {&CameraRawSettings::exposure, &CameraRawSettings::contrast, &CameraRawSettings::highlights, &CameraRawSettings::shadows, &CameraRawSettings::whites, &CameraRawSettings::blacks, &CameraRawSettings::temperature, &CameraRawSettings::tint, &CameraRawSettings::vibrance, &CameraRawSettings::saturation, &CameraRawSettings::texture, &CameraRawSettings::clarity, &CameraRawSettings::dehaze, &CameraRawSettings::glow, &CameraRawSettings::vignetteAmount, &CameraRawSettings::grainAmount}) all.*field = next++; const auto cleared = [&](std::initializer_list fields) { CameraRawSettings expected = all; for (double CameraRawSettings::*field : fields) expected.*field = 0; return expected; }; QVERIFY(all.applying({.light = false}) == cleared({&CameraRawSettings::exposure, &CameraRawSettings::contrast, &CameraRawSettings::highlights, &CameraRawSettings::shadows, &CameraRawSettings::whites, &CameraRawSettings::blacks})); QVERIFY(all.applying({.color = false}) == cleared({&CameraRawSettings::temperature, &CameraRawSettings::tint, &CameraRawSettings::vibrance, &CameraRawSettings::saturation})); QVERIFY(all.applying({.effects = false}) == cleared({&CameraRawSettings::texture, &CameraRawSettings::clarity, &CameraRawSettings::dehaze, &CameraRawSettings::glow, &CameraRawSettings::vignetteAmount, &CameraRawSettings::grainAmount})); } void CameraRawWiringTests::effectsReachTheKernelInOrder() { const QImage picture = noise(); CameraRawSettings settings; settings.texture = 30, settings.clarity = -20, settings.dehaze = 15, settings.glow = 40, settings.glowStyle = CameraRawGlowStyle::bloom; settings.glowRange = 10, settings.glowSpread = -30, settings.glowWarmth = 25, settings.vignetteAmount = -60; settings.vignetteStyle = CameraRawVignetteStyle::colorPriority, settings.vignetteMidpoint = 30, settings.vignetteRoundness = 20; settings.vignetteFeather = 70, settings.vignetteHighlights = 40; const QImage direct = kernel(picture, [](uchar *bytes, size_t w, size_t h, size_t s) { adjust_camera_raw_effects(bytes, w, h, s, 30, -20, 15, 40, int(CameraRawGlowStyle::bloom), 10, -30, 25, -60, 30, 20, 70, 40, int(CameraRawVignetteStyle::colorPriority), 1); }); QVERIFY(pixels(settings.apply(picture)) == pixels(direct)); } void CameraRawWiringTests::opticsAndDetailReachTheKernelsInOrder() { const QImage picture = noise(); CameraRawSettings settings; settings.optics = {.removeChromaticAberration = true, .enableLensProfile = true, .profileDistortion = 60, .profileVignetting = 40, .distortion = 20, .purpleAmount = 30, .purpleHueLow = 260, .purpleHueHigh = 320, .greenAmount = 40, .greenHueLow = 50, .greenHueHigh = 130, .vignetteAmount = -45, .vignetteMidpoint = 30}; settings.detail = {.sharpenAmount = 80, .sharpenRadius = 30, .sharpenDetail = 60, .sharpenMasking = 20, .noiseLuminance = 40, .noiseLuminanceDetail = 70, .noiseLuminanceContrast = 30, .noiseColor = 50, .noiseColorDetail = 20, .noiseColorSmoothness = 80}; const double k = settings.optics.distortionK(PixelFilter::lensStrength); const QImage direct = kernel(picture, [k](uchar *bytes, size_t w, size_t h, size_t s) { adjust_camera_raw_optics(bytes, w, h, s, 1, 1, 60, 40, k, 30, 260, 320, 40, 50, 130, -45, 30, 1); adjust_camera_raw_detail(bytes, w, h, s, 80, 30, 60, 20, 40, 70, 30, 50, 20, 80, 1); }); QVERIFY(pixels(settings.apply(picture)) == pixels(direct)); } void CameraRawWiringTests::offsetAndAspectPlaceThePicture() { const QImage picture = checker(24, 24); const auto warped = [&](const std::function &change) { CameraRawGeometrySettings geometry; change(geometry); return pixels(geometry.apply(picture)); }; // Offset X slides; Offset Y stretches from the middle. QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.offsetX = 40; }), pixels(placed(picture, QTransform::fromTranslate(24 * 0.06, 0)))) <= 1); const double stretch = (24 + 2 * 24 * 0.06) / 24; QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.offsetY = 40; }), pixels(placed(picture, QTransform::fromTranslate(12, 12).scale(1, stretch).translate(-12, -12)))) <= 1); // Aspect widens by its factor and flattens by the same. QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.aspect = 50; }), pixels(placed(picture, QTransform::fromTranslate(12, 12).scale(1.25, 1 / 1.25).translate(-12, -12)))) <= 1); } void CameraRawWiringTests::constrainCropFitsTheWholeFrame() { // A wide box fills the width, the top clear. CameraRawGeometrySettings wide{.aspect = 50, .scale = -50, .constrainCrop = true}; const QImage filled = wide.apply(checker(24, 24)); QVERIFY(qAlpha(filled.pixel(0, 12)) > 0 && qAlpha(filled.pixel(23, 12)) > 0); QCOMPARE(qAlpha(filled.pixel(12, 0)), 0); // A box filling one side alone is still centred. CameraRawGeometrySettings tall{.aspect = -50, .offsetX = 50}; const QImage loose = tall.apply(checker(24, 24)); tall.constrainCrop = true; const QImage centred = tall.apply(checker(24, 24)); QCOMPARE(qAlpha(loose.pixel(3, 12)), 0); QVERIFY(qAlpha(centred.pixel(3, 12)) > 0); } void CameraRawWiringTests::theScopeCountsEachChannelAndHue() { QImage picture = BrushRaster::context(3, 1, false); picture.setPixelColor(0, 0, QColor(0, 255, 0, 128)); picture.setPixelColor(1, 0, QColor(0, 0, 255)); picture.setPixelColor(2, 0, QColor(255, 0, 255)); const CameraRawScope scope = CameraRawScope::make(picture).value(); // Counts weigh by alpha: the green pixel is half. const double half = 128 / 255.0; const auto near = [](double left, double right) { return std::abs(left - right) < 1e-9; }; QVERIFY(near(scope.red[0], 1 + half) && near(scope.red[255], 1)); QVERIFY(near(scope.green[255], half) && near(scope.green[0], 2)); QVERIFY(near(scope.blue[255], 2) && near(scope.blue[0], half)); const auto cell = [&](int column, int line) { return scope.vectorscope[size_t(line * CameraRawScope::scopeSide + column)]; }; // Green at 120°, blue at 240°, magenta at 300°. QVERIFY(std::abs(cell(16, 58) - 128 / 255.0) < 1e-9); QCOMPARE(cell(16, 5), 1.0); QCOMPARE(cell(47, 5), 1.0); } void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder() { const QImage picture = noise(); CameraRawSettings settings; CameraRawCurveSettings &curve = settings.curve; curve.lights = 30, curve.shadows = -20, curve.refineSaturation = 40; curve.rgb = {{0, 0.05}, {0.5, 0.6}, {1, 0.95}}; curve.red = {{0, 0}, {0.3, 0.45}, {1, 1}}; curve.green = {{0, 0.1}, {1, 0.8}}; curve.blue = {{0, 0}, {0.7, 0.5}, {1, 1}}; for (size_t index = 0; index < 8; ++index) { settings.mixer.hue[index] = 5 + double(index); settings.mixer.saturation[index] = -30 + 7 * double(index); settings.mixer.luminance[index] = 20 - 4 * double(index); } settings.mixer.points = {{200, 0.6, 0.4, 25, -35, 15, 40, 0.5, 0.3, false}, {40, 0.8, 0.6, -10, 20, -45, 60, 0.3, 0.6, false}}; settings.grading = {{210, 40, -20}, {100, 25, 10}, {30, 55, 15}, {300, 10, -5}, 70, -30}; const CameraRawCurveSettings shape = curve.normalized(); const std::vector tone = shape.toneTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green), blue = shape.channelTable(shape.blue); std::vector mixer; for (size_t index = 0; index < 8; ++index) mixer.push_back(float((5 + double(index)) / 100)); for (size_t index = 0; index < 8; ++index) mixer.push_back(float((-30 + 7 * double(index)) / 100)); for (size_t index = 0; index < 8; ++index) mixer.push_back(float((20 - 4 * double(index)) / 100)); const std::vector points{float(200 / 360.0), 0.6f, 0.4f, 0.25f, -0.35f, 0.15f, float(40 / 360.0), 0.5f, 0.3f, float(40 / 360.0), 0.8f, 0.6f, -0.1f, 0.2f, -0.45f, float(60 / 360.0), 0.3f, 0.6f}; const std::vector grade{float(210 / 360.0), 0.4f, -0.2f, float(100 / 360.0), 0.25f, 0.1f, float(30 / 360.0), 0.55f, 0.15f, float(300 / 360.0), 0.1f, -0.05f}; for (const int visualize : {-1, 1}) { const QImage direct = kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t stride) { adjust_camera_raw_curve_color(bytes, w, h, stride, tone.data(), red.data(), green.data(), blue.data(), 0.4, mixer.data(), 2, points.data(), grade.data(), 0.7, -0.3, visualize); }); QVERIFY(pixels(settings.apply(picture, std::nullopt, 1, 0, visualize)) == pixels(direct)); } } void CameraRawWiringTests::theSharpenMaskAndEachLensFlagReachTheirKernels() { const QImage picture = noise(); CameraRawSettings masked; masked.detail = {.sharpenAmount = 50, .sharpenRadius = 30, .sharpenDetail = 60, .sharpenMasking = 20}; const QImage mask = kernel(picture, [](uchar *bytes, size_t w, size_t h, size_t s) { adjust_camera_raw_sharpen_mask_overlay(bytes, w, h, s, 30, 60, 20, 1); }); QVERIFY(pixels(masked.apply(picture, std::nullopt, 1, 0, -1, true)) == pixels(mask)); const QImage half = kernel(picture, [](uchar *bytes, size_t w, size_t h, size_t s) { adjust_camera_raw_sharpen_mask_overlay(bytes, w, h, s, 80, 60, 20, 0.25); }); masked.detail.sharpenRadius = 80; QVERIFY(pixels(masked.apply(picture, std::nullopt, 0.25, 0, -1, true)) == pixels(half)); for (const bool chromatic : {true, false}) { CameraRawSettings lens; lens.optics.removeChromaticAberration = chromatic; lens.optics.enableLensProfile = !chromatic; const double k = lens.optics.distortionK(PixelFilter::lensStrength); const QImage direct = kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t s) { adjust_camera_raw_optics(bytes, w, h, s, chromatic ? 1 : 0, chromatic ? 0 : 1, 100, 100, k, 0, 270, 310, 0, 60, 120, 0, 50, 1); }); QVERIFY(pixels(lens.apply(picture)) == pixels(direct)); } } void CameraRawWiringTests::grainScalesByThePreview() { const QImage picture = noise(); CameraRawSettings settings; settings.grainAmount = 40; const QImage direct = kernel(picture, [](uchar *bytes, size_t w, size_t h, size_t s) { adjust_grain(bytes, w, h, s, 40, 5.375, 50, 3, 0, 0, 2); }); QVERIFY(pixels(settings.apply(picture, std::nullopt, 0.5, 3)) == pixels(direct)); // A dull colour plots nearer the middle: chroma over value. QImage dull = BrushRaster::context(1, 1, false); dull.setPixelColor(0, 0, QColor(128, 64, 64)); QCOMPARE(CameraRawScope::make(dull).value().vectorscope[size_t(32 * CameraRawScope::scopeSide + 47)], 1.0); } void CameraRawWiringTests::turnVerticalAndScalePlaceThePicture() { const QImage picture = checker(24, 24); const auto warped = [&](const std::function &change) { CameraRawGeometrySettings geometry; change(geometry); return pixels(geometry.apply(picture)); }; // A positive turn goes counterclockwise on screen, as Core Image's. QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.rotate = 10; }), pixels(placed(picture, QTransform::fromTranslate(12, 12).rotate(-10).translate(-12, -12)))) <= 1); QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.scale = 50; }), pixels(placed(picture, QTransform::fromTranslate(12, 12).scale(1.5, 1.5).translate(-12, -12)))) <= 1); // Vertical 40 widens the top by 0.072 of the width. const double v = 0.4 * 24 * 0.18; QTransform trapezoid; QVERIFY(QTransform::quadToQuad(QPolygonF(QRectF(0, 0, 24, 24)).mid(0, 4), QPolygonF({QPointF(-v, 0), QPointF(24 + v, 0), QPointF(24, 24), QPointF(0, 24)}), trapezoid)); QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.vertical = 40; }), pixels(placed(picture, trapezoid))) <= 1); QTransform wideBottom; QVERIFY(QTransform::quadToQuad(QPolygonF(QRectF(0, 0, 24, 24)).mid(0, 4), QPolygonF({QPointF(0, 0), QPointF(24, 0), QPointF(24 + v, 24), QPointF(-v, 24)}), wideBottom)); QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.horizontal = 40; }), pixels(placed(picture, wideBottom))) <= 1); } QTEST_GUILESS_MAIN(CameraRawWiringTests) #include "CameraRawWiringTests.moc"