diff --git a/CHANGELOG.md b/CHANGELOG.md index c526425cf..3e5312822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,14 @@ ## Unreleased +### Features +- Added a `canRead` method to the core module (#512) + ### Improvements - Better release bioformats resources (#502) - Better handling of tiff files with JPEG compression and RGB colorspace (#503) - The openjpeg tile source can decode with parallelism (#511) +- Geospatial tile sources are preferred for geospatial files (#512) ### Bug Fixes - Harden updates of the item view after making a large image (#508) diff --git a/large_image/__init__.py b/large_image/__init__.py index 9ee572ad6..fc548e9b5 100644 --- a/large_image/__init__.py +++ b/large_image/__init__.py @@ -19,7 +19,7 @@ from pkg_resources import DistributionNotFound, get_distribution from . import tilesource # noqa -from .tilesource import getTileSource # noqa +from .tilesource import canRead, getTileSource # noqa try: diff --git a/large_image/tilesource/__init__.py b/large_image/tilesource/__init__.py index 1a78acbf3..7b140f9ac 100644 --- a/large_image/tilesource/__init__.py +++ b/large_image/tilesource/__init__.py @@ -14,6 +14,30 @@ AvailableTileSources = {} +def isGeospatial(path): + """ + Check if a path is likely to be a geospatial file. + + :params path: The path to the file + :returns: True if geospatial. + """ + try: + from osgeo import gdal + from osgeo import gdalconst + except ImportError: + # TODO: log a warning + return False + try: + ds = gdal.Open(path, gdalconst.GA_ReadOnly) + except Exception: + return False + if ds.GetProjection(): + return True + if ds.GetDriver().ShortName in {'NITF', 'netCDF'}: + return True + return False + + def loadTileSources(entryPointName='large_image.source', sourceDict=AvailableTileSources): """ Load all tilesources from entrypoints and add them to the @@ -34,7 +58,7 @@ def loadTileSources(entryPointName='large_image.source', sourceDict=AvailableTil pass -def getTileSourceFromDict(availableSources, pathOrUri, *args, **kwargs): +def getSourceNameFromDict(availableSources, pathOrUri, *args, **kwargs): """ Get a tile source based on a ordered dictionary of known sources and a path name or URI. Additional parameters are passed to the tile source and can @@ -43,12 +67,15 @@ def getTileSourceFromDict(availableSources, pathOrUri, *args, **kwargs): :param availableSources: an ordered dictionary of sources to try. :param pathOrUri: either a file path or a fixed source via large_image://. - :returns: a tile source instance or and error. + :returns: the name of a tile source that can read the input, or None if + there is no such source. """ - sourceObj = pathOrUri uriWithoutProtocol = pathOrUri.split('://', 1)[-1] isLargeImageUri = pathOrUri.startswith('large_image://') extensions = [ext.lower() for ext in os.path.basename(uriWithoutProtocol).split('.')[1:]] + properties = { + 'geospatial': isGeospatial(pathOrUri), + } sourceList = [] for sourceName in availableSources: sourceExtensions = availableSources[sourceName].extensions @@ -60,10 +87,29 @@ def getTileSourceFromDict(availableSources, pathOrUri, *args, **kwargs): priority = SourcePriority.NAMED if priority >= SourcePriority.MANUAL: continue - sourceList.append((priority, sourceName)) - for _priority, sourceName in sorted(sourceList): - if availableSources[sourceName].canRead(sourceObj, *args, **kwargs): - return availableSources[sourceName](sourceObj, *args, **kwargs) + propertiesClash = any( + getattr(availableSources[sourceName], k, False) != v + for k, v in properties.items()) + sourceList.append((propertiesClash, priority, sourceName)) + for _clash, _priority, sourceName in sorted(sourceList): + if availableSources[sourceName].canRead(pathOrUri, *args, **kwargs): + return sourceName + + +def getTileSourceFromDict(availableSources, pathOrUri, *args, **kwargs): + """ + Get a tile source based on a ordered dictionary of known sources and a path + name or URI. Additional parameters are passed to the tile source and can + be used for properties such as encoding. + + :param availableSources: an ordered dictionary of sources to try. + :param pathOrUri: either a file path or a fixed source via + large_image://. + :returns: a tile source instance or and error. + """ + sourceName = getSourceNameFromDict(availableSources, pathOrUri, *args, **kwargs) + if sourceName: + return availableSources[sourceName](pathOrUri, *args, **kwargs) raise TileSourceException('No available tilesource for %s' % pathOrUri) @@ -79,10 +125,24 @@ def getTileSource(*args, **kwargs): return getTileSourceFromDict(AvailableTileSources, *args, **kwargs) +def canRead(*args, **kwargs): + """ + Check if large_image can read a path or uri. + + :returns: True if any appropriate source reports it can read the path or + uri. + """ + if not len(AvailableTileSources): + loadTileSources() + if getSourceNameFromDict(AvailableTileSources, *args, **kwargs): + return True + return False + + __all__ = [ 'TileSource', 'FileTileSource', 'exceptions', 'TileGeneralException', 'TileSourceException', 'TileSourceAssetstoreException', 'TileOutputMimeTypes', 'TILE_FORMAT_IMAGE', 'TILE_FORMAT_PIL', 'TILE_FORMAT_NUMPY', - 'AvailableTileSources', 'getTileSource', 'nearPowerOfTwo', + 'AvailableTileSources', 'getTileSource', 'canRead', 'getSourceNameFromDict', 'nearPowerOfTwo', 'etreeToDict', 'dictToEtree', ] diff --git a/sources/gdal/large_image_source_gdal/__init__.py b/sources/gdal/large_image_source_gdal/__init__.py index 5f1342498..b76074a6a 100644 --- a/sources/gdal/large_image_source_gdal/__init__.py +++ b/sources/gdal/large_image_source_gdal/__init__.py @@ -85,6 +85,7 @@ class GDALFileTileSource(FileTileSource): 'image/tiff': SourcePriority.LOW, 'image/x-tiff': SourcePriority.LOW, } + geospatial = True def __init__(self, path, projection=None, unitsPerPixel=None, **kwargs): """ diff --git a/sources/mapnik/large_image_source_mapnik/__init__.py b/sources/mapnik/large_image_source_mapnik/__init__.py index 45809da84..7c9d6eb48 100644 --- a/sources/mapnik/large_image_source_mapnik/__init__.py +++ b/sources/mapnik/large_image_source_mapnik/__init__.py @@ -65,6 +65,7 @@ class MapnikFileTileSource(GDALFileTileSource): 'image/tiff': SourcePriority.LOWER, 'image/x-tiff': SourcePriority.LOWER, } + geospatial = True def __init__(self, path, projection=None, unitsPerPixel=None, **kwargs): """ diff --git a/test/data/04091217_ruc.nc.sha512 b/test/data/04091217_ruc.nc.sha512 new file mode 100755 index 000000000..69fb111e6 --- /dev/null +++ b/test/data/04091217_ruc.nc.sha512 @@ -0,0 +1 @@ +3380c8de64afc46a5cfe764921e0fd5582380b1065754a6e6c4fa506625ee26edb1637aeda59c1d2a2dc245364b191563d8572488c32dcafe9e706e208fd9939 diff --git a/test/test_source_base.py b/test/test_source_base.py index 03ffd6c18..7e501d9ba 100644 --- a/test/test_source_base.py +++ b/test/test_source_base.py @@ -1,7 +1,11 @@ # -*- coding: utf-8 -*- +import os +import large_image from large_image.tilesource import nearPowerOfTwo +from . import utilities + def testNearPowerOfTwo(): assert nearPowerOfTwo(45808, 11456) @@ -11,3 +15,12 @@ def testNearPowerOfTwo(): assert not nearPowerOfTwo(45808, 11400, 0.005) assert nearPowerOfTwo(45808, 11500) assert not nearPowerOfTwo(45808, 11500, 0.005) + + +def testCanRead(): + testDir = os.path.dirname(os.path.realpath(__file__)) + imagePath = os.path.join(testDir, 'test_files', 'yb10kx5k.png') + assert large_image.canRead(imagePath) is False + + imagePath = utilities.externaldata('data/sample_image.ptif.sha512') + assert large_image.canRead(imagePath) is True diff --git a/test/test_source_mapnik.py b/test/test_source_mapnik.py index 16fd268ef..8f8963fad 100644 --- a/test/test_source_mapnik.py +++ b/test/test_source_mapnik.py @@ -8,6 +8,7 @@ import pytest import six +import large_image from large_image.exceptions import TileSourceException import large_image_source_mapnik @@ -332,3 +333,36 @@ def testGuardAgainstBadLatLong(): assert bounds['xmax'] == 179.99583333 assert bounds['ymin'] == -89.99583333 assert bounds['ymax'] == 90 + + +def testTileFromNetCDF(): + imagePath = utilities.externaldata('data/04091217_ruc.nc.sha512') + source = large_image_source_mapnik.MapnikFileTileSource(imagePath) + tileMetadata = source.getMetadata() + + assert tileMetadata['tileWidth'] == 256 + assert tileMetadata['tileHeight'] == 256 + assert tileMetadata['sizeX'] == 93 + assert tileMetadata['sizeY'] == 65 + assert tileMetadata['levels'] == 1 + assert tileMetadata['bounds']['srs'].strip() == '+init=epsg:4326' + assert tileMetadata['geospatial'] + + # Getting the metadata with a specified projection will be different + source = large_image_source_mapnik.MapnikFileTileSource( + imagePath, projection='EPSG:3857') + tileMetadata = source.getMetadata() + + assert tileMetadata['tileWidth'] == 256 + assert tileMetadata['tileHeight'] == 256 + assert tileMetadata['sizeX'] == 512 + assert tileMetadata['sizeY'] == 512 + assert tileMetadata['levels'] == 2 + assert tileMetadata['bounds']['srs'] == '+init=epsg:3857' + assert tileMetadata['geospatial'] + + +def testTileSourceFromNetCDF(): + imagePath = utilities.externaldata('data/04091217_ruc.nc.sha512') + ts = large_image.getTileSource(imagePath) + assert 'mapnik' in ts.name