diff --git a/README.md b/README.md index 5a48a16f..80d9d032 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ Unicolour is a .NET library written in C# for working with colour: - Colour mixing / colour interpolation - Colour comparison - Colour properties +- Colour gamut mapping +## Overview 🧭 A `Unicolour` encapsulates a single colour and its representation across different colour spaces. It supports: - RGB - HSB/HSV @@ -65,6 +67,9 @@ Simulation of colour vision deficiency (CVD) / colour blindness is supported for - Tritanopia (no blue perception) - Achromatopsia (no colour perception) +If a colour is outwith the display gamut, the closest in-gamut colour can be obtained through +gamut mapping according to CSS specifications. + Unicolour uses sRGB as the default RGB model and standard illuminant D65 (2° observer) as the default white point of the XYZ colour space. These [can be overridden](#advanced-configuration-) using the `Configuration` parameter. @@ -102,17 +107,17 @@ Targets [.NET Standard 2.0](https://docs.microsoft.com/en-us/dotnet/standard/net | HCT | `Unicolour.FromHct()` | `.Hct` | `.MixHct()` | ## How to use 🌈 -1. Install the package from [NuGet](https://www.nuget.org/packages/Wacton.Unicolour/) +##### 1. Install the package from [NuGet](https://www.nuget.org/packages/Wacton.Unicolour/) ``` dotnet add package Wacton.Unicolour ``` -2. Import the package: +##### 2. Import the package: ```c# using Wacton.Unicolour; ``` -3. Create a `Unicolour` from values: +##### 3. Create a `Unicolour` from values: ```c# var unicolour = Unicolour.FromHex("#FF1493"); var unicolour = Unicolour.FromRgb255(255, 20, 147); @@ -138,7 +143,7 @@ var unicolour = Unicolour.FromCam16(62.47, 42.60, -1.36); var unicolour = Unicolour.FromHct(358.2, 100.38, 55.96); ``` -4. Get representations of a colour in different colour spaces: +##### 4. Get representations of a colour in different colour spaces: ```c# var rgb = unicolour.Rgb; var hsb = unicolour.Hsb; @@ -162,14 +167,14 @@ var cam16 = unicolour.Cam16; var hct = unicolour.Hct; ``` -5. Get properties of a colour +##### 5. Get properties of a colour ```c# var hex = unicolour.Hex; var relativeLuminance = unicolour.RelativeLuminance; var temperature = unicolour.Temperature; ``` -6. Mix colours (interpolate between them): +##### 6. Mix colours (interpolate between them): ```c# var mixed = unicolour1.MixRgb(unicolour2, 0.5); var mixed = unicolour1.MixHsb(unicolour2, 0.5); @@ -193,7 +198,7 @@ var mixed = unicolour1.MixCam16(unicolour2, 0.5); var mixed = unicolour1.MixHct(unicolour2, 0.5); ``` -7. Compare colours: +##### 7. Compare colours: ```c# var contrast = unicolour1.Contrast(unicolour2); var difference = unicolour1.DeltaE76(unicolour2); @@ -207,7 +212,12 @@ var difference = unicolour1.DeltaECam02(unicolour2); var difference = unicolour1.DeltaECam16(unicolour2); ``` -8. Simulate colour vision deficiency: +##### 8. Map to display gamut: +```c# +var mapped = unicolour.MapToGamut(); +``` + +##### 9. Simulate colour vision deficiency: ```c# var protanopia = unicolour.SimulateProtanopia(); var deuteranopia = unicolour.SimulateDeuteranopia(); diff --git a/Unicolour.Example/Gradient.cs b/Unicolour.Example/Gradient.cs index 1ca48f4c..afad73c9 100644 --- a/Unicolour.Example/Gradient.cs +++ b/Unicolour.Example/Gradient.cs @@ -5,7 +5,7 @@ namespace Wacton.Unicolour.Example; internal static class Gradient { - const bool ConstrainUndisplayableColours = true; // if false, undisplayable colours will render as transparent + private const bool RenderOutOfGamutAsTransparent = false; internal delegate Unicolour Mix(Unicolour start, Unicolour end, double distance); @@ -61,7 +61,7 @@ private static void SetLabel(Image image, string text, Unicolour colour) private static Rgba32 AsRgba32(Unicolour unicolour) { var (r, g, b) = unicolour.Rgb.Byte255.ConstrainedTriplet; - var a = ConstrainUndisplayableColours || unicolour.IsDisplayable ? 255 : 0; + var a = unicolour.IsInDisplayGamut || !RenderOutOfGamutAsTransparent ? 255 : 0; return new Rgba32((byte) r, (byte) g, (byte) b, (byte) a); } } \ No newline at end of file diff --git a/Unicolour.Tests/ConfigureRgbTests.cs b/Unicolour.Tests/ConfigureRgbTests.cs index 0070c4de..71931cda 100644 --- a/Unicolour.Tests/ConfigureRgbTests.cs +++ b/Unicolour.Tests/ConfigureRgbTests.cs @@ -10,7 +10,7 @@ */ public class ConfigureRgbTests { - private const double Tolerance = 0.01; + private const double Tolerance = 0.001; // https://en.wikipedia.org/wiki/Adobe_RGB_color_space private static readonly Chromaticity AdobeChromaticityR = new(0.64, 0.33); @@ -24,24 +24,28 @@ public class ConfigureRgbTests private const double Gamma = 2.19921875; - private static readonly Dictionary StandardRgbToDisplayP3Lookup = new() + private static readonly Configuration StandardRgbConfig = new(RgbConfiguration.StandardRgb, XyzConfiguration.D65); + private static readonly Configuration DisplayP3Config = new(RgbConfiguration.DisplayP3, XyzConfiguration.D65); + private static readonly Configuration Rec2020Config = new(RgbConfiguration.Rec2020, XyzConfiguration.D65); + + private static readonly List StandardRgbToDisplayP3Lookup = new() { - { new(1.0, 0.0, 0.0), new(0.9175, 0.2003, 0.1386) }, - { new(0.0, 1.0, 0.0), new(0.4587, 0.9853, 0.2983) }, - { new(0.0, 0.0, 1.0), new(0.0000, 0.0000, 0.9596) }, - { new(1.0930, -0.5435, -0.2538), new(1.0, 0.0, 0.0) }, - { new(-2.9057, 1.0183, -1.0162), new(0.0, 1.0, 0.0) }, - { new(0.00, 0.00, 1.04), new(0.0, 0.0, 1.0) } + new TestCaseData(new ColourTriplet(1, 0, 0), new ColourTriplet(0.9175, 0.2003, 0.1386)), + new TestCaseData(new ColourTriplet(0, 1, 0), new ColourTriplet(0.4584, 0.9853, 0.2983)), + new TestCaseData(new ColourTriplet(0, 0, 1), new ColourTriplet(0.0000, 0.0000, 0.9596)), + new TestCaseData(new ColourTriplet(1.0931, -0.2267, -0.1501), new ColourTriplet(1, 0, 0)), + new TestCaseData(new ColourTriplet(-0.5116, 1.0183, -0.3107), new ColourTriplet(0, 1, 0)), + new TestCaseData(new ColourTriplet(0.0000, 0.0000, 1.0420), new ColourTriplet(0, 0, 1)) }; - private static readonly Dictionary StandardRgbToRec2020Lookup = new() + private static readonly List StandardRgbToRec2020Lookup = new() { - { new(1.0, 0.0, 0.0), new(0.7920, 0.2310, 0.0738) }, - { new(0.0, 1.0, 0.0), new(0.5675, 0.9593, 0.2690) }, - { new(0.0, 0.0, 1.0), new(0.1683, 0.0511, 0.9468) }, - { new(1.2482, -1.6094, -0.2346), new(1.0, 0.0, 0.0) }, - { new(-7.5910, 1.0563, -1.2998), new(0.0, 1.0, 0.0) }, - { new(-0.9409, -0.1079, 1.0505), new(0.0, 0.0, 1.0) } + new TestCaseData(new ColourTriplet(1, 0, 0), new ColourTriplet(0.7920, 0.2310, 0.0738)), + new TestCaseData(new ColourTriplet(0, 1, 0), new ColourTriplet(0.5675, 0.9593, 0.2690)), + new TestCaseData(new ColourTriplet(0, 0, 1), new ColourTriplet(0.1684, 0.0511, 0.9468)), + new TestCaseData(new ColourTriplet(1.2482, -0.3879, -0.1435), new ColourTriplet(1, 0, 0)), + new TestCaseData(new ColourTriplet(-0.7904, 1.0563, -0.3502), new ColourTriplet(0, 1, 0)), + new TestCaseData(new ColourTriplet(-0.2992, -0.0886, 1.0505), new ColourTriplet(0, 0, 1)) }; [Test] @@ -189,61 +193,37 @@ public void XyzD50ToWideGamutRgbD50() AssertUtils.AssertTriplet(unicolourXyz, expectedRgb, Tolerance); AssertUtils.AssertTriplet(unicolourLab, expectedRgb, Tolerance); } - - [Test] - public void ConvertStandardRgbToDisplayP3() + + [TestCaseSource(nameof(StandardRgbToDisplayP3Lookup))] + public void ConvertStandardRgbToDisplayP3(ColourTriplet standardRgbTriplet, ColourTriplet displayP3Triplet) { - var standardRgbConfig = new Configuration(RgbConfiguration.StandardRgb, XyzConfiguration.D65); - var displayP3Config = new Configuration(RgbConfiguration.DisplayP3, XyzConfiguration.D65); - - foreach (var (standardRgbTriplet, displayP3Triplet) in StandardRgbToDisplayP3Lookup) - { - var standardRgb = Unicolour.FromRgb(standardRgbConfig, standardRgbTriplet.Tuple); - var displayP3 = standardRgb.ConvertToConfiguration(displayP3Config); - AssertUtils.AssertTriplet(displayP3, displayP3Triplet, Tolerance); - } + var standardRgb = Unicolour.FromRgb(StandardRgbConfig, standardRgbTriplet.Tuple); + var displayP3 = standardRgb.ConvertToConfiguration(DisplayP3Config); + AssertUtils.AssertTriplet(displayP3, displayP3Triplet, Tolerance); } - [Test] - public void ConvertDisplayP3ToStandardRgb() + [TestCaseSource(nameof(StandardRgbToDisplayP3Lookup))] + public void ConvertDisplayP3ToStandardRgb(ColourTriplet standardRgbTriplet, ColourTriplet displayP3Triplet) { - var standardRgbConfig = new Configuration(RgbConfiguration.StandardRgb, XyzConfiguration.D65); - var displayP3Config = new Configuration(RgbConfiguration.DisplayP3, XyzConfiguration.D65); - - foreach (var (standardRgbTriplet, displayP3Triplet) in StandardRgbToDisplayP3Lookup) - { - var displayP3 = Unicolour.FromRgb(displayP3Config, displayP3Triplet.Tuple); - var standardRgb = displayP3.ConvertToConfiguration(standardRgbConfig); - AssertUtils.AssertTriplet(standardRgb, standardRgbTriplet, Tolerance); - } + var displayP3 = Unicolour.FromRgb(DisplayP3Config, displayP3Triplet.Tuple); + var standardRgb = displayP3.ConvertToConfiguration(StandardRgbConfig); + AssertUtils.AssertTriplet(standardRgb, standardRgbTriplet, Tolerance); } - - [Test] - public void ConvertStandardRgbToRec2020() + + [TestCaseSource(nameof(StandardRgbToRec2020Lookup))] + public void ConvertStandardRgbToRec2020(ColourTriplet standardRgbTriplet, ColourTriplet rec2020Triplet) { - var standardRgbConfig = new Configuration(RgbConfiguration.StandardRgb, XyzConfiguration.D65); - var rec2020Config = new Configuration(RgbConfiguration.Rec2020, XyzConfiguration.D65); - - foreach (var (standardRgbTriplet, rec2020Triplet) in StandardRgbToRec2020Lookup) - { - var standardRgb = Unicolour.FromRgb(standardRgbConfig, standardRgbTriplet.Tuple); - var rec2020 = standardRgb.ConvertToConfiguration(rec2020Config); - AssertUtils.AssertTriplet(rec2020, rec2020Triplet, Tolerance); - } + var standardRgb = Unicolour.FromRgb(StandardRgbConfig, standardRgbTriplet.Tuple); + var rec2020 = standardRgb.ConvertToConfiguration(Rec2020Config); + AssertUtils.AssertTriplet(rec2020, rec2020Triplet, Tolerance); } - [Test] - public void ConvertRec2020ToStandardRgb() + [TestCaseSource(nameof(StandardRgbToRec2020Lookup))] + public void ConvertRec2020ToStandardRgb(ColourTriplet standardRgbTriplet, ColourTriplet rec2020Triplet) { - var standardRgbConfig = new Configuration(RgbConfiguration.StandardRgb, XyzConfiguration.D65); - var rec2020Config = new Configuration(RgbConfiguration.Rec2020, XyzConfiguration.D65); - - foreach (var (standardRgbTriplet, rec2020Triplet) in StandardRgbToRec2020Lookup) - { - var rec2020 = Unicolour.FromRgb(rec2020Config, rec2020Triplet.Tuple); - var standardRgb = rec2020.ConvertToConfiguration(standardRgbConfig); - AssertUtils.AssertTriplet(standardRgb, standardRgbTriplet, Tolerance); - } + var rec2020 = Unicolour.FromRgb(Rec2020Config, rec2020Triplet.Tuple); + var standardRgb = rec2020.ConvertToConfiguration(StandardRgbConfig); + AssertUtils.AssertTriplet(standardRgb, standardRgbTriplet, Tolerance); } [TestCase(Illuminant.A)] diff --git a/Unicolour.Tests/DisplayableColourTests.cs b/Unicolour.Tests/DisplayGamutTests.cs similarity index 81% rename from Unicolour.Tests/DisplayableColourTests.cs rename to Unicolour.Tests/DisplayGamutTests.cs index 0bcd0948..0f4762e9 100644 --- a/Unicolour.Tests/DisplayableColourTests.cs +++ b/Unicolour.Tests/DisplayGamutTests.cs @@ -2,16 +2,16 @@ using NUnit.Framework; -public class DisplayableColourTests +public class DisplayGamutTests { [TestCase(0.0, 0.0, 0.0)] [TestCase(0.5, 0.5, 0.5)] [TestCase(1.0, 1.0, 1.0)] [TestCase(double.Epsilon, double.Epsilon, double.Epsilon)] - public void DisplayableRgb(double r, double g, double b) + public void InRgbGamut(double r, double g, double b) { var unicolour = Unicolour.FromRgb(r, g, b); - Assert.That(unicolour.IsDisplayable, Is.True); + Assert.That(unicolour.IsInDisplayGamut, Is.True); } [TestCase(-0.00001, 0.0, 0.0)] @@ -35,9 +35,9 @@ public void DisplayableRgb(double r, double g, double b) [TestCase(double.NaN, 0.5, 0.5)] [TestCase(0.5, double.NaN, 0.5)] [TestCase(0.5, 0.5, double.NaN)] - public void UndisplayableRgb(double r, double g, double b) + public void OutRgbGamut(double r, double g, double b) { var unicolour = Unicolour.FromRgb(r, g, b); - Assert.That(unicolour.IsDisplayable, Is.False); + Assert.That(unicolour.IsInDisplayGamut, Is.False); } } \ No newline at end of file diff --git a/Unicolour.Tests/EqualityTests.cs b/Unicolour.Tests/EqualityTests.cs index cc056fb0..613550bf 100644 --- a/Unicolour.Tests/EqualityTests.cs +++ b/Unicolour.Tests/EqualityTests.cs @@ -427,7 +427,7 @@ private static void AssertUnicoloursEqual(Unicolour unicolour1, Unicolour unicol AssertEqual(unicolour1.Hct, unicolour2.Hct); AssertEqual(unicolour1.Alpha, unicolour2.Alpha); AssertEqual(unicolour1.Hex, unicolour2.Hex); - AssertEqual(unicolour1.IsDisplayable, unicolour2.IsDisplayable); + AssertEqual(unicolour1.IsInDisplayGamut, unicolour2.IsInDisplayGamut); AssertEqual(unicolour1.RelativeLuminance, unicolour2.RelativeLuminance); AssertEqual(unicolour1.Description, unicolour2.Description); AssertEqual(unicolour1.Temperature, unicolour2.Temperature); diff --git a/Unicolour.Tests/GamutMappingTests.cs b/Unicolour.Tests/GamutMappingTests.cs new file mode 100644 index 00000000..34e50c9b --- /dev/null +++ b/Unicolour.Tests/GamutMappingTests.cs @@ -0,0 +1,149 @@ +namespace Wacton.Unicolour.Tests; + +using NUnit.Framework; +using Wacton.Unicolour.Tests.Utils; + +public class GamutMappingTests +{ + private const double Tolerance = 0.00000000001; + + [TestCase(0, 0, 0)] + [TestCase(0.00000000001, 0, 0)] + [TestCase(0, 0.00000000001, 0)] + [TestCase(0, 0, 0.00000000001)] + [TestCase(1, 1, 0.99999999999)] + [TestCase(1, 0.99999999999, 1)] + [TestCase(0.99999999999, 1, 1)] + [TestCase(1, 1, 1)] + public void RgbInGamut(double r, double g, double b) + { + var original = Unicolour.FromRgb(r, g, b); + var gamutMapped = original.MapToGamut(); + Assert.That(original.IsInDisplayGamut, Is.True); + Assert.That(gamutMapped.IsInDisplayGamut, Is.True); + AssertUtils.AssertTriplet(gamutMapped.Rgb.Triplet, original.Rgb.Triplet, Tolerance); + } + + [TestCase(-0.00000000001, 0, 0)] + [TestCase(0, -0.00000000001, 0)] + [TestCase(0, 0, -0.00000000001)] + [TestCase(1, 1, 1.00000000001)] + [TestCase(1, 1.00000000001, 1)] + [TestCase(1.00000000001, 1, 1)] + public void RgbOutOfGamut(double r, double g, double b) + { + var original = Unicolour.FromRgb(r, g, b); + var gamutMapped = original.MapToGamut(); + Assert.That(original.IsInDisplayGamut, Is.False); + Assert.That(gamutMapped.IsInDisplayGamut, Is.True); + } + + [TestCase(1.0, 0, 0)] + [TestCase(1.00000000001, 0, 0)] + public void MaxLightness(double l, double c, double h) + { + var white = Unicolour.FromOklch(l, c, h); + var gamutMapped = white.MapToGamut(); + AssertUtils.AssertTriplet(gamutMapped, new(1, 1, 1), Tolerance); + } + + [TestCase(0, 0, 0)] + [TestCase(-0.00000000001, 0, 0)] + public void MinLightness(double l, double c, double h) + { + var white = Unicolour.FromOklch(l, c, h); + var gamutMapped = white.MapToGamut(); + AssertUtils.AssertTriplet(gamutMapped, new(0, 0, 0), Tolerance); + } + + [TestCase] + public void NoChromaInGamut() + { + // OKLCH without chroma isn't processed by the gamut mapping algorithm (chroma is already considered to be converged, can't go lower) + // OKLCH (0.5, 0, 0) corresponds to an in-gamut sRGB (0.39, 0.39, 0.39), so make sure that original RGB is returned + var original = Unicolour.FromOklch(0.5, 0, 0); + var gamutMapped = original.MapToGamut(); + Assert.That(original.IsInDisplayGamut, Is.True); + Assert.That(gamutMapped.IsInDisplayGamut, Is.True); + AssertUtils.AssertTriplet(gamutMapped.Rgb.Triplet, original.Rgb.Triplet, Tolerance); + } + + [TestCase] + public void NoChromaOutOfGamut() + { + // OKLCH without chroma isn't processed by the gamut mapping algorithm (chroma is already considered to be converged, can't go lower) + // OKLCH (0.99999, 0, 0) corresponds to an out-of-gamut RGB (1.00010, 0.99998, 0.99974), so make sure RGB is brought into gamut anyway + var original = Unicolour.FromOklch(0.99999, 0, 0); + var gamutMapped = original.MapToGamut(); + Assert.That(original.IsInDisplayGamut, Is.False); + Assert.That(gamutMapped.IsInDisplayGamut, Is.True); + } + + [Test] + public void NegativeChroma() + { + var original = Unicolour.FromOklch(0.5, -0.5, 180); + var gamutMapped = original.MapToGamut(); + Assert.That(gamutMapped.Rgb.IsInGamut, Is.True); + AssertUtils.AssertTriplet(gamutMapped.Rgb.Triplet, original.Rgb.ConstrainedTriplet, Tolerance); + } + + [TestCase(double.PositiveInfinity)] + [TestCase(double.NaN)] + public void ChromaCannotConverge(double chroma) + { + var original = Unicolour.FromOklch(0.5, chroma, 180); + Assert.DoesNotThrow(() => original.MapToGamut()); + } + + [Test] + public void NegativeHue() + { + var original = Unicolour.FromOklch(0.5, 0.1, -180); + var gamutMapped = original.MapToGamut(); + Assert.That(gamutMapped.Rgb.IsInGamut, Is.True); + AssertUtils.AssertTriplet(gamutMapped.Rgb.Triplet, original.Rgb.ConstrainedTriplet, 0.0001); + } + + [TestCaseSource(typeof(RandomColours), nameof(RandomColours.RgbTriplets))] + public void RandomRgb(ColourTriplet triplet) + { + var original = Unicolour.FromRgb(triplet.Tuple); + var gamutMapped = original.MapToGamut(); + Assert.That(original.IsInDisplayGamut, Is.True); + Assert.That(gamutMapped.IsInDisplayGamut, Is.True); + AssertUtils.AssertTriplet(gamutMapped.Rgb.Triplet, original.Rgb.Triplet, Tolerance); + } + + [TestCaseSource(typeof(RandomColours), nameof(RandomColours.OklchTriplets))] + public void RandomOklch(ColourTriplet triplet) + { + var original = Unicolour.FromOklch(triplet.Tuple); + var gamutMapped = original.MapToGamut(); + Assert.That(gamutMapped.Rgb.IsInGamut, Is.True); + Assert.That(gamutMapped.Rgb.Triplet, Is.EqualTo(gamutMapped.Rgb.ConstrainedTriplet)); + } + + [Test] + public void YellowOutOfGamut() + { + const double tolerance = 0.001; + var yellowDisplayP3 = Unicolour.FromRgb(new Configuration(RgbConfiguration.DisplayP3), 1, 1, 0); + var yellowStandardRgb = yellowDisplayP3.ConvertToConfiguration(new Configuration(RgbConfiguration.StandardRgb)); + AssertUtils.AssertTriplet(yellowDisplayP3, new(1.00000, 1.00000, 0.00000), tolerance); + AssertUtils.AssertTriplet(yellowStandardRgb, new(1.00000, 1.00000, -0.34630), tolerance); + AssertUtils.AssertTriplet(yellowDisplayP3, new(0.96476, 0.24503, 110.23), tolerance); + AssertUtils.AssertTriplet(yellowStandardRgb, new(0.96476, 0.24503, 110.23), tolerance); + + var gamutMappedDisplayP3 = yellowDisplayP3.MapToGamut(); + Assert.That(gamutMappedDisplayP3, Is.EqualTo(yellowDisplayP3)); + Assert.That(yellowDisplayP3.IsInDisplayGamut, Is.True); + Assert.That(gamutMappedDisplayP3.IsInDisplayGamut, Is.True); + + var gamutMappedStandardRgb = yellowStandardRgb.MapToGamut(); + Assert.That(yellowStandardRgb.Oklch.C, Is.EqualTo(0.245).Within(tolerance)); + Assert.That(yellowStandardRgb.IsInDisplayGamut, Is.False); + Assert.That(gamutMappedStandardRgb.Oklch.C, Is.EqualTo(0.210).Within(tolerance)); + Assert.That(gamutMappedStandardRgb.IsInDisplayGamut, Is.True); + } +} \ No newline at end of file diff --git a/Unicolour.Tests/LazyEvaluationTests.cs b/Unicolour.Tests/LazyEvaluationTests.cs index d2e3ce2e..3f06c3a6 100644 --- a/Unicolour.Tests/LazyEvaluationTests.cs +++ b/Unicolour.Tests/LazyEvaluationTests.cs @@ -77,10 +77,10 @@ public void AfterHex(Func unicolourFunction) } [TestCaseSource(nameof(TestCases))] - public void AfterIsDisplayable(Func unicolourFunction) + public void AfterIsInDisplayGamut(Func unicolourFunction) { var unicolour = unicolourFunction(); - _ = unicolour.IsDisplayable; + _ = unicolour.IsInDisplayGamut; AssertBackingFieldEvaluated(unicolour, ColourSpace.Rgb); } diff --git a/Unicolour.Tests/NotNumberTests.cs b/Unicolour.Tests/NotNumberTests.cs index 954f8168..dafda828 100644 --- a/Unicolour.Tests/NotNumberTests.cs +++ b/Unicolour.Tests/NotNumberTests.cs @@ -108,7 +108,7 @@ private static void AssertUnicolour(Unicolour unicolour) Assert.That(initial.UseAsHued, Is.False); Assert.That(initial.ToString().StartsWith("NaN")); Assert.That(unicolour.Hex, Is.EqualTo("-")); - Assert.That(unicolour.IsDisplayable, Is.False); + Assert.That(unicolour.IsInDisplayGamut, Is.False); Assert.That(unicolour.RelativeLuminance, Is.NaN); Assert.That(unicolour.Description, Is.EqualTo("-")); diff --git a/Unicolour.Tests/OtherLibraries/ColorMineFactory.cs b/Unicolour.Tests/OtherLibraries/ColorMineFactory.cs index b7f0b312..cafcf7b3 100644 --- a/Unicolour.Tests/OtherLibraries/ColorMineFactory.cs +++ b/Unicolour.Tests/OtherLibraries/ColorMineFactory.cs @@ -21,7 +21,10 @@ namespace Wacton.Unicolour.Tests.OtherLibraries; internal class ColorMineFactory : ITestColourFactory { private static readonly Tolerances Tolerances = new() - {Rgb = 0.0005, Hsb = 0.00005, Hsl = 0.0125, Xyz = 0.0005, Xyy = 0.0005, Lab = 0.05, Lchab = 0.05, Luv = 0.05}; + { + Rgb = 0.0005, Hsb = 0.00005, Hsl = 0.0125, Xyz = 0.0005, Xyy = 0.0005, + Lab = 0.05, Lchab = 0.05, Luv = 0.05 + }; public TestColour FromRgb(double r, double g, double b, string name) { diff --git a/Unicolour.Tests/OtherLibraries/ColourfulFactory.cs b/Unicolour.Tests/OtherLibraries/ColourfulFactory.cs index afe8c20a..b9d9eb52 100644 --- a/Unicolour.Tests/OtherLibraries/ColourfulFactory.cs +++ b/Unicolour.Tests/OtherLibraries/ColourfulFactory.cs @@ -23,7 +23,10 @@ namespace Wacton.Unicolour.Tests.OtherLibraries; internal class ColourfulFactory : ITestColourFactory { private static readonly Tolerances Tolerances = new() - {Rgb = 0.0000000001, RgbLinear = 0.0000000001, Xyz = 0.0000000001, Xyy = 0.000000005, Lab = 0.0000005, Lchab = 0.0000005, Luv = 0.000005, Lchuv = 0.000005}; + { + Rgb = 0.0000000001, RgbLinear = 0.0000000001, Xyz = 0.0000000001, Xyy = 0.000000005, + Lab = 0.0000005, Lchab = 0.0000005, Luv = 0.000005, Lchuv = 0.000005 + }; public TestColour FromRgb(double r, double g, double b, string name) { @@ -159,7 +162,8 @@ private static TestColour Create(string name, IsRgbConstrained = false, IsRgbLinearConstrained = false, ExcludeFromLchTestReasons = LchExclusions(rgb), - ExcludeFromXyyTestReasons = XyyExclusions(xyy) + ExcludeFromXyyTestReasons = XyyExclusions(xyy), + ExcludeFromAllTestReasons = AllExclusions(rgbLinear) }; } @@ -176,7 +180,15 @@ private static List XyyExclusions(ColourfulXyy xyy) if (HasNoChromaticityY(xyy)) exclusions.Add("Colourful sets all values to 0 when xyY has no y-chromaticity"); return exclusions; } + + private static List AllExclusions(ColourfulRgbLinear rgbLinear) + { + var exclusions = new List(); + if (HasNegativeLinear(rgbLinear)) exclusions.Add("Colourful does not handle linear companding when negative"); + return exclusions; + } private static bool IsGreyscale(ColourfulRgb rgb) => rgb.R == rgb.G && rgb.G == rgb.B; private static bool HasNoChromaticityY(ColourfulXyy xyy) => xyy.y <= 0.0; + private static bool HasNegativeLinear(ColourfulRgbLinear rgbLinear) => rgbLinear.R < 0 || rgbLinear.G < 0 || rgbLinear.B < 0; } \ No newline at end of file diff --git a/Unicolour.Tests/OtherLibraries/OpenCvFactory.cs b/Unicolour.Tests/OtherLibraries/OpenCvFactory.cs index ca95f0f4..2f5e2a2b 100644 --- a/Unicolour.Tests/OtherLibraries/OpenCvFactory.cs +++ b/Unicolour.Tests/OtherLibraries/OpenCvFactory.cs @@ -16,7 +16,11 @@ namespace Wacton.Unicolour.Tests.OtherLibraries; */ internal class OpenCvFactory : ITestColourFactory { - public static readonly Tolerances Tolerances = new() {Rgb = 0.05, Hsb = 0.05, Hsl = 0.01, Xyz = 0.0005, Lab = 1.0, Luv = 1.0}; + public static readonly Tolerances Tolerances = new() + { + Rgb = 0.05, Hsb = 0.05, Hsl = 0.01, Xyz = 0.0005, + Lab = 1.0, Luv = 1.0 + }; public TestColour FromRgb(double r, double g, double b, string name) { diff --git a/Unicolour.Tests/OtherLibraries/SixLaborsFactory.cs b/Unicolour.Tests/OtherLibraries/SixLaborsFactory.cs index 91dd82ec..4f9a3911 100644 --- a/Unicolour.Tests/OtherLibraries/SixLaborsFactory.cs +++ b/Unicolour.Tests/OtherLibraries/SixLaborsFactory.cs @@ -26,7 +26,10 @@ namespace Wacton.Unicolour.Tests.OtherLibraries; internal class SixLaborsFactory : ITestColourFactory { private static readonly Tolerances Tolerances = new() - {Rgb = 0.001, RgbLinear = 0.005, Hsb = 0.000005, Hsl = 0.000005, Xyz = 0.005, Xyy = 0.005, Lab = 0.1, Luv = 0.2, Lchuv = 0.1}; + { + Rgb = 0.001, RgbLinear = 0.005, Hsb = 0.000005, Hsl = 0.000005, Xyz = 0.005, Xyy = 0.005, + Lab = 0.001, Luv = 0.2, Lchuv = 0.1 + }; private static readonly ColorSpaceConverter Converter = new(new ColorSpaceConverterOptions { diff --git a/Unicolour.Tests/Utils/AssertUtils.cs b/Unicolour.Tests/Utils/AssertUtils.cs index 48b813b3..5639ac27 100644 --- a/Unicolour.Tests/Utils/AssertUtils.cs +++ b/Unicolour.Tests/Utils/AssertUtils.cs @@ -73,8 +73,8 @@ void AccessProperties() AccessProperty(() => unicolour.Hsl); AccessProperty(() => unicolour.Hsluv); AccessProperty(() => unicolour.Hwb); - AccessProperty(() => unicolour.IsDisplayable); AccessProperty(() => unicolour.Ictcp); + AccessProperty(() => unicolour.IsInDisplayGamut); AccessProperty(() => unicolour.Jzazbz); AccessProperty(() => unicolour.Jzczhz); AccessProperty(() => unicolour.Lab); diff --git a/Unicolour/ColourRepresentation.cs b/Unicolour/ColourRepresentation.cs index 7ac4a3af..319567d2 100644 --- a/Unicolour/ColourRepresentation.cs +++ b/Unicolour/ColourRepresentation.cs @@ -9,7 +9,6 @@ public abstract record ColourRepresentation public ColourTriplet Triplet => new(First, Second, Third, HueIndex); internal ColourHeritage Heritage { get; } - // TODO: consider gamut mapping (https://www.w3.org/TR/css-color-4/#binsearch ?) protected virtual double ConstrainedFirst => First; protected virtual double ConstrainedSecond => Second; protected virtual double ConstrainedThird => Third; diff --git a/Unicolour/Companding.cs b/Unicolour/Companding.cs index c36a46e8..1cf86989 100644 --- a/Unicolour/Companding.cs +++ b/Unicolour/Companding.cs @@ -7,18 +7,28 @@ public static class Companding public static class StandardRgb { - public static double FromLinear(double value) + public static double FromLinear(double linear) { - return value <= 0.0031308 - ? 12.92 * value - : 1.055 * Gamma(value, 2.4) - 0.055; + if (double.IsNaN(linear)) return double.NaN; + return Math.Sign(linear) * Nonlinear(Math.Abs(linear)); + double Nonlinear(double value) + { + return value <= 0.0031308 + ? 12.92 * value + : 1.055 * Gamma(value, 2.4) - 0.055; + } } - - public static double ToLinear(double value) + + public static double ToLinear(double nonlinear) { - return value <= 0.04045 - ? value / 12.92 - : InverseGamma((value + 0.055) / 1.055, 2.4); + if (double.IsNaN(nonlinear)) return double.NaN; + return Math.Sign(nonlinear) * Linear(Math.Abs(nonlinear)); + double Linear(double value) + { + return value <= 0.04045 + ? value / 12.92 + : InverseGamma((value + 0.055) / 1.055, 2.4); + } } } @@ -33,16 +43,27 @@ public static class Rec2020 private const double Alpha = 1.09929682680944; private const double Beta = 0.018053968510807; - public static double FromLinear(double e) + public static double FromLinear(double linear) { - if (e < Beta) return 4.5 * e; - return Alpha * Math.Pow(e, 0.45) - (Alpha - 1); + if (double.IsNaN(linear)) return double.NaN; + return Math.Sign(linear) * Nonlinear(Math.Abs(linear)); + double Nonlinear(double e) + { + if (e < Beta) return 4.5 * e; + return Alpha * Math.Pow(e, 0.45) - (Alpha - 1); + } + } - public static double ToLinear(double ePrime) + public static double ToLinear(double nonlinear) { - if (ePrime < Beta * 4.5) return ePrime / 4.5; - return Math.Pow((ePrime + (Alpha - 1)) / Alpha, 1 / 0.45); + if (double.IsNaN(nonlinear)) return double.NaN; + return Math.Sign(nonlinear) * Linear(Math.Abs(nonlinear)); + double Linear(double ePrime) + { + if (ePrime < Beta * 4.5) return ePrime / 4.5; + return Math.Pow((ePrime + (Alpha - 1)) / Alpha, 1 / 0.45); + } } } } \ No newline at end of file diff --git a/Unicolour/GamutMapping.cs b/Unicolour/GamutMapping.cs new file mode 100644 index 00000000..2d663540 --- /dev/null +++ b/Unicolour/GamutMapping.cs @@ -0,0 +1,86 @@ +namespace Wacton.Unicolour; + +internal static class GamutMapping +{ + /* + * adapted from https://www.w3.org/TR/css-color-4/#css-gamut-mapping & https://www.w3.org/TR/css-color-4/#binsearch + * the pseudocode doesn't appear to handle the edge case scenario where: + * a) origin colour OKLCH chroma < epsilon + * b) origin colour destination (RGB here) is out-of-gamut + * e.g. OKLCH (0.99999, 0, 0) --> RGB (1.00010, 0.99998, 0.99974) + * - the search never executes since chroma = 0 (min 0 - max 0 < epsilon 0.0001) + * - even if the search did execute, would not return clipped variant since ΔE is *too small*, and min never changes from 0 + * so need to clip if the mapped colour is somehow out-of-gamut (i.e. not processed) + */ + internal static Unicolour ToRgbGamut(Unicolour unicolour) + { + var config = unicolour.Config; + var rgb = unicolour.Rgb; + var alpha = unicolour.Alpha.A; + if (unicolour.IsInDisplayGamut) return Unicolour.FromRgb(config, rgb.Triplet.Tuple, alpha); + + var oklch = unicolour.Oklch; + if (oklch.L >= 1.0) return Unicolour.FromRgb(config, 1, 1, 1, alpha); + if (oklch.L <= 0.0) return Unicolour.FromRgb(config, 0, 0, 0, alpha); + + const double jnd = 0.02; + const double epsilon = 0.0001; + var minChroma = 0.0; + var maxChroma = oklch.C; + var minChromaInGamut = true; + + // iteration count ensures the while loop doesn't get stuck in an endless cycle if bad input is provided + // e.g. double.Epsilon + var iterations = 0; + Unicolour? current = null; + bool HasChromaConverged() => maxChroma - minChroma <= epsilon; + while (!HasChromaConverged() && iterations < 1000) + { + iterations++; + + var chroma = (minChroma + maxChroma) / 2.0; + current = FromOklchWithChroma(chroma); + + if (minChromaInGamut && current.Rgb.IsInGamut) + { + minChroma = chroma; + continue; + } + + var clipped = FromRgbWithClipping(current.Rgb); + var deltaE = clipped.DeltaEOk(current); + + var isNoticeableDifference = deltaE >= jnd; + if (isNoticeableDifference) + { + maxChroma = chroma; + } + else + { + // not clear to me why a clipped colour must have ΔE from "current" colour between 0.0199 - 0.02 + // effectively: only returning clipped when ΔE == JND, but continue if the non-noticeable ΔE is *too small* + // but I assume it's something to do with this comment about intersecting shallow and concave gamut boundaries + // https://github.com/w3c/csswg-drafts/issues/7653#issuecomment-1489096489 + var isUnnoticeableDifferenceLargeEnough = jnd - deltaE < epsilon; + if (isUnnoticeableDifferenceLargeEnough) + { + return clipped; + } + + minChromaInGamut = false; + minChroma = chroma; + } + } + + // in case while loop never executes (e.g. Oklch.C == 0) + current ??= FromOklchWithChroma(oklch.C); + + // it's possible for the "current" colour to still be out of RGB gamut, either because: + // a) the original OKLCH was not processed (chroma too low) and was already out of RGB gamut + // b) the algorithm converged on an OKLCH that is out of RGB gamut (happens ~5% of the time for me with using random OKLCH inputs) + return current.IsInDisplayGamut ? current : FromRgbWithClipping(current.Rgb); + + Unicolour FromOklchWithChroma(double chroma) => Unicolour.FromOklch(config, oklch.L, chroma, oklch.H, alpha); + Unicolour FromRgbWithClipping(Rgb unclippedRgb) => Unicolour.FromRgb(config, unclippedRgb.ConstrainedTriplet.Tuple, alpha); + } +} \ No newline at end of file diff --git a/Unicolour/Rgb.cs b/Unicolour/Rgb.cs index fffbf9fb..947c6ae2 100644 --- a/Unicolour/Rgb.cs +++ b/Unicolour/Rgb.cs @@ -17,7 +17,7 @@ public record Rgb : ColourRepresentation // for almost all cases, doing this check in linear RGB will return the same result // but handling it here feels most natural as it is the intended "display" space // and isn't concerned about questionable custom inverse-companding-to-linear functions (e.g. where where RGB <= 1.0 but RGB-Linear > 1.0) - internal bool IsDisplayable => !UseAsNaN && R is >= 0 and <= 1.0 && G is >= 0 and <= 1.0 && B is >= 0 and <= 1.0; + internal bool IsInGamut => !UseAsNaN && R is >= 0 and <= 1.0 && G is >= 0 and <= 1.0 && B is >= 0 and <= 1.0; public RgbLinear Linear { get; } public Rgb255 Byte255 { get; } diff --git a/Unicolour/Unicolour.cs b/Unicolour/Unicolour.cs index 8a50fd9c..404a2cb2 100644 --- a/Unicolour/Unicolour.cs +++ b/Unicolour/Unicolour.cs @@ -48,8 +48,8 @@ public partial class Unicolour : IEquatable public Alpha Alpha { get; } public Configuration Config { get; } - public string Hex => !IsDisplayable ? "-" : Rgb.Byte255.ConstrainedHex; - public bool IsDisplayable => Rgb.IsDisplayable; + public string Hex => !IsInDisplayGamut ? "-" : Rgb.Byte255.ConstrainedHex; + public bool IsInDisplayGamut => Rgb.IsInGamut; public double RelativeLuminance => Rgb.Linear.RelativeLuminance; public string Description => string.Join(" ", ColourDescription.Get(Hsl)); public Temperature Temperature => Temperature.Get(Xyz); @@ -95,10 +95,12 @@ private Unicolour(Configuration config, ColourRepresentation initialRepresentati public Unicolour MixCam16(Unicolour other, double amount = 0.5) => Interpolation.Mix(ColourSpace.Cam16, this, other, amount); public Unicolour MixHct(Unicolour other, double amount = 0.5) => Interpolation.Mix(ColourSpace.Hct, this, other, amount); - public Unicolour SimulateProtanopia() => VisionDeficiency.SimulateProtanopia(Rgb, Config); - public Unicolour SimulateDeuteranopia() => VisionDeficiency.SimulateDeuteranopia(Rgb, Config); - public Unicolour SimulateTritanopia() => VisionDeficiency.SimulateTritanopia(Rgb, Config); - public Unicolour SimulateAchromatopsia() => VisionDeficiency.SimulateAchromatopsia(RelativeLuminance, Config); + public Unicolour SimulateProtanopia() => VisionDeficiency.SimulateProtanopia(this, Config); + public Unicolour SimulateDeuteranopia() => VisionDeficiency.SimulateDeuteranopia(this, Config); + public Unicolour SimulateTritanopia() => VisionDeficiency.SimulateTritanopia(this, Config); + public Unicolour SimulateAchromatopsia() => VisionDeficiency.SimulateAchromatopsia(this, Config); + + public Unicolour MapToGamut() => GamutMapping.ToRgbGamut(this); public Unicolour ConvertToConfiguration(Configuration newConfig) { diff --git a/Unicolour/VisionDeficiency.cs b/Unicolour/VisionDeficiency.cs index 526db951..7798a1ae 100644 --- a/Unicolour/VisionDeficiency.cs +++ b/Unicolour/VisionDeficiency.cs @@ -35,13 +35,13 @@ private static Unicolour SimulateCvd(Rgb rgb, Configuration config, Matrix cvdMa return Unicolour.FromRgb(config, simulatedRgbMatrix.ToTriplet().Tuple); } - internal static Unicolour SimulateProtanopia(Rgb rgb, Configuration config) => SimulateCvd(rgb, config, Protanomaly); - internal static Unicolour SimulateDeuteranopia(Rgb rgb, Configuration config) => SimulateCvd(rgb, config, Deuteranomaly); - internal static Unicolour SimulateTritanopia(Rgb rgb, Configuration config) => SimulateCvd(rgb, config, Tritanomaly); - internal static Unicolour SimulateAchromatopsia(double luminance, Configuration config) + internal static Unicolour SimulateProtanopia(Unicolour unicolour, Configuration config) => SimulateCvd(unicolour.Rgb, config, Protanomaly); + internal static Unicolour SimulateDeuteranopia(Unicolour unicolour, Configuration config) => SimulateCvd(unicolour.Rgb, config, Deuteranomaly); + internal static Unicolour SimulateTritanopia(Unicolour unicolour, Configuration config) => SimulateCvd(unicolour.Rgb, config, Tritanomaly); + internal static Unicolour SimulateAchromatopsia(Unicolour unicolour, Configuration config) { // luminance is based on Linear RGB, so needs to be companded back into chosen RGB space - var rgbLuminance = config.Rgb.CompandFromLinear(luminance); + var rgbLuminance = config.Rgb.CompandFromLinear(unicolour.RelativeLuminance); return Unicolour.FromRgb(config, rgbLuminance, rgbLuminance, rgbLuminance); } } \ No newline at end of file