mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Fix P3-to-sRGB color conversion to operate in linear light (#181720)
<!-- Thanks for filing a pull request! Reviewers are typically assigned within a week of filing a request. To learn more about code review, see our documentation on Tree Hygiene: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md --> ### Problem The P3-to-sRGB color conversion applies an affine matrix directly to gamma-encoded values in both the C++ engine (`dl_color.cc`) and Dart (`painting.dart`). This is mathematically incorrect — color space conversion matrices must operate in linear light. The result is P3 colors that appear nearly identical to sRGB instead of showing the full gamut difference. For example, P3 `#1ECAD3` (30/255, 202/255, 211/255) converts to: - **Old (wrong):** extendedSRGB (-0.12, 0.86, 0.87) - **New (correct):** extendedSRGB (-0.38, 0.81, 0.84) The red channel error of 0.26 is clearly visible — colors appear washed out instead of vivid. ### Fix Replace the single affine matrix multiply with the correct 3-step pipeline: 1. **Linearize** — decode gamma via sRGB EOTF 2. **Transform** — apply 3x3 P3-to-sRGB matrix in linear space 3. **Encode** — re-apply gamma via sRGB OETF Extended range (negative values from out-of-gamut colors) is handled by mirroring the transfer function. **C++ (`dl_color.cc`):** Replace affine matrix with `p3ToExtendedSrgb()` using `double` precision. **Dart (`painting.dart`):** Replace `_MatrixColorTransform` with `_P3ToSrgbTransform` and `_SrgbToP3Transform` classes. Also fixes the sRGB-to-P3 direction. ### Performance Negligible. The C++ conversion runs once per paint setup in `ReadColor`, not per frame or per pixel. The Dart conversion runs once per `Color.withValues()` call. ### Tests - **C++:** Added `ColorSpaceP3ToExtendedSRGBLinearLight` test in `dl_color_unittests.cc` - **Dart:** Added 3 tests in `colors_test.dart`: mid-range P3→extendedSRGB, P3 green→extendedSRGB, sRGB→P3 round-trip All new tests fail with the old code and pass with the fix. Existing tests continue to pass. ## Issue: https://github.com/flutter/flutter/issues/181717 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. **New tests pass, can't run golden tests!** --------- Co-authored-by: gaaclarke <30870216+gaaclarke@users.noreply.github.com>
This commit is contained in:
parent
3ce3f1345d
commit
f8aaee5694
@ -5,36 +5,87 @@
|
||||
#include "flutter/display_list/dl_color.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace flutter {
|
||||
|
||||
namespace {
|
||||
const std::array<DlScalar, 12> kP3ToSrgb = {
|
||||
1.306671048092539, -0.298061942172353,
|
||||
0.213228303487995, -0.213580156254466, //
|
||||
-0.117390025596251, 1.127722006101976,
|
||||
0.109727644608938, -0.109450321455370, //
|
||||
0.214813187718391, 0.054268702864647,
|
||||
1.406898424029350, -0.364892765879631};
|
||||
|
||||
DlColor transform(const DlColor& color,
|
||||
const std::array<DlScalar, 12>& matrix,
|
||||
DlColorSpace color_space) {
|
||||
return DlColor(color.getAlphaF(),
|
||||
matrix[0] * color.getRedF() + //
|
||||
matrix[1] * color.getGreenF() + //
|
||||
matrix[2] * color.getBlueF() + //
|
||||
matrix[3], //
|
||||
matrix[4] * color.getRedF() + //
|
||||
matrix[5] * color.getGreenF() + //
|
||||
matrix[6] * color.getBlueF() + //
|
||||
matrix[7], //
|
||||
matrix[8] * color.getRedF() + //
|
||||
matrix[9] * color.getGreenF() + //
|
||||
matrix[10] * color.getBlueF() + //
|
||||
matrix[11], //
|
||||
color_space);
|
||||
/// sRGB standard constants for transfer functions.
|
||||
/// See https://en.wikipedia.org/wiki/SRGB.
|
||||
constexpr double kSrgbGamma = 2.4;
|
||||
constexpr double kSrgbLinearThreshold = 0.04045;
|
||||
constexpr double kSrgbLinearSlope = 12.92;
|
||||
constexpr double kSrgbEncodedOffset = 0.055;
|
||||
constexpr double kSrgbEncodedDivisor = 1.055;
|
||||
constexpr double kSrgbLinearToEncodedThreshold = 0.0031308;
|
||||
|
||||
/// sRGB electro-optical transfer function (gamma decode, gamma ~2.2 to linear).
|
||||
double srgbEOTF(double v) {
|
||||
if (v <= kSrgbLinearThreshold) {
|
||||
return v / kSrgbLinearSlope;
|
||||
}
|
||||
return std::pow((v + kSrgbEncodedOffset) / kSrgbEncodedDivisor, kSrgbGamma);
|
||||
}
|
||||
|
||||
/// sRGB opto-electronic transfer function (linear to gamma encode).
|
||||
double srgbOETF(double v) {
|
||||
if (v <= kSrgbLinearToEncodedThreshold) {
|
||||
return v * kSrgbLinearSlope;
|
||||
}
|
||||
return kSrgbEncodedDivisor * std::pow(v, 1.0 / kSrgbGamma) -
|
||||
kSrgbEncodedOffset;
|
||||
}
|
||||
|
||||
/// sRGB EOTF extended to handle negative values (for extended sRGB).
|
||||
double srgbEOTFExtended(double v) {
|
||||
return v < 0.0 ? -srgbEOTF(-v) : srgbEOTF(v);
|
||||
}
|
||||
|
||||
/// sRGB OETF extended to handle negative values (for extended sRGB).
|
||||
double srgbOETFExtended(double v) {
|
||||
return v < 0.0 ? -srgbOETF(-v) : srgbOETF(v);
|
||||
}
|
||||
|
||||
/// Display P3 to sRGB linear 3x3 matrix.
|
||||
/// Both P3 and sRGB use the same D65 white point.
|
||||
/// P3 has wider primaries than sRGB, so converting P3 colors to sRGB
|
||||
/// can produce values outside [0,1] (extended sRGB).
|
||||
///
|
||||
/// Matrix derived from:
|
||||
/// M = sRGB_XYZ_to_RGB * P3_RGB_to_XYZ
|
||||
static constexpr double kP3ToSrgbLinear[9] = {
|
||||
1.2249401, -0.2249402, 0.0, -0.0420569, 1.0420571,
|
||||
0.0, -0.0196376, -0.0786507, 1.0982884,
|
||||
};
|
||||
|
||||
/// Converts a Display P3 color (gamma-encoded) to extended sRGB
|
||||
/// (gamma-encoded). Steps: P3 gamma decode -> linear P3 -> linear sRGB (via 3x3
|
||||
/// matrix) -> sRGB gamma encode.
|
||||
DlColor p3ToExtendedSrgb(const DlColor& color) {
|
||||
// Linearize P3 values (P3 uses same transfer function as sRGB).
|
||||
double r_lin = srgbEOTFExtended(static_cast<double>(color.getRedF()));
|
||||
double g_lin = srgbEOTFExtended(static_cast<double>(color.getGreenF()));
|
||||
double b_lin = srgbEOTFExtended(static_cast<double>(color.getBlueF()));
|
||||
|
||||
// Apply 3x3 P3-to-sRGB matrix in linear space.
|
||||
double r_srgb_lin = kP3ToSrgbLinear[0] * r_lin + kP3ToSrgbLinear[1] * g_lin +
|
||||
kP3ToSrgbLinear[2] * b_lin;
|
||||
double g_srgb_lin = kP3ToSrgbLinear[3] * r_lin + kP3ToSrgbLinear[4] * g_lin +
|
||||
kP3ToSrgbLinear[5] * b_lin;
|
||||
double b_srgb_lin = kP3ToSrgbLinear[6] * r_lin + kP3ToSrgbLinear[7] * g_lin +
|
||||
kP3ToSrgbLinear[8] * b_lin;
|
||||
|
||||
// Gamma encode back to sRGB.
|
||||
double r_out = srgbOETFExtended(r_srgb_lin);
|
||||
double g_out = srgbOETFExtended(g_srgb_lin);
|
||||
double b_out = srgbOETFExtended(b_srgb_lin);
|
||||
|
||||
return DlColor(color.getAlphaF(), static_cast<float>(r_out),
|
||||
static_cast<float>(g_out), static_cast<float>(b_out),
|
||||
DlColorSpace::kExtendedSRGB);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DlColor DlColor::withColorSpace(DlColorSpace color_space) const {
|
||||
@ -65,10 +116,9 @@ DlColor DlColor::withColorSpace(DlColorSpace color_space) const {
|
||||
case DlColorSpace::kDisplayP3:
|
||||
switch (color_space) {
|
||||
case DlColorSpace::kSRGB:
|
||||
return transform(*this, kP3ToSrgb, DlColorSpace::kExtendedSRGB)
|
||||
.withColorSpace(DlColorSpace::kSRGB);
|
||||
return p3ToExtendedSrgb(*this).withColorSpace(DlColorSpace::kSRGB);
|
||||
case DlColorSpace::kExtendedSRGB:
|
||||
return transform(*this, kP3ToSrgb, DlColorSpace::kExtendedSRGB);
|
||||
return p3ToExtendedSrgb(*this);
|
||||
case DlColorSpace::kDisplayP3:
|
||||
return *this;
|
||||
}
|
||||
|
||||
@ -299,6 +299,35 @@ TEST(DisplayListColor, ColorSpaceP3ToExtendedSRGB) {
|
||||
<< blue.withColorSpace(DlColorSpace::kExtendedSRGB);
|
||||
}
|
||||
|
||||
// Verifies that P3-to-sRGB conversion operates in linear light.
|
||||
// Mid-range values (not 0 or 1) expose the gamma nonlinearity:
|
||||
// the correct pipeline is EOTF(decode) -> 3x3 matrix -> OETF(encode),
|
||||
// which produces significantly different results than applying a matrix
|
||||
// directly to gamma-encoded values.
|
||||
TEST(DisplayListColor, ColorSpaceP3ToExtendedSRGBLinearLight) {
|
||||
// P3 #1ECAD3 (30/255, 202/255, 211/255)
|
||||
// Correct extended sRGB: (-0.3764, 0.8065, 0.8372)
|
||||
// Wrong (gamma-encoded matrix): (-0.1195, 0.8609, 0.8675)
|
||||
// Red channel diff: 0.2569 — well above isClose tolerance of 1/256.
|
||||
DlColor p3_teal(1.0, 30.0f / 255.0f, 202.0f / 255.0f, 211.0f / 255.0f,
|
||||
DlColorSpace::kDisplayP3);
|
||||
DlColor expected_teal(1.0, -0.3764f, 0.8065f, 0.8372f,
|
||||
DlColorSpace::kExtendedSRGB);
|
||||
EXPECT_TRUE(expected_teal.isClose(
|
||||
p3_teal.withColorSpace(DlColorSpace::kExtendedSRGB)))
|
||||
<< p3_teal.withColorSpace(DlColorSpace::kExtendedSRGB);
|
||||
|
||||
// P3 (0, 1, 1) — max teal
|
||||
// Correct extended sRGB: (-0.5116, 1.0183, 1.0086)
|
||||
// Wrong (gamma-encoded matrix): (-0.2984, 1.1280, 1.0963)
|
||||
DlColor p3_max_teal(1.0, 0.0, 1.0, 1.0, DlColorSpace::kDisplayP3);
|
||||
DlColor expected_max_teal(1.0, -0.5116f, 1.0183f, 1.0086f,
|
||||
DlColorSpace::kExtendedSRGB);
|
||||
EXPECT_TRUE(expected_max_teal.isClose(
|
||||
p3_max_teal.withColorSpace(DlColorSpace::kExtendedSRGB)))
|
||||
<< p3_max_teal.withColorSpace(DlColorSpace::kExtendedSRGB);
|
||||
}
|
||||
|
||||
TEST(DisplayListColor, ColorSpaceP3ToSRGB) {
|
||||
DlColor red(0.9, 1.0, 0.0, 0.0, DlColorSpace::kDisplayP3);
|
||||
EXPECT_TRUE(DlColor(0.9, 1.0, 0.0, 0.0, DlColorSpace::kSRGB)
|
||||
|
||||
@ -3904,58 +3904,124 @@ class _ClampTransform implements _ColorTransform {
|
||||
}
|
||||
}
|
||||
|
||||
class _MatrixColorTransform implements _ColorTransform {
|
||||
/// Row-major.
|
||||
const _MatrixColorTransform(this.values);
|
||||
// sRGB standard constants for transfer functions.
|
||||
// See https://en.wikipedia.org/wiki/SRGB.
|
||||
const double _kSrgbGamma = 2.4;
|
||||
const double _kSrgbLinearThreshold = 0.04045;
|
||||
const double _kSrgbLinearSlope = 12.92;
|
||||
const double _kSrgbEncodedOffset = 0.055;
|
||||
const double _kSrgbEncodedDivisor = 1.055;
|
||||
const double _kSrgbLinearToEncodedThreshold = 0.0031308;
|
||||
|
||||
final List<double> values;
|
||||
/// sRGB electro-optical transfer function (gamma decode to linear).
|
||||
double _srgbEOTF(double v) {
|
||||
if (v <= _kSrgbLinearThreshold) {
|
||||
return v / _kSrgbLinearSlope;
|
||||
}
|
||||
return math.pow((v + _kSrgbEncodedOffset) / _kSrgbEncodedDivisor, _kSrgbGamma).toDouble();
|
||||
}
|
||||
|
||||
/// sRGB opto-electronic transfer function (linear to gamma encode).
|
||||
double _srgbOETF(double v) {
|
||||
if (v <= _kSrgbLinearToEncodedThreshold) {
|
||||
return v * _kSrgbLinearSlope;
|
||||
}
|
||||
return _kSrgbEncodedDivisor * math.pow(v, 1.0 / _kSrgbGamma).toDouble() - _kSrgbEncodedOffset;
|
||||
}
|
||||
|
||||
/// Extended versions that handle negative values by mirroring.
|
||||
double _srgbEOTFExtended(double v) {
|
||||
return v < 0.0 ? -_srgbEOTF(-v) : _srgbEOTF(v);
|
||||
}
|
||||
|
||||
double _srgbOETFExtended(double v) {
|
||||
return v < 0.0 ? -_srgbOETF(-v) : _srgbOETF(v);
|
||||
}
|
||||
|
||||
/// Display P3 to sRGB 3x3 matrix in linear space.
|
||||
/// M = sRGB_XYZ_to_RGB * P3_RGB_to_XYZ
|
||||
const List<double> _kP3ToSrgbLinear = <double>[
|
||||
1.2249401,
|
||||
-0.2249402,
|
||||
0.0,
|
||||
-0.0420569,
|
||||
1.0420571,
|
||||
0.0,
|
||||
-0.0196376,
|
||||
-0.0786507,
|
||||
1.0982884,
|
||||
];
|
||||
|
||||
/// sRGB to Display P3 3x3 matrix in linear space (inverse of [_kP3ToSrgbLinear]).
|
||||
const List<double> _kSrgbToP3Linear = <double>[
|
||||
0.8224622,
|
||||
0.1775380,
|
||||
0.0,
|
||||
0.0331942,
|
||||
0.9668058,
|
||||
0.0,
|
||||
0.0170806,
|
||||
0.0723974,
|
||||
0.9105220,
|
||||
];
|
||||
|
||||
/// Converts Display P3 (gamma-encoded) to extended sRGB (gamma-encoded).
|
||||
/// Pipeline: EOTF(decode) -> 3x3 matrix -> OETF(encode).
|
||||
class _P3ToSrgbTransform implements _ColorTransform {
|
||||
const _P3ToSrgbTransform();
|
||||
|
||||
@override
|
||||
Color transform(Color color, ColorSpace resultColorSpace) {
|
||||
final double rLin = _srgbEOTFExtended(color.r);
|
||||
final double gLin = _srgbEOTFExtended(color.g);
|
||||
final double bLin = _srgbEOTFExtended(color.b);
|
||||
|
||||
final double rOut =
|
||||
_kP3ToSrgbLinear[0] * rLin + _kP3ToSrgbLinear[1] * gLin + _kP3ToSrgbLinear[2] * bLin;
|
||||
final double gOut =
|
||||
_kP3ToSrgbLinear[3] * rLin + _kP3ToSrgbLinear[4] * gLin + _kP3ToSrgbLinear[5] * bLin;
|
||||
final double bOut =
|
||||
_kP3ToSrgbLinear[6] * rLin + _kP3ToSrgbLinear[7] * gLin + _kP3ToSrgbLinear[8] * bLin;
|
||||
|
||||
return Color.from(
|
||||
alpha: color.a,
|
||||
red: values[0] * color.r + values[1] * color.g + values[2] * color.b + values[3],
|
||||
green: values[4] * color.r + values[5] * color.g + values[6] * color.b + values[7],
|
||||
blue: values[8] * color.r + values[9] * color.g + values[10] * color.b + values[11],
|
||||
red: _srgbOETFExtended(rOut),
|
||||
green: _srgbOETFExtended(gOut),
|
||||
blue: _srgbOETFExtended(bOut),
|
||||
colorSpace: resultColorSpace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts sRGB (gamma-encoded) to Display P3 (gamma-encoded).
|
||||
/// Pipeline: EOTF(decode) -> 3x3 matrix -> OETF(encode).
|
||||
class _SrgbToP3Transform implements _ColorTransform {
|
||||
const _SrgbToP3Transform();
|
||||
|
||||
@override
|
||||
Color transform(Color color, ColorSpace resultColorSpace) {
|
||||
final double rLin = _srgbEOTFExtended(color.r);
|
||||
final double gLin = _srgbEOTFExtended(color.g);
|
||||
final double bLin = _srgbEOTFExtended(color.b);
|
||||
|
||||
final double rOut =
|
||||
_kSrgbToP3Linear[0] * rLin + _kSrgbToP3Linear[1] * gLin + _kSrgbToP3Linear[2] * bLin;
|
||||
final double gOut =
|
||||
_kSrgbToP3Linear[3] * rLin + _kSrgbToP3Linear[4] * gLin + _kSrgbToP3Linear[5] * bLin;
|
||||
final double bOut =
|
||||
_kSrgbToP3Linear[6] * rLin + _kSrgbToP3Linear[7] * gLin + _kSrgbToP3Linear[8] * bLin;
|
||||
|
||||
return Color.from(
|
||||
alpha: color.a,
|
||||
red: _srgbOETFExtended(rOut),
|
||||
green: _srgbOETFExtended(gOut),
|
||||
blue: _srgbOETFExtended(bOut),
|
||||
colorSpace: resultColorSpace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_ColorTransform _getColorTransform(ColorSpace source, ColorSpace destination) {
|
||||
// The transforms were calculated with the following octave script from known
|
||||
// conversions. These transforms have a white point that matches Apple's.
|
||||
//
|
||||
// p3Colors = [
|
||||
// 1, 0, 0, 0.25;
|
||||
// 0, 1, 0, 0.5;
|
||||
// 0, 0, 1, 0.75;
|
||||
// 1, 1, 1, 1;
|
||||
// ];
|
||||
// srgbColors = [
|
||||
// 1.0930908918380737, -0.5116420984268188, -0.0003518527664709836, 0.12397786229848862;
|
||||
// -0.22684034705162048, 1.0182716846466064, 0.00027732315356843174, 0.5073589086532593;
|
||||
// -0.15007957816123962, -0.31062406301498413, 1.0420056581497192, 0.771118700504303;
|
||||
// 1, 1, 1, 1;
|
||||
// ];
|
||||
//
|
||||
// format long
|
||||
// p3ToSrgb = srgbColors * inv(p3Colors)
|
||||
// srgbToP3 = inv(p3ToSrgb)
|
||||
const srgbToP3 = _MatrixColorTransform(<double>[
|
||||
0.808052267214446, 0.220292047628890, -0.139648846160100,
|
||||
0.145738111193222, //
|
||||
0.096480880462996, 0.916386732581291, -0.086093928394828,
|
||||
0.089490172325882, //
|
||||
-0.127099563510240, -0.068983484963878, 0.735426667591299, 0.233655661600230,
|
||||
]);
|
||||
const _ColorTransform p3ToSrgb = _MatrixColorTransform(<double>[
|
||||
1.306671048092539, -0.298061942172353, 0.213228303487995,
|
||||
-0.213580156254466, //
|
||||
-0.117390025596251, 1.127722006101976, 0.109727644608938,
|
||||
-0.109450321455370, //
|
||||
0.214813187718391, 0.054268702864647, 1.406898424029350, -0.364892765879631,
|
||||
]);
|
||||
switch (source) {
|
||||
case ColorSpace.sRGB:
|
||||
switch (destination) {
|
||||
@ -3964,7 +4030,7 @@ _ColorTransform _getColorTransform(ColorSpace source, ColorSpace destination) {
|
||||
case ColorSpace.extendedSRGB:
|
||||
return const _IdentityColorTransform();
|
||||
case ColorSpace.displayP3:
|
||||
return srgbToP3;
|
||||
return const _SrgbToP3Transform();
|
||||
}
|
||||
case ColorSpace.extendedSRGB:
|
||||
switch (destination) {
|
||||
@ -3973,14 +4039,14 @@ _ColorTransform _getColorTransform(ColorSpace source, ColorSpace destination) {
|
||||
case ColorSpace.extendedSRGB:
|
||||
return const _IdentityColorTransform();
|
||||
case ColorSpace.displayP3:
|
||||
return const _ClampTransform(srgbToP3);
|
||||
return const _ClampTransform(_SrgbToP3Transform());
|
||||
}
|
||||
case ColorSpace.displayP3:
|
||||
switch (destination) {
|
||||
case ColorSpace.sRGB:
|
||||
return const _ClampTransform(p3ToSrgb);
|
||||
return const _ClampTransform(_P3ToSrgbTransform());
|
||||
case ColorSpace.extendedSRGB:
|
||||
return p3ToSrgb;
|
||||
return const _P3ToSrgbTransform();
|
||||
case ColorSpace.displayP3:
|
||||
return const _IdentityColorTransform();
|
||||
}
|
||||
|
||||
@ -503,52 +503,138 @@ class _ClampTransform implements _ColorTransform {
|
||||
}
|
||||
}
|
||||
|
||||
class _MatrixColorTransform implements _ColorTransform {
|
||||
const _MatrixColorTransform(this.values);
|
||||
// sRGB standard constants for transfer functions.
|
||||
// See https://en.wikipedia.org/wiki/SRGB.
|
||||
const double _kSrgbGamma = 2.4;
|
||||
const double _kSrgbLinearThreshold = 0.04045;
|
||||
const double _kSrgbLinearSlope = 12.92;
|
||||
const double _kSrgbEncodedOffset = 0.055;
|
||||
const double _kSrgbEncodedDivisor = 1.055;
|
||||
const double _kSrgbLinearToEncodedThreshold = 0.0031308;
|
||||
|
||||
final List<double> values;
|
||||
/// sRGB electro-optical transfer function (gamma decode to linear).
|
||||
double _srgbEOTF(double v) {
|
||||
if (v <= _kSrgbLinearThreshold) {
|
||||
return v / _kSrgbLinearSlope;
|
||||
}
|
||||
return math.pow((v + _kSrgbEncodedOffset) / _kSrgbEncodedDivisor, _kSrgbGamma).toDouble();
|
||||
}
|
||||
|
||||
/// sRGB opto-electronic transfer function (linear to gamma encode).
|
||||
double _srgbOETF(double v) {
|
||||
if (v <= _kSrgbLinearToEncodedThreshold) {
|
||||
return v * _kSrgbLinearSlope;
|
||||
}
|
||||
return _kSrgbEncodedDivisor * math.pow(v, 1.0 / _kSrgbGamma).toDouble() - _kSrgbEncodedOffset;
|
||||
}
|
||||
|
||||
/// Extended versions that handle negative values by mirroring.
|
||||
double _srgbEOTFExtended(double v) {
|
||||
return v < 0.0 ? -_srgbEOTF(-v) : _srgbEOTF(v);
|
||||
}
|
||||
|
||||
double _srgbOETFExtended(double v) {
|
||||
return v < 0.0 ? -_srgbOETF(-v) : _srgbOETF(v);
|
||||
}
|
||||
|
||||
/// Display P3 to sRGB 3x3 matrix in linear space.
|
||||
/// M = sRGB_XYZ_to_RGB * P3_RGB_to_XYZ
|
||||
const List<double> _kP3ToSrgbLinear = <double>[
|
||||
1.2249401,
|
||||
-0.2249402,
|
||||
0.0,
|
||||
-0.0420569,
|
||||
1.0420571,
|
||||
0.0,
|
||||
-0.0196376,
|
||||
-0.0786507,
|
||||
1.0982884,
|
||||
];
|
||||
|
||||
/// sRGB to Display P3 3x3 matrix in linear space (inverse of [_kP3ToSrgbLinear]).
|
||||
const List<double> _kSrgbToP3Linear = <double>[
|
||||
0.8224622,
|
||||
0.1775380,
|
||||
0.0,
|
||||
0.0331942,
|
||||
0.9668058,
|
||||
0.0,
|
||||
0.0170806,
|
||||
0.0723974,
|
||||
0.9105220,
|
||||
];
|
||||
|
||||
/// Converts Display P3 (gamma-encoded) to extended sRGB (gamma-encoded).
|
||||
/// Pipeline: EOTF(decode) -> 3x3 matrix -> OETF(encode).
|
||||
class _P3ToSrgbTransform implements _ColorTransform {
|
||||
const _P3ToSrgbTransform();
|
||||
|
||||
@override
|
||||
Color transform(Color color, ColorSpace resultColorSpace) {
|
||||
final double rLin = _srgbEOTFExtended(color.r);
|
||||
final double gLin = _srgbEOTFExtended(color.g);
|
||||
final double bLin = _srgbEOTFExtended(color.b);
|
||||
|
||||
final double rOut =
|
||||
_kP3ToSrgbLinear[0] * rLin + _kP3ToSrgbLinear[1] * gLin + _kP3ToSrgbLinear[2] * bLin;
|
||||
final double gOut =
|
||||
_kP3ToSrgbLinear[3] * rLin + _kP3ToSrgbLinear[4] * gLin + _kP3ToSrgbLinear[5] * bLin;
|
||||
final double bOut =
|
||||
_kP3ToSrgbLinear[6] * rLin + _kP3ToSrgbLinear[7] * gLin + _kP3ToSrgbLinear[8] * bLin;
|
||||
|
||||
return Color.from(
|
||||
alpha: color.a,
|
||||
red: values[0] * color.r + values[1] * color.g + values[2] * color.b + values[3],
|
||||
green: values[4] * color.r + values[5] * color.g + values[6] * color.b + values[7],
|
||||
blue: values[8] * color.r + values[9] * color.g + values[10] * color.b + values[11],
|
||||
red: _srgbOETFExtended(rOut),
|
||||
green: _srgbOETFExtended(gOut),
|
||||
blue: _srgbOETFExtended(bOut),
|
||||
colorSpace: resultColorSpace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Converts sRGB (gamma-encoded) to Display P3 (gamma-encoded).
|
||||
// Pipeline: EOTF(decode) -> 3x3 matrix -> OETF(encode).
|
||||
class _SrgbToP3Transform implements _ColorTransform {
|
||||
const _SrgbToP3Transform();
|
||||
|
||||
@override
|
||||
Color transform(Color color, ColorSpace resultColorSpace) {
|
||||
final double rLin = _srgbEOTFExtended(color.r);
|
||||
final double gLin = _srgbEOTFExtended(color.g);
|
||||
final double bLin = _srgbEOTFExtended(color.b);
|
||||
|
||||
final double rOut =
|
||||
_kSrgbToP3Linear[0] * rLin + _kSrgbToP3Linear[1] * gLin + _kSrgbToP3Linear[2] * bLin;
|
||||
final double gOut =
|
||||
_kSrgbToP3Linear[3] * rLin + _kSrgbToP3Linear[4] * gLin + _kSrgbToP3Linear[5] * bLin;
|
||||
final double bOut =
|
||||
_kSrgbToP3Linear[6] * rLin + _kSrgbToP3Linear[7] * gLin + _kSrgbToP3Linear[8] * bLin;
|
||||
|
||||
return Color.from(
|
||||
alpha: color.a,
|
||||
red: _srgbOETFExtended(rOut),
|
||||
green: _srgbOETFExtended(gOut),
|
||||
blue: _srgbOETFExtended(bOut),
|
||||
colorSpace: resultColorSpace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_ColorTransform _getColorTransform(ColorSpace source, ColorSpace destination) {
|
||||
const srgbToP3 = _MatrixColorTransform(<double>[
|
||||
0.808052267214446, 0.220292047628890, -0.139648846160100,
|
||||
0.145738111193222, //
|
||||
0.096480880462996, 0.916386732581291, -0.086093928394828,
|
||||
0.089490172325882, //
|
||||
-0.127099563510240, -0.068983484963878, 0.735426667591299, 0.233655661600230,
|
||||
]);
|
||||
const _ColorTransform p3ToSrgb = _MatrixColorTransform(<double>[
|
||||
1.306671048092539, -0.298061942172353, 0.213228303487995,
|
||||
-0.213580156254466, //
|
||||
-0.117390025596251, 1.127722006101976, 0.109727644608938,
|
||||
-0.109450321455370, //
|
||||
0.214813187718391, 0.054268702864647, 1.406898424029350, -0.364892765879631,
|
||||
]);
|
||||
return switch (source) {
|
||||
ColorSpace.sRGB => switch (destination) {
|
||||
ColorSpace.sRGB => const _IdentityColorTransform(),
|
||||
ColorSpace.extendedSRGB => const _IdentityColorTransform(),
|
||||
ColorSpace.displayP3 => srgbToP3,
|
||||
ColorSpace.displayP3 => const _SrgbToP3Transform(),
|
||||
},
|
||||
ColorSpace.extendedSRGB => switch (destination) {
|
||||
ColorSpace.sRGB => const _ClampTransform(_IdentityColorTransform()),
|
||||
ColorSpace.extendedSRGB => const _IdentityColorTransform(),
|
||||
ColorSpace.displayP3 => const _ClampTransform(srgbToP3),
|
||||
ColorSpace.displayP3 => const _ClampTransform(_SrgbToP3Transform()),
|
||||
},
|
||||
ColorSpace.displayP3 => switch (destination) {
|
||||
ColorSpace.sRGB => const _ClampTransform(p3ToSrgb),
|
||||
ColorSpace.extendedSRGB => p3ToSrgb,
|
||||
ColorSpace.sRGB => const _ClampTransform(_P3ToSrgbTransform()),
|
||||
ColorSpace.extendedSRGB => const _P3ToSrgbTransform(),
|
||||
ColorSpace.displayP3 => const _IdentityColorTransform(),
|
||||
},
|
||||
};
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:ui' show ColorSpace;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@ -540,6 +542,57 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
// Verifies that P3-to-sRGB color conversion operates in linear light.
|
||||
// Mid-range values expose the gamma nonlinearity: the correct pipeline
|
||||
// is EOTF(decode) -> 3x3 matrix -> OETF(encode).
|
||||
test('Display P3 to extended sRGB color conversion', () {
|
||||
// P3 #1ECAD3 (30/255, 202/255, 211/255)
|
||||
const p3Color = Color.from(
|
||||
alpha: 1.0,
|
||||
red: 30 / 255.0,
|
||||
green: 202 / 255.0,
|
||||
blue: 211 / 255.0,
|
||||
colorSpace: ColorSpace.displayP3,
|
||||
);
|
||||
|
||||
final Color esrgb = p3Color.withValues(colorSpace: ColorSpace.extendedSRGB);
|
||||
expect(esrgb.colorSpace, ColorSpace.extendedSRGB);
|
||||
|
||||
// Correct values from linearize -> matrix -> encode pipeline.
|
||||
// The red channel must be strongly negative for a P3 teal in extended sRGB.
|
||||
// Correct: ~-0.376. Wrong (gamma-encoded matrix): ~-0.120.
|
||||
expect(esrgb.r, within<double>(distance: 0.01, from: -0.376));
|
||||
expect(esrgb.g, within<double>(distance: 0.01, from: 0.807));
|
||||
expect(esrgb.b, within<double>(distance: 0.01, from: 0.837));
|
||||
});
|
||||
|
||||
test('Display P3 pure green to extended sRGB', () {
|
||||
const p3Green = Color.from(
|
||||
alpha: 1.0,
|
||||
red: 0.0,
|
||||
green: 1.0,
|
||||
blue: 0.0,
|
||||
colorSpace: ColorSpace.displayP3,
|
||||
);
|
||||
final Color esrgb = p3Green.withValues(colorSpace: ColorSpace.extendedSRGB);
|
||||
|
||||
// P3 pure green -> extended sRGB should have negative red and blue.
|
||||
// Correct: R ~-0.512, G ~1.018, B ~-0.311
|
||||
expect(esrgb.r, within<double>(distance: 0.01, from: -0.512));
|
||||
expect(esrgb.g, within<double>(distance: 0.01, from: 1.018));
|
||||
expect(esrgb.b, within<double>(distance: 0.01, from: -0.311));
|
||||
});
|
||||
|
||||
test('sRGB to Display P3 round-trip', () {
|
||||
const srgbColor = Color.from(alpha: 1.0, red: 0.5, green: 0.3, blue: 0.8);
|
||||
// sRGB -> P3 -> sRGB should round-trip.
|
||||
final Color p3 = srgbColor.withValues(colorSpace: ColorSpace.displayP3);
|
||||
final Color backToSrgb = p3.withValues(colorSpace: ColorSpace.extendedSRGB);
|
||||
expect(backToSrgb.r, within<double>(distance: 0.01, from: srgbColor.r));
|
||||
expect(backToSrgb.g, within<double>(distance: 0.01, from: srgbColor.g));
|
||||
expect(backToSrgb.b, within<double>(distance: 0.01, from: srgbColor.b));
|
||||
});
|
||||
|
||||
test('ColorSwatch test', () {
|
||||
final int color = nonconst(0xFF027223);
|
||||
final greens1 = ColorSwatch<String>(color, const <String, Color>{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user