This Delta E 2000 Python script gives every photo in a product set one number: how far its neutral tones sit from the rest of the set, measured in CIEDE2000 units, with a non-zero exit code when any photo drifts past your limit. On a six-photo test listing it passed the re-saved copy (0.40) and failed the warm, cool, green and underexposed versions, which a plain average RGB comparison ranks in the wrong order.
AI helped draft this post. I ran every code block below on Python 3.13 and pasted the printed output from those runs. Virse, whose photo color tool is mentioned once as a fix step, has a business relationship with this post.
TL;DR
- Reduce each photo to a neutral signature: the median Lab value of its least colorful lit pixels.
- Score each signature with scikit-image's
deltaE_ciede2000against the set median or a hero photo, twice: plain ΔE00, and a cast score with L* equalized. - Fail the batch when a cast passes 2.0. The script exits with 1, so it can sit in front of an upload step.
This is written for developers who maintain product image pipelines. It measures consistency inside a set of photos, not absolute color accuracy against the physical product.
What does a Delta E 2000 Python score tell you about a photo set?
It tells you how visible the color difference between two photos is likely to be, on a scale where 1.0 is roughly the smallest difference a viewer can see. Techkon puts it this way: "Generally, a 1 DE00 is deemed the threshold, indicating that any value below it signifies an imperceptible difference" (Techkon). Konica Minolta's guide reads 1 to 2 as "barely noticeable under close inspection" and 3 or more as a "clearly visible difference, often unacceptable" (Konica Minolta).
Delta E 2000 (also written ΔE00 or CIEDE2000) compares two colors, not two images. To score a set, you need three decisions:
- Which single Lab color represents each photo.
- What you compare against: the set's median, or one hero photo.
- Which limit fails the batch.
For a product listing, the thing that should match is rarely the product. A red mug and a blue mug are supposed to differ. What should match is the backdrop, the white of the sweep, the wall behind a sofa: the neutral parts of the frame, where a warm or green cast is easy to see next to the other listing images. The script below scores those.
Which Python library should compute CIEDE2000?
For arrays of colors, scikit-image's skimage.color.deltaE_ciede2000 is the practical choice: it is vectorized, and scikit-image also gives you rgb2lab, which converts sRGB to CIELAB under the D65 illuminant by default (scikit-image docs). The pure-Python packages that rank for this query, such as deltae and python-colormath, are built around comparing individual colors, which suits a swatch check better than a photo's worth of pixels.
CIEDE2000 came after the CIE76 and CIE94 formulas. scikit-image's docs call it "a major revision of CIDE94" and add that "the perceptual calibration is largely based on experience with automotive paint on smooth surfaces." Keep that second sentence in mind when you borrow its thresholds for photos.
Before trusting any implementation, check it against the test pairs published with Sharma, Wu and Dalal's implementation notes (Color Research & Application, 2005, DOI 10.1002/col.20070). The spreadsheet is on Gaurav Sharma's CIEDE2000 page; I converted its 34 rows to JSON.
pip install numpy pillow scikit-image colour-science xlrd
# xls_to_json.py (pip install xlrd)
import json
import xlrd
sheet = xlrd.open_workbook("CIEDE2000.xls").sheet_by_index(0)
rows = [r[:6] + [r[38]] for r in map(sheet.row_values, range(sheet.nrows)) if isinstance(r[0], float)]
json.dump(rows, open("sharma_pairs.json", "w"))
print(len(rows), "pairs")
# check_sharma.py
import json
import numpy as np
import colour
from skimage.color import deltaE_ciede2000
rows = np.array(json.load(open("sharma_pairs.json"))) # L1 a1 b1 L2 a2 b2 dE00
lab1, lab2, expected = rows[:, 0:3], rows[:, 3:6], rows[:, 6]
sk = deltaE_ciede2000(lab1, lab2)
co = colour.delta_E(lab1, lab2, method="CIE 2000")
print(f"pairs: {len(rows)}")
print(f"scikit-image max abs error: {np.abs(sk - expected).max():.1e}")
print(f"colour-science max abs error: {np.abs(co - expected).max():.1e}")
print(f"swap arguments, max change: {np.abs(deltaE_ciede2000(lab2, lab1) - sk).max():.1e}")
pairs: 34
scikit-image max abs error: 3.1e-14
colour-science max abs error: 1.3e-15
swap arguments, max change: 0.0e+00
Both libraries match the reference values to floating-point noise. I tested with numpy 2.4.1, scikit-image 0.26.0, Pillow 12.2.0 and colour-science 0.4.7.
How do you turn a whole photo into one Lab color?
Take the median Lab value of the least colorful pixels that are neither in shadow nor clipped. That is the "neutral signature" of the photo.
The obvious alternative, the mean of the whole frame, mostly measures content. A photo with more wood floor in it looks "warmer" than one with more white wall, even if both were shot under the same light. The neutral pixels are where a white-balance problem shows up and where the content matters least.
def neutral_signature(lab, share=0.2, l_min=40, l_max=95):
"""Median Lab of the least colorful lit pixels: backdrop, walls, white goods."""
lit = lab[(lab[:, 0] > l_min) & (lab[:, 0] < l_max)] # drop shadows, clipped whites
if len(lit) < 500:
lit = lab
chroma = np.hypot(lit[:, 1], lit[:, 2])
return np.median(lit[chroma <= np.quantile(chroma, share)], axis=0)
| Parameter | Default | What it controls |
|---|---|---|
share |
0.2 | Fraction of lit pixels, lowest chroma first, that form the signature |
l_min |
40 | Pixels darker than this L* are ignored (shadows carry noise and cast from bounce light) |
l_max |
95 | Pixels brighter than this are ignored (clipped whites have no color left to measure) |
max_side |
512 | Resize before conversion so large camera files stay fast |
The median matters here. A few saturated pixels that slip into the 20% cannot drag a median the way they drag a mean.
How do you separate exposure drift from a color cast?
Score each photo twice. The first score is plain ΔE00 between the photo's signature and the reference. The second copies the reference's L* into the photo's signature before comparing, so only the a*/b* difference is left. I call that the cast score.
def score(sigs, ref):
total = de00(ref, sigs) # lightness + cast
flat = np.array(sigs, dtype=np.float64)
flat[:, 0] = ref[0] # same L*: only the cast is left
cast = de00(ref, flat)
return total, cast
The split matters because the two failures need different fixes. A photo that is a third of a stop dark needs an exposure change. A photo with a green tint needs a white-balance change. Both produce a similar-sized plain ΔE00, so one number alone does not tell whoever fixes the photo what to do.
What does the full script look like?
Here is score_set.py in full. It takes a list of photos, scores them against the set median (or --ref), prints a table and exits with 1 if anything fails.
"""Score how consistent the color of a photo set is, using Delta E 2000.
usage: python score_set.py photos/*.jpg [--cast 2.0] [--total 4.0] [--ref hero.jpg]
exit code 1 when any photo is over the limit, so it can gate a batch.
"""
import argparse
import sys
import numpy as np
from PIL import Image
from skimage.color import rgb2lab, deltaE_ciede2000
def load_lab(path, max_side=512):
img = Image.open(path).convert("RGB")
img.thumbnail((max_side, max_side)) # keeps big camera files fast
rgb = np.asarray(img, dtype=np.float64) / 255.0
return rgb2lab(rgb).reshape(-1, 3) # sRGB -> CIELAB (D65)
def neutral_signature(lab, share=0.2, l_min=40, l_max=95):
"""Median Lab of the least colorful lit pixels: backdrop, walls, white goods."""
lit = lab[(lab[:, 0] > l_min) & (lab[:, 0] < l_max)] # drop shadows, clipped whites
if len(lit) < 500:
lit = lab
chroma = np.hypot(lit[:, 1], lit[:, 2])
return np.median(lit[chroma <= np.quantile(chroma, share)], axis=0)
def de00(ref, sigs):
sigs = np.asarray(sigs, dtype=np.float64)
ref = np.broadcast_to(np.asarray(ref, dtype=np.float64), sigs.shape)
return deltaE_ciede2000(ref, sigs)
def score(sigs, ref):
total = de00(ref, sigs) # lightness + cast
flat = np.array(sigs, dtype=np.float64)
flat[:, 0] = ref[0] # same L*: only the cast is left
cast = de00(ref, flat)
return total, cast
def main():
ap = argparse.ArgumentParser()
ap.add_argument("photos", nargs="+")
ap.add_argument("--cast", type=float, default=2.0, help="max cast dE00")
ap.add_argument("--total", type=float, help="max full dE00 (off by default)")
ap.add_argument("--ref", help="score against this photo, not the set median")
a = ap.parse_args()
sigs = np.array([neutral_signature(load_lab(p)) for p in a.photos])
ref = neutral_signature(load_lab(a.ref)) if a.ref else np.median(sigs, axis=0)
total, cast = score(sigs, ref)
print(f"reference L*a*b*: {ref[0]:.1f} {ref[1]:.1f} {ref[2]:.1f}")
print(f"{'photo':<18}{'L*':>6}{'a*':>6}{'b*':>6}{'dE00':>8}{'cast':>8}")
fail = cast > a.cast
if a.total is not None:
fail |= total > a.total
for p, s, t, c, f in zip(a.photos, sigs, total, cast, fail):
name = p.replace("\\", "/").split("/")[-1]
print(f"{name:<18}{s[0]:6.1f}{s[1]:6.1f}{s[2]:6.1f}{t:8.2f}{c:8.2f}{' FAIL' if f else ''}")
print(f"set: max cast {cast.max():.2f}, median cast {np.median(cast):.2f}, "
f"{int(fail.sum())}/{len(fail)} failed")
sys.exit(1 if fail.any() else 0)
if __name__ == "__main__":
main()
The np.broadcast_to line in de00 works around a bug. In scikit-image 0.26.0, passing an (N, 3) array and a single (3,) reference color to deltaE_ciede2000 raises AxisError: source: axis 1 is out of bounds for array of dimension 1, and swapping the arguments raises ValueError: not enough values to unpack. That is scikit-image issue #8326, still open when I checked; deltaE_cie76 and deltaE_ciede94 accept the same inputs. Broadcasting the reference to the full shape first avoids it.
Because it exits with 1 on failure, you can put it in front of an upload step:
python score_set.py export/SKU-1042/*.jpg --ref export/SKU-1042/hero.jpg --total 4.0 && ./upload.sh SKU-1042
What does the script print on a test listing?
To see whether the scores mean anything, I built a listing with known problems. The base image is a bedroom photo from the example gallery on Virse's Color Grade Transfer page. make_variants.py applies channel gains in linear light, the way a white-balance or exposure slider does in a raw converter, and saves six JPEGs. The variants are synthetic; the scores are real output.
# make_variants.py
"""Build a test listing set with known problems from one photo.
Gains are applied in linear light, like a white-balance or exposure change
in a raw converter. The names say what was injected.
"""
import os
import numpy as np
from PIL import Image
def to_linear(c):
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
def to_srgb(c):
c = np.clip(c, 0, 1)
return np.where(c <= 0.0031308, c * 12.92, 1.055 * c ** (1 / 2.4) - 0.055)
def variant(rgb, r=1.0, g=1.0, b=1.0, ev=0.0):
lin = to_linear(rgb) * np.array([r, g, b]) * 2 ** ev
return (to_srgb(lin) * 255 + 0.5).astype(np.uint8)
src = np.asarray(Image.open("photos/strengths/bed_01.png").convert("RGB")) / 255.0
os.makedirs("listing", exist_ok=True)
jobs = {
"01_hero": dict(),
"02_resaved": dict(), # JPEG re-encode only
"03_under_third": dict(ev=-1/3), # 1/3 stop darker
"04_warm": dict(r=1.06, b=0.92),
"05_cool": dict(r=0.94, b=1.08),
"06_green": dict(g=1.04),
}
for name, kw in jobs.items():
q = 80 if name == "02_resaved" else 95
Image.fromarray(variant(src, **kw)).save(f"listing/{name}.jpg", quality=q)
print(name, kw)
$ python score_set.py listing/*.jpg --ref listing/01_hero.jpg --total 4.0
reference L*a*b*: 81.0 0.8 2.8
photo L* a* b* dE00 cast
01_hero.jpg 81.0 0.8 2.8 0.00 0.00
02_resaved.jpg 81.4 1.0 2.7 0.40 0.31
03_under_third.jpg 74.3 0.7 2.6 4.75 0.29 FAIL
04_warm.jpg 80.8 1.6 7.1 3.54 3.54 FAIL
05_cool.jpg 79.2 0.0 0.2 2.96 2.68 FAIL
06_green.jpg 81.4 -0.9 4.2 2.82 2.81 FAIL
set: max cast 3.54, median cast 1.49, 4/6 failed
Reading it row by row:
- The re-saved JPEG moves 0.40, under the 1.0 visibility threshold. Compression alone does not trip the gate.
- The underexposed copy has a plain ΔE00 of 4.75 but a cast of 0.29. The columns say "exposure, not white balance", and that is exactly what was injected.
- Warm, cool and green all land between 2.68 and 3.54 on the cast column, inside Konica Minolta's "clearly visible" band or just below it.
Without --ref, the script uses the set median as the reference. On the same six files that gives a max cast of 3.63 and the same three cast failures. The underexposed copy is not flagged in that run: its cast is 0.16, and --total is off unless you set it.
Why not just compare average RGB?
Because average RGB distance ranks these photos in the wrong order. I computed the Euclidean distance between each file's mean RGB and the hero's:
| Photo | Mean RGB distance | ΔE00 | Cast ΔE00 |
|---|---|---|---|
| resaved | 0.1 | 0.40 | 0.31 |
| under_third | 27.5 | 4.75 | 0.29 |
| warm | 7.2 | 3.54 | 3.54 |
| cool | 7.0 | 2.96 | 2.68 |
| green | 2.8 | 2.82 | 2.81 |
By RGB distance, the green tint looks like the second-best photo in the set, ten times closer than the darker copy. By ΔE00 its cast is almost as strong as the warm one. For single colors, the dev.to post "Difference Between 2 Colours Using Python" describes the underlying problem with Euclidean distance: "What looks like identical colours to us, might give us a Euclidean distance that is greater than the Euclidean distance of colours that look different to us" (dev.to).
A related trap: OpenCV's 8-bit COLOR_BGR2LAB stores L* scaled to 0 to 255 and a*/b* offset by 128. I fed two nearby pixels, RGB (200, 190, 175) and (200, 185, 175), through both libraries:
OpenCV 8-bit Lab: [[198.0, 129.0, 137.0], [194.0, 132.0, 135.0]]
scikit-image Lab: [[77.4, 0.95, 8.83], [76.11, 3.62, 6.99]]
dE00 on raw OpenCV values: 1.75
dE00 after rescaling: 4.66
dE00 scikit-image: 4.15
Raw OpenCV values would pass that pair under a 2.0 limit. Rescaling fixes the units but keeps the 8-bit rounding error. Convert float RGB with rgb2lab instead.
What Delta E 2000 threshold should the gate use?
Start with a cast limit of 2.0 and turn on --total 4.0 only for sets with the same composition. The 2.0 sits at the top of the 1 to 2 "barely noticeable" band from the Konica Minolta guide, so anything flagged is past what most viewers let slide.
| Situation | Reference | Limits |
|---|---|---|
| Same product, several angles, one backdrop |
--ref the approved hero |
--cast 2.0 --total 4.0 |
| Different products, same studio setup | set median | --cast 2.0 |
| Mixed scenes (lifestyle, UGC, stock) | set median |
--cast 3.0, review flags by eye |
| Brand with a strict color spec |
--ref hero |
--cast 1.0 |
Konica Minolta gives an example of how widely tolerances vary: "a luxury cosmetic brand may demand ΔE ≤ 1.0, while an automotive supplier may accept ΔE ≤ 2.0." Those are for measured physical color. For photos, calibrate on your own data: run the script over five or ten listings your team already considers fine, look at the max cast values, and set the limit just above them.
One detail about the median reference: with an odd number of photos, the median signature is often one of the photos itself, so one row reads 0.00. That does not make that photo "correct". It is the middle of the set.
How does a real before/after set score?
The same page's gallery has a "Mixed Cameras, One Look" example: "three source photos graded against a single reference." I cut the two rows apart and scored each as a set.
sources: cast 1.23, 6.97, 0.00 max 6.97 (1/3 failed)
graded: cast 0.00, 0.75, 0.68 max 0.75 (0/3 failed)
The Mediterranean street photo in the source row fails with a cast of 6.97. After grading, the whole row sits under 1.0.
The Mixed Cameras example from Virse's tool page, scored as two sets: max cast 6.97 before, 0.75 after.
When a photo fails, you can fix white balance by hand in a raw editor, or, when you do not have the raw file, match the flagged photo to your hero with an AI color match for photos. Virse's Color Grade Transfer takes the photo plus one tone reference, matches Lab statistics rather than regenerating the image, and has Light, Standard and Full strengths. It returns one graded image per run, so you re-score the new file with the same command and see whether it passes.
The photo color matching form: one source image, one tone reference, three strengths.
Where does the neutral signature break?
It breaks when the least colorful lit pixels in one photo come from a different light than in the others. The clearest case in my runs was the six-photo interior set from the same gallery. Five photos scored a cast between 0.18 and 2.32 (two of them, at 2.03 and 2.32, just over the 2.0 limit). The doorway shot scored 5.44, with a plain ΔE00 of 19.84.
Where the neutral signature looks: the sunlit wall in one shot, the brighter room behind the door in the other.
The mask shows why. In the corner shot, the signature comes from the sunlit wall. In the doorway shot, most of it comes from the brighter room behind the door, which is under different light from the foreground wall. The photo may be fine; the signature is measuring a different surface.
Other cases where I would not trust the number without a look:
- Product fills the frame and is strongly colored, with no backdrop in view. There are no neutral pixels to measure, so the signature picks the least colorful parts of the product.
- Mixed lighting in one frame, such as a window plus warm lamps. The 20% slice can land on either.
- Very small sets. With two or three photos, the median is weak, so use
--ref.
The fix for all of these is the same: pass a mask. If your product photos come from a template, crop a fixed backdrop region (for example, the top 15% of the frame) before calling neutral_signature. The rest of the script does not change.
FAQ
Is Delta E 2000 symmetric? In my test, swapping the two arguments changed no value across all 34 Sharma pairs (max change 0.0). You can pass the reference in either position. Hold on to one convention anyway so that your logs read the same way.
Does this work on uncalibrated sRGB JPEGs? For consistency inside a set, yes, because every photo goes through the same conversion. For absolute color accuracy against a physical product, no: that needs a calibrated capture. python-colormath's docs describe ΔE as "a good rough starting point for comparing colors," and that is the right level of trust for camera JPEGs.
Can I use ΔE76 instead? You can, since scikit-image has deltaE_cie76. The thresholds in this post are for ΔE00, and the two scales differ, so do not reuse the numbers.
To try it on your own catalog, copy score_set.py, run it over one SKU folder with --ref pointing at the approved hero, and read the cast column before you pick a limit.




Top comments (0)