Skip to content

Commit

Permalink
Add an openjpeg source using the glymur library.
Browse files Browse the repository at this point in the history
  • Loading branch information
manthey committed Oct 2, 2019
1 parent 593385c commit f44cb78
Show file tree
Hide file tree
Showing 6 changed files with 272 additions and 39 deletions.
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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] ;
Expand Down
3 changes: 3 additions & 0 deletions server/tilesource/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand Down
46 changes: 39 additions & 7 deletions server/tilesource/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
225 changes: 225 additions & 0 deletions server/tilesource/openjpeg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
# -*- 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)
try:
# Optrascan metadata
scanDetails = self._description_xml['ScanInfo']['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'
33 changes: 1 addition & 32 deletions server/tilesource/tiff_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@
'openslide': [
'openslide-python>=1.1.0'
],
'openjpeg': [
'glymur>=0.8.18'
],
'mapnik': [
'mapnik',
'pyproj',
Expand Down

0 comments on commit f44cb78

Please sign in to comment.