Repository navigation
Consolidate CIELab↔RGB conversion on DCMTK's IODCIELabUtil and drop dcmqi's ColorUtilities #563
Description
Summary
dcmqi currently carries two independent CIELab↔sRGB implementations and uses both, in different code paths. I propose we consolidate on DCMTK's IODCIELabUtil and delete dcmqi's ColorUtilities.
Beyond the redundancy, the investigation that prompted this turned up a concrete correctness problem: the dcmqi implementation does not round-trip, while DCMTK's does exactly. Details and measurements below.
Current situation
1. dcmqi's own implementation — include/dcmqi/ColorUtilities.h + libsrc/ColorUtilities.cpp, a C++ port of @dclunie's PixelMed ColorUtilities (the Java source is kept in util/ColorUtilities.java). Used on the primary conversion paths:
libsrc/Itk2DicomConverter.cpp#L323—getIntegerScaledCIELabPCSFromSRGB(), writing Recommended Display CIELab Value per segmentlibsrc/Dicom2ItkConverterBase.cpp#L269—getSRGBFromIntegerScaledCIELabPCS(), reading the color back out into the JSON metadata
2. DCMTK's IODCIELabUtil (dcmtk/dcmiod/cielabutil.h). Used in exactly one place:
libsrc/Bin2Label.cpp#L704—IODCIELabUtil::dicomLab2RGB(), building the Palette Color LUT for binary→labelmap conversion in PALETTE color model
So a SEG converted to labelmap/PALETTE gets its colors through DCMTK, while the same colors written on the way in, and read back into JSON, go through dcmqi's code. Two code paths, two implementations, same DICOM attribute.
dcmtk/dcmiod/cielabutil.h is additionally #included in Dicom2ItkConverterLabel.cpp#L7 and Dicom2ItkConverterBin.cpp#L8, but neither file calls anything from it — dead includes.
How we got here
ColorUtilities was introduced in ab6a207 (Dec 2020), "ENH: switch to CIELab conversion from @dclunie", with the rationale:
round-trip conversion fails using DCMTK routines
That diagnosis appears to have been mistaken. The DCMTK call sites that were replaced looked like this:
double rgbDouble[3] = {double(rgb[0])/256., double(rgb[1])/256., double(rgb[2])/256.};
IODCIELabUtil::rgb2DicomLab(cielab[0], cielab[1], cielab[2], rgbDouble[0], rgbDouble[1], rgbDouble[2]);
// ...
IODCIELabUtil::dicomLab2RGB(rgb[0], rgb[1], rgb[2], ciedcm[0], ciedcm[1], ciedcm[2]);
rgb[0] = unsigned(rgb[0]*256);IODCIELabUtil expects RGB normalised to 0..1, i.e. division by 255, not 256 — and the return path truncates with unsigned(...) rather than rounding. That scaling error and truncation, in dcmqi's own glue code, is what broke the round trip. DCMTK's conversion math was not at fault.
Measurements
Exhaustive sweep over all 16,777,216 RGB triples, round-tripping sRGB → DICOM-scaled CIELab → sRGB, linking the real ColorUtilities.cpp and the real libdcmiod (DCMTK 3.6.8):
| Implementation | Max error | Triples not recovered exactly |
|---|---|---|
dcmqi ColorUtilities (current master) |
114 | 229,690 (1.37%) |
DCMTK IODCIELabUtil, /255 + rounding |
0 | 0 (0.00%) |
DCMTK IODCIELabUtil, /256 + truncation (as used pre-2020) |
1 | 2,255,929 (13.45%) |
The third row reproduces the 2020 observation and confirms its cause: an off-by-one on 13% of values, entirely attributable to the /256 + truncation glue, and bounded at 1 unit. Used correctly, DCMTK recovers every one of the 16.7M triples exactly.
Forward agreement between the two implementations is otherwise fine — max difference 2/65535 in the DICOM-scaled CIELab values, from DCMTK carrying the sRGB matrix to ~19 significant digits vs the 4-digit easyrgb.com constants in ColorUtilities. Both use the D65 white point and the same piecewise gamma, so there is no colorimetric disagreement to reconcile, only precision.
Root cause of the dcmqi failures
libsrc/ColorUtilities.cpp#L89-L95, in getCIEXYZFromLAB():
if (var_Y_pow3 > 0.008856) var_Y = var_Y_pow3;
else var_Y = ( var_Y - 16 / 116 ) / 7.787; // 16 / 116 == 016 / 116 is integer division and evaluates to 0 instead of 0.1379. The forward function getCIELabFromXYZ() 20 lines earlier correctly writes 16.0 / 116; only the inverse is affected, so the linear (low-luminance) branch of Lab→XYZ is wrong and dark colors break.
This is a porting bug, not inherited: the Java original writes 16d / 116 (util/ColorUtilities.java#L111-L117) and is correct. The d suffix was lost in translation to C++.
Changing those three lines to 16.0 / 116 takes dcmqi's round-trip error to 0/16,777,216 — confirming this is the sole cause.
User-visible effect
Round-tripping a SEG through dcmqi (itkimage2segimage then segimage2itkimage), the RGB reported in the output JSON:
| input RGB | DICOM CIELab written | dcmqi reads back | DCMTK would read back |
|---|---|---|---|
| (0, 0, 0) | (0, 32896, 32896) | (36, 36, 36) | (0, 0, 0) |
| (10, 10, 10) | (1797, 32896, 32896) | (39, 39, 39) | (10, 10, 10) |
| (20, 20, 20) | (4141, 32896, 32896) | (44, 44, 44) | (20, 20, 20) |
| (25, 22, 23) | (4987, 33336, 32853) | (-89, 58, 44) | (25, 22, 23) |
| (128, 0, 0) | (16732, 45246, 42677) | (125, 3, 39) | (128, 0, 0) |
| (255, 0, 0) | (34886, 53484, 50172) | (255, 0, 0) | (255, 0, 0) |
Black comes back as dark grey, and dark red picks up a blue cast. Note also that getSRGBFromCIEXYZPCS() does no clamping, so out-of-range results such as -89 are possible; since these feed SegmentAttributes::setRecommendedDisplayRGBValue(const unsigned&, ...), a negative value is reinterpreted as 4294967207.
The DICOM written by dcmqi is fine — the encoding path is only affected at the 2/65535 precision level. It is the decoding path, and therefore the JSON metadata dcmqi reports, that is wrong.
Proposal
Consolidate on DCMTK's IODCIELabUtil and remove dcmqi's ColorUtilities:
- Replace the two
ColorUtilitiescall sites withIODCIELabUtil, scaling RGB by 255.0 and rounding (lround) on the way back, with clamping to 0..255. - Optionally keep a small dcmqi-internal helper for the
int/unsigned↔doublemarshalling, so the0..255-int convention used throughout dcmqi is expressed in one place rather than at each call site. - Delete
include/dcmqi/ColorUtilities.h,libsrc/ColorUtilities.cppand their entries inlibsrc/CMakeLists.txt. - Drop the dead
cielabutil.hincludes inDicom2ItkConverterLabel.cppandDicom2ItkConverterBin.cpp, or make them real once the call sites move. - Add a round-trip regression test over the RGB cube (or a representative sample including the dark-color cases above) so this cannot regress again.
- Decide what to do with
util/ColorUtilities.javaandutil/TestColorConversions_SRGB_CIELabPCS.java— presumably remove them alongside, as they exist only as the reference for the C++ port.
Rationale for standardising on DCMTK rather than fixing ColorUtilities in place: dcmqi already depends on DCMTK, IODCIELabUtil is exact over the full RGB cube, it carries higher-precision matrix constants, it is the implementation that DCMTK's own dcmseg/dcmiod use for the same attribute, and one of our code paths (Bin2Label.cpp) is already on it. Keeping a second implementation in dcmqi means maintaining colorimetry code that isn't our core concern.
Worth noting that fixing the 16 / 116 bug is a one-line-times-three change and could go in first as a quick correctness fix if we want the bug closed before the larger consolidation lands.
Backwards-compatibility note
Any comparison baselines that captured RGB values produced by the current (incorrect) decoding path will change. Test data with dark segment colors is where this will show up.
Measurements above were produced against 0ed03e9 with DCMTK 3.6.8, linking the actual ColorUtilities.cpp and libdcmiod — happy to attach the test harness if useful.
Activity
dclunie commentedon Sep 16, 2026
FYI, based on Michael Halle m@halle.us's feedback about the pixelmed code (a) using D65 instead of D50 for the CIELab conversion, (b) not using as precise values in the transformation as CSS Color Module 4 calculations, and (c) not clamping integer output (but wrapping around and producing completely wrong values), I have updated the com.pixelmed.utils.ColorUtilities, accordingly, and the com.pixelmed.test.TestColorConversions_SRGB_CIELabPCS classes to add regression tests with some round trip values for the CSS 147 solid colors and some QIICR colors (Slicer General Anatomy Colors). See attached.
Let me know if you have any comments, and if I don't heard anything I will include these in the next public release of the pixelmed toolkit.
Note that I have left lots of comments in the code with old less precise values used and references to their sources, since not all implementations use exactly the same methods.
ColorUtilities.java
TestColorConversions_SRGB_CIELabPCS.java
You might also consider using for reference the CSS example code, but it doesn't include the conversion to integer 16-bit ICC-style PCS CIELab scaled values that are required.
fedorov commentedon Sep 18, 2026
Looks like @dclunie updated Pixelmed - https://github.com/fedorov/pixelmed/pull/4/changes#diff-4504b3388ab87d06a9a3695fbe405184a8577f187befd10386ee60c5a56e5d50 - diff for
ColorUtilities.java@CPBridge.michaelonken commentedon Sep 24, 2026
Hi,
I aligned the DCMTK-related code with the pixelmed implementation (DCMTK/dcmtk@8810587)
michaelonken commentedon Sep 24, 2026
@fedorov Next I would address replacing the dcmqi implementation with the DCMTK routines.
michaelonken commentedon Sep 24, 2026
Here is the corresponding PR: #565 .
mhalle commentedon Sep 25, 2026
This PR as written sounds like this issue is only one of precision or clamping rather than one of colorimetry.
Both issues are at play. The clamping issue crashes stuff so that fix is about code correctness and robustness.
The colorimetry causes a change of appearance between old and new implementations. "Both use the D65 white point and the same piecewise gamma, so there is no colorimetric disagreement to reconcile, only precision" is now not accurate, since the code is now adopting a D50 illuminant standards that matches the DICOM standard.
Also note that the consolidation on this corrected color conversion code impacts GSPS annotation colors because they are CIELab.
IODCIELabUtil is also used by dcmpstat/libsrc/dvpsgl.cc for Graphic Layer Recommended Display CIELab Value (0070,0401), in both directions, so this reaches presentation-state annotation colors as well as segmentation. Probably worth a line in the release notes.
Note that tests that do round trip color conversions don't necessarily prevent the D65 / D50 illuminant bug. A better additional test could provide a set of correct conversion values that adhere to the standard, not just a particular implementation of it.
michaelonken commentedon Sep 25, 2026
Thank you very much for looking into this in detail since you know much more about the topic than I do.
I think you are right in all points:
Yes, DCMTK's IODCIELabUtil is also used by dcmpstat for the Graphic Layer Recommended Display CIELab Value (0070,0401), in both directions. I will mention this in the DCMTK release notes.
DCMTK's unit test (dcmiod/tests/tcielabutil.cc) already checks 40 reference colors (CSS named colors and Slicer General Anatomy colors) against D50 CIELab values computed with the CSS Color Module Level 4 sample code, in both directions, plus the exhaustive round trip. dcmqi only passes values to and from that class. Its existing metadata round-trip tests compare the RGB values read back from the written DICOM file, so the dcmqi side should be covered indirectly as well.
The sentence "D65 on both sides, precision only" has been written before the DCMTK change. I have updated the PR description: it now says clearly that this is a change in colorimetry (D65 -> D50) and mentions what happens to files written by older versions.
fedorov commentedon Oct 2, 2026
@mhalle do you have any further comments? DCMTK is preparing for the new release in a week or so, so nothing can be changed there (can still do that after the release), but dcmqi PR can be updated. If we don't hear back from you, we will merge this PR in a week or so!