Skip to content

Commit af96739

Browse files
committed
Add additional image profile to profile fallbacks
1 parent 54c6003 commit af96739

2 files changed

Lines changed: 223 additions & 18 deletions

File tree

library/image_utils.py

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,40 @@
1818
_SRGB_PROFILE = ImageCms.createProfile("sRGB")
1919
_SRGB_CMS = ImageCms.ImageCmsProfile(_SRGB_PROFILE)
2020

21+
# Progressively less strict (flags, rendering-intent) pairs. Some ICC
22+
# profiles (V4, wide-gamut, CMYK, etc.) reject BLACKPOINTCOMPENSATION
23+
# or only define certain rendering intents, so we try several combos
24+
# before giving up.
25+
_TRANSFORM_ATTEMPTS = [
26+
(ImageCms.Flags.BLACKPOINTCOMPENSATION, ImageCms.Intent.PERCEPTUAL),
27+
(0, ImageCms.Intent.PERCEPTUAL),
28+
(ImageCms.Flags.BLACKPOINTCOMPENSATION, ImageCms.Intent.RELATIVE_COLORIMETRIC),
29+
(0, ImageCms.Intent.RELATIVE_COLORIMETRIC),
30+
]
31+
32+
33+
def _log_profile_failure(im: Image.Image, src_profile=None) -> None:
34+
"""Log diagnostic information about a failed ICC conversion."""
35+
profile_name = profile_class = color_space = "<unavailable>"
36+
try:
37+
if src_profile is not None:
38+
profile_name = ImageCms.getProfileName(src_profile).strip()
39+
profile_class = ImageCms.getProfileInfo(src_profile, "header/flags", 0).strip()
40+
color_space = ImageCms.getColorSpace(src_profile).strip()
41+
except Exception:
42+
pass
43+
44+
logger.warning(
45+
"ICC-aware colour conversion failed for image mode=%s, size=%s; "
46+
"profile name=%r, class=%r, colour-space=%r — "
47+
"falling back to plain .convert('RGB')",
48+
im.mode,
49+
im.size,
50+
profile_name,
51+
profile_class,
52+
color_space,
53+
)
54+
2155

2256
def to_srgb(im: Image.Image, assume: str = "sRGB") -> Image.Image:
2357
"""Convert *im* to sRGB, respecting any embedded ICC profile.
@@ -44,13 +78,18 @@ def to_srgb(im: Image.Image, assume: str = "sRGB") -> Image.Image:
4478
CMYK profile is present (or the *assume* fallback). PIL's
4579
``profileToProfile`` handles the CMYK → RGB channel reduction
4680
natively when ``outputMode="RGB"``.
81+
* The function tries several (flags, rendering-intent) combinations
82+
before falling back, so that images carrying unusual ICC profiles
83+
(V4, wide-gamut, etc.) still get correct colour conversion when
84+
possible.
4785
* On any failure a plain ``im.convert("RGB")`` is returned so
4886
callers never need to handle exceptions.
4987
"""
5088
# Fast path: already RGB and sRGB — nothing to do.
5189
if im.mode == "RGB" and not im.info.get("icc_profile"):
5290
return im
5391

92+
src = None # ensure defined for the except clause
5493
try:
5594
# --- determine source profile ---
5695
icc_bytes = im.info.get("icc_profile")
@@ -60,22 +99,26 @@ def to_srgb(im: Image.Image, assume: str = "sRGB") -> Image.Image:
6099
src = ImageCms.createProfile(assume)
61100

62101
# --- convert via the ICC pipeline ---
63-
im = ImageCms.profileToProfile(
64-
im,
65-
src,
66-
_SRGB_PROFILE,
67-
outputMode="RGB",
68-
renderingIntent=0,
69-
flags=ImageCms.Flags.BLACKPOINTCOMPENSATION,
70-
)
71-
72-
# Tag the result so downstream consumers know the colour space.
73-
im.info["icc_profile"] = _SRGB_CMS.tobytes()
74-
return im
102+
for flags, intent in _TRANSFORM_ATTEMPTS:
103+
try:
104+
result = ImageCms.profileToProfile(
105+
im,
106+
src,
107+
_SRGB_PROFILE,
108+
outputMode="RGB",
109+
renderingIntent=intent,
110+
flags=flags,
111+
)
112+
# Tag the result so downstream consumers know the colour space.
113+
result.info["icc_profile"] = _SRGB_CMS.tobytes()
114+
return result
115+
except Exception:
116+
continue
117+
118+
# All ICC transform attempts failed — fall back.
119+
_log_profile_failure(im, src)
120+
return im.convert("RGB")
75121

76122
except Exception:
77-
logger.warning(
78-
"ICC-aware colour conversion failed; falling back to plain .convert('RGB')",
79-
exc_info=True,
80-
)
123+
_log_profile_failure(im, src)
81124
return im.convert("RGB")

tests/test_image_utils.py

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import io
44
import os
55
import sys
6-
from unittest.mock import patch
6+
from unittest.mock import MagicMock, patch, call
77

88
import numpy as np
99
import pytest
@@ -12,7 +12,7 @@
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
15+
from library.image_utils import to_srgb, _TRANSFORM_ATTEMPTS, _log_profile_failure
1616

1717

1818
# ---------------------------------------------------------------------------
@@ -29,6 +29,11 @@ def _make_srgb_profile_bytes() -> bytes:
2929
return ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()
3030

3131

32+
def _make_adobe_rgb_profile_bytes() -> bytes:
33+
"""Return raw ICC bytes for AdobeRGB (wide-gamut)."""
34+
return ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()
35+
36+
3237
# ---------------------------------------------------------------------------
3338
# Tests
3439
# ---------------------------------------------------------------------------
@@ -168,6 +173,163 @@ def test_idempotent_for_rgb_no_profile(self):
168173
assert r1 is im
169174
assert r2 is im
170175

176+
# -- graduated fallback --------------------------------------------------
177+
178+
def test_graduated_fallback_retries_with_less_flags(self):
179+
"""When the first attempt (BPC + perceptual) fails, the function should
180+
retry with less strict flag combinations before giving up."""
181+
im = _make_solid("RGB", color=(128, 64, 32))
182+
im.info["icc_profile"] = _make_srgb_profile_bytes()
183+
184+
# Capture the real function reference before patching.
185+
_real_ptp = ImageCms.profileToProfile
186+
call_count = [0]
187+
188+
def side_effect(*args, **kwargs):
189+
call_count[0] += 1
190+
flags = kwargs.get("flags", 0)
191+
# Fail only when BLACKPOINTCOMPENSATION is requested.
192+
if flags & ImageCms.Flags.BLACKPOINTCOMPENSATION:
193+
raise ValueError("cannot build transform")
194+
return _real_ptp(*args, **kwargs)
195+
196+
with patch("library.image_utils.ImageCms.profileToProfile", side_effect=side_effect):
197+
result = to_srgb(im)
198+
199+
assert result.mode == "RGB"
200+
# At least 2 calls: first with BPC (fails), second without BPC (succeeds).
201+
assert call_count[0] >= 2
202+
203+
def test_graduated_fallback_tries_all_intent_combinations(self):
204+
"""When all BPC variants fail, the function should also try different
205+
rendering intents before falling back."""
206+
im = _make_solid("RGB", color=(128, 64, 32))
207+
im.info["icc_profile"] = _make_srgb_profile_bytes()
208+
209+
call_args_log = []
210+
211+
def side_effect(*args, **kwargs):
212+
call_args_log.append(kwargs)
213+
raise ValueError("cannot build transform")
214+
215+
with patch("library.image_utils.ImageCms.profileToProfile", side_effect=side_effect):
216+
result = to_srgb(im)
217+
218+
# Should have tried all 4 combinations from _TRANSFORM_ATTEMPTS.
219+
assert len(call_args_log) == len(_TRANSFORM_ATTEMPTS)
220+
# Verify each attempt uses a different (flags, intent) pair.
221+
seen = {(a.get("flags", 0), a.get("renderingIntent", 0)) for a in call_args_log}
222+
assert len(seen) == len(_TRANSFORM_ATTEMPTS)
223+
224+
def test_graduated_fallback_ultimate_fallback_to_convert(self):
225+
"""When ALL ICC transform attempts fail, plain .convert('RGB') is used."""
226+
im = _make_solid("L", color=128)
227+
im.info["icc_profile"] = _make_srgb_profile_bytes()
228+
229+
with patch("library.image_utils.ImageCms.profileToProfile", side_effect=ValueError("cannot build transform")):
230+
result = to_srgb(im)
231+
232+
assert result.mode == "RGB"
233+
expected = im.convert("RGB")
234+
np.testing.assert_array_equal(np.array(result), np.array(expected))
235+
236+
def test_graduated_fallback_succeeds_on_first_attempt(self):
237+
"""When the first attempt succeeds, no retries should be made."""
238+
im = _make_solid("RGB", color=(128, 64, 32))
239+
im.info["icc_profile"] = _make_srgb_profile_bytes()
240+
241+
with patch("library.image_utils.ImageCms.profileToProfile", wraps=ImageCms.profileToProfile) as mock_ptp:
242+
result = to_srgb(im)
243+
244+
mock_ptp.assert_called_once()
245+
assert result.mode == "RGB"
246+
247+
def test_graduated_fallback_succeeds_on_last_attempt(self):
248+
"""Even if only the last (flags, intent) combo works, conversion should succeed."""
249+
im = _make_solid("RGB", color=(128, 64, 32))
250+
im.info["icc_profile"] = _make_srgb_profile_bytes()
251+
252+
# Capture the real function reference before patching.
253+
_real_ptp = ImageCms.profileToProfile
254+
last_flags, last_intent = _TRANSFORM_ATTEMPTS[-1]
255+
attempt_count = [0]
256+
257+
def side_effect(*args, **kwargs):
258+
attempt_count[0] += 1
259+
flags = kwargs.get("flags", 0)
260+
intent = kwargs.get("renderingIntent", 0)
261+
# Only succeed on the very last combination.
262+
if flags == last_flags and intent == last_intent:
263+
return _real_ptp(*args, **kwargs)
264+
raise ValueError("cannot build transform")
265+
266+
with patch("library.image_utils.ImageCms.profileToProfile", side_effect=side_effect):
267+
result = to_srgb(im)
268+
269+
assert result.mode == "RGB"
270+
assert attempt_count[0] == len(_TRANSFORM_ATTEMPTS)
271+
272+
def test_graduated_fallback_result_tagged_srgb(self):
273+
"""Successful conversion on a retry should still tag the result with sRGB ICC."""
274+
im = _make_solid("RGB", color=(128, 64, 32))
275+
im.info["icc_profile"] = _make_srgb_profile_bytes()
276+
277+
# Capture the real function reference before patching.
278+
_real_ptp = ImageCms.profileToProfile
279+
call_count = [0]
280+
281+
def side_effect(*args, **kwargs):
282+
call_count[0] += 1
283+
flags = kwargs.get("flags", 0)
284+
if flags & ImageCms.Flags.BLACKPOINTCOMPENSATION:
285+
raise ValueError("cannot build transform")
286+
return _real_ptp(*args, **kwargs)
287+
288+
with patch("library.image_utils.ImageCms.profileToProfile", side_effect=side_effect):
289+
result = to_srgb(im)
290+
291+
icc = result.info.get("icc_profile")
292+
assert icc is not None
293+
profile = ImageCms.ImageCmsProfile(io.BytesIO(icc))
294+
assert "sRGB" in ImageCms.getProfileName(profile)
295+
296+
297+
class TestLogProfileFailure:
298+
"""Test suite for the _log_profile_failure helper."""
299+
300+
def test_logs_image_mode_and_size(self, caplog):
301+
"""Log message should include the image mode and size."""
302+
im = _make_solid("CMYK", size=(64, 48), color=(0, 0, 0, 0))
303+
with caplog.at_level("WARNING", logger="library.image_utils"):
304+
_log_profile_failure(im)
305+
assert "mode=CMYK" in caplog.text
306+
assert "size=(64, 48)" in caplog.text
307+
308+
def test_logs_profile_details_when_available(self, caplog):
309+
"""Log message should include profile name, class, and colour-space."""
310+
im = _make_solid("RGB", color=(128, 128, 128))
311+
src = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB"))
312+
with caplog.at_level("WARNING", logger="library.image_utils"):
313+
_log_profile_failure(im, src)
314+
assert "sRGB" in caplog.text
315+
316+
def test_handles_none_profile_gracefully(self, caplog):
317+
"""Should not raise when src_profile is None."""
318+
im = _make_solid("L", color=128)
319+
with caplog.at_level("WARNING", logger="library.image_utils"):
320+
_log_profile_failure(im, None)
321+
assert "unavailable" in caplog.text
322+
323+
def test_handles_broken_profile_gracefully(self, caplog):
324+
"""Should not raise when src_profile methods throw."""
325+
im = _make_solid("RGB", color=(128, 128, 128))
326+
broken_profile = MagicMock()
327+
broken_profile.tobytes.side_effect = Exception("broken")
328+
# ImageCms functions may raise on a mock object — that's fine.
329+
with caplog.at_level("WARNING", logger="library.image_utils"):
330+
_log_profile_failure(im, broken_profile)
331+
assert "falling back" in caplog.text
332+
171333

172334
if __name__ == "__main__":
173335
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)