Skip to content

Conversation

@nico
Copy link
Contributor

@nico nico commented Dec 6, 2025

This is similar to #26452, but for Lut16TagData instead of for Lut8TagData. Lut16TagData supports both PCSXYZ and PCSLAB, so this adds one built-in profile each.

The XYZ profile identity-maps PCSXYZ to nCIEXYZ.

This allows converting byte-encoded LAB values to XYZ:

% echo '255, 0, 128' | \
      icc -n XYZ --stdin-u8-to-pcs
pcsxyz(1.9999695, 0, 1.0039062)

It can also be written to a file, for other tools to use it:

icc -n XYZ --reencode-to serenity-XYZ.icc

(...or you can then run icc serenity-XYZ.icc instead of
icc -n XYZ if you want to dump it from a file instead of from
memory, for some reason.)

This profile uses mft2 (16-bit lut) because no mft1 (8-bit lut) PCSXYZ
encoding exists. That's probably because XYZ is a linear space, and for
linear spaces 8 bits isn't enough resolution.

This profile is a bit weird!

  • We map the result to 8-bit values for display in icc.
    We store PCS values as floats, so when converting to a space with
    gamma, getting 8 bits out is fine, since we convert to 8 bits after
    all the other conversion math. But here the final data color
    space is also XYZ, and now we invented our own 8-bit XYZ encoding
    by just linearly mapping the ICC 16-bit XYZ encoding to it.

  • Since this is an identity mapping, it leaks the spec's internal PCSXYZ
    encoding into the output data:
    0x0000 maps to 0.0f per spec, which for us becomes 0x00
    0x8000 maps to 1.0f per spec, which for us is not representable
    (0x80 corresponds to 0x8080)
    0xFFFF maps to 1.0 + (32767.0 / 32768.0) == 1.999969f (for us, 0xFF)

To repeat, 0xFF maps to 1.999969f.

We could use a linear ramp to map 0x00..0xff to 0.0f..1.0f, but
then the table wouldn't be its own inverse, and directly exposing
the actual PCSXYZ encoding is nice for testing.

In other words, ICC profiles are data-encoding-dependent. One could also
make a profile that maps 0x00..0xff to red 0.25..0.5 instead of to
0.0..1.0 (if one only needed red values in that range and wanted more
resolution in that range).

Anyways, this profile is probably mostly useful for testing.

The LAB_mft2 profile is even weirder, mostly because it's different from the the existing (mft1) LAB profile.

mft2 uses a "legacy 16-bit PCSLAB encoding" instead of the "normal"
16-bit ICC PCSLAB encoding, and this being an identity mapping leaks
this into the outputs. We again linearly map this internal 16-bit
encoding to 8 bits.

This legacy encoding maps 0 to 0.0 and FF00 to 1.0 for L
(and FFFF to a bit more than 1.0), and 0 to -128.0 and FF00 to
127.0 for a* and b* (and FFFF to a bit more than 127.0).

This means this produces different values than our built-in LAB mft1
profile:

% echo 'pcslab(100, 127, -128)' | \
      icc -n LAB_mft2 --stdin-u8-from-pcs | \
      icc -n LAB --stdin-u8-to-pcs
pcslab(99.60784, 126, -128)

Staying in LAB for both steps is exact, of course:

% echo 'pcslab(100, 127, -128)' | \
      icc -n LAB --stdin-u8-from-pcs | \
      icc -n LAB --stdin-u8-to-pcs
pcslab(100, 127, -128)

Staying in LAB_mft2 for both steps is also exact, up to u8
resolution (100 and 127 don't exactly map to an u8 value with
our "16-bit legacy PCSLAB encoding mapped to u8" encoding):

% echo 'pcslab(100, 127, -128)' | \
      icc -n LAB_mft2 --stdin-u8-from-pcs | \
      icc -n LAB_mft2 --stdin-u8-to-pcs
pcslab(99.99693, 126.99219, -128)

This is just because the same PCSLAB value is encoded slightly
differently in the mft1 and mft2 LAB encodings:

% echo 'pcslab(100, 127, -128)' | \
      icc -n LAB --stdin-u8-from-pcs
255, 255, 0

% echo 'pcslab(100, 127, -128)' | \
      icc -n LAB_mft2 --stdin-u8-from-pcs
254, 254, 0

(There's no XYZ mft1 encoding, so at least we don't have mft1 and mft2
profiles that have different output.))

Use this profile to add a roundtrip test for Lut16TagData when PCS
is PCSLAB.

@github-actions github-actions bot added the 👀 pr-needs-review PR needs review from a maintainer or community member label Dec 6, 2025
@nico
Copy link
Contributor Author

nico commented Dec 6, 2025

Thanks to #26459, this also means image can now convert to profiles that use mft2 for the PCS->data transform. That's rare, but it does happen. If you have Photoshop, these two use mft2 for the back transform:

/Library/Application Support/Adobe/Color/Profiles/Photoshop5DefaultCMYK.icc
/Library/Application Support/Adobe/Color/Profiles/Photoshop4DefaultCMYK.icc

The "PNG image with embedded color lookup table profile (has fallback matrix tags)" image on https://displaycal.net/icc-color-management-test/ also embeds a profile with mft2 BToA*Tags.

Trying something similar to #26459 (comment) with Photoshop5DefaultCMYK.icc:

% Build/lagom/bin/image -o burst-out-serenity-cmyk-mft2.tiff \
    --assign-color-profile serenity-sRGB.icc \
    --convert-to-color-profile '/Library/Application Support/Adobe/Color/Profiles/Photoshop5DefaultCMYK.icc' \
    burst.png

% Build/lagom/bin/image -o burst-out-serenity-rgb-mft2.webp \
    --convert-to-color-profile serenity-sRGB.icc \
    burst-out-serenity-cmyk-mft2.tiff
% magick burst-out-magick-embed-srgb.png \
    -profile  '/Library/Application Support/Adobe/Color/Profiles/Photoshop5DefaultCMYK.icc' \
    burst-out-magick-cmyk-mft2.tiff

% magick burst-out-magick-cmyk-mft2.tiff \
    -profile serenity-sRGB.icc \
    burst-out-magick-rgb-mft2.png
% Build/lagom/bin/imgcmp --stats \   
    burst-out-serenity-rgb-mft2.webp \
    burst-out-magick-rgb-mft2.png
number of differing pixels: 9529914 (56.80%)
max error R:  253, G:  241, B:  240
avg error R: 70.16, G: 64.93, B: 54.43
max error at (3572, 2110): rgb(0, 0, 0) vs rgb(253, 239, 94)

Huh, this looks pretty off, actually! And visually looking at the output, burst-out-serenity-rgb-mft2.webp has quite a few black pixels that shouldn't be there.

So more to do here.

@nico
Copy link
Contributor Author

nico commented Dec 6, 2025

diff --git a/Userland/Libraries/LibGfx/ICC/TagTypes.h b/Userland/Libraries/LibGfx/ICC/TagTypes.h
index 00bb11e5bcd..22fcf4fff62 100644
--- a/Userland/Libraries/LibGfx/ICC/TagTypes.h
+++ b/Userland/Libraries/LibGfx/ICC/TagTypes.h
@@ -1232,7 +1232,7 @@ inline ErrorOr<void> Lut16TagData::evaluate_from_pcs(ColorSpace connection_space
     //  Each output table entry is appropriately normalized to the range 0 to 65535.
     //  The outputTable is of size (OutputChannels x outputTableEntries x 2) bytes.
     //  When stored in this tag, the one-dimensional lookup tables are packed one after another"
-    for (u8 c = 0; c < 3; ++c)
+    for (u8 c = 0; c < color.size(); ++c)
         color[c] = lerp_1d(m_output_tables.span().slice(c * m_number_of_output_table_entries, m_number_of_output_table_entries), color[c] / 65535.0f) / 65535.0f;
 
     // Since the LUTs assume that everything's in 0..1 and we assume that's mapped linearly to bytes,
% Build/lagom/bin/imgcmp  \          
    burst-out-serenity-rgb-mft2.webp \
    burst-out-magick-rgb-mft2.png
number of differing pixels: 2867000 (17.09%)
max error R:   11, G:    8, B:    9
avg error R: 0.08, G: 0.05, B: 0.08
max error at (1026, 637): rgb(0, 166, 77) vs rgb(11, 166, 77)
first difference at (2, 0): rgb(156, 117, 22) vs rgb(156, 117, 21)

Good enough for now!

@nico nico force-pushed the icc-mft2-from-pcs branch from c25bb83 to a70bf71 Compare December 6, 2025 15:32
nico added 3 commits December 6, 2025 11:43
This profile identity-maps PCSXYZ to nCIEXYZ.

This allows converting byte-encoded LAB values to XYZ:

    % echo '255, 0, 128' | \
          icc -n XYZ --stdin-u8-to-pcs
    pcsxyz(1.9999695, 0, 1.0039062)

It can also be written to a file, for other tools to use it:

    icc -n XYZ --reencode-to serenity-XYZ.icc

(...or you can then run `icc serenity-XYZ.icc` instead of
`icc -n XYZ` if you want to dump it from a file instead of from
memory, for some reason.)

This profile uses mft2 (16-bit lut) because no mft1 (8-bit lut) PCSXYZ
encoding exists. That's probably because XYZ is a linear space, and for
linear spaces 8 bits isn't enough resolution.

This profile is a bit weird!

- *We* map the result to 8-bit values for display in `icc`.
  We store PCS values as floats, so when converting to a space with
  gamma, getting 8 bits out is fine, since we convert to 8 bits after
  all the other conversion math. But here the final data color
  space is also XYZ, and now we invented our own 8-bit XYZ encoding
  by just linearly mapping the ICC 16-bit XYZ encoding to it.

- Since this is an identity mapping, it leaks the spec's internal PCSXYZ
  encoding into the output data:
  0x0000 maps to 0.0f per spec, which for us becomes 0x00
  0x8000 maps to 1.0f per spec, which for us is not representable
                                (0x80 corresponds to 0x8080)
  0xFFFF maps to 1.0 + (32767.0 / 32768.0) == 1.999969f (for us, 0xFF)

To repeat, 0xFF maps to 1.999969f.

We could use a linear ramp to map 0x00..0xff to 0.0f..1.0f, but
then the table wouldn't be its own inverse, and directly exposing
the actual PCSXYZ encoding is nice for testing.

In other words, ICC profiles are data-encoding-dependent. One could also
make a profile that maps 0x00..0xff to red 0.25..0.5 instead of to
0.0..1.0 (if one only needed red values in that range and wanted more
resolution in that range).

Anyways, this profile is probably mostly useful for testing.
Makes this work:

    % echo 'pcsxyz(1.0, 0.0, 0.5)' | \
          Build/lagom/bin/icc -n XYZ --stdin-u8-from-pcs
    128, 0, 64

(Note that 1.0 maps to 128, as mentioned in the previous commit
message.)

Also makes it possible to write a roundtrip test for the
IdentityXYZ_D50() profile, so do so.

This also allows doing roundabout lab->xyz conversion (albeit filtered
through u8 resolution):

    % echo 'pcslab(100, -128, 127)' | \
          icc -n XYZ --stdin-u8-from-pcs | \
          icc -n XYZ --stdin-u8-to-pcs
    pcsxyz(0.3999939, 1.0039062, 0.039215088)
This is even weirder than the XYZ mft2 profile added in an
earlier commit in this PR:

mft2 uses a "legacy 16-bit PCSLAB encoding" instead of the "normal"
16-bit ICC PCSLAB encoding, and this being an identity mapping leaks
this into the outputs. We again linearly map this internal 16-bit
encoding to 8 bits.

This legacy encoding maps 0 to 0.0 and FF00 to 1.0 for L
(and FFFF to a bit more than 1.0), and 0 to -128.0 and FF00 to
127.0 for a* and b* (and FFFF to a bit more than 127.0).

This means this produces different values than our built-in LAB mft1
profile:

    % echo 'pcslab(100, 127, -128)' | \
          icc -n LAB_mft2 --stdin-u8-from-pcs | \
          icc -n LAB --stdin-u8-to-pcs
    pcslab(99.60784, 126, -128)

Staying in LAB for both steps is exact, of course:

    % echo 'pcslab(100, 127, -128)' | \
          icc -n LAB --stdin-u8-from-pcs | \
          icc -n LAB --stdin-u8-to-pcs
    pcslab(100, 127, -128)

Staying in LAB_mft2 for both steps is also exact, up to u8
resolution (100 and 127 don't exactly map to an u8 value with
our "16-bit legacy PCSLAB encoding mapped to u8" encoding):

    % echo 'pcslab(100, 127, -128)' | \
          icc -n LAB_mft2 --stdin-u8-from-pcs | \
          icc -n LAB_mft2 --stdin-u8-to-pcs
    pcslab(99.99693, 126.99219, -128)

This is just because the same PCSLAB value is encoded slightly
differently in the mft1 and mft2 LAB encodings:

    % echo 'pcslab(100, 127, -128)' | \
          icc -n LAB --stdin-u8-from-pcs
    255, 255, 0

    % echo 'pcslab(100, 127, -128)' | \
          icc -n LAB_mft2 --stdin-u8-from-pcs
    254, 254, 0

(There's no XYZ mft1 encoding, so at least we don't have mft1 and mft2
profiles that have different output.)

Use this profile to add a roundtrip test for Lut16TagData when PCS
is PCSLAB.
@nico nico force-pushed the icc-mft2-from-pcs branch from a70bf71 to a07915d Compare December 6, 2025 16:58
@nico nico merged commit 76de952 into SerenityOS:master Dec 7, 2025
12 checks passed
@nico nico deleted the icc-mft2-from-pcs branch December 7, 2025 14:02
@github-actions github-actions bot removed the 👀 pr-needs-review PR needs review from a maintainer or community member label Dec 7, 2025
nico added a commit to nico/serenity that referenced this pull request Dec 10, 2025
These are really only useful for testing, so don't mention them
in `icc --help` output for `-n`.

They're supposed to behave identically to the built-in XYZ and
LAB profiles (but not the LAB_mft2 profile, which uses a slightly
different internal encoding, see SerenityOS#26462. The new XYZ profiles added
here behave like the XYZ profile described there, but the new LAB
profiles do not behave like the LAB_mft2 profile described there,
but like the LAB (no _mft2) profile:

    % echo 'pcslab(100, 127, -128)' | \
          icc -n LAB_mABmBA_u8_clut --stdin-u8-from-pcs | \
          icc -n LAB --stdin-u8-to-pcs
    pcslab(100, 127, -128)

    % echo 'pcslab(100, 127, -128)' | \
          icc -n LAB_mABmBA_u16_clut --stdin-u8-from-pcs | \
          icc -n LAB --stdin-u8-to-pcs
    pcslab(100, 127, -128)

    % echo 'pcslab(100, 127, -128)' | \
          icc -n LAB_mABmBA_no_clut --stdin-u8-from-pcs | \
          icc -n LAB --stdin-u8-to-pcs
    pcslab(100, 127, -128)

The profiles without a CLUT already round-trip fine, so add
tests for those.
nico added a commit to nico/serenity that referenced this pull request Dec 10, 2025
Similar to what SerenityOS#26452 did for Lut8TagData and SerenityOS#26462 for Lut16TagData.

With this, we can convert from PCS to all tag types :^)

Which means our ICC implementation is now fairly complete! We don't
support DeviceLink or Abstract profile types (both don't appear in
images; both are arguably useful in some not-embedded-in-images
scenarios), nor the optiona BToD / DToB tags, and are missing
some other minor things (like grayscale profiles -- but not much
is missing here). But overall, this is now a fairly complete basic
ICC implementation :^)
nico added a commit to nico/serenity that referenced this pull request Dec 10, 2025
Similar to what SerenityOS#26452 did for Lut8TagData and SerenityOS#26462 for Lut16TagData.

With this, we can convert from PCS to all tag types :^)

Which means our ICC implementation is now fairly complete! We don't
support DeviceLink or Abstract profile types (both don't appear in
images; both are arguably useful in some not-embedded-in-images
scenarios), nor the optiona BToD / DToB tags, and are missing
some other minor things (like grayscale profiles -- but not much
is missing here). But overall, this is now a fairly complete (if slow)
basic ICC implementation :^)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant