33import io
44import os
55import sys
6- from unittest .mock import patch
6+ from unittest .mock import MagicMock , patch , call
77
88import numpy as np
99import pytest
1212# Allow running from repo root or tests/
1313sys .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
172334if __name__ == "__main__" :
173335 pytest .main ([__file__ , "-v" ])
0 commit comments