diff --git a/.travis.yml b/.travis.yml index d43cc9a69..db36eb376 100644 --- a/.travis.yml +++ b/.travis.yml @@ -225,6 +225,7 @@ install: - popd - girder-install plugin --symlink $large_image_path # Install all extras (since "girder-install plugin" does not provide a mechanism to specify them + - pip install glymur --find-links https://manthey.github.io/large_image_wheels # Trusty supports gdal 1.10.0; don't test mapnik on Python 3 (for now) - if [ -n "${PY3}" ]; then pip install -e $large_image_path[memcached,openslide] ; diff --git a/server/tilesource/__init__.py b/server/tilesource/__init__.py index 4816da530..007635f81 100644 --- a/server/tilesource/__init__.py +++ b/server/tilesource/__init__.py @@ -56,6 +56,9 @@ 'girder': True}, {'moduleName': '.mapniksource', 'className': 'MapnikTileSource'}, {'moduleName': '.mapniksource', 'className': 'MapnikGirderTileSource', 'girder': True}, + {'moduleName': '.openjpeg', 'className': 'OpenjpegFileTileSource'}, + {'moduleName': '.openjpeg', 'className': 'OpenjpegGirderTileSource', + 'girder': True}, {'moduleName': '.pil', 'className': 'PILFileTileSource'}, {'moduleName': '.pil', 'className': 'PILGirderTileSource', 'girder': True}, {'moduleName': '.test', 'className': 'TestTileSource'}, diff --git a/server/tilesource/base.py b/server/tilesource/base.py index 5a4f6aa1b..412472e87 100644 --- a/server/tilesource/base.py +++ b/server/tilesource/base.py @@ -18,7 +18,14 @@ ############################################################################# import math +import numpy import os +import PIL +import PIL.Image +import PIL.ImageColor +import PIL.ImageDraw +import six +from collections import defaultdict from six import BytesIO from ..cache_util import getTileCache, strhash, methodcache @@ -55,11 +62,6 @@ class TileGeneralException(Exception): except ImportError: logger.warning('Error: Could not import PIL') PIL = None -try: - import numpy -except ImportError: - logger.warning('Error: Could not import numpy') - numpy = None TILE_FORMAT_IMAGE = 'image' @@ -179,6 +181,37 @@ def _letterboxImage(image, width, height, fill): return result +def etreeToDict(t): + """ + Convert an xml etree to a nested dictionary without schema names in the + keys. + + @param t: an etree. + @returns: a python dictionary with the results. + """ + # Remove schema + tag = t.tag.split('}', 1)[1] if t.tag.startswith('{') else t.tag + d = {tag: {}} + children = list(t) + if children: + entries = defaultdict(list) + for entry in map(etreeToDict, children): + for k, v in six.iteritems(entry): + entries[k].append(v) + d = {tag: {k: v[0] if len(v) == 1 else v + for k, v in six.iteritems(entries)}} + + if t.attrib: + d[tag].update({(k.split('}', 1)[1] if k.startswith('{') else k): v + for k, v in six.iteritems(t.attrib)}) + text = (t.text or '').strip() + if text and len(d[tag]): + d[tag]['text'] = text + elif text: + d[tag] = text + return d + + def nearPowerOfTwo(val1, val2, tolerance=0.02): """ Check if two values are different by nearly a power of two. @@ -1056,8 +1089,7 @@ def _pilFormatMatches(self, image, match=True, **kwargs): # compatibility could be an issue. return False - def _outputTile(self, tile, tileEncoding, x, y, z, pilImageAllowed=False, - **kwargs): + def _outputTile(self, tile, tileEncoding, x, y, z, pilImageAllowed=False, **kwargs): """ Convert a tile from a PIL image or image in memory to the desired encoding. diff --git a/server/tilesource/openjpeg.py b/server/tilesource/openjpeg.py new file mode 100644 index 000000000..6ed6e6eb2 --- /dev/null +++ b/server/tilesource/openjpeg.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- + +############################################################################## +# Copyright Kitware Inc. +# +# Licensed under the Apache License, Version 2.0 ( the "License" ); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +############################################################################## + +import glymur +import math +import PIL.Image +import six +import threading +import warnings + +from six import BytesIO +from xml.etree import cElementTree + +from ..cache_util import LruCacheMetaclass, methodcache +from ..constants import SourcePriority +from .base import TileSourceException, FileTileSource, etreeToDict, TILE_FORMAT_PIL + + +try: + import girder + from girder import logger + from .base import GirderTileSource +except ImportError: + girder = None + import logging as logger + logger.getLogger().setLevel(logger.INFO) + + +warnings.filterwarnings('ignore', category=UserWarning, module='glymur') + + +@six.add_metaclass(LruCacheMetaclass) +class OpenjpegFileTileSource(FileTileSource): + """ + Provides tile access to SVS files and other files the openjpeg library can + read. + """ + + cacheName = 'tilesource' + name = 'openjpegfile' + extensions = { + None: SourcePriority.MEDIUM, + 'jp2': SourcePriority.PREFERRED, + 'jpf': SourcePriority.PREFERRED, + 'j2k': SourcePriority.PREFERRED, + 'jpx': SourcePriority.PREFERRED, + } + mimeTypes = { + None: SourcePriority.FALLBACK, + 'image/jp2': SourcePriority.PREFERRED, + 'image/jpx': SourcePriority.PREFERRED, + } + + _boxToTag = { + # In the few samples I've seen, both of these appear to be macro images + b'mig ': 'macro', + b'mag ': 'label', + # This contains a largish image + # b'psi ': 'other', + } + _xmlTag = b'mxl ' + + def __init__(self, path, **kwargs): + """ + Initialize the tile class. See the base class for other available + parameters. + + :param path: a filesystem path for the tile source. + """ + super(OpenjpegFileTileSource, self).__init__(path, **kwargs) + + largeImagePath = self._getLargeImagePath() + + self._largeImagePath = largeImagePath + self._pixelInfo = {} + self._openjpegLock = threading.RLock() + try: + self._openjpeg = glymur.Jp2k(largeImagePath) + except glymur.jp2box.InvalidJp2kError: + raise TileSourceException('File cannot be opened via Glymur and OpenJPEG.') + try: + self.sizeY, self.sizeX = self._openjpeg.shape[:2] + except IndexError: + raise TileSourceException('File cannot be opened via Glymur and OpenJPEG.') + self.levels = self._openjpeg.codestream.segment[2].num_res + 1 + self.tileWidth = self.tileHeight = 2 ** int(math.ceil(max( + math.log(float(self.sizeX)) / math.log(2) - self.levels + 1, + math.log(float(self.sizeY)) / math.log(2) - self.levels + 1))) + # read associated images and metadata from boxes + self._associatedImages = {} + for box in self._openjpeg.box: + if box.box_id == self._xmlTag or box.box_id in self._boxToTag: + data = self._readbox(box) + if data is None: + continue + if box.box_id == self._xmlTag: + self._parseMetadataXml(data) + continue + try: + self._associatedImages[self._boxToTag[box.box_id]] = PIL.Image.open( + BytesIO(data)) + except Exception: + pass + if box.box_id == 'jp2c': + for segment in box.codestream.segment: + if segment.marker_id == 'CME' and hasattr(segment, 'ccme'): + self._parseMetadataXml(segment.ccme) + + def getNativeMagnification(self): + """ + Get the magnification at a particular level. + + :return: magnification, width of a pixel in mm, height of a pixel in mm. + """ + mm_x = self._pixelInfo.get('mm_x') + mm_y = self._pixelInfo.get('mm_y') + # Estimate the magnification if we don't have a direct value + mag = self._pixelInfo.get('magnification') or 0.01 / mm_x if mm_x else None + return { + 'magnification': mag, + 'mm_x': mm_x, + 'mm_y': mm_y, + } + + def _parseMetadataXml(self, meta): + if not isinstance(meta, six.string_types): + meta = meta.decode('utf8', 'ignore') + try: + xml = cElementTree.fromstring(meta) + except Exception: + return + self._description_xml = etreeToDict(xml) + xml = self._description_xml + try: + # Optrascan metadata + scanDetails = xml.get('ScanInfo', xml.get('EncodeInfo'))['ScanDetails'] + mag = float(scanDetails['Magnification']) + # In microns; convert to mm + scale = float(scanDetails['PixelResolution']) * 1e-3 + self._pixelInfo = { + 'magnification': mag, + 'mm_x': scale, + 'mm_y': scale, + } + except Exception: + pass + + def _getAssociatedImage(self, imageKey): + """ + Get an associated image in PIL format. + + :param imageKey: the key of the associated image. + :return: the image in PIL format or None. + """ + return self._associatedImages.get(imageKey) + + def getAssociatedImagesList(self): + """ + Return a list of associated images. + + :return: the list of image keys. + """ + return list(self._associatedImages.keys()) + + def _readbox(self, box): + if box.length > 16 * 1024 * 1024: + return + try: + fp = open(self._largeImagePath, 'rb') + headerLength = 16 + fp.seek(box.offset + headerLength) + data = fp.read(box.length - headerLength) + return data + except Exception: + pass + + @methodcache() + def getTile(self, x, y, z, pilImageAllowed=False, **kwargs): + if z < 0 or z >= self.levels: + raise TileSourceException('z layer does not exist') + step = 2 ** (self.levels - 1 - z) + x0 = x * step * self.tileWidth + x1 = min((x + 1) * step * self.tileWidth, self.sizeX) + y0 = y * step * self.tileHeight + y1 = min((y + 1) * step * self.tileHeight, self.sizeY) + if x < 0 or x0 >= self.sizeX: + raise TileSourceException('x is outside layer') + if y < 0 or y0 >= self.sizeY: + raise TileSourceException('y is outside layer') + with self._openjpegLock: + tile = self._openjpeg[y0:y1:step, x0:x1:step] + mode = 'L' + if len(tile.shape) == 3: + mode = ['L', 'LA', 'RGB', 'RGBA'][tile.shape[2] - 1] + tile = PIL.Image.frombytes(mode, (tile.shape[1], tile.shape[0]), tile) + if tile.size != (self.tileWidth, self.tileHeight): + wrap = PIL.Image.new(mode, (self.tileWidth, self.tileHeight)) + wrap.paste(tile, (0, 0)) + tile = wrap + return self._outputTile(tile, TILE_FORMAT_PIL, x, y, z, pilImageAllowed, **kwargs) + + +if girder: + class OpenjpegGirderTileSource(OpenjpegFileTileSource, GirderTileSource): + """ + Provides tile access to Girder items with an SVS file or other files that + the openslide library can read. + """ + + cacheName = 'tilesource' + name = 'openjpeg' diff --git a/server/tilesource/tiff_reader.py b/server/tilesource/tiff_reader.py index 314ad891c..06bfdd63f 100644 --- a/server/tilesource/tiff_reader.py +++ b/server/tilesource/tiff_reader.py @@ -21,11 +21,11 @@ import os import six -from collections import defaultdict from functools import partial from xml.etree import cElementTree from ..cache_util import LRUCache, strhash, methodcache +from .base import etreeToDict try: from girder import logger @@ -53,37 +53,6 @@ libtiff_ctypes.suppress_warnings() -def etreeToDict(t): - """ - Convert an xml etree to a nested dictionary without schema names in the - keys. - - @param t: an etree. - @returns: a python dictionary with the results. - """ - # Remove schema - tag = t.tag.split('}', 1)[1] if t.tag.startswith('{') else t.tag - d = {tag: {}} - children = list(t) - if children: - entries = defaultdict(list) - for entry in map(etreeToDict, children): - for k, v in six.iteritems(entry): - entries[k].append(v) - d = {tag: {k: v[0] if len(v) == 1 else v - for k, v in six.iteritems(entries)}} - - if t.attrib: - d[tag].update({(k.split('}', 1)[1] if k.startswith('{') else k): v - for k, v in six.iteritems(t.attrib)}) - text = (t.text or '').strip() - if text and len(d[tag]): - d[tag]['text'] = text - elif text: - d[tag] = text - return d - - def patchLibtiff(): libtiff_ctypes.libtiff.TIFFFieldWithTag.restype = \ ctypes.POINTER(libtiff_ctypes.TIFFFieldInfo) diff --git a/setup.py b/setup.py index a6562340f..6f0b4f8db 100644 --- a/setup.py +++ b/setup.py @@ -74,6 +74,9 @@ 'openslide': [ 'openslide-python>=1.1.0' ], + 'openjpeg': [ + 'glymur>=0.8.18' + ], 'mapnik': [ 'mapnik', 'pyproj',