Camera Raw curves as Photoshop's: the fitted parametric curve, the tone curve on each channel, one drag in the graph (13.18)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 18:10:13 -04:00
1 parent 5bfbc6cd5d
commit a77cce4598
17 files changed
+862 -143

No files matched your search

+1
View File
@@ -135,6 +135,7 @@ add_library(compositor_core STATIC
src/UI/TransformInspector.cpp
src/UI/EffectsSheet.cpp
src/UI/CameraRawColorControls.cpp
src/UI/CameraRawColorControls+Curve.cpp
src/UI/CameraRawColorControls+Grading.cpp
src/UI/CameraRawColorControls+Mixer.cpp
src/UI/CameraRawControls.cpp
+16 -10
View File
@@ -729,7 +729,7 @@ static double point_weight(double h, double s, double l, const float *point) {
}
void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride,
const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut,
const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut,
double refineSaturation, const float *mixer, int pointCount, const float *points,
const float *grade, double blending, double balance, int visualize) {
for (size_t y = 0; y < height; ++y) {
@@ -739,16 +739,22 @@ void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, s
double alpha = p[3];
if (!alpha) continue;
double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha);
double tone = rec709(r, g, b);
double mapped = lut_at(lumaLut, tone);
scale_luminance(&r, &g, &b, mapped);
if (refineSaturation != 0 && tone > 1e-4) {
double factor = 1 + refineSaturation * (mapped / tone - 1);
double lum = rec709(r, g, b);
r = camera_clamp(lum + (r - lum) * factor);
g = camera_clamp(lum + (g - lum) * factor);
b = camera_clamp(lum + (b - lum) * factor);
// The tone curve works on red, green and blue alike, as Photoshop's does, so contrast brings color strength
// with it. Refine Saturation below zero eases toward changing brightness alone (−100), and above zero adds
// more color.
double curvedR = lut_at(toneLut, r), curvedG = lut_at(toneLut, g), curvedB = lut_at(toneLut, b);
if (refineSaturation < 0) {
double br = r, bg = g, bb = b;
scale_luminance(&br, &bg, &bb, lut_at(toneLut, rec709(r, g, b)));
double k = -refineSaturation;
curvedR += (br - curvedR) * k; curvedG += (bg - curvedG) * k; curvedB += (bb - curvedB) * k;
} else if (refineSaturation > 0) {
double lum = rec709(curvedR, curvedG, curvedB), factor = 1 + refineSaturation;
curvedR = camera_clamp(lum + (curvedR - lum) * factor);
curvedG = camera_clamp(lum + (curvedG - lum) * factor);
curvedB = camera_clamp(lum + (curvedB - lum) * factor);
}
r = curvedR; g = curvedG; b = curvedB;
r = lut_at(redLut, r); g = lut_at(greenLut, g); b = lut_at(blueLut, b);
double h, s, l;
rgb_to_hsl(r, g, b, &h, &s, &l);
+2 -2
View File
@@ -48,12 +48,12 @@ void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride
// the radii match a full-size render. Grain is applied separately. Alpha is kept.
// Blue over clipped shadows and red over clipped highlights, on top of the grade. Preview only.
void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride, int shadows, int highlights);
// Curve, Color Mixer, and Color Grading after the basic grade. `lumaLut` and the channel LUTs are 256
// Curve, Color Mixer, and Color Grading after the basic grade. `toneLut` and the channel LUTs are 256
// entries. `mixer` is 24 floats: hue, saturation, luminance for eight families, −1…1. Each point color is
// 9 floats (hue, saturation, luminance, three shifts −1…1, three range half-widths). `grade` is four wheels
// of hue turns, saturation 0…1, and luminance −1…1. `visualize` darkens pixels outside that point color.
void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride,
const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut,
const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut,
double refineSaturation, const float *mixer, int pointCount, const float *points,
const float *grade, double blending, double balance, int visualize);
void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_t stride,
+27 -18
View File
@@ -79,25 +79,34 @@ bool CameraRawCurveSettings::adjusts() const
|| !isLinear(green) || !isLinear(blue);
}
double CameraRawCurveSettings::parametric(double tone) const
// Bends tones below `lower` by `low`, above `upper` by `high`.
double CameraRawCurveSettings::bend(double tone, double lower, double low, double upper, double high)
{
const double shadow = shadowSplit / 100, dark = darkSplit / 100, light = lightSplit / 100;
double amount, lo, hi;
if (tone < shadow)
amount = shadows, lo = 0, hi = shadow;
else if (tone < dark)
amount = darks, lo = shadow, hi = dark;
else if (tone < light)
amount = lights, lo = dark, hi = light;
else
amount = highlights, lo = light, hi = 1;
const double span = std::max(0.02, hi - lo);
const double weight = 1 - std::abs(tone - (lo + hi) / 2) / (span / 2);
// A tone in its region weighs 0 to 1, unfloored.
return std::min(1.0, std::max(0.0, tone + (amount / 100) * weight * 0.22));
// Fitted to Photoshop: Darks −51 dips 0.1 there.
const double strength = 1.66;
if (tone < lower)
return lower * std::pow(tone / lower, std::pow(2, -low / 100 * strength));
if (tone > upper) {
const double rest = 1 - upper;
return 1 - rest * std::pow((1 - tone) / rest, std::pow(2, high / 100 * strength));
}
return tone;
}
std::vector<float> CameraRawCurveSettings::lumaTable() const
double CameraRawCurveSettings::parametric(double tone) const
{
if (shadows == 0 && darks == 0 && lights == 0 && highlights == 0)
return tone;
// Gamma bends, smoothed as Image › Curves smooths.
std::vector<CurvePoint> anchors;
for (int index = 0; index <= 32; ++index) {
const double x = index / 32.0;
anchors.push_back({x, bend(bend(x, shadowSplit / 100, shadows, lightSplit / 100, highlights), darkSplit / 100, darks, darkSplit / 100, lights)});
}
return point(tone, anchors);
}
std::vector<float> CameraRawCurveSettings::toneTable() const
{
std::vector<float> table(256);
for (int index = 0; index < 256; ++index)
@@ -262,10 +271,10 @@ void CameraRawSettings::applyCurveColor(uchar *pixels, int width, int height, qs
const CameraRawCurveSettings shape = curve.normalized();
const CameraRawMixerSettings mixing = mixer.normalized();
const CameraRawGradingSettings grade = grading.normalized();
const std::vector<float> luma = shape.lumaTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
const std::vector<float> tone = shape.toneTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
blueTable = shape.channelTable(shape.blue), mixerFloats = mixing.mixerFloats(), pointFloats = mixing.pointFloats(),
gradeFloats = grade.gradeFloats();
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), luma.data(), redTable.data(), greenTable.data(),
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), tone.data(), redTable.data(), greenTable.data(),
blueTable.data(), shape.refineSaturation / 100, mixerFloats.data(), int(mixing.points.size()), pointFloats.data(),
gradeFloats.data(), grade.blending / 100, grade.balance / 100, visualize);
}
+6 -2
View File
@@ -33,15 +33,19 @@ struct CameraRawCurveSettings {
static std::vector<CurvePoint> strongContrast();
static bool isLinear(const std::vector<CurvePoint> &points);
bool adjusts() const;
// Lifts or lowers the region a tone belongs to.
// Photoshop's parametric curve: gamma bends, smoothed as Curves.
double parametric(double tone) const;
std::vector<float> lumaTable() const;
// Applied to red, green and blue alike.
std::vector<float> toneTable() const;
std::vector<float> channelTable(const std::vector<CurvePoint> &points) const;
CameraRawCurveSettings nudged(CameraRawPointChannel channel, double tone, double delta) const;
// Swift's region(for:): the parametric amount a tone moves.
double &region(double tone);
CameraRawCurveSettings normalized() const;
friend bool operator==(const CameraRawCurveSettings &, const CameraRawCurveSettings &) = default;
private:
static double bend(double tone, double lower, double low, double upper, double high);
};
// One picked colour and how far its adjustment reaches.
+114
View File
@@ -0,0 +1,114 @@
#include "UI/CameraRawColorControls.h"
#include <algorithm>
#include <array>
#include <cmath>
// Swift's curve graph drags, picked once at the press.
std::optional<CameraRawCurveControls::Drag> CameraRawCurveControls::parametricDrag(QPointF at, QSizeF size) const
{
const CameraRawCurveSettings curve = raw().curve;
const double tone = at.x() / std::max(size.width(), 1.0) * 100;
const std::array<double, 3> splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit};
// Along the bottom, the nearest divider, the first of equals.
if (at.y() > size.height() - 18) {
size_t nearest = 0;
for (size_t index = 1; index < splits.size(); ++index)
if (std::abs(splits[index] - tone) < std::abs(splits[nearest] - tone))
nearest = index;
return Drag{.kind = Drag::Kind::divider, .index = nearest, .from = at};
}
double CameraRawCurveSettings::*key = tone < splits[0] ? &CameraRawCurveSettings::shadows
: tone < splits[1] ? &CameraRawCurveSettings::darks
: tone < splits[2] ? &CameraRawCurveSettings::lights
: &CameraRawCurveSettings::highlights;
return Drag{.kind = Drag::Kind::region, .key = key, .start = curve.*key, .from = at};
}
std::optional<CameraRawCurveControls::Drag> CameraRawCurveControls::pointDrag(QPointF at, QSizeF size)
{
const double x = at.x() / std::max(size.width(), 1.0), y = 1 - at.y() / std::max(size.height(), 1.0);
std::vector<CurvePoint> points = currentPoints();
// On a point, that point; the first of equals.
std::optional<size_t> near;
for (size_t index = 0; index < points.size(); ++index)
if (!near || std::hypot(points[index].x - x, points[index].y - y) < std::hypot(points[*near].x - x, points[*near].y - y))
near = index;
if (near && std::hypot(points[*near].x - x, points[*near].y - y) < 0.055) {
m_selectedPoint = near;
return Drag{.kind = Drag::Kind::point, .index = *near, .from = at};
}
// Else a new point; the ends bound its x.
if (points.size() >= 16 || !std::all_of(points.begin(), points.end(), [x](const CurvePoint &each) { return std::abs(each.x - x) > 0.01; }))
return std::nullopt;
const CurvePoint point{x, y};
points.push_back(point);
std::stable_sort(points.begin(), points.end(), [](const CurvePoint &left, const CurvePoint &right) { return left.x < right.x; });
store(points);
const size_t index = size_t(std::find(points.begin(), points.end(), point) - points.begin());
m_selectedPoint = index;
return Drag{.kind = Drag::Kind::point, .index = index, .from = at};
}
void CameraRawCurveControls::beginDrag(QPointF at, QSizeF size)
{
// Qt may lose a release: every press picks afresh.
const bool parametric = !m_session.filterEdit() || m_session.filterEdit()->rawPanel.curvePage == CameraRawCurvePage::parametric;
m_drag = parametric ? parametricDrag(at, size) : pointDrag(at, size);
continueDrag(at, size);
}
void CameraRawCurveControls::continueDrag(QPointF at, QSizeF size)
{
if (!m_drag)
return;
const double x = at.x() / std::max(size.width(), 1.0), y = 1 - at.y() / std::max(size.height(), 1.0);
const Drag drag = *m_drag;
switch (drag.kind) {
case Drag::Kind::point: {
std::vector<CurvePoint> points = currentPoints();
if (drag.index >= points.size())
return;
points[drag.index].y = y;
// Ends stay ends; the rest keep their order.
if (drag.index > 0 && drag.index < points.size() - 1)
points[drag.index].x = std::min(points[drag.index + 1].x - 0.01, std::max(points[drag.index - 1].x + 0.01, x));
store(points);
return;
}
case Drag::Kind::divider: {
const double value = x * 100;
update([&drag, value](CameraRawSettings &settings) {
// Below the next divider; normalizing does the rest.
CameraRawCurveSettings &curve = settings.curve;
if (drag.index == 0)
curve.shadowSplit = std::min(value, curve.darkSplit - 2);
else if (drag.index == 1)
curve.darkSplit = std::min(curve.lightSplit - 2, value);
else
curve.lightSplit = value;
});
return;
}
case Drag::Kind::region: {
const double amount = std::min(100.0, std::max(-100.0, drag.start - (at.y() - drag.from.y()) / std::max(size.height(), 1.0) * 200));
update([&drag, amount](CameraRawSettings &settings) { settings.curve.*drag.key = std::round(amount); });
return;
}
}
}
void CameraRawCurveControls::removePoint(QPointF at, QSizeF size)
{
std::vector<CurvePoint> points = currentPoints();
const double x = at.x() / size.width();
// The nearest inner point by x, within 0.04; ends stay.
std::optional<size_t> nearest;
for (size_t index = 1; index + 1 < points.size(); ++index)
if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x))
nearest = index;
if (!nearest || !(std::abs(points[*nearest].x - x) < 0.04))
return;
points.erase(points.begin() + std::ptrdiff_t(*nearest));
m_selectedPoint.reset();
store(points);
}
+35 -82
View File
@@ -87,103 +87,45 @@ protected:
return;
}
painter.setPen(Qt::NoPen);
painter.setBrush(Qt::white);
for (const CurvePoint &point : m_owner.currentPoints())
painter.drawEllipse(QRectF(point.x * w - 4, (1 - point.y) * h - 4, 8, 8));
const std::vector<CurvePoint> points = m_owner.currentPoints();
for (size_t index = 0; index < points.size(); ++index) {
painter.setBrush(m_owner.m_selectedPoint == index ? palette().color(QPalette::Highlight) : QColor(Qt::white));
painter.drawEllipse(QRectF(points[index].x * w - 4, (1 - points[index].y) * h - 4, 8, 8));
}
}
// Swift's two drags: still, a press adds; moved, it drags.
// Swift's one drag: picked at the press, kept until release.
void mousePressEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton)
m_press = event->position();
m_owner.beginDrag(event->position(), size());
}
void mouseMoveEvent(QMouseEvent *event) override
{
if (!m_press || !event->buttons().testFlag(Qt::LeftButton))
return;
if (!m_moved && QLineF(*m_press, event->position()).length() < 2)
return;
m_moved = true;
if (parametric())
moveDivider(event->position().x());
else
movePoint(event->position());
if (event->buttons().testFlag(Qt::LeftButton))
m_owner.continueDrag(event->position(), size());
}
void mouseReleaseEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton && m_press && !m_moved && event->position() == *m_press && !parametric())
addPoint(event->position());
m_press.reset();
m_moved = false;
if (event->button() == Qt::LeftButton)
m_owner.m_drag.reset();
}
// The double click's own release must add nothing back.
// The second press drags too; then the point goes.
void mouseDoubleClickEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton && !parametric())
removePoint(event->position());
if (event->button() != Qt::LeftButton)
return;
m_owner.beginDrag(event->position(), size());
if (!parametric())
m_owner.removePoint(event->position(), size());
m_owner.m_drag.reset();
}
private:
void moveDivider(double x)
{
const double value = std::min(98.0, std::max(2.0, x / std::max(double(width()), 1.0) * 100));
const CameraRawCurveSettings curve = m_owner.raw().curve;
const std::array<double, 3> splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit};
size_t nearest = 0;
for (size_t index = 1; index < 3; ++index)
if (std::abs(splits[index] - value) < std::abs(splits[nearest] - value))
nearest = index;
m_owner.update([nearest, value](CameraRawSettings &settings) {
(nearest == 0 ? settings.curve.shadowSplit : nearest == 1 ? settings.curve.darkSplit : settings.curve.lightSplit) = value;
});
}
void addPoint(QPointF at)
{
std::vector<CurvePoint> points = m_owner.currentPoints();
points.push_back({std::min(0.99, std::max(0.01, at.x() / width())), std::min(1.0, std::max(0.0, 1 - at.y() / height()))});
m_owner.store(points);
}
// The nearest inner point by x; ends stay.
std::optional<size_t> nearestInner(const std::vector<CurvePoint> &points, double x) const
{
std::optional<size_t> nearest;
for (size_t index = 1; index + 1 < points.size(); ++index)
if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x))
nearest = index;
return nearest;
}
void movePoint(QPointF at)
{
std::vector<CurvePoint> points = m_owner.currentPoints();
const double x = at.x() / width();
const std::optional<size_t> index = nearestInner(points, x);
if (!index)
return;
points[*index] = {std::min(0.98, std::max(0.02, x)), std::min(1.0, std::max(0.0, 1 - at.y() / height()))};
m_owner.store(points);
}
void removePoint(QPointF at)
{
std::vector<CurvePoint> points = m_owner.currentPoints();
const double x = at.x() / width();
const std::optional<size_t> index = nearestInner(points, x);
if (!index || !(std::abs(points[*index].x - x) < 0.04))
return;
points.erase(points.begin() + std::ptrdiff_t(*index));
m_owner.store(points);
}
CameraRawCurveControls &m_owner;
std::optional<QPointF> m_press;
bool m_moved = false;
};
namespace {
@@ -250,6 +192,9 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget *
presetRow->addStretch(1);
points->addLayout(presetRow);
connect(m_preset, &QComboBox::activated, this, [this](int preset) {
// A fix beyond Swift: a preset's points drop the choice.
if (preset != 0)
m_selectedPoint.reset();
if (preset == 1)
store(CameraRawCurveSettings::linear());
else if (preset == 2)
@@ -258,7 +203,7 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget *
store(CameraRawCurveSettings::strongContrast());
});
m_refine = new CameraRawRow({.name = QStringLiteral("refineSaturation"), .title = QStringLiteral("Refine Saturation"),
.help = QStringLiteral("How much the RGB curve also changes color strength. Zero keeps it to brightness."), .titleWidth = 88,
.help = QStringLiteral("How much the curve also changes color strength. Zero matches Photoshop; lower keeps it to brightness, higher adds more color."), .titleWidth = 88,
.fixedTitle = true, .fieldWidth = 48, .scrub = 1},
[this] { return raw().curve.refineSaturation; },
[this](double value) { update([value](CameraRawSettings &settings) { settings.curve.refineSaturation = value; }); },
@@ -334,15 +279,23 @@ void CameraRawCurveControls::synchronize()
m_channelBox->setVisible(point);
const CameraRawPointChannel channel = edit ? edit->rawPanel.pointChannel : CameraRawPointChannel::rgb;
m_channel->button(int(channel))->setChecked(true);
m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click the curve to add one. Double-click a point to remove it.")
: QStringLiteral("Drag a divider to change which tones the neighboring sliders affect."));
// Swift's onChange: another channel drops the choice and drag.
if (channel != m_shownChannel) {
m_shownChannel = channel;
m_selectedPoint.reset();
m_drag.reset();
}
m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click to add one. Double-click a point to remove it.")
: QStringLiteral("Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers."));
m_graph->update();
m_amounts->setVisible(!point);
m_points->setVisible(point);
const std::vector<CurvePoint> points = currentPoints();
// Swift's selectedPoint: the last inner point, else the last.
const CurvePoint selected = points.size() > 2 ? points[points.size() - 2] : points.back();
m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(selected.x * 255)).arg(std::lround(selected.y * 255)));
// Swift's selectedPoint: the chosen index, while it exists.
const bool chosen = m_selectedPoint && *m_selectedPoint < points.size();
m_selected->setVisible(chosen);
if (chosen)
m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(points[*m_selectedPoint].x * 255)).arg(std::lround(points[*m_selectedPoint].y * 255)));
const QSignalBlocker quiet(m_preset);
m_preset->setCurrentIndex(points == CameraRawCurveSettings::linear() ? 1
: points == CameraRawCurveSettings::mediumContrast() ? 2
+16
View File
@@ -28,9 +28,25 @@ private:
void update(const std::function<void(CameraRawSettings &)> &change);
void store(const std::vector<CurvePoint> &points);
void panel(const std::function<void(CameraRawPanel &)> &change);
// Swift's Drag: a point, divider, or region.
struct Drag {
enum class Kind { point, divider, region } kind;
size_t index = 0;
double CameraRawCurveSettings::*key = nullptr;
double start = 0;
QPointF from;
};
std::optional<Drag> parametricDrag(QPointF at, QSizeF size) const;
std::optional<Drag> pointDrag(QPointF at, QSizeF size);
void beginDrag(QPointF at, QSizeF size);
void continueDrag(QPointF at, QSizeF size);
void removePoint(QPointF at, QSizeF size);
friend class CameraRawCurveGraph;
EditorSession &m_session;
std::optional<Drag> m_drag;
std::optional<size_t> m_selectedPoint;
CameraRawPointChannel m_shownChannel = CameraRawPointChannel::rgb;
QButtonGroup *m_page;
QWidget *m_channelBox;
QButtonGroup *m_channel;
+2 -11
View File
@@ -29,7 +29,7 @@ class CameraRawColorTests : public QObject {
Q_OBJECT
private slots:
void curvesAreRepairedIntoOrder();
void theParametricCurveLiftsItsRegion();
void aToneFindsItsRegion();
void aNudgeMovesTheFirstNearestPointWithinRange();
void splitsAndAmountsStayInTheirRanges();
void mixerFamiliesOverlapRoundTheWheel();
@@ -56,17 +56,8 @@ void CameraRawColorTests::curvesAreRepairedIntoOrder()
QCOMPARE(table[100], float(100 / 255.0));
}
void CameraRawColorTests::theParametricCurveLiftsItsRegion()
void CameraRawColorTests::aToneFindsItsRegion()
{
CameraRawCurveSettings curve;
curve.shadows = 100;
// The region's middle takes the whole 22%.
QVERIFY(std::abs(curve.parametric(0.125) - 0.345) < 1e-12);
QCOMPARE(curve.parametric(0.375), 0.375);
curve.highlights = -100;
curve.lightSplit = 100;
// A region of no width still takes a fiftieth's span.
QVERIFY(std::abs(curve.parametric(1) - 0.78) < 1e-12);
// Region picks the amount a tone moves.
CameraRawCurveSettings regions;
regions.region(0.1) = 1;
+400
View File
@@ -0,0 +1,400 @@
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "UI/CameraRawColorControls.h"
#include <QComboBox>
#include <QLabel>
#include <QToolButton>
#include <QtTest>
// Swift 1.4.5: the Camera Raw curve graph's one drag.
namespace {
struct Graph {
EditorSession session;
std::unique_ptr<CameraRawCurveControls> controls;
QWidget *graph = nullptr;
explicit Graph(bool point = false)
{
session.createDocument(8, 8);
QImage image = BrushRaster::context(8, 8, false);
image.fill(QColor(200, 120, 60));
session.insert(ImportedImage(image, image, QStringLiteral("Warm")));
session.beginFilter(FilterKind::cameraRaw);
controls = std::make_unique<CameraRawCurveControls>(session);
controls->resize(374, 600);
controls->show();
graph = controls->findChild<QWidget *>(QStringLiteral("cameraRawCurveGraph"));
if (point)
controls->findChild<QToolButton *>(QStringLiteral("curvePage1"))->click();
}
const CameraRawCurveSettings &curve() const { return session.filterEdit().value().settings.cameraRaw.curve; }
void set(const std::function<void(CameraRawCurveSettings &)> &change)
{
FilterSettings settings = session.filterEdit().value().settings;
change(settings.cameraRaw.curve);
session.updateFilter(settings, true);
}
// A curve position in the graph's own points.
QPoint at(double x, double y) const { return QPoint(int(std::lround(x * graph->width())), int(std::lround((1 - y) * graph->height()))); }
void press(QPoint where, Qt::MouseButton button = Qt::LeftButton) const { QTest::mousePress(graph, button, {}, where); }
void move(QPoint where, Qt::MouseButtons buttons = Qt::LeftButton) const
{
QMouseEvent event(QEvent::MouseMove, where, graph->mapToGlobal(where), Qt::NoButton, buttons, Qt::NoModifier);
QApplication::sendEvent(graph, &event);
}
void release(QPoint where) const { QTest::mouseRelease(graph, Qt::LeftButton, {}, where); }
QLabel &readout() const { return *controls->findChild<QLabel *>(QStringLiteral("curveSelectedPoint")); }
QColor dot(double x, double y) const { return graph->grab().toImage().pixelColor(at(x, y)); }
};
}
class CameraRawCurveGraphTests : public QObject {
Q_OBJECT
private slots:
void aRegionDragLiftsOrLowersThePressedRegion();
void theRegionIsPickedOnceByItsTone();
void dividersMoveInTheBottomStripAndStayOrdered();
void aPointIsPickedByDistanceAndKept();
void aPressElsewhereAddsAndDragsAPoint();
void addingIsRefusedNearAnEdgeAPointOrSixteen();
void theChosenPointIsLitAndReadOut();
void aNewChannelOrPresetDropsTheChoice();
void aDoubleClickRemovesAnInnerPointAndTheChoice();
void onlyTheLeftButtonDragsAndAPressStartsAfresh();
void theGraphSaysWhatADragDoes();
};
void CameraRawCurveGraphTests::aRegionDragLiftsOrLowersThePressedRegion()
{
Graph graph;
const int h = graph.graph->height();
graph.set([](CameraRawCurveSettings &curve) { curve.lights = 10; });
// Up lifts: a quarter height adds 50.
graph.press(QPoint(int(graph.graph->width() * 0.6), 100));
graph.move(QPoint(int(graph.graph->width() * 0.6), 100 - h / 4));
QCOMPARE(graph.curve().lights, std::round(10 + double(h / 4) / h * 200));
// Down lowers, clamped at −100, rounded to whole.
graph.move(QPoint(int(graph.graph->width() * 0.6), 100 + h));
QCOMPARE(graph.curve().lights, -100.0);
graph.move(QPoint(10, 100 + 3));
QCOMPARE(graph.curve().lights, std::round(10 - 3.0 / h * 200));
graph.release(QPoint(10, 103));
QVERIFY(graph.curve().shadows == 0 && graph.curve().darks == 0 && graph.curve().highlights == 0);
// Far up, +100 at most.
graph.press(QPoint(int(graph.graph->width() * 0.9), 120));
graph.move(QPoint(int(graph.graph->width() * 0.9), 120 - 4 * h));
QCOMPARE(graph.curve().highlights, 100.0);
}
void CameraRawCurveGraphTests::theRegionIsPickedOnceByItsTone()
{
Graph graph;
const int w = graph.graph->width();
graph.set([](CameraRawCurveSettings &curve) { curve.shadowSplit = 20; });
// A tone on a divider joins the region above.
const std::pair<double, double CameraRawCurveSettings::*> regions[] = {
{0.1, &CameraRawCurveSettings::shadows}, {0.2, &CameraRawCurveSettings::darks}, {0.5, &CameraRawCurveSettings::lights}, {0.75, &CameraRawCurveSettings::highlights}};
for (const auto &[tone, key] : regions) {
const CameraRawCurveSettings before = graph.curve();
graph.press(QPoint(int(std::lround(tone * w)), 60));
graph.move(QPoint(int(std::lround(tone * w)), 50));
graph.release(QPoint(int(std::lround(tone * w)), 50));
CameraRawCurveSettings expected = before;
expected.*key = std::round(10.0 / graph.graph->height() * 200);
QVERIFY2(graph.curve() == expected, qPrintable(QString::number(tone)));
}
// At tone 50 exactly, each divider hands it upward.
QCOMPARE(w % 2, 0);
const std::tuple<std::array<double, 3>, double CameraRawCurveSettings::*> onDividers[] = {
{{50, 60, 80}, &CameraRawCurveSettings::darks}, {{20, 50, 80}, &CameraRawCurveSettings::lights}, {{20, 30, 50}, &CameraRawCurveSettings::highlights}};
for (const auto &[splits, key] : onDividers) {
graph.set([&splits](CameraRawCurveSettings &curve) {
curve = CameraRawCurveSettings();
curve.shadowSplit = splits[0];
curve.darkSplit = splits[1];
curve.lightSplit = splits[2];
});
graph.press(QPoint(w / 2, 60));
graph.move(QPoint(w / 2, 50));
graph.release(QPoint(w / 2, 50));
QVERIFY(graph.curve().*key > 0);
}
// Kept through the drag: across another region, the same one.
graph.set([](CameraRawCurveSettings &curve) { curve.shadows = curve.darks = curve.lights = curve.highlights = 0; });
graph.press(QPoint(int(0.1 * w), 60));
graph.move(QPoint(int(0.9 * w), 40));
QVERIFY(graph.curve().shadows > 0 && graph.curve().highlights == 0);
}
void CameraRawCurveGraphTests::dividersMoveInTheBottomStripAndStayOrdered()
{
Graph graph;
const int w = graph.graph->width(), h = graph.graph->height();
// The strip is the bottom 18 points.
graph.press(QPoint(int(0.5 * w), h - 18));
graph.move(QPoint(int(0.6 * w), h - 30));
graph.release(QPoint(int(0.6 * w), h - 30));
QCOMPARE(graph.curve().darkSplit, 50.0);
QVERIFY(graph.curve().lights > 0);
graph.set([](CameraRawCurveSettings &curve) { curve.lights = 0; });
// Each divider, the nearest to the press, between its neighbours.
const auto dragged = [&](double from, double to) {
graph.press(QPoint(int(std::lround(from * w)), h - 17));
graph.move(QPoint(int(std::lround(to * w)), 10));
graph.release(QPoint(int(std::lround(to * w)), 10));
};
dragged(0.3, 0.9);
QCOMPARE(graph.curve().shadowSplit, 48.0);
// Past 2 and 98 the settings' own limits hold.
dragged(0.47, 0.0);
QCOMPARE(graph.curve().shadowSplit, 5.0);
dragged(0.55, 0.9);
QCOMPARE(graph.curve().darkSplit, 73.0);
dragged(0.73, 0.01);
QCOMPARE(graph.curve().darkSplit, 7.0);
dragged(0.74, 0.0);
QCOMPARE(graph.curve().lightSplit, 9.0);
dragged(0.095, 1.2);
QCOMPARE(graph.curve().lightSplit, 98.0);
QVERIFY(graph.curve().shadows == 0 && graph.curve().darks == 0 && graph.curve().lights == 0 && graph.curve().highlights == 0);
// At tone 50 exactly, between 40 and 60: the first.
QCOMPARE(w % 2, 0);
graph.set([](CameraRawCurveSettings &curve) {
curve.shadowSplit = 40;
curve.darkSplit = 60;
curve.lightSplit = 80;
});
graph.press(QPoint(w / 2, h - 5));
graph.move(QPoint(int(0.45 * w), h - 5));
QVERIFY(std::abs(graph.curve().shadowSplit - 45) < 0.5 && graph.curve().darkSplit == 60);
}
void CameraRawCurveGraphTests::aPointIsPickedByDistanceAndKept()
{
Graph graph(true);
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.36, 0.5}, {1, 1}}; });
// Nearer by distance, not by x: the second.
graph.press(graph.at(0.33, 0.48));
graph.move(graph.at(0.33, 0.6));
QCOMPARE(graph.curve().rgb.size(), size_t(4));
QVERIFY(std::abs(graph.curve().rgb[2].y - 0.6) < 0.01 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3}));
// Kept as it passes x 0.99, held before the end.
graph.move(graph.at(1.2, 0.2));
QVERIFY(std::abs(graph.curve().rgb[2].x - 0.99) < 1e-9 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3}));
// Onto its neighbour: a hundredth past, then repaired away.
graph.move(graph.at(0.1, 0.2));
QCOMPARE(graph.curve().rgb.size(), size_t(3));
QVERIFY(graph.curve().rgb[1] == (CurvePoint{0.3, 0.3}));
graph.release(graph.at(0.1, 0.2));
// Onto the next point: a hundredth short; that one goes.
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.5, 0.5}, {1, 1}}; });
graph.press(graph.at(0.3, 0.3));
graph.move(graph.at(0.8, 0.3));
graph.release(graph.at(0.8, 0.3));
QCOMPARE(graph.curve().rgb.size(), size_t(3));
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.49) < 1e-9);
// 0.05 away takes the point; 0.06 away adds one.
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
graph.press(graph.at(0.53, 0.54));
graph.release(graph.at(0.53, 0.54));
QCOMPARE(graph.curve().rgb.size(), size_t(3));
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.53) < 0.003);
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
graph.press(graph.at(0.54, 0.545));
graph.release(graph.at(0.54, 0.545));
QCOMPARE(graph.curve().rgb.size(), size_t(4));
QVERIFY(graph.curve().rgb[1] == (CurvePoint{0.5, 0.5}));
}
void CameraRawCurveGraphTests::aPressElsewhereAddsAndDragsAPoint()
{
Graph graph(true);
graph.press(graph.at(0.25, 0.6));
QCOMPARE(graph.curve().rgb.size(), size_t(3));
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.25) < 0.01 && std::abs(graph.curve().rgb[1].y - 0.6) < 0.01);
// The same press drags it on, in order.
graph.move(graph.at(0.4, 0.7));
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.4) < 0.01 && std::abs(graph.curve().rgb[1].y - 0.7) < 0.01);
graph.release(graph.at(0.4, 0.7));
// Added below, sorted in at its x.
graph.press(graph.at(0.8, 0.95));
graph.move(graph.at(0.7, 1.4));
graph.release(graph.at(0.7, 1.4));
QCOMPARE(graph.curve().rgb.size(), size_t(4));
QVERIFY(std::abs(graph.curve().rgb[2].x - 0.7) < 0.01 && graph.curve().rgb[2].y == 1);
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.4) < 0.01);
// The ends move their output alone.
graph.press(graph.at(1, 1));
graph.move(graph.at(0.6, 0.8));
graph.release(graph.at(0.6, 0.8));
QVERIFY(graph.curve().rgb.back().x == 1 && std::abs(graph.curve().rgb.back().y - 0.8) < 0.01);
graph.press(graph.at(0, 0));
graph.move(graph.at(0.3, -0.5));
graph.release(graph.at(0.3, -0.5));
QVERIFY(graph.curve().rgb.front() == (CurvePoint{0, 0}));
}
void CameraRawCurveGraphTests::addingIsRefusedNearAnEdgeAPointOrSixteen()
{
Graph graph(true);
// Too near an end's x, or a point's.
const int w = graph.graph->width();
graph.press(QPoint(2, 20));
graph.move(QPoint(40, 30));
graph.release(QPoint(40, 30));
graph.press(QPoint(w - 2, 140));
graph.release(QPoint(w - 2, 140));
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.2}, {1, 1}}; });
graph.press(graph.at(0.505, 0.9));
graph.move(graph.at(0.6, 0.9));
graph.release(graph.at(0.6, 0.9));
QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.5, 0.2}, {1, 1}}));
QVERIFY(!graph.readout().isVisible());
// Sixteen points take no seventeenth.
graph.set([](CameraRawCurveSettings &curve) {
curve.rgb = {{0, 0}};
for (int index = 1; index < 15; ++index)
curve.rgb.push_back({index * 0.06, index * 0.06});
curve.rgb.push_back({1, 1});
});
QCOMPARE(graph.curve().rgb.size(), size_t(16));
graph.press(graph.at(0.93, 0.2));
graph.release(graph.at(0.93, 0.2));
QCOMPARE(graph.curve().rgb.size(), size_t(16));
// Fifteen take a sixteenth.
graph.set([](CameraRawCurveSettings &curve) { curve.rgb.erase(curve.rgb.begin() + 1); });
graph.press(graph.at(0.93, 0.2));
graph.release(graph.at(0.93, 0.2));
QCOMPARE(graph.curve().rgb.size(), size_t(16));
}
void CameraRawCurveGraphTests::theChosenPointIsLitAndReadOut()
{
Graph graph(true);
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.4}, {0.7, 0.6}, {1, 1}}; });
const QColor accent = graph.graph->palette().color(QPalette::Highlight);
QCOMPARE(graph.dot(0.3, 0.4), QColor(Qt::white));
QVERIFY(!graph.readout().isVisible());
const auto shown = [&graph](size_t index) {
const CurvePoint point = graph.curve().rgb[index];
return QStringLiteral("In %1 Out %2").arg(std::lround(point.x * 255)).arg(std::lround(point.y * 255));
};
// The press takes the point to the pointer.
graph.press(graph.at(0.3, 0.4));
QVERIFY(std::abs(graph.curve().rgb[1].x - 0.3) < 0.003 && std::abs(graph.curve().rgb[1].y - 0.4) < 0.007);
QCOMPARE(graph.dot(0.3, 0.4), accent);
QCOMPARE(graph.dot(0.7, 0.6), QColor(Qt::white));
QCOMPARE(graph.readout().text(), shown(1));
// The readout follows the drag.
graph.move(graph.at(0.4, 0.5));
QCOMPARE(graph.readout().text(), shown(1));
QVERIFY(graph.readout().text().startsWith("In 102 Out 12"));
graph.release(graph.at(0.4, 0.5));
// An end point is chosen too.
graph.press(graph.at(1, 1));
graph.release(graph.at(1, 1));
QCOMPARE(graph.readout().text(), QString("In 255 Out 255"));
QCOMPARE(graph.graph->grab().toImage().pixelColor(graph.graph->width() - 2, 2), accent);
// A new point is chosen as it is made.
graph.press(graph.at(0.85, 0.2));
graph.release(graph.at(0.85, 0.2));
QCOMPARE(graph.readout().text(), shown(3));
QVERIFY(std::abs(graph.curve().rgb[3].x - 0.85) < 0.003);
QCOMPARE(graph.dot(0.85, 0.2), accent);
// A choice past the points changed elsewhere hides.
graph.press(graph.at(1, 1));
graph.release(graph.at(1, 1));
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = CameraRawCurveSettings::linear(); });
QVERIFY(!graph.readout().isVisible());
}
void CameraRawCurveGraphTests::aNewChannelOrPresetDropsTheChoice()
{
Graph graph(true);
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = curve.red = {{0, 0}, {0.3, 0.4}, {1, 1}}; });
graph.press(graph.at(0.3, 0.4));
QVERIFY(graph.readout().isVisible());
// Another channel: no choice, and the held drag lets go.
graph.controls->findChild<QToolButton *>(QStringLiteral("pointChannel1"))->click();
QVERIFY(!graph.readout().isVisible());
QCOMPARE(graph.dot(0.3, 0.4), QColor(Qt::white));
graph.move(graph.at(0.3, 0.9));
QVERIFY(graph.curve().red == (std::vector<CurvePoint>{{0, 0}, {0.3, 0.4}, {1, 1}}));
graph.release(graph.at(0.3, 0.9));
// A preset's points drop it too; Custom keeps it.
graph.press(graph.at(0.3, 0.4));
graph.release(graph.at(0.3, 0.4));
auto &preset = *graph.controls->findChild<QComboBox *>(QStringLiteral("curvePreset"));
emit preset.activated(0);
graph.set([](CameraRawCurveSettings &curve) { curve.refineSaturation = 10; });
QVERIFY(graph.readout().isVisible());
emit preset.activated(2);
QVERIFY(graph.curve().red == CameraRawCurveSettings::mediumContrast());
QVERIFY(!graph.readout().isVisible());
QCOMPARE(graph.dot(0.25, 0.18), QColor(Qt::white));
}
void CameraRawCurveGraphTests::aDoubleClickRemovesAnInnerPointAndTheChoice()
{
Graph graph(true);
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
QTest::mouseDClick(graph.graph->window()->windowHandle(), Qt::LeftButton, {}, graph.graph->mapTo(graph.graph->window(), graph.at(0.5, 0.5)));
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
QVERIFY(!graph.readout().isVisible());
// On empty ground: one click adds, two remove.
QTest::mouseDClick(graph.graph->window()->windowHandle(), Qt::LeftButton, {}, graph.graph->mapTo(graph.graph->window(), graph.at(0.4, 0.8)));
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
// A move after it drags nothing.
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.6, 0.6}, {1, 1}}; });
QTest::mouseDClick(graph.graph, Qt::LeftButton, {}, graph.at(0.3, 0.3));
graph.move(graph.at(0.5, 0.9));
QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.6, 0.6}, {1, 1}}));
// An end point stays, though chosen.
QTest::mouseDClick(graph.graph, Qt::LeftButton, {}, graph.at(1, 1));
QCOMPARE(graph.curve().rgb.size(), size_t(3));
QCOMPARE(graph.readout().text(), QString("In 255 Out 255"));
}
void CameraRawCurveGraphTests::onlyTheLeftButtonDragsAndAPressStartsAfresh()
{
Graph graph(true);
graph.press(graph.at(0.5, 0.8), Qt::RightButton);
graph.move(graph.at(0.5, 0.2), Qt::RightButton);
QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear());
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
QTest::mouseDClick(graph.graph, Qt::RightButton, {}, graph.at(0.5, 0.5));
QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.5, 0.5}, {1, 1}}));
// A move without the left button drags nothing.
graph.press(graph.at(0.5, 0.5));
graph.move(graph.at(0.5, 0.9), Qt::RightButton);
QVERIFY(std::abs(graph.curve().rgb[1].y - 0.5) < 0.01);
graph.release(graph.at(0.5, 0.5));
// A lost release: the next press picks its own target.
graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.7, 0.7}, {1, 1}}; });
graph.press(graph.at(0.3, 0.3));
graph.press(graph.at(0.7, 0.7));
graph.move(graph.at(0.7, 0.9));
QVERIFY(std::abs(graph.curve().rgb[1].y - 0.3) < 0.01 && std::abs(graph.curve().rgb[2].y - 0.9) < 0.01);
// A release lets go: moves then change nothing.
graph.release(graph.at(0.7, 0.9));
const std::vector<CurvePoint> held = graph.curve().rgb;
graph.move(graph.at(0.5, 0.1));
QVERIFY(graph.curve().rgb == held);
// Parametric too: a release ends the region.
graph.controls->findChild<QToolButton *>(QStringLiteral("curvePage0"))->click();
graph.press(QPoint(30, 80));
graph.release(QPoint(30, 80));
graph.move(QPoint(30, 10));
QCOMPARE(graph.curve().shadows, 0.0);
}
void CameraRawCurveGraphTests::theGraphSaysWhatADragDoes()
{
Graph graph;
QCOMPARE(graph.graph->toolTip(), QString("Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers."));
graph.controls->findChild<QToolButton *>(QStringLiteral("curvePage1"))->click();
QCOMPARE(graph.graph->toolTip(), QString("Drag a point. Click to add one. Double-click a point to remove it."));
}
QTEST_MAIN(CameraRawCurveGraphTests)
#include "CameraRawCurveGraphTests.moc"
+10 -4
View File
@@ -94,14 +94,20 @@ void CameraRawCurveMixerTests::theParametricPageMovesRegionsAndDividers()
QTest::mouseDClick(&slider, Qt::LeftButton, {}, knob(slider));
QCOMPARE(group.raw().curve.*member, 0.0);
}
// A drag moves the nearest divider, within 2 and 98.
// Along the bottom: the nearest divider, kept ordered.
auto &graph = group.child<QWidget>(QStringLiteral("cameraRawCurveGraph"));
press(graph, QPoint(int(graph.width() * 0.3), 75));
drag(graph, QPoint(int(graph.width() * 0.35), 75));
const int h = graph.height();
press(graph, QPoint(int(graph.width() * 0.3), h - 5));
drag(graph, QPoint(int(graph.width() * 0.35), h - 5));
QVERIFY(std::abs(group.raw().curve.shadowSplit - 35) < 1 && group.raw().curve.darkSplit == 50);
drag(graph, QPoint(graph.width() + 40, 75));
QVERIFY(group.raw().curve.lightSplit == 98 || group.raw().curve.shadowSplit == 98 || group.raw().curve.darkSplit == 98);
QCOMPARE(group.raw().curve.shadowSplit, 48.0);
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(graph.width() + 40, 75));
// Above it a drag lifts the region under the press.
press(graph, QPoint(int(graph.width() * 0.3), 75));
drag(graph, QPoint(int(graph.width() * 0.9), 75 - h / 4));
QCOMPARE(group.raw().curve.shadows, std::round(double(h / 4) / h * 200));
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(int(graph.width() * 0.9), 75 - h / 4));
// A double click here leaves the point curve's points.
group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
QTest::mouseDClick(&graph, Qt::LeftButton, {}, QPoint(graph.width() / 2, graph.height() / 2));
+213
View File
@@ -0,0 +1,213 @@
#include "CameraRawFixtures.h"
#include <QtTest>
#include <cmath>
// Swift 1.4.5: Camera Raw's curves as Photoshop's, the model.
using namespace CameraRawFixtures;
namespace {
// Swift's bend, written out apart from the code.
double bent(double tone, double lower, double low, double upper, double high)
{
if (tone < lower && lower > 0)
return lower * std::pow(tone / lower, std::pow(2.0, -low / 100 * 1.66));
if (tone > upper && upper < 1)
return 1 - (1 - upper) * std::pow((1 - tone) / (1 - upper), std::pow(2.0, high / 100 * 1.66));
return tone;
}
// The kernel's table lookup: linear between entries.
double looked(const std::vector<float> &table, double value)
{
const double at = value * 255;
const int below = int(std::floor(at));
if (below >= 255)
return table[255];
return table[size_t(below)] + (table[size_t(below + 1)] - table[size_t(below)]) * (at - below);
}
double luminance(const std::array<int, 4> &pixel)
{
return 0.2126 * pixel[0] + 0.7152 * pixel[1] + 0.0722 * pixel[2];
}
}
class CameraRawCurveTests : public QObject {
Q_OBJECT
private slots:
void parametricCurveIsSmooth();
void parametricCurveMatchesPhotoshop();
void curveDeepensColorLikePhotoshop();
void theCurvePassesThroughEachBentAnchor();
void eachRegionBendsOnlyItsRange();
void theToneTableRunsThroughThePointCurve();
void refineSaturationEasesOrStrengthens();
};
void CameraRawCurveTests::parametricCurveIsSmooth()
{
CameraRawCurveSettings curve;
QCOMPARE(curve.parametric(0.3), 0.3);
for (const std::array<double, 4> &amounts : {std::array{100.0, 0.0, 0.0, 0.0}, {0, 100, -100, 0}, {-100, 50, 100, -60}}) {
curve.shadows = amounts[0];
curve.darks = amounts[1];
curve.lights = amounts[2];
curve.highlights = amounts[3];
std::vector<double> samples;
for (int index = 0; index <= 200; ++index)
samples.push_back(curve.parametric(index / 200.0));
QCOMPARE(samples.front(), 0.0);
QCOMPARE(samples.back(), 1.0);
for (size_t index = 1; index < samples.size(); ++index)
QVERIFY(samples[index] >= samples[index - 1] - 1e-9);
// Finer sampling halves the slope's jumps; a corner keeps them.
const auto jump = [&curve](int steps) {
std::vector<double> slopes;
double last = curve.parametric(0);
for (int index = 1; index <= steps; ++index) {
const double value = curve.parametric(double(index) / steps);
slopes.push_back((value - last) * steps);
last = value;
}
double worst = 0;
for (size_t index = 1; index < slopes.size(); ++index)
worst = std::max(worst, std::abs(slopes[index] - slopes[index - 1]));
return worst;
};
QVERIFY(jump(400) < jump(200) * 0.7);
}
curve = CameraRawCurveSettings();
curve.darks = 100;
const double before = curve.parametric(0.4);
curve.darkSplit = 70;
QVERIFY(curve.parametric(0.4) > before);
}
void CameraRawCurveTests::parametricCurveMatchesPhotoshop()
{
CameraRawCurveSettings curve;
curve.darks = -51;
curve.lights = 59;
const std::pair<double, double> photoshop[] = {{0.093, 0.011}, {0.192, 0.089}, {0.267, 0.174}, {0.367, 0.310}, {0.491, 0.498},
{0.616, 0.698}, {0.690, 0.804}, {0.765, 0.886}, {0.840, 0.947}, {0.915, 0.982}};
for (const auto &[tone, expected] : photoshop)
QVERIFY2(std::abs(curve.parametric(tone) - expected) < 0.035, qPrintable(QString::number(curve.parametric(tone))));
}
void CameraRawCurveTests::curveDeepensColorLikePhotoshop()
{
const QImage orange = image(2, 2, 0.85, 0.35, 0.1);
CameraRawSettings settings;
settings.curve.darks = -51;
settings.curve.lights = 59;
const std::array<int, 4> curved = pixels(settings.apply(orange))[0];
QVERIFY2(curved[0] > 230 && curved[1] < 80, qPrintable(QString("%1 %2").arg(curved[0]).arg(curved[1])));
settings.curve.refineSaturation = -100;
const std::array<int, 4> brightness = pixels(settings.apply(orange))[0];
QVERIFY(double(brightness[1]) / double(brightness[0]) > 0.35);
}
void CameraRawCurveTests::theCurvePassesThroughEachBentAnchor()
{
CameraRawCurveSettings curve;
curve.shadows = 40;
curve.darks = -51;
curve.lights = 59;
curve.highlights = -30;
curve.shadowSplit = 20;
curve.darkSplit = 45;
curve.lightSplit = 80;
// Shadows and highlights first, then darks and lights.
for (int index = 0; index <= 32; ++index) {
const double x = index / 32.0;
const double expected = bent(bent(x, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59);
QVERIFY2(std::abs(curve.parametric(x) - expected) < 1e-9, qPrintable(QString::number(x)));
}
// Between anchors the smooth curve, not a straight line.
const double middle = (bent(bent(1 / 32.0, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59) + bent(bent(2 / 32.0, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59)) / 2;
QVERIFY(std::abs(curve.parametric(1.5 / 32) - middle) > 1e-6);
}
void CameraRawCurveTests::eachRegionBendsOnlyItsRange()
{
// Shadows and highlights bend past their dividers alone.
CameraRawCurveSettings shadows;
shadows.shadows = 100;
QVERIFY(shadows.parametric(4 / 32.0) > 4 / 32.0 + 0.05);
QVERIFY(std::abs(shadows.parametric(0.25) - 0.25) < 1e-9);
QVERIFY(std::abs(shadows.parametric(0.5) - 0.5) < 1e-9);
CameraRawCurveSettings highlights;
highlights.highlights = -100;
QVERIFY(highlights.parametric(28 / 32.0) < 28 / 32.0 - 0.05);
QVERIFY(std::abs(highlights.parametric(0.75) - 0.75) < 1e-9);
// Darks cover all below the middle; lights all above it.
CameraRawCurveSettings darks;
darks.darks = 100;
QVERIFY(darks.parametric(4 / 32.0) > 4 / 32.0 + 0.02 && darks.parametric(12 / 32.0) > 12 / 32.0 + 0.05);
QVERIFY(std::abs(darks.parametric(0.5) - 0.5) < 1e-9 && std::abs(darks.parametric(0.75) - 0.75) < 1e-9);
CameraRawCurveSettings lights;
lights.lights = -100;
QVERIFY(lights.parametric(20 / 32.0) < 20 / 32.0 - 0.05 && lights.parametric(28 / 32.0) < 28 / 32.0 - 0.02);
QVERIFY(std::abs(lights.parametric(0.25) - 0.25) < 1e-9);
// A divider at an end bends nothing past it.
CameraRawCurveSettings ends;
ends.shadows = 100;
ends.highlights = 100;
ends.shadowSplit = 0;
ends.lightSplit = 100;
for (int index = 0; index <= 32; ++index)
QVERIFY(std::abs(ends.parametric(index / 32.0) - index / 32.0) < 1e-9);
}
void CameraRawCurveTests::theToneTableRunsThroughThePointCurve()
{
CameraRawCurveSettings curve;
curve.lights = 59;
curve.rgb = {{0, 0}, {0.5, 0.6}, {1, 1}};
const std::vector<float> table = curve.toneTable();
QCOMPARE(table.size(), size_t(256));
CameraRawCurveSettings point;
point.rgb = curve.rgb;
// Without amounts, the point curve alone: 0.5 to 0.6.
QVERIFY(std::abs(point.toneTable()[128] - 0.6) < 0.005);
for (const int index : {0, 64, 128, 200, 255}) {
const double expected = point.toneTable()[size_t(std::lround(curve.parametric(index / 255.0) * 255))];
QVERIFY2(std::abs(table[size_t(index)] - expected) < 0.01, qPrintable(QString::number(index)));
}
QCOMPARE(table[255], 1.0f);
}
void CameraRawCurveTests::refineSaturationEasesOrStrengthens()
{
const QImage orange = image(2, 2, 0.85, 0.35, 0.1);
CameraRawSettings settings;
settings.curve.darks = -51;
settings.curve.lights = 59;
// At zero each channel runs through the tone curve alone.
const std::vector<float> table = settings.curve.toneTable();
const std::array<int, 4> source = pixels(orange)[0], curved = pixels(settings.apply(orange))[0];
for (int channel = 0; channel < 3; ++channel)
QVERIFY2(std::abs(curved[size_t(channel)] - looked(table, source[size_t(channel)] / 255.0) * 255) <= 1, qPrintable(QString::number(channel)));
// −100: the colour scaled to the curved brightness, hue kept.
settings.curve.refineSaturation = -100;
const std::array<int, 4> brightness = pixels(settings.apply(orange))[0];
const double mapped = looked(table, luminance(source) / 255) * 255;
QVERIFY(std::abs(luminance(brightness) - mapped) <= 1.5);
QVERIFY(std::abs(double(brightness[1]) / brightness[0] - double(source[1]) / source[0]) < 0.02);
// −50: halfway between the two.
settings.curve.refineSaturation = -50;
const std::array<int, 4> half = pixels(settings.apply(orange))[0];
for (int channel = 0; channel < 3; ++channel)
QVERIFY(std::abs(half[size_t(channel)] - (curved[size_t(channel)] + brightness[size_t(channel)]) / 2.0) <= 1.5);
// +50: half again as far from luminance.
settings.curve.refineSaturation = 50;
const std::array<int, 4> stronger = pixels(settings.apply(orange))[0];
const double lum = luminance(curved);
for (int channel = 0; channel < 3; ++channel) {
const double expected = std::clamp(lum + (curved[size_t(channel)] - lum) * 1.5, 0.0, 255.0);
QVERIFY2(std::abs(stronger[size_t(channel)] - expected) <= 1.5, qPrintable(QString("%1 %2").arg(stronger[size_t(channel)]).arg(expected)));
}
}
QTEST_MAIN(CameraRawCurveTests)
#include "CameraRawCurveTests.moc"
+6 -6
View File
@@ -89,7 +89,7 @@ void CameraRawGroupDrawTests::theCurvePagesShowTheirOwnControls()
QCOMPARE(preset.toolTip(), QString("Replaces this curve with a straight line or a contrast curve."));
QCOMPARE(channel.toolTip(), QString("RGB changes brightness. Red, green, and blue also shift the color."));
QCOMPARE(group.child<QWidget>(QStringLiteral("cameraRawCurveGraph")).toolTip(),
QString("Drag a point. Click the curve to add one. Double-click a point to remove it."));
QString("Drag a point. Click to add one. Double-click a point to remove it."));
QCOMPARE(group.child<QWidget>(QStringLiteral("curveSelectedPoint")).toolTip(), QString("Input and output of the selected curve point."));
QCOMPARE(group.child<QAbstractButton>(QStringLiteral("curveTargeted")).toolTip(),
QString("Drag on the picture to move the curve for the tone under the pointer."));
@@ -126,20 +126,20 @@ void CameraRawGroupDrawTests::endPointsStayAndARealDoubleClickRemoves()
group.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; });
auto &graph = group.child<QWidget>(QStringLiteral("cameraRawCurveGraph"));
const int w = graph.width(), h = graph.height();
// With no inner point a drag moves nothing.
// An end point keeps its x; its output follows.
QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(2, h - 2));
QMouseEvent move(QEvent::MouseMove, QPointF(40, 40), graph.mapToGlobal(QPointF(40, 40)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(&graph, &move);
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(40, 40));
QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear());
// An inner point is held within 0.02 and 0.98.
QCOMPARE(group.raw().curve.rgb.size(), size_t(2));
QVERIFY(group.raw().curve.rgb[0].x == 0 && std::abs(group.raw().curve.rgb[0].y - (1 - 40.0 / h)) < 1e-9 && group.raw().curve.rgb[1] == (CurvePoint{1, 1}));
// Past the start: a hundredth in, then repaired away.
group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(w / 2, h / 2));
QMouseEvent far(QEvent::MouseMove, QPointF(-50, h / 2), graph.mapToGlobal(QPointF(-50, h / 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(&graph, &far);
QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(-50, h / 2));
QCOMPARE(group.raw().curve.rgb.size(), size_t(3));
QVERIFY(std::abs(group.raw().curve.rgb[1].x - 0.02) < 1e-9 && group.raw().curve.rgb.front().x == 0 && group.raw().curve.rgb.back().x == 1);
QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear());
// A real double click: press, release, double click, release.
group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; });
doubleClick(graph, QPoint(w / 2, h / 2));
+1 -1
View File
@@ -83,7 +83,7 @@ void CameraRawGroupRowsTests::curveRows()
checkRow(*group.controls, group.session, row);
group.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; });
checkRow(*group.controls, group.session,
{"refineSaturation", "Refine Saturation", "How much the RGB curve also changes color strength. Zero keeps it to brightness.", -100, 100, 0, false,
{"refineSaturation", "Refine Saturation", "How much the curve also changes color strength. Zero matches Photoshop; lower keeps it to brightness, higher adds more color.", -100, 100, 0, false,
48, 88, true, false, curve(&CameraRawCurveSettings::refineSaturation)});
}
+9 -5
View File
@@ -88,7 +88,7 @@ void CameraRawGroupWordsTests::everyViewCarriesSwiftsWords()
curve.show();
holds(*curve.controls, {"Parametric", "Point", "RGB", "Red", "Green", "Blue", "Parametric lifts tonal regions. Point places anchors on the curve.",
"RGB changes brightness. Red, green, and blue also shift the color.",
"Drag a divider to change which tones the neighboring sliders affect.", "Targeted Adjustment", "Preset"});
"Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers.", "Targeted Adjustment", "Preset"});
Group<CameraRawMixerControls> mixer;
mixer.set([](CameraRawSettings &settings) { settings.mixer.points = {CameraRawPointColor{.hue = 30, .saturation = 1, .luminance = 0.5}}; });
mixer.show();
@@ -228,7 +228,7 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints()
Group<CameraRawCurveControls> curve;
curve.show();
auto &graph = curve.child<QWidget>(QStringLiteral("cameraRawCurveGraph"));
// Lights at 100 lift their region's middle by 0.22.
// The drawn curve is the model's parametric curve.
curve.set([](CameraRawSettings &settings) { settings.curve.lights = 100; });
QImage drawn = graph.grab().toImage();
const int w = drawn.width(), h = drawn.height();
@@ -237,7 +237,7 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints()
for (int y = 0; y < h; ++y)
if (qGray(drawn.pixel(int(0.625 * w), y)) > qGray(drawn.pixel(int(0.625 * w), brightest)))
brightest = y;
QVERIFY2(std::abs(brightest - (1 - 0.845) * h) < 3, qPrintable(QString::number(brightest)));
QVERIFY2(std::abs(brightest - (1 - curve.raw().curve.parametric(0.625)) * h) < 3, qPrintable(QString::number(brightest)));
// A write repaints the graph without being asked.
struct Paints : QObject {
int count = 0;
@@ -253,10 +253,14 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints()
paints.count = 0;
curve.set([](CameraRawSettings &settings) { settings.curve.darkSplit = 60; });
QTRY_VERIFY(paints.count > 0);
// The selected point is the last inner one.
// No point is chosen until one is pressed.
curve.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; });
curve.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.2, 0.3}, {0.6, 0.7}, {1, 1}}; });
QCOMPARE(curve.child<QLabel>(QStringLiteral("curveSelectedPoint")).text(), QString("In 153 Out 179"));
auto &readout = curve.child<QLabel>(QStringLiteral("curveSelectedPoint"));
QVERIFY(!readout.isVisible());
QTest::mouseClick(&graph, Qt::LeftButton, {}, QPoint(int(0.6 * graph.width()), int(0.3 * graph.height())));
QVERIFY(readout.isVisible());
QCOMPARE(readout.text(), QString("In 153 Out 179"));
}
void CameraRawGroupWordsTests::widgetsFollowTheModelAndGuardsHold()
+2 -2
View File
@@ -238,7 +238,7 @@ void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder()
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<float> luma = shape.lumaTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green),
const std::vector<float> tone = shape.toneTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green),
blue = shape.channelTable(shape.blue);
std::vector<float> mixer;
for (size_t index = 0; index < 8; ++index)
@@ -253,7 +253,7 @@ void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder()
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, luma.data(), red.data(), green.data(), blue.data(), 0.4, mixer.data(), 2, points.data(),
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));
+2
View File
@@ -168,6 +168,8 @@ compositor_test(ProjectWorkspaceFlowTests)
compositor_test(QuitWhileEditingTextTests)
compositor_test(QuitPendingEditsTests)
compositor_test(ScanlinesTests)
compositor_test(CameraRawCurveTests)
compositor_test(CameraRawCurveGraphTests)
compositor_test(OpenRecentTests)
compositor_test(ColoredSliderTests)
compositor_test(SheetTeardownTests)