A Streamlit app that computes ΔE76 and ΔE2000 for an X-Rite/Calibrite ColorChecker Classic target, directly from a photographed TIFF with an embedded ICC profile - no manual patch clicking, no spreadsheet math.
Built for a workflow using TIFF export with embedded ICC profile, an spectrophotometer (own
measurement of the physical chart, .ti3), and vendor reference data (.cie).
The script was created to enable the local measurement of color reproduction quality in photographs.
- Load a TIFF with an embedded ICC profile.
- Detect the ColorChecker Classic automatically (
cv2.mcc), or fine-tune the 4 corners by clicking on a fixed-size preview — no scrubbing through sliders, no dragging. - Sample each of the 24 patches from the original, full-resolution image (median of the pixels in the center of each patch, avoiding borders/shadows).
- Convert measured RGB → Lab through the TIFF's own embedded ICC
profile (via LittleCMS/
PIL.ImageCms) — not an assumption of sRGB or AdobeRGB. - Compare against your reference(s):
- vendor Lab data (
.cie) - your own spectrophotometer measurement of that physical chart (
.ti3, XYZ → Lab, D50) — usually the more accurate ground truth, since individual chart units vary from the generic vendor average
- vendor Lab data (
- Report ΔE76 and CIEDE2000 per patch, with a color-coded table, a bar chart, side-by-side color swatches, and a CSV export.
The core of this tool is that it reads the color through the exact ICC profile embedded in your TIFF rather than assuming a generic working space. This has been cross-validated against Phase One NimbusQA: for the same physical patch, the measured L*a*b* values from this tool and from NimbusQA agree to within ~0.1–0.2 Lab units.
- Python 3.9+
- Any tool that can export a TIFF with an embedded ICC profile
- OPTIONAL: ArgyllCMS and a spectrophotometer (for generating your own
.ti3measurement) — external tools, not part of this app
Python packages (see requirements.txt):
streamlit==1.40.0
streamlit-image-coordinates==0.4.0
opencv-contrib-python
pillow
numpy
pandas
plotly
streamlit==1.40.0andstreamlit-image-coordinates==0.4.0are pinned deliberately. Newer Streamlit releases (1.4x+) moved/renamed internal APIs (streamlit.elements.image.image_to_urland related) thatstreamlit-image-coordinatesdepends on — installing an unpinned/newer Streamlit will break the corner-picker with anAttributeErrororImportError. Don'tpip install --upgrade streamlitin this environment without checking compatibility first.opencv-contrib-python, notopencv-python. Auto-detection uses thecv2.mccmodule, which only ships in the-contribbuild. The two packages conflict and cannot both be installed in the same environment.
If you already have the wrong ones installed:
pip uninstall opencv-python opencv-python-headless -y
pip install -r requirements.txt --force-reinstallgit clone <this-repo-url>
cd <this-repo>
pip install -r requirements.txt(Use a virtualenv if you'd rather not touch system packages; drop --force-reinstall for a normal first install.)
python -m streamlit run colorchecker_app.pyUse python -m streamlit run ... rather than the streamlit executable
directly — on some Windows Python installs the streamlit.exe launcher
script points at a stale interpreter path and fails with Fatal error in launcher. Running it as a module sidesteps that.
Streamlit's default upload limit is 200MB. For bigger files:
python -m streamlit run colorchecker_app.py --server.maxUploadSize 2000(value in MB). The on-screen preview is always downscaled to a fixed size — the original resolution never affects how big anything looks on screen, only upload size.
If another Streamlit instance is already using the default port:
python -m streamlit run colorchecker_app.py --server.port 8502- Sidebar → 1. Files
- Upload the TIFF photo with embedded ICC profile (If you don't have one, you can use the
example_photo.tiffile included in the package for testing purposes.). - Upload
.cievendor reference (They are included with the files in the repository) and/or.ti3(your spectrophotometer measurement. I’m attaching ExampleMeasurement.ti3 so you can see the structure of the reference file.) — at least one is required to compute ΔE.
- Upload the TIFF photo with embedded ICC profile (If you don't have one, you can use the
- Sidebar → 2. Chart type
ColorChecker Classic (24 patches)— fully supported, auto-detected.ColorChecker SG— listed for future support; no.chtgeometry has been measured for it yet, so it falls back to fully manual corners.Custom chart— define your own row × column grid; no auto-detect, manual corners only.
- Sidebar → 3. Patch sampling
- Slider for what fraction of each patch to sample from (smaller = safer against edge artifacts/shadows, larger = more pixels averaged).
- Step 1 — check / fix the grid
- The chart is detected automatically on upload. If it's off, or detection failed, use the corner selector (🟢A1 🔵A6 🔴D6 🟡D1) and click on the photo where that corner should be — it jumps to the next corner automatically after each click.
- "Precise numeric correction" expander lets you type exact pixel coordinates instead of clicking.
- Select a patch label scheme, then click "Grid looks good — compute ΔE".
- Results: color-coded table (green = low ΔE, red = high), a ΔE-per- patch bar chart with a ΔE≈2 just-noticeable-difference reference line, side-by-side measured-vs-reference color swatches, and a CSV download.
The "Show reference (Aim) values in the table" checkbox is a pure display filter — the underlying computation (ICC transform, sampling, ΔE) only runs once, when you click the compute button. Toggling the checkbox does not recompute anything; it just shows/hides the L*/a*/b* Aim columns in the table you're already looking at. The CSV download always contains every column regardless of the toggle.
X-Rite/Calibrite's own chart layout (and the .cht/.cie/.ti3 files this tool reads) numbers patches by row: A1–A6 is the top row left to right, B1–B6 the next row down, etc.
Phase One NimbusQA numbers patches by column instead: its A1–A4 is the first column top to bottom, its B1–B4 the second column, and so on. Same 24 physical patches, different label convention — comparing rows by label between the two tools without accounting for this will look like wildly different results even when the underlying measurements agree.
This app can display results in either scheme:
Argyll/.cht — rows(default): matches your.cie/.ti3files and ArgyllCMS conventions.Like Phase One NimbusQA — columns: remaps every label so it lines up 1:1 with NimbusQA's own output, for direct side-by-side comparison.
The remap is a straightforward transpose (row_letter+col_number →
new_letter(col_index)+row_number), verified against NimbusQA screenshots
patch-by-patch (dark skin → A1, orange → A2, white → A4, etc.). It's a pure
relabeling for display/export — it does not change where anything is
sampled.
- The four corner-fraction constants baked into
CHART_CONFIGS["classic"](A1,A6,D6,D1) were computed directly from a real ColorChecker Classic.chtfile (Argyll chart-recognition geometry): total board 330×228 units, with e.g. patchA1's center at(35.25, 34.75)— i.e.(0.10682, 0.15241)as a fraction of the board. These are not generic/guessed proportions. - When auto-detection runs,
cv2.mcc.CCheckerDetectorfinds the outer bounding box of the physical chart; a perspective homography then maps those corner fractions onto the actual photo to get accurate patch-center positions even under mild perspective/rotation. - The remaining 20 patch centers are interpolated via a perspective
homography across the 4 corner points (
cv2.getPerspectiveTransform), which handles keystone distortion from off-axis photography — a straight bilinear interpolation would not.
np.array(image) on a Pillow image in 'LAB' mode mis-decodes the a/b
channels — this is a real, verified quirk (confirmed: it disagrees with
image.getpixel() on the same pixel). The app reads Lab pixel data via
image.getdata() instead, which decodes correctly. If you extend this code
and are tempted to np.array() a Lab-mode image directly — don't.
CHART_CONFIGS is the single place that defines a chart type:
"classic": {
"label": "ColorChecker Classic (24 patches)",
"rows": ["A", "B", "C", "D"],
"cols": list(range(1, 7)),
"corner_fracs": {...}, # from a real .cht file — see above
"cv2_chart_type": "MCC24", # cv2.mcc constant for auto-detect
"auto_detect": True,
...
},To add a new chart type with auto-detection (e.g. ColorChecker SG), you need
its corner_fracs measured from a real .cht file the same way Classic's
were — don't guess these from a product photo, they need to come from
Argyll's actual chart-recognition geometry. Without corner_fracs, the
entry still works but falls back to fully manual corner placement (as sg
and custom currently do).
| Symptom | Cause | Fix |
|---|---|---|
AttributeError: module 'streamlit.elements.image' has no attribute 'image_to_url' |
Streamlit was upgraded past what streamlit-image-coordinates supports |
pip install -r requirements.txt --force-reinstall to restore the pinned versions |
Fatal error in launcher on Windows |
Stale streamlit.exe launcher path |
Run python -m streamlit run colorchecker_app.py instead |
| Preview image is huge / cropped to a tiny zoomed-in corner | Windows display scaling (125%/150%) combined with the click-picker rendering at native size | Already fixed in this version via an explicit width= on the picker; make sure you're on the current colorchecker_app.py |
| "No embedded ICC profile" error | TIFF was exported without an embedded profile | In Capture One, enable "Embed ICC Profile" on export |
| Auto-detection fails / picks the wrong region | Low contrast between chart and background, chart too small in frame, or an unsupported chart type | Use the manual corner-click flow (see Usage); this is expected to happen sometimes, it's not a bug |
ImportError for cv2.mcc |
Plain opencv-python installed instead of opencv-contrib-python |
See Requirements above |
| Upload rejected / silently fails for a big TIFF | Default 200MB Streamlit upload limit | --server.maxUploadSize 2000 (see Running) |
.cie— CGATS-format vendor reference,SAMPLE_ID+LAB_L/LAB_A/LAB_Bper patch (e.g.A01,A02, ...). IDs are normalized (A01→A1) so either zero-padded or bare numbering works..ti3— CGATS-format ArgyllCMS measurement file,SAMPLE_LOC+XYZ_X/XYZ_Y/XYZ_Zper patch. Converted to Lab assuming a D50 white point (96.42, 100.0, 82.49), matching Argyll/ICC PCS convention.- Both parsers look for
BEGIN_DATA_FORMAT/END_DATA_FORMATandBEGIN_DATA/END_DATAblocks and handle quoted fields.
- ΔE76: plain Euclidean distance in L*a*b* space.
- ΔE2000 (CIEDE2000): full implementation including the rotation term, verified against the standard Sharma et al. (2005) reference test dataset (matches published values to 4 decimal places on all checked cases).
Both are computed with kL = kC = kH = 1 (no weighting).
