-
Notifications
You must be signed in to change notification settings - Fork 3.3k
LibGfx/ICC+icc: Add built-in identity LAB profile using mft1 (Lut8TagData), implement conversion from PCS to it #26452
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
Merged
+262
−29
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
8fb76eb
LibGfx/ICC: Rename Lut{16,8}TagData::evaluate() to evaluate_to_pcs()
nico 85dca07
LibGfx/ICC: Improve comments in Lut16TagData::evaluate_to_pcs()
nico 01835d3
Tests/LibGfx: Rename sRGB-specific tests to have "sRGB" in their name
nico 91f0186
Tests/LibGfx: Add a test for round-tripping through a matrix ICC profile
nico 4478e69
LibGfx/ICC: Add a 3D->ND overload of lerp_nd()
nico 582fb48
LibGfx/ICC: Rename rgb_header() to profile_header()
nico 14384be
LibGfx/ICC+icc: Add a built-in identity LAB profile
nico 5d443c8
LibGfx/ICC: Implement coverting from PCS to Lut8TagData
nico File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| /* | ||
| * Copyright (c) 2023, Nico Weber <[email protected]> | ||
| * Copyright (c) 2023-2025, Nico Weber <[email protected]> | ||
| * | ||
| * SPDX-License-Identifier: BSD-2-Clause | ||
| */ | ||
|
|
@@ -116,7 +116,7 @@ TEST_CASE(built_in_sRGB) | |
| EXPECT(memmem(serialized_bytes.data(), serialized_bytes.size(), sf32, sizeof(sf32)) != nullptr); | ||
| } | ||
|
|
||
| TEST_CASE(to_pcs) | ||
| TEST_CASE(sRGB_to_pcs) | ||
| { | ||
| auto sRGB = MUST(Gfx::ICC::sRGB()); | ||
| EXPECT(sRGB->data_color_space() == Gfx::ICC::ColorSpace::RGB); | ||
|
|
@@ -174,7 +174,7 @@ TEST_CASE(to_pcs) | |
| EXPECT_APPROXIMATE_VECTOR3(xyz_from_sRGB(64, 128, 192), r_xyz * f64 + g_xyz * f128 + b_xyz * f192); | ||
| } | ||
|
|
||
| TEST_CASE(from_pcs) | ||
| TEST_CASE(sRBB_from_pcs) | ||
| { | ||
| auto sRGB = MUST(Gfx::ICC::sRGB()); | ||
|
|
||
|
|
@@ -224,7 +224,7 @@ TEST_CASE(from_pcs) | |
| EXPECT_EQ(sRGB_from_xyz(r_xyz * f64 + g_xyz * f128 + b_xyz * f192), Color(64, 128, 192)); | ||
| } | ||
|
|
||
| TEST_CASE(to_lab) | ||
| TEST_CASE(sRGB_to_lab) | ||
| { | ||
| auto sRGB = MUST(Gfx::ICC::sRGB()); | ||
| auto lab_from_sRGB = [&sRGB](u8 r, u8 g, u8 b) { | ||
|
|
@@ -274,6 +274,38 @@ TEST_CASE(to_lab) | |
| EXPECT_APPROXIMATE_LAB(lab_from_sRGB(255, 255, 255), expected[7]); | ||
| } | ||
|
|
||
| static void test_roundtrip(Gfx::ICC::Profile const& profile) | ||
| { | ||
| // Ideally this would be 1, but that makes tests take a few minutes on fast machine. | ||
| // It's supposed to pass with 1, though. | ||
| int const increment = 7; | ||
| for (int a = 0; a < 256; a += increment) { | ||
| for (int b = 0; b < 256; b += increment) { | ||
| for (int c = 0; c < 256; c += increment) { | ||
| u8 color_in[3] = { static_cast<u8>(a), static_cast<u8>(b), static_cast<u8>(c) }; | ||
| u8 color_out[3]; | ||
| auto pcs = MUST(profile.to_pcs(color_in)); | ||
| MUST(profile.from_pcs(profile, pcs, color_out)); | ||
| EXPECT_EQ(color_in[0], color_out[0]); | ||
| EXPECT_EQ(color_in[1], color_out[1]); | ||
| EXPECT_EQ(color_in[2], color_out[2]); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| TEST_CASE(roundtrip_lab_mft1) | ||
| { | ||
| auto profile = TRY_OR_FAIL(Gfx::ICC::IdentityLAB()); | ||
| test_roundtrip(*profile); | ||
| } | ||
|
|
||
| TEST_CASE(roundtrip_sRGB_matrix_profile) | ||
| { | ||
| auto profile = TRY_OR_FAIL(Gfx::ICC::sRGB()); | ||
| test_roundtrip(*profile); | ||
| } | ||
|
|
||
| TEST_CASE(malformed_profile) | ||
| { | ||
| Array test_inputs = { | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| /* | ||
| * Copyright (c) 2022-2023, Nico Weber <[email protected]> | ||
| * Copyright (c) 2022-2025, Nico Weber <[email protected]> | ||
| * | ||
| * SPDX-License-Identifier: BSD-2-Clause | ||
| */ | ||
|
|
@@ -1282,12 +1282,12 @@ ErrorOr<FloatVector3> Profile::to_pcs_a_to_b(TagData const& tag_data, ReadonlyBy | |
| switch (tag_data.type()) { | ||
| case Lut16TagData::Type: { | ||
| auto const& a_to_b = static_cast<Lut16TagData const&>(tag_data); | ||
| result = TRY(a_to_b.evaluate(data_color_space(), connection_space(), color)); | ||
| result = TRY(a_to_b.evaluate_to_pcs(data_color_space(), connection_space(), color)); | ||
| break; | ||
| } | ||
| case Lut8TagData::Type: { | ||
| auto const& a_to_b = static_cast<Lut8TagData const&>(tag_data); | ||
| result = TRY(a_to_b.evaluate(data_color_space(), connection_space(), color)); | ||
| result = TRY(a_to_b.evaluate_to_pcs(data_color_space(), connection_space(), color)); | ||
| break; | ||
| } | ||
| case LutAToBTagData::Type: { | ||
|
|
@@ -1494,9 +1494,10 @@ ErrorOr<void> Profile::from_pcs_b_to_a(TagData const& tag_data, FloatVector3 con | |
| case Lut16TagData::Type: | ||
| // FIXME | ||
| return Error::from_string_literal("ICC::Profile::to_pcs: BToA*Tag handling for mft2 tags not yet implemented"); | ||
| case Lut8TagData::Type: | ||
| // FIXME | ||
| return Error::from_string_literal("ICC::Profile::to_pcs: BToA*Tag handling for mft1 tags not yet implemented"); | ||
| case Lut8TagData::Type: { | ||
| auto const& a_to_b = static_cast<Lut8TagData const&>(tag_data); | ||
| return a_to_b.evaluate_from_pcs(connection_space(), data_color_space(), pcs, out_bytes); | ||
| } | ||
| case LutBToATagData::Type: { | ||
| auto const& b_to_a = static_cast<LutBToATagData const&>(tag_data); | ||
| if (b_to_a.number_of_input_channels() != number_of_components_in_color_space(connection_space())) | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| /* | ||
| * Copyright (c) 2023, Nico Weber <[email protected]> | ||
| * Copyright (c) 2023-2025, Nico Weber <[email protected]> | ||
| * | ||
| * SPDX-License-Identifier: BSD-2-Clause | ||
| */ | ||
|
|
@@ -63,6 +63,33 @@ inline FloatVector3 lerp_nd(Function<unsigned(size_t)> size, Function<FloatVecto | |
| return sample_output; | ||
| } | ||
|
|
||
| // Same as above, but 3D->ND instead of ND->3D. | ||
| inline void lerp_nd(IntVector3 size, Function<void(IntVector3 const&, Span<float>)> sample, FloatVector3 const& x, Span<float> scratch, Span<float> out) | ||
| { | ||
| unsigned left_index[3]; | ||
| float factor[3]; | ||
| for (size_t i = 0; i < 3; ++i) { | ||
| unsigned n = size[i] - 1; | ||
| float ec = x[i] * n; | ||
| left_index[i] = min(static_cast<unsigned>(ec), n - 1); | ||
| factor[i] = ec - left_index[i]; | ||
| } | ||
|
|
||
| out.fill(0.0f); | ||
| // The i'th bit of mask indicates if the i'th coordinate is rounded up or down. | ||
| IntVector3 coordinates; | ||
| for (size_t mask = 0; mask < (1u << 3); ++mask) { | ||
| float sample_weight = 1.0f; | ||
| for (size_t i = 0; i < 3; ++i) { | ||
| coordinates[i] = left_index[i] + ((mask >> i) & 1u); | ||
| sample_weight *= ((mask >> i) & 1u) ? factor[i] : 1.0f - factor[i]; | ||
| } | ||
| sample(coordinates, scratch); | ||
| for (size_t i = 0; i < out.size(); ++i) | ||
| out[i] += scratch[i] * sample_weight; | ||
| } | ||
| } | ||
|
|
||
| using S15Fixed16 = FixedPoint<16, i32>; | ||
| using U16Fixed16 = FixedPoint<16, u32>; | ||
|
|
||
|
|
@@ -317,7 +344,9 @@ class Lut16TagData : public TagData { | |
| Vector<u16> const& clut_values() const { return m_clut_values; } | ||
| Vector<u16> const& output_tables() const { return m_output_tables; } | ||
|
|
||
| ErrorOr<FloatVector3> evaluate(ColorSpace input_space, ColorSpace connection_space, ReadonlyBytes) const; | ||
| // FIXME: If we add DeviceLink support, this can become an arbitrary nD -> nD transform. | ||
| // For now, we only have nD -> 3D. | ||
| ErrorOr<FloatVector3> evaluate_to_pcs(ColorSpace input_space, ColorSpace connection_space, ReadonlyBytes) const; | ||
|
|
||
| private: | ||
| EMatrix3x3 m_e; | ||
|
|
@@ -376,7 +405,10 @@ class Lut8TagData : public TagData { | |
| Vector<u8> const& clut_values() const { return m_clut_values; } | ||
| Vector<u8> const& output_tables() const { return m_output_tables; } | ||
|
|
||
| ErrorOr<FloatVector3> evaluate(ColorSpace input_space, ColorSpace connection_space, ReadonlyBytes) const; | ||
| // FIXME: If we add DeviceLink support, this can become an arbitrary nD -> nD transform. | ||
| // For now, 3D -> nD and nD -> 3D is sufficient. | ||
| ErrorOr<FloatVector3> evaluate_to_pcs(ColorSpace input_space, ColorSpace connection_space, ReadonlyBytes) const; | ||
| ErrorOr<void> evaluate_from_pcs(ColorSpace connection_space, ColorSpace output_space, FloatVector3, Bytes) const; | ||
|
|
||
| private: | ||
| EMatrix3x3 m_e; | ||
|
|
@@ -1035,7 +1067,7 @@ class XYZTagData : public TagData { | |
| Vector<XYZ, 1> m_xyzs; | ||
| }; | ||
|
|
||
| inline ErrorOr<FloatVector3> Lut16TagData::evaluate(ColorSpace input_space, ColorSpace connection_space, ReadonlyBytes color_u8) const | ||
| inline ErrorOr<FloatVector3> Lut16TagData::evaluate_to_pcs(ColorSpace input_space, ColorSpace connection_space, ReadonlyBytes color_u8) const | ||
| { | ||
| // See comment at start of LutAToBTagData::evaluate() for the clipping flow. | ||
| VERIFY(connection_space == ColorSpace::PCSXYZ || connection_space == ColorSpace::PCSLAB); | ||
|
|
@@ -1044,15 +1076,15 @@ inline ErrorOr<FloatVector3> Lut16TagData::evaluate(ColorSpace input_space, Colo | |
| // FIXME: This will be wrong once Profile::from_pcs_b_to_a() calls this function too. | ||
| VERIFY(number_of_output_channels() == 3); | ||
|
|
||
| // ICC v4, 10.11 lut8Type | ||
| // ICC v4, 10.10 lut16Type | ||
| // "Data is processed using these elements via the following sequence: | ||
| // (matrix) ⇨ (1d input tables) ⇨ (multi-dimensional lookup table, CLUT) ⇨ (1d output tables)" | ||
| // (matrix) ⇨ (1D input tables) ⇨ (multi-dimensional lookup table, CLUT) ⇨ (1D output tables)" | ||
|
|
||
| Vector<float, 4> color; | ||
| for (u8 c : color_u8) | ||
| color.append(c / 255.0f); | ||
|
|
||
| // "3 x 3 matrix (which shall be the identity matrix unless the input colour space is PCSXYZ)" | ||
| // "The matrix shall be an identity matrix unless the input is in the PCSXYZ colour space." | ||
| // In practice, it's usually RGB or CMYK. | ||
| if (input_space == ColorSpace::PCSXYZ) { | ||
| EMatrix3x3 const& e = m_e; | ||
|
|
@@ -1112,24 +1144,24 @@ inline ErrorOr<FloatVector3> Lut16TagData::evaluate(ColorSpace input_space, Colo | |
| // shall be clipped on a per-component basis." | ||
| output_color *= 65535.0f / 65280.0f; | ||
|
|
||
| // Table 42 — Legacy PCSLAB L* encoding | ||
| // Table 12 — PCSLAB L* encoding | ||
| // (The multiplication above and using Table 12 is equivalent to using Table 42 — Legacy PCSLAB L* encoding.) | ||
| output_color[0] = clamp(output_color[0] * 100.0f, 0.0f, 100.0f); | ||
|
|
||
| // Table 43 — Legacy PCSLAB a* or PCSLAB b* encoding | ||
| // Table 13 — PCSLAB a* or PCSLAB b* encoding | ||
| // (The multiplication above and using Table 12 is equivalent to using Table 43 — Legacy PCSLAB a* or PCSLAB b* encoding.) | ||
| output_color[1] = clamp(output_color[1] * 255.0f - 128.0f, -128.0f, 127.0f); | ||
| output_color[2] = clamp(output_color[2] * 255.0f - 128.0f, -128.0f, 127.0f); | ||
| } | ||
|
|
||
| return output_color; | ||
| } | ||
|
|
||
| inline ErrorOr<FloatVector3> Lut8TagData::evaluate(ColorSpace input_space, ColorSpace connection_space, ReadonlyBytes color_u8) const | ||
| inline ErrorOr<FloatVector3> Lut8TagData::evaluate_to_pcs(ColorSpace input_space, ColorSpace connection_space, ReadonlyBytes color_u8) const | ||
| { | ||
| // See comment at start of LutAToBTagData::evaluate() for the clipping flow. | ||
| VERIFY(connection_space == ColorSpace::PCSXYZ || connection_space == ColorSpace::PCSLAB); | ||
| VERIFY(number_of_input_channels() == color_u8.size()); | ||
|
|
||
| // FIXME: This will be wrong once Profile::from_pcs_b_to_a() calls this function too. | ||
| VERIFY(number_of_output_channels() == 3); | ||
|
|
||
| // ICC v4, 10.11 lut8Type | ||
|
|
@@ -1202,6 +1234,96 @@ inline ErrorOr<FloatVector3> Lut8TagData::evaluate(ColorSpace input_space, Color | |
| return output_color; | ||
| } | ||
|
|
||
| inline ErrorOr<void> Lut8TagData::evaluate_from_pcs(ColorSpace connection_space, ColorSpace output_space, FloatVector3 pcs, Bytes color_u8) const | ||
| { | ||
| // This is very similar to Lut8TagData::evaluate_from_pcs(), but instead of converting from device space to PCS, | ||
| // it converts from PCS to device space. | ||
| VERIFY(connection_space == ColorSpace::PCSXYZ || connection_space == ColorSpace::PCSLAB); | ||
| VERIFY(number_of_input_channels() == 3); | ||
| VERIFY(number_of_output_channels() == color_u8.size()); | ||
|
|
||
| // ICC v4, 10.11 lut8Type | ||
| // "Data is processed using these elements via the following sequence: | ||
| // (matrix) ⇨ (1d input tables) ⇨ (multi-dimensional lookup table, CLUT) ⇨ (1d output tables)" | ||
|
|
||
| if (connection_space == ColorSpace::PCSXYZ) { | ||
| // "An 8-bit PCSXYZ encoding has not been defined, so the interpretation of a lut8Type in a profile that uses PCSXYZ is implementation specific." | ||
| } else { | ||
| VERIFY(connection_space == ColorSpace::PCSLAB); | ||
|
|
||
| // ICC v4, 6.3.4.2 General PCS encoding | ||
| // Table 12 — PCSLAB L* encoding | ||
| pcs[0] = clamp(pcs[0] / 100.0f, 0.0f, 1.0f); | ||
|
|
||
| // Table 13 — PCSLAB a* or PCSLAB b* encoding | ||
| pcs[1] = clamp((pcs[1] + 128.0f) / 255.0f, 0.0f, 1.0f); | ||
| pcs[2] = clamp((pcs[2] + 128.0f) / 255.0f, 0.0f, 1.0f); | ||
| } | ||
|
|
||
| // "3 x 3 matrix (which shall be the identity matrix unless the input colour space is PCSXYZ)" | ||
| // Since "An 8-bit PCSXYZ encoding has not been defined", this should never happen in practice. | ||
| if (connection_space == ColorSpace::PCSXYZ) { | ||
| EMatrix3x3 const& e = m_e; | ||
| pcs = FloatVector3 { | ||
| (float)e[0] * pcs[0] + (float)e[1] * pcs[1] + (float)e[2] * pcs[2], | ||
| (float)e[3] * pcs[0] + (float)e[4] * pcs[1] + (float)e[5] * pcs[2], | ||
| (float)e[6] * pcs[0] + (float)e[7] * pcs[1] + (float)e[8] * pcs[2], | ||
| }; | ||
| } | ||
|
|
||
| // "The input tables are arrays of uInt8Number values. Each input table consists of 256 uInt8Number integers. | ||
| // Each input table entry is appropriately normalized to the range 0 to 255. | ||
| // The inputTable is of size (InputChannels x 256) bytes. | ||
| // When stored in this tag, the one-dimensional lookup tables are packed one after another" | ||
| for (size_t c = 0; c < 3; ++c) | ||
| pcs[c] = lerp_1d(m_input_tables.span().slice(c * 256, 256), pcs[c]) / 255.0f; | ||
|
|
||
| // "The CLUT is organized as an i-dimensional array with a given number of grid points in each dimension, | ||
| // where i is the number of input channels (input tables) in the transform. | ||
| // The dimension corresponding to the first input channel varies least rapidly and | ||
| // the dimension corresponding to the last input channel varies most rapidly. | ||
| // Each grid point value is an o-byte array, where o is the number of output channels. | ||
| // The first sequential byte of the entry contains the function value for the first output function, | ||
| // the second sequential byte of the entry contains the function value for the second output function, | ||
| // and so on until all the output functions have been supplied." | ||
| auto sample = [this](IntVector3 const& coordinates, Span<float> out) { | ||
| size_t stride = out.size(); | ||
| size_t offset = 0; | ||
| for (int i = 3 - 1; i >= 0; --i) { | ||
| offset += coordinates[i] * stride; | ||
| stride *= m_number_of_clut_grid_points; | ||
| } | ||
| for (size_t c = 0; c < out.size(); ++c) | ||
| out[c] = (float)m_clut_values[offset + c]; | ||
| }; | ||
|
|
||
| Vector<float, 4> scratch; | ||
| Vector<float, 4> color; | ||
| scratch.resize(number_of_output_channels()); | ||
| color.resize(number_of_output_channels()); | ||
|
|
||
| lerp_nd({ m_number_of_clut_grid_points, m_number_of_clut_grid_points, m_number_of_clut_grid_points }, move(sample), pcs, scratch.span(), color.span()); | ||
|
|
||
| // "The output tables are arrays of uInt8Number values. Each output table consists of 256 uInt8Number integers. | ||
| // Each output table entry is appropriately normalized to the range 0 to 255. | ||
| // The outputTable is of size (OutputChannels x 256) bytes. | ||
| // When stored in this tag, the one-dimensional lookup tables are packed one after another" | ||
| for (u8 c = 0; c < color.size(); ++c) | ||
| color[c] = lerp_1d(m_output_tables.span().slice(c * 256, 256), color[c] / 255.0f) / 255.0f; | ||
|
|
||
| // Since the LUTs assume that everything's in 0..1 and we assume that's mapped linearly to bytes, | ||
| // we don't need to look at output_space. | ||
| // 6.5 Device encoding | ||
| // "The specification of device value encoding is determined by the device. Normally, device values in the range of | ||
| // 0,0 to 1,0 are encoded using a 0 to 255 (FFh) range when using 8 bits" | ||
| (void)output_space; | ||
|
|
||
| for (u8 c = 0; c < color_u8.size(); ++c) | ||
| color_u8[c] = round_to<u8>(clamp(color[c] * 255.0f, 0.0f, 255.0f)); | ||
|
|
||
| return {}; | ||
| } | ||
|
|
||
| inline ErrorOr<FloatVector3> LutAToBTagData::evaluate(ColorSpace connection_space, ReadonlyBytes color_u8) const | ||
| { | ||
| VERIFY(connection_space == ColorSpace::PCSXYZ || connection_space == ColorSpace::PCSLAB); | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could use
GENto test a different subset on every run. Which means that we should have complete coverage over time.I'm just thinking out-loud.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a big fan of deterministic behavior 😅