From 872388753118b8b8808ead1894e5907af081a582 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Thu, 26 Jan 2023 09:40:07 -0500 Subject: [PATCH 1/2] Apply ICC profiles. This is done on tiff and openslide tiff-based sources. --- CHANGELOG.md | 5 +- docs/tilesource_options.rst | 2 + .../girder_large_image/models/image_item.py | 5 + large_image/tilesource/base.py | 97 +++++++++++++++++-- .../large_image_source_openslide/__init__.py | 3 + .../tiff/large_image_source_tiff/__init__.py | 7 ++ test/test_converter.py | 4 +- test/test_source_openslide.py | 2 +- 8 files changed, 113 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8be25f41..d7a3809ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Change Log -## 1.19.4 +## 1.20.0 + +### Features +- ICC color profile support ([#1037](../../pull/1037)) ### Improvements - Speed up generating tiles for some multi source files ([#1035](../../pull/1035)) diff --git a/docs/tilesource_options.rst b/docs/tilesource_options.rst index 033fce9ac..ecbce0b3c 100644 --- a/docs/tilesource_options.rst +++ b/docs/tilesource_options.rst @@ -64,6 +64,8 @@ A band definition is an object which can contain the following keys: - ``axis``: if specified, keep on the specified axis (channel) of the intermediate numpy array. This is typically between 0 and 3 for the red, green, blue, and alpha channels. Only the first such value is used, and this can be specified as a base key if ``bands`` is specified. +- ``icc``: by default, sources that expose ICC color profiles will apply those profiles to the image data, converting the results to the sRGB profile. To use the raw image data without ICC profile adjustments, specify an ``icc`` value of ``false``. If the entire style is ``{"icc": false}``, the results will be the same as the default bands with only the adjustment being skipped. Note that not all tile sources expose ICC color profile information, even if the base file format contains it. + - ``function``: if specified, call a function to modify the resulting image. This can be specified as a base key and as a band key. Style functions can be called at multiple stages in the styling pipeline: - ``pre`` stage: this passes the original tile image to the function before any band data is applied. diff --git a/girder/girder_large_image/models/image_item.py b/girder/girder_large_image/models/image_item.py index 9b0226e53..b97405899 100644 --- a/girder/girder_large_image/models/image_item.py +++ b/girder/girder_large_image/models/image_item.py @@ -18,6 +18,7 @@ import json import pickle +import PIL.ImageCms import pymongo from girder_jobs.constants import JobStatus from girder_jobs.models.job import Job @@ -246,6 +247,10 @@ def getMetadata(self, item, **kwargs): def getInternalMetadata(self, item, **kwargs): tileSource = self._loadTileSource(item, **kwargs) result = tileSource.getInternalMetadata() or {} + if tileSource.getICCProfiles(): + result['iccprofiles'] = [ + PIL.ImageCms.getProfileInfo(prof).strip() or 'present' if prof else None + for prof in tileSource.getICCProfiles()] result['tilesource'] = tileSource.name return result diff --git a/large_image/tilesource/base.py b/large_image/tilesource/base.py index cad7837c0..f05537be8 100644 --- a/large_image/tilesource/base.py +++ b/large_image/tilesource/base.py @@ -1,3 +1,4 @@ +import io import json import math import os @@ -11,6 +12,7 @@ import numpy import PIL import PIL.Image +import PIL.ImageCms import PIL.ImageColor import PIL.ImageDraw @@ -1191,6 +1193,73 @@ def _applyStyleFunction(self, image, sc, stage, function=None): self.logger.exception('Failed to execute style function %s' % function['name']) return image + def getICCProfiles(self, idx=None): + """ + Get a list of all ICC profiles that are available for the source, or + get a specific profile. + + :param idx: a 0-based index into the profiles to get one profile, or + None to get a list of all profiles. + :returns: either one or a list of PIL.ImageCms.CmsProfile objects, or + None if no profiles are available. If a list, entries in the list + may be None. + """ + if not hasattr(self, '_iccprofiles'): + return None + results = [] + for pidx, prof in enumerate(self._iccprofiles): + if idx is not None and pidx != idx: + continue + if hasattr(self, '_iccprofilesObjects') and self._iccprofilesObjects[pidx] is not None: + prof = self._iccprofilesObjects[pidx]['profile'] + elif not isinstance(prof, PIL.ImageCms.ImageCmsProfile): + prof = PIL.ImageCms.getOpenProfile(io.BytesIO(prof)) + if idx == pidx: + return prof + results.append(prof) + return results + + def _applyICCProfile(self, sc, frame): + """ + Apply an ICC profile to an image. + + :param sc: the style context. + :param frame: the frame to use for auto ranging. + :returns: an image with the icc profile, if any, applied. + """ + profileIdx = frame if frame and len(self._iccprofiles) >= frame + 1 else 0 + sc.iccimage = sc.image + sc.iccapplied = False + if not self._iccprofiles[profileIdx]: + return sc.image + if not hasattr(self, '_iccprofilesObjects'): + self._iccprofilesObjects = [None] * len(self._iccprofiles) + image = _imageToPIL(sc.image) + mode = image.mode + if not hasattr(self, '_iccsrgbprofile'): + self._iccsrgbprofile = PIL.ImageCms.createProfile('sRGB') + try: + if self._iccprofilesObjects[profileIdx] is None: + self._iccprofilesObjects[profileIdx] = { + 'profile': self.getICCProfiles(profileIdx) + } + if mode not in self._iccprofilesObjects[profileIdx]: + self._iccprofilesObjects[profileIdx][mode] = \ + PIL.ImageCms.buildTransformFromOpenProfiles( + self._iccprofilesObjects[profileIdx]['profile'], + self._iccsrgbprofile, mode, mode) + transform = self._iccprofilesObjects[profileIdx][mode] + + PIL.ImageCms.applyTransform(image, transform, inPlace=True) + sc.iccimage = _imageToNumpy(image)[0] + sc.iccapplied = True + except Exception as exc: + raise + if not hasattr(self, '_iccerror'): + self._iccerror = exc + self.logger.exception('Failed to apply ICC profile') + return sc.iccimage + def _applyStyle(self, image, style, x, y, z, frame=None): # noqa """ Apply a style to a numpy image. @@ -1205,11 +1274,19 @@ def _applyStyle(self, image, style, x, y, z, frame=None): # noqa """ sc = types.SimpleNamespace( image=image, originalStyle=style, x=x, y=y, z=z, frame=frame, - mainImage=image, mainFrame=frame) - sc.dtype = style.get('dtype') - sc.axis = style.get('axis') - sc.style = style if 'bands' in style else {'bands': [style]} - sc.output = numpy.zeros((image.shape[0], image.shape[1], 4), float) + mainImage=image, mainFrame=frame, dtype=None, axis=None) + if style is None: + sc.style = {'bands': []} + else: + sc.style = style if 'bands' in style else {'bands': [style]} + sc.dtype = style.get('dtype') + sc.axis = style.get('axis') + if hasattr(self, '_iccprofiles') and sc.style.get('icc', True): + image = self._applyICCProfile(sc, frame) + if style is None or (len(style) == 1 and 'icc' in style): + sc.output = image + else: + sc.output = numpy.zeros((image.shape[0], image.shape[1], 4), float) image = self._applyStyleFunction(image, sc, 'pre') for eidx, entry in enumerate(sc.style['bands']): sc.styleIndex = eidx @@ -1343,10 +1420,10 @@ def _outputTileNumpyStyle(self, tile, applyStyle, x, y, z, frame=None): :returns: a numpy array and a target PIL image mode. """ tile, mode = _imageToNumpy(tile) - if applyStyle and getattr(self, 'style', None): + if applyStyle and (getattr(self, 'style', None) or hasattr(self, '_iccprofiles')): with self._styleLock: if not getattr(self, '_skipStyle', False): - tile = self._applyStyle(tile, self.style, x, y, z, frame) + tile = self._applyStyle(tile, getattr(self, 'style', None), x, y, z, frame) if tile.shape[0] != self.tileHeight or tile.shape[1] != self.tileWidth: extend = numpy.zeros( (self.tileHeight, self.tileWidth, tile.shape[2]), @@ -1386,8 +1463,10 @@ def _outputTile(self, tile, tileEncoding, x, y, z, pilImageAllowed=False, not isEdge and (not applyStyle or not getattr(self, 'style', None))): return tile mode = None - if (numpyAllowed == 'always' or tileEncoding == TILE_FORMAT_NUMPY or - (applyStyle and getattr(self, 'style', None)) or isEdge): + if (numpyAllowed == 'always' or tileEncoding == TILE_FORMAT_NUMPY or (applyStyle and ( + (getattr(self, 'style', None) or hasattr(self, '_iccprofiles')) and + getattr(self, 'style', None) != {'icc': False})) or + isEdge): tile, mode = self._outputTileNumpyStyle( tile, applyStyle, x, y, z, self._getFrame(**kwargs)) if isEdge: diff --git a/sources/openslide/large_image_source_openslide/__init__.py b/sources/openslide/large_image_source_openslide/__init__.py index d89ceb40f..385494837 100644 --- a/sources/openslide/large_image_source_openslide/__init__.py +++ b/sources/openslide/large_image_source_openslide/__init__.py @@ -95,6 +95,9 @@ def __init__(self, path, **kwargs): # noqa if libtiff_ctypes: try: self._tiffinfo = tifftools.read_tiff(self._largeImagePath) + if tifftools.Tag.ICCProfile.value in self._tiffinfo['ifds'][0]['tags']: + self._iccprofiles = [self._tiffinfo['ifds'][0]['tags'][ + tifftools.Tag.ICCProfile.value]['data']] except Exception: pass diff --git a/sources/tiff/large_image_source_tiff/__init__.py b/sources/tiff/large_image_source_tiff/__init__.py index 7075be5d2..c9c7de9b5 100644 --- a/sources/tiff/large_image_source_tiff/__init__.py +++ b/sources/tiff/large_image_source_tiff/__init__.py @@ -308,6 +308,13 @@ def _initWithTiffTools(self): # noqa frames[-1][key] = frameMetadata[key] except Exception: pass + if tifftools.Tag.ICCProfile.value in ifd['tags']: + if not hasattr(self, '_iccprofiles'): + self._iccprofiles = [] + while len(self._iccprofiles) < len(frames) - 1: + self._iccprofiles.append(None) + self._iccprofiles.append(ifd['tags'][ + tifftools.Tag.ICCProfile.value]['data']) # otherwise, add to the first frame missing that level elif level < self.levels - 1 and any( frame for frame in frames if frame['dirs'][level] is None): diff --git a/test/test_converter.py b/test/test_converter.py index fe84c60fd..91cb094fe 100644 --- a/test/test_converter.py +++ b/test/test_converter.py @@ -146,7 +146,9 @@ def testConvertJp2kCompression(tmpdir): source = large_image_source_tiff.open(outputPath) image, _ = source.getRegion( output={'maxWidth': 200, 'maxHeight': 200}, format=constants.TILE_FORMAT_NUMPY) - assert (image[12][167] == [215, 135, 172]).all() + # Without or with icc adjustment + assert ((image[12][167] == [215, 135, 172]).all() or + (image[12][167] == [216, 134, 172]).all()) outputPath2 = os.path.join(tmpdir, 'out2.tiff') large_image_converter.convert(imagePath, outputPath2, compression='jp2k', psnr=50) diff --git a/test/test_source_openslide.py b/test/test_source_openslide.py index c3ab6c2ac..ae51cfe52 100644 --- a/test/test_source_openslide.py +++ b/test/test_source_openslide.py @@ -318,7 +318,7 @@ def testGetPixel(): imagePath = datastore.fetch( 'sample_jp2k_33003_TCGA-CV-7242-11A-01-TS1.1838afb1-9eee-' '4a70-9ae3-50e3ab45e242.svs') - source = large_image_source_openslide.open(imagePath) + source = large_image_source_openslide.open(imagePath, style={'icc': False}) pixel = source.getPixel(region={'left': 12125, 'top': 10640}) assert pixel == {'r': 156, 'g': 98, 'b': 138, 'a': 255} From e44f91082b37b7ecd7562e7eeb7e4b3b8b751912 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Thu, 26 Jan 2023 13:41:19 -0500 Subject: [PATCH 2/2] Add ICC profile support for openjpeg, pil, and tifffile sources. --- sources/bioformats/large_image_source_bioformats/__init__.py | 2 +- sources/openjpeg/large_image_source_openjpeg/__init__.py | 4 ++++ sources/pil/large_image_source_pil/__init__.py | 2 ++ sources/tifffile/large_image_source_tifffile/__init__.py | 2 ++ test/test_source_base.py | 5 +++-- 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/sources/bioformats/large_image_source_bioformats/__init__.py b/sources/bioformats/large_image_source_bioformats/__init__.py index 20360b533..30db56b19 100644 --- a/sources/bioformats/large_image_source_bioformats/__init__.py +++ b/sources/bioformats/large_image_source_bioformats/__init__.py @@ -61,7 +61,7 @@ # Default to ignoring files with no extension and some specific extensions. config.ConfigValues['source_bioformats_ignored_names'] = \ - r'(^[^.]*|\.(jpg|jpeg|jpe|png|tif|tiff|ndpi|nd2|ome|nc|json))$' + r'(^[^.]*|\.(jpg|jpeg|jpe|png|tif|tiff|ndpi|nd2|ome|nc|json|isyntax))$' def _monitor_thread(): diff --git a/sources/openjpeg/large_image_source_openjpeg/__init__.py b/sources/openjpeg/large_image_source_openjpeg/__init__.py index dbfac9573..06ae15180 100644 --- a/sources/openjpeg/large_image_source_openjpeg/__init__.py +++ b/sources/openjpeg/large_image_source_openjpeg/__init__.py @@ -154,6 +154,10 @@ def _getAssociatedImages(self): for segment in box.codestream.segment: if segment.marker_id == 'CME' and hasattr(segment, 'ccme'): self._parseMetadataXml(segment.ccme) + if hasattr(box, 'box'): + for subbox in box.box: + if getattr(subbox, 'icc_profile', None): + self._iccprofiles = [subbox.icc_profile] def getNativeMagnification(self): """ diff --git a/sources/pil/large_image_source_pil/__init__.py b/sources/pil/large_image_source_pil/__init__.py index 8908121b5..bb8048861 100644 --- a/sources/pil/large_image_source_pil/__init__.py +++ b/sources/pil/large_image_source_pil/__init__.py @@ -125,6 +125,8 @@ def __init__(self, path, maxSize=None, **kwargs): maxWidth, maxHeight = getMaxSize(maxSize, self.defaultMaxSize()) if maxwh > max(maxWidth, maxHeight): raise TileSourceError('PIL tile size is too large.') + if self._pilImage.info.get('icc_profile', None): + self._iccprofiles = [self._pilImage.info.get('icc_profile')] # If the rotation flag exists, loading the image may change the width # and height if getattr(self._pilImage, '_tile_orientation', None) not in {None, 1}: diff --git a/sources/tifffile/large_image_source_tifffile/__init__.py b/sources/tifffile/large_image_source_tifffile/__init__.py index ae918f1f5..f97f30048 100644 --- a/sources/tifffile/large_image_source_tifffile/__init__.py +++ b/sources/tifffile/large_image_source_tifffile/__init__.py @@ -109,6 +109,8 @@ def __init__(self, path, **kwargs): if ('TileLength' in page.tags and self._minTileSize <= page.tags['TileLength'].value <= self._maxTileSize): self.tileHeight = page.tags['TileLength'].value + if 'InterColorProfile' in page.tags: + self._iccprofiles = [page.tags['InterColorProfile'].value] self.sizeX = s.shape[s.axes.index('X')] self.sizeY = s.shape[s.axes.index('Y')] try: diff --git a/test/test_source_base.py b/test/test_source_base.py index 35d7be859..50a28d60b 100644 --- a/test/test_source_base.py +++ b/test/test_source_base.py @@ -23,8 +23,9 @@ # a download order. SourceAndFiles = { 'bioformats': { - 'read': r'\.(czi|jp2|svs|scn)$', - 'noread': r'(JK-kidney_B|TCGA-AA-A02O|\.scn$)', + 'read': r'\.(czi|jp2|svs|scn|dcm)$', + 'noread': r'JK-kidney_B', + 'skip': r'TCGA-AA-A02O.*\.svs', # We need to modify the bioformats reader similar to tiff's # getTileFromEmptyDirectory 'skipTiles': r'(TCGA-DU-6399|sample_jp2k_33003)',