Skip to content

Commit 9303a66

Browse files
committed
image profile icc profile convert compatiblity check
1 parent c6ff54d commit 9303a66

2 files changed

Lines changed: 229 additions & 41 deletions

File tree

library/image_utils.py

Lines changed: 94 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,59 @@
2828
(0, ImageCms.Intent.RELATIVE_COLORIMETRIC),
2929
]
3030

31+
# Maps ICC colour-space signatures (bytes 16-19 of the ICC header) to
32+
# the PIL image modes that are *compatible* with that colour-space.
33+
# littlecms cannot build a transform when the image mode and the ICC
34+
# colour-space are fundamentally incompatible (e.g. greyscale pixel
35+
# data with an RGB profile).
36+
_CS_TO_MODES = {
37+
"RGB ": ("RGB", "RGBA"),
38+
"GRAY": ("L", "LA"),
39+
"CMYK": ("CMYK",),
40+
}
41+
42+
43+
def _extract_icc_color_space(src_profile, icc_bytes: bytes | None = None) -> str:
44+
"""Return the ICC colour-space signature, or ``""`` if unknown.
45+
46+
Tries the ``ImageCms`` API first, then falls back to reading the
47+
4-byte colour-space field directly from the raw ICC header (bytes
48+
16-19 per ICC.1:2022 §7.2.6).
49+
"""
50+
try:
51+
cs = ImageCms.getColorSpace(src_profile)
52+
if cs:
53+
return cs.strip()
54+
except Exception:
55+
pass
56+
57+
# Fallback: raw ICC header from bytes or from profile object.
58+
raw = icc_bytes
59+
if raw is None and src_profile is not None:
60+
try:
61+
raw = src_profile.tobytes()
62+
except Exception:
63+
pass
64+
if raw is not None and len(raw) >= 20:
65+
try:
66+
return raw[16:20].decode("ascii", errors="replace")
67+
except Exception:
68+
pass
69+
70+
return ""
71+
72+
73+
def _mode_matches_profile(im_mode: str, color_space: str) -> bool:
74+
"""Return ``True`` if *im_mode* is compatible with *color_space*.
75+
76+
If *color_space* is unrecognised the function returns ``True``
77+
(benefit of the doubt — we'll let littlecms try).
78+
"""
79+
expected = _CS_TO_MODES.get(color_space)
80+
if expected is None:
81+
return True # unknown colour-space → don't block the attempt
82+
return im_mode in expected
83+
3184

3285
def _log_profile_failure(im: Image.Image, src_profile=None) -> None:
3386
"""Log diagnostic information about a failed ICC conversion.
@@ -45,7 +98,8 @@ def _log_profile_failure(im: Image.Image, src_profile=None) -> None:
4598

4699
# --- ICC profile details (each call independent so one failure
47100
# doesn't suppress the others) ---
48-
profile_name = profile_class = color_space = "<unavailable>"
101+
profile_name = profile_class = "<unavailable>"
102+
color_space = ""
49103
icc_size = 0
50104
if src_profile is not None:
51105
try:
@@ -56,46 +110,23 @@ def _log_profile_failure(im: Image.Image, src_profile=None) -> None:
56110
profile_class = ImageCms.getProfileClass(src_profile).strip()
57111
except Exception:
58112
pass
59-
try:
60-
color_space = ImageCms.getColorSpace(src_profile).strip()
61-
except Exception:
62-
pass
63113
try:
64114
icc_size = len(src_profile.tobytes())
65115
except Exception:
66116
pass
67117

68-
# --- detect the likely cause ---
69-
reason = ""
70118
icc_bytes = im.info.get("icc_profile")
119+
color_space = _extract_icc_color_space(src_profile, icc_bytes) or "<unavailable>"
71120
if icc_bytes:
72121
icc_size = icc_size or len(icc_bytes)
73122

74-
# Fallback: if the ImageCms API didn't yield a colour-space,
75-
# read it directly from the ICC header (bytes 16-19 are the
76-
# colour-space signature in the ICC spec).
77-
if color_space == "<unavailable>" and len(icc_bytes) >= 20:
78-
try:
79-
color_space = icc_bytes[16:20].decode("ascii", errors="replace")
80-
except Exception:
81-
pass
82-
83-
# Check for a common mismatch: e.g. greyscale/LA image carrying
84-
# an RGB profile (or vice-versa). littlecms cannot build a
85-
# transform when the image mode and the ICC colour-space are
86-
# fundamentally incompatible.
87-
_CS_TO_MODES = {
88-
"RGB ": ("RGB", "RGBA"),
89-
"GRAY": ("L", "LA"),
90-
"CMYK": ("CMYK",),
91-
}
92-
expected_modes = _CS_TO_MODES.get(color_space, ())
93-
if color_space != "<unavailable>" and im.mode not in expected_modes:
94-
reason = (
95-
f" — likely cause: image mode={im.mode} does not match "
96-
f"ICC profile colour-space={color_space!r}; "
97-
f"littlecms cannot build a transform across these types"
98-
)
123+
reason = ""
124+
if color_space != "<unavailable>" and not _mode_matches_profile(im.mode, color_space):
125+
reason = (
126+
f" — likely cause: image mode={im.mode} does not match "
127+
f"ICC profile colour-space={color_space!r}; "
128+
f"littlecms cannot build a transform across these types"
129+
)
99130

100131
logger.warning(
101132
"ICC-aware colour conversion failed for image mode=%s, size=%s, "
@@ -139,6 +170,10 @@ def to_srgb(im: Image.Image, assume: str = "sRGB") -> Image.Image:
139170
CMYK profile is present (or the *assume* fallback). PIL's
140171
``profileToProfile`` handles the CMYK → RGB channel reduction
141172
natively when ``outputMode="RGB"``.
173+
* If the image mode and the embedded ICC profile colour-space are
174+
incompatible (e.g. a greyscale image carrying an sRGB profile),
175+
the ICC pipeline is skipped entirely and a plain
176+
``im.convert("RGB")`` is returned without logging a warning.
142177
* The function tries several (flags, rendering-intent) combinations
143178
before falling back, so that images carrying unusual ICC profiles
144179
(V4, wide-gamut, etc.) still get correct colour conversion when
@@ -150,16 +185,35 @@ def to_srgb(im: Image.Image, assume: str = "sRGB") -> Image.Image:
150185
if im.mode == "RGB" and not im.info.get("icc_profile"):
151186
return im
152187

153-
src = None # ensure defined for the except clause
154-
try:
155-
# --- determine source profile ---
156-
icc_bytes = im.info.get("icc_profile")
157-
if icc_bytes:
188+
icc_bytes = im.info.get("icc_profile")
189+
190+
# --- proactive mismatch check ---
191+
# If the image carries an ICC profile whose colour-space is
192+
# incompatible with the image mode (e.g. greyscale pixels + sRGB
193+
# profile), littlecms will *never* be able to build a transform.
194+
# Detect this and convert silently rather than trying all four
195+
# fallback combos and emitting a noisy warning.
196+
if icc_bytes:
197+
try:
158198
src = ImageCms.ImageCmsProfile(io.BytesIO(icc_bytes))
159-
else:
160-
src = ImageCms.createProfile(assume)
199+
except Exception:
200+
# Profile bytes are corrupt — plain convert is the only option.
201+
return im.convert("RGB")
202+
203+
cs = _extract_icc_color_space(src, icc_bytes)
204+
if cs and not _mode_matches_profile(im.mode, cs):
205+
logger.debug(
206+
"Image mode=%s is incompatible with ICC colour-space=%r; "
207+
"skipping ICC pipeline and converting directly to RGB",
208+
im.mode,
209+
cs,
210+
)
211+
return im.convert("RGB")
212+
else:
213+
src = ImageCms.createProfile(assume)
161214

162-
# --- convert via the ICC pipeline ---
215+
# --- convert via the ICC pipeline ---
216+
try:
163217
for flags, intent in _TRANSFORM_ATTEMPTS:
164218
try:
165219
result = ImageCms.profileToProfile(

tests/test_image_utils.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
# Allow running from repo root or tests/
1313
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
1414

15-
from library.image_utils import to_srgb, _TRANSFORM_ATTEMPTS, _log_profile_failure
15+
from library.image_utils import (
16+
to_srgb,
17+
_TRANSFORM_ATTEMPTS,
18+
_log_profile_failure,
19+
_extract_icc_color_space,
20+
_mode_matches_profile,
21+
)
1622

1723

1824
# ---------------------------------------------------------------------------
@@ -365,5 +371,133 @@ def test_handles_broken_profile_gracefully(self, caplog):
365371
assert "falling back" in caplog.text
366372

367373

374+
class TestProactiveMismatch:
375+
"""Tests for the proactive mode/profile mismatch detection."""
376+
377+
def test_greyscale_with_srgb_profile_converts_silently(self, caplog):
378+
"""A greyscale image carrying an sRGB profile should convert
379+
directly to RGB without emitting a WARNING — the mismatch is
380+
known and expected."""
381+
im = _make_solid("L", color=128)
382+
im.info["icc_profile"] = _make_srgb_profile_bytes()
383+
384+
with caplog.at_level("DEBUG", logger="library.image_utils"):
385+
result = to_srgb(im)
386+
387+
assert result.mode == "RGB"
388+
assert result.size == im.size
389+
# Should log at DEBUG, not WARNING.
390+
assert "incompatible" in caplog.text.lower() or "skipping" in caplog.text.lower()
391+
assert "WARNING" not in caplog.text
392+
393+
def test_greyscale_with_srgb_profile_uses_plain_convert(self):
394+
"""Fallback path should produce the same pixels as im.convert('RGB')."""
395+
im = _make_solid("L", color=200)
396+
im.info["icc_profile"] = _make_srgb_profile_bytes()
397+
398+
result = to_srgb(im)
399+
expected = im.convert("RGB")
400+
np.testing.assert_array_equal(np.array(result), np.array(expected))
401+
402+
def test_la_with_srgb_profile_converts_silently(self, caplog):
403+
"""LA (greyscale + alpha) with an sRGB profile should also be
404+
detected as a mismatch and converted silently."""
405+
im = _make_solid("LA", color=(128, 255))
406+
im.info["icc_profile"] = _make_srgb_profile_bytes()
407+
408+
with caplog.at_level("DEBUG", logger="library.image_utils"):
409+
result = to_srgb(im)
410+
411+
assert result.mode == "RGB"
412+
assert "WARNING" not in caplog.text
413+
414+
def test_rgb_with_srgb_profile_still_uses_icc_pipeline(self):
415+
"""An RGB image with an sRGB profile should still go through the
416+
ICC pipeline (sRGB→sRGB is identity, but the pipeline should run)."""
417+
im = _make_solid("RGB", color=(128, 64, 32))
418+
im.info["icc_profile"] = _make_srgb_profile_bytes()
419+
420+
with patch("library.image_utils.ImageCms.profileToProfile", wraps=ImageCms.profileToProfile) as mock_ptp:
421+
result = to_srgb(im)
422+
423+
mock_ptp.assert_called_once()
424+
assert result.mode == "RGB"
425+
426+
def test_corrupt_profile_bytes_converts_directly(self):
427+
"""Corrupt ICC bytes that cannot even be parsed should still
428+
produce an RGB result via plain convert."""
429+
im = _make_solid("L", color=100)
430+
im.info["icc_profile"] = b"not-a-valid-icc-profile"
431+
432+
result = to_srgb(im)
433+
assert result.mode == "RGB"
434+
expected = im.convert("RGB")
435+
np.testing.assert_array_equal(np.array(result), np.array(expected))
436+
437+
def test_no_warning_for_known_mismatch(self, caplog):
438+
"""The proactive detection should suppress the WARNING that would
439+
otherwise be emitted by _log_profile_failure."""
440+
im = _make_solid("L", color=128)
441+
im.info["icc_profile"] = _make_srgb_profile_bytes()
442+
443+
with caplog.at_level("WARNING", logger="library.image_utils"):
444+
to_srgb(im)
445+
446+
# No WARNING should appear at all.
447+
assert caplog.text.count("WARNING") == 0
448+
449+
450+
class TestExtractIccColorSpace:
451+
"""Tests for the _extract_icc_color_space helper."""
452+
453+
def test_extracts_rgb_from_profile(self):
454+
"""An sRGB profile should yield colour-space 'RGB '."""
455+
src = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB"))
456+
cs = _extract_icc_color_space(src)
457+
assert "RGB" in cs
458+
459+
def test_extracts_from_raw_bytes_fallback(self):
460+
"""When the ImageCms API fails, should fall back to raw header."""
461+
srgb_bytes = _make_srgb_profile_bytes()
462+
# Pass a mock that makes getColorSpace fail
463+
mock_profile = MagicMock()
464+
cs = _extract_icc_color_space(mock_profile, srgb_bytes)
465+
assert "RGB" in cs
466+
467+
def test_returns_empty_for_no_data(self):
468+
"""No profile and no bytes → empty string."""
469+
cs = _extract_icc_color_space(MagicMock(), None)
470+
assert cs == ""
471+
472+
473+
class TestModeMatchesProfile:
474+
"""Tests for the _mode_matches_profile helper."""
475+
476+
def test_rgb_matches_rgb_space(self):
477+
assert _mode_matches_profile("RGB", "RGB ") is True
478+
479+
def test_rgba_matches_rgb_space(self):
480+
assert _mode_matches_profile("RGBA", "RGB ") is True
481+
482+
def test_l_does_not_match_rgb_space(self):
483+
assert _mode_matches_profile("L", "RGB ") is False
484+
485+
def test_la_does_not_match_rgb_space(self):
486+
assert _mode_matches_profile("LA", "RGB ") is False
487+
488+
def test_l_matches_gray_space(self):
489+
assert _mode_matches_profile("L", "GRAY") is True
490+
491+
def test_cmyk_matches_cmyk_space(self):
492+
assert _mode_matches_profile("CMYK", "CMYK") is True
493+
494+
def test_unknown_space_returns_true(self):
495+
"""Unrecognised colour-space should not block the attempt."""
496+
assert _mode_matches_profile("L", "XYZ ") is True
497+
498+
def test_rgb_does_not_match_gray_space(self):
499+
assert _mode_matches_profile("RGB", "GRAY") is False
500+
501+
368502
if __name__ == "__main__":
369503
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)