14.2: RAW import rounds to 8 bits with fine noise, in the kernel; the develop sheet shows Importing… until the layer lands

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 02:38:04 -04:00
1 parent 5aad975e8b
commit c04d1fb6e4
14 files changed
+470 -65

No files matched your search

+130 -12
View File
@@ -37,15 +37,36 @@ QUrl dng(const QTemporaryDir &folder, const QString &name, const DNGFixture::Opt
return QUrl::fromLocalFile(DNGFixture::write(folder.filePath(name), options));
}
QColor centre(const ImageLayer &layer)
// A region's mean, rounded: the noise averages out.
QColor tone(const QImage &image, const QRect &region)
{
const QImage image = layer.asset.value().image();
return image.pixelColor(image.width() / 2, image.height() / 2);
std::array<double, 3> sums{};
for (int y = region.top(); y <= region.bottom(); ++y) {
for (int x = region.left(); x <= region.right(); ++x) {
const QColor colour = image.pixelColor(x, y);
sums[0] += colour.red();
sums[1] += colour.green();
sums[2] += colour.blue();
}
}
const double count = double(region.width()) * region.height();
return QColor(int(std::lround(sums[0] / count)), int(std::lround(sums[1] / count)), int(std::lround(sums[2] / count)));
}
QColor centre(const QImage &image)
{
return image.pixelColor(image.width() / 2, image.height() / 2);
return tone(image, image.rect());
}
QColor centre(const ImageLayer &layer)
{
return centre(layer.asset.value().image());
}
// The preview's picture, clear of the spinner and plate.
QColor shown(QWidget *preview)
{
return tone(preview->grab().toImage(), QRect(70, 20, 150, 100));
}
QString label(QWidget *sheet, const char *name)
@@ -62,6 +83,9 @@ private slots:
void whatCannotBeDevelopedIsReported();
void theLimitsLetTheirEdgeThrough();
void theSheetPreviewsAndAnswers();
void theSheetWaitsWhileTheFrameDevelops();
void aLongImportShowsTheImportingPlateAlone();
void finishingWithNoImportWaitingClosesTheSheet();
};
void RawDevelopTests::theSheetsSettingsDevelopTheLayer()
@@ -78,11 +102,20 @@ void RawDevelopTests::theSheetsSettingsDevelopTheLayer()
RawDevelopSettings settings = session.rawDevelop().value().settings;
settings.boost = 0;
settings.exposure = 1;
// The sheet hears at once that it has gone.
// The sheet hears at once that it is importing.
QSignalSpy changed(&session, &EditorSession::changed);
session.finishRawDevelop(settings);
QVERIFY(!session.rawDevelop() && changed.count() == 1);
QVERIFY(session.rawDevelop() && session.rawImporting() && changed.count() == 1);
// It goes once the layer is in.
std::vector<std::pair<bool, size_t>> seen;
connect(&session, &EditorSession::changed, this, [&] {
seen.emplace_back(session.rawImporting(), session.document() ? session.document().value().layers.size() : 0);
});
QTRY_VERIFY(done);
QVERIFY(!session.rawDevelop() && !session.rawImporting());
const auto ended = std::find_if(seen.begin(), seen.end(), [](const auto &each) { return !each.first; });
QVERIFY(ended != seen.end() && ended->second == 1);
QVERIFY(std::all_of(seen.begin(), ended, [](const auto &each) { return each.first; }));
QCOMPARE(names(session), (QStringList{"Gray", "Plain"}));
const ImageLayer &developed = session.document().value().layers.front();
QCOMPARE(developed.asset.value().size(), QSize(64, 48));
@@ -100,6 +133,7 @@ void RawDevelopTests::cancelSkipsTheFileAndTheRestImport()
session.importImages({dng(folder, "Gray.dng"), png(folder)}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(session.rawDevelop());
session.finishRawDevelop(std::nullopt);
QVERIFY(!session.rawDevelop() && !session.rawImporting());
QTRY_VERIFY(done);
QCOMPARE(names(session), QStringList{"Plain"});
QVERIFY(!session.importError() && !session.rawDevelop());
@@ -140,7 +174,10 @@ void RawDevelopTests::whatCannotBeDevelopedIsReported()
QVERIFY(broken.open(QIODevice::WriteOnly) && broken.write("gone") == 4);
broken.close();
session.finishRawDevelop(session.rawDevelop().value().settings);
QVERIFY(session.rawImporting());
QTRY_VERIFY(done);
// A develop that fails takes the sheet down too.
QVERIFY(!session.rawDevelop() && !session.rawImporting());
QCOMPARE(session.importError().value(), "Gray.dng: " + unreadable);
QCOMPARE(names(session), QStringList{"Plain"});
// A white the camera cannot record says so.
@@ -223,7 +260,7 @@ void RawDevelopTests::theSheetPreviewsAndAnswers()
// The first preview lands: the boosted gray, fitted.
QWidget *preview = sheet->findChild<QWidget *>("rawPreview");
QTRY_VERIFY(sheet->findChild<QProgressBar *>("rawSpinner")->isHidden());
QCOMPARE(preview->grab().toImage().pixelColor(200, 170), QColor(139, 139, 139));
QCOMPARE(shown(preview), QColor(139, 139, 139));
// A develop under way spins; a newer change drops it.
QSlider *exposure = sheet->findChild<QSlider *>("rawExposure");
QProgressBar *spinner = sheet->findChild<QProgressBar *>("rawSpinner");
@@ -238,12 +275,12 @@ void RawDevelopTests::theSheetPreviewsAndAnswers()
disconnect(once);
QThreadPool::globalInstance()->waitForDone();
QTest::qWait(50);
QCOMPARE(preview->grab().toImage().pixelColor(200, 170), QColor(139, 139, 139));
QCOMPARE(shown(preview), QColor(139, 139, 139));
QVERIFY(!spinner->isHidden());
RawDevelopSettings dimmer = session.rawDevelop().value().settings;
dimmer.exposure = -1;
wait->start();
QTRY_COMPARE_WITH_TIMEOUT(preview->grab().toImage().pixelColor(200, 170), centre(RawImporter::develop(path, dimmer)), 1000);
QTRY_COMPARE_WITH_TIMEOUT(shown(preview), centre(RawImporter::develop(path, dimmer)), 1000);
QVERIFY(spinner->isHidden());
// A slider changes the settings; the preview follows.
exposure->setValue(100);
@@ -252,7 +289,7 @@ void RawDevelopTests::theSheetPreviewsAndAnswers()
RawDevelopSettings brighter = session.rawDevelop().value().settings;
brighter.exposure = 1;
const QColor expected = centre(RawImporter::develop(path, brighter));
QTRY_COMPARE(preview->grab().toImage().pixelColor(200, 170), expected);
QTRY_COMPARE(shown(preview), expected);
sheet->findChild<QSlider *>("rawTemperature")->setValue(3000);
QCOMPARE(label(sheet, "rawTemperatureValue"), QString("3000 K"));
reset->click();
@@ -260,11 +297,11 @@ void RawDevelopTests::theSheetPreviewsAndAnswers()
QCOMPARE(label(sheet, "rawTemperatureValue"), QString("6501 K"));
QCOMPARE(sheet->findChild<QSlider *>("rawExposure")->value(), 0);
QVERIFY(!reset->isEnabled());
QTRY_COMPARE(preview->grab().toImage().pixelColor(200, 170), QColor(139, 139, 139));
QTRY_COMPARE(shown(preview), QColor(139, 139, 139));
// Import develops the whole frame with the sheet's settings.
sheet->findChild<QSlider *>("rawExposure")->setValue(100);
sheet->findChild<QPushButton *>("rawImport")->click();
QVERIFY(!session.rawDevelop());
QVERIFY(session.rawDevelop() && session.rawImporting());
QTRY_VERIFY(done);
QTRY_VERIFY(!view.findChild<QDialog *>("rawDevelopSheet"));
QCOMPARE(centre(session.document().value().layers.back()), expected);
@@ -292,5 +329,86 @@ void RawDevelopTests::theSheetPreviewsAndAnswers()
QCOMPARE(session.document().value().layers.size(), size_t(1));
}
void RawDevelopTests::theSheetWaitsWhileTheFrameDevelops()
{
const QTemporaryDir folder;
const QUrl raw = dng(folder, "Gray.dng");
EditorSession session;
ContentView view(session);
view.resize(900, 600);
view.show();
QVERIFY(QTest::qWaitForWindowExposed(&view));
bool done = false;
session.importImages({raw}, std::nullopt, [&done] { done = true; });
QDialog *sheet = view.findChild<QDialog *>("rawDevelopSheet");
QVERIFY(sheet);
QTRY_VERIFY(sheet->findChild<QProgressBar *>("rawSpinner")->isHidden());
QWidget *plate = sheet->findChild<QWidget *>("rawImporting");
QVERIFY(plate->isHidden());
// Import pressed: the develop waits for the event loop.
sheet->findChild<QPushButton *>("rawImport")->click();
QVERIFY(!plate->isHidden() && session.rawImporting());
QStringList words;
for (const QLabel *each : plate->findChildren<QLabel *>())
words << each->text();
QCOMPARE(words, QStringList{"Importing…"});
QVERIFY(sheet->findChild<QProgressBar *>("rawSpinner")->isHidden());
for (const char *name : {"rawExposure", "rawTemperature", "rawTint", "rawBoost"})
QVERIFY(!sheet->findChild<QSlider *>(name)->isEnabled());
for (const char *name : {"rawReset", "rawCancel", "rawImport"})
QVERIFY(!sheet->findChild<QPushButton *>(name)->isEnabled());
// Escape and the close button wait for the layer.
QTest::keyClick(sheet, Qt::Key_Escape);
QVERIFY(!sheet->close());
QVERIFY(sheet->isVisible() && session.rawDevelop() && session.rawImporting() && !done);
QTRY_VERIFY(done);
QTRY_VERIFY(!view.findChild<QDialog *>("rawDevelopSheet"));
QCOMPARE(session.document().value().layers.size(), size_t(1));
}
void RawDevelopTests::aLongImportShowsTheImportingPlateAlone()
{
// A frame large enough that each develop takes a while.
const QTemporaryDir folder;
DNGFixture::Options large;
large.width = 4000;
large.height = 3000;
const QUrl raw = dng(folder, "Large.dng", large);
EditorSession session;
ContentView view(session);
view.resize(900, 600);
view.show();
QVERIFY(QTest::qWaitForWindowExposed(&view));
bool done = false;
session.importImages({raw}, std::nullopt, [&done] { done = true; });
QDialog *sheet = view.findChild<QDialog *>("rawDevelopSheet");
QProgressBar *spinner = sheet->findChild<QProgressBar *>("rawSpinner");
QTimer *wait = sheet->findChild<QTimer *>("rawWait");
QTRY_VERIFY_WITH_TIMEOUT(spinner->isHidden(), 20'000);
// A preview under way: Import hides its spinner at once.
QSlider *exposure = sheet->findChild<QSlider *>("rawExposure");
exposure->setValue(50);
QPushButton *reset = sheet->findChild<QPushButton *>("rawReset");
QVERIFY(reset->isEnabled());
QTRY_VERIFY(!spinner->isHidden());
// Another change waits on its timer as Import is pressed.
exposure->setValue(60);
QVERIFY(wait->isActive());
sheet->findChild<QPushButton *>("rawImport")->click();
QVERIFY(spinner->isHidden() && !reset->isEnabled());
// That develop starts during the import and shows no spinner.
QTRY_VERIFY(!wait->isActive());
QVERIFY(session.rawImporting() && spinner->isHidden());
QTRY_VERIFY_WITH_TIMEOUT(done, 60'000);
}
void RawDevelopTests::finishingWithNoImportWaitingClosesTheSheet()
{
// An answer with nobody waiting cannot leave the sheet importing.
EditorSession session;
session.finishRawDevelop(RawDevelopSettings());
QVERIFY(!session.rawImporting() && !session.rawDevelop());
}
QTEST_MAIN(RawDevelopTests)
#include "RawDevelopTests.moc"
+131
View File
@@ -0,0 +1,131 @@
#include "DNGFixture.h"
#include "IO/RawImporter.h"
#include <QTemporaryDir>
#include <QtTest>
#include <set>
extern "C" {
#include "DitherPixels.h"
}
// Swift's RawDitherTests: 16 bits rounded to 8 with fine noise.
namespace {
// Premultiplied RGBA at 16 bits, one value a row.
std::vector<quint16> wide(int width, int height, const std::function<double(int row)> &tone)
{
std::vector<quint16> values(size_t(width) * size_t(height) * 4);
for (int y = 0; y < height; ++y) {
const quint16 value = quint16(std::lround(tone(y) * 65535));
for (int x = 0; x < width; ++x) {
quint16 *pixel = values.data() + (size_t(y) * size_t(width) + size_t(x)) * 4;
pixel[0] = pixel[1] = pixel[2] = value;
pixel[3] = 65535;
}
}
return values;
}
QImage quantized(const std::vector<quint16> &values, int width, int height)
{
QImage image(width, height, QImage::Format_RGBA8888_Premultiplied);
dither_quantize16(values.data(), image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()));
return image;
}
}
class RawDitherTests : public QObject {
Q_OBJECT
private slots:
void ditheredGradientFollowsTheTonesNotTheSteps();
void ditherIsFineAndRepeatable();
void theNoiseNeverPassesAlpha();
void theDevelopIsDithered();
};
void RawDitherTests::ditheredGradientFollowsTheTonesNotTheSteps()
{
// From 0.30 at the top to 0.32: five steps.
const auto tone = [](int row) { return 0.30 + 0.02 * (double(row) + 0.5) / 400; };
const QImage image = quantized(wide(300, 400, tone), 300, 400);
double worst = 0, plain = 0;
std::vector<double> means;
std::set<int> steps;
for (int y = 0; y < 400; ++y) {
const uchar *row = image.constScanLine(y);
double sum = 0;
for (int x = 0; x < 300; ++x)
sum += row[x * 4];
means.push_back(sum / 300);
const double ideal = tone(y) * 255;
worst = std::max(worst, std::abs(means.back() - ideal));
plain = std::max(plain, std::abs(std::round(ideal) - ideal));
steps.insert(int(std::lround(ideal)));
}
QVERIFY2(worst < 0.25 && plain > 0.45, qPrintable(QStringLiteral("%1 %2").arg(worst).arg(plain)));
QVERIFY(means.front() < means.back());
QVERIFY(steps.size() <= 6);
}
void RawDitherTests::ditherIsFineAndRepeatable()
{
const std::vector<quint16> flat = wide(64, 64, [](int) { return 0.5; });
const QImage first = quantized(flat, 64, 64), second = quantized(flat, 64, 64);
QCOMPARE(first, second);
std::set<int> seen;
double sum = 0;
for (int y = 0; y < 64; ++y) {
for (int x = 0; x < 64; ++x) {
const uchar *pixel = first.constScanLine(y) + x * 4;
seen.insert(pixel[0]);
sum += pixel[0];
QCOMPARE(int(pixel[3]), 255);
}
}
QVERIFY(*seen.begin() >= 126 && *seen.rbegin() <= 129 && seen.size() > 1);
QVERIFY2(std::abs(sum / 4096 - 127.5) < 0.1, qPrintable(QString::number(sum / 4096)));
// Two draws: three in four stay on a level.
const QImage level = quantized(wide(64, 64, [](int) { return 128.0 / 255; }), 64, 64);
int kept = 0;
for (int y = 0; y < 64; ++y) {
for (int x = 0; x < 64; ++x)
kept += level.constScanLine(y)[x * 4] == 128;
}
QVERIFY2(std::abs(kept / 4096.0 - 0.75) < 0.03, qPrintable(QString::number(kept / 4096.0)));
}
void RawDitherTests::theNoiseNeverPassesAlpha()
{
// Premultiplied colour at its alpha: noise up is clamped.
std::vector<quint16> values(16 * 16 * 4);
for (size_t index = 0; index < values.size(); index += 4) {
values[index] = values[index + 1] = values[index + 2] = 32896;
values[index + 3] = 32896;
}
const QImage image = quantized(values, 16, 16);
for (int y = 0; y < 16; ++y) {
for (int x = 0; x < 16; ++x) {
const uchar *pixel = image.constScanLine(y) + x * 4;
QCOMPARE(int(pixel[3]), 128);
QVERIFY(pixel[0] <= 128 && pixel[1] <= 128 && pixel[2] <= 128);
}
}
}
void RawDitherTests::theDevelopIsDithered()
{
const QTemporaryDir folder;
const QString path = DNGFixture::write(folder.filePath("gray.dng"));
RawDevelopSettings settings = RawImporter::asShot(path).value();
settings.boost = 0;
const QImage developed = RawImporter::develop(path, settings);
QCOMPARE(developed, RawImporter::develop(path, settings));
std::set<int> reds;
for (int y = 0; y < developed.height(); ++y) {
for (int x = 0; x < developed.width(); ++x)
reds.insert(developed.pixelColor(x, y).red());
}
// 20000 of 65535 through sRGB's curve is 149.94.
QCOMPARE(reds, (std::set<int>{149, 150, 151}));
}
QTEST_GUILESS_MAIN(RawDitherTests)
#include "RawDitherTests.moc"
+14 -3
View File
@@ -12,10 +12,20 @@ QColor middle(const QImage &image)
return image.pixelColor(image.width() / 2, image.height() / 2);
}
// Each channel's mean, rounded: the import's noise averages out.
std::array<int, 3> rgb(const QImage &image)
{
const QColor colour = middle(image);
return {colour.red(), colour.green(), colour.blue()};
std::array<double, 3> sums{};
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
const QColor colour = image.pixelColor(x, y);
sums[0] += colour.red();
sums[1] += colour.green();
sums[2] += colour.blue();
}
}
const double count = double(image.width()) * image.height();
return {int(std::lround(sums[0] / count)), int(std::lround(sums[1] / count)), int(std::lround(sums[2] / count))};
}
// The camera's neutral under a white, through the matrix.
@@ -231,7 +241,8 @@ void RawImporterTests::theQueueKeepsThePreviewsDecodeUntilReleased()
QCOMPARE(queue.develop(path, settings, 40).size(), QSize(32, 24));
const QString other = DNGFixture::write(folder.filePath("other.dng"));
QCOMPARE(rgb(queue.develop(other, settings, 20))[1], 150);
QCOMPARE(rgb(queue.develop(path, settings, 20))[1], 109);
// Qt's area average truncates the fine noise: a level low.
QCOMPARE(rgb(queue.develop(path, settings, 20))[1], 108);
queue.release();
}
+3
View File
@@ -294,6 +294,9 @@ compositor_test(CanvasEditContractTests)
target_include_directories(AdjustmentBandsTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(RawImporterTests)
compositor_test(RawDevelopTests)
compositor_test(RawDitherTests)
# It drives the kernel's rounding directly.
target_include_directories(RawDitherTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(ToolDefaultsTests)
compositor_test(ColorAdjustmentTests)
compositor_test(CameraRawTests)