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
3285def _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 (
0 commit comments