Skip to content
40 changes: 36 additions & 4 deletions Tests/LibGfx/TestICCProfile.cpp
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
*/
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

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

We could use GEN to test a different subset on every run. Which means that we should have complete coverage over time.
I'm just thinking out-loud.

Copy link
Contributor Author

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 😅

// 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 = {
Expand Down
13 changes: 7 additions & 6 deletions Userland/Libraries/LibGfx/ICC/Profile.cpp
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
*/
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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()))
Expand Down
146 changes: 134 additions & 12 deletions Userland/Libraries/LibGfx/ICC/TagTypes.h
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
*/
Expand Down Expand Up @@ -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>;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading