From bb4e972e3a48bbc7beabc86f806b73da8f85987b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:23:01 +0000 Subject: [PATCH 1/6] Initial plan From af78168251e4bc4379a6284cc992e8861504b014 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:28:09 +0000 Subject: [PATCH 2/6] Add ICC profile support: TJPARAM_SAVEMARKERS, get_icc_profile, set_icc_profile, encode icc_profile param, tests Co-authored-by: lilohuang <8341304+lilohuang@users.noreply.github.com> --- tests/test_turbojpeg.py | 53 +++++++++++++++++- turbojpeg.py | 120 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 2 deletions(-) diff --git a/tests/test_turbojpeg.py b/tests/test_turbojpeg.py index 9d5c7d0..a5aae0e 100644 --- a/tests/test_turbojpeg.py +++ b/tests/test_turbojpeg.py @@ -30,7 +30,8 @@ TJSAMP_444, TJSAMP_422, TJSAMP_420, TJSAMP_GRAY, TJSAMP_440, TJSAMP_411, TJCS_RGB, TJCS_YCbCr, TJCS_GRAY, TJFLAG_PROGRESSIVE, TJFLAG_FASTUPSAMPLE, TJFLAG_FASTDCT, - TJPRECISION_8, TJPRECISION_12, TJPRECISION_16 + TJPRECISION_8, TJPRECISION_12, TJPRECISION_16, + TJPARAM_SAVEMARKERS ) @@ -1802,5 +1803,55 @@ def test_lossless_different_pixel_formats(self, jpeg_instance): assert np.array_equal(img_gray, decoded_gray), "Lossless grayscale should be perfect" +import struct + +def _make_minimal_srgb_icc(): + """Build a minimal but structurally valid ICC profile for sRGB.""" + profile = bytearray(132) + struct.pack_into('>I', profile, 0, 132) + struct.pack_into('>I', profile, 8, 0x02100000) + profile[12:16] = b'mntr' + profile[16:20] = b'RGB ' + profile[20:24] = b'XYZ ' + profile[36:40] = b'acsp' + return bytes(profile) + +MINIMAL_SRGB_ICC = _make_minimal_srgb_icc() + + +class TestICCProfile: + """Test ICC profile embed/extract functionality.""" + + def test_get_icc_profile_with_embedded_profile(self, jpeg_instance, sample_bgr_image): + """Test that get_icc_profile returns non-empty bytes for a JPEG with ICC profile.""" + jpeg_with_icc = jpeg_instance.encode( + sample_bgr_image, quality=85, icc_profile=MINIMAL_SRGB_ICC) + icc = jpeg_instance.get_icc_profile(jpeg_with_icc) + assert icc is not None, "Expected ICC profile but got None" + assert isinstance(icc, bytes), "ICC profile should be bytes" + assert len(icc) > 0, "ICC profile should be non-empty" + assert icc == MINIMAL_SRGB_ICC, "Extracted ICC profile should match embedded profile" + + def test_get_icc_profile_without_profile(self, jpeg_instance, sample_bgr_image): + """Test that get_icc_profile returns None for a JPEG without ICC profile.""" + jpeg_without_icc = jpeg_instance.encode(sample_bgr_image, quality=85) + icc = jpeg_instance.get_icc_profile(jpeg_without_icc) + assert icc is None, \ + "Expected None for JPEG without ICC profile" + + def test_icc_profile_roundtrip(self, jpeg_instance, sample_bgr_image): + """Test ICC profile survives a full encode→decode_header roundtrip.""" + jpeg_data = jpeg_instance.encode( + sample_bgr_image, quality=95, + jpeg_subsample=TJSAMP_444, + icc_profile=MINIMAL_SRGB_ICC) + assert jpeg_data is not None + assert len(jpeg_data) > 0 + extracted_icc = jpeg_instance.get_icc_profile(jpeg_data) + assert extracted_icc is not None, "ICC profile missing after roundtrip" + assert extracted_icc == MINIMAL_SRGB_ICC, \ + f"ICC profile mismatch: expected {len(MINIMAL_SRGB_ICC)} bytes, got {len(extracted_icc) if extracted_icc else 0}" + + if __name__ == '__main__': pytest.main([__file__, '-v']) diff --git a/turbojpeg.py b/turbojpeg.py index 1cf3d81..29e0db9 100644 --- a/turbojpeg.py +++ b/turbojpeg.py @@ -192,6 +192,7 @@ TJPARAM_DENSITYUNITS = 22 TJPARAM_MAXPIXELS = 23 TJPARAM_MAXMEMORY = 24 +TJPARAM_SAVEMARKERS = 25 class CroppingRegion(Structure): _fields_ = [("x", c_int), ("y", c_int), ("w", c_int), ("h", c_int)] @@ -519,6 +520,22 @@ def __init__(self, lib_path=None): except AttributeError: self.__decompressToYUVPlanes16 = None + # tj3GetICCProfile - retrieve ICC profile from decompressor after header parsing + try: + self.__get_icc_profile_fn = turbo_jpeg.tj3GetICCProfile + self.__get_icc_profile_fn.argtypes = [c_void_p, POINTER(c_void_p), POINTER(c_size_t)] + self.__get_icc_profile_fn.restype = c_int + except AttributeError: + self.__get_icc_profile_fn = None + + # tj3SetICCProfile - attach ICC profile to compressor before compression + try: + self.__set_icc_profile_fn = turbo_jpeg.tj3SetICCProfile + self.__set_icc_profile_fn.argtypes = [c_void_p, c_void_p, c_size_t] + self.__set_icc_profile_fn.restype = c_int + except AttributeError: + self.__set_icc_profile_fn = None + # tjGetScalingFactors - still the current API in 3.1.x get_scaling_factors = turbo_jpeg.tjGetScalingFactors get_scaling_factors.argtypes = [POINTER(c_int)] @@ -592,6 +609,101 @@ def decode_header(self, jpeg_buf, return_precision=False): finally: self.__destroy(handle) + def get_icc_profile(self, jpeg_buf): + """Extracts the embedded ICC color profile from a JPEG image. + + Requires TurboJPEG 3.0 or later with tj3GetICCProfile support. + + Parameters + ---------- + jpeg_buf : bytes + JPEG image data buffer containing an embedded ICC profile. + + Returns + ------- + bytes or None + Raw ICC profile data as a bytes object, or None if no ICC profile + is present in the JPEG stream. + + Raises + ------ + OSError + If the JPEG header cannot be parsed or a fatal error occurs. + NotImplementedError + If the loaded libturbojpeg does not export tj3GetICCProfile. + + Examples + -------- + >>> jpeg = TurboJPEG() + >>> with open('photo_with_icc.jpg', 'rb') as f: + ... data = f.read() + >>> icc = jpeg.get_icc_profile(data) + >>> if icc: + ... print(f'ICC profile size: {len(icc)} bytes') + """ + if self.__get_icc_profile_fn is None: + raise NotImplementedError( + 'tj3GetICCProfile is not available in the loaded libturbojpeg. ' + 'Please upgrade to libjpeg-turbo 3.0 or later.') + handle = self.__init(TJINIT_DECOMPRESS) + try: + # Set TJPARAM_SAVEMARKERS to 2 (APP2) so the decompressor + # retains ICC profile markers during header parsing. + if self.__set(handle, TJPARAM_SAVEMARKERS, 2) != 0: + self.__report_error(handle) + jpeg_array = np.frombuffer(jpeg_buf, dtype=np.uint8) + src_addr = self.__getaddr(jpeg_array) + status = self.__decompress_header(handle, src_addr, jpeg_array.size) + if status != 0: + self.__report_error(handle) + icc_buf = c_void_p() + icc_size = c_size_t() + status = self.__get_icc_profile_fn(handle, byref(icc_buf), byref(icc_size)) + if status != 0: + # A non-fatal return (e.g. no profile present) should return None + err_code = self.__get_error_code(handle) + if err_code == TJERR_WARNING: + return None + self.__report_error(handle) + if icc_buf.value is None or icc_size.value == 0: + return None + result = self.__copy_from_buffer(icc_buf.value, icc_size.value) + self.__free(icc_buf) + return result + finally: + self.__destroy(handle) + + def set_icc_profile(self, handle, icc_buf): + """Attaches an ICC color profile to an active compressor handle. + + This is a low-level helper intended for use when building custom + compression pipelines. In most cases, use encode() with the + icc_profile parameter instead. + + Parameters + ---------- + handle : ctypes void pointer + An active TurboJPEG compressor handle (TJINIT_COMPRESS). + icc_buf : bytes + Raw ICC profile data to embed. + + Raises + ------ + OSError + If tj3SetICCProfile returns a non-zero status. + NotImplementedError + If the loaded libturbojpeg does not export tj3SetICCProfile. + """ + if self.__set_icc_profile_fn is None: + raise NotImplementedError( + 'tj3SetICCProfile is not available in the loaded libturbojpeg. ' + 'Please upgrade to libjpeg-turbo 3.0 or later.') + icc_array = np.frombuffer(icc_buf, dtype=np.uint8) + icc_addr = self.__getaddr(icc_array) + status = self.__set_icc_profile_fn(handle, icc_addr, len(icc_buf)) + if status != 0: + self.__report_error(handle) + def decode(self, jpeg_buf, pixel_format=TJPF_BGR, scaling_factor=None, flags=0, dst=None): """decodes JPEG memory buffer to numpy array. @@ -707,7 +819,7 @@ def decode_to_yuv_planes(self, jpeg_buf, scaling_factor=None, strides=(0, 0, 0), finally: self.__destroy(handle) - def encode(self, img_array, quality=85, pixel_format=TJPF_BGR, jpeg_subsample=TJSAMP_422, flags=0, dst=None, lossless=False): + def encode(self, img_array, quality=85, pixel_format=TJPF_BGR, jpeg_subsample=TJSAMP_422, flags=0, dst=None, lossless=False, icc_profile=None): """encodes numpy array to JPEG memory buffer. Parameters @@ -729,6 +841,9 @@ def encode(self, img_array, quality=85, pixel_format=TJPF_BGR, jpeg_subsample=TJ When True, provides perfect reconstruction with larger file sizes. Note: quality and jpeg_subsample parameters are ignored in lossless mode; subsampling is automatically set to 4:4:4 by the library. + icc_profile : bytes or None + Raw ICC profile data to embed in the JPEG (optional). + Requires TurboJPEG 3.0 or later with tj3SetICCProfile support. Returns ------- @@ -763,6 +878,9 @@ def encode(self, img_array, quality=85, pixel_format=TJPF_BGR, jpeg_subsample=TJ if img_array.dtype != np.uint8: raise ValueError('encode() requires uint8 array (values 0-255); use encode_12bit() for 12-bit images (uint16, 0-4095) or encode_16bit() for 16-bit images (uint16, 0-65535)') + if icc_profile is not None: + self.set_icc_profile(handle, icc_profile) + if dst is not None and not self.__is_buffer(dst): raise TypeError('\'dst\' argument must support buffer protocol') if (dst is not None and From ad0b62a2992cada3b95f598e5d16ace47f621c93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:31:54 +0000 Subject: [PATCH 3/6] Remove redundant version availability checks from get_icc_profile and set_icc_profile Co-authored-by: lilohuang <8341304+lilohuang@users.noreply.github.com> --- turbojpeg.py | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/turbojpeg.py b/turbojpeg.py index 29e0db9..bbc9130 100644 --- a/turbojpeg.py +++ b/turbojpeg.py @@ -521,20 +521,14 @@ def __init__(self, lib_path=None): self.__decompressToYUVPlanes16 = None # tj3GetICCProfile - retrieve ICC profile from decompressor after header parsing - try: - self.__get_icc_profile_fn = turbo_jpeg.tj3GetICCProfile - self.__get_icc_profile_fn.argtypes = [c_void_p, POINTER(c_void_p), POINTER(c_size_t)] - self.__get_icc_profile_fn.restype = c_int - except AttributeError: - self.__get_icc_profile_fn = None + self.__get_icc_profile_fn = turbo_jpeg.tj3GetICCProfile + self.__get_icc_profile_fn.argtypes = [c_void_p, POINTER(c_void_p), POINTER(c_size_t)] + self.__get_icc_profile_fn.restype = c_int # tj3SetICCProfile - attach ICC profile to compressor before compression - try: - self.__set_icc_profile_fn = turbo_jpeg.tj3SetICCProfile - self.__set_icc_profile_fn.argtypes = [c_void_p, c_void_p, c_size_t] - self.__set_icc_profile_fn.restype = c_int - except AttributeError: - self.__set_icc_profile_fn = None + self.__set_icc_profile_fn = turbo_jpeg.tj3SetICCProfile + self.__set_icc_profile_fn.argtypes = [c_void_p, c_void_p, c_size_t] + self.__set_icc_profile_fn.restype = c_int # tjGetScalingFactors - still the current API in 3.1.x get_scaling_factors = turbo_jpeg.tjGetScalingFactors @@ -629,8 +623,6 @@ def get_icc_profile(self, jpeg_buf): ------ OSError If the JPEG header cannot be parsed or a fatal error occurs. - NotImplementedError - If the loaded libturbojpeg does not export tj3GetICCProfile. Examples -------- @@ -641,10 +633,6 @@ def get_icc_profile(self, jpeg_buf): >>> if icc: ... print(f'ICC profile size: {len(icc)} bytes') """ - if self.__get_icc_profile_fn is None: - raise NotImplementedError( - 'tj3GetICCProfile is not available in the loaded libturbojpeg. ' - 'Please upgrade to libjpeg-turbo 3.0 or later.') handle = self.__init(TJINIT_DECOMPRESS) try: # Set TJPARAM_SAVEMARKERS to 2 (APP2) so the decompressor @@ -691,13 +679,7 @@ def set_icc_profile(self, handle, icc_buf): ------ OSError If tj3SetICCProfile returns a non-zero status. - NotImplementedError - If the loaded libturbojpeg does not export tj3SetICCProfile. """ - if self.__set_icc_profile_fn is None: - raise NotImplementedError( - 'tj3SetICCProfile is not available in the loaded libturbojpeg. ' - 'Please upgrade to libjpeg-turbo 3.0 or later.') icc_array = np.frombuffer(icc_buf, dtype=np.uint8) icc_addr = self.__getaddr(icc_array) status = self.__set_icc_profile_fn(handle, icc_addr, len(icc_buf)) From 5f13636c28466e3355ff686339b059541f39e2f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:40:12 +0000 Subject: [PATCH 4/6] Rename __get_icc_profile_fn/__set_icc_profile_fn to follow existing naming convention Co-authored-by: lilohuang <8341304+lilohuang@users.noreply.github.com> --- turbojpeg.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/turbojpeg.py b/turbojpeg.py index bbc9130..1615edc 100644 --- a/turbojpeg.py +++ b/turbojpeg.py @@ -521,14 +521,14 @@ def __init__(self, lib_path=None): self.__decompressToYUVPlanes16 = None # tj3GetICCProfile - retrieve ICC profile from decompressor after header parsing - self.__get_icc_profile_fn = turbo_jpeg.tj3GetICCProfile - self.__get_icc_profile_fn.argtypes = [c_void_p, POINTER(c_void_p), POINTER(c_size_t)] - self.__get_icc_profile_fn.restype = c_int + self.__get_icc_profile = turbo_jpeg.tj3GetICCProfile + self.__get_icc_profile.argtypes = [c_void_p, POINTER(c_void_p), POINTER(c_size_t)] + self.__get_icc_profile.restype = c_int # tj3SetICCProfile - attach ICC profile to compressor before compression - self.__set_icc_profile_fn = turbo_jpeg.tj3SetICCProfile - self.__set_icc_profile_fn.argtypes = [c_void_p, c_void_p, c_size_t] - self.__set_icc_profile_fn.restype = c_int + self.__set_icc_profile = turbo_jpeg.tj3SetICCProfile + self.__set_icc_profile.argtypes = [c_void_p, c_void_p, c_size_t] + self.__set_icc_profile.restype = c_int # tjGetScalingFactors - still the current API in 3.1.x get_scaling_factors = turbo_jpeg.tjGetScalingFactors @@ -646,7 +646,7 @@ def get_icc_profile(self, jpeg_buf): self.__report_error(handle) icc_buf = c_void_p() icc_size = c_size_t() - status = self.__get_icc_profile_fn(handle, byref(icc_buf), byref(icc_size)) + status = self.__get_icc_profile(handle, byref(icc_buf), byref(icc_size)) if status != 0: # A non-fatal return (e.g. no profile present) should return None err_code = self.__get_error_code(handle) @@ -682,7 +682,7 @@ def set_icc_profile(self, handle, icc_buf): """ icc_array = np.frombuffer(icc_buf, dtype=np.uint8) icc_addr = self.__getaddr(icc_array) - status = self.__set_icc_profile_fn(handle, icc_addr, len(icc_buf)) + status = self.__set_icc_profile(handle, icc_addr, len(icc_buf)) if status != 0: self.__report_error(handle) From 63d8eb050ab08243d2685d43c06ffea4599f4a0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:54:38 +0000 Subject: [PATCH 5/6] Use try/except pattern for ICC bindings (TurboJPEG 3.1+), add NotImplementedError guards Co-authored-by: lilohuang <8341304+lilohuang@users.noreply.github.com> --- turbojpeg.py | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/turbojpeg.py b/turbojpeg.py index 1615edc..3144ef3 100644 --- a/turbojpeg.py +++ b/turbojpeg.py @@ -520,15 +520,21 @@ def __init__(self, lib_path=None): except AttributeError: self.__decompressToYUVPlanes16 = None - # tj3GetICCProfile - retrieve ICC profile from decompressor after header parsing - self.__get_icc_profile = turbo_jpeg.tj3GetICCProfile - self.__get_icc_profile.argtypes = [c_void_p, POINTER(c_void_p), POINTER(c_size_t)] - self.__get_icc_profile.restype = c_int + # tj3GetICCProfile - retrieve ICC profile from decompressor after header parsing (TurboJPEG 3.1+) + try: + self.__get_icc_profile = turbo_jpeg.tj3GetICCProfile + self.__get_icc_profile.argtypes = [c_void_p, POINTER(c_void_p), POINTER(c_size_t)] + self.__get_icc_profile.restype = c_int + except AttributeError: + self.__get_icc_profile = None - # tj3SetICCProfile - attach ICC profile to compressor before compression - self.__set_icc_profile = turbo_jpeg.tj3SetICCProfile - self.__set_icc_profile.argtypes = [c_void_p, c_void_p, c_size_t] - self.__set_icc_profile.restype = c_int + # tj3SetICCProfile - attach ICC profile to compressor before compression (TurboJPEG 3.1+) + try: + self.__set_icc_profile = turbo_jpeg.tj3SetICCProfile + self.__set_icc_profile.argtypes = [c_void_p, c_void_p, c_size_t] + self.__set_icc_profile.restype = c_int + except AttributeError: + self.__set_icc_profile = None # tjGetScalingFactors - still the current API in 3.1.x get_scaling_factors = turbo_jpeg.tjGetScalingFactors @@ -606,7 +612,7 @@ def decode_header(self, jpeg_buf, return_precision=False): def get_icc_profile(self, jpeg_buf): """Extracts the embedded ICC color profile from a JPEG image. - Requires TurboJPEG 3.0 or later with tj3GetICCProfile support. + Requires TurboJPEG 3.1 or later with tj3GetICCProfile support. Parameters ---------- @@ -623,6 +629,8 @@ def get_icc_profile(self, jpeg_buf): ------ OSError If the JPEG header cannot be parsed or a fatal error occurs. + NotImplementedError + If the loaded libturbojpeg does not export tj3GetICCProfile. Examples -------- @@ -633,6 +641,10 @@ def get_icc_profile(self, jpeg_buf): >>> if icc: ... print(f'ICC profile size: {len(icc)} bytes') """ + if self.__get_icc_profile is None: + raise NotImplementedError( + 'tj3GetICCProfile is not available in the loaded libturbojpeg. ' + 'Please upgrade to libjpeg-turbo 3.1 or later.') handle = self.__init(TJINIT_DECOMPRESS) try: # Set TJPARAM_SAVEMARKERS to 2 (APP2) so the decompressor @@ -679,7 +691,13 @@ def set_icc_profile(self, handle, icc_buf): ------ OSError If tj3SetICCProfile returns a non-zero status. + NotImplementedError + If the loaded libturbojpeg does not export tj3SetICCProfile. """ + if self.__set_icc_profile is None: + raise NotImplementedError( + 'tj3SetICCProfile is not available in the loaded libturbojpeg. ' + 'Please upgrade to libjpeg-turbo 3.1 or later.') icc_array = np.frombuffer(icc_buf, dtype=np.uint8) icc_addr = self.__getaddr(icc_array) status = self.__set_icc_profile(handle, icc_addr, len(icc_buf)) From 0183ebef72301c83a695dd754429bd4dd3bda5e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:58:18 +0000 Subject: [PATCH 6/6] Skip ICC tests gracefully when TurboJPEG 3.1+ is not available (NotImplementedError) Co-authored-by: lilohuang <8341304+lilohuang@users.noreply.github.com> --- tests/test_turbojpeg.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_turbojpeg.py b/tests/test_turbojpeg.py index a5aae0e..76b565f 100644 --- a/tests/test_turbojpeg.py +++ b/tests/test_turbojpeg.py @@ -1820,7 +1820,17 @@ def _make_minimal_srgb_icc(): class TestICCProfile: - """Test ICC profile embed/extract functionality.""" + """Test ICC profile embed/extract functionality (requires TurboJPEG 3.1+).""" + + @pytest.fixture(autouse=True) + def require_icc_support(self, jpeg_instance): + """Skip the test if the loaded libturbojpeg does not support ICC profiles.""" + try: + jpeg_instance.get_icc_profile(b'\xff\xd8\xff\xd9') + except NotImplementedError as e: + pytest.skip(str(e)) + except (OSError, Exception): + pass # Header parse error is fine — the function exists def test_get_icc_profile_with_embedded_profile(self, jpeg_instance, sample_bgr_image): """Test that get_icc_profile returns non-empty bytes for a JPEG with ICC profile."""