Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Color conversion with ICC profiles #1567

Draft
wants to merge 84 commits into
base: main
Choose a base branch
from

Conversation

JimBobSquarePants
Copy link
Member

@JimBobSquarePants JimBobSquarePants commented Feb 27, 2021

Prerequisites

  • I have written a descriptive pull-request title
  • I have verified that there are no overlapping pull-requests open
  • I have verified that I am following matches the existing coding patterns and practice as demonstrated in the repository. These follow strict Stylecop rules 👮.
  • I have provided test coverage for my change (where applicable)

Description

Note: This is a replacement for the original PR #273 from @JBildstein that was automatically closed by our Git LFS history rewrite. Individual commits have unfortunately been lost in the process. Help is very much needed to complete the work.

As the title says, this adds methods for converting colors with an ICC profile.

Architecturally, the idea is that the profile is checked once for available and appropriate conversion methods and a then a delegate is stored that only takes the color values to convert and returns the calculated values. The possible performance penalty for using a delegate is far smaller than searching through the profile for every conversion. I'm open for other suggestions though.

There are classes to convert from the profile connection space (=PCS, can be XYZ or Lab) to the data space (RGB, CMYK, etc.) and vice versa. There are also classes to convert from PCS to PCS and Data to Data but they are only used for special profiles and are not important for us now but I still added them for completeness sake.

A challenge here is writing tests for this because of the complexity of the calculations and the big amount of different possible conversion paths. This is a rough list of the paths that exist:

  • "A to B" and "B to A" tags
    • IccLut8TagDataEntry
      • Input IccLut[], Clut, Output IccLut[]
      • Matrix(3x3), Input IccLut[], IccClut, Output IccLut[]
    • IccLut16TagDataEntry
      • Input IccLut[], IccClut, Output IccLut[]
      • Matrix(3x3), Input IccLut[], IccClut, Output IccLut[]
    • IccLutAToBTagDataEntry/IccLutBToATagDataEntry (Curve types can either be IccCurveTagDataEntry or IccParametricCurveTagDataEntry (which has several curve subtypes))
      • CurveA[], Clut, CurveM[], Matrix(3x1), Matrix(3x3), CurveB[]
      • CurveA[], Clut, CurveB[]
      • CurveM[], Matrix(3x1), Matrix(3x3), CurveB[]
      • CurveB[]
  • "D to B" tags
    • IccMultiProcessElementsTagDataEntry that contains an array of any of those types in any order:
      • IccCurveSetProcessElement
        • IccOneDimensionalCurve[] where each curve can have several curve subtypes
      • IccMatrixProcessElement
        • Matrix(Nr. of input Channels by Nr. of output Channels), Matrix(Nr. of output channels by 1)
      • IccClutProcessElement
        • IccClut
  • Color Trc
    • Matrix(3x3), one curve for R, G and B each (Curve types can either be IccCurveTagDataEntry or IccParametricCurveTagDataEntry (which has several curve subtypes))
  • Gray Trc
    • Curve (Curve type can either be IccCurveTagDataEntry or IccParametricCurveTagDataEntry (which has several curve subtypes))

The three main approaches in that list are

  • A to B/B to A: using a combination of lookup tables, matrices and curves
  • D to B: using a chain of multi process elements (curves, matrices or lookup)
  • Trc: using curves (and matrices for color but not for gray)

The most used approaches are Color Trc for RGB profiles and LutAToB/LutBToA for CMYK profiles.

Todo list:

  • Integrate with the rest of the project
  • Write tests that cover all conversion paths
  • Review architecture
  • Improve speed and accuracy of the calculations

Help and suggestions are very welcome.

@brianpopow
Copy link
Collaborator

I wonder why the test MatrixCalculator_WithMatrix_ReturnsResult only fails with netcoreapp2.1 and not with the other frameworks.

@JimBobSquarePants
Copy link
Member Author

@brianpopow It'll be an accuracy issue most likely. (I hope it's not a JIT issue). It should be possible to inspect the result and see.

@codecov
Copy link

codecov bot commented Jul 13, 2021

Codecov Report

❗ No coverage uploaded for pull request base (main@ca20c92). Click here to learn what that means.
The diff coverage is n/a.

❗ Current head 5834c39 differs from pull request most recent head f60d4b8. Consider uploading reports for the commit f60d4b8 to get more accurate results

@@          Coverage Diff           @@
##             main   #1567   +/-   ##
======================================
  Coverage        ?     87%           
======================================
  Files           ?    1023           
  Lines           ?   55212           
  Branches        ?    7052           
======================================
  Hits            ?   48227           
  Misses          ?    5768           
  Partials        ?    1217           
Flag Coverage Δ
unittests 87% <0%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

📣 We’re building smart automated test selection to slash your CI/CD build times. Learn more

@brianpopow
Copy link
Collaborator

@brianpopow It'll be an accuracy issue most likely. (I hope it's not a JIT issue). It should be possible to inspect the result and see.

The issue only happens with a Release build. I think i found the reason, but it seems very weird. Vector3.Zero does not have the expected value (0, 0, 0).

This can be seen with the testoutput:

[xUnit.net 00:00:07.19]     MatrixCalculator_WithMatrix_ReturnsResult(matrix2D: { {M11:1 M12:0 M13:0 M14:0} {M21:0 M22:1 M23:0 M24:0} {M31:0 M32:0 M33:1 M34:0} {M41:0 M42:0 M43:0 M44:1} }, matrix1D: <-0,0007887525. 4,590794E-41. 1>, input: <0,5. 0,5. 0,5. 0>, expected: <0,5. 0,5. 0,5. 0>) [FAIL]

matrix1D is supposed to be Vector3.Zero

@JimBobSquarePants
Copy link
Member Author

Vector3.Zero does not have the expected value (0, 0, 0).

@brianpopow Woah! That's bonkers!

@brianpopow
Copy link
Collaborator

Vector3.Zero does not have the expected value (0, 0, 0).

@brianpopow Woah! That's bonkers!

I have reported this issue: dotnet/runtime#55623

They confirmed the issue, but they say its unlikely to be fixed because netcore2.1 is out of support in august.
So long story short: be careful with default values or Vector.Zero in testdata.

@brianpopow
Copy link
Collaborator

@JimBobSquarePants It would be really nice, if we could bring this PR forward. This would be a good addition to ImageSharp. I thought, I may ask you, if you know what the PR needs (besides tests) to be finished?

What would be the right way to apply an embedded color profile? My first attempt was:

var converter = new IccPcsToDataConverter(profile);
for (int y = 0; y < image.Height; y++)
{
    for (int x = 0; x < image.Width; x++)
    {
        var inputVec = image[x, y].ToVector4();
        Vector4 converted = converter.Calculate(inputVec);
        image[x, y] = new RgbaVector(converted.X, converted.Y, converted.Z);
    }
}

Here is an example image with adobe rgb color profile:

Momiji-AdobeRGB-yes

This does not seems to work, the colors seem to be wrong. Here are more example images

@JimBobSquarePants
Copy link
Member Author

@brianpopow Honestly.....

I don't know. I was hoping the OP would come back to finish things off. I've just kept things updated over the years and hadn't gotten involved at all in the implementation as yet.

Judging from the old comments in the previous PR I believe the code is based somewhat based on the following

https://github.com/InternationalColorConsortium/DemoIccMAX/tree/master/IccProfLib

As for accuracy. That result looks like it's just spitting out the sRGB values again.

I do agree that it would be an awesome addition to the library and would save a ton of headaches. I hoped we'd get it in V3 but that's a mighty big ask.

@brianpopow
Copy link
Collaborator

I think we definitely need a reference implementation to compare the results against. I tried BABL which gnome is using, but i could not get it to work on windows. I will take a look at DemoIccMAX

@JimBobSquarePants
Copy link
Member Author

Just a quick update: some initial testing suggests that the core LUT functionality is working, at least for 4D CMYK device to LAB PCS, with respect to DemoIccMAX.

For example, with some careful handling of numeric types, this Fogra39 profile with hardcoded relative intent (an easier intent to test, no need for PCS adjustment) gives exactly the same LAB values.

var bytes = File.ReadAllBytes("./Coated_Fogra39L_VIGC_300.icc");
var converter = new IccDataToPcsConverter(new IccProfile(bytes));
var pcs = converter.Calculate(new Vector4(0.8f, 0.6f, 0.4f, 0.2f));
var iccLab = Vector4.Multiply(pcs, 65535f / 65280f); // Lab2 to Lab4

var l = iccLab[0] * 100;                    // 37.8027306
var a = (float)(iccLab[1] * 255.0 - 128.0); // -3.48995304
var b = (float)(iccLab[2] * 255.0 - 128.0); // -15.3412971

For what it's worth, my library uses doubles and different CLUT implementation and gets the result (37.802734372871825, -3.489946905189541, -15.341296133649976). The iccLab values, before the scaling, begin to differ at around 7 decimal places, which I guess is expected.

Given that the CLUT calculator is a direct port of the DemoIccMAX code, it's no real surprise that everything looks to work sensibly, but there are 5 different interpolation functions there; I only tested one usage of the 4D interpolation, plenty of room for bugs elsewhere!

Finally, the failing tests in IccProfileConverterTests

  • CMYK isn't roundtrippable, so I'm not sure that CanRoundTripProfile makes sense

  • CanConvertToSRGB looks suspiciously like the pixel values coming from issue-129.jpg are in an RGBA format

    • First pixel = <0.43921572, 1, 0.62352943, 1> = what I'd call a "light vibrant mint" #70FF9F
    • Last pixel = <0.30588236, 0.85490197, 1, 1> = what I'd call a "light vibrant cyan" #4EDAFF
    • ...and this matches the test input image pretty well
    • Passing these values in to a CMYK to PCS converter is just going to become basically black thanks to the K = 1
    • ... which also matches the test output image

Sadly I don't know anything about how images actually encode CMYK data, so I'm not sure there's anything I can contribute in code just yet 😥. Happy to help get to the bottom of any other specific ICC conversion concerns!

Apologies for the slow response and thank you for your investigation. Yes...That makes perfect sense.... 😞

Our code is assuming that the converters are operating in a manner that can handle the input Vector using an RGBA layout with values ranging from 0-1. Of course, this isn't the case and is a huge bug.

When converting from a CMYK profile we're going to need to convert from TPixel to Vector4 to CMYK, then adjust the values, then convert back to the TPixel layout. Looks like we should be checking the IccColorSpaceType property there and working out the conversion.

This is going to get messy given that our color-space converter expects a generic type.

@JimBobSquarePants
Copy link
Member Author

The fix for this (dear reader) will be to update ColorProfileConverter to utilize ICCProfile when provided in the same manner as UniColour and to use the converter whenever a profile is present and we wish to normalize the input on decode. It's not possible to use a single pipeline to handle this.

If anyone has time to make a start on the first part I can run with the second.

@waacton
Copy link
Collaborator

waacton commented Nov 28, 2024

... update ColorProfileConverter to utilize ICCProfile when provided

This sounds like something I'm conceptually familiar with so I'll aim to find some time over the holidays (though if anyone else is keen, please go ahead)

@JimBobSquarePants
Copy link
Member Author

@waacton if you can please do. 👍

@waacton
Copy link
Collaborator

waacton commented Nov 29, 2024

@JimBobSquarePants I've been exploring the ColorProfileConverter code a little bit to get the ball rolling and I think it's going to spawn a lot of questions. Is there anything like an internal wiki that I can dump thoughts into and record the latest understanding, to avoid spamming this PR thread?

The kind of things lurking in my mind are:

  1. Implementation details - ICC profile conversions don't fit neatly into the existing architecture (which I think you alluded to with "It's not possible to use a single pipeline to handle this.")
  2. Validation - I've had a hard time finding a definitive answer for ICC conversions, and ended up jury-rigging something from the DemoIccMAX reference implementation to generate test data. The repo to generate test data is currently private but will become public once it's less painful to use. (Note that these seem to be different than System.Windows.Media.Color.FromValues so I've had to assume the ICC reference implementation is the gold standard here)
  3. Code reuse - it's taken a lot of fiddling in Unicolour to handle edge cases (e.g. adjustments need to be made after the ICC conversion, depending on rendering intent, profile version, and the bit-depth of the LUT). I suspect a direct dependency isn't suitable, except for perhaps a first-pass proof-of-concept, but maybe an almost-verbatim copy of utility classes is? And a conversion comparison with Unicolour in unit tests to spot diverging implementation?
  4. Profile restrictions - there are an unholy number of ICC conversion permutations; my Unicolour implementation only supports a specific subset so that I can focus on getting them right (with the intent of expanding the subset over time). As a result, I'm not familiar with edge cases and adjustments for the scenarios I'm not yet supporting, so there's a higher risk of those conversions being subtly and going undetected. (However, Unicolour technically supports CMYK ⇔ XYZ but I can't find a profile that does this, so I can't generate test data to validate it - just one of many permutations that might not actually be correct 😓)

@JimBobSquarePants
Copy link
Member Author

@JimBobSquarePants I've been exploring the ColorProfileConverter code a little bit to get the ball rolling and I think it's going to spawn a lot of questions. Is there anything like an internal wiki that I can dump thoughts into and record the latest understanding, to avoid spamming this PR thread?

The kind of things lurking in my mind are:

  1. Implementation details - ICC profile conversions don't fit neatly into the existing architecture (which I think you alluded to with "It's not possible to use a single pipeline to handle this.")
  2. Validation - I've had a hard time finding a definitive answer for ICC conversions, and ended up jury-rigging something from the DemoIccMAX reference implementation to generate test data. The repo to generate test data is currently private but will become public once it's less painful to use. (Note that these seem to be different than System.Windows.Media.Color.FromValues so I've had to assume the ICC reference implementation is the gold standard here)
  3. Code reuse - it's taken a lot of fiddling in Unicolour to handle edge cases (e.g. adjustments need to be made after the ICC conversion, depending on rendering intent, profile version, and the bit-depth of the LUT). I suspect a direct dependency isn't suitable, except for perhaps a first-pass proof-of-concept, but maybe an almost-verbatim copy of utility classes is? And a conversion comparison with Unicolour in unit tests to spot diverging implementation?
  4. Profile restrictions - there are an unholy number of ICC conversion permutations; my Unicolour implementation only supports a specific subset so that I can focus on getting them right (with the intent of expanding the subset over time). As a result, I'm not familiar with edge cases and adjustments for the scenarios I'm not yet supporting, so there's a higher risk of those conversions being subtly and going undetected. (However, Unicolour technically supports CMYK ⇔ XYZ but I can't find a profile that does this, so I can't generate test data to validate it - just one of many permutations that might not actually be correct 😓)

Hi @waacton

Thank you for having a look at this and sorry for the slow reply. My computer time is limited currently while I recover from a few "long term super bad posture and I really should know better" injuries.

I had a flash of inspiration the last night and figured out a way to extend the ColorProfileConverter type to defer to ICC profiles for the conversion if present. I'd really love to have your input here.

The gist of it is as follows:

  • By my understanding ICC profile conversion expects all input/output to be normalized to the 0-1 range. In order to facilitate that I created an additional IColorProfile<TSelf> interface which provides methods to normalize to/from the default color scaling to the ICC scaling. I think I've implemented that scaling correctly for each color type but need to write additional tests.
  • Each conversion method in ColorProfileConverter will defer to a new method ConvertUsingIccProfile when required utilizing the new normalization methods to interface with the IccDataToPcsConverter code. I think my implementation there is correct, but this requires validation.
  • This code, like yours supports any permutation of color profile but like you say, there's no example ICC profiles in the wild to cover these test cases.

The type IccProfileConverter will go. The code there was based upon the false premise that I could use our IPixel types but that soon falls down when you hit a profile that is non RGB based. This was, in fact what I was alluding to when I said "It's not possible to use a single pipeline to handle this."

Instead, the plan is to pass any ICC profiles to the converter when we do any color space conversion using it. This code, unfortunately, is scattered through the various formats. I also need to massively simplify the JPEG colorspace conversion code to use only (hopefully Vector<float>) for the SIMD based conversions before I can plumb it in there.

I had a look at the UniColor code earlier and I cannot seem to understand how you normalize values for conversion? It's making me very much doubt myself that my approach is sound. Using the library would be a very good idea for test references.

On tests, we need to test a lot of things. The more test data we can compare against the better.

I just had to disable some tests LutEntryCalculatorTests that I hadn't realized were failing for a long time following a change to use a 1D float for the ClutCalculator. I fixed an obvious issue with the calculator itself in my most recent commit but I'm not sure how the test data was generated and whether it is correct. I compare our conversion code with DemoIccMax and it looks correct.

CC @brianpopow

@waacton
Copy link
Collaborator

waacton commented Dec 4, 2024

sorry for the slow reply

Not a problem in the slightest I'm just trying to get a head start, and I feel a bit bad that you have to keep getting so heavily involved 😅

By my understanding ICC profile conversion expects all input/output to be normalized to the 0-1 range ... I had a look at the UniColor code earlier and I cannot seem to understand how you normalize values for conversion?

Yeah it's not particularly obvious. You can't simply give the ICC LUTs actual LAB values (sometimes PCS adjustment is needed, need to map to [0, 1], and potentially a correction for ICC v2 LAB values) or actual XYZ values (sometimes PCS adjustment is needed, need to map to [0, ~0.5] 😕) so I've tried to isolate everything ICC-related from the pure unrestricted maths of the non-ICC colour spaces, which ends up looking something like:

  1. Unicolour passes XYZ values only to the Unicolour.Icc layer in an attempt to keep things maintainable and protect my sanity (code)
  2. Unicolour.Icc processes XYZ values to meet requirements of the ICC PCS. Right now that means converting it to "IccXyz" or "IccLab" formats as those are the only PCS values I support currently, handling adjustments and mapping for input into ICC LUTs (code & usage)
  3. These "IccXyz" or "IccLab" values are used as input to the ICC LUTs (code)

So I think the normalisation you're looking for is in point 2, and is a layer that you might be missing. Basically, all the glue I use between standard colour spaces and the ICC profile is in Unicolour.Icc.Convert. That whole layer of adjustments was based on figuring out how the DemoIccMAX IccRoundTrip tool worked - hopefully I've not been mislead...

Looking at your latest changes, that was the direction I was trying to go, I just hadn't worked out how - still getting accustomed to the codebase! For what it's worth, I at least had an IccProfile property in ColorConversionOptions 😄.

If this is already able to perform ICC conversions would you object if I

  1. Added some unit tests comparing ImageSharp ICC conversion to Unicolour, with some ICC profiles I'm confident I'm handling well?
  2. Potentially port some of that Unicolour.Icc.Convert code to handle adjustments to/from ICC inputs/outputs?

@waacton
Copy link
Collaborator

waacton commented Dec 5, 2024

One additional question @JimBobSquarePants - the latest update to ColorProfileConverter converts between 2 ICC profiles; does ImageSharp have a use case for ICC profile to non-ICC representation (e.g. ICC ⇔ sRGB), or is always converting from one profile to another sufficient?

@JimBobSquarePants
Copy link
Member Author

Hi @waacton was just about to reply to your message.

Yeah it's not particularly obvious. You can't simply give the ICC LUTs actual LAB values (sometimes PCS adjustment is needed, need to map to [0, 1], and potentially a correction for ICC v2 LAB values) or actual XYZ values (sometimes PCS adjustment is needed, need to map to [0, ~0.5] 😕) so I've tried to isolate everything ICC-related from the pure unrestricted maths of the non-ICC colour spaces, which ends up looking something like:

I'm not sure we're talking about normalization in the same regard. I've covered the LAB/XYZ PCS normalization (I used your code as a reference) but I also meant that the to convert from the data to PCS you would need to normalize to [0-1] scaling. For example, if my RGB component values in the image data are scaled [0-255] I would have to first convert them to [0-1]

  1. Added some unit tests comparing ImageSharp ICC conversion to Unicolour, with some ICC profiles I'm confident I'm handling well?
  2. Potentially port some of that Unicolour.Icc.Convert code to handle adjustments to/from ICC inputs/outputs?

I think 1. is a definite requirement. You've solved the problem for your needs so we should compare to an accessible working solution. I think 2 has already been covered in the code I've written.

does ImageSharp have a use case for ICC profile to non-ICC representation (e.g. ICC ⇔ sRGB), or is always converting from one profile to another sufficient?

That's something I never actually considered. Given the type parameter constraints I think we'd have to write multiple methods to allow the conversion which might get complicated fast. Would complete the API though nicely. Perhaps a nice to have which can be added down the line.

What do you think of the approach I've developed for ColorProfileConverter btw is it sensible? sane?

@waacton
Copy link
Collaborator

waacton commented Dec 6, 2024

@JimBobSquarePants

I think 2 has already been covered in the code I've written

I noticed that a few moments after I sent that message! 🤦

I'm not sure we're talking about normalization in the same regard ... For example, if my RGB component values in the image data are scaled [0-255] I would have to first convert them to [0-1]

Ah yes I see, in that case that goes back to my comment that Unicolour funnels every ICC conversion through my tried-and-tested XYZ colour space (even if it means a redundant conversion, e.g. LAB ⇒ XYZ in Unicolour layer, and then XYZ ⇒ LAB ⇒ IccLab in Unicolour.Icc layer - though in most cases it's needed for white point adjustments anyway). My XYZ implementation is in the 0 - 1 magnitude but it doesn't strictly limit to [0, 1], and it doesn't seem to be a problem for the DemoIccMAX code.

What do you think of the approach I've developed for ColorProfileConverter btw is it sensible? sane?

As a newcomer I found it a bit confusing but it's slowly clicking into place. Things like Cmyk : IColorProfile<Cmyk, Rgb> led me to assume Cmyk conversions need to pass via Rgb space. However the ConvertUsingIccProfile escape hatch helps to clear that up. I can't point fingers, Unicolour has its own complicated system of chaining colour spaces, it's just a hard problem. I think it's as sensible and sane as my own approach; it's either something like this or thousands of lines of conditional statements.

With regards to ICC ⇒ non-ICC, that makes things much easier - and is a big part of what I was struggling with! I agree it's a nice-to-have for a more complete API later on though no need to further complicate matters right now.

I've got a wee demo testing values converting from FOGRA39 to SWOP2006 (and various permutations) using converter.Convert<Cmyk, Cmyk>, and so far they match Unicolour within a tolerance of 0.0000005 ✨. If you're happy with the approach I can extend it to more profiles and try to detect differences that are more than just float vs double precision. Is there a branch I have permissions to push to?

@JimBobSquarePants
Copy link
Member Author

I've got a wee demo testing values converting from FOGRA39 to SWOP2006 (and various permutations) using converter.Convert<Cmyk, Cmyk>, and so far they match Unicolour within a tolerance of 0.0000005 ✨.

This is absolutely the BEST news!!

I've sent you an invite. You should be able to push directly to this branch once accepted.

@CLAassistant
Copy link

CLAassistant commented Dec 7, 2024

CLA assistant check
All committers have signed the CLA.

@waacton
Copy link
Collaborator

waacton commented Dec 7, 2024

Doesn't feel like enough code for all the discussion around it 😄 - let me know if there's anything I need to change to meet the project's standards and best practices. Otherwise if you give the thumbs up, and any opinions about what kind of values to test, I'll expand the tests.

Dare I ask about profiles with non-CMYK data colour spaces? For instance, I've tested Unicolour with a 7-channel profile representing CMYKOGV but that seems pretty niche. But are "input" profiles for things like cameras and scanners something that ImageSharp is likely to encounter?

@JimBobSquarePants
Copy link
Member Author

JimBobSquarePants commented Dec 10, 2024

image

@waacton I did some cleanup and added ICC profiles to LFS since they're quite chunky. It's great to see tests passing!

I think we should try to expand on these tests to cover as many conversion types as possible (e.g CMYK to RGB is important) and then once confident it works, I can start wiring up the transforms to the individual formats.

Dare I ask about profiles with non-CMYK data colour spaces? For instance, I've tested Unicolour with a 7-channel profile representing CMYKOGV but that seems pretty niche. But are "input" profiles for things like cameras and scanners something that ImageSharp is likely to encounter?

This is something that @saucecontrol has brought up before. I don't actually know what we might encounter. AFAIK image formats are very unlikely to contain non-standard profiles but that doesn't mean we should not potentially consider them. At the moment, as you've spotted, we're limited to 4 channels since we use Vector4 for the transforms. Whether it would be possible to use some sort of Channels struct (why 15 colors max, not 16 like a sane thing) and have semi decent conversion performance I do not know.

@waacton
Copy link
Collaborator

waacton commented Dec 10, 2024

@JimBobSquarePants the expanded tests uncovered some tricky things around perceptual rendering intent for v2 profiles (which seems to be the most common type of profile) that I've tried to address. I plan to work on it more later this week but I've focused on getting tests passing over optimised code for now.

Notably I've introduced an extra step in the ICC conversion, where all conversions go via XYZ - even if both profiles have LAB PCS - in case a PCS adjustment needs to be made (performed in XYZ space). Shouldn't be too hard to bypass some of the work when unnecessary if the additional overhead is problematic, just more branching surely.

I also feel like I've butchered the use of vectors a bit, would appreciate a review there and a point in the right direction 🙏

Copy link
Member Author

@JimBobSquarePants JimBobSquarePants left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just added some notes as I'm a little confused. I've followed up with some changes to improve readability when working with Vector4

@@ -33,64 +34,103 @@ internal static TTo ConvertUsingIccProfile<TFrom, TTo>(this ColorProfileConverte
MemoryAllocator = converter.Options.MemoryAllocator,

// TODO: Double check this but I think these are normalized values.
SourceWhitePoint = CieXyz.FromScaledVector4(new(converter.Options.SourceIccProfile.Header.PcsIlluminant, 1F)),
TargetWhitePoint = CieXyz.FromScaledVector4(new(converter.Options.TargetIccProfile.Header.PcsIlluminant, 1F)),
SourceWhitePoint = new CieXyz(converter.Options.SourceIccProfile.Header.PcsIlluminant),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the illuminant values not given using the ICC scaling? I would have assumed they were given we need to pass them as such.

Copy link
Collaborator

@waacton waacton Dec 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has sent me down a rabbit hole and I don't feel any closer to understanding why, but the illuminant values are stored in the header in the range [0, 1], no scaling needed.

I can't find solid information why the scaling is even needed for XYZ LUTs other than "that's what the DemoIccMAX code does". The closest thing I can find in the v4 spec itself is this footnote in Annex F.3 page 102:

NOTE A three-component Matrix-based model can alternatively be represented in a lutAToBType tag with M curves, a matrix with zero offsets, and identity B curves. While the M curves are set to the corresponding TRC curves, matrix values from the three-component Matrix-based model need to be scaled by (32 768/65 535) before being stored in the lutAToBType matrix in order to produce equivalent PCS values. (32 768/65 535) represents the encoding factor for the PCS PCSXYZ encoding.

(The spec is so cumbersome, the information I'm looking for could easily be buried elsewhere...)

At this point I'm assuming either

  • XYZ LUT data is in [0, ~0.5] by convention (or by something in the spec I can't find)
  • XYZ LUT data range is profile-specific, and I've not encountered one that isn't [0, ~0.5] (or DemoIccMAX doesn't account for the possibility)

🤕

One other note, as far as I understand the PCS illuminant must be D50 (in case that enables any further optimisation)

7.2.16 PCS illuminant field (Bytes 68 to 79)

The PCS illuminant field shall contain the nCIEXYZ values of the illuminant of the PCS, encoded as an
XYZNumber. The value, when rounded to four decimals, shall be X = 0,9642, Y = 1,0 and Z = 0,8249.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about it a bit more, it's going to be related to LUTs storing uInt16 [0, 65535] but XYZ values being encoded as s15Fixed16 [-32768, ~32768], and needing to account for that.

IccVersion targetVersion = targetHeader.Version;

// all conversions are funnelled through XYZ in case PCS adjustments need to be made
CieXyz xyz;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading below we only need this if adjustPcsForPerceptual is true. I'd rather avoid the overhead of additional conversions when not necessary. We'll be using this code in our decoder which must be fast.

Copy link
Collaborator

@waacton waacton Dec 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a shot at avoiding the overhead when unnecessary. I expect it will result in functions that look very similar like ConvertIcc() and ConvertIccWithPerceptualAdjustment() - I can't see a natural if (adjustmentNeeded) { PerformExtraStep() } at the moment

{
// Convert from Lab v4 to Lab v2.
pcs = LabToLabV2(pcs);
Vector3 iccXyz = xyz.ToScaledVector4().AsVector128().AsVector3();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we're mixing up normalized and standard values here and it's a little confusing.

// We use the original ref values here...
Vector3 scale = Vector3.One - Vector3.Divide(refBlack.ToVector3(), refWhite.ToVector3());

// But scale them here?
Vector3 offset = refBlack.ToScaledVector4().AsVector128().AsVector3();

I would extract the methods out with an explanation of the theory behind them also. For example, I don't understand why the math for source and targeted PCS adjustments is different. We're going to need to vectorize these also. (Which may mean providing your reference colors as Vector4)

Copy link
Collaborator

@waacton waacton Dec 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, happy to refactor to methods with explanations. I think I need to do some reading on best practices regarding Vectors etc.

CieLab lab = pcsConverter.Convert<CieXyz, CieLab>(in xyz);
pcs = lab.ToScaledVector4();
case IccColorSpaceType.CieLab:
if (sourceConverter.Is16BitLutEntry)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is a 16bit LUT calculator treated differently and why is that not version specific?

Copy link
Collaborator

@waacton waacton Dec 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LAB encodings only changed for 16-bit LUTs from v2 to v4.

The LAB encodings themselves:

  • 16-bit max values for 100 & 127 were FF00 in v2 and became FFFF in v4
  • 8-bit max values for 100 & 127 were FF in v2 and stayed FF in v4

But for the LUTs, the 16-bit type continues to use the legacy encoding:

For colour values that are in the PCSLAB colour space on the PCS side of the tag, this tag uses the legacy 16-
bit PCSLAB encoding defined in Tables 42 and 43, not the 16-bit PCSLAB encoding defined in 6.3.4.2. This
encoding is retained for backwards compatibility with profile version 2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants